Skip to content

Commit fd63339

Browse files
committed
Add real patchloom CLI integration tests
Add test/unit/patchloomCli.test.ts (13 tests) that invoke the actual patchloom binary against real temp directories: - --version parsing and compatibility assessment - resolvePatchloomStatusWithInputs with real binary - agent-rules markdown output generation - doc set on JSON and YAML (comment preservation) - replace text substitution - tidy final newline enforcement - search with JSON output and match counting - doc get value reading - batch multi-operation via stdin - create with --content - exit code 3 for no-match search Tests skip gracefully if patchloom is not on PATH or at known build locations, so CI without patchloom installed still passes. Test count: 81 -> 94 unit + 1 integration Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
1 parent d44d252 commit fd63339

1 file changed

Lines changed: 258 additions & 0 deletions

File tree

test/unit/patchloomCli.test.ts

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
/**
2+
* Integration tests that invoke the real patchloom binary.
3+
*
4+
* These tests exercise the actual boundary between the extension and the CLI:
5+
* process spawning, argument formatting, output parsing, and exit code handling.
6+
*
7+
* Skipped automatically if patchloom is not available on PATH or at a known
8+
* build location, so CI without patchloom installed still passes.
9+
*/
10+
import assert from "node:assert/strict";
11+
import { execFile } from "node:child_process";
12+
import { constants as fsConstants } from "node:fs";
13+
import * as fs from "node:fs/promises";
14+
import * as os from "node:os";
15+
import * as path from "node:path";
16+
import { promisify } from "node:util";
17+
import test, { describe } from "node:test";
18+
import {
19+
findOnPath,
20+
parsePatchloomVersion,
21+
assessPatchloomCompatibility,
22+
resolvePatchloomStatusWithInputs
23+
} from "../../src/binary/patchloom";
24+
25+
const execFileAsync = promisify(execFile);
26+
27+
const KNOWN_BUILD_PATHS = [
28+
path.join(os.homedir(), "patchloom", "target", "release", "patchloom"),
29+
path.join(os.homedir(), "patchloom", "target", "debug", "patchloom")
30+
];
31+
32+
async function findPatchloom(): Promise<string | undefined> {
33+
// Try PATH first
34+
const onPath = await findOnPath(process.env.PATH, process.platform);
35+
if (onPath) {
36+
return onPath;
37+
}
38+
39+
// Fall back to known build locations
40+
for (const p of KNOWN_BUILD_PATHS) {
41+
try {
42+
await fs.access(p, fsConstants.X_OK);
43+
return p;
44+
} catch {
45+
// not found, try next
46+
}
47+
}
48+
49+
return undefined;
50+
}
51+
52+
async function withTempDir(fn: (dir: string) => Promise<void>): Promise<void> {
53+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "patchloom-cli-test-"));
54+
try {
55+
await fn(dir);
56+
} finally {
57+
await fs.rm(dir, { recursive: true, force: true });
58+
}
59+
}
60+
61+
describe("patchloom CLI integration", async () => {
62+
const binaryPath = await findPatchloom();
63+
if (!binaryPath) {
64+
test("skipped: patchloom binary not found", { skip: "patchloom not on PATH or at known build path" }, () => {});
65+
return;
66+
}
67+
68+
test("--version returns parseable version string", async () => {
69+
const { stdout, stderr } = await execFileAsync(binaryPath, ["--version"], { timeout: 5000 });
70+
const output = `${stdout}${stderr}`.trim();
71+
assert.ok(output.length > 0, "version output should not be empty");
72+
73+
const version = parsePatchloomVersion(output);
74+
assert.ok(version, `should parse a version from: ${output}`);
75+
assert.match(version, /^\d+\.\d+\.\d+/, "version should be semver");
76+
});
77+
78+
test("--version output passes compatibility assessment", async () => {
79+
const { stdout, stderr } = await execFileAsync(binaryPath, ["--version"], { timeout: 5000 });
80+
const output = `${stdout}${stderr}`.trim();
81+
const assessment = assessPatchloomCompatibility(output);
82+
assert.equal(assessment.compatibility, "supported",
83+
`installed patchloom should be compatible: ${assessment.message}`);
84+
});
85+
86+
test("resolvePatchloomStatusWithInputs discovers the real binary", async () => {
87+
const status = await resolvePatchloomStatusWithInputs({
88+
configuredPath: binaryPath
89+
});
90+
91+
assert.equal(status.ready, true);
92+
assert.equal(status.source, "setting");
93+
assert.ok(status.version, "should have a version string");
94+
assert.ok(status.binaryPath, "should have a binary path");
95+
});
96+
97+
test("agent-rules produces markdown output", async () => {
98+
await withTempDir(async (dir) => {
99+
const { stdout } = await execFileAsync(binaryPath, ["agent-rules"], {
100+
cwd: dir,
101+
timeout: 10000
102+
});
103+
104+
assert.ok(stdout.length > 100, "agent-rules output should be substantial");
105+
assert.match(stdout, /patchloom/i, "output should mention patchloom");
106+
assert.match(stdout, /^#/m, "output should contain markdown headings");
107+
});
108+
});
109+
110+
test("doc set modifies a JSON file by selector", async () => {
111+
await withTempDir(async (dir) => {
112+
const jsonFile = path.join(dir, "config.json");
113+
await fs.writeFile(jsonFile, JSON.stringify({ server: { port: 3000 } }), "utf8");
114+
115+
await execFileAsync(binaryPath, [
116+
"doc", "set", jsonFile, "server.port", "8080", "--apply"
117+
], { cwd: dir, timeout: 5000 });
118+
119+
const result = JSON.parse(await fs.readFile(jsonFile, "utf8")) as Record<string, Record<string, unknown>>;
120+
assert.equal(result.server.port, 8080, "port should be updated to 8080");
121+
});
122+
});
123+
124+
test("doc set preserves YAML comments", async () => {
125+
await withTempDir(async (dir) => {
126+
const yamlFile = path.join(dir, "config.yaml");
127+
await fs.writeFile(yamlFile, [
128+
"# Server configuration",
129+
"server:",
130+
" host: localhost # local only",
131+
" port: 3000",
132+
""
133+
].join("\n"), "utf8");
134+
135+
await execFileAsync(binaryPath, [
136+
"doc", "set", yamlFile, "server.port", "9090", "--apply"
137+
], { cwd: dir, timeout: 5000 });
138+
139+
const result = await fs.readFile(yamlFile, "utf8");
140+
assert.match(result, /# Server configuration/, "top comment should be preserved");
141+
assert.match(result, /# local only/, "inline comment should be preserved");
142+
assert.match(result, /9090/, "port should be updated");
143+
});
144+
});
145+
146+
test("replace performs text substitution in a file", async () => {
147+
await withTempDir(async (dir) => {
148+
const txtFile = path.join(dir, "readme.txt");
149+
await fs.writeFile(txtFile, "Hello old_name, welcome to old_name project.\n", "utf8");
150+
151+
await execFileAsync(binaryPath, [
152+
"replace", "old_name", "--to", "new_name", txtFile, "--apply"
153+
], { cwd: dir, timeout: 5000 });
154+
155+
const result = await fs.readFile(txtFile, "utf8");
156+
assert.ok(!result.includes("old_name"), "old_name should be replaced");
157+
assert.ok(result.includes("new_name"), "new_name should be present");
158+
});
159+
});
160+
161+
test("tidy fix ensures final newline", async () => {
162+
await withTempDir(async (dir) => {
163+
const txtFile = path.join(dir, "no-newline.txt");
164+
await fs.writeFile(txtFile, "no trailing newline", "utf8");
165+
166+
await execFileAsync(binaryPath, [
167+
"tidy", "fix", txtFile, "--ensure-final-newline", "--apply"
168+
], { cwd: dir, timeout: 5000 });
169+
170+
const result = await fs.readFile(txtFile, "utf8");
171+
assert.ok(result.endsWith("\n"), "file should end with newline after tidy");
172+
});
173+
});
174+
175+
test("search finds text across files", async () => {
176+
await withTempDir(async (dir) => {
177+
await fs.writeFile(path.join(dir, "a.txt"), "hello world\n", "utf8");
178+
await fs.writeFile(path.join(dir, "b.txt"), "goodbye world\n", "utf8");
179+
180+
const { stdout } = await execFileAsync(binaryPath, [
181+
"search", "world", dir, "--json"
182+
], { timeout: 5000 });
183+
184+
const result = JSON.parse(stdout) as { match_count: number };
185+
assert.equal(result.match_count, 2, "should find 'world' in both files");
186+
});
187+
});
188+
189+
test("doc get reads a value by selector", async () => {
190+
await withTempDir(async (dir) => {
191+
const jsonFile = path.join(dir, "data.json");
192+
await fs.writeFile(jsonFile, JSON.stringify({ version: "1.2.3" }), "utf8");
193+
194+
const { stdout } = await execFileAsync(binaryPath, [
195+
"doc", "get", jsonFile, "version"
196+
], { timeout: 5000 });
197+
198+
assert.match(stdout.trim(), /1\.2\.3/, "should output the version value");
199+
});
200+
});
201+
202+
test("batch applies multiple operations via stdin", async () => {
203+
await withTempDir(async (dir) => {
204+
const jsonFile = path.join(dir, "package.json");
205+
const txtFile = path.join(dir, "VERSION");
206+
await fs.writeFile(jsonFile, JSON.stringify({ version: "1.0.0", name: "test" }), "utf8");
207+
await fs.writeFile(txtFile, "1.0.0\n", "utf8");
208+
209+
const batchInput = [
210+
`doc.set ${jsonFile} version "2.0.0"`,
211+
`replace ${txtFile} "1.0.0" "2.0.0"`
212+
].join("\n");
213+
214+
const child = execFile(binaryPath, ["batch", "--apply"], { cwd: dir, timeout: 5000 });
215+
child.stdin!.write(batchInput);
216+
child.stdin!.end();
217+
await new Promise<void>((resolve, reject) => {
218+
child.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`batch exited ${code}`)));
219+
child.on("error", reject);
220+
});
221+
222+
const pkg = JSON.parse(await fs.readFile(jsonFile, "utf8")) as Record<string, unknown>;
223+
assert.equal(pkg.version, "2.0.0", "package.json version should be updated");
224+
225+
const ver = await fs.readFile(txtFile, "utf8");
226+
assert.match(ver, /2\.0\.0/, "VERSION file should be updated");
227+
});
228+
});
229+
230+
test("create makes a new file", async () => {
231+
await withTempDir(async (dir) => {
232+
const newFile = path.join(dir, "created.txt");
233+
234+
await execFileAsync(binaryPath, [
235+
"create", newFile, "--content", "hello from patchloom", "--apply"
236+
], { cwd: dir, timeout: 5000 });
237+
238+
const content = await fs.readFile(newFile, "utf8");
239+
assert.match(content, /hello from patchloom/, "created file should have the specified content");
240+
});
241+
});
242+
243+
test("exit code 3 for search with no matches", async () => {
244+
await withTempDir(async (dir) => {
245+
await fs.writeFile(path.join(dir, "empty.txt"), "nothing here\n", "utf8");
246+
247+
try {
248+
await execFileAsync(binaryPath, [
249+
"search", "NONEXISTENT_STRING_xyz", dir
250+
], { timeout: 5000 });
251+
assert.fail("should have exited with non-zero code");
252+
} catch (error) {
253+
const execError = error as Error & { code?: number };
254+
assert.equal(execError.code, 3, "no-match exit code should be 3");
255+
}
256+
});
257+
});
258+
});

0 commit comments

Comments
 (0)