diff --git a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml index 5de776f5b16..c0a43f227b9 100644 --- a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml +++ b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml @@ -43,6 +43,11 @@ on: required: false default: "" type: string + published_upgrade_survivor_scenarios: + description: Optional scenario list for published-upgrade-survivor lane expansion + required: false + default: "" + type: string package_artifact_name: description: Existing workflow artifact containing openclaw-current.tgz; blank packs the selected ref required: false @@ -133,6 +138,11 @@ on: required: false default: "" type: string + published_upgrade_survivor_scenarios: + description: Optional scenario list for published-upgrade-survivor lane expansion + required: false + default: "" + type: string package_artifact_name: description: Existing workflow artifact containing openclaw-current.tgz; blank packs the selected ref required: false @@ -706,6 +716,7 @@ jobs: OPENCLAW_CURRENT_PACKAGE_TGZ: .artifacts/docker-e2e-package/openclaw-current.tgz OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC: ${{ inputs.published_upgrade_survivor_baseline }} OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS: ${{ inputs.published_upgrade_survivor_baselines }} + OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS: ${{ inputs.published_upgrade_survivor_scenarios }} OPENCLAW_SKIP_DOCKER_BUILD: "1" INCLUDE_OPENWEBUI: ${{ inputs.include_openwebui }} DOCKER_E2E_CHUNK: ${{ matrix.chunk_id }} @@ -941,6 +952,7 @@ jobs: OPENCLAW_CURRENT_PACKAGE_TGZ: .artifacts/docker-e2e-package/openclaw-current.tgz OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC: ${{ inputs.published_upgrade_survivor_baseline }} OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS: ${{ inputs.published_upgrade_survivor_baselines }} + OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS: ${{ inputs.published_upgrade_survivor_scenarios }} 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 cfea4839843..cefa1325631 100644 --- a/.github/workflows/package-acceptance.yml +++ b/.github/workflows/package-acceptance.yml @@ -74,6 +74,11 @@ on: required: false default: "" type: string + published_upgrade_survivor_scenarios: + description: Optional scenario list for published-upgrade-survivor; use reported-issues for known upgrade failure shapes + required: false + default: "" + type: string telegram_mode: description: Optional Telegram QA lane for the resolved package candidate required: true @@ -149,6 +154,11 @@ on: required: false default: "" type: string + published_upgrade_survivor_scenarios: + description: Optional scenario list for published-upgrade-survivor; use reported-issues for known upgrade failure shapes + required: false + default: "" + type: string telegram_mode: description: Optional Telegram QA lane for the resolved package candidate required: false @@ -286,6 +296,7 @@ jobs: package_sha256: ${{ steps.resolve.outputs.sha256 }} package_version: ${{ steps.resolve.outputs.package_version }} published_upgrade_survivor_baselines: ${{ steps.upgrade_survivor_baselines.outputs.baselines }} + published_upgrade_survivor_scenarios: ${{ inputs.published_upgrade_survivor_scenarios }} telegram_enabled: ${{ steps.profile.outputs.telegram_enabled }} telegram_mode: ${{ steps.profile.outputs.telegram_mode }} steps: @@ -470,6 +481,7 @@ jobs: WORKFLOW_REF: ${{ inputs.workflow_ref }} PUBLISHED_UPGRADE_SURVIVOR_BASELINE: ${{ inputs.published_upgrade_survivor_baseline }} PUBLISHED_UPGRADE_SURVIVOR_BASELINES: ${{ steps.upgrade_survivor_baselines.outputs.baselines }} + PUBLISHED_UPGRADE_SURVIVOR_SCENARIOS: ${{ inputs.published_upgrade_survivor_scenarios }} shell: bash run: | { @@ -485,6 +497,7 @@ jobs: echo "- Profile: \`${SUITE_PROFILE}\`" echo "- Published upgrade survivor baseline: \`${PUBLISHED_UPGRADE_SURVIVOR_BASELINE}\`" echo "- Published upgrade survivor baselines: \`${PUBLISHED_UPGRADE_SURVIVOR_BASELINES}\`" + echo "- Published upgrade survivor scenarios: \`${PUBLISHED_UPGRADE_SURVIVOR_SCENARIOS}\`" } >> "$GITHUB_STEP_SUMMARY" docker_acceptance: @@ -499,6 +512,7 @@ jobs: docker_lanes: ${{ needs.resolve_package.outputs.docker_lanes }} published_upgrade_survivor_baseline: ${{ inputs.published_upgrade_survivor_baseline }} published_upgrade_survivor_baselines: ${{ needs.resolve_package.outputs.published_upgrade_survivor_baselines }} + published_upgrade_survivor_scenarios: ${{ needs.resolve_package.outputs.published_upgrade_survivor_scenarios }} 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 d4a8e110ee7..b6684891789 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 `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 fallback published baseline, defaulting to `openclaw@latest`; failed-lane rerun commands preserve that baseline. Set `published_upgrade_survivor_baselines=release-history` to expand the lane across a deduped history matrix: the latest six stable releases, `2026.4.23`, and the latest stable release before `2026-03-15`. Local aggregate runs can pass exact package specs with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS`, or keep a single lane with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC` 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`. 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 fallback published baseline, defaulting to `openclaw@latest`; failed-lane rerun commands preserve that baseline. Set `published_upgrade_survivor_baselines=release-history` to expand the lane across a deduped history matrix: the latest six stable releases, `2026.4.23`, and the latest stable release before `2026-03-15`. Set `published_upgrade_survivor_scenarios=reported-issues` to expand the same baselines across issue-shaped fixtures for Feishu config/runtime-deps, preserved bootstrap/persona files, tilde log paths, and stale versioned runtime-deps roots. Local aggregate runs can pass exact package specs with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS`, keep a single lane with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC` such as `openclaw@2026.4.15`, or set `OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS` for the scenario matrix. The published lane configures the baseline with a baked `openclaw config set` command recipe, records recipe steps in `summary.json`, and probes `/healthz`, `/readyz`, plus RPC status after Gateway start. 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 5b6f1afe042..7beaf6fc00c 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -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, 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 one baseline with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC`, or ask the aggregate scheduler to expand exact baselines with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS`; Package Acceptance exposes those as `published_upgrade_survivor_baseline` and `published_upgrade_survivor_baselines`. +- 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, `/healthz`, `/readyz`, and RPC status budgets. Override one baseline with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC`, ask the aggregate scheduler to expand exact baselines with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS`, and expand issue-shaped fixtures with `OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS` such as `reported-issues`; Package Acceptance exposes those as `published_upgrade_survivor_baseline`, `published_upgrade_survivor_baselines`, and `published_upgrade_survivor_scenarios`. - 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 31a1f29254e..5ed27b15184 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, 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 one baseline with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC`, or expand an exact matrix with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS`; Package Acceptance exposes those as `published_upgrade_survivor_baseline` and `published_upgrade_survivor_baselines`. +- `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, `/healthz`, `/readyz`, and RPC status survive or repair cleanly. Override one baseline with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC`, expand an exact matrix with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS`, or add scenario fixtures with `OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS=reported-issues`; Package Acceptance exposes those as `published_upgrade_survivor_baseline`, `published_upgrade_survivor_baselines`, and `published_upgrade_survivor_scenarios`. ## Local PR gate diff --git a/scripts/e2e/lib/upgrade-survivor/assertions.mjs b/scripts/e2e/lib/upgrade-survivor/assertions.mjs index 9d2829ee74e..0e152aa7afa 100644 --- a/scripts/e2e/lib/upgrade-survivor/assertions.mjs +++ b/scripts/e2e/lib/upgrade-survivor/assertions.mjs @@ -2,6 +2,20 @@ import fs from "node:fs"; import path from "node:path"; const command = process.argv[2]; +const SCENARIOS = new Set([ + "base", + "feishu-channel", + "bootstrap-persona", + "tilde-log-path", + "versioned-runtime-deps", +]); + +const PERSONA_FILES = new Map([ + ["BOOTSTRAP.md", "# Existing Bootstrap\n\nDo not overwrite me during update.\n"], + ["SOUL.md", "# Existing Soul\n\nKeep this voice intact.\n"], + ["USER.md", "# Existing User\n\nPrefers survivor tests.\n"], + ["MEMORY.md", "# Existing Memory\n\nUpgrade reports came from real users.\n"], +]); function requireEnv(name) { const value = process.env[name]; @@ -30,6 +44,12 @@ function assert(condition, message) { } } +function getScenario() { + const scenario = process.env.OPENCLAW_UPGRADE_SURVIVOR_SCENARIO || "base"; + assert(SCENARIOS.has(scenario), `unknown upgrade survivor scenario: ${scenario}`); + return scenario; +} + function getConfig() { return readJson(requireEnv("OPENCLAW_CONFIG_PATH")); } @@ -56,11 +76,17 @@ function hasCoverage(coverage) { function seedState() { const stateDir = requireEnv("OPENCLAW_STATE_DIR"); const workspace = requireEnv("OPENCLAW_TEST_WORKSPACE_DIR"); + const scenario = getScenario(); write( path.join(workspace, "IDENTITY.md"), "# Upgrade Survivor\n\nThis workspace must survive package update and doctor repair.\n", ); + if (scenario === "bootstrap-persona") { + for (const [fileName, contents] of PERSONA_FILES) { + write(path.join(workspace, fileName), contents); + } + } writeJson(path.join(workspace, ".openclaw", "workspace-state.json"), { version: 1, setupCompletedAt: "2026-04-01T00:00:00.000Z", @@ -90,6 +116,33 @@ function seedState() { `${JSON.stringify({ name: "stale-sentinel", version: "0.0.0" }, null, 2)}\n`, ); } + if (scenario === "versioned-runtime-deps") { + const version = process.env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_VERSION || "2026.4.24"; + for (const plugin of ["discord", "feishu", "telegram", "whatsapp"]) { + writeJson( + path.join( + runtimeRoot, + `openclaw-${version}-${plugin}`, + ".openclaw-runtime-deps-stamp.json", + ), + { + packageVersion: version, + plugin, + stale: true, + }, + ); + write( + path.join( + runtimeRoot, + `openclaw-${version}-${plugin}`, + "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"], @@ -98,6 +151,7 @@ function seedState() { telegramGroup: "-1001234567890", whatsappGroup: "120363000000000000@g.us", workspaceIdentity: path.join(workspace, "IDENTITY.md"), + scenario, }); } @@ -150,6 +204,9 @@ function assertConfigSurvived() { 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 (hasCoverage(coverage) && acceptsIntent(coverage, "feishu-channel")) { + assert(pluginAllow.includes("feishu"), "feishu plugin allow entry missing"); + } } if (acceptsIntent(coverage, "discord-channel")) { @@ -192,11 +249,31 @@ function assertConfigSurvived() { ); } } + + if (hasCoverage(coverage) && acceptsIntent(coverage, "feishu-channel")) { + const feishu = config.channels?.feishu; + assert(feishu?.enabled === true, "feishu enabled flag changed"); + assert(feishu?.connectionMode === "webhook", "feishu connection mode changed"); + assert(feishu?.defaultAccount === "default", "feishu default account changed"); + assert(feishu?.accounts?.default?.appId === "cli_upgrade_survivor", "feishu account changed"); + assert( + feishu.groups?.oc_upgrade_survivor?.requireMention === true, + "feishu group mention policy changed", + ); + } + + if (hasCoverage(coverage) && acceptsIntent(coverage, "logging")) { + assert( + config.logging?.file === "~/openclaw-upgrade-survivor/gateway.jsonl", + "logging.file tilde path changed", + ); + } } function assertStateSurvived() { const stateDir = requireEnv("OPENCLAW_STATE_DIR"); const workspace = requireEnv("OPENCLAW_TEST_WORKSPACE_DIR"); + const scenario = getScenario(); assert(fs.existsSync(path.join(workspace, "IDENTITY.md")), "workspace identity file missing"); assert( fs.existsSync(path.join(stateDir, "agents", "main", "sessions", "legacy-session.json")), @@ -206,6 +283,27 @@ function assertStateSurvived() { fs.existsSync(path.join(stateDir, "plugin-runtime-deps", "discord")), "plugin runtime deps root missing", ); + if (scenario === "bootstrap-persona") { + for (const [fileName, contents] of PERSONA_FILES) { + const actual = fs.readFileSync(path.join(workspace, fileName), "utf8"); + assert(actual === contents, `${fileName} was changed during update/doctor`); + } + } + if (scenario === "versioned-runtime-deps") { + const stage = process.env.OPENCLAW_UPGRADE_SURVIVOR_ASSERT_STAGE || "survival"; + if (stage === "baseline") { + return; + } + const version = process.env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_VERSION || "2026.4.24"; + const runtimeRoot = path.join(stateDir, "plugin-runtime-deps"); + const staleVersionedRoots = fs.existsSync(runtimeRoot) + ? fs.readdirSync(runtimeRoot).filter((entry) => entry.startsWith(`openclaw-${version}-`)) + : []; + assert( + staleVersionedRoots.length === 0, + `stale versioned runtime deps survived update/doctor: ${staleVersionedRoots.join(", ")}`, + ); + } } function assertStatusJson([file]) { diff --git a/scripts/e2e/lib/upgrade-survivor/config-recipe.mjs b/scripts/e2e/lib/upgrade-survivor/config-recipe.mjs index b71ccdb99b6..28fe9db5c0f 100644 --- a/scripts/e2e/lib/upgrade-survivor/config-recipe.mjs +++ b/scripts/e2e/lib/upgrade-survivor/config-recipe.mjs @@ -68,6 +68,31 @@ const representativeConfigSteps = [ ), ]; +const scenarioConfigSteps = new Map([ + [ + "feishu-channel", + [ + configSetJsonFile("plugins-feishu", "plugins", "plugins", "plugins-feishu.json"), + configSetJsonFile( + "channels-feishu", + "feishu-channel", + "channels.feishu", + "channels-feishu.json", + ), + ], + ], + [ + "tilde-log-path", + [ + { + id: "logging-file", + intent: "logging", + argv: ["config", "set", "logging.file", "~/openclaw-upgrade-survivor/gateway.jsonl"], + }, + ], + ], +]); + const recipe = [ { id: "update-channel", @@ -83,6 +108,10 @@ const recipe = [ }, ]; +function selectedScenario() { + return process.env.OPENCLAW_UPGRADE_SURVIVOR_SCENARIO || "base"; +} + function runOpenClaw(step) { const result = spawnSync("openclaw", step.argv, { encoding: "utf8", @@ -103,10 +132,13 @@ function runOpenClaw(step) { function applyRecipe() { const summaryPath = option("--summary"); const baselineVersion = option("--baseline-version", null); + const scenario = selectedScenario(); + const scenarioSteps = scenarioConfigSteps.get(scenario) ?? []; const summary = { source: "baseline-cli-command-recipe", recipe: "upgrade-survivor-v1", baselineVersion, + scenario, acceptedIntents: [ "update", "gateway", @@ -117,12 +149,13 @@ function applyRecipe() { "discord-channel", "telegram-channel", "whatsapp-channel", + ...scenarioSteps.map((step) => step.intent), ], skippedIntents: [], steps: [], }; - for (const step of recipe) { + for (const step of [...recipe.slice(0, -1), ...scenarioSteps, recipe.at(-1)]) { const outcome = runOpenClaw(step); summary.steps.push(outcome); writeJson(summaryPath, summary); diff --git a/scripts/e2e/lib/upgrade-survivor/config-recipe/channels-feishu.json b/scripts/e2e/lib/upgrade-survivor/config-recipe/channels-feishu.json new file mode 100644 index 00000000000..e4341fea9a8 --- /dev/null +++ b/scripts/e2e/lib/upgrade-survivor/config-recipe/channels-feishu.json @@ -0,0 +1,37 @@ +{ + "enabled": true, + "domain": "feishu", + "connectionMode": "webhook", + "defaultAccount": "default", + "verificationToken": "upgrade-survivor-feishu-verification", + "encryptKey": "upgrade-survivor-feishu-encrypt", + "webhookPath": "/feishu/events", + "webhookHost": "127.0.0.1", + "webhookPort": 3000, + "accounts": { + "default": { + "enabled": true, + "name": "Upgrade Survivor Feishu", + "appId": "cli_upgrade_survivor", + "appSecret": { + "source": "env", + "provider": "default", + "id": "FEISHU_APP_SECRET" + } + } + }, + "dmPolicy": "allowlist", + "allowFrom": ["ou_upgrade_survivor"], + "groupPolicy": "allowlist", + "groupAllowFrom": ["oc_upgrade_survivor"], + "groups": { + "oc_upgrade_survivor": { + "enabled": true, + "requireMention": true, + "tools": { + "allow": ["message_send"], + "deny": ["exec"] + } + } + } +} diff --git a/scripts/e2e/lib/upgrade-survivor/config-recipe/plugins-feishu.json b/scripts/e2e/lib/upgrade-survivor/config-recipe/plugins-feishu.json new file mode 100644 index 00000000000..be6dc18a1f3 --- /dev/null +++ b/scripts/e2e/lib/upgrade-survivor/config-recipe/plugins-feishu.json @@ -0,0 +1,18 @@ +{ + "enabled": true, + "allow": ["discord", "feishu", "memory", "telegram", "whatsapp"], + "entries": { + "discord": { + "enabled": true + }, + "feishu": { + "enabled": true + }, + "telegram": { + "enabled": true + }, + "whatsapp": { + "enabled": true + } + } +} diff --git a/scripts/e2e/lib/upgrade-survivor/probe-gateway.mjs b/scripts/e2e/lib/upgrade-survivor/probe-gateway.mjs new file mode 100644 index 00000000000..e847cfeab50 --- /dev/null +++ b/scripts/e2e/lib/upgrade-survivor/probe-gateway.mjs @@ -0,0 +1,63 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; + +const args = process.argv.slice(2); + +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 writeJson(file, value) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); +} + +const baseUrl = option("--base-url"); +const probePath = option("--path"); +const expectKind = option("--expect"); +const out = option("--out"); +const url = new URL(probePath, baseUrl).toString(); + +const startedAt = Date.now(); +const response = await fetch(url, { method: "GET" }); +const text = await response.text(); +let body; +try { + body = text ? JSON.parse(text) : null; +} catch (error) { + throw new Error(`${url} returned non-JSON probe body: ${String(error)}`); +} +const elapsedMs = Date.now() - startedAt; + +if (!response.ok) { + throw new Error(`${url} probe failed with HTTP ${response.status}: ${text}`); +} +if (expectKind === "live") { + if (body?.ok !== true || body?.status !== "live") { + throw new Error(`${url} did not report live status: ${text}`); + } +} else if (expectKind === "ready") { + if (body?.ready !== true) { + throw new Error(`${url} did not report ready status: ${text}`); + } +} else { + throw new Error(`unknown probe expectation: ${expectKind}`); +} + +writeJson(out, { + body, + elapsedMs, + path: probePath, + status: response.status, + url, +}); diff --git a/scripts/e2e/lib/upgrade-survivor/run.sh b/scripts/e2e/lib/upgrade-survivor/run.sh index ffd460c8a06..a106b1c8783 100644 --- a/scripts/e2e/lib/upgrade-survivor/run.sh +++ b/scripts/e2e/lib/upgrade-survivor/run.sh @@ -16,6 +16,7 @@ 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" +export FEISHU_APP_SECRET="upgrade-survivor-feishu-secret" ARTIFACT_ROOT="$(dirname "${OPENCLAW_UPGRADE_SURVIVOR_SUMMARY_JSON:-/tmp/openclaw-upgrade-survivor-artifacts/summary.json}")" mkdir -p "$ARTIFACT_ROOT" @@ -33,6 +34,7 @@ PHASE_LOG="$ARTIFACT_ROOT/phases.jsonl" 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:-}}" +SCENARIO="${OPENCLAW_UPGRADE_SURVIVOR_SCENARIO:-base}" CURRENT_PHASE="setup" FAILURE_PHASE="" FAILURE_MESSAGE="" @@ -44,12 +46,16 @@ candidate_version="" installed_version="" start_seconds="" status_seconds="" +healthz_seconds="" +readyz_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" +HEALTHZ_JSON="$ARTIFACT_ROOT/healthz.json" +READYZ_JSON="$ARTIFACT_ROOT/readyz.json" STATUS_JSON="$ARTIFACT_ROOT/status.json" STATUS_ERR="$ARTIFACT_ROOT/status.err" BASELINE_CONFIG_VALIDATE_LOG="$ARTIFACT_ROOT/baseline-config-validate.log" @@ -128,7 +134,10 @@ write_summary() { SUMMARY_BASELINE_VERSION="$baseline_version" \ SUMMARY_CANDIDATE_VERSION="$candidate_version" \ SUMMARY_INSTALLED_VERSION="$installed_version" \ + SUMMARY_SCENARIO="$SCENARIO" \ SUMMARY_START_SECONDS="$start_seconds" \ + SUMMARY_HEALTHZ_SECONDS="$healthz_seconds" \ + SUMMARY_READYZ_SECONDS="$readyz_seconds" \ SUMMARY_STATUS_SECONDS="$status_seconds" \ SUMMARY_FAILURE_PHASE="$FAILURE_PHASE" \ SUMMARY_CONFIG_COVERAGE="$CONFIG_COVERAGE_JSON" \ @@ -153,6 +162,7 @@ const summary = { spec: process.env.SUMMARY_BASELINE_SPEC || null, version: process.env.SUMMARY_BASELINE_VERSION || null, }, + scenario: process.env.SUMMARY_SCENARIO || "base", 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, @@ -161,6 +171,8 @@ const summary = { installedVersion: process.env.SUMMARY_INSTALLED_VERSION || null, timings: { startupSeconds: numberOrNull(process.env.SUMMARY_START_SECONDS), + healthzSeconds: numberOrNull(process.env.SUMMARY_HEALTHZ_SECONDS), + readyzSeconds: numberOrNull(process.env.SUMMARY_READYZ_SECONDS), statusSeconds: numberOrNull(process.env.SUMMARY_STATUS_SECONDS), }, config: readJsonOrNull(process.env.SUMMARY_CONFIG_COVERAGE), @@ -273,6 +285,7 @@ install_baseline() { 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 + export OPENCLAW_UPGRADE_SURVIVOR_BASELINE_VERSION="$baseline_version" node scripts/e2e/lib/upgrade-survivor/assertions.mjs seed } @@ -291,8 +304,10 @@ validate_baseline_config() { } assert_baseline_state() { - node scripts/e2e/lib/upgrade-survivor/assertions.mjs assert-config - node scripts/e2e/lib/upgrade-survivor/assertions.mjs assert-state + OPENCLAW_UPGRADE_SURVIVOR_ASSERT_STAGE=baseline \ + node scripts/e2e/lib/upgrade-survivor/assertions.mjs assert-config + OPENCLAW_UPGRADE_SURVIVOR_ASSERT_STAGE=baseline \ + node scripts/e2e/lib/upgrade-survivor/assertions.mjs assert-state } resolve_candidate_version() { @@ -349,6 +364,14 @@ run_doctor() { fi } +validate_post_doctor_config() { + if ! openclaw config validate >>"$DOCTOR_LOG" 2>&1; then + echo "post-doctor config validation 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 @@ -359,6 +382,22 @@ assert_survival() { fi } +probe_gateway_endpoint() { + local path="$1" + local expect_kind="$2" + local out_file="$3" + local start_epoch + local end_epoch + start_epoch="$(node -e "process.stdout.write(String(Date.now()))")" + node scripts/e2e/lib/upgrade-survivor/probe-gateway.mjs \ + --base-url "http://127.0.0.1:18789" \ + --path "$path" \ + --expect "$expect_kind" \ + --out "$out_file" + end_epoch="$(node -e "process.stdout.write(String(Date.now()))")" + printf '%s\n' "$(((end_epoch - start_epoch + 999) / 1000))" +} + start_gateway() { local port=18789 local budget="${OPENCLAW_UPGRADE_SURVIVOR_START_BUDGET_SECONDS:-90}" @@ -377,6 +416,11 @@ start_gateway() { fi } +check_gateway_probes() { + healthz_seconds="$(probe_gateway_endpoint /healthz live "$HEALTHZ_JSON")" + readyz_seconds="$(probe_gateway_endpoint /readyz ready "$READYZ_JSON")" +} + check_gateway_status() { local port=18789 local budget="${OPENCLAW_UPGRADE_SURVIVOR_STATUS_BUDGET_SECONDS:-30}" @@ -409,8 +453,10 @@ phase assert-baseline assert_baseline_state phase resolve-candidate resolve_candidate_version phase update-candidate update_candidate phase doctor run_doctor +phase validate-post-doctor-config validate_post_doctor_config phase assert-survival assert_survival phase gateway-start start_gateway +phase gateway-probes check_gateway_probes 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." +echo "Upgrade survivor Docker E2E passed baseline=${baseline_spec} scenario=${SCENARIO} candidate=${candidate_version} startup=${start_seconds}s healthz=${healthz_seconds}s readyz=${readyz_seconds}s status=${status_seconds}s." diff --git a/scripts/e2e/upgrade-survivor-docker.sh b/scripts/e2e/upgrade-survivor-docker.sh index 6afebbbee35..0f301ae0b94 100755 --- a/scripts/e2e/upgrade-survivor-docker.sh +++ b/scripts/e2e/upgrade-survivor-docker.sh @@ -12,6 +12,7 @@ IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-upgrade-survivor-e2e" OPENCLAW_ SKIP_BUILD="${OPENCLAW_UPGRADE_SURVIVOR_E2E_SKIP_BUILD:-0}" DOCKER_RUN_TIMEOUT="${OPENCLAW_UPGRADE_SURVIVOR_DOCKER_RUN_TIMEOUT:-900s}" BASELINE_SPEC="${OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC:-}" +SCENARIO="${OPENCLAW_UPGRADE_SURVIVOR_SCENARIO:-base}" ARTIFACT_DIR="${OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_DIR:-$ROOT_DIR/.artifacts/upgrade-survivor}" normalize_npm_candidate() { @@ -81,6 +82,7 @@ if [ "${OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE:-0}" = "1" ]; then -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_SCENARIO="$SCENARIO" \ -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}" \ @@ -103,6 +105,7 @@ 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_SCENARIO="$SCENARIO" \ -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" \ @@ -134,6 +137,7 @@ 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" +export FEISHU_APP_SECRET="upgrade-survivor-feishu-secret" gateway_pid="" cleanup() { @@ -153,8 +157,8 @@ OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT="$( 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 +OPENCLAW_UPGRADE_SURVIVOR_ASSERT_STAGE=baseline node scripts/e2e/lib/upgrade-survivor/assertions.mjs assert-config +OPENCLAW_UPGRADE_SURVIVOR_ASSERT_STAGE=baseline node scripts/e2e/lib/upgrade-survivor/assertions.mjs assert-state echo "Running package update against the mounted tarball..." set +e @@ -174,6 +178,11 @@ if ! openclaw doctor --fix --non-interactive >/tmp/openclaw-upgrade-survivor-doc cat /tmp/openclaw-upgrade-survivor-doctor.log >&2 || true exit 1 fi +if ! openclaw config validate >>/tmp/openclaw-upgrade-survivor-doctor.log 2>&1; then + echo "post-doctor config validation 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 @@ -196,6 +205,18 @@ if [ "$start_seconds" -gt "$START_BUDGET" ]; then exit 1 fi +echo "Checking gateway HTTP probes..." +node scripts/e2e/lib/upgrade-survivor/probe-gateway.mjs \ + --base-url "http://127.0.0.1:$PORT" \ + --path /healthz \ + --expect live \ + --out /tmp/openclaw-upgrade-survivor-healthz.json +node scripts/e2e/lib/upgrade-survivor/probe-gateway.mjs \ + --base-url "http://127.0.0.1:$PORT" \ + --path /readyz \ + --expect ready \ + --out /tmp/openclaw-upgrade-survivor-readyz.json + 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 @@ -213,5 +234,5 @@ if [ "$status_seconds" -gt "$STATUS_BUDGET" ]; then 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." +echo "Upgrade survivor Docker E2E passed scenario=${OPENCLAW_UPGRADE_SURVIVOR_SCENARIO:-base} startup=${start_seconds}s status=${status_seconds}s." ' diff --git a/scripts/lib/docker-e2e-plan.mjs b/scripts/lib/docker-e2e-plan.mjs index 92668da865e..234ef7ae1d6 100644 --- a/scripts/lib/docker-e2e-plan.mjs +++ b/scripts/lib/docker-e2e-plan.mjs @@ -70,6 +70,19 @@ function sanitizeLaneNameSuffix(value) { ); } +export const UPGRADE_SURVIVOR_SCENARIOS = [ + "base", + "feishu-channel", + "bootstrap-persona", + "tilde-log-path", + "versioned-runtime-deps", +]; + +const UPGRADE_SURVIVOR_SCENARIO_ALIASES = new Map([ + ["reported-issues", UPGRADE_SURVIVOR_SCENARIOS], + ["far-reaching", UPGRADE_SURVIVOR_SCENARIOS], +]); + export function normalizeUpgradeSurvivorBaselineSpec(raw) { const value = String(raw ?? "").trim(); if (!value) { @@ -102,26 +115,75 @@ export function parseUpgradeSurvivorBaselineSpecs(raw) { ]; } -export function expandUpgradeSurvivorBaselineLanes(poolLanes, rawBaselineSpecs) { +export function normalizeUpgradeSurvivorScenario(raw) { + const value = String(raw ?? "").trim(); + if (!value) { + return undefined; + } + if (!UPGRADE_SURVIVOR_SCENARIOS.includes(value)) { + throw new Error( + `invalid published upgrade survivor scenario: ${JSON.stringify( + value, + )}. Expected one of: ${UPGRADE_SURVIVOR_SCENARIOS.join(", ")}, reported-issues.`, + ); + } + return value; +} + +export function parseUpgradeSurvivorScenarios(raw) { + if (!raw) { + return []; + } + return [ + ...new Set( + String(raw) + .split(/[,\s]+/u) + .map((token) => token.trim()) + .filter(Boolean) + .flatMap((token) => UPGRADE_SURVIVOR_SCENARIO_ALIASES.get(token) ?? [token]) + .map(normalizeUpgradeSurvivorScenario) + .filter(Boolean), + ), + ]; +} + +export function expandUpgradeSurvivorBaselineLanes(poolLanes, rawBaselineSpecs, rawScenarios = "") { const baselineSpecs = parseUpgradeSurvivorBaselineSpecs(rawBaselineSpecs); - if (baselineSpecs.length === 0) { + const scenarios = parseUpgradeSurvivorScenarios(rawScenarios); + if (baselineSpecs.length === 0 && scenarios.length === 0) { return poolLanes; } return poolLanes.flatMap((poolLane) => { if (poolLane.name !== "published-upgrade-survivor") { return [poolLane]; } - return baselineSpecs.map((baselineSpec) => { - const suffix = sanitizeLaneNameSuffix(baselineSpec); - const name = `${poolLane.name}-${suffix}`; - return Object.assign({}, poolLane, { - cacheKey: poolLane.cacheKey ? `${poolLane.cacheKey}-${suffix}` : name, - command: `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC=${shellQuote( - baselineSpec, - )} ${poolLane.command}`, - name, - }); - }); + const matrixBaselines = baselineSpecs.length > 0 ? baselineSpecs : [undefined]; + const matrixScenarios = scenarios.length > 0 ? scenarios : [undefined]; + return matrixBaselines.flatMap((baselineSpec) => + matrixScenarios.map((scenario) => { + const suffixParts = [ + baselineSpec ? sanitizeLaneNameSuffix(baselineSpec) : "", + scenario && scenario !== "base" ? sanitizeLaneNameSuffix(scenario) : "", + ].filter(Boolean); + const suffix = suffixParts.join("-"); + const name = suffix ? `${poolLane.name}-${suffix}` : poolLane.name; + const commandPrefix = [ + baselineSpec ? `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC=${shellQuote(baselineSpec)}` : "", + scenario ? `OPENCLAW_UPGRADE_SURVIVOR_SCENARIO=${shellQuote(scenario)}` : "", + ] + .filter(Boolean) + .join(" "); + return Object.assign({}, poolLane, { + cacheKey: poolLane.cacheKey + ? suffix + ? `${poolLane.cacheKey}-${suffix}` + : poolLane.cacheKey + : name, + command: commandPrefix ? `${commandPrefix} ${poolLane.command}` : poolLane.command, + name, + }); + }), + ); }); } @@ -213,6 +275,7 @@ export function findLaneByName(name) { expandUpgradeSurvivorBaselineLanes( [...allReleasePathLanes({ includeOpenWebUI: true }), ...mainLanes, ...tailLanes], process.env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS, + process.env.OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS, ), ).find((poolLane) => poolLane.name === name); } @@ -277,13 +340,18 @@ export function resolveDockerE2ePlan(options) { const retriedMainLanes = applyLiveRetries(mainLanes, options.liveRetries); const retriedTailLanes = applyLiveRetries(tailLanes, options.liveRetries); const upgradeSurvivorBaselines = options.upgradeSurvivorBaselines ?? ""; + const upgradeSurvivorScenarios = options.upgradeSurvivorScenarios ?? ""; const unexpandedSelectableLanes = dedupeLanes([ ...allReleasePathLanes({ includeOpenWebUI: options.includeOpenWebUI }), ...retriedMainLanes, ...retriedTailLanes, ]); const selectableLanes = dedupeLanes( - expandUpgradeSurvivorBaselineLanes(unexpandedSelectableLanes, upgradeSurvivorBaselines), + expandUpgradeSurvivorBaselineLanes( + unexpandedSelectableLanes, + upgradeSurvivorBaselines, + upgradeSurvivorScenarios, + ), ); const releaseLanes = options.selectedLaneNames.length === 0 && options.profile === RELEASE_PATH_PROFILE @@ -291,12 +359,14 @@ export function resolveDockerE2ePlan(options) { ? expandUpgradeSurvivorBaselineLanes( allReleasePathLanes({ includeOpenWebUI: options.includeOpenWebUI }), upgradeSurvivorBaselines, + upgradeSurvivorScenarios, ) : expandUpgradeSurvivorBaselineLanes( releasePathChunkLanes(options.releaseChunk, { includeOpenWebUI: options.includeOpenWebUI, }), upgradeSurvivorBaselines, + upgradeSurvivorScenarios, ) : undefined; const selectedLanes = @@ -310,7 +380,11 @@ export function resolveDockerE2ePlan(options) { (poolLane) => poolLane.name === selectedName, ); if (unexpandedLane) { - return expandUpgradeSurvivorBaselineLanes([unexpandedLane], upgradeSurvivorBaselines); + return expandUpgradeSurvivorBaselineLanes( + [unexpandedLane], + upgradeSurvivorBaselines, + upgradeSurvivorScenarios, + ); } selectNamedLanes(selectableLanes, [selectedName], "OPENCLAW_DOCKER_ALL_LANES"); return []; diff --git a/scripts/test-docker-all.mjs b/scripts/test-docker-all.mjs index b8ce6f5225b..237c2b04b60 100644 --- a/scripts/test-docker-all.mjs +++ b/scripts/test-docker-all.mjs @@ -234,6 +234,12 @@ function githubWorkflowRerunCommand(laneNames, ref) { `published_upgrade_survivor_baselines=${shellQuote(process.env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS)}`, ); } + if (process.env.OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS) { + fields.push( + "-f", + `published_upgrade_survivor_scenarios=${shellQuote(process.env.OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS)}`, + ); + } if (process.env.OPENCLAW_DOCKER_E2E_BARE_IMAGE) { fields.push( "-f", @@ -264,6 +270,7 @@ function buildLaneRerunCommand(name, baseEnv) { ["OPENCLAW_CURRENT_PACKAGE_TGZ", baseEnv.OPENCLAW_CURRENT_PACKAGE_TGZ], ["OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC", baseEnv.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC], ["OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS", baseEnv.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS], + ["OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS", baseEnv.OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS], ]; if (baseEnv.OPENCLAW_DOCKER_ALL_PNPM_COMMAND) { env.push(["OPENCLAW_DOCKER_ALL_PNPM_COMMAND", baseEnv.OPENCLAW_DOCKER_ALL_PNPM_COMMAND]); @@ -1133,6 +1140,7 @@ async function main() { selectedLaneNames, timingStore, upgradeSurvivorBaselines: process.env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS, + upgradeSurvivorScenarios: process.env.OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS, }); if (planJson) { diff --git a/test/scripts/docker-e2e-plan.test.ts b/test/scripts/docker-e2e-plan.test.ts index 3917adcd81b..fe38ae99dfa 100644 --- a/test/scripts/docker-e2e-plan.test.ts +++ b/test/scripts/docker-e2e-plan.test.ts @@ -384,6 +384,49 @@ describe("scripts/lib/docker-e2e-plan", () => { ]); }); + it("expands the published upgrade survivor lane across scenarios", () => { + const plan = planFor({ + selectedLaneNames: ["published-upgrade-survivor"], + upgradeSurvivorBaselines: "2026.4.29 2026.4.23", + upgradeSurvivorScenarios: "base feishu-channel tilde-log-path", + }); + + expect(plan.lanes.map((lane) => lane.name)).toEqual([ + "published-upgrade-survivor-2026.4.29", + "published-upgrade-survivor-2026.4.29-feishu-channel", + "published-upgrade-survivor-2026.4.29-tilde-log-path", + "published-upgrade-survivor-2026.4.23", + "published-upgrade-survivor-2026.4.23-feishu-channel", + "published-upgrade-survivor-2026.4.23-tilde-log-path", + ]); + expect(plan.lanes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + command: expect.stringContaining("OPENCLAW_UPGRADE_SURVIVOR_SCENARIO='feishu-channel'"), + }), + expect.objectContaining({ + command: expect.stringContaining("OPENCLAW_UPGRADE_SURVIVOR_SCENARIO='tilde-log-path'"), + }), + ]), + ); + }); + + it("expands reported upgrade issue scenarios", () => { + const plan = planFor({ + selectedLaneNames: ["published-upgrade-survivor"], + upgradeSurvivorBaselines: "2026.4.29", + upgradeSurvivorScenarios: "reported-issues", + }); + + expect(plan.lanes.map((lane) => lane.name)).toEqual([ + "published-upgrade-survivor-2026.4.29", + "published-upgrade-survivor-2026.4.29-feishu-channel", + "published-upgrade-survivor-2026.4.29-bootstrap-persona", + "published-upgrade-survivor-2026.4.29-tilde-log-path", + "published-upgrade-survivor-2026.4.29-versioned-runtime-deps", + ]); + }); + it("plans a live-only selected lane without package e2e images", () => { const plan = planFor({ selectedLaneNames: ["live-models"] }); diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 1b47769adfa..b6fe17dec0c 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -43,6 +43,7 @@ describe("package acceptance workflow", () => { expect(workflow).toContain("suite_profile:"); expect(workflow).toContain("published_upgrade_survivor_baseline:"); expect(workflow).toContain("published_upgrade_survivor_baselines:"); + expect(workflow).toContain("published_upgrade_survivor_scenarios:"); expect(workflow).toContain("scripts/resolve-upgrade-survivor-baselines.mjs"); expect(workflow).toContain("--history-count 6"); expect(workflow).toContain("--include-version 2026.4.23"); @@ -76,8 +77,12 @@ describe("package acceptance workflow", () => { expect(workflow).toContain( "published_upgrade_survivor_baselines: ${{ needs.resolve_package.outputs.published_upgrade_survivor_baselines }}", ); + expect(workflow).toContain( + "published_upgrade_survivor_scenarios: ${{ needs.resolve_package.outputs.published_upgrade_survivor_scenarios }}", + ); expect(workflow).toContain("Published upgrade survivor baseline:"); expect(workflow).toContain("Published upgrade survivor baselines:"); + expect(workflow).toContain("Published upgrade survivor scenarios:"); }); }); @@ -92,6 +97,7 @@ describe("package artifact reuse", () => { expect(workflow).toContain("package_artifact_run_id:"); expect(workflow).toContain("published_upgrade_survivor_baseline:"); expect(workflow).toContain("published_upgrade_survivor_baselines:"); + expect(workflow).toContain("published_upgrade_survivor_scenarios:"); expect(workflow).toContain("docker_e2e_bare_image:"); expect(workflow).toContain("docker_e2e_functional_image:"); expect(workflow).toContain("OPENCLAW_DOCKER_E2E_SELECTED_SHA:"); @@ -101,6 +107,9 @@ describe("package artifact reuse", () => { expect(workflow).toContain( "OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS: ${{ inputs.published_upgrade_survivor_baselines }}", ); + expect(workflow).toContain( + "OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS: ${{ inputs.published_upgrade_survivor_scenarios }}", + ); 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 != ''"); @@ -133,9 +142,11 @@ describe("package artifact reuse", () => { '["OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC", baseEnv.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC]', ); expect(scheduler).toContain('["OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS",'); + expect(scheduler).toContain('["OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS",'); expect(packageJson).toContain("OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE=1"); expect(publishedUpgradeSurvivor).toContain("validate_baseline_package_spec"); expect(publishedUpgradeSurvivor).toContain("openclaw@(beta|latest|"); + expect(publishedUpgradeSurvivor).toContain("probe_gateway_endpoint"); expect( publishedUpgradeSurvivor.indexOf('validate_baseline_package_spec "$baseline_spec"'), ).toBeLessThan(