diff --git a/js/viewer/README.md b/js/viewer/README.md index 422eb46..270ac30 100644 --- a/js/viewer/README.md +++ b/js/viewer/README.md @@ -39,9 +39,10 @@ deliberate trap showing as tracked expected-fail debt on both targets. - `harness.mjs` — the browser-safe runner core: tag-inventory parsing (custom sections), mark scheduling, the per-case loop with shard - striping. Shared by the page's workers and the Node selftest; the - future gating jco adapter (#5) must use this same module — the gate - and the page must not drift. + striping and the opt-in gating-adapter options (#50: per-case fresh + instances, per-case wall bound). Shared by the page's workers and + the Node selftest; the gating jco adapters (#5) must use this same + module — the gate and the page must not drift. - `context.js` — the host-implemented `test-context` provider. - `worker.mjs` — one shard of a live run (module workers cannot see import maps, which is why every transpile here maps imports to diff --git a/js/viewer/harness.mjs b/js/viewer/harness.mjs index 1de7cbe..699b84f 100644 --- a/js/viewer/harness.mjs +++ b/js/viewer/harness.mjs @@ -106,6 +106,23 @@ export function envelope(target, suite) { * suite-order index alongside the event so a sharded consumer can * restore suite order. * + * The gating-adapter options (#50), both opt-in: + * + * `freshCases` gives every case a fresh instance: census and striping + * still come from `cases`, but each execution re-enumerates from the + * factory and runs the matching case (a vanished case throws — drift, + * unsound, not a failing case). For instantiation-mode transpiles this + * is the wasmtime runner's instance-per-case granularity; module-mode + * transpiles are singletons and cannot use it. + * + * `caseTimeoutMs` is the per-case wall bound (the runner's + * `--case-timeout`): on expiry the case fails with + * `{"limit-exceeded":"case-timeout"}` provenance and the loop moves + * on. JSPI attempts cannot be cancelled — the abandoned attempt keeps + * running until its instance is dropped, so pair this with + * `freshCases` (a timed-out shared instance may be wedged + * mid-suspension, poisoning every later case). + * * @param {object} options * @param {Array} options.cases `tests.all()` from the transpiled suite. * @param {new (onDiagnostic: (msg: string) => void) => object} options.Context @@ -114,9 +131,21 @@ export function envelope(target, suite) { * @param {string} [options.only] Substring filter (skips emit entirely). * @param {(event: object, index: number) => void} options.emit * @param {{ index: number, count: number }} [options.shard] + * @param {() => Promise} [options.freshCases] + * @param {number} [options.caseTimeoutMs] * @returns {Promise<{passed, failed, skipped, na, total}>} */ -export async function runCases({ cases, Context, tagsOf, missing, only, emit, shard }) { +export async function runCases({ + cases, + Context, + tagsOf, + missing, + only, + emit, + shard, + freshCases, + caseTimeoutMs, +}) { const { index: shardIndex, count: shardCount } = shard ?? { index: 0, count: 1 }; let passed = 0, failed = 0, skipped = 0, na = 0, total = 0; for (const [caseIndex, testCase] of cases.entries()) { @@ -136,13 +165,44 @@ export async function runCases({ cases, Context, tagsOf, missing, only, emit, sh emit({ case: name, status: "not-applicable", detail: excluding ?? "" }, caseIndex); continue; } + let executed = testCase; + if (freshCases) { + const fresh = await freshCases(); + executed = fresh.find((c) => String(c.name()) === name); + if (!executed) { + throw new Error(`case ${name} vanished on re-enumeration`); + } + } const diags = []; const ctx = new Context((msg) => diags.push(msg)); let event; try { - await testCase.run(ctx); - passed++; - event = { case: name, status: "pass", provenance: "returned" }; + const attempt = executed.run(ctx); + let timedOut = false; + if (caseTimeoutMs) { + let timer; + timedOut = await Promise.race([ + attempt.then(() => false), + new Promise((resolve) => { + timer = setTimeout(() => resolve(true), caseTimeoutMs); + }), + ]).finally(() => clearTimeout(timer)); + } else { + await attempt; + } + if (timedOut) { + failed++; + event = { + case: name, + status: "fail", + provenance: { "limit-exceeded": "case-timeout" }, + detail: `case timeout exceeded (${caseTimeoutMs / 1000}s)`, + "diagnostics-complete": false, + }; + } else { + passed++; + event = { case: name, status: "pass", provenance: "returned" }; + } } catch (e) { const payload = e?.payload ?? e; if (payload?.tag === "failed") { diff --git a/js/viewer/selftest.mjs b/js/viewer/selftest.mjs index 7ee3b14..e92423d 100644 --- a/js/viewer/selftest.mjs +++ b/js/viewer/selftest.mjs @@ -111,6 +111,70 @@ if (JSON.stringify(merged) !== JSON.stringify(fixture.counts)) { fail(`sharded counts diverge: ${JSON.stringify(merged)} vs ${JSON.stringify(fixture.counts)}`); } +// --- 3. Gating-adapter options (#50): freshCases + caseTimeoutMs ---- +// Synthetic cases (the loop only needs name()/run()): a hanging case +// must produce the limit-exceeded row and not stall the loop, every +// execution must re-enumerate through the factory, and a case +// vanishing on re-enumeration must throw (drift), not fail. +{ + const mkCase = (name, run) => ({ name: () => name, run }); + const template = [ + mkCase("synthetic/pass", async () => {}), + mkCase("synthetic/hang", () => new Promise(() => {})), + mkCase("synthetic/fail", async () => { + throw { payload: { tag: "failed", val: "boom" } }; + }), + ]; + let enumerations = 0; + const events = []; + const counts = await runCases({ + cases: template, + Context, + tagsOf: () => [], + missing: [], + emit: (event) => events.push(event), + caseTimeoutMs: 100, + freshCases: async () => { + enumerations++; + return template; + }, + }); + if (counts.passed !== 1 || counts.failed !== 2 || counts.total !== 3) { + fail(`synthetic counts: ${JSON.stringify(counts)}`); + } + if (enumerations !== 3) { + fail(`freshCases enumerated ${enumerations} times, want one per case`); + } + const hang = events.find((e) => e.case === "synthetic/hang"); + if ( + hang?.status !== "fail" || + hang?.provenance?.["limit-exceeded"] !== "case-timeout" || + hang?.["diagnostics-complete"] !== false + ) { + fail(`hang row: ${JSON.stringify(hang)}`); + } + const failed = events.find((e) => e.case === "synthetic/fail"); + if (failed?.provenance !== "returned" || failed?.detail !== "boom") { + fail(`payload mapping under the race: ${JSON.stringify(failed)}`); + } + let vanished = false; + try { + await runCases({ + cases: [mkCase("synthetic/pass", async () => {})], + Context, + tagsOf: () => [], + missing: [], + emit: () => {}, + freshCases: async () => [], + }); + } catch { + vanished = true; + } + if (!vanished) { + fail("vanished case on re-enumeration did not throw"); + } +} + console.log( `viewer selftest ok: aggregate ${JSON.stringify(doc.summary)}; ` + `sample ${JSON.stringify(sample.counts)}; fixture ${JSON.stringify(fixture.counts)}`, diff --git a/package.json b/package.json index 7f152ed..27dc443 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lann/component-test-js", - "version": "0.1.0", + "version": "0.2.0", "private": false, "type": "module", "description": "The lann:component-test stack's JS runner core, consumable as a rev-pinned git dependency (npm git installs cannot select subdirectories, so this facade lives at the repo root). Source-only by design: the browser-safe harness (tag inventory, mark scheduling, the case loop), the test-context provider, and the shard worker. Built artifacts (the viewer-aggregate engine) are deliberately excluded - they wait for registry publishing. Stability policy until then: pinned rev or nothing.",