fix(02): revise plans per checker (B1 + 4 flags) — add tabs permission for D-P2-02

- BLOCKER B1: add `tabs` to manifest.json permissions (DEC-011 Amendment 1
  cites Phase 2 D-P2-02 meta.urls feature as justification). Honors
  D-P2-02 "all tabs visible" wording verbatim. Updates manifest-i18n test
  expected permission list lockstep.
- F1: add A28 harness assertion for REQ-archive-layout strict zip-layout
  verification (5 entries, no extras).
- F2: createArchive empty-tracker fallback removed; logs warn + sets
  urls:[] instead of fake [extension-origin URL]. 02-01 RED test pins
  empty-tracker → urls:[].
- F3: 02-02 Task 3 prose deliberation struck; typed `blob-url-mint-failed`
  throw is the resolved-only contract.
- F4: 02-02 Task 3 verify block adds full-suite `npm test` after focused
  test runs.
- A27 strict-mode (Plan 02-04): REQUIRES both URLs in meta.urls; FAILS
  on length < 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 14:25:20 +02:00
parent 0608b22427
commit 9dcfcf0793
8 changed files with 496 additions and 121 deletions

View File

@@ -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:
---
<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.
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>
<task type="auto" tdd="true">
<name>Task 3: assertA26 + A27 — meta.urls shape + multi-tab dedup empirical (D-P2-02)</name>
<name>Task 3: assertA26 + A27 (strict) + A28 — meta.urls shape + multi-tab strict + REQ-archive-layout (D-P2-02/03 + REQ-archive-layout)</name>
<files>tests/uat/extension-page-harness.ts, tests/uat/lib/harness-page-driver.ts, tests/uat/harness.test.ts</files>
<behavior>
- assertA26 page-side: chain off A25's produced zip (read host-side filename via merged checks).
@@ -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 `<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.
(7) Dispatch SAVE_ARCHIVE; await ack with success===true
(8) Host-side: load latest zip; parse meta.json; read meta.urls
(9) Strict assertions (all REQUIRED to pass):
(a) `meta.urls.length >= 2` (FAIL on length < 2)
(b) `meta.urls` contains BOTH 'https://example.com/' AND 'https://www.iana.org/' (order-flexible; use Set membership)
(c) `meta.urls.every(u => typeof u === 'string' && u.length > 0)` — no `[object Object]`, no nulls, no empty strings
(d) `meta.urls.every(u => !u.startsWith('chrome-extension://'))` — no extension-origin sentinel fallback (F2)
(e) `meta.urls.every(u => !u.startsWith('chrome://'))` — no chrome-internal URLs
(10) Cleanup: try/catch close both tabs via chrome.tabs.remove (silent-ignore on already-closed)
DEC-011 Amendment 1 guarantee: with `tabs` permission granted, chrome.tabs.get(tabId).url
and chrome.tabs.query({}) reliably populate the URL field. snapshotOpenTabs at SAVE time
(Plan 02-03 Task 2) provides a defensive belt + suspenders. A27 has UNAMBIGUOUS contract:
both URLs MUST appear; the test is the binding empirical gate for D-P2-02.
- assertA28 (REQ-archive-layout strict zip-layout): chain off A25's produced zip (reuse merged-checks pattern). Use JSZip.loadAsync to enumerate zip entries; assert:
(a) Zip contains EXACTLY 5 entries (no more, no fewer)
(b) The 5 paths are EXACTLY `video/last_30sec.webm`, `rrweb/session.json`, `logs/events.json`, `screenshot.png`, `meta.json` (set-equality; order-flexible)
(c) NO extra entries (no `__MACOSX/`, no `.DS_Store`, no temp files)
Cross-references REQ-archive-layout + REQ-popup-ui (popup-triggered SAVE flows through the same createArchive path) + REQ-screenshot-on-export (screenshot.png entry verified present).
- driveA26 + driveA27 + driveA28: standard page.evaluate wrappers + host-side zip-lookup (mirror driveA5's merged-checks pattern). driveA27 additionally inspects meta.urls host-side via JSZip; driveA28 inspects the zip directory listing.
- Orchestrator update: tests/uat/harness.test.ts adds A24, A25, A26, A27, A28 to the assertion sequence AFTER A23. Total target: 24 (Phase 1 baseline) + 5 (Phase 2) = 29/29 GREEN.
- FORBIDDEN_HOOK_STRINGS lockstep: A24/A25/A26/A27/A28 use chrome.* monkey-patch (A24's downloads spy) + JSZip + chrome.tabs.create/update which are production APIs. No new test-hook surface required. Tier-1 inventory STAYS at 12. Plan-checker verifies via final grep.
</behavior>
<action>
Add `assertA26` and `assertA27` to tests/uat/extension-page-harness.ts:
@@ -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<AssertionResult> {
async function assertA27(): Promise<AssertionResult & { tabAUrl: string; tabBUrl: string }> {
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<AssertionResult> {
return {
name: 'A28 — REQ-archive-layout strict zip-layout (5 entries)',
checks: [],
diagnostics: ['assertA28 page-side stub; host-side driver inspects zip directory'],
};
}
```
Host-side driveA27 (merged-checks pattern with strict meta.urls assertions):
```typescript
export async function driveA27(page: Page, downloadsDir: string): Promise<AssertionRecord> {
const preExisting = new Set(readdirSync(downloadsDir).filter(isZipFilename));
const pageResult = await page.evaluate(async () => {
const harness = (window as any).__mokoshHarness;
return await harness.assertA27();
}) as AssertionRecord & { tabAUrl: string; tabBUrl: string };
// Locate the new zip produced by A27.
let zipPath: string | null = null;
const pollStart = Date.now();
while (Date.now() - pollStart < 8000) {
const candidates = readdirSync(downloadsDir).filter(
(name) => isZipFilename(name) && !preExisting.has(name),
);
if (candidates.length > 0) {
zipPath = `${downloadsDir}/${candidates[candidates.length - 1]}`;
break;
}
await new Promise(r => setTimeout(r, 100));
}
const mergedChecks: CheckRecord[] = pageResult.checks.slice();
const mergedDiagnostics = pageResult.diagnostics.slice();
if (zipPath === null) {
mergedChecks.push({
name: 'A27.2: zip emitted to downloadsDir',
expected: 'zip file present', actual: 'no zip found within 8s', passed: false,
});
} else {
const zipBytes = readFileSync(zipPath);
const zip = await JSZip.loadAsync(zipBytes);
const metaFile = zip.file('meta.json');
const metaText = metaFile !== null ? await metaFile.async('string') : '{}';
let meta: { urls?: unknown } = {};
try { meta = JSON.parse(metaText); } catch {}
const urlsRaw = (meta as { urls?: unknown }).urls;
const urls: string[] = Array.isArray(urlsRaw) ? urlsRaw.filter((u): u is string => typeof u === 'string') : [];
mergedChecks.push({
name: 'A27.2: meta.urls is an Array',
expected: true, actual: Array.isArray(urlsRaw), passed: Array.isArray(urlsRaw),
});
mergedChecks.push({
name: 'A27.3: meta.urls.length >= 2 (STRICT — both URLs REQUIRED post DEC-011 Amendment 1)',
expected: '>=2', actual: urls.length, passed: urls.length >= 2,
});
mergedChecks.push({
name: `A27.4: meta.urls contains ${pageResult.tabAUrl}`,
expected: true, actual: urls.includes(pageResult.tabAUrl), passed: urls.includes(pageResult.tabAUrl),
});
mergedChecks.push({
name: `A27.5: meta.urls contains ${pageResult.tabBUrl}`,
expected: true, actual: urls.includes(pageResult.tabBUrl), passed: urls.includes(pageResult.tabBUrl),
});
mergedChecks.push({
name: 'A27.6: every meta.urls[i] is a non-empty string (no [object Object], no nulls)',
expected: true,
actual: urls.every(u => typeof u === 'string' && u.length > 0),
passed: urls.length > 0 && urls.every(u => typeof u === 'string' && u.length > 0),
});
mergedChecks.push({
name: 'A27.7: no extension-origin sentinel URLs (F2 — empty-tracker fallback removed)',
expected: true,
actual: urls.every(u => !u.startsWith('chrome-extension://')),
passed: urls.every(u => !u.startsWith('chrome-extension://')),
});
mergedChecks.push({
name: 'A27.8: no chrome-internal URLs in meta.urls (filter rules)',
expected: true,
actual: urls.every(u => !u.startsWith('chrome://') && !u.startsWith('about:')),
passed: urls.every(u => !u.startsWith('chrome://') && !u.startsWith('about:')),
});
mergedDiagnostics.push(`zipPath=${zipPath}; meta.urls=${JSON.stringify(urls)}`);
}
return {
passed: mergedChecks.every(c => c.passed),
name: pageResult.name,
checks: mergedChecks,
diagnostics: mergedDiagnostics,
error: pageResult.error,
};
}
export async function driveA28(page: Page, downloadsDir: string): Promise<AssertionRecord> {
// Reuse A25's most-recent zip (A28 does NOT trigger a new SAVE — chains off A25).
const zipFiles = readdirSync(downloadsDir).filter(isZipFilename).sort();
const checks: CheckRecord[] = [];
const diagnostics: string[] = [];
if (zipFiles.length === 0) {
checks.push({
name: 'A28.1: at least one zip present in downloadsDir',
expected: '>=1', actual: 0, passed: false,
});
return { passed: false, name: 'A28 — REQ-archive-layout strict (5 entries)', checks, diagnostics };
}
const latest = `${downloadsDir}/${zipFiles[zipFiles.length - 1]}`;
const zipBytes = readFileSync(latest);
const zip = await JSZip.loadAsync(zipBytes);
const EXPECTED_PATHS: ReadonlyArray<string> = [
'video/last_30sec.webm',
'rrweb/session.json',
'logs/events.json',
'screenshot.png',
'meta.json',
];
const actualPaths = Object.keys(zip.files).filter(p => !zip.files[p].dir).sort();
const expectedSorted = [...EXPECTED_PATHS].sort();
checks.push({
name: 'A28.1: zip has EXACTLY 5 entries (REQ-archive-layout)',
expected: 5, actual: actualPaths.length, passed: actualPaths.length === 5,
});
checks.push({
name: 'A28.2: zip entries set-equal to the canonical 5 paths',
expected: expectedSorted.join(','),
actual: actualPaths.join(','),
passed: JSON.stringify(actualPaths) === JSON.stringify(expectedSorted),
});
checks.push({
name: 'A28.3: no extras (no __MACOSX/, no .DS_Store, no temp files)',
expected: 'no extras',
actual: actualPaths.filter(p => !EXPECTED_PATHS.includes(p)).join(',') || 'none',
passed: actualPaths.every(p => EXPECTED_PATHS.includes(p)),
});
diagnostics.push(`zipPath=${latest}; entries=${actualPaths.join(',')}`);
return {
passed: checks.every(c => c.passed),
name: 'A28 — REQ-archive-layout strict (5 entries; D-P2-02/03 + REQ-popup-ui + REQ-screenshot-on-export)',
checks, diagnostics,
};
}
```
@@ -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).
</action>
<verify>
<automated>HEADLESS=1 npm run test:uat 2>&1 | grep -E "(A26|A27|FAIL|PASS|28/28)" | tail -30</automated>
<automated>HEADLESS=1 npm run test:uat 2>&1 | grep -E "(A26|A27|A28|FAIL|PASS|29/29)" | tail -30</automated>
</verify>
<done>
A26 + A27 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)`.
</done>
</task>
@@ -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.
</what-built>
@@ -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. |
</threat_model>
<verification>
@@ -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.
</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).
1. UAT harness 29/29 GREEN (A0-A23 Phase 1 baseline + A24+A25+A26+A27 strict + A28 Phase 2 extension).
2. A27 STRICT mode: meta.urls contains both example.com AND iana.org (length >= 2 REQUIRED); no extension-origin sentinels (F2).
3. A28 STRICT mode: zip contains exactly the 5 canonical entries; no extras (REQ-archive-layout).
4. Vitest full suite GREEN (Phase 1 baseline + Plan 02-01 RED tests now all GREEN post-Plans 02-02 + 02-03 + Plan 02-03 Task 0 manifest i18n test additions).
5. Tier-1 FORBIDDEN_HOOK_STRINGS = 12 (unchanged — A24+A27+A28 use chrome.* monkey-patch + production APIs).
6. Pre-checkpoint bundle gates PASS per `feedback-pre-checkpoint-bundle-gates.md`.
7. Operator empirical UAT cycle 1 ack "approved" OR documented deviations.
8. Phase 2 closure marker flippable: REQUIREMENTS.md + STATE.md + ROADMAP.md (this last is orchestrator's territory post-checkpoint).
</success_criteria>
<output>
After completion, create `.planning/phases/02-stabilize-export-pipeline/02-04-SUMMARY.md`
documenting:
- 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).
</output>