Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions js/viewer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 64 additions & 4 deletions js/viewer/harness.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Array>} [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()) {
Expand All @@ -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") {
Expand Down
64 changes: 64 additions & 0 deletions js/viewer/selftest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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.",
Expand Down
Loading