---
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>
