/** * `rp ai-test` — CSV-driven scenario runner for product modules. * * Workflow: * 1. Read test-plan.csv from the current module directory. * 2. Pull dashboard credentials from 1Password via `op` CLI. * 3. For each scenario row, spawn a Claude CLI sub-process with the * Playwright MCP server configured. Inner agent logs in once (if * it lands on a sign-in form), then drives the scenario. * 4. Aggregate per-row verdicts into a summary; exit non-zero if any * scenario failed. * * Target by default: the dashboard host derived from `.root-config.json`. * Hard rule: an AI agent must never drive production. There are two guards * (see host-guard.ts): (1) a fail-closed host allowlist rejects unknown/stale * hosts, and (2) — the real production guard — login.ts forces the org's * sandbox flag on and verifies it, because app.rootplatform.com serves both * sandbox and production data for an org (the difference is the in-app Sandbox * toggle, not the hostname). No --allow-production escape hatch. */ import chalk from 'chalk'; import * as fs from 'node:fs'; import * as os from 'node:os'; import path from 'node:path'; import { parseTestPlanCsv, type Scenario } from '../helpers/ai-test/csv-parser'; import { readDashboardCreds } from '../helpers/ai-test/one-password'; import { buildScenarioPrompt } from '../helpers/ai-test/scenario-prompt'; import { driveScenario, type ScenarioVerdict } from '../helpers/ai-test/claude-driver'; import { establishSession } from '../helpers/ai-test/login'; import { AI_TEST_DIRNAME, loadTrace, saveTrace, scenarioFingerprint, type CacheIo, type TraceLocator, type TraceStep, } from '../helpers/ai-test/scenario-cache'; import { describeLocator, replayTrace } from '../helpers/ai-test/deterministic-replay'; import { resolveDashboardUrl, assertAllowedDashboardHost } from '../helpers/ai-test/host-guard'; import { symbols } from '../helpers/symbols'; const TEST_PLAN_FILENAME = 'test-plan.csv'; const ROOT_CONFIG_FILENAME = '.root-config.json'; export interface AiTestOptions { /** 1Password item name holding the dashboard login. Required. */ opItem?: string; /** Optional 1Password vault to disambiguate the item title. */ opVault?: string; /** Dashboard base URL override. If omitted, derived from .root-config.json `host`. */ dashboardUrl?: string; /** Per-run output dir for mcp.json + screenshots / videos. */ outputDir?: string; /** Skip scenarios past the first failure. */ bail?: boolean; /** Disable the record-and-replay cache — every scenario re-discovers from scratch. */ noCache?: boolean; /** Record a replay.webm for every scenario. Without it, runs are screenshot-only. */ video?: boolean; /** Max seconds the AI agent may spend recording/healing a single scenario. Commander passes the raw string. */ scenarioTimeout?: string; /** Per-step deterministic-replay budget in seconds before a step heals. Commander passes the raw string. */ stepTimeout?: string; } export interface AiTestDeps { cwd: () => string; readFile: (p: string) => string; writeFile: (p: string, c: string) => void; ensureDir: (p: string) => void; fileExists: (p: string) => boolean; listDir: (p: string) => string[]; removeDir: (p: string) => void; copyFile: (src: string, dest: string) => void; readCreds: typeof readDashboardCreds; establishSession: typeof establishSession; driveScenario: typeof driveScenario; replayTrace: typeof replayTrace; log: (line: string) => void; /** Make a private, disposable dir for the session token (OS temp, not the repo). */ makeSessionDir: () => string; /** Remove the session dir (and the token inside it) once the run ends. */ cleanupSessionDir: (dir: string) => void; /** * Make a disposable dir (OS temp, not the module tree) for the AI agent's * @playwright/mcp output — console/page/mcp dumps — so they never accumulate in * the scenario folder, not even while the agent is running. */ makeAgentDir: () => string; } const PRODUCTION_DEPS: AiTestDeps = { cwd: () => process.cwd(), readFile: (p) => fs.readFileSync(p, 'utf-8'), writeFile: (p, c) => fs.writeFileSync(p, c, 'utf-8'), ensureDir: (p) => fs.mkdirSync(p, { recursive: true }), fileExists: (p) => fs.existsSync(p), listDir: (p) => fs.readdirSync(p), removeDir: (p) => fs.rmSync(p, { recursive: true, force: true }), copyFile: (src, dest) => fs.copyFileSync(src, dest), readCreds: readDashboardCreds, establishSession, driveScenario, replayTrace, log: (line) => console.log(line), makeSessionDir: () => fs.mkdtempSync(path.join(os.tmpdir(), 'rp-ai-test-session-')), cleanupSessionDir: (dir) => fs.rmSync(dir, { recursive: true, force: true }), makeAgentDir: () => fs.mkdtempSync(path.join(os.tmpdir(), 'rp-ai-test-agent-')), }; // Commander hands the `--scenario-timeout` value through as a raw string. // Returns milliseconds, or undefined to let driveScenario apply its default. export const parseScenarioTimeoutMs = (raw?: string): number | undefined => { if (raw === undefined) return undefined; const seconds = Number(raw); if (!Number.isFinite(seconds) || seconds <= 0) { throw new Error(`rp ai-test: --scenario-timeout must be a positive number of seconds (got "${raw}").`); } return Math.round(seconds * 1000); }; // Commander hands the `--step-timeout` value through as a raw string. // Returns milliseconds, or undefined to let replayTrace apply its default. export const parseStepTimeoutMs = (raw?: string): number | undefined => { if (raw === undefined) return undefined; const seconds = Number(raw); if (!Number.isFinite(seconds) || seconds <= 0) { throw new Error(`rp ai-test: --step-timeout must be a positive number of seconds (got "${raw}").`); } return Math.round(seconds * 1000); }; // Debug artifacts that @playwright/mcp (per-step a11y snapshots, console dump) // and our own MCP config drop into the agent's output dir. They're invaluable // when a scenario FAILS but pure noise on a green one. The AI path writes them to // a throwaway temp dir, so on a FAIL we copy just these back into the scenario // folder for debugging; on a PASS they're discarded with the temp dir. const DEBUG_ARTIFACT_PATTERNS: RegExp[] = [/^console-.*\.log$/, /^page-.*\.ya?ml$/, /^mcp\.json$/]; export const isPrunableArtifact = (filename: string): boolean => DEBUG_ARTIFACT_PATTERNS.some((re) => re.test(filename)); // A passing AI recording is only cached once it SELF-REPLAYS deterministically // with raw Playwright. The agent's emitted trace is non-deterministic and lossy // (observed live: it omits a required UI step, or drops fill values, between // runs), and such a trace heals on EVERY future run — the exact "we record but // can't replay from cache" failure. So we record → self-replay → re-record on // failure, caching only a proven-replayable path. Bounded so a genuinely // un-replayable scenario still terminates (caching nothing, with a loud warn, so // the next run records clean rather than from a poisoned hint). // // Set to 5 (not 3): per-attempt recording quality is the limiting factor, and the // longest scenarios (e.g. main + spouse + child, 30+ steps) need several shots to // land ONE clean, self-replayable trace — observed live that 3 attempts could // exhaust on the family scenario while shorter ones converged in 1–2. Extra // attempts only cost time on the rare scenario that needs them; a scenario that // self-replays early exits the loop immediately. const MAX_RECORD_ATTEMPTS = 5; // The dashboard session token is seeded ONCE (a scripted login) and shared by // every scenario's browser via --storage-state. A fully-cached run finishes in // seconds, so the token never ages — but a cold run that AI-records each scenario // can take 20+ minutes, and the token expires mid-run, so the LATER scenarios // land on the login page and fail "not authenticated — session setup failed". // Re-seed the session whenever it's been alive longer than this interval: chosen // safely under the observed token lifetime (a cached run that never gets near it // re-logs zero times) and far above the ~30s TOTP window (so each refresh always // fetches a fresh, unused one-time code — no replay-protection collision). export const SESSION_REFRESH_INTERVAL_MS = 7 * 60_000; // Before each slow AI record attempt we want a near-fresh token (a record can run // minutes and outlive a token seeded scenarios ago — the agent then spends the // whole attempt stuck on the login page). 35s sits just above the ~30s TOTP // window so a refresh right after a prior login still fetches an unused one-time // code; below it the token is fresh enough to reuse without re-logging. export const RECORD_SESSION_MAX_AGE_MS = 35_000; // Only the deterministic-replay path can record a video; the AI record/heal path // (@playwright/mcp) can't. Video is opt-in (`--video`): a default run is // screenshot-only, so it never asks for one. With --video, the note instead // flags the rare case of a pass whose AI run emitted no usable ::trace:: — there // was no replayable path to record. Surfaced in both the console and verdict.json // so the absent .webm reads as expected, not a bug. export const noVideoNote = (wantVideo: boolean): string => wantVideo ? 'no replayable trace this run, so no video could be recorded' : 'run with --video to record a replay video'; /** * Self-heal the skeleton when a fast-replay healed because an `expect` step's * locator couldn't be found on the page. We only reach the heal+cache path when * the AI agent re-ran and PASSED — so the outcome IS correct and the failed * assertion was a false-negative on the raw-replay path (a paraphrased/absence * string the agent re-emits but raw Playwright can't resolve, e.g. "No covered * people"). Left in the cache it heals (slowly) every single run. Drop that one * assertion from the trace about to be cached so the NEXT run replays clean. * Conservative: only ever removes the single offending `expect` (matched by its * rendered locator), never a navigation/interaction step, and never anything * when the failed step wasn't an assertion or the heal didn't pass. */ export const dropUnresolvableAssert = ( trace: TraceStep[], failedStep: TraceStep | undefined, ): { trace: TraceStep[]; dropped: TraceLocator | undefined } => { if (!failedStep || failedStep.action !== 'expect') return { trace, dropped: undefined }; const target = describeLocator(failedStep.locator); const kept = trace.filter((s) => !(s.action === 'expect' && describeLocator(s.locator) === target)); if (kept.length === trace.length) return { trace, dropped: undefined }; return { trace: kept, dropped: failedStep.locator }; }; /** * Is a freshly recorded trace complete enough to cache as the scenario's replay * skeleton? Every ai-test scenario issues a policy, which is impossible without * entering form data, so a usable trace MUST contain at least one data-entry step * (`fill`/`select`). A navigate-only or click-only trace is the signature of an * agent that truncated its `::trace::` emission mid-flow (it still did the real * work in its own browser and PASSED, but emitted a stub). Such a trace would * trivially "self-replay" — navigation always succeeds — and get cached as a * false 1-step success that fast-replays forever WITHOUT actually issuing a * policy. Rejecting it forces a clean re-record until a complete trace lands. */ export const isUsableScenarioTrace = (trace: TraceStep[]): boolean => trace.some((s) => s.action === 'fill' || s.action === 'select'); const verdictSymbol = (v: ScenarioVerdict): string => { if (v.status === 'pass') return chalk.green(symbols.success); if (v.status === 'fail') return chalk.red(symbols.error); return chalk.yellow(symbols.unknown); }; export const aiTest = async ( options: AiTestOptions = {}, deps: AiTestDeps = PRODUCTION_DEPS, ): Promise<{ scenarios: ScenarioVerdict[]; exitCode: number }> => { if (!options.opItem) { throw new Error('rp ai-test: --op-item is required (1Password item holding the dashboard login).'); } const cwd = deps.cwd(); const planPath = path.join(cwd, TEST_PLAN_FILENAME); const configPath = path.join(cwd, ROOT_CONFIG_FILENAME); if (!deps.fileExists(planPath)) { throw new Error(`No ${TEST_PLAN_FILENAME} found in current directory. Create one alongside .root-config.json.`); } if (!deps.fileExists(configPath)) { throw new Error( `No ${ROOT_CONFIG_FILENAME} in current directory. Run \`rp ai-test\` from inside a cloned product module.`, ); } const rootConfig = JSON.parse(deps.readFile(configPath)) as { host?: string; productModuleKey?: string; organizationId?: string; }; if (!rootConfig.productModuleKey) { throw new Error(`.root-config.json is missing productModuleKey`); } if (!rootConfig.host) { throw new Error(`.root-config.json is missing host`); } if (!rootConfig.organizationId) { throw new Error(`.root-config.json is missing organizationId (needed to scope the dashboard session)`); } const dashboardUrl = resolveDashboardUrl(rootConfig.host, options.dashboardUrl); assertAllowedDashboardHost(dashboardUrl); const dashboardHost = new URL(dashboardUrl).hostname; // Org-scoped deep-link base. The dashboard routes are /orgs//insurance/..., // so pinning the org in the URL lets every scenario skip the dashboard-root → // org-card-by-name dance (paid once per scenario) AND removes the org-selection // guesswork that picked the wrong org when names were ambiguous. const orgScopedBaseUrl = `${dashboardUrl}/orgs/${rootConfig.organizationId}/insurance`; const csvRaw = deps.readFile(planPath); const scenarios: Scenario[] = parseTestPlanCsv(csvRaw); // parseTestPlanCsv already throws on header-only / empty CSVs, so a 0-length // scenarios array is unreachable here. No defensive re-check. // Single fixed runs dir, wiped at the start of every run so the latest result // overwrites the previous one — we don't accumulate a timestamped folder per run. const outputDir = options.outputDir ?? path.join(cwd, AI_TEST_DIRNAME, 'runs'); if (!options.outputDir) deps.removeDir(outputDir); deps.ensureDir(outputDir); deps.log(chalk.bold(`rp ai-test: ${scenarios.length} scenario(s) against ${dashboardUrl}`)); deps.log(chalk.gray(`Module: ${rootConfig.productModuleKey}`)); deps.log(chalk.gray(`Output: ${outputDir}`)); const vaultSuffix = options.opVault ? ` (vault "${options.opVault}")` : ''; deps.log(chalk.gray(`Credentials: 1Password item "${options.opItem}"${vaultSuffix}`)); deps.log(''); // Fetch creds + log in immediately so the fresh TOTP is still valid when the // scripted login types it. The agent never sees the creds — it inherits the // resulting authenticated session via storageState. const creds = deps.readCreds({ item: options.opItem, vault: options.opVault }); deps.log(chalk.gray('Logging in (scripted) to seed the session…')); // The session token is a live credential: keep it OUT of the module repo's // run/output dir (PR-attachable, often un-gitignored). Write it to a private // OS temp dir and delete it in the `finally` below, pass or fail. const sessionDir = deps.makeSessionDir(); const storageStatePath = await deps.establishSession({ dashboardUrl, creds, organizationId: rootConfig.organizationId, sessionDir, }); deps.log(chalk.gray('Session ready (token held in a private temp dir).')); deps.log(''); // The token expires on a long (cold-record) run; re-seed it before it does so // the later scenarios stay authenticated. establishSession overwrites the same // storageState.json in place, so the path every scenario already holds keeps // pointing at the live token — nothing downstream needs re-wiring. // Capture the values the guards above already narrowed to string — a closure // doesn't inherit that control-flow narrowing. const opItem = options.opItem; const opVault = options.opVault; const organizationId = rootConfig.organizationId; let lastLoginAt = Date.now(); // maxAgeMs lets the caller pick how fresh the session must be. The default // (7 min) is for the fast cached-replay path between scenarios. Before a slow // AI record attempt we pass RECORD_SESSION_MAX_AGE_MS so the agent always // starts a record with a near-fresh token — a single record can run minutes // and outlive a token seeded a few scenarios ago, which lands the agent on the // login page for the whole attempt. The floor (35s) sits just above the ~30s // TOTP window so back-to-back refreshes never reuse a one-time code. const refreshSessionIfStale = async (maxAgeMs: number = SESSION_REFRESH_INTERVAL_MS): Promise => { if (Date.now() - lastLoginAt < maxAgeMs) return; deps.log(chalk.gray(` ${symbols.retry}refreshing dashboard session (token nearing expiry, no AI)…`)); const freshCreds = deps.readCreds({ item: opItem, vault: opVault }); await deps.establishSession({ dashboardUrl, creds: freshCreds, organizationId, sessionDir }); lastLoginAt = Date.now(); }; try { // Cache persists in the module dir (NOT the disposable per-run outputDir) so a // green run's navigation skeleton survives for the next invocation. const cacheIo: CacheIo = { readFile: deps.readFile, writeFile: deps.writeFile, ensureDir: deps.ensureDir, fileExists: deps.fileExists, }; const useCache = !options.noCache; // Videos are opt-in: a default run is screenshot-only. With --video, both // paths record one in the SAME run — a cached scenario via the deterministic // replay pass, and an uncached/healed scenario via the agent's own browser // (@playwright/mcp recordVideo). The fragile re-replay is only a fallback. const wantVideo = !!options.video; const scenarioTimeoutMs = parseScenarioTimeoutMs(options.scenarioTimeout); const stepTimeoutMs = parseStepTimeoutMs(options.stepTimeout); const results: ScenarioVerdict[] = []; for (const scenario of scenarios) { const scenarioDir = path.join(outputDir, scenario.id); deps.ensureDir(scenarioDir); deps.log(chalk.gray(`${symbols.pointer}${scenario.id}: ${scenario.description}`)); // Refresh BEFORE each scenario so a token aged out by previous slow scenarios // is renewed before this one's browser inherits it. await refreshSessionIfStale(); // Optional per-scenario deep-link suffix from a `start_path` CSV column (e.g. // "policies", "workbench/modules"). Appended to the org-scoped base; leading/ // trailing slashes are tolerated. Absent → land on the org insurance home. // Split on '/' and drop empty segments to strip leading/trailing (and collapse // doubled) slashes without a backtracking-prone regex. const startPath = (scenario.extra.start_path ?? '').trim().split('/').filter(Boolean).join('/'); const startUrl = startPath ? `${orgScopedBaseUrl}/${startPath}` : orgScopedBaseUrl; deps.log(chalk.gray(` ${symbols.arrow}start: ${startUrl}`)); const fingerprint = scenarioFingerprint(scenario); const cached = useCache ? loadTrace(cacheIo, { moduleDir: cwd, scenarioId: scenario.id, moduleKey: rootConfig.productModuleKey, dashboardHost, fingerprint, }) : null; // Fast path: a cached skeleton is re-executed with raw Playwright (no LLM, // seconds). It either fully replays — instant PASS — or hits a step it can't // resolve and we heal that one scenario via the slow AI agent below. let replay = cached ? await deps.replayTrace({ steps: cached.steps, storageStatePath, outputDir: scenarioDir, moduleKey: rootConfig.productModuleKey, organizationId: rootConfig.organizationId, recordVideo: wantVideo, ...(stepTimeoutMs === undefined ? {} : { stepTimeoutMs }), }) : null; if (cached) { deps.log(chalk.gray(` ${symbols.retry}fast-replay (${cached.steps.length} cached steps, no AI)`)); } // One transient render race (a slow form load, a mid-re-render button) must // not burn a 10-20 minute AI heal on an otherwise-good cache. Retry the // fast-replay ONCE against a fresh browser; a genuinely stale cache fails // again and heals exactly as before. (A ProductionGuardError still throws // straight through — it never reaches, and must never reach, a retry.) if (cached && replay && replay.status !== 'replayed') { deps.log( chalk.yellow( ` ${symbols.warning}fast-replay flaked (${replay.reason}) — retrying the replay once before healing`, ), ); replay = await deps.replayTrace({ steps: cached.steps, storageStatePath, outputDir: scenarioDir, moduleKey: rootConfig.productModuleKey, organizationId: rootConfig.organizationId, recordVideo: wantVideo, ...(stepTimeoutMs === undefined ? {} : { stepTimeoutMs }), }); } let verdict: ScenarioVerdict; let durationMs: number; let outcomeTag = ''; let traceToSave: TraceStep[] | null = null; if (replay && replay.status === 'replayed') { // Keep the rich recorded reason (premium, policy number, covered members) // as the verdict's primary text, tagging it so the deterministic-replay // origin stays visible. Fall back to the bare mechanical reason for caches // recorded before recordedReason existed. const replayTag = `fast-replay: ${replay.stepsRun} cached step(s), no AI`; verdict = { id: scenario.id, status: 'pass', reason: cached?.recordedReason ? `${cached.recordedReason} [${replayTag}]` : `${replayTag}`, screenshot: replay.screenshot, video: replay.video, }; durationMs = replay.durationMs; outcomeTag = ' [replayed]'; // Converge the cache: replay rewrote each resolved actionable locator to // the element's real DOM id. Re-persist when that changed anything so the // skeleton settles on deterministic #id selectors after one pass — and // the agent's guessed/case-wrong locators stop forcing a heal next run. if (useCache && cached && replay.steps && JSON.stringify(replay.steps) !== JSON.stringify(cached.steps)) { const canonicalised = saveTrace(cacheIo, { moduleDir: cwd, scenarioId: scenario.id, moduleKey: rootConfig.productModuleKey, dashboardHost, fingerprint, steps: replay.steps, ...(cached.recordedReason ? { recordedReason: cached.recordedReason } : {}), }); if (canonicalised) { deps.log( chalk.gray(` ${symbols.edit}cached locators canonicalised (${canonicalised.steps.length} steps)`), ); } } } else { if (replay) { deps.log(chalk.yellow(` ${symbols.warning}replay step failed (${replay.reason}) — healing via AI agent`)); } const prompt = buildScenarioPrompt({ scenario, dashboardUrl, moduleKey: rootConfig.productModuleKey, startUrl, cachedTrace: cached?.steps, }); durationMs = 0; let selfReplayed = false; let attempt = 0; do { attempt++; if (attempt > 1) { deps.log( chalk.gray( ` ${symbols.retry}re-recording (attempt ${attempt}/${MAX_RECORD_ATTEMPTS}) — the previous trace didn't self-replay`, ), ); } // A single scenario can need several record attempts, each up to the // scenario timeout — long enough for the token to age out between them. // Force a near-fresh session here (tight 35s threshold, not the 7-min // cached-replay interval) so every record attempt starts authenticated; // a stale token lands the agent on the login page for the whole attempt. await refreshSessionIfStale(RECORD_SESSION_MAX_AGE_MS); traceToSave = null; // @playwright/mcp drops a console log + one page-*.yml per agent step + // mcp.json into its output dir. Point that at a throwaway temp dir OUTSIDE // the module tree so the scenario folder never accumulates them — not even // mid-run. We bring back only the final screenshot (and, on a FAIL, the // debug dumps, which are worth keeping to diagnose the failure). const agentDir = deps.makeAgentDir(); // With --video, the agent's OWN browser records the session via // @playwright/mcp's recordVideo config (no CLI flag exists). This dir // lives under the durable scenario folder — NOT agentDir, which is shred // at the end of the run — so the .webm survives. It gives the AI // record/heal path a video on the same run, with no fragile re-replay. const aiVideoDir = wantVideo ? path.join(scenarioDir, 'ai-video') : undefined; const run = await deps.driveScenario(scenario.id, { prompt, outputDir: agentDir, storageStatePath, timeoutMs: scenarioTimeoutMs, ...(aiVideoDir ? { videoDir: aiVideoDir } : {}), }); verdict = run.verdict; durationMs += run.durationMs; outcomeTag = replay ? ' [healed]' : ''; // Only a passing run yields a known-good skeleton worth caching/healing. if (useCache && run.verdict.status === 'pass') { if (isUsableScenarioTrace(run.trace)) { // If this heal was triggered by an `expect` whose locator raw replay // couldn't find, drop that assertion before caching: the AI agent just // confirmed the outcome, so it was a false-negative locator that would // otherwise heal forever. Tell the user exactly what was dropped. const { trace: healedTrace, dropped } = dropUnresolvableAssert(run.trace, replay?.failedStep); traceToSave = healedTrace; if (dropped) { deps.log( chalk.yellow( ` ${symbols.warning}${scenario.id}: dropped a check that couldn't be replayed — ${describeLocator(dropped)} wasn't found on the page.`, ), ); deps.log( chalk.gray( ' The AI confirmed the outcome another way, so the scenario still passed. To keep that check, re-add it in test-plan.csv using the exact on-screen wording.', ), ); } } else { // A pass with no usable trace can't fast-replay — it re-records (slow) // every run and never gets a video. This covers BOTH an empty trace and // a truncated one (navigate/click-only, no data-entry step): the latter // would otherwise trivially self-replay and get cached as a false 1-step // success that never issues a policy. Don't let either pass silently: // warn, and keep the raw agent output (normally discarded on a pass) so // the missing/truncated ::trace:: line is diagnosable on the next run. const traceShape = run.trace.length === 0 ? 'no trace' : `a truncated ${run.trace.length}-step trace (no data-entry step)`; deps.log( chalk.yellow( ` ${symbols.warning}no replay cache saved — agent passed but emitted ${traceShape}; re-recording for a complete trace`, ), ); const agentLog = path.join(scenarioDir, 'agent-output.log'); deps.writeFile(agentLog, run.rawOutput); deps.log(chalk.gray(` ${symbols.branch}raw agent output saved for diagnosis: ${agentLog}`)); } } if (verdict.screenshot) { const shot = path.basename(verdict.screenshot); const shotSrc = path.join(agentDir, shot); if (deps.fileExists(shotSrc)) { deps.copyFile(shotSrc, path.join(scenarioDir, shot)); verdict.screenshot = path.join(scenarioDir, shot); } else { verdict.screenshot = undefined; } } // The agent recorded its session under aiVideoDir with a random-hash // name. Promote it to `replay.webm` beside verdict.json (the same place // the fast-replay path writes its video) so the scenario folder is // self-describing, then drop the recording dir. if (aiVideoDir) { if (verdict.video && deps.fileExists(verdict.video)) { const dest = path.join(scenarioDir, 'replay.webm'); deps.copyFile(verdict.video, dest); verdict.video = dest; } else { verdict.video = undefined; } deps.removeDir(aiVideoDir); } // A failing scenario is worth debugging — bring its dumps into the folder. // So is a scenario that PASSED but only after healing (replay attempted and // failed): its agent a11y snapshots show the element raw replay couldn't // resolve, which is the only way to root-cause a step that heals every run // (the artifacts are otherwise shred on pass). A clean pass with no heal // leaves nothing behind but the screenshot + verdict. if (verdict.status !== 'pass' || replay) { for (const file of deps.listDir(agentDir)) { if (isPrunableArtifact(file)) deps.copyFile(path.join(agentDir, file), path.join(scenarioDir, file)); } } deps.removeDir(agentDir); // A genuinely failing scenario won't yield a trace — report it as-is, // don't burn re-record attempts on a real failure. if (verdict.status !== 'pass') { // An `unknown` verdict means the agent's output couldn't be parsed — // keep the raw stream (normally discarded) so the malformed // ::verdict:: emission is diagnosable instead of vanishing with the // whole record (16 minutes lost silently on 2026-07-03). if (verdict.status === 'unknown') { const agentLog = path.join(scenarioDir, 'agent-output.log'); deps.writeFile(agentLog, run.rawOutput); deps.log(chalk.gray(` ${symbols.branch}raw agent output saved for diagnosis: ${agentLog}`)); } break; } // Validate the freshly recorded skeleton by self-replaying it with raw // Playwright BEFORE persisting anything. The agent records role/name/label // locators (it can't see DOM ids through the a11y snapshot); raw Playwright // CAN read the live DOM, so this pass both PROVES the trace is replayable // and rewrites every resolved actionable locator to the element's real // `#id` (scoping a duplicate-row click via hasText, and resolving the // catalog product click via the module-keyed add-button id). We cache ONLY // what self-replays end-to-end, so a lossy AI recording (a dropped UI step // or fill value) never lands in the committable cache, never fast-replay- // fails next run, and — critically — never gets re-fed to the agent as a // broken fast-path hint that teaches it to re-record the same lossy path. // This same pass records the --video .webm when the AI path didn't. if (traceToSave) { const wantEagerVideo = wantVideo && !verdict.video; deps.log( chalk.gray(` ${symbols.retry}replaying recorded path to id-anchor the cache (raw Playwright, no AI)`), ); let anchored = traceToSave; const replayAnchoredOnce = async () => { let outcome = await deps.replayTrace({ steps: anchored, storageStatePath, outputDir: scenarioDir, moduleKey: rootConfig.productModuleKey, organizationId: rootConfig.organizationId, recordVideo: wantEagerVideo, ...(stepTimeoutMs === undefined ? {} : { stepTimeoutMs }), }); // An `expect` the raw replay can't resolve is a false-negative ASSERTION, // not a broken action path: the agent already confirmed the outcome on // its own run, so the on-screen wording simply didn't match the recorded // locator literally. Drop it and re-replay the ACTIONABLE skeleton — far // better than burning a full slow re-record on an assertion no re-record // can make raw-resolvable. We only fall through to re-record when an // actionable step (click/fill/select/hover) fails, the true signal of a // lossy recording (a missing step or a dropped value). while (outcome.status === 'heal-needed' && outcome.failedStep?.action === 'expect') { const { trace: trimmed, dropped } = dropUnresolvableAssert(anchored, outcome.failedStep); if (!dropped || trimmed.length === anchored.length) break; deps.log( chalk.yellow( ` ${symbols.warning}dropped an unreplayable check (${describeLocator(dropped)}) — the AI confirmed the outcome another way; caching the actionable path`, ), ); anchored = trimmed; outcome = await deps.replayTrace({ steps: anchored, storageStatePath, outputDir: scenarioDir, moduleKey: rootConfig.productModuleKey, organizationId: rootConfig.organizationId, recordVideo: wantEagerVideo, ...(stepTimeoutMs === undefined ? {} : { stepTimeoutMs }), }); } return outcome; }; let eagerReplay = await replayAnchoredOnce(); // A single anchor replay can flake on a transient render race (observed // live 2026-07-03: the catalog Add button mid-re-render read "not // stable" then "not visible"; a slow form load left `#cover_amount` // unresolved). One replay retry costs ~a minute against a fresh // browser; the re-record it would otherwise trigger costs 10-20 // minutes of AI time. Retry ONCE — a genuinely lossy trace still // fails the retry and falls through to re-record as before. if (eagerReplay.status !== 'replayed') { deps.log( chalk.yellow( ` ${symbols.warning}anchor replay flaked (${eagerReplay.reason}) — retrying the replay once before re-recording`, ), ); eagerReplay = await replayAnchoredOnce(); } if (eagerReplay.status === 'replayed') { selfReplayed = true; // FIRST and ONLY cache write for this record: persist the id-anchored // steps the replay produced (or the self-replayed skeleton if nothing // was rewritten), so the cache converges to deterministic #id selectors // in a single pass and is GUARANTEED to fast-replay next run. const finalSteps = eagerReplay.steps ?? anchored; const saved = saveTrace(cacheIo, { moduleDir: cwd, scenarioId: scenario.id, moduleKey: rootConfig.productModuleKey, dashboardHost, fingerprint, steps: finalSteps, recordedReason: verdict.reason, }); if (saved) { deps.log(chalk.gray(` ${symbols.edit}cached path id-anchored (${saved.steps.length} steps)`)); } if (wantEagerVideo) { verdict.video = eagerReplay.video; if (eagerReplay.screenshot) verdict.screenshot = eagerReplay.screenshot; } } else if (attempt < MAX_RECORD_ATTEMPTS) { // The just-recorded path didn't self-replay — the agent's trace is lossy // (a missing UI step or a dropped fill value) or hit a genuinely // duplicate target. Nothing was cached, so just re-record for a // replayable trace rather than persisting one we KNOW will heal. deps.log( chalk.yellow( ` ${symbols.warning}recorded path didn't self-replay (${eagerReplay.reason}) — re-recording for a replayable trace`, ), ); } else if (cached) { // Attempts exhausted but a prior cache exists — leave it intact rather // than overwriting a known-good (id-anchored) cache with a trace we KNOW // heals. This run already healed via the AI agent, so it still passed. deps.log( chalk.yellow( ` ${symbols.warning}recorded path didn't self-replay after ${MAX_RECORD_ATTEMPTS} attempts (${eagerReplay.reason}) — keeping the previous cache; this run healed`, ), ); } else { // Attempts exhausted and no prior cache. Deliberately cache NOTHING: a // trace that failed self-replay is lossy, and persisting it would re-feed // it to the agent as a `cachedTrace` hint next run — teaching it the same // broken path (the cache-poisoning loop). A clean slate lets the next run // record from the prompt alone, which is how the other scenarios converge. // The scenario still passed THIS run (it healed via the AI agent). deps.log( chalk.yellow( ` ${symbols.warning}recorded path didn't self-replay after ${MAX_RECORD_ATTEMPTS} attempts (${eagerReplay.reason}) — caching nothing so the next run records clean (no poisoned hint)`, ), ); } } } while (!selfReplayed && attempt < MAX_RECORD_ATTEMPTS); } results.push(verdict); deps.log( `${verdictSymbol(verdict)}${scenario.id} (${(durationMs / 1000).toFixed(1)}s)${outcomeTag} — ${verdict.reason}`, ); if (verdict.screenshot) { deps.log(chalk.gray(` screenshot: ${verdict.screenshot}`)); } if (verdict.video) { deps.log(chalk.gray(` video: ${verdict.video}`)); } else if (verdict.status === 'pass') { deps.log(chalk.gray(` ${symbols.info}no video — ${noVideoNote(wantVideo)}`)); } // Make the scenario folder self-describing: the verdict travels with the // screenshot/video instead of living only in the terminal scrollback. We // record the video as a bare filename (it sits beside this verdict.json, so // no absolute path) — or `null` plus a `videoNote` when no video was // recorded, so the missing .webm reads as an expected state, not a bug. deps.writeFile( path.join(scenarioDir, 'verdict.json'), JSON.stringify( { id: verdict.id, status: verdict.status, reason: verdict.reason, durationMs: Math.round(durationMs), video: verdict.video ? path.basename(verdict.video) : null, ...(verdict.video || verdict.status !== 'pass' ? {} : { videoNote: noVideoNote(wantVideo) }), }, null, 2, ), ); if (options.bail && verdict.status === 'fail') { deps.log(chalk.yellow(`${symbols.warning}Bail mode: stopping after first failure.`)); break; } } const passed = results.filter((r) => r.status === 'pass').length; const failed = results.filter((r) => r.status === 'fail').length; const unknown = results.filter((r) => r.status === 'unknown').length; deps.log(''); const exitCode = failed === 0 && unknown === 0 ? 0 : 1; const summaryMessage = `Summary: ${passed} passed · ${failed} failed · ${unknown} unknown · ${results.length} total`; deps.log(exitCode === 0 ? chalk.green.bold(summaryMessage) : chalk.red.bold(summaryMessage)); return { scenarios: results, exitCode }; } finally { // Always shred the session token, pass or fail (even on a thrown error). deps.cleanupSessionDir(sessionDir); } };