mirror of
https://github.com/juxt/allium.git
synced 2026-09-14 13:56:44 +08:00
32a03809c0
Brings up a complete test infrastructure spanning four tiers, each
testing a different layer of the project. Built incrementally over
many smaller commits and squashed here.
Tier 1 — language fixtures (free, fast)
Per-construct fixtures under tests/fixtures/lang/ that round-trip
through `allium check`. Covers diagnostic codes the CLI emits, with
a drift mechanism that keeps the corpus in sync as 3.2.x evolves.
Tier 2 — doc-example validation (free, fast)
Extracts code blocks from skills/ and reference docs and validates
each against the language. Inline annotations and an out-of-line
override file (tests/fixtures/docs/overrides.json) handle the long
tail of "this example is illustrative, not buildable."
Tier 3 — skill behavioural evals (gated --live, ~$0.05–$0.50/scenario)
`claude -p` invokes a skill against a fixture workspace; a second
`claude -p` call scores the result against a rubric (LLM-as-judge).
Supports both bare-API and OAuth auth modes. Rubrics under
tests/fixtures/evals/rubrics/.
Tier 4 — end-to-end pipeline (gated --live, ~$1–$10/scenario)
Multi-step distill→weed→tend→propagate scenarios against checked-in
fixture projects. Cumulative workspace state at the end of the run
is compared file-by-file against accepted snapshots.
Tier 4 features developed across this work:
• Dual snapshot comparison: text + Allium model JSON. Either being
a match passes.
• Snapshot-failure judge: LLM classifies diffs as cosmetic /
structural / semantic and gates pass/fail accordingly. Default
haiku, ~$0.05 per failed snapshot. Verdict cached per kept run.
• Kept workspaces (--keep-workspace): preserves the workspace +
per-step checkpoints + a manifest at tests/.tier4-runs/<scenario>-<ts>/
so expensive generation output can be inspected, snapshotted,
and replayed without regenerating.
• Replay mode (--workspace <path>): re-runs the snapshot comparison
against a kept workspace at no API cost.
• Auto-resume (--workspace <path> after scenario steps were added):
detects checkpoint vs scenario-step count, resumes from the last
checkpoint as the working state.
• Variance scenarios (repeat: N): a scenario can opt into running
N independent times into runs/01..NN/ subdirs under one parent
kept dir, with a configurable concurrency cap (--concurrency N,
default 3). Used to investigate distill-output stability.
• Convergence-report script (scripts/tier4-convergence.mjs): diffs
a tracked file across consecutive checkpoints; with --judge,
classifies each transition. Verdicts cached.
• Variance-report script (scripts/tier4-variance.mjs): line-count
distribution + pairwise (N choose 2) judge verdicts + clustering
(cosmetic + structural pairs treated as behaviourally equivalent).
Verdicts cached. --pair drilldown for one specific pair.
Auth modes: both bare ANTHROPIC_API_KEY and OAuth (--oauth) supported
across Tier 3 and Tier 4. The --json-schema flag was found incompatible
with OAuth/agent mode and dropped; envelope JSON is parsed instead.
UX: pretty output (colors + glyphs) with --plain opt-out, an allium
CLI version banner, in-place heartbeat updates on interactive
terminals, --verbose to stream claude stderr + pass --debug.
Snapshot baselines committed for the three node-todo scenarios
(distill-only, distill-then-tend, full-pipeline) plus the two
variance-investigation scenarios (weed-bounded, weed-convergence).
Documentation: tests/README.md (running, scenario authoring, kept
workspaces, replay, variance), tests/RATIONALE.md (the why behind
the tier split), and four iteration-by-iteration variance reports
in tests/docs/ that drove the prompt changes in the follow-up
commits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
197 lines
7.3 KiB
JavaScript
197 lines
7.3 KiB
JavaScript
#!/usr/bin/env node
|
|
//
|
|
// Top-level test orchestrator. Forwards to per-tier runners and existing
|
|
// scripts; sums their counters; exits non-zero on any failure.
|
|
//
|
|
// Usage:
|
|
// node scripts/test.mjs # all offline tiers
|
|
// node scripts/test.mjs --live # include API-using tiers when wired up
|
|
// node scripts/test.mjs tier1 # one group
|
|
// node scripts/test.mjs tier1 artifact # selected groups
|
|
// node scripts/test.mjs tier1 entities # group plus per-tier filter
|
|
//
|
|
// Groups currently wired:
|
|
// tier1 — language fixtures (offline, requires `allium` on PATH)
|
|
// artifact — forwards to scripts/test-skills.mjs (offline)
|
|
// hook — forwards to hooks/allium-check.test.mjs (offline)
|
|
//
|
|
// Tiers 2, 3 and 4 are planned but not yet implemented; they will be
|
|
// added as their runners land.
|
|
|
|
import { execFileSync } from "child_process";
|
|
import path from "path";
|
|
import { fileURLToPath } from "url";
|
|
import { run as tier1Run } from "../tests/tier1-language.mjs";
|
|
import { run as tier2Run } from "../tests/tier2-docs.mjs";
|
|
import { run as tier3Run } from "../tests/tier3-evals.mjs";
|
|
import { run as tier4Run } from "../tests/tier4-e2e.mjs";
|
|
import { summarise } from "../tests/lib/reporter.mjs";
|
|
import { printBanner } from "../tests/lib/banner.mjs";
|
|
|
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = path.dirname(HERE);
|
|
|
|
const argv = process.argv.slice(2);
|
|
const live = argv.includes("--live");
|
|
const flags = new Set(argv.filter((a) => a.startsWith("--")));
|
|
|
|
// Some flags take a value (e.g. `--workspace /path`). Skip the value
|
|
// when bucketing positional args so it doesn't get misread as a
|
|
// scenario filter or group name.
|
|
const VALUE_FLAGS = ["--workspace", "--repeats", "--concurrency"];
|
|
const valueIndices = new Set();
|
|
for (const f of VALUE_FLAGS) {
|
|
const i = argv.indexOf(f);
|
|
if (i >= 0 && i < argv.length - 1) valueIndices.add(i + 1);
|
|
}
|
|
const positional = argv.filter(
|
|
(a, i) => !a.startsWith("--") && !valueIndices.has(i)
|
|
);
|
|
function takeFlagValue(flag) {
|
|
const i = argv.indexOf(flag);
|
|
if (i < 0 || i === argv.length - 1) return null;
|
|
const v = argv[i + 1];
|
|
if (v.startsWith("--")) return null;
|
|
return v;
|
|
}
|
|
|
|
const KNOWN_GROUPS = ["tier1", "tier2", "tier3", "tier4", "artifact", "hook"];
|
|
const requestedGroups = positional.filter((a) => KNOWN_GROUPS.includes(a));
|
|
const filters = positional.filter((a) => !KNOWN_GROUPS.includes(a));
|
|
|
|
function shouldRun(group) {
|
|
return requestedGroups.length === 0 || requestedGroups.includes(group);
|
|
}
|
|
|
|
const totals = {
|
|
passed: 0,
|
|
failed: 0,
|
|
skipped: 0,
|
|
drifted: 0,
|
|
failures: [],
|
|
elapsedMs: 0, // wall-clock for the orchestrator, set just before summarise
|
|
};
|
|
|
|
function add(counters) {
|
|
totals.passed += counters.passed;
|
|
totals.failed += counters.failed;
|
|
totals.skipped += counters.skipped;
|
|
totals.drifted += counters.drifted ?? 0;
|
|
totals.failures.push(...(counters.failures ?? []));
|
|
}
|
|
|
|
const orchestratorStartedAt = Date.now();
|
|
|
|
printBanner();
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// tier1 — language fixtures
|
|
// ---------------------------------------------------------------------------
|
|
if (shouldRun("tier1")) {
|
|
add(await tier1Run({ filters, quiet: flags.has("--quiet") }));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// tier2 — doc-example validation
|
|
// ---------------------------------------------------------------------------
|
|
if (shouldRun("tier2")) {
|
|
add(await tier2Run({ filters, quiet: flags.has("--quiet") }));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// tier3 — skill behavioural evals (gated behind --live; costs API spend)
|
|
// ---------------------------------------------------------------------------
|
|
if (shouldRun("tier3")) {
|
|
add(
|
|
await tier3Run({
|
|
filters,
|
|
live,
|
|
quiet: flags.has("--quiet"),
|
|
verbose: flags.has("--verbose"),
|
|
})
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// tier4 — end-to-end pipeline (gated behind --live; multi-step, expensive)
|
|
// ---------------------------------------------------------------------------
|
|
if (shouldRun("tier4")) {
|
|
const repeatsArg = takeFlagValue("--repeats");
|
|
const concurrencyArg = takeFlagValue("--concurrency");
|
|
add(
|
|
await tier4Run({
|
|
filters,
|
|
live,
|
|
quiet: flags.has("--quiet"),
|
|
updateSnapshots: flags.has("--update-snapshots"),
|
|
verbose: flags.has("--verbose"),
|
|
keepWorkspace: flags.has("--keep-workspace"),
|
|
workspaceOverride: takeFlagValue("--workspace"),
|
|
judge: !flags.has("--no-judge"),
|
|
showDiff: flags.has("--diff"),
|
|
repeats: repeatsArg ? Math.max(1, parseInt(repeatsArg, 10)) : null,
|
|
concurrency: concurrencyArg ? Math.max(1, parseInt(concurrencyArg, 10)) : 3,
|
|
})
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// artifact — existing skill-structure tests
|
|
// ---------------------------------------------------------------------------
|
|
if (shouldRun("artifact")) {
|
|
console.log("\nArtifact — scripts/test-skills.mjs");
|
|
const args = [path.join(ROOT, "scripts", "test-skills.mjs")];
|
|
if (live) args.push("--live");
|
|
add(forwardCounters("artifact", args));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// hook — existing hook tests
|
|
// ---------------------------------------------------------------------------
|
|
if (shouldRun("hook")) {
|
|
console.log("\nHook — hooks/allium-check.test.mjs");
|
|
add(forwardCounters("hook", [path.join(ROOT, "hooks", "allium-check.test.mjs")]));
|
|
}
|
|
|
|
totals.elapsedMs = Date.now() - orchestratorStartedAt;
|
|
summarise("Total", totals);
|
|
process.exit(totals.failed === 0 ? 0 : 1);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Forwarding helper. Existing scripts use their own pass/fail counters and
|
|
// don't expose a programmatic API; we run them as subprocesses and infer
|
|
// counters from their final exit code. A non-zero exit becomes one failure.
|
|
//
|
|
// Reports elapsed wall-clock for the subprocess (the inner test counts
|
|
// aren't visible here, but the time spent running them is).
|
|
// ---------------------------------------------------------------------------
|
|
function forwardCounters(label, args) {
|
|
const startedAt = Date.now();
|
|
try {
|
|
execFileSync("node", args, { stdio: "inherit" });
|
|
const elapsedMs = Date.now() - startedAt;
|
|
console.log(` ${label} forwarded subprocess: ${formatDurationLocal(elapsedMs)}`);
|
|
return { passed: 1, failed: 0, skipped: 0, failures: [] };
|
|
} catch (e) {
|
|
const elapsedMs = Date.now() - startedAt;
|
|
console.log(` ${label} forwarded subprocess: ${formatDurationLocal(elapsedMs)}`);
|
|
return {
|
|
passed: 0,
|
|
failed: 1,
|
|
skipped: 0,
|
|
failures: [`${label} subprocess exited ${e.status ?? "non-zero"}`],
|
|
};
|
|
}
|
|
}
|
|
|
|
// Compact local copy of formatDuration so the orchestrator stays
|
|
// independent of the reporter's import surface for this small thing.
|
|
function formatDurationLocal(ms) {
|
|
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
if (ms < 60_000) return ms < 10000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms / 1000)}s`;
|
|
const totalSec = Math.round(ms / 1000);
|
|
const m = Math.floor(totalSec / 60);
|
|
const s = totalSec % 60;
|
|
return s === 0 ? `${m}m` : `${m}m ${s}s`;
|
|
}
|