- BLOCKER B1: add `tabs` to manifest.json permissions (DEC-011 Amendment 1 cites Phase 2 D-P2-02 meta.urls feature as justification). Honors D-P2-02 "all tabs visible" wording verbatim. Updates manifest-i18n test expected permission list lockstep. - F1: add A28 harness assertion for REQ-archive-layout strict zip-layout verification (5 entries, no extras). - F2: createArchive empty-tracker fallback removed; logs warn + sets urls:[] instead of fake [extension-origin URL]. 02-01 RED test pins empty-tracker → urls:[]. - F3: 02-02 Task 3 prose deliberation struck; typed `blob-url-mint-failed` throw is the resolved-only contract. - F4: 02-02 Task 3 verify block adds full-suite `npm test` after focused test runs. - A27 strict-mode (Plan 02-04): REQUIRES both URLs in meta.urls; FAILS on length < 2. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
918 lines
48 KiB
Markdown
918 lines
48 KiB
Markdown
---
|
||
phase: 02-stabilize-export-pipeline
|
||
plan: 04
|
||
type: auto
|
||
wave: 3
|
||
depends_on: [02, 03]
|
||
files_modified:
|
||
- tests/uat/extension-page-harness.ts
|
||
- tests/uat/lib/harness-page-driver.ts
|
||
- tests/uat/harness.test.ts
|
||
- tests/background/no-test-hooks-in-prod-bundle.test.ts
|
||
autonomous: false
|
||
requirements:
|
||
- REQ-archive-export-latency
|
||
- REQ-meta-json-schema
|
||
- REQ-popup-ui
|
||
- REQ-screenshot-on-export
|
||
- REQ-archive-layout
|
||
tags:
|
||
- uat-harness
|
||
- a24
|
||
- a25
|
||
- a26
|
||
- a27
|
||
- a28
|
||
- a27-strict-mode
|
||
- dec-011-amendment-1
|
||
- blob-url-empirical
|
||
- latency-5s
|
||
- meta-urls-shape
|
||
- archive-layout-strict
|
||
- operator-checkpoint
|
||
- phase-2-closure
|
||
- approach-b
|
||
must_haves:
|
||
truths:
|
||
- "UAT harness extends with A24+ assertions covering the D-P2-01 + D-P2-02 + D-P2-03 contracts empirically (page-side, no SW-side hooks per Approach B)."
|
||
- "A24 verifies the SAVE_ARCHIVE → chrome.downloads.create call site receives a `blob:` URL prefix (NOT `data:application/zip;base64,`)."
|
||
- "A25 verifies the SAVE_ARCHIVE → zip-on-disk latency (<5000ms from chrome.runtime.sendMessage({type:'SAVE_ARCHIVE'}) dispatch to file appearing in downloadsDir) per REQ-archive-export-latency."
|
||
- "A26 verifies the produced meta.json has the 8-field D-P2-02/D-P2-03 shape: schemaVersion='2', urls is non-empty string[], no `url` key present."
|
||
- "A27 STRICT mode (post DEC-011 Amendment 1, 2026-05-20): harness opens TWO tabs in sequence via chrome.tabs.create + activates each via chrome.tabs.update; after SAVE_ARCHIVE, meta.urls is EXACTLY `[url1, url2]` (order-flexible; ≥2 length REQUIRED; NO `[object Object]`, NO extension-origin sentinel, NO chrome:// URLs). Test FAILS on length < 2 OR on any missing URL string."
|
||
- "Tier-1 FORBIDDEN_HOOK_STRINGS inventory extended IF new hook surfaces required for A24+; lockstep across tests/background/no-test-hooks-in-prod-bundle.test.ts AND tests/uat/harness.test.ts A0 mirror."
|
||
- "Final operator empirical checkpoint validates: (a) saving a real ~6MB archive completes successfully (was: data:URL Network error pre-Plan-02-02); (b) the produced meta.json has the new 8-field shape (operator opens the zip with archive-manager tool); (c) the saved zip opens cleanly in the OS file manager."
|
||
- "Pre-checkpoint bundle gates per saved memory `feedback-pre-checkpoint-bundle-gates.md` are run BEFORE operator checkpoint: SW CSP grep (no `new Function`/`eval`), SW Node-globals grep (no `Buffer.from`), DOM-globals grep, SW-bundle-import gate, manifest validation."
|
||
- "A28 verifies REQ-archive-layout: the downloaded zip contains EXACTLY 5 entries — `video/last_30sec.webm`, `rrweb/session.json`, `logs/events.json`, `screenshot.png`, `meta.json` (no extras, no missing). Uses JSZip parsing in the harness driver against the host-side zip blob. Cross-references REQ-archive-layout + REQ-popup-ui (popup-triggered SAVE) + REQ-screenshot-on-export."
|
||
- "Phase 2 closes with 4/4 plans landed; 24/24 UAT baseline preserved or extended to 29/29 (A24+A25+A26+A27+A28 inclusive); vitest baseline preserved or extended; operator empirical ack documented."
|
||
artifacts:
|
||
- path: "tests/uat/extension-page-harness.ts"
|
||
provides: "assertA24 (blob: URL prefix) + assertA25 (5s latency) + assertA26 (meta.urls shape) + assertA27 (multi-tab URL dedup, STRICT) + assertA28 (REQ-archive-layout strict zip-layout)"
|
||
contains: "assertA24|assertA25|assertA26|assertA27|assertA28"
|
||
- path: "tests/uat/lib/harness-page-driver.ts"
|
||
provides: "driveA24, driveA25, driveA26, driveA27, driveA28 page.evaluate wrappers"
|
||
contains: "driveA24|driveA25|driveA26|driveA27|driveA28"
|
||
- path: "tests/uat/harness.test.ts"
|
||
provides: "Orchestrator runs A24+A25+A26+A27+A28 after A23; FORBIDDEN_HOOK_STRINGS lockstep if new hook symbols"
|
||
contains: "driveA24|driveA25|driveA26|driveA27|driveA28"
|
||
- path: "tests/background/no-test-hooks-in-prod-bundle.test.ts"
|
||
provides: "Lockstep FORBIDDEN_HOOK_STRINGS update if new hook symbols introduced"
|
||
contains: "FORBIDDEN_HOOK_STRINGS"
|
||
key_links:
|
||
- from: "tests/uat/harness.test.ts"
|
||
to: "tests/uat/lib/harness-page-driver.ts:driveA24..A28"
|
||
via: "import + sequential orchestrator dispatch after driveA23"
|
||
pattern: "driveA24|driveA25|driveA26|driveA27|driveA28"
|
||
- from: "tests/uat/extension-page-harness.ts:assertA25"
|
||
to: "performance.now() bookends around SAVE_ARCHIVE dispatch + host-side downloadsDir poll"
|
||
via: "page-side measures dispatch→ack; host-side measures dispatch→file-on-disk; assert total < 5000ms"
|
||
pattern: "assertA25"
|
||
- from: "tests/uat/extension-page-harness.ts:assertA24"
|
||
to: "chrome.downloads.onChanged listener OR chrome.downloads spy proxy"
|
||
via: "harness page spies on chrome.downloads.download via Proxy/replace OR reads delta.url from onChanged event; verifies prefix is 'blob:'"
|
||
pattern: "chrome\\.downloads"
|
||
---
|
||
|
||
<objective>
|
||
Wave 3 of Phase 2: extend the UAT harness with A24+A25+A26+A27+A28 assertions covering the
|
||
D-P2-01 + D-P2-02 + D-P2-03 contracts AND REQ-archive-layout end-to-end through a real Chrome
|
||
instance. A27 runs in STRICT mode (post DEC-011 Amendment 1) — REQUIRES both multi-tab URLs in
|
||
meta.urls; FAILS on length<2. A28 strict-pins the 5-entry zip layout (no extras). Run pre-checkpoint
|
||
bundle gates per saved memory before surfacing the final operator empirical checkpoint. Close Phase 2.
|
||
|
||
Purpose: empirical verification that Plans 02-02 (Blob URL pipeline) and 02-03 (meta.urls schema)
|
||
work in a real Chrome instance, not just in unit-test isolation. The harness extension is the
|
||
closure gate for Phase 2 — analogous to Plan 01-13's 15/15 harness PASS that closed Phase 1
|
||
functional contract.
|
||
|
||
Output:
|
||
- 5 new harness assertions (A24+A25+A26+A27+A28) wired through page-harness + driver + orchestrator.
|
||
- A27 in STRICT mode (post DEC-011 Amendment 1) — both URLs REQUIRED; FAILS on length<2.
|
||
- A28 pins REQ-archive-layout: exactly 5 zip entries (`video/last_30sec.webm`, `rrweb/session.json`, `logs/events.json`, `screenshot.png`, `meta.json`).
|
||
- Tier-1 FORBIDDEN_HOOK_STRINGS lockstep IF new hook symbols required.
|
||
- Pre-checkpoint bundle gate validation completed.
|
||
- Operator empirical checkpoint documenting Plan 02-02 + 02-03 + the 5s-latency contract on a
|
||
real 6MB archive.
|
||
</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/REQUIREMENTS.md
|
||
@.planning/STATE.md
|
||
@.planning/phases/02-stabilize-export-pipeline/02-CONTEXT.md
|
||
@.planning/phases/02-stabilize-export-pipeline/02-01-PLAN.md
|
||
@.planning/phases/02-stabilize-export-pipeline/02-02-PLAN.md
|
||
@.planning/phases/02-stabilize-export-pipeline/02-03-PLAN.md
|
||
|
||
# Precedent for harness extension pattern (Approach B)
|
||
@.planning/phases/01-stabilize-video-pipeline/01-13-SUMMARY.md
|
||
@.planning/phases/01-stabilize-video-pipeline/01-14-SUMMARY.md
|
||
|
||
# Files under modification
|
||
@tests/uat/harness.test.ts
|
||
@tests/uat/lib/harness-page-driver.ts
|
||
@tests/background/no-test-hooks-in-prod-bundle.test.ts
|
||
|
||
<interfaces>
|
||
<!-- Existing harness orchestrator pattern (read these before extending). -->
|
||
|
||
From tests/uat/harness.test.ts FORBIDDEN_HOOK_STRINGS (line 107-122):
|
||
```typescript
|
||
const FORBIDDEN_HOOK_STRINGS: ReadonlyArray<string> = [
|
||
'__mokoshTest',
|
||
'setCurrentStream',
|
||
'setSegmentCountGetter',
|
||
'installFakeDisplayMedia',
|
||
'uninstallFakeDisplayMedia',
|
||
'dispatchEndedOnTrack',
|
||
'getSegmentCount',
|
||
'__mokoshOffscreenQuery',
|
||
'get-display-surface',
|
||
'get-segment-count',
|
||
'lastGetDisplayMediaConstraints',
|
||
'get-last-getDisplayMedia-constraints',
|
||
];
|
||
```
|
||
|
||
Existing driver pattern (tests/uat/lib/harness-page-driver.ts:driveA23):
|
||
```typescript
|
||
export async function driveA23(page: Page): Promise<AssertionRecord> {
|
||
return await page.evaluate(async () => {
|
||
const harness = (window as any).__mokoshHarness;
|
||
const r: AssertionRecord = await harness.assertA23();
|
||
return r;
|
||
}) as AssertionRecord;
|
||
}
|
||
```
|
||
|
||
A5 driver pattern (host-side + page-side merge) for downloadsDir polling — driveA24/A25
|
||
should reuse this pattern OR chain off A5's existing zip-on-disk verification. See
|
||
tests/uat/lib/harness-page-driver.ts:driveA5 (line 215) for the merged-checks pattern.
|
||
</interfaces>
|
||
</context>
|
||
|
||
<tasks>
|
||
|
||
<task type="auto" tdd="true">
|
||
<name>Task 1: assertA24 — chrome.downloads receives blob: URL prefix (D-P2-01 empirical)</name>
|
||
<files>tests/uat/extension-page-harness.ts, tests/uat/lib/harness-page-driver.ts</files>
|
||
<behavior>
|
||
- assertA24 page-side: at SAVE_ARCHIVE dispatch time, spy on chrome.downloads.download via a
|
||
page-context Proxy / monkey-patch installed BEFORE the dispatch. Wait for the SAVE_ARCHIVE
|
||
ack, then read the captured args[0].url. Assert:
|
||
(a) url.startsWith('blob:') === true
|
||
(b) url.startsWith('data:application/zip;base64,') === false
|
||
- driveA24: standard page.evaluate wrapper (mirrors driveA23 shape).
|
||
- A24 chains AFTER A23 in the orchestrator — does its own setupFreshRecording before SAVE
|
||
OR reuses A5's recording state if testable. PLANNER-DECISION: A24 does its OWN fresh
|
||
recording + save dispatch because the spy-installation requires controlling the window
|
||
RIGHT BEFORE the SAVE — chaining off A5's already-completed save misses the spy window.
|
||
- NO new test-hook symbols required: chrome.downloads.download is a chrome.* API that the
|
||
privileged extension-internal page can monkey-patch directly without going through the
|
||
__mokoshOffscreenQuery bridge. The Proxy replaces chrome.downloads.download with a wrapper
|
||
that records args + delegates to the original; assertA24 reads back the captured args + restores.
|
||
- This means Tier-1 FORBIDDEN_HOOK_STRINGS inventory STAYS at 12 (no new symbols).
|
||
</behavior>
|
||
<action>
|
||
Edit `tests/uat/extension-page-harness.ts` to add `assertA24` (pattern after assertA23 — read
|
||
lines around 1906-1989 from the file for the existing assertA23 shape):
|
||
|
||
```typescript
|
||
/**
|
||
* A24 — D-P2-01 empirical: SAVE_ARCHIVE → chrome.downloads.download is invoked with a
|
||
* `blob:` URL (NOT `data:application/zip;base64,`). Closes audit P0-6 functionally.
|
||
*
|
||
* Pattern: install a chrome.downloads.download Proxy that records the first url arg,
|
||
* dispatch SAVE_ARCHIVE, await ack, restore original, assert the captured url prefix.
|
||
*
|
||
* Chains: independent of A5 (does its own setupFreshRecording + SAVE) because the spy
|
||
* must be installed BEFORE the SAVE dispatch.
|
||
*/
|
||
async function assertA24(): Promise<AssertionResult> {
|
||
const checks: CheckRecord[] = [];
|
||
const diagnostics: string[] = [];
|
||
|
||
// Setup: fresh recording (mirrors A23's pattern; reuses setupFreshRecording helper).
|
||
await setupFreshRecording();
|
||
// Settle: let one segment land so SAVE has video to package.
|
||
await new Promise(r => setTimeout(r, 11_000));
|
||
|
||
// Install spy.
|
||
const original = chrome.downloads.download.bind(chrome.downloads);
|
||
let capturedUrl: string | null = null;
|
||
(chrome.downloads as any).download = (opts: chrome.downloads.DownloadOptions) => {
|
||
capturedUrl = opts.url;
|
||
return original(opts);
|
||
};
|
||
|
||
try {
|
||
const ack: { success: boolean } = await new Promise((resolve) => {
|
||
chrome.runtime.sendMessage({ type: 'SAVE_ARCHIVE' }, resolve);
|
||
});
|
||
checks.push({
|
||
name: 'A24.1: SAVE_ARCHIVE ack received with success=true',
|
||
expected: true,
|
||
actual: ack.success,
|
||
passed: ack.success === true,
|
||
});
|
||
// Poll up to 5s for the spy to fire (chrome.downloads.download is async-resolved
|
||
// post the offscreen bridge round-trip).
|
||
const pollStart = Date.now();
|
||
while (capturedUrl === null && Date.now() - pollStart < 5000) {
|
||
await new Promise(r => setTimeout(r, 100));
|
||
}
|
||
checks.push({
|
||
name: 'A24.2: chrome.downloads.download was invoked',
|
||
expected: true,
|
||
actual: capturedUrl !== null,
|
||
passed: capturedUrl !== null,
|
||
});
|
||
const urlIsBlob = capturedUrl !== null && capturedUrl.startsWith('blob:');
|
||
const urlIsDataBase64 = capturedUrl !== null && capturedUrl.startsWith('data:application/zip;base64,');
|
||
checks.push({
|
||
name: 'A24.3: download URL starts with "blob:" (D-P2-01)',
|
||
expected: true,
|
||
actual: urlIsBlob,
|
||
passed: urlIsBlob,
|
||
});
|
||
checks.push({
|
||
name: 'A24.4: download URL does NOT start with "data:application/zip;base64," (legacy path retired)',
|
||
expected: true,
|
||
actual: !urlIsDataBase64,
|
||
passed: !urlIsDataBase64,
|
||
});
|
||
diagnostics.push(`capturedUrl prefix: ${capturedUrl?.substring(0, 40) ?? '<null>'}...`);
|
||
} finally {
|
||
(chrome.downloads as any).download = original;
|
||
}
|
||
|
||
return {
|
||
name: 'A24 — D-P2-01 Blob URL download (closes P0-6)',
|
||
checks,
|
||
diagnostics,
|
||
};
|
||
}
|
||
```
|
||
|
||
Register assertA24 on the __mokoshHarness window surface (mirror the assertA23 registration line).
|
||
|
||
Edit `tests/uat/lib/harness-page-driver.ts` to add driveA24 (standard wrapper, identical
|
||
to driveA23 shape).
|
||
|
||
Per D-P2-01: this is the empirical closure of P0-6. The unit test (Plan 02-01 Task 1) proves
|
||
the wire-format at the SW boundary; A24 proves it end-to-end through a real Chrome instance,
|
||
including the offscreen bridge round-trip + the chrome.downloads platform call.
|
||
</action>
|
||
<verify>
|
||
<automated>npm run build:test 2>&1 | tail -5 ; HEADLESS=1 npm run test:uat 2>&1 | grep -E "(A24|FAIL|PASS)" | tail -20</automated>
|
||
</verify>
|
||
<done>
|
||
A24 page-side + driver wired. Harness orchestrator runs through A24. ALL 4 A24 checks GREEN.
|
||
Tier-1 FORBIDDEN_HOOK_STRINGS unchanged at 12 (chrome.downloads spy is a chrome.* monkey-patch,
|
||
not a test-hook symbol).
|
||
Atomic commit:
|
||
`feat(02-04): harness A24 — empirical Blob URL download verification (D-P2-01)`.
|
||
</done>
|
||
</task>
|
||
|
||
<task type="auto" tdd="true">
|
||
<name>Task 2: assertA25 — SAVE→zip-on-disk latency <5s (REQ-archive-export-latency)</name>
|
||
<files>tests/uat/extension-page-harness.ts, tests/uat/lib/harness-page-driver.ts</files>
|
||
<behavior>
|
||
- assertA25 page-side: dispatch SAVE_ARCHIVE; record `t0 = performance.now()` before dispatch
|
||
and `tAck = performance.now()` after the ack. Return both timings in the page result.
|
||
- driveA25 host-side: also polls downloadsDir for the new zip file (mirrors A5's pattern) and
|
||
records `tFile = Date.now()` when the file is observed. Returns the page result merged with
|
||
the host-side timing.
|
||
- Assertions:
|
||
(a) tAck - t0 < 5000 (page-side: dispatch → ack)
|
||
(b) tFile - t0_host < 5000 (host-side: dispatch → file on disk, where t0_host is captured
|
||
on the driver side just before page.evaluate)
|
||
- Operator-facing tolerance: 5000ms is the SPEC §10 #6 + CON-archive-export-latency hard ceiling.
|
||
Recorded actual latency reported in diagnostics for retrospective tuning.
|
||
- A25 chains AFTER A24's SAVE (which already produced one zip in downloadsDir) — A25 does its
|
||
OWN setupFreshRecording + SAVE because the latency measurement must be on a clean save, not
|
||
compounded with A24's still-pending state.
|
||
- PLANNER-DECISION on the empty-buffer race: A25 settles for 11s after setupFreshRecording before
|
||
dispatching SAVE_ARCHIVE (mirrors A13's pattern from Plan 01-13 Wave 3D — let one segment land).
|
||
The 5s SAVE→file latency budget is measured FROM SAVE dispatch, NOT from the broader test orchestration.
|
||
</behavior>
|
||
<action>
|
||
Add `assertA25` to tests/uat/extension-page-harness.ts (mirror assertA24's structure):
|
||
|
||
```typescript
|
||
/**
|
||
* A25 — REQ-archive-export-latency: SAVE_ARCHIVE → zip on disk in <5000ms.
|
||
* CON-archive-export-latency + SPEC §10 #6 hard ceiling.
|
||
*
|
||
* Returns dispatch + ack timings (page-side); host-side driver merges the
|
||
* dispatch→file-on-disk timing (mirrors A5's merged-checks pattern).
|
||
*/
|
||
async function assertA25(): Promise<AssertionResult & { t0: number; tAck: number }> {
|
||
const checks: CheckRecord[] = [];
|
||
const diagnostics: string[] = [];
|
||
|
||
await setupFreshRecording();
|
||
await new Promise(r => setTimeout(r, 11_000));
|
||
|
||
const t0 = performance.now();
|
||
const ack: { success: boolean } = await new Promise((resolve) => {
|
||
chrome.runtime.sendMessage({ type: 'SAVE_ARCHIVE' }, resolve);
|
||
});
|
||
const tAck = performance.now();
|
||
const elapsedAck = tAck - t0;
|
||
|
||
checks.push({
|
||
name: 'A25.1: SAVE_ARCHIVE ack received with success=true',
|
||
expected: true,
|
||
actual: ack.success,
|
||
passed: ack.success === true,
|
||
});
|
||
checks.push({
|
||
name: 'A25.2: dispatch → ack latency < 5000ms',
|
||
expected: '<5000ms',
|
||
actual: `${elapsedAck.toFixed(0)}ms`,
|
||
passed: elapsedAck < 5000,
|
||
});
|
||
diagnostics.push(`page-side latency: t0=${t0.toFixed(0)} tAck=${tAck.toFixed(0)} delta=${elapsedAck.toFixed(0)}ms`);
|
||
|
||
return {
|
||
name: 'A25 — REQ-archive-export-latency: <5000ms (SPEC §10 #6)',
|
||
checks,
|
||
diagnostics,
|
||
t0,
|
||
tAck,
|
||
};
|
||
}
|
||
```
|
||
|
||
Add `driveA25` with host-side latency merge (pattern after driveA5):
|
||
```typescript
|
||
export async function driveA25(page: Page, downloadsDir: string): Promise<AssertionRecord> {
|
||
const preExisting = new Set(readdirSync(downloadsDir).filter(isZipFilename));
|
||
const t0_host = Date.now();
|
||
const pageResult = await page.evaluate(async () => {
|
||
const harness = (window as any).__mokoshHarness;
|
||
return await harness.assertA25();
|
||
}) as AssertionRecord & { t0: number; tAck: number };
|
||
|
||
// Host-side poll for new zip.
|
||
let tFile: number | null = null;
|
||
const pollStart = Date.now();
|
||
while (Date.now() - pollStart < 6000 /* 1s budget over the 5s SLO for slack */) {
|
||
const candidates = readdirSync(downloadsDir).filter(
|
||
(name) => isZipFilename(name) && !preExisting.has(name),
|
||
);
|
||
if (candidates.length > 0) {
|
||
tFile = Date.now();
|
||
break;
|
||
}
|
||
await new Promise(r => setTimeout(r, 100));
|
||
}
|
||
|
||
const mergedChecks: CheckRecord[] = pageResult.checks.slice();
|
||
const elapsedFile = tFile !== null ? tFile - t0_host : -1;
|
||
mergedChecks.push({
|
||
name: 'A25.3: host-side dispatch → zip-on-disk latency < 5000ms',
|
||
expected: '<5000ms',
|
||
actual: elapsedFile >= 0 ? `${elapsedFile}ms` : 'zip never appeared',
|
||
passed: elapsedFile >= 0 && elapsedFile < 5000,
|
||
});
|
||
const mergedDiagnostics = pageResult.diagnostics.slice();
|
||
mergedDiagnostics.push(`host-side latency: t0_host=${t0_host} tFile=${tFile ?? '<missing>'} delta=${elapsedFile}ms`);
|
||
|
||
return {
|
||
passed: mergedChecks.every(c => c.passed),
|
||
name: pageResult.name,
|
||
checks: mergedChecks,
|
||
diagnostics: mergedDiagnostics,
|
||
error: pageResult.error,
|
||
};
|
||
}
|
||
```
|
||
|
||
Note: `isZipFilename` is already exported in harness-page-driver.ts (line 309) — reuse.
|
||
|
||
Per REQ-archive-export-latency + CON-archive-export-latency: this is the canonical SPEC §10 #6
|
||
empirical gate. The 5000ms budget is end-to-end (SAVE dispatch → file on disk).
|
||
</action>
|
||
<verify>
|
||
<automated>HEADLESS=1 npm run test:uat 2>&1 | grep -E "(A25|FAIL|PASS|latency)" | tail -20</automated>
|
||
</verify>
|
||
<done>
|
||
A25 page-side + driver wired with merged checks. 3 checks GREEN (ack received + page-side
|
||
latency + host-side latency, all <5000ms). Atomic commit:
|
||
`feat(02-04): harness A25 — empirical <5s SAVE→zip latency (REQ-archive-export-latency, SPEC §10 #6)`.
|
||
</done>
|
||
</task>
|
||
|
||
<task type="auto" tdd="true">
|
||
<name>Task 3: assertA26 + A27 (strict) + A28 — meta.urls shape + multi-tab strict + REQ-archive-layout (D-P2-02/03 + REQ-archive-layout)</name>
|
||
<files>tests/uat/extension-page-harness.ts, tests/uat/lib/harness-page-driver.ts, tests/uat/harness.test.ts</files>
|
||
<behavior>
|
||
- assertA26 page-side: chain off A25's produced zip (read host-side filename via merged checks).
|
||
Use JSZip.loadAsync to read meta.json from the zip; JSON.parse; assert:
|
||
(a) Object.keys(meta).length === 8
|
||
(b) meta.schemaVersion === '2'
|
||
(c) Array.isArray(meta.urls) && meta.urls.length >= 1
|
||
(d) meta.url === undefined (the legacy single-URL field is gone)
|
||
(e) meta.urls.every(u => /^(https?|chrome-extension):\/\//.test(u))
|
||
- assertA27 STRICT mode (post DEC-011 Amendment 1) — REQUIRES both multi-tab URLs in meta.urls. Sequence:
|
||
(1) setupFreshRecording
|
||
(2) Open tab A: `chrome.tabs.create({ url: 'https://example.com/', active: false })`; wait 1500ms for navigation
|
||
(3) Activate tab A: `chrome.tabs.update(tabA.id, { active: true })`; wait 500ms for onActivated to fire
|
||
(4) Open tab B: `chrome.tabs.create({ url: 'https://www.iana.org/', active: false })`; wait 1500ms for navigation
|
||
(5) Activate tab B: `chrome.tabs.update(tabB.id, { active: true })`; wait 500ms for onActivated to fire
|
||
(6) Wait 11s for one segment to land
|
||
(7) Dispatch SAVE_ARCHIVE; await ack with success===true
|
||
(8) Host-side: load latest zip; parse meta.json; read meta.urls
|
||
(9) Strict assertions (all REQUIRED to pass):
|
||
(a) `meta.urls.length >= 2` (FAIL on length < 2)
|
||
(b) `meta.urls` contains BOTH 'https://example.com/' AND 'https://www.iana.org/' (order-flexible; use Set membership)
|
||
(c) `meta.urls.every(u => typeof u === 'string' && u.length > 0)` — no `[object Object]`, no nulls, no empty strings
|
||
(d) `meta.urls.every(u => !u.startsWith('chrome-extension://'))` — no extension-origin sentinel fallback (F2)
|
||
(e) `meta.urls.every(u => !u.startsWith('chrome://'))` — no chrome-internal URLs
|
||
(10) Cleanup: try/catch close both tabs via chrome.tabs.remove (silent-ignore on already-closed)
|
||
DEC-011 Amendment 1 guarantee: with `tabs` permission granted, chrome.tabs.get(tabId).url
|
||
and chrome.tabs.query({}) reliably populate the URL field. snapshotOpenTabs at SAVE time
|
||
(Plan 02-03 Task 2) provides a defensive belt + suspenders. A27 has UNAMBIGUOUS contract:
|
||
both URLs MUST appear; the test is the binding empirical gate for D-P2-02.
|
||
- assertA28 (REQ-archive-layout strict zip-layout): chain off A25's produced zip (reuse merged-checks pattern). Use JSZip.loadAsync to enumerate zip entries; assert:
|
||
(a) Zip contains EXACTLY 5 entries (no more, no fewer)
|
||
(b) The 5 paths are EXACTLY `video/last_30sec.webm`, `rrweb/session.json`, `logs/events.json`, `screenshot.png`, `meta.json` (set-equality; order-flexible)
|
||
(c) NO extra entries (no `__MACOSX/`, no `.DS_Store`, no temp files)
|
||
Cross-references REQ-archive-layout + REQ-popup-ui (popup-triggered SAVE flows through the same createArchive path) + REQ-screenshot-on-export (screenshot.png entry verified present).
|
||
- driveA26 + driveA27 + driveA28: standard page.evaluate wrappers + host-side zip-lookup (mirror driveA5's merged-checks pattern). driveA27 additionally inspects meta.urls host-side via JSZip; driveA28 inspects the zip directory listing.
|
||
- Orchestrator update: tests/uat/harness.test.ts adds A24, A25, A26, A27, A28 to the assertion sequence AFTER A23. Total target: 24 (Phase 1 baseline) + 5 (Phase 2) = 29/29 GREEN.
|
||
- FORBIDDEN_HOOK_STRINGS lockstep: A24/A25/A26/A27/A28 use chrome.* monkey-patch (A24's downloads spy) + JSZip + chrome.tabs.create/update which are production APIs. No new test-hook surface required. Tier-1 inventory STAYS at 12. Plan-checker verifies via final grep.
|
||
</behavior>
|
||
<action>
|
||
Add `assertA26` and `assertA27` to tests/uat/extension-page-harness.ts:
|
||
|
||
```typescript
|
||
/**
|
||
* A26 — D-P2-02 + D-P2-03 empirical: meta.json has the 8-field shape with urls[] (not url:string)
|
||
* and schemaVersion='2'. Chains off A25's produced zip.
|
||
*/
|
||
async function assertA26(zipBytes: Uint8Array): Promise<AssertionResult> {
|
||
const checks: CheckRecord[] = [];
|
||
const diagnostics: string[] = [];
|
||
|
||
const zip = await JSZip.loadAsync(zipBytes);
|
||
const metaFile = zip.file('meta.json');
|
||
checks.push({
|
||
name: 'A26.1: meta.json entry exists in zip',
|
||
expected: true, actual: metaFile !== null, passed: metaFile !== null,
|
||
});
|
||
if (metaFile === null) {
|
||
return { name: 'A26 — meta.json 8-field shape (D-P2-02/03)', checks, diagnostics };
|
||
}
|
||
const metaText = await metaFile.async('string');
|
||
const meta = JSON.parse(metaText);
|
||
diagnostics.push(`meta.json keys: ${Object.keys(meta).join(',')}`);
|
||
|
||
checks.push({
|
||
name: 'A26.2: meta has exactly 8 fields',
|
||
expected: 8, actual: Object.keys(meta).length, passed: Object.keys(meta).length === 8,
|
||
});
|
||
checks.push({
|
||
name: 'A26.3: meta.schemaVersion === "2"',
|
||
expected: '2', actual: meta.schemaVersion, passed: meta.schemaVersion === '2',
|
||
});
|
||
checks.push({
|
||
name: 'A26.4: meta.urls is non-empty Array',
|
||
expected: 'non-empty Array',
|
||
actual: Array.isArray(meta.urls) ? `Array(${meta.urls.length})` : typeof meta.urls,
|
||
passed: Array.isArray(meta.urls) && meta.urls.length >= 1,
|
||
});
|
||
checks.push({
|
||
name: 'A26.5: meta.url (legacy field) is undefined',
|
||
expected: 'undefined', actual: typeof meta.url, passed: meta.url === undefined,
|
||
});
|
||
checks.push({
|
||
name: 'A26.6: every meta.urls[i] matches /^(https?|chrome-extension):\\/\\//',
|
||
expected: true,
|
||
actual: Array.isArray(meta.urls) && meta.urls.every((u: string) => /^(https?|chrome-extension):\/\//.test(u)),
|
||
passed: Array.isArray(meta.urls) && meta.urls.every((u: string) => /^(https?|chrome-extension):\/\//.test(u)),
|
||
});
|
||
|
||
return { name: 'A26 — meta.json 8-field shape (D-P2-02/D-P2-03)', checks, diagnostics };
|
||
}
|
||
|
||
/**
|
||
* A27 — STRICT mode (post DEC-011 Amendment 1): multi-tab URL tracking.
|
||
* Opens TWO tabs sequentially, activates each, then dispatches SAVE.
|
||
* Host-side driver asserts meta.urls contains BOTH URLs (length >= 2 REQUIRED;
|
||
* FAILS on length < 2; no extension-origin sentinel; no chrome-internal URLs).
|
||
*/
|
||
async function assertA27(): Promise<AssertionResult & { tabAUrl: string; tabBUrl: string }> {
|
||
const checks: CheckRecord[] = [];
|
||
const diagnostics: string[] = [];
|
||
|
||
await setupFreshRecording();
|
||
|
||
const TAB_A_URL = 'https://example.com/';
|
||
const TAB_B_URL = 'https://www.iana.org/';
|
||
|
||
// Open + activate tab A.
|
||
const tabA = await chrome.tabs.create({ url: TAB_A_URL, active: false });
|
||
await new Promise(r => setTimeout(r, 1500));
|
||
if (tabA.id !== undefined) {
|
||
await chrome.tabs.update(tabA.id, { active: true });
|
||
await new Promise(r => setTimeout(r, 500));
|
||
}
|
||
|
||
// Open + activate tab B.
|
||
const tabB = await chrome.tabs.create({ url: TAB_B_URL, active: false });
|
||
await new Promise(r => setTimeout(r, 1500));
|
||
if (tabB.id !== undefined) {
|
||
await chrome.tabs.update(tabB.id, { active: true });
|
||
await new Promise(r => setTimeout(r, 500));
|
||
}
|
||
|
||
// Settle for one segment.
|
||
await new Promise(r => setTimeout(r, 11_000));
|
||
|
||
// SAVE.
|
||
const ack: { success: boolean } = await new Promise(resolve => {
|
||
chrome.runtime.sendMessage({ type: 'SAVE_ARCHIVE' }, resolve);
|
||
});
|
||
checks.push({
|
||
name: 'A27.1: SAVE_ARCHIVE ack received with success=true',
|
||
expected: true, actual: ack.success, passed: ack.success === true,
|
||
});
|
||
|
||
// Cleanup tabs (silent-ignore on already-closed).
|
||
try { if (tabA.id !== undefined) await chrome.tabs.remove(tabA.id); } catch {}
|
||
try { if (tabB.id !== undefined) await chrome.tabs.remove(tabB.id); } catch {}
|
||
|
||
diagnostics.push(`opened tabA.id=${tabA.id} url=${TAB_A_URL}; tabB.id=${tabB.id} url=${TAB_B_URL}`);
|
||
return {
|
||
name: 'A27 — D-P2-02 STRICT multi-tab urls[] (both URLs REQUIRED)',
|
||
checks, diagnostics,
|
||
tabAUrl: TAB_A_URL,
|
||
tabBUrl: TAB_B_URL,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* A28 — REQ-archive-layout strict zip-layout: zip contains EXACTLY the 5
|
||
* canonical entries and no extras. Chains off A25's produced zip via the
|
||
* host-side driver's merged-checks pattern (no new SAVE dispatch).
|
||
*
|
||
* NOTE: page-side assertA28 is a STUB returning the assertion name; all
|
||
* real work happens host-side in driveA28 (zip directory enumeration).
|
||
*/
|
||
async function assertA28(): Promise<AssertionResult> {
|
||
return {
|
||
name: 'A28 — REQ-archive-layout strict zip-layout (5 entries)',
|
||
checks: [],
|
||
diagnostics: ['assertA28 page-side stub; host-side driver inspects zip directory'],
|
||
};
|
||
}
|
||
```
|
||
|
||
Host-side driveA27 (merged-checks pattern with strict meta.urls assertions):
|
||
|
||
```typescript
|
||
export async function driveA27(page: Page, downloadsDir: string): Promise<AssertionRecord> {
|
||
const preExisting = new Set(readdirSync(downloadsDir).filter(isZipFilename));
|
||
const pageResult = await page.evaluate(async () => {
|
||
const harness = (window as any).__mokoshHarness;
|
||
return await harness.assertA27();
|
||
}) as AssertionRecord & { tabAUrl: string; tabBUrl: string };
|
||
|
||
// Locate the new zip produced by A27.
|
||
let zipPath: string | null = null;
|
||
const pollStart = Date.now();
|
||
while (Date.now() - pollStart < 8000) {
|
||
const candidates = readdirSync(downloadsDir).filter(
|
||
(name) => isZipFilename(name) && !preExisting.has(name),
|
||
);
|
||
if (candidates.length > 0) {
|
||
zipPath = `${downloadsDir}/${candidates[candidates.length - 1]}`;
|
||
break;
|
||
}
|
||
await new Promise(r => setTimeout(r, 100));
|
||
}
|
||
|
||
const mergedChecks: CheckRecord[] = pageResult.checks.slice();
|
||
const mergedDiagnostics = pageResult.diagnostics.slice();
|
||
|
||
if (zipPath === null) {
|
||
mergedChecks.push({
|
||
name: 'A27.2: zip emitted to downloadsDir',
|
||
expected: 'zip file present', actual: 'no zip found within 8s', passed: false,
|
||
});
|
||
} else {
|
||
const zipBytes = readFileSync(zipPath);
|
||
const zip = await JSZip.loadAsync(zipBytes);
|
||
const metaFile = zip.file('meta.json');
|
||
const metaText = metaFile !== null ? await metaFile.async('string') : '{}';
|
||
let meta: { urls?: unknown } = {};
|
||
try { meta = JSON.parse(metaText); } catch {}
|
||
const urlsRaw = (meta as { urls?: unknown }).urls;
|
||
const urls: string[] = Array.isArray(urlsRaw) ? urlsRaw.filter((u): u is string => typeof u === 'string') : [];
|
||
|
||
mergedChecks.push({
|
||
name: 'A27.2: meta.urls is an Array',
|
||
expected: true, actual: Array.isArray(urlsRaw), passed: Array.isArray(urlsRaw),
|
||
});
|
||
mergedChecks.push({
|
||
name: 'A27.3: meta.urls.length >= 2 (STRICT — both URLs REQUIRED post DEC-011 Amendment 1)',
|
||
expected: '>=2', actual: urls.length, passed: urls.length >= 2,
|
||
});
|
||
mergedChecks.push({
|
||
name: `A27.4: meta.urls contains ${pageResult.tabAUrl}`,
|
||
expected: true, actual: urls.includes(pageResult.tabAUrl), passed: urls.includes(pageResult.tabAUrl),
|
||
});
|
||
mergedChecks.push({
|
||
name: `A27.5: meta.urls contains ${pageResult.tabBUrl}`,
|
||
expected: true, actual: urls.includes(pageResult.tabBUrl), passed: urls.includes(pageResult.tabBUrl),
|
||
});
|
||
mergedChecks.push({
|
||
name: 'A27.6: every meta.urls[i] is a non-empty string (no [object Object], no nulls)',
|
||
expected: true,
|
||
actual: urls.every(u => typeof u === 'string' && u.length > 0),
|
||
passed: urls.length > 0 && urls.every(u => typeof u === 'string' && u.length > 0),
|
||
});
|
||
mergedChecks.push({
|
||
name: 'A27.7: no extension-origin sentinel URLs (F2 — empty-tracker fallback removed)',
|
||
expected: true,
|
||
actual: urls.every(u => !u.startsWith('chrome-extension://')),
|
||
passed: urls.every(u => !u.startsWith('chrome-extension://')),
|
||
});
|
||
mergedChecks.push({
|
||
name: 'A27.8: no chrome-internal URLs in meta.urls (filter rules)',
|
||
expected: true,
|
||
actual: urls.every(u => !u.startsWith('chrome://') && !u.startsWith('about:')),
|
||
passed: urls.every(u => !u.startsWith('chrome://') && !u.startsWith('about:')),
|
||
});
|
||
mergedDiagnostics.push(`zipPath=${zipPath}; meta.urls=${JSON.stringify(urls)}`);
|
||
}
|
||
|
||
return {
|
||
passed: mergedChecks.every(c => c.passed),
|
||
name: pageResult.name,
|
||
checks: mergedChecks,
|
||
diagnostics: mergedDiagnostics,
|
||
error: pageResult.error,
|
||
};
|
||
}
|
||
|
||
export async function driveA28(page: Page, downloadsDir: string): Promise<AssertionRecord> {
|
||
// Reuse A25's most-recent zip (A28 does NOT trigger a new SAVE — chains off A25).
|
||
const zipFiles = readdirSync(downloadsDir).filter(isZipFilename).sort();
|
||
const checks: CheckRecord[] = [];
|
||
const diagnostics: string[] = [];
|
||
if (zipFiles.length === 0) {
|
||
checks.push({
|
||
name: 'A28.1: at least one zip present in downloadsDir',
|
||
expected: '>=1', actual: 0, passed: false,
|
||
});
|
||
return { passed: false, name: 'A28 — REQ-archive-layout strict (5 entries)', checks, diagnostics };
|
||
}
|
||
const latest = `${downloadsDir}/${zipFiles[zipFiles.length - 1]}`;
|
||
const zipBytes = readFileSync(latest);
|
||
const zip = await JSZip.loadAsync(zipBytes);
|
||
|
||
const EXPECTED_PATHS: ReadonlyArray<string> = [
|
||
'video/last_30sec.webm',
|
||
'rrweb/session.json',
|
||
'logs/events.json',
|
||
'screenshot.png',
|
||
'meta.json',
|
||
];
|
||
const actualPaths = Object.keys(zip.files).filter(p => !zip.files[p].dir).sort();
|
||
const expectedSorted = [...EXPECTED_PATHS].sort();
|
||
|
||
checks.push({
|
||
name: 'A28.1: zip has EXACTLY 5 entries (REQ-archive-layout)',
|
||
expected: 5, actual: actualPaths.length, passed: actualPaths.length === 5,
|
||
});
|
||
checks.push({
|
||
name: 'A28.2: zip entries set-equal to the canonical 5 paths',
|
||
expected: expectedSorted.join(','),
|
||
actual: actualPaths.join(','),
|
||
passed: JSON.stringify(actualPaths) === JSON.stringify(expectedSorted),
|
||
});
|
||
checks.push({
|
||
name: 'A28.3: no extras (no __MACOSX/, no .DS_Store, no temp files)',
|
||
expected: 'no extras',
|
||
actual: actualPaths.filter(p => !EXPECTED_PATHS.includes(p)).join(',') || 'none',
|
||
passed: actualPaths.every(p => EXPECTED_PATHS.includes(p)),
|
||
});
|
||
diagnostics.push(`zipPath=${latest}; entries=${actualPaths.join(',')}`);
|
||
|
||
return {
|
||
passed: checks.every(c => c.passed),
|
||
name: 'A28 — REQ-archive-layout strict (5 entries; D-P2-02/03 + REQ-popup-ui + REQ-screenshot-on-export)',
|
||
checks, diagnostics,
|
||
};
|
||
}
|
||
```
|
||
|
||
Add `driveA26(page, downloadsDir)` + `driveA27(page, downloadsDir)` with host-side zip-lookup
|
||
+ meta.urls inspection — pattern after driveA5's merged-checks shape.
|
||
|
||
For driveA27's host side: after the page returns the ack, locate the most-recent zip in
|
||
downloadsDir, load it via JSZip, parse meta.json, assert example.com AND iana.org are both
|
||
in meta.urls.
|
||
|
||
Update tests/uat/harness.test.ts orchestrator (search for the existing sequence around the
|
||
driveA23 dispatch) to add:
|
||
```typescript
|
||
drivers.push({ name: 'A24', fn: () => driveA24(page) });
|
||
drivers.push({ name: 'A25', fn: () => driveA25(page, handles.downloadsDir) });
|
||
drivers.push({ name: 'A26', fn: () => driveA26(page, handles.downloadsDir) });
|
||
drivers.push({ name: 'A27', fn: () => driveA27(page, handles.downloadsDir) });
|
||
drivers.push({ name: 'A28', fn: () => driveA28(page, handles.downloadsDir) });
|
||
```
|
||
Final target: 29/29 GREEN.
|
||
|
||
FORBIDDEN_HOOK_STRINGS verification: grep the new test files for any new symbols that might
|
||
leak into dist/. Expected: none (all symbols are local to extension-page-harness.ts which is
|
||
NOT built into dist/ — it's loaded via the test-harness page, not the production manifest).
|
||
Tier-1 inventory stays at 12.
|
||
|
||
Per D-P2-02 + D-P2-03 + REQ-archive-export-latency + REQ-archive-layout: A24+A25+A26+A27+A28
|
||
collectively validate the full Phase 2 contract empirically. A27 in STRICT mode is the binding
|
||
empirical gate for D-P2-02 (post DEC-011 Amendment 1). A28 pins the canonical 5-entry zip layout.
|
||
After A28, Phase 2 functional contract is HARNESS-CLOSED (analogous to Plan 01-13's role for Phase 1).
|
||
</action>
|
||
<verify>
|
||
<automated>HEADLESS=1 npm run test:uat 2>&1 | grep -E "(A26|A27|A28|FAIL|PASS|29/29)" | tail -30</automated>
|
||
</verify>
|
||
<done>
|
||
A26 + A27 (strict) + A28 page-side + drivers wired. Orchestrator runs 29 assertions sequentially.
|
||
ALL 29 GREEN. A27 length>=2 strict check passes (both example.com + iana.org appear in meta.urls
|
||
per DEC-011 Amendment 1). A28 zip-layout 5-entry strict check passes (REQ-archive-layout).
|
||
FORBIDDEN_HOOK_STRINGS unchanged at 12. Atomic commit:
|
||
`feat(02-04): harness A26+A27(strict)+A28 — empirical meta.json 8-field + multi-tab urls[] STRICT + REQ-archive-layout (D-P2-02/03 + DEC-011 Amendment 1)`.
|
||
</done>
|
||
</task>
|
||
|
||
<task type="checkpoint:human-verify" gate="blocking">
|
||
<name>Task 4: Pre-checkpoint bundle gates + operator empirical checkpoint</name>
|
||
<files>(no files modified; verification-only — gate runs against existing dist/ + a real Chrome instance)</files>
|
||
<action>
|
||
**Step 1 — Pre-checkpoint bundle gates** (orchestrator-driven; per saved memory
|
||
`feedback-pre-checkpoint-bundle-gates.md`; MUST PASS before surfacing the empirical Step 2 to operator):
|
||
|
||
Run each in sequence; on first failure, surface a diagnostic to the operator + block the checkpoint:
|
||
1. `npm run build` → exit 0; dist/ populated.
|
||
2. SW CSP-safety grep: `grep -rE 'new Function\(|eval\(' dist/assets/index-*-bg.js` → 0 hits OR documented pre-existing exceptions only (e.g., setimmediate polyfill `new Function` per Plan 01-12 Wave 7 disclosure — exact-string match exception list documented in `.planning/phases/01-stabilize-video-pipeline/deferred-items.md`).
|
||
3. SW Node-globals grep: `grep -rE 'Buffer\.from|Buffer\.alloc|process\.|require\(' dist/assets/index-*-bg.js` → 0 hits.
|
||
4. DOM-globals grep: `grep -rE '(window\.|document\.)' dist/assets/index-*-bg.js | grep -vE '^//|globalThis|^$'` → 0 hits in SW chunk (DOM globals are forbidden in SW context — see DEC-006).
|
||
5. Tier-1 SW-bundle-import gate: `npx vitest run tests/background/sw-bundle-import.test.ts` → GREEN.
|
||
6. Tier-1 FORBIDDEN_HOOK_STRINGS gate: `npx vitest run tests/background/no-test-hooks-in-prod-bundle.test.ts` → 12 strings, 0 hits each → GREEN.
|
||
7. Manifest validation gate: `npx vitest run tests/i18n/manifest-i18n.test.ts tests/i18n/locale-parity.test.ts tests/build/` → GREEN.
|
||
|
||
**Step 2 — Operator-driven empirical UAT cycle 1** (manual, ~5 min):
|
||
|
||
Step 2.1 — Load unpacked extension from `dist/` into Chrome.
|
||
Expected: no warnings/errors in chrome://extensions/.
|
||
|
||
Step 2.2 — Open a tab with `https://example.com`. Open a second tab with `https://www.iana.org`.
|
||
Click the Mokosh toolbar icon → pick "Entire screen" in the picker.
|
||
Expected: REC badge appears; recording starts.
|
||
|
||
Step 2.3 — Switch between the two tabs a few times. Wait at least 15 seconds (one full segment lands).
|
||
|
||
Step 2.4 — Open the Mokosh popup. Click "Сохранить отчёт об ошибке" (or the i18n equivalent).
|
||
Expected within 5 seconds:
|
||
(a) A `session_report_*.zip` file lands in Downloads folder
|
||
(b) The popup transitions idle → "Сохраняю..." → "Готово! ✓" → idle (3s revert)
|
||
|
||
Step 2.5 — Open the zip with the OS archive manager.
|
||
Expected layout:
|
||
```
|
||
session_report_*.zip
|
||
├── video/last_30sec.webm
|
||
├── rrweb/session.json
|
||
├── logs/events.json
|
||
├── screenshot.png
|
||
└── meta.json
|
||
```
|
||
|
||
Step 2.6 — Open meta.json. Verify (STRICT, post DEC-011 Amendment 1):
|
||
(a) `schemaVersion: "2"` present
|
||
(b) `urls` field is an ARRAY (not a string)
|
||
(c) `urls` contains BOTH `https://example.com/` AND `https://www.iana.org/` (length >= 2 REQUIRED)
|
||
(d) NO `url` field present (just `urls`)
|
||
(e) Exactly 8 keys total
|
||
(f) NO `chrome-extension://...` URLs in `urls` (F2 — empty-tracker fallback removed)
|
||
|
||
Step 2.7 — Open video/last_30sec.webm in a browser (drag into a Chrome tab).
|
||
Expected: ~30 seconds of video plays end-to-end.
|
||
|
||
Step 2.8 — Verify the >2 MB archive case (the regression class P0-6 closes):
|
||
(a) In Chrome DevTools → Network panel of the Mokosh offscreen / extension context, observe
|
||
the download was initiated from a `blob:chrome-extension://<id>/<uuid>` URL (NOT a
|
||
`data:application/zip;base64,...`).
|
||
(b) If the archive is larger than 2 MB (typical for ≥15s of video), this proves the
|
||
D-P2-01 migration works for the canonical use case.
|
||
|
||
**Reply contract:** Type "approved" if Steps 1-2 all match expectations.
|
||
If any step deviates, describe the deviation (which step + what was observed + what was expected).
|
||
Deviations route to a follow-up plan (02-05 OR a debug session per saved memory
|
||
`feedback-gsd-ceremony-for-fixes.md` — NEVER hot-edit).
|
||
|
||
**Why human-verify (not auto):** Steps 2.1-2.8 require a real Chrome instance with screen-share
|
||
grant + a real OS Downloads folder + a real archive-manager tool. The harness in Tasks 1-3
|
||
covers the chrome.* + zip-shape contracts but cannot validate the OS-level archive integrity
|
||
(Step 2.5 archive-manager open) or the operator's empirical observation of the network panel
|
||
(Step 2.8a). Per saved memory `feedback-pre-checkpoint-bundle-gates.md`: "Operator time is for
|
||
things automation cannot verify" — Steps 2.5, 2.7, 2.8 are exactly those.
|
||
</action>
|
||
<verify>
|
||
<automated>npm run build 2>&1 | tail -5 ; npx vitest run tests/background/no-test-hooks-in-prod-bundle.test.ts tests/background/sw-bundle-import.test.ts tests/i18n/ tests/build/ 2>&1 | tail -10</automated>
|
||
</verify>
|
||
<done>
|
||
Step 1 gates ALL GREEN (7/7 sub-gates). Operator empirical reply received:
|
||
EITHER "approved" → Phase 2 closes, mark REQUIREMENTS.md REQ-archive-export-latency +
|
||
REQ-meta-json-schema + REQ-popup-ui + REQ-archive-layout + REQ-screenshot-on-export as Complete,
|
||
flip STATE.md Phase 2 → COMPLETE, update ROADMAP.md Phase 2 status;
|
||
OR deviations documented → route through `/gsd-debug` per saved memory + follow-up plan 02-05.
|
||
</done>
|
||
<what-built>
|
||
Phase 2 implementation complete:
|
||
- Plan 02-02: Offscreen-minted Blob URL pipeline (D-P2-01, closes P0-6).
|
||
- Plan 02-03: meta.json urls[] + schemaVersion + tab-url-tracker (D-P2-02, closes P1 #10).
|
||
- Plan 02-04 Tasks 1-3: UAT harness A24+A25+A26+A27(STRICT)+A28 GREEN (29/29). A27 strict-mode unblocked by DEC-011 Amendment 1; A28 pins REQ-archive-layout 5-entry zip.
|
||
Acceptance baselines preserved: vitest GREEN, UAT GREEN, Tier-1 FORBIDDEN_HOOK_STRINGS = 12,
|
||
production bundle hook-free.
|
||
</what-built>
|
||
<how-to-verify>
|
||
See `<action>` block above — Step 1 (orchestrator) + Step 2 (operator) define the verification
|
||
contract verbatim. Reply contract codified in the `<action>` block.
|
||
</how-to-verify>
|
||
<resume-signal>Type "approved" or describe deviations (e.g., "Step 2.6c failed: meta.urls only had 1 entry, not 2")</resume-signal>
|
||
</task>
|
||
|
||
</tasks>
|
||
|
||
<threat_model>
|
||
## Trust Boundaries
|
||
|
||
| Boundary | Description |
|
||
|----------|-------------|
|
||
| harness page → chrome.downloads | privileged extension-internal page; spy via Proxy is intra-extension; not exposed to web pages |
|
||
| operator's manual zip inspection | filesystem boundary; archive may contain operator credentials per "log is internal" charter — operator decides if it's safe to share externally |
|
||
|
||
## STRIDE Threat Register
|
||
|
||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||
|-----------|----------|-----------|-------------|-----------------|
|
||
| T-02-04-01 | Tampering | chrome.downloads.download spy left installed after A24 (failure to restore in finally) | mitigate | A24 uses try/finally to restore original chrome.downloads.download; orchestrator bails on first failure, so A25 would observe the spy if A24's finally fails. Add a sentinel test: A25 reads `chrome.downloads.download.toString()` to verify it's the native function (not the proxy). |
|
||
| T-02-04-02 | Repudiation | A25 latency measurement skewed by harness startup overhead | accept | Measurement bookends bracket ONLY the SAVE_ARCHIVE dispatch → ack, NOT the broader test orchestration. setupFreshRecording + segment-settle happen BEFORE the t0 mark. |
|
||
| T-02-04-03 | Information Disclosure | A27 creates real tabs with example.com / iana.org which appear in real Downloads zips | accept | Per "log is internal" charter (CONTEXT.md re-phasing context); test tabs are public sites with no PII; downloadsDir is a per-run mkdtempSync, cleaned up by test runner. |
|
||
| T-02-04-04 | Denial of Service | A27 leaks tabs if cleanup fails (chrome.tabs.remove throws on already-closed tabs) | mitigate | A27's cleanup is wrapped in try/catch (silent-ignore — closing an already-closed tab is benign). |
|
||
| T-02-04-05 | Elevation of Privilege | New harness assertions reveal previously-uncovered chrome.* APIs to the test surface | accept | Approach B (Plan 01-13 SUMMARY): the harness page is a privileged extension-internal page that already has full chrome.* access. A24+A27 use chrome.downloads + chrome.tabs which are part of the extension capability set per manifest.json. NOTE: Plan 02-03 (preceding wave) added `tabs` permission via DEC-011 Amendment 1; A27 in STRICT mode consumes that capability. The amendment is the LOCKED scope addition; no further manifest deltas in this plan. |
|
||
</threat_model>
|
||
|
||
<verification>
|
||
- `npm run build` → clean.
|
||
- `npm run build:test` → clean.
|
||
- `npx tsc --noEmit` → clean.
|
||
- Pre-checkpoint bundle gates (Task 4 Step 1):
|
||
- `tests/background/no-test-hooks-in-prod-bundle.test.ts` → 12 strings, all 0 hits → GREEN.
|
||
- `tests/background/sw-bundle-import.test.ts` → GREEN.
|
||
- `tests/build/no-remote-fonts.test.ts` → GREEN.
|
||
- `tests/build/icons-present.test.ts` → GREEN.
|
||
- `tests/build/fonts-present.test.ts` → GREEN.
|
||
- `tests/i18n/manifest-i18n.test.ts` + `tests/i18n/locale-parity.test.ts` → GREEN.
|
||
- `npm test` (full suite) → GREEN.
|
||
- `HEADLESS=1 npm run test:uat` → 29/29 GREEN (A0-A23 + A24+A25+A26+A27 strict + A28).
|
||
- Operator empirical Task 4 Step 2 → "approved" or surfaces deviations to drive Plan 02-05 follow-up.
|
||
</verification>
|
||
|
||
<success_criteria>
|
||
1. UAT harness 29/29 GREEN (A0-A23 Phase 1 baseline + A24+A25+A26+A27 strict + A28 Phase 2 extension).
|
||
2. A27 STRICT mode: meta.urls contains both example.com AND iana.org (length >= 2 REQUIRED); no extension-origin sentinels (F2).
|
||
3. A28 STRICT mode: zip contains exactly the 5 canonical entries; no extras (REQ-archive-layout).
|
||
4. Vitest full suite GREEN (Phase 1 baseline + Plan 02-01 RED tests now all GREEN post-Plans 02-02 + 02-03 + Plan 02-03 Task 0 manifest i18n test additions).
|
||
5. Tier-1 FORBIDDEN_HOOK_STRINGS = 12 (unchanged — A24+A27+A28 use chrome.* monkey-patch + production APIs).
|
||
6. Pre-checkpoint bundle gates PASS per `feedback-pre-checkpoint-bundle-gates.md`.
|
||
7. Operator empirical UAT cycle 1 ack "approved" OR documented deviations.
|
||
8. Phase 2 closure marker flippable: REQUIREMENTS.md + STATE.md + ROADMAP.md (this last is orchestrator's territory post-checkpoint).
|
||
</success_criteria>
|
||
|
||
<output>
|
||
After completion, create `.planning/phases/02-stabilize-export-pipeline/02-04-SUMMARY.md`
|
||
documenting:
|
||
- 5 new harness assertions (A24+A25+A26+A27 strict + A28) with their check-counts and rationale.
|
||
- A27 strict-mode rationale (DEC-011 Amendment 1 unblocks both-URLs-required contract).
|
||
- A28 REQ-archive-layout strict zip-layout pin (5 entries; cross-references REQ-popup-ui + REQ-screenshot-on-export).
|
||
- Tier-1 FORBIDDEN_HOOK_STRINGS unchanged at 12 (architectural rationale: chrome.* spy / production APIs vs new test-hook symbols).
|
||
- Pre-checkpoint bundle gate run record (each gate result inline).
|
||
- Operator empirical ack quote (verbatim) OR list of deviations + follow-up plan-pointer.
|
||
- Phase 2 closure summary: 4/4 plans landed; vitest + UAT GREEN; P0-6 + P1 #10 closed; meta.json 8-field schema shipped; DEC-011 Amendment 1 landed.
|
||
- Forward link: Phase 3 (SPEC §10 smoke + DOM/event-log verification) inherits the harness as its closure template (mirrors Plan 01-13's role for Phase 2).
|
||
</output>
|