From bda0e686444b8d8484a8b578994145bdc1be7a74 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:45:23 +0800 Subject: [PATCH] test(skills): add bot event routing product flow --- .../e2e/bot-event-routing-product-flow.mjs | 277 ++++++++++++++++++ skills/skills.index.json | 26 ++ .../cases/bot-event-routing-product-flow.yaml | 55 ++++ 3 files changed, 358 insertions(+) create mode 100644 skills/scripts/e2e/bot-event-routing-product-flow.mjs create mode 100644 skills/skills/langbot-testing/cases/bot-event-routing-product-flow.yaml diff --git a/skills/scripts/e2e/bot-event-routing-product-flow.mjs b/skills/scripts/e2e/bot-event-routing-product-flow.mjs new file mode 100644 index 000000000..b8fd59481 --- /dev/null +++ b/skills/scripts/e2e/bot-event-routing-product-flow.mjs @@ -0,0 +1,277 @@ +#!/usr/bin/env node + +import { + apiJson, + bodyText, + createBrowser, + ensureAuthenticatedBrowser, + ensureEvidence, + evidencePaths, + exitCode, + loadEnvFiles, + localIsoWithOffset, + safeScreenshot, + scanBrowserDiagnostics, + writeResult, +} from "./lib/langbot-e2e.mjs"; + +const caseId = "bot-event-routing-product-flow"; +await loadEnvFiles(); +const paths = evidencePaths(caseId); +await ensureEvidence(paths); + +const startedAt = new Date(); +const frontendUrl = process.env.LANGBOT_FRONTEND_URL || ""; +const backendUrl = process.env.LANGBOT_BACKEND_URL || ""; +const fixtureName = `EBA Product Flow ${paths.runId}`; + +let browser; +let token = ""; +let botId = ""; +const result = { + source: "automation", + case_id: caseId, + run_id: paths.runId, + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: "", + finished_at_local: "", + status: "fail", + reason: "", + url: "", + bot_id: "", + visible_signals: [], + api: {}, + diagnostics: null, + cleanup: null, + evidence: { + console_log: paths.consoleLog, + network_log: paths.networkLog, + screenshot: paths.screenshot, + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }, + evidence_collected: ["ui", "screenshot", "console", "api_diagnostic"], +}; + +try { + if (!frontendUrl) throw new Error("LANGBOT_FRONTEND_URL is not configured."); + if (!backendUrl) throw new Error("LANGBOT_BACKEND_URL is not configured."); + + browser = await createBrowser(paths); + const { page } = browser; + await page.goto(frontendUrl, { waitUntil: "domcontentloaded" }); + const auth = await ensureAuthenticatedBrowser(page, { + frontendUrl, + backendUrl, + }); + if (auth.status !== "pass") { + result.status = auth.status; + throw new Error(auth.reason); + } + + token = await page.evaluate(() => localStorage.getItem("token") || ""); + if (!token) { + result.status = "blocked"; + throw new Error("Authenticated browser has no reusable local token."); + } + + await page.goto(`${frontendUrl.replace(/\/$/, "")}/home/bots?id=new`, { + waitUntil: "domcontentloaded", + }); + await page + .getByText(/Create Bot|创建机器人|ボットを作成/) + .first() + .waitFor({ timeout: 15_000 }); + await page.getByRole("combobox").first().click(); + await page + .getByRole("option", { name: /HTTP Bot|HTTP 通用接入|HTTP ボット/ }) + .click(); + await page + .getByText(/Event Routing|事件路由|イベントルーティング/) + .first() + .waitFor(); + await page + .getByRole("button", { name: /Add Route|添加路由|ルートを追加/ }) + .waitFor(); + result.visible_signals.push("create-mode-routing"); + + const create = await apiJson(backendUrl, "/api/v1/platform/bots", { + method: "POST", + token, + body: { + name: fixtureName, + description: "Temporary EBA product-flow fixture", + adapter: "http_bot", + adapter_config: { + inbound_secret: "eba-product-flow-local-only", + callback_url: "http://127.0.0.1:9/langbot-e2e-unused", + outbound_secret: "", + default_session_type: "person", + signature_required: false, + callback_timeout: 1, + callback_max_retries: 0, + }, + enable: true, + event_bindings: [ + { + id: "qa-message-discard", + event_pattern: "message.received", + target_type: "discard", + target_uuid: "", + filters: [], + priority: 0, + enabled: true, + description: "Discard the deterministic QA event", + order: 0, + }, + ], + }, + }); + botId = create.json.data?.uuid || ""; + result.api.create_bot = { + http_status: create.status, + code: create.json.code ?? null, + }; + result.bot_id = botId; + if (create.status >= 400 || create.json.code !== 0 || !botId) { + throw new Error(create.json.msg || "Failed to create the temporary Bot."); + } + + const botUrl = `${frontendUrl.replace(/\/$/, "")}/home/bots?id=${encodeURIComponent(botId)}`; + await page.goto(botUrl, { waitUntil: "domcontentloaded" }); + await page + .waitForLoadState("networkidle", { timeout: 10_000 }) + .catch(() => {}); + result.url = page.url(); + + await page + .getByText(/Event Routing|事件路由|イベントルーティング/) + .first() + .waitFor({ timeout: 15_000 }); + await page + .getByText( + /Events this adapter can receive|此适配器可接收的事件|このアダプターが受信できるイベント/, + ) + .waitFor(); + await page + .getByText(/Message received|收到消息|メッセージを受信/) + .first() + .waitFor(); + result.visible_signals.push( + "event-routing", + "adapter-capabilities", + "friendly-event-name", + ); + + await page + .getByRole("button", { name: /Test route|测试路由|ルートをテスト/ }) + .click(); + await page.getByRole("dialog").waitFor(); + await page + .getByRole("button", { name: /Preview route|预览路由|ルートをプレビュー/ }) + .click(); + await page + .getByText(/Route matched|已命中路由|ルートに一致しました/) + .waitFor({ timeout: 15_000 }); + await page + .getByText(/Discard|丢弃|破棄/) + .first() + .waitFor(); + result.visible_signals.push("dry-run-matched", "discard-target"); + + await page + .getByRole("button", { + name: /Run saved route|运行已保存路由|保存済みルートを実行/, + }) + .click(); + await page + .getByText( + /saved route ran successfully|已保存路由运行成功|保存済みルートを実行しました/, + ) + .waitFor({ timeout: 20_000 }); + result.visible_signals.push("test-event-dispatched"); + + await page + .getByRole("button", { name: /Close|关闭|閉じる/ }) + .first() + .click(); + await page.getByRole("dialog").waitFor({ state: "hidden" }); + await page + .getByText(/Discarded|已丢弃|破棄済み/) + .first() + .waitFor({ timeout: 10_000 }); + result.visible_signals.push("route-status-discarded"); + + const text = await bodyText(page); + if (/\bEBA event\b/.test(text)) { + throw new Error( + "The primary route surface exposes an internal EBA runtime message.", + ); + } + + const status = await apiJson( + backendUrl, + `/api/v1/platform/bots/${encodeURIComponent(botId)}/event-routes/status`, + { token }, + ); + const latest = status.json.data?.routes?.find( + (route) => route.binding_id === "qa-message-discard", + ); + result.api.route_status = { + http_status: status.status, + code: status.json.code ?? null, + last_status: latest?.last_status || null, + }; + if ( + status.status >= 400 || + status.json.code !== 0 || + latest?.last_status !== "discarded" + ) { + throw new Error( + "Route status API did not confirm the discarded synthetic event.", + ); + } + + await safeScreenshot(page, paths.screenshot); + result.diagnostics = await scanBrowserDiagnostics(paths); + if (result.diagnostics.status !== "pass") { + throw new Error(result.diagnostics.reason); + } + result.status = "pass"; + result.reason = + "Bot event routing, dry-run, synthetic dispatch, and visible route status passed in the WebUI."; +} catch (error) { + if (!["blocked", "env_issue"].includes(result.status)) result.status = "fail"; + result.reason = result.reason || error.message; + if (browser?.page) await safeScreenshot(browser.page, paths.screenshot); +} finally { + if (botId && token && backendUrl) { + const cleanup = await apiJson( + backendUrl, + `/api/v1/platform/bots/${encodeURIComponent(botId)}`, + { method: "DELETE", token }, + ).catch((error) => ({ + status: 0, + json: { code: null, msg: error.message }, + })); + result.cleanup = { + http_status: cleanup.status, + code: cleanup.json.code ?? null, + deleted: cleanup.status < 400 && cleanup.json.code === 0, + }; + if (!result.cleanup.deleted && result.status === "pass") { + result.status = "fail"; + result.reason = + cleanup.json.msg || "The temporary Bot could not be deleted."; + } + } + if (browser) await browser.close().catch(() => {}); + const finishedAt = new Date(); + result.finished_at = finishedAt.toISOString(); + result.finished_at_local = localIsoWithOffset(finishedAt); + await writeResult(paths, result); + console.log(JSON.stringify(result, null, 2)); +} + +process.exit(exitCode(result.status)); diff --git a/skills/skills.index.json b/skills/skills.index.json index 72a76cb72..bfac05e33 100644 --- a/skills/skills.index.json +++ b/skills/skills.index.json @@ -151,6 +151,7 @@ "agent-runner-qa-debug-chat", "agent-runner-release-preflight", "agent-runner-runtime-chaos", + "bot-event-routing-product-flow", "dify-agent-debug-chat", "langbot-fake-provider-debug-chat-cross-pipeline-isolation", "langbot-fake-provider-debug-chat-fault-recovery", @@ -482,6 +483,31 @@ "filesystem" ] }, + { + "id": "bot-event-routing-product-flow", + "title": "Bot event routing can be configured and tested from the WebUI", + "mode": "agent-browser", + "area": "bot", + "type": "feature", + "priority": "p0", + "risk": "medium", + "ci_eligible": false, + "tags": [ + "bot", + "eba", + "event-routing", + "productization" + ], + "automation": "scripts/e2e/bot-event-routing-product-flow.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "ui", + "screenshot", + "console", + "api_diagnostic" + ] + }, { "id": "dify-agent-debug-chat", "title": "Dify AgentRunner returns a response through Pipeline Debug Chat", diff --git a/skills/skills/langbot-testing/cases/bot-event-routing-product-flow.yaml b/skills/skills/langbot-testing/cases/bot-event-routing-product-flow.yaml new file mode 100644 index 000000000..6a72e3336 --- /dev/null +++ b/skills/skills/langbot-testing/cases/bot-event-routing-product-flow.yaml @@ -0,0 +1,55 @@ +id: bot-event-routing-product-flow +title: "Bot event routing can be configured and tested from the WebUI" +mode: agent-browser +area: bot +type: feature +priority: p0 +risk: medium +ci_eligible: false +tags: + - bot + - eba + - event-routing + - productization +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE +automation: scripts/e2e/bot-event-routing-product-flow.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE +preconditions: + - "The target is a local test instance where a temporary HTTP Bot may be created and deleted." +steps: + - "Open Create Bot, choose HTTP Bot, and confirm event routing can be configured before the first save." + - "Create a temporary HTTP Bot with a saved message.received route to the discard processor." + - "Open the Bot configuration in the WebUI." + - "Confirm the adapter capability summary, friendly event name, target, and route status are visible." + - "Open Test event route and run a dry-run against the current form." + - "Run the saved runtime route with a synthetic event." + - "Close the dialog and confirm the route card shows the latest discarded status." +checks: + - "UI: A user can choose a channel and add event routes during initial Bot creation." + - "UI: Event routing uses user-facing labels and does not require the raw event name in the primary route card." + - "UI: Dry-run visibly reports that the route matched the discard processor." + - "UI: Saved-route execution visibly succeeds, explains its side-effect boundary, and updates route status to discarded." + - "Console: No unexpected frontend errors appear during the flow." + - "Network: Bot, dry-run, route-status, and test-event requests return without 5xx responses." + - "Cleanup: The temporary Bot is deleted after evidence is collected." +evidence_required: + - ui + - screenshot + - console + - api_diagnostic +diagnostics: + - "The fixture deliberately uses the discard processor so the product-flow test cannot invoke a model, tool, or external callback." + - "A passing API call without the visible matched and discarded UI states is not a pass." +troubleshooting: + - backend-not-listening + - proxy-env-mismatch