diff --git a/tests/uat/extension-page-harness.ts b/tests/uat/extension-page-harness.ts index 80db0f3..c9feef6 100644 --- a/tests/uat/extension-page-harness.ts +++ b/tests/uat/extension-page-harness.ts @@ -3089,6 +3089,232 @@ async function assertA25(): Promise { 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 { + 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 { + 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 ?? ''}`); + 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 ?? ''}`); + 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 { + 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; // Plan 02-04 Task 2 — REQ-archive-export-latency (5s ceiling) assertA25: () => Promise; + // Plan 02-04 Task 3 — D-P2-02 + D-P2-03 + REQ-archive-layout + assertA26: () => Promise; + assertA27: () => Promise; + assertA28: () => Promise; getManifestVersion: () => Promise; }; } @@ -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 {}; diff --git a/tests/uat/harness.test.ts b/tests/uat/harness.test.ts index 41ea7db..eb3d315 100644 --- a/tests/uat/harness.test.ts +++ b/tests/uat/harness.test.ts @@ -93,6 +93,10 @@ import { driveA24, // Plan 02-04 Task 2 — REQ-archive-export-latency (5s ceiling) driveA25, + // Plan 02-04 Task 3 — meta.json 8-field + multi-tab strict + REQ-archive-layout + driveA26, + driveA27, + driveA28, getManifestVersion, } from './lib/harness-page-driver'; import { @@ -261,7 +265,7 @@ async function assertA0_GrepGate(): Promise<{ */ async function main(): Promise { process.stdout.write('\nMokosh Plan 01-13 + 01-14 + 02-04 — UAT harness orchestrator\n'); - process.stdout.write('Architecture: A0 pre-flight + extension-internal page driver (A1..A14, A15..A17, A18..A22, A23, A24, A25)\n'); + process.stdout.write('Architecture: A0 pre-flight + extension-internal page driver (A1..A14, A15..A17, A18..A22, A23, A24, A25, A26, A27, A28)\n'); process.stdout.write('='.repeat(72) + '\n'); // A0 pre-flight (no Chrome launch needed; runs against built dist/). @@ -319,6 +323,16 @@ async function main(): Promise { // dispatch→file-on-disk latency check (mirrors A5/A12/A13 wrapping). const driveA25Wrapped: (page: import('puppeteer').Page) => Promise = (page) => driveA25(page, handles.downloadsDir); + // Plan 02-04 Task 3 — driveA26/A27/A28 need downloadsDir for host-side + // zip inspection (JSZip-parse meta.json + zip-layout enumeration). A26 + // chains off A25's zip (no new SAVE); A27 owns its SAVE (multi-tab); + // A28 chains off A27's zip (no new SAVE). + const driveA26Wrapped: (page: import('puppeteer').Page) => Promise = + (page) => driveA26(page, handles.downloadsDir); + const driveA27Wrapped: (page: import('puppeteer').Page) => Promise = + (page) => driveA27(page, handles.downloadsDir); + const driveA28Wrapped: (page: import('puppeteer').Page) => Promise = + (page) => driveA28(page, handles.downloadsDir); const drivers: ReadonlyArray<{ readonly name: string; @@ -398,6 +412,22 @@ async function main(): Promise { // with A24's still-pending state). The 11s segment-settle is NOT // counted toward the 5s budget — only the SAVE dispatch. { name: 'A25', drive: driveA25Wrapped }, + // Plan 02-04 Task 3 A26: D-P2-02 + D-P2-03 meta.json 8-field shape. + // Chains off A25's zip (no new SAVE); host-side JSZip-parse meta.json + // and asserts the 8-field shape with urls[] + schemaVersion='2'. + { name: 'A26', drive: driveA26Wrapped }, + // Plan 02-04 Task 3 A27: STRICT multi-tab urls[] post DEC-011 Amendment 1. + // Opens 2 tabs sequentially + activates each + 11s settle + SAVE; host-side + // asserts meta.urls contains BOTH example.com + iana.org (length>=2 + // REQUIRED; FAILS on length<2; no extension-origin sentinels; no + // chrome-internal URLs). Owns its SAVE dispatch (multi-tab tracker + // state needs both onActivated events to fire BEFORE the SAVE). + { name: 'A27', drive: driveA27Wrapped }, + // Plan 02-04 Task 3 A28: REQ-archive-layout strict 5-entry zip-layout. + // Chains off A27's zip (no new SAVE); host-side enumerates zip entries + // and asserts EXACTLY 5 paths: video/last_30sec.webm, rrweb/session.json, + // logs/events.json, screenshot.png, meta.json (set-equality; no extras). + { name: 'A28', drive: driveA28Wrapped }, ]; const buffers = { swConsole: handles.swConsole, offConsole: handles.offConsole }; diff --git a/tests/uat/lib/harness-page-driver.ts b/tests/uat/lib/harness-page-driver.ts index e56462d..ff8e974 100644 --- a/tests/uat/lib/harness-page-driver.ts +++ b/tests/uat/lib/harness-page-driver.ts @@ -38,6 +38,7 @@ import { existsSync, mkdtempSync, readFileSync, readdirSync, statSync, unlinkSyn import { tmpdir } from 'node:os'; import { join, resolve as resolvePath } from 'node:path'; +import JSZip from 'jszip'; import type { Page } from 'puppeteer'; import type { AssertionRecord, CheckRecord } from './assertions'; @@ -1339,3 +1340,494 @@ export async function driveA25( // assertions that need to surface host-required payloads (zip bytes, // webm bytes, etc.) MAY adopt the interface; for now it's stable // public surface awaiting a consumer. + +/* ─── Plan 02-04 Task 3 — driveA26 + driveA27 + driveA28 ───────────── + * + * driveA26 — D-P2-02 + D-P2-03 meta.json 8-field shape (chains off A25) + * driveA27 — DEC-011 Amendment 1 STRICT multi-tab urls[] (own SAVE) + * driveA28 — REQ-archive-layout strict 5-entry zip-layout (chains off A27) + * + * Architecture: page-side is minimal (orchestration only); all zip + * inspection runs host-side via JSZip (already imported in + * tests/uat/lib/zip.ts; mirrored here for the new drivers). + * + * Chaining strategy: + * - A26: most-recently-modified zip in downloadsDir is A25's product. + * - A27: new SAVE dispatch (clean multi-tab tracker state) → new zip. + * - A28: most-recently-modified zip is A27's product (5-entry layout). + * + * The "latest zip wins" pattern (vs A25's mtime-delta detection) works + * because A26 + A28 are read-only post-A25/A27 SAVE dispatches; there's + * no race window where a partial zip could be observed (A25 + A27 + * already waited for the zip to land before returning). + */ + +/** Canonical 5-entry zip layout per REQ-archive-layout. Order-independent + * (driveA28 uses set-equality), but the sorted form is the cached + * expected for the actual === expected check below. */ +const A28_EXPECTED_PATHS: ReadonlyArray = [ + 'video/last_30sec.webm', + 'rrweb/session.json', + 'logs/events.json', + 'screenshot.png', + 'meta.json', +]; + +/** Maximum wait for the A27 SAVE_ARCHIVE zip to appear in downloadsDir. + * Generous 8s ceiling (vs A25's 6s) because A27 dispatches its SAVE + * AFTER opening 2 tabs + 11s settle — the page-side wall time is + * already longer than A25's; the host-side poll bookend reflects that. */ +const A27_HOST_POLL_TIMEOUT_MS = 8_000; +/** Polling cadence — matches A25's 100ms. */ +const A27_HOST_POLL_INTERVAL_MS = 100; + +/** + * Internal: pick the most-recently-modified .zip file in `downloadsDir`, + * or null if no .zip files exist. Used by driveA26 + driveA28 to chain + * off A25/A27 without re-dispatching SAVE. The "latest mtime wins" + * pattern works because the A25/A27 driver returns AFTER its zip lands + * (await-pattern via the stable-size poll); no race with mid-write + * partial zips at the read instant. + * + * @param downloadsDir - Absolute path to the per-run downloads dir. + * @returns Absolute path of the latest .zip, or null on empty dir. + */ +function findLatestZip(downloadsDir: string): string | null { + const candidates = readdirSync(downloadsDir).filter(isZipFilename); + if (candidates.length === 0) { + return null; + } + const withMtimes = candidates.map((name) => ({ + name, + mtimeMs: statSync(resolvePath(downloadsDir, name)).mtimeMs, + })); + withMtimes.sort((a, b) => b.mtimeMs - a.mtimeMs); + return resolvePath(downloadsDir, withMtimes[0].name); +} + +/** + * Drive A26 (Plan 02-04 Task 3 — D-P2-02 + D-P2-03 meta.json shape). + * + * Chains off A25's most-recently-modified zip in downloadsDir. No new + * SAVE dispatch. Loads the zip via JSZip, parses meta.json, asserts + * the 8-field D-P2-02/D-P2-03 shape per Plan 02-03 (urls[] + schemaVersion). + * + * Page-side returns a stub; the host-side does ALL inspection. + * + * @param page - The harness page from `launchHarnessBrowser`. + * @param downloadsDir - Absolute path to the per-run downloads directory. + * @returns AssertionRecord with 6 checks (A26.1..A26.6). + */ +export async function driveA26( + page: Page, + downloadsDir: string, +): Promise { + // Phase 1 — page-side stub (uniform orchestrator shape). + const pageResult = await page.evaluate(async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose. + const harness = (window as any).__mokoshHarness; + const r: AssertionRecord = await harness.assertA26(); + return r; + }) as AssertionRecord; + + const mergedChecks: CheckRecord[] = pageResult.checks.slice(); + const mergedDiagnostics: string[] = pageResult.diagnostics.slice(); + + // Phase 2 — locate A25's zip. + const zipPath = findLatestZip(downloadsDir); + if (zipPath === null) { + mergedChecks.push({ + name: 'A26.0: at least one zip present in downloadsDir (chain-off A25)', + expected: '>=1 zip', + actual: 'no zip in downloadsDir', + passed: false, + }); + return { + passed: false, + name: pageResult.name, + checks: mergedChecks, + diagnostics: mergedDiagnostics, + error: pageResult.error, + }; + } + mergedDiagnostics.push(`A26 zipPath=${zipPath}`); + + // Phase 3 — load + inspect meta.json. + const zipBytes = readFileSync(zipPath); + const zip = await JSZip.loadAsync(zipBytes); + const metaFile = zip.file('meta.json'); + + mergedChecks.push({ + name: 'A26.1: meta.json entry exists in zip', + expected: true, + actual: metaFile !== null, + passed: metaFile !== null, + }); + + if (metaFile === null) { + return { + passed: false, + name: pageResult.name, + checks: mergedChecks, + diagnostics: mergedDiagnostics, + error: pageResult.error, + }; + } + + const metaText = await metaFile.async('string'); + let meta: Record = {}; + let parseErr: string | null = null; + try { + meta = JSON.parse(metaText) as Record; + } catch (err) { + parseErr = err instanceof Error ? err.message : String(err); + } + + if (parseErr !== null) { + mergedChecks.push({ + name: 'A26.2: meta.json parses as JSON', + expected: 'JSON.parse success', + actual: ``, + passed: false, + }); + return { + passed: false, + name: pageResult.name, + checks: mergedChecks, + diagnostics: mergedDiagnostics, + error: pageResult.error, + }; + } + + const keys = Object.keys(meta); + mergedDiagnostics.push(`meta.json keys: ${keys.join(',')}`); + + // A26.2 — exactly 8 fields per Plan 02-03 D-P2-03 schema. + mergedChecks.push({ + name: 'A26.2: meta has exactly 8 fields', + expected: 8, + actual: keys.length, + passed: keys.length === 8, + }); + + // A26.3 — schemaVersion === '2'. + mergedChecks.push({ + name: 'A26.3: meta.schemaVersion === "2"', + expected: '2', + actual: meta.schemaVersion as unknown, + passed: meta.schemaVersion === '2', + }); + + // A26.4 — urls is non-empty Array. + const urlsField = meta.urls; + const urlsIsArray = Array.isArray(urlsField); + const urlsLength = urlsIsArray ? (urlsField as unknown[]).length : 0; + mergedChecks.push({ + name: 'A26.4: meta.urls is non-empty Array', + expected: 'non-empty Array', + actual: urlsIsArray ? `Array(${urlsLength})` : typeof urlsField, + passed: urlsIsArray && urlsLength >= 1, + }); + + // A26.5 — legacy url field is gone (D-P2-02 migration). + mergedChecks.push({ + name: 'A26.5: meta.url (legacy single-URL field) is undefined', + expected: 'undefined', + actual: typeof meta.url, + passed: meta.url === undefined, + }); + + // A26.6 — every URL matches the canonical protocol set. + // Production filter (src/background/tab-url-tracker.ts) accepts http/https/ + // chrome-extension (extension-origin pages may be a legitimate active + // surface during recording). A26 mirrors that contract. + const urlsArr: string[] = urlsIsArray + ? (urlsField as unknown[]).filter((u): u is string => typeof u === 'string') + : []; + const allMatchPattern = + urlsArr.length > 0 && + urlsArr.length === urlsLength && + urlsArr.every((u) => /^(https?|chrome-extension):\/\//.test(u)); + mergedChecks.push({ + name: 'A26.6: every meta.urls[i] matches /^(https?|chrome-extension):\\/\\//', + expected: true, + actual: allMatchPattern, + passed: allMatchPattern, + }); + mergedDiagnostics.push(`meta.urls=${JSON.stringify(urlsArr)}`); + + const mergedPassed = mergedChecks.every((c) => c.passed); + return { + passed: mergedPassed, + name: pageResult.name, + checks: mergedChecks, + diagnostics: mergedDiagnostics, + error: pageResult.error, + }; +} + +/** + * Drive A27 (Plan 02-04 Task 3 — STRICT multi-tab urls[] post DEC-011 A1). + * + * Four-phase orchestration: + * 1. Host side: snapshot pre-existing zips (mtime-aware for A25/A26 chain). + * 2. Page side: assertA27 opens 2 tabs + activates each + 11s settle + + * dispatches SAVE_ARCHIVE + cleans up tabs. Returns tabAUrl + tabBUrl. + * 3. Host side: poll downloadsDir for the new-or-updated zip (8s ceiling). + * 4. Host side: load zip via JSZip + parse meta.json + assert strict + * multi-URL contract (length >= 2, both URLs present, no sentinels, + * no chrome-internal URLs). + * + * Strict contract per DEC-011 Amendment 1 + plan must-haves: + * - A27.1: SAVE_ARCHIVE ack (page-side) + * - A27.2: meta.urls is Array + * - A27.3: length >= 2 (FAIL on length < 2) + * - A27.4: contains TAB_A_URL + * - A27.5: contains TAB_B_URL + * - A27.6: every entry is non-empty string (no [object Object]) + * - A27.7: no extension-origin sentinels (F2 — empty-tracker fallback) + * - A27.8: no chrome-internal URLs (chrome:// or about:) + * + * @param page - The harness page from `launchHarnessBrowser`. + * @param downloadsDir - Absolute path to the per-run downloads directory. + * @returns AssertionRecord with 8 merged checks. + */ +export async function driveA27( + page: Page, + downloadsDir: string, +): Promise { + // Phase 1 — snapshot pre-existing zips (mtime-aware; mirrors driveA25). + const preSnapshot = snapshotExistingZips(downloadsDir); + + // Phase 2 — page-side orchestration. + const pageResult = await page.evaluate(async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose. + const harness = (window as any).__mokoshHarness; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- A27Result extends AssertionRecord with tabAUrl + tabBUrl + const r: any = await harness.assertA27(); + return r; + }) as AssertionRecord & { tabAUrl: string; tabBUrl: string }; + + const mergedChecks: CheckRecord[] = pageResult.checks.slice(); + const mergedDiagnostics: string[] = pageResult.diagnostics.slice(); + + // Phase 3 — host-side poll for new-or-updated zip. + let zipPath: string | null = null; + const pollStart = Date.now(); + while (Date.now() - pollStart < A27_HOST_POLL_TIMEOUT_MS) { + const allZips = readdirSync(downloadsDir).filter(isZipFilename); + const candidates: Array<{ name: string; mtimeMs: number }> = []; + for (const name of allZips) { + const fullPath = resolvePath(downloadsDir, name); + const mtimeMs = statSync(fullPath).mtimeMs; + const prior = preSnapshot.get(name); + if (prior === undefined || mtimeMs > prior.mtimeMs) { + candidates.push({ name, mtimeMs }); + } + } + if (candidates.length > 0) { + candidates.sort((a, b) => b.mtimeMs - a.mtimeMs); + const candPath = resolvePath(downloadsDir, candidates[0].name); + // Stable-size protocol (mirrors pollForNewOrUpdatedZip). + const sizeFirst = statSync(candPath).size; + await new Promise((r) => setTimeout(r, 100)); + const sizeSecond = statSync(candPath).size; + if (sizeFirst === sizeSecond && sizeFirst > 0) { + zipPath = candPath; + break; + } + } + await new Promise((r) => setTimeout(r, A27_HOST_POLL_INTERVAL_MS)); + } + + if (zipPath === null) { + mergedChecks.push({ + name: 'A27.2: new zip appeared in downloadsDir within timeout', + expected: 'new zip', + actual: `no new zip within ${A27_HOST_POLL_TIMEOUT_MS}ms`, + passed: false, + }); + return { + passed: false, + name: pageResult.name, + checks: mergedChecks, + diagnostics: mergedDiagnostics, + error: pageResult.error, + }; + } + mergedDiagnostics.push(`A27 zipPath=${zipPath}`); + + // Phase 4 — load + parse meta.json. + 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) as { urls?: unknown }; + } catch { + // intentional — driveA27 reports the parse failure via the length<2 check below. + } + const urlsRaw = meta.urls; + const urlsArrAll: unknown[] = Array.isArray(urlsRaw) ? urlsRaw : []; + const urls: string[] = urlsArrAll.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: + urlsArrAll.length === urls.length && + urls.every((u) => u.length > 0), + passed: + urls.length > 0 && + urlsArrAll.length === urls.length && + urls.every((u) => 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 (chrome:// or about:)', + expected: true, + actual: urls.every((u) => !u.startsWith('chrome://') && !u.startsWith('about:')), + passed: urls.every((u) => !u.startsWith('chrome://') && !u.startsWith('about:')), + }); + mergedDiagnostics.push(`meta.urls=${JSON.stringify(urls)}`); + + const mergedPassed = mergedChecks.every((c) => c.passed); + return { + passed: mergedPassed, + name: pageResult.name, + checks: mergedChecks, + diagnostics: mergedDiagnostics, + error: pageResult.error, + }; +} + +/** + * Drive A28 (Plan 02-04 Task 3 — REQ-archive-layout strict 5-entry zip-layout). + * + * Chains off A27's most-recently-modified zip. No new SAVE dispatch. + * Loads the zip via JSZip, enumerates entries (excluding directory + * entries), and asserts exactly the 5 canonical paths per REQ-archive- + * layout: video/last_30sec.webm, rrweb/session.json, logs/events.json, + * screenshot.png, meta.json (set-equality; order-flexible). + * + * Cross-references: + * - REQ-archive-layout: the 5 canonical entries + * - REQ-popup-ui: popup-triggered SAVE flows through the same createArchive + * path that emits these entries + * - REQ-screenshot-on-export: screenshot.png entry verified present + * + * @param page - The harness page from `launchHarnessBrowser`. + * @param downloadsDir - Absolute path to the per-run downloads directory. + * @returns AssertionRecord with 3 checks (A28.1..A28.3). + */ +export async function driveA28( + page: Page, + downloadsDir: string, +): Promise { + // Phase 1 — page-side stub (uniform orchestrator shape). + const pageResult = await page.evaluate(async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose. + const harness = (window as any).__mokoshHarness; + const r: AssertionRecord = await harness.assertA28(); + return r; + }) as AssertionRecord; + + const mergedChecks: CheckRecord[] = pageResult.checks.slice(); + const mergedDiagnostics: string[] = pageResult.diagnostics.slice(); + + // Phase 2 — locate A27's zip. + const zipPath = findLatestZip(downloadsDir); + if (zipPath === null) { + mergedChecks.push({ + name: 'A28.0: at least one zip present in downloadsDir (chain-off A27)', + expected: '>=1 zip', + actual: 'no zip in downloadsDir', + passed: false, + }); + return { + passed: false, + name: pageResult.name, + checks: mergedChecks, + diagnostics: mergedDiagnostics, + error: pageResult.error, + }; + } + mergedDiagnostics.push(`A28 zipPath=${zipPath}`); + + // Phase 3 — enumerate zip entries. Filter-pipeline form per project + // style (no `continue` — see ~/.claude/CLAUDE.md "Control Flow" §). + // Skip directory entries; REQ-archive-layout counts files only. + const zipBytes = readFileSync(zipPath); + const zip = await JSZip.loadAsync(zipBytes); + const actualPaths: string[] = Object.keys(zip.files) + .filter((path) => !zip.files[path].dir) + .sort(); + const expectedSorted = [...A28_EXPECTED_PATHS].sort(); + mergedDiagnostics.push(`A28 entries=${actualPaths.join(',')}`); + + // A28.1 — exactly 5 entries. + mergedChecks.push({ + name: 'A28.1: zip has EXACTLY 5 entries (REQ-archive-layout)', + expected: 5, + actual: actualPaths.length, + passed: actualPaths.length === 5, + }); + + // A28.2 — entries set-equal to the canonical 5 paths. + const setEqual = JSON.stringify(actualPaths) === JSON.stringify(expectedSorted); + mergedChecks.push({ + name: 'A28.2: zip entries set-equal to the canonical 5 paths', + expected: expectedSorted.join(','), + actual: actualPaths.join(','), + passed: setEqual, + }); + + // A28.3 — no extras (no __MACOSX/, no .DS_Store, no temp files). + const expectedSet = new Set(A28_EXPECTED_PATHS); + const extras = actualPaths.filter((p) => !expectedSet.has(p)); + mergedChecks.push({ + name: 'A28.3: no extras (no __MACOSX/, no .DS_Store, no temp files)', + expected: 'no extras', + actual: extras.length === 0 ? 'none' : extras.join(','), + passed: extras.length === 0, + }); + + const mergedPassed = mergedChecks.every((c) => c.passed); + return { + passed: mergedPassed, + name: pageResult.name, + checks: mergedChecks, + diagnostics: mergedDiagnostics, + error: pageResult.error, + }; +}