feat(02-04): harness A26+A27(strict)+A28 — meta.json 8-field + multi-tab urls[] STRICT + REQ-archive-layout (D-P2-02/03 + DEC-011 Amendment 1)

Wave 3 closure task 3 — extends the UAT harness with 3 new assertions
(A26 + A27 + A28) for empirical verification of the D-P2-02/D-P2-03
contracts + REQ-archive-layout end-to-end through a real Chrome instance.

Page side (tests/uat/extension-page-harness.ts):
  - assertA26() — stub returning the assertion name; host-side does all
    inspection (JSZip is host-only via tests/uat/lib/zip.ts).
  - assertA27() — STRICT mode (post DEC-011 Amendment 1): owns its
    setupFreshRecording + opens 2 tabs (example.com + iana.org) +
    activates each (chrome.tabs.update active:true) + 11s settle + SAVE
    + tab cleanup in finally with try/catch (T-02-04-04 mitigation).
    Returns A27.1 (SAVE ack) + tabAUrl + tabBUrl for the host driver.
  - assertA28() — stub returning the assertion name; host-side enumerates
    zip entries.
  - __mokoshHarness surface extended from 25 → 28 methods.

Host side (tests/uat/lib/harness-page-driver.ts):
  - driveA26 — chains off A25's zip via findLatestZip helper; loads via
    JSZip, parses meta.json, asserts 6 checks: entry present, exactly 8
    fields, schemaVersion='2', urls is non-empty Array, legacy url field
    undefined, every URL matches /^(https?|chrome-extension):\\/\\//.
  - driveA27 — snapshot pre-existing zips; runs page-side; polls 8s for
    new-or-updated zip with stable-size protocol; loads + parses
    meta.json; asserts 8 STRICT checks per DEC-011 Amendment 1: SAVE ack,
    meta.urls is Array, length>=2, contains tabAUrl, contains tabBUrl,
    every entry non-empty string, no extension-origin sentinels (F2),
    no chrome-internal URLs.
  - driveA28 — chains off A27's zip; enumerates non-directory entries
    via filter pipeline (per CLAUDE.md no-continue style); asserts 3
    checks: exactly 5 entries, set-equal to the canonical 5 paths, no
    extras.
  - findLatestZip helper added for A26/A28 chaining (mtime-sort wins).
  - JSZip imported at top (mirrors tests/uat/lib/zip.ts pattern).

Orchestrator (tests/uat/harness.test.ts):
  - Imports driveA26/A27/A28 + wraps each with handles.downloadsDir.
  - Drivers array extends from 25 → 28 (running total 29/29 with A0).
  - Architecture banner updated to mention A26+A27+A28.

FORBIDDEN_HOOK_STRINGS impact: NONE. A26/A28 are host-side JSZip ops;
A27 uses chrome.tabs.create + chrome.tabs.update + chrome.tabs.remove
(production APIs; `tabs` permission granted via DEC-011 Amendment 1
landed in Plan 02-03). Tier-1 inventory stays at 12.

Verification (pre-commit):
  - npx tsc --noEmit: clean.
  - npm run build: exit 0; dist/ populated.
  - 4 new manifest gates (Tier-1 + SW-bundle-import) verified in followup.

Closes Plan 02-04 Task 3 (Wave 3 functional contract). Pre-checkpoint
bundle gates + operator empirical UAT cycle follow in Task 4.
This commit is contained in:
2026-05-20 17:16:35 +02:00
parent b6b3f377b8
commit 20e06a6a58
3 changed files with 757 additions and 2 deletions

View File

@@ -3089,6 +3089,232 @@ async function assertA25(): Promise<A25Result> {
return result;
}
/* ─── Plan 02-04 Task 3 — A26 + A27 (strict) + A28 ─────────────────────
*
* A26 — D-P2-02 + D-P2-03 empirical: meta.json has the 8-field shape
* with urls[] (not url:string) and schemaVersion='2'.
* A27 — STRICT mode (post DEC-011 Amendment 1): multi-tab URL tracking.
* Opens TWO tabs sequentially, activates each, then dispatches
* SAVE. Host-side asserts meta.urls contains BOTH URLs (length>=2
* REQUIRED; FAILS on length<2).
* A28 — REQ-archive-layout strict zip-layout: zip contains EXACTLY the
* 5 canonical entries (video/last_30sec.webm, rrweb/session.json,
* logs/events.json, screenshot.png, meta.json). Cross-references
* REQ-popup-ui + REQ-screenshot-on-export.
*
* Architecture (per saved memory feedback-no-unilateral-scope-reduction +
* Plan 01-13 Approach B): page-side does the orchestration (SAVE dispatch
* + tab management); host-side does the zip parsing via JSZip (already
* imported in tests/uat/lib/zip.ts; not bundled into the harness page
* realm). A26 + A28 page-sides are intentional stubs returning the
* assertion name — all zip-inspection work is host-side in the drivers.
*
* FORBIDDEN_HOOK_STRINGS impact: NONE. A27 uses chrome.tabs.create +
* chrome.tabs.update + chrome.tabs.remove (production APIs; `tabs`
* permission granted via DEC-011 Amendment 1). Tier-1 inventory stays
* at the post-A24/A25 baseline.
*
* T-02-04-04 mitigation: A27 wraps both tab-cleanup calls in try/catch
* with silent-ignore on already-closed (chrome.tabs.remove throws if
* the tab id is gone; the test doesn't care about that side effect).
*/
/** SAVE_ARCHIVE dispatch timeout for A27 — matches A24/A25's 15s. */
const A27_SAVE_ARCHIVE_TIMEOUT_MS = 15_000;
/** Pre-SAVE segment-settle window (10s rotation + 1s slack). */
const A27_SEGMENT_SETTLE_MS = 11_000;
/** Wait after chrome.tabs.create for the tab navigation to complete
* (the URL field on the tab object only populates once the page
* begins loading). Conservative 1500ms per plan spec. */
const A27_TAB_NAVIGATION_WAIT_MS = 1_500;
/** Wait after chrome.tabs.update({active:true}) for chrome.tabs.onActivated
* to fire + tab-url-tracker (Plan 02-03 Task 2) to capture the URL. */
const A27_TAB_ACTIVATION_WAIT_MS = 500;
/** Canonical multi-tab URLs for A27 — public sites with stable URLs,
* no PII (per T-02-04-03 disposition). example.com is RFC 2606
* reserved; iana.org is the IANA homepage. Both serve plain HTML
* reliably under headless Chrome. */
const A27_TAB_A_URL = 'https://example.com/';
const A27_TAB_B_URL = 'https://www.iana.org/';
/**
* Extended result shape — A27 returns the canonical URLs the host-side
* driver needs to assert against meta.urls.
*/
interface A27Result extends AssertionResult {
tabAUrl: string;
tabBUrl: string;
}
/**
* A26 — D-P2-02 + D-P2-03 empirical (page-side stub).
*
* Returns the assertion name + a sentinel diagnostic. All real work
* happens host-side in driveA26 (JSZip-parse the latest zip + assert
* meta.json shape). The page-side stub exists purely so the orchestrator's
* single-method-per-assertion contract (window.__mokoshHarness.assertA26)
* is uniform across all 29 assertions.
*
* Chaining: A26 reads the zip produced by A25 (host-side driver picks
* the most-recently-modified zip in downloadsDir). No new SAVE dispatch.
*
* @returns Stub AssertionResult with empty checks; driveA26 fills them.
*/
async function assertA26(): Promise<AssertionResult> {
return {
passed: true,
name: 'A26 — meta.json 8-field shape (D-P2-02 + D-P2-03)',
checks: [],
diagnostics: [
'assertA26 page-side stub; host-side driveA26 inspects latest zip + asserts meta.json shape',
],
};
}
/**
* A27 — STRICT mode (post DEC-011 Amendment 1): multi-tab URL tracking.
*
* Sequence (per plan spec):
* 1. setupFreshRecording (clean state)
* 2. chrome.tabs.create(TAB_A_URL, active:false) → wait 1500ms for nav
* 3. chrome.tabs.update(tabA.id, {active:true}) → wait 500ms for onActivated
* 4. chrome.tabs.create(TAB_B_URL, active:false) → wait 1500ms for nav
* 5. chrome.tabs.update(tabB.id, {active:true}) → wait 500ms for onActivated
* 6. Wait 11s for one segment to land
* 7. Dispatch SAVE_ARCHIVE; await ack with success===true
* 8. Cleanup: try/catch close both tabs via chrome.tabs.remove
*
* Host-side driveA27 then loads the produced zip + parses meta.json + asserts:
* - meta.urls.length >= 2 (FAIL on length < 2)
* - Both TAB_A_URL and TAB_B_URL are in meta.urls (order-flexible)
* - No extension-origin sentinels (chrome-extension://)
* - No chrome-internal URLs (chrome:// or about:)
*
* Page-side returns A27.1 (SAVE_ARCHIVE ack) + the canonical URLs so
* the host-side driver can compute the strict assertions.
*
* T-02-04-04 mitigation: cleanup runs in finally; both tabs.remove
* calls wrapped in try/catch (silent-ignore on already-closed).
*
* @returns A27Result with 1 check (SAVE ack) + tabAUrl + tabBUrl for the driver.
*/
async function assertA27(): Promise<A27Result> {
const result: A27Result = {
passed: false,
name: 'A27 — D-P2-02 STRICT multi-tab urls[] (both URLs REQUIRED post DEC-011 Amendment 1)',
checks: [],
diagnostics: [],
tabAUrl: A27_TAB_A_URL,
tabBUrl: A27_TAB_B_URL,
};
let tabAId: number | undefined;
let tabBId: number | undefined;
try {
diag(result, 'Step 1: setupFreshRecording (A27 owns its recording)');
const setupResp = await setupFreshRecording();
if (!setupResp.ok) {
throw new Error(
`setupFreshRecording failed: ${setupResp.error ?? '(no error)'}`,
);
}
diag(result, 'Step 1 OK — REC state established');
diag(result, `Step 2: chrome.tabs.create(${A27_TAB_A_URL}, active:false)`);
const tabA = await chrome.tabs.create({ url: A27_TAB_A_URL, active: false });
tabAId = tabA.id;
diag(result, `Step 2 result: tabA.id=${tabAId}, tabA.url=${tabA.url ?? '<pending>'}`);
await new Promise((r) => setTimeout(r, A27_TAB_NAVIGATION_WAIT_MS));
if (tabAId !== undefined) {
diag(result, `Step 3: chrome.tabs.update(${tabAId}, {active:true})`);
await chrome.tabs.update(tabAId, { active: true });
await new Promise((r) => setTimeout(r, A27_TAB_ACTIVATION_WAIT_MS));
}
diag(result, `Step 4: chrome.tabs.create(${A27_TAB_B_URL}, active:false)`);
const tabB = await chrome.tabs.create({ url: A27_TAB_B_URL, active: false });
tabBId = tabB.id;
diag(result, `Step 4 result: tabB.id=${tabBId}, tabB.url=${tabB.url ?? '<pending>'}`);
await new Promise((r) => setTimeout(r, A27_TAB_NAVIGATION_WAIT_MS));
if (tabBId !== undefined) {
diag(result, `Step 5: chrome.tabs.update(${tabBId}, {active:true})`);
await chrome.tabs.update(tabBId, { active: true });
await new Promise((r) => setTimeout(r, A27_TAB_ACTIVATION_WAIT_MS));
}
diag(result, `Step 6: settle ${A27_SEGMENT_SETTLE_MS}ms for one segment to land`);
await new Promise((r) => setTimeout(r, A27_SEGMENT_SETTLE_MS));
diag(result, 'Step 7: dispatch SAVE_ARCHIVE');
const ack = await sendMessageWithTimeout<{ success: boolean; error?: string }>(
{ type: 'SAVE_ARCHIVE' },
A27_SAVE_ARCHIVE_TIMEOUT_MS,
'SAVE_ARCHIVE (A27)',
);
diag(result, `Step 7 result: ${JSON.stringify(ack)}`);
result.checks.push({
name: 'A27.1: SAVE_ARCHIVE ack received with success=true',
expected: true,
actual: ack.success,
passed: ack.success === true,
});
result.passed = result.checks.every((c) => c.passed);
} catch (err) {
result.error = err instanceof Error ? err.message : String(err);
diag(result, `THREW: ${result.error}`);
} finally {
// T-02-04-04 mitigation: cleanup tabs with silent-ignore on
// already-closed. chrome.tabs.remove rejects if the tab id is gone;
// we don't care about that side effect for this assertion.
if (tabAId !== undefined) {
try {
await chrome.tabs.remove(tabAId);
} catch (rmErr) {
diag(result, `(tabA cleanup ignored: ${String(rmErr)})`);
}
}
if (tabBId !== undefined) {
try {
await chrome.tabs.remove(tabBId);
} catch (rmErr) {
diag(result, `(tabB cleanup ignored: ${String(rmErr)})`);
}
}
}
return result;
}
/**
* A28 — REQ-archive-layout strict zip-layout (page-side stub).
*
* Returns the assertion name + a sentinel diagnostic. All real work
* happens host-side in driveA28 (JSZip enumerates the latest zip's
* entries + asserts exactly 5 canonical paths, no extras). The
* page-side stub exists purely for orchestrator uniformity.
*
* Chaining: A28 reads the zip produced by A27 (host-side picks the
* most-recently-modified zip in downloadsDir). No new SAVE dispatch.
*
* @returns Stub AssertionResult with empty checks; driveA28 fills them.
*/
async function assertA28(): Promise<AssertionResult> {
return {
passed: true,
name: 'A28 — REQ-archive-layout strict (5 entries)',
checks: [],
diagnostics: [
'assertA28 page-side stub; host-side driveA28 enumerates latest zip + asserts 5-entry layout',
],
};
}
/**
* Read `chrome.runtime.getManifest().version`. Used by the host-side
* orchestrator at startup to capture the expected version for A13's
@@ -3136,6 +3362,10 @@ declare global {
assertA24: () => Promise<AssertionResult>;
// Plan 02-04 Task 2 — REQ-archive-export-latency (5s ceiling)
assertA25: () => Promise<A25Result>;
// Plan 02-04 Task 3 — D-P2-02 + D-P2-03 + REQ-archive-layout
assertA26: () => Promise<AssertionResult>;
assertA27: () => Promise<A27Result>;
assertA28: () => Promise<AssertionResult>;
getManifestVersion: () => Promise<string>;
};
}
@@ -3167,6 +3397,9 @@ window.__mokoshHarness = {
assertA23,
assertA24,
assertA25,
assertA26,
assertA27,
assertA28,
getManifestVersion,
};
@@ -3175,6 +3408,6 @@ if (statusEl !== null) {
statusEl.textContent = 'Harness ready. window.__mokoshHarness.{assertA1..A28, getManifestVersion} available.';
}
console.log('[harness-page] ready — window.__mokoshHarness installed (Plan 01-13 Task 9: A1..A14 + Plan 01-10 Wave 3: A15..A17 + Plan 01-12 Wave 6: A18..A22 + Plan 01-14: A23 + Plan 02-04 Tasks 1-2: A24+A25 + getManifestVersion)');
console.log('[harness-page] ready — window.__mokoshHarness installed (Plan 01-13 Task 9: A1..A14 + Plan 01-10 Wave 3: A15..A17 + Plan 01-12 Wave 6: A18..A22 + Plan 01-14: A23 + Plan 02-04 Tasks 1-2: A24+A25 + Plan 02-04 Task 3: A26+A27+A28 + getManifestVersion)');
export {};