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>
769 lines
38 KiB
Markdown
769 lines
38 KiB
Markdown
---
|
||
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$' && grep -c "id=\"probe-modal-trigger\"" tests/uat/extension-page-harness.html | grep -v '^#' | grep -q '^1$' && grep -c "id=\"probe-table\"" tests/uat/extension-page-harness.html | grep -v '^#' | grep -q '^1$' && ! grep -q "textarea" tests/uat/extension-page-harness.html && 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 && grep -c "assertA29" tests/uat/extension-page-harness.ts | grep -v '^#' | awk '$1 >= 3' && grep -c "driveA29" tests/uat/lib/harness-page-driver.ts | grep -v '^#' | awk '$1 >= 2' && grep -c "driveA29" tests/uat/harness.test.ts | grep -v '^#' | awk '$1 >= 3' && grep -c "from '@rrweb/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 '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>
|