diff --git a/tests/uat/extension-page-harness.ts b/tests/uat/extension-page-harness.ts index 1d71906..0ea9db0 100644 --- a/tests/uat/extension-page-harness.ts +++ b/tests/uat/extension-page-harness.ts @@ -3425,24 +3425,62 @@ async function assertA29(): Promise { * setupInputLogging at line 77, setupNavigationLogging at line * 99, setupErrorLogging at line 133, setupNetworkLogging at * line 164) all fire on synthetic browser events dispatched - * on the harness page, producing UserEvent entries with each - * of the 5 type-values (click / input / navigation / - * js_error / network_error) in logs/events.json. + * in a probe https:// tab where the production content script + * is injected, producing UserEvent entries with each of the 5 + * type-values (click / input / navigation / js_error / + * network_error) in logs/events.json. * - * Trigger strategy (all on the harness page; no new tabs opened): - * - click: programmatic .click() on #probe-submit - * - input: set #probe-email.value + dispatch Event('input', bubbles:true) - * - navigation: history.pushState (intercepted at src/content/index.ts:121) + * Implementation note — MV3 content-script reachability (deviation): + * The plan as written assumed `` content_scripts coverage + * includes `chrome-extension://` URLs — empirically (Task 2 + * verification dump 2026-05-20T17:36:25Z), it does NOT. The Chrome + * match-pattern docs are explicit: `` permits the schemes + * `http`, `https`, `file`, `ftp`, `urn` — NOT `chrome-extension`. + * The SW SAVE_ARCHIVE handler logged "Could not establish connection. + * Receiving end does not exist." when targeting the harness page, + * confirming no content script is present on chrome-extension://. + * + * A30 therefore creates a fresh `https://example.com` probe tab + * (mirrors A27's pattern, including DEC-011 Amendment 1 `tabs` + * permission), uses chrome.scripting.executeScript (default + * ISOLATED world — the content script's world) to dispatch all 5 + * triggers, then SAVEs while the probe tab is active so the SW + * harvests events from the page where the content script is alive. + * + * In addition: even if `chrome-extension://` HAD been covered by + * ``, page-world `fetch()` from `page.evaluate(...)` would + * NOT have been intercepted by `src/content/index.ts:167` + * (`window.fetch = ...`) — content-script global mutations stay + * inside the ISOLATED world. executeScript with default ISOLATED + * world targeting + the content-script's own runtime view of fetch + * solves both issues with one mechanism. + * + * Trigger strategy (all inside an injected ISOLATED-world script on + * the example.com probe tab): + * - click: dispatch a real `MouseEvent('click')` on document.body + * - input: create a synthetic , set value, dispatchEvent('input') + * - navigation: window.dispatchEvent(new PopStateEvent('popstate')) + * (production `popstate` listener at src/content/index.ts:111; + * NOTE: history.pushState was the plan-spec mechanism, but + * it triggers a Puppeteer CDP execution-context teardown + * — see deviation log + the popstate path is functionally + * equivalent since the production code in + * src/content/index.ts:121 wraps pushState by also firing + * handleNavigation which routes through the same + * listener as popstate at line 111). * - js_error: window.dispatchEvent(new ErrorEvent('error', ...)) - * - network_error: fetch(404-probe-url).catch(noop) — production - * fetch interception at src/content/index.ts:167 logs response.ok===false + * - network_error: fetch(404-probe-url).catch(noop) — runs in + * ISOLATED world so the patched window.fetch + * at src/content/index.ts:167 fires. * - * Page-side dispatches all 5 triggers + settles + SAVE. Host-side - * driveA30 JSZip-parses logs/events.json and asserts each of the 5 - * UserEvent.type literal values is present. + * Page-side opens probe tab + injects triggers + settles + SAVE. + * Host-side driveA30 JSZip-parses logs/events.json + asserts each of + * the 5 UserEvent.type literal values is present. * * FORBIDDEN_HOOK_STRINGS impact: NONE. A30 rides production listeners - * + existing helpers. Tier-1 inventory stays at 12. + * + chrome.tabs.* (`tabs` perm) + chrome.scripting.executeScript + * (`scripting` perm — already in manifest) + existing helpers. Tier-1 + * inventory stays at 12. */ /** SAVE_ARCHIVE dispatch timeout for A30 — matches A24/A25/A27/A29. */ @@ -3450,18 +3488,26 @@ const A30_SAVE_ARCHIVE_TIMEOUT_MS = 15_000; /** Pre-SAVE segment-settle window (10s rotation + 1s slack). */ const A30_SEGMENT_SETTLE_MS = 11_000; /** Settle between trigger dispatches and SAVE so event handlers complete. */ -const A30_TRIGGER_SETTLE_MS = 500; -/** 404 probe URL — chrome.tabs perm grant is irrelevant; fetch happens - * from the harness page realm. example.com is RFC 2606 reserved + - * serves a 404 reliably for unknown paths under headless Chrome. */ +const A30_TRIGGER_SETTLE_MS = 1_000; +/** Wait after chrome.tabs.create for the tab navigation to complete so + * the content script attaches + production listeners are set up + * (mirrors A27_TAB_NAVIGATION_WAIT_MS = 1.5s). */ +const A30_TAB_NAVIGATION_WAIT_MS = 1_500; +/** Probe tab URL — example.com is RFC 2606 reserved + serves stable + * HTML under headless Chrome (Plan 02-04 A27 fixture parity). */ +const A30_PROBE_TAB_URL = 'https://example.com/'; +/** 404 probe URL — same origin as the probe tab so the fetch is a + * same-origin GET (no CORS preflight noise). */ const A30_404_PROBE_URL = 'https://example.com/this-path-does-not-exist-404-probe-a30'; /** * A30 — Event-log empirical (SPEC §10 #5 / REQ-user-event-log). * - * Dispatches 5 synthetic browser events that exercise each of the - * production listeners; runs setupFreshRecording so event-log - * cleanup hasn't dropped anything; settles a segment; SAVEs. Host-side + * Creates a fresh `https://example.com` probe tab, injects all 5 + * synthetic event triggers into the content script's ISOLATED world + * via chrome.scripting.executeScript so the production listeners fire, + * settles a segment, SAVEs while the probe tab is active so the SW + * harvests the content script's userEvents[] from that tab. Host-side * driveA30 inspects logs/events.json from the produced zip and asserts * each of the 5 UserEvent.type literal values appears at least once. * @@ -3476,6 +3522,8 @@ async function assertA30(): Promise { diagnostics: [], }; + let probeTabId: number | undefined; + try { diag(result, 'Step 1: setupFreshRecording (A30 owns its recording — clean event-log window)'); const setupResp = await setupFreshRecording(); @@ -3484,54 +3532,108 @@ async function assertA30(): Promise { } diag(result, 'Step 1 OK — REC state established'); - diag(result, `Step 2: settle ${A30_SEGMENT_SETTLE_MS}ms for first segment rotation`); + diag(result, `Step 2: chrome.tabs.create(${A30_PROBE_TAB_URL}, active:true) — content script ISOLATED world is alive on https://, not on chrome-extension://`); + const probeTab = await chrome.tabs.create({ url: A30_PROBE_TAB_URL, active: true }); + probeTabId = probeTab.id; + diag(result, `Step 2 result: probeTab.id=${probeTabId}, probeTab.url=${probeTab.url ?? ''}`); + if (probeTabId === undefined) { + throw new Error('chrome.tabs.create returned undefined tab.id'); + } + + diag(result, `Step 3: wait ${A30_TAB_NAVIGATION_WAIT_MS}ms for navigation + content script attach`); + await new Promise((r) => setTimeout(r, A30_TAB_NAVIGATION_WAIT_MS)); + + diag(result, `Step 4: settle ${A30_SEGMENT_SETTLE_MS}ms for first segment rotation`); await new Promise((r) => setTimeout(r, A30_SEGMENT_SETTLE_MS)); - diag(result, 'Step 3: click trigger — programmatic .click() on #probe-submit'); - const submitBtn = document.querySelector('#probe-submit'); - if (submitBtn !== null) { - submitBtn.click(); - } else { - diag(result, 'Step 3 WARN — #probe-submit missing; click trigger skipped'); - } + diag(result, 'Step 5: chrome.scripting.executeScript — inject 5 synthetic triggers in ISOLATED world (content-script realm; fetch wrapper at src/content/index.ts:167 sees the fetch)'); + const probeUrl = A30_404_PROBE_URL; + const injectionResults = await chrome.scripting.executeScript({ + target: { tabId: probeTabId }, + world: 'ISOLATED', + func: async (probe404Url: string): Promise<{ + click: boolean; + input: boolean; + navigation: boolean; + jsError: boolean; + networkErrorTriggered: boolean; + fetchThrew: boolean; + }> => { + // click — synthetic MouseEvent on the document body. Production + // listener at src/content/index.ts:61 captures the click via + // document.addEventListener('click', ...). + const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true }); + const clickDispatched = document.body.dispatchEvent(clickEvent); - diag(result, 'Step 4: input trigger — set #probe-email.value + dispatch input event'); - const emailInput = document.querySelector('#probe-email'); - if (emailInput !== null) { - emailInput.value = 'a30@probe.local'; - emailInput.dispatchEvent(new Event('input', { bubbles: true })); - } else { - diag(result, 'Step 4 WARN — #probe-email missing; input trigger skipped'); - } + // input — synthetic , set value, dispatchEvent('input', + // bubbles:true). Production listener at src/content/index.ts:78 + // captures via document.addEventListener('input', ...). Skips + // password type (line 82) — type='text' here. + const probeInput = document.createElement('input'); + probeInput.type = 'text'; + probeInput.id = 'a30-probe-input'; + probeInput.value = 'a30@probe.local'; + document.body.appendChild(probeInput); + const inputDispatched = probeInput.dispatchEvent( + new Event('input', { bubbles: true }), + ); + probeInput.remove(); - diag(result, 'Step 5: navigation trigger — history.pushState (production wrapper at src/content/index.ts:121 intercepts)'); - history.pushState({}, '', window.location.pathname + '#a30-probe'); + // navigation — window-level popstate event. Production listener + // at src/content/index.ts:111 captures via + // window.addEventListener('popstate', ...). + const navigationDispatched = window.dispatchEvent( + new PopStateEvent('popstate', { state: {} }), + ); - diag(result, 'Step 6: js_error trigger — window.dispatchEvent(ErrorEvent("error"))'); - window.dispatchEvent(new ErrorEvent('error', { - message: 'a30-probe-js-error', - filename: 'a30-probe.js', - lineno: 1, - colno: 1, - })); + // js_error — window-level ErrorEvent. Production listener at + // src/content/index.ts:134 captures via + // window.addEventListener('error', ...). + const errorDispatched = window.dispatchEvent( + new ErrorEvent('error', { + message: 'a30-probe-js-error', + filename: 'a30-probe.js', + lineno: 1, + colno: 1, + }), + ); - diag(result, `Step 7: network_error trigger — fetch(${A30_404_PROBE_URL}) (.catch noop)`); - try { - await fetch(A30_404_PROBE_URL); - } catch (fetchErr) { - diag(result, `Step 7 fetch threw (acceptable for network_error path): ${String(fetchErr)}`); - } + // network_error — fetch into a 404 path. The content script + // patches window.fetch at src/content/index.ts:167 in its + // ISOLATED world; this fetch is in the SAME ISOLATED world + // so it routes through the wrapper. response.ok===false → + // addUserEvent({type:'network_error'}) at line 171. + let fetchThrew = false; + try { + await fetch(probe404Url); + } catch (fetchErr) { + fetchThrew = true; + } - diag(result, `Step 8: settle ${A30_TRIGGER_SETTLE_MS}ms so async handlers (fetch.then) complete`); + return { + click: clickDispatched, + input: inputDispatched, + navigation: navigationDispatched, + jsError: errorDispatched, + networkErrorTriggered: true, + fetchThrew, + }; + }, + args: [probeUrl], + }); + const injectionSummary = injectionResults[0]?.result ?? null; + diag(result, `Step 5 result: ${JSON.stringify(injectionSummary)}`); + + diag(result, `Step 6: settle ${A30_TRIGGER_SETTLE_MS}ms so async handlers (fetch.then) complete + userEvents[] populates`); await new Promise((r) => setTimeout(r, A30_TRIGGER_SETTLE_MS)); - diag(result, 'Step 9: dispatch SAVE_ARCHIVE'); + diag(result, 'Step 7: dispatch SAVE_ARCHIVE (probe tab is the active tab; SW will harvest from there)'); const ack = await sendMessageWithTimeout<{ success: boolean; error?: string }>( { type: 'SAVE_ARCHIVE' }, A30_SAVE_ARCHIVE_TIMEOUT_MS, 'SAVE_ARCHIVE (A30)', ); - diag(result, `Step 9 result: ${JSON.stringify(ack)}`); + diag(result, `Step 7 result: ${JSON.stringify(ack)}`); result.checks.push({ name: 'A30.1: SAVE_ARCHIVE ack received with success=true', @@ -3544,6 +3646,15 @@ async function assertA30(): Promise { } catch (err) { result.error = err instanceof Error ? err.message : String(err); diag(result, `THREW: ${result.error}`); + } finally { + // T-02-04-04 mitigation parity: cleanup probe tab with silent-ignore. + if (probeTabId !== undefined) { + try { + await chrome.tabs.remove(probeTabId); + } catch (rmErr) { + diag(result, `(probe tab cleanup ignored: ${String(rmErr)})`); + } + } } return result; diff --git a/tests/uat/harness.test.ts b/tests/uat/harness.test.ts index f9ec2e6..0e4e39d 100644 --- a/tests/uat/harness.test.ts +++ b/tests/uat/harness.test.ts @@ -99,6 +99,8 @@ import { driveA28, // Plan 03-01 — rrweb DOM verification (SPEC §10 #4 / REQ-rrweb-dom-buffer) driveA29, + // Plan 03-02 — event-log verification (SPEC §10 #5 / REQ-user-event-log) + driveA30, getManifestVersion, } from './lib/harness-page-driver'; import { @@ -267,7 +269,7 @@ async function assertA0_GrepGate(): Promise<{ */ async function main(): Promise { process.stdout.write('\nMokosh Plan 01-13 + 01-14 + 02-04 — UAT harness orchestrator\n'); - process.stdout.write('Architecture: A0 pre-flight + extension-internal page driver (A1..A14, A15..A17, A18..A22, A23, A24, A25, A26, A27, A28, A29)\n'); + process.stdout.write('Architecture: A0 pre-flight + extension-internal page driver (A1..A14, A15..A17, A18..A22, A23, A24, A25, A26, A27, A28, A29, A30)\n'); process.stdout.write('='.repeat(72) + '\n'); // A0 pre-flight (no Chrome launch needed; runs against built dist/). @@ -339,6 +341,10 @@ async function main(): Promise { // of rrweb/session.json from the just-produced zip. const driveA29Wrapped: (page: import('puppeteer').Page) => Promise = (page) => driveA29(page, handles.downloadsDir); + // Plan 03-02 — driveA30 needs downloadsDir for host-side JSZip parse + // of logs/events.json from the just-produced zip. + const driveA30Wrapped: (page: import('puppeteer').Page) => Promise = + (page) => driveA30(page, handles.downloadsDir); const drivers: ReadonlyArray<{ readonly name: string; @@ -441,6 +447,13 @@ async function main(): Promise { // rrweb/session.json and asserts the EventType enum surfaces // (Meta=4, FullSnapshot=2, IncrementalSnapshot=3) are present. { name: 'A29', drive: driveA29Wrapped }, + // Plan 03-02 A30: event-log verification (SPEC §10 #5). + // A30 owns its SAVE because event-log cleanup runs every 60s + // (src/content/index.ts CLEANUP_INTERVAL_MS=60_000) and we need a + // fresh event-log window for the 5 synthetic triggers. Host-side + // driveA30 JSZip-parses logs/events.json and asserts presence of + // each of the 5 UserEvent.type literal values. + { name: 'A30', drive: driveA30Wrapped }, ]; const buffers = { swConsole: handles.swConsole, offConsole: handles.offConsole }; diff --git a/tests/uat/lib/harness-page-driver.ts b/tests/uat/lib/harness-page-driver.ts index 8ca1afe..237e241 100644 --- a/tests/uat/lib/harness-page-driver.ts +++ b/tests/uat/lib/harness-page-driver.ts @@ -44,6 +44,7 @@ import type { Page } from 'puppeteer'; import type { AssertionRecord, CheckRecord } from './assertions'; import { assertArchiveShape, extractEntryToFile } from './zip'; +import type { UserEvent } from '../../../src/shared/types'; /** * Extended assertion-record shape for A5/A12/A13 which return @@ -1998,3 +1999,150 @@ export async function driveA29( error: pageResult.error, }; } + +/* ─── Plan 03-02 — driveA30 (event-log verification host-side) ──────── */ + +/** Canonical 5-tuple of UserEvent.type literal values per + * CON-event-log-schema + src/shared/types.ts:126. Driver iterates this + * list to push one presence-check per type. */ +const A30_EXPECTED_TYPES: ReadonlyArray = [ + 'click', + 'input', + 'navigation', + 'js_error', + 'network_error', +]; + +/** + * Drive A30 (Plan 03-02 — SPEC §10 #5 / REQ-user-event-log). + * + * Page-side assertA30 dispatches 5 synthetic event triggers + * (click/input/navigation/js_error/network_error) + setupFreshRecording + * + SAVE. Host-side driveA30 JSZip-parses logs/events.json from the + * produced zip and asserts each of the 5 UserEvent.type literal values + * appears at least once. + * + * Filter-pipeline form (no `continue`) per CLAUDE.md Control Flow §. + * + * Checks (6 total — 1 page-side + 5 host-side): + * - A30.1: SAVE_ARCHIVE ack success (page-side) + * - A30.2: logs/events.json contains >=1 'click' event + * - A30.3: logs/events.json contains >=1 'input' event + * - A30.4: logs/events.json contains >=1 'navigation' event + * - A30.5: logs/events.json contains >=1 'js_error' event + * - A30.6: logs/events.json contains >=1 'network_error' event + * + * @param page - The harness page from `launchHarnessBrowser`. + * @param downloadsDir - Absolute path to the per-run downloads directory. + * @returns AssertionRecord with 6 merged checks. + */ +export async function driveA30( + page: Page, + downloadsDir: string, +): Promise { + // Phase 1 — page-side orchestration + SAVE. + const pageResult = await page.evaluate(async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose. + const harness = (window as any).__mokoshHarness; + const r: AssertionRecord = await harness.assertA30(); + return r; + }) as AssertionRecord; + + const mergedChecks: CheckRecord[] = pageResult.checks.slice(); + const mergedDiagnostics: string[] = pageResult.diagnostics.slice(); + + // Phase 2 — locate the produced zip. + const zipPath = findLatestZip(downloadsDir); + if (zipPath === null) { + mergedChecks.push({ + name: 'A30.0: at least one zip present in downloadsDir', + expected: '>=1 zip', + actual: 'no zip in downloadsDir', + passed: false, + }); + return { + passed: false, + name: pageResult.name, + checks: mergedChecks, + diagnostics: mergedDiagnostics, + error: pageResult.error, + }; + } + mergedDiagnostics.push(`A30 zipPath=${zipPath}`); + + // Phase 3 — load + inspect logs/events.json. + const zipBytes = readFileSync(zipPath); + const zip = await JSZip.loadAsync(zipBytes); + const eventsFile = zip.file('logs/events.json'); + mergedChecks.push({ + name: 'A30.0a: logs/events.json entry exists in zip', + expected: true, + actual: eventsFile !== null, + passed: eventsFile !== null, + }); + + if (eventsFile === null) { + return { + passed: false, + name: pageResult.name, + checks: mergedChecks, + diagnostics: mergedDiagnostics, + error: pageResult.error, + }; + } + + const eventsRaw = await eventsFile.async('string'); + let userEvents: UserEvent[] = []; + let parseErr: string | null = null; + try { + userEvents = JSON.parse(eventsRaw) as UserEvent[]; + } catch (err) { + parseErr = err instanceof Error ? err.message : String(err); + } + + if (parseErr !== null) { + mergedChecks.push({ + name: 'A30.0b: logs/events.json parses as JSON', + expected: 'JSON.parse success', + actual: ``, + passed: false, + }); + return { + passed: false, + name: pageResult.name, + checks: mergedChecks, + diagnostics: mergedDiagnostics, + error: pageResult.error, + }; + } + + // Filter-pipeline form per CLAUDE.md Control Flow §. + const typeCountsMap = new Map(); + for (const expectedType of A30_EXPECTED_TYPES) { + typeCountsMap.set(expectedType, userEvents.filter((e) => e.type === expectedType).length); + } + mergedDiagnostics.push(`A30 userEvents.length=${userEvents.length}`); + const typeCountsRepr = [...typeCountsMap.entries()].map(([t, n]) => `${t}=${n}`).join(','); + mergedDiagnostics.push(`A30 type counts: ${typeCountsRepr}`); + + let checkIndex = 2; + for (const expectedType of A30_EXPECTED_TYPES) { + const count = typeCountsMap.get(expectedType) ?? 0; + mergedChecks.push({ + name: `A30.${checkIndex}: logs/events.json contains at least one '${expectedType}' event`, + expected: `>=1 ${expectedType}`, + actual: count, + passed: count > 0, + }); + checkIndex += 1; + } + + const mergedPassed = mergedChecks.every((c) => c.passed); + return { + passed: mergedPassed, + name: pageResult.name, + checks: mergedChecks, + diagnostics: mergedDiagnostics, + error: pageResult.error, + }; +}