feat(03-02): Task 2 — driveA30 + orchestrator wiring (A30 31/31 GREEN; cs-injection-world fix)

- driveA30 host-side (tests/uat/lib/harness-page-driver.ts):
  - import type { UserEvent } from '../../../src/shared/types' (5-type tuple grep).
  - A30_EXPECTED_TYPES = ['click','input','navigation','js_error','network_error']
    (canonical CON-event-log-schema 5-tuple).
  - 3-phase pattern (page.evaluate stub → findLatestZip → JSZip
    logs/events.json) per Plan 02-04 driveA26 analog.
  - 6 host-side checks: A30.0a (entry present) + A30.2..A30.6 (5 type
    presence). Filter-pipeline form; no `continue`.

- Orchestrator wiring (tests/uat/harness.test.ts):
  - driveA30 import + driveA30Wrapped const + drivers-array entry with
    Plan 03-02 banner; Architecture banner updated A29 -> A29, A30.

- assertA30 architectural rewrite (deviation Rule 3 — blocking fix):
  The plan's original strategy "dispatch synthetic events ON the harness
  page (chrome-extension://) so the production listeners on that page
  fire" was empirically wrong on two counts:

  1. Chrome MV3 `<all_urls>` match-pattern (Chrome match-pattern docs)
     permits schemes http/https/file/ftp/urn only — NOT
     chrome-extension. The harness page has NO content script attached;
     the SW SAVE_ARCHIVE handler reported "Could not establish
     connection. Receiving end does not exist." when the active tab was
     the harness page (verified empirically 2026-05-20T17:36:25Z trace).

  2. Even if (1) had been satisfied, page.evaluate-side fetch() runs in
     the MAIN world while the content-script's window.fetch wrapper at
     src/content/index.ts:167 patches only the content-script's
     ISOLATED-world window. Page-world fetches NEVER reach the
     production network_error wrapper.

  Fix: A30 now creates a fresh https://example.com probe tab via
  chrome.tabs.create (mirrors A27's pattern; DEC-011 Amendment 1 `tabs`
  perm; `scripting` perm already in manifest); uses
  chrome.scripting.executeScript with default `world: 'ISOLATED'` to
  inject all 5 triggers directly in the content-script's realm; SAVEs
  while the probe tab is active (SW harvests events.json from a tab
  whose content script IS attached); cleans up the probe tab in finally
  (T-02-04-04 silent-ignore parity). All 5 UserEvent types now land
  empirically: type counts: click=1,input=1,navigation=1,js_error=1,
  network_error=1; userEvents.length=5.

- UAT 30 → 31 GREEN; vitest 171/171 preserved; Tier-1 FORBIDDEN_HOOK_STRINGS
  unchanged at 12 (A30 rides production chrome.tabs + chrome.scripting +
  GET_RRWEB_EVENTS round-trip — no new test-only symbols).
This commit is contained in:
2026-05-20 19:48:47 +02:00
parent b5181012a8
commit 116432a3cd
3 changed files with 327 additions and 55 deletions

View File

@@ -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<UserEvent['type']> = [
'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<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.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: `<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 typeCountsMap = new Map<string, number>();
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,
};
}