Module: runner/recorder

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

  • startRecording — Launch browser + begin capture.
  • stopRecording — Stop capture; return { actions, playwrightCode }.
  • 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.

Source:

Members

(inner, constant) ACCEPTED_DEVICE_NAMES

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.

Source:

(inner, constant) INTERACTION_KINDS

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.

Source:

(inner, constant) RECORDER_SCRIPT

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.

Source:

(inner, constant) STEALTH_SCRIPT

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.

Source:

(inner, constant) TIMINGS

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.

Source:

(inner, constant) completedSessions :Map.<string, CompletedRecording>

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>
Source:

(inner, constant) sessions :Map.<string, RecordingSession>

Type:
  • Map.<string, RecordingSession>
Source:

Methods

(static) actionsToPlaywrightCode(testName, startUrl, actions) → {string}

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.

Parameters:
Name Type Description
testName string
startUrl string
actions Array.<RecordedAction>
Source:
Returns:

Playwright source code.

Type
string

(static) addAssertionAction(sessionId, action) → {RecordedAction}

Append a manual assertion action to an in-flight recording session. Mirrors Playwright recorder's explicit "Add assertion" flow.

Parameters:
Name Type Description
sessionId string
action RecordedAction
Source:
Returns:
Type
RecordedAction

(static) filterEmittableActions(actions) → {Array.<RecordedAction>}

Filter a list of captured actions down to the ones the code generator would actually emit. Convenience wrapper around isEmittableAction.

Parameters:
Name Type Description
actions Array.<RecordedAction>
Source:
Returns:
Type
Array.<RecordedAction>

(static) forwardInput(sessionId, event) → {Promise.<void>}

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)

Parameters:
Name Type Description
sessionId string
event Object
Properties
Name Type Attributes Description
type "mousePressed" | "mouseReleased" | "mouseMoved" | "keyDown" | "keyUp" | "char" | "scroll"
x number <optional>

Viewport x (already scaled by caller).

y number <optional>

Viewport y (already scaled by caller).

button number <optional>

DOM MouseEvent.button: 0=left, 1=middle, 2=right. Pass undefined (omit) for moves with no button held — CDP requires "none" then.

clickCount number <optional>

1 for single click.

deltaX number <optional>

Horizontal scroll delta.

deltaY number <optional>

Vertical scroll delta.

key string <optional>

DOM key name, e.g. "Enter".

code string <optional>

DOM code, e.g. "KeyA".

keyCode number <optional>

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.

text string <optional>

Printable text for char events.

modifiers number <optional>

Bitmask: Alt=1, Ctrl=2, Meta=4, Shift=8.

Source:
Throws:

When the session is not found or has no CDP session.

Type
Error
Returns:
Type
Promise.<void>

(static) getRecording(sessionId) → {RecordingSession|null}

Look up an in-flight recording session.

Parameters:
Name Type Description
sessionId string
Source:
Returns:
Type
RecordingSession | null

(static) isEmittableAction(a) → {boolean}

Predicate matching the required-field branches in 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 actionsToPlaywrightCode, add the matching branch here too.

Parameters:
Name Type Description
a RecordedAction
Source:
Returns:
Type
boolean

(static) isNoisyTestId(value) → {boolean}

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.

Parameters:
Name Type Description
value string

Raw data-testid attribute value.

Source:
Returns:

true when the value looks noisy and should be demoted.

Type
boolean

(static) pauseRecording(sessionId) → {Object}

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. forwardInput — short-circuits CDP dispatch so user clicks / keystrokes from the canvas overlay never reach the page.
  2. The __sentriRecord exposeBinding callback in 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.
Parameters:
Name Type Description
sessionId string
Source:
Throws:

when the session is unknown or not in "recording" state.

Type
Error
Returns:
Type
Object

(static) popLastRecordingAction(sessionId) → {Object}

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.

Parameters:
Name Type Description
sessionId string
Source:
Throws:

when the session is unknown or not in "recording" state.

Type
Error
Returns:
Type
Object

(static) probeAtPoint(sessionId, point) → {Promise.<({selector: string, label: string, rect: {x: number, y: number, width: number, height: number}}|null)>}

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.

Parameters:
Name Type Description
sessionId string
point Object

Viewport coordinates (already scaled by the frontend from CSS pixels via LiveBrowserView.scaleCoords).

Source:
Throws:

when the session is unknown or not recording.

Type
Error
Returns:

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.

Type
Promise.<({selector: string, label: string, rect: {x: number, y: number, width: number, height: number}}|null)>

(static) recordedActionToStepText(a) → {string}

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.

Parameters:
Name Type Description
a RecordedAction
Source:
Returns:

A single step sentence suitable for the persisted steps[] array.

Type
string

(static) resumeRecording(sessionId) → {Object}

DIF-015c Gap 3 — resume action capture after a pause. Idempotent on a session that was never paused (the flag was already falsy). See pauseRecording for the list of guarded call sites.

Parameters:
Name Type Description
sessionId string
Source:
Throws:

when the session is unknown or not in "recording" state.

Type
Error
Returns:
Type
Object

(static) startRecording(args) → {Promise.<RecordingSession>}

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).

Parameters:
Name Type Description
args Object
Properties
Name Type Attributes Default Description
sessionId string

Unique ID used for SSE + session tracking.

projectId string
startUrl string
device string <optional>

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.

stealth boolean <optional>
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.

Source:
Returns:
Type
Promise.<RecordingSession>

(static) stopRecording(sessionId, optsopt) → {Promise.<{actions: Array.<RecordedAction>, playwrightCode: string, url: string}>}

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.

Parameters:
Name Type Attributes Description
sessionId string
opts Object <optional>
Properties
Name Type Attributes Description
testName string <optional>

Optional name to embed in the generated test.

Source:
Throws:

When the session does not exist.

Type
Error
Returns:
Type
Promise.<{actions: Array.<RecordedAction>, playwrightCode: string, url: string}>

(static) switchDevice(sessionId, device) → {Promise.<{device: string, viewport: {width: number, height: number}, url: string}>}

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).
Parameters:
Name Type Description
sessionId string
device string

One of DEVICE_PRESETS[].value (empty string = desktop default). Validated against ACCEPTED_DEVICE_NAMES; unknown values throw.

Source:
Throws:

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).

Type
Error
Returns:
Type
Promise.<{device: string, viewport: {width: number, height: number}, url: string}>

(static) takeCompletedRecording(sessionId) → {CompletedRecording|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.

Parameters:
Name Type Description
sessionId string
Source:
Returns:
Type
CompletedRecording | null

(inner) _looksLikeSecretValue(value) → {boolean}

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.

Parameters:
Name Type Description
value string | undefined

Raw value about to be persisted.

Source:
Returns:

true when the value matches a known credential pattern.

Type
boolean

(inner) _luhnValid(digits) → {boolean}

SEC-007 — Luhn check for credit card validation. Returns true when the digits-only string passes the standard mod-10 checksum.

Parameters:
Name Type Description
digits string

Digits-only string (caller must strip spaces/dashes).

Source:
Returns:
Type
boolean

(inner) escapeJsSingleQuote(str) → {string}

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.

Parameters:
Name Type Description
str string
Source:
Returns:
Type
string

(inner) friendlyTarget(a) → {string}

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.

Parameters:
Name Type Description
a RecordedAction
Source:
Returns:

Either the "<label>" <noun>, "<label>", or "".

Type
string

(inner) friendlyTargetFromSelector(selector, nounopt) → {string}

Same as 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.

Parameters:
Name Type Attributes Description
selector string

Raw selector to extract a friendly name from.

noun string <optional>

Element noun ("element", "column").

Source:
Returns:
  • the '<name>' <noun>, '<name>', or "".
Type
string

(inner) resolveDeviceContext(deviceopt) → {Object}

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.

Parameters:
Name Type Attributes Description
device string <optional>

One of DEVICE_PRESETS[].value (curated list).

Source:
Returns:
Type
Object

(inner) shortUrl()

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.

Source:

Type Definitions

CompletedRecording

Type:
  • Object
Properties:
Name Type Description
projectId string
actions Array.<RecordedAction>
playwrightCode string
url string
completedAt number
reason "auto_timeout" | "manual"
Source:

RecordedAction

Type:
  • Object
Properties:
Name Type Attributes Description
kind "goto" | "click" | "dblclick" | "rightClick" | "hover" | "fill" | "press" | "select" | "check" | "uncheck" | "upload" | "drag" | "assertVisible" | "assertText" | "assertValue" | "assertUrl" | "assertCount" | "assertHasClass"
selector string <optional>

Best-effort role/label/text/css selector.

label string <optional>

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.

value string <optional>

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).

redacted boolean <optional>

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].

url string <optional>

For goto.

key string <optional>

For press.

frameUrl string <optional>

URL of iframe containing the action.

pageAlias string <optional>

"page" for main tab, "popupN" for popups.

target string <optional>

For drag/drop target selector.

ts number

Epoch ms when the action was captured.

Source:

RecordingSession

Type:
  • Object
Properties:
Name Type Attributes Description
id string
projectId string
url string

Starting URL.

status "recording" | "stopping" | "stopped"
actions Array.<RecordedAction>
startedAt number
device string <optional>

DIF-015c Gap 5: active device profile (e.g. "iPhone 14"); empty string or undefined → desktop default.

viewport Object <optional>

Resolved viewport (from device descriptor, falling back to VIEWPORT_WIDTH/HEIGHT).

paused boolean <optional>

DIF-015c Gap 3: pause flag.

stealth boolean <optional>

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.

browser Object <optional>

Playwright Browser (internal).

context Object <optional>

Playwright BrowserContext (internal).

page Object <optional>

Playwright Page (internal).

stopScreencast function <optional>

Cleanup fn returned by startScreencast.

cdpSession Object <optional>

CDP session for input forwarding.

frameNavTimer * <optional>

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.

Source: