Skip to content

Commit 6057088

Browse files
committed
Add end-to-end integration tests for CLI boundary
Extend patchloomCli.test.ts from 13 to 23 tests with deeper integration flows that exercise the actual extension-to-CLI boundary: Exit code handling: - exit 0: tidy check on clean file - exit 2: replace --check with pending changes, tidy check on dirty file Initialize Project round-trip: - agent-rules output -> write -> classifyAgentsFile -> up_to_date - modified AGENTS.md -> classifyAgentsFile -> different Quick action preview flow: - copy file to temp dir, retarget action, apply on copy, verify original untouched and preview has replacement Full status details with real binary: - resolvePatchloomStatusWithInputs -> buildStatusDetails -> verify rendered string contains real version, path, workspace info - preferredStatusAction suggests Initialize Project when AGENTS.md missing MCP server: - mcp-server starts and responds to JSON-RPC initialize (skips gracefully if binary lacks --features mcp) MCP config round-trip: - configureMcpTargets writes config pointing to real binary - inspectMcpTargets reads it back and confirms configured=true Test count: 94 -> 104 unit + 1 integration Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
1 parent fd63339 commit 6057088

1 file changed

Lines changed: 252 additions & 0 deletions

File tree

test/unit/patchloomCli.test.ts

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ import {
2121
assessPatchloomCompatibility,
2222
resolvePatchloomStatusWithInputs
2323
} from "../../src/binary/patchloom";
24+
import { classifyAgentsFile } from "../../src/commands/initializeProject";
25+
import { buildStatusDetails, preferredStatusAction } from "../../src/commands/showStatus";
26+
import { buildReplaceQuickAction, retargetQuickAction, withApplyFlag } from "../../src/commands/quickActions";
27+
import { configureMcpTargets, inspectMcpTargets } from "../../src/mcp/config";
2428

2529
const execFileAsync = promisify(execFile);
2630

@@ -255,4 +259,252 @@ describe("patchloom CLI integration", async () => {
255259
}
256260
});
257261
});
262+
263+
test("exit code 2 for replace --check with pending changes", async () => {
264+
await withTempDir(async (dir) => {
265+
const file = path.join(dir, "check-target.txt");
266+
await fs.writeFile(file, "hello world\n", "utf8");
267+
268+
try {
269+
await execFileAsync(binaryPath, [
270+
"replace", "hello", "--to", "goodbye", file, "--check"
271+
], { timeout: 5000 });
272+
assert.fail("should have exited with non-zero code");
273+
} catch (error) {
274+
const execError = error as Error & { code?: number };
275+
assert.equal(execError.code, 2, "changes-detected exit code should be 2");
276+
}
277+
278+
// File should be unchanged (--check is read-only)
279+
const content = await fs.readFile(file, "utf8");
280+
assert.equal(content, "hello world\n", "file must not be modified by --check");
281+
});
282+
});
283+
284+
test("exit code 0 for tidy check on a clean file", async () => {
285+
await withTempDir(async (dir) => {
286+
const file = path.join(dir, "clean.txt");
287+
await fs.writeFile(file, "already clean\n", "utf8");
288+
289+
// File has a final newline and no trailing whitespace; tidy check should exit 0
290+
await execFileAsync(binaryPath, [
291+
"tidy", "check", file, "--ensure-final-newline"
292+
], { timeout: 5000 });
293+
// If we get here, exit code was 0 (no issues found)
294+
});
295+
});
296+
297+
test("exit code 2 for tidy check on a file needing fixes", async () => {
298+
await withTempDir(async (dir) => {
299+
const file = path.join(dir, "dirty.txt");
300+
await fs.writeFile(file, "no trailing newline", "utf8");
301+
302+
try {
303+
await execFileAsync(binaryPath, [
304+
"tidy", "check", file, "--ensure-final-newline"
305+
], { timeout: 5000 });
306+
assert.fail("should have exited with non-zero code");
307+
} catch (error) {
308+
const execError = error as Error & { code?: number };
309+
assert.equal(execError.code, 2, "tidy check should return 2 for issues found");
310+
}
311+
});
312+
});
313+
314+
// --- Initialize Project round-trip ---
315+
316+
test("agent-rules output classified as up_to_date after write", async () => {
317+
await withTempDir(async (dir) => {
318+
// Generate agent rules
319+
const { stdout: rules } = await execFileAsync(binaryPath, ["agent-rules"], {
320+
cwd: dir,
321+
timeout: 10000
322+
});
323+
324+
// Write it exactly as generated
325+
const agentsPath = path.join(dir, "AGENTS.md");
326+
const content = rules.endsWith("\n") ? rules : `${rules}\n`;
327+
await fs.writeFile(agentsPath, content, "utf8");
328+
329+
// Extension's classifier should see it as up_to_date
330+
const existing = await fs.readFile(agentsPath, "utf8");
331+
const state = classifyAgentsFile(existing, content);
332+
assert.equal(state, "up_to_date",
333+
"freshly written agent-rules output should be classified as up_to_date");
334+
});
335+
});
336+
337+
test("agent-rules output classified as different after modification", async () => {
338+
await withTempDir(async (dir) => {
339+
const { stdout: rules } = await execFileAsync(binaryPath, ["agent-rules"], {
340+
cwd: dir,
341+
timeout: 10000
342+
});
343+
344+
const content = rules.endsWith("\n") ? rules : `${rules}\n`;
345+
const modified = content + "\n## Custom section\n\nExtra content.\n";
346+
347+
const state = classifyAgentsFile(modified, content);
348+
assert.equal(state, "different",
349+
"modified agent-rules should be classified as different");
350+
});
351+
});
352+
353+
// --- Quick action preview flow ---
354+
355+
test("quick action preview flow: copy, apply to copy, compare", async () => {
356+
await withTempDir(async (dir) => {
357+
// Original file
358+
const originalFile = path.join(dir, "original.txt");
359+
const originalContent = "The quick brown fox jumps over the lazy dog.\n";
360+
await fs.writeFile(originalFile, originalContent, "utf8");
361+
362+
// Build the quick action (extension builds these from user input)
363+
const action = buildReplaceQuickAction(originalFile, "fox", "cat");
364+
365+
// Simulate the preview flow: copy to temp, retarget, apply
366+
const previewDir = path.join(dir, "preview");
367+
await fs.mkdir(previewDir);
368+
const previewFile = path.join(previewDir, "original.txt");
369+
await fs.writeFile(previewFile, originalContent, "utf8");
370+
371+
const previewAction = retargetQuickAction(action, previewFile);
372+
const applyArgs = withApplyFlag([...previewAction.args]);
373+
374+
await execFileAsync(binaryPath, applyArgs, { cwd: previewDir, timeout: 5000 });
375+
376+
// Original is untouched
377+
const originalAfter = await fs.readFile(originalFile, "utf8");
378+
assert.equal(originalAfter, originalContent, "original file must not be modified during preview");
379+
380+
// Preview file has the replacement
381+
const previewContent = await fs.readFile(previewFile, "utf8");
382+
assert.ok(previewContent.includes("cat"), "preview should contain the replacement");
383+
assert.ok(!previewContent.includes("fox"), "preview should not contain the original text");
384+
});
385+
});
386+
387+
// --- Full status details with real binary ---
388+
389+
test("buildStatusDetails renders real binary status correctly", async () => {
390+
const status = await resolvePatchloomStatusWithInputs({
391+
configuredPath: binaryPath
392+
});
393+
394+
const details = buildStatusDetails(status, {
395+
hasWorkspace: true,
396+
workspaceName: "test-project",
397+
hasAgentsFile: false,
398+
workspaceCount: 1,
399+
environmentLabel: "Local",
400+
environmentSupport: "supported"
401+
});
402+
403+
assert.match(details, /Patchloom is ready/, "should report ready");
404+
assert.match(details, /patchloom\.path/, "should show source as setting");
405+
assert.ok(details.includes(binaryPath), "should include the binary path");
406+
assert.match(details, /Workspace: test-project/, "should include workspace name");
407+
assert.match(details, /AGENTS\.md: missing/, "should report missing AGENTS.md");
408+
});
409+
410+
test("preferredStatusAction suggests Initialize Project for real ready status", async () => {
411+
const status = await resolvePatchloomStatusWithInputs({
412+
configuredPath: binaryPath
413+
});
414+
415+
const action = preferredStatusAction(status, {
416+
hasWorkspace: true,
417+
workspaceName: "test",
418+
hasAgentsFile: false,
419+
workspaceCount: 1,
420+
environmentLabel: "Local",
421+
environmentSupport: "supported"
422+
});
423+
424+
assert.ok(action, "should suggest an action when AGENTS.md is missing");
425+
assert.equal(action.command, "patchloom.initializeProject");
426+
});
427+
428+
// --- MCP config write then verify server starts ---
429+
430+
test("mcp-server starts and responds to JSON-RPC initialize", async () => {
431+
// Check if this binary was built with MCP support
432+
try {
433+
await execFileAsync(binaryPath, ["mcp-server", "--help"], { timeout: 5000 });
434+
} catch {
435+
// mcp-server not available in this build; skip
436+
return;
437+
}
438+
439+
const child = execFile(binaryPath, ["mcp-server"], { timeout: 10000 });
440+
let stdout = "";
441+
child.stdout!.on("data", (data: Buffer) => { stdout += data.toString(); });
442+
443+
// Send a JSON-RPC initialize request using the MCP wire format
444+
const initRequest = JSON.stringify({
445+
jsonrpc: "2.0",
446+
id: 1,
447+
method: "initialize",
448+
params: {
449+
protocolVersion: "2024-11-05",
450+
capabilities: {},
451+
clientInfo: { name: "test", version: "0.0.1" }
452+
}
453+
});
454+
const header = `Content-Length: ${Buffer.byteLength(initRequest)}\r\n\r\n`;
455+
child.stdin!.write(header + initRequest);
456+
457+
// Wait for the server to respond (it needs to start the tokio runtime)
458+
const deadline = Date.now() + 5000;
459+
while (stdout.length === 0 && Date.now() < deadline) {
460+
await new Promise((resolve) => setTimeout(resolve, 100));
461+
}
462+
463+
child.kill();
464+
465+
// Should have received a JSON-RPC response with Content-Length header
466+
assert.ok(stdout.length > 0, "mcp-server should produce output");
467+
assert.match(stdout, /Content-Length/i, "response should use Content-Length framing");
468+
assert.match(stdout, /jsonrpc/, "response body should be JSON-RPC");
469+
});
470+
471+
test("MCP config written for real binary is structurally valid", async () => {
472+
await withTempDir(async (workspace) => {
473+
const readFile = async (filePath: string) => {
474+
try { return await fs.readFile(filePath, "utf8"); } catch { return undefined; }
475+
};
476+
const writeFile = async (filePath: string, content: string) => {
477+
await fs.mkdir(path.dirname(filePath), { recursive: true });
478+
await fs.writeFile(filePath, content, "utf8");
479+
};
480+
481+
// Write MCP config pointing to the real binary
482+
await configureMcpTargets({
483+
workspaceFolderPath: workspace,
484+
homeDir: workspace,
485+
includeKinds: ["vscode-workspace"],
486+
patchloomPathSetting: binaryPath,
487+
readFile,
488+
writeFile
489+
});
490+
491+
// Read back and verify structure
492+
const configPath = path.join(workspace, ".vscode", "mcp.json");
493+
const config = JSON.parse(await fs.readFile(configPath, "utf8")) as Record<string, unknown>;
494+
const servers = config.servers as Record<string, Record<string, unknown>>;
495+
assert.ok(servers.patchloom, "should have a patchloom server entry");
496+
assert.equal(servers.patchloom.command, binaryPath, "command should point to real binary");
497+
assert.deepEqual(servers.patchloom.args, ["mcp-server"], "args should be mcp-server");
498+
499+
// Verify the config is re-readable by inspectMcpTargets
500+
const targets = await inspectMcpTargets({
501+
workspaceFolderPath: workspace,
502+
homeDir: workspace,
503+
readFile
504+
});
505+
const vscodeTarget = targets.find((t) => t.kind === "vscode-workspace");
506+
assert.ok(vscodeTarget);
507+
assert.equal(vscodeTarget.configured, true, "target should be detected as configured");
508+
});
509+
});
258510
});

0 commit comments

Comments
 (0)