feat(04-05): A34 host-side + orchestrator — fetch+XHR network_error empirical (ROADMAP SC #2 GREEN)

- Append driveA34 host-side: JSZip-parse logs/events.json + filter
  network_error entries by '404-fetch-a34' / '404-xhr-a34' target
  marker; assert >=1 of each + meta.status === 404
- readMetaStatus helper narrows UserEvent.meta.status (typed
  Record<string,unknown>) to number without an unchecked any cast
- 3-site orchestrator wiring in harness.test.ts: import binding,
  driveA34Wrapped (downloadsDir closure), drivers-array push entry
- UAT harness 34 -> 35; skip-mode (SKIP_LONG_UAT=1) 35/35 GREEN
- A34 empirical: fetch entry target carries the real URL
  (https://example.com/404-fetch-a34-<stamp>), NOT '[object Request]'
  — Plan 04-01 P1 #11 fix validated end-to-end at the SAVE->archive
  layer; XHR entry confirms the distinct prototype-wrapper path;
  both meta.status === 404 (ROADMAP SC #2 closed)
- vitest baseline 184/184 GREEN preserved (no unit tests this plan)
- FORBIDDEN_HOOK_STRINGS unchanged at 12

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-22 12:26:14 +02:00
parent a20372a8b8
commit 0712c245a1
2 changed files with 218 additions and 0 deletions

View File

@@ -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<string, unknown>` (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<AssertionRecord> {
// 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: `<error: ${parseErr}>`,
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 ?? '<none>'} meta.status=${fetchStatus ?? '<none>'}`,
);
mergedDiagnostics.push(
`A34 xhr-entry[0].target=${xhrEntries[0]?.target ?? '<none>'} meta.status=${xhrStatus ?? '<none>'}`,
);
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,
};
}