| import fs from "node:fs/promises"; |
| import path from "node:path"; |
| import { PassThrough } from "node:stream"; |
| import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| import { quoteCmdScriptArg } from "./cmd-argv.js"; |
| import "./test-helpers/schtasks-base-mocks.js"; |
| import { |
| inspectPortUsage, |
| killProcessTree, |
| resetSchtasksBaseMocks, |
| schtasksResponses, |
| withWindowsEnv, |
| writeGatewayScript, |
| } from "./test-helpers/schtasks-fixtures.js"; |
| const childUnref = vi.hoisted(() => vi.fn()); |
| const spawn = vi.hoisted(() => vi.fn(() => ({ unref: childUnref }))); |
|
|
| vi.mock("node:child_process", async (importOriginal) => { |
| const actual = await importOriginal<typeof import("node:child_process")>(); |
| return { |
| ...actual, |
| spawn, |
| }; |
| }); |
|
|
| const { |
| installScheduledTask, |
| isScheduledTaskInstalled, |
| readScheduledTaskRuntime, |
| restartScheduledTask, |
| resolveTaskScriptPath, |
| stopScheduledTask, |
| } = await import("./schtasks.js"); |
|
|
| function resolveStartupEntryPath(env: Record<string, string>) { |
| return path.join( |
| env.APPDATA, |
| "Microsoft", |
| "Windows", |
| "Start Menu", |
| "Programs", |
| "Startup", |
| "OpenClaw Gateway.cmd", |
| ); |
| } |
|
|
| async function writeStartupFallbackEntry(env: Record<string, string>) { |
| const startupEntryPath = resolveStartupEntryPath(env); |
| await fs.mkdir(path.dirname(startupEntryPath), { recursive: true }); |
| await fs.writeFile(startupEntryPath, "@echo off\r\n", "utf8"); |
| return startupEntryPath; |
| } |
|
|
| function expectStartupFallbackSpawn(env: Record<string, string>) { |
| expect(spawn).toHaveBeenCalledWith( |
| "cmd.exe", |
| ["/d", "/s", "/c", quoteCmdScriptArg(resolveTaskScriptPath(env))], |
| expect.objectContaining({ detached: true, stdio: "ignore", windowsHide: true }), |
| ); |
| } |
|
|
| function addStartupFallbackMissingResponses( |
| extraResponses: Array<{ code: number; stdout: string; stderr: string }> = [], |
| ) { |
| schtasksResponses.push( |
| { code: 0, stdout: "", stderr: "" }, |
| { code: 1, stdout: "", stderr: "not found" }, |
| ...extraResponses, |
| ); |
| } |
| beforeEach(() => { |
| resetSchtasksBaseMocks(); |
| spawn.mockClear(); |
| childUnref.mockClear(); |
| }); |
|
|
| afterEach(() => { |
| vi.restoreAllMocks(); |
| }); |
|
|
| describe("Windows startup fallback", () => { |
| it("falls back to a Startup-folder launcher when schtasks create is denied", async () => { |
| await withWindowsEnv("openclaw-win-startup-", async ({ env }) => { |
| schtasksResponses.push( |
| { code: 0, stdout: "", stderr: "" }, |
| { code: 5, stdout: "", stderr: "ERROR: Access is denied." }, |
| ); |
|
|
| const stdout = new PassThrough(); |
| let printed = ""; |
| stdout.on("data", (chunk) => { |
| printed += String(chunk); |
| }); |
|
|
| const result = await installScheduledTask({ |
| env, |
| stdout, |
| programArguments: ["node", "gateway.js", "--port", "18789"], |
| environment: { OPENCLAW_GATEWAY_PORT: "18789" }, |
| }); |
|
|
| const startupEntryPath = resolveStartupEntryPath(env); |
| const startupScript = await fs.readFile(startupEntryPath, "utf8"); |
| expect(result.scriptPath).toBe(resolveTaskScriptPath(env)); |
| expect(startupScript).toContain('start "" /min cmd.exe /d /c'); |
| expect(startupScript).toContain("gateway.cmd"); |
| expect(spawn).toHaveBeenCalledWith( |
| "cmd.exe", |
| ["/d", "/s", "/c", quoteCmdScriptArg(resolveTaskScriptPath(env))], |
| expect.objectContaining({ detached: true, stdio: "ignore", windowsHide: true }), |
| ); |
| expect(childUnref).toHaveBeenCalled(); |
| expect(printed).toContain("Installed Windows login item"); |
| }); |
| }); |
|
|
| it("falls back to a Startup-folder launcher when schtasks create hangs", async () => { |
| await withWindowsEnv("openclaw-win-startup-", async ({ env }) => { |
| schtasksResponses.push( |
| { code: 0, stdout: "", stderr: "" }, |
| { code: 124, stdout: "", stderr: "schtasks timed out after 15000ms" }, |
| ); |
|
|
| const stdout = new PassThrough(); |
| await installScheduledTask({ |
| env, |
| stdout, |
| programArguments: ["node", "gateway.js", "--port", "18789"], |
| environment: { OPENCLAW_GATEWAY_PORT: "18789" }, |
| }); |
|
|
| await expect(fs.access(resolveStartupEntryPath(env))).resolves.toBeUndefined(); |
| expectStartupFallbackSpawn(env); |
| }); |
| }); |
|
|
| it("treats an installed Startup-folder launcher as loaded", async () => { |
| await withWindowsEnv("openclaw-win-startup-", async ({ env }) => { |
| addStartupFallbackMissingResponses(); |
| await writeStartupFallbackEntry(env); |
|
|
| await expect(isScheduledTaskInstalled({ env })).resolves.toBe(true); |
| }); |
| }); |
|
|
| it("reports runtime from the gateway listener when using the Startup fallback", async () => { |
| await withWindowsEnv("openclaw-win-startup-", async ({ env }) => { |
| addStartupFallbackMissingResponses(); |
| await writeStartupFallbackEntry(env); |
| inspectPortUsage.mockResolvedValue({ |
| port: 18789, |
| status: "busy", |
| listeners: [{ pid: 4242, command: "node.exe" }], |
| hints: [], |
| }); |
|
|
| await expect(readScheduledTaskRuntime(env)).resolves.toMatchObject({ |
| status: "running", |
| pid: 4242, |
| }); |
| }); |
| }); |
|
|
| it("restarts the Startup fallback by killing the current pid and relaunching the entry", async () => { |
| await withWindowsEnv("openclaw-win-startup-", async ({ env }) => { |
| addStartupFallbackMissingResponses([ |
| { code: 0, stdout: "", stderr: "" }, |
| { code: 1, stdout: "", stderr: "not found" }, |
| ]); |
| await writeStartupFallbackEntry(env); |
| inspectPortUsage.mockResolvedValue({ |
| port: 18789, |
| status: "busy", |
| listeners: [{ pid: 5151, command: "node.exe" }], |
| hints: [], |
| }); |
|
|
| const stdout = new PassThrough(); |
| await expect(restartScheduledTask({ env, stdout })).resolves.toEqual({ |
| outcome: "completed", |
| }); |
| expect(killProcessTree).toHaveBeenCalledWith(5151, { graceMs: 300 }); |
| expectStartupFallbackSpawn(env); |
| }); |
| }); |
|
|
| it("kills the Startup fallback runtime even when the CLI env omits the gateway port", async () => { |
| await withWindowsEnv("openclaw-win-startup-", async ({ env }) => { |
| schtasksResponses.push({ code: 0, stdout: "", stderr: "" }); |
| await writeGatewayScript(env); |
| await writeStartupFallbackEntry(env); |
| inspectPortUsage |
| .mockResolvedValueOnce({ |
| port: 18789, |
| status: "busy", |
| listeners: [{ pid: 5151, command: "node.exe" }], |
| hints: [], |
| }) |
| .mockResolvedValueOnce({ |
| port: 18789, |
| status: "busy", |
| listeners: [{ pid: 5151, command: "node.exe" }], |
| hints: [], |
| }) |
| .mockResolvedValueOnce({ |
| port: 18789, |
| status: "free", |
| listeners: [], |
| hints: [], |
| }); |
|
|
| const stdout = new PassThrough(); |
| const envWithoutPort = { ...env }; |
| delete envWithoutPort.OPENCLAW_GATEWAY_PORT; |
| await stopScheduledTask({ env: envWithoutPort, stdout }); |
|
|
| expect(killProcessTree).toHaveBeenCalledWith(5151, { graceMs: 300 }); |
| }); |
| }); |
| }); |
|
|