mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 17:40:44 +00:00
refactor: convert parallels smoke scripts to typescript
This commit is contained in:
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();
|
||||
Reference in New Issue
Block a user