Files
shockbot/test/ci.test.ts
T
Colin McDonnell 1f4c3031be ci: filter test matrices by per-test coverage globs (#730)
* 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".
2026-05-14 03:55:33 +00:00

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);
});
});
});