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
19 changes: 15 additions & 4 deletions ct-runner/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
// deno run -A ct-runner/src/main.ts <suite.wasm> --out results.jsonl \
// [--translator <translator_shim.wasm>] [--imports <module.ts>] \
// [--target NAME] [--suite-name NAME] \
// [--only SUBSTRING] [--case-timeout-ms N] [--no-fresh-cases] [--jspi]
// [--only SUBSTRING] [--missing f1,f2,...] [--case-timeout-ms N] \
// [--no-fresh-cases] [--jspi]
//
// `--imports <module.ts>` convention (contracts/embedder-api.md §"Module
// wiring and instantiation"): a TS module whose default export is either
Expand All @@ -30,7 +31,8 @@ function usageError(msg: string): never {
"usage: deno run -A ct-runner/src/main.ts <suite.wasm> --out <results.jsonl> " +
"[--translator <translator_shim.wasm>] [--imports <module.ts>] " +
"[--target NAME] [--suite-name NAME] " +
"[--only SUBSTRING] [--case-timeout-ms N] [--no-fresh-cases] [--jspi]",
"[--only SUBSTRING] [--missing f1,f2,...] [--case-timeout-ms N] " +
"[--no-fresh-cases] [--jspi]",
);
Deno.exit(2);
}
Expand All @@ -43,6 +45,7 @@ interface Cli {
target: string;
suiteName?: string;
only?: string;
missing?: string[];
caseTimeoutMs?: number;
freshCases: boolean;
jspi: boolean;
Expand All @@ -56,6 +59,7 @@ function parseArgs(argv: string[]): Cli {
let target = "deltic/host";
let suiteName: string | undefined;
let only: string | undefined;
let missing: string[] | undefined;
let caseTimeoutMs: number | undefined;
let freshCases = true;
let jspi = false;
Expand All @@ -81,6 +85,11 @@ function parseArgs(argv: string[]): Cli {
case "--only":
only = argv[++i];
break;
case "--missing":
// Comma-separated missing-feature list (upstream ct-runner's
// --missing f1,f2,...); tag gating per src/tags.ts.
missing = argv[++i].split(",").filter((f) => f !== "");
break;
case "--case-timeout-ms":
caseTimeoutMs = Number(argv[++i]);
break;
Expand All @@ -105,6 +114,7 @@ function parseArgs(argv: string[]): Cli {
target,
suiteName,
only,
missing,
caseTimeoutMs,
freshCases,
jspi,
Expand Down Expand Up @@ -192,6 +202,7 @@ async function main() {
target: cli.target,
suiteName: cli.suiteName ?? suiteNameFrom(cli.suitePath),
only: cli.only,
missing: cli.missing,
caseTimeoutMs: cli.caseTimeoutMs,
freshCases: cli.freshCases,
jspi: cli.jspi,
Expand All @@ -200,8 +211,8 @@ async function main() {
});
await Deno.writeTextFile(cli.out, lines.join("\n") + "\n");
console.error(
`${counts.passed} passed | ${counts.failed} failed | ${counts.skipped} skipped ` +
`(${counts.total} total) -> ${cli.out}`,
`${counts.passed} passed | ${counts.failed} failed | ${counts.skipped} skipped | ` +
`${counts.na} n/a (${counts.total} total) -> ${cli.out}`,
);
if (counts.failed > 0) Deno.exit(1);
} catch (e) {
Expand Down
77 changes: 70 additions & 7 deletions ct-runner/src/run-suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ import {
} from "../../runtime/src/embedder/mod.ts";
import { Context, testContextImportRecord } from "./context.ts";
import { analyzeImports, requireImportsResolved } from "./import-analysis.ts";
import {
applies,
firstExcluding,
loadTagsInventory,
tagsOf,
} from "./tags.ts";

/** The suite's `tests` interface id (wit/tests.wit `interface tests`, v0.1.0). */
export const TESTS_INTERFACE = "polymorph:test/tests@0.1.0";
Expand All @@ -44,6 +50,17 @@ export interface RunSuiteOptions {
/** Substring filter: non-matching cases are skipped entirely (no emit),
* per js/viewer/harness.mjs `runCases`'s `only` handling. */
only?: string;
/**
* Feature-tag scheduling (issue #25): the features this target LACKS —
* js/viewer/harness.mjs's `missing`. Tag-gating activates whenever the
* suite carries a `component-test:tags@0.1` inventory (src/tags.ts):
* non-applicable cases emit `not-applicable` rows instead of executing,
* and an enumerated case no record covers throws (inventory drift — the
* run is unsound, not failing). Passing `missing` for a suite WITHOUT an
* inventory is an error (gating requested but impossible — upstream's
* runner refuses the same way rather than silently degrading).
*/
missing?: string[];
/**
* Per-case wall-clock budget in ms (the `--case-timeout` runner option
* documented in harness.mjs's `runSuiteJsonl` doc comment). On expiry the
Expand Down Expand Up @@ -76,6 +93,8 @@ export interface RunCounts {
passed: number;
failed: number;
skipped: number;
/** Cases scheduled out as `not-applicable` (tag gating; harness.mjs `na`). */
na: number;
total: number;
}

Expand Down Expand Up @@ -143,18 +162,34 @@ export async function runSuite(
const censusTests = await newTests();
const census = await censusTests.all();

// Feature-tag scheduling (issue #25): gate on the suite's own
// `component-test:tags@0.1` inventory when it has one — the SDK embeds it
// in the guest core module and it survives wac composition, so this
// introspecting runner CAN see it (revising the earlier "cannot see the
// tags section" stance recorded below). Suites without an inventory run
// feature-blind exactly as before.
const inventory = loadTagsInventory(artifacts.componentBytes);
const missing = opts.missing ?? [];
if (inventory === null && opts.missing !== undefined) {
throw new Error(
"missing-features given, but the suite carries no " +
"component-test:tags@0.1 inventory (not built with their SDK, or " +
"sections stripped) — tag gating is impossible, refusing to " +
"silently run feature-blind",
);
}

const suiteName = opts.suiteName.replaceAll("-", "_");
const artifactSha256 = await sha256Hex(artifacts.componentBytes);
opts.emit(JSON.stringify({
"component-test-results": "0.1",
target: opts.target,
suite: { name: suiteName, "artifact-sha256": artifactSha256 },
// "none": this runner applies no tag/manifest scheduling — it executes
// every enumerated case (component-test-results/src/lib.rs `RunInfo`
// doc comment: "none" is for producers that "cannot see the tags
// section", which is exactly this L1-direct runner's position — it never
// reads L0 static feature-tag metadata at all).
run: { segment: 0, scheduling: "none" },
// "tags" when this run schedules against the suite's tag inventory,
// "none" for inventory-less suites (component-test-results/src/lib.rs
// `RunInfo`: "none" is for producers that cannot see the tags section
// — with the inventory in hand, this runner no longer is one).
run: { segment: 0, scheduling: inventory !== null ? "tags" : "none" },
}));

if (census.length === 0) {
Expand All @@ -166,7 +201,7 @@ export async function runSuite(
);
}

const counts: RunCounts = { passed: 0, failed: 0, skipped: 0, total: 0 };
const counts: RunCounts = { passed: 0, failed: 0, skipped: 0, na: 0, total: 0 };

for (const [i, testCase] of census.entries()) {
const name = String(await testCase.name());
Expand All @@ -175,6 +210,28 @@ export async function runSuite(
// continue" — a filtered-out case is skipped entirely, no emit.
if (opts.only && !name.includes(opts.only)) continue;

// harness.mjs `runCases` mark scheduling, in its exact order: `only`
// first (above), then drift, then applicability. The N/A row's shape is
// the embed runner's (expected/verify-pipeline-fixture.jsonl):
// status, first excluding mark as detail, diagnostics-complete true.
if (inventory !== null) {
const tags = tagsOf(inventory, name);
if (tags === undefined) {
throw new Error(`inventory drift: no tags record covers ${name}`);
}
if (!applies(tags, missing)) {
counts.na++;
opts.emit(JSON.stringify({
case: name,
status: "not-applicable",
detail: firstExcluding(tags, missing),
"diagnostics-complete": true,
}));
opts.log?.(`${name} … not-applicable`);
continue;
}
}

// js/viewer/harness.mjs `runCases`' `freshCases` branch: re-enumerate
// from a fresh instance and run the matching case; a vanished case is
// inventory drift, not a failing case, and throws.
Expand Down Expand Up @@ -230,6 +287,10 @@ export async function runSuite(
status: "pass",
provenance: "returned",
"duration-ms": durationMs,
// The case returned normally, so its diagnostics sideband is
// complete (upstream emits this on every returned row; deltic's
// trap/timeout rows already carry `false`).
"diagnostics-complete": true,
};
}
} catch (e) {
Expand All @@ -247,6 +308,7 @@ export async function runSuite(
provenance: "returned",
detail: payload.val,
"duration-ms": durationMs,
"diagnostics-complete": true,
};
} else if (payload?.tag === "skipped") {
counts.skipped++;
Expand All @@ -256,6 +318,7 @@ export async function runSuite(
provenance: "returned",
detail: payload.val,
"duration-ms": durationMs,
"diagnostics-complete": true,
};
} else {
// Contract violation: `outcome` has exactly two cases. Treat as
Expand Down
173 changes: 173 additions & 0 deletions ct-runner/src/tags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
// Feature-tag scheduling (issue #25): the L0 tags inventory and the
// applicability rule, ported from polymorph-test's authorities —
//
// - Section format: crates/component-test-formats/src/inventory.rs
// (`collect_tags_sections` / `parse_tags_records`): newline-delimited
// `name tag...` text records in `component-test:tags@0.1` custom
// sections, collected from the component AND nested modules/components
// (their reader uses wasmparser's `parse_all`, which descends; the
// SDK's `#[link_section]` puts the records in the guest CORE module,
// and those survive wac composition — verified empirically on the
// polymorph-tls composed suites). Records are newline-delimited within
// a section; a producer may omit the final newline, so a newline is
// repaired per section before concatenation, exactly as upstream does.
// - Record forms: `name tag...` (exact) and `prefix/* tag...` (generated
// rows: leaves are enumerated at run time below the prefix).
// - Applicability: crates/component-test-core/src/tags.rs — `feature`
// requires the target to HAVE the feature, `!feature` requires it to
// LACK it; a case applies iff every mark is satisfied against the
// runner's missing-features list. js/viewer/harness.mjs `applies()` is
// the JS-leg reference this mirrors.
// - Drift policy: harness.mjs `runCases` — an enumerated case that no
// record covers throws (the run is unsound, not failing).

/** The custom-section name (component-test-core `name::TAGS_SECTION`). */
export const TAGS_SECTION = "component-test:tags@0.1";

/** Parsed static inventory: exact case records + generated-row prefixes. */
export interface TagsInventory {
exact: Map<string, string[]>;
prefixes: Array<{ prefix: string; tags: string[] }>;
}

const MAGIC = [0x00, 0x61, 0x73, 0x6d]; // "\0asm"

function hasWasmMagic(bytes: Uint8Array, at = 0): boolean {
return bytes.length >= at + 8 && MAGIC.every((b, i) => bytes[at + i] === b);
}

/** u32 LEB128 at `pos`; returns [value, nextPos]. Traps on overlong/EOF. */
function lebU32(bytes: Uint8Array, pos: number): [number, number] {
let result = 0;
let shift = 0;
for (;;) {
if (pos >= bytes.length) throw new Error("tags scan: truncated LEB128");
const b = bytes[pos++];
result |= (b & 0x7f) << shift;
if ((b & 0x80) === 0) break;
shift += 7;
if (shift >= 35) throw new Error("tags scan: LEB128 too long for u32");
}
return [result >>> 0, pos];
}

/**
* Collect the concatenated bytes of every `component-test:tags@0.1` custom
* section in `bytes` — the component's own sections plus those of nested
* core modules (section id 1) and nested components (section id 4), which
* both embed complete wasm binaries. Returns null when no section exists
* anywhere (a suite not built with their SDK).
*/
export function collectTagsSections(bytes: Uint8Array): Uint8Array | null {
if (!hasWasmMagic(bytes)) throw new Error("tags scan: not a wasm binary");
const chunks: Uint8Array[] = [];
const decoder = new TextDecoder();
const NL = new Uint8Array([0x0a]);

const scan = (buf: Uint8Array, core: boolean) => {
let pos = 8; // magic + version/layer
while (pos < buf.length) {
const id = buf[pos++];
const [size, afterSize] = lebU32(buf, pos);
pos = afterSize;
const end = pos + size;
if (end > buf.length) throw new Error("tags scan: truncated section");
if (id === 0) {
const [nameLen, afterName] = lebU32(buf, pos);
const nameEnd = afterName + nameLen;
if (nameEnd > end) throw new Error("tags scan: truncated custom name");
if (decoder.decode(buf.subarray(afterName, nameEnd)) === TAGS_SECTION) {
const data = buf.subarray(nameEnd, end);
chunks.push(data);
// Newline repair per section (inventory.rs: nothing guarantees a
// producer terminates its last record).
if (data.length === 0 || data[data.length - 1] !== 0x0a) {
chunks.push(NL);
}
}
} else if (!core && (id === 1 || id === 4)) {
// 1 = core module, 4 = nested component: payload is a full binary.
const payload = buf.subarray(pos, end);
if (hasWasmMagic(payload)) scan(payload, id === 1);
}
pos = end;
}
};

scan(bytes, false);
if (chunks.length === 0) return null;
const total = chunks.reduce((n, c) => n + c.length, 0);
const out = new Uint8Array(total);
let off = 0;
for (const c of chunks) {
out.set(c, off);
off += c.length;
}
return out;
}

/**
* Parse concatenated records (inventory.rs `parse_tags_records`): one
* record per line, `name tag...`, blank lines skipped, duplicate names and
* empty tags rejected. Grammar validation beyond that (WIT-label checks)
* is the producer's job — their SDK validates at macro-expansion time.
*/
export function parseTagsRecords(bytes: Uint8Array): TagsInventory {
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
const inv: TagsInventory = { exact: new Map(), prefixes: [] };
const seen = new Set<string>();
for (const line of text.split("\n")) {
if (line.trim() === "") continue;
const parts = line.split(" ").filter((p) => p !== "");
const name = parts[0];
const tags = parts.slice(1);
for (const t of tags) {
if (t === "" || t === "!") {
throw new Error(`tags section: empty mark on \`${name}\``);
}
}
if (seen.has(name)) {
throw new Error(`tags section: duplicate record \`${name}\``);
}
seen.add(name);
const prefix = name.endsWith("/*") ? name.slice(0, -2) : null;
if (prefix !== null) {
inv.prefixes.push({ prefix, tags });
} else {
inv.exact.set(name, tags);
}
}
return inv;
}

/** Convenience: scan + parse; null when the suite carries no inventory. */
export function loadTagsInventory(bytes: Uint8Array): TagsInventory | null {
const sections = collectTagsSections(bytes);
return sections === null ? null : parseTagsRecords(sections);
}

/** The tags covering `name`: exact record, else a generated-row prefix
* record (leaves live below `prefix/`), else undefined (inventory drift). */
export function tagsOf(inv: TagsInventory, name: string): string[] | undefined {
const exact = inv.exact.get(name);
if (exact !== undefined) return exact;
for (const { prefix, tags } of inv.prefixes) {
if (name.startsWith(prefix + "/")) return tags;
}
return undefined;
}

/** harness.mjs `applies()`: `!f` needs f missing; `f` needs f present. */
export function applies(tags: string[], missing: string[]): boolean {
return tags.every((t) =>
t.startsWith("!") ? missing.includes(t.slice(1)) : !missing.includes(t)
);
}

/** The N/A row's `detail`: the first unsatisfied mark (harness.mjs's
* `excluding`), empty string if somehow none (mirrors `excluding ?? ""`). */
export function firstExcluding(tags: string[], missing: string[]): string {
return tags.find((t) =>
t.startsWith("!") ? !missing.includes(t.slice(1)) : missing.includes(t)
) ?? "";
}
Loading
Loading