|
| 1 | +// #6732: the CLI mirror for loopover_monitor_open_prs. The MCP tool and GET |
| 2 | +// /v1/contributors/:login/open-pr-monitor already served this; only the stdio/CLI surface was missing. |
| 3 | +// These pin the three things that can silently rot: the tool is registered, both surfaces hit the same |
| 4 | +// route, and `monitor-open-prs --json` stays byte-identical to what the tool returns for one input. |
| 5 | +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; |
| 6 | +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; |
| 7 | +import { mkdtempSync, rmSync } from "node:fs"; |
| 8 | +import { tmpdir } from "node:os"; |
| 9 | +import { join } from "node:path"; |
| 10 | +import { afterEach, beforeEach, describe, expect, it } from "vitest"; |
| 11 | +// Any CLI command that calls the API must go through runAsync: the fixture server lives in this process, |
| 12 | +// so run()'s execFileSync would block the event loop and the child's fetch would abort before a response. |
| 13 | +import { closeFixtureServer, openPrMonitorFixture, run, runAsync, runExpectingFailure, startFixtureServer } from "./support/mcp-cli-harness"; |
| 14 | + |
| 15 | +const bin = join(process.cwd(), "packages/loopover-mcp/bin/loopover-mcp.js"); |
| 16 | + |
| 17 | +let client: Client; |
| 18 | +let transport: StdioClientTransport; |
| 19 | +let configDir: string; |
| 20 | +let apiUrl: string; |
| 21 | +let capturedRequests: Array<{ url: string; method: string }>; |
| 22 | + |
| 23 | +async function connect() { |
| 24 | + configDir = mkdtempSync(join(tmpdir(), "loopover-monitor-open-prs-")); |
| 25 | + capturedRequests = []; |
| 26 | + apiUrl = await startFixtureServer({ |
| 27 | + onApiRequest: (request) => { |
| 28 | + if (request.url && request.url.includes("/open-pr-monitor")) { |
| 29 | + capturedRequests.push({ url: request.url ?? "", method: request.method ?? "GET" }); |
| 30 | + } |
| 31 | + }, |
| 32 | + }); |
| 33 | + transport = new StdioClientTransport({ |
| 34 | + command: "node", |
| 35 | + args: [bin, "--stdio"], |
| 36 | + env: { |
| 37 | + ...process.env, |
| 38 | + LOOPOVER_CONFIG_DIR: configDir, |
| 39 | + LOOPOVER_API_URL: apiUrl, |
| 40 | + LOOPOVER_TOKEN: "session-token", |
| 41 | + LOOPOVER_API_TIMEOUT_MS: "5000", |
| 42 | + }, |
| 43 | + }); |
| 44 | + client = new Client({ name: "monitor-open-prs-test", version: "0.0.1" }); |
| 45 | + await client.connect(transport); |
| 46 | +} |
| 47 | + |
| 48 | +async function disconnect() { |
| 49 | + await client.close().catch(() => undefined); |
| 50 | + await closeFixtureServer(); |
| 51 | + if (configDir) rmSync(configDir, { recursive: true, force: true }); |
| 52 | +} |
| 53 | + |
| 54 | +describe("loopover_monitor_open_prs stdio proxy", () => { |
| 55 | + beforeEach(connect); |
| 56 | + afterEach(disconnect); |
| 57 | + |
| 58 | + it("registers the tool in the stdio server tool list", async () => { |
| 59 | + const { tools } = await client.listTools(); |
| 60 | + expect(tools.map((t) => t.name)).toContain("loopover_monitor_open_prs"); |
| 61 | + }); |
| 62 | + |
| 63 | + it("proxies login to /v1/contributors/:login/open-pr-monitor via apiGet and returns the monitor", async () => { |
| 64 | + const result = await client.callTool({ name: "loopover_monitor_open_prs", arguments: { login: "JSONbored" } }); |
| 65 | + expect(capturedRequests.length).toBe(1); |
| 66 | + const captured = capturedRequests[0]!; |
| 67 | + expect(captured.url).toContain("/v1/contributors/JSONbored/open-pr-monitor"); |
| 68 | + expect(captured.method).toBe("GET"); |
| 69 | + expect(result.isError).toBeFalsy(); |
| 70 | + const text = JSON.stringify(result); |
| 71 | + expect(text).toContain("JSONbored/gittensory"); |
| 72 | + expect(text).toContain("failing_checks"); |
| 73 | + // The tool summary is the API's own sentence, not a second one invented client-side. |
| 74 | + expect(text).toContain(openPrMonitorFixture().summary); |
| 75 | + }); |
| 76 | +}); |
| 77 | + |
| 78 | +describe("loopover-mcp monitor-open-prs CLI", () => { |
| 79 | + beforeEach(connect); |
| 80 | + afterEach(disconnect); |
| 81 | + |
| 82 | + it("--json emits exactly the payload the MCP tool surfaces for the same login (mirror parity)", async () => { |
| 83 | + const viaTool = await client.callTool({ name: "loopover_monitor_open_prs", arguments: { login: "JSONbored" } }); |
| 84 | + const toolData = (viaTool as { structuredContent?: unknown }).structuredContent; |
| 85 | + const viaCli = JSON.parse(await runAsync(["monitor-open-prs", "--login", "JSONbored", "--json"], { LOOPOVER_API_URL: apiUrl, LOOPOVER_TOKEN: "session-token" })); |
| 86 | + expect(viaCli).toEqual(openPrMonitorFixture()); |
| 87 | + if (toolData !== undefined) expect(viaCli).toEqual(toolData); |
| 88 | + }); |
| 89 | + |
| 90 | + it("prints the API summary, guidance, and a next-step line per open PR", async () => { |
| 91 | + const out = await runAsync(["monitor-open-prs", "--login", "JSONbored"], { LOOPOVER_API_URL: apiUrl, LOOPOVER_TOKEN: "session-token" }); |
| 92 | + const fixture = openPrMonitorFixture(); |
| 93 | + expect(out).toContain(fixture.summary); |
| 94 | + expect(out).toContain(fixture.guidance[0]!); |
| 95 | + expect(out).toContain("JSONbored/gittensory#42 [failing_checks] fix(queue): drain stale entries"); |
| 96 | + expect(out).toContain(" - Fix the failing check, then push."); |
| 97 | + }); |
| 98 | + |
| 99 | + it("resolves the login from LOOPOVER_LOGIN, then GITHUB_LOGIN, the way decision-pack does", async () => { |
| 100 | + const viaLoopoverLogin = await runAsync(["monitor-open-prs", "--json"], { LOOPOVER_API_URL: apiUrl, LOOPOVER_TOKEN: "session-token", LOOPOVER_LOGIN: "JSONbored" }); |
| 101 | + expect(JSON.parse(viaLoopoverLogin)).toEqual(openPrMonitorFixture()); |
| 102 | + const viaGithubLogin = await runAsync(["monitor-open-prs", "--json"], { LOOPOVER_API_URL: apiUrl, LOOPOVER_TOKEN: "session-token", GITHUB_LOGIN: "JSONbored" }); |
| 103 | + expect(JSON.parse(viaGithubLogin)).toEqual(openPrMonitorFixture()); |
| 104 | + }); |
| 105 | + |
| 106 | + it("fails with the shared login-required message when no login is resolvable", () => { |
| 107 | + const failure = runExpectingFailure(["monitor-open-prs"], { LOOPOVER_API_URL: apiUrl, LOOPOVER_TOKEN: "session-token", LOOPOVER_LOGIN: "", GITHUB_LOGIN: "" }); |
| 108 | + expect(failure.status).toBe(1); |
| 109 | + expect(`${failure.stdout}${failure.stderr}`).toMatch(/Pass --login <github-login> or set LOOPOVER_LOGIN\./); |
| 110 | + }); |
| 111 | + |
| 112 | + // #6261: the API composes the summary/guidance and echoes PR titles back from third-party repos, so a hostile |
| 113 | + // string must not be able to repaint the terminal. --json stays raw on purpose: JSON.stringify escapes U+001B. |
| 114 | + it("strips ANSI escapes from API-chosen text on the plain-text path but not from --json", async () => { |
| 115 | + await closeFixtureServer(); |
| 116 | + const hostileUrl = await startFixtureServer({ openPrMonitor: { summary: "[31mFAKE PASS[0m", guidance: ["[2Krewritten"] } }); |
| 117 | + const env = { LOOPOVER_API_URL: hostileUrl, LOOPOVER_TOKEN: "session-token" }; |
| 118 | + |
| 119 | + const plain = await runAsync(["monitor-open-prs", "--login", "JSONbored"], env); |
| 120 | + expect(plain).not.toContain(""); |
| 121 | + expect(plain).toContain("FAKE PASS"); |
| 122 | + expect(plain).toContain("rewritten"); |
| 123 | + |
| 124 | + const asJson = await runAsync(["monitor-open-prs", "--login", "JSONbored", "--json"], env); |
| 125 | + expect(JSON.parse(asJson).summary).toBe("[31mFAKE PASS[0m"); |
| 126 | + }); |
| 127 | + |
| 128 | + it("documents itself in --help and in the shell-completion command list", () => { |
| 129 | + expect(run(["--help"])).toContain("loopover-mcp monitor-open-prs --login <github-login> [--json]"); |
| 130 | + expect(run(["monitor-open-prs", "--help"])).toContain("Mirrors the loopover_monitor_open_prs MCP tool"); |
| 131 | + expect(run(["completion", "bash"])).toContain("monitor-open-prs"); |
| 132 | + }); |
| 133 | +}); |
0 commit comments