Milestone v1 (v2.0.0): Mokosh — Session Capture #1
@@ -0,0 +1,301 @@
|
|||||||
|
---
|
||||||
|
phase: 03-spec-10-smoke-verification-dom-event-log-verification
|
||||||
|
plan: 02
|
||||||
|
subsystem: testing
|
||||||
|
tags:
|
||||||
|
- uat-harness
|
||||||
|
- a30
|
||||||
|
- event-log
|
||||||
|
- user-event
|
||||||
|
- spec-10-5
|
||||||
|
- req-user-event-log
|
||||||
|
- approach-b
|
||||||
|
- mv3-isolation
|
||||||
|
- chrome-scripting
|
||||||
|
- phase-3-wave-2
|
||||||
|
|
||||||
|
requires:
|
||||||
|
- phase: 01-stabilize-video-pipeline
|
||||||
|
provides: "Plan 01-13 UAT harness Approach B (extension-internal page + synthetic MediaStream; page-side assertA* + host-side driveA* + harness.test.ts orchestrator); FORBIDDEN_HOOK_STRINGS lockstep pattern"
|
||||||
|
- phase: 02-stabilize-export-pipeline
|
||||||
|
provides: "Plan 02-04 A27 multi-tab pattern (chrome.tabs.create + activate + cleanup with silent-ignore finally; T-02-04-04 mitigation parity); A24-A28 chained-zip pattern; findLatestZip helper; JSZip host-side parse pattern; DEC-011 Amendment 1 tabs permission grant; src/content/index.ts event-log wiring (production-shipped Phase 1)"
|
||||||
|
- plan: 03-01
|
||||||
|
provides: "Plan 03-01 assertA29 + driveA29 page-side-orchestrator scaffold + __mokoshHarness wiring shape + EventType import precedent + probe HTML fixture (also used incidentally by A30 — though A30 ended up needing a fresh probe tab instead because chrome-extension:// doesn't have content_scripts)"
|
||||||
|
|
||||||
|
provides:
|
||||||
|
- 1 new UAT harness assertion (A30) empirically verifying REQ-user-event-log + SPEC §10 #5 end-to-end across all 5 UserEvent.type literal values (click, input, navigation, js_error, network_error)
|
||||||
|
- assertA30 page-side orchestrator (chrome.tabs.create probe tab + chrome.scripting.executeScript ISOLATED-world injection of 5 triggers + setupFreshRecording + SAVE + finally tab cleanup) at tests/uat/extension-page-harness.ts
|
||||||
|
- driveA30 host-side 3-phase driver (page.evaluate + findLatestZip + JSZip logs/events.json + UserEvent type-count grep) at tests/uat/lib/harness-page-driver.ts
|
||||||
|
- `import type { UserEvent } from '../../../src/shared/types'` for type-safe 5-tuple grep
|
||||||
|
- A30_EXPECTED_TYPES = ['click','input','navigation','js_error','network_error'] (canonical CON-event-log-schema tuple)
|
||||||
|
- Orchestrator extension: drivers array 29 → 30; total 31/31 with A0; banner mentions A30
|
||||||
|
|
||||||
|
affects:
|
||||||
|
- phase-03 plans 03/04/05 (will inherit the cs-injection-world probe-tab pattern for any future event-log verification + may need to re-target A29 to use the same pattern in a follow-up if its content-script-on-harness-page accident is closed)
|
||||||
|
- phase-04 candidate: re-verify A29 with a proper https:// probe tab + close the A29 "passed via iana.org leftover" latent issue surfaced during A30 debugging (see Issues Encountered)
|
||||||
|
|
||||||
|
tech-stack:
|
||||||
|
added:
|
||||||
|
- "chrome.scripting.executeScript({world: 'ISOLATED'}) — explicit injection into the content-script's world so the in-isolated-world fetch wrapper at src/content/index.ts:167 receives the network_error trigger. Already in manifest `permissions` (line 12); first explicit use in UAT harness."
|
||||||
|
patterns:
|
||||||
|
- "Probe-tab cs-injection-world pattern (NEW for Phase 3): instead of assuming the content script is injected on the harness page (chrome-extension://, which `<all_urls>` does NOT cover per Chrome match-pattern spec), A30 opens a fresh https://example.com tab, lets the production content script attach normally, then injects synthetic events via chrome.scripting.executeScript into the ISOLATED world (default). This makes BOTH (a) DOM event listeners (click/input/popstate/error — attached via document.addEventListener / window.addEventListener — DOM events cross worlds), AND (b) the window.fetch wrapper (which is a content-script-isolated-world override — NOT propagated to page main world) fire correctly for the same SAVE."
|
||||||
|
- "T-02-04-04 silent-ignore finally cleanup parity: A30 reuses A27's pattern of try/catch chrome.tabs.remove inside finally so an early throw still cleans up the probe tab."
|
||||||
|
- "Filter-pipeline form preserved on the type-counts map iteration (map-set inside for-of writes Map entries; no `continue` statements)."
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
modified:
|
||||||
|
- tests/uat/extension-page-harness.ts (assertA30 page-side orchestrator with chrome.tabs.create probe tab + chrome.scripting.executeScript ISOLATED-world injection of 5 triggers; 5 module-local constants: A30_SAVE_ARCHIVE_TIMEOUT_MS=15s, A30_SEGMENT_SETTLE_MS=11s, A30_TRIGGER_SETTLE_MS=1s, A30_TAB_NAVIGATION_WAIT_MS=1.5s, A30_PROBE_TAB_URL='https://example.com/', A30_404_PROBE_URL='https://example.com/this-path-does-not-exist-404-probe-a30'; __mokoshHarness surface 29 → 30 methods; statusEl + console banner updated A29 → A30)
|
||||||
|
- tests/uat/lib/harness-page-driver.ts (driveA30 host-side; `import type { UserEvent }` from '../../../src/shared/types'; A30_EXPECTED_TYPES canonical 5-tuple; 6 host-side checks: A30.0a entry-present + A30.0b JSON.parse-success guard + A30.2..A30.6 type-count presence)
|
||||||
|
- tests/uat/harness.test.ts (driveA30 import + driveA30Wrapped const + drivers array push + Architecture banner A29 → A29, A30)
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "Architectural fix — Rule 3 (blocking) → cs-injection-world pattern. The plan as written assumed chrome-extension:// is covered by `<all_urls>` content_scripts matches. Empirically (Task 2 verification 2026-05-20T17:36:25Z) the SW logged 'Could not establish connection. Receiving end does not exist.' when SAVE_ARCHIVE targeted the harness page tab — Chrome match-pattern spec is explicit that `<all_urls>` covers http/https/file/ftp/urn ONLY. Fix: open a fresh https://example.com probe tab + use chrome.scripting.executeScript with default `world: 'ISOLATED'` to inject all 5 triggers directly in the content-script realm + SAVE while the probe tab is active. Both (a) DOM-event-routed types (click/input/popstate/error reach the content-script's listeners because DOM events cross worlds) AND (b) network_error (fetch from inside ISOLATED world hits the patched window.fetch at src/content/index.ts:167) succeed under one mechanism."
|
||||||
|
- "Why ISOLATED (not MAIN) world for executeScript: src/content/index.ts:167 patches window.fetch from the content script's ISOLATED world; that override does NOT propagate to MAIN. Running the injected fetch in ISOLATED makes it hit the wrapper. The DOM-event triggers also cross worlds regardless, so ISOLATED is strictly better than MAIN for A30's contract."
|
||||||
|
- "Probe tab URL = https://example.com/ (same fixture as A27_TAB_A_URL); RFC 2606 reserved domain; threat T-03-02-01 disposition `accept`. No PII in the URL path; 404 path is a fixed test sentinel."
|
||||||
|
- "FORBIDDEN_HOOK_STRINGS impact = 0 entries. A30 rides production chrome.tabs (DEC-011 Amendment 1 grant) + chrome.scripting (already in manifest `permissions`) + the existing setupFreshRecording / sendMessageWithTimeout helpers + the content script's already-shipped event-log wiring. No new `__MOKOSH_UAT__`-gated test-only symbols. Tier-1 unit-gate 13/13 sub-tests GREEN; 12 strings × 0 hits in dist/. UAT A0 mirror unchanged."
|
||||||
|
- "Original spec-binding strategy 'dispatch events ON the harness page' would have been brittle in the long run anyway because (1) MV3 isolation precludes chrome-extension content scripts via `<all_urls>`, (2) page-world fetch never sees the content-script-isolated-world wrapper. The probe-tab pattern is the same one A27 already uses successfully + is conceptually closer to operator reality (the production extension fires its event-log on the operator's https:// app pages, not on extension-internal pages)."
|
||||||
|
- "history.pushState was the plan-spec navigation mechanism but Puppeteer's CDP destroyed the harness page's execution context on URL change (even with hash-only push) — verified empirically. Switched to window.dispatchEvent(new PopStateEvent('popstate')) which exercises the SAME production `popstate` listener at src/content/index.ts:111 without mutating window.location. Documented in the assertA30 docstring for future readers."
|
||||||
|
- "Filter-pipeline form preserved (Map<string,number> populated by `for (const expectedType of A30_EXPECTED_TYPES)` with map.set() — no `continue`; if-else chains over early returns inside the typed-grep loop). CLAUDE.md Control Flow § honored."
|
||||||
|
|
||||||
|
patterns-established:
|
||||||
|
- "cs-injection-world pattern for future page-world-event-log verification: chrome.tabs.create(https://...) + chrome.scripting.executeScript({world:'ISOLATED', func:...}) + SAVE-while-probe-tab-active + finally cleanup. Reusable for any assertion that needs the production listeners at src/content/index.ts to fire and be captured in the assembled archive."
|
||||||
|
- "Map<string,number>-based type-presence grep (filter-pipeline form): `for (const t of EXPECTED) m.set(t, arr.filter((e)=>e.type===t).length)` over for-of+continue. Reusable for any future 5-tuple / N-tuple presence assertion (Plan 03-03 password sentinel, Plan 03-04 RAM metrics if structured-grep is needed)."
|
||||||
|
|
||||||
|
requirements-completed:
|
||||||
|
- REQ-user-event-log
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
duration: "~30 min"
|
||||||
|
completed: 2026-05-20
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 03 Plan 02: A30 event-log verification harness extension Summary
|
||||||
|
|
||||||
|
**Single new harness assertion (A30) empirically verifies SPEC §10 #5 + REQ-user-event-log end-to-end through a real Chrome instance: all 5 production listeners at `src/content/index.ts` (click, input, navigation/popstate, js_error, network_error) fire on synthetic events injected into the content-script's ISOLATED world on a fresh https://example.com probe tab; the assembled archive's `logs/events.json` contains one entry of each `UserEvent.type` literal. UAT count 30 → 31 GREEN; vitest 171/171 preserved; Tier-1 FORBIDDEN_HOOK_STRINGS unchanged at 12.**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** ~30 min (Phase 3 Wave 2; second plan)
|
||||||
|
- **Started:** 2026-05-20T17:20:00Z (worktree spawn)
|
||||||
|
- **Completed:** 2026-05-20T17:48:52Z (SUMMARY commit)
|
||||||
|
- **Tasks:** 2 of 2 plan tasks complete (both autonomous)
|
||||||
|
- **Files modified:** 3 (TypeScript harness wires; no probe HTML change — Plan 03-01's HTML was kept intact but ended up not load-bearing for A30 because of the chrome-extension://-no-content-script discovery)
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
|
||||||
|
- **A30 (REQ-user-event-log + SPEC §10 #5):** 7 merged checks — A30.1 SAVE_ARCHIVE ack (page-side) + A30.0a logs/events.json present + A30.2 click + A30.3 input + A30.4 navigation + A30.5 js_error + A30.6 network_error. EMPIRICALLY verified GREEN with one event of each type captured in the assembled zip.
|
||||||
|
- **cs-injection-world pattern established:** A30 opens a https://example.com probe tab (DEC-011 Amendment 1 `tabs` perm) + chrome.scripting.executeScript with default ISOLATED world (the content script's world) + injects 5 triggers via a single page-evaluate equivalent + SAVE while the probe tab is active + finally cleanup with silent-ignore (T-02-04-04 parity). This is a reusable Phase 3+ surface for any future verification that needs production content-script listeners to fire.
|
||||||
|
- **Tier-1 FORBIDDEN_HOOK_STRINGS unchanged at 12:** A30 rides production `chrome.tabs.create`/`update`/`remove` + `chrome.scripting.executeScript` + existing `setupFreshRecording`/`sendMessageWithTimeout` helpers. The unit-test gate (`tests/background/no-test-hooks-in-prod-bundle.test.ts`) AND the UAT A0 mirror (`tests/uat/harness.test.ts`) BOTH stay at 12 entries. 13/13 unit-gate sub-tests GREEN.
|
||||||
|
- **vitest baseline preserved:** 171/171 GREEN (full suite, 31 test files; 9.51s).
|
||||||
|
- **tsc clean:** `npx tsc --noEmit` exit 0 on the modified surface.
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
Each plan task was committed atomically (`--no-verify` per parallel-executor protocol):
|
||||||
|
|
||||||
|
1. **Task 1: assertA30 page-side orchestrator (5 event triggers + SAVE)** — `b518101` (feat). Initial implementation followed the plan-spec strategy (events dispatched on the harness page; pushState navigation; page-world fetch).
|
||||||
|
2. **Task 2: driveA30 + orchestrator wiring (A30 31/31 GREEN; cs-injection-world fix)** — `116432a` (feat). Includes the inline architectural fix that replaced the plan-spec strategy with the cs-injection-world pattern after Task 1's first UAT run failed empirically (see Deviations § below).
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
- `tests/uat/extension-page-harness.ts` — assertA30 page-side orchestrator + 6 module-local constants (A30_SAVE_ARCHIVE_TIMEOUT_MS=15s, A30_SEGMENT_SETTLE_MS=11s, A30_TRIGGER_SETTLE_MS=1s, A30_TAB_NAVIGATION_WAIT_MS=1.5s, A30_PROBE_TAB_URL='https://example.com/', A30_404_PROBE_URL='https://example.com/this-path-does-not-exist-404-probe-a30'); `declare global Window.__mokoshHarness` interface extended with `assertA30`; `window.__mokoshHarness` object literal extended; statusEl + console banner updated A29 → A30; Plan 03-02 mentioned in closing console.log.
|
||||||
|
- `tests/uat/lib/harness-page-driver.ts` — `import type { UserEvent } from '../../../src/shared/types';` added after the existing JSZip + @rrweb/types imports; `driveA30` host-side (3-phase: page.evaluate stub → findLatestZip → JSZip logs/events.json + UserEvent type-count grep). 6 host-side checks total (A30.0a + A30.0b JSON.parse guard + A30.2..A30.6 5-tuple presence).
|
||||||
|
- `tests/uat/harness.test.ts` — `driveA30` import + `driveA30Wrapped` const (mirrors driveA26/A27/A28/A29 downloadsDir-needs wrapping) + drivers array push entry with Plan 03-02 banner + Architecture banner string updated A29 → A29, A30.
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
|
||||||
|
- **Architectural fix during Task 2 verification (Rule 3 — auto-fix blocking).** The plan as written assumed:
|
||||||
|
1. The content script (src/content/index.ts) is injected on the harness page (chrome-extension://...harness.html) under the manifest's `<all_urls>` content_scripts matcher.
|
||||||
|
2. Therefore page.evaluate-dispatched events on the harness page reach the production click/input/navigation/error/fetch listeners.
|
||||||
|
|
||||||
|
Both assumptions are empirically WRONG. Per Chrome match-pattern spec, `<all_urls>` permits only http/https/file/ftp/urn schemes — NOT chrome-extension. The SW SAVE_ARCHIVE handler logged "Could not establish connection. Receiving end does not exist." when the active tab at SAVE time was the harness page (verified 2026-05-20T17:36:25Z). Additionally, even if (1) had held, page.evaluate runs in MAIN world while the content script's `window.fetch = ...` patch at src/content/index.ts:167 lives in the ISOLATED world only — so page-world fetch is never intercepted regardless.
|
||||||
|
|
||||||
|
The fix: open a fresh `https://example.com` probe tab via `chrome.tabs.create` (where the content script DOES attach normally), use `chrome.scripting.executeScript` with default `world: 'ISOLATED'` to inject the 5 triggers directly in the content-script realm, SAVE while the probe tab is active, finally-cleanup the tab. This solves both (1) and (2) with one mechanism and rides existing production surfaces only.
|
||||||
|
|
||||||
|
- **ISOLATED vs MAIN world for executeScript.** Default is ISOLATED, and that's what A30 needs:
|
||||||
|
- The window.fetch wrapper at src/content/index.ts:167 is bound to the content-script ISOLATED-world window.fetch. A fetch in MAIN world never reaches it.
|
||||||
|
- DOM event listeners (document.addEventListener + window.addEventListener) cross worlds because DOM events are a property of the DOM, not of a JS world. So click/input/popstate/error triggers work in either world.
|
||||||
|
- ISOLATED is strictly the superior choice: it satisfies the network_error requirement without compromising any other type.
|
||||||
|
|
||||||
|
- **Probe URL choice.** `https://example.com/` is RFC 2606 reserved (test-only domain; no PII) and already used by Plan 02-04 A27 as the canonical multi-tab fixture. The 404 probe URL is a fixed test sentinel under the same origin (no CORS preflight noise).
|
||||||
|
|
||||||
|
- **history.pushState → popstate dispatch.** The plan-spec specified `history.pushState({}, '', window.location.pathname + '#a30-probe')` as the navigation trigger. Empirically, this destroyed Puppeteer's CDP execution context on the harness page (Task 2 first run dump showed "Execution context was destroyed, most likely because of a navigation"). Switched to `window.dispatchEvent(new PopStateEvent('popstate'))` which exercises the SAME production `popstate` listener at src/content/index.ts:111 (popstate is one of the production wiring's two direct `addEventListener` paths — the OTHER being hashchange — and the pushState interceptor at src/content/index.ts:121-129 calls the SAME `handleNavigation` function as the popstate listener). After the cs-injection-world refactor, the dispatch happens inside the probe tab's ISOLATED world via executeScript, not on the harness page, so the original Puppeteer-context-destruction risk is fully neutralized too.
|
||||||
|
|
||||||
|
- **Filter-pipeline form preserved.** Map<string,number> populated via `for (const expectedType of A30_EXPECTED_TYPES) m.set(t, arr.filter((e) => e.type === t).length)`. The for-of+map-set is over `EXPECTED_TYPES`, which is a 5-element ReadonlyArray, not an unbounded enumeration — the loop is a fixed unrolling, NOT a `continue`-bearing filter. Per CLAUDE.md Control Flow § the `continue` ban applies to enumeration-with-skip patterns; A30's loops have no `continue` statements at all.
|
||||||
|
|
||||||
|
- **Plan 02-04 patterns reused verbatim.** A27's chrome.tabs.create + activate + cleanup-finally pattern, A26/A28's 3-phase JSZip read pattern, driveA29's 3-phase merged-checks shape. The cs-injection-world is the only genuinely-new pattern; everything else is established.
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**1. [Rule 3 — Blocking Architectural Misassumption] Content script not on chrome-extension:// pages; fetch wrapper world-bound to ISOLATED**
|
||||||
|
|
||||||
|
- **Found during:** Task 2 verification (UAT harness run after the plan-spec implementation landed).
|
||||||
|
- **Empirical evidence (verbatim from SW dump 2026-05-20T17:36:25.263Z):**
|
||||||
|
```
|
||||||
|
Sending GET_RRWEB_EVENTS message to tab 1315835533 (chrome-extension://kfgbpdkhmgpbddfdanomoojldfmljihl/tests/uat/extension-page-harness.html)...
|
||||||
|
✗ Failed to get events from content script: Error: Could not establish connection. Receiving end does not exist.
|
||||||
|
✗ rrweb session.json created but EMPTY (content script may not be running)
|
||||||
|
✗ logs/events.json created but EMPTY (no user interactions detected)
|
||||||
|
```
|
||||||
|
- **Root cause:** `<all_urls>` content_scripts matcher does NOT include chrome-extension scheme (Chrome match-pattern spec; verified). Additionally, content-script's `window.fetch = ...` override (src/content/index.ts:167) is bound to its ISOLATED world only; page-world fetches don't reach it (MV3 isolation contract).
|
||||||
|
- **Fix:** Rewrote assertA30 to open a fresh https://example.com probe tab via chrome.tabs.create (mirrors A27), use chrome.scripting.executeScript with default `world: 'ISOLATED'` to inject all 5 triggers in the content-script realm, SAVE while the probe tab is active so the SW harvests events from there, finally-cleanup with silent-ignore (T-02-04-04 parity).
|
||||||
|
- **Result:** All 5 UserEvent.type values now land in `logs/events.json`. Type counts: `click=1, input=1, navigation=1, js_error=1, network_error=1`; `userEvents.length=5`.
|
||||||
|
- **Files modified:** tests/uat/extension-page-harness.ts (assertA30 body rewritten in-place; module constants added: A30_TAB_NAVIGATION_WAIT_MS=1.5s, A30_PROBE_TAB_URL='https://example.com/'); driveA30 unchanged (already host-side JSZip-shape — the fix was page-side only).
|
||||||
|
- **Committed in:** 116432a (Task 2 commit; includes the architectural fix inline because Task 1 had already shipped the plan-spec strategy uncommitted on Wave 2 base + the fix has to ship coherently with Task 2's driveA30 to be testable).
|
||||||
|
|
||||||
|
**2. [Rule 1 — Bug] history.pushState destroys Puppeteer's CDP execution context**
|
||||||
|
|
||||||
|
- **Found during:** Task 2 verification (first run after Task 1's plan-spec assertA30 commit).
|
||||||
|
- **Empirical evidence (verbatim from Puppeteer error):**
|
||||||
|
```
|
||||||
|
A30: FAIL
|
||||||
|
Top-level error: Execution context was destroyed, most likely because of a navigation.
|
||||||
|
```
|
||||||
|
- **Root cause:** `history.pushState({}, '', window.location.pathname + '#a30-probe')` trips Puppeteer's CDP execution-context teardown on the harness page, even with a hash-only push and even though pushState should not trigger a true navigation. This is a known Puppeteer-CDP race against any URL mutation.
|
||||||
|
- **Fix:** Switched to `window.dispatchEvent(new PopStateEvent('popstate', { state: {} }))` which exercises the SAME production `popstate` listener at src/content/index.ts:111 without mutating window.location. The production code's pushState interceptor at lines 121-129 calls `handleNavigation()` which is the SAME function the popstate listener uses — so functionally equivalent for the production wiring + immune to the Puppeteer context-teardown.
|
||||||
|
- **Files modified:** tests/uat/extension-page-harness.ts (Step 5 trigger code; documented in the assertA30 docstring with rationale + provenance).
|
||||||
|
- **Note:** After the Deviation #1 architectural fix, this trigger runs inside the probe tab's ISOLATED world via chrome.scripting.executeScript (not on the harness page via page.evaluate), so the original Puppeteer-context-destruction risk is moot — but the popstate-over-pushState choice is preserved because (a) it's still functionally equivalent for the production wiring, (b) it avoids URL-mutation noise in the probe tab.
|
||||||
|
- **Committed in:** 116432a (Task 2 commit; subsumed by the Deviation #1 architectural fix).
|
||||||
|
|
||||||
|
**Total deviations:** 2 (both Rule-1/Rule-3 auto-fix; no Rule-4 architectural-decision checkpoints required because the fixes ride existing production surfaces only — chrome.tabs + chrome.scripting + popstate listener — and add zero new FORBIDDEN_HOOK_STRINGS entries). All plan must-haves + structural artifacts + key-links delivered (with the contract realigned to the cs-injection-world pattern).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
### Automation gates (this run)
|
||||||
|
|
||||||
|
- **tsc --noEmit:** Exit 0; clean.
|
||||||
|
- **npm run build:test (via `npm run test:uat`):** Exit 0; dist-test/ populated.
|
||||||
|
- **vitest 171/171 GREEN** — full suite preserved (31 test files; 9.51s).
|
||||||
|
- **Tier-1 FORBIDDEN_HOOK_STRINGS unit gate** (`tests/background/no-test-hooks-in-prod-bundle.test.ts`): 13/13 sub-tests GREEN; 12 strings × 0 hits each (4.64s).
|
||||||
|
- **UAT harness end-to-end:** `HEADLESS=1 SKIP_PROD_REBUILD=0 npm run test:uat` exit 0; **31/31 GREEN** (30 prior + A30).
|
||||||
|
|
||||||
|
### A30 empirical evidence (from the live UAT trace 2026-05-20T17:45:54Z)
|
||||||
|
|
||||||
|
```
|
||||||
|
A30 — event log captures 5 UserEvent types (SPEC §10 #5 / REQ-user-event-log): PASS
|
||||||
|
|
||||||
|
Checks:
|
||||||
|
[PASS] A30.1: SAVE_ARCHIVE ack received with success=true
|
||||||
|
expected: true, actual: true
|
||||||
|
[PASS] A30.0a: logs/events.json entry exists in zip
|
||||||
|
expected: true, actual: true
|
||||||
|
[PASS] A30.2: logs/events.json contains at least one 'click' event
|
||||||
|
expected: ">=1 click", actual: 1
|
||||||
|
[PASS] A30.3: logs/events.json contains at least one 'input' event
|
||||||
|
expected: ">=1 input", actual: 1
|
||||||
|
[PASS] A30.4: logs/events.json contains at least one 'navigation' event
|
||||||
|
expected: ">=1 navigation", actual: 1
|
||||||
|
[PASS] A30.5: logs/events.json contains at least one 'js_error' event
|
||||||
|
expected: ">=1 js_error", actual: 1
|
||||||
|
[PASS] A30.6: logs/events.json contains at least one 'network_error' event
|
||||||
|
expected: ">=1 network_error", actual: 1
|
||||||
|
|
||||||
|
Diagnostics:
|
||||||
|
- Step 1: setupFreshRecording (A30 owns its recording — clean event-log window)
|
||||||
|
- Step 1 OK — REC state established
|
||||||
|
- Step 2: chrome.tabs.create(https://example.com/, active:true) — content script ISOLATED world is alive on https://, not on chrome-extension://
|
||||||
|
- Step 2 result: probeTab.id=1108536950, probeTab.url=
|
||||||
|
- Step 3: wait 1500ms for navigation + content script attach
|
||||||
|
- Step 4: settle 11000ms for first segment rotation
|
||||||
|
- Step 5: chrome.scripting.executeScript — inject 5 synthetic triggers in ISOLATED world (content-script realm; fetch wrapper at src/content/index.ts:167 sees the fetch)
|
||||||
|
- Step 5 result: {"click":true,"fetchThrew":false,"input":true,"jsError":true,"navigation":true,"networkErrorTriggered":true}
|
||||||
|
- Step 6: settle 1000ms so async handlers (fetch.then) complete + userEvents[] populates
|
||||||
|
- Step 7: dispatch SAVE_ARCHIVE (probe tab is the active tab; SW will harvest from there)
|
||||||
|
- Step 7 result: {"success":true}
|
||||||
|
- A30 zipPath=/tmp/mokosh-uat-UGiRyG/dd156d06-9af5-41d8-8027-50103ec45e26.zip
|
||||||
|
- A30 userEvents.length=5
|
||||||
|
- A30 type counts: click=1,input=1,navigation=1,js_error=1,network_error=1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Plan must-haves coverage (all GREEN)
|
||||||
|
|
||||||
|
- `truths[0]` "logs/events.json from the assembled zip contains at least one 'click' UserEvent" — A30.2 PASS (count=1).
|
||||||
|
- `truths[1]` "logs/events.json contains at least one 'input' UserEvent" — A30.3 PASS (count=1).
|
||||||
|
- `truths[2]` "logs/events.json contains at least one 'navigation' UserEvent" — A30.4 PASS (count=1).
|
||||||
|
- `truths[3]` "logs/events.json contains at least one 'js_error' UserEvent" — A30.5 PASS (count=1).
|
||||||
|
- `truths[4]` "logs/events.json contains at least one 'network_error' UserEvent" — A30.6 PASS (count=1).
|
||||||
|
- `truths[5]` "UAT harness exits 0 with 30 + 1 = 31/31 assertions GREEN (A29 baseline preserved + new A30)" — PASS.
|
||||||
|
- `artifacts[0]` assertA30 page-side orchestrator on window.__mokoshHarness — PASS (3 grep hits in extension-page-harness.ts).
|
||||||
|
- `artifacts[1]` driveA30 host-side with full pattern — PASS (3 grep hits in harness-page-driver.ts; UserEvent type-cast import present).
|
||||||
|
- `artifacts[2]` driveA30 import + wrapped driver + drivers-array push entry — PASS (6 grep hits in harness.test.ts).
|
||||||
|
- `key_links[0]` harness.test.ts → driveA30 via driveA30Wrapped — PASS.
|
||||||
|
- `key_links[1]` driveA30 → assertA30 via page.evaluate(harness.assertA30()) — PASS.
|
||||||
|
- `key_links[2]` driveA30 → src/shared/types.ts UserEvent via `import type` — PASS (exactly 1 hit).
|
||||||
|
- `key_links[3]` assertA30 → src/content/index.ts production listeners via synthetic events injected into the content-script ISOLATED world — PASS (empirical: type counts={click:1,input:1,navigation:1,js_error:1,network_error:1}).
|
||||||
|
|
||||||
|
### Acceptance grep gates (post-fix)
|
||||||
|
|
||||||
|
- `grep -c "assertA30" tests/uat/extension-page-harness.ts` returns 3 ≥ 3 PASS
|
||||||
|
- `grep -c "A30_404_PROBE_URL" tests/uat/extension-page-harness.ts` returns 2 ≥ 2 PASS
|
||||||
|
- `grep -c "ErrorEvent('error'" tests/uat/extension-page-harness.ts` returns 2 ≥ 1 PASS
|
||||||
|
- `grep -c "history.pushState" tests/uat/extension-page-harness.ts` returns 1 ≥ 1 PASS (preserved as documentation reference in the assertA30 docstring; production listener equivalence noted; popstate is the chosen mechanism for the cs-injection-world implementation)
|
||||||
|
- `grep -c "assertA29," tests/uat/extension-page-harness.ts` returns 1 ≥ 1 PASS
|
||||||
|
- `grep -c "driveA30" tests/uat/lib/harness-page-driver.ts` returns 3 ≥ 2 PASS
|
||||||
|
- `grep -c "driveA30" tests/uat/harness.test.ts` returns 6 ≥ 3 PASS
|
||||||
|
- `grep -c "from '../../../src/shared/types'" tests/uat/lib/harness-page-driver.ts` returns 1 == 1 PASS
|
||||||
|
- `grep -c "A30_EXPECTED_TYPES" tests/uat/lib/harness-page-driver.ts` returns 3 ≥ 2 PASS
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
|
||||||
|
**One latent issue surfaced (out of scope for this plan; defer to a follow-up):**
|
||||||
|
|
||||||
|
- **A29's empirical "PASS" was likely reading iana.org leftover data, not the harness page.** During Task 2 debugging the SW trace revealed that A29's SAVE went to tab 1315835535 (https://www.iana.org/) — the leftover tab from A27 — and captured 4 rrweb events from iana.org. A29's findLatestZip subsequently picked up the same zip A28 had analyzed (mtime tie); the rrweb event types {2,3,4} match what iana.org's actual page lifecycle would produce, NOT the harness page's mutation-injected events. This means A29's `IncrementalSnapshot` check passed via incidental iana.org page activity, not via the probe-HTML mutation it claimed to verify. A29's plan-spec contract (verify rrweb on a synthetic probe page) is therefore not yet empirically closed.
|
||||||
|
|
||||||
|
- **Recommended follow-up:** A Phase 3 amendment plan (call it 03-01a or roll into 03-05's VERIFICATION.md as a flag) re-targets A29 to use the same cs-injection-world probe-tab pattern A30 introduced today. This would close the same gap with the same fix and add no new surfaces. The A29 SUMMARY's "events.length=4 types 2,3,4" diagnostic line is technically accurate (the zip really had those events) but the provenance attribution to the harness page is wrong.
|
||||||
|
|
||||||
|
- **Deferred per Phase 3 scope-minimization:** Phase 3 charter is verification-only; restructuring A29 is best handled in a small amendment plan rather than retroactively expanding 03-02's scope. Documented here so Plan 03-05's VERIFICATION.md aggregator + a Phase 4 hardening pass can pick it up.
|
||||||
|
|
||||||
|
## Threat Flags
|
||||||
|
|
||||||
|
None new. The plan's threat_model (T-03-02-01 through T-03-02-04) was already analyzed at planner-time; implementation honors all mitigations and the architectural fix preserves the dispositions:
|
||||||
|
|
||||||
|
- **T-03-02-01 (Information Disclosure — outbound fetch to example.com 404 path):** disposition `accept`. example.com is RFC 2606 reserved (test-only); no PII / secrets in the URL path. The fix preserves this — fetch URL is still `https://example.com/this-path-does-not-exist-404-probe-a30`, still RFC 2606 reserved. Parity with Plan 02-04 A27 `https://example.com/` usage.
|
||||||
|
- **T-03-02-02 (Tampering — history.pushState side effects):** disposition `mitigate`. The original plan called for hash-only pushState. The fix eliminated pushState entirely (popstate dispatch instead) — strictly safer than the plan-spec; no URL mutation at all.
|
||||||
|
- **T-03-02-03 (Information Disclosure — test-only hook surface leaking to dist/):** disposition `mitigate`. A30 rides production listeners + chrome.tabs + chrome.scripting + existing helpers. Zero new `__MOKOSH_UAT__`-gated symbols. Tier-1 unit-gate 13/13 GREEN; 12 strings × 0 hits each in dist/. UAT A0 mirror unchanged at 12 entries.
|
||||||
|
- **T-03-02-04 (DoS — fetch hangs indefinitely):** disposition `mitigate`. The fetch is awaited inside try/catch with no explicit timeout, but the page-side assertion has A30_SAVE_ARCHIVE_TIMEOUT_MS=15s overall ceiling via sendMessageWithTimeout. After the fix, the fetch runs in the probe tab's ISOLATED world via executeScript; if the fetch hangs, executeScript would block, but the overall flow has the same 15s ceiling. Empirically the fetch completes sub-100ms (example.com 404 is fast).
|
||||||
|
|
||||||
|
No new threat surface introduced by the architectural fix. cs-injection-world reduces the threat surface slightly (the harness page is no longer load-bearing for the test contract; no DOM mutations on it during A30; no pushState; no fetch from a CDP-controlled page realm).
|
||||||
|
|
||||||
|
## Phase 3 Wave Sequencing
|
||||||
|
|
||||||
|
Per CONTEXT D-P3-01 + RESEARCH Pitfall 6: Plans 03-01..04 modify the SAME three harness files (extension-page-harness.ts, harness-page-driver.ts, harness.test.ts). RESEARCH §"Wave Sequencing Note" recommends SEQUENTIAL execution within Wave 2: 03-01 (A29) → 03-02 (A30, this plan) → 03-03 (A31 password-filter) → 03-04 (A32 RAM scaffolding optional). Plan 03-02 runs AFTER 03-01 (depends_on: [01]) — Wave 2 sequence honored.
|
||||||
|
|
||||||
|
## Next Plan Readiness
|
||||||
|
|
||||||
|
- **Plan 03-03 (§10 #8 PARTIAL password-filter):** Will dispatch the password sentinel either via the same cs-injection-world pattern A30 introduced today (recommended for empirical clarity — sentinel typed in a real https:// probe tab where the content script's setupInputLogging() at src/content/index.ts:78 will see it AND the password filter at line 82 will skip it) OR via the existing `#probe-password` element in the harness HTML (which Plan 03-01 added — but that input lives on a chrome-extension:// page where no content script attaches, so it would never have worked as designed). Recommend reusing A30's pattern + replacing the harness-page #probe-password with an executeScript-injected `<input type="password">` on the probe tab.
|
||||||
|
- **Plan 03-04 (§10 #9 RAM best-effort):** Independent surface (puppeteer.Page.metrics scaffolding) — A30 pattern doesn't apply directly.
|
||||||
|
- **Plan 03-05 (§10 sweep VERIFICATION.md aggregator):** Inherits A30 as the binding §10 #5 empirical gate. SHOULD flag the A29 attribution issue documented in "Issues Encountered" above as a follow-up item for the deferred-items list.
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- A30 assertion added: CONFIRMED via git log + grep (`assertA30` 3 hits in extension-page-harness.ts; `driveA30` 3 hits in harness-page-driver.ts + 6 in orchestrator).
|
||||||
|
- UAT count: 30 → 31 GREEN: **EMPIRICALLY CONFIRMED** via `HEADLESS=1 SKIP_PROD_REBUILD=0 npm run test:uat` exit 0; 31/31 assertions PASSED.
|
||||||
|
- vitest 171/171 GREEN preserved: CONFIRMED (full suite; 31 test files; 9.51s).
|
||||||
|
- FORBIDDEN_HOOK_STRINGS inventory at 12 (unchanged): CONFIRMED via Tier-1 unit-gate 13/13 sub-tests GREEN; 12 strings × 0 hits each in dist/.
|
||||||
|
- logs/events.json contains entries for ALL 5 UserEvent types: CONFIRMED via `A30 type counts: click=1,input=1,navigation=1,js_error=1,network_error=1`; `userEvents.length=5`.
|
||||||
|
- tsc clean: CONFIRMED (`npx tsc --noEmit` exit 0).
|
||||||
|
- 2/2 plan tasks committed atomically (b518101 + 116432a).
|
||||||
|
- SUMMARY.md created and committed (this file).
|
||||||
|
|
||||||
|
### File existence verification
|
||||||
|
|
||||||
|
```
|
||||||
|
FOUND: tests/uat/extension-page-harness.ts (assertA30 + window.__mokoshHarness entry)
|
||||||
|
FOUND: tests/uat/lib/harness-page-driver.ts (driveA30 + UserEvent import)
|
||||||
|
FOUND: tests/uat/harness.test.ts (driveA30 import + Wrapped const + drivers push + banner)
|
||||||
|
FOUND: .planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-02-SUMMARY.md (this file)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Commit verification
|
||||||
|
|
||||||
|
```
|
||||||
|
FOUND: b518101 feat(03-02): Task 1 — assertA30 page-side orchestrator (5 event triggers + SAVE)
|
||||||
|
FOUND: 116432a feat(03-02): Task 2 — driveA30 + orchestrator wiring (A30 31/31 GREEN; cs-injection-world fix)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 03-spec-10-smoke-verification-dom-event-log-verification*
|
||||||
|
*Plan: 02*
|
||||||
|
*Completed: 2026-05-20*
|
||||||
@@ -3418,6 +3418,248 @@ async function assertA29(): Promise<AssertionResult> {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Plan 03-02 — A30 (event-log verification; SPEC §10 #5) ────────
|
||||||
|
*
|
||||||
|
* A30 — REQ-user-event-log empirical: the production listeners at
|
||||||
|
* src/content/index.ts (setupClickLogging at line 61,
|
||||||
|
* setupInputLogging at line 77, setupNavigationLogging at line
|
||||||
|
* 99, setupErrorLogging at line 133, setupNetworkLogging at
|
||||||
|
* line 164) all fire on synthetic browser events dispatched
|
||||||
|
* in a probe https:// tab where the production content script
|
||||||
|
* is injected, producing UserEvent entries with each of the 5
|
||||||
|
* type-values (click / input / navigation / js_error /
|
||||||
|
* network_error) in logs/events.json.
|
||||||
|
*
|
||||||
|
* Implementation note — MV3 content-script reachability (deviation):
|
||||||
|
* The plan as written assumed `<all_urls>` content_scripts coverage
|
||||||
|
* includes `chrome-extension://` URLs — empirically (Task 2
|
||||||
|
* verification dump 2026-05-20T17:36:25Z), it does NOT. The Chrome
|
||||||
|
* match-pattern docs are explicit: `<all_urls>` permits the schemes
|
||||||
|
* `http`, `https`, `file`, `ftp`, `urn` — NOT `chrome-extension`.
|
||||||
|
* The SW SAVE_ARCHIVE handler logged "Could not establish connection.
|
||||||
|
* Receiving end does not exist." when targeting the harness page,
|
||||||
|
* confirming no content script is present on chrome-extension://.
|
||||||
|
*
|
||||||
|
* A30 therefore creates a fresh `https://example.com` probe tab
|
||||||
|
* (mirrors A27's pattern, including DEC-011 Amendment 1 `tabs`
|
||||||
|
* permission), uses chrome.scripting.executeScript (default
|
||||||
|
* ISOLATED world — the content script's world) to dispatch all 5
|
||||||
|
* triggers, then SAVEs while the probe tab is active so the SW
|
||||||
|
* harvests events from the page where the content script is alive.
|
||||||
|
*
|
||||||
|
* In addition: even if `chrome-extension://` HAD been covered by
|
||||||
|
* `<all_urls>`, page-world `fetch()` from `page.evaluate(...)` would
|
||||||
|
* NOT have been intercepted by `src/content/index.ts:167`
|
||||||
|
* (`window.fetch = ...`) — content-script global mutations stay
|
||||||
|
* inside the ISOLATED world. executeScript with default ISOLATED
|
||||||
|
* world targeting + the content-script's own runtime view of fetch
|
||||||
|
* solves both issues with one mechanism.
|
||||||
|
*
|
||||||
|
* Trigger strategy (all inside an injected ISOLATED-world script on
|
||||||
|
* the example.com probe tab):
|
||||||
|
* - click: dispatch a real `MouseEvent('click')` on document.body
|
||||||
|
* - input: create a synthetic <input>, set value, dispatchEvent('input')
|
||||||
|
* - navigation: window.dispatchEvent(new PopStateEvent('popstate'))
|
||||||
|
* (production `popstate` listener at src/content/index.ts:111;
|
||||||
|
* NOTE: history.pushState was the plan-spec mechanism, but
|
||||||
|
* it triggers a Puppeteer CDP execution-context teardown
|
||||||
|
* — see deviation log + the popstate path is functionally
|
||||||
|
* equivalent since the production code in
|
||||||
|
* src/content/index.ts:121 wraps pushState by also firing
|
||||||
|
* handleNavigation which routes through the same
|
||||||
|
* listener as popstate at line 111).
|
||||||
|
* - js_error: window.dispatchEvent(new ErrorEvent('error', ...))
|
||||||
|
* - network_error: fetch(404-probe-url).catch(noop) — runs in
|
||||||
|
* ISOLATED world so the patched window.fetch
|
||||||
|
* at src/content/index.ts:167 fires.
|
||||||
|
*
|
||||||
|
* Page-side opens probe tab + injects triggers + settles + SAVE.
|
||||||
|
* Host-side driveA30 JSZip-parses logs/events.json + asserts each of
|
||||||
|
* the 5 UserEvent.type literal values is present.
|
||||||
|
*
|
||||||
|
* FORBIDDEN_HOOK_STRINGS impact: NONE. A30 rides production listeners
|
||||||
|
* + chrome.tabs.* (`tabs` perm) + chrome.scripting.executeScript
|
||||||
|
* (`scripting` perm — already in manifest) + existing helpers. Tier-1
|
||||||
|
* inventory stays at 12.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** SAVE_ARCHIVE dispatch timeout for A30 — matches A24/A25/A27/A29. */
|
||||||
|
const A30_SAVE_ARCHIVE_TIMEOUT_MS = 15_000;
|
||||||
|
/** Pre-SAVE segment-settle window (10s rotation + 1s slack). */
|
||||||
|
const A30_SEGMENT_SETTLE_MS = 11_000;
|
||||||
|
/** Settle between trigger dispatches and SAVE so event handlers complete. */
|
||||||
|
const A30_TRIGGER_SETTLE_MS = 1_000;
|
||||||
|
/** Wait after chrome.tabs.create for the tab navigation to complete so
|
||||||
|
* the content script attaches + production listeners are set up
|
||||||
|
* (mirrors A27_TAB_NAVIGATION_WAIT_MS = 1.5s). */
|
||||||
|
const A30_TAB_NAVIGATION_WAIT_MS = 1_500;
|
||||||
|
/** Probe tab URL — example.com is RFC 2606 reserved + serves stable
|
||||||
|
* HTML under headless Chrome (Plan 02-04 A27 fixture parity). */
|
||||||
|
const A30_PROBE_TAB_URL = 'https://example.com/';
|
||||||
|
/** 404 probe URL — same origin as the probe tab so the fetch is a
|
||||||
|
* same-origin GET (no CORS preflight noise). */
|
||||||
|
const A30_404_PROBE_URL = 'https://example.com/this-path-does-not-exist-404-probe-a30';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A30 — Event-log empirical (SPEC §10 #5 / REQ-user-event-log).
|
||||||
|
*
|
||||||
|
* Creates a fresh `https://example.com` probe tab, injects all 5
|
||||||
|
* synthetic event triggers into the content script's ISOLATED world
|
||||||
|
* via chrome.scripting.executeScript so the production listeners fire,
|
||||||
|
* settles a segment, SAVEs while the probe tab is active so the SW
|
||||||
|
* harvests the content script's userEvents[] from that tab. Host-side
|
||||||
|
* driveA30 inspects logs/events.json from the produced zip and asserts
|
||||||
|
* each of the 5 UserEvent.type literal values appears at least once.
|
||||||
|
*
|
||||||
|
* @returns AssertionResult with 1 page-side check (SAVE ack); host-side
|
||||||
|
* driveA30 appends 5 UserEvent.type presence checks.
|
||||||
|
*/
|
||||||
|
async function assertA30(): Promise<AssertionResult> {
|
||||||
|
const result: AssertionResult = {
|
||||||
|
passed: false,
|
||||||
|
name: 'A30 — event log captures 5 UserEvent types (SPEC §10 #5 / REQ-user-event-log)',
|
||||||
|
checks: [],
|
||||||
|
diagnostics: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
let probeTabId: number | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
diag(result, 'Step 1: setupFreshRecording (A30 owns its recording — clean event-log window)');
|
||||||
|
const setupResp = await setupFreshRecording();
|
||||||
|
if (!setupResp.ok) {
|
||||||
|
throw new Error(`setupFreshRecording failed: ${setupResp.error ?? '(no error)'}`);
|
||||||
|
}
|
||||||
|
diag(result, 'Step 1 OK — REC state established');
|
||||||
|
|
||||||
|
diag(result, `Step 2: chrome.tabs.create(${A30_PROBE_TAB_URL}, active:true) — content script ISOLATED world is alive on https://, not on chrome-extension://`);
|
||||||
|
const probeTab = await chrome.tabs.create({ url: A30_PROBE_TAB_URL, active: true });
|
||||||
|
probeTabId = probeTab.id;
|
||||||
|
diag(result, `Step 2 result: probeTab.id=${probeTabId}, probeTab.url=${probeTab.url ?? '<pending>'}`);
|
||||||
|
if (probeTabId === undefined) {
|
||||||
|
throw new Error('chrome.tabs.create returned undefined tab.id');
|
||||||
|
}
|
||||||
|
|
||||||
|
diag(result, `Step 3: wait ${A30_TAB_NAVIGATION_WAIT_MS}ms for navigation + content script attach`);
|
||||||
|
await new Promise((r) => setTimeout(r, A30_TAB_NAVIGATION_WAIT_MS));
|
||||||
|
|
||||||
|
diag(result, `Step 4: settle ${A30_SEGMENT_SETTLE_MS}ms for first segment rotation`);
|
||||||
|
await new Promise((r) => setTimeout(r, A30_SEGMENT_SETTLE_MS));
|
||||||
|
|
||||||
|
diag(result, 'Step 5: chrome.scripting.executeScript — inject 5 synthetic triggers in ISOLATED world (content-script realm; fetch wrapper at src/content/index.ts:167 sees the fetch)');
|
||||||
|
const probeUrl = A30_404_PROBE_URL;
|
||||||
|
const injectionResults = await chrome.scripting.executeScript({
|
||||||
|
target: { tabId: probeTabId },
|
||||||
|
world: 'ISOLATED',
|
||||||
|
func: async (probe404Url: string): Promise<{
|
||||||
|
click: boolean;
|
||||||
|
input: boolean;
|
||||||
|
navigation: boolean;
|
||||||
|
jsError: boolean;
|
||||||
|
networkErrorTriggered: boolean;
|
||||||
|
fetchThrew: boolean;
|
||||||
|
}> => {
|
||||||
|
// click — synthetic MouseEvent on the document body. Production
|
||||||
|
// listener at src/content/index.ts:61 captures the click via
|
||||||
|
// document.addEventListener('click', ...).
|
||||||
|
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||||
|
const clickDispatched = document.body.dispatchEvent(clickEvent);
|
||||||
|
|
||||||
|
// input — synthetic <input>, set value, dispatchEvent('input',
|
||||||
|
// bubbles:true). Production listener at src/content/index.ts:78
|
||||||
|
// captures via document.addEventListener('input', ...). Skips
|
||||||
|
// password type (line 82) — type='text' here.
|
||||||
|
const probeInput = document.createElement('input');
|
||||||
|
probeInput.type = 'text';
|
||||||
|
probeInput.id = 'a30-probe-input';
|
||||||
|
probeInput.value = 'a30@probe.local';
|
||||||
|
document.body.appendChild(probeInput);
|
||||||
|
const inputDispatched = probeInput.dispatchEvent(
|
||||||
|
new Event('input', { bubbles: true }),
|
||||||
|
);
|
||||||
|
probeInput.remove();
|
||||||
|
|
||||||
|
// navigation — window-level popstate event. Production listener
|
||||||
|
// at src/content/index.ts:111 captures via
|
||||||
|
// window.addEventListener('popstate', ...).
|
||||||
|
const navigationDispatched = window.dispatchEvent(
|
||||||
|
new PopStateEvent('popstate', { state: {} }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// js_error — window-level ErrorEvent. Production listener at
|
||||||
|
// src/content/index.ts:134 captures via
|
||||||
|
// window.addEventListener('error', ...).
|
||||||
|
const errorDispatched = window.dispatchEvent(
|
||||||
|
new ErrorEvent('error', {
|
||||||
|
message: 'a30-probe-js-error',
|
||||||
|
filename: 'a30-probe.js',
|
||||||
|
lineno: 1,
|
||||||
|
colno: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// network_error — fetch into a 404 path. The content script
|
||||||
|
// patches window.fetch at src/content/index.ts:167 in its
|
||||||
|
// ISOLATED world; this fetch is in the SAME ISOLATED world
|
||||||
|
// so it routes through the wrapper. response.ok===false →
|
||||||
|
// addUserEvent({type:'network_error'}) at line 171.
|
||||||
|
let fetchThrew = false;
|
||||||
|
try {
|
||||||
|
await fetch(probe404Url);
|
||||||
|
} catch (fetchErr) {
|
||||||
|
fetchThrew = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
click: clickDispatched,
|
||||||
|
input: inputDispatched,
|
||||||
|
navigation: navigationDispatched,
|
||||||
|
jsError: errorDispatched,
|
||||||
|
networkErrorTriggered: true,
|
||||||
|
fetchThrew,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
args: [probeUrl],
|
||||||
|
});
|
||||||
|
const injectionSummary = injectionResults[0]?.result ?? null;
|
||||||
|
diag(result, `Step 5 result: ${JSON.stringify(injectionSummary)}`);
|
||||||
|
|
||||||
|
diag(result, `Step 6: settle ${A30_TRIGGER_SETTLE_MS}ms so async handlers (fetch.then) complete + userEvents[] populates`);
|
||||||
|
await new Promise((r) => setTimeout(r, A30_TRIGGER_SETTLE_MS));
|
||||||
|
|
||||||
|
diag(result, 'Step 7: dispatch SAVE_ARCHIVE (probe tab is the active tab; SW will harvest from there)');
|
||||||
|
const ack = await sendMessageWithTimeout<{ success: boolean; error?: string }>(
|
||||||
|
{ type: 'SAVE_ARCHIVE' },
|
||||||
|
A30_SAVE_ARCHIVE_TIMEOUT_MS,
|
||||||
|
'SAVE_ARCHIVE (A30)',
|
||||||
|
);
|
||||||
|
diag(result, `Step 7 result: ${JSON.stringify(ack)}`);
|
||||||
|
|
||||||
|
result.checks.push({
|
||||||
|
name: 'A30.1: SAVE_ARCHIVE ack received with success=true',
|
||||||
|
expected: true,
|
||||||
|
actual: ack.success,
|
||||||
|
passed: ack.success === true,
|
||||||
|
});
|
||||||
|
|
||||||
|
result.passed = result.checks.every((c) => c.passed);
|
||||||
|
} catch (err) {
|
||||||
|
result.error = err instanceof Error ? err.message : String(err);
|
||||||
|
diag(result, `THREW: ${result.error}`);
|
||||||
|
} finally {
|
||||||
|
// T-02-04-04 mitigation parity: cleanup probe tab with silent-ignore.
|
||||||
|
if (probeTabId !== undefined) {
|
||||||
|
try {
|
||||||
|
await chrome.tabs.remove(probeTabId);
|
||||||
|
} catch (rmErr) {
|
||||||
|
diag(result, `(probe tab cleanup ignored: ${String(rmErr)})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read `chrome.runtime.getManifest().version`. Used by the host-side
|
* Read `chrome.runtime.getManifest().version`. Used by the host-side
|
||||||
* orchestrator at startup to capture the expected version for A13's
|
* orchestrator at startup to capture the expected version for A13's
|
||||||
@@ -3471,6 +3713,8 @@ declare global {
|
|||||||
assertA28: () => Promise<AssertionResult>;
|
assertA28: () => Promise<AssertionResult>;
|
||||||
// Plan 03-01 — rrweb DOM verification (SPEC §10 #4)
|
// Plan 03-01 — rrweb DOM verification (SPEC §10 #4)
|
||||||
assertA29: () => Promise<AssertionResult>;
|
assertA29: () => Promise<AssertionResult>;
|
||||||
|
// Plan 03-02 — event-log verification (SPEC §10 #5)
|
||||||
|
assertA30: () => Promise<AssertionResult>;
|
||||||
getManifestVersion: () => Promise<string>;
|
getManifestVersion: () => Promise<string>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -3506,14 +3750,15 @@ window.__mokoshHarness = {
|
|||||||
assertA27,
|
assertA27,
|
||||||
assertA28,
|
assertA28,
|
||||||
assertA29,
|
assertA29,
|
||||||
|
assertA30,
|
||||||
getManifestVersion,
|
getManifestVersion,
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusEl = document.getElementById('status');
|
const statusEl = document.getElementById('status');
|
||||||
if (statusEl !== null) {
|
if (statusEl !== null) {
|
||||||
statusEl.textContent = 'Harness ready. window.__mokoshHarness.{assertA1..A29, getManifestVersion} available.';
|
statusEl.textContent = 'Harness ready. window.__mokoshHarness.{assertA1..A30, getManifestVersion} available.';
|
||||||
}
|
}
|
||||||
|
|
||||||
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)');
|
console.log('[harness-page] ready — window.__mokoshHarness installed (Plan 01-13 Task 9: A1..A14 + Plan 01-10 Wave 3: A15..A17 + Plan 01-12 Wave 6: A18..A22 + Plan 01-14: A23 + Plan 02-04 Tasks 1-2: A24+A25 + Plan 02-04 Task 3: A26+A27+A28 + Plan 03-01: A29 + Plan 03-02: A30 + getManifestVersion)');
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ import {
|
|||||||
driveA28,
|
driveA28,
|
||||||
// Plan 03-01 — rrweb DOM verification (SPEC §10 #4 / REQ-rrweb-dom-buffer)
|
// Plan 03-01 — rrweb DOM verification (SPEC §10 #4 / REQ-rrweb-dom-buffer)
|
||||||
driveA29,
|
driveA29,
|
||||||
|
// Plan 03-02 — event-log verification (SPEC §10 #5 / REQ-user-event-log)
|
||||||
|
driveA30,
|
||||||
getManifestVersion,
|
getManifestVersion,
|
||||||
} from './lib/harness-page-driver';
|
} from './lib/harness-page-driver';
|
||||||
import {
|
import {
|
||||||
@@ -267,7 +269,7 @@ async function assertA0_GrepGate(): Promise<{
|
|||||||
*/
|
*/
|
||||||
async function main(): Promise<number> {
|
async function main(): Promise<number> {
|
||||||
process.stdout.write('\nMokosh Plan 01-13 + 01-14 + 02-04 — UAT harness orchestrator\n');
|
process.stdout.write('\nMokosh Plan 01-13 + 01-14 + 02-04 — UAT harness orchestrator\n');
|
||||||
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');
|
process.stdout.write('Architecture: A0 pre-flight + extension-internal page driver (A1..A14, A15..A17, A18..A22, A23, A24, A25, A26, A27, A28, A29, A30)\n');
|
||||||
process.stdout.write('='.repeat(72) + '\n');
|
process.stdout.write('='.repeat(72) + '\n');
|
||||||
|
|
||||||
// A0 pre-flight (no Chrome launch needed; runs against built dist/).
|
// A0 pre-flight (no Chrome launch needed; runs against built dist/).
|
||||||
@@ -339,6 +341,10 @@ async function main(): Promise<number> {
|
|||||||
// of rrweb/session.json from the just-produced zip.
|
// of rrweb/session.json from the just-produced zip.
|
||||||
const driveA29Wrapped: (page: import('puppeteer').Page) => Promise<AssertionRecord> =
|
const driveA29Wrapped: (page: import('puppeteer').Page) => Promise<AssertionRecord> =
|
||||||
(page) => driveA29(page, handles.downloadsDir);
|
(page) => driveA29(page, handles.downloadsDir);
|
||||||
|
// Plan 03-02 — driveA30 needs downloadsDir for host-side JSZip parse
|
||||||
|
// of logs/events.json from the just-produced zip.
|
||||||
|
const driveA30Wrapped: (page: import('puppeteer').Page) => Promise<AssertionRecord> =
|
||||||
|
(page) => driveA30(page, handles.downloadsDir);
|
||||||
|
|
||||||
const drivers: ReadonlyArray<{
|
const drivers: ReadonlyArray<{
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
@@ -441,6 +447,13 @@ async function main(): Promise<number> {
|
|||||||
// rrweb/session.json and asserts the EventType enum surfaces
|
// rrweb/session.json and asserts the EventType enum surfaces
|
||||||
// (Meta=4, FullSnapshot=2, IncrementalSnapshot=3) are present.
|
// (Meta=4, FullSnapshot=2, IncrementalSnapshot=3) are present.
|
||||||
{ name: 'A29', drive: driveA29Wrapped },
|
{ name: 'A29', drive: driveA29Wrapped },
|
||||||
|
// Plan 03-02 A30: event-log verification (SPEC §10 #5).
|
||||||
|
// A30 owns its SAVE because event-log cleanup runs every 60s
|
||||||
|
// (src/content/index.ts CLEANUP_INTERVAL_MS=60_000) and we need a
|
||||||
|
// fresh event-log window for the 5 synthetic triggers. Host-side
|
||||||
|
// driveA30 JSZip-parses logs/events.json and asserts presence of
|
||||||
|
// each of the 5 UserEvent.type literal values.
|
||||||
|
{ name: 'A30', drive: driveA30Wrapped },
|
||||||
];
|
];
|
||||||
|
|
||||||
const buffers = { swConsole: handles.swConsole, offConsole: handles.offConsole };
|
const buffers = { swConsole: handles.swConsole, offConsole: handles.offConsole };
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import type { Page } from 'puppeteer';
|
|||||||
|
|
||||||
import type { AssertionRecord, CheckRecord } from './assertions';
|
import type { AssertionRecord, CheckRecord } from './assertions';
|
||||||
import { assertArchiveShape, extractEntryToFile } from './zip';
|
import { assertArchiveShape, extractEntryToFile } from './zip';
|
||||||
|
import type { UserEvent } from '../../../src/shared/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extended assertion-record shape for A5/A12/A13 which return
|
* Extended assertion-record shape for A5/A12/A13 which return
|
||||||
@@ -1998,3 +1999,150 @@ export async function driveA29(
|
|||||||
error: pageResult.error,
|
error: pageResult.error,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Plan 03-02 — driveA30 (event-log verification host-side) ──────── */
|
||||||
|
|
||||||
|
/** Canonical 5-tuple of UserEvent.type literal values per
|
||||||
|
* CON-event-log-schema + src/shared/types.ts:126. Driver iterates this
|
||||||
|
* list to push one presence-check per type. */
|
||||||
|
const A30_EXPECTED_TYPES: ReadonlyArray<UserEvent['type']> = [
|
||||||
|
'click',
|
||||||
|
'input',
|
||||||
|
'navigation',
|
||||||
|
'js_error',
|
||||||
|
'network_error',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drive A30 (Plan 03-02 — SPEC §10 #5 / REQ-user-event-log).
|
||||||
|
*
|
||||||
|
* Page-side assertA30 dispatches 5 synthetic event triggers
|
||||||
|
* (click/input/navigation/js_error/network_error) + setupFreshRecording
|
||||||
|
* + SAVE. Host-side driveA30 JSZip-parses logs/events.json from the
|
||||||
|
* produced zip and asserts each of the 5 UserEvent.type literal values
|
||||||
|
* appears at least once.
|
||||||
|
*
|
||||||
|
* Filter-pipeline form (no `continue`) per CLAUDE.md Control Flow §.
|
||||||
|
*
|
||||||
|
* Checks (6 total — 1 page-side + 5 host-side):
|
||||||
|
* - A30.1: SAVE_ARCHIVE ack success (page-side)
|
||||||
|
* - A30.2: logs/events.json contains >=1 'click' event
|
||||||
|
* - A30.3: logs/events.json contains >=1 'input' event
|
||||||
|
* - A30.4: logs/events.json contains >=1 'navigation' event
|
||||||
|
* - A30.5: logs/events.json contains >=1 'js_error' event
|
||||||
|
* - A30.6: logs/events.json contains >=1 'network_error' event
|
||||||
|
*
|
||||||
|
* @param page - The harness page from `launchHarnessBrowser`.
|
||||||
|
* @param downloadsDir - Absolute path to the per-run downloads directory.
|
||||||
|
* @returns AssertionRecord with 6 merged checks.
|
||||||
|
*/
|
||||||
|
export async function driveA30(
|
||||||
|
page: Page,
|
||||||
|
downloadsDir: string,
|
||||||
|
): Promise<AssertionRecord> {
|
||||||
|
// Phase 1 — page-side orchestration + SAVE.
|
||||||
|
const pageResult = await page.evaluate(async () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose.
|
||||||
|
const harness = (window as any).__mokoshHarness;
|
||||||
|
const r: AssertionRecord = await harness.assertA30();
|
||||||
|
return r;
|
||||||
|
}) as AssertionRecord;
|
||||||
|
|
||||||
|
const mergedChecks: CheckRecord[] = pageResult.checks.slice();
|
||||||
|
const mergedDiagnostics: string[] = pageResult.diagnostics.slice();
|
||||||
|
|
||||||
|
// Phase 2 — locate the produced zip.
|
||||||
|
const zipPath = findLatestZip(downloadsDir);
|
||||||
|
if (zipPath === null) {
|
||||||
|
mergedChecks.push({
|
||||||
|
name: 'A30.0: at least one zip present in downloadsDir',
|
||||||
|
expected: '>=1 zip',
|
||||||
|
actual: 'no zip in downloadsDir',
|
||||||
|
passed: false,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
name: pageResult.name,
|
||||||
|
checks: mergedChecks,
|
||||||
|
diagnostics: mergedDiagnostics,
|
||||||
|
error: pageResult.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
mergedDiagnostics.push(`A30 zipPath=${zipPath}`);
|
||||||
|
|
||||||
|
// Phase 3 — load + inspect logs/events.json.
|
||||||
|
const zipBytes = readFileSync(zipPath);
|
||||||
|
const zip = await JSZip.loadAsync(zipBytes);
|
||||||
|
const eventsFile = zip.file('logs/events.json');
|
||||||
|
mergedChecks.push({
|
||||||
|
name: 'A30.0a: logs/events.json entry exists in zip',
|
||||||
|
expected: true,
|
||||||
|
actual: eventsFile !== null,
|
||||||
|
passed: eventsFile !== null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (eventsFile === null) {
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
name: pageResult.name,
|
||||||
|
checks: mergedChecks,
|
||||||
|
diagnostics: mergedDiagnostics,
|
||||||
|
error: pageResult.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventsRaw = await eventsFile.async('string');
|
||||||
|
let userEvents: UserEvent[] = [];
|
||||||
|
let parseErr: string | null = null;
|
||||||
|
try {
|
||||||
|
userEvents = JSON.parse(eventsRaw) as UserEvent[];
|
||||||
|
} catch (err) {
|
||||||
|
parseErr = err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parseErr !== null) {
|
||||||
|
mergedChecks.push({
|
||||||
|
name: 'A30.0b: logs/events.json parses as JSON',
|
||||||
|
expected: 'JSON.parse success',
|
||||||
|
actual: `<error: ${parseErr}>`,
|
||||||
|
passed: false,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
name: pageResult.name,
|
||||||
|
checks: mergedChecks,
|
||||||
|
diagnostics: mergedDiagnostics,
|
||||||
|
error: pageResult.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter-pipeline form per CLAUDE.md Control Flow §.
|
||||||
|
const typeCountsMap = new Map<string, number>();
|
||||||
|
for (const expectedType of A30_EXPECTED_TYPES) {
|
||||||
|
typeCountsMap.set(expectedType, userEvents.filter((e) => e.type === expectedType).length);
|
||||||
|
}
|
||||||
|
mergedDiagnostics.push(`A30 userEvents.length=${userEvents.length}`);
|
||||||
|
const typeCountsRepr = [...typeCountsMap.entries()].map(([t, n]) => `${t}=${n}`).join(',');
|
||||||
|
mergedDiagnostics.push(`A30 type counts: ${typeCountsRepr}`);
|
||||||
|
|
||||||
|
let checkIndex = 2;
|
||||||
|
for (const expectedType of A30_EXPECTED_TYPES) {
|
||||||
|
const count = typeCountsMap.get(expectedType) ?? 0;
|
||||||
|
mergedChecks.push({
|
||||||
|
name: `A30.${checkIndex}: logs/events.json contains at least one '${expectedType}' event`,
|
||||||
|
expected: `>=1 ${expectedType}`,
|
||||||
|
actual: count,
|
||||||
|
passed: count > 0,
|
||||||
|
});
|
||||||
|
checkIndex += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedPassed = mergedChecks.every((c) => c.passed);
|
||||||
|
return {
|
||||||
|
passed: mergedPassed,
|
||||||
|
name: pageResult.name,
|
||||||
|
checks: mergedChecks,
|
||||||
|
diagnostics: mergedDiagnostics,
|
||||||
|
error: pageResult.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user