From beb1d9b4815db30d205531a3756e3ea667266b9b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Apr 2026 09:30:12 +0100 Subject: [PATCH] refactor: extract docker e2e helpers --- scripts/e2e/config-reload-source-docker.sh | 68 +-- .../bundled-channel-runtime-deps-runner.sh | 59 ++- .../bundled-channel/assert-channel-status.mjs | 22 + .../assert-no-staged-manifest-spec.mjs | 44 ++ .../bundled-channel/assert-update-result.mjs | 26 ++ scripts/e2e/lib/bundled-channel/channel.sh | 32 +- scripts/e2e/lib/bundled-channel/common.sh | 237 +--------- .../lib/bundled-channel/disabled-config.sh | 11 +- .../e2e/lib/bundled-channel/load-failure.sh | 11 +- .../package-version-from-tgz.mjs | 6 + scripts/e2e/lib/bundled-channel/root-owned.sh | 3 +- .../e2e/lib/bundled-channel/setup-entry.sh | 11 +- scripts/e2e/lib/bundled-channel/update.sh | 49 +-- .../e2e/lib/bundled-channel/write-config.mjs | 179 ++++++++ scripts/e2e/lib/clawhub-fixture-server.cjs | 257 +++++++++++ scripts/e2e/lib/config-reload/assert-log.mjs | 26 ++ .../e2e/lib/config-reload/mutate-metadata.mjs | 6 + .../lib/kitchen-sink-plugin/assertions.mjs | 326 ++++++++++++++ .../clawhub-fixture-server.cjs | 183 -------- scripts/e2e/lib/kitchen-sink-plugin/sweep.sh | 317 +------------- scripts/e2e/lib/plugins/assertions.mjs | 406 ++++++++++++++++++ .../lib/plugins/clawhub-fixture-server.cjs | 137 ------ scripts/e2e/lib/plugins/clawhub.sh | 146 +------ scripts/e2e/lib/plugins/fixtures.sh | 49 +-- scripts/e2e/lib/plugins/marketplace.sh | 103 +---- scripts/e2e/lib/plugins/sweep.sh | 121 +----- 26 files changed, 1413 insertions(+), 1422 deletions(-) create mode 100644 scripts/e2e/lib/bundled-channel/assert-channel-status.mjs create mode 100644 scripts/e2e/lib/bundled-channel/assert-no-staged-manifest-spec.mjs create mode 100644 scripts/e2e/lib/bundled-channel/assert-update-result.mjs create mode 100644 scripts/e2e/lib/bundled-channel/package-version-from-tgz.mjs create mode 100644 scripts/e2e/lib/bundled-channel/write-config.mjs create mode 100644 scripts/e2e/lib/clawhub-fixture-server.cjs create mode 100644 scripts/e2e/lib/config-reload/assert-log.mjs create mode 100644 scripts/e2e/lib/config-reload/mutate-metadata.mjs create mode 100644 scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs delete mode 100644 scripts/e2e/lib/kitchen-sink-plugin/clawhub-fixture-server.cjs create mode 100644 scripts/e2e/lib/plugins/assertions.mjs delete mode 100644 scripts/e2e/lib/plugins/clawhub-fixture-server.cjs diff --git a/scripts/e2e/config-reload-source-docker.sh b/scripts/e2e/config-reload-source-docker.sh index 96f1dd4d785..72c915b3ec1 100755 --- a/scripts/e2e/config-reload-source-docker.sh +++ b/scripts/e2e/config-reload-source-docker.sh @@ -11,7 +11,7 @@ TOKEN="reload-e2e-token" CONTAINER_NAME="openclaw-config-reload-e2e-$$" cleanup() { - docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + docker_e2e_docker_cmd rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true } trap cleanup EXIT @@ -61,27 +61,15 @@ JSON openclaw_e2e_exec_gateway \"\$entry\" $PORT loopback /tmp/config-reload-e2e.log" >/dev/null echo "Waiting for gateway..." -ready=0 -for _ in $(seq 1 180); do - if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null || echo false)" != "true" ]; then - break - fi - if docker exec "$CONTAINER_NAME" bash -lc "source scripts/lib/openclaw-e2e-instance.sh; openclaw_e2e_probe_tcp 127.0.0.1 $PORT" >/dev/null 2>&1; then - ready=1 - break - fi - sleep 0.5 -done - -if [ "$ready" -ne 1 ]; then +if ! docker_e2e_wait_container_bash "$CONTAINER_NAME" 180 0.5 "source scripts/lib/openclaw-e2e-instance.sh; openclaw_e2e_probe_tcp 127.0.0.1 $PORT"; then echo "Gateway failed to start" - docker logs "$CONTAINER_NAME" 2>&1 | tail -n 120 || true - docker exec "$CONTAINER_NAME" bash -lc "tail -n 120 /tmp/config-reload-e2e.log" || true + docker_e2e_docker_cmd logs "$CONTAINER_NAME" 2>&1 | tail -n 120 || true + docker_e2e_docker_cmd exec "$CONTAINER_NAME" bash -lc "tail -n 120 /tmp/config-reload-e2e.log" || true exit 1 fi echo "Checking initial RPC status..." -docker exec "$CONTAINER_NAME" bash -lc " +docker_e2e_docker_cmd exec "$CONTAINER_NAME" bash -lc " source /tmp/openclaw-test-state-env source scripts/lib/openclaw-e2e-instance.sh entry=\"\$(openclaw_e2e_resolve_entrypoint)\" @@ -89,26 +77,19 @@ node \"\$entry\" gateway status --url ws://127.0.0.1:$PORT --token '$TOKEN' --re " echo "Mutating hot-reload gateway metadata..." -docker exec "$CONTAINER_NAME" bash -lc "source /tmp/openclaw-test-state-env -node --input-type=module - <<'NODE' -import fs from 'node:fs'; - -const configPath = process.env.OPENCLAW_CONFIG_PATH; -const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); -config.gateway.channelHealthCheckMinutes = 2; -fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); -NODE" +docker_e2e_docker_cmd exec "$CONTAINER_NAME" bash -lc "source /tmp/openclaw-test-state-env +node scripts/e2e/lib/config-reload/mutate-metadata.mjs" sleep 2 -if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null || echo false)" != "true" ]; then +if [ "$(docker_e2e_docker_cmd inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null || echo false)" != "true" ]; then echo "Gateway container exited after config metadata write" - docker logs "$CONTAINER_NAME" 2>&1 | tail -n 120 || true + docker_e2e_docker_cmd logs "$CONTAINER_NAME" 2>&1 | tail -n 120 || true exit 1 fi echo "Checking post-write RPC status..." -docker exec "$CONTAINER_NAME" bash -lc " +docker_e2e_docker_cmd exec "$CONTAINER_NAME" bash -lc " source /tmp/openclaw-test-state-env source scripts/lib/openclaw-e2e-instance.sh entry=\"\$(openclaw_e2e_resolve_entrypoint)\" @@ -116,33 +97,6 @@ node \"\$entry\" gateway status --url ws://127.0.0.1:$PORT --token '$TOKEN' --re " echo "Checking reload log..." -docker exec "$CONTAINER_NAME" bash -lc "node --input-type=module - <<'NODE' -import fs from 'node:fs'; - -const log = fs.readFileSync('/tmp/config-reload-e2e.log', 'utf8'); -const reloadLines = log - .split('\n') - .filter((line) => line.includes('config change detected; evaluating reload')); -const restartLines = log - .split('\n') - .filter((line) => line.includes('config change requires gateway restart')); -if (restartLines.length > 0) { - console.error(log.split('\n').slice(-160).join('\n')); - throw new Error('unexpected restart-required reload line found'); -} -for (const line of reloadLines) { - for (const needle of ['gateway.auth.token', 'plugins.entries.firecrawl.config.webFetch']) { - if (line.includes(needle)) { - console.error(log.split('\n').slice(-160).join('\n')); - throw new Error('runtime-only path appeared in reload diff: ' + needle); - } - } -} -if (reloadLines.length === 0) { - console.error(log.split('\n').slice(-160).join('\n')); - throw new Error('expected config reload detection log after metadata write'); -} -console.log('ok'); -NODE" +docker_e2e_docker_cmd exec "$CONTAINER_NAME" bash -lc "node scripts/e2e/lib/config-reload/assert-log.mjs" echo "Config reload Docker E2E passed." diff --git a/scripts/e2e/lib/bundled-channel-runtime-deps-runner.sh b/scripts/e2e/lib/bundled-channel-runtime-deps-runner.sh index c5d2ccce18e..8ddf72d7d4e 100644 --- a/scripts/e2e/lib/bundled-channel-runtime-deps-runner.sh +++ b/scripts/e2e/lib/bundled-channel-runtime-deps-runner.sh @@ -4,6 +4,47 @@ # The large scenario bodies stay in the owning test script; this helper keeps # env flag parsing and dispatch in one small, reviewable place. +bundled_channel_state_script_b64() { + docker_e2e_test_state_shell_b64 "$1" empty +} + +run_bundled_channel_container() { + local label="$1" + local timeout_value="$2" + shift 2 + run_logged_print "$label" timeout "$timeout_value" docker run --rm \ + "${DOCKER_E2E_HARNESS_ARGS[@]}" \ + "$@" +} + +run_bundled_channel_container_with_state() { + local label="$1" + local timeout_value="$2" + local state_label="$3" + shift 3 + local state_script_b64 + state_script_b64="$(bundled_channel_state_script_b64 "$state_label")" + run_bundled_channel_container "$label" "$timeout_value" \ + -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ + -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$state_script_b64" \ + "$@" +} + +run_bundled_channel_container_with_state_heartbeat() { + local label="$1" + local heartbeat="$2" + local timeout_value="$3" + local state_label="$4" + shift 4 + local state_script_b64 + state_script_b64="$(bundled_channel_state_script_b64 "$state_label")" + run_logged_print_heartbeat "$label" "$heartbeat" timeout "$timeout_value" docker run --rm \ + "${DOCKER_E2E_HARNESS_ARGS[@]}" \ + -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ + -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$state_script_b64" \ + "$@" +} + run_bundled_channel_runtime_dep_scenarios() { if [ "$RUN_CHANNEL_SCENARIOS" != "0" ]; then IFS=',' read -r -a CHANNEL_SCENARIOS <<<"${OPENCLAW_BUNDLED_CHANNELS:-${CHANNEL_ONLY:-telegram,discord,slack,feishu,memory-lancedb}}" @@ -11,15 +52,15 @@ run_bundled_channel_runtime_dep_scenarios() { channel_scenario="${channel_scenario//[[:space:]]/}" [ -n "$channel_scenario" ] || continue case "$channel_scenario" in - telegram) run_channel_scenario telegram grammy ;; - discord) run_channel_scenario discord discord-api-types ;; - slack) run_channel_scenario slack @slack/web-api ;; - feishu) run_channel_scenario feishu @larksuiteoapi/node-sdk ;; - memory-lancedb) run_channel_scenario memory-lancedb @lancedb/lancedb ;; - *) - echo "Unsupported OPENCLAW_BUNDLED_CHANNELS entry: $channel_scenario" >&2 - exit 1 - ;; + telegram) run_channel_scenario telegram grammy ;; + discord) run_channel_scenario discord discord-api-types ;; + slack) run_channel_scenario slack @slack/web-api ;; + feishu) run_channel_scenario feishu @larksuiteoapi/node-sdk ;; + memory-lancedb) run_channel_scenario memory-lancedb @lancedb/lancedb ;; + *) + echo "Unsupported OPENCLAW_BUNDLED_CHANNELS entry: $channel_scenario" >&2 + exit 1 + ;; esac done fi diff --git a/scripts/e2e/lib/bundled-channel/assert-channel-status.mjs b/scripts/e2e/lib/bundled-channel/assert-channel-status.mjs new file mode 100644 index 00000000000..6a608149153 --- /dev/null +++ b/scripts/e2e/lib/bundled-channel/assert-channel-status.mjs @@ -0,0 +1,22 @@ +import fs from "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`); diff --git a/scripts/e2e/lib/bundled-channel/assert-no-staged-manifest-spec.mjs b/scripts/e2e/lib/bundled-channel/assert-no-staged-manifest-spec.mjs new file mode 100644 index 00000000000..634f4f0f5a3 --- /dev/null +++ b/scripts/e2e/lib/bundled-channel/assert-no-staged-manifest-spec.mjs @@ -0,0 +1,44 @@ +import fs from "node:fs"; +import path from "node:path"; + +const stageDir = process.argv[2]; +const depName = process.argv[3]; +const manifestName = ".openclaw-runtime-deps.json"; +const matches = []; + +function visit(dir) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + visit(fullPath); + continue; + } + if (entry.name !== manifestName) { + continue; + } + let parsed; + try { + parsed = JSON.parse(fs.readFileSync(fullPath, "utf8")); + } catch { + continue; + } + const specs = Array.isArray(parsed.specs) ? parsed.specs : []; + for (const spec of specs) { + if (typeof spec === "string" && spec.startsWith(`${depName}@`)) { + matches.push(`${fullPath}: ${spec}`); + } + } + } +} + +visit(stageDir); +if (matches.length > 0) { + process.stderr.write(`${matches.join("\n")}\n`); + process.exit(1); +} diff --git a/scripts/e2e/lib/bundled-channel/assert-update-result.mjs b/scripts/e2e/lib/bundled-channel/assert-update-result.mjs new file mode 100644 index 00000000000..9a2b6a3bc63 --- /dev/null +++ b/scripts/e2e/lib/bundled-channel/assert-update-result.mjs @@ -0,0 +1,26 @@ +import fs from "node:fs"; + +const payload = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const expectedBefore = process.argv[3]; +const expectedAfter = process.argv[4]; +if (payload.status !== "ok") { + throw new Error(`expected update status ok, got ${JSON.stringify(payload.status)}`); +} +if (expectedBefore && (payload.before?.version ?? null) !== expectedBefore) { + throw new Error( + `expected before.version ${expectedBefore}, got ${JSON.stringify(payload.before?.version)}`, + ); +} +if ((payload.after?.version ?? null) !== expectedAfter) { + throw new Error( + `expected after.version ${expectedAfter}, got ${JSON.stringify(payload.after?.version)}`, + ); +} +const steps = Array.isArray(payload.steps) ? payload.steps : []; +const doctor = steps.find((step) => step?.name === "openclaw doctor"); +if (!doctor) { + throw new Error("missing openclaw doctor step"); +} +if (Number(doctor.exitCode ?? 1) !== 0) { + throw new Error(`openclaw doctor step failed: ${JSON.stringify(doctor)}`); +} diff --git a/scripts/e2e/lib/bundled-channel/channel.sh b/scripts/e2e/lib/bundled-channel/channel.sh index ab3e987b45c..69d8e14fbc2 100644 --- a/scripts/e2e/lib/bundled-channel/channel.sh +++ b/scripts/e2e/lib/bundled-channel/channel.sh @@ -6,16 +6,14 @@ run_channel_scenario() { local channel="$1" local dep_sentinel="$2" - local state_script_b64 - state_script_b64="$(docker_e2e_test_state_shell_b64 "bundled-channel-deps-$channel" empty)" echo "Running bundled $channel runtime deps Docker E2E..." - run_logged_print "bundled-channel-deps-$channel" timeout "$DOCKER_RUN_TIMEOUT" docker run --rm \ - "${DOCKER_E2E_HARNESS_ARGS[@]}" \ - -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ + run_bundled_channel_container_with_state \ + "bundled-channel-deps-$channel" \ + "$DOCKER_RUN_TIMEOUT" \ + "bundled-channel-deps-$channel" \ -e OPENCLAW_CHANNEL_UNDER_TEST="$channel" \ -e OPENCLAW_DEP_SENTINEL="$dep_sentinel" \ - -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$state_script_b64" \ "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ -i "$IMAGE_NAME" bash -s <<'EOF' set -euo pipefail @@ -105,27 +103,7 @@ wait_for_gateway_health() { parse_channel_status_json() { local out="$1" local channel="$2" - 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 + node scripts/e2e/lib/bundled-channel/assert-channel-status.mjs "$out" "$channel" } assert_channel_status() { diff --git a/scripts/e2e/lib/bundled-channel/common.sh b/scripts/e2e/lib/bundled-channel/common.sh index 95c029f02bc..7a788e0811b 100644 --- a/scripts/e2e/lib/bundled-channel/common.sh +++ b/scripts/e2e/lib/bundled-channel/common.sh @@ -109,53 +109,7 @@ bundled_channel_assert_no_staged_manifest_spec() { local channel="$1" local dep_path="$2" local log_file="${3:-}" - if ! node - <<'NODE' "$(bundled_channel_stage_dir)" "$dep_path" -const fs = require("node:fs"); -const path = require("node:path"); - -const stageDir = process.argv[2]; -const depName = process.argv[3]; -const manifestName = ".openclaw-runtime-deps.json"; -const matches = []; - -function visit(dir) { - let entries; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return; - } - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - visit(fullPath); - continue; - } - if (entry.name !== manifestName) { - continue; - } - let parsed; - try { - parsed = JSON.parse(fs.readFileSync(fullPath, "utf8")); - } catch { - continue; - } - const specs = Array.isArray(parsed.specs) ? parsed.specs : []; - for (const spec of specs) { - if (typeof spec === "string" && spec.startsWith(`${depName}@`)) { - matches.push(`${fullPath}: ${spec}`); - } - } - } -} - -visit(stageDir); -if (matches.length > 0) { - process.stderr.write(`${matches.join("\n")}\n`); - process.exit(1); -} -NODE - then + if ! node scripts/e2e/lib/bundled-channel/assert-no-staged-manifest-spec.mjs "$(bundled_channel_stage_dir)" "$dep_path"; then echo "$channel unexpectedly selected $dep_path for external runtime deps" >&2 if [ -n "$log_file" ]; then cat "$log_file" >&2 || true @@ -176,189 +130,8 @@ bundled_channel_remove_runtime_dep() { bundled_channel_write_config() { local mode="$1" - node - <<'NODE' "$mode" "${TOKEN:-bundled-channel-config-token}" "${PORT:-18789}" -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 = - process.env.OPENCLAW_BUNDLED_CHANNEL_CONFIG_PATH || - path.join(process.env.HOME, ".openclaw", "openclaw.json"); -const config = fs.existsSync(configPath) - ? JSON.parse(fs.readFileSync(configPath, "utf8")) - : {}; - -if (mode === "disabled-config") { - const stateDir = path.dirname(configPath); - const disabledConfig = { - gateway: { - mode: "local", - auth: { - mode: "token", - token: "disabled-config-runtime-deps-token", - }, - }, - plugins: { - enabled: true, - entries: { - discord: { enabled: false }, - }, - }, - channels: { - telegram: { - enabled: false, - botToken: "123456:disabled-config-token", - dmPolicy: "disabled", - groupPolicy: "disabled", - }, - slack: { - enabled: false, - botToken: "xoxb-disabled-config-token", - appToken: "xapp-disabled-config-token", - }, - discord: { - enabled: true, - token: "disabled-plugin-entry-token", - dmPolicy: "disabled", - groupPolicy: "disabled", - }, - }, - }; - fs.mkdirSync(path.join(stateDir, "agents", "main", "sessions"), { recursive: true }); - fs.writeFileSync(configPath, `${JSON.stringify(disabledConfig, null, 2)}\n`, "utf8"); - fs.chmodSync(stateDir, 0o700); - fs.chmodSync(configPath, 0o600); - process.exit(0); -} - -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, -}; -config.channels = { - ...(config.channels || {}), - telegram: { - ...(config.channels?.telegram || {}), - enabled: mode === "telegram", - botToken: - process.env.OPENCLAW_BUNDLED_CHANNEL_TELEGRAM_TOKEN || - "123456:bundled-channel-update-token", - dmPolicy: "disabled", - groupPolicy: "disabled", - }, - discord: { - ...(config.channels?.discord || {}), - enabled: mode === "discord", - dmPolicy: "disabled", - groupPolicy: "disabled", - }, - slack: { - ...(config.channels?.slack || {}), - enabled: mode === "slack", - botToken: - process.env.OPENCLAW_BUNDLED_CHANNEL_SLACK_BOT_TOKEN || - "xoxb-bundled-channel-update-token", - appToken: - process.env.OPENCLAW_BUNDLED_CHANNEL_SLACK_APP_TOKEN || - "xapp-bundled-channel-update-token", - }, - feishu: { - ...(config.channels?.feishu || {}), - enabled: mode === "feishu", - }, -}; -if (mode === "memory-lancedb") { - config.plugins = { - ...(config.plugins || {}), - enabled: true, - allow: [...new Set([...(config.plugins?.allow || []), "memory-lancedb"])], - slots: { - ...(config.plugins?.slots || {}), - memory: "memory-lancedb", - }, - entries: { - ...(config.plugins?.entries || {}), - "memory-lancedb": { - ...(config.plugins?.entries?.["memory-lancedb"] || {}), - enabled: true, - config: { - ...(config.plugins?.entries?.["memory-lancedb"]?.config || {}), - embedding: { - ...(config.plugins?.entries?.["memory-lancedb"]?.config?.embedding || {}), - apiKey: process.env.OPENAI_API_KEY, - model: "text-embedding-3-small", - }, - dbPath: process.env.OPENCLAW_BUNDLED_CHANNEL_MEMORY_DB_PATH || "~/.openclaw/memory/lancedb-e2e", - autoCapture: false, - autoRecall: false, - }, - }, - }, - }; -} -if (mode === "acpx") { - config.plugins = { - ...(config.plugins || {}), - enabled: true, - allow: - Array.isArray(config.plugins?.allow) && config.plugins.allow.length > 0 - ? [...new Set([...config.plugins.allow, "acpx"])] - : config.plugins?.allow, - entries: { - ...(config.plugins?.entries || {}), - acpx: { - ...(config.plugins?.entries?.acpx || {}), - enabled: true, - }, - }, - }; -} -if (mode === "setup-entry-channels") { - config.plugins = { - ...(config.plugins || {}), - enabled: true, - }; - config.channels = { - ...(config.channels || {}), - feishu: { - ...(config.channels?.feishu || {}), - enabled: true, - }, - whatsapp: { - ...(config.channels?.whatsapp || {}), - enabled: true, - }, - }; -} - -fs.mkdirSync(path.dirname(configPath), { recursive: true }); -fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); -NODE + node scripts/e2e/lib/bundled-channel/write-config.mjs \ + "$mode" \ + "${TOKEN:-bundled-channel-config-token}" \ + "${PORT:-18789}" } diff --git a/scripts/e2e/lib/bundled-channel/disabled-config.sh b/scripts/e2e/lib/bundled-channel/disabled-config.sh index 3bc53c60448..dc87d5c4826 100644 --- a/scripts/e2e/lib/bundled-channel/disabled-config.sh +++ b/scripts/e2e/lib/bundled-channel/disabled-config.sh @@ -4,14 +4,11 @@ # Sourced by scripts/e2e/bundled-channel-runtime-deps-docker.sh. run_disabled_config_scenario() { - local state_script_b64 - state_script_b64="$(docker_e2e_test_state_shell_b64 bundled-channel-disabled-config empty)" - echo "Running bundled channel disabled-config runtime deps Docker E2E..." - run_logged_print bundled-channel-disabled-config timeout "$DOCKER_RUN_TIMEOUT" docker run --rm \ - "${DOCKER_E2E_HARNESS_ARGS[@]}" \ - -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ - -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$state_script_b64" \ + run_bundled_channel_container_with_state \ + bundled-channel-disabled-config \ + "$DOCKER_RUN_TIMEOUT" \ + bundled-channel-disabled-config \ "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ -i "$IMAGE_NAME" bash -s <<'EOF' set -euo pipefail diff --git a/scripts/e2e/lib/bundled-channel/load-failure.sh b/scripts/e2e/lib/bundled-channel/load-failure.sh index fe15c2c9ad3..0babce01617 100644 --- a/scripts/e2e/lib/bundled-channel/load-failure.sh +++ b/scripts/e2e/lib/bundled-channel/load-failure.sh @@ -4,14 +4,11 @@ # Sourced by scripts/e2e/bundled-channel-runtime-deps-docker.sh. run_load_failure_scenario() { - local state_script_b64 - state_script_b64="$(docker_e2e_test_state_shell_b64 bundled-channel-load-failure empty)" - echo "Running bundled channel load-failure isolation Docker E2E..." - run_logged_print bundled-channel-load-failure timeout "$DOCKER_RUN_TIMEOUT" docker run --rm \ - "${DOCKER_E2E_HARNESS_ARGS[@]}" \ - -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ - -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$state_script_b64" \ + run_bundled_channel_container_with_state \ + bundled-channel-load-failure \ + "$DOCKER_RUN_TIMEOUT" \ + bundled-channel-load-failure \ "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ -i "$IMAGE_NAME" bash -s <<'EOF' set -euo pipefail diff --git a/scripts/e2e/lib/bundled-channel/package-version-from-tgz.mjs b/scripts/e2e/lib/bundled-channel/package-version-from-tgz.mjs new file mode 100644 index 00000000000..b0fa9ede611 --- /dev/null +++ b/scripts/e2e/lib/bundled-channel/package-version-from-tgz.mjs @@ -0,0 +1,6 @@ +import { execFileSync } from "node:child_process"; + +const raw = execFileSync("tar", ["-xOf", process.argv[2], "package/package.json"], { + encoding: "utf8", +}); +process.stdout.write(String(JSON.parse(raw).version)); diff --git a/scripts/e2e/lib/bundled-channel/root-owned.sh b/scripts/e2e/lib/bundled-channel/root-owned.sh index b96aac32d14..741300932f2 100644 --- a/scripts/e2e/lib/bundled-channel/root-owned.sh +++ b/scripts/e2e/lib/bundled-channel/root-owned.sh @@ -5,8 +5,7 @@ run_root_owned_global_scenario() { echo "Running bundled channel root-owned global install Docker E2E..." - run_logged_print bundled-channel-root-owned timeout "$DOCKER_RUN_TIMEOUT" docker run --rm \ - "${DOCKER_E2E_HARNESS_ARGS[@]}" \ + run_bundled_channel_container bundled-channel-root-owned "$DOCKER_RUN_TIMEOUT" \ --user root \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ diff --git a/scripts/e2e/lib/bundled-channel/setup-entry.sh b/scripts/e2e/lib/bundled-channel/setup-entry.sh index d6461aa8780..9529052a6bf 100644 --- a/scripts/e2e/lib/bundled-channel/setup-entry.sh +++ b/scripts/e2e/lib/bundled-channel/setup-entry.sh @@ -4,14 +4,11 @@ # Sourced by scripts/e2e/bundled-channel-runtime-deps-docker.sh. run_setup_entry_scenario() { - local state_script_b64 - state_script_b64="$(docker_e2e_test_state_shell_b64 bundled-channel-setup-entry empty)" - echo "Running bundled channel setup-entry runtime deps Docker E2E..." - run_logged_print bundled-channel-setup-entry timeout "$DOCKER_RUN_TIMEOUT" docker run --rm \ - "${DOCKER_E2E_HARNESS_ARGS[@]}" \ - -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ - -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$state_script_b64" \ + run_bundled_channel_container_with_state \ + bundled-channel-setup-entry \ + "$DOCKER_RUN_TIMEOUT" \ + bundled-channel-setup-entry \ "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ -i "$IMAGE_NAME" bash -s <<'EOF' set -euo pipefail diff --git a/scripts/e2e/lib/bundled-channel/update.sh b/scripts/e2e/lib/bundled-channel/update.sh index 837ec5dd271..c8657dd8340 100644 --- a/scripts/e2e/lib/bundled-channel/update.sh +++ b/scripts/e2e/lib/bundled-channel/update.sh @@ -4,16 +4,14 @@ # Sourced by scripts/e2e/bundled-channel-runtime-deps-docker.sh. run_update_scenario() { - local state_script_b64 - state_script_b64="$(docker_e2e_test_state_shell_b64 bundled-channel-update empty)" - echo "Running bundled channel runtime deps Docker update E2E..." - run_logged_print_heartbeat bundled-channel-update 30 timeout "$DOCKER_UPDATE_RUN_TIMEOUT" docker run --rm \ - "${DOCKER_E2E_HARNESS_ARGS[@]}" \ - -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ + run_bundled_channel_container_with_state_heartbeat \ + bundled-channel-update \ + 30 \ + "$DOCKER_UPDATE_RUN_TIMEOUT" \ + bundled-channel-update \ -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}" \ - -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$state_script_b64" \ "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ -i "$IMAGE_NAME" bash -s <<'EOF' set -euo pipefail @@ -49,45 +47,12 @@ assert_no_unknown_stage_roots() { package_tgz="${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}" update_target="file:$package_tgz" -candidate_version="$(node - <<'NODE' "$package_tgz" -const { execFileSync } = require("node:child_process"); -const raw = execFileSync("tar", ["-xOf", process.argv[2], "package/package.json"], { - encoding: "utf8", -}); -process.stdout.write(String(JSON.parse(raw).version)); -NODE -)" +candidate_version="$(node scripts/e2e/lib/bundled-channel/package-version-from-tgz.mjs "$package_tgz")" assert_update_ok() { local json_file="$1" local expected_before="$2" - node - <<'NODE' "$json_file" "$expected_before" "$candidate_version" -const fs = require("node:fs"); -const payload = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); -const expectedBefore = process.argv[3]; -const expectedAfter = process.argv[4]; -if (payload.status !== "ok") { - throw new Error(`expected update status ok, got ${JSON.stringify(payload.status)}`); -} -if (expectedBefore && (payload.before?.version ?? null) !== expectedBefore) { - throw new Error( - `expected before.version ${expectedBefore}, got ${JSON.stringify(payload.before?.version)}`, - ); -} -if ((payload.after?.version ?? null) !== expectedAfter) { - throw new Error( - `expected after.version ${expectedAfter}, got ${JSON.stringify(payload.after?.version)}`, - ); -} -const steps = Array.isArray(payload.steps) ? payload.steps : []; -const doctor = steps.find((step) => step?.name === "openclaw doctor"); -if (!doctor) { - throw new Error("missing openclaw doctor step"); -} -if (Number(doctor.exitCode ?? 1) !== 0) { - throw new Error(`openclaw doctor step failed: ${JSON.stringify(doctor)}`); -} -NODE + node scripts/e2e/lib/bundled-channel/assert-update-result.mjs "$json_file" "$expected_before" "$candidate_version" } run_update_and_capture() { diff --git a/scripts/e2e/lib/bundled-channel/write-config.mjs b/scripts/e2e/lib/bundled-channel/write-config.mjs new file mode 100644 index 00000000000..374577ee634 --- /dev/null +++ b/scripts/e2e/lib/bundled-channel/write-config.mjs @@ -0,0 +1,179 @@ +import fs from "node:fs"; +import path from "node:path"; + +const mode = process.argv[2]; +const token = process.argv[3]; +const port = Number(process.argv[4]); +const configPath = + process.env.OPENCLAW_BUNDLED_CHANNEL_CONFIG_PATH || + path.join(process.env.HOME, ".openclaw", "openclaw.json"); +const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, "utf8")) : {}; + +if (mode === "disabled-config") { + const stateDir = path.dirname(configPath); + const disabledConfig = { + gateway: { + mode: "local", + auth: { + mode: "token", + token: "disabled-config-runtime-deps-token", + }, + }, + plugins: { + enabled: true, + entries: { + discord: { enabled: false }, + }, + }, + channels: { + telegram: { + enabled: false, + botToken: "123456:disabled-config-token", + dmPolicy: "disabled", + groupPolicy: "disabled", + }, + slack: { + enabled: false, + botToken: "xoxb-disabled-config-token", + appToken: "xapp-disabled-config-token", + }, + discord: { + enabled: true, + token: "disabled-plugin-entry-token", + dmPolicy: "disabled", + groupPolicy: "disabled", + }, + }, + }; + fs.mkdirSync(path.join(stateDir, "agents", "main", "sessions"), { recursive: true }); + fs.writeFileSync(configPath, `${JSON.stringify(disabledConfig, null, 2)}\n`, "utf8"); + fs.chmodSync(stateDir, 0o700); + fs.chmodSync(configPath, 0o600); + process.exit(0); +} + +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, +}; +config.channels = { + ...config.channels, + telegram: { + ...config.channels?.telegram, + enabled: mode === "telegram", + botToken: + process.env.OPENCLAW_BUNDLED_CHANNEL_TELEGRAM_TOKEN || "123456:bundled-channel-update-token", + dmPolicy: "disabled", + groupPolicy: "disabled", + }, + discord: { + ...config.channels?.discord, + enabled: mode === "discord", + dmPolicy: "disabled", + groupPolicy: "disabled", + }, + slack: { + ...config.channels?.slack, + enabled: mode === "slack", + botToken: + process.env.OPENCLAW_BUNDLED_CHANNEL_SLACK_BOT_TOKEN || "xoxb-bundled-channel-update-token", + appToken: + process.env.OPENCLAW_BUNDLED_CHANNEL_SLACK_APP_TOKEN || "xapp-bundled-channel-update-token", + }, + feishu: { + ...config.channels?.feishu, + enabled: mode === "feishu", + }, +}; +if (mode === "memory-lancedb") { + config.plugins = { + ...config.plugins, + enabled: true, + allow: [...new Set([...(config.plugins?.allow || []), "memory-lancedb"])], + slots: { + ...config.plugins?.slots, + memory: "memory-lancedb", + }, + entries: { + ...config.plugins?.entries, + "memory-lancedb": { + ...config.plugins?.entries?.["memory-lancedb"], + enabled: true, + config: { + ...config.plugins?.entries?.["memory-lancedb"]?.config, + embedding: { + ...config.plugins?.entries?.["memory-lancedb"]?.config?.embedding, + apiKey: process.env.OPENAI_API_KEY, + model: "text-embedding-3-small", + }, + dbPath: + process.env.OPENCLAW_BUNDLED_CHANNEL_MEMORY_DB_PATH || "~/.openclaw/memory/lancedb-e2e", + autoCapture: false, + autoRecall: false, + }, + }, + }, + }; +} +if (mode === "acpx") { + config.plugins = { + ...config.plugins, + enabled: true, + allow: + Array.isArray(config.plugins?.allow) && config.plugins.allow.length > 0 + ? [...new Set([...config.plugins.allow, "acpx"])] + : config.plugins?.allow, + entries: { + ...config.plugins?.entries, + acpx: { + ...config.plugins?.entries?.acpx, + enabled: true, + }, + }, + }; +} +if (mode === "setup-entry-channels") { + config.plugins = { + ...config.plugins, + enabled: true, + }; + config.channels = { + ...config.channels, + feishu: { + ...config.channels?.feishu, + enabled: true, + }, + whatsapp: { + ...config.channels?.whatsapp, + enabled: true, + }, + }; +} + +fs.mkdirSync(path.dirname(configPath), { recursive: true }); +fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); diff --git a/scripts/e2e/lib/clawhub-fixture-server.cjs b/scripts/e2e/lib/clawhub-fixture-server.cjs new file mode 100644 index 00000000000..f02ba6340e0 --- /dev/null +++ b/scripts/e2e/lib/clawhub-fixture-server.cjs @@ -0,0 +1,257 @@ +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const http = require("node:http"); +const path = require("node:path"); +const { createRequire } = require("node:module"); + +const profile = process.argv[2]; +const portFile = process.argv[3]; +const requireFromApp = createRequire(path.join(process.cwd(), "package.json")); +const JSZip = requireFromApp("jszip"); +const packageName = "openclaw-kitchen-sink"; +const pluginId = "openclaw-kitchen-sink-fixture"; + +const profiles = { + "kitchen-sink-plugin": { + version: "0.1.3", + packageJson: { + name: packageName, + version: "0.1.3", + openclaw: { extensions: ["./index.js"] }, + }, + indexJs: `module.exports = { + id: "${pluginId}", + name: "OpenClaw Kitchen Sink", + register(api) { + api.registerProvider({ + id: "kitchen-sink-provider", + label: "Kitchen Sink Provider", + docsPath: "/providers/kitchen-sink", + auth: [], + }); + api.registerChannel({ + plugin: { + id: "kitchen-sink-channel", + meta: { + id: "kitchen-sink-channel", + label: "Kitchen Sink Channel", + selectionLabel: "Kitchen Sink", + docsPath: "/channels/kitchen-sink", + blurb: "Kitchen sink ClawHub fixture channel", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({ accountId: "default" }), + }, + outbound: { deliveryMode: "direct" }, + }, + }); + }, +}; +`, + manifest: { + id: pluginId, + name: "OpenClaw Kitchen Sink", + channels: ["kitchen-sink-channel"], + providers: ["kitchen-sink-provider"], + configSchema: { + type: "object", + properties: {}, + }, + }, + packageDetail(sha256hash) { + const packageDetail = { + package: { + name: packageName, + displayName: "OpenClaw Kitchen Sink", + family: "code-plugin", + runtimeId: pluginId, + channel: "official", + isOfficial: true, + summary: "Kitchen sink plugin fixture for prerelease CI.", + ownerHandle: "openclaw", + createdAt: 0, + updatedAt: 0, + latestVersion: this.version, + tags: { latest: this.version }, + capabilityTags: ["test-fixture"], + executesCode: true, + compatibility: { + pluginApiRange: ">=2026.4.11", + minGatewayVersion: "2026.4.11", + }, + capabilities: { + executesCode: true, + runtimeId: pluginId, + capabilityTags: ["test-fixture"], + channels: ["kitchen-sink-channel"], + providers: ["kitchen-sink-provider"], + }, + verification: { + tier: "source-linked", + sourceRepo: "https://github.com/openclaw/kitchen-sink", + hasProvenance: false, + scanStatus: "passed", + }, + }, + }; + return { + packageDetail, + versionDetail: { + package: { + name: packageName, + displayName: "OpenClaw Kitchen Sink", + family: "code-plugin", + }, + version: { + version: this.version, + createdAt: 0, + changelog: "Fixture package for kitchen-sink plugin prerelease CI.", + distTags: ["latest"], + sha256hash, + compatibility: packageDetail.package.compatibility, + capabilities: packageDetail.package.capabilities, + verification: packageDetail.package.verification, + }, + }, + betaStatus: 404, + }; + }, + }, + plugins: { + version: "0.1.0", + packageJson: { + name: packageName, + version: "0.1.0", + openclaw: { extensions: ["./index.js"] }, + }, + indexJs: `module.exports = { + id: "${pluginId}", + name: "OpenClaw Kitchen Sink", + description: "Docker E2E kitchen-sink plugin fixture", + register(api) { + api.on("before_agent_start", async (event, context) => ({ + kitchenSink: true, + observedEventKeys: Object.keys(event || {}), + observedContextKeys: Object.keys(context || {}), + })); + api.registerTool(() => null, { name: "kitchen_sink_tool" }); + api.registerGatewayMethod("kitchen-sink.ping", async () => ({ ok: true })); + api.registerCli(() => {}, { commands: ["kitchen-sink"] }); + api.registerService({ id: "kitchen-sink-service", start: () => {} }); + }, +}; +`, + manifest: { + id: pluginId, + configSchema: { + type: "object", + properties: {}, + }, + }, + packageDetail(sha256hash) { + const compatibility = { + pluginApiRange: ">=2026.4.26", + minGatewayVersion: "2026.4.26", + }; + return { + packageDetail: { + package: { + name: packageName, + displayName: "OpenClaw Kitchen Sink", + family: "code-plugin", + channel: "official", + isOfficial: true, + runtimeId: pluginId, + latestVersion: this.version, + createdAt: 0, + updatedAt: 0, + compatibility, + }, + }, + versionDetail: { + version: { + version: this.version, + createdAt: 0, + changelog: "Kitchen-sink fixture package for Docker plugin E2E.", + sha256hash, + compatibility, + }, + }, + }; + }, + }, +}; + +const fixture = profiles[profile]; +if (!fixture || !portFile) { + console.error("usage: clawhub-fixture-server.cjs "); + process.exit(1); +} + +async function main() { + const zip = new JSZip(); + zip.file("package/package.json", `${JSON.stringify(fixture.packageJson, null, 2)}\n`, { + date: new Date(0), + }); + zip.file("package/index.js", fixture.indexJs, { date: new Date(0) }); + zip.file("package/openclaw.plugin.json", `${JSON.stringify(fixture.manifest, null, 2)}\n`, { + date: new Date(0), + }); + + const archive = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" }); + const sha256hash = crypto.createHash("sha256").update(archive).digest("hex"); + const { packageDetail, versionDetail, betaStatus } = fixture.packageDetail(sha256hash); + + const json = (response, value, status = 200) => { + response.writeHead(status, { "content-type": "application/json" }); + response.end(`${JSON.stringify(value)}\n`); + }; + + const server = http.createServer((request, response) => { + const url = new URL(request.url, "http://127.0.0.1"); + if (request.method !== "GET") { + response.writeHead(405); + response.end("method not allowed"); + return; + } + if (url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}`) { + json(response, packageDetail); + return; + } + if ( + url.pathname === + `/api/v1/packages/${encodeURIComponent(packageName)}/versions/${fixture.version}` + ) { + json(response, versionDetail); + return; + } + if ( + betaStatus !== undefined && + url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}/versions/beta` + ) { + json(response, { error: "version not found" }, betaStatus ?? 404); + return; + } + if (url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}/download`) { + response.writeHead(200, { + "content-type": "application/zip", + "content-length": String(archive.length), + }); + response.end(archive); + return; + } + response.writeHead(404, { "content-type": "text/plain" }); + response.end(`not found: ${url.pathname}`); + }); + + server.listen(0, "127.0.0.1", () => { + fs.writeFileSync(portFile, String(server.address().port)); + }); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/e2e/lib/config-reload/assert-log.mjs b/scripts/e2e/lib/config-reload/assert-log.mjs new file mode 100644 index 00000000000..8d9c78a0d0d --- /dev/null +++ b/scripts/e2e/lib/config-reload/assert-log.mjs @@ -0,0 +1,26 @@ +import fs from "node:fs"; + +const log = fs.readFileSync("/tmp/config-reload-e2e.log", "utf8"); +const reloadLines = log + .split("\n") + .filter((line) => line.includes("config change detected; evaluating reload")); +const restartLines = log + .split("\n") + .filter((line) => line.includes("config change requires gateway restart")); + +if (restartLines.length > 0) { + console.error(log.split("\n").slice(-160).join("\n")); + throw new Error("unexpected restart-required reload line found"); +} +for (const line of reloadLines) { + for (const needle of ["gateway.auth.token", "plugins.entries.firecrawl.config.webFetch"]) { + if (line.includes(needle)) { + console.error(log.split("\n").slice(-160).join("\n")); + throw new Error(`runtime-only path appeared in reload diff: ${needle}`); + } + } +} +if (reloadLines.length === 0) { + console.error(log.split("\n").slice(-160).join("\n")); + throw new Error("expected config reload detection log after metadata write"); +} diff --git a/scripts/e2e/lib/config-reload/mutate-metadata.mjs b/scripts/e2e/lib/config-reload/mutate-metadata.mjs new file mode 100644 index 00000000000..bf90bd60d60 --- /dev/null +++ b/scripts/e2e/lib/config-reload/mutate-metadata.mjs @@ -0,0 +1,6 @@ +import fs from "node:fs"; + +const configPath = process.env.OPENCLAW_CONFIG_PATH; +const config = JSON.parse(fs.readFileSync(configPath, "utf8")); +config.gateway.channelHealthCheckMinutes = 2; +fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); diff --git a/scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs b/scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs new file mode 100644 index 00000000000..e1018328bbe --- /dev/null +++ b/scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs @@ -0,0 +1,326 @@ +import fs from "node:fs"; +import path from "node:path"; + +const command = process.argv[2]; + +const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8")); + +function expectFailure() { + const outputFile = process.argv[3]; + const output = fs.readFileSync(outputFile, "utf8"); + const source = process.env.KITCHEN_SINK_SOURCE; + const spec = process.env.KITCHEN_SINK_SPEC; + const displayedSpec = source === "npm" ? spec.replace(/^npm:/u, "") : spec; + const expected = + source === "clawhub" + ? /Version not found on ClawHub|ClawHub .* failed \(404\)|version.*not found/iu + : /No matching version|ETARGET|notarget|npm (?:error|ERR!)/iu; + if (!output.includes(displayedSpec)) { + throw new Error(`expected failure output to mention ${displayedSpec}`); + } + if (!expected.test(output)) { + throw new Error(`unexpected ${source} beta failure output:\n${output}`); + } +} + +function scanLogs() { + const roots = ["/tmp", path.join(process.env.HOME, ".openclaw")]; + const files = []; + const visit = (entry) => { + if (!fs.existsSync(entry)) { + return; + } + const stat = fs.statSync(entry); + if (stat.isDirectory()) { + for (const child of fs.readdirSync(entry)) { + visit(path.join(entry, child)); + } + return; + } + if (/\.(?:log|jsonl)$/u.test(entry) || /openclaw-kitchen-sink-/u.test(path.basename(entry))) { + if (entry.includes("/.npm/_logs/")) { + return; + } + files.push(entry); + } + }; + for (const root of roots) { + visit(root); + } + + const deny = [ + /\buncaught exception\b/iu, + /\bunhandled rejection\b/iu, + /\bfatal\b/iu, + /\bpanic\b/iu, + /\blevel["']?\s*:\s*["']error["']/iu, + /\[(?:error|ERROR)\]/u, + ]; + const allow = [/0 errors?/iu, /expected no diagnostics errors?/iu, /diagnostics errors?:\s*$/iu]; + const findings = []; + for (const file of files) { + const text = fs.readFileSync(file, "utf8"); + const lines = text.split(/\r?\n/u); + lines.forEach((line, index) => { + if (allow.some((pattern) => pattern.test(line))) { + return; + } + if (deny.some((pattern) => pattern.test(line))) { + findings.push(`${file}:${index + 1}: ${line}`); + } + }); + } + if (findings.length > 0) { + throw new Error(`unexpected error-like log lines:\n${findings.join("\n")}`); + } + console.log(`log scan passed (${files.length} file(s))`); +} + +function readConfig() { + const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); + return { + configPath, + exists: fs.existsSync(configPath), + config: fs.existsSync(configPath) ? readJson(configPath) : {}, + }; +} + +function configureRuntime() { + const pluginId = process.env.KITCHEN_SINK_ID; + const { configPath, config } = readConfig(); + config.plugins = config.plugins || {}; + config.plugins.entries = config.plugins.entries || {}; + config.plugins.entries[pluginId] = { + ...config.plugins.entries[pluginId], + hooks: { + ...config.plugins.entries[pluginId]?.hooks, + allowConversationAccess: true, + }, + }; + config.channels = { + ...config.channels, + "kitchen-sink-channel": { enabled: true, token: "kitchen-sink-ci" }, + }; + fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`); +} + +function removeChannelConfig() { + const { configPath, exists, config } = readConfig(); + if (!exists) { + return; + } + delete config.channels?.["kitchen-sink-channel"]; + fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`); +} + +const expectIncludes = (listValue, expected, field) => { + if (!Array.isArray(listValue) || !listValue.includes(expected)) { + throw new Error(`${field} missing ${expected}: ${JSON.stringify(listValue)}`); + } +}; + +function assertInstalled() { + const pluginId = process.env.KITCHEN_SINK_ID; + const spec = process.env.KITCHEN_SINK_SPEC; + const source = process.env.KITCHEN_SINK_SOURCE; + const surfaceMode = process.env.KITCHEN_SINK_SURFACE_MODE; + const label = process.env.KITCHEN_SINK_LABEL; + const list = readJson(`/tmp/kitchen-sink-${label}-plugins.json`); + const inspect = readJson(`/tmp/kitchen-sink-${label}-inspect.json`); + const allInspect = readJson(`/tmp/kitchen-sink-${label}-inspect-all.json`); + const plugin = (list.plugins || []).find((entry) => entry.id === pluginId); + if (!plugin) { + throw new Error(`kitchen-sink plugin not found after install: ${pluginId}`); + } + if (plugin.status !== "loaded") { + throw new Error(`unexpected kitchen-sink status after enable: ${plugin.status}`); + } + if (inspect.plugin?.id !== pluginId) { + throw new Error(`unexpected inspected kitchen-sink plugin id: ${inspect.plugin?.id}`); + } + if (inspect.plugin?.enabled !== true || inspect.plugin?.status !== "loaded") { + throw new Error( + `expected enabled loaded kitchen-sink plugin, got enabled=${inspect.plugin?.enabled} status=${inspect.plugin?.status}`, + ); + } + + expectIncludes(inspect.plugin?.channelIds, "kitchen-sink-channel", "channels"); + expectIncludes(inspect.plugin?.providerIds, "kitchen-sink-provider", "providers"); + + const diagnostics = [ + ...(list.diagnostics || []), + ...(inspect.diagnostics || []), + ...(allInspect.diagnostics || []), + ]; + const errorMessages = new Set( + diagnostics.filter((diag) => diag?.level === "error").map((diag) => String(diag.message || "")), + ); + + if (surfaceMode === "full") { + const toolNames = Array.isArray(inspect.tools) + ? inspect.tools.flatMap((entry) => (Array.isArray(entry?.names) ? entry.names : [])) + : []; + const pluginSurfaceIds = { + speechProviderIds: ["kitchen-sink-speech-provider", "speech providers"], + realtimeTranscriptionProviderIds: [ + "kitchen-sink-realtime-transcription-provider", + "realtime transcription providers", + ], + realtimeVoiceProviderIds: [ + "kitchen-sink-realtime-voice-provider", + "realtime voice providers", + ], + mediaUnderstandingProviderIds: [ + "kitchen-sink-media-understanding-provider", + "media understanding providers", + ], + imageGenerationProviderIds: [ + "kitchen-sink-image-generation-provider", + "image generation providers", + ], + videoGenerationProviderIds: [ + "kitchen-sink-video-generation-provider", + "video generation providers", + ], + musicGenerationProviderIds: [ + "kitchen-sink-music-generation-provider", + "music generation providers", + ], + webFetchProviderIds: ["kitchen-sink-web-fetch-provider", "web fetch providers"], + webSearchProviderIds: ["kitchen-sink-web-search-provider", "web search providers"], + migrationProviderIds: ["kitchen-sink-migration-provider", "migration providers"], + agentHarnessIds: ["kitchen-sink-agent-harness", "agent harnesses"], + }; + for (const [field, [id, label]] of Object.entries(pluginSurfaceIds)) { + expectIncludes(inspect.plugin?.[field], id, label); + } + expectIncludes(inspect.services, "kitchen-sink-service", "services"); + expectIncludes(inspect.commands, "kitchen-sink-command", "commands"); + expectIncludes(toolNames, "kitchen-sink-tool", "tools"); + if ( + (inspect.plugin?.hookCount || 0) < 30 || + !Array.isArray(inspect.typedHooks) || + inspect.typedHooks.length < 30 + ) { + throw new Error( + `expected kitchen-sink typed hooks to load, got hookCount=${inspect.plugin?.hookCount} typedHooks=${inspect.typedHooks?.length}`, + ); + } + + const expectedErrorMessages = new Set([ + "only bundled plugins can register agent tool result middleware", + "cli registration missing explicit commands metadata", + "only bundled plugins can register Codex app-server extension factories", + "http route registration missing or invalid auth: /kitchen-sink/http-route", + "plugin must own memory slot or declare contracts.memoryEmbeddingProviders for adapter: kitchen-sink-memory-embedding-provider", + ]); + for (const message of errorMessages) { + if (!expectedErrorMessages.has(message)) { + throw new Error(`unexpected kitchen-sink diagnostic error: ${message}`); + } + } + for (const message of expectedErrorMessages) { + if (!errorMessages.has(message)) { + throw new Error(`missing expected kitchen-sink diagnostic error: ${message}`); + } + } + } else if (errorMessages.size > 0) { + throw new Error(`unexpected kitchen-sink diagnostic errors: ${[...errorMessages].join(", ")}`); + } + + const indexPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json"); + const index = readJson(indexPath); + const record = (index.installRecords ?? index.records ?? {})[pluginId]; + if (!record) { + throw new Error(`missing kitchen-sink install record for ${pluginId}`); + } + if (record.source !== source) { + throw new Error(`expected kitchen-sink install source=${source}, got ${record.source}`); + } + if (source === "npm") { + const expectedSpec = spec.replace(/^npm:/u, ""); + if (record.spec !== expectedSpec) { + throw new Error(`expected kitchen-sink npm spec ${expectedSpec}, got ${record.spec}`); + } + if (!record.resolvedVersion || !record.resolvedSpec) { + throw new Error(`missing npm resolution metadata: ${JSON.stringify(record)}`); + } + } else if (source === "clawhub") { + const value = spec.slice("clawhub:".length).trim(); + const slashIndex = value.lastIndexOf("/"); + const atIndex = value.lastIndexOf("@"); + const packageName = atIndex > 0 && atIndex > slashIndex ? value.slice(0, atIndex) : value; + if (record.spec !== spec) { + throw new Error(`expected kitchen-sink ClawHub spec ${spec}, got ${record.spec}`); + } + if (record.clawhubPackage !== packageName) { + throw new Error(`expected ClawHub package ${packageName}, got ${record.clawhubPackage}`); + } + if (record.clawhubFamily !== "code-plugin" && record.clawhubFamily !== "bundle-plugin") { + throw new Error(`unexpected ClawHub family: ${record.clawhubFamily}`); + } + if (!record.version || !record.integrity || !record.resolvedAt) { + throw new Error(`missing ClawHub resolution metadata: ${JSON.stringify(record)}`); + } + } + if (typeof record.installPath !== "string" || record.installPath.length === 0) { + throw new Error("missing kitchen-sink install path"); + } + const installPath = record.installPath.replace(/^~(?=$|\/)/u, process.env.HOME); + if (!fs.existsSync(installPath)) { + throw new Error(`kitchen-sink install path missing: ${record.installPath}`); + } + fs.writeFileSync(`/tmp/kitchen-sink-${label}-install-path.txt`, installPath, "utf8"); +} + +function assertRemoved() { + const pluginId = process.env.KITCHEN_SINK_ID; + const label = process.env.KITCHEN_SINK_LABEL; + const list = readJson(`/tmp/kitchen-sink-${label}-uninstalled.json`); + if ((list.plugins || []).some((entry) => entry.id === pluginId)) { + throw new Error(`kitchen-sink plugin still listed after uninstall: ${pluginId}`); + } + + const indexPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json"); + const index = fs.existsSync(indexPath) ? readJson(indexPath) : {}; + const records = index.installRecords ?? index.records ?? {}; + if (records[pluginId]) { + throw new Error(`kitchen-sink install record still present after uninstall: ${pluginId}`); + } + + const { config } = readConfig(); + if (config.plugins?.entries?.[pluginId]) { + throw new Error(`kitchen-sink config entry still present after uninstall: ${pluginId}`); + } + if ((config.plugins?.allow || []).includes(pluginId)) { + throw new Error(`kitchen-sink allowlist still contains ${pluginId}`); + } + if ((config.plugins?.deny || []).includes(pluginId)) { + throw new Error(`kitchen-sink denylist still contains ${pluginId}`); + } + if (config.channels?.["kitchen-sink-channel"]) { + throw new Error("kitchen-sink channel config still present after uninstall"); + } + const installPathFile = `/tmp/kitchen-sink-${label}-install-path.txt`; + if (fs.existsSync(installPathFile)) { + const installPath = fs.readFileSync(installPathFile, "utf8").trim(); + if (installPath && fs.existsSync(installPath)) { + throw new Error(`kitchen-sink managed install directory still exists: ${installPath}`); + } + } +} + +const commands = { + "expect-failure": expectFailure, + "scan-logs": scanLogs, + "configure-runtime": configureRuntime, + "remove-channel-config": removeChannelConfig, + "assert-installed": assertInstalled, + "assert-removed": assertRemoved, +}; + +const fn = commands[command]; +if (!fn) { + throw new Error(`unknown kitchen-sink assertion command: ${command}`); +} +fn(); diff --git a/scripts/e2e/lib/kitchen-sink-plugin/clawhub-fixture-server.cjs b/scripts/e2e/lib/kitchen-sink-plugin/clawhub-fixture-server.cjs deleted file mode 100644 index 19571c996c7..00000000000 --- a/scripts/e2e/lib/kitchen-sink-plugin/clawhub-fixture-server.cjs +++ /dev/null @@ -1,183 +0,0 @@ -const crypto = require("node:crypto"); -const http = require("node:http"); -const path = require("node:path"); -const { createRequire } = require("node:module"); - -const portFile = process.argv[2]; -const requireFromApp = createRequire(path.join(process.cwd(), "package.json")); -const JSZip = requireFromApp("jszip"); -const packageName = "openclaw-kitchen-sink"; -const pluginId = "openclaw-kitchen-sink-fixture"; -const version = "0.1.3"; - -async function main() { - const zip = new JSZip(); - zip.file( - "package/package.json", - `${JSON.stringify( - { - name: packageName, - version, - openclaw: { extensions: ["./index.js"] }, - }, - null, - 2, - )}\n`, - { date: new Date(0) }, - ); - zip.file( - "package/index.js", - `module.exports = { - id: "${pluginId}", - name: "OpenClaw Kitchen Sink", - register(api) { - api.registerProvider({ - id: "kitchen-sink-provider", - label: "Kitchen Sink Provider", - docsPath: "/providers/kitchen-sink", - auth: [], - }); - api.registerChannel({ - plugin: { - id: "kitchen-sink-channel", - meta: { - id: "kitchen-sink-channel", - label: "Kitchen Sink Channel", - selectionLabel: "Kitchen Sink", - docsPath: "/channels/kitchen-sink", - blurb: "Kitchen sink ClawHub fixture channel", - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({ accountId: "default" }), - }, - outbound: { deliveryMode: "direct" }, - }, - }); - }, -}; -`, - { date: new Date(0) }, - ); - zip.file( - "package/openclaw.plugin.json", - `${JSON.stringify( - { - id: pluginId, - name: "OpenClaw Kitchen Sink", - channels: ["kitchen-sink-channel"], - providers: ["kitchen-sink-provider"], - configSchema: { - type: "object", - properties: {}, - }, - }, - null, - 2, - )}\n`, - { date: new Date(0) }, - ); - - const archive = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" }); - const sha256hash = crypto.createHash("sha256").update(archive).digest("hex"); - const packageDetail = { - package: { - name: packageName, - displayName: "OpenClaw Kitchen Sink", - family: "code-plugin", - runtimeId: pluginId, - channel: "official", - isOfficial: true, - summary: "Kitchen sink plugin fixture for prerelease CI.", - ownerHandle: "openclaw", - createdAt: 0, - updatedAt: 0, - latestVersion: version, - tags: { latest: version }, - capabilityTags: ["test-fixture"], - executesCode: true, - compatibility: { - pluginApiRange: ">=2026.4.11", - minGatewayVersion: "2026.4.11", - }, - capabilities: { - executesCode: true, - runtimeId: pluginId, - capabilityTags: ["test-fixture"], - channels: ["kitchen-sink-channel"], - providers: ["kitchen-sink-provider"], - }, - verification: { - tier: "source-linked", - sourceRepo: "https://github.com/openclaw/kitchen-sink", - hasProvenance: false, - scanStatus: "passed", - }, - }, - }; - const versionDetail = { - package: { - name: packageName, - displayName: "OpenClaw Kitchen Sink", - family: "code-plugin", - }, - version: { - version, - createdAt: 0, - changelog: "Fixture package for kitchen-sink plugin prerelease CI.", - distTags: ["latest"], - sha256hash, - compatibility: packageDetail.package.compatibility, - capabilities: packageDetail.package.capabilities, - verification: packageDetail.package.verification, - }, - }; - - const json = (response, value, status = 200) => { - response.writeHead(status, { "content-type": "application/json" }); - response.end(`${JSON.stringify(value)}\n`); - }; - - const server = http.createServer((request, response) => { - const url = new URL(request.url, "http://127.0.0.1"); - if (request.method !== "GET") { - response.writeHead(405); - response.end("method not allowed"); - return; - } - if (url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}`) { - json(response, packageDetail); - return; - } - if ( - url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}/versions/${version}` - ) { - json(response, versionDetail); - return; - } - if (url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}/versions/beta`) { - json(response, { error: "version not found" }, 404); - return; - } - if (url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}/download`) { - response.writeHead(200, { - "content-type": "application/zip", - "content-length": String(archive.length), - }); - response.end(archive); - return; - } - response.writeHead(404, { "content-type": "text/plain" }); - response.end(`not found: ${url.pathname}`); - }); - - server.listen(0, "127.0.0.1", () => { - require("node:fs").writeFileSync(portFile, String(server.address().port)); - }); -} - -main().catch((error) => { - console.error(error); - process.exit(1); -}); diff --git a/scripts/e2e/lib/kitchen-sink-plugin/sweep.sh b/scripts/e2e/lib/kitchen-sink-plugin/sweep.sh index daef182dc24..7efe7dadf80 100644 --- a/scripts/e2e/lib/kitchen-sink-plugin/sweep.sh +++ b/scripts/e2e/lib/kitchen-sink-plugin/sweep.sh @@ -22,25 +22,7 @@ run_expect_failure() { echo "Expected ${label} to fail, but it succeeded." >&2 exit 1 fi - node - "$output_file" <<'NODE' -const fs = require("node:fs"); - -const output = fs.readFileSync(process.argv[2], "utf8"); -const source = process.env.KITCHEN_SINK_SOURCE; -const spec = process.env.KITCHEN_SINK_SPEC; -const displayedSpec = source === "npm" ? spec.replace(/^npm:/u, "") : spec; -const expected = - source === "clawhub" - ? /Version not found on ClawHub|ClawHub .* failed \(404\)|version.*not found/iu - : /No matching version|ETARGET|notarget|npm (?:error|ERR!)/iu; -if (!output.includes(displayedSpec)) { - throw new Error(`expected failure output to mention ${displayedSpec}`); -} -if (!expected.test(output)) { - throw new Error(`unexpected ${source} beta failure output:\n${output}`); -} -console.log("ok"); -NODE + node scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs expect-failure "$output_file" } start_kitchen_sink_clawhub_fixture_server() { @@ -49,7 +31,7 @@ start_kitchen_sink_clawhub_fixture_server() { local server_port_file="$fixture_dir/clawhub-fixture-port" local server_pid_file="$fixture_dir/clawhub-fixture-pid" - node scripts/e2e/lib/kitchen-sink-plugin/clawhub-fixture-server.cjs "$server_port_file" >"$server_log" 2>&1 & + node scripts/e2e/lib/clawhub-fixture-server.cjs kitchen-sink-plugin "$server_port_file" >"$server_log" 2>&1 & local server_pid="$!" echo "$server_pid" >"$server_pid_file" @@ -72,308 +54,23 @@ start_kitchen_sink_clawhub_fixture_server() { } scan_logs_for_unexpected_errors() { - node - <<'NODE' -const fs = require("node:fs"); -const path = require("node:path"); - -const roots = ["/tmp", path.join(process.env.HOME, ".openclaw")]; -const files = []; -const visit = (entry) => { - if (!fs.existsSync(entry)) { - return; - } - const stat = fs.statSync(entry); - if (stat.isDirectory()) { - for (const child of fs.readdirSync(entry)) { - visit(path.join(entry, child)); - } - return; - } - if (/\.(?:log|jsonl)$/u.test(entry) || /openclaw-kitchen-sink-/u.test(path.basename(entry))) { - if (entry.includes("/.npm/_logs/")) { - return; - } - files.push(entry); - } -}; -for (const root of roots) { - visit(root); -} - -const deny = [ - /\buncaught exception\b/iu, - /\bunhandled rejection\b/iu, - /\bfatal\b/iu, - /\bpanic\b/iu, - /\blevel["']?\s*:\s*["']error["']/iu, - /\[(?:error|ERROR)\]/u, -]; -const allow = [ - /0 errors?/iu, - /expected no diagnostics errors?/iu, - /diagnostics errors?:\s*$/iu, -]; -const findings = []; -for (const file of files) { - const text = fs.readFileSync(file, "utf8"); - const lines = text.split(/\r?\n/u); - lines.forEach((line, index) => { - if (allow.some((pattern) => pattern.test(line))) { - return; - } - if (deny.some((pattern) => pattern.test(line))) { - findings.push(`${file}:${index + 1}: ${line}`); - } - }); -} -if (findings.length > 0) { - throw new Error(`unexpected error-like log lines:\n${findings.join("\n")}`); -} -console.log(`log scan passed (${files.length} file(s))`); -NODE + node scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs scan-logs } configure_kitchen_sink_runtime() { - node - <<'NODE' -const fs = require("node:fs"); -const path = require("node:path"); - -const pluginId = process.env.KITCHEN_SINK_ID; -const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); -const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, "utf8")) : {}; -config.plugins = config.plugins || {}; -config.plugins.entries = config.plugins.entries || {}; -config.plugins.entries[pluginId] = { - ...(config.plugins.entries[pluginId] || {}), - hooks: { - ...(config.plugins.entries[pluginId]?.hooks || {}), - allowConversationAccess: true, - }, -}; -config.channels = { - ...(config.channels || {}), - "kitchen-sink-channel": { enabled: true, token: "kitchen-sink-ci" }, -}; -fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`); -NODE + node scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs configure-runtime } remove_kitchen_sink_channel_config() { - node - <<'NODE' -const fs = require("node:fs"); -const path = require("node:path"); - -const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); -if (fs.existsSync(configPath)) { - const config = JSON.parse(fs.readFileSync(configPath, "utf8")); - delete config.channels?.["kitchen-sink-channel"]; - fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`); -} -NODE + node scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs remove-channel-config } assert_kitchen_sink_installed() { - node - <<'NODE' -const fs = require("node:fs"); -const path = require("node:path"); - -const pluginId = process.env.KITCHEN_SINK_ID; -const spec = process.env.KITCHEN_SINK_SPEC; -const source = process.env.KITCHEN_SINK_SOURCE; -const surfaceMode = process.env.KITCHEN_SINK_SURFACE_MODE; -const label = process.env.KITCHEN_SINK_LABEL; -const list = JSON.parse(fs.readFileSync(`/tmp/kitchen-sink-${label}-plugins.json`, "utf8")); -const inspect = JSON.parse(fs.readFileSync(`/tmp/kitchen-sink-${label}-inspect.json`, "utf8")); -const allInspect = JSON.parse(fs.readFileSync(`/tmp/kitchen-sink-${label}-inspect-all.json`, "utf8")); -const plugin = (list.plugins || []).find((entry) => entry.id === pluginId); -if (!plugin) throw new Error(`kitchen-sink plugin not found after install: ${pluginId}`); -if (plugin.status !== "loaded") { - throw new Error(`unexpected kitchen-sink status after enable: ${plugin.status}`); -} -if (inspect.plugin?.id !== pluginId) { - throw new Error(`unexpected inspected kitchen-sink plugin id: ${inspect.plugin?.id}`); -} -if (inspect.plugin?.enabled !== true || inspect.plugin?.status !== "loaded") { - throw new Error( - `expected enabled loaded kitchen-sink plugin, got enabled=${inspect.plugin?.enabled} status=${inspect.plugin?.status}`, - ); -} - -const expectIncludes = (listValue, expected, field) => { - if (!Array.isArray(listValue) || !listValue.includes(expected)) { - throw new Error(`${field} missing ${expected}: ${JSON.stringify(listValue)}`); - } -}; -expectIncludes(inspect.plugin?.channelIds, "kitchen-sink-channel", "channels"); -expectIncludes(inspect.plugin?.providerIds, "kitchen-sink-provider", "providers"); - -const diagnostics = [ - ...(list.diagnostics || []), - ...(inspect.diagnostics || []), - ...(allInspect.diagnostics || []), -]; -const errorMessages = new Set( - diagnostics - .filter((diag) => diag?.level === "error") - .map((diag) => String(diag.message || "")), -); - -if (surfaceMode === "full") { - const toolNames = Array.isArray(inspect.tools) - ? inspect.tools.flatMap((entry) => (Array.isArray(entry?.names) ? entry.names : [])) - : []; - expectIncludes(inspect.plugin?.speechProviderIds, "kitchen-sink-speech-provider", "speech providers"); - expectIncludes( - inspect.plugin?.realtimeTranscriptionProviderIds, - "kitchen-sink-realtime-transcription-provider", - "realtime transcription providers", - ); - expectIncludes( - inspect.plugin?.realtimeVoiceProviderIds, - "kitchen-sink-realtime-voice-provider", - "realtime voice providers", - ); - expectIncludes( - inspect.plugin?.mediaUnderstandingProviderIds, - "kitchen-sink-media-understanding-provider", - "media understanding providers", - ); - expectIncludes( - inspect.plugin?.imageGenerationProviderIds, - "kitchen-sink-image-generation-provider", - "image generation providers", - ); - expectIncludes( - inspect.plugin?.videoGenerationProviderIds, - "kitchen-sink-video-generation-provider", - "video generation providers", - ); - expectIncludes( - inspect.plugin?.musicGenerationProviderIds, - "kitchen-sink-music-generation-provider", - "music generation providers", - ); - expectIncludes(inspect.plugin?.webFetchProviderIds, "kitchen-sink-web-fetch-provider", "web fetch providers"); - expectIncludes(inspect.plugin?.webSearchProviderIds, "kitchen-sink-web-search-provider", "web search providers"); - expectIncludes(inspect.plugin?.migrationProviderIds, "kitchen-sink-migration-provider", "migration providers"); - expectIncludes(inspect.plugin?.agentHarnessIds, "kitchen-sink-agent-harness", "agent harnesses"); - expectIncludes(inspect.services, "kitchen-sink-service", "services"); - expectIncludes(inspect.commands, "kitchen-sink-command", "commands"); - expectIncludes(toolNames, "kitchen-sink-tool", "tools"); - if ((inspect.plugin?.hookCount || 0) < 30 || !Array.isArray(inspect.typedHooks) || inspect.typedHooks.length < 30) { - throw new Error( - `expected kitchen-sink typed hooks to load, got hookCount=${inspect.plugin?.hookCount} typedHooks=${inspect.typedHooks?.length}`, - ); - } - - const expectedErrorMessages = new Set([ - "only bundled plugins can register agent tool result middleware", - "cli registration missing explicit commands metadata", - "only bundled plugins can register Codex app-server extension factories", - "http route registration missing or invalid auth: /kitchen-sink/http-route", - "plugin must own memory slot or declare contracts.memoryEmbeddingProviders for adapter: kitchen-sink-memory-embedding-provider", - ]); - for (const message of errorMessages) { - if (!expectedErrorMessages.has(message)) { - throw new Error(`unexpected kitchen-sink diagnostic error: ${message}`); - } - } - for (const message of expectedErrorMessages) { - if (!errorMessages.has(message)) { - throw new Error(`missing expected kitchen-sink diagnostic error: ${message}`); - } - } -} else if (errorMessages.size > 0) { - throw new Error(`unexpected kitchen-sink diagnostic errors: ${[...errorMessages].join(", ")}`); -} - -const indexPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json"); -const index = JSON.parse(fs.readFileSync(indexPath, "utf8")); -const record = (index.installRecords ?? index.records ?? {})[pluginId]; -if (!record) throw new Error(`missing kitchen-sink install record for ${pluginId}`); -if (record.source !== source) { - throw new Error(`expected kitchen-sink install source=${source}, got ${record.source}`); -} -if (source === "npm") { - const expectedSpec = spec.replace(/^npm:/u, ""); - if (record.spec !== expectedSpec) { - throw new Error(`expected kitchen-sink npm spec ${expectedSpec}, got ${record.spec}`); - } - if (!record.resolvedVersion || !record.resolvedSpec) { - throw new Error(`missing npm resolution metadata: ${JSON.stringify(record)}`); - } -} else if (source === "clawhub") { - const value = spec.slice("clawhub:".length).trim(); - const slashIndex = value.lastIndexOf("/"); - const atIndex = value.lastIndexOf("@"); - const packageName = atIndex > 0 && atIndex > slashIndex ? value.slice(0, atIndex) : value; - if (record.spec !== spec) { - throw new Error(`expected kitchen-sink ClawHub spec ${spec}, got ${record.spec}`); - } - if (record.clawhubPackage !== packageName) { - throw new Error(`expected ClawHub package ${packageName}, got ${record.clawhubPackage}`); - } - if (record.clawhubFamily !== "code-plugin" && record.clawhubFamily !== "bundle-plugin") { - throw new Error(`unexpected ClawHub family: ${record.clawhubFamily}`); - } - if (!record.version || !record.integrity || !record.resolvedAt) { - throw new Error(`missing ClawHub resolution metadata: ${JSON.stringify(record)}`); - } -} -if (typeof record.installPath !== "string" || record.installPath.length === 0) { - throw new Error("missing kitchen-sink install path"); -} -const installPath = record.installPath.replace(/^~(?=$|\/)/u, process.env.HOME); -if (!fs.existsSync(installPath)) { - throw new Error(`kitchen-sink install path missing: ${record.installPath}`); -} -fs.writeFileSync(`/tmp/kitchen-sink-${label}-install-path.txt`, installPath, "utf8"); -console.log("ok"); -NODE + node scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs assert-installed } assert_kitchen_sink_removed() { - node - <<'NODE' -const fs = require("node:fs"); -const path = require("node:path"); - -const pluginId = process.env.KITCHEN_SINK_ID; -const label = process.env.KITCHEN_SINK_LABEL; -const list = JSON.parse(fs.readFileSync(`/tmp/kitchen-sink-${label}-uninstalled.json`, "utf8")); -if ((list.plugins || []).some((entry) => entry.id === pluginId)) { - throw new Error(`kitchen-sink plugin still listed after uninstall: ${pluginId}`); -} - -const indexPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json"); -const index = fs.existsSync(indexPath) ? JSON.parse(fs.readFileSync(indexPath, "utf8")) : {}; -const records = index.installRecords ?? index.records ?? {}; -if (records[pluginId]) { - throw new Error(`kitchen-sink install record still present after uninstall: ${pluginId}`); -} - -const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); -const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, "utf8")) : {}; -if (config.plugins?.entries?.[pluginId]) { - throw new Error(`kitchen-sink config entry still present after uninstall: ${pluginId}`); -} -if ((config.plugins?.allow || []).includes(pluginId)) { - throw new Error(`kitchen-sink allowlist still contains ${pluginId}`); -} -if ((config.plugins?.deny || []).includes(pluginId)) { - throw new Error(`kitchen-sink denylist still contains ${pluginId}`); -} -if (config.channels?.["kitchen-sink-channel"]) { - throw new Error("kitchen-sink channel config still present after uninstall"); -} -const installPathFile = `/tmp/kitchen-sink-${label}-install-path.txt`; -if (fs.existsSync(installPathFile)) { - const installPath = fs.readFileSync(installPathFile, "utf8").trim(); - if (installPath && fs.existsSync(installPath)) { - throw new Error(`kitchen-sink managed install directory still exists: ${installPath}`); - } -} -console.log("ok"); -NODE + node scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs assert-removed } run_success_scenario() { diff --git a/scripts/e2e/lib/plugins/assertions.mjs b/scripts/e2e/lib/plugins/assertions.mjs new file mode 100644 index 00000000000..5854cadbd79 --- /dev/null +++ b/scripts/e2e/lib/plugins/assertions.mjs @@ -0,0 +1,406 @@ +import fs from "node:fs"; +import path from "node:path"; + +const command = process.argv[2]; +const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8")); + +function setManifestId() { + const file = process.argv[3]; + const id = process.argv[4]; + const parsed = readJson(file); + parsed.id = id; + fs.writeFileSync(file, `${JSON.stringify(parsed, null, 2)}\n`); +} + +function recordFixturePluginTrust() { + const pluginId = process.argv[3]; + const pluginRoot = process.argv[4]; + const enabled = process.argv[5] === "1"; + const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); + const config = fs.existsSync(configPath) ? readJson(configPath) : {}; + const plugins = (config.plugins ??= {}); + const entries = (plugins.entries ??= {}); + entries[pluginId] = { ...entries[pluginId], enabled }; + delete plugins.installs; + plugins.allow = Array.from(new Set([...(plugins.allow ?? []), pluginId])).toSorted((a, b) => + a.localeCompare(b), + ); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); + + const ledgerPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json"); + const ledger = fs.existsSync(ledgerPath) + ? readJson(ledgerPath) + : { + version: 1, + warning: + "DO NOT EDIT. This file is generated by OpenClaw plugin install/update/uninstall commands. Use `openclaw plugins install/update/uninstall` instead.", + records: {}, + }; + ledger.updatedAtMs = Date.now(); + ledger.records ??= {}; + ledger.records[pluginId] = { + ...ledger.records[pluginId], + source: "path", + installPath: pluginRoot, + sourcePath: pluginRoot, + }; + fs.mkdirSync(path.dirname(ledgerPath), { recursive: true }); + fs.writeFileSync(ledgerPath, `${JSON.stringify(ledger, null, 2)}\n`, "utf8"); +} + +function assertDemoPlugin() { + const data = readJson("/tmp/plugins.json"); + const inspect = readJson("/tmp/plugins-inspect.json"); + const plugin = (data.plugins || []).find((entry) => entry.id === "demo-plugin"); + if (!plugin) { + throw new Error("plugin not found"); + } + if (plugin.status !== "loaded") { + throw new Error(`unexpected status: ${plugin.status}`); + } + + const assertIncludes = (list, value, label) => { + if (!Array.isArray(list) || !list.includes(value)) { + throw new Error(`${label} missing: ${value}`); + } + }; + + const inspectToolNames = Array.isArray(inspect.tools) + ? inspect.tools.flatMap((entry) => (Array.isArray(entry?.names) ? entry.names : [])) + : []; + assertIncludes(inspectToolNames, "demo_tool", "tool"); + assertIncludes(inspect.gatewayMethods, "demo.ping", "gateway method"); + assertIncludes(inspect.cliCommands, "demo", "cli command"); + assertIncludes(inspect.services, "demo-service", "service"); + + const diagErrors = (data.diagnostics || []).filter((diag) => diag.level === "error"); + if (diagErrors.length > 0) { + throw new Error(`diagnostics errors: ${diagErrors.map((diag) => diag.message).join("; ")}`); + } +} + +function assertSimplePlugin(jsonFile, inspectFile, pluginId, method) { + const data = readJson(jsonFile); + const inspect = readJson(inspectFile); + const plugin = (data.plugins || []).find((entry) => entry.id === pluginId); + if (!plugin) { + throw new Error(`${pluginId} plugin not found`); + } + if (plugin.status !== "loaded") { + throw new Error(`unexpected status: ${plugin.status}`); + } + if (!Array.isArray(inspect.gatewayMethods) || !inspect.gatewayMethods.includes(method)) { + throw new Error(`expected gateway method ${method}`); + } +} + +function assertClaudeBundleDisabled() { + const data = readJson("/tmp/plugins-bundle-disabled.json"); + const plugin = (data.plugins || []).find((entry) => entry.id === "claude-bundle-e2e"); + if (!plugin) { + throw new Error("Claude bundle plugin not found"); + } + if (plugin.status !== "disabled") { + throw new Error(`expected disabled bundle before enable, got ${plugin.status}`); + } +} + +function assertClaudeBundleInspect() { + const inspect = readJson("/tmp/plugins-bundle-inspect.json"); + if (inspect.plugin?.bundleFormat !== "claude") { + throw new Error(`expected Claude bundle format, got ${inspect.plugin?.bundleFormat}`); + } + if (inspect.plugin?.enabled !== true || inspect.plugin?.status !== "loaded") { + throw new Error( + `expected enabled loaded Claude bundle, got enabled=${inspect.plugin?.enabled} status=${inspect.plugin?.status}`, + ); + } +} + +function assertSlashInstall() { + const inspect = readJson("/tmp/plugin-command-install-show.json"); + if (inspect.plugin?.status !== "loaded") { + throw new Error(`expected loaded status after install, got ${inspect.plugin?.status}`); + } + if (inspect.plugin?.enabled !== true) { + throw new Error(`expected enabled status after install, got ${inspect.plugin?.enabled}`); + } + if (!inspect.gatewayMethods.includes("demo.slash.install")) { + throw new Error(`expected installed gateway method, got ${inspect.gatewayMethods.join(", ")}`); + } +} + +function parseClawHubPackageName(rawSpec) { + const value = rawSpec.slice("clawhub:".length).trim(); + const slashIndex = value.lastIndexOf("/"); + const atIndex = value.lastIndexOf("@"); + return atIndex > 0 && atIndex > slashIndex ? value.slice(0, atIndex) : value; +} + +function assertMarketplaceList() { + const data = readJson("/tmp/marketplace-list.json"); + const names = (data.plugins || []).map((entry) => entry.name).toSorted(); + if (data.name !== "Fixture Marketplace") { + throw new Error(`unexpected marketplace name: ${data.name}`); + } + if (!names.includes("marketplace-shortcut") || !names.includes("marketplace-direct")) { + throw new Error(`unexpected marketplace plugins: ${names.join(", ")}`); + } +} + +function assertMarketplaceInstalled() { + const data = readJson("/tmp/plugins-marketplace.json"); + const shortcutInspect = readJson("/tmp/plugins-marketplace-shortcut-inspect.json"); + const directInspect = readJson("/tmp/plugins-marketplace-direct-inspect.json"); + const getPlugin = (id) => { + const plugin = (data.plugins || []).find((entry) => entry.id === id); + if (!plugin) { + throw new Error(`plugin not found: ${id}`); + } + if (plugin.status !== "loaded") { + throw new Error(`unexpected status for ${id}: ${plugin.status}`); + } + return plugin; + }; + + const shortcut = getPlugin("marketplace-shortcut"); + const direct = getPlugin("marketplace-direct"); + if (shortcut.version !== "0.0.1") { + throw new Error(`unexpected shortcut version: ${shortcut.version}`); + } + if (direct.version !== "0.0.1") { + throw new Error(`unexpected direct version: ${direct.version}`); + } + if (!shortcutInspect.gatewayMethods.includes("demo.marketplace.shortcut.v1")) { + throw new Error("expected marketplace shortcut gateway method"); + } + if (!directInspect.gatewayMethods.includes("demo.marketplace.direct.v1")) { + throw new Error("expected marketplace direct gateway method"); + } +} + +function assertMarketplaceRecords() { + const indexPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json"); + const index = readJson(indexPath); + const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); + const config = fs.existsSync(configPath) ? readJson(configPath) : {}; + const allowLegacyCompat = process.env.OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT === "1"; + if (!allowLegacyCompat && !index.installRecords) { + throw new Error("expected modern installRecords in installed plugin index"); + } + const installRecords = allowLegacyCompat + ? (index.installRecords ?? index.records ?? config.plugins?.installs ?? {}) + : (index.installRecords ?? {}); + for (const id of ["marketplace-shortcut", "marketplace-direct"]) { + const record = installRecords[id]; + if (!record) { + if (allowLegacyCompat) { + console.log(`legacy package did not persist marketplace install record for ${id}`); + continue; + } + throw new Error(`missing marketplace install record for ${id}`); + } + if (record.source !== "marketplace") { + throw new Error(`unexpected source for ${id}: ${record.source}`); + } + if (record.marketplaceSource !== "claude-fixtures") { + throw new Error(`unexpected marketplace source for ${id}: ${record.marketplaceSource}`); + } + if (record.marketplacePlugin !== id) { + throw new Error(`unexpected marketplace plugin for ${id}: ${record.marketplacePlugin}`); + } + } +} + +function assertMarketplaceUpdated() { + const data = readJson("/tmp/plugins-marketplace-updated.json"); + const inspect = readJson("/tmp/plugins-marketplace-updated-inspect.json"); + const plugin = (data.plugins || []).find((entry) => entry.id === "marketplace-shortcut"); + if (!plugin) { + throw new Error("updated marketplace plugin not found"); + } + if (plugin.version !== "0.0.2") { + throw new Error(`unexpected updated version: ${plugin.version}`); + } + if (!inspect.gatewayMethods.includes("demo.marketplace.shortcut.v2")) { + throw new Error(`expected updated gateway method, got ${inspect.gatewayMethods.join(", ")}`); + } +} + +async function assertClawHubPreflight() { + const spec = process.env.CLAWHUB_PLUGIN_SPEC; + if (!spec?.startsWith("clawhub:")) { + throw new Error(`expected clawhub: spec, got ${spec}`); + } + + const packageName = parseClawHubPackageName(spec); + const baseUrl = ( + process.env.OPENCLAW_CLAWHUB_URL || + process.env.CLAWHUB_URL || + "https://clawhub.ai" + ).replace(/\/+$/, ""); + const token = + process.env.OPENCLAW_CLAWHUB_TOKEN || + process.env.CLAWHUB_TOKEN || + process.env.CLAWHUB_AUTH_TOKEN || + ""; + const response = await fetch(`${baseUrl}/api/v1/packages/${encodeURIComponent(packageName)}`, { + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + }); + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error( + `ClawHub package preflight failed for ${packageName}: ${response.status} ${body}`, + ); + } + const detail = await response.json(); + const family = detail.package?.family; + if (family !== "code-plugin" && family !== "bundle-plugin") { + throw new Error(`ClawHub package ${packageName} is not installable as a plugin: ${family}`); + } + if (detail.package?.runtimeId && detail.package.runtimeId !== process.env.CLAWHUB_PLUGIN_ID) { + throw new Error( + `ClawHub package ${packageName} runtimeId ${detail.package.runtimeId} does not match expected ${process.env.CLAWHUB_PLUGIN_ID}`, + ); + } + console.log(`Using ClawHub package ${packageName} (${family}).`); +} + +function assertClawHubInstalled() { + const pluginId = process.env.CLAWHUB_PLUGIN_ID; + const spec = process.env.CLAWHUB_PLUGIN_SPEC; + const packageName = parseClawHubPackageName(spec); + const list = readJson("/tmp/plugins-clawhub-installed.json"); + const inspect = readJson("/tmp/plugins-clawhub-inspect.json"); + const plugin = (list.plugins || []).find((entry) => entry.id === pluginId); + if (!plugin) { + throw new Error(`ClawHub plugin not found after install: ${pluginId}`); + } + if (plugin.status !== "loaded") { + throw new Error(`unexpected ClawHub plugin status for ${pluginId}: ${plugin.status}`); + } + if (inspect.plugin?.id !== pluginId) { + throw new Error(`unexpected ClawHub inspect plugin id: ${inspect.plugin?.id}`); + } + + const indexPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json"); + const index = readJson(indexPath); + const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); + const config = fs.existsSync(configPath) ? readJson(configPath) : {}; + const allowLegacyCompat = process.env.OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT === "1"; + if (!allowLegacyCompat && !index.installRecords) { + throw new Error("expected modern installRecords in installed plugin index"); + } + const installRecords = allowLegacyCompat + ? (index.installRecords ?? index.records ?? config.plugins?.installs ?? {}) + : (index.installRecords ?? {}); + const record = installRecords[pluginId]; + if (!record) { + throw new Error(`missing ClawHub install record for ${pluginId}`); + } + if (record.source !== "clawhub") { + throw new Error(`unexpected ClawHub install source for ${pluginId}: ${record.source}`); + } + if (record.clawhubPackage !== packageName) { + throw new Error( + `unexpected ClawHub package for ${pluginId}: ${record.clawhubPackage}, expected ${packageName}`, + ); + } + if (record.clawhubFamily !== "code-plugin" && record.clawhubFamily !== "bundle-plugin") { + throw new Error(`unexpected ClawHub family for ${pluginId}: ${record.clawhubFamily}`); + } + if (typeof record.installPath !== "string" || record.installPath.length === 0) { + throw new Error(`missing ClawHub install path for ${pluginId}`); + } + + const installPath = record.installPath.replace(/^~(?=$|\/)/u, process.env.HOME); + const extensionsRoot = path.join(process.env.HOME, ".openclaw", "extensions"); + if (!installPath.startsWith(`${extensionsRoot}${path.sep}`)) { + throw new Error(`ClawHub install path is outside managed extensions root: ${installPath}`); + } + if (!fs.existsSync(installPath)) { + throw new Error(`ClawHub install path missing on disk: ${installPath}`); + } + fs.writeFileSync("/tmp/plugins-clawhub-install-path.txt", installPath, "utf8"); +} + +function assertClawHubRemoved() { + const pluginId = process.env.CLAWHUB_PLUGIN_ID; + const installPath = fs.readFileSync("/tmp/plugins-clawhub-install-path.txt", "utf8").trim(); + const list = readJson("/tmp/plugins-clawhub-uninstalled.json"); + if ((list.plugins || []).some((entry) => entry.id === pluginId)) { + throw new Error(`ClawHub plugin still listed after uninstall: ${pluginId}`); + } + + const indexPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json"); + const index = fs.existsSync(indexPath) ? readJson(indexPath) : {}; + const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); + const config = fs.existsSync(configPath) ? readJson(configPath) : {}; + const installRecords = index.installRecords ?? index.records ?? config.plugins?.installs ?? {}; + if (installRecords[pluginId]) { + throw new Error(`ClawHub install record still present after uninstall: ${pluginId}`); + } + + const configAfterUninstallPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); + const configAfterUninstall = fs.existsSync(configAfterUninstallPath) + ? readJson(configAfterUninstallPath) + : {}; + if (configAfterUninstall.plugins?.entries?.[pluginId]) { + throw new Error(`ClawHub config entry still present after uninstall: ${pluginId}`); + } + if ((configAfterUninstall.plugins?.allow || []).includes(pluginId)) { + throw new Error(`ClawHub allowlist entry still present after uninstall: ${pluginId}`); + } + if ((configAfterUninstall.plugins?.deny || []).includes(pluginId)) { + throw new Error(`ClawHub denylist entry still present after uninstall: ${pluginId}`); + } + if (fs.existsSync(installPath)) { + throw new Error( + `ClawHub managed install directory still exists after uninstall: ${installPath}`, + ); + } +} + +const commands = { + "set-manifest-id": setManifestId, + "record-fixture-plugin-trust": recordFixturePluginTrust, + "demo-plugin": assertDemoPlugin, + "plugin-tgz": () => + assertSimplePlugin( + "/tmp/plugins2.json", + "/tmp/plugins2-inspect.json", + "demo-plugin-tgz", + "demo.tgz", + ), + "plugin-dir": () => + assertSimplePlugin( + "/tmp/plugins3.json", + "/tmp/plugins3-inspect.json", + "demo-plugin-dir", + "demo.dir", + ), + "plugin-file": () => + assertSimplePlugin( + "/tmp/plugins4.json", + "/tmp/plugins4-inspect.json", + "demo-plugin-file", + "demo.file", + ), + "bundle-disabled": assertClaudeBundleDisabled, + "bundle-inspect": assertClaudeBundleInspect, + "slash-install": assertSlashInstall, + "marketplace-list": assertMarketplaceList, + "marketplace-installed": assertMarketplaceInstalled, + "marketplace-records": assertMarketplaceRecords, + "marketplace-updated": assertMarketplaceUpdated, + "clawhub-preflight": assertClawHubPreflight, + "clawhub-installed": assertClawHubInstalled, + "clawhub-removed": assertClawHubRemoved, +}; + +const fn = commands[command]; +if (!fn) { + throw new Error(`unknown plugins assertion command: ${command}`); +} +await fn(); diff --git a/scripts/e2e/lib/plugins/clawhub-fixture-server.cjs b/scripts/e2e/lib/plugins/clawhub-fixture-server.cjs deleted file mode 100644 index ef87afac3a6..00000000000 --- a/scripts/e2e/lib/plugins/clawhub-fixture-server.cjs +++ /dev/null @@ -1,137 +0,0 @@ -const crypto = require("node:crypto"); -const http = require("node:http"); -const path = require("node:path"); -const { createRequire } = require("node:module"); - -const portFile = process.argv[2]; -const requireFromApp = createRequire(path.join(process.cwd(), "package.json")); -const JSZip = requireFromApp("jszip"); -const packageName = "openclaw-kitchen-sink"; -const pluginId = "openclaw-kitchen-sink-fixture"; -const version = "0.1.0"; - -async function main() { - const zip = new JSZip(); - zip.file( - "package/package.json", - `${JSON.stringify( - { - name: packageName, - version, - openclaw: { extensions: ["./index.js"] }, - }, - null, - 2, - )}\n`, - { date: new Date(0) }, - ); - zip.file( - "package/index.js", - `module.exports = { - id: "${pluginId}", - name: "OpenClaw Kitchen Sink", - description: "Docker E2E kitchen-sink plugin fixture", - register(api) { - api.on("before_agent_start", async (event, context) => ({ - kitchenSink: true, - observedEventKeys: Object.keys(event || {}), - observedContextKeys: Object.keys(context || {}), - })); - api.registerTool(() => null, { name: "kitchen_sink_tool" }); - api.registerGatewayMethod("kitchen-sink.ping", async () => ({ ok: true })); - api.registerCli(() => {}, { commands: ["kitchen-sink"] }); - api.registerService({ id: "kitchen-sink-service", start: () => {} }); - }, -}; -`, - { date: new Date(0) }, - ); - zip.file( - "package/openclaw.plugin.json", - `${JSON.stringify( - { - id: pluginId, - configSchema: { - type: "object", - properties: {}, - }, - }, - null, - 2, - )}\n`, - { date: new Date(0) }, - ); - - const archive = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" }); - const sha256hash = crypto.createHash("sha256").update(archive).digest("hex"); - - const json = (response, value) => { - response.writeHead(200, { "content-type": "application/json" }); - response.end(`${JSON.stringify(value)}\n`); - }; - - const server = http.createServer((request, response) => { - const url = new URL(request.url, "http://127.0.0.1"); - if (request.method !== "GET") { - response.writeHead(405); - response.end("method not allowed"); - return; - } - if (url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}`) { - json(response, { - package: { - name: packageName, - displayName: "OpenClaw Kitchen Sink", - family: "code-plugin", - channel: "official", - isOfficial: true, - runtimeId: pluginId, - latestVersion: version, - createdAt: 0, - updatedAt: 0, - compatibility: { - pluginApiRange: ">=2026.4.26", - minGatewayVersion: "2026.4.26", - }, - }, - }); - return; - } - if ( - url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}/versions/${version}` - ) { - json(response, { - version: { - version, - createdAt: 0, - changelog: "Kitchen-sink fixture package for Docker plugin E2E.", - sha256hash, - compatibility: { - pluginApiRange: ">=2026.4.26", - minGatewayVersion: "2026.4.26", - }, - }, - }); - return; - } - if (url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}/download`) { - response.writeHead(200, { - "content-type": "application/zip", - "content-length": String(archive.length), - }); - response.end(archive); - return; - } - response.writeHead(404, { "content-type": "text/plain" }); - response.end(`not found: ${url.pathname}`); - }); - - server.listen(0, "127.0.0.1", () => { - require("node:fs").writeFileSync(portFile, String(server.address().port)); - }); -} - -main().catch((error) => { - console.error(error); - process.exit(1); -}); diff --git a/scripts/e2e/lib/plugins/clawhub.sh b/scripts/e2e/lib/plugins/clawhub.sh index 8cc6cac1c90..4ae7bfe3c90 100644 --- a/scripts/e2e/lib/plugins/clawhub.sh +++ b/scripts/e2e/lib/plugins/clawhub.sh @@ -13,7 +13,7 @@ run_plugins_clawhub_scenario() { local server_port_file="$fixture_dir/clawhub-fixture-port" local server_pid_file="$fixture_dir/clawhub-fixture-pid" - node scripts/e2e/lib/plugins/clawhub-fixture-server.cjs "$server_port_file" >"$server_log" 2>&1 & + node scripts/e2e/lib/clawhub-fixture-server.cjs plugins "$server_port_file" >"$server_log" 2>&1 & local server_pid="$!" echo "$server_pid" >"$server_pid_file" @@ -41,155 +41,17 @@ run_plugins_clawhub_scenario() { start_clawhub_fixture_server "$clawhub_fixture_dir" fi - node - <<'NODE' -const spec = process.env.CLAWHUB_PLUGIN_SPEC; -if (!spec?.startsWith("clawhub:")) { - throw new Error(`expected clawhub: spec, got ${spec}`); -} - -const parsePackageName = (rawSpec) => { - const value = rawSpec.slice("clawhub:".length).trim(); - const slashIndex = value.lastIndexOf("/"); - const atIndex = value.lastIndexOf("@"); - return atIndex > 0 && atIndex > slashIndex ? value.slice(0, atIndex) : value; -}; - -const packageName = parsePackageName(spec); -const baseUrl = (process.env.OPENCLAW_CLAWHUB_URL || process.env.CLAWHUB_URL || "https://clawhub.ai") - .replace(/\/+$/, ""); -const token = - process.env.OPENCLAW_CLAWHUB_TOKEN || - process.env.CLAWHUB_TOKEN || - process.env.CLAWHUB_AUTH_TOKEN || - ""; -const response = await fetch(`${baseUrl}/api/v1/packages/${encodeURIComponent(packageName)}`, { - headers: token ? { Authorization: `Bearer ${token}` } : undefined, -}); -if (!response.ok) { - const body = await response.text().catch(() => ""); - throw new Error(`ClawHub package preflight failed for ${packageName}: ${response.status} ${body}`); -} -const detail = await response.json(); -const family = detail.package?.family; -if (family !== "code-plugin" && family !== "bundle-plugin") { - throw new Error(`ClawHub package ${packageName} is not installable as a plugin: ${family}`); -} -if (detail.package?.runtimeId && detail.package.runtimeId !== process.env.CLAWHUB_PLUGIN_ID) { - throw new Error( - `ClawHub package ${packageName} runtimeId ${detail.package.runtimeId} does not match expected ${process.env.CLAWHUB_PLUGIN_ID}`, - ); -} -console.log(`Using ClawHub package ${packageName} (${family}).`); -NODE + node scripts/e2e/lib/plugins/assertions.mjs clawhub-preflight run_logged install-clawhub node "$OPENCLAW_ENTRY" plugins install "$CLAWHUB_PLUGIN_SPEC" node "$OPENCLAW_ENTRY" plugins list --json >/tmp/plugins-clawhub-installed.json node "$OPENCLAW_ENTRY" plugins inspect "$CLAWHUB_PLUGIN_ID" --json >/tmp/plugins-clawhub-inspect.json - node - <<'NODE' -const fs = require("node:fs"); -const path = require("node:path"); - -const pluginId = process.env.CLAWHUB_PLUGIN_ID; -const spec = process.env.CLAWHUB_PLUGIN_SPEC; -const parsePackageName = (rawSpec) => { - const value = rawSpec.slice("clawhub:".length).trim(); - const slashIndex = value.lastIndexOf("/"); - const atIndex = value.lastIndexOf("@"); - return atIndex > 0 && atIndex > slashIndex ? value.slice(0, atIndex) : value; -}; -const packageName = parsePackageName(spec); -const list = JSON.parse(fs.readFileSync("/tmp/plugins-clawhub-installed.json", "utf8")); -const inspect = JSON.parse(fs.readFileSync("/tmp/plugins-clawhub-inspect.json", "utf8")); -const plugin = (list.plugins || []).find((entry) => entry.id === pluginId); -if (!plugin) throw new Error(`ClawHub plugin not found after install: ${pluginId}`); -if (plugin.status !== "loaded") { - throw new Error(`unexpected ClawHub plugin status for ${pluginId}: ${plugin.status}`); -} -if (inspect.plugin?.id !== pluginId) { - throw new Error(`unexpected ClawHub inspect plugin id: ${inspect.plugin?.id}`); -} - -const indexPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json"); -const index = JSON.parse(fs.readFileSync(indexPath, "utf8")); -const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); -const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, "utf8")) : {}; -const allowLegacyCompat = process.env.OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT === "1"; -if (!allowLegacyCompat && !index.installRecords) { - throw new Error("expected modern installRecords in installed plugin index"); -} -const installRecords = allowLegacyCompat - ? index.installRecords ?? index.records ?? config.plugins?.installs ?? {} - : index.installRecords ?? {}; -const record = installRecords[pluginId]; -if (!record) throw new Error(`missing ClawHub install record for ${pluginId}`); -if (record.source !== "clawhub") { - throw new Error(`unexpected ClawHub install source for ${pluginId}: ${record.source}`); -} -if (record.clawhubPackage !== packageName) { - throw new Error( - `unexpected ClawHub package for ${pluginId}: ${record.clawhubPackage}, expected ${packageName}`, - ); -} -if (record.clawhubFamily !== "code-plugin" && record.clawhubFamily !== "bundle-plugin") { - throw new Error(`unexpected ClawHub family for ${pluginId}: ${record.clawhubFamily}`); -} -if (typeof record.installPath !== "string" || record.installPath.length === 0) { - throw new Error(`missing ClawHub install path for ${pluginId}`); -} - -const installPath = record.installPath.replace(/^~(?=$|\/)/, process.env.HOME); -const extensionsRoot = path.join(process.env.HOME, ".openclaw", "extensions"); -if (!installPath.startsWith(`${extensionsRoot}${path.sep}`)) { - throw new Error(`ClawHub install path is outside managed extensions root: ${installPath}`); -} -if (!fs.existsSync(installPath)) { - throw new Error(`ClawHub install path missing on disk: ${installPath}`); -} -fs.writeFileSync("/tmp/plugins-clawhub-install-path.txt", installPath, "utf8"); -console.log("ok"); -NODE + node scripts/e2e/lib/plugins/assertions.mjs clawhub-installed run_logged uninstall-clawhub node "$OPENCLAW_ENTRY" plugins uninstall "$CLAWHUB_PLUGIN_SPEC" --force node "$OPENCLAW_ENTRY" plugins list --json >/tmp/plugins-clawhub-uninstalled.json - node - <<'NODE' -const fs = require("node:fs"); -const path = require("node:path"); - -const pluginId = process.env.CLAWHUB_PLUGIN_ID; -const installPath = fs.readFileSync("/tmp/plugins-clawhub-install-path.txt", "utf8").trim(); -const list = JSON.parse(fs.readFileSync("/tmp/plugins-clawhub-uninstalled.json", "utf8")); -if ((list.plugins || []).some((entry) => entry.id === pluginId)) { - throw new Error(`ClawHub plugin still listed after uninstall: ${pluginId}`); -} - -const indexPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json"); -const index = fs.existsSync(indexPath) ? JSON.parse(fs.readFileSync(indexPath, "utf8")) : {}; -const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); -const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, "utf8")) : {}; -const installRecords = index.installRecords ?? index.records ?? config.plugins?.installs ?? {}; -if (installRecords[pluginId]) { - throw new Error(`ClawHub install record still present after uninstall: ${pluginId}`); -} - -const configAfterUninstallPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); -const configAfterUninstall = fs.existsSync(configAfterUninstallPath) - ? JSON.parse(fs.readFileSync(configAfterUninstallPath, "utf8")) - : {}; -if (configAfterUninstall.plugins?.entries?.[pluginId]) { - throw new Error(`ClawHub config entry still present after uninstall: ${pluginId}`); -} -if ((configAfterUninstall.plugins?.allow || []).includes(pluginId)) { - throw new Error(`ClawHub allowlist entry still present after uninstall: ${pluginId}`); -} -if ((configAfterUninstall.plugins?.deny || []).includes(pluginId)) { - throw new Error(`ClawHub denylist entry still present after uninstall: ${pluginId}`); -} -if (fs.existsSync(installPath)) { - throw new Error(`ClawHub managed install directory still exists after uninstall: ${installPath}`); -} -console.log("ok"); -NODE + node scripts/e2e/lib/plugins/assertions.mjs clawhub-removed fi } diff --git a/scripts/e2e/lib/plugins/fixtures.sh b/scripts/e2e/lib/plugins/fixtures.sh index 593dd5b9da1..1fe8af76e94 100644 --- a/scripts/e2e/lib/plugins/fixtures.sh +++ b/scripts/e2e/lib/plugins/fixtures.sh @@ -2,45 +2,7 @@ record_fixture_plugin_trust() { local plugin_id="$1" local plugin_root="$2" local enabled="$3" - node - "$plugin_id" "$plugin_root" "$enabled" <<'NODE' -const fs = require("node:fs"); -const path = require("node:path"); - -const pluginId = process.argv[2]; -const pluginRoot = process.argv[3]; -const enabled = process.argv[4] === "1"; -const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); -const config = fs.existsSync(configPath) - ? JSON.parse(fs.readFileSync(configPath, "utf8")) - : {}; -const plugins = (config.plugins ??= {}); -const entries = (plugins.entries ??= {}); -entries[pluginId] = { ...(entries[pluginId] ?? {}), enabled }; -delete plugins.installs; -plugins.allow = Array.from(new Set([...(plugins.allow ?? []), pluginId])).sort(); -fs.mkdirSync(path.dirname(configPath), { recursive: true }); -fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); - -const ledgerPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json"); -const ledger = fs.existsSync(ledgerPath) - ? JSON.parse(fs.readFileSync(ledgerPath, "utf8")) - : { - version: 1, - warning: - "DO NOT EDIT. This file is generated by OpenClaw plugin install/update/uninstall commands. Use `openclaw plugins install/update/uninstall` instead.", - records: {}, - }; -ledger.updatedAtMs = Date.now(); -ledger.records ??= {}; -ledger.records[pluginId] = { - ...(ledger.records[pluginId] ?? {}), - source: "path", - installPath: pluginRoot, - sourcePath: pluginRoot, -}; -fs.mkdirSync(path.dirname(ledgerPath), { recursive: true }); -fs.writeFileSync(ledgerPath, `${JSON.stringify(ledger, null, 2)}\n`, "utf8"); -NODE + node scripts/e2e/lib/plugins/assertions.mjs record-fixture-plugin-trust "$plugin_id" "$plugin_root" "$enabled" } write_fixture_plugin() { @@ -76,12 +38,5 @@ JS } } JSON - node - "$dir/openclaw.plugin.json" "$id" <<'NODE' -const fs = require("node:fs"); -const file = process.argv[2]; -const id = process.argv[3]; -const parsed = JSON.parse(fs.readFileSync(file, "utf8")); -parsed.id = id; -fs.writeFileSync(file, `${JSON.stringify(parsed, null, 2)}\n`); -NODE + node scripts/e2e/lib/plugins/assertions.mjs set-manifest-id "$dir/openclaw.plugin.json" "$id" } diff --git a/scripts/e2e/lib/plugins/marketplace.sh b/scripts/e2e/lib/plugins/marketplace.sh index 5e63c17e1ac..9f2defc86f1 100644 --- a/scripts/e2e/lib/plugins/marketplace.sh +++ b/scripts/e2e/lib/plugins/marketplace.sh @@ -51,19 +51,7 @@ JSON node "$OPENCLAW_ENTRY" plugins marketplace list claude-fixtures --json >/tmp/marketplace-list.json - node - <<'NODE' -const fs = require("node:fs"); - -const data = JSON.parse(fs.readFileSync("/tmp/marketplace-list.json", "utf8")); -const names = (data.plugins || []).map((entry) => entry.name).sort(); -if (data.name !== "Fixture Marketplace") { - throw new Error(`unexpected marketplace name: ${data.name}`); -} -if (!names.includes("marketplace-shortcut") || !names.includes("marketplace-direct")) { - throw new Error(`unexpected marketplace plugins: ${names.join(", ")}`); -} -console.log("ok"); -NODE + node scripts/e2e/lib/plugins/assertions.mjs marketplace-list run_logged install-marketplace-shortcut node "$OPENCLAW_ENTRY" plugins install marketplace-shortcut@claude-fixtures run_logged install-marketplace-direct node "$OPENCLAW_ENTRY" plugins install marketplace-direct --marketplace claude-fixtures @@ -71,78 +59,9 @@ NODE node "$OPENCLAW_ENTRY" plugins inspect marketplace-shortcut --json >/tmp/plugins-marketplace-shortcut-inspect.json node "$OPENCLAW_ENTRY" plugins inspect marketplace-direct --json >/tmp/plugins-marketplace-direct-inspect.json - node - <<'NODE' -const fs = require("node:fs"); + node scripts/e2e/lib/plugins/assertions.mjs marketplace-installed -const data = JSON.parse(fs.readFileSync("/tmp/plugins-marketplace.json", "utf8")); -const shortcutInspect = JSON.parse( - fs.readFileSync("/tmp/plugins-marketplace-shortcut-inspect.json", "utf8"), -); -const directInspect = JSON.parse( - fs.readFileSync("/tmp/plugins-marketplace-direct-inspect.json", "utf8"), -); -const getPlugin = (id) => { - const plugin = (data.plugins || []).find((entry) => entry.id === id); - if (!plugin) throw new Error(`plugin not found: ${id}`); - if (plugin.status !== "loaded") { - throw new Error(`unexpected status for ${id}: ${plugin.status}`); - } - return plugin; -}; - -const shortcut = getPlugin("marketplace-shortcut"); -const direct = getPlugin("marketplace-direct"); -if (shortcut.version !== "0.0.1") { - throw new Error(`unexpected shortcut version: ${shortcut.version}`); -} -if (direct.version !== "0.0.1") { - throw new Error(`unexpected direct version: ${direct.version}`); -} -if (!shortcutInspect.gatewayMethods.includes("demo.marketplace.shortcut.v1")) { - throw new Error("expected marketplace shortcut gateway method"); -} -if (!directInspect.gatewayMethods.includes("demo.marketplace.direct.v1")) { - throw new Error("expected marketplace direct gateway method"); -} -console.log("ok"); -NODE - - node - <<'NODE' -const fs = require("node:fs"); -const path = require("node:path"); - -const indexPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json"); -const index = JSON.parse(fs.readFileSync(indexPath, "utf8")); -const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); -const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, "utf8")) : {}; -const allowLegacyCompat = process.env.OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT === "1"; -if (!allowLegacyCompat && !index.installRecords) { - throw new Error("expected modern installRecords in installed plugin index"); -} -const installRecords = allowLegacyCompat - ? index.installRecords ?? index.records ?? config.plugins?.installs ?? {} - : index.installRecords ?? {}; -for (const id of ["marketplace-shortcut", "marketplace-direct"]) { - const record = installRecords[id]; - if (!record) { - if (allowLegacyCompat) { - console.log(`legacy package did not persist marketplace install record for ${id}`); - continue; - } - throw new Error(`missing marketplace install record for ${id}`); - } - if (record.source !== "marketplace") { - throw new Error(`unexpected source for ${id}: ${record.source}`); - } - if (record.marketplaceSource !== "claude-fixtures") { - throw new Error(`unexpected marketplace source for ${id}: ${record.marketplaceSource}`); - } - if (record.marketplacePlugin !== id) { - throw new Error(`unexpected marketplace plugin for ${id}: ${record.marketplacePlugin}`); - } -} -console.log("ok"); -NODE + node scripts/e2e/lib/plugins/assertions.mjs marketplace-records write_fixture_plugin \ "$marketplace_root/plugins/marketplace-shortcut" \ @@ -155,19 +74,5 @@ NODE node "$OPENCLAW_ENTRY" plugins list --json >/tmp/plugins-marketplace-updated.json node "$OPENCLAW_ENTRY" plugins inspect marketplace-shortcut --json >/tmp/plugins-marketplace-updated-inspect.json - node - <<'NODE' -const fs = require("node:fs"); - -const data = JSON.parse(fs.readFileSync("/tmp/plugins-marketplace-updated.json", "utf8")); -const inspect = JSON.parse(fs.readFileSync("/tmp/plugins-marketplace-updated-inspect.json", "utf8")); -const plugin = (data.plugins || []).find((entry) => entry.id === "marketplace-shortcut"); -if (!plugin) throw new Error("updated marketplace plugin not found"); -if (plugin.version !== "0.0.2") { - throw new Error(`unexpected updated version: ${plugin.version}`); -} -if (!inspect.gatewayMethods.includes("demo.marketplace.shortcut.v2")) { - throw new Error(`expected updated gateway method, got ${inspect.gatewayMethods.join(", ")}`); -} -console.log("ok"); -NODE + node scripts/e2e/lib/plugins/assertions.mjs marketplace-updated } diff --git a/scripts/e2e/lib/plugins/sweep.sh b/scripts/e2e/lib/plugins/sweep.sh index 37a696d3b75..56bf523dff4 100644 --- a/scripts/e2e/lib/plugins/sweep.sh +++ b/scripts/e2e/lib/plugins/sweep.sh @@ -47,38 +47,7 @@ record_fixture_plugin_trust "$demo_plugin_id" "$demo_plugin_root" 1 node "$OPENCLAW_ENTRY" plugins list --json >/tmp/plugins.json node "$OPENCLAW_ENTRY" plugins inspect demo-plugin --json >/tmp/plugins-inspect.json -node - <<'NODE' -const fs = require("node:fs"); - -const data = JSON.parse(fs.readFileSync("/tmp/plugins.json", "utf8")); -const inspect = JSON.parse(fs.readFileSync("/tmp/plugins-inspect.json", "utf8")); -const plugin = (data.plugins || []).find((entry) => entry.id === "demo-plugin"); -if (!plugin) throw new Error("plugin not found"); -if (plugin.status !== "loaded") { - throw new Error(`unexpected status: ${plugin.status}`); -} - -const assertIncludes = (list, value, label) => { - if (!Array.isArray(list) || !list.includes(value)) { - throw new Error(`${label} missing: ${value}`); - } -}; - -const inspectToolNames = Array.isArray(inspect.tools) - ? inspect.tools.flatMap((entry) => (Array.isArray(entry?.names) ? entry.names : [])) - : []; -assertIncludes(inspectToolNames, "demo_tool", "tool"); -assertIncludes(inspect.gatewayMethods, "demo.ping", "gateway method"); -assertIncludes(inspect.cliCommands, "demo", "cli command"); -assertIncludes(inspect.services, "demo-service", "service"); - -const diagErrors = (data.diagnostics || []).filter((diag) => diag.level === "error"); -if (diagErrors.length > 0) { - throw new Error(`diagnostics errors: ${diagErrors.map((diag) => diag.message).join("; ")}`); -} - -console.log("ok"); -NODE +node scripts/e2e/lib/plugins/assertions.mjs demo-plugin echo "Testing tgz install flow..." pack_dir="$(mktemp -d "/tmp/openclaw-plugin-pack.XXXXXX")" @@ -114,21 +83,7 @@ run_logged install-tgz node "$OPENCLAW_ENTRY" plugins install /tmp/demo-plugin-t node "$OPENCLAW_ENTRY" plugins list --json >/tmp/plugins2.json node "$OPENCLAW_ENTRY" plugins inspect demo-plugin-tgz --json >/tmp/plugins2-inspect.json -node - <<'NODE' -const fs = require("node:fs"); - -const data = JSON.parse(fs.readFileSync("/tmp/plugins2.json", "utf8")); -const inspect = JSON.parse(fs.readFileSync("/tmp/plugins2-inspect.json", "utf8")); -const plugin = (data.plugins || []).find((entry) => entry.id === "demo-plugin-tgz"); -if (!plugin) throw new Error("tgz plugin not found"); -if (plugin.status !== "loaded") { - throw new Error(`unexpected status: ${plugin.status}`); -} -if (!Array.isArray(inspect.gatewayMethods) || !inspect.gatewayMethods.includes("demo.tgz")) { - throw new Error("expected gateway method demo.tgz"); -} -console.log("ok"); -NODE +node scripts/e2e/lib/plugins/assertions.mjs plugin-tgz echo "Testing install from local folder (plugins.load.paths)..." dir_plugin="$(mktemp -d "/tmp/openclaw-plugin-dir.XXXXXX")" @@ -162,21 +117,7 @@ run_logged install-dir node "$OPENCLAW_ENTRY" plugins install "$dir_plugin" node "$OPENCLAW_ENTRY" plugins list --json >/tmp/plugins3.json node "$OPENCLAW_ENTRY" plugins inspect demo-plugin-dir --json >/tmp/plugins3-inspect.json -node - <<'NODE' -const fs = require("node:fs"); - -const data = JSON.parse(fs.readFileSync("/tmp/plugins3.json", "utf8")); -const inspect = JSON.parse(fs.readFileSync("/tmp/plugins3-inspect.json", "utf8")); -const plugin = (data.plugins || []).find((entry) => entry.id === "demo-plugin-dir"); -if (!plugin) throw new Error("dir plugin not found"); -if (plugin.status !== "loaded") { - throw new Error(`unexpected status: ${plugin.status}`); -} -if (!Array.isArray(inspect.gatewayMethods) || !inspect.gatewayMethods.includes("demo.dir")) { - throw new Error("expected gateway method demo.dir"); -} -console.log("ok"); -NODE +node scripts/e2e/lib/plugins/assertions.mjs plugin-dir echo "Testing install from npm spec (file:)..." file_pack_dir="$(mktemp -d "/tmp/openclaw-plugin-filepack.XXXXXX")" @@ -211,21 +152,7 @@ run_logged install-file node "$OPENCLAW_ENTRY" plugins install "file:$file_pack_ node "$OPENCLAW_ENTRY" plugins list --json >/tmp/plugins4.json node "$OPENCLAW_ENTRY" plugins inspect demo-plugin-file --json >/tmp/plugins4-inspect.json -node - <<'NODE' -const fs = require("node:fs"); - -const data = JSON.parse(fs.readFileSync("/tmp/plugins4.json", "utf8")); -const inspect = JSON.parse(fs.readFileSync("/tmp/plugins4-inspect.json", "utf8")); -const plugin = (data.plugins || []).find((entry) => entry.id === "demo-plugin-file"); -if (!plugin) throw new Error("file plugin not found"); -if (plugin.status !== "loaded") { - throw new Error(`unexpected status: ${plugin.status}`); -} -if (!Array.isArray(inspect.gatewayMethods) || !inspect.gatewayMethods.includes("demo.file")) { - throw new Error("expected gateway method demo.file"); -} -console.log("ok"); -NODE +node scripts/e2e/lib/plugins/assertions.mjs plugin-file echo "Testing Claude bundle enable and inspect flow..." bundle_plugin_id="claude-bundle-e2e" @@ -248,32 +175,11 @@ MD record_fixture_plugin_trust "$bundle_plugin_id" "$bundle_root" 0 node "$OPENCLAW_ENTRY" plugins list --json >/tmp/plugins-bundle-disabled.json -node - <<'NODE' -const fs = require("node:fs"); -const data = JSON.parse(fs.readFileSync("/tmp/plugins-bundle-disabled.json", "utf8")); -const plugin = (data.plugins || []).find((entry) => entry.id === "claude-bundle-e2e"); -if (!plugin) throw new Error("Claude bundle plugin not found"); -if (plugin.status !== "disabled") { - throw new Error(`expected disabled bundle before enable, got ${plugin.status}`); -} -console.log("ok"); -NODE +node scripts/e2e/lib/plugins/assertions.mjs bundle-disabled run_logged enable-claude-bundle node "$OPENCLAW_ENTRY" plugins enable claude-bundle-e2e node "$OPENCLAW_ENTRY" plugins inspect claude-bundle-e2e --json >/tmp/plugins-bundle-inspect.json -node - <<'NODE' -const fs = require("node:fs"); -const inspect = JSON.parse(fs.readFileSync("/tmp/plugins-bundle-inspect.json", "utf8")); -if (inspect.plugin?.bundleFormat !== "claude") { - throw new Error(`expected Claude bundle format, got ${inspect.plugin?.bundleFormat}`); -} -if (inspect.plugin?.enabled !== true || inspect.plugin?.status !== "loaded") { - throw new Error( - `expected enabled loaded Claude bundle, got enabled=${inspect.plugin?.enabled} status=${inspect.plugin?.status}`, - ); -} -console.log("ok"); -NODE +node scripts/e2e/lib/plugins/assertions.mjs bundle-inspect echo "Testing plugin install visible after explicit restart..." slash_install_dir="$(mktemp -d "/tmp/openclaw-plugin-slash-install.XXXXXX")" @@ -305,20 +211,7 @@ JSON run_logged install-slash-plugin node "$OPENCLAW_ENTRY" plugins install "$slash_install_dir" node "$OPENCLAW_ENTRY" plugins inspect slash-install-plugin --json >/tmp/plugin-command-install-show.json -node - <<'NODE' -const fs = require("node:fs"); -const inspect = JSON.parse(fs.readFileSync("/tmp/plugin-command-install-show.json", "utf8")); -if (inspect.plugin?.status !== "loaded") { - throw new Error(`expected loaded status after install, got ${inspect.plugin?.status}`); -} -if (inspect.plugin?.enabled !== true) { - throw new Error(`expected enabled status after install, got ${inspect.plugin?.enabled}`); -} -if (!inspect.gatewayMethods.includes("demo.slash.install")) { - throw new Error(`expected installed gateway method, got ${inspect.gatewayMethods.join(", ")}`); -} -console.log("ok"); -NODE +node scripts/e2e/lib/plugins/assertions.mjs slash-install run_plugins_marketplace_scenario