From 58bfefbad3e3612dc26a3df57a0f0c498806ada6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 13 May 2026 19:17:37 +0100 Subject: [PATCH] test: add release user journey docker lane --- CHANGELOG.md | 1 + docs/help/testing.md | 4 +- package.json | 1 + .../lib/release-user-journey/assertions.mjs | 245 +++++++++++++++ .../clickclack-fixture.mjs | 287 ++++++++++++++++++ .../e2e/lib/release-user-journey/scenario.sh | 211 +++++++++++++ scripts/e2e/release-user-journey-docker.sh | 31 ++ scripts/lib/docker-e2e-scenarios.mjs | 10 + 8 files changed, 789 insertions(+), 1 deletion(-) create mode 100644 scripts/e2e/lib/release-user-journey/assertions.mjs create mode 100644 scripts/e2e/lib/release-user-journey/clickclack-fixture.mjs create mode 100755 scripts/e2e/lib/release-user-journey/scenario.sh create mode 100755 scripts/e2e/release-user-journey-docker.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index bd51fab26602..bc5d19266460 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -97,6 +97,7 @@ Docs: https://docs.openclaw.ai ### Changes - Docs: add a dedicated ds4 provider page with local DeepSeek V4 Flash config, on-demand startup, context sizing, and live verification steps. +- Release validation: add a package-installed Docker user-journey lane that verifies onboarding, mocked model setup, external plugin install/uninstall, ClickClack outbound/inbound messaging, Gateway restart survival, and doctor. - Maintainers: add a Clawdtributor skill for Discrawl-backed contributor PR triage, live status checks, and compact review formatting. - Telegram: support Mini App `web_app` buttons in generic message presentation payloads, allowing `openclaw message send --presentation` to render Telegram Web App inline buttons for private chats. (#81356) Thanks @jzakirov. - Scripts: add `OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree` so high-capacity local worktrees can use independent heavy-check locks while shared locks remain the default. Fixes #80729. (#80734) Thanks @samzong. diff --git a/docs/help/testing.md b/docs/help/testing.md index 15388d5ea757..ddc823886c75 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -765,7 +765,7 @@ These Docker runners split into two buckets: - `Package Acceptance` is the GitHub-native package gate for "does this installable tarball work as a product?" It resolves one candidate package from `source=npm`, `source=ref`, `source=url`, or `source=artifact`, uploads it as `package-under-test`, then runs the reusable Docker E2E lanes against that exact tarball instead of repacking the selected ref. Profiles are ordered by breadth: `smoke`, `package`, `product`, and `full`. See [Testing updates and plugins](/help/testing-updates-plugins) for the package/update/plugin contract, published-upgrade survivor matrix, release defaults, and failure triage. - Build and release checks run `scripts/check-cli-bootstrap-imports.mjs` after tsdown. The guard walks the static built graph from `dist/entry.js` and `dist/cli/run-main.js` and fails if pre-dispatch startup imports package dependencies such as Commander, prompt UI, undici, or logging before command dispatch; it also keeps the bundled gateway run chunk under budget and rejects static imports of known cold gateway paths. Packaged CLI smoke also covers root help, onboard help, doctor help, status, config schema, and a model-list command. - Package Acceptance legacy compatibility is capped at `2026.4.25` (`2026.4.25-beta.*` included). Through that cutoff, the harness tolerates only shipped-package metadata gaps: omitted private QA inventory entries, missing `gateway install --wrapper`, missing patch files in the tarball-derived git fixture, missing persisted `update.channel`, legacy plugin install-record locations, missing marketplace install-record persistence, and config metadata migration during `plugins update`. For packages after `2026.4.25`, those paths are strict failures. -- Container smoke runners: `test:docker:openwebui`, `test:docker:onboard`, `test:docker:npm-onboard-channel-agent`, `test:docker:skill-install`, `test:docker:update-channel-switch`, `test:docker:upgrade-survivor`, `test:docker:published-upgrade-survivor`, `test:docker:session-runtime-context`, `test:docker:agents-delete-shared-workspace`, `test:docker:gateway-network`, `test:docker:browser-cdp-snapshot`, `test:docker:mcp-channels`, `test:docker:pi-bundle-mcp-tools`, `test:docker:cron-mcp-cleanup`, `test:docker:plugins`, `test:docker:plugin-update`, `test:docker:plugin-lifecycle-matrix`, and `test:docker:config-reload` boot one or more real containers and verify higher-level integration paths. +- Container smoke runners: `test:docker:openwebui`, `test:docker:onboard`, `test:docker:npm-onboard-channel-agent`, `test:docker:release-user-journey`, `test:docker:skill-install`, `test:docker:update-channel-switch`, `test:docker:upgrade-survivor`, `test:docker:published-upgrade-survivor`, `test:docker:session-runtime-context`, `test:docker:agents-delete-shared-workspace`, `test:docker:gateway-network`, `test:docker:browser-cdp-snapshot`, `test:docker:mcp-channels`, `test:docker:pi-bundle-mcp-tools`, `test:docker:cron-mcp-cleanup`, `test:docker:plugins`, `test:docker:plugin-update`, `test:docker:plugin-lifecycle-matrix`, and `test:docker:config-reload` boot one or more real containers and verify higher-level integration paths. The live-model Docker runners also bind-mount only the needed CLI auth homes (or all supported ones when the run is not narrowed), then copy them into the container home before the run so external-CLI OAuth can refresh tokens without mutating the host auth store: @@ -778,6 +778,8 @@ The live-model Docker runners also bind-mount only the needed CLI auth homes (or - Open WebUI live smoke: `pnpm test:docker:openwebui` (script: `scripts/e2e/openwebui-docker.sh`) - Onboarding wizard (TTY, full scaffolding): `pnpm test:docker:onboard` (script: `scripts/e2e/onboard-docker.sh`) - Npm tarball onboarding/channel/agent smoke: `pnpm test:docker:npm-onboard-channel-agent` installs the packed OpenClaw tarball globally in Docker, configures OpenAI via env-ref onboarding plus Telegram by default, runs doctor, and runs one mocked OpenAI agent turn. Reuse a prebuilt tarball with `OPENCLAW_CURRENT_PACKAGE_TGZ=/path/to/openclaw-*.tgz`, skip the host rebuild with `OPENCLAW_NPM_ONBOARD_HOST_BUILD=0`, or switch channel with `OPENCLAW_NPM_ONBOARD_CHANNEL=discord` or `OPENCLAW_NPM_ONBOARD_CHANNEL=slack`. + +- Release user journey smoke: `pnpm test:docker:release-user-journey` installs the packed OpenClaw tarball globally in a clean Docker home, runs onboarding, configures a mocked OpenAI provider, runs an agent turn, installs/uninstalls external plugins, configures ClickClack against a local fixture, verifies outbound/inbound messaging, restarts Gateway, and runs doctor. - Skill install smoke: `pnpm test:docker:skill-install` installs the packed OpenClaw tarball globally in Docker, disables uploaded archive installs in config, resolves the current live ClawHub skill slug from search, installs it with `openclaw skills install`, and verifies the installed skill plus `.clawhub` origin/lock metadata. - Update channel switch smoke: `pnpm test:docker:update-channel-switch` installs the packed OpenClaw tarball globally in Docker, switches from package `stable` to git `dev`, verifies the persisted channel and plugin post-update work, then switches back to package `stable` and checks update status. - Upgrade survivor smoke: `pnpm test:docker:upgrade-survivor` installs the packed OpenClaw tarball over a dirty old-user fixture with agents, channel config, plugin allowlists, stale plugin dependency state, and existing workspace/session files. It runs package update plus non-interactive doctor without live provider or channel keys, then starts a loopback Gateway and checks config/state preservation plus startup/status budgets. diff --git a/package.json b/package.json index 5007b7ba11c6..74041345f99b 100644 --- a/package.json +++ b/package.json @@ -1617,6 +1617,7 @@ "test:docker:npm-onboard-slack-channel-agent": "OPENCLAW_NPM_ONBOARD_CHANNEL=slack bash scripts/e2e/npm-onboard-channel-agent-docker.sh", "test:docker:npm-telegram-live": "bash scripts/e2e/npm-telegram-live-docker.sh", "test:docker:onboard": "bash scripts/e2e/onboard-docker.sh", + "test:docker:release-user-journey": "bash scripts/e2e/release-user-journey-docker.sh", "test:docker:openai-chat-tools": "bash scripts/e2e/openai-chat-tools-docker.sh", "test:docker:openai-image-auth": "bash scripts/e2e/openai-image-auth-docker.sh", "test:docker:openai-web-search-minimal": "bash scripts/e2e/openai-web-search-minimal-docker.sh", diff --git a/scripts/e2e/lib/release-user-journey/assertions.mjs b/scripts/e2e/lib/release-user-journey/assertions.mjs new file mode 100644 index 000000000000..3188f1526a0f --- /dev/null +++ b/scripts/e2e/lib/release-user-journey/assertions.mjs @@ -0,0 +1,245 @@ +import fs from "node:fs"; +import path from "node:path"; + +const command = process.argv[2]; + +function readJson(file) { + return JSON.parse(fs.readFileSync(file, "utf8")); +} + +function configPath() { + return ( + process.env.OPENCLAW_CONFIG_PATH ?? + path.join(process.env.HOME ?? "", ".openclaw", "openclaw.json") + ); +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +function writeConfig(cfg) { + fs.writeFileSync(configPath(), `${JSON.stringify(cfg, null, 2)}\n`); +} + +function assertOnboard() { + const home = process.argv[3]; + const stateDir = path.join(home, ".openclaw"); + const authPath = path.join(stateDir, "agents", "main", "agent", "auth-profiles.json"); + assert(fs.existsSync(configPath()), "onboard did not write openclaw.json"); + const stateRaw = + fs.readFileSync(configPath(), "utf8") + + (fs.existsSync(authPath) ? fs.readFileSync(authPath, "utf8") : ""); + assert( + !stateRaw.includes("sk-openclaw-release-user-journey"), + "onboard persisted raw OpenAI key", + ); +} + +function configureMockModel() { + const mockPort = Number(process.argv[3]); + const cfg = readJson(configPath()); + const modelRef = "openai/gpt-5.5"; + const cost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; + cfg.models = { + ...cfg.models, + mode: "merge", + providers: { + ...cfg.models?.providers, + openai: { + ...cfg.models?.providers?.openai, + baseUrl: `http://127.0.0.1:${mockPort}/v1`, + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + api: "openai-responses", + request: { ...cfg.models?.providers?.openai?.request, allowPrivateNetwork: true }, + models: [ + { + id: "gpt-5.5", + name: "gpt-5.5", + api: "openai-responses", + reasoning: false, + input: ["text", "image"], + cost, + contextWindow: 128000, + contextTokens: 96000, + maxTokens: 4096, + }, + ], + }, + }, + }; + cfg.agents = { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + model: { primary: modelRef }, + models: { + ...cfg.agents?.defaults?.models, + [modelRef]: { params: { transport: "sse", openaiWsWarmup: false } }, + }, + }, + }; + cfg.plugins = { + ...cfg.plugins, + enabled: true, + }; + writeConfig(cfg); +} + +function assertAgentTurn() { + const marker = process.argv[3]; + const outputPath = process.argv[4]; + const requestLogPath = process.argv[5]; + const output = fs.readFileSync(outputPath, "utf8"); + assert(output.includes(marker), `agent output did not contain marker. Output: ${output}`); + const requestLog = fs.existsSync(requestLogPath) ? fs.readFileSync(requestLogPath, "utf8") : ""; + assert( + /\/v1\/(responses|chat\/completions)/u.test(requestLog), + "mock OpenAI server was not used", + ); +} + +function assertFileContains() { + const file = process.argv[3]; + const needle = process.argv[4]; + const raw = fs.readFileSync(file, "utf8"); + assert(raw.includes(needle), `${file} did not contain ${needle}. Output: ${raw}`); +} + +function assertPluginUninstalled() { + const pluginId = process.argv[3]; + const cfg = readJson(configPath()); + const recordsPath = path.join(process.env.HOME ?? "", ".openclaw", "plugins", "installs.json"); + const records = fs.existsSync(recordsPath) ? readJson(recordsPath) : {}; + const installRecords = records.installRecords ?? records.records ?? {}; + assert(!installRecords[pluginId], `install record still present for ${pluginId}`); + assert(!cfg.plugins?.entries?.[pluginId], `plugin config entry still present for ${pluginId}`); + assert(!(cfg.plugins?.allow ?? []).includes(pluginId), `allowlist still contains ${pluginId}`); + assert(!(cfg.plugins?.deny ?? []).includes(pluginId), `denylist still contains ${pluginId}`); +} + +function configureClickClack() { + const baseUrl = process.argv[3]; + const cfg = readJson(configPath()); + cfg.plugins = { + ...cfg.plugins, + enabled: true, + entries: { + ...cfg.plugins?.entries, + clickclack: { + ...cfg.plugins?.entries?.clickclack, + enabled: true, + llm: { + ...cfg.plugins?.entries?.clickclack?.llm, + allowAgentIdOverride: true, + allowModelOverride: true, + allowedModels: ["openai/gpt-5.5"], + }, + }, + }, + }; + cfg.channels = { + ...cfg.channels, + clickclack: { + ...cfg.channels?.clickclack, + enabled: true, + baseUrl, + token: { source: "env", provider: "default", id: "CLICKCLACK_BOT_TOKEN" }, + workspace: "release", + defaultTo: "channel:general", + replyMode: "model", + model: "openai/gpt-5.5", + reconnectMs: 250, + }, + }; + writeConfig(cfg); +} + +function assertChannelStatus() { + const channel = process.argv[3]; + const statusPath = process.argv[4]; + const status = readJson(statusPath); + const configured = Array.isArray(status.configuredChannels) ? status.configuredChannels : []; + const liveStatus = status.channels?.[channel]; + assert( + configured.includes(channel) || liveStatus?.ok === true, + `${channel} missing from channels status: ${JSON.stringify(status)}`, + ); +} + +async function postClickClackInbound() { + const baseUrl = process.argv[3]; + const body = process.argv[4]; + const response = await fetch(`${baseUrl}/fixture/inbound`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ body }), + }); + assert(response.ok, `fixture inbound failed: ${response.status} ${await response.text()}`); +} + +async function waitClickClackSocket() { + const baseUrl = process.argv[3]; + const timeoutSeconds = Number(process.argv[4] ?? 30); + const deadline = Date.now() + timeoutSeconds * 1000; + while (Date.now() < deadline) { + const response = await fetch(`${baseUrl}/fixture/state`).catch(() => undefined); + if (response?.ok) { + const state = await response.json(); + if (Number(state.socketCount ?? 0) > 0) { + return; + } + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`Timed out waiting for ClickClack websocket connection at ${baseUrl}`); +} + +function assertClickClackState() { + const mode = process.argv[3]; + const statePath = process.argv[4]; + const needle = process.argv[5]; + const state = readJson(statePath); + const haystack = JSON.stringify(mode === "outbound" ? state.outboundMessages : state); + assert(haystack.includes(needle), `ClickClack state did not contain ${needle}: ${haystack}`); +} + +async function waitClickClackReply() { + const statePath = process.argv[3]; + const marker = process.argv[4]; + const timeoutSeconds = Number(process.argv[5] ?? 30); + const deadline = Date.now() + timeoutSeconds * 1000; + while (Date.now() < deadline) { + if (fs.existsSync(statePath)) { + const state = readJson(statePath); + if (JSON.stringify(state.threadReplies ?? []).includes(marker)) { + return; + } + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + const state = fs.existsSync(statePath) ? fs.readFileSync(statePath, "utf8") : ""; + throw new Error(`Timed out waiting for ClickClack reply marker ${marker}. State: ${state}`); +} + +const commands = { + "assert-onboard": assertOnboard, + "configure-mock-model": configureMockModel, + "assert-agent-turn": assertAgentTurn, + "assert-file-contains": assertFileContains, + "assert-plugin-uninstalled": assertPluginUninstalled, + "configure-clickclack": configureClickClack, + "assert-channel-status": assertChannelStatus, + "post-clickclack-inbound": postClickClackInbound, + "wait-clickclack-socket": waitClickClackSocket, + "assert-clickclack-state": assertClickClackState, + "wait-clickclack-reply": waitClickClackReply, +}; + +const fn = commands[command]; +if (!fn) { + throw new Error(`unknown release-user-journey assertion command: ${command ?? ""}`); +} +await fn(); diff --git a/scripts/e2e/lib/release-user-journey/clickclack-fixture.mjs b/scripts/e2e/lib/release-user-journey/clickclack-fixture.mjs new file mode 100644 index 000000000000..4b513d73f18f --- /dev/null +++ b/scripts/e2e/lib/release-user-journey/clickclack-fixture.mjs @@ -0,0 +1,287 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import http from "node:http"; + +const port = Number(process.env.CLICKCLACK_FIXTURE_PORT ?? 44181); +const token = process.env.CLICKCLACK_FIXTURE_TOKEN ?? "clickclack-release-token"; +const statePath = process.env.CLICKCLACK_FIXTURE_STATE ?? "/tmp/openclaw-clickclack-fixture.json"; +const workspace = { + id: "ws_release", + name: "Release Workspace", + slug: "release", + created_at: new Date(0).toISOString(), +}; +const channel = { + id: "ch_general", + workspace_id: workspace.id, + name: "general", + kind: "text", + created_at: new Date(0).toISOString(), +}; +const botUser = { + id: "usr_bot", + kind: "bot", + display_name: "OpenClaw Bot", + handle: "openclaw", + avatar_url: "", + created_at: new Date(0).toISOString(), +}; +const humanUser = { + id: "usr_human", + kind: "human", + display_name: "Release User", + handle: "release-user", + avatar_url: "", + created_at: new Date(0).toISOString(), +}; + +let messageSeq = 0; +let eventSeq = 0; +const messages = []; +const threadReplies = []; +const outboundMessages = []; +const sockets = new Set(); + +function persist() { + fs.writeFileSync( + statePath, + `${JSON.stringify( + { + messages, + threadReplies, + outboundMessages, + socketCount: sockets.size, + }, + null, + 2, + )}\n`, + ); +} + +function now() { + return new Date().toISOString(); +} + +function json(res, status, body) { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(body)); +} + +function unauthorized(res) { + json(res, 401, { error: "unauthorized" }); +} + +function checkAuth(req, res) { + if (req.url?.startsWith("/fixture/") || req.url === "/health") { + return true; + } + if (req.headers.authorization !== `Bearer ${token}`) { + unauthorized(res); + return false; + } + return true; +} + +function readBody(req) { + return new Promise((resolve, reject) => { + let body = ""; + req.setEncoding("utf8"); + req.on("data", (chunk) => { + body += chunk; + }); + req.on("end", () => { + try { + resolve(body ? JSON.parse(body) : {}); + } catch { + resolve({}); + } + }); + req.on("error", reject); + }); +} + +function createMessage({ body, author = humanUser, parentMessageId = undefined }) { + messageSeq += 1; + const id = `msg_${messageSeq}`; + const message = { + id, + workspace_id: workspace.id, + channel_id: channel.id, + author_id: author.id, + ...(parentMessageId ? { parent_message_id: parentMessageId } : {}), + thread_root_id: parentMessageId ?? id, + channel_seq: messageSeq, + thread_seq: parentMessageId ? threadReplies.length + 1 : 0, + body, + body_format: "markdown", + created_at: now(), + author, + }; + if (parentMessageId) { + threadReplies.push(message); + } else { + messages.push(message); + } + persist(); + return message; +} + +function eventFor(message) { + eventSeq += 1; + return { + id: `evt_${eventSeq}`, + cursor: String(eventSeq), + type: message.parent_message_id ? "thread.reply_created" : "message.created", + workspace_id: workspace.id, + channel_id: channel.id, + seq: message.channel_seq, + created_at: now(), + payload: { + message_id: message.id, + author_id: message.author_id, + ...(message.parent_message_id ? { root_message_id: message.thread_root_id } : {}), + }, + }; +} + +function frameText(text) { + const payload = Buffer.from(text); + if (payload.length < 126) { + return Buffer.concat([Buffer.from([0x81, payload.length]), payload]); + } + if (payload.length < 65536) { + const header = Buffer.alloc(4); + header[0] = 0x81; + header[1] = 126; + header.writeUInt16BE(payload.length, 2); + return Buffer.concat([header, payload]); + } + const header = Buffer.alloc(10); + header[0] = 0x81; + header[1] = 127; + header.writeBigUInt64BE(BigInt(payload.length), 2); + return Buffer.concat([header, payload]); +} + +function broadcast(event) { + const frame = frameText(JSON.stringify(event)); + for (const socket of sockets) { + socket.write(frame); + } +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url ?? "/", "http://127.0.0.1"); + if (!checkAuth(req, res)) { + return; + } + if (req.method === "GET" && url.pathname === "/health") { + json(res, 200, { ok: true }); + return; + } + if (req.method === "GET" && url.pathname === "/api/me") { + json(res, 200, { user: botUser }); + return; + } + if (req.method === "GET" && url.pathname === "/api/workspaces") { + json(res, 200, { workspaces: [workspace] }); + return; + } + if (req.method === "GET" && url.pathname === `/api/workspaces/${workspace.id}/channels`) { + json(res, 200, { channels: [channel] }); + return; + } + if (req.method === "GET" && url.pathname === `/api/channels/${channel.id}/messages`) { + const afterSeq = Number(url.searchParams.get("after_seq") ?? 0); + json(res, 200, { + messages: messages.filter((message) => (message.channel_seq ?? 0) > afterSeq), + }); + return; + } + if (req.method === "POST" && url.pathname === `/api/channels/${channel.id}/messages`) { + const body = await readBody(req); + const message = createMessage({ body: String(body.body ?? ""), author: botUser }); + outboundMessages.push(message); + persist(); + json(res, 200, { message }); + return; + } + const threadReplyMatch = url.pathname.match(/^\/api\/messages\/([^/]+)\/thread\/replies$/u); + if (req.method === "POST" && threadReplyMatch) { + const body = await readBody(req); + const message = createMessage({ + body: String(body.body ?? ""), + author: botUser, + parentMessageId: decodeURIComponent(threadReplyMatch[1]), + }); + json(res, 200, { message }); + return; + } + const threadMatch = url.pathname.match(/^\/api\/messages\/([^/]+)\/thread$/u); + if (req.method === "GET" && threadMatch) { + const rootId = decodeURIComponent(threadMatch[1]); + json(res, 200, { + root: messages.find((message) => message.id === rootId) ?? null, + replies: threadReplies.filter((message) => message.thread_root_id === rootId), + }); + return; + } + if (req.method === "GET" && url.pathname === "/api/realtime/events") { + json(res, 200, { events: [] }); + return; + } + if (req.method === "POST" && url.pathname === "/fixture/inbound") { + const body = await readBody(req); + const message = createMessage({ body: String(body.body ?? ""), author: humanUser }); + broadcast(eventFor(message)); + json(res, 200, { message }); + return; + } + if (req.method === "GET" && url.pathname === "/fixture/state") { + json(res, 200, { messages, threadReplies, outboundMessages, socketCount: sockets.size }); + return; + } + json(res, 404, { error: `unhandled ${req.method} ${url.pathname}` }); +}); + +server.on("upgrade", (req, socket) => { + const url = new URL(req.url ?? "/", "http://127.0.0.1"); + if (url.pathname !== "/api/realtime/ws" || req.headers.authorization !== `Bearer ${token}`) { + socket.destroy(); + return; + } + const key = req.headers["sec-websocket-key"]; + if (typeof key !== "string") { + socket.destroy(); + return; + } + const accept = crypto + .createHash("sha1") + .update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`) + .digest("base64"); + socket.write( + [ + "HTTP/1.1 101 Switching Protocols", + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Accept: ${accept}`, + "", + "", + ].join("\r\n"), + ); + sockets.add(socket); + persist(); + socket.on("close", () => { + sockets.delete(socket); + persist(); + }); + socket.on("error", () => { + sockets.delete(socket); + persist(); + }); +}); + +persist(); +server.listen(port, "127.0.0.1", () => { + console.log(`clickclack fixture listening on ${port}`); +}); diff --git a/scripts/e2e/lib/release-user-journey/scenario.sh b/scripts/e2e/lib/release-user-journey/scenario.sh new file mode 100755 index 000000000000..8ebb4e322878 --- /dev/null +++ b/scripts/e2e/lib/release-user-journey/scenario.sh @@ -0,0 +1,211 @@ +#!/usr/bin/env bash +set -euo pipefail +trap "" PIPE +export TERM=xterm-256color +export NO_COLOR=1 + +source scripts/lib/openclaw-e2e-instance.sh + +openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}" +openclaw_e2e_install_trash_shim + +export NPM_CONFIG_PREFIX="$HOME/.npm-global" +export PATH="$NPM_CONFIG_PREFIX/bin:$PATH" +export npm_config_loglevel=error +export npm_config_fund=false +export npm_config_audit=false +export OPENAI_API_KEY="sk-openclaw-release-user-journey" +export OPENCLAW_GATEWAY_TOKEN="release-user-journey-token" +export CLICKCLACK_BOT_TOKEN="clickclack-release-token" + +PORT="18789" +MOCK_PORT="44180" +CLICKCLACK_PORT="44181" +SUCCESS_MARKER="OPENCLAW_E2E_OK_RELEASE_USER_JOURNEY" +MOCK_REQUEST_LOG="/tmp/openclaw-release-user-journey-openai.jsonl" +CLICKCLACK_STATE="/tmp/openclaw-release-user-journey-clickclack.json" +export SUCCESS_MARKER MOCK_REQUEST_LOG CLICKCLACK_STATE + +mock_pid="" +clickclack_pid="" +gateway_pid="" + +cleanup() { + openclaw_e2e_terminate_gateways "${gateway_pid:-}" + openclaw_e2e_stop_process "${clickclack_pid:-}" + openclaw_e2e_stop_process "${mock_pid:-}" +} +trap cleanup EXIT + +dump_debug_logs() { + local status="$1" + echo "release user journey failed with exit code $status" >&2 + openclaw_e2e_dump_logs \ + /tmp/openclaw-release-user-journey-install.log \ + /tmp/openclaw-release-user-journey-onboard.log \ + /tmp/openclaw-release-user-journey-openai.log \ + "$MOCK_REQUEST_LOG" \ + /tmp/openclaw-release-user-journey-agent.log \ + /tmp/openclaw-release-user-journey-plugin-a-install.log \ + /tmp/openclaw-release-user-journey-plugin-a-cli.log \ + /tmp/openclaw-release-user-journey-plugin-a-uninstall.log \ + /tmp/openclaw-release-user-journey-plugin-b-install.log \ + /tmp/openclaw-release-user-journey-plugin-b-cli.log \ + /tmp/openclaw-release-user-journey-clickclack.log \ + /tmp/openclaw-release-user-journey-clickclack-server.log \ + /tmp/openclaw-release-user-journey-clickclack-outbound.json \ + /tmp/openclaw-release-user-journey-clickclack-inbound.json \ + /tmp/openclaw-release-user-journey-gateway-1.log \ + /tmp/openclaw-release-user-journey-gateway-2.log \ + /tmp/openclaw-release-user-journey-status.json \ + /tmp/openclaw-release-user-journey-doctor.log \ + "$CLICKCLACK_STATE" +} +trap 'status=$?; dump_debug_logs "$status"; exit "$status"' ERR + +start_gateway() { + local log_path="$1" + gateway_pid="$(openclaw_e2e_start_gateway "$entry" "$PORT" "$log_path")" + openclaw_e2e_wait_gateway_ready "$gateway_pid" "$log_path" +} + +stop_gateway() { + openclaw_e2e_terminate_gateways "${gateway_pid:-}" + gateway_pid="" +} + +write_journey_plugin() { + local dir="$1" + local id="$2" + local version="$3" + local method="$4" + local name="$5" + local cli_root="$6" + local cli_output="$7" + + mkdir -p "$dir" + node - "$dir" "$id" "$version" "$method" "$name" "$cli_root" "$cli_output" <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); + +const [dir, id, version, method, name, cliRoot, cliOutput] = process.argv.slice(2); +fs.writeFileSync( + path.join(dir, "package.json"), + `${JSON.stringify( + { + name: `@openclaw/${id}`, + version, + openclaw: { extensions: ["./index.js"] }, + }, + null, + 2, + )}\n`, +); +fs.writeFileSync( + 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 })); api.registerCli(({ program }) => { const root = program.command(${JSON.stringify(cliRoot)}).description(${JSON.stringify(`${name} fixture command`)}); root.command("ping").description("Print fixture ping output").action(() => { console.log(${JSON.stringify(cliOutput)}); }); }, { descriptors: [{ name: ${JSON.stringify(cliRoot)}, description: ${JSON.stringify(`${name} fixture command`)}, hasSubcommands: true }] }); }, };\n`, +); +fs.writeFileSync( + path.join(dir, "openclaw.plugin.json"), + `${JSON.stringify({ id, configSchema: { type: "object", properties: {} } }, null, 2)}\n`, +); +NODE +} + +openclaw_e2e_install_package /tmp/openclaw-release-user-journey-install.log +command -v openclaw >/dev/null +package_root="$(openclaw_e2e_package_root)" +entry="$(openclaw_e2e_package_entrypoint "$package_root")" + +mock_pid="$(openclaw_e2e_start_mock_openai "$MOCK_PORT" /tmp/openclaw-release-user-journey-openai.log)" +openclaw_e2e_wait_mock_openai "$MOCK_PORT" + +CLICKCLACK_FIXTURE_PORT="$CLICKCLACK_PORT" \ +CLICKCLACK_FIXTURE_TOKEN="$CLICKCLACK_BOT_TOKEN" \ +CLICKCLACK_FIXTURE_STATE="$CLICKCLACK_STATE" \ + node scripts/e2e/lib/release-user-journey/clickclack-fixture.mjs >/tmp/openclaw-release-user-journey-clickclack-server.log 2>&1 & +clickclack_pid="$!" +for _ in $(seq 1 100); do + if openclaw_e2e_probe_http_status "http://127.0.0.1:$CLICKCLACK_PORT/health" 200 >/dev/null 2>&1; then + break + fi + sleep 0.1 +done +openclaw_e2e_probe_http_status "http://127.0.0.1:$CLICKCLACK_PORT/health" 200 + +echo "Running non-interactive onboarding..." +openclaw onboard \ + --non-interactive \ + --accept-risk \ + --flow quickstart \ + --mode local \ + --auth-choice skip \ + --gateway-port "$PORT" \ + --gateway-bind loopback \ + --skip-daemon \ + --skip-ui \ + --skip-channels \ + --skip-skills \ + --skip-health >/tmp/openclaw-release-user-journey-onboard.log 2>&1 +node scripts/e2e/lib/release-user-journey/assertions.mjs assert-onboard "$HOME" +node scripts/e2e/lib/release-user-journey/assertions.mjs configure-mock-model "$MOCK_PORT" + +echo "Running package-installed agent turn..." +openclaw agent --local \ + --agent main \ + --session-id release-user-journey-agent \ + --message "Return marker $SUCCESS_MARKER" \ + --thinking off \ + --json >/tmp/openclaw-release-user-journey-agent.log 2>&1 +node scripts/e2e/lib/release-user-journey/assertions.mjs assert-agent-turn "$SUCCESS_MARKER" /tmp/openclaw-release-user-journey-agent.log "$MOCK_REQUEST_LOG" + +echo "Installing first external plugin..." +plugin_a_dir="$(mktemp -d "/tmp/openclaw-release-journey-plugin-a.XXXXXX")" +write_journey_plugin "$plugin_a_dir" journey-plugin-a 0.0.1 journey.a "Journey Plugin A" journey-a "journey-plugin-a:pong" +openclaw plugins install "$plugin_a_dir" >/tmp/openclaw-release-user-journey-plugin-a-install.log 2>&1 +openclaw journey-a ping >/tmp/openclaw-release-user-journey-plugin-a-cli.log 2>&1 +node scripts/e2e/lib/release-user-journey/assertions.mjs assert-file-contains /tmp/openclaw-release-user-journey-plugin-a-cli.log "journey-plugin-a:pong" + +echo "Uninstalling first external plugin..." +openclaw plugins uninstall journey-plugin-a --force >/tmp/openclaw-release-user-journey-plugin-a-uninstall.log 2>&1 +node scripts/e2e/lib/release-user-journey/assertions.mjs assert-plugin-uninstalled journey-plugin-a + +echo "Installing replacement external plugin..." +plugin_b_dir="$(mktemp -d "/tmp/openclaw-release-journey-plugin-b.XXXXXX")" +write_journey_plugin "$plugin_b_dir" journey-plugin-b 0.0.1 journey.b "Journey Plugin B" journey-b "journey-plugin-b:pong" +openclaw plugins install "$plugin_b_dir" >/tmp/openclaw-release-user-journey-plugin-b-install.log 2>&1 +openclaw journey-b ping >/tmp/openclaw-release-user-journey-plugin-b-cli.log 2>&1 +node scripts/e2e/lib/release-user-journey/assertions.mjs assert-file-contains /tmp/openclaw-release-user-journey-plugin-b-cli.log "journey-plugin-b:pong" + +echo "Configuring ClickClack..." +node scripts/e2e/lib/release-user-journey/assertions.mjs configure-clickclack "http://127.0.0.1:$CLICKCLACK_PORT" +openclaw channels status --json >/tmp/openclaw-release-user-journey-status.json 2>/tmp/openclaw-release-user-journey-status.err +node scripts/e2e/lib/release-user-journey/assertions.mjs assert-channel-status clickclack /tmp/openclaw-release-user-journey-status.json + +echo "Sending ClickClack outbound message..." +openclaw message send \ + --channel clickclack \ + --target channel:general \ + --message "release journey outbound" \ + --json >/tmp/openclaw-release-user-journey-clickclack-outbound.json 2>/tmp/openclaw-release-user-journey-clickclack-outbound.err +node scripts/e2e/lib/release-user-journey/assertions.mjs assert-clickclack-state outbound "$CLICKCLACK_STATE" "release journey outbound" + +echo "Starting Gateway for ClickClack inbound..." +start_gateway /tmp/openclaw-release-user-journey-gateway-1.log +node scripts/e2e/lib/release-user-journey/assertions.mjs wait-clickclack-socket "http://127.0.0.1:$CLICKCLACK_PORT" 45 +node scripts/e2e/lib/release-user-journey/assertions.mjs post-clickclack-inbound "http://127.0.0.1:$CLICKCLACK_PORT" "Return marker $SUCCESS_MARKER" +node scripts/e2e/lib/release-user-journey/assertions.mjs wait-clickclack-reply "$CLICKCLACK_STATE" "$SUCCESS_MARKER" 45 + +echo "Restarting Gateway and checking state survival..." +stop_gateway +start_gateway /tmp/openclaw-release-user-journey-gateway-2.log +openclaw plugins inspect journey-plugin-b --runtime --json >/tmp/openclaw-release-user-journey-plugin-b-after-restart.json 2>&1 +openclaw channels status --json >/tmp/openclaw-release-user-journey-status-after-restart.json 2>/tmp/openclaw-release-user-journey-status-after-restart.err +node scripts/e2e/lib/release-user-journey/assertions.mjs assert-channel-status clickclack /tmp/openclaw-release-user-journey-status-after-restart.json +node scripts/e2e/lib/release-user-journey/assertions.mjs assert-file-contains /tmp/openclaw-release-user-journey-plugin-b-after-restart.json "journey-plugin-b" +stop_gateway + +echo "Running doctor at end of release journey..." +openclaw doctor --repair --non-interactive >/tmp/openclaw-release-user-journey-doctor.log 2>&1 + +echo "Release user journey scenario passed." diff --git a/scripts/e2e/release-user-journey-docker.sh b/scripts/e2e/release-user-journey-docker.sh new file mode 100755 index 000000000000..c43d15ce7855 --- /dev/null +++ b/scripts/e2e/release-user-journey-docker.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Package-installed release journey smoke with provider, plugins, ClickClack, +# agent turn, and restart survival. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh" +source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh" + +IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-release-user-journey-e2e" OPENCLAW_RELEASE_USER_JOURNEY_E2E_IMAGE)" +SKIP_BUILD="${OPENCLAW_RELEASE_USER_JOURNEY_E2E_SKIP_BUILD:-0}" +PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz release-user-journey "${OPENCLAW_CURRENT_PACKAGE_TGZ:-}")" +docker_e2e_package_mount_args "$PACKAGE_TGZ" + +docker_e2e_build_or_reuse "$IMAGE_NAME" release-user-journey "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "bare" "$SKIP_BUILD" +OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 release-user-journey empty)" + +run_log="$(docker_e2e_run_log release-user-journey)" +echo "Running release user journey Docker E2E..." +if ! docker_e2e_run_with_harness \ + -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ + -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \ + "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ + -i "$IMAGE_NAME" bash scripts/e2e/lib/release-user-journey/scenario.sh >"$run_log" 2>&1; then + docker_e2e_print_log "$run_log" + rm -f "$run_log" + exit 1 +fi + +rm -f "$run_log" +echo "Release user journey Docker E2E passed." diff --git a/scripts/lib/docker-e2e-scenarios.mjs b/scripts/lib/docker-e2e-scenarios.mjs index 0e3067ef2bcf..b31cd1deef29 100644 --- a/scripts/lib/docker-e2e-scenarios.mjs +++ b/scripts/lib/docker-e2e-scenarios.mjs @@ -236,6 +236,16 @@ export const mainLanes = [ "OPENCLAW_NPM_ONBOARD_CHANNEL=slack OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:npm-onboard-channel-agent", { resources: ["service"], stateScenario: "empty", weight: 3 }, ), + npmLane( + "release-user-journey", + "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:release-user-journey", + { + resources: ["npm", "service"], + stateScenario: "empty", + timeoutMs: 20 * 60 * 1000, + weight: 4, + }, + ), serviceLane("gateway-network", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:gateway-network"), serviceLane( "agents-delete-shared-workspace",