Skip to content

Commit f218c21

Browse files
abeltranoCopilot
andcommitted
test(cli): add Jest test suite for PromptKit CLI
Add comprehensive test coverage for the CLI module: - manifest.js: loadManifest, getTemplates, persona/protocol/format/ taxonomy lookups, resolveTemplateDeps (16 tests) - assemble.js: stripFrontmatter, loadComponent, full assembly with section ordering, param substitution, verbatim inclusion (15 tests) - launch.js: detectCli priority order with mocked child_process, copyContentToTemp file/directory operations (9 tests) - cli.js: integration tests for list, assemble, --version, error handling via subprocess execution (7 tests) Tests validate against real repo content (personas/, protocols/, formats/, templates/, manifest.yaml) rather than synthetic fixtures, so they catch structural regressions without maintenance burden. Infrastructure: - Jest dev dependency with test and test:coverage scripts - CI workflow (.github/workflows/cli-tests.yml) triggers on cli/ changes - coverage/ added to .gitignore Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1ec0475 commit f218c21

8 files changed

Lines changed: 4992 additions & 19 deletions

File tree

.github/workflows/cli-tests.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# SPDX-License-Identifier: MIT
2+
# Copyright (c) PromptKit Contributors
3+
4+
name: CLI Tests
5+
6+
on:
7+
push:
8+
paths:
9+
- 'cli/**'
10+
pull_request:
11+
paths:
12+
- 'cli/**'
13+
14+
jobs:
15+
test:
16+
name: Run CLI tests
17+
runs-on: ubuntu-latest
18+
steps:
19+
- uses: actions/checkout@v4
20+
21+
- name: Set up Node.js
22+
uses: actions/setup-node@v4
23+
with:
24+
node-version: '22'
25+
26+
- name: Install dependencies
27+
working-directory: cli
28+
run: npm ci
29+
30+
- name: Run tests
31+
working-directory: cli
32+
run: npm test

cli/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
node_modules/
22
content/
3+
coverage/

cli/__tests__/assemble.test.js

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
// SPDX-License-Identifier: MIT
2+
3+
const path = require("path");
4+
const {
5+
assemble,
6+
loadComponent,
7+
stripFrontmatter,
8+
} = require("../lib/assemble");
9+
const { loadManifest, getTemplates } = require("../lib/manifest");
10+
11+
// Use the real repo content — no synthetic fixtures
12+
const repoRoot = path.resolve(__dirname, "..", "..");
13+
14+
describe("assemble", () => {
15+
describe("stripFrontmatter", () => {
16+
// Pure function tests use inline strings — no files needed
17+
test("removes YAML frontmatter delimited by ---", () => {
18+
const input = "---\nname: test\ntype: foo\n---\n# Body\n\nContent here.";
19+
expect(stripFrontmatter(input)).toBe("# Body\n\nContent here.");
20+
});
21+
22+
test("returns content unchanged when no frontmatter present", () => {
23+
const input = "# Just a heading\n\nSome content.";
24+
expect(stripFrontmatter(input)).toBe(input);
25+
});
26+
27+
test("handles frontmatter with Windows-style line endings", () => {
28+
const input = "---\r\nname: test\r\n---\r\n# Body";
29+
expect(stripFrontmatter(input)).toBe("# Body");
30+
});
31+
32+
test("trims leading/trailing whitespace after stripping", () => {
33+
const input = "---\nname: test\n---\n\n # Body \n\n";
34+
expect(stripFrontmatter(input)).toBe("# Body");
35+
});
36+
});
37+
38+
describe("loadComponent", () => {
39+
test("loads a persona and strips HTML comments and frontmatter", () => {
40+
const body = loadComponent(repoRoot, "personas/systems-engineer.md");
41+
expect(body).not.toContain("SPDX-License-Identifier");
42+
expect(body).toContain("# Persona: Senior Systems Engineer");
43+
});
44+
45+
test("loads a protocol and strips HTML comments and frontmatter", () => {
46+
const body = loadComponent(
47+
repoRoot,
48+
"protocols/guardrails/anti-hallucination.md"
49+
);
50+
expect(body).not.toContain("SPDX-License-Identifier");
51+
expect(body).toContain("# Protocol: Anti-Hallucination Guardrails");
52+
});
53+
54+
test("returns null for missing component and warns", () => {
55+
const warnSpy = jest.spyOn(console, "warn").mockImplementation();
56+
const result = loadComponent(repoRoot, "nonexistent.md");
57+
58+
expect(result).toBeNull();
59+
expect(warnSpy).toHaveBeenCalledWith(
60+
expect.stringContaining("nonexistent.md")
61+
);
62+
warnSpy.mockRestore();
63+
});
64+
});
65+
66+
describe("assemble (full)", () => {
67+
let manifest;
68+
69+
beforeAll(() => {
70+
manifest = loadManifest(repoRoot);
71+
});
72+
73+
test("assembles a complete prompt with all sections in order", () => {
74+
const templates = getTemplates(manifest);
75+
const bug = templates.find((t) => t.name === "investigate-bug");
76+
const result = assemble(repoRoot, manifest, bug);
77+
78+
// Check section headers are present in order
79+
const identityPos = result.indexOf("# Identity");
80+
const protocolsPos = result.indexOf("# Reasoning Protocols");
81+
const formatPos = result.indexOf("# Output Format");
82+
const taskPos = result.indexOf("# Task");
83+
84+
expect(identityPos).toBeGreaterThanOrEqual(0);
85+
expect(protocolsPos).toBeGreaterThan(identityPos);
86+
expect(formatPos).toBeGreaterThan(protocolsPos);
87+
expect(taskPos).toBeGreaterThan(formatPos);
88+
});
89+
90+
test("includes real persona body text verbatim", () => {
91+
const templates = getTemplates(manifest);
92+
const bug = templates.find((t) => t.name === "investigate-bug");
93+
const result = assemble(repoRoot, manifest, bug);
94+
95+
expect(result).toContain("# Persona: Senior Systems Engineer");
96+
});
97+
98+
test("includes all protocol bodies", () => {
99+
const templates = getTemplates(manifest);
100+
const bug = templates.find((t) => t.name === "investigate-bug");
101+
const result = assemble(repoRoot, manifest, bug);
102+
103+
expect(result).toContain("# Protocol: Anti-Hallucination Guardrails");
104+
expect(result).toContain("# Protocol: Root Cause Analysis");
105+
});
106+
107+
test("includes taxonomy section when template references one", () => {
108+
const templates = getTemplates(manifest);
109+
const review = templates.find((t) => t.name === "review-code");
110+
const result = assemble(repoRoot, manifest, review);
111+
112+
expect(result).toContain("# Classification Taxonomy");
113+
});
114+
115+
test("includes format body text", () => {
116+
const templates = getTemplates(manifest);
117+
const bug = templates.find((t) => t.name === "investigate-bug");
118+
const result = assemble(repoRoot, manifest, bug);
119+
120+
expect(result).toContain("# Format: Investigation Report");
121+
});
122+
123+
test("substitutes parameters when provided", () => {
124+
const templates = getTemplates(manifest);
125+
const bug = templates.find((t) => t.name === "investigate-bug");
126+
const result = assemble(repoRoot, manifest, bug, {
127+
problem_description: "Use-after-free in socket handler",
128+
code_context: "See socket.c line 42",
129+
environment: "Linux x86_64",
130+
});
131+
132+
expect(result).toContain("Use-after-free in socket handler");
133+
expect(result).toContain("See socket.c line 42");
134+
expect(result).not.toContain("{{problem_description}}");
135+
});
136+
137+
test("leaves unfilled params as placeholders", () => {
138+
const templates = getTemplates(manifest);
139+
const bug = templates.find((t) => t.name === "investigate-bug");
140+
const result = assemble(repoRoot, manifest, bug, {
141+
problem_description: "memory leak",
142+
});
143+
144+
expect(result).toContain("memory leak");
145+
expect(result).toContain("{{code_context}}");
146+
});
147+
148+
test("does not contain YAML frontmatter or SPDX headers", () => {
149+
const templates = getTemplates(manifest);
150+
const bug = templates.find((t) => t.name === "investigate-bug");
151+
const result = assemble(repoRoot, manifest, bug);
152+
153+
expect(result).not.toContain("SPDX-License-Identifier");
154+
// YAML frontmatter (---\nkey: val) should not appear; section dividers (---) are fine
155+
expect(result).not.toMatch(/^---\r?\n\w+:/m);
156+
});
157+
158+
test("separates sections with --- dividers", () => {
159+
const templates = getTemplates(manifest);
160+
const bug = templates.find((t) => t.name === "investigate-bug");
161+
const result = assemble(repoRoot, manifest, bug);
162+
163+
const sections = result.split("\n\n---\n\n");
164+
expect(sections.length).toBeGreaterThanOrEqual(4);
165+
});
166+
});
167+
});

cli/__tests__/cli.test.js

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// SPDX-License-Identifier: MIT
2+
3+
const path = require("path");
4+
const { execFileSync } = require("child_process");
5+
6+
const cliPath = path.resolve(__dirname, "..", "bin", "cli.js");
7+
8+
// Integration tests run the actual CLI as a subprocess.
9+
// The CLI reads content copied from the repo root via the prepare script.
10+
11+
describe("cli integration", () => {
12+
13+
describe("list command", () => {
14+
test("lists templates as JSON", () => {
15+
const result = runCli(["list", "--json"]);
16+
const templates = JSON.parse(result);
17+
18+
expect(Array.isArray(templates)).toBe(true);
19+
expect(templates.length).toBeGreaterThan(0);
20+
expect(templates[0]).toHaveProperty("name");
21+
expect(templates[0]).toHaveProperty("category");
22+
});
23+
24+
test("lists templates in human-readable format", () => {
25+
const result = runCli(["list"]);
26+
27+
expect(result).toContain("Available PromptKit templates:");
28+
expect(result).toContain("investigate-bug");
29+
});
30+
});
31+
32+
describe("assemble command", () => {
33+
const outputPath = path.join(
34+
require("os").tmpdir(),
35+
`promptkit-test-${Date.now()}.md`
36+
);
37+
38+
afterAll(() => {
39+
try {
40+
require("fs").unlinkSync(outputPath);
41+
} catch {
42+
// ignore cleanup errors
43+
}
44+
});
45+
46+
test("assembles a template to a file", () => {
47+
runCli(["assemble", "investigate-bug", "--output", outputPath]);
48+
49+
const fs = require("fs");
50+
expect(fs.existsSync(outputPath)).toBe(true);
51+
52+
const content = fs.readFileSync(outputPath, "utf8");
53+
expect(content).toContain("# Identity");
54+
expect(content).toContain("# Task");
55+
});
56+
57+
test("substitutes params via --param", () => {
58+
runCli([
59+
"assemble",
60+
"investigate-bug",
61+
"--output",
62+
outputPath,
63+
"--param",
64+
"problem_description=test bug",
65+
"--param",
66+
"code_context=test context",
67+
"--param",
68+
"environment=test env",
69+
]);
70+
71+
const content = require("fs").readFileSync(outputPath, "utf8");
72+
expect(content).toContain("test bug");
73+
expect(content).toContain("test context");
74+
expect(content).not.toContain("{{problem_description}}");
75+
});
76+
77+
test("reports unfilled parameters", () => {
78+
const result = runCli(["assemble", "investigate-bug", "--output", outputPath]);
79+
80+
expect(result).toContain("unfilled parameter");
81+
});
82+
83+
test("exits with error for unknown template", () => {
84+
expect(() => {
85+
runCli(["assemble", "nonexistent-template"]);
86+
}).toThrow();
87+
});
88+
});
89+
90+
describe("--version", () => {
91+
test("prints the version number", () => {
92+
const result = runCli(["--version"]);
93+
expect(result.trim()).toMatch(/^\d+\.\d+\.\d+/);
94+
});
95+
});
96+
});
97+
98+
/**
99+
* Runs the CLI with the given arguments. Ensures the content/ directory
100+
* is populated from the repo root via the prepare script before first use.
101+
*/
102+
function runCli(args) {
103+
// The CLI reads content from ../content relative to bin/cli.js.
104+
// We need the prepare script to have run, or we run it first.
105+
const contentDir = path.resolve(__dirname, "..", "content");
106+
const fs = require("fs");
107+
108+
if (!fs.existsSync(path.join(contentDir, "manifest.yaml"))) {
109+
// Run prepare to copy content
110+
execFileSync(process.execPath, [
111+
path.resolve(__dirname, "..", "scripts", "copy-content.js"),
112+
], { cwd: path.resolve(__dirname, "..") });
113+
}
114+
115+
const result = execFileSync(
116+
process.execPath,
117+
[cliPath, ...args],
118+
{
119+
encoding: "utf8",
120+
cwd: path.resolve(__dirname, ".."),
121+
timeout: 10000,
122+
}
123+
);
124+
return result;
125+
}

0 commit comments

Comments
 (0)