import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { captureEnv } from "../test-utils/env.js"; import * as tailscale from "./tailscale.js"; const { ensureGoInstalled, ensureTailscaledInstalled, getTailnetHostname, enableTailscaleServe, disableTailscaleServe, ensureFunnel, } = tailscale; const tailscaleBin = expect.stringMatching(/tailscale$/i); function createRuntimeWithExitError() { return { error: vi.fn(), log: vi.fn(), exit: ((code: number) => { throw new Error(`exit ${code}`); }) as (code: number) => never, }; } 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; beforeEach(() => { envSnapshot = captureEnv(["OPENCLAW_TEST_TAILSCALE_BINARY"]); process.env.OPENCLAW_TEST_TAILSCALE_BINARY = "tailscale"; }); afterEach(() => { envSnapshot.restore(); vi.restoreAllMocks(); }); it("parses DNS name from tailscale status", async () => { const exec = vi.fn().mockResolvedValue({ stdout: JSON.stringify({ Self: { DNSName: "host.tailnet.ts.net.", TailscaleIPs: ["100.1.1.1"] }, }), }); const host = await getTailnetHostname(exec); expect(host).toBe("host.tailnet.ts.net"); }); it("falls back to IP when DNS missing", async () => { const exec = vi.fn().mockResolvedValue({ stdout: JSON.stringify({ Self: { TailscaleIPs: ["100.2.2.2"] } }), }); const host = await getTailnetHostname(exec); expect(host).toBe("100.2.2.2"); }); 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.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(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 () => { const exec = vi .fn() .mockRejectedValueOnce(new Error("permission denied")) .mockResolvedValueOnce({ stdout: "" }); await enableTailscaleServe(3000, exec as never); 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 () => { const exec = vi.fn().mockResolvedValue({ stdout: "" }); await enableTailscaleServe(3000, exec as never); expect(exec).toHaveBeenCalledTimes(1); expect(exec).toHaveBeenCalledWith( tailscaleBin, expect.arrayContaining(["serve", "--bg", "--yes", "3000"]), expect.any(Object), ); }); it("disableTailscaleServe uses fallback", async () => { const exec = vi .fn() .mockRejectedValueOnce(new Error("permission denied")) .mockResolvedValueOnce({ stdout: "" }); await disableTailscaleServe(exec as never); expect(exec).toHaveBeenCalledTimes(2); expect(exec).toHaveBeenNthCalledWith( 2, "sudo", expect.arrayContaining(["-n", tailscaleBin, "serve", "reset"]), expect.any(Object), ); }); it("ensureFunnel uses fallback for enabling", async () => { const exec = vi .fn() .mockResolvedValueOnce({ stdout: JSON.stringify({ BackendState: "Running" }) }) // status .mockRejectedValueOnce(new Error("permission denied")) // enable normal .mockResolvedValueOnce({ stdout: "" }); // enable sudo const runtime = { error: vi.fn(), log: vi.fn(), exit: vi.fn() as unknown as (code: number) => never, }; const prompt = vi.fn(); await ensureFunnel(8080, exec as never, runtime, prompt); expect(exec).toHaveBeenNthCalledWith( 1, tailscaleBin, expect.arrayContaining(["funnel", "status", "--json"]), ); expect(exec).toHaveBeenNthCalledWith( 2, tailscaleBin, expect.arrayContaining(["funnel", "--yes", "--bg", "8080"]), expect.any(Object), ); expect(exec).toHaveBeenNthCalledWith( 3, "sudo", expect.arrayContaining(["-n", tailscaleBin, "funnel", "--yes", "--bg", "8080"]), expect.any(Object), ); }); it("enableTailscaleServe skips sudo on non-permission errors", async () => { const exec = vi.fn().mockRejectedValueOnce(new Error("boom")); await expect(enableTailscaleServe(3000, exec as never)).rejects.toThrow("boom"); expect(exec).toHaveBeenCalledTimes(1); }); it("enableTailscaleServe rethrows original error if sudo fails", async () => { const originalError = Object.assign(new Error("permission denied"), { stderr: "permission denied", }); const exec = vi .fn() .mockRejectedValueOnce(originalError) .mockRejectedValueOnce(new Error("sudo: a password is required")); await expect(enableTailscaleServe(3000, exec as never)).rejects.toBe(originalError); expect(exec).toHaveBeenCalledTimes(2); }); });