/**
* @module runner/recorder
* @description DIF-015 — Interactive browser recorder for test creation.
*
* Opens a Playwright browser pointed at the project's URL, streams a
* live CDP screencast to the frontend via SSE (reusing the existing
* `emitRunEvent` channel), and captures raw user interactions
* (clicks, fills, key-presses, navigations) as Playwright actions.
*
* On stop, the captured action list is transformed into a Playwright
* test body and returned so the caller (routes/tests.js) can persist
* a Draft test and run it through the rest of the generation pipeline
* (assertion enhancement, self-healing transform) just like any other
* AI-generated test.
*
* ### Exports
* - {@link startRecording} — Launch browser + begin capture.
* - {@link stopRecording} — Stop capture; return `{ actions, playwrightCode }`.
* - {@link getRecording} — Inspect an in-flight recording (for abort / status).
*
* ### Design notes
* Capture is done entirely in the **page context** via a single injected
* listener that posts events back to Node through `page.exposeBinding`.
* This is the same approach Playwright's own `codegen` uses for JavaScript
* action recording, minus the DevTools UI — we only need the raw event
* stream.
*/
import { launchBrowser, VIEWPORT_WIDTH, VIEWPORT_HEIGHT, NAVIGATION_TIMEOUT } from "./config.js";
import { startScreencast } from "./screencast.js";
import { formatLogLine } from "../utils/logFormatter.js";
import * as runRepo from "../database/repositories/runRepo.js";
/**
* Tunable timing constants for the recorder. Centralised so reviewers can
* see every magic number in one place and so operators can override the
* server-side ones via environment variables without grepping through the
* file.
*
* The in-page timings (DBLCLICK_DEFER_MS, HOVER_DWELL_MS, FILL_DEBOUNCE_MS,
* DBLCLICK_WINDOW_MS) are inlined into `RECORDER_SCRIPT` because that
* string runs in the browser context where this module's bindings are not
* in scope. Keep this block in sync if you change them inside the script
* — a mismatch is silent and can produce confusing replay behaviour.
*
* @internal
*/
const TIMINGS = {
/**
* Hard cap for how long a single recording session may stay open.
* Defence-in-depth for the case where a client disconnects without
* hitting stop/discard (e.g. browser tab closed, network cut). After
* this timeout the server tears down the Chromium process and deletes
* the session from the map.
* @env MAX_RECORDING_MS (default 1_800_000 = 30 min, floor 60_000)
*/
MAX_RECORDING_MS: Math.max(
60_000,
parseInt(process.env.MAX_RECORDING_MS || "1800000", 10) || 1_800_000,
),
/**
* TTL for the cache of auto-torn-down recordings. A user who clicks
* "Stop & Save" within this window of the safety-net timeout firing
* still recovers their captured actions instead of seeing a 500 error.
* @env RECORDER_COMPLETED_TTL_MS (default 120_000 = 2 min, floor 10_000)
*/
COMPLETED_TTL_MS: Math.max(
10_000,
parseInt(process.env.RECORDER_COMPLETED_TTL_MS || "120000", 10) || 120_000,
),
/**
* Hard window in which a trailing `dblclick` event can cancel the two
* preceding `click` events captured by `__sentriRecord`. CDP/browser
* dispatches click→click→dblclick in that order; without this dedup the
* recorded action list would replay all three handlers in sequence.
* Used in `exposeBinding`'s consumer; see also DBLCLICK_DEFER_MS below,
* which is the in-page equivalent applied to the click event itself.
*/
DBLCLICK_WINDOW_MS: 500,
/**
* In-page deferral window for emitting captured `click` actions. Browser
* dispatch order is click→click→dblclick, so a trailing `dblclick`
* listener inside the page can cancel the queued clicks for the same
* selector before the binding flushes them to Node. Interpolated into
* `RECORDER_SCRIPT` because that body runs in the browser context.
*/
DBLCLICK_DEFER_MS: 250,
/**
* In-page dwell time before a sustained pointer hover is emitted as a
* `hover` action. Filters out drive-by mouseovers while still catching
* deliberate hovers (tooltips, dropdown triggers).
*/
HOVER_DWELL_MS: 600,
/**
* In-page debounce window after the last keystroke in a text field
* before the resulting `fill` action is emitted. Coalesces a multi-
* character entry into a single recorded fill.
*/
FILL_DEBOUNCE_MS: 300,
};
// Backwards-compatible aliases used elsewhere in the module / tests.
const MAX_RECORDING_MS = TIMINGS.MAX_RECORDING_MS;
/**
* @typedef {Object} RecordedAction
* @property {"goto"|"click"|"dblclick"|"rightClick"|"hover"|"fill"|"press"|"select"|"check"|"uncheck"|"upload"|"drag"|"assertVisible"|"assertText"|"assertValue"|"assertUrl"} kind
* @property {string} [selector] - Best-effort role/label/text/css selector.
* @property {string} [label] - Human-readable label for the target
* element (aria-label / inner text /
* placeholder / `name`). Used by the Test
* Detail Steps panel so reviewers see "the
* Search button" instead of `role=button…`.
* The selector is still the source of truth
* for the generated Playwright code.
* @property {string} [value] - For `fill`, the final value typed.
* @property {string} [url] - For `goto`.
* @property {string} [key] - For `press`.
* @property {string} [frameUrl] - URL of iframe containing the action.
* @property {string} [pageAlias] - "page" for main tab, "popupN" for popups.
* @property {string} [target] - For drag/drop target selector.
* @property {number} ts - Epoch ms when the action was captured.
*/
/**
* @typedef {Object} RecordingSession
* @property {string} id
* @property {string} projectId
* @property {string} url - Starting URL.
* @property {"recording"|"stopping"|"stopped"} status
* @property {Array<RecordedAction>} actions
* @property {number} startedAt
* @property {Object} [browser] - Playwright Browser (internal).
* @property {Object} [context] - Playwright BrowserContext (internal).
* @property {Object} [page] - Playwright Page (internal).
* @property {Function} [stopScreencast] - Cleanup fn returned by startScreencast.
* @property {Object} [cdpSession] - CDP session for input forwarding.
*/
/** @type {Map<string, RecordingSession>} */
const sessions = new Map();
/**
* @typedef {Object} CompletedRecording
* @property {string} projectId
* @property {Array<RecordedAction>} actions
* @property {string} playwrightCode
* @property {string} url
* @property {number} completedAt
* @property {"auto_timeout"|"manual"} reason
*/
/**
* Short-lived cache of recordings torn down by the MAX_RECORDING_MS safety-net
* timeout. Entries live for `COMPLETED_TTL_MS` so a user who clicks
* "Stop & Save" moments after the timeout fires can still recover their
* captured actions instead of losing them to a 500 error. Scoped to the in-
* process recorder so no external store is needed.
* @type {Map<string, CompletedRecording>}
*/
const completedSessions = new Map();
const COMPLETED_TTL_MS = TIMINGS.COMPLETED_TTL_MS;
/**
* JS source injected into every page frame. It captures pointer/keyboard
* events and relays them to Node via the `__sentriRecord` binding. We
* de-duplicate by dispatch target + event type so that a single click
* doesn't emit multiple entries when bubbling through the DOM.
*
* `bestSelector` intentionally mirrors Playwright's own heuristics loosely:
* prefer role-based selectors, then data-testid, then aria-label, then
* a short CSS chain.
*
* Built once at module load — the timing constants come from `TIMINGS`
* (Node-side) and are baked into the script as numeric literals before
* `addInitScript` ships it to the page. This keeps a single source of
* truth across the Node boundary; previously the same values lived as
* inline magic numbers inside the script.
*/
const RECORDER_SCRIPT = `
(() => {
if (window.__sentriRecorderInstalled) return;
window.__sentriRecorderInstalled = true;
function bestSelector(el) {
if (!el || el.nodeType !== 1) return "";
const testId = el.getAttribute("data-testid") || el.getAttribute("data-test-id");
if (testId) return 'data-testid=' + JSON.stringify(testId.trim());
const role = el.getAttribute("role") || roleFromTag(el.tagName);
const label = (el.getAttribute("aria-label") || "").trim().slice(0, 80);
if (role && label) return 'role=' + role + '[name=' + JSON.stringify(label) + ']';
if ((el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT") && el.labels && el.labels[0]) {
const l = (el.labels[0].innerText || el.labels[0].textContent || "").trim().replace(/\\s+/g, " ").slice(0, 80);
if (l) return 'label=' + JSON.stringify(l);
}
const ph = (el.getAttribute("placeholder") || "").trim();
if (ph) return 'placeholder=' + JSON.stringify(ph.slice(0, 80));
const alt = (el.getAttribute("alt") || "").trim();
if (alt) return 'alt=' + JSON.stringify(alt.slice(0, 80));
const title = (el.getAttribute("title") || "").trim();
if (title) return 'title=' + JSON.stringify(title.slice(0, 80));
const txt = (el.innerText || el.textContent || "").trim().replace(/\\s+/g, " ").slice(0, 80);
if (txt && txt.length >= 2) return 'text=' + JSON.stringify(txt);
if (el.id) return "#" + CSS.escape(el.id);
if (el.name) return el.tagName.toLowerCase() + '[name="' + el.name + '"]';
const cls = (el.className && typeof el.className === "string") ? el.className.split(/\\s+/).filter(Boolean).slice(0, 2).join(".") : "";
return el.tagName.toLowerCase() + (cls ? "." + cls : "");
}
// Friendly label for the human-readable Steps panel. Mirrors how AI-
// generated steps reference elements ("the Search button", "the Email
// field") rather than CSS / role= selectors. Falls through a priority
// chain: aria-label → trimmed inner text → placeholder → name → "" so
// the caller can fall back to a kind-only sentence ("User clicks").
function bestLabel(el) {
if (!el || el.nodeType !== 1) return "";
const aria = (el.getAttribute("aria-label") || "").trim();
if (aria) return aria.slice(0, 60);
const text = (el.innerText || el.textContent || "").trim().replace(/\\s+/g, " ");
if (text && text.length <= 60) return text;
if (text) return text.slice(0, 57) + "…";
const ph = (el.getAttribute && el.getAttribute("placeholder")) || "";
if (ph) return ph.trim().slice(0, 60);
if (el.name) return String(el.name).slice(0, 60);
return "";
}
function roleFromTag(tag) {
tag = (tag || "").toLowerCase();
if (tag === "button") return "button";
if (tag === "a") return "link";
if (tag === "input" || tag === "textarea") return "textbox";
if (tag === "select") return "combobox";
return "";
}
// Browser dispatches click → click → dblclick for a double-click gesture.
// Defer click emission by one OS double-click window so a trailing
// "dblclick" listener can cancel the queued clicks for the same target;
// otherwise replay would re-run the click handler twice before the
// intended double-click and toggle UI state / submit forms early.
const pendingClickTimers = new Map(); // selector -> timeout id
function eventElement(ev) {
const p = ev.composedPath && ev.composedPath();
return (p && p[0] && p[0].nodeType === 1) ? p[0] : ev.target;
}
document.addEventListener("click", (ev) => {
const raw = eventElement(ev);
const el = raw.closest ? (raw.closest("a, button, input, textarea, select, [role], [data-testid], [data-test-id], [contenteditable='true']") || raw) : raw;
const sel = bestSelector(el);
const label = bestLabel(el);
const emit = () => {
pendingClickTimers.delete(sel);
window.__sentriRecord && window.__sentriRecord({
kind: "click", selector: sel, label, ts: Date.now(),
});
};
if (!sel) { emit(); return; }
const prev = pendingClickTimers.get(sel);
if (prev) clearTimeout(prev);
pendingClickTimers.set(sel, setTimeout(emit, ${TIMINGS.DBLCLICK_DEFER_MS}));
}, true);
document.addEventListener("dblclick", (ev) => {
const raw = eventElement(ev);
const el = raw.closest ? (raw.closest("a, button, input, textarea, select, [role], [data-testid], [data-test-id], [contenteditable='true']") || raw) : raw;
const sel = bestSelector(el);
// Cancel any queued click(s) for this selector — a dblclick supersedes
// the two click events that preceded it.
if (sel) {
const pending = pendingClickTimers.get(sel);
if (pending) { clearTimeout(pending); pendingClickTimers.delete(sel); }
}
window.__sentriRecord && window.__sentriRecord({
kind: "dblclick", selector: sel, label: bestLabel(el), ts: Date.now(),
});
}, true);
document.addEventListener("contextmenu", (ev) => {
const raw = eventElement(ev);
const el = raw.closest ? (raw.closest("a, button, input, textarea, select, [role], [data-testid], [data-test-id], [contenteditable='true']") || raw) : raw;
window.__sentriRecord && window.__sentriRecord({
kind: "rightClick", selector: bestSelector(el), label: bestLabel(el), ts: Date.now(),
});
}, true);
// Hover capture uses a dwell timer so casual pointer movement through
// nested DOM doesn't flood the action log. We only emit a "hover" action
// after the pointer has rested on the same interactive ancestor for
// \`TIMINGS.HOVER_DWELL_MS\` — long enough to filter out drive-by
// mouseover events while still catching deliberate hovers (tooltips,
// dropdown triggers).
let hoverDwellTimer = null;
let lastHoverSelector = "";
document.addEventListener("mouseover", (ev) => {
const raw = eventElement(ev);
const el = raw.closest ? (raw.closest("a, button, input, textarea, select, [role], [data-testid], [data-test-id], [contenteditable='true']") || raw) : raw;
if (!el) return;
const sel = bestSelector(el);
if (!sel) return;
if (sel === lastHoverSelector) return; // already pending / just emitted for this target
if (hoverDwellTimer) { clearTimeout(hoverDwellTimer); hoverDwellTimer = null; }
hoverDwellTimer = setTimeout(() => {
hoverDwellTimer = null;
lastHoverSelector = sel;
window.__sentriRecord && window.__sentriRecord({
kind: "hover", selector: sel, label: bestLabel(el), ts: Date.now(),
});
}, ${TIMINGS.HOVER_DWELL_MS});
}, true);
document.addEventListener("mouseout", () => {
if (hoverDwellTimer) { clearTimeout(hoverDwellTimer); hoverDwellTimer = null; }
lastHoverSelector = "";
}, true);
// Per-selector fill-debounce timers AND a "last emitted" cache. The two
// work together to dedupe fill actions across the input + change handlers:
// - The "input" handler captures normal typing as a debounced fill and
// records the emitted value in \`lastEmittedFill\`.
// - The "change" handler is the safety-net for browser autofill / paste
// scenarios that bypass the "input" event entirely. It checks
// \`lastEmittedFill\` and skips the redundant fill when the input
// handler already covered the same selector + value.
// Without this dedup, typing "hello" then blurring fired two identical
// \`fill\` actions and produced two consecutive \`safeFill(sel, 'hello')\`
// calls in the generated code. The lastEmittedFill entry is purged after
// the change event so a subsequent retype of the same value re-fires.
const inputTimers = new Map();
const lastEmittedFill = new Map();
document.addEventListener("input", (ev) => {
const el = eventElement(ev);
if (!el || (el.tagName !== "INPUT" && el.tagName !== "TEXTAREA")) return;
if (el.type === "checkbox" || el.type === "radio" || el.type === "file") return;
const sel = bestSelector(el);
if (!sel) return;
const prev = inputTimers.get(sel);
if (prev) clearTimeout(prev);
inputTimers.set(sel, setTimeout(() => {
inputTimers.delete(sel);
const value = el.value;
lastEmittedFill.set(sel, value);
window.__sentriRecord && window.__sentriRecord({
kind: "fill", selector: sel, label: bestLabel(el), value, ts: Date.now(),
});
}, ${TIMINGS.FILL_DEBOUNCE_MS}));
}, true);
document.addEventListener("change", (ev) => {
const el = eventElement(ev);
if (!el) return;
if (el.tagName === "INPUT" && (el.type === "checkbox" || el.type === "radio")) {
window.__sentriRecord && window.__sentriRecord({
kind: el.checked ? "check" : "uncheck",
selector: bestSelector(el),
label: bestLabel(el),
ts: Date.now(),
});
} else if (el.tagName === "INPUT" && el.type === "file") {
const names = (el.files && el.files.length)
? Array.from(el.files).map((f) => f.name).join(", ")
: "";
window.__sentriRecord && window.__sentriRecord({
kind: "upload", selector: bestSelector(el), label: bestLabel(el), value: names, ts: Date.now(),
});
} else if (el.tagName === "SELECT") {
window.__sentriRecord && window.__sentriRecord({
kind: "select", selector: bestSelector(el), label: bestLabel(el), value: el.value, ts: Date.now(),
});
} else if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
// Safety-net branch for autofill / paste / programmatic value changes
// that bypass the "input" event. Skip when the "input" handler already
// emitted a fill for this selector + value. If a debounced "input"
// fill is still pending, flush it synchronously here so the recorded
// action carries the latest value (the user committed it by blurring).
const sel = bestSelector(el);
if (!sel) return;
const pending = inputTimers.get(sel);
if (pending) {
clearTimeout(pending);
inputTimers.delete(sel);
// Fall through to emit below; the input handler hadn't fired yet.
} else if (lastEmittedFill.get(sel) === el.value) {
// Already emitted by the input handler with the exact same value —
// the change event is the trailing duplicate. Drop it and clear
// the dedup entry so a subsequent retype of the same value still
// gets recorded.
lastEmittedFill.delete(sel);
return;
}
lastEmittedFill.set(sel, el.value);
window.__sentriRecord && window.__sentriRecord({
kind: "fill", selector: sel, label: bestLabel(el), value: el.value, ts: Date.now(),
});
}
}, true);
document.addEventListener("keydown", (ev) => {
// Keep modifier-only events out, but capture regular typing + editing
// keys so replay preserves keyboard-driven interactions.
if (ev.key === "Shift" || ev.key === "Control" || ev.key === "Meta" || ev.key === "Alt") return;
// If a printable single character is being typed into an editable field,
// the "input" handler above already captures the resulting fill via
// \`safeFill(sel, '<value>')\`. Emitting an additional per-keystroke
// press here would generate redundant \`keyboard.press('h')\` calls
// alongside the fill — replay would type each character once via press,
// then clear-and-retype the whole string via safeFill, breaking React
// controlled inputs / autocompletes / char-by-char validators that fire
// mid-typing. Keyboard shortcuts (Ctrl+A, Cmd+V, Ctrl+Enter) and editing
// keys (Enter, Tab, Backspace, arrows) are still captured because they
// don't conflict with the fill capture.
const t = ev.target;
const isEditable = !!(t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable));
if (ev.key.length === 1 && isEditable && !ev.ctrlKey && !ev.metaKey) return;
if (ev.key.length === 1 || ["Enter", "Escape", "Tab", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Backspace", "Delete"].includes(ev.key) || ev.ctrlKey || ev.metaKey) {
window.__sentriRecord && window.__sentriRecord({
kind: "press", key: ev.key, selector: bestSelector(ev.target), label: bestLabel(ev.target), ts: Date.now(),
});
}
}, true);
let dragSource = "";
document.addEventListener("dragstart", (ev) => {
const el = eventElement(ev);
dragSource = bestSelector(el);
}, true);
document.addEventListener("drop", (ev) => {
const target = eventElement(ev);
const targetSel = bestSelector(target);
if (!dragSource || !targetSel) return;
window.__sentriRecord && window.__sentriRecord({
kind: "drag", selector: dragSource, target: targetSel, label: bestLabel(target), ts: Date.now(),
});
dragSource = "";
}, true);
})();
`;
/**
* Escape a user-controlled string so it can be safely interpolated into a
* JavaScript single-quoted string literal in generated source code. Handles
* backslash (`\`), single quote (`'`), newline (`\n`), carriage return (`\r`),
* line/paragraph separators (U+2028 / U+2029 — these break literals in most
* engines), and other C0 control characters via `\xHH` escapes.
*
* Order matters: backslash must be escaped first, otherwise subsequent
* replacements would double-escape their own inserted backslashes.
*
* @param {string} str
* @returns {string}
*/
function escapeJsSingleQuote(str) {
return String(str ?? "")
.replace(/\\/g, "\\\\")
.replace(/'/g, "\\'")
.replace(/\n/g, "\\n")
.replace(/\r/g, "\\r")
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029")
// Any remaining C0 / DEL control byte → \xHH. These would either break
// the literal (e.g. U+0008) or render untrustworthy generated code.
// eslint-disable-next-line no-control-regex
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, "0")}`);
}
/**
* Derive a human-readable target phrase from a recorded action — prefers the
* captured `label` (aria-label / inner text / placeholder), then falls back
* to extracting a friendly name from a Playwright role selector
* (`role=button[name="Sign in"]` → `the "Sign in" button`), and finally to
* an empty string so the caller can degrade to a target-less sentence.
*
* Important: callers must NOT splice raw selectors into the persisted steps
* — the AI generate / crawl pipeline ("User clicks the Sign Up button") and
* the manual-test path both produce English prose, and recorded steps need
* to render alongside them on the Test Detail page without leaking
* `role=…[name="…"]` or CSS into the reviewer's view.
*
* @param {RecordedAction} a
* @returns {string} Either ` the "<label>" <noun>`, ` "<label>"`, or `""`.
*/
function friendlyTarget(a, noun = "") {
const raw = (a.label || "").trim();
if (raw) {
return noun ? ` the '${raw}' ${noun}` : ` '${raw}'`;
}
// Legacy actions captured before the `label` field existed only carry the
// selector. Try to recover a name from `role=foo[name="bar"]` so older
// recordings still render readable steps after this upgrade ships.
const sel = a.selector || "";
const m = sel.match(/role=([a-z]+)\[name="([^"]+)"\]/i);
if (m) {
const role = m[1].toLowerCase();
const name = m[2];
// Caller passed a noun (e.g. "button") → use it. Otherwise omit the
// role name entirely — silently substituting the role for the noun
// (e.g. " the 'Done' region") leaks a developer-facing concept and
// also breaks "The …" prefixing in assertion-style step formatters
// (would produce "The the 'Done' region is visible").
return noun ? ` the '${name}' ${noun}` : ` '${name}'`;
}
// No label, no role selector — return empty so the sentence reads cleanly
// ("User clicks") instead of leaking a CSS selector to the reviewer.
return "";
}
/**
* Same as {@link friendlyTarget} but operates on a raw selector/label pair
* pulled from a different action's drop-target fields (`a.target` is a
* selector string only — there's no separate `targetLabel`). Used by the
* `drag` step formatter so the rendered sentence reads as
* `User drags the 'Card 1' card onto the 'Done' column` instead of dropping
* the target half of the gesture entirely.
*
* @param {string} selector - Raw selector to extract a friendly name from.
* @param {string} [noun] - Element noun (`"element"`, `"column"`).
* @returns {string} - ` the '<name>' <noun>`, ` '<name>'`, or `""`.
*/
function friendlyTargetFromSelector(selector, noun = "") {
const sel = String(selector || "");
if (!sel) return "";
const m = sel.match(/role=([a-z]+)\[name="([^"]+)"\]/i);
if (m) {
const name = m[2];
// Same rationale as friendlyTarget: only include a noun when the caller
// explicitly asked for one. Substituting the parsed role (e.g.
// "region") leaks developer-facing terminology into the persisted step.
return noun ? ` the '${name}' ${noun}` : ` '${name}'`;
}
return "";
}
/**
* Trim a captured URL for display in the Steps panel. Strips the query
* string + fragment (which dominate noisy recorder URLs like Amazon search
* pages with 6 tracking params) and caps the rendered length so a single
* step doesn't push the panel sideways.
*/
function shortUrl(u) {
if (!u) return "";
try {
const url = new URL(u);
const base = `${url.origin}${url.pathname}`;
return base.length > 80 ? base.slice(0, 77) + "…" : base;
} catch {
return String(u).slice(0, 80);
}
}
/**
* Render a captured action as a short, human-readable step sentence so the
* recorder's persisted `steps[]` array aligns visually with the AI generate /
* crawl pipeline output (`outputSchema.js`) and the manual-test creation path
* — both of which produce English prose like "User clicks the Sign Up
* button". The Test Detail page renders all three sources through the same
* Steps panel, and previously the recorder was the only producer emitting
* engineer-shaped strings ("Step 1: click → #login"), making recorded tests
* stick out and look broken to manual reviewers.
*
* @param {RecordedAction} a
* @returns {string} A single step sentence suitable for the persisted `steps[]` array.
*/
export function recordedActionToStepText(a) {
// Recorded `fill` / `select` / `upload` / `assert*` values can contain
// secrets (passwords, API keys). The raw value already lives in
// `playwrightCode`; truncate aggressively in the human-readable steps so
// the Test Detail page doesn't surface it.
const truncVal = (v, n = 40) => String(v ?? "").slice(0, n);
switch (a.kind) {
case "goto":
return `User navigates to ${shortUrl(a.url)}`.trim();
case "click":
return `User clicks${friendlyTarget(a, "button")}`;
case "dblclick":
// "double-clicks" is technical jargon to a manual tester. The AI
// pipeline (`outputSchema.js:74-78`) favours plain user-intent prose,
// so describe the gesture as a repeated click on the target instead.
// Drop the "element" fallback noun — it reads as developer jargon
// ("clicks the Save element"). With a captured label the sentence
// says "User clicks 'Save' twice"; without one it degrades to a clean
// "User clicks twice".
return `User clicks${friendlyTarget(a)} twice`;
case "rightClick":
// Same rationale as dblclick — "right-clicks" leaks the input device.
// The user-visible outcome of a right-click is the context menu, so
// describe that instead.
return `User opens the context menu on${friendlyTarget(a)}`;
case "hover":
return `User hovers over${friendlyTarget(a)}`;
case "fill":
// Match the AI pipeline's "User fills in X with 'value'" phrasing
// (outputSchema.js:74-78) — recorder previously used "User fills the
// 'Email' field with …" which read differently from AI-generated and
// manually-created steps on the same Test Detail page.
return `User fills in${friendlyTarget(a, "field")} with '${truncVal(a.value)}'`;
case "press":
return `User presses ${a.key || ""}`.trim();
case "select":
return `User selects '${truncVal(a.value)}'${friendlyTarget(a, "dropdown") ? ` in${friendlyTarget(a, "dropdown")}` : ""}`;
case "check":
return `User checks${friendlyTarget(a, "checkbox")}`;
case "uncheck":
return `User unchecks${friendlyTarget(a, "checkbox")}`;
case "upload":
// Drop the "file input" noun — manual testers don't think in input
// types. "User uploads 'resume.pdf' for the 'Attach CV' field" reads
// closer to the user's intent than "… in the 'Attach CV' file input".
return `User uploads '${truncVal(a.value)}'${friendlyTarget(a, "field") ? ` for${friendlyTarget(a, "field")}` : ""}`;
case "drag": {
// Surface BOTH source and drop-target so reviewers can follow the
// gesture from the persisted steps alone. The previous formatter
// dropped the target entirely, leaving "User drags 'Card 1'" with no
// indication of where it landed. No "element" fallback noun — it
// reads as developer jargon when reviewers see it in the Steps panel.
const source = friendlyTarget(a);
const target = friendlyTargetFromSelector(a.target);
return target
? `User drags${source} onto${target}`
: `User drags${source}`;
}
case "assertVisible":
// Match the AI pipeline's outcome-style assertions ("the 'Sign in'
// button is visible") rather than our previous engineer-shaped
// "User asserts visibility of …" phrasing. The Steps panel renders
// recorded + AI-generated tests through the same component, so the
// sentence shapes need to be interchangeable. Fall back to "An
// element" (capitalised, no jargon "the element") when no friendly
// label can be recovered.
return friendlyTarget(a)
? `The${friendlyTarget(a)} is visible`
: `The expected content is visible`;
case "assertText":
return friendlyTarget(a)
? `The${friendlyTarget(a)} contains '${truncVal(a.value)}'`
: `The page contains '${truncVal(a.value)}'`;
case "assertValue": {
// `friendlyTarget(a, "field")` returns " the 'Email' field" (with a
// lowercase leading "the"), so we splice the captured label into the
// sentence directly rather than prefixing another "The" — the
// resulting "The the …" duplication was a CI failure on the previous
// pass.
const t = friendlyTarget(a, "field");
return t
? `The${t.replace(/^ the /, " ")} has value '${truncVal(a.value)}'`
: `The field has value '${truncVal(a.value)}'`;
}
case "assertUrl":
// "URL" is engineer-speak; manual testers think "page address". Use
// "page address" so the persisted step reads naturally next to AI-
// generated steps like "User opens the dashboard page".
return `The page address contains '${truncVal(a.value, 60)}'`;
default:
// Fall back to the action kind so unknown future kinds still show
// something — better than emitting an empty string into the steps list.
return `User performs ${a.kind || "action"}${friendlyTarget(a)}`;
}
}
/**
* Predicate matching the required-field branches in
* {@link actionsToPlaywrightCode}. Returns `true` iff the action carries
* enough information for the code generator to emit a corresponding line.
*
* Exported so route handlers building the persisted human-readable
* `steps[]` array can filter with the **same** rules the code generator
* applies — without this shared predicate the two would drift, causing
* `steps.length !== playwrightCode` step counts on the Test Detail page
* (and breaking step-based edit/regeneration that indexes by position).
* If you add a new action kind to {@link actionsToPlaywrightCode}, add the
* matching branch here too.
*
* @param {RecordedAction} a
* @returns {boolean}
*/
export function isEmittableAction(a) {
switch (a?.kind) {
case "goto": return !!a.url;
case "click":
case "dblclick":
case "rightClick":
case "hover":
case "fill":
case "select":
case "check":
case "uncheck":
case "upload":
case "assertVisible":
case "assertText":
case "assertValue": return !!a.selector;
case "press": return !!a.key;
case "drag": return !!a.selector && !!a.target;
case "assertUrl": return !!a.value;
default: return false;
}
}
/**
* Filter a list of captured actions down to the ones the code generator
* would actually emit. Convenience wrapper around {@link isEmittableAction}.
*
* @param {Array<RecordedAction>} actions
* @returns {Array<RecordedAction>}
*/
export function filterEmittableActions(actions) {
return (actions || []).filter(isEmittableAction);
}
/**
* Convert a list of captured actions into a Playwright test body. The output
* is wrapped in the repo-standard `test(...)` shape so the existing runner
* (codeExecutor, codeParsing) treats it like any AI-generated test.
*
* @param {string} testName
* @param {string} startUrl
* @param {Array<RecordedAction>} actions
* @returns {string} Playwright source code.
*/
export function actionsToPlaywrightCode(testName, startUrl, actions) {
const safeName = escapeJsSingleQuote(testName || "Recorded test");
const safeStartUrl = escapeJsSingleQuote(startUrl || "");
const lines = [];
const actorExpr = (action) => {
const alias = escapeJsSingleQuote(action?.pageAlias || "page");
const base = alias === "page" ? "page" : `(await ensurePopup('${alias}'))`;
const frameUrl = String(action?.frameUrl || "");
if (!frameUrl) return base;
return `(await ensureFrame(${base}, '${escapeJsSingleQuote(frameUrl)}'))`;
};
lines.push(`const __popupPages = new Map();`);
lines.push(`context.on('page', (p) => {`);
lines.push(` const alias = 'popup' + (__popupPages.size + 1);`);
lines.push(` __popupPages.set(alias, p);`);
lines.push(`});`);
lines.push(`const ensurePopup = async (alias) => {`);
lines.push(` for (let i = 0; i < 50; i++) {`);
lines.push(` const p = __popupPages.get(alias);`);
lines.push(` if (p) return p;`);
lines.push(` await page.waitForTimeout(100);`);
lines.push(` }`);
lines.push(` throw new Error('Popup not found: ' + alias);`);
lines.push(`};`);
lines.push(`const ensureFrame = async (p, frameUrl) => {`);
lines.push(` for (let i = 0; i < 50; i++) {`);
lines.push(` const f = p.frames().find((fr) => fr.url().includes(frameUrl));`);
lines.push(` if (f) return f;`);
lines.push(` await p.waitForTimeout(100);`);
lines.push(` }`);
lines.push(` throw new Error('Frame not found for URL: ' + frameUrl);`);
lines.push(`};`);
lines.push(`await page.goto('${safeStartUrl}');`);
// `startRecording` always pushes an initial `goto` to startUrl as actions[0]
// (and `framenavigated` can echo the same URL). We emit the initial goto
// above, so suppress any subsequent consecutive gotos to the same URL to
// avoid duplicate navigation in the generated script.
let lastGotoUrl = String(startUrl || "");
let stepNo = 1;
for (const a of actions) {
const sel = escapeJsSingleQuote(a.selector || "");
const targetSel = escapeJsSingleQuote(a.target || "");
const actor = actorExpr(a);
if (a.kind === "goto" && a.url) {
if (a.url === lastGotoUrl) continue;
lastGotoUrl = a.url;
const safeUrl = escapeJsSingleQuote(a.url);
const gotoActor = a.pageAlias && a.pageAlias !== "page" ? `(await ensurePopup('${escapeJsSingleQuote(a.pageAlias)}'))` : "page";
lines.push(`// Step ${stepNo}: Navigate`);
lines.push(`await ${gotoActor}.goto('${safeUrl}');`);
} else if (a.kind === "click" && sel) {
lines.push(`// Step ${stepNo}: Click element`);
lines.push(`await safeClick(${actor}, '${sel}');`);
} else if (a.kind === "dblclick" && sel) {
lines.push(`// Step ${stepNo}: Double click element`);
lines.push(`await ${actor}.locator('${sel}').dblclick();`);
} else if (a.kind === "rightClick" && sel) {
lines.push(`// Step ${stepNo}: Right click element`);
lines.push(`await ${actor}.locator('${sel}').click({ button: 'right' });`);
} else if (a.kind === "hover" && sel) {
lines.push(`// Step ${stepNo}: Hover over element`);
lines.push(`await ${actor}.locator('${sel}').hover();`);
} else if (a.kind === "fill" && sel) {
lines.push(`// Step ${stepNo}: Fill field`);
lines.push(`await safeFill(${actor}, '${sel}', '${escapeJsSingleQuote(a.value || "")}');`);
} else if (a.kind === "press" && a.key) {
// `keyboard` only exists on Page (not Frame), so always route key
// presses through the owning page even when the action originated
// inside an iframe — keyboard input is page-scoped in CDP anyway.
const pageActor = a.pageAlias && a.pageAlias !== "page" ? `(await ensurePopup('${escapeJsSingleQuote(a.pageAlias)}'))` : "page";
lines.push(`// Step ${stepNo}: Press ${escapeJsSingleQuote(a.key)}`);
lines.push(`await ${pageActor}.keyboard.press('${escapeJsSingleQuote(a.key)}');`);
} else if (a.kind === "select" && sel) {
// Route through the self-healing helper so recorded selects benefit
// from the safeSelect waterfall (getByLabel → getByRole('combobox') →
// aria-label fallback). `applyHealingTransforms` won't rewrite a raw
// `page.selectOption('#css', ...)` because `bestSelector()` always
// produces CSS-looking output, so emit `safeSelect` directly here to
// stay consistent with how `safeClick` and `safeFill` are handled
// above.
lines.push(`// Step ${stepNo}: Select option`);
lines.push(`await safeSelect(${actor}, '${sel}', '${escapeJsSingleQuote(a.value || "")}');`);
} else if ((a.kind === "check" || a.kind === "uncheck") && sel) {
// Same rationale as safeSelect above — the recorder's CSS-looking
// selectors bypass the applyHealingTransforms regex guard, so emit
// safeCheck/safeUncheck directly. These helpers gained list/row
// scoped fallbacks in PR #103 for TodoMVC-style patterns, which
// recorded checkboxes benefit from for free.
lines.push(`// Step ${stepNo}: ${a.kind === "check" ? "Check" : "Uncheck"}`);
lines.push(`await ${a.kind === "check" ? "safeCheck" : "safeUncheck"}(${actor}, '${sel}');`);
} else if (a.kind === "upload" && sel) {
// The recorder only sees browser-side `File.name` values — it has no
// access to the original bytes or a server-side path. Emit the
// captured filenames as a comment so reviewers can wire up real
// fixtures, but ship a no-op `[]` payload so replay doesn't crash
// with ENOENT trying to read non-existent local files.
const capturedNames = String(a.value || "").split(",").map((n) => n.trim()).filter(Boolean);
lines.push(`// Step ${stepNo}: Upload file(s)`);
if (capturedNames.length) {
lines.push(`// NOTE: recorder captured filenames ${JSON.stringify(capturedNames)} — replace [] with real fixture path(s) before running outside the recorder`);
} else {
lines.push(`// NOTE: replace with real fixture path(s) before running outside the recorder`);
}
lines.push(`await safeUpload(${actor}, '${sel}', []);`);
} else if (a.kind === "drag" && sel && targetSel) {
lines.push(`// Step ${stepNo}: Drag and drop`);
lines.push(`await ${actor}.locator('${sel}').dragTo(${actor}.locator('${targetSel}'));`);
} else if (a.kind === "assertVisible" && sel) {
lines.push(`// Step ${stepNo}: Assert element is visible`);
lines.push(`await expect(${actor}.locator('${sel}')).toBeVisible();`);
} else if (a.kind === "assertText" && sel) {
lines.push(`// Step ${stepNo}: Assert element text`);
lines.push(`await expect(${actor}.locator('${sel}')).toContainText('${escapeJsSingleQuote(a.value || "")}');`);
} else if (a.kind === "assertValue" && sel) {
lines.push(`// Step ${stepNo}: Assert field value`);
lines.push(`await expect(${actor}.locator('${sel}')).toHaveValue('${escapeJsSingleQuote(a.value || "")}');`);
} else if (a.kind === "assertUrl" && a.value) {
// The frontend prompts for a "URL fragment or regex text", and most
// users type a plain URL fragment containing regex metacharacters
// (`?`, `[`, `(`, `+`, `.`) that would either crash `new RegExp(...)`
// with a SyntaxError or silently change semantics (e.g. `?` making
// the previous char optional). Escape regex metacharacters so the
// captured value matches literally — that's what users expect.
const literal = String(a.value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
lines.push(`// Step ${stepNo}: Assert URL`);
lines.push(`await expect(page).toHaveURL(new RegExp('${escapeJsSingleQuote(literal)}'));`);
} else {
continue;
}
stepNo++;
}
lines.push(`// Step ${stepNo}: Verify page is still reachable`);
lines.push(`await expect(page).toHaveURL(/.*/);`);
return (
`import { test, expect } from '@playwright/test';\n\n` +
`test('${safeName}', async ({ page, context }) => {\n` +
lines.map(l => " " + l).join("\n") +
"\n});\n"
);
}
/**
* Append a manual assertion action to an in-flight recording session.
* Mirrors Playwright recorder's explicit "Add assertion" flow.
*
* @param {string} sessionId
* @param {RecordedAction} action
* @returns {RecordedAction}
*/
export function addAssertionAction(sessionId, action) {
const session = sessions.get(sessionId);
if (!session) throw new Error(`Recording session ${sessionId} not found.`);
if (session.status !== "recording") throw new Error(`Recording session ${sessionId} is not recording.`);
const kind = String(action?.kind || "");
const allowed = new Set(["assertVisible", "assertText", "assertValue", "assertUrl"]);
if (!allowed.has(kind)) throw new Error(`Invalid assertion kind: ${kind}`);
const selector = action?.selector ? String(action.selector).slice(0, 200) : undefined;
const value = action?.value != null ? String(action.value).slice(0, 500) : undefined;
// Reject payloads that would later be silently dropped by
// `actionsToPlaywrightCode` — assertions without their required field
// would render in the Steps panel but disappear from the generated test
// code, leaving users with assertions they think exist but don't.
if (kind !== "assertUrl" && !selector) {
throw new Error(`Invalid assertion: selector is required for ${kind}.`);
}
if ((kind === "assertText" || kind === "assertValue" || kind === "assertUrl") && !value) {
throw new Error(`Invalid assertion: value is required for ${kind}.`);
}
const row = {
kind,
selector,
label: action?.label ? String(action.label).slice(0, 80) : undefined,
value,
ts: Date.now(),
};
session.actions.push(row);
return row;
}
/**
* Start a new interactive recording session. Opens a Playwright browser,
* navigates to `startUrl`, installs the capture script, and begins a CDP
* screencast on the given session ID (reused as the SSE run ID).
*
* @param {Object} args
* @param {string} args.sessionId - Unique ID used for SSE + session tracking.
* @param {string} args.projectId
* @param {string} args.startUrl
* @returns {Promise<RecordingSession>}
*/
export async function startRecording({ sessionId, projectId, startUrl }) {
if (sessions.has(sessionId)) {
throw new Error(`Recording session ${sessionId} is already active.`);
}
if (!startUrl || !/^https?:\/\//i.test(startUrl)) {
throw new Error("startUrl must be a valid http(s) URL.");
}
const browser = await launchBrowser();
let context;
let page;
try {
context = await browser.newContext({
viewport: { width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT },
ignoreHTTPSErrors: true,
acceptDownloads: true,
});
const session = /** @type {RecordingSession} */ ({
id: sessionId,
projectId,
url: startUrl,
status: "recording",
actions: [],
startedAt: Date.now(),
browser,
context,
});
const pageAliases = new Map();
page = await context.newPage();
pageAliases.set(page, "page");
session.page = page;
context.on("page", (p) => {
if (pageAliases.has(p)) return;
pageAliases.set(p, `popup${Math.max(1, pageAliases.size)}`);
p.on("framenavigated", (frame) => {
if (frame === p.mainFrame() && frame.url() && frame.url() !== "about:blank") {
session.actions.push({ kind: "goto", pageAlias: pageAliases.get(p), url: frame.url(), ts: Date.now() });
}
});
});
// Expose a binding for the injected script to relay captured events.
await context.exposeBinding("__sentriRecord", (source, action) => {
if (session.status !== "recording") return;
if (!action || typeof action !== "object") return;
const sourcePage = source?.page || null;
const sourceFrame = source?.frame || null;
const isMainFrame = !!(sourcePage && sourceFrame && sourcePage.mainFrame() === sourceFrame);
const row = {
kind: String(action.kind || ""),
selector: action.selector ? String(action.selector).slice(0, 200) : undefined,
target: action.target ? String(action.target).slice(0, 200) : undefined,
label: action.label ? String(action.label).slice(0, 80) : undefined,
value: action.value != null ? String(action.value).slice(0, 500) : undefined,
url: action.url ? String(action.url) : undefined,
key: action.key ? String(action.key) : undefined,
pageAlias: sourcePage ? (pageAliases.get(sourcePage) || "page") : "page",
frameUrl: !isMainFrame && sourceFrame?.url ? String(sourceFrame.url()).slice(0, 500) : undefined,
ts: Number(action.ts) || Date.now(),
};
// Browsers fire two `click` events before a `dblclick`. Without
// suppression a user double-click replays as click, click, dblclick
// — running the same handler three times. Drop trailing clicks on
// the same selector that arrived within the OS double-click window
// (`TIMINGS.DBLCLICK_WINDOW_MS`) so the recorded action list matches
// user intent.
if (row.kind === "dblclick" && row.selector) {
for (let i = session.actions.length - 1; i >= 0; i--) {
const prev = session.actions[i];
if (row.ts - (prev.ts || 0) > TIMINGS.DBLCLICK_WINDOW_MS) break;
if (prev.kind === "click" && prev.selector === row.selector) {
session.actions.splice(i, 1);
}
}
}
session.actions.push(row);
});
await context.addInitScript(RECORDER_SCRIPT);
// Navigate to the starting URL and record it as the first action.
await page.goto(startUrl, { waitUntil: "domcontentloaded", timeout: NAVIGATION_TIMEOUT }).catch(() => {});
session.actions.push({ kind: "goto", pageAlias: "page", url: startUrl, ts: Date.now() });
// Capture subsequent in-page navigations (form submit, link click that
// triggers a full load) so the generated script replays them via goto.
page.on("framenavigated", (frame) => {
if (frame === page.mainFrame() && frame.url() && frame.url() !== "about:blank") {
session.actions.push({ kind: "goto", pageAlias: "page", url: frame.url(), ts: Date.now() });
}
});
// Start CDP screencast so the RecorderModal can show the live browser.
// startScreencast now returns { stop, cdpSession } — store both so the
// recorder can forward mouse/keyboard events from the canvas overlay.
const screencastResult = await startScreencast(page, sessionId);
if (screencastResult) {
session.stopScreencast = screencastResult.stop;
session.cdpSession = screencastResult.cdpSession;
}
// Defence-in-depth: if the client never calls stop/discard (e.g. tab
// closed, network died) the browser would remain open forever. Force-kill
// the session after `MAX_RECORDING_MS` so we never leak Chromium.
session.idleTimeout = setTimeout(async () => {
console.error(formatLogLine("warn", null, `[recorder] session ${sessionId} exceeded MAX_RECORDING_MS (${MAX_RECORDING_MS}ms) — auto-tearing down`));
try {
const result = await stopRecording(sessionId);
// Stash the generated test so a user who hits "Stop & Save" right
// after the timeout fires doesn't lose their captured actions.
completedSessions.set(sessionId, {
projectId: session.projectId,
actions: result.actions,
playwrightCode: result.playwrightCode,
url: result.url,
completedAt: Date.now(),
reason: "auto_timeout",
});
const purge = setTimeout(() => completedSessions.delete(sessionId), COMPLETED_TTL_MS);
purge.unref?.();
} catch { /* session may already be gone; nothing to stash */ }
// Close out the stub `runs` row created by POST /record. Without this,
// the row stays in `status: "running"` forever and the partial unique
// index `idx_runs_one_active_per_project` blocks every future run on
// this project (crawl/test_run/generate report opaque UNIQUE errors;
// the next recorder launch's orphan sweep is the only path that
// recovers, but only for `record` rows).
try {
runRepo.update(sessionId, {
status: "interrupted",
finishedAt: new Date().toISOString(),
error: `Recorder exceeded MAX_RECORDING_MS (${MAX_RECORDING_MS}ms) — auto-torn-down`,
});
} catch { /* row may not exist (e.g. tests that bypass route layer) */ }
}, MAX_RECORDING_MS);
// Node's timer would keep the process alive; recorder sessions are
// per-request resources, so let the event loop exit if everything else
// is quiescent.
session.idleTimeout.unref?.();
// Only publish the session after all async setup has succeeded —
// otherwise the caller never learns the sessionId to stop and the
// browser would leak permanently.
sessions.set(sessionId, session);
return session;
} catch (err) {
// Tear down any partial Playwright resources so we don't leak a
// Chromium process when setup fails mid-way.
try { await page?.close(); } catch { /* ignore */ }
try { await context?.close(); } catch { /* ignore */ }
try { await browser?.close(); } catch { /* ignore */ }
throw err;
}
}
/**
* Look up an in-flight recording session.
* @param {string} sessionId
* @returns {RecordingSession|null}
*/
export function getRecording(sessionId) {
return sessions.get(sessionId) || null;
}
/**
* Look up and remove a recording that was auto-torn-down by the safety-net
* timeout. Returns `null` if no such recording is cached (either never timed
* out, or the TTL has expired). The entry is removed on read so callers get
* at-most-once delivery of the captured actions.
*
* @param {string} sessionId
* @returns {CompletedRecording|null}
*/
export function takeCompletedRecording(sessionId) {
const entry = completedSessions.get(sessionId);
if (!entry) return null;
completedSessions.delete(sessionId);
return entry;
}
/**
* Test-only seam: install a fake recording session keyed by `sessionId` so
* unit tests can exercise {@link forwardInput} (and its CDP dispatch logic)
* without launching a real Chromium. Returns a disposer that removes the
* session from the in-memory map.
*
* Intentionally not part of the public API — only the module's own test file
* imports this. The `_test` prefix and JSDoc tag should keep it out of normal
* usage; reviewers should reject any non-test caller.
*
* @param {string} sessionId
* @param {Object} fields - Partial RecordingSession fields to seed.
* @returns {Function} Disposer `() => void` that deletes the seeded session.
* @private
*/
export function _testSeedSession(sessionId, fields = {}) {
sessions.set(sessionId, {
id: sessionId,
projectId: fields.projectId ?? "TEST-PROJECT",
url: fields.url ?? "https://example.com",
status: fields.status ?? "recording",
actions: [],
startedAt: Date.now(),
cdpSession: fields.cdpSession,
...fields,
});
return () => sessions.delete(sessionId);
}
/**
* Forward a user input event from the canvas overlay to the headless browser
* via CDP Input domain commands. This is the core mechanism that makes the
* recorder interactive — without it the canvas is read-only and the user can
* never produce actions in the headless browser.
*
* Supported event types:
* mousePressed / mouseReleased / mouseMoved → Input.dispatchMouseEvent
* keyDown / keyUp / char → Input.dispatchKeyEvent
* scroll → Input.dispatchMouseEvent (wheel)
*
* @param {string} sessionId
* @param {Object} event
* @param {"mousePressed"|"mouseReleased"|"mouseMoved"|"keyDown"|"keyUp"|"char"|"scroll"} event.type
* @param {number} [event.x] - Viewport x (already scaled by caller).
* @param {number} [event.y] - Viewport y (already scaled by caller).
* @param {number} [event.button] - DOM MouseEvent.button: 0=left, 1=middle, 2=right.
* Pass `undefined` (omit) for moves with no
* button held — CDP requires `"none"` then.
* @param {number} [event.clickCount] - 1 for single click.
* @param {number} [event.deltaX] - Horizontal scroll delta.
* @param {number} [event.deltaY] - Vertical scroll delta.
* @param {string} [event.key] - DOM key name, e.g. "Enter".
* @param {string} [event.code] - DOM code, e.g. "KeyA".
* @param {number} [event.keyCode] - DOM virtual keycode (`e.keyCode`).
* Required for non-printable keys —
* without it CDP fires keyDown but
* Backspace/Enter/Tab/Arrows have no
* effect on the page.
* @param {string} [event.text] - Printable text for char events.
* @param {number} [event.modifiers] - Bitmask: Alt=1, Ctrl=2, Meta=4, Shift=8.
* @returns {Promise<void>}
* @throws {Error} When the session is not found or has no CDP session.
*/
export async function forwardInput(sessionId, event) {
const session = sessions.get(sessionId);
if (!session) throw new Error(`Recording session ${sessionId} not found.`);
if (!session.cdpSession) throw new Error(`Session ${sessionId} has no CDP session — cannot forward input.`);
if (session.status !== "recording") return; // ignore input after stop is called
const cdp = session.cdpSession;
const { type } = event;
try {
if (type === "mousePressed" || type === "mouseReleased" || type === "mouseMoved") {
// DOM MouseEvent.button: 0=left, 1=middle, 2=right. CDP uses string
// names. For `mouseMoved` with no button held the caller should omit
// `event.button` so we dispatch `"none"` — otherwise CDP interprets a
// numeric 0 as a held left-button and treats the move as a drag.
const buttonMap = { 0: "left", 1: "middle", 2: "right" };
const cdpButton = event.button == null ? "none" : (buttonMap[event.button] ?? "none");
await cdp.send("Input.dispatchMouseEvent", {
type,
x: Math.round(event.x ?? 0),
y: Math.round(event.y ?? 0),
button: cdpButton,
clickCount: event.clickCount ?? (type === "mousePressed" ? 1 : 0),
modifiers: event.modifiers ?? 0,
});
} else if (type === "scroll") {
await cdp.send("Input.dispatchMouseEvent", {
type: "mouseWheel",
x: Math.round(event.x ?? 0),
y: Math.round(event.y ?? 0),
deltaX: event.deltaX ?? 0,
deltaY: event.deltaY ?? 0,
modifiers: event.modifiers ?? 0,
});
} else if (type === "keyDown" || type === "keyUp") {
// `windowsVirtualKeyCode` is what makes Backspace/Enter/Tab/Arrows
// actually trigger their default action in the page. Without it CDP
// fires the event but the page receives no operation. The frontend
// forwards `e.keyCode` from the DOM event for this purpose.
const args = {
type,
key: event.key ?? "",
code: event.code ?? "",
text: type === "keyDown" ? (event.text ?? "") : "",
modifiers: event.modifiers ?? 0,
};
if (typeof event.keyCode === "number" && event.keyCode > 0) {
args.windowsVirtualKeyCode = event.keyCode;
args.nativeVirtualKeyCode = event.keyCode;
}
await cdp.send("Input.dispatchKeyEvent", args);
} else if (type === "char") {
await cdp.send("Input.dispatchKeyEvent", {
type: "char",
key: event.text ?? "",
text: event.text ?? "",
modifiers: event.modifiers ?? 0,
});
}
} catch (err) {
// CDP errors (e.g. page navigating mid-click) are transient — don't crash
// the session. Log at debug level so they don't flood production logs.
if (process.env.LOG_LEVEL === "debug") {
console.error(formatLogLine("debug", null, `[recorder] forwardInput CDP error: ${err.message}`));
}
}
}
/**
* Stop an in-flight recording session, tear down the Playwright browser,
* and return the captured actions transformed into Playwright source. The
* generated code is wrapped in the repo-standard `test(...)` shape so the
* caller can persist it as a Draft test row and re-run it through the
* normal runner.
*
* Idempotent w.r.t. the in-memory map: the session is removed regardless
* of whether teardown errors. The browser/context/page cleanup calls are
* `.catch(() => {})`-shielded so a half-closed browser never leaks state.
*
* @param {string} sessionId
* @param {Object} [opts]
* @param {string} [opts.testName] - Optional name to embed in the generated test.
* @returns {Promise<{ actions: Array<RecordedAction>, playwrightCode: string, url: string }>}
* @throws {Error} When the session does not exist.
*/
export async function stopRecording(sessionId, opts = {}) {
const session = sessions.get(sessionId);
if (!session) throw new Error(`Recording session ${sessionId} not found.`);
session.status = "stopping";
try {
if (session.idleTimeout) { clearTimeout(session.idleTimeout); session.idleTimeout = null; }
if (session.stopScreencast) await session.stopScreencast().catch(() => {});
await session.page?.close().catch(() => {});
await session.context?.close().catch(() => {});
await session.browser?.close().catch(() => {});
} finally {
session.status = "stopped";
sessions.delete(sessionId);
}
const testName = opts.testName || `Recorded flow @ ${new Date().toISOString()}`;
const playwrightCode = actionsToPlaywrightCode(testName, session.url, session.actions);
return { actions: session.actions, playwrightCode, url: session.url };
}