mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-25 03:27:15 +00:00
feat: MCP server + in-repo skills (agent-friendly platform) (#2269)
* feat(api): support global API key from config.yaml (api.global_api_key) Accept a config-defined global API key anywhere a web-UI key is accepted (X-API-Key / Bearer), with no login session and no DB record. Useful for automated deployments and AI agents (HTTP API + MCP). Defaults to empty (disabled); does not require the lbk_ prefix. - templates/config.yaml: add api.global_api_key with security notes - service/apikey.py: verify_api_key checks global key first (constant-time) - docs/API_KEY_AUTH.md: document the global key + security guidance - tests: cover global-key match, prefix-free, fallback-to-db, disabled * feat(mcp): expose LangBot management as an MCP server at /mcp Add an MCP (Model Context Protocol) server so external AI agents can manage a LangBot instance. Reuses the same API-key auth as the HTTP API (including the config.yaml global API key). - pkg/api/mcp/server.py: FastMCP server wrapping the service layer; 21 curated tools across system/bots/pipelines/models/knowledge/mcp-servers/skills - pkg/api/mcp/mount.py: ASGI dispatcher fronting Quart; authenticates /mcp requests with an API key, runs the streamable-HTTP session manager lifespan - controller/main.py: serve the wrapped ASGI app via hypercorn (was run_task) - web: new 'MCP' tab in the API integration dialog showing endpoint, auth, and client config; i18n for 8 locales - tests/manual/mcp_smoke.py: e2e check (401 unauth, list tools, call tools) Tool surface is intentionally curated (not all ~25 route groups) to keep the agent surface small, safe, and maintainable. Extend deliberately. * feat(skills): add in-repo skills/ as the single source of truth Migrate the agent skills + QA/e2e test harness from the (now archived) langbot-app/langbot-skills repo into LangBot/skills/, and add four new skills. Migrated: - langbot-plugin-dev, langbot-testing (e2e), langbot-env-setup, langbot-skills-maintenance, langbot-eba-adapter-dev - the bin/lbs CLI (src/, test/, scripts/, schemas/, qa-agent-docs/) New: - langbot-dev core backend + web development - langbot-deploy Docker/K8s deployment + config.yaml + global API key - langbot-mcp-ops operating the LangBot MCP server (/mcp) - langbot-space-ops operating the Space marketplace MCP server - src/cli.ts repoRoot(): recognize the skills assets root (skills.index.json + bin/lbs) so the CLI works when nested inside the LangBot repo - README.md: unified skill catalog; skills.index.json regenerated Parity with source verified: bin/lbs validate + node test suite match the source repo (only the uncommitted .lbpkg build-artifact fixture differs). * docs(agents): document agent-facing surfaces + API/MCP/skills sync rule * docs(readme): add 'Built for AI Agents' section across all locales Highlight MCP server, in-repo skills (single source of truth), AGENTS.md sync rule, and llms.txt. Cross-link LangBot Space MCP marketplace. * style(mcp): fix ruff format + prettier lint in MCP server and API panel * style(web): prettier format MCP i18n locale entries * docs(skills): note MCP instance control in dev/testing skills All development-guidance skills now point to the LangBot instance MCP server (/mcp) and the Space marketplace MCP server, reusing API keys.
This commit is contained in:
@@ -0,0 +1,416 @@
|
||||
import {
|
||||
bodyText,
|
||||
clickFirstVisible,
|
||||
countOccurrences,
|
||||
gotoFrontend,
|
||||
isLoginUrl,
|
||||
} from "./langbot-e2e.mjs";
|
||||
|
||||
export const DEBUG_CHAT_FAILURE_SIGNALS = [
|
||||
"Agent runner temporarily unavailable",
|
||||
"All models failed during streaming setup",
|
||||
"调用超时",
|
||||
"超时",
|
||||
];
|
||||
|
||||
export function minExpectedOccurrences(beforeText, expectedText, prompt) {
|
||||
const beforeCount = countOccurrences(beforeText, expectedText);
|
||||
return beforeCount + (String(prompt).includes(expectedText) ? 2 : 1);
|
||||
}
|
||||
|
||||
export function latestExpectedLeafMatches(latestExpectedLeaf, prompt) {
|
||||
return Boolean(latestExpectedLeaf)
|
||||
&& latestExpectedLeaf !== prompt
|
||||
&& !String(latestExpectedLeaf).includes(prompt);
|
||||
}
|
||||
|
||||
export function findNewFailureSignal(beforeText, afterText, failureSignals = DEBUG_CHAT_FAILURE_SIGNALS) {
|
||||
return failureSignals.find((signal) => countOccurrences(afterText, signal) > countOccurrences(beforeText, signal)) || "";
|
||||
}
|
||||
|
||||
function findFailureSignalInText(text, failureSignals = DEBUG_CHAT_FAILURE_SIGNALS) {
|
||||
return failureSignals.find((signal) => String(text || "").includes(signal)) || "";
|
||||
}
|
||||
|
||||
function countExpectedInMessages(messages, expectedText) {
|
||||
return messages
|
||||
.filter((message) => message.role === "assistant")
|
||||
.reduce((count, message) => count + countOccurrences(message.text, expectedText), 0);
|
||||
}
|
||||
|
||||
function debugChatInput(page) {
|
||||
return page
|
||||
.locator('input[placeholder*="message"], input[placeholder*="消息"], textarea[placeholder*="message"], textarea[placeholder*="消息"]')
|
||||
.last();
|
||||
}
|
||||
|
||||
async function clickDebugChatTab(page) {
|
||||
const tabByRole = page.getByRole("tab", { name: /Debug Chat|调试聊天|调试对话|Debug|调试/i }).first();
|
||||
if (await tabByRole.isVisible({ timeout: 3_000 }).catch(() => false)) {
|
||||
await tabByRole.click();
|
||||
return true;
|
||||
}
|
||||
|
||||
const tabBySelector = page.locator('[role="tab"]').filter({ hasText: /Debug Chat|调试聊天|调试对话|Debug|调试/i }).first();
|
||||
if (await tabBySelector.isVisible({ timeout: 2_000 }).catch(() => false)) {
|
||||
await tabBySelector.click();
|
||||
return true;
|
||||
}
|
||||
|
||||
return Boolean(await clickFirstVisible(page, ["Debug Chat", "调试聊天", "调试对话"], 2_000));
|
||||
}
|
||||
|
||||
async function waitForDebugChatReady(page, timeout = 20_000) {
|
||||
const input = debugChatInput(page);
|
||||
const visible = await input.isVisible({ timeout }).catch(() => false);
|
||||
if (!visible) {
|
||||
return {
|
||||
ready: false,
|
||||
reason: "Debug Chat tab was clicked, but the Debug Chat input did not become visible.",
|
||||
};
|
||||
}
|
||||
|
||||
const enabled = await input.isEnabled({ timeout }).catch(() => false);
|
||||
if (!enabled) {
|
||||
return {
|
||||
ready: false,
|
||||
reason: "Debug Chat input is visible but disabled; WebSocket may not be connected.",
|
||||
};
|
||||
}
|
||||
|
||||
return { ready: true, reason: "" };
|
||||
}
|
||||
|
||||
export function classifyDebugChatResult({
|
||||
beforeText,
|
||||
afterText,
|
||||
expectedText,
|
||||
prompt,
|
||||
latestExpectedLeaf,
|
||||
latestFailureLeaf,
|
||||
beforeMessages = null,
|
||||
afterMessages = null,
|
||||
latestAssistantText = "",
|
||||
failureSignals = DEBUG_CHAT_FAILURE_SIGNALS,
|
||||
}) {
|
||||
const minExpectedCount = minExpectedOccurrences(beforeText, expectedText, prompt);
|
||||
const finalCount = countOccurrences(afterText, expectedText);
|
||||
const failureText = findNewFailureSignal(beforeText, afterText, failureSignals);
|
||||
const promptContainsExpected = String(prompt).includes(expectedText);
|
||||
const hasMessageEvidence = Array.isArray(beforeMessages) && Array.isArray(afterMessages);
|
||||
const beforeAssistantExpectedCount = hasMessageEvidence
|
||||
? countExpectedInMessages(beforeMessages, expectedText)
|
||||
: null;
|
||||
const afterAssistantExpectedCount = hasMessageEvidence
|
||||
? countExpectedInMessages(afterMessages, expectedText)
|
||||
: null;
|
||||
const assistantExpectedIncreased = hasMessageEvidence
|
||||
? afterAssistantExpectedCount > beforeAssistantExpectedCount
|
||||
: false;
|
||||
|
||||
if (hasMessageEvidence) {
|
||||
const latestAssistantFailure = findFailureSignalInText(latestAssistantText, failureSignals);
|
||||
if (latestAssistantFailure) {
|
||||
return {
|
||||
status: "fail",
|
||||
reason: `Debug Chat displayed a known failure signal in the latest assistant message: ${latestAssistantFailure}`,
|
||||
min_expected_count: minExpectedCount,
|
||||
final_count: finalCount,
|
||||
failure_signal: latestAssistantFailure,
|
||||
before_assistant_expected_count: beforeAssistantExpectedCount,
|
||||
after_assistant_expected_count: afterAssistantExpectedCount,
|
||||
};
|
||||
}
|
||||
if (assistantExpectedIncreased && String(latestAssistantText).includes(expectedText)) {
|
||||
return {
|
||||
status: "pass",
|
||||
reason: `Expected text appeared in a new assistant message: ${expectedText}`,
|
||||
min_expected_count: minExpectedCount,
|
||||
final_count: finalCount,
|
||||
before_assistant_expected_count: beforeAssistantExpectedCount,
|
||||
after_assistant_expected_count: afterAssistantExpectedCount,
|
||||
};
|
||||
}
|
||||
if (failureText) {
|
||||
return {
|
||||
status: "fail",
|
||||
reason: `Debug Chat displayed a known failure signal: ${failureText}`,
|
||||
min_expected_count: minExpectedCount,
|
||||
final_count: finalCount,
|
||||
failure_signal: failureText,
|
||||
before_assistant_expected_count: beforeAssistantExpectedCount,
|
||||
after_assistant_expected_count: afterAssistantExpectedCount,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "fail",
|
||||
reason: `Expected text did not appear in a new assistant message. Expected assistant occurrences to increase above ${beforeAssistantExpectedCount}, saw ${afterAssistantExpectedCount}.`,
|
||||
min_expected_count: minExpectedCount,
|
||||
final_count: finalCount,
|
||||
before_assistant_expected_count: beforeAssistantExpectedCount,
|
||||
after_assistant_expected_count: afterAssistantExpectedCount,
|
||||
};
|
||||
}
|
||||
if (failureText) {
|
||||
return {
|
||||
status: "fail",
|
||||
reason: `Debug Chat displayed a known failure signal: ${failureText}`,
|
||||
min_expected_count: minExpectedCount,
|
||||
final_count: finalCount,
|
||||
failure_signal: failureText,
|
||||
before_assistant_expected_count: beforeAssistantExpectedCount,
|
||||
after_assistant_expected_count: afterAssistantExpectedCount,
|
||||
};
|
||||
}
|
||||
if (latestExpectedLeafMatches(latestExpectedLeaf, prompt) && finalCount >= minExpectedCount) {
|
||||
return {
|
||||
status: "pass",
|
||||
reason: `Expected text appeared in the latest visible response leaf: ${expectedText}`,
|
||||
min_expected_count: minExpectedCount,
|
||||
final_count: finalCount,
|
||||
};
|
||||
}
|
||||
if (!promptContainsExpected && finalCount >= minExpectedCount) {
|
||||
return {
|
||||
status: "pass",
|
||||
reason: `Expected text appeared enough times for user prompt plus bot response: ${expectedText}`,
|
||||
min_expected_count: minExpectedCount,
|
||||
final_count: finalCount,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "fail",
|
||||
reason: `Bot response did not appear. Expected ${minExpectedCount} occurrences of ${expectedText}, saw ${finalCount}.`,
|
||||
min_expected_count: minExpectedCount,
|
||||
final_count: finalCount,
|
||||
};
|
||||
}
|
||||
|
||||
export async function openPipelineDebugChat(page, { pipelineUrl, pipelineName, envHint = "LANGBOT_PIPELINE_URL or LANGBOT_PIPELINE_NAME" }) {
|
||||
if (pipelineUrl) {
|
||||
await page.goto(pipelineUrl, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {});
|
||||
} else {
|
||||
if (!pipelineName) {
|
||||
return {
|
||||
opened: false,
|
||||
status: "blocked",
|
||||
reason: `Set ${envHint} before running pipeline-debug-chat automation.`,
|
||||
};
|
||||
}
|
||||
await gotoFrontend(page);
|
||||
if (isLoginUrl(page.url())) {
|
||||
return {
|
||||
opened: false,
|
||||
status: "blocked",
|
||||
reason: "Browser profile is not authenticated for LANGBOT_FRONTEND_URL.",
|
||||
};
|
||||
}
|
||||
const clickedPipelines = await clickFirstVisible(page, ["Pipelines", "流水线"], 4_000);
|
||||
if (!clickedPipelines) {
|
||||
return { opened: false, status: "fail", reason: "Could not find Pipelines navigation." };
|
||||
}
|
||||
await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {});
|
||||
const clickedPipeline = await clickFirstVisible(page, [pipelineName], 5_000);
|
||||
if (!clickedPipeline) {
|
||||
return { opened: false, status: "blocked", reason: `Could not find pipeline named ${pipelineName}.` };
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoginUrl(page.url())) {
|
||||
return {
|
||||
opened: false,
|
||||
status: "blocked",
|
||||
reason: "Browser profile is not authenticated for LANGBOT_FRONTEND_URL.",
|
||||
};
|
||||
}
|
||||
|
||||
const clickedDebug = await clickDebugChatTab(page);
|
||||
if (!clickedDebug) {
|
||||
return { opened: false, status: "fail", reason: "Could not find the Debug Chat tab." };
|
||||
}
|
||||
await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {});
|
||||
const ready = await waitForDebugChatReady(page);
|
||||
if (!ready.ready) {
|
||||
return { opened: false, status: "fail", reason: ready.reason };
|
||||
}
|
||||
return { opened: true };
|
||||
}
|
||||
|
||||
export async function latestVisibleLeafText(page, needles) {
|
||||
return await page.evaluate((items) => {
|
||||
const isVisible = (element) => {
|
||||
const style = window.getComputedStyle(element);
|
||||
const rect = element.getBoundingClientRect();
|
||||
return style.visibility !== "hidden"
|
||||
&& style.display !== "none"
|
||||
&& rect.width > 0
|
||||
&& rect.height > 0;
|
||||
};
|
||||
const leaves = [];
|
||||
for (const element of document.body.querySelectorAll("*")) {
|
||||
if (!isVisible(element)) continue;
|
||||
const text = element.innerText?.trim();
|
||||
if (!text || text.length > 4000) continue;
|
||||
const visibleChildHasText = Array.from(element.children).some((child) => (
|
||||
isVisible(child) && child.innerText?.trim()
|
||||
));
|
||||
if (visibleChildHasText) continue;
|
||||
if (!items.some((needle) => text.includes(needle))) continue;
|
||||
leaves.push(text);
|
||||
}
|
||||
return leaves.at(-1) || "";
|
||||
}, needles);
|
||||
}
|
||||
|
||||
export async function visibleDebugChatMessages(page) {
|
||||
return await page.evaluate(() => {
|
||||
const isVisible = (element) => {
|
||||
const style = window.getComputedStyle(element);
|
||||
const rect = element.getBoundingClientRect();
|
||||
return style.visibility !== "hidden"
|
||||
&& style.display !== "none"
|
||||
&& rect.width > 0
|
||||
&& rect.height > 0;
|
||||
};
|
||||
const classText = (element) => String(element.getAttribute("class") || "");
|
||||
return Array.from(document.querySelectorAll("div.max-w-3xl"))
|
||||
.filter((element) => isVisible(element))
|
||||
.map((element) => {
|
||||
const row = element.parentElement;
|
||||
const text = element.innerText?.trim() || "";
|
||||
const isUser = classText(element).includes("user-message-bubble")
|
||||
|| classText(row).includes("justify-end");
|
||||
return {
|
||||
role: isUser ? "user" : "assistant",
|
||||
text,
|
||||
};
|
||||
})
|
||||
.filter((message) => message.text);
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForExpectedDebugChatText(page, { expectedText, minExpectedCount, timeoutMs }) {
|
||||
await page.waitForFunction(
|
||||
({ expected, min }) => {
|
||||
return document.body.innerText.split(expected).length - 1 >= min;
|
||||
},
|
||||
{ expected: expectedText, min: minExpectedCount },
|
||||
{ timeout: timeoutMs },
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
export async function waitForDebugChatTextStable(page, { timeoutMs = 5_000, quietMs = 750 } = {}) {
|
||||
const startedAt = Date.now();
|
||||
let lastText = await bodyText(page);
|
||||
let stableSince = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
await page.waitForTimeout(250);
|
||||
const currentText = await bodyText(page);
|
||||
if (currentText !== lastText) {
|
||||
lastText = currentText;
|
||||
stableSince = Date.now();
|
||||
continue;
|
||||
}
|
||||
if (Date.now() - stableSince >= quietMs) return;
|
||||
}
|
||||
}
|
||||
|
||||
export async function attachDebugChatImage(page, imagePath) {
|
||||
if (!imagePath) return { status: "not_required", reason: "" };
|
||||
const input = page.locator('input[type="file"][accept*="image"], input[type="file"]').first();
|
||||
if (!await input.count()) {
|
||||
return { status: "fail", reason: "Could not find a Debug Chat image upload input." };
|
||||
}
|
||||
await input.setInputFiles(imagePath);
|
||||
await page.locator("img").last().waitFor({ state: "visible", timeout: 10_000 }).catch(() => {});
|
||||
return { status: "ready", reason: `Attached image fixture: ${imagePath}` };
|
||||
}
|
||||
|
||||
export async function sendDebugChatPrompt(page, prompt, imagePath = "") {
|
||||
const imageResult = await attachDebugChatImage(page, imagePath);
|
||||
if (imageResult.status === "fail") return imageResult;
|
||||
|
||||
const input = debugChatInput(page);
|
||||
const inputVisible = await input.isVisible({ timeout: 5_000 }).catch(() => false);
|
||||
const inputEnabled = inputVisible && await input.isEnabled({ timeout: 10_000 }).catch(() => false);
|
||||
if (!inputVisible || !inputEnabled) return false;
|
||||
await input.fill(prompt).catch(async () => {
|
||||
await input.click();
|
||||
await input.pressSequentially(prompt);
|
||||
});
|
||||
const clickedSend = await clickFirstVisible(page, ["Send", "发送", "提交"], 1_500);
|
||||
if (!clickedSend) await page.keyboard.press("Enter");
|
||||
await page.getByText(prompt, { exact: false }).last().waitFor({ state: "visible", timeout: 10_000 }).catch(() => {});
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function runDebugChatPrompt(page, { prompt, expectedText, responseTimeoutMs, imagePath = "", failureSignals = DEBUG_CHAT_FAILURE_SIGNALS }) {
|
||||
const beforeText = await bodyText(page);
|
||||
const beforeMessages = await visibleDebugChatMessages(page);
|
||||
const minExpectedCount = minExpectedOccurrences(beforeText, expectedText, prompt);
|
||||
const sent = await sendDebugChatPrompt(page, prompt, imagePath);
|
||||
if (sent !== true) {
|
||||
if (sent && typeof sent === "object" && typeof sent.reason === "string") return sent;
|
||||
return { status: "fail", reason: "Could not find a Debug Chat text input." };
|
||||
}
|
||||
|
||||
await waitForExpectedDebugChatText(page, {
|
||||
expectedText,
|
||||
minExpectedCount,
|
||||
prompt,
|
||||
timeoutMs: responseTimeoutMs,
|
||||
});
|
||||
await waitForDebugChatTextStable(page);
|
||||
|
||||
const afterText = await bodyText(page);
|
||||
const afterMessages = await visibleDebugChatMessages(page);
|
||||
const latestAssistantText = afterMessages.filter((message) => message.role === "assistant").at(-1)?.text || "";
|
||||
const latestExpectedLeaf = await latestVisibleLeafText(page, [expectedText]);
|
||||
const failureText = findNewFailureSignal(beforeText, afterText, failureSignals);
|
||||
const latestFailureLeaf = failureText ? await latestVisibleLeafText(page, [failureText]) : "";
|
||||
|
||||
return classifyDebugChatResult({
|
||||
beforeText,
|
||||
afterText,
|
||||
expectedText,
|
||||
prompt,
|
||||
latestExpectedLeaf,
|
||||
latestFailureLeaf,
|
||||
beforeMessages,
|
||||
afterMessages,
|
||||
latestAssistantText,
|
||||
failureSignals,
|
||||
});
|
||||
}
|
||||
|
||||
export async function setDebugChatStreamOutput(page, desired) {
|
||||
if (desired === null || desired === undefined) return { status: "not_required", reason: "" };
|
||||
|
||||
const streamSwitch = page.locator('[role="switch"]').first();
|
||||
if (!await streamSwitch.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
||||
return { status: "blocked", reason: "Debug Chat stream switch was not visible." };
|
||||
}
|
||||
if (!await streamSwitch.isEnabled({ timeout: 10_000 }).catch(() => false)) {
|
||||
return { status: "blocked", reason: "Debug Chat stream switch was visible but disabled." };
|
||||
}
|
||||
|
||||
const checked = (await streamSwitch.getAttribute("aria-checked").catch(() => null)) === "true";
|
||||
if (checked !== desired) {
|
||||
await streamSwitch.click();
|
||||
await page.waitForFunction(
|
||||
({ selector, expected }) => document.querySelector(selector)?.getAttribute("aria-checked") === String(expected),
|
||||
{ selector: '[role="switch"]', expected: desired },
|
||||
{ timeout: 5_000 },
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
const finalChecked = (await streamSwitch.getAttribute("aria-checked").catch(() => null)) === "true";
|
||||
if (finalChecked !== desired) {
|
||||
return {
|
||||
status: "fail",
|
||||
reason: `Debug Chat stream switch did not reach requested state: ${desired ? "on" : "off"}.`,
|
||||
};
|
||||
}
|
||||
return { status: "ready", reason: `Debug Chat stream switch is ${desired ? "on" : "off"}.` };
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { env } from "node:process";
|
||||
|
||||
const secretRe = /(?:authorization|bearer|token|secret|password|api[_-]?key|jwt|oauth)\s*[:=]\s*["']?[^"',\s]+/gi;
|
||||
|
||||
export function redact(text) {
|
||||
return String(text ?? "")
|
||||
.replace(secretRe, (match) => match.replace(/[:=]\s*["']?.*$/, "=[redacted]"))
|
||||
.replace(/\bbearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, "Bearer [redacted]")
|
||||
.replace(/\bsk-[A-Za-z0-9_-]{6,}\b/g, "[redacted]");
|
||||
}
|
||||
|
||||
export function timestampSlug(date = new Date()) {
|
||||
return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
export function localIsoWithOffset(date = new Date()) {
|
||||
const offsetMinutes = -date.getTimezoneOffset();
|
||||
const sign = offsetMinutes >= 0 ? "+" : "-";
|
||||
const absolute = Math.abs(offsetMinutes);
|
||||
const pad = (value) => String(value).padStart(2, "0");
|
||||
const yyyy = date.getFullYear();
|
||||
const mm = pad(date.getMonth() + 1);
|
||||
const dd = pad(date.getDate());
|
||||
const hh = pad(date.getHours());
|
||||
const mi = pad(date.getMinutes());
|
||||
const ss = pad(date.getSeconds());
|
||||
const ms = String(date.getMilliseconds()).padStart(3, "0");
|
||||
return `${yyyy}-${mm}-${dd}T${hh}:${mi}:${ss}.${ms}${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`;
|
||||
}
|
||||
|
||||
export function evidencePaths(caseId) {
|
||||
const runId = env.LBS_RUN_ID || `${timestampSlug()}-${caseId}`;
|
||||
const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join("reports", "evidence", runId));
|
||||
return {
|
||||
runId,
|
||||
evidenceDir,
|
||||
consoleLog: join(evidenceDir, "console.log"),
|
||||
networkLog: join(evidenceDir, "network.log"),
|
||||
screenshot: join(evidenceDir, "screenshot.png"),
|
||||
automationResultJson: join(evidenceDir, "automation-result.json"),
|
||||
resultJson: join(evidenceDir, "result.json"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureEvidence(paths) {
|
||||
await mkdir(paths.evidenceDir, { recursive: true });
|
||||
await appendFile(paths.consoleLog, "", "utf8");
|
||||
await appendFile(paths.networkLog, "", "utf8");
|
||||
}
|
||||
|
||||
export async function pathExists(path) {
|
||||
try {
|
||||
await stat(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function appendLine(path, line) {
|
||||
await appendFile(path, `[${localIsoWithOffset()}] ${redact(line)}\n`, "utf8");
|
||||
}
|
||||
|
||||
export async function writeResult(paths, result) {
|
||||
const text = `${JSON.stringify(result, null, 2)}\n`;
|
||||
if (paths.automationResultJson) await writeFile(paths.automationResultJson, text, "utf8");
|
||||
if (paths.resultJson && paths.resultJson !== paths.automationResultJson) {
|
||||
await writeFile(paths.resultJson, text, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadEnvFiles(paths = ["skills/.env", "skills/.env.local"]) {
|
||||
for (const path of paths) {
|
||||
let text = "";
|
||||
try {
|
||||
text = await readFile(path, "utf8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const equals = trimmed.indexOf("=");
|
||||
if (equals <= 0) continue;
|
||||
const key = trimmed.slice(0, equals).trim();
|
||||
const value = trimmed.slice(equals + 1).trim().replace(/^["']|["']$/g, "");
|
||||
if (!(key in env)) env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function readRecoveryKey(repo = env.LANGBOT_REPO || "../LangBot") {
|
||||
const configPath = resolve(repo, "data/config.yaml");
|
||||
const config = await readFile(configPath, "utf8");
|
||||
const match = config.match(/^\s*recovery_key:\s*['"]?([^'"\s#]+)['"]?\s*$/m);
|
||||
return match?.[1] || "";
|
||||
}
|
||||
|
||||
export async function apiJson(backendUrl, path, { method = "GET", token = "", body } = {}) {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const response = await fetch(`${backendUrl.replace(/\/$/, "")}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
return {
|
||||
status: response.status,
|
||||
json: await response.json().catch(() => ({})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function checkBackendToken(backendUrl, token) {
|
||||
if (!token) {
|
||||
return { authenticated: false, http_status: 0, code: null, reason: "No token." };
|
||||
}
|
||||
const response = await apiJson(backendUrl, "/api/v1/user/check-token", { token });
|
||||
const code = response.json.code ?? null;
|
||||
const authenticated = response.status < 400 && code === 0;
|
||||
return {
|
||||
authenticated,
|
||||
http_status: response.status,
|
||||
code,
|
||||
reason: authenticated ? "Token accepted by backend." : response.json.msg || "Backend rejected token.",
|
||||
};
|
||||
}
|
||||
|
||||
export async function resetAndAuthLocalUser({ backendUrl, user, password, recoveryKey = "" }) {
|
||||
const key = recoveryKey || await readRecoveryKey();
|
||||
if (!key) throw new Error("Could not read recovery_key from LangBot config.");
|
||||
|
||||
const reset = await apiJson(backendUrl, "/api/v1/user/reset-password", {
|
||||
method: "POST",
|
||||
body: {
|
||||
user,
|
||||
recovery_key: key,
|
||||
new_password: password,
|
||||
},
|
||||
});
|
||||
if (reset.status >= 400 || reset.json.code !== 0) {
|
||||
throw new Error(reset.json.msg || `Password reset failed with HTTP ${reset.status}.`);
|
||||
}
|
||||
|
||||
const auth = await apiJson(backendUrl, "/api/v1/user/auth", {
|
||||
method: "POST",
|
||||
body: { user, password },
|
||||
});
|
||||
const token = auth.json.data?.token || "";
|
||||
if (auth.status >= 400 || auth.json.code !== 0 || !token) {
|
||||
throw new Error(auth.json.msg || `Auth failed with HTTP ${auth.status}.`);
|
||||
}
|
||||
|
||||
const check = await checkBackendToken(backendUrl, token);
|
||||
if (!check.authenticated) {
|
||||
throw new Error(check.reason || "Authenticated token failed backend token check.");
|
||||
}
|
||||
|
||||
return { token, check };
|
||||
}
|
||||
|
||||
export async function setBrowserToken(page, frontendUrl, token) {
|
||||
await page.addInitScript((value) => {
|
||||
localStorage.setItem("token", value);
|
||||
}, token);
|
||||
await page.goto(frontendUrl, { waitUntil: "domcontentloaded" });
|
||||
await page.evaluate((value) => localStorage.setItem("token", value), token);
|
||||
}
|
||||
|
||||
export async function verifyBrowserToken(page, backendUrl) {
|
||||
return await page.evaluate(async (baseUrl) => {
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) {
|
||||
return { authenticated: false, http_status: 0, code: null, reason: "No localStorage token." };
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${baseUrl.replace(/\/$/, "")}/api/v1/user/check-token`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const json = await response.json().catch(() => ({}));
|
||||
const code = json.code ?? null;
|
||||
const authenticated = response.status < 400 && code === 0;
|
||||
return {
|
||||
authenticated,
|
||||
http_status: response.status,
|
||||
code,
|
||||
reason: authenticated ? "Token accepted by backend." : json.msg || "Backend rejected token.",
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
authenticated: false,
|
||||
http_status: 0,
|
||||
code: null,
|
||||
reason: error.message,
|
||||
};
|
||||
}
|
||||
}, backendUrl);
|
||||
}
|
||||
|
||||
export function exitCode(status) {
|
||||
if (status === "pass") return 0;
|
||||
if (status === "blocked" || status === "env_issue") return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
export async function loadPlaywright() {
|
||||
try {
|
||||
return await import("playwright");
|
||||
} catch {
|
||||
throw new Error(
|
||||
"Playwright is not installed. Install it in this repo with `npm install --save-dev playwright`, then run `npx playwright install chromium`.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createBrowser(paths) {
|
||||
const { chromium } = await loadPlaywright();
|
||||
const headed = env.LBS_HEADED === "1";
|
||||
const launchOptions = {
|
||||
headless: !headed,
|
||||
};
|
||||
if (env.LANGBOT_CHROMIUM_EXECUTABLE && await pathExists(env.LANGBOT_CHROMIUM_EXECUTABLE)) {
|
||||
launchOptions.executablePath = env.LANGBOT_CHROMIUM_EXECUTABLE;
|
||||
}
|
||||
|
||||
let browser;
|
||||
let context;
|
||||
if (env.LANGBOT_BROWSER_PROFILE) {
|
||||
context = await chromium.launchPersistentContext(resolve(env.LANGBOT_BROWSER_PROFILE), {
|
||||
...launchOptions,
|
||||
viewport: { width: 1440, height: 960 },
|
||||
});
|
||||
} else {
|
||||
browser = await chromium.launch(launchOptions);
|
||||
context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||
}
|
||||
const page = context.pages()[0] || await context.newPage();
|
||||
|
||||
page.on("console", (message) => {
|
||||
appendLine(paths.consoleLog, `[${message.type()}] ${message.text()}`).catch(() => {});
|
||||
});
|
||||
page.on("pageerror", (error) => {
|
||||
appendLine(paths.consoleLog, `[pageerror] ${error.message}`).catch(() => {});
|
||||
});
|
||||
page.on("requestfailed", (request) => {
|
||||
appendLine(paths.networkLog, `[requestfailed] ${request.method()} ${request.url()} ${request.failure()?.errorText ?? ""}`).catch(() => {});
|
||||
});
|
||||
page.on("response", (response) => {
|
||||
if (response.status() < 400) return;
|
||||
appendLine(paths.networkLog, `[response] ${response.status()} ${response.url()}`).catch(() => {});
|
||||
});
|
||||
|
||||
return {
|
||||
page,
|
||||
context,
|
||||
async close() {
|
||||
await context.close();
|
||||
if (browser) await browser.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function safeScreenshot(page, path) {
|
||||
try {
|
||||
await page.screenshot({ path, fullPage: true });
|
||||
} catch {
|
||||
// Screenshot evidence is useful, but a screenshot failure should not hide the real test result.
|
||||
}
|
||||
}
|
||||
|
||||
export async function gotoFrontend(page) {
|
||||
const frontendUrl = env.LANGBOT_FRONTEND_URL;
|
||||
if (!frontendUrl) {
|
||||
throw new Error("LANGBOT_FRONTEND_URL is not configured.");
|
||||
}
|
||||
await page.goto(frontendUrl, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {});
|
||||
}
|
||||
|
||||
export function isLoginUrl(url) {
|
||||
return /\/login(?:[/?#]|$)/.test(url);
|
||||
}
|
||||
|
||||
export async function bodyText(page) {
|
||||
return await page.locator("body").innerText({ timeout: 5_000 }).catch(() => "");
|
||||
}
|
||||
|
||||
export function countOccurrences(haystack, needle) {
|
||||
if (!needle) return 0;
|
||||
return String(haystack).split(needle).length - 1;
|
||||
}
|
||||
|
||||
export async function clickFirstVisible(page, labels, timeout = 2_000) {
|
||||
for (const label of labels) {
|
||||
const roleButton = page.getByRole("button", { name: label }).first();
|
||||
if (await roleButton.isVisible({ timeout }).catch(() => false)) {
|
||||
await roleButton.click();
|
||||
return label;
|
||||
}
|
||||
|
||||
const roleLink = page.getByRole("link", { name: label }).first();
|
||||
if (await roleLink.isVisible({ timeout }).catch(() => false)) {
|
||||
await roleLink.click();
|
||||
return label;
|
||||
}
|
||||
|
||||
const text = page.getByText(label, { exact: false }).first();
|
||||
if (await text.isVisible({ timeout }).catch(() => false)) {
|
||||
await text.click();
|
||||
return label;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function fillFirstTextInput(page, value) {
|
||||
const candidates = [
|
||||
page.getByRole("textbox").last(),
|
||||
page.locator("textarea").last(),
|
||||
page.locator("[contenteditable=true]").last(),
|
||||
page.locator("input[type=text]").last(),
|
||||
];
|
||||
|
||||
for (const locator of candidates) {
|
||||
if (!await locator.isVisible({ timeout: 2_000 }).catch(() => false)) continue;
|
||||
await locator.fill(value).catch(async () => {
|
||||
await locator.click();
|
||||
await locator.pressSequentially(value);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function waitForVisibleText(page, text, timeout = 20_000) {
|
||||
await page.getByText(text, { exact: false }).last().waitFor({ state: "visible", timeout });
|
||||
}
|
||||
Reference in New Issue
Block a user