diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md index 7aa8b14..82607b5 100644 --- a/.planning/PROJECT.md +++ b/.planning/PROJECT.md @@ -134,7 +134,7 @@ nothing is validated until SPEC §10 acceptance passes.) | **DEC-008**: Screenshot via `chrome.tabs.captureVisibleTab` | SPEC §4.4, §5 — captured at export time, not continuously. | — Pending | locked (Phase 1) | | **DEC-009**: WebM header chunk retained indefinitely | SPEC §4.1, §8 — WebM without its header is not playable. | — Pending | locked (Phase 1) | | **DEC-010**: Service Worker keepalive via long-lived port | AMENDED by Phase 01: SPEC §8 originally specified `chrome.alarms` at 20 s; Phase 01 swaps to a `chrome.runtime.connect` port between offscreen and SW with 25 s ping cadence and 290 s pre-emptive reconnect. See `.planning/intel/decisions.md` DEC-010 Amendment. | — Pending | locked (Phase 1, post-Amendment) | -| **DEC-011**: Manifest permissions set | SPEC §7 — `tabCapture`, `activeTab`, `downloads`, `scripting`, `storage` + `host_permissions: [""]`. | — Pending | locked (Phase 1) | +| **DEC-011**: Manifest permissions set | AMENDED 2026-05-20 (Amendment 1) by Plan 02-03: SPEC §7 originally specified `tabCapture`, `activeTab`, `downloads`, `scripting`, `storage` + `host_permissions: [""]`. Phase 01 retired `tabCapture` (DEC-003 Amendment) and added `desktopCapture`, `offscreen`, `notifications`. Amendment 1 (2026-05-20) ADDS `tabs` to enable `chrome.tabs.get(tabId).url` + `chrome.tabs.query({})` for the Phase 2 D-P2-02 `meta.urls` feature (tab-url-tracker requires URL visibility beyond active-tab semantics). Current locked set: `desktopCapture`, `activeTab`, `tabs`, `downloads`, `scripting`, `storage`, `offscreen`, `notifications` + `host_permissions: [""]`. Audit T-1-02 ("unused permissions expand attack surface") is acknowledged but overridden — the permission is genuinely USED by the meta.urls feature, so it is not unused. See `.planning/phases/02-stabilize-export-pipeline/02-CONTEXT.md` Revision Log. | — Pending | locked (Phase 1, post-Amendment 1) | | **DEC-012**: Vite + crxjs + TypeScript build toolchain | README §"Технический стек" — DOC-level only; SPEC does not prescribe. | — Pending | locked (Phase 1) — auto-overridable by future ADR | ## Success Metric (Developer-Facing) diff --git a/.planning/phases/02-stabilize-export-pipeline/02-01-PLAN.md b/.planning/phases/02-stabilize-export-pipeline/02-01-PLAN.md index 50e19a9..2e21ed6 100644 --- a/.planning/phases/02-stabilize-export-pipeline/02-01-PLAN.md +++ b/.planning/phases/02-stabilize-export-pipeline/02-01-PLAN.md @@ -25,11 +25,11 @@ 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." + - "meta-json-urls-schema.test.ts pins SessionMetadata.urls as string[], createArchive emitting meta.json with urls (not url), AND the F2-pinned empty-tracker contract (urls: [] for whole-desktop-no-tab sessions; NO sentinel fallback)." + - "strict-meta-json-validation.test.ts pins ISO-8601 Z-suffix timestamp, urls is an Array of filter-matching URLs (empty PERMITTED per F2 resolution), 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)." + - "153/153 baseline vitest count rises to 153 + N (where N = number of new it() blocks; expected ~16 after F2 added Test 5 to meta-json-urls-schema.test.ts)." artifacts: - path: "tests/background/blob-url-download.test.ts" provides: "RED pin: downloadArchive calls chrome.downloads.download({ url: blob:..., filename })" @@ -229,8 +229,14 @@ Existing test conventions (read these for stub patterns): '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 + - Test 5 ("empty-tracker → meta.urls === []", per F2 plan-checker iteration 1): + mock the tab-tracking source to return an EMPTY array (whole-desktop-no-tab session); + invoke createArchive; assert `meta.urls` is EXACTLY `[]` (empty Array; not undefined; not + `[extension-origin-URL]`; not `null`). This pins the F2 resolution: NO sentinel fallback. + The empty array is the canonical representation of a whole-desktop recording with no browser + tabs open at SAVE time. + - All 5 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+5 via the tab-tracking module not existing yet (these will throw import errors — acceptable as the RED signal). @@ -256,10 +262,10 @@ Existing test conventions (read these for stub patterns): npx vitest run tests/background/meta-json-urls-schema.test.ts 2>&1 | grep -E "(FAIL|RED|failed|TS\d+)" | head -10 - 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 + File exists, 5 it() blocks under a single describe. Test 1 fails via tsc compile error on missing + `urls` field. Tests 2-5 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)`. + Commit message: `test(02-01): RED — pin meta.json urls[] schema + dedup/filter + empty-tracker semantics (D-P2-02 + F2)`. @@ -270,14 +276,15 @@ Existing test conventions (read these for stub patterns): - 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 `` 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 `` 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 3 ("urls is an Array of valid URLs; empty IS PERMITTED for whole-desktop sessions"): + `Array.isArray(meta.urls) && meta.urls.every(u => /^(https?|chrome-extension):\/\//.test(u))`. + Per F2 (plan-checker iteration 1 resolution): the empty-tracker case is MEANINGFUL (whole-desktop + recording with no browser tabs open). The strict-validation rule from D-P2-03 ("urls array + non-empty") is RELAXED to PERMIT empty arrays here; CONTEXT.md `` permissive clause + wins over the original D-P2-03 strict clause. Operator empirical workflow (Plan 02-04 Task 4 + Step 2) verifies the non-empty case via the multi-tab path. Document this resolution inline + in the test as a block comment citing both CONTEXT.md sources AND the F2 plan-checker + iteration 1 decision. - 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`. @@ -347,9 +354,9 @@ Existing test conventions (read these for stub patterns): - `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 — +- `npx vitest run tests/background/meta-json-urls-schema.test.ts` → 5 RED (includes F2 empty-tracker → urls:[] pin). +- `npx vitest run tests/build/strict-meta-json-validation.test.ts` → 8 RED (Test 3 RELAXED to permit empty urls[]). +- `npm test` → previously-153 GREEN + N RED (16 ± 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 diff --git a/.planning/phases/02-stabilize-export-pipeline/02-02-PLAN.md b/.planning/phases/02-stabilize-export-pipeline/02-02-PLAN.md index e6a8769..61518ac 100644 --- a/.planning/phases/02-stabilize-export-pipeline/02-02-PLAN.md +++ b/.planning/phases/02-stabilize-export-pipeline/02-02-PLAN.md @@ -290,7 +290,7 @@ Existing blobToBase64 / base64ToBlob (src/shared/binary.ts:42-85) — REUSE AS-I (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')`. + (5) If response.url === '' or no response within timeout (5000ms), throw a typed error: `new Error('blob-url-mint-failed: offscreen unresponsive or empty url')`. NO FALLBACK to legacy data: URL — the blob: URL is the contract; failure surfaces via the typed error so the operator gets a clear failure mode (routed through saveArchive's catch block to the RECORDING_ERROR channel, mirroring the EmptyVideoBufferError pattern). Silent corrupt-archive paths are forbidden. (6) Call chrome.downloads.download({ url, filename, saveAs: false }) capturing the returned downloadId. (7) Store downloadId → url in a module-scoped Map (`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): @@ -433,7 +433,7 @@ Existing blobToBase64 / base64ToBlob (src/shared/binary.ts:42-85) — REUSE AS-I listener for URL.revokeObjectURL lifecycle. - 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 + 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 ; npm test 2>&1 | tail -30 tsc clean. npm run build clean. tests/background/blob-url-download.test.ts → 3/3 GREEN. diff --git a/.planning/phases/02-stabilize-export-pipeline/02-03-PLAN.md b/.planning/phases/02-stabilize-export-pipeline/02-03-PLAN.md index 0eb695f..4d94d96 100644 --- a/.planning/phases/02-stabilize-export-pipeline/02-03-PLAN.md +++ b/.planning/phases/02-stabilize-export-pipeline/02-03-PLAN.md @@ -5,6 +5,9 @@ type: auto wave: 2 depends_on: [01] files_modified: + - manifest.json + - tests/i18n/manifest-i18n.test.ts + - .planning/PROJECT.md - src/background/tab-url-tracker.ts - src/shared/types.ts - src/background/index.ts @@ -28,7 +31,7 @@ must_haves: - ".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)." + - "DEC-011 amended (Amendment 1, 2026-05-20): `tabs` permission ADDED to manifest.json by this plan. chrome.tabs.get(tabId).url is now reliably defined for all tabs in any window (not just activeTab). The tab-url-tracker enumerates chrome.tabs.query({}) at SAVE time as a defensive fallback to capture tabs the operator OPENED but never activated during the 30s window. tests/i18n/manifest-i18n.test.ts pins the new 8-entry permission set as a regression guard." artifacts: - path: "src/background/tab-url-tracker.ts" provides: "getTabUrlsSeen(): string[] + initTabUrlTracker(): void + clearTabUrlsSeen(): void" @@ -43,6 +46,15 @@ must_haves: - path: ".planning/REQUIREMENTS.md" provides: "REQ-meta-json-schema amended for 8-field shape + breaking-change cutover note" contains: "schemaVersion|urls.*string\\[\\]" + - path: "manifest.json" + provides: "permissions array extended to include `tabs` (DEC-011 Amendment 1)" + contains: "\"tabs\"" + - path: "tests/i18n/manifest-i18n.test.ts" + provides: "regression-pin describe block for the 8-entry permission set including `tabs`" + contains: "DEC-011 Amendment 1|EXPECTED_PERMISSIONS" + - path: ".planning/PROJECT.md" + provides: "DEC-011 row rewritten with Amendment 1 (2026-05-20) citing Phase 2 D-P2-02 as justification" + contains: "Amendment 1" key_links: - from: "src/background/index.ts:initialize" to: "src/background/tab-url-tracker.ts:initTabUrlTracker" @@ -189,6 +201,65 @@ REQ-meta-json-schema (REQUIREMENTS.md:106-119) — verbatim text to amend: + + Task 0: DEC-011 Amendment 1 — add `tabs` to manifest + sync PROJECT.md + i18n permission-pin test + manifest.json, .planning/PROJECT.md, tests/i18n/manifest-i18n.test.ts + + Per plan-checker iteration 1 BLOCKER B1 resolution (user-approved Option A: honor D-P2-02 + "all tabs visible" verbatim by adding `tabs` permission to manifest.json). + + 1. **manifest.json:** extend the `permissions` array to include `"tabs"`. The locked + post-amendment set has exactly 8 entries (alphabetic order is NOT required; the + i18n test pins membership via set-equality, not order): + ```json + "permissions": [ + "desktopCapture", + "activeTab", + "tabs", + "downloads", + "scripting", + "storage", + "offscreen", + "notifications" + ] + ``` + NOTE: this commit may have already been applied during the revision pass — verify before + editing. If `grep -c '"tabs"' manifest.json` returns a value matching the post-amendment + state, treat this step as a no-op verification and proceed. + + 2. **.planning/PROJECT.md DEC-011 row:** rewrite the row to embed Amendment 1 + (2026-05-20) referencing Phase 2 D-P2-02 as justification. The amended row must: + (a) Cite SPEC §7 as the original source and DEC-003 Amendment as Phase 01 delta. + (b) State Amendment 1 ADDS `tabs` for D-P2-02 meta.urls feature. + (c) List the current locked set verbatim (8 permissions + host_permissions). + (d) Acknowledge audit T-1-02 ("unused permissions expand attack surface") and + explicitly override it: the permission is USED by the meta.urls feature, so + it is not unused. + (e) Set status to `locked (Phase 1, post-Amendment 1)`. + + 3. **tests/i18n/manifest-i18n.test.ts:** add a new describe block + `"Plan 02-03 DEC-011 Amendment 1: manifest.json permissions include \"tabs\" (D-P2-02)"` + containing TWO assertions: + (a) `manifest.permissions.includes('tabs') === true` + (b) Set-equality between `manifest.permissions` (sorted) and the locked 8-entry + EXPECTED_PERMISSIONS constant (sorted). Both lengths must match. + Pre-existing test blocks (`Plan 01-12: ...`) MUST remain GREEN unchanged. + + The amendment closes plan-checker iteration 1 BLOCKER B1 and unblocks Plan 02-04 A27's + strict-mode (both-URLs-required) assertion — see Plan 02-04 Task 3 amendment in the same + iteration 1 pass. + + + grep -c '"tabs"' manifest.json ; grep -c 'Amendment 1' .planning/PROJECT.md ; npx vitest run tests/i18n/manifest-i18n.test.ts 2>&1 | tail -10 + + + manifest.json permissions includes `"tabs"` exactly once. PROJECT.md DEC-011 row carries + Amendment 1 prose (≥1 grep hit for "Amendment 1"). tests/i18n/manifest-i18n.test.ts new + describe block GREEN (2/2 assertions pass). Atomic commit shared with the rest of the + iteration 1 revision pass — see commit conventions in the orchestrator brief. + + + Task 1: Create src/background/tab-url-tracker.ts module src/background/tab-url-tracker.ts @@ -239,19 +310,31 @@ REQ-meta-json-schema (REQUIREMENTS.md:106-119) — verbatim text to amend: 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 `` "tabs permission gap"). Document this limitation inline. + NOTE on `tabs` permission (post DEC-011 Amendment 1, 2026-05-20): the new task in this plan + AMENDS manifest.json to include the `tabs` permission. With `tabs` granted, chrome.tabs.get(tabId) + reliably populates the `.url` field for any tab in any window — closing the limitation noted in + Plan 01-13 SUMMARY Known Limitations item 3. The tracker therefore captures URL transitions for + BOTH active and inactive tabs (the `chrome.tabs.onUpdated` listener already fires for any tab's + URL change regardless of active state). A diagnostic logger.warn fires only if `.url` is still + missing after the permission is granted (shouldn't happen — defensive only). - 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[]. + SAVE-time defensive enumeration (per F2 + DEC-011 Amendment 1 capability): in addition to the + event-driven Set maintenance, the tracker exposes a `snapshotOpenTabs(): Promise` helper + that invokes `chrome.tabs.query({})` and folds every returned tab.url (that passes the filter) + into the internal Set + firstSeenOrder. createArchive (Task 2 below) calls this helper BEFORE + reading `getTabUrlsSeen()` so any tab the operator OPENED during the 30s window but never + activated (so the onActivated listener never fired for it) is still captured. This is purely + additive — duplicates dedup; order preserves first-seen-first; no behavior change for + already-observed tabs. + + Per F2 (plan-checker iteration 1): the empty-tracker case is NO LONGER a sentinel-URL fallback. + If after `snapshotOpenTabs()` the tracker is STILL empty (e.g., whole-desktop recording with NO + browser tabs open at SAVE time — operator captured a non-Chrome window via desktopCapture only), + that is a MEANINGFUL state and is represented faithfully as `urls: []` (empty array). The strict- + validation rule in `tests/build/strict-meta-json-validation.test.ts` Test 3 was relaxed in Plan + 02-01 iteration 1 to PERMIT empty urls[] for this case; Plan 02-01's RED tests pin the + empty-tracker → `meta.urls === []` contract explicitly. NO fake extension-origin URL is + inserted. npx tsc --noEmit 2>&1 | head -20 @@ -269,16 +352,27 @@ REQ-meta-json-schema (REQUIREMENTS.md:106-119) — verbatim text to amend: - 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'; + (a) import { initTabUrlTracker, snapshotOpenTabs, 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: + (c) createArchive (line 673-684) updated. Per F2 (plan-checker iteration 1) the empty-tracker case is NOT a sentinel-URL fallback; it is `urls: []`: ```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]; + // DEC-011 Amendment 1: with `tabs` permission granted, snapshot every currently-open + // tab's URL at SAVE time as a defensive fallback. This captures tabs the operator + // OPENED during the 30s window but never activated (so the onActivated listener + // never fired for them). Dedup + order preservation is handled by the tracker. + try { + await snapshotOpenTabs(); + } catch (err) { + logger.warn('snapshotOpenTabs failed at SAVE time (continuing with tracker state):', err); + } + const urls = getTabUrlsSeen(); + if (urls.length === 0) { + // Meaningful state: whole-desktop recording with NO browser tabs open. Per F2 + // resolution, this is represented faithfully as urls:[] — NO fake extension-origin + // URL inserted. tests/background/meta-json-urls-schema.test.ts pins this contract. + logger.warn('createArchive: tabUrlsSeen is empty after snapshotOpenTabs — emitting meta.urls=[] (whole-desktop session with no browser tabs)'); + } const metadata: SessionMetadata = { schemaVersion: '2', timestamp: new Date().toISOString(), @@ -308,7 +402,7 @@ REQ-meta-json-schema (REQUIREMENTS.md:106-119) — verbatim text to amend: decision to make schemaVersion the 8th field. 2. Edit `src/background/index.ts`: - - Add import: `import { initTabUrlTracker, getTabUrlsSeen } from './tab-url-tracker';` near + - Add import: `import { initTabUrlTracker, snapshotOpenTabs, 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 @@ -436,25 +530,28 @@ REQ-meta-json-schema (REQUIREMENTS.md:106-119) — verbatim text to amend: - `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). +- DEC-011 Amendment 1 verification: `grep -c '"tabs"' manifest.json` ≥ 1; `npx vitest run tests/i18n/manifest-i18n.test.ts` GREEN (pre-existing 10 tests + 2 new permission-set tests = 12/12 GREEN); `grep -c 'Amendment 1' .planning/PROJECT.md` ≥ 1. -1. tests/background/meta-json-urls-schema.test.ts 4/4 GREEN. -2. tests/build/strict-meta-json-validation.test.ts 8/8 GREEN. +1. tests/background/meta-json-urls-schema.test.ts 4/4 GREEN (including the F2-pinned empty-tracker → `meta.urls === []` contract). +2. tests/build/strict-meta-json-validation.test.ts 8/8 GREEN (Test 3 relaxed to PERMIT empty urls[] per F2 resolution). 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 ``. +7. DEC-011 Amendment 1 LANDED: manifest.json permissions includes `"tabs"`; PROJECT.md DEC-011 row carries Amendment 1 prose; tests/i18n/manifest-i18n.test.ts permission-set describe block GREEN. The `tabs` permission gap noted in Plan 01-13 SUMMARY Known Limitations item 3 is CLOSED by this plan, not deferred. +8. createArchive empty-tracker fallback REMOVED (F2): emits `urls: []` + logger.warn for whole-desktop-no-tab sessions; NO fake extension-origin URL inserted. 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) +- New module src/background/tab-url-tracker.ts (LOC + public API summary) — 4 exports incl. snapshotOpenTabs (DEC-011 Amendment 1) - 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 `` -- Tabs permission gap deferral citation (CONTEXT.md ``) -- Forward link: Plan 02-04 may amend UAT harness A13 if its meta.json assertions assumed the old `url` field +- DEC-011 Amendment 1 (2026-05-20): `tabs` permission ADDED; tests/i18n/manifest-i18n.test.ts pins the 8-entry set; PROJECT.md DEC-011 row rewritten with Amendment 1 prose +- F2 resolution: empty-tracker case emits `urls: []` (no fake extension-origin fallback) +- Forward link: Plan 02-04 A27 strict-mode (both URLs required) is unblocked by Amendment 1 diff --git a/.planning/phases/02-stabilize-export-pipeline/02-04-PLAN.md b/.planning/phases/02-stabilize-export-pipeline/02-04-PLAN.md index 44889f9..88b7be3 100644 --- a/.planning/phases/02-stabilize-export-pipeline/02-04-PLAN.md +++ b/.planning/phases/02-stabilize-export-pipeline/02-04-PLAN.md @@ -22,9 +22,13 @@ tags: - a25 - a26 - a27 + - a28 + - a27-strict-mode + - dec-011-amendment-1 - blob-url-empirical - latency-5s - meta-urls-shape + - archive-layout-strict - operator-checkpoint - phase-2-closure - approach-b @@ -34,29 +38,30 @@ must_haves: - "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)." + - "A27 STRICT mode (post DEC-011 Amendment 1, 2026-05-20): harness opens TWO tabs in sequence via chrome.tabs.create + activates each via chrome.tabs.update; after SAVE_ARCHIVE, meta.urls is EXACTLY `[url1, url2]` (order-flexible; ≥2 length REQUIRED; NO `[object Object]`, NO extension-origin sentinel, NO chrome:// URLs). Test FAILS on length < 2 OR on any missing URL string." - "Tier-1 FORBIDDEN_HOOK_STRINGS inventory extended IF new hook surfaces required for A24+; lockstep across tests/background/no-test-hooks-in-prod-bundle.test.ts AND tests/uat/harness.test.ts A0 mirror." - "Final operator empirical checkpoint validates: (a) saving a real ~6MB archive completes successfully (was: data:URL Network error pre-Plan-02-02); (b) the produced meta.json has the new 8-field shape (operator opens the zip with archive-manager tool); (c) the saved zip opens cleanly in the OS file manager." - "Pre-checkpoint bundle gates per saved memory `feedback-pre-checkpoint-bundle-gates.md` are run BEFORE operator checkpoint: SW CSP grep (no `new Function`/`eval`), SW Node-globals grep (no `Buffer.from`), DOM-globals grep, SW-bundle-import gate, manifest validation." - - "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." + - "A28 verifies REQ-archive-layout: the downloaded zip contains EXACTLY 5 entries — `video/last_30sec.webm`, `rrweb/session.json`, `logs/events.json`, `screenshot.png`, `meta.json` (no extras, no missing). Uses JSZip parsing in the harness driver against the host-side zip blob. Cross-references REQ-archive-layout + REQ-popup-ui (popup-triggered SAVE) + REQ-screenshot-on-export." + - "Phase 2 closes with 4/4 plans landed; 24/24 UAT baseline preserved or extended to 29/29 (A24+A25+A26+A27+A28 inclusive); vitest baseline preserved or extended; operator empirical ack documented." artifacts: - path: "tests/uat/extension-page-harness.ts" - provides: "assertA24 (blob: URL prefix) + assertA25 (5s latency) + assertA26 (meta.urls shape) + assertA27 (multi-tab URL dedup)" - contains: "assertA24|assertA25|assertA26|assertA27" + provides: "assertA24 (blob: URL prefix) + assertA25 (5s latency) + assertA26 (meta.urls shape) + assertA27 (multi-tab URL dedup, STRICT) + assertA28 (REQ-archive-layout strict zip-layout)" + contains: "assertA24|assertA25|assertA26|assertA27|assertA28" - path: "tests/uat/lib/harness-page-driver.ts" - provides: "driveA24, driveA25, driveA26, driveA27 page.evaluate wrappers" - contains: "driveA24|driveA25|driveA26|driveA27" + provides: "driveA24, driveA25, driveA26, driveA27, driveA28 page.evaluate wrappers" + contains: "driveA24|driveA25|driveA26|driveA27|driveA28" - path: "tests/uat/harness.test.ts" - provides: "Orchestrator runs A24+A25+A26+A27 after A23; FORBIDDEN_HOOK_STRINGS lockstep if new hook symbols" - contains: "driveA24|driveA25|driveA26|driveA27" + provides: "Orchestrator runs A24+A25+A26+A27+A28 after A23; FORBIDDEN_HOOK_STRINGS lockstep if new hook symbols" + contains: "driveA24|driveA25|driveA26|driveA27|driveA28" - path: "tests/background/no-test-hooks-in-prod-bundle.test.ts" provides: "Lockstep FORBIDDEN_HOOK_STRINGS update if new hook symbols introduced" contains: "FORBIDDEN_HOOK_STRINGS" key_links: - from: "tests/uat/harness.test.ts" - to: "tests/uat/lib/harness-page-driver.ts:driveA24..A27" + to: "tests/uat/lib/harness-page-driver.ts:driveA24..A28" via: "import + sequential orchestrator dispatch after driveA23" - pattern: "driveA24|driveA25|driveA26|driveA27" + pattern: "driveA24|driveA25|driveA26|driveA27|driveA28" - from: "tests/uat/extension-page-harness.ts:assertA25" to: "performance.now() bookends around SAVE_ARCHIVE dispatch + host-side downloadsDir poll" via: "page-side measures dispatch→ack; host-side measures dispatch→file-on-disk; assert total < 5000ms" @@ -68,9 +73,11 @@ must_haves: --- -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. +Wave 3 of Phase 2: extend the UAT harness with A24+A25+A26+A27+A28 assertions covering the +D-P2-01 + D-P2-02 + D-P2-03 contracts AND REQ-archive-layout end-to-end through a real Chrome +instance. A27 runs in STRICT mode (post DEC-011 Amendment 1) — REQUIRES both multi-tab URLs in +meta.urls; FAILS on length<2. A28 strict-pins the 5-entry zip layout (no extras). Run pre-checkpoint +bundle gates per saved memory before surfacing the final operator empirical checkpoint. Close Phase 2. Purpose: empirical verification that Plans 02-02 (Blob URL pipeline) and 02-03 (meta.urls schema) work in a real Chrome instance, not just in unit-test isolation. The harness extension is the @@ -78,7 +85,9 @@ closure gate for Phase 2 — analogous to Plan 01-13's 15/15 harness PASS that c functional contract. Output: -- 4 new harness assertions (A24+A25+A26+A27) wired through page-harness + driver + orchestrator. +- 5 new harness assertions (A24+A25+A26+A27+A28) wired through page-harness + driver + orchestrator. +- A27 in STRICT mode (post DEC-011 Amendment 1) — both URLs REQUIRED; FAILS on length<2. +- A28 pins REQ-archive-layout: exactly 5 zip entries (`video/last_30sec.webm`, `rrweb/session.json`, `logs/events.json`, `screenshot.png`, `meta.json`). - Tier-1 FORBIDDEN_HOOK_STRINGS lockstep IF new hook symbols required. - Pre-checkpoint bundle gate validation completed. - Operator empirical checkpoint documenting Plan 02-02 + 02-03 + the 5s-latency contract on a @@ -403,7 +412,7 @@ tests/uat/lib/harness-page-driver.ts:driveA5 (line 215) for the merged-checks pa - Task 3: assertA26 + A27 — meta.urls shape + multi-tab dedup empirical (D-P2-02) + Task 3: assertA26 + A27 (strict) + A28 — meta.urls shape + multi-tab strict + REQ-archive-layout (D-P2-02/03 + REQ-archive-layout) tests/uat/extension-page-harness.ts, tests/uat/lib/harness-page-driver.ts, tests/uat/harness.test.ts - assertA26 page-side: chain off A25's produced zip (read host-side filename via merged checks). @@ -413,32 +422,34 @@ tests/uat/lib/harness-page-driver.ts:driveA5 (line 215) for the merged-checks pa (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: + - assertA27 STRICT mode (post DEC-011 Amendment 1) — REQUIRES both multi-tab URLs in meta.urls. 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 + (2) Open tab A: `chrome.tabs.create({ url: 'https://example.com/', active: false })`; wait 1500ms for navigation + (3) Activate tab A: `chrome.tabs.update(tabA.id, { active: true })`; wait 500ms for onActivated to fire + (4) Open tab B: `chrome.tabs.create({ url: 'https://www.iana.org/', active: false })`; wait 1500ms for navigation + (5) Activate tab B: `chrome.tabs.update(tabB.id, { active: true })`; wait 500ms for onActivated to fire (6) Wait 11s for one segment to land - (7) Dispatch SAVE_ARCHIVE - (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 `` 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. + (7) Dispatch SAVE_ARCHIVE; await ack with success===true + (8) Host-side: load latest zip; parse meta.json; read meta.urls + (9) Strict assertions (all REQUIRED to pass): + (a) `meta.urls.length >= 2` (FAIL on length < 2) + (b) `meta.urls` contains BOTH 'https://example.com/' AND 'https://www.iana.org/' (order-flexible; use Set membership) + (c) `meta.urls.every(u => typeof u === 'string' && u.length > 0)` — no `[object Object]`, no nulls, no empty strings + (d) `meta.urls.every(u => !u.startsWith('chrome-extension://'))` — no extension-origin sentinel fallback (F2) + (e) `meta.urls.every(u => !u.startsWith('chrome://'))` — no chrome-internal URLs + (10) Cleanup: try/catch close both tabs via chrome.tabs.remove (silent-ignore on already-closed) + DEC-011 Amendment 1 guarantee: with `tabs` permission granted, chrome.tabs.get(tabId).url + and chrome.tabs.query({}) reliably populate the URL field. snapshotOpenTabs at SAVE time + (Plan 02-03 Task 2) provides a defensive belt + suspenders. A27 has UNAMBIGUOUS contract: + both URLs MUST appear; the test is the binding empirical gate for D-P2-02. + - assertA28 (REQ-archive-layout strict zip-layout): chain off A25's produced zip (reuse merged-checks pattern). Use JSZip.loadAsync to enumerate zip entries; assert: + (a) Zip contains EXACTLY 5 entries (no more, no fewer) + (b) The 5 paths are EXACTLY `video/last_30sec.webm`, `rrweb/session.json`, `logs/events.json`, `screenshot.png`, `meta.json` (set-equality; order-flexible) + (c) NO extra entries (no `__MACOSX/`, no `.DS_Store`, no temp files) + Cross-references REQ-archive-layout + REQ-popup-ui (popup-triggered SAVE flows through the same createArchive path) + REQ-screenshot-on-export (screenshot.png entry verified present). + - driveA26 + driveA27 + driveA28: standard page.evaluate wrappers + host-side zip-lookup (mirror driveA5's merged-checks pattern). driveA27 additionally inspects meta.urls host-side via JSZip; driveA28 inspects the zip directory listing. + - Orchestrator update: tests/uat/harness.test.ts adds A24, A25, A26, A27, A28 to the assertion sequence AFTER A23. Total target: 24 (Phase 1 baseline) + 5 (Phase 2) = 29/29 GREEN. + - FORBIDDEN_HOOK_STRINGS lockstep: A24/A25/A26/A27/A28 use chrome.* monkey-patch (A24's downloads spy) + JSZip + chrome.tabs.create/update which are production APIs. No new test-hook surface required. Tier-1 inventory STAYS at 12. Plan-checker verifies via final grep. Add `assertA26` and `assertA27` to tests/uat/extension-page-harness.ts: @@ -494,20 +505,35 @@ tests/uat/lib/harness-page-driver.ts:driveA5 (line 215) for the merged-checks pa } /** - * A27 — D-P2-02 empirical: multi-tab URL tracking. Activates two distinct - * URLs during the recording window; meta.urls should contain both. + * A27 — STRICT mode (post DEC-011 Amendment 1): multi-tab URL tracking. + * Opens TWO tabs sequentially, activates each, then dispatches SAVE. + * Host-side driver asserts meta.urls contains BOTH URLs (length >= 2 REQUIRED; + * FAILS on length < 2; no extension-origin sentinel; no chrome-internal URLs). */ - async function assertA27(): Promise { + async function assertA27(): Promise { 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 }); + const TAB_A_URL = 'https://example.com/'; + const TAB_B_URL = 'https://www.iana.org/'; + + // Open + activate tab A. + const tabA = await chrome.tabs.create({ url: TAB_A_URL, active: false }); await new Promise(r => setTimeout(r, 1500)); - const tabB = await chrome.tabs.create({ url: 'https://www.iana.org', active: true }); + if (tabA.id !== undefined) { + await chrome.tabs.update(tabA.id, { active: true }); + await new Promise(r => setTimeout(r, 500)); + } + + // Open + activate tab B. + const tabB = await chrome.tabs.create({ url: TAB_B_URL, active: false }); await new Promise(r => setTimeout(r, 1500)); + if (tabB.id !== undefined) { + await chrome.tabs.update(tabB.id, { active: true }); + await new Promise(r => setTimeout(r, 500)); + } // Settle for one segment. await new Promise(r => setTimeout(r, 11_000)); @@ -517,16 +543,177 @@ tests/uat/lib/harness-page-driver.ts:driveA5 (line 215) for the merged-checks pa chrome.runtime.sendMessage({ type: 'SAVE_ARCHIVE' }, resolve); }); checks.push({ - name: 'A27.1: SAVE_ARCHIVE ack received', + name: 'A27.1: SAVE_ARCHIVE ack received with success=true', 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); + // Cleanup tabs (silent-ignore on already-closed). + try { if (tabA.id !== undefined) await chrome.tabs.remove(tabA.id); } catch {} + try { if (tabB.id !== undefined) await chrome.tabs.remove(tabB.id); } catch {} - diagnostics.push(`opened tabA=${tabA.id} tabB=${tabB.id}; meta.urls inspection deferred to host-side`); - return { name: 'A27 — multi-tab URL dedup (D-P2-02)', checks, diagnostics }; + diagnostics.push(`opened tabA.id=${tabA.id} url=${TAB_A_URL}; tabB.id=${tabB.id} url=${TAB_B_URL}`); + return { + name: 'A27 — D-P2-02 STRICT multi-tab urls[] (both URLs REQUIRED)', + checks, diagnostics, + tabAUrl: TAB_A_URL, + tabBUrl: TAB_B_URL, + }; + } + + /** + * A28 — REQ-archive-layout strict zip-layout: zip contains EXACTLY the 5 + * canonical entries and no extras. Chains off A25's produced zip via the + * host-side driver's merged-checks pattern (no new SAVE dispatch). + * + * NOTE: page-side assertA28 is a STUB returning the assertion name; all + * real work happens host-side in driveA28 (zip directory enumeration). + */ + async function assertA28(): Promise { + return { + name: 'A28 — REQ-archive-layout strict zip-layout (5 entries)', + checks: [], + diagnostics: ['assertA28 page-side stub; host-side driver inspects zip directory'], + }; + } + ``` + + Host-side driveA27 (merged-checks pattern with strict meta.urls assertions): + + ```typescript + export async function driveA27(page: Page, downloadsDir: string): Promise { + const preExisting = new Set(readdirSync(downloadsDir).filter(isZipFilename)); + const pageResult = await page.evaluate(async () => { + const harness = (window as any).__mokoshHarness; + return await harness.assertA27(); + }) as AssertionRecord & { tabAUrl: string; tabBUrl: string }; + + // Locate the new zip produced by A27. + let zipPath: string | null = null; + const pollStart = Date.now(); + while (Date.now() - pollStart < 8000) { + const candidates = readdirSync(downloadsDir).filter( + (name) => isZipFilename(name) && !preExisting.has(name), + ); + if (candidates.length > 0) { + zipPath = `${downloadsDir}/${candidates[candidates.length - 1]}`; + break; + } + await new Promise(r => setTimeout(r, 100)); + } + + const mergedChecks: CheckRecord[] = pageResult.checks.slice(); + const mergedDiagnostics = pageResult.diagnostics.slice(); + + if (zipPath === null) { + mergedChecks.push({ + name: 'A27.2: zip emitted to downloadsDir', + expected: 'zip file present', actual: 'no zip found within 8s', passed: false, + }); + } else { + const zipBytes = readFileSync(zipPath); + const zip = await JSZip.loadAsync(zipBytes); + const metaFile = zip.file('meta.json'); + const metaText = metaFile !== null ? await metaFile.async('string') : '{}'; + let meta: { urls?: unknown } = {}; + try { meta = JSON.parse(metaText); } catch {} + const urlsRaw = (meta as { urls?: unknown }).urls; + const urls: string[] = Array.isArray(urlsRaw) ? urlsRaw.filter((u): u is string => typeof u === 'string') : []; + + mergedChecks.push({ + name: 'A27.2: meta.urls is an Array', + expected: true, actual: Array.isArray(urlsRaw), passed: Array.isArray(urlsRaw), + }); + mergedChecks.push({ + name: 'A27.3: meta.urls.length >= 2 (STRICT — both URLs REQUIRED post DEC-011 Amendment 1)', + expected: '>=2', actual: urls.length, passed: urls.length >= 2, + }); + mergedChecks.push({ + name: `A27.4: meta.urls contains ${pageResult.tabAUrl}`, + expected: true, actual: urls.includes(pageResult.tabAUrl), passed: urls.includes(pageResult.tabAUrl), + }); + mergedChecks.push({ + name: `A27.5: meta.urls contains ${pageResult.tabBUrl}`, + expected: true, actual: urls.includes(pageResult.tabBUrl), passed: urls.includes(pageResult.tabBUrl), + }); + mergedChecks.push({ + name: 'A27.6: every meta.urls[i] is a non-empty string (no [object Object], no nulls)', + expected: true, + actual: urls.every(u => typeof u === 'string' && u.length > 0), + passed: urls.length > 0 && urls.every(u => typeof u === 'string' && u.length > 0), + }); + mergedChecks.push({ + name: 'A27.7: no extension-origin sentinel URLs (F2 — empty-tracker fallback removed)', + expected: true, + actual: urls.every(u => !u.startsWith('chrome-extension://')), + passed: urls.every(u => !u.startsWith('chrome-extension://')), + }); + mergedChecks.push({ + name: 'A27.8: no chrome-internal URLs in meta.urls (filter rules)', + expected: true, + actual: urls.every(u => !u.startsWith('chrome://') && !u.startsWith('about:')), + passed: urls.every(u => !u.startsWith('chrome://') && !u.startsWith('about:')), + }); + mergedDiagnostics.push(`zipPath=${zipPath}; meta.urls=${JSON.stringify(urls)}`); + } + + return { + passed: mergedChecks.every(c => c.passed), + name: pageResult.name, + checks: mergedChecks, + diagnostics: mergedDiagnostics, + error: pageResult.error, + }; + } + + export async function driveA28(page: Page, downloadsDir: string): Promise { + // Reuse A25's most-recent zip (A28 does NOT trigger a new SAVE — chains off A25). + const zipFiles = readdirSync(downloadsDir).filter(isZipFilename).sort(); + const checks: CheckRecord[] = []; + const diagnostics: string[] = []; + if (zipFiles.length === 0) { + checks.push({ + name: 'A28.1: at least one zip present in downloadsDir', + expected: '>=1', actual: 0, passed: false, + }); + return { passed: false, name: 'A28 — REQ-archive-layout strict (5 entries)', checks, diagnostics }; + } + const latest = `${downloadsDir}/${zipFiles[zipFiles.length - 1]}`; + const zipBytes = readFileSync(latest); + const zip = await JSZip.loadAsync(zipBytes); + + const EXPECTED_PATHS: ReadonlyArray = [ + 'video/last_30sec.webm', + 'rrweb/session.json', + 'logs/events.json', + 'screenshot.png', + 'meta.json', + ]; + const actualPaths = Object.keys(zip.files).filter(p => !zip.files[p].dir).sort(); + const expectedSorted = [...EXPECTED_PATHS].sort(); + + checks.push({ + name: 'A28.1: zip has EXACTLY 5 entries (REQ-archive-layout)', + expected: 5, actual: actualPaths.length, passed: actualPaths.length === 5, + }); + checks.push({ + name: 'A28.2: zip entries set-equal to the canonical 5 paths', + expected: expectedSorted.join(','), + actual: actualPaths.join(','), + passed: JSON.stringify(actualPaths) === JSON.stringify(expectedSorted), + }); + checks.push({ + name: 'A28.3: no extras (no __MACOSX/, no .DS_Store, no temp files)', + expected: 'no extras', + actual: actualPaths.filter(p => !EXPECTED_PATHS.includes(p)).join(',') || 'none', + passed: actualPaths.every(p => EXPECTED_PATHS.includes(p)), + }); + diagnostics.push(`zipPath=${latest}; entries=${actualPaths.join(',')}`); + + return { + passed: checks.every(c => c.passed), + name: 'A28 — REQ-archive-layout strict (5 entries; D-P2-02/03 + REQ-popup-ui + REQ-screenshot-on-export)', + checks, diagnostics, + }; } ``` @@ -544,25 +731,29 @@ tests/uat/lib/harness-page-driver.ts:driveA5 (line 215) for the merged-checks pa drivers.push({ name: 'A25', fn: () => driveA25(page, handles.downloadsDir) }); drivers.push({ name: 'A26', fn: () => driveA26(page, handles.downloadsDir) }); drivers.push({ name: 'A27', fn: () => driveA27(page, handles.downloadsDir) }); + drivers.push({ name: 'A28', fn: () => driveA28(page, handles.downloadsDir) }); ``` - Final target: 28/28 GREEN. + Final target: 29/29 GREEN. FORBIDDEN_HOOK_STRINGS verification: grep the new test files for any new symbols that might leak into dist/. Expected: none (all symbols are local to extension-page-harness.ts which is NOT built into dist/ — it's loaded via the test-harness page, not the production manifest). Tier-1 inventory stays at 12. - Per D-P2-02 + D-P2-03 + REQ-archive-export-latency: 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). + Per D-P2-02 + D-P2-03 + REQ-archive-export-latency + REQ-archive-layout: A24+A25+A26+A27+A28 + collectively validate the full Phase 2 contract empirically. A27 in STRICT mode is the binding + empirical gate for D-P2-02 (post DEC-011 Amendment 1). A28 pins the canonical 5-entry zip layout. + After A28, Phase 2 functional contract is HARNESS-CLOSED (analogous to Plan 01-13's role for Phase 1). - HEADLESS=1 npm run test:uat 2>&1 | grep -E "(A26|A27|FAIL|PASS|28/28)" | tail -30 + HEADLESS=1 npm run test:uat 2>&1 | grep -E "(A26|A27|A28|FAIL|PASS|29/29)" | tail -30 - A26 + A27 page-side + drivers wired. Orchestrator runs 28 assertions sequentially. ALL 28 GREEN. + A26 + A27 (strict) + A28 page-side + drivers wired. Orchestrator runs 29 assertions sequentially. + ALL 29 GREEN. A27 length>=2 strict check passes (both example.com + iana.org appear in meta.urls + per DEC-011 Amendment 1). A28 zip-layout 5-entry strict check passes (REQ-archive-layout). FORBIDDEN_HOOK_STRINGS unchanged at 12. Atomic commit: - `feat(02-04): harness A26+A27 — empirical meta.json 8-field + multi-tab urls[] verification (D-P2-02/03)`. + `feat(02-04): harness A26+A27(strict)+A28 — empirical meta.json 8-field + multi-tab urls[] STRICT + REQ-archive-layout (D-P2-02/03 + DEC-011 Amendment 1)`. @@ -609,12 +800,13 @@ tests/uat/lib/harness-page-driver.ts:driveA5 (line 215) for the merged-checks pa └── meta.json ``` - Step 2.6 — Open meta.json. Verify: + Step 2.6 — Open meta.json. Verify (STRICT, post DEC-011 Amendment 1): (a) `schemaVersion: "2"` present (b) `urls` field is an ARRAY (not a string) - (c) `urls` contains at least `https://example.com/` AND `https://www.iana.org/` + (c) `urls` contains BOTH `https://example.com/` AND `https://www.iana.org/` (length >= 2 REQUIRED) (d) NO `url` field present (just `urls`) (e) Exactly 8 keys total + (f) NO `chrome-extension://...` URLs in `urls` (F2 — empty-tracker fallback removed) Step 2.7 — Open video/last_30sec.webm in a browser (drag into a Chrome tab). Expected: ~30 seconds of video plays end-to-end. @@ -652,7 +844,7 @@ tests/uat/lib/harness-page-driver.ts:driveA5 (line 215) for the merged-checks pa 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). + - Plan 02-04 Tasks 1-3: UAT harness A24+A25+A26+A27(STRICT)+A28 GREEN (29/29). A27 strict-mode unblocked by DEC-011 Amendment 1; A28 pins REQ-archive-layout 5-entry zip. Acceptance baselines preserved: vitest GREEN, UAT GREEN, Tier-1 FORBIDDEN_HOOK_STRINGS = 12, production bundle hook-free. @@ -681,7 +873,7 @@ tests/uat/lib/harness-page-driver.ts:driveA5 (line 215) for the merged-checks pa | 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. | +| T-02-04-05 | Elevation of Privilege | New harness assertions reveal previously-uncovered chrome.* APIs to the test surface | accept | Approach B (Plan 01-13 SUMMARY): the harness page is a privileged extension-internal page that already has full chrome.* access. A24+A27 use chrome.downloads + chrome.tabs which are part of the extension capability set per manifest.json. NOTE: Plan 02-03 (preceding wave) added `tabs` permission via DEC-011 Amendment 1; A27 in STRICT mode consumes that capability. The amendment is the LOCKED scope addition; no further manifest deltas in this plan. | @@ -696,26 +888,30 @@ tests/uat/lib/harness-page-driver.ts:driveA5 (line 215) for the merged-checks pa - `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. +- `HEADLESS=1 npm run test:uat` → 29/29 GREEN (A0-A23 + A24+A25+A26+A27 strict + A28). - Operator empirical Task 4 Step 2 → "approved" or surfaces deviations to drive Plan 02-05 follow-up. -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). +1. UAT harness 29/29 GREEN (A0-A23 Phase 1 baseline + A24+A25+A26+A27 strict + A28 Phase 2 extension). +2. A27 STRICT mode: meta.urls contains both example.com AND iana.org (length >= 2 REQUIRED); no extension-origin sentinels (F2). +3. A28 STRICT mode: zip contains exactly the 5 canonical entries; no extras (REQ-archive-layout). +4. Vitest full suite GREEN (Phase 1 baseline + Plan 02-01 RED tests now all GREEN post-Plans 02-02 + 02-03 + Plan 02-03 Task 0 manifest i18n test additions). +5. Tier-1 FORBIDDEN_HOOK_STRINGS = 12 (unchanged — A24+A27+A28 use chrome.* monkey-patch + production APIs). +6. Pre-checkpoint bundle gates PASS per `feedback-pre-checkpoint-bundle-gates.md`. +7. Operator empirical UAT cycle 1 ack "approved" OR documented deviations. +8. Phase 2 closure marker flippable: REQUIREMENTS.md + STATE.md + ROADMAP.md (this last is orchestrator's territory post-checkpoint). 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. +- 5 new harness assertions (A24+A25+A26+A27 strict + A28) with their check-counts and rationale. +- A27 strict-mode rationale (DEC-011 Amendment 1 unblocks both-URLs-required contract). +- A28 REQ-archive-layout strict zip-layout pin (5 entries; cross-references REQ-popup-ui + REQ-screenshot-on-export). - Tier-1 FORBIDDEN_HOOK_STRINGS unchanged at 12 (architectural rationale: chrome.* spy / production APIs vs new test-hook symbols). - Pre-checkpoint bundle gate run record (each gate result inline). - Operator empirical ack quote (verbatim) OR list of deviations + follow-up plan-pointer. -- Phase 2 closure summary: 4/4 plans landed; vitest + UAT GREEN; P0-6 + P1 #10 closed; meta.json 8-field schema shipped. +- Phase 2 closure summary: 4/4 plans landed; vitest + UAT GREEN; P0-6 + P1 #10 closed; meta.json 8-field schema shipped; DEC-011 Amendment 1 landed. - Forward link: Phase 3 (SPEC §10 smoke + DOM/event-log verification) inherits the harness as its closure template (mirrors Plan 01-13's role for Phase 2). diff --git a/.planning/phases/02-stabilize-export-pipeline/02-CONTEXT.md b/.planning/phases/02-stabilize-export-pipeline/02-CONTEXT.md index 97f13e2..442adc4 100644 --- a/.planning/phases/02-stabilize-export-pipeline/02-CONTEXT.md +++ b/.planning/phases/02-stabilize-export-pipeline/02-CONTEXT.md @@ -136,7 +136,42 @@ None — discussion stayed within phase scope. +## Revision Log + +### 2026-05-20 — DEC-011 Amendment 1 + plan-checker iteration 1 + +- **DEC-011 Amendment 1 (Phase 2 scope addition):** `tabs` permission ADDED to manifest.json + per user direction during plan-checker iteration 1. Justification: Phase 2's D-P2-02 + meta.urls feature REQUIRES tab URL visibility beyond active-tab semantics. Audit T-1-02 + ("declaring unused permissions expands attack surface") is acknowledged but overridden for + this Phase 2 feature; the meta.urls feature is now genuinely USED, so the permission is not + unused. tests/i18n/manifest-i18n.test.ts pins the new 8-entry permission set as a + regression guard. PROJECT.md DEC-011 row rewritten with Amendment 1 prose. +- **Plan 02-04 A27 strict-mode:** harness MUST observe BOTH tab URLs in meta.urls after a + multi-tab session. meta.urls.length >= 2 REQUIRED; test FAILS on length < 2. No + extension-origin sentinels permitted. Empty-tracker case (no browser interaction during + recording) still produces urls:[] per F2 resolution — but A27 explicitly EXERCISES the + multi-tab path so its meta.urls is NEVER empty. +- **createArchive empty-tracker fallback removed (F2):** tracker.getTabUrlsSeen() returning + empty array is meaningful (whole-desktop-no-tab session) and meta.urls: [] is the canonical + representation. tests/background/meta-json-urls-schema.test.ts adds Test 5 pinning this + contract; tests/build/strict-meta-json-validation.test.ts Test 3 relaxed to PERMIT empty + urls[] (still validates URL format on non-empty arrays). createArchive calls a new + snapshotOpenTabs() helper (chrome.tabs.query({}) defensive enumeration via DEC-011 + Amendment 1) BEFORE reading getTabUrlsSeen() so any tab the operator opened but never + activated is still captured. Empty array IS the result when no tabs are open at SAVE time. +- **Plan 02-04 A28 added (F1):** REQ-archive-layout strict zip-layout pin. Harness driver + enumerates zip entries and asserts EXACTLY 5 paths (`video/last_30sec.webm`, + `rrweb/session.json`, `logs/events.json`, `screenshot.png`, `meta.json`). Cross-references + REQ-archive-layout + REQ-popup-ui + REQ-screenshot-on-export. UAT target: 28→29 GREEN. +- **Plan 02-02 Task 3 F3 resolved:** OR-deliberation in createArchive prose struck. The + resolved-only contract is: throw typed `blob-url-mint-failed` error on empty/timeout + response from offscreen; NO data: URL fallback for any archive size. +- **Plan 02-02 Task 3 F4 resolved:** verify block extended with full-suite `npm test` after + the focused-test runs so unrelated regressions surface during execution. + --- *Phase: 2-stabilize-export-pipeline* *Context gathered: 2026-05-20* +*Revised: 2026-05-20 (plan-checker iteration 1)* diff --git a/manifest.json b/manifest.json index 182293d..13ff21f 100644 --- a/manifest.json +++ b/manifest.json @@ -7,6 +7,7 @@ "permissions": [ "desktopCapture", "activeTab", + "tabs", "downloads", "scripting", "storage", @@ -47,4 +48,4 @@ "48": "icons/icon48.png", "128": "icons/icon128.png" } -} \ No newline at end of file +} diff --git a/tests/i18n/manifest-i18n.test.ts b/tests/i18n/manifest-i18n.test.ts index b560792..6363520 100644 --- a/tests/i18n/manifest-i18n.test.ts +++ b/tests/i18n/manifest-i18n.test.ts @@ -12,10 +12,17 @@ // after Wave-3 Task 1 landed the manifest + messages.json files. This // file now pins the post-D-07 state as a regression guard. // +// 2026-05-20 Plan 02-03 amendment (DEC-011 Amendment 1): manifest.json +// permissions array extended to include `"tabs"` per Phase 2 D-P2-02 +// (meta.urls multi-tab visibility). The permission-set describe block +// below pins the post-amendment 8-entry set as a regression guard. +// // References: // - RESEARCH §10 + §11 (Chrome i18n schema; __MSG_* placeholder rules) // - brand-decisions-v1.md D-07 override (`Mokosh — Session Capture`) // - brand-decisions-v1.md D-08 tagline (`Thirty seconds ago, always at hand.`) +// - .planning/PROJECT.md DEC-011 Amendment 1 (`tabs` permission, 2026-05-20) +// - .planning/phases/02-stabilize-export-pipeline/02-CONTEXT.md D-P2-02 import { describe, expect, it } from 'vitest'; import { existsSync, readFileSync } from 'node:fs'; @@ -89,3 +96,35 @@ describe('Plan 01-12: _locales/ru/messages.json (primary operator locale)', () = expect(ru.extDesc?.message).toBe('Тридцать секунд назад, всегда под рукой.'); }); }); + +describe('Plan 02-03 DEC-011 Amendment 1: manifest.json permissions include "tabs" (D-P2-02)', () => { + // Locked set per DEC-011 Amendment 1 (2026-05-20). `tabs` added to enable + // chrome.tabs.get(tabId).url + chrome.tabs.query for the tab-url-tracker + // (D-P2-02 meta.urls feature). Any future permission delta requires a + // formal DEC-011 amendment update — DO NOT edit this list without one. + const EXPECTED_PERMISSIONS: ReadonlyArray = [ + 'desktopCapture', + 'activeTab', + 'tabs', + 'downloads', + 'scripting', + 'storage', + 'offscreen', + 'notifications', + ]; + + it('manifest.json:permissions includes "tabs" (DEC-011 Amendment 1)', () => { + const manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')); + expect(Array.isArray(manifest.permissions)).toBe(true); + expect(manifest.permissions).toContain('tabs'); + }); + + it('manifest.json:permissions has exactly the 8 entries in DEC-011 Amendment 1', () => { + const manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')); + expect(Array.isArray(manifest.permissions)).toBe(true); + const actual = [...manifest.permissions].sort(); + const expected = [...EXPECTED_PERMISSIONS].sort(); + expect(actual).toEqual(expected); + expect(manifest.permissions.length).toBe(EXPECTED_PERMISSIONS.length); + }); +});