Milestone v1 (v2.0.0): Mokosh — Session Capture #1

Merged
strategy155 merged 297 commits from gsd/phase-04-harden-clean-up-optional into main 2026-05-31 15:34:17 +00:00
3 changed files with 273 additions and 3 deletions
Showing only changes of commit 47e9818cb1 - Show all commits

View File

@@ -2961,6 +2961,134 @@ async function assertA24(): Promise<AssertionResult> {
return result; return result;
} }
/* ─── Plan 02-04 Task 2 — A25 (5s SAVE→ack+file-on-disk latency) ───────
*
* A25 — REQ-archive-export-latency / SPEC §10 #6: page-side records
* dispatch→ack timings via performance.now() bookends; host-side
* driver merges the dispatch→file-on-disk timing via downloadsDir
* polling. End-to-end ceiling: 5000 ms.
*
* T-02-04-02 disposition (accept): the bracket is t0=just-before-SAVE,
* tAck=just-after-ack — NOT broader test orchestration. setupFreshRecording
* + segment-settle happen BEFORE the t0 mark. The 5s budget is measured
* FROM the SAVE dispatch instant.
*/
/** SAVE_ARCHIVE dispatch timeout for A25 — matches A24's 15s. */
const A25_SAVE_ARCHIVE_TIMEOUT_MS = 15_000;
/** Pre-SAVE segment-settle window (10s rotation + 1s slack). */
const A25_SEGMENT_SETTLE_MS = 11_000;
/** Hard latency ceiling per SPEC §10 #6 + CON-archive-export-latency. */
const A25_LATENCY_CEILING_MS = 5_000;
/**
* Extended result shape — A25 returns the t0/tAck bookends so the
* host-side driver can compute the merged dispatch→file-on-disk
* latency check.
*
* t0Wall is `Date.now()` captured at the SAVE_ARCHIVE dispatch instant
* (NOT at setupFreshRecording start). It is the canonical anchor for
* host-side dispatch→file-on-disk measurement, because performance.now()
* is monotonic and not comparable to Date.now() across realms. The
* driver computes `tFileHost - t0Wall < 5000ms` to assert REQ-archive-
* export-latency per T-02-04-02 disposition (bracket only the SAVE
* dispatch, not the broader test orchestration).
*/
interface A25Result extends AssertionResult {
t0: number;
tAck: number;
t0Wall: number;
ackSuccess: boolean;
}
/**
* A25 — SAVE_ARCHIVE → zip on disk in <5000ms (REQ-archive-export-latency).
*
* Page-side measures dispatch→ack ONLY (performance.now() bookends
* bracket the chrome.runtime.sendMessage call). Host-side `driveA25`
* additionally polls downloadsDir for the new zip + measures
* dispatch→file-on-disk via mtime delta.
*
* Chaining: A25 does its OWN setupFreshRecording + SAVE (clean latency
* measurement, not compounded with A24's still-pending state). The
* setupFreshRecording + 11s settle WALL TIME is OUTSIDE the t0/tAck
* bracket — only the SAVE dispatch latency counts against the 5s budget.
*
* @returns A25Result with structured checks + t0/tAck bookends for the
* host-side driver.
*/
async function assertA25(): Promise<A25Result> {
const result: A25Result = {
passed: false,
name: 'A25 — REQ-archive-export-latency: <5000ms SAVE→ack (SPEC §10 #6)',
checks: [],
diagnostics: [],
t0: 0,
tAck: 0,
t0Wall: 0,
ackSuccess: false,
};
try {
diag(result, 'Step 1: setupFreshRecording (A25 owns its recording — clean latency measurement)');
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: settle ${A25_SEGMENT_SETTLE_MS}ms for first segment rotation (NOT counted toward 5s budget)`);
await new Promise((r) => setTimeout(r, A25_SEGMENT_SETTLE_MS));
diag(result, 'Step 3: t0 = performance.now() AND t0Wall = Date.now(), then SAVE_ARCHIVE then tAck (this is the budgeted bracket)');
const t0 = performance.now();
const t0Wall = Date.now();
const ack = await sendMessageWithTimeout<{ success: boolean; error?: string }>(
{ type: 'SAVE_ARCHIVE' },
A25_SAVE_ARCHIVE_TIMEOUT_MS,
'SAVE_ARCHIVE (A25)',
);
const tAck = performance.now();
const elapsedAck = tAck - t0;
result.t0 = t0;
result.tAck = tAck;
result.t0Wall = t0Wall;
result.ackSuccess = ack.success === true;
diag(
result,
`Step 3 result: ack=${JSON.stringify(ack)}, t0=${t0.toFixed(0)}, tAck=${tAck.toFixed(0)}, t0Wall=${t0Wall}, elapsedAck=${elapsedAck.toFixed(0)}ms`,
);
result.checks.push({
name: 'A25.1: SAVE_ARCHIVE ack received with success=true',
expected: true,
actual: ack.success,
passed: ack.success === true,
});
result.checks.push({
name: `A25.2: page-side dispatch → ack latency < ${A25_LATENCY_CEILING_MS}ms`,
expected: `<${A25_LATENCY_CEILING_MS}ms`,
actual: `${elapsedAck.toFixed(0)}ms`,
passed: elapsedAck < A25_LATENCY_CEILING_MS,
});
diag(
result,
`page-side latency: t0=${t0.toFixed(0)} tAck=${tAck.toFixed(0)} delta=${elapsedAck.toFixed(0)}ms`,
);
result.passed = result.checks.every((c) => c.passed);
} catch (err) {
result.error = err instanceof Error ? err.message : String(err);
diag(result, `THREW: ${result.error}`);
}
return result;
}
/** /**
* 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
@@ -3004,8 +3132,10 @@ declare global {
assertA22: () => Promise<AssertionResult>; assertA22: () => Promise<AssertionResult>;
// Plan 01-14 — picker narrowing // Plan 01-14 — picker narrowing
assertA23: () => Promise<AssertionResult>; assertA23: () => Promise<AssertionResult>;
// Plan 02-04 Task 1 — Phase 2 closure (A24 first) // Plan 02-04 Task 1 — Phase 2 closure (A24 D-P2-01 Blob URL)
assertA24: () => Promise<AssertionResult>; assertA24: () => Promise<AssertionResult>;
// Plan 02-04 Task 2 — REQ-archive-export-latency (5s ceiling)
assertA25: () => Promise<A25Result>;
getManifestVersion: () => Promise<string>; getManifestVersion: () => Promise<string>;
}; };
} }
@@ -3036,6 +3166,7 @@ window.__mokoshHarness = {
assertA22, assertA22,
assertA23, assertA23,
assertA24, assertA24,
assertA25,
getManifestVersion, getManifestVersion,
}; };
@@ -3044,6 +3175,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 Task 1: A24 + 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 + getManifestVersion)');
export {}; export {};

View File

@@ -91,6 +91,8 @@ import {
driveA23, driveA23,
// Plan 02-04 Task 1 — D-P2-01 empirical Blob URL verification // Plan 02-04 Task 1 — D-P2-01 empirical Blob URL verification
driveA24, driveA24,
// Plan 02-04 Task 2 — REQ-archive-export-latency (5s ceiling)
driveA25,
getManifestVersion, getManifestVersion,
} from './lib/harness-page-driver'; } from './lib/harness-page-driver';
import { import {
@@ -259,7 +261,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)\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('='.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/).
@@ -313,6 +315,10 @@ async function main(): Promise<number> {
(page) => driveA12(page, handles.downloadsDir); (page) => driveA12(page, handles.downloadsDir);
const driveA13Wrapped: (page: import('puppeteer').Page) => Promise<AssertionRecord> = const driveA13Wrapped: (page: import('puppeteer').Page) => Promise<AssertionRecord> =
(page) => driveA13(page, handles.downloadsDir, expectedManifestVersion); (page) => driveA13(page, handles.downloadsDir, expectedManifestVersion);
// Plan 02-04 Task 2 — driveA25 needs downloadsDir for the host-side
// dispatch→file-on-disk latency check (mirrors A5/A12/A13 wrapping).
const driveA25Wrapped: (page: import('puppeteer').Page) => Promise<AssertionRecord> =
(page) => driveA25(page, handles.downloadsDir);
const drivers: ReadonlyArray<{ const drivers: ReadonlyArray<{
readonly name: string; readonly name: string;
@@ -384,6 +390,14 @@ async function main(): Promise<number> {
// must be installed pre-dispatch. After A24 the recording stays alive // must be installed pre-dispatch. After A24 the recording stays alive
// for any chained Plan 02-04 Tasks 2-3 assertions (Phase 2 closure). // for any chained Plan 02-04 Tasks 2-3 assertions (Phase 2 closure).
{ name: 'A24', drive: driveA24 }, { name: 'A24', drive: driveA24 },
// Plan 02-04 Task 2 A25: REQ-archive-export-latency / SPEC §10 #6.
// Page-side measures SAVE→ack via performance.now() bookends; host-side
// adds the dispatch→file-on-disk latency check via downloadsDir
// polling + mtime delta. Hard ceiling: 5000ms end-to-end. A25 owns
// its setupFreshRecording (clean latency measurement; not compounded
// 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 },
]; ];
const buffers = { swConsole: handles.swConsole, offConsole: handles.offConsole }; const buffers = { swConsole: handles.swConsole, offConsole: handles.offConsole };

View File

@@ -1207,6 +1207,131 @@ export async function driveA24(page: Page): Promise<AssertionRecord> {
}) as AssertionRecord; }) as AssertionRecord;
} }
/* ─── Plan 02-04 Task 2 — driveA25 (REQ-archive-export-latency, 5s) ──── */
/** Maximum wait for the SAVE_ARCHIVE zip to appear in downloadsDir, host-
* side. 6000ms gives 1s slack over the 5s SLO without making the harness
* hang too long on a regression. */
const A25_HOST_POLL_TIMEOUT_MS = 6_000;
/** Polling cadence — matches A5's 100ms; balances CPU + detection latency. */
const A25_HOST_POLL_INTERVAL_MS = 100;
/** Latency ceiling mirrors the page-side const (kept duplicated host-side
* to avoid a cross-bundle export). */
const A25_LATENCY_CEILING_MS = 5_000;
/**
* Drive A25 (Plan 02-04 REQ-archive-export-latency 5s ceiling).
* Four-phase orchestration:
*
* 1. Host side: snapshot existing zips in downloadsDir BEFORE dispatch.
* Record t0_host = Date.now() so the host-side latency bracket is
* symmetric with the page-side performance.now() bracket.
*
* 2. Page side: dispatch SAVE_ARCHIVE via `assertA25` harness method.
* Returns A25Result with t0/tAck (page-side bookends) + ackSuccess.
*
* 3. Host side: poll downloadsDir for the new zip; record tFile_host
* = Date.now() when the file appears.
*
* 4. Host side: merge checks — A25.3 (host-side dispatch→file-on-disk
* latency < 5000ms) on top of A25.1+A25.2 (page-side).
*
* Note: t0_host is captured BEFORE the page.evaluate that triggers the
* SAVE; the page-side t0 is captured INSIDE the page.evaluate just
* before the chrome.runtime.sendMessage. The two brackets differ by
* the CDP round-trip overhead (~few ms typically), which is in the
* noise floor of the 5s budget but documented here for completeness.
*
* Chains AFTER driveA24 in the orchestrator. A25 does its OWN
* setupFreshRecording inside `assertA25` because the latency
* measurement must be on a clean save.
*
* @param page - The harness page from `launchHarnessBrowser`.
* @param downloadsDir - Absolute path to the per-run downloads directory.
* @returns Structured AssertionRecord with 3 merged checks (page-side
* ack + page-side latency + host-side latency).
*/
export async function driveA25(
page: Page,
downloadsDir: string,
): Promise<AssertionRecord> {
// Phase 1 — snapshot pre-existing zips (filename + mtime for the
// overwrite-aware detector below). Mirrors A12/A13's pattern.
// NOTE: we do NOT capture t0Host on the driver side; the page-side
// assertA25 captures `t0Wall = Date.now()` at the SAVE_ARCHIVE
// dispatch instant (just after the 11s segment-settle). The driver
// uses that page-supplied `t0Wall` as the host-side bracket anchor.
// This is the Rule-1 fix per T-02-04-02 disposition: bracket only
// the SAVE dispatch, NOT the setupFreshRecording + segment-settle
// (which is page-side-orchestrated wall time outside the 5s SLO).
const preSnapshot = snapshotExistingZips(downloadsDir);
// Phase 2 — page-side dispatch via assertA25.
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 -- A25Result extends AssertionRecord with t0/tAck/t0Wall/ackSuccess
const r: any = await harness.assertA25();
return r;
}) as AssertionRecord & {
t0: number;
tAck: number;
t0Wall: number;
ackSuccess: boolean;
};
// Phase 3 — host-side poll for a new-or-updated zip.
let tFileHost: number | null = null;
const pollStart = Date.now();
while (Date.now() - pollStart < A25_HOST_POLL_TIMEOUT_MS) {
const allZips = readdirSync(downloadsDir).filter((name) => name.endsWith('.zip'));
let foundNew = false;
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) {
foundNew = true;
break;
}
}
if (foundNew) {
tFileHost = Date.now();
break;
}
await new Promise((r) => setTimeout(r, A25_HOST_POLL_INTERVAL_MS));
}
// Phase 4 — merge checks.
const mergedChecks: CheckRecord[] = pageResult.checks.slice();
const mergedDiagnostics: string[] = pageResult.diagnostics.slice();
// Compute host-side dispatch→file latency using the PAGE-SUPPLIED
// t0Wall (captured at the SAVE_ARCHIVE dispatch instant, after the
// 11s segment-settle). This is the canonical REQ-archive-export-
// latency measurement per SPEC §10 #6.
const t0Wall = pageResult.t0Wall;
const elapsedFile = tFileHost !== null ? tFileHost - t0Wall : -1;
mergedChecks.push({
name: `A25.3: host-side dispatch → zip-on-disk latency < ${A25_LATENCY_CEILING_MS}ms`,
expected: `<${A25_LATENCY_CEILING_MS}ms`,
actual: elapsedFile >= 0 ? `${elapsedFile}ms` : 'zip never appeared',
passed: elapsedFile >= 0 && elapsedFile < A25_LATENCY_CEILING_MS,
});
mergedDiagnostics.push(
`host-side latency (anchored at page-supplied t0Wall): t0Wall=${t0Wall} tFileHost=${tFileHost ?? '<missing>'} delta=${elapsedFile}ms`,
);
const mergedPassed = mergedChecks.every((c) => c.passed);
return {
passed: mergedPassed,
name: pageResult.name,
checks: mergedChecks,
diagnostics: mergedDiagnostics,
error: pageResult.error,
};
}
// Note (Wave 3D): the AssertionWithBytes interface is retained at the // Note (Wave 3D): the AssertionWithBytes interface is retained at the
// top of this file as a public export — but Wave 3D's drivers no // top of this file as a public export — but Wave 3D's drivers no
// longer use it (the host side now does all bytes-handling internally // longer use it (the host side now does all bytes-handling internally