Files
mokosh/.planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-01-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

769 lines
38 KiB
Markdown
Raw 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: 01
type: execute
wave: 1
depends_on: []
files_modified:
- tests/uat/extension-page-harness.html
- tests/uat/extension-page-harness.ts
- tests/uat/lib/harness-page-driver.ts
- tests/uat/harness.test.ts
autonomous: true
requirements:
- REQ-rrweb-dom-buffer
tags:
- uat-harness
- a29
- rrweb
- spec-10-4
- approach-b
- probe-html
user_setup: []
must_haves:
truths:
- "rrweb session.json contains > 0 events after a probe-page interaction"
- "rrweb emits at least one Meta event (EventType=4) on session start"
- "rrweb emits at least one FullSnapshot (EventType=2) on session start"
- "rrweb emits at least one IncrementalSnapshot (EventType=3) after a DOM mutation on the probe page"
- "UAT harness exits 0 with 29 + 1 = 30/30 assertions GREEN (A0..A28 baseline preserved + new A29)"
artifacts:
- path: "tests/uat/extension-page-harness.html"
provides: "Probe HTML (form + table + modal trigger + DOM mutation source) appended below existing scaffold; tokens.css link in head untouched"
contains: "id=\"probe-form\""
- path: "tests/uat/extension-page-harness.ts"
provides: "assertA29 page-side stub registered on window.__mokoshHarness"
contains: "assertA29"
- path: "tests/uat/lib/harness-page-driver.ts"
provides: "driveA29 host-side: triggers probe-page DOM mutation, runs setupFreshRecording + SAVE, JSZip-parses rrweb/session.json, EventType enum grep"
contains: "driveA29"
- path: "tests/uat/harness.test.ts"
provides: "driveA29 import + wrapped driver + drivers-array push entry with banner comment"
contains: "driveA29"
key_links:
- from: "tests/uat/harness.test.ts"
to: "tests/uat/lib/harness-page-driver.ts driveA29"
via: "import + wrapped driver const + drivers-array push"
pattern: "driveA29Wrapped"
- from: "tests/uat/lib/harness-page-driver.ts driveA29"
to: "tests/uat/extension-page-harness.ts assertA29"
via: "page.evaluate(() => window.__mokoshHarness.assertA29())"
pattern: "harness.assertA29\\(\\)"
- from: "tests/uat/lib/harness-page-driver.ts driveA29"
to: "@rrweb/types EventType enum"
via: "import { EventType } from '@rrweb/types'"
pattern: "import.*EventType.*from.*@rrweb/types"
---
<objective>
Extend the UAT harness with A29 — empirical verification that SPEC §10 #4
(rrweb DOM event recording on typical pages) is satisfied. Production
wiring at `src/content/index.ts:284-309` already ships `rrweb.record()` +
`maskInputOptions.password=true`; A29 confirms it actually emits the
required EventType events on a synthetic probe page (form + table +
modal + DOM-mutation trigger).
Purpose: Closes REQ-rrweb-dom-buffer empirical verification gap. Phase 1
shipped the wiring; Phase 3 confirms it works end-to-end through a real
Chrome instance + a real probe page.
Output: 4-check A29 assertion (events.length > 0 + Meta present +
FullSnapshot present + IncrementalSnapshot present); UAT count 29 → 30
GREEN; probe HTML appended to extension-page-harness.html below the
existing `<pre id="status">` scaffold (head unchanged; tokens.css link
preserved for A18/A21).
</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-UI-SPEC.md
@.planning/phases/02-stabilize-export-pipeline/02-04-SUMMARY.md
@src/content/index.ts
@tests/uat/extension-page-harness.html
<interfaces>
<!-- Key contracts the executor needs. Extracted from existing code. -->
<!-- Do NOT re-read; these are the canonical signatures. -->
From @rrweb/types/dist/index.d.ts (lines 186-194):
```typescript
export declare enum EventType {
DomContentLoaded = 0,
Load = 1,
FullSnapshot = 2,
IncrementalSnapshot = 3,
Meta = 4,
Custom = 5,
Plugin = 6
}
```
From tests/uat/lib/assertions.ts (lines 25-44):
```typescript
export interface CheckRecord {
readonly name: string;
readonly expected: unknown;
readonly actual: unknown;
readonly passed: boolean;
}
export interface AssertionRecord {
readonly passed: boolean;
readonly name: string;
readonly checks: ReadonlyArray<CheckRecord>;
readonly diagnostics: ReadonlyArray<string>;
readonly error?: string;
}
```
From tests/uat/extension-page-harness.ts (line 169):
```typescript
interface AssertionResult {
passed: boolean;
name: string;
checks: Array<{ name: string; expected: unknown; actual: unknown; passed: boolean }>;
diagnostics: string[];
error?: string;
}
function diag(result: AssertionResult, line: string): void; // line 190
async function setupFreshRecording(): Promise<{ ok: boolean; error?: string }>; // line 825
async function sendMessageWithTimeout<T>(msg: unknown, timeoutMs: number, label: string): Promise<T>; // line 234
```
From tests/uat/lib/harness-page-driver.ts (lines 1395, 41):
```typescript
function findLatestZip(downloadsDir: string): string | null; // host-side; mtime-sort wins
import JSZip from 'jszip'; // host-only, not bundled to page
```
From src/content/index.ts (verifier subject — READ-ONLY):
```typescript
// Lines 284-311: record({ emit(event){ rrwebEvents.push(event) }, maskInputOptions: { password: true, ... } })
// Line 318: chrome.runtime.onMessage 'GET_RRWEB_EVENTS' returns { events, userEvents }
// Line 82: setupInputLogging filter — if (target.type === 'password') return;
```
From src/background/index.ts (READ-ONLY):
```typescript
// GET_RRWEB_EVENTS handler in createArchive: chrome.tabs.sendMessage(activeTabId, { type: 'GET_RRWEB_EVENTS' })
// Result lands in rrweb/session.json inside the assembled zip
```
</interfaces>
# Plan Anchors
- **Approach B pattern source:** Plan 02-04 driveA26 (canonical host-side
JSZip read; chains off prior SAVE) — see `02-04-SUMMARY.md` and
`tests/uat/lib/harness-page-driver.ts:1421-1567` for the exact 3-phase
shape (page-side stub → findLatestZip → JSZip-parse).
- **RESEARCH Pitfall 1 (MUST OBSERVE):** synthetic probe HTML produces
`Meta` + `FullSnapshot` on load but NOT `IncrementalSnapshot` unless a
DOM mutation happens between page load and SAVE. Plan must inject a
mutation pre-SAVE (e.g. click the modal trigger button or input text).
- **RESEARCH Pitfall 4 (HARD BAN):** Probe HTML MUST NOT include
`<textarea>` — rrweb 2.0.0-alpha.4 issue #1596 leaks textarea values
even with `maskInputOptions.textarea` set.
- **UI-SPEC ban (HARD):** Probe HTML must NOT modify the `<head>` block.
The existing `<link rel="stylesheet" href="../../src/shared/tokens.css">`
is load-bearing for A18 + A21. Probe HTML appends below the existing
`<pre id="status">` (line 21) and BEFORE the existing `<script>` tag.
- **FORBIDDEN_HOOK_STRINGS lockstep (RESEARCH §"Anti-Patterns"):** A29
rides production surfaces (rrweb.record + GET_RRWEB_EVENTS bridge +
chrome.runtime.sendMessage). Tier-1 inventory stays at **12 entries**.
No new `__MOKOSH_UAT__`-gated symbols expected.
- **Source audit citation:** Implements REQ-rrweb-dom-buffer per
REQUIREMENTS.md lines 54-60 (10-min window + 5000-event cap + DOM
capture via rrweb 2.0.0-alpha.4 pin). The 10-min cleanup mechanics
are already covered by `src/content/index.ts:27-50` and are NOT
exercised by A29 — A29's contract is "rrweb records events without
errors on probe pages", which closes §10 #4 literally per CONTEXT
D-P3-01.
</context>
<tasks>
<task type="auto" tdd="false">
<name>Task 1: Append probe HTML to extension-page-harness.html (form + table + modal)</name>
<files>tests/uat/extension-page-harness.html</files>
<read_first>
- tests/uat/extension-page-harness.html (full file; 24 lines — see exact contents in interfaces above)
- .planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-UI-SPEC.md §"Test Fixture Conventions" (probe HTML rules: NO textarea; NO tokens.css import; NO data-mokosh-* attrs; data-test-* OK)
- .planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-RESEARCH.md §"Pitfall 1" (DOM mutation required pre-SAVE) and §"Pitfall 4" (textarea ban)
</read_first>
<behavior>
- The harness page exposes (querySelector-reachable) a form with at
least one text input (id="probe-text"), one email input
(id="probe-email"), one password input (id="probe-password"),
one submit button, and an enclosing form (id="probe-form").
- The harness page exposes a small static table with thead + tbody +
at least 2 data rows (id="probe-table").
- The harness page exposes a modal trigger button
(id="probe-modal-trigger") that, when clicked, toggles a hidden
div (id="probe-modal") to visible (display:none → display:block).
This click is the canonical DOM-mutation source per Pitfall 1.
- The existing tokens.css link in `<head>` is NOT modified.
- The existing `<h1>`, two `<p>` lines, and `<pre id="status">` are
NOT modified. The existing `<script type="module">` is NOT moved.
- The probe HTML appears between line 21 (`<pre id="status">`) and
line 22 (`<script type="module">`) in the source — appended to
the body after the existing scaffold, before the script.
- No `<textarea>` element anywhere in the file (rrweb alpha.4
issue #1596).
- No `<link>` or `<style>` additions referencing tokens.css.
- No `data-mokosh-slot`, `data-mokosh-key`, or `data-mokosh-i18n-key`
attributes (reserved for production welcome page).
- The probe HTML uses inline minimal styles (`style="display:none"`
on the modal); no `<style>` block.
- On the modal trigger click, `document.getElementById('probe-modal')`
toggles its `style.display` between 'none' and 'block' via a
small inline `onclick` handler. (rrweb records the attribute
mutation, satisfying IncrementalSnapshot emission.)
</behavior>
<action>
1. Read the current `tests/uat/extension-page-harness.html` (24 lines, contents shown in interfaces).
2. Open the file for editing. Locate the line ` <pre id="status">Ready.</pre>` (line 21) and the next line ` <script type="module" src="./extension-page-harness.ts"></script>` (line 22).
3. Insert the following probe HTML block BETWEEN line 21 and line 22 (preserving the trailing newline structure):
```html
<!--
Plan 03-01 Wave 1 — probe HTML for A29 rrweb DOM verification
(SPEC §10 #4). Provides form + table + modal-trigger so rrweb's
record() emits Meta + FullSnapshot + IncrementalSnapshot events
against this page. Per RESEARCH Pitfall 4: NO <textarea> (rrweb
2.0.0-alpha.4 issue #1596 leaks textarea values even with
maskInputOptions.textarea set). Per UI-SPEC Section "Test Fixture
Conventions": probe HTML uses plain data-test-* attributes; no
data-mokosh-* (production-welcome-page reserved); no tokens.css
import (the harness head already imports the canonical tokens
for A18/A21 — probe sub-tree should appear unstyled to keep
rrweb snapshots focused on structural DOM).
-->
<form id="probe-form" data-test="probe-form">
<input type="text" id="probe-text" data-test="probe-text" />
<input type="email" id="probe-email" data-test="probe-email" />
<input type="password" id="probe-password" data-test="probe-password" />
<button type="submit" id="probe-submit" data-test="probe-submit">Submit</button>
</form>
<table id="probe-table" data-test="probe-table">
<thead>
<tr><th>col-a</th><th>col-b</th></tr>
</thead>
<tbody>
<tr><td>row-1-a</td><td>row-1-b</td></tr>
<tr><td>row-2-a</td><td>row-2-b</td></tr>
</tbody>
</table>
<button id="probe-modal-trigger" data-test="probe-modal-trigger"
onclick="(function(){var m=document.getElementById('probe-modal');if(m){m.style.display=(m.style.display==='block'?'none':'block');}})()">
Toggle modal
</button>
<div id="probe-modal" data-test="probe-modal" style="display:none;">
<p>Probe modal content (Plan 03-01 A29 IncrementalSnapshot trigger).</p>
</div>
```
4. Verify no `<textarea>` was introduced (grep should return 0 hits on the modified file).
5. Verify the `<head>` block is unchanged.
6. No new dependencies, no other file edits in this task.
</action>
<verify>
<automated>grep -c "id=\"probe-form\"" tests/uat/extension-page-harness.html | grep -v '^#' | grep -q '^1$' &amp;&amp; grep -c "id=\"probe-modal-trigger\"" tests/uat/extension-page-harness.html | grep -v '^#' | grep -q '^1$' &amp;&amp; grep -c "id=\"probe-table\"" tests/uat/extension-page-harness.html | grep -v '^#' | grep -q '^1$' &amp;&amp; ! grep -q "textarea" tests/uat/extension-page-harness.html &amp;&amp; grep -q '<link rel="stylesheet" href="../../src/shared/tokens.css">' tests/uat/extension-page-harness.html</automated>
</verify>
<acceptance_criteria>
- `grep -c 'id="probe-form"' tests/uat/extension-page-harness.html` returns exactly `1`.
- `grep -c 'id="probe-modal-trigger"' tests/uat/extension-page-harness.html` returns exactly `1`.
- `grep -c 'id="probe-table"' tests/uat/extension-page-harness.html` returns exactly `1`.
- `grep -c 'id="probe-password"' tests/uat/extension-page-harness.html` returns exactly `1` (carried into Plan 03-03).
- `grep -c 'textarea' tests/uat/extension-page-harness.html` returns `0`.
- `grep -c '<link rel="stylesheet" href="../../src/shared/tokens.css">' tests/uat/extension-page-harness.html` returns exactly `1` (head unchanged).
- `grep -c 'data-mokosh-slot\|data-mokosh-key\|data-mokosh-i18n-key' tests/uat/extension-page-harness.html` returns `0`.
- `npm run build` exits 0.
</acceptance_criteria>
<done>
Probe HTML lives in the harness page below the existing scaffold;
head + script tag + existing body content all unchanged; no
textarea; ready for Task 2 to wire assertA29.
</done>
</task>
<task type="auto" tdd="false">
<name>Task 2: Add assertA29 page-side stub + driveA29 host-side + harness.test.ts orchestrator wiring</name>
<files>tests/uat/extension-page-harness.ts, tests/uat/lib/harness-page-driver.ts, tests/uat/harness.test.ts</files>
<read_first>
- tests/uat/extension-page-harness.ts lines 3151-3413 (assertA26 stub + assertA27 multi-tab + assertA28 stub + declare global block + window.__mokoshHarness object literal — pattern for new assertion)
- tests/uat/lib/harness-page-driver.ts lines 1395-1567 (findLatestZip + driveA26 3-phase pattern; canonical for host-side JSZip parse + EventType-style structural assertions)
- tests/uat/lib/harness-page-driver.ts lines 36-46 (existing imports + JSZip + AssertionRecord/CheckRecord)
- tests/uat/harness.test.ts lines 65-130 (existing driver imports + FORBIDDEN_HOOK_STRINGS at 12 entries)
- tests/uat/harness.test.ts lines 316-431 (existing wrapped-driver consts + drivers-array push pattern)
- node_modules/@rrweb/types/dist/index.d.ts lines 186-194 (EventType enum values; confirmed in interfaces above)
- .planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-PATTERNS.md §"EventType enum grep pattern (Plan 03-01)" and §"Page-side stub pattern" (canonical implementation)
</read_first>
<behavior>
Page-side (`tests/uat/extension-page-harness.ts`):
- Adds module-local constants `A29_SEGMENT_SETTLE_MS = 11_000`
(matches A24/A25/A27 settle window).
- Adds `async function assertA29(): Promise<AssertionResult>` that
- dispatches a programmatic DOM mutation on the probe HTML:
- sets `#probe-text.value = 'probe'` and dispatches `Event('input', { bubbles: true })`
- clicks `#probe-modal-trigger` (toggles `#probe-modal` style.display)
- waits 500 ms for rrweb to enqueue the IncrementalSnapshot
- calls `setupFreshRecording()` then waits `A29_SEGMENT_SETTLE_MS`
- dispatches `SAVE_ARCHIVE` via `sendMessageWithTimeout` (15s timeout, label 'SAVE_ARCHIVE (A29)')
- pushes `A29.1: SAVE_ARCHIVE ack success===true` to checks
- returns the `AssertionResult` (page-side computes ONLY the SAVE
ack; the EventType-enum-shape checks live host-side in driveA29
because @rrweb/types isn't bundled to the page).
- Adds `assertA29: () => Promise<AssertionResult>;` to the
`declare global { interface Window { __mokoshHarness: { ... } } }`
block (preserve existing entries; append after assertA28).
- Adds `assertA29,` to the `window.__mokoshHarness = { ... }`
object literal (preserve existing entries; append after assertA28).
- Update the closing console.log message line (currently at
line 3411) to mention `+ Plan 03-01: A29`.
Host-side (`tests/uat/lib/harness-page-driver.ts`):
- Adds `import { EventType } from '@rrweb/types';` at the top of
the imports section (after the JSZip import on line 41).
- Adds `export async function driveA29(page: Page, downloadsDir: string): Promise<AssertionRecord>` modeled on driveA26 (lines 1421-1567):
- Phase 1 — page.evaluate `harness.assertA29()``pageResult`
- Phase 2 — `findLatestZip(downloadsDir)`; if null push
`A29.0` failure and return
- Phase 3 — JSZip.loadAsync(buf); read `rrweb/session.json`
entry; if absent push failed `A29.0a: rrweb/session.json
present` check + return
- Parse the JSON as `Array<{ type: number; timestamp: number }>`.
- Push 4 checks (names verbatim):
- `A29.1: rrweb/session.json contains > 0 events`
expected `>0`, actual `events.length`, passed `events.length > 0`
- `A29.2: rrweb emitted at least one Meta event (EventType.Meta=4)`
expected `has Meta`, actual `events.some((e) => e.type === EventType.Meta)`, passed same
- `A29.3: rrweb emitted at least one FullSnapshot (EventType.FullSnapshot=2)`
expected `has FullSnapshot`, actual `events.some((e) => e.type === EventType.FullSnapshot)`, passed same
- `A29.4: rrweb emitted at least one IncrementalSnapshot (EventType.IncrementalSnapshot=3)`
expected `has IncrementalSnapshot`, actual `events.some((e) => e.type === EventType.IncrementalSnapshot)`, passed same
- Merge with `pageResult.checks` (carries A29.1 SAVE ack already);
recompute `mergedPassed = mergedChecks.every((c) => c.passed)`.
- Push diagnostic `A29 zipPath=${zipPath}`,
`A29 events.length=${events.length}`, and
`A29 event types: ${[...new Set(events.map((e) => e.type))].sort().join(',')}`.
Orchestrator (`tests/uat/harness.test.ts`):
- Adds `driveA29,` to the import block from `./lib/harness-page-driver` (after `driveA28,`).
- Adds `const driveA29Wrapped: (page: import('puppeteer').Page) => Promise<AssertionRecord> = (page) => driveA29(page, handles.downloadsDir);` after the existing `driveA28Wrapped` const (mirrors A26/A28 host-side-needs-downloadsDir pattern).
- Adds `{ name: 'A29', drive: driveA29Wrapped },` to the `drivers` array after the A28 entry, with a banner comment block citing Plan 03-01 + SPEC §10 #4 + the chaining strategy (A29 owns its SAVE; injects DOM mutation pre-SAVE per RESEARCH Pitfall 1).
- FORBIDDEN_HOOK_STRINGS array stays at 12 entries (no new `__MOKOSH_UAT__`-gated symbols).
</behavior>
<action>
1. Open `tests/uat/extension-page-harness.ts`. Locate the `assertA28` stub (currently around lines 3294-3316) and the `getManifestVersion` declaration block following it.
2. Insert the new `assertA29` block AFTER `assertA28` (and AFTER its end-of-file comments / before `getManifestVersion`). Use this concrete code:
```typescript
/* ─── Plan 03-01 Task 2 — A29 (rrweb DOM verification; SPEC §10 #4) ─
*
* A29 — REQ-rrweb-dom-buffer empirical: rrweb's record() (already
* wired at src/content/index.ts:285) emits Meta + FullSnapshot
* + at least one IncrementalSnapshot when the harness page
* contains the probe HTML (form + table + modal) AND the driver
* injects a DOM mutation before SAVE (RESEARCH Pitfall 1:
* static probe HTML emits Meta + FullSnapshot but not
* IncrementalSnapshot without mutation).
*
* Page side: dispatch the probe-page DOM mutations (input value +
* modal toggle), settle, setupFreshRecording, settle one segment,
* dispatch SAVE_ARCHIVE, push A29.1 ack check. The host-side driveA29
* does the EventType-enum-shape grep against rrweb/session.json from
* the assembled zip (matches A26's chained-assertion pattern; JSZip
* + @rrweb/types are host-only deps).
*
* FORBIDDEN_HOOK_STRINGS impact: NONE. A29 rides production rrweb
* wiring (record() at src/content/index.ts:285 + GET_RRWEB_EVENTS
* round-trip at src/background/index.ts → src/content/index.ts:318)
* + the existing setupFreshRecording / sendMessageWithTimeout
* helpers. Tier-1 inventory stays at 12 entries.
*/
/** SAVE_ARCHIVE dispatch timeout for A29 — matches A24/A25/A27. */
const A29_SAVE_ARCHIVE_TIMEOUT_MS = 15_000;
/** Pre-SAVE segment-settle window (10s rotation + 1s slack). */
const A29_SEGMENT_SETTLE_MS = 11_000;
/** Settle window between DOM mutation and SAVE so rrweb's
* IncrementalSnapshot lands in the in-memory buffer before
* GET_RRWEB_EVENTS fires. */
const A29_MUTATION_SETTLE_MS = 500;
/**
* A29 — rrweb DOM event recording empirical (SPEC §10 #4).
*
* Page-side dispatches the probe-page mutation (input.value + modal
* toggle), settles, runs setupFreshRecording + segment-settle, then
* dispatches SAVE_ARCHIVE. Only the SAVE ack lives in the
* page-side checks; the EventType-enum-shape grep is host-side
* because @rrweb/types is host-only.
*
* @returns AssertionResult with 1 page-side check (SAVE ack); host-side
* driveA29 appends A29.0/A29.1..A29.4 EventType checks.
*/
async function assertA29(): Promise<AssertionResult> {
const result: AssertionResult = {
passed: false,
name: 'A29 — rrweb DOM events recorded without errors (SPEC §10 #4 / REQ-rrweb-dom-buffer)',
checks: [],
diagnostics: [],
};
try {
diag(result, 'Step 1: dispatch probe-page DOM mutation (input value + modal toggle)');
const textInput = document.querySelector<HTMLInputElement>('#probe-text');
if (textInput !== null) {
textInput.value = 'probe';
textInput.dispatchEvent(new Event('input', { bubbles: true }));
}
const modalTrigger = document.querySelector<HTMLButtonElement>('#probe-modal-trigger');
if (modalTrigger !== null) {
modalTrigger.click();
}
diag(result, `Step 1 OK — mutations dispatched (#probe-text=${textInput !== null}, #probe-modal-trigger=${modalTrigger !== null})`);
diag(result, `Step 2: settle ${A29_MUTATION_SETTLE_MS}ms for rrweb IncrementalSnapshot to enqueue`);
await new Promise((r) => setTimeout(r, A29_MUTATION_SETTLE_MS));
diag(result, 'Step 3: setupFreshRecording');
const setupResp = await setupFreshRecording();
if (!setupResp.ok) {
throw new Error(`setupFreshRecording failed: ${setupResp.error ?? '(no error)'}`);
}
diag(result, 'Step 3 OK — REC state established');
diag(result, `Step 4: settle ${A29_SEGMENT_SETTLE_MS}ms for first segment rotation`);
await new Promise((r) => setTimeout(r, A29_SEGMENT_SETTLE_MS));
diag(result, 'Step 5: dispatch SAVE_ARCHIVE');
const ack = await sendMessageWithTimeout<{ success: boolean; error?: string }>(
{ type: 'SAVE_ARCHIVE' },
A29_SAVE_ARCHIVE_TIMEOUT_MS,
'SAVE_ARCHIVE (A29)',
);
diag(result, `Step 5 result: ${JSON.stringify(ack)}`);
result.checks.push({
name: 'A29.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 SAME file, locate the `declare global { interface Window { __mokoshHarness: { ... } } }` block (around lines 3332-3372). Inside the type literal, AFTER the existing `assertA28: () => Promise<AssertionResult>;` entry and BEFORE `getManifestVersion`, insert:
```typescript
// Plan 03-01 — rrweb DOM verification (SPEC §10 #4)
assertA29: () => Promise<AssertionResult>;
```
4. Locate the `window.__mokoshHarness = { ... }` object literal (around lines 3374-3404). After `assertA28,` and before `getManifestVersion,` insert:
```typescript
assertA29,
```
5. Locate the trailing `console.log('[harness-page] ready — ...')` line (around line 3411) and replace the message to append `+ Plan 03-01: A29`. Concrete new line:
```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 + getManifestVersion)');
```
6. Open `tests/uat/lib/harness-page-driver.ts`. In the import block (after the `import JSZip from 'jszip';` line — around line 41), add:
```typescript
import { EventType } from '@rrweb/types';
```
7. At the end of the file (after the existing `driveA28` export), append the new `driveA29` export:
```typescript
/* ─── Plan 03-01 — driveA29 (rrweb DOM verification host-side) ─────── */
/**
* Drive A29 (Plan 03-01 — SPEC §10 #4 / REQ-rrweb-dom-buffer).
*
* Three-phase orchestration:
* 1. Page-side assertA29 dispatches the probe DOM mutations, settles,
* runs setupFreshRecording, settles a segment, dispatches SAVE.
* Returns AssertionRecord with A29.1 (SAVE ack) only.
* 2. Host-side findLatestZip picks the just-produced zip (mtime-sort
* wins; race-free per Plan 02-04 precedent — assertA29 awaits the
* SAVE ack before returning so the zip has landed by here).
* 3. Host-side JSZip-parse rrweb/session.json + EventType-enum grep.
*
* Checks appended host-side:
* - A29.0a: rrweb/session.json entry exists in zip
* - A29.1 (already from page side): SAVE_ARCHIVE ack success
* - A29.2: events.length > 0
* - A29.3: events.some(e => e.type === EventType.Meta)
* - A29.4: events.some(e => e.type === EventType.FullSnapshot)
* - A29.5: events.some(e => e.type === EventType.IncrementalSnapshot)
*
* RESEARCH Pitfall 1 mitigation: the page-side dispatches a real DOM
* mutation (input value + modal toggle) BEFORE SAVE so the
* IncrementalSnapshot check (A29.5) has actual content to find.
*
* @param page - The harness page from `launchHarnessBrowser`.
* @param downloadsDir - Absolute path to the per-run downloads directory.
* @returns AssertionRecord with 6 merged checks (A29.0a + A29.1..A29.5).
*/
export async function driveA29(
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.assertA29();
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: 'A29.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(`A29 zipPath=${zipPath}`);
// Phase 3 — load + inspect rrweb/session.json.
const zipBytes = readFileSync(zipPath);
const zip = await JSZip.loadAsync(zipBytes);
const rrwebFile = zip.file('rrweb/session.json');
mergedChecks.push({
name: 'A29.0a: rrweb/session.json entry exists in zip',
expected: true,
actual: rrwebFile !== null,
passed: rrwebFile !== null,
});
if (rrwebFile === null) {
return {
passed: false,
name: pageResult.name,
checks: mergedChecks,
diagnostics: mergedDiagnostics,
error: pageResult.error,
};
}
const rrwebText = await rrwebFile.async('string');
let events: Array<{ type: number; timestamp: number }> = [];
let parseErr: string | null = null;
try {
events = JSON.parse(rrwebText) as Array<{ type: number; timestamp: number }>;
} catch (err) {
parseErr = err instanceof Error ? err.message : String(err);
}
if (parseErr !== null) {
mergedChecks.push({
name: 'A29.0b: rrweb/session.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,
};
}
const distinctTypes = [...new Set(events.map((e) => e.type))].sort((a, b) => a - b);
mergedDiagnostics.push(`A29 events.length=${events.length}`);
mergedDiagnostics.push(`A29 event types: ${distinctTypes.join(',')}`);
mergedChecks.push({
name: 'A29.2: rrweb/session.json contains > 0 events',
expected: '>0',
actual: events.length,
passed: events.length > 0,
});
mergedChecks.push({
name: `A29.3: rrweb emitted at least one Meta event (EventType.Meta=${EventType.Meta})`,
expected: 'has Meta',
actual: events.some((e) => e.type === EventType.Meta),
passed: events.some((e) => e.type === EventType.Meta),
});
mergedChecks.push({
name: `A29.4: rrweb emitted at least one FullSnapshot (EventType.FullSnapshot=${EventType.FullSnapshot})`,
expected: 'has FullSnapshot',
actual: events.some((e) => e.type === EventType.FullSnapshot),
passed: events.some((e) => e.type === EventType.FullSnapshot),
});
mergedChecks.push({
name: `A29.5: rrweb emitted at least one IncrementalSnapshot (EventType.IncrementalSnapshot=${EventType.IncrementalSnapshot})`,
expected: 'has IncrementalSnapshot',
actual: events.some((e) => e.type === EventType.IncrementalSnapshot),
passed: events.some((e) => e.type === EventType.IncrementalSnapshot),
});
const mergedPassed = mergedChecks.every((c) => c.passed);
return {
passed: mergedPassed,
name: pageResult.name,
checks: mergedChecks,
diagnostics: mergedDiagnostics,
error: pageResult.error,
};
}
```
8. Open `tests/uat/harness.test.ts`. In the import block from `./lib/harness-page-driver` (around lines 65-101), AFTER `driveA28,` and BEFORE `getManifestVersion,`, add:
```typescript
// Plan 03-01 — rrweb DOM verification (SPEC §10 #4 / REQ-rrweb-dom-buffer)
driveA29,
```
9. In the same file, locate the existing `driveA28Wrapped` const (around lines 330-335). AFTER it, add:
```typescript
// Plan 03-01 — driveA29 needs downloadsDir for host-side JSZip parse
// of rrweb/session.json from the just-produced zip.
const driveA29Wrapped: (page: import('puppeteer').Page) => Promise<AssertionRecord> =
(page) => driveA29(page, handles.downloadsDir);
```
10. In the `drivers` array (around lines 337-431), AFTER the existing `{ name: 'A28', drive: driveA28Wrapped },` entry, add:
```typescript
// Plan 03-01 A29: rrweb DOM verification (SPEC §10 #4).
// A29 owns its SAVE because the probe-page DOM mutation must
// happen between page load and SAVE so rrweb's IncrementalSnapshot
// fires (RESEARCH Pitfall 1). Host-side driveA29 JSZip-parses
// rrweb/session.json and asserts the EventType enum surfaces
// (Meta=4, FullSnapshot=2, IncrementalSnapshot=3) are present.
{ name: 'A29', drive: driveA29Wrapped },
```
11. Update the orchestrator banner string (currently at line 268: `'Architecture: A0 pre-flight + extension-internal page driver (A1..A14, A15..A17, A18..A22, A23, A24, A25, A26, A27, A28)'`) to append `, A29`. Concrete replacement string for line 268:
```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)\n');
```
12. Run `npx tsc --noEmit` to confirm no type errors. Expected: clean.
13. Run `SKIP_PROD_REBUILD=0 HEADLESS=1 npm run test:uat` (full rebuild + harness). Expected: `30/30 GREEN` (29 prior + A29).
</action>
<verify>
<automated>npx tsc --noEmit &amp;&amp; grep -c "assertA29" tests/uat/extension-page-harness.ts | grep -v '^#' | awk '$1 &gt;= 3' &amp;&amp; grep -c "driveA29" tests/uat/lib/harness-page-driver.ts | grep -v '^#' | awk '$1 &gt;= 2' &amp;&amp; grep -c "driveA29" tests/uat/harness.test.ts | grep -v '^#' | awk '$1 &gt;= 3' &amp;&amp; grep -c "from '@rrweb/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 'assertA29' tests/uat/extension-page-harness.ts` returns >=3 (function definition + declare global entry + object literal entry).
- `grep -c 'driveA29' tests/uat/lib/harness-page-driver.ts` returns >=2 (function definition + docstring or signature mention).
- `grep -c 'driveA29' tests/uat/harness.test.ts` returns >=3 (import line + wrapped const + drivers array push).
- `grep -c "from '@rrweb/types'" tests/uat/lib/harness-page-driver.ts` returns exactly 1.
- `HEADLESS=1 SKIP_PROD_REBUILD=0 npm run test:uat` exits 0 with stdout containing `UAT harness: 30/30 assertions passed`.
- `npm test -- --run tests/background/no-test-hooks-in-prod-bundle.test.ts` exits 0 (Tier-1 inventory stays at 12 entries; verifies no new hook leaks).
</acceptance_criteria>
<done>
UAT harness runs 30/30 GREEN with A29 covering SPEC §10 #4 end-to-end:
rrweb session.json from the assembled zip contains > 0 events with
Meta + FullSnapshot + IncrementalSnapshot EventType-enum members
all present. FORBIDDEN_HOOK_STRINGS unchanged at 12.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Puppeteer host ↔ page realm | Test harness drives the page via page.evaluate; no production data flows out |
| Page realm ↔ content script | Existing GET_RRWEB_EVENTS round-trip via chrome.runtime.sendMessage |
| Content script ↔ SW | Existing chrome.tabs.sendMessage path (read-only for Plan 03-01) |
| dist-test/ ↔ dist/ | Two-bundle separation: Plan 03-01 adds NO test-only symbols; production bundle invariant unchanged |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-01-01 | Information Disclosure | Probe HTML password input visible in DOM | accept | Sentinel value is not introduced by Plan 03-01 (Plan 03-03 types the negative-assertion sentinel). Plan 03-01 leaves the password input empty; rrweb's existing maskInputOptions.password=true at src/content/index.ts:306 masks any value that lands. |
| T-03-01-02 | Tampering | New probe HTML elements interfere with A18/A21 token-resolution checks | mitigate | UI-SPEC ban enforced: probe HTML appends BELOW existing scaffold; head + tokens.css link untouched; probe sub-tree uses no var(--mks-*) tokens; verification grep at task acceptance proves the existing link line is preserved. |
| T-03-01-03 | Information Disclosure | Test-only hook surface leaking to production bundle | mitigate | A29 rides production rrweb wiring + existing GET_RRWEB_EVENTS bridge + sendMessageWithTimeout helper. FORBIDDEN_HOOK_STRINGS inventory unchanged at 12 entries; Tier-1 unit-test gate runs as part of Task 2 acceptance. |
| T-03-01-04 | Denial of Service | rrweb cleanupOldEvents fires mid-A29 + drops the IncrementalSnapshot before SAVE | accept | A29 settles 500ms after mutation + 11s segment-settle + SAVE. CLEANUP_INTERVAL_MS=60s + retention=10min at src/content/index.ts:16-18 — A29's wall-clock <12s budget is far below the cleanup window; mutation event safely retained. |
No new production surface; threat surface unchanged from Phase 2
(CON-manifest-permissions intact including DEC-011 Amendment 1 tabs
permission). UAT harness extension is test-only and tree-shakes from
production bundle via the existing `__MOKOSH_UAT__` Vite-define-token —
A29 introduces no new `__MOKOSH_UAT__`-gated symbols.
</threat_model>
<verification>
- `npx tsc --noEmit` exits 0.
- `HEADLESS=1 SKIP_PROD_REBUILD=0 npm run test:uat` exits 0 with 30/30 GREEN.
- `npm test -- --run tests/background/no-test-hooks-in-prod-bundle.test.ts` exits 0 (12 FORBIDDEN_HOOK_STRINGS × 0 hits each).
- `grep -c '<textarea' tests/uat/extension-page-harness.html` returns 0.
- `grep -c '<link rel="stylesheet" href="../../src/shared/tokens.css">' tests/uat/extension-page-harness.html` returns 1.
</verification>
<success_criteria>
- A29 GREEN in UAT harness across 6 host-side checks (A29.0a + A29.1..A29.5).
- Probe HTML committed to harness page in correct location; head + script tag preserved.
- FORBIDDEN_HOOK_STRINGS unchanged at 12 entries.
- vitest baseline preserved (171/171 GREEN; SKIP_BUILD=1 path).
- REQ-rrweb-dom-buffer empirically verified end-to-end through rrweb's
`record()` wiring + GET_RRWEB_EVENTS bridge + the assembled zip's
rrweb/session.json content.
</success_criteria>
<output>
After completion, create
`.planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-01-SUMMARY.md`
documenting:
- 4-check A29 contract verified (Meta + FullSnapshot + IncrementalSnapshot + count > 0)
- Probe HTML composition rationale (form + table + modal; NO textarea)
- DOM-mutation injection pattern (input value + modal click)
- RESEARCH Pitfall 1 mitigation confirmed empirically
- UAT 29 → 30 GREEN; Tier-1 inventory unchanged at 12
- Plan 03-02 wave dependency: SAME 3 harness files; SEQUENTIAL within Wave 2 per RESEARCH Pitfall 6
</output>