mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-14 08:26:07 +00:00
199 lines
7.5 KiB
JavaScript
Executable File
199 lines
7.5 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
import { readFile } from "node:fs/promises";
|
|
import { resolve } from "node:path";
|
|
import { env } from "node:process";
|
|
import {
|
|
apiJson,
|
|
ensureEvidence,
|
|
evidencePaths,
|
|
loadEnvFiles,
|
|
resetAndAuthLocalUser,
|
|
writeResult,
|
|
} from "./lib/langbot-e2e.mjs";
|
|
|
|
const caseId = env.LBS_CASE_ID || "install-qa-plugin-smoke";
|
|
const paths = evidencePaths(caseId);
|
|
await loadEnvFiles();
|
|
await ensureEvidence(paths);
|
|
|
|
const backendUrl = env.LANGBOT_BACKEND_URL || "";
|
|
const user = env.LANGBOT_E2E_LOGIN_USER || "";
|
|
const password = env.LANGBOT_E2E_LOGIN_PASSWORD || "LangBotE2ELocalPass!2026";
|
|
const packagePath = resolve(
|
|
env.LANGBOT_E2E_PLUGIN_PACKAGE
|
|
|| env.LANGBOT_QA_PLUGIN_SMOKE_PACKAGE
|
|
|| "skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/dist/qa-plugin-smoke-0.1.0.lbpkg",
|
|
);
|
|
const expectedPluginId = env.LANGBOT_E2E_EXPECTED_PLUGIN_ID || "qa/plugin-smoke";
|
|
const expectedTool = env.LANGBOT_E2E_EXPECTED_TOOL || (expectedPluginId === "qa/plugin-smoke" ? "qa_plugin_echo" : "");
|
|
const expectedRunnerId = env.LANGBOT_E2E_EXPECTED_RUNNER_ID || "";
|
|
|
|
const result = {
|
|
source: "automation",
|
|
case_id: caseId,
|
|
run_id: paths.runId,
|
|
status: "fail",
|
|
reason: "",
|
|
backend_url: backendUrl,
|
|
package_path: packagePath,
|
|
package_preview: null,
|
|
task_id: null,
|
|
task: null,
|
|
plugin_present_before: false,
|
|
plugin_present_after: false,
|
|
tool_names: [],
|
|
runner_ids: [],
|
|
evidence: {
|
|
automation_result_json: paths.automationResultJson,
|
|
result_json: paths.resultJson,
|
|
},
|
|
evidence_collected: ["api_diagnostic", "filesystem"],
|
|
};
|
|
|
|
try {
|
|
if (!backendUrl) throw new Error("LANGBOT_BACKEND_URL is not configured.");
|
|
if (!user) throw new Error("LANGBOT_E2E_LOGIN_USER is required.");
|
|
const bytes = await readFile(packagePath);
|
|
|
|
const auth = await resetAndAuthLocalUser({ backendUrl, user, password });
|
|
result.package_preview = await previewPackage(backendUrl, auth.token, bytes, packagePath);
|
|
const metadata = result.package_preview.metadata || {};
|
|
if (`${metadata.author}/${metadata.name}` !== expectedPluginId) {
|
|
throw new Error(`Fixture package metadata is ${metadata.author}/${metadata.name}, expected ${expectedPluginId}.`);
|
|
}
|
|
result.plugin_present_before = await hasPlugin(backendUrl, auth.token);
|
|
|
|
if (!result.plugin_present_before) {
|
|
const form = new FormData();
|
|
form.set("file", new Blob([bytes]), packagePath.split("/").pop());
|
|
const response = await fetch(`${backendUrl.replace(/\/$/, "")}/api/v1/plugins/install/local`, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${auth.token}` },
|
|
body: form,
|
|
});
|
|
const json = await response.json().catch(() => ({}));
|
|
if (response.status >= 400 || json.code !== 0) {
|
|
throw new Error(json.msg || `Plugin install request failed with HTTP ${response.status}.`);
|
|
}
|
|
result.task_id = json.data?.task_id ?? null;
|
|
if (!result.task_id) throw new Error("Plugin install response did not include task_id.");
|
|
result.task = await waitForTask(backendUrl, auth.token, result.task_id);
|
|
if (!isTaskComplete(result.task)) {
|
|
throw new Error(`Plugin install task did not complete successfully: ${JSON.stringify(result.task)}`);
|
|
}
|
|
}
|
|
|
|
await sleep(1000);
|
|
result.plugin_present_after = await hasPlugin(backendUrl, auth.token);
|
|
if (!result.plugin_present_after) throw new Error(`${expectedPluginId} is not listed by /api/v1/plugins after install.`);
|
|
if (expectedTool) {
|
|
result.tool_names = await listToolNames(backendUrl, auth.token);
|
|
if (!result.tool_names.includes(expectedTool)) {
|
|
throw new Error(`${expectedTool} is not listed by /api/v1/tools after install.`);
|
|
}
|
|
}
|
|
if (expectedRunnerId) {
|
|
result.runner_ids = await listRunnerIds(backendUrl, auth.token);
|
|
if (!result.runner_ids.includes(expectedRunnerId)) {
|
|
throw new Error(`${expectedRunnerId} is not listed by /api/v1/pipelines/_/metadata after install.`);
|
|
}
|
|
}
|
|
|
|
result.status = "pass";
|
|
result.reason = `${expectedPluginId} is installed.`;
|
|
} catch (error) {
|
|
result.status = "fail";
|
|
result.reason = error.message;
|
|
} finally {
|
|
await writeResult(paths, result);
|
|
console.log(JSON.stringify(result, null, 2));
|
|
}
|
|
|
|
process.exit(result.status === "pass" ? 0 : 1);
|
|
|
|
async function hasPlugin(backendUrl, token) {
|
|
const response = await apiJson(backendUrl, "/api/v1/plugins", { token });
|
|
const plugins = response.json.data?.plugins || [];
|
|
return plugins.some((plugin) => {
|
|
const metadata = plugin.manifest?.manifest?.metadata || plugin.manifest?.metadata || plugin.metadata || {};
|
|
return `${metadata.author}/${metadata.name}` === expectedPluginId;
|
|
});
|
|
}
|
|
|
|
async function previewPackage(backendUrl, token, bytes, packagePath) {
|
|
const form = new FormData();
|
|
form.set("file", new Blob([bytes]), packagePath.split("/").pop());
|
|
const response = await fetch(`${backendUrl.replace(/\/$/, "")}/api/v1/plugins/install/local/preview`, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
body: form,
|
|
});
|
|
const json = await response.json().catch(() => ({}));
|
|
if (response.status >= 400 || json.code !== 0) {
|
|
throw new Error(json.msg || `Plugin package preview failed with HTTP ${response.status}.`);
|
|
}
|
|
return {
|
|
metadata: json.data?.metadata || {},
|
|
component_types: json.data?.component_types || [],
|
|
file_count: json.data?.file_count ?? null,
|
|
};
|
|
}
|
|
|
|
async function listToolNames(backendUrl, token) {
|
|
const response = await apiJson(backendUrl, "/api/v1/tools", { token });
|
|
return (response.json.data?.tools || [])
|
|
.map((tool) => tool.name || tool.tool_name || tool.function?.name || "")
|
|
.filter(Boolean)
|
|
.sort();
|
|
}
|
|
|
|
async function listRunnerIds(backendUrl, token) {
|
|
const response = await apiJson(backendUrl, "/api/v1/pipelines/_/metadata", { token });
|
|
const configs = response.json.data?.configs || [];
|
|
return configs
|
|
.flatMap((section) => section.stages || [])
|
|
.flatMap((stage) => stage.config || [])
|
|
.filter((item) => item.name === "id")
|
|
.flatMap((item) => item.options || [])
|
|
.map((option) => option.name || option.value || option.id || "")
|
|
.filter(Boolean)
|
|
.sort();
|
|
}
|
|
|
|
async function waitForTask(backendUrl, token, taskId) {
|
|
const deadline = Date.now() + Number(env.LANGBOT_PLUGIN_INSTALL_TIMEOUT_MS || 120000);
|
|
let last = null;
|
|
while (Date.now() < deadline) {
|
|
const response = await apiJson(backendUrl, `/api/v1/system/tasks/${encodeURIComponent(taskId)}`, { token });
|
|
last = response.json.data || response.json;
|
|
if (isTaskComplete(last) || isTaskFailed(last)) return last;
|
|
await sleep(1000);
|
|
}
|
|
return last;
|
|
}
|
|
|
|
function isTaskComplete(task) {
|
|
const status = String(task?.status || task?.state || "").toLowerCase();
|
|
const runtimeStatus = String(task?.runtime?.status || task?.runtime?.state || "").toLowerCase();
|
|
return ["done", "completed", "success", "succeeded", "finished"].includes(status)
|
|
|| ["done", "completed", "success", "succeeded", "finished"].includes(runtimeStatus)
|
|
|| task?.done === true
|
|
|| task?.completed === true
|
|
|| (task?.runtime?.done === true && !task?.runtime?.exception);
|
|
}
|
|
|
|
function isTaskFailed(task) {
|
|
const status = String(task?.status || task?.state || "").toLowerCase();
|
|
const runtimeStatus = String(task?.runtime?.status || task?.runtime?.state || "").toLowerCase();
|
|
return ["failed", "error", "cancelled", "canceled"].includes(status)
|
|
|| ["failed", "error", "cancelled", "canceled"].includes(runtimeStatus)
|
|
|| task?.failed === true
|
|
|| Boolean(task?.error)
|
|
|| Boolean(task?.runtime?.exception);
|
|
}
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|