#!/usr/bin/env node import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import process from "node:process"; import { chromium } from "playwright"; const DEFAULTS = { appUrl: "http://127.0.0.1:1420/", healthUrl: "http://127.0.0.1:3030/health", invokeUrl: "http://127.0.0.1:3030/invoke", timeoutMs: 90_000, headless: true, }; const ONBOARDING_VERSION = "1.1.0"; const USER_FACING_FORBIDDEN_TEXT = [ ".lime/knowledge", "KNOWLEDGE.md", "knowledge_builder", "compiled/brief.md", "sources/source.md", "frontmatter", " 前端地址,默认 http://127.0.0.1:1420/ --health-url DevBridge 健康检查地址,默认 http://127.0.0.1:3030/health --invoke-url DevBridge invoke 地址,默认 http://127.0.0.1:3030/invoke --timeout-ms 单步等待超时,默认 90000 --headed 使用有界面 Chrome;默认 headless -h, --help 显示帮助 `); } function parseArgs(argv) { const options = { ...DEFAULTS }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--app-url" && argv[index + 1]) { options.appUrl = String(argv[index + 1]).trim(); index += 1; continue; } if (arg === "--health-url" && argv[index + 1]) { options.healthUrl = String(argv[index + 1]).trim(); index += 1; continue; } if (arg === "--invoke-url" && argv[index + 1]) { options.invokeUrl = String(argv[index + 1]).trim(); index += 1; continue; } if (arg === "--timeout-ms" && argv[index + 1]) { options.timeoutMs = Number(argv[index + 1]); index += 1; continue; } if (arg === "--headed") { options.headless = false; continue; } if (arg === "--help" || arg === "-h") { printHelp(); process.exit(0); } } if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) { throw new Error("--timeout-ms 必须是正数"); } return options; } function log(stage) { console.log(`[knowledge:product-e2e] stage=${stage}`); } async function waitForHealth(options) { const deadline = Date.now() + options.timeoutMs; let lastError = null; while (Date.now() < deadline) { try { const response = await fetch(options.healthUrl, { signal: AbortSignal.timeout(Math.min(5_000, options.timeoutMs)), }); if (response.ok) { const payload = await response.json().catch(() => ({})); console.log( `[knowledge:product-e2e] DevBridge 已就绪 status=${payload?.status || response.status}`, ); return; } lastError = new Error(`HTTP ${response.status}`); } catch (error) { lastError = error; } await new Promise((resolve) => setTimeout(resolve, 1_000)); } throw new Error( `DevBridge 未就绪: ${lastError instanceof Error ? lastError.message : String(lastError)}`, ); } async function invoke(options, cmd, args) { const response = await fetch(options.invokeUrl, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ cmd, args }), signal: AbortSignal.timeout(options.timeoutMs), }); if (!response.ok) { throw new Error(`${cmd} HTTP ${response.status}: ${response.statusText}`); } const payload = await response.json(); if (payload?.error) { throw new Error(`${cmd}: ${payload.error}`); } return payload.result; } async function seedPack(options, workingDir, pack) { await invoke(options, "knowledge_import_source", { request: { workingDir, packName: pack.name, description: pack.title, packType: pack.type, sourceFileName: pack.sourceFileName, sourceText: pack.sourceText, }, }); await invoke(options, "knowledge_compile_pack", { request: { workingDir, name: pack.name }, }); await invoke(options, "knowledge_update_pack_status", { request: { workingDir, name: pack.name, status: pack.status }, }); } async function waitText(page, options, label, needles) { try { await page.waitForFunction( (expected) => { const body = document.body?.innerText || ""; const fields = Array.from(document.querySelectorAll("input, textarea")) .map((element) => element.value || "") .join("\n"); const text = `${body}\n${fields}`; return expected.every((item) => text.includes(item)); }, needles, { timeout: options.timeoutMs }, ); } catch { const body = await page .locator("body") .innerText() .catch(() => ""); const fields = await page .locator("input, textarea") .evaluateAll((items) => items.map((element) => element.value || "").join("\n"), ) .catch(() => ""); const text = `${body}\n${fields}`; const missing = needles.filter((needle) => !text.includes(needle)); throw new Error( `${label} 缺少 ${JSON.stringify(missing)};页面预览:${text.slice(0, 1800)}`, ); } } async function assertNoLeak(page, label) { const mainText = (await page .locator("main") .last() .innerText() .catch(() => "")) || (await page.locator("body").innerText()); const leaked = USER_FACING_FORBIDDEN_TEXT.filter((needle) => mainText.includes(needle), ); if (leaked.length > 0) { throw new Error(`${label} 暴露内部词:${JSON.stringify(leaked)}`); } } async function clickSideNav(page, options, name) { await page .locator('[data-testid="app-sidebar-main-nav"]') .getByRole("button", { name, exact: true }) .click({ timeout: options.timeoutMs }); } async function openKnowledgeOverview(page, options) { const text = await page .locator("body") .innerText() .catch(() => ""); if ( text.includes("项目资料状态说明") || text.includes("存到哪里?") || text.includes("整理新资料") ) { if (text.includes("项目资料状态说明")) { const backButton = page.getByRole("button", { name: "回到项目资料", exact: true, }); if (await backButton.isVisible().catch(() => false)) { await backButton.click({ timeout: options.timeoutMs }); await waitText(page, options, "状态说明返回项目资料首页", [ "让 Lime 记住这个项目", "项目资料清单", ]); return; } } await clickSideNav(page, options, "灵感"); await waitText(page, options, "灵感页", ["收藏过的想法"]); } await clickSideNav(page, options, "项目资料"); await waitText(page, options, "项目资料首页", [ "让 Lime 记住这个项目", "项目资料清单", ]); } async function clickArticleButton(page, options, title, buttonName) { await page .locator("article") .filter({ hasText: title }) .getByRole("button", { name: buttonName, exact: true }) .click({ timeout: options.timeoutMs }); } async function prepareProject(options) { const workingDir = fs.mkdtempSync( path.join(os.tmpdir(), "lime-knowledge-product-e2e-"), ); const project = await invoke(options, "workspace_create", { request: { name: `Knowledge Product E2E ${Date.now()}`, rootPath: workingDir, workspaceType: "temporary", }, }); const projectId = String(project?.id || "").trim(); if (!projectId) { throw new Error("workspace_create 未返回项目 ID"); } const projectRoot = String(project?.rootPath || project?.root_path || workingDir).trim() || workingDir; fs.mkdirSync(projectRoot, { recursive: true }); await seedPack(options, projectRoot, PACKS.persona); await seedPack(options, projectRoot, PACKS.ready); await seedPack(options, projectRoot, PACKS.review); await invoke(options, "knowledge_set_default_pack", { request: { workingDir: projectRoot, name: PACKS.ready.name }, }); return { projectId, projectRoot }; } async function run() { const options = parseArgs(process.argv.slice(2)); let browser = null; let context = null; log("wait-health"); await waitForHealth(options); log("prepare-project"); const { projectId, projectRoot } = await prepareProject(options); try { log("launch-browser"); browser = await chromium.launch({ channel: "chrome", headless: options.headless, }); context = await browser.newContext({ viewport: { width: 1440, height: 960 }, }); const page = await context.newPage(); const consoleErrors = []; const mockMessages = []; page.on("console", (message) => { const text = message.text(); if (message.type() === "error") { consoleErrors.push(text); } if (text.includes("[Mock] invoke")) { mockMessages.push(text); } }); page.on("pageerror", (error) => { consoleErrors.push(error.stack || error.message); }); log("home-baseline"); await page.goto(options.appUrl, { waitUntil: "domcontentloaded" }); await page.evaluate( ({ nextProjectId, nextProjectRoot, onboardingVersion }) => { localStorage.setItem("lime_onboarding_complete", "true"); localStorage.setItem("lime_onboarding_version", onboardingVersion); localStorage.setItem("lime_user_profile", "developer"); localStorage.setItem("lime.knowledge.working-dir", nextProjectRoot); localStorage.setItem( "agent_last_project_id", JSON.stringify(nextProjectId), ); localStorage.setItem("lime-resource-project-id", nextProjectId); }, { nextProjectId: projectId, nextProjectRoot: projectRoot, onboardingVersion: ONBOARDING_VERSION, }, ); await page.reload({ waitUntil: "domcontentloaded" }); await waitText(page, options, "首页加载", [ "青柠一下,灵感即来", "项目资料", ]); log("knowledge-overview"); await openKnowledgeOverview(page, options); await waitText(page, options, "项目资料首页内容", [ "可用于创作", "待确认", "需要补充", "建议本轮使用", PACKS.persona.title, PACKS.ready.title, PACKS.review.title, "资料只有确认后才会用于创作", "查看状态说明", ]); await assertNoLeak(page, "项目资料首页"); if ((await page.locator("body").innerText()).includes("本轮创作会使用")) { throw new Error("首页仍宣称资料会自动进入本轮创作"); } log("states-page"); await page .getByRole("button", { name: "查看状态说明", exact: true }) .click({ timeout: options.timeoutMs }); await waitText(page, options, "状态说明页", [ "项目资料状态说明", "没有资料", "已可用", "待确认", "需要补充", "整理失败", ]); await assertNoLeak(page, "状态说明页"); await openKnowledgeOverview(page, options); log("review-confirm"); await clickArticleButton(page, options, PACKS.review.title, "去确认"); await waitText(page, options, "确认资料页", [ PACKS.review.title, "完整资料文档", "查看文档内容", "需要你确认的内容", "确认后会发生什么", "确认可用", ]); await assertNoLeak(page, "确认资料页"); { const text = await page.locator("body").innerText(); if (text.includes("导出") || text.includes("常用金句")) { throw new Error("确认页仍存在不可用导出或臆造确认项"); } } await page .getByRole("button", { name: "确认可用", exact: true }) .click({ timeout: options.timeoutMs }); await waitText(page, options, "确认完成", [ "资料已确认可用", PACKS.review.title, ]); await page .getByRole("button", { name: "回到项目资料", exact: true }) .click({ timeout: options.timeoutMs }); await waitText(page, options, "确认后首页", [ "项目资料清单", PACKS.review.title, "已可用", ]); log("seed-agent-result"); await page.evaluate( ({ nextProjectId, message }) => { const now = new Date().toISOString(); sessionStorage.setItem( `aster_messages_${nextProjectId}`, JSON.stringify([ { id: message.id, role: "assistant", content: message.content, timestamp: now, }, ]), ); sessionStorage.removeItem(`aster_curr_sessionId_${nextProjectId}`); sessionStorage.removeItem(`aster_last_sessionId_${nextProjectId}`); sessionStorage.removeItem(`aster_thread_turns_${nextProjectId}`); sessionStorage.removeItem(`aster_thread_items_${nextProjectId}`); sessionStorage.removeItem(`aster_curr_turnId_${nextProjectId}`); }, { nextProjectId: projectId, message: AGENT_RESULT }, ); log("composer-select"); await clickArticleButton(page, options, PACKS.ready.title, "用于创作"); await waitText(page, options, "选择创作资料弹层", [ "选择这次创作用哪些资料", "写作口吻(只能选 1 个)", "要参考的资料(可多选)", "这次会怎么用", ]); await page .locator( `[data-testid="knowledge-composer-persona-${PACKS.persona.name}"]`, ) .click({ timeout: options.timeoutMs }); await page .locator(`[data-testid="knowledge-composer-data-${PACKS.review.name}"]`) .click({ timeout: options.timeoutMs }); await waitText(page, options, "选择资料数量", ["已选 3 份资料"]); await page .getByRole("button", { name: "确认使用", exact: true }) .click({ timeout: options.timeoutMs }); await waitText(page, options, "Agent 创作页", [ `资料:${PACKS.ready.title}`, "+2", "保存到项目资料", "事实:该结果来自当前 Agent 对话", ]); log("agent-save-to-knowledge"); await page .getByRole("button", { name: "保存到项目资料" }) .first() .click({ timeout: options.timeoutMs }); await waitText(page, options, "保存到项目资料页", [ "存到哪里?", "补充已有资料", "新建一份资料", "保存到项目资料", "保存后不会立刻用于创作,确认后才会生效", "事实:该结果来自当前 Agent 对话", ]); await assertNoLeak(page, "保存到项目资料页"); { const text = await page.locator("body").innerText(); if (text.includes("新增 2 个内容点") || text.includes("更新 1 个章节")) { throw new Error("保存前仍展示假统计结果"); } } await page .getByRole("button", { name: "保存到项目资料", exact: true }) .click({ timeout: options.timeoutMs }); await waitText(page, options, "保存完成", [ "资料已保存,确认后才会用于创作", "内容已进入项目资料", "下一步需要确认后才会用于创作", ]); log("organize-new-material"); await openKnowledgeOverview(page, options); await page .getByRole("button", { name: "整理新资料", exact: true }) .first() .click({ timeout: options.timeoutMs }); await waitText(page, options, "整理新资料页", [ "选择资料用途", "添加原始资料", "Lime 开始整理", "当前先支持粘贴正文", "这里不再设置“默认使用”", "没有确认的资料不会自动用于创作", ]); { const text = await page.locator("body").innerText(); if ( text.includes("创作时是否默认使用") || text.includes("先保存原始资料") ) { throw new Error("整理页仍存在不能自洽的默认使用/保存入口"); } } await page .getByRole("button", { name: "内容运营", exact: true }) .click({ timeout: options.timeoutMs }); await page.getByLabel("资料名称").fill("Product E2E 内容运营验收资料"); await page .getByLabel("原始资料正文") .fill( [ "# Product E2E 内容运营验收资料", "", "- 栏目:每周二发布选题复盘,每周五发布案例拆解。", "- SOP:选题必须包含目标人群、表达角度、引用素材和风险边界。", "- 边界:没有来源的增长数据必须标记待确认,不能编造成事实。", ].join("\n"), ); await page .getByRole("button", { name: "Lime 开始整理", exact: true }) .click({ timeout: options.timeoutMs }); await waitText(page, options, "整理结果", [ "Product E2E 内容运营验收资料", "完整资料文档", "需要你确认的内容", "确认可用", ]); await assertNoLeak(page, "整理结果页"); if (consoleErrors.length > 0) { throw new Error( `console error: ${JSON.stringify(consoleErrors.slice(0, 5))}`, ); } console.log( JSON.stringify( { result: "passed", projectId, projectRoot, checked: [ "home-baseline", "knowledge-overview", "states-page", "review-confirm", "composer-select", "agent-save-to-knowledge", "organize-new-material", ], consoleErrors: consoleErrors.length, mockInvokes: mockMessages.length, mockMessages: mockMessages.slice(0, 10), }, null, 2, ), ); } finally { await context?.close().catch(() => {}); await browser?.close().catch(() => {}); } } run().catch((error) => { console.error(error instanceof Error ? error.stack || error.message : error); process.exit(1); });