Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: Test

on:
push:
branches: [main]
pull_request:

jobs:
test:
name: bun test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Run tests
run: bun test
13 changes: 9 additions & 4 deletions tests/agent-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,16 @@ import { join } from "node:path";
* specs consumed by OpenCode at runtime, not executable code, so this suite
* validates structure (required frontmatter keys, JSON-well-formedness of
* inline tools/permission blocks) rather than routing *behavior*.
*
* `tools` is intentionally NOT in REQUIRED_KEYS: several agents (security,
* writer, pc-doctor, npm-helper, build-helper, deploy-helper) omit it
* entirely and rely on the default toolset, only declaring `tools` when
* overriding it (e.g. orchestrator, profiler, explorer, test-engineer).
*/

const AGENTS_DIR = join(import.meta.dir, "..", "agents");
const REQUIRED_KEYS = ["description", "mode", "model", "tools", "permission"] as const;
const REQUIRED_KEYS = ["description", "mode", "model", "permission"] as const;
const OPTIONAL_JSON_KEYS = ["tools", "permission"] as const;

function extractFrontmatter(raw: string): string {
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
Expand All @@ -28,7 +34,7 @@ function hasTopLevelKey(frontmatter: string, key: string): boolean {

/** Returns the inline JSON string for `key:` when the value is single-line JSON
* (e.g. `tools: {"webfetch":true}`), or null when the value is a multi-line
* YAML block (e.g. `tools:\n read: true`) or absent. */
* YAML block (e.g. `tools:\n read: true`) or absent entirely. */
function inlineJsonValue(frontmatter: string, key: string): string | null {
const match = frontmatter.match(new RegExp(`^${key}:\\s*(\\{.*\\})\\s*$`, "m"));
return match ? match[1] : null;
Expand Down Expand Up @@ -59,7 +65,7 @@ describe("agent frontmatter schema", () => {

test("tools/permission inline JSON (if single-line) parses as valid JSON", () => {
const fm = extractFrontmatter(raw);
for (const key of ["tools", "permission"]) {
for (const key of OPTIONAL_JSON_KEYS) {
const inline = inlineJsonValue(fm, key);
if (inline !== null) {
expect(() => JSON.parse(inline)).not.toThrow();
Expand All @@ -83,7 +89,6 @@ describe("agent frontmatter schema — negative case (proves the check has teeth
].join("\n");

const fm = extractFrontmatter(badFixture);
expect(hasTopLevelKey(fm, "tools")).toBe(false);
expect(hasTopLevelKey(fm, "permission")).toBe(false);
});

Expand Down
102 changes: 102 additions & 0 deletions tests/prepare-pages.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { afterEach, describe, expect, test } from "bun:test";
import { spawnSync } from "node:child_process";
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

/**
* Integration tests for scripts/prepare-pages.mjs.
*
* Per its own header comment, this script:
* 1. Renames `site/index.docs.html` (Vite's build output for `index.docs.html`,
* see vite.docs.config.ts's `outDir: "site"` / `rollupOptions.input`) to
* `site/index.html` so GitHub Pages serves it as the SPA entry point.
* 2. Copies that same file to `site/404.html` so client-side routes resolve
* through GitHub Pages' SPA fallback.
* 3. Exits non-zero with a clear error if `site/index.docs.html` is missing
* (i.e. the Vite build hasn't run yet).
*
* Rather than re-deriving these internals, this suite copies the real script
* into an isolated temp "repo" sandbox and runs it as a subprocess against a
* fabricated site/ directory, asserting on the resulting filesystem state and
* process output — a black-box contract test, not a reimplementation.
*/

const REAL_SCRIPT = join(import.meta.dir, "..", "scripts", "prepare-pages.mjs");
const sandboxes: string[] = [];

function makeSandbox(): string {
const dir = mkdtempSync(join(tmpdir(), "prepare-pages-"));
sandboxes.push(dir);
mkdirSync(join(dir, "scripts"), { recursive: true });
cpSync(REAL_SCRIPT, join(dir, "scripts", "prepare-pages.mjs"));
return dir;
}

function runScript(sandbox: string) {
return spawnSync("node", [join(sandbox, "scripts", "prepare-pages.mjs")], {
cwd: sandbox,
encoding: "utf-8",
});
}

afterEach(() => {
while (sandboxes.length) {
rmSync(sandboxes.pop()!, { recursive: true, force: true });
}
});

describe("scripts/prepare-pages.mjs — happy path", () => {
test("renames site/index.docs.html to site/index.html and copies it to site/404.html", () => {
const sandbox = makeSandbox();
mkdirSync(join(sandbox, "site"), { recursive: true });
writeFileSync(join(sandbox, "site", "index.docs.html"), "<html>built docs</html>");

const result = runScript(sandbox);

expect(result.status).toBe(0);
expect(existsSync(join(sandbox, "site", "index.docs.html"))).toBe(false);
expect(existsSync(join(sandbox, "site", "index.html"))).toBe(true);
expect(existsSync(join(sandbox, "site", "404.html"))).toBe(true);

const indexContent = readFileSync(join(sandbox, "site", "index.html"), "utf-8");
const notFoundContent = readFileSync(join(sandbox, "site", "404.html"), "utf-8");
expect(indexContent).toBe("<html>built docs</html>");
expect(notFoundContent).toBe(indexContent);
});

test("prints the written file paths on success", () => {
const sandbox = makeSandbox();
mkdirSync(join(sandbox, "site"), { recursive: true });
writeFileSync(join(sandbox, "site", "index.docs.html"), "<html>ok</html>");

const result = runScript(sandbox);

expect(result.stdout).toContain("prepare-pages:");
expect(result.stdout).toContain("index.html");
expect(result.stdout).toContain("404.html");
});
});

describe("scripts/prepare-pages.mjs — error path", () => {
test("exits non-zero with a clear error when site/index.docs.html is missing", () => {
const sandbox = makeSandbox();
mkdirSync(join(sandbox, "site"), { recursive: true });

const result = runScript(sandbox);

expect(result.status).not.toBe(0);
expect(result.stderr).toContain("expected");
expect(result.stderr.toLowerCase()).toContain("vite build");
expect(existsSync(join(sandbox, "site", "index.html"))).toBe(false);
expect(existsSync(join(sandbox, "site", "404.html"))).toBe(false);
});

test("exits non-zero when the site/ directory itself does not exist", () => {
const sandbox = makeSandbox();

const result = runScript(sandbox);

expect(result.status).not.toBe(0);
});
});
100 changes: 100 additions & 0 deletions tests/routing-consistency.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { describe, expect, test } from "bun:test";
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";

/**
* Routing-consistency tests.
*
* agents/*.md files are prompt specs, not executable code, so there is no
* runtime `route()` function to unit test directly. The closest useful
* equivalent: cross-check the two places routing IDs are declared
* (agents/orchestrator.md's "Agent Routing" table and AGENTS.md's "Runtime
* Subagent Roster" table) against each other and against the actual files
* in agents/. This is exactly the kind of drift AGENTS.md's own naming note
* warns about (retired taxonomy names like `sisyphus`/`metis`/`momus`).
*/

const REPO_ROOT = join(import.meta.dir, "..");
const AGENTS_DIR = join(REPO_ROOT, "agents");

function extractSection(markdown: string, headingRegex: RegExp): string {
const startMatch = markdown.match(headingRegex);
if (!startMatch || startMatch.index === undefined) {
throw new Error(`Section matching ${headingRegex} not found.`);
}
const start = startMatch.index + startMatch[0].length;
const rest = markdown.slice(start);
const nextHeading = rest.search(/\n##\s/);
return nextHeading === -1 ? rest : rest.slice(0, nextHeading);
}

function extractBacktickIds(tableSection: string): Set<string> {
const ids = new Set<string>();
const rowRegex = /^\|\s*`([a-z0-9-]+)`\s*\|/gm;
let match: RegExpExecArray | null;
while ((match = rowRegex.exec(tableSection)) !== null) {
ids.add(match[1]);
}
return ids;
}

const orchestratorMd = readFileSync(join(AGENTS_DIR, "orchestrator.md"), "utf-8");
const agentsMd = readFileSync(join(REPO_ROOT, "AGENTS.md"), "utf-8");

const routingTableIds = extractBacktickIds(extractSection(orchestratorMd, /##\s+Agent Routing\n/));
const rosterTableIds = extractBacktickIds(extractSection(agentsMd, /##\s+Runtime Subagent Roster\n/));

const agentFiles = readdirSync(AGENTS_DIR)
.filter((f) => f.endsWith(".md") && f !== "orchestrator.md")
.map((f) => f.replace(/\.md$/, ""));

describe("routing consistency: agents/orchestrator.md routing table", () => {
test("routing table is not empty", () => {
expect(routingTableIds.size).toBeGreaterThan(0);
});

for (const id of Array.from(routingTableIds)) {
test(`"${id}" has a matching agents/${id}.md file`, () => {
expect(agentFiles).toContain(id);
});
}
});

describe("routing consistency: AGENTS.md runtime subagent roster", () => {
test("roster table is not empty", () => {
expect(rosterTableIds.size).toBeGreaterThan(0);
});

for (const id of Array.from(rosterTableIds)) {
test(`"${id}" has a matching agents/${id}.md file`, () => {
expect(agentFiles).toContain(id);
});
}
});

describe("routing consistency: no orphan agent files", () => {
for (const id of agentFiles) {
test(`agents/${id}.md is referenced in orchestrator.md's routing table`, () => {
expect(routingTableIds.has(id)).toBe(true);
});

test(`agents/${id}.md is referenced in AGENTS.md's roster`, () => {
expect(rosterTableIds.has(id)).toBe(true);
});
}
});

describe("routing consistency: the two tables agree with each other", () => {
test("routing table and roster table list the same set of runtime IDs", () => {
expect(Array.from(routingTableIds).sort()).toEqual(Array.from(rosterTableIds).sort());
});
});

describe("routing consistency — negative case (proves the check has teeth)", () => {
test("retired taxonomy-only names have no corresponding runtime agent file", () => {
const retiredNames = ["sisyphus", "metis", "momus", "explore", "fixer", "hephaestus"];
for (const name of retiredNames) {
expect(agentFiles).not.toContain(name);
}
});
});
Loading