import { execFileSync, spawnSync } from "node:child_process"; import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; const WRAPPERS = { linux: "scripts/e2e/parallels-linux-smoke.sh", macos: "scripts/e2e/parallels-macos-smoke.sh", npmUpdate: "scripts/e2e/parallels-npm-update-smoke.sh", windows: "scripts/e2e/parallels-windows-smoke.sh", }; const TS_PATHS = { agentWorkspace: "scripts/e2e/parallels/agent-workspace.ts", common: "scripts/e2e/parallels/common.ts", guestTransports: "scripts/e2e/parallels/guest-transports.ts", hostCommand: "scripts/e2e/parallels/host-command.ts", hostServer: "scripts/e2e/parallels/host-server.ts", laneRunner: "scripts/e2e/parallels/lane-runner.ts", linux: "scripts/e2e/parallels/linux-smoke.ts", macosDiscord: "scripts/e2e/parallels/macos-discord.ts", macos: "scripts/e2e/parallels/macos-smoke.ts", npmUpdateScripts: "scripts/e2e/parallels/npm-update-scripts.ts", npmUpdate: "scripts/e2e/parallels/npm-update-smoke.ts", packageArtifact: "scripts/e2e/parallels/package-artifact.ts", parallelsVm: "scripts/e2e/parallels/parallels-vm.ts", phaseRunner: "scripts/e2e/parallels/phase-runner.ts", powershell: "scripts/e2e/parallels/powershell.ts", providerAuth: "scripts/e2e/parallels/provider-auth.ts", snapshots: "scripts/e2e/parallels/snapshots.ts", windows: "scripts/e2e/parallels/windows-smoke.ts", windowsGit: "scripts/e2e/parallels/windows-git.ts", }; const OS_TS_PATHS = [TS_PATHS.linux, TS_PATHS.macos, TS_PATHS.windows]; function runTsEval(source: string, env: Record = {}) { return execFileSync("node", ["--import", "tsx", "--input-type=module", "--eval", source], { encoding: "utf8", env: { ...process.env, ...env }, }); } function resolveProviderAuth( provider: string, options: { apiKeyEnv?: string; env?: Record; modelId?: string; } = {}, ) { const source = ` import { resolveProviderAuth } from "./${TS_PATHS.common}"; const result = resolveProviderAuth({ provider: ${JSON.stringify(provider)}, apiKeyEnv: ${JSON.stringify(options.apiKeyEnv)}, modelId: ${JSON.stringify(options.modelId)}, }); console.log(JSON.stringify(result)); `; return JSON.parse(runTsEval(source, options.env)) as { apiKeyEnv: string; apiKeyValue: string; authChoice: string; authKeyFlag: string; modelId: string; }; } describe("Parallels smoke model selection", () => { it("keeps the public shell entrypoints as thin TypeScript launchers", () => { for (const [platform, wrapperPath] of Object.entries(WRAPPERS)) { const wrapper = readFileSync(wrapperPath, "utf8"); expect(wrapper, wrapperPath).toContain('exec pnpm --dir "$ROOT_DIR" exec tsx'); if (platform === "npmUpdate") { expect(wrapper, wrapperPath).toContain(TS_PATHS.npmUpdate); } else { expect(wrapper, wrapperPath).toContain(TS_PATHS[platform as "linux" | "macos" | "windows"]); } expect(wrapper.split("\n").filter(Boolean).length).toBeLessThanOrEqual(5); } }); it("keeps provider auth and model defaults in the shared TypeScript helper", () => { const providerAuth = readFileSync(TS_PATHS.providerAuth, "utf8"); expect(providerAuth).toContain("OPENCLAW_PARALLELS_OPENAI_MODEL"); expect(providerAuth).toContain("OPENCLAW_PARALLELS_WINDOWS_OPENAI_MODEL"); expect(providerAuth).toContain("openai/gpt-5.5"); expect(providerAuth).toContain('authChoice: "openai-api-key"'); expect(providerAuth).toContain('authChoice: "apiKey"'); expect(providerAuth).toContain('authChoice: "minimax-global-api"'); for (const scriptPath of [...OS_TS_PATHS, TS_PATHS.npmUpdate]) { const script = readFileSync(scriptPath, "utf8"); expect(script, scriptPath).toMatch(/resolve(?:Windows)?ProviderAuth/u); expect(script, scriptPath).toContain("--model "); expect(script, scriptPath).toContain("modelId"); } }); it("writes full model ids as config map keys in provider batches", () => { const source = ` import { modelProviderConfigBatchJson } from "./${TS_PATHS.common}"; const result = modelProviderConfigBatchJson("openai/gpt-5.5", "windows"); console.log(result); `; const batch = JSON.parse(runTsEval(source, { OPENAI_API_KEY: "sk-openai" })) as Array<{ path: string; }>; expect(batch.map((entry) => entry.path)).toContain('agents.defaults.models["openai/gpt-5.5"]'); }); it("keeps snapshot, host, package, and quote helpers shared", () => { const common = readFileSync(TS_PATHS.common, "utf8"); const hostCommand = readFileSync(TS_PATHS.hostCommand, "utf8"); const hostServer = readFileSync(TS_PATHS.hostServer, "utf8"); const laneRunner = readFileSync(TS_PATHS.laneRunner, "utf8"); const packageArtifact = readFileSync(TS_PATHS.packageArtifact, "utf8"); const parallelsVm = readFileSync(TS_PATHS.parallelsVm, "utf8"); const snapshots = readFileSync(TS_PATHS.snapshots, "utf8"); expect(common).toContain('export * from "./host-command.ts"'); expect(common).toContain('export * from "./lane-runner.ts"'); expect(common).toContain('export * from "./package-artifact.ts"'); expect(common).toContain('export * from "./parallels-vm.ts"'); expect(common).toContain('export * from "./snapshots.ts"'); expect(hostCommand).toContain("export function shellQuote"); expect(laneRunner).toContain("export async function runSmokeLane"); expect(packageArtifact).toContain("withPackageLock"); expect(packageArtifact).toContain("Wait for Parallels package lock"); expect(packageArtifact).toContain("export async function packageVersionFromTgz"); expect(packageArtifact).toContain("export async function packOpenClaw"); expect(parallelsVm).toContain("export function resolveUbuntuVmName"); expect(parallelsVm).toContain("export function waitForVmStatus"); expect(hostServer).toContain("export async function startHostServer"); expect(hostServer).toContain("http.server"); expect(snapshots).toContain("export function resolveSnapshot"); for (const scriptPath of OS_TS_PATHS) { const script = readFileSync(scriptPath, "utf8"); expect(script, scriptPath).toContain("resolveSnapshot"); expect(script, scriptPath).toContain("runSmokeLane"); expect(script, scriptPath).not.toContain("def aliases(name: str)"); } }); it("quotes shell args and resolves fuzzy snapshot hints through the shared TypeScript helper", () => { const tempDir = mkdtempSync(join(tmpdir(), "openclaw-parallels-helper-")); const prlctlPath = join(tempDir, "prlctl"); writeFileSync( prlctlPath, `#!/usr/bin/env bash set -euo pipefail if [[ "$1" == "snapshot-list" ]]; then cat <<'JSON' { "{older}": {"name": "fresh", "state": "running"}, "{wanted}": {"name": "fresh-poweroff-2026-04-01", "state": "poweroff"}, "{other}": {"name": "unrelated", "state": "poweroff"} } JSON exit 0 fi exit 1 `, ); chmodSync(prlctlPath, 0o755); try { const output = runTsEval( ` import { resolveSnapshot, shellQuote } from "./${TS_PATHS.common}"; console.log(shellQuote("it's ok")); const snapshot = resolveSnapshot("vm", "fresh"); console.log([snapshot.id, snapshot.state, snapshot.name].join("\\t")); `, { PATH: `${tempDir}:${process.env.PATH ?? ""}` }, ); expect(output.split("\n")[0]).toBe("'it'\"'\"'s ok'"); expect(output).toContain("{wanted}\tpoweroff\tfresh-poweroff-2026-04-01"); } finally { rmSync(tempDir, { force: true, recursive: true }); } }); it("uses one Ubuntu VM fallback resolver for Linux lanes", () => { const tempDir = mkdtempSync(join(tmpdir(), "openclaw-parallels-vm-helper-")); const prlctlPath = join(tempDir, "prlctl"); writeFileSync( prlctlPath, `#!/usr/bin/env bash set -euo pipefail if [[ "$1" == "list" ]]; then cat <<'JSON' [ {"name": "Ubuntu 25.10"}, {"name": "Ubuntu 23.10"}, {"name": "Ubuntu 24.04.3 ARM64"} ] JSON exit 0 fi exit 1 `, ); chmodSync(prlctlPath, 0o755); try { const output = runTsEval( ` import { resolveUbuntuVmName } from "./${TS_PATHS.common}"; console.log(resolveUbuntuVmName("Ubuntu missing")); `, { PATH: `${tempDir}:${process.env.PATH ?? ""}` }, ); expect(output.trim()).toBe("Ubuntu 24.04.3 ARM64"); } finally { rmSync(tempDir, { force: true, recursive: true }); } }); it("waits for apt locks during Linux snapshot bootstrap", () => { const script = readFileSync(TS_PATHS.linux, "utf8"); expect(script).toContain("DPkg::Lock::Timeout=300"); }); it("resolves provider defaults and explicit model overrides", () => { expect(resolveProviderAuth("openai", { env: { OPENAI_API_KEY: "sk-openai" } })).toEqual({ apiKeyEnv: "OPENAI_API_KEY", apiKeyValue: "sk-openai", authChoice: "openai-api-key", authKeyFlag: "openai-api-key", modelId: "openai/gpt-5.5", }); expect( resolveProviderAuth("anthropic", { apiKeyEnv: "CUSTOM_ANTHROPIC_KEY", env: { CUSTOM_ANTHROPIC_KEY: "sk-anthropic" }, modelId: "anthropic/custom", }), ).toEqual({ apiKeyEnv: "CUSTOM_ANTHROPIC_KEY", apiKeyValue: "sk-anthropic", authChoice: "apiKey", authKeyFlag: "anthropic-api-key", modelId: "anthropic/custom", }); }); it("uses the shared GPT-5 OpenAI model for Windows smoke unless overridden", () => { const source = ` import { resolveWindowsProviderAuth } from "./${TS_PATHS.common}"; const result = resolveWindowsProviderAuth({ provider: "openai", }); console.log(JSON.stringify(result)); `; expect(JSON.parse(runTsEval(source, { OPENAI_API_KEY: "sk-openai" }))).toMatchObject({ apiKeyEnv: "OPENAI_API_KEY", modelId: "openai/gpt-5.5", }); expect( JSON.parse( runTsEval(source, { OPENAI_API_KEY: "sk-openai", OPENCLAW_PARALLELS_WINDOWS_OPENAI_MODEL: "openai/custom-windows", }), ), ).toMatchObject({ modelId: "openai/custom-windows", }); }); it("rejects invalid providers and missing keys before touching guests", () => { const invalidProvider = spawnSync( "node", [ "--import", "tsx", "--input-type=module", "--eval", `import { parseProvider } from "./${TS_PATHS.common}"; parseProvider("bogus");`, ], { encoding: "utf8", env: process.env }, ); expect(invalidProvider.status).toBe(1); expect(invalidProvider.stderr).toContain("invalid --provider: bogus"); const missingKey = spawnSync( "node", [ "--import", "tsx", "--input-type=module", "--eval", `import { resolveProviderAuth } from "./${TS_PATHS.common}"; resolveProviderAuth({ provider: "openai", apiKeyEnv: "PARALLELS_TEST_MISSING_KEY" });`, ], { encoding: "utf8", env: { ...process.env, PARALLELS_TEST_MISSING_KEY: "" }, }, ); expect(missingKey.status).toBe(1); expect(missingKey.stderr).toContain("PARALLELS_TEST_MISSING_KEY is required"); }); it("seeds agent workspace state before OS smoke agent turns", () => { const workspace = readFileSync(TS_PATHS.agentWorkspace, "utf8"); expect(workspace).toContain("workspace-state.json"); expect(workspace).toContain("IDENTITY.md"); expect(workspace).toContain("BOOTSTRAP.md"); for (const scriptPath of OS_TS_PATHS) { const script = readFileSync(scriptPath, "utf8"); expect(script, scriptPath).toContain("AgentWorkspaceScript"); expect(script, scriptPath).toContain("parallels-"); if (scriptPath !== TS_PATHS.windows) { expect(script, scriptPath).toContain("agents.defaults.skipBootstrap"); expect(script, scriptPath).toContain("tools.profile"); } expect(script, scriptPath).toContain("--thinking"); expect(script, scriptPath).toContain("minimal"); expect(script, scriptPath).toContain("finalAssistant(Raw|Visible)Text"); } expect(readFileSync(TS_PATHS.macos, "utf8")).toContain("modelProviderConfigBatchJson"); expect(readFileSync(TS_PATHS.macos, "utf8")).toContain("config set --batch-file"); expect(readFileSync(TS_PATHS.linux, "utf8")).toContain("modelProviderConfigBatchJson"); expect(readFileSync(TS_PATHS.linux, "utf8")).toContain("config set --batch-file"); expect(readFileSync(TS_PATHS.windows, "utf8")).toContain("windowsAgentTurnConfigPatchScript"); const powershell = readFileSync(TS_PATHS.powershell, "utf8"); expect(powershell).toContain("config set --batch-file"); expect(powershell).toContain("agents.defaults.skipBootstrap"); expect(powershell).toContain("tools.profile"); expect(powershell).toContain("replace(/^\\\\uFEFF/u"); const npmUpdateScripts = readFileSync(TS_PATHS.npmUpdateScripts, "utf8"); expect(npmUpdateScripts).toContain("posixAgentWorkspaceScript"); expect(npmUpdateScripts).toContain("windowsAgentWorkspaceScript"); expect(npmUpdateScripts).toContain("tools.profile"); expect(npmUpdateScripts).toContain("--thinking minimal"); expect(npmUpdateScripts).toContain("finalAssistant(Raw|Visible)Text"); expect(npmUpdateScripts).toContain("posixAssertAgentOkScript"); expect(npmUpdateScripts).toContain("windowsAgentTurnConfigPatchScript"); expect(npmUpdateScripts).toContain("modelProviderConfigBatchJson"); expect(npmUpdateScripts).toContain("config set --batch-file"); }); it("clears phase timers and applies phase deadlines to guest commands", () => { const phaseRunner = readFileSync(TS_PATHS.phaseRunner, "utf8"); const guestTransports = readFileSync(TS_PATHS.guestTransports, "utf8"); expect(phaseRunner).toContain("clearTimeout(timer)"); expect(phaseRunner).toContain("remainingTimeoutMs"); expect(guestTransports).toContain("this.phases.remainingTimeoutMs"); for (const scriptPath of OS_TS_PATHS) { const script = readFileSync(scriptPath, "utf8"); expect(script, scriptPath).toContain("PhaseRunner"); expect(script, scriptPath).toContain("remainingPhaseTimeoutMs"); expect(script, scriptPath).toContain("timeoutMs:"); } }); it("runs POSIX guest shell scripts with a normal install umask", () => { const guestTransports = readFileSync(TS_PATHS.guestTransports, "utf8"); expect(guestTransports.match(/umask 022/g)).toHaveLength(2); }); it("provisions portable Git before Windows dev update lanes", () => { const script = readFileSync(TS_PATHS.windows, "utf8"); const windowsGit = readFileSync(TS_PATHS.windowsGit, "utf8"); const combined = `${script}\n${windowsGit}`; expect(script).toContain("prepareMinGitZip"); expect(script).toContain("ensureGuestGit"); expect(script).toContain("fresh.ensure-git"); expect(script).toContain("upgrade.ensure-git"); expect(combined).toContain("MinGit-"); expect(combined).toContain("portable-git"); expect(combined).toContain("where.exe git.exe"); }); it("preseeds dev update channel before stable-to-dev update lanes", () => { const macos = readFileSync(TS_PATHS.macos, "utf8"); const windows = readFileSync(TS_PATHS.windows, "utf8"); expect(macos).toContain('channel: "dev"'); expect(windows).toContain("Name channel -Value 'dev'"); expect(macos).toContain("OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS=1"); expect(windows).toContain("OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS"); }); it("passes aggregate model overrides into each OS fresh lane", () => { const script = readFileSync(TS_PATHS.npmUpdate, "utf8"); expect(script).toContain("scripts/e2e/parallels/${platform}-smoke.ts"); expect(script).toContain('"--model"'); expect(script).toContain("auth.modelId"); expect(script).toContain("authForPlatform"); expect(script).toContain("OPENCLAW_PARALLELS_LINUX_DISABLE_BONJOUR"); }); it("keeps aggregate update guest scripts isolated from the npm-update orchestrator", () => { const orchestrator = readFileSync(TS_PATHS.npmUpdate, "utf8"); const updateScripts = readFileSync(TS_PATHS.npmUpdateScripts, "utf8"); expect(orchestrator).toContain("macosUpdateScript"); expect(orchestrator).toContain("windowsUpdateScript"); expect(orchestrator).toContain("linuxUpdateScript"); expect(orchestrator).not.toContain("Remove-FuturePluginEntries"); expect(updateScripts).toContain("Remove-FuturePluginEntries"); expect(updateScripts).toContain("scrub_future_plugin_entries"); expect(updateScripts).toContain("Invoke-OpenClaw update"); expect(updateScripts).toContain("Parallels npm update smoke test assistant."); }); it("keeps macOS Discord roundtrip isolated from the lane orchestrator", () => { const macos = readFileSync(TS_PATHS.macos, "utf8"); const discord = readFileSync(TS_PATHS.macosDiscord, "utf8"); expect(macos).toContain("MacosDiscordSmoke"); expect(macos).not.toContain("Authorization: Bot"); expect(discord).toContain("Authorization: Bot"); expect(discord).toContain('"--silent"'); expect(discord).toContain("doctor --fix --yes --non-interactive"); expect(discord).toContain("channels status --probe --json"); expect(discord).toContain("Stop ${this.input.vmName} after successful Discord smoke"); }); it("keeps Windows gateway reachability on a real deadline with start recovery", () => { const script = readFileSync(TS_PATHS.windows, "utf8"); expect(script).toContain("OPENCLAW_PARALLELS_WINDOWS_GATEWAY_RECOVERY_AFTER_S"); expect(script).toContain("Date.now() < deadline"); expect(script).toContain("gateway start"); expect(script).toContain("gateway-reachable recovery"); }); it("runs Windows ref onboarding through a detached done-file runner", () => { const script = readFileSync(TS_PATHS.windows, "utf8"); const transports = readFileSync(TS_PATHS.guestTransports, "utf8"); expect(script).toContain("guestPowerShellBackground"); expect(script).toContain("runWindowsBackgroundPowerShell"); expect(transports).toContain("Join-Path $env:TEMP"); expect(transports).toContain("__OPENCLAW_BACKGROUND_DONE__"); expect(transports).toContain("__OPENCLAW_BACKGROUND_EXIT__"); expect(transports).toContain("__OPENCLAW_LOG_OFFSET__"); expect(transports).toContain("poll.status !== 0 && poll.status !== 124"); expect(transports).toContain("Start-Process -FilePath powershell.exe"); expect(transports).toContain('launch.stdout.includes("started")'); expect(transports).toContain("waitForWindowsBackgroundMaterialized"); }); it("returns timed-out host command status when check is disabled", () => { const result = JSON.parse( runTsEval(` import { run } from "./${TS_PATHS.hostCommand}"; const result = run(process.execPath, ["-e", "process.stdout.write('partial'); setTimeout(() => {}, 1000);"], { check: false, quiet: true, timeoutMs: 50, }); console.log(JSON.stringify(result)); `), ) as { status: number; stdout: string }; expect(result.status).toBe(124); expect(result.stdout).toEqual(expect.any(String)); }); it("runs the Windows agent turn through the detached done-file runner", () => { const script = readFileSync(TS_PATHS.windows, "utf8"); expect(script).toContain('guestPowerShellBackground(\n "agent-turn"'); expect(script).toContain("OPENCLAW_PARALLELS_WINDOWS_AGENT_TIMEOUT_S"); expect(script).toContain("OPENCLAW_PARALLELS_WINDOWS_AGENT_TIMEOUT_S || 2700"); expect(script).toContain("windowsAgentTurnConfigPatchScript(this.auth.modelId)"); expect(script).toContain("--model"); expect(script).toContain('resolveParallelsModelTimeoutSeconds("windows")'); expect(script).toContain("finalAssistant(Raw|Visible)Text"); expect(script).toContain("parallels-windows-smoke-retry-$attempt"); expect(script).toContain("agent turn attempt $attempt failed or finished without OK response"); expect(script).not.toContain("$config.models.providers"); expect(script).not.toContain("timeoutSeconds = 300"); expect(script).toContain('"$sessionId.jsonl"'); }); it("gives GPT-5.5 enough Parallels model time on slower desktop guests", () => { const source = ` import { resolveParallelsModelTimeoutSeconds } from "./${TS_PATHS.common}"; console.log(JSON.stringify({ macos: resolveParallelsModelTimeoutSeconds("macos"), windows: resolveParallelsModelTimeoutSeconds("windows"), linux: resolveParallelsModelTimeoutSeconds("linux"), })); `; expect(JSON.parse(runTsEval(source))).toEqual({ linux: 900, macos: 1800, windows: 1800, }); expect(readFileSync(TS_PATHS.macos, "utf8")).toContain( "OPENCLAW_PARALLELS_MACOS_AGENT_TIMEOUT_S || 2700", ); expect(readFileSync(TS_PATHS.macos, "utf8")).toContain( '--timeout ${resolveParallelsModelTimeoutSeconds("macos")}', ); expect(readFileSync(TS_PATHS.linux, "utf8")).toContain( '--timeout ${resolveParallelsModelTimeoutSeconds("linux")}', ); }); it("waits through transient Windows restoring state before VM operations", () => { const script = readFileSync(TS_PATHS.windows, "utf8"); const transports = readFileSync(TS_PATHS.guestTransports, "utf8"); expect(script).toContain("waitForVmNotRestoring"); expect(script).toContain("snapshot-switch retry"); expect(transports).toContain("launch retry"); }); it("keeps Windows update-only env flags scoped before verification", () => { const windows = readFileSync(TS_PATHS.windows, "utf8"); const powershell = readFileSync(TS_PATHS.powershell, "utf8"); expect(powershell).toContain("windowsScopedEnvFunction"); expect(windows).toContain( "Invoke-WithScopedEnv @{ OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS", ); expect(windows).toContain("$script:OpenClawUpdateExit = $LASTEXITCODE"); expect(windows).not.toContain("$env:OPENCLAW_DISABLE_BUNDLED_PLUGINS = '1'"); }); it("writes Parallels phase timing artifacts", () => { const phaseRunner = readFileSync(TS_PATHS.phaseRunner, "utf8"); const npmUpdate = readFileSync(TS_PATHS.npmUpdate, "utf8"); expect(phaseRunner).toContain("phase-timings.json"); expect(phaseRunner).toContain("slowest"); expect(npmUpdate).toContain("timings: this.timings"); expect(npmUpdate).toContain("recordTiming"); }); it("resolves Windows OpenClaw commands without assuming the npm shim path", () => { const powershell = readFileSync(TS_PATHS.powershell, "utf8"); const windows = readFileSync(TS_PATHS.windows, "utf8"); expect(powershell).toContain("windowsOpenClawResolver"); expect(powershell).toContain("providerTimeoutConfigJson"); expect(powershell).toContain("models.providers.${providerId}"); expect(powershell).toContain("agents.defaults.models${configPathMapKey(modelId)}"); expect(powershell).toContain("configPathMapKey"); expect(powershell).toContain('transport: "sse"'); expect(powershell).toContain("Resolve-OpenClawCommand"); expect(powershell).toContain("npm\\node_modules\\openclaw\\openclaw.mjs"); expect(powershell).toContain("$ErrorActionPreference = 'Continue'"); expect(powershell).toContain("$PSNativeCommandUseErrorActionPreference = $false"); expect(windows).toContain("windowsOpenClawResolver"); expect(windows).toContain("Invoke-OpenClaw gateway"); expect(windows).not.toContain("Join-Path $env:APPDATA 'npm\\\\openclaw.cmd'"); }); });