538 lines
16 KiB
TypeScript
538 lines
16 KiB
TypeScript
import { existsSync, readdirSync } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { config } from "dotenv";
|
|
import { runInDocker } from "../utils/docker.ts";
|
|
import { ensureGitHubToken } from "../utils/github.ts";
|
|
import { isInsideDocker } from "../utils/globals.ts";
|
|
import {
|
|
installSignalHandlers,
|
|
killTrackedChildren,
|
|
setSignalHandler,
|
|
} from "../utils/subprocess.ts";
|
|
import {
|
|
type AgentResult,
|
|
agents,
|
|
getPrefix,
|
|
printResults,
|
|
printSingleValidation,
|
|
runAgentStreaming,
|
|
type TestRunnerOptions,
|
|
type TestTag,
|
|
type ValidationResult,
|
|
validateResult,
|
|
} from "./utils.ts";
|
|
|
|
/**
|
|
* unified test runner for all agent tests.
|
|
*
|
|
* usage: node test/run.ts [filters...]
|
|
*
|
|
* filters can be test names, tags, or agent names:
|
|
* node test/run.ts # run all tests (excludes adhoc-tagged tests)
|
|
* node test/run.ts smoke # run tests named "smoke" or tagged "smoke"
|
|
* node test/run.ts claude # run all tests for claude only
|
|
* node test/run.ts fs # run all tests tagged "fs"
|
|
* node test/run.ts agnostic # run all agnostic-tagged tests (with claude)
|
|
* node test/run.ts adhoc # run all adhoc-tagged tests
|
|
* node test/run.ts smoke claude # run smoke tests for claude only
|
|
*
|
|
* special tags:
|
|
* - "agnostic": runs with claude only, excluded when filtering by agent
|
|
* - "adhoc": excluded from default runs, must be explicitly requested
|
|
*
|
|
* by default, runs in a Docker container for isolation.
|
|
*/
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
export const actionDir = join(__dirname, "..");
|
|
|
|
// load .env files
|
|
config({ path: join(actionDir, ".env") });
|
|
config({ path: join(actionDir, "..", ".env") });
|
|
|
|
const nodeModulesVolume = "pullfrog-action-test-node-modules";
|
|
const mcpPortBase = 49000;
|
|
let nextMcpPort = mcpPortBase;
|
|
|
|
function allocateMcpPort(): number {
|
|
const port = nextMcpPort;
|
|
nextMcpPort += 1;
|
|
return port;
|
|
}
|
|
|
|
function buildNodeCmd(args: string[]): string {
|
|
const passArgs = args.map((arg) => `'${arg.replace(/'/g, "'\\''")}'`).join(" ");
|
|
return `node test/run.ts ${passArgs}`;
|
|
}
|
|
|
|
// run the test runner inside docker
|
|
function runTestsInDocker(args: string[]): never {
|
|
const result = runInDocker({
|
|
actionDir,
|
|
args,
|
|
nodeCmd: buildNodeCmd(args),
|
|
volumeName: nodeModulesVolume,
|
|
envFilterMode: "allowlist",
|
|
onStart: () => console.log("» running tests in docker container...\n"),
|
|
});
|
|
|
|
process.exit(result.status ?? 1);
|
|
}
|
|
|
|
type TestInfo = {
|
|
name: string;
|
|
config: TestRunnerOptions;
|
|
};
|
|
|
|
type CancelState = {
|
|
canceled: boolean;
|
|
signal: NodeJS.Signals | null;
|
|
};
|
|
|
|
type TestModule = {
|
|
test?: TestRunnerOptions;
|
|
tests?: Record<string, TestRunnerOptions>;
|
|
};
|
|
|
|
// load all tests from all directories
|
|
async function loadAllTests(): Promise<TestInfo[]> {
|
|
const testInfos: TestInfo[] = [];
|
|
const dirs = ["crossagent", "agnostic", "adhoc"];
|
|
|
|
for (const dir of dirs) {
|
|
const dirPath = join(__dirname, dir);
|
|
if (!existsSync(dirPath)) continue;
|
|
|
|
const files = readdirSync(dirPath).filter((f) => f.endsWith(".ts"));
|
|
for (const file of files) {
|
|
const filePath = join(dirPath, file);
|
|
const module = (await import(filePath)) as TestModule;
|
|
|
|
if (module.test) {
|
|
testInfos.push({ name: module.test.name, config: module.test });
|
|
} else if (module.tests) {
|
|
const entries = Object.entries(module.tests);
|
|
for (const entry of entries) {
|
|
testInfos.push({ name: entry[0], config: entry[1] });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return testInfos;
|
|
}
|
|
|
|
// check if test has a specific tag
|
|
function hasTag(test: TestInfo, tag: TestTag): boolean {
|
|
return test.config.tags?.includes(tag) ?? false;
|
|
}
|
|
|
|
type ParsedArgs = {
|
|
filters: string[]; // test names or tags
|
|
agentFilters: string[];
|
|
};
|
|
|
|
function parseArgs(args: string[], allTests: TestInfo[]): ParsedArgs {
|
|
const testNames = new Set(allTests.map((t) => t.name));
|
|
const allTags = new Set(allTests.flatMap((t) => t.config.tags ?? []));
|
|
|
|
const filters: string[] = [];
|
|
const agentFilters: string[] = [];
|
|
|
|
for (const arg of args) {
|
|
if (agents.includes(arg as (typeof agents)[number])) {
|
|
agentFilters.push(arg);
|
|
} else if (testNames.has(arg) || allTags.has(arg as TestTag)) {
|
|
filters.push(arg);
|
|
} else {
|
|
console.error(`unknown argument: ${arg}`);
|
|
console.error(`available tests: ${[...testNames].join(", ")}`);
|
|
console.error(`available tags: ${[...allTags].join(", ")}`);
|
|
console.error(`available agents: ${agents.join(", ")}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
return { filters, agentFilters };
|
|
}
|
|
|
|
// filter tests based on filters (names or tags)
|
|
function filterTests(allTests: TestInfo[], filters: string[]): TestInfo[] {
|
|
if (filters.length === 0) {
|
|
// default: exclude adhoc tests
|
|
return allTests.filter((t) => !hasTag(t, "adhoc"));
|
|
}
|
|
|
|
// match tests by name or tag
|
|
return allTests.filter((t) => {
|
|
for (const filter of filters) {
|
|
if (t.name === filter || hasTag(t, filter as TestTag)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
});
|
|
}
|
|
|
|
type RunContext = {
|
|
testInfo: TestInfo;
|
|
agent: string;
|
|
cancelState: CancelState;
|
|
results: Map<string, ValidationResult>;
|
|
};
|
|
|
|
function getRunKey(test: string, agent: string): string {
|
|
return `${test}::${agent}`;
|
|
}
|
|
|
|
type CanceledValidationContext = {
|
|
testInfo: TestInfo;
|
|
agent: string;
|
|
signal: NodeJS.Signals;
|
|
};
|
|
|
|
function buildCanceledValidation(ctx: CanceledValidationContext): ValidationResult {
|
|
return {
|
|
test: ctx.testInfo.name,
|
|
agent: ctx.agent,
|
|
passed: false,
|
|
canceled: true,
|
|
checks: [{ name: "canceled", passed: false }],
|
|
output: `canceled by ${ctx.signal}`,
|
|
};
|
|
}
|
|
|
|
const MAX_RETRIES = 2;
|
|
const RATE_LIMIT_BACKOFF_MS = 60_000; // 1 minute for rate limits
|
|
const FLAKY_RETRY_BACKOFF_MS = 5_000; // 5 seconds for transient failures
|
|
|
|
type RetryDecision = { retry: false } | { retry: true; reason: string; backoffMs: number };
|
|
|
|
/**
|
|
* determine if a failed test run should be retried.
|
|
*
|
|
* retryable (transient infrastructure failures):
|
|
* - rate limit errors from API providers
|
|
* - agent crashed/errored but no security-relevant checks failed
|
|
* (e.g., agent didn't call set_output due to MCP connection drop)
|
|
* - set_output not called — all output-dependent checks cascade fail
|
|
*
|
|
* NOT retryable (genuine test failures):
|
|
* - security checks failed (sandbox breach, token leak, etc.)
|
|
* - agent successfully ran and called set_output but produced wrong results
|
|
*/
|
|
// detect rate limit / quota errors across all providers
|
|
const RATE_LIMIT_PATTERNS = [
|
|
"Rate limit reached", // anthropic
|
|
"Resource has been exhausted", // google/gemini
|
|
"quota exceeded", // google/gemini
|
|
"429", // generic HTTP 429
|
|
"Too Many Requests", // generic
|
|
];
|
|
|
|
function isRateLimited(output: string): boolean {
|
|
const lower = output.toLowerCase();
|
|
return RATE_LIMIT_PATTERNS.some((p) => lower.includes(p.toLowerCase()));
|
|
}
|
|
|
|
function shouldRetry(result: AgentResult, validation: ValidationResult): RetryDecision {
|
|
// rate limit / quota exhaustion: agent never got to run properly
|
|
if (!result.success && isRateLimited(result.output)) {
|
|
return { retry: true, reason: "rate limited", backoffMs: RATE_LIMIT_BACKOFF_MS };
|
|
}
|
|
|
|
// already passed — no retry needed
|
|
if (validation.passed) {
|
|
return { retry: false };
|
|
}
|
|
|
|
// if the test has a set_output check and it failed, other check failures are
|
|
// cascade failures — validators gate their checks on `setOutputCalled && ...`
|
|
// so they always fail when there's no structured output.
|
|
// security-relevant checks (like no_leak_filtered, native_blocked) are designed
|
|
// to PASS when set_output wasn't called (defensive coding). so cascade failures
|
|
// are never genuine security findings — they're transient instruction-following
|
|
// issues (MCP connection drop, agent confusion, low effort level, etc.).
|
|
const setOutputCheck = validation.checks.find((c) => c.name === "set_output");
|
|
if (setOutputCheck && !setOutputCheck.passed) {
|
|
// if the output contains rate limit indicators, use the longer backoff
|
|
// (the agent process may have succeeded but the subagent hit quota limits)
|
|
const backoffMs = isRateLimited(result.output) ? RATE_LIMIT_BACKOFF_MS : FLAKY_RETRY_BACKOFF_MS;
|
|
return {
|
|
retry: true,
|
|
reason: isRateLimited(result.output)
|
|
? "rate limited (set_output cascade)"
|
|
: "set_output not called (cascade)",
|
|
backoffMs,
|
|
};
|
|
}
|
|
|
|
// set_output was called (or test has no set_output check) — if any other check
|
|
// failed, that's a genuine test failure with real data, not a cascade. don't retry.
|
|
const otherCheckFailed = validation.checks.some((c) => !c.passed && c.name !== "set_output");
|
|
if (otherCheckFailed) {
|
|
return { retry: false };
|
|
}
|
|
|
|
// agent process failed (non-zero exit) but no structured output to validate
|
|
if (!result.success) {
|
|
return { retry: true, reason: "agent process failed", backoffMs: FLAKY_RETRY_BACKOFF_MS };
|
|
}
|
|
|
|
return { retry: false };
|
|
}
|
|
|
|
async function runTestForAgent(ctx: RunContext): Promise<ValidationResult> {
|
|
const testConfig = ctx.testInfo.config;
|
|
const env: Record<string, string> = {};
|
|
if (testConfig.env) {
|
|
const entries = Object.entries(testConfig.env);
|
|
for (const entry of entries) {
|
|
env[entry[0]] = entry[1];
|
|
}
|
|
}
|
|
if (testConfig.agentEnv) {
|
|
const agentEnv = testConfig.agentEnv.get(ctx.agent);
|
|
if (agentEnv) {
|
|
const entries = Object.entries(agentEnv);
|
|
for (const entry of entries) {
|
|
env[entry[0]] = entry[1];
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!Object.hasOwn(env, "PULLFROG_MCP_PORT")) {
|
|
env.PULLFROG_MCP_PORT = String(allocateMcpPort());
|
|
}
|
|
|
|
// pass repo setup commands to play.ts for pre-agent execution
|
|
if (testConfig.repoSetup) {
|
|
env.PULLFROG_TEST_REPO_SETUP = testConfig.repoSetup;
|
|
}
|
|
|
|
// opencode: use codex (OpenAI) to avoid google quota issues and gemini doom-looping
|
|
if (ctx.agent === "opencode") {
|
|
env.OPENCODE_MODEL ??= "openai/gpt-5.1-codex-mini";
|
|
}
|
|
|
|
// gemini: use flash for all tests (including mini-effort) to avoid pro quota limits
|
|
if (ctx.agent === "gemini") {
|
|
env.GEMINI_MODEL ??= "gemini-3-flash-preview";
|
|
}
|
|
|
|
// build file-based env vars for MCP servers that don't inherit parent env
|
|
let fileEnv: Record<string, string> | undefined;
|
|
if (testConfig.fileAgentEnv) {
|
|
const agentFileEnv = testConfig.fileAgentEnv.get(ctx.agent);
|
|
if (agentFileEnv) {
|
|
fileEnv = {};
|
|
const entries = Object.entries(agentFileEnv);
|
|
for (const entry of entries) {
|
|
fileEnv[entry[0]] = entry[1];
|
|
}
|
|
}
|
|
}
|
|
|
|
const prefix = getPrefix({ test: ctx.testInfo.name, agent: ctx.agent });
|
|
|
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
if (ctx.cancelState.canceled) break;
|
|
|
|
// allocate a fresh port on retries (previous server is gone)
|
|
if (attempt > 0) {
|
|
env.PULLFROG_MCP_PORT = String(allocateMcpPort());
|
|
}
|
|
|
|
const result = await runAgentStreaming({
|
|
test: ctx.testInfo.name,
|
|
agent: ctx.agent,
|
|
fixture: testConfig.fixture,
|
|
env,
|
|
fileEnv,
|
|
isCanceled: () => ctx.cancelState.canceled,
|
|
});
|
|
|
|
const validation = validateResult(result, testConfig.validator, {
|
|
test: ctx.testInfo.name,
|
|
expectFailure: testConfig.expectFailure,
|
|
});
|
|
|
|
// check if we should retry
|
|
if (attempt < MAX_RETRIES) {
|
|
const decision = shouldRetry(result, validation);
|
|
if (decision.retry) {
|
|
console.log(
|
|
`\n${prefix} ${decision.reason} — retrying in ${decision.backoffMs / 1000}s (retry ${attempt + 1}/${MAX_RETRIES})...\n`
|
|
);
|
|
await new Promise((r) => setTimeout(r, decision.backoffMs));
|
|
continue;
|
|
}
|
|
}
|
|
|
|
ctx.results.set(getRunKey(ctx.testInfo.name, ctx.agent), validation);
|
|
return validation;
|
|
}
|
|
|
|
// should not reach here, but handle canceled state
|
|
return buildCanceledValidation({
|
|
testInfo: ctx.testInfo,
|
|
agent: ctx.agent,
|
|
signal: ctx.cancelState.signal ?? "SIGTERM",
|
|
});
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const args = process.argv.slice(2);
|
|
|
|
// run in Docker unless already inside
|
|
if (!isInsideDocker) {
|
|
// acquire token for docker if needed
|
|
await ensureGitHubToken();
|
|
runTestsInDocker(args);
|
|
}
|
|
|
|
// load all tests
|
|
const allTests = await loadAllTests();
|
|
const parsed = parseArgs(args, allTests);
|
|
|
|
// filter tests
|
|
const filteredTests = filterTests(allTests, parsed.filters);
|
|
|
|
if (filteredTests.length === 0) {
|
|
console.error("no tests to run");
|
|
process.exit(1);
|
|
}
|
|
|
|
// determine which agents to run
|
|
const agentsToRun = parsed.agentFilters.length > 0 ? parsed.agentFilters : [...agents];
|
|
|
|
// build list of test runs
|
|
type TestRun = { testInfo: TestInfo; agent: string };
|
|
const runs: TestRun[] = [];
|
|
|
|
for (const testInfo of filteredTests) {
|
|
const isAgnostic = hasTag(testInfo, "agnostic");
|
|
|
|
if (isAgnostic) {
|
|
// agnostic tests: skip if only filtering by agent, otherwise run with claude
|
|
if (parsed.filters.length === 0 && parsed.agentFilters.length > 0) {
|
|
continue;
|
|
}
|
|
runs.push({ testInfo, agent: "claude" });
|
|
} else {
|
|
// determine which agents to run for this test
|
|
const testAgents = testInfo.config.agents ?? agents;
|
|
const effectiveAgents = agentsToRun.filter((a) => testAgents.includes(a));
|
|
for (const agent of effectiveAgents) {
|
|
runs.push({ testInfo, agent });
|
|
}
|
|
}
|
|
}
|
|
|
|
if (runs.length === 0) {
|
|
console.error("no test runs after filtering");
|
|
process.exit(1);
|
|
}
|
|
|
|
// describe what we're running
|
|
const runTestNames = [...new Set(runs.map((r) => r.testInfo.name))];
|
|
const runAgentNames = [...new Set(runs.map((r) => r.agent))];
|
|
console.log(`running ${runTestNames.join(", ")} for: ${runAgentNames.join(", ")}\n`);
|
|
|
|
const cancelState: CancelState = { canceled: false, signal: null };
|
|
const results = new Map<string, ValidationResult>();
|
|
let resultsPrinted = false;
|
|
|
|
function printAndExit(validations: ValidationResult[]): void {
|
|
if (resultsPrinted) return;
|
|
resultsPrinted = true;
|
|
console.log();
|
|
for (const v of validations) {
|
|
printSingleValidation(v);
|
|
}
|
|
printResults(validations);
|
|
const allPassed = validations.every((v) => v.passed);
|
|
process.exit(allPassed ? 0 : 1);
|
|
}
|
|
|
|
function handleCancel(signal: NodeJS.Signals): void {
|
|
if (cancelState.canceled) return;
|
|
cancelState.canceled = true;
|
|
cancelState.signal = signal;
|
|
killTrackedChildren();
|
|
|
|
const validations: ValidationResult[] = [];
|
|
for (const run of runs) {
|
|
const key = getRunKey(run.testInfo.name, run.agent);
|
|
const existing = results.get(key);
|
|
if (existing) {
|
|
validations.push(existing);
|
|
} else {
|
|
validations.push(
|
|
buildCanceledValidation({
|
|
testInfo: run.testInfo,
|
|
agent: run.agent,
|
|
signal,
|
|
})
|
|
);
|
|
}
|
|
}
|
|
printAndExit(validations);
|
|
}
|
|
|
|
setSignalHandler(handleCancel);
|
|
installSignalHandlers();
|
|
|
|
// run tests with limited concurrency to avoid overwhelming agent APIs
|
|
const maxConcurrency = 5;
|
|
const validations = await runWithConcurrencyLimit(runs, maxConcurrency, (run) =>
|
|
runTestForAgent({
|
|
testInfo: run.testInfo,
|
|
agent: run.agent,
|
|
cancelState,
|
|
results,
|
|
})
|
|
);
|
|
|
|
if (!cancelState.canceled) {
|
|
printAndExit(validations);
|
|
}
|
|
}
|
|
|
|
// simple concurrency limiter
|
|
async function runWithConcurrencyLimit<T, R>(
|
|
items: T[],
|
|
limit: number,
|
|
fn: (item: T) => Promise<R>
|
|
): Promise<R[]> {
|
|
const results: R[] = [];
|
|
const executing: Promise<void>[] = [];
|
|
|
|
for (const item of items) {
|
|
const p = fn(item).then(
|
|
(result) => {
|
|
results.push(result);
|
|
},
|
|
(err: unknown) => {
|
|
console.error("runWithConcurrencyLimit: fn rejected unexpectedly", err);
|
|
throw err;
|
|
}
|
|
);
|
|
|
|
const e = p.then(() => {
|
|
executing.splice(executing.indexOf(e), 1);
|
|
});
|
|
executing.push(e);
|
|
|
|
if (executing.length >= limit) {
|
|
await Promise.race(executing);
|
|
}
|
|
}
|
|
|
|
await Promise.all(executing);
|
|
return results;
|
|
}
|
|
|
|
main();
|