#!/usr/bin/env node "use strict"; const assert = require("node:assert/strict"); const fs = require("node:fs"); const http = require("node:http"); const net = require("node:net"); const os = require("node:os"); const path = require("node:path"); const { spawn } = require("node:child_process"); const { chromium } = require("playwright"); const ROOT = path.resolve(__dirname, ".."); const OUT_DIR = process.env.MNOTE_YUXI_SMOKE_OUTPUT_DIR || fs.mkdtempSync(path.join(ROOT, "tmp", "task561-yuxi-extensions-")); const TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 90_000); const CHROME = process.env.MNOTE_YUXI_CHROME || "/usr/bin/google-chrome"; const ACTOR = "mnote-e2e"; const PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!"; function pickPort() { return new Promise((resolve, reject) => { const server = net.createServer(); server.listen(0, "127.0.0.1", () => { const address = server.address(); const port = address && typeof address === "object" ? address.port : 0; server.close(() => resolve(port)); }); server.on("error", reject); }); } function waitForHttpOk(url, timeoutMs) { const deadline = Date.now() + timeoutMs; return new Promise((resolve, reject) => { const tick = () => { const request = http.get(url, (response) => { response.resume(); if (response.statusCode >= 200 && response.statusCode < 500) return resolve(); retry(); }); request.on("error", retry); request.setTimeout(1000, () => { request.destroy(); retry(); }); }; const retry = () => { if (Date.now() > deadline) return reject(new Error(`server_not_ready: ${url}`)); setTimeout(tick, 250); }; tick(); }); } async function signIn(context, baseUrl) { const response = await context.request.fetch(`${baseUrl}/api/auth`, { method: "POST", headers: { "content-type": "application/json" }, data: { action: "auth:signIn", args: { provider: "password", params: { account: ACTOR, password: PASSWORD, flow: "signIn" } } }, timeout: TIMEOUT_MS, }); const text = await response.text(); assert(response.ok(), `/api/auth 登录失败: ${response.status()} ${text.slice(0, 500)}`); const whoami = await context.request.fetch(`${baseUrl}/api/auth/whoami`, { timeout: TIMEOUT_MS }); return JSON.parse(await whoami.text()); } async function main() { fs.mkdirSync(OUT_DIR, { recursive: true }); const port = await pickPort(); const baseUrl = `http://127.0.0.1:${port}`; const server = spawn("cargo", ["run", "-q", "-p", "mnote-web", "--bin", "mnote-web"], { cwd: path.join(ROOT, "rust"), env: { ...process.env, MNOTE_WEB_BIND: `127.0.0.1:${port}`, MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}` }, stdio: ["ignore", "pipe", "pipe"], }); let stderr = ""; server.stderr.on("data", (chunk) => { stderr += chunk.toString(); }); const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task561-yuxi-")); let browser; const failures = []; function fail(label, detail) { failures.push({ label, detail }); console.error(`[FAIL] ${label}: ${detail}`); } try { await waitForHttpOk(`${baseUrl}/health`, TIMEOUT_MS); browser = await chromium.launch({ headless: process.env.HEADFUL !== "1", executablePath: fs.existsSync(CHROME) ? CHROME : undefined }); const context = await browser.newContext({ viewport: { width: 1440, height: 960 } }); const viewer = await signIn(context, baseUrl); const actorId = viewer.userId || ACTOR; const suffix = Date.now().toString(36); const workspaceId = `local-ws:${actorId}:task561-yuxi-${suffix}`; const rootUri = `file://${root}`; const relativePath = `Yuxi-Test-${suffix}.md`; const documentId = `local-md:${relativePath.replaceAll("/", "~2F")}`; fs.mkdirSync(path.join(root, ".mnote"), { recursive: true }); fs.writeFileSync(path.join(root, ".mnote", "workspace.json"), JSON.stringify({ workspaceId, ownerId: actorId }, null, 2), "utf8"); fs.writeFileSync(path.join(root, relativePath), `# Yuxi 7-61 Smoke Test\n\n${suffix}\n`, "utf8"); const page = await context.newPage(); const url = new URL(`${baseUrl}/documents/${encodeURIComponent(documentId)}`); url.searchParams.set("sourceKind", "local_folder"); url.searchParams.set("rootUri", rootUri); url.searchParams.set("workspaceId", workspaceId); await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: TIMEOUT_MS }); // ========== SCENARIO 1: 扩展 tab ========== console.log("=== Scenario 1: 扩展 tab ==="); const settingsTrigger = page.locator('[data-testid="wolai-page-settings-trigger"]'); await settingsTrigger.click({ timeout: TIMEOUT_MS }); await page.waitForSelector('[data-testid="wolai-page-settings-popover"]', { timeout: TIMEOUT_MS }); // Click extensions tab const extTab = page.locator('[data-page-settings-tab="extensions"]'); await extTab.click({ timeout: TIMEOUT_MS }); // Verify extensions panel is visible const extPanel = page.locator('[data-page-settings-panel="extensions"]'); await extPanel.waitFor({ state: "visible", timeout: TIMEOUT_MS }); const extVisible = await extPanel.isVisible(); if (!extVisible) fail("扩展面板不可见", "extensions panel hidden"); else console.log(" ✅ 扩展面板可见"); // Verify 4 sub-tabs const expectedSubTabs = ["知识库", "Skills", "MCP", "Agent 工具"]; for (const tabName of expectedSubTabs) { const tab = page.locator(`[data-extensions-subtab]`).filter({ hasText: tabName }); const visible = await tab.isVisible().catch(() => false); if (!visible) fail(`扩展子tab不可见: ${tabName}`, "sub-tab not visible"); else console.log(` ✅ 子tab: ${tabName}`); } // Click each sub-tab and verify panel shows for (const [i, tabName] of expectedSubTabs.entries()) { const panelKey = ["knowledge", "skills", "mcp", "tools"][i]; const tab = page.locator(`[data-extensions-subtab="${panelKey}"]`); await tab.click({ timeout: TIMEOUT_MS }); await page.waitForTimeout(500); const panel = page.locator(`[data-extensions-panel="${panelKey}"]`); const hiddenVal = await panel.evaluate(el => el.hidden).catch(() => true); if (hiddenVal === true) fail(`扩展子面板不可见: ${tabName}`, `${panelKey} panel hidden (el.hidden = ${hiddenVal})`); else console.log(` ✅ 子面板切换: ${tabName} (hidden=${hiddenVal})`); } // Take screenshot await page.screenshot({ path: path.join(OUT_DIR, "scenario1-extensions.png"), fullPage: false }); // ========== SCENARIO 2: 知识库资料源管理 + 检索测试 ========== console.log("=== Scenario 2: 知识库详情页 ==="); // Close settings first await page.keyboard.press("Escape"); await page.waitForTimeout(500); // Open knowledge-rag settings const ragTrigger = page.locator('[data-testid="mnote-knowledge-rag-settings-toggle"]'); const ragTriggerExists = await ragTrigger.count(); if (ragTriggerExists === 0) { // Try via settings extensions subpanel await settingsTrigger.click({ timeout: TIMEOUT_MS }); await extTab.click({ timeout: TIMEOUT_MS }); const knowledgeSubtab = page.locator('[data-extensions-subtab="knowledge"]'); await knowledgeSubtab.click({ timeout: TIMEOUT_MS }); await page.waitForTimeout(500); } else { await ragTrigger.click({ timeout: TIMEOUT_MS }); } // Check if knowledge-rag popover exists const ragPopover = page.locator('[data-testid="mnote-knowledge-rag-settings-popover"]'); const ragPopoverExists = await ragPopover.count(); if (ragPopoverExists > 0) { console.log(" ✅ 知识库设置弹窗已打开"); // Verify 3 tabs const expectedRagTabs = ["资料源管理", "检索测试", "检索参数"]; for (const tabName of expectedRagTabs) { const tab = page.locator('[data-kb-rag-tab]').filter({ hasText: tabName }); const visible = await tab.isVisible().catch(() => false); if (!visible) fail(`知识库tab不可见: ${tabName}`, "kb-rag tab not visible"); else console.log(` ✅ 知识库tab: ${tabName}`); } // SCENARIO 2a: 检索测试 const searchTab = page.locator('[data-kb-rag-tab="search"]'); await searchTab.click({ timeout: TIMEOUT_MS }); const searchPanel = page.locator('[data-kb-rag-panel="search"]'); await searchPanel.waitFor({ state: "visible", timeout: TIMEOUT_MS }); console.log(" ✅ 检索测试面板可见"); // Type search query const searchInput = page.locator('[data-kb-rag-search-input="true"]'); await searchInput.fill("test query", { timeout: TIMEOUT_MS }); const searchBtn = page.locator('[data-kb-rag-action="search"]'); await searchBtn.click({ timeout: TIMEOUT_MS }); // Wait for results (loading or results) await page.waitForTimeout(2000); const resultsArea = page.locator('[data-kb-rag-search-results="true"]'); const resultsText = await resultsArea.textContent().catch(() => ""); console.log(` 📝 检索结果: ${resultsText.slice(0, 200)}`); // SCENARIO 2b: 检索参数 const paramsTab = page.locator('[data-kb-rag-tab="params"]'); await paramsTab.click({ timeout: TIMEOUT_MS }); const paramsPanel = page.locator('[data-kb-rag-panel="params"]'); await paramsPanel.waitFor({ state: "visible", timeout: TIMEOUT_MS }); console.log(" ✅ 检索参数面板可见"); // Verify param form elements const modeSelect = page.locator('[data-kb-rag-param="mode"]'); const modeVisible = await modeSelect.isVisible().catch(() => false); if (!modeVisible) fail("检索模式下拉不可见", "mode select hidden"); else console.log(" ✅ 检索模式下拉"); const topKInput = page.locator('[data-kb-rag-param="top_k"]'); const topKVisible = await topKInput.isVisible().catch(() => false); if (!topKVisible) fail("Top-K输入不可见", "top_k input hidden"); else console.log(" ✅ Top-K输入"); const thresholdRange = page.locator('[data-kb-rag-param="similarity_threshold"]'); const thresholdVisible = await thresholdRange.isVisible().catch(() => false); if (!thresholdVisible) fail("相似度阈值不可见", "threshold range hidden"); else console.log(" ✅ 相似度阈值滑块"); // Test save params await topKInput.fill("20", { timeout: TIMEOUT_MS }); const saveBtn = page.locator('[data-kb-rag-action="save-params"]'); await saveBtn.click({ timeout: TIMEOUT_MS }); await page.waitForTimeout(500); console.log(" ✅ 检索参数保存"); await page.screenshot({ path: path.join(OUT_DIR, "scenario2-knowledge-rag.png"), fullPage: false }); } else { console.log(" ⚠️ 知识库弹窗未找到,可能需先配置LightRAG"); } // Close popovers await page.keyboard.press("Escape"); await page.waitForTimeout(500); // ========== SCENARIO 3: 仪表盘 ========== console.log("=== Scenario 3: 仪表盘 ==="); await settingsTrigger.click({ timeout: TIMEOUT_MS }); await page.waitForSelector('[data-testid="wolai-page-settings-popover"]', { timeout: TIMEOUT_MS }); const dashTab = page.locator('[data-page-settings-tab="dashboard"]'); await dashTab.click({ timeout: TIMEOUT_MS }); const dashPanel = page.locator('[data-page-settings-panel="dashboard"]'); await dashPanel.waitFor({ state: "visible", timeout: TIMEOUT_MS }); console.log(" ✅ 仪表盘面板可见"); // Verify dashboard cards const expectedCards = ["知识库", "Skills", "MCP", "Agent Runs"]; for (const cardTitle of expectedCards) { const card = page.locator('.mnote-dashboard-card-title').filter({ hasText: cardTitle }); const visible = await card.isVisible().catch(() => false); if (!visible) fail(`仪表盘卡片不可见: ${cardTitle}`, "dashboard card not visible"); else console.log(` ✅ 仪表盘卡片: ${cardTitle}`); } // Verify refresh button const refreshBtn = page.locator('[data-dashboard-action="refresh"]'); const refreshVisible = await refreshBtn.isVisible().catch(() => false); if (!refreshVisible) fail("刷新按钮不可见", "refresh button hidden"); else console.log(" ✅ 刷新按钮"); await page.screenshot({ path: path.join(OUT_DIR, "scenario3-dashboard.png"), fullPage: false }); // ========== SCENARIO 4: 刷新后扩展tab不丢失 ========== console.log("=== Scenario 4: 刷新后tab状态 ==="); await page.reload({ waitUntil: "domcontentloaded", timeout: TIMEOUT_MS }); await page.waitForSelector('[data-testid="wolai-page-settings-trigger"]', { timeout: TIMEOUT_MS }); await settingsTrigger.click({ timeout: TIMEOUT_MS }); await page.waitForSelector('[data-testid="wolai-page-settings-popover"]', { timeout: TIMEOUT_MS }); // Check extensions tab still there const extTabAfter = page.locator('[data-page-settings-tab="extensions"]'); const extTabVisible = await extTabAfter.isVisible().catch(() => false); if (!extTabVisible) fail("刷新后扩展tab不可见", "extensions tab gone after refresh"); else console.log(" ✅ 刷新后扩展tab存在"); const dashTabAfter = page.locator('[data-page-settings-tab="dashboard"]'); const dashTabVisible = await dashTabAfter.isVisible().catch(() => false); if (!dashTabVisible) fail("刷新后仪表盘tab不可见", "dashboard tab gone after refresh"); else console.log(" ✅ 刷新后仪表盘tab存在"); // ========== RESULTS ========== const result = { ok: failures.length === 0, failures, baseUrl, outDir: OUT_DIR, workspaceId, documentId, summary: failures.length === 0 ? "全部 4 个场景验证通过:扩展tab/知识库详情/仪表盘/刷新" : `${failures.length} 个失败: ${failures.map(f => f.label).join(", ")}`, }; fs.writeFileSync(path.join(OUT_DIR, "result.json"), JSON.stringify(result, null, 2), "utf8"); console.log("\n=== RESULT ==="); console.log(JSON.stringify(result, null, 2)); await context.close().catch(() => undefined); } catch (error) { fs.writeFileSync(path.join(OUT_DIR, "failure.json"), JSON.stringify({ ok: false, error: String(error && error.stack || error), stderr }, null, 2), "utf8"); throw error; } finally { if (browser) await browser.close().catch(() => undefined); server.kill("SIGTERM"); await new Promise((resolve) => server.once("exit", resolve)); if (process.env.MNOTE_KEEP_YUXI_ROOT !== "1") fs.rmSync(root, { recursive: true, force: true }); } } main().catch((error) => { console.error(error && error.stack ? error.stack : error); process.exit(1); });