diff --git a/src/infra/tailscale.test.ts b/src/infra/tailscale.test.ts index db402e51521..37658c2b287 100644 --- a/src/infra/tailscale.test.ts +++ b/src/infra/tailscale.test.ts @@ -22,6 +22,13 @@ function createRuntimeWithExitError() { }; } +function expectServeFallbackCommand(params: { callArgs: string[]; sudoArgs: string[] }) { + return [ + [tailscaleBin, expect.arrayContaining(params.callArgs)], + ["sudo", expect.arrayContaining(["-n", tailscaleBin, ...params.sudoArgs])], + ]; +} + describe("tailscale helpers", () => { let envSnapshot: ReturnType; @@ -53,53 +60,62 @@ describe("tailscale helpers", () => { expect(host).toBe("100.2.2.2"); }); - it("ensureGoInstalled installs when missing and user agrees", async () => { - const exec = vi.fn().mockRejectedValueOnce(new Error("no go")).mockResolvedValue({}); // brew install go - const prompt = vi.fn().mockResolvedValue(true); - const runtime = createRuntimeWithExitError(); - await ensureGoInstalled(exec as never, prompt, runtime); - expect(exec).toHaveBeenCalledWith("brew", ["install", "go"]); + it("parses noisy JSON output from tailscale status", async () => { + const exec = vi.fn().mockResolvedValue({ + stdout: + 'warning: stale state\n{"Self":{"DNSName":"noisy.tailnet.ts.net.","TailscaleIPs":["100.9.9.9"]}}\n', + }); + const host = await getTailnetHostname(exec); + expect(host).toBe("noisy.tailnet.ts.net"); }); - it("ensureGoInstalled exits when missing and user declines install", async () => { - const exec = vi.fn().mockRejectedValueOnce(new Error("no go")); + it.each([ + { + name: "ensureGoInstalled installs when missing and user agrees", + fn: ensureGoInstalled, + missingError: new Error("no go"), + installCommand: ["brew", ["install", "go"]] as const, + promptResult: true, + }, + { + name: "ensureTailscaledInstalled installs when missing and user agrees", + fn: ensureTailscaledInstalled, + missingError: new Error("missing"), + installCommand: ["brew", ["install", "tailscale"]] as const, + promptResult: true, + }, + ])("$name", async ({ fn, missingError, installCommand, promptResult }) => { + const exec = vi.fn().mockRejectedValueOnce(missingError).mockResolvedValue({}); + const prompt = vi.fn().mockResolvedValue(promptResult); + const runtime = createRuntimeWithExitError(); + await fn(exec as never, prompt, runtime); + expect(exec).toHaveBeenCalledWith(installCommand[0], installCommand[1]); + }); + + it.each([ + { + name: "ensureGoInstalled exits when missing and user declines install", + fn: ensureGoInstalled, + missingError: new Error("no go"), + errorMessage: "Go is required to build tailscaled from source. Aborting.", + }, + { + name: "ensureTailscaledInstalled exits when missing and user declines install", + fn: ensureTailscaledInstalled, + missingError: new Error("missing"), + errorMessage: "tailscaled is required for user-space funnel. Aborting.", + }, + ])("$name", async ({ fn, missingError, errorMessage }) => { + const exec = vi.fn().mockRejectedValueOnce(missingError); const prompt = vi.fn().mockResolvedValue(false); const runtime = createRuntimeWithExitError(); - await expect(ensureGoInstalled(exec as never, prompt, runtime)).rejects.toThrow("exit 1"); - - expect(runtime.error).toHaveBeenCalledWith( - "Go is required to build tailscaled from source. Aborting.", - ); - expect(exec).toHaveBeenCalledTimes(1); - }); - - it("ensureTailscaledInstalled installs when missing and user agrees", async () => { - const exec = vi.fn().mockRejectedValueOnce(new Error("missing")).mockResolvedValue({}); - const prompt = vi.fn().mockResolvedValue(true); - const runtime = createRuntimeWithExitError(); - await ensureTailscaledInstalled(exec as never, prompt, runtime); - expect(exec).toHaveBeenCalledWith("brew", ["install", "tailscale"]); - }); - - it("ensureTailscaledInstalled exits when missing and user declines install", async () => { - const exec = vi.fn().mockRejectedValueOnce(new Error("missing")); - const prompt = vi.fn().mockResolvedValue(false); - const runtime = createRuntimeWithExitError(); - - await expect(ensureTailscaledInstalled(exec as never, prompt, runtime)).rejects.toThrow( - "exit 1", - ); - - expect(runtime.error).toHaveBeenCalledWith( - "tailscaled is required for user-space funnel. Aborting.", - ); + await expect(fn(exec as never, prompt, runtime)).rejects.toThrow("exit 1"); + expect(runtime.error).toHaveBeenCalledWith(errorMessage); expect(exec).toHaveBeenCalledTimes(1); }); it("enableTailscaleServe attempts normal first, then sudo", async () => { - // 1. First attempt fails - // 2. Second attempt (sudo) succeeds const exec = vi .fn() .mockRejectedValueOnce(new Error("permission denied")) @@ -107,19 +123,12 @@ describe("tailscale helpers", () => { await enableTailscaleServe(3000, exec as never); - expect(exec).toHaveBeenNthCalledWith( - 1, - tailscaleBin, - expect.arrayContaining(["serve", "--bg", "--yes", "3000"]), - expect.any(Object), - ); - - expect(exec).toHaveBeenNthCalledWith( - 2, - "sudo", - expect.arrayContaining(["-n", tailscaleBin, "serve", "--bg", "--yes", "3000"]), - expect.any(Object), - ); + const [firstCall, secondCall] = expectServeFallbackCommand({ + callArgs: ["serve", "--bg", "--yes", "3000"], + sudoArgs: ["serve", "--bg", "--yes", "3000"], + }); + expect(exec).toHaveBeenNthCalledWith(1, firstCall[0], firstCall[1], expect.any(Object)); + expect(exec).toHaveBeenNthCalledWith(2, secondCall[0], secondCall[1], expect.any(Object)); }); it("enableTailscaleServe does NOT use sudo if first attempt succeeds", async () => { @@ -153,10 +162,6 @@ describe("tailscale helpers", () => { }); it("ensureFunnel uses fallback for enabling", async () => { - // Mock exec: - // 1. status (success) - // 2. enable (fails) - // 3. enable sudo (success) const exec = vi .fn() .mockResolvedValueOnce({ stdout: JSON.stringify({ BackendState: "Running" }) }) // status @@ -172,22 +177,17 @@ describe("tailscale helpers", () => { await ensureFunnel(8080, exec as never, runtime, prompt); - // 1. status expect(exec).toHaveBeenNthCalledWith( 1, tailscaleBin, expect.arrayContaining(["funnel", "status", "--json"]), ); - - // 2. enable normal expect(exec).toHaveBeenNthCalledWith( 2, tailscaleBin, expect.arrayContaining(["funnel", "--yes", "--bg", "8080"]), expect.any(Object), ); - - // 3. enable sudo expect(exec).toHaveBeenNthCalledWith( 3, "sudo",