Files
mokosh/.planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-02-PLAN.md
Mark b3bfbf4a8d feat(03): plans 01-05 — Phase 3 SPEC §10 smoke + DOM/event-log verification
5 plans across 5 waves (Wave 2 sequential per RESEARCH Pitfall 6 file overlap):
- 03-01 Wave 1: rrweb DOM verification harness extension (A29; REQ-rrweb-dom-buffer; §10 #4)
- 03-02 Wave 2: event-log verification harness extension (A30; REQ-user-event-log; §10 #5)
- 03-03 Wave 3: §10 #8 password-filter PARTIAL verification (A31; D-P3-02 charter)
- 03-04 Wave 4: §10 #9 RAM ceiling best-effort + Page.metrics scaffolding (A32; D-P3-04)
- 03-05 Wave 5: §10 sweep VERIFICATION.md + REQUIREMENTS/ROADMAP/STATE marker flips
  (REQ-install-clean + REQ-rrweb-dom-buffer + REQ-user-event-log)

Each plan has:
- frontmatter (wave + depends_on + files_modified + autonomous + requirements + tags + must_haves)
- tasks with mandatory <read_first> + <acceptance_criteria> + concrete <action>
- <threat_model> block per security gate
- Validation map row(s) added to 03-VALIDATION.md (10 tasks total)

Expected UAT growth: 29/29 → 33/33 GREEN (A29-A32 + 03-05 docs).
Expected vitest baseline preserved: 171/171.
Expected Tier-1 FORBIDDEN_HOOK_STRINGS: 12 (A29+ ride production surfaces only).

ROADMAP.md Phase 3 entry replaces "Plans: TBD" with full 5-plan list.
VALIDATION.md status: planner_filled (nyquist_compliant: true; wave_0_complete: true).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 19:01:21 +02:00

681 lines
33 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 03
slug: spec-10-smoke-verification-dom-event-log-verification
plan: 02
type: execute
wave: 2
depends_on:
- 01
files_modified:
- tests/uat/extension-page-harness.ts
- tests/uat/lib/harness-page-driver.ts
- tests/uat/harness.test.ts
autonomous: true
requirements:
- REQ-user-event-log
tags:
- uat-harness
- a30
- event-log
- spec-10-5
- approach-b
- user-event
user_setup: []
must_haves:
truths:
- "logs/events.json from the assembled zip contains at least one 'click' UserEvent"
- "logs/events.json contains at least one 'input' UserEvent"
- "logs/events.json contains at least one 'navigation' UserEvent"
- "logs/events.json contains at least one 'js_error' UserEvent"
- "logs/events.json contains at least one 'network_error' UserEvent"
- "UAT harness exits 0 with 30 + 1 = 31/31 assertions GREEN (A29 baseline preserved + new A30)"
artifacts:
- path: "tests/uat/extension-page-harness.ts"
provides: "assertA30 page-side orchestrator: triggers 5 event types (click/input/navigation/js_error/network_error), runs setupFreshRecording + SAVE; registered on window.__mokoshHarness"
contains: "assertA30"
- path: "tests/uat/lib/harness-page-driver.ts"
provides: "driveA30 host-side: JSZip-parse logs/events.json + UserEvent type grep across all 5 event-types"
contains: "driveA30"
- path: "tests/uat/harness.test.ts"
provides: "driveA30 import + wrapped driver + drivers-array push entry"
contains: "driveA30"
key_links:
- from: "tests/uat/lib/harness-page-driver.ts driveA30"
to: "tests/uat/extension-page-harness.ts assertA30"
via: "page.evaluate(() => window.__mokoshHarness.assertA30())"
pattern: "harness.assertA30\\(\\)"
- from: "tests/uat/lib/harness-page-driver.ts driveA30"
to: "src/shared/types.ts UserEvent"
via: "import type { UserEvent } from '../../../src/shared/types'"
pattern: "import type \\{ UserEvent \\}"
- from: "tests/uat/extension-page-harness.ts assertA30"
to: "src/content/index.ts (production event-log wiring)"
via: "synthetic browser events trigger production click/input/navigation/error/fetch listeners"
pattern: "addUserEvent\\("
---
<objective>
Extend the UAT harness with A30 — empirical verification that SPEC §10 #5
(event log captures clicks, navigation, network errors, plus the input
and js_error types per CON-event-log-schema) is satisfied. Production
wiring at `src/content/index.ts:60-237` already ships listeners for all
5 UserEvent types; A30 confirms they fire correctly and land in
`logs/events.json` of the assembled archive.
Purpose: Closes REQ-user-event-log empirical verification gap. Phase 1
shipped the wiring; Phase 3 confirms all 5 types are captured during a
synthetic event-injection drive.
Output: A30 assertion with 6 host-side checks (SAVE ack + presence of
each of 5 event types in logs/events.json); UAT count 30 → 31 GREEN.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/REQUIREMENTS.md
@.planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-CONTEXT.md
@.planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-RESEARCH.md
@.planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-PATTERNS.md
@.planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-01-PLAN.md
@src/content/index.ts
@src/shared/types.ts
<interfaces>
<!-- Key contracts the executor needs. Extracted from existing code. -->
From src/shared/types.ts (lines 124-131):
```typescript
export interface UserEvent {
timestamp: number;
type: 'click' | 'input' | 'navigation' | 'js_error' | 'network_error';
target: string;
value?: string;
url: string;
meta?: Record<string, unknown>;
}
```
From src/content/index.ts (READ-ONLY; verifier subject):
```typescript
// setupClickLogging at line 61: document.addEventListener('click', ...)
// setupInputLogging at line 77: document.addEventListener('input', ...); password filter at line 82
// setupNavigationLogging at line 99: popstate + hashchange + pushState/replaceState intercept
// setupErrorLogging at line 133: window.addEventListener('error', ...) + unhandledrejection
// setupNetworkLogging at line 164: fetch interception (response.ok === false) + XHR loadend (status >= 400)
// All push to userEvents[]; GET_RRWEB_EVENTS handler at line 318 returns events + userEvents
```
From tests/uat/extension-page-harness.ts (existing helpers):
```typescript
function diag(result: AssertionResult, line: string): void;
async function setupFreshRecording(): Promise<{ ok: boolean; error?: string }>;
async function sendMessageWithTimeout<T>(msg: unknown, timeoutMs: number, label: string): Promise<T>;
// Pattern for new assertions: A29 from Plan 03-01 (precedent — same file).
```
From tests/uat/lib/harness-page-driver.ts:
```typescript
function findLatestZip(downloadsDir: string): string | null;
// JSZip + readFileSync host-side; no chrome.* access
```
</interfaces>
# Plan Anchors
- **Sequential wave assignment (per RESEARCH Pitfall 6 + file-overlap rule):** Plan 03-02 lives in wave 2
modifies the SAME 3 harness files as Plan 03-01. depends_on: [01]
enforces sequential ordering — Plan 03-02 runs AFTER 03-01 commits.
- **Production wiring is in the content script — NOT the harness page.**
src/content/index.ts attaches listeners to `document` and `window`
via content-script injection into the active tab. The harness page
(chrome-extension://.../extension-page-harness.html) is an extension-
internal page where the content script is also injected (per
manifest content_scripts `<all_urls>`-equivalent). Therefore
synthetic events dispatched ON the harness page from
`page.evaluate(...)` reach the production listeners and produce real
UserEvent entries.
- **5 event types must each fire at least once:** click + input +
navigation + js_error + network_error (per CON-event-log-schema +
REQUIREMENTS.md REQ-user-event-log lines 65-72).
- **Network error trigger via fetch to a known-404:** Plan 02-04 A27
uses `https://example.com` as a stable harness fixture. fetch to
`https://example.com/this-path-does-not-exist-404-probe-a30` is a
reliable network_error trigger (404 surfaces). RESEARCH Pitfall 3
warning: USE https:// only (URL_SCHEME_ALLOW regex). example.com
is the safe choice.
- **Navigation trigger via pushState:** the production interceptor at
src/content/index.ts:114-129 wraps history.pushState/replaceState
and dispatches navigation events. A30 fires
`history.pushState({}, '', window.location.pathname + '#a30-probe')`
which routes through the wrapper → navigation event.
- **js_error trigger via window.dispatchEvent(new ErrorEvent('error'))**
is the canonical synthetic trigger; production at line 133 listens
for `window` 'error' events.
- **input trigger:** keep separate from Plan 03-01 mutation (Plan
03-01's `#probe-text` value="probe" already fires the input event
via dispatchEvent at line 80, but A30 runs in its own fresh
recording cycle after A29 + setupFreshRecording, so events are
separated by recording window). A30 dispatches a NEW input event
on `#probe-email` with value 'a30@probe.local' to be self-contained.
- **click trigger:** dispatch a synthetic click on `#probe-submit` via
`.click()`. The production click listener at line 61 captures it
regardless of whether the form submits (form submit event is a
separate listener not in scope).
- **All triggers happen ON the harness page (window/document of the
harness page).** No new tab is opened; chrome.tabs.create is NOT
needed. This avoids the Plan 02-04 A27 multi-tab complexity and
the chrome-extension://-tab quirks.
- **FORBIDDEN_HOOK_STRINGS lockstep:** A30 rides production listeners
+ existing setupFreshRecording / sendMessageWithTimeout helpers.
Tier-1 inventory stays at 12 entries.
</context>
<tasks>
<task type="auto" tdd="false">
<name>Task 1: Add assertA30 page-side orchestrator (5 event triggers + SAVE)</name>
<files>tests/uat/extension-page-harness.ts</files>
<read_first>
- tests/uat/extension-page-harness.ts lines 3151-3413 (assertA28 stub + declare global block + window.__mokoshHarness object literal — the canonical extension shape Plan 03-01 just used for assertA29)
- tests/uat/extension-page-harness.ts where assertA29 was JUST added by Plan 03-01 (study its shape — same module; new assertion appends after it)
- src/content/index.ts lines 60-237 (production listener wiring — what synthetic events must trigger)
- src/shared/types.ts lines 124-131 (UserEvent type with 5-literal union)
</read_first>
<behavior>
- Adds module-local constants:
- `A30_SAVE_ARCHIVE_TIMEOUT_MS = 15_000`
- `A30_SEGMENT_SETTLE_MS = 11_000`
- `A30_TRIGGER_SETTLE_MS = 500` (wait between trigger and SAVE)
- `A30_404_PROBE_URL = 'https://example.com/this-path-does-not-exist-404-probe-a30'`
- Adds `async function assertA30(): Promise<AssertionResult>` that:
- Step 0: `setupFreshRecording()` (clean event-log window; previous events from A29 won't count)
- Step 1: settle `A30_SEGMENT_SETTLE_MS` so a segment lands (matches other assertions; not strictly required for event log but keeps the run consistent)
- Step 2: click trigger — `(document.querySelector('#probe-submit') as HTMLButtonElement | null)?.click()` (use safe-nav; assertion proceeds even if the element is gone)
- Step 3: input trigger — set `#probe-email.value = 'a30@probe.local'` + dispatch `Event('input', { bubbles: true })`
- Step 4: navigation trigger — `history.pushState({}, '', window.location.pathname + '#a30-probe')` (production wrapper at src/content/index.ts:114-129 fires navigation event)
- Step 5: js_error trigger — `window.dispatchEvent(new ErrorEvent('error', { message: 'a30-probe-js-error', filename: 'a30', lineno: 1, colno: 1 }))`
- Step 6: network_error trigger — `await fetch(A30_404_PROBE_URL).catch(() => undefined)` (production fetch interception at line 167 fires network_error on response.ok===false)
- Step 7: settle `A30_TRIGGER_SETTLE_MS` so all event handlers complete and entries land in userEvents[]
- Step 8: dispatch `SAVE_ARCHIVE`
- Push `A30.1: SAVE_ARCHIVE ack received with success=true` to checks
- Returns AssertionResult; host-side driveA30 appends the 5 type-presence checks
- Adds `assertA30` to `declare global { interface Window { __mokoshHarness: { ... } } }` + `window.__mokoshHarness = { ... }` object literal (preserve all existing entries including assertA29 from Plan 03-01).
- Updates the closing console.log line to append `+ Plan 03-02: A30`.
</behavior>
<action>
1. Open `tests/uat/extension-page-harness.ts`. Locate the assertA29 block added by Plan 03-01 + the `getManifestVersion` declaration following it.
2. Insert the new assertA30 block AFTER assertA29 (BEFORE getManifestVersion). Use this concrete code:
```typescript
/* ─── Plan 03-02 — A30 (event-log verification; SPEC §10 #5) ────────
*
* A30 — REQ-user-event-log empirical: the production listeners at
* src/content/index.ts (setupClickLogging at line 61,
* setupInputLogging at line 77, setupNavigationLogging at line
* 99, setupErrorLogging at line 133, setupNetworkLogging at
* line 164) all fire on synthetic browser events dispatched
* on the harness page, producing UserEvent entries with each
* of the 5 type-values (click / input / navigation /
* js_error / network_error) in logs/events.json.
*
* Trigger strategy (all on the harness page; no new tabs opened):
* - click: programmatic .click() on #probe-submit
* - input: set #probe-email.value + dispatch Event('input', bubbles:true)
* - navigation: history.pushState (intercepted at src/content/index.ts:121)
* - js_error: window.dispatchEvent(new ErrorEvent('error', ...))
* - network_error: fetch(404-probe-url).catch(noop) — production
* fetch interception at src/content/index.ts:167 logs response.ok===false
*
* Page-side dispatches all 5 triggers + settles + SAVE. Host-side
* driveA30 JSZip-parses logs/events.json and asserts each of the 5
* UserEvent.type literal values is present.
*
* FORBIDDEN_HOOK_STRINGS impact: NONE. A30 rides production listeners
* + existing helpers. Tier-1 inventory stays at 12.
*/
/** SAVE_ARCHIVE dispatch timeout for A30 — matches A24/A25/A27/A29. */
const A30_SAVE_ARCHIVE_TIMEOUT_MS = 15_000;
/** Pre-SAVE segment-settle window (10s rotation + 1s slack). */
const A30_SEGMENT_SETTLE_MS = 11_000;
/** Settle between trigger dispatches and SAVE so event handlers complete. */
const A30_TRIGGER_SETTLE_MS = 500;
/** 404 probe URL — chrome.tabs perm grant is irrelevant; fetch happens
* from the harness page realm. example.com is RFC 2606 reserved +
* serves a 404 reliably for unknown paths under headless Chrome. */
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).
*
* Dispatches 5 synthetic browser events that exercise each of the
* production listeners; runs setupFreshRecording so event-log
* cleanup hasn't dropped anything; settles a segment; SAVEs. Host-side
* driveA30 inspects logs/events.json from the produced zip and asserts
* each of the 5 UserEvent.type literal values appears at least once.
*
* @returns AssertionResult with 1 page-side check (SAVE ack); host-side
* driveA30 appends 5 UserEvent.type presence checks.
*/
async function assertA30(): Promise<AssertionResult> {
const result: AssertionResult = {
passed: false,
name: 'A30 — event log captures 5 UserEvent types (SPEC §10 #5 / REQ-user-event-log)',
checks: [],
diagnostics: [],
};
try {
diag(result, 'Step 1: setupFreshRecording (A30 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: settle ${A30_SEGMENT_SETTLE_MS}ms for first segment rotation`);
await new Promise((r) => setTimeout(r, A30_SEGMENT_SETTLE_MS));
diag(result, 'Step 3: click trigger — programmatic .click() on #probe-submit');
const submitBtn = document.querySelector<HTMLButtonElement>('#probe-submit');
if (submitBtn !== null) {
submitBtn.click();
} else {
diag(result, 'Step 3 WARN — #probe-submit missing; click trigger skipped');
}
diag(result, 'Step 4: input trigger — set #probe-email.value + dispatch input event');
const emailInput = document.querySelector<HTMLInputElement>('#probe-email');
if (emailInput !== null) {
emailInput.value = 'a30@probe.local';
emailInput.dispatchEvent(new Event('input', { bubbles: true }));
} else {
diag(result, 'Step 4 WARN — #probe-email missing; input trigger skipped');
}
diag(result, 'Step 5: navigation trigger — history.pushState (production wrapper at src/content/index.ts:121 intercepts)');
history.pushState({}, '', window.location.pathname + '#a30-probe');
diag(result, 'Step 6: js_error trigger — window.dispatchEvent(ErrorEvent("error"))');
window.dispatchEvent(new ErrorEvent('error', {
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)`);
try {
await fetch(A30_404_PROBE_URL);
} catch (fetchErr) {
diag(result, `Step 7 fetch threw (acceptable for network_error path): ${String(fetchErr)}`);
}
diag(result, `Step 8: settle ${A30_TRIGGER_SETTLE_MS}ms so async handlers (fetch.then) complete`);
await new Promise((r) => setTimeout(r, A30_TRIGGER_SETTLE_MS));
diag(result, 'Step 9: dispatch SAVE_ARCHIVE');
const ack = await sendMessageWithTimeout<{ success: boolean; error?: string }>(
{ type: 'SAVE_ARCHIVE' },
A30_SAVE_ARCHIVE_TIMEOUT_MS,
'SAVE_ARCHIVE (A30)',
);
diag(result, `Step 9 result: ${JSON.stringify(ack)}`);
result.checks.push({
name: 'A30.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}`);
}
return result;
}
```
3. In the `declare global { interface Window { __mokoshHarness: { ... } } }` block, AFTER the assertA29 entry that Plan 03-01 added and BEFORE `getManifestVersion`, insert:
```typescript
// Plan 03-02 — event-log verification (SPEC §10 #5)
assertA30: () => Promise<AssertionResult>;
```
4. In the `window.__mokoshHarness = { ... }` object literal, AFTER `assertA29,` and BEFORE `getManifestVersion,` insert:
```typescript
assertA30,
```
5. Update the closing `console.log(...)` line to append `+ Plan 03-02: A30`. Concrete replacement string (preserves the Plan 03-01 mention):
```typescript
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 + getManifestVersion)');
```
6. Run `npx tsc --noEmit` to confirm no type errors.
</action>
<verify>
<automated>npx tsc --noEmit &amp;&amp; grep -c "assertA30" tests/uat/extension-page-harness.ts | grep -v '^#' | awk '$1 &gt;= 3'</automated>
</verify>
<acceptance_criteria>
- `npx tsc --noEmit` exits 0.
- `grep -c "assertA30" tests/uat/extension-page-harness.ts` returns >=3 (function definition + declare global entry + object literal entry).
- `grep -c "A30_404_PROBE_URL" tests/uat/extension-page-harness.ts` returns >=2 (declaration + usage).
- `grep -c "ErrorEvent('error'" tests/uat/extension-page-harness.ts` returns >=1.
- `grep -c "history.pushState" tests/uat/extension-page-harness.ts` returns >=1.
- Existing Plan 03-01 assertA29 entry still present in __mokoshHarness object literal (`grep -c 'assertA29,' tests/uat/extension-page-harness.ts` returns >=1).
</acceptance_criteria>
<done>
Page-side assertA30 dispatches 5 synthetic event triggers + SAVE; registered on __mokoshHarness; ready for Task 2 host-side driveA30 grep.
</done>
</task>
<task type="auto" tdd="false">
<name>Task 2: Add driveA30 host-side (UserEvent type grep) + orchestrator wiring</name>
<files>tests/uat/lib/harness-page-driver.ts, tests/uat/harness.test.ts</files>
<read_first>
- tests/uat/lib/harness-page-driver.ts lines 1395-1567 (driveA26 3-phase pattern; canonical for host-side JSZip + named-entry parse)
- tests/uat/lib/harness-page-driver.ts where driveA29 was JUST added by Plan 03-01 (same file; new driver appends below)
- src/shared/types.ts lines 124-131 (UserEvent type — to import for the type-cast on parse)
- tests/uat/harness.test.ts lines 65-431 (existing import block + wrapped-driver consts + drivers array push)
- .planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-PATTERNS.md §"UserEvent type-grep pattern (Plan 03-02)"
</read_first>
<behavior>
Host-side (`tests/uat/lib/harness-page-driver.ts`):
- Adds `import type { UserEvent } from '../../../src/shared/types';` near the top imports (after the existing JSZip + EventType imports).
- Adds `export async function driveA30(page: Page, downloadsDir: string): Promise<AssertionRecord>`:
- Phase 1 — page.evaluate harness.assertA30()
- Phase 2 — findLatestZip(downloadsDir); if null push A30.0 failure
- Phase 3 — JSZip.loadAsync; read `logs/events.json` entry; if absent push A30.0a failure
- Parse as `UserEvent[]`; on JSON parse failure push A30.0b failure
- For each EXPECTED_TYPE in ['click', 'input', 'navigation', 'js_error', 'network_error']:
- Push check `A30.${2..6}: logs/events.json contains at least one '${type}' event`
expected `>=1 ${type}`, actual `userEvents.filter((e) => e.type === type).length`, passed same > 0
- Diagnostics: `A30 zipPath=${zipPath}`, `A30 userEvents.length=${userEvents.length}`, `A30 type counts: ${...}`
- Filter-pipeline form (no `continue`).
Orchestrator (`tests/uat/harness.test.ts`):
- Adds `driveA30,` to the import block (after the `driveA29,` Plan 03-01 added).
- Adds `const driveA30Wrapped: (page: import('puppeteer').Page) => Promise<AssertionRecord> = (page) => driveA30(page, handles.downloadsDir);` after the existing driveA29Wrapped.
- Adds `{ name: 'A30', drive: driveA30Wrapped },` to the drivers array after the A29 entry, with banner comment citing Plan 03-02 + SPEC §10 #5.
- Update the orchestrator banner line to append `, A30`.
</behavior>
<action>
1. Open `tests/uat/lib/harness-page-driver.ts`. In the import block (near top of file), AFTER the existing JSZip import + the EventType import that Plan 03-01 added, add:
```typescript
import type { UserEvent } from '../../../src/shared/types';
```
2. At the end of the file (AFTER driveA29 that Plan 03-01 added), append the new driveA30 export:
```typescript
/* ─── 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,
};
}
```
3. Open `tests/uat/harness.test.ts`. In the import block from `./lib/harness-page-driver`, AFTER `driveA29,` and BEFORE `getManifestVersion,` add:
```typescript
// Plan 03-02 — event-log verification (SPEC §10 #5 / REQ-user-event-log)
driveA30,
```
4. AFTER the existing `driveA29Wrapped` const, add:
```typescript
// 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);
```
5. In the drivers array, AFTER the `{ name: 'A29', ... }` entry Plan 03-01 added, add:
```typescript
// 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 },
```
6. Update the orchestrator banner line at line 268. Concrete replacement (preserves the Plan 03-01 mention):
```typescript
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');
```
7. Run `npx tsc --noEmit` to confirm no type errors.
8. Run `HEADLESS=1 SKIP_PROD_REBUILD=0 npm run test:uat`. Expected: `31/31 GREEN`.
</action>
<verify>
<automated>npx tsc --noEmit &amp;&amp; grep -c "driveA30" tests/uat/lib/harness-page-driver.ts | grep -v '^#' | awk '$1 &gt;= 2' &amp;&amp; grep -c "driveA30" tests/uat/harness.test.ts | grep -v '^#' | awk '$1 &gt;= 3' &amp;&amp; grep -c "from '../../../src/shared/types'" tests/uat/lib/harness-page-driver.ts | grep -v '^#' | grep -q '^1$' &amp;&amp; HEADLESS=1 SKIP_PROD_REBUILD=0 npm run test:uat</automated>
</verify>
<acceptance_criteria>
- `npx tsc --noEmit` exits 0.
- `grep -c "driveA30" tests/uat/lib/harness-page-driver.ts` returns >=2.
- `grep -c "driveA30" tests/uat/harness.test.ts` returns >=3.
- `grep -c "from '../../../src/shared/types'" tests/uat/lib/harness-page-driver.ts` returns exactly 1.
- `grep -c "A30_EXPECTED_TYPES" tests/uat/lib/harness-page-driver.ts` returns >=2 (declaration + loop usage).
- `HEADLESS=1 SKIP_PROD_REBUILD=0 npm run test:uat` exits 0 with stdout containing `UAT harness: 31/31 assertions passed`.
- `npm test -- --run tests/background/no-test-hooks-in-prod-bundle.test.ts` exits 0 (Tier-1 inventory stays at 12 entries).
</acceptance_criteria>
<done>
UAT harness runs 31/31 GREEN with A30 verifying SPEC §10 #5 end-to-end:
logs/events.json from the assembled zip contains at least one entry
for each of the 5 UserEvent.type literal values. Filter-pipeline
form preserved; no `continue` statements introduced.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Puppeteer host ↔ page realm | Test harness drives via page.evaluate; production listeners run inside the harness page (content script injects to all_urls) |
| Page realm ↔ content script | Synthetic events on document/window route to production listeners; no new bridge surfaces |
| Outbound fetch (network_error trigger) | Single fetch to https://example.com/<404-path> — RFC 2606 reserved domain; no PII; no real endpoint |
| dist-test/ ↔ dist/ | Two-bundle separation: Plan 03-02 adds NO test-only symbols; production bundle invariant unchanged |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-02-01 | Information Disclosure | network_error trigger fetches a real public URL | accept | example.com is RFC 2606 reserved (test-only); no PII / secrets in URL path; outbound request is harmless probe traffic (parity with Plan 02-04 A27 multi-tab https://example.com usage). |
| T-03-02-02 | Tampering | history.pushState changes harness page URL mid-test; subsequent assertions could see different document.location | mitigate | Hash-only push (`#a30-probe`); pushState does not navigate. Subsequent drivers (Plan 03-03 A31) run setupFreshRecording which is location-agnostic. No impact on tokens.css resolution or A18/A21 invariants. |
| T-03-02-03 | Information Disclosure | Test-only hook surface leaking to production bundle | mitigate | A30 rides production listeners + existing helpers; no `__MOKOSH_UAT__`-gated symbols. FORBIDDEN_HOOK_STRINGS inventory unchanged at 12 entries; Tier-1 unit-test gate runs as part of Task 2 acceptance. |
| T-03-02-04 | Denial of Service | network_error trigger fetch hangs indefinitely (example.com slow/unreachable in CI) | mitigate | fetch is awaited inside try/catch with no explicit timeout, but the page-side assertion has A30_SAVE_ARCHIVE_TIMEOUT_MS=15s overall ceiling via sendMessageWithTimeout — a hung fetch causes the test to FAIL with a clear timeout message rather than hang. CI/dev machines typically resolve example.com sub-100ms. |
No new production surface; threat surface unchanged from Plan 03-01.
UAT harness extension is test-only.
</threat_model>
<verification>
- `npx tsc --noEmit` exits 0.
- `HEADLESS=1 SKIP_PROD_REBUILD=0 npm run test:uat` exits 0 with 31/31 GREEN.
- `npm test -- --run tests/background/no-test-hooks-in-prod-bundle.test.ts` exits 0 (12 FORBIDDEN_HOOK_STRINGS × 0 hits each).
- A30 host-side diagnostic line shows non-zero count for each of the 5 UserEvent types.
</verification>
<success_criteria>
- A30 GREEN with 6 checks (SAVE ack + 5 type-presence).
- REQ-user-event-log empirically verified across all 5 UserEvent.type literal values.
- Filter-pipeline form (no `continue` statements introduced) per CLAUDE.md Control Flow §.
- FORBIDDEN_HOOK_STRINGS unchanged at 12 entries.
- vitest baseline preserved (171/171 GREEN).
</success_criteria>
<output>
After completion, create
`.planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-02-SUMMARY.md`
documenting:
- 5 trigger dispatch patterns (click via .click() + input via dispatchEvent + navigation via pushState + js_error via dispatchEvent(ErrorEvent) + network_error via fetch 404)
- 6-check A30 contract empirically verified
- UAT 30 → 31 GREEN; Tier-1 inventory unchanged at 12
- Plan 03-03 wave dependency: SAME 3 harness files; SEQUENTIAL within Wave 2 per RESEARCH Pitfall 6
</output>