From a20372a8b8458ef94acbd9540647e756c1425c98 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 22 May 2026 11:37:12 +0200 Subject: [PATCH] =?UTF-8?q?feat(04-05):=20A34=20page-side=20=E2=80=94=20cs?= =?UTF-8?q?-injection-world=20fetch=20+=20XHR=20404=20injection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Append assertA34 after assertA31 — cs-injection-world skeleton (verbatim from assertA30/A31; ROADMAP SC #2 empirical) - chrome.scripting.executeScript ISOLATED injects TWO 404 triggers into the content-script realm: fetch(404) + XMLHttpRequest(404) - fetch trigger validates Plan 04-01 P1 #11 (Request-narrow URL extraction) end-to-end in a real Chrome page context - XHR trigger covers the distinct XMLHttpRequest.prototype wrapper path that A30 did not exercise - Date.now() uniqueness stamp on both probe URLs (T-04-05-02) - assertA34 registered in Window interface + __mokoshHarness literal - Tier-1 FORBIDDEN_HOOK_STRINGS unchanged at 12 (rides production window.fetch + XMLHttpRequest.prototype + chrome.scripting/tabs) Co-Authored-By: Claude Opus 4.7 --- tests/uat/extension-page-harness.ts | 240 +++++++++++++++++++++++++++- 1 file changed, 238 insertions(+), 2 deletions(-) diff --git a/tests/uat/extension-page-harness.ts b/tests/uat/extension-page-harness.ts index 862f1f0..5412e7c 100644 --- a/tests/uat/extension-page-harness.ts +++ b/tests/uat/extension-page-harness.ts @@ -4020,6 +4020,239 @@ async function assertA31(): Promise { return result; } +/* ─── Plan 04-05 — A34 (fetch + XHR network_error empirical; ROADMAP SC #2) ─ + * + * A34 — ROADMAP SC #2 empirical: "A page that issues a failing `fetch` + * (response code >= 400) produces a `network_error` entry in + * `events.json`; a failing `XMLHttpRequest` does too." + * + * A34 EXTENDS Plan 03-02's A30 (which exercised the fetch path + * once via a 404-fetch from a probe tab) with: + * + * 1. An empirical end-to-end test that the Plan 04-01 P1 #11 + * fetch URL extraction fix (`args[0] instanceof Request ? + * args[0].url : String(args[0])` at src/content/index.ts:194 + * + :214) works in a REAL Chrome page context — the fetch + * network_error entry's `target` field must carry the + * actual URL, NOT the literal '[object Request]' that the + * pre-fix implicit coercion produced. A30 only proved a + * network_error entry EXISTS; A34.4 pins the URL value. + * + * 2. A complementary XMLHttpRequest 404 path that A30 does NOT + * cover. XHR uses a distinct code path in + * src/content/index.ts (the XMLHttpRequest.prototype.open + * + .send wrappers at lines ~225-258, with the loadend + * listener emitting network_error when xhr.status >= 400) + * and merits its own empirical gate. + * + * cs-injection-world pattern (Plan 03-02 / Plan 04-03 A29 precedent — + * verbatim skeleton from assertA30 / assertA31 with A34 substitutions): + * - chrome.tabs.create({url: 'https://example.com/'}) probe tab. + * - Wait 1.5s for content-script attach. + * - Wait 11s for first segment rotation. + * - chrome.scripting.executeScript world:'ISOLATED' injects TWO + * triggers into the content-script realm so BOTH production + * wrappers (window.fetch + XMLHttpRequest.prototype) intercept the + * failing requests: + * - fetch('https://example.com/404-fetch-a34-').catch(noop) + * - new XMLHttpRequest(); open('GET', '/404-xhr-a34-'); send() + * The `-` (Date.now()) suffix is a uniqueness guard against + * any future intermediate-caching behavior change (T-04-05-02). + * The fetch(404).catch(noop) is REQUIRED — without the catch the + * network rejection would surface as a separate js_error UserEvent + * which A34 does not care about. + * - Wait ~1s for both responses to land + both content-script + * wrappers to enqueue their network_error UserEvents (the XHR + * loadend listener is async; the fetch .then/.catch chain is async). + * - SAVE_ARCHIVE. + * - Host-side driveA34 JSZip-parses logs/events.json; filters for + * network_error entries whose target contains '404-fetch-a34' / + * '404-xhr-a34'; asserts >=1 of each + meta.status === 404. + * + * FORBIDDEN_HOOK_STRINGS impact: NONE. A34 rides production wrappers + * (window.fetch + XMLHttpRequest.prototype at src/content/index.ts) + + * chrome.tabs.* (`tabs` perm) + chrome.scripting.executeScript + * (`scripting` perm) + existing helpers. Tier-1 inventory stays at 12. + */ + +/** SAVE_ARCHIVE dispatch timeout for A34 — matches A24/A25/A27/A29/A30/A31. */ +const A34_SAVE_ARCHIVE_TIMEOUT_MS = 15_000; +/** Pre-SAVE segment-settle window (10s rotation + 1s slack). */ +const A34_SEGMENT_SETTLE_MS = 11_000; +/** Settle after the fetch+XHR triggers so BOTH async network-error + * wrappers in src/content/index.ts complete: the fetch .then/.catch + * chain AND the XHR loadend listener both enqueue their UserEvent. */ +const A34_NETWORK_SETTLE_MS = 1_000; +/** Wait after chrome.tabs.create for the tab navigation to complete so + * the content script attaches + production wrappers are installed + * (mirrors A30_TAB_NAVIGATION_WAIT_MS = 1.5s). */ +const A34_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 / Plan 03-02 A30 parity). */ +const A34_PROBE_TAB_URL = 'https://example.com/'; +/** 404 fetch probe path — same origin as the probe tab so the fetch is + * a same-origin GET (no CORS preflight noise). example.com serves a + * 404 for arbitrary unknown paths. driveA34 filters network_error + * entries whose target contains this literal. */ +const A34_404_FETCH_PATH = '/404-fetch-a34'; +/** 404 XHR probe path — distinct from the fetch path so driveA34 can + * tell the two protocol entries apart. Same same-origin rationale. */ +const A34_404_XHR_PATH = '/404-xhr-a34'; + +/** + * A34 — fetch + XHR network_error empirical (ROADMAP SC #2). + * + * Creates a fresh `https://example.com` probe tab (where the content + * script attaches normally per Plan 03-02 cs-injection-world insight), + * injects TWO failing-request triggers — a `fetch(404)` and 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 intercept them, settles a + * segment, SAVEs while the probe tab is active, finally-cleanup the + * tab. Host-side driveA34 inspects 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 '[object Request]') + * - >=1 network_error entry whose target contains '404-xhr-a34' + * (proves the XHR loadend wrapper fired) + * - the fetch entry's meta.status === 404 + * - the XHR entry's meta.status === 404 + * + * @returns AssertionResult with 1 page-side check (SAVE ack); host-side + * driveA34 appends the fetch/XHR presence + status checks. + */ +async function assertA34(): Promise { + const result: AssertionResult = { + passed: false, + name: 'A34 — fetch + XHR network_error empirical (ROADMAP SC #2)', + checks: [], + diagnostics: [], + }; + + let probeTabId: number | undefined; + + try { + diag(result, 'Step 1: setupFreshRecording (A34 owns its recording — clean event-log window)'); + const setupResp = await setupFreshRecording(); + if (!setupResp.ok) { + throw new Error(`setupFreshRecording failed: ${setupResp.error ?? '(no error)'}`); + } + diag(result, 'Step 1 OK — REC state established'); + + diag(result, `Step 2: chrome.tabs.create(${A34_PROBE_TAB_URL}, active:true) — content script ISOLATED world is alive on https://, not on chrome-extension:// (Plan 03-02 lesson)`); + const probeTab = await chrome.tabs.create({ url: A34_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 ${A34_TAB_NAVIGATION_WAIT_MS}ms for navigation + content script attach`); + await new Promise((r) => setTimeout(r, A34_TAB_NAVIGATION_WAIT_MS)); + + diag(result, `Step 4: settle ${A34_SEGMENT_SETTLE_MS}ms for first segment rotation`); + await new Promise((r) => setTimeout(r, A34_SEGMENT_SETTLE_MS)); + + diag(result, 'Step 5: chrome.scripting.executeScript — inject fetch(404) + XHR(404) triggers in ISOLATED world (production window.fetch + XMLHttpRequest.prototype wrappers at src/content/index.ts both intercept)'); + const injectionResults = await chrome.scripting.executeScript({ + target: { tabId: probeTabId }, + world: 'ISOLATED', + func: async (fetchPath: string, xhrPath: string): Promise<{ + stamp: number; + fetchUrl: string; + xhrUrl: string; + fetchSettled: boolean; + xhrLoadended: boolean; + }> => { + // Uniqueness guard against any future intermediate-caching + // behavior change (T-04-05-02). The 404 paths do not exist + // today so the response is always fresh, but the stamp keeps + // A34 robust if example.com's caching semantics ever change. + const stamp = Date.now(); + const fetchUrl = `https://example.com${fetchPath}-${stamp}`; + const xhrUrl = `https://example.com${xhrPath}-${stamp}`; + + // Trigger 1 — failing fetch. The content script patches + // window.fetch at src/content/index.ts:183 in its ISOLATED + // world; this fetch runs in the SAME ISOLATED world so it + // routes through the wrapper. response.ok===false → + // addUserEvent({type:'network_error'}) at line 187. The + // .catch(noop) is REQUIRED — without it a network-layer + // rejection would surface as a separate js_error UserEvent + // (window 'unhandledrejection' listener) which A34 ignores. + let fetchSettled = false; + try { + await fetch(fetchUrl); + fetchSettled = true; + } catch { + // Expected for a non-2xx / network failure; the production + // wrapper's .catch branch (src/content/index.ts:206) has + // already enqueued the network_error UserEvent by here. + fetchSettled = true; + } + + // Trigger 2 — failing XHR. The content script wraps + // XMLHttpRequest.prototype.open + .send at + // src/content/index.ts:232/240; the loadend listener emits a + // network_error UserEvent when xhr.status >= 400. xhr.send() + // is fire-and-forget, so we await the loadend event here so + // the production wrapper has enqueued its UserEvent before + // this injected function returns. + const xhrLoadended = await new Promise((resolve) => { + const xhr = new XMLHttpRequest(); + xhr.open('GET', xhrUrl); + xhr.addEventListener('loadend', () => resolve(true)); + xhr.addEventListener('error', () => resolve(true)); + xhr.send(); + }); + + return { stamp, fetchUrl, xhrUrl, fetchSettled, xhrLoadended }; + }, + args: [ + A34_404_FETCH_PATH, + A34_404_XHR_PATH, + ], + }); + const injectionSummary = injectionResults[0]?.result ?? null; + diag(result, `Step 5 result: ${JSON.stringify(injectionSummary)}`); + + diag(result, `Step 6: settle ${A34_NETWORK_SETTLE_MS}ms so BOTH async wrappers (fetch .then/.catch + XHR loadend) finish enqueuing their network_error UserEvent`); + await new Promise((r) => setTimeout(r, A34_NETWORK_SETTLE_MS)); + + 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' }, + A34_SAVE_ARCHIVE_TIMEOUT_MS, + 'SAVE_ARCHIVE (A34)', + ); + diag(result, `Step 7 result: ${JSON.stringify(ack)}`); + + result.checks.push({ + name: 'A34.1: SAVE_ARCHIVE ack received with success=true', + expected: true, + actual: ack.success, + passed: ack.success === true, + }); + + result.passed = result.checks.every((c) => c.passed); + } catch (err) { + result.error = err instanceof Error ? err.message : String(err); + diag(result, `THREW: ${result.error}`); + } finally { + // T-02-04-04 mitigation parity (Plan 03-02 / 03-03 precedent): + // cleanup probe tab with silent-ignore on already-closed. + if (probeTabId !== undefined) { + try { + await chrome.tabs.remove(probeTabId); + } catch (rmErr) { + diag(result, `(probe tab cleanup ignored: ${String(rmErr)})`); + } + } + } + + return result; +} + /** * Read `chrome.runtime.getManifest().version`. Used by the host-side * orchestrator at startup to capture the expected version for A13's @@ -4077,6 +4310,8 @@ declare global { assertA30: () => Promise; // Plan 03-03 — password-filter PARTIAL (SPEC §10 #8 PARTIAL per D-P3-02) assertA31: () => Promise; + // Plan 04-05 — fetch + XHR network_error empirical (ROADMAP SC #2) + assertA34: () => Promise; getManifestVersion: () => Promise; }; } @@ -4114,14 +4349,15 @@ window.__mokoshHarness = { assertA29, assertA30, assertA31, + assertA34, getManifestVersion, }; const statusEl = document.getElementById('status'); if (statusEl !== null) { - statusEl.textContent = 'Harness ready. window.__mokoshHarness.{assertA1..A31, getManifestVersion} available.'; + statusEl.textContent = 'Harness ready. window.__mokoshHarness.{assertA1..A31, assertA34, getManifestVersion} available.'; } -console.log('[harness-page] ready — window.__mokoshHarness installed (Plan 01-13 Task 9: A1..A14 + Plan 01-10 Wave 3: A15..A17 + Plan 01-12 Wave 6: A18..A22 + Plan 01-14: A23 + Plan 02-04 Tasks 1-2: A24+A25 + Plan 02-04 Task 3: A26+A27+A28 + Plan 03-01: A29 + Plan 03-02: A30 + Plan 03-03: A31 + getManifestVersion)'); +console.log('[harness-page] ready — window.__mokoshHarness installed (Plan 01-13 Task 9: A1..A14 + Plan 01-10 Wave 3: A15..A17 + Plan 01-12 Wave 6: A18..A22 + Plan 01-14: A23 + Plan 02-04 Tasks 1-2: A24+A25 + Plan 02-04 Task 3: A26+A27+A28 + Plan 03-01: A29 + Plan 03-02: A30 + Plan 03-03: A31 + Plan 04-05: A34 + getManifestVersion)'); export {};