mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-28 09:52:13 +00:00
1090 lines
45 KiB
TypeScript
1090 lines
45 KiB
TypeScript
import { execFileSync } from "node:child_process";
|
|
import { mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
const HELPER_PATH = "scripts/lib/docker-build.sh";
|
|
const DOCKER_ALL_SCHEDULER_PATH = "scripts/test-docker-all.mjs";
|
|
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.mjs";
|
|
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 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 CODEX_NPM_PLUGIN_LIVE_DOCKER_E2E_PATH = "scripts/e2e/codex-npm-plugin-live-docker.sh";
|
|
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 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 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 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 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 UPGRADE_SURVIVOR_RUN_SCRIPT = "scripts/e2e/lib/upgrade-survivor/run.sh";
|
|
const GATEWAY_NETWORK_DOCKER_E2E_PATH = "scripts/e2e/gateway-network-docker.sh";
|
|
const CENTRALIZED_BUILD_SCRIPTS = [
|
|
"scripts/docker/setup.sh",
|
|
"scripts/e2e/browser-cdp-snapshot-docker.sh",
|
|
"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 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"))
|
|
.sort();
|
|
}
|
|
|
|
function shellQuote(value: string): string {
|
|
return `'${value.replace(/'/gu, `'\\''`)}'`;
|
|
}
|
|
|
|
describe("docker build helper", () => {
|
|
it("forces BuildKit for centralized Docker builds", () => {
|
|
const helper = readFileSync(HELPER_PATH, "utf8");
|
|
|
|
expect(helper).toContain("DOCKER_BUILDKIT=1");
|
|
expect(helper).toContain("docker_build_exec()");
|
|
expect(helper).toContain("docker_build_run()");
|
|
expect(helper).toContain("docker buildx build --load");
|
|
expect(helper).toContain("docker_build_transient_failure()");
|
|
expect(helper).toContain("OPENCLAW_DOCKER_BUILD_RETRIES");
|
|
expect(helper).toContain("frontend grpc server closed unexpectedly");
|
|
});
|
|
|
|
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("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");
|
|
|
|
expect(helper).toContain("docker_build_on_missing_enabled()");
|
|
expect(helper).toContain("OPENCLAW_DOCKER_BUILD_ON_MISSING");
|
|
expect(helper).toContain("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('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("keeps reused Docker image probes behind the timeout-aware helper", () => {
|
|
const workDir = mkdtempSync(join(tmpdir(), "openclaw-docker-image-reuse-timeout-"));
|
|
|
|
try {
|
|
const rootDir = process.cwd();
|
|
const script = `
|
|
set -euo pipefail
|
|
ROOT_DIR=${shellQuote(rootDir)}
|
|
TMPDIR=${shellQuote(workDir)}
|
|
export ROOT_DIR TMPDIR
|
|
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"
|
|
`;
|
|
|
|
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
|
|
} finally {
|
|
rmSync(workDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("removes functional Docker build package inputs after the build", () => {
|
|
const workDir = mkdtempSync(join(tmpdir(), "openclaw-docker-build-cleanup-"));
|
|
|
|
try {
|
|
const rootDir = process.cwd();
|
|
const script = `
|
|
set -euo pipefail
|
|
ROOT_DIR=${shellQuote(rootDir)}
|
|
TMPDIR=${shellQuote(workDir)}
|
|
export ROOT_DIR TMPDIR
|
|
|
|
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
|
|
`;
|
|
|
|
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
|
|
} finally {
|
|
rmSync(workDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("keeps caller-provided functional Docker build packages", () => {
|
|
const workDir = mkdtempSync(join(tmpdir(), "openclaw-docker-build-external-package-"));
|
|
|
|
try {
|
|
const rootDir = process.cwd();
|
|
const script = `
|
|
set -euo pipefail
|
|
ROOT_DIR=${shellQuote(rootDir)}
|
|
TMPDIR=${shellQuote(workDir)}
|
|
export ROOT_DIR TMPDIR
|
|
|
|
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
|
|
`;
|
|
|
|
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
|
|
} finally {
|
|
rmSync(workDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("cleans generated package mounts after harness Docker runs", () => {
|
|
const workDir = mkdtempSync(join(tmpdir(), "openclaw-docker-package-mount-cleanup-"));
|
|
|
|
try {
|
|
const rootDir = process.cwd();
|
|
const script = `
|
|
set -euo pipefail
|
|
ROOT_DIR=${shellQuote(rootDir)}
|
|
TMPDIR=${shellQuote(workDir)}
|
|
export ROOT_DIR TMPDIR
|
|
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)
|
|
printf "%s %s\\n" "$1" "$2" >"$TMPDIR/docker-timeout-seen"
|
|
shift 2
|
|
;;
|
|
*)
|
|
printf "%s\\n" "$1" >"$TMPDIR/docker-timeout-seen"
|
|
shift
|
|
;;
|
|
esac
|
|
"$@"
|
|
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() {
|
|
local mount_path=""
|
|
local expect_volume_path=0
|
|
local arg
|
|
for arg in "$@"; do
|
|
if [[ "$expect_volume_path" == "1" ]]; then
|
|
mount_path="\${arg%%:*}"
|
|
expect_volume_path=0
|
|
continue
|
|
fi
|
|
if [[ "$arg" == "-v" ]]; then
|
|
expect_volume_path=1
|
|
fi
|
|
done
|
|
|
|
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"
|
|
test -f "$TMPDIR/package-mount-seen"
|
|
test ! -e "$pack_dir"
|
|
|
|
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 ! -e "$TMPDIR/docker-timeout-seen"
|
|
test -f "$external_dir/openclaw-current.tgz"
|
|
`;
|
|
|
|
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
|
|
} finally {
|
|
rmSync(workDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("propagates the shared E2E npm install timeout into package-backed containers", () => {
|
|
const workDir = mkdtempSync(join(tmpdir(), "openclaw-docker-package-timeout-env-"));
|
|
|
|
try {
|
|
const rootDir = process.cwd();
|
|
const script = `
|
|
set -euo pipefail
|
|
ROOT_DIR=${shellQuote(rootDir)}
|
|
TMPDIR=${shellQuote(workDir)}
|
|
export ROOT_DIR TMPDIR
|
|
source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh"
|
|
|
|
package="$TMPDIR/openclaw-current.tgz"
|
|
printf fixture >"$package"
|
|
export OPENCLAW_E2E_NPM_INSTALL_TIMEOUT=42s
|
|
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"
|
|
`;
|
|
|
|
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
|
|
} finally {
|
|
rmSync(workDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
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"',
|
|
);
|
|
expect(updateChannel).toContain(
|
|
'openclaw_e2e_maybe_timeout "${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-600s}" npm install --omit=optional --no-fund --no-audit',
|
|
);
|
|
expect(updateChannel).toContain(
|
|
'openclaw_e2e_maybe_timeout "${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-600s}" npm install -g --prefix /tmp/npm-prefix --omit=optional "$pkg_tgz_path"',
|
|
);
|
|
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("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(
|
|
'timeout --kill-after=30s "$DOCKER_RUN_TIMEOUT" bash scripts/e2e/lib/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 the harness run wrapper available with pre-sourced Docker command helpers", () => {
|
|
const workDir = mkdtempSync(join(tmpdir(), "openclaw-docker-package-helper-guard-"));
|
|
|
|
try {
|
|
const rootDir = process.cwd();
|
|
const script = `
|
|
set -euo pipefail
|
|
ROOT_DIR=${shellQuote(rootDir)}
|
|
TMPDIR=${shellQuote(workDir)}
|
|
export ROOT_DIR TMPDIR
|
|
|
|
docker_e2e_docker_cmd() {
|
|
printf "%s\\n" "$*" >"$TMPDIR/docker-cmd-seen"
|
|
}
|
|
|
|
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
|
|
test -f "$TMPDIR/docker-run-seen"
|
|
|
|
docker_e2e_run_detached_with_harness image-name
|
|
test -f "$TMPDIR/docker-cmd-seen"
|
|
`;
|
|
|
|
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
|
|
} finally {
|
|
rmSync(workDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("cleans Codex npm plugin live package artifacts on every exit path", () => {
|
|
const runner = readFileSync(CODEX_NPM_PLUGIN_LIVE_DOCKER_E2E_PATH, "utf8");
|
|
|
|
expect(runner).toContain('CODEX_PLUGIN_PACK_DIR=""');
|
|
expect(runner).toContain('run_log=""');
|
|
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).toContain("trap cleanup EXIT");
|
|
expect(runner).not.toContain('rm -f "$run_log"\n exit 1');
|
|
});
|
|
|
|
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("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("includes procps in the shared Docker E2E image for process watchdogs", () => {
|
|
const dockerfile = readFileSync("scripts/e2e/Dockerfile", "utf8");
|
|
|
|
expect(dockerfile).toContain("procps");
|
|
});
|
|
|
|
it("keeps onboarding Docker E2E resource-guarded", () => {
|
|
const runner = readFileSync(ONBOARD_DOCKER_E2E_PATH, "utf8");
|
|
|
|
expect(runner).toContain("OPENCLAW_ONBOARD_MAX_MEMORY_MIB");
|
|
expect(runner).toContain("OPENCLAW_ONBOARD_MAX_CPU_PERCENT");
|
|
expect(runner).toContain('--name "$CONTAINER_NAME"');
|
|
expect(runner).toContain("docker_e2e_docker_cmd stats --no-stream");
|
|
expect(runner).toContain("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 of [
|
|
ONBOARD_DOCKER_E2E_PATH,
|
|
KITCHEN_SINK_PLUGIN_DOCKER_E2E_PATH,
|
|
KITCHEN_SINK_RPC_DOCKER_E2E_PATH,
|
|
]) {
|
|
const runner = readFileSync(path, "utf8");
|
|
|
|
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_docker_cmd inspect "$CONTAINER_NAME"');
|
|
expect(runner, path).toContain("docker_e2e_docker_cmd stats --no-stream");
|
|
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);
|
|
}
|
|
});
|
|
|
|
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"');
|
|
}
|
|
});
|
|
|
|
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("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");
|
|
|
|
expect(helper).toContain(
|
|
'-v "$ROOT_DIR/scripts/windows-cmd-helpers.mjs:/app/scripts/windows-cmd-helpers.mjs:ro"',
|
|
);
|
|
});
|
|
|
|
it("preserves pnpm lookup paths for scheduled Docker child lanes", () => {
|
|
const scheduler = readFileSync(DOCKER_ALL_SCHEDULER_PATH, "utf8");
|
|
|
|
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("withResolvedPnpmCommand");
|
|
expect(scheduler).toContain("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 pnpm test:install:e2e"',
|
|
);
|
|
expect(scenarios).toContain(
|
|
'"OPENCLAW_INSTALL_TAG=beta OPENCLAW_E2E_MODELS=anthropic OPENCLAW_INSTALL_E2E_IMAGE=openclaw-install-e2e-anthropic:local pnpm test:install:e2e"',
|
|
);
|
|
expect(scenarios).toContain(
|
|
'"OPENCLAW_OPENWEBUI_MODEL=openai/gpt-5.4-mini OPENWEBUI_SMOKE_MODE=models OPENCLAW_OPENWEBUI_PROVIDER_TIMEOUT_SECONDS=300 OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:openwebui"',
|
|
);
|
|
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");
|
|
|
|
expect(runner).toContain(
|
|
'AGENT_TURNS_PARALLEL="${OPENCLAW_INSTALL_E2E_AGENT_TURNS_PARALLEL:-1}"',
|
|
);
|
|
expect(runner).toContain("time_phase");
|
|
expect(runner).toContain("phase_mark_start");
|
|
expect(runner).toContain("run_agent_turn_bg");
|
|
expect(runner).toContain("wait_agent_turn_batch");
|
|
expect(runner).toContain("agent_turn_outputs_include_billing_drift");
|
|
expect(runner).toContain("SKIP: Anthropic billing drift during installer agent tool smoke");
|
|
expect(runner).not.toContain('run_agent_turn_bg "read proof"');
|
|
expect(runner).toContain('run_agent_turn_bg "image write"');
|
|
expect(runner).toContain('run_agent_turn_logged_or_skip_profile "read proof copy"');
|
|
expect(wrapper).toContain("OPENCLAW_INSTALL_E2E_AGENT_TURNS_PARALLEL");
|
|
expect(wrapper).toContain("OPENCLAW_INSTALL_E2E_AGENT_TOOL_SMOKE");
|
|
expect(wrapper).toContain("OPENCLAW_INSTALL_E2E_OPENAI_MODEL");
|
|
expect(wrapper).toContain("OPENCLAW_INSTALL_E2E_OPENAI_PROVIDER_TIMEOUT_SECONDS");
|
|
expect(wrapper).toContain("OPENCLAW_INSTALL_E2E_AGENT_TURN_TIMEOUT_SECONDS:-300");
|
|
expect(runner).toContain("OPENCLAW_INSTALL_E2E_OPENAI_MODEL");
|
|
expect(runner).toContain("OPENCLAW_INSTALL_E2E_OPENAI_PROVIDER_TIMEOUT_SECONDS");
|
|
expect(runner).toContain(
|
|
'AGENT_TURN_TIMEOUT_SECONDS="${OPENCLAW_INSTALL_E2E_AGENT_TURN_TIMEOUT_SECONDS:-300}"',
|
|
);
|
|
});
|
|
|
|
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("index.installRecords ?? index.records ?? 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");
|
|
|
|
expect(runner).toContain("UPDATE_FAILED=0");
|
|
expect(runner).toContain("GATEWAY_START_FAILED=0");
|
|
expect(runner).toContain("GATEWAY_HEALTH_FAILED=0");
|
|
expect(runner).toContain('if [ "$UPDATE_FAILED" -ne 0 ]; then');
|
|
expect(runner).toContain('if [ "$GATEWAY_START_FAILED" -ne 0 ]; then');
|
|
expect(runner).toContain('if [ "$GATEWAY_HEALTH_FAILED" -ne 0 ]; then');
|
|
expect(runner).toContain("ActiveState=active");
|
|
expect(runner).toContain("OPENCLAW_NO_RESPAWN=1");
|
|
expect(runner).toContain("is-enabled)");
|
|
expect(runner).toContain("/healthz");
|
|
expect(runner).toContain("FAIL: gateway install failed before update");
|
|
expect(runner).not.toContain('gateway-install.err" || true');
|
|
expect(runner).not.toContain("WARNING: Gateway status probe failed");
|
|
});
|
|
|
|
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 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",
|
|
);
|
|
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(doctorScenario).toContain("scripts/e2e/lib/package-compat.mjs");
|
|
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(updateChannelAssertions).toContain("expected persisted update.channel ${channel}");
|
|
expect(pluginsAssertions).toContain("expected modern installRecords in installed plugin index");
|
|
});
|
|
|
|
it("routes doctor install switch commands through the E2E timeout helper", () => {
|
|
const scenario = readFileSync(DOCTOR_SWITCH_SCENARIO_PATH, "utf8");
|
|
|
|
expect(scenario).toContain('command_timeout="${OPENCLAW_DOCKER_DOCTOR_SWITCH_COMMAND_TIMEOUT:-900s}"');
|
|
expect(scenario).toContain('openclaw_e2e_maybe_timeout "$command_timeout" bash -c "$install_cmd"');
|
|
expect(scenario).toContain('openclaw_e2e_maybe_timeout "$command_timeout" bash -c "$doctor_cmd"');
|
|
expect(scenario).toContain('openclaw_e2e_maybe_timeout "$command_timeout" "$npm_bin" gateway install --wrapper "$wrapper" --force');
|
|
expect(scenario).toContain('openclaw_e2e_maybe_timeout "$command_timeout" node "$git_cli" doctor --repair --force --yes');
|
|
expect(scenario).not.toMatch(/^\s*if ! timeout "\$command_timeout"/mu);
|
|
});
|
|
|
|
it("prepares pnpm workspace package fixtures without package dependencies", () => {
|
|
const root = mkdtempSync(join(tmpdir(), "openclaw-update-channel-fixture-"));
|
|
try {
|
|
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();
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
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");
|
|
|
|
expect(runner).toContain("OPENCLAW_BUNDLED_PLUGIN_SWEEP_TOTAL");
|
|
expect(runner).toContain("OPENCLAW_BUNDLED_PLUGIN_SWEEP_INDEX");
|
|
expect(runner).toContain("OPENCLAW_BUNDLED_PLUGIN_RUNTIME_READY_MS");
|
|
expect(runner).toContain("scripts/e2e/lib/bundled-plugin-install-uninstall/sweep.sh");
|
|
expect(runner).toContain('tee "$RUN_LOG"');
|
|
expect(runner).not.toContain('cat "$RUN_LOG"');
|
|
expect(probe).toContain('"openclaw.plugin.json"');
|
|
expect(runtimeSmoke).toContain("process.env.OPENCLAW_BUNDLED_PLUGIN_RUNTIME_READY_MS");
|
|
expect(runtimeSmoke).toContain("900000");
|
|
expect(sweep).toContain("read -r plugin_id plugin_dir requires_config");
|
|
expect(sweep).toContain('node "$OPENCLAW_ENTRY" plugins install "$plugin_id"');
|
|
expect(sweep).toContain('node "$OPENCLAW_ENTRY" plugins uninstall "$plugin_id" --force');
|
|
expect(sweep).toContain("assert-installed");
|
|
expect(sweep).toContain("assert-uninstalled");
|
|
});
|
|
|
|
it("passes installer tag env to bash, not curl", () => {
|
|
const runner = readFileSync(INSTALL_E2E_RUNNER_PATH, "utf8");
|
|
|
|
expect(runner).toContain('curl -fsSL "$INSTALL_URL" | OPENCLAW_BETA=1 bash');
|
|
expect(runner).toContain('curl -fsSL "$INSTALL_URL" | OPENCLAW_VERSION="$INSTALL_TAG" bash');
|
|
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");
|
|
|
|
expect(runner).toContain('SESSION_ID_PREFIX="e2e-tools-${profile}"');
|
|
expect(runner).toContain('TURN2B_SESSION_ID="${SESSION_ID_PREFIX}-read-copy"');
|
|
expect(runner).toContain('TURN3_SESSION_ID="${SESSION_ID_PREFIX}-exec-hostname"');
|
|
expect(runner).toContain('TURN4_SESSION_ID="${SESSION_ID_PREFIX}-image-write"');
|
|
});
|
|
|
|
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");
|
|
|
|
expect(runner).toContain("scripts/e2e/lib/openai-web-search-minimal/scenario.sh");
|
|
expect(scenario).toContain("scripts/e2e/lib/openai-web-search-minimal/client.mjs");
|
|
expect(client).toContain("const callGateway = await loadCallGateway();");
|
|
expect(client).toContain('method: "agent"');
|
|
expect(client).toContain("expectFinal: true");
|
|
expect(client).toContain('scopes: ["operator.write"]');
|
|
expect(client).not.toContain('"agent.wait"');
|
|
});
|
|
|
|
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");
|
|
|
|
expect(runner).toContain("scripts/e2e/lib/plugins/sweep.sh");
|
|
expect(runner).toContain("OPENCLAW_PLUGINS_E2E_LIVE_CLAWHUB");
|
|
expect(sweep).toContain("scripts/e2e/lib/plugins/clawhub.sh");
|
|
expect(clawhub).toContain("start_clawhub_fixture_server()");
|
|
expect(clawhub).toContain('OPENCLAW_CLAWHUB_URL="http://127.0.0.1:');
|
|
expect(clawhub).toContain("OPENCLAW_PLUGINS_E2E_LIVE_CLAWHUB");
|
|
expect(clawhub).toContain("OPENCLAW_PLUGINS_E2E_LIVE_NPM_REGISTRY");
|
|
expect(clawhub).toContain("live ClawHub can rate-limit CI");
|
|
expect(clawhub).toContain('[[ -n "${OPENCLAW_CLAWHUB_URL:-}" || -n "${CLAWHUB_URL:-}" ]]');
|
|
expect(clawhub).toContain("Ignoring ambient ClawHub URL for fixture-mode plugin E2E");
|
|
expect(clawhub).toContain("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");
|
|
|
|
expect(runner).toContain("--reporter=verbose -t");
|
|
expect(runner).not.toContain("-- --reporter=verbose");
|
|
expect(runner).toContain("docker_e2e_docker_run_cmd run --rm");
|
|
expect(runner).toContain('docker_e2e_docker_cmd rm -f "$CONTAINER_NAME"');
|
|
expect(runner).not.toMatch(/(^|\n)docker run --rm/u);
|
|
expect(runner).toContain("expected focused Vitest summary for exactly 3 passed tests");
|
|
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 install/update sources in the Docker plugin sweep", () => {
|
|
const sweep = readFileSync(PLUGINS_DOCKER_SWEEP_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");
|
|
|
|
expect(sweep).toContain('plugins install "$dir_plugin"');
|
|
expect(sweep).toContain("plugins update demo-plugin-dir");
|
|
expect(assertions).toContain('Skipping "demo-plugin-dir" (source: path).');
|
|
|
|
expect(sweep).toContain("start_npm_fixture_registry");
|
|
expect(sweep).toContain('plugins install "npm:@openclaw/demo-plugin-npm@0.0.1"');
|
|
expect(sweep).toContain("plugins update demo-plugin-npm");
|
|
expect(assertions).toContain("demo-plugin-npm is up to date (0.0.1).");
|
|
expect(npmRegistry).toContain('"dist-tags": { latest: entry.latestVersion }');
|
|
expect(npmRegistry).toContain("existing.latestVersion = version");
|
|
expect(npmRegistry).toContain("packageArgs.length % 3");
|
|
|
|
expect(sweep).toContain('plugins install "git:$git_update_repo_url@main"');
|
|
expect(sweep).toContain("plugins update demo-plugin-git-update");
|
|
expect(assertions).toContain("demo.git.update.v2");
|
|
|
|
expect(clawhub).toContain('plugins install "$CLAWHUB_PLUGIN_SPEC"');
|
|
expect(clawhub).toContain('plugins update "$CLAWHUB_PLUGIN_ID"');
|
|
expect(clawhub).toContain("clawhub:@openclaw/kitchen-sink");
|
|
expect(assertions).toContain("clawhub-updated");
|
|
expect(assertions).toContain("record.clawpackSha256");
|
|
expect(assertions).toContain("record.artifactKind");
|
|
expect(assertions).toContain("record.npmIntegrity");
|
|
});
|
|
});
|