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

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

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

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

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

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

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

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

View File

@@ -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,
};
}