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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user