diff --git a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml index 774cc31d8fe..9c08237f1e3 100644 --- a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml +++ b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml @@ -33,6 +33,11 @@ on: required: false default: 1 type: number + published_upgrade_survivor_baseline: + description: Published OpenClaw package baseline for the published-upgrade-survivor Docker lane + required: false + default: openclaw@latest + type: string package_artifact_name: description: Existing workflow artifact containing openclaw-current.tgz; blank packs the selected ref required: false @@ -113,6 +118,11 @@ on: required: false default: 1 type: number + published_upgrade_survivor_baseline: + description: Published OpenClaw package baseline for the published-upgrade-survivor Docker lane + required: false + default: openclaw@latest + type: string package_artifact_name: description: Existing workflow artifact containing openclaw-current.tgz; blank packs the selected ref required: false @@ -684,6 +694,7 @@ jobs: OPENCLAW_DOCKER_E2E_REPO_ROOT: ${{ github.workspace }} OPENCLAW_DOCKER_E2E_SELECTED_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }} OPENCLAW_CURRENT_PACKAGE_TGZ: .artifacts/docker-e2e-package/openclaw-current.tgz + OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC: ${{ inputs.published_upgrade_survivor_baseline }} OPENCLAW_SKIP_DOCKER_BUILD: "1" INCLUDE_OPENWEBUI: ${{ inputs.include_openwebui }} DOCKER_E2E_CHUNK: ${{ matrix.chunk_id }} @@ -917,6 +928,7 @@ jobs: OPENCLAW_DOCKER_E2E_REPO_ROOT: ${{ github.workspace }} OPENCLAW_DOCKER_E2E_SELECTED_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }} OPENCLAW_CURRENT_PACKAGE_TGZ: .artifacts/docker-e2e-package/openclaw-current.tgz + OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC: ${{ inputs.published_upgrade_survivor_baseline }} OPENCLAW_SKIP_DOCKER_BUILD: "1" INCLUDE_OPENWEBUI: ${{ inputs.include_openwebui }} DOCKER_E2E_LANES: ${{ matrix.group.docker_lanes }} diff --git a/.github/workflows/package-acceptance.yml b/.github/workflows/package-acceptance.yml index ad398c817f3..41ed7ac4b8d 100644 --- a/.github/workflows/package-acceptance.yml +++ b/.github/workflows/package-acceptance.yml @@ -64,6 +64,11 @@ on: required: false default: "" type: string + published_upgrade_survivor_baseline: + description: Published OpenClaw package baseline for the published-upgrade-survivor Docker lane + required: false + default: openclaw@latest + type: string telegram_mode: description: Optional Telegram QA lane for the resolved package candidate required: true @@ -129,6 +134,11 @@ on: required: false default: "" type: string + published_upgrade_survivor_baseline: + description: Published OpenClaw package baseline for the published-upgrade-survivor Docker lane + required: false + default: openclaw@latest + type: string telegram_mode: description: Optional Telegram QA lane for the resolved package candidate required: false @@ -413,6 +423,7 @@ jobs: SOURCE: ${{ inputs.source }} SUITE_PROFILE: ${{ inputs.suite_profile }} WORKFLOW_REF: ${{ inputs.workflow_ref }} + PUBLISHED_UPGRADE_SURVIVOR_BASELINE: ${{ inputs.published_upgrade_survivor_baseline }} shell: bash run: | { @@ -426,6 +437,7 @@ jobs: echo "- Version: \`${PACKAGE_VERSION}\`" echo "- SHA-256: \`${PACKAGE_SHA256}\`" echo "- Profile: \`${SUITE_PROFILE}\`" + echo "- Published upgrade survivor baseline: \`${PUBLISHED_UPGRADE_SURVIVOR_BASELINE}\`" } >> "$GITHUB_STEP_SUMMARY" docker_acceptance: @@ -438,6 +450,7 @@ jobs: include_release_path_suites: ${{ needs.resolve_package.outputs.include_release_path_suites == 'true' }} include_openwebui: ${{ needs.resolve_package.outputs.include_openwebui == 'true' }} docker_lanes: ${{ needs.resolve_package.outputs.docker_lanes }} + published_upgrade_survivor_baseline: ${{ inputs.published_upgrade_survivor_baseline }} package_artifact_name: ${{ needs.resolve_package.outputs.package_artifact_name }} include_live_suites: ${{ needs.resolve_package.outputs.include_live_suites == 'true' }} live_models_only: false diff --git a/docs/ci.md b/docs/ci.md index 0fec3e992b5..9981b1a75a2 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -188,7 +188,7 @@ Keep `workflow_ref` and `package_ref` separate. `workflow_ref` is the trusted wo The `package` profile uses offline plugin coverage so published-package validation is not gated on live ClawHub availability. The optional Telegram lane reuses the `package-under-test` artifact in `NPM Telegram Beta E2E`, with the published npm spec path kept for standalone dispatches. -Release checks call Package Acceptance with `source=ref`, `package_ref=`, `workflow_ref=`, `suite_profile=custom`, `docker_lanes='bundled-channel-deps-compat plugins-offline'`, and `telegram_mode=mock-openai`. Release-path Docker chunks cover the overlapping package/update/plugin lanes; Package Acceptance keeps the artifact-native bundled-channel compat, offline plugin, and Telegram proof against the same resolved package tarball. Cross-OS release checks still cover OS-specific onboarding, installer, and platform behavior; package/update product validation should start with Package Acceptance. The Windows packaged and installer fresh lanes also verify that an installed package can import a browser-control override from a raw absolute Windows path. The OpenAI cross-OS agent-turn smoke defaults to `OPENCLAW_CROSS_OS_OPENAI_MODEL` when set, otherwise `openai/gpt-5.4-mini`, so the install and gateway proof stays fast and deterministic. +Release checks call Package Acceptance with `source=ref`, `package_ref=`, `workflow_ref=`, `suite_profile=custom`, `docker_lanes='bundled-channel-deps-compat plugins-offline'`, and `telegram_mode=mock-openai`. Release-path Docker chunks cover the overlapping package/update/plugin lanes; Package Acceptance keeps the artifact-native bundled-channel compat, offline plugin, and Telegram proof against the same resolved package tarball. Cross-OS release checks still cover OS-specific onboarding, installer, and platform behavior; package/update product validation should start with Package Acceptance. The `published-upgrade-survivor` Docker lane validates one published package baseline per run. In Package Acceptance, the resolved `package-under-test` tarball is always the candidate and `published_upgrade_survivor_baseline` selects the published baseline, defaulting to `openclaw@latest`; failed-lane rerun commands preserve that baseline. Local runs can set `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC` to an exact package such as `openclaw@2026.4.15`. The published lane configures the baseline with a baked `openclaw config set` command recipe, then records recipe steps in `summary.json`. Broader previous-version coverage should shard Package Acceptance across exact `published_upgrade_survivor_baseline` values. The Windows packaged and installer fresh lanes also verify that an installed package can import a browser-control override from a raw absolute Windows path. The OpenAI cross-OS agent-turn smoke defaults to `OPENCLAW_CROSS_OS_OPENAI_MODEL` when set, otherwise `openai/gpt-5.4-mini`, so the install and gateway proof stays fast and deterministic. ### Legacy compatibility windows diff --git a/docs/help/testing.md b/docs/help/testing.md index e88d81b06ce..037b24c1594 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -600,7 +600,7 @@ 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 plus the keyless upgrade-survivor fixture, the published-baseline upgrade survivor lane, 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, the published-baseline upgrade survivor lane, 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. For `published-upgrade-survivor`, Package Acceptance always uses `package-under-test` as the candidate and `published_upgrade_survivor_baseline` as the published baseline, defaulting to `openclaw@latest`; shard broader coverage by dispatching multiple runs with exact baseline values. The published lane configures its baseline with a baked `openclaw config set` command recipe, then records recipe steps in the lane summary. 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, prepared image inputs, and the published upgrade-survivor baseline 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:upgrade-survivor`, `test:docker:published-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. @@ -618,7 +618,7 @@ The live-model Docker runners also bind-mount only the needed CLI auth homes (or - 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. -- Published upgrade survivor smoke: `pnpm test:docker:published-upgrade-survivor` installs `openclaw@latest` by default over the same dirty old-user fixture, updates that published install to the candidate tarball, runs non-interactive doctor, then starts a loopback Gateway and checks the same config/state preservation plus startup/status budgets. Override the baseline with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC`. +- Published upgrade survivor smoke: `pnpm test:docker:published-upgrade-survivor` installs `openclaw@latest` by default, seeds realistic existing-user files, configures that baseline with a baked command recipe, validates the resulting config, updates that published install to the candidate tarball, runs non-interactive doctor, writes `.artifacts/upgrade-survivor/summary.json`, then starts a loopback Gateway and checks configured intents, state preservation, startup, and status budgets. Override the baseline with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC`; Package Acceptance exposes the same value as `published_upgrade_survivor_baseline`. - 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/RELEASING.md b/docs/reference/RELEASING.md index f7f89d03dbe..9d60f73e9f4 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -117,8 +117,11 @@ the maintainer-only release runbook. Actions run. The workflow resolves the candidate to `package-under-test`, reuses the Docker E2E release scheduler against that tarball, and can run Telegram QA against the same tarball with - `telegram_mode=mock-openai` or `telegram_mode=live-frontier`. - Example: `gh workflow run package-acceptance.yml --ref main -f workflow_ref=main -f source=npm -f package_spec=openclaw@beta -f suite_profile=product -f telegram_mode=mock-openai` + `telegram_mode=mock-openai` or `telegram_mode=live-frontier`. When the + selected Docker lanes include `published-upgrade-survivor`, the package + artifact is the candidate and `published_upgrade_survivor_baseline` selects + the published baseline. + Example: `gh workflow run package-acceptance.yml --ref main -f workflow_ref=main -f source=npm -f package_spec=openclaw@beta -f suite_profile=product -f published_upgrade_survivor_baseline=openclaw@2026.4.26 -f telegram_mode=mock-openai` Common profiles: - `smoke`: install/channel/agent, gateway network, and config reload lanes - `package`: artifact-native package/update/plugin lanes without OpenWebUI or live ClawHub @@ -457,7 +460,8 @@ gh workflow run package-acceptance.yml \ -f workflow_ref=main \ -f source=npm \ -f package_spec=openclaw@beta \ - -f suite_profile=product + -f suite_profile=product \ + -f published_upgrade_survivor_baseline=openclaw@2026.4.26 ``` Common package profiles: diff --git a/docs/reference/test.md b/docs/reference/test.md index f9ddbc8d893..6e406448266 100644 --- a/docs/reference/test.md +++ b/docs/reference/test.md @@ -43,7 +43,7 @@ title: "Tests" - `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. -- `pnpm test:docker:published-upgrade-survivor`: Installs `openclaw@latest` by default over the dirty old-user fixture, updates that published install to the packed OpenClaw tarball, then runs the same doctor, Gateway startup, and RPC survival assertions. Override the baseline with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC`. +- `pnpm test:docker:published-upgrade-survivor`: Installs `openclaw@latest` by default, seeds realistic existing-user files without live provider or channel keys, configures that baseline with a baked `openclaw config set` command recipe, updates that published install to the packed OpenClaw tarball, runs non-interactive doctor, writes `.artifacts/upgrade-survivor/summary.json`, then starts a loopback Gateway and checks that configured intents, workspace/session files, stale plugin config/runtime-deps state, startup, and RPC status survive or repair cleanly. Override the baseline with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC`; Package Acceptance exposes the same value as `published_upgrade_survivor_baseline`. ## Local PR gate diff --git a/package.json b/package.json index 0898e8e327d..871f3688fe0 100644 --- a/package.json +++ b/package.json @@ -1514,7 +1514,7 @@ "test:docker:pi-bundle-mcp-tools": "bash scripts/e2e/pi-bundle-mcp-tools-docker.sh", "test:docker:plugin-update": "bash scripts/e2e/plugin-update-unchanged-docker.sh", "test:docker:plugins": "bash scripts/e2e/plugins-docker.sh", - "test:docker:published-upgrade-survivor": "env OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC=${OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC:-openclaw@latest} bash scripts/e2e/upgrade-survivor-docker.sh", + "test:docker:published-upgrade-survivor": "env OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE=1 OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC=${OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC:-openclaw@latest} bash scripts/e2e/upgrade-survivor-docker.sh", "test:docker:qr": "bash scripts/e2e/qr-import-docker.sh", "test:docker:rerun": "node scripts/docker-e2e-rerun.mjs", "test:docker:session-runtime-context": "bash scripts/e2e/session-runtime-context-docker.sh", diff --git a/scripts/e2e/lib/upgrade-survivor/assertions.mjs b/scripts/e2e/lib/upgrade-survivor/assertions.mjs index 225bbdade4c..9d2829ee74e 100644 --- a/scripts/e2e/lib/upgrade-survivor/assertions.mjs +++ b/scripts/e2e/lib/upgrade-survivor/assertions.mjs @@ -34,6 +34,25 @@ function getConfig() { return readJson(requireEnv("OPENCLAW_CONFIG_PATH")); } +function getCoverage() { + const file = process.env.OPENCLAW_UPGRADE_SURVIVOR_CONFIG_COVERAGE_JSON; + if (!file || !fs.existsSync(file)) { + return null; + } + return readJson(file); +} + +function acceptsIntent(coverage, id) { + if (!coverage) { + return true; + } + return Array.isArray(coverage.acceptedIntents) && coverage.acceptedIntents.includes(id); +} + +function hasCoverage(coverage) { + return !!coverage; +} + function seedState() { const stateDir = requireEnv("OPENCLAW_STATE_DIR"); const workspace = requireEnv("OPENCLAW_TEST_WORKSPACE_DIR"); @@ -84,60 +103,95 @@ function seedState() { 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 coverage = getCoverage(); - 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", - ); + if (acceptsIntent(coverage, "update")) { + assert(config.update?.channel === "stable", "update.channel was not preserved"); + } + if (acceptsIntent(coverage, "gateway")) { + assert(config.gateway?.auth?.mode === "token", "gateway auth mode was not preserved"); + } - 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"); + if (acceptsIntent(coverage, "models")) { + assert(config.models?.providers?.openai, "OpenAI model provider missing"); + } - 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", - ); + if (acceptsIntent(coverage, "agents")) { + 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", + ); + if (hasCoverage(coverage)) { + assert(config.agents?.defaults?.contextTokens === 64000, "default contextTokens changed"); + } else { + 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 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"); + if (acceptsIntent(coverage, "skills")) { + assert(config.skills?.allowBundled?.includes("memory"), "memory skill allowlist changed"); + } + + if (acceptsIntent(coverage, "plugins")) { + 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"); + } + + if (acceptsIntent(coverage, "discord-channel")) { + 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"); + } + + if (acceptsIntent(coverage, "telegram-channel")) { + const telegram = config.channels?.telegram; + assert(telegram?.enabled === true, "telegram enabled flag changed"); + assert( + telegram.groups?.["-1001234567890"]?.requireMention === true, + "telegram group policy changed", + ); + } + + if (acceptsIntent(coverage, "whatsapp-channel")) { + const whatsapp = config.channels?.whatsapp; + assert(whatsapp?.enabled === true, "whatsapp enabled flag changed"); + const whatsappGroup = whatsapp.groups?.["120363000000000000@g.us"]; + if (hasCoverage(coverage)) { + assert(whatsappGroup?.requireMention === true, "whatsapp group policy changed"); + } else { + assert( + whatsappGroup?.systemPrompt === "Use the existing WhatsApp group prompt.", + "whatsapp group policy changed", + ); + } + } } function assertStateSurvived() { diff --git a/scripts/e2e/lib/upgrade-survivor/config-recipe.mjs b/scripts/e2e/lib/upgrade-survivor/config-recipe.mjs new file mode 100644 index 00000000000..b71ccdb99b6 --- /dev/null +++ b/scripts/e2e/lib/upgrade-survivor/config-recipe.mjs @@ -0,0 +1,139 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +const args = process.argv.slice(2); +const command = args.shift(); + +function option(name, fallback) { + const index = args.indexOf(name); + if (index === -1) { + return fallback; + } + const value = args[index + 1]; + if (!value) { + throw new Error(`missing value for ${name}`); + } + return value; +} + +function tail(value, max = 2400) { + const text = String(value || ""); + return text.length <= max ? text : text.slice(-max); +} + +function writeJson(file, value) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); +} + +const configSectionDir = new URL("./config-recipe/", import.meta.url); + +function readConfigSection(fileName) { + const fileUrl = new URL(fileName, configSectionDir); + return JSON.stringify(JSON.parse(fs.readFileSync(fileUrl, "utf8"))); +} + +function configSetJsonFile(id, intent, configPath, fileName) { + return { + id, + intent, + argv: ["config", "set", configPath, readConfigSection(fileName), "--strict-json"], + }; +} + +const representativeConfigSteps = [ + configSetJsonFile("models-openai", "models", "models.providers.openai", "models-openai.json"), + configSetJsonFile("agents", "agents", "agents", "agents.json"), + configSetJsonFile("skills", "skills", "skills", "skills.json"), + configSetJsonFile("plugins", "plugins", "plugins", "plugins.json"), + configSetJsonFile( + "channels-discord", + "discord-channel", + "channels.discord", + "channels-discord.json", + ), + configSetJsonFile( + "channels-telegram", + "telegram-channel", + "channels.telegram", + "channels-telegram.json", + ), + configSetJsonFile( + "channels-whatsapp", + "whatsapp-channel", + "channels.whatsapp", + "channels-whatsapp.json", + ), +]; + +const recipe = [ + { + id: "update-channel", + intent: "update", + argv: ["config", "set", "update.channel", "stable"], + }, + configSetJsonFile("gateway", "gateway", "gateway", "gateway.json"), + ...representativeConfigSteps, + { + id: "validate", + intent: "validate", + argv: ["config", "validate"], + }, +]; + +function runOpenClaw(step) { + const result = spawnSync("openclaw", step.argv, { + encoding: "utf8", + env: process.env, + }); + return { + id: step.id, + intent: step.intent, + command: ["openclaw", ...step.argv].join(" "), + status: result.status, + signal: result.signal, + ok: result.status === 0, + stdout: tail(result.stdout), + stderr: tail(result.stderr), + }; +} + +function applyRecipe() { + const summaryPath = option("--summary"); + const baselineVersion = option("--baseline-version", null); + const summary = { + source: "baseline-cli-command-recipe", + recipe: "upgrade-survivor-v1", + baselineVersion, + acceptedIntents: [ + "update", + "gateway", + "models", + "agents", + "skills", + "plugins", + "discord-channel", + "telegram-channel", + "whatsapp-channel", + ], + skippedIntents: [], + steps: [], + }; + + for (const step of recipe) { + const outcome = runOpenClaw(step); + summary.steps.push(outcome); + writeJson(summaryPath, summary); + if (!outcome.ok) { + throw new Error(`baseline config recipe failed at ${step.id}`); + } + } +} + +if (command === "apply") { + applyRecipe(); +} else { + throw new Error(`unknown upgrade-survivor config-recipe command: ${command ?? ""}`); +} diff --git a/scripts/e2e/lib/upgrade-survivor/config-recipe/agents.json b/scripts/e2e/lib/upgrade-survivor/config-recipe/agents.json new file mode 100644 index 00000000000..9cf0f87e2d1 --- /dev/null +++ b/scripts/e2e/lib/upgrade-survivor/config-recipe/agents.json @@ -0,0 +1,31 @@ +{ + "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"] + }, + { + "id": "ops", + "name": "Ops", + "workspace": "~/workspace/ops", + "model": { + "primary": "openai/gpt-4.1-mini" + }, + "fastModeDefault": true + } + ] +} diff --git a/scripts/e2e/lib/upgrade-survivor/config-recipe/channels-discord.json b/scripts/e2e/lib/upgrade-survivor/config-recipe/channels-discord.json new file mode 100644 index 00000000000..3ea0b92325e --- /dev/null +++ b/scripts/e2e/lib/upgrade-survivor/config-recipe/channels-discord.json @@ -0,0 +1,32 @@ +{ + "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 + } +} diff --git a/scripts/e2e/lib/upgrade-survivor/config-recipe/channels-telegram.json b/scripts/e2e/lib/upgrade-survivor/config-recipe/channels-telegram.json new file mode 100644 index 00000000000..03241477708 --- /dev/null +++ b/scripts/e2e/lib/upgrade-survivor/config-recipe/channels-telegram.json @@ -0,0 +1,22 @@ +{ + "enabled": true, + "botToken": { + "source": "env", + "provider": "default", + "id": "TELEGRAM_BOT_TOKEN" + }, + "dmPolicy": "allowlist", + "allowFrom": ["123456789"], + "defaultTo": "123456789", + "groupPolicy": "allowlist", + "groupAllowFrom": ["123456789"], + "groups": { + "-1001234567890": { + "requireMention": true, + "tools": { + "allow": ["message_send"], + "deny": ["exec"] + } + } + } +} diff --git a/scripts/e2e/lib/upgrade-survivor/config-recipe/channels-whatsapp.json b/scripts/e2e/lib/upgrade-survivor/config-recipe/channels-whatsapp.json new file mode 100644 index 00000000000..7904dd4fbdc --- /dev/null +++ b/scripts/e2e/lib/upgrade-survivor/config-recipe/channels-whatsapp.json @@ -0,0 +1,23 @@ +{ + "enabled": true, + "dmPolicy": "allowlist", + "allowFrom": ["+15555550123"], + "defaultTo": "+15555550123", + "groupPolicy": "allowlist", + "groupAllowFrom": ["+15555550123"], + "groups": { + "120363000000000000@g.us": { + "requireMention": true, + "tools": { + "allow": ["message_send"], + "deny": ["exec"] + } + } + }, + "accounts": { + "default": { + "enabled": true, + "name": "Default WhatsApp" + } + } +} diff --git a/scripts/e2e/lib/upgrade-survivor/config-recipe/gateway.json b/scripts/e2e/lib/upgrade-survivor/config-recipe/gateway.json new file mode 100644 index 00000000000..ec4faf5f819 --- /dev/null +++ b/scripts/e2e/lib/upgrade-survivor/config-recipe/gateway.json @@ -0,0 +1,13 @@ +{ + "mode": "local", + "port": 18789, + "bind": "loopback", + "auth": { + "mode": "token", + "token": { + "source": "env", + "provider": "default", + "id": "GATEWAY_AUTH_TOKEN_REF" + } + } +} diff --git a/scripts/e2e/lib/upgrade-survivor/config-recipe/models-openai.json b/scripts/e2e/lib/upgrade-survivor/config-recipe/models-openai.json new file mode 100644 index 00000000000..6bc5cfd420a --- /dev/null +++ b/scripts/e2e/lib/upgrade-survivor/config-recipe/models-openai.json @@ -0,0 +1,10 @@ +{ + "api": "openai-responses", + "apiKey": { + "source": "env", + "provider": "default", + "id": "OPENAI_API_KEY" + }, + "baseUrl": "https://api.openai.com/v1", + "models": [] +} diff --git a/scripts/e2e/lib/upgrade-survivor/config-recipe/plugins.json b/scripts/e2e/lib/upgrade-survivor/config-recipe/plugins.json new file mode 100644 index 00000000000..393db2b872a --- /dev/null +++ b/scripts/e2e/lib/upgrade-survivor/config-recipe/plugins.json @@ -0,0 +1,15 @@ +{ + "enabled": true, + "allow": ["discord", "memory", "telegram", "whatsapp"], + "entries": { + "discord": { + "enabled": true + }, + "telegram": { + "enabled": true + }, + "whatsapp": { + "enabled": true + } + } +} diff --git a/scripts/e2e/lib/upgrade-survivor/config-recipe/skills.json b/scripts/e2e/lib/upgrade-survivor/config-recipe/skills.json new file mode 100644 index 00000000000..7ba05e32f1e --- /dev/null +++ b/scripts/e2e/lib/upgrade-survivor/config-recipe/skills.json @@ -0,0 +1,7 @@ +{ + "allowBundled": ["memory", "openclaw-testing"], + "limits": { + "maxSkillsInPrompt": 8, + "maxSkillsPromptChars": 30000 + } +} diff --git a/scripts/e2e/lib/upgrade-survivor/run.sh b/scripts/e2e/lib/upgrade-survivor/run.sh new file mode 100644 index 00000000000..cad12ae7995 --- /dev/null +++ b/scripts/e2e/lib/upgrade-survivor/run.sh @@ -0,0 +1,405 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +source scripts/lib/openclaw-e2e-instance.sh + +export npm_config_loglevel=error +export npm_config_fund=false +export npm_config_audit=false +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" + +ARTIFACT_ROOT="$(dirname "${OPENCLAW_UPGRADE_SURVIVOR_SUMMARY_JSON:-/tmp/openclaw-upgrade-survivor-artifacts/summary.json}")" +mkdir -p "$ARTIFACT_ROOT" +export TMPDIR="$ARTIFACT_ROOT/tmp" +mkdir -p "$TMPDIR" +export npm_config_prefix="$ARTIFACT_ROOT/npm-prefix" +export NPM_CONFIG_PREFIX="$npm_config_prefix" +export npm_config_cache="$ARTIFACT_ROOT/npm-cache" +export npm_config_tmp="$TMPDIR" +mkdir -p "$npm_config_prefix" "$npm_config_cache" +export PATH="$npm_config_prefix/bin:$PATH" + +SUMMARY_JSON="${OPENCLAW_UPGRADE_SURVIVOR_SUMMARY_JSON:-$ARTIFACT_ROOT/summary.json}" +PHASE_LOG="$ARTIFACT_ROOT/phases.jsonl" +: >"$PHASE_LOG" +BASELINE_RAW="${OPENCLAW_UPGRADE_SURVIVOR_BASELINE:?missing OPENCLAW_UPGRADE_SURVIVOR_BASELINE}" +CANDIDATE_KIND="${OPENCLAW_UPGRADE_SURVIVOR_CANDIDATE_KIND:-tarball}" +CANDIDATE_SPEC="${OPENCLAW_UPGRADE_SURVIVOR_CANDIDATE_SPEC:-${OPENCLAW_CURRENT_PACKAGE_TGZ:-}}" +CURRENT_PHASE="setup" +FAILURE_PHASE="" +FAILURE_MESSAGE="" +gateway_pid="" +baseline_spec="" +baseline_version="" +baseline_version_expected="0" +candidate_version="" +installed_version="" +start_seconds="" +status_seconds="" + +BASELINE_INSTALL_LOG="$ARTIFACT_ROOT/baseline-install.log" +UPDATE_JSON="$ARTIFACT_ROOT/update.json" +UPDATE_ERR="$ARTIFACT_ROOT/update.err" +DOCTOR_LOG="$ARTIFACT_ROOT/doctor.log" +GATEWAY_LOG="$ARTIFACT_ROOT/gateway.log" +STATUS_JSON="$ARTIFACT_ROOT/status.json" +STATUS_ERR="$ARTIFACT_ROOT/status.err" +BASELINE_CONFIG_VALIDATE_LOG="$ARTIFACT_ROOT/baseline-config-validate.log" +CONFIG_COVERAGE_JSON="$ARTIFACT_ROOT/config-recipe.json" +export OPENCLAW_UPGRADE_SURVIVOR_CONFIG_COVERAGE_JSON="$CONFIG_COVERAGE_JSON" + +normalize_baseline() { + local raw="${BASELINE_RAW//[[:space:]]/}" + if [ -z "$raw" ]; then + echo "OPENCLAW_UPGRADE_SURVIVOR_BASELINE cannot be empty" >&2 + return 1 + fi + case "$raw" in + openclaw@*) + baseline_spec="$raw" + baseline_version="${raw#openclaw@}" + ;; + *@*) + echo "OPENCLAW_UPGRADE_SURVIVOR_BASELINE must be openclaw@ or a bare version" >&2 + return 1 + ;; + *) + baseline_version="$raw" + baseline_spec="openclaw@$raw" + ;; + esac + case "$baseline_version" in + latest | beta) + baseline_version="" + baseline_version_expected="0" + ;; + dev | main | "") + echo "OPENCLAW_UPGRADE_SURVIVOR_BASELINE must be openclaw@latest, openclaw@beta, openclaw@, or a bare version" >&2 + return 1 + ;; + *) + baseline_version_expected="1" + ;; + esac +} + +json_event() { + local phase="$1" + local status="$2" + PHASE_EVENT_PHASE="$phase" PHASE_EVENT_STATUS="$status" node <<'NODE' >>"$PHASE_LOG" +const event = { + phase: process.env.PHASE_EVENT_PHASE, + status: process.env.PHASE_EVENT_STATUS, + at: new Date().toISOString(), +}; +process.stdout.write(`${JSON.stringify(event)}\n`); +NODE +} + +write_summary() { + local status="$1" + local message="${2:-}" + mkdir -p "$(dirname "$SUMMARY_JSON")" + SUMMARY_STATUS="$status" \ + SUMMARY_MESSAGE="$message" \ + SUMMARY_PHASE_LOG="$PHASE_LOG" \ + SUMMARY_JSON="$SUMMARY_JSON" \ + SUMMARY_BASELINE_SPEC="$baseline_spec" \ + SUMMARY_BASELINE_VERSION="$baseline_version" \ + SUMMARY_CANDIDATE_VERSION="$candidate_version" \ + SUMMARY_INSTALLED_VERSION="$installed_version" \ + SUMMARY_START_SECONDS="$start_seconds" \ + SUMMARY_STATUS_SECONDS="$status_seconds" \ + SUMMARY_FAILURE_PHASE="$FAILURE_PHASE" \ + SUMMARY_CONFIG_COVERAGE="$CONFIG_COVERAGE_JSON" \ + node <<'NODE' +const fs = require("node:fs"); +const phaseLog = process.env.SUMMARY_PHASE_LOG; +const phases = fs.existsSync(phaseLog) + ? fs.readFileSync(phaseLog, "utf8").trim().split("\n").filter(Boolean).map((line) => JSON.parse(line)) + : []; +const numberOrNull = (value) => { + if (!value) return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +}; +const readJsonOrNull = (file) => { + if (!file || !fs.existsSync(file)) return null; + return JSON.parse(fs.readFileSync(file, "utf8")); +}; +const summary = { + status: process.env.SUMMARY_STATUS, + baseline: { + spec: process.env.SUMMARY_BASELINE_SPEC || null, + version: process.env.SUMMARY_BASELINE_VERSION || null, + }, + candidate: { + kind: process.env.OPENCLAW_UPGRADE_SURVIVOR_CANDIDATE_KIND || null, + spec: process.env.OPENCLAW_UPGRADE_SURVIVOR_CANDIDATE_SPEC || process.env.OPENCLAW_CURRENT_PACKAGE_TGZ || null, + version: process.env.SUMMARY_CANDIDATE_VERSION || null, + }, + installedVersion: process.env.SUMMARY_INSTALLED_VERSION || null, + timings: { + startupSeconds: numberOrNull(process.env.SUMMARY_START_SECONDS), + statusSeconds: numberOrNull(process.env.SUMMARY_STATUS_SECONDS), + }, + config: readJsonOrNull(process.env.SUMMARY_CONFIG_COVERAGE), + failure: process.env.SUMMARY_STATUS === "passed" + ? null + : { + phase: process.env.SUMMARY_FAILURE_PHASE || null, + message: process.env.SUMMARY_MESSAGE || null, + }, + phases, +}; +fs.writeFileSync(process.env.SUMMARY_JSON, `${JSON.stringify(summary, null, 2)}\n`); +NODE +} + +cleanup() { + openclaw_e2e_terminate_gateways "${gateway_pid:-}" +} + +on_error() { + local status="$1" + FAILURE_PHASE="${CURRENT_PHASE:-unknown}" + FAILURE_MESSAGE="phase ${FAILURE_PHASE} failed with status ${status}" + json_event "$FAILURE_PHASE" failed || true + return "$status" +} + +on_exit() { + local status="$1" + set +e + cleanup + if [ "$status" -eq 0 ]; then + write_summary passed "" + else + [ -n "$FAILURE_PHASE" ] || FAILURE_PHASE="${CURRENT_PHASE:-unknown}" + [ -n "$FAILURE_MESSAGE" ] || FAILURE_MESSAGE="upgrade survivor failed with status $status" + write_summary failed "$FAILURE_MESSAGE" + fi + echo "Upgrade survivor summary: $SUMMARY_JSON" + cat "$SUMMARY_JSON" 2>/dev/null || true + exit "$status" +} + +trap 'on_error $?' ERR +trap 'on_exit $?' EXIT + +phase() { + local name="$1" + shift + CURRENT_PHASE="$name" + echo "==> upgrade-survivor:$name" + json_event "$name" started + "$@" + json_event "$name" passed + CURRENT_PHASE="" +} + +package_root() { + printf '%s/lib/node_modules/openclaw\n' "$npm_config_prefix" +} + +read_installed_version() { + node -p 'JSON.parse(require("node:fs").readFileSync(process.argv[1] + "/package.json", "utf8")).version' "$(package_root)" +} + +storage_preflight() { + echo "Storage preflight:" + df -h "$ARTIFACT_ROOT" "$TMPDIR" /tmp || true +} + +reset_run_state() { + rm -rf "$npm_config_prefix" "$TMPDIR" "$ARTIFACT_ROOT/state-home" + mkdir -p "$npm_config_prefix" "$npm_config_cache" "$TMPDIR" +} + +install_baseline() { + normalize_baseline + echo "Installing baseline package: $baseline_spec" + if ! npm install -g --prefix "$npm_config_prefix" "$baseline_spec" --no-fund --no-audit >"$BASELINE_INSTALL_LOG" 2>&1; then + echo "baseline npm install failed" >&2 + cat "$BASELINE_INSTALL_LOG" >&2 || true + return 1 + fi + if ! command -v openclaw >/dev/null; then + echo "baseline install did not expose openclaw on PATH" >&2 + echo "PATH=$PATH" >&2 + find "$npm_config_prefix" -maxdepth 3 -type f -o -type l >&2 || true + return 1 + fi + installed_version="$(read_installed_version)" + if [ "$baseline_version_expected" = "1" ] && [ "$installed_version" != "$baseline_version" ]; then + echo "baseline package version mismatch: expected $baseline_version, got $installed_version" >&2 + cat "$(package_root)/package.json" >&2 || true + return 1 + fi + baseline_version="$installed_version" + local version_output + if ! version_output="$(openclaw --version 2>&1)"; then + echo "baseline openclaw --version failed" >&2 + echo "$version_output" >&2 + return 1 + fi + if [[ "$version_output" != *"$baseline_version"* ]]; then + echo "baseline openclaw --version mismatch: expected output to include $baseline_version" >&2 + echo "$version_output" >&2 + return 1 + fi +} + +seed_state() { + openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_FUNCTION_B64:?missing OPENCLAW_TEST_STATE_FUNCTION_B64}" + openclaw_test_state_create "$ARTIFACT_ROOT/state-home" minimal + node scripts/e2e/lib/upgrade-survivor/assertions.mjs seed +} + +apply_baseline_config_recipe() { + node scripts/e2e/lib/upgrade-survivor/config-recipe.mjs apply \ + --summary "$CONFIG_COVERAGE_JSON" \ + --baseline-version "$baseline_version" +} + +validate_baseline_config() { + if ! openclaw config validate >"$BASELINE_CONFIG_VALIDATE_LOG" 2>&1; then + echo "generated baseline config failed baseline validation" >&2 + cat "$BASELINE_CONFIG_VALIDATE_LOG" >&2 || true + return 1 + fi +} + +assert_baseline_state() { + node scripts/e2e/lib/upgrade-survivor/assertions.mjs assert-config + node scripts/e2e/lib/upgrade-survivor/assertions.mjs assert-state +} + +resolve_candidate_version() { + if [ -z "$CANDIDATE_SPEC" ]; then + echo "missing OPENCLAW_UPGRADE_SURVIVOR_CANDIDATE_SPEC" >&2 + return 1 + fi + case "$CANDIDATE_KIND" in + tarball) + candidate_version="$( + node -e ' + const { execFileSync } = require("node:child_process"); + const packageJson = execFileSync("tar", ["-xOf", process.argv[1], "package/package.json"], { + encoding: "utf8", + }); + process.stdout.write(JSON.parse(packageJson).version); + ' "$CANDIDATE_SPEC" + )" + ;; + npm) + candidate_version="$(npm view "$CANDIDATE_SPEC" version --silent)" + ;; + *) + echo "unknown candidate kind: $CANDIDATE_KIND" >&2 + return 1 + ;; + esac + if [ -z "$candidate_version" ]; then + echo "could not resolve candidate version from $CANDIDATE_KIND:$CANDIDATE_SPEC" >&2 + return 1 + fi + OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT="$( + node scripts/e2e/lib/package-compat.mjs "$candidate_version" + )" + export OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT +} + +update_candidate() { + echo "Updating baseline $baseline_spec to candidate $CANDIDATE_KIND:$CANDIDATE_SPEC ($candidate_version)" + if ! openclaw update --tag "$CANDIDATE_SPEC" --yes --json --no-restart >"$UPDATE_JSON" 2>"$UPDATE_ERR"; then + echo "openclaw update failed" >&2 + cat "$UPDATE_ERR" >&2 || true + cat "$UPDATE_JSON" >&2 || true + return 1 + fi + installed_version="$(read_installed_version)" +} + +run_doctor() { + if ! openclaw doctor --fix --non-interactive >"$DOCTOR_LOG" 2>&1; then + echo "openclaw doctor failed" >&2 + cat "$DOCTOR_LOG" >&2 || true + return 1 + fi +} + +assert_survival() { + node scripts/e2e/lib/upgrade-survivor/assertions.mjs assert-config + node scripts/e2e/lib/upgrade-survivor/assertions.mjs assert-state + installed_version="$(read_installed_version)" + if [ "$installed_version" != "$candidate_version" ]; then + echo "candidate package version mismatch: expected $candidate_version, got $installed_version" >&2 + return 1 + fi +} + +start_gateway() { + local port=18789 + local budget="${OPENCLAW_UPGRADE_SURVIVOR_START_BUDGET_SECONDS:-90}" + local start_epoch + local ready_epoch + start_epoch="$(node -e "process.stdout.write(String(Date.now()))")" + openclaw gateway --port "$port" --bind loopback --allow-unconfigured >"$GATEWAY_LOG" 2>&1 & + gateway_pid="$!" + openclaw_e2e_wait_gateway_ready "$gateway_pid" "$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 "$budget" ]; then + echo "gateway startup exceeded survivor budget: ${start_seconds}s > ${budget}s" >&2 + cat "$GATEWAY_LOG" >&2 || true + return 1 + fi +} + +check_gateway_status() { + local port=18789 + local budget="${OPENCLAW_UPGRADE_SURVIVOR_STATUS_BUDGET_SECONDS:-30}" + local status_start + local status_end + 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 >"$STATUS_JSON" 2>"$STATUS_ERR"; then + echo "gateway status failed" >&2 + cat "$STATUS_ERR" >&2 || true + cat "$GATEWAY_LOG" >&2 || true + return 1 + fi + status_end="$(node -e "process.stdout.write(String(Date.now()))")" + status_seconds=$(((status_end - status_start + 999) / 1000)) + if [ "$status_seconds" -gt "$budget" ]; then + echo "gateway status exceeded survivor budget: ${status_seconds}s > ${budget}s" >&2 + cat "$STATUS_JSON" >&2 || true + return 1 + fi + node scripts/e2e/lib/upgrade-survivor/assertions.mjs assert-status-json "$STATUS_JSON" +} + +phase storage-preflight storage_preflight +phase reset-run-state reset_run_state +phase install-baseline install_baseline +phase seed-state seed_state +phase apply-baseline-config-recipe apply_baseline_config_recipe +phase validate-baseline-config validate_baseline_config +phase assert-baseline assert_baseline_state +phase resolve-candidate resolve_candidate_version +phase update-candidate update_candidate +phase doctor run_doctor +phase assert-survival assert_survival +phase gateway-start start_gateway +phase gateway-status check_gateway_status + +echo "Upgrade survivor Docker E2E passed baseline=${baseline_spec} candidate=${candidate_version} startup=${start_seconds}s status=${status_seconds}s." diff --git a/scripts/e2e/upgrade-survivor-docker.sh b/scripts/e2e/upgrade-survivor-docker.sh index d5b90d07fdb..6afebbbee35 100755 --- a/scripts/e2e/upgrade-survivor-docker.sh +++ b/scripts/e2e/upgrade-survivor-docker.sh @@ -1,6 +1,7 @@ #!/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. +# Installs the packed OpenClaw tarball over dirty old-user state. When +# OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC is set, installs that published +# baseline first and upgrades it to the selected candidate. set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" @@ -9,12 +10,91 @@ 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}" BASELINE_SPEC="${OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC:-}" +ARTIFACT_DIR="${OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_DIR:-$ROOT_DIR/.artifacts/upgrade-survivor}" +normalize_npm_candidate() { + local raw="$1" + case "$raw" in + latest | beta) + printf 'openclaw@%s\n' "$raw" + ;; + openclaw@*) + printf '%s\n' "$raw" + ;; + *@*) + echo "OPENCLAW_UPGRADE_SURVIVOR_CANDIDATE must be current, latest, beta, openclaw@, a bare version, or a .tgz path." >&2 + return 1 + ;; + *) + printf 'openclaw@%s\n' "$raw" + ;; + esac +} + +if [ "${OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE:-0}" = "1" ]; then + if [ -z "${BASELINE_SPEC// }" ]; then + echo "OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC is required for published upgrade survivor" >&2 + exit 1 + fi + + mkdir -p "$ARTIFACT_DIR" + + DOCKER_E2E_PACKAGE_ARGS=() + CANDIDATE_RAW="${OPENCLAW_UPGRADE_SURVIVOR_CANDIDATE:-current}" + CANDIDATE_KIND="npm" + CANDIDATE_SPEC="" + + if [ -n "${OPENCLAW_CURRENT_PACKAGE_TGZ:-}" ]; then + PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz upgrade-survivor "$OPENCLAW_CURRENT_PACKAGE_TGZ")" + docker_e2e_package_mount_args "$PACKAGE_TGZ" + CANDIDATE_KIND="tarball" + CANDIDATE_SPEC="/tmp/openclaw-current.tgz" + elif [ "$CANDIDATE_RAW" = "current" ]; then + PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz upgrade-survivor)" + docker_e2e_package_mount_args "$PACKAGE_TGZ" + CANDIDATE_KIND="tarball" + CANDIDATE_SPEC="/tmp/openclaw-current.tgz" + elif [[ "$CANDIDATE_RAW" == *.tgz ]]; then + if [ ! -f "$CANDIDATE_RAW" ]; then + echo "OpenClaw candidate tarball does not exist: $CANDIDATE_RAW" >&2 + exit 1 + fi + PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz upgrade-survivor "$CANDIDATE_RAW")" + docker_e2e_package_mount_args "$PACKAGE_TGZ" + CANDIDATE_KIND="tarball" + CANDIDATE_SPEC="/tmp/openclaw-current.tgz" + else + CANDIDATE_KIND="npm" + CANDIDATE_SPEC="$(normalize_npm_candidate "$CANDIDATE_RAW")" + fi + + OPENCLAW_TEST_STATE_FUNCTION_B64="$(docker_e2e_test_state_function_b64)" + + docker_e2e_build_or_reuse "$IMAGE_NAME" upgrade-survivor "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "bare" "$SKIP_BUILD" + + echo "Running published upgrade survivor Docker E2E..." + docker_e2e_run_with_harness \ + -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ + -e OPENCLAW_TEST_STATE_FUNCTION_B64="$OPENCLAW_TEST_STATE_FUNCTION_B64" \ + -e OPENCLAW_UPGRADE_SURVIVOR_BASELINE="$BASELINE_SPEC" \ + -e OPENCLAW_UPGRADE_SURVIVOR_CANDIDATE_KIND="$CANDIDATE_KIND" \ + -e OPENCLAW_UPGRADE_SURVIVOR_CANDIDATE_SPEC="$CANDIDATE_SPEC" \ + -e OPENCLAW_UPGRADE_SURVIVOR_SUMMARY_JSON=/tmp/openclaw-upgrade-survivor-artifacts/summary.json \ + -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}" \ + -v "$ARTIFACT_DIR:/tmp/openclaw-upgrade-survivor-artifacts" \ + "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ + "$IMAGE_NAME" \ + timeout "$DOCKER_RUN_TIMEOUT" bash scripts/e2e/lib/upgrade-survivor/run.sh + exit 0 +fi + +PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz upgrade-survivor "${OPENCLAW_CURRENT_PACKAGE_TGZ:-}")" docker_e2e_package_mount_args "$PACKAGE_TGZ" OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 upgrade-survivor upgrade-survivor)" +mkdir -p "$ARTIFACT_DIR" docker_e2e_build_or_reuse "$IMAGE_NAME" upgrade-survivor "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "bare" "$SKIP_BUILD" @@ -22,9 +102,10 @@ 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_ARTIFACT_ROOT=/tmp/openclaw-upgrade-survivor-artifacts \ -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}" \ - -e OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC="$BASELINE_SPEC" \ + -v "$ARTIFACT_DIR:/tmp/openclaw-upgrade-survivor-artifacts" \ "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ "$IMAGE_NAME" \ timeout "$DOCKER_RUN_TIMEOUT" bash -lc 'set -euo pipefail @@ -33,9 +114,16 @@ 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 OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_ROOT="${OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_ROOT:-/tmp/openclaw-upgrade-survivor-artifacts}" +mkdir -p "$OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_ROOT" +export TMPDIR="$OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_ROOT/tmp" +export OPENCLAW_TEST_STATE_TMPDIR="$OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_ROOT/state-tmp" +export npm_config_prefix="$OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_ROOT/npm-prefix" +export NPM_CONFIG_PREFIX="$npm_config_prefix" +export npm_config_cache="$OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_ROOT/npm-cache" +export npm_config_tmp="$TMPDIR" +mkdir -p "$TMPDIR" "$OPENCLAW_TEST_STATE_TMPDIR" "$npm_config_prefix" "$npm_config_cache" +export PATH="$npm_config_prefix/bin:$PATH" export CI=true export OPENCLAW_NO_ONBOARD=1 export OPENCLAW_NO_PROMPT=1 @@ -56,18 +144,9 @@ 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 -if [ -n "${OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC:-}" ]; then - echo "Installing published upgrade survivor baseline: ${OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC}" - if ! npm install -g --prefix /tmp/npm-prefix "$OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC" --no-fund --no-audit >/tmp/openclaw-upgrade-survivor-install.log 2>&1; then - echo "npm install failed for upgrade survivor baseline" >&2 - cat /tmp/openclaw-upgrade-survivor-install.log >&2 || true - exit 1 - fi -else - openclaw_e2e_install_package /tmp/openclaw-upgrade-survivor-install.log "upgrade survivor package" /tmp/npm-prefix -fi +openclaw_e2e_install_package "$OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_ROOT/install.log" "upgrade survivor package" "$npm_config_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")" +package_version="$(node -p "JSON.parse(require(\"node:fs\").readFileSync(process.argv[1] + \"/lib/node_modules/openclaw/package.json\", \"utf8\")).version" "$npm_config_prefix")" OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT="$( node scripts/e2e/lib/package-compat.mjs "$package_version" )" diff --git a/scripts/lib/docker-e2e-scenarios.mjs b/scripts/lib/docker-e2e-scenarios.mjs index 593974bc611..2b42a858cba 100644 --- a/scripts/lib/docker-e2e-scenarios.mjs +++ b/scripts/lib/docker-e2e-scenarios.mjs @@ -10,6 +10,7 @@ const LIVE_CLI_TIMEOUT_MS = 20 * 60 * 1000; const LIVE_PROFILE_TIMEOUT_MS = 20 * 60 * 1000; const OPENWEBUI_TIMEOUT_MS = 20 * 60 * 1000; export const BUNDLED_PLUGIN_INSTALL_UNINSTALL_SHARDS = 24; +const upgradeSurvivorCommand = "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:upgrade-survivor"; export const LIVE_RETRY_PATTERNS = [ /529\b/i, @@ -278,7 +279,7 @@ export const mainLanes = [ weight: 3, }, ), - npmLane("upgrade-survivor", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:upgrade-survivor", { + npmLane("upgrade-survivor", upgradeSurvivorCommand, { stateScenario: "upgrade-survivor", timeoutMs: 20 * 60 * 1000, weight: 3, @@ -547,7 +548,7 @@ const releasePathPackageUpdateCoreLanes = [ weight: 3, }, ), - npmLane("upgrade-survivor", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:upgrade-survivor", { + npmLane("upgrade-survivor", upgradeSurvivorCommand, { stateScenario: "upgrade-survivor", timeoutMs: 20 * 60 * 1000, weight: 3, diff --git a/scripts/lib/openclaw-test-state.mjs b/scripts/lib/openclaw-test-state.mjs index be6fe849e99..fce0e5e1563 100644 --- a/scripts/lib/openclaw-test-state.mjs +++ b/scripts/lib/openclaw-test-state.mjs @@ -319,9 +319,12 @@ export function renderShellSnippet(options = {}) { const scenario = requireScenario(options.scenario); const config = scenarioConfig(scenario, options); const env = scenarioEnv(scenario); - const template = `/tmp/openclaw-${label}-${scenario}-home.XXXXXX`; + const homeTemplate = `openclaw-${label}-${scenario}-home.XXXXXX`; const lines = [ - `OPENCLAW_TEST_STATE_HOME="$(mktemp -d ${shellQuote(template)})"`, + 'OPENCLAW_TEST_STATE_TMP_ROOT="${OPENCLAW_TEST_STATE_TMPDIR:-${TMPDIR:-/tmp}}"', + "export OPENCLAW_TEST_STATE_TMP_ROOT", + 'mkdir -p "$OPENCLAW_TEST_STATE_TMP_ROOT"', + `OPENCLAW_TEST_STATE_HOME="$(mktemp -d "$OPENCLAW_TEST_STATE_TMP_ROOT/${homeTemplate}")"`, 'export HOME="$OPENCLAW_TEST_STATE_HOME"', 'export USERPROFILE="$OPENCLAW_TEST_STATE_HOME"', 'export OPENCLAW_HOME="$OPENCLAW_TEST_STATE_HOME"', @@ -360,7 +363,9 @@ export function renderShellFunction() { *) label="$(printf "%s" "$label" | tr -cs "A-Za-z0-9_.-" "-" | sed -e "s/^-*//" -e "s/-*$//")" [ -n "$label" ] || label="state" - OPENCLAW_TEST_STATE_HOME="$(mktemp -d "/tmp/openclaw-$label-$scenario-home.XXXXXX")" + local tmp_root="\${OPENCLAW_TEST_STATE_TMPDIR:-\${TMPDIR:-/tmp}}" + mkdir -p "$tmp_root" + OPENCLAW_TEST_STATE_HOME="$(mktemp -d "$tmp_root/openclaw-$label-$scenario-home.XXXXXX")" ;; esac export HOME="$OPENCLAW_TEST_STATE_HOME" diff --git a/scripts/test-docker-all.mjs b/scripts/test-docker-all.mjs index e6b33324429..0fc7e632fc9 100644 --- a/scripts/test-docker-all.mjs +++ b/scripts/test-docker-all.mjs @@ -222,6 +222,12 @@ function githubWorkflowRerunCommand(laneNames, ref) { )}`, ); } + if (process.env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC) { + fields.push( + "-f", + `published_upgrade_survivor_baseline=${shellQuote(process.env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC)}`, + ); + } if (process.env.OPENCLAW_DOCKER_E2E_BARE_IMAGE) { fields.push( "-f", @@ -250,6 +256,7 @@ function buildLaneRerunCommand(name, baseEnv) { ["OPENCLAW_DOCKER_E2E_BARE_IMAGE", baseEnv.OPENCLAW_DOCKER_E2E_BARE_IMAGE], ["OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE", baseEnv.OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE], ["OPENCLAW_CURRENT_PACKAGE_TGZ", baseEnv.OPENCLAW_CURRENT_PACKAGE_TGZ], + ["OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC", baseEnv.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC], ]; if (baseEnv.OPENCLAW_DOCKER_ALL_PNPM_COMMAND) { env.push(["OPENCLAW_DOCKER_ALL_PNPM_COMMAND", baseEnv.OPENCLAW_DOCKER_ALL_PNPM_COMMAND]); diff --git a/test/scripts/docker-e2e-plan.test.ts b/test/scripts/docker-e2e-plan.test.ts index 843e62ed4ec..c39e2a5df3a 100644 --- a/test/scripts/docker-e2e-plan.test.ts +++ b/test/scripts/docker-e2e-plan.test.ts @@ -194,6 +194,7 @@ describe("scripts/lib/docker-e2e-plan", () => { }), expect.objectContaining({ name: "upgrade-survivor", + command: "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:upgrade-survivor", stateScenario: "upgrade-survivor", }), expect.objectContaining({ diff --git a/test/scripts/openclaw-test-state.test.ts b/test/scripts/openclaw-test-state.test.ts index 35773295db8..eb410266a31 100644 --- a/test/scripts/openclaw-test-state.test.ts +++ b/test/scripts/openclaw-test-state.test.ts @@ -15,6 +15,10 @@ function shellQuote(value: string): string { return `'${value.replace(/'/gu, `'\\''`)}'`; } +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} + describe("scripts/lib/openclaw-test-state", () => { it("creates a sourceable env file and JSON description", async () => { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-state-script-")); @@ -88,7 +92,10 @@ describe("scripts/lib/openclaw-test-state", () => { "update-stable", ]); expect(stdout).toContain( - "mktemp -d '/tmp/openclaw-update-channel-switch-update-stable-home.XXXXXX'", + 'OPENCLAW_TEST_STATE_TMP_ROOT="${OPENCLAW_TEST_STATE_TMPDIR:-${TMPDIR:-/tmp}}"', + ); + expect(stdout).toContain( + 'mktemp -d "$OPENCLAW_TEST_STATE_TMP_ROOT/openclaw-update-channel-switch-update-stable-home.XXXXXX"', ); expect(stdout).toContain("OPENCLAW_TEST_STATE_JSON"); expect(stdout).toContain('"channel": "stable"'); @@ -100,10 +107,26 @@ describe("scripts/lib/openclaw-test-state", () => { ]); const payload = JSON.parse(probe.stdout); - expect(payload.home).toMatch(/^\/tmp\/openclaw-update-channel-switch-update-stable-home\./u); + expect(payload.home.startsWith(os.tmpdir())).toBe(true); + expect(path.basename(payload.home)).toMatch( + /^openclaw-update-channel-switch-update-stable-home\./u, + ); expect(payload.openclawHome).toBe(payload.home); expect(payload.workspace).toBe(`${payload.home}/workspace`); expect(payload.channel).toBe("stable"); + + const customTemp = path.join(tempRoot, "state-tmp"); + const customProbe = await execFileAsync("bash", [ + "-lc", + `export OPENCLAW_TEST_STATE_TMPDIR=${shellQuote(customTemp)}; source ${shellQuote(snippetFile)}; node -e 'process.stdout.write(JSON.stringify({home:process.env.HOME,tmpRoot:process.env.OPENCLAW_TEST_STATE_TMP_ROOT}));'; rm -rf "$HOME"`, + ]); + const customPayload = JSON.parse(customProbe.stdout); + expect(customPayload.tmpRoot).toBe(customTemp); + expect(customPayload.home).toMatch( + new RegExp( + `^${escapeRegex(customTemp)}/openclaw-update-channel-switch-update-stable-home\\.`, + ), + ); } finally { await fs.rm(tempRoot, { recursive: true, force: true }); } @@ -167,11 +190,12 @@ describe("scripts/lib/openclaw-test-state", () => { const probe = await execFileAsync("bash", [ "-lc", - `source ${shellQuote(snippetFile)}; export OPENCLAW_AGENT_DIR=/tmp/outside-agent; openclaw_test_state_create "onboard case" minimal; node -e 'const fs=require("node:fs"); const config=JSON.parse(fs.readFileSync(process.env.OPENCLAW_CONFIG_PATH,"utf8")); process.stdout.write(JSON.stringify({home:process.env.HOME,agentDir:process.env.OPENCLAW_AGENT_DIR || null,workspace:process.env.OPENCLAW_TEST_WORKSPACE_DIR,config}));'; rm -rf "$HOME"`, + `export OPENCLAW_TEST_STATE_TMPDIR=${shellQuote(path.join(tempRoot, "function-tmp"))}; source ${shellQuote(snippetFile)}; export OPENCLAW_AGENT_DIR=/tmp/outside-agent; openclaw_test_state_create "onboard case" minimal; node -e 'const fs=require("node:fs"); const config=JSON.parse(fs.readFileSync(process.env.OPENCLAW_CONFIG_PATH,"utf8")); process.stdout.write(JSON.stringify({home:process.env.HOME,tmpDir:process.env.OPENCLAW_TEST_STATE_TMPDIR,agentDir:process.env.OPENCLAW_AGENT_DIR || null,workspace:process.env.OPENCLAW_TEST_WORKSPACE_DIR,config}));'; rm -rf "$HOME"`, ]); const payload = JSON.parse(probe.stdout); - expect(payload.home).toMatch(/^\/tmp\/openclaw-onboard-case-minimal-home\./u); + expect(payload.home).toBe(`${payload.tmpDir}/${path.basename(payload.home)}`); + expect(payload.home).toContain("/openclaw-onboard-case-minimal-home."); expect(payload.agentDir).toBeNull(); expect(payload.workspace).toBe(`${payload.home}/workspace`); expect(payload.config).toEqual({}); diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 5445e47dbbf..9fdda2be104 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; const PACKAGE_ACCEPTANCE_WORKFLOW = ".github/workflows/package-acceptance.yml"; const LIVE_E2E_WORKFLOW = ".github/workflows/openclaw-live-and-e2e-checks-reusable.yml"; const NPM_TELEGRAM_WORKFLOW = ".github/workflows/npm-telegram-beta-e2e.yml"; +const PACKAGE_JSON = "package.json"; const RELEASE_CHECKS_WORKFLOW = ".github/workflows/openclaw-release-checks.yml"; const FULL_RELEASE_VALIDATION_WORKFLOW = ".github/workflows/full-release-validation.yml"; const QA_LIVE_TRANSPORTS_WORKFLOW = ".github/workflows/qa-live-transports-convex.yml"; @@ -39,9 +40,11 @@ describe("package acceptance workflow", () => { const workflow = readFileSync(PACKAGE_ACCEPTANCE_WORKFLOW, "utf8"); expect(workflow).toContain("suite_profile:"); + expect(workflow).toContain("published_upgrade_survivor_baseline:"); 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("published-upgrade-survivor"); expect(workflow).toContain("bundled-channel-deps-compat"); expect(workflow).toContain("plugins-offline plugin-update"); expect(workflow).toContain("include_release_path_suites=true"); @@ -61,18 +64,28 @@ describe("package acceptance workflow", () => { expect(workflow).toContain( "harness_ref: ${{ needs.resolve_package.outputs.package_source_sha || inputs.workflow_ref }}", ); + expect(workflow).toContain( + "published_upgrade_survivor_baseline: ${{ inputs.published_upgrade_survivor_baseline }}", + ); + expect(workflow).toContain("Published upgrade survivor baseline:"); }); }); describe("package artifact reuse", () => { it("lets reusable Docker E2E consume an already resolved package artifact", () => { const workflow = readFileSync(LIVE_E2E_WORKFLOW, "utf8"); + const packageJson = readFileSync(PACKAGE_JSON, "utf8"); + const scheduler = readFileSync("scripts/test-docker-all.mjs", "utf8"); expect(workflow).toContain("package_artifact_name:"); expect(workflow).toContain("package_artifact_run_id:"); + expect(workflow).toContain("published_upgrade_survivor_baseline:"); expect(workflow).toContain("docker_e2e_bare_image:"); expect(workflow).toContain("docker_e2e_functional_image:"); expect(workflow).toContain("OPENCLAW_DOCKER_E2E_SELECTED_SHA:"); + expect(workflow).toContain( + "OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC: ${{ inputs.published_upgrade_survivor_baseline }}", + ); expect(workflow).toContain("Download current-run OpenClaw Docker E2E package"); expect(workflow).toContain("Download previous-run OpenClaw Docker E2E package"); expect(workflow).toContain("inputs.package_artifact_name != ''"); @@ -95,6 +108,13 @@ describe("package artifact reuse", () => { expect(workflow).toContain("LANES: ${{ matrix.group.docker_lanes }}"); expect(workflow).toContain("DOCKER_E2E_LANES: ${{ matrix.group.docker_lanes }}"); expect(workflow).toContain("name: docker-e2e-${{ steps.plan.outputs.artifact_suffix }}"); + expect(scheduler).toContain( + "published_upgrade_survivor_baseline=${shellQuote(process.env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC)}", + ); + expect(scheduler).toContain( + '["OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC", baseEnv.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC]', + ); + expect(packageJson).toContain("OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE=1"); }); it("bounds shared Docker image pulls so package acceptance cannot stall forever", () => {