From f14e91b39f24616abd84f633409892c9ea15bab0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 21 Apr 2026 08:23:29 +0100 Subject: [PATCH] test: add bundled channel dependency Docker smoke --- .../openclaw-live-and-e2e-checks-reusable.yml | 6 + docs/help/testing.md | 6 + package.json | 3 +- .../bundled-channel-runtime-deps-docker.sh | 286 ++++++++++++++++++ 4 files changed, 300 insertions(+), 1 deletion(-) create mode 100644 scripts/e2e/bundled-channel-runtime-deps-docker.sh diff --git a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml index dc79f868d5f..eba268cbd9e 100644 --- a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml +++ b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml @@ -381,6 +381,12 @@ jobs: timeout_minutes: 75 release_path: true openwebui_only: false + - suite_id: docker-bundled-channel-deps + label: Bundled Channel Runtime Deps Docker E2E + command: pnpm test:docker:bundled-channel-deps + timeout_minutes: 75 + release_path: true + openwebui_only: false - suite_id: docker-doctor-switch label: Doctor Install Switch Docker E2E command: pnpm test:docker:doctor-switch diff --git a/docs/help/testing.md b/docs/help/testing.md index 36770a5d0dc..ba99877b279 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -76,6 +76,12 @@ These commands sit beside the main test suites when you need QA-lab realism: `.artifacts/qa-e2e/...`. - `pnpm qa:lab:up` - Starts the Docker-backed QA site for operator-style QA work. +- `pnpm test:docker:bundled-channel-deps` + - Packs and installs the current OpenClaw build in Docker, starts the Gateway + with OpenAI configured, then enables Telegram and Discord via config edits. + - Verifies the first Gateway restart installs each bundled channel plugin's + runtime dependencies on demand, and a second restart does not reinstall + dependencies that were already activated. - `pnpm openclaw qa aimock` - Starts only the local AIMock provider server for direct protocol smoke testing. diff --git a/package.json b/package.json index 8930c0c032a..930ef9745c2 100644 --- a/package.json +++ b/package.json @@ -1407,7 +1407,8 @@ "test:contracts:plugins": "node scripts/run-vitest.mjs run --config test/vitest/vitest.contracts-plugin.config.ts --maxWorkers=1", "test:coverage": "node scripts/run-vitest.mjs run --config test/vitest/vitest.unit.config.ts --coverage", "test:coverage:changed": "node scripts/run-vitest.mjs run --config test/vitest/vitest.unit.config.ts --coverage --changed origin/main", - "test:docker:all": "pnpm test:docker:live-build && OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-models && OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-gateway && pnpm test:docker:openwebui && pnpm test:docker:onboard && pnpm test:docker:gateway-network && pnpm test:docker:mcp-channels && pnpm test:docker:qr && pnpm test:docker:doctor-switch && pnpm test:docker:plugins && pnpm test:docker:cleanup", + "test:docker:all": "pnpm test:docker:live-build && OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-models && OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-gateway && pnpm test:docker:openwebui && pnpm test:docker:onboard && pnpm test:docker:gateway-network && pnpm test:docker:mcp-channels && pnpm test:docker:qr && pnpm test:docker:doctor-switch && pnpm test:docker:plugins && pnpm test:docker:bundled-channel-deps && pnpm test:docker:cleanup", + "test:docker:bundled-channel-deps": "bash scripts/e2e/bundled-channel-runtime-deps-docker.sh", "test:docker:cleanup": "bash scripts/test-cleanup-docker.sh", "test:docker:doctor-switch": "bash scripts/e2e/doctor-install-switch-docker.sh", "test:docker:gateway-network": "bash scripts/e2e/gateway-network-docker.sh", diff --git a/scripts/e2e/bundled-channel-runtime-deps-docker.sh b/scripts/e2e/bundled-channel-runtime-deps-docker.sh new file mode 100644 index 00000000000..6d7b26b4573 --- /dev/null +++ b/scripts/e2e/bundled-channel-runtime-deps-docker.sh @@ -0,0 +1,286 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "$ROOT_DIR/scripts/lib/docker-e2e-logs.sh" + +IMAGE_NAME="${OPENCLAW_BUNDLED_CHANNEL_DEPS_E2E_IMAGE:-openclaw-bundled-channel-deps-e2e}" + +echo "Building Docker image..." +run_logged bundled-channel-deps-build docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" + +run_channel_scenario() { + local channel="$1" + local dep_sentinel="$2" + local run_log + run_log="$(mktemp "${TMPDIR:-/tmp}/openclaw-bundled-channel-deps-$channel.XXXXXX")" + + echo "Running bundled $channel runtime deps Docker E2E..." + if ! docker run --rm \ + -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ + -e OPENCLAW_CHANNEL_UNDER_TEST="$channel" \ + -e OPENCLAW_DEP_SENTINEL="$dep_sentinel" \ + -i "$IMAGE_NAME" bash -s >"$run_log" 2>&1 <<'EOF' +set -euo pipefail + +export HOME="$(mktemp -d "/tmp/openclaw-bundled-channel-deps.XXXXXX")" +export NPM_CONFIG_PREFIX="$HOME/.npm-global" +export PATH="$NPM_CONFIG_PREFIX/bin:$PATH" +export OPENAI_API_KEY="sk-openclaw-bundled-channel-deps-e2e" +export OPENCLAW_NO_ONBOARD=1 + +TOKEN="bundled-channel-deps-token" +PORT="18789" +CHANNEL="${OPENCLAW_CHANNEL_UNDER_TEST:?missing OPENCLAW_CHANNEL_UNDER_TEST}" +DEP_SENTINEL="${OPENCLAW_DEP_SENTINEL:?missing OPENCLAW_DEP_SENTINEL}" +gateway_pid="" + +cleanup() { + if [ -n "${gateway_pid:-}" ] && kill -0 "$gateway_pid" 2>/dev/null; then + kill "$gateway_pid" 2>/dev/null || true + wait "$gateway_pid" 2>/dev/null || true + fi +} +trap cleanup EXIT + +echo "Packing and installing current OpenClaw build..." +pack_dir="$(mktemp -d "/tmp/openclaw-pack.XXXXXX")" +npm pack --ignore-scripts --pack-destination "$pack_dir" >/tmp/openclaw-pack.log 2>&1 +package_tgz="$(find "$pack_dir" -maxdepth 1 -name 'openclaw-*.tgz' -print -quit)" +if [ -z "$package_tgz" ]; then + cat /tmp/openclaw-pack.log + echo "missing packed OpenClaw tarball" >&2 + exit 1 +fi +npm install -g "$package_tgz" --no-fund --no-audit >/tmp/openclaw-install.log 2>&1 + +command -v openclaw >/dev/null +package_root="$(npm root -g)/openclaw" +test -d "$package_root/dist/extensions/telegram" +test -d "$package_root/dist/extensions/discord" + +if [ -d "$package_root/dist/extensions/telegram/node_modules" ]; then + echo "telegram runtime deps should not be preinstalled in package" >&2 + find "$package_root/dist/extensions/telegram/node_modules" -maxdepth 2 -type f | head -20 >&2 || true + exit 1 +fi +if [ -d "$package_root/dist/extensions/discord/node_modules" ]; then + echo "discord runtime deps should not be preinstalled in package" >&2 + find "$package_root/dist/extensions/discord/node_modules" -maxdepth 2 -type f | head -20 >&2 || true + exit 1 +fi + +write_config() { + local mode="$1" + node - <<'NODE' "$mode" "$TOKEN" "$PORT" +const fs = require("node:fs"); +const path = require("node:path"); + +const mode = process.argv[2]; +const token = process.argv[3]; +const port = Number(process.argv[4]); +const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); +const config = fs.existsSync(configPath) + ? JSON.parse(fs.readFileSync(configPath, "utf8")) + : {}; + +config.gateway = { + ...(config.gateway || {}), + port, + auth: { mode: "token", token }, + controlUi: { enabled: false }, +}; +config.agents = { + ...(config.agents || {}), + defaults: { + ...(config.agents?.defaults || {}), + model: { primary: "openai/gpt-4.1-mini" }, + }, +}; +config.models = { + ...(config.models || {}), + providers: { + ...(config.models?.providers || {}), + openai: { + ...(config.models?.providers?.openai || {}), + apiKey: process.env.OPENAI_API_KEY, + baseUrl: "https://api.openai.com/v1", + models: [], + }, + }, +}; +config.plugins = { + ...(config.plugins || {}), + enabled: true, +}; + +if (mode === "telegram") { + config.channels = { + ...(config.channels || {}), + telegram: { + ...(config.channels?.telegram || {}), + enabled: true, + dmPolicy: "disabled", + groupPolicy: "disabled", + }, + }; +} +if (mode === "discord") { + config.channels = { + ...(config.channels || {}), + discord: { + ...(config.channels?.discord || {}), + enabled: true, + dmPolicy: "disabled", + groupPolicy: "disabled", + }, + }; +} + +fs.mkdirSync(path.dirname(configPath), { recursive: true }); +fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); +NODE +} + +start_gateway() { + local log_file="$1" + : >"$log_file" + openclaw gateway --port "$PORT" --bind loopback --allow-unconfigured >"$log_file" 2>&1 & + gateway_pid="$!" + + for _ in $(seq 1 240); do + if grep -Eq "listening on ws://|\\[gateway\\] ready \\(" "$log_file"; then + return 0 + fi + if ! kill -0 "$gateway_pid" 2>/dev/null; then + echo "gateway exited unexpectedly" >&2 + cat "$log_file" >&2 + exit 1 + fi + sleep 0.25 + done + + echo "timed out waiting for gateway" >&2 + cat "$log_file" >&2 + exit 1 +} + +stop_gateway() { + if [ -n "${gateway_pid:-}" ] && kill -0 "$gateway_pid" 2>/dev/null; then + kill "$gateway_pid" 2>/dev/null || true + wait "$gateway_pid" 2>/dev/null || true + fi + gateway_pid="" +} + +wait_for_gateway_health() { + for _ in $(seq 1 120); do + if openclaw gateway health --url "ws://127.0.0.1:$PORT" --token "$TOKEN" --json >/dev/null 2>&1; then + return 0 + fi + sleep 0.25 + done + echo "timed out waiting for gateway health" >&2 + return 1 +} + +assert_channel_status() { + local channel="$1" + local out="/tmp/openclaw-channel-status-$channel.json" + openclaw gateway call channels.status \ + --url "ws://127.0.0.1:$PORT" \ + --token "$TOKEN" \ + --timeout 30000 \ + --json \ + --params '{"probe":false}' >"$out" + node - <<'NODE' "$out" "$channel" +const fs = require("node:fs"); +const raw = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const payload = raw.result ?? raw.data ?? raw; +const channel = process.argv[3]; +const dump = () => JSON.stringify(raw, null, 2).slice(0, 4000); +const hasChannelMeta = Array.isArray(payload.channelMeta) + ? payload.channelMeta.some((entry) => entry?.id === channel) + : Boolean(payload.channelMeta?.[channel]); +if (!hasChannelMeta) { + throw new Error(`missing channelMeta.${channel}\n${dump()}`); +} +if (!payload.channels || !payload.channels[channel]) { + throw new Error(`missing channels.${channel}\n${dump()}`); +} +const accounts = payload.channelAccounts?.[channel]; +if (!Array.isArray(accounts) || accounts.length === 0) { + throw new Error(`missing channelAccounts.${channel}\n${dump()}`); +} +console.log(`${channel} channel plugin visible`); +NODE +} + +assert_installed_once() { + local log_file="$1" + local channel="$2" + local count + count="$(grep -c "\\[plugins\\] $channel installed bundled runtime deps:" "$log_file" || true)" + if [ "$count" -ne 1 ]; then + echo "expected exactly one runtime deps install for $channel, got $count" >&2 + cat "$log_file" >&2 + exit 1 + fi +} + +assert_not_installed() { + local log_file="$1" + local channel="$2" + if grep -q "\\[plugins\\] $channel installed bundled runtime deps:" "$log_file"; then + echo "expected no runtime deps reinstall for $channel" >&2 + cat "$log_file" >&2 + exit 1 + fi +} + +assert_dep_sentinel() { + local channel="$1" + local dep_path="$2" + if [ ! -f "$package_root/dist/extensions/$channel/node_modules/$dep_path/package.json" ]; then + echo "missing dependency sentinel for $channel: $dep_path" >&2 + find "$package_root/dist/extensions/$channel" -maxdepth 3 -type f | sort | head -80 >&2 || true + exit 1 + fi +} + +echo "Starting baseline gateway with OpenAI configured..." +write_config baseline +start_gateway "/tmp/openclaw-$CHANNEL-baseline.log" +wait_for_gateway_health +stop_gateway + +echo "Enabling $CHANNEL by config edit, then restarting gateway..." +write_config "$CHANNEL" +start_gateway "/tmp/openclaw-$CHANNEL-first.log" +wait_for_gateway_health +assert_installed_once "/tmp/openclaw-$CHANNEL-first.log" "$CHANNEL" +assert_dep_sentinel "$CHANNEL" "$DEP_SENTINEL" +assert_channel_status "$CHANNEL" +stop_gateway + +echo "Restarting gateway again; $CHANNEL deps must stay installed..." +start_gateway "/tmp/openclaw-$CHANNEL-second.log" +wait_for_gateway_health +assert_not_installed "/tmp/openclaw-$CHANNEL-second.log" "$CHANNEL" +assert_channel_status "$CHANNEL" +stop_gateway + +echo "bundled $CHANNEL runtime deps Docker E2E passed" +EOF + then + cat "$run_log" + rm -f "$run_log" + exit 1 + fi + + cat "$run_log" + rm -f "$run_log" +} + +run_channel_scenario telegram grammy +run_channel_scenario discord discord-api-types