Skip to content

Commit fe3aea9

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 (19 tests) - assemble.js: stripFrontmatter, loadComponent, full assembly with section ordering, param substitution, verbatim inclusion (18 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) Infrastructure: - Jest dev dependency with test and test:coverage scripts - Test fixtures (manifest, persona, protocols, format, taxonomy, templates) in __tests__/fixtures/ - 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 fe3aea9

16 files changed

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

cli/__tests__/cli.test.js

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

0 commit comments

Comments
 (0)