import { spawn, type ChildProcess } from "node:child_process"; import fs from "node:fs/promises"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { prepareRestartScript, runRestartScript } from "./restart-helper.js"; vi.mock("node:child_process", () => ({ spawn: vi.fn(), })); describe("restart-helper", () => { const originalPlatform = process.platform; const originalGetUid = process.getuid; async function prepareAndReadScript(env: Record, gatewayPort = 18789) { const scriptPath = await prepareRestartScript(env, gatewayPort); expect(scriptPath).toBeTruthy(); const content = await fs.readFile(scriptPath!, "utf-8"); return { scriptPath: scriptPath!, content }; } async function cleanupScript(scriptPath: string) { await fs.unlink(scriptPath); } function expectWindowsRestartWaitOrdering(content: string, port = 18789) { const endCommand = 'schtasks /End /TN "'; const pollAttemptsInit = "set /a attempts=0"; const pollLabel = ":wait_for_port_release"; const pollAttemptIncrement = "set /a attempts+=1"; const pollNetstatCheck = `netstat -ano | findstr /R /C:":${port} .*LISTENING" >nul`; const forceKillLabel = ":force_kill_listener"; const forceKillCommand = "taskkill /F /PID %%P >nul 2>&1"; const portReleasedLabel = ":port_released"; const runCommand = 'schtasks /Run /TN "'; const endIndex = content.indexOf(endCommand); const attemptsInitIndex = content.indexOf(pollAttemptsInit, endIndex); const pollLabelIndex = content.indexOf(pollLabel, attemptsInitIndex); const pollAttemptIncrementIndex = content.indexOf(pollAttemptIncrement, pollLabelIndex); const pollNetstatCheckIndex = content.indexOf(pollNetstatCheck, pollAttemptIncrementIndex); const forceKillLabelIndex = content.indexOf(forceKillLabel, pollNetstatCheckIndex); const forceKillCommandIndex = content.indexOf(forceKillCommand, forceKillLabelIndex); const portReleasedLabelIndex = content.indexOf(portReleasedLabel, forceKillCommandIndex); const runIndex = content.indexOf(runCommand, portReleasedLabelIndex); expect(endIndex).toBeGreaterThanOrEqual(0); expect(attemptsInitIndex).toBeGreaterThan(endIndex); expect(pollLabelIndex).toBeGreaterThan(attemptsInitIndex); expect(pollAttemptIncrementIndex).toBeGreaterThan(pollLabelIndex); expect(pollNetstatCheckIndex).toBeGreaterThan(pollAttemptIncrementIndex); expect(forceKillLabelIndex).toBeGreaterThan(pollNetstatCheckIndex); expect(forceKillCommandIndex).toBeGreaterThan(forceKillLabelIndex); expect(portReleasedLabelIndex).toBeGreaterThan(forceKillCommandIndex); expect(runIndex).toBeGreaterThan(portReleasedLabelIndex); expect(content).not.toContain("timeout /t 3 /nobreak >nul"); } beforeEach(() => { vi.resetAllMocks(); }); afterEach(() => { Object.defineProperty(process, "platform", { value: originalPlatform }); process.getuid = originalGetUid; }); describe("prepareRestartScript", () => { it("creates a systemd restart script on Linux", async () => { Object.defineProperty(process, "platform", { value: "linux" }); const { scriptPath, content } = await prepareAndReadScript({ OPENCLAW_PROFILE: "default", }); expect(scriptPath.endsWith(".sh")).toBe(true); expect(content).toContain("#!/bin/sh"); expect(content).toContain("systemctl --user restart 'openclaw-gateway.service'"); // Script should self-cleanup expect(content).toContain('rm -f "$0"'); await cleanupScript(scriptPath); }); it("uses OPENCLAW_SYSTEMD_UNIT override for systemd scripts", async () => { Object.defineProperty(process, "platform", { value: "linux" }); const { scriptPath, content } = await prepareAndReadScript({ OPENCLAW_PROFILE: "default", OPENCLAW_SYSTEMD_UNIT: "custom-gateway", }); expect(content).toContain("systemctl --user restart 'custom-gateway.service'"); await cleanupScript(scriptPath); }); it("creates a launchd restart script on macOS", async () => { Object.defineProperty(process, "platform", { value: "darwin" }); process.getuid = () => 501; const { scriptPath, content } = await prepareAndReadScript({ OPENCLAW_PROFILE: "default", }); expect(scriptPath.endsWith(".sh")).toBe(true); expect(content).toContain("#!/bin/sh"); expect(content).toContain("launchctl kickstart -k 'gui/501/ai.openclaw.gateway'"); // Should clear disabled state and fall back to bootstrap when kickstart fails. expect(content).toContain("launchctl enable 'gui/501/ai.openclaw.gateway'"); expect(content).toContain("launchctl bootstrap 'gui/501'"); expect(content).toContain('rm -f "$0"'); await cleanupScript(scriptPath); }); it("uses OPENCLAW_LAUNCHD_LABEL override on macOS", async () => { Object.defineProperty(process, "platform", { value: "darwin" }); process.getuid = () => 501; const { scriptPath, content } = await prepareAndReadScript({ OPENCLAW_PROFILE: "default", OPENCLAW_LAUNCHD_LABEL: "com.custom.openclaw", }); expect(content).toContain("launchctl kickstart -k 'gui/501/com.custom.openclaw'"); await cleanupScript(scriptPath); }); it("creates a schtasks restart script on Windows", async () => { Object.defineProperty(process, "platform", { value: "win32" }); const { scriptPath, content } = await prepareAndReadScript({ OPENCLAW_PROFILE: "default", }); expect(scriptPath.endsWith(".bat")).toBe(true); expect(content).toContain("@echo off"); expect(content).toContain('schtasks /End /TN "OpenClaw Gateway"'); expect(content).toContain('schtasks /Run /TN "OpenClaw Gateway"'); expectWindowsRestartWaitOrdering(content); // Batch self-cleanup expect(content).toContain('del "%~f0"'); await cleanupScript(scriptPath); }); it("uses OPENCLAW_WINDOWS_TASK_NAME override on Windows", async () => { Object.defineProperty(process, "platform", { value: "win32" }); const { scriptPath, content } = await prepareAndReadScript({ OPENCLAW_PROFILE: "default", OPENCLAW_WINDOWS_TASK_NAME: "OpenClaw Gateway (custom)", }); expect(content).toContain('schtasks /End /TN "OpenClaw Gateway (custom)"'); expect(content).toContain('schtasks /Run /TN "OpenClaw Gateway (custom)"'); expectWindowsRestartWaitOrdering(content); await cleanupScript(scriptPath); }); it("uses passed gateway port for port polling on Windows", async () => { Object.defineProperty(process, "platform", { value: "win32" }); const customPort = 9999; const { scriptPath, content } = await prepareAndReadScript( { OPENCLAW_PROFILE: "default", }, customPort, ); expect(content).toContain(`netstat -ano | findstr /R /C:":${customPort} .*LISTENING" >nul`); expect(content).toContain( `for /f "tokens=5" %%P in ('netstat -ano ^| findstr /R /C:":${customPort} .*LISTENING"') do (`, ); expectWindowsRestartWaitOrdering(content, customPort); await cleanupScript(scriptPath); }); it("uses custom profile in service names", async () => { Object.defineProperty(process, "platform", { value: "linux" }); const { scriptPath, content } = await prepareAndReadScript({ OPENCLAW_PROFILE: "production", }); expect(content).toContain("openclaw-gateway-production.service"); await cleanupScript(scriptPath); }); it("uses custom profile in macOS launchd label", async () => { Object.defineProperty(process, "platform", { value: "darwin" }); process.getuid = () => 502; const { scriptPath, content } = await prepareAndReadScript({ OPENCLAW_PROFILE: "staging", }); expect(content).toContain("gui/502/ai.openclaw.staging"); await cleanupScript(scriptPath); }); it("uses custom profile in Windows task name", async () => { Object.defineProperty(process, "platform", { value: "win32" }); const { scriptPath, content } = await prepareAndReadScript({ OPENCLAW_PROFILE: "production", }); expect(content).toContain('schtasks /End /TN "OpenClaw Gateway (production)"'); expectWindowsRestartWaitOrdering(content); await cleanupScript(scriptPath); }); it("returns null for unsupported platforms", async () => { Object.defineProperty(process, "platform", { value: "aix" }); const scriptPath = await prepareRestartScript({}); expect(scriptPath).toBeNull(); }); it("returns null when script creation fails", async () => { Object.defineProperty(process, "platform", { value: "linux" }); const writeFileSpy = vi .spyOn(fs, "writeFile") .mockRejectedValueOnce(new Error("simulated write failure")); const scriptPath = await prepareRestartScript({ OPENCLAW_PROFILE: "default", }); expect(scriptPath).toBeNull(); writeFileSpy.mockRestore(); }); it("escapes single quotes in profile names for shell scripts", async () => { Object.defineProperty(process, "platform", { value: "linux" }); const { scriptPath, content } = await prepareAndReadScript({ OPENCLAW_PROFILE: "it's-a-test", }); // Single quotes should be escaped with '\'' pattern expect(content).not.toContain("it's"); expect(content).toContain("it'\\''s"); await cleanupScript(scriptPath); }); it("expands HOME in plist path instead of leaving literal $HOME", async () => { Object.defineProperty(process, "platform", { value: "darwin" }); process.getuid = () => 501; const { scriptPath, content } = await prepareAndReadScript({ HOME: "/Users/testuser", OPENCLAW_PROFILE: "default", }); // The plist path must contain the resolved home dir, not literal $HOME expect(content).toMatch(/[\\/]Users[\\/]testuser[\\/]Library[\\/]LaunchAgents[\\/]/); expect(content).not.toContain("$HOME"); await cleanupScript(scriptPath); }); it("prefers env parameter HOME over process.env.HOME for plist path", async () => { Object.defineProperty(process, "platform", { value: "darwin" }); process.getuid = () => 502; const { scriptPath, content } = await prepareAndReadScript({ HOME: "/Users/envhome", OPENCLAW_PROFILE: "default", }); expect(content).toMatch(/[\\/]Users[\\/]envhome[\\/]Library[\\/]LaunchAgents[\\/]/); await cleanupScript(scriptPath); }); it("shell-escapes the label in the plist path on macOS", async () => { Object.defineProperty(process, "platform", { value: "darwin" }); process.getuid = () => 501; const { scriptPath, content } = await prepareAndReadScript({ HOME: "/Users/testuser", OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.it's-a-test", }); // The plist path must also shell-escape the label to prevent injection expect(content).toContain("ai.openclaw.it'\\''s-a-test.plist"); await cleanupScript(scriptPath); }); it("rejects unsafe batch profile names on Windows", async () => { Object.defineProperty(process, "platform", { value: "win32" }); const scriptPath = await prepareRestartScript({ OPENCLAW_PROFILE: "test&whoami", }); expect(scriptPath).toBeNull(); }); }); describe("runRestartScript", () => { it("spawns the script as a detached process on Linux", async () => { Object.defineProperty(process, "platform", { value: "linux" }); const scriptPath = "/tmp/fake-script.sh"; const mockChild = { unref: vi.fn() }; vi.mocked(spawn).mockReturnValue(mockChild as unknown as ChildProcess); await runRestartScript(scriptPath); expect(spawn).toHaveBeenCalledWith("/bin/sh", [scriptPath], { detached: true, stdio: "ignore", windowsHide: true, }); expect(mockChild.unref).toHaveBeenCalled(); }); it("uses cmd.exe on Windows", async () => { Object.defineProperty(process, "platform", { value: "win32" }); const scriptPath = "C:\\Temp\\fake-script.bat"; const mockChild = { unref: vi.fn() }; vi.mocked(spawn).mockReturnValue(mockChild as unknown as ChildProcess); await runRestartScript(scriptPath); expect(spawn).toHaveBeenCalledWith("cmd.exe", ["/d", "/s", "/c", scriptPath], { detached: true, stdio: "ignore", windowsHide: true, }); expect(mockChild.unref).toHaveBeenCalled(); }); it("quotes cmd.exe /c paths with metacharacters on Windows", async () => { Object.defineProperty(process, "platform", { value: "win32" }); const scriptPath = "C:\\Temp\\me&(ow)\\fake-script.bat"; const mockChild = { unref: vi.fn() }; vi.mocked(spawn).mockReturnValue(mockChild as unknown as ChildProcess); await runRestartScript(scriptPath); expect(spawn).toHaveBeenCalledWith("cmd.exe", ["/d", "/s", "/c", `"${scriptPath}"`], { detached: true, stdio: "ignore", windowsHide: true, }); }); }); });