mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-17 01:46:07 +00:00
feat(wizard): add scenario templates and EBA release gate
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Drive one deterministic event through an enabled OneBot reverse WebSocket."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
|
||||
import websockets
|
||||
|
||||
|
||||
async def run(port: int) -> None:
|
||||
uri = f'ws://127.0.0.1:{port}/ws'
|
||||
headers = {
|
||||
'X-Self-ID': '900001',
|
||||
'X-Client-Role': 'Universal',
|
||||
'User-Agent': 'LangBot-E2E-OneBot/1.0',
|
||||
}
|
||||
connect_deadline = time.monotonic() + 15
|
||||
connection = None
|
||||
while time.monotonic() < connect_deadline:
|
||||
try:
|
||||
connection = await websockets.connect(uri, additional_headers=headers)
|
||||
break
|
||||
except OSError:
|
||||
await asyncio.sleep(0.25)
|
||||
if connection is None:
|
||||
raise RuntimeError(f'OneBot reverse WebSocket did not open on port {port}')
|
||||
|
||||
actions: list[str] = []
|
||||
event_deadline = time.monotonic() + 20
|
||||
async with connection as ws:
|
||||
await ws.send(
|
||||
json.dumps(
|
||||
{
|
||||
'post_type': 'notice',
|
||||
'notice_type': 'group_increase',
|
||||
'sub_type': 'invite',
|
||||
'time': int(time.time()),
|
||||
'self_id': 900001,
|
||||
'group_id': 20001,
|
||||
'operator_id': 10002,
|
||||
'user_id': 10003,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
delivered = False
|
||||
while time.monotonic() < event_deadline and not delivered:
|
||||
try:
|
||||
raw = await asyncio.wait_for(ws.recv(), timeout=2)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
request = json.loads(raw)
|
||||
action = request.get('action', '')
|
||||
actions.append(action)
|
||||
if action == 'get_group_info':
|
||||
data = {'group_id': 20001, 'group_name': 'LangBot Runtime QA'}
|
||||
elif action == 'get_group_member_info':
|
||||
data = {
|
||||
'group_id': 20001,
|
||||
'user_id': 10003,
|
||||
'nickname': 'Runtime QA Member',
|
||||
'card': 'Runtime QA Member',
|
||||
}
|
||||
elif action == 'send_group_msg':
|
||||
data = {'message_id': 70001}
|
||||
delivered = True
|
||||
else:
|
||||
data = {}
|
||||
await ws.send(
|
||||
json.dumps(
|
||||
{
|
||||
'status': 'ok',
|
||||
'retcode': 0,
|
||||
'data': data,
|
||||
'echo': request.get('echo'),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
if not delivered:
|
||||
raise RuntimeError(f'Agent output was not delivered; actions={actions}')
|
||||
print(json.dumps({'connected': True, 'actions': actions, 'delivered': True}))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--port', type=int, required=True)
|
||||
args = parser.parse_args()
|
||||
asyncio.run(run(args.port))
|
||||
@@ -0,0 +1,348 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { createServer } from "node:net";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
createBrowser,
|
||||
ensureAuthenticatedBrowser,
|
||||
ensureEvidence,
|
||||
evidencePaths,
|
||||
exitCode,
|
||||
loadEnvFiles,
|
||||
localIsoWithOffset,
|
||||
pathExists,
|
||||
resolveLangBotRepo,
|
||||
safeScreenshot,
|
||||
scanBrowserDiagnostics,
|
||||
writeResult,
|
||||
} from "./lib/langbot-e2e.mjs";
|
||||
|
||||
const caseId = "wizard-onebot-agent-runtime";
|
||||
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 || "";
|
||||
let browser;
|
||||
let botId = "";
|
||||
let agentId = "";
|
||||
let token = "";
|
||||
|
||||
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: "",
|
||||
agent_id: "",
|
||||
visible_signals: [],
|
||||
api: {},
|
||||
runtime: {},
|
||||
cleanup: {},
|
||||
diagnostics: null,
|
||||
evidence: {
|
||||
screenshot: paths.screenshot,
|
||||
console_log: paths.consoleLog,
|
||||
network_log: paths.networkLog,
|
||||
automation_result_json: paths.automationResultJson,
|
||||
result_json: paths.resultJson,
|
||||
},
|
||||
evidence_collected: ["ui", "screenshot", "console", "api_diagnostic"],
|
||||
};
|
||||
|
||||
async function getFreePort() {
|
||||
const server = createServer();
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
const port = typeof address === "object" && address ? address.port : 0;
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
if (!port) throw new Error("Could not allocate a temporary OneBot port.");
|
||||
return port;
|
||||
}
|
||||
|
||||
async function api(page, path, options = {}) {
|
||||
return await page.evaluate(
|
||||
async ({ baseUrl, path, options, token }) => {
|
||||
const response = await fetch(`${baseUrl.replace(/\/$/, "")}${path}`, {
|
||||
method: options.method || "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body:
|
||||
options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||
});
|
||||
return {
|
||||
status: response.status,
|
||||
json: await response.json().catch(() => ({})),
|
||||
};
|
||||
},
|
||||
{ baseUrl: backendUrl, path, options, token },
|
||||
);
|
||||
}
|
||||
|
||||
async function runProbe(port) {
|
||||
const repo = await resolveLangBotRepo();
|
||||
const python = join(repo, ".venv", "bin", "python");
|
||||
const script = join(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
"onebot-runtime-probe.py",
|
||||
);
|
||||
if (!(await pathExists(python))) {
|
||||
throw new Error(`LangBot virtualenv Python is missing: ${python}`);
|
||||
}
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn(python, [script, "--port", String(port)], {
|
||||
cwd: repo,
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => (stdout += chunk));
|
||||
child.stderr.on("data", (chunk) => (stderr += chunk));
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve(JSON.parse(stdout.trim().split("\n").at(-1)));
|
||||
} else {
|
||||
reject(
|
||||
new Error(
|
||||
stderr.trim() || stdout.trim() || `OneBot probe exited ${code}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
if (!frontendUrl) throw new Error("LANGBOT_FRONTEND_URL is not configured.");
|
||||
if (!backendUrl) throw new Error("LANGBOT_BACKEND_URL is not configured.");
|
||||
const port = await getFreePort();
|
||||
|
||||
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 token is unavailable.");
|
||||
}
|
||||
|
||||
// Keep this case isolated from the instance's real onboarding state.
|
||||
await page.route("**/api/v1/system/wizard/progress", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ code: 0, msg: "ok", data: {} }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`${frontendUrl.replace(/\/$/, "")}/wizard`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await page
|
||||
.getByRole("button", {
|
||||
name: /Welcome new members|欢迎新成员|新しいメンバーを歓迎/,
|
||||
})
|
||||
.click();
|
||||
await page.getByText("OneBot v11", { exact: true }).last().click();
|
||||
|
||||
const botResponse = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "POST" &&
|
||||
/\/api\/v1\/platform\/bots$/.test(new URL(response.url()).pathname),
|
||||
);
|
||||
await page
|
||||
.getByRole("button", {
|
||||
name: /Confirm, Create Bot|确定,创建机器人|確定、ボットを作成/,
|
||||
})
|
||||
.click();
|
||||
const botPayload = await (await botResponse).json();
|
||||
botId = botPayload.data?.uuid || "";
|
||||
if (!botId) throw new Error("Wizard did not create a Bot.");
|
||||
result.bot_id = botId;
|
||||
result.api.create_bot = { code: botPayload.code ?? null };
|
||||
|
||||
await page
|
||||
.getByText(/Configure Your Bot|配置机器人|ボットを設定/)
|
||||
.first()
|
||||
.waitFor({ timeout: 15_000 });
|
||||
const portInput = page.getByRole("spinbutton").first();
|
||||
await portInput.fill(String(port));
|
||||
await portInput.press("Tab");
|
||||
await page
|
||||
.getByRole("button", {
|
||||
name: /Save & Enable Bot|保存并启用|保存して有効化/,
|
||||
})
|
||||
.click();
|
||||
await page
|
||||
.getByText(
|
||||
/Bot configuration saved and enabled|机器人配置已保存并启用|ボット設定が保存され、有効になりました/,
|
||||
)
|
||||
.waitFor({ timeout: 15_000 });
|
||||
result.visible_signals.push("bot-created", "adapter-enabled");
|
||||
|
||||
await page.getByRole("button", { name: /Next|下一步|次へ/ }).click();
|
||||
await page
|
||||
.getByText(/Local Agent|本地 Agent/)
|
||||
.first()
|
||||
.click();
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
Array.from(document.querySelectorAll("textarea")).some((element) => {
|
||||
const value = element.value || "";
|
||||
return [
|
||||
"Welcome new group members",
|
||||
"欢迎新群成员",
|
||||
"新しいグループメンバー",
|
||||
].some((expected) => value.includes(expected));
|
||||
}),
|
||||
null,
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
result.visible_signals.push("scenario-prompt-visible");
|
||||
|
||||
await page
|
||||
.getByText(/QA Deterministic Runner|QA 确定性 Runner/)
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const agentResponse = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "POST" &&
|
||||
/\/api\/v1\/agents$/.test(new URL(response.url()).pathname),
|
||||
);
|
||||
await page
|
||||
.getByRole("button", {
|
||||
name: /Create & Deploy|创建并部署|作成&デプロイ/,
|
||||
})
|
||||
.click();
|
||||
const agentPayload = await (await agentResponse).json();
|
||||
agentId = agentPayload.data?.uuid || "";
|
||||
if (!agentId) throw new Error("Wizard did not create an Agent.");
|
||||
result.agent_id = agentId;
|
||||
result.api.create_agent = { code: agentPayload.code ?? null };
|
||||
await page.getByText(/All Set!|一切就绪!/).waitFor({ timeout: 15_000 });
|
||||
result.visible_signals.push("agent-created", "wizard-complete");
|
||||
|
||||
const savedBot = await api(page, `/api/v1/platform/bots/${botId}`);
|
||||
const route = savedBot.json.data?.bot?.event_bindings?.[0];
|
||||
result.api.saved_route = {
|
||||
http_status: savedBot.status,
|
||||
event_pattern: route?.event_pattern || null,
|
||||
target_type: route?.target_type || null,
|
||||
target_matches: route?.target_uuid === agentId,
|
||||
};
|
||||
if (
|
||||
route?.event_pattern !== "group.member_joined" ||
|
||||
route?.target_type !== "agent" ||
|
||||
route?.target_uuid !== agentId
|
||||
) {
|
||||
throw new Error("Wizard did not persist the expected Agent route.");
|
||||
}
|
||||
result.visible_signals.push("route-persisted");
|
||||
|
||||
result.runtime.onebot = await runProbe(port);
|
||||
let routeStatus;
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
routeStatus = await api(
|
||||
page,
|
||||
`/api/v1/platform/bots/${botId}/event-routes/status`,
|
||||
);
|
||||
if (routeStatus.json.data?.routes?.[0]?.last_status === "delivered") break;
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
const latest = routeStatus?.json?.data?.routes?.[0];
|
||||
result.runtime.route_status = latest || null;
|
||||
if (latest?.last_status !== "delivered") {
|
||||
throw new Error(
|
||||
`Runtime route did not reach delivered status: ${latest?.last_status || "none"}`,
|
||||
);
|
||||
}
|
||||
result.visible_signals.push(
|
||||
"platform-event-converted",
|
||||
"agent-ran",
|
||||
"reply-delivered",
|
||||
);
|
||||
|
||||
const botUrl = `${frontendUrl.replace(/\/$/, "")}/home/bots?id=${encodeURIComponent(botId)}`;
|
||||
await page.goto(botUrl, { waitUntil: "domcontentloaded" });
|
||||
result.url = page.url();
|
||||
const deliveredStatus = page
|
||||
.getByText(/Delivered|已投递|配信済み/, { exact: true })
|
||||
.first();
|
||||
await deliveredStatus.waitFor({ timeout: 15_000 });
|
||||
await deliveredStatus.scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(250);
|
||||
result.visible_signals.push("delivered-status-visible");
|
||||
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 =
|
||||
"Quick Start created an enabled OneBot scenario that converted a platform event, ran an Agent, delivered its reply, and showed the final status.";
|
||||
} 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 (browser?.page && token) {
|
||||
if (botId) {
|
||||
const deleted = await api(
|
||||
browser.page,
|
||||
`/api/v1/platform/bots/${encodeURIComponent(botId)}`,
|
||||
{ method: "DELETE" },
|
||||
).catch(() => ({ status: 0, json: {} }));
|
||||
result.cleanup.bot_deleted =
|
||||
deleted.status < 400 && deleted.json.code === 0;
|
||||
}
|
||||
if (agentId) {
|
||||
const deleted = await api(
|
||||
browser.page,
|
||||
`/api/v1/agents/${encodeURIComponent(agentId)}`,
|
||||
{ method: "DELETE" },
|
||||
).catch(() => ({ status: 0, json: {} }));
|
||||
result.cleanup.agent_deleted =
|
||||
deleted.status < 400 && deleted.json.code === 0;
|
||||
}
|
||||
}
|
||||
const cleanupFailed =
|
||||
(botId && !result.cleanup.bot_deleted) ||
|
||||
(agentId && !result.cleanup.agent_deleted);
|
||||
if (cleanupFailed && result.status === "pass") {
|
||||
result.status = "fail";
|
||||
result.reason = "The temporary OneBot wizard resources were not 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));
|
||||
Reference in New Issue
Block a user