feat(02): plans 01-04 — Phase 2 export pipeline closure (Blob URL + meta.urls + schema + harness)
Wave structure (4 plans, 3 waves): - 02-01 (Wave 1 RED): 15 RED tests pinning D-P2-01 (blob: URL contract), D-P2-02 (meta.urls schema + dedup + filter), D-P2-03 (strict 8-field validation + schemaVersion '2' cutover marker). - 02-02 (Wave 2): Offscreen-minted Blob URL pipeline — extends PortMessageType with CREATE/REVOKE messages; SW downloadArchive rewrite (data: → blob: via base64-on-wire to offscreen + URL.createObjectURL + chrome.downloads.onChanged revoke lifecycle). Closes audit P0-6; unblocks >2 MB archives. - 02-03 (Wave 2): meta.urls schema migration + tab-url-tracker module (chrome.tabs.onActivated + onUpdated → deduplicated, filtered, first-seen- ordered string[]); SessionMetadata 7→8 fields with schemaVersion + urls; REQUIREMENTS.md REQ-meta-json-schema amendment. Closes P1 #10. - 02-04 (Wave 3): UAT harness A24+A25+A26+A27 — blob: URL prefix, <5s SAVE→zip latency, meta.json 8-field shape, multi-tab dedup; pre-checkpoint bundle gates per saved memory + operator empirical UAT cycle 1. Tier-1 FORBIDDEN_HOOK_STRINGS inventory stays at 12 (no new hook symbols — chrome.* monkey-patches + JSZip + production APIs only). Locked decisions honored (per 02-CONTEXT.md): - D-P2-01: offscreen-minted Blob URL via existing keepalivePort + base64 wire format (reuses D-12 precedent at src/shared/binary.ts). - D-P2-02: meta.json url:string → urls:string[]; URL filter per CONTEXT.md <specifics> (include https://, chrome-extension://; exclude chrome://, about:, devtools://, file://); dedup + first-seen ordering. - D-P2-03: full scope; 8-field strict schema validation with schemaVersion='2' as the 8th field (planner-resolved tentative pick; revisable by plan-checker). Architectural constraints preserved: - Always-on charter (Plan 01-09 Amendment 3): no finally-block in saveArchive; no clearTabUrlsSeen on SAVE. - Tier-1 FORBIDDEN_HOOK_STRINGS = 12 (no new test-hook symbols). - Never await import(...) in src/background/index.ts (Plan 01-11 SUMMARY). - Pre-checkpoint bundle gates per feedback-pre-checkpoint-bundle-gates.md (run in 02-04 Task 4 before operator surface). Plan validation: gsd-sdk frontmatter.validate + verify.plan-structure GREEN for all 4 plans. ROADMAP updated: Phase 2 Plans list + Goal/Success Criteria block annotated with D-P2-02/D-P2-03 amendments + 5th success criterion (Blob URL + revoke lifecycle for >2 MB archives); Progress table 0/TBD → 0/4. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -98,8 +98,11 @@ directory + `vite.config.ts` inline string + `src/background/`.
|
|||||||
### Phase 2: Stabilize export pipeline
|
### Phase 2: Stabilize export pipeline
|
||||||
**Goal**: A click on "Сохранить отчёт об ошибке" produces a SPEC-conformant ZIP
|
**Goal**: A click on "Сохранить отчёт об ошибке" produces a SPEC-conformant ZIP
|
||||||
archive on disk in under 5 s, containing a screenshot taken at click time,
|
archive on disk in under 5 s, containing a screenshot taken at click time,
|
||||||
laid out per CON-archive-layout, with `meta.json` per CON-meta-json-schema, and
|
laid out per CON-archive-layout, with `meta.json` per CON-meta-json-schema
|
||||||
declared by a manifest carrying exactly the permission set in DEC-011.
|
(post-2026-05-20 amendment: 8-field shape with `urls: string[]` replacing
|
||||||
|
`url: string` + new `schemaVersion: '2'` cutover marker per D-P2-02 + D-P2-03),
|
||||||
|
and downloaded via an offscreen-minted Blob URL (closes audit P0-6 base64
|
||||||
|
data-URL cap; D-P2-01).
|
||||||
|
|
||||||
**Depends on**: Phase 1 (export consumes the video buffer + the rrweb/event-log
|
**Depends on**: Phase 1 (export consumes the video buffer + the rrweb/event-log
|
||||||
infrastructure already shipped in src/content/index.ts; original "Phase 2 DOM"
|
infrastructure already shipped in src/content/index.ts; original "Phase 2 DOM"
|
||||||
@@ -108,9 +111,10 @@ dependency removed per 2026-05-20 re-phasing).
|
|||||||
**Scope note (2026-05-20):** Plans 01-08 (webm-remux + JSZip), 01-09 (popup
|
**Scope note (2026-05-20):** Plans 01-08 (webm-remux + JSZip), 01-09 (popup
|
||||||
state machine + SAVE-only UI), 01-10 (welcome tab + i18n), and 01-12 (manifest
|
state machine + SAVE-only UI), 01-10 (welcome tab + i18n), and 01-12 (manifest
|
||||||
i18n + en+ru locales) already shipped most of the originally-planned export
|
i18n + en+ru locales) already shipped most of the originally-planned export
|
||||||
surface. Phase 2 likely collapses to 2-3 small plans closing residual gaps:
|
surface. Phase 2 closes the AUDIT residuals: P0-6 (base64 → Blob URL via
|
||||||
screenshot-at-click capture, meta.json schema validation, and SPEC §10 #6
|
offscreen-minted URL.createObjectURL per DEC-006) + P1 #10 (meta.json
|
||||||
<5s export-latency verification.
|
`url:string` → `urls:string[]` schema migration) + strict 8-field schema
|
||||||
|
validation + UAT harness <5s latency assertion (REQ-archive-export-latency).
|
||||||
|
|
||||||
**Requirements**: REQ-popup-ui, REQ-screenshot-on-export, REQ-archive-layout,
|
**Requirements**: REQ-popup-ui, REQ-screenshot-on-export, REQ-archive-layout,
|
||||||
REQ-meta-json-schema, REQ-archive-export-latency, REQ-manifest-permissions
|
REQ-meta-json-schema, REQ-archive-export-latency, REQ-manifest-permissions
|
||||||
@@ -134,14 +138,30 @@ REQ-meta-json-schema, REQ-archive-export-latency, REQ-manifest-permissions
|
|||||||
opening it reveals exactly the layout in REQ-archive-layout
|
opening it reveals exactly the layout in REQ-archive-layout
|
||||||
(`video/last_30sec.webm`, `rrweb/session.json`, `logs/events.json`,
|
(`video/last_30sec.webm`, `rrweb/session.json`, `logs/events.json`,
|
||||||
`screenshot.png`, `meta.json` at the root) with no extra entries.
|
`screenshot.png`, `meta.json` at the root) with no extra entries.
|
||||||
3. `meta.json` validates against the verbatim CON-meta-json-schema (all 7
|
3. `meta.json` validates against the verbatim CON-meta-json-schema (8 fields
|
||||||
fields present, types correct, `timestamp` is ISO-8601 with `Z`).
|
per the 2026-05-20 D-P2-02/D-P2-03 amendment: `schemaVersion`, `timestamp`,
|
||||||
|
`urls`, `userAgent`, `extensionVersion`, `videoBufferSeconds`,
|
||||||
|
`logDurationMinutes`, `totalEvents`; `urls` is a non-empty deduplicated
|
||||||
|
`string[]` of operator tab URLs visited during the 30s window; types
|
||||||
|
correct; `timestamp` is ISO-8601 with `Z`).
|
||||||
4. `manifest.json` in `dist/` after `npm run build` declares exactly the
|
4. `manifest.json` in `dist/` after `npm run build` declares exactly the
|
||||||
permission set in DEC-011 with no additional or missing entries; loading
|
permission set in DEC-011 with no additional or missing entries; loading
|
||||||
unpacked into Chrome produces no permission-related warnings or errors in
|
unpacked into Chrome produces no permission-related warnings or errors in
|
||||||
`chrome://extensions/`.
|
`chrome://extensions/`.
|
||||||
|
5. A real >2 MB archive downloads to disk successfully (the canonical 5-10 MB
|
||||||
|
operator bug-report archive — previously failed via base64 data-URL cap).
|
||||||
|
The chrome.downloads.download call site receives a `blob:` URL (not
|
||||||
|
`data:application/zip;base64,`); URL.revokeObjectURL is dispatched via
|
||||||
|
chrome.downloads.onChanged 'complete' (D-P2-01 lifecycle).
|
||||||
|
|
||||||
|
**Plans**: 4 plans (02-01 through 02-04). Wave 1 RED tests → Wave 2 parallel
|
||||||
|
implementation (Blob URL + meta.urls) → Wave 3 harness extension + operator
|
||||||
|
empirical checkpoint.
|
||||||
|
- [ ] 02-01-PLAN.md — Wave 0 RED tests: blob-url-download.test.ts + meta-json-urls-schema.test.ts + strict-meta-json-validation.test.ts pinning D-P2-01/D-P2-02/D-P2-03 contracts (TDD)
|
||||||
|
- [ ] 02-02-PLAN.md — Wave 1 Blob URL pipeline (D-P2-01, closes P0-6): offscreen CREATE/REVOKE handlers via base64-on-wire; SW downloadArchive rewrite; chrome.downloads.onChanged revoke lifecycle
|
||||||
|
- [ ] 02-03-PLAN.md — Wave 1 meta.urls + tab-url-tracker (D-P2-02 + D-P2-03, closes P1 #10): SessionMetadata 7→8 fields with schemaVersion+urls; chrome.tabs.onActivated/onUpdated listeners; REQUIREMENTS.md REQ-meta-json-schema amendment
|
||||||
|
- [ ] 02-04-PLAN.md — Wave 2 harness A24+A25+A26+A27 + operator empirical checkpoint: blob:URL prefix, <5s SAVE→zip latency, meta.json 8-field shape, multi-tab dedup; pre-checkpoint bundle gates + operator UAT cycle 1
|
||||||
|
|
||||||
**Plans**: TBD
|
|
||||||
**UI hint**: yes
|
**UI hint**: yes
|
||||||
|
|
||||||
### Phase 3: SPEC §10 smoke verification + DOM/event-log verification
|
### Phase 3: SPEC §10 smoke verification + DOM/event-log verification
|
||||||
@@ -247,6 +267,6 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5.
|
|||||||
| Phase | Plans Complete | Status | Completed |
|
| Phase | Plans Complete | Status | Completed |
|
||||||
|-------|----------------|--------|-----------|
|
|-------|----------------|--------|-----------|
|
||||||
| 1. Stabilize video pipeline | 14/14 | **CLOSED 2026-05-20** via gsd-verifier audit GREEN (17/17 must-haves; commit 586836f); all markers flipped | Functional contract closed 2026-05-19 via Plan 01-13 harness PASS; design/brand contract closed 2026-05-20 via Plan 01-12 brand-fit ack; welcome-tab contract closed 2026-05-20 via Plan 01-10 cycle-2 operator ack "All good" + 5 inter-cycle debug fixes |
|
| 1. Stabilize video pipeline | 14/14 | **CLOSED 2026-05-20** via gsd-verifier audit GREEN (17/17 must-haves; commit 586836f); all markers flipped | Functional contract closed 2026-05-19 via Plan 01-13 harness PASS; design/brand contract closed 2026-05-20 via Plan 01-12 brand-fit ack; welcome-tab contract closed 2026-05-20 via Plan 01-10 cycle-2 operator ack "All good" + 5 inter-cycle debug fixes |
|
||||||
| 2. Stabilize export pipeline | 0/TBD | Not started (narrowed scope post-2026-05-20 re-phasing; ~2-3 plans expected) | - |
|
| 2. Stabilize export pipeline | 0/4 | Plans landed 2026-05-20 (4 plans: Wave 0 RED → Wave 1 Blob URL + meta.urls parallel → Wave 2 harness + operator checkpoint); execution pending | - |
|
||||||
| 3. SPEC §10 smoke + DOM/event-log verification | 0/TBD | Not started (absorbed Phase-2 DOM verification per 2026-05-20 re-phasing; ~2-3 plans) | - |
|
| 3. SPEC §10 smoke + DOM/event-log verification | 0/TBD | Not started (absorbed Phase-2 DOM verification per 2026-05-20 re-phasing; ~2-3 plans) | - |
|
||||||
| 4. Harden + clean up (optional) | 0/TBD | Not started (deferred backlog: cursor visibility, dark-surface logo, tabs perm gap, ffprobe flakes, ROADMAP backfill, rrweb-version upgrade research, REQ-password-confidentiality v2 candidate) | - |
|
| 4. Harden + clean up (optional) | 0/TBD | Not started (deferred backlog: cursor visibility, dark-surface logo, tabs perm gap, ffprobe flakes, ROADMAP backfill, rrweb-version upgrade research, REQ-password-confidentiality v2 candidate) | - |
|
||||||
|
|||||||
376
.planning/phases/02-stabilize-export-pipeline/02-01-PLAN.md
Normal file
376
.planning/phases/02-stabilize-export-pipeline/02-01-PLAN.md
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
---
|
||||||
|
phase: 02-stabilize-export-pipeline
|
||||||
|
plan: 01
|
||||||
|
type: tdd
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- tests/background/blob-url-download.test.ts
|
||||||
|
- tests/background/meta-json-urls-schema.test.ts
|
||||||
|
- tests/build/strict-meta-json-validation.test.ts
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- REQ-archive-export-latency
|
||||||
|
- REQ-meta-json-schema
|
||||||
|
tags:
|
||||||
|
- tdd
|
||||||
|
- red-tests
|
||||||
|
- blob-url-migration
|
||||||
|
- meta-json-urls-array
|
||||||
|
- schema-validation
|
||||||
|
- p0-6
|
||||||
|
- p1-10
|
||||||
|
- wave-0
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Three RED test files exist and fail under the current HEAD (cc042a5) with concrete failure messages, NOT timeouts or missing imports."
|
||||||
|
- "blob-url-download.test.ts pins downloadArchive() calling chrome.downloads.download with url starting with 'blob:' (not 'data:application/zip;base64,')."
|
||||||
|
- "meta-json-urls-schema.test.ts pins SessionMetadata.urls as string[] and createArchive emitting meta.json with urls (not url)."
|
||||||
|
- "strict-meta-json-validation.test.ts pins ISO-8601 Z-suffix timestamp, non-empty urls[], semver extensionVersion, non-negative totalEvents, exactly 8 fields."
|
||||||
|
- "All 3 files use Vitest describe+it patterns matching existing tests/background/*.test.ts conventions (vi.fn() chrome.* stubs; jsdom or node env per existing patterns)."
|
||||||
|
- "Tier-1 FORBIDDEN_HOOK_STRINGS unit-test gate remains GREEN (12 entries unchanged — this plan adds no test-hook symbols)."
|
||||||
|
- "153/153 baseline vitest count rises to 153 + N (where N = number of new it() blocks; expected ~10-14)."
|
||||||
|
artifacts:
|
||||||
|
- path: "tests/background/blob-url-download.test.ts"
|
||||||
|
provides: "RED pin: downloadArchive calls chrome.downloads.download({ url: blob:..., filename })"
|
||||||
|
min_lines: 80
|
||||||
|
contains: "describe.*blob.*url|chrome.downloads.download"
|
||||||
|
- path: "tests/background/meta-json-urls-schema.test.ts"
|
||||||
|
provides: "RED pin: SessionMetadata.urls: string[] + createArchive emits meta.json.urls"
|
||||||
|
min_lines: 60
|
||||||
|
contains: "urls.*string\\[\\]|meta\\.json"
|
||||||
|
- path: "tests/build/strict-meta-json-validation.test.ts"
|
||||||
|
provides: "RED pin: 8-field schema with ISO-8601 + semver + non-empty urls[] + non-negative totalEvents"
|
||||||
|
min_lines: 80
|
||||||
|
contains: "ISO-8601|semver|urls"
|
||||||
|
key_links:
|
||||||
|
- from: "tests/background/blob-url-download.test.ts"
|
||||||
|
to: "src/background/index.ts:downloadArchive"
|
||||||
|
via: "vi.mocked(chrome.downloads.download) inspection of first-call args[0].url prefix"
|
||||||
|
pattern: "chrome\\.downloads\\.download.*url.*blob:"
|
||||||
|
- from: "tests/background/meta-json-urls-schema.test.ts"
|
||||||
|
to: "src/background/index.ts:createArchive + src/shared/types.ts:SessionMetadata"
|
||||||
|
via: "createArchive() returns Blob; unzip via JSZip; read meta.json; assert .urls is Array, .url is undefined"
|
||||||
|
pattern: "metadata\\.urls|metadata\\.url"
|
||||||
|
- from: "tests/build/strict-meta-json-validation.test.ts"
|
||||||
|
to: "meta.json wire shape (post-Plan 02-03)"
|
||||||
|
via: "regex assertions on timestamp / version / totalEvents + Array.isArray(urls) + Object.keys(meta).length === 8"
|
||||||
|
pattern: "Object\\.keys.*length.*8|Array\\.isArray.*urls"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Wave 0 RED gate for Phase 2: three failing test files that pin the locked
|
||||||
|
decisions D-P2-01 (Blob URL download), D-P2-02 (meta.json urls[] migration),
|
||||||
|
and D-P2-03's schema-validation workstream. These tests MUST be RED at the
|
||||||
|
end of this plan and MUST flip GREEN as Plans 02-02 and 02-03 land
|
||||||
|
implementation.
|
||||||
|
|
||||||
|
Purpose: pin the contracts BEFORE any production code changes (TDD discipline
|
||||||
|
matching Plan 01-02 Wave 0 precedent). Without RED tests committed first,
|
||||||
|
nothing forces D-P2-01/D-P2-02/D-P2-03 to be implemented exactly as locked —
|
||||||
|
silent partial implementation could land otherwise.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
- tests/background/blob-url-download.test.ts (RED — pins D-P2-01)
|
||||||
|
- tests/background/meta-json-urls-schema.test.ts (RED — pins D-P2-02)
|
||||||
|
- tests/build/strict-meta-json-validation.test.ts (RED — pins D-P2-03 schema)
|
||||||
|
- vitest count rises by N RED tests (acceptable RED window — closed by Plans 02-02 + 02-03 within the same Phase 2 execution)
|
||||||
|
</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
|
||||||
|
|
||||||
|
# Source under test
|
||||||
|
@src/background/index.ts
|
||||||
|
@src/shared/types.ts
|
||||||
|
@src/shared/binary.ts
|
||||||
|
|
||||||
|
# Precedent for RED test style (Plan 01-02 Wave 0 + Plan 01-08 webm-remux deps test)
|
||||||
|
@.planning/phases/01-stabilize-video-pipeline/01-08-SUMMARY.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Contracts the tests will read from / write expectations against.
|
||||||
|
Embedded so test authors don't go on a codebase scavenger hunt. -->
|
||||||
|
|
||||||
|
From src/shared/types.ts (CURRENT — pre-Plan-02-03):
|
||||||
|
```typescript
|
||||||
|
export interface SessionMetadata {
|
||||||
|
timestamp: string;
|
||||||
|
url: string; // ← D-P2-02 MIGRATES THIS to urls: string[]
|
||||||
|
userAgent: string;
|
||||||
|
extensionVersion: string;
|
||||||
|
videoBufferSeconds: number;
|
||||||
|
logDurationMinutes: number;
|
||||||
|
totalEvents: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/background/index.ts:695-718 (CURRENT downloadArchive — pre-Plan-02-02):
|
||||||
|
```typescript
|
||||||
|
async function downloadArchive(archiveBlob: Blob) {
|
||||||
|
const filename = `session_report_${dateStr}_${timeStr}.zip`;
|
||||||
|
const base64 = await blobToBase64(archiveBlob);
|
||||||
|
const url = `data:application/zip;base64,${base64}`; // ← D-P2-01 MIGRATES THIS to blob:URL
|
||||||
|
await chrome.downloads.download({ url, filename, saveAs: false });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/background/index.ts:608-692 (CURRENT createArchive — pre-Plan-02-03):
|
||||||
|
```typescript
|
||||||
|
async function createArchive(videoBufferResponse, rrwebEvents, userEvents, screenshot): Promise<Blob> {
|
||||||
|
// ... assembles zip ...
|
||||||
|
const metadata: SessionMetadata = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
url: new URL(chrome.runtime.getURL('')).origin, // ← D-P2-02 MIGRATES TO urls: string[]
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
extensionVersion: manifest.version,
|
||||||
|
videoBufferSeconds: 30,
|
||||||
|
logDurationMinutes: 10,
|
||||||
|
totalEvents: rrwebEvents.length + userEvents.length
|
||||||
|
};
|
||||||
|
zip.file('meta.json', JSON.stringify(metadata, null, 2));
|
||||||
|
const archiveBlob = await zip.generateAsync({ type: 'blob' });
|
||||||
|
return archiveBlob;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing test conventions (read these for stub patterns):
|
||||||
|
- `tests/background/save-archive-does-not-stop-recording.test.ts` — chrome.runtime + chrome.downloads stub pattern
|
||||||
|
- `tests/background/webm-remux.test.ts` — JSZip + Blob unzip pattern
|
||||||
|
- `tests/build/no-remote-fonts.test.ts` — post-build dist/ scan pattern (model for strict-meta-json-validation.test.ts STRUCTURE; this plan's variant scans createArchive's output, not dist/)
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="tdd" tdd="true">
|
||||||
|
<name>Task 1: RED — blob-url-download.test.ts pins D-P2-01</name>
|
||||||
|
<files>tests/background/blob-url-download.test.ts</files>
|
||||||
|
<behavior>
|
||||||
|
- Test 1 ("downloadArchive calls chrome.downloads.download with a blob: URL, NOT a data:application/zip;base64, URL"):
|
||||||
|
stub chrome.downloads.download via vi.fn(); invoke downloadArchive(testBlob); assert
|
||||||
|
mockDownload.mock.calls[0][0].url starts with 'blob:' AND does NOT start with 'data:application/zip;base64,'.
|
||||||
|
- Test 2 ("downloadArchive completes within 5000ms for a 6 MB archive"):
|
||||||
|
build a synthetic 6 MB Blob (above Chrome's ~2 MB data-URL cap that motivates D-P2-01);
|
||||||
|
measure performance.now() before/after downloadArchive; assert delta < 5000.
|
||||||
|
- Test 3 ("URL.revokeObjectURL is scheduled for the minted URL after chrome.downloads.onChanged 'complete' event"):
|
||||||
|
stub chrome.downloads.onChanged.addListener via vi.fn(); invoke downloadArchive(testBlob);
|
||||||
|
simulate the listener callback firing with {state: {current: 'complete'}, id: <downloadId>};
|
||||||
|
assert URL.revokeObjectURL was called with the same url passed to chrome.downloads.download.
|
||||||
|
- All 3 tests MUST fail under current HEAD with `url` starting with `data:application/zip;base64,`
|
||||||
|
(Test 1), exceeding the 5000ms budget for the 6 MB case OR throwing "Network error" if Chrome's
|
||||||
|
data-URL cap rejects (Test 2 — accept either failure mode as the RED signal), and NEVER calling
|
||||||
|
URL.revokeObjectURL because the current path doesn't mint one (Test 3).
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Create `tests/background/blob-url-download.test.ts` per the behavior block above.
|
||||||
|
|
||||||
|
Follow `tests/background/save-archive-does-not-stop-recording.test.ts` for the chrome.* stub pattern
|
||||||
|
(vi.stubGlobal('chrome', { runtime: ..., downloads: { download: vi.fn(), onChanged: { addListener: vi.fn() } } })).
|
||||||
|
Use `import { downloadArchive } from '../../src/background/index'` ONLY IF downloadArchive is exported;
|
||||||
|
if NOT, the test imports an internal-helper-export OR uses the saveArchive path with chrome.downloads.download
|
||||||
|
spy — pick whichever the existing save-archive test uses. Document the choice in a comment block.
|
||||||
|
|
||||||
|
Use jsdom env (default in vitest.config.ts) so URL.createObjectURL + URL.revokeObjectURL are available.
|
||||||
|
|
||||||
|
For Test 2's 6 MB blob: `new Blob([new Uint8Array(6 * 1024 * 1024)], { type: 'application/zip' })`.
|
||||||
|
|
||||||
|
Per D-P2-01 — this test pins downloadArchive's WIRE FORMAT (blob: vs data:), the LATENCY
|
||||||
|
PROPERTY (which only Blob URL achieves for >2 MB archives), and the LIFECYCLE
|
||||||
|
(URL.revokeObjectURL hooked to chrome.downloads.onChanged 'complete'). The implementation
|
||||||
|
in Plan 02-02 will mint the Blob URL inside the offscreen document (D-P2-01) and pass it
|
||||||
|
back to the SW via a new port-bridge message; this test does not encode HOW the URL is
|
||||||
|
minted, only WHAT the SW downloadArchive call site sends to chrome.downloads.download.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>npx vitest run tests/background/blob-url-download.test.ts 2>&1 | grep -E "(FAIL|RED|failed)" | head -5</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
File `tests/background/blob-url-download.test.ts` exists, exports 3 it() blocks under a
|
||||||
|
single describe, ALL 3 fail with concrete assertion errors (NOT import errors / NOT timeouts).
|
||||||
|
`npx vitest run tests/background/blob-url-download.test.ts --reporter=verbose` shows
|
||||||
|
3 failed tests with assertion messages identifying the wrong url prefix / latency / missing revoke.
|
||||||
|
Commit message: `test(02-01): RED — pin Blob URL download contract (D-P2-01)`.
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="tdd" tdd="true">
|
||||||
|
<name>Task 2: RED — meta-json-urls-schema.test.ts pins D-P2-02</name>
|
||||||
|
<files>tests/background/meta-json-urls-schema.test.ts</files>
|
||||||
|
<behavior>
|
||||||
|
- Test 1 ("SessionMetadata.urls is string[], SessionMetadata.url does not exist"):
|
||||||
|
`import type { SessionMetadata } from '../../src/shared/types'`; use a `satisfies` check
|
||||||
|
OR a `const _check: SessionMetadata['urls'] extends string[] ? true : false = true` pattern
|
||||||
|
so the test is compile-time-checked AND runtime-introspectable. RED state: TypeScript
|
||||||
|
tsc compile of this test currently fails because `urls` is not on SessionMetadata yet.
|
||||||
|
Wrap the type-level check in a runtime `expect(true).toBe(true)` so the failure mode is
|
||||||
|
"tsc compile failure" → flips GREEN after Plan 02-03 amends types.ts.
|
||||||
|
- Test 2 ("createArchive emits meta.json with `urls: string[]`, not `url: string`"):
|
||||||
|
stub chrome.runtime.getManifest + navigator.userAgent + the offscreen video-buffer fetch
|
||||||
|
(return a minimal 1-segment VideoBufferResponse so remux doesn't blow up — reuse pattern
|
||||||
|
from webm-remux.test.ts); invoke createArchive(); extract meta.json from the returned
|
||||||
|
Blob via JSZip.loadAsync + zip.file('meta.json').async('string') + JSON.parse;
|
||||||
|
assert `Array.isArray(meta.urls) === true` AND `meta.url === undefined`.
|
||||||
|
- Test 3 ("meta.json.urls is deduplicated and ordered first-seen-first"):
|
||||||
|
mock the tab-tracking source (chrome.tabs.query OR a yet-to-be-implemented module helper —
|
||||||
|
see "module seam" note below); inject 3 tab URL events: A, B, A;
|
||||||
|
assert `meta.urls` equals `['A', 'B']` (dedup; A appears once; first-seen order preserved).
|
||||||
|
- Test 4 ("chrome:// and about: URLs are filtered out; chrome-extension:// included"):
|
||||||
|
inject 4 events: 'https://example.com', 'chrome://newtab', 'about:blank',
|
||||||
|
'chrome-extension://abc/popup.html'; assert `meta.urls` equals
|
||||||
|
`['https://example.com', 'chrome-extension://abc/popup.html']` (per CONTEXT.md
|
||||||
|
"URL filter" specifics block).
|
||||||
|
- All 4 tests MUST fail under current HEAD: Test 1 via tsc compile failure (urls missing on type);
|
||||||
|
Test 2 via `meta.url` present + `meta.urls` undefined; Test 3+4 via the tab-tracking module
|
||||||
|
not existing yet (these will throw import errors — acceptable as the RED signal).
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Create `tests/background/meta-json-urls-schema.test.ts` per the behavior block above.
|
||||||
|
|
||||||
|
Module seam for tab tracking: assume Plan 02-03 will introduce a new module (planner-suggested name:
|
||||||
|
`src/background/tab-url-tracker.ts`) exporting `getTabUrlsSeen(): string[]`. The test imports
|
||||||
|
this seam by name; the absence of the module at the current HEAD is the RED signal for Tests 3+4.
|
||||||
|
Document the seam name + expected export in a block comment at the top of the test file so Plan
|
||||||
|
02-03 implements against the same shape.
|
||||||
|
|
||||||
|
For Test 2's createArchive invocation: pattern after `tests/background/webm-remux.test.ts` and
|
||||||
|
`tests/background/save-archive-does-not-stop-recording.test.ts` — stub VideoBufferResponse with
|
||||||
|
a single 1-byte segment (createArchive's remux path needs at least 1 to not throw
|
||||||
|
EmptyVideoBufferError; use a pre-baked fixture Blob OR mock remuxSegments via vi.spyOn).
|
||||||
|
|
||||||
|
Per D-P2-02 — this test pins the SHAPE (urls: string[] not url: string), the DEDUP+ORDER semantics
|
||||||
|
(first-seen-first), and the FILTER (exclude chrome:// + about:; include chrome-extension://) verbatim
|
||||||
|
from CONTEXT.md `<specifics>` block. The implementation in Plan 02-03 implements the tab-tracker
|
||||||
|
+ amends types.ts + amends createArchive's meta.json construction.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>npx vitest run tests/background/meta-json-urls-schema.test.ts 2>&1 | grep -E "(FAIL|RED|failed|TS\d+)" | head -10</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
File exists, 4 it() blocks under a single describe. Test 1 fails via tsc compile error on missing
|
||||||
|
`urls` field. Tests 2-4 fail with assertion errors OR import errors (latter for the missing
|
||||||
|
tab-url-tracker module). NONE pass under current HEAD.
|
||||||
|
Commit message: `test(02-01): RED — pin meta.json urls[] schema + dedup/filter semantics (D-P2-02)`.
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="tdd" tdd="true">
|
||||||
|
<name>Task 3: RED — strict-meta-json-validation.test.ts pins D-P2-03 schema</name>
|
||||||
|
<files>tests/build/strict-meta-json-validation.test.ts</files>
|
||||||
|
<behavior>
|
||||||
|
- Test 1 ("meta.json has exactly 8 fields"): Object.keys(meta).length === 8.
|
||||||
|
- Test 2 ("timestamp is ISO-8601 with Z suffix"):
|
||||||
|
`/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/.test(meta.timestamp)`.
|
||||||
|
- Test 3 ("urls is a non-empty string[] of valid URLs"):
|
||||||
|
`Array.isArray(meta.urls) && meta.urls.length >= 1 && meta.urls.every(u => /^(https?|chrome-extension):\/\//.test(u))`.
|
||||||
|
Note: CONTEXT.md `<specifics>` allows empty urls[] for "purely whole-desktop recording without
|
||||||
|
browser-tab interaction" — but D-P2-02 also says "the operator's primary tab at SAVE time should
|
||||||
|
always be in the array if it has a valid URL", and CONTEXT.md `<decisions>` D-P2-03 mandates
|
||||||
|
"urls array non-empty" as a schema validation rule. Resolve in favor of the strict-validation
|
||||||
|
rule from D-P2-03 (non-empty required). Document the resolution inline in the test as a
|
||||||
|
block comment citing both CONTEXT.md sources.
|
||||||
|
- Test 4 ("extensionVersion matches semver"): `/^\d+\.\d+\.\d+$/.test(meta.extensionVersion)`.
|
||||||
|
- Test 5 ("totalEvents is non-negative integer"):
|
||||||
|
`Number.isInteger(meta.totalEvents) && meta.totalEvents >= 0`.
|
||||||
|
- Test 6 ("videoBufferSeconds is exactly 30"): per CON-meta-json-schema verbatim.
|
||||||
|
- Test 7 ("logDurationMinutes is exactly 10"): per CON-meta-json-schema verbatim.
|
||||||
|
- Test 8 ("no extra fields"): Object.keys(meta).every(k => EXPECTED_KEYS.includes(k))
|
||||||
|
where EXPECTED_KEYS = ['timestamp','urls','userAgent','extensionVersion','videoBufferSeconds','logDurationMinutes','totalEvents'] PLUS one of {schemaVersion?, archiveVersion?} ← reserve one slot for the 8th field. The current schema has 7; D-P2-03 says "8 fields exact". DECIDED INLINE: the 8th field is `urls` REPLACING `url` (net +0 → still 7 fields), BUT D-P2-03 explicitly says "8 fields exact". RESOLUTION: read the test as the strict-validation TARGET; the missing 8th field is the SHIPPING DECISION that Plan 02-03 must make and implement (planner suggests `schemaVersion: '2'` to mark the breaking-change cutover; documented as the implementer-choice in Plan 02-03). The RED test file embeds the EXPECTED_KEYS list including `schemaVersion` so Plan 02-03 has zero ambiguity.
|
||||||
|
- All 8 tests MUST fail under current HEAD (the createArchive meta.json shape doesn't match
|
||||||
|
most assertions — it has `url:string`, no `urls`, no `schemaVersion`, and 7 fields).
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Create `tests/build/strict-meta-json-validation.test.ts` per the behavior block above.
|
||||||
|
|
||||||
|
Pattern after `tests/build/no-remote-fonts.test.ts` (a `tests/build/` validator) and
|
||||||
|
`tests/background/save-archive-does-not-stop-recording.test.ts` (createArchive invocation +
|
||||||
|
JSZip extract pattern). The test invokes createArchive() via the same path as Task 2's
|
||||||
|
`meta-json-urls-schema.test.ts`, extracts meta.json, JSON.parses it, and runs all 8 strict
|
||||||
|
schema checks.
|
||||||
|
|
||||||
|
The EXPECTED_KEYS constant at the top of the test:
|
||||||
|
```typescript
|
||||||
|
const EXPECTED_KEYS = [
|
||||||
|
'timestamp', 'urls', 'userAgent', 'extensionVersion',
|
||||||
|
'videoBufferSeconds', 'logDurationMinutes', 'totalEvents', 'schemaVersion',
|
||||||
|
];
|
||||||
|
```
|
||||||
|
The 8th field `schemaVersion` is the planner-suggested marker for the D-P2-02 url→urls
|
||||||
|
breaking-change cutover. Plan 02-03's implementer COMMITS to adding `schemaVersion: '2'`
|
||||||
|
as a constant in src/background/index.ts; the test pins this so Plan 02-03 cannot land
|
||||||
|
a 7-field meta.json that satisfies Tests 1-7 but fails the "exactly 8 fields" + "no extra fields"
|
||||||
|
pair. (Plan-checker may revise this — the 8th field name is a TENTATIVE PLANNER PICK;
|
||||||
|
if Plan 02-03's implementer or Plan 02-04 has a stronger argument for a different 8th
|
||||||
|
field name, this test file becomes the canonical place to amend.)
|
||||||
|
|
||||||
|
Add a block comment at the top citing CONTEXT.md D-P2-03 + REQUIREMENTS.md REQ-meta-json-schema
|
||||||
|
as the contract source, and noting the planner-resolved tension between Tests 3's non-empty
|
||||||
|
rule (per D-P2-03) and the CONTEXT.md `<specifics>` permissive empty-array clause.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>npx vitest run tests/build/strict-meta-json-validation.test.ts 2>&1 | grep -E "(FAIL|RED|failed)" | head -10</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
File exists, 8 it() blocks under a single describe. ALL 8 fail (current meta.json has 7 fields
|
||||||
|
+ `url:string` instead of `urls:string[]` + no `schemaVersion`).
|
||||||
|
Commit message: `test(02-01): RED — pin strict 8-field meta.json schema validation (D-P2-03)`.
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| test author → test runtime | Test code controls chrome.* stubs; only the SW/offscreen surfaces under test are exercised |
|
||||||
|
| createArchive output → JSZip parse | meta.json wire shape; D-P2-03 schema validation prevents downstream consumers (UAT harness, future SRV-* upload) from receiving malformed metadata |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-02-01-01 | Tampering | RED test as silent-skip risk (skip(true) / it.skip pattern slipping in) | mitigate | All 15 tests in this plan use bare `it()` + concrete expect()s; no `describe.skip` or `it.skip`. Plan-checker verifies grep -c "\.skip" $files == 0. |
|
||||||
|
| T-02-01-02 | Repudiation | RED tests committed without RED proof (CI shows GREEN because test imports failed silently) | mitigate | Task verify> blocks run `npx vitest run <file>` and grep for "FAIL" — proves RED state at commit time. Plan 02-02 + 02-03 implementers prove GREEN-flip via same grep with "PASS". |
|
||||||
|
| T-02-01-03 | Information Disclosure | Test creates real `meta.json` containing real `chrome.runtime.id` extension origin URL | accept | Tests run in jsdom + stubbed chrome.runtime.getURL returning fake 'https://test/'. Real origin never reaches test fixtures. |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `npx vitest run tests/background/blob-url-download.test.ts` → 3 RED.
|
||||||
|
- `npx vitest run tests/background/meta-json-urls-schema.test.ts` → 4 RED.
|
||||||
|
- `npx vitest run tests/build/strict-meta-json-validation.test.ts` → 8 RED.
|
||||||
|
- `npm test` → previously-153 GREEN + N RED (15 ± 1 RED from this plan). Acceptable window —
|
||||||
|
Plans 02-02 + 02-03 close all RED before phase closure.
|
||||||
|
- `grep -c '\.skip' tests/background/blob-url-download.test.ts tests/background/meta-json-urls-schema.test.ts tests/build/strict-meta-json-validation.test.ts` → 0.
|
||||||
|
- Tier-1 grep gate: `npx vitest run tests/background/no-test-hooks-in-prod-bundle.test.ts` GREEN
|
||||||
|
(unchanged at 12 FORBIDDEN_HOOK_STRINGS — this plan touches NO production code, only test fixtures).
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
1. Three RED test files exist with concrete failure messages under current HEAD.
|
||||||
|
2. `meta-json-urls-schema.test.ts` Test 1 fails at the TypeScript-compile layer (proves the type
|
||||||
|
migration is genuinely required, not just a runtime field rename).
|
||||||
|
3. Tier-1 grep gate (12 FORBIDDEN_HOOK_STRINGS) remains GREEN.
|
||||||
|
4. UAT harness (24/24 GREEN baseline) UNCHANGED by this plan (test-only changes).
|
||||||
|
5. Commits land with `test(02-01): RED — ...` prefix matching Plan 01-02 / 01-13 RED-test precedent.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/02-stabilize-export-pipeline/02-01-SUMMARY.md`
|
||||||
|
documenting:
|
||||||
|
- RED test count delta (153 GREEN + N RED new)
|
||||||
|
- Specific failure-mode-per-test diagnostic strings
|
||||||
|
- Planner-resolved tensions: D-P2-03 "non-empty urls[]" vs CONTEXT.md `<specifics>` permissive
|
||||||
|
empty array (resolved IN FAVOR of strict rule); 8th field name (`schemaVersion` tentative pick).
|
||||||
|
- Forward link: tests flip GREEN in Plans 02-02 + 02-03.
|
||||||
|
</output>
|
||||||
498
.planning/phases/02-stabilize-export-pipeline/02-02-PLAN.md
Normal file
498
.planning/phases/02-stabilize-export-pipeline/02-02-PLAN.md
Normal file
@@ -0,0 +1,498 @@
|
|||||||
|
---
|
||||||
|
phase: 02-stabilize-export-pipeline
|
||||||
|
plan: 02
|
||||||
|
type: auto
|
||||||
|
wave: 2
|
||||||
|
depends_on: [01]
|
||||||
|
files_modified:
|
||||||
|
- src/offscreen/recorder.ts
|
||||||
|
- src/shared/types.ts
|
||||||
|
- src/background/index.ts
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- REQ-archive-export-latency
|
||||||
|
tags:
|
||||||
|
- blob-url-migration
|
||||||
|
- p0-6-fix
|
||||||
|
- offscreen-port-bridge
|
||||||
|
- chrome-downloads-onchanged
|
||||||
|
- url-createObjectURL
|
||||||
|
- url-revokeObjectURL
|
||||||
|
- d-p2-01
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "downloadArchive in src/background/index.ts no longer constructs a `data:application/zip;base64,` URL; it requests a Blob URL from offscreen via the existing long-lived port (D-17) and passes that to chrome.downloads.download."
|
||||||
|
- "Offscreen recorder.ts handles a new PortMessageType variant (CREATE_DOWNLOAD_URL + DOWNLOAD_URL) that mints `URL.createObjectURL(blob)` and returns the URL string to SW."
|
||||||
|
- "Archive Blob travels SW→offscreen via base64-on-wire (reusing src/shared/binary.ts blobToBase64 + base64ToBlob — same wire-format precedent as D-12 video segments)."
|
||||||
|
- "chrome.downloads.onChanged listener wired in SW: when state.current==='complete' or 'interrupted' for the tracked downloadId, dispatch URL.revokeObjectURL to offscreen via the same port (REVOKE_DOWNLOAD_URL message)."
|
||||||
|
- "A 6 MB archive (above Chrome's ~2 MB data-URL cap) downloads to disk without 'Network error' or any failure; tests/background/blob-url-download.test.ts Test 2 GREEN."
|
||||||
|
- "All 3 tests in tests/background/blob-url-download.test.ts flip RED→GREEN."
|
||||||
|
- "Tier-1 FORBIDDEN_HOOK_STRINGS gate remains at 12 entries (this plan adds NO test-hook surfaces; the new port messages are PRODUCTION surface — they ride existing keepalivePort in the same way REQUEST_BUFFER/BUFFER do)."
|
||||||
|
- "UAT harness 24/24 GREEN preserved (no harness-level changes in this plan; A5 + A12 download-flow assertions empirically prove the Blob URL path end-to-end)."
|
||||||
|
- "Always-on charter preserved: SAVE creates a zip; recorder stays in REC. No finally-block state reset in saveArchive (Plan 01-09 Amendment 3 invariant)."
|
||||||
|
artifacts:
|
||||||
|
- path: "src/offscreen/recorder.ts"
|
||||||
|
provides: "CREATE_DOWNLOAD_URL + REVOKE_DOWNLOAD_URL message handlers on existing keepalivePort"
|
||||||
|
contains: "URL\\.createObjectURL|URL\\.revokeObjectURL|CREATE_DOWNLOAD_URL"
|
||||||
|
- path: "src/shared/types.ts"
|
||||||
|
provides: "PortMessageType union extended with CREATE_DOWNLOAD_URL + DOWNLOAD_URL + REVOKE_DOWNLOAD_URL"
|
||||||
|
contains: "CREATE_DOWNLOAD_URL|DOWNLOAD_URL"
|
||||||
|
- path: "src/background/index.ts"
|
||||||
|
provides: "downloadArchive rewritten to bridge through offscreen for URL.createObjectURL + chrome.downloads.onChanged listener for revoke"
|
||||||
|
contains: "chrome\\.downloads\\.onChanged|REVOKE_DOWNLOAD_URL|blob:"
|
||||||
|
key_links:
|
||||||
|
- from: "src/background/index.ts:downloadArchive"
|
||||||
|
to: "src/offscreen/recorder.ts (via keepalivePort)"
|
||||||
|
via: "port.postMessage({ type: 'CREATE_DOWNLOAD_URL', requestId, dataBase64 }) then await DOWNLOAD_URL response"
|
||||||
|
pattern: "CREATE_DOWNLOAD_URL.*requestId|DOWNLOAD_URL.*requestId"
|
||||||
|
- from: "src/background/index.ts:chrome.downloads.onChanged listener"
|
||||||
|
to: "src/offscreen/recorder.ts REVOKE_DOWNLOAD_URL handler"
|
||||||
|
via: "delta.state.current === 'complete' → port.postMessage({ type: 'REVOKE_DOWNLOAD_URL', url })"
|
||||||
|
pattern: "chrome\\.downloads\\.onChanged\\.addListener"
|
||||||
|
- from: "src/offscreen/recorder.ts:onPortMessage"
|
||||||
|
to: "URL.createObjectURL"
|
||||||
|
via: "case CREATE_DOWNLOAD_URL: decode base64ToBlob → URL.createObjectURL(blob) → respond { type: 'DOWNLOAD_URL', requestId, url }"
|
||||||
|
pattern: "URL\\.createObjectURL"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Migrate the archive download path from base64 `data:` URL (current src/background/index.ts:709-710)
|
||||||
|
to an offscreen-minted `blob:` URL per D-P2-01. The SW cannot call `URL.createObjectURL` (DEC-006);
|
||||||
|
the offscreen document can. Wire a new port-bridge that ships the archive Blob SW→offscreen
|
||||||
|
(reusing the D-12 base64 wire-format from src/shared/binary.ts), mints the URL in offscreen,
|
||||||
|
returns the URL string to SW, and the SW invokes chrome.downloads.download with that URL.
|
||||||
|
Wire chrome.downloads.onChanged to dispatch URL.revokeObjectURL after download completion.
|
||||||
|
|
||||||
|
Purpose: closes the audit P0-6 + lifts the ~2 MB data-URL cap that currently breaks the canonical
|
||||||
|
5-10 MB operator bug-report archive (CONTEXT.md `<specifics>` "Real-archive-size assumption").
|
||||||
|
|
||||||
|
Output:
|
||||||
|
- 3 source-code modifications in src/offscreen/recorder.ts, src/shared/types.ts, src/background/index.ts.
|
||||||
|
- All 3 tests in tests/background/blob-url-download.test.ts flip RED→GREEN.
|
||||||
|
- 153 + N (from Plan 02-01) → 153 + N + 0 vitest count (no new tests in this plan; flips the existing
|
||||||
|
3 RED to GREEN; existing tests preserved unchanged).
|
||||||
|
- UAT harness 24/24 GREEN preserved.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/02-stabilize-export-pipeline/02-CONTEXT.md
|
||||||
|
@.planning/phases/02-stabilize-export-pipeline/02-01-PLAN.md
|
||||||
|
|
||||||
|
# Source code under modification
|
||||||
|
@src/background/index.ts
|
||||||
|
@src/offscreen/recorder.ts
|
||||||
|
@src/shared/types.ts
|
||||||
|
@src/shared/binary.ts
|
||||||
|
|
||||||
|
# Precedent for port-bridge wire format
|
||||||
|
@.planning/phases/01-stabilize-video-pipeline/01-07-SUMMARY.md
|
||||||
|
|
||||||
|
# Tests that flip GREEN under this plan
|
||||||
|
@tests/background/blob-url-download.test.ts
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Wire format for the new port messages — embedded so executor doesn't dig. -->
|
||||||
|
|
||||||
|
Extended PortMessageType union (src/shared/types.ts):
|
||||||
|
```typescript
|
||||||
|
export type PortMessageType =
|
||||||
|
| 'PING'
|
||||||
|
| 'PONG'
|
||||||
|
| 'REQUEST_BUFFER'
|
||||||
|
| 'BUFFER'
|
||||||
|
| 'CREATE_DOWNLOAD_URL' // NEW (SW → offscreen): "here is a Blob; mint a URL for it"
|
||||||
|
| 'DOWNLOAD_URL' // NEW (offscreen → SW): "here is the minted URL"
|
||||||
|
| 'REVOKE_DOWNLOAD_URL'; // NEW (SW → offscreen): "you can revoke this URL now"
|
||||||
|
|
||||||
|
export interface PortMessage {
|
||||||
|
type: PortMessageType;
|
||||||
|
requestId?: string;
|
||||||
|
segments?: TransferredVideoSegment[]; // BUFFER only
|
||||||
|
// NEW for CREATE_DOWNLOAD_URL: archive bytes as base64 + type for Blob reconstruction
|
||||||
|
dataBase64?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
// NEW for DOWNLOAD_URL: the minted blob:URL string
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing keepalivePort lifecycle (do NOT touch):
|
||||||
|
- src/offscreen/recorder.ts:790-840 — connectPort() + onPortMessage + reconnectPort
|
||||||
|
- src/background/index.ts:340-510 (approx, around getVideoBufferFromOffscreen) — SW-side onConnect handler + perRequestListeners map
|
||||||
|
|
||||||
|
Existing chrome.downloads.download call site (src/background/index.ts:712-716):
|
||||||
|
```typescript
|
||||||
|
await chrome.downloads.download({
|
||||||
|
url: url, // ← this string flips from data: to blob:
|
||||||
|
filename: filename,
|
||||||
|
saveAs: false
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing blobToBase64 / base64ToBlob (src/shared/binary.ts:42-85) — REUSE AS-IS.
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Extend PortMessageType + PortMessage in src/shared/types.ts (D-P2-01 wire contract)</name>
|
||||||
|
<files>src/shared/types.ts</files>
|
||||||
|
<behavior>
|
||||||
|
- PortMessageType union grows from 4 to 7 entries: PING, PONG, REQUEST_BUFFER, BUFFER, CREATE_DOWNLOAD_URL, DOWNLOAD_URL, REVOKE_DOWNLOAD_URL.
|
||||||
|
- PortMessage interface adds optional `dataBase64?: string`, `mimeType?: string`, `url?: string` — same optional-tagged-union pattern as the existing `segments?:`.
|
||||||
|
- Type-level test (Task 2 from Plan 02-01 if it covers types): `meta.json` urls field NOT touched here (that is Plan 02-03 territory). This task is wire-format only.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Edit `src/shared/types.ts` to extend PortMessageType + PortMessage per the interfaces block above.
|
||||||
|
|
||||||
|
Add a docstring block above the union explaining: "CREATE_DOWNLOAD_URL + DOWNLOAD_URL + REVOKE_DOWNLOAD_URL
|
||||||
|
are the D-P2-01 Blob URL migration triplet (P0-6 fix). SW posts CREATE_DOWNLOAD_URL with the
|
||||||
|
archive bytes as base64; offscreen mints URL.createObjectURL and responds with DOWNLOAD_URL;
|
||||||
|
SW calls chrome.downloads.download(url); when chrome.downloads.onChanged reports 'complete' or
|
||||||
|
'interrupted', SW posts REVOKE_DOWNLOAD_URL so offscreen can free the URL. The base64 wire-format
|
||||||
|
reuses the D-12 precedent from src/shared/binary.ts — chrome.runtime.Port JSON-serializes payloads,
|
||||||
|
so Blob → empty object; base64 round-trips cleanly. See .planning/phases/02-stabilize-export-pipeline/
|
||||||
|
02-CONTEXT.md D-P2-01 for the full architectural rationale."
|
||||||
|
|
||||||
|
No other type changes in this task — SessionMetadata.urls is Plan 02-03.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>npx tsc --noEmit 2>&1 | head -20</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
tsc --noEmit clean. PortMessageType union has 7 entries. PortMessage interface has the 3 new
|
||||||
|
optional fields with the docstring rationale. Atomic commit:
|
||||||
|
`feat(02-02): wire-format — extend PortMessage with CREATE_DOWNLOAD_URL/DOWNLOAD_URL/REVOKE_DOWNLOAD_URL (D-P2-01)`.
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: Offscreen handler — mint + revoke Blob URLs on port bridge</name>
|
||||||
|
<files>src/offscreen/recorder.ts</files>
|
||||||
|
<behavior>
|
||||||
|
- `onPortMessage` (src/offscreen/recorder.ts:614) grows two new branches:
|
||||||
|
(a) `type === 'CREATE_DOWNLOAD_URL'`: extract requestId, dataBase64, mimeType;
|
||||||
|
call `base64ToBlob(dataBase64, mimeType)` (reuse src/shared/binary.ts);
|
||||||
|
call `URL.createObjectURL(blob)` to get the blob:URL string;
|
||||||
|
post back `{ type: 'DOWNLOAD_URL', requestId, url }` on keepalivePort.
|
||||||
|
(b) `type === 'REVOKE_DOWNLOAD_URL'`: extract url string; call `URL.revokeObjectURL(url)`;
|
||||||
|
no response (fire-and-forget).
|
||||||
|
- Defense-in-depth: if dataBase64 is empty / malformed, respond with `{ type: 'DOWNLOAD_URL', requestId, url: '' }` and log a warn — let SW timeout fire (mirrors the encodeAndSendBuffer pattern at recorder.ts:670).
|
||||||
|
- Maintain a module-scoped Set<string> of minted URLs (for hygiene — emit a warn if REVOKE arrives for a URL not in the set, but always call revokeObjectURL anyway; URL.revokeObjectURL on an unknown URL is a no-op per WHATWG spec).
|
||||||
|
- In-flight guard: a second CREATE_DOWNLOAD_URL while one is in flight is allowed (each gets its own requestId) — unlike encodeAndSendBuffer which gates concurrent calls because they share segment state. URL minting is stateless per-Blob.
|
||||||
|
- No changes to recording lifecycle / segment-rotation / D-13 / D-17 keepalive / __MOKOSH_UAT__ test hooks.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Edit `src/offscreen/recorder.ts` to add two new cases inside the existing `onPortMessage` function
|
||||||
|
(after the REQUEST_BUFFER branch at line ~626):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (type === 'CREATE_DOWNLOAD_URL') {
|
||||||
|
const requestId = (message as { requestId?: unknown }).requestId;
|
||||||
|
const dataBase64 = (message as { dataBase64?: unknown }).dataBase64;
|
||||||
|
const mimeType = (message as { mimeType?: unknown }).mimeType ?? 'application/zip';
|
||||||
|
if (typeof requestId !== 'string' || requestId.length === 0) {
|
||||||
|
logger.warn('CREATE_DOWNLOAD_URL without requestId — dropping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof dataBase64 !== 'string') {
|
||||||
|
logger.warn('CREATE_DOWNLOAD_URL with non-string dataBase64 — dropping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void handleCreateDownloadUrl(requestId, dataBase64, typeof mimeType === 'string' ? mimeType : 'application/zip');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type === 'REVOKE_DOWNLOAD_URL') {
|
||||||
|
const url = (message as { url?: unknown }).url;
|
||||||
|
if (typeof url !== 'string' || url.length === 0) {
|
||||||
|
logger.warn('REVOKE_DOWNLOAD_URL without url — dropping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
mintedDownloadUrls.delete(url);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('URL.revokeObjectURL threw:', err);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the helper + state right before onPortMessage:
|
||||||
|
```typescript
|
||||||
|
// D-P2-01 Blob URL hygiene set: track minted URLs so we can warn (not error)
|
||||||
|
// on unexpected revoke ops. URL.revokeObjectURL on an unknown URL is a no-op
|
||||||
|
// per the WHATWG spec, so this is purely diagnostic.
|
||||||
|
const mintedDownloadUrls = new Set<string>();
|
||||||
|
|
||||||
|
async function handleCreateDownloadUrl(requestId: string, dataBase64: string, mimeType: string): Promise<void> {
|
||||||
|
if (keepalivePort === null) {
|
||||||
|
logger.warn('CREATE_DOWNLOAD_URL: port unavailable at handler entry — dropping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let url = '';
|
||||||
|
try {
|
||||||
|
const blob = base64ToBlob(dataBase64, mimeType);
|
||||||
|
if (blob.size === 0) {
|
||||||
|
logger.warn('CREATE_DOWNLOAD_URL: empty blob from base64 decode — responding with empty url');
|
||||||
|
} else {
|
||||||
|
url = URL.createObjectURL(blob);
|
||||||
|
mintedDownloadUrls.add(url);
|
||||||
|
logger.log(`Minted Blob URL: ${url.substring(0, 30)}... (size: ${blob.size} bytes)`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('CREATE_DOWNLOAD_URL: base64ToBlob or createObjectURL threw:', err);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
keepalivePort.postMessage({ type: 'DOWNLOAD_URL', requestId, url });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('DOWNLOAD_URL post failed (port may have disconnected):', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify the import `import { blobToBase64, base64ToBlob } from '../shared/binary';` at the top
|
||||||
|
already imports base64ToBlob (recorder.ts line 18 imports blobToBase64 only — extend to include base64ToBlob).
|
||||||
|
|
||||||
|
No changes to bootstrap, connectPort, ping loop, or segment-rotation.
|
||||||
|
|
||||||
|
Per D-P2-01 architectural rationale: this lives in the offscreen because SW lacks
|
||||||
|
URL.createObjectURL (DEC-006). The offscreen does, and we already have the keepalivePort
|
||||||
|
open (D-17). Reusing the port (not opening a new one) avoids two connect-overhead penalties
|
||||||
|
per save flow.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>npx tsc --noEmit 2>&1 | head -10 ; npm run build 2>&1 | tail -10</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
tsc clean. npm run build clean. Two new cases in onPortMessage; one new helper handleCreateDownloadUrl;
|
||||||
|
one new module-scoped Set mintedDownloadUrls; import line widened to include base64ToBlob.
|
||||||
|
Atomic commit:
|
||||||
|
`feat(02-02): offscreen — CREATE/REVOKE Blob URL handlers on keepalivePort (D-P2-01)`.
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 3: SW downloadArchive rewrite + chrome.downloads.onChanged revoke wiring</name>
|
||||||
|
<files>src/background/index.ts</files>
|
||||||
|
<behavior>
|
||||||
|
- `downloadArchive(archiveBlob)` (line 695) rewritten:
|
||||||
|
(1) Encode archiveBlob to base64 via blobToBase64 (already imported at line 2).
|
||||||
|
(2) Generate a requestId (use crypto.randomUUID() — SW supports it; see getVideoBufferFromOffscreen pattern).
|
||||||
|
(3) Post `{ type: 'CREATE_DOWNLOAD_URL', requestId, dataBase64, mimeType: 'application/zip' }` on the existing port (offscreenPort variable — see Plan 01-04 wiring at src/background/index.ts:onConnect host).
|
||||||
|
(4) Await the matching `DOWNLOAD_URL` response (per-request listener map pattern, mirroring REQUEST_BUFFER → BUFFER from getVideoBufferFromOffscreen).
|
||||||
|
(5) If response.url === '' or no response within timeout (5000ms), throw an EmptyVideoBufferError-equivalent "blob-url-mint-failed" or log + return (planner-decision: log warn, attempt fallback via legacy data: URL ONLY for archives <1 MB — otherwise throw and surface to operator). Resolved inline: NO FALLBACK. The blob: URL is the new contract; failure must surface via a typed error so the operator gets a clear failure mode, not a silent corrupt archive. Throw `new Error('blob-url-mint-failed: offscreen unresponsive or empty url')`.
|
||||||
|
(6) Call chrome.downloads.download({ url, filename, saveAs: false }) capturing the returned downloadId.
|
||||||
|
(7) Store downloadId → url in a module-scoped Map<number, string> (`pendingRevokes`) so chrome.downloads.onChanged can dispatch the revoke.
|
||||||
|
- chrome.downloads.onChanged listener registered at module init (after the existing chrome.runtime.onMessage listener registration around line 843):
|
||||||
|
```typescript
|
||||||
|
chrome.downloads.onChanged.addListener((delta) => {
|
||||||
|
if (!delta.state) return;
|
||||||
|
if (delta.state.current === 'complete' || delta.state.current === 'interrupted') {
|
||||||
|
const url = pendingRevokes.get(delta.id);
|
||||||
|
if (url !== undefined) {
|
||||||
|
pendingRevokes.delete(delta.id);
|
||||||
|
if (offscreenPort !== null) {
|
||||||
|
try {
|
||||||
|
offscreenPort.postMessage({ type: 'REVOKE_DOWNLOAD_URL', url });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('REVOKE_DOWNLOAD_URL post failed:', err);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Offscreen port unavailable — the URL leaks in offscreen until the
|
||||||
|
// document is torn down (which happens on browser close / extension
|
||||||
|
// reload). Not a security issue (URLs are extension-origin scoped),
|
||||||
|
// just a per-session memory leak diagnostically noted.
|
||||||
|
logger.warn(`offscreenPort null at revoke time; url ${url.substring(0,30)}... leaks until offscreen teardown`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
- Defense-in-depth: NEVER `await import(...)` from src/background/index.ts (MV3 SW dynamic-import blocker — Plan 01-11 SUMMARY). All new logic is eager top-of-module.
|
||||||
|
- Always-on charter preserved: no changes to saveArchive (line 741); downloadArchive's caller signature unchanged; saveArchive's no-finally invariant from Plan 01-09 Amendment 3 untouched.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Edit `src/background/index.ts`:
|
||||||
|
|
||||||
|
1. Add module-scoped `pendingRevokes: Map<number, string>` near the existing state declarations
|
||||||
|
(line 68 region) with docstring citing D-P2-01.
|
||||||
|
|
||||||
|
2. Add module-scoped `pendingDownloadUrlResolvers: Map<string, (url: string) => void>` for the
|
||||||
|
requestId → resolver pattern (mirroring `pendingBufferResolvers` if it exists, or the
|
||||||
|
per-request listener pattern in getVideoBufferFromOffscreen — read that function to align
|
||||||
|
the implementation).
|
||||||
|
|
||||||
|
3. Extend the existing SW-side port `onMessage` handler (registered in onConnect — read
|
||||||
|
src/background/index.ts around the keepalive port plumbing to find the exact location)
|
||||||
|
to handle the new DOWNLOAD_URL message:
|
||||||
|
```typescript
|
||||||
|
if (msg.type === 'DOWNLOAD_URL' && typeof msg.requestId === 'string') {
|
||||||
|
const resolver = pendingDownloadUrlResolvers.get(msg.requestId);
|
||||||
|
if (resolver !== undefined) {
|
||||||
|
pendingDownloadUrlResolvers.delete(msg.requestId);
|
||||||
|
resolver(typeof msg.url === 'string' ? msg.url : '');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Rewrite `downloadArchive(archiveBlob)` (line 695-718):
|
||||||
|
```typescript
|
||||||
|
async function downloadArchive(archiveBlob: Blob) {
|
||||||
|
const now = new Date();
|
||||||
|
const dateStr = now.toISOString().replace(/[:.]/g, '-').split('T')[0];
|
||||||
|
const timeStr = now.toTimeString().split(' ')[0].replace(/:/g, '-');
|
||||||
|
const filename = `session_report_${dateStr}_${timeStr}.zip`;
|
||||||
|
|
||||||
|
logger.log(`Downloading archive: ${filename} (${archiveBlob.size} bytes)`);
|
||||||
|
|
||||||
|
// D-P2-01: mint blob:URL via offscreen (SW lacks URL.createObjectURL per DEC-006).
|
||||||
|
// Bridge: encode → port post CREATE_DOWNLOAD_URL → await DOWNLOAD_URL → call chrome.downloads.
|
||||||
|
const dataBase64 = await blobToBase64(archiveBlob);
|
||||||
|
const requestId = crypto.randomUUID();
|
||||||
|
const urlPromise = new Promise<string>((resolve) => {
|
||||||
|
pendingDownloadUrlResolvers.set(requestId, resolve);
|
||||||
|
});
|
||||||
|
if (offscreenPort === null) {
|
||||||
|
throw new Error('blob-url-mint-failed: offscreen port unavailable');
|
||||||
|
}
|
||||||
|
offscreenPort.postMessage({
|
||||||
|
type: 'CREATE_DOWNLOAD_URL',
|
||||||
|
requestId,
|
||||||
|
dataBase64,
|
||||||
|
mimeType: 'application/zip',
|
||||||
|
});
|
||||||
|
const timeoutMs = 5000;
|
||||||
|
const url = await Promise.race([
|
||||||
|
urlPromise,
|
||||||
|
new Promise<string>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error('blob-url-mint-timeout')), timeoutMs)
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
if (url === '') {
|
||||||
|
throw new Error('blob-url-mint-failed: offscreen returned empty url');
|
||||||
|
}
|
||||||
|
const downloadId = await chrome.downloads.download({
|
||||||
|
url,
|
||||||
|
filename,
|
||||||
|
saveAs: false,
|
||||||
|
});
|
||||||
|
if (typeof downloadId === 'number') {
|
||||||
|
pendingRevokes.set(downloadId, url);
|
||||||
|
}
|
||||||
|
logger.log(`Archive download started: id=${downloadId}, blob-url=${url.substring(0, 30)}...`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Register chrome.downloads.onChanged listener at module init (defensive try/catch like the
|
||||||
|
other listener registrations at line 843 region):
|
||||||
|
```typescript
|
||||||
|
try {
|
||||||
|
if (chrome.downloads?.onChanged?.addListener) {
|
||||||
|
chrome.downloads.onChanged.addListener((delta) => {
|
||||||
|
if (!delta.state) return;
|
||||||
|
const newState = delta.state.current;
|
||||||
|
if (newState !== 'complete' && newState !== 'interrupted') return;
|
||||||
|
const url = pendingRevokes.get(delta.id);
|
||||||
|
if (url === undefined) return;
|
||||||
|
pendingRevokes.delete(delta.id);
|
||||||
|
if (offscreenPort !== null) {
|
||||||
|
try {
|
||||||
|
offscreenPort.postMessage({ type: 'REVOKE_DOWNLOAD_URL', url });
|
||||||
|
logger.log(`Dispatched REVOKE_DOWNLOAD_URL for downloadId=${delta.id}`);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('REVOKE_DOWNLOAD_URL post failed:', err);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warn(`offscreenPort null at revoke time; url ${url.substring(0,30)}... leaks until offscreen teardown`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('chrome.downloads.onChanged.addListener failed:', err);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
NOTE on `offscreenPort` variable: the existing SW-side onConnect handler stores the port reference
|
||||||
|
somewhere (read `src/background/index.ts` around line 350-510 OR grep for `chrome.runtime.onConnect`
|
||||||
|
to find the exact variable). Reuse that — do NOT introduce a parallel port reference.
|
||||||
|
|
||||||
|
Per D-P2-01 — this completes the architectural triangle: SW packages zip → SW asks offscreen
|
||||||
|
to mint URL → offscreen mints → SW downloads → onChanged fires → SW asks offscreen to revoke.
|
||||||
|
Matches CONTEXT.md `<decisions>` D-P2-01 verbatim, including the chrome.downloads.onChanged
|
||||||
|
listener for URL.revokeObjectURL lifecycle.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>npx tsc --noEmit 2>&1 | head -20 ; npm run build 2>&1 | tail -10 ; npx vitest run tests/background/blob-url-download.test.ts 2>&1 | tail -20</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
tsc clean. npm run build clean. tests/background/blob-url-download.test.ts → 3/3 GREEN.
|
||||||
|
All other vitest tests (153 baseline + Plan 02-01 RED tests that are not yet GREEN — only the
|
||||||
|
blob-url-download.test.ts ones flip here) preserved.
|
||||||
|
Pre-commit Tier-1 grep gate: GREEN (12 FORBIDDEN_HOOK_STRINGS unchanged — no new test-hook symbols).
|
||||||
|
Atomic commit:
|
||||||
|
`feat(02-02): SW — downloadArchive via offscreen-minted Blob URL + revoke lifecycle (D-P2-01 closes P0-6)`.
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| SW → offscreen via port | Existing T-1-04 mitigation (sender-id check) preserved — no new boundary |
|
||||||
|
| chrome.downloads.onChanged event source | Chrome browser → SW; trustworthy per MV3 platform contract |
|
||||||
|
| Blob URL in offscreen origin | URL minted in offscreen document origin (chrome-extension://<id>); accessible only to extension contexts and the chrome.downloads internals; NOT exposed to web pages |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-02-02-01 | Information Disclosure | Blob URL leaks to web pages via shared-context bug | accept | URLs are extension-origin scoped per WHATWG URL spec; web pages cannot navigate to chrome-extension:// blob URLs without manifest web_accessible_resources entries (none added). Verified via grep: zero new web_accessible_resources in this plan. |
|
||||||
|
| T-02-02-02 | Denial of Service | Unbounded pendingRevokes Map growth if onChanged never fires | mitigate | pendingRevokes entries are deleted on every onChanged 'complete'/'interrupted' delta; SW idle teardown (~30s) drops the entire Map; per-session leak is bounded by O(saves-per-session) which is operationally <100. Diagnostic warn logged if offscreenPort is null at revoke time (URL leaks until offscreen teardown — acceptable). |
|
||||||
|
| T-02-02-03 | Tampering | Malicious offscreen returns a non-blob: URL string in DOWNLOAD_URL response | mitigate | offscreen is same-extension origin (sender-id check); chrome.downloads.download validates the URL scheme and Chrome will reject non-blob:/non-data:/non-http(s): URLs at the platform layer. Defense-in-depth: SW could add `if (!url.startsWith('blob:')) throw` — add this guard as part of the validation chain (downloadArchive's url=='' check is extended to also reject non-blob: prefixes). |
|
||||||
|
| T-02-02-04 | Elevation of Privilege | base64ToBlob in offscreen accepts arbitrary mimeType from SW port message | accept | mimeType is consumer-level metadata only; URL.createObjectURL doesn't grant any new privileges based on Blob.type. The risk is purely cosmetic (wrong file extension on download), not an EoP. |
|
||||||
|
| T-02-02-05 | Repudiation | downloadArchive failure surfaces only as a logger.warn — operator sees a missing zip with no diagnostic | mitigate | Throw a typed Error ('blob-url-mint-failed' / 'blob-url-mint-timeout' / 'offscreen port unavailable') → saveArchive catch block routes through the existing RECORDING_ERROR channel (see src/background/index.ts:809-829 EmptyVideoBufferError path) — operator sees a recovery notification. Plan-checker verifies the catch path is wired. |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `npx tsc --noEmit` → clean.
|
||||||
|
- `npm run build` → clean.
|
||||||
|
- `npm run build:test` → clean (test bundle still builds; __MOKOSH_UAT__ token does not interact with new port messages — they are production surfaces).
|
||||||
|
- `npx vitest run tests/background/blob-url-download.test.ts --reporter=verbose` → 3/3 GREEN.
|
||||||
|
- `npx vitest run` (full suite) → previously 153/153 GREEN preserved, PLUS blob-url-download.test.ts +3 GREEN. Plan 02-01's other RED tests (meta-json-urls-schema.test.ts + strict-meta-json-validation.test.ts) REMAIN RED — flipped GREEN in Plan 02-03.
|
||||||
|
- `npx vitest run tests/background/no-test-hooks-in-prod-bundle.test.ts` → 12 FORBIDDEN_HOOK_STRINGS unchanged → GREEN.
|
||||||
|
- `npm run test:uat` → 24/24 GREEN (UAT preserved; harness inspects the existing zip-on-disk shape via A5/A12/A13, which the Blob URL path produces identically).
|
||||||
|
- Grep gate: `grep -c "data:application/zip;base64," src/background/index.ts` → 0 (the legacy path is gone). `grep -c "blob:" src/background/index.ts` ≥ 1.
|
||||||
|
- Manual operator empirical (deferred to Plan 02-04 operator checkpoint): save with the current build; verify the produced zip lands in Downloads + opens cleanly; verify Chrome DevTools Network panel shows blob: URL (not data:) as the download source. (Plan 02-04 wires this into the harness if possible.)
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
1. tests/background/blob-url-download.test.ts 3/3 GREEN.
|
||||||
|
2. UAT harness 24/24 GREEN preserved.
|
||||||
|
3. Production bundle ships chrome.downloads.onChanged listener (1 grep match in dist/) + zero `data:application/zip;base64,` (0 grep matches in dist/).
|
||||||
|
4. Tier-1 FORBIDDEN_HOOK_STRINGS = 12 (unchanged).
|
||||||
|
5. No `await import(...)` in src/background/index.ts (Plan 01-11 SUMMARY invariant).
|
||||||
|
6. Always-on charter preserved: saveArchive in src/background/index.ts:741 still has NO `finally` block resetting recorder state.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/02-stabilize-export-pipeline/02-02-SUMMARY.md`
|
||||||
|
documenting:
|
||||||
|
- 3 source files modified; line-count deltas.
|
||||||
|
- Wire-format extension: 3 new PortMessageType variants; in-flight resolver Map; revoke lifecycle Map.
|
||||||
|
- Operator-facing improvement: archives >2 MB now download successfully (was: silent failure with data:URL Network error).
|
||||||
|
- Forward link to Plan 02-04 harness extension for empirical A24+ verification.
|
||||||
|
</output>
|
||||||
460
.planning/phases/02-stabilize-export-pipeline/02-03-PLAN.md
Normal file
460
.planning/phases/02-stabilize-export-pipeline/02-03-PLAN.md
Normal file
@@ -0,0 +1,460 @@
|
|||||||
|
---
|
||||||
|
phase: 02-stabilize-export-pipeline
|
||||||
|
plan: 03
|
||||||
|
type: auto
|
||||||
|
wave: 2
|
||||||
|
depends_on: [01]
|
||||||
|
files_modified:
|
||||||
|
- src/background/tab-url-tracker.ts
|
||||||
|
- src/shared/types.ts
|
||||||
|
- src/background/index.ts
|
||||||
|
- .planning/REQUIREMENTS.md
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- REQ-meta-json-schema
|
||||||
|
tags:
|
||||||
|
- meta-json-urls-array
|
||||||
|
- tab-url-tracking
|
||||||
|
- schema-amendment
|
||||||
|
- p1-10-fix
|
||||||
|
- d-p2-02
|
||||||
|
- d-p2-03
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "src/background/tab-url-tracker.ts exists exporting getTabUrlsSeen(): string[] returning a deduplicated, first-seen-ordered, filtered (no chrome://, no about:; include chrome-extension://) list of tab URLs observed during the rolling 30s window."
|
||||||
|
- "SessionMetadata.url: string is replaced by SessionMetadata.urls: string[] in src/shared/types.ts."
|
||||||
|
- "createArchive in src/background/index.ts assembles meta.json with urls (not url), plus a new schemaVersion: '2' field, totalling exactly 8 fields per D-P2-03."
|
||||||
|
- "Tests tests/background/meta-json-urls-schema.test.ts (4 tests) and tests/build/strict-meta-json-validation.test.ts (8 tests) flip RED→GREEN."
|
||||||
|
- ".planning/REQUIREMENTS.md REQ-meta-json-schema text amended to reflect the new 8-field shape, with the breaking-change cutover documented inline."
|
||||||
|
- "chrome.tabs.onActivated and chrome.tabs.onUpdated listeners registered in src/background/index.ts maintain the tracker's internal Set; pruning happens alongside the video segment ring buffer lifecycle (no separate timer)."
|
||||||
|
- "Always-on charter preserved (Plan 01-09 Amendment 3): SAVE creates a zip with the current tracker state; tracker continues accumulating after SAVE."
|
||||||
|
- "manifest.json permissions: the existing `tabs` permission gap (Plan 01-13 SUMMARY Known Limitations item 3) is NOT closed by this plan — the tracker uses chrome.tabs.onActivated + chrome.tabs.onUpdated which work on `activeTab` permission alone for the active-tab URL events. Cross-window/inactive-tab URL events require `tabs` permission and are deferred to Phase 4 hardening (planner-decision documented inline)."
|
||||||
|
artifacts:
|
||||||
|
- path: "src/background/tab-url-tracker.ts"
|
||||||
|
provides: "getTabUrlsSeen(): string[] + initTabUrlTracker(): void + clearTabUrlsSeen(): void"
|
||||||
|
min_lines: 80
|
||||||
|
contains: "getTabUrlsSeen|chrome\\.tabs\\.onActivated|chrome\\.tabs\\.onUpdated"
|
||||||
|
- path: "src/shared/types.ts"
|
||||||
|
provides: "SessionMetadata.urls: string[] (replaces url: string); new schemaVersion: string field"
|
||||||
|
contains: "urls.*string\\[\\]|schemaVersion"
|
||||||
|
- path: "src/background/index.ts"
|
||||||
|
provides: "createArchive consumes getTabUrlsSeen for meta.urls + initTabUrlTracker at SW init"
|
||||||
|
contains: "getTabUrlsSeen|schemaVersion"
|
||||||
|
- path: ".planning/REQUIREMENTS.md"
|
||||||
|
provides: "REQ-meta-json-schema amended for 8-field shape + breaking-change cutover note"
|
||||||
|
contains: "schemaVersion|urls.*string\\[\\]"
|
||||||
|
key_links:
|
||||||
|
- from: "src/background/index.ts:initialize"
|
||||||
|
to: "src/background/tab-url-tracker.ts:initTabUrlTracker"
|
||||||
|
via: "initTabUrlTracker() called at SW init alongside chrome.runtime.onMessage listener"
|
||||||
|
pattern: "initTabUrlTracker\\(\\)"
|
||||||
|
- from: "src/background/index.ts:createArchive"
|
||||||
|
to: "src/background/tab-url-tracker.ts:getTabUrlsSeen"
|
||||||
|
via: "metadata.urls = getTabUrlsSeen()"
|
||||||
|
pattern: "metadata\\.urls.*=.*getTabUrlsSeen"
|
||||||
|
- from: "src/background/tab-url-tracker.ts"
|
||||||
|
to: "chrome.tabs.onActivated + chrome.tabs.onUpdated"
|
||||||
|
via: "addListener at module init; each event derives the URL via tab.url and inserts into the Set if it passes the filter"
|
||||||
|
pattern: "chrome\\.tabs\\.on(Activated|Updated)\\.addListener"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Replace SessionMetadata.url: string with SessionMetadata.urls: string[] per D-P2-02. The urls array
|
||||||
|
captures all tab URLs observed during the rolling recording window (operator's full multi-tab
|
||||||
|
bug-reproduction context, not just the active-at-save tab). Add a new schemaVersion field per
|
||||||
|
the planner-resolved 8th-field decision from Plan 02-01. Amend REQUIREMENTS.md REQ-meta-json-schema
|
||||||
|
text to reflect the breaking-change cutover.
|
||||||
|
|
||||||
|
Purpose: closes audit P1 #10 + makes the meta.json multi-tab-aware for the operator's typical workflow
|
||||||
|
(switching tabs across the 30s window when reproducing a bug). Maintains the always-on charter
|
||||||
|
(tracker keeps running after SAVE; no reset).
|
||||||
|
|
||||||
|
Output:
|
||||||
|
- New module src/background/tab-url-tracker.ts (~80-120 LOC).
|
||||||
|
- src/shared/types.ts SessionMetadata interface updated.
|
||||||
|
- src/background/index.ts createArchive + initialize updated.
|
||||||
|
- .planning/REQUIREMENTS.md REQ-meta-json-schema text amended.
|
||||||
|
- tests/background/meta-json-urls-schema.test.ts (4) + tests/build/strict-meta-json-validation.test.ts (8) flip RED→GREEN.
|
||||||
|
</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
|
||||||
|
|
||||||
|
# Source code under modification
|
||||||
|
@src/background/index.ts
|
||||||
|
@src/shared/types.ts
|
||||||
|
|
||||||
|
# Tests that flip GREEN under this plan
|
||||||
|
@tests/background/meta-json-urls-schema.test.ts
|
||||||
|
@tests/build/strict-meta-json-validation.test.ts
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Target shape for the tab-url-tracker module. Embedded so executor needs no codebase exploration. -->
|
||||||
|
|
||||||
|
src/background/tab-url-tracker.ts public API:
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* Initialize the tab-URL tracker. Registers chrome.tabs.onActivated +
|
||||||
|
* chrome.tabs.onUpdated listeners that maintain an internal Set of URLs
|
||||||
|
* observed during the SW's lifetime. Must be called once at SW init.
|
||||||
|
*
|
||||||
|
* Idempotent — calling twice is safe (defensive try/catch around listener
|
||||||
|
* registration matches the pattern in src/background/index.ts:bootstrap).
|
||||||
|
*
|
||||||
|
* D-P2-02 binding: captures the operator's multi-tab context, not just
|
||||||
|
* the active-at-save tab.
|
||||||
|
*/
|
||||||
|
export function initTabUrlTracker(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the deduplicated, first-seen-ordered, filtered list of tab URLs
|
||||||
|
* observed since init OR since the last clearTabUrlsSeen() call.
|
||||||
|
*
|
||||||
|
* Filter (per CONTEXT.md `<specifics>` block):
|
||||||
|
* - INCLUDE: https://, http://, chrome-extension://
|
||||||
|
* - EXCLUDE: chrome://, about:, devtools://, file:// (low diagnostic value
|
||||||
|
* OR privacy-concerning local-fs paths)
|
||||||
|
*
|
||||||
|
* Dedup: each URL appears exactly once.
|
||||||
|
* Order: first-seen-first.
|
||||||
|
*
|
||||||
|
* Always returns a NEW array (copy) — caller cannot mutate the internal Set.
|
||||||
|
*/
|
||||||
|
export function getTabUrlsSeen(): string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the internal Set. NOT called by saveArchive (always-on charter
|
||||||
|
* preserved — SAVE creates a zip with the current state; tracker keeps
|
||||||
|
* accumulating). Reserved for future use (e.g., manual session-reset).
|
||||||
|
*/
|
||||||
|
export function clearTabUrlsSeen(): void;
|
||||||
|
```
|
||||||
|
|
||||||
|
Updated SessionMetadata (src/shared/types.ts):
|
||||||
|
```typescript
|
||||||
|
export interface SessionMetadata {
|
||||||
|
schemaVersion: string; // NEW — '2' marks the D-P2-02 url→urls cutover
|
||||||
|
timestamp: string; // ISO-8601 with Z
|
||||||
|
urls: string[]; // NEW — replaces `url: string`; non-empty per D-P2-03
|
||||||
|
userAgent: string;
|
||||||
|
extensionVersion: string;
|
||||||
|
videoBufferSeconds: number;
|
||||||
|
logDurationMinutes: number;
|
||||||
|
totalEvents: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing createArchive call site (src/background/index.ts:673-684):
|
||||||
|
```typescript
|
||||||
|
const metadata: SessionMetadata = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
url: new URL(chrome.runtime.getURL('')).origin, // ← REPLACE
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
extensionVersion: manifest.version,
|
||||||
|
videoBufferSeconds: 30,
|
||||||
|
logDurationMinutes: 10,
|
||||||
|
totalEvents: rrwebEvents.length + userEvents.length
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
REQ-meta-json-schema (REQUIREMENTS.md:106-119) — verbatim text to amend:
|
||||||
|
```
|
||||||
|
- [ ] **REQ-meta-json-schema**: `meta.json` inside the archive conforms to the
|
||||||
|
verbatim schema:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timestamp": "2025-05-15T14:32:10Z",
|
||||||
|
"url": "https://...", ← REPLACE WITH urls: []
|
||||||
|
"userAgent": "Chrome/...",
|
||||||
|
"extensionVersion": "1.0.0",
|
||||||
|
"videoBufferSeconds": 30,
|
||||||
|
"logDurationMinutes": 10,
|
||||||
|
"totalEvents": 143
|
||||||
|
}
|
||||||
|
```
|
||||||
|
All fields required. Binding: CON-meta-json-schema.
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Create src/background/tab-url-tracker.ts module</name>
|
||||||
|
<files>src/background/tab-url-tracker.ts</files>
|
||||||
|
<behavior>
|
||||||
|
- Module exports initTabUrlTracker, getTabUrlsSeen, clearTabUrlsSeen per the interface block.
|
||||||
|
- Internal state: `let tabUrlsSeen: Set<string> = new Set()` AND `let firstSeenOrder: string[] = []` (Set for O(1) dedup; array for ordering).
|
||||||
|
- On chrome.tabs.onActivated: query the activated tab via chrome.tabs.get(activeInfo.tabId); if tab.url passes filter, addUrl(tab.url).
|
||||||
|
- On chrome.tabs.onUpdated: when changeInfo.url is present AND passes filter, addUrl(changeInfo.url). (Filters out the cascade of intermediate events that fire during page load — `changeInfo.status === 'complete'` is NOT required because we want to capture URL transitions even for SPA-style routing).
|
||||||
|
- addUrl helper: if URL not in Set, push to firstSeenOrder array AND add to Set; idempotent.
|
||||||
|
- Filter helper: accept https://, http://, chrome-extension://; reject chrome://, about:, devtools://, file://, blob:, data:.
|
||||||
|
- Defense-in-depth: all chrome.* listener registrations wrapped in try/catch (matches src/background/index.ts:bootstrap pattern for chrome.notifications + chrome.action listeners).
|
||||||
|
- getTabUrlsSeen returns a slice (copy) of firstSeenOrder — caller cannot mutate internal state.
|
||||||
|
- clearTabUrlsSeen empties both the Set and the array.
|
||||||
|
- module-init guard: `let initialized = false`; initTabUrlTracker sets to true; subsequent calls return early with a logger.warn for idempotency.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Create `src/background/tab-url-tracker.ts` with the exact public API from the interfaces block.
|
||||||
|
|
||||||
|
Use the Logger pattern from src/shared/logger.ts (existing pattern at recorder.ts:78 / index.ts:49):
|
||||||
|
```typescript
|
||||||
|
import { Logger } from '../shared/logger';
|
||||||
|
const logger = new Logger('TabUrlTracker');
|
||||||
|
```
|
||||||
|
|
||||||
|
The filter implementation:
|
||||||
|
```typescript
|
||||||
|
function passesFilter(url: string): boolean {
|
||||||
|
// Per .planning/phases/02-stabilize-export-pipeline/02-CONTEXT.md <specifics>:
|
||||||
|
// INCLUDE: https://, http://, chrome-extension://
|
||||||
|
// EXCLUDE: chrome://, about:, devtools://, file://, blob:, data:
|
||||||
|
return /^(https?|chrome-extension):\/\//.test(url);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Module structure (top → bottom):
|
||||||
|
1. Logger import + instantiation.
|
||||||
|
2. State: `let tabUrlsSeen: Set<string>`, `let firstSeenOrder: string[]`, `let initialized: boolean`.
|
||||||
|
3. passesFilter helper.
|
||||||
|
4. addUrl helper (internal).
|
||||||
|
5. initTabUrlTracker (public) — registers listeners with defensive try/catch.
|
||||||
|
6. getTabUrlsSeen (public).
|
||||||
|
7. clearTabUrlsSeen (public).
|
||||||
|
|
||||||
|
Per D-P2-02 + CONTEXT.md `<specifics>`: the operator's primary tab at SAVE time should always
|
||||||
|
be in the array if it has a valid URL. The chrome.tabs.onActivated path covers this — the active
|
||||||
|
tab fires onActivated whenever the operator switches to it (including the first activation when
|
||||||
|
the SW starts up). Combined with chrome.tabs.onUpdated, this captures both tab-switch events
|
||||||
|
and in-tab navigation events. No explicit "snapshot active tab at SAVE time" is needed (the
|
||||||
|
tracker has already captured it via onActivated).
|
||||||
|
|
||||||
|
NOTE on `tabs` permission gap: chrome.tabs.onActivated provides tabId in the event payload;
|
||||||
|
chrome.tabs.get(tabId) returns the tab WITHOUT the `.url` field unless `tabs` permission is
|
||||||
|
granted (Plan 01-13 SUMMARY Known Limitations item 3). For the active tab specifically,
|
||||||
|
`activeTab` permission grants temporary URL access. The tracker accepts the URL when present
|
||||||
|
and skips silently when absent (logged at warn level for diagnostics). This means inactive-tab
|
||||||
|
URL changes are NOT captured — acceptable per planner-decision deferred to Phase 4 hardening
|
||||||
|
(CONTEXT.md `<deferred>` "tabs permission gap"). Document this limitation inline.
|
||||||
|
|
||||||
|
Per D-P2-03's "urls array non-empty" rule from Plan 02-01 Task 3: if getTabUrlsSeen returns
|
||||||
|
an empty array at SAVE time, createArchive (Task 3 below) must fall back to a sentinel value
|
||||||
|
(e.g., the extension-origin URL from `new URL(chrome.runtime.getURL('')).origin` — same as the
|
||||||
|
current single-url path). This guarantees the strict 8-field schema validation never fails on
|
||||||
|
empty urls[].
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>npx tsc --noEmit 2>&1 | head -20</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
File exists. tsc clean. Three exports per public API. Defensive try/catch wraps chrome.tabs.*
|
||||||
|
listener registrations. Atomic commit:
|
||||||
|
`feat(02-03): tab-url-tracker — chrome.tabs.onActivated + onUpdated → urls[] with dedup + filter (D-P2-02)`.
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: Update SessionMetadata type + meta.json assembly + tracker wiring</name>
|
||||||
|
<files>src/shared/types.ts, src/background/index.ts</files>
|
||||||
|
<behavior>
|
||||||
|
- src/shared/types.ts SessionMetadata: REPLACE `url: string` with `urls: string[]`; ADD `schemaVersion: string` as the first field. Total 8 fields.
|
||||||
|
- src/background/index.ts:
|
||||||
|
(a) import { initTabUrlTracker, getTabUrlsSeen } from './tab-url-tracker';
|
||||||
|
(b) call initTabUrlTracker() inside the existing `initialize()` function (line 957) under defensive try/catch matching the chrome.notifications.onClicked pattern.
|
||||||
|
(c) createArchive (line 673-684) updated:
|
||||||
|
```typescript
|
||||||
|
const manifest = chrome.runtime.getManifest();
|
||||||
|
const tabUrls = getTabUrlsSeen();
|
||||||
|
// D-P2-03 non-empty guarantee: if the tracker has no URLs (e.g., SW just spawned,
|
||||||
|
// operator hasn't activated a real tab yet), fall back to the extension-origin URL
|
||||||
|
// so the strict-validation schema (REQ-meta-json-schema) never fails on empty array.
|
||||||
|
const urls = tabUrls.length > 0 ? tabUrls : [new URL(chrome.runtime.getURL('')).origin];
|
||||||
|
const metadata: SessionMetadata = {
|
||||||
|
schemaVersion: '2',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
urls,
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
extensionVersion: manifest.version,
|
||||||
|
videoBufferSeconds: 30,
|
||||||
|
logDurationMinutes: 10,
|
||||||
|
totalEvents: rrwebEvents.length + userEvents.length
|
||||||
|
};
|
||||||
|
```
|
||||||
|
- Object key ordering matches the SessionMetadata interface declaration order (TypeScript object
|
||||||
|
literal preserves source order; meta.json output is JSON.stringify(metadata, null, 2) → keys
|
||||||
|
in insertion order per ECMA-262).
|
||||||
|
- Always-on charter preserved: createArchive does NOT call clearTabUrlsSeen() — the tracker
|
||||||
|
continues accumulating across saves (next save captures any tabs activated after this one).
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
1. Edit `src/shared/types.ts` SessionMetadata interface (lines 102-111):
|
||||||
|
- DELETE `url: string;`.
|
||||||
|
- INSERT `schemaVersion: string;` as the first field.
|
||||||
|
- INSERT `urls: string[];` in the position previously occupied by `url`.
|
||||||
|
- Final ordering: schemaVersion, timestamp, urls, userAgent, extensionVersion,
|
||||||
|
videoBufferSeconds, logDurationMinutes, totalEvents (8 fields).
|
||||||
|
- Add a docstring block above the interface citing D-P2-02 (the url→urls cutover) +
|
||||||
|
D-P2-03 (schemaVersion + 8-field exact rule) + the planner-resolved Plan 02-01 Task 3
|
||||||
|
decision to make schemaVersion the 8th field.
|
||||||
|
|
||||||
|
2. Edit `src/background/index.ts`:
|
||||||
|
- Add import: `import { initTabUrlTracker, getTabUrlsSeen } from './tab-url-tracker';` near
|
||||||
|
the existing import block (line 1-11).
|
||||||
|
- In `initialize()` (line 957), add a defensive try/catch block calling initTabUrlTracker()
|
||||||
|
right after the existing chrome.notifications.onClicked listener registration (search for
|
||||||
|
that pattern; mirror the surrounding try/catch shape).
|
||||||
|
- Replace the metadata-construction block in createArchive (line 673-684) with the new
|
||||||
|
8-field shape per the behavior block above.
|
||||||
|
|
||||||
|
3. Per D-P2-02 + D-P2-03:
|
||||||
|
- schemaVersion: '2' marks the breaking-change cutover. Future schema bumps increment.
|
||||||
|
- urls is the operator's multi-tab context.
|
||||||
|
- The non-empty fallback (extension-origin URL) guarantees REQ-meta-json-schema strict-
|
||||||
|
validation passes even on a freshly-spawned SW with no tab activity.
|
||||||
|
|
||||||
|
Per the planner-resolved decision in Plan 02-01 Task 3: the 8th field name `schemaVersion`
|
||||||
|
was a tentative planner pick. If plan-checker / implementer surfaces a stronger candidate
|
||||||
|
name, BOTH this plan AND tests/build/strict-meta-json-validation.test.ts EXPECTED_KEYS must
|
||||||
|
change in lockstep (matching the Plan 01-14 lockstep-change pattern for FORBIDDEN_HOOK_STRINGS).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>npx tsc --noEmit 2>&1 | head -20 ; npm run build 2>&1 | tail -10 ; npx vitest run tests/background/meta-json-urls-schema.test.ts tests/build/strict-meta-json-validation.test.ts 2>&1 | tail -30</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
tsc clean. npm run build clean. SessionMetadata has exactly 8 fields. createArchive emits
|
||||||
|
8-field meta.json with schemaVersion='2' + urls (non-empty via fallback). All 4 tests in
|
||||||
|
meta-json-urls-schema.test.ts GREEN. All 8 tests in strict-meta-json-validation.test.ts GREEN.
|
||||||
|
Atomic commit:
|
||||||
|
`feat(02-03): meta.json — urls[] + schemaVersion (D-P2-02 + D-P2-03; replaces url:string)`.
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 3: Amend REQUIREMENTS.md REQ-meta-json-schema for the new 8-field shape</name>
|
||||||
|
<files>.planning/REQUIREMENTS.md</files>
|
||||||
|
<behavior>
|
||||||
|
- REQ-meta-json-schema entry (lines 106-119) updated to reflect the new 8-field schema verbatim.
|
||||||
|
- Inline comment block citing D-P2-02 + D-P2-03 + Plan 02-03 as the cutover provenance.
|
||||||
|
- Acceptance criteria preserved: all fields required; types correct; timestamp ISO-8601 with Z.
|
||||||
|
- Add new acceptance criteria: urls is non-empty string[]; schemaVersion === '2'.
|
||||||
|
- Traceability table entry for REQ-meta-json-schema status update: "Pending → Complete pending Plan 02-04 harness validation" (Plan 02-04 may flip this to Complete; this plan only ships the implementation + amended text).
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Edit `.planning/REQUIREMENTS.md`:
|
||||||
|
|
||||||
|
1. Replace lines 106-119 (REQ-meta-json-schema block) with the new 8-field schema:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- [ ] **REQ-meta-json-schema**: `meta.json` inside the archive conforms to the
|
||||||
|
verbatim schema (D-P2-02 + D-P2-03 cutover; replaces the 7-field `url:string`
|
||||||
|
shape per audit P1 #10 amendment 2026-05-20):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schemaVersion": "2",
|
||||||
|
"timestamp": "2025-05-15T14:32:10Z",
|
||||||
|
"urls": ["https://example.com/", "https://app.example.com/dashboard"],
|
||||||
|
"userAgent": "Chrome/...",
|
||||||
|
"extensionVersion": "1.0.0",
|
||||||
|
"videoBufferSeconds": 30,
|
||||||
|
"logDurationMinutes": 10,
|
||||||
|
"totalEvents": 143
|
||||||
|
}
|
||||||
|
```
|
||||||
|
All 8 fields required. Acceptance:
|
||||||
|
- `schemaVersion === '2'` (marks the D-P2-02 url→urls cutover; future schema bumps increment)
|
||||||
|
- `timestamp` ISO-8601 with `Z` suffix
|
||||||
|
- `urls` is a non-empty `string[]` of URLs matching `/^(https?|chrome-extension):\/\//` (per CONTEXT.md `<specifics>` filter rules — exclude chrome://, about:, devtools://, file://)
|
||||||
|
- `urls` is deduplicated; ordering is first-seen-first across the rolling recording window
|
||||||
|
- `extensionVersion` matches semver
|
||||||
|
- `totalEvents` is a non-negative integer
|
||||||
|
- exactly 8 keys; no extras
|
||||||
|
Binding: CON-meta-json-schema (this REQ-text supersedes the original CON-meta-json-schema 7-field shape).
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Update the Traceability table entry (line 225) for REQ-meta-json-schema:
|
||||||
|
FROM: `| REQ-meta-json-schema | Phase 3 (originally) → **Phase 2** (renumbered) | Pending |`
|
||||||
|
TO: `| REQ-meta-json-schema | Phase 2 | Pending (implementation landed via Plan 02-03; harness validation deferred to Plan 02-04) |`
|
||||||
|
|
||||||
|
3. Append a `*Updated YYYY-MM-DD*` footer line at the bottom of the file matching the existing
|
||||||
|
footer pattern (line 249 area). Date: 2026-05-20.
|
||||||
|
|
||||||
|
Per D-P2-02 + D-P2-03: this is the canonical schema-amendment Phase 2 ships. The 8-field shape
|
||||||
|
becomes the new baseline for all downstream consumers (UAT harness, future v2 SRV-* uploads,
|
||||||
|
operator post-mortem tooling).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep -c "schemaVersion" /home/parf/projects/work/repremium/.planning/REQUIREMENTS.md ; grep -c "urls.*string\[\]" /home/parf/projects/work/repremium/.planning/REQUIREMENTS.md</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
REQUIREMENTS.md REQ-meta-json-schema block reflects 8-field shape. Traceability table updated.
|
||||||
|
Footer line appended with 2026-05-20 date. Atomic commit:
|
||||||
|
`docs(02-03): REQUIREMENTS — REQ-meta-json-schema amended for 8-field shape with urls[] + schemaVersion`.
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| tab URL strings → meta.json | Tab URLs may contain sensitive operator state (per CONTEXT.md `<decisions>` D-P2-02 "Privacy note"); the "log is internal" charter accepts this. |
|
||||||
|
| chrome.tabs.* event source | Chrome platform → SW; trustworthy per MV3 contract. |
|
||||||
|
| tab-url-tracker internal Set → getTabUrlsSeen consumer | Module returns a COPY (slice); caller cannot mutate internal state. |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-02-03-01 | Information Disclosure | URLs with embedded credentials (https://user:pass@host/) leak into meta.json | accept | Charter shift 2026-05-20 ("log is internal") explicitly accepts this. REQ-password-confidentiality moved to Out of Scope v1. No mitigation in v1; v2/Phase 4 candidate. |
|
||||||
|
| T-02-03-02 | Information Disclosure | chrome-extension://<id>/ URLs reveal extension presence | accept | The extension is installed unpacked locally; presence is already self-evident from chrome://extensions. INCLUDE per CONTEXT.md `<specifics>` filter. |
|
||||||
|
| T-02-03-03 | Tampering | Malicious extension overrides chrome.tabs.* events | mitigate | Extension permissions are user-granted at install time. Defensive try/catch wraps every chrome.* call so malformed events don't crash the SW. |
|
||||||
|
| T-02-03-04 | Denial of Service | Unbounded tab-url Set growth in long-running SW | mitigate | URL set is bounded by O(unique tabs operator visits per SW lifetime). Operationally ≤500 even in heavy use. No pruning needed within v1 budget; SW idle teardown (~30s) clears state. Phase 4 hardening can add pruning if production telemetry surfaces unbounded growth. |
|
||||||
|
| T-02-03-05 | Repudiation | Tracker silently drops URLs when `tabs` permission absent (chrome.tabs.get returns undefined .url) | mitigate | Diagnostic logger.warn at every dropped event so post-mortem investigation can identify the permission gap. Phase 4 hardening adds `tabs` permission per CONTEXT.md `<deferred>` item. |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `npx tsc --noEmit` → clean.
|
||||||
|
- `npm run build` → clean.
|
||||||
|
- `npx vitest run tests/background/meta-json-urls-schema.test.ts` → 4/4 GREEN.
|
||||||
|
- `npx vitest run tests/build/strict-meta-json-validation.test.ts` → 8/8 GREEN.
|
||||||
|
- `npx vitest run` (full suite) → previously RED tests from Plan 02-01 all GREEN. Net: 153 + 15 = 168 GREEN (or whatever the exact count is post-Plan-02-01 + Plan-02-02).
|
||||||
|
- `npx vitest run tests/background/no-test-hooks-in-prod-bundle.test.ts` → 12 FORBIDDEN_HOOK_STRINGS unchanged → GREEN.
|
||||||
|
- `npm run test:uat` → 24/24 GREEN preserved (no harness changes in this plan; A13's meta.json shape check
|
||||||
|
may detect the schemaVersion + urls fields — verify A13 still GREEN; if it depends on the old `url` field, Plan 02-04 addresses).
|
||||||
|
- `grep -c "schemaVersion" .planning/REQUIREMENTS.md` ≥ 2 (in REQ block + acceptance bullet).
|
||||||
|
- `grep -c "url: string" src/shared/types.ts` → 0 (the legacy field is gone).
|
||||||
|
- Always-on charter check: `grep -n "clearTabUrlsSeen" src/background/index.ts` → 0 hits (createArchive does NOT clear; tracker keeps accumulating after SAVE).
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
1. tests/background/meta-json-urls-schema.test.ts 4/4 GREEN.
|
||||||
|
2. tests/build/strict-meta-json-validation.test.ts 8/8 GREEN.
|
||||||
|
3. UAT harness 24/24 GREEN preserved (or A13 amended in lockstep if its meta.json assertions depend on the old `url` field — verify before commit).
|
||||||
|
4. .planning/REQUIREMENTS.md REQ-meta-json-schema reflects the 8-field shape with breaking-change cutover documented.
|
||||||
|
5. Always-on charter preserved: tab-url-tracker continues accumulating after SAVE; no createArchive-time clearTabUrlsSeen call.
|
||||||
|
6. Tier-1 FORBIDDEN_HOOK_STRINGS = 12 (unchanged).
|
||||||
|
7. tabs permission gap NOT closed by this plan — explicit deferral comment in tab-url-tracker.ts citing CONTEXT.md `<deferred>`.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/02-stabilize-export-pipeline/02-03-SUMMARY.md`
|
||||||
|
documenting:
|
||||||
|
- New module src/background/tab-url-tracker.ts (LOC + public API summary)
|
||||||
|
- SessionMetadata field-count delta (7 → 8) + ordering rationale
|
||||||
|
- 8th field `schemaVersion` decision: planner-suggested in Plan 02-01 Task 3, ratified here
|
||||||
|
- Filter rules verbatim from CONTEXT.md `<specifics>`
|
||||||
|
- Tabs permission gap deferral citation (CONTEXT.md `<deferred>`)
|
||||||
|
- Forward link: Plan 02-04 may amend UAT harness A13 if its meta.json assertions assumed the old `url` field
|
||||||
|
</output>
|
||||||
721
.planning/phases/02-stabilize-export-pipeline/02-04-PLAN.md
Normal file
721
.planning/phases/02-stabilize-export-pipeline/02-04-PLAN.md
Normal file
@@ -0,0 +1,721 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
|
- blob-url-empirical
|
||||||
|
- latency-5s
|
||||||
|
- meta-urls-shape
|
||||||
|
- 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 verifies tab-URL tracking by activating 2 distinct tabs during the recording window, then SAVE, then inspecting meta.urls — expects both URLs to be present (deduplicated, first-seen-ordered)."
|
||||||
|
- "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."
|
||||||
|
- "Phase 2 closes with 4/4 plans landed; 24/24 UAT baseline preserved or extended to 28/28 (A24+A25+A26+A27 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)"
|
||||||
|
contains: "assertA24|assertA25|assertA26|assertA27"
|
||||||
|
- path: "tests/uat/lib/harness-page-driver.ts"
|
||||||
|
provides: "driveA24, driveA25, driveA26, driveA27 page.evaluate wrappers"
|
||||||
|
contains: "driveA24|driveA25|driveA26|driveA27"
|
||||||
|
- path: "tests/uat/harness.test.ts"
|
||||||
|
provides: "Orchestrator runs A24+A25+A26+A27 after A23; FORBIDDEN_HOOK_STRINGS lockstep if new hook symbols"
|
||||||
|
contains: "driveA24|driveA25|driveA26|driveA27"
|
||||||
|
- 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..A27"
|
||||||
|
via: "import + sequential orchestrator dispatch after driveA23"
|
||||||
|
pattern: "driveA24|driveA25|driveA26|driveA27"
|
||||||
|
- 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 assertions covering the D-P2-01 +
|
||||||
|
D-P2-02 + D-P2-03 contracts end-to-end through a real Chrome instance. 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:
|
||||||
|
- 4 new harness assertions (A24+A25+A26+A27) wired through page-harness + driver + orchestrator.
|
||||||
|
- 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 — meta.urls shape + multi-tab dedup empirical (D-P2-02)</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 page-side: requires multi-tab activation BEFORE save. Sequence:
|
||||||
|
(1) setupFreshRecording
|
||||||
|
(2) Open tab A (e.g., chrome.tabs.create({ url: 'https://example.com' })) and wait for it to load
|
||||||
|
(3) Activate tab A
|
||||||
|
(4) Open tab B (e.g., chrome.tabs.create({ url: 'https://www.iana.org' })) and wait
|
||||||
|
(5) Activate tab B
|
||||||
|
(6) Wait 11s for one segment to land
|
||||||
|
(7) Dispatch SAVE_ARCHIVE
|
||||||
|
(8) Read meta.urls from the produced zip
|
||||||
|
(9) Assert both example.com AND iana.org appear in meta.urls
|
||||||
|
(10) Cleanup: close both tabs
|
||||||
|
- PLANNER-NOTE on tabs permission limitation: A27 depends on chrome.tabs URL access. Per Plan
|
||||||
|
01-13 SUMMARY Known Limitations item 3, `tabs` permission is NOT declared and `chrome.tabs.query`
|
||||||
|
may return tabs without `.url`. RESOLUTION: A27 uses chrome.tabs.create + chrome.tabs.update
|
||||||
|
to drive tab activation directly, bypassing chrome.tabs.query. The tracker's chrome.tabs.onActivated
|
||||||
|
listener fires regardless of permission — it's the post-event chrome.tabs.get(tabId) inside the
|
||||||
|
tracker that may return undefined .url. If A27 reveals the gap empirically, surface a diagnostic
|
||||||
|
in the test result and defer the closure to Phase 4 hardening (CONTEXT.md `<deferred>` tabs
|
||||||
|
permission gap item). If A27 PASSES on the active-tab path, the limitation is non-blocking
|
||||||
|
for Phase 2.
|
||||||
|
- driveA26 + driveA27: standard page.evaluate wrappers (mirror driveA23/A24).
|
||||||
|
- Orchestrator update: tests/uat/harness.test.ts adds A24, A25, A26, A27 to the assertion
|
||||||
|
sequence AFTER A23. Total target: 24 (Phase 1 baseline) + 4 (Phase 2) = 28/28 GREEN.
|
||||||
|
- FORBIDDEN_HOOK_STRINGS lockstep: PLANNER-DECISION — A24/A25/A26/A27 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 — D-P2-02 empirical: multi-tab URL tracking. Activates two distinct
|
||||||
|
* URLs during the recording window; meta.urls should contain both.
|
||||||
|
*/
|
||||||
|
async function assertA27(): Promise<AssertionResult> {
|
||||||
|
const checks: CheckRecord[] = [];
|
||||||
|
const diagnostics: string[] = [];
|
||||||
|
|
||||||
|
await setupFreshRecording();
|
||||||
|
|
||||||
|
// Open + activate 2 distinct tabs.
|
||||||
|
const tabA = await chrome.tabs.create({ url: 'https://example.com', active: true });
|
||||||
|
await new Promise(r => setTimeout(r, 1500));
|
||||||
|
const tabB = await chrome.tabs.create({ url: 'https://www.iana.org', active: true });
|
||||||
|
await new Promise(r => setTimeout(r, 1500));
|
||||||
|
|
||||||
|
// 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',
|
||||||
|
expected: true, actual: ack.success, passed: ack.success === true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup tabs.
|
||||||
|
if (tabA.id) await chrome.tabs.remove(tabA.id);
|
||||||
|
if (tabB.id) await chrome.tabs.remove(tabB.id);
|
||||||
|
|
||||||
|
diagnostics.push(`opened tabA=${tabA.id} tabB=${tabB.id}; meta.urls inspection deferred to host-side`);
|
||||||
|
return { name: 'A27 — multi-tab URL dedup (D-P2-02)', 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) });
|
||||||
|
```
|
||||||
|
Final target: 28/28 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: A24+A25+A26+A27 collectively validate the
|
||||||
|
full Phase 2 contract empirically. After A27, 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|FAIL|PASS|28/28)" | tail -30</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
A26 + A27 page-side + drivers wired. Orchestrator runs 28 assertions sequentially. ALL 28 GREEN.
|
||||||
|
FORBIDDEN_HOOK_STRINGS unchanged at 12. Atomic commit:
|
||||||
|
`feat(02-04): harness A26+A27 — empirical meta.json 8-field + multi-tab urls[] verification (D-P2-02/03)`.
|
||||||
|
</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:
|
||||||
|
(a) `schemaVersion: "2"` present
|
||||||
|
(b) `urls` field is an ARRAY (not a string)
|
||||||
|
(c) `urls` contains at least `https://example.com/` AND `https://www.iana.org/`
|
||||||
|
(d) NO `url` field present (just `urls`)
|
||||||
|
(e) Exactly 8 keys total
|
||||||
|
|
||||||
|
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 GREEN (28/28).
|
||||||
|
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 existing extension capability set per manifest.json. No new manifest permissions 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` → 28/28 GREEN.
|
||||||
|
- Operator empirical Task 4 Step 2 → "approved" or surfaces deviations to drive Plan 02-05 follow-up.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
1. UAT harness 28/28 GREEN (A0-A23 Phase 1 baseline + A24+A25+A26+A27 Phase 2 extension).
|
||||||
|
2. Vitest full suite GREEN (Phase 1 baseline + Plan 02-01 RED tests now all GREEN post-Plans 02-02 + 02-03).
|
||||||
|
3. Tier-1 FORBIDDEN_HOOK_STRINGS = 12 (unchanged — A24+A27 use chrome.* monkey-patch + production APIs).
|
||||||
|
4. Pre-checkpoint bundle gates PASS per `feedback-pre-checkpoint-bundle-gates.md`.
|
||||||
|
5. Operator empirical UAT cycle 1 ack "approved" OR documented deviations.
|
||||||
|
6. 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:
|
||||||
|
- 4 new harness assertions (A24+A25+A26+A27) with their check-counts and rationale.
|
||||||
|
- 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.
|
||||||
|
- 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>
|
||||||
Reference in New Issue
Block a user