Files

378 lines
13 KiB
JavaScript
Executable File

#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { mkdirSync, readFileSync, readdirSync, statSync } from "node:fs";
import { basename, join, relative, sep } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const evalsDir = fileURLToPath(new URL("..", import.meta.url));
const repoRoot = fileURLToPath(new URL("../..", import.meta.url));
const testsDir = join(evalsDir, "specs");
const worldsDir = join(evalsDir, "results/.worlds");
const usage = `Usage: node evals/bin/evals.mjs [test-names...] [flags]
Run E2E tests:
--with-llm-vision Judge vision claims inline (default: defer judging)
--local Force isolated local resources and clear inherited remote placement
--daytona Set OPENWORK_EVAL_DAYTONA=1
--den <url> Set OPENWORK_EVAL_DEN_API_URL=<url>
Judge then publish evidence:
--publish Enter judge-then-publish mode
--pr <n> Publish to pull request n
--test-run <value> Select a test run path, directory ID, name, or latest (default: latest)
--dry-run Render publication output without posting
--force Forward force to the publisher
Other:
--help, -h Show this help
Publish mode cannot be combined with test names, --with-llm-vision, --daytona,
--local, or --den. Named tests auto-consent to opt-in flags declared in their source;
value-bearing environment variables are never auto-set.
Run exit codes:
0 Passed, or an unfiltered E2E suite completed with expected skips
1 One or more tests failed
2 A named test skipped and its result is incomplete
Publish exit codes:
0 Judge and publisher succeeded
1 Failed claims were published, or publishing failed
2 Pending claims require judging before publication
`;
export function consentVarsFromSource(text) {
const variables = new Set();
const optInPattern = /optIn\s*:\s*\[([^\]]*)\]/gs;
const envPattern = /process\.env\.(OPENWORK_EVAL_[A-Z0-9_]+)(?:\?\.trim\(\))?\s*===\s*"1"/g;
for (const match of text.matchAll(optInPattern)) {
for (const literal of match[1].matchAll(/["'](OPENWORK_EVAL_[A-Z0-9_]+)["']/g)) {
variables.add(literal[1]);
}
}
for (const match of text.matchAll(envPattern)) variables.add(match[1]);
return [...variables].sort();
}
function valueAfter(args, index, flag) {
const value = args[index + 1];
if (!value || value.startsWith("--")) throw new Error(`${flag} requires a value.`);
return value;
}
export function parseArgs(args) {
const options = {
testNames: [],
withLlmVision: false,
local: false,
daytona: false,
publish: false,
dryRun: false,
force: false,
help: false,
};
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--help" || arg === "-h") options.help = true;
else if (arg === "--with-llm-vision") options.withLlmVision = true;
else if (arg === "--local") options.local = true;
else if (arg === "--daytona") options.daytona = true;
else if (arg === "--publish") options.publish = true;
else if (arg === "--dry-run") options.dryRun = true;
else if (arg === "--force") options.force = true;
else if (arg === "--den" || arg === "--pr" || arg === "--test-run") {
const value = valueAfter(args, index, arg);
if (arg === "--den") options.den = value;
else if (arg === "--pr") options.pr = value;
else options.testRun = value;
index += 1;
} else if (arg.startsWith("-")) {
throw new Error(`Unknown flag: ${arg}`);
} else {
options.testNames.push(arg);
}
}
if (options.local && (options.daytona || options.den !== undefined)) {
const conflicts = [];
if (options.daytona) conflicts.push("--daytona");
if (options.den !== undefined) conflicts.push("--den");
throw new Error(`--local is mutually exclusive with ${conflicts.join(" and ")}.`);
}
if (options.publish) {
const conflicts = [];
if (options.testNames.length > 0) conflicts.push("test names");
if (options.withLlmVision) conflicts.push("--with-llm-vision");
if (options.local) conflicts.push("--local");
if (options.daytona) conflicts.push("--daytona");
if (options.den !== undefined) conflicts.push("--den");
if (conflicts.length > 0) {
throw new Error(`--publish is mutually exclusive with ${conflicts.join(", ")}.`);
}
if (!options.pr && !options.dryRun && !options.help) {
throw new Error("--pr <n> is required unless --dry-run is set.");
}
} else {
const publishFlags = [];
if (options.pr !== undefined) publishFlags.push("--pr");
if (options.testRun !== undefined) publishFlags.push("--test-run");
if (options.dryRun) publishFlags.push("--dry-run");
if (options.force) publishFlags.push("--force");
if (publishFlags.length > 0) {
throw new Error(`${publishFlags.join(", ")} require --publish.`);
}
}
return options;
}
const REMOTE_PLACEMENT_ENV = [
"OPENWORK_EVAL_DAYTONA",
"OPENWORK_EVAL_DAYTONA_SANDBOX",
"OPENWORK_EVAL_DAYTONA_SANDBOX_ID",
"OPENWORK_EVAL_DAYTONA_DEN_SANDBOX",
"OPENWORK_EVAL_DAYTONA_DESKTOP_SANDBOX",
"OPENWORK_EVAL_DEN_API_URL",
"OPENWORK_EVAL_DEN_WEB_URL",
];
/** Resolve the child environment before any test process can provision resources. */
export function resolveRunEnvironment(options, env = process.env) {
const childEnv = { ...env };
if (options.local) {
for (const name of REMOTE_PLACEMENT_ENV) delete childEnv[name];
return childEnv;
}
if (options.daytona) childEnv.OPENWORK_EVAL_DAYTONA = "1";
if (options.den !== undefined) childEnv.OPENWORK_EVAL_DEN_API_URL = options.den;
return childEnv;
}
function testFiles(directory = testsDir) {
return readdirSync(directory, { recursive: true, withFileTypes: true })
.filter((entry) => entry.isFile() && /\.e2e\.test\.ts$/.test(entry.name))
.map((entry) => join(entry.parentPath, entry.name))
.sort();
}
export function resolveTestNames(names, files = testFiles()) {
const entries = files.map((file) => ({
file,
base: basename(file),
relative: relative(testsDir, file).split(sep).join("/"),
}));
const resolved = [];
for (const name of names) {
const normalized = name.replace(/^\.\//, "").replace(/^specs\//, "");
let matches = entries.filter((entry) => entry.relative === normalized);
if (matches.length === 0) {
matches = entries.filter((entry) =>
entry.base === `${normalized}.e2e.test.ts`
);
}
if (matches.length === 0) {
matches = entries.filter((entry) => entry.base.startsWith(normalized));
}
if (matches.length > 1) {
throw new Error(`Test name "${name}" is ambiguous:\n${matches.map((entry) => ` ${entry.relative}`).join("\n")}`);
}
if (matches.length === 0) {
const close = entries.filter((entry) => entry.base.includes(normalized));
throw new Error(`No test matches "${name}". Close candidates:\n${close.length > 0 ? close.map((entry) => ` ${entry.relative}`).join("\n") : " (none)"}`);
}
if (!resolved.includes(matches[0].file)) resolved.push(matches[0].file);
}
return resolved;
}
function reportAssertions(report) {
if (!Array.isArray(report?.testResults)) return [];
return report.testResults.flatMap((result) => {
if (!Array.isArray(result?.assertionResults)) return [];
return result.assertionResults.map((assertion) => ({ assertion, result }));
});
}
function reportCount(report, field, status) {
if (Number.isFinite(report?.[field])) return report[field];
const assertions = reportAssertions(report);
if (assertions.length === 0) return null;
return assertions.filter(({ assertion }) => status.includes(assertion?.status)).length;
}
export function summarize(report) {
const assertions = reportAssertions(report);
return {
passed: reportCount(report, "numPassedTests", ["passed"]),
failed: reportCount(report, "numFailedTests", ["failed"]),
skipped: reportCount(report, "numPendingTests", ["pending", "skipped", "todo", "disabled"]),
skips: assertions
.filter(({ assertion }) => ["pending", "skipped", "todo", "disabled"].includes(assertion?.status))
.map(({ assertion, result }) => ({
file: basename(result?.name ?? result?.testFilePath ?? "unknown"),
title: assertion?.title ?? assertion?.fullName ?? "unknown",
})),
};
}
export function verdictFor(summary, { childExit = 0 } = {}) {
if ((summary.failed ?? 0) > 0 || childExit !== 0) return "failed";
if ((summary.skipped ?? 0) > 0) return "incomplete";
return "passed";
}
export function exitCodeFor(verdict, { named = false } = {}) {
if (verdict === "failed") return 1;
if (verdict === "incomplete" && named) return 2;
return 0;
}
export function worldSnapshotsSince(startTime, directory = worldsDir) {
try {
return readdirSync(directory, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
.flatMap((entry) => {
try {
const path = join(directory, entry.name);
const mtime = statSync(path).mtimeMs;
return mtime >= startTime ? [{ path, mtime }] : [];
} catch {
return [];
}
})
.sort((left, right) => right.mtime - left.mtime)
.map(({ path }) => path);
} catch {
return [];
}
}
function childStatus(result) {
if (result.error) process.stderr.write(`${result.error.message}\n`);
return result.status ?? 1;
}
function publish(options) {
const testRun = options.testRun ?? "latest";
const judge = spawnSync(process.execPath, [
join(evalsDir, "packages/test-evidence/bin/test-evidence-judge.mjs"),
"--test-run",
testRun,
], { cwd: repoRoot, env: process.env, stdio: "inherit" });
const judgeStatus = childStatus(judge);
if (judgeStatus === 2 && !options.dryRun) {
process.stderr.write("Pending claims need OPENAI_API_KEY or ANTHROPIC_API_KEY for judging; rerun after providing one, or use --dry-run.\n");
return 2;
}
if (![0, 1, 2].includes(judgeStatus)) return judgeStatus;
const publishArgs = [join(evalsDir, "packages/test-artifacts/bin/publish-pr.mjs")];
if (options.pr) publishArgs.push("--pr", options.pr);
if (options.testRun) publishArgs.push("--test-run", options.testRun);
if (options.dryRun) publishArgs.push("--dry-run");
if (options.force) publishArgs.push("--force");
const published = spawnSync(process.execPath, publishArgs, {
cwd: repoRoot,
env: process.env,
stdio: "inherit",
});
const publishStatus = childStatus(published);
return judgeStatus === 1 && publishStatus === 0 ? 1 : publishStatus;
}
function run(options) {
const runStartedAt = Date.now();
const resolved = resolveTestNames(options.testNames);
const childEnv = resolveRunEnvironment(options);
childEnv.OPENWORK_EVAL_E2E_TESTS = "1";
const consented = new Set(["OPENWORK_EVAL_E2E_TESTS"]);
for (const file of resolved) {
for (const variable of consentVarsFromSource(readFileSync(file, "utf8"))) {
if (Object.hasOwn(process.env, variable)) continue;
childEnv[variable] = "1";
consented.add(variable);
}
}
if (options.withLlmVision) delete childEnv.OPENWORK_EVAL_VISION;
else childEnv.OPENWORK_EVAL_VISION = "defer";
const outputDir = join(evalsDir, "results/.testkit");
mkdirSync(outputDir, { recursive: true });
const outputFile = join(outputDir, `cli-run-${Date.now()}.json`);
const vitestArgs = [
"exec", "vitest", "run",
"--config", "vitest.config.ts",
"--project", "e2e",
"--reporter=default",
"--reporter=json",
`--outputFile=${outputFile}`,
...resolved.map((file) => relative(evalsDir, file).split(sep).join("/")),
];
const child = spawnSync("pnpm", vitestArgs, { cwd: evalsDir, env: childEnv, stdio: "inherit" });
const status = childStatus(child);
let report;
try {
const parsed = JSON.parse(readFileSync(outputFile, "utf8"));
if (!Number.isFinite(parsed?.numTotalTests) || !Array.isArray(parsed?.testResults)) {
throw new Error("Invalid Vitest JSON report.");
}
report = parsed;
} catch {
report = undefined;
}
const summary = summarize(report);
const verdict = verdictFor(summary, { childExit: status });
if (verdict === "failed") {
const snapshots = worldSnapshotsSince(runStartedAt);
if (snapshots.length > 0) {
const paths = snapshots.map((path) => relative(repoRoot, path).split(sep).join("/"));
process.stderr.write(`world receipt metadata from this run: ${paths.join(", ")}\n`);
}
}
process.stdout.write(`${JSON.stringify({
command: "evals:e2e",
lane: "e2e",
daytona: childEnv.OPENWORK_EVAL_DAYTONA?.trim() === "1",
placement: options.local
? "local"
: options.daytona
? "daytona"
: options.den !== undefined
? "attached"
: "automatic",
vision: options.withLlmVision ? "inline" : "defer",
files: options.testNames.length > 0 ? options.testNames : ["all"],
...summary,
consented: [...consented].sort(),
verdict,
})}\n`);
return exitCodeFor(verdict, { named: resolved.length > 0 });
}
export function main(argv = process.argv.slice(2)) {
let options;
try {
options = parseArgs(argv);
if (options.help) {
process.stdout.write(usage);
return 0;
}
return options.publish ? publish(options) : run(options);
} catch (error) {
process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}\n`);
return 1;
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exitCode = main();
}