mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
* fix(android): align Play Store icon with the app Co-authored-by: IWhatsskill <284122573+IWhatsskill@users.noreply.github.com> * fix(test): cover Bun candidate versions through the smoke entrypoint Replace the source-string assertion made stale by the candidate-helper extraction with a mismatched tarball case in the existing installation fixture. Co-authored-by: IWhatsskill <284122573+IWhatsskill@users.noreply.github.com> --------- Co-authored-by: IWhatsskill <284122573+IWhatsskill@users.noreply.github.com>
3432 lines
127 KiB
TypeScript
3432 lines
127 KiB
TypeScript
import { spawn, spawnSync } from "node:child_process";
|
|
import {
|
|
chmodSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
rmSync,
|
|
symlinkSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import path, { join } from "node:path";
|
|
import { runInNewContext } from "node:vm";
|
|
import { expectDefined } from "@openclaw/normalization-core";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { parse } from "yaml";
|
|
import { resolveTestNodeExecPath } from "../../src/test-utils/node-process.js";
|
|
import { createTempDirTracker } from "../helpers/temp-dir.js";
|
|
|
|
const SCRIPT_PATH = "scripts/test-install-sh-docker.sh";
|
|
const INSTALL_E2E_DOCKER_PATH = "scripts/test-install-sh-e2e-docker.sh";
|
|
const INSTALL_E2E_DOCKERFILE_PATH = "scripts/docker/install-sh-e2e/Dockerfile";
|
|
const INSTALL_E2E_RUNNER_PATH = "scripts/docker/install-sh-e2e/run.sh";
|
|
const DOCKER_SETUP_PATH = "scripts/docker/setup.sh";
|
|
const HOST_TIMEOUT_PATH = "scripts/lib/host-timeout.sh";
|
|
const PODMAN_SETUP_PATH = "scripts/podman/setup.sh";
|
|
const PODMAN_QUADLET_TEMPLATE_PATH = "scripts/podman/openclaw.container.in";
|
|
const PODMAN_RUN_PATH = "scripts/run-openclaw-podman.sh";
|
|
const SMOKE_DOCKERFILE_PATH = "scripts/docker/install-sh-smoke/Dockerfile";
|
|
const SMOKE_RUNNER_PATH = "scripts/docker/install-sh-smoke/run.sh";
|
|
const NONROOT_DOCKERFILE_PATH = "scripts/docker/install-sh-nonroot/Dockerfile";
|
|
const NONROOT_RUNNER_PATH = "scripts/docker/install-sh-nonroot/run.sh";
|
|
const BUN_GLOBAL_SMOKE_PATH = "scripts/e2e/bun-global-install-smoke.sh";
|
|
const BUN_GLOBAL_ASSERTIONS_PATH = "scripts/e2e/lib/bun-global-install/assertions.mjs";
|
|
const DOCKER_E2E_PACKAGE_HELPER_PATH = "scripts/lib/docker-e2e-package.sh";
|
|
const INSTALL_SMOKE_WORKFLOW_PATH = ".github/workflows/install-smoke-reusable.yml";
|
|
const INSTALL_SMOKE_WRAPPER_PATH = ".github/workflows/install-smoke.yml";
|
|
const RELEASE_CHECKS_WORKFLOW_PATH = ".github/workflows/openclaw-release-checks.yml";
|
|
const LIVE_E2E_WORKFLOW_PATH = ".github/workflows/openclaw-live-and-e2e-checks-reusable.yml";
|
|
const tempDirs = createTempDirTracker();
|
|
const testNodeExecPath = resolveTestNodeExecPath();
|
|
|
|
afterEach(() => {
|
|
tempDirs.cleanup();
|
|
});
|
|
|
|
class ScriptExit extends Error {
|
|
constructor(readonly status: number) {
|
|
super(`script exited ${String(status)}`);
|
|
}
|
|
}
|
|
|
|
function extractNonrootNodePreflight(): string {
|
|
const script = readFileSync(NONROOT_RUNNER_PATH, "utf8");
|
|
const match = script.match(/node -e '\n([\s\S]*?)\n'\ncommand -v npm/u);
|
|
if (!match) {
|
|
throw new Error("non-root smoke Node preflight was not found");
|
|
}
|
|
return expectDefined(match[1], "non-root smoke Node preflight capture");
|
|
}
|
|
|
|
function extractInstallE2eInstallerFunction(): string {
|
|
const script = readFileSync(INSTALL_E2E_RUNNER_PATH, "utf8");
|
|
const startMarker = "run_official_installer() (\n";
|
|
const endMarker = "\n\nverify_installed_version()";
|
|
const start = script.indexOf(startMarker);
|
|
const end = script.indexOf(endMarker, start + startMarker.length);
|
|
if (start < 0 || end <= start) {
|
|
throw new Error("install E2E installer function was not found");
|
|
}
|
|
return script.slice(start, end);
|
|
}
|
|
|
|
function extractNonrootInstallerStep(): string {
|
|
const script = readFileSync(NONROOT_RUNNER_PATH, "utf8");
|
|
const startMarker = 'echo "==> Run installer (non-root user)"';
|
|
const endMarker = "\n\n# Ensure PATH";
|
|
const start = script.indexOf(startMarker);
|
|
const end = script.indexOf(endMarker, start + startMarker.length);
|
|
if (start < 0 || end <= start) {
|
|
throw new Error("non-root installer step was not found");
|
|
}
|
|
return script.slice(start, end);
|
|
}
|
|
|
|
function extractDockerTimezoneValidator(): string {
|
|
const script = readFileSync(DOCKER_SETUP_PATH, "utf8");
|
|
const match = script.match(
|
|
/(is_valid_timezone_in_image\(\) \{[\s\S]*?\n\})\n\nvalidate_mount_path_value/u,
|
|
);
|
|
if (!match) {
|
|
throw new Error("Docker timezone validator was not found");
|
|
}
|
|
return expectDefined(match[1], "Docker timezone validator capture");
|
|
}
|
|
|
|
function runDockerTimezoneValidator(timezone: string) {
|
|
const root = tempDirs.make("openclaw-docker-timezone-");
|
|
const binDir = join(root, "bin");
|
|
const dockerPath = join(binDir, "docker");
|
|
mkdirSync(binDir, { recursive: true });
|
|
writeFileSync(
|
|
dockerPath,
|
|
[
|
|
"#!/bin/bash",
|
|
"set -euo pipefail",
|
|
'while [[ "$#" -gt 0 && "$1" != "-e" ]]; do shift; done',
|
|
'exec "$HOST_NODE" "$@"',
|
|
"",
|
|
].join("\n"),
|
|
{ mode: 0o755 },
|
|
);
|
|
|
|
return spawnSync(
|
|
"bash",
|
|
[
|
|
"--noprofile",
|
|
"--norc",
|
|
"-c",
|
|
`${extractDockerTimezoneValidator()}\nIMAGE_NAME=openclaw:test\nis_valid_timezone_in_image "$TIMEZONE"`,
|
|
],
|
|
{
|
|
encoding: "utf8",
|
|
env: {
|
|
HOME: root,
|
|
HOST_NODE: testNodeExecPath,
|
|
PATH: `${binDir}:${process.env.PATH ?? ""}`,
|
|
TIMEZONE: timezone,
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
function runInstallE2eInstallerFixture(params: {
|
|
curlExitCode?: number;
|
|
installTag: string;
|
|
installerBody: string;
|
|
}) {
|
|
const root = tempDirs.make("openclaw-install-e2e-download-");
|
|
const binDir = join(root, "bin");
|
|
const curlPath = join(binDir, "curl");
|
|
const curlArgsPath = join(root, "curl-args.txt");
|
|
const installerSourcePath = join(root, "installer-source.sh");
|
|
const markerPath = join(root, "installer-marker.txt");
|
|
const outputPathCapture = join(root, "curl-output-path.txt");
|
|
mkdirSync(binDir, { recursive: true });
|
|
writeFileSync(installerSourcePath, params.installerBody);
|
|
writeFileSync(
|
|
curlPath,
|
|
[
|
|
"#!/bin/sh",
|
|
"set -eu",
|
|
'printf \'%s\\n\' "$*" >"$CURL_ARGS_PATH"',
|
|
'output=""',
|
|
'while [ "$#" -gt 0 ]; do',
|
|
' if [ "$1" = "-o" ]; then',
|
|
" shift",
|
|
' output="$1"',
|
|
" fi",
|
|
" shift",
|
|
"done",
|
|
'cp "$FAKE_INSTALLER_SOURCE" "$output"',
|
|
'printf "%s" "$output" >"$OUTPUT_PATH_CAPTURE"',
|
|
'exit "$FAKE_CURL_EXIT"',
|
|
"",
|
|
].join("\n"),
|
|
{ mode: 0o755 },
|
|
);
|
|
|
|
const env: NodeJS.ProcessEnv = {
|
|
...process.env,
|
|
CURL_ARGS_PATH: curlArgsPath,
|
|
FAKE_CURL_EXIT: String(params.curlExitCode ?? 0),
|
|
FAKE_INSTALLER_SOURCE: installerSourcePath,
|
|
INSTALL_MARKER: markerPath,
|
|
OUTPUT_PATH_CAPTURE: outputPathCapture,
|
|
PATH: `${binDir}:${process.env.PATH ?? ""}`,
|
|
};
|
|
delete env.OPENCLAW_BETA;
|
|
delete env.OPENCLAW_VERSION;
|
|
|
|
const result = spawnSync(
|
|
"/bin/bash",
|
|
[
|
|
"-c",
|
|
[
|
|
"set -u",
|
|
extractInstallE2eInstallerFunction(),
|
|
'INSTALL_URL="https://installer.example.test/install.sh"',
|
|
`INSTALL_TAG=${JSON.stringify(params.installTag)}`,
|
|
"run_official_installer",
|
|
].join("\n"),
|
|
],
|
|
{
|
|
encoding: "utf8",
|
|
env,
|
|
},
|
|
);
|
|
|
|
return { curlArgsPath, markerPath, outputPathCapture, result };
|
|
}
|
|
|
|
function runNonrootInstallerFixture(curlExitCode: number) {
|
|
const root = tempDirs.make("openclaw-install-nonroot-download-");
|
|
const binDir = join(root, "bin");
|
|
const curlArgsPath = join(root, "curl-args.txt");
|
|
const markerPath = join(root, "installer-marker.txt");
|
|
const outputPathCapture = join(root, "curl-output-path.txt");
|
|
mkdirSync(binDir, { recursive: true });
|
|
writeFileSync(
|
|
join(binDir, "curl"),
|
|
[
|
|
"#!/bin/sh",
|
|
"set -eu",
|
|
`printf '%s\\0' "$@" >"$CURL_ARGS_PATH"`,
|
|
'output=""',
|
|
'while [ "$#" -gt 0 ]; do',
|
|
' if [ "$1" = "-o" ]; then',
|
|
" shift",
|
|
' output="$1"',
|
|
" fi",
|
|
" shift",
|
|
"done",
|
|
`printf '%s\\n' 'touch "$INSTALL_MARKER"' >"$output"`,
|
|
'chmod +x "$output"',
|
|
'printf "%s" "$output" >"$OUTPUT_PATH_CAPTURE"',
|
|
'exit "$FAKE_CURL_EXIT"',
|
|
"",
|
|
].join("\n"),
|
|
{ mode: 0o755 },
|
|
);
|
|
|
|
const result = spawnSync(
|
|
"/bin/bash",
|
|
[
|
|
"--noprofile",
|
|
"--norc",
|
|
"-c",
|
|
[
|
|
"set -euo pipefail",
|
|
'INSTALL_URL="https://installer.example.test/install.sh"',
|
|
extractNonrootInstallerStep(),
|
|
].join("\n"),
|
|
],
|
|
{
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
CURL_ARGS_PATH: curlArgsPath,
|
|
FAKE_CURL_EXIT: String(curlExitCode),
|
|
INSTALL_MARKER: markerPath,
|
|
OUTPUT_PATH_CAPTURE: outputPathCapture,
|
|
PATH: `${binDir}:${process.env.PATH ?? ""}`,
|
|
},
|
|
},
|
|
);
|
|
|
|
return { curlArgsPath, markerPath, outputPathCapture, result };
|
|
}
|
|
|
|
function runNonrootNodePreflight(
|
|
version: string,
|
|
options: { sqlite?: boolean; sqliteVersion?: string } = {},
|
|
) {
|
|
const stderr: string[] = [];
|
|
try {
|
|
runInNewContext(extractNonrootNodePreflight(), {
|
|
process: {
|
|
versions: { node: version },
|
|
stderr: {
|
|
write(message: string) {
|
|
stderr.push(message);
|
|
},
|
|
},
|
|
exit(status: number) {
|
|
throw new ScriptExit(status);
|
|
},
|
|
},
|
|
require(specifier: string) {
|
|
if (specifier === "node:sqlite" && options.sqlite === false) {
|
|
throw new Error("missing node:sqlite");
|
|
}
|
|
return {
|
|
DatabaseSync: class {
|
|
prepare() {
|
|
return {
|
|
get: () => ({ version: options.sqliteVersion ?? "3.51.3" }),
|
|
};
|
|
}
|
|
|
|
close() {}
|
|
},
|
|
};
|
|
},
|
|
});
|
|
return { status: 0, stderr: stderr.join("") };
|
|
} catch (error) {
|
|
if (error instanceof ScriptExit) {
|
|
return { status: error.status, stderr: stderr.join("") };
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function runDefaultSmokePlatform(env: Record<string, string>, hostArch: string): string {
|
|
const script = readFileSync(SCRIPT_PATH, "utf8");
|
|
const match = script.match(
|
|
/(resolve_default_smoke_platform\(\) \{[\s\S]*?\n\})\n\nprint_pack_audit/u,
|
|
);
|
|
if (!match) {
|
|
throw new Error("resolve_default_smoke_platform was not found");
|
|
}
|
|
const result = spawnSync(
|
|
"bash",
|
|
[
|
|
"--noprofile",
|
|
"--norc",
|
|
"-c",
|
|
`${match[1]}\nuname() { if [[ "\${1:-}" == "-m" ]]; then printf "%s" "$FAKE_UNAME_ARCH"; else command uname "$@"; fi; }\nresolve_default_smoke_platform`,
|
|
],
|
|
{
|
|
encoding: "utf8",
|
|
env: {
|
|
HOME: "/tmp",
|
|
PATH: process.env.PATH ?? "",
|
|
FAKE_UNAME_ARCH: hostArch,
|
|
...env,
|
|
},
|
|
},
|
|
);
|
|
expect(result.stderr).toBe("");
|
|
expect(result.status).toBe(0);
|
|
return result.stdout;
|
|
}
|
|
|
|
function extractInstallE2eAgentJsonParser(): string {
|
|
const script = readFileSync(INSTALL_E2E_RUNNER_PATH, "utf8");
|
|
const match = script.match(
|
|
/node - <<'NODE' "\$out_json"\n([\s\S]*?)\nNODE\n\}\n\nRUN_AGENT_TURN_BG_PID/u,
|
|
);
|
|
if (!match) {
|
|
throw new Error("install E2E agent JSON parser was not found");
|
|
}
|
|
return expectDefined(match[1], "install E2E agent JSON parser capture");
|
|
}
|
|
|
|
function normalizeInstallE2eAgentOutput(output: string) {
|
|
const root = mkdtempSync(join(tmpdir(), "openclaw-install-e2e-agent-output-"));
|
|
const outputPath = join(root, "agent.json");
|
|
writeFileSync(outputPath, output, "utf8");
|
|
try {
|
|
const result = spawnSync(testNodeExecPath, ["-", outputPath], {
|
|
encoding: "utf8",
|
|
input: extractInstallE2eAgentJsonParser(),
|
|
});
|
|
return {
|
|
output: readFileSync(outputPath, "utf8"),
|
|
status: result.status,
|
|
stderr: result.stderr,
|
|
};
|
|
} finally {
|
|
rmSync(root, { force: true, recursive: true });
|
|
}
|
|
}
|
|
|
|
function extractInstallSmokeUpdateJsonParser(): string {
|
|
const script = readFileSync(SMOKE_RUNNER_PATH, "utf8");
|
|
const match = script.match(
|
|
/UPDATE_JSON="\$UPDATE_JSON" \\\n[\s\S]*?node - <<'NODE'\n([\s\S]*?)\nNODE\n\n {2}echo "==> Verify updated version"/u,
|
|
);
|
|
if (!match) {
|
|
throw new Error("install smoke update JSON parser was not found");
|
|
}
|
|
return expectDefined(match[1], "install smoke update JSON parser capture");
|
|
}
|
|
|
|
function validateInstallSmokeUpdateJson(doctorStep?: Record<string, unknown>) {
|
|
const updateUrl = "http://candidate.invalid/openclaw.tgz";
|
|
const payload = {
|
|
status: "ok",
|
|
before: { version: "2026.7.0" },
|
|
after: { version: "2026.7.1" },
|
|
steps: [
|
|
{
|
|
name: "global update",
|
|
exitCode: 0,
|
|
command: `npm install ${updateUrl}`,
|
|
},
|
|
...(doctorStep ? [doctorStep] : []),
|
|
],
|
|
};
|
|
return spawnSync(testNodeExecPath, ["-"], {
|
|
encoding: "utf8",
|
|
input: extractInstallSmokeUpdateJsonParser(),
|
|
env: {
|
|
...process.env,
|
|
UPDATE_JSON: JSON.stringify(payload),
|
|
UPDATE_EXPECT_VERSION: payload.after.version,
|
|
UPDATE_BASELINE_VERSION: payload.before.version,
|
|
UPDATE_TAG_URL: updateUrl,
|
|
},
|
|
});
|
|
}
|
|
|
|
function extractInstallSmokeInstallerPipeline(): string {
|
|
const script = readFileSync(SMOKE_RUNNER_PATH, "utf8");
|
|
const match = script.match(/(run_installer_pipeline\(\) \{[\s\S]*?\n\})\n\nrun_install_smoke/u);
|
|
if (!match) {
|
|
throw new Error("install smoke installer pipeline helper was not found");
|
|
}
|
|
return expectDefined(match[1], "install smoke installer pipeline helper capture");
|
|
}
|
|
|
|
function readNulSeparatedArgs(filePath: string): string[] {
|
|
return readFileSync(filePath, "utf8").split("\0").filter(Boolean);
|
|
}
|
|
|
|
function runInstallSmokeInstallerPipelineFixture(params: {
|
|
curlExitCode?: number;
|
|
installerArgs: string[];
|
|
}) {
|
|
const root = tempDirs.make("openclaw-install-smoke-pipeline-");
|
|
const binDir = join(root, "bin");
|
|
const curlArgsPath = join(root, "curl-args.txt");
|
|
const installerArgsPath = join(root, "installer-args.txt");
|
|
const installerMarkerPath = join(root, "installer-ran");
|
|
const installerSourcePath = join(root, "installer.sh");
|
|
const timeoutArgsPath = join(root, "timeout-args.txt");
|
|
const installUrl = "https://installer.example.test/install.sh?channel=beta&trace=1";
|
|
mkdirSync(binDir, { recursive: true });
|
|
writeFileSync(
|
|
join(binDir, "timeout"),
|
|
[
|
|
"#!/usr/bin/env bash",
|
|
"set -euo pipefail",
|
|
`printf '%s\\0' "$@" >"$TIMEOUT_ARGS_PATH"`,
|
|
"shift 2",
|
|
'exec "$@"',
|
|
"",
|
|
].join("\n"),
|
|
{ mode: 0o755 },
|
|
);
|
|
writeFileSync(
|
|
join(binDir, "curl"),
|
|
[
|
|
"#!/usr/bin/env bash",
|
|
"set -euo pipefail",
|
|
`printf '%s\\0' "$@" >"$CURL_ARGS_PATH"`,
|
|
'cat "$FAKE_INSTALLER_SOURCE"',
|
|
'exit "$FAKE_CURL_EXIT"',
|
|
"",
|
|
].join("\n"),
|
|
{ mode: 0o755 },
|
|
);
|
|
writeFileSync(
|
|
installerSourcePath,
|
|
[
|
|
"#!/usr/bin/env bash",
|
|
"set -euo pipefail",
|
|
`printf '%s\\0' "$@" >"$INSTALLER_ARGS_PATH"`,
|
|
'touch "$INSTALLER_MARKER_PATH"',
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
|
|
const result = spawnSync(
|
|
"/bin/bash",
|
|
[
|
|
"--noprofile",
|
|
"--norc",
|
|
"-c",
|
|
`set -euo pipefail
|
|
${extractInstallSmokeInstallerPipeline()}
|
|
run_installer_pipeline "$INSTALL_URL" "$@"`,
|
|
"_",
|
|
...params.installerArgs,
|
|
],
|
|
{
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
CURL_ARGS_PATH: curlArgsPath,
|
|
FAKE_CURL_EXIT: String(params.curlExitCode ?? 0),
|
|
FAKE_INSTALLER_SOURCE: installerSourcePath,
|
|
INSTALLER_ARGS_PATH: installerArgsPath,
|
|
INSTALLER_MARKER_PATH: installerMarkerPath,
|
|
INSTALL_COMMAND_TIMEOUT: "17",
|
|
INSTALL_URL: installUrl,
|
|
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
|
TIMEOUT_ARGS_PATH: timeoutArgsPath,
|
|
},
|
|
},
|
|
);
|
|
|
|
return {
|
|
curlArgsPath,
|
|
installUrl,
|
|
installerArgsPath,
|
|
installerMarkerPath,
|
|
result,
|
|
timeoutArgsPath,
|
|
};
|
|
}
|
|
|
|
function expectInstallDockerfileContract(
|
|
dockerfilePath: string,
|
|
runnerPath: string,
|
|
entrypoint: string,
|
|
): string {
|
|
const dockerfile = readFileSync(dockerfilePath, "utf8");
|
|
|
|
expect(dockerfile).toContain("# syntax=docker/dockerfile:1.27.0");
|
|
expect(dockerfile).toMatch(/^FROM \S+@sha256:[a-f0-9]{64}$/m);
|
|
expect(dockerfile).toContain("apt-get");
|
|
expect(dockerfile).toContain("bash");
|
|
expect(dockerfile).toContain("ca-certificates");
|
|
expect(dockerfile).toContain("curl");
|
|
expect(dockerfile).toContain(
|
|
"COPY install-sh-common/version-parse.sh /usr/local/install-sh-common/version-parse.sh",
|
|
);
|
|
expect(dockerfile).toContain(`COPY --chmod=755 ${runnerPath} ${entrypoint}`);
|
|
expect(dockerfile).toContain(`ENTRYPOINT ["${entrypoint}"]`);
|
|
return dockerfile;
|
|
}
|
|
|
|
async function waitForCondition(
|
|
predicate: () => boolean,
|
|
label: string,
|
|
timeoutMs = 2_000,
|
|
): Promise<void> {
|
|
const deadlineAt = Date.now() + timeoutMs;
|
|
while (Date.now() < deadlineAt) {
|
|
if (predicate()) {
|
|
return;
|
|
}
|
|
await new Promise((resolve) => {
|
|
setTimeout(resolve, 5);
|
|
});
|
|
}
|
|
throw new Error(`timed out waiting for ${label}`);
|
|
}
|
|
|
|
function isProcessAlive(pid: number): boolean {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function extractReadPackTarballFilename(): string {
|
|
const script = readFileSync(SCRIPT_PATH, "utf8");
|
|
const match = script.match(/(read_pack_tarball_filename\(\) \{[\s\S]*?\n\})\n\nSMOKE_IMAGE/u);
|
|
if (!match) {
|
|
throw new Error("read_pack_tarball_filename helper was not found");
|
|
}
|
|
return expectDefined(match[1], "pack tarball filename helper capture");
|
|
}
|
|
|
|
function extractInstallSmokePackHelper(name: string, nextName: string): string {
|
|
const script = readFileSync(SCRIPT_PATH, "utf8");
|
|
const start = script.indexOf(`${name}() {`);
|
|
const end = script.indexOf(`\n\n${nextName}() {`, start);
|
|
if (start < 0 || end <= start) {
|
|
throw new Error(`${name} helper was not found`);
|
|
}
|
|
return script.slice(start, end);
|
|
}
|
|
|
|
function runInstallSmokePackHelpers(
|
|
packJson: unknown,
|
|
budgetBytes?: number,
|
|
githubActions = false,
|
|
) {
|
|
const root = tempDirs.make("openclaw-install-pack-helper-");
|
|
const packJsonPath = join(root, "pack.json");
|
|
const summaryPath = join(root, "summary.md");
|
|
writeFileSync(packJsonPath, JSON.stringify(packJson), "utf8");
|
|
const env: NodeJS.ProcessEnv = {
|
|
...process.env,
|
|
PACK_JSON_PATH: packJsonPath,
|
|
HARNESS_ROOT: process.cwd(),
|
|
GITHUB_ACTIONS: githubActions ? "true" : "false",
|
|
GITHUB_STEP_SUMMARY: summaryPath,
|
|
};
|
|
delete env.OPENCLAW_INSTALL_SMOKE_PACK_UNPACKED_BUDGET_BYTES;
|
|
if (budgetBytes !== undefined) {
|
|
env.OPENCLAW_INSTALL_SMOKE_PACK_UNPACKED_BUDGET_BYTES = String(budgetBytes);
|
|
}
|
|
const result = spawnSync(
|
|
"bash",
|
|
[
|
|
"--noprofile",
|
|
"--norc",
|
|
"-c",
|
|
`set -euo pipefail
|
|
${extractInstallSmokePackHelper("normalize_npm_pack_json_file", "run_install_smoke_container")}
|
|
${extractInstallSmokePackHelper("assert_pack_unpacked_size_budget", "print_pack_delta_audit")}
|
|
normalize_npm_pack_json_file "$PACK_JSON_PATH"
|
|
assert_pack_unpacked_size_budget "fixture" "$PACK_JSON_PATH"`,
|
|
],
|
|
{
|
|
encoding: "utf8",
|
|
env,
|
|
},
|
|
);
|
|
return {
|
|
normalized: JSON.parse(readFileSync(packJsonPath, "utf8")),
|
|
result,
|
|
summary: existsSync(summaryPath) ? readFileSync(summaryPath, "utf8") : "",
|
|
};
|
|
}
|
|
|
|
function runReadPackTarballFilename(filename: string) {
|
|
return spawnSync(
|
|
"bash",
|
|
[
|
|
"--noprofile",
|
|
"--norc",
|
|
"-c",
|
|
`${extractReadPackTarballFilename()}
|
|
pack_json_file="$(mktemp)"
|
|
trap 'rm -f "$pack_json_file"' EXIT
|
|
printf '%s' "$PACK_JSON" >"$pack_json_file"
|
|
read_pack_tarball_filename "$pack_json_file"`,
|
|
],
|
|
{
|
|
encoding: "utf8",
|
|
env: {
|
|
HOME: "/tmp",
|
|
PACK_JSON: JSON.stringify([{ filename }]),
|
|
PATH: process.env.PATH ?? "",
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
function extractEnsureLocalUpdateDistImportClosure(): string {
|
|
const script = readFileSync(SCRIPT_PATH, "utf8");
|
|
const match = script.match(
|
|
/(ensure_local_update_dist_import_closure\(\) \{[\s\S]*?\n\})\n\nread_candidate_version/u,
|
|
);
|
|
if (!match) {
|
|
throw new Error("ensure_local_update_dist_import_closure helper was not found");
|
|
}
|
|
return expectDefined(match[1], "local update import closure helper capture");
|
|
}
|
|
|
|
type RestorePathEscape = "packages" | "ai";
|
|
|
|
function runRestoreLocalDistFixture(
|
|
options: { failAiSwap?: boolean; symlinkEscape?: RestorePathEscape } = {},
|
|
) {
|
|
const fixtureRoot = tempDirs.make("openclaw-install-restore-root-");
|
|
const imageRoot = tempDirs.make("openclaw-install-restore-image-");
|
|
let externalSentinel = "";
|
|
for (const [relativePath, contents] of [
|
|
["dist/root.txt", "old-root"],
|
|
["packages/ai/dist/ai.txt", "old-ai"],
|
|
["packages/ai/package.json", "{}"],
|
|
] as const) {
|
|
const target = join(fixtureRoot, relativePath);
|
|
mkdirSync(path.dirname(target), { recursive: true });
|
|
writeFileSync(target, contents);
|
|
}
|
|
for (const [relativePath, contents] of [
|
|
["app/dist/root.txt", "new-root"],
|
|
["app/node_modules/@openclaw/ai/dist/ai.txt", "new-ai"],
|
|
] as const) {
|
|
const target = join(imageRoot, relativePath);
|
|
mkdirSync(path.dirname(target), { recursive: true });
|
|
writeFileSync(target, contents);
|
|
}
|
|
|
|
if (options.symlinkEscape) {
|
|
const escapeRoot = tempDirs.make("openclaw-install-restore-escape-");
|
|
const externalAiRoot =
|
|
options.symlinkEscape === "packages" ? join(escapeRoot, "packages", "ai") : escapeRoot;
|
|
externalSentinel = join(externalAiRoot, "dist", "ai.txt");
|
|
mkdirSync(path.dirname(externalSentinel), { recursive: true });
|
|
writeFileSync(join(externalAiRoot, "package.json"), "{}");
|
|
writeFileSync(externalSentinel, "external-ai");
|
|
if (options.symlinkEscape === "packages") {
|
|
rmSync(join(fixtureRoot, "packages"), { force: true, recursive: true });
|
|
symlinkSync(join(escapeRoot, "packages"), join(fixtureRoot, "packages"), "dir");
|
|
} else {
|
|
rmSync(join(fixtureRoot, "packages", "ai"), { force: true, recursive: true });
|
|
symlinkSync(externalAiRoot, join(fixtureRoot, "packages", "ai"), "dir");
|
|
}
|
|
}
|
|
|
|
return spawnSync(
|
|
"bash",
|
|
[
|
|
"--noprofile",
|
|
"--norc",
|
|
"-c",
|
|
`set -euo pipefail
|
|
REPO_ROOT="$FIXTURE_REPO"
|
|
ROOT_DIR="$FIXTURE_ROOT"
|
|
IMAGE_ROOT="$FIXTURE_IMAGE"
|
|
docker_e2e_docker_cmd() {
|
|
printf 'docker-call=%s\\n' "$1" >&2
|
|
case "$1" in
|
|
create)
|
|
printf "fixture"
|
|
;;
|
|
cp)
|
|
local source="\${2#fixture:}"
|
|
cp -R "$IMAGE_ROOT$source" "$3"
|
|
;;
|
|
rm)
|
|
;;
|
|
*)
|
|
return 2
|
|
;;
|
|
esac
|
|
}
|
|
docker_e2e_docker_run_cmd() {
|
|
docker_e2e_docker_cmd "$@"
|
|
}
|
|
mv() {
|
|
if [[ "$FAIL_AI_SWAP" == "1" && "$1" == */ai-dist && "$2" == */packages/ai/dist ]]; then
|
|
return 1
|
|
fi
|
|
command mv "$@"
|
|
}
|
|
source "$REPO_ROOT/${DOCKER_E2E_PACKAGE_HELPER_PATH}"
|
|
status=0
|
|
docker_e2e_restore_package_dist_from_image fixture-image || status=$?
|
|
printf 'status=%s\\n' "$status"
|
|
printf 'root=%s\\n' "$(cat "$ROOT_DIR/dist/root.txt")"
|
|
printf 'ai=%s\\n' "$(cat "$ROOT_DIR/packages/ai/dist/ai.txt")"
|
|
if [[ -n "$EXTERNAL_SENTINEL" ]]; then
|
|
printf 'external=%s\\n' "$(cat "$EXTERNAL_SENTINEL")"
|
|
fi
|
|
`,
|
|
],
|
|
{
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
FAIL_AI_SWAP: options.failAiSwap ? "1" : "0",
|
|
EXTERNAL_SENTINEL: externalSentinel,
|
|
FIXTURE_IMAGE: imageRoot,
|
|
FIXTURE_REPO: process.cwd(),
|
|
FIXTURE_ROOT: fixtureRoot,
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
describe("test-install-sh-docker", () => {
|
|
it("defaults ARM hosts to native arm64 while keeping x64 CI on amd64", () => {
|
|
expect(runDefaultSmokePlatform({ CI: "true" }, "aarch64")).toBe("linux/arm64");
|
|
expect(runDefaultSmokePlatform({ GITHUB_ACTIONS: "true" }, "x86_64")).toBe("linux/amd64");
|
|
expect(runDefaultSmokePlatform({}, "arm64")).toBe("linux/arm64");
|
|
expect(
|
|
runDefaultSmokePlatform({ OPENCLAW_INSTALL_SMOKE_PLATFORM: "linux/s390x" }, "x86_64"),
|
|
).toBe("linux/s390x");
|
|
});
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"serves distinct candidate and baseline bytes when npm packs the same version",
|
|
() => {
|
|
const root = tempDirs.make("openclaw-install-same-version-");
|
|
const bin = join(root, "bin");
|
|
const ready = join(root, "server-ready");
|
|
const receipts = join(root, "served.jsonl");
|
|
mkdirSync(bin);
|
|
symlinkSync(testNodeExecPath, join(bin, "node"));
|
|
expect(spawnSync("mkfifo", [ready]).status).toBe(0);
|
|
const writeCommand = (name: string, source: string) =>
|
|
writeFileSync(join(bin, name), source, { mode: 0o755 });
|
|
writeCommand(
|
|
"npm",
|
|
`#!/usr/bin/env node
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const args = process.argv.slice(2);
|
|
const pack = args.indexOf("pack");
|
|
if (pack >= 0) {
|
|
const dir = args[args.indexOf("--pack-destination") + 1];
|
|
const filename = "openclaw-2026.9.6.tgz";
|
|
const bytes = args[pack + 1].startsWith("openclaw@") ? "published baseline" : "candidate build";
|
|
fs.writeFileSync(path.join(dir, filename), bytes);
|
|
console.log(JSON.stringify([{ name: "openclaw", version: "2026.9.6", filename, size: bytes.length, unpackedSize: bytes.length }]));
|
|
} else if (args.includes("view")) {
|
|
console.log("2026.9.6");
|
|
} else {
|
|
throw new Error("Unexpected npm invocation: " + args.join(" "));
|
|
}
|
|
`,
|
|
);
|
|
writeCommand(
|
|
"python3",
|
|
`#!/usr/bin/env node
|
|
const fs = require("node:fs");
|
|
const http = require("node:http");
|
|
const path = require("node:path");
|
|
http.createServer((request, response) => {
|
|
const file = path.join(process.cwd(), new URL(request.url, "http://localhost").pathname.slice(1));
|
|
fs.createReadStream(file).on("error", () => {
|
|
response.statusCode = 404;
|
|
response.end("missing package");
|
|
}).pipe(response);
|
|
}).listen(Number(process.argv[4]), "127.0.0.1", () => {
|
|
fs.writeFileSync(process.env.SERVER_READY, "ready\\n");
|
|
});
|
|
`,
|
|
);
|
|
// Join actual listener readiness instead of spending the runner's startup sleep.
|
|
writeCommand(
|
|
"sleep",
|
|
'#!/bin/bash\nIFS= read -r ready < "$SERVER_READY"\n[[ "$ready" == ready ]]\n',
|
|
);
|
|
writeCommand(
|
|
"docker",
|
|
`#!/usr/bin/env node
|
|
const fs = require("node:fs");
|
|
const args = process.argv.slice(2);
|
|
if (args[0] !== "run") throw new Error("Unexpected Docker invocation");
|
|
const env = new Map();
|
|
for (let index = 0; index < args.length; index++) {
|
|
if (args[index] === "-e") {
|
|
const entry = args[++index];
|
|
const equals = entry.indexOf("=");
|
|
env.set(entry.slice(0, equals), entry.slice(equals + 1));
|
|
}
|
|
}
|
|
(async () => {
|
|
for (const [key, kind] of [
|
|
["OPENCLAW_INSTALL_FRESH_TAG_URL", "fresh"],
|
|
["OPENCLAW_INSTALL_UPDATE_BASELINE_TAG_URL", "baseline"],
|
|
["OPENCLAW_INSTALL_UPDATE_TAG_URL", "update"],
|
|
]) {
|
|
const url = env.get(key);
|
|
if (!url) continue;
|
|
const response = await fetch(url);
|
|
if (!response.ok) throw new Error("Package HTTP status " + response.status);
|
|
const bytes = await response.text();
|
|
fs.appendFileSync(process.env.SERVED_RECEIPTS, JSON.stringify({ kind, url, bytes }) + "\\n");
|
|
}
|
|
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
|
`,
|
|
);
|
|
|
|
const result = spawnSync("bash", [SCRIPT_PATH], {
|
|
encoding: "utf8",
|
|
timeout: 30_000,
|
|
env: {
|
|
HOME: root,
|
|
PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}`,
|
|
TMPDIR: root,
|
|
SERVER_READY: ready,
|
|
SERVED_RECEIPTS: receipts,
|
|
npm_config_userconfig: "/dev/null",
|
|
npm_config_globalconfig: "/dev/null",
|
|
OPENCLAW_DOCKER_E2E_DISABLE_RESOURCE_LIMITS: "1",
|
|
OPENCLAW_INSTALL_SMOKE_GROUP: "update",
|
|
OPENCLAW_INSTALL_SMOKE_SKIP_IMAGE_BUILD: "1",
|
|
OPENCLAW_INSTALL_SMOKE_SKIP_NPM_GLOBAL: "1",
|
|
OPENCLAW_INSTALL_SMOKE_SKIP_FRESHNESS: "1",
|
|
OPENCLAW_INSTALL_SMOKE_UPDATE_BASELINE: "2026.9.6",
|
|
OPENCLAW_INSTALL_SMOKE_UPDATE_PACKAGE_SPEC: "candidate-fixture",
|
|
OPENCLAW_INSTALL_SMOKE_UPDATE_HOST: "127.0.0.1",
|
|
},
|
|
});
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.status, result.stderr).toBe(0);
|
|
const served = readFileSync(receipts, "utf8")
|
|
.trim()
|
|
.split("\n")
|
|
.map((line) => JSON.parse(line) as { kind: string; url: string; bytes: string });
|
|
expect(served.map(({ kind, bytes }) => ({ kind, bytes }))).toEqual([
|
|
{ kind: "fresh", bytes: "candidate build" },
|
|
{ kind: "baseline", bytes: "published baseline" },
|
|
{ kind: "update", bytes: "candidate build" },
|
|
]);
|
|
expect(served[0]?.url).toBe(served[2]?.url);
|
|
expect(served[1]?.url).not.toBe(served[2]?.url);
|
|
},
|
|
);
|
|
|
|
it("supports npm update package specs without a separate expected-version env", () => {
|
|
const script = readFileSync(SCRIPT_PATH, "utf8");
|
|
|
|
expect(script).toContain(
|
|
'UPDATE_EXPECT_VERSION="${OPENCLAW_INSTALL_SMOKE_UPDATE_EXPECT_VERSION:-}"',
|
|
);
|
|
expect(script).toContain('if [[ -z "$UPDATE_EXPECT_VERSION" ]]; then');
|
|
expect(script).toContain('UPDATE_EXPECT_VERSION="$packed_update_version"');
|
|
expect(script).toContain(
|
|
"packed update version ${packed_update_version} does not match expected ${UPDATE_EXPECT_VERSION}",
|
|
);
|
|
});
|
|
|
|
it("uses npm latest as the update baseline and resolves it to the concrete packed version", () => {
|
|
const script = readFileSync(SCRIPT_PATH, "utf8");
|
|
const runner = readFileSync(SMOKE_RUNNER_PATH, "utf8");
|
|
const workflow = readFileSync(INSTALL_SMOKE_WORKFLOW_PATH, "utf8");
|
|
|
|
expect(script).toContain(
|
|
'UPDATE_BASELINE_VERSION="${OPENCLAW_INSTALL_SMOKE_UPDATE_BASELINE:-latest}"',
|
|
);
|
|
expect(script).toContain('quiet_npm pack "${PACKAGE_NAME}@${UPDATE_BASELINE_VERSION}"');
|
|
expect(script).toContain('UPDATE_BASELINE_VERSION="$(');
|
|
expect(runner).toContain(
|
|
'UPDATE_BASELINE_VERSION="${OPENCLAW_INSTALL_UPDATE_BASELINE:-latest}"',
|
|
);
|
|
expect(runner).toContain("resolve_update_baseline_version");
|
|
expect(runner).toContain('quiet_npm view "${PACKAGE_NAME}@${UPDATE_BASELINE_VERSION}" version');
|
|
expect(workflow).toContain(
|
|
"OPENCLAW_INSTALL_SMOKE_UPDATE_BASELINE: ${{ inputs.update_baseline_version || 'latest' }}",
|
|
);
|
|
});
|
|
|
|
it("keeps install-sh Dockerfiles wired to their runner contracts", () => {
|
|
const e2eDockerfile = expectInstallDockerfileContract(
|
|
INSTALL_E2E_DOCKERFILE_PATH,
|
|
"install-sh-e2e/run.sh",
|
|
"/usr/local/bin/openclaw-install-e2e",
|
|
);
|
|
const smokeDockerfile = expectInstallDockerfileContract(
|
|
SMOKE_DOCKERFILE_PATH,
|
|
"install-sh-smoke/run.sh",
|
|
"/usr/local/bin/openclaw-install-smoke",
|
|
);
|
|
const nonrootDockerfile = expectInstallDockerfileContract(
|
|
NONROOT_DOCKERFILE_PATH,
|
|
"install-sh-nonroot/run.sh",
|
|
"/usr/local/bin/openclaw-install-nonroot",
|
|
);
|
|
|
|
expect(e2eDockerfile).toContain("USER appuser");
|
|
expect(smokeDockerfile).toContain(
|
|
"COPY install-sh-common/cli-verify.sh /usr/local/install-sh-common/cli-verify.sh",
|
|
);
|
|
expect(nonrootDockerfile).toContain(
|
|
"COPY install-sh-common/cli-verify.sh /usr/local/install-sh-common/cli-verify.sh",
|
|
);
|
|
expect(nonrootDockerfile).toContain("USER app");
|
|
expect(nonrootDockerfile).toContain("WORKDIR /home/app");
|
|
expect(nonrootDockerfile).toContain("NPM_CONFIG_UPDATE_NOTIFIER=false");
|
|
expect(nonrootDockerfile).toContain('installer="$(mktemp)"');
|
|
expect(nonrootDockerfile).toContain(
|
|
'curl -fsSL --connect-timeout 10 --max-time 120 -o "$installer" https://deb.nodesource.com/setup_24.x',
|
|
);
|
|
expect(nonrootDockerfile).toContain('bash "$installer"');
|
|
expect(nonrootDockerfile).toContain('rm -f "$installer"');
|
|
expect(nonrootDockerfile).not.toMatch(/curl[^\n]+\|\s*bash/u);
|
|
});
|
|
|
|
it("keeps shared install helpers parsing and verifying installed CLI versions", () => {
|
|
const root = tempDirs.make("openclaw-install-helper-");
|
|
const binDir = join(root, "bin");
|
|
mkdirSync(binDir, { recursive: true });
|
|
writeFileSync(
|
|
join(binDir, "openclaw"),
|
|
[
|
|
"#!/usr/bin/env bash",
|
|
"set -euo pipefail",
|
|
'case "${1:-}" in',
|
|
" --version)",
|
|
" printf 'OpenClaw v2026.6.21-beta.1\\r\\n'",
|
|
" ;;",
|
|
" --help)",
|
|
" printf 'usage\\n'",
|
|
" ;;",
|
|
" *)",
|
|
" exit 2",
|
|
" ;;",
|
|
"esac",
|
|
"",
|
|
].join("\n"),
|
|
{ mode: 0o755 },
|
|
);
|
|
|
|
const result = spawnSync(
|
|
"bash",
|
|
[
|
|
"-c",
|
|
[
|
|
"set -euo pipefail",
|
|
"source scripts/docker/install-sh-common/cli-verify.sh",
|
|
"printf 'parsed=%s\\n' \"$(extract_openclaw_semver 'OpenClaw v2026.6.21-beta.1+build.7')\"",
|
|
"verify_installed_cli openclaw 2026.6.21-beta.1",
|
|
].join("\n"),
|
|
],
|
|
{
|
|
cwd: process.cwd(),
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
HOME: root,
|
|
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stderr).toBe("");
|
|
expect(result.stdout).toContain("parsed=2026.6.21-beta.1+build.7");
|
|
expect(result.stdout).toContain(
|
|
"cli=openclaw installed=2026.6.21-beta.1 expected=2026.6.21-beta.1",
|
|
);
|
|
expect(result.stdout).toContain("==> Sanity: CLI runs");
|
|
});
|
|
|
|
it("keeps release-harness npm lookups outside caller freshness policy", () => {
|
|
const root = tempDirs.make("openclaw-install-npm-policy-");
|
|
const binDir = join(root, "bin");
|
|
mkdirSync(binDir, { recursive: true });
|
|
writeFileSync(
|
|
join(binDir, "npm"),
|
|
`#!${process.execPath}\nprocess.stdout.write(JSON.stringify({ args: process.argv.slice(2), policy: Object.fromEntries(Object.entries(process.env).filter(([key]) => ["npm_config_before", "npm_config_min_release_age", "npm_config_min-release-age"].includes(key.toLowerCase()))) }));\n`,
|
|
{ mode: 0o755 },
|
|
);
|
|
|
|
const noPolicyResult = spawnSync(
|
|
"bash",
|
|
[
|
|
"-c",
|
|
"set -euo pipefail; source scripts/docker/install-sh-common/version-parse.sh; run_npm_without_freshness_policy bash -c 'printf ok'",
|
|
],
|
|
{
|
|
cwd: process.cwd(),
|
|
encoding: "utf8",
|
|
env: Object.fromEntries(
|
|
Object.entries(process.env).filter(
|
|
([key]) =>
|
|
![
|
|
"npm_config_before",
|
|
"npm_config_globalconfig",
|
|
"npm_config_min_release_age",
|
|
"npm_config_min-release-age",
|
|
"npm_config_userconfig",
|
|
].includes(key.toLowerCase()),
|
|
),
|
|
),
|
|
},
|
|
);
|
|
expect(noPolicyResult.status, noPolicyResult.stderr).toBe(0);
|
|
expect(noPolicyResult.stdout).toBe("ok");
|
|
|
|
const noHomeResult = spawnSync(
|
|
"bash",
|
|
[
|
|
"-c",
|
|
"unset HOME; source scripts/docker/install-sh-common/version-parse.sh; resolve_npm_config_path_value '~/.npmrc'",
|
|
],
|
|
{ cwd: process.cwd(), encoding: "utf8", env: process.env },
|
|
);
|
|
expect(noHomeResult.status, noHomeResult.stderr).toBe(0);
|
|
const nativeHomeResult = spawnSync(
|
|
process.execPath,
|
|
["-e", "delete process.env.HOME; process.stdout.write(require('node:os').homedir())"],
|
|
{ encoding: "utf8", env: process.env },
|
|
);
|
|
expect(nativeHomeResult.status, nativeHomeResult.stderr).toBe(0);
|
|
expect(noHomeResult.stdout).toBe(join(nativeHomeResult.stdout, ".npmrc"));
|
|
|
|
const result = spawnSync(
|
|
"bash",
|
|
[
|
|
"-c",
|
|
"source scripts/docker/install-sh-common/version-parse.sh; quiet_npm view openclaw@2026.8.32 version",
|
|
],
|
|
{
|
|
cwd: process.cwd(),
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
NPM_CONFIG_BEFORE: "2026-09-17T23:07:28.000Z",
|
|
NPM_CONFIG_GLOBALCONFIG: join(root, "missing-global.npmrc"),
|
|
NPM_CONFIG_before: "2026-09-17T23:07:28.000Z",
|
|
"NPM_CONFIG_MIN-RELEASE-AGE": "10080",
|
|
NPM_CONFIG_MIN_RELEASE_AGE: "10080",
|
|
Npm_Config_Min_Release_Age: "10080",
|
|
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
|
npm_config_before: "2026-09-17T23:07:28.000Z",
|
|
npm_config_min_release_age: "10080",
|
|
"npm_config_min-release-age": "10080",
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(result.status, result.stderr).toBe(0);
|
|
const invocation = JSON.parse(result.stdout) as {
|
|
args: string[];
|
|
policy: Record<string, string>;
|
|
};
|
|
expect(invocation.args.slice(0, 3)).toEqual([
|
|
expect.stringMatching(/^--prefix=\/.+/u),
|
|
expect.stringMatching(/^--userconfig=\/dev\/fd\/\d+$/u),
|
|
expect.stringMatching(/^--globalconfig=\/dev\/fd\/\d+$/u),
|
|
]);
|
|
expect(invocation).toEqual({
|
|
args: [
|
|
...invocation.args.slice(0, 3),
|
|
"--loglevel=error",
|
|
"--logs-max=0",
|
|
"--no-update-notifier",
|
|
"--no-fund",
|
|
"--no-audit",
|
|
"--no-progress",
|
|
"view",
|
|
"openclaw@2026.8.32",
|
|
"version",
|
|
],
|
|
policy: {},
|
|
});
|
|
|
|
const projectDir = join(root, "project");
|
|
const workspaceDir = join(projectDir, "packages", "fixture");
|
|
const nestedProjectDir = join(workspaceDir, "nested");
|
|
mkdirSync(nestedProjectDir, { recursive: true });
|
|
writeFileSync(
|
|
join(projectDir, "package.json"),
|
|
'{"name":"npm-policy-root","workspaces":["!packages/private","./packages/*/"]}\n',
|
|
);
|
|
writeFileSync(join(workspaceDir, "package.json"), '{"name":"npm-policy-fixture"}\n');
|
|
const globalConfigPath = join(root, "global.npmrc");
|
|
const projectGlobalConfigPath = join(root, "project-global.npmrc");
|
|
const selectedUserConfigPath = join(root, "project-user.npmrc");
|
|
writeFileSync(globalConfigPath, "min-release-age=7\nfetch-retries=23\n");
|
|
writeFileSync(projectGlobalConfigPath, "min-release-age=7\nfetch-retries=17\n");
|
|
writeFileSync(
|
|
join(root, ".npmrc"),
|
|
[
|
|
"before=2026-09-17T23:07:28.000Z",
|
|
"globalconfig=${HOME}/global.npmrc",
|
|
"registry=https://user-registry.example.test/",
|
|
"ca[]=user-ca",
|
|
].join("\n"),
|
|
);
|
|
writeFileSync(
|
|
selectedUserConfigPath,
|
|
"globalconfig=${HOME}/global.npmrc\nregistry=https://selected-user-registry.example.test/\n",
|
|
);
|
|
writeFileSync(
|
|
join(projectDir, ".npmrc"),
|
|
"before=2026-09-17T23:07:28.000Z\nuserconfig=${HOME}/project-user.npmrc # selected user config\nglobalconfig=${HOME}/project-global.npmrc # selected global config\nregistry=https://project-registry.example.test/\nca[]=project-ca\ncafile=./certs/ca.pem\n",
|
|
);
|
|
writeFileSync(join(nestedProjectDir, ".npmrc"), "registry=https://nested.example.test/\n");
|
|
writeFileSync(join(workspaceDir, ".npmrc"), "registry=https://workspace.example.test/\n");
|
|
const projectResult = spawnSync(
|
|
"bash",
|
|
[
|
|
"-c",
|
|
'source "$1"; quiet_npm config get before; quiet_npm config get registry; quiet_npm config get fetch-retries; quiet_npm config get ca --json; quiet_npm config get cafile',
|
|
"bash",
|
|
join(process.cwd(), "scripts/docker/install-sh-common/version-parse.sh"),
|
|
],
|
|
{
|
|
cwd: nestedProjectDir,
|
|
encoding: "utf8",
|
|
env: { ...process.env, HOME: root },
|
|
},
|
|
);
|
|
expect(projectResult.status, projectResult.stderr).toBe(0);
|
|
expect(projectResult.stdout.trim().split("\n")).toEqual([
|
|
"null",
|
|
"https://project-registry.example.test/",
|
|
"17",
|
|
"project-ca",
|
|
join(nestedProjectDir, "certs", "ca.pem"),
|
|
]);
|
|
|
|
const globalResult = spawnSync(
|
|
"bash",
|
|
[
|
|
"-c",
|
|
'source "$1"; quiet_npm config get registry --location global',
|
|
"bash",
|
|
join(process.cwd(), "scripts/docker/install-sh-common/version-parse.sh"),
|
|
],
|
|
{
|
|
cwd: nestedProjectDir,
|
|
encoding: "utf8",
|
|
env: { ...process.env, HOME: root },
|
|
},
|
|
);
|
|
expect(globalResult.status, globalResult.stderr).toBe(0);
|
|
expect(globalResult.stdout.trim()).toBe("https://user-registry.example.test/");
|
|
|
|
const excludedWorkspaceDir = join(projectDir, "packages", "private");
|
|
const excludedNestedDir = join(excludedWorkspaceDir, "nested");
|
|
mkdirSync(excludedNestedDir, { recursive: true });
|
|
writeFileSync(join(excludedWorkspaceDir, "package.json"), '{"name":"npm-policy-private"}\n');
|
|
writeFileSync(
|
|
join(excludedWorkspaceDir, ".npmrc"),
|
|
"registry=https://private-registry.example.test/\n",
|
|
);
|
|
const excludedResult = spawnSync(
|
|
"bash",
|
|
[
|
|
"-c",
|
|
'source "$1"; quiet_npm config get registry',
|
|
"bash",
|
|
join(process.cwd(), "scripts/docker/install-sh-common/version-parse.sh"),
|
|
],
|
|
{
|
|
cwd: excludedNestedDir,
|
|
encoding: "utf8",
|
|
env: { ...process.env, HOME: root },
|
|
},
|
|
);
|
|
expect(excludedResult.status, excludedResult.stderr).toBe(0);
|
|
expect(excludedResult.stdout.trim()).toBe("https://private-registry.example.test/");
|
|
|
|
const lowercaseUserConfigPath = join(root, "lowercase#user.npmrc");
|
|
writeFileSync(lowercaseUserConfigPath, "fetch-timeout=1234\n");
|
|
const configPathNeutralEnv = Object.fromEntries(
|
|
Object.entries(process.env).filter(
|
|
([key]) =>
|
|
!["npm_config_globalconfig", "npm_config_userconfig"].includes(key.toLowerCase()),
|
|
),
|
|
);
|
|
for (const configEnv of [
|
|
{
|
|
NPM_CONFIG_GLOBALCONFIG: join(root, "missing-global.npmrc"),
|
|
NPM_CONFIG_USERCONFIG: join(root, "missing-user.npmrc"),
|
|
npm_config_globalconfig: "~/project-global.npmrc",
|
|
npm_config_userconfig: "${HOME}/lowercase#user.npmrc",
|
|
},
|
|
{
|
|
Npm_Config_Globalconfig: "~/project-global.npmrc",
|
|
Npm_Config_Userconfig: "${HOME}/lowercase#user.npmrc",
|
|
},
|
|
]) {
|
|
const configEnvResult = spawnSync(
|
|
"bash",
|
|
[
|
|
"-c",
|
|
'source "$1"; quiet_npm config get fetch-timeout; quiet_npm config get fetch-retries; quiet_npm config get globalconfig',
|
|
"bash",
|
|
join(process.cwd(), "scripts/docker/install-sh-common/version-parse.sh"),
|
|
],
|
|
{
|
|
cwd: nestedProjectDir,
|
|
encoding: "utf8",
|
|
env: { ...configPathNeutralEnv, HOME: root, ...configEnv },
|
|
},
|
|
);
|
|
expect(configEnvResult.status, configEnvResult.stderr).toBe(0);
|
|
expect(configEnvResult.stdout.trim().split("\n")).toEqual([
|
|
"1234",
|
|
"17",
|
|
expect.stringMatching(/^\/dev\/fd\/\d+$/u),
|
|
]);
|
|
}
|
|
});
|
|
|
|
it("can reuse dist from the already-built root Docker smoke image", () => {
|
|
const script = readFileSync(SCRIPT_PATH, "utf8");
|
|
const dockerfile = readFileSync("Dockerfile", "utf8");
|
|
|
|
expect(script).toContain('ROOT_DIR="${OPENCLAW_INSTALL_SMOKE_SOURCE_DIR:-$HARNESS_ROOT}"');
|
|
expect(script).toContain('UPDATE_DIST_IMAGE="${OPENCLAW_INSTALL_SMOKE_UPDATE_DIST_IMAGE:-}"');
|
|
expect(script).toContain("docker_e2e_restore_package_dist_from_image");
|
|
expect(script).toContain('source "$HARNESS_ROOT/scripts/lib/docker-e2e-package.sh"');
|
|
expect(script).toContain(
|
|
'DOCKER_COMMAND_TIMEOUT="${DOCKER_COMMAND_TIMEOUT:-${OPENCLAW_INSTALL_SMOKE_DOCKER_COMMAND_TIMEOUT:-600s}}"',
|
|
);
|
|
expect(script).toContain("ensure_local_update_dist_import_closure");
|
|
expect(script).toContain(
|
|
'node "$HARNESS_ROOT/scripts/check-package-dist-imports.mjs" "$ROOT_DIR"',
|
|
);
|
|
expect(script).toContain("WARN: reused Docker image dist failed import-closure check");
|
|
expect(script).toContain("pnpm build");
|
|
expect(script).not.toContain("pnpm ui:build");
|
|
expect(script).toContain('-f "$HARNESS_ROOT/scripts/docker/install-sh-smoke/Dockerfile"');
|
|
expect(script).toContain('-f "$HARNESS_ROOT/scripts/docker/install-sh-nonroot/Dockerfile"');
|
|
expect(dockerfile).toContain("node scripts/check-package-dist-imports.mjs /app");
|
|
});
|
|
|
|
it("restores root and AI build trees from one image", () => {
|
|
const result = runRestoreLocalDistFixture();
|
|
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(result.stdout).toContain("status=0");
|
|
expect(result.stdout).toContain("root=new-root");
|
|
expect(result.stdout).toContain("ai=new-ai");
|
|
});
|
|
|
|
it("rolls both build trees back when the AI swap fails", () => {
|
|
const result = runRestoreLocalDistFixture({ failAiSwap: true });
|
|
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(result.stdout).toContain("status=1");
|
|
expect(result.stdout).toContain("root=old-root");
|
|
expect(result.stdout).toContain("ai=old-ai");
|
|
});
|
|
|
|
it.each(["packages", "ai"] as const)(
|
|
"rejects a symlinked %s path before restoring artifacts",
|
|
(symlinkEscape) => {
|
|
const result = runRestoreLocalDistFixture({ symlinkEscape });
|
|
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(result.stdout).toContain("status=1");
|
|
expect(result.stdout).toContain("root=old-root");
|
|
expect(result.stdout).toContain("ai=external-ai");
|
|
expect(result.stdout).toContain("external=external-ai");
|
|
expect(result.stderr).not.toContain("docker-call=");
|
|
expect(result.stderr).toContain("refusing package artifact restore through a symlinked");
|
|
},
|
|
);
|
|
|
|
it("fails closed when exact image artifacts fail import closure", () => {
|
|
const result = spawnSync(
|
|
"bash",
|
|
[
|
|
"--noprofile",
|
|
"--norc",
|
|
"-c",
|
|
`set -euo pipefail
|
|
HARNESS_ROOT=/trusted
|
|
ROOT_DIR=/candidate
|
|
UPDATE_SKIP_LOCAL_BUILD=1
|
|
node() {
|
|
return 1
|
|
}
|
|
pnpm() {
|
|
printf 'pnpm-called\\n'
|
|
}
|
|
${extractEnsureLocalUpdateDistImportClosure()}
|
|
status=0
|
|
ensure_local_update_dist_import_closure || status=$?
|
|
printf 'status=%s\\n' "$status"
|
|
`,
|
|
],
|
|
{ encoding: "utf8" },
|
|
);
|
|
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(result.stdout).toContain("status=1");
|
|
expect(result.stdout).not.toContain("pnpm-called");
|
|
expect(result.stderr).toContain("exact-image mode forbids a local rebuild");
|
|
});
|
|
|
|
it("bounds installer smoke container runs", () => {
|
|
const script = readFileSync(SCRIPT_PATH, "utf8");
|
|
|
|
expect(script).toContain(
|
|
'INSTALL_SMOKE_DOCKER_RUN_TIMEOUT="${OPENCLAW_INSTALL_SMOKE_DOCKER_RUN_TIMEOUT:-2700s}"',
|
|
);
|
|
expect(script).toContain("run_install_smoke_container()");
|
|
expect(script).toContain(
|
|
'DOCKER_COMMAND_TIMEOUT="$INSTALL_SMOKE_DOCKER_RUN_TIMEOUT" docker_e2e_docker_run_cmd run "$@"',
|
|
);
|
|
expect(script.match(/run_install_smoke_container --rm -t/g)?.length).toBe(6);
|
|
expect(script).not.toContain("docker run --rm -t \\");
|
|
});
|
|
|
|
it("rejects stale non-root smoke Node runtimes below the runtime floor", () => {
|
|
const result = runNonrootNodePreflight("22.22.2");
|
|
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain("unsupported node 22.22.2");
|
|
});
|
|
|
|
it("rejects non-root smoke Node runtimes without node:sqlite", () => {
|
|
const result = runNonrootNodePreflight("24.16.0", { sqlite: false });
|
|
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain("unsupported node 24.16.0: missing node:sqlite");
|
|
});
|
|
|
|
it("rejects non-root smoke Node runtimes with vulnerable system SQLite", () => {
|
|
const result = runNonrootNodePreflight("24.17.0", { sqliteVersion: "3.51.2" });
|
|
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain("unsupported node 24.17.0: unsafe SQLite 3.51.2");
|
|
});
|
|
|
|
it("accepts non-root smoke Node runtimes that match the installer runtime floor", () => {
|
|
expect(runNonrootNodePreflight("22.23.2").status).toBe(1);
|
|
expect(runNonrootNodePreflight("24.16.0").status).toBe(0);
|
|
expect(runNonrootNodePreflight("25.9.0").status).toBe(1);
|
|
expect(runNonrootNodePreflight("26.0.0").status).toBe(1);
|
|
expect(runNonrootNodePreflight("26.1.0").status).toBe(0);
|
|
});
|
|
|
|
it("runs the root Dockerfile build with the CI heap limit", () => {
|
|
const dockerfile = readFileSync("Dockerfile", "utf8");
|
|
|
|
expect(dockerfile).toContain(
|
|
'ARG OPENCLAW_DOCKER_BUILD_NODE_OPTIONS="--max-old-space-size=8192"',
|
|
);
|
|
expect(dockerfile).toContain('ARG OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB=""');
|
|
expect(dockerfile).toContain("ARG OPENCLAW_DOCKER_BUILD_SKIP_DTS=1");
|
|
expect(dockerfile).toContain(
|
|
'OPENCLAW_RUN_NODE_SKIP_DTS_BUILD="$OPENCLAW_DOCKER_BUILD_SKIP_DTS" OPENCLAW_TSDOWN_MAX_OLD_SPACE_MB="$OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB" NODE_OPTIONS="$OPENCLAW_DOCKER_BUILD_NODE_OPTIONS" pnpm_config_verify_deps_before_run=false pnpm build:docker',
|
|
);
|
|
});
|
|
|
|
it("copies the launcher version contract into root Docker build and runtime stages", () => {
|
|
const dockerfile = readFileSync("Dockerfile", "utf8");
|
|
|
|
expect(dockerfile).toContain("COPY node-version.mjs ./");
|
|
expect(dockerfile).toContain(
|
|
"COPY --from=runtime-assets --chown=node:node /app/node-version.mjs .",
|
|
);
|
|
});
|
|
|
|
it("exports the Playwright browser cache installed by the root Dockerfile", () => {
|
|
const dockerfile = readFileSync("Dockerfile", "utf8");
|
|
|
|
expect(dockerfile).toContain("ENV PLAYWRIGHT_BROWSERS_PATH=/home/node/.cache/ms-playwright");
|
|
expect(dockerfile).toContain('mkdir -p "$PLAYWRIGHT_BROWSERS_PATH"');
|
|
expect(dockerfile).toContain(
|
|
"node /app/node_modules/playwright-core/cli.js install --with-deps chromium",
|
|
);
|
|
});
|
|
|
|
it("verifies fs-safe native loading before and after Docker runtime assembly", () => {
|
|
const dockerfile = readFileSync("Dockerfile", "utf8");
|
|
const verifier = readFileSync("scripts/docker/verify-native-addons.sh", "utf8");
|
|
|
|
expect(dockerfile).toContain(
|
|
"COPY scripts/docker/verify-fs-safe-native.mjs ./scripts/docker/verify-fs-safe-native.mjs",
|
|
);
|
|
expect(dockerfile.match(/RUN sh scripts\/docker\/verify-native-addons\.sh/gu)).toHaveLength(2);
|
|
expect(dockerfile).toContain(
|
|
"node /tmp/verify-fs-safe-native.mjs --package-root /app --mode require",
|
|
);
|
|
expect(verifier).toContain(
|
|
"node scripts/docker/verify-fs-safe-native.mjs --package-root /app --mode require",
|
|
);
|
|
expect(verifier).toContain("if grep -qx 'matrix' /tmp/openclaw-selected-plugin-dirs; then");
|
|
});
|
|
|
|
it("passes the baked browser build arg through Docker setup", () => {
|
|
const script = readFileSync(DOCKER_SETUP_PATH, "utf8");
|
|
|
|
expect(script).toContain('export OPENCLAW_INSTALL_BROWSER="${OPENCLAW_INSTALL_BROWSER:-}"');
|
|
expect(script).toContain("OPENCLAW_INSTALL_BROWSER \\");
|
|
expect(script).toContain('--build-arg "OPENCLAW_INSTALL_BROWSER=${OPENCLAW_INSTALL_BROWSER}"');
|
|
});
|
|
|
|
it("bounds Docker setup image pulls", () => {
|
|
const script = readFileSync(DOCKER_SETUP_PATH, "utf8");
|
|
const timeoutHelper = readFileSync(HOST_TIMEOUT_PATH, "utf8");
|
|
|
|
expect(script).toContain('source "$ROOT_DIR/scripts/lib/host-timeout.sh"');
|
|
expect(script).toContain('DOCKER_PULL_TIMEOUT="${OPENCLAW_DOCKER_SETUP_PULL_TIMEOUT:-600s}"');
|
|
expect(script).toContain("run_docker_pull()");
|
|
expect(script).toContain(
|
|
'openclaw_host_timeout_cmd "$DOCKER_PULL_TIMEOUT" docker pull "$image"',
|
|
);
|
|
expect(timeoutHelper).toContain("elif command -v gtimeout >/dev/null 2>&1; then");
|
|
expect(timeoutHelper).toContain('"$timeout_bin" --kill-after=30s "$timeout_value" "$@"');
|
|
expect(script).toContain('run_docker_pull "$IMAGE_NAME"');
|
|
expect(script).not.toContain('docker pull "$IMAGE_NAME"');
|
|
});
|
|
|
|
it("validates Docker timezones against the selected image runtime", () => {
|
|
for (const timezone of ["Asia/Shanghai", "UTC", "US/Pacific"]) {
|
|
const result = runDockerTimezoneValidator(timezone);
|
|
expect(result.stderr).toBe("");
|
|
expect(result.status).toBe(0);
|
|
}
|
|
|
|
for (const timezone of ["zone.tab", "iso3166.tab", "Factory", "localtime"]) {
|
|
const result = runDockerTimezoneValidator(timezone);
|
|
expect(result.status).toBe(1);
|
|
}
|
|
|
|
const script = readFileSync(DOCKER_SETUP_PATH, "utf8");
|
|
expect(script).not.toContain("/usr/share/zoneinfo");
|
|
expect(script).toContain(
|
|
'fail "OPENCLAW_TZ must be supported by $IMAGE_NAME (e.g. Asia/Shanghai)."',
|
|
);
|
|
});
|
|
|
|
it("bounds Podman setup image pulls", () => {
|
|
const script = readFileSync(PODMAN_SETUP_PATH, "utf8");
|
|
|
|
expect(script).toContain('source "$REPO_PATH/scripts/lib/host-timeout.sh"');
|
|
expect(script).toContain('PODMAN_PULL_TIMEOUT="${OPENCLAW_PODMAN_SETUP_PULL_TIMEOUT:-600s}"');
|
|
expect(script).toContain("run_podman_pull()");
|
|
expect(script).toContain(
|
|
'openclaw_host_timeout_cmd "$PODMAN_PULL_TIMEOUT" podman pull "$image"',
|
|
);
|
|
expect(script).toContain('run_podman_pull "$OPENCLAW_IMAGE"');
|
|
expect(script).not.toContain('podman pull "$OPENCLAW_IMAGE"');
|
|
});
|
|
|
|
it("bounds Podman setup image builds", () => {
|
|
const script = readFileSync(PODMAN_SETUP_PATH, "utf8");
|
|
|
|
expect(script).toContain(
|
|
'PODMAN_BUILD_TIMEOUT="${OPENCLAW_PODMAN_SETUP_BUILD_TIMEOUT:-1800s}"',
|
|
);
|
|
expect(script).toContain("run_podman_build()");
|
|
expect(script).toContain('openclaw_host_timeout_cmd "$PODMAN_BUILD_TIMEOUT" podman build "$@"');
|
|
expect(script).toContain('run_podman_build -t "$OPENCLAW_IMAGE"');
|
|
expect(script).not.toContain('podman build -t "$OPENCLAW_IMAGE"');
|
|
});
|
|
|
|
it("bounds detached Podman launches without timing out onboarding", () => {
|
|
const script = readFileSync(PODMAN_RUN_PATH, "utf8");
|
|
|
|
expect(script).toContain('PODMAN_RUN_TIMEOUT="${OPENCLAW_PODMAN_RUN_TIMEOUT:-600s}"');
|
|
expect(script).toContain("OPENCLAW_PODMAN_RUN_TIMEOUT|OPENCLAW_PODMAN_GATEWAY_HOST_PORT");
|
|
expect(script).toContain('source "$SCRIPT_DIR/lib/host-timeout.sh"');
|
|
expect(script).toContain("run_podman_detached()");
|
|
expect(script).toContain('openclaw_host_timeout_cmd "$PODMAN_RUN_TIMEOUT" podman run "$@"');
|
|
expect(script).toContain('podman run --pull="$PODMAN_PULL" --rm -it \\');
|
|
expect(script).toContain('run_podman_detached --pull="$PODMAN_PULL" -d --replace \\');
|
|
expect(script).not.toContain('podman run --pull="$PODMAN_PULL" -d --replace \\');
|
|
});
|
|
|
|
it("passes image-scoped pip packages through Docker and Podman setup", () => {
|
|
const dockerSetup = readFileSync(DOCKER_SETUP_PATH, "utf8");
|
|
const podmanSetup = readFileSync(PODMAN_SETUP_PATH, "utf8");
|
|
const dockerfile = readFileSync("Dockerfile", "utf8");
|
|
|
|
expect(dockerfile).toContain("ARG OPENCLAW_IMAGE_PIP_PACKAGES");
|
|
expect(dockerfile).toContain(
|
|
"python3 -m pip install --no-cache-dir --break-system-packages $OPENCLAW_IMAGE_PIP_PACKAGES",
|
|
);
|
|
expect(dockerSetup).toContain(
|
|
'export OPENCLAW_IMAGE_PIP_PACKAGES="${OPENCLAW_IMAGE_PIP_PACKAGES:-}"',
|
|
);
|
|
expect(dockerSetup).toContain("OPENCLAW_IMAGE_PIP_PACKAGES \\");
|
|
expect(dockerSetup).toContain(
|
|
'--build-arg "OPENCLAW_IMAGE_PIP_PACKAGES=${OPENCLAW_IMAGE_PIP_PACKAGES}"',
|
|
);
|
|
expect(dockerSetup).not.toContain("OPENCLAW_DOCKER_PIP_PACKAGES");
|
|
expect(podmanSetup).toContain('OPENCLAW_IMAGE_PIP_PACKAGES="${OPENCLAW_IMAGE_PIP_PACKAGES:-}"');
|
|
expect(podmanSetup).toContain(
|
|
'BUILD_ARGS+=(--build-arg "OPENCLAW_IMAGE_PIP_PACKAGES=${OPENCLAW_IMAGE_PIP_PACKAGES}")',
|
|
);
|
|
expect(podmanSetup).not.toContain("OPENCLAW_DOCKER_PIP_PACKAGES");
|
|
});
|
|
|
|
it("passes one source identity into local Docker and Podman builds", () => {
|
|
const dockerSetup = readFileSync(DOCKER_SETUP_PATH, "utf8");
|
|
const podmanSetup = readFileSync(PODMAN_SETUP_PATH, "utf8");
|
|
|
|
for (const setupScript of [dockerSetup, podmanSetup]) {
|
|
expect(setupScript).toContain("scripts/lib/build-metadata.sh");
|
|
expect(setupScript).toContain("openclaw_resolve_git_commit");
|
|
expect(setupScript).toContain("openclaw_resolve_build_timestamp");
|
|
expect(setupScript).toContain("OPENCLAW_BUILD_TIMESTAMP=${BUILD_TIMESTAMP}");
|
|
expect(setupScript).toContain("GIT_COMMIT=${BUILD_GIT_COMMIT}");
|
|
}
|
|
});
|
|
|
|
it("keeps the Podman Quadlet template aligned with setup substitutions", () => {
|
|
const setupScript = readFileSync(PODMAN_SETUP_PATH, "utf8");
|
|
const template = readFileSync(PODMAN_QUADLET_TEMPLATE_PATH, "utf8");
|
|
|
|
expect(setupScript).toContain(
|
|
'QUADLET_TEMPLATE="$REPO_PATH/scripts/podman/openclaw.container.in"',
|
|
);
|
|
for (const placeholder of [
|
|
"OPENCLAW_CONFIG_DIR",
|
|
"OPENCLAW_WORKSPACE_DIR",
|
|
"IMAGE_NAME",
|
|
"CONTAINER_NAME",
|
|
]) {
|
|
expect(setupScript).toContain(`{{${placeholder}}}`);
|
|
expect(template).toContain(`{{${placeholder}}}`);
|
|
}
|
|
|
|
expect(template).toContain("UserNS=keep-id");
|
|
expect(template).toContain("User=%U:%G");
|
|
expect(template).toContain("Volume={{OPENCLAW_CONFIG_DIR}}:/home/node/.openclaw:Z");
|
|
expect(template).toContain(
|
|
"Volume={{OPENCLAW_WORKSPACE_DIR}}:/home/node/.openclaw/workspace:Z",
|
|
);
|
|
expect(template).toContain("EnvironmentFile={{OPENCLAW_CONFIG_DIR}}/.env");
|
|
expect(template).toContain("PublishPort=127.0.0.1:18789:18789");
|
|
expect(template).toContain("Exec=node dist/index.js gateway --bind lan --port 18789");
|
|
expect(template).not.toContain("/home/admin");
|
|
});
|
|
|
|
it("allows repository branch history and release tags for secret-backed Docker release checks", () => {
|
|
const workflow = readFileSync(LIVE_E2E_WORKFLOW_PATH, "utf8");
|
|
|
|
expect(workflow).toContain('git rev-parse --verify "${INPUT_REF}^{commit}"');
|
|
expect(workflow).toContain(
|
|
'git merge-base --is-ancestor "$selected_sha" refs/remotes/origin/main',
|
|
);
|
|
expect(workflow).toContain("repository-branch-history");
|
|
expect(workflow).toContain("git tag --points-at \"$selected_sha\" | grep -Eq '^v'");
|
|
expect(workflow).toContain(
|
|
"git for-each-ref --format='%(refname:short)' --contains \"$selected_sha\" refs/remotes/origin",
|
|
);
|
|
expect(workflow).toContain("reachable from an OpenClaw branch or release tag");
|
|
});
|
|
|
|
it.each([
|
|
{ label: "exact budget", unpackedSize: 320 * 1024 * 1024, exitCode: 0, githubActions: false },
|
|
{
|
|
label: "one byte over budget locally",
|
|
unpackedSize: 320 * 1024 * 1024 + 1,
|
|
exitCode: 1,
|
|
githubActions: false,
|
|
},
|
|
{
|
|
label: "one byte over budget in CI",
|
|
unpackedSize: 320 * 1024 * 1024 + 1,
|
|
exitCode: 0,
|
|
githubActions: true,
|
|
},
|
|
])("enforces the default pack budget for $label", ({ unpackedSize, exitCode, githubActions }) => {
|
|
const { result, summary } = runInstallSmokePackHelpers(
|
|
[{ filename: "candidate.tgz", unpackedSize }],
|
|
undefined,
|
|
githubActions,
|
|
);
|
|
|
|
expect(result.status).toBe(exitCode);
|
|
if (exitCode === 0 && !githubActions) {
|
|
expect(result.stderr).toBe("");
|
|
} else {
|
|
expect(result.stderr).toContain(
|
|
`candidate.tgz unpackedSize ${unpackedSize} bytes (320.0 MiB) exceeds budget 335544320 bytes`,
|
|
);
|
|
}
|
|
if (githubActions) {
|
|
expect(result.stderr).toContain("::warning file=package.json,");
|
|
expect(summary).toContain(`unpackedSize ${unpackedSize} bytes`);
|
|
} else {
|
|
expect(summary).toBe("");
|
|
}
|
|
});
|
|
|
|
it.each([false, true])("fails closed when pack metadata has no size (CI=%s)", (githubActions) => {
|
|
const { result } = runInstallSmokePackHelpers(
|
|
[{ filename: "candidate.tgz" }],
|
|
undefined,
|
|
githubActions,
|
|
);
|
|
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain("install smoke cannot verify pack budget");
|
|
});
|
|
|
|
it("normalizes npm 12 pack output and enforces the budget without tsx", () => {
|
|
const withinBudget = runInstallSmokePackHelpers({
|
|
openclaw: {
|
|
filename: "openclaw-2026.8.1.tgz",
|
|
unpackedSize: 100,
|
|
version: "2026.8.1",
|
|
},
|
|
});
|
|
expect(withinBudget.result.status).toBe(0);
|
|
expect(withinBudget.result.stderr).toBe("");
|
|
expect(withinBudget.normalized).toEqual([
|
|
{
|
|
filename: "openclaw-2026.8.1.tgz",
|
|
unpackedSize: 100,
|
|
version: "2026.8.1",
|
|
},
|
|
]);
|
|
|
|
const oversized = runInstallSmokePackHelpers(
|
|
[{ name: "ignored" }, { filename: "candidate.tgz", unpackedSize: 101 }],
|
|
100,
|
|
);
|
|
expect(oversized.result.status).not.toBe(0);
|
|
expect(oversized.result.stderr).toContain(
|
|
"candidate.tgz unpackedSize 101 bytes (0.0 MiB) exceeds budget 100 bytes",
|
|
);
|
|
});
|
|
|
|
it("rejects path-like npm pack tarball filenames in update smoke metadata", () => {
|
|
expect(runReadPackTarballFilename("openclaw-2026.6.17.tgz")).toMatchObject({
|
|
status: 0,
|
|
stdout: "openclaw-2026.6.17.tgz",
|
|
});
|
|
|
|
const unsafeFilenames = [
|
|
"../openclaw.tgz",
|
|
"nested/openclaw.tgz",
|
|
"nested\\openclaw.tgz",
|
|
"/tmp/openclaw.tgz",
|
|
"C:\\temp\\openclaw.tgz",
|
|
"openclaw.tar.gz",
|
|
];
|
|
|
|
for (const filename of unsafeFilenames) {
|
|
const result = runReadPackTarballFilename(filename);
|
|
|
|
expect(result.status, filename).not.toBe(0);
|
|
expect(result.stderr, filename).toContain("npm pack reported unsafe tarball filename");
|
|
}
|
|
});
|
|
|
|
it("uses the package artifact helper for local update tarballs", () => {
|
|
const script = readFileSync(SCRIPT_PATH, "utf8");
|
|
|
|
expect(script).toContain('node "$HARNESS_ROOT/scripts/package-openclaw-for-docker.mjs"');
|
|
expect(script).toContain("--allow-unreleased-changelog");
|
|
expect(script).toContain("OPENCLAW_INSTALL_SMOKE_ALLOW_UNRELEASED_CHANGELOG");
|
|
expect(script).toContain(
|
|
'if [[ "${OPENCLAW_INSTALL_SMOKE_ALLOW_UNRELEASED_CHANGELOG:-true}" == "true" ]]',
|
|
);
|
|
expect(script).toContain("package_args+=(--allow-unreleased-changelog)");
|
|
expect(script).toContain('--source-dir "$ROOT_DIR"');
|
|
expect(script).toContain('--pack-json "$pack_json_file"');
|
|
expect(script).toContain("--skip-build");
|
|
expect(script).not.toContain("node --import tsx scripts/write-package-dist-inventory.ts");
|
|
expect(script).not.toContain("quiet_npm pack --ignore-scripts --json");
|
|
expect(script).toContain('node "$HARNESS_ROOT/scripts/check-openclaw-package-tarball.mjs"');
|
|
expect(script).toContain("--require-bundled-workspace-deps");
|
|
});
|
|
|
|
it("keeps frozen payload mode free of candidate build and normalization work", () => {
|
|
const script = readFileSync(SCRIPT_PATH, "utf8");
|
|
const prepareStart = script.indexOf("prepare_update_tarball() {");
|
|
const frozenStart = script.indexOf('if [[ -n "$FROZEN_PAYLOAD_DIR" ]]; then', prepareStart);
|
|
const packageSpecBranch = script.indexOf('elif [[ -n "$UPDATE_PACKAGE_SPEC" ]]', frozenStart);
|
|
const frozenBranch = script.slice(frozenStart, packageSpecBranch);
|
|
|
|
expect(prepareStart).toBeGreaterThan(0);
|
|
expect(frozenStart).toBeGreaterThan(0);
|
|
expect(packageSpecBranch).toBeGreaterThan(frozenStart);
|
|
expect(frozenBranch).toContain('cp "$FROZEN_PAYLOAD_DIR/candidate.tgz"');
|
|
expect(frozenBranch).toContain('cp "$FROZEN_PAYLOAD_DIR/candidate-pack.json"');
|
|
expect(frozenBranch).not.toContain("pnpm build");
|
|
expect(frozenBranch).not.toContain("package-openclaw-for-docker");
|
|
expect(frozenBranch).not.toContain("normalize_npm_pack_json_file");
|
|
expect(script).not.toContain("node --import tsx");
|
|
expect(script).toContain('if [[ -z "$FROZEN_PAYLOAD_DIR" ]]; then');
|
|
expect(script).toContain(
|
|
'require_regular_payload_file "$FROZEN_PAYLOAD_DIR/install.sh" "installer"',
|
|
);
|
|
expect(script).toContain(
|
|
'require_regular_payload_file "$FROZEN_PAYLOAD_DIR/install-cli.sh" "CLI installer"',
|
|
);
|
|
});
|
|
|
|
it("runs candidate tarballs through the installer script instead of direct npm", () => {
|
|
const wrapper = readFileSync(SCRIPT_PATH, "utf8");
|
|
const runner = readFileSync(SMOKE_RUNNER_PATH, "utf8");
|
|
|
|
expect(wrapper).toContain('-v "$INSTALL_SCRIPT_PATH:/tmp/openclaw-install.sh:ro"');
|
|
expect(wrapper).toContain(
|
|
'FROZEN_PAYLOAD_DIR="${OPENCLAW_INSTALL_SMOKE_FROZEN_PAYLOAD_DIR:-}"',
|
|
);
|
|
expect(wrapper).toContain('FROZEN_NODE_VERSION="${OPENCLAW_INSTALL_SMOKE_NODE_VERSION:-}"');
|
|
expect(wrapper).toContain('-e "OPENCLAW_NODE_VERSION=$FROZEN_NODE_VERSION"');
|
|
expect(runner).toContain("Run official installer one-liner for latest release tarball");
|
|
expect(runner).toContain("run_installer_pipeline");
|
|
expect(runner).toContain('--version "$FRESH_TAG_URL"');
|
|
expect(runner).not.toContain('npm_install_global "install latest release tarball"');
|
|
});
|
|
|
|
it("bounds both non-root installer pipelines and propagates curl failures", () => {
|
|
const wrapper = readFileSync(SCRIPT_PATH, "utf8");
|
|
const nonrootRunner = readFileSync(NONROOT_RUNNER_PATH, "utf8");
|
|
|
|
expect(wrapper).toContain('-e OPENCLAW_INSTALL_CLI_URL="$CLI_INSTALL_URL"');
|
|
expect(wrapper).toContain(
|
|
`'set -o pipefail; curl -fsSL --connect-timeout 30 --max-time 300 -- "$OPENCLAW_INSTALL_CLI_URL" | bash -s -- --set-npm-prefix --no-onboard'`,
|
|
);
|
|
expect(nonrootRunner).toContain(
|
|
'curl -fsSL --connect-timeout 30 --max-time 300 -o "$installer" -- "$INSTALL_URL"',
|
|
);
|
|
expect(nonrootRunner.indexOf('-o "$installer"')).toBeLessThan(
|
|
nonrootRunner.indexOf('bash "$installer"'),
|
|
);
|
|
});
|
|
|
|
it("does not execute or retain a non-root installer after curl fails", () => {
|
|
const fixture = runNonrootInstallerFixture(28);
|
|
const outputPath = readFileSync(fixture.outputPathCapture, "utf8");
|
|
|
|
expect(fixture.result.status).toBe(28);
|
|
expect(readNulSeparatedArgs(fixture.curlArgsPath)).toEqual([
|
|
"-fsSL",
|
|
"--connect-timeout",
|
|
"30",
|
|
"--max-time",
|
|
"300",
|
|
"-o",
|
|
outputPath,
|
|
"--",
|
|
"https://installer.example.test/install.sh",
|
|
]);
|
|
expect(existsSync(fixture.markerPath)).toBe(false);
|
|
expect(existsSync(outputPath)).toBe(false);
|
|
});
|
|
|
|
it("executes and cleans the non-root installer after curl succeeds", () => {
|
|
const fixture = runNonrootInstallerFixture(0);
|
|
|
|
expect(fixture.result.status, fixture.result.stderr).toBe(0);
|
|
expect(existsSync(fixture.markerPath)).toBe(true);
|
|
expect(existsSync(readFileSync(fixture.outputPathCapture, "utf8"))).toBe(false);
|
|
});
|
|
|
|
it("uses public npm latest as the non-root installer expectation", () => {
|
|
const wrapper = readFileSync(SCRIPT_PATH, "utf8");
|
|
|
|
expect(wrapper).toContain(
|
|
'public_latest_version="$(quiet_npm view "$PACKAGE_NAME" version 2>/dev/null || true)"',
|
|
);
|
|
expect(wrapper).toContain('LATEST_VERSION="$public_latest_version"');
|
|
expect(wrapper).toContain('LATEST_VERSION="$(quiet_npm view "$PACKAGE_NAME" version)"');
|
|
expect(wrapper).toContain('-e OPENCLAW_INSTALL_EXPECT_VERSION="$LATEST_VERSION"');
|
|
});
|
|
|
|
it("runs update and non-root installer groups independently while defaulting to all", () => {
|
|
const wrapper = readFileSync(SCRIPT_PATH, "utf8");
|
|
|
|
expect(wrapper).toContain('INSTALL_SMOKE_GROUP="${OPENCLAW_INSTALL_SMOKE_GROUP:-all}"');
|
|
expect(wrapper).toContain("RUN_UPDATE_GROUP=0");
|
|
expect(wrapper).toContain("RUN_NONROOT_GROUP=0");
|
|
expect(wrapper).toContain('case "$INSTALL_SMOKE_GROUP" in');
|
|
expect(wrapper).toContain("all)");
|
|
expect(wrapper).toContain("update)");
|
|
expect(wrapper).toContain("nonroot)");
|
|
expect(wrapper).toContain('if [[ "$RUN_UPDATE_GROUP" == "1" ]]');
|
|
expect(wrapper).toContain('if [[ "$RUN_NONROOT_GROUP" != "1" ]]');
|
|
expect(wrapper.indexOf('if [[ "$RUN_UPDATE_GROUP" == "1" ]]')).toBeLessThan(
|
|
wrapper.indexOf('if [[ "$RUN_NONROOT_GROUP" != "1" ]]'),
|
|
);
|
|
|
|
const invalid = spawnSync("bash", [SCRIPT_PATH], {
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
OPENCLAW_INSTALL_SMOKE_GROUP: "invalid",
|
|
},
|
|
});
|
|
expect(invalid.status).toBe(2);
|
|
expect(invalid.stderr).toContain(
|
|
"OPENCLAW_INSTALL_SMOKE_GROUP must be all, update, or nonroot",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("install-sh E2E runner", () => {
|
|
it("does not execute a partial installer after a bounded download fails", () => {
|
|
const fixture = runInstallE2eInstallerFixture({
|
|
curlExitCode: 28,
|
|
installerBody: 'touch "$INSTALL_MARKER"\n',
|
|
installTag: "latest",
|
|
});
|
|
|
|
expect(fixture.result.status).toBe(28);
|
|
expect(readFileSync(fixture.curlArgsPath, "utf8")).toContain(
|
|
"-fsSL --connect-timeout 10 --max-time 120 https://installer.example.test/install.sh -o",
|
|
);
|
|
expect(existsSync(fixture.markerPath)).toBe(false);
|
|
expect(existsSync(readFileSync(fixture.outputPathCapture, "utf8"))).toBe(false);
|
|
});
|
|
|
|
it.each([
|
|
["latest", "|"],
|
|
["beta", "1|"],
|
|
["2026.7.1", "|2026.7.1"],
|
|
])(
|
|
"executes a complete %s installer with the expected tag environment",
|
|
(installTag, expected) => {
|
|
const fixture = runInstallE2eInstallerFixture({
|
|
installTag,
|
|
installerBody:
|
|
'printf "%s|%s" "${OPENCLAW_BETA-}" "${OPENCLAW_VERSION-}" >"$INSTALL_MARKER"\n',
|
|
});
|
|
|
|
expect(fixture.result.status, fixture.result.stderr).toBe(0);
|
|
expect(readFileSync(fixture.markerPath, "utf8")).toBe(expected);
|
|
expect(existsSync(readFileSync(fixture.outputPathCapture, "utf8"))).toBe(false);
|
|
},
|
|
);
|
|
|
|
it("normalizes Docker wrapper timing and toggle knobs before forwarding", () => {
|
|
const wrapper = readFileSync(INSTALL_E2E_DOCKER_PATH, "utf8");
|
|
|
|
expect(wrapper).toContain(
|
|
'AGENT_TURN_TIMEOUT_SECONDS="$(\n docker_e2e_read_positive_int_env OPENCLAW_INSTALL_E2E_AGENT_TURN_TIMEOUT_SECONDS 300\n)"',
|
|
);
|
|
expect(wrapper).toContain(
|
|
'OPENAI_PROVIDER_TIMEOUT_SECONDS="$(\n docker_e2e_read_positive_int_env OPENCLAW_INSTALL_E2E_OPENAI_PROVIDER_TIMEOUT_SECONDS "$AGENT_TURN_TIMEOUT_SECONDS"\n)"',
|
|
);
|
|
expect(wrapper).toContain(
|
|
'AGENT_TURNS_PARALLEL="$(read_boolean_env OPENCLAW_INSTALL_E2E_AGENT_TURNS_PARALLEL 1)"',
|
|
);
|
|
expect(wrapper).toContain(
|
|
'AGENT_TOOL_SMOKE="$(read_boolean_env OPENCLAW_INSTALL_E2E_AGENT_TOOL_SMOKE 1)"',
|
|
);
|
|
expect(wrapper).toContain(
|
|
'SESSION_SCAN_BYTES="$(\n docker_e2e_read_positive_int_env OPENCLAW_INSTALL_E2E_SESSION_SCAN_BYTES 16777216\n)"',
|
|
);
|
|
expect(wrapper).toContain(
|
|
'SESSION_LINE_BYTES="$(\n docker_e2e_read_positive_int_env OPENCLAW_INSTALL_E2E_SESSION_LINE_BYTES 1048576\n)"',
|
|
);
|
|
expect(wrapper).toContain(
|
|
'SESSION_SCAN_DEPTH="$(docker_e2e_read_positive_int_env OPENCLAW_INSTALL_E2E_SESSION_SCAN_DEPTH 64)"',
|
|
);
|
|
expect(wrapper).toContain(
|
|
'SESSION_SCAN_NODES="$(docker_e2e_read_positive_int_env OPENCLAW_INSTALL_E2E_SESSION_SCAN_NODES 100000)"',
|
|
);
|
|
expect(wrapper).toContain(
|
|
'-e OPENCLAW_INSTALL_E2E_OPENAI_PROVIDER_TIMEOUT_SECONDS="$OPENAI_PROVIDER_TIMEOUT_SECONDS"',
|
|
);
|
|
expect(wrapper).toContain(
|
|
'-e OPENCLAW_INSTALL_E2E_AGENT_TURN_TIMEOUT_SECONDS="$AGENT_TURN_TIMEOUT_SECONDS"',
|
|
);
|
|
expect(wrapper).toContain(
|
|
'-e OPENCLAW_INSTALL_E2E_AGENT_TURNS_PARALLEL="$AGENT_TURNS_PARALLEL"',
|
|
);
|
|
expect(wrapper).toContain('-e OPENCLAW_INSTALL_E2E_AGENT_TOOL_SMOKE="$AGENT_TOOL_SMOKE"');
|
|
expect(wrapper).toContain('-e OPENCLAW_INSTALL_E2E_SESSION_SCAN_BYTES="$SESSION_SCAN_BYTES"');
|
|
expect(wrapper).toContain('-e OPENCLAW_INSTALL_E2E_SESSION_LINE_BYTES="$SESSION_LINE_BYTES"');
|
|
expect(wrapper).toContain('-e OPENCLAW_INSTALL_E2E_SESSION_SCAN_DEPTH="$SESSION_SCAN_DEPTH"');
|
|
expect(wrapper).toContain('-e OPENCLAW_INSTALL_E2E_SESSION_SCAN_NODES="$SESSION_SCAN_NODES"');
|
|
expect(wrapper).not.toContain(
|
|
'OPENCLAW_INSTALL_E2E_OPENAI_PROVIDER_TIMEOUT_SECONDS="${OPENCLAW_INSTALL_E2E_OPENAI_PROVIDER_TIMEOUT_SECONDS:-}"',
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
["turn timeout", "OPENCLAW_INSTALL_E2E_AGENT_TURN_TIMEOUT_SECONDS", "300s"],
|
|
["provider timeout", "OPENCLAW_INSTALL_E2E_OPENAI_PROVIDER_TIMEOUT_SECONDS", "1e3"],
|
|
["parallel toggle", "OPENCLAW_INSTALL_E2E_AGENT_TURNS_PARALLEL", "2"],
|
|
["tool smoke toggle", "OPENCLAW_INSTALL_E2E_AGENT_TOOL_SMOKE", "false"],
|
|
["session scan bytes", "OPENCLAW_INSTALL_E2E_SESSION_SCAN_BYTES", "16mb"],
|
|
["session line bytes", "OPENCLAW_INSTALL_E2E_SESSION_LINE_BYTES", "1mb"],
|
|
["session scan depth", "OPENCLAW_INSTALL_E2E_SESSION_SCAN_DEPTH", "0"],
|
|
["session scan nodes", "OPENCLAW_INSTALL_E2E_SESSION_SCAN_NODES", "100k"],
|
|
])("rejects invalid install E2E Docker %s before image build", (_label, envName, value) => {
|
|
const result = spawnSync("bash", [INSTALL_E2E_DOCKER_PATH], {
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
[envName]: value,
|
|
},
|
|
});
|
|
|
|
expect(result.status).toBe(2);
|
|
expect(result.stderr).toContain(`invalid ${envName}: ${value}`);
|
|
expect(result.stdout).not.toContain("==> Build image:");
|
|
});
|
|
|
|
it("validates agent timing and toggle knobs before running provider setup", () => {
|
|
const script = readFileSync(INSTALL_E2E_RUNNER_PATH, "utf8");
|
|
|
|
expect(script).toContain(
|
|
'AGENT_TURN_TIMEOUT_SECONDS="$(read_positive_int_env OPENCLAW_INSTALL_E2E_AGENT_TURN_TIMEOUT_SECONDS 300)"',
|
|
);
|
|
expect(script).toContain(
|
|
'AGENT_TURNS_PARALLEL="$(read_boolean_env OPENCLAW_INSTALL_E2E_AGENT_TURNS_PARALLEL 1)"',
|
|
);
|
|
expect(script).toContain(
|
|
'AGENT_TOOL_SMOKE="$(read_boolean_env OPENCLAW_INSTALL_E2E_AGENT_TOOL_SMOKE 1)"',
|
|
);
|
|
expect(script).toContain(
|
|
'OPENAI_PROVIDER_TIMEOUT_SECONDS="$(read_positive_int_env OPENCLAW_INSTALL_E2E_OPENAI_PROVIDER_TIMEOUT_SECONDS "$AGENT_TURN_TIMEOUT_SECONDS")"',
|
|
);
|
|
expect(script).toContain('timeout --kill-after=15s "${AGENT_TURN_TIMEOUT_SECONDS}s"');
|
|
expect(script).toContain('\\"timeoutSeconds\\":${OPENAI_PROVIDER_TIMEOUT_SECONDS}');
|
|
expect(script).toContain('openclaw --profile "$profile" agent \\');
|
|
expect(script).not.toContain("\n --local \\\n");
|
|
});
|
|
|
|
it("normalizes agent JSON when structured lifecycle diagnostics follow the result", () => {
|
|
const payload = {
|
|
result: {
|
|
payloads: [{ text: "LEFT=RED RIGHT=GREEN" }],
|
|
},
|
|
replayInvalid: true,
|
|
};
|
|
const result = normalizeInstallE2eAgentOutput(
|
|
`${JSON.stringify(payload, null, 2)}\n[agent] ${JSON.stringify({ stopReason: "stop" })}\n`,
|
|
);
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stderr).toBe("");
|
|
expect(JSON.parse(result.output)).toEqual(payload);
|
|
});
|
|
|
|
it.each([
|
|
["turn timeout", "OPENCLAW_INSTALL_E2E_AGENT_TURN_TIMEOUT_SECONDS", "300s"],
|
|
["provider timeout", "OPENCLAW_INSTALL_E2E_OPENAI_PROVIDER_TIMEOUT_SECONDS", "1e3"],
|
|
["parallel toggle", "OPENCLAW_INSTALL_E2E_AGENT_TURNS_PARALLEL", "2"],
|
|
["tool smoke toggle", "OPENCLAW_INSTALL_E2E_AGENT_TOOL_SMOKE", "false"],
|
|
])("rejects invalid install E2E %s before credential preflight", (_label, envName, value) => {
|
|
const result = spawnSync("bash", [INSTALL_E2E_RUNNER_PATH], {
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
[envName]: value,
|
|
},
|
|
});
|
|
|
|
expect(result.status).toBe(2);
|
|
expect(result.stderr).toContain(`invalid ${envName}: ${value}`);
|
|
expect(result.stderr).not.toContain("OPENCLAW_E2E_MODELS=both requires");
|
|
});
|
|
});
|
|
|
|
describe("install-sh smoke runner", () => {
|
|
it.runIf(process.platform !== "win32").each([0, 23])(
|
|
"reaps the heartbeat timer and preserves command exit %i",
|
|
(exitCode) => {
|
|
const root = tempDirs.make("openclaw-smoke-heartbeat-");
|
|
const bin = join(root, "bin");
|
|
const pidFile = join(root, "timer.pid");
|
|
mkdirSync(bin);
|
|
writeFileSync(
|
|
join(bin, "sleep"),
|
|
'#!/bin/bash\nexec >/dev/null 2>&1\nprintf "%s" "$$" >"$SLEEP_PID_FILE"\nexec /bin/sleep "$@"\n',
|
|
{ mode: 0o755 },
|
|
);
|
|
const runner = readFileSync(SMOKE_RUNNER_PATH, "utf8");
|
|
const heartbeat = runner.slice(
|
|
runner.indexOf("run_with_heartbeat() {"),
|
|
runner.indexOf("\nis_self_swapped_package_process_exit()"),
|
|
);
|
|
const command = `
|
|
const fs = require("node:fs");
|
|
const timer = setInterval(() => {
|
|
if (fs.existsSync(process.env.SLEEP_PID_FILE) && /^\\d+$/.test(fs.readFileSync(process.env.SLEEP_PID_FILE, "utf8"))) {
|
|
clearInterval(timer);
|
|
process.exit(${exitCode});
|
|
}
|
|
}, 5);
|
|
setTimeout(() => process.exit(99), 2000).unref();
|
|
`;
|
|
let timerPid = 0;
|
|
try {
|
|
const result = spawnSync(
|
|
"bash",
|
|
[
|
|
"-c",
|
|
`set -euo pipefail
|
|
HEARTBEAT_INTERVAL=60
|
|
${heartbeat}
|
|
command_result=0
|
|
run_with_heartbeat fixture "$HOST_NODE" -e "$COMMAND_SOURCE" || command_result=$?
|
|
printf 'command-status=%s\\n' "$command_result"
|
|
`,
|
|
],
|
|
{
|
|
encoding: "utf8",
|
|
timeout: 5_000,
|
|
env: {
|
|
HOME: root,
|
|
PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}`,
|
|
SLEEP_PID_FILE: pidFile,
|
|
HOST_NODE: testNodeExecPath,
|
|
COMMAND_SOURCE: command,
|
|
},
|
|
},
|
|
);
|
|
timerPid = Number(readFileSync(pidFile, "utf8"));
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(result.stdout.trim()).toBe(`command-status=${exitCode}`);
|
|
expect(timerPid).toBeGreaterThan(0);
|
|
expect(isProcessAlive(timerPid)).toBe(false);
|
|
} finally {
|
|
if (timerPid && isProcessAlive(timerPid)) {
|
|
process.kill(timerPid, "SIGKILL");
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform !== "win32").each([
|
|
{ scenario: "idle", exitCode: 0, updateCount: 2 },
|
|
{ scenario: "candidate refusal", exitCode: 21, updateCount: 2 },
|
|
{ scenario: "live process", exitCode: 1, updateCount: 0 },
|
|
{ scenario: "service definition", exitCode: 1, updateCount: 0 },
|
|
{ scenario: "service manager", exitCode: 1, updateCount: 0 },
|
|
{ scenario: "inspection failure", exitCode: 1, updateCount: 0 },
|
|
])(
|
|
"uses the baseline manual path only after offline proof and checks candidate defaults: $scenario",
|
|
({ scenario, exitCode, updateCount }) => {
|
|
const root = tempDirs.make("openclaw-update-smoke-");
|
|
const bin = join(root, "bin");
|
|
const globalRoot = join(root, "node_modules");
|
|
const versionFile = join(root, "version");
|
|
const callsFile = join(root, "updates.jsonl");
|
|
const preload = join(root, "native-inspection.cjs");
|
|
mkdirSync(bin);
|
|
mkdirSync(join(globalRoot, "openclaw"), { recursive: true });
|
|
writeFileSync(join(globalRoot, "openclaw", "package.json"), '{"version":"2026.8.2"}');
|
|
writeFileSync(versionFile, "2026.8.2");
|
|
writeFileSync(callsFile, "");
|
|
symlinkSync(testNodeExecPath, join(bin, "node"));
|
|
writeFileSync(
|
|
join(bin, "npm"),
|
|
'#!/bin/bash\nif [[ " $* " == *" root -g "* ]]; then printf "%s\\n" "$FAKE_GLOBAL_ROOT"; fi\n',
|
|
{ mode: 0o755 },
|
|
);
|
|
writeFileSync(join(bin, "timeout"), '#!/bin/bash\nshift 2\nexec "$@"\n', { mode: 0o755 });
|
|
writeFileSync(
|
|
join(bin, "openclaw"),
|
|
`#!${testNodeExecPath}
|
|
const fs = require("node:fs");
|
|
const args = process.argv.slice(2);
|
|
if (args[0] === "--version") {
|
|
console.log("OpenClaw " + fs.readFileSync(process.env.FAKE_VERSION_FILE, "utf8"));
|
|
} else if (args[0] === "update") {
|
|
const before = fs.readFileSync(process.env.FAKE_VERSION_FILE, "utf8");
|
|
fs.appendFileSync(process.env.FAKE_CALLS_FILE, JSON.stringify(args) + "\\n");
|
|
if (before === "2026.8.2" && !args.includes("--no-restart")) process.exit(20);
|
|
if (before === "2026.9.1" && (args.includes("--no-restart") || process.env.FAKE_SCENARIO === "candidate refusal")) process.exit(21);
|
|
fs.writeFileSync(process.env.FAKE_VERSION_FILE, "2026.9.1");
|
|
console.log(JSON.stringify({
|
|
status: before === "2026.9.1" ? "skipped" : "ok",
|
|
...(before === "2026.9.1" ? { reason: "already-current" } : {}),
|
|
before: { version: before, buildId: "candidate-build" }, after: { version: "2026.9.1", buildId: "candidate-build" },
|
|
steps: [
|
|
{ name: before === "2026.9.1" ? "package-install" : "global update", exitCode: 0, command: "npm install " + args[args.indexOf("--tag") + 1] },
|
|
...(before === "2026.9.1" ? [] : [{ name: "openclaw doctor", exitCode: 0 }]),
|
|
],
|
|
}));
|
|
}
|
|
`,
|
|
{ mode: 0o755 },
|
|
);
|
|
// Simulate native /proc and service files; execute the complete shell runner and CLI boundary.
|
|
writeFileSync(
|
|
preload,
|
|
`const fs = require("node:fs");
|
|
const realRead = fs.readFileSync;
|
|
const realList = fs.readdirSync;
|
|
const realStat = fs.lstatSync;
|
|
const absent = () => { throw Object.assign(new Error("absent"), { code: "ENOENT" }); };
|
|
Object.defineProperty(process, "platform", { value: "linux" });
|
|
Object.defineProperty(process, "ppid", { value: 1 });
|
|
fs.lstatSync = (file, ...args) => {
|
|
if (String(file).startsWith("/run/")) {
|
|
if (process.env.FAKE_SCENARIO === "service manager") return {};
|
|
return absent();
|
|
}
|
|
return realStat(file, ...args);
|
|
};
|
|
fs.readdirSync = (file, ...args) => {
|
|
if (file === "/proc") return ["1", String(process.pid), ...(process.env.FAKE_SCENARIO === "live process" ? ["42"] : [])];
|
|
if (String(file).endsWith("/systemd")) {
|
|
if (process.env.FAKE_SCENARIO === "inspection failure") throw Object.assign(new Error("inspection denied"), { code: "EACCES" });
|
|
return process.env.FAKE_SCENARIO === "service definition" ? [{ name: "openclaw-gateway.service", parentPath: file }] : [];
|
|
}
|
|
return realList(file, ...args);
|
|
};
|
|
fs.readFileSync = (file, ...args) => {
|
|
if (file === "/proc/1/cmdline") return "bash\\0/usr/local/bin/openclaw-install-smoke\\0";
|
|
if (file === "/proc/42/cmdline") return "openclaw-gateway\\0";
|
|
return realRead(file, ...args);
|
|
};
|
|
`,
|
|
);
|
|
const result = spawnSync("bash", [SMOKE_RUNNER_PATH], {
|
|
encoding: "utf8",
|
|
env: {
|
|
HOME: root,
|
|
PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}`,
|
|
NODE_OPTIONS: `--require=${preload}`,
|
|
FAKE_SCENARIO: scenario,
|
|
FAKE_GLOBAL_ROOT: globalRoot,
|
|
FAKE_VERSION_FILE: versionFile,
|
|
FAKE_CALLS_FILE: callsFile,
|
|
OPENCLAW_INSTALL_SMOKE_MODE: "update",
|
|
OPENCLAW_INSTALL_UPDATE_BASELINE: "2026.8.2",
|
|
OPENCLAW_INSTALL_UPDATE_BASELINE_TAG_URL: "http://baseline.invalid/openclaw.tgz",
|
|
OPENCLAW_INSTALL_UPDATE_EXPECT_VERSION: "2026.9.1",
|
|
OPENCLAW_INSTALL_UPDATE_TAG_URL: "http://candidate.invalid/openclaw.tgz",
|
|
OPENCLAW_INSTALL_SMOKE_HEARTBEAT_INTERVAL: "0",
|
|
},
|
|
});
|
|
const calls = readFileSync(callsFile, "utf8")
|
|
.trim()
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.map((line) => JSON.parse(line) as string[]);
|
|
expect(result.status, result.stderr).toBe(exitCode);
|
|
expect(calls).toHaveLength(updateCount);
|
|
if (updateCount > 0) {
|
|
expect(calls[0]).toContain("--no-restart");
|
|
expect(calls[1]).not.toContain("--no-restart");
|
|
}
|
|
if (exitCode === 0) {
|
|
expect(result.stdout).toContain("Verified idle container");
|
|
expect(result.stdout.trim().endsWith("OK")).toBe(true);
|
|
} else {
|
|
expect(result.stdout.trim().endsWith("OK")).toBe(false);
|
|
}
|
|
},
|
|
);
|
|
|
|
it("passes the URL and installer arguments through the timed pipeline unchanged", () => {
|
|
const installerArgs = [
|
|
"--install-method",
|
|
"npm",
|
|
"--version",
|
|
"https://packages.example.test/openclaw.tgz?x=1&y=2",
|
|
"--no-prompt",
|
|
];
|
|
const fixture = runInstallSmokeInstallerPipelineFixture({ installerArgs });
|
|
|
|
expect(fixture.result.status, fixture.result.stderr).toBe(0);
|
|
expect(readNulSeparatedArgs(fixture.timeoutArgsPath)).toEqual([
|
|
"--kill-after=30s",
|
|
"17s",
|
|
"bash",
|
|
"-o",
|
|
"pipefail",
|
|
"-c",
|
|
'curl -fsSL --connect-timeout 30 --max-time 300 -- "$1" | bash -s -- "${@:2}"',
|
|
"_",
|
|
fixture.installUrl,
|
|
...installerArgs,
|
|
]);
|
|
expect(readNulSeparatedArgs(fixture.curlArgsPath)).toEqual([
|
|
"-fsSL",
|
|
"--connect-timeout",
|
|
"30",
|
|
"--max-time",
|
|
"300",
|
|
"--",
|
|
fixture.installUrl,
|
|
]);
|
|
expect(readNulSeparatedArgs(fixture.installerArgsPath)).toEqual(installerArgs);
|
|
expect(existsSync(fixture.installerMarkerPath)).toBe(true);
|
|
});
|
|
|
|
it("propagates curl exit 28 even when the piped installer exits successfully", () => {
|
|
const fixture = runInstallSmokeInstallerPipelineFixture({
|
|
curlExitCode: 28,
|
|
installerArgs: ["--no-prompt"],
|
|
});
|
|
|
|
expect(fixture.result.status, fixture.result.stderr).toBe(28);
|
|
expect(existsSync(fixture.installerMarkerPath)).toBe(true);
|
|
expect(readNulSeparatedArgs(fixture.timeoutArgsPath).slice(0, 6)).toEqual([
|
|
"--kill-after=30s",
|
|
"17s",
|
|
"bash",
|
|
"-o",
|
|
"pipefail",
|
|
"-c",
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
["2026.9.2", "2026.9.3", 0],
|
|
["2026.9.2", "2026.9.3", 17],
|
|
])(
|
|
"routes installer transition %s → %s and preserves exit %s",
|
|
(baseline, candidate, failure) => {
|
|
const runner = readFileSync(SMOKE_RUNNER_PATH, "utf8");
|
|
const start = runner.indexOf("run_update_smoke() {");
|
|
const body = runner.slice(start, runner.indexOf("\nrun_update_candidate()", start));
|
|
const result = spawnSync(
|
|
"bash",
|
|
[
|
|
"-c",
|
|
`
|
|
set -eu
|
|
${body}
|
|
resolve_update_baseline_version() { :; }
|
|
npm_install_global() { :; }
|
|
print_install_audit() { :; }
|
|
verify_installed_cli() { :; }
|
|
assert_update_smoke_offline() { echo idle; }
|
|
run_historical_external_transition() { echo external; return 99; }
|
|
run_update_candidate() { echo "self-update:$1:$*"; if [[ "$1" == "$UPDATE_BASELINE_VERSION" ]]; then return "$FAILURE"; fi; }
|
|
verify_candidate_ai_runtime() { echo verified; }
|
|
run_update_smoke
|
|
`,
|
|
],
|
|
{
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
PACKAGE_NAME: "openclaw",
|
|
UPDATE_BASELINE_VERSION: baseline,
|
|
UPDATE_EXPECT_VERSION: candidate,
|
|
UPDATE_BASELINE_TAG_URL: "",
|
|
UPDATE_TAG_URL: "https://fixture.invalid/candidate.tgz",
|
|
FAILURE: String(failure),
|
|
},
|
|
},
|
|
);
|
|
expect(result.status, result.stderr).toBe(failure);
|
|
expect(result.stdout).toContain("idle\n");
|
|
expect(result.stdout).toContain(`self-update:${baseline}:${baseline} applied --no-restart\n`);
|
|
expect(result.stdout).not.toContain("external\n");
|
|
if (failure === 0) {
|
|
expect(result.stdout).toContain(`self-update:${candidate}:${candidate} already-current\n`);
|
|
expect(result.stdout).toContain("verified\n");
|
|
} else {
|
|
expect(result.stdout).not.toContain(`self-update:${candidate}:`);
|
|
expect(result.stdout).not.toContain("verified\n");
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
["verified same-build no-op", {}, "already-current", 0],
|
|
[
|
|
"released-driver same-build no-op",
|
|
{
|
|
steps: [
|
|
{
|
|
name: "global update",
|
|
exitCode: 0,
|
|
command: "npm install http://candidate.invalid/openclaw.tgz",
|
|
},
|
|
],
|
|
},
|
|
"already-current",
|
|
0,
|
|
],
|
|
[
|
|
"wrong staging step identity",
|
|
{
|
|
steps: [
|
|
{
|
|
name: "package-pack",
|
|
exitCode: 0,
|
|
command: "npm pack http://candidate.invalid/openclaw.tgz",
|
|
},
|
|
],
|
|
},
|
|
"already-current",
|
|
1,
|
|
],
|
|
[
|
|
"failed staging step",
|
|
{
|
|
steps: [
|
|
{
|
|
name: "package-install",
|
|
exitCode: 1,
|
|
command: "npm install http://candidate.invalid/openclaw.tgz",
|
|
},
|
|
],
|
|
},
|
|
"already-current",
|
|
1,
|
|
],
|
|
[
|
|
"wrong staging target",
|
|
{
|
|
steps: [
|
|
{
|
|
name: "package-install",
|
|
exitCode: 0,
|
|
command: "npm install http://wrong.invalid/openclaw.tgz",
|
|
},
|
|
],
|
|
},
|
|
"already-current",
|
|
1,
|
|
],
|
|
["unrelated skip", { reason: "dirty" }, "already-current", 1],
|
|
["changed build", { after: { version: "2026.9.3", buildId: "other" } }, "already-current", 1],
|
|
["missing build identity", { before: { version: "2026.9.3" } }, "already-current", 1],
|
|
[
|
|
"unexpected activation",
|
|
{
|
|
steps: [
|
|
{
|
|
name: "package-install",
|
|
exitCode: 0,
|
|
command: "npm install http://candidate.invalid/openclaw.tgz",
|
|
},
|
|
{ name: "global install swap", exitCode: 0 },
|
|
],
|
|
},
|
|
"already-current",
|
|
1,
|
|
],
|
|
["skipped first upgrade", {}, "applied", 1],
|
|
])("validates explicit installer outcome: %s", (_label, overrides, outcome, expectedExit) => {
|
|
const url = "http://candidate.invalid/openclaw.tgz";
|
|
const payload = {
|
|
status: "skipped",
|
|
reason: "already-current",
|
|
before: { version: "2026.9.3", buildId: "candidate-build" },
|
|
after: { version: "2026.9.3", buildId: "candidate-build" },
|
|
steps: [{ name: "package-install", exitCode: 0, command: `npm install ${url}` }],
|
|
...overrides,
|
|
};
|
|
const result = spawnSync(testNodeExecPath, ["-"], {
|
|
encoding: "utf8",
|
|
input: extractInstallSmokeUpdateJsonParser(),
|
|
env: {
|
|
...process.env,
|
|
UPDATE_JSON: JSON.stringify(payload),
|
|
UPDATE_EXPECT_VERSION: "2026.9.3",
|
|
UPDATE_BASELINE_VERSION: "2026.9.3",
|
|
UPDATE_TAG_URL: url,
|
|
UPDATE_EXPECT_OUTCOME: outcome,
|
|
},
|
|
});
|
|
expect(result.status, result.stderr).toBe(expectedExit);
|
|
});
|
|
|
|
it("accepts legacy same-version apply only with the frozen-target compatibility flag", () => {
|
|
const url = "http://candidate.invalid/openclaw.tgz";
|
|
const payload = {
|
|
status: "ok",
|
|
before: { version: "2026.7.33" },
|
|
after: { version: "2026.7.33" },
|
|
steps: [
|
|
{ name: "global update", exitCode: 0, command: `npm install ${url}` },
|
|
{ name: "global install swap", exitCode: 0 },
|
|
{ name: "openclaw doctor", exitCode: 0 },
|
|
],
|
|
};
|
|
const run = (allowLegacy: boolean) =>
|
|
spawnSync(testNodeExecPath, ["-"], {
|
|
encoding: "utf8",
|
|
input: extractInstallSmokeUpdateJsonParser(),
|
|
env: {
|
|
...process.env,
|
|
UPDATE_JSON: JSON.stringify(payload),
|
|
UPDATE_EXPECT_VERSION: "2026.7.33",
|
|
UPDATE_BASELINE_VERSION: "2026.7.33",
|
|
UPDATE_TAG_URL: url,
|
|
UPDATE_EXPECT_OUTCOME: "already-current",
|
|
...(allowLegacy ? { OPENCLAW_INSTALL_ALLOW_LEGACY_SAME_VERSION_APPLY: "1" } : {}),
|
|
},
|
|
});
|
|
|
|
expect(run(false).status).toBe(1);
|
|
expect(run(true).status).toBe(0);
|
|
});
|
|
|
|
it.each([
|
|
["successful", { name: "openclaw doctor", exitCode: 0 }],
|
|
[
|
|
"recoverable advisory",
|
|
{
|
|
name: "openclaw doctor",
|
|
exitCode: 86,
|
|
advisory: { kind: "package-post-install-doctor", message: "repair deferred" },
|
|
},
|
|
],
|
|
])("accepts a %s package post-install doctor result", (_label, doctorStep) => {
|
|
const result = validateInstallSmokeUpdateJson(doctorStep);
|
|
|
|
expect(result.status, result.stderr).toBe(0);
|
|
});
|
|
|
|
it.each([
|
|
["missing", undefined, "missing openclaw doctor step"],
|
|
["fatal", { name: "openclaw doctor", exitCode: 1 }, "openclaw doctor step failed"],
|
|
["untyped advisory", { name: "openclaw doctor", exitCode: 86 }, "openclaw doctor step failed"],
|
|
[
|
|
"wrong advisory kind",
|
|
{ name: "openclaw doctor", exitCode: 86, advisory: { kind: "other" } },
|
|
"openclaw doctor step failed",
|
|
],
|
|
[
|
|
"wrong advisory exit",
|
|
{
|
|
name: "openclaw doctor",
|
|
exitCode: 1,
|
|
advisory: { kind: "package-post-install-doctor" },
|
|
},
|
|
"openclaw doctor step failed",
|
|
],
|
|
])("rejects a %s package post-install doctor result", (_label, doctorStep, error) => {
|
|
const result = validateInstallSmokeUpdateJson(doctorStep);
|
|
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stderr).toContain(error);
|
|
});
|
|
|
|
it.each([
|
|
["command timeout", "OPENCLAW_INSTALL_SMOKE_COMMAND_TIMEOUT", "900s"],
|
|
["heartbeat interval", "OPENCLAW_INSTALL_SMOKE_HEARTBEAT_INTERVAL", "60s"],
|
|
])("rejects invalid install smoke %s before running npm", (_label, envName, value) => {
|
|
const result = spawnSync("bash", [SMOKE_RUNNER_PATH], {
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
[envName]: value,
|
|
},
|
|
});
|
|
|
|
expect(result.status).toBe(2);
|
|
expect(result.stderr).toContain(`invalid ${envName}: ${value}`);
|
|
expect(result.stderr).not.toContain("unsupported OPENCLAW_INSTALL_SMOKE_MODE");
|
|
});
|
|
|
|
it("covers plain npm global installs and npm-driven updates", () => {
|
|
const script = readFileSync(SCRIPT_PATH, "utf8");
|
|
const runner = readFileSync(SMOKE_RUNNER_PATH, "utf8");
|
|
|
|
expect(script).toContain('SKIP_NPM_GLOBAL="${OPENCLAW_INSTALL_SMOKE_SKIP_NPM_GLOBAL:-0}"');
|
|
expect(script).toContain('NPM_CACHE_DIR="${OPENCLAW_INSTALL_SMOKE_NPM_CACHE_DIR:-}"');
|
|
expect(script).toContain("-e npm_config_cache=/npm-cache");
|
|
expect(script).toContain('${NPM_CACHE_DOCKER_ARGS[@]+"${NPM_CACHE_DOCKER_ARGS[@]}"}');
|
|
expect(script).toContain("remove_owned_npm_cache");
|
|
expect(script).toContain('sudo -n rm -rf "$NPM_CACHE_DIR"');
|
|
expect(script).not.toMatch(
|
|
/Run installer non-root test:[\s\S]*"\$\{NPM_CACHE_DOCKER_ARGS\[@\]\}"/,
|
|
);
|
|
expect(script).not.toMatch(
|
|
/Run CLI installer non-root test[\s\S]*"\$\{NPM_CACHE_DOCKER_ARGS\[@\]\}"/,
|
|
);
|
|
expect(script).toContain("==> Run direct npm global smoke");
|
|
expect(script).toContain("OPENCLAW_INSTALL_SMOKE_MODE=npm-global");
|
|
expect(runner).toContain("run_npm_global_smoke");
|
|
expect(runner).toContain("==> Direct npm global install candidate");
|
|
expect(runner).toContain("==> Direct npm global update candidate");
|
|
});
|
|
|
|
it("forwards smoke-runner control knobs into Docker containers", () => {
|
|
const script = readFileSync(SCRIPT_PATH, "utf8");
|
|
|
|
expect(script).toContain("SMOKE_RUNNER_ENV_ARGS=()");
|
|
for (const envName of [
|
|
"OPENCLAW_INSTALL_ALLOW_LEGACY_SAME_VERSION_APPLY",
|
|
"OPENCLAW_INSTALL_SMOKE_COMMAND_TIMEOUT",
|
|
"OPENCLAW_INSTALL_SMOKE_HEARTBEAT_INTERVAL",
|
|
"OPENCLAW_INSTALL_SMOKE_PREVIOUS",
|
|
"OPENCLAW_INSTALL_SMOKE_SKIP_PREVIOUS",
|
|
]) {
|
|
expect(script).toContain(envName);
|
|
}
|
|
expect(script).toMatch(
|
|
/Run installer smoke test[\s\S]*\$\{SMOKE_RUNNER_ENV_ARGS\[@\]\+"\$\{SMOKE_RUNNER_ENV_ARGS\[@\]\}"\}/u,
|
|
);
|
|
expect(script).toMatch(
|
|
/Run update smoke[\s\S]*\$\{SMOKE_RUNNER_ENV_ARGS\[@\]\+"\$\{SMOKE_RUNNER_ENV_ARGS\[@\]\}"\}/u,
|
|
);
|
|
expect(script).toMatch(
|
|
/Run direct npm global smoke[\s\S]*\$\{SMOKE_RUNNER_ENV_ARGS\[@\]\+"\$\{SMOKE_RUNNER_ENV_ARGS\[@\]\}"\}/u,
|
|
);
|
|
expect(script).toMatch(
|
|
/Run installer npm freshness smoke[\s\S]*\$\{SMOKE_RUNNER_ENV_ARGS\[@\]\+"\$\{SMOKE_RUNNER_ENV_ARGS\[@\]\}"\}/u,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("bun global install smoke", () => {
|
|
const runForceKillOrderingFixture = (
|
|
first: "timer" | "drain",
|
|
failure?: "permission" | "uncleared",
|
|
) => {
|
|
const tempDir = tempDirs.make("openclaw-bun-global-force-kill-");
|
|
const preloadPath = path.join(tempDir, "lifecycle.mjs");
|
|
// Drive both native-observed callback orders at the real CLI boundary.
|
|
// Only the child and clock are simulated; the helper owns all cleanup logic.
|
|
writeFileSync(
|
|
preloadPath,
|
|
`import childProcess from "node:child_process";
|
|
import { EventEmitter } from "node:events";
|
|
import { syncBuiltinESMExports } from "node:module";
|
|
let now = 0;
|
|
let alive = true;
|
|
let forceKills = 0;
|
|
const timers = [];
|
|
process.on("exit", () => console.log("force-kill-attempts=" + forceKills));
|
|
process.kill = (pid, signal) => {
|
|
if (pid !== -1234) throw new Error("unexpected fixture signal target");
|
|
if (signal === "SIGKILL") {
|
|
forceKills++;
|
|
if (${JSON.stringify(failure)} === "permission" ||
|
|
(forceKills > 1 && ${JSON.stringify(failure)} !== "uncleared")) {
|
|
throw Object.assign(new Error("kill EPERM"), { code: "EPERM" });
|
|
}
|
|
}
|
|
if (signal === 0 && !alive) {
|
|
throw Object.assign(new Error("kill ESRCH"), { code: "ESRCH" });
|
|
}
|
|
return true;
|
|
};
|
|
childProcess.spawn = () => {
|
|
const stream = () => Object.assign(new EventEmitter(), { setEncoding() {} });
|
|
const child = Object.assign(new EventEmitter(), {
|
|
pid: 1234, stdout: stream(), stderr: stream(),
|
|
});
|
|
Date.now = () => now;
|
|
globalThis.setTimeout = (callback, delay) => {
|
|
const timer = { callback, delay, cleared: false, unref() {} };
|
|
timers.push(timer);
|
|
return timer;
|
|
};
|
|
globalThis.clearTimeout = (timer) => { if (timer) timer.cleared = true; };
|
|
const fire = async (delay, at) => {
|
|
now = at;
|
|
const timer = timers.find((entry) => !entry.cleared && entry.delay === delay);
|
|
if (!timer) throw new Error("missing fixture timer: " + delay);
|
|
timer.cleared = true;
|
|
timer.callback();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
};
|
|
process.nextTick(async () => {
|
|
process.emit("SIGTERM");
|
|
child.emit("close", 0, null);
|
|
await Promise.resolve();
|
|
if (${JSON.stringify(first)} === "timer") {
|
|
await fire(100, 100);
|
|
await fire(25, 100);
|
|
} else {
|
|
await fire(25, 100);
|
|
if (${JSON.stringify(failure)} === "permission") return;
|
|
await fire(100, 100);
|
|
}
|
|
alive = ${JSON.stringify(failure)} === "uncleared";
|
|
await fire(25, 200);
|
|
});
|
|
return child;
|
|
};
|
|
syncBuiltinESMExports();
|
|
`,
|
|
);
|
|
return spawnSync(
|
|
testNodeExecPath,
|
|
["--import", preloadPath, BUN_GLOBAL_ASSERTIONS_PATH, "run-with-timeout", "60000", "fixture"],
|
|
{
|
|
encoding: "utf8",
|
|
env: { ...process.env, OPENCLAW_BUN_GLOBAL_SMOKE_TIMEOUT_KILL_GRACE_MS: "100" },
|
|
},
|
|
);
|
|
};
|
|
|
|
it.runIf(process.platform !== "win32").each(["timer", "drain"] as const)(
|
|
"force-kills Bun descendants once when the %s callback runs first",
|
|
(first) => {
|
|
const result = runForceKillOrderingFixture(first);
|
|
expect(result.status, result.stderr).toBe(143);
|
|
expect(result.stdout).toContain("force-kill-attempts=1");
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform !== "win32")("propagates Bun force-kill permission failures", () => {
|
|
const result = runForceKillOrderingFixture("drain", "permission");
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain("kill EPERM");
|
|
expect(result.stdout).toContain("force-kill-attempts=1");
|
|
});
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"fails when a Bun process group remains after force-kill cleanup",
|
|
() => {
|
|
const result = runForceKillOrderingFixture("timer", "uncleared");
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain("command process group remained active after SIGKILL");
|
|
expect(result.stdout).toContain("force-kill-attempts=1");
|
|
},
|
|
);
|
|
|
|
it("packs the current tree and capability-binds the installed package runtime", () => {
|
|
const script = readFileSync(BUN_GLOBAL_SMOKE_PATH, "utf8");
|
|
const assertions = readFileSync(BUN_GLOBAL_ASSERTIONS_PATH, "utf8");
|
|
|
|
expect(script).toContain("node scripts/package-openclaw-for-docker.mjs");
|
|
expect(script).toContain("--allow-unreleased-changelog");
|
|
expect(script).toContain("OPENCLAW_BUN_GLOBAL_SMOKE_ALLOW_UNRELEASED_CHANGELOG");
|
|
expect(script).toContain(
|
|
'if [[ "${OPENCLAW_BUN_GLOBAL_SMOKE_ALLOW_UNRELEASED_CHANGELOG:-true}" == "true" ]]',
|
|
);
|
|
expect(script).toContain("package_args+=(--allow-unreleased-changelog)");
|
|
expect(script).toContain("--skip-build");
|
|
expect(script).toContain("--output-name openclaw-current.tgz");
|
|
expect(script).not.toContain("npm pack --ignore-scripts --json --pack-destination");
|
|
expect(script).toContain('"$bun_path" install -g --trust "$PACKAGE_TGZ" --no-progress');
|
|
expect(script).toContain('"$openclaw_bin" --help');
|
|
expect(script).toContain('grep -Fq "Bun runtime is unsupported" "$DIRECT_BUN_LOG"');
|
|
expect(script).toContain('grep -Fq "node:sqlite" "$DIRECT_BUN_LOG"');
|
|
expect(script).toContain('runtime_command=("$openclaw_bin")');
|
|
expect(script).toContain('return "$direct_bun_status"');
|
|
expect(script).toContain("OPENCLAW_BUN_GLOBAL_SMOKE_PROOF_PATH");
|
|
expect(script).toContain("infer image providers --json");
|
|
expect(script).toContain("assert-image-providers");
|
|
expect(script).toContain("assert-openclaw-trusted");
|
|
expect(script).toContain("agent --local");
|
|
expect(script).toContain("gateway health");
|
|
expect(script).toContain("openclaw_e2e_wait_gateway_ready");
|
|
expect(assertions).toContain("image providers output is missing bundled provider");
|
|
expect(script).toContain("OPENCLAW_BUN_GLOBAL_SMOKE_DIST_IMAGE");
|
|
expect(script).toContain('source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh"');
|
|
expect(script).toContain("docker_e2e_restore_package_dist_from_image");
|
|
expect(script).toContain(
|
|
'COMMAND_TIMEOUT_MS="$(docker_e2e_read_positive_int_env OPENCLAW_BUN_GLOBAL_SMOKE_TIMEOUT_MS 180000)"',
|
|
);
|
|
expect(script).toContain(
|
|
'DOCKER_COMMAND_TIMEOUT="${DOCKER_COMMAND_TIMEOUT:-${OPENCLAW_BUN_GLOBAL_SMOKE_DOCKER_COMMAND_TIMEOUT:-600s}}"',
|
|
);
|
|
});
|
|
|
|
it("rejects invalid Bun global install command timeouts before Bun setup", () => {
|
|
const result = spawnSync("bash", [BUN_GLOBAL_SMOKE_PATH], {
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
OPENCLAW_BUN_GLOBAL_SMOKE_TIMEOUT_MS: "180000ms",
|
|
},
|
|
});
|
|
|
|
expect(result.status).toBe(2);
|
|
expect(result.stderr).toContain("invalid OPENCLAW_BUN_GLOBAL_SMOKE_TIMEOUT_MS: 180000ms");
|
|
expect(result.stderr).not.toContain("Bun is required");
|
|
});
|
|
|
|
it("requires root and AI candidate versions to match", () => {
|
|
const tempDir = tempDirs.make("openclaw-bun-candidate-versions-");
|
|
const rootManifestPath = join(tempDir, "openclaw.json");
|
|
const aiManifestPath = join(tempDir, "ai.json");
|
|
writeFileSync(
|
|
rootManifestPath,
|
|
JSON.stringify({
|
|
name: "openclaw",
|
|
version: "2026.6.17",
|
|
dependencies: { "@openclaw/ai": "2026.6.17" },
|
|
}),
|
|
);
|
|
writeFileSync(aiManifestPath, JSON.stringify({ name: "@openclaw/ai", version: "2026.6.17" }));
|
|
|
|
const matching = spawnSync(
|
|
testNodeExecPath,
|
|
[BUN_GLOBAL_ASSERTIONS_PATH, "assert-release-versions", rootManifestPath, aiManifestPath],
|
|
{ encoding: "utf8" },
|
|
);
|
|
expect(matching).toMatchObject({ status: 0, stdout: "2026.6.17" });
|
|
|
|
writeFileSync(aiManifestPath, JSON.stringify({ name: "@openclaw/ai", version: "2026.6.18" }));
|
|
const mismatched = spawnSync(
|
|
testNodeExecPath,
|
|
[BUN_GLOBAL_ASSERTIONS_PATH, "assert-release-versions", rootManifestPath, aiManifestPath],
|
|
{ encoding: "utf8" },
|
|
);
|
|
expect(mismatched.status).not.toBe(0);
|
|
expect(mismatched.stderr).toContain(
|
|
"candidate version mismatch: openclaw=2026.6.17, dependency=2026.6.17, @openclaw/ai=2026.6.18",
|
|
);
|
|
});
|
|
|
|
it("requires Bun 1.4 or newer", () => {
|
|
const supported = spawnSync(
|
|
testNodeExecPath,
|
|
[BUN_GLOBAL_ASSERTIONS_PATH, "assert-bun-version", "1.4.0"],
|
|
{ encoding: "utf8" },
|
|
);
|
|
expect(supported.status, supported.stderr).toBe(0);
|
|
|
|
const unsupported = spawnSync(
|
|
testNodeExecPath,
|
|
[BUN_GLOBAL_ASSERTIONS_PATH, "assert-bun-version", "1.3.14"],
|
|
{ encoding: "utf8" },
|
|
);
|
|
expect(unsupported.status).not.toBe(0);
|
|
expect(unsupported.stderr).toContain("Bun 1.4 or newer is required; found 1.3.14");
|
|
});
|
|
|
|
it("requires Bun to trust and execute OpenClaw lifecycle scripts", () => {
|
|
const tempDir = tempDirs.make("openclaw-bun-trusted-lifecycle-");
|
|
const packageRoot = join(tempDir, "node_modules", "openclaw");
|
|
const globalManifestPath = join(tempDir, "package.json");
|
|
const untrustedOutputPath = join(tempDir, "untrusted.txt");
|
|
mkdirSync(join(packageRoot, "dist"), { recursive: true });
|
|
writeFileSync(globalManifestPath, JSON.stringify({ trustedDependencies: ["openclaw"] }));
|
|
writeFileSync(untrustedOutputPath, "./node_modules/koffi [install]\n");
|
|
|
|
const trusted = spawnSync(
|
|
testNodeExecPath,
|
|
[
|
|
BUN_GLOBAL_ASSERTIONS_PATH,
|
|
"assert-openclaw-trusted",
|
|
packageRoot,
|
|
globalManifestPath,
|
|
untrustedOutputPath,
|
|
],
|
|
{ encoding: "utf8" },
|
|
);
|
|
expect(trusted.status, trusted.stderr).toBe(0);
|
|
|
|
writeFileSync(untrustedOutputPath, "./node_modules/openclaw [preinstall, postinstall]\n");
|
|
const blocked = spawnSync(
|
|
testNodeExecPath,
|
|
[
|
|
BUN_GLOBAL_ASSERTIONS_PATH,
|
|
"assert-openclaw-trusted",
|
|
packageRoot,
|
|
globalManifestPath,
|
|
untrustedOutputPath,
|
|
],
|
|
{ encoding: "utf8" },
|
|
);
|
|
expect(blocked.status).not.toBe(0);
|
|
expect(blocked.stderr).toContain("OpenClaw lifecycle scripts remain blocked by Bun");
|
|
|
|
writeFileSync(untrustedOutputPath, "");
|
|
writeFileSync(join(packageRoot, ".openclaw-lifecycle-pending"), "pending\n");
|
|
const skipped = spawnSync(
|
|
testNodeExecPath,
|
|
[
|
|
BUN_GLOBAL_ASSERTIONS_PATH,
|
|
"assert-openclaw-trusted",
|
|
packageRoot,
|
|
globalManifestPath,
|
|
untrustedOutputPath,
|
|
],
|
|
{ encoding: "utf8" },
|
|
);
|
|
expect(skipped.status).not.toBe(0);
|
|
expect(skipped.stderr).toContain("OpenClaw package lifecycle did not complete");
|
|
});
|
|
|
|
it.runIf(process.platform !== "win32").each([
|
|
{
|
|
name: "uses bundled AI bytes when a prebuilt tarball is provided",
|
|
bundledAi: true,
|
|
bunRuntime: "supported",
|
|
statusExit: 0,
|
|
},
|
|
{
|
|
name: "rejects a mismatched bundled AI candidate before installation",
|
|
bundledAi: true,
|
|
bunRuntime: "supported",
|
|
statusExit: 0,
|
|
aiVersion: "2026.6.18",
|
|
},
|
|
{
|
|
name: "installs an older tarball with no bundled AI dependency unchanged",
|
|
bundledAi: false,
|
|
bunRuntime: "supported",
|
|
statusExit: 0,
|
|
},
|
|
{
|
|
name: "preserves redirected Bun command diagnostics and exit status",
|
|
bundledAi: true,
|
|
bunRuntime: "supported",
|
|
statusExit: 23,
|
|
},
|
|
{
|
|
name: "uses Node for a Bun-installed package that explicitly rejects the Bun runtime",
|
|
bundledAi: true,
|
|
bunRuntime: "unsupported",
|
|
statusExit: 0,
|
|
},
|
|
{
|
|
name: "does not hide an unexpected direct Bun runtime failure",
|
|
bundledAi: true,
|
|
bunRuntime: "unexpected-failure",
|
|
statusExit: 0,
|
|
},
|
|
])("$name", ({ bundledAi, bunRuntime, statusExit, aiVersion = "2026.6.17" }) => {
|
|
const tempDir = tempDirs.make("openclaw-bun-prebuilt-");
|
|
const packageDir = join(tempDir, "fixture", "package");
|
|
const aiDir = join(packageDir, "node_modules", "@openclaw", "ai");
|
|
const packageTgz = join(tempDir, "openclaw-prebuilt.tgz");
|
|
const bunPath = join(tempDir, "bun");
|
|
const statePath = join(tempDir, "state-path");
|
|
const aiTarballPath = join(tempDir, "ai-tarball-path");
|
|
mkdirSync(packageDir, { recursive: true });
|
|
writeFileSync(
|
|
join(packageDir, "package.json"),
|
|
JSON.stringify({
|
|
name: "openclaw",
|
|
version: "2026.6.17",
|
|
...(bundledAi
|
|
? {
|
|
dependencies: { "@openclaw/ai": "2026.6.17" },
|
|
bundleDependencies: ["@openclaw/ai"],
|
|
}
|
|
: {}),
|
|
}),
|
|
);
|
|
if (bundledAi) {
|
|
mkdirSync(aiDir, { recursive: true });
|
|
writeFileSync(
|
|
join(aiDir, "package.json"),
|
|
JSON.stringify({ name: "@openclaw/ai", version: aiVersion }),
|
|
);
|
|
}
|
|
const packed = spawnSync(
|
|
"tar",
|
|
["-czf", packageTgz, "-C", join(tempDir, "fixture"), "package"],
|
|
{
|
|
encoding: "utf8",
|
|
},
|
|
);
|
|
expect(packed.status, packed.stderr).toBe(0);
|
|
writeFileSync(
|
|
bunPath,
|
|
`#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
if [ "\${1:-}" = "--version" ]; then
|
|
echo "1.4.0"
|
|
exit 0
|
|
fi
|
|
if [ "\${1:-}" = "pm" ] && [ "\${2:-}" = "-g" ] && [ "\${3:-}" = "untrusted" ]; then
|
|
echo './node_modules/koffi [install]'
|
|
exit 0
|
|
fi
|
|
if [ "\${1:-}" = "run" ] && [ "\${2:-}" = "--bun" ]; then
|
|
echo "OpenClaw 2026.6.17"
|
|
exit 0
|
|
fi
|
|
if [[ "\${1:-}" == */verify-fs-safe-native.mjs ]]; then
|
|
test "\${2:-}" = "--package-root"
|
|
test -d "\${3:-}"
|
|
test "\${4:-}" = "--mode"
|
|
test "\${5:-}" = "require"
|
|
exit 0
|
|
fi
|
|
if [[ "\${1:-}" == */openclaw.mjs ]]; then
|
|
if [ "$FAKE_BUN_RUNTIME" = "unsupported" ]; then
|
|
echo 'openclaw: the Bun runtime is unsupported because OpenClaw requires node:sqlite.' >&2
|
|
exit 1
|
|
fi
|
|
if [ "$FAKE_BUN_RUNTIME" = "unexpected-failure" ]; then
|
|
echo 'synthetic direct Bun runtime failure' >&2
|
|
exit 42
|
|
fi
|
|
shift
|
|
exec node "$BUN_INSTALL/install/global/node_modules/openclaw/openclaw.mjs" "$@"
|
|
fi
|
|
test "\${1:-}" = "install"
|
|
case " $* " in
|
|
*' --trust '*) ;;
|
|
*) echo 'missing --trust' >&2; exit 1 ;;
|
|
esac
|
|
mkdir -p "$BUN_INSTALL/install/global"
|
|
if [ ! -f "$BUN_INSTALL/install/global/package.json" ]; then
|
|
echo '{}' >"$BUN_INSTALL/install/global/package.json"
|
|
fi
|
|
if [ "$EXPECT_AI_OVERRIDE" = "1" ]; then
|
|
override="$(node -e 'const p=require(process.argv[1]);process.stdout.write(p.overrides["@openclaw/ai"])' "$BUN_INSTALL/install/global/package.json")"
|
|
case "\${override#file:}" in
|
|
*.tgz) ;;
|
|
*) exit 1 ;;
|
|
esac
|
|
test -f "\${override#file:}"
|
|
fi
|
|
package_root="$BUN_INSTALL/install/global/node_modules/openclaw"
|
|
mkdir -p "$BUN_INSTALL/bin" "$package_root/dist/plugin-sdk"
|
|
printf '%s\\n' "$OPENCLAW_STATE_DIR" >"$FAKE_STATE_PATH"
|
|
if [ "$EXPECT_AI_OVERRIDE" = "1" ]; then
|
|
printf '%s\\n' "\${override#file:}" >"$FAKE_AI_TARBALL_PATH"
|
|
else
|
|
node -e 'const p=require(process.argv[1]);process.exit(p.overrides ? 1 : 0)' "$BUN_INSTALL/install/global/package.json"
|
|
fi
|
|
# Synthetic package redactor isolates stderr routing; canonical redaction has separate proof.
|
|
cat >"$package_root/dist/plugin-sdk/logging-core.js" <<'REDACTOR'
|
|
exports.redactSensitiveText = (text) => text;
|
|
REDACTOR
|
|
cat >"$package_root/openclaw.mjs" <<'OPENCLAW'
|
|
#!/usr/bin/env node
|
|
import fs from "node:fs";
|
|
import http from "node:http";
|
|
|
|
const args = process.argv.slice(2);
|
|
if (args[0] === "--version") {
|
|
console.log("OpenClaw 2026.6.17");
|
|
} else if (args[0] === "--help") {
|
|
console.log("Usage: openclaw");
|
|
} else if (args[0] === "infer") {
|
|
console.log(JSON.stringify([{ id: "google" }, { id: "openai" }, { id: "xai" }]));
|
|
} else if (args[0] === "status" && process.env.FAKE_STATUS_EXIT !== "0") {
|
|
console.error("synthetic Bun status failure");
|
|
process.exit(Number(process.env.FAKE_STATUS_EXIT));
|
|
} else if (args[0] === "status" || (args[0] === "plugins" && args[1] === "list")) {
|
|
console.log("{}");
|
|
} else if (args[0] === "agent") {
|
|
fs.appendFileSync(process.env.MOCK_REQUEST_LOG, '{"path":"/v1/responses"}\\n');
|
|
console.log(JSON.stringify({ payloads: [{ text: process.env.SUCCESS_MARKER }] }));
|
|
} else if (args[0] === "gateway" && args[1] === "health") {
|
|
console.log('{"ok":true}');
|
|
} else if (args[0] === "gateway" && args[1] === "call" && args[2] === "status") {
|
|
console.log(JSON.stringify({ eventLoop: {
|
|
cpuCoreRatio: 0.1, utilization: 0.2, delayP99Ms: 20, delayMaxMs: 21, intervalMs: 1000,
|
|
} }));
|
|
} else if (args[0] === "gateway") {
|
|
const port = Number(args[args.indexOf("--port") + 1]);
|
|
http.createServer((_req, res) => {
|
|
res.writeHead(200, { "content-type": "text/plain" });
|
|
res.end("ok");
|
|
})
|
|
.listen(port, "127.0.0.1", () => console.log("[gateway] ready at http://127.0.0.1:" + port));
|
|
} else {
|
|
process.exit(1);
|
|
}
|
|
OPENCLAW
|
|
chmod +x "$package_root/openclaw.mjs"
|
|
ln -s "$package_root/openclaw.mjs" "$BUN_INSTALL/bin/openclaw"
|
|
node -e 'const fs=require("node:fs");const p=process.argv[1];const value=JSON.parse(fs.readFileSync(p,"utf8"));value.trustedDependencies=["openclaw"];fs.writeFileSync(p,JSON.stringify(value))' "$BUN_INSTALL/install/global/package.json"
|
|
`,
|
|
);
|
|
chmodSync(bunPath, 0o755);
|
|
|
|
const result = spawnSync("bash", [BUN_GLOBAL_SMOKE_PATH], {
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
BUN_BIN: bunPath,
|
|
EXPECT_AI_OVERRIDE: bundledAi ? "1" : "0",
|
|
FAKE_BUN_RUNTIME: bunRuntime,
|
|
FAKE_STATUS_EXIT: String(statusExit),
|
|
FAKE_STATE_PATH: statePath,
|
|
FAKE_AI_TARBALL_PATH: aiTarballPath,
|
|
OPENCLAW_BUN_GLOBAL_SMOKE_HOST_BUILD: "0",
|
|
OPENCLAW_BUN_GLOBAL_SMOKE_PACKAGE_TGZ: packageTgz,
|
|
OPENCLAW_BUN_GLOBAL_SMOKE_TIMEOUT_MS: "10000",
|
|
},
|
|
});
|
|
|
|
if (aiVersion !== "2026.6.17") {
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain(
|
|
"candidate version mismatch: openclaw=2026.6.17, dependency=2026.6.17, @openclaw/ai=2026.6.18",
|
|
);
|
|
expect(existsSync(statePath)).toBe(false);
|
|
return;
|
|
}
|
|
|
|
const expectedExit = bunRuntime === "unexpected-failure" ? 42 : statusExit;
|
|
expect(result.status, result.stderr).toBe(expectedExit);
|
|
expect(existsSync(path.dirname(readFileSync(statePath, "utf8").trim()))).toBe(false);
|
|
if (bundledAi) {
|
|
expect(existsSync(path.dirname(readFileSync(aiTarballPath, "utf8").trim()))).toBe(false);
|
|
}
|
|
if (bunRuntime !== "unexpected-failure") {
|
|
expect(result.stdout).toContain("bun-global-install-smoke: image providers OK (3 providers)");
|
|
}
|
|
if (bunRuntime === "unexpected-failure") {
|
|
expect(result.stderr).toContain("synthetic direct Bun runtime failure");
|
|
expect(result.stdout).not.toContain("Gateway runtime OK");
|
|
} else if (statusExit === 0) {
|
|
expect(result.stdout).toContain(
|
|
`bun-global-install-smoke: Bun 1.4.0 install with ${bunRuntime === "supported" ? "Bun" : "Node"} CLI, local agent, and Gateway runtime OK`,
|
|
);
|
|
} else {
|
|
expect(result.stderr).toContain("bun global install smoke failed with exit code 23");
|
|
expect(result.stderr).toContain("synthetic Bun status failure");
|
|
expect(result.stderr).not.toContain("failure log omitted");
|
|
expect(result.stdout).not.toContain("Gateway runtime OK");
|
|
}
|
|
});
|
|
|
|
it.runIf(process.platform !== "win32" && existsSync("/usr/bin/time"))(
|
|
"preserves Bun global timeout kill grace after the leader exits",
|
|
() => {
|
|
const tempDir = tempDirs.make("openclaw-bun-global-timeout-grace-");
|
|
const readyPath = path.join(tempDir, "ready");
|
|
const drainedPath = path.join(tempDir, "drained");
|
|
const childScript = [
|
|
"const fs = require('node:fs');",
|
|
"process.on('SIGTERM', () => {",
|
|
" setTimeout(() => {",
|
|
" fs.writeFileSync(process.argv[2], 'drained');",
|
|
" process.exit(0);",
|
|
" }, 50);",
|
|
"});",
|
|
"fs.writeFileSync(process.argv[1], 'ready');",
|
|
"setInterval(() => {}, 1000);",
|
|
].join("\n");
|
|
|
|
const result = spawnSync(
|
|
testNodeExecPath,
|
|
[
|
|
BUN_GLOBAL_ASSERTIONS_PATH,
|
|
"run-with-timeout",
|
|
"500",
|
|
"/usr/bin/time",
|
|
testNodeExecPath,
|
|
"-e",
|
|
childScript,
|
|
readyPath,
|
|
drainedPath,
|
|
],
|
|
{
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
OPENCLAW_BUN_GLOBAL_SMOKE_TIMEOUT_KILL_GRACE_MS: "1000",
|
|
},
|
|
timeout: 5_000,
|
|
},
|
|
);
|
|
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain("command timed out after 500ms: /usr/bin/time");
|
|
expect(readFileSync(readyPath, "utf8")).toBe("ready");
|
|
expect(readFileSync(drainedPath, "utf8")).toBe("drained");
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"cleans Bun global smoke descendants on parent signal",
|
|
async () => {
|
|
const tempDir = tempDirs.make("openclaw-bun-global-parent-signal-");
|
|
const readyPath = path.join(tempDir, "ready");
|
|
const descendantPidPath = path.join(tempDir, "descendant.pid");
|
|
let descendantPid = 0;
|
|
const descendantScript = [
|
|
"const fs = require('node:fs');",
|
|
`fs.writeFileSync(${JSON.stringify(descendantPidPath)}, String(process.pid));`,
|
|
"process.on('SIGTERM', () => {});",
|
|
"setInterval(() => {}, 1000);",
|
|
].join("\n");
|
|
const parentScript = [
|
|
"const childProcess = require('node:child_process');",
|
|
"const fs = require('node:fs');",
|
|
`childProcess.spawn(process.execPath, ["-e", ${JSON.stringify(descendantScript)}], { stdio: "ignore" });`,
|
|
`fs.writeFileSync(${JSON.stringify(readyPath)}, "ready");`,
|
|
"process.on('SIGTERM', () => process.exit(0));",
|
|
"setInterval(() => {}, 1000);",
|
|
].join("\n");
|
|
const runner = spawn(
|
|
testNodeExecPath,
|
|
[
|
|
BUN_GLOBAL_ASSERTIONS_PATH,
|
|
"run-with-timeout",
|
|
"60000",
|
|
testNodeExecPath,
|
|
"-e",
|
|
parentScript,
|
|
],
|
|
{
|
|
env: {
|
|
...process.env,
|
|
OPENCLAW_BUN_GLOBAL_SMOKE_TIMEOUT_KILL_GRACE_MS: "100",
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
},
|
|
);
|
|
let runnerStderr = "";
|
|
runner.stderr.setEncoding("utf8").on("data", (chunk: string) => {
|
|
runnerStderr = `${runnerStderr}${chunk}`.slice(-16_384);
|
|
});
|
|
const runnerExit = new Promise<{ status: number | null; signal: NodeJS.Signals | null }>(
|
|
(resolve) => {
|
|
runner.once("close", (status, signal) => resolve({ status, signal }));
|
|
},
|
|
);
|
|
|
|
// The descendant's pid file can be observed created-but-empty between its
|
|
// open() and write(); readiness must require parseable content, not
|
|
// existence, or the integer assertion below flakes on loaded runners.
|
|
const readDescendantPid = () => {
|
|
if (!existsSync(descendantPidPath)) {
|
|
return null;
|
|
}
|
|
const pid = Number.parseInt(readFileSync(descendantPidPath, "utf8"), 10);
|
|
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
};
|
|
try {
|
|
await waitForCondition(
|
|
() => existsSync(readyPath) && readDescendantPid() !== null,
|
|
"Bun global smoke descendant readiness",
|
|
);
|
|
descendantPid = readDescendantPid() ?? 0;
|
|
expect(descendantPid).toBeGreaterThan(0);
|
|
expect(isProcessAlive(descendantPid)).toBe(true);
|
|
|
|
const signalSent = runner.kill("SIGTERM");
|
|
const result = await runnerExit;
|
|
|
|
expect(signalSent, runnerStderr).toBe(true);
|
|
expect(result, runnerStderr).toEqual({ status: 143, signal: null });
|
|
await waitForCondition(
|
|
() => !isProcessAlive(descendantPid),
|
|
"Bun global smoke descendant cleanup",
|
|
);
|
|
} finally {
|
|
if (runner.pid && isProcessAlive(runner.pid)) {
|
|
process.kill(runner.pid, "SIGKILL");
|
|
}
|
|
if (descendantPid && isProcessAlive(descendantPid)) {
|
|
process.kill(descendantPid, "SIGKILL");
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
it("gates workflow Bun install smoke to scheduled and release-check runs", () => {
|
|
const workflow = readFileSync(INSTALL_SMOKE_WORKFLOW_PATH, "utf8");
|
|
const wrapper = readFileSync(INSTALL_SMOKE_WRAPPER_PATH, "utf8");
|
|
const releaseChecks = readFileSync(RELEASE_CHECKS_WORKFLOW_PATH, "utf8");
|
|
|
|
expect(workflow).not.toContain("pull_request:");
|
|
expect(workflow).not.toContain("branches: [main]");
|
|
expect(workflow).toContain("workflow_call:");
|
|
expect(workflow).not.toContain("workflow_dispatch:");
|
|
expect(workflow).not.toContain("schedule:");
|
|
expect(wrapper).toContain('cron: "17 3 * * *"');
|
|
expect(wrapper).toContain("workflow_dispatch:");
|
|
expect(wrapper).toContain("uses: ./.github/workflows/install-smoke-reusable.yml");
|
|
expect(wrapper).toContain(
|
|
"github.event_name == 'schedule' || inputs.run_bun_global_install_smoke",
|
|
);
|
|
expect(workflow).toContain("run_bun_global_install_smoke:");
|
|
expect(workflow).toContain(
|
|
"if: needs.preflight.outputs.run_full_install_smoke == 'true' && needs.preflight.outputs.run_bun_global_install_smoke == 'true'",
|
|
);
|
|
expect(workflow).toContain("bun_global_install_smoke:");
|
|
expect(workflow).toContain("Setup trusted release harness for Bun smoke");
|
|
expect(workflow).toContain("uses: ./.release-harness/.github/actions/setup-release-harness");
|
|
expect(workflow).toContain("npm install -g bun@1.4.2");
|
|
expect(workflow).toContain('install-bun: "false"');
|
|
expect(workflow).toContain("Run Bun global install candidate-payload smoke");
|
|
expect(workflow).toContain("working-directory: .release-harness");
|
|
expect(workflow).toContain("bash scripts/e2e/bun-global-install-smoke.sh");
|
|
expect(workflow).not.toContain("uses: ./.release-harness/.github/actions/setup-node-env");
|
|
expect(workflow).toContain(
|
|
"OPENCLAW_BUN_GLOBAL_SMOKE_PACKAGE_TGZ: ${{ runner.temp }}/install-smoke-candidate-payload/candidate.tgz",
|
|
);
|
|
expect(workflow).not.toContain("OPENCLAW_BUN_GLOBAL_SMOKE_DIST_IMAGE");
|
|
expect(workflow).toContain("group: ${{ github.workflow }}-workflow-call-${{ github.run_id }}");
|
|
expect(workflow).toContain("cancel-in-progress: false");
|
|
expect(workflow).not.toContain(
|
|
"github.event_name == 'workflow_call' || github.event_name == 'push'",
|
|
);
|
|
expect(workflow).not.toContain("github.event_name == 'pull_request'");
|
|
expect(workflow).not.toContain("node scripts/ci-changed-scope.mjs");
|
|
expect(workflow).toContain("OPENCLAW_CI_WORKFLOW_BUN_GLOBAL_INSTALL_SMOKE");
|
|
expect(workflow).toContain('run_bun_global_install_smoke="$workflow_bun_global_install_smoke"');
|
|
expect(workflow).not.toContain("OPENCLAW_CI_EVENT_NAME");
|
|
expect(workflow).not.toContain('if [ "$event_name"');
|
|
expect(workflow).toContain('echo "run_bun_global_install_smoke=$run_bun_global_install_smoke"');
|
|
expect(workflow).toContain("run_fast_install_smoke=true");
|
|
expect(workflow).toContain("run_full_install_smoke=true");
|
|
expect(workflow).toContain("run_install_smoke=true");
|
|
expect(workflow).toContain("install-smoke-fast:");
|
|
expect(workflow).toContain("run_fast_install_smoke");
|
|
expect(workflow).toContain("run_full_install_smoke");
|
|
expect(workflow).toContain("timeout --kill-after=30s 45m docker buildx build");
|
|
expect(workflow).not.toContain('docker pull "$IMAGE_REF"');
|
|
expect(workflow).not.toContain("packages: write");
|
|
expect(workflow).not.toContain("--push");
|
|
expect(workflow).not.toContain('timeout 300s docker pull "$IMAGE_REF"');
|
|
expect(workflow.match(/timeout --kill-after=30s 20m docker run --rm/g)?.length).toBe(6);
|
|
expect(workflow).not.toMatch(/(^|\n)\s+docker run --rm --entrypoint sh/u);
|
|
expect(workflow).toContain("--progress=plain");
|
|
expect(workflow).toContain("--load");
|
|
expect(workflow).toContain("OPENCLAW_INSTALL_URL: file:///tmp/openclaw-install.sh");
|
|
expect(workflow).toContain("OPENCLAW_INSTALL_CLI_URL: file:///tmp/openclaw-install-cli.sh");
|
|
expect(workflow).toContain('OPENCLAW_INSTALL_SMOKE_SKIP_CLI: "0"');
|
|
expect(workflow).toContain("Run Rocky Linux installer smoke");
|
|
expect(workflow).toContain("Run Rocky Linux CLI installer smoke");
|
|
expect(workflow).toContain("PAYLOAD_DIR: ${{ runner.temp }}/install-smoke-candidate-payload");
|
|
expect(workflow).toContain("$PAYLOAD_DIR/install-cli.sh:/tmp/install-cli.sh:ro");
|
|
expect(workflow).toContain("bash /tmp/install-cli.sh --prefix /tmp/openclaw-cli");
|
|
expect(workflow.match(/-e OPENCLAW_NODE_VERSION="\$\{NODE_VERSION\}"/gu)).toHaveLength(2);
|
|
expect(workflow).toContain("rockylinux:9@sha256:");
|
|
expect(workflow).toContain("pnpm-workspace.yaml");
|
|
expect(workflow).toContain("workspace.patchedDependencies");
|
|
expect(workflow).toContain('throw new Error(\\"missing patch for \\" + dep + \\": \\" + rel)');
|
|
expect(workflow).not.toContain("throw new Error(`missing patch");
|
|
expect(workflow).not.toContain("pkg.pnpm?.patchedDependencies");
|
|
expect(workflow).not.toContain("--cache-from");
|
|
expect(workflow).not.toContain("--cache-to");
|
|
expect(workflow).not.toContain("type=gha");
|
|
expect(workflow).toContain('OPENCLAW_INSTALL_SMOKE_SKIP_NPM_GLOBAL: "1"');
|
|
expect(releaseChecks).toContain("install_smoke_release_checks:");
|
|
expect(releaseChecks).toContain("uses: ./.github/workflows/install-smoke-reusable.yml");
|
|
expect(releaseChecks).toContain("run_bun_global_install_smoke: true");
|
|
});
|
|
|
|
it("packages candidate bytes in isolation and gives consumers only verified artifacts", () => {
|
|
const workflow = parse(readFileSync(INSTALL_SMOKE_WORKFLOW_PATH, "utf8"));
|
|
const cases = [
|
|
{
|
|
buildName: "Build installer smoke image",
|
|
consumerName: "installer_smoke_update",
|
|
dockerfile: "./scripts/docker/install-sh-smoke/Dockerfile",
|
|
group: "update",
|
|
producerName: "installer_smoke_update_image",
|
|
testName: "Run installer update docker tests",
|
|
},
|
|
{
|
|
buildName: "Build installer non-root image",
|
|
consumerName: "installer_smoke_nonroot",
|
|
dockerfile: "./scripts/docker/install-sh-nonroot/Dockerfile",
|
|
group: "nonroot",
|
|
producerName: "installer_smoke_nonroot_image",
|
|
testName: "Run installer non-root docker tests",
|
|
},
|
|
] as const;
|
|
const workflowStep = (
|
|
workflowJob: { steps?: Array<Record<string, unknown>> },
|
|
name: string,
|
|
) => {
|
|
const found = workflowJob.steps?.find((entry) => entry.name === name);
|
|
expect(found, name).toBeDefined();
|
|
return found!;
|
|
};
|
|
|
|
for (const testCase of cases) {
|
|
const producer = workflow.jobs[testCase.producerName];
|
|
const consumer = workflow.jobs[testCase.consumerName];
|
|
expect(workflowStep(producer, "Checkout trusted release harness").with).toMatchObject({
|
|
repository: "openclaw/openclaw",
|
|
ref: "main",
|
|
"fetch-depth": 1,
|
|
"persist-credentials": false,
|
|
});
|
|
const buildStep = workflowStep(producer, testCase.buildName);
|
|
expect(buildStep.run).toContain(`-f ./.release-harness/${testCase.dockerfile.slice(2)}`);
|
|
expect(buildStep.run).not.toContain("candidate/scripts/docker");
|
|
|
|
expect(workflowStep(consumer, "Checkout trusted release harness").with).toMatchObject({
|
|
repository: "openclaw/openclaw",
|
|
ref: "main",
|
|
"fetch-depth": 1,
|
|
"persist-credentials": false,
|
|
});
|
|
expect(
|
|
consumer.steps.find((entry: { name?: string }) => entry.name === "Checkout candidate CLI"),
|
|
).toBeUndefined();
|
|
expect(
|
|
consumer.steps.find((entry: { uses?: string }) =>
|
|
entry.uses?.includes("./.github/actions/setup-node-env"),
|
|
),
|
|
).toBeUndefined();
|
|
expect(workflowStep(consumer, "Validate candidate payload artifact binding").run).toContain(
|
|
'verify-upload "Candidate payload"',
|
|
);
|
|
expect(workflowStep(consumer, "Verify candidate payload contents").run).toContain(
|
|
"install-smoke-candidate-payload.mts verify",
|
|
);
|
|
const run = workflowStep(consumer, testCase.testName);
|
|
expect(run.env).toMatchObject({
|
|
OPENCLAW_INSTALL_SMOKE_FROZEN_PAYLOAD_DIR:
|
|
"${{ runner.temp }}/install-smoke-candidate-payload",
|
|
OPENCLAW_INSTALL_SMOKE_GROUP: testCase.group,
|
|
});
|
|
if (testCase.consumerName === "installer_smoke_update") {
|
|
expect(run.env).toMatchObject({
|
|
OPENCLAW_INSTALL_ALLOW_LEGACY_SAME_VERSION_APPLY:
|
|
"${{ inputs.allow_frozen_target_scenario_omissions && '1' || '0' }}",
|
|
});
|
|
}
|
|
expect(run.run).toBe("bash .release-harness/scripts/test-install-sh-docker.sh");
|
|
}
|
|
|
|
const payload = workflow.jobs.installer_smoke_candidate_payload;
|
|
expect(payload.needs).toEqual(["preflight"]);
|
|
expect(workflowStep(payload, "Download exact candidate source archive").run).toContain(
|
|
"https://codeload.github.com/${TARGET_REPOSITORY}/tar.gz/${TARGET_SHA}",
|
|
);
|
|
expect(workflowStep(payload, "Package candidate only inside pinned harness").run).toContain(
|
|
"--user node",
|
|
);
|
|
expect(workflowStep(payload, "Seal candidate payload in clean pinned harness").run).toContain(
|
|
"--network none",
|
|
);
|
|
expect(
|
|
workflowStep(workflow.jobs.installer_smoke_update, "Run Rocky Linux installer smoke").run,
|
|
).toContain("$PAYLOAD_DIR/install.sh");
|
|
});
|
|
|
|
it.each([0, 23])("relays package container warnings without changing exit %i", (exitCode) => {
|
|
const workflow = parse(readFileSync(INSTALL_SMOKE_WORKFLOW_PATH, "utf8"));
|
|
const packageStep = workflow.jobs.installer_smoke_candidate_payload.steps.find(
|
|
(entry: { name?: string }) => entry.name === "Package candidate only inside pinned harness",
|
|
);
|
|
const root = tempDirs.make("openclaw-package-container-summary-");
|
|
const summary = join(root, "summary.md");
|
|
writeFileSync(summary, "existing summary\n");
|
|
const result = spawnSync(
|
|
"bash",
|
|
[
|
|
"--noprofile",
|
|
"--norc",
|
|
"-c",
|
|
`${String.raw`
|
|
timeout() { shift 2; "$@"; }
|
|
docker() {
|
|
local receipt="" forward_actions=0 forward_summary=0 arg
|
|
for arg in "$@"; do
|
|
case "$arg" in
|
|
GITHUB_ACTIONS) forward_actions=1 ;;
|
|
GITHUB_STEP_SUMMARY=/tmp/openclaw-limit-summary.md) forward_summary=1 ;;
|
|
*:/tmp/openclaw-limit-summary.md) receipt="$(printf '%s' "$arg" | sed 's|:/tmp/openclaw-limit-summary.md$||')" ;;
|
|
esac
|
|
done
|
|
[[ "$forward_actions" == 1 && "$forward_summary" == 1 && "$GITHUB_ACTIONS" == true ]] || return 98
|
|
[[ -n "$receipt" && "$receipt" != "$GITHUB_STEP_SUMMARY" ]] || return 99
|
|
printf '<p>Warning: package size</p>\n' > "$receipt"
|
|
printf '::warning file=package.json,title=Package size::over budget\n'
|
|
return "$FIXTURE_EXIT"
|
|
}
|
|
`}
|
|
${packageStep.run}`,
|
|
],
|
|
{
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
FIXTURE_EXIT: String(exitCode),
|
|
GITHUB_ACTIONS: "true",
|
|
GITHUB_STEP_SUMMARY: summary,
|
|
RUNNER_TEMP: root,
|
|
},
|
|
},
|
|
);
|
|
expect(result.status).toBe(exitCode);
|
|
expect(result.stdout).toContain("::warning file=package.json,");
|
|
expect(readFileSync(summary, "utf8")).toBe("existing summary\n<p>Warning: package size</p>\n");
|
|
});
|
|
|
|
it("kills Bun global install smoke commands that ignore TERM after timeout", () => {
|
|
const result = spawnSync(
|
|
testNodeExecPath,
|
|
[
|
|
BUN_GLOBAL_ASSERTIONS_PATH,
|
|
"run-with-timeout",
|
|
"50",
|
|
testNodeExecPath,
|
|
"-e",
|
|
"process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);",
|
|
],
|
|
{
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
OPENCLAW_BUN_GLOBAL_SMOKE_TIMEOUT_KILL_GRACE_MS: "50",
|
|
},
|
|
timeout: 5000,
|
|
},
|
|
);
|
|
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain(`command timed out after 50ms: ${testNodeExecPath}`);
|
|
});
|
|
});
|