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:
@@ -3089,6 +3089,232 @@ async function assertA25(): Promise<A25Result> {
|
|||||||
return result;
|
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
|
* Read `chrome.runtime.getManifest().version`. Used by the host-side
|
||||||
* orchestrator at startup to capture the expected version for A13's
|
* orchestrator at startup to capture the expected version for A13's
|
||||||
@@ -3136,6 +3362,10 @@ declare global {
|
|||||||
assertA24: () => Promise<AssertionResult>;
|
assertA24: () => Promise<AssertionResult>;
|
||||||
// Plan 02-04 Task 2 — REQ-archive-export-latency (5s ceiling)
|
// Plan 02-04 Task 2 — REQ-archive-export-latency (5s ceiling)
|
||||||
assertA25: () => Promise<A25Result>;
|
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>;
|
getManifestVersion: () => Promise<string>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -3167,6 +3397,9 @@ window.__mokoshHarness = {
|
|||||||
assertA23,
|
assertA23,
|
||||||
assertA24,
|
assertA24,
|
||||||
assertA25,
|
assertA25,
|
||||||
|
assertA26,
|
||||||
|
assertA27,
|
||||||
|
assertA28,
|
||||||
getManifestVersion,
|
getManifestVersion,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3175,6 +3408,6 @@ if (statusEl !== null) {
|
|||||||
statusEl.textContent = 'Harness ready. window.__mokoshHarness.{assertA1..A28, getManifestVersion} available.';
|
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 {};
|
export {};
|
||||||
|
|||||||
@@ -93,6 +93,10 @@ import {
|
|||||||
driveA24,
|
driveA24,
|
||||||
// Plan 02-04 Task 2 — REQ-archive-export-latency (5s ceiling)
|
// Plan 02-04 Task 2 — REQ-archive-export-latency (5s ceiling)
|
||||||
driveA25,
|
driveA25,
|
||||||
|
// Plan 02-04 Task 3 — meta.json 8-field + multi-tab strict + REQ-archive-layout
|
||||||
|
driveA26,
|
||||||
|
driveA27,
|
||||||
|
driveA28,
|
||||||
getManifestVersion,
|
getManifestVersion,
|
||||||
} from './lib/harness-page-driver';
|
} from './lib/harness-page-driver';
|
||||||
import {
|
import {
|
||||||
@@ -261,7 +265,7 @@ async function assertA0_GrepGate(): Promise<{
|
|||||||
*/
|
*/
|
||||||
async function main(): Promise<number> {
|
async function main(): Promise<number> {
|
||||||
process.stdout.write('\nMokosh Plan 01-13 + 01-14 + 02-04 — UAT harness orchestrator\n');
|
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');
|
process.stdout.write('='.repeat(72) + '\n');
|
||||||
|
|
||||||
// A0 pre-flight (no Chrome launch needed; runs against built dist/).
|
// A0 pre-flight (no Chrome launch needed; runs against built dist/).
|
||||||
@@ -319,6 +323,16 @@ async function main(): Promise<number> {
|
|||||||
// dispatch→file-on-disk latency check (mirrors A5/A12/A13 wrapping).
|
// dispatch→file-on-disk latency check (mirrors A5/A12/A13 wrapping).
|
||||||
const driveA25Wrapped: (page: import('puppeteer').Page) => Promise<AssertionRecord> =
|
const driveA25Wrapped: (page: import('puppeteer').Page) => Promise<AssertionRecord> =
|
||||||
(page) => driveA25(page, handles.downloadsDir);
|
(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<AssertionRecord> =
|
||||||
|
(page) => driveA26(page, handles.downloadsDir);
|
||||||
|
const driveA27Wrapped: (page: import('puppeteer').Page) => Promise<AssertionRecord> =
|
||||||
|
(page) => driveA27(page, handles.downloadsDir);
|
||||||
|
const driveA28Wrapped: (page: import('puppeteer').Page) => Promise<AssertionRecord> =
|
||||||
|
(page) => driveA28(page, handles.downloadsDir);
|
||||||
|
|
||||||
const drivers: ReadonlyArray<{
|
const drivers: ReadonlyArray<{
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
@@ -398,6 +412,22 @@ async function main(): Promise<number> {
|
|||||||
// with A24's still-pending state). The 11s segment-settle is NOT
|
// with A24's still-pending state). The 11s segment-settle is NOT
|
||||||
// counted toward the 5s budget — only the SAVE dispatch.
|
// counted toward the 5s budget — only the SAVE dispatch.
|
||||||
{ name: 'A25', drive: driveA25Wrapped },
|
{ 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 };
|
const buffers = { swConsole: handles.swConsole, offConsole: handles.offConsole };
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { existsSync, mkdtempSync, readFileSync, readdirSync, statSync, unlinkSyn
|
|||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join, resolve as resolvePath } from 'node:path';
|
import { join, resolve as resolvePath } from 'node:path';
|
||||||
|
|
||||||
|
import JSZip from 'jszip';
|
||||||
import type { Page } from 'puppeteer';
|
import type { Page } from 'puppeteer';
|
||||||
|
|
||||||
import type { AssertionRecord, CheckRecord } from './assertions';
|
import type { AssertionRecord, CheckRecord } from './assertions';
|
||||||
@@ -1339,3 +1340,494 @@ export async function driveA25(
|
|||||||
// assertions that need to surface host-required payloads (zip bytes,
|
// assertions that need to surface host-required payloads (zip bytes,
|
||||||
// webm bytes, etc.) MAY adopt the interface; for now it's stable
|
// webm bytes, etc.) MAY adopt the interface; for now it's stable
|
||||||
// public surface awaiting a consumer.
|
// 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<string> = [
|
||||||
|
'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<AssertionRecord> {
|
||||||
|
// 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<string, unknown> = {};
|
||||||
|
let parseErr: string | null = null;
|
||||||
|
try {
|
||||||
|
meta = JSON.parse(metaText) as Record<string, unknown>;
|
||||||
|
} 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: `<error: ${parseErr}>`,
|
||||||
|
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<AssertionRecord> {
|
||||||
|
// 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<AssertionRecord> {
|
||||||
|
// 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user