Source: runner/recorder.js

/**
 * @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, resolveDevice, DEVICE_PRESETS } from "./config.js";
import { startScreencast } from "./screencast.js";
import { formatLogLine } from "../utils/logFormatter.js";
import * as runRepo from "../database/repositories/runRepo.js";
import { buildInjectedBootstrapScript } from "./playwrightSelectorGenerator.js";

/**
 * DIF-015c Gap 5 — set of device names accepted by the recorder. Built
 * once at module load from the curated `DEVICE_PRESETS` exported by
 * `config.js` (the same list `RunRegressionModal` mirrors), plus the
 * empty-string sentinel for "Desktop (default)". Any other value is a
 * 400 from the route layer — we deliberately do NOT accept arbitrary
 * `playwright.devices` keys at the recorder boundary because the curated
 * list is what the UI exposes and what `executeTest.js` exercises in CI.
 *
 * @internal
 */
const ACCEPTED_DEVICE_NAMES = new Set(DEVICE_PRESETS.map((d) => d.value));

/**
 * DIF-015c Gap 6 — opt-in stealth bootstrap script. Patches the five
 * fingerprint surfaces real-world `if (navigator.webdriver) { block() }`
 * detection scripts check, **without** pulling in the
 * `puppeteer-extra-plugin-stealth` dependency tree (which would add a
 * cat-and-mouse fingerprint patcher running on every page, plus a
 * security-review surface for anti-bot logic AGENT.md `:123` warns
 * against bundling without explicit justification).
 *
 * Applied via `context.addInitScript(STEALTH_SCRIPT)` ONLY when the
 * session was launched with `stealth: true`. Default-mode runs never
 * call `addInitScript` with this body so pre-Gap-6 behaviour is
 * bit-for-bit identical.
 *
 * Coverage of common headless-detection probes:
 *
 *   - `navigator.webdriver` — headless sets this to `true`; we redefine
 *     the getter to return `undefined` (matches what a real Chrome
 *     window returns).
 *   - `navigator.plugins` — headless reports an empty `PluginArray`; we
 *     synthesise a 3-entry array shaped like a real Chrome install
 *     (PDF Viewer + Chrome PDF Viewer + Chromium PDF Viewer).
 *   - `navigator.languages` — headless reports `[]`; we force the
 *     equivalent of a US-English Chrome install. Operators can still
 *     override per-context via the existing `locale` device option.
 *   - `window.chrome` — real Chrome exposes a `chrome` object with at
 *     least a `runtime` stub; headless leaves it undefined. We
 *     synthesise the minimal shape that detection scripts probe.
 *   - `Permissions.prototype.query` for `notifications` — real Chrome
 *     returns `{ state: "prompt" }` for an unset permission; headless
 *     returns `{ state: "denied" }` which is a strong tell. We patch
 *     the prototype to flip notifications back to `"prompt"` while
 *     leaving every other permission query untouched.
 *
 * The script is intentionally narrow — it covers the ~90% of detection
 * scripts that read these five surfaces, NOT the long tail of
 * canvas-fingerprint / WebGL-renderer / battery-API patches the upstream
 * plugin chains together. If a target site detects us despite this
 * stealth profile, the right answer is to add a single targeted patch
 * here rather than pull in the full upstream stack.
 *
 * @internal
 */
const STEALTH_SCRIPT = `
(() => {
  try {
    if (window.__sentriStealthInstalled) return;
    window.__sentriStealthInstalled = true;

    // 1. navigator.webdriver — the canonical "is this headless?" probe.
    //    Real Chrome returns undefined; headless returns true. Redefine
    //    the getter on the prototype so existing read paths see undefined
    //    without breaking property-descriptor introspection.
    try {
      Object.defineProperty(navigator, "webdriver", {
        get: () => undefined,
        configurable: true,
      });
    } catch (_) { /* defineProperty may throw on some platforms */ }

    // 2. navigator.plugins — synthesise a real-Chrome-shaped PluginArray.
    //    Detection scripts often read .length, so a 3-entry array (the
    //    default Chrome install on macOS/Windows) is the safest spoof.
    try {
      const fakePlugins = [
        { name: "PDF Viewer", filename: "internal-pdf-viewer", description: "Portable Document Format" },
        { name: "Chrome PDF Viewer", filename: "internal-pdf-viewer", description: "Portable Document Format" },
        { name: "Chromium PDF Viewer", filename: "internal-pdf-viewer", description: "Portable Document Format" },
      ];
      Object.defineProperty(navigator, "plugins", {
        get: () => fakePlugins,
        configurable: true,
      });
    } catch (_) { /* ignore */ }

    // 3. navigator.languages — headless reports [], real browsers report
    //    at least the UI locale. Force the equivalent of a US-English
    //    install; operators who need a different locale should set the
    //    Playwright \`locale\` device option which takes precedence at
    //    the context layer.
    try {
      Object.defineProperty(navigator, "languages", {
        get: () => ["en-US", "en"],
        configurable: true,
      });
    } catch (_) { /* ignore */ }

    // 4. window.chrome — real Chrome exposes a top-level \`chrome\`
    //    object with at least a \`runtime\` stub. Headless leaves it
    //    undefined. Synthesise the minimal shape so detection scripts
    //    that probe \`window.chrome\` or \`window.chrome.runtime\` see
    //    something plausible.
    try {
      if (!window.chrome) {
        Object.defineProperty(window, "chrome", {
          value: { runtime: {} },
          writable: true,
          configurable: true,
        });
      } else if (!window.chrome.runtime) {
        window.chrome.runtime = {};
      }
    } catch (_) { /* ignore */ }

    // 5. Permissions.prototype.query — patch \`notifications\` only.
    //    Real Chrome returns { state: "prompt" } for unset notifications;
    //    headless returns "denied" which is a strong tell. Leave every
    //    other permission untouched so legitimate permission flows
    //    (geolocation, camera, mic) behave normally.
    try {
      if (window.Permissions && window.Permissions.prototype && window.Permissions.prototype.query) {
        const origQuery = window.Permissions.prototype.query;
        window.Permissions.prototype.query = function (params) {
          if (params && params.name === "notifications") {
            return Promise.resolve({ state: "prompt" });
          }
          return origQuery.call(this, params);
        };
      }
    } catch (_) { /* ignore */ }
  } catch (err) {
    // Surface init failures the same way RECORDER_SCRIPT does so a
    // half-applied stealth profile doesn't silently degrade — better
    // for the operator to see "stealth init failed" in the backend
    // log than to wonder why their target site still detects them.
    console.error("[sentri-stealth] init failed:", err && err.stack ? err.stack : err);
  }
})();
`;

/**
 * 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;

/**
 * Action kinds that represent a real user interaction (as opposed to a
 * drive-by `hover` or a passive `goto`). Used by the `__sentriRecord`
 * binding to strip a trailing `hover` action on the same selector when the
 * very next action is an interaction — see the block that consumes this
 * set for the full rationale.
 *
 * Lives at module scope (matching `TIMINGS` above) rather than inside the
 * binding callback so we don't re-allocate a Set on every captured action
 * event during an active recording session.
 *
 * @internal
 */
const INTERACTION_KINDS = new Set([
  "click", "dblclick", "rightClick", "fill",
  "select", "check", "uncheck", "upload", "press",
]);

/**
 * SEC-007 — server-side defence-in-depth credential detector.
 *
 * Module-scope sibling to the in-page `isSensitiveField` heuristic. The
 * page-side script is the **primary** redaction path (it sees the
 * `<input type="password">` attribute and sets `redacted: true` before
 * the value crosses the binding boundary). This helper runs at the
 * Node-side binding callback and only matters when something slipped
 * past — a SUT that uses `<input type="text">` for a password without
 * matching the name/id heuristic, or a future RECORDER_SCRIPT change
 * that introduces a regression.
 *
 * Detection rules (entropy + known-credential shapes):
 *   - JWT (3-segment base64url): `eyJ…\.…\.…`
 *   - AWS access key id: `AKIA[0-9A-Z]{16}`
 *   - Bearer token literal: `Bearer <token>`
 *   - Stripe / GitHub / OpenAI / Slack key prefixes (industry-standard
 *     gitleaks rules): `sk_(live|test)_…`, `ghp_…`, `gho_…`, `ghs_…`,
 *     `xox[abps]-…`
 *   - High-entropy 32+ char base64ish blob (catches API keys / session
 *     tokens that don't match a known prefix)
 *   - Credit card (Luhn-checked, 13–19 digits)
 *
 * Returns `false` for the empty string, sentinel values
 * (`__SENTRI_SECRET_<n>__`), and obvious-non-secret short tokens (≤8
 * chars or all whitespace) so the false-positive rate stays low.
 *
 * The detection is intentionally narrower than `secretScanner.js`
 * (which scans generated test code post-hoc and is allowed false
 * positives) — at this site a false positive would silently corrupt the
 * captured fill value, breaking replay. Only catch what we're sure of.
 *
 * @param {string|undefined} value - Raw value about to be persisted.
 * @returns {boolean} `true` when the value matches a known credential pattern.
 * @internal
 */
function _looksLikeSecretValue(value) {
  if (typeof value !== "string" || value.length === 0) return false;
  if (value.length <= 8) return false;
  if (/^__SENTRI_SECRET_/.test(value)) return false;
  // JWT (3 base64url segments separated by dots)
  if (/^eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9._-]{10,}\.[A-Za-z0-9._-]{10,}$/.test(value)) return true;
  // AWS access key id
  if (/^AKIA[0-9A-Z]{16}$/.test(value)) return true;
  // Bearer token literal (rare in a fill value, but possible from a
  // pasted Authorization header)
  if (/^Bearer\s+[A-Za-z0-9._~+/-]{16,}$/i.test(value)) return true;
  // Provider-prefixed tokens (industry-standard gitleaks set)
  if (/^sk_(?:live|test)_[A-Za-z0-9]{20,}$/.test(value)) return true;
  if (/^gh[poas]_[A-Za-z0-9]{30,}$/.test(value)) return true;
  if (/^xox[abps]-[A-Za-z0-9-]{20,}$/.test(value)) return true;
  // Luhn-validated credit card (13–19 digits, accepting space/dash
  // separators for human-typed cards). Strip separators first.
  const compact = value.replace(/[\s-]/g, "");
  if (/^\d{13,19}$/.test(compact) && _luhnValid(compact)) return true;
  return false;
}

/**
 * SEC-007 — Luhn check for credit card validation. Returns true when the
 * digits-only string passes the standard mod-10 checksum.
 *
 * @param {string} digits - Digits-only string (caller must strip spaces/dashes).
 * @returns {boolean}
 * @internal
 */
function _luhnValid(digits) {
  let sum = 0;
  let alt = false;
  for (let i = digits.length - 1; i >= 0; i--) {
    let n = digits.charCodeAt(i) - 48;
    if (n < 0 || n > 9) return false;
    if (alt) { n *= 2; if (n > 9) n -= 9; }
    sum += n;
    alt = !alt;
  }
  return sum % 10 === 0;
}

/**
 * DIF-015b — quality-scores a `data-testid` value. Returns `true` when the
 * value looks machine-generated / random (numeric-only, `el_` / `comp-` /
 * `t-` prefix + hex tail, or a long unseparated token).
 *
 * **This is only used by the hand-rolled fallback selectorGenerator** that
 * runs when Playwright's `InjectedScript` source cannot be loaded (missing
 * `playwright-core` install, Playwright bumped to a version with a different
 * injected-bundle layout, etc.). The primary path delegates to Playwright's
 * own selector generator which has its own — more sophisticated — noise
 * scoring built in.
 *
 * Exported for unit tests that exercise the fallback path directly; callers
 * outside the fallback should not depend on this heuristic.
 *
 * @param {string} value - Raw `data-testid` attribute value.
 * @returns {boolean} `true` when the value looks noisy and should be demoted.
 */
export function isNoisyTestId(value) {
  const v = (value || "").trim();
  if (!v) return true;
  if (/^\d+$/.test(v)) return true;
  if (/^(?:el_|comp-|t-)[a-z0-9_-]*[0-9a-f]{4,}$/i.test(v)) return true;
  if (v.length > 30 && !/[-_:.]/.test(v)) return true;
  return false;
}

/**
 * @typedef {Object} RecordedAction
 * @property {"goto"|"click"|"dblclick"|"rightClick"|"hover"|"fill"|"press"|"select"|"check"|"uncheck"|"upload"|"drag"|"assertVisible"|"assertText"|"assertValue"|"assertUrl"|"assertCount"|"assertHasClass"} 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.
 *                                   For sensitive inputs (passwords, OTP,
 *                                   payment fields, operator-marked
 *                                   `data-sentri-secret`) this is the
 *                                   sentinel `__SENTRI_SECRET_<n>__`
 *                                   rather than the raw value (SEC-007).
 * @property {boolean} [redacted]  - SEC-007: `true` when `value` is a
 *                                   credential sentinel rather than the
 *                                   user-typed value. Codegen rewrites
 *                                   redacted fills to
 *                                   `process.env.SENTRI_SECRET_<n>`; the
 *                                   step prose renders as `[REDACTED]`.
 * @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 {string}  [device]          - DIF-015c Gap 5: active device profile
 *                                          (e.g. `"iPhone 14"`); empty string
 *                                          or undefined → desktop default.
 * @property {{width: number, height: number}} [viewport] - Resolved viewport
 *                                          (from device descriptor, falling
 *                                          back to `VIEWPORT_WIDTH/HEIGHT`).
 * @property {boolean} [paused]           - DIF-015c Gap 3: pause flag.
 * @property {boolean} [stealth]          - DIF-015c Gap 6: when true the
 *                                          recorder context has `STEALTH_SCRIPT`
 *                                          installed via `addInitScript`,
 *                                          patching `navigator.webdriver` +
 *                                          friends so headless-detecting
 *                                          target apps render normally.
 *                                          Default false → pre-Gap-6
 *                                          behaviour bit-for-bit unchanged.
 * @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.
 * @property {*}       [frameNavTimer]   - DIF-015c Gap 5 follow-up: Node-side
 *                                          setTimeout handle for the debounced
 *                                          `framenavigated` flush. Tracked on the
 *                                          session so `switchDevice` can cancel
 *                                          it before tearing the page down,
 *                                          preventing a stale `goto` for the
 *                                          pre-switch URL from leaking into
 *                                          `actions[]` after the rebuild lands.
 */

/** @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.
 *
 * `selectorGenerator` mirrors Playwright-style priority heuristics (DIF-015b):
 * prefer role-based selectors, then data-testid, then aria-label, then
 * a short CSS chain.
 *
 * **Disambiguation (DIF-015b follow-up):** when the chosen CSS-fallback
 * selector matches more than one element on the page, append a Playwright
 * `>> nth=N` token so replay targets the same element the user clicked.
 * Without this, three identical `button.btn-primary` on a page would all
 * replay against the first match. Role/data-testid/label/text selectors are
 * NOT disambiguated by index — they're already semantic anchors and adding
 * `nth=N` to them would mask a real test smell (multiple identical labels
 * on the same page is a symptom worth surfacing, not silently fixing).
 *
 * **Shadow DOM and iframes** still fall through to the host-document selector
 * (this PR's scope is naming + nth disambiguation only). Iframe support is
 * partially handled at the action layer via `frameUrl` capture — see the
 * `__sentriRecord` binding. Full shadow-root traversal is tracked as a
 * follow-up sub-item under DIF-015b in ROADMAP.md.
 *
 * 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 = `
(() => {
  try {
  if (window.__sentriRecorderInstalled) return;
  window.__sentriRecorderInstalled = true;

  // CSS-only fallback used both as the final branch in selectorGenerator
  // AND by the nth=N disambiguator (which needs a CSS string to count
  // matches via document.querySelectorAll).
  function cssFallback(el) {
    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 : "");
  }

  // DIF-015b: append ">> nth=N" if the CSS fallback matches multiple elements.
  // Only applied to CSS-fallback selectors — semantic selectors (role=, text=,
  // data-testid=, label=, placeholder=, alt=, title=) intentionally pass
  // through unchanged. Bail out cheaply on a unique match so the common case
  // pays nothing beyond a single querySelectorAll call.
  function disambiguateCss(el, cssSel) {
    if (!cssSel || el.id) return cssSel; // #id selectors are unique by definition
    let matches;
    try { matches = document.querySelectorAll(cssSel); }
    catch { return cssSel; } // malformed selector — let replay fail visibly instead of corrupting it
    if (matches.length <= 1) return cssSel;
    const idx = Array.prototype.indexOf.call(matches, el);
    if (idx < 0) return cssSel; // el is in a shadow root or detached — querySelectorAll can't see it
    return cssSel + " >> nth=" + idx;
  }

  // DIF-015b Gap 2 — inlined hand-rolled copy of the Node-side
  // \`isNoisyTestId()\` (kept in source above). Previously this was injected
  // via \`\${isNoisyTestId.toString()}\` interpolation, but the interpolation
  // produced a \"SyntaxError: Unexpected end of input\" at page-init time
  // (the function body's regex literals contained \\\\d / \\\\s sequences that
  // collided with the outer template-literal escaping rules), which caused
  // the entire IIFE to abort before any DOM listeners were attached — the
  // symptom was the recorder only ever capturing \`goto\` actions while
  // every click/fill/keypress was silently dropped. Inlining the function
  // body as static script text avoids the interpolation altogether.
  function isNoisyTestId(value) {
    const v = (value || "").trim();
    if (!v) return true;
    if (/^[0-9]+$/.test(v)) return true;
    if (/^(?:el_|comp-|t-)[a-z0-9_-]*[0-9a-f]{4,}$/i.test(v)) return true;
    if (v.length > 30 && !/[-_:.]/.test(v)) return true;
    return false;
  }

  function selectorGenerator(el) {
    if (!el || el.nodeType !== 1) return "";
    // Primary path: delegate to Playwright's own InjectedScript-based
    // selector generator when its bootstrap script ran successfully. This
    // is the same algorithm Playwright's \`codegen\` tool produces and
    // covers ancestor scoring, noise-testid demotion, shadow-DOM
    // traversal, and iframe locator chains — none of which the fallback
    // below handles. If Playwright returns an empty string we fall
    // through to the local heuristic so a single misclassified element
    // doesn't break the recording.
    if (typeof window.__playwrightSelector === "function") {
      try {
        const pw = window.__playwrightSelector(el);
        if (pw && typeof pw === "string") return pw;
      } catch (_) { /* fall through to hand-rolled fallback */ }
    }
    // Fallback path — runs when Playwright's InjectedScript source could
    // not be loaded at server start, or its API surface drifted in a
    // version bump and the bootstrap left __playwrightSelector
    // unpopulated. Order matches the documented priority in the module
    // JSDoc; the testid noise heuristic only matters here because
    // Playwright's generator already handles it on the primary path.
    const testId = (el.getAttribute("data-testid") || el.getAttribute("data-test-id") || "").trim();
    const role = el.getAttribute("role") || roleFromTag(el.tagName);
    const label = (el.getAttribute("aria-label") || "").trim().slice(0, 80);
    if (testId && !isNoisyTestId(testId)) return 'data-testid=' + JSON.stringify(testId);
    if (role && label) return 'role=' + role + '[name=' + JSON.stringify(label) + ']';
    if (testId) return 'data-testid=' + JSON.stringify(testId);
    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);
    // CSS fallback — disambiguate with nth=N when the selector matches
    // multiple elements. Semantic selectors above are intentionally exempt.
    return disambiguateCss(el, cssFallback(el));
  }
  // 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 "";
  }

  // SEC-007 — in-page credential redaction.
  //
  // Sensitive values (passwords, OTP codes, payment card numbers, fields
  // the operator tagged with \`data-sentri-secret\`) MUST never cross the
  // \`__sentriRecord\` binding boundary as plaintext — if they did, they
  // would land in \`session.actions[].value\`, be persisted to the \`tests\`
  // table inside \`playwrightCode\` + \`steps\`, and replay through the AI
  // assertion pipeline (leaking the credential to the LLM provider). We
  // instead assign a stable per-session ordinal and emit a sentinel
  // \`__SENTRI_SECRET_<n>__\` so codegen can rewrite to
  // \`process.env.SENTRI_SECRET_<n>\` at replay time. The raw value never
  // leaves the page context.
  //
  // Detection rules (matches industry-standard tools — Mabl, Testim, BearQ):
  //   - \`<input type="password">\` (the canonical case)
  //   - \`autocomplete\` hint matches \`current-password|new-password|one-time-code|cc-number|cc-csc|cc-exp\`
  //   - operator-marked \`data-sentri-secret="true"\` attribute on the input
  //     (or any ancestor — supports wrapping the whole field in a flagged div)
  //   - input \`name\`/\`id\` matches the credential heuristic regex (catches
  //     misconfigured sites that use \`<input type="text" name="password">\`)
  //
  // The sentinel map is per-page (lives in the IIFE closure) so reloading
  // the recorder script resets numbering — this matches how operators
  // think about credentials ("the password field on this flow") rather
  // than across-session reuse, which would be a footgun for shared envs.
  const sentinelBySelector = new Map();
  let nextSentinelOrdinal = 1;
  function isSensitiveField(el) {
    if (!el || el.nodeType !== 1) return false;
    if (el.tagName !== "INPUT" && el.tagName !== "TEXTAREA") return false;
    // 1. type="password" — the unambiguous case.
    if (el.type === "password") return true;
    // 2. autocomplete hint per WHATWG Forms spec §autofill detail tokens.
    const ac = String(el.getAttribute("autocomplete") || "").toLowerCase().trim();
    if (ac && /(?:^|\\s)(?:current-password|new-password|one-time-code|cc-number|cc-csc|cc-exp|cc-exp-month|cc-exp-year)(?:\\s|$)/.test(ac)) return true;
    // 3. operator-marked sensitive — check the input AND any ancestor so a
    //    wrapping <div data-sentri-secret> covers all children.
    if (el.closest && el.closest('[data-sentri-secret="true"]')) return true;
    // 4. heuristic: name/id contains \`password\`/\`secret\`/\`pin\`/\`cvv\`/\`cvc\`
    //    as a separated token (avoids false positives on \`passport_number\`,
    //    \`secretary\`). Catches sites that use \`<input type="text">\` for a
    //    password field (rare but real — observed on legacy admin panels).
    const probe = (String(el.name || "") + " " + String(el.id || "")).toLowerCase();
    if (/(?:^|[\\W_])(?:password|passwd|pwd|secret|pin|cvv|cvc|otp|totp)(?:[\\W_]|$)/.test(probe)) return true;
    return false;
  }
  function sentinelFor(el, sel) {
    // Re-use the same sentinel ordinal for repeated fills on the same
    // selector — operators retyping the same password into the same field
    // should produce one \`SENTRI_SECRET_1\`, not multiple.
    const key = sel || (el && (el.name || el.id)) || "unkeyed";
    const existing = sentinelBySelector.get(key);
    if (existing) return existing;
    const sentinel = "__SENTRI_SECRET_" + nextSentinelOrdinal + "__";
    nextSentinelOrdinal += 1;
    sentinelBySelector.set(key, sentinel);
    return sentinel;
  }

  // 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.
  // Pending clicks: sel -> { timer, emit }. We keep the emit fn alongside
  // the timer so flushPendingClicks() can fire it synchronously when the
  // page is about to navigate. Without this, clicking a submit button or
  // link loses the click action — the 250 ms dblclick-defer timer is
  // destroyed along with the page before it fires.
  const pendingClickTimers = new Map();
  function flushPendingClicks() {
    for (const sel of Array.from(pendingClickTimers.keys())) {
      const pending = pendingClickTimers.get(sel);
      if (!pending) continue;
      clearTimeout(pending.timer);
      pendingClickTimers.delete(sel);
      try { pending.emit(); } catch (_) { /* best-effort */ }
    }
  }
  let shortcutCaptureBudget = 0;
  // SEC-007-followup — hover capture is OFF by default.
  //
  // The dwell-timer emit path below previously recorded a \`hover\` action
  // every time the operator's cursor rested on an interactive ancestor for
  // 600 ms, even when the operator's intent was to click / pick / read. The
  // "strip trailing hover if same selector" guard in the binding (see
  // \`INTERACTION_KINDS.has(row.kind) && row.selector\` block in
  // \`_attachAndOpenRecorderPage\`) only fires when the hover selector
  // matches the next interaction's selector — but in real flows the hover
  // lands on a heading / link wrapper (\`<h1>\` inside \`<a>\`, \`role=heading\`)
  // while the click lands on a button or input. The selectors differ, the
  // guard misses, and every test ends up with junk \`hover\` steps before
  // every intended action.
  //
  // Industry-standard recorders (Playwright codegen, Mabl, Testim) all
  // default hover capture OFF for exactly this reason. Operators who need a
  // hover-to-reveal step for a tooltip flow can enable capture via the
  // exposed setter (UI toggle in \`RecorderModal\` is a follow-up); the
  // detection scaffolding (dwell timer, interactive-ancestor walk, sel dedup)
  // is preserved verbatim so flipping the flag back on requires zero code
  // change beyond the gate.
  //
  // NOTE: every backtick in this comment block MUST be escaped (\\\`) — the
  // surrounding RECORDER_SCRIPT is a Node-side template literal that's
  // interpolated into the IIFE before \`addInitScript\`. An unescaped
  // backtick closes the outer template prematurely and produces a
  // \`SyntaxError: Unexpected identifier 'hover'\` at module-load time.
  let hoverCaptureEnabled = false;
  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 = selectorGenerator(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.timer);
    const timer = setTimeout(emit, ${TIMINGS.DBLCLICK_DEFER_MS});
    pendingClickTimers.set(sel, { timer, emit });
  }, 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 = selectorGenerator(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.timer); 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: selectorGenerator(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);
    // Only capture hovers on interactive ancestors — do NOT fall back to the
    // raw element. The \`|| raw\` pattern that was here previously caused every
    // mouseover on a generic container (div, section, body) to emit a hover
    // action with a noisy CSS selector, flooding the captured steps list with
    // drive-by movements across layout elements.
    const el = raw.closest ? raw.closest("a, button, input, textarea, select, [role], [data-testid], [data-test-id], [contenteditable='true']") : null;
    if (!el) return;
    const sel = selectorGenerator(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;
      // SEC-007-followup — only emit the hover action when capture is
      // explicitly enabled. Default is off (matches Playwright codegen,
      // Mabl, Testim). The dwell timer still runs so the lastHoverSelector
      // bookkeeping stays consistent — flipping the flag back on at
      // runtime takes effect on the next dwell without state leak.
      if (!hoverCaptureEnabled) return;
      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.
  // Pending fills: sel -> { timer, el, label }. We keep the captured element
  // ref alongside the timer so flushPendingFill() can emit synchronously
  // (re-querying via document.querySelector would lose elements inside
  // shadow roots / iframes). flushPendingFill() is invoked by Enter
  // keydown, form submit, and pagehide handlers below — without those
  // flushes, a user who hits Enter to submit (or whose form auto-submits
  // and navigates) loses the typed value because the 300 ms debounce
  // timer is destroyed along with the page before it fires.
  const inputTimers = new Map();
  const lastEmittedFill = new Map();
  function flushPendingFill(sel) {
    const pending = inputTimers.get(sel);
    if (!pending) return;
    clearTimeout(pending.timer);
    inputTimers.delete(sel);
    // SEC-007 — honour the sensitive flag captured at input time. We use
    // the flag rather than re-running isSensitiveField(pending.el) because
    // the element may have been detached by the navigation that triggered
    // this flush (Enter-to-submit, pagehide), and a detached input loses
    // its \`type\`/\`autocomplete\` attribute lookup paths.
    const value = pending.sensitive
      ? sentinelFor(pending.el, sel)
      : (pending.el ? pending.el.value : "");
    if (lastEmittedFill.get(sel) === value) {
      lastEmittedFill.delete(sel);
      return;
    }
    lastEmittedFill.set(sel, value);
    window.__sentriRecord && window.__sentriRecord({
      kind: "fill", selector: sel, label: pending.label, value, redacted: pending.sensitive || undefined, ts: Date.now(),
    });
  }
  function flushAllPendingFills() {
    for (const sel of Array.from(inputTimers.keys())) flushPendingFill(sel);
  }
  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 = selectorGenerator(el);
    if (!sel) return;
    const prev = inputTimers.get(sel);
    if (prev) clearTimeout(prev.timer);
    const label = bestLabel(el);
    // SEC-007 — compute sensitivity BEFORE the debounce closure so the
    // redaction is decided at the user-interaction moment, not at flush
    // time (the element may be detached / re-rendered by then on SPA
    // frameworks). The sentinel itself is computed inside the closure so
    // sentinelBySelector reflects the latest selector-to-sentinel mapping.
    const sensitive = isSensitiveField(el);
    const timer = setTimeout(() => {
      inputTimers.delete(sel);
      // SEC-007 — for sensitive fields we NEVER read \`el.value\` into a
      // variable that crosses the binding boundary. The sentinel replaces
      // the raw value before \`__sentriRecord\` is called, and the value
      // local goes out of scope at function return without touching any
      // global / closure / module state.
      const value = sensitive ? sentinelFor(el, sel) : el.value;
      // Skip when the paste handler already emitted the exact same value —
      // browsers always fire \`input\` after \`paste\`, so without this dedup
      // a pasted token produces two identical \`fill\` actions. Clear the
      // entry after the check so a subsequent retype of the same value still
      // re-fires (mirrors the change handler's dedup semantics).
      if (lastEmittedFill.get(sel) === value) {
        lastEmittedFill.delete(sel);
        return;
      }
      lastEmittedFill.set(sel, value);
      window.__sentriRecord && window.__sentriRecord({
        kind: "fill", selector: sel, label, value, redacted: sensitive || undefined, ts: Date.now(),
      });
    }, ${TIMINGS.FILL_DEBOUNCE_MS});
    inputTimers.set(sel, { timer, el, label, sensitive });
  }, true);

  // Flush on form submit — Enter-to-submit on Google search and other
  // forms navigates away before the input debounce can fire. The capture
  // phase listener runs synchronously inside the same user-gesture task
  // as the navigation, so __sentriRecord (an exposeBinding) gets the
  // event queued before the page unloads.
  document.addEventListener("submit", () => {
    flushPendingClicks();
    flushAllPendingFills();
  }, true);

  // Last-chance flush before navigation. \`pagehide\` is more reliable
  // than \`beforeunload\` (fires for back/forward cache, programmatic
  // navigations, and HTTP redirects) — best-effort because exposeBinding
  // marshalling is async, but works for "type → click submit button"
  // flows where the click handler runs synchronously before unload.
  // Order matters: flush clicks first so the recorded sequence is
  // click → fill → goto rather than fill → click → goto when both are
  // pending (rare but possible with synthetic events).
  window.addEventListener("pagehide", () => {
    flushPendingClicks();
    flushAllPendingFills();
  }, true);

  document.addEventListener("paste", (ev) => {
    const el = eventElement(ev);
    if (!el || (el.tagName !== "INPUT" && el.tagName !== "TEXTAREA")) return;
    const sel = selectorGenerator(el);
    if (!sel) return;
    const hasClipboard = ev.clipboardData && typeof ev.clipboardData.getData === "function"
      && !!(ev.clipboardData.getData("text") || "");
    if (!hasClipboard) return;
    // Cancel any pending input-handler debounce — the post-paste \`input\`
    // event would otherwise queue a second emission for the same change.
    if (inputTimers.get(sel)) { clearTimeout(inputTimers.get(sel).timer); inputTimers.delete(sel); }
    // Defer to a microtask so \`el.value\` reflects the post-paste field
    // contents (paste fires in the capture phase before the browser mutates
    // value). Using el.value — not just the clipboard snippet — means
    // pasting into a field with pre-existing text records the full final
    // value, matching what the input/change handlers would emit.
    //
    // SEC-007 — paste into a sensitive field (password manager autofill is
    // the canonical case) must redact too. Catching this branch matters:
    // 1Password / Bitwarden / Chrome autofill all dispatch \`paste\` →
    // \`input\` for password fields, and without this guard the paste
    // handler would record the raw password before the input handler's
    // redaction fired (paste runs first; its dedup cache primes the input
    // handler's skip path).
    const sensitive = isSensitiveField(el);
    setTimeout(() => {
      const rawValue = String(el.value || "").slice(0, 500);
      if (!rawValue) return;
      const value = sensitive ? sentinelFor(el, sel) : rawValue;
      // Prime the dedup cache so the subsequent \`input\` event (always
      // fired after paste) is suppressed by the guard added above.
      lastEmittedFill.set(sel, value);
      window.__sentriRecord && window.__sentriRecord({
        kind: "fill", selector: sel, label: bestLabel(el), value, redacted: sensitive || undefined, ts: Date.now(),
      });
    }, 0);
  }, 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: selectorGenerator(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: selectorGenerator(el), label: bestLabel(el), value: names, ts: Date.now(),
      });
    } else if (el.tagName === "SELECT") {
      window.__sentriRecord && window.__sentriRecord({
        kind: "select", selector: selectorGenerator(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 = selectorGenerator(el);
      if (!sel) return;
      // SEC-007 — same sensitivity check as the input + paste handlers, so
      // programmatic autofill (\`el.value = "..."\` from a password manager
      // extension) and the trailing change event for a manually-typed
      // password both produce a sentinel instead of the raw value.
      const sensitive = isSensitiveField(el);
      const effectiveValue = sensitive ? sentinelFor(el, sel) : el.value;
      const pending = inputTimers.get(sel);
      if (pending) {
        clearTimeout(pending.timer);
        inputTimers.delete(sel);
        // Fall through to emit below; the input handler hadn't fired yet.
      } else if (lastEmittedFill.get(sel) === effectiveValue) {
        // 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, effectiveValue);
      window.__sentriRecord && window.__sentriRecord({
        kind: "fill", selector: sel, label: bestLabel(el), value: effectiveValue, redacted: sensitive || undefined, 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;
    // Enter often submits a form and navigates away before the 300 ms
    // input debounce fires, losing the typed value. Flush all pending
    // fills synchronously so the recorded order is fill → press Enter →
    // goto rather than just press Enter → goto. Same rationale for Tab
    // (commits autocomplete + moves focus, can trigger nav on some sites).
    if (ev.key === "Enter" || ev.key === "Tab") {
      flushPendingClicks();
      flushAllPendingFills();
    }
    // 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) {
      if (shortcutCaptureBudget <= 0) return;
      shortcutCaptureBudget -= 1;
    }
    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: selectorGenerator(ev.target), label: bestLabel(ev.target), ts: Date.now(),
      });
    }
  }, true);

  let dragSource = "";
  document.addEventListener("dragstart", (ev) => {
    const el = eventElement(ev);
    dragSource = selectorGenerator(el);
  }, true);
  window.__sentriRecorderSetShortcutBudget = (n) => {
    const parsed = Number.isFinite(Number(n)) ? Number(n) : 0;
    shortcutCaptureBudget = Math.max(0, Math.floor(parsed));
  };
  // SEC-007-followup — setter for the hover-capture opt-in. Strict boolean
  // coercion (mirrors how Gap 6 stealth coerces) so a stringy "true" can't
  // accidentally enable capture from a misconfigured caller. The frontend
  // would call this via a \`page.evaluate\` shim from a route handler that
  // accepts the toggle from a UI control; without that wiring the flag
  // stays off and recordings are clean by default.
  window.__sentriRecorderSetHoverCapture = (enabled) => {
    hoverCaptureEnabled = enabled === true;
  };
  // DIF-015c Gap 2 (point-and-click assert UX) — expose the in-page
  // selectorGenerator + bestLabel + the closest-interactive-ancestor
  // walk on \`window\` so the Node-side \`probeAtPoint\` helper can
  // resolve \`{selector, label, rect}\` for an arbitrary viewport
  // coordinate via \`page.evaluate\`. Without these exports the probe
  // would have to re-implement the same Playwright + hand-rolled
  // fallback logic, which is exactly the drift class of bug AGENT.md
  // §"Do not duplicate shared utilities" warns against. Returns null
  // when no interactive ancestor is found so the caller can fall back
  // to the operator's manual selector paste.
  window.__sentriProbeAtPoint = (x, y) => {
    try {
      const el = document.elementFromPoint(x, y);
      if (!el) return null;
      const target = el.closest
        ? (el.closest("a, button, input, textarea, select, [role], [data-testid], [data-test-id], [contenteditable='true']") || el)
        : el;
      const selector = selectorGenerator(target);
      const label = bestLabel(target);
      const rect = target.getBoundingClientRect();
      return {
        selector: selector || "",
        label: label || "",
        rect: {
          x: rect.left,
          y: rect.top,
          width: rect.width,
          height: rect.height,
        },
      };
    } catch (_) {
      return null;
    }
  };

  document.addEventListener("drop", (ev) => {
    const target = eventElement(ev);
    const targetSel = selectorGenerator(target);
    if (!dragSource || !targetSel) return;
    window.__sentriRecord && window.__sentriRecord({
      kind: "drag", selector: dragSource, target: targetSel, label: bestLabel(target), ts: Date.now(),
    });
    dragSource = "";
  }, true);
  } catch (err) {
    // Surface init-time failures via console.error — the backend pipes page
    // console output to its log so this lands in the same stream as the
    // \`[recorder/page-error]\` warnings. Without this, a thrown listener
    // setup leaves the recorder in a half-installed state where the binding
    // exists but no DOM events are wired up — the symptom is "only goto
    // actions are captured" (which is the only kind that comes from the
    // Node-side \`framenavigated\` listener, not the in-page script).
    console.error("[sentri-recorder] init failed:", err && err.stack ? err.stack : err);
    window.__sentriRecorderInstalled = false;
  }
})();
`;

/**
 * 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.
      //
      // SEC-007 — redacted fills render with a `[REDACTED]` placeholder
      // so reviewers see at a glance that the field is credential-typed
      // and the persisted prose carries no credential surface area. We
      // detect via either the explicit `redacted: true` marker (set by
      // the in-page rule) OR the sentinel pattern (covers actions that
      // pre-date the marker — the marker was added in the same SEC-007
      // PR but actions captured during a hot reload would have value
      // without marker).
      const isRedacted = a.redacted === true || /^__SENTRI_SECRET_/.test(String(a.value || ""));
      const display = isRedacted ? "[REDACTED]" : `'${truncVal(a.value)}'`;
      return `User fills in${friendlyTarget(a, "field")} with ${display}`;
    }
    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)}'`;
    case "assertCount": {
      // Outcome-style assertion matching the AI pipeline's phrasing. With
      // a captured label we read "There are N 'Item' rows"; without one we
      // degrade cleanly to a generic "There are N matching elements"
      // rather than leaking the selector. The captured `value` is the
      // expected count (string-coerced upstream); render as an integer.
      const n = Number(a.value);
      const count = Number.isFinite(n) ? n : a.value;
      const t = friendlyTarget(a);
      return t
        ? `There are ${count} matching${t}`
        : `There are ${count} matching elements`;
    }
    case "assertHasClass": {
      // Reads as "The 'Submit' button has the 'is-loading' class" so it
      // sits naturally next to assertVisible / assertText prose. No label
      // → fall back to "The matched element has the 'X' class" rather
      // than emitting a bare quote with nothing in front of it.
      const t = friendlyTarget(a);
      return t
        ? `The${t} has the '${truncVal(a.value)}' class`
        : `The matched element has the '${truncVal(a.value)}' class`;
    }
    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;
    // assertCount + assertHasClass need BOTH a selector (to bind the
    // expectation to) AND a value (the count / class name). Without
    // either field the code generator would emit nothing — match that
    // here so steps[].length and `// Step N:` comment count stay aligned.
    case "assertCount":
    case "assertHasClass": return !!a.selector && a.value != null && String(a.value).length > 0;
    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;
    // The frameLocator argument is a single-quoted JS string ('...') containing
    // a CSS attribute selector that is itself double-quoted ([src*="..."]).
    // `escapeJsSingleQuote` covers the outer JS string layer; we ALSO have to
    // escape any literal `"` inside frameUrl so the inner attribute selector
    // doesn't terminate early. Without this, a frame URL containing a `"`
    // (rare in practice — usually `%22`-encoded — but possible from custom
    // `src` values that bypass URL normalisation) produces a malformed
    // selector like `iframe[src*="frame"hijack"]` that throws at runtime.
    // Escape any literal `"` inside frameUrl so the inner CSS attribute
    // selector doesn't terminate early. The generated JS source wraps the
    // selector in a single-quoted string (`'iframe[src*="…"]'`), so we
    // need `\"` to appear in that source — which means emitting a
    // backslash + quote pair (`\\"`) here. Using `'\\"'` would only emit
    // a bare `"` at runtime (the JS parser consumes the backslash),
    // leaving the inner attribute selector unescaped.
    const escapedFrameUrl = escapeJsSingleQuote(frameUrl).replace(/"/g, '\\\\"');
    return `${base}.frameLocator('iframe[src*="${escapedFrameUrl}"]').first()`;
  };
  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(`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) {
      // SEC-007 — when the captured value is a redaction sentinel
      // (`__SENTRI_SECRET_<n>__`), emit `process.env.SENTRI_SECRET_<n>`
      // instead of the literal string so the generated test reads its
      // credentials from the runtime environment rather than carrying
      // them in the source. The generator also emits a guard that
      // throws a clear error if the env var is unset at replay time —
      // far better than a confusing "field is empty" assertion failure
      // when an operator runs a recorded test in a fresh CI shard.
      const m = String(a.value || "").match(/^__SENTRI_SECRET_(\d+|AUTO)__$/);
      if (m) {
        const envName = `SENTRI_SECRET_${m[1]}`;
        lines.push(`// Step ${stepNo}: Fill field (credential — value sourced from env)`);
        lines.push(`if (!process.env.${envName}) throw new Error('SEC-007: required env var ${envName} is not set; this recorded test was authored against a sensitive field. See docs/api/tests.md § Recorder credential redaction.');`);
        lines.push(`await safeFill(${actor}, '${sel}', process.env.${envName});`);
      } else {
        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 `selectorGenerator()` 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 if (a.kind === "assertCount" && sel && a.value != null && String(a.value).length > 0) {
      // Coerce to a non-negative integer so `toHaveCount(NaN)` can't slip
      // through. Anything that doesn't parse cleanly falls back to 0 —
      // emit the assertion anyway so the reviewer sees a clearly broken
      // step in the Test Detail view rather than the action silently
      // disappearing from playwrightCode.
      const n = Number.parseInt(String(a.value), 10);
      const expected = Number.isFinite(n) && n >= 0 ? n : 0;
      lines.push(`// Step ${stepNo}: Assert element count`);
      lines.push(`await expect(${actor}.locator('${sel}')).toHaveCount(${expected});`);
    } else if (a.kind === "assertHasClass" && sel && a.value != null && String(a.value).length > 0) {
      // Playwright's `toHaveClass` matches the FULL class attribute when
      // given a string, so partial-class matches (the common case — "is
      // this button currently `is-loading`?") need a regex. Build a
      // word-boundary regex with escaped metacharacters so a class name
      // like `btn.primary` (rare but valid in framework-generated CSS)
      // doesn't break out of its regex literal.
      const literal = String(a.value).trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
      lines.push(`// Step ${stepNo}: Assert element class`);
      lines.push(`await expect(${actor}.locator('${sel}')).toHaveClass(new RegExp('(^|\\\\s)${escapeJsSingleQuote(literal)}(\\\\s|$)'));`);
    } 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",
    // DIF-015c Gap 2 — count + class assertions. Both require selector AND
    // value (count: integer-parseable; hasClass: non-empty class token).
    "assertCount", "assertHasClass",
  ]);
  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" || kind === "assertCount" || kind === "assertHasClass") && !value) {
    throw new Error(`Invalid assertion: value is required for ${kind}.`);
  }
  // assertCount needs a non-negative integer-parseable value. Reject
  // anything that wouldn't survive Number.parseInt cleanly so the route
  // surfaces a 400 instead of letting the code generator fall back to its
  // `expected = 0` defence and confusing the reviewer at replay time.
  if (kind === "assertCount") {
    const n = Number.parseInt(String(value), 10);
    if (!Number.isFinite(n) || n < 0 || String(n) !== String(value).trim()) {
      throw new Error("Invalid assertion: value for assertCount must be a non-negative integer.");
    }
  }
  const row = {
    kind,
    selector,
    label: action?.label ? String(action.label).slice(0, 80) : undefined,
    value,
    ts: Date.now(),
  };
  session.actions.push(row);
  return row;
}

/**
 * DIF-015c Gap 3 — pause action capture on an in-flight recording.
 * Flips `session.paused = true`; the browser stays open and the
 * screencast continues so the operator can navigate the SUT without
 * polluting `actions[]`. Three call sites honour the flag:
 *
 *   1. {@link forwardInput} — short-circuits CDP dispatch so user
 *      clicks / keystrokes from the canvas overlay never reach the page.
 *   2. The `__sentriRecord` exposeBinding callback in {@link startRecording}
 *      — drops in-page-captured DOM events (debounced fills flushing,
 *      framework re-renders firing change handlers, programmatic clicks
 *      from page JS) so in-flight work that started before pause does
 *      not silently leak in.
 *   3. The popup + debounced main-page `framenavigated` handlers —
 *      drop synthesised `goto` actions while paused.
 *
 * @param {string} sessionId
 * @returns {{ paused: true }}
 * @throws {Error} when the session is unknown or not in `"recording"` state.
 */
export function pauseRecording(sessionId) {
  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.`);
  session.paused = true;
  return { paused: true };
}

/**
 * DIF-015c Gap 3 — resume action capture after a pause. Idempotent on a
 * session that was never paused (the flag was already falsy). See
 * {@link pauseRecording} for the list of guarded call sites.
 *
 * @param {string} sessionId
 * @returns {{ paused: false }}
 * @throws {Error} when the session is unknown or not in `"recording"` state.
 */
export function resumeRecording(sessionId) {
  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.`);
  session.paused = false;
  return { paused: false };
}

/**
 * DIF-015c Gap 3 — undo the most recent recorded action. Idempotent on
 * an empty `session.actions[]` (returns `{ removed: null, actionCount: 0 }`
 * rather than 4xx) so the UI can fire the button without first checking
 * the step count — matches the spec's "idempotent on empty actions[]"
 * acceptance criterion in `NEXT.md`.
 *
 * @param {string} sessionId
 * @returns {{ removed: RecordedAction|null, actionCount: number }}
 * @throws {Error} when the session is unknown or not in `"recording"` state.
 */
export function popLastRecordingAction(sessionId) {
  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 removed = session.actions.length ? session.actions.pop() : null;
  return { removed, actionCount: session.actions.length };
}

/**
 * DIF-015c Gap 2 (point-and-click assert UX) — resolve the
 * `{selector, label, rect}` for an arbitrary viewport coordinate so the
 * frontend can highlight the hovered element and pre-fill the
 * "Add verification" form on click. Mirrors how Playwright codegen's
 * inspector probes the page under the cursor.
 *
 * The probe runs entirely in the page context via `page.evaluate`,
 * calling the `window.__sentriProbeAtPoint` helper that the recorder
 * script attaches at init time. That helper reuses the SAME selector +
 * label heuristics the click/fill listeners use, so the picker's
 * suggestion is byte-aligned with what a real click would have captured.
 *
 * The probe is cheap (~5 ms in practice) and idempotent — the operator
 * can pump events at hover frequency (~30 fps) without polluting the
 * session. We deliberately do NOT record the probe as an action; it's
 * a read-only inspection.
 *
 * @param {string} sessionId
 * @param {{x: number, y: number}} point - Viewport coordinates (already
 *   scaled by the frontend from CSS pixels via `LiveBrowserView.scaleCoords`).
 * @returns {Promise<{selector: string, label: string, rect: {x: number, y: number, width: number, height: number}}|null>}
 *   The hovered element's selector + friendly label + bounding rect, or
 *   `null` when no interactive ancestor was found (e.g. cursor over the
 *   page background). The caller falls back to manual selector paste.
 * @throws {Error} when the session is unknown or not recording.
 */
export async function probeAtPoint(sessionId, point) {
  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.`);
  if (!session.page) throw new Error(`Recording session ${sessionId} has no active page.`);
  const x = Math.max(0, Math.round(Number(point?.x) || 0));
  const y = Math.max(0, Math.round(Number(point?.y) || 0));
  try {
    const probe = await session.page.evaluate(
      ({ x, y }) => {
        if (typeof window.__sentriProbeAtPoint !== "function") return null;
        return window.__sentriProbeAtPoint(x, y);
      },
      { x, y },
    );
    return probe || null;
  } catch (err) {
    // Page navigating mid-probe → return null so the frontend just drops
    // the highlight rather than surfacing a 500 to the operator. The
    // probe is best-effort by design.
    if (process.env.LOG_LEVEL === "debug") {
      console.error(formatLogLine("debug", null, `[recorder] probeAtPoint failed: ${err.message}`));
    }
    return null;
  }
}

/**
 * DIF-015c Gap 5 — switch the device profile of an in-flight recording
 * session. Playwright applies device emulation (userAgent, viewport,
 * deviceScaleFactor, hasTouch, locale) at `browser.newContext()` time
 * only — there is no mid-context API for swapping descriptors. To honour
 * the operator's device choice mid-session we tear down the page+context
 * and rebuild them under the new descriptor against the **same** browser
 * process. Captured `session.actions[]` are preserved across the switch
 * (the operator's step history is not lost), but page state (cookies,
 * partially-filled forms, scroll position, in-flight requests) is — the
 * UI surfaces a confirmation prompt before calling this so operators
 * understand the trade-off.
 *
 * Acceptance criteria (NEXT.md `:53`):
 *   - Device dropdown shows the same options as `RunRegressionModal` ✓
 *     (DEVICE_PRESETS shared via `config.js`).
 *   - Switching mid-session resizes the canvas to match ✓ (response
 *     `viewport` flows back to the frontend; `LiveBrowserView` already
 *     rescales pointer coordinates against `viewportW/viewportH`).
 *   - Selectors regenerated at the new viewport's pixel scale ✓ (the
 *     rebuilt context's `deviceScaleFactor` flows from the descriptor;
 *     subsequent captures use the new viewport's coordinate space
 *     because `selectorGenerator` runs against the rebuilt page).
 *
 * @param {string} sessionId
 * @param {string} device - One of `DEVICE_PRESETS[].value` (empty string =
 *   desktop default). Validated against `ACCEPTED_DEVICE_NAMES`; unknown
 *   values throw.
 * @returns {Promise<{device: string, viewport: {width: number, height: number}, url: string}>}
 * @throws {Error} when the session is unknown, not recording, the device
 *   is invalid, or the rebuild fails (in which case the session is left
 *   in a torn-down state and the caller should re-issue `stopRecording`).
 */
export async function switchDevice(sessionId, device) {
  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 requested = device == null ? "" : String(device);
  if (!ACCEPTED_DEVICE_NAMES.has(requested)) {
    throw new Error(`Invalid device: ${requested}`);
  }
  // Idempotent on the active device — return the current viewport so the
  // frontend can reconcile without firing a teardown/rebuild dance.
  // Mirrors how `resumeRecording` no-ops on a never-paused session.
  if (requested === (session.device || "")) {
    return {
      device: session.device || "",
      viewport: session.viewport || { width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT },
      url: session.url,
    };
  }

  // Snapshot the current page URL BEFORE teardown so we can navigate the
  // rebuilt page to wherever the operator was. If `page.url()` throws
  // (page already closing), fall back to the recorded session URL so we
  // still land somewhere sensible.
  let currentUrl;
  try { currentUrl = session.page?.url() || session.url; }
  catch { currentUrl = session.url; }

  const browser = session.browser;
  if (!browser) throw new Error(`Recording session ${sessionId} has no browser to switch device on.`);

  // Tear down current page + screencast + context — but NOT the browser.
  // The browser process stays open and the new context is created under
  // it, saving ~500ms vs. a full launch. Catch-and-swallow each step so
  // a partial teardown still lets the rebuild run.
  // DIF-015c Gap 5 follow-up — cancel the debounced framenavigated timer
  // BEFORE closing the page. Without this, an in-flight 800ms debounce
  // that started before the switch fires after the rebuilt page is alive
  // and pushes a stale `goto` for the pre-switch URL into
  // `session.actions`. The status guard at timer-fire time doesn't help
  // here because switchDevice deliberately leaves `session.status` as
  // `"recording"` to keep the session alive across the rebuild.
  if (session.frameNavTimer) { clearTimeout(session.frameNavTimer); session.frameNavTimer = null; }
  try { if (session.stopScreencast) await session.stopScreencast(); } catch { /* ignore */ }
  try { await session.page?.close(); } catch { /* ignore */ }
  try { await session.context?.close(); } catch { /* ignore */ }
  session.stopScreencast = null;
  session.cdpSession = null;
  session.page = null;
  session.context = null;

  // Rebuild the context + page under the new device descriptor. This
  // duplicates the binding + addInitScript + framenavigated wiring from
  // `startRecording`; keep the two in sync if you change either.
  try {
    const { contextOpts, viewport: effectiveViewport } = resolveDeviceContext(requested);
    const newContext = await browser.newContext(contextOpts);
    session.context = newContext;
    session.device = requested;
    session.viewport = effectiveViewport;

    const newPage = await _attachAndOpenRecorderPage(session, newContext, currentUrl, effectiveViewport);
    session.page = newPage;
    return {
      device: session.device,
      viewport: session.viewport,
      url: session.url,
    };
  } catch (err) {
    // Rebuild failed — leave the session in a torn-down state so the
    // operator's next call (likely Stop & Save or Discard) hits the
    // "not recording" branch and we don't leak a Chromium process.
    try { await session.context?.close(); } catch { /* ignore */ }
    session.context = null;
    session.page = null;
    session.status = "stopping";
    throw new Error(`Device switch failed: ${err.message}`);
  }
}

/**
 * DIF-015c Gap 5 — private helper that wires the `__sentriRecord` binding
 * + Playwright selector bootstrap + addInitScript + popup + main-page
 * `framenavigated` handlers onto a fresh context, opens a page, navigates
 * to `startUrl`, and starts the CDP screencast at the resolved viewport.
 * Used by {@link switchDevice} after a context teardown.
 *
 * The body below duplicates the inline setup in `startRecording` — keep
 * the two in sync if you change either. A full extraction would require
 * threading the binding closure, pageAliases map, and screencast handle
 * through a single options object, which is more refactor than this PR's
 * scope. The duplication is bounded and clearly documented.
 *
 * Side effects on `session`:
 *   - `session.pageAliases` is reused from the prior context so popup
 *     `popup1`/`popup2` labels stay stable across the switch.
 *   - `session.stopScreencast` + `session.cdpSession` are re-assigned to
 *     the new screencast handle so `forwardInput` resumes routing CDP
 *     events to the rebuilt page.
 *   - `session.url` is updated to the post-navigate landed URL.
 *
 * @param {RecordingSession} session
 * @param {Object} context - Freshly-built Playwright BrowserContext.
 * @param {string} startUrl - URL to navigate the rebuilt page to.
 * @param {{width: number, height: number}} viewport - Effective viewport
 *   from the device descriptor; threaded into `startScreencast`.
 * @returns {Promise<Object>} The newly-created Playwright Page.
 * @private
 */
async function _attachAndOpenRecorderPage(session, context, startUrl, viewport) {
  // Reuse the prior context's alias map so popup labels stay stable.
  // Stale entries (the closed pre-switch main page) are pruned below
  // once the new page is registered.
  const pageAliases = session.pageAliases || new Map();
  session.pageAliases = pageAliases;

  // Re-attach the binding to the new context. The closure captures
  // the same `session` reference, so dedup state (DBLCLICK window,
  // hover collapse, fill collapse) keeps working against
  // `session.actions[]` without reinitialisation.
  await context.exposeBinding("__sentriRecord", (source, action) => {
    if (session.status !== "recording") return;
    if (session.paused === true) 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,
      // SEC-007 — preserve the redaction marker so codegen + step prose
      // can route this fill through `process.env.SENTRI_SECRET_N` instead
      // of emitting the (already-sentinel) value literal. The page-side
      // script sets `redacted: true` only when the value is a
      // `__SENTRI_SECRET_<n>__` placeholder; we also defence-in-depth log
      // a warning here if we ever see a raw credential pattern slip
      // through (a future RECORDER_SCRIPT change that bypasses the
      // sensitivity check), so the leak is caught before persistence.
      redacted: action.redacted === true || 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(),
    };
    // SEC-007 defence-in-depth — if the in-page script forgot to mark a
    // fill on a known-sensitive selector pattern, catch it here at the
    // binding boundary before it lands in session.actions[]. The
    // redaction marker is added retroactively and a warning is logged
    // (value NEVER logged — only the selector + label).
    if (row.kind === "fill" && row.redacted !== true && _looksLikeSecretValue(row.value)) {
      console.error(formatLogLine("warn", null, `[recorder] SEC-007 server-side redaction triggered for selector=${row.selector || "<unknown>"} label=${row.label || "<unlabelled>"} — in-page rule missed this field; review credential heuristic`));
      row.value = "__SENTRI_SECRET_AUTO__";
      row.redacted = true;
    }
    // Dedup heuristics — verbatim copy of the startRecording binding.
    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);
        }
      }
    }
    if (row.kind === "hover") {
      const last = session.actions[session.actions.length - 1];
      if (last && last.kind === "hover") session.actions.pop();
    }
    if (INTERACTION_KINDS.has(row.kind) && row.selector) {
      const last = session.actions[session.actions.length - 1];
      if (last && last.kind === "hover" && last.selector === row.selector) session.actions.pop();
    }
    if (row.kind === "fill" && row.selector) {
      const last = session.actions[session.actions.length - 1];
      if (last && last.kind === "fill" && last.selector === row.selector) session.actions.pop();
    }
    const POINTER_KINDS = new Set(["click", "dblclick", "rightClick", "hover"]);
    if (POINTER_KINDS.has(row.kind) && !row.label) {
      const sel = row.selector || "";
      const hasSemanticSelector = /^(?:role=|text=|data-testid=|label=|placeholder=|alt=|title=)/.test(sel);
      if (!hasSemanticSelector) row._noLabel = true;
    }
    session.actions.push(row);
  });

  // _attachAndOpenRecorderPage continues in the next chunk —
  // addInitScript + page creation + framenavigated + screencast.
  return _finishOpenRecorderPage(session, context, startUrl, viewport, pageAliases);
}

/**
 * Second half of {@link _attachAndOpenRecorderPage} — keeps each function
 * body under the size where editing tools get unhappy. Splits the work
 * AT a natural seam: by this point the `__sentriRecord` binding is
 * registered on the context, so creating new pages is safe. This block
 * handles addInitScripts + page creation + framenavigated listeners +
 * initial navigation + screencast restart.
 *
 * @private
 */
async function _finishOpenRecorderPage(session, context, startUrl, viewport, pageAliases) {
  // DIF-015c Gap 6: re-apply stealth on the rebuilt context so a
  // mid-session device switch doesn't undo the stealth profile the
  // operator opted into at launch. Default-mode (`stealth !== true`)
  // takes the early-return path so pre-Gap-6 behaviour is unchanged.
  if (session.stealth === true) {
    await context.addInitScript(STEALTH_SCRIPT);
  }

  // Inject Playwright's own InjectedScript bootstrap before our recorder
  // script so `window.__playwrightSelector` is populated by the time
  // selectorGenerator() runs on the first user interaction. Mirrors the
  // startRecording flow; see the longer comment there for the rationale.
  let bootstrap = "";
  try { bootstrap = buildInjectedBootstrapScript(); }
  catch (err) {
    console.error(formatLogLine("warn", null, `[recorder] buildInjectedBootstrapScript failed during device switch — falling back to hand-rolled selectorGenerator: ${err.message}`));
  }
  if (bootstrap) await context.addInitScript(bootstrap);
  await context.addInitScript(RECORDER_SCRIPT);

  // Drop stale page refs from the prior context (closed main page,
  // closed popups) before wiring the new page in, so popup labels
  // recompute against the live page count.
  for (const k of Array.from(pageAliases.keys())) {
    try { if (!k || (typeof k === "object" && k.isClosed?.())) pageAliases.delete(k); }
    catch { pageAliases.delete(k); }
  }

  const page = await context.newPage();
  pageAliases.set(page, "page");

  page.on("pageerror", (err) => {
    if (err && err.message && err.message.includes("sentri-recorder")) {
      console.error(formatLogLine("warn", null, `[recorder/page-error] ${err.message}`));
    }
  });
  context.on("page", (p) => {
    if (pageAliases.has(p)) return;
    pageAliases.set(p, `popup${Math.max(1, pageAliases.size)}`);
    p.on("framenavigated", (frame) => {
      if (session.paused === true) return;
      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() });
      }
    });
  });

  // Navigate to the snapshotted URL so the operator lands back where
  // they were before the device switch. The post-navigate URL drives
  // the recorded `goto` action so any server-side redirect (HTTPS
  // upgrade, locale prefix) is captured truthfully.
  await page.goto(startUrl, { waitUntil: "domcontentloaded", timeout: NAVIGATION_TIMEOUT }).catch(() => {});
  const landedUrl = page.url() || startUrl;
  session.url = landedUrl;
  // Push a `goto` so the recorded steps reflect the device-switch
  // navigation — without this, the rebuilt page renders silently and
  // the operator's first post-switch click looks like it happened on
  // the pre-switch URL in the generated playwrightCode.
  session.actions.push({ kind: "goto", pageAlias: "page", url: landedUrl, ts: Date.now() });

  // Debounced main-page framenavigated — verbatim copy of the
  // startRecording handler, with the same pause guard.
  //
  // DIF-015c Gap 5 follow-up — track the timer on `session` (instead of
  // closure-local) so `switchDevice` can clear it before tearing the
  // page down. Without this, an in-flight 800ms debounce that started
  // BEFORE the device switch fires AFTER the rebuilt page is alive and
  // pushes a stale `goto` for the pre-switch URL into `session.actions`.
  // The dedup guard at the timer fire ("last.url === pendingFrameUrl")
  // only catches the case where the new page happens to land on the same
  // URL the prior debounce captured — common but not guaranteed.
  // `stopRecording` doesn't need this because it flips
  // `session.status = "stopping"` before closing the page, and the
  // status guard at timer-fire time catches the stale fire there.
  const FRAME_NAV_DEBOUNCE_MS = 800;
  let pendingFrameUrl = "";
  page.on("framenavigated", (frame) => {
    if (frame !== page.mainFrame()) return;
    const url = frame.url();
    if (!url || url === "about:blank") return;
    pendingFrameUrl = url;
    if (session.frameNavTimer) { clearTimeout(session.frameNavTimer); session.frameNavTimer = null; }
    session.frameNavTimer = setTimeout(() => {
      session.frameNavTimer = null;
      if (session.status !== "recording") return;
      if (session.paused === true) return;
      const last = [...session.actions].reverse().find((a) => a.kind === "goto");
      if (last && last.url === pendingFrameUrl) return;
      session.actions.push({ kind: "goto", pageAlias: "page", url: pendingFrameUrl, ts: Date.now() });
    }, FRAME_NAV_DEBOUNCE_MS);
  });

  // Restart the CDP screencast at the new viewport so the canvas
  // resizes to native device dimensions — LiveBrowserView already
  // rescales pointer coordinates against the viewport prop, so the
  // frontend just needs to update its `viewport` state from the
  // route response.
  const screencastResult = await startScreencast(page, session.id, {
    interactive: true,
    viewport,
  });
  if (screencastResult) {
    session.stopScreencast = screencastResult.stop;
    session.cdpSession = screencastResult.cdpSession;
  }

  return page;
}

/**
 * DIF-015c Gap 5 — pure helper that resolves a device name to a
 * `browser.newContext()` options object plus the effective viewport.
 * Mirrors the device-descriptor merge already done by `executeTest.js`
 * (`backend/src/runner/executeTest.js:208-232`) so recorder runs feel
 * identical to test runs at the same device profile.
 *
 * Empty / unknown device names fall back to the desktop defaults so a
 * caller that never opts into device emulation behaves bit-for-bit
 * identically to the pre-Gap-5 recorder.
 *
 * @param {string} [device] - One of `DEVICE_PRESETS[].value` (curated list).
 * @returns {{ contextOpts: Object, viewport: {width: number, height: number}, descriptor: Object|null }}
 */
function resolveDeviceContext(device) {
  const descriptor = resolveDevice(device || "");
  const viewport = descriptor?.viewport || { width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT };
  // Spread the descriptor first so explicit overrides below take precedence
  // — same merge order as `executeTest.js`. Without `viewport` overridden
  // explicitly, a missing descriptor would leave the context at Playwright's
  // default 1280×720 even when the caller asked for desktop, which is
  // already what we want; the explicit assignment keeps the contract
  // observable to readers.
  const contextOpts = {
    ...(descriptor || {}),
    viewport,
    ignoreHTTPSErrors: true,
    acceptDownloads: true,
  };
  return { contextOpts, viewport, descriptor: descriptor || null };
}

/**
 * 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
 * @param {string} [args.device]    - DIF-015c Gap 5: optional device name
 *   from `DEVICE_PRESETS` (e.g. `"iPhone 14"`). Empty string / undefined →
 *   desktop default (legacy behaviour). Unknown values are rejected at the
 *   route layer; this helper assumes the caller has already validated.
 * @param {boolean} [args.stealth=false] - DIF-015c Gap 6: when true, the
 *   `STEALTH_SCRIPT` is installed via `context.addInitScript` BEFORE the
 *   recorder script and main page navigation, so `navigator.webdriver`
 *   reads as `undefined` (and four other surfaces look real-Chrome-ish)
 *   from the very first byte of the SUT. Default false → no init script
 *   is registered for stealth; pre-Gap-6 behaviour bit-for-bit unchanged.
 * @returns {Promise<RecordingSession>}
 */
export async function startRecording({ sessionId, projectId, startUrl, device = "", stealth = false }) {
  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 {
    const { contextOpts, viewport: effectiveViewport } = resolveDeviceContext(device);
    context = await browser.newContext(contextOpts);

    const session = /** @type {RecordingSession} */ ({
      id: sessionId,
      projectId,
      url: startUrl,
      status: "recording",
      actions: [],
      startedAt: Date.now(),
      device: device || "",
      viewport: effectiveViewport,
      stealth: !!stealth,
      browser,
      context,
    });

    const pageAliases = new Map();
    session.pageAliases = pageAliases;

    // CRITICAL ORDERING: `exposeBinding` and `addInitScript` only apply to
    // pages / documents created AFTER they are registered. If we call
    // `context.newPage()` before these, the resulting page never has
    // `window.__sentriRecord` installed and `RECORDER_SCRIPT` may not run
    // on its initial document — the symptom is the recorder only emitting
    // `goto` actions (from the Node-side `framenavigated` listener) while
    // every click/fill/keypress is silently dropped because the in-page
    // emit guard `window.__sentriRecord && …` is falsy.
    //
    // Register the binding and init scripts on the context FIRST, then
    // create the page.

    // Expose a binding for the injected script to relay captured events.
    await context.exposeBinding("__sentriRecord", (source, action) => {
      if (session.status !== "recording") return;
      // While paused, drop all in-page-captured actions. `forwardInput`
      // already short-circuits user-initiated CDP input at the route
      // layer, but the page can still emit events from in-flight work
      // that started before pause (debounced fills flushing, framework
      // re-renders firing change handlers, programmatic clicks fired by
      // page JS, …). Without this guard those would leak into
      // `session.actions[]` and surprise the operator at replay time.
      if (session.paused === true) 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,
        // SEC-007 — propagate the redaction marker (set by the in-page
        // script when the source field matched isSensitiveField). The
        // server-side check below catches the case where the in-page
        // rule missed (rare — only fires for SUTs that use a text input
        // for credentials AND don't match the name/id heuristic).
        redacted: action.redacted === true || 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);
          }
        }
      }
      // Strip consecutive hovers — when a new hover arrives and the last
      // recorded action is ALSO a hover, replace it in-place. A hover chain
      // produced by the user sweeping the mouse across the page (A → B → C)
      // is always noise; only the final resting position before a real
      // interaction is meaningful. Replacing instead of appending keeps the
      // steps list clean without discarding intentional hovers (tooltip
      // triggers, dropdown openers) — the last hover before a pause is still
      // preserved.
      if (row.kind === "hover") {
        const last = session.actions[session.actions.length - 1];
        if (last && last.kind === "hover") {
          session.actions.pop();
        }
      }
      // Drop noisy hover actions that immediately precede a real interaction
      // on the same selector. The in-page `HOVER_DWELL_MS` filter catches
      // drive-by mouseovers, but a user pausing on a button before clicking
      // it (very common — that's what "aim and click" looks like) still
      // produces a junk `hover` action right before the `click`. Strip the
      // trailing hover when the very next action is an interaction on the
      // same target so the captured step list reflects user intent rather
      // than mouse mechanics. `INTERACTION_KINDS` is defined at module
      // scope above — don't re-allocate it per event.
      if (INTERACTION_KINDS.has(row.kind) && row.selector) {
        const last = session.actions[session.actions.length - 1];
        if (last && last.kind === "hover" && last.selector === row.selector) {
          session.actions.pop();
        }
      }
      // Collapse consecutive `fill` actions on the same selector — the
      // in-page 300 ms debounce emits one fill per typing pause, so
      // typing "iphone" with a micro-pause after "i" produces two
      // steps: `fill 'i'` then `fill 'iphone'`. The second supersedes
      // the first (same field, final value wins), so drop the earlier
      // row in place instead of appending a new one. Matches the
      // consecutive-hover dedup pattern above.
      if (row.kind === "fill" && row.selector) {
        const last = session.actions[session.actions.length - 1];
        if (last && last.kind === "fill" && last.selector === row.selector) {
          session.actions.pop();
        }
      }
      // Tag `click` / `dblclick` / `rightClick` / `hover` rows that arrive
      // without a friendly label AND without a semantic selector prefix
      // (role=, text=, data-testid=, label=, placeholder=, alt=, title=).
      // These come from layout-container clicks with no accessible name,
      // and rendering them as bare "Click" / "Hover over" entries in the
      // Steps panel is noise that hurts step-quality. We keep the row in
      // `session.actions` (so the CSS-fallback selector still drives
      // `playwrightCode` replay) but mark it with `_noLabel: true` so the
      // sidebar / persisted `steps[]` formatter can hide it from the
      // human-readable view. Replay fidelity AND step quality both
      // preserved — earlier revisions dropped the row entirely, which
      // silently broke replay for icon-only buttons.
      const POINTER_KINDS = new Set(["click", "dblclick", "rightClick", "hover"]);
      if (POINTER_KINDS.has(row.kind) && !row.label) {
        const sel = row.selector || "";
        const hasSemanticSelector = /^(?:role=|text=|data-testid=|label=|placeholder=|alt=|title=)/.test(sel);
        if (!hasSemanticSelector) {
          row._noLabel = true;
        }
      }
      session.actions.push(row);
    });
    // Inject Playwright's own InjectedScript bootstrap before our
    // recorder script so `window.__playwrightSelector` is populated by
    // the time selectorGenerator() runs on the first user interaction.
    // `addInitScript` calls run in registration order, and the empty
    // string from `buildInjectedBootstrapScript()` (when the bundle
    // can't be loaded) is a no-op — addInitScript accepts empty strings
    // without complaint, but skip the call to keep the page-init log
    // clean.
    // Defence-in-depth: if `buildInjectedBootstrapScript()` throws (e.g.
    // playwright-core layout drift), swallow the error so the recorder
    // script below is still registered. Without this guard a thrown
    // bootstrap would skip `addInitScript(RECORDER_SCRIPT)` entirely and
    // the page would have no `window.__sentriRecord` binding — the
    // symptom is the recorder only emitting `goto` actions while every
    // click/fill/keypress is silently dropped.
    // DIF-015c Gap 6: install the stealth bootstrap BEFORE the
    // Playwright selector bootstrap and recorder script so
    // `navigator.webdriver` reads as `undefined` from the very first
    // byte of the SUT. Default-mode (`stealth: false`) skips this
    // entirely — pre-Gap-6 behaviour is bit-for-bit identical.
    // Without the early-bird ordering, a target site can read
    // `navigator.webdriver` synchronously during its bootstrap
    // (`<head><script>if (navigator.webdriver) location = "/blocked"</script>`)
    // before our addInitScript runs.
    if (stealth === true) {
      await context.addInitScript(STEALTH_SCRIPT);
      console.log(formatLogLine("info", null, `[recorder] stealth profile enabled for session=${sessionId}`));
    }

    let bootstrap = "";
    try { bootstrap = buildInjectedBootstrapScript(); }
    catch (err) {
      console.error(formatLogLine("warn", null, `[recorder] buildInjectedBootstrapScript failed — falling back to hand-rolled selectorGenerator: ${err.message}`));
    }
    if (bootstrap) await context.addInitScript(bootstrap);

    await context.addInitScript(RECORDER_SCRIPT);

    // Now that the binding + init scripts are registered on the context,
    // create the page. Both will be applied to this page's documents,
    // including the initial about:blank and the upcoming `page.goto`.
    page = await context.newPage();
    pageAliases.set(page, "page");
    session.page = page;

    // Surface in-page recorder init failures via the backend log. The IIFE
    // in `RECORDER_SCRIPT` is wrapped in try/catch and emits a
    // `[sentri-recorder] init failed:` console.error on any thrown listener
    // setup; piping page console here lets us notice silently broken
    // recordings (no actions captured) instead of having to attach a
    // debugger.
    page.on("pageerror", (err) => {
      if (err && err.message && err.message.includes("sentri-recorder")) {
        console.error(formatLogLine("warn", null, `[recorder/page-error] ${err.message}`));
      }
    });
    context.on("page", (p) => {
      if (pageAliases.has(p)) return;
      pageAliases.set(p, `popup${Math.max(1, pageAliases.size)}`);
      p.on("framenavigated", (frame) => {
        if (session.paused === true) return;
        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() });
        }
      });
    });

    // Navigate to the starting URL and record it as the first action.
    // We capture the actual landed URL (after any server-side redirects)
    // from the page rather than the caller-supplied `startUrl` so the
    // generated goto reflects the canonical entry point.
    //
    // Also promote `session.url` to the landed URL so every downstream
    // consumer that dedups against the first goto (`actionsToPlaywrightCode`'s
    // hardcoded `await page.goto(startUrl)` + `lastGotoUrl` seed, and
    // `routes/tests.js`'s persisted-steps dedup loop) sees the same URL the
    // recorded `actions[0]` carries. Without this, any redirect at launch
    // (http→https, apex→www, OAuth callback) leaves the pre-redirect URL
    // seeded as `lastGotoUrl` while `actions[0].url` is the post-redirect
    // value — the inequality bypasses dedup and the generated test emits
    // two consecutive `page.goto(...)` calls for what is one navigation.
    await page.goto(startUrl, { waitUntil: "domcontentloaded", timeout: NAVIGATION_TIMEOUT }).catch(() => {});
    const landedUrl = page.url() || startUrl;
    session.url = landedUrl;
    session.actions.push({ kind: "goto", pageAlias: "page", url: landedUrl, ts: Date.now() });

    // Debounced framenavigated handler — fires for EVERY step in a redirect
    // chain (including intermediate tracking hops like /sorry/index). Without
    // debouncing, a search-form submit that redirects three times before
    // settling on the results page produces three consecutive goto steps, all
    // of which show up in the sidebar as "Navigate to …". We defer the push
    // by FRAME_NAV_DEBOUNCE_MS so only the URL the browser actually lands on
    // after the chain has settled is recorded. Each new framenavigated event
    // during the window resets the timer and promotes the newer URL — the
    // final flush captures the canonical destination.
    //
    // Dedup: if the settled URL matches the last recorded goto (e.g. the
    // listener fires for the initial page.goto that already pushed an action
    // above, or a hash-only change on an SPA), the action is silently dropped.
    // DIF-015c Gap 5 follow-up — the timer lives on `session` (not as a
    // closure-local variable) so `switchDevice` can clear it before
    // tearing the page down. See the matching block in
    // `_finishOpenRecorderPage` for the full rationale.
    const FRAME_NAV_DEBOUNCE_MS = 800;
    let pendingFrameUrl = "";
    page.on("framenavigated", (frame) => {
      if (frame !== page.mainFrame()) return;
      const url = frame.url();
      if (!url || url === "about:blank") return;
      pendingFrameUrl = url;
      if (session.frameNavTimer) { clearTimeout(session.frameNavTimer); session.frameNavTimer = null; }
      session.frameNavTimer = setTimeout(() => {
        session.frameNavTimer = null;
        if (session.status !== "recording") return;
        // Pause also drops debounced framenavigated flushes — a
        // navigation that started before pause but settled after should
        // not produce a recorded `goto` (matches the `__sentriRecord`
        // binding guard above; consistent operator-facing model).
        if (session.paused === true) return;
        // Deduplicate: skip if the settled URL is the same as the last
        // recorded goto so initial-page echoes and trivial hash changes
        // don't produce spurious Navigate steps in the sidebar.
        const last = [...session.actions].reverse().find((a) => a.kind === "goto");
        if (last && last.url === pendingFrameUrl) return;
        session.actions.push({ kind: "goto", pageAlias: "page", url: pendingFrameUrl, ts: Date.now() });
      }, FRAME_NAV_DEBOUNCE_MS);
    });

    // 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.
    // DIF-015c Gap 5: pass the resolved viewport so iPhone / Pixel device
    // profiles stream JPEG frames at native resolution (390×844 for an
    // iPhone 14) instead of being letterboxed inside the desktop default.
    const screencastResult = await startScreencast(page, sessionId, {
      interactive: true,
      viewport: effectiveViewport,
    });
    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
  if (session.paused === true) return;

  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,
      });
    } else if (type === "shortcutCapture") {
      await session.page?.evaluate((budget) => {
        if (typeof window.__sentriRecorderSetShortcutBudget === "function") {
          window.__sentriRecorderSetShortcutBudget(budget);
        }
      }, event?.count ?? 3);
    }
  } 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; }
    // DIF-015c Gap 5 follow-up — defence-in-depth, the timer's status
    // guard at fire time already catches stale fires here (we just set
    // `session.status = "stopping"` above), but clearing eagerly avoids
    // a useless Node-side setTimeout sitting around for up to 800ms.
    if (session.frameNavTimer) { clearTimeout(session.frameNavTimer); session.frameNavTimer = 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 };
}