From 4f73baf7d7b1eb8ce0ff01d889135d054d21bfd8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Apr 2026 11:48:52 +0100 Subject: [PATCH] refactor(test): split e2e fixture helpers --- .../e2e/lib/doctor-install-switch/scenario.sh | 60 +--- .../lib/doctor-install-switch/shims/loginctl | 7 + .../lib/doctor-install-switch/shims/systemctl | 34 +++ scripts/e2e/lib/fixture.mjs | 289 +----------------- scripts/e2e/lib/fixtures/common.mjs | 24 ++ scripts/e2e/lib/fixtures/config.mjs | 115 +++++++ scripts/e2e/lib/fixtures/plugins.mjs | 82 +++++ scripts/e2e/lib/fixtures/workspace.mjs | 70 +++++ .../lib/npm-telegram-live/prepare-package.mjs | 13 + scripts/e2e/lib/onboard/log-contains.mjs | 32 ++ scripts/e2e/lib/onboard/scenario.sh | 31 +- scripts/e2e/lib/parallels-package-common.sh | 41 +-- .../parallels-package/build-info-commit.mjs | 9 + .../log-progress-extract.mjs | 18 ++ scripts/e2e/npm-telegram-live-docker.sh | 21 +- ...erver.agent.gateway-server-agent-a.test.ts | 38 +-- ...erver.agent.gateway-server-agent-b.test.ts | 17 +- src/gateway/test/agent-command-helpers.ts | 32 ++ 18 files changed, 466 insertions(+), 467 deletions(-) create mode 100755 scripts/e2e/lib/doctor-install-switch/shims/loginctl create mode 100755 scripts/e2e/lib/doctor-install-switch/shims/systemctl create mode 100644 scripts/e2e/lib/fixtures/common.mjs create mode 100644 scripts/e2e/lib/fixtures/config.mjs create mode 100644 scripts/e2e/lib/fixtures/plugins.mjs create mode 100644 scripts/e2e/lib/fixtures/workspace.mjs create mode 100644 scripts/e2e/lib/npm-telegram-live/prepare-package.mjs create mode 100644 scripts/e2e/lib/onboard/log-contains.mjs create mode 100644 scripts/e2e/lib/parallels-package/build-info-commit.mjs create mode 100644 scripts/e2e/lib/parallels-package/log-progress-extract.mjs create mode 100644 src/gateway/test/agent-command-helpers.ts diff --git a/scripts/e2e/lib/doctor-install-switch/scenario.sh b/scripts/e2e/lib/doctor-install-switch/scenario.sh index b98c1e7ba01..4808391a86a 100644 --- a/scripts/e2e/lib/doctor-install-switch/scenario.sh +++ b/scripts/e2e/lib/doctor-install-switch/scenario.sh @@ -12,63 +12,9 @@ export OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 # Stub systemd/loginctl so doctor + daemon flows work in Docker. export PATH="/tmp/openclaw-bin:$PATH" mkdir -p /tmp/openclaw-bin - -cat >/tmp/openclaw-bin/systemctl <<"SYSTEMCTL" -#!/usr/bin/env bash -set -euo pipefail - -args=("$@") -if [[ "${args[0]:-}" == "--user" ]]; then -args=("${args[@]:1}") -fi -cmd="${args[0]:-}" -case "$cmd" in -status) - exit 0 - ;; -is-active) - echo "inactive" >&2 - exit 3 - ;; -is-enabled) - unit="${args[1]:-}" - unit_path="$HOME/.config/systemd/user/${unit}" - if [ -f "$unit_path" ]; then - echo "enabled" - exit 0 - fi - echo "disabled" >&2 - exit 1 - ;; -show) - echo "ActiveState=inactive" - echo "SubState=dead" - echo "MainPID=0" - echo "ExecMainStatus=0" - echo "ExecMainCode=0" - exit 0 - ;; -*) - exit 0 - ;; -esac -SYSTEMCTL -chmod +x /tmp/openclaw-bin/systemctl - -cat >/tmp/openclaw-bin/loginctl <<"LOGINCTL" -#!/usr/bin/env bash -set -euo pipefail - -if [[ "$*" == *"show-user"* ]]; then -echo "Linger=yes" -exit 0 -fi -if [[ "$*" == *"enable-linger"* ]]; then -exit 0 -fi -exit 0 -LOGINCTL -chmod +x /tmp/openclaw-bin/loginctl +cp scripts/e2e/lib/doctor-install-switch/shims/systemctl /tmp/openclaw-bin/systemctl +cp scripts/e2e/lib/doctor-install-switch/shims/loginctl /tmp/openclaw-bin/loginctl +chmod +x /tmp/openclaw-bin/systemctl /tmp/openclaw-bin/loginctl package_tgz="${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}" git_root="/tmp/openclaw-git" diff --git a/scripts/e2e/lib/doctor-install-switch/shims/loginctl b/scripts/e2e/lib/doctor-install-switch/shims/loginctl new file mode 100755 index 00000000000..71244232053 --- /dev/null +++ b/scripts/e2e/lib/doctor-install-switch/shims/loginctl @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +case "$*" in + *show-user*) echo "Linger=yes" ;; + *enable-linger*) ;; +esac diff --git a/scripts/e2e/lib/doctor-install-switch/shims/systemctl b/scripts/e2e/lib/doctor-install-switch/shims/systemctl new file mode 100755 index 00000000000..94f13451595 --- /dev/null +++ b/scripts/e2e/lib/doctor-install-switch/shims/systemctl @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +args=("$@") +if [[ "${args[0]:-}" == "--user" ]]; then + args=("${args[@]:1}") +fi +cmd="${args[0]:-}" + +case "$cmd" in + status) ;; + is-active) + echo "inactive" >&2 + exit 3 + ;; + is-enabled) + unit="${args[1]:-}" + unit_path="$HOME/.config/systemd/user/${unit}" + if [ -f "$unit_path" ]; then + echo "enabled" + exit 0 + fi + echo "disabled" >&2 + exit 1 + ;; + show) + printf "%s\n" \ + "ActiveState=inactive" \ + "SubState=dead" \ + "MainPID=0" \ + "ExecMainStatus=0" \ + "ExecMainCode=0" + ;; +esac diff --git a/scripts/e2e/lib/fixture.mjs b/scripts/e2e/lib/fixture.mjs index f2a1622143b..4cb8a9a91bc 100644 --- a/scripts/e2e/lib/fixture.mjs +++ b/scripts/e2e/lib/fixture.mjs @@ -1,283 +1,16 @@ -import fs from "node:fs"; -import path from "node:path"; +import { configCommands } from "./fixtures/config.mjs"; +import { pluginCommands } from "./fixtures/plugins.mjs"; +import { workspaceCommands } from "./fixtures/workspace.mjs"; const [command, ...args] = process.argv.slice(2); -const json = (value) => `${JSON.stringify(value, null, 2)}\n`; -const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8")); -const write = (file, contents) => { - fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync(file, contents); -}; -const writeJson = (file, value) => write(file, json(value)); -const requireArg = (value, name) => { - if (!value) { - throw new Error(`${name} is required`); - } - return value; -}; -const assert = (condition, message) => { - if (!condition) { - throw new Error(message); - } -}; -function writePluginManifest(file, id) { - writeJson(file, { id, configSchema: { type: "object", properties: {} } }); +const handler = { + ...pluginCommands, + ...configCommands, + ...workspaceCommands, +}[command]; +if (!handler) { + throw new Error(`unknown fixture command: ${command}`); } -function writePluginDemo([dir]) { - write( - path.join(requireArg(dir, "dir"), "index.js"), - 'module.exports = { id: "demo-plugin", name: "Demo Plugin", description: "Docker E2E demo plugin", register(api) { api.registerTool(() => null, { name: "demo_tool" }); api.registerGatewayMethod("demo.ping", async () => ({ ok: true })); api.registerCli(() => {}, { commands: ["demo"] }); api.registerService({ id: "demo-service", start: () => {} }); }, };\n', - ); - writePluginManifest(path.join(dir, "openclaw.plugin.json"), "demo-plugin"); -} - -function writePlugin([dir, id, version, method, name]) { - for (const [value, label] of [ - [dir, "dir"], - [id, "id"], - [version, "version"], - [method, "method"], - [name, "name"], - ]) { - requireArg(value, label); - } - writeJson(path.join(dir, "package.json"), { - name: `@openclaw/${id}`, - version, - openclaw: { extensions: ["./index.js"] }, - }); - write( - path.join(dir, "index.js"), - `module.exports = { id: ${JSON.stringify(id)}, name: ${JSON.stringify(name)}, register(api) { api.registerGatewayMethod(${JSON.stringify(method)}, async () => ({ ok: true })); }, };\n`, - ); - writePluginManifest(path.join(dir, "openclaw.plugin.json"), id); -} - -function writeClaudeBundle([root]) { - root = requireArg(root, "root"); - writeJson(path.join(root, ".claude-plugin", "plugin.json"), { name: "claude-bundle-e2e" }); - write( - path.join(root, "commands", "office-hours.md"), - "---\ndescription: Help with architecture and rollout planning\n---\nAct as an engineering advisor.\n\nFocus on:\n$ARGUMENTS\n", - ); -} - -function writePluginMarketplace([root]) { - root = requireArg(root, "root"); - writeJson(path.join(root, ".claude-plugin", "marketplace.json"), { - name: "Fixture Marketplace", - version: "1.0.0", - plugins: [ - { - name: "marketplace-shortcut", - version: "0.0.1", - description: "Shortcut install fixture", - source: "./plugins/marketplace-shortcut", - }, - { - name: "marketplace-direct", - version: "0.0.1", - description: "Explicit marketplace fixture", - source: { type: "path", path: "./plugins/marketplace-direct" }, - }, - ], - }); - writeJson(path.join(process.env.HOME, ".claude", "plugins", "known_marketplaces.json"), { - "claude-fixtures": { - installLocation: root, - source: { type: "github", repo: "openclaw/fixture-marketplace" }, - }, - }); -} - -function writeConfig(kind) { - const configPath = requireArg(process.env.OPENCLAW_CONFIG_PATH, "OPENCLAW_CONFIG_PATH"); - const port = Number(process.env.PORT ?? 18789); - const config = - kind === "config-reload" - ? { - gateway: { - port, - auth: { - mode: "token", - token: { source: "env", provider: "default", id: "GATEWAY_AUTH_TOKEN_REF" }, - }, - channelHealthCheckMinutes: 1, - controlUi: { enabled: false }, - reload: { mode: "hybrid", debounceMs: 0 }, - }, - } - : kind === "browser-cdp" - ? { - gateway: { - port, - auth: { - mode: "token", - token: requireArg(process.env.OPENCLAW_GATEWAY_TOKEN, "OPENCLAW_GATEWAY_TOKEN"), - }, - controlUi: { enabled: false }, - }, - browser: { - enabled: true, - defaultProfile: "docker-cdp", - ssrfPolicy: { allowedHostnames: ["127.0.0.1"] }, - profiles: { - "docker-cdp": { - cdpUrl: `http://127.0.0.1:${Number(process.env.CDP_PORT ?? 19222)}`, - color: "#FF4500", - }, - }, - }, - } - : null; - writeJson(configPath, requireArg(config, "known config kind")); -} - -function writeOpenAiWebSearchMinimalConfig() { - writeJson(path.join(process.env.OPENCLAW_STATE_DIR, "openclaw.json"), { - agents: { - defaults: { - model: { primary: "openai/gpt-5" }, - models: { - "openai/gpt-5": { - params: { transport: "sse", openaiWsWarmup: false }, - }, - }, - }, - }, - models: { - providers: { - openai: { - api: "openai-responses", - baseUrl: "http://api.openai.com/v1", - apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, - request: { allowPrivateNetwork: true }, - models: [ - { - id: "gpt-5", - name: "gpt-5", - api: "openai-responses", - reasoning: true, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 128000, - contextTokens: 96000, - maxTokens: 4096, - }, - ], - }, - }, - }, - tools: { web: { search: { enabled: true, maxResults: 3 } } }, - plugins: { enabled: true, allow: ["openai"], entries: { openai: { enabled: true } } }, - gateway: { auth: { mode: "token", token: process.env.OPENCLAW_GATEWAY_TOKEN } }, - }); -} - -function writeOpenWebUiConfig([openaiApiKey]) { - const batchPath = requireArg( - process.env.OPENCLAW_CONFIG_BATCH_PATH, - "OPENCLAW_CONFIG_BATCH_PATH", - ); - writeJson(batchPath, [ - { path: "models.providers.openai.apiKey", value: requireArg(openaiApiKey, "OpenAI API key") }, - { - path: "models.providers.openai.baseUrl", - value: (process.env.OPENAI_BASE_URL || "https://api.openai.com/v1").trim(), - }, - { path: "models.providers.openai.models", value: [] }, - { path: "gateway.controlUi.enabled", value: false }, - { path: "gateway.mode", value: "local" }, - { path: "gateway.bind", value: "lan" }, - { path: "gateway.auth.mode", value: "token" }, - { path: "gateway.auth.token", value: process.env.OPENCLAW_GATEWAY_TOKEN }, - { path: "gateway.http.endpoints.chatCompletions.enabled", value: true }, - { path: "agents.defaults.model.primary", value: process.env.OPENCLAW_OPENWEBUI_MODEL }, - ]); -} - -function writeOpenWebUiWorkspace() { - const workspace = - process.env.OPENCLAW_WORKSPACE_DIR || path.join(process.env.HOME, ".openclaw", "workspace"); - write( - path.join(workspace, "IDENTITY.md"), - "# Identity\n\n- Name: OpenClaw\n- Purpose: Open WebUI Docker compatibility smoke test assistant.\n", - ); - writeJson(path.join(workspace, ".openclaw", "workspace-state.json"), { - version: 1, - setupCompletedAt: "2026-01-01T00:00:00.000Z", - }); - fs.rmSync(path.join(workspace, "BOOTSTRAP.md"), { force: true }); -} - -function writeAgentsDeleteConfig() { - const stateDir = requireArg(process.env.OPENCLAW_STATE_DIR, "OPENCLAW_STATE_DIR"); - const sharedWorkspace = requireArg(process.env.SHARED_WORKSPACE, "SHARED_WORKSPACE"); - fs.mkdirSync(sharedWorkspace, { recursive: true }); - writeJson(path.join(stateDir, "openclaw.json"), { - agents: { - list: [ - { id: "main", workspace: sharedWorkspace }, - { id: "ops", workspace: sharedWorkspace }, - ], - }, - }); -} - -function assertAgentsDeleteResult([outputPath]) { - let parsed; - try { - parsed = readJson(requireArg(outputPath, "outputPath")); - } catch (error) { - console.error("agents delete --json did not emit valid JSON:"); - console.error(fs.readFileSync(outputPath, "utf8").trim()); - throw error; - } - for (const [actual, expected, label] of [ - [parsed.agentId, "ops", "agentId"], - [parsed.workspace, process.env.SHARED_WORKSPACE, "workspace"], - [parsed.workspaceRetained, true, "workspaceRetained"], - [parsed.workspaceRetainedReason, "shared", "workspaceRetainedReason"], - ]) { - assert(actual === expected, `${label} mismatch: ${JSON.stringify(actual)}`); - } - assert( - Array.isArray(parsed.workspaceSharedWith) && parsed.workspaceSharedWith.includes("main"), - "missing shared-with main marker", - ); - assert(fs.existsSync(process.env.SHARED_WORKSPACE), "shared workspace was removed"); - const remaining = - readJson(path.join(process.env.OPENCLAW_STATE_DIR, "openclaw.json"))?.agents?.list ?? []; - assert(Array.isArray(remaining), "agents list missing after delete"); - assert(!remaining.some((entry) => entry?.id === "ops"), "deleted agent remained in config"); - assert( - remaining.some((entry) => entry?.id === "main"), - "main agent missing after delete", - ); - console.log("agents delete shared workspace smoke ok"); -} - -const commands = { - "plugin-demo": writePluginDemo, - plugin: writePlugin, - "plugin-manifest": ([file, id]) => - writePluginManifest(requireArg(file, "file"), requireArg(id, "id")), - "claude-bundle": writeClaudeBundle, - marketplace: writePluginMarketplace, - "config-reload": () => writeConfig("config-reload"), - "browser-cdp": () => writeConfig("browser-cdp"), - "openai-web-search-minimal-config": writeOpenAiWebSearchMinimalConfig, - "openwebui-config": writeOpenWebUiConfig, - "openwebui-workspace": writeOpenWebUiWorkspace, - "agents-delete-config": writeAgentsDeleteConfig, - "agents-delete-assert": assertAgentsDeleteResult, -}; - -( - commands[command] ?? - (() => { - throw new Error(`unknown fixture command: ${command}`); - }) -)(args); +handler(args); diff --git a/scripts/e2e/lib/fixtures/common.mjs b/scripts/e2e/lib/fixtures/common.mjs new file mode 100644 index 00000000000..dd7113baa8e --- /dev/null +++ b/scripts/e2e/lib/fixtures/common.mjs @@ -0,0 +1,24 @@ +import fs from "node:fs"; +import path from "node:path"; + +export const json = (value) => `${JSON.stringify(value, null, 2)}\n`; +export const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8")); + +export const write = (file, contents) => { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, contents); +}; +export const writeJson = (file, value) => write(file, json(value)); + +export const requireArg = (value, name) => { + if (!value) { + throw new Error(`${name} is required`); + } + return value; +}; + +export const assert = (condition, message) => { + if (!condition) { + throw new Error(message); + } +}; diff --git a/scripts/e2e/lib/fixtures/config.mjs b/scripts/e2e/lib/fixtures/config.mjs new file mode 100644 index 00000000000..e3025d9e09f --- /dev/null +++ b/scripts/e2e/lib/fixtures/config.mjs @@ -0,0 +1,115 @@ +import path from "node:path"; +import { requireArg, writeJson } from "./common.mjs"; + +function writeConfig(kind) { + const configPath = requireArg(process.env.OPENCLAW_CONFIG_PATH, "OPENCLAW_CONFIG_PATH"); + const port = Number(process.env.PORT ?? 18789); + const config = + kind === "config-reload" + ? { + gateway: { + port, + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "GATEWAY_AUTH_TOKEN_REF" }, + }, + channelHealthCheckMinutes: 1, + controlUi: { enabled: false }, + reload: { mode: "hybrid", debounceMs: 0 }, + }, + } + : kind === "browser-cdp" + ? { + gateway: { + port, + auth: { + mode: "token", + token: requireArg(process.env.OPENCLAW_GATEWAY_TOKEN, "OPENCLAW_GATEWAY_TOKEN"), + }, + controlUi: { enabled: false }, + }, + browser: { + enabled: true, + defaultProfile: "docker-cdp", + ssrfPolicy: { allowedHostnames: ["127.0.0.1"] }, + profiles: { + "docker-cdp": { + cdpUrl: `http://127.0.0.1:${Number(process.env.CDP_PORT ?? 19222)}`, + color: "#FF4500", + }, + }, + }, + } + : null; + writeJson(configPath, requireArg(config, "known config kind")); +} + +function writeOpenAiWebSearchMinimalConfig() { + writeJson(path.join(process.env.OPENCLAW_STATE_DIR, "openclaw.json"), { + agents: { + defaults: { + model: { primary: "openai/gpt-5" }, + models: { + "openai/gpt-5": { + params: { transport: "sse", openaiWsWarmup: false }, + }, + }, + }, + }, + models: { + providers: { + openai: { + api: "openai-responses", + baseUrl: "http://api.openai.com/v1", + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + request: { allowPrivateNetwork: true }, + models: [ + { + id: "gpt-5", + name: "gpt-5", + api: "openai-responses", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + contextTokens: 96000, + maxTokens: 4096, + }, + ], + }, + }, + }, + tools: { web: { search: { enabled: true, maxResults: 3 } } }, + plugins: { enabled: true, allow: ["openai"], entries: { openai: { enabled: true } } }, + gateway: { auth: { mode: "token", token: process.env.OPENCLAW_GATEWAY_TOKEN } }, + }); +} + +function writeOpenWebUiConfig([openaiApiKey]) { + const batchPath = requireArg( + process.env.OPENCLAW_CONFIG_BATCH_PATH, + "OPENCLAW_CONFIG_BATCH_PATH", + ); + writeJson(batchPath, [ + { path: "models.providers.openai.apiKey", value: requireArg(openaiApiKey, "OpenAI API key") }, + { + path: "models.providers.openai.baseUrl", + value: (process.env.OPENAI_BASE_URL || "https://api.openai.com/v1").trim(), + }, + { path: "models.providers.openai.models", value: [] }, + { path: "gateway.controlUi.enabled", value: false }, + { path: "gateway.mode", value: "local" }, + { path: "gateway.bind", value: "lan" }, + { path: "gateway.auth.mode", value: "token" }, + { path: "gateway.auth.token", value: process.env.OPENCLAW_GATEWAY_TOKEN }, + { path: "gateway.http.endpoints.chatCompletions.enabled", value: true }, + { path: "agents.defaults.model.primary", value: process.env.OPENCLAW_OPENWEBUI_MODEL }, + ]); +} + +export const configCommands = { + "config-reload": () => writeConfig("config-reload"), + "browser-cdp": () => writeConfig("browser-cdp"), + "openai-web-search-minimal-config": writeOpenAiWebSearchMinimalConfig, + "openwebui-config": writeOpenWebUiConfig, +}; diff --git a/scripts/e2e/lib/fixtures/plugins.mjs b/scripts/e2e/lib/fixtures/plugins.mjs new file mode 100644 index 00000000000..8cc13d820df --- /dev/null +++ b/scripts/e2e/lib/fixtures/plugins.mjs @@ -0,0 +1,82 @@ +import path from "node:path"; +import { requireArg, write, writeJson } from "./common.mjs"; + +function writePluginManifest(file, id) { + writeJson(file, { id, configSchema: { type: "object", properties: {} } }); +} + +function writePluginDemo([dir]) { + write( + path.join(requireArg(dir, "dir"), "index.js"), + 'module.exports = { id: "demo-plugin", name: "Demo Plugin", description: "Docker E2E demo plugin", register(api) { api.registerTool(() => null, { name: "demo_tool" }); api.registerGatewayMethod("demo.ping", async () => ({ ok: true })); api.registerCli(() => {}, { commands: ["demo"] }); api.registerService({ id: "demo-service", start: () => {} }); }, };\n', + ); + writePluginManifest(path.join(dir, "openclaw.plugin.json"), "demo-plugin"); +} + +function writePlugin([dir, id, version, method, name]) { + for (const [value, label] of [ + [dir, "dir"], + [id, "id"], + [version, "version"], + [method, "method"], + [name, "name"], + ]) { + requireArg(value, label); + } + writeJson(path.join(dir, "package.json"), { + name: `@openclaw/${id}`, + version, + openclaw: { extensions: ["./index.js"] }, + }); + write( + path.join(dir, "index.js"), + `module.exports = { id: ${JSON.stringify(id)}, name: ${JSON.stringify(name)}, register(api) { api.registerGatewayMethod(${JSON.stringify(method)}, async () => ({ ok: true })); }, };\n`, + ); + writePluginManifest(path.join(dir, "openclaw.plugin.json"), id); +} + +function writeClaudeBundle([root]) { + root = requireArg(root, "root"); + writeJson(path.join(root, ".claude-plugin", "plugin.json"), { name: "claude-bundle-e2e" }); + write( + path.join(root, "commands", "office-hours.md"), + "---\ndescription: Help with architecture and rollout planning\n---\nAct as an engineering advisor.\n\nFocus on:\n$ARGUMENTS\n", + ); +} + +function writePluginMarketplace([root]) { + root = requireArg(root, "root"); + writeJson(path.join(root, ".claude-plugin", "marketplace.json"), { + name: "Fixture Marketplace", + version: "1.0.0", + plugins: [ + { + name: "marketplace-shortcut", + version: "0.0.1", + description: "Shortcut install fixture", + source: "./plugins/marketplace-shortcut", + }, + { + name: "marketplace-direct", + version: "0.0.1", + description: "Explicit marketplace fixture", + source: { type: "path", path: "./plugins/marketplace-direct" }, + }, + ], + }); + writeJson(path.join(process.env.HOME, ".claude", "plugins", "known_marketplaces.json"), { + "claude-fixtures": { + installLocation: root, + source: { type: "github", repo: "openclaw/fixture-marketplace" }, + }, + }); +} + +export const pluginCommands = { + "plugin-demo": writePluginDemo, + plugin: writePlugin, + "plugin-manifest": ([file, id]) => + writePluginManifest(requireArg(file, "file"), requireArg(id, "id")), + "claude-bundle": writeClaudeBundle, + marketplace: writePluginMarketplace, +}; diff --git a/scripts/e2e/lib/fixtures/workspace.mjs b/scripts/e2e/lib/fixtures/workspace.mjs new file mode 100644 index 00000000000..9528c295964 --- /dev/null +++ b/scripts/e2e/lib/fixtures/workspace.mjs @@ -0,0 +1,70 @@ +import fs from "node:fs"; +import path from "node:path"; +import { assert, readJson, requireArg, write, writeJson } from "./common.mjs"; + +function writeOpenWebUiWorkspace() { + const workspace = + process.env.OPENCLAW_WORKSPACE_DIR || path.join(process.env.HOME, ".openclaw", "workspace"); + write( + path.join(workspace, "IDENTITY.md"), + "# Identity\n\n- Name: OpenClaw\n- Purpose: Open WebUI Docker compatibility smoke test assistant.\n", + ); + writeJson(path.join(workspace, ".openclaw", "workspace-state.json"), { + version: 1, + setupCompletedAt: "2026-01-01T00:00:00.000Z", + }); + fs.rmSync(path.join(workspace, "BOOTSTRAP.md"), { force: true }); +} + +function writeAgentsDeleteConfig() { + const stateDir = requireArg(process.env.OPENCLAW_STATE_DIR, "OPENCLAW_STATE_DIR"); + const sharedWorkspace = requireArg(process.env.SHARED_WORKSPACE, "SHARED_WORKSPACE"); + fs.mkdirSync(sharedWorkspace, { recursive: true }); + writeJson(path.join(stateDir, "openclaw.json"), { + agents: { + list: [ + { id: "main", workspace: sharedWorkspace }, + { id: "ops", workspace: sharedWorkspace }, + ], + }, + }); +} + +function assertAgentsDeleteResult([outputPath]) { + let parsed; + try { + parsed = readJson(requireArg(outputPath, "outputPath")); + } catch (error) { + console.error("agents delete --json did not emit valid JSON:"); + console.error(fs.readFileSync(outputPath, "utf8").trim()); + throw error; + } + for (const [actual, expected, label] of [ + [parsed.agentId, "ops", "agentId"], + [parsed.workspace, process.env.SHARED_WORKSPACE, "workspace"], + [parsed.workspaceRetained, true, "workspaceRetained"], + [parsed.workspaceRetainedReason, "shared", "workspaceRetainedReason"], + ]) { + assert(actual === expected, `${label} mismatch: ${JSON.stringify(actual)}`); + } + assert( + Array.isArray(parsed.workspaceSharedWith) && parsed.workspaceSharedWith.includes("main"), + "missing shared-with main marker", + ); + assert(fs.existsSync(process.env.SHARED_WORKSPACE), "shared workspace was removed"); + const remaining = + readJson(path.join(process.env.OPENCLAW_STATE_DIR, "openclaw.json"))?.agents?.list ?? []; + assert(Array.isArray(remaining), "agents list missing after delete"); + assert(!remaining.some((entry) => entry?.id === "ops"), "deleted agent remained in config"); + assert( + remaining.some((entry) => entry?.id === "main"), + "main agent missing after delete", + ); + console.log("agents delete shared workspace smoke ok"); +} + +export const workspaceCommands = { + "openwebui-workspace": writeOpenWebUiWorkspace, + "agents-delete-config": writeAgentsDeleteConfig, + "agents-delete-assert": assertAgentsDeleteResult, +}; diff --git a/scripts/e2e/lib/npm-telegram-live/prepare-package.mjs b/scripts/e2e/lib/npm-telegram-live/prepare-package.mjs new file mode 100644 index 00000000000..7b9c7825f3b --- /dev/null +++ b/scripts/e2e/lib/npm-telegram-live/prepare-package.mjs @@ -0,0 +1,13 @@ +import fs from "node:fs"; + +for (const packageJsonPath of process.argv.slice(2)) { + const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + pkg.exports = pkg.exports && typeof pkg.exports === "object" ? pkg.exports : {}; + if (!pkg.exports["./plugin-sdk/gateway-runtime"]) { + pkg.exports["./plugin-sdk/gateway-runtime"] = { + types: "./dist/plugin-sdk/gateway-runtime.d.ts", + default: "./dist/plugin-sdk/gateway-runtime.js", + }; + } + fs.writeFileSync(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`); +} diff --git a/scripts/e2e/lib/onboard/log-contains.mjs b/scripts/e2e/lib/onboard/log-contains.mjs new file mode 100644 index 00000000000..f8e34ef9dcc --- /dev/null +++ b/scripts/e2e/lib/onboard/log-contains.mjs @@ -0,0 +1,32 @@ +import fs from "node:fs"; + +const [file, needle] = process.argv.slice(2); +if (!file || !needle) { + process.exit(1); +} + +let text = ""; +try { + text = fs.readFileSync(file, "utf8"); +} catch { + process.exit(1); +} + +if (text.length > 120000) { + text = text.slice(-120000); +} + +const normalizeScriptOutput = (value) => value.replace(/\r?\n/g, "").replace(/\r/g, ""); +const oscPattern = new RegExp(String.raw`\u001b\][^\u0007]*(?:\u0007|\u001b\\)`, "g"); +const csiPattern = new RegExp(String.raw`\u001b\[[0-?]*[ -/]*[@-~]`, "g"); + +const stripAnsi = (value) => + normalizeScriptOutput(value).replace(oscPattern, "").replace(csiPattern, ""); + +const compact = (value) => + stripAnsi(value) + .toLowerCase() + .replace(/[^a-z]+/g, ""); +const compactNeedle = compact(needle); + +process.exit(compactNeedle && compact(text).includes(compactNeedle) ? 0 : 1); diff --git a/scripts/e2e/lib/onboard/scenario.sh b/scripts/e2e/lib/onboard/scenario.sh index 05af48765aa..d6f1d3b8f19 100644 --- a/scripts/e2e/lib/onboard/scenario.sh +++ b/scripts/e2e/lib/onboard/scenario.sh @@ -23,8 +23,6 @@ wait_for_log() { local needle="$1" local timeout_s="${2:-45}" local quiet_on_timeout="${3:-false}" - local needle_compact - needle_compact="$(printf "%s" "$needle" | tr -cd "[:alpha:]")" local start_s start_s="$(date +%s)" while true; do @@ -32,34 +30,7 @@ wait_for_log() { if grep -a -F -q "$needle" "$WIZARD_LOG_PATH"; then return 0 fi - if NEEDLE=\"$needle_compact\" node --input-type=module -e " - import fs from \"node:fs\"; - const file = process.env.WIZARD_LOG_PATH; - const needle = process.env.NEEDLE ?? \"\"; - let text = \"\"; - try { text = fs.readFileSync(file, \"utf8\"); } catch { process.exit(1); } - // Clack/script output can include lots of control sequences; keep a larger tail and strip ANSI more robustly. - if (text.length > 120000) text = text.slice(-120000); - const normalizeScriptOutput = (value) => - value - // util-linux script can emit each byte on its own CRLF-delimited line. - // Collapse those first so ANSI/control stripping works on real sequences. - .replace(/\\r?\\n/g, \"\") - .replace(/\\r/g, \"\"); - const stripAnsi = (value) => - normalizeScriptOutput(value) - // OSC: ESC ] ... BEL or ESC \\ - .replace(/\\x1b\\][^\\x07]*(?:\\x07|\\x1b\\\\)/g, \"\") - // CSI: ESC [ ... cmd - .replace(/\\x1b\\[[0-?]*[ -/]*[@-~]/g, \"\"); - // Letters-only: script output sometimes fragments ANSI sequences into digits/letters that - // can otherwise break substring matching. - const compact = (value) => stripAnsi(value).toLowerCase().replace(/[^a-z]+/g, \"\"); - const haystack = compact(text); - const compactNeedle = compact(needle); - if (!compactNeedle) process.exit(1); - process.exit(haystack.includes(compactNeedle) ? 0 : 1); - "; then + if node scripts/e2e/lib/onboard/log-contains.mjs "$WIZARD_LOG_PATH" "$needle"; then return 0 fi fi diff --git a/scripts/e2e/lib/parallels-package-common.sh b/scripts/e2e/lib/parallels-package-common.sh index 516effd8909..58d223a3a70 100644 --- a/scripts/e2e/lib/parallels-package-common.sh +++ b/scripts/e2e/lib/parallels-package-common.sh @@ -1,16 +1,7 @@ #!/usr/bin/env bash parallels_package_current_build_commit() { - python3 - <<'PY' -import json -import pathlib - -path = pathlib.Path("dist/build-info.json") -if not path.exists(): - print("") -else: - print(json.loads(path.read_text()).get("commit", "")) -PY + node scripts/e2e/lib/parallels-package/build-info-commit.mjs } parallels_package_acquire_build_lock() { @@ -66,35 +57,9 @@ parallels_package_assert_no_generated_drift() { } parallels_log_progress_extract() { - local python_bin="$1" + local _python_bin="$1" local log_path="$2" - "$python_bin" - "$log_path" <<'PY' -import pathlib -import sys - -path = pathlib.Path(sys.argv[1]) -if not path.exists(): - print("") - raise SystemExit(0) - -text = path.read_text(encoding="utf-8", errors="replace") -lines = [line.strip() for line in text.splitlines() if line.strip()] - -for line in reversed(lines): - if line.startswith("==> "): - print(line[4:].strip()) - raise SystemExit(0) - -for line in reversed(lines): - if line.startswith("warn:") or line.startswith("error:"): - print(line) - raise SystemExit(0) - -if lines: - print(lines[-1][:240]) -else: - print("") -PY + node scripts/e2e/lib/parallels-package/log-progress-extract.mjs "$log_path" } parallels_child_job_running() { diff --git a/scripts/e2e/lib/parallels-package/build-info-commit.mjs b/scripts/e2e/lib/parallels-package/build-info-commit.mjs new file mode 100644 index 00000000000..851e9f391cb --- /dev/null +++ b/scripts/e2e/lib/parallels-package/build-info-commit.mjs @@ -0,0 +1,9 @@ +import fs from "node:fs"; + +const path = "dist/build-info.json"; +if (!fs.existsSync(path)) { + console.log(""); +} else { + const buildInfo = JSON.parse(fs.readFileSync(path, "utf8")); + console.log(buildInfo.commit ?? ""); +} diff --git a/scripts/e2e/lib/parallels-package/log-progress-extract.mjs b/scripts/e2e/lib/parallels-package/log-progress-extract.mjs new file mode 100644 index 00000000000..c5ebff378bf --- /dev/null +++ b/scripts/e2e/lib/parallels-package/log-progress-extract.mjs @@ -0,0 +1,18 @@ +import fs from "node:fs"; + +const [logPath] = process.argv.slice(2); +if (!logPath || !fs.existsSync(logPath)) { + console.log(""); + process.exit(0); +} + +const text = fs.readFileSync(logPath, "utf8"); +const lines = text + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); +const reversed = lines.toReversed(); + +const progress = reversed.find((line) => line.startsWith("==> ")); +const warning = reversed.find((line) => line.startsWith("warn:") || line.startsWith("error:")); +console.log(progress?.slice(4).trim() ?? warning ?? lines.at(-1)?.slice(0, 240) ?? ""); diff --git a/scripts/e2e/npm-telegram-live-docker.sh b/scripts/e2e/npm-telegram-live-docker.sh index fe967b64955..106d68011dc 100755 --- a/scripts/e2e/npm-telegram-live-docker.sh +++ b/scripts/e2e/npm-telegram-live-docker.sh @@ -233,24 +233,9 @@ ln -sfnT /app/extensions "$openclaw_package_dir/extensions" mkdir -p /app/node_modules/@openclaw rm -rf /app/node_modules/@openclaw/qa-channel ln -sfnT /app/extensions/qa-channel /app/node_modules/@openclaw/qa-channel -node --input-type=module <<'NODE' -import fs from "node:fs"; - -for (const packageJsonPath of [ - "/app/package.json", - "/app/node_modules/openclaw/package.json", -]) { - const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); - pkg.exports = pkg.exports && typeof pkg.exports === "object" ? pkg.exports : {}; - if (!pkg.exports["./plugin-sdk/gateway-runtime"]) { - pkg.exports["./plugin-sdk/gateway-runtime"] = { - types: "./dist/plugin-sdk/gateway-runtime.d.ts", - default: "./dist/plugin-sdk/gateway-runtime.js", - }; - } - fs.writeFileSync(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`); -} -NODE +node scripts/e2e/lib/npm-telegram-live/prepare-package.mjs \ + /app/package.json \ + /app/node_modules/openclaw/package.json for deps_dir in "$openclaw_package_dir/node_modules" /npm-global/lib/node_modules; do [ -d "$deps_dir" ] || continue for dependency_dir in "$deps_dir"/*; do diff --git a/src/gateway/server.agent.gateway-server-agent-a.test.ts b/src/gateway/server.agent.gateway-server-agent-a.test.ts index fc96a137813..ffe9b3f7fa4 100644 --- a/src/gateway/server.agent.gateway-server-agent-a.test.ts +++ b/src/gateway/server.agent.gateway-server-agent-a.test.ts @@ -15,6 +15,7 @@ import { testState, writeSessionStore, } from "./test-helpers.js"; +import { waitForAgentCommandCall } from "./test/agent-command-helpers.js"; installGatewayTestHooks({ scope: "suite" }); @@ -41,10 +42,6 @@ afterAll(async () => { const BASE_IMAGE_PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X3mIAAAAASUVORK5CYII="; -type AgentCommandCall = Record; - -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - function expectChannels(call: Record, channel: string) { expect(call.channel).toBe(channel); expect(call.messageChannel).toBe(channel); @@ -63,24 +60,6 @@ async function setTestSessionStore(params: { }); } -async function latestAgentCall(runId?: string): Promise { - for (let elapsed = 0; elapsed <= 2_000; elapsed += 5) { - const calls = vi.mocked(agentCommand).mock.calls as unknown as Array<[unknown]>; - const call = runId - ? calls.map((entry) => entry[0] as AgentCommandCall).find((entry) => entry.runId === runId) - : (calls.at(-1)?.[0] as AgentCommandCall | undefined); - if (call) { - return call; - } - await sleep(5); - } - throw new Error( - runId - ? `expected agentCommand to be called for ${runId}` - : "expected agentCommand to be called", - ); -} - async function runMainAgentDeliveryWithSession(params: { entry: Record; request: Record; @@ -104,8 +83,7 @@ async function runMainAgentDeliveryWithSession(params: { ...params.request, }); expect(res.ok).toBe(true); - const runId = params.request.idempotencyKey; - return await latestAgentCall(typeof runId === "string" ? runId : undefined); + return await waitForAgentCommandCall(String(params.request.idempotencyKey)); } finally { testState.allowFrom = undefined; } @@ -209,7 +187,7 @@ describe("gateway server agent", () => { }); expect(res.ok).toBe(true); - const call = await latestAgentCall("idem-agent-last-stale"); + const call = await waitForAgentCommandCall("idem-agent-last-stale"); expectChannels(call, "whatsapp"); expect(call.to).toBe("+1555"); expect(call.deliveryTargetMode).toBe("implicit"); @@ -233,7 +211,7 @@ describe("gateway server agent", () => { }); expect(res.ok).toBe(true); - const call = await latestAgentCall("idem-agent-subkey"); + const call = await waitForAgentCommandCall("idem-agent-subkey"); expect(call.sessionKey).toBe("agent:main:subagent:abc"); expect(call.sessionId).toBe("sess-sub"); expectChannels(call, "webchat"); @@ -259,7 +237,7 @@ describe("gateway server agent", () => { idempotencyKey: "idem-agent-subdepth", }); expect(res.ok).toBe(true); - await latestAgentCall("idem-agent-subdepth"); + await waitForAgentCommandCall("idem-agent-subdepth"); const raw = await fs.readFile(sharedSessionStorePath, "utf-8"); const persisted = JSON.parse(raw) as Record< @@ -288,7 +266,7 @@ describe("gateway server agent", () => { }); expect(res.ok).toBe(true); - const call = await latestAgentCall("idem-agent-id"); + const call = await waitForAgentCommandCall("idem-agent-id"); expect(call.sessionKey).toBe("agent:ops:main"); expect(call.sessionId).toBe("sess-ops"); }); @@ -435,7 +413,7 @@ describe("gateway server agent", () => { }); expect(res.ok).toBe(true); - const call = await latestAgentCall("idem-agent-attachments"); + const call = await waitForAgentCommandCall("idem-agent-attachments"); expect(call.sessionKey).toBe("agent:main:main"); expectChannels(call, "webchat"); expect(typeof call.message).toBe("string"); @@ -532,7 +510,7 @@ describe("gateway server agent", () => { }); expect(res.ok).toBe(true); - const call = await latestAgentCall(tc.idempotencyKey); + const call = await waitForAgentCommandCall(tc.idempotencyKey); expectChannels(call, tc.lastChannel); expect(call.to).toBe(tc.lastTo); expect(call.deliver).toBe(true); diff --git a/src/gateway/server.agent.gateway-server-agent-b.test.ts b/src/gateway/server.agent.gateway-server-agent-b.test.ts index d4fb26c35e5..6f3222b81a2 100644 --- a/src/gateway/server.agent.gateway-server-agent-b.test.ts +++ b/src/gateway/server.agent.gateway-server-agent-b.test.ts @@ -22,6 +22,7 @@ import { withGatewayServer, writeSessionStore, } from "./test-helpers.js"; +import { readAgentCommandCall } from "./test/agent-command-helpers.js"; installGatewayTestHooks({ scope: "suite" }); @@ -111,22 +112,6 @@ function expectChannels(call: Record, channel: string) { expect(call.messageChannel).toBe(channel); } -async function readAgentCommandCall(params: { runId?: string; fromEnd?: number } = {}) { - if (params.runId) { - await vi.waitFor(() => - expect( - (vi.mocked(agentCommand).mock.calls as unknown as Array<[Record]>).some( - ([call]) => call.runId === params.runId, - ), - ).toBe(true), - ); - const calls = vi.mocked(agentCommand).mock.calls as unknown as Array<[Record]>; - return calls.find(([call]) => call.runId === params.runId)?.[0] ?? {}; - } - const calls = vi.mocked(agentCommand).mock.calls; - return (calls.at(-(params.fromEnd ?? 1))?.[0] ?? {}) as Record; -} - async function expectAgentRoutingCall(params: { channel: string; deliver: boolean; diff --git a/src/gateway/test/agent-command-helpers.ts b/src/gateway/test/agent-command-helpers.ts new file mode 100644 index 00000000000..4587e7b9616 --- /dev/null +++ b/src/gateway/test/agent-command-helpers.ts @@ -0,0 +1,32 @@ +import { vi } from "vitest"; +import { agentCommand } from "../test-helpers.runtime-state.js"; + +export type AgentCommandCall = Record; + +function agentCommandCalls(): Array<[AgentCommandCall]> { + return vi.mocked(agentCommand).mock.calls as unknown as Array<[AgentCommandCall]>; +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export async function waitForAgentCommandCall(runId: string): Promise { + for (let elapsed = 0; elapsed <= 2_000; elapsed += 5) { + const call = agentCommandCalls() + .map((entry) => entry[0]) + .find((entry) => entry.runId === runId); + if (call) { + return call; + } + await sleep(5); + } + throw new Error(`expected agentCommand to be called for ${runId}`); +} + +export async function readAgentCommandCall( + params: { runId?: string; fromEnd?: number } = {}, +): Promise { + if (params.runId) { + return await waitForAgentCommandCall(params.runId); + } + return agentCommandCalls().at(-(params.fromEnd ?? 1))?.[0] ?? {}; +}