openclaw / src /cli /update-cli /restart-helper.windows.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
eb3f11e verified
Raw History Blame Contribute Delete
12.4 kB
import { execFile } from "node:child_process";
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { prepareRestartScript, runRestartScript } from "./restart-helper.js";
const windowsKillPolicyStartMarker = "# OPENCLAW_RESTART_KILL_POLICY_BEGIN";
const windowsKillPolicyEndMarker = "# OPENCLAW_RESTART_KILL_POLICY_END";
function findPowerShell(): string | null {
const executables = process.platform === "win32" ? ["powershell.exe", "pwsh.exe"] : ["pwsh"];
const candidates = [
process.env.OPENCLAW_TEST_PWSH,
...executables.flatMap((executable) =>
(process.env.PATH ?? "")
.split(path.delimiter)
.filter(Boolean)
.map((dir) => path.join(dir, executable)),
),
];
return (
candidates.find((candidate): candidate is string =>
Boolean(candidate && existsSync(candidate)),
) ?? null
);
}
const powerShellPath = findPowerShell();
const itWithPowerShell = powerShellPath ? it : it.skip;
async function prepareWindowsScript(gatewayArgv: readonly string[] = []): Promise<string> {
const originalPlatform = process.platform;
try {
Object.defineProperty(process, "platform", { value: "win32" });
const scriptPath = await prepareRestartScript({}, 18789, gatewayArgv);
if (!scriptPath) {
throw new Error("expected a standalone restart script");
}
return scriptPath;
} finally {
Object.defineProperty(process, "platform", { value: originalPlatform });
}
}
function extractWindowsKillPolicy(content: string): string {
const start = content.indexOf(windowsKillPolicyStartMarker);
const end = content.indexOf(windowsKillPolicyEndMarker, start);
if (start < 0 || end < 0) {
throw new Error("Windows restart kill-policy markers missing");
}
return content.slice(start + windowsKillPolicyStartMarker.length, end);
}
async function executeWindowsKillPolicy(content: string, testBody: string) {
if (!powerShellPath) {
throw new Error("PowerShell is unavailable");
}
const policy = extractWindowsKillPolicy(content);
const input = [
"& {",
'$ErrorActionPreference = "Stop"',
"$script:RestartLogs = [Collections.Generic.List[string]]::new()",
"function Write-RestartLog { param([string]$Message) $script:RestartLogs.Add($Message) | Out-Null }",
policy,
testBody,
"}\n\n",
].join("\n");
return await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
const child = execFile(
powerShellPath,
["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "-"],
{ env: process.env },
(error, stdout, stderr) => {
if (error) {
reject(new Error(`PowerShell policy test failed: ${stderr || stdout}`, { cause: error }));
return;
}
resolve({ stdout, stderr });
},
);
child.stdin?.end(input);
});
}
itWithPowerShell(
"executes the shipped PowerShell kill policy with mocked Windows facts",
async () => {
const scriptPath = await prepareWindowsScript([
"node",
"C:\\openclaw\\dist\\entry.js",
"gateway",
"--port",
"18789",
]);
try {
const content = await fs.readFile(scriptPath.replace(/\.cmd$/u, ".ps1"), "utf8");
const result = await executeWindowsKillPolicy(
content,
String.raw`
function Assert-True {
param([bool]$Condition, [string]$Message)
if (-not $Condition) { throw $Message }
}
function Assert-DecisionLog {
param([string]$Decision)
$match = @($script:RestartLogs | Where-Object { $_ -like "*decision=$Decision*" })
Assert-True ($match.Count -gt 0) "missing decision log: $Decision"
}
$commandLines = @(
@{ Line = '"C:\Program Files\node.exe" "C:\openclaw\entry.js" gateway'; Expected = @('C:\Program Files\node.exe', 'C:\openclaw\entry.js', 'gateway') },
@{ Line = 'node "" "a b" C:\plain\path'; Expected = @('node', '', 'a b', 'C:\plain\path') },
@{ Line = 'node a\\\b d"e f"g h'; Expected = @('node', 'a\\\b', 'de fg', 'h') },
@{ Line = 'node a\\\"b c d'; Expected = @('node', 'a\"b', 'c', 'd') },
@{ Line = 'node a\\\\"b c" d e'; Expected = @('node', 'a\\b c', 'd', 'e') },
@{ Line = 'node "a""b"'; Expected = @('node', 'a"b') },
@{ Line = 'node a"""b'; Expected = @('node', 'a"b') },
@{ Line = 'node a""""b'; Expected = @('node', 'a"b') },
@{ Line = 'node "C:\trailing\\"'; Expected = @('node', 'C:\trailing\') },
@{ Line = ('node' + [char]9 + 'first' + [char]9 + 'second'); Expected = @('node', 'first', 'second') }
)
foreach ($case in $commandLines) {
$actual = @(Split-OpenClawWindowsCommandLine -CommandLine $case.Line)
Assert-True ($actual.Count -eq $case.Expected.Count) "argument count mismatch: $($case.Line)"
for ($index = 0; $index -lt $actual.Count; $index++) {
Assert-True ([string]::Equals($actual[$index], $case.Expected[$index], [StringComparison]::Ordinal)) "argument mismatch: $($case.Line) at $index"
}
}
function New-ProcessFacts {
param([int]$ProcessId, [string]$CreationTime, [string[]]$Argv)
return [pscustomobject]@{
ProcessId = $ProcessId
CreationTimeFileTime = $CreationTime
Argv = $Argv
}
}
function New-TestLease {
param([string]$CreationTime)
$lease = [pscustomobject]@{
CreatedAt = [datetime]::FromFileTimeUtc([long]$CreationTime)
Held = $false
Terminated = $false
Disposed = $false
}
$lease | Add-Member -MemberType ScriptProperty -Name Handle -Value {
$this.Held = $true
return 1
}
$lease | Add-Member -MemberType ScriptProperty -Name StartTime -Value {
if (-not $this.Held) { throw "identity read without a retained process handle" }
return $this.CreatedAt
}
$lease | Add-Member -MemberType ScriptMethod -Name Kill -Value {
if (-not $this.Held) { throw "kill without a retained process handle" }
$this.Terminated = $true
}
$lease | Add-Member -MemberType ScriptMethod -Name Dispose -Value {
$this.Disposed = $true
}
return $lease
}
function Invoke-MockedKill {
param($Observed, $Rechecked, $Listeners, $Lease, [string[]]$ExpectedArgv)
$script:MockObserved = $Observed
$script:MockRechecked = $Rechecked
$script:MockListeners = $Listeners
$script:MockLease = $Lease
$script:ProcessQueryCalls = 0
$script:ListenerQueryCalls = 0
$script:ProcessOpenCalls = 0
$processQuery = {
param([int]$IgnoredPid)
$script:ProcessQueryCalls += 1
if ($script:ProcessQueryCalls -eq 1) { return $script:MockObserved }
return $script:MockRechecked
}
$listenerQuery = {
param([int]$IgnoredPort)
$script:ListenerQueryCalls += 1
return $script:MockListeners
}
$processOpen = {
param([int]$IgnoredPid)
$script:ProcessOpenCalls += 1
return $script:MockLease
}
Invoke-OpenClawVerifiedListenerKill -ProcessId 4242 -Port 18789 -ExpectedArgv $ExpectedArgv -ProcessQuery $processQuery -ListenerQuery $listenerQuery -ProcessOpen $processOpen
}
# Get-NetTCPConnection exposes object properties, including duplicate IPv4/IPv6 rows.
function Get-NetTCPConnection {
param($State, $ErrorAction)
@(
[pscustomobject]@{ LocalPort = 18789; OwningProcess = 4242 },
[pscustomobject]@{ LocalPort = 18789; OwningProcess = 4242 },
[pscustomobject]@{ LocalPort = 443; OwningProcess = 5252 }
)
}
$snapshot = Get-OpenClawListenerSnapshot -Port 18789
Assert-True $snapshot.Known "Get-NetTCPConnection snapshot should be known"
Assert-True (@($snapshot.Pids).Count -eq 1) "duplicate listener PIDs should collapse"
Assert-True (@($snapshot.Pids)[0] -eq 4242) "wrong Get-NetTCPConnection PID"
# Force the locale-independent netstat fallback. Localized state text is ignored.
function Get-NetTCPConnection { param($State, $ErrorAction) throw "unavailable" }
function netstat.exe {
$script:LASTEXITCODE = 0
@(
" TCP 0.0.0.0:18789 0.0.0.0:0 LISTENING 4242",
" TCP [::]:18789 [::]:0 ABHÖREN 4242",
" TCP 127.0.0.1:18789 127.0.0.1:61234 HERGESTELLT 5252"
)
}
$snapshot = Get-OpenClawListenerSnapshot -Port 18789
Assert-True $snapshot.Known "netstat snapshot should be known"
Assert-True (@($snapshot.Pids).Count -eq 1) "netstat IPv4/IPv6 PIDs should collapse"
Assert-True (@($snapshot.Pids)[0] -eq 4242) "wrong netstat PID"
function netstat.exe { $script:LASTEXITCODE = 1 }
$snapshot = Get-OpenClawListenerSnapshot -Port 18789
Assert-True (-not $snapshot.Known) "failed listener queries must remain unknown"
$creation = "133987654321000000"
$expected = @("node", "C:\openclaw\dist\entry.js", "gateway", "--port", "18789")
$managed = New-ProcessFacts 4242 $creation @("node.exe", "C:\openclaw\dist\entry.js", "gateway", "--port", "18789")
$knownListener = [pscustomobject]@{ Known = $true; Pids = @(4242) }
$script:RestartLogs.Clear()
$lease = New-TestLease $creation
Invoke-MockedKill $managed $managed $knownListener $lease $expected
Assert-True $lease.Terminated "managed stale listener was not killed"
Assert-True $lease.Disposed "managed listener handle was not disposed"
Assert-True ($script:ProcessQueryCalls -eq 2) "managed process was not rechecked"
Assert-True ($script:ListenerQueryCalls -eq 1) "managed listener was not rechecked"
$script:RestartLogs.Clear()
$foreign = New-ProcessFacts 4242 $creation @("python.exe", "foreign-listener.py")
$lease = New-TestLease $creation
Invoke-MockedKill $foreign $foreign $knownListener $lease $expected
Assert-True (-not $lease.Terminated) "foreign listener was killed"
Assert-True ($script:ProcessOpenCalls -eq 0) "foreign listener opened a terminate handle"
Assert-DecisionLog "command-mismatch"
$script:RestartLogs.Clear()
$lease = New-TestLease $creation
Invoke-MockedKill $null $null $knownListener $lease $expected
Assert-True (-not $lease.Terminated) "unknown owner was killed"
Assert-True ($script:ProcessOpenCalls -eq 0) "unknown owner opened a terminate handle"
Assert-DecisionLog "process-unavailable"
$script:RestartLogs.Clear()
$lease = New-TestLease $creation
$unknownListeners = [pscustomobject]@{ Known = $false; Pids = @() }
Invoke-MockedKill $managed $managed $unknownListeners $lease $expected
Assert-True (-not $lease.Terminated) "unknown listener state was killed"
Assert-True $lease.Disposed "unknown listener handle was not disposed"
Assert-DecisionLog "listener-query-unavailable"
$script:RestartLogs.Clear()
$recycledLease = New-TestLease "133987654399000000"
Invoke-MockedKill $managed $managed $knownListener $recycledLease $expected
Assert-True (-not $recycledLease.Terminated) "recycled PID target was killed"
Assert-True $recycledLease.Disposed "recycled PID handle was not disposed"
Assert-DecisionLog "process-replaced"
Write-Output "OPENCLAW_RESTART_POLICY_OK"
`,
);
expect(result.stderr).toBe("");
expect(result.stdout).toContain("OPENCLAW_RESTART_POLICY_OK");
} finally {
await fs.rm(path.dirname(scriptPath), { recursive: true, force: true });
}
},
);
describe.runIf(process.platform === "win32")("Windows restart wrapper", () => {
it.each([
{
name: "completed restart",
body: '& { [Console]::Out.WriteLine("OPENCLAW_RESTART_COMPLETE"); exit 0 }\n\n',
accepted: true,
},
{
name: "failed restart with a completion marker",
body: '& { [Console]::Out.WriteLine("OPENCLAW_RESTART_COMPLETE"); exit 7 }\n\n',
accepted: false,
},
{
name: "malformed input",
body: '& { [Console]::Out.WriteLine("OPENCLAW_RESTART_COMPLETE"); this is ( }\n\n',
accepted: false,
},
{
name: "incomplete input",
body: '& { [Console]::Out.WriteLine("OPENCLAW_RESTART_COMPLETE"); exit 0\n',
accepted: false,
},
])("reports the native outcome for $name", async ({ body, accepted }) => {
const scriptPath = await prepareWindowsScript();
try {
await fs.writeFile(scriptPath.replace(/\.cmd$/u, ".ps1"), body);
// A leftover outcome cannot authorize a new invocation that never ran.
await fs.writeFile(scriptPath.replace(/\.cmd$/u, ".out"), "OPENCLAW_RESTART_COMPLETE\r\n");
await expect(runRestartScript(scriptPath, 10_000)).resolves.toBe(accepted);
expect(existsSync(path.dirname(scriptPath))).toBe(false);
} finally {
await fs.rm(path.dirname(scriptPath), { recursive: true, force: true });
}
});
});