feat(04-05): A34 page-side — cs-injection-world fetch + XHR 404 injection

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-05-22 11:37:12 +02:00
parent 125269dcc5
commit a20372a8b8

View File

@@ -4020,6 +4020,239 @@ async function assertA31(): Promise<AssertionResult> {
return result; 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-<stamp>').catch(noop)
* - new XMLHttpRequest(); open('GET', '/404-xhr-a34-<stamp>'); send()
* The `-<stamp>` (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<AssertionResult> {
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 ?? '<pending>'}`);
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<boolean>((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 * Read `chrome.runtime.getManifest().version`. Used by the host-side
* orchestrator at startup to capture the expected version for A13's * orchestrator at startup to capture the expected version for A13's
@@ -4077,6 +4310,8 @@ declare global {
assertA30: () => Promise<AssertionResult>; assertA30: () => Promise<AssertionResult>;
// Plan 03-03 — password-filter PARTIAL (SPEC §10 #8 PARTIAL per D-P3-02) // Plan 03-03 — password-filter PARTIAL (SPEC §10 #8 PARTIAL per D-P3-02)
assertA31: () => Promise<AssertionResult>; assertA31: () => Promise<AssertionResult>;
// Plan 04-05 — fetch + XHR network_error empirical (ROADMAP SC #2)
assertA34: () => Promise<AssertionResult>;
getManifestVersion: () => Promise<string>; getManifestVersion: () => Promise<string>;
}; };
} }
@@ -4114,14 +4349,15 @@ window.__mokoshHarness = {
assertA29, assertA29,
assertA30, assertA30,
assertA31, assertA31,
assertA34,
getManifestVersion, getManifestVersion,
}; };
const statusEl = document.getElementById('status'); const statusEl = document.getElementById('status');
if (statusEl !== null) { 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 {}; export {};