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:
@@ -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<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