/** * Deterministic fast-replay for `rp ai-test`. * * The slow path is the AI agent (claude-driver.ts): it DISCOVERS the route to a * product-module surface and strictly evaluates `scenario_expected`. On a PASS * it emits a durable navigation+assertion skeleton (scenario-cache.ts) — role + * accessible name, visible text, field labels, URLs, never ephemeral `e17` refs. * * This module is the fast path: given that cached skeleton it re-executes the * exact same steps with RAW Playwright (no LLM) in seconds. Each step carries a * STRUCTURED locator (kind + role/name/text/label/…), so it maps straight onto a * single `page.getBy*` call with no parsing or guessing, wrapped in a * poll-until-present loop so an in-flight page settles. * * Self-heal contract: if ANY step can't be resolved/executed, replay STOPS and * returns `heal-needed`. The caller (ai-test.ts) then falls back to the full AI * agent for that one scenario, which re-discovers the route AND re-evaluates * `scenario_expected` from scratch. That fallback is the safety net — a genuine * product-module regression surfaces there as a strict fail, so healing can * never mask a broken assertion; it only absorbs incidental navigation drift. */ import path from 'node:path'; import { assertAllowedDashboardHost, assertSandboxModeEnabled, ProductionGuardError, sandboxFlagKey, } from './host-guard'; import type { TraceLocator, TraceStep } from './scenario-cache'; /** Structural subset of Playwright's Locator we drive — keeps replay testable. */ export interface ReplayLocatorLike { first(): ReplayLocatorLike; nth(index: number): ReplayLocatorLike; /** Narrow the matched set to elements containing `hasText` (scoping). */ filter(opts: { hasText?: string }): ReplayLocatorLike; count(): Promise; isVisible(): Promise; isEnabled(): Promise; click(opts?: { timeout?: number }): Promise; fill(value: string, opts?: { timeout?: number }): Promise; selectOption(value: string, opts?: { timeout?: number }): Promise; hover(opts?: { timeout?: number }): Promise; /** Read a DOM attribute (used to canonicalise a resolved locator to its id). */ getAttribute(name: string): Promise; } /** Structural subset of Playwright's Video we drive (replay video capture). */ export interface ReplayVideoLike { saveAs(target: string): Promise; delete(): Promise; } /** Structural subset of Playwright's Page we drive. */ export interface ReplayPageLike { goto(url: string, opts?: { waitUntil?: string; timeout?: number }): Promise; getByRole(role: string, opts?: { name?: string; exact?: boolean }): ReplayLocatorLike; getByText(text: string, opts?: { exact?: boolean }): ReplayLocatorLike; getByLabel(text: string, opts?: { exact?: boolean }): ReplayLocatorLike; getByPlaceholder(text: string): ReplayLocatorLike; getByTestId(testid: string): ReplayLocatorLike; locator(selector: string): ReplayLocatorLike; screenshot(opts: { path: string }): Promise; video(): ReplayVideoLike | null; /** Run a fn inside the page (used to re-read the sandbox flag at replay time). */ evaluate(fn: (key: string) => string | null, arg: string): Promise; } export interface ReplayContextLike { newPage(): Promise; /** Closing the context finalizes any recorded video so its file can be saved. */ close(): Promise; } export interface ReplayBrowserLike { newContext(opts?: { storageState?: string; recordVideo?: { dir: string } }): Promise; close(): Promise; } export const REPLAY_SCREENSHOT_FILENAME = 'replay.png'; export const REPLAY_VIDEO_FILENAME = 'replay.webm'; export type ReplayStatus = 'replayed' | 'heal-needed'; export interface ReplayResult { status: ReplayStatus; /** How many steps executed successfully (all of them when `replayed`). */ stepsRun: number; /** The step that couldn't be resolved/executed (only when `heal-needed`). */ failedStep?: TraceStep; /** Human-readable reason for the heal (only when `heal-needed`). */ reason?: string; /** Screenshot of the final state, if one was captured. */ screenshot?: string; /** Video of the whole replay, if one was captured. */ video?: string; /** * The skeleton actually executed, with each resolved actionable locator * rewritten to the element's real DOM id (canonicalisation). Only present on a * `replayed` run; the caller re-persists it when it differs from the cache so * the skeleton converges to deterministic `#id` selectors after one pass. */ steps?: TraceStep[]; durationMs: number; } export interface ReplayTraceParams { steps: TraceStep[]; /** Pre-seeded authenticated session, same file every scenario's MCP browser uses. */ storageStatePath: string; /** * The product module under test (e.g. `root_funeral`). Lets the engine resolve * the one click the a11y recorder can never pin — selecting the product in the * catalog. Each catalog "Add" control shares the accessible name "Add", so the * recorder captures the product's display NAME (an inert heading), which matches * many elements and none actionable. The platform gives the add control a * stable, module-keyed id (`#add-product-module-key--button`), so with the * key the catalog click id-anchors deterministically like every other step. */ moduleKey?: string; /** * Org whose `${orgId}_sandbox` localStorage flag the dashboard reads to decide * sandbox-vs-production. login.ts sets+verifies it before snapshotting the * session, but the same host serves production data, so the replay browser * re-reads and re-asserts it on its OWN context (after the first navigate) * before any data-mutating step runs — defence-in-depth on the one invariant * that keeps an AI agent off production. When omitted the re-assert is skipped * (login-time guard still applies); production runs must always pass it. */ organizationId?: string; /** Where to drop the end-of-replay screenshot. */ outputDir: string; /** Capture a replay.webm of the whole run. Defaults to true. */ recordVideo?: boolean; /** Per-step budget for the poll-until-resolved loop. */ stepTimeoutMs?: number; /** Override for tests. Production leaves undefined → headless Chromium. */ launchBrowser?: () => Promise; /** Override the poll cadence (tests pass 0 to avoid real waits). */ sleep?: (ms: number) => Promise; } // A step polls until its element is present, up to this budget, before it heals. // Kept generous (not 10s) because real dashboard fields render behind async work // — e.g. the policyholder `#id_number` input only mounts after the quote→details // step's network round-trip — and a too-tight budget heals a healthy scenario // (slow re-record + no video) instead of just waiting a beat longer. Overridable // per run via `rp ai-test --step-timeout `. const DEFAULT_STEP_TIMEOUT_MS = 15_000; const POLL_INTERVAL_MS = 250; // `expect` steps often assert on async confirmations (e.g. policy-lifecycle // activity-log entries that the backend writes a few seconds after an action), // so a presence check that fits an action's budget can still flap. Give expect // steps a wider budget — scaled off the action budget so tests' tiny overrides // stay tiny — to stop healthy scenarios healing on the slow trailing assertion. const EXPECT_TIMEOUT_MULTIPLIER = 3; const defaultSleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); const defaultLaunchBrowser = async (): Promise => { // Lazy import so the CLI doesn't pay Playwright's load cost unless replay runs. const { chromium } = await import('playwright'); return (await chromium.launch({ headless: true })) as unknown as ReplayBrowserLike; }; /** Human-readable one-liner for a structured locator (logs / heal reasons). */ export const describeLocator = (l: TraceLocator): string => { const hasTextPart = l.hasText === undefined ? '' : ` hasText="${l.hasText}"`; switch (l.kind) { case 'url': { return `url=${l.url}`; } case 'role': { const namePart = l.name === undefined ? '' : ` name="${l.name}"`; const exactPart = l.exact ? ' exact' : ''; return `role=${l.role}${namePart}${exactPart}${hasTextPart}`; } case 'text': { return `text="${l.text}"${l.exact ? ' exact' : ''}${hasTextPart}`; } case 'label': { return `label="${l.label}"${l.exact ? ' exact' : ''}${hasTextPart}`; } case 'placeholder': { return `placeholder="${l.placeholder}"`; } case 'testid': { return `testid=${l.testid}`; } case 'css': { return `css=${l.css}`; } } }; /** * Map a structured locator straight onto a Playwright getBy* call. No parsing, * no guessing — the recording agent committed to exactly one primitive, so * replay resolves it deterministically. Returns null only for a `url` locator * (navigation is handled by the caller, not resolved as an element). */ export const resolveLocator = (page: ReplayPageLike, l: TraceLocator): ReplayLocatorLike | null => { const base = resolveBaseLocator(page, l); // `hasText` is an engine-synthesised scoping refinement (duplicate-row // disambiguation): narrow the base match set to the element also containing it. if (base !== null && l.hasText !== undefined) return base.filter({ hasText: l.hasText }); return base; }; const resolveBaseLocator = (page: ReplayPageLike, l: TraceLocator): ReplayLocatorLike | null => { switch (l.kind) { case 'url': { return null; } case 'role': { return page.getByRole(l.role as string, { ...(l.name === undefined ? {} : { name: l.name }), ...(l.exact === undefined ? {} : { exact: l.exact }), }); } case 'text': { return page.getByText(l.text as string, l.exact === undefined ? undefined : { exact: l.exact }); } case 'label': { return page.getByLabel(l.label as string, l.exact === undefined ? undefined : { exact: l.exact }); } case 'placeholder': { return page.getByPlaceholder(l.placeholder as string); } case 'testid': { return page.getByTestId(l.testid as string); } case 'css': { return page.locator(l.css as string); } } }; /** Actions that mutate the page — these must resolve to exactly one element. */ const ACTIONABLE: ReadonlySet = new Set(['click', 'fill', 'select', 'hover']); /** * How long a single step waits to resolve before the replay heals. An `expect` * step gets a wider budget than an action: it usually asserts on an async * confirmation that settles a few seconds after the action that triggered it, so * a healthy scenario shouldn't heal just because the slow trailing assertion * outran an action-sized budget. */ export const stepBudgetMs = (action: TraceStep['action'], stepTimeoutMs: number): number => ACTIONABLE.has(action) ? stepTimeoutMs : stepTimeoutMs * EXPECT_TIMEOUT_MULTIPLIER; /** Perform a step's action against an already-resolved single-element locator. */ const performAction = async ( page: ReplayPageLike, loc: ReplayLocatorLike, step: TraceStep, stepTimeoutMs: number, ): Promise => { switch (step.action) { case 'fill': { await loc.fill(step.value ?? '', { timeout: stepTimeoutMs }); break; } case 'select': { const value = step.value ?? ''; try { // Native — root-web genders are react-select comboboxes // (`` with `role=option` menu items). Open the // control and click the option by its exact accessible name (`exact` // matters: name="Male" would otherwise substring-match "Female"). await loc.click({ timeout: stepTimeoutMs }); await page.getByRole('option', { name: value, exact: true }).first().click({ timeout: stepTimeoutMs }); } break; } case 'click': { await loc.click({ timeout: stepTimeoutMs }); break; } case 'hover': { { await loc.hover({ timeout: stepTimeoutMs }); // No default } break; } } // `expect` only needs presence — the caller already proved count()>0. }; /** * `getByRole(role, { name })` matches the accessible name as a SUBSTRING, so a * name that is a prefix of others ("Add Root Funeral" vs "Add Root Funeral * Validation Errors") resolves to several controls. When that happens on an * actionable role+name step, retry with `exact: true` — if it pins exactly one * element that is the control the recorder saw, keeping replay deterministic * without depending on the (non-deterministic) recorder emitting `exact`. * Returns null when no exact variant applies (non-role locator, no name, or * already exact). */ const exactRoleVariant = (page: ReplayPageLike, l: TraceLocator): ReplayLocatorLike | null => { if (l.kind !== 'role' || l.name === undefined || l.exact === true) return null; return page.getByRole(l.role as string, { name: l.name, exact: true }); }; /** * Last-resort disambiguation for an actionable step whose locator matched several * elements and whose exact-name variant didn't pin one: scan the N matches and * return the index of the SINGLE one that is both visible and enabled, or null if * zero — or more than one — qualify. * * This safely absorbs the common SPA cause of role+name ambiguity: an off-screen * or disabled duplicate (a collapsed-menu copy, a disabled current-version * button) sitting alongside the one real control the recorder acted on. It never * picks arbitrarily among multiple genuinely-actionable matches — if two are * visible+enabled it returns null and the caller heals, preserving the invariant * that replay only acts when exactly one element is unambiguously the target. */ const singleVisibleEnabledIndex = async (locator: ReplayLocatorLike, count: number): Promise => { let found: number | null = null; for (let i = 0; i < count; i += 1) { const candidate = locator.nth(i); let usable = false; try { usable = (await candidate.isVisible()) && (await candidate.isEnabled()); } catch { usable = false; } if (usable) { if (found !== null) return null; found = i; } } return found; }; /** A simple HTML id usable as a bare `#id`; anything else needs `[id="…"]`. */ const SIMPLE_ID = /^[A-Za-z_][\w-]*$/; /** Build the canonical css locator for an element's real DOM id. */ const canonicalLocatorFromId = (id: string): TraceLocator => SIMPLE_ID.test(id) ? { kind: 'css', css: `#${id}` } : { kind: 'css', css: `[id="${id}"]` }; const snakeToCamel = (s: string): string => s.replaceAll(/_([a-z0-9])/g, (_m, c: string) => c.toUpperCase()); const camelToSnake = (s: string): string => s.replaceAll(/[A-Z]/g, (c) => `_${c.toLowerCase()}`); /** Casing variants of an identifier (snake↔camel), excluding the original. */ const idCaseVariants = (id: string): string[] => { const set = new Set([snakeToCamel(id), camelToSnake(id)]); set.delete(id); return [...set]; }; /** * Pull the bare identifier out of `#id`, `[id="id"]`, or `[name="id"]`, allowing * an optional leading tag qualifier (e.g. `input[name="children[0].date_of_birth"]`, * which the recording agent routinely emits). The attribute-value capture keeps * dots and brackets so a react-hook-form field key resolves intact. */ const idFromCss = (css: string): string | null => { const hash = /^#([\w-]+)$/.exec(css); if (hash) return hash[1]; const attr = /^[A-Za-z]*\[(?:id|name)="([^"]+)"\]$/.exec(css); return attr ? attr[1] : null; }; /** * react-select assigns each mounted control an instance number from a single * module-level counter (`react-select-7`, `-8`, …), so the SAME dropdown gets a * different number between runs depending on how many selects mounted before it. * The recorder reads the live id off the open menu and bakes * `#react-select-7-option-1`, which resolves to NOTHING on the next run — gender * never sets and the wizard's Next button stays disabled, forcing a heal every * run. The OPTION INDEX (`-option-1`) is stable, though: it's the option's fixed * position in the schema-defined list. Returns that index so the locator can be * rewritten instance-agnostically (`[id$="-option-1"]`); null when not a * react-select option id. */ const reactSelectOptionIndex = (css: string): number | null => { const id = idFromCss(css); if (id === null) return null; const m = /^react-select-\d+-option-(\d+)$/.exec(id); return m ? Number(m[1]) : null; }; /** Instance-agnostic locator for a react-select option at a fixed list index. */ const reactSelectOptionLocator = (index: number): TraceLocator => ({ kind: 'css', css: `[id^="react-select-"][id$="-option-${index}"]`, }); /** Strip the required-field marker (`*`) and surrounding whitespace from a name. */ const stripRequiredMarker = (s: string): string => s.replaceAll('*', '').trim(); /** * Equivalent locators to try when a recorded locator resolves to nothing, before * healing via the (slow) AI agent. The recording agent sees an accessibility * snapshot, not raw DOM, so it back-derives field ids from labels and gets the * CASING wrong (`#id_number` vs the real `#idNumber`) and captures names with the * required-marker `*` baked in. These cheap equivalents recover the common cases: * • a css id/name whose snake↔camel casing is off → try both casings on id+name; * • a role/label name carrying the `*` marker → try it stripped, and as a label. * Acted on only when one uniquely resolves (the caller enforces single-match), * after which canonicalisation rewrites the cache to the element's real id. */ const fallbackLocators = (l: TraceLocator): TraceLocator[] => { const out: TraceLocator[] = []; if (l.kind === 'css' && l.css) { const optionIndex = reactSelectOptionIndex(l.css); if (optionIndex !== null) { // A baked `#react-select-7-option-1` won't resolve once the instance number // drifts; the open menu's option at this fixed index will. This is the only // fallback that recovers it — there's no casing/attribute variant to try. out.push(reactSelectOptionLocator(optionIndex)); return out; } const id = idFromCss(l.css); if (id) { // Try the SAME identifier on BOTH the id and name attributes, then its // snake↔camel case variants on both. The recorder back-derives a field key // from the a11y label and commits it to ONE attribute (usually `name`), but // the real control may carry that key on the OTHER attribute — a // react-datepicker input renders `id="children[0].date_of_birth"` with no // `name`, so a recorded `[name="…"]` resolves to nothing until retried as // `[id="…"]`. Always quote the value (`[id="…"]`, never `#…`) so identifiers // with dots/brackets stay valid CSS. for (const v of [id, ...idCaseVariants(id)]) { out.push({ kind: 'css', css: `[id="${v}"]` }, { kind: 'css', css: `[name="${v}"]` }); } } } else if (l.kind === 'role' && l.name) { const stripped = stripRequiredMarker(l.name); if (stripped && stripped !== l.name) out.push({ kind: 'role', role: l.role, name: stripped }); if (stripped) out.push({ kind: 'label', label: stripped }); // The recorder back-derives role from the a11y snapshot and routinely mis-types // an interactive control's role — e.g. it emits role=button for a "New policy" // control that is actually a link, so getByRole('button',{name}) resolves to // NOTHING and the trace can't self-replay. Mirror the kind:text branch: swap // button↔link, and try the name as plain text, for every name variant. Acted on // only when exactly one element resolves (the caller enforces single-match), // after which canonicalisation rewrites the cache to the element's real #id. const names = stripped && stripped !== l.name ? [l.name, stripped] : [l.name]; const swap = l.role === 'button' ? 'link' : l.role === 'link' ? 'button' : undefined; for (const name of names) { if (swap) { out.push({ kind: 'role', role: swap, name, exact: true }, { kind: 'role', role: swap, name }); } out.push({ kind: 'text', text: name }); } } else if (l.kind === 'label' && l.label) { const stripped = stripRequiredMarker(l.label); if (stripped && stripped !== l.label) out.push({ kind: 'label', label: stripped }); } else if (l.kind === 'text' && l.text) { // A `text` locator the recorder emitted for a CLICK is almost always an // interactive control whose accessible name equals that text — but // `getByText` matches rendered text content, so it misses controls whose // label comes from an `aria-label`/icon (e.g. a "New policy" button that // renders only a "+") and over-matches when the text appears in a heading // too. Resolving the same string by ROLE (button → link) pins the real // control, which canonicalisation then rewrites to its `#id`. Try the exact // text first (avoids substring over-match), then the raw substring. const stripped = stripRequiredMarker(l.text); for (const name of stripped && stripped !== l.text ? [l.text, stripped] : [l.text]) { out.push( { kind: 'role', role: 'button', name, exact: true }, { kind: 'role', role: 'link', name, exact: true }, { kind: 'role', role: 'button', name }, { kind: 'role', role: 'link', name }, ); } } return out; }; /** * Once a step has resolved to its single target element, read the element's real * DOM `id` and return the canonical `#id` locator. Only actionable steps with an * id are canonicalised — `expect` presence checks usually target id-less text. * Returns null when there's no usable id, leaving the recorded locator as-is. */ const canonicalLocatorForElement = async (loc: ReplayLocatorLike, step: TraceStep): Promise => { if (!ACTIONABLE.has(step.action)) return null; let id: string | null = null; try { id = await loc.getAttribute('id'); } catch { id = null; } if (id === null || id.length === 0) return null; // Don't re-bake a drifting react-select instance id back into the cache — // normalise it to the instance-agnostic option-index locator so the next run // resolves it directly without a heal. const optionIndex = reactSelectOptionIndex(`#${id}`); if (optionIndex !== null) return reactSelectOptionLocator(optionIndex); return canonicalLocatorFromId(id); }; type PickResult = | { status: 'single'; loc: ReplayLocatorLike } | { status: 'none' } | { status: 'ambiguous'; count: number }; /** * Reduce a resolved locator to the SINGLE element to act on, applying the * actionable-step disambiguation: an exact-name variant first, then the single * visible+enabled match. Returns `none` when nothing matches (caller keeps * polling / tries a fallback) and `ambiguous` when several genuinely qualify * (caller heals rather than act on the wrong one). `expect` is presence-only, so * any match count ≥1 collapses to the first element. */ const pickSingle = async ( page: ReplayPageLike, locator: ReplayLocatorLike, l: TraceLocator, actionable: boolean, ): Promise => { let n = 0; try { n = await locator.count(); } catch { n = 0; } if (n === 0) return { status: 'none' }; if (!actionable || n === 1) return { status: 'single', loc: locator.first() }; const exact = exactRoleVariant(page, l); if (exact) { let exactCount = 0; try { exactCount = await exact.count(); } catch { exactCount = 0; } if (exactCount === 1) return { status: 'single', loc: exact.first() }; } // Exact-name didn't pin one (e.g. the real names are longer than the recorded // substring). Fall back to the single visible+enabled match if there's exactly // one — otherwise it's genuinely ambiguous. const visibleIndex = await singleVisibleEnabledIndex(locator, n); if (visibleIndex !== null) return { status: 'single', loc: locator.nth(visibleIndex) }; return { status: 'ambiguous', count: n }; }; interface ResolveOutcome { /** Canonical `#id` locator for the element acted on, or null to keep the recorded one. */ canonical: TraceLocator | null; } /** * Last-ditch disambiguation for a genuinely ambiguous actionable step (several * elements share an accessible name, e.g. duplicate table rows): narrow the set * with `.filter({ hasText })` using a distinguishing token taken from the * recorded step sequence (the next step's typed/selected value). Returns the * single scoped element AND the token that pinned it (so the caller can bake a * deterministic `hasText`-scoped locator) — or null when the token doesn't pin * exactly one, in which case the caller heals loudly rather than guess. */ const scopeByHasText = async ( page: ReplayPageLike, l: TraceLocator, disambiguator: string | undefined, actionable: boolean, ): Promise<{ loc: ReplayLocatorLike; hasText: string } | null> => { if (!disambiguator) return null; const scopedLocator: TraceLocator = { ...l, hasText: disambiguator }; const scoped = resolveLocator(page, scopedLocator); if (scoped === null) return null; const pick = await pickSingle(page, scoped, scopedLocator, actionable); if (pick.status === 'single') return { loc: pick.loc, hasText: disambiguator }; return null; }; /** * Stable platform DOM ids for the navigation CTAs the pure-a11y recorder can't * anchor by name (the dashboard "New policy" button is named inconsistently run * to run; the catalog "Add" sits behind a display name shared with inert card * text). Returned in priority order; each is tried only when it resolves to * exactly one element, so an id absent off its own page is simply skipped. */ const knownStableIdLocators = (l: TraceLocator, moduleKey?: string): TraceLocator[] => { const out: TraceLocator[] = []; const name = l.kind === 'role' ? l.name : l.kind === 'text' ? l.text : undefined; if (name && /new policy/i.test(name)) { out.push({ kind: 'css', css: '#home-new-policy-button' }); } if (moduleKey) { out.push({ kind: 'css', css: `#add-product-module-key-${moduleKey}-button` }); } return out; }; /** * Resolve a step's structured locator and perform the action, polling until the * element is present so an in-flight render settles within the budget. When the * recorded locator never resolves, equivalent fallbacks (id casing variants, * name attribute, required-marker-stripped name/label) are tried once each * before giving up. Throws when nothing resolves — the signal for the caller to * heal via the AI agent. * * For an actionable step (click/fill/select/hover) an AMBIGUOUS locator (more * than one match) tries the exact-name variant first; if that still doesn't pin * exactly one element it heals, rather than silently `.first()`-clicking one of * N controls and landing on the wrong element. `expect` is presence-only, so >1 * match is fine. * * On success it reads the resolved element's real DOM id and returns a canonical * `#id` locator so the caller can re-persist a cache that converges to * deterministic selectors. */ const resolveAndAct = async ( page: ReplayPageLike, step: TraceStep, stepTimeoutMs: number, sleep: (ms: number) => Promise, disambiguator?: string, moduleKey?: string, ): Promise => { const describe = describeLocator(step.locator); const locator = resolveLocator(page, step.locator); if (locator === null) { throw new Error(`step ${step.action} has a url locator but is not a navigate step`); } const actionable = ACTIONABLE.has(step.action); const deadline = Date.now() + stepBudgetMs(step.action, stepTimeoutMs); // Set when Phase 1 saw the recorded locator match several genuinely-actionable // elements: a more specific fallback (e.g. role=button for a `text` match) may // still pin exactly one; only if even those stay ambiguous do we heal LOUDLY // and flag the duplicate as a data bug rather than the generic resolve failure. let ambiguousCount: number | null = null; // Phase 1: poll the recorded locator until it resolves or the budget lapses. for (;;) { const pick = await pickSingle(page, locator, step.locator, actionable); if (pick.status === 'single') { // Read the real DOM id BEFORE acting. A navigating/closing click (open a // modal, submit a step, confirm) detaches the element, so reading it after // the action throws and the locator never converges to #id. const canonical = await canonicalLocatorForElement(pick.loc, step); await performAction(page, pick.loc, step, stepTimeoutMs); return { canonical }; } if (pick.status === 'ambiguous') { // Several elements share this accessible name (duplicate table rows). Try to // pin ONE with a distinguishing token from the recorded step sequence; on // success act on it and converge the cache (to the element's #id, or to a // `hasText`-scoped locator when it has no id). const scoped = await scopeByHasText(page, step.locator, disambiguator, actionable); if (scoped) { const canonical = await canonicalLocatorForElement(scoped.loc, step); await performAction(page, scoped.loc, step, stepTimeoutMs); return { canonical: canonical ?? { ...step.locator, hasText: scoped.hasText } }; } // No distinguishing token — fall through to the fallbacks (a more specific // locator may pin one) before deciding it's a true duplicate. ambiguousCount = pick.count; break; } if (Date.now() >= deadline) break; await sleep(POLL_INTERVAL_MS); } // Phase 2: the recorded locator never resolved (or stayed ambiguous) — try // equivalent fallbacks once each (the budget has already elapsed, so the page // has settled). for (const fb of fallbackLocators(step.locator)) { const resolved = resolveLocator(page, fb); if (resolved === null) continue; const pick = await pickSingle(page, resolved, fb, actionable); if (pick.status === 'single') { // Read the real DOM id BEFORE acting. A navigating/closing click (open a // modal, submit a step, confirm) detaches the element, so reading it after // the action throws and the locator never converges to #id. const canonical = await canonicalLocatorForElement(pick.loc, step); await performAction(page, pick.loc, step, stepTimeoutMs); return { canonical }; } } // Known-stable-control fallback. A handful of navigation CTAs carry a STABLE // platform DOM id but an accessible name the a11y recorder names inconsistently // (observed live: the dashboard "New policy" button recorded as "New policy", // "New Policy", or by a nearby heading; the catalog product "Add" recorded by // the product's display name, which sits on inert card/heading/description text // while every real Add control shares the name "Add"). No name-based locator can // reliably pin these, so anchor them to their stable ids directly — this is the // production-Playwright "use ids" approach for the controls pure-a11y recording // can't anchor. Guarded to text/role CLICKs whose recorded name/text matches the // control, and acted on only when the id resolves to exactly one element, so a // mis-resolved later step never jumps here (the id is absent off its own page). if (step.action === 'click' && (step.locator.kind === 'text' || step.locator.kind === 'role')) { for (const candidate of knownStableIdLocators(step.locator, moduleKey)) { const resolved = resolveLocator(page, candidate); if (resolved === null) continue; const pick = await pickSingle(page, resolved, candidate, actionable); if (pick.status === 'single') { const canonical = await canonicalLocatorForElement(pick.loc, step); await performAction(page, pick.loc, step, stepTimeoutMs); return { canonical: canonical ?? candidate }; } } } // A genuine duplicate (ambiguous in Phase 1, no fallback pinned one) heals // LOUDLY so the data/test-plan bug is visible, not silently first-picked. if (ambiguousCount !== null) { throw new Error( `duplicate target ${describe} matched ${ambiguousCount} elements with no distinguishing data — make the row unique (fix test-plan/seed) or record a click on a unique in-row control`, ); } throw new Error(`could not resolve ${describe} for a ${step.action} step`); }; /** * Distinguishing token to disambiguate an ambiguous actionable step from the * recorded sequence: the value typed/selected in the immediately following * fill/select step (e.g. an id number a flow enters right after picking a member * row). Stops at a navigate boundary — a value on the next page can't be in the * current row. Returns undefined when there's no usable token. Used only as a * last resort: a value that appears in exactly one of the duplicate rows pins it; * one that appears in all/none leaves the step to heal loudly. */ const nextStepDisambiguator = (steps: TraceStep[], index: number): string | undefined => { const next = steps[index + 1]; if (!next || next.action === 'navigate') return undefined; return next.value && next.value.length > 0 ? next.value : undefined; }; /** * Re-execute a cached scenario skeleton with raw Playwright. Returns `replayed` * when every step succeeded, or `heal-needed` (with the offending step) the * moment one can't be resolved/executed — never throws for a step failure. */ export const replayTrace = async (params: ReplayTraceParams): Promise => { const { steps, storageStatePath, outputDir, moduleKey, organizationId, recordVideo = true, stepTimeoutMs = DEFAULT_STEP_TIMEOUT_MS, launchBrowser = defaultLaunchBrowser, sleep = defaultSleep, } = params; const startMs = Date.now(); const screenshotPath = path.join(outputDir, REPLAY_SCREENSHOT_FILENAME); const videoPath = path.join(outputDir, REPLAY_VIDEO_FILENAME); // An empty skeleton has nothing to replay — heal straight away. if (steps.length === 0) { return { status: 'heal-needed', stepsRun: 0, reason: 'no cached steps to replay', durationMs: Date.now() - startMs, }; } let browser: ReplayBrowserLike; try { browser = await launchBrowser(); } catch (error) { // Playwright missing / browser launch failed → heal via the AI path rather // than hard-failing the whole run. return { status: 'heal-needed', stepsRun: 0, reason: `browser launch failed: ${(error as Error).message}`, durationMs: Date.now() - startMs, }; } let stepsRun = 0; let context: ReplayContextLike | undefined; let page: ReplayPageLike | undefined; // The skeleton as executed, with each resolved actionable locator rewritten to // the element's real DOM id. Returned so the caller can re-persist a cache that // converges to deterministic `#id` selectors after one successful pass. const canonicalSteps: TraceStep[] = []; try { // recordVideo captures the WHOLE replay; the file is finalized on context // close (finalizeReplayVideo below) and saved as a deterministic replay.webm. // Skipped unless the caller asked for a video (rp ai-test --video). context = await browser.newContext({ storageState: storageStatePath, ...(recordVideo ? { recordVideo: { dir: outputDir } } : {}), }); page = await context.newPage(); // Re-verify sandbox mode on THIS browser before any data-mutating step. The // login-time guard set+checked the flag, but the same host serves production // and this context re-hydrates the flag from storageState — so we read it back // from the live page (after the first navigate puts us on the dashboard origin // where localStorage exists) and re-assert. A breach throws ProductionGuardError, // re-thrown out of replayTrace (NOT folded into heal-needed) so a production run // aborts hard instead of silently re-recording against prod. let sandboxVerified = false; for (let i = 0; i < steps.length; i += 1) { const step = steps[i]; if (step.action === 'navigate') { // `domcontentloaded` (not `networkidle`): the dashboard is an SPA with // long-lived websockets, so the network never idles — the per-step // poll-until-resolved loop below is what actually waits for the target // element to render. const url = step.locator.url; if (!url) throw new Error('navigate step has no url locator'); // Fail-closed host guard on EVERY navigate URL, not just the base dashboard. // The cache lives in the (committable, CI-writable) module repo dir, so a // poisoned/stale trace with an allowlisted `dashboardHost` but an unknown/stale // navigate host would otherwise drive that host with a live session and no AI // in the loop. A refusal throws → caught below → heal-needed, so the run falls // back to fresh AI discovery from the already-guarded base URL. (Sandbox-vs- // production is enforced by the storageState's sandbox flag, set at login.) assertAllowedDashboardHost(url); await page.goto(url, { waitUntil: 'domcontentloaded', timeout: stepTimeoutMs }); // Now on the dashboard origin: re-assert sandbox mode once, before the // first actionable (data-mutating) step runs. if (organizationId && !sandboxVerified) { const flag = await page.evaluate( (key: string) => (globalThis as unknown as { localStorage: { getItem(k: string): string | null } }).localStorage.getItem( key, ), sandboxFlagKey(organizationId), ); assertSandboxModeEnabled(organizationId, flag); sandboxVerified = true; } canonicalSteps.push(step); } else { const { canonical } = await resolveAndAct( page, step, stepTimeoutMs, sleep, nextStepDisambiguator(steps, i), moduleKey, ); canonicalSteps.push(canonical ? { ...step, locator: canonical } : step); } stepsRun += 1; } let screenshot: string | undefined; try { await page.screenshot({ path: screenshotPath }); screenshot = screenshotPath; } catch { screenshot = undefined; } const video = await finalizeReplayVideo(context, page, videoPath, recordVideo); return { status: 'replayed', stepsRun, screenshot, video, steps: canonicalSteps, durationMs: Date.now() - startMs }; } catch (error) { // A replay that heals is incomplete — its partial video would sit beside the // AI agent's real screenshot and read as a finished run, so discard it (close // the context to release Chromium, then delete without saving). await finalizeReplayVideo(context, page, videoPath, false); // A sandbox-mode breach is NOT a healable step failure — never fold it into // heal-needed (that would silently re-record against production). Re-throw so // the run aborts hard, mirroring login.ts. if (error instanceof ProductionGuardError) throw error; return { status: 'heal-needed', stepsRun, failedStep: steps[stepsRun], reason: (error as Error).message, durationMs: Date.now() - startMs, }; } finally { await browser.close(); } }; /** * Close the context to finalize the recording. When `keep`, save it as a * deterministic `replay.webm` (Playwright otherwise writes a random-hash * filename) and return the path; otherwise discard the (partial, heal-path) * recording. Either way the random-hash original is deleted. Never throws — a * missing/failed video must not fail the replay. */ const finalizeReplayVideo = async ( context: ReplayContextLike | undefined, page: ReplayPageLike | undefined, target: string, keep: boolean, ): Promise => { if (!context || !page) return undefined; const video = page.video(); try { await context.close(); } catch { return undefined; } if (!video) return undefined; try { if (keep) await video.saveAs(target); await video.delete(); return keep ? target : undefined; } catch { return undefined; } };