import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { isDirectScriptExecution, resolvePnpmSpawnCall, resolveSpawnCall, shouldUseCmdExeForCommand, } from "../../scripts/ui.js"; async function waitFor(predicate: () => boolean, label: string, timeoutMs = 3_000): Promise { const startedAt = Date.now(); while (!predicate()) { if (Date.now() - startedAt > timeoutMs) { throw new Error(`timed out waiting for ${label}`); } await new Promise((resolve) => { setTimeout(resolve, 25); }); } } async function waitForExit( child: ChildProcess, timeoutMs = 3_000, ): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { return await new Promise((resolve, reject) => { const timer = setTimeout(() => { child.kill("SIGKILL"); reject(new Error("timed out waiting for child exit")); }, timeoutMs); child.once("exit", (code, signal) => { clearTimeout(timer); resolve({ code, signal }); }); child.once("error", (error) => { clearTimeout(timer); reject(error); }); }); } describe("scripts/ui windows spawn behavior", () => { it("wraps Windows command launchers with cmd.exe without enabling shell mode", () => { expect( shouldUseCmdExeForCommand("C:\\Users\\dev\\AppData\\Local\\pnpm\\pnpm.CMD", "win32"), ).toBe(true); expect( resolveSpawnCall( "C:\\Program Files\\nodejs\\pnpm.cmd", ["run", "build", "-t", "path with spaces"], { PATH: "C:\\bin" }, { comSpec: "C:\\Windows\\System32\\cmd.exe", cwd: "C:\\repo\\ui", platform: "win32" }, ), ).toEqual({ command: "C:\\Windows\\System32\\cmd.exe", args: [ "/d", "/s", "/c", '""C:\\Program Files\\nodejs\\pnpm.cmd" run build -t "path with spaces""', ], options: { cwd: "C:\\repo\\ui", stdio: "inherit", env: { PATH: "C:\\bin" }, shell: false, windowsVerbatimArguments: true, }, }); }); it("does not use cmd.exe for non-command launchers", () => { expect(shouldUseCmdExeForCommand("C:\\Program Files\\nodejs\\node.exe", "win32")).toBe(false); expect(shouldUseCmdExeForCommand("C:\\tools\\pnpm.com", "win32")).toBe(false); expect(shouldUseCmdExeForCommand("/usr/local/bin/pnpm", "linux")).toBe(false); expect( resolveSpawnCall( "C:\\Program Files\\nodejs\\pnpm.exe", ["run", "build"], { PATH: "C:\\bin" }, { cwd: "C:\\repo\\ui", platform: "win32" }, ), ).toEqual({ command: "C:\\Program Files\\nodejs\\pnpm.exe", args: ["run", "build"], options: { cwd: "C:\\repo\\ui", stdio: "inherit", env: { PATH: "C:\\bin" }, shell: false, }, }); }); it("rejects unsafe cmd.exe arguments before launch", () => { expect(() => resolveSpawnCall("C:\\tools\\pnpm.cmd", ["run", "build", "evil&calc"], undefined, { platform: "win32", }), ).toThrow(/unsafe windows cmd\.exe argument/i); expect(() => resolveSpawnCall("C:\\tools\\pnpm.cmd", ["run", "build", "%PATH%"], undefined, { platform: "win32", }), ).toThrow(/unsafe windows cmd\.exe argument/i); }); it("routes Windows Corepack pnpm entrypoints through node", () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pnpm-runner-")); const npmExecPath = path.join(tempDir, "pnpm.mjs"); fs.writeFileSync(npmExecPath, "console.log('pnpm');\n"); try { expect( resolvePnpmSpawnCall( ["run", "build"], { npm_execpath: npmExecPath, ComSpec: "C:\\Windows\\System32\\cmd.exe", }, { cwd: "C:\\repo\\ui", nodeExecPath: "C:\\Program Files\\nodejs\\node.exe", platform: "win32", }, ), ).toEqual({ command: "C:\\Program Files\\nodejs\\node.exe", args: [npmExecPath, "run", "build"], options: { cwd: "C:\\repo\\ui", stdio: "inherit", env: { npm_execpath: npmExecPath, ComSpec: "C:\\Windows\\System32\\cmd.exe", }, shell: false, windowsVerbatimArguments: undefined, }, }); } finally { fs.rmSync(tempDir, { force: true, recursive: true }); } }); it("keeps non-Windows launches direct even with shell metacharacters", () => { expect( resolveSpawnCall( "/usr/local/bin/pnpm", ["run", "build", "contains&metacharacters"], { PATH: "/bin" }, { cwd: "/repo/ui", platform: "linux" }, ), ).toEqual({ command: "/usr/local/bin/pnpm", args: ["run", "build", "contains&metacharacters"], options: { cwd: "/repo/ui", stdio: "inherit", env: { PATH: "/bin" }, shell: false, }, }); }); it("detects direct execution through a junctioned script path", () => { const realScriptPath = path.resolve("repo/openclaw/scripts/ui.js"); const junctionScriptPath = path.resolve("linked/openclaw/scripts/ui.js"); const realpath = (entry: string) => (entry === junctionScriptPath ? realScriptPath : entry); expect(isDirectScriptExecution(junctionScriptPath, realScriptPath, realpath)).toBe(true); }); it("honors build-all no-pnpm mode before requiring a pnpm runner", () => { const result = spawnSync(process.execPath, ["scripts/ui.js", "build", "--help"], { cwd: path.resolve("."), encoding: "utf8", env: { ...process.env, OPENCLAW_BUILD_ALL_NO_PNPM: "1", PATH: "", }, }); const output = `${result.stdout}${result.stderr}`; expect(result.status).toBe(0); expect(output).not.toContain("Missing UI runner"); expect(output).toContain("vite"); }); it.runIf(process.platform !== "win32")( "terminates the pnpm child on wrapper SIGTERM", async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-ui-wrapper-signals-")); const runnerPath = path.join(tempDir, "pnpm.mjs"); const readyFile = path.join(tempDir, "ready"); const signaledFile = path.join(tempDir, "signaled"); fs.writeFileSync( runnerPath, [ "import fs from 'node:fs';", "process.on('SIGTERM', () => {", " fs.writeFileSync(process.env.SIGNALED_FILE, 'SIGTERM');", " setTimeout(() => process.exit(0), 25);", "});", "fs.writeFileSync(process.env.READY_FILE, process.argv.slice(2).join(' '));", "setInterval(() => {}, 1000);", ].join("\n"), ); const wrapper = spawn(process.execPath, ["scripts/ui.js", "install"], { cwd: path.resolve("."), env: { ...process.env, npm_execpath: runnerPath, READY_FILE: readyFile, SIGNALED_FILE: signaledFile, }, stdio: "ignore", }); try { await waitFor(() => fs.existsSync(readyFile), "UI runner readiness"); expect(fs.readFileSync(readyFile, "utf8")).toBe("install"); wrapper.kill("SIGTERM"); const exit = await waitForExit(wrapper); expect(exit).toEqual({ code: null, signal: "SIGTERM" }); expect(fs.readFileSync(signaledFile, "utf8")).toBe("SIGTERM"); } finally { wrapper.kill("SIGKILL"); fs.rmSync(tempDir, { force: true, recursive: true }); } }, ); });