import { spawnSync } from "node:child_process"; import { chmodSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { createScriptTestHarness } from "./test-helpers"; const SCRIPT_PATH = "scripts/install.ps1"; function extractFunctionBody(source: string, name: string): string { const match = source.match( new RegExp(`^function ${name} \\{\\r?\\n([\\s\\S]*?)^\\}\\r?\\n`, "m"), ); expect(match?.[1]).toBeDefined(); return match![1]; } function findPowerShell(): string | undefined { for (const candidate of ["pwsh", "powershell"]) { const result = spawnSync( candidate, ["-NoLogo", "-NoProfile", "-Command", "$PSVersionTable.PSVersion"], { encoding: "utf8", }, ); if (result.status === 0) { return candidate; } } return undefined; } function toPowerShellSingleQuotedLiteral(value: string): string { return `'${value.replaceAll("'", "''")}'`; } function createFailingNodeFixture(source: string): string { const scriptWithoutEntryPoint = source.replace( /\r?\n\$installSucceeded = Main\r?\nComplete-Install -Succeeded:\$installSucceeded\s*$/m, "", ); expect(scriptWithoutEntryPoint).not.toBe(source); return [ scriptWithoutEntryPoint, "", "function Write-Banner { }", "function Ensure-ExecutionPolicy { return $true }", "function Ensure-Node { return $false }", "", "$installSucceeded = Main", "Complete-Install -Succeeded:$installSucceeded", "", ].join("\n"); } describe("install.ps1 failure handling", () => { const harness = createScriptTestHarness(); const source = readFileSync(SCRIPT_PATH, "utf8"); const powershell = findPowerShell(); const runIfPowerShell = powershell ? it : it.skip; it("does not exit directly from inside Main", () => { const mainBody = extractFunctionBody(source, "Main"); expect(mainBody).not.toMatch(/\bexit\b/i); expect(mainBody).toContain("return (Fail-Install)"); }); it("keeps failure termination in the top-level completion handler", () => { const completeInstallBody = extractFunctionBody(source, "Complete-Install"); expect(completeInstallBody).toMatch(/\$PSCommandPath/); expect(completeInstallBody).toMatch(/\bexit \$script:InstallExitCode\b/); expect(completeInstallBody).toMatch(/\bthrow "OpenClaw installation failed with exit code/); }); runIfPowerShell("exits non-zero when run as a script file", () => { const tempDir = harness.createTempDir("openclaw-install-ps1-"); const scriptPath = join(tempDir, "install.ps1"); writeFileSync(scriptPath, createFailingNodeFixture(source)); chmodSync(scriptPath, 0o755); const result = spawnSync( powershell!, ["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath], { encoding: "utf8" }, ); expect(result.status).toBe(1); }); runIfPowerShell("throws without killing the caller when run as a scriptblock", () => { const tempDir = harness.createTempDir("openclaw-install-ps1-"); const scriptPath = join(tempDir, "install.ps1"); writeFileSync(scriptPath, createFailingNodeFixture(source)); chmodSync(scriptPath, 0o755); const command = [ "try {", ` & ([scriptblock]::Create((Get-Content -LiteralPath ${toPowerShellSingleQuotedLiteral(scriptPath)} -Raw)))`, "} catch {", ' Write-Output "caught=$($_.Exception.Message)"', "}", 'Write-Output "alive-after-install"', ].join("\n"); const result = spawnSync(powershell!, ["-NoLogo", "-NoProfile", "-Command", command], { encoding: "utf8", }); expect(result.status).toBe(0); expect(result.stdout).toContain("caught=OpenClaw installation failed with exit code 1."); expect(result.stdout).toContain("alive-after-install"); }); runIfPowerShell("keeps npm chatter out of Main's success return value", () => { const tempDir = harness.createTempDir("openclaw-install-ps1-"); const scriptPath = join(tempDir, "install.ps1"); const scriptWithoutEntryPoint = source.replace( /\r?\n\$installSucceeded = Main\r?\nComplete-Install -Succeeded:\$installSucceeded\s*$/m, "", ); writeFileSync( scriptPath, [ scriptWithoutEntryPoint, "", "function Write-Banner { }", "function Ensure-ExecutionPolicy { return $true }", "function Ensure-Node { return $true }", "function Add-ToPath { param([string]$Path) }", "function Invoke-NativeCommandCapture {", " param([string]$FilePath, [string[]]$Arguments)", " return @{ ExitCode = 0; Stdout = 'npm stdout'; Stderr = 'npm stderr' }", "}", "$NoOnboard = $true", "$result = Main", "if ($result -is [array]) { throw 'Main returned an array' }", 'if ($result -ne $true) { throw "Main returned $result" }', "", ].join("\n"), ); chmodSync(scriptPath, 0o755); const result = spawnSync( powershell!, ["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath], { encoding: "utf8" }, ); expect(result.status).toBe(0); expect(result.stderr).toBe(""); }); });