From 6709589117ce597db12b540b9a5b615a7e8b6c15 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 11 Apr 2026 21:35:38 -0700 Subject: [PATCH] test: harden npm install docker smoke --- scripts/docker/install-sh-smoke/run.sh | 110 +++++++++++++++- scripts/test-install-sh-docker.sh | 131 ++++++++++++++++++-- test/scripts/test-install-sh-docker.test.ts | 53 ++++++++ 3 files changed, 281 insertions(+), 13 deletions(-) create mode 100644 test/scripts/test-install-sh-docker.test.ts diff --git a/scripts/docker/install-sh-smoke/run.sh b/scripts/docker/install-sh-smoke/run.sh index 9275a64f763..8e55f1e9075 100755 --- a/scripts/docker/install-sh-smoke/run.sh +++ b/scripts/docker/install-sh-smoke/run.sh @@ -13,16 +13,108 @@ UPDATE_BASELINE_VERSION="${OPENCLAW_INSTALL_UPDATE_BASELINE:-2026.4.10}" UPDATE_BASELINE_TAG_URL="${OPENCLAW_INSTALL_UPDATE_BASELINE_TAG_URL:-}" UPDATE_EXPECT_VERSION="${OPENCLAW_INSTALL_UPDATE_EXPECT_VERSION:-}" UPDATE_TAG_URL="${OPENCLAW_INSTALL_UPDATE_TAG_URL:-}" +HEARTBEAT_INTERVAL="${OPENCLAW_INSTALL_SMOKE_HEARTBEAT_INTERVAL:-60}" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" # shellcheck source=../install-sh-common/cli-verify.sh source "$SCRIPT_DIR/../install-sh-common/cli-verify.sh" +emit_status() { + if [[ -w /dev/tty ]]; then + printf "%s\n" "$*" >/dev/tty + else + printf "%s\n" "$*" >&2 + fi +} + +global_package_root() { + local npm_root + npm_root="$(quiet_npm root -g 2>/dev/null || true)" + if [[ -n "$npm_root" ]]; then + printf "%s/%s" "$npm_root" "$PACKAGE_NAME" + fi +} + +describe_installed_package() { + local root="$1" + local files="missing" + local size="missing" + local version="missing" + if [[ -d "$root" ]]; then + files="$(find "$root" -type f 2>/dev/null | wc -l | tr -d " ")" + size="$(du -sh "$root" 2>/dev/null | cut -f1 || true)" + version="$( + node -e ' +try { + process.stdout.write(String(require(`${process.argv[1]}/package.json`).version ?? "missing")); +} catch { + process.stdout.write("missing"); +} +' "$root" + )" + fi + printf "version=%s size=%s files=%s root=%s" "$version" "$size" "$files" "$root" +} + +print_install_audit() { + local label="$1" + local root + root="$(global_package_root)" + if [[ -n "$root" ]]; then + echo "==> Install audit (${label}): $(describe_installed_package "$root")" + fi +} + +run_with_heartbeat() { + local label="$1" + shift + local interval="$HEARTBEAT_INTERVAL" + if ! [[ "$interval" =~ ^[0-9]+$ ]] || [[ "$interval" == "0" ]]; then + "$@" + return + fi + + local start + local command_pid + local heartbeat_pid + local status + start="$(date +%s)" + set +e + "$@" & + command_pid=$! + ( + while true; do + sleep "$interval" + kill -0 "$command_pid" >/dev/null 2>&1 || exit 0 + local now + local elapsed + local root + now="$(date +%s)" + elapsed=$((now - start)) + root="$(global_package_root)" + if [[ -n "$root" ]]; then + emit_status "==> Still running (${label}, ${elapsed}s): $(describe_installed_package "$root")" + else + emit_status "==> Still running (${label}, ${elapsed}s)" + fi + done + ) & + heartbeat_pid=$! + wait "$command_pid" + status=$? + kill "$heartbeat_pid" >/dev/null 2>&1 || true + wait "$heartbeat_pid" >/dev/null 2>&1 || true + set -e + return "$status" +} + run_install_smoke() { if [[ -n "$FRESH_VERSION" && -n "$FRESH_TAG_URL" ]]; then echo "package=$PACKAGE_NAME latest=$FRESH_VERSION source=$FRESH_TAG_URL" echo "==> Install latest release tarball" - quiet_npm install -g --omit=optional "$FRESH_TAG_URL" + run_with_heartbeat "install latest release tarball" \ + quiet_npm install -g --omit=optional "$FRESH_TAG_URL" + print_install_audit "fresh install" echo "==> Verify installed version" if [[ -n "${OPENCLAW_INSTALL_LATEST_OUT:-}" ]]; then @@ -75,7 +167,9 @@ NODE echo "==> Skip preinstall previous (OPENCLAW_INSTALL_SMOKE_SKIP_PREVIOUS=1)" else echo "==> Preinstall previous (forces installer upgrade path)" - quiet_npm install -g "${PACKAGE_NAME}@${PREVIOUS_VERSION}" + run_with_heartbeat "preinstall previous release" \ + quiet_npm install -g "${PACKAGE_NAME}@${PREVIOUS_VERSION}" + print_install_audit "previous install" fi echo "==> Run official installer one-liner" @@ -103,10 +197,13 @@ run_update_smoke() { echo "package=$PACKAGE_NAME baseline=$UPDATE_BASELINE_VERSION target=$UPDATE_EXPECT_VERSION" echo "==> Install baseline release" if [[ -n "$UPDATE_BASELINE_TAG_URL" ]]; then - quiet_npm install -g --omit=optional "$UPDATE_BASELINE_TAG_URL" + run_with_heartbeat "install baseline release" \ + quiet_npm install -g --omit=optional "$UPDATE_BASELINE_TAG_URL" else - quiet_npm install -g --omit=optional "${PACKAGE_NAME}@${UPDATE_BASELINE_VERSION}" + run_with_heartbeat "install baseline release" \ + quiet_npm install -g --omit=optional "${PACKAGE_NAME}@${UPDATE_BASELINE_VERSION}" fi + print_install_audit "baseline install" verify_installed_cli "$PACKAGE_NAME" "$UPDATE_BASELINE_VERSION" echo "==> Run openclaw update from host-served tgz" @@ -116,7 +213,9 @@ run_update_smoke() { update_stderr_file="$(mktemp)" set +e UPDATE_JSON="$( - npm_config_omit=optional NPM_CONFIG_OMIT=optional openclaw update --tag "$UPDATE_TAG_URL" --yes --json 2>"$update_stderr_file" + run_with_heartbeat "openclaw update" \ + env npm_config_omit=optional NPM_CONFIG_OMIT=optional \ + openclaw update --tag "$UPDATE_TAG_URL" --yes --json 2>"$update_stderr_file" )" update_status=$? set -e @@ -170,6 +269,7 @@ if (typeof updateStep.command !== "string" || !updateStep.command.includes(expec NODE echo "==> Verify updated version" + print_install_audit "updated install" verify_installed_cli "$PACKAGE_NAME" "$UPDATE_EXPECT_VERSION" echo "OK" diff --git a/scripts/test-install-sh-docker.sh b/scripts/test-install-sh-docker.sh index 3e1f704e83c..d74da025c40 100755 --- a/scripts/test-install-sh-docker.sh +++ b/scripts/test-install-sh-docker.sh @@ -5,9 +5,107 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # shellcheck source=./docker/install-sh-common/version-parse.sh source "$ROOT_DIR/scripts/docker/install-sh-common/version-parse.sh" +resolve_default_smoke_platform() { + local host_os + local host_arch + if [[ -n "${OPENCLAW_INSTALL_SMOKE_PLATFORM:-}" ]]; then + printf "%s" "$OPENCLAW_INSTALL_SMOKE_PLATFORM" + return + fi + if [[ "${CI:-}" == "true" || "${GITHUB_ACTIONS:-}" == "true" ]]; then + printf "linux/amd64" + return + fi + host_os="$(uname -s)" + host_arch="$(uname -m)" + if [[ "$host_os" == "Darwin" && "$host_arch" == "arm64" ]]; then + printf "linux/arm64" + return + fi + printf "linux/amd64" +} + +print_pack_audit() { + local label="$1" + local pack_json_file="$2" + node -e ' +const raw = require("node:fs").readFileSync(process.argv[2], "utf8") || "[]"; +const label = process.argv[1]; +const parsed = JSON.parse(raw); +const last = Array.isArray(parsed) ? parsed.at(-1) : null; +if (!last) { + process.exit(1); +} +const formatBytes = (value) => { + if (!Number.isFinite(value)) return "unknown"; + const units = ["B", "KiB", "MiB", "GiB"]; + let current = value; + let unit = 0; + while (current >= 1024 && unit < units.length - 1) { + current /= 1024; + unit += 1; + } + return `${current.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`; +}; +const fileCount = Number.isFinite(last.entryCount) + ? last.entryCount + : Array.isArray(last.files) + ? last.files.length + : "unknown"; +console.log( + `==> Pack audit (${label}): version=${last.version ?? "unknown"} tgz=${formatBytes(last.size)} unpacked=${formatBytes(last.unpackedSize)} files=${fileCount}`, +); +' "$label" "$pack_json_file" +} + +print_pack_delta_audit() { + local baseline_pack_json_file="$1" + local update_pack_json_file="$2" + node -e ' +const fs = require("node:fs"); +const [baselinePath, updatePath] = process.argv.slice(1); +const readLast = (path) => { + const parsed = JSON.parse(fs.readFileSync(path, "utf8") || "[]"); + return Array.isArray(parsed) ? parsed.at(-1) : null; +}; +const baseline = readLast(baselinePath); +const update = readLast(updatePath); +if (!baseline || !update) { + process.exit(1); +} +const formatSignedBytes = (value) => { + if (!Number.isFinite(value)) return "unknown"; + const sign = value > 0 ? "+" : value < 0 ? "-" : ""; + let current = Math.abs(value); + const units = ["B", "KiB", "MiB", "GiB"]; + let unit = 0; + while (current >= 1024 && unit < units.length - 1) { + current /= 1024; + unit += 1; + } + return `${sign}${current.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`; +}; +const fileCount = (entry) => + Number.isFinite(entry.entryCount) + ? entry.entryCount + : Array.isArray(entry.files) + ? entry.files.length + : undefined; +const baselineFiles = fileCount(baseline); +const updateFiles = fileCount(update); +const fileDelta = + Number.isFinite(baselineFiles) && Number.isFinite(updateFiles) + ? `${updateFiles - baselineFiles >= 0 ? "+" : ""}${updateFiles - baselineFiles}` + : "unknown"; +console.log( + `==> Pack audit delta (${baseline.version ?? "baseline"} -> ${update.version ?? "update"}): tgz=${formatSignedBytes((update.size ?? NaN) - (baseline.size ?? NaN))} unpacked=${formatSignedBytes((update.unpackedSize ?? NaN) - (baseline.unpackedSize ?? NaN))} files=${fileDelta}`, +); +' "$baseline_pack_json_file" "$update_pack_json_file" +} + SMOKE_IMAGE="${OPENCLAW_INSTALL_SMOKE_IMAGE:-openclaw-install-smoke:local}" NONROOT_IMAGE="${OPENCLAW_INSTALL_NONROOT_IMAGE:-openclaw-install-nonroot:local}" -SMOKE_PLATFORM="${OPENCLAW_INSTALL_SMOKE_PLATFORM:-linux/amd64}" +SMOKE_PLATFORM="$(resolve_default_smoke_platform)" NONROOT_PLATFORM="${OPENCLAW_INSTALL_NONROOT_PLATFORM:-$SMOKE_PLATFORM}" INSTALL_URL="${OPENCLAW_INSTALL_URL:-https://openclaw.bot/install.sh}" CLI_INSTALL_URL="${OPENCLAW_INSTALL_CLI_URL:-https://openclaw.bot/install-cli.sh}" @@ -21,6 +119,7 @@ UPDATE_PACKAGE_SPEC="${OPENCLAW_INSTALL_SMOKE_UPDATE_PACKAGE_SPEC:-}" UPDATE_SKIP_LOCAL_BUILD="${OPENCLAW_INSTALL_SMOKE_UPDATE_SKIP_LOCAL_BUILD:-0}" UPDATE_HOST_ALIAS="${OPENCLAW_INSTALL_SMOKE_UPDATE_HOST:-host.docker.internal}" UPDATE_PORT="${OPENCLAW_INSTALL_SMOKE_UPDATE_PORT:-}" +UPDATE_EXPECT_VERSION="${OPENCLAW_INSTALL_SMOKE_UPDATE_EXPECT_VERSION:-}" LATEST_DIR="$(mktemp -d)" LATEST_FILE="${LATEST_DIR}/latest" UPDATE_DIR="$(mktemp -d)" @@ -28,7 +127,6 @@ UPDATE_SERVER_PID="" UPDATE_SERVER_LOG="${UPDATE_DIR}/http.log" UPDATE_TGZ_FILE="" BASELINE_TGZ_FILE="" -UPDATE_EXPECT_VERSION="" BASELINE_TAG_URL="" FRESH_TAG_URL="" UPDATE_TAG_URL="" @@ -64,14 +162,11 @@ prepare_update_tarball() { local baseline_pack_json local pack_json_file local baseline_pack_json_file + local packed_update_version pack_json_file="${UPDATE_DIR}/pack.json" baseline_pack_json_file="${UPDATE_DIR}/baseline-pack.json" if [[ -n "$UPDATE_PACKAGE_SPEC" ]]; then echo "==> Pack update tgz from spec: $UPDATE_PACKAGE_SPEC" - if [[ -z "$UPDATE_EXPECT_VERSION" ]]; then - echo "ERROR: OPENCLAW_INSTALL_SMOKE_UPDATE_EXPECT_VERSION is required with OPENCLAW_INSTALL_SMOKE_UPDATE_PACKAGE_SPEC" >&2 - exit 1 - fi quiet_npm pack "$UPDATE_PACKAGE_SPEC" --json --pack-destination "$UPDATE_DIR" >"$pack_json_file" else echo "==> Build local release artifacts for update smoke" @@ -95,6 +190,24 @@ if (!last || typeof last.filename !== "string" || last.filename.length === 0) { process.stdout.write(last.filename); ' "$pack_json_file" )" + print_pack_audit "update" "$pack_json_file" + packed_update_version="$( + node -e ' +const raw = require("node:fs").readFileSync(process.argv[1], "utf8") || "[]"; +const parsed = JSON.parse(raw); +const last = Array.isArray(parsed) ? parsed.at(-1) : null; +if (!last || typeof last.version !== "string" || last.version.length === 0) { + process.exit(1); +} +process.stdout.write(last.version); +' "$pack_json_file" + )" + if [[ -z "$UPDATE_EXPECT_VERSION" ]]; then + UPDATE_EXPECT_VERSION="$packed_update_version" + elif [[ "$UPDATE_EXPECT_VERSION" != "$packed_update_version" ]]; then + echo "ERROR: packed update version ${packed_update_version} does not match expected ${UPDATE_EXPECT_VERSION}" >&2 + exit 1 + fi echo "==> Pack baseline tgz: ${PACKAGE_NAME}@${UPDATE_BASELINE_VERSION}" quiet_npm pack "${PACKAGE_NAME}@${UPDATE_BASELINE_VERSION}" --json --pack-destination "$UPDATE_DIR" >"$baseline_pack_json_file" @@ -109,6 +222,8 @@ if (!last || typeof last.filename !== "string" || last.filename.length === 0) { process.stdout.write(last.filename); ' "$baseline_pack_json_file" )" + print_pack_audit "baseline" "$baseline_pack_json_file" + print_pack_delta_audit "$baseline_pack_json_file" "$pack_json_file" } prepare_update_host_access() { @@ -145,7 +260,7 @@ start_update_server() { if [[ "$SKIP_SMOKE_IMAGE_BUILD" == "1" ]]; then echo "==> Reuse prebuilt smoke image: $SMOKE_IMAGE" else - echo "==> Build smoke image (upgrade, root): $SMOKE_IMAGE" + echo "==> Build smoke image (upgrade, root, ${SMOKE_PLATFORM}): $SMOKE_IMAGE" docker build \ --platform "$SMOKE_PLATFORM" \ -t "$SMOKE_IMAGE" \ @@ -205,7 +320,7 @@ else if [[ "$SKIP_NONROOT_IMAGE_BUILD" == "1" ]]; then echo "==> Reuse prebuilt non-root image: $NONROOT_IMAGE" else - echo "==> Build non-root image: $NONROOT_IMAGE" + echo "==> Build non-root image (${NONROOT_PLATFORM}): $NONROOT_IMAGE" docker build \ --platform "$NONROOT_PLATFORM" \ -t "$NONROOT_IMAGE" \ diff --git a/test/scripts/test-install-sh-docker.test.ts b/test/scripts/test-install-sh-docker.test.ts new file mode 100644 index 00000000000..a99593573da --- /dev/null +++ b/test/scripts/test-install-sh-docker.test.ts @@ -0,0 +1,53 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const SCRIPT_PATH = "scripts/test-install-sh-docker.sh"; +const SMOKE_RUNNER_PATH = "scripts/docker/install-sh-smoke/run.sh"; + +describe("test-install-sh-docker", () => { + it("defaults local Apple Silicon smoke runs to native arm64 while keeping CI on amd64", () => { + const script = readFileSync(SCRIPT_PATH, "utf8"); + + expect(script).toContain("resolve_default_smoke_platform"); + expect(script).toContain('printf "linux/amd64"'); + expect(script).toContain('[[ "$host_os" == "Darwin" && "$host_arch" == "arm64" ]]'); + expect(script).toContain('printf "linux/arm64"'); + }); + + it("supports npm update package specs without a separate expected-version env", () => { + const script = readFileSync(SCRIPT_PATH, "utf8"); + + expect(script).toContain( + 'UPDATE_EXPECT_VERSION="${OPENCLAW_INSTALL_SMOKE_UPDATE_EXPECT_VERSION:-}"', + ); + expect(script).toContain('if [[ -z "$UPDATE_EXPECT_VERSION" ]]; then'); + expect(script).toContain('UPDATE_EXPECT_VERSION="$packed_update_version"'); + expect(script).toContain( + "packed update version ${packed_update_version} does not match expected ${UPDATE_EXPECT_VERSION}", + ); + }); + + it("prints package size audits for release smoke tarballs", () => { + const script = readFileSync(SCRIPT_PATH, "utf8"); + + expect(script).toContain("print_pack_audit"); + expect(script).toContain("print_pack_delta_audit"); + expect(script).toContain("==> Pack audit"); + expect(script).toContain("==> Pack audit delta"); + }); +}); + +describe("install-sh smoke runner", () => { + it("wraps long npm/update operations with heartbeat and install-size audits", () => { + const script = readFileSync(SMOKE_RUNNER_PATH, "utf8"); + + expect(script).toContain( + 'HEARTBEAT_INTERVAL="${OPENCLAW_INSTALL_SMOKE_HEARTBEAT_INTERVAL:-60}"', + ); + expect(script).toContain("run_with_heartbeat"); + expect(script).toContain("==> Still running"); + expect(script).toContain("print_install_audit"); + expect(script).toContain("quiet_npm install -g --omit=optional"); + expect(script).toContain("openclaw update --tag"); + }); +});