diff --git a/package.json b/package.json index ba3f9def3e5..c62824334d5 100644 --- a/package.json +++ b/package.json @@ -1400,6 +1400,7 @@ "lint:auth:no-pairing-store-group": "node scripts/check-no-pairing-store-group-auth.mjs", "lint:auth:pairing-account-scope": "node scripts/check-pairing-account-scope.mjs", "lint:core": "node scripts/run-oxlint.mjs --tsconfig tsconfig.oxlint.core.json src ui packages", + "lint:docker-e2e": "node scripts/check-docker-e2e-boundaries.mjs", "lint:docs": "pnpm dlx markdownlint-cli2", "lint:docs:fix": "pnpm dlx markdownlint-cli2 --fix", "lint:extensions": "node scripts/run-oxlint.mjs --tsconfig tsconfig.oxlint.extensions.json extensions", @@ -1415,7 +1416,7 @@ "lint:plugins:no-monolithic-plugin-sdk-entry-imports": "node --import tsx scripts/check-no-monolithic-plugin-sdk-entry-imports.ts", "lint:plugins:no-register-http-handler": "node scripts/check-no-register-http-handler.mjs", "lint:plugins:plugin-sdk-subpaths-exported": "node scripts/check-plugin-sdk-subpath-exports.mjs", - "lint:scripts": "node scripts/run-oxlint.mjs --tsconfig tsconfig.oxlint.scripts.json scripts", + "lint:scripts": "pnpm lint:docker-e2e && node scripts/run-oxlint.mjs --tsconfig tsconfig.oxlint.scripts.json scripts", "lint:swift": "swiftlint lint --config .swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)", "lint:tmp:channel-agnostic-boundaries": "node scripts/check-channel-agnostic-boundaries.mjs", "lint:tmp:dynamic-import-warts": "node scripts/check-dynamic-import-warts.mjs", diff --git a/scripts/check-docker-e2e-boundaries.mjs b/scripts/check-docker-e2e-boundaries.mjs new file mode 100644 index 00000000000..885b248de14 --- /dev/null +++ b/scripts/check-docker-e2e-boundaries.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node +// Cheap guard for Docker E2E test boundaries. +// Docker E2E must test packaged npm tarballs and package-installed images, not +// the source checkout copied or mounted as the app under test. +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const errors = []; + +function readText(relativePath) { + return fs.readFileSync(path.join(ROOT_DIR, relativePath), "utf8"); +} + +function walk(dir, out = []) { + for (const entry of fs.readdirSync(path.join(ROOT_DIR, dir), { withFileTypes: true })) { + const relativePath = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(relativePath, out); + } else { + out.push(relativePath); + } + } + return out; +} + +for (const relativePath of walk("scripts/e2e")) { + if (!/\.(?:sh|ts|mjs|js)$/u.test(relativePath)) { + continue; + } + const text = readText(relativePath); + if (/from\s+["']\.\.\/\.\.\/src\//u.test(text) || /import\(["']\.\.\/\.\.\/src\//u.test(text)) { + errors.push(`${relativePath}: Docker E2E harness must import built dist, not ../../src`); + } + if (/-v\s+["']?\$ROOT_DIR:\/app(?::|["'\s]|$)/u.test(text)) { + errors.push(`${relativePath}: do not mount the repo root as /app in Docker E2E`); + } +} + +const dockerfile = readText("scripts/e2e/Dockerfile"); +if (/^\s*(?:COPY|ADD)\s+\.\s+\/app(?:\s|$)/imu.test(dockerfile)) { + errors.push("scripts/e2e/Dockerfile: do not copy the source checkout into /app"); +} + +if (errors.length > 0) { + console.error(errors.join("\n")); + process.exit(1); +} + +console.log("Docker E2E package boundary guard passed."); diff --git a/scripts/docker-e2e.mjs b/scripts/docker-e2e.mjs new file mode 100644 index 00000000000..753e720b56d --- /dev/null +++ b/scripts/docker-e2e.mjs @@ -0,0 +1,86 @@ +// Docker E2E CI helper. +// Converts scheduler JSON into GitHub Actions outputs and compact markdown +// summaries so the workflow does not duplicate Docker E2E planning logic. +import fs from "node:fs"; + +function usage() { + return [ + "Usage:", + " node scripts/docker-e2e.mjs github-outputs ", + " node scripts/docker-e2e.mjs summary ", + ].join("\n"); +} + +function readJson(file) { + return JSON.parse(fs.readFileSync(file, "utf8")); +} + +function boolOutput(value) { + return value ? "1" : "0"; +} + +function githubOutputs(plan) { + const needs = plan.needs ?? {}; + return [ + `credentials=${(plan.credentials ?? []).join(",")}`, + `needs_bare_image=${boolOutput(needs.bareImage)}`, + `needs_e2e_image=${boolOutput(needs.e2eImage)}`, + `needs_functional_image=${boolOutput(needs.functionalImage)}`, + `needs_live_image=${boolOutput(needs.liveImage)}`, + `needs_package=${boolOutput(needs.package)}`, + ]; +} + +function markdownCell(value) { + return String(value ?? "").replaceAll("|", "\\|"); +} + +function inlineCode(value) { + return `\`${String(value ?? "").replaceAll("`", "\\`")}\``; +} + +function summaryMarkdown(summary, title) { + const lanes = Array.isArray(summary.lanes) ? summary.lanes : []; + const lines = [ + `### ${title}`, + "", + `Status: ${inlineCode(summary.status)}`, + "", + "| Lane | Status | Seconds | Timed out | Rerun |", + "| --- | ---: | ---: | --- | --- |", + ]; + for (const lane of lanes) { + const status = lane.status === 0 ? "pass" : `fail ${lane.status}`; + lines.push( + `| ${inlineCode(lane.name)} | ${markdownCell(status)} | ${markdownCell(lane.elapsedSeconds)} | ${lane.timedOut ? "yes" : "no"} | ${inlineCode(lane.rerunCommand)} |`, + ); + } + + const phases = Array.isArray(summary.phases) ? summary.phases : []; + if (phases.length > 0) { + lines.push("", "| Phase | Seconds | Status | Image kind |", "| --- | ---: | --- | --- |"); + for (const phase of phases) { + lines.push( + `| ${inlineCode(phase.name)} | ${markdownCell(phase.elapsedSeconds)} | ${markdownCell(phase.status)} | ${markdownCell(phase.imageKind)} |`, + ); + } + } + return lines.join("\n"); +} + +const [command, file, ...args] = process.argv.slice(2); +if (!command || !file) { + throw new Error(usage()); +} + +if (command === "github-outputs") { + process.stdout.write(`${githubOutputs(readJson(file)).join("\n")}\n`); +} else if (command === "summary") { + const title = args.join(" ").trim(); + if (!title) { + throw new Error(usage()); + } + process.stdout.write(`${summaryMarkdown(readJson(file), title)}\n`); +} else { + throw new Error(`unknown command: ${command}\n${usage()}`); +} diff --git a/scripts/e2e/bundled-channel-runtime-deps-docker.sh b/scripts/e2e/bundled-channel-runtime-deps-docker.sh index 3eff712ab98..706104a13ec 100644 --- a/scripts/e2e/bundled-channel-runtime-deps-docker.sh +++ b/scripts/e2e/bundled-channel-runtime-deps-docker.sh @@ -12,7 +12,7 @@ IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-bundled-channel-deps-e2e" OPENC UPDATE_BASELINE_VERSION="${OPENCLAW_BUNDLED_CHANNEL_UPDATE_BASELINE_VERSION:-2026.4.20}" DOCKER_TARGET="${OPENCLAW_BUNDLED_CHANNEL_DOCKER_TARGET:-bare}" HOST_BUILD="${OPENCLAW_BUNDLED_CHANNEL_HOST_BUILD:-1}" -PACKAGE_TGZ="${OPENCLAW_BUNDLED_CHANNEL_PACKAGE_TGZ:-}" +PACKAGE_TGZ="${OPENCLAW_CURRENT_PACKAGE_TGZ:-}" RUN_CHANNEL_SCENARIOS="${OPENCLAW_BUNDLED_CHANNEL_SCENARIOS:-1}" RUN_UPDATE_SCENARIO="${OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO:-1}" RUN_ROOT_OWNED_SCENARIO="${OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO:-1}" @@ -30,15 +30,14 @@ prepare_package_tgz() { return 0 fi if [ "$HOST_BUILD" = "0" ] && [ -z "${OPENCLAW_CURRENT_PACKAGE_TGZ:-}" ]; then - echo "OPENCLAW_BUNDLED_CHANNEL_HOST_BUILD=0 requires OPENCLAW_CURRENT_PACKAGE_TGZ or OPENCLAW_BUNDLED_CHANNEL_PACKAGE_TGZ" >&2 + echo "OPENCLAW_BUNDLED_CHANNEL_HOST_BUILD=0 requires OPENCLAW_CURRENT_PACKAGE_TGZ" >&2 exit 1 fi PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz bundled-channel-deps)" } prepare_package_tgz -DOCKER_PACKAGE_TGZ="/tmp/openclaw-current.tgz" -PACKAGE_DOCKER_ARGS=(-v "$PACKAGE_TGZ:$DOCKER_PACKAGE_TGZ:ro" -e "OPENCLAW_CURRENT_PACKAGE_TGZ=$DOCKER_PACKAGE_TGZ") +docker_e2e_package_mount_args "$PACKAGE_TGZ" run_channel_scenario() { local channel="$1" @@ -51,7 +50,7 @@ run_channel_scenario() { -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ -e OPENCLAW_CHANNEL_UNDER_TEST="$channel" \ -e OPENCLAW_DEP_SENTINEL="$dep_sentinel" \ - "${PACKAGE_DOCKER_ARGS[@]}" \ + "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ -i "$IMAGE_NAME" bash -s >"$run_log" 2>&1 <<'EOF' set -euo pipefail @@ -463,7 +462,7 @@ run_root_owned_global_scenario() { echo "Running bundled channel root-owned global install Docker E2E..." if ! timeout "$DOCKER_RUN_TIMEOUT" docker run --rm --user root \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ - "${PACKAGE_DOCKER_ARGS[@]}" \ + "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ -i "$IMAGE_NAME" bash -s >"$run_log" 2>&1 <<'EOF' set -euo pipefail @@ -640,7 +639,7 @@ run_setup_entry_scenario() { echo "Running bundled channel setup-entry runtime deps Docker E2E..." if ! timeout "$DOCKER_RUN_TIMEOUT" docker run --rm \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ - "${PACKAGE_DOCKER_ARGS[@]}" \ + "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ -i "$IMAGE_NAME" bash -s >"$run_log" 2>&1 <<'EOF' set -euo pipefail @@ -897,7 +896,7 @@ run_disabled_config_scenario() { echo "Running bundled channel disabled-config runtime deps Docker E2E..." if ! timeout "$DOCKER_RUN_TIMEOUT" docker run --rm \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ - "${PACKAGE_DOCKER_ARGS[@]}" \ + "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ -i "$IMAGE_NAME" bash -s >"$run_log" 2>&1 <<'EOF' set -euo pipefail @@ -1064,7 +1063,7 @@ run_update_scenario() { -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ -e OPENCLAW_BUNDLED_CHANNEL_UPDATE_BASELINE_VERSION="$UPDATE_BASELINE_VERSION" \ -e "OPENCLAW_BUNDLED_CHANNEL_UPDATE_TARGETS=${OPENCLAW_BUNDLED_CHANNEL_UPDATE_TARGETS:-telegram,discord,slack,feishu,memory-lancedb,acpx}" \ - "${PACKAGE_DOCKER_ARGS[@]}" \ + "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ -i "$IMAGE_NAME" bash -s >"$run_log" 2>&1 <<'EOF' set -euo pipefail @@ -1496,7 +1495,7 @@ run_load_failure_scenario() { echo "Running bundled channel load-failure isolation Docker E2E..." if ! timeout "$DOCKER_RUN_TIMEOUT" docker run --rm \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ - "${PACKAGE_DOCKER_ARGS[@]}" \ + "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ -i "$IMAGE_NAME" bash -s >"$run_log" 2>&1 <<'EOF' set -euo pipefail diff --git a/scripts/e2e/crestodian-first-run-docker.sh b/scripts/e2e/crestodian-first-run-docker.sh index eb1709e5991..f9292c3b17a 100644 --- a/scripts/e2e/crestodian-first-run-docker.sh +++ b/scripts/e2e/crestodian-first-run-docker.sh @@ -16,6 +16,7 @@ cleanup() { trap cleanup EXIT docker_e2e_build_or_reuse "$IMAGE_NAME" crestodian-first-run +docker_e2e_harness_mount_args echo "Running in-container Crestodian first-run smoke..." # Harness files are mounted read-only; the app under test comes from /app/dist. @@ -24,7 +25,7 @@ docker run --rm \ --name "$CONTAINER_NAME" \ -e "OPENCLAW_STATE_DIR=/tmp/openclaw-state" \ -e "OPENCLAW_CONFIG_PATH=/tmp/openclaw-state/openclaw.json" \ - -v "$ROOT_DIR/scripts/e2e:/app/scripts/e2e:ro" \ + "${DOCKER_E2E_HARNESS_ARGS[@]}" \ "$IMAGE_NAME" \ bash -lc "set -euo pipefail node --import tsx scripts/e2e/crestodian-first-run-docker-client.ts diff --git a/scripts/e2e/crestodian-planner-docker.sh b/scripts/e2e/crestodian-planner-docker.sh index a3a9352ee5a..cad3272ad48 100755 --- a/scripts/e2e/crestodian-planner-docker.sh +++ b/scripts/e2e/crestodian-planner-docker.sh @@ -16,6 +16,7 @@ cleanup() { trap cleanup EXIT docker_e2e_build_or_reuse "$IMAGE_NAME" crestodian-planner +docker_e2e_harness_mount_args echo "Running in-container Crestodian planner fallback smoke..." # Harness files are mounted read-only; the app under test comes from /app/dist. @@ -24,7 +25,7 @@ docker run --rm \ --name "$CONTAINER_NAME" \ -e "OPENCLAW_STATE_DIR=/tmp/openclaw-state" \ -e "OPENCLAW_CONFIG_PATH=/tmp/openclaw-state/openclaw.json" \ - -v "$ROOT_DIR/scripts/e2e:/app/scripts/e2e:ro" \ + "${DOCKER_E2E_HARNESS_ARGS[@]}" \ "$IMAGE_NAME" \ bash -lc "set -euo pipefail node --import tsx scripts/e2e/crestodian-planner-docker-client.ts diff --git a/scripts/e2e/crestodian-rescue-docker.sh b/scripts/e2e/crestodian-rescue-docker.sh index c45b1274937..4db23f4be82 100755 --- a/scripts/e2e/crestodian-rescue-docker.sh +++ b/scripts/e2e/crestodian-rescue-docker.sh @@ -16,6 +16,7 @@ cleanup() { trap cleanup EXIT docker_e2e_build_or_reuse "$IMAGE_NAME" crestodian-rescue +docker_e2e_harness_mount_args echo "Running in-container Crestodian rescue smoke..." # Harness files are mounted read-only; the app under test comes from /app/dist. @@ -24,7 +25,7 @@ docker run --rm \ --name "$CONTAINER_NAME" \ -e "OPENCLAW_STATE_DIR=/tmp/openclaw-state" \ -e "OPENCLAW_CONFIG_PATH=/tmp/openclaw-state/openclaw.json" \ - -v "$ROOT_DIR/scripts/e2e:/app/scripts/e2e:ro" \ + "${DOCKER_E2E_HARNESS_ARGS[@]}" \ "$IMAGE_NAME" \ bash -lc "set -euo pipefail node --import tsx scripts/e2e/crestodian-rescue-docker-client.ts diff --git a/scripts/e2e/cron-mcp-cleanup-docker.sh b/scripts/e2e/cron-mcp-cleanup-docker.sh index 7ae872451e6..eca96a8f175 100644 --- a/scripts/e2e/cron-mcp-cleanup-docker.sh +++ b/scripts/e2e/cron-mcp-cleanup-docker.sh @@ -18,6 +18,7 @@ cleanup() { trap cleanup EXIT docker_e2e_build_or_reuse "$IMAGE_NAME" cron-mcp-cleanup +docker_e2e_harness_mount_args echo "Running in-container cron/subagent MCP cleanup smoke..." # Harness files are mounted read-only; the app under test comes from /app/dist. @@ -36,7 +37,7 @@ docker run --rm \ -e "GW_URL=ws://127.0.0.1:$PORT" \ -e "GW_TOKEN=$TOKEN" \ -e "OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1" \ - -v "$ROOT_DIR/scripts/e2e:/app/scripts/e2e:ro" \ + "${DOCKER_E2E_HARNESS_ARGS[@]}" \ "$IMAGE_NAME" \ bash -lc "set -euo pipefail entry=dist/index.mjs diff --git a/scripts/e2e/mcp-channels-docker.sh b/scripts/e2e/mcp-channels-docker.sh index 4b8f2db490b..d8d214ef2c2 100644 --- a/scripts/e2e/mcp-channels-docker.sh +++ b/scripts/e2e/mcp-channels-docker.sh @@ -18,6 +18,7 @@ cleanup() { trap cleanup EXIT docker_e2e_build_or_reuse "$IMAGE_NAME" mcp-channels +docker_e2e_harness_mount_args echo "Running in-container gateway + MCP smoke..." # Harness files are mounted read-only; the app under test comes from /app/dist. @@ -36,7 +37,7 @@ docker run --rm \ -e "GW_URL=ws://127.0.0.1:$PORT" \ -e "GW_TOKEN=$TOKEN" \ -e "OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1" \ - -v "$ROOT_DIR/scripts/e2e:/app/scripts/e2e:ro" \ + "${DOCKER_E2E_HARNESS_ARGS[@]}" \ "$IMAGE_NAME" \ bash -lc "set -euo pipefail entry=dist/index.mjs diff --git a/scripts/e2e/npm-onboard-channel-agent-docker.sh b/scripts/e2e/npm-onboard-channel-agent-docker.sh index 8e95d2467f0..891d840eed7 100644 --- a/scripts/e2e/npm-onboard-channel-agent-docker.sh +++ b/scripts/e2e/npm-onboard-channel-agent-docker.sh @@ -10,7 +10,7 @@ source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh" IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-npm-onboard-channel-agent-e2e" OPENCLAW_NPM_ONBOARD_E2E_IMAGE)" DOCKER_TARGET="${OPENCLAW_NPM_ONBOARD_DOCKER_TARGET:-bare}" HOST_BUILD="${OPENCLAW_NPM_ONBOARD_HOST_BUILD:-1}" -PACKAGE_TGZ="${OPENCLAW_NPM_ONBOARD_PACKAGE_TGZ:-}" +PACKAGE_TGZ="${OPENCLAW_CURRENT_PACKAGE_TGZ:-}" CHANNEL="${OPENCLAW_NPM_ONBOARD_CHANNEL:-telegram}" case "$CHANNEL" in @@ -29,7 +29,7 @@ prepare_package_tgz() { return 0 fi if [ "$HOST_BUILD" = "0" ] && [ -z "${OPENCLAW_CURRENT_PACKAGE_TGZ:-}" ]; then - echo "OPENCLAW_NPM_ONBOARD_HOST_BUILD=0 requires OPENCLAW_CURRENT_PACKAGE_TGZ or OPENCLAW_NPM_ONBOARD_PACKAGE_TGZ" >&2 + echo "OPENCLAW_NPM_ONBOARD_HOST_BUILD=0 requires OPENCLAW_CURRENT_PACKAGE_TGZ" >&2 exit 1 fi PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz npm-onboard-channel-agent)" @@ -37,16 +37,16 @@ prepare_package_tgz() { prepare_package_tgz -DOCKER_PACKAGE_TGZ="/tmp/openclaw-current.tgz" +docker_e2e_package_mount_args "$PACKAGE_TGZ" +docker_e2e_harness_mount_args run_log="$(mktemp "${TMPDIR:-/tmp}/openclaw-npm-onboard-channel-agent.XXXXXX")" echo "Running npm tarball onboard/channel/agent Docker E2E ($CHANNEL)..." if ! docker run --rm \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ -e OPENCLAW_NPM_ONBOARD_CHANNEL="$CHANNEL" \ - -e OPENCLAW_CURRENT_PACKAGE_TGZ="$DOCKER_PACKAGE_TGZ" \ - -v "$PACKAGE_TGZ:$DOCKER_PACKAGE_TGZ:ro" \ - -v "$ROOT_DIR/scripts/e2e:/app/scripts/e2e:ro" \ + "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ + "${DOCKER_E2E_HARNESS_ARGS[@]}" \ -i "$IMAGE_NAME" bash -s >"$run_log" 2>&1 <<'EOF' set -euo pipefail diff --git a/scripts/e2e/npm-telegram-live-docker.sh b/scripts/e2e/npm-telegram-live-docker.sh index 62b263f1a03..9fdb21b9432 100755 --- a/scripts/e2e/npm-telegram-live-docker.sh +++ b/scripts/e2e/npm-telegram-live-docker.sh @@ -49,6 +49,7 @@ validate_openclaw_package_spec() { validate_openclaw_package_spec "$PACKAGE_SPEC" docker_e2e_build_or_reuse "$IMAGE_NAME" npm-telegram-live "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "$DOCKER_TARGET" +docker_e2e_harness_mount_args mkdir -p "$ROOT_DIR/.artifacts/qa-e2e" run_log="$(mktemp "${TMPDIR:-/tmp}/openclaw-npm-telegram-live.XXXXXX")" @@ -147,7 +148,7 @@ EOF run_logged docker run --rm \ "${docker_env[@]}" \ -v "$ROOT_DIR/.artifacts:/app/.artifacts" \ - -v "$ROOT_DIR/scripts/e2e:/app/scripts/e2e:ro" \ + "${DOCKER_E2E_HARNESS_ARGS[@]}" \ -v "$ROOT_DIR/extensions:/app/extensions:ro" \ -v "$npm_prefix_host:/npm-global" \ -i "$IMAGE_NAME" bash -s <<'EOF' diff --git a/scripts/e2e/openai-image-auth-docker.sh b/scripts/e2e/openai-image-auth-docker.sh index 26479598225..059c78aa60a 100644 --- a/scripts/e2e/openai-image-auth-docker.sh +++ b/scripts/e2e/openai-image-auth-docker.sh @@ -10,13 +10,14 @@ IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-openai-image-auth-e2e" OPENCLAW SKIP_BUILD="${OPENCLAW_OPENAI_IMAGE_AUTH_E2E_SKIP_BUILD:-0}" docker_e2e_build_or_reuse "$IMAGE_NAME" openai-image-auth "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "" "$SKIP_BUILD" +docker_e2e_harness_mount_args echo "Running OpenAI image auth Docker E2E..." # Harness files are mounted read-only; the app under test comes from /app/dist. run_logged openai-image-auth docker run --rm \ -e "OPENAI_API_KEY=sk-openclaw-image-auth-e2e" \ -e "OPENCLAW_QA_ALLOW_LOCAL_IMAGE_PROVIDER=1" \ - -v "$ROOT_DIR/scripts/e2e:/app/scripts/e2e:ro" \ + "${DOCKER_E2E_HARNESS_ARGS[@]}" \ -i "$IMAGE_NAME" bash -lc ' set -euo pipefail export HOME="$(mktemp -d "/tmp/openclaw-openai-image-auth.XXXXXX")" diff --git a/scripts/e2e/openwebui-docker.sh b/scripts/e2e/openwebui-docker.sh index 6c440f37426..54f080e8774 100755 --- a/scripts/e2e/openwebui-docker.sh +++ b/scripts/e2e/openwebui-docker.sh @@ -49,6 +49,7 @@ cleanup() { trap cleanup EXIT docker_e2e_build_or_reuse "$IMAGE_NAME" openwebui +docker_e2e_harness_mount_args echo "Pulling Open WebUI image: $OPENWEBUI_IMAGE" timeout "$DOCKER_PULL_TIMEOUT" docker pull "$OPENWEBUI_IMAGE" >/dev/null @@ -69,7 +70,7 @@ docker_cmd docker run -d \ -e "OPENCLAW_SKIP_CANVAS_HOST=1" \ -e OPENAI_API_KEY \ ${OPENAI_BASE_URL_VALUE:+-e OPENAI_BASE_URL} \ - -v "$ROOT_DIR/scripts/e2e:/app/scripts/e2e:ro" \ + "${DOCKER_E2E_HARNESS_ARGS[@]}" \ "$IMAGE_NAME" \ bash -lc ' set -euo pipefail diff --git a/scripts/e2e/pi-bundle-mcp-tools-docker.sh b/scripts/e2e/pi-bundle-mcp-tools-docker.sh index 8eced1626ea..20f9c7ad699 100755 --- a/scripts/e2e/pi-bundle-mcp-tools-docker.sh +++ b/scripts/e2e/pi-bundle-mcp-tools-docker.sh @@ -16,6 +16,7 @@ cleanup() { trap cleanup EXIT docker_e2e_build_or_reuse "$IMAGE_NAME" pi-bundle-mcp-tools +docker_e2e_harness_mount_args echo "Running in-container Pi bundle MCP tool availability smoke..." # Harness files are mounted read-only; the app under test comes from /app/dist. @@ -23,7 +24,7 @@ set +e docker run --rm \ --name "$CONTAINER_NAME" \ -e "OPENCLAW_STATE_DIR=/tmp/openclaw-state" \ - -v "$ROOT_DIR/scripts/e2e:/app/scripts/e2e:ro" \ + "${DOCKER_E2E_HARNESS_ARGS[@]}" \ "$IMAGE_NAME" \ bash -lc "set -euo pipefail node --import tsx scripts/e2e/pi-bundle-mcp-tools-docker-client.ts diff --git a/scripts/e2e/session-runtime-context-docker.sh b/scripts/e2e/session-runtime-context-docker.sh index 205c9ce058e..27a97814564 100644 --- a/scripts/e2e/session-runtime-context-docker.sh +++ b/scripts/e2e/session-runtime-context-docker.sh @@ -17,6 +17,7 @@ cleanup() { trap cleanup EXIT docker_e2e_build_or_reuse "$IMAGE_NAME" session-runtime-context +docker_e2e_harness_mount_args echo "Running session runtime context Docker E2E..." # Harness files are mounted read-only; the app under test comes from /app/dist. @@ -24,7 +25,7 @@ set +e docker run --rm \ --name "$CONTAINER_NAME" \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ - -v "$ROOT_DIR/scripts/e2e:/app/scripts/e2e:ro" \ + "${DOCKER_E2E_HARNESS_ARGS[@]}" \ "$IMAGE_NAME" \ bash -lc 'set -euo pipefail; node --import tsx scripts/e2e/session-runtime-context-docker-client.ts' \ >"$RUN_LOG" 2>&1 diff --git a/scripts/lib/docker-e2e-package.sh b/scripts/lib/docker-e2e-package.sh index 418905caf52..6b25f25a564 100644 --- a/scripts/lib/docker-e2e-package.sh +++ b/scripts/lib/docker-e2e-package.sh @@ -29,16 +29,13 @@ docker_e2e_prepare_package_tgz() { return 0 fi - echo "Building OpenClaw package artifacts..." - run_logged "$label-host-build" pnpm build - echo "Writing package inventory and packing OpenClaw once..." - run_logged "$label-inventory" node --import tsx --input-type=module -e 'const { writePackageDistInventory } = await import("./src/infra/package-dist-inventory.ts"); await writePackageDistInventory(process.cwd());' - local pack_dir pack_dir="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-docker-e2e-pack.XXXXXX")" - run_logged "$label-pack" npm pack --ignore-scripts --pack-destination "$pack_dir" - - package_tgz="$(find "$pack_dir" -maxdepth 1 -name 'openclaw-*.tgz' -print -quit)" + package_tgz="$( + node "$ROOT_DIR/scripts/package-openclaw-for-docker.mjs" \ + --output-dir "$pack_dir" \ + --output-name openclaw-current.tgz + )" if [ -z "$package_tgz" ]; then echo "missing packed OpenClaw tarball" >&2 return 1 @@ -61,3 +58,7 @@ docker_e2e_package_mount_args() { local target="${2:-/tmp/openclaw-current.tgz}" DOCKER_E2E_PACKAGE_ARGS=(-v "$package_tgz:$target:ro" -e "OPENCLAW_CURRENT_PACKAGE_TGZ=$target") } + +docker_e2e_harness_mount_args() { + DOCKER_E2E_HARNESS_ARGS=(-v "$ROOT_DIR/scripts/e2e:/app/scripts/e2e:ro") +} diff --git a/scripts/lib/docker-e2e-plan.mjs b/scripts/lib/docker-e2e-plan.mjs new file mode 100644 index 00000000000..0e2483f7470 --- /dev/null +++ b/scripts/lib/docker-e2e-plan.mjs @@ -0,0 +1,236 @@ +// Docker E2E scheduler planning helpers. +// This module turns the scenario catalog plus env-driven inputs into a concrete +// lane plan. It intentionally does not define scenario commands. +import { + DEFAULT_LIVE_RETRIES, + allReleasePathLanes, + mainLanes, + releasePathChunkLanes, + tailLanes, +} from "./docker-e2e-scenarios.mjs"; + +export { DEFAULT_LIVE_RETRIES }; + +export const DEFAULT_E2E_BARE_IMAGE = "openclaw-docker-e2e-bare:local"; +export const DEFAULT_E2E_FUNCTIONAL_IMAGE = "openclaw-docker-e2e-functional:local"; +export const DEFAULT_E2E_IMAGE = DEFAULT_E2E_FUNCTIONAL_IMAGE; +export const DEFAULT_PARALLELISM = 10; +export const DEFAULT_PROFILE = "all"; +export const DEFAULT_RESOURCE_LIMITS = { + docker: DEFAULT_PARALLELISM, + live: 9, + "live:claude": 4, + "live:codex": 4, + "live:droid": 4, + "live:gemini": 4, + "live:opencode": 4, + npm: 10, + service: 7, +}; +export const DEFAULT_TAIL_PARALLELISM = 10; +export const RELEASE_PATH_PROFILE = "release-path"; + +export function parseLaneSelection(raw) { + if (!raw) { + return []; + } + return [ + ...new Set( + String(raw) + .split(/[,\s]+/u) + .map((token) => token.trim()) + .filter(Boolean), + ), + ]; +} + +export function dedupeLanes(poolLanes) { + const byName = new Map(); + for (const poolLane of poolLanes) { + if (!byName.has(poolLane.name)) { + byName.set(poolLane.name, poolLane); + } + } + return [...byName.values()]; +} + +export function selectNamedLanes(poolLanes, selectedNames, label) { + const byName = new Map(poolLanes.map((poolLane) => [poolLane.name, poolLane])); + const missing = selectedNames.filter((name) => !byName.has(name)); + if (missing.length > 0) { + throw new Error( + `${label} unknown lane(s): ${missing.join(", ")}. Available lanes: ${[...byName.keys()] + .toSorted((a, b) => a.localeCompare(b)) + .join(", ")}`, + ); + } + return selectedNames.map((name) => byName.get(name)); +} + +export function parseLiveMode(raw) { + const mode = raw || "all"; + if (mode === "all" || mode === "skip" || mode === "only") { + return mode; + } + throw new Error( + `OPENCLAW_DOCKER_ALL_LIVE_MODE must be one of: all, skip, only. Got: ${JSON.stringify(raw)}`, + ); +} + +export function parseProfile(raw) { + const profile = raw || DEFAULT_PROFILE; + if (profile === DEFAULT_PROFILE || profile === RELEASE_PATH_PROFILE) { + return profile; + } + throw new Error( + `OPENCLAW_DOCKER_ALL_PROFILE must be one of: ${DEFAULT_PROFILE}, ${RELEASE_PATH_PROFILE}. Got: ${JSON.stringify(raw)}`, + ); +} + +export function applyLiveMode(poolLanes, mode) { + if (mode === "all") { + return poolLanes; + } + return poolLanes.filter((poolLane) => (mode === "only" ? poolLane.live : !poolLane.live)); +} + +export function applyLiveRetries(poolLanes, retries) { + return poolLanes.map((poolLane) => (poolLane.live ? { ...poolLane, retries } : poolLane)); +} + +export function laneWeight(poolLane) { + return Math.max(1, poolLane.weight ?? 1); +} + +export function laneResources(poolLane) { + return ["docker", ...(poolLane.resources ?? [])]; +} + +export function laneSummary(poolLane) { + const resources = laneResources(poolLane).join(","); + const timeout = poolLane.timeoutMs ? ` timeout=${Math.round(poolLane.timeoutMs / 1000)}s` : ""; + const retries = poolLane.retries > 0 ? ` retries=${poolLane.retries}` : ""; + const cache = poolLane.cacheKey ? ` cache=${poolLane.cacheKey}` : ""; + const image = poolLane.e2eImageKind ? ` image=${poolLane.e2eImageKind}` : ""; + return `${poolLane.name}(w=${laneWeight(poolLane)} r=${resources}${timeout}${retries}${cache}${image})`; +} + +export function lanesNeedE2eImageKind(poolLanes, kind) { + return poolLanes.some((poolLane) => poolLane.e2eImageKind === kind); +} + +export function lanesNeedOpenClawPackage(poolLanes) { + return poolLanes.some((poolLane) => poolLane.e2eImageKind); +} + +export function findLaneByName(name) { + return dedupeLanes([ + ...allReleasePathLanes({ includeOpenWebUI: true }), + ...mainLanes, + ...tailLanes, + ]).find((poolLane) => poolLane.name === name); +} + +export function laneCredentialRequirements(poolLane) { + const credentials = []; + if (poolLane.name === "install-e2e") { + credentials.push("openai", "anthropic"); + } + if (poolLane.name === "openwebui" || poolLane.name === "openai-web-search-minimal") { + credentials.push("openai"); + } + return credentials; +} + +function unique(values) { + return [...new Set(values.filter(Boolean))]; +} + +export function buildPlanJson(params) { + const scheduledLanes = [...params.orderedLanes, ...params.orderedTailLanes]; + const imageKinds = unique(scheduledLanes.map((poolLane) => poolLane.e2eImageKind)).toSorted( + (a, b) => a.localeCompare(b), + ); + return { + chunk: params.releaseChunk || undefined, + credentials: unique(scheduledLanes.flatMap(laneCredentialRequirements)).toSorted((a, b) => + a.localeCompare(b), + ), + imageKinds, + includeOpenWebUI: params.includeOpenWebUI, + lanes: scheduledLanes.map((poolLane) => ({ + command: poolLane.command, + imageKind: poolLane.e2eImageKind, + live: poolLane.live, + name: poolLane.name, + resources: laneResources(poolLane), + timeoutMs: poolLane.timeoutMs, + weight: laneWeight(poolLane), + })), + mainLanes: params.orderedLanes.map((poolLane) => poolLane.name), + needs: { + bareImage: imageKinds.includes("bare"), + e2eImage: imageKinds.length > 0, + functionalImage: imageKinds.includes("functional"), + liveImage: scheduledLanes.some((poolLane) => poolLane.live), + package: lanesNeedOpenClawPackage(scheduledLanes), + }, + profile: params.profile, + selectedLanes: params.selectedLaneNames, + tailLanes: params.orderedTailLanes.map((poolLane) => poolLane.name), + version: 1, + }; +} + +export function resolveDockerE2ePlan(options) { + const retriedMainLanes = applyLiveRetries(mainLanes, options.liveRetries); + const retriedTailLanes = applyLiveRetries(tailLanes, options.liveRetries); + const releaseLanes = + options.selectedLaneNames.length === 0 && options.profile === RELEASE_PATH_PROFILE + ? options.planReleaseAll + ? allReleasePathLanes({ includeOpenWebUI: options.includeOpenWebUI }) + : releasePathChunkLanes(options.releaseChunk, { + includeOpenWebUI: options.includeOpenWebUI, + }) + : undefined; + const selectedLanes = + options.selectedLaneNames.length > 0 + ? selectNamedLanes( + dedupeLanes([ + ...allReleasePathLanes({ includeOpenWebUI: options.includeOpenWebUI }), + ...retriedMainLanes, + ...retriedTailLanes, + ]), + options.selectedLaneNames, + "OPENCLAW_DOCKER_ALL_LANES", + ) + : undefined; + const configuredLanes = selectedLanes + ? selectedLanes + : releaseLanes + ? releaseLanes + : options.liveMode === "only" + ? applyLiveMode([...retriedMainLanes, ...retriedTailLanes], options.liveMode) + : applyLiveMode(retriedMainLanes, options.liveMode); + const configuredTailLanes = + selectedLanes || releaseLanes + ? [] + : options.liveMode === "only" + ? [] + : applyLiveMode(retriedTailLanes, options.liveMode); + const orderedLanes = options.orderLanes(configuredLanes, options.timingStore); + const orderedTailLanes = options.orderLanes(configuredTailLanes, options.timingStore); + return { + orderedLanes, + orderedTailLanes, + plan: buildPlanJson({ + includeOpenWebUI: options.includeOpenWebUI, + orderedLanes, + orderedTailLanes, + profile: options.profile, + releaseChunk: options.releaseChunk, + selectedLaneNames: options.selectedLaneNames, + }), + scheduledLanes: [...orderedLanes, ...orderedTailLanes], + }; +} diff --git a/scripts/lib/docker-e2e-scenarios.mjs b/scripts/lib/docker-e2e-scenarios.mjs new file mode 100644 index 00000000000..9696f8a6184 --- /dev/null +++ b/scripts/lib/docker-e2e-scenarios.mjs @@ -0,0 +1,417 @@ +// Docker E2E scenario catalog. +// Keep lane names, commands, image kind, timeout, resources, and release chunks +// here. Planning and execution live in separate modules. + +const BUNDLED_UPDATE_TIMEOUT_MS = 20 * 60 * 1000; +export const DEFAULT_LIVE_RETRIES = 1; +const LIVE_ACP_TIMEOUT_MS = 20 * 60 * 1000; +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 LIVE_RETRY_PATTERNS = [ + /529\b/i, + /overloaded/i, + /capacity/i, + /rate.?limit/i, + /gateway closed \(1000 normal closure\)/i, + /ECONNRESET|ETIMEDOUT|ENOTFOUND/i, +]; + +const bundledChannelLaneCommand = + "OPENCLAW_SKIP_DOCKER_BUILD=1 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=0 pnpm test:docker:bundled-channel-deps"; + +function lane(name, command, options = {}) { + return { + cacheKey: options.cacheKey, + command, + e2eImageKind: options.e2eImageKind ?? (options.live ? undefined : "functional"), + estimateSeconds: options.estimateSeconds, + live: options.live === true, + name, + retryPatterns: options.retryPatterns ?? [], + retries: options.retries ?? 0, + resources: options.resources ?? [], + timeoutMs: options.timeoutMs, + weight: options.weight ?? 1, + }; +} + +function liveProviderResource(provider) { + if (!provider) { + return undefined; + } + if (provider === "claude-cli" || provider === "claude") { + return "live:claude"; + } + if (provider === "codex-cli" || provider === "codex") { + return "live:codex"; + } + if (provider === "droid") { + return "live:droid"; + } + if (provider === "google-gemini-cli" || provider === "gemini") { + return "live:gemini"; + } + if (provider === "opencode") { + return "live:opencode"; + } + if (provider === "openai") { + return "live:openai"; + } + return `live:${provider}`; +} + +function liveProviderResources(options) { + const providers = options.providers ?? (options.provider ? [options.provider] : []); + return providers.map(liveProviderResource).filter(Boolean); +} + +function liveLane(name, command, options = {}) { + return lane(name, command, { + ...options, + live: true, + resources: ["live", ...liveProviderResources(options), ...(options.resources ?? [])], + retryPatterns: options.retryPatterns ?? LIVE_RETRY_PATTERNS, + retries: options.retries ?? DEFAULT_LIVE_RETRIES, + weight: options.weight ?? 3, + }); +} + +function npmLane(name, command, options = {}) { + return lane(name, command, { + ...options, + e2eImageKind: options.e2eImageKind ?? "bare", + resources: ["npm", ...(options.resources ?? [])], + weight: options.weight ?? 2, + }); +} + +function serviceLane(name, command, options = {}) { + return lane(name, command, { + ...options, + resources: ["service", ...(options.resources ?? [])], + weight: options.weight ?? 2, + }); +} + +function bundledChannelScenarioLane(name, env, options = {}) { + return npmLane( + name, + `${env} OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:bundled-channel-deps`, + options, + ); +} + +const bundledScenarioLanes = [ + ...["telegram", "discord", "slack", "feishu", "memory-lancedb"].map((channel) => + npmLane( + `bundled-channel-${channel}`, + `OPENCLAW_BUNDLED_CHANNELS=${channel} ${bundledChannelLaneCommand}`, + ), + ), + ...["telegram", "discord", "slack", "feishu", "memory-lancedb", "acpx"].map((target) => + bundledChannelScenarioLane( + `bundled-channel-update-${target}`, + `OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=1 OPENCLAW_BUNDLED_CHANNEL_UPDATE_TARGETS=${target} OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=0`, + { timeoutMs: BUNDLED_UPDATE_TIMEOUT_MS }, + ), + ), + bundledChannelScenarioLane( + "bundled-channel-root-owned", + "OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=1 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=0", + ), + bundledChannelScenarioLane( + "bundled-channel-setup-entry", + "OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=1 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=0", + ), + bundledChannelScenarioLane( + "bundled-channel-load-failure", + "OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=1 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=0", + ), + bundledChannelScenarioLane( + "bundled-channel-disabled-config", + "OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=1", + ), +]; + +export const mainLanes = [ + liveLane("live-models", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-models", { + providers: ["claude-cli", "codex-cli", "google-gemini-cli"], + timeoutMs: LIVE_PROFILE_TIMEOUT_MS, + weight: 4, + }), + liveLane("live-gateway", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-gateway", { + providers: ["claude-cli", "codex-cli", "google-gemini-cli"], + timeoutMs: LIVE_PROFILE_TIMEOUT_MS, + weight: 4, + }), + liveLane( + "live-cli-backend-claude", + "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-cli-backend:claude", + { + cacheKey: "cli-backend-claude", + provider: "claude-cli", + resources: ["npm"], + timeoutMs: LIVE_CLI_TIMEOUT_MS, + weight: 3, + }, + ), + liveLane( + "live-cli-backend-gemini", + "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-cli-backend:gemini", + { + cacheKey: "cli-backend-gemini", + provider: "google-gemini-cli", + resources: ["npm"], + timeoutMs: LIVE_CLI_TIMEOUT_MS, + weight: 3, + }, + ), + serviceLane("openwebui", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:openwebui", { + timeoutMs: OPENWEBUI_TIMEOUT_MS, + weight: 5, + }), + serviceLane("onboard", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:onboard", { + weight: 2, + }), + npmLane( + "npm-onboard-channel-agent", + "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:npm-onboard-channel-agent", + { resources: ["service"], weight: 3 }, + ), + serviceLane("gateway-network", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:gateway-network"), + serviceLane( + "agents-delete-shared-workspace", + "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:agents-delete-shared-workspace", + ), + serviceLane("mcp-channels", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:mcp-channels", { + resources: ["npm"], + weight: 3, + }), + lane("pi-bundle-mcp-tools", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:pi-bundle-mcp-tools"), + lane("crestodian-rescue", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:crestodian-rescue"), + lane("crestodian-planner", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:crestodian-planner"), + serviceLane( + "cron-mcp-cleanup", + "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:cron-mcp-cleanup", + { resources: ["npm"], weight: 3 }, + ), + npmLane("doctor-switch", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:doctor-switch", { + weight: 3, + }), + npmLane( + "update-channel-switch", + "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:update-channel-switch", + { + timeoutMs: 30 * 60 * 1000, + weight: 3, + }, + ), + lane("plugins", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugins", { + resources: ["npm", "service"], + weight: 6, + }), + npmLane("plugin-update", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugin-update"), + serviceLane("config-reload", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:config-reload"), + ...bundledScenarioLanes, + lane("openai-image-auth", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:openai-image-auth"), + lane( + "crestodian-first-run", + "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:crestodian-first-run", + ), + lane( + "session-runtime-context", + "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:session-runtime-context", + ), + lane("qr", "pnpm test:docker:qr"), +]; + +export const tailLanes = [ + serviceLane( + "openai-web-search-minimal", + "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:openai-web-search-minimal", + { timeoutMs: 8 * 60 * 1000 }, + ), + liveLane( + "live-codex-harness", + "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-codex-harness", + { + cacheKey: "codex-harness", + provider: "codex-cli", + resources: ["npm"], + timeoutMs: LIVE_ACP_TIMEOUT_MS, + weight: 3, + }, + ), + liveLane("live-codex-bind", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-codex-bind", { + cacheKey: "codex-harness", + provider: "codex-cli", + resources: ["npm"], + timeoutMs: LIVE_ACP_TIMEOUT_MS, + weight: 3, + }), + liveLane( + "live-cli-backend-codex", + "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-cli-backend:codex", + { + cacheKey: "cli-backend-codex", + provider: "codex-cli", + resources: ["npm"], + timeoutMs: LIVE_CLI_TIMEOUT_MS, + weight: 3, + }, + ), + liveLane( + "live-acp-bind-claude", + "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-acp-bind:claude", + { + cacheKey: "acp-bind-claude", + provider: "claude-cli", + resources: ["npm"], + timeoutMs: LIVE_ACP_TIMEOUT_MS, + weight: 3, + }, + ), + liveLane( + "live-acp-bind-codex", + "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-acp-bind:codex", + { + cacheKey: "acp-bind-codex", + provider: "codex-cli", + resources: ["npm"], + timeoutMs: LIVE_ACP_TIMEOUT_MS, + weight: 3, + }, + ), + liveLane( + "live-acp-bind-droid", + "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-acp-bind:droid", + { + cacheKey: "acp-bind-droid", + provider: "droid", + resources: ["npm"], + timeoutMs: LIVE_ACP_TIMEOUT_MS, + weight: 3, + }, + ), + liveLane( + "live-acp-bind-gemini", + "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-acp-bind:gemini", + { + cacheKey: "acp-bind-gemini", + provider: "google-gemini-cli", + resources: ["npm"], + timeoutMs: LIVE_ACP_TIMEOUT_MS, + weight: 3, + }, + ), + liveLane( + "live-acp-bind-opencode", + "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-acp-bind:opencode", + { + cacheKey: "acp-bind-opencode", + provider: "opencode", + resources: ["npm"], + timeoutMs: LIVE_ACP_TIMEOUT_MS, + weight: 3, + }, + ), +]; + +const releasePathChunks = { + core: [ + lane("qr", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:qr"), + serviceLane("onboard", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:onboard", { + weight: 2, + }), + serviceLane("gateway-network", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:gateway-network"), + serviceLane("config-reload", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:config-reload"), + lane( + "session-runtime-context", + "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:session-runtime-context", + ), + lane( + "pi-bundle-mcp-tools", + "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:pi-bundle-mcp-tools", + ), + serviceLane("mcp-channels", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:mcp-channels", { + resources: ["npm"], + weight: 3, + }), + ], + "package-update": [ + npmLane("install-e2e", "OPENCLAW_E2E_MODELS=both pnpm test:install:e2e", { + resources: ["service"], + weight: 4, + }), + npmLane( + "npm-onboard-channel-agent", + "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:npm-onboard-channel-agent", + { resources: ["service"], weight: 3 }, + ), + npmLane("doctor-switch", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:doctor-switch", { + weight: 3, + }), + npmLane( + "update-channel-switch", + "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:update-channel-switch", + { + timeoutMs: 30 * 60 * 1000, + weight: 3, + }, + ), + ], + "plugins-integrations": [ + lane("plugins", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugins", { + resources: ["npm", "service"], + weight: 6, + }), + npmLane("plugin-update", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugin-update"), + npmLane( + "bundled-channel-deps", + "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:bundled-channel-deps", + { resources: ["service"], weight: 3 }, + ), + serviceLane( + "cron-mcp-cleanup", + "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:cron-mcp-cleanup", + { + resources: ["npm"], + weight: 3, + }, + ), + serviceLane( + "openai-web-search-minimal", + "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:openai-web-search-minimal", + { timeoutMs: 8 * 60 * 1000 }, + ), + ], +}; + +export function releasePathChunkLanes(chunk, options = {}) { + const base = releasePathChunks[chunk]; + if (!base) { + throw new Error( + `OPENCLAW_DOCKER_ALL_CHUNK must be one of: ${Object.keys(releasePathChunks).join(", ")}. Got: ${JSON.stringify(chunk)}`, + ); + } + if (chunk !== "plugins-integrations" || !options.includeOpenWebUI) { + return base; + } + return [ + ...base, + serviceLane("openwebui", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:openwebui", { + timeoutMs: OPENWEBUI_TIMEOUT_MS, + weight: 5, + }), + ]; +} + +export function allReleasePathLanes(options = {}) { + return Object.keys(releasePathChunks).flatMap((chunk) => + releasePathChunkLanes(chunk, { + includeOpenWebUI: chunk === "plugins-integrations" && options.includeOpenWebUI, + }), + ); +} diff --git a/scripts/package-openclaw-for-docker.mjs b/scripts/package-openclaw-for-docker.mjs new file mode 100644 index 00000000000..80c5afbeba2 --- /dev/null +++ b/scripts/package-openclaw-for-docker.mjs @@ -0,0 +1,148 @@ +#!/usr/bin/env node +// Builds the OpenClaw package artifact used by Docker E2E. +// The script owns the build/inventory/pack sequence so local scheduler, shell +// helpers, and GitHub Actions all prepare the exact same npm tarball. +import { spawn } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function parseArgs(argv) { + const options = { + outputDir: "", + outputName: "", + skipBuild: false, + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--output-dir") { + options.outputDir = argv[(index += 1)] ?? ""; + } else if (arg?.startsWith("--output-dir=")) { + options.outputDir = arg.slice("--output-dir=".length); + } else if (arg === "--output-name") { + options.outputName = argv[(index += 1)] ?? ""; + } else if (arg?.startsWith("--output-name=")) { + options.outputName = arg.slice("--output-name=".length); + } else if (arg === "--skip-build") { + options.skipBuild = true; + } else { + throw new Error(`unknown argument: ${arg}`); + } + } + return options; +} + +function run(command, args) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: ROOT_DIR, + stdio: ["ignore", "pipe", "pipe"], + }); + child.stdout.pipe(process.stderr, { end: false }); + child.stderr.pipe(process.stderr, { end: false }); + child.on("error", reject); + child.on("close", (status, signal) => { + if (status === 0) { + resolve(); + return; + } + reject(new Error(`${command} ${args.join(" ")} failed with ${status ?? signal}`)); + }); + }); +} + +async function runCapture(command, args) { + return await new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: ROOT_DIR, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + child.stdout.on("data", (chunk) => { + stdout += String(chunk); + }); + child.stderr.pipe(process.stderr, { end: false }); + child.on("error", reject); + child.on("close", (status, signal) => { + if (status === 0) { + resolve(stdout); + return; + } + reject(new Error(`${command} ${args.join(" ")} failed with ${status ?? signal}`)); + }); + }); +} + +async function newestOpenClawTarball(outputDir, packOutput) { + let fromOutput = ""; + for (const line of packOutput.split(/\r?\n/u)) { + const trimmed = line.trim(); + if (/^openclaw-.*\.tgz$/u.test(trimmed)) { + fromOutput = trimmed; + } + } + if (fromOutput) { + return path.join(outputDir, fromOutput); + } + + const entries = await fs.readdir(outputDir); + const packed = entries + .filter((entry) => /^openclaw-.*\.tgz$/u.test(entry)) + .toSorted() + .at(-1); + if (!packed) { + throw new Error(`missing packed OpenClaw tarball in ${outputDir}`); + } + return path.join(outputDir, packed); +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const outputDir = path.resolve( + ROOT_DIR, + options.outputDir || path.join(".artifacts", "docker-e2e-package"), + ); + await fs.mkdir(outputDir, { recursive: true }); + + if (!options.skipBuild) { + console.error("==> Building OpenClaw package artifacts"); + await run("pnpm", ["build"]); + } + + console.error("==> Writing OpenClaw package inventory"); + await run("node", [ + "--import", + "tsx", + "--input-type=module", + "-e", + "const { writePackageDistInventory } = await import('./src/infra/package-dist-inventory.ts'); await writePackageDistInventory(process.cwd());", + ]); + + console.error("==> Packing OpenClaw package"); + const packOutput = await runCapture("npm", [ + "pack", + "--silent", + "--ignore-scripts", + "--pack-destination", + outputDir, + ]); + let tarball = await newestOpenClawTarball(outputDir, packOutput); + + if (options.outputName) { + const target = path.join(outputDir, options.outputName); + if (target !== tarball) { + await fs.rm(target, { force: true }); + await fs.rename(tarball, target); + tarball = target; + } + } + + process.stdout.write(`${tarball}\n`); +} + +await main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/test-docker-all.mjs b/scripts/test-docker-all.mjs index b3d769022ee..4ef804a43e2 100644 --- a/scripts/test-docker-all.mjs +++ b/scripts/test-docker-all.mjs @@ -6,508 +6,40 @@ import fs from "node:fs"; import { mkdir, readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { + DEFAULT_E2E_BARE_IMAGE, + DEFAULT_E2E_FUNCTIONAL_IMAGE, + DEFAULT_E2E_IMAGE, + DEFAULT_LIVE_RETRIES, + DEFAULT_PARALLELISM, + DEFAULT_PROFILE, + DEFAULT_RESOURCE_LIMITS, + DEFAULT_TAIL_PARALLELISM, + RELEASE_PATH_PROFILE, + findLaneByName, + laneResources, + laneSummary, + laneWeight, + lanesNeedE2eImageKind, + lanesNeedOpenClawPackage, + parseLaneSelection, + parseLiveMode, + parseProfile, + resolveDockerE2ePlan, +} from "./lib/docker-e2e-plan.mjs"; const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const DEFAULT_E2E_BARE_IMAGE = "openclaw-docker-e2e-bare:local"; -const DEFAULT_E2E_FUNCTIONAL_IMAGE = "openclaw-docker-e2e-functional:local"; -const DEFAULT_E2E_IMAGE = DEFAULT_E2E_FUNCTIONAL_IMAGE; -const DEFAULT_PARALLELISM = 10; -const DEFAULT_TAIL_PARALLELISM = 10; const DEFAULT_FAILURE_TAIL_LINES = 80; const DEFAULT_LANE_TIMEOUT_MS = 120 * 60 * 1000; const DEFAULT_LANE_START_STAGGER_MS = 2_000; -const DEFAULT_LIVE_RETRIES = 1; const DEFAULT_STATUS_INTERVAL_MS = 30_000; const DEFAULT_PREFLIGHT_RUN_TIMEOUT_MS = 60_000; const DEFAULT_TIMINGS_FILE = path.join(ROOT_DIR, ".artifacts/docker-tests/lane-timings.json"); -const DEFAULT_PROFILE = "all"; -const RELEASE_PATH_PROFILE = "release-path"; -const LIVE_PROFILE_TIMEOUT_MS = 20 * 60 * 1000; -const LIVE_CLI_TIMEOUT_MS = 20 * 60 * 1000; -const LIVE_ACP_TIMEOUT_MS = 20 * 60 * 1000; -const OPENWEBUI_TIMEOUT_MS = 20 * 60 * 1000; -const BUNDLED_UPDATE_TIMEOUT_MS = 20 * 60 * 1000; -const DEFAULT_RESOURCE_LIMITS = { - docker: DEFAULT_PARALLELISM, - live: 9, - "live:claude": 4, - "live:codex": 4, - "live:droid": 4, - "live:gemini": 4, - "live:opencode": 4, - npm: 10, - service: 7, -}; -const LIVE_RETRY_PATTERNS = [ - /529\b/i, - /overloaded/i, - /capacity/i, - /rate.?limit/i, - /gateway closed \(1000 normal closure\)/i, - /ECONNRESET|ETIMEDOUT|ENOTFOUND/i, -]; - -const bundledChannelLaneCommand = - "OPENCLAW_SKIP_DOCKER_BUILD=1 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=0 pnpm test:docker:bundled-channel-deps"; - -function lane(name, command, options = {}) { - return { - cacheKey: options.cacheKey, - command, - e2eImageKind: options.e2eImageKind ?? (options.live ? undefined : "functional"), - estimateSeconds: options.estimateSeconds, - live: options.live === true, - name, - retryPatterns: options.retryPatterns ?? [], - retries: options.retries ?? 0, - resources: options.resources ?? [], - timeoutMs: options.timeoutMs, - weight: options.weight ?? 1, - }; -} - -function liveProviderResource(provider) { - if (!provider) { - return undefined; +const cliArgs = new Set(process.argv.slice(2)); +for (const arg of cliArgs) { + if (arg !== "--plan-json") { + throw new Error(`unknown argument: ${arg}`); } - if (provider === "claude-cli" || provider === "claude") { - return "live:claude"; - } - if (provider === "codex-cli" || provider === "codex") { - return "live:codex"; - } - if (provider === "droid") { - return "live:droid"; - } - if (provider === "google-gemini-cli" || provider === "gemini") { - return "live:gemini"; - } - if (provider === "opencode") { - return "live:opencode"; - } - if (provider === "openai") { - return "live:openai"; - } - return `live:${provider}`; -} - -function liveProviderResources(options) { - const providers = options.providers ?? (options.provider ? [options.provider] : []); - return providers.map(liveProviderResource).filter(Boolean); -} - -function liveLane(name, command, options = {}) { - return lane(name, command, { - ...options, - live: true, - resources: ["live", ...liveProviderResources(options), ...(options.resources ?? [])], - retryPatterns: options.retryPatterns ?? LIVE_RETRY_PATTERNS, - retries: options.retries ?? DEFAULT_LIVE_RETRIES, - weight: options.weight ?? 3, - }); -} - -function npmLane(name, command, options = {}) { - return lane(name, command, { - ...options, - e2eImageKind: options.e2eImageKind ?? "bare", - resources: ["npm", ...(options.resources ?? [])], - weight: options.weight ?? 2, - }); -} - -function serviceLane(name, command, options = {}) { - return lane(name, command, { - ...options, - resources: ["service", ...(options.resources ?? [])], - weight: options.weight ?? 2, - }); -} - -const bundledScenarioLanes = [ - npmLane( - "bundled-channel-telegram", - `OPENCLAW_BUNDLED_CHANNELS=telegram ${bundledChannelLaneCommand}`, - ), - npmLane( - "bundled-channel-discord", - `OPENCLAW_BUNDLED_CHANNELS=discord ${bundledChannelLaneCommand}`, - ), - npmLane("bundled-channel-slack", `OPENCLAW_BUNDLED_CHANNELS=slack ${bundledChannelLaneCommand}`), - npmLane( - "bundled-channel-feishu", - `OPENCLAW_BUNDLED_CHANNELS=feishu ${bundledChannelLaneCommand}`, - ), - npmLane( - "bundled-channel-memory-lancedb", - `OPENCLAW_BUNDLED_CHANNELS=memory-lancedb ${bundledChannelLaneCommand}`, - ), - npmLane( - "bundled-channel-update-telegram", - "OPENCLAW_SKIP_DOCKER_BUILD=1 OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=1 OPENCLAW_BUNDLED_CHANNEL_UPDATE_TARGETS=telegram OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=0 pnpm test:docker:bundled-channel-deps", - { timeoutMs: BUNDLED_UPDATE_TIMEOUT_MS }, - ), - npmLane( - "bundled-channel-update-discord", - "OPENCLAW_SKIP_DOCKER_BUILD=1 OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=1 OPENCLAW_BUNDLED_CHANNEL_UPDATE_TARGETS=discord OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=0 pnpm test:docker:bundled-channel-deps", - { timeoutMs: BUNDLED_UPDATE_TIMEOUT_MS }, - ), - npmLane( - "bundled-channel-update-slack", - "OPENCLAW_SKIP_DOCKER_BUILD=1 OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=1 OPENCLAW_BUNDLED_CHANNEL_UPDATE_TARGETS=slack OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=0 pnpm test:docker:bundled-channel-deps", - { timeoutMs: BUNDLED_UPDATE_TIMEOUT_MS }, - ), - npmLane( - "bundled-channel-update-feishu", - "OPENCLAW_SKIP_DOCKER_BUILD=1 OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=1 OPENCLAW_BUNDLED_CHANNEL_UPDATE_TARGETS=feishu OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=0 pnpm test:docker:bundled-channel-deps", - { timeoutMs: BUNDLED_UPDATE_TIMEOUT_MS }, - ), - npmLane( - "bundled-channel-update-memory-lancedb", - "OPENCLAW_SKIP_DOCKER_BUILD=1 OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=1 OPENCLAW_BUNDLED_CHANNEL_UPDATE_TARGETS=memory-lancedb OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=0 pnpm test:docker:bundled-channel-deps", - { timeoutMs: BUNDLED_UPDATE_TIMEOUT_MS }, - ), - npmLane( - "bundled-channel-update-acpx", - "OPENCLAW_SKIP_DOCKER_BUILD=1 OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=1 OPENCLAW_BUNDLED_CHANNEL_UPDATE_TARGETS=acpx OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=0 pnpm test:docker:bundled-channel-deps", - { timeoutMs: BUNDLED_UPDATE_TIMEOUT_MS }, - ), - npmLane( - "bundled-channel-root-owned", - "OPENCLAW_SKIP_DOCKER_BUILD=1 OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=1 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=0 pnpm test:docker:bundled-channel-deps", - ), - npmLane( - "bundled-channel-setup-entry", - "OPENCLAW_SKIP_DOCKER_BUILD=1 OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=1 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=0 pnpm test:docker:bundled-channel-deps", - ), - npmLane( - "bundled-channel-load-failure", - "OPENCLAW_SKIP_DOCKER_BUILD=1 OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=1 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=0 pnpm test:docker:bundled-channel-deps", - ), - npmLane( - "bundled-channel-disabled-config", - "OPENCLAW_SKIP_DOCKER_BUILD=1 OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=1 pnpm test:docker:bundled-channel-deps", - ), -]; - -const lanes = [ - liveLane("live-models", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-models", { - providers: ["claude-cli", "codex-cli", "google-gemini-cli"], - timeoutMs: LIVE_PROFILE_TIMEOUT_MS, - weight: 4, - }), - liveLane("live-gateway", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-gateway", { - providers: ["claude-cli", "codex-cli", "google-gemini-cli"], - timeoutMs: LIVE_PROFILE_TIMEOUT_MS, - weight: 4, - }), - liveLane( - "live-cli-backend-claude", - "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-cli-backend:claude", - { - cacheKey: "cli-backend-claude", - provider: "claude-cli", - resources: ["npm"], - timeoutMs: LIVE_CLI_TIMEOUT_MS, - weight: 3, - }, - ), - liveLane( - "live-cli-backend-gemini", - "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-cli-backend:gemini", - { - cacheKey: "cli-backend-gemini", - provider: "google-gemini-cli", - resources: ["npm"], - timeoutMs: LIVE_CLI_TIMEOUT_MS, - weight: 3, - }, - ), - serviceLane("openwebui", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:openwebui", { - timeoutMs: OPENWEBUI_TIMEOUT_MS, - weight: 5, - }), - serviceLane("onboard", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:onboard", { - weight: 2, - }), - npmLane( - "npm-onboard-channel-agent", - "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:npm-onboard-channel-agent", - { resources: ["service"], weight: 3 }, - ), - serviceLane("gateway-network", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:gateway-network"), - serviceLane( - "agents-delete-shared-workspace", - "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:agents-delete-shared-workspace", - ), - serviceLane("mcp-channels", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:mcp-channels", { - resources: ["npm"], - weight: 3, - }), - lane("pi-bundle-mcp-tools", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:pi-bundle-mcp-tools"), - lane("crestodian-rescue", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:crestodian-rescue"), - lane("crestodian-planner", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:crestodian-planner"), - serviceLane( - "cron-mcp-cleanup", - "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:cron-mcp-cleanup", - { resources: ["npm"], weight: 3 }, - ), - npmLane("doctor-switch", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:doctor-switch", { - weight: 3, - }), - npmLane( - "update-channel-switch", - "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:update-channel-switch", - { - timeoutMs: 30 * 60 * 1000, - weight: 3, - }, - ), - lane("plugins", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugins", { - resources: ["npm", "service"], - weight: 6, - }), - npmLane("plugin-update", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugin-update"), - serviceLane("config-reload", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:config-reload"), - ...bundledScenarioLanes, - lane("openai-image-auth", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:openai-image-auth"), - lane( - "crestodian-first-run", - "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:crestodian-first-run", - ), - lane( - "session-runtime-context", - "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:session-runtime-context", - ), - lane("qr", "pnpm test:docker:qr"), -]; - -const exclusiveLanes = [ - serviceLane( - "openai-web-search-minimal", - "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:openai-web-search-minimal", - { timeoutMs: 8 * 60 * 1000 }, - ), - liveLane( - "live-codex-harness", - "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-codex-harness", - { - cacheKey: "codex-harness", - provider: "codex-cli", - resources: ["npm"], - timeoutMs: LIVE_ACP_TIMEOUT_MS, - weight: 3, - }, - ), - liveLane("live-codex-bind", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-codex-bind", { - cacheKey: "codex-harness", - provider: "codex-cli", - resources: ["npm"], - timeoutMs: LIVE_ACP_TIMEOUT_MS, - weight: 3, - }), - liveLane( - "live-cli-backend-codex", - "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-cli-backend:codex", - { - cacheKey: "cli-backend-codex", - provider: "codex-cli", - resources: ["npm"], - timeoutMs: LIVE_CLI_TIMEOUT_MS, - weight: 3, - }, - ), - liveLane( - "live-acp-bind-claude", - "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-acp-bind:claude", - { - cacheKey: "acp-bind-claude", - provider: "claude-cli", - resources: ["npm"], - timeoutMs: LIVE_ACP_TIMEOUT_MS, - weight: 3, - }, - ), - liveLane( - "live-acp-bind-codex", - "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-acp-bind:codex", - { - cacheKey: "acp-bind-codex", - provider: "codex-cli", - resources: ["npm"], - timeoutMs: LIVE_ACP_TIMEOUT_MS, - weight: 3, - }, - ), - liveLane( - "live-acp-bind-droid", - "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-acp-bind:droid", - { - cacheKey: "acp-bind-droid", - provider: "droid", - resources: ["npm"], - timeoutMs: LIVE_ACP_TIMEOUT_MS, - weight: 3, - }, - ), - liveLane( - "live-acp-bind-gemini", - "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-acp-bind:gemini", - { - cacheKey: "acp-bind-gemini", - provider: "google-gemini-cli", - resources: ["npm"], - timeoutMs: LIVE_ACP_TIMEOUT_MS, - weight: 3, - }, - ), - liveLane( - "live-acp-bind-opencode", - "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-acp-bind:opencode", - { - cacheKey: "acp-bind-opencode", - provider: "opencode", - resources: ["npm"], - timeoutMs: LIVE_ACP_TIMEOUT_MS, - weight: 3, - }, - ), -]; - -const tailLanes = exclusiveLanes; - -const releasePathChunks = { - core: [ - lane("qr", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:qr"), - serviceLane("onboard", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:onboard", { - weight: 2, - }), - serviceLane("gateway-network", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:gateway-network"), - serviceLane("config-reload", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:config-reload"), - lane( - "session-runtime-context", - "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:session-runtime-context", - ), - lane( - "pi-bundle-mcp-tools", - "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:pi-bundle-mcp-tools", - ), - serviceLane("mcp-channels", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:mcp-channels", { - resources: ["npm"], - weight: 3, - }), - ], - "package-update": [ - npmLane("install-e2e", "OPENCLAW_E2E_MODELS=both pnpm test:install:e2e", { - resources: ["service"], - weight: 4, - }), - npmLane( - "npm-onboard-channel-agent", - "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:npm-onboard-channel-agent", - { resources: ["service"], weight: 3 }, - ), - npmLane("doctor-switch", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:doctor-switch", { - weight: 3, - }), - npmLane( - "update-channel-switch", - "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:update-channel-switch", - { - timeoutMs: 30 * 60 * 1000, - weight: 3, - }, - ), - ], - "plugins-integrations": [ - lane("plugins", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugins", { - resources: ["npm", "service"], - weight: 6, - }), - npmLane("plugin-update", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugin-update"), - npmLane( - "bundled-channel-deps", - "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:bundled-channel-deps", - { resources: ["service"], weight: 3 }, - ), - serviceLane( - "cron-mcp-cleanup", - "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:cron-mcp-cleanup", - { - resources: ["npm"], - weight: 3, - }, - ), - serviceLane( - "openai-web-search-minimal", - "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:openai-web-search-minimal", - { timeoutMs: 8 * 60 * 1000 }, - ), - ], -}; - -function releasePathChunkLanes(chunk, options = {}) { - const base = releasePathChunks[chunk]; - if (!base) { - throw new Error( - `OPENCLAW_DOCKER_ALL_CHUNK must be one of: ${Object.keys(releasePathChunks).join(", ")}. Got: ${JSON.stringify(chunk)}`, - ); - } - if (chunk !== "plugins-integrations" || !options.includeOpenWebUI) { - return base; - } - return [ - ...base, - serviceLane("openwebui", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:openwebui", { - timeoutMs: OPENWEBUI_TIMEOUT_MS, - weight: 5, - }), - ]; -} - -function allReleasePathLanes(options = {}) { - return Object.keys(releasePathChunks).flatMap((chunk) => - releasePathChunkLanes(chunk, { - includeOpenWebUI: chunk === "plugins-integrations" && options.includeOpenWebUI, - }), - ); -} - -function parseLaneSelection(raw) { - if (!raw) { - return []; - } - return [ - ...new Set( - String(raw) - .split(/[,\s]+/u) - .map((token) => token.trim()) - .filter(Boolean), - ), - ]; -} - -function dedupeLanes(poolLanes) { - const byName = new Map(); - for (const poolLane of poolLanes) { - if (!byName.has(poolLane.name)) { - byName.set(poolLane.name, poolLane); - } - } - return [...byName.values()]; -} - -function selectNamedLanes(poolLanes, selectedNames, label) { - const byName = new Map(poolLanes.map((poolLane) => [poolLane.name, poolLane])); - const missing = selectedNames.filter((name) => !byName.has(name)); - if (missing.length > 0) { - throw new Error( - `${label} unknown lane(s): ${missing.join(", ")}. Available lanes: ${[...byName.keys()] - .toSorted((a, b) => a.localeCompare(b)) - .join(", ")}`, - ); - } - return selectedNames.map((name) => byName.get(name)); } function parsePositiveInt(raw, fallback, label) { @@ -539,37 +71,6 @@ function parseBool(raw, fallback) { return !/^(?:0|false|no)$/i.test(raw); } -function parseLiveMode(raw) { - const mode = raw || "all"; - if (mode === "all" || mode === "skip" || mode === "only") { - return mode; - } - throw new Error( - `OPENCLAW_DOCKER_ALL_LIVE_MODE must be one of: all, skip, only. Got: ${JSON.stringify(raw)}`, - ); -} - -function parseProfile(raw) { - const profile = raw || DEFAULT_PROFILE; - if (profile === DEFAULT_PROFILE || profile === RELEASE_PATH_PROFILE) { - return profile; - } - throw new Error( - `OPENCLAW_DOCKER_ALL_PROFILE must be one of: ${DEFAULT_PROFILE}, ${RELEASE_PATH_PROFILE}. Got: ${JSON.stringify(raw)}`, - ); -} - -function applyLiveMode(poolLanes, mode) { - if (mode === "all") { - return poolLanes; - } - return poolLanes.filter((poolLane) => (mode === "only" ? poolLane.live : !poolLane.live)); -} - -function applyLiveRetries(poolLanes, retries) { - return poolLanes.map((poolLane) => (poolLane.live ? { ...poolLane, retries } : poolLane)); -} - function resourceLimitsSummary(resourceLimits) { return Object.entries(resourceLimits) .map(([resource, limit]) => `${resource}=${String(limit)}`) @@ -601,21 +102,19 @@ function parseSchedulerOptions(env, parallelism) { }; } -function laneWeight(poolLane) { - return Math.max(1, poolLane.weight ?? 1); +function timingSeconds(timingStore, poolLane) { + const fromStore = timingStore?.lanes?.[poolLane.name]?.durationSeconds; + if (typeof fromStore === "number" && Number.isFinite(fromStore) && fromStore > 0) { + return fromStore; + } + return poolLane.estimateSeconds ?? 0; } -function laneResources(poolLane) { - return ["docker", ...(poolLane.resources ?? [])]; -} - -function laneSummary(poolLane) { - const resources = laneResources(poolLane).join(","); - const timeout = poolLane.timeoutMs ? ` timeout=${Math.round(poolLane.timeoutMs / 1000)}s` : ""; - const retries = poolLane.retries > 0 ? ` retries=${poolLane.retries}` : ""; - const cache = poolLane.cacheKey ? ` cache=${poolLane.cacheKey}` : ""; - const image = poolLane.e2eImageKind ? ` image=${poolLane.e2eImageKind}` : ""; - return `${poolLane.name}(w=${laneWeight(poolLane)} r=${resources}${timeout}${retries}${cache}${image})`; +function orderLanes(poolLanes, timingStore) { + return poolLanes + .map((poolLane, index) => ({ index, poolLane, seconds: timingSeconds(timingStore, poolLane) })) + .toSorted((a, b) => b.seconds - a.seconds || a.index - b.index) + .map(({ poolLane }) => poolLane); } function sleep(ms) { @@ -672,47 +171,6 @@ function buildLaneRerunCommand(name, baseEnv) { .join(" ")} pnpm test:docker:all`; } -function findLaneByName(name) { - return dedupeLanes([ - ...allReleasePathLanes({ includeOpenWebUI: true }), - ...lanes, - ...tailLanes, - ]).find((poolLane) => poolLane.name === name); -} - -function e2eImageForKind(kind, baseEnv) { - if (kind === "bare") { - return baseEnv.OPENCLAW_DOCKER_E2E_BARE_IMAGE || baseEnv.OPENCLAW_DOCKER_E2E_IMAGE; - } - if (kind === "functional") { - return baseEnv.OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE || baseEnv.OPENCLAW_DOCKER_E2E_IMAGE; - } - return baseEnv.OPENCLAW_DOCKER_E2E_IMAGE; -} - -function e2eImageForLane(poolLane, baseEnv) { - return e2eImageForKind(poolLane.e2eImageKind, baseEnv); -} - -function lanesNeedE2eImageKind(poolLanes, kind) { - return poolLanes.some((poolLane) => poolLane.e2eImageKind === kind); -} - -function timingSeconds(timingStore, poolLane) { - const fromStore = timingStore?.lanes?.[poolLane.name]?.durationSeconds; - if (typeof fromStore === "number" && Number.isFinite(fromStore) && fromStore > 0) { - return fromStore; - } - return poolLane.estimateSeconds ?? 0; -} - -function orderLanes(poolLanes, timingStore) { - return poolLanes - .map((poolLane, index) => ({ index, poolLane, seconds: timingSeconds(timingStore, poolLane) })) - .toSorted((a, b) => b.seconds - a.seconds || a.index - b.index) - .map(({ poolLane }) => poolLane); -} - async function loadTimingStore(file, enabled) { if (!enabled) { return { enabled: false, file, lanes: {}, version: 1 }; @@ -812,10 +270,6 @@ function printLaneManifest(label, poolLanes, timingStore) { } } -function lanesNeedOpenClawPackage(poolLanes) { - return poolLanes.some((poolLane) => poolLane.e2eImageKind); -} - function dockerPreflightContainerNames(raw) { return raw .split(/\r?\n/) @@ -1016,15 +470,10 @@ async function runDockerPreflight(baseEnv, options) { } async function prepareOpenClawPackage(baseEnv, logDir) { - const existing = - baseEnv.OPENCLAW_CURRENT_PACKAGE_TGZ || - baseEnv.OPENCLAW_BUNDLED_CHANNEL_PACKAGE_TGZ || - baseEnv.OPENCLAW_NPM_ONBOARD_PACKAGE_TGZ; + const existing = baseEnv.OPENCLAW_CURRENT_PACKAGE_TGZ; if (existing) { const packageTgz = path.resolve(existing); baseEnv.OPENCLAW_CURRENT_PACKAGE_TGZ = packageTgz; - baseEnv.OPENCLAW_BUNDLED_CHANNEL_PACKAGE_TGZ ||= packageTgz; - baseEnv.OPENCLAW_NPM_ONBOARD_PACKAGE_TGZ ||= packageTgz; baseEnv.OPENCLAW_BUNDLED_CHANNEL_HOST_BUILD = "0"; baseEnv.OPENCLAW_NPM_ONBOARD_HOST_BUILD = "0"; console.log(`==> OpenClaw package: ${packageTgz}`); @@ -1033,33 +482,29 @@ async function prepareOpenClawPackage(baseEnv, logDir) { const packDir = path.join(logDir, "openclaw-package"); await mkdir(packDir, { recursive: true }); - await runForeground("Build OpenClaw package artifacts once", "pnpm build", baseEnv); + const packageTgz = path.join(packDir, "openclaw-current.tgz"); await runForeground( - "Write OpenClaw package inventory", - "node --import tsx --input-type=module -e \"const { writePackageDistInventory } = await import('./src/infra/package-dist-inventory.ts'); await writePackageDistInventory(process.cwd());\"", + "Prepare OpenClaw package once", + `node scripts/package-openclaw-for-docker.mjs --output-dir ${shellQuote(packDir)} --output-name openclaw-current.tgz`, baseEnv, ); - await runForeground( - "Pack OpenClaw package once", - `npm pack --silent --ignore-scripts --pack-destination ${shellQuote(packDir)}`, - baseEnv, - ); - - const packed = (await fs.promises.readdir(packDir)) - .filter((entry) => /^openclaw-.*\.tgz$/.test(entry)) - .toSorted() - .at(-1); - if (!packed) { - throw new Error(`missing packed OpenClaw tarball in ${packDir}`); - } - baseEnv.OPENCLAW_CURRENT_PACKAGE_TGZ = path.join(packDir, packed); - baseEnv.OPENCLAW_BUNDLED_CHANNEL_PACKAGE_TGZ = baseEnv.OPENCLAW_CURRENT_PACKAGE_TGZ; + await fs.promises.access(packageTgz); + baseEnv.OPENCLAW_CURRENT_PACKAGE_TGZ = packageTgz; baseEnv.OPENCLAW_BUNDLED_CHANNEL_HOST_BUILD = "0"; - baseEnv.OPENCLAW_NPM_ONBOARD_PACKAGE_TGZ = baseEnv.OPENCLAW_CURRENT_PACKAGE_TGZ; baseEnv.OPENCLAW_NPM_ONBOARD_HOST_BUILD = "0"; console.log(`==> OpenClaw package: ${baseEnv.OPENCLAW_CURRENT_PACKAGE_TGZ}`); } +function e2eImageForLane(poolLane, baseEnv) { + if (poolLane.e2eImageKind === "bare") { + return baseEnv.OPENCLAW_DOCKER_E2E_BARE_IMAGE; + } + if (poolLane.e2eImageKind === "functional") { + return baseEnv.OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE; + } + return undefined; +} + function laneEnv(poolLane, baseEnv, logDir, cacheKey) { const env = { ...baseEnv, @@ -1405,6 +850,9 @@ async function main() { const preflightCleanup = parseBool(process.env.OPENCLAW_DOCKER_ALL_PREFLIGHT_CLEANUP, true); const timingsEnabled = parseBool(process.env.OPENCLAW_DOCKER_ALL_TIMINGS, true); const buildEnabled = parseBool(process.env.OPENCLAW_DOCKER_ALL_BUILD, true); + const planJson = + cliArgs.has("--plan-json") || parseBool(process.env.OPENCLAW_DOCKER_ALL_PLAN_JSON, false); + const planReleaseAll = parseBool(process.env.OPENCLAW_DOCKER_ALL_PLAN_RELEASE_ALL, false); const profile = parseProfile(process.env.OPENCLAW_DOCKER_ALL_PROFILE); const releaseChunk = process.env.OPENCLAW_DOCKER_ALL_CHUNK || process.env.DOCKER_E2E_CHUNK || ""; const includeOpenWebUI = parseBool( @@ -1431,7 +879,6 @@ async function main() { process.env.OPENCLAW_DOCKER_ALL_LOG_DIR || path.join(ROOT_DIR, ".artifacts/docker-tests", runId), ); - await mkdir(logDir, { recursive: true }); const baseEnv = commandEnv({ OPENCLAW_DOCKER_E2E_BARE_IMAGE: @@ -1450,40 +897,24 @@ async function main() { appendExtension(baseEnv, "codex"); const timingStore = await loadTimingStore(timingsFile, timingsEnabled); - const retriedMainLanes = applyLiveRetries(lanes, liveRetries); - const retriedTailLanes = applyLiveRetries(tailLanes, liveRetries); - const releaseLanes = - selectedLaneNames.length === 0 && profile === RELEASE_PATH_PROFILE - ? releasePathChunkLanes(releaseChunk, { includeOpenWebUI }) - : undefined; - const selectedLanes = - selectedLaneNames.length > 0 - ? selectNamedLanes( - dedupeLanes([ - ...allReleasePathLanes({ includeOpenWebUI }), - ...retriedMainLanes, - ...retriedTailLanes, - ]), - selectedLaneNames, - "OPENCLAW_DOCKER_ALL_LANES", - ) - : undefined; - const configuredLanes = selectedLanes - ? selectedLanes - : releaseLanes - ? releaseLanes - : liveMode === "only" - ? applyLiveMode([...retriedMainLanes, ...retriedTailLanes], liveMode) - : applyLiveMode(retriedMainLanes, liveMode); - const configuredTailLanes = - selectedLanes || releaseLanes - ? [] - : liveMode === "only" - ? [] - : applyLiveMode(retriedTailLanes, liveMode); - const orderedLanes = orderLanes(configuredLanes, timingStore); - const orderedTailLanes = orderLanes(configuredTailLanes, timingStore); + const { orderedLanes, orderedTailLanes, plan, scheduledLanes } = resolveDockerE2ePlan({ + includeOpenWebUI, + liveMode, + liveRetries, + orderLanes, + planReleaseAll: planJson && planReleaseAll, + profile, + releaseChunk, + selectedLaneNames, + timingStore, + }); + if (planJson) { + process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`); + return; + } + + await mkdir(logDir, { recursive: true }); console.log(`==> Docker test logs: ${logDir}`); console.log(`==> Profile: ${profile}${releaseChunk ? ` chunk=${releaseChunk}` : ""}`); console.log(`==> Parallelism: ${parallelism}`); @@ -1538,7 +969,6 @@ async function main() { }); }, ); - const scheduledLanes = [...orderedLanes, ...orderedTailLanes]; if (lanesNeedOpenClawPackage(scheduledLanes)) { await runPhase(phases, "prepare-openclaw-package", {}, async () => { await prepareOpenClawPackage(baseEnv, logDir);