diff --git a/.github/workflows/package-acceptance.yml b/.github/workflows/package-acceptance.yml index 567053897c1..5d5373a5832 100644 --- a/.github/workflows/package-acceptance.yml +++ b/.github/workflows/package-acceptance.yml @@ -354,10 +354,10 @@ jobs: docker_lanes="npm-onboard-channel-agent gateway-network config-reload" ;; package) - docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch bundled-channel-deps-compat plugins-offline plugin-update" + docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch upgrade-survivor bundled-channel-deps-compat plugins-offline plugin-update" ;; product) - docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch bundled-channel-deps-compat plugins plugin-update mcp-channels cron-mcp-cleanup openai-web-search-minimal openwebui" + docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch upgrade-survivor bundled-channel-deps-compat plugins plugin-update mcp-channels cron-mcp-cleanup openai-web-search-minimal openwebui" include_openwebui=true ;; full) diff --git a/docs/ci.md b/docs/ci.md index 00924382d94..2cb087f06aa 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -169,7 +169,7 @@ Keep `workflow_ref` and `package_ref` separate. `workflow_ref` is the trusted wo ### Suite profiles - `smoke` — `npm-onboard-channel-agent`, `gateway-network`, `config-reload` -- `package` — `npm-onboard-channel-agent`, `doctor-switch`, `update-channel-switch`, `bundled-channel-deps-compat`, `plugins-offline`, `plugin-update` +- `package` — `npm-onboard-channel-agent`, `doctor-switch`, `update-channel-switch`, `upgrade-survivor`, `bundled-channel-deps-compat`, `plugins-offline`, `plugin-update` - `product` — `package` plus `mcp-channels`, `cron-mcp-cleanup`, `openai-web-search-minimal`, `openwebui` - `full` — full Docker release-path chunks with OpenWebUI - `custom` — exact `docker_lanes`; required when `suite_profile=custom` diff --git a/docs/help/testing.md b/docs/help/testing.md index 8dad38a71d8..b952f1629bf 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -600,10 +600,10 @@ These Docker runners split into two buckets: `OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=90000`. Override those env vars when you explicitly want the larger exhaustive scan. - `test:docker:all` builds the live Docker image once via `test:docker:live-build`, packs OpenClaw once as an npm tarball through `scripts/package-openclaw-for-docker.mjs`, then builds/reuses two `scripts/e2e/Dockerfile` images. The bare image is only the Node/Git runner for install/update/plugin-dependency lanes; those lanes mount the prebuilt tarball. The functional image installs the same tarball into `/app` for built-app functionality lanes. Docker lane definitions live in `scripts/lib/docker-e2e-scenarios.mjs`; planner logic lives in `scripts/lib/docker-e2e-plan.mjs`; `scripts/test-docker-all.mjs` executes the selected plan. The aggregate uses a weighted local scheduler: `OPENCLAW_DOCKER_ALL_PARALLELISM` controls process slots, while resource caps keep heavy live, npm-install, and multi-service lanes from all starting at once. If a single lane is heavier than the active caps, the scheduler can still start it when the pool is empty and then keeps it running alone until capacity is available again. Defaults are 10 slots, `OPENCLAW_DOCKER_ALL_LIVE_LIMIT=9`, `OPENCLAW_DOCKER_ALL_NPM_LIMIT=10`, and `OPENCLAW_DOCKER_ALL_SERVICE_LIMIT=7`; tune `OPENCLAW_DOCKER_ALL_WEIGHT_LIMIT` or `OPENCLAW_DOCKER_ALL_DOCKER_LIMIT` only when the Docker host has more headroom. The runner performs a Docker preflight by default, removes stale OpenClaw E2E containers, prints status every 30 seconds, stores successful lane timings in `.artifacts/docker-tests/lane-timings.json`, and uses those timings to start longer lanes first on later runs. Use `OPENCLAW_DOCKER_ALL_DRY_RUN=1` to print the weighted lane manifest without building or running Docker, or `node scripts/test-docker-all.mjs --plan-json` to print the CI plan for selected lanes, package/image needs, and credentials. -- `Package Acceptance` is the GitHub-native package gate for "does this installable tarball work as a product?" It resolves one candidate package from `source=npm`, `source=ref`, `source=url`, or `source=artifact`, uploads it as `package-under-test`, then runs the reusable Docker E2E lanes against that exact tarball instead of repacking the selected ref. `workflow_ref` selects the trusted workflow/harness scripts, while `package_ref` selects the source commit/branch/tag to pack when `source=ref`; this lets current acceptance logic validate older trusted commits. Profiles are ordered by breadth: `smoke` is quick install/channel/agent plus gateway/config, `package` is the package/update/plugin contract and the default native replacement for most Parallels package/update coverage, `product` adds MCP channels, cron/subagent cleanup, OpenAI web search, and OpenWebUI, and `full` runs the release-path Docker chunks with OpenWebUI. Release validation runs a custom package delta (`bundled-channel-deps-compat plugins-offline`) plus Telegram package QA because the release-path Docker chunks already cover the overlapping package/update/plugin lanes. Targeted GitHub Docker rerun commands generated from artifacts include prior package artifact and prepared image inputs when available, so failed lanes can avoid rebuilding the package and images. +- `Package Acceptance` is the GitHub-native package gate for "does this installable tarball work as a product?" It resolves one candidate package from `source=npm`, `source=ref`, `source=url`, or `source=artifact`, uploads it as `package-under-test`, then runs the reusable Docker E2E lanes against that exact tarball instead of repacking the selected ref. `workflow_ref` selects the trusted workflow/harness scripts, while `package_ref` selects the source commit/branch/tag to pack when `source=ref`; this lets current acceptance logic validate older trusted commits. Profiles are ordered by breadth: `smoke` is quick install/channel/agent plus gateway/config, `package` is the package/update/plugin contract plus the keyless upgrade-survivor fixture and the default native replacement for most Parallels package/update coverage, `product` adds MCP channels, cron/subagent cleanup, OpenAI web search, and OpenWebUI, and `full` runs the release-path Docker chunks with OpenWebUI. Release validation runs a custom package delta (`bundled-channel-deps-compat plugins-offline`) plus Telegram package QA because the release-path Docker chunks already cover the overlapping package/update/plugin lanes. Targeted GitHub Docker rerun commands generated from artifacts include prior package artifact and prepared image inputs when available, so failed lanes can avoid rebuilding the package and images. - Build and release checks run `scripts/check-cli-bootstrap-imports.mjs` after tsdown. The guard walks the static built graph from `dist/entry.js` and `dist/cli/run-main.js` and fails if pre-dispatch startup imports package dependencies such as Commander, prompt UI, undici, or logging before command dispatch; it also keeps the bundled gateway run chunk under budget and rejects static imports of known cold gateway paths. Packaged CLI smoke also covers root help, onboard help, doctor help, status, config schema, and a model-list command. - Package Acceptance legacy compatibility is capped at `2026.4.25` (`2026.4.25-beta.*` included). Through that cutoff, the harness tolerates only shipped-package metadata gaps: omitted private QA inventory entries, missing `gateway install --wrapper`, missing patch files in the tarball-derived git fixture, missing persisted `update.channel`, legacy plugin install-record locations, missing marketplace install-record persistence, and config metadata migration during `plugins update`. For packages after `2026.4.25`, those paths are strict failures. -- Container smoke runners: `test:docker:openwebui`, `test:docker:onboard`, `test:docker:npm-onboard-channel-agent`, `test:docker:update-channel-switch`, `test:docker:session-runtime-context`, `test:docker:agents-delete-shared-workspace`, `test:docker:gateway-network`, `test:docker:browser-cdp-snapshot`, `test:docker:mcp-channels`, `test:docker:pi-bundle-mcp-tools`, `test:docker:cron-mcp-cleanup`, `test:docker:plugins`, `test:docker:plugin-update`, and `test:docker:config-reload` boot one or more real containers and verify higher-level integration paths. +- Container smoke runners: `test:docker:openwebui`, `test:docker:onboard`, `test:docker:npm-onboard-channel-agent`, `test:docker:update-channel-switch`, `test:docker:upgrade-survivor`, `test:docker:session-runtime-context`, `test:docker:agents-delete-shared-workspace`, `test:docker:gateway-network`, `test:docker:browser-cdp-snapshot`, `test:docker:mcp-channels`, `test:docker:pi-bundle-mcp-tools`, `test:docker:cron-mcp-cleanup`, `test:docker:plugins`, `test:docker:plugin-update`, and `test:docker:config-reload` boot one or more real containers and verify higher-level integration paths. The live-model Docker runners also bind-mount only the needed CLI auth homes (or all supported ones when the run is not narrowed), then copy them into the container home before the run so external-CLI OAuth can refresh tokens without mutating the host auth store: @@ -617,6 +617,7 @@ The live-model Docker runners also bind-mount only the needed CLI auth homes (or - Onboarding wizard (TTY, full scaffolding): `pnpm test:docker:onboard` (script: `scripts/e2e/onboard-docker.sh`) - Npm tarball onboarding/channel/agent smoke: `pnpm test:docker:npm-onboard-channel-agent` installs the packed OpenClaw tarball globally in Docker, configures OpenAI via env-ref onboarding plus Telegram by default, verifies doctor repairs activated plugin runtime deps, and runs one mocked OpenAI agent turn. Reuse a prebuilt tarball with `OPENCLAW_CURRENT_PACKAGE_TGZ=/path/to/openclaw-*.tgz`, skip the host rebuild with `OPENCLAW_NPM_ONBOARD_HOST_BUILD=0`, or switch channel with `OPENCLAW_NPM_ONBOARD_CHANNEL=discord`. - Update channel switch smoke: `pnpm test:docker:update-channel-switch` installs the packed OpenClaw tarball globally in Docker, switches from package `stable` to git `dev`, verifies the persisted channel and plugin post-update work, then switches back to package `stable` and checks update status. +- Upgrade survivor smoke: `pnpm test:docker:upgrade-survivor` installs the packed OpenClaw tarball over a dirty old-user fixture with agents, channel config, plugin allowlists, stale plugin runtime-deps state, and existing workspace/session files. It runs package update plus non-interactive doctor without live provider or channel keys, then starts a loopback Gateway and checks config/state preservation plus startup/status budgets. - Session runtime context smoke: `pnpm test:docker:session-runtime-context` verifies hidden runtime context transcript persistence plus doctor repair of affected duplicated prompt-rewrite branches. - Bun global install smoke: `bash scripts/e2e/bun-global-install-smoke.sh` packs the current tree, installs it with `bun install -g` in an isolated home, and verifies `openclaw infer image providers --json` returns bundled image providers instead of hanging. Reuse a prebuilt tarball with `OPENCLAW_BUN_GLOBAL_SMOKE_PACKAGE_TGZ=/path/to/openclaw-*.tgz`, skip the host build with `OPENCLAW_BUN_GLOBAL_SMOKE_HOST_BUILD=0`, or copy `dist/` from a built Docker image with `OPENCLAW_BUN_GLOBAL_SMOKE_DIST_IMAGE=openclaw-dockerfile-smoke:local`. - Installer Docker smoke: `bash scripts/test-install-sh-docker.sh` shares one npm cache across its root, update, and direct-npm containers. Update smoke defaults to npm `latest` as the stable baseline before upgrading to the candidate tarball. Override with `OPENCLAW_INSTALL_SMOKE_UPDATE_BASELINE=2026.4.22` locally, or with the Install Smoke workflow's `update_baseline_version` input on GitHub. Non-root installer checks keep an isolated npm cache so root-owned cache entries do not mask user-local install behavior. Set `OPENCLAW_INSTALL_SMOKE_NPM_CACHE_DIR=/path/to/cache` to reuse the root/update/direct-npm cache across local reruns. diff --git a/docs/reference/test.md b/docs/reference/test.md index fec63687c3d..fb3bc1616bb 100644 --- a/docs/reference/test.md +++ b/docs/reference/test.md @@ -42,6 +42,7 @@ title: "Tests" - CLI backend live Docker probes can be run as focused lanes, for example `pnpm test:docker:live-cli-backend:codex`, `pnpm test:docker:live-cli-backend:codex:resume`, or `pnpm test:docker:live-cli-backend:codex:mcp`. Claude and Gemini have matching `:resume` and `:mcp` aliases. - `pnpm test:docker:openwebui`: Starts Dockerized OpenClaw + Open WebUI, signs in through Open WebUI, checks `/api/models`, then runs a real proxied chat through `/api/chat/completions`. Requires a usable live model key (for example OpenAI in `~/.profile`), pulls an external Open WebUI image, and is not expected to be CI-stable like the normal unit/e2e suites. - `pnpm test:docker:mcp-channels`: Starts a seeded Gateway container and a second client container that spawns `openclaw mcp serve`, then verifies routed conversation discovery, transcript reads, attachment metadata, live event queue behavior, outbound send routing, and Claude-style channel + permission notifications over the real stdio bridge. The Claude notification assertion reads the raw stdio MCP frames directly so the smoke reflects what the bridge actually emits. +- `pnpm test:docker:upgrade-survivor`: Installs the packed OpenClaw tarball over a dirty old-user fixture, runs package update plus non-interactive doctor without live provider or channel keys, then starts a loopback Gateway and checks that agents, channel config, plugin allowlists, workspace/session files, stale plugin runtime-deps state, startup, and RPC status survive. ## Local PR gate diff --git a/package.json b/package.json index 972ed6276ad..8b39799916f 100644 --- a/package.json +++ b/package.json @@ -1518,6 +1518,7 @@ "test:docker:session-runtime-context": "bash scripts/e2e/session-runtime-context-docker.sh", "test:docker:timings": "node scripts/docker-e2e-timings.mjs", "test:docker:update-channel-switch": "bash scripts/e2e/update-channel-switch-docker.sh", + "test:docker:upgrade-survivor": "bash scripts/e2e/upgrade-survivor-docker.sh", "test:e2e": "node scripts/run-vitest.mjs run --config test/vitest/vitest.e2e.config.ts", "test:e2e:openshell": "OPENCLAW_E2E_OPENSHELL=1 node scripts/run-vitest.mjs run --config test/vitest/vitest.e2e.config.ts extensions/openshell/src/backend.e2e.test.ts", "test:extension": "node scripts/test-extension.mjs", diff --git a/scripts/e2e/lib/upgrade-survivor/assertions.mjs b/scripts/e2e/lib/upgrade-survivor/assertions.mjs new file mode 100644 index 00000000000..225bbdade4c --- /dev/null +++ b/scripts/e2e/lib/upgrade-survivor/assertions.mjs @@ -0,0 +1,174 @@ +import fs from "node:fs"; +import path from "node:path"; + +const command = process.argv[2]; + +function requireEnv(name) { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} is required`); + } + return value; +} + +function readJson(file) { + return JSON.parse(fs.readFileSync(file, "utf8")); +} + +function write(file, contents) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, contents); +} + +function writeJson(file, value) { + write(file, `${JSON.stringify(value, null, 2)}\n`); +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +function getConfig() { + return readJson(requireEnv("OPENCLAW_CONFIG_PATH")); +} + +function seedState() { + const stateDir = requireEnv("OPENCLAW_STATE_DIR"); + const workspace = requireEnv("OPENCLAW_TEST_WORKSPACE_DIR"); + + write( + path.join(workspace, "IDENTITY.md"), + "# Upgrade Survivor\n\nThis workspace must survive package update and doctor repair.\n", + ); + writeJson(path.join(workspace, ".openclaw", "workspace-state.json"), { + version: 1, + setupCompletedAt: "2026-04-01T00:00:00.000Z", + }); + writeJson(path.join(stateDir, "agents", "main", "sessions", "legacy-session.json"), { + id: "legacy-session", + agentId: "main", + title: "Existing user session", + }); + + const runtimeRoot = path.join(stateDir, "plugin-runtime-deps"); + for (const plugin of ["discord", "telegram", "whatsapp"]) { + writeJson(path.join(runtimeRoot, plugin, ".openclaw-runtime-deps-stamp.json"), { + version: 0, + plugin, + stale: true, + }); + write( + path.join( + runtimeRoot, + plugin, + ".openclaw-runtime-deps-copy-stale", + "node_modules", + "stale-sentinel", + "package.json", + ), + `${JSON.stringify({ name: "stale-sentinel", version: "0.0.0" }, null, 2)}\n`, + ); + } + + writeJson(path.join(stateDir, "survivor-baseline.json"), { + agents: ["main", "ops"], + discordGuild: "222222222222222222", + discordChannel: "333333333333333333", + telegramGroup: "-1001234567890", + whatsappGroup: "120363000000000000@g.us", + workspaceIdentity: path.join(workspace, "IDENTITY.md"), + }); +} + +function assertConfigSurvived() { + const config = getConfig(); + assert(config.update?.channel === "stable", "update.channel was not preserved"); + assert(config.gateway?.auth?.mode === "token", "gateway auth mode was not preserved"); + + const agents = config.agents?.list ?? []; + assert(Array.isArray(agents), "agents.list missing after update/doctor"); + assert( + agents.some((agent) => agent?.id === "main"), + "main agent missing", + ); + assert( + agents.some((agent) => agent?.id === "ops"), + "ops agent missing", + ); + assert( + agents.find((agent) => agent?.id === "main")?.contextTokens === 64000, + "main agent contextTokens changed", + ); + assert( + agents.find((agent) => agent?.id === "ops")?.fastModeDefault === true, + "ops fastModeDefault changed", + ); + + const discord = config.channels?.discord; + assert(discord?.enabled === true, "discord enabled flag changed"); + const discordAllowFrom = discord.allowFrom ?? discord.dm?.allowFrom; + const discordDmPolicy = discord.dmPolicy ?? discord.dm?.policy; + assert(discordDmPolicy === "allowlist", "discord DM policy changed"); + assert( + Array.isArray(discordAllowFrom) && discordAllowFrom.includes("111111111111111111"), + "discord allowFrom changed", + ); + assert( + discord.guilds?.["222222222222222222"]?.channels?.["333333333333333333"]?.requireMention === + true, + "discord guild channel mention policy changed", + ); + assert(discord.threadBindings?.idleHours === 72, "discord thread binding ttl changed"); + + assert(config.channels?.telegram?.enabled === true, "telegram enabled flag changed"); + assert( + config.channels?.telegram?.groups?.["-1001234567890"]?.requireMention === true, + "telegram group policy changed", + ); + assert(config.channels?.whatsapp?.enabled === true, "whatsapp enabled flag changed"); + assert( + config.channels?.whatsapp?.groups?.["120363000000000000@g.us"]?.systemPrompt === + "Use the existing WhatsApp group prompt.", + "whatsapp group policy changed", + ); + + const pluginAllow = config.plugins?.allow ?? []; + assert(pluginAllow.includes("discord"), "discord plugin allow entry missing"); + assert(pluginAllow.includes("telegram"), "telegram plugin allow entry missing"); + assert(pluginAllow.includes("whatsapp"), "whatsapp plugin allow entry missing"); +} + +function assertStateSurvived() { + const stateDir = requireEnv("OPENCLAW_STATE_DIR"); + const workspace = requireEnv("OPENCLAW_TEST_WORKSPACE_DIR"); + assert(fs.existsSync(path.join(workspace, "IDENTITY.md")), "workspace identity file missing"); + assert( + fs.existsSync(path.join(stateDir, "agents", "main", "sessions", "legacy-session.json")), + "legacy session file missing", + ); + assert( + fs.existsSync(path.join(stateDir, "plugin-runtime-deps", "discord")), + "plugin runtime deps root missing", + ); +} + +function assertStatusJson([file]) { + const status = readJson(file); + assert(status && typeof status === "object", "gateway status JSON was not an object"); + const text = JSON.stringify(status); + assert(/running|connected|ok|ready/u.test(text), "gateway status did not report a healthy state"); +} + +if (command === "seed") { + seedState(); +} else if (command === "assert-config") { + assertConfigSurvived(); +} else if (command === "assert-state") { + assertStateSurvived(); +} else if (command === "assert-status-json") { + assertStatusJson(process.argv.slice(3)); +} else { + throw new Error(`unknown upgrade-survivor assertion command: ${command ?? ""}`); +} diff --git a/scripts/e2e/upgrade-survivor-docker.sh b/scripts/e2e/upgrade-survivor-docker.sh new file mode 100755 index 00000000000..2c7e7d6169b --- /dev/null +++ b/scripts/e2e/upgrade-survivor-docker.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# Installs the packed OpenClaw tarball over a dirty old-user state fixture, runs +# the package update/doctor paths, then proves the Gateway still boots. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh" +source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh" + +IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-upgrade-survivor-e2e" OPENCLAW_UPGRADE_SURVIVOR_E2E_IMAGE)" +SKIP_BUILD="${OPENCLAW_UPGRADE_SURVIVOR_E2E_SKIP_BUILD:-0}" +PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz upgrade-survivor "${OPENCLAW_CURRENT_PACKAGE_TGZ:-}")" +DOCKER_RUN_TIMEOUT="${OPENCLAW_UPGRADE_SURVIVOR_DOCKER_RUN_TIMEOUT:-900s}" + +docker_e2e_package_mount_args "$PACKAGE_TGZ" +OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 upgrade-survivor upgrade-survivor)" + +docker_e2e_build_or_reuse "$IMAGE_NAME" upgrade-survivor "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "bare" "$SKIP_BUILD" + +echo "Running upgrade survivor Docker E2E..." +docker_e2e_run_with_harness \ + -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ + -e OPENCLAW_TEST_STATE_SCRIPT_B64="$OPENCLAW_TEST_STATE_SCRIPT_B64" \ + -e OPENCLAW_UPGRADE_SURVIVOR_START_BUDGET_SECONDS="${OPENCLAW_UPGRADE_SURVIVOR_START_BUDGET_SECONDS:-90}" \ + -e OPENCLAW_UPGRADE_SURVIVOR_STATUS_BUDGET_SECONDS="${OPENCLAW_UPGRADE_SURVIVOR_STATUS_BUDGET_SECONDS:-30}" \ + "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ + "$IMAGE_NAME" \ + timeout "$DOCKER_RUN_TIMEOUT" bash -lc 'set -euo pipefail +source scripts/lib/openclaw-e2e-instance.sh + +export npm_config_loglevel=error +export npm_config_fund=false +export npm_config_audit=false +export npm_config_prefix=/tmp/npm-prefix +export NPM_CONFIG_PREFIX=/tmp/npm-prefix +export PATH="/tmp/npm-prefix/bin:$PATH" +export CI=true +export OPENCLAW_NO_ONBOARD=1 +export OPENCLAW_NO_PROMPT=1 +export OPENCLAW_SKIP_PROVIDERS=1 +export OPENCLAW_SKIP_CHANNELS=1 +export OPENCLAW_DISABLE_BONJOUR=1 +export GATEWAY_AUTH_TOKEN_REF="upgrade-survivor-token" +export OPENAI_API_KEY="sk-openclaw-upgrade-survivor" +export DISCORD_BOT_TOKEN="upgrade-survivor-discord-token" +export TELEGRAM_BOT_TOKEN="123456:upgrade-survivor-telegram-token" + +gateway_pid="" +cleanup() { + openclaw_e2e_terminate_gateways "${gateway_pid:-}" +} +trap cleanup EXIT + +openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}" +node scripts/e2e/lib/upgrade-survivor/assertions.mjs seed + +openclaw_e2e_install_package /tmp/openclaw-upgrade-survivor-install.log "upgrade survivor package" /tmp/npm-prefix +command -v openclaw >/dev/null +package_version="$(node -p "JSON.parse(require(\"node:fs\").readFileSync(\"/tmp/npm-prefix/lib/node_modules/openclaw/package.json\", \"utf8\")).version")" +OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT="$( + node scripts/e2e/lib/package-compat.mjs "$package_version" +)" +export OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT + +echo "Checking dirty-state config before update..." +node scripts/e2e/lib/upgrade-survivor/assertions.mjs assert-config +node scripts/e2e/lib/upgrade-survivor/assertions.mjs assert-state + +echo "Running package update against the mounted tarball..." +set +e +openclaw update --tag "${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}" --yes --json --no-restart >/tmp/openclaw-upgrade-survivor-update.json 2>/tmp/openclaw-upgrade-survivor-update.err +update_status=$? +set -e +if [ "$update_status" -ne 0 ]; then + echo "openclaw update failed" >&2 + cat /tmp/openclaw-upgrade-survivor-update.err >&2 || true + cat /tmp/openclaw-upgrade-survivor-update.json >&2 || true + exit "$update_status" +fi + +echo "Running non-interactive doctor repair..." +if ! openclaw doctor --fix --non-interactive >/tmp/openclaw-upgrade-survivor-doctor.log 2>&1; then + echo "openclaw doctor failed" >&2 + cat /tmp/openclaw-upgrade-survivor-doctor.log >&2 || true + exit 1 +fi + +echo "Verifying config and state survived update/doctor..." +node scripts/e2e/lib/upgrade-survivor/assertions.mjs assert-config +node scripts/e2e/lib/upgrade-survivor/assertions.mjs assert-state + +PORT=18789 +START_BUDGET="${OPENCLAW_UPGRADE_SURVIVOR_START_BUDGET_SECONDS:-90}" +STATUS_BUDGET="${OPENCLAW_UPGRADE_SURVIVOR_STATUS_BUDGET_SECONDS:-30}" + +echo "Starting gateway from upgraded state..." +start_epoch="$(node -e "process.stdout.write(String(Date.now()))")" +openclaw gateway --port "$PORT" --bind loopback --allow-unconfigured >/tmp/openclaw-upgrade-survivor-gateway.log 2>&1 & +gateway_pid="$!" +openclaw_e2e_wait_gateway_ready "$gateway_pid" /tmp/openclaw-upgrade-survivor-gateway.log 360 +ready_epoch="$(node -e "process.stdout.write(String(Date.now()))")" +start_seconds=$(((ready_epoch - start_epoch + 999) / 1000)) +if [ "$start_seconds" -gt "$START_BUDGET" ]; then + echo "gateway startup exceeded survivor budget: ${start_seconds}s > ${START_BUDGET}s" >&2 + cat /tmp/openclaw-upgrade-survivor-gateway.log >&2 || true + exit 1 +fi + +echo "Checking gateway RPC status..." +status_start="$(node -e "process.stdout.write(String(Date.now()))")" +if ! openclaw gateway status --url "ws://127.0.0.1:$PORT" --token "$GATEWAY_AUTH_TOKEN_REF" --require-rpc --timeout 30000 --json >/tmp/openclaw-upgrade-survivor-status.json 2>/tmp/openclaw-upgrade-survivor-status.err; then + echo "gateway status failed" >&2 + cat /tmp/openclaw-upgrade-survivor-status.err >&2 || true + cat /tmp/openclaw-upgrade-survivor-gateway.log >&2 || true + exit 1 +fi +status_end="$(node -e "process.stdout.write(String(Date.now()))")" +status_seconds=$(((status_end - status_start + 999) / 1000)) +if [ "$status_seconds" -gt "$STATUS_BUDGET" ]; then + echo "gateway status exceeded survivor budget: ${status_seconds}s > ${STATUS_BUDGET}s" >&2 + cat /tmp/openclaw-upgrade-survivor-status.json >&2 || true + exit 1 +fi +node scripts/e2e/lib/upgrade-survivor/assertions.mjs assert-status-json /tmp/openclaw-upgrade-survivor-status.json + +echo "Upgrade survivor Docker E2E passed in startup=${start_seconds}s status=${status_seconds}s." +' diff --git a/scripts/lib/docker-e2e-scenarios.mjs b/scripts/lib/docker-e2e-scenarios.mjs index 59e39918abc..c976b121e74 100644 --- a/scripts/lib/docker-e2e-scenarios.mjs +++ b/scripts/lib/docker-e2e-scenarios.mjs @@ -278,6 +278,11 @@ export const mainLanes = [ weight: 3, }, ), + npmLane("upgrade-survivor", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:upgrade-survivor", { + stateScenario: "upgrade-survivor", + timeoutMs: 20 * 60 * 1000, + weight: 3, + }), lane("plugins", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugins", { resources: ["npm", "service"], stateScenario: "empty", @@ -530,6 +535,11 @@ const releasePathPackageUpdateCoreLanes = [ weight: 3, }, ), + npmLane("upgrade-survivor", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:upgrade-survivor", { + stateScenario: "upgrade-survivor", + timeoutMs: 20 * 60 * 1000, + weight: 3, + }), ]; const primaryReleasePathChunks = { diff --git a/scripts/lib/openclaw-test-state.mjs b/scripts/lib/openclaw-test-state.mjs index e49343606e2..be6fe849e99 100644 --- a/scripts/lib/openclaw-test-state.mjs +++ b/scripts/lib/openclaw-test-state.mjs @@ -11,6 +11,7 @@ const SCENARIOS = new Set([ "empty", "minimal", "update-stable", + "upgrade-survivor", "gateway-loopback", "external-service", ]); @@ -86,6 +87,135 @@ function scenarioConfig(scenario, options = {}) { plugins: {}, }; } + if (scenario === "upgrade-survivor") { + return { + update: { + channel: "stable", + }, + gateway: { + mode: "local", + port: Number(options.port || 18789), + bind: "loopback", + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "GATEWAY_AUTH_TOKEN_REF" }, + }, + controlUi: { + enabled: false, + }, + }, + models: { + providers: { + openai: { + api: "openai-responses", + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + baseUrl: "https://api.openai.com/v1", + models: [], + }, + }, + }, + agents: { + defaults: { + model: { + primary: "openai/gpt-4.1-mini", + }, + contextTokens: 64000, + skills: ["memory"], + }, + list: [ + { + id: "main", + default: true, + name: "Main", + workspace: "~/workspace", + model: { + primary: "openai/gpt-4.1-mini", + }, + thinkingDefault: "low", + skills: ["memory"], + contextTokens: 64000, + }, + { + id: "ops", + name: "Ops", + workspace: "~/workspace/ops", + model: { + primary: "openai/gpt-4.1-mini", + }, + fastModeDefault: true, + }, + ], + }, + skills: { + allowBundled: ["memory", "openclaw-testing"], + limits: { + maxSkillsInPrompt: 8, + maxSkillsPromptChars: 30000, + }, + }, + plugins: { + enabled: true, + allow: ["discord", "telegram", "whatsapp", "memory"], + entries: { + discord: { enabled: true }, + telegram: { enabled: true }, + whatsapp: { enabled: true }, + }, + }, + channels: { + discord: { + enabled: true, + token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" }, + dm: { + policy: "allowlist", + allowFrom: ["111111111111111111"], + }, + groupPolicy: "allowlist", + guilds: { + "222222222222222222": { + slug: "survivor-guild", + channels: { + "333333333333333333": { + enabled: true, + requireMention: true, + tools: { + allow: ["message_send"], + deny: ["exec"], + }, + }, + }, + }, + }, + threadBindings: { + enabled: true, + idleHours: 72, + }, + }, + telegram: { + enabled: true, + botToken: { source: "env", provider: "default", id: "TELEGRAM_BOT_TOKEN" }, + dmPolicy: "allowlist", + allowFrom: ["123456789"], + groups: { + "-1001234567890": { + enabled: true, + requireMention: true, + }, + }, + }, + whatsapp: { + enabled: true, + dmPolicy: "allowlist", + allowFrom: ["+15555550123"], + groups: { + "120363000000000000@g.us": { + systemPrompt: "Use the existing WhatsApp group prompt.", + }, + }, + }, + }, + }; + } if (scenario === "gateway-loopback") { return { gateway: { @@ -216,7 +346,7 @@ export function renderShellFunction() { local label="$raw_label" local scenario="\${2:-empty}" case "$scenario" in - empty|minimal|update-stable|gateway-loopback|external-service) ;; + empty|minimal|update-stable|upgrade-survivor|gateway-loopback|external-service) ;; *) echo "unknown OpenClaw test-state scenario: $scenario" >&2 return 1 @@ -257,6 +387,181 @@ OPENCLAW_TEST_STATE_JSON }, "plugins": {} } +OPENCLAW_TEST_STATE_JSON + ;; + upgrade-survivor) + cat > "$OPENCLAW_CONFIG_PATH" <<'OPENCLAW_TEST_STATE_JSON' +{ + "update": { + "channel": "stable" + }, + "gateway": { + "mode": "local", + "port": 18789, + "bind": "loopback", + "auth": { + "mode": "token", + "token": { + "source": "env", + "provider": "default", + "id": "GATEWAY_AUTH_TOKEN_REF" + } + }, + "controlUi": { + "enabled": false + } + }, + "models": { + "providers": { + "openai": { + "api": "openai-responses", + "apiKey": { + "source": "env", + "provider": "default", + "id": "OPENAI_API_KEY" + }, + "baseUrl": "https://api.openai.com/v1", + "models": [] + } + } + }, + "agents": { + "defaults": { + "model": { + "primary": "openai/gpt-4.1-mini" + }, + "contextTokens": 64000, + "skills": [ + "memory" + ] + }, + "list": [ + { + "id": "main", + "default": true, + "name": "Main", + "workspace": "~/workspace", + "model": { + "primary": "openai/gpt-4.1-mini" + }, + "thinkingDefault": "low", + "skills": [ + "memory" + ], + "contextTokens": 64000 + }, + { + "id": "ops", + "name": "Ops", + "workspace": "~/workspace/ops", + "model": { + "primary": "openai/gpt-4.1-mini" + }, + "fastModeDefault": true + } + ] + }, + "skills": { + "allowBundled": [ + "memory", + "openclaw-testing" + ], + "limits": { + "maxSkillsInPrompt": 8, + "maxSkillsPromptChars": 30000 + } + }, + "plugins": { + "enabled": true, + "allow": [ + "discord", + "telegram", + "whatsapp", + "memory" + ], + "entries": { + "discord": { + "enabled": true + }, + "telegram": { + "enabled": true + }, + "whatsapp": { + "enabled": true + } + } + }, + "channels": { + "discord": { + "enabled": true, + "token": { + "source": "env", + "provider": "default", + "id": "DISCORD_BOT_TOKEN" + }, + "dm": { + "policy": "allowlist", + "allowFrom": [ + "111111111111111111" + ] + }, + "groupPolicy": "allowlist", + "guilds": { + "222222222222222222": { + "slug": "survivor-guild", + "channels": { + "333333333333333333": { + "enabled": true, + "requireMention": true, + "tools": { + "allow": [ + "message_send" + ], + "deny": [ + "exec" + ] + } + } + } + } + }, + "threadBindings": { + "enabled": true, + "idleHours": 72 + } + }, + "telegram": { + "enabled": true, + "botToken": { + "source": "env", + "provider": "default", + "id": "TELEGRAM_BOT_TOKEN" + }, + "dmPolicy": "allowlist", + "allowFrom": [ + "123456789" + ], + "groups": { + "-1001234567890": { + "enabled": true, + "requireMention": true + } + } + }, + "whatsapp": { + "enabled": true, + "dmPolicy": "allowlist", + "allowFrom": [ + "+15555550123" + ], + "groups": { + "120363000000000000@g.us": { + "systemPrompt": "Use the existing WhatsApp group prompt." + } + } + } + } +} OPENCLAW_TEST_STATE_JSON ;; gateway-loopback) diff --git a/src/test-utils/openclaw-test-state.test.ts b/src/test-utils/openclaw-test-state.test.ts index b10575dbd02..ba783f6332c 100644 --- a/src/test-utils/openclaw-test-state.test.ts +++ b/src/test-utils/openclaw-test-state.test.ts @@ -161,6 +161,26 @@ describe("openclaw test state", () => { ); }); + it("creates upgrade survivor fixture state", async () => { + await withOpenClawTestState( + { + scenario: "upgrade-survivor", + }, + async (state) => { + const config = JSON.parse(await fs.readFile(state.configPath, "utf8")); + expect(config).toMatchObject({ + update: { + channel: "stable", + }, + plugins: { + enabled: true, + allow: ["discord", "telegram", "whatsapp", "memory"], + }, + }); + }, + ); + }); + it("keeps external-service env scoped to the fixture", async () => { const previousPolicy = process.env.OPENCLAW_SERVICE_REPAIR_POLICY; diff --git a/src/test-utils/openclaw-test-state.ts b/src/test-utils/openclaw-test-state.ts index a8a8c591296..5f26e31fcfa 100644 --- a/src/test-utils/openclaw-test-state.ts +++ b/src/test-utils/openclaw-test-state.ts @@ -10,6 +10,7 @@ export type OpenClawTestStateScenario = | "empty" | "minimal" | "update-stable" + | "upgrade-survivor" | "gateway-loopback" | "external-service"; @@ -133,6 +134,33 @@ function scenarioConfig(options: OpenClawTestStateOptions): Record { "npm-onboard-channel-agent", "doctor-switch", "update-channel-switch", + "upgrade-survivor", ]); expect(packageUpdateCore.lanes).toEqual( expect.arrayContaining([ @@ -189,6 +190,10 @@ describe("scripts/lib/docker-e2e-plan", () => { name: "update-channel-switch", stateScenario: "update-stable", }), + expect.objectContaining({ + name: "upgrade-survivor", + stateScenario: "upgrade-survivor", + }), ]), ); expect(pluginsRuntimePlugins.lanes.map((lane) => lane.name)).toEqual(["plugins"]); @@ -394,6 +399,7 @@ describe("scripts/lib/docker-e2e-plan", () => { "bundled-channel-setup-entry", "bundled-plugin-install-uninstall-0", "update-channel-switch", + "upgrade-survivor", ], }); @@ -474,6 +480,10 @@ describe("scripts/lib/docker-e2e-plan", () => { name: "update-channel-switch", stateScenario: "update-stable", }), + expect.objectContaining({ + name: "upgrade-survivor", + stateScenario: "upgrade-survivor", + }), ]); }); diff --git a/test/scripts/openclaw-test-state.test.ts b/test/scripts/openclaw-test-state.test.ts index 7ce42958d66..35773295db8 100644 --- a/test/scripts/openclaw-test-state.test.ts +++ b/test/scripts/openclaw-test-state.test.ts @@ -109,6 +109,52 @@ describe("scripts/lib/openclaw-test-state", () => { } }); + it("creates the upgrade survivor scenario", async () => { + const { stdout } = await execFileAsync(process.execPath, [ + scriptPath, + "--", + "create", + "--label", + "upgrade-survivor", + "--scenario", + "upgrade-survivor", + "--json", + ]); + const payload = JSON.parse(stdout); + try { + expect(payload.scenario).toBe("upgrade-survivor"); + expect(payload.config).toMatchObject({ + update: { + channel: "stable", + }, + gateway: { + auth: { + token: { + id: "GATEWAY_AUTH_TOKEN_REF", + source: "env", + }, + }, + }, + channels: { + discord: { + enabled: true, + dm: { + policy: "allowlist", + }, + }, + telegram: { + enabled: true, + }, + whatsapp: { + enabled: true, + }, + }, + }); + } finally { + await fs.rm(payload.root, { recursive: true, force: true }); + } + }); + it("renders a reusable Docker shell function", async () => { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-state-function-")); const snippetFile = path.join(tempRoot, "state-function.sh"); diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 68fd48db093..ef9744b98d1 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -41,6 +41,7 @@ describe("package acceptance workflow", () => { expect(workflow).toContain("suite_profile:"); expect(workflow).toContain("npm-onboard-channel-agent gateway-network config-reload"); expect(workflow).toContain("npm-onboard-channel-agent doctor-switch"); + expect(workflow).toContain("update-channel-switch upgrade-survivor"); expect(workflow).toContain("bundled-channel-deps-compat"); expect(workflow).toContain("plugins-offline plugin-update"); expect(workflow).toContain("include_release_path_suites=true");