mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-17 01:46:07 +00:00
fix(agent-runner): validate clean marketplace onboarding
This commit is contained in:
@@ -106,14 +106,16 @@ try {
|
||||
const messageBehavior = page.getByRole("menuitem", {
|
||||
name: /Reply to messages|回复收到的消息|受信メッセージに返信/,
|
||||
});
|
||||
const noEventRoutes = page.getByText(
|
||||
/No event routes|暂无事件路由|イベントルートはありません/,
|
||||
{ exact: true },
|
||||
);
|
||||
await noEventRoutes.waitFor();
|
||||
await messageBehavior.waitFor();
|
||||
await page.waitForTimeout(250);
|
||||
await safeScreenshot(page, scenarioMenuScreenshot);
|
||||
await messageBehavior.click();
|
||||
await page
|
||||
.getByText(/Message received|收到消息|メッセージを受信/)
|
||||
.first()
|
||||
.waitFor();
|
||||
await noEventRoutes.waitFor({ state: "hidden" });
|
||||
result.visible_signals.push("create-mode-routing", "scenario-route-added");
|
||||
|
||||
const create = await apiJson(backendUrl, "/api/v1/platform/bots", {
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { closeSync, openSync } from "node:fs";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { createServer } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { spawn } from "node:child_process";
|
||||
import { pathExists, resolveLangBotRepo } from "./langbot-e2e.mjs";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const proxyKeys = [
|
||||
"ALL_PROXY",
|
||||
"all_proxy",
|
||||
"HTTP_PROXY",
|
||||
"http_proxy",
|
||||
"HTTPS_PROXY",
|
||||
"https_proxy",
|
||||
];
|
||||
|
||||
function withoutProxy(source = process.env) {
|
||||
const next = { ...source };
|
||||
for (const key of proxyKeys) delete next[key];
|
||||
next.NO_PROXY = "127.0.0.1,localhost";
|
||||
next.no_proxy = "127.0.0.1,localhost";
|
||||
return next;
|
||||
}
|
||||
|
||||
async function getFreePort() {
|
||||
const server = createServer();
|
||||
await new Promise((resolvePromise, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolvePromise);
|
||||
});
|
||||
const address = server.address();
|
||||
const port = typeof address === "object" && address ? address.port : 0;
|
||||
await new Promise((resolvePromise) => server.close(resolvePromise));
|
||||
if (!port) throw new Error("Could not allocate an isolated LangBot port.");
|
||||
return port;
|
||||
}
|
||||
|
||||
async function waitForHttp(url, child, timeoutMs, label) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (child.exitCode !== null) {
|
||||
throw new Error(`${label} exited before becoming ready (code ${child.exitCode}).`);
|
||||
}
|
||||
try {
|
||||
const response = await fetch(url, { redirect: "manual" });
|
||||
if (response.status < 500) return response;
|
||||
} catch {
|
||||
// The service is still starting.
|
||||
}
|
||||
await new Promise((resolvePromise) => setTimeout(resolvePromise, 500));
|
||||
}
|
||||
throw new Error(`${label} did not become ready within ${timeoutMs}ms.`);
|
||||
}
|
||||
|
||||
function spawnLogged(command, args, { cwd, env, logPath }) {
|
||||
const logFd = openSync(logPath, "a");
|
||||
try {
|
||||
return spawn(command, args, {
|
||||
cwd,
|
||||
env,
|
||||
detached: true,
|
||||
stdio: ["ignore", logFd, logFd],
|
||||
});
|
||||
} finally {
|
||||
closeSync(logFd);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopProcess(child) {
|
||||
if (!child || child.exitCode !== null) return;
|
||||
const closed = new Promise((resolvePromise) => child.once("close", resolvePromise));
|
||||
try {
|
||||
process.kill(-child.pid, "SIGTERM");
|
||||
} catch {
|
||||
child.kill("SIGTERM");
|
||||
}
|
||||
const graceful = await Promise.race([
|
||||
closed.then(() => true),
|
||||
new Promise((resolvePromise) => setTimeout(() => resolvePromise(false), 5_000)),
|
||||
]);
|
||||
if (graceful) return;
|
||||
try {
|
||||
process.kill(-child.pid, "SIGKILL");
|
||||
} catch {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
await closed.catch(() => {});
|
||||
}
|
||||
|
||||
async function initializeUser(backendUrl, user, password) {
|
||||
const response = await fetch(`${backendUrl}/api/v1/user/init`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user, password }),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (response.status >= 400 || ![0, 1].includes(payload.code)) {
|
||||
throw new Error(payload.msg || `Could not initialize isolated user (HTTP ${response.status}).`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function startIsolatedLangBotInstance({ evidenceDir }) {
|
||||
const repo = await resolveLangBotRepo();
|
||||
const webRepo = process.env.LANGBOT_WEB_REPO || join(repo, "web");
|
||||
const python = join(repo, ".venv", "bin", "python");
|
||||
const configScript = resolve(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
"../prepare-isolated-langbot-config.py",
|
||||
);
|
||||
if (!(await pathExists(python))) throw new Error(`LangBot virtualenv Python is missing: ${python}`);
|
||||
if (!(await pathExists(join(webRepo, "node_modules")))) {
|
||||
throw new Error(`LangBot frontend dependencies are missing: ${join(webRepo, "node_modules")}`);
|
||||
}
|
||||
|
||||
const [backendPort, frontendPort, pluginDebugPort] = await Promise.all([
|
||||
getFreePort(),
|
||||
getFreePort(),
|
||||
getFreePort(),
|
||||
]);
|
||||
const instanceRoot = await mkdtemp(join(tmpdir(), "langbot-clean-catalog-"));
|
||||
const backendUrl = `http://127.0.0.1:${backendPort}`;
|
||||
const frontendUrl = `http://127.0.0.1:${frontendPort}`;
|
||||
const backendLog = join(evidenceDir, "isolated-backend.log");
|
||||
const frontendLog = join(evidenceDir, "isolated-frontend.log");
|
||||
const user = "langbot-e2e@example.invalid";
|
||||
const password = "LangBotIsolatedE2E!2026";
|
||||
let backend;
|
||||
let frontend;
|
||||
|
||||
const stop = async () => {
|
||||
await stopProcess(frontend);
|
||||
await stopProcess(backend);
|
||||
await rm(instanceRoot, { recursive: true, force: true });
|
||||
};
|
||||
|
||||
try {
|
||||
await execFileAsync(
|
||||
python,
|
||||
[
|
||||
configScript,
|
||||
"--instance-root",
|
||||
instanceRoot,
|
||||
"--port",
|
||||
String(backendPort),
|
||||
"--plugin-debug-port",
|
||||
String(pluginDebugPort),
|
||||
],
|
||||
{ cwd: repo, env: withoutProxy(), timeout: 30_000 },
|
||||
);
|
||||
|
||||
backend = spawnLogged(python, ["-m", "langbot"], {
|
||||
cwd: instanceRoot,
|
||||
env: {
|
||||
...withoutProxy(),
|
||||
PYTHONPATH: join(repo, "src"),
|
||||
LANGBOT_DATA_ROOT: join(instanceRoot, "data"),
|
||||
API__PORT: String(backendPort),
|
||||
API__WEBHOOK_PREFIX: backendUrl,
|
||||
SPACE__DISABLE_TELEMETRY: "true",
|
||||
SPACE__DISABLE_MODELS_SERVICE: "true",
|
||||
},
|
||||
logPath: backendLog,
|
||||
});
|
||||
await waitForHttp(`${backendUrl}/api/v1/system/info`, backend, 180_000, "Isolated LangBot backend");
|
||||
|
||||
const configText = await readFile(join(instanceRoot, "data", "config.yaml"), "utf8");
|
||||
const recoveryKey = configText.match(/^\s*recovery_key:\s*['"]?([^'"\s#]+)['"]?\s*$/m)?.[1] || "";
|
||||
if (!recoveryKey) throw new Error("Isolated LangBot did not generate a recovery key.");
|
||||
await initializeUser(backendUrl, user, password);
|
||||
|
||||
frontend = spawnLogged(
|
||||
"pnpm",
|
||||
["exec", "vite", "--host", "127.0.0.1", "--port", String(frontendPort), "--strictPort"],
|
||||
{
|
||||
cwd: webRepo,
|
||||
env: { ...withoutProxy(), VITE_API_BASE_URL: backendUrl },
|
||||
logPath: frontendLog,
|
||||
},
|
||||
);
|
||||
await waitForHttp(frontendUrl, frontend, 60_000, "Isolated LangBot frontend");
|
||||
|
||||
return {
|
||||
backendUrl,
|
||||
frontendUrl,
|
||||
recoveryKey,
|
||||
user,
|
||||
password,
|
||||
logs: { backend: backendLog, frontend: frontendLog },
|
||||
stop,
|
||||
};
|
||||
} catch (error) {
|
||||
await stop();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from tests.e2e.utils.config_factory import ( # noqa: E402
|
||||
create_minimal_config,
|
||||
create_test_directories,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--instance-root', required=True, type=Path)
|
||||
parser.add_argument('--port', required=True, type=int)
|
||||
parser.add_argument('--plugin-debug-port', required=True, type=int)
|
||||
args = parser.parse_args()
|
||||
|
||||
config_path = create_minimal_config(args.instance_root, port=args.port)
|
||||
create_test_directories(args.instance_root)
|
||||
|
||||
with config_path.open('r', encoding='utf-8') as file:
|
||||
config = yaml.safe_load(file)
|
||||
|
||||
config['plugin']['enable'] = True
|
||||
config['plugin']['enable_marketplace'] = True
|
||||
config['plugin']['display_plugin_debug_url'] = (
|
||||
f'ws://127.0.0.1:{args.plugin_debug_port}/plugin/debug/ws'
|
||||
)
|
||||
|
||||
with config_path.open('w', encoding='utf-8') as file:
|
||||
yaml.safe_dump(config, file, allow_unicode=True, sort_keys=False)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -13,17 +13,20 @@ import {
|
||||
scanBrowserDiagnostics,
|
||||
writeResult,
|
||||
} from "./lib/langbot-e2e.mjs";
|
||||
import { startIsolatedLangBotInstance } from "./lib/isolated-langbot-instance.mjs";
|
||||
|
||||
const caseId = "wizard-runner-marketplace-catalog";
|
||||
await loadEnvFiles();
|
||||
const paths = evidencePaths(caseId);
|
||||
await ensureEvidence(paths);
|
||||
const mobileScreenshot = paths.screenshot.replace(/\.png$/, "-mobile.png");
|
||||
const installedScreenshot = paths.screenshot.replace(/\.png$/, "-installed.png");
|
||||
|
||||
const startedAt = new Date();
|
||||
const frontendUrl = process.env.LANGBOT_FRONTEND_URL || "";
|
||||
const backendUrl = process.env.LANGBOT_BACKEND_URL || "";
|
||||
let frontendUrl = "";
|
||||
let backendUrl = "";
|
||||
let browser;
|
||||
let isolated;
|
||||
let token = "";
|
||||
let botId = "";
|
||||
|
||||
@@ -41,13 +44,17 @@ const result = {
|
||||
visible_signals: [],
|
||||
api: {},
|
||||
marketplace_request: null,
|
||||
marketplace_response: null,
|
||||
diagnostics: null,
|
||||
cleanup: {},
|
||||
evidence: {
|
||||
screenshot: paths.screenshot,
|
||||
mobile_screenshot: mobileScreenshot,
|
||||
installed_screenshot: installedScreenshot,
|
||||
console_log: paths.consoleLog,
|
||||
network_log: paths.networkLog,
|
||||
isolated_backend_log: `${paths.evidenceDir}/isolated-backend.log`,
|
||||
isolated_frontend_log: `${paths.evidenceDir}/isolated-frontend.log`,
|
||||
automation_result_json: paths.automationResultJson,
|
||||
result_json: paths.resultJson,
|
||||
},
|
||||
@@ -55,8 +62,15 @@ const result = {
|
||||
};
|
||||
|
||||
try {
|
||||
if (!frontendUrl) throw new Error("LANGBOT_FRONTEND_URL is not configured.");
|
||||
if (!backendUrl) throw new Error("LANGBOT_BACKEND_URL is not configured.");
|
||||
isolated = await startIsolatedLangBotInstance({
|
||||
evidenceDir: paths.evidenceDir,
|
||||
});
|
||||
frontendUrl = isolated.frontendUrl;
|
||||
backendUrl = isolated.backendUrl;
|
||||
result.api.isolated_instance = {
|
||||
backend_url: backendUrl,
|
||||
frontend_url: frontendUrl,
|
||||
};
|
||||
|
||||
browser = await createBrowser(paths);
|
||||
const { page } = browser;
|
||||
@@ -82,11 +96,35 @@ try {
|
||||
// The assertion below reports a missing or malformed catalog request.
|
||||
}
|
||||
});
|
||||
page.on("response", async (response) => {
|
||||
const pathname = new URL(response.url()).pathname;
|
||||
if (!/\/api\/v1\/marketplace\/(extensions|plugins)\/search$/.test(pathname)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = await response.json();
|
||||
const entries = payload?.data?.extensions || payload?.data?.plugins || [];
|
||||
const localAgent = entries.find(
|
||||
(entry) => `${entry.author}/${entry.name}` === "langbot-team/LocalAgent",
|
||||
);
|
||||
result.marketplace_response = {
|
||||
endpoint: pathname,
|
||||
total: payload?.data?.total ?? entries.length,
|
||||
local_agent_present: Boolean(localAgent),
|
||||
local_agent_version: localAgent?.latest_version || null,
|
||||
};
|
||||
} catch {
|
||||
// The UI assertions below surface malformed Marketplace responses.
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto(frontendUrl, { waitUntil: "domcontentloaded" });
|
||||
const auth = await ensureAuthenticatedBrowser(page, {
|
||||
frontendUrl,
|
||||
backendUrl,
|
||||
user: isolated.user,
|
||||
password: isolated.password,
|
||||
recoveryKey: isolated.recoveryKey,
|
||||
});
|
||||
if (auth.status !== "pass") {
|
||||
result.status = auth.status;
|
||||
@@ -174,12 +212,17 @@ try {
|
||||
exact: true,
|
||||
})
|
||||
.waitFor({ timeout: 15_000 });
|
||||
await page
|
||||
.getByText(
|
||||
/No AgentRunner extensions are published yet|市场暂未发布 AgentRunner 扩展|AgentRunner 拡張機能はまだ公開されていません/,
|
||||
{ exact: true },
|
||||
)
|
||||
.waitFor({ timeout: 15_000 });
|
||||
const localAgentIdentity = page.getByText("langbot-team/LocalAgent", {
|
||||
exact: true,
|
||||
});
|
||||
await localAgentIdentity.waitFor({ timeout: 30_000 });
|
||||
const localAgentCard = localAgentIdentity.locator(
|
||||
"xpath=ancestor::*[@data-slot='card'][1]",
|
||||
);
|
||||
const installButton = localAgentCard.getByRole("button", {
|
||||
name: /Install & Continue|安装并继续|インストールして続行/,
|
||||
});
|
||||
await installButton.waitFor();
|
||||
const browseLink = page.getByRole("link", {
|
||||
name: /Browse Runner Extensions|浏览运行器扩展|Runner 拡張機能を見る/,
|
||||
});
|
||||
@@ -199,10 +242,19 @@ try {
|
||||
"Wizard did not request the AgentRunner Marketplace catalog.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
!result.marketplace_response?.local_agent_present ||
|
||||
!result.marketplace_response?.local_agent_version
|
||||
) {
|
||||
throw new Error(
|
||||
"The Marketplace catalog did not return an installable langbot-team/LocalAgent version.",
|
||||
);
|
||||
}
|
||||
|
||||
result.visible_signals.push(
|
||||
"first-run-ai-engine-step",
|
||||
"empty-runner-catalog-state",
|
||||
"published-local-agent-runner",
|
||||
"install-and-continue-action",
|
||||
"runner-marketplace-link",
|
||||
"next-disabled-without-runner",
|
||||
);
|
||||
@@ -220,13 +272,73 @@ try {
|
||||
await safeScreenshot(page, mobileScreenshot);
|
||||
result.visible_signals.push("mobile-layout");
|
||||
|
||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||
await installButton.click();
|
||||
const installingButton = localAgentCard.getByRole("button", {
|
||||
name: /Installing\.\.\.|正在安装\.\.\.|インストール中\.\.\./,
|
||||
});
|
||||
await installingButton.waitFor({ timeout: 15_000 });
|
||||
const installOutcome = await Promise.race([
|
||||
nextButton
|
||||
.click({ trial: true, timeout: 180_000 })
|
||||
.then(() => "registered"),
|
||||
installButton
|
||||
.waitFor({ state: "visible", timeout: 180_000 })
|
||||
.then(() => "failed"),
|
||||
]);
|
||||
if (installOutcome === "failed") {
|
||||
throw new Error("LocalAgent installation failed before Runner registration.");
|
||||
}
|
||||
if (await nextButton.isDisabled()) {
|
||||
throw new Error("Create & Deploy remained disabled after LocalAgent installation.");
|
||||
}
|
||||
|
||||
const [installedPluginsResponse, installedMetadataResponse] =
|
||||
await Promise.all([
|
||||
apiJson(backendUrl, "/api/v1/plugins", { token }),
|
||||
apiJson(backendUrl, "/api/v1/pipelines/_/metadata", { token }),
|
||||
]);
|
||||
const postInstallPlugins =
|
||||
installedPluginsResponse.json.data?.plugins || [];
|
||||
const installedRunnerStage = installedMetadataResponse.json.data?.configs
|
||||
?.find((config) => config.name === "ai")
|
||||
?.stages?.find((stage) => stage.name === "runner");
|
||||
const installedRunnerOptions =
|
||||
installedRunnerStage?.config?.find((item) => item.name === "id")?.options ||
|
||||
[];
|
||||
const localAgentInstalled = postInstallPlugins.some((plugin) => {
|
||||
const metadata = plugin.manifest?.manifest?.metadata || {};
|
||||
return `${metadata.author}/${metadata.name}` === "langbot-team/LocalAgent";
|
||||
});
|
||||
const localAgentRegistered = installedRunnerOptions.some(
|
||||
(option) => option.name === "plugin:langbot-team/LocalAgent/default",
|
||||
);
|
||||
result.api.installed_state = {
|
||||
plugin_count: postInstallPlugins.length,
|
||||
runner_count: installedRunnerOptions.length,
|
||||
local_agent_installed: localAgentInstalled,
|
||||
local_agent_registered: localAgentRegistered,
|
||||
};
|
||||
if (!localAgentInstalled || !localAgentRegistered) {
|
||||
throw new Error(
|
||||
"LocalAgent installation completed in the UI but the plugin or Runner registration is missing.",
|
||||
);
|
||||
}
|
||||
await safeScreenshot(page, installedScreenshot);
|
||||
result.visible_signals.push(
|
||||
"local-agent-installed",
|
||||
"local-agent-runner-registered",
|
||||
"local-agent-selected",
|
||||
"create-and-deploy-enabled",
|
||||
);
|
||||
|
||||
result.diagnostics = await scanBrowserDiagnostics(paths);
|
||||
if (result.diagnostics.status !== "pass") {
|
||||
throw new Error(result.diagnostics.reason);
|
||||
}
|
||||
result.status = "pass";
|
||||
result.reason =
|
||||
"A clean first-run instance requested the AgentRunner catalog, showed a safe empty state, and prevented continuing without a Runner.";
|
||||
"A clean first-run instance discovered LocalAgent in the AgentRunner catalog, installed and registered it, selected it, and enabled Create & Deploy.";
|
||||
} catch (error) {
|
||||
if (!["blocked", "env_issue"].includes(result.status)) result.status = "fail";
|
||||
result.reason = result.reason || error.message;
|
||||
@@ -270,6 +382,18 @@ try {
|
||||
result.reason = "The clean Wizard fixtures were not fully reset.";
|
||||
}
|
||||
if (browser) await browser.close().catch(() => {});
|
||||
if (isolated) {
|
||||
try {
|
||||
await isolated.stop();
|
||||
cleanup.isolated_instance_stopped = true;
|
||||
} catch (error) {
|
||||
cleanup.isolated_instance_stopped = false;
|
||||
if (result.status === "pass") {
|
||||
result.status = "fail";
|
||||
result.reason = `Could not stop the isolated LangBot instance: ${error.message}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
const finishedAt = new Date();
|
||||
result.finished_at = finishedAt.toISOString();
|
||||
result.finished_at_local = localIsoWithOffset(finishedAt);
|
||||
|
||||
Reference in New Issue
Block a user