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>
681 lines
33 KiB
Markdown
681 lines
33 KiB
Markdown
---
|
||
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 && grep -c "assertA30" tests/uat/extension-page-harness.ts | grep -v '^#' | awk '$1 >= 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 && grep -c "driveA30" tests/uat/lib/harness-page-driver.ts | grep -v '^#' | awk '$1 >= 2' && grep -c "driveA30" tests/uat/harness.test.ts | grep -v '^#' | awk '$1 >= 3' && grep -c "from '../../../src/shared/types'" tests/uat/lib/harness-page-driver.ts | grep -v '^#' | grep -q '^1$' && 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>
|