diff --git a/src/cli/program.force.test.ts b/src/cli/program.force.test.ts new file mode 100644 index 000000000..331c9d3af --- /dev/null +++ b/src/cli/program.force.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("node:child_process", async () => { + const actual = await vi.importActual( + "node:child_process", + ); + return { + ...actual, + execFileSync: vi.fn(), + }; +}); + +import { execFileSync } from "node:child_process"; +import { + forceFreePort, + listPortListeners, + parseLsofOutput, + type PortProcess, +} from "./program.js"; + +describe("gateway --force helpers", () => { + let originalKill: typeof process.kill; + + beforeEach(() => { + vi.clearAllMocks(); + originalKill = process.kill; + }); + + afterEach(() => { + process.kill = originalKill; + }); + + it("parses lsof output into pid/command pairs", () => { + const sample = ["p123", "cnode", "p456", "cpython", ""].join("\n"); + const parsed = parseLsofOutput(sample); + expect(parsed).toEqual([ + { pid: 123, command: "node" }, + { pid: 456, command: "python" }, + ]); + }); + + it("returns empty list when lsof finds nothing", () => { + (execFileSync as unknown as vi.Mock).mockImplementation(() => { + const err = new Error("no matches"); + // @ts-expect-error partial + err.status = 1; // lsof uses exit 1 for no matches + throw err; + }); + expect(listPortListeners(18789)).toEqual([]); + }); + + it("throws when lsof missing", () => { + (execFileSync as unknown as vi.Mock).mockImplementation(() => { + const err = new Error("not found"); + // @ts-expect-error partial + err.code = "ENOENT"; + throw err; + }); + expect(() => listPortListeners(18789)).toThrow(/lsof not found/); + }); + + it("kills each listener and returns metadata", () => { + (execFileSync as unknown as vi.Mock).mockReturnValue( + ["p42", "cnode", "p99", "cssh", ""].join("\n"), + ); + const killMock = vi.fn(); + // @ts-expect-error override for test + process.kill = killMock; + + const killed = forceFreePort(18789); + + expect(execFileSync).toHaveBeenCalled(); + expect(killMock).toHaveBeenCalledTimes(2); + expect(killMock).toHaveBeenCalledWith(42, "SIGTERM"); + expect(killMock).toHaveBeenCalledWith(99, "SIGTERM"); + expect(killed).toEqual([ + { pid: 42, command: "node" }, + { pid: 99, command: "ssh" }, + ]); + }); +}); diff --git a/src/cli/program.ts b/src/cli/program.ts index 43eefb05a..8df7ebbf3 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -17,9 +17,9 @@ import { VERSION } from "../version.js"; import { startWebChatServer } from "../webchat/server.js"; import { createDefaultDeps } from "./deps.js"; -type PortProcess = { pid: number; command?: string }; +export type PortProcess = { pid: number; command?: string }; -function parseLsofOutput(output: string): PortProcess[] { +export function parseLsofOutput(output: string): PortProcess[] { const lines = output.split(/\r?\n/).filter(Boolean); const results: PortProcess[] = []; let current: Partial = {}; @@ -35,7 +35,7 @@ function parseLsofOutput(output: string): PortProcess[] { return results; } -function listPortListeners(port: number): PortProcess[] { +export function listPortListeners(port: number): PortProcess[] { try { const out = execFileSync( "lsof", @@ -55,7 +55,7 @@ function listPortListeners(port: number): PortProcess[] { } } -function forceFreePort(port: number): PortProcess[] { +export function forceFreePort(port: number): PortProcess[] { const listeners = listPortListeners(port); for (const proc of listeners) { try {