From 25e2e64ce4c42ecf9c30db0329a9782b001acb87 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 22 Apr 2026 02:42:04 +0100 Subject: [PATCH] test(docker): cover bundled channel update deps --- docs/help/testing.md | 4 + .../bundled-channel-runtime-deps-docker.sh | 287 ++++++++++++++++++ 2 files changed, 291 insertions(+) diff --git a/docs/help/testing.md b/docs/help/testing.md index ecf480d671f..d25387c1a53 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -82,6 +82,10 @@ These commands sit beside the main test suites when you need QA-lab realism: - Verifies the first Gateway restart installs each bundled channel plugin's runtime dependencies on demand, and a second restart does not reinstall dependencies that were already activated. + - Also installs a known older npm baseline, enables Telegram before running + `openclaw update --tag `, and verifies the candidate's + post-update doctor repairs bundled channel runtime dependencies without a + harness-side postinstall repair. - `pnpm openclaw qa aimock` - Starts only the local AIMock provider server for direct protocol smoke testing. diff --git a/scripts/e2e/bundled-channel-runtime-deps-docker.sh b/scripts/e2e/bundled-channel-runtime-deps-docker.sh index 6d7b26b4573..6a6b763fdfa 100644 --- a/scripts/e2e/bundled-channel-runtime-deps-docker.sh +++ b/scripts/e2e/bundled-channel-runtime-deps-docker.sh @@ -5,6 +5,8 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" source "$ROOT_DIR/scripts/lib/docker-e2e-logs.sh" IMAGE_NAME="${OPENCLAW_BUNDLED_CHANNEL_DEPS_E2E_IMAGE:-openclaw-bundled-channel-deps-e2e}" +UPDATE_BASELINE_VERSION="${OPENCLAW_BUNDLED_CHANNEL_UPDATE_BASELINE_VERSION:-2026.4.20}" +RUN_UPDATE_SCENARIO="${OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO:-1}" echo "Building Docker image..." run_logged bundled-channel-deps-build docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" @@ -282,5 +284,290 @@ EOF rm -f "$run_log" } +run_update_scenario() { + local run_log + run_log="$(mktemp "${TMPDIR:-/tmp}/openclaw-bundled-channel-update.XXXXXX")" + + echo "Running bundled channel runtime deps Docker update E2E..." + if ! docker run --rm \ + -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ + -e OPENCLAW_BUNDLED_CHANNEL_UPDATE_BASELINE_VERSION="$UPDATE_BASELINE_VERSION" \ + -i "$IMAGE_NAME" bash -s >"$run_log" 2>&1 <<'EOF' +set -euo pipefail + +export HOME="$(mktemp -d "/tmp/openclaw-bundled-channel-update.XXXXXX")" +export NPM_CONFIG_PREFIX="$HOME/.npm-global" +export PATH="$NPM_CONFIG_PREFIX/bin:$PATH" +export OPENAI_API_KEY="sk-openclaw-bundled-channel-update-e2e" +export OPENCLAW_NO_ONBOARD=1 +export OPENCLAW_UPDATE_PACKAGE_SPEC="" + +BASELINE_VERSION="${OPENCLAW_BUNDLED_CHANNEL_UPDATE_BASELINE_VERSION:?missing baseline version}" +TOKEN="bundled-channel-update-token" +PORT="18790" + +package_root() { + printf "%s/openclaw" "$(npm root -g)" +} + +pack_current_candidate() { + local pack_dir + pack_dir="$(mktemp -d "/tmp/openclaw-update-pack.XXXXXX")" + node --import tsx --input-type=module -e 'const { writePackageDistInventory } = await import("./src/infra/package-dist-inventory.ts"); await writePackageDistInventory(process.cwd());' >/tmp/openclaw-update-inventory.log 2>&1 + npm pack --ignore-scripts --pack-destination "$pack_dir" >/tmp/openclaw-update-pack.log 2>&1 + find "$pack_dir" -maxdepth 1 -name 'openclaw-*.tgz' -print -quit +} + +package_tgz="$(pack_current_candidate)" +if [ -z "$package_tgz" ]; then + cat /tmp/openclaw-update-pack.log + echo "missing packed OpenClaw candidate tarball" >&2 + exit 1 +fi +update_target="file:$package_tgz" +candidate_version="$(node - <<'NODE' "$package_tgz" +const { execFileSync } = require("node:child_process"); +const raw = execFileSync("tar", ["-xOf", process.argv[2], "package/package.json"], { + encoding: "utf8", +}); +process.stdout.write(String(JSON.parse(raw).version)); +NODE +)" + +write_config() { + local mode="$1" + node - <<'NODE' "$mode" "$TOKEN" "$PORT" +const fs = require("node:fs"); +const path = require("node:path"); + +const mode = process.argv[2]; +const token = process.argv[3]; +const port = Number(process.argv[4]); +const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); +const config = fs.existsSync(configPath) + ? JSON.parse(fs.readFileSync(configPath, "utf8")) + : {}; + +config.gateway = { + ...(config.gateway || {}), + port, + auth: { mode: "token", token }, + controlUi: { enabled: false }, +}; +config.agents = { + ...(config.agents || {}), + defaults: { + ...(config.agents?.defaults || {}), + model: { primary: "openai/gpt-4.1-mini" }, + }, +}; +config.models = { + ...(config.models || {}), + providers: { + ...(config.models?.providers || {}), + openai: { + ...(config.models?.providers?.openai || {}), + apiKey: process.env.OPENAI_API_KEY, + baseUrl: "https://api.openai.com/v1", + models: [], + }, + }, +}; +config.plugins = { + ...(config.plugins || {}), + enabled: true, +}; +config.channels = { + ...(config.channels || {}), + telegram: { + ...(config.channels?.telegram || {}), + enabled: mode === "telegram", + botToken: "123456:bundled-channel-update-token", + dmPolicy: "disabled", + groupPolicy: "disabled", + }, + discord: { + ...(config.channels?.discord || {}), + enabled: mode === "discord", + dmPolicy: "disabled", + groupPolicy: "disabled", + }, +}; + +fs.mkdirSync(path.dirname(configPath), { recursive: true }); +fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); +NODE +} + +assert_dep_sentinel() { + local channel="$1" + local dep_path="$2" + local root + root="$(package_root)" + if [ ! -f "$root/dist/extensions/$channel/node_modules/$dep_path/package.json" ]; then + echo "missing dependency sentinel for $channel: $dep_path" >&2 + find "$root/dist/extensions/$channel" -maxdepth 3 -type f | sort | head -80 >&2 || true + exit 1 + fi +} + +assert_no_dep_sentinel() { + local channel="$1" + local dep_path="$2" + local root + root="$(package_root)" + if [ -f "$root/dist/extensions/$channel/node_modules/$dep_path/package.json" ]; then + echo "dependency sentinel should be absent before repair for $channel: $dep_path" >&2 + exit 1 + fi +} + +assert_dep_available() { + local channel="$1" + local dep_path="$2" + local root + root="$(package_root)" + for candidate in \ + "$root/dist/extensions/$channel/node_modules/$dep_path/package.json" \ + "$root/dist/extensions/node_modules/$dep_path/package.json" \ + "$root/node_modules/$dep_path/package.json"; do + if [ -f "$candidate" ]; then + return 0 + fi + done + echo "missing dependency sentinel for $channel: $dep_path" >&2 + find "$root/dist/extensions/$channel" -maxdepth 3 -type f | sort | head -80 >&2 || true + find "$root/node_modules" -maxdepth 3 -path "*/$dep_path/package.json" -type f -print >&2 || true + exit 1 +} + +assert_no_dep_available() { + local channel="$1" + local dep_path="$2" + local root + root="$(package_root)" + for candidate in \ + "$root/dist/extensions/$channel/node_modules/$dep_path/package.json" \ + "$root/dist/extensions/node_modules/$dep_path/package.json" \ + "$root/node_modules/$dep_path/package.json"; do + if [ -f "$candidate" ]; then + echo "dependency sentinel should be absent before repair for $channel: $dep_path ($candidate)" >&2 + exit 1 + fi + done +} + +remove_runtime_dep() { + local channel="$1" + local dep_path="$2" + local root + root="$(package_root)" + rm -rf "$root/dist/extensions/$channel/node_modules" + rm -rf "$root/dist/extensions/node_modules/$dep_path" + rm -rf "$root/node_modules/$dep_path" +} + +assert_update_ok() { + local json_file="$1" + local expected_before="$2" + node - <<'NODE' "$json_file" "$expected_before" "$candidate_version" +const fs = require("node:fs"); +const payload = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const expectedBefore = process.argv[3]; +const expectedAfter = process.argv[4]; +if (payload.status !== "ok") { + throw new Error(`expected update status ok, got ${JSON.stringify(payload.status)}`); +} +if (expectedBefore && (payload.before?.version ?? null) !== expectedBefore) { + throw new Error( + `expected before.version ${expectedBefore}, got ${JSON.stringify(payload.before?.version)}`, + ); +} +if ((payload.after?.version ?? null) !== expectedAfter) { + throw new Error( + `expected after.version ${expectedAfter}, got ${JSON.stringify(payload.after?.version)}`, + ); +} +const steps = Array.isArray(payload.steps) ? payload.steps : []; +const doctor = steps.find((step) => step?.name === "openclaw doctor"); +if (!doctor) { + throw new Error("missing openclaw doctor step"); +} +if (Number(doctor.exitCode ?? 1) !== 0) { + throw new Error(`openclaw doctor step failed: ${JSON.stringify(doctor)}`); +} +NODE +} + +run_update_and_capture() { + local label="$1" + local out_file="$2" + set +e + openclaw update --tag "$update_target" --yes --json >"$out_file" 2>"/tmp/openclaw-$label-update.stderr" + local status=$? + set -e + if [ "$status" -ne 0 ]; then + echo "openclaw update failed for $label with exit code $status" >&2 + cat "$out_file" >&2 || true + cat "/tmp/openclaw-$label-update.stderr" >&2 || true + exit "$status" + fi +} + +echo "Installing known-bad baseline $BASELINE_VERSION..." +npm install -g "openclaw@$BASELINE_VERSION" --omit=optional --no-fund --no-audit >/tmp/openclaw-update-baseline-install.log 2>&1 +command -v openclaw >/dev/null +baseline_root="$(package_root)" +test -d "$baseline_root/dist/extensions/telegram" + +echo "Replicating configured Telegram missing-runtime state..." +write_config telegram +assert_no_dep_available telegram grammy +set +e +openclaw doctor --non-interactive >/tmp/openclaw-baseline-doctor.log 2>&1 +baseline_doctor_status=$? +set -e +if [ "$baseline_doctor_status" -eq 0 ] || ! grep -Eq "grammy|ERR_MODULE_NOT_FOUND|Cannot find module" /tmp/openclaw-baseline-doctor.log; then + echo "expected baseline doctor to fail on missing Telegram runtime deps" >&2 + cat /tmp/openclaw-baseline-doctor.log >&2 + exit 1 +fi + +echo "Updating from baseline to current candidate; candidate doctor must repair Telegram deps..." +run_update_and_capture telegram /tmp/openclaw-update-telegram.json +cat /tmp/openclaw-update-telegram.json +assert_update_ok /tmp/openclaw-update-telegram.json "$BASELINE_VERSION" +assert_dep_available telegram grammy + +echo "Mutating installed package: remove Telegram deps, then update-mode doctor repairs them..." +remove_runtime_dep telegram grammy +assert_no_dep_available telegram grammy +OPENCLAW_UPDATE_IN_PROGRESS=1 openclaw doctor --non-interactive >/tmp/openclaw-update-mode-doctor.log 2>&1 +assert_dep_available telegram grammy + +echo "Mutating config to Discord and rerunning same-version update path..." +write_config discord +remove_runtime_dep discord discord-api-types +assert_no_dep_available discord discord-api-types +run_update_and_capture discord /tmp/openclaw-update-discord.json +cat /tmp/openclaw-update-discord.json +assert_update_ok /tmp/openclaw-update-discord.json "$candidate_version" +assert_dep_available discord discord-api-types + +echo "bundled channel runtime deps Docker update E2E passed" +EOF + then + cat "$run_log" + rm -f "$run_log" + exit 1 + fi + + cat "$run_log" + rm -f "$run_log" +} + run_channel_scenario telegram grammy run_channel_scenario discord discord-api-types +if [ "$RUN_UPDATE_SCENARIO" != "0" ]; then + run_update_scenario +fi