mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
5152 lines
197 KiB
TypeScript
5152 lines
197 KiB
TypeScript
// Docker Build Helper tests cover docker build helper script behavior.
|
|
import { type ChildProcess, execFileSync, spawn, spawnSync } from "node:child_process";
|
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import { pathToFileURL } from "node:url";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
|
|
|
|
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
|
|
|
const HELPER_PATH = "scripts/lib/docker-build.sh";
|
|
const DOCKER_ALL_SCHEDULER_PATH = "scripts/test-docker-all.mts";
|
|
const DOCKER_E2E_PACKAGE_HELPER_PATH = "scripts/lib/docker-e2e-package.sh";
|
|
const DOCKER_E2E_IMAGE_HELPER_PATH = "scripts/lib/docker-e2e-image.sh";
|
|
const DOCKER_E2E_SCENARIOS_PATH = "scripts/lib/docker-e2e-scenarios.mts";
|
|
const OPENCLAW_E2E_INSTANCE_HELPER_PATH = "scripts/lib/openclaw-e2e-instance.sh";
|
|
const COMPOSE_SETUP_E2E_PATH = "scripts/e2e/compose-setup.sh";
|
|
const CLI_INSTALLER_DISTRIBUTION_E2E_PATH = "scripts/e2e/cli-installer-distribution-docker.sh";
|
|
const DOCKER_PACKAGE_INSTALL_E2E_PATH = "scripts/e2e/docker-package-install.sh";
|
|
const INSTALL_E2E_RUNNER_PATH = "scripts/docker/install-sh-e2e/run.sh";
|
|
const CLEANUP_DOCKER_SMOKE_PATH = "scripts/test-cleanup-docker.sh";
|
|
const INSTALL_E2E_DOCKER_SMOKE_PATH = "scripts/test-install-sh-e2e-docker.sh";
|
|
const LIVE_CLI_BACKEND_DOCKER_PATH = "scripts/test-live-cli-backend-docker.sh";
|
|
const LIVE_BUILD_DOCKER_PATH = "scripts/test-live-build-docker.sh";
|
|
const OPENAI_WEB_SEARCH_MINIMAL_E2E_PATH = "scripts/e2e/openai-web-search-minimal-docker.sh";
|
|
const OPENAI_WEB_SEARCH_MINIMAL_SCENARIO_PATH =
|
|
"scripts/e2e/lib/openai-web-search-minimal/scenario.sh";
|
|
const OPENAI_WEB_SEARCH_MINIMAL_CLIENT_PATH =
|
|
"scripts/e2e/lib/openai-web-search-minimal/client.mjs";
|
|
const AGENTS_DELETE_SHARED_WORKSPACE_DOCKER_E2E_PATH =
|
|
"scripts/e2e/agents-delete-shared-workspace-docker.sh";
|
|
const OPENWEBUI_DOCKER_E2E_PATH = "scripts/e2e/openwebui-docker.sh";
|
|
const ONBOARD_DOCKER_E2E_PATH = "scripts/e2e/onboard-docker.sh";
|
|
const KITCHEN_SINK_PLUGIN_DOCKER_E2E_PATH = "scripts/e2e/kitchen-sink-plugin-docker.sh";
|
|
const KITCHEN_SINK_RPC_DOCKER_E2E_PATH = "scripts/e2e/kitchen-sink-rpc-docker.sh";
|
|
const CODEX_ON_DEMAND_DOCKER_E2E_PATH = "scripts/e2e/codex-on-demand-docker.sh";
|
|
const MCP_CODE_MODE_GATEWAY_DOCKER_E2E_PATH = "scripts/e2e/mcp-code-mode-gateway-docker.sh";
|
|
const MCP_CODE_MODE_GATEWAY_LIVE_DOCKER_E2E_PATH =
|
|
"scripts/e2e/mcp-code-mode-gateway-live-docker.sh";
|
|
const CODEX_MEDIA_PATH_DOCKER_E2E_PATH = "scripts/e2e/codex-media-path-docker.sh";
|
|
const OPENAI_CHAT_TOOLS_DOCKER_E2E_PATH = "scripts/e2e/openai-chat-tools-docker.sh";
|
|
const CODEX_MEDIA_PATH_SCENARIO_PATH = "scripts/e2e/lib/codex-media-path/scenario.sh";
|
|
const OPENAI_CHAT_TOOLS_SCENARIO_PATH = "scripts/e2e/lib/openai-chat-tools/scenario.sh";
|
|
const CODEX_NPM_PLUGIN_LIVE_DOCKER_E2E_PATH = "scripts/e2e/codex-npm-plugin-live-docker.sh";
|
|
const CODEX_NPM_PLUGIN_LIVE_FOLLOWTHROUGH_PATH =
|
|
"scripts/e2e/lib/codex-npm-plugin-live/followthrough-turn.mjs";
|
|
const LIVE_PLUGIN_TOOL_DOCKER_E2E_PATH = "scripts/e2e/live-plugin-tool-docker.sh";
|
|
const NPM_ONBOARD_CHANNEL_AGENT_DOCKER_E2E_PATH = "scripts/e2e/npm-onboard-channel-agent-docker.sh";
|
|
const SKILL_INSTALL_DOCKER_E2E_PATH = "scripts/e2e/skill-install-docker.sh";
|
|
const PLUGIN_BINDING_COMMAND_ESCAPE_DOCKER_E2E_PATH =
|
|
"scripts/e2e/plugin-binding-command-escape-docker.sh";
|
|
const PLUGIN_BINDING_COMMAND_ESCAPE_DOCKERFILE_PATH =
|
|
"scripts/e2e/plugin-binding-command-escape.Dockerfile";
|
|
const QR_IMPORT_DOCKER_E2E_PATH = "scripts/e2e/qr-import-docker.sh";
|
|
const MULTI_NODE_UPDATE_DOCKER_E2E_PATH = "scripts/e2e/multi-node-update-docker.sh";
|
|
const BUNDLED_PLUGIN_INSTALL_UNINSTALL_E2E_PATH =
|
|
"scripts/e2e/bundled-plugin-install-uninstall-docker.sh";
|
|
const AGENT_BUNDLE_MCP_TOOLS_DOCKER_E2E_PATH = "scripts/e2e/agent-bundle-mcp-tools-docker.sh";
|
|
const SYSTEM_AGENT_FIRST_RUN_DOCKER_E2E_PATH = "scripts/e2e/system-agent-first-run-docker.sh";
|
|
const SYSTEM_AGENT_RESCUE_DOCKER_E2E_PATH = "scripts/e2e/system-agent-rescue-docker.sh";
|
|
const SESSION_RUNTIME_CONTEXT_DOCKER_E2E_PATH = "scripts/e2e/session-runtime-context-docker.sh";
|
|
const BUNDLED_PLUGIN_INSTALL_UNINSTALL_SWEEP_PATH =
|
|
"scripts/e2e/lib/bundled-plugin-install-uninstall/sweep.sh";
|
|
const BUNDLED_PLUGIN_INSTALL_UNINSTALL_PROBE_PATH =
|
|
"scripts/e2e/lib/bundled-plugin-install-uninstall/probe.mjs";
|
|
const BUNDLED_PLUGIN_INSTALL_UNINSTALL_RUNTIME_SMOKE_PATH =
|
|
"scripts/e2e/lib/bundled-plugin-install-uninstall/runtime-smoke.mjs";
|
|
const CLEANUP_SMOKE_DOCKERFILE_PATH = "scripts/docker/cleanup-smoke/Dockerfile";
|
|
const CLEANUP_SMOKE_RUN_PATH = "scripts/docker/cleanup-smoke/run.sh";
|
|
const PLUGINS_DOCKER_E2E_PATH = "scripts/e2e/plugins-docker.sh";
|
|
const PLUGINS_DOCKER_SWEEP_PATH = "scripts/e2e/lib/plugins/sweep.sh";
|
|
const PLUGINS_DOCKER_MARKETPLACE_PATH = "scripts/e2e/lib/plugins/marketplace.sh";
|
|
const PLUGINS_DOCKER_CLAWHUB_PATH = "scripts/e2e/lib/plugins/clawhub.sh";
|
|
const PLUGINS_DOCKER_ASSERTIONS_PATH = "scripts/e2e/lib/plugins/assertions.mjs";
|
|
const PLUGINS_DOCKER_NPM_REGISTRY_PATH = "scripts/e2e/lib/plugins/npm-registry-server.mjs";
|
|
const PLUGIN_UPDATE_DOCKER_E2E_PATH = "scripts/e2e/plugin-update-unchanged-docker.sh";
|
|
const PLUGIN_UPDATE_SCENARIO_PATH = "scripts/e2e/lib/plugin-update/unchanged-scenario.sh";
|
|
const PLUGIN_UPDATE_CORRUPT_SCENARIO_PATH =
|
|
"scripts/e2e/lib/plugin-update/corrupt-update-scenario.sh";
|
|
const PLUGIN_UPDATE_PROBE_PATH = "scripts/e2e/lib/plugin-update/probe.mjs";
|
|
const PLUGIN_LIFECYCLE_MATRIX_DOCKER_E2E_PATH = "scripts/e2e/plugin-lifecycle-matrix-docker.sh";
|
|
const DOCTOR_SWITCH_DOCKER_E2E_PATH = "scripts/e2e/doctor-install-switch-docker.sh";
|
|
const DOCTOR_SWITCH_SCENARIO_PATH = "scripts/e2e/lib/doctor-install-switch/scenario.sh";
|
|
const DOCTOR_SWITCH_LOGINCTL_SHIM_PATH = "scripts/e2e/lib/doctor-install-switch/shims/loginctl";
|
|
const DOCTOR_SWITCH_SYSTEMCTL_SHIM_PATH = "scripts/e2e/lib/doctor-install-switch/shims/systemctl";
|
|
const PACKAGE_COMPAT_PATH = "scripts/e2e/lib/package-compat.mjs";
|
|
const UPGRADE_SURVIVOR_DOCKER_E2E_PATH = "scripts/e2e/upgrade-survivor-docker.sh";
|
|
const UPDATE_CHANNEL_SWITCH_DOCKER_E2E_PATH = "scripts/e2e/update-channel-switch-docker.sh";
|
|
const UPDATE_CHANNEL_SWITCH_ASSERTIONS_PATH =
|
|
"scripts/e2e/lib/update-channel-switch/assertions.mjs";
|
|
const RELEASE_UPGRADE_USER_JOURNEY_SCENARIO_PATH =
|
|
"scripts/e2e/lib/release-upgrade-user-journey/scenario.sh";
|
|
const RELEASE_TYPED_ONBOARDING_SCENARIO_PATH =
|
|
"scripts/e2e/lib/release-typed-onboarding/scenario.sh";
|
|
const RELEASE_USER_JOURNEY_DOCKER_E2E_PATH = "scripts/e2e/release-user-journey-docker.sh";
|
|
const RELEASE_USER_JOURNEY_SCENARIO_PATH = "scripts/e2e/lib/release-user-journey/scenario.sh";
|
|
const UPGRADE_SURVIVOR_RUN_SCRIPT = "scripts/e2e/lib/upgrade-survivor/run.sh";
|
|
const UPGRADE_SURVIVOR_UPDATE_RESTART_AUTH_PATH =
|
|
"scripts/e2e/lib/upgrade-survivor/update-restart-auth.sh";
|
|
const GATEWAY_NETWORK_DOCKER_E2E_PATH = "scripts/e2e/gateway-network-docker.sh";
|
|
const BROWSER_CDP_SNAPSHOT_DOCKER_E2E_PATH = "scripts/e2e/browser-cdp-snapshot-docker.sh";
|
|
const SANDBOX_BROWSER_SIDECAR_DOCKER_E2E_PATH = "scripts/e2e/sandbox-browser-sidecar-docker.sh";
|
|
const SANDBOX_BROWSER_SIDECAR_SCENARIO_PATH =
|
|
"scripts/e2e/lib/sandbox-browser-sidecar/scenario.mjs";
|
|
const CENTRALIZED_BUILD_SCRIPTS = [
|
|
"scripts/docker/setup.sh",
|
|
BROWSER_CDP_SNAPSHOT_DOCKER_E2E_PATH,
|
|
SANDBOX_BROWSER_SIDECAR_DOCKER_E2E_PATH,
|
|
"scripts/e2e/qr-import-docker.sh",
|
|
"scripts/lib/docker-e2e-image.sh",
|
|
"scripts/sandbox-browser-setup.sh",
|
|
"scripts/sandbox-common-setup.sh",
|
|
"scripts/sandbox-setup.sh",
|
|
"scripts/test-cleanup-docker.sh",
|
|
"scripts/test-install-sh-docker.sh",
|
|
"scripts/test-install-sh-e2e-docker.sh",
|
|
"scripts/test-live-build-docker.sh",
|
|
] as const;
|
|
|
|
function extractUpgradeSurvivorPayload(script: string) {
|
|
const marker = " bash -lc ";
|
|
const start = script.indexOf(marker);
|
|
const quoted = script.slice(start + marker.length).trimEnd();
|
|
if (start < 0 || !quoted.startsWith("'") || !quoted.endsWith("'")) {
|
|
throw new Error("upgrade survivor bash -lc payload not found");
|
|
}
|
|
return quoted.slice(1, -1).replaceAll(`'"'"'`, "'");
|
|
}
|
|
const BOUNDED_CLIENT_LOG_DOCKER_E2E_SCRIPTS = [
|
|
"scripts/e2e/cron-mcp-cleanup-docker.sh",
|
|
"scripts/e2e/mcp-channels-docker.sh",
|
|
"scripts/e2e/mcp-code-mode-gateway-docker.sh",
|
|
"scripts/e2e/mcp-code-mode-gateway-live-docker.sh",
|
|
] as const;
|
|
|
|
function packageBackedDockerRunnerPaths(): string[] {
|
|
return readdirSync("scripts/e2e")
|
|
.filter((entry) => entry.endsWith("-docker.sh"))
|
|
.map((entry) => join("scripts/e2e", entry))
|
|
.filter((path) => readFileSync(path, "utf8").includes("docker_e2e_prepare_package_tgz"))
|
|
.toSorted();
|
|
}
|
|
|
|
function shellQuote(value: string): string {
|
|
return `'${value.replace(/'/gu, `'\\''`)}'`;
|
|
}
|
|
|
|
function renderRepoShell(
|
|
parts: TemplateStringsArray,
|
|
values: readonly unknown[],
|
|
workDir?: string,
|
|
): string {
|
|
const body = parts.reduce(
|
|
(result, part, index) => result + part + (index < values.length ? String(values[index]) : ""),
|
|
"",
|
|
);
|
|
const scriptBody = body.startsWith("\n") ? body.slice(1) : body;
|
|
const tempSetup = workDir ? `TMPDIR=${shellQuote(workDir)}\nexport ROOT_DIR TMPDIR\n` : "";
|
|
return `
|
|
set -euo pipefail
|
|
ROOT_DIR=${shellQuote(process.cwd())}
|
|
${tempSetup}${scriptBody}`;
|
|
}
|
|
|
|
function repoRootShell(parts: TemplateStringsArray, ...values: unknown[]): string {
|
|
return renderRepoShell(parts, values);
|
|
}
|
|
|
|
function repoShell(workDir: string) {
|
|
return (parts: TemplateStringsArray, ...values: unknown[]): string =>
|
|
renderRepoShell(parts, values, workDir);
|
|
}
|
|
|
|
function writeExecutables(directory: string, files: Record<string, string>): void {
|
|
mkdirSync(directory, { recursive: true });
|
|
for (const [name, contents] of Object.entries(files)) {
|
|
writeFileSync(join(directory, name), contents, { mode: 0o755 });
|
|
}
|
|
}
|
|
|
|
function expectTextToIncludeAll(text: string, snippets: readonly string[]): void {
|
|
for (const snippet of snippets) {
|
|
expect(text).toContain(snippet);
|
|
}
|
|
}
|
|
|
|
function extractUpgradeSurvivorSupervisor(script: string): string {
|
|
const match = script.match(
|
|
/cat >"\$supervisor_script" <<'SUPERVISOR'\n(?<source>[\s\S]*?)\nSUPERVISOR/u,
|
|
);
|
|
const source = match?.groups?.source;
|
|
if (!source) {
|
|
throw new Error("upgrade survivor supervisor source not found");
|
|
}
|
|
return source;
|
|
}
|
|
|
|
async function waitForProcessExit(child: ChildProcess, timeoutMs = 5_000): Promise<number | null> {
|
|
if (child.exitCode !== null || child.signalCode !== null) {
|
|
return child.exitCode;
|
|
}
|
|
return await new Promise<number | null>((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
child.kill("SIGKILL");
|
|
reject(new Error("process did not exit before its test deadline"));
|
|
}, timeoutMs);
|
|
child.once("exit", (code) => {
|
|
clearTimeout(timeout);
|
|
resolve(code);
|
|
});
|
|
});
|
|
}
|
|
|
|
function isProcessRunning(pid: number): boolean {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function writeTermIgnoringDescendant(workDir: string): string {
|
|
const descendantPath = join(workDir, "descendant.mjs");
|
|
writeFileSync(
|
|
descendantPath,
|
|
`import fs from "node:fs";
|
|
process.on("SIGTERM", () => {});
|
|
fs.writeFileSync(process.env.DESCENDANT_PID_FILE, String(process.pid));
|
|
setInterval(() => {}, 1_000);
|
|
`,
|
|
);
|
|
return descendantPath;
|
|
}
|
|
|
|
function cleanupSmokeLogTailHelpers(): string {
|
|
const script = readFileSync(CLEANUP_SMOKE_RUN_PATH, "utf8");
|
|
const match = script.match(
|
|
/(read_positive_int_env\(\) \{[\s\S]*?\n\}\n\nprint_log_tail\(\) \{[\s\S]*?\n\})\n\nread_positive_int_env/u,
|
|
);
|
|
if (!match) {
|
|
throw new Error("cleanup smoke log helpers were not found");
|
|
}
|
|
const helpers = match[1];
|
|
if (helpers === undefined) {
|
|
throw new Error("cleanup smoke log helper capture was not found");
|
|
}
|
|
return helpers;
|
|
}
|
|
|
|
function runCleanupDefaultPlatform(env: Record<string, string>, hostArch: string): string {
|
|
const script = readFileSync(CLEANUP_DOCKER_SMOKE_PATH, "utf8");
|
|
const match = script.match(/(resolve_default_cleanup_platform\(\) \{[\s\S]*?\n\})\n\nPLATFORM=/u);
|
|
if (!match) {
|
|
throw new Error("resolve_default_cleanup_platform was not found");
|
|
}
|
|
return execFileSync(
|
|
"bash",
|
|
[
|
|
"--noprofile",
|
|
"--norc",
|
|
"-c",
|
|
`${match[1]}\nuname() { if [[ "\${1:-}" == "-m" ]]; then printf "%s" "$FAKE_UNAME_ARCH"; else command uname "$@"; fi; }\nresolve_default_cleanup_platform`,
|
|
],
|
|
{
|
|
encoding: "utf8",
|
|
env: {
|
|
HOME: "/tmp",
|
|
PATH: process.env.PATH ?? "",
|
|
FAKE_UNAME_ARCH: hostArch,
|
|
...env,
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
describe("docker build helper", () => {
|
|
it("allows deployments to build an immutable sandbox image tag", () => {
|
|
const script = readFileSync("scripts/sandbox-setup.sh", "utf8");
|
|
expect(script).toContain(
|
|
'IMAGE_NAME="${OPENCLAW_SANDBOX_IMAGE:-openclaw-sandbox:bookworm-slim}"',
|
|
);
|
|
});
|
|
|
|
it("treats Docker registry auth 5xx failures as transient build failures", () => {
|
|
const workDir = tempDirs.make("openclaw-docker-build-transient-");
|
|
const logPath = join(workDir, "docker-build.log");
|
|
writeFileSync(
|
|
logPath,
|
|
[
|
|
"#3 ERROR: failed to authorize: failed to fetch oauth token: unexpected status from POST request to https://auth.docker.io/token: 504 Gateway Timeout: error code: 504",
|
|
"ERROR: failed to solve: failed to resolve source metadata for docker.io/docker/dockerfile:1.7",
|
|
].join("\n"),
|
|
);
|
|
|
|
const script = repoRootShell`
|
|
LOG_PATH=${shellQuote(logPath)}
|
|
source "$ROOT_DIR/scripts/lib/docker-build.sh"
|
|
docker_build_transient_failure "$LOG_PATH"
|
|
`;
|
|
|
|
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
|
|
});
|
|
|
|
it("detects Docker builder memory exhaustion failures", () => {
|
|
const workDir = tempDirs.make("openclaw-docker-build-memory-");
|
|
const logPath = join(workDir, "docker-build.log");
|
|
writeFileSync(
|
|
logPath,
|
|
[
|
|
'ERROR: failed to build: failed to solve: ResourceExhausted: process "/bin/sh -c pnpm build:docker" did not complete successfully: cannot allocate memory',
|
|
].join("\n"),
|
|
);
|
|
|
|
const script = repoRootShell`
|
|
LOG_PATH=${shellQuote(logPath)}
|
|
source "$ROOT_DIR/scripts/lib/docker-build.sh"
|
|
docker_build_resource_exhausted_failure "$LOG_PATH"
|
|
`;
|
|
|
|
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
|
|
});
|
|
|
|
it("detects compiler processes killed by the OOM killer", () => {
|
|
const workDir = tempDirs.make("openclaw-docker-build-killed-compiler-");
|
|
const logPath = join(workDir, "docker-build.log");
|
|
writeFileSync(logPath, "c++: fatal error: Killed signal terminated program cc1plus\n");
|
|
|
|
const script = repoRootShell`
|
|
LOG_PATH=${shellQuote(logPath)}
|
|
source "$ROOT_DIR/scripts/lib/docker-build.sh"
|
|
docker_build_resource_exhausted_failure "$LOG_PATH"
|
|
`;
|
|
|
|
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
|
|
});
|
|
|
|
it("retries Corepack connect timeouts without misreading Dockerfile comments as OOM", () => {
|
|
const workDir = tempDirs.make("openclaw-docker-build-connect-timeout-");
|
|
const logPath = join(workDir, "docker-build.log");
|
|
writeFileSync(
|
|
logPath,
|
|
[
|
|
'# Docker builds on small VMs may otherwise fail with "Killed" (exit 137).',
|
|
"ConnectTimeoutError: Connect Timeout Error (attempted addresses: 192.0.2.1:443)",
|
|
].join("\n"),
|
|
);
|
|
|
|
const script = repoRootShell`
|
|
LOG_PATH=${shellQuote(logPath)}
|
|
source "$ROOT_DIR/scripts/lib/docker-build.sh"
|
|
docker_build_transient_failure "$LOG_PATH"
|
|
if docker_build_resource_exhausted_failure "$LOG_PATH"; then
|
|
exit 3
|
|
fi
|
|
`;
|
|
|
|
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
|
|
});
|
|
|
|
it("keeps shell-script Docker builds behind the helper", () => {
|
|
for (const path of CENTRALIZED_BUILD_SCRIPTS) {
|
|
const script = readFileSync(path, "utf8");
|
|
|
|
expect(script, path).toMatch(/docker-build\.sh|docker-e2e-image\.sh/);
|
|
expect(script, path).not.toMatch(/\bdocker build\b/);
|
|
expect(script, path).not.toMatch(/run_logged\s+\S+\s+docker\s+build/);
|
|
}
|
|
});
|
|
|
|
it("routes standalone Docker smoke runs through the timeout-aware helper", () => {
|
|
const cleanupSmoke = readFileSync(CLEANUP_DOCKER_SMOKE_PATH, "utf8");
|
|
const installE2eSmoke = readFileSync(INSTALL_E2E_DOCKER_SMOKE_PATH, "utf8");
|
|
|
|
expect(cleanupSmoke).toContain('source "$ROOT_DIR/scripts/lib/docker-e2e-container.sh"');
|
|
expect(cleanupSmoke).toContain(
|
|
'DOCKER_COMMAND_TIMEOUT="${DOCKER_COMMAND_TIMEOUT:-${OPENCLAW_CLEANUP_SMOKE_DOCKER_TIMEOUT:-600s}}"',
|
|
);
|
|
expect(cleanupSmoke).toContain(
|
|
'docker_e2e_docker_run_cmd run --rm --platform "$PLATFORM" -t "$IMAGE_NAME"',
|
|
);
|
|
expect(cleanupSmoke).not.toContain('docker run --rm --platform "$PLATFORM" -t "$IMAGE_NAME"');
|
|
|
|
expect(installE2eSmoke).toContain('source "$ROOT_DIR/scripts/lib/docker-e2e-container.sh"');
|
|
expect(installE2eSmoke).toContain(
|
|
'DOCKER_COMMAND_TIMEOUT="${DOCKER_COMMAND_TIMEOUT:-${OPENCLAW_INSTALL_E2E_DOCKER_TIMEOUT:-2700s}}"',
|
|
);
|
|
expect(installE2eSmoke).toContain("docker_e2e_docker_run_cmd run --rm \\");
|
|
expect(installE2eSmoke).not.toContain("docker run --rm \\");
|
|
});
|
|
|
|
it("runs the sandbox browser sidecar proof from the package-installed image", () => {
|
|
const runner = readFileSync(SANDBOX_BROWSER_SIDECAR_DOCKER_E2E_PATH, "utf8");
|
|
const scenario = readFileSync(SANDBOX_BROWSER_SIDECAR_SCENARIO_PATH, "utf8");
|
|
|
|
expect(runner).toContain('source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"');
|
|
expect(runner).toContain("docker_e2e_build_or_reuse");
|
|
expect(runner).toContain("--network host");
|
|
expect(runner).toContain('--group-add "$SOCKET_GID"');
|
|
expect(runner).toContain('-v "$DOCKER_SOCKET:/var/run/docker.sock"');
|
|
expect(runner).toContain('-v "$SCENARIO_ROOT:$SCENARIO_ROOT"');
|
|
expect(runner).toContain("scripts/docker/sandbox/Dockerfile.browser");
|
|
expect(runner).toContain("remove_prefixed_containers");
|
|
expect(scenario).toContain('from "openclaw/plugin-sdk/agent-harness-runtime"');
|
|
expect(scenario).toContain("Promise.all([");
|
|
expect(scenario).toContain('"sandbox", "list", "--browser", "--json"');
|
|
expect(scenario).toContain('"sandbox", "recreate", "--browser", "--session"');
|
|
expect(scenario).not.toMatch(/from\s+["'][.]{1,2}\/.*src\//u);
|
|
});
|
|
|
|
it("gives cleanup-smoke builds enough Node heap while preserving explicit callers", () => {
|
|
const cleanupRun = readFileSync(CLEANUP_SMOKE_RUN_PATH, "utf8");
|
|
expect(cleanupRun).toContain("ensure_cleanup_smoke_node_options()");
|
|
expect(cleanupRun).toContain('export NODE_OPTIONS="$current"');
|
|
expect(cleanupRun).toContain("--max-old-space-size=8192");
|
|
expect(cleanupRun).toContain('*" --max-old-space-size="*');
|
|
expect(cleanupRun).toContain('*" --max_old_space_size="*');
|
|
expect(cleanupRun.indexOf("ensure_cleanup_smoke_node_options")).toBeLessThan(
|
|
cleanupRun.indexOf("pnpm build >/tmp/openclaw-cleanup-build.log"),
|
|
);
|
|
});
|
|
|
|
it("rejects invalid cleanup-smoke log byte limits", () => {
|
|
const workDir = tempDirs.make("openclaw-cleanup-smoke-log-invalid-");
|
|
const logPath = join(workDir, "cleanup.log");
|
|
writeFileSync(logPath, "cleanup output\n");
|
|
const script = `
|
|
set -euo pipefail
|
|
LOG_PATH=${shellQuote(logPath)}
|
|
export OPENCLAW_CLEANUP_SMOKE_LOG_PRINT_BYTES=64kb
|
|
|
|
${cleanupSmokeLogTailHelpers()}
|
|
|
|
print_log_tail "$LOG_PATH"
|
|
`;
|
|
|
|
const result = spawnSync("bash", ["-lc", script], { encoding: "utf8" });
|
|
|
|
expect(result.status).toBe(2);
|
|
expect(result.stderr).toContain("invalid OPENCLAW_CLEANUP_SMOKE_LOG_PRINT_BYTES: 64kb");
|
|
expect(result.stdout).toBe("");
|
|
});
|
|
|
|
it("normalizes zero-padded cleanup-smoke log byte limits", () => {
|
|
const workDir = tempDirs.make("openclaw-cleanup-smoke-log-tail-");
|
|
const logPath = join(workDir, "cleanup.log");
|
|
writeFileSync(logPath, "old-cleanup-output-recent\n");
|
|
const script = `
|
|
set -euo pipefail
|
|
LOG_PATH=${shellQuote(logPath)}
|
|
export OPENCLAW_CLEANUP_SMOKE_LOG_PRINT_BYTES=0008
|
|
|
|
${cleanupSmokeLogTailHelpers()}
|
|
|
|
print_log_tail "$LOG_PATH"
|
|
`;
|
|
|
|
const result = spawnSync("bash", ["-lc", script], { encoding: "utf8" });
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toContain("truncated: showing last 8");
|
|
expect(result.stdout).toContain("-recent\n");
|
|
expect(result.stdout).not.toContain("old-cleanup-output");
|
|
expect(result.stderr).toBe("");
|
|
});
|
|
|
|
it("prints Docker MCP client logs through the bounded helper", () => {
|
|
for (const scriptPath of BOUNDED_CLIENT_LOG_DOCKER_E2E_SCRIPTS) {
|
|
const script = readFileSync(scriptPath, "utf8");
|
|
|
|
expect(script, scriptPath).toContain('source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"');
|
|
expect(script.match(/docker_e2e_print_log "\$CLIENT_LOG"/g), scriptPath).toHaveLength(2);
|
|
expect(script, scriptPath).not.toContain('cat "$CLIENT_LOG"');
|
|
}
|
|
});
|
|
|
|
it("prints in-container Docker client logs through bounded helpers", () => {
|
|
for (const scriptPath of [CODEX_MEDIA_PATH_SCENARIO_PATH, OPENAI_CHAT_TOOLS_SCENARIO_PATH]) {
|
|
const script = readFileSync(scriptPath, "utf8");
|
|
|
|
expect(script, scriptPath).toContain("source scripts/lib/openclaw-e2e-instance.sh");
|
|
expect(script, scriptPath).toContain('openclaw_e2e_print_log "$CLIENT_LOG"');
|
|
expect(script, scriptPath).not.toContain('cat "$CLIENT_LOG"');
|
|
}
|
|
});
|
|
|
|
it("runs cleanup smoke on the native ARM platform instead of pulling an amd64 tag", () => {
|
|
expect(runCleanupDefaultPlatform({ CI: "true" }, "aarch64")).toBe("linux/arm64");
|
|
expect(runCleanupDefaultPlatform({ GITHUB_ACTIONS: "true" }, "x86_64")).toBe("linux/amd64");
|
|
expect(runCleanupDefaultPlatform({}, "arm64")).toBe("linux/arm64");
|
|
expect(
|
|
runCleanupDefaultPlatform({ OPENCLAW_CLEANUP_SMOKE_PLATFORM: "linux/s390x" }, "x86_64"),
|
|
).toBe("linux/s390x");
|
|
});
|
|
|
|
it("lets Testbox fall back to building when a reused Docker image is missing", () => {
|
|
const helper = readFileSync(HELPER_PATH, "utf8");
|
|
const e2eImageHelper = readFileSync(DOCKER_E2E_IMAGE_HELPER_PATH, "utf8");
|
|
const liveBuild = readFileSync(LIVE_BUILD_DOCKER_PATH, "utf8");
|
|
const liveCliBackend = readFileSync(LIVE_CLI_BACKEND_DOCKER_PATH, "utf8");
|
|
|
|
expectTextToIncludeAll(helper, [
|
|
"docker_build_on_missing_enabled()",
|
|
"OPENCLAW_DOCKER_BUILD_ON_MISSING",
|
|
"OPENCLAW_TESTBOX",
|
|
]);
|
|
|
|
expect(e2eImageHelper).toContain("docker_build_on_missing_enabled");
|
|
expect(e2eImageHelper).toContain("Docker image not available; building");
|
|
expect(e2eImageHelper).toContain('docker_e2e_docker_cmd image inspect "$image_name"');
|
|
expect(e2eImageHelper).toContain('docker_e2e_docker_cmd pull "$image_name"');
|
|
expect(liveBuild).toContain('source "$SCRIPT_ROOT_DIR/scripts/lib/docker-e2e-container.sh"');
|
|
expect(liveBuild).toContain(
|
|
'DOCKER_COMMAND_TIMEOUT="${DOCKER_COMMAND_TIMEOUT:-${OPENCLAW_LIVE_DOCKER_PULL_TIMEOUT:-600s}}"',
|
|
);
|
|
expect(liveBuild).toContain(
|
|
'LIVE_IMAGE_PULL_ATTEMPTS="${OPENCLAW_LIVE_DOCKER_PULL_ATTEMPTS:-3}"',
|
|
);
|
|
expect(liveBuild).toContain('docker_e2e_docker_cmd image inspect "$LIVE_IMAGE_NAME"');
|
|
expect(liveBuild).toContain('docker_e2e_docker_cmd pull "$LIVE_IMAGE_NAME"');
|
|
expect(liveBuild).not.toContain('docker image inspect "$LIVE_IMAGE_NAME"');
|
|
expect(liveBuild).not.toContain('docker pull "$LIVE_IMAGE_NAME"');
|
|
expect(liveBuild).toContain("Live-test image not available; building");
|
|
expect(readFileSync(OPENWEBUI_DOCKER_E2E_PATH, "utf8")).toContain(
|
|
'DOCKER_COMMAND_TIMEOUT="$DOCKER_PULL_TIMEOUT" docker_e2e_docker_cmd pull "$OPENWEBUI_IMAGE"',
|
|
);
|
|
expect(readFileSync(OPENWEBUI_DOCKER_E2E_PATH, "utf8")).not.toContain(
|
|
'timeout "$DOCKER_PULL_TIMEOUT" docker pull "$OPENWEBUI_IMAGE"',
|
|
);
|
|
expect(liveCliBackend).toContain(
|
|
'OPENCLAW_LIVE_DOCKER_REPO_ROOT="$ROOT_DIR" "$TRUSTED_HARNESS_DIR/scripts/test-live-build-docker.sh"',
|
|
);
|
|
expect(liveCliBackend).toContain("codex-cli is no longer a bundled CLI backend");
|
|
expect(liveCliBackend).not.toContain("==> Direct Codex CLI probe ok");
|
|
expect(liveCliBackend).not.toContain(
|
|
'echo "==> Reuse live-test image: $LIVE_IMAGE_NAME (OPENCLAW_SKIP_DOCKER_BUILD=1)"',
|
|
);
|
|
});
|
|
|
|
it("resolves source and compiled candidate test-state entrypoints", () => {
|
|
const resolveEntrypoint = (rootDir: string) =>
|
|
spawnSync(
|
|
"bash",
|
|
["-c", `source "${DOCKER_E2E_IMAGE_HELPER_PATH}"; docker_e2e_test_state_entrypoint`],
|
|
{
|
|
cwd: process.cwd(),
|
|
encoding: "utf8",
|
|
env: { ...process.env, ROOT_DIR: rootDir },
|
|
},
|
|
);
|
|
|
|
const sourceResult = resolveEntrypoint(process.cwd());
|
|
expect(sourceResult.status, sourceResult.stderr).toBe(0);
|
|
expect(sourceResult.stdout.trim()).toBe(
|
|
join(process.cwd(), "scripts/lib/openclaw-test-state.mts"),
|
|
);
|
|
|
|
const compiledRoot = tempDirs.make("openclaw-compiled-test-state-");
|
|
const missingResult = resolveEntrypoint(compiledRoot);
|
|
expect(missingResult.status).toBe(1);
|
|
expect(missingResult.stderr).toContain("OpenClaw test-state entrypoint not found");
|
|
|
|
const compiledDir = join(compiledRoot, "scripts/lib");
|
|
mkdirSync(compiledDir, { recursive: true });
|
|
const compiledEntrypoint = join(compiledDir, "openclaw-test-state.mjs");
|
|
writeFileSync(compiledEntrypoint, "", "utf8");
|
|
const compiledResult = resolveEntrypoint(compiledRoot);
|
|
expect(compiledResult.status, compiledResult.stderr).toBe(0);
|
|
expect(compiledResult.stdout.trim()).toBe(compiledEntrypoint);
|
|
});
|
|
|
|
it("runs current TypeScript and frozen JavaScript Docker harness entrypoints", () => {
|
|
const fixtureRoot = tempDirs.make("openclaw-docker-script-entrypoint-");
|
|
const scriptStem = join(fixtureRoot, "fixture");
|
|
const runFixture = (value: string) =>
|
|
spawnSync(
|
|
"bash",
|
|
[
|
|
"-c",
|
|
`source "${OPENCLAW_E2E_INSTANCE_HELPER_PATH}"; openclaw_e2e_run_script_entrypoint "$1" "$2"`,
|
|
"openclaw-docker-script-entrypoint",
|
|
scriptStem,
|
|
value,
|
|
],
|
|
{
|
|
cwd: process.cwd(),
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
PATH: `${join(process.cwd(), "node_modules/.bin")}:${process.env.PATH ?? ""}`,
|
|
},
|
|
},
|
|
);
|
|
|
|
writeFileSync(
|
|
`${scriptStem}.mts`,
|
|
'enum Choice { Current = "current" }\nconst value: Choice = process.argv[2] as Choice; process.stdout.write(`mts:${value}`);\n',
|
|
"utf8",
|
|
);
|
|
const sourceResult = runFixture("current");
|
|
expect(sourceResult.status, sourceResult.stderr).toBe(0);
|
|
expect(sourceResult.stdout).toBe("mts:current");
|
|
|
|
rmSync(`${scriptStem}.mts`);
|
|
writeFileSync(
|
|
`${scriptStem}.mjs`,
|
|
'process.stdout.write(`mjs:${process.argv[2] ?? ""}`);\n',
|
|
"utf8",
|
|
);
|
|
const compiledResult = runFixture("frozen");
|
|
expect(compiledResult.status, compiledResult.stderr).toBe(0);
|
|
expect(compiledResult.stdout).toBe("mjs:frozen");
|
|
|
|
rmSync(`${scriptStem}.mjs`);
|
|
const missingResult = runFixture("missing");
|
|
expect(missingResult.status).toBe(1);
|
|
expect(missingResult.stderr).toContain("script entrypoint not found");
|
|
});
|
|
|
|
it("routes package-only Docker TypeScript entrypoints through their required runtime", () => {
|
|
const gatewayRunner = readFileSync(GATEWAY_NETWORK_DOCKER_E2E_PATH, "utf8");
|
|
const imageHelper = readFileSync(DOCKER_E2E_IMAGE_HELPER_PATH, "utf8");
|
|
const kitchenSinkRunner = readFileSync(KITCHEN_SINK_RPC_DOCKER_E2E_PATH, "utf8");
|
|
const releaseUpgradeScenario = readFileSync(RELEASE_UPGRADE_USER_JOURNEY_SCENARIO_PATH, "utf8");
|
|
expect(gatewayRunner).toContain("node scripts/e2e/lib/gateway-network/client.mts");
|
|
expect(kitchenSinkRunner).toContain(
|
|
"openclaw_e2e_run_script_entrypoint scripts/e2e/kitchen-sink-rpc-walk",
|
|
);
|
|
expect(releaseUpgradeScenario).toContain(
|
|
"openclaw_e2e_run_script_entrypoint \\\n scripts/lib/release-upgrade-baseline",
|
|
);
|
|
expect(gatewayRunner).not.toContain("node --import tsx");
|
|
expect(imageHelper).not.toContain('node --import tsx "$entrypoint"');
|
|
expect(kitchenSinkRunner).not.toContain("node --import tsx");
|
|
expect(releaseUpgradeScenario).not.toContain("node --import tsx");
|
|
});
|
|
|
|
it("rejects malformed Docker E2E resource limits before a suite starts", () => {
|
|
const helper = readFileSync(DOCKER_E2E_IMAGE_HELPER_PATH, "utf8");
|
|
const scripts = [
|
|
readFileSync(ONBOARD_DOCKER_E2E_PATH, "utf8"),
|
|
readFileSync(KITCHEN_SINK_PLUGIN_DOCKER_E2E_PATH, "utf8"),
|
|
readFileSync(KITCHEN_SINK_RPC_DOCKER_E2E_PATH, "utf8"),
|
|
readFileSync(OPENWEBUI_DOCKER_E2E_PATH, "utf8"),
|
|
];
|
|
|
|
expect(helper).toContain("docker_e2e_read_nonnegative_decimal_env()");
|
|
for (const script of scripts) {
|
|
expect(script).toContain("docker_e2e_read_nonnegative_decimal_env");
|
|
}
|
|
|
|
const runProbe = (value: string) => {
|
|
const script = [
|
|
"source scripts/lib/docker-e2e-image.sh",
|
|
"docker_e2e_read_nonnegative_decimal_env OPENCLAW_SAMPLE_RESOURCE_LIMIT 2048",
|
|
].join("\n");
|
|
return spawnSync("bash", ["-c", script], {
|
|
cwd: process.cwd(),
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
OPENCLAW_SAMPLE_RESOURCE_LIMIT: value,
|
|
},
|
|
});
|
|
};
|
|
|
|
const invalid = runProbe("12mb");
|
|
const overlarge = runProbe("9999999999");
|
|
const overprecise = runProbe("12.1234567");
|
|
const decimal = runProbe("12.5");
|
|
expect(invalid.status).toBe(2);
|
|
expect(invalid.stderr).toContain("invalid OPENCLAW_SAMPLE_RESOURCE_LIMIT: 12mb");
|
|
expect(overlarge.status).toBe(2);
|
|
expect(overlarge.stderr).toContain("invalid OPENCLAW_SAMPLE_RESOURCE_LIMIT: 9999999999");
|
|
expect(overprecise.status).toBe(2);
|
|
expect(overprecise.stderr).toContain("invalid OPENCLAW_SAMPLE_RESOURCE_LIMIT: 12.1234567");
|
|
expect(decimal.status).toBe(0);
|
|
expect(decimal.stdout.trimEnd()).toBe("12.5");
|
|
});
|
|
|
|
it("keeps Testbox image-build fallback before isolating live MCP code-mode runtime flags", () => {
|
|
const script = readFileSync(MCP_CODE_MODE_GATEWAY_LIVE_DOCKER_E2E_PATH, "utf8");
|
|
const buildIndex = script.indexOf('docker_e2e_build_or_reuse "$IMAGE_NAME"');
|
|
const unsetIndex = script.indexOf("unset OPENCLAW_TESTBOX");
|
|
|
|
expect(buildIndex).toBeGreaterThanOrEqual(0);
|
|
expect(unsetIndex).toBeGreaterThan(buildIndex);
|
|
expect(script).toContain("host/testbox mode flags that can change packaged behavior");
|
|
});
|
|
|
|
it("wraps centralized Docker builds with the timeout helper", () => {
|
|
const workDir = tempDirs.make("openclaw-docker-build-timeout-");
|
|
writeExecutables(join(workDir, "bin"), {
|
|
timeout: `#!/bin/bash
|
|
set -euo pipefail
|
|
if [[ "$1" = "--kill-after=1s" ]]; then
|
|
exit 0
|
|
fi
|
|
printf '%s %s|%s\\n' "$1" "$2" "\${*:3}" >>"$TMPDIR/timeout-seen"
|
|
shift 2
|
|
"$@"
|
|
`,
|
|
docker: `#!/bin/sh
|
|
printf "%s\\n" "$*" >>"$TMPDIR/docker-seen"
|
|
`,
|
|
});
|
|
|
|
const script = repoShell(workDir)`
|
|
export PATH="$TMPDIR/bin:$PATH"
|
|
export OPENCLAW_DOCKER_BUILD_TIMEOUT=17s
|
|
|
|
source "$ROOT_DIR/scripts/lib/docker-build.sh"
|
|
|
|
docker_build_run e2e-build -t demo-image .
|
|
|
|
grep -q '^--kill-after=30s 17s|env DOCKER_BUILDKIT=1 docker build -t demo-image .$' "$TMPDIR/timeout-seen"
|
|
grep -q '^build -t demo-image .$' "$TMPDIR/docker-seen"
|
|
`;
|
|
|
|
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
|
|
});
|
|
|
|
it("prints heartbeat progress for long successful centralized Docker builds", () => {
|
|
const workDir = tempDirs.make("openclaw-docker-build-heartbeat-");
|
|
writeExecutables(join(workDir, "bin"), {
|
|
timeout: `#!/bin/bash
|
|
set -euo pipefail
|
|
if [[ "$1" = "--kill-after=1s" ]]; then
|
|
exit 0
|
|
fi
|
|
shift 2
|
|
"$@"
|
|
`,
|
|
docker: `#!/bin/sh
|
|
printf "captured docker build log\\n"
|
|
/bin/sleep 0.05
|
|
`,
|
|
});
|
|
|
|
const script = repoShell(workDir)`
|
|
export PATH="$TMPDIR/bin:$PATH"
|
|
export OPENCLAW_DOCKER_BUILD_HEARTBEAT_SECONDS=1
|
|
|
|
source "$ROOT_DIR/scripts/lib/docker-build.sh"
|
|
|
|
printf "captured docker build log\\n" >"$TMPDIR/build.log"
|
|
output="$(docker_build_maybe_print_heartbeat e2e-build 1 1 "$TMPDIR/build.log")"
|
|
[[ "$output" = *"Docker build e2e-build still running ("* ]]
|
|
[[ "$output" = *"log bytes captured"* ]]
|
|
[[ "$output" != *"captured docker build log"* ]]
|
|
`;
|
|
|
|
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
|
|
});
|
|
|
|
it("stops the tracked build command without retrying when interrupted", async () => {
|
|
const workDir = tempDirs.make("openclaw-docker-build-signal-");
|
|
writeExecutables(join(workDir, "bin"), {
|
|
docker: `#!/bin/bash
|
|
set -euo pipefail
|
|
count=0
|
|
if [ -f "$TMPDIR/docker-count" ]; then
|
|
count="$(<"$TMPDIR/docker-count")"
|
|
fi
|
|
count="$((count + 1))"
|
|
printf '%s\\n' "$count" >"$TMPDIR/docker-count"
|
|
printf '%s\\n' "$$" >"$TMPDIR/docker.pid"
|
|
printf 'rpc error: code = Unavailable\\n'
|
|
trap 'printf "term\\n" >"$TMPDIR/docker.term"; exit 0' TERM
|
|
mkfifo "$TMPDIR/docker.block"
|
|
printf 'ready\\n' >"$TMPDIR/docker.ready"
|
|
while true; do
|
|
read -r -t 1 _ <> "$TMPDIR/docker.block" || true
|
|
done
|
|
`,
|
|
});
|
|
|
|
writeExecutables(workDir, {
|
|
"runner.sh": `#!/bin/bash
|
|
set -euo pipefail
|
|
ROOT_DIR=${shellQuote(process.cwd())}
|
|
TMPDIR=${shellQuote(workDir)}
|
|
export ROOT_DIR TMPDIR
|
|
export PATH="$TMPDIR/bin:$PATH"
|
|
export OPENCLAW_DOCKER_BUILD_RETRIES=3
|
|
source "$ROOT_DIR/scripts/lib/docker-build.sh"
|
|
docker_build_run e2e-build -t demo-image .
|
|
`,
|
|
});
|
|
|
|
const waitForFile = async (filePath: string) => {
|
|
for (let attempt = 0; attempt < 500; attempt += 1) {
|
|
if (existsSync(filePath)) {
|
|
return;
|
|
}
|
|
await delay(10);
|
|
}
|
|
throw new Error(`file was not written: ${filePath}`);
|
|
};
|
|
const waitForExit = async (child: ReturnType<typeof spawn>) =>
|
|
await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => {
|
|
child.once("exit", (code, signal) => resolve({ code, signal }));
|
|
});
|
|
const waitForDead = async (pid: number) => {
|
|
for (let attempt = 0; attempt < 500; attempt += 1) {
|
|
try {
|
|
process.kill(pid, 0);
|
|
} catch {
|
|
return;
|
|
}
|
|
await delay(10);
|
|
}
|
|
throw new Error(`process stayed alive: ${pid}`);
|
|
};
|
|
const runInterruptedBuild = async (signal: NodeJS.Signals, expectedCode: number) => {
|
|
rmSync(join(workDir, "docker.pid"), { force: true });
|
|
rmSync(join(workDir, "docker.term"), { force: true });
|
|
rmSync(join(workDir, "docker.ready"), { force: true });
|
|
rmSync(join(workDir, "docker.block"), { force: true });
|
|
rmSync(join(workDir, "docker-count"), { force: true });
|
|
const runner = spawn(join(workDir, "runner.sh"), {
|
|
env: { ...process.env, TMPDIR: workDir },
|
|
stdio: "ignore",
|
|
});
|
|
try {
|
|
const pidPath = join(workDir, "docker.pid");
|
|
await waitForFile(pidPath);
|
|
await waitForFile(join(workDir, "docker.ready"));
|
|
const buildPid = Number.parseInt(readFileSync(pidPath, "utf8"), 10);
|
|
|
|
runner.kill(signal);
|
|
const exit = await waitForExit(runner);
|
|
|
|
expect(exit).toEqual({ code: expectedCode, signal: null });
|
|
await waitForFile(join(workDir, "docker.term"));
|
|
expect(readFileSync(join(workDir, "docker-count"), "utf8").trim()).toBe("1");
|
|
await waitForDead(buildPid);
|
|
} finally {
|
|
if (runner.exitCode === null && runner.signalCode === null) {
|
|
runner.kill("SIGKILL");
|
|
}
|
|
}
|
|
};
|
|
|
|
await runInterruptedBuild("SIGTERM", 143);
|
|
await runInterruptedBuild("SIGINT", 130);
|
|
});
|
|
|
|
it("does not delay fast successful centralized Docker builds until the next heartbeat", () => {
|
|
const workDir = tempDirs.make("openclaw-docker-build-fast-heartbeat-");
|
|
writeExecutables(join(workDir, "bin"), {
|
|
timeout: `#!/bin/bash
|
|
set -euo pipefail
|
|
if [[ "$1" = "--kill-after=1s" ]]; then
|
|
exit 0
|
|
fi
|
|
shift 2
|
|
"$@"
|
|
`,
|
|
docker: `#!/bin/sh
|
|
printf "quick docker build log\\n"
|
|
`,
|
|
});
|
|
|
|
const script = repoShell(workDir)`
|
|
export PATH="$TMPDIR/bin:$PATH"
|
|
export OPENCLAW_DOCKER_BUILD_HEARTBEAT_SECONDS=30
|
|
|
|
source "$ROOT_DIR/scripts/lib/docker-build.sh"
|
|
|
|
output="$(docker_build_run e2e-build -t demo-image .)"
|
|
[[ -z "$output" ]]
|
|
`;
|
|
const startedAt = Date.now();
|
|
|
|
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
|
|
|
|
expect(Date.now() - startedAt).toBeLessThan(5_000);
|
|
});
|
|
|
|
it("normalizes zero-padded centralized Docker build heartbeat intervals", () => {
|
|
const script = repoRootShell`
|
|
export ROOT_DIR
|
|
export OPENCLAW_DOCKER_BUILD_HEARTBEAT_SECONDS=08
|
|
|
|
source "$ROOT_DIR/scripts/lib/docker-build.sh"
|
|
|
|
[[ "$(docker_build_heartbeat_seconds)" = "8" ]]
|
|
`;
|
|
|
|
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
|
|
});
|
|
|
|
it("normalizes zero-padded centralized Docker build retry counts", () => {
|
|
const script = repoRootShell`
|
|
export ROOT_DIR
|
|
export OPENCLAW_DOCKER_BUILD_RETRIES=08
|
|
|
|
source "$ROOT_DIR/scripts/lib/docker-build.sh"
|
|
|
|
[[ "$(docker_build_retry_count)" = "8" ]]
|
|
`;
|
|
|
|
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
"retry count",
|
|
"OPENCLAW_DOCKER_BUILD_RETRIES",
|
|
"2x",
|
|
"invalid OPENCLAW_DOCKER_BUILD_RETRIES: 2x",
|
|
],
|
|
[
|
|
"heartbeat interval",
|
|
"OPENCLAW_DOCKER_BUILD_HEARTBEAT_SECONDS",
|
|
"soon",
|
|
"invalid OPENCLAW_DOCKER_BUILD_HEARTBEAT_SECONDS: soon",
|
|
],
|
|
])(
|
|
"rejects invalid centralized Docker build %s before invoking docker",
|
|
(_label, envName, value, expectedError) => {
|
|
const workDir = tempDirs.make("openclaw-docker-build-config-");
|
|
const markerPath = join(workDir, "docker-invoked");
|
|
|
|
writeExecutables(join(workDir, "bin"), {
|
|
docker: `#!/bin/bash
|
|
printf invoked >${shellQuote(markerPath)}
|
|
exit 0
|
|
`,
|
|
});
|
|
|
|
const script = repoRootShell`
|
|
TMPDIR=${shellQuote(workDir)}
|
|
export ROOT_DIR TMPDIR
|
|
export PATH="$TMPDIR/bin:$PATH"
|
|
|
|
source "$ROOT_DIR/scripts/lib/docker-build.sh"
|
|
|
|
docker_build_run e2e-build -t demo-image .
|
|
`;
|
|
|
|
const result = spawnSync("bash", ["-lc", script], {
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
[envName]: value,
|
|
},
|
|
});
|
|
|
|
expect(result.status).toBe(2);
|
|
expect(result.stderr).toContain(expectedError);
|
|
expect(existsSync(markerPath)).toBe(false);
|
|
},
|
|
);
|
|
|
|
it("fails centralized Docker builds fast when timeout is unavailable", () => {
|
|
const workDir = tempDirs.make("openclaw-docker-build-timeout-required-");
|
|
mkdirSync(join(workDir, "bin"));
|
|
const script = repoShell(workDir)`
|
|
export PATH="$TMPDIR/bin"
|
|
export OPENCLAW_DOCKER_BUILD_TIMEOUT=19s
|
|
|
|
dirname() {
|
|
/usr/bin/dirname "$@"
|
|
}
|
|
|
|
grep() {
|
|
/usr/bin/grep "$@"
|
|
}
|
|
|
|
cat() {
|
|
/bin/cat "$@"
|
|
}
|
|
|
|
rm() {
|
|
/bin/rm "$@"
|
|
}
|
|
|
|
mktemp() {
|
|
/usr/bin/mktemp "$@"
|
|
}
|
|
|
|
docker() {
|
|
printf "%s\\n" "$*" >"$TMPDIR/docker-seen"
|
|
}
|
|
export -f dirname grep cat rm mktemp docker
|
|
|
|
source "$ROOT_DIR/scripts/lib/docker-build.sh"
|
|
|
|
set +e
|
|
docker_build_run e2e-build -t demo-image . >"$TMPDIR/stdout" 2>"$TMPDIR/stderr"
|
|
status="$?"
|
|
set -e
|
|
|
|
stdout="$(<"$TMPDIR/stdout")"
|
|
[[ "$status" = "1" ]]
|
|
[[ "$stdout" = *"timeout command not found; cannot bound Docker command after 19s"* ]]
|
|
[[ ! -e "$TMPDIR/docker-seen" ]]
|
|
`;
|
|
|
|
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
|
|
});
|
|
|
|
it("keeps setup-style Docker builds compatible when timeout is unavailable", () => {
|
|
const workDir = tempDirs.make("openclaw-docker-build-timeout-optional-");
|
|
writeExecutables(join(workDir, "bin"), {
|
|
env: `#!/bin/sh
|
|
while [ "$#" -gt 0 ]; do
|
|
case "$1" in
|
|
*=*)
|
|
shift
|
|
;;
|
|
*)
|
|
break
|
|
;;
|
|
esac
|
|
done
|
|
exec "$@"
|
|
`,
|
|
docker: `#!/bin/sh
|
|
printf "%s\\n" "$*" >"$TMPDIR/docker-seen"
|
|
`,
|
|
});
|
|
|
|
const script = repoShell(workDir)`
|
|
export PATH="$TMPDIR/bin"
|
|
export OPENCLAW_DOCKER_BUILD_TIMEOUT=23s
|
|
|
|
dirname() {
|
|
/usr/bin/dirname "$@"
|
|
}
|
|
|
|
grep() {
|
|
/usr/bin/grep "$@"
|
|
}
|
|
|
|
rm() {
|
|
/bin/rm "$@"
|
|
}
|
|
|
|
mktemp() {
|
|
/usr/bin/mktemp "$@"
|
|
}
|
|
export -f dirname grep rm mktemp
|
|
|
|
source "$ROOT_DIR/scripts/lib/docker-build.sh"
|
|
|
|
docker_build_exec -t setup-image .
|
|
|
|
[[ "$(<"$TMPDIR/docker-seen")" = "build -t setup-image ." ]]
|
|
`;
|
|
|
|
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
title: "keeps reused Docker image probes behind the timeout-aware helper",
|
|
tempPrefix: "openclaw-docker-image-reuse-timeout-",
|
|
scriptSource: (workDir: string) => repoShell(workDir)`
|
|
export DOCKER_COMMAND_TIMEOUT=3s
|
|
export OPENCLAW_SKIP_DOCKER_BUILD=1
|
|
|
|
mkdir -p "$TMPDIR/bin"
|
|
cat >"$TMPDIR/bin/timeout" <<'SH'
|
|
#!/usr/bin/env bash
|
|
case "$1" in
|
|
--kill-after=1s)
|
|
exit 0
|
|
;;
|
|
--kill-after=30s)
|
|
printf "%s %s|%s\\n" "$1" "$2" "$3 $4 $5" >>"$TMPDIR/timeout-seen"
|
|
shift 2
|
|
;;
|
|
*)
|
|
printf "%s|%s\\n" "$1" "$2 $3 $4" >>"$TMPDIR/timeout-seen"
|
|
shift
|
|
;;
|
|
esac
|
|
"$@"
|
|
SH
|
|
chmod +x "$TMPDIR/bin/timeout"
|
|
export PATH="$TMPDIR/bin:$PATH"
|
|
|
|
docker() {
|
|
printf "%s\\n" "$*" >>"$TMPDIR/docker-seen"
|
|
case "$1 $2" in
|
|
"image inspect")
|
|
return 1
|
|
;;
|
|
"pull openclaw-reuse-image")
|
|
return 0
|
|
;;
|
|
*)
|
|
return 9
|
|
;;
|
|
esac
|
|
}
|
|
export -f docker
|
|
|
|
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
|
|
|
|
docker_e2e_build_or_reuse \\
|
|
openclaw-reuse-image \\
|
|
reuse-timeout-proof \\
|
|
"$ROOT_DIR/scripts/e2e/Dockerfile" \\
|
|
"$ROOT_DIR" \\
|
|
functional
|
|
|
|
test "$(grep -c '^--kill-after=30s 3s|' "$TMPDIR/timeout-seen")" = "2"
|
|
grep -q '^image inspect openclaw-reuse-image$' "$TMPDIR/docker-seen"
|
|
grep -q '^pull openclaw-reuse-image$' "$TMPDIR/docker-seen"
|
|
`,
|
|
},
|
|
{
|
|
title: "explains how to opt out when Docker rejects default resource limits",
|
|
tempPrefix: "openclaw-docker-resource-diagnostic-",
|
|
scriptSource: (workDir: string) => repoShell(workDir)`
|
|
export OPENCLAW_DOCKER_E2E_AVAILABLE_CPUS=8
|
|
unset OPENCLAW_DOCKER_E2E_DISABLE_RESOURCE_LIMITS
|
|
unset OPENCLAW_DOCKER_E2E_MEMORY OPENCLAW_DOCKER_E2E_CPUS OPENCLAW_DOCKER_E2E_PIDS_LIMIT
|
|
|
|
docker() {
|
|
printf "%s\\n" "$*" >>"$TMPDIR/docker-seen"
|
|
echo "docker: Error response from daemon: NanoCPUs can not be set, as the cgroup is not mounted" >&2
|
|
return 125
|
|
}
|
|
|
|
mktemp() {
|
|
local dir=""
|
|
dir="$(/usr/bin/mktemp "$@")" || return
|
|
printf "%s\\n" "$*" >"$TMPDIR/mktemp-seen"
|
|
printf "%s\\n" "$dir" >"$TMPDIR/diagnostic-dir"
|
|
printf "%s\\n" "$dir"
|
|
}
|
|
|
|
tail() {
|
|
printf "%s\\n" "$*" >"$TMPDIR/tail-seen"
|
|
/usr/bin/tail "$@"
|
|
}
|
|
|
|
source "$ROOT_DIR/scripts/lib/docker-e2e-container.sh"
|
|
docker_e2e_timeout_cmd() {
|
|
shift
|
|
"$@"
|
|
}
|
|
|
|
set +e
|
|
printf "before Docker\\n" >"$TMPDIR/stderr"
|
|
docker_e2e_docker_cmd run demo 2>>"$TMPDIR/stderr"
|
|
status="$?"
|
|
set -e
|
|
|
|
stderr="$(<"$TMPDIR/stderr")"
|
|
[[ "$status" = "125" ]]
|
|
[[ "$stderr" = before\\ Docker* ]]
|
|
[[ "$stderr" = *"NanoCPUs can not be set"* ]]
|
|
[[ "$stderr" = *"Docker E2E resource limits are incompatible with this Docker runtime"* ]]
|
|
[[ "$stderr" = *"OPENCLAW_DOCKER_E2E_DISABLE_RESOURCE_LIMITS=1"* ]]
|
|
[[ "$(grep -c '^run ' "$TMPDIR/docker-seen")" = "1" ]]
|
|
[[ "$(<"$TMPDIR/tail-seen")" = "-c 65536" ]]
|
|
[[ "$(<"$TMPDIR/mktemp-seen")" = -d* ]]
|
|
[[ ! -e "$(<"$TMPDIR/diagnostic-dir")" ]]
|
|
`,
|
|
},
|
|
{
|
|
title: "does not suggest resource opt-out for other Docker failures",
|
|
tempPrefix: "openclaw-docker-resource-unrelated-",
|
|
scriptSource: (workDir: string) => repoShell(workDir)`
|
|
export OPENCLAW_DOCKER_E2E_AVAILABLE_CPUS=8
|
|
unset OPENCLAW_DOCKER_E2E_DISABLE_RESOURCE_LIMITS
|
|
unset OPENCLAW_DOCKER_E2E_MEMORY OPENCLAW_DOCKER_E2E_CPUS OPENCLAW_DOCKER_E2E_PIDS_LIMIT
|
|
|
|
docker() {
|
|
printf "%s\\n" "$*" >>"$TMPDIR/docker-seen"
|
|
echo "docker: Error response from daemon: No such image: cgroup-helper" >&2
|
|
return 125
|
|
}
|
|
|
|
source "$ROOT_DIR/scripts/lib/docker-e2e-container.sh"
|
|
docker_e2e_timeout_cmd() {
|
|
shift
|
|
"$@"
|
|
}
|
|
|
|
set +e
|
|
docker_e2e_docker_cmd run demo 2>"$TMPDIR/stderr"
|
|
status="$?"
|
|
set -e
|
|
|
|
stderr="$(<"$TMPDIR/stderr")"
|
|
[[ "$status" = "125" ]]
|
|
[[ "$stderr" = *"No such image: cgroup-helper"* ]]
|
|
[[ "$stderr" != *"OPENCLAW_DOCKER_E2E_DISABLE_RESOURCE_LIMITS"* ]]
|
|
[[ "$(grep -c '^run ' "$TMPDIR/docker-seen")" = "1" ]]
|
|
`,
|
|
},
|
|
{
|
|
title: "rejects invalid Docker run pids limits before invoking docker",
|
|
tempPrefix: "openclaw-docker-resource-pids-",
|
|
scriptSource: (workDir: string) => repoShell(workDir)`
|
|
|
|
docker() {
|
|
printf invoked >"$TMPDIR/docker-seen"
|
|
}
|
|
export -f docker
|
|
|
|
source "$ROOT_DIR/scripts/lib/docker-e2e-container.sh"
|
|
|
|
set +e
|
|
OPENCLAW_DOCKER_E2E_PIDS_LIMIT=many docker_e2e_docker_cmd run demo 2>"$TMPDIR/stderr"
|
|
status="$?"
|
|
set -e
|
|
|
|
[[ "$status" = "2" ]]
|
|
[[ "$(<"$TMPDIR/stderr")" = *"invalid OPENCLAW_DOCKER_E2E_PIDS_LIMIT: many"* ]]
|
|
[[ ! -e "$TMPDIR/docker-seen" ]]
|
|
`,
|
|
},
|
|
{
|
|
title: "rejects invalid package-backed Docker run pids limits before invoking docker",
|
|
tempPrefix: "openclaw-docker-package-pids-",
|
|
scriptSource: (workDir: string) => repoShell(workDir)`
|
|
|
|
dirname() {
|
|
/usr/bin/dirname "$@"
|
|
}
|
|
|
|
docker() {
|
|
printf invoked >"$TMPDIR/docker-seen"
|
|
}
|
|
export -f docker
|
|
|
|
source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh"
|
|
|
|
set +e
|
|
OPENCLAW_DOCKER_E2E_PIDS_LIMIT=many docker_e2e_docker_run_cmd run demo 2>"$TMPDIR/stderr"
|
|
status="$?"
|
|
set -e
|
|
|
|
[[ "$status" = "2" ]]
|
|
[[ "$(<"$TMPDIR/stderr")" = *"invalid OPENCLAW_DOCKER_E2E_PIDS_LIMIT: many"* ]]
|
|
[[ ! -e "$TMPDIR/docker-seen" ]]
|
|
`,
|
|
},
|
|
{
|
|
title: "diagnoses rejected resource limits through the canonical package helper",
|
|
tempPrefix: "openclaw-docker-package-diagnostic-",
|
|
scriptSource: (workDir: string) => repoShell(workDir)`
|
|
export OPENCLAW_DOCKER_E2E_AVAILABLE_CPUS=8
|
|
unset OPENCLAW_DOCKER_E2E_DISABLE_RESOURCE_LIMITS
|
|
unset OPENCLAW_DOCKER_E2E_MEMORY OPENCLAW_DOCKER_E2E_CPUS OPENCLAW_DOCKER_E2E_PIDS_LIMIT
|
|
|
|
timeout() {
|
|
if [[ "$1" = "--kill-after=1s" ]]; then
|
|
return 0
|
|
fi
|
|
shift 2
|
|
"$@"
|
|
}
|
|
|
|
docker() {
|
|
printf "%s\\n" "$*" >>"$TMPDIR/docker-seen"
|
|
echo "OCI runtime create failed: crun: controller pids is not available" >&2
|
|
return 125
|
|
}
|
|
|
|
source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh"
|
|
|
|
set +e
|
|
docker_e2e_docker_run_cmd run demo 2>"$TMPDIR/stderr"
|
|
status="$?"
|
|
set -e
|
|
|
|
stderr="$(<"$TMPDIR/stderr")"
|
|
[[ "$status" = "125" ]]
|
|
[[ "$stderr" = *"controller pids is not available"* ]]
|
|
[[ "$stderr" = *"Docker E2E resource limits are incompatible with this Docker runtime"* ]]
|
|
[[ "$stderr" = *"OPENCLAW_DOCKER_E2E_DISABLE_RESOURCE_LIMITS=1"* ]]
|
|
[[ "$(grep -c '^run ' "$TMPDIR/docker-seen")" = "1" ]]
|
|
`,
|
|
},
|
|
{
|
|
title: "removes functional Docker build package inputs after the build",
|
|
tempPrefix: "openclaw-docker-build-cleanup-",
|
|
scriptSource: (workDir: string) => repoShell(workDir)`
|
|
|
|
node() {
|
|
local script="$1"
|
|
shift
|
|
if [[ "$script" != "$ROOT_DIR/scripts/package-openclaw-for-docker.mjs" ]]; then
|
|
command node "$script" "$@"
|
|
return
|
|
fi
|
|
|
|
local output_dir=""
|
|
local output_name=""
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--output-dir)
|
|
output_dir="$2"
|
|
shift 2
|
|
;;
|
|
--output-name)
|
|
output_name="$2"
|
|
shift 2
|
|
;;
|
|
*)
|
|
shift
|
|
;;
|
|
esac
|
|
done
|
|
|
|
mkdir -p "$output_dir"
|
|
printf fixture >"$output_dir/$output_name"
|
|
printf "%s\\n" "$output_dir/$output_name"
|
|
}
|
|
export -f node
|
|
|
|
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
|
|
|
|
docker_build_run() {
|
|
local build_context=""
|
|
local arg
|
|
for arg in "$@"; do
|
|
case "$arg" in
|
|
openclaw_package=*)
|
|
build_context="\${arg#openclaw_package=}"
|
|
;;
|
|
esac
|
|
done
|
|
|
|
test -n "$build_context"
|
|
test -f "$build_context/openclaw-current.tgz"
|
|
printf "%s\\n" "$build_context" >"$TMPDIR/build-context-seen"
|
|
}
|
|
|
|
docker_e2e_build_or_reuse \\
|
|
openclaw-test-image \\
|
|
cleanup-proof \\
|
|
"$ROOT_DIR/scripts/e2e/Dockerfile" \\
|
|
"$ROOT_DIR" \\
|
|
functional
|
|
|
|
test -f "$TMPDIR/build-context-seen"
|
|
leftovers="$(find "$TMPDIR" -maxdepth 1 \\( \\
|
|
-name 'openclaw-docker-e2e-pack.*' \\
|
|
-o -name 'openclaw-docker-e2e-package-context.*' \\
|
|
\\) -print)"
|
|
if [[ -n "$leftovers" ]]; then
|
|
printf 'leftover functional build inputs:\\n%s\\n' "$leftovers" >&2
|
|
exit 1
|
|
fi
|
|
`,
|
|
},
|
|
{
|
|
title: "keeps caller-provided functional Docker build packages",
|
|
tempPrefix: "openclaw-docker-build-external-package-",
|
|
scriptSource: (workDir: string) => repoShell(workDir)`
|
|
|
|
external_dir="$TMPDIR/external-package"
|
|
mkdir -p "$external_dir"
|
|
printf fixture >"$external_dir/openclaw-current.tgz"
|
|
OPENCLAW_CURRENT_PACKAGE_TGZ="$external_dir/openclaw-current.tgz"
|
|
export OPENCLAW_CURRENT_PACKAGE_TGZ
|
|
|
|
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
|
|
|
|
docker_build_run() {
|
|
local build_context=""
|
|
local arg
|
|
for arg in "$@"; do
|
|
case "$arg" in
|
|
openclaw_package=*)
|
|
build_context="\${arg#openclaw_package=}"
|
|
;;
|
|
esac
|
|
done
|
|
|
|
test -n "$build_context"
|
|
test -f "$build_context/openclaw-current.tgz"
|
|
printf "%s\\n" "$build_context" >"$TMPDIR/build-context-seen"
|
|
}
|
|
|
|
docker_e2e_build_or_reuse \\
|
|
openclaw-test-image \\
|
|
external-package-proof \\
|
|
"$ROOT_DIR/scripts/e2e/Dockerfile" \\
|
|
"$ROOT_DIR" \\
|
|
functional
|
|
|
|
test -f "$TMPDIR/build-context-seen"
|
|
test -f "$OPENCLAW_CURRENT_PACKAGE_TGZ"
|
|
leftovers="$(find "$TMPDIR" -maxdepth 1 -name 'openclaw-docker-e2e-package-context.*' -print)"
|
|
if [[ -n "$leftovers" ]]; then
|
|
printf 'leftover functional build context:\\n%s\\n' "$leftovers" >&2
|
|
exit 1
|
|
fi
|
|
`,
|
|
},
|
|
{
|
|
title: "cleans generated package mounts after harness Docker runs",
|
|
tempPrefix: "openclaw-docker-package-mount-cleanup-",
|
|
scriptSource: (workDir: string) => repoShell(workDir)`
|
|
export DOCKER_COMMAND_TIMEOUT=3s
|
|
|
|
mkdir -p "$TMPDIR/bin"
|
|
cat >"$TMPDIR/bin/timeout" <<'SH'
|
|
#!/usr/bin/env bash
|
|
case "$1" in
|
|
--kill-after=1s)
|
|
exit 0
|
|
;;
|
|
--kill-after=30s)
|
|
timeout_args="$1 $2"
|
|
shift 2
|
|
;;
|
|
*)
|
|
timeout_args="$1"
|
|
shift
|
|
;;
|
|
esac
|
|
if [[ "\${1:-}" == "docker" && "\${2:-}" == "run" ]]; then
|
|
printf "%s\\n" "$timeout_args" >"$TMPDIR/docker-timeout-seen"
|
|
fi
|
|
"$@"
|
|
SH
|
|
chmod +x "$TMPDIR/bin/timeout"
|
|
export PATH="$TMPDIR/bin:$PATH"
|
|
|
|
node() {
|
|
local script="$1"
|
|
shift
|
|
if [[ "$script" != "$ROOT_DIR/scripts/package-openclaw-for-docker.mjs" ]]; then
|
|
command node "$script" "$@"
|
|
return
|
|
fi
|
|
|
|
local output_dir=""
|
|
local output_name=""
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--output-dir)
|
|
output_dir="$2"
|
|
shift 2
|
|
;;
|
|
--output-name)
|
|
output_name="$2"
|
|
shift 2
|
|
;;
|
|
*)
|
|
shift
|
|
;;
|
|
esac
|
|
done
|
|
|
|
mkdir -p "$output_dir"
|
|
printf fixture >"$output_dir/$output_name"
|
|
printf "%s\\n" "$output_dir/$output_name"
|
|
}
|
|
export -f node
|
|
|
|
source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh"
|
|
|
|
docker() {
|
|
if [[ "$1" == "rm" ]]; then
|
|
shift
|
|
test "$1" = "-f"
|
|
shift
|
|
printf "%s\\n" "$1" >>"$TMPDIR/docker-rm-seen"
|
|
return 0
|
|
fi
|
|
|
|
local cidfile=""
|
|
local mount_path=""
|
|
local expect_volume_path=0
|
|
local expect_cidfile=0
|
|
local arg
|
|
for arg in "$@"; do
|
|
if [[ "$expect_cidfile" == "1" ]]; then
|
|
cidfile="$arg"
|
|
expect_cidfile=0
|
|
continue
|
|
fi
|
|
if [[ "$expect_volume_path" == "1" ]]; then
|
|
mount_path="\${arg%%:*}"
|
|
expect_volume_path=0
|
|
continue
|
|
fi
|
|
if [[ "$arg" == "--cidfile" ]]; then
|
|
expect_cidfile=1
|
|
continue
|
|
fi
|
|
if [[ "$arg" == "-v" ]]; then
|
|
expect_volume_path=1
|
|
fi
|
|
done
|
|
|
|
test -n "$cidfile"
|
|
test ! -e "$cidfile"
|
|
printf "container-%s\\n" "\${DOCKER_STUB_STATUS:-}" >"$cidfile"
|
|
test -n "$mount_path"
|
|
test -f "$mount_path"
|
|
printf "%s\\n" "$mount_path" >"$TMPDIR/package-mount-seen"
|
|
return "\${DOCKER_STUB_STATUS:-0}"
|
|
}
|
|
export -f docker
|
|
|
|
package_tgz="$(docker_e2e_prepare_package_tgz mount-cleanup)"
|
|
pack_dir="$(dirname "$package_tgz")"
|
|
docker_e2e_package_mount_args "$package_tgz"
|
|
DOCKER_STUB_STATUS=7 docker_e2e_run_with_harness image-name bash -lc true || run_status="$?"
|
|
test "\${run_status:-0}" = "7"
|
|
test "$(cat "$TMPDIR/docker-timeout-seen")" = "--kill-after=30s 3s"
|
|
grep -qx "container-7" "$TMPDIR/docker-rm-seen"
|
|
test -f "$TMPDIR/package-mount-seen"
|
|
test ! -e "$pack_dir"
|
|
test -z "$(find "$TMPDIR" -maxdepth 1 -name 'openclaw-docker-e2e-container.*' -print)"
|
|
|
|
external_dir="$TMPDIR/external-package"
|
|
mkdir -p "$external_dir"
|
|
printf fixture >"$external_dir/openclaw-current.tgz"
|
|
docker_e2e_package_mount_args "$external_dir/openclaw-current.tgz"
|
|
unset DOCKER_COMMAND_TIMEOUT
|
|
rm -f "$TMPDIR/docker-timeout-seen"
|
|
docker_e2e_run_with_harness image-name bash -lc true
|
|
test "$(cat "$TMPDIR/docker-timeout-seen")" = "--kill-after=30s 3600s"
|
|
grep -qx "container-" "$TMPDIR/docker-rm-seen"
|
|
test -f "$external_dir/openclaw-current.tgz"
|
|
`,
|
|
},
|
|
{
|
|
title: "propagates shared E2E command timeouts into package-backed containers",
|
|
tempPrefix: "openclaw-docker-package-timeout-env-",
|
|
scriptSource: (workDir: string) => repoShell(workDir)`
|
|
source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh"
|
|
|
|
package="$TMPDIR/openclaw-current.tgz"
|
|
printf fixture >"$package"
|
|
export OPENCLAW_E2E_NPM_INSTALL_TIMEOUT=42s
|
|
export OPENCLAW_E2E_COMMAND_TIMEOUT=23s
|
|
docker_e2e_package_mount_args "$package"
|
|
printf "%s\\n" "\${DOCKER_E2E_PACKAGE_ARGS[@]}" >"$TMPDIR/package-args"
|
|
|
|
grep -qx -- "-e" "$TMPDIR/package-args"
|
|
grep -qx -- "OPENCLAW_CURRENT_PACKAGE_TGZ=/tmp/openclaw-current.tgz" "$TMPDIR/package-args"
|
|
grep -qx -- "OPENCLAW_E2E_NPM_INSTALL_TIMEOUT=42s" "$TMPDIR/package-args"
|
|
grep -qx -- "OPENCLAW_E2E_COMMAND_TIMEOUT=23s" "$TMPDIR/package-args"
|
|
`,
|
|
},
|
|
{
|
|
title:
|
|
"keeps both harness run wrappers available when the package helper is sourced directly",
|
|
tempPrefix: "openclaw-docker-package-helper-guard-",
|
|
scriptSource: (workDir: string) => repoShell(workDir)`
|
|
|
|
mkdir -p "$TMPDIR/bin"
|
|
cat >"$TMPDIR/bin/timeout" <<'SH'
|
|
#!/usr/bin/env bash
|
|
case "$1" in
|
|
--kill-after=1s)
|
|
exit 0
|
|
;;
|
|
--kill-after=30s)
|
|
shift 2
|
|
;;
|
|
*)
|
|
shift
|
|
;;
|
|
esac
|
|
"$@"
|
|
SH
|
|
chmod +x "$TMPDIR/bin/timeout"
|
|
export PATH="$TMPDIR/bin:$PATH"
|
|
|
|
docker() {
|
|
printf "%s\\n" "$*" >>"$TMPDIR/docker-run-seen"
|
|
}
|
|
export -f docker
|
|
|
|
source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh"
|
|
|
|
docker_e2e_run_with_harness image-name bash -lc true
|
|
docker_e2e_run_detached_with_harness image-name
|
|
[[ $(wc -l <"$TMPDIR/docker-run-seen") -eq 2 ]]
|
|
`,
|
|
},
|
|
{
|
|
title: "forwards harness stdin to backgrounded Docker runs",
|
|
tempPrefix: "openclaw-docker-harness-stdin-",
|
|
scriptSource: (workDir: string) => repoShell(workDir)`
|
|
|
|
mkdir -p "$TMPDIR/bin"
|
|
cat >"$TMPDIR/bin/timeout" <<'SH'
|
|
#!/usr/bin/env bash
|
|
case "$1" in
|
|
--kill-after=1s)
|
|
exit 0
|
|
;;
|
|
--kill-after=30s)
|
|
shift 2
|
|
;;
|
|
*)
|
|
shift
|
|
;;
|
|
esac
|
|
"$@"
|
|
SH
|
|
chmod +x "$TMPDIR/bin/timeout"
|
|
export PATH="$TMPDIR/bin:$PATH"
|
|
|
|
source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh"
|
|
|
|
docker() {
|
|
if [[ "$1" == "rm" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
local cidfile=""
|
|
local expect_cidfile=0
|
|
local arg
|
|
for arg in "$@"; do
|
|
if [[ "$expect_cidfile" == "1" ]]; then
|
|
cidfile="$arg"
|
|
expect_cidfile=0
|
|
continue
|
|
fi
|
|
if [[ "$arg" == "--cidfile" ]]; then
|
|
expect_cidfile=1
|
|
fi
|
|
done
|
|
|
|
test -n "$cidfile"
|
|
printf "container-stdin\\n" >"$cidfile"
|
|
cat >"$TMPDIR/docker-stdin-seen"
|
|
}
|
|
export -f docker
|
|
|
|
docker_e2e_run_with_harness image-name bash -s <<'SH'
|
|
printf "heredoc reached docker\\n"
|
|
SH
|
|
|
|
grep -Fxq 'printf "heredoc reached docker\\n"' "$TMPDIR/docker-stdin-seen"
|
|
`,
|
|
},
|
|
{
|
|
title: "bounds printed Docker E2E logs to the configured tail",
|
|
tempPrefix: "openclaw-docker-e2e-log-print-tail-",
|
|
scriptSource: (workDir: string) => repoShell(workDir)`
|
|
export OPENCLAW_DOCKER_E2E_LOG_PRINT_BYTES=64
|
|
|
|
source "$ROOT_DIR/scripts/lib/docker-e2e-logs.sh"
|
|
|
|
output="$(run_logged_print_heartbeat plugins-run 30 bash -c 'printf "DO_NOT_PRINT_OLD_LOG_START"; printf "%0200d" 0; printf "recent container log tail\\\\n"')"
|
|
[[ "$output" = *"truncated: showing last 64"* ]]
|
|
[[ "$output" = *"recent container log tail"* ]]
|
|
[[ "$output" != *"DO_NOT_PRINT_OLD_LOG_START"* ]]
|
|
`,
|
|
},
|
|
{
|
|
title: "prints heartbeat progress for long successful Docker E2E log captures",
|
|
tempPrefix: "openclaw-docker-e2e-log-heartbeat-",
|
|
scriptSource: (workDir: string) => repoShell(workDir)`
|
|
|
|
source "$ROOT_DIR/scripts/lib/docker-e2e-logs.sh"
|
|
|
|
printf "captured container log\\n" >"$TMPDIR/run.log"
|
|
output="$(docker_e2e_maybe_print_log_heartbeat plugins-run 1 1 "$TMPDIR/run.log")"
|
|
[[ "$output" = *"still running plugins-run ("* ]]
|
|
[[ "$output" = *"log bytes captured"* ]]
|
|
[[ "$output" != *"captured container log"* ]]
|
|
`,
|
|
},
|
|
{
|
|
title: "cleans the heartbeat command when the wrapper is terminated",
|
|
tempPrefix: "openclaw-docker-e2e-log-term-cleanup-",
|
|
scriptSource: (workDir: string) => repoShell(workDir)`
|
|
export OPENCLAW_DOCKER_E2E_HEARTBEAT_TERM_GRACE_SECONDS=1
|
|
|
|
source "$ROOT_DIR/scripts/lib/docker-e2e-logs.sh"
|
|
|
|
command_pid_file="$TMPDIR/command.pid"
|
|
(
|
|
run_logged_print_heartbeat plugins-run 30 bash -c 'trap "exit 0" TERM; printf "%s" "$$" > "$1"; while true; do /bin/sleep 0.05; done' bash "$command_pid_file"
|
|
) &
|
|
wrapper_pid="$!"
|
|
for _ in $(seq 1 100); do
|
|
[ -s "$command_pid_file" ] && break
|
|
/bin/sleep 0.01
|
|
done
|
|
if [ ! -s "$command_pid_file" ]; then
|
|
kill -TERM "$wrapper_pid" 2>/dev/null || true
|
|
echo "heartbeat command pid was not recorded" >&2
|
|
exit 1
|
|
fi
|
|
command_pid="$(cat "$command_pid_file")"
|
|
kill -TERM "$wrapper_pid"
|
|
for _ in $(seq 1 50); do
|
|
if ! kill -0 "$command_pid" 2>/dev/null; then
|
|
wait "$wrapper_pid" 2>/dev/null || true
|
|
exit 0
|
|
fi
|
|
/bin/sleep 0.01
|
|
done
|
|
kill -TERM "$command_pid" 2>/dev/null || true
|
|
kill -TERM "$wrapper_pid" 2>/dev/null || true
|
|
echo "heartbeat command still alive after wrapper termination: $command_pid" >&2
|
|
exit 1
|
|
`,
|
|
},
|
|
{
|
|
title: "cleans harness containers when heartbeat-wrapped Docker runs are terminated",
|
|
tempPrefix: "openclaw-docker-e2e-harness-term-cleanup-",
|
|
scriptSource: (workDir: string) => repoShell(workDir)`
|
|
|
|
mkdir -p "$TMPDIR/bin"
|
|
cat >"$TMPDIR/bin/timeout" <<'SH'
|
|
#!/usr/bin/env bash
|
|
case "$1" in
|
|
--kill-after=1s)
|
|
exit 0
|
|
;;
|
|
--kill-after=30s)
|
|
shift 2
|
|
;;
|
|
*)
|
|
shift
|
|
;;
|
|
esac
|
|
"$@"
|
|
SH
|
|
chmod +x "$TMPDIR/bin/timeout"
|
|
export PATH="$TMPDIR/bin:$PATH"
|
|
|
|
source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh"
|
|
|
|
docker() {
|
|
if [[ "$1" == "rm" ]]; then
|
|
shift
|
|
test "$1" = "-f"
|
|
shift
|
|
printf "%s\\n" "$1" >>"$TMPDIR/docker-rm-seen"
|
|
return 0
|
|
fi
|
|
|
|
local cidfile=""
|
|
local expect_cidfile=0
|
|
local arg
|
|
for arg in "$@"; do
|
|
if [[ "$expect_cidfile" == "1" ]]; then
|
|
cidfile="$arg"
|
|
expect_cidfile=0
|
|
continue
|
|
fi
|
|
if [[ "$arg" == "--cidfile" ]]; then
|
|
expect_cidfile=1
|
|
fi
|
|
done
|
|
|
|
test -n "$cidfile"
|
|
printf "container-term\\n" >"$cidfile"
|
|
printf "started\\n" >"$TMPDIR/docker-started"
|
|
printf "docker running\\n"
|
|
trap 'exit 143' TERM
|
|
while true; do /bin/sleep 0.05; done
|
|
}
|
|
export -f docker
|
|
|
|
(
|
|
docker_e2e_run_logged_print_with_harness plugins-run image-name bash -lc true
|
|
) &
|
|
wrapper_pid="$!"
|
|
for _ in $(seq 1 50); do
|
|
[ -s "$TMPDIR/docker-started" ] && break
|
|
/bin/sleep 0.01
|
|
kill -0 "$wrapper_pid" 2>/dev/null || true
|
|
done
|
|
test -s "$TMPDIR/docker-started"
|
|
kill -TERM "$wrapper_pid" 2>/dev/null || true
|
|
wait "$wrapper_pid" 2>/dev/null || true
|
|
for _ in $(seq 1 50); do
|
|
grep -qx "container-term" "$TMPDIR/docker-rm-seen" 2>/dev/null && break
|
|
/bin/sleep 0.01
|
|
done
|
|
grep -qx "container-term" "$TMPDIR/docker-rm-seen"
|
|
test -z "$(find "$TMPDIR" -maxdepth 1 -name 'openclaw-docker-e2e-container.*' -print)"
|
|
`,
|
|
},
|
|
{
|
|
title: "normalizes zero-padded Docker E2E stats heartbeat intervals",
|
|
tempPrefix: "openclaw-docker-e2e-stats-zero-heartbeat-",
|
|
scriptSource: (workDir: string) => repoShell(workDir)`
|
|
|
|
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
|
|
|
|
docker_e2e_docker_cmd() {
|
|
case "$1" in
|
|
inspect) return 0 ;;
|
|
stats) printf '{"MemUsage":"1MiB / 2MiB","CPUPerc":"0.1%%"}\\n'; return 0 ;;
|
|
*) return 0 ;;
|
|
esac
|
|
}
|
|
|
|
sleep() {
|
|
SECONDS=$((SECONDS + \${1%%.*}))
|
|
}
|
|
|
|
kill_checks=0
|
|
kill() {
|
|
if [[ "\${1:-}" == "-0" && "\${2:-}" == "sampled-docker-pid" ]]; then
|
|
kill_checks=$((kill_checks + 1))
|
|
[[ "$kill_checks" -le 6 ]]
|
|
return
|
|
fi
|
|
command kill "$@"
|
|
}
|
|
|
|
stats_log="$TMPDIR/stats.log"
|
|
run_log="$TMPDIR/run.log"
|
|
sampler_log="$TMPDIR/sampler.log"
|
|
printf "container output\\n" >"$run_log"
|
|
|
|
docker_e2e_sample_stats_until_exit demo sampled-docker-pid "$stats_log" "$run_log" "Docker stats" 08 >"$sampler_log" 2>&1
|
|
output="$(cat "$sampler_log")"
|
|
|
|
[[ "$output" =~ Docker\\ stats\\ still\\ running\\ \\(([0-9]+)s\\ elapsed, ]]
|
|
heartbeat_elapsed="\${BASH_REMATCH[1]}"
|
|
(( heartbeat_elapsed >= 8 ))
|
|
[[ "$output" != *"value too great for base"* ]]
|
|
[[ -s "$stats_log" ]]
|
|
`,
|
|
},
|
|
])("$title", ({ tempPrefix, scriptSource }) => {
|
|
const workDir = tempDirs.make(tempPrefix);
|
|
const script = scriptSource(workDir);
|
|
|
|
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
|
|
});
|
|
|
|
it("derives the browser CDP image from the shared functional image", () => {
|
|
const workDir = tempDirs.make("openclaw-browser-cdp-shared-image-");
|
|
writeExecutables(join(workDir, "bin"), {
|
|
docker: `#!/usr/bin/env bash
|
|
printf "%s\\n" "$*" >>"$TMPDIR/docker-seen"
|
|
case "$1 $2" in
|
|
"image inspect")
|
|
exit 0
|
|
;;
|
|
"inspect -f")
|
|
printf "true\\n"
|
|
exit 0
|
|
;;
|
|
"rm -f")
|
|
exit 0
|
|
;;
|
|
"run "*)
|
|
printf "container-id\\n"
|
|
exit 0
|
|
;;
|
|
"exec "*)
|
|
exit 0
|
|
;;
|
|
esac
|
|
case "$1" in
|
|
build)
|
|
exit 0
|
|
;;
|
|
esac
|
|
exit 9
|
|
`,
|
|
node: `#!/usr/bin/env bash
|
|
printf "echo state\\n"
|
|
`,
|
|
timeout: `#!/usr/bin/env bash
|
|
case "\${1:-}" in
|
|
--kill-after=1s | --kill-after=30s)
|
|
shift 2
|
|
;;
|
|
*)
|
|
shift
|
|
;;
|
|
esac
|
|
exec "$@"
|
|
`,
|
|
});
|
|
|
|
const script = repoRootShell`
|
|
TMPDIR=${shellQuote(workDir)}
|
|
export ROOT_DIR TMPDIR
|
|
export PATH="$TMPDIR/bin:$PATH"
|
|
export OPENCLAW_SKIP_DOCKER_BUILD=1
|
|
export OPENCLAW_DOCKER_E2E_IMAGE=shared-functional
|
|
export OPENCLAW_DOCKER_ALL_LANE_NAME=browser-cdp-snapshot
|
|
|
|
bash "$ROOT_DIR/scripts/e2e/browser-cdp-snapshot-docker.sh"
|
|
|
|
grep -q '^image inspect shared-functional$' "$TMPDIR/docker-seen"
|
|
grep -Fq 'build -t openclaw-browser-cdp-snapshot-e2e:browser-cdp-snapshot' "$TMPDIR/docker-seen"
|
|
grep -Fq ' openclaw-browser-cdp-snapshot-e2e:browser-cdp-snapshot ' "$TMPDIR/docker-seen"
|
|
if grep -Fq ' shared-functional ' "$TMPDIR/docker-seen"; then
|
|
echo "browser CDP lane reused the shared image without Chromium" >&2
|
|
exit 1
|
|
fi
|
|
`;
|
|
|
|
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
|
|
});
|
|
|
|
it("fails fast on invalid browser CDP snapshot byte limits", () => {
|
|
const result = spawnSync("bash", [BROWSER_CDP_SNAPSHOT_DOCKER_E2E_PATH], {
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
OPENCLAW_BROWSER_CDP_SNAPSHOT_MAX_BYTES: "64kb",
|
|
OPENCLAW_SKIP_DOCKER_BUILD: "1",
|
|
},
|
|
});
|
|
|
|
expect(result.status).toBe(2);
|
|
expect(result.stderr).toContain("invalid OPENCLAW_BROWSER_CDP_SNAPSHOT_MAX_BYTES: 64kb");
|
|
});
|
|
|
|
it("forwards browser CDP snapshot byte limits into the Docker runner", () => {
|
|
const runner = readFileSync(BROWSER_CDP_SNAPSHOT_DOCKER_E2E_PATH, "utf8");
|
|
expect(runner).toContain(
|
|
"docker_e2e_read_positive_int_env OPENCLAW_BROWSER_CDP_SNAPSHOT_MAX_BYTES 524288",
|
|
);
|
|
expect(runner).toContain('-e "OPENCLAW_BROWSER_CDP_SNAPSHOT_MAX_BYTES=$SNAPSHOT_MAX_BYTES"');
|
|
});
|
|
|
|
it("uses Playwright Chromium for the browser CDP snapshot image", () => {
|
|
const runner = readFileSync(BROWSER_CDP_SNAPSHOT_DOCKER_E2E_PATH, "utf8");
|
|
expect(runner).toContain("ENV PLAYWRIGHT_BROWSERS_PATH=/home/appuser/.cache/ms-playwright");
|
|
expect(runner).toContain("playwright-core/cli.js install --with-deps chromium");
|
|
expect(runner).not.toContain("apt-get install -y --no-install-recommends chromium");
|
|
});
|
|
|
|
it("opens the browser CDP fixture before snapshotting", () => {
|
|
const runner = readFileSync(BROWSER_CDP_SNAPSHOT_DOCKER_E2E_PATH, "utf8");
|
|
const quarantineIndex = runner.indexOf("mkdir -p /tmp/openclaw-browser-cdp");
|
|
const configIndex = runner.indexOf("node scripts/e2e/lib/fixture.mjs browser-cdp");
|
|
const openIndex = runner.indexOf(
|
|
'browser \\"\\${base_args[@]}\\" --browser-profile docker-cdp open',
|
|
);
|
|
const doctorIndex = runner.indexOf(
|
|
'browser \\"\\${base_args[@]}\\" --browser-profile docker-cdp doctor --deep',
|
|
);
|
|
const snapshotIndex = runner.indexOf(
|
|
'browser \\"\\${base_args[@]}\\" --browser-profile docker-cdp snapshot --interactive',
|
|
);
|
|
|
|
expect(quarantineIndex).toBeGreaterThan(-1);
|
|
expect(configIndex).toBeGreaterThan(-1);
|
|
expect(configIndex).toBeGreaterThan(quarantineIndex);
|
|
expect(openIndex).toBeGreaterThan(-1);
|
|
expect(openIndex).toBeGreaterThan(configIndex);
|
|
expect(doctorIndex).toBeGreaterThan(openIndex);
|
|
expect(snapshotIndex).toBeGreaterThan(doctorIndex);
|
|
expect(runner).toContain(">/tmp/browser-cdp-doctor.txt 2>&1 || true");
|
|
expect(runner).toContain("failed to disable Playwright AI snapshot chunk");
|
|
});
|
|
|
|
it("fails Docker commands fast when timeout is unavailable", () => {
|
|
const workDir = tempDirs.make("openclaw-docker-timeout-required-");
|
|
mkdirSync(join(workDir, "bin"));
|
|
const script = repoShell(workDir)`
|
|
export PATH="$TMPDIR/bin"
|
|
export DOCKER_COMMAND_TIMEOUT=7s
|
|
|
|
docker() {
|
|
printf "%s\\n" "$*" >"$TMPDIR/docker-seen"
|
|
}
|
|
export -f docker
|
|
|
|
source "$ROOT_DIR/scripts/lib/docker-e2e-container.sh"
|
|
|
|
set +e
|
|
docker_e2e_docker_cmd ps 2>"$TMPDIR/stderr"
|
|
status="$?"
|
|
set -e
|
|
|
|
stderr="$(<"$TMPDIR/stderr")"
|
|
[[ "$status" = "127" ]]
|
|
[[ "$stderr" = *"timeout command not found; cannot bound Docker command after 7s"* ]]
|
|
[[ ! -e "$TMPDIR/docker-seen" ]]
|
|
`;
|
|
|
|
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
|
|
});
|
|
|
|
it("uses a Node watchdog for Docker commands when timeout is unavailable", () => {
|
|
const workDir = tempDirs.make("openclaw-docker-node-timeout-");
|
|
writeExecutables(join(workDir, "bin"), {
|
|
node: `#!/bin/bash\nexec ${shellQuote(process.execPath)} "$@"\n`,
|
|
docker: `#!/bin/bash\ninput="$(/bin/cat)"\nprintf "%s|%s\\n" "$*" "$input" >"$TMPDIR/docker-seen"\nexit 13\n`,
|
|
});
|
|
|
|
const script = repoShell(workDir)`
|
|
export PATH="$TMPDIR/bin"
|
|
export DOCKER_COMMAND_TIMEOUT=7s
|
|
unset OPENCLAW_DOCKER_E2E_DISABLE_RESOURCE_LIMITS
|
|
unset OPENCLAW_DOCKER_E2E_MEMORY OPENCLAW_DOCKER_E2E_CPUS OPENCLAW_DOCKER_E2E_PIDS_LIMIT
|
|
|
|
source "$ROOT_DIR/scripts/lib/docker-e2e-container.sh"
|
|
|
|
set +e
|
|
printf payload | docker_e2e_docker_cmd run -i demo 2>"$TMPDIR/stderr"
|
|
status="$?"
|
|
set -e
|
|
|
|
stderr="$(<"$TMPDIR/stderr")"
|
|
[[ "$status" = "13" ]]
|
|
[[ "$stderr" = *"timeout command not found; using Node watchdog for Docker command timeout 7s"* ]]
|
|
[[ "$(<"$TMPDIR/docker-seen")" = "run --memory 8g --cpus 16 --pids-limit 2048 -i demo|payload" ]]
|
|
`;
|
|
|
|
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
|
|
});
|
|
|
|
it("adds default Docker run resource limits without overriding explicit limits", () => {
|
|
const workDir = tempDirs.make("openclaw-docker-resource-limits-");
|
|
writeExecutables(join(workDir, "bin"), {
|
|
timeout: `#!/bin/bash
|
|
set -euo pipefail
|
|
if [[ "$1" = "--kill-after=1s" ]]; then
|
|
exit 0
|
|
fi
|
|
shift 2
|
|
"$@"
|
|
`,
|
|
});
|
|
|
|
const script = repoShell(workDir)`
|
|
export PATH="$TMPDIR/bin:$PATH"
|
|
unset OPENCLAW_DOCKER_E2E_DISABLE_RESOURCE_LIMITS
|
|
unset OPENCLAW_DOCKER_E2E_MEMORY OPENCLAW_DOCKER_E2E_CPUS OPENCLAW_DOCKER_E2E_PIDS_LIMIT
|
|
export OPENCLAW_DOCKER_E2E_AVAILABLE_CPUS=32
|
|
|
|
docker() {
|
|
printf "%s\\n" "$*" >>"$TMPDIR/docker-seen"
|
|
}
|
|
export -f docker
|
|
|
|
source "$ROOT_DIR/scripts/lib/docker-e2e-container.sh"
|
|
|
|
docker_e2e_docker_cmd run demo
|
|
OPENCLAW_DOCKER_E2E_MEMORY=12g OPENCLAW_DOCKER_E2E_CPUS=4 OPENCLAW_DOCKER_E2E_PIDS_LIMIT=512 docker_e2e_docker_cmd run demo
|
|
OPENCLAW_DOCKER_E2E_AVAILABLE_CPUS=8 OPENCLAW_DOCKER_E2E_MEMORY=12g OPENCLAW_DOCKER_E2E_CPUS=16 OPENCLAW_DOCKER_E2E_PIDS_LIMIT=512 docker_e2e_docker_cmd run demo
|
|
docker_e2e_docker_cmd run --memory 2g --cpus 3 --pids-limit 99 demo
|
|
OPENCLAW_DOCKER_E2E_DISABLE_RESOURCE_LIMITS=1 docker_e2e_docker_cmd run demo
|
|
|
|
[[ "$(sed -n '1p' "$TMPDIR/docker-seen")" = "run --memory 8g --cpus 16 --pids-limit 2048 demo" ]]
|
|
[[ "$(sed -n '2p' "$TMPDIR/docker-seen")" = "run --memory 12g --cpus 4 --pids-limit 512 demo" ]]
|
|
[[ "$(sed -n '3p' "$TMPDIR/docker-seen")" = "run --memory 12g --cpus 8 --pids-limit 512 demo" ]]
|
|
[[ "$(sed -n '4p' "$TMPDIR/docker-seen")" = "run --memory 2g --cpus 3 --pids-limit 99 demo" ]]
|
|
[[ "$(sed -n '5p' "$TMPDIR/docker-seen")" = "run demo" ]]
|
|
`;
|
|
|
|
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
|
|
});
|
|
|
|
it("runs Docker when resource diagnostic capture is unavailable", () => {
|
|
const workDir = tempDirs.make("openclaw-docker-resource-no-temp-");
|
|
const missingTmpDir = join(workDir, "missing");
|
|
const script = repoRootShell`
|
|
TMPDIR=${shellQuote(missingTmpDir)}
|
|
export ROOT_DIR TMPDIR
|
|
export OPENCLAW_DOCKER_E2E_AVAILABLE_CPUS=8
|
|
unset OPENCLAW_DOCKER_E2E_DISABLE_RESOURCE_LIMITS
|
|
unset OPENCLAW_DOCKER_E2E_MEMORY OPENCLAW_DOCKER_E2E_CPUS OPENCLAW_DOCKER_E2E_PIDS_LIMIT
|
|
|
|
docker() {
|
|
printf "%s\\n" "$*" >>${shellQuote(join(workDir, "docker-seen"))}
|
|
return 7
|
|
}
|
|
|
|
source "$ROOT_DIR/scripts/lib/docker-e2e-container.sh"
|
|
docker_e2e_timeout_cmd() {
|
|
shift
|
|
"$@"
|
|
}
|
|
|
|
set +e
|
|
docker_e2e_docker_cmd run demo 2>/dev/null
|
|
status="$?"
|
|
set -e
|
|
|
|
[[ "$status" = "7" ]]
|
|
[[ "$(grep -c '^run ' ${shellQuote(join(workDir, "docker-seen"))})" = "1" ]]
|
|
`;
|
|
|
|
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
|
|
});
|
|
|
|
for (const [shellSignal, expectedStatus] of [
|
|
["TERM", "143"],
|
|
["HUP", "129"],
|
|
] as const) {
|
|
it(`escalates Docker watchdog children that ignore parent SIG${shellSignal}`, () => {
|
|
const workDir = tempDirs.make("openclaw-docker-node-signal-");
|
|
writeExecutables(join(workDir, "bin"), {
|
|
node: `#!/bin/bash\nexec ${shellQuote(process.execPath)} "$@"\n`,
|
|
docker: `#!/bin/bash
|
|
printf "%s\\n" "$$" >"$TMPDIR/docker-pid"
|
|
printf "%s\\n" "$PPID" >"$TMPDIR/watchdog-pid"
|
|
trap "" TERM HUP
|
|
while true; do /bin/sleep 1; done
|
|
`,
|
|
});
|
|
|
|
const script = repoRootShell`
|
|
TMPDIR=${shellQuote(workDir)}
|
|
export ROOT_DIR TMPDIR
|
|
export PATH="$TMPDIR/bin"
|
|
export DOCKER_COMMAND_TIMEOUT=30s
|
|
export OPENCLAW_DOCKER_TIMEOUT_KILL_GRACE_MS=100
|
|
|
|
source "$ROOT_DIR/scripts/lib/docker-e2e-container.sh"
|
|
|
|
docker_e2e_docker_cmd run demo &
|
|
watchdog_pid="$!"
|
|
for ((i = 0; i < 100; i += 1)); do
|
|
[ -s "$TMPDIR/docker-pid" ] && [ -s "$TMPDIR/watchdog-pid" ] && break
|
|
/bin/sleep 0.02
|
|
done
|
|
[ -s "$TMPDIR/docker-pid" ]
|
|
[ -s "$TMPDIR/watchdog-pid" ]
|
|
kill -${shellSignal} "$(/bin/cat "$TMPDIR/watchdog-pid")"
|
|
set +e
|
|
wait "$watchdog_pid"
|
|
status="$?"
|
|
set -e
|
|
[ "$status" = "${expectedStatus}" ]
|
|
docker_pid="$(/bin/cat "$TMPDIR/docker-pid")"
|
|
for ((i = 0; i < 100; i += 1)); do
|
|
kill -0 "$docker_pid" 2>/dev/null || exit 0
|
|
/bin/sleep 0.02
|
|
done
|
|
echo "docker child still alive after watchdog termination" >&2
|
|
exit 1
|
|
`;
|
|
|
|
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
|
|
});
|
|
}
|
|
|
|
it("uses plain timeout when kill-after is unsupported", () => {
|
|
const workDir = tempDirs.make("openclaw-docker-plain-timeout-");
|
|
writeExecutables(join(workDir, "bin"), {
|
|
timeout: `#!/bin/bash
|
|
set -euo pipefail
|
|
if [[ "$1" = "--kill-after=1s" ]]; then
|
|
exit 1
|
|
fi
|
|
printf 'plain:%s|%s\\n' "$1" "\${*:2}" >>"$TMPDIR/timeout-seen"
|
|
shift
|
|
"$@"
|
|
`,
|
|
});
|
|
|
|
const script = repoShell(workDir)`
|
|
export PATH="$TMPDIR/bin:$PATH"
|
|
export DOCKER_COMMAND_TIMEOUT=9s
|
|
|
|
docker() {
|
|
printf "%s\\n" "$*" >>"$TMPDIR/docker-seen"
|
|
}
|
|
export -f docker
|
|
|
|
source "$ROOT_DIR/scripts/lib/docker-e2e-container.sh"
|
|
|
|
docker_e2e_docker_cmd image inspect demo
|
|
|
|
grep -q '^plain:9s|docker image inspect demo$' "$TMPDIR/timeout-seen"
|
|
grep -q '^image inspect demo$' "$TMPDIR/docker-seen"
|
|
`;
|
|
|
|
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
|
|
});
|
|
|
|
it("uses gtimeout when timeout is unavailable", () => {
|
|
const workDir = tempDirs.make("openclaw-docker-gtimeout-");
|
|
writeExecutables(join(workDir, "bin"), {
|
|
gtimeout: `#!/bin/bash
|
|
set -euo pipefail
|
|
if [[ "$1" = "--kill-after=1s" ]]; then
|
|
exit 0
|
|
fi
|
|
printf 'gtimeout:%s %s|%s\\n' "$1" "$2" "\${*:3}" >>"$TMPDIR/timeout-seen"
|
|
shift 2
|
|
"$@"
|
|
`,
|
|
});
|
|
|
|
const script = repoShell(workDir)`
|
|
export PATH="$TMPDIR/bin"
|
|
export OPENCLAW_DOCKER_E2E_RUN_TIMEOUT=13s
|
|
export OPENCLAW_DOCKER_E2E_AVAILABLE_CPUS=8
|
|
unset OPENCLAW_DOCKER_E2E_DISABLE_RESOURCE_LIMITS
|
|
unset OPENCLAW_DOCKER_E2E_MEMORY OPENCLAW_DOCKER_E2E_CPUS OPENCLAW_DOCKER_E2E_PIDS_LIMIT
|
|
|
|
docker() {
|
|
printf "%s\\n" "$*" >>"$TMPDIR/docker-seen"
|
|
}
|
|
export -f docker
|
|
|
|
source "$ROOT_DIR/scripts/lib/docker-e2e-container.sh"
|
|
|
|
docker_e2e_docker_run_cmd run demo
|
|
|
|
[[ "$(<"$TMPDIR/timeout-seen")" = "gtimeout:--kill-after=30s 13s|docker run --memory 8g --cpus 8 --pids-limit 2048 demo" ]]
|
|
[[ "$(<"$TMPDIR/docker-seen")" = "run --memory 8g --cpus 8 --pids-limit 2048 demo" ]]
|
|
`;
|
|
|
|
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
|
|
});
|
|
|
|
it("keeps package-backed Docker runs bounded when the package helper is sourced directly", () => {
|
|
const workDir = tempDirs.make("openclaw-docker-package-timeout-required-");
|
|
mkdirSync(join(workDir, "bin"));
|
|
const script = repoShell(workDir)`
|
|
export PATH="$TMPDIR/bin"
|
|
export OPENCLAW_DOCKER_E2E_RUN_TIMEOUT=11s
|
|
|
|
dirname() {
|
|
/usr/bin/dirname "$@"
|
|
}
|
|
|
|
docker() {
|
|
printf "%s\\n" "$*" >"$TMPDIR/docker-seen"
|
|
}
|
|
export -f docker
|
|
|
|
source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh"
|
|
|
|
set +e
|
|
docker_e2e_docker_run_cmd run demo 2>"$TMPDIR/stderr"
|
|
status="$?"
|
|
set -e
|
|
|
|
stderr="$(<"$TMPDIR/stderr")"
|
|
[[ "$status" = "127" ]]
|
|
[[ "$stderr" = *"timeout command not found; cannot bound Docker command after 11s"* ]]
|
|
[[ ! -e "$TMPDIR/docker-seen" ]]
|
|
`;
|
|
|
|
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
|
|
});
|
|
|
|
it("uses gtimeout for package-backed Docker runs sourced through the package helper", () => {
|
|
const workDir = tempDirs.make("openclaw-docker-package-gtimeout-");
|
|
writeExecutables(join(workDir, "bin"), {
|
|
gtimeout: `#!/bin/bash
|
|
set -euo pipefail
|
|
if [[ "$1" = "--kill-after=1s" ]]; then
|
|
exit 0
|
|
fi
|
|
printf 'gtimeout:%s %s|%s\\n' "$1" "$2" "\${*:3}" >>"$TMPDIR/timeout-seen"
|
|
shift 2
|
|
"$@"
|
|
`,
|
|
});
|
|
|
|
const script = repoShell(workDir)`
|
|
export PATH="$TMPDIR/bin"
|
|
export OPENCLAW_DOCKER_E2E_RUN_TIMEOUT=15s
|
|
export OPENCLAW_DOCKER_E2E_AVAILABLE_CPUS=8
|
|
unset OPENCLAW_DOCKER_E2E_DISABLE_RESOURCE_LIMITS
|
|
unset OPENCLAW_DOCKER_E2E_MEMORY OPENCLAW_DOCKER_E2E_CPUS OPENCLAW_DOCKER_E2E_PIDS_LIMIT
|
|
|
|
dirname() {
|
|
/usr/bin/dirname "$@"
|
|
}
|
|
|
|
docker() {
|
|
printf "%s\\n" "$*" >>"$TMPDIR/docker-seen"
|
|
}
|
|
export -f docker
|
|
|
|
source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh"
|
|
|
|
docker_e2e_docker_run_cmd run demo
|
|
|
|
[[ "$(<"$TMPDIR/timeout-seen")" = "gtimeout:--kill-after=30s 15s|docker run --memory 8g --cpus 8 --pids-limit 2048 demo" ]]
|
|
[[ "$(<"$TMPDIR/docker-seen")" = "run --memory 8g --cpus 8 --pids-limit 2048 demo" ]]
|
|
`;
|
|
|
|
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
|
|
});
|
|
|
|
it("passes plugin lifecycle sampler timeout overrides into Docker", () => {
|
|
const runner = readFileSync(PLUGIN_LIFECYCLE_MATRIX_DOCKER_E2E_PATH, "utf8");
|
|
expectTextToIncludeAll(runner, [
|
|
"append_positive_int_env()",
|
|
"append_positive_number_env()",
|
|
"append_positive_int_env OPENCLAW_PLUGIN_LIFECYCLE_PHASE_TIMEOUT_MS",
|
|
"append_positive_int_env OPENCLAW_PLUGIN_LIFECYCLE_TIMEOUT_KILL_GRACE_MS",
|
|
"append_positive_int_env OPENCLAW_PLUGIN_LIFECYCLE_METRIC_POLL_MS",
|
|
"append_positive_int_env OPENCLAW_PLUGIN_LIFECYCLE_MAX_RSS_KB",
|
|
"append_positive_int_env OPENCLAW_PLUGIN_LIFECYCLE_MAX_WALL_MS",
|
|
"append_positive_number_env OPENCLAW_PLUGIN_LIFECYCLE_MAX_CPU_CORE_RATIO",
|
|
'docker_e2e_run_with_harness \\\n "${DOCKER_ENV_ARGS[@]}"',
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
["phase timeout", "OPENCLAW_PLUGIN_LIFECYCLE_PHASE_TIMEOUT_MS", "150ms"],
|
|
["CPU ratio", "OPENCLAW_PLUGIN_LIFECYCLE_MAX_CPU_CORE_RATIO", "0"],
|
|
])(
|
|
"rejects invalid plugin lifecycle Docker %s overrides before package setup",
|
|
(_label, envName, value) => {
|
|
const result = spawnSync("bash", [PLUGIN_LIFECYCLE_MATRIX_DOCKER_E2E_PATH], {
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
OPENCLAW_CURRENT_PACKAGE_TGZ: "/tmp/openclaw-missing-package.tgz",
|
|
[envName]: value,
|
|
},
|
|
});
|
|
|
|
expect(result.status).toBe(2);
|
|
expect(result.stderr).toContain(`invalid ${envName}: ${value}`);
|
|
expect(result.stderr).not.toContain("OpenClaw package tarball does not exist");
|
|
},
|
|
);
|
|
|
|
it("wraps direct Docker E2E npm installs with the shared timeout helper", () => {
|
|
const multiNode = readFileSync(MULTI_NODE_UPDATE_DOCKER_E2E_PATH, "utf8");
|
|
const updateChannel = readFileSync(UPDATE_CHANNEL_SWITCH_DOCKER_E2E_PATH, "utf8");
|
|
const doctorSwitch = readFileSync(DOCTOR_SWITCH_SCENARIO_PATH, "utf8");
|
|
const releaseUpgrade = readFileSync(RELEASE_UPGRADE_USER_JOURNEY_SCENARIO_PATH, "utf8");
|
|
const upgradeSurvivor = readFileSync(UPGRADE_SURVIVOR_RUN_SCRIPT, "utf8");
|
|
const pluginCorrupt = readFileSync(PLUGIN_UPDATE_CORRUPT_SCENARIO_PATH, "utf8");
|
|
|
|
expect(multiNode).toContain(
|
|
'openclaw_e2e_install_package "$ARTIFACTS/install-a.log" "OpenClaw package under node-A prefix" "$NPM_PREFIX_A"',
|
|
);
|
|
expectTextToIncludeAll(updateChannel, [
|
|
'openclaw_e2e_maybe_timeout "${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-600s}" npm install --omit=optional --no-fund --no-audit',
|
|
'openclaw_e2e_maybe_timeout "${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-600s}" npm install -g --prefix /tmp/npm-prefix --omit=optional "$pkg_tgz_path"',
|
|
"openclaw_e2e_print_log /tmp/openclaw-git-install.log",
|
|
'openclaw_e2e_print_log "$package_install_log"',
|
|
]);
|
|
|
|
expect(updateChannel).not.toContain("cat /tmp/openclaw-git-install.log");
|
|
expect(updateChannel).not.toContain('cat "$package_install_log"');
|
|
expect(doctorSwitch).toContain(
|
|
'openclaw_e2e_maybe_timeout "${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-600s}" npm install --omit=optional --no-fund --no-audit',
|
|
);
|
|
expect(doctorSwitch).toContain(
|
|
'openclaw_e2e_maybe_timeout "${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-600s}" npm install -g --prefix /tmp/npm-prefix --omit=optional "$package_tgz"',
|
|
);
|
|
for (const script of [releaseUpgrade, upgradeSurvivor, pluginCorrupt]) {
|
|
expect(script).toContain(
|
|
'openclaw_e2e_maybe_timeout "${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-600s}" npm install -g',
|
|
);
|
|
}
|
|
});
|
|
|
|
it("keeps upgrade survivor mutable state off the host-mounted artifact tree", () => {
|
|
const runner = readFileSync(UPGRADE_SURVIVOR_DOCKER_E2E_PATH, "utf8");
|
|
const publishedRunner = readFileSync(UPGRADE_SURVIVOR_RUN_SCRIPT, "utf8");
|
|
|
|
for (const script of [runner, publishedRunner]) {
|
|
expectTextToIncludeAll(script, [
|
|
"openclaw-upgrade-survivor-runtime",
|
|
"OPENCLAW_UPGRADE_SURVIVOR_TMPDIR",
|
|
"OPENCLAW_UPGRADE_SURVIVOR_TEST_STATE_TMPDIR",
|
|
'export npm_config_cache="${OPENCLAW_UPGRADE_SURVIVOR_NPM_CACHE:-$OPENCLAW_UPGRADE_SURVIVOR_RUNTIME_ROOT/npm-cache}"',
|
|
'export NPM_CONFIG_CACHE="$npm_config_cache"',
|
|
'chmod 700 "$npm_config_cache" || true',
|
|
]);
|
|
|
|
expect(script).not.toContain('export TMPDIR="$ARTIFACT_ROOT/tmp"');
|
|
expect(script).not.toContain('export TMPDIR="$OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_ROOT/tmp"');
|
|
expect(script).not.toContain('export npm_config_cache="$ARTIFACT_ROOT/npm-cache"');
|
|
expect(script).not.toContain(
|
|
'export npm_config_cache="$OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_ROOT/npm-cache"',
|
|
);
|
|
}
|
|
});
|
|
|
|
it("lets upgrade survivor fixture registries resolve transitive public packages", () => {
|
|
const runner = readFileSync(UPGRADE_SURVIVOR_DOCKER_E2E_PATH, "utf8");
|
|
const publishedRunner = readFileSync(UPGRADE_SURVIVOR_RUN_SCRIPT, "utf8");
|
|
|
|
for (const script of [runner, publishedRunner]) {
|
|
expect(script).toContain("OPENCLAW_NPM_REGISTRY_UPSTREAM=https://registry.npmjs.org");
|
|
expect(script).toContain("node scripts/e2e/lib/plugins/npm-registry-server.mjs");
|
|
expect(script).toContain(
|
|
"read -r plugin_package_name plugin_package_version plugin_package_tarball",
|
|
);
|
|
expect(script).not.toContain("read -r package_name package_version package_tarball");
|
|
}
|
|
expect(runner).toContain('OPENCLAW_NPM_REGISTRY_DIST_TAGS="beta=$package_version"');
|
|
expect(publishedRunner).toContain('OPENCLAW_NPM_REGISTRY_DIST_TAGS="beta=$candidate_version"');
|
|
});
|
|
|
|
it("starts the upgrade survivor plugin registry before updates with scenario-owned config", () => {
|
|
const runner = readFileSync(UPGRADE_SURVIVOR_DOCKER_E2E_PATH, "utf8");
|
|
const publishedRunner = readFileSync(UPGRADE_SURVIVOR_RUN_SCRIPT, "utf8");
|
|
const updateRestartAuth = readFileSync(UPGRADE_SURVIVOR_UPDATE_RESTART_AUTH_PATH, "utf8");
|
|
|
|
expect(runner.indexOf("\nconfigure_plugin_registry\n")).toBeLessThan(
|
|
runner.indexOf('\necho "Running package update against the mounted tarball..."\n'),
|
|
);
|
|
expect(
|
|
publishedRunner.indexOf("phase configure-plugin-registry configure_plugin_registry"),
|
|
).toBeLessThan(publishedRunner.indexOf("phase update-candidate update_candidate"));
|
|
const runnerClawHubIndex = runner.indexOf("\nconfigure_clawhub_fixture\n");
|
|
const runnerPrepareIndex = runner.indexOf(
|
|
'prepare_update_restart_probe_current_install "$PORT" "$GATEWAY_LOG"',
|
|
);
|
|
const runnerPluginRegistryIndex = runner.indexOf("\nconfigure_plugin_registry\n");
|
|
expect(runnerClawHubIndex).toBeGreaterThan(-1);
|
|
expect(runnerClawHubIndex).toBeLessThan(runnerPrepareIndex);
|
|
expect(runnerPrepareIndex).toBeLessThan(runnerPluginRegistryIndex);
|
|
expect(runnerPluginRegistryIndex).toBeLessThan(
|
|
runner.indexOf('\necho "Running package update against the mounted tarball..."\n'),
|
|
);
|
|
const publishedClawHubIndex = publishedRunner.indexOf(
|
|
"phase configure-clawhub-fixture configure_clawhub_fixture",
|
|
);
|
|
const publishedPrepareIndex = publishedRunner.indexOf(
|
|
"phase prepare-update-restart-probe prepare_update_restart_probe",
|
|
);
|
|
const publishedPluginRegistryIndex = publishedRunner.indexOf(
|
|
"phase configure-plugin-registry configure_plugin_registry",
|
|
);
|
|
expect(publishedClawHubIndex).toBeGreaterThan(-1);
|
|
expect(publishedClawHubIndex).toBeLessThan(publishedPrepareIndex);
|
|
expect(publishedPrepareIndex).toBeLessThan(publishedPluginRegistryIndex);
|
|
expect(publishedPluginRegistryIndex).toBeLessThan(
|
|
publishedRunner.indexOf("phase update-candidate update_candidate"),
|
|
);
|
|
expect(publishedRunner.indexOf("phase update-candidate update_candidate")).toBeLessThan(
|
|
publishedRunner.indexOf("phase assert-prepublish-requests node"),
|
|
);
|
|
expect(publishedRunner).toContain('if [ "$candidate_version" = "2026.6.35" ]; then');
|
|
expect(publishedRunner).toContain('prepublish_package="@openclaw/whatsapp"');
|
|
expect(publishedRunner).toContain('if [ "$SCENARIO" = "configured-plugin-installs" ]; then');
|
|
expect(publishedRunner).toContain('prepublish_package="@openclaw/matrix"');
|
|
expect(publishedRunner).toContain(
|
|
'assert-prepublish-requests "$OPENCLAW_CLAWHUB_URL" "$prepublish_package" "$candidate_version"',
|
|
);
|
|
expect(publishedRunner).toContain(
|
|
'local tarball="$fixture_root/openclaw-brave-plugin-${candidate_version}.tgz"',
|
|
);
|
|
expect(publishedRunner).toContain('FIXTURE_PACKAGE_VERSION="$candidate_version"');
|
|
expect(publishedRunner).toContain("version,");
|
|
expect(publishedRunner).toContain(
|
|
'registry_args+=("@openclaw/brave-plugin" "$candidate_version" "$tarball")',
|
|
);
|
|
expect(publishedRunner).toContain('"$clawhub_security_mode"');
|
|
expect(publishedRunner.indexOf("phase assert-prepublish-requests node")).toBeLessThan(
|
|
publishedRunner.indexOf("phase doctor run_doctor"),
|
|
);
|
|
expect(runner.indexOf('openclaw "${update_args[@]}"')).toBeLessThan(
|
|
runner.indexOf(
|
|
'assert-prepublish-requests "$OPENCLAW_CLAWHUB_URL" "@openclaw/whatsapp" "$package_version"',
|
|
),
|
|
);
|
|
expect(
|
|
runner.indexOf(
|
|
'assert-prepublish-requests "$OPENCLAW_CLAWHUB_URL" "@openclaw/whatsapp" "$package_version"',
|
|
),
|
|
).toBeLessThan(runner.indexOf("openclaw doctor --fix --non-interactive"));
|
|
expect(runner).toContain(
|
|
'if [ "${OPENCLAW_UPGRADE_SURVIVOR_SCENARIO:-base}" = "feishu-channel" ]; then',
|
|
);
|
|
expect(publishedRunner).toContain('if [ "$SCENARIO" = "feishu-channel" ]; then');
|
|
expect(publishedRunner).toContain(
|
|
[
|
|
'if [ "$SCENARIO" = "configured-plugin-installs" ]; then',
|
|
' export MATRIX_ACCESS_TOKEN="upgrade-survivor-matrix-token"',
|
|
' export BRAVE_API_KEY="BSA_upgrade_survivor_brave_key"',
|
|
"fi",
|
|
].join("\n"),
|
|
);
|
|
for (const script of [runner, publishedRunner]) {
|
|
expectTextToIncludeAll(script, [
|
|
"prepublish-artifacts",
|
|
"prepublish-plugin-registry.json",
|
|
"unset OPENCLAW_CLAWHUB_URL CLAWHUB_URL",
|
|
'export OPENCLAW_CLAWHUB_URL="http://127.0.0.1:$(cat "$port_file")"',
|
|
'openclaw_e2e_stop_process "${clawhub_fixture_pid:-}"',
|
|
"assert-prepublish-requests",
|
|
]);
|
|
expect(script).not.toContain("CLAWHUB_EXPECTED_VERSION");
|
|
expect(script).not.toContain("/__fixture__/requests");
|
|
expect(script).not.toContain("https://clawhub.ai");
|
|
const emptyRegistryGuardIndex = script.indexOf('if [ "${#registry_args[@]}" -eq 0 ]; then');
|
|
const fixtureDirectoryIndex = script.indexOf(
|
|
'mkdir -p "$fixture_root"',
|
|
emptyRegistryGuardIndex,
|
|
);
|
|
const registryServerIndex = script.indexOf(
|
|
"OPENCLAW_NPM_REGISTRY_UPSTREAM=https://registry.npmjs.org",
|
|
);
|
|
expect(emptyRegistryGuardIndex).toBeGreaterThanOrEqual(0);
|
|
expect(fixtureDirectoryIndex).toBeGreaterThanOrEqual(0);
|
|
expect(registryServerIndex).toBeGreaterThanOrEqual(0);
|
|
expect(emptyRegistryGuardIndex).toBeLessThan(fixtureDirectoryIndex);
|
|
expect(fixtureDirectoryIndex).toBeLessThan(registryServerIndex);
|
|
expect(script).not.toContain('\nexport FEISHU_APP_SECRET="upgrade-survivor-feishu-secret"\n');
|
|
}
|
|
expectTextToIncludeAll(publishedRunner, [
|
|
"park_prepublish_authored_config",
|
|
"park-prepublish-auth-config",
|
|
"assert_prepublish_fixture_idle",
|
|
"assert-no-requests",
|
|
"restore_prepublish_authored_config",
|
|
"restore-prepublish-auth-config",
|
|
"cmp -s",
|
|
"'^(GATEWAY_AUTH_TOKEN_REF|OPENCLAW_CLAWHUB_URL)='",
|
|
"OPENCLAW_CLAWHUB_URL=%s",
|
|
]);
|
|
expect(publishedRunner.indexOf("park_prepublish_authored_config")).toBeLessThan(
|
|
publishedRunner.lastIndexOf("assert_prepublish_fixture_idle"),
|
|
);
|
|
expect(publishedRunner.lastIndexOf("assert_prepublish_fixture_idle")).toBeLessThan(
|
|
publishedRunner.lastIndexOf("restore_prepublish_authored_config"),
|
|
);
|
|
expect(publishedRunner.lastIndexOf("restore_prepublish_authored_config")).toBeLessThan(
|
|
publishedRunner.lastIndexOf("write_update_restart_service_env"),
|
|
);
|
|
for (const script of [runner, updateRestartAuth]) {
|
|
expect(script).not.toContain("park-prepublish-auth-config");
|
|
expect(script).not.toContain("assert-no-requests");
|
|
}
|
|
expect(publishedRunner).not.toContain(
|
|
'\nexport MATRIX_ACCESS_TOKEN="upgrade-survivor-matrix-token"\n',
|
|
);
|
|
expect(publishedRunner).not.toContain(
|
|
'\nexport BRAVE_API_KEY="BSA_upgrade_survivor_brave_key"\n',
|
|
);
|
|
expect(
|
|
runner.match(
|
|
/-v "\$HARNESS_ROOT_DIR\/scripts\/e2e\/lib\/clawhub-fixture-server\.cjs:\/tmp\/openclaw-clawhub-fixture-server\.cjs:ro"/gu,
|
|
),
|
|
).toHaveLength(2);
|
|
expect(
|
|
runner.match(
|
|
/-e OPENCLAW_UPGRADE_SURVIVOR_CLAWHUB_FIXTURE_SERVER=\/tmp\/openclaw-clawhub-fixture-server\.cjs/gu,
|
|
),
|
|
).toHaveLength(2);
|
|
});
|
|
|
|
it("keeps upgrade survivor wrappers and the embedded payload valid bash", () => {
|
|
for (const path of [UPGRADE_SURVIVOR_DOCKER_E2E_PATH, UPGRADE_SURVIVOR_RUN_SCRIPT]) {
|
|
const result = spawnSync("bash", ["-n", path], { encoding: "utf8" });
|
|
expect(result.status, result.stderr).toBe(0);
|
|
}
|
|
const wrapper = readFileSync(UPGRADE_SURVIVOR_DOCKER_E2E_PATH, "utf8");
|
|
const inner = spawnSync("bash", ["-n"], {
|
|
input: extractUpgradeSurvivorPayload(wrapper),
|
|
encoding: "utf8",
|
|
});
|
|
expect(inner.status, inner.stderr).toBe(0);
|
|
});
|
|
|
|
it("selects the live model test runner shipped by the staged candidate", () => {
|
|
for (const scriptPath of [
|
|
"scripts/test-live-gateway-models-docker.sh",
|
|
"scripts/test-live-models-docker.sh",
|
|
]) {
|
|
const script = readFileSync(scriptPath, "utf8");
|
|
const legacyRunnerIndex = script.indexOf("node scripts/test-live.mjs --");
|
|
const currentRunnerIndex = script.indexOf("node --import tsx scripts/test-live.mts --");
|
|
|
|
expect(script).toContain("if [[ -f scripts/test-live.mjs ]]; then");
|
|
expect(legacyRunnerIndex).toBeGreaterThan(-1);
|
|
expect(currentRunnerIndex).toBeGreaterThan(legacyRunnerIndex);
|
|
}
|
|
});
|
|
|
|
it("wraps package-backed scenario OpenClaw CLI calls with the shared timeout helper", () => {
|
|
const paths = [
|
|
CODEX_ON_DEMAND_DOCKER_E2E_PATH,
|
|
CODEX_MEDIA_PATH_SCENARIO_PATH,
|
|
CODEX_NPM_PLUGIN_LIVE_DOCKER_E2E_PATH,
|
|
LIVE_PLUGIN_TOOL_DOCKER_E2E_PATH,
|
|
NPM_ONBOARD_CHANNEL_AGENT_DOCKER_E2E_PATH,
|
|
UPDATE_CHANNEL_SWITCH_DOCKER_E2E_PATH,
|
|
RELEASE_UPGRADE_USER_JOURNEY_SCENARIO_PATH,
|
|
"scripts/e2e/lib/release-media-memory/scenario.sh",
|
|
"scripts/e2e/lib/release-plugin-marketplace/scenario.sh",
|
|
"scripts/e2e/lib/release-typed-onboarding/scenario.sh",
|
|
"scripts/e2e/lib/release-user-journey/scenario.sh",
|
|
];
|
|
|
|
for (const path of paths) {
|
|
const script = readFileSync(path, "utf8");
|
|
|
|
expect(script, path).toContain("openclaw_e2e_enable_openclaw_cli_timeout");
|
|
}
|
|
expect(readFileSync(RELEASE_UPGRADE_USER_JOURNEY_SCENARIO_PATH, "utf8")).toContain(
|
|
'openclaw_e2e_run_command node "$baseline_entry" onboard',
|
|
);
|
|
});
|
|
|
|
it("preserves actionable, secret-safe typed onboarding failure diagnostics", () => {
|
|
const script = readFileSync(RELEASE_TYPED_ONBOARDING_SCENARIO_PATH, "utf8");
|
|
expect(script).toContain("set -Eeuo pipefail");
|
|
expect(script).toContain("{ exec 3>&-; } 2>/dev/null || true");
|
|
expect(script).toContain("--suppress-gateway-token-output");
|
|
expect(script).not.toContain("exec 3>&- 2>/dev/null || true");
|
|
expect(script).not.toContain('"$HOME/.openclaw/agents/main/agent/auth-profiles.json"');
|
|
});
|
|
|
|
it("keeps append-only mock E2E state under per-run scratch roots", () => {
|
|
const scripts = [
|
|
{
|
|
path: RELEASE_TYPED_ONBOARDING_SCENARIO_PATH,
|
|
scratch:
|
|
'scenario_tmp="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-release-typed-onboarding.XXXXXX")"',
|
|
logDir: 'LOG_DIR="$scenario_tmp/logs"',
|
|
requestLog: 'MOCK_REQUEST_LOG="$scenario_tmp/openai-requests.jsonl"',
|
|
expectedPaths: [
|
|
'INSTALL_LOG="$LOG_DIR/install.log"',
|
|
'ONBOARD_LOG="$LOG_DIR/onboard.log"',
|
|
'OPENAI_LOG="$LOG_DIR/openai.log"',
|
|
'AGENT_LOG="$LOG_DIR/agent.log"',
|
|
'input_fifo_dir="$(mktemp -d "$scenario_tmp/input.XXXXXX")"',
|
|
],
|
|
removed: [
|
|
"/tmp/openclaw-release-typed-onboarding-openai.jsonl",
|
|
"/tmp/openclaw-release-typed-onboarding-install.log",
|
|
"/tmp/openclaw-release-typed-onboarding.log",
|
|
"/tmp/openclaw-release-typed-onboarding-openai.log",
|
|
"/tmp/openclaw-release-typed-onboarding-agent.log",
|
|
'mktemp -d "/tmp/openclaw-release-typed-onboarding.XXXXXX"',
|
|
],
|
|
},
|
|
{
|
|
path: RELEASE_USER_JOURNEY_SCENARIO_PATH,
|
|
scratch:
|
|
'scenario_tmp="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-release-user-journey.XXXXXX")"',
|
|
logDir: 'LOG_DIR="$scenario_tmp/logs"',
|
|
requestLog: 'MOCK_REQUEST_LOG="$scenario_tmp/openai-requests.jsonl"',
|
|
extraState: 'CLICKCLACK_STATE="$scenario_tmp/clickclack.json"',
|
|
expectedPaths: [
|
|
'INSTALL_LOG="$LOG_DIR/install.log"',
|
|
'ONBOARD_LOG="$LOG_DIR/onboard.log"',
|
|
'OPENAI_LOG="$LOG_DIR/openai.log"',
|
|
'AGENT_LOG="$LOG_DIR/agent.log"',
|
|
'PLUGIN_A_INSTALL_PATH_FILE="$scenario_tmp/plugin-a-install-path.txt"',
|
|
'PLUGIN_A_SOURCE_PATH_FILE="$scenario_tmp/plugin-a-source-path.txt"',
|
|
'plugin_a_dir="$(mktemp -d "$scenario_tmp/plugin-a.XXXXXX")"',
|
|
'plugin_b_dir="$(mktemp -d "$scenario_tmp/plugin-b.XXXXXX")"',
|
|
],
|
|
removed: [
|
|
"/tmp/openclaw-release-user-journey-openai.jsonl",
|
|
"/tmp/openclaw-release-user-journey-clickclack.json",
|
|
"/tmp/openclaw-release-user-journey-install.log",
|
|
"/tmp/openclaw-release-user-journey-onboard.log",
|
|
"/tmp/openclaw-release-user-journey-agent.log",
|
|
"/tmp/openclaw-release-user-journey-plugin-a-install-path.txt",
|
|
"/tmp/openclaw-release-user-journey-plugin-a-source-path.txt",
|
|
'mktemp -d "/tmp/openclaw-release-journey-plugin-a.XXXXXX"',
|
|
'mktemp -d "/tmp/openclaw-release-journey-plugin-b.XXXXXX"',
|
|
],
|
|
},
|
|
{
|
|
path: RELEASE_UPGRADE_USER_JOURNEY_SCENARIO_PATH,
|
|
scratch:
|
|
'scenario_tmp="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-release-upgrade-user-journey.XXXXXX")"',
|
|
logDir: 'LOG_DIR="$scenario_tmp/logs"',
|
|
requestLog: 'MOCK_REQUEST_LOG="$scenario_tmp/openai-requests.jsonl"',
|
|
extraState: 'CLICKCLACK_STATE="$scenario_tmp/clickclack.json"',
|
|
expectedPaths: [
|
|
'BASELINE_INSTALL_LOG="$LOG_DIR/baseline-install.log"',
|
|
'CANDIDATE_INSTALL_LOG="$LOG_DIR/candidate-install.log"',
|
|
'ONBOARD_LOG="$LOG_DIR/onboard.log"',
|
|
'OPENAI_LOG="$LOG_DIR/openai.log"',
|
|
'PLUGIN_INSTALL_LOG="$LOG_DIR/plugin-install.log"',
|
|
'AGENT_LOG="$LOG_DIR/agent.log"',
|
|
'plugin_dir="$(mktemp -d "$scenario_tmp/plugin.XXXXXX")"',
|
|
'plugins install "$plugin_dir" --force',
|
|
],
|
|
removed: [
|
|
"/tmp/openclaw-release-upgrade-user-journey-openai.jsonl",
|
|
"/tmp/openclaw-release-upgrade-user-journey-clickclack.json",
|
|
"/tmp/openclaw-release-upgrade-baseline-install.log",
|
|
"/tmp/openclaw-release-upgrade-candidate-install.log",
|
|
"/tmp/openclaw-release-upgrade-onboard.log",
|
|
"/tmp/openclaw-release-upgrade-agent.log",
|
|
'mktemp -d "/tmp/openclaw-release-upgrade-plugin.XXXXXX"',
|
|
],
|
|
},
|
|
{
|
|
path: NPM_ONBOARD_CHANNEL_AGENT_DOCKER_E2E_PATH,
|
|
scratch:
|
|
'scenario_tmp="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-npm-onboard-channel-agent.XXXXXX")"',
|
|
requestLog: 'MOCK_REQUEST_LOG="$scenario_tmp/mock-openai-requests.jsonl"',
|
|
removed: ["/tmp/openclaw-mock-openai-requests.jsonl"],
|
|
},
|
|
];
|
|
|
|
for (const {
|
|
path,
|
|
scratch,
|
|
logDir,
|
|
requestLog,
|
|
extraState,
|
|
expectedPaths,
|
|
removed,
|
|
} of scripts) {
|
|
const script = readFileSync(path, "utf8");
|
|
|
|
expect(script, path).toContain(scratch);
|
|
if (logDir) {
|
|
expect(script, path).toContain(logDir);
|
|
}
|
|
expect(script, path).toContain(requestLog);
|
|
expect(script, path).toContain('rm -rf "$scenario_tmp"');
|
|
if (extraState) {
|
|
expect(script, path).toContain(extraState);
|
|
}
|
|
for (const expectedPath of expectedPaths ?? []) {
|
|
expect(script, path).toContain(expectedPath);
|
|
}
|
|
for (const stalePath of removed) {
|
|
expect(script, path).not.toContain(stalePath);
|
|
}
|
|
expect(script, path).not.toMatch(/\/tmp\/openclaw-release-[\w-]+\.(?:log|json|err|txt)/u);
|
|
}
|
|
});
|
|
|
|
it("kills timed Docker scenario runners after the grace period", () => {
|
|
const multiNode = readFileSync(MULTI_NODE_UPDATE_DOCKER_E2E_PATH, "utf8");
|
|
const upgradeSurvivor = readFileSync(UPGRADE_SURVIVOR_DOCKER_E2E_PATH, "utf8");
|
|
|
|
expect(multiNode).toContain('timeout --kill-after=30s "$DOCKER_RUN_TIMEOUT" bash -lc');
|
|
expect(upgradeSurvivor).toContain(
|
|
'ROOT_DIR="$(cd "${OPENCLAW_DOCKER_E2E_REPO_ROOT:-$HARNESS_ROOT_DIR}" && pwd)"',
|
|
);
|
|
expect(upgradeSurvivor).toContain('DOCKER_E2E_HARNESS_ROOT_DIR="$HARNESS_ROOT_DIR"');
|
|
expect(upgradeSurvivor).toContain(
|
|
'-v "$HARNESS_ROOT_DIR/scripts/e2e/lib/upgrade-survivor/run.sh:/tmp/openclaw-upgrade-survivor-run.sh:ro"',
|
|
);
|
|
expect(upgradeSurvivor).toContain(
|
|
'timeout --kill-after=30s "$DOCKER_RUN_TIMEOUT" bash /tmp/openclaw-upgrade-survivor-run.sh',
|
|
);
|
|
expect(upgradeSurvivor).toContain('timeout --kill-after=30s "$DOCKER_RUN_TIMEOUT" bash -lc');
|
|
for (const script of [multiNode, upgradeSurvivor]) {
|
|
expect(script).not.toContain('timeout "$DOCKER_RUN_TIMEOUT"');
|
|
}
|
|
});
|
|
|
|
it("keeps multi-node update Docker artifacts isolated by default", () => {
|
|
const multiNode = readFileSync(MULTI_NODE_UPDATE_DOCKER_E2E_PATH, "utf8");
|
|
expect(multiNode).toContain(
|
|
'RUN_ID="${OPENCLAW_MULTI_NODE_RUN_ID:-$(date -u +%Y%m%dT%H%M%SZ)-$$}"',
|
|
);
|
|
expect(multiNode).toContain(
|
|
'ARTIFACT_DIR="${OPENCLAW_MULTI_NODE_ARTIFACT_DIR:-$ROOT_DIR/.artifacts/multi-node-update/$RUN_ID}"',
|
|
);
|
|
expect(multiNode).toContain('-v "$ARTIFACT_DIR:/tmp/artifacts"');
|
|
expect(multiNode).not.toContain(
|
|
'ARTIFACT_DIR="${OPENCLAW_MULTI_NODE_ARTIFACT_DIR:-$ROOT_DIR/.artifacts/multi-node-update}"',
|
|
);
|
|
});
|
|
|
|
it("reuses the shared bare image for multi-node update targeted runs", () => {
|
|
const workDir = tempDirs.make("openclaw-multi-node-shared-image-");
|
|
writeFileSync(join(workDir, "openclaw-current.tgz"), "fake package");
|
|
writeExecutables(join(workDir, "bin"), {
|
|
docker: `#!/usr/bin/env bash
|
|
printf "%s\\n" "$*" >>"$TMPDIR/docker-seen"
|
|
case "$1 $2" in
|
|
"image inspect")
|
|
exit 0
|
|
;;
|
|
"run "*)
|
|
exit 0
|
|
;;
|
|
esac
|
|
exit 9
|
|
`,
|
|
timeout: `#!/usr/bin/env bash
|
|
case "\${1:-}" in
|
|
--kill-after=1s)
|
|
exit 0
|
|
;;
|
|
--kill-after=30s)
|
|
shift 2
|
|
;;
|
|
*)
|
|
shift
|
|
;;
|
|
esac
|
|
exec "$@"
|
|
`,
|
|
});
|
|
|
|
const script = repoRootShell`
|
|
TMPDIR=${shellQuote(workDir)}
|
|
export ROOT_DIR TMPDIR
|
|
export PATH="$TMPDIR/bin:$PATH"
|
|
export OPENCLAW_SKIP_DOCKER_BUILD=1
|
|
export OPENCLAW_DOCKER_E2E_IMAGE=shared-bare
|
|
export OPENCLAW_CURRENT_PACKAGE_TGZ="$TMPDIR/openclaw-current.tgz"
|
|
export OPENCLAW_MULTI_NODE_ARTIFACT_DIR="$TMPDIR/artifacts"
|
|
|
|
bash "$ROOT_DIR/scripts/e2e/multi-node-update-docker.sh"
|
|
|
|
grep -q '^image inspect shared-bare$' "$TMPDIR/docker-seen"
|
|
grep -Fq ' shared-bare ' "$TMPDIR/docker-seen"
|
|
if grep -Fq 'openclaw-multi-node-update-e2e' "$TMPDIR/docker-seen"; then
|
|
echo "multi-node update lane ignored the shared targeted image" >&2
|
|
exit 1
|
|
fi
|
|
`;
|
|
|
|
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
|
|
});
|
|
|
|
it("bounds upgrade survivor foreground OpenClaw CLI calls", () => {
|
|
const runner = readFileSync(UPGRADE_SURVIVOR_DOCKER_E2E_PATH, "utf8");
|
|
const publishedRunner = readFileSync(UPGRADE_SURVIVOR_RUN_SCRIPT, "utf8");
|
|
const updateRestartAuth = readFileSync(UPGRADE_SURVIVOR_UPDATE_RESTART_AUTH_PATH, "utf8");
|
|
|
|
expectTextToIncludeAll(runner, [
|
|
'source "$HARNESS_ROOT_DIR/scripts/lib/openclaw-e2e-instance.sh"',
|
|
'START_BUDGET_SECONDS="$(openclaw_e2e_read_positive_int_env OPENCLAW_UPGRADE_SURVIVOR_START_BUDGET_SECONDS 90)"',
|
|
'STATUS_BUDGET_SECONDS="$(openclaw_e2e_read_positive_int_env OPENCLAW_UPGRADE_SURVIVOR_STATUS_BUDGET_SECONDS 30)"',
|
|
'-e OPENCLAW_UPGRADE_SURVIVOR_START_BUDGET_SECONDS="$START_BUDGET_SECONDS"',
|
|
'-e OPENCLAW_UPGRADE_SURVIVOR_STATUS_BUDGET_SECONDS="$STATUS_BUDGET_SECONDS"',
|
|
'START_BUDGET="$(openclaw_e2e_read_positive_int_env OPENCLAW_UPGRADE_SURVIVOR_START_BUDGET_SECONDS 90)"',
|
|
'STATUS_BUDGET="$(openclaw_e2e_read_positive_int_env OPENCLAW_UPGRADE_SURVIVOR_STATUS_BUDGET_SECONDS 30)"',
|
|
'COMMAND_TIMEOUT="${OPENCLAW_UPGRADE_SURVIVOR_COMMAND_TIMEOUT:-900s}"',
|
|
'-e OPENCLAW_UPGRADE_SURVIVOR_COMMAND_TIMEOUT="$COMMAND_TIMEOUT"',
|
|
'command_timeout="${OPENCLAW_UPGRADE_SURVIVOR_COMMAND_TIMEOUT:-900s}"',
|
|
'openclaw_e2e_maybe_timeout "$command_timeout" env -u OPENCLAW_GATEWAY_TOKEN',
|
|
'openclaw_e2e_maybe_timeout "$command_timeout" openclaw doctor --fix --non-interactive',
|
|
'openclaw_e2e_maybe_timeout "$command_timeout" openclaw config validate',
|
|
'openclaw_e2e_maybe_timeout "$command_timeout" openclaw gateway status',
|
|
'openclaw gateway --port "$PORT" --bind loopback --allow-unconfigured',
|
|
'PROBE_TIMEOUT_MS="$(openclaw_e2e_read_nonnegative_int_env OPENCLAW_UPGRADE_SURVIVOR_PROBE_TIMEOUT_MS 60000)"',
|
|
"openclaw_e2e_read_positive_int_env OPENCLAW_UPGRADE_SURVIVOR_PROBE_ATTEMPT_TIMEOUT_MS 5000",
|
|
"openclaw_e2e_read_positive_int_env OPENCLAW_UPGRADE_SURVIVOR_PROBE_MAX_BODY_BYTES 1048576",
|
|
'-e OPENCLAW_UPGRADE_SURVIVOR_PROBE_TIMEOUT_MS="$PROBE_TIMEOUT_MS"',
|
|
'-e OPENCLAW_UPGRADE_SURVIVOR_PROBE_ATTEMPT_TIMEOUT_MS="$PROBE_ATTEMPT_TIMEOUT_MS"',
|
|
'-e OPENCLAW_UPGRADE_SURVIVOR_PROBE_MAX_BODY_BYTES="$PROBE_MAX_BODY_BYTES"',
|
|
"readyz_probe_args=(",
|
|
'readyz_probe_args+=(--allow-failing "$OPENCLAW_UPGRADE_SURVIVOR_READYZ_ALLOW_FAILING")',
|
|
"readyz_probe_args+=(--allow-degraded-ready)",
|
|
'node scripts/e2e/lib/upgrade-survivor/probe-gateway.mjs "${readyz_probe_args[@]}"',
|
|
"OPENCLAW_UPGRADE_SURVIVOR_READYZ_ALLOW_FAILING",
|
|
"OPENCLAW_UPGRADE_SURVIVOR_READYZ_ALLOW_DEGRADED",
|
|
]);
|
|
|
|
expect(publishedRunner).toContain(
|
|
'COMMAND_TIMEOUT="${OPENCLAW_UPGRADE_SURVIVOR_COMMAND_TIMEOUT:-900s}"',
|
|
);
|
|
expect(publishedRunner).toContain(
|
|
'budget="$(openclaw_e2e_read_positive_int_env OPENCLAW_UPGRADE_SURVIVOR_START_BUDGET_SECONDS 90)"',
|
|
);
|
|
expect(publishedRunner).toContain(
|
|
'budget="$(openclaw_e2e_read_positive_int_env OPENCLAW_UPGRADE_SURVIVOR_STATUS_BUDGET_SECONDS 30)"',
|
|
);
|
|
expect(publishedRunner).toContain(
|
|
'openclaw_e2e_maybe_timeout "$COMMAND_TIMEOUT" env -u OPENCLAW_GATEWAY_TOKEN',
|
|
);
|
|
expect(publishedRunner).toContain(
|
|
'openclaw_e2e_maybe_timeout "$COMMAND_TIMEOUT" openclaw --version',
|
|
);
|
|
expect(publishedRunner).toContain(
|
|
'openclaw_e2e_maybe_timeout "$COMMAND_TIMEOUT" openclaw config validate >"$BASELINE_CONFIG_VALIDATE_LOG"',
|
|
);
|
|
expect(publishedRunner).toContain(
|
|
'openclaw_e2e_maybe_timeout "$COMMAND_TIMEOUT" "${update_env[@]}" openclaw',
|
|
);
|
|
expect(publishedRunner).toContain(
|
|
'openclaw_e2e_maybe_timeout "$COMMAND_TIMEOUT" "${root_cli_env[@]}" openclaw',
|
|
);
|
|
expect(publishedRunner).toContain(
|
|
'openclaw_e2e_maybe_timeout "$COMMAND_TIMEOUT" openclaw doctor --fix --non-interactive',
|
|
);
|
|
expect(publishedRunner).toContain(
|
|
'openclaw_e2e_maybe_timeout "$COMMAND_TIMEOUT" openclaw config validate',
|
|
);
|
|
expect(publishedRunner).toContain(
|
|
'openclaw_e2e_maybe_timeout "$COMMAND_TIMEOUT" openclaw gateway status',
|
|
);
|
|
expect(publishedRunner).toContain('openclaw gateway --port "$port" --bind loopback');
|
|
expect(publishedRunner).toContain("start_gateway legacy-ready-log-ok");
|
|
expect(publishedRunner).toContain(
|
|
'openclaw_e2e_wait_gateway_ready "$gateway_pid" "$GATEWAY_LOG" 360 "$port" "${1:-strict}"',
|
|
);
|
|
|
|
expect(updateRestartAuth).toContain(
|
|
'command_timeout="${OPENCLAW_UPGRADE_SURVIVOR_COMMAND_TIMEOUT:-900s}"',
|
|
);
|
|
expect(updateRestartAuth).toContain(
|
|
'openclaw_e2e_maybe_timeout "$command_timeout" env -u OPENCLAW_GATEWAY_TOKEN',
|
|
);
|
|
expect(updateRestartAuth).toContain('openclaw gateway --port "$port" --bind loopback');
|
|
expect(updateRestartAuth).toContain(
|
|
'openclaw_e2e_wait_gateway_ready "$gateway_pid" "$log_file" 360 "$port"',
|
|
);
|
|
});
|
|
|
|
it("keeps upgrade survivor auto-auth success summary set -u safe", () => {
|
|
const runner = readFileSync(UPGRADE_SURVIVOR_DOCKER_E2E_PATH, "utf8");
|
|
const summaryDefaultIndex = runner.indexOf('startup_summary="n/a"');
|
|
const autoAuthIndex = runner.indexOf(
|
|
'if [ "$UPDATE_RESTART_MODE" = "auto-auth" ]; then',
|
|
summaryDefaultIndex,
|
|
);
|
|
const manualSummaryIndex = runner.indexOf('startup_summary="${start_seconds}s"', autoAuthIndex);
|
|
const successIndex = runner.indexOf(
|
|
"startup=${startup_summary} status=${status_seconds}s",
|
|
manualSummaryIndex,
|
|
);
|
|
|
|
expect(summaryDefaultIndex).toBeGreaterThan(-1);
|
|
expect(autoAuthIndex).toBeGreaterThan(summaryDefaultIndex);
|
|
expect(manualSummaryIndex).toBeGreaterThan(autoAuthIndex);
|
|
expect(successIndex).toBeGreaterThan(manualSummaryIndex);
|
|
});
|
|
|
|
it("models systemd restart supervision in update-restart auth fixtures", () => {
|
|
const runner = readFileSync(UPGRADE_SURVIVOR_DOCKER_E2E_PATH, "utf8");
|
|
const publishedRunner = readFileSync(UPGRADE_SURVIVOR_RUN_SCRIPT, "utf8");
|
|
const updateRestartAuth = readFileSync(UPGRADE_SURVIVOR_UPDATE_RESTART_AUTH_PATH, "utf8");
|
|
|
|
for (const script of [publishedRunner, updateRestartAuth]) {
|
|
expectTextToIncludeAll(script, [
|
|
'supervisor_script="${pid_file}.supervisor.mjs"',
|
|
'process_state="$(awk \'{ print $3 }\' "/proc/$pid/stat" 2>/dev/null || true)"',
|
|
'OPENCLAW_SYSTEMCTL_SHIM_EXEC_START="$exec_start"',
|
|
'nohup node "$supervisor_script"',
|
|
'if (key.startsWith("OPENCLAW_UPDATE_")) {',
|
|
"delete childEnv.OPENCLAW_COMPATIBILITY_HOST_VERSION;",
|
|
'process.on("SIGTERM", stop);',
|
|
"const stopTimeoutMs = 30_000;",
|
|
"process.kill(-pid, signal);",
|
|
'signalProcessGroup(pid, "SIGTERM");',
|
|
'signalProcessGroup(pid, "SIGKILL");',
|
|
"drainProcessGroup(childGroupPid, () => {",
|
|
"detached: true,",
|
|
"if (code === 78) return finish();",
|
|
"const restartDelayMs = 5_000;",
|
|
"const restartWindowMs = 60_000;",
|
|
"const restartBurst = 5;",
|
|
"if (starts.length >= restartBurst) {",
|
|
"setTimeout(start, restartDelayMs);",
|
|
"for _ in $(seq 1 350)",
|
|
]);
|
|
}
|
|
for (const script of [runner, publishedRunner]) {
|
|
expect(script).toContain("systemctl --user stop openclaw-gateway.service");
|
|
}
|
|
});
|
|
|
|
it("stops supervised gateway restarts after the systemd burst limit", async () => {
|
|
const workDir = tempDirs.make("openclaw-update-restart-supervisor-");
|
|
const scripts = [
|
|
readFileSync(UPGRADE_SURVIVOR_RUN_SCRIPT, "utf8"),
|
|
readFileSync(UPGRADE_SURVIVOR_UPDATE_RESTART_AUTH_PATH, "utf8"),
|
|
];
|
|
|
|
for (const [index, script] of scripts.entries()) {
|
|
const supervisorPath = join(workDir, `supervisor-${index}.mjs`);
|
|
const countPath = join(workDir, `starts-${index}`);
|
|
const logPath = join(workDir, `daemon-${index}.log`);
|
|
const source = extractUpgradeSurvivorSupervisor(script)
|
|
.replace("const restartDelayMs = 5_000;", "const restartDelayMs = 5;")
|
|
.replace("const restartWindowMs = 60_000;", "const restartWindowMs = 5_000;");
|
|
writeFileSync(supervisorPath, source);
|
|
|
|
const command =
|
|
'node -e \'require("node:fs").appendFileSync(process.env.COUNT_FILE, "x"); process.exit(1)\'';
|
|
const supervisor = spawn(process.execPath, [supervisorPath], {
|
|
env: {
|
|
...process.env,
|
|
COUNT_FILE: countPath,
|
|
OPENCLAW_SYSTEMCTL_SHIM_DAEMON_LOG: logPath,
|
|
OPENCLAW_SYSTEMCTL_SHIM_EXEC_START: command,
|
|
},
|
|
stdio: "ignore",
|
|
});
|
|
const exitCode = await waitForProcessExit(supervisor);
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(readFileSync(countPath, "utf8")).toBe("xxxxx");
|
|
expect(readFileSync(logPath, "utf8")).toContain(
|
|
"[systemctl-shim] gateway restart limit reached",
|
|
);
|
|
}
|
|
});
|
|
|
|
it("allows a supervised gateway to drain within the systemd stop timeout", async () => {
|
|
const workDir = tempDirs.make("openclaw-update-restart-graceful-stop-");
|
|
const scripts = [
|
|
readFileSync(UPGRADE_SURVIVOR_RUN_SCRIPT, "utf8"),
|
|
readFileSync(UPGRADE_SURVIVOR_UPDATE_RESTART_AUTH_PATH, "utf8"),
|
|
];
|
|
|
|
for (const [index, script] of scripts.entries()) {
|
|
const supervisorPath = join(workDir, `graceful-supervisor-${index}.mjs`);
|
|
const statePath = join(workDir, `graceful-state-${index}`);
|
|
const logPath = join(workDir, `graceful-daemon-${index}.log`);
|
|
const source = extractUpgradeSurvivorSupervisor(script).replace(
|
|
"const stopTimeoutMs = 30_000;",
|
|
"const stopTimeoutMs = 200;",
|
|
);
|
|
writeFileSync(supervisorPath, source);
|
|
|
|
const command =
|
|
'node -e \'const fs=require("node:fs"); process.on("SIGTERM",()=>setTimeout(()=>{fs.appendFileSync(process.env.STATE_FILE, "-graceful"); process.exit(0)},50)); fs.writeFileSync(process.env.STATE_FILE, "ready"); setInterval(()=>{},1000)\'';
|
|
const supervisor = spawn(process.execPath, [supervisorPath], {
|
|
env: {
|
|
...process.env,
|
|
OPENCLAW_SYSTEMCTL_SHIM_DAEMON_LOG: logPath,
|
|
OPENCLAW_SYSTEMCTL_SHIM_EXEC_START: command,
|
|
STATE_FILE: statePath,
|
|
},
|
|
stdio: "ignore",
|
|
});
|
|
try {
|
|
for (let attempt = 0; attempt < 100 && !existsSync(statePath); attempt += 1) {
|
|
await delay(10);
|
|
}
|
|
expect(existsSync(statePath)).toBe(true);
|
|
|
|
supervisor.kill("SIGTERM");
|
|
expect(await waitForProcessExit(supervisor)).toBe(0);
|
|
expect(readFileSync(statePath, "utf8")).toBe("ready-graceful");
|
|
} finally {
|
|
if (supervisor.exitCode === null && supervisor.signalCode === null) {
|
|
supervisor.kill("SIGTERM");
|
|
await waitForProcessExit(supervisor).catch(() => undefined);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
it("preserves the ClawHub fixture URL across a supervised gateway restart", async () => {
|
|
const workDir = tempDirs.make("openclaw-update-restart-clawhub-env-");
|
|
const gatewayPath = join(workDir, "gateway.mjs");
|
|
writeFileSync(
|
|
gatewayPath,
|
|
`import fs from "node:fs";
|
|
fs.appendFileSync(process.env.URLS_FILE, process.env.OPENCLAW_CLAWHUB_URL + "\\n");
|
|
const starts = fs.readFileSync(process.env.URLS_FILE, "utf8").trim().split("\\n").length;
|
|
process.exit(starts === 1 ? 1 : 78);
|
|
`,
|
|
);
|
|
const scripts = [
|
|
readFileSync(UPGRADE_SURVIVOR_RUN_SCRIPT, "utf8"),
|
|
readFileSync(UPGRADE_SURVIVOR_UPDATE_RESTART_AUTH_PATH, "utf8"),
|
|
];
|
|
|
|
for (const [index, script] of scripts.entries()) {
|
|
const supervisorPath = join(workDir, `clawhub-env-supervisor-${index}.mjs`);
|
|
const urlsPath = join(workDir, `clawhub-env-urls-${index}`);
|
|
const logPath = join(workDir, `clawhub-env-daemon-${index}.log`);
|
|
const source = extractUpgradeSurvivorSupervisor(script).replace(
|
|
"const restartDelayMs = 5_000;",
|
|
"const restartDelayMs = 5;",
|
|
);
|
|
writeFileSync(supervisorPath, source);
|
|
|
|
const supervisor = spawn(process.execPath, [supervisorPath], {
|
|
env: {
|
|
...process.env,
|
|
OPENCLAW_CLAWHUB_URL: "http://127.0.0.1:43123",
|
|
OPENCLAW_SYSTEMCTL_SHIM_DAEMON_LOG: logPath,
|
|
OPENCLAW_SYSTEMCTL_SHIM_EXEC_START: `${shellQuote(process.execPath)} ${shellQuote(gatewayPath)}`,
|
|
URLS_FILE: urlsPath,
|
|
},
|
|
stdio: "ignore",
|
|
});
|
|
try {
|
|
expect(await waitForProcessExit(supervisor)).toBe(0);
|
|
expect(readFileSync(urlsPath, "utf8").trim().split("\n")).toEqual([
|
|
"http://127.0.0.1:43123",
|
|
"http://127.0.0.1:43123",
|
|
]);
|
|
} finally {
|
|
if (supervisor.exitCode === null && supervisor.signalCode === null) {
|
|
supervisor.kill("SIGTERM");
|
|
await waitForProcessExit(supervisor).catch(() => undefined);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"terminates supervised gateway descendants at the systemd stop timeout",
|
|
async () => {
|
|
const workDir = tempDirs.make("openclaw-update-restart-process-group-");
|
|
const descendantPath = writeTermIgnoringDescendant(workDir);
|
|
const gatewayPath = join(workDir, "gateway.mjs");
|
|
writeFileSync(
|
|
gatewayPath,
|
|
`import fs from "node:fs";
|
|
import { spawn } from "node:child_process";
|
|
process.on("SIGTERM", () => {
|
|
setTimeout(() => {
|
|
fs.appendFileSync(process.env.STATE_FILE, "-graceful");
|
|
process.exit(0);
|
|
}, 50);
|
|
});
|
|
spawn(process.execPath, [process.env.DESCENDANT_SCRIPT], { stdio: "ignore" });
|
|
const ready = setInterval(() => {
|
|
if (!fs.existsSync(process.env.DESCENDANT_PID_FILE)) return;
|
|
clearInterval(ready);
|
|
fs.writeFileSync(process.env.STATE_FILE, "ready");
|
|
}, 5);
|
|
setInterval(() => {}, 1_000);
|
|
`,
|
|
);
|
|
const scripts = [
|
|
readFileSync(UPGRADE_SURVIVOR_RUN_SCRIPT, "utf8"),
|
|
readFileSync(UPGRADE_SURVIVOR_UPDATE_RESTART_AUTH_PATH, "utf8"),
|
|
];
|
|
|
|
for (const [index, script] of scripts.entries()) {
|
|
const supervisorPath = join(workDir, `process-group-supervisor-${index}.mjs`);
|
|
const statePath = join(workDir, `process-group-state-${index}`);
|
|
const descendantPidPath = join(workDir, `process-group-descendant-${index}.pid`);
|
|
const logPath = join(workDir, `process-group-daemon-${index}.log`);
|
|
const source = extractUpgradeSurvivorSupervisor(script).replace(
|
|
"const stopTimeoutMs = 30_000;",
|
|
"const stopTimeoutMs = 200;",
|
|
);
|
|
writeFileSync(supervisorPath, source);
|
|
|
|
const supervisor = spawn(process.execPath, [supervisorPath], {
|
|
env: {
|
|
...process.env,
|
|
DESCENDANT_PID_FILE: descendantPidPath,
|
|
DESCENDANT_SCRIPT: descendantPath,
|
|
OPENCLAW_SYSTEMCTL_SHIM_DAEMON_LOG: logPath,
|
|
OPENCLAW_SYSTEMCTL_SHIM_EXEC_START: `${shellQuote(process.execPath)} ${shellQuote(gatewayPath)}`,
|
|
STATE_FILE: statePath,
|
|
},
|
|
stdio: "ignore",
|
|
});
|
|
let descendantPid: number | undefined;
|
|
try {
|
|
for (let attempt = 0; attempt < 100 && !existsSync(statePath); attempt += 1) {
|
|
await delay(10);
|
|
}
|
|
expect(existsSync(statePath)).toBe(true);
|
|
descendantPid = Number.parseInt(readFileSync(descendantPidPath, "utf8"), 10);
|
|
expect(descendantPid).toBeGreaterThan(1);
|
|
expect(isProcessRunning(descendantPid)).toBe(true);
|
|
|
|
supervisor.kill("SIGTERM");
|
|
expect(await waitForProcessExit(supervisor)).toBe(0);
|
|
expect(readFileSync(statePath, "utf8")).toBe("ready-graceful");
|
|
for (let attempt = 0; attempt < 100 && isProcessRunning(descendantPid); attempt += 1) {
|
|
await delay(10);
|
|
}
|
|
expect(isProcessRunning(descendantPid)).toBe(false);
|
|
} finally {
|
|
if (supervisor.exitCode === null && supervisor.signalCode === null) {
|
|
supervisor.kill("SIGTERM");
|
|
await waitForProcessExit(supervisor).catch(() => undefined);
|
|
}
|
|
if (descendantPid !== undefined && isProcessRunning(descendantPid)) {
|
|
try {
|
|
process.kill(descendantPid, "SIGKILL");
|
|
} catch {}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"drains the previous gateway process group before restarting",
|
|
async () => {
|
|
const workDir = tempDirs.make("openclaw-update-restart-process-group-restart-");
|
|
const descendantPath = writeTermIgnoringDescendant(workDir);
|
|
const gatewayPath = join(workDir, "restart-gateway.mjs");
|
|
writeFileSync(
|
|
gatewayPath,
|
|
`import fs from "node:fs";
|
|
import { spawn } from "node:child_process";
|
|
fs.appendFileSync(process.env.STARTS_FILE, "x");
|
|
const starts = fs.readFileSync(process.env.STARTS_FILE, "utf8").length;
|
|
if (starts === 1) {
|
|
spawn(process.execPath, [process.env.DESCENDANT_SCRIPT], { stdio: "ignore" });
|
|
const ready = setInterval(() => {
|
|
if (!fs.existsSync(process.env.DESCENDANT_PID_FILE)) return;
|
|
clearInterval(ready);
|
|
process.exit(1);
|
|
}, 5);
|
|
setInterval(() => {}, 1_000);
|
|
} else {
|
|
const pid = Number.parseInt(fs.readFileSync(process.env.DESCENDANT_PID_FILE, "utf8"), 10);
|
|
let running = false;
|
|
try {
|
|
process.kill(pid, 0);
|
|
running = true;
|
|
const statPath = "/proc/" + pid + "/stat";
|
|
if (fs.existsSync(statPath)) running = fs.readFileSync(statPath, "utf8").split(" ")[2] !== "Z";
|
|
} catch {}
|
|
fs.writeFileSync(process.env.REPLACEMENT_FILE, running ? "overlap" : "drained");
|
|
process.exit(78);
|
|
}
|
|
`,
|
|
);
|
|
const scripts = [
|
|
readFileSync(UPGRADE_SURVIVOR_RUN_SCRIPT, "utf8"),
|
|
readFileSync(UPGRADE_SURVIVOR_UPDATE_RESTART_AUTH_PATH, "utf8"),
|
|
];
|
|
|
|
for (const [index, script] of scripts.entries()) {
|
|
const supervisorPath = join(workDir, `restart-group-supervisor-${index}.mjs`);
|
|
const startsPath = join(workDir, `restart-group-starts-${index}`);
|
|
const descendantPidPath = join(workDir, `restart-group-descendant-${index}.pid`);
|
|
const replacementPath = join(workDir, `restart-group-replacement-${index}`);
|
|
const logPath = join(workDir, `restart-group-daemon-${index}.log`);
|
|
const source = extractUpgradeSurvivorSupervisor(script)
|
|
.replace("const restartDelayMs = 5_000;", "const restartDelayMs = 5;")
|
|
.replace("const stopTimeoutMs = 30_000;", "const stopTimeoutMs = 200;");
|
|
writeFileSync(supervisorPath, source);
|
|
|
|
const supervisor = spawn(process.execPath, [supervisorPath], {
|
|
env: {
|
|
...process.env,
|
|
DESCENDANT_PID_FILE: descendantPidPath,
|
|
DESCENDANT_SCRIPT: descendantPath,
|
|
OPENCLAW_SYSTEMCTL_SHIM_DAEMON_LOG: logPath,
|
|
OPENCLAW_SYSTEMCTL_SHIM_EXEC_START: `${shellQuote(process.execPath)} ${shellQuote(gatewayPath)}`,
|
|
REPLACEMENT_FILE: replacementPath,
|
|
STARTS_FILE: startsPath,
|
|
},
|
|
stdio: "ignore",
|
|
});
|
|
let descendantPid: number | undefined;
|
|
try {
|
|
expect(await waitForProcessExit(supervisor)).toBe(0);
|
|
descendantPid = Number.parseInt(readFileSync(descendantPidPath, "utf8"), 10);
|
|
expect(descendantPid).toBeGreaterThan(1);
|
|
expect(readFileSync(startsPath, "utf8")).toBe("xx");
|
|
expect(readFileSync(replacementPath, "utf8")).toBe("drained");
|
|
} finally {
|
|
if (supervisor.exitCode === null && supervisor.signalCode === null) {
|
|
supervisor.kill("SIGTERM");
|
|
await waitForProcessExit(supervisor).catch(() => undefined);
|
|
}
|
|
if (descendantPid !== undefined && isProcessRunning(descendantPid)) {
|
|
try {
|
|
process.kill(descendantPid, "SIGKILL");
|
|
} catch {}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
["start budget", "OPENCLAW_UPGRADE_SURVIVOR_START_BUDGET_SECONDS", "90s"],
|
|
["status budget", "OPENCLAW_UPGRADE_SURVIVOR_STATUS_BUDGET_SECONDS", "30s"],
|
|
["probe timeout", "OPENCLAW_UPGRADE_SURVIVOR_PROBE_TIMEOUT_MS", "soon"],
|
|
["probe attempt timeout", "OPENCLAW_UPGRADE_SURVIVOR_PROBE_ATTEMPT_TIMEOUT_MS", "0"],
|
|
["probe body cap", "OPENCLAW_UPGRADE_SURVIVOR_PROBE_MAX_BODY_BYTES", "64bytes"],
|
|
])("rejects invalid upgrade survivor Docker %s before Docker setup", (_label, envName, value) => {
|
|
const result = spawnSync("bash", [UPGRADE_SURVIVOR_DOCKER_E2E_PATH], {
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
OPENCLAW_UPGRADE_SURVIVOR_E2E_SKIP_BUILD: "1",
|
|
[envName]: value,
|
|
},
|
|
});
|
|
|
|
expect(result.status).toBe(2);
|
|
expect(result.stderr).toContain(`invalid ${envName}: ${value}`);
|
|
expect(result.stderr).not.toContain("Docker image not found");
|
|
});
|
|
|
|
it("bounds upgrade survivor failure log diagnostics", () => {
|
|
const runner = readFileSync(UPGRADE_SURVIVOR_DOCKER_E2E_PATH, "utf8");
|
|
const publishedRunner = readFileSync(UPGRADE_SURVIVOR_RUN_SCRIPT, "utf8");
|
|
|
|
expectTextToIncludeAll(runner, [
|
|
"openclaw_e2e_print_log /tmp/openclaw-upgrade-survivor-update.err",
|
|
"openclaw_e2e_print_log /tmp/openclaw-upgrade-survivor-update.json",
|
|
"openclaw_e2e_print_log /tmp/openclaw-upgrade-survivor-doctor.log",
|
|
"openclaw_e2e_print_log /tmp/openclaw-upgrade-survivor-status.err",
|
|
"openclaw_e2e_print_log /tmp/openclaw-upgrade-survivor-status.json",
|
|
'openclaw_e2e_print_log "$GATEWAY_LOG"',
|
|
'openclaw_e2e_print_log "$SYSTEMCTL_SHIM_DAEMON_LOG"',
|
|
'openclaw_e2e_print_log "$log_file"',
|
|
]);
|
|
|
|
expect(runner).not.toContain("cat /tmp/openclaw-upgrade-survivor-update.err");
|
|
expect(runner).not.toContain("cat /tmp/openclaw-upgrade-survivor-update.json");
|
|
expect(runner).not.toContain("cat /tmp/openclaw-upgrade-survivor-doctor.log");
|
|
expect(runner).not.toContain("cat /tmp/openclaw-upgrade-survivor-status.err");
|
|
expect(runner).not.toContain("cat /tmp/openclaw-upgrade-survivor-status.json");
|
|
expect(runner).not.toContain('cat "$GATEWAY_LOG"');
|
|
expect(runner).not.toContain('cat "$SYSTEMCTL_SHIM_DAEMON_LOG"');
|
|
expect(runner).not.toContain('cat "$log_file"');
|
|
expect(runner).not.toContain('openclaw_e2e_print_log "$SYSTEMCTL_SHIM_LOG"');
|
|
|
|
expect(publishedRunner).toContain('openclaw_e2e_print_log "$BASELINE_INSTALL_LOG"');
|
|
expect(publishedRunner).toContain('openclaw_e2e_print_log "$BASELINE_CONFIG_VALIDATE_LOG"');
|
|
expect(publishedRunner).toContain('openclaw_e2e_print_log "$BASELINE_SERVICE_INSTALL_ERR"');
|
|
expect(publishedRunner).toContain('openclaw_e2e_print_log "$BASELINE_SERVICE_INSTALL_JSON"');
|
|
expect(publishedRunner).toContain('openclaw_e2e_print_log "$UPDATE_ERR"');
|
|
expect(publishedRunner).toContain('openclaw_e2e_print_log "$UPDATE_JSON"');
|
|
expect(publishedRunner).toContain('openclaw_e2e_print_log "$DOCTOR_LOG"');
|
|
expect(publishedRunner).toContain('openclaw_e2e_print_log "$GATEWAY_LOG"');
|
|
expect(publishedRunner).toContain('openclaw_e2e_print_log "$STATUS_ERR"');
|
|
expect(publishedRunner).toContain('openclaw_e2e_print_log "$STATUS_JSON"');
|
|
expect(publishedRunner).toContain('openclaw_e2e_print_log "$log_file"');
|
|
expect(publishedRunner).not.toContain('cat "$BASELINE_INSTALL_LOG"');
|
|
expect(publishedRunner).not.toContain('cat "$BASELINE_CONFIG_VALIDATE_LOG"');
|
|
expect(publishedRunner).not.toContain('cat "$BASELINE_SERVICE_INSTALL_ERR"');
|
|
expect(publishedRunner).not.toContain('cat "$BASELINE_SERVICE_INSTALL_JSON"');
|
|
expect(publishedRunner).not.toContain('cat "$UPDATE_ERR"');
|
|
expect(publishedRunner).not.toContain('cat "$UPDATE_JSON"');
|
|
expect(publishedRunner).not.toContain('cat "$DOCTOR_LOG"');
|
|
expect(publishedRunner).not.toContain('cat "$GATEWAY_LOG"');
|
|
expect(publishedRunner).not.toContain('cat "$STATUS_ERR"');
|
|
expect(publishedRunner).not.toContain('cat "$STATUS_JSON"');
|
|
expect(publishedRunner).not.toContain('cat "$log_file"');
|
|
expect(publishedRunner).not.toContain('openclaw_e2e_print_log "$SYSTEMCTL_SHIM_LOG"');
|
|
expect(publishedRunner).not.toContain('openclaw_e2e_print_log "$SYSTEMCTL_SHIM_DAEMON_LOG"');
|
|
});
|
|
|
|
it("preserves caller-owned file descriptors around harness runs", () => {
|
|
const workDir = tempDirs.make("openclaw-docker-harness-fd-");
|
|
const script = String.raw`
|
|
set -euo pipefail
|
|
ROOT_DIR=${shellQuote(process.cwd())}
|
|
TMPDIR=${shellQuote(workDir)}
|
|
export ROOT_DIR TMPDIR
|
|
|
|
mkdir -p "$TMPDIR/bin"
|
|
cat >"$TMPDIR/bin/timeout" <<'SH'
|
|
#!/usr/bin/env bash
|
|
case "$1" in
|
|
--kill-after=1s)
|
|
exit 0
|
|
;;
|
|
--kill-after=30s)
|
|
shift 2
|
|
;;
|
|
*)
|
|
shift
|
|
;;
|
|
esac
|
|
"$@"
|
|
SH
|
|
chmod +x "$TMPDIR/bin/timeout"
|
|
export PATH="$TMPDIR/bin:$PATH"
|
|
|
|
source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh"
|
|
|
|
docker() {
|
|
local cidfile=""
|
|
local expect_cidfile=0
|
|
local arg
|
|
for arg in "$@"; do
|
|
if [[ "$expect_cidfile" == "1" ]]; then
|
|
cidfile="$arg"
|
|
expect_cidfile=0
|
|
continue
|
|
fi
|
|
if [[ "$arg" == "--cidfile" ]]; then
|
|
expect_cidfile=1
|
|
fi
|
|
done
|
|
test -n "$cidfile"
|
|
printf "container-fd\n" >"$cidfile"
|
|
cat >/dev/null
|
|
}
|
|
export -f docker
|
|
|
|
exec 19>"$TMPDIR/caller-fd"
|
|
docker_e2e_run_with_harness image-name bash -s <<'SH'
|
|
true
|
|
SH
|
|
printf "preserved\n" >&19
|
|
exec 19>&-
|
|
grep -Fxq preserved "$TMPDIR/caller-fd"
|
|
`;
|
|
|
|
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
|
|
});
|
|
|
|
it("cleans Codex npm plugin live package artifacts on every exit path", () => {
|
|
const runner = readFileSync(CODEX_NPM_PLUGIN_LIVE_DOCKER_E2E_PATH, "utf8");
|
|
expectTextToIncludeAll(runner, ['CODEX_PLUGIN_PACK_DIR=""', 'run_log=""', "trap cleanup EXIT"]);
|
|
|
|
expect(runner).toMatch(
|
|
/cleanup\(\) \{[\s\S]*rm -rf "\$CODEX_PLUGIN_PACK_DIR"[\s\S]*docker_e2e_cleanup_package_tgz "\$PACKAGE_TGZ"[\s\S]*rm -f "\$run_log"/u,
|
|
);
|
|
|
|
expect(runner).not.toContain('rm -f "$run_log"\n exit 1');
|
|
});
|
|
|
|
it("wires the Codex npm plugin live assertion boundary into Docker", () => {
|
|
const runner = readFileSync(CODEX_NPM_PLUGIN_LIVE_DOCKER_E2E_PATH, "utf8");
|
|
const assertions = readFileSync("scripts/e2e/lib/codex-npm-plugin-live/assertions.mjs", "utf8");
|
|
expectTextToIncludeAll(runner, [
|
|
"docker_e2e_print_log /tmp/openclaw-codex-plugin-pack.log",
|
|
"scripts/e2e/lib/plugins/npm-registry-server.mjs",
|
|
'CODEX_PLUGIN_SPEC="npm:${CODEX_PLUGIN_REGISTRY_PACKAGE}@${CODEX_PLUGIN_REGISTRY_VERSION}"',
|
|
'export NPM_CONFIG_REGISTRY="http://127.0.0.1:$(cat "$registry_port_file")"',
|
|
"trap cleanup_scenario EXIT",
|
|
'openclaw_e2e_stop_process "${registry_pid:-}"',
|
|
'if [ "$status" -ne 0 ] && [ "$debug_logs_dumped" -eq 0 ]; then',
|
|
"assert-agent-error",
|
|
"assert-followthrough",
|
|
"followthrough-turn.mjs",
|
|
"docker_e2e_read_positive_int_env OPENCLAW_CODEX_NPM_PLUGIN_AGENT_TIMEOUT_SECONDS 420",
|
|
'docker_e2e_read_positive_int_env OPENCLAW_CODEX_NPM_PLUGIN_AGENT_TIMEOUT_SECONDS "$AGENT_TURN_TIMEOUT_SECONDS"',
|
|
'-e "OPENCLAW_CODEX_NPM_PLUGIN_AGENT_TIMEOUT_SECONDS=$AGENT_TURN_TIMEOUT_SECONDS"',
|
|
'-e "OPENCLAW_E2E_COMMAND_TIMEOUT=$COMMAND_TIMEOUT"',
|
|
'--timeout "$AGENT_TURN_TIMEOUT_SECONDS"',
|
|
]);
|
|
expect(runner).not.toContain("cat /tmp/openclaw-codex-plugin-pack.log");
|
|
expect(runner).not.toContain('CODEX_PLUGIN_SPEC="npm-pack:$container_path"');
|
|
expect(runner).not.toContain("trap 'openclaw_e2e_stop_process \"${registry_pid:-}\"' EXIT");
|
|
expect(runner).not.toContain("final=false");
|
|
expect(runner).not.toContain("--timeout 420");
|
|
expectTextToIncludeAll(assertions, [
|
|
'Requested agent harness "codex" is not registered',
|
|
"Unknown model: codex/",
|
|
'Agent harness runtime "codex" is not present in the prepared registry.',
|
|
]);
|
|
});
|
|
|
|
it("prints the OpenAI chat-tools gateway log when startup exits early", () => {
|
|
const scenario = readFileSync(OPENAI_CHAT_TOOLS_SCENARIO_PATH, "utf8");
|
|
expectTextToIncludeAll(scenario, [
|
|
'if ! kill -0 "$gateway_pid" 2>/dev/null',
|
|
'echo "gateway exited before listening" >&2',
|
|
'openclaw_e2e_print_log "$GATEWAY_LOG" >&2',
|
|
]);
|
|
});
|
|
|
|
it("writes the packaged Codex follow-through result independently of stdout logs", () => {
|
|
const workDir = tempDirs.make("openclaw-codex-followthrough-");
|
|
const packageRoot = join(workDir, "package");
|
|
const runtimeDir = join(packageRoot, "dist", "plugin-sdk");
|
|
const outputPath = join(workDir, "result.json");
|
|
mkdirSync(runtimeDir, { recursive: true });
|
|
writeFileSync(join(packageRoot, "package.json"), '{"type":"module"}\n');
|
|
writeFileSync(
|
|
join(runtimeDir, "agent-runtime.js"),
|
|
[
|
|
"export async function agentCommandFromIngress(opts, runtime) {",
|
|
' runtime.log("unexpected runtime output");',
|
|
' console.log("unexpected subsystem output");',
|
|
" return { captured: opts };",
|
|
"}",
|
|
].join("\n"),
|
|
);
|
|
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[
|
|
CODEX_NPM_PLUGIN_LIVE_FOLLOWTHROUGH_PATH,
|
|
packageRoot,
|
|
"followthrough-session",
|
|
"openai/gpt-5.4",
|
|
"90",
|
|
outputPath,
|
|
"follow through",
|
|
],
|
|
{ encoding: "utf8" },
|
|
);
|
|
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(result.stderr).toBe("");
|
|
expect(result.stdout).toContain("unexpected subsystem output");
|
|
expect(JSON.parse(readFileSync(outputPath, "utf8"))).toEqual({
|
|
captured: expect.objectContaining({
|
|
sessionId: "followthrough-session",
|
|
model: "openai/gpt-5.4",
|
|
message: "follow through",
|
|
thinking: "medium",
|
|
timeout: "90",
|
|
json: true,
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
senderIsOwner: true,
|
|
allowModelOverride: true,
|
|
cleanupBundleMcpOnRunEnd: true,
|
|
cleanupCliLiveSessionOnRunEnd: true,
|
|
oneShotCliRun: true,
|
|
}),
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
"Codex npm plugin live",
|
|
CODEX_NPM_PLUGIN_LIVE_DOCKER_E2E_PATH,
|
|
"OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TEXT_FILE_BYTES",
|
|
"64kb",
|
|
],
|
|
[
|
|
"Codex npm plugin live agent timeout",
|
|
CODEX_NPM_PLUGIN_LIVE_DOCKER_E2E_PATH,
|
|
"OPENCLAW_CODEX_NPM_PLUGIN_AGENT_TIMEOUT_SECONDS",
|
|
"420s",
|
|
],
|
|
[
|
|
"npm onboard channel-agent",
|
|
NPM_ONBOARD_CHANNEL_AGENT_DOCKER_E2E_PATH,
|
|
"OPENCLAW_NPM_ONBOARD_JSON_ARTIFACT_MAX_BYTES",
|
|
"64kb",
|
|
],
|
|
[
|
|
"plugins",
|
|
PLUGINS_DOCKER_E2E_PATH,
|
|
"OPENCLAW_PLUGINS_E2E_CLAWHUB_PREFLIGHT_TIMEOUT_MS",
|
|
"soon",
|
|
],
|
|
[
|
|
"release user journey",
|
|
RELEASE_USER_JOURNEY_DOCKER_E2E_PATH,
|
|
"OPENCLAW_RELEASE_USER_JOURNEY_HTTP_BODY_MAX_BYTES",
|
|
"64kb",
|
|
],
|
|
])(
|
|
"rejects invalid package assertion env before Docker setup for %s",
|
|
(_label, path, envName, value) => {
|
|
const result = spawnSync("bash", [path], {
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
OPENCLAW_SKIP_DOCKER_BUILD: "1",
|
|
[envName]: value,
|
|
},
|
|
});
|
|
|
|
expect(result.status).toBe(2);
|
|
expect(result.stderr).toContain(`invalid ${envName}: ${value}`);
|
|
expect(result.stderr).not.toContain("Docker image not found");
|
|
},
|
|
);
|
|
|
|
it("forwards package assertion env limits into Docker runners", () => {
|
|
const expectations = [
|
|
[
|
|
CODEX_NPM_PLUGIN_LIVE_DOCKER_E2E_PATH,
|
|
[
|
|
["OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TEXT_FILE_BYTES", "1048576"],
|
|
["OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_ERROR_TAIL_BYTES", "65536"],
|
|
["OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TRANSCRIPT_FILES", "64"],
|
|
["OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TRANSCRIPT_WALK_ENTRIES", "4096"],
|
|
["OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TRANSCRIPT_SCAN_BYTES", "2097152"],
|
|
["OPENCLAW_CODEX_NPM_PLUGIN_AGENT_TIMEOUT_SECONDS", "420"],
|
|
],
|
|
],
|
|
[
|
|
NPM_ONBOARD_CHANNEL_AGENT_DOCKER_E2E_PATH,
|
|
[
|
|
["OPENCLAW_NPM_ONBOARD_JSON_ARTIFACT_MAX_BYTES", "1048576"],
|
|
["OPENCLAW_NPM_ONBOARD_STATUS_TEXT_MAX_BYTES", "1048576"],
|
|
],
|
|
],
|
|
[
|
|
PLUGINS_DOCKER_E2E_PATH,
|
|
[
|
|
["OPENCLAW_PLUGINS_E2E_CLAWHUB_PREFLIGHT_BODY_MAX_BYTES", "1048576"],
|
|
["OPENCLAW_PLUGINS_E2E_CLAWHUB_PREFLIGHT_TIMEOUT_MS", "30000"],
|
|
],
|
|
],
|
|
[
|
|
RELEASE_USER_JOURNEY_DOCKER_E2E_PATH,
|
|
[
|
|
["OPENCLAW_RELEASE_USER_JOURNEY_HTTP_TIMEOUT_MS", "5000"],
|
|
["OPENCLAW_RELEASE_USER_JOURNEY_HTTP_BODY_MAX_BYTES", "1048576"],
|
|
],
|
|
],
|
|
] as const;
|
|
|
|
for (const [path, envs] of expectations) {
|
|
const runner = readFileSync(path, "utf8");
|
|
for (const [envName, fallback] of envs) {
|
|
expect(runner, `${path} reads ${envName}`).toContain(
|
|
`docker_e2e_read_positive_int_env ${envName} ${fallback}`,
|
|
);
|
|
expect(runner, `${path} forwards ${envName}`).toContain(`-e "${envName}=`);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("gives Codex on-demand package installs enough time to reach Codex assertions", () => {
|
|
const runner = readFileSync(CODEX_ON_DEMAND_DOCKER_E2E_PATH, "utf8");
|
|
expect(runner).toContain(
|
|
'export OPENCLAW_E2E_NPM_INSTALL_TIMEOUT="${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-1200s}"',
|
|
);
|
|
});
|
|
|
|
it("cleans package-backed onboarding and plugin Docker artifacts on every exit path", () => {
|
|
for (const path of [
|
|
CODEX_ON_DEMAND_DOCKER_E2E_PATH,
|
|
LIVE_PLUGIN_TOOL_DOCKER_E2E_PATH,
|
|
NPM_ONBOARD_CHANNEL_AGENT_DOCKER_E2E_PATH,
|
|
]) {
|
|
const runner = readFileSync(path, "utf8");
|
|
|
|
expect(runner, path).toContain('run_log=""');
|
|
expect(runner, path).toMatch(
|
|
/cleanup\(\) \{[\s\S]*docker_e2e_cleanup_package_tgz "\$PACKAGE_TGZ"[\s\S]*rm -f "\$run_log"/u,
|
|
);
|
|
expect(runner, path).toContain("trap cleanup EXIT");
|
|
expect(runner, path).not.toContain('rm -f "$run_log"\n exit 1');
|
|
}
|
|
});
|
|
|
|
it("threads the live plugin tool output cap into the Docker harness", () => {
|
|
const runner = readFileSync(LIVE_PLUGIN_TOOL_DOCKER_E2E_PATH, "utf8");
|
|
expectTextToIncludeAll(runner, [
|
|
'source "$ROOT_DIR/scripts/lib/openclaw-e2e-instance.sh"',
|
|
'AGENT_TURN_TIMEOUT_SECONDS="$(openclaw_e2e_read_positive_int_env OPENCLAW_LIVE_PLUGIN_TOOL_TIMEOUT_SECONDS 300)"',
|
|
'AGENT_TURN_TIMEOUT_SECONDS="$(openclaw_e2e_read_positive_int_env OPENCLAW_LIVE_PLUGIN_TOOL_TIMEOUT_SECONDS "$AGENT_TURN_TIMEOUT_SECONDS")"',
|
|
'COMMAND_TIMEOUT="${OPENCLAW_E2E_COMMAND_TIMEOUT:-$((10#$AGENT_TURN_TIMEOUT_SECONDS + 60))s}"',
|
|
'AGENT_OUTPUT_MAX_BYTES="$(openclaw_e2e_read_positive_int_env OPENCLAW_LIVE_PLUGIN_TOOL_AGENT_OUTPUT_MAX_BYTES 1048576)"',
|
|
'AGENT_OUTPUT_DUMP_BYTES="$(openclaw_e2e_read_nonnegative_int_env OPENCLAW_LIVE_PLUGIN_TOOL_AGENT_OUTPUT_DUMP_BYTES 16384)"',
|
|
'SESSION_SCAN_MAX_ENTRIES="$(openclaw_e2e_read_positive_int_env OPENCLAW_LIVE_PLUGIN_TOOL_SESSION_SCAN_MAX_ENTRIES 50000)"',
|
|
'-e "OPENCLAW_LIVE_PLUGIN_TOOL_AGENT_OUTPUT_DUMP_BYTES=$AGENT_OUTPUT_DUMP_BYTES"',
|
|
'-e "OPENCLAW_LIVE_PLUGIN_TOOL_AGENT_OUTPUT_MAX_BYTES=$AGENT_OUTPUT_MAX_BYTES"',
|
|
'-e "OPENCLAW_LIVE_PLUGIN_TOOL_SESSION_SCAN_MAX_ENTRIES=$SESSION_SCAN_MAX_ENTRIES"',
|
|
'-e "OPENCLAW_E2E_COMMAND_TIMEOUT=$COMMAND_TIMEOUT"',
|
|
"OPENCLAW_LIVE_PLUGIN_TOOL_AGENT_OUTPUT_DUMP_BYTES",
|
|
'tail -c "$agent_output_dump_bytes" /tmp/openclaw-agent.json',
|
|
]);
|
|
const earlyTimeoutEnvIndex = runner.indexOf(
|
|
"openclaw_e2e_read_positive_int_env OPENCLAW_LIVE_PLUGIN_TOOL_TIMEOUT_SECONDS 300",
|
|
);
|
|
const profileSourceIndex = runner.indexOf('source "$PROFILE_FILE"');
|
|
const finalTimeoutEnvIndex = runner.lastIndexOf(
|
|
"openclaw_e2e_read_positive_int_env OPENCLAW_LIVE_PLUGIN_TOOL_TIMEOUT_SECONDS",
|
|
);
|
|
const dockerBuildIndex = runner.indexOf("docker_e2e_build_or_reuse");
|
|
expect(earlyTimeoutEnvIndex).toBeGreaterThanOrEqual(0);
|
|
expect(dockerBuildIndex).toBeGreaterThan(earlyTimeoutEnvIndex);
|
|
expect(profileSourceIndex).toBeGreaterThanOrEqual(0);
|
|
expect(profileSourceIndex).toBeGreaterThan(dockerBuildIndex);
|
|
expect(finalTimeoutEnvIndex).toBeGreaterThan(profileSourceIndex);
|
|
|
|
expect(runner).not.toContain(
|
|
'AGENT_OUTPUT_MAX_BYTES="${OPENCLAW_LIVE_PLUGIN_TOOL_AGENT_OUTPUT_MAX_BYTES:-1048576}"',
|
|
);
|
|
|
|
const dumpLogsStart = runner.indexOf("openclaw_e2e_dump_logs \\");
|
|
const dumpLogsEnd = runner.indexOf("\n}", dumpLogsStart);
|
|
expect(runner.slice(dumpLogsStart, dumpLogsEnd)).not.toContain("/tmp/openclaw-agent.json");
|
|
});
|
|
|
|
it.each([
|
|
["timeout", "OPENCLAW_LIVE_PLUGIN_TOOL_TIMEOUT_SECONDS", "1e3"],
|
|
["output cap", "OPENCLAW_LIVE_PLUGIN_TOOL_AGENT_OUTPUT_MAX_BYTES", "64kb"],
|
|
["output dump cap", "OPENCLAW_LIVE_PLUGIN_TOOL_AGENT_OUTPUT_DUMP_BYTES", "64kb"],
|
|
["session scan cap", "OPENCLAW_LIVE_PLUGIN_TOOL_SESSION_SCAN_MAX_ENTRIES", "0"],
|
|
])(
|
|
"rejects invalid live plugin tool Docker %s values before Docker setup",
|
|
(_label, envName, value) => {
|
|
const result = spawnSync("bash", [LIVE_PLUGIN_TOOL_DOCKER_E2E_PATH], {
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
OPENCLAW_LIVE_PLUGIN_TOOL_HOST_BUILD: "0",
|
|
OPENCLAW_SKIP_DOCKER_BUILD: "1",
|
|
[envName]: value,
|
|
},
|
|
});
|
|
|
|
expect(result.status).toBe(2);
|
|
expect(result.stderr).toContain(`invalid ${envName}: ${value}`);
|
|
expect(result.stderr).not.toContain("Docker image not found");
|
|
},
|
|
);
|
|
|
|
it("keeps live plugin tool npm pack tarball paths inside the fixture directory", () => {
|
|
const runner = readFileSync(LIVE_PLUGIN_TOOL_DOCKER_E2E_PATH, "utf8");
|
|
expectTextToIncludeAll(runner, [
|
|
'npm pack --pack-destination "$fixture_dir" --silent',
|
|
"/tmp/openclaw-live-plugin-tool-pack.log",
|
|
"find \"$fixture_dir\" -maxdepth 1 -type f -name '*.tgz' | sort",
|
|
"Expected one packed fixture plugin tarball",
|
|
"openclaw_e2e_dump_logs /tmp/openclaw-live-plugin-tool-pack.log",
|
|
'plugin_tgz="${plugin_tgzs[0]}"',
|
|
]);
|
|
|
|
expect(runner).not.toContain('plugin_tgz="$fixture_dir/$plugin_pack"');
|
|
});
|
|
|
|
it("cleans every prepared Docker package tarball on every runner exit path", () => {
|
|
const paths = packageBackedDockerRunnerPaths();
|
|
|
|
expect(paths.length).toBeGreaterThan(0);
|
|
for (const path of paths) {
|
|
const runner = readFileSync(path, "utf8");
|
|
|
|
expect(runner, path).toMatch(
|
|
/docker_e2e_cleanup_package_tgz "\$\{PACKAGE_TGZ:-\}"|docker_e2e_cleanup_package_tgz "\$PACKAGE_TGZ"/u,
|
|
);
|
|
expect(runner, path).toMatch(/trap cleanup(?:_outer)? EXIT/u);
|
|
expect(runner, path).not.toContain('rm -f "$run_log"\n exit 1');
|
|
}
|
|
});
|
|
|
|
it("runs skill install through the package-cleaning Docker harness", () => {
|
|
const runner = readFileSync(SKILL_INSTALL_DOCKER_E2E_PATH, "utf8");
|
|
expect(runner).toContain('docker_e2e_package_mount_args "$PACKAGE_TGZ"');
|
|
expect(runner).toMatch(
|
|
/run_logged_print \\\n\s+skill-install-run \\\n\s+docker_e2e_run_with_harness \\/u,
|
|
);
|
|
expect(runner).not.toContain("docker_e2e_harness_mount_args");
|
|
expect(runner).not.toContain("docker run --rm");
|
|
});
|
|
|
|
it.each([
|
|
["printed log bytes", "OPENCLAW_DOCKER_E2E_LOG_PRINT_BYTES", "64kb"],
|
|
["heartbeat termination grace", "OPENCLAW_DOCKER_E2E_HEARTBEAT_TERM_GRACE_SECONDS", "soon"],
|
|
])("rejects invalid Docker E2E %s before setup", (_label, envName, value) => {
|
|
const workDir = tempDirs.make("openclaw-docker-e2e-log-invalid-");
|
|
const script = repoShell(workDir)`
|
|
export ${envName}=${shellQuote(value)}
|
|
|
|
source "$ROOT_DIR/scripts/lib/docker-e2e-logs.sh"
|
|
|
|
run_logged_print_heartbeat plugins-run 30 bash -c 'printf "should not print\\\\n"'
|
|
`;
|
|
|
|
const result = spawnSync("bash", ["-lc", script], { encoding: "utf8" });
|
|
|
|
expect(result.status).toBe(2);
|
|
expect(result.stderr).toContain(`invalid ${envName}: ${value}`);
|
|
expect(result.stdout).toBe("");
|
|
});
|
|
|
|
it("rejects invalid Docker E2E log heartbeat env before harness setup", () => {
|
|
const workDir = tempDirs.make("openclaw-docker-e2e-log-heartbeat-invalid-");
|
|
const script = repoShell(workDir)`
|
|
export OPENCLAW_DOCKER_E2E_LOG_HEARTBEAT_SECONDS=1e3
|
|
|
|
source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh"
|
|
|
|
docker_e2e_run_with_harness() {
|
|
echo "should not run"
|
|
}
|
|
|
|
docker_e2e_run_logged_print_with_harness plugins-run image-name
|
|
`;
|
|
|
|
const result = spawnSync("bash", ["-lc", script], { encoding: "utf8" });
|
|
|
|
expect(result.status).toBe(2);
|
|
expect(result.stderr).toContain("invalid OPENCLAW_DOCKER_E2E_LOG_HEARTBEAT_SECONDS: 1e3");
|
|
expect(result.stdout).toBe("");
|
|
});
|
|
|
|
it("preserves heredoc stdin through Docker E2E heartbeat logging", () => {
|
|
const workDir = tempDirs.make("openclaw-docker-e2e-log-stdin-");
|
|
const script = repoShell(workDir)`
|
|
|
|
source "$ROOT_DIR/scripts/lib/docker-e2e-logs.sh"
|
|
|
|
run_logged_print_heartbeat plugins-run 30 bash -s <<'SH'
|
|
printf "first payload line\\nsecond payload line\\n"
|
|
SH
|
|
`;
|
|
|
|
expect(execFileSync("bash", ["-lc", script], { encoding: "utf8" })).toBe(
|
|
"first payload line\nsecond payload line\n",
|
|
);
|
|
});
|
|
|
|
it("preserves failing heredoc output and status through Docker E2E heartbeat logging", () => {
|
|
const workDir = tempDirs.make("openclaw-docker-e2e-log-failing-stdin-");
|
|
const script = repoShell(workDir)`
|
|
|
|
source "$ROOT_DIR/scripts/lib/docker-e2e-logs.sh"
|
|
|
|
run_logged_print_heartbeat plugins-run 30 bash -s <<'SH'
|
|
printf "captured failure output\\n"
|
|
exit 37
|
|
SH
|
|
`;
|
|
|
|
const result = spawnSync("bash", ["-lc", script], { encoding: "utf8" });
|
|
|
|
expect(result.status).toBe(37);
|
|
expect(result.stdout).toBe("captured failure output\n");
|
|
expect(result.stderr).toBe("");
|
|
});
|
|
|
|
it("does not delay fast successful Docker E2E log captures until the next heartbeat", () => {
|
|
const workDir = tempDirs.make("openclaw-docker-e2e-log-fast-heartbeat-");
|
|
const script = repoShell(workDir)`
|
|
|
|
source "$ROOT_DIR/scripts/lib/docker-e2e-logs.sh"
|
|
|
|
output="$(run_logged_print_heartbeat plugins-run 30 bash -c 'printf "quick container log\\\\n"')"
|
|
[[ "$output" = "quick container log" ]]
|
|
`;
|
|
const startedAt = Date.now();
|
|
|
|
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
|
|
|
|
expect(Date.now() - startedAt).toBeLessThan(5_000);
|
|
});
|
|
|
|
it("normalizes zero-padded Docker E2E log heartbeat intervals", () => {
|
|
const script = repoRootShell`
|
|
export ROOT_DIR
|
|
|
|
source "$ROOT_DIR/scripts/lib/docker-e2e-logs.sh"
|
|
|
|
[[ "$(docker_e2e_normalize_positive_int_value 'Docker E2E log heartbeat interval' 08)" = "8" ]]
|
|
`;
|
|
|
|
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
|
|
});
|
|
|
|
it("includes procps in the shared Docker E2E image for process watchdogs", () => {
|
|
const dockerfile = readFileSync("scripts/e2e/Dockerfile", "utf8");
|
|
expect(dockerfile).toContain("procps");
|
|
});
|
|
|
|
it("caches package downloads across prepared Docker E2E image builds", () => {
|
|
const dockerfile = readFileSync("scripts/e2e/Dockerfile", "utf8");
|
|
expect(dockerfile).toContain(
|
|
"--mount=type=cache,target=/home/appuser/.npm,uid=1001,gid=1001,sharing=locked",
|
|
);
|
|
});
|
|
|
|
it("keeps private bundled plugins discoverable without persisting a curated registry", () => {
|
|
const dockerfile = readFileSync("scripts/e2e/Dockerfile", "utf8");
|
|
expect(dockerfile).toContain("runBundledPluginPostinstall");
|
|
expect(dockerfile).not.toContain("node /app/scripts/postinstall-bundled-plugins.mjs");
|
|
});
|
|
|
|
it("keeps onboarding Docker E2E resource-guarded", () => {
|
|
const runner = readFileSync(ONBOARD_DOCKER_E2E_PATH, "utf8");
|
|
expectTextToIncludeAll(runner, [
|
|
"OPENCLAW_ONBOARD_MAX_MEMORY_MIB",
|
|
"OPENCLAW_ONBOARD_MAX_CPU_PERCENT",
|
|
'COMMAND_TIMEOUT="${OPENCLAW_ONBOARD_COMMAND_TIMEOUT:-${OPENCLAW_E2E_COMMAND_TIMEOUT:-300s}}"',
|
|
'GATEWAY_WAIT_ATTEMPTS="$(openclaw_e2e_read_positive_int_env OPENCLAW_ONBOARD_GATEWAY_WAIT_ATTEMPTS 20)"',
|
|
'GATEWAY_WAIT_INTERVAL_S="$(docker_e2e_read_nonnegative_decimal_env OPENCLAW_ONBOARD_GATEWAY_WAIT_INTERVAL_S 1)"',
|
|
'-e "OPENCLAW_E2E_COMMAND_TIMEOUT=$COMMAND_TIMEOUT"',
|
|
'-e "OPENCLAW_ONBOARD_GATEWAY_WAIT_ATTEMPTS=$GATEWAY_WAIT_ATTEMPTS"',
|
|
'-e "OPENCLAW_ONBOARD_GATEWAY_WAIT_INTERVAL_S=$GATEWAY_WAIT_INTERVAL_S"',
|
|
'--name "$CONTAINER_NAME"',
|
|
"docker_e2e_sample_stats_until_exit \\",
|
|
'"$STATS_LOG" \\',
|
|
'"$RUN_LOG" \\',
|
|
"assert-resource-ceiling.mjs",
|
|
]);
|
|
|
|
expect(runner).not.toContain("docker_e2e_run_with_harness -t");
|
|
});
|
|
|
|
it("cleans resource-sampled Docker E2E temp logs on every exit path", () => {
|
|
for (const { path, label } of [
|
|
{ path: ONBOARD_DOCKER_E2E_PATH, label: "onboard" },
|
|
{ path: KITCHEN_SINK_PLUGIN_DOCKER_E2E_PATH, label: "kitchen-sink" },
|
|
{ path: KITCHEN_SINK_RPC_DOCKER_E2E_PATH, label: "kitchen-sink-rpc" },
|
|
]) {
|
|
const runner = readFileSync(path, "utf8");
|
|
const resourceAssertion = `node scripts/e2e/lib/docker-stats/assert-resource-ceiling.mjs "$STATS_LOG" "$MAX_MEMORY_MIB" "$MAX_CPU_PERCENT" ${label}`;
|
|
|
|
expect(runner, path).toContain('RUN_LOG="$(mktemp');
|
|
expect(runner, path).toContain('STATS_LOG="$(mktemp');
|
|
expect(runner, path).toContain(
|
|
'DOCKER_COMMAND_TIMEOUT="$DOCKER_RUN_TIMEOUT" docker_e2e_docker_run_cmd run --name "$CONTAINER_NAME"',
|
|
);
|
|
expect(runner, path).toContain('DOCKER_RUN_TIMEOUT="${OPENCLAW_');
|
|
expect(runner, path).toContain("docker_e2e_sample_stats_until_exit \\");
|
|
expect(runner, path).toContain('"$STATS_LOG" \\');
|
|
expect(runner, path).toContain('"$RUN_LOG" \\');
|
|
expect(runner, path).toContain('docker_e2e_print_log "$RUN_LOG"');
|
|
expect(runner, path).not.toContain('cat "$RUN_LOG"');
|
|
expect(runner, path).not.toMatch(/(^|\n)docker run --name "\$CONTAINER_NAME"/u);
|
|
expect(runner, path).not.toMatch(/(^|\n)docker (?:inspect|stats) /u);
|
|
expect(runner, path).toMatch(/cleanup\(\) \{[\s\S]*rm -f "\$RUN_LOG" "\$STATS_LOG"/u);
|
|
expect(runner, path).toContain(`if [ "$run_status" -eq 0 ]; then\n ${resourceAssertion}`);
|
|
expect(runner, path).toContain(
|
|
`elif [ -s "$STATS_LOG" ]; then\n if ! ${resourceAssertion}; then`,
|
|
);
|
|
expect(runner, path).toContain("RESOURCE_CEILING_FAILED lane=");
|
|
expect(runner, path).toContain("primary_status=$run_status");
|
|
expect(runner, path).not.toContain(`${resourceAssertion} || true`);
|
|
expect(runner, path).not.toContain(`${resourceAssertion}\n\nexit "$run_status"`);
|
|
}
|
|
});
|
|
|
|
it("keeps captured Docker E2E run log replay bounded", () => {
|
|
for (const path of [
|
|
AGENT_BUNDLE_MCP_TOOLS_DOCKER_E2E_PATH,
|
|
SYSTEM_AGENT_FIRST_RUN_DOCKER_E2E_PATH,
|
|
SYSTEM_AGENT_RESCUE_DOCKER_E2E_PATH,
|
|
PLUGIN_BINDING_COMMAND_ESCAPE_DOCKER_E2E_PATH,
|
|
SESSION_RUNTIME_CONTEXT_DOCKER_E2E_PATH,
|
|
]) {
|
|
const runner = readFileSync(path, "utf8");
|
|
|
|
expect(runner, path).toContain('RUN_LOG="$(mktemp');
|
|
expect(runner, path).toContain('docker_e2e_print_log "$RUN_LOG"');
|
|
expect(runner, path).not.toContain('cat "$RUN_LOG"');
|
|
}
|
|
|
|
const pluginBinding = readFileSync(PLUGIN_BINDING_COMMAND_ESCAPE_DOCKER_E2E_PATH, "utf8");
|
|
expect(pluginBinding).toContain("const scanBytes = 65536");
|
|
expect(pluginBinding).toContain("fs.statSync(logPath)");
|
|
expect(pluginBinding).toContain("fs.readSync(fd, buffer, 0, length, stat.size - length)");
|
|
expect(pluginBinding).not.toContain("process.env.OPENCLAW_DOCKER_E2E_LOG_PRINT_BYTES");
|
|
expect(pluginBinding).not.toContain('readFileSync(logPath, "utf8")');
|
|
});
|
|
|
|
it("keeps Open WebUI Docker E2E resource-guarded", () => {
|
|
const runner = readFileSync(OPENWEBUI_DOCKER_E2E_PATH, "utf8");
|
|
expectTextToIncludeAll(runner, [
|
|
'validate_positive_int OPENCLAW_OPENWEBUI_PROVIDER_TIMEOUT_SECONDS "$PROVIDER_TIMEOUT_SECONDS"',
|
|
'validate_positive_int OPENCLAW_OPENWEBUI_FETCH_TIMEOUT_MS "$PROBE_FETCH_TIMEOUT_MS"',
|
|
"docker_e2e_read_tcp_port_env OPENCLAW_OPENWEBUI_GATEWAY_PORT 18789",
|
|
"docker_e2e_read_tcp_port_env OPENCLAW_OPENWEBUI_PORT 8080",
|
|
"OPENCLAW_OPENWEBUI_MAX_MEMORY_MIB",
|
|
"OPENCLAW_OPENWEBUI_MAX_CPU_PERCENT",
|
|
'STATS_LOG="$(mktemp',
|
|
'PROBE_LOG="$(mktemp',
|
|
'STATS_STOP_FILE="$(mktemp',
|
|
"sample_openwebui_stats_once()",
|
|
"start_openwebui_stats_sampler()",
|
|
"start_openwebui_stats_sampler\n",
|
|
'node "$entry" doctor --fix --yes --force',
|
|
`openclaw_e2e_exec_gateway "$entry" '"$PORT"' lan`,
|
|
'for container_name in "$GW_NAME" "$OW_NAME"; do',
|
|
'"$GW_NAME" \\',
|
|
'"$OW_NAME" \\',
|
|
'"$container_name" >>"$STATS_LOG"',
|
|
"assert_openwebui_stats()",
|
|
'node scripts/e2e/lib/docker-stats/assert-resource-ceiling.mjs "$STATS_LOG" "$MAX_MEMORY_MIB" "$MAX_CPU_PERCENT" openwebui',
|
|
'node /app/scripts/e2e/openwebui-probe.mjs >"$PROBE_LOG" 2>&1 &',
|
|
]);
|
|
|
|
expect(runner).toMatch(
|
|
/cleanup\(\) \{[\s\S]*rm -f "\$STATS_STOP_FILE"[\s\S]*wait "\$stats_pid"/u,
|
|
);
|
|
expect(runner).toMatch(/cleanup\(\) \{[\s\S]*rm -f "\$STATS_LOG" "\$PROBE_LOG"/u);
|
|
|
|
expect(runner).toMatch(
|
|
/sample_openwebui_stats_once\nstop_openwebui_stats_samplers\nassert_openwebui_stats\necho "OK"/u,
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
["gateway", "OPENCLAW_OPENWEBUI_GATEWAY_PORT", "1e3"],
|
|
["webui", "OPENCLAW_OPENWEBUI_PORT", "65536"],
|
|
])("rejects invalid Open WebUI Docker %s ports before Docker setup", (_label, envName, value) => {
|
|
const result = spawnSync("bash", [OPENWEBUI_DOCKER_E2E_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("OPENAI_API_KEY is required");
|
|
});
|
|
|
|
it.each([
|
|
["provider", "OPENCLAW_OPENWEBUI_PROVIDER_TIMEOUT_SECONDS", "300s"],
|
|
["fetch", "OPENCLAW_OPENWEBUI_FETCH_TIMEOUT_MS", "8000ms"],
|
|
])(
|
|
"rejects invalid Open WebUI Docker %s timeouts before Docker setup",
|
|
(_label, envName, value) => {
|
|
const result = spawnSync("bash", [OPENWEBUI_DOCKER_E2E_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("OPENAI_API_KEY is required");
|
|
},
|
|
);
|
|
|
|
it("accepts decimal Open WebUI Docker numeric inputs with leading zeroes", () => {
|
|
const result = spawnSync("bash", [OPENWEBUI_DOCKER_E2E_PATH], {
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
OPENAI_API_KEY: "",
|
|
OPENCLAW_OPENWEBUI_FETCH_TIMEOUT_MS: "09000",
|
|
OPENCLAW_OPENWEBUI_GATEWAY_PORT: "018789",
|
|
OPENCLAW_OPENWEBUI_PORT: "08080",
|
|
OPENCLAW_OPENWEBUI_PROVIDER_TIMEOUT_SECONDS: "08",
|
|
},
|
|
});
|
|
|
|
expect(result.status).toBe(2);
|
|
expect(result.stderr).toContain("OPENAI_API_KEY is required");
|
|
expect(result.stderr).not.toContain("value too great for base");
|
|
});
|
|
|
|
it.each([
|
|
[MCP_CODE_MODE_GATEWAY_DOCKER_E2E_PATH, "OPENCLAW_MCP_CODE_MODE_GATEWAY_PORT", "1e3"],
|
|
[MCP_CODE_MODE_GATEWAY_DOCKER_E2E_PATH, "OPENCLAW_MCP_CODE_MODE_MOCK_PORT", "65536"],
|
|
[MCP_CODE_MODE_GATEWAY_LIVE_DOCKER_E2E_PATH, "OPENCLAW_MCP_CODE_MODE_LIVE_GATEWAY_PORT", "0"],
|
|
[CODEX_MEDIA_PATH_DOCKER_E2E_PATH, "OPENCLAW_CODEX_MEDIA_PATH_PORT", "18790tcp"],
|
|
[OPENAI_CHAT_TOOLS_DOCKER_E2E_PATH, "OPENCLAW_OPENAI_CHAT_TOOLS_PORT", "0"],
|
|
[OPENAI_WEB_SEARCH_MINIMAL_E2E_PATH, "OPENCLAW_OPENAI_WEB_SEARCH_MINIMAL_PORT", "18789tcp"],
|
|
])("rejects invalid Docker E2E ports before setup", (scriptPath, envName, value) => {
|
|
const result = spawnSync("bash", [scriptPath], {
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
[envName]: value,
|
|
},
|
|
});
|
|
|
|
expect(result.status).toBe(2);
|
|
expect(result.stderr).toContain(`invalid ${envName}: ${value}`);
|
|
expect(result.stderr).not.toContain("OPENAI_API_KEY was not available");
|
|
});
|
|
|
|
it.each([
|
|
["timeout", "OPENCLAW_CODEX_MEDIA_PATH_TIMEOUT_SECONDS", "180s"],
|
|
["log tail cap", "OPENCLAW_CODEX_MEDIA_PATH_LOG_TAIL_MAX_BYTES", "64kb"],
|
|
])("rejects invalid Codex media path Docker %s before Docker setup", (_label, envName, value) => {
|
|
const result = spawnSync("bash", [CODEX_MEDIA_PATH_DOCKER_E2E_PATH], {
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
[envName]: value,
|
|
OPENCLAW_SKIP_DOCKER_BUILD: "1",
|
|
},
|
|
});
|
|
|
|
expect(result.status).toBe(2);
|
|
expect(result.stderr).toContain(`invalid ${envName}: ${value}`);
|
|
expect(result.stderr).not.toContain("Docker image not found");
|
|
});
|
|
|
|
it("forwards Codex media path client limits into Docker", () => {
|
|
const runner = readFileSync(CODEX_MEDIA_PATH_DOCKER_E2E_PATH, "utf8");
|
|
expect(runner).toContain(
|
|
'LOG_TAIL_MAX_BYTES="$(docker_e2e_read_positive_int_env OPENCLAW_CODEX_MEDIA_PATH_LOG_TAIL_MAX_BYTES 2097152)"',
|
|
);
|
|
expect(runner).toContain(
|
|
'-e "OPENCLAW_CODEX_MEDIA_PATH_LOG_TAIL_MAX_BYTES=$LOG_TAIL_MAX_BYTES"',
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
[MCP_CODE_MODE_GATEWAY_DOCKER_E2E_PATH, "OPENCLAW_MCP_CODE_MODE_CLIENT_TIMEOUT_MS", "1e3"],
|
|
[
|
|
MCP_CODE_MODE_GATEWAY_DOCKER_E2E_PATH,
|
|
"OPENCLAW_MCP_CODE_MODE_CLIENT_BODY_MAX_BYTES",
|
|
"64bytes",
|
|
],
|
|
[MCP_CODE_MODE_GATEWAY_LIVE_DOCKER_E2E_PATH, "OPENCLAW_MCP_CODE_MODE_CLIENT_TIMEOUT_MS", "1e3"],
|
|
[
|
|
MCP_CODE_MODE_GATEWAY_LIVE_DOCKER_E2E_PATH,
|
|
"OPENCLAW_MCP_CODE_MODE_CLIENT_BODY_MAX_BYTES",
|
|
"64bytes",
|
|
],
|
|
])("rejects invalid MCP code-mode client env before setup", (scriptPath, envName, value) => {
|
|
const result = spawnSync("bash", [scriptPath], {
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
[envName]: value,
|
|
OPENCLAW_SKIP_DOCKER_BUILD: "1",
|
|
},
|
|
});
|
|
|
|
expect(result.status).toBe(2);
|
|
expect(result.stderr).toContain(`invalid ${envName}: ${value}`);
|
|
expect(result.stderr).not.toContain("Docker image not found");
|
|
expect(result.stderr).not.toContain("OPENAI_API_KEY was not available");
|
|
});
|
|
|
|
it.each([MCP_CODE_MODE_GATEWAY_DOCKER_E2E_PATH, MCP_CODE_MODE_GATEWAY_LIVE_DOCKER_E2E_PATH])(
|
|
"forwards MCP code-mode client fetch limits into Docker",
|
|
(scriptPath) => {
|
|
const runner = readFileSync(scriptPath, "utf8");
|
|
|
|
expectTextToIncludeAll(runner, [
|
|
'CLIENT_TIMEOUT_MS="$(docker_e2e_read_positive_int_env OPENCLAW_MCP_CODE_MODE_CLIENT_TIMEOUT_MS 300000)"',
|
|
'CLIENT_BODY_MAX_BYTES="$(docker_e2e_read_positive_int_env OPENCLAW_MCP_CODE_MODE_CLIENT_BODY_MAX_BYTES 1048576)"',
|
|
'-e "OPENCLAW_MCP_CODE_MODE_CLIENT_TIMEOUT_MS=$CLIENT_TIMEOUT_MS"',
|
|
'-e "OPENCLAW_MCP_CODE_MODE_CLIENT_BODY_MAX_BYTES=$CLIENT_BODY_MAX_BYTES"',
|
|
]);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
["timeout", "OPENCLAW_OPENAI_CHAT_TOOLS_TIMEOUT_SECONDS", "180s"],
|
|
["body cap", "OPENCLAW_OPENAI_CHAT_TOOLS_MAX_BODY_BYTES", "64kb"],
|
|
])("rejects invalid OpenAI chat tools Docker %s before auth setup", (_label, envName, value) => {
|
|
const result = spawnSync("bash", [OPENAI_CHAT_TOOLS_DOCKER_E2E_PATH], {
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
OPENAI_API_KEY: "",
|
|
[envName]: value,
|
|
},
|
|
});
|
|
|
|
expect(result.status).toBe(2);
|
|
expect(result.stderr).toContain(`invalid ${envName}: ${value}`);
|
|
expect(result.stderr).not.toContain("OPENAI_API_KEY was not available");
|
|
});
|
|
|
|
it("forwards every OpenAI chat tools runtime env knob into Docker", () => {
|
|
const runner = readFileSync(OPENAI_CHAT_TOOLS_DOCKER_E2E_PATH, "utf8");
|
|
const client = readFileSync("scripts/e2e/lib/openai-chat-tools/client.mjs", "utf8");
|
|
const writer = readFileSync("scripts/e2e/lib/openai-chat-tools/write-config.mjs", "utf8");
|
|
const consumed = new Set(
|
|
[...`${client}\n${writer}`.matchAll(/["`](OPENCLAW_OPENAI_CHAT_TOOLS_[A-Z0-9_]+)["`]/gu)]
|
|
.map((match) => match[1])
|
|
.filter((envName): envName is string => envName !== undefined),
|
|
);
|
|
const forwarded = new Set(
|
|
[...runner.matchAll(/-e\s+"(OPENCLAW_OPENAI_CHAT_TOOLS_[A-Z0-9_]+)=/gu)]
|
|
.map((match) => match[1])
|
|
.filter((envName): envName is string => envName !== undefined),
|
|
);
|
|
const missing = [...consumed]
|
|
.filter((envName) => !forwarded.has(envName))
|
|
.toSorted((left, right) => left.localeCompare(right));
|
|
|
|
expect(missing).toEqual([]);
|
|
});
|
|
|
|
it("forwards every kitchen-sink RPC runtime env knob into Docker", () => {
|
|
const runner = readFileSync(KITCHEN_SINK_RPC_DOCKER_E2E_PATH, "utf8");
|
|
const walk = readFileSync("scripts/e2e/kitchen-sink-rpc-walk.mts", "utf8");
|
|
const consumed = new Set(
|
|
[...walk.matchAll(/\b(?:env|process\.env)\.(OPENCLAW_KITCHEN_SINK_[A-Z0-9_]+)/gu)]
|
|
.map((match) => match[1])
|
|
.filter((envName): envName is string => envName !== undefined),
|
|
);
|
|
const forwarded = new Set(
|
|
[...runner.matchAll(/\b(OPENCLAW_KITCHEN_SINK_[A-Z0-9_]+)\b/gu)]
|
|
.map((match) => match[1])
|
|
.filter((envName): envName is string => envName !== undefined),
|
|
);
|
|
const missing = [...consumed]
|
|
.filter((envName) => !forwarded.has(envName))
|
|
.toSorted((left, right) => left.localeCompare(right));
|
|
|
|
expect(missing).toEqual([]);
|
|
});
|
|
|
|
it("keeps the kitchen-sink RPC Docker watchdog above the internal walk budgets", () => {
|
|
const runner = readFileSync(KITCHEN_SINK_RPC_DOCKER_E2E_PATH, "utf8");
|
|
expect(runner).toContain(
|
|
'DOCKER_RUN_TIMEOUT="${OPENCLAW_KITCHEN_SINK_RPC_DOCKER_RUN_TIMEOUT:-1500s}"',
|
|
);
|
|
});
|
|
|
|
it("bounds kitchen-sink plugin CLI commands inside the Docker sweep", () => {
|
|
const runner = readFileSync(KITCHEN_SINK_PLUGIN_DOCKER_E2E_PATH, "utf8");
|
|
const sweep = readFileSync("scripts/e2e/lib/kitchen-sink-plugin/sweep.sh", "utf8");
|
|
|
|
expectTextToIncludeAll(runner, [
|
|
'KITCHEN_SINK_CLI_TIMEOUT="${OPENCLAW_KITCHEN_SINK_PLUGIN_CLI_TIMEOUT:-${KITCHEN_SINK_CLI_TIMEOUT:-180s}}"',
|
|
"docker_e2e_read_positive_int_env OPENCLAW_DOCKER_E2E_LOG_PRINT_BYTES 65536",
|
|
"docker_e2e_read_positive_int_env OPENCLAW_CLAWHUB_FIXTURE_WAIT_ATTEMPTS 600",
|
|
'-e "OPENCLAW_CLAWHUB_FIXTURE_WAIT_ATTEMPTS=$CLAW_HUB_FIXTURE_WAIT_ATTEMPTS"',
|
|
'-e "OPENCLAW_DOCKER_E2E_LOG_PRINT_BYTES=$OPENCLAW_DOCKER_E2E_LOG_PRINT_BYTES"',
|
|
'-e "KITCHEN_SINK_CLI_TIMEOUT=$KITCHEN_SINK_CLI_TIMEOUT"',
|
|
]);
|
|
|
|
expectTextToIncludeAll(sweep, [
|
|
'KITCHEN_SINK_CLI_TIMEOUT="${KITCHEN_SINK_CLI_TIMEOUT:-180s}"',
|
|
"run_kitchen_sink_openclaw_logged()",
|
|
"run_kitchen_sink_openclaw_capture()",
|
|
'openclaw_e2e_maybe_timeout "$KITCHEN_SINK_CLI_TIMEOUT" node "$OPENCLAW_ENTRY" "$@" >"$log_file" 2>&1',
|
|
'local log_file="${KITCHEN_SINK_TMP_DIR}/${safe_label}.log"',
|
|
]);
|
|
|
|
for (const line of sweep.split("\n")) {
|
|
if (!line.includes('node "$OPENCLAW_ENTRY" plugins')) {
|
|
continue;
|
|
}
|
|
|
|
expect(line).toContain("openclaw_e2e_maybe_timeout");
|
|
}
|
|
});
|
|
|
|
it("routes named Docker E2E container cleanup through the timeout-aware helper", () => {
|
|
for (const path of readdirSync("scripts/e2e")
|
|
.filter((entry) => entry.endsWith("-docker.sh"))
|
|
.map((entry) => join("scripts/e2e", entry))) {
|
|
const runner = readFileSync(path, "utf8");
|
|
if (!runner.includes('CONTAINER_NAME="')) {
|
|
continue;
|
|
}
|
|
|
|
expect(runner, path).not.toMatch(/(^|\n)\s*docker rm -f "\$CONTAINER_NAME"/u);
|
|
expect(runner, path).toContain('docker_e2e_docker_cmd rm -f "$CONTAINER_NAME"');
|
|
}
|
|
|
|
const composeRunner = readFileSync(COMPOSE_SETUP_E2E_PATH, "utf8");
|
|
expect(composeRunner).not.toMatch(/(^|\n)\s*docker rm -f "\$CLI_NAME"/u);
|
|
expect(composeRunner).toContain('docker_e2e_docker_cmd rm -f "$CLI_NAME"');
|
|
|
|
const packageRunner = readFileSync(DOCKER_PACKAGE_INSTALL_E2E_PATH, "utf8");
|
|
expect(packageRunner).not.toMatch(/(^|\n)\s*docker rm -f/u);
|
|
expect(packageRunner).toContain("docker_e2e_docker_cmd rm -f");
|
|
expect(packageRunner).toContain(
|
|
'DOCKER_RUN_TIMEOUT="${OPENCLAW_DOCKER_PACKAGE_INSTALL_RUN_TIMEOUT:-120s}"',
|
|
);
|
|
expect(packageRunner).toContain(
|
|
'DOCKER_COMMAND_TIMEOUT="$DOCKER_RUN_TIMEOUT" docker_e2e_docker_run_cmd run -d',
|
|
);
|
|
expect(packageRunner).not.toMatch(/(^|\n)docker run -d/u);
|
|
for (const runner of [composeRunner, packageRunner]) {
|
|
expect(runner).toContain(
|
|
'node --import tsx "$ROOT_DIR/scripts/e2e/lib/docker-artifact-proof/write-identities.ts"',
|
|
);
|
|
}
|
|
});
|
|
|
|
it("executes each CLI distribution boundary instead of promoting metadata", () => {
|
|
const installerRunner = readFileSync(CLI_INSTALLER_DISTRIBUTION_E2E_PATH, "utf8");
|
|
const packageRunner = readFileSync(DOCKER_PACKAGE_INSTALL_E2E_PATH, "utf8");
|
|
const updateRunner = readFileSync(UPDATE_CHANNEL_SWITCH_DOCKER_E2E_PATH, "utf8");
|
|
|
|
expectTextToIncludeAll(packageRunner, [
|
|
"npm install -g --prefix /tmp/openclaw-proof",
|
|
"pnpm add --global --allow-build=openclaw",
|
|
"bun@1.3.14",
|
|
'test "$(command -v openclaw)" = "/tmp/openclaw-proof/bin/openclaw"',
|
|
'test "$(command -v openclaw)" = "$PNPM_HOME/openclaw"',
|
|
"OPENCLAW_BUN_GLOBAL_SMOKE_PROOF_PATH",
|
|
'BUN_HARNESS_DIR="$(mktemp -d',
|
|
"chmod -R a+rX",
|
|
'-v "$BUN_HARNESS_DIR:/repo:ro"',
|
|
'--container "npm=$NPM_PROOF_CONTAINER"',
|
|
'--container "pnpm=$PNPM_PROOF_CONTAINER"',
|
|
'--container "bun=$BUN_PROOF_CONTAINER"',
|
|
]);
|
|
expect(packageRunner).not.toContain('-v "$ROOT_DIR:/repo:ro"');
|
|
expectTextToIncludeAll(installerRunner, [
|
|
"bash /tmp/install.sh",
|
|
"--version file:/tmp/openclaw-current.tgz",
|
|
'source "$HOME/.bashrc"',
|
|
"hash -r",
|
|
"bash /tmp/openclaw-source/scripts/install-cli.sh",
|
|
"--install-method git",
|
|
"--prefix /tmp/openclaw-prefix",
|
|
"--node-version 24.15.0",
|
|
"apt-get install -y --no-install-recommends curl",
|
|
"command -v curl >/dev/null",
|
|
'chmod 0555 "$SOURCE_PROOF_SCRIPT"',
|
|
'SOURCE_MEMORY="${OPENCLAW_CLI_INSTALLER_SOURCE_MEMORY:-16g}"',
|
|
'--memory "$SOURCE_MEMORY"',
|
|
"runuser -u appuser",
|
|
'test -r "$0"',
|
|
'test -x "$0"',
|
|
'grep -Fq "/tmp/openclaw-source/dist/entry.js" "$prefix_cli"',
|
|
"openclaw update status --json",
|
|
"expected git install kind",
|
|
]);
|
|
expect(installerRunner.match(/--memory "\$SOURCE_MEMORY"/gu)).toHaveLength(1);
|
|
expect(installerRunner.indexOf('--memory "$SOURCE_MEMORY"')).toBeGreaterThan(
|
|
installerRunner.indexOf('echo "==> install-cli.sh dedicated-prefix source-checkout proof"'),
|
|
);
|
|
expectTextToIncludeAll(updateRunner, [
|
|
"openclaw update --channel beta",
|
|
'OPENCLAW_NPM_REGISTRY_DIST_TAGS="latest=0.0.0,beta=$package_version"',
|
|
"OPENCLAW_NPM_REGISTRY_UPSTREAM=https://registry.npmjs.org",
|
|
"assert-update beta",
|
|
"assert-config-channel beta",
|
|
"assert-installed-version",
|
|
"assert-status-kind package",
|
|
"openclaw update --channel dev",
|
|
"openclaw update --channel stable",
|
|
]);
|
|
expect(updateRunner).toContain("openclaw update --channel beta --yes --json --no-restart");
|
|
expect(updateRunner).not.toContain("openclaw update --channel beta --tag");
|
|
});
|
|
|
|
it("routes the gateway network client through the timeout-aware run helper", () => {
|
|
const runner = readFileSync(GATEWAY_NETWORK_DOCKER_E2E_PATH, "utf8");
|
|
expect(runner).toContain(
|
|
'DOCKER_COMMAND_TIMEOUT="$CLIENT_TIMEOUT" run_logged gateway-network-client docker_e2e_docker_run_cmd run --rm',
|
|
);
|
|
expect(runner).not.toContain(
|
|
'run_logged gateway-network-client timeout "$CLIENT_TIMEOUT" docker run --rm',
|
|
);
|
|
});
|
|
|
|
it("proves gateway suspension across a same-container process restart", () => {
|
|
const runner = readFileSync(GATEWAY_NETWORK_DOCKER_E2E_PATH, "utf8");
|
|
expectTextToIncludeAll(runner, [
|
|
"plugins enable admin-http-rpc",
|
|
"/tmp/gateway-network-configured",
|
|
"run_suspension_phase() {",
|
|
"GW_MODE=suspension-$stage-restart",
|
|
"run_suspension_phase pre",
|
|
"run_suspension_phase post",
|
|
"GW_URL=ws://127.0.0.1:$PORT",
|
|
'SUSPENSION_STATE_PATH="/tmp/gateway-network-suspension.json"',
|
|
'container_id="$(docker_e2e_docker_cmd inspect',
|
|
'docker_e2e_docker_cmd stop "$GW_NAME"',
|
|
'docker_e2e_docker_cmd start "$GW_NAME"',
|
|
'if [[ "$restarted_container_id" != "$container_id" ]]',
|
|
"openclaw_e2e_probe_http http://127.0.0.1:$PORT/readyz ok 400",
|
|
'run_logged_print "gateway-network-suspension-$stage"',
|
|
'"phase":"container-restart","durationMs":%d',
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
["connect", "OPENCLAW_GATEWAY_NETWORK_CLIENT_CONNECT_TIMEOUT_MS", "100ms"],
|
|
["ready", "OPENCLAW_GATEWAY_NETWORK_CONNECT_READY_TIMEOUT_MS", "1e3"],
|
|
])(
|
|
"rejects invalid gateway network client %s timeout before Docker setup",
|
|
(_label, envName, value) => {
|
|
const result = spawnSync("bash", [GATEWAY_NETWORK_DOCKER_E2E_PATH], {
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
[envName]: value,
|
|
OPENCLAW_SKIP_DOCKER_BUILD: "1",
|
|
},
|
|
});
|
|
|
|
expect(result.status).toBe(2);
|
|
expect(result.stderr).toContain(`invalid ${envName}: ${value}`);
|
|
expect(result.stderr).not.toContain("Docker image not found");
|
|
},
|
|
);
|
|
|
|
it("forwards gateway network client timeout env into the Docker client", () => {
|
|
const runner = readFileSync(GATEWAY_NETWORK_DOCKER_E2E_PATH, "utf8");
|
|
expectTextToIncludeAll(runner, [
|
|
"docker_e2e_read_positive_int_env OPENCLAW_GATEWAY_NETWORK_CLIENT_CONNECT_TIMEOUT_MS 80000",
|
|
"docker_e2e_read_positive_int_env OPENCLAW_GATEWAY_NETWORK_CONNECT_READY_TIMEOUT_MS 80000",
|
|
'-e "OPENCLAW_GATEWAY_NETWORK_CLIENT_CONNECT_TIMEOUT_MS=$CLIENT_CONNECT_TIMEOUT_MS"',
|
|
'-e "OPENCLAW_GATEWAY_NETWORK_CONNECT_READY_TIMEOUT_MS=$CONNECT_READY_TIMEOUT_MS"',
|
|
'"${CLIENT_LIMIT_ENV_ARGS[@]}"',
|
|
]);
|
|
});
|
|
|
|
it("requires TCP readiness for the gateway network runner", () => {
|
|
const runner = readFileSync(GATEWAY_NETWORK_DOCKER_E2E_PATH, "utf8");
|
|
expect(runner).toContain("openclaw_e2e_probe_tcp 127.0.0.1 $PORT");
|
|
expect(runner).not.toMatch(/openclaw_e2e_probe_tcp[^\n]*\|\|[^\n]*gateway-net-e2e\.log/u);
|
|
});
|
|
|
|
it("copies root lifecycle scripts before cleanup-smoke installs dependencies", () => {
|
|
const dockerfile = readFileSync(CLEANUP_SMOKE_DOCKERFILE_PATH, "utf8");
|
|
const installIndex = dockerfile.indexOf("pnpm install --frozen-lockfile");
|
|
|
|
for (const script of [
|
|
"scripts/preinstall-package-manager-warning.mjs",
|
|
"scripts/postinstall-bundled-plugins.mjs",
|
|
"scripts/prepare-git-hooks.mjs",
|
|
]) {
|
|
const copyIndex = dockerfile.indexOf(script);
|
|
|
|
expect(copyIndex, script).toBeGreaterThanOrEqual(0);
|
|
expect(copyIndex, script).toBeLessThan(installIndex);
|
|
}
|
|
});
|
|
|
|
it("mounts root helper modules imported by bare Docker E2E scripts", () => {
|
|
const helper = readFileSync(DOCKER_E2E_PACKAGE_HELPER_PATH, "utf8");
|
|
expectTextToIncludeAll(helper, [
|
|
"--allow-unreleased-changelog",
|
|
'local harness_root="${DOCKER_E2E_HARNESS_ROOT_DIR:-$ROOT_DIR}"',
|
|
'-v "$harness_root/scripts/windows-cmd-helpers.mjs:/app/scripts/windows-cmd-helpers.mjs:ro"',
|
|
'-v "$harness_root/packages/gateway-client/src:/app/packages/gateway-client/src:ro"',
|
|
'-v "$harness_root/packages/normalization-core/package.json:/app/packages/normalization-core/package.json:ro"',
|
|
'-v "$harness_root/packages/normalization-core/src:/app/packages/normalization-core/src:ro"',
|
|
'-v "$harness_root/tsconfig.json:/app/tsconfig.json:ro"',
|
|
'-v "$harness_root/test/e2e/qa-lab:/app/test/e2e/qa-lab:ro"',
|
|
'-v "$harness_root/test/helpers:/app/test/helpers:ro"',
|
|
]);
|
|
});
|
|
|
|
it("preserves pnpm lookup paths for scheduled Docker child lanes", () => {
|
|
const scheduler = readFileSync(DOCKER_ALL_SCHEDULER_PATH, "utf8");
|
|
expect(scheduler).toContain("--allow-unreleased-changelog");
|
|
expect(scheduler).toContain("env.PNPM_HOME");
|
|
expect(scheduler).toContain("env.npm_execpath ? path.dirname(env.npm_execpath)");
|
|
expect(scheduler).toContain("path.dirname(process.execPath)");
|
|
expect(scheduler).toContain("env.PATH = [...new Set(pathEntries)].join(path.delimiter)");
|
|
expect(scheduler).toContain(
|
|
"const pnpmCommand = env.OPENCLAW_DOCKER_ALL_PNPM_COMMAND?.trim();",
|
|
);
|
|
expect(scheduler).toContain("lane.command.replace(/(^|\\s)pnpm(?=\\s)/g");
|
|
expect(scheduler).toContain(
|
|
'env.push(["OPENCLAW_DOCKER_ALL_PNPM_COMMAND", baseEnv.OPENCLAW_DOCKER_ALL_PNPM_COMMAND]);',
|
|
);
|
|
});
|
|
|
|
it("runs release installer E2E against the npm beta tag", () => {
|
|
const scenarios = readFileSync(DOCKER_E2E_SCENARIOS_PATH, "utf8");
|
|
const openWebUiRunner = readFileSync(OPENWEBUI_DOCKER_E2E_PATH, "utf8");
|
|
|
|
expect(scenarios).toContain(
|
|
'"OPENCLAW_INSTALL_TAG=beta OPENCLAW_E2E_MODELS=openai OPENCLAW_INSTALL_E2E_IMAGE=openclaw-install-e2e-openai:local OPENCLAW_INSTALL_E2E_AGENT_TOOL_SMOKE=0 OPENCLAW_INSTALL_E2E_OPENAI_MODEL=openai/gpt-5.4-mini OPENCLAW_INSTALL_E2E_AGENT_TURN_TIMEOUT_SECONDS=120 OPENCLAW_INSTALL_E2E_OPENAI_PROVIDER_TIMEOUT_SECONDS=120"',
|
|
);
|
|
expect(scenarios).toContain(
|
|
'"OPENCLAW_INSTALL_TAG=beta OPENCLAW_E2E_MODELS=anthropic OPENCLAW_INSTALL_E2E_IMAGE=openclaw-install-e2e-anthropic:local"',
|
|
);
|
|
expect(scenarios).toContain('"test-install-sh-e2e-docker.sh"');
|
|
expect(scenarios).not.toContain("pnpm test:install:e2e");
|
|
expect(scenarios).toContain(
|
|
'"OPENCLAW_OPENWEBUI_MODEL=openai/gpt-5.4-mini OPENCLAW_OPENWEBUI_PROVIDER_TIMEOUT_SECONDS=300 OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:openwebui"',
|
|
);
|
|
expect(scenarios).not.toContain("OPENWEBUI_SMOKE_MODE=models");
|
|
expect(openWebUiRunner).toContain(
|
|
'SMOKE_MODE="${OPENWEBUI_SMOKE_MODE:-${OPENCLAW_OPENWEBUI_SMOKE_MODE:-chat}}"',
|
|
);
|
|
expect(openWebUiRunner).toContain('-e "OPENWEBUI_SMOKE_MODE=$SMOKE_MODE"');
|
|
});
|
|
|
|
it("times and parallelizes release installer E2E agent turns after gateway startup", () => {
|
|
const runner = readFileSync(INSTALL_E2E_RUNNER_PATH, "utf8");
|
|
const wrapper = readFileSync("scripts/test-install-sh-e2e-docker.sh", "utf8");
|
|
|
|
expectTextToIncludeAll(runner, [
|
|
'AGENT_TURNS_PARALLEL="$(read_boolean_env OPENCLAW_INSTALL_E2E_AGENT_TURNS_PARALLEL 1)"',
|
|
'AGENT_TOOL_SMOKE="$(read_boolean_env OPENCLAW_INSTALL_E2E_AGENT_TOOL_SMOKE 1)"',
|
|
"time_phase",
|
|
"phase_mark_start",
|
|
"run_agent_turn_bg",
|
|
"wait_agent_turn_batch",
|
|
"agent_turn_outputs_include_billing_drift",
|
|
"SKIP: Anthropic billing drift during installer agent tool smoke",
|
|
'run_agent_turn_bg "image write"',
|
|
'run_agent_turn_logged_or_skip_profile "read proof copy"',
|
|
"OPENCLAW_INSTALL_E2E_OPENAI_MODEL",
|
|
"OPENCLAW_INSTALL_E2E_OPENAI_PROVIDER_TIMEOUT_SECONDS",
|
|
'AGENT_TURN_TIMEOUT_SECONDS="$(read_positive_int_env OPENCLAW_INSTALL_E2E_AGENT_TURN_TIMEOUT_SECONDS 300)"',
|
|
]);
|
|
|
|
expect(runner).not.toContain('run_agent_turn_bg "read proof"');
|
|
|
|
expectTextToIncludeAll(wrapper, [
|
|
"OPENCLAW_INSTALL_E2E_AGENT_TURNS_PARALLEL",
|
|
"OPENCLAW_INSTALL_E2E_AGENT_TOOL_SMOKE",
|
|
"OPENCLAW_INSTALL_E2E_OPENAI_MODEL",
|
|
"OPENCLAW_INSTALL_E2E_OPENAI_PROVIDER_TIMEOUT_SECONDS",
|
|
"docker_e2e_read_positive_int_env OPENCLAW_INSTALL_E2E_AGENT_TURN_TIMEOUT_SECONDS 300",
|
|
'docker_e2e_read_positive_int_env OPENCLAW_INSTALL_E2E_OPENAI_PROVIDER_TIMEOUT_SECONDS "$AGENT_TURN_TIMEOUT_SECONDS"',
|
|
'-e OPENCLAW_INSTALL_E2E_AGENT_TURN_TIMEOUT_SECONDS="$AGENT_TURN_TIMEOUT_SECONDS"',
|
|
"OPENCLAW_INSTALL_E2E_PROFILE_FILE",
|
|
"OPENCLAW_PROFILE_FILE",
|
|
"OPENCLAW_TESTBOX_PROFILE_FILE",
|
|
"read_profile_env_value",
|
|
'source "$PROFILE_FILE"',
|
|
'export "$key"',
|
|
"Profile file: $PROFILE_STATUS",
|
|
]);
|
|
|
|
expect(wrapper).not.toContain("set -a");
|
|
});
|
|
|
|
it("keeps package acceptance plugin coverage offline-capable", () => {
|
|
const scenarios = readFileSync(DOCKER_E2E_SCENARIOS_PATH, "utf8");
|
|
expect(scenarios).toContain('"plugins-offline"');
|
|
expect(scenarios).toContain("`bundled-plugin-install-uninstall-${index}`");
|
|
expect(scenarios).toContain("pnpm test:docker:bundled-plugin-install-uninstall");
|
|
expect(scenarios).toContain("OPENCLAW_PLUGINS_E2E_CLAWHUB=0");
|
|
});
|
|
|
|
it("allows plugin update smoke to tolerate config metadata migrations", () => {
|
|
const runner = readFileSync(PLUGIN_UPDATE_DOCKER_E2E_PATH, "utf8");
|
|
const scenario = readFileSync(PLUGIN_UPDATE_SCENARIO_PATH, "utf8");
|
|
const probe = readFileSync(PLUGIN_UPDATE_PROBE_PATH, "utf8");
|
|
|
|
expect(runner).toContain("scripts/e2e/lib/plugin-update/unchanged-scenario.sh");
|
|
expect(probe).toContain("plugin install record changed unexpectedly");
|
|
expect(probe).toContain(
|
|
"readPluginInstallRecords({ fallbackRecords: config.plugins?.installs ?? {} })",
|
|
);
|
|
expect(scenario).toContain("Config changed unexpectedly for modern package");
|
|
expect(scenario).not.toContain("before_hash");
|
|
});
|
|
|
|
it("fails the multi-node update probe on update or restart regressions", () => {
|
|
const runner = readFileSync(MULTI_NODE_UPDATE_DOCKER_E2E_PATH, "utf8");
|
|
expectTextToIncludeAll(runner, [
|
|
"UPDATE_FAILED=0",
|
|
"GATEWAY_START_FAILED=0",
|
|
"GATEWAY_HEALTH_FAILED=0",
|
|
'if [ "$UPDATE_FAILED" -ne 0 ]; then',
|
|
'if [ "$GATEWAY_START_FAILED" -ne 0 ]; then',
|
|
'if [ "$GATEWAY_HEALTH_FAILED" -ne 0 ]; then',
|
|
'printf "%s\\n" "\\$!" >"$GATEWAY_PID_FILE"',
|
|
'printf "ActiveState=active\\nSubState=running',
|
|
'status.service?.runtime?.status !== "running"',
|
|
"FAIL: gateway service was not running before update",
|
|
"OPENCLAW_NO_RESPAWN=1",
|
|
"is-enabled)",
|
|
"/healthz",
|
|
"FAIL: gateway install failed before update",
|
|
]);
|
|
|
|
expect(runner).not.toContain('gateway-install.err" || true');
|
|
expect(runner).not.toContain("WARNING: Gateway status probe failed");
|
|
});
|
|
|
|
it("keeps a stalled multi-node health request inside the probe deadline", () => {
|
|
const runner = readFileSync(MULTI_NODE_UPDATE_DOCKER_E2E_PATH, "utf8");
|
|
const startMarker = "if PORT=18789 node <<NODE\n";
|
|
const endMarker = "\nNODE\n then";
|
|
const start = runner.indexOf(startMarker);
|
|
const end = runner.indexOf(endMarker, start + startMarker.length);
|
|
expect(start).toBeGreaterThanOrEqual(0);
|
|
expect(end).toBeGreaterThan(start);
|
|
const probe = runner.slice(start + startMarker.length, end);
|
|
const workDir = tempDirs.make("openclaw-multi-node-health-timeout-");
|
|
const preloadPath = join(workDir, "stalling-fetch.mjs");
|
|
|
|
writeFileSync(
|
|
preloadPath,
|
|
[
|
|
"// Advance the deadline when the request aborts without waiting 30 wall-clock seconds.",
|
|
"const realSetTimeout = globalThis.setTimeout;",
|
|
"let now = 0;",
|
|
"Date.now = () => now;",
|
|
"Object.defineProperty(AbortSignal, 'timeout', { value(delayMs) {",
|
|
" const controller = new AbortController();",
|
|
" realSetTimeout(() => {",
|
|
" now += delayMs;",
|
|
" controller.abort(new DOMException('health deadline elapsed', 'TimeoutError'));",
|
|
" }, 0);",
|
|
" return controller.signal;",
|
|
"} });",
|
|
"globalThis.fetch = async (_url, init = {}) => await new Promise((_resolve, reject) => {",
|
|
" init.signal.addEventListener('abort', () => {",
|
|
" process.stderr.write('hung fetch aborted\\n');",
|
|
" reject(init.signal.reason);",
|
|
" }, { once: true });",
|
|
"});",
|
|
].join("\n"),
|
|
);
|
|
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
["--import", pathToFileURL(preloadPath).href, "--input-type=module", "--eval", probe],
|
|
{
|
|
encoding: "utf8",
|
|
env: { ...process.env, PORT: "18789" },
|
|
timeout: 5_000,
|
|
},
|
|
);
|
|
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr.match(/hung fetch aborted/gu)).toHaveLength(1);
|
|
expect(result.stderr).toContain("health deadline elapsed");
|
|
});
|
|
|
|
it("caps package acceptance legacy compatibility at 2026.4.25", () => {
|
|
const doctorScenario = readFileSync(DOCTOR_SWITCH_SCENARIO_PATH, "utf8");
|
|
const updateChannel = readFileSync(UPDATE_CHANNEL_SWITCH_DOCKER_E2E_PATH, "utf8");
|
|
const pluginsSweep = readFileSync(PLUGINS_DOCKER_SWEEP_PATH, "utf8");
|
|
const pluginsMarketplace = readFileSync(PLUGINS_DOCKER_MARKETPLACE_PATH, "utf8");
|
|
const pluginsClawhub = readFileSync(PLUGINS_DOCKER_CLAWHUB_PATH, "utf8");
|
|
const pluginsAssertions = readFileSync(PLUGINS_DOCKER_ASSERTIONS_PATH, "utf8");
|
|
const pluginUpdateScenario = readFileSync(PLUGIN_UPDATE_SCENARIO_PATH, "utf8");
|
|
const pluginUpdateProbe = readFileSync(PLUGIN_UPDATE_PROBE_PATH, "utf8");
|
|
const updateChannelAssertions = readFileSync(UPDATE_CHANNEL_SWITCH_ASSERTIONS_PATH, "utf8");
|
|
const packageCompat = readFileSync(PACKAGE_COMPAT_PATH, "utf8");
|
|
const doctorLoginctlShim = readFileSync(DOCTOR_SWITCH_LOGINCTL_SHIM_PATH, "utf8");
|
|
const doctorSystemctlShim = readFileSync(DOCTOR_SWITCH_SYSTEMCTL_SHIM_PATH, "utf8");
|
|
const scripts = [
|
|
doctorScenario,
|
|
updateChannel,
|
|
updateChannelAssertions,
|
|
pluginsSweep,
|
|
pluginsMarketplace,
|
|
pluginsClawhub,
|
|
pluginsAssertions,
|
|
pluginUpdateScenario,
|
|
pluginUpdateProbe,
|
|
];
|
|
|
|
expect(readFileSync(DOCTOR_SWITCH_DOCKER_E2E_PATH, "utf8")).toContain(
|
|
"scripts/e2e/lib/doctor-install-switch/scenario.sh",
|
|
);
|
|
expectTextToIncludeAll(doctorScenario, [
|
|
"cp scripts/e2e/lib/doctor-install-switch/shims/systemctl",
|
|
"cp scripts/e2e/lib/doctor-install-switch/shims/loginctl",
|
|
"OPENCLAW_UPDATE_PARENT_ALLOWS_GATEWAY_SERVICE_REPAIR=1",
|
|
"scripts/e2e/lib/package-compat.mjs",
|
|
]);
|
|
|
|
expect(doctorLoginctlShim).toContain("Linger=yes");
|
|
expect(doctorSystemctlShim).toContain("ActiveState=inactive");
|
|
expect(doctorSystemctlShim).toContain('unit_path="$HOME/.config/systemd/user/${unit}"');
|
|
|
|
expect(readFileSync(PLUGINS_DOCKER_E2E_PATH, "utf8")).toContain(
|
|
"scripts/e2e/lib/plugins/sweep.sh",
|
|
);
|
|
expect(readFileSync(PLUGIN_UPDATE_DOCKER_E2E_PATH, "utf8")).toContain(
|
|
"scripts/e2e/lib/plugin-update/unchanged-scenario.sh",
|
|
);
|
|
expect(packageCompat).toContain("day <= 25");
|
|
|
|
expect(pluginsSweep).toContain("scripts/e2e/lib/package-compat.mjs");
|
|
expect(pluginUpdateProbe).toContain("../package-compat.mjs");
|
|
expect(scripts.join("\n")).toContain("OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT");
|
|
expect(scripts.join("\n")).toContain(
|
|
"Package $package_version must support gateway install --wrapper.",
|
|
);
|
|
expect(updateChannel).toContain("assert-config-channel dev");
|
|
expect(updateChannel).toContain("assert-config-channel beta");
|
|
expect(updateChannelAssertions).toContain("expected persisted update.channel ${channel}");
|
|
expect(pluginsAssertions).toContain("expected modern installRecords in installed plugin index");
|
|
});
|
|
|
|
it("keeps the doctor switch systemctl shim system scope empty", () => {
|
|
const home = tempDirs.make("openclaw-doctor-systemctl-shim-");
|
|
const env = { ...process.env, HOME: home };
|
|
const loadState = spawnSync(
|
|
DOCTOR_SWITCH_SYSTEMCTL_SHIM_PATH,
|
|
["show", "--property=LoadState", "--value", "openclaw-gateway.service"],
|
|
{ encoding: "utf8", env },
|
|
);
|
|
const unitPath = spawnSync(
|
|
DOCTOR_SWITCH_SYSTEMCTL_SHIM_PATH,
|
|
["show", "--property=UnitPath", "--value"],
|
|
{ encoding: "utf8", env },
|
|
);
|
|
|
|
expect(loadState.status).toBe(0);
|
|
expect(loadState.stdout.trim()).toBe("not-found");
|
|
expect(unitPath.status).toBe(0);
|
|
expect(unitPath.stdout).toContain("/etc/systemd/system");
|
|
});
|
|
|
|
it("routes doctor install switch commands through the E2E timeout helper", () => {
|
|
const runner = readFileSync(DOCTOR_SWITCH_DOCKER_E2E_PATH, "utf8");
|
|
const scenario = readFileSync(DOCTOR_SWITCH_SCENARIO_PATH, "utf8");
|
|
|
|
expectTextToIncludeAll(runner, [
|
|
'NPM_INSTALL_TIMEOUT="${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-600s}"',
|
|
'COMMAND_TIMEOUT="${OPENCLAW_DOCKER_DOCTOR_SWITCH_COMMAND_TIMEOUT:-900s}"',
|
|
'-e "OPENCLAW_E2E_NPM_INSTALL_TIMEOUT=$NPM_INSTALL_TIMEOUT"',
|
|
'-e "OPENCLAW_DOCKER_DOCTOR_SWITCH_COMMAND_TIMEOUT=$COMMAND_TIMEOUT"',
|
|
]);
|
|
|
|
expectTextToIncludeAll(scenario, [
|
|
'command_timeout="${OPENCLAW_DOCKER_DOCTOR_SWITCH_COMMAND_TIMEOUT:-900s}"',
|
|
"use_default_service_identity() {",
|
|
"local account_home",
|
|
'account_home="$(node -p \'require("node:os").userInfo().homedir\')"',
|
|
'export HOME="$account_home"',
|
|
'export USERPROFILE="$account_home"',
|
|
"unset OPENCLAW_HOME OPENCLAW_STATE_DIR OPENCLAW_CONFIG_PATH",
|
|
'openclaw_test_state_create "switch-${name}" empty\n use_default_service_identity',
|
|
'openclaw_e2e_maybe_timeout "$command_timeout" bash -c "$install_cmd"',
|
|
'openclaw_e2e_maybe_timeout "$command_timeout" bash -c "$doctor_cmd"',
|
|
'openclaw_e2e_maybe_timeout "$command_timeout" "$npm_bin" gateway install --wrapper "$wrapper" --force',
|
|
'openclaw_e2e_maybe_timeout "$command_timeout" node "$git_cli" doctor --repair --force --yes',
|
|
]);
|
|
|
|
expect(
|
|
scenario.match(/unset OPENCLAW_HOME OPENCLAW_STATE_DIR OPENCLAW_CONFIG_PATH/gu),
|
|
).toHaveLength(1);
|
|
expect(scenario.match(/export USERPROFILE="\$account_home"/gu)).toHaveLength(1);
|
|
expect(scenario.match(/^ {2}use_default_service_identity$/gmu)).toHaveLength(3);
|
|
expect(scenario).not.toMatch(/^\s*if ! timeout "\$command_timeout"/mu);
|
|
});
|
|
|
|
it("uses the account home for upgrade survivor auto-auth state", () => {
|
|
const runner = readFileSync(UPGRADE_SURVIVOR_RUN_SCRIPT, "utf8");
|
|
expectTextToIncludeAll(runner, [
|
|
'if [ "$UPDATE_RESTART_MODE" = "auto-auth" ]; then',
|
|
'account_home="$(getent passwd "$(id -u)" | cut -d: -f6)"',
|
|
'if [ -z "$account_home" ]; then',
|
|
'export HOME="$account_home"',
|
|
'export USERPROFILE="$account_home"',
|
|
"unset OPENCLAW_HOME",
|
|
'export OPENCLAW_STATE_DIR="$account_home/.openclaw"',
|
|
'export OPENCLAW_CONFIG_PATH="$OPENCLAW_STATE_DIR/openclaw.json"',
|
|
]);
|
|
|
|
expect(runner.indexOf("unset OPENCLAW_HOME")).toBeLessThan(
|
|
runner.indexOf('export OPENCLAW_STATE_DIR="$account_home/.openclaw"'),
|
|
);
|
|
expect(
|
|
runner.indexOf('export OPENCLAW_CONFIG_PATH="$OPENCLAW_STATE_DIR/openclaw.json"'),
|
|
).toBeLessThan(runner.indexOf("node scripts/e2e/lib/upgrade-survivor/assertions.mjs seed"));
|
|
});
|
|
|
|
it("bounds doctor install switch command log diagnostics", () => {
|
|
const scenario = readFileSync(DOCTOR_SWITCH_SCENARIO_PATH, "utf8");
|
|
expectTextToIncludeAll(scenario, [
|
|
'openclaw_e2e_print_log "$npm_log"',
|
|
'openclaw_e2e_print_log "$install_log"',
|
|
'openclaw_e2e_print_log "$doctor_log"',
|
|
'openclaw_e2e_print_log "$reinstall_log"',
|
|
'openclaw_e2e_print_log "$env_repair_log"',
|
|
'openclaw_e2e_print_log "$clear_log"',
|
|
]);
|
|
|
|
expect(scenario).not.toContain('cat "$npm_log"');
|
|
expect(scenario).not.toContain('cat "$install_log"');
|
|
expect(scenario).not.toContain('cat "$doctor_log"');
|
|
expect(scenario).not.toContain('cat "$reinstall_log"');
|
|
expect(scenario).not.toContain('cat "$env_repair_log"');
|
|
expect(scenario).not.toContain('cat "$clear_log"');
|
|
});
|
|
|
|
it("prepares pnpm workspace package fixtures without package dependencies", () => {
|
|
const root = tempDirs.make("openclaw-update-channel-fixture-");
|
|
mkdirSync(join(root, "patches"));
|
|
writeFileSync(
|
|
join(root, "package.json"),
|
|
`${JSON.stringify({ name: "openclaw", version: "2026.5.6", scripts: {} }, null, 2)}\n`,
|
|
"utf8",
|
|
);
|
|
writeFileSync(
|
|
join(root, "pnpm-workspace.yaml"),
|
|
[
|
|
"packages:",
|
|
" - .",
|
|
"",
|
|
"patchedDependencies:",
|
|
' "kept@1.0.0": "patches/kept.patch"',
|
|
"allowBuilds:",
|
|
" esbuild: true",
|
|
"",
|
|
].join("\n"),
|
|
"utf8",
|
|
);
|
|
writeFileSync(join(root, "patches", "kept.patch"), "", "utf8");
|
|
|
|
execFileSync(process.execPath, [
|
|
UPDATE_CHANNEL_SWITCH_ASSERTIONS_PATH,
|
|
"prepare-git-fixture",
|
|
root,
|
|
]);
|
|
|
|
const workspace = readFileSync(join(root, "pnpm-workspace.yaml"), "utf8");
|
|
const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as {
|
|
pnpm?: unknown;
|
|
};
|
|
expect(workspace).toContain(' "kept@1.0.0": "patches/kept.patch"');
|
|
expect(workspace).toContain("allowUnusedPatches: true");
|
|
expect(workspace).toContain("minimumReleaseAge: 0");
|
|
expect(workspace).toContain("allowBuilds:");
|
|
expect(manifest.pnpm).toBeUndefined();
|
|
});
|
|
|
|
it("keeps bundled plugin install/uninstall sweep chunkable", () => {
|
|
const runner = readFileSync(BUNDLED_PLUGIN_INSTALL_UNINSTALL_E2E_PATH, "utf8");
|
|
const sweep = readFileSync(BUNDLED_PLUGIN_INSTALL_UNINSTALL_SWEEP_PATH, "utf8");
|
|
const probe = readFileSync(BUNDLED_PLUGIN_INSTALL_UNINSTALL_PROBE_PATH, "utf8");
|
|
const runtimeSmoke = readFileSync(BUNDLED_PLUGIN_INSTALL_UNINSTALL_RUNTIME_SMOKE_PATH, "utf8");
|
|
const forwardedRuntimeEnv = [
|
|
"OPENCLAW_BUNDLED_PLUGIN_LIST_TIMEOUT_MS",
|
|
"OPENCLAW_BUNDLED_PLUGIN_LIST_MAX_BUFFER_BYTES",
|
|
"OPENCLAW_BUNDLED_PLUGIN_RUNTIME_OUTPUT_CHARS",
|
|
"OPENCLAW_BUNDLED_PLUGIN_RUNTIME_LOG_SCAN_BYTES",
|
|
"OPENCLAW_BUNDLED_PLUGIN_RUNTIME_GATEWAY_LOG_BYTES",
|
|
"OPENCLAW_BUNDLED_PLUGIN_RUNTIME_READY_MS",
|
|
"OPENCLAW_BUNDLED_PLUGIN_RUNTIME_RPC_MS",
|
|
"OPENCLAW_BUNDLED_PLUGIN_RUNTIME_RPC_READY_MS",
|
|
"OPENCLAW_BUNDLED_PLUGIN_RUNTIME_COMMAND_MS",
|
|
"OPENCLAW_BUNDLED_PLUGIN_RUNTIME_HTTP_MS",
|
|
"OPENCLAW_BUNDLED_PLUGIN_RUNTIME_TEARDOWN_GRACE_MS",
|
|
"OPENCLAW_BUNDLED_PLUGIN_RUNTIME_TEARDOWN_KILL_GRACE_MS",
|
|
"OPENCLAW_BUNDLED_PLUGIN_RUNTIME_WATCHDOG_MS",
|
|
] as const;
|
|
|
|
expectTextToIncludeAll(runner, [
|
|
"OPENCLAW_BUNDLED_PLUGIN_SWEEP_TOTAL",
|
|
"OPENCLAW_BUNDLED_PLUGIN_SWEEP_INDEX",
|
|
"OPENCLAW_BUNDLED_PLUGIN_SWEEP_COMMAND_TIMEOUT",
|
|
"OPENCLAW_PLUGIN_LIFECYCLE_TRACE",
|
|
"docker_e2e_read_tcp_port_env OPENCLAW_BUNDLED_PLUGIN_RUNTIME_PORT_BASE 19000",
|
|
'-e "OPENCLAW_BUNDLED_PLUGIN_RUNTIME_PORT_BASE=$RUNTIME_PORT_BASE"',
|
|
"scripts/e2e/lib/bundled-plugin-install-uninstall/sweep.sh",
|
|
'tee "$RUN_LOG"',
|
|
]);
|
|
|
|
for (const envName of forwardedRuntimeEnv) {
|
|
expect(runner, `${envName} forwarded by Docker wrapper`).toContain(envName);
|
|
expect(probe + runtimeSmoke, `${envName} consumed by probe/runtime smoke`).toContain(envName);
|
|
}
|
|
|
|
for (const [envName, fallback] of [
|
|
["OPENCLAW_BUNDLED_PLUGIN_LIST_TIMEOUT_MS", "30000"],
|
|
["OPENCLAW_BUNDLED_PLUGIN_LIST_MAX_BUFFER_BYTES", "4194304"],
|
|
["OPENCLAW_BUNDLED_PLUGIN_RUNTIME_OUTPUT_CHARS", "1048576"],
|
|
["OPENCLAW_BUNDLED_PLUGIN_RUNTIME_LOG_SCAN_BYTES", "262144"],
|
|
["OPENCLAW_BUNDLED_PLUGIN_RUNTIME_GATEWAY_LOG_BYTES", "16777216"],
|
|
["OPENCLAW_BUNDLED_PLUGIN_RUNTIME_READY_MS", "900000"],
|
|
["OPENCLAW_BUNDLED_PLUGIN_RUNTIME_RPC_MS", "60000"],
|
|
["OPENCLAW_BUNDLED_PLUGIN_RUNTIME_RPC_READY_MS", "210000"],
|
|
["OPENCLAW_BUNDLED_PLUGIN_RUNTIME_WATCHDOG_MS", "1000"],
|
|
["OPENCLAW_BUNDLED_PLUGIN_RUNTIME_COMMAND_MS", "120000"],
|
|
["OPENCLAW_BUNDLED_PLUGIN_RUNTIME_HTTP_MS", "5000"],
|
|
["OPENCLAW_BUNDLED_PLUGIN_RUNTIME_TEARDOWN_GRACE_MS", "10000"],
|
|
["OPENCLAW_BUNDLED_PLUGIN_RUNTIME_TEARDOWN_KILL_GRACE_MS", "1000"],
|
|
] as const) {
|
|
expect(runner, `${envName} host validation`).toContain(
|
|
`docker_e2e_read_positive_int_env ${envName} ${fallback}`,
|
|
);
|
|
expect(runner, `${envName} Docker forwarding`).toContain(`-e "${envName}=`);
|
|
}
|
|
|
|
expect(runner).not.toContain('cat "$RUN_LOG"');
|
|
expect(probe).toContain('"openclaw.plugin.json"');
|
|
expect(runtimeSmoke).toContain(
|
|
'readPositiveIntEnv("OPENCLAW_BUNDLED_PLUGIN_RUNTIME_READY_MS", 900000)',
|
|
);
|
|
expectTextToIncludeAll(sweep, [
|
|
"read -r plugin_id plugin_dir requires_config",
|
|
'node "$OPENCLAW_ENTRY" plugins install "$plugin_id"',
|
|
'node "$OPENCLAW_ENTRY" plugins uninstall "$plugin_id" --force',
|
|
"now_ms()",
|
|
"lifecycle_trace_enabled()",
|
|
"if lifecycle_trace_enabled; then",
|
|
"install_ms=",
|
|
"runtime_ms=",
|
|
"uninstall_ms=",
|
|
"assert-installed",
|
|
"assert-uninstalled",
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
["list timeout", "OPENCLAW_BUNDLED_PLUGIN_LIST_TIMEOUT_MS", "100ms"],
|
|
["runtime port base", "OPENCLAW_BUNDLED_PLUGIN_RUNTIME_PORT_BASE", "99999"],
|
|
["runtime log scan", "OPENCLAW_BUNDLED_PLUGIN_RUNTIME_LOG_SCAN_BYTES", "64bytes"],
|
|
["runtime command timeout", "OPENCLAW_BUNDLED_PLUGIN_RUNTIME_COMMAND_MS", "soon"],
|
|
["runtime teardown grace", "OPENCLAW_BUNDLED_PLUGIN_RUNTIME_TEARDOWN_GRACE_MS", "0"],
|
|
])(
|
|
"rejects invalid bundled plugin Docker %s values before Docker setup",
|
|
(_label, envName, value) => {
|
|
const result = spawnSync("bash", [BUNDLED_PLUGIN_INSTALL_UNINSTALL_E2E_PATH], {
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
OPENCLAW_SKIP_DOCKER_BUILD: "1",
|
|
[envName]: value,
|
|
},
|
|
});
|
|
|
|
expect(result.status).toBe(2);
|
|
expect(result.stderr).toContain(`invalid ${envName}: ${value}`);
|
|
expect(result.stderr).not.toContain("Docker image not found");
|
|
},
|
|
);
|
|
|
|
it("passes installer tag env to bash, not curl", () => {
|
|
const runner = readFileSync(INSTALL_E2E_RUNNER_PATH, "utf8");
|
|
expect(runner).toContain('OPENCLAW_BETA=1 bash "$installer"');
|
|
expect(runner).toContain('OPENCLAW_VERSION="$INSTALL_TAG" bash "$installer"');
|
|
expect(runner).not.toContain('OPENCLAW_BETA=1 curl -fsSL "$INSTALL_URL" | bash');
|
|
expect(runner).not.toContain(
|
|
'OPENCLAW_VERSION="$INSTALL_TAG" curl -fsSL "$INSTALL_URL" | bash',
|
|
);
|
|
});
|
|
|
|
it("keeps installer E2E agent turns out of the interactive bootstrap ritual", () => {
|
|
const runner = readFileSync(INSTALL_E2E_RUNNER_PATH, "utf8");
|
|
expect(runner).toContain('rm -f "$workspace/BOOTSTRAP.md"');
|
|
expect(runner.indexOf('rm -f "$workspace/BOOTSTRAP.md"')).toBeLessThan(
|
|
runner.indexOf('phase_mark_start "Agent turns ($profile)"'),
|
|
);
|
|
});
|
|
|
|
it("keeps installer E2E tool smokes in isolated sessions", () => {
|
|
const runner = readFileSync(INSTALL_E2E_RUNNER_PATH, "utf8");
|
|
expectTextToIncludeAll(runner, [
|
|
'SESSION_ID_PREFIX="e2e-tools-${profile}"',
|
|
'TURN2B_SESSION_ID="${SESSION_ID_PREFIX}-read-copy"',
|
|
'TURN3_SESSION_ID="${SESSION_ID_PREFIX}-exec-hostname"',
|
|
'TURN4_SESSION_ID="${SESSION_ID_PREFIX}-image-write"',
|
|
]);
|
|
});
|
|
|
|
it("bounds installer E2E session transcript tool scans", () => {
|
|
const runner = readFileSync(INSTALL_E2E_RUNNER_PATH, "utf8");
|
|
const start = runner.indexOf("assert_session_used_tools() {");
|
|
const end = runner.indexOf("\nsession_jsonl_path()", start);
|
|
const helper = runner.slice(start, end);
|
|
|
|
expect(start).toBeGreaterThanOrEqual(0);
|
|
expect(end).toBeGreaterThan(start);
|
|
expectTextToIncludeAll(helper, [
|
|
"OPENCLAW_INSTALL_E2E_SESSION_SCAN_BYTES",
|
|
"OPENCLAW_INSTALL_E2E_SESSION_LINE_BYTES",
|
|
"OPENCLAW_INSTALL_E2E_SESSION_SCAN_DEPTH",
|
|
"OPENCLAW_INSTALL_E2E_SESSION_SCAN_NODES",
|
|
"fs.createReadStream",
|
|
"Buffer.concat",
|
|
"skippedOversizedLines",
|
|
]);
|
|
|
|
expect(helper).not.toContain('require("node:readline")');
|
|
expect(helper).not.toContain("fs.readFileSync");
|
|
expect(helper).not.toContain('.split("\\n")');
|
|
});
|
|
|
|
it("exports SQLite-backed installer E2E sessions before scanning tools", () => {
|
|
const runner = readFileSync(INSTALL_E2E_RUNNER_PATH, "utf8");
|
|
const start = runner.indexOf("assert_session_used_tools() {");
|
|
const end = runner.indexOf("\nsession_jsonl_path()", start);
|
|
const helper = runner.slice(start, end);
|
|
|
|
expectTextToIncludeAll(helper, [
|
|
'jsonl="$(session_jsonl_path "$profile" "$session_id")"',
|
|
'if [[ ! -f "$jsonl" ]]',
|
|
'openclaw --profile "$profile" sessions export-trajectory',
|
|
'--session-key "agent:main:explicit:${session_id}"',
|
|
'--workspace "$export_workspace"',
|
|
'jsonl="$export_workspace/.openclaw/trajectory-exports/scan/events.jsonl"',
|
|
'rm -rf "$export_workspace"',
|
|
]);
|
|
});
|
|
|
|
it("keeps OpenAI web search smoke on one gateway agent connection", () => {
|
|
const runner = readFileSync(OPENAI_WEB_SEARCH_MINIMAL_E2E_PATH, "utf8");
|
|
const scenario = readFileSync(OPENAI_WEB_SEARCH_MINIMAL_SCENARIO_PATH, "utf8");
|
|
const client = readFileSync(OPENAI_WEB_SEARCH_MINIMAL_CLIENT_PATH, "utf8");
|
|
|
|
expectTextToIncludeAll(runner, [
|
|
'PORT="$(docker_e2e_read_tcp_port_env OPENCLAW_OPENAI_WEB_SEARCH_MINIMAL_PORT 18789)"',
|
|
'MOCK_PORT="443"',
|
|
'-e "PORT=$PORT"',
|
|
'-e "MOCK_PORT=$MOCK_PORT"',
|
|
"scripts/e2e/lib/openai-web-search-minimal/scenario.sh",
|
|
]);
|
|
|
|
expect(runner).not.toContain("OPENCLAW_OPENAI_WEB_SEARCH_MINIMAL_MOCK_PORT");
|
|
|
|
expectTextToIncludeAll(scenario, [
|
|
'export NODE_EXTRA_CA_CERTS="$TLS_CA_CERT"',
|
|
'MOCK_TLS_CERT="$TLS_SERVER_CERT"',
|
|
'MOCK_TLS_KEY="$TLS_SERVER_KEY"',
|
|
'openclaw_e2e_wait_mock_openai "$MOCK_PORT" 80 400 "https://api.openai.com:$MOCK_PORT"',
|
|
"scripts/e2e/lib/openai-web-search-minimal/client.mjs",
|
|
]);
|
|
|
|
expectTextToIncludeAll(client, [
|
|
"const callGateway = await loadCallGateway();",
|
|
'method: "agent"',
|
|
"expectFinal: true",
|
|
'scopes: ["operator.write"]',
|
|
]);
|
|
|
|
expect(client).not.toContain('"agent.wait"');
|
|
});
|
|
|
|
it("cleans OpenAI web search smoke processes through the E2E helpers", () => {
|
|
const scenario = readFileSync(OPENAI_WEB_SEARCH_MINIMAL_SCENARIO_PATH, "utf8");
|
|
expectTextToIncludeAll(scenario, [
|
|
'openclaw_e2e_terminate_gateways "${gateway_pid:-}"',
|
|
'openclaw_e2e_stop_process "${mock_pid:-}"',
|
|
'gateway_pid="$(openclaw_e2e_start_gateway "$entry" "$PORT" "$GATEWAY_LOG")"',
|
|
'openclaw_e2e_wait_mock_openai "$MOCK_PORT" 80 400 "https://api.openai.com:$MOCK_PORT"',
|
|
'openclaw_e2e_wait_gateway_ready "$gateway_pid" "$GATEWAY_LOG" 360 "$PORT"',
|
|
]);
|
|
|
|
expect(scenario).not.toContain("fetch('http://127.0.0.1:${MOCK_PORT}/health')");
|
|
expect(scenario).not.toContain('kill "$gateway_pid"');
|
|
expect(scenario).not.toContain('kill "$mock_pid"');
|
|
expect(scenario).not.toContain('node "$entry" gateway --port "$PORT"');
|
|
});
|
|
|
|
it("runs agents delete shared workspace smoke through one managed gateway", () => {
|
|
const runner = readFileSync(AGENTS_DELETE_SHARED_WORKSPACE_DOCKER_E2E_PATH, "utf8");
|
|
expectTextToIncludeAll(runner, [
|
|
'entry="$(openclaw_e2e_resolve_entrypoint)"',
|
|
'gateway_pid="$(openclaw_e2e_start_gateway "$entry" 18789 "$gateway_log")"',
|
|
'openclaw_e2e_wait_gateway_ready "$gateway_pid" "$gateway_log" 300 18789',
|
|
'node "$entry" agents delete ops --force --json > "$output_file"',
|
|
'openclaw_e2e_terminate_gateways "${gateway_pid:-}"',
|
|
'openclaw_e2e_print_log "$gateway_log" >&2',
|
|
"trap cleanup EXIT",
|
|
"trap dump_logs_on_error ERR",
|
|
]);
|
|
|
|
expect(runner.match(/openclaw_e2e_start_gateway/gu)).toHaveLength(1);
|
|
expect(runner.match(/openclaw_e2e_wait_gateway_ready/gu)).toHaveLength(1);
|
|
expect(runner).not.toContain("run_openclaw()");
|
|
expect(runner).not.toContain("for _ in");
|
|
});
|
|
|
|
it("keeps OpenAI web search smoke logs isolated per run", () => {
|
|
const scenario = readFileSync(OPENAI_WEB_SEARCH_MINIMAL_SCENARIO_PATH, "utf8");
|
|
expectTextToIncludeAll(scenario, [
|
|
'scenario_tmp="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-openai-web-search-minimal.XXXXXX")"',
|
|
'MOCK_REQUEST_LOG="$scenario_tmp/requests.jsonl"',
|
|
'GATEWAY_LOG="$scenario_tmp/gateway.log"',
|
|
'MOCK_LOG="$scenario_tmp/mock.log"',
|
|
'CLIENT_SUCCESS_LOG="$scenario_tmp/client-success.log"',
|
|
'CLIENT_REJECT_LOG="$scenario_tmp/client-reject.log"',
|
|
'openclaw_e2e_print_log "$file"',
|
|
'rm -rf "$scenario_tmp"',
|
|
]);
|
|
|
|
expect(scenario).not.toContain("sed -n '1,260p'");
|
|
expect(scenario).not.toContain("/tmp/openclaw-openai-web-search-minimal-requests.jsonl");
|
|
expect(scenario).not.toContain("/tmp/openclaw-openai-web-search-minimal-client-success.log");
|
|
expect(scenario).not.toContain("/tmp/openclaw-openai-web-search-minimal-client-reject.log");
|
|
});
|
|
|
|
it("keeps ClawHub plugin Docker smoke hermetic by default", () => {
|
|
const runner = readFileSync(PLUGINS_DOCKER_E2E_PATH, "utf8");
|
|
const sweep = readFileSync(PLUGINS_DOCKER_SWEEP_PATH, "utf8");
|
|
const clawhub = readFileSync(PLUGINS_DOCKER_CLAWHUB_PATH, "utf8");
|
|
|
|
expectTextToIncludeAll(runner, [
|
|
"scripts/e2e/lib/plugins/sweep.sh",
|
|
"OPENCLAW_PLUGINS_E2E_LIVE_CLAWHUB",
|
|
"OPENCLAW_PLUGINS_E2E_LIVE_NPM_REGISTRY",
|
|
]);
|
|
|
|
expect(sweep).toContain("scripts/e2e/lib/plugins/clawhub.sh");
|
|
expectTextToIncludeAll(clawhub, [
|
|
"start_clawhub_fixture_server()",
|
|
'OPENCLAW_CLAWHUB_URL="http://127.0.0.1:',
|
|
"OPENCLAW_PLUGINS_E2E_LIVE_CLAWHUB",
|
|
"OPENCLAW_PLUGINS_E2E_LIVE_NPM_REGISTRY",
|
|
"live ClawHub can rate-limit CI",
|
|
'[[ -n "${OPENCLAW_CLAWHUB_URL:-}" || -n "${CLAWHUB_URL:-}" ]]',
|
|
"Ignoring ambient ClawHub URL for fixture-mode plugin E2E",
|
|
"unset OPENCLAW_CLAWHUB_URL CLAWHUB_URL",
|
|
]);
|
|
});
|
|
|
|
it("keeps the plugin binding command escape Docker smoke focused", () => {
|
|
const runner = readFileSync(PLUGIN_BINDING_COMMAND_ESCAPE_DOCKER_E2E_PATH, "utf8");
|
|
const dockerfile = readFileSync(PLUGIN_BINDING_COMMAND_ESCAPE_DOCKERFILE_PATH, "utf8");
|
|
|
|
expectTextToIncludeAll(runner, [
|
|
"--reporter=verbose -t",
|
|
'DOCKER_RUN_TIMEOUT="${OPENCLAW_PLUGIN_BINDING_COMMAND_ESCAPE_DOCKER_RUN_TIMEOUT:-900s}"',
|
|
'DOCKER_COMMAND_TIMEOUT="$DOCKER_RUN_TIMEOUT" docker_e2e_docker_run_cmd run --rm',
|
|
'docker_e2e_docker_cmd rm -f "$CONTAINER_NAME"',
|
|
"lets authorized gateway-style plugin commands escape plugin-owned bindings",
|
|
"keeps unauthorized plugin-owned binding slash replies suppressed while routed to the bound plugin",
|
|
"expected focused Vitest summary for exactly 3 passed tests",
|
|
]);
|
|
expect(runner).not.toContain("-- --reporter=verbose");
|
|
|
|
expect(runner).not.toMatch(/(^|\n)docker run --rm/u);
|
|
|
|
expect(runner).not.toContain(
|
|
"keeps unauthorized plugin-owned binding slash text routed to the bound plugin",
|
|
);
|
|
|
|
expect(dockerfile).toContain("OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL=1");
|
|
expect(dockerfile).toContain(
|
|
"pnpm install --frozen-lockfile --ignore-scripts --filter openclaw",
|
|
);
|
|
});
|
|
|
|
it("routes QR import Docker smoke through the timeout-aware run helper", () => {
|
|
const runner = readFileSync(QR_IMPORT_DOCKER_E2E_PATH, "utf8");
|
|
expect(runner).toContain("scripts/lib/docker-e2e-container.sh");
|
|
expect(runner).toContain("run_logged qr-import-run docker_e2e_docker_run_cmd run --rm -t");
|
|
expect(runner).not.toContain("run_logged qr-import-run docker run --rm");
|
|
});
|
|
|
|
it("covers plugin CLI sources in the Docker plugin sweep", () => {
|
|
const runner = readFileSync(PLUGINS_DOCKER_E2E_PATH, "utf8");
|
|
const sweep = readFileSync(PLUGINS_DOCKER_SWEEP_PATH, "utf8");
|
|
const marketplace = readFileSync(PLUGINS_DOCKER_MARKETPLACE_PATH, "utf8");
|
|
const clawhub = readFileSync(PLUGINS_DOCKER_CLAWHUB_PATH, "utf8");
|
|
const assertions = readFileSync(PLUGINS_DOCKER_ASSERTIONS_PATH, "utf8");
|
|
const npmRegistry = readFileSync(PLUGINS_DOCKER_NPM_REGISTRY_PATH, "utf8");
|
|
|
|
expectTextToIncludeAll(sweep, [
|
|
'OPENCLAW_PLUGINS_CLI_TIMEOUT="${OPENCLAW_PLUGINS_CLI_TIMEOUT:-180s}"',
|
|
"run_plugins_openclaw_capture()",
|
|
'openclaw_e2e_maybe_timeout "$OPENCLAW_PLUGINS_CLI_TIMEOUT" node "$OPENCLAW_ENTRY" "$@" >"$output_file"',
|
|
"plugins_lifecycle_trace_enabled()",
|
|
"print_plugins_stderr_log()",
|
|
'openclaw_e2e_maybe_timeout "$OPENCLAW_PLUGINS_CLI_TIMEOUT" node "$OPENCLAW_ENTRY" "$@" >"$output_file" 2>"$error_file"',
|
|
"Plugin sweep command timed out after %s: %s",
|
|
"Plugin sweep command failed with status %s: %s",
|
|
"Plugin sweep capture timed out after %s: %s",
|
|
"Plugin sweep capture failed with status %s: %s",
|
|
'plugins install "$dir_plugin" --force',
|
|
"plugins update demo-plugin-dir",
|
|
"start_npm_fixture_registry",
|
|
'plugins install "npm:@openclaw/demo-plugin-npm@0.0.1" --force',
|
|
"plugins update demo-plugin-npm",
|
|
'plugins install "git:$git_update_repo_url@main" --force',
|
|
"plugins update demo-plugin-git-update",
|
|
]);
|
|
expect(runner).toContain('PLUGINS_CLI_TIMEOUT="${OPENCLAW_PLUGINS_CLI_TIMEOUT:-180s}"');
|
|
expect(runner).toContain('-e "OPENCLAW_PLUGINS_CLI_TIMEOUT=$PLUGINS_CLI_TIMEOUT"');
|
|
expect(runner).toContain("OPENCLAW_PLUGIN_LIFECYCLE_TRACE");
|
|
expect(sweep).not.toContain('run_logged install-npm node "$OPENCLAW_ENTRY"');
|
|
for (const [path, script] of [
|
|
[PLUGINS_DOCKER_SWEEP_PATH, sweep],
|
|
[PLUGINS_DOCKER_MARKETPLACE_PATH, marketplace],
|
|
[PLUGINS_DOCKER_CLAWHUB_PATH, clawhub],
|
|
] as const) {
|
|
const unboundedPluginCliLines = script
|
|
.split("\n")
|
|
.filter((line) => line.includes('node "$OPENCLAW_ENTRY" plugins'))
|
|
.filter((line) => !line.includes("openclaw_e2e_maybe_timeout"));
|
|
|
|
expect(unboundedPluginCliLines, path).toEqual([]);
|
|
}
|
|
|
|
expectTextToIncludeAll(assertions, [
|
|
'Skipping "demo-plugin-dir" (source: path).',
|
|
"demo-plugin-npm is up to date (0.0.1).",
|
|
"demo.git.update.v2",
|
|
"clawhub-updated",
|
|
"record.clawpackSha256",
|
|
"record.artifactKind",
|
|
"record.npmIntegrity",
|
|
]);
|
|
|
|
expectTextToIncludeAll(npmRegistry, [
|
|
"OPENCLAW_NPM_REGISTRY_DIST_TAGS",
|
|
"Object.fromEntries(distTagOverrides)",
|
|
"existing.latestVersion = version",
|
|
"packageArgs.length % 3",
|
|
]);
|
|
|
|
expectTextToIncludeAll(clawhub, [
|
|
'plugins install "$CLAWHUB_PLUGIN_SPEC"',
|
|
'plugins update "$CLAWHUB_PLUGIN_ID"',
|
|
"run_plugins_openclaw_logged install-clawhub",
|
|
'openclaw_e2e_maybe_timeout "$OPENCLAW_PLUGINS_CLI_TIMEOUT"',
|
|
"clawhub:@openclaw/kitchen-sink",
|
|
]);
|
|
});
|
|
});
|