Skip to content

Commit 1e946a0

Browse files
authored
fix: managed install archive extraction path mismatch + e2e MCP tests (#136)
* fix: managed install archive extraction path mismatch cargo-dist archives extract to patchloom-<triple>/patchloom, but the promotion step expected the binary at managed-bin/patchloom. The rename failed with ENOENT on every real managed install attempt. Fix: after extraction, detect the cargo-dist subdirectory and move the binary to the expected staged path before promotion. Also add end-to-end tests that perform a real managed install (download from GitHub, verify checksum, extract, promote) then start the MCP server and validate JSON-RPC initialize and tools/list responses. These tests would have caught this bug on day one. Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca> * docs: update test count in AGENTS.md for managed install e2e Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca> --------- Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
1 parent a92562e commit 1e946a0

3 files changed

Lines changed: 155 additions & 1 deletion

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ test/
5252
managedLifecycle.test.ts Managed install with real file I/O (22 tests)
5353
mcpConfig.test.ts MCP config with real temp directories (9 tests)
5454
outputChannel.test.ts Output channel logging wrapper (10 tests)
55-
patchloomCli.test.ts Patchloom CLI integration tests with real binary (29 tests)
55+
patchloomCli.test.ts Patchloom CLI integration with real binary + managed install e2e MCP (35 tests)
5656
propertyBased.test.ts Property-based tests with fast-check (13 tests)
5757
quickActions.test.ts Quick action command building, path containment, patch merge (46 tests)
5858
verifyMcp.test.ts MCP server verify and JSON-RPC response parsing (15 tests)

src/install/managed.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -587,6 +587,18 @@ export async function performManagedInstall(inputs: PerformManagedInstallInputs)
587587
format: target.archiveFormat
588588
});
589589

590+
// cargo-dist archives extract to <triple>/patchloom, but promotion
591+
// expects the binary at managed-bin/patchloom. Move it into place.
592+
const extractedBinaryPath = path.join(
593+
txPaths.stagingRoot,
594+
`patchloom-${target.targetTriple}`,
595+
managedBinaryName(platform)
596+
);
597+
if (await defaultFileExists(extractedBinaryPath)) {
598+
await defaultEnsureDir(path.dirname(txPaths.stagedBinaryPath));
599+
await defaultRenameFile(extractedBinaryPath, txPaths.stagedBinaryPath);
600+
}
601+
590602
report("installing");
591603
await promoteManagedInstallBinary({
592604
paths: txPaths,

test/unit/patchloomCli.test.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { classifyAgentsFile } from "../../src/commands/initializeProject.js";
2525
import { buildStatusDetails, preferredStatusAction } from "../../src/commands/showStatus.js";
2626
import { buildReplaceQuickAction, retargetQuickAction, withApplyFlag } from "../../src/commands/quickActions.js";
2727
import { configureMcpTargets, inspectMcpTargets } from "../../src/mcp/config.js";
28+
import { performManagedInstall } from "../../src/install/managed.js";
2829

2930
const execFileAsync = promisify(execFile);
3031

@@ -620,3 +621,144 @@ describe("patchloom CLI integration", async () => {
620621
});
621622
});
622623
});
624+
625+
// --- End-to-end: managed install + MCP server ---
626+
//
627+
// Downloads the real patchloom binary via performManagedInstall (no mocks),
628+
// starts the MCP server, sends JSON-RPC requests, and validates responses.
629+
// This proves the full pipeline works on a clean machine with no pre-installed binary.
630+
631+
describe("managed install end-to-end MCP", { timeout: 120_000 }, async () => {
632+
let installDir: string;
633+
let binaryPath: string;
634+
635+
// Install once for all tests in this block
636+
try {
637+
installDir = await fs.mkdtemp(path.join(os.tmpdir(), "patchloom-e2e-"));
638+
const result = await performManagedInstall({ installRoot: installDir });
639+
binaryPath = result.binaryPath;
640+
} catch (err) {
641+
// Network or platform issue; skip all tests in this block
642+
test("skipped: managed install failed", {
643+
skip: `managed install unavailable: ${err instanceof Error ? err.message : String(err)}`
644+
}, () => {});
645+
return;
646+
}
647+
648+
// Verify the binary is executable
649+
test("managed install produces a runnable binary", async () => {
650+
const { stdout, stderr } = await execFileAsync(binaryPath, ["--version"], { timeout: 5000 });
651+
const output = `${stdout}${stderr}`.trim();
652+
const version = parsePatchloomVersion(output);
653+
assert.ok(version, `should parse version from managed binary: ${output}`);
654+
assert.match(version, /^\d+\.\d+\.\d+/);
655+
});
656+
657+
test("MCP server responds to initialize", async () => {
658+
const child = execFile(binaryPath, ["mcp-server"], { timeout: 15000 });
659+
let stdout = "";
660+
child.stdout!.on("data", (data: Buffer) => { stdout += data.toString(); });
661+
662+
const initRequest = JSON.stringify({
663+
jsonrpc: "2.0",
664+
id: 1,
665+
method: "initialize",
666+
params: {
667+
protocolVersion: "2024-11-05",
668+
capabilities: {},
669+
clientInfo: { name: "e2e-test", version: "0.0.1" }
670+
}
671+
});
672+
child.stdin!.write(initRequest + "\n");
673+
674+
const deadline = Date.now() + 10000;
675+
while (stdout.length === 0 && Date.now() < deadline) {
676+
await new Promise((resolve) => setTimeout(resolve, 100));
677+
}
678+
679+
child.kill();
680+
681+
assert.ok(stdout.length > 0, "mcp-server should produce output");
682+
const response = JSON.parse(stdout.trim().split("\n")[0]) as Record<string, unknown>;
683+
assert.equal(response.jsonrpc, "2.0");
684+
assert.equal(response.id, 1);
685+
const responseResult = response.result as Record<string, unknown>;
686+
assert.ok(responseResult, "response should have a result");
687+
const serverInfo = responseResult.serverInfo as Record<string, string>;
688+
assert.ok(serverInfo?.name, "response should include serverInfo.name");
689+
});
690+
691+
test("MCP server lists available tools", async () => {
692+
const child = execFile(binaryPath, ["mcp-server"], { timeout: 15000 });
693+
let stdout = "";
694+
child.stdout!.on("data", (data: Buffer) => { stdout += data.toString(); });
695+
696+
// Must initialize first, then list tools
697+
const initRequest = JSON.stringify({
698+
jsonrpc: "2.0",
699+
id: 1,
700+
method: "initialize",
701+
params: {
702+
protocolVersion: "2024-11-05",
703+
capabilities: {},
704+
clientInfo: { name: "e2e-test", version: "0.0.1" }
705+
}
706+
});
707+
child.stdin!.write(initRequest + "\n");
708+
709+
// Wait for initialize response
710+
let deadline = Date.now() + 10000;
711+
while (!stdout.includes('"id":1') && Date.now() < deadline) {
712+
await new Promise((resolve) => setTimeout(resolve, 100));
713+
}
714+
715+
// Send initialized notification then tools/list
716+
const initializedNotification = JSON.stringify({
717+
jsonrpc: "2.0",
718+
method: "notifications/initialized"
719+
});
720+
child.stdin!.write(initializedNotification + "\n");
721+
722+
const toolsRequest = JSON.stringify({
723+
jsonrpc: "2.0",
724+
id: 2,
725+
method: "tools/list"
726+
});
727+
child.stdin!.write(toolsRequest + "\n");
728+
729+
// Wait for tools/list response
730+
deadline = Date.now() + 10000;
731+
while (!stdout.includes('"id":2') && Date.now() < deadline) {
732+
await new Promise((resolve) => setTimeout(resolve, 100));
733+
}
734+
735+
child.kill();
736+
737+
// Parse the tools/list response (second JSON line)
738+
const lines = stdout.trim().split("\n");
739+
const toolsLine = lines.find((line) => line.includes('"id":2'));
740+
assert.ok(toolsLine, "should have a tools/list response");
741+
742+
const toolsResponse = JSON.parse(toolsLine) as Record<string, unknown>;
743+
assert.equal(toolsResponse.jsonrpc, "2.0");
744+
assert.equal(toolsResponse.id, 2);
745+
const toolsResult = toolsResponse.result as Record<string, unknown>;
746+
assert.ok(toolsResult, "tools/list should have a result");
747+
const tools = toolsResult.tools as Array<Record<string, unknown>>;
748+
assert.ok(Array.isArray(tools), "result.tools should be an array");
749+
assert.ok(tools.length > 0, "should expose at least one tool");
750+
751+
// Verify tools have required MCP fields
752+
for (const tool of tools) {
753+
assert.ok(typeof tool.name === "string" && tool.name.length > 0,
754+
`tool should have a non-empty name: ${JSON.stringify(tool)}`);
755+
assert.ok(tool.inputSchema !== undefined,
756+
`tool ${tool.name} should have an inputSchema`);
757+
}
758+
});
759+
760+
// Cleanup
761+
test("cleanup managed install temp directory", async () => {
762+
await fs.rm(installDir, { recursive: true, force: true });
763+
});
764+
});

0 commit comments

Comments
 (0)