mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:10:44 +00:00
refactor: convert parallels smoke scripts to typescript
This commit is contained in:
1065
scripts/e2e/parallels-linux-smoke.sh
Normal file → Executable file
1065
scripts/e2e/parallels-linux-smoke.sh
Normal file → Executable file
File diff suppressed because it is too large
Load Diff
2126
scripts/e2e/parallels-macos-smoke.sh
Normal file → Executable file
2126
scripts/e2e/parallels-macos-smoke.sh
Normal file → Executable file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
2782
scripts/e2e/parallels-windows-smoke.sh
Normal file → Executable file
2782
scripts/e2e/parallels-windows-smoke.sh
Normal file → Executable file
File diff suppressed because it is too large
Load Diff
38
scripts/e2e/parallels/agent-workspace.ts
Normal file
38
scripts/e2e/parallels/agent-workspace.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export function posixAgentWorkspaceScript(purpose: string): string {
|
||||
return `set -eu
|
||||
workspace="\${OPENCLAW_WORKSPACE_DIR:-$HOME/.openclaw/workspace}"
|
||||
mkdir -p "$workspace/.openclaw"
|
||||
cat > "$workspace/IDENTITY.md" <<'IDENTITY_EOF'
|
||||
# Identity
|
||||
|
||||
- Name: OpenClaw
|
||||
- Purpose: ${purpose}
|
||||
IDENTITY_EOF
|
||||
cat > "$workspace/.openclaw/workspace-state.json" <<'STATE_EOF'
|
||||
{
|
||||
"version": 1,
|
||||
"setupCompletedAt": "2026-01-01T00:00:00.000Z"
|
||||
}
|
||||
STATE_EOF
|
||||
rm -f "$workspace/BOOTSTRAP.md"`;
|
||||
}
|
||||
|
||||
export function windowsAgentWorkspaceScript(purpose: string): string {
|
||||
return `$workspace = $env:OPENCLAW_WORKSPACE_DIR
|
||||
if (-not $workspace) { $workspace = Join-Path $env:USERPROFILE '.openclaw\\workspace' }
|
||||
$stateDir = Join-Path $workspace '.openclaw'
|
||||
New-Item -ItemType Directory -Path $stateDir -Force | Out-Null
|
||||
@'
|
||||
# Identity
|
||||
|
||||
- Name: OpenClaw
|
||||
- Purpose: ${purpose}
|
||||
'@ | Set-Content -Path (Join-Path $workspace 'IDENTITY.md') -Encoding UTF8
|
||||
@'
|
||||
{
|
||||
"version": 1,
|
||||
"setupCompletedAt": "2026-01-01T00:00:00.000Z"
|
||||
}
|
||||
'@ | Set-Content -Path (Join-Path $stateDir 'workspace-state.json') -Encoding UTF8
|
||||
Remove-Item (Join-Path $workspace 'BOOTSTRAP.md') -Force -ErrorAction SilentlyContinue`;
|
||||
}
|
||||
7
scripts/e2e/parallels/common.ts
Normal file
7
scripts/e2e/parallels/common.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from "./filesystem.ts";
|
||||
export * from "./host-command.ts";
|
||||
export * from "./host-server.ts";
|
||||
export * from "./package-artifact.ts";
|
||||
export * from "./provider-auth.ts";
|
||||
export * from "./snapshots.ts";
|
||||
export * from "./types.ts";
|
||||
39
scripts/e2e/parallels/filesystem.ts
Normal file
39
scripts/e2e/parallels/filesystem.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { writeFileSync, rmSync } from "node:fs";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export async function exists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readJson<T>(filePath: string): Promise<T> {
|
||||
return JSON.parse(await readFile(filePath, "utf8")) as T;
|
||||
}
|
||||
|
||||
export async function writeJson(filePath: string, value: unknown): Promise<void> {
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
export async function makeTempDir(prefix: string): Promise<string> {
|
||||
return mkdtempSync(path.join(tmpdir(), prefix));
|
||||
}
|
||||
|
||||
export async function cleanupPath(filePath: string): Promise<void> {
|
||||
await rm(filePath, { force: true, recursive: true }).catch(() => undefined);
|
||||
}
|
||||
|
||||
export function cleanupPathSync(filePath: string): void {
|
||||
rmSync(filePath, { force: true, recursive: true });
|
||||
}
|
||||
|
||||
export function writeExecutable(filePath: string, content: string): void {
|
||||
writeFileSync(filePath, content, { encoding: "utf8", mode: 0o755 });
|
||||
}
|
||||
116
scripts/e2e/parallels/guest-transports.ts
Normal file
116
scripts/e2e/parallels/guest-transports.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { run } from "./host-command.ts";
|
||||
import type { PhaseRunner } from "./phase-runner.ts";
|
||||
import { encodePowerShell } from "./powershell.ts";
|
||||
|
||||
export interface GuestExecOptions {
|
||||
check?: boolean;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export class LinuxGuest {
|
||||
constructor(
|
||||
private vmName: string,
|
||||
private phases: PhaseRunner,
|
||||
) {}
|
||||
|
||||
exec(args: string[], options: GuestExecOptions = {}): string {
|
||||
const result = run("prlctl", ["exec", this.vmName, "/usr/bin/env", "HOME=/root", ...args], {
|
||||
check: options.check,
|
||||
quiet: true,
|
||||
timeoutMs: this.phases.remainingTimeoutMs(options.timeoutMs),
|
||||
});
|
||||
this.phases.append(result.stdout);
|
||||
this.phases.append(result.stderr);
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
bash(script: string): string {
|
||||
const encoded = Buffer.from(script, "utf8").toString("base64");
|
||||
return this.exec(["bash", "-lc", `printf '%s' '${encoded}' | base64 -d | bash`]);
|
||||
}
|
||||
}
|
||||
|
||||
export interface MacosGuestOptions extends GuestExecOptions {
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
export class MacosGuest {
|
||||
constructor(
|
||||
private input: {
|
||||
vmName: string;
|
||||
getUser: () => string;
|
||||
getTransport: () => "current-user" | "sudo";
|
||||
resolveDesktopHome: (user: string) => string;
|
||||
path: string;
|
||||
},
|
||||
private phases: PhaseRunner,
|
||||
) {}
|
||||
|
||||
exec(args: string[], options: MacosGuestOptions = {}): string {
|
||||
const envArgs = Object.entries({ PATH: this.input.path, ...options.env }).map(
|
||||
([key, value]) => `${key}=${value}`,
|
||||
);
|
||||
const user = this.input.getUser();
|
||||
const transportArgs =
|
||||
this.input.getTransport() === "sudo"
|
||||
? [
|
||||
"exec",
|
||||
this.input.vmName,
|
||||
"/usr/bin/sudo",
|
||||
"-H",
|
||||
"-u",
|
||||
user,
|
||||
"/usr/bin/env",
|
||||
`HOME=${this.input.resolveDesktopHome(user)}`,
|
||||
`USER=${user}`,
|
||||
`LOGNAME=${user}`,
|
||||
...envArgs,
|
||||
...args,
|
||||
]
|
||||
: ["exec", this.input.vmName, "--current-user", "/usr/bin/env", ...envArgs, ...args];
|
||||
const result = run("prlctl", transportArgs, {
|
||||
check: options.check,
|
||||
quiet: true,
|
||||
timeoutMs: this.phases.remainingTimeoutMs(options.timeoutMs),
|
||||
});
|
||||
this.phases.append(result.stdout);
|
||||
this.phases.append(result.stderr);
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
sh(script: string, env: Record<string, string> = {}): string {
|
||||
return this.exec(["/bin/bash", "-lc", script], { env });
|
||||
}
|
||||
}
|
||||
|
||||
export class WindowsGuest {
|
||||
constructor(
|
||||
private vmName: string,
|
||||
private phases: PhaseRunner,
|
||||
) {}
|
||||
|
||||
exec(args: string[], options: GuestExecOptions = {}): string {
|
||||
const result = run("prlctl", ["exec", this.vmName, "--current-user", ...args], {
|
||||
check: options.check,
|
||||
quiet: true,
|
||||
timeoutMs: this.phases.remainingTimeoutMs(options.timeoutMs),
|
||||
});
|
||||
this.phases.append(result.stdout);
|
||||
this.phases.append(result.stderr);
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
powershell(script: string, options: GuestExecOptions = {}): string {
|
||||
return this.exec(
|
||||
[
|
||||
"powershell.exe",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-EncodedCommand",
|
||||
encodePowerShell(script),
|
||||
],
|
||||
options,
|
||||
);
|
||||
}
|
||||
}
|
||||
122
scripts/e2e/parallels/host-command.ts
Normal file
122
scripts/e2e/parallels/host-command.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { spawn, spawnSync, type SpawnOptions } from "node:child_process";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { CommandResult, RunOptions } from "./types.ts";
|
||||
|
||||
export const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
|
||||
export function say(message: string): void {
|
||||
process.stdout.write(`==> ${message}\n`);
|
||||
}
|
||||
|
||||
export function warn(message: string): void {
|
||||
process.stderr.write(`warn: ${message}\n`);
|
||||
}
|
||||
|
||||
export function die(message: string): never {
|
||||
process.stderr.write(`error: ${message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
export function shellQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
export function run(command: string, args: string[], options: RunOptions = {}): CommandResult {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd ?? repoRoot,
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, ...options.env },
|
||||
input: options.input,
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
stdio: options.quiet ? ["pipe", "pipe", "pipe"] : ["pipe", "pipe", "pipe"],
|
||||
timeout: options.timeoutMs,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
const status = result.status ?? (result.signal ? 128 : 1);
|
||||
const commandResult = {
|
||||
stderr: result.stderr ?? "",
|
||||
stdout: result.stdout ?? "",
|
||||
status,
|
||||
};
|
||||
if (options.check !== false && status !== 0) {
|
||||
if (commandResult.stdout) {
|
||||
process.stdout.write(commandResult.stdout);
|
||||
}
|
||||
if (commandResult.stderr) {
|
||||
process.stderr.write(commandResult.stderr);
|
||||
}
|
||||
die(`command failed (${status}): ${[command, ...args].join(" ")}`);
|
||||
}
|
||||
return commandResult;
|
||||
}
|
||||
|
||||
export function sh(script: string, options: RunOptions = {}): CommandResult {
|
||||
return run("bash", ["-lc", script], options);
|
||||
}
|
||||
|
||||
export async function runStreaming(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: RunOptions & { logPath?: string } = {},
|
||||
): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: options.cwd ?? repoRoot,
|
||||
env: { ...process.env, ...options.env },
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
} satisfies SpawnOptions);
|
||||
|
||||
let log = "";
|
||||
const append = (chunk: Buffer): void => {
|
||||
const text = chunk.toString("utf8");
|
||||
log += text;
|
||||
if (!options.quiet) {
|
||||
process.stdout.write(text);
|
||||
}
|
||||
};
|
||||
child.stdout?.on("data", append);
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
const text = chunk.toString("utf8");
|
||||
log += text;
|
||||
if (!options.quiet) {
|
||||
process.stderr.write(text);
|
||||
}
|
||||
});
|
||||
if (options.input != null) {
|
||||
child.stdin?.end(options.input);
|
||||
} else {
|
||||
child.stdin?.end();
|
||||
}
|
||||
|
||||
let timedOut = false;
|
||||
const timer =
|
||||
options.timeoutMs == null
|
||||
? undefined
|
||||
: setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill("SIGTERM");
|
||||
setTimeout(() => child.kill("SIGKILL"), 2_000).unref();
|
||||
}, options.timeoutMs);
|
||||
|
||||
child.on("error", reject);
|
||||
child.on("close", async (code, signal) => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
if (options.logPath) {
|
||||
await writeFile(options.logPath, log, "utf8");
|
||||
}
|
||||
if (timedOut) {
|
||||
resolve(124);
|
||||
} else {
|
||||
resolve(code ?? (signal ? 128 : 1));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
107
scripts/e2e/parallels/host-server.ts
Normal file
107
scripts/e2e/parallels/host-server.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import { stat } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import path from "node:path";
|
||||
import { exists } from "./filesystem.ts";
|
||||
import { die, run, say, sh, warn } from "./host-command.ts";
|
||||
import type { HostServer } from "./types.ts";
|
||||
|
||||
export function resolveHostIp(explicit = ""): string {
|
||||
if (explicit) {
|
||||
return explicit;
|
||||
}
|
||||
const output = sh("ifconfig | awk '/inet 10\\.211\\./ { print $2; exit }'", {
|
||||
quiet: true,
|
||||
}).stdout.trim();
|
||||
if (!output) {
|
||||
die("failed to detect Parallels host IP; pass --host-ip");
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export function allocateHostPort(): number {
|
||||
return Number(
|
||||
run(
|
||||
"python3",
|
||||
[
|
||||
"-c",
|
||||
"import socket; s=socket.socket(); s.bind(('0.0.0.0', 0)); print(s.getsockname()[1]); s.close()",
|
||||
],
|
||||
{ quiet: true },
|
||||
).stdout.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
export async function isHostPortFree(port: number): Promise<boolean> {
|
||||
return await new Promise((resolve) => {
|
||||
const server = createServer();
|
||||
server.once("error", () => resolve(false));
|
||||
server.listen(port, "0.0.0.0", () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveHostPort(
|
||||
port: number,
|
||||
explicit: boolean,
|
||||
defaultPort: number,
|
||||
): Promise<number> {
|
||||
if (await isHostPortFree(port)) {
|
||||
return port;
|
||||
}
|
||||
if (explicit) {
|
||||
die(`host port ${port} already in use`);
|
||||
}
|
||||
const allocated = allocateHostPort();
|
||||
warn(`host port ${defaultPort} busy; using ${allocated}`);
|
||||
return allocated;
|
||||
}
|
||||
|
||||
export async function startHostServer(input: {
|
||||
dir: string;
|
||||
hostIp: string;
|
||||
port: number;
|
||||
artifactPath: string;
|
||||
label: string;
|
||||
}): Promise<HostServer> {
|
||||
const artifactName = path.basename(input.artifactPath);
|
||||
const server = createServer(async (request, response) => {
|
||||
const requestPath = decodeURIComponent(
|
||||
new URL(request.url ?? "/", "http://127.0.0.1").pathname,
|
||||
);
|
||||
const fileName = path.basename(requestPath);
|
||||
const filePath = path.join(input.dir, fileName);
|
||||
if (fileName !== artifactName && !(await exists(filePath))) {
|
||||
response.statusCode = 404;
|
||||
response.end("not found");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const info = await stat(filePath);
|
||||
response.setHeader("Content-Length", String(info.size));
|
||||
response.setHeader("Content-Type", "application/octet-stream");
|
||||
createReadStream(filePath).pipe(response);
|
||||
} catch {
|
||||
response.statusCode = 404;
|
||||
response.end("not found");
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(input.port, "0.0.0.0", () => resolve());
|
||||
});
|
||||
const address = server.address();
|
||||
const actualPort = typeof address === "object" && address ? address.port : input.port;
|
||||
say(`Serve ${input.label} on ${input.hostIp}:${actualPort}`);
|
||||
return {
|
||||
hostIp: input.hostIp,
|
||||
port: actualPort,
|
||||
stop: async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
},
|
||||
urlFor: (filePath) =>
|
||||
`http://${input.hostIp}:${actualPort}/${encodeURIComponent(path.basename(filePath))}`,
|
||||
};
|
||||
}
|
||||
789
scripts/e2e/parallels/linux-smoke.ts
Executable file
789
scripts/e2e/parallels/linux-smoke.ts
Executable file
@@ -0,0 +1,789 @@
|
||||
#!/usr/bin/env -S pnpm tsx
|
||||
import { mkdir, readFile, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { posixAgentWorkspaceScript } from "./agent-workspace.ts";
|
||||
import {
|
||||
die,
|
||||
ensureValue,
|
||||
makeTempDir,
|
||||
packageBuildCommitFromTgz,
|
||||
packageVersionFromTgz,
|
||||
packOpenClaw,
|
||||
parseBoolEnv,
|
||||
parseMode,
|
||||
parseProvider,
|
||||
repoRoot,
|
||||
resolveHostIp,
|
||||
resolveHostPort,
|
||||
resolveLatestVersion,
|
||||
resolveProviderAuth,
|
||||
resolveSnapshot,
|
||||
run,
|
||||
say,
|
||||
shellQuote,
|
||||
startHostServer,
|
||||
warn,
|
||||
writeJson,
|
||||
type HostServer,
|
||||
type Mode,
|
||||
type PackageArtifact,
|
||||
type Provider,
|
||||
type ProviderAuth,
|
||||
type SnapshotInfo,
|
||||
} from "./common.ts";
|
||||
import { LinuxGuest } from "./guest-transports.ts";
|
||||
import { PhaseRunner } from "./phase-runner.ts";
|
||||
|
||||
interface LinuxOptions {
|
||||
vmName: string;
|
||||
vmNameExplicit: boolean;
|
||||
snapshotHint: string;
|
||||
mode: Mode;
|
||||
provider: Provider;
|
||||
apiKeyEnv?: string;
|
||||
modelId?: string;
|
||||
installUrl: string;
|
||||
hostPort: number;
|
||||
hostPortExplicit: boolean;
|
||||
hostIp?: string;
|
||||
latestVersion?: string;
|
||||
installVersion?: string;
|
||||
targetPackageSpec?: string;
|
||||
keepServer: boolean;
|
||||
json: boolean;
|
||||
}
|
||||
|
||||
interface LinuxSummary {
|
||||
vm: string;
|
||||
snapshotHint: string;
|
||||
snapshotId: string;
|
||||
mode: Mode;
|
||||
provider: Provider;
|
||||
latestVersion: string;
|
||||
installVersion: string;
|
||||
targetPackageSpec: string;
|
||||
currentHead: string;
|
||||
runDir: string;
|
||||
daemon: string;
|
||||
freshMain: {
|
||||
status: string;
|
||||
version: string;
|
||||
gateway: string;
|
||||
agent: string;
|
||||
};
|
||||
upgrade: {
|
||||
status: string;
|
||||
latestVersionInstalled: string;
|
||||
mainVersion: string;
|
||||
gateway: string;
|
||||
agent: string;
|
||||
};
|
||||
}
|
||||
|
||||
const defaultOptions = (): LinuxOptions => ({
|
||||
apiKeyEnv: undefined,
|
||||
hostIp: undefined,
|
||||
hostPort: 18427,
|
||||
hostPortExplicit: false,
|
||||
installUrl: "https://openclaw.ai/install.sh",
|
||||
installVersion: "",
|
||||
json: false,
|
||||
keepServer: false,
|
||||
latestVersion: "",
|
||||
mode: "both",
|
||||
modelId: undefined,
|
||||
provider: "openai",
|
||||
snapshotHint: "fresh",
|
||||
targetPackageSpec: "",
|
||||
vmName: "Ubuntu 24.04.3 ARM64",
|
||||
vmNameExplicit: false,
|
||||
});
|
||||
|
||||
function usage(): string {
|
||||
return `Usage: bash scripts/e2e/parallels-linux-smoke.sh [options]
|
||||
|
||||
Options:
|
||||
--vm <name> Parallels VM name. Default: "Ubuntu 24.04.3 ARM64"
|
||||
Falls back to the closest Ubuntu VM when omitted and unavailable.
|
||||
--snapshot-hint <name> Snapshot name substring/fuzzy match. Default: "fresh"
|
||||
--mode <fresh|upgrade|both>
|
||||
--provider <openai|anthropic|minimax>
|
||||
Provider auth/model lane. Default: openai
|
||||
--model <provider/model> Override the model used for the agent-turn smoke.
|
||||
--api-key-env <var> Host env var name for provider API key.
|
||||
--openai-api-key-env <var> Alias for --api-key-env (backward compatible)
|
||||
--install-url <url> Installer URL for latest release. Default: https://openclaw.ai/install.sh
|
||||
--host-port <port> Host HTTP port for current-main tgz. Default: 18427
|
||||
--host-ip <ip> Override Parallels host IP.
|
||||
--latest-version <ver> Override npm latest version lookup.
|
||||
--install-version <ver> Pin site-installer version/dist-tag for the baseline lane.
|
||||
--target-package-spec <npm-spec>
|
||||
Install this npm package tarball instead of packing current main.
|
||||
--keep-server Leave temp host HTTP server running.
|
||||
--json Print machine-readable JSON summary.
|
||||
-h, --help Show help.
|
||||
`;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): LinuxOptions {
|
||||
const options = defaultOptions();
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
switch (arg) {
|
||||
case "--":
|
||||
break;
|
||||
case "--vm":
|
||||
options.vmName = ensureValue(argv, i, arg);
|
||||
options.vmNameExplicit = true;
|
||||
i++;
|
||||
break;
|
||||
case "--snapshot-hint":
|
||||
options.snapshotHint = ensureValue(argv, i, arg);
|
||||
i++;
|
||||
break;
|
||||
case "--mode":
|
||||
options.mode = parseMode(ensureValue(argv, i, arg));
|
||||
i++;
|
||||
break;
|
||||
case "--provider":
|
||||
options.provider = parseProvider(ensureValue(argv, i, arg));
|
||||
i++;
|
||||
break;
|
||||
case "--model":
|
||||
options.modelId = ensureValue(argv, i, arg);
|
||||
i++;
|
||||
break;
|
||||
case "--api-key-env":
|
||||
case "--openai-api-key-env":
|
||||
options.apiKeyEnv = ensureValue(argv, i, arg);
|
||||
i++;
|
||||
break;
|
||||
case "--install-url":
|
||||
options.installUrl = ensureValue(argv, i, arg);
|
||||
i++;
|
||||
break;
|
||||
case "--host-port":
|
||||
options.hostPort = Number(ensureValue(argv, i, arg));
|
||||
options.hostPortExplicit = true;
|
||||
i++;
|
||||
break;
|
||||
case "--host-ip":
|
||||
options.hostIp = ensureValue(argv, i, arg);
|
||||
i++;
|
||||
break;
|
||||
case "--latest-version":
|
||||
options.latestVersion = ensureValue(argv, i, arg);
|
||||
i++;
|
||||
break;
|
||||
case "--install-version":
|
||||
options.installVersion = ensureValue(argv, i, arg);
|
||||
i++;
|
||||
break;
|
||||
case "--target-package-spec":
|
||||
options.targetPackageSpec = ensureValue(argv, i, arg);
|
||||
i++;
|
||||
break;
|
||||
case "--keep-server":
|
||||
options.keepServer = true;
|
||||
break;
|
||||
case "--json":
|
||||
options.json = true;
|
||||
break;
|
||||
case "-h":
|
||||
case "--help":
|
||||
process.stdout.write(usage());
|
||||
process.exit(0);
|
||||
default:
|
||||
die(`unknown arg: ${arg}`);
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
class LinuxSmoke {
|
||||
private auth: ProviderAuth;
|
||||
private disableBonjour = parseBoolEnv(process.env.OPENCLAW_PARALLELS_LINUX_DISABLE_BONJOUR);
|
||||
private hostIp = "";
|
||||
private hostPort = 0;
|
||||
private server: HostServer | null = null;
|
||||
private runDir = "";
|
||||
private tgzDir = "";
|
||||
private artifact: PackageArtifact | null = null;
|
||||
private latestVersion = "";
|
||||
private snapshot!: SnapshotInfo;
|
||||
private phases!: PhaseRunner;
|
||||
private guest!: LinuxGuest;
|
||||
|
||||
private status = {
|
||||
daemon: "systemd-user-unavailable",
|
||||
freshAgent: "skip",
|
||||
freshGateway: "skip",
|
||||
freshMain: "skip",
|
||||
freshVersion: "skip",
|
||||
latestInstalledVersion: "skip",
|
||||
upgrade: "skip",
|
||||
upgradeAgent: "skip",
|
||||
upgradeGateway: "skip",
|
||||
upgradeVersion: "skip",
|
||||
};
|
||||
|
||||
constructor(private options: LinuxOptions) {
|
||||
this.auth = resolveProviderAuth({
|
||||
apiKeyEnv: options.apiKeyEnv,
|
||||
modelId: options.modelId,
|
||||
provider: options.provider,
|
||||
});
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
this.runDir = await makeTempDir("openclaw-parallels-linux.");
|
||||
this.phases = new PhaseRunner(this.runDir);
|
||||
this.tgzDir = await makeTempDir("openclaw-parallels-linux-tgz.");
|
||||
try {
|
||||
this.options.vmName = this.resolveVmName();
|
||||
this.snapshot = resolveSnapshot(this.options.vmName, this.options.snapshotHint);
|
||||
this.guest = new LinuxGuest(this.options.vmName, this.phases);
|
||||
this.latestVersion = resolveLatestVersion(this.options.latestVersion);
|
||||
this.hostIp = resolveHostIp(this.options.hostIp);
|
||||
this.hostPort = await resolveHostPort(
|
||||
this.options.hostPort,
|
||||
this.options.hostPortExplicit,
|
||||
defaultOptions().hostPort,
|
||||
);
|
||||
|
||||
say(`VM: ${this.options.vmName}`);
|
||||
say(`Snapshot hint: ${this.options.snapshotHint}`);
|
||||
say(`Resolved snapshot: ${this.snapshot.name} [${this.snapshot.state}]`);
|
||||
say(`Latest npm version: ${this.latestVersion}`);
|
||||
say(
|
||||
`Current head: ${run("git", ["rev-parse", "--short", "HEAD"], { quiet: true }).stdout.trim()}`,
|
||||
);
|
||||
say(`Run logs: ${this.runDir}`);
|
||||
|
||||
this.artifact = await packOpenClaw({
|
||||
destination: this.tgzDir,
|
||||
packageSpec: this.options.targetPackageSpec,
|
||||
requireControlUi: false,
|
||||
});
|
||||
this.server = await startHostServer({
|
||||
artifactPath: this.artifact.path,
|
||||
dir: this.tgzDir,
|
||||
hostIp: this.hostIp,
|
||||
label: this.artifactLabel(),
|
||||
port: this.hostPort,
|
||||
});
|
||||
this.hostPort = this.server.port;
|
||||
|
||||
if (this.options.mode === "fresh" || this.options.mode === "both") {
|
||||
await this.runLane("fresh", async () => this.runFreshLane());
|
||||
}
|
||||
if (this.options.mode === "upgrade" || this.options.mode === "both") {
|
||||
await this.runLane("upgrade", async () => this.runUpgradeLane());
|
||||
}
|
||||
|
||||
const summaryPath = await this.writeSummary();
|
||||
if (this.options.json) {
|
||||
process.stdout.write(await readFile(summaryPath, "utf8"));
|
||||
} else {
|
||||
this.printSummary(summaryPath);
|
||||
}
|
||||
|
||||
if (this.status.freshMain === "fail" || this.status.upgrade === "fail") {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} finally {
|
||||
if (!this.options.keepServer) {
|
||||
await this.server?.stop().catch(() => undefined);
|
||||
}
|
||||
if (!this.options.keepServer) {
|
||||
await rm(this.tgzDir, { force: true, recursive: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async runLane(name: "fresh" | "upgrade", fn: () => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await fn();
|
||||
if (name === "fresh") {
|
||||
this.status.freshMain = "pass";
|
||||
} else {
|
||||
this.status.upgrade = "pass";
|
||||
}
|
||||
} catch (error) {
|
||||
if (name === "fresh") {
|
||||
this.status.freshMain = "fail";
|
||||
} else {
|
||||
this.status.upgrade = "fail";
|
||||
}
|
||||
warn(`${name} lane failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private artifactLabel(): string {
|
||||
return this.options.targetPackageSpec ? "target package tgz" : "current main tgz";
|
||||
}
|
||||
|
||||
private resolveVmName(): string {
|
||||
const payload = JSON.parse(
|
||||
run("prlctl", ["list", "--all", "--json"], { quiet: true }).stdout,
|
||||
) as Array<{
|
||||
name?: string;
|
||||
}>;
|
||||
const names = payload.map((item) => (item.name ?? "").trim()).filter(Boolean);
|
||||
if (names.includes(this.options.vmName)) {
|
||||
return this.options.vmName;
|
||||
}
|
||||
if (this.options.vmNameExplicit) {
|
||||
die(`VM not found: ${this.options.vmName}`);
|
||||
}
|
||||
const ubuntu = names
|
||||
.map((name) => ({ name, version: /ubuntu\s+(\d+(?:\.\d+)*)/i.exec(name)?.[1] }))
|
||||
.filter((item): item is { name: string; version: string } => Boolean(item.version))
|
||||
.map((item) => ({
|
||||
name: item.name,
|
||||
parts: item.version.split(".").map(Number),
|
||||
version: item.version,
|
||||
}))
|
||||
.filter((item) => item.parts[0] >= 24)
|
||||
.toSorted((a, b) => compareVersions(a.parts, b.parts));
|
||||
const fallback = ubuntu[0]?.name ?? names.find((name) => /ubuntu/i.test(name));
|
||||
if (!fallback) {
|
||||
die(`VM not found: ${this.options.vmName}`);
|
||||
}
|
||||
warn(`requested VM ${this.options.vmName} not found; using ${fallback}`);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private async runFreshLane(): Promise<void> {
|
||||
await this.phase("fresh.restore-snapshot", 180, () => this.restoreSnapshot());
|
||||
await this.phase("fresh.bootstrap-guest", 600, () => this.bootstrapGuest());
|
||||
await this.phase("fresh.install-latest-bootstrap", 420, () => this.installLatestRelease());
|
||||
await this.phase("fresh.install-main", 420, () =>
|
||||
this.installMainTgz("openclaw-main-fresh.tgz"),
|
||||
);
|
||||
this.status.freshVersion = await this.extractLastVersion("fresh.install-main");
|
||||
await this.phase("fresh.verify-main-version", 90, () => this.verifyTargetVersion());
|
||||
await this.phase("fresh.onboard-ref", 180, () => this.runRefOnboard());
|
||||
await this.phase("fresh.inject-bad-plugin", 90, () => this.injectBadPluginFixture());
|
||||
await this.phase("fresh.gateway-start", 240, () => this.startGatewayBackground());
|
||||
await this.phase("fresh.bad-plugin-diagnostic", 90, () => this.verifyBadPluginDiagnostic());
|
||||
await this.phase("fresh.gateway-status", 240, () => this.verifyGatewayStatus());
|
||||
this.status.freshGateway = "pass";
|
||||
await this.phase(
|
||||
"fresh.first-local-agent-turn",
|
||||
Number(process.env.OPENCLAW_PARALLELS_LINUX_AGENT_TIMEOUT_S || 300),
|
||||
() => this.verifyLocalTurn(),
|
||||
);
|
||||
this.status.freshAgent = "pass";
|
||||
}
|
||||
|
||||
private async runUpgradeLane(): Promise<void> {
|
||||
await this.phase("upgrade.restore-snapshot", 180, () => this.restoreSnapshot());
|
||||
await this.phase("upgrade.bootstrap-guest", 600, () => this.bootstrapGuest());
|
||||
await this.phase("upgrade.install-latest", 420, () => this.installLatestRelease());
|
||||
this.status.latestInstalledVersion = await this.extractLastVersion("upgrade.install-latest");
|
||||
await this.phase("upgrade.verify-latest-version", 90, () =>
|
||||
this.verifyVersionContains(this.latestVersion),
|
||||
);
|
||||
await this.phase("upgrade.install-main", 420, () =>
|
||||
this.installMainTgz("openclaw-main-upgrade.tgz"),
|
||||
);
|
||||
this.status.upgradeVersion = await this.extractLastVersion("upgrade.install-main");
|
||||
await this.phase("upgrade.verify-main-version", 90, () => this.verifyTargetVersion());
|
||||
await this.phase("upgrade.inject-bad-plugin", 90, () => this.injectBadPluginFixture());
|
||||
await this.phase("upgrade.onboard-ref", 180, () => this.runRefOnboard());
|
||||
await this.phase("upgrade.gateway-start", 240, () => this.startGatewayBackground());
|
||||
await this.phase("upgrade.bad-plugin-diagnostic", 90, () => this.verifyBadPluginDiagnostic());
|
||||
await this.phase("upgrade.gateway-status", 240, () => this.verifyGatewayStatus());
|
||||
this.status.upgradeGateway = "pass";
|
||||
await this.phase(
|
||||
"upgrade.first-local-agent-turn",
|
||||
Number(process.env.OPENCLAW_PARALLELS_LINUX_AGENT_TIMEOUT_S || 300),
|
||||
() => this.verifyLocalTurn(),
|
||||
);
|
||||
this.status.upgradeAgent = "pass";
|
||||
}
|
||||
|
||||
private async phase(
|
||||
name: string,
|
||||
timeoutSeconds: number,
|
||||
fn: () => Promise<void> | void,
|
||||
): Promise<void> {
|
||||
await this.phases.phase(name, timeoutSeconds, fn);
|
||||
}
|
||||
|
||||
private remainingPhaseTimeoutMs(): number | undefined {
|
||||
return this.phases.remainingTimeoutMs();
|
||||
}
|
||||
|
||||
private log(text: string): void {
|
||||
this.phases.append(text);
|
||||
}
|
||||
|
||||
private guestExec(args: string[], options: { check?: boolean; timeoutMs?: number } = {}): string {
|
||||
return this.guest.exec(args, options);
|
||||
}
|
||||
|
||||
private guestBash(script: string): string {
|
||||
return this.guest.bash(script);
|
||||
}
|
||||
|
||||
private waitForVmStatus(expected: string, timeoutSeconds = 180): void {
|
||||
const deadline = Date.now() + timeoutSeconds * 1000;
|
||||
while (Date.now() < deadline) {
|
||||
const status = run("prlctl", ["status", this.options.vmName], {
|
||||
check: false,
|
||||
quiet: true,
|
||||
}).stdout;
|
||||
if (status.includes(` ${expected}`)) {
|
||||
return;
|
||||
}
|
||||
run("sleep", ["1"], { quiet: true });
|
||||
}
|
||||
die(`VM ${this.options.vmName} did not reach ${expected}`);
|
||||
}
|
||||
|
||||
private waitForGuestReady(timeoutSeconds = 180): void {
|
||||
const deadline = Date.now() + timeoutSeconds * 1000;
|
||||
while (Date.now() < deadline) {
|
||||
if (
|
||||
run("prlctl", ["exec", this.options.vmName, "/usr/bin/env", "HOME=/root", "/bin/true"], {
|
||||
check: false,
|
||||
quiet: true,
|
||||
timeoutMs: this.remainingPhaseTimeoutMs(),
|
||||
}).status === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
run("sleep", ["2"], { quiet: true });
|
||||
}
|
||||
die(`guest did not become ready in ${this.options.vmName}`);
|
||||
}
|
||||
|
||||
private restoreSnapshot(): void {
|
||||
say(`Restore snapshot ${this.options.snapshotHint} (${this.snapshot.id})`);
|
||||
run("prlctl", ["snapshot-switch", this.options.vmName, "--id", this.snapshot.id], {
|
||||
quiet: true,
|
||||
});
|
||||
if (this.snapshot.state === "poweroff") {
|
||||
this.waitForVmStatus("stopped");
|
||||
say(`Start restored poweroff snapshot ${this.snapshot.name}`);
|
||||
run("prlctl", ["start", this.options.vmName], { quiet: true });
|
||||
}
|
||||
this.waitForGuestReady();
|
||||
}
|
||||
|
||||
private bootstrapGuest(): void {
|
||||
const hostNow = `@${Math.floor(Date.now() / 1000)}`;
|
||||
this.guestExec(["date", "-u", "-s", hostNow]);
|
||||
this.guestExec(["hwclock", "--systohc"], { check: false });
|
||||
this.guestExec(["timedatectl", "set-ntp", "true"], { check: false });
|
||||
this.guestExec(["systemctl", "restart", "systemd-timesyncd"], { check: false });
|
||||
this.guestExec(["apt-get", "-o", "Acquire::Check-Date=false", "update"]);
|
||||
this.guestExec(["apt-get", "install", "-y", "curl", "ca-certificates"]);
|
||||
}
|
||||
|
||||
private installLatestRelease(): void {
|
||||
this.guestExec(["curl", "-fsSL", this.options.installUrl, "-o", "/tmp/openclaw-install.sh"]);
|
||||
if (this.options.installVersion) {
|
||||
this.guestExec([
|
||||
"/usr/bin/env",
|
||||
"OPENCLAW_NO_ONBOARD=1",
|
||||
"bash",
|
||||
"/tmp/openclaw-install.sh",
|
||||
"--version",
|
||||
this.options.installVersion,
|
||||
"--no-onboard",
|
||||
]);
|
||||
} else {
|
||||
this.guestExec([
|
||||
"/usr/bin/env",
|
||||
"OPENCLAW_NO_ONBOARD=1",
|
||||
"bash",
|
||||
"/tmp/openclaw-install.sh",
|
||||
"--no-onboard",
|
||||
]);
|
||||
}
|
||||
this.guestExec(["openclaw", "--version"]);
|
||||
}
|
||||
|
||||
private installMainTgz(tempName: string): void {
|
||||
if (!this.artifact || !this.server) {
|
||||
die("package artifact/server missing");
|
||||
}
|
||||
const tgzUrl = this.server.urlFor(this.artifact.path);
|
||||
this.guestExec(["curl", "-fsSL", tgzUrl, "-o", `/tmp/${tempName}`]);
|
||||
this.guestExec(["npm", "install", "-g", `/tmp/${tempName}`, "--no-fund", "--no-audit"]);
|
||||
this.guestExec(["openclaw", "--version"]);
|
||||
}
|
||||
|
||||
private async verifyTargetVersion(): Promise<void> {
|
||||
if (!this.artifact) {
|
||||
die("package artifact missing");
|
||||
}
|
||||
if (this.options.targetPackageSpec) {
|
||||
const version = this.artifact.version || (await packageVersionFromTgz(this.artifact.path));
|
||||
this.verifyVersionContains(version);
|
||||
return;
|
||||
}
|
||||
const commit =
|
||||
this.artifact.buildCommitShort ||
|
||||
(await packageBuildCommitFromTgz(this.artifact.path)).slice(0, 7);
|
||||
this.verifyVersionContains(commit);
|
||||
}
|
||||
|
||||
private verifyVersionContains(needle: string): void {
|
||||
const version = this.guestExec(["openclaw", "--version"]);
|
||||
if (!version.includes(needle)) {
|
||||
throw new Error(`version mismatch: expected substring ${needle}`);
|
||||
}
|
||||
}
|
||||
|
||||
private runRefOnboard(): void {
|
||||
this.guestExec([
|
||||
"/usr/bin/env",
|
||||
`${this.auth.apiKeyEnv}=${this.auth.apiKeyValue}`,
|
||||
"openclaw",
|
||||
"onboard",
|
||||
"--non-interactive",
|
||||
"--mode",
|
||||
"local",
|
||||
"--auth-choice",
|
||||
this.auth.authChoice,
|
||||
"--secret-input-mode",
|
||||
"ref",
|
||||
"--gateway-port",
|
||||
"18789",
|
||||
"--gateway-bind",
|
||||
"loopback",
|
||||
"--skip-skills",
|
||||
"--skip-health",
|
||||
"--accept-risk",
|
||||
"--json",
|
||||
]);
|
||||
}
|
||||
|
||||
private injectBadPluginFixture(): void {
|
||||
this.guestBash(String.raw`set -euo pipefail
|
||||
plugin_dir=/root/.openclaw/test-bad-plugin
|
||||
mkdir -p "$plugin_dir"
|
||||
cat >"$plugin_dir/package.json" <<'JSON'
|
||||
{"name":"@openclaw/test-bad-plugin","version":"1.0.0","openclaw":{"extensions":["./index.cjs"],"setupEntry":"./setup-entry.cjs"}}
|
||||
JSON
|
||||
cat >"$plugin_dir/openclaw.plugin.json" <<'JSON'
|
||||
{"id":"test-bad-plugin","configSchema":{"type":"object","additionalProperties":false,"properties":{}},"channels":["test-bad-plugin"]}
|
||||
JSON
|
||||
cat >"$plugin_dir/index.cjs" <<'JS'
|
||||
module.exports = { id: "test-bad-plugin", register() {} };
|
||||
JS
|
||||
cat >"$plugin_dir/setup-entry.cjs" <<'JS'
|
||||
module.exports = {
|
||||
kind: "bundled-channel-setup-entry",
|
||||
loadSetupPlugin() {
|
||||
throw new Error("boom: bad plugin smoke fixture");
|
||||
},
|
||||
};
|
||||
JS
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
config_path = Path("/root/.openclaw/openclaw.json")
|
||||
config = json.loads(config_path.read_text()) if config_path.exists() else {}
|
||||
plugins = config.setdefault("plugins", {})
|
||||
load = plugins.setdefault("load", {})
|
||||
paths = load.setdefault("paths", [])
|
||||
plugin_dir = "/root/.openclaw/test-bad-plugin"
|
||||
if plugin_dir not in paths:
|
||||
paths.append(plugin_dir)
|
||||
allow = plugins.get("allow")
|
||||
if isinstance(allow, list) and "test-bad-plugin" not in allow:
|
||||
allow.append("test-bad-plugin")
|
||||
config_path.write_text(json.dumps(config, indent=2) + "\n")
|
||||
PY`);
|
||||
}
|
||||
|
||||
private startGatewayBackground(): void {
|
||||
const bonjourEnv = this.disableBonjour ? " OPENCLAW_DISABLE_BONJOUR=1" : "";
|
||||
this.guestExec([
|
||||
"bash",
|
||||
"-lc",
|
||||
String.raw`pkill -f "openclaw gateway run" >/dev/null 2>&1 || true
|
||||
rm -f /tmp/openclaw-parallels-linux-gateway.log
|
||||
setsid sh -lc ` +
|
||||
shellQuote(
|
||||
`exec env OPENCLAW_HOME=/root OPENCLAW_STATE_DIR=/root/.openclaw OPENCLAW_CONFIG_PATH=/root/.openclaw/openclaw.json${bonjourEnv} ${this.auth.apiKeyEnv}=${shellQuote(
|
||||
this.auth.apiKeyValue,
|
||||
)} openclaw gateway run --bind loopback --port 18789 --force >/tmp/openclaw-parallels-linux-gateway.log 2>&1`,
|
||||
) +
|
||||
String.raw` >/dev/null 2>&1 < /dev/null &`,
|
||||
]);
|
||||
const deadline = Date.now() + 240_000;
|
||||
while (Date.now() < deadline) {
|
||||
if (this.showGatewayStatusCompat(false)) {
|
||||
return;
|
||||
}
|
||||
run("sleep", ["2"], { quiet: true });
|
||||
}
|
||||
throw new Error("gateway did not become ready");
|
||||
}
|
||||
|
||||
private showGatewayStatusCompat(check = true): boolean {
|
||||
const help = this.guestExec(["openclaw", "gateway", "status", "--help"], { check: false });
|
||||
const args = help.includes("--require-rpc")
|
||||
? ["openclaw", "gateway", "status", "--deep", "--require-rpc"]
|
||||
: ["openclaw", "gateway", "status", "--deep"];
|
||||
const result = run(
|
||||
"prlctl",
|
||||
["exec", this.options.vmName, "/usr/bin/env", "HOME=/root", ...args],
|
||||
{
|
||||
check: false,
|
||||
quiet: true,
|
||||
timeoutMs: this.remainingPhaseTimeoutMs(),
|
||||
},
|
||||
);
|
||||
this.log(result.stdout);
|
||||
this.log(result.stderr);
|
||||
if (check && result.status !== 0) {
|
||||
throw new Error("gateway status failed");
|
||||
}
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
private verifyGatewayStatus(): void {
|
||||
for (let attempt = 1; attempt <= 8; attempt++) {
|
||||
const result = run(
|
||||
"prlctl",
|
||||
[
|
||||
"exec",
|
||||
this.options.vmName,
|
||||
"/usr/bin/env",
|
||||
"HOME=/root",
|
||||
"openclaw",
|
||||
"gateway",
|
||||
"status",
|
||||
"--deep",
|
||||
"--require-rpc",
|
||||
"--timeout",
|
||||
"15000",
|
||||
],
|
||||
{ check: false, quiet: true, timeoutMs: this.remainingPhaseTimeoutMs() },
|
||||
);
|
||||
this.log(result.stdout);
|
||||
this.log(result.stderr);
|
||||
if (result.status === 0) {
|
||||
return;
|
||||
}
|
||||
if (attempt < 8) {
|
||||
warn(`gateway-status retry ${attempt}`);
|
||||
run("sleep", ["5"], { quiet: true });
|
||||
}
|
||||
}
|
||||
throw new Error("gateway status did not become RPC-ready");
|
||||
}
|
||||
|
||||
private verifyBadPluginDiagnostic(): void {
|
||||
this.guestExec([
|
||||
"bash",
|
||||
"-lc",
|
||||
'grep -F "failed to load setup entry" /tmp/openclaw-parallels-linux-gateway.log',
|
||||
]);
|
||||
}
|
||||
|
||||
private verifyLocalTurn(): void {
|
||||
this.guestExec(["openclaw", "models", "set", this.auth.modelId]);
|
||||
this.guestExec([
|
||||
"openclaw",
|
||||
"config",
|
||||
"set",
|
||||
"agents.defaults.skipBootstrap",
|
||||
"true",
|
||||
"--strict-json",
|
||||
]);
|
||||
this.prepareAgentWorkspace();
|
||||
this.guestExec([
|
||||
"/bin/sh",
|
||||
"-lc",
|
||||
`exec /usr/bin/env ${shellQuote(`${this.auth.apiKeyEnv}=${this.auth.apiKeyValue}`)} openclaw agent --local --agent main --session-id parallels-linux-smoke --message ${shellQuote(
|
||||
"Reply with exact ASCII text OK only.",
|
||||
)} --json`,
|
||||
]);
|
||||
}
|
||||
|
||||
private prepareAgentWorkspace(): void {
|
||||
this.guestExec([
|
||||
"/bin/sh",
|
||||
"-lc",
|
||||
posixAgentWorkspaceScript("Parallels Linux smoke test assistant."),
|
||||
]);
|
||||
}
|
||||
|
||||
private async extractLastVersion(phaseId: string): Promise<string> {
|
||||
const text = await readFile(path.join(this.runDir, `${phaseId}.log`), "utf8").catch(() => "");
|
||||
return [...text.matchAll(/OpenClaw [^\r\n]+ \([0-9a-f]{7,}\)/g)].at(-1)?.[0] ?? "";
|
||||
}
|
||||
|
||||
private async writeSummary(): Promise<string> {
|
||||
const summaryPath = path.join(this.runDir, "summary.json");
|
||||
const summary: LinuxSummary = {
|
||||
currentHead:
|
||||
this.artifact?.buildCommitShort ||
|
||||
run("git", ["rev-parse", "--short", "HEAD"], { quiet: true }).stdout.trim(),
|
||||
daemon: this.status.daemon,
|
||||
freshMain: {
|
||||
agent: this.status.freshAgent,
|
||||
gateway: this.status.freshGateway,
|
||||
status: this.status.freshMain,
|
||||
version: this.status.freshVersion,
|
||||
},
|
||||
installVersion: this.options.installVersion || "",
|
||||
latestVersion: this.latestVersion,
|
||||
mode: this.options.mode,
|
||||
provider: this.options.provider,
|
||||
runDir: this.runDir,
|
||||
snapshotHint: this.options.snapshotHint,
|
||||
snapshotId: this.snapshot.id,
|
||||
targetPackageSpec: this.options.targetPackageSpec || "",
|
||||
upgrade: {
|
||||
agent: this.status.upgradeAgent,
|
||||
gateway: this.status.upgradeGateway,
|
||||
latestVersionInstalled: this.status.latestInstalledVersion,
|
||||
mainVersion: this.status.upgradeVersion,
|
||||
status: this.status.upgrade,
|
||||
},
|
||||
vm: this.options.vmName,
|
||||
};
|
||||
await writeJson(summaryPath, summary);
|
||||
return summaryPath;
|
||||
}
|
||||
|
||||
private printSummary(summaryPath: string): void {
|
||||
process.stdout.write("\nSummary:\n");
|
||||
if (this.options.targetPackageSpec) {
|
||||
process.stdout.write(` target-package: ${this.options.targetPackageSpec}\n`);
|
||||
}
|
||||
if (this.options.installVersion) {
|
||||
process.stdout.write(` baseline-install-version: ${this.options.installVersion}\n`);
|
||||
}
|
||||
process.stdout.write(` daemon: ${this.status.daemon}\n`);
|
||||
process.stdout.write(` fresh-main: ${this.status.freshMain} (${this.status.freshVersion})\n`);
|
||||
process.stdout.write(
|
||||
` latest->main: ${this.status.upgrade} (${this.status.upgradeVersion})\n`,
|
||||
);
|
||||
process.stdout.write(` logs: ${this.runDir}\n`);
|
||||
process.stdout.write(` summary: ${summaryPath}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function compareVersions(a: number[], b: number[]): number {
|
||||
for (let index = 0; index < Math.max(a.length, b.length); index++) {
|
||||
const diff = (a[index] ?? 0) - (b[index] ?? 0);
|
||||
if (diff !== 0) {
|
||||
return diff;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
await mkdir(repoRoot, { recursive: true });
|
||||
await new LinuxSmoke(options).run();
|
||||
1252
scripts/e2e/parallels/macos-smoke.ts
Executable file
1252
scripts/e2e/parallels/macos-smoke.ts
Executable file
File diff suppressed because it is too large
Load Diff
719
scripts/e2e/parallels/npm-update-smoke.ts
Executable file
719
scripts/e2e/parallels/npm-update-smoke.ts
Executable file
@@ -0,0 +1,719 @@
|
||||
#!/usr/bin/env -S pnpm tsx
|
||||
import { spawn } from "node:child_process";
|
||||
import { readFile, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { posixAgentWorkspaceScript, windowsAgentWorkspaceScript } from "./agent-workspace.ts";
|
||||
import {
|
||||
die,
|
||||
ensureValue,
|
||||
makeTempDir,
|
||||
packOpenClaw,
|
||||
parsePlatformList,
|
||||
parseProvider,
|
||||
repoRoot,
|
||||
resolveHostIp,
|
||||
resolveLatestVersion,
|
||||
resolveProviderAuth,
|
||||
run,
|
||||
say,
|
||||
shellQuote,
|
||||
startHostServer,
|
||||
warn,
|
||||
writeJson,
|
||||
type HostServer,
|
||||
type PackageArtifact,
|
||||
type Platform,
|
||||
type Provider,
|
||||
type ProviderAuth,
|
||||
} from "./common.ts";
|
||||
import { encodePowerShell, psSingleQuote } from "./powershell.ts";
|
||||
|
||||
interface NpmUpdateOptions {
|
||||
packageSpec: string;
|
||||
updateTarget: string;
|
||||
platforms: Set<Platform>;
|
||||
provider: Provider;
|
||||
apiKeyEnv?: string;
|
||||
modelId?: string;
|
||||
json: boolean;
|
||||
}
|
||||
|
||||
interface Job {
|
||||
done: boolean;
|
||||
label: string;
|
||||
logPath: string;
|
||||
promise: Promise<number>;
|
||||
}
|
||||
|
||||
interface NpmUpdateSummary {
|
||||
packageSpec: string;
|
||||
updateTarget: string;
|
||||
updateExpected: string;
|
||||
provider: Provider;
|
||||
latestVersion: string;
|
||||
currentHead: string;
|
||||
runDir: string;
|
||||
fresh: Record<Platform, string>;
|
||||
update: Record<Platform, { status: string; version: string }>;
|
||||
}
|
||||
|
||||
const macosVm = "macOS Tahoe";
|
||||
const windowsVm = "Windows 11";
|
||||
const linuxVmDefault = "Ubuntu 24.04.3 ARM64";
|
||||
const updateTimeoutSeconds = Number(process.env.OPENCLAW_PARALLELS_NPM_UPDATE_TIMEOUT_S || 1200);
|
||||
|
||||
function usage(): string {
|
||||
return `Usage: bash scripts/e2e/parallels-npm-update-smoke.sh [options]
|
||||
|
||||
Options:
|
||||
--package-spec <npm-spec> Baseline npm package spec. Default: openclaw@latest
|
||||
--update-target <target> Target passed to guest 'openclaw update --tag'.
|
||||
Default: host-served tgz packed from current checkout.
|
||||
--platform <list> Comma-separated platforms to run: all, macos, windows, linux.
|
||||
Default: all
|
||||
--provider <openai|anthropic|minimax>
|
||||
--model <provider/model> Override the model used for agent-turn smoke checks.
|
||||
--api-key-env <var> Host env var name for provider API key.
|
||||
--openai-api-key-env <var> Alias for --api-key-env (backward compatible)
|
||||
--json Print machine-readable JSON summary.
|
||||
-h, --help Show help.
|
||||
`;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): NpmUpdateOptions {
|
||||
const options: NpmUpdateOptions = {
|
||||
apiKeyEnv: undefined,
|
||||
json: false,
|
||||
modelId: undefined,
|
||||
packageSpec: "",
|
||||
platforms: parsePlatformList("all"),
|
||||
provider: "openai",
|
||||
updateTarget: "",
|
||||
};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
switch (arg) {
|
||||
case "--":
|
||||
break;
|
||||
case "--package-spec":
|
||||
options.packageSpec = ensureValue(argv, i, arg);
|
||||
i++;
|
||||
break;
|
||||
case "--update-target":
|
||||
options.updateTarget = ensureValue(argv, i, arg);
|
||||
i++;
|
||||
break;
|
||||
case "--platform":
|
||||
case "--only":
|
||||
options.platforms = parsePlatformList(ensureValue(argv, i, arg));
|
||||
i++;
|
||||
break;
|
||||
case "--provider":
|
||||
options.provider = parseProvider(ensureValue(argv, i, arg));
|
||||
i++;
|
||||
break;
|
||||
case "--model":
|
||||
options.modelId = ensureValue(argv, i, arg);
|
||||
i++;
|
||||
break;
|
||||
case "--api-key-env":
|
||||
case "--openai-api-key-env":
|
||||
options.apiKeyEnv = ensureValue(argv, i, arg);
|
||||
i++;
|
||||
break;
|
||||
case "--json":
|
||||
options.json = true;
|
||||
break;
|
||||
case "-h":
|
||||
case "--help":
|
||||
process.stdout.write(usage());
|
||||
process.exit(0);
|
||||
default:
|
||||
die(`unknown arg: ${arg}`);
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function platformRecord<T>(value: T): Record<Platform, T> {
|
||||
return { linux: value, macos: value, windows: value };
|
||||
}
|
||||
|
||||
class NpmUpdateSmoke {
|
||||
private auth: ProviderAuth;
|
||||
private runDir = "";
|
||||
private tgzDir = "";
|
||||
private latestVersion = "";
|
||||
private packageSpec = "";
|
||||
private currentHead = "";
|
||||
private currentHeadShort = "";
|
||||
private hostIp = "";
|
||||
private server: HostServer | null = null;
|
||||
private artifact: PackageArtifact | null = null;
|
||||
private updateTargetEffective = "";
|
||||
private updateExpectedNeedle = "";
|
||||
private linuxVm = linuxVmDefault;
|
||||
|
||||
private freshStatus = platformRecord("skip");
|
||||
private updateStatus = platformRecord("skip");
|
||||
private updateVersion = platformRecord("skip");
|
||||
|
||||
constructor(private options: NpmUpdateOptions) {
|
||||
this.auth = resolveProviderAuth({
|
||||
apiKeyEnv: options.apiKeyEnv,
|
||||
modelId: options.modelId,
|
||||
provider: options.provider,
|
||||
});
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
this.runDir = await makeTempDir("openclaw-parallels-npm-update.");
|
||||
this.tgzDir = await makeTempDir("openclaw-parallels-npm-update-tgz.");
|
||||
try {
|
||||
this.latestVersion = resolveLatestVersion();
|
||||
this.packageSpec = this.options.packageSpec || `openclaw@${this.latestVersion}`;
|
||||
this.currentHead = run("git", ["rev-parse", "HEAD"], { quiet: true }).stdout.trim();
|
||||
this.currentHeadShort = run("git", ["rev-parse", "--short=7", "HEAD"], {
|
||||
quiet: true,
|
||||
}).stdout.trim();
|
||||
this.hostIp = resolveHostIp("");
|
||||
|
||||
if (this.options.platforms.has("linux")) {
|
||||
this.linuxVm = this.resolveLinuxVmName();
|
||||
}
|
||||
this.preflightRegistryUpdateTarget();
|
||||
|
||||
say(`Run fresh npm baseline: ${this.packageSpec}`);
|
||||
say(`Platforms: ${[...this.options.platforms].join(",")}`);
|
||||
say(`Run dir: ${this.runDir}`);
|
||||
await this.runFreshBaselines();
|
||||
|
||||
await this.prepareUpdateTarget();
|
||||
say(`Run same-guest openclaw update to ${this.updateTargetEffective}`);
|
||||
await this.runSameGuestUpdates();
|
||||
|
||||
const summaryPath = await this.writeSummary();
|
||||
if (this.options.json) {
|
||||
process.stdout.write(await readFile(summaryPath, "utf8"));
|
||||
} else {
|
||||
say(`Run dir: ${this.runDir}`);
|
||||
process.stdout.write(await readFile(summaryPath, "utf8"));
|
||||
}
|
||||
} finally {
|
||||
await this.server?.stop().catch(() => undefined);
|
||||
await rm(this.tgzDir, { force: true, recursive: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
private async runFreshBaselines(): Promise<void> {
|
||||
const jobs: Job[] = [];
|
||||
if (this.options.platforms.has("macos")) {
|
||||
jobs.push(this.spawnFresh("macOS", "macos", []));
|
||||
}
|
||||
if (this.options.platforms.has("windows")) {
|
||||
jobs.push(this.spawnFresh("Windows", "windows", []));
|
||||
}
|
||||
if (this.options.platforms.has("linux")) {
|
||||
jobs.push(
|
||||
this.spawnFresh("Linux", "linux", ["--vm", this.linuxVm], {
|
||||
OPENCLAW_PARALLELS_LINUX_DISABLE_BONJOUR: "1",
|
||||
}),
|
||||
);
|
||||
}
|
||||
await this.monitorJobs("fresh", jobs);
|
||||
for (const job of jobs) {
|
||||
const status = (await job.promise) === 0 ? "pass" : "fail";
|
||||
const platform = this.platformFromLabel(job.label);
|
||||
this.freshStatus[platform] = status;
|
||||
if (status !== "pass") {
|
||||
this.dumpLogTail(job.logPath);
|
||||
die(`${job.label} fresh baseline failed`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private spawnFresh(
|
||||
label: string,
|
||||
platform: Platform,
|
||||
extraArgs: string[],
|
||||
env: NodeJS.ProcessEnv = {},
|
||||
): Job {
|
||||
const logPath = path.join(this.runDir, `${platform}-fresh.log`);
|
||||
const args = [
|
||||
"exec",
|
||||
"tsx",
|
||||
`scripts/e2e/parallels/${platform}-smoke.ts`,
|
||||
"--mode",
|
||||
"fresh",
|
||||
"--provider",
|
||||
this.options.provider,
|
||||
"--model",
|
||||
this.auth.modelId,
|
||||
"--api-key-env",
|
||||
this.auth.apiKeyEnv,
|
||||
"--target-package-spec",
|
||||
this.packageSpec,
|
||||
"--json",
|
||||
...extraArgs,
|
||||
];
|
||||
const job: Job = {
|
||||
done: false,
|
||||
label,
|
||||
logPath,
|
||||
promise: Promise.resolve(1),
|
||||
};
|
||||
job.promise = this.spawnLogged("pnpm", args, logPath, env).finally(() => {
|
||||
job.done = true;
|
||||
});
|
||||
return job;
|
||||
}
|
||||
|
||||
private async prepareUpdateTarget(): Promise<void> {
|
||||
if (!this.options.updateTarget || this.options.updateTarget === "local-main") {
|
||||
this.artifact = await packOpenClaw({
|
||||
destination: this.tgzDir,
|
||||
requireControlUi: true,
|
||||
stageRuntimeDeps: true,
|
||||
});
|
||||
this.server = await startHostServer({
|
||||
artifactPath: this.artifact.path,
|
||||
dir: this.tgzDir,
|
||||
hostIp: this.hostIp,
|
||||
label: "current main tgz",
|
||||
port: 0,
|
||||
});
|
||||
this.updateTargetEffective = this.server.urlFor(this.artifact.path);
|
||||
this.updateExpectedNeedle = this.currentHeadShort;
|
||||
return;
|
||||
}
|
||||
this.updateTargetEffective = this.options.updateTarget;
|
||||
this.updateExpectedNeedle = this.isExplicitPackageTarget(this.updateTargetEffective)
|
||||
? ""
|
||||
: this.resolveRegistryTargetVersion(this.updateTargetEffective) || this.updateTargetEffective;
|
||||
}
|
||||
|
||||
private async runSameGuestUpdates(): Promise<void> {
|
||||
const jobs: Job[] = [];
|
||||
if (this.options.platforms.has("macos")) {
|
||||
this.ensureVmRunning(macosVm);
|
||||
jobs.push(this.spawnUpdate("macOS", "macos", () => this.runMacosUpdate()));
|
||||
}
|
||||
if (this.options.platforms.has("windows")) {
|
||||
this.ensureVmRunning(windowsVm);
|
||||
jobs.push(this.spawnUpdate("Windows", "windows", () => this.runWindowsUpdate()));
|
||||
}
|
||||
if (this.options.platforms.has("linux")) {
|
||||
this.ensureVmRunning(this.linuxVm);
|
||||
jobs.push(this.spawnUpdate("Linux", "linux", () => this.runLinuxUpdate()));
|
||||
}
|
||||
await this.monitorJobs("update", jobs);
|
||||
for (const job of jobs) {
|
||||
const platform = this.platformFromLabel(job.label);
|
||||
const status = (await job.promise) === 0 ? "pass" : "fail";
|
||||
this.updateStatus[platform] = status;
|
||||
this.updateVersion[platform] = await this.extractLastVersion(job.logPath);
|
||||
if (status !== "pass") {
|
||||
this.dumpLogTail(job.logPath);
|
||||
die(`${job.label} update failed`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private spawnUpdate(label: string, platform: Platform, fn: () => void): Job {
|
||||
const logPath = path.join(this.runDir, `${platform}-update.log`);
|
||||
const job: Job = {
|
||||
done: false,
|
||||
label,
|
||||
logPath,
|
||||
promise: Promise.resolve(1),
|
||||
};
|
||||
job.promise = (async () => {
|
||||
const originalStdout = process.stdout.write.bind(process.stdout);
|
||||
const originalStderr = process.stderr.write.bind(process.stderr);
|
||||
let log = "";
|
||||
const append = (chunk: string | Uint8Array): boolean => {
|
||||
const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
|
||||
log += text;
|
||||
return true;
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
append(`${label} update timed out after ${updateTimeoutSeconds}s\n`);
|
||||
}, updateTimeoutSeconds * 1000);
|
||||
try {
|
||||
process.stdout.write = ((chunk: string | Uint8Array) =>
|
||||
append(chunk)) as typeof process.stdout.write;
|
||||
process.stderr.write = ((chunk: string | Uint8Array) =>
|
||||
append(chunk)) as typeof process.stderr.write;
|
||||
fn();
|
||||
await writeFile(logPath, log, "utf8");
|
||||
return 0;
|
||||
} catch (error) {
|
||||
append(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
await writeFile(logPath, log, "utf8");
|
||||
return 1;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
process.stdout.write = originalStdout;
|
||||
process.stderr.write = originalStderr;
|
||||
}
|
||||
})().finally(() => {
|
||||
job.done = true;
|
||||
});
|
||||
return job;
|
||||
}
|
||||
|
||||
private runMacosUpdate(): void {
|
||||
const expectedCheck = this.updateExpectedNeedle
|
||||
? `version="$(/opt/homebrew/bin/openclaw --version)"; printf '%s\\n' "$version"; case "$version" in *${shellQuote(
|
||||
this.updateExpectedNeedle,
|
||||
)}*) ;; *) echo "version mismatch: expected ${this.updateExpectedNeedle}" >&2; exit 1 ;; esac`
|
||||
: "/opt/homebrew/bin/openclaw --version";
|
||||
const script = String.raw`set -euo pipefail
|
||||
scrub_future_plugin_entries() {
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
path = Path.home() / ".openclaw" / "openclaw.json"
|
||||
if not path.exists():
|
||||
raise SystemExit(0)
|
||||
try:
|
||||
config = json.loads(path.read_text())
|
||||
except Exception:
|
||||
raise SystemExit(0)
|
||||
plugins = config.get("plugins")
|
||||
if not isinstance(plugins, dict):
|
||||
raise SystemExit(0)
|
||||
entries = plugins.get("entries")
|
||||
if isinstance(entries, dict):
|
||||
entries.pop("feishu", None)
|
||||
entries.pop("whatsapp", None)
|
||||
allow = plugins.get("allow")
|
||||
if isinstance(allow, list):
|
||||
plugins["allow"] = [item for item in allow if item not in {"feishu", "whatsapp"}]
|
||||
path.write_text(json.dumps(config, indent=2) + "\n")
|
||||
PY
|
||||
}
|
||||
stop_openclaw_gateway_processes() {
|
||||
OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 /opt/homebrew/bin/openclaw gateway stop || true
|
||||
pkill -f 'openclaw.*gateway' >/dev/null 2>&1 || true
|
||||
}
|
||||
scrub_future_plugin_entries
|
||||
stop_openclaw_gateway_processes
|
||||
OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 /opt/homebrew/bin/openclaw update --tag ${shellQuote(this.updateTargetEffective)} --yes --json
|
||||
${expectedCheck}
|
||||
/opt/homebrew/bin/openclaw gateway restart
|
||||
/opt/homebrew/bin/openclaw gateway status --deep --require-rpc
|
||||
/opt/homebrew/bin/openclaw models set ${shellQuote(this.auth.modelId)}
|
||||
/opt/homebrew/bin/openclaw config set agents.defaults.skipBootstrap true --strict-json
|
||||
${posixAgentWorkspaceScript("Parallels npm update smoke test assistant.")}
|
||||
${this.auth.apiKeyEnv}=${shellQuote(this.auth.apiKeyValue)} /opt/homebrew/bin/openclaw agent --local --agent main --session-id parallels-npm-update-macos --message 'Reply with exact ASCII text OK only.' --json`;
|
||||
this.guestMacos(script, updateTimeoutSeconds * 1000);
|
||||
}
|
||||
|
||||
private runWindowsUpdate(): void {
|
||||
const expected = this.updateExpectedNeedle;
|
||||
this.guestWindows(
|
||||
`$ErrorActionPreference = 'Stop'
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
function Remove-FuturePluginEntries {
|
||||
$configPath = Join-Path $env:USERPROFILE '.openclaw\\openclaw.json'
|
||||
if (-not (Test-Path $configPath)) { return }
|
||||
try { $config = Get-Content $configPath -Raw | ConvertFrom-Json -AsHashtable } catch { return }
|
||||
$plugins = $config['plugins']
|
||||
if (-not ($plugins -is [hashtable])) { return }
|
||||
$entries = $plugins['entries']
|
||||
if ($entries -is [hashtable]) {
|
||||
foreach ($pluginId in @('feishu', 'whatsapp')) {
|
||||
if ($entries.ContainsKey($pluginId)) { $entries.Remove($pluginId) }
|
||||
}
|
||||
}
|
||||
$allow = $plugins['allow']
|
||||
if ($allow -is [array]) {
|
||||
$plugins['allow'] = @($allow | Where-Object { $_ -notin @('feishu', 'whatsapp') })
|
||||
}
|
||||
$config | ConvertTo-Json -Depth 100 | Set-Content -Path $configPath -Encoding UTF8
|
||||
}
|
||||
function Stop-OpenClawGatewayProcesses {
|
||||
$openclaw = Join-Path $env:APPDATA 'npm\\openclaw.cmd'
|
||||
& $openclaw gateway stop *>&1 | Out-Host
|
||||
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.CommandLine -match 'openclaw.*gateway' } |
|
||||
ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
Remove-FuturePluginEntries
|
||||
Stop-OpenClawGatewayProcesses
|
||||
$env:OPENCLAW_DISABLE_BUNDLED_PLUGINS = '1'
|
||||
$openclaw = Join-Path $env:APPDATA 'npm\\openclaw.cmd'
|
||||
& $openclaw update --tag ${psSingleQuote(this.updateTargetEffective)} --yes --json
|
||||
if ($LASTEXITCODE -ne 0) { throw "openclaw update failed with exit code $LASTEXITCODE" }
|
||||
$version = & $openclaw --version
|
||||
$version
|
||||
${expected ? `if (($version | Out-String) -notlike ${psSingleQuote(`*${expected}*`)}) { throw ${psSingleQuote(`version mismatch: expected ${expected}`)} }` : ""}
|
||||
& $openclaw gateway restart
|
||||
& $openclaw gateway status --deep --require-rpc
|
||||
& $openclaw models set ${psSingleQuote(this.auth.modelId)}
|
||||
& $openclaw config set agents.defaults.skipBootstrap true --strict-json
|
||||
${windowsAgentWorkspaceScript("Parallels npm update smoke test assistant.")}
|
||||
Set-Item -Path ('Env:' + ${psSingleQuote(this.auth.apiKeyEnv)}) -Value ${psSingleQuote(this.auth.apiKeyValue)}
|
||||
& $openclaw agent --local --agent main --session-id parallels-npm-update-windows --message 'Reply with exact ASCII text OK only.' --json`,
|
||||
updateTimeoutSeconds * 1000,
|
||||
);
|
||||
}
|
||||
|
||||
private runLinuxUpdate(): void {
|
||||
const expectedCheck = this.updateExpectedNeedle
|
||||
? `version="$(openclaw --version)"; printf '%s\\n' "$version"; case "$version" in *${shellQuote(this.updateExpectedNeedle)}*) ;; *) echo "version mismatch: expected ${this.updateExpectedNeedle}" >&2; exit 1 ;; esac`
|
||||
: "openclaw --version";
|
||||
const script = String.raw`set -euo pipefail
|
||||
scrub_future_plugin_entries() {
|
||||
node - <<'JS'
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const configPath = path.join(process.env.HOME || "/root", ".openclaw", "openclaw.json");
|
||||
if (!fs.existsSync(configPath)) process.exit(0);
|
||||
let config;
|
||||
try { config = JSON.parse(fs.readFileSync(configPath, "utf8")); } catch { process.exit(0); }
|
||||
const plugins = config.plugins;
|
||||
if (!plugins || typeof plugins !== "object") process.exit(0);
|
||||
if (plugins.entries && typeof plugins.entries === "object") {
|
||||
delete plugins.entries.feishu;
|
||||
delete plugins.entries.whatsapp;
|
||||
}
|
||||
if (Array.isArray(plugins.allow)) {
|
||||
plugins.allow = plugins.allow.filter((id) => id !== "feishu" && id !== "whatsapp");
|
||||
}
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
||||
JS
|
||||
}
|
||||
stop_openclaw_gateway_processes() {
|
||||
OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 openclaw gateway stop || true
|
||||
pkill -f 'openclaw.*gateway' >/dev/null 2>&1 || true
|
||||
}
|
||||
scrub_future_plugin_entries
|
||||
stop_openclaw_gateway_processes
|
||||
OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 openclaw update --tag ${shellQuote(this.updateTargetEffective)} --yes --json
|
||||
${expectedCheck}
|
||||
openclaw gateway restart
|
||||
openclaw gateway status --deep --require-rpc
|
||||
openclaw models set ${shellQuote(this.auth.modelId)}
|
||||
openclaw config set agents.defaults.skipBootstrap true --strict-json
|
||||
${posixAgentWorkspaceScript("Parallels npm update smoke test assistant.")}
|
||||
${this.auth.apiKeyEnv}=${shellQuote(this.auth.apiKeyValue)} openclaw agent --local --agent main --session-id parallels-npm-update-linux --message 'Reply with exact ASCII text OK only.' --json`;
|
||||
this.guestLinux(script, updateTimeoutSeconds * 1000);
|
||||
}
|
||||
|
||||
private spawnLogged(
|
||||
command: string,
|
||||
args: string[],
|
||||
logPath: string,
|
||||
env: NodeJS.ProcessEnv = {},
|
||||
): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: repoRoot,
|
||||
env: { ...process.env, ...env },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let log = "";
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
log += chunk.toString("utf8");
|
||||
});
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
log += chunk.toString("utf8");
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("close", async (code) => {
|
||||
await writeFile(logPath, log, "utf8");
|
||||
resolve(code ?? 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async monitorJobs(label: string, jobs: Job[]): Promise<void> {
|
||||
const pending = new Set(jobs.map((job) => job.label));
|
||||
while (pending.size > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 15_000));
|
||||
for (const job of jobs) {
|
||||
if (!pending.has(job.label)) {
|
||||
continue;
|
||||
}
|
||||
if (job.done) {
|
||||
pending.delete(job.label);
|
||||
}
|
||||
}
|
||||
if (pending.size > 0) {
|
||||
say(`${label} still running: ${[...pending].join(", ")}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private guestMacos(script: string, timeoutMs: number): void {
|
||||
run(
|
||||
"prlctl",
|
||||
[
|
||||
"exec",
|
||||
macosVm,
|
||||
"--current-user",
|
||||
"/usr/bin/env",
|
||||
"PATH=/opt/homebrew/bin:/opt/homebrew/opt/node/bin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin",
|
||||
"/bin/bash",
|
||||
"-lc",
|
||||
script,
|
||||
],
|
||||
{ timeoutMs },
|
||||
);
|
||||
}
|
||||
|
||||
private guestWindows(script: string, timeoutMs: number): void {
|
||||
run(
|
||||
"prlctl",
|
||||
[
|
||||
"exec",
|
||||
windowsVm,
|
||||
"--current-user",
|
||||
"powershell.exe",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-EncodedCommand",
|
||||
encodePowerShell(script),
|
||||
],
|
||||
{ timeoutMs },
|
||||
);
|
||||
}
|
||||
|
||||
private guestLinux(script: string, timeoutMs: number): void {
|
||||
run("prlctl", ["exec", this.linuxVm, "/usr/bin/env", "HOME=/root", "bash", "-lc", script], {
|
||||
timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
private ensureVmRunning(vmName: string): void {
|
||||
const deadline = Date.now() + 180_000;
|
||||
while (Date.now() < deadline) {
|
||||
const status = this.vmStatus(vmName);
|
||||
if (status === "running") {
|
||||
return;
|
||||
}
|
||||
if (status === "stopped") {
|
||||
say(`Start ${vmName} before update phase`);
|
||||
run("prlctl", ["start", vmName], { quiet: true });
|
||||
} else if (status === "suspended" || status === "paused") {
|
||||
say(`Resume ${vmName} before update phase`);
|
||||
run("prlctl", ["resume", vmName], { quiet: true });
|
||||
} else if (status === "missing") {
|
||||
die(`VM not found before update phase: ${vmName}`);
|
||||
}
|
||||
run("sleep", ["5"], { quiet: true });
|
||||
}
|
||||
die(`VM did not become running before update phase: ${vmName}`);
|
||||
}
|
||||
|
||||
private vmStatus(vmName: string): string {
|
||||
const payload = JSON.parse(
|
||||
run("prlctl", ["list", "--all", "--json"], { quiet: true }).stdout,
|
||||
) as Array<{
|
||||
name?: string;
|
||||
status?: string;
|
||||
}>;
|
||||
return payload.find((vm) => vm.name === vmName)?.status || "missing";
|
||||
}
|
||||
|
||||
private resolveLinuxVmName(): string {
|
||||
const payload = JSON.parse(
|
||||
run("prlctl", ["list", "--all", "--json"], { quiet: true }).stdout,
|
||||
) as Array<{
|
||||
name?: string;
|
||||
}>;
|
||||
const names = payload.map((item) => (item.name ?? "").trim()).filter(Boolean);
|
||||
if (names.includes(linuxVmDefault)) {
|
||||
return linuxVmDefault;
|
||||
}
|
||||
const fallback = names.find((name) => /ubuntu/i.test(name));
|
||||
if (!fallback) {
|
||||
die(`VM not found: ${linuxVmDefault}`);
|
||||
}
|
||||
warn(`requested VM ${linuxVmDefault} not found; using ${fallback}`);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private resolveRegistryTargetVersion(target: string): string {
|
||||
const spec = target.startsWith("openclaw@") ? target : `openclaw@${target}`;
|
||||
return (
|
||||
run("npm", ["view", spec, "version"], { check: false, quiet: true })
|
||||
.stdout.trim()
|
||||
.split("\n")
|
||||
.at(-1) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
private isExplicitPackageTarget(target: string): boolean {
|
||||
return (
|
||||
target.includes("://") ||
|
||||
target.includes("#") ||
|
||||
/^(file|github|git\+ssh|git\+https|git\+http|git\+file|npm):/.test(target)
|
||||
);
|
||||
}
|
||||
|
||||
private preflightRegistryUpdateTarget(): void {
|
||||
if (
|
||||
!this.options.updateTarget ||
|
||||
this.options.updateTarget === "local-main" ||
|
||||
this.isExplicitPackageTarget(this.options.updateTarget)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const baseline = this.resolveRegistryTargetVersion(this.packageSpec);
|
||||
const target = this.resolveRegistryTargetVersion(this.options.updateTarget);
|
||||
if (baseline && target && baseline === target) {
|
||||
die(
|
||||
`--update-target ${this.options.updateTarget} resolves to openclaw@${target}, same as baseline ${this.packageSpec}; publish or choose a newer --update-target before running VM update coverage`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private platformFromLabel(label: string): Platform {
|
||||
if (label === "macOS") {
|
||||
return "macos";
|
||||
}
|
||||
return label.toLowerCase() as Platform;
|
||||
}
|
||||
|
||||
private async extractLastVersion(logPath: string): Promise<string> {
|
||||
const log = await readFile(logPath, "utf8").catch(() => "");
|
||||
const matches = [...log.matchAll(/openclaw\s+([0-9][^\s]*)/g)];
|
||||
return matches.at(-1)?.[1] ?? "";
|
||||
}
|
||||
|
||||
private dumpLogTail(logPath: string): void {
|
||||
const log = run("tail", ["-n", "80", logPath], { check: false, quiet: true }).stdout;
|
||||
if (log) {
|
||||
process.stderr.write(log);
|
||||
}
|
||||
}
|
||||
|
||||
private async writeSummary(): Promise<string> {
|
||||
const summary: NpmUpdateSummary = {
|
||||
currentHead: this.currentHeadShort,
|
||||
fresh: this.freshStatus,
|
||||
latestVersion: this.latestVersion,
|
||||
packageSpec: this.packageSpec,
|
||||
provider: this.options.provider,
|
||||
runDir: this.runDir,
|
||||
update: {
|
||||
linux: { status: this.updateStatus.linux, version: this.updateVersion.linux },
|
||||
macos: { status: this.updateStatus.macos, version: this.updateVersion.macos },
|
||||
windows: { status: this.updateStatus.windows, version: this.updateVersion.windows },
|
||||
},
|
||||
updateExpected: this.updateExpectedNeedle,
|
||||
updateTarget: this.updateTargetEffective,
|
||||
};
|
||||
const summaryPath = path.join(this.runDir, "summary.json");
|
||||
await writeJson(summaryPath, summary);
|
||||
return summaryPath;
|
||||
}
|
||||
}
|
||||
|
||||
await new NpmUpdateSmoke(parseArgs(process.argv.slice(2))).run().catch((error: unknown) => {
|
||||
die(error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
145
scripts/e2e/parallels/package-artifact.ts
Normal file
145
scripts/e2e/parallels/package-artifact.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { copyFile, mkdir } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { exists, readJson } from "./filesystem.ts";
|
||||
import { die, repoRoot, run, say, sh } from "./host-command.ts";
|
||||
import type { PackageArtifact } from "./types.ts";
|
||||
|
||||
export async function extractPackageJsonFromTgz<T>(tgzPath: string, entry: string): Promise<T> {
|
||||
const output = run("tar", ["-xOf", tgzPath, entry], { quiet: true }).stdout;
|
||||
return JSON.parse(output) as T;
|
||||
}
|
||||
|
||||
export async function packageVersionFromTgz(tgzPath: string): Promise<string> {
|
||||
const pkg = await extractPackageJsonFromTgz<{ version: string }>(tgzPath, "package/package.json");
|
||||
return pkg.version;
|
||||
}
|
||||
|
||||
export async function packageBuildCommitFromTgz(tgzPath: string): Promise<string> {
|
||||
const info = await extractPackageJsonFromTgz<{ commit?: string }>(
|
||||
tgzPath,
|
||||
"package/dist/build-info.json",
|
||||
);
|
||||
return info.commit ?? "";
|
||||
}
|
||||
|
||||
export async function ensureCurrentBuild(input: {
|
||||
lockDir: string;
|
||||
requireControlUi?: boolean;
|
||||
checkDirty?: boolean;
|
||||
}): Promise<void> {
|
||||
void input.lockDir;
|
||||
const head = run("git", ["rev-parse", "HEAD"], { quiet: true }).stdout.trim();
|
||||
const buildInfoPath = path.join(repoRoot, "dist/build-info.json");
|
||||
let buildCommit = "";
|
||||
if (await exists(buildInfoPath)) {
|
||||
buildCommit = (await readJson<{ commit?: string }>(buildInfoPath)).commit ?? "";
|
||||
}
|
||||
const dirty =
|
||||
input.checkDirty !== false &&
|
||||
run(
|
||||
"git",
|
||||
[
|
||||
"status",
|
||||
"--porcelain",
|
||||
"--",
|
||||
"src",
|
||||
"ui",
|
||||
"packages",
|
||||
"extensions",
|
||||
"package.json",
|
||||
"pnpm-lock.yaml",
|
||||
"tsconfig*.json",
|
||||
],
|
||||
{ quiet: true },
|
||||
).stdout.trim() !== "";
|
||||
const controlReady =
|
||||
!input.requireControlUi ||
|
||||
((await exists(path.join(repoRoot, "dist/control-ui/index.html"))) &&
|
||||
sh("compgen -G 'dist/control-ui/assets/*' >/dev/null", { check: false, quiet: true })
|
||||
.status === 0);
|
||||
if (buildCommit === head && !dirty && controlReady) {
|
||||
return;
|
||||
}
|
||||
say("Build dist for current head");
|
||||
run("pnpm", ["build"]);
|
||||
if (input.requireControlUi) {
|
||||
say("Build Control UI for current head");
|
||||
run("pnpm", ["ui:build"]);
|
||||
}
|
||||
const drift = run("git", ["status", "--porcelain", "--", "src/canvas-host/a2ui/.bundle.hash"], {
|
||||
quiet: true,
|
||||
}).stdout.trim();
|
||||
if (drift) {
|
||||
die(`generated file drift after build; commit or revert before Parallels packaging:\n${drift}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function packOpenClaw(input: {
|
||||
destination: string;
|
||||
packageSpec?: string;
|
||||
requireControlUi?: boolean;
|
||||
stageRuntimeDeps?: boolean;
|
||||
}): Promise<PackageArtifact> {
|
||||
await mkdir(input.destination, { recursive: true });
|
||||
if (input.packageSpec) {
|
||||
say(`Pack target package tgz: ${input.packageSpec}`);
|
||||
const output = run(
|
||||
"npm",
|
||||
[
|
||||
"pack",
|
||||
input.packageSpec,
|
||||
"--ignore-scripts",
|
||||
"--json",
|
||||
"--pack-destination",
|
||||
input.destination,
|
||||
],
|
||||
{ quiet: true },
|
||||
).stdout;
|
||||
const packed = JSON.parse(output).at(-1)?.filename as string | undefined;
|
||||
if (!packed) {
|
||||
die("npm pack did not report a filename");
|
||||
}
|
||||
const tgzPath = path.join(input.destination, path.basename(packed));
|
||||
const version = await packageVersionFromTgz(tgzPath);
|
||||
say(`Packed ${tgzPath}`);
|
||||
say(`Target package version: ${version}`);
|
||||
return { path: tgzPath, version };
|
||||
}
|
||||
|
||||
await ensureCurrentBuild({
|
||||
checkDirty: true,
|
||||
lockDir: path.join(tmpdir(), "openclaw-parallels-build.lock"),
|
||||
requireControlUi: input.requireControlUi,
|
||||
});
|
||||
run("node", [
|
||||
"--import",
|
||||
"tsx",
|
||||
"--input-type=module",
|
||||
"--eval",
|
||||
"import { writePackageDistInventory } from './src/infra/package-dist-inventory.ts'; await writePackageDistInventory(process.cwd());",
|
||||
]);
|
||||
if (input.stageRuntimeDeps) {
|
||||
run("node", ["scripts/stage-bundled-plugin-runtime-deps.mjs"]);
|
||||
}
|
||||
const shortHead = run("git", ["rev-parse", "--short", "HEAD"], { quiet: true }).stdout.trim();
|
||||
const output = run(
|
||||
"npm",
|
||||
["pack", "--ignore-scripts", "--json", "--pack-destination", input.destination],
|
||||
{
|
||||
quiet: true,
|
||||
},
|
||||
).stdout;
|
||||
const packed = JSON.parse(output).at(-1)?.filename as string | undefined;
|
||||
if (!packed) {
|
||||
die("npm pack did not report a filename");
|
||||
}
|
||||
const tgzPath = path.join(input.destination, `openclaw-main-${shortHead}.tgz`);
|
||||
await copyFile(path.join(input.destination, packed), tgzPath);
|
||||
const buildCommit = await packageBuildCommitFromTgz(tgzPath);
|
||||
if (!buildCommit) {
|
||||
die(`failed to read packed build commit from ${tgzPath}`);
|
||||
}
|
||||
say(`Packed ${tgzPath}`);
|
||||
return { buildCommit, buildCommitShort: buildCommit.slice(0, 7), path: tgzPath };
|
||||
}
|
||||
74
scripts/e2e/parallels/phase-runner.ts
Normal file
74
scripts/e2e/parallels/phase-runner.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { say, warn } from "./host-command.ts";
|
||||
|
||||
export class PhaseRunner {
|
||||
private logText = "";
|
||||
private deadlineMs = 0;
|
||||
|
||||
constructor(private runDir: string) {}
|
||||
|
||||
async phase(name: string, timeoutSeconds: number, fn: () => Promise<void> | void): Promise<void> {
|
||||
const logPath = path.join(this.runDir, `${name}.log`);
|
||||
say(name);
|
||||
this.logText = "";
|
||||
this.deadlineMs = Date.now() + timeoutSeconds * 1000;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error(`${name} timed out after ${timeoutSeconds}s`)),
|
||||
timeoutSeconds * 1000,
|
||||
);
|
||||
});
|
||||
try {
|
||||
await Promise.race([Promise.resolve(fn()), timeout]);
|
||||
await writeFile(logPath, this.logText, "utf8");
|
||||
} catch (error) {
|
||||
await writeFile(logPath, this.logText, "utf8").catch(() => undefined);
|
||||
warn(`${name} failed`);
|
||||
warn(`log tail: ${logPath}`);
|
||||
process.stderr.write(this.logText.split("\n").slice(-80).join("\n"));
|
||||
process.stderr.write("\n");
|
||||
throw error;
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
this.deadlineMs = 0;
|
||||
}
|
||||
}
|
||||
|
||||
async phaseReturns(
|
||||
name: string,
|
||||
timeoutSeconds: number,
|
||||
fn: () => Promise<void> | void,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await this.phase(name, timeoutSeconds, fn);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
remainingTimeoutMs(fallbackMs?: number): number | undefined {
|
||||
if (this.deadlineMs === 0) {
|
||||
return fallbackMs;
|
||||
}
|
||||
const remaining = this.deadlineMs - Date.now();
|
||||
if (remaining <= 0) {
|
||||
throw new Error("phase deadline exceeded before starting guest command");
|
||||
}
|
||||
return Math.max(1_000, fallbackMs == null ? remaining : Math.min(remaining, fallbackMs));
|
||||
}
|
||||
|
||||
append(text: string): void {
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
this.logText += text;
|
||||
if (!text.endsWith("\n")) {
|
||||
this.logText += "\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
13
scripts/e2e/parallels/powershell.ts
Normal file
13
scripts/e2e/parallels/powershell.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export function psSingleQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", "''")}'`;
|
||||
}
|
||||
|
||||
export function psArray(values: string[]): string {
|
||||
return `@(${values.map(psSingleQuote).join(", ")})`;
|
||||
}
|
||||
|
||||
export function encodePowerShell(script: string): string {
|
||||
return Buffer.from(`$ProgressPreference = 'SilentlyContinue'\n${script}`, "utf16le").toString(
|
||||
"base64",
|
||||
);
|
||||
}
|
||||
106
scripts/e2e/parallels/provider-auth.ts
Normal file
106
scripts/e2e/parallels/provider-auth.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { die, run } from "./host-command.ts";
|
||||
import type { Mode, Platform, Provider, ProviderAuth } from "./types.ts";
|
||||
|
||||
export function parseBoolEnv(value: string | undefined): boolean {
|
||||
return /^(1|true|yes|on)$/i.test(value ?? "");
|
||||
}
|
||||
|
||||
export function ensureValue(args: string[], index: number, flag: string): string {
|
||||
const value = args[index + 1];
|
||||
if (value == null || value === "") {
|
||||
die(`${flag} requires a value`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function resolveProviderAuth(input: {
|
||||
provider: Provider;
|
||||
apiKeyEnv?: string;
|
||||
modelId?: string;
|
||||
}): ProviderAuth {
|
||||
const providerDefaults: Record<Provider, Omit<ProviderAuth, "apiKeyValue">> = {
|
||||
anthropic: {
|
||||
apiKeyEnv: input.apiKeyEnv || "ANTHROPIC_API_KEY",
|
||||
authChoice: "apiKey",
|
||||
authKeyFlag: "anthropic-api-key",
|
||||
modelId:
|
||||
input.modelId ||
|
||||
process.env.OPENCLAW_PARALLELS_ANTHROPIC_MODEL ||
|
||||
"anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
minimax: {
|
||||
apiKeyEnv: input.apiKeyEnv || "MINIMAX_API_KEY",
|
||||
authChoice: "minimax-global-api",
|
||||
authKeyFlag: "minimax-api-key",
|
||||
modelId:
|
||||
input.modelId || process.env.OPENCLAW_PARALLELS_MINIMAX_MODEL || "minimax/MiniMax-M2.7",
|
||||
},
|
||||
openai: {
|
||||
apiKeyEnv: input.apiKeyEnv || "OPENAI_API_KEY",
|
||||
authChoice: "openai-api-key",
|
||||
authKeyFlag: "openai-api-key",
|
||||
modelId: input.modelId || process.env.OPENCLAW_PARALLELS_OPENAI_MODEL || "openai/gpt-5.5",
|
||||
},
|
||||
};
|
||||
const resolved = providerDefaults[input.provider];
|
||||
const apiKeyValue = process.env[resolved.apiKeyEnv] ?? "";
|
||||
if (!apiKeyValue) {
|
||||
die(`${resolved.apiKeyEnv} is required`);
|
||||
}
|
||||
return { ...resolved, apiKeyValue };
|
||||
}
|
||||
|
||||
export function parseProvider(value: string): Provider {
|
||||
if (value === "openai" || value === "anthropic" || value === "minimax") {
|
||||
return value;
|
||||
}
|
||||
return die(`invalid --provider: ${value}`);
|
||||
}
|
||||
|
||||
export function parseMode(value: string): Mode {
|
||||
if (value === "fresh" || value === "upgrade" || value === "both") {
|
||||
return value;
|
||||
}
|
||||
return die(`invalid --mode: ${value}`);
|
||||
}
|
||||
|
||||
export function parsePlatformList(value: string): Set<Platform> {
|
||||
const normalized = value.replaceAll(" ", "");
|
||||
if (normalized === "all") {
|
||||
return new Set(["macos", "windows", "linux"]);
|
||||
}
|
||||
const result = new Set<Platform>();
|
||||
for (const entry of normalized.split(",")) {
|
||||
if (entry === "macos" || entry === "windows" || entry === "linux") {
|
||||
result.add(entry);
|
||||
} else {
|
||||
die(`invalid --platform entry: ${entry}`);
|
||||
}
|
||||
}
|
||||
if (result.size === 0) {
|
||||
die("--platform must include at least one platform");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function resolveLatestVersion(versionOverride = ""): string {
|
||||
if (versionOverride) {
|
||||
return versionOverride;
|
||||
}
|
||||
return run(
|
||||
"npm",
|
||||
[
|
||||
"view",
|
||||
"openclaw",
|
||||
"version",
|
||||
"--userconfig",
|
||||
mkdtempSync(path.join(tmpdir(), "openclaw-npm-")),
|
||||
],
|
||||
{
|
||||
quiet: true,
|
||||
},
|
||||
).stdout.trim();
|
||||
}
|
||||
73
scripts/e2e/parallels/snapshots.ts
Normal file
73
scripts/e2e/parallels/snapshots.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { die, run } from "./host-command.ts";
|
||||
import type { SnapshotInfo } from "./types.ts";
|
||||
|
||||
export function resolveSnapshot(vmName: string, hint: string): SnapshotInfo {
|
||||
const output = run("prlctl", ["snapshot-list", vmName, "--json"], { quiet: true }).stdout;
|
||||
const payload = JSON.parse(output) as Record<string, { name?: string; state?: string }>;
|
||||
let best: SnapshotInfo | null = null;
|
||||
let bestScore = -1;
|
||||
const aliases = (name: string): string[] => {
|
||||
const values = [name];
|
||||
for (const pattern of [/^(.*)-poweroff$/, /^(.*)-poweroff-\d{4}-\d{2}-\d{2}$/]) {
|
||||
const match = name.match(pattern);
|
||||
if (match?.[1]) {
|
||||
values.push(match[1]);
|
||||
}
|
||||
}
|
||||
return values;
|
||||
};
|
||||
const normalizedHint = hint.trim().toLowerCase();
|
||||
for (const [id, meta] of Object.entries(payload)) {
|
||||
const name = (meta.name ?? "").trim();
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
let score = 0;
|
||||
for (const alias of aliases(name.toLowerCase())) {
|
||||
if (alias === normalizedHint) {
|
||||
score = Math.max(score, 10);
|
||||
} else if (normalizedHint && alias.includes(normalizedHint)) {
|
||||
score = Math.max(score, 5 + normalizedHint.length / Math.max(alias.length, 1));
|
||||
} else {
|
||||
score = Math.max(score, stringSimilarity(normalizedHint, alias));
|
||||
}
|
||||
}
|
||||
if ((meta.state ?? "").toLowerCase() === "poweroff") {
|
||||
score += 0.5;
|
||||
}
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = { id, name, state: (meta.state ?? "").trim() };
|
||||
}
|
||||
}
|
||||
if (!best) {
|
||||
die("no snapshot matched");
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function stringSimilarity(a: string, b: string): number {
|
||||
if (a === b) {
|
||||
return 1;
|
||||
}
|
||||
const rows = a.length + 1;
|
||||
const cols = b.length + 1;
|
||||
const matrix = Array.from({ length: rows }, () => Array<number>(cols).fill(0));
|
||||
for (let i = 0; i < rows; i++) {
|
||||
matrix[i][0] = i;
|
||||
}
|
||||
for (let j = 0; j < cols; j++) {
|
||||
matrix[0][j] = j;
|
||||
}
|
||||
for (let i = 1; i < rows; i++) {
|
||||
for (let j = 1; j < cols; j++) {
|
||||
matrix[i][j] = Math.min(
|
||||
matrix[i - 1][j] + 1,
|
||||
matrix[i][j - 1] + 1,
|
||||
matrix[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
const distance = matrix[a.length][b.length];
|
||||
return 1 - distance / Math.max(a.length, b.length, 1);
|
||||
}
|
||||
46
scripts/e2e/parallels/types.ts
Normal file
46
scripts/e2e/parallels/types.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export type Provider = "openai" | "anthropic" | "minimax";
|
||||
export type Mode = "fresh" | "upgrade" | "both";
|
||||
export type Platform = "macos" | "windows" | "linux";
|
||||
|
||||
export interface CommandResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
export interface RunOptions {
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
input?: string;
|
||||
timeoutMs?: number;
|
||||
check?: boolean;
|
||||
quiet?: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderAuth {
|
||||
authChoice: string;
|
||||
authKeyFlag: string;
|
||||
apiKeyEnv: string;
|
||||
apiKeyValue: string;
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
export interface SnapshotInfo {
|
||||
id: string;
|
||||
state: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface PackageArtifact {
|
||||
path: string;
|
||||
version?: string;
|
||||
buildCommit?: string;
|
||||
buildCommitShort?: string;
|
||||
}
|
||||
|
||||
export interface HostServer {
|
||||
hostIp: string;
|
||||
port: number;
|
||||
urlFor(filePath: string): string;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
124
scripts/e2e/parallels/windows-git.ts
Normal file
124
scripts/e2e/parallels/windows-git.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import path from "node:path";
|
||||
import type { WindowsGuest } from "./guest-transports.ts";
|
||||
import { die, run, say } from "./host-command.ts";
|
||||
import { psSingleQuote } from "./powershell.ts";
|
||||
import type { HostServer } from "./types.ts";
|
||||
|
||||
export async function prepareMinGitZip(tgzDir: string): Promise<string> {
|
||||
const metadata = run(
|
||||
"python3",
|
||||
[
|
||||
"-c",
|
||||
String.raw`import json
|
||||
import urllib.request
|
||||
|
||||
req = urllib.request.Request(
|
||||
"https://api.github.com/repos/git-for-windows/git/releases/latest",
|
||||
headers={
|
||||
"User-Agent": "openclaw-parallels-smoke",
|
||||
"Accept": "application/vnd.github+json",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as response:
|
||||
data = json.load(response)
|
||||
|
||||
assets = data.get("assets", [])
|
||||
preferred_names = [
|
||||
"MinGit-2.53.0.2-arm64.zip",
|
||||
"MinGit-2.53.0.2-64-bit.zip",
|
||||
]
|
||||
|
||||
best = None
|
||||
for wanted in preferred_names:
|
||||
for asset in assets:
|
||||
if asset.get("name") == wanted:
|
||||
best = asset
|
||||
break
|
||||
if best:
|
||||
break
|
||||
|
||||
if best is None:
|
||||
candidates = []
|
||||
for asset in assets:
|
||||
name = asset.get("name", "")
|
||||
if not (name.startswith("MinGit-") and name.endswith(".zip")):
|
||||
continue
|
||||
if "busybox" in name:
|
||||
continue
|
||||
if "-arm64." in name:
|
||||
rank = 0
|
||||
elif "-64-bit." in name:
|
||||
rank = 1
|
||||
elif "-32-bit." in name:
|
||||
rank = 2
|
||||
else:
|
||||
rank = 3
|
||||
candidates.append((rank, name, asset))
|
||||
if candidates:
|
||||
best = sorted(candidates, key=lambda item: (item[0], item[1]))[0][2]
|
||||
|
||||
if best is None:
|
||||
raise SystemExit("no MinGit asset found")
|
||||
|
||||
print(best["name"])
|
||||
print(best["browser_download_url"])`,
|
||||
],
|
||||
{ quiet: true },
|
||||
).stdout.trim();
|
||||
const [name, url] = metadata.split("\n");
|
||||
if (!name || !url) {
|
||||
die("failed to resolve MinGit download metadata");
|
||||
}
|
||||
const zipPath = path.join(tgzDir, name);
|
||||
say(`Download ${name}`);
|
||||
run("curl", [
|
||||
"--retry",
|
||||
"5",
|
||||
"--retry-delay",
|
||||
"3",
|
||||
"--retry-all-errors",
|
||||
"-fsSL",
|
||||
url,
|
||||
"-o",
|
||||
zipPath,
|
||||
]);
|
||||
return zipPath;
|
||||
}
|
||||
|
||||
export function ensureGuestGit(input: {
|
||||
guest: WindowsGuest;
|
||||
server: HostServer | null;
|
||||
minGitZipPath: string;
|
||||
}): void {
|
||||
const existing = input.guest.exec(
|
||||
["cmd.exe", "/d", "/s", "/c", "where git.exe && git.exe --version"],
|
||||
{
|
||||
check: false,
|
||||
timeoutMs: 120_000,
|
||||
},
|
||||
);
|
||||
if (existing.includes("git version")) {
|
||||
return;
|
||||
}
|
||||
if (!input.server || !input.minGitZipPath) {
|
||||
die("MinGit artifact/server missing");
|
||||
}
|
||||
const minGitUrl = input.server.urlFor(input.minGitZipPath);
|
||||
const minGitName = path.basename(input.minGitZipPath);
|
||||
input.guest.powershell(
|
||||
`$ErrorActionPreference = 'Stop'
|
||||
$depsRoot = Join-Path $env:LOCALAPPDATA 'OpenClaw\\deps'
|
||||
$portableGit = Join-Path $depsRoot 'portable-git'
|
||||
$archive = Join-Path $env:TEMP ${psSingleQuote(minGitName)}
|
||||
if (Test-Path $portableGit) {
|
||||
Remove-Item $portableGit -Recurse -Force
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path $portableGit | Out-Null
|
||||
curl.exe -fsSL ${psSingleQuote(minGitUrl)} -o $archive
|
||||
tar.exe -xf $archive -C $portableGit
|
||||
Remove-Item $archive -Force -ErrorAction SilentlyContinue
|
||||
$env:PATH = "$portableGit\\cmd;$portableGit\\mingw64\\bin;$portableGit\\usr\\bin;$env:PATH"
|
||||
git.exe --version`,
|
||||
{ timeoutMs: 1_200_000 },
|
||||
);
|
||||
}
|
||||
782
scripts/e2e/parallels/windows-smoke.ts
Executable file
782
scripts/e2e/parallels/windows-smoke.ts
Executable file
@@ -0,0 +1,782 @@
|
||||
#!/usr/bin/env -S pnpm tsx
|
||||
import { readFile, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { windowsAgentWorkspaceScript } from "./agent-workspace.ts";
|
||||
import {
|
||||
die,
|
||||
ensureValue,
|
||||
makeTempDir,
|
||||
packageBuildCommitFromTgz,
|
||||
packageVersionFromTgz,
|
||||
packOpenClaw,
|
||||
parseMode,
|
||||
parseProvider,
|
||||
resolveHostIp,
|
||||
resolveHostPort,
|
||||
resolveLatestVersion,
|
||||
resolveProviderAuth,
|
||||
resolveSnapshot,
|
||||
run,
|
||||
say,
|
||||
startHostServer,
|
||||
warn,
|
||||
writeJson,
|
||||
type HostServer,
|
||||
type Mode,
|
||||
type PackageArtifact,
|
||||
type Provider,
|
||||
type ProviderAuth,
|
||||
type SnapshotInfo,
|
||||
} from "./common.ts";
|
||||
import { WindowsGuest } from "./guest-transports.ts";
|
||||
import { PhaseRunner } from "./phase-runner.ts";
|
||||
import { psArray, psSingleQuote } from "./powershell.ts";
|
||||
import { ensureGuestGit, prepareMinGitZip } from "./windows-git.ts";
|
||||
|
||||
interface WindowsOptions {
|
||||
vmName: string;
|
||||
snapshotHint: string;
|
||||
mode: Mode;
|
||||
provider: Provider;
|
||||
apiKeyEnv?: string;
|
||||
modelId?: string;
|
||||
installUrl: string;
|
||||
hostPort: number;
|
||||
hostPortExplicit: boolean;
|
||||
hostIp?: string;
|
||||
latestVersion?: string;
|
||||
installVersion?: string;
|
||||
targetPackageSpec?: string;
|
||||
upgradeFromPackedMain: boolean;
|
||||
skipLatestRefCheck: boolean;
|
||||
keepServer: boolean;
|
||||
json: boolean;
|
||||
}
|
||||
|
||||
interface WindowsSummary {
|
||||
vm: string;
|
||||
snapshotHint: string;
|
||||
snapshotId: string;
|
||||
mode: Mode;
|
||||
provider: Provider;
|
||||
latestVersion: string;
|
||||
installVersion: string;
|
||||
targetPackageSpec: string;
|
||||
currentHead: string;
|
||||
runDir: string;
|
||||
freshMain: {
|
||||
status: string;
|
||||
version: string;
|
||||
gateway: string;
|
||||
agent: string;
|
||||
};
|
||||
upgrade: {
|
||||
precheck: string;
|
||||
status: string;
|
||||
latestVersionInstalled: string;
|
||||
mainVersion: string;
|
||||
gateway: string;
|
||||
agent: string;
|
||||
};
|
||||
}
|
||||
|
||||
const defaultOptions = (): WindowsOptions => ({
|
||||
hostIp: undefined,
|
||||
hostPort: 18426,
|
||||
hostPortExplicit: false,
|
||||
installUrl: "https://openclaw.ai/install.ps1",
|
||||
installVersion: "",
|
||||
json: false,
|
||||
keepServer: false,
|
||||
latestVersion: "",
|
||||
mode: "both",
|
||||
modelId: undefined,
|
||||
provider: "openai",
|
||||
skipLatestRefCheck: false,
|
||||
snapshotHint: "pre-openclaw-native-e2e-2026-03-12",
|
||||
targetPackageSpec: "",
|
||||
upgradeFromPackedMain: false,
|
||||
vmName: "Windows 11",
|
||||
});
|
||||
|
||||
function usage(): string {
|
||||
return `Usage: bash scripts/e2e/parallels-windows-smoke.sh [options]
|
||||
|
||||
Options:
|
||||
--vm <name> Parallels VM name. Default: "Windows 11"
|
||||
--snapshot-hint <name> Snapshot name substring/fuzzy match.
|
||||
Default: "pre-openclaw-native-e2e-2026-03-12"
|
||||
--mode <fresh|upgrade|both>
|
||||
--provider <openai|anthropic|minimax>
|
||||
--model <provider/model> Override the model used for the agent-turn smoke.
|
||||
--api-key-env <var> Host env var name for provider API key.
|
||||
--openai-api-key-env <var> Alias for --api-key-env (backward compatible)
|
||||
--install-url <url> Installer URL for latest release. Default: https://openclaw.ai/install.ps1
|
||||
--host-port <port> Host HTTP port for current-main tgz. Default: 18426
|
||||
--host-ip <ip> Override Parallels host IP.
|
||||
--latest-version <ver> Override npm latest version lookup.
|
||||
--install-version <ver> Pin site-installer version/dist-tag for the baseline lane.
|
||||
--upgrade-from-packed-main
|
||||
Upgrade lane: install packed current-main npm tgz as baseline,
|
||||
then run openclaw update --channel dev.
|
||||
--target-package-spec <npm-spec>
|
||||
Install this npm package tarball instead of packing current main.
|
||||
--skip-latest-ref-check Skip latest-release ref-mode precheck.
|
||||
--keep-server Leave temp host HTTP server running.
|
||||
--json Print machine-readable JSON summary.
|
||||
-h, --help Show help.
|
||||
`;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): WindowsOptions {
|
||||
const options = defaultOptions();
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
switch (arg) {
|
||||
case "--":
|
||||
break;
|
||||
case "--vm":
|
||||
options.vmName = ensureValue(argv, i, arg);
|
||||
i++;
|
||||
break;
|
||||
case "--snapshot-hint":
|
||||
options.snapshotHint = ensureValue(argv, i, arg);
|
||||
i++;
|
||||
break;
|
||||
case "--mode":
|
||||
options.mode = parseMode(ensureValue(argv, i, arg));
|
||||
i++;
|
||||
break;
|
||||
case "--provider":
|
||||
options.provider = parseProvider(ensureValue(argv, i, arg));
|
||||
i++;
|
||||
break;
|
||||
case "--model":
|
||||
options.modelId = ensureValue(argv, i, arg);
|
||||
i++;
|
||||
break;
|
||||
case "--api-key-env":
|
||||
case "--openai-api-key-env":
|
||||
options.apiKeyEnv = ensureValue(argv, i, arg);
|
||||
i++;
|
||||
break;
|
||||
case "--install-url":
|
||||
options.installUrl = ensureValue(argv, i, arg);
|
||||
i++;
|
||||
break;
|
||||
case "--host-port":
|
||||
options.hostPort = Number(ensureValue(argv, i, arg));
|
||||
options.hostPortExplicit = true;
|
||||
i++;
|
||||
break;
|
||||
case "--host-ip":
|
||||
options.hostIp = ensureValue(argv, i, arg);
|
||||
i++;
|
||||
break;
|
||||
case "--latest-version":
|
||||
options.latestVersion = ensureValue(argv, i, arg);
|
||||
i++;
|
||||
break;
|
||||
case "--install-version":
|
||||
options.installVersion = ensureValue(argv, i, arg);
|
||||
i++;
|
||||
break;
|
||||
case "--upgrade-from-packed-main":
|
||||
options.upgradeFromPackedMain = true;
|
||||
break;
|
||||
case "--target-package-spec":
|
||||
options.targetPackageSpec = ensureValue(argv, i, arg);
|
||||
i++;
|
||||
break;
|
||||
case "--skip-latest-ref-check":
|
||||
options.skipLatestRefCheck = true;
|
||||
break;
|
||||
case "--keep-server":
|
||||
options.keepServer = true;
|
||||
break;
|
||||
case "--json":
|
||||
options.json = true;
|
||||
break;
|
||||
case "-h":
|
||||
case "--help":
|
||||
process.stdout.write(usage());
|
||||
process.exit(0);
|
||||
default:
|
||||
die(`unknown arg: ${arg}`);
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
class WindowsSmoke {
|
||||
private auth: ProviderAuth;
|
||||
private hostIp = "";
|
||||
private hostPort = 0;
|
||||
private runDir = "";
|
||||
private tgzDir = "";
|
||||
private server: HostServer | null = null;
|
||||
private artifact: PackageArtifact | null = null;
|
||||
private minGitZipPath = "";
|
||||
private latestVersion = "";
|
||||
private installVersion = "";
|
||||
private targetExpectVersion = "";
|
||||
private snapshot!: SnapshotInfo;
|
||||
private phases!: PhaseRunner;
|
||||
private guest!: WindowsGuest;
|
||||
|
||||
private status = {
|
||||
freshAgent: "skip",
|
||||
freshGateway: "skip",
|
||||
freshMain: "skip",
|
||||
freshVersion: "skip",
|
||||
latestInstalledVersion: "skip",
|
||||
upgrade: "skip",
|
||||
upgradeAgent: "skip",
|
||||
upgradeGateway: "skip",
|
||||
upgradePrecheck: "skip",
|
||||
upgradeVersion: "skip",
|
||||
};
|
||||
|
||||
constructor(private options: WindowsOptions) {
|
||||
this.auth = resolveProviderAuth({
|
||||
apiKeyEnv: options.apiKeyEnv,
|
||||
modelId: options.modelId,
|
||||
provider: options.provider,
|
||||
});
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
this.runDir = await makeTempDir("openclaw-parallels-windows.");
|
||||
this.phases = new PhaseRunner(this.runDir);
|
||||
this.guest = new WindowsGuest(this.options.vmName, this.phases);
|
||||
this.tgzDir = await makeTempDir("openclaw-parallels-windows-tgz.");
|
||||
try {
|
||||
this.snapshot = resolveSnapshot(this.options.vmName, this.options.snapshotHint);
|
||||
this.latestVersion = resolveLatestVersion(this.options.latestVersion);
|
||||
this.installVersion = this.options.installVersion || this.latestVersion;
|
||||
this.hostIp = resolveHostIp(this.options.hostIp);
|
||||
this.hostPort = await resolveHostPort(
|
||||
this.options.hostPort,
|
||||
this.options.hostPortExplicit,
|
||||
defaultOptions().hostPort,
|
||||
);
|
||||
|
||||
say(`VM: ${this.options.vmName}`);
|
||||
say(`Snapshot hint: ${this.options.snapshotHint}`);
|
||||
say(`Resolved snapshot: ${this.snapshot.name} [${this.snapshot.state}]`);
|
||||
say(`Latest npm version: ${this.latestVersion}`);
|
||||
say(
|
||||
`Current head: ${run("git", ["rev-parse", "--short", "HEAD"], { quiet: true }).stdout.trim()}`,
|
||||
);
|
||||
say(`Run logs: ${this.runDir}`);
|
||||
|
||||
this.minGitZipPath = await prepareMinGitZip(this.tgzDir);
|
||||
if (this.needsHostTgz()) {
|
||||
this.artifact = await packOpenClaw({
|
||||
destination: this.tgzDir,
|
||||
packageSpec: this.options.targetPackageSpec,
|
||||
requireControlUi: false,
|
||||
});
|
||||
if (this.options.targetPackageSpec) {
|
||||
this.targetExpectVersion =
|
||||
this.artifact.version || (await packageVersionFromTgz(this.artifact.path));
|
||||
}
|
||||
this.server = await startHostServer({
|
||||
artifactPath: this.artifact.path,
|
||||
dir: this.tgzDir,
|
||||
hostIp: this.hostIp,
|
||||
label: this.artifactLabel(),
|
||||
port: this.hostPort,
|
||||
});
|
||||
this.hostPort = this.server.port;
|
||||
}
|
||||
if (!this.server) {
|
||||
this.server = await startHostServer({
|
||||
artifactPath: this.minGitZipPath,
|
||||
dir: this.tgzDir,
|
||||
hostIp: this.hostIp,
|
||||
label: "Windows smoke artifacts",
|
||||
port: this.hostPort,
|
||||
});
|
||||
this.hostPort = this.server.port;
|
||||
}
|
||||
|
||||
if (this.options.mode === "fresh" || this.options.mode === "both") {
|
||||
await this.runLane("fresh", async () => this.runFreshLane());
|
||||
}
|
||||
if (this.options.mode === "upgrade" || this.options.mode === "both") {
|
||||
await this.runLane("upgrade", async () => this.runUpgradeLane());
|
||||
}
|
||||
|
||||
const summaryPath = await this.writeSummary();
|
||||
if (this.options.json) {
|
||||
process.stdout.write(await readFile(summaryPath, "utf8"));
|
||||
} else {
|
||||
this.printSummary(summaryPath);
|
||||
}
|
||||
if (this.status.freshMain === "fail" || this.status.upgrade === "fail") {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} finally {
|
||||
if (!this.options.keepServer) {
|
||||
await this.server?.stop().catch(() => undefined);
|
||||
await rm(this.tgzDir, { force: true, recursive: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private needsHostTgz(): boolean {
|
||||
return (
|
||||
this.options.mode === "fresh" ||
|
||||
this.options.mode === "both" ||
|
||||
this.options.upgradeFromPackedMain ||
|
||||
Boolean(this.options.targetPackageSpec)
|
||||
);
|
||||
}
|
||||
|
||||
private artifactLabel(): string {
|
||||
if (
|
||||
!this.options.targetPackageSpec &&
|
||||
this.options.mode === "upgrade" &&
|
||||
!this.options.upgradeFromPackedMain
|
||||
) {
|
||||
return "Windows smoke artifacts";
|
||||
}
|
||||
if (this.options.targetPackageSpec) {
|
||||
return "baseline package tgz";
|
||||
}
|
||||
if (this.options.upgradeFromPackedMain) {
|
||||
return "packed main tgz";
|
||||
}
|
||||
return "current main tgz";
|
||||
}
|
||||
|
||||
private upgradeSummaryLabel(): string {
|
||||
if (this.options.targetPackageSpec) {
|
||||
return "target-package->dev";
|
||||
}
|
||||
return this.options.upgradeFromPackedMain ? "packed-main->dev" : "latest->dev";
|
||||
}
|
||||
|
||||
private async runLane(name: "fresh" | "upgrade", fn: () => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await fn();
|
||||
if (name === "fresh") {
|
||||
this.status.freshMain = "pass";
|
||||
} else {
|
||||
this.status.upgrade = "pass";
|
||||
}
|
||||
} catch (error) {
|
||||
if (name === "fresh") {
|
||||
this.status.freshMain = "fail";
|
||||
} else {
|
||||
this.status.upgrade = "fail";
|
||||
}
|
||||
warn(`${name} lane failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async runFreshLane(): Promise<void> {
|
||||
await this.phase("fresh.restore-snapshot", 240, () => this.restoreSnapshot());
|
||||
await this.phase("fresh.wait-for-user", 240, () => this.waitForGuestReady());
|
||||
await this.phase("fresh.ensure-git", 1200, () =>
|
||||
ensureGuestGit({ guest: this.guest, minGitZipPath: this.minGitZipPath, server: this.server }),
|
||||
);
|
||||
await this.phase("fresh.install-main", 420, () => this.installMain("openclaw-main-fresh.tgz"));
|
||||
this.status.freshVersion = await this.extractLastVersion("fresh.install-main");
|
||||
await this.phase("fresh.verify-main-version", 120, () => this.verifyTargetVersion());
|
||||
await this.phase("fresh.onboard-ref", 720, () => this.runRefOnboard());
|
||||
await this.phase("fresh.gateway-restart", 420, () => this.gatewayAction("restart"));
|
||||
await this.phase("fresh.gateway-status", 420, () => this.verifyGatewayReachable());
|
||||
this.status.freshGateway = "pass";
|
||||
await this.phase(
|
||||
"fresh.first-agent-turn",
|
||||
Number(process.env.OPENCLAW_PARALLELS_WINDOWS_AGENT_TIMEOUT_S || 900),
|
||||
() => this.verifyTurn(),
|
||||
);
|
||||
this.status.freshAgent = "pass";
|
||||
}
|
||||
|
||||
private async runUpgradeLane(): Promise<void> {
|
||||
await this.phase("upgrade.restore-snapshot", 240, () => this.restoreSnapshot());
|
||||
await this.phase("upgrade.wait-for-user", 240, () => this.waitForGuestReady());
|
||||
await this.phase("upgrade.ensure-git", 1200, () =>
|
||||
ensureGuestGit({ guest: this.guest, minGitZipPath: this.minGitZipPath, server: this.server }),
|
||||
);
|
||||
if (this.options.targetPackageSpec || this.options.upgradeFromPackedMain) {
|
||||
await this.phase("upgrade.install-baseline-package", 420, () =>
|
||||
this.installMain("openclaw-main-upgrade.tgz"),
|
||||
);
|
||||
this.status.latestInstalledVersion = await this.extractLastVersion(
|
||||
"upgrade.install-baseline-package",
|
||||
);
|
||||
await this.phase("upgrade.verify-baseline-package-version", 120, () =>
|
||||
this.verifyTargetVersion(),
|
||||
);
|
||||
} else {
|
||||
await this.phase("upgrade.install-baseline", 420, () => this.installLatestRelease());
|
||||
this.status.latestInstalledVersion = await this.extractLastVersion(
|
||||
"upgrade.install-baseline",
|
||||
);
|
||||
await this.phase("upgrade.verify-baseline-version", 120, () =>
|
||||
this.verifyVersionContains(this.installVersion),
|
||||
);
|
||||
}
|
||||
if (this.options.skipLatestRefCheck) {
|
||||
this.status.upgradePrecheck = "skipped";
|
||||
} else if (
|
||||
await this.phaseReturns("upgrade.latest-ref-precheck", 720, () =>
|
||||
this.captureLatestRefFailure(),
|
||||
)
|
||||
) {
|
||||
this.status.upgradePrecheck = "latest-ref-pass";
|
||||
} else {
|
||||
this.status.upgradePrecheck = "latest-ref-fail";
|
||||
}
|
||||
await this.phase(
|
||||
"upgrade.update-dev",
|
||||
Number(process.env.OPENCLAW_PARALLELS_WINDOWS_UPDATE_TIMEOUT_S || 1200),
|
||||
() => this.runDevChannelUpdate(),
|
||||
);
|
||||
this.status.upgradeVersion = await this.extractLastVersion("upgrade.update-dev");
|
||||
await this.phase("upgrade.verify-dev-channel", 120, () => this.verifyDevChannelUpdate());
|
||||
await this.phase("upgrade.gateway-stop", 420, () => this.gatewayAction("stop"));
|
||||
await this.phase("upgrade.onboard-ref", 720, () => this.runRefOnboard());
|
||||
await this.phase("upgrade.gateway-restart", 420, () => this.gatewayAction("restart"));
|
||||
await this.phase("upgrade.gateway-status", 420, () => this.verifyGatewayReachable());
|
||||
this.status.upgradeGateway = "pass";
|
||||
await this.phase(
|
||||
"upgrade.first-agent-turn",
|
||||
Number(process.env.OPENCLAW_PARALLELS_WINDOWS_AGENT_TIMEOUT_S || 900),
|
||||
() => this.verifyTurn(),
|
||||
);
|
||||
this.status.upgradeAgent = "pass";
|
||||
}
|
||||
|
||||
private async phase(
|
||||
name: string,
|
||||
timeoutSeconds: number,
|
||||
fn: () => Promise<void> | void,
|
||||
): Promise<void> {
|
||||
await this.phases.phase(name, timeoutSeconds, fn);
|
||||
}
|
||||
|
||||
private remainingPhaseTimeoutMs(fallbackMs?: number): number | undefined {
|
||||
return this.phases.remainingTimeoutMs(fallbackMs);
|
||||
}
|
||||
|
||||
private async phaseReturns(
|
||||
name: string,
|
||||
timeoutSeconds: number,
|
||||
fn: () => Promise<void> | void,
|
||||
): Promise<boolean> {
|
||||
return await this.phases.phaseReturns(name, timeoutSeconds, fn);
|
||||
}
|
||||
|
||||
private log(text: string): void {
|
||||
this.phases.append(text);
|
||||
}
|
||||
|
||||
private guestExec(args: string[], options: { check?: boolean; timeoutMs?: number } = {}): string {
|
||||
return this.guest.exec(args, options);
|
||||
}
|
||||
|
||||
private guestPowerShell(
|
||||
script: string,
|
||||
options: { check?: boolean; timeoutMs?: number } = {},
|
||||
): string {
|
||||
return this.guest.powershell(script, options);
|
||||
}
|
||||
|
||||
private restoreSnapshot(): void {
|
||||
say(`Restore snapshot ${this.options.snapshotHint} (${this.snapshot.id})`);
|
||||
run("prlctl", ["snapshot-switch", this.options.vmName, "--id", this.snapshot.id], {
|
||||
quiet: true,
|
||||
});
|
||||
if (this.snapshot.state === "poweroff") {
|
||||
this.waitForVmStatus("stopped");
|
||||
say(`Start restored poweroff snapshot ${this.snapshot.name}`);
|
||||
run("prlctl", ["start", this.options.vmName], { quiet: true });
|
||||
}
|
||||
}
|
||||
|
||||
private waitForVmStatus(expected: string, timeoutSeconds = 240): void {
|
||||
const deadline = Date.now() + timeoutSeconds * 1000;
|
||||
while (Date.now() < deadline) {
|
||||
const status = run("prlctl", ["status", this.options.vmName], {
|
||||
check: false,
|
||||
quiet: true,
|
||||
}).stdout;
|
||||
if (status.includes(` ${expected}`)) {
|
||||
return;
|
||||
}
|
||||
run("sleep", ["1"], { quiet: true });
|
||||
}
|
||||
throw new Error(`VM ${this.options.vmName} did not reach ${expected}`);
|
||||
}
|
||||
|
||||
private waitForGuestReady(timeoutSeconds = 240): void {
|
||||
const deadline = Date.now() + timeoutSeconds * 1000;
|
||||
while (Date.now() < deadline) {
|
||||
const result = run(
|
||||
"prlctl",
|
||||
["exec", this.options.vmName, "--current-user", "cmd.exe", "/d", "/s", "/c", "echo ready"],
|
||||
{
|
||||
check: false,
|
||||
quiet: true,
|
||||
timeoutMs: this.remainingPhaseTimeoutMs(),
|
||||
},
|
||||
);
|
||||
if (result.status === 0) {
|
||||
return;
|
||||
}
|
||||
run("sleep", ["3"], { quiet: true });
|
||||
}
|
||||
throw new Error("Windows guest did not become ready");
|
||||
}
|
||||
|
||||
private installLatestRelease(): void {
|
||||
const versionArg = this.installVersion ? ` -Tag ${psSingleQuote(this.installVersion)}` : "";
|
||||
this.guestPowerShell(
|
||||
`$ErrorActionPreference = 'Stop'
|
||||
$script = Invoke-RestMethod -Uri ${psSingleQuote(this.options.installUrl)}
|
||||
& ([scriptblock]::Create($script))${versionArg} -NoOnboard
|
||||
if ($LASTEXITCODE -ne 0) { throw "installer failed with exit code $LASTEXITCODE" }
|
||||
& (Join-Path $env:APPDATA 'npm\\openclaw.cmd') --version
|
||||
if ($LASTEXITCODE -ne 0) { throw "openclaw --version failed with exit code $LASTEXITCODE" }`,
|
||||
{ timeoutMs: 420_000 },
|
||||
);
|
||||
}
|
||||
|
||||
private installMain(tempName: string): void {
|
||||
if (!this.artifact || !this.server) {
|
||||
die("package artifact/server missing");
|
||||
}
|
||||
const tgzUrl = this.server.urlFor(this.artifact.path);
|
||||
this.guestPowerShell(
|
||||
`$ErrorActionPreference = 'Stop'
|
||||
$tgz = Join-Path $env:TEMP ${psSingleQuote(tempName)}
|
||||
curl.exe -fsSL ${psSingleQuote(tgzUrl)} -o $tgz
|
||||
npm.cmd install -g $tgz --no-fund --no-audit --loglevel=error
|
||||
if ($LASTEXITCODE -ne 0) { throw "npm install failed with exit code $LASTEXITCODE" }
|
||||
& (Join-Path $env:APPDATA 'npm\\openclaw.cmd') --version
|
||||
if ($LASTEXITCODE -ne 0) { throw "openclaw --version failed with exit code $LASTEXITCODE" }`,
|
||||
{ timeoutMs: 420_000 },
|
||||
);
|
||||
}
|
||||
|
||||
private async verifyTargetVersion(): Promise<void> {
|
||||
if (this.options.targetPackageSpec) {
|
||||
this.verifyVersionContains(this.targetExpectVersion);
|
||||
return;
|
||||
}
|
||||
if (!this.artifact) {
|
||||
die("package artifact missing");
|
||||
}
|
||||
const commit =
|
||||
this.artifact.buildCommitShort ||
|
||||
(await packageBuildCommitFromTgz(this.artifact.path)).slice(0, 7);
|
||||
this.verifyVersionContains(commit);
|
||||
}
|
||||
|
||||
private verifyVersionContains(needle: string): void {
|
||||
const version = this.guestPowerShell(
|
||||
"& (Join-Path $env:APPDATA 'npm\\openclaw.cmd') --version",
|
||||
);
|
||||
if (!version.includes(needle)) {
|
||||
throw new Error(`version mismatch: expected substring ${needle}`);
|
||||
}
|
||||
}
|
||||
|
||||
private captureLatestRefFailure(): void {
|
||||
this.runRefOnboard();
|
||||
this.showGatewayStatusCompat();
|
||||
}
|
||||
|
||||
private runRefOnboard(): void {
|
||||
this.guestPowerShell(
|
||||
`$ErrorActionPreference = 'Stop'
|
||||
Set-Item -Path ('Env:' + ${psSingleQuote(this.auth.apiKeyEnv)}) -Value ${psSingleQuote(this.auth.apiKeyValue)}
|
||||
$openclaw = Join-Path $env:APPDATA 'npm\\openclaw.cmd'
|
||||
& $openclaw onboard --non-interactive --mode local --auth-choice ${psSingleQuote(this.auth.authChoice)} --secret-input-mode ref --gateway-port 18789 --gateway-bind loopback --install-daemon --skip-skills --skip-health --accept-risk --json
|
||||
if ($LASTEXITCODE -ne 0) { throw "openclaw onboard failed with exit code $LASTEXITCODE" }`,
|
||||
{ timeoutMs: 720_000 },
|
||||
);
|
||||
}
|
||||
|
||||
private runDevChannelUpdate(): void {
|
||||
this.guestPowerShell(
|
||||
`$ErrorActionPreference = 'Stop'
|
||||
$portableGit = Join-Path (Join-Path (Join-Path $env:LOCALAPPDATA 'OpenClaw\\deps') 'portable-git') ''
|
||||
$env:PATH = "$portableGit\\cmd;$portableGit\\mingw64\\bin;$portableGit\\usr\\bin;$env:PATH"
|
||||
where.exe git.exe
|
||||
$env:OPENCLAW_DISABLE_BUNDLED_PLUGINS = '1'
|
||||
$openclaw = Join-Path $env:APPDATA 'npm\\openclaw.cmd'
|
||||
& $openclaw update --channel dev --yes --json
|
||||
if ($LASTEXITCODE -ne 0) { throw "openclaw update failed with exit code $LASTEXITCODE" }
|
||||
& $openclaw --version
|
||||
& $openclaw update status --json`,
|
||||
{ timeoutMs: Number(process.env.OPENCLAW_PARALLELS_WINDOWS_UPDATE_TIMEOUT_S || 1200) * 1000 },
|
||||
);
|
||||
}
|
||||
|
||||
private verifyDevChannelUpdate(): void {
|
||||
const status = this.guestPowerShell(
|
||||
`$portableGit = Join-Path (Join-Path (Join-Path $env:LOCALAPPDATA 'OpenClaw\\deps') 'portable-git') ''
|
||||
$env:PATH = "$portableGit\\cmd;$portableGit\\mingw64\\bin;$portableGit\\usr\\bin;$env:PATH"
|
||||
where.exe git.exe
|
||||
& (Join-Path $env:APPDATA 'npm\\openclaw.cmd') update status --json`,
|
||||
);
|
||||
for (const needle of ['"installKind": "git"', '"value": "dev"', '"branch": "main"']) {
|
||||
if (!status.includes(needle)) {
|
||||
throw new Error(`dev update status missing ${needle}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private gatewayAction(action: "restart" | "stop"): void {
|
||||
this.guestPowerShell(
|
||||
`$openclaw = Join-Path $env:APPDATA 'npm\\openclaw.cmd'
|
||||
& $openclaw gateway ${action}
|
||||
if ($LASTEXITCODE -ne 0) { throw "gateway ${action} failed with exit code $LASTEXITCODE" }`,
|
||||
{ timeoutMs: 420_000 },
|
||||
);
|
||||
}
|
||||
|
||||
private verifyGatewayReachable(): void {
|
||||
const deadline = Date.now() + 420_000;
|
||||
let attempt = 1;
|
||||
let recoveryTried = false;
|
||||
const recoveryAfter =
|
||||
Number(process.env.OPENCLAW_PARALLELS_WINDOWS_GATEWAY_RECOVERY_AFTER_S || 180) * 1000;
|
||||
const start = Date.now();
|
||||
while (Date.now() < deadline) {
|
||||
const probe = this.guestPowerShell(
|
||||
"& (Join-Path $env:APPDATA 'npm\\openclaw.cmd') gateway probe --url ws://127.0.0.1:18789 --timeout 30000 --json",
|
||||
{ check: false, timeoutMs: 60_000 },
|
||||
);
|
||||
if (/"ok"\s*:\s*true/.test(probe)) {
|
||||
return;
|
||||
}
|
||||
if (!recoveryTried && Date.now() - start >= recoveryAfter) {
|
||||
warn(
|
||||
`gateway-reachable recovery: gateway start after ${Math.floor((Date.now() - start) / 1000)}s`,
|
||||
);
|
||||
this.guestPowerShell("& (Join-Path $env:APPDATA 'npm\\openclaw.cmd') gateway start", {
|
||||
check: false,
|
||||
timeoutMs: 120_000,
|
||||
});
|
||||
recoveryTried = true;
|
||||
}
|
||||
warn(`gateway-reachable retry ${attempt}`);
|
||||
attempt++;
|
||||
run("sleep", ["5"], { quiet: true });
|
||||
}
|
||||
throw new Error("gateway did not become reachable");
|
||||
}
|
||||
|
||||
private showGatewayStatusCompat(): void {
|
||||
const help = this.guestPowerShell(
|
||||
"& (Join-Path $env:APPDATA 'npm\\openclaw.cmd') gateway status --help",
|
||||
{
|
||||
check: false,
|
||||
},
|
||||
);
|
||||
const suffix = help.includes("--require-rpc") ? "--deep --require-rpc" : "--deep";
|
||||
this.guestPowerShell(`& (Join-Path $env:APPDATA 'npm\\openclaw.cmd') gateway status ${suffix}`);
|
||||
}
|
||||
|
||||
private verifyTurn(): void {
|
||||
this.guestPowerShell(
|
||||
`$openclaw = Join-Path $env:APPDATA 'npm\\openclaw.cmd'
|
||||
& $openclaw models set ${psSingleQuote(this.auth.modelId)}
|
||||
if ($LASTEXITCODE -ne 0) { throw "models set failed" }
|
||||
& $openclaw config set agents.defaults.skipBootstrap true --strict-json
|
||||
if ($LASTEXITCODE -ne 0) { throw "config set failed" }
|
||||
${windowsAgentWorkspaceScript("Parallels Windows smoke test assistant.")}
|
||||
Set-Item -Path ('Env:' + ${psSingleQuote(this.auth.apiKeyEnv)}) -Value ${psSingleQuote(this.auth.apiKeyValue)}
|
||||
$args = ${psArray([
|
||||
"agent",
|
||||
"--local",
|
||||
"--agent",
|
||||
"main",
|
||||
"--session-id",
|
||||
"parallels-windows-smoke",
|
||||
"--message",
|
||||
"Reply with exact ASCII text OK only.",
|
||||
"--json",
|
||||
])}
|
||||
$output = & $openclaw @args 2>&1
|
||||
if ($null -ne $output) { $output | ForEach-Object { $_ } }
|
||||
if ($LASTEXITCODE -ne 0) { throw "agent failed with exit code $LASTEXITCODE" }
|
||||
if (($output | Out-String) -notmatch '"finalAssistant(Raw|Visible)Text":\\s*"OK"') { throw 'openclaw agent finished without OK response' }`,
|
||||
{ timeoutMs: Number(process.env.OPENCLAW_PARALLELS_WINDOWS_AGENT_TIMEOUT_S || 900) * 1000 },
|
||||
);
|
||||
}
|
||||
|
||||
private async extractLastVersion(phaseName: string): Promise<string> {
|
||||
const log = await readFile(path.join(this.runDir, `${phaseName}.log`), "utf8").catch(() => "");
|
||||
const matches = [...log.matchAll(/openclaw\s+([0-9][^\s]*)/g)];
|
||||
return matches.at(-1)?.[1] ?? "";
|
||||
}
|
||||
|
||||
private async writeSummary(): Promise<string> {
|
||||
const summary: WindowsSummary = {
|
||||
currentHead:
|
||||
this.artifact?.buildCommitShort ||
|
||||
run("git", ["rev-parse", "--short", "HEAD"], { quiet: true }).stdout.trim(),
|
||||
freshMain: {
|
||||
agent: this.status.freshAgent,
|
||||
gateway: this.status.freshGateway,
|
||||
status: this.status.freshMain,
|
||||
version: this.status.freshVersion,
|
||||
},
|
||||
installVersion: this.options.installVersion || "",
|
||||
latestVersion: this.latestVersion,
|
||||
mode: this.options.mode,
|
||||
provider: this.options.provider,
|
||||
runDir: this.runDir,
|
||||
snapshotHint: this.options.snapshotHint,
|
||||
snapshotId: this.snapshot.id,
|
||||
targetPackageSpec: this.options.targetPackageSpec || "",
|
||||
upgrade: {
|
||||
agent: this.status.upgradeAgent,
|
||||
gateway: this.status.upgradeGateway,
|
||||
latestVersionInstalled: this.status.latestInstalledVersion,
|
||||
mainVersion: this.status.upgradeVersion,
|
||||
precheck: this.status.upgradePrecheck,
|
||||
status: this.status.upgrade,
|
||||
},
|
||||
vm: this.options.vmName,
|
||||
};
|
||||
const summaryPath = path.join(this.runDir, "summary.json");
|
||||
await writeJson(summaryPath, summary);
|
||||
return summaryPath;
|
||||
}
|
||||
|
||||
private printSummary(summaryPath: string): void {
|
||||
process.stdout.write("\nSummary:\n");
|
||||
if (this.options.targetPackageSpec) {
|
||||
process.stdout.write(` target-package: ${this.options.targetPackageSpec}\n`);
|
||||
}
|
||||
if (this.options.upgradeFromPackedMain) {
|
||||
process.stdout.write(" upgrade-from-packed-main: yes\n");
|
||||
}
|
||||
if (this.options.installVersion) {
|
||||
process.stdout.write(` baseline-install-version: ${this.options.installVersion}\n`);
|
||||
}
|
||||
process.stdout.write(` fresh-main: ${this.status.freshMain} (${this.status.freshVersion})\n`);
|
||||
process.stdout.write(
|
||||
` ${this.upgradeSummaryLabel()} precheck: ${this.status.upgradePrecheck} (${this.status.latestInstalledVersion})\n`,
|
||||
);
|
||||
process.stdout.write(
|
||||
` ${this.upgradeSummaryLabel()}: ${this.status.upgrade} (${this.status.upgradeVersion})\n`,
|
||||
);
|
||||
process.stdout.write(` logs: ${this.runDir}\n`);
|
||||
process.stdout.write(` summary: ${summaryPath}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
await new WindowsSmoke(parseArgs(process.argv.slice(2))).run().catch((error: unknown) => {
|
||||
die(error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
Reference in New Issue
Block a user