Milestone v1 (v2.0.0): Mokosh — Session Capture #1
@@ -3425,24 +3425,62 @@ async function assertA29(): Promise<AssertionResult> {
|
|||||||
* setupInputLogging at line 77, setupNavigationLogging at line
|
* setupInputLogging at line 77, setupNavigationLogging at line
|
||||||
* 99, setupErrorLogging at line 133, setupNetworkLogging at
|
* 99, setupErrorLogging at line 133, setupNetworkLogging at
|
||||||
* line 164) all fire on synthetic browser events dispatched
|
* line 164) all fire on synthetic browser events dispatched
|
||||||
* on the harness page, producing UserEvent entries with each
|
* in a probe https:// tab where the production content script
|
||||||
* of the 5 type-values (click / input / navigation /
|
* is injected, producing UserEvent entries with each of the 5
|
||||||
* js_error / network_error) in logs/events.json.
|
* type-values (click / input / navigation / js_error /
|
||||||
|
* network_error) in logs/events.json.
|
||||||
*
|
*
|
||||||
* Trigger strategy (all on the harness page; no new tabs opened):
|
* Implementation note — MV3 content-script reachability (deviation):
|
||||||
* - click: programmatic .click() on #probe-submit
|
* The plan as written assumed `<all_urls>` content_scripts coverage
|
||||||
* - input: set #probe-email.value + dispatch Event('input', bubbles:true)
|
* includes `chrome-extension://` URLs — empirically (Task 2
|
||||||
* - navigation: history.pushState (intercepted at src/content/index.ts:121)
|
* verification dump 2026-05-20T17:36:25Z), it does NOT. The Chrome
|
||||||
|
* match-pattern docs are explicit: `<all_urls>` permits the schemes
|
||||||
|
* `http`, `https`, `file`, `ftp`, `urn` — NOT `chrome-extension`.
|
||||||
|
* The SW SAVE_ARCHIVE handler logged "Could not establish connection.
|
||||||
|
* Receiving end does not exist." when targeting the harness page,
|
||||||
|
* confirming no content script is present on chrome-extension://.
|
||||||
|
*
|
||||||
|
* A30 therefore creates a fresh `https://example.com` probe tab
|
||||||
|
* (mirrors A27's pattern, including DEC-011 Amendment 1 `tabs`
|
||||||
|
* permission), uses chrome.scripting.executeScript (default
|
||||||
|
* ISOLATED world — the content script's world) to dispatch all 5
|
||||||
|
* triggers, then SAVEs while the probe tab is active so the SW
|
||||||
|
* harvests events from the page where the content script is alive.
|
||||||
|
*
|
||||||
|
* In addition: even if `chrome-extension://` HAD been covered by
|
||||||
|
* `<all_urls>`, page-world `fetch()` from `page.evaluate(...)` would
|
||||||
|
* NOT have been intercepted by `src/content/index.ts:167`
|
||||||
|
* (`window.fetch = ...`) — content-script global mutations stay
|
||||||
|
* inside the ISOLATED world. executeScript with default ISOLATED
|
||||||
|
* world targeting + the content-script's own runtime view of fetch
|
||||||
|
* solves both issues with one mechanism.
|
||||||
|
*
|
||||||
|
* Trigger strategy (all inside an injected ISOLATED-world script on
|
||||||
|
* the example.com probe tab):
|
||||||
|
* - click: dispatch a real `MouseEvent('click')` on document.body
|
||||||
|
* - input: create a synthetic <input>, set value, dispatchEvent('input')
|
||||||
|
* - navigation: window.dispatchEvent(new PopStateEvent('popstate'))
|
||||||
|
* (production `popstate` listener at src/content/index.ts:111;
|
||||||
|
* NOTE: history.pushState was the plan-spec mechanism, but
|
||||||
|
* it triggers a Puppeteer CDP execution-context teardown
|
||||||
|
* — see deviation log + the popstate path is functionally
|
||||||
|
* equivalent since the production code in
|
||||||
|
* src/content/index.ts:121 wraps pushState by also firing
|
||||||
|
* handleNavigation which routes through the same
|
||||||
|
* listener as popstate at line 111).
|
||||||
* - js_error: window.dispatchEvent(new ErrorEvent('error', ...))
|
* - js_error: window.dispatchEvent(new ErrorEvent('error', ...))
|
||||||
* - network_error: fetch(404-probe-url).catch(noop) — production
|
* - network_error: fetch(404-probe-url).catch(noop) — runs in
|
||||||
* fetch interception at src/content/index.ts:167 logs response.ok===false
|
* ISOLATED world so the patched window.fetch
|
||||||
|
* at src/content/index.ts:167 fires.
|
||||||
*
|
*
|
||||||
* Page-side dispatches all 5 triggers + settles + SAVE. Host-side
|
* Page-side opens probe tab + injects triggers + settles + SAVE.
|
||||||
* driveA30 JSZip-parses logs/events.json and asserts each of the 5
|
* Host-side driveA30 JSZip-parses logs/events.json + asserts each of
|
||||||
* UserEvent.type literal values is present.
|
* the 5 UserEvent.type literal values is present.
|
||||||
*
|
*
|
||||||
* FORBIDDEN_HOOK_STRINGS impact: NONE. A30 rides production listeners
|
* FORBIDDEN_HOOK_STRINGS impact: NONE. A30 rides production listeners
|
||||||
* + existing helpers. Tier-1 inventory stays at 12.
|
* + chrome.tabs.* (`tabs` perm) + chrome.scripting.executeScript
|
||||||
|
* (`scripting` perm — already in manifest) + existing helpers. Tier-1
|
||||||
|
* inventory stays at 12.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** SAVE_ARCHIVE dispatch timeout for A30 — matches A24/A25/A27/A29. */
|
/** SAVE_ARCHIVE dispatch timeout for A30 — matches A24/A25/A27/A29. */
|
||||||
@@ -3450,18 +3488,26 @@ const A30_SAVE_ARCHIVE_TIMEOUT_MS = 15_000;
|
|||||||
/** Pre-SAVE segment-settle window (10s rotation + 1s slack). */
|
/** Pre-SAVE segment-settle window (10s rotation + 1s slack). */
|
||||||
const A30_SEGMENT_SETTLE_MS = 11_000;
|
const A30_SEGMENT_SETTLE_MS = 11_000;
|
||||||
/** Settle between trigger dispatches and SAVE so event handlers complete. */
|
/** Settle between trigger dispatches and SAVE so event handlers complete. */
|
||||||
const A30_TRIGGER_SETTLE_MS = 500;
|
const A30_TRIGGER_SETTLE_MS = 1_000;
|
||||||
/** 404 probe URL — chrome.tabs perm grant is irrelevant; fetch happens
|
/** Wait after chrome.tabs.create for the tab navigation to complete so
|
||||||
* from the harness page realm. example.com is RFC 2606 reserved +
|
* the content script attaches + production listeners are set up
|
||||||
* serves a 404 reliably for unknown paths under headless Chrome. */
|
* (mirrors A27_TAB_NAVIGATION_WAIT_MS = 1.5s). */
|
||||||
|
const A30_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 fixture parity). */
|
||||||
|
const A30_PROBE_TAB_URL = 'https://example.com/';
|
||||||
|
/** 404 probe URL — same origin as the probe tab so the fetch is a
|
||||||
|
* same-origin GET (no CORS preflight noise). */
|
||||||
const A30_404_PROBE_URL = 'https://example.com/this-path-does-not-exist-404-probe-a30';
|
const A30_404_PROBE_URL = 'https://example.com/this-path-does-not-exist-404-probe-a30';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A30 — Event-log empirical (SPEC §10 #5 / REQ-user-event-log).
|
* A30 — Event-log empirical (SPEC §10 #5 / REQ-user-event-log).
|
||||||
*
|
*
|
||||||
* Dispatches 5 synthetic browser events that exercise each of the
|
* Creates a fresh `https://example.com` probe tab, injects all 5
|
||||||
* production listeners; runs setupFreshRecording so event-log
|
* synthetic event triggers into the content script's ISOLATED world
|
||||||
* cleanup hasn't dropped anything; settles a segment; SAVEs. Host-side
|
* via chrome.scripting.executeScript so the production listeners fire,
|
||||||
|
* settles a segment, SAVEs while the probe tab is active so the SW
|
||||||
|
* harvests the content script's userEvents[] from that tab. Host-side
|
||||||
* driveA30 inspects logs/events.json from the produced zip and asserts
|
* driveA30 inspects logs/events.json from the produced zip and asserts
|
||||||
* each of the 5 UserEvent.type literal values appears at least once.
|
* each of the 5 UserEvent.type literal values appears at least once.
|
||||||
*
|
*
|
||||||
@@ -3476,6 +3522,8 @@ async function assertA30(): Promise<AssertionResult> {
|
|||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let probeTabId: number | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
diag(result, 'Step 1: setupFreshRecording (A30 owns its recording — clean event-log window)');
|
diag(result, 'Step 1: setupFreshRecording (A30 owns its recording — clean event-log window)');
|
||||||
const setupResp = await setupFreshRecording();
|
const setupResp = await setupFreshRecording();
|
||||||
@@ -3484,54 +3532,108 @@ async function assertA30(): Promise<AssertionResult> {
|
|||||||
}
|
}
|
||||||
diag(result, 'Step 1 OK — REC state established');
|
diag(result, 'Step 1 OK — REC state established');
|
||||||
|
|
||||||
diag(result, `Step 2: settle ${A30_SEGMENT_SETTLE_MS}ms for first segment rotation`);
|
diag(result, `Step 2: chrome.tabs.create(${A30_PROBE_TAB_URL}, active:true) — content script ISOLATED world is alive on https://, not on chrome-extension://`);
|
||||||
|
const probeTab = await chrome.tabs.create({ url: A30_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 ${A30_TAB_NAVIGATION_WAIT_MS}ms for navigation + content script attach`);
|
||||||
|
await new Promise((r) => setTimeout(r, A30_TAB_NAVIGATION_WAIT_MS));
|
||||||
|
|
||||||
|
diag(result, `Step 4: settle ${A30_SEGMENT_SETTLE_MS}ms for first segment rotation`);
|
||||||
await new Promise((r) => setTimeout(r, A30_SEGMENT_SETTLE_MS));
|
await new Promise((r) => setTimeout(r, A30_SEGMENT_SETTLE_MS));
|
||||||
|
|
||||||
diag(result, 'Step 3: click trigger — programmatic .click() on #probe-submit');
|
diag(result, 'Step 5: chrome.scripting.executeScript — inject 5 synthetic triggers in ISOLATED world (content-script realm; fetch wrapper at src/content/index.ts:167 sees the fetch)');
|
||||||
const submitBtn = document.querySelector<HTMLButtonElement>('#probe-submit');
|
const probeUrl = A30_404_PROBE_URL;
|
||||||
if (submitBtn !== null) {
|
const injectionResults = await chrome.scripting.executeScript({
|
||||||
submitBtn.click();
|
target: { tabId: probeTabId },
|
||||||
} else {
|
world: 'ISOLATED',
|
||||||
diag(result, 'Step 3 WARN — #probe-submit missing; click trigger skipped');
|
func: async (probe404Url: string): Promise<{
|
||||||
}
|
click: boolean;
|
||||||
|
input: boolean;
|
||||||
|
navigation: boolean;
|
||||||
|
jsError: boolean;
|
||||||
|
networkErrorTriggered: boolean;
|
||||||
|
fetchThrew: boolean;
|
||||||
|
}> => {
|
||||||
|
// click — synthetic MouseEvent on the document body. Production
|
||||||
|
// listener at src/content/index.ts:61 captures the click via
|
||||||
|
// document.addEventListener('click', ...).
|
||||||
|
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||||
|
const clickDispatched = document.body.dispatchEvent(clickEvent);
|
||||||
|
|
||||||
diag(result, 'Step 4: input trigger — set #probe-email.value + dispatch input event');
|
// input — synthetic <input>, set value, dispatchEvent('input',
|
||||||
const emailInput = document.querySelector<HTMLInputElement>('#probe-email');
|
// bubbles:true). Production listener at src/content/index.ts:78
|
||||||
if (emailInput !== null) {
|
// captures via document.addEventListener('input', ...). Skips
|
||||||
emailInput.value = 'a30@probe.local';
|
// password type (line 82) — type='text' here.
|
||||||
emailInput.dispatchEvent(new Event('input', { bubbles: true }));
|
const probeInput = document.createElement('input');
|
||||||
} else {
|
probeInput.type = 'text';
|
||||||
diag(result, 'Step 4 WARN — #probe-email missing; input trigger skipped');
|
probeInput.id = 'a30-probe-input';
|
||||||
}
|
probeInput.value = 'a30@probe.local';
|
||||||
|
document.body.appendChild(probeInput);
|
||||||
|
const inputDispatched = probeInput.dispatchEvent(
|
||||||
|
new Event('input', { bubbles: true }),
|
||||||
|
);
|
||||||
|
probeInput.remove();
|
||||||
|
|
||||||
diag(result, 'Step 5: navigation trigger — history.pushState (production wrapper at src/content/index.ts:121 intercepts)');
|
// navigation — window-level popstate event. Production listener
|
||||||
history.pushState({}, '', window.location.pathname + '#a30-probe');
|
// at src/content/index.ts:111 captures via
|
||||||
|
// window.addEventListener('popstate', ...).
|
||||||
|
const navigationDispatched = window.dispatchEvent(
|
||||||
|
new PopStateEvent('popstate', { state: {} }),
|
||||||
|
);
|
||||||
|
|
||||||
diag(result, 'Step 6: js_error trigger — window.dispatchEvent(ErrorEvent("error"))');
|
// js_error — window-level ErrorEvent. Production listener at
|
||||||
window.dispatchEvent(new ErrorEvent('error', {
|
// src/content/index.ts:134 captures via
|
||||||
message: 'a30-probe-js-error',
|
// window.addEventListener('error', ...).
|
||||||
filename: 'a30-probe.js',
|
const errorDispatched = window.dispatchEvent(
|
||||||
lineno: 1,
|
new ErrorEvent('error', {
|
||||||
colno: 1,
|
message: 'a30-probe-js-error',
|
||||||
}));
|
filename: 'a30-probe.js',
|
||||||
|
lineno: 1,
|
||||||
|
colno: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
diag(result, `Step 7: network_error trigger — fetch(${A30_404_PROBE_URL}) (.catch noop)`);
|
// network_error — fetch into a 404 path. The content script
|
||||||
try {
|
// patches window.fetch at src/content/index.ts:167 in its
|
||||||
await fetch(A30_404_PROBE_URL);
|
// ISOLATED world; this fetch is in the SAME ISOLATED world
|
||||||
} catch (fetchErr) {
|
// so it routes through the wrapper. response.ok===false →
|
||||||
diag(result, `Step 7 fetch threw (acceptable for network_error path): ${String(fetchErr)}`);
|
// addUserEvent({type:'network_error'}) at line 171.
|
||||||
}
|
let fetchThrew = false;
|
||||||
|
try {
|
||||||
|
await fetch(probe404Url);
|
||||||
|
} catch (fetchErr) {
|
||||||
|
fetchThrew = true;
|
||||||
|
}
|
||||||
|
|
||||||
diag(result, `Step 8: settle ${A30_TRIGGER_SETTLE_MS}ms so async handlers (fetch.then) complete`);
|
return {
|
||||||
|
click: clickDispatched,
|
||||||
|
input: inputDispatched,
|
||||||
|
navigation: navigationDispatched,
|
||||||
|
jsError: errorDispatched,
|
||||||
|
networkErrorTriggered: true,
|
||||||
|
fetchThrew,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
args: [probeUrl],
|
||||||
|
});
|
||||||
|
const injectionSummary = injectionResults[0]?.result ?? null;
|
||||||
|
diag(result, `Step 5 result: ${JSON.stringify(injectionSummary)}`);
|
||||||
|
|
||||||
|
diag(result, `Step 6: settle ${A30_TRIGGER_SETTLE_MS}ms so async handlers (fetch.then) complete + userEvents[] populates`);
|
||||||
await new Promise((r) => setTimeout(r, A30_TRIGGER_SETTLE_MS));
|
await new Promise((r) => setTimeout(r, A30_TRIGGER_SETTLE_MS));
|
||||||
|
|
||||||
diag(result, 'Step 9: dispatch SAVE_ARCHIVE');
|
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 }>(
|
const ack = await sendMessageWithTimeout<{ success: boolean; error?: string }>(
|
||||||
{ type: 'SAVE_ARCHIVE' },
|
{ type: 'SAVE_ARCHIVE' },
|
||||||
A30_SAVE_ARCHIVE_TIMEOUT_MS,
|
A30_SAVE_ARCHIVE_TIMEOUT_MS,
|
||||||
'SAVE_ARCHIVE (A30)',
|
'SAVE_ARCHIVE (A30)',
|
||||||
);
|
);
|
||||||
diag(result, `Step 9 result: ${JSON.stringify(ack)}`);
|
diag(result, `Step 7 result: ${JSON.stringify(ack)}`);
|
||||||
|
|
||||||
result.checks.push({
|
result.checks.push({
|
||||||
name: 'A30.1: SAVE_ARCHIVE ack received with success=true',
|
name: 'A30.1: SAVE_ARCHIVE ack received with success=true',
|
||||||
@@ -3544,6 +3646,15 @@ async function assertA30(): Promise<AssertionResult> {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
result.error = err instanceof Error ? err.message : String(err);
|
result.error = err instanceof Error ? err.message : String(err);
|
||||||
diag(result, `THREW: ${result.error}`);
|
diag(result, `THREW: ${result.error}`);
|
||||||
|
} finally {
|
||||||
|
// T-02-04-04 mitigation parity: cleanup probe tab with silent-ignore.
|
||||||
|
if (probeTabId !== undefined) {
|
||||||
|
try {
|
||||||
|
await chrome.tabs.remove(probeTabId);
|
||||||
|
} catch (rmErr) {
|
||||||
|
diag(result, `(probe tab cleanup ignored: ${String(rmErr)})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ import {
|
|||||||
driveA28,
|
driveA28,
|
||||||
// Plan 03-01 — rrweb DOM verification (SPEC §10 #4 / REQ-rrweb-dom-buffer)
|
// Plan 03-01 — rrweb DOM verification (SPEC §10 #4 / REQ-rrweb-dom-buffer)
|
||||||
driveA29,
|
driveA29,
|
||||||
|
// Plan 03-02 — event-log verification (SPEC §10 #5 / REQ-user-event-log)
|
||||||
|
driveA30,
|
||||||
getManifestVersion,
|
getManifestVersion,
|
||||||
} from './lib/harness-page-driver';
|
} from './lib/harness-page-driver';
|
||||||
import {
|
import {
|
||||||
@@ -267,7 +269,7 @@ async function assertA0_GrepGate(): Promise<{
|
|||||||
*/
|
*/
|
||||||
async function main(): Promise<number> {
|
async function main(): Promise<number> {
|
||||||
process.stdout.write('\nMokosh Plan 01-13 + 01-14 + 02-04 — UAT harness orchestrator\n');
|
process.stdout.write('\nMokosh Plan 01-13 + 01-14 + 02-04 — UAT harness orchestrator\n');
|
||||||
process.stdout.write('Architecture: A0 pre-flight + extension-internal page driver (A1..A14, A15..A17, A18..A22, A23, A24, A25, A26, A27, A28, A29)\n');
|
process.stdout.write('Architecture: A0 pre-flight + extension-internal page driver (A1..A14, A15..A17, A18..A22, A23, A24, A25, A26, A27, A28, A29, A30)\n');
|
||||||
process.stdout.write('='.repeat(72) + '\n');
|
process.stdout.write('='.repeat(72) + '\n');
|
||||||
|
|
||||||
// A0 pre-flight (no Chrome launch needed; runs against built dist/).
|
// A0 pre-flight (no Chrome launch needed; runs against built dist/).
|
||||||
@@ -339,6 +341,10 @@ async function main(): Promise<number> {
|
|||||||
// of rrweb/session.json from the just-produced zip.
|
// of rrweb/session.json from the just-produced zip.
|
||||||
const driveA29Wrapped: (page: import('puppeteer').Page) => Promise<AssertionRecord> =
|
const driveA29Wrapped: (page: import('puppeteer').Page) => Promise<AssertionRecord> =
|
||||||
(page) => driveA29(page, handles.downloadsDir);
|
(page) => driveA29(page, handles.downloadsDir);
|
||||||
|
// Plan 03-02 — driveA30 needs downloadsDir for host-side JSZip parse
|
||||||
|
// of logs/events.json from the just-produced zip.
|
||||||
|
const driveA30Wrapped: (page: import('puppeteer').Page) => Promise<AssertionRecord> =
|
||||||
|
(page) => driveA30(page, handles.downloadsDir);
|
||||||
|
|
||||||
const drivers: ReadonlyArray<{
|
const drivers: ReadonlyArray<{
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
@@ -441,6 +447,13 @@ async function main(): Promise<number> {
|
|||||||
// rrweb/session.json and asserts the EventType enum surfaces
|
// rrweb/session.json and asserts the EventType enum surfaces
|
||||||
// (Meta=4, FullSnapshot=2, IncrementalSnapshot=3) are present.
|
// (Meta=4, FullSnapshot=2, IncrementalSnapshot=3) are present.
|
||||||
{ name: 'A29', drive: driveA29Wrapped },
|
{ name: 'A29', drive: driveA29Wrapped },
|
||||||
|
// Plan 03-02 A30: event-log verification (SPEC §10 #5).
|
||||||
|
// A30 owns its SAVE because event-log cleanup runs every 60s
|
||||||
|
// (src/content/index.ts CLEANUP_INTERVAL_MS=60_000) and we need a
|
||||||
|
// fresh event-log window for the 5 synthetic triggers. Host-side
|
||||||
|
// driveA30 JSZip-parses logs/events.json and asserts presence of
|
||||||
|
// each of the 5 UserEvent.type literal values.
|
||||||
|
{ name: 'A30', drive: driveA30Wrapped },
|
||||||
];
|
];
|
||||||
|
|
||||||
const buffers = { swConsole: handles.swConsole, offConsole: handles.offConsole };
|
const buffers = { swConsole: handles.swConsole, offConsole: handles.offConsole };
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import type { Page } from 'puppeteer';
|
|||||||
|
|
||||||
import type { AssertionRecord, CheckRecord } from './assertions';
|
import type { AssertionRecord, CheckRecord } from './assertions';
|
||||||
import { assertArchiveShape, extractEntryToFile } from './zip';
|
import { assertArchiveShape, extractEntryToFile } from './zip';
|
||||||
|
import type { UserEvent } from '../../../src/shared/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extended assertion-record shape for A5/A12/A13 which return
|
* Extended assertion-record shape for A5/A12/A13 which return
|
||||||
@@ -1998,3 +1999,150 @@ export async function driveA29(
|
|||||||
error: pageResult.error,
|
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