248 lines
8.0 KiB
TypeScript
248 lines
8.0 KiB
TypeScript
import { readdir, readFile, rm, stat } from "node:fs/promises";
|
|
import process from "node:process";
|
|
import { assert, expect, test } from "vite-plus/test";
|
|
import { parse } from "yaml";
|
|
|
|
import { inheritedEnvironment, makeTempDir, runCommand } from "#testing";
|
|
|
|
// A parsed YAML or JSON mapping, keyed by its dynamic field names.
|
|
type Mapping = Map<string, unknown>;
|
|
|
|
const mapping = (value: unknown, at: string): Mapping => {
|
|
assert(
|
|
typeof value === "object" && value !== null && !Array.isArray(value),
|
|
at,
|
|
);
|
|
return new Map(Object.entries(value));
|
|
};
|
|
|
|
const sequence = (value: unknown, at: string): Mapping[] => {
|
|
assert(Array.isArray(value), at);
|
|
return value.map((entry: unknown, index) =>
|
|
mapping(entry, `${at}[${index}]`),
|
|
);
|
|
};
|
|
|
|
async function yaml(path: string): Promise<Mapping> {
|
|
const value: unknown = parse(await readFile(path, "utf8"));
|
|
return mapping(value, path);
|
|
}
|
|
|
|
async function yamlFiles(): Promise<string[]> {
|
|
const entries = await readdir(".", { recursive: true, withFileTypes: true });
|
|
return entries
|
|
.filter((entry) => entry.isFile() && /\.ya?ml$/u.test(entry.name))
|
|
.map((entry) => `${entry.parentPath}/${entry.name}`.replace(/^\.\//u, ""))
|
|
.filter((path) => !/^(?:\.git|\.superpowers|node_modules)\//u.test(path));
|
|
}
|
|
|
|
const findStep = (steps: Mapping[], key: string, value: string): Mapping => {
|
|
const step = steps.find((entry) => entry.get(key) === value);
|
|
assert(step !== undefined, `${key}: ${value}`);
|
|
return step;
|
|
};
|
|
|
|
test("all committed YAML parses", async () => {
|
|
const files = await yamlFiles();
|
|
assert(files.length > 0);
|
|
await Promise.all(
|
|
files.map(async (file) => {
|
|
await yaml(file);
|
|
}),
|
|
);
|
|
});
|
|
|
|
test("the action exposes only the master key and scopes it to preparation", async () => {
|
|
const action = await yaml("action.yml");
|
|
expect([...mapping(action.get("inputs"), "inputs").keys()]).toStrictEqual([
|
|
"master-key",
|
|
]);
|
|
const steps = sequence(
|
|
mapping(action.get("runs"), "runs").get("steps"),
|
|
"runs.steps",
|
|
);
|
|
for (const step of steps) {
|
|
const run = step.get("run");
|
|
if (typeof run === "string") {
|
|
expect(run.includes("\n"), String(step.get("name"))).toBe(false);
|
|
}
|
|
}
|
|
const checkout = findStep(steps, "uses", "actions/checkout@v4");
|
|
const prepare = findStep(steps, "id", "prepare");
|
|
const agent = findStep(steps, "id", "agent");
|
|
const post = findStep(steps, "id", "post");
|
|
findStep(steps, "id", "source");
|
|
expect(mapping(checkout.get("with"), "checkout.with").get("token")).toBe(
|
|
"${{ gitea.token }}",
|
|
);
|
|
expect(
|
|
mapping(prepare.get("env"), "prepare.env").get("BOT_AGENTS_MASTER_KEY"),
|
|
).toBe("${{ inputs.master-key }}");
|
|
expect(
|
|
JSON.stringify(Object.fromEntries(agent)).includes("BOT_AGENTS_MASTER_KEY"),
|
|
).toBe(false);
|
|
expect(
|
|
JSON.stringify(Object.fromEntries(post)).includes("BOT_AGENTS_MASTER_KEY"),
|
|
).toBe(false);
|
|
expect(mapping(agent.get("env"), "agent.env").get("GITEA_TOKEN")).toBe(
|
|
"${{ gitea.token }}",
|
|
);
|
|
assert(String(post.get("if")).includes("always()"));
|
|
for (const step of [prepare, agent, post]) {
|
|
assert(String(step.get("run")).includes("steps.source.outputs.path"));
|
|
}
|
|
});
|
|
|
|
test("the source protection script stages immutable action files", async () => {
|
|
const temp = await makeTempDir();
|
|
const outputFile = `${temp}/output`;
|
|
try {
|
|
const environment = inheritedEnvironment();
|
|
environment.set("ACTION_PATH", process.cwd());
|
|
environment.set("GITHUB_OUTPUT", outputFile);
|
|
environment.set("RUNNER_TEMP", temp);
|
|
const result = await runCommand(
|
|
"/bin/bash",
|
|
["scripts/protect-source.sh"],
|
|
environment,
|
|
);
|
|
assert(result.success, result.stderr);
|
|
|
|
const output = await readFile(outputFile, "utf8");
|
|
const path = output.trim().slice(5);
|
|
const files = [
|
|
"src/prepare/index.ts",
|
|
"src/run/index.ts",
|
|
"src/post/index.ts",
|
|
"src/runtime/index.ts",
|
|
"src/agents/auth.ts",
|
|
"package.json",
|
|
"profiles.json",
|
|
"prompt.md",
|
|
];
|
|
const stats = await Promise.all(
|
|
files.map(async (file) => {
|
|
const info = await stat(`${path}/${file}`);
|
|
return info;
|
|
}),
|
|
);
|
|
for (const [index, info] of stats.entries()) {
|
|
assert(info.isFile(), files[index]);
|
|
expect(info.mode & 0o022, files[index]).toStrictEqual(0);
|
|
}
|
|
} finally {
|
|
await rm(temp, { recursive: true });
|
|
}
|
|
});
|
|
|
|
test("module-relative runtime assets exist", async () => {
|
|
const module = new URL("../src/run/index.ts", import.meta.url);
|
|
const source = await readFile(module, "utf8");
|
|
const assets = [
|
|
...source.matchAll(/new URL\("(?<asset>[^"]+)", import\.meta\.url\)/gu),
|
|
].map(([, asset = ""]) => new URL(asset, module));
|
|
const stats = await Promise.all(
|
|
assets.map(async (asset) => {
|
|
const info = await stat(asset);
|
|
return info;
|
|
}),
|
|
);
|
|
for (const [index, info] of stats.entries()) {
|
|
assert(info.isFile(), assets[index]?.pathname);
|
|
}
|
|
});
|
|
|
|
test("the repository uses its trusted main action", async () => {
|
|
const workflow = await yaml(".gitea/workflows/agents.yml");
|
|
const job = mapping(
|
|
mapping(workflow.get("jobs"), "jobs").get("agent"),
|
|
"jobs.agent",
|
|
);
|
|
const permissions = mapping(job.get("permissions"), "jobs.agent.permissions");
|
|
expect(Object.fromEntries(permissions)).toStrictEqual({
|
|
code: "write",
|
|
issues: "write",
|
|
"pull-requests": "write",
|
|
});
|
|
const steps = sequence(job.get("steps"), "jobs.agent.steps");
|
|
expect(steps.findIndex((step) => step.get("uses") === "./")).toStrictEqual(
|
|
-1,
|
|
);
|
|
const trusted = findStep(
|
|
steps,
|
|
"uses",
|
|
"https://git.capsulizers.com/commons/bot-agents@main",
|
|
);
|
|
expect(mapping(trusted.get("with"), "trusted.with").get("master-key")).toBe(
|
|
"${{ secrets.BOT_AGENTS_MASTER_KEY }}",
|
|
);
|
|
expect(
|
|
JSON.stringify(Object.fromEntries(workflow)).includes(
|
|
"gitea.actor != 'bot'",
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
test("CI checks every phase and the root isolation suite", async () => {
|
|
const workflow = await yaml(".gitea/workflows/check.yml");
|
|
const job = mapping(
|
|
mapping(workflow.get("jobs"), "jobs").get("check"),
|
|
"jobs.check",
|
|
);
|
|
const commands = sequence(job.get("steps"), "jobs.check.steps")
|
|
.map((step) => {
|
|
const run = step.get("run");
|
|
return typeof run === "string" ? run : "";
|
|
})
|
|
.join("\n");
|
|
// `vp check` type-checks every phase, and `vp test` runs each of them under
|
|
// Node as well as the root-only isolation tests.
|
|
assert(commands.includes("npm ci"));
|
|
assert(commands.includes("vp check"));
|
|
assert(commands.includes("vp test"));
|
|
});
|
|
|
|
test("uses the standard Skills installer without custom discovery", async () => {
|
|
const runner = await readFile("src/run/index.ts", "utf8");
|
|
assert(runner.includes("skills add obra/superpowers"));
|
|
const removed = await stat("superpowers.ts").then(
|
|
() => false,
|
|
(error: unknown) =>
|
|
error instanceof Error && "code" in error && error.code === "ENOENT",
|
|
);
|
|
assert(removed, "superpowers.ts should be removed");
|
|
});
|
|
|
|
test("installs the OpenCode CLI", async () => {
|
|
const action = await readFile("action.yml", "utf8");
|
|
assert(action.includes("opencode-ai"));
|
|
});
|
|
|
|
test("does not install Deno", async () => {
|
|
const action = await readFile("action.yml", "utf8");
|
|
expect(action).not.toMatch(/\bdeno\b/iu);
|
|
});
|
|
|
|
test("profile preparation does not receive provider credentials", async () => {
|
|
const runner = await readFile("src/run/index.ts", "utf8");
|
|
assert(runner.includes("await prepareProfile(profile);"));
|
|
expect(runner.includes("prepareProfile(profile, token)")).toBe(false);
|
|
});
|
|
|
|
test("central profiles fall back from Claude to Codex to OpenCode", async () => {
|
|
const credentials: unknown = JSON.parse(
|
|
await readFile("profiles.json", "utf8"),
|
|
);
|
|
const profiles = sequence(
|
|
mapping(credentials, "profiles.json").get("profiles"),
|
|
"profiles",
|
|
);
|
|
expect(profiles.map((profile) => profile.get("type"))).toStrictEqual([
|
|
"CLAUDE",
|
|
"CODEX",
|
|
"OPENCODE",
|
|
]);
|
|
expect(profiles[2]?.get("model")).toBe("openrouter/qwen/qwen3.8-27b");
|
|
assert(String(profiles[2]?.get("token")).startsWith("v1:"));
|
|
});
|