diff --git a/tests/uat/harness.test.ts b/tests/uat/harness.test.ts index b9f2664..529da8e 100644 --- a/tests/uat/harness.test.ts +++ b/tests/uat/harness.test.ts @@ -109,6 +109,9 @@ import { // reframe per debug session-2 verdict; needs Browser + extensionId for // CDP-based SW kill + downloadsDir for host-side JSZip parse). driveA33, + // Plan 04-05 — driveA34 fetch + XHR network_error empirical (ROADMAP SC #2; + // needs downloadsDir for host-side JSZip parse of logs/events.json). + driveA34, getManifestVersion, } from './lib/harness-page-driver'; import { @@ -363,6 +366,10 @@ async function main(): Promise { // AND downloadsDir for host-side JSZip parse of post-restart zip. const driveA33Wrapped: (page: import('puppeteer').Page) => Promise = (page) => driveA33(page, handles.browser, handles.extensionId, handles.downloadsDir); + // Plan 04-05 — driveA34 needs downloadsDir for host-side JSZip parse of + // logs/events.json (fetch + XHR network_error entry inspection). + const driveA34Wrapped: (page: import('puppeteer').Page) => Promise = + (page) => driveA34(page, handles.downloadsDir); const drivers: ReadonlyArray<{ readonly name: string; @@ -514,6 +521,17 @@ async function main(): Promise { }) : driveA33Wrapped, }, + // Plan 04-05 A34: fetch + XHR network_error empirical (ROADMAP SC #2). + // Verifies both protocol paths in src/content/index.ts setupNetworkLogging + // produce events.json entries. Empirically validates Plan 04-01 P1 #11 + // fetch URL extraction fix at the SAVE->archive layer (A34.4 + A34.5). + // A34 owns its SAVE because event-log cleanup runs every 60s + // (src/content/index.ts CLEANUP_INTERVAL_MS) and the 2 synthetic + // failing requests need a fresh event-log window. Opens a fresh + // https://example.com probe tab + injects fetch(404)+XHR(404) via + // chrome.scripting.executeScript ISOLATED-world. Runs ~25s (always + // RUN — not env-gated; the 5-min wait is A33's, not A34's). + { name: 'A34', drive: driveA34Wrapped }, ]; 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 9f17f28..f8d634f 100644 --- a/tests/uat/lib/harness-page-driver.ts +++ b/tests/uat/lib/harness-page-driver.ts @@ -2710,3 +2710,203 @@ export async function driveA33( diagnostics, }; } + +/* ─── Plan 04-05 — driveA34 (fetch + XHR network_error host-side) ───── */ + +/** Substring marker for the fetch 404 probe path — same literal the + * page-side A34_404_FETCH_PATH constant produces. driveA34 filters + * network_error entries whose target contains this. */ +const A34_FETCH_MARKER = '404-fetch-a34'; +/** Substring marker for the XHR 404 probe path — same literal the + * page-side A34_404_XHR_PATH constant produces. */ +const A34_XHR_MARKER = '404-xhr-a34'; +/** Expected HTTP status for both the fetch + XHR 404 probes. */ +const A34_EXPECTED_STATUS = 404; + +/** + * Read a numeric `status` field out of a UserEvent.meta record. + * + * `UserEvent.meta` is typed `Record` (src/shared/types.ts), + * so the production wrappers' `meta.status` value arrives untyped. This + * helper narrows it to a number (or `null` when absent / non-numeric) + * without an unchecked `any` cast. + * + * @param event - The UserEvent whose meta.status to read. + * @returns The numeric status, or null when meta.status is missing or + * not a number. + */ +function readMetaStatus(event: UserEvent): number | null { + const status = event.meta?.status; + return typeof status === 'number' ? status : null; +} + +/** + * Drive A34 (Plan 04-05 — ROADMAP SC #2: fetch + XHR network_error). + * + * Page-side assertA34 opened a fresh https://example.com probe tab, + * injected a `fetch(404)` + an `XMLHttpRequest` GET against a 404 path + * into the content-script's ISOLATED world via + * chrome.scripting.executeScript so BOTH production network wrappers in + * src/content/index.ts (window.fetch + XMLHttpRequest.prototype) + * intercept the failing requests, settled, SAVEd, finally-cleanup the + * tab. Host-side driveA34 JSZip-parses logs/events.json and asserts: + * - >=1 network_error entry whose target contains '404-fetch-a34' + * (proves the fetch wrapper fired AND — per Plan 04-01 P1 #11 — + * the target carries the real URL, NOT the literal + * '[object Request]' that pre-fix implicit coercion produced) + * - >=1 network_error entry whose target contains '404-xhr-a34' + * (proves the XHR loadend wrapper fired — the distinct code path + * A30 never exercised) + * - the fetch entry's meta.status === 404 (end-to-end status pin) + * - the XHR entry's meta.status === 404 + * + * Filter-pipeline form (no `continue`) per CLAUDE.md Control Flow §. + * + * Checks (6 total — 1 page-side + 5 host-side): + * - A34.1: SAVE_ARCHIVE ack success (page-side) + * - A34.0a: logs/events.json entry exists in zip + * - A34.2: >=1 fetch network_error entry (Plan 04-01 P1 #11 end-to-end) + * - A34.3: >=1 XHR network_error entry + * - A34.4: fetch entry meta.status === 404 + * - A34.5: XHR entry meta.status === 404 + * + * @param page - The harness page from `launchHarnessBrowser`. + * @param downloadsDir - Absolute path to the per-run downloads directory. + * @returns AssertionRecord with the merged checks. + */ +export async function driveA34( + 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.assertA34(); + 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: 'A34.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(`A34 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: 'A34.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: 'A34.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 networkErrors = userEvents.filter((e) => e.type === 'network_error'); + const fetchEntries = networkErrors.filter( + (e) => typeof e.target === 'string' && e.target.includes(A34_FETCH_MARKER), + ); + const xhrEntries = networkErrors.filter( + (e) => typeof e.target === 'string' && e.target.includes(A34_XHR_MARKER), + ); + mergedDiagnostics.push(`A34 userEvents.length=${userEvents.length}, network_error count=${networkErrors.length}`); + mergedDiagnostics.push( + `A34 fetch-entry count=${fetchEntries.length}, xhr-entry count=${xhrEntries.length}`, + ); + + const fetchStatus = fetchEntries.length > 0 ? readMetaStatus(fetchEntries[0]) : null; + const xhrStatus = xhrEntries.length > 0 ? readMetaStatus(xhrEntries[0]) : null; + mergedDiagnostics.push( + `A34 fetch-entry[0].target=${fetchEntries[0]?.target ?? ''} meta.status=${fetchStatus ?? ''}`, + ); + mergedDiagnostics.push( + `A34 xhr-entry[0].target=${xhrEntries[0]?.target ?? ''} meta.status=${xhrStatus ?? ''}`, + ); + + mergedChecks.push({ + name: "A34.2: fetch 404 produced network_error entry containing '404-fetch-a34' (Plan 04-01 P1 #11 end-to-end — target carries the real URL, not '[object Request]')", + expected: '>=1 fetch entry', + actual: fetchEntries.length, + passed: fetchEntries.length >= 1, + }); + mergedChecks.push({ + name: "A34.3: XHR 404 produced network_error entry containing '404-xhr-a34' (distinct XMLHttpRequest.prototype wrapper path)", + expected: '>=1 XHR entry', + actual: xhrEntries.length, + passed: xhrEntries.length >= 1, + }); + mergedChecks.push({ + name: `A34.4: fetch network_error entry meta.status === ${A34_EXPECTED_STATUS}`, + expected: A34_EXPECTED_STATUS, + actual: fetchStatus, + passed: fetchStatus === A34_EXPECTED_STATUS, + }); + mergedChecks.push({ + name: `A34.5: XHR network_error entry meta.status === ${A34_EXPECTED_STATUS}`, + expected: A34_EXPECTED_STATUS, + actual: xhrStatus, + passed: xhrStatus === A34_EXPECTED_STATUS, + }); + + const mergedPassed = mergedChecks.every((c) => c.passed); + return { + passed: mergedPassed, + name: pageResult.name, + checks: mergedChecks, + diagnostics: mergedDiagnostics, + error: pageResult.error, + }; +}