1f4c3031be
* ci: filter test matrices by per-test coverage globs to cut LLM spend every test in `crossagent/`, `agnostic/`, and every provider entry now declares a `coverage: string[]` of repo-relative globs. the new `changes` job runs `paths-filter` for a docs-only short-circuit, then pipes the changed-file list into `action/test/matrix.ts`, which intersects each entry's coverage against the diff and emits filtered `agents`, `agnostic`, `flagships`, and `aliases` matrices. main pushes and `workflow_dispatch` set `FULL=1` to run everything as a stale-glob safety net. retires `changed-agents.sh` and the `MODE=flagships` branch in `list-aliases.ts` in favor of one consistent model. * ci(matrix): switch test discovery to dep-free static parsing the GHA `changes` job has no `node_modules` installed. the previous dynamic-import path pulled the test files transitively through `utils.ts` -> `agents/index.ts` -> `@actions/core`, which exploded with ERR_MODULE_NOT_FOUND. parse the test files via regex instead so matrix.ts stays zero-dep — the chain (matrix -> coverage / providers / list-aliases / models) imports only node builtins and relative TS files. * ci(matrix): address PR #730 review feedback - drop dangling `action/mcp/toolFiltering.ts` glob from `nobash`, `restricted`, `tokenExfil` (file doesn't exist; `.test.ts` does, but the runtime tooling lives in `mcp/shell.ts` and `agents/{claude,opencode}.ts`, both already covered). - drop unused `coverageForProvider` export and its `byName` map from `providers.ts` (matrix.ts builds its own lookup inline). - derive the active agent list from `agents/index.ts` via the same dep-free regex tactic as `parseTestFile` instead of hardcoding `["claude", "opencode"]` — adding a new harness file now wires it into the dynamic matrix automatically. - treat `coverage: []` as `coverage: undefined` in `shouldRun` so an accidentally-empty array doesn't silently skip CI on every PR. - add `action/utils/activity.ts` and `action/mcp/selectMode.ts` to the `timeout` test's coverage — the activity-timeout enforcement path was the original reason the test exists. - ungate the `root` job (lint/format/typecheck/vitest). it's a required status check on `main`, so gating it on `code == 'true'` would make docs-only PRs unmergeable (skipped jobs don't satisfy required-check rules). the real LLM savings come from skipping the four matrices, not from skipping `root`. - harden the four matrix-job `if:` guards from `outputs.matrix && ...` to `outputs.matrix != '' && ...` — explicit > implicit short-circuit. - document `expandBraces`'s flat-only support so a future author isn't surprised by `{a,{b,c}}` not expanding. - fix awkward sentence in `wiki/action-tests.md` "CI Cost Filtering".
160 lines
5.3 KiB
TypeScript
160 lines
5.3 KiB
TypeScript
import { readdirSync, readFileSync } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { describe, expect, it } from "vitest";
|
|
import { parse } from "yaml";
|
|
import { agents } from "../agents/index.ts";
|
|
import type { WorkflowPermissions } from "../external.ts";
|
|
import { providers } from "../models.ts";
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const actionDir = join(__dirname, "..");
|
|
const rootDir = join(actionDir, "..");
|
|
|
|
type WorkflowJob = {
|
|
"runs-on": string;
|
|
"timeout-minutes"?: number;
|
|
permissions?: WorkflowPermissions;
|
|
strategy?: { "fail-fast": boolean; matrix: Record<string, unknown> };
|
|
env?: Record<string, string>;
|
|
steps?: unknown[];
|
|
};
|
|
|
|
type Workflow = {
|
|
name: string;
|
|
jobs: Record<string, WorkflowJob>;
|
|
};
|
|
|
|
const rootWorkflow = parse(
|
|
readFileSync(join(rootDir, ".github/workflows/test.yml"), "utf-8")
|
|
) as Workflow;
|
|
const actionWorkflow = parse(
|
|
readFileSync(join(actionDir, ".github/workflows/test.yml"), "utf-8")
|
|
) as Workflow;
|
|
|
|
function getTestNamesFromDir(dir: string): string[] {
|
|
const dirPath = join(__dirname, dir);
|
|
const files = readdirSync(dirPath).filter((f) => f.endsWith(".ts"));
|
|
const names: string[] = [];
|
|
|
|
for (const file of files) {
|
|
const content = readFileSync(join(dirPath, file), "utf-8");
|
|
const match = content.match(/^\s+name:\s*"([^"]+)"/m);
|
|
if (match) {
|
|
names.push(match[1]);
|
|
}
|
|
}
|
|
|
|
return names.sort();
|
|
}
|
|
|
|
function getEnvVarNames(job: WorkflowJob): string[] {
|
|
return Object.keys(job.env ?? {}).sort();
|
|
}
|
|
|
|
const expectedAgents = Object.keys(agents).sort();
|
|
const crossagentTests = getTestNamesFromDir("crossagent");
|
|
const agnosticTests = getTestNamesFromDir("agnostic");
|
|
const adhocTests = getTestNamesFromDir("adhoc");
|
|
|
|
// all provider API key names + GITHUB_TOKEN + model overrides
|
|
const expectedAgentEnvVars = [
|
|
"GITHUB_TOKEN",
|
|
...new Set(Object.values(providers).flatMap((p) => [...p.envVars])),
|
|
"PULLFROG_MODEL",
|
|
].sort();
|
|
|
|
const expectedAgnosticEnvVars = ["ANTHROPIC_API_KEY", "GITHUB_TOKEN"].sort();
|
|
|
|
describe("ci workflow consistency", () => {
|
|
it("workflow names match", () => {
|
|
expect(rootWorkflow.name).toBe(actionWorkflow.name);
|
|
});
|
|
|
|
it("no duplicate test names across directories", () => {
|
|
const allNames = [...crossagentTests, ...agnosticTests, ...adhocTests];
|
|
const duplicates = allNames.filter((name, idx) => allNames.indexOf(name) !== idx);
|
|
expect(duplicates).toEqual([]);
|
|
});
|
|
|
|
describe("cross-agent tests", () => {
|
|
const rootJob = rootWorkflow.jobs["action-agents"];
|
|
const actionJob = actionWorkflow.jobs.agents;
|
|
|
|
it("root agents matrix is wired to the dynamic matrix output", () => {
|
|
const include = rootJob.strategy?.matrix.include;
|
|
expect(typeof include).toBe("string");
|
|
expect(include as string).toContain("fromJSON(needs.changes.outputs.matrix).agents");
|
|
});
|
|
|
|
it("action agent matrix matches agents map", () => {
|
|
expect((actionJob.strategy?.matrix.agent as string[])?.slice().sort()).toEqual(
|
|
expectedAgents
|
|
);
|
|
});
|
|
|
|
it("action test matrix matches crossagent/ directory", () => {
|
|
expect((actionJob.strategy?.matrix.test as string[])?.slice().sort()).toEqual(
|
|
crossagentTests
|
|
);
|
|
});
|
|
|
|
it("permissions match between root and action", () => {
|
|
expect(rootJob.permissions).toEqual(actionJob.permissions);
|
|
});
|
|
|
|
it("timeout-minutes match between root and action", () => {
|
|
expect(rootJob["timeout-minutes"]).toEqual(actionJob["timeout-minutes"]);
|
|
});
|
|
|
|
it("env vars match between root and action", () => {
|
|
expect(getEnvVarNames(rootJob)).toEqual(getEnvVarNames(actionJob));
|
|
});
|
|
|
|
it("env vars cover all provider API keys", () => {
|
|
expect(getEnvVarNames(rootJob)).toEqual(expectedAgentEnvVars);
|
|
});
|
|
|
|
it("fail-fast is enabled in both", () => {
|
|
expect(rootJob.strategy?.["fail-fast"]).toBe(true);
|
|
expect(actionJob.strategy?.["fail-fast"]).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("agnostic tests", () => {
|
|
const rootJob = rootWorkflow.jobs["action-agnostic"];
|
|
const actionJob = actionWorkflow.jobs.agnostic;
|
|
|
|
it("root agnostic matrix is wired to the dynamic matrix output", () => {
|
|
const include = rootJob.strategy?.matrix.include;
|
|
expect(typeof include).toBe("string");
|
|
expect(include as string).toContain("fromJSON(needs.changes.outputs.matrix).agnostic");
|
|
});
|
|
|
|
it("action test matrix matches agnostic/ directory", () => {
|
|
expect((actionJob.strategy?.matrix.test as string[])?.slice().sort()).toEqual(agnosticTests);
|
|
});
|
|
|
|
it("permissions match between root and action", () => {
|
|
expect(rootJob.permissions).toEqual(actionJob.permissions);
|
|
});
|
|
|
|
it("timeout-minutes match between root and action", () => {
|
|
expect(rootJob["timeout-minutes"]).toEqual(actionJob["timeout-minutes"]);
|
|
});
|
|
|
|
it("env vars match between root and action", () => {
|
|
expect(getEnvVarNames(rootJob)).toEqual(getEnvVarNames(actionJob));
|
|
});
|
|
|
|
it("env vars are correct for agnostic tests", () => {
|
|
expect(getEnvVarNames(rootJob)).toEqual(expectedAgnosticEnvVars);
|
|
});
|
|
|
|
it("fail-fast is enabled in both", () => {
|
|
expect(rootJob.strategy?.["fail-fast"]).toBe(true);
|
|
expect(actionJob.strategy?.["fail-fast"]).toBe(true);
|
|
});
|
|
});
|
|
});
|