#!/usr/bin/env node // Pi Lab browser smoke // 验证 Pi Lab 默认独立悬浮入口 + MNote-native drawer,不复用 OpenHub drawer/provider tab/iframe。 // 需要 mnote-web 已在运行;MNOTE_PAGE_AI_PI_LAB 默认开启,设为 0 时才强制关闭。 "use strict"; const assert = require("node:assert"); const fs = require("node:fs"); const path = require("node:path"); const { chromium } = require("playwright"); const BASE = process.env.MNOTE_PI_LAB_BASE || "http://127.0.0.1:3000"; const AUTH_COOKIE = process.env.MNOTE_PI_LAB_AUTH || ""; const AUTH_HEADER = process.env.MNOTE_PI_LAB_AUTH_HEADER || ""; const TARGET_URL = process.env.MNOTE_PI_LAB_BROWSER_URL || `${BASE}/`; const UI_TIMEOUT_MS = parseInt(process.env.UI_TIMEOUT_MS || "20000", 10); const SCREENSHOT = process.env.MNOTE_PI_LAB_SCREENSHOT || path.join( __dirname, "..", "tmp", `page-ai-pi-lab-drawer-${new Date().toISOString().replace(/[:.]/g, "-")}.png`, ); const CHROMIUM_EXECUTABLE = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE || (fs.existsSync("/usr/bin/chromium-browser") ? "/usr/bin/chromium-browser" : "") || (fs.existsSync("/usr/bin/chromium") ? "/usr/bin/chromium" : "") || (fs.existsSync("/usr/bin/google-chrome") ? "/usr/bin/google-chrome" : ""); async function addAuth(context) { if (AUTH_HEADER) await context.setExtraHTTPHeaders({ Authorization: AUTH_HEADER }); if (!AUTH_COOKIE) return; const cookies = AUTH_COOKIE.split(";").map((c) => { const [name, ...rest] = c.trim().split("="); return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" }; }); await context.addCookies(cookies); } async function quickLoginIfNeeded(page) { await page.goto(`${BASE}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" }); if (!(await quickLoginButton.isVisible({ timeout: 4000 }).catch(() => false))) return; await Promise.all([ page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: UI_TIMEOUT_MS }).catch(() => null), quickLoginButton.click(), ]); } async function main() { console.log(`\n🧪 Pi Lab browser smoke (base: ${BASE})\n`); let browserRoot = process.env.MNOTE_PI_LAB_BROWSER_ROOT || "/tmp/mnote-pi-lab-browser-smoke"; const pagePath = "__pi_lab_browser_smoke.md"; let rootUri = `file://${browserRoot}`; fs.mkdirSync(browserRoot, { recursive: true }); fs.writeFileSync(path.join(browserRoot, pagePath), "# Pi Lab browser smoke\n\nBrowser Original\n", "utf8"); const browser = await chromium.launch({ headless: process.env.MNOTE_PI_LAB_HEADED === "1" ? false : true, executablePath: CHROMIUM_EXECUTABLE || undefined, }); const context = await browser.newContext({ viewport: { width: 1440, height: 960 } }); await addAuth(context); const page = await context.newPage(); try { await quickLoginIfNeeded(page); const response = await page.goto(TARGET_URL, { waitUntil: "commit", timeout: UI_TIMEOUT_MS }); const status = response ? response.status() : -1; console.log(` 1. MNote shell status: ${status}, url: ${page.url()}`); assert(status >= 200 && status < 400, `MNote shell should load, got ${status}`); await page.waitForFunction(() => typeof window.createSidebarPageAiPiLabRuntime === "function", null, { timeout: UI_TIMEOUT_MS, }); await page.locator("[data-page-ai-pi-lab-launcher]").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); console.log(" 2. Pi Lab floating launcher visible"); await page.locator("[data-page-ai-pi-lab-launcher]").click(); await page.locator('[data-page-ai-pi-lab="drawer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await page.locator('[data-page-ai-pi-lab="panel"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); console.log(" 3. Pi Lab independent drawer visible"); const drawerEvidence = await page.evaluate(() => { const openHubDrawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]'); const piDrawer = document.querySelector('[data-page-ai-pi-lab="drawer"]'); const piPanel = document.querySelector('[data-page-ai-pi-lab="panel"]'); const diagnostics = document.querySelector("[data-page-ai-pi-lab-diagnostics]"); return { piDrawerVisible: Boolean(piDrawer && getComputedStyle(piDrawer).display !== "none"), panelInPiDrawer: Boolean(piDrawer && piPanel && piDrawer.contains(piPanel)), panelInOpenHubDrawer: Boolean(openHubDrawer && piPanel && openHubDrawer.contains(piPanel)), piDrawerHasIframe: Boolean(piDrawer && piDrawer.querySelector("iframe")), openHubTabInPi: Boolean(piDrawer && piDrawer.querySelector("[data-page-ai-pi-lab-openhub-tab]")), contextChips: document.querySelectorAll("[data-page-ai-pi-lab-context-strip] [data-page-ai-pi-lab-context]").length, hasCurrentPageContext: Boolean(document.querySelector("[data-page-ai-pi-lab-current-page]")), hasChangedFilesContext: Boolean(document.querySelector("[data-page-ai-pi-lab-changed-files]")), diagnosticsClosed: diagnostics ? diagnostics.open === false : false, model: document.querySelector("[data-page-ai-pi-lab-model-chip]")?.textContent || "", status: document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent || "", toolCount: document.querySelectorAll("[data-page-ai-pi-lab-tool-list] .wolai-page-ai-pi-lab-tool-pill").length, }; }); assert(drawerEvidence.piDrawerVisible, "Pi Lab independent drawer should be visible"); assert(drawerEvidence.panelInPiDrawer, "Pi Lab panel should be mounted inside independent Pi drawer"); assert.equal(drawerEvidence.panelInOpenHubDrawer, false, "Pi Lab panel must not be inside OpenHub drawer"); assert.equal(drawerEvidence.piDrawerHasIframe, false, "Pi Lab drawer must not iframe a second app"); assert.equal(drawerEvidence.openHubTabInPi, false, "Pi Lab drawer must not expose OpenHub provider tab"); assert(drawerEvidence.contextChips >= 3, `expected context strip chips, got ${drawerEvidence.contextChips}`); assert(drawerEvidence.hasCurrentPageContext, "context strip should retain current page binding"); assert(drawerEvidence.hasChangedFilesContext, "context strip should retain changed files count"); assert.equal(drawerEvidence.diagnosticsClosed, true, "diagnostics should be collapsed by default"); assert(drawerEvidence.model.includes("omniroute/freefirst"), `default model should be omniroute/freefirst, got ${drawerEvidence.model}`); assert(drawerEvidence.toolCount >= 5, `expected MNote tool rail, got ${drawerEvidence.toolCount}`); console.log(" 4. Independent drawer, context strip and default model verified"); const startButton = page.locator("[data-page-ai-pi-lab-btn-start]"); if (await startButton.isVisible().catch(() => false)) { await startButton.click(); } await page.waitForFunction(() => document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent?.includes("ready"), null, { timeout: UI_TIMEOUT_MS, }); const startStatusResp = await page.request.get(`${BASE}/api/page-ai/pi/status`); assert(startStatusResp.ok(), `status after UI start should be OK, got ${startStatusResp.status()}`); const startStatusData = await startStatusResp.json(); const sessionId = startStatusData.sessionId || startStatusData.session?.sessionId; assert(sessionId, "UI start should create sessionId"); const firstAllowedRoot = startStatusData.session?.allowedRootsSnapshot?.roots?.[0] || null; if (firstAllowedRoot?.rootPath) { browserRoot = String(firstAllowedRoot.rootPath); rootUri = String(firstAllowedRoot.rootUri || `file://${browserRoot}`); fs.mkdirSync(browserRoot, { recursive: true }); fs.writeFileSync(path.join(browserRoot, pagePath), "# Pi Lab browser smoke\n\nBrowser Original\n", "utf8"); } await page.waitForTimeout(800); await page.locator("[data-page-ai-pi-lab-input]").fill("Pi Lab browser smoke prompt"); await page.waitForFunction(() => { const button = document.querySelector("[data-page-ai-pi-lab-btn-send]"); return button && !button.disabled; }, null, { timeout: UI_TIMEOUT_MS }); console.log(" 5. Runtime started and composer is interactive"); const deniedResp = await page.request.post(`${BASE}/api/page-ai/pi/tool-call`, { data: { sessionId, toolName: "mnote.local_file.read", params: { path: "/etc/hosts" } }, }); assert(deniedResp.ok(), `deny tool call should return HTTP OK, got ${deniedResp.status()}`); const patchResp = await page.request.post(`${BASE}/api/page-ai/pi/tool-call`, { data: { sessionId, toolName: "mnote.local_file.patch", params: { path: path.join(browserRoot, pagePath), operations: [{ op: "replace", old: "Browser Original", new: "Browser Patched" }], }, }, }); assert(patchResp.ok(), `patch tool call should return HTTP OK, got ${patchResp.status()}`); const ragResp = await page.request.post(`${BASE}/api/page-ai/pi/tool-call`, { data: { sessionId, toolName: "mnote.knowledge_rag.query", params: { rootUri, query: "Pi Lab browser smoke", topK: 1 } }, timeout: 8000, }).catch((error) => ({ ok: () => false, status: () => `timeout: ${error.message}` })); if (!ragResp.ok()) { console.warn(` ! LightRAG direct tool call skipped in browser smoke: ${ragResp.status()}`); } const receiptResp = await page.request.post(`${BASE}/api/page-ai/pi/tool-call`, { data: { sessionId, toolName: "mnote.tool_receipt.write", params: { citations: [{ title: "LightRAG mock citation", source: "lightrag-mock" }], }, }, }); assert(receiptResp.ok(), `receipt tool call should return HTTP OK, got ${receiptResp.status()}`); await page.waitForTimeout(800); await page.waitForFunction(() => { const text = document.querySelector("[data-page-ai-pi-lab-receipts]")?.textContent || ""; return text.includes("denied") && text.includes("diff") && text.includes("mnote.tool_receipt.write"); }, null, { timeout: UI_TIMEOUT_MS }); await page.locator("[data-page-ai-pi-lab-input]").fill("Pi Lab browser smoke prompt"); await page.locator("[data-page-ai-pi-lab-btn-send]").click(); await page.waitForFunction(() => { const root = document.querySelector('[data-page-ai-pi-lab="drawer"]'); const text = root?.textContent || ""; return text.includes("[Pi Lab mock] prompt accepted") && text.includes("LightRAG mock citation") && text.includes("patch"); }, null, { timeout: UI_TIMEOUT_MS }); await page.locator("[data-page-ai-pi-lab-btn-abort]").click(); await page.waitForFunction(() => (document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent || "").includes("aborted"), null, { timeout: UI_TIMEOUT_MS, }); const runtimeEvidence = await page.evaluate(() => { const root = document.querySelector('[data-page-ai-pi-lab="drawer"]'); const text = root?.textContent || ""; return { hasPromptReply: text.includes("[Pi Lab mock] prompt accepted"), hasAborted: text.includes("aborted") || text.includes("已中止"), hasDeny: text.includes("denied"), hasReceipt: text.includes("mnote.local_file.patch") || text.includes("mnote.tool_receipt.write"), hasCitation: text.includes("LightRAG mock citation") || text.includes("citations 1"), hasDiff: text.includes("patch") || text.includes("diff"), changedFiles: document.querySelector("[data-page-ai-pi-lab-changed-files]")?.textContent || "", }; }); assert(runtimeEvidence.hasPromptReply, "Pi Lab should show streamed mock assistant reply"); assert(runtimeEvidence.hasAborted, "Pi Lab should show abort state"); assert(runtimeEvidence.hasDeny, "Pi Lab should show allowed-roots deny receipt"); assert(runtimeEvidence.hasReceipt, "Pi Lab should show tool receipt"); assert(runtimeEvidence.hasCitation, "Pi Lab should show LightRAG citation evidence"); assert(runtimeEvidence.hasDiff, "Pi Lab should show diff/changed file evidence"); assert.notEqual(runtimeEvidence.changedFiles, "0", "changed files chip should be non-zero"); console.log(" 6. Stream, abort, deny, citation, receipt and diff evidence visible"); const openHubEvidence = await page.evaluate(async () => { const api = window.__mnoteSidebarPageAiRuntime; if (api && typeof api.openPageAiDrawer === "function") { api.openPageAiDrawer(); } await new Promise((resolve) => setTimeout(resolve, 400)); const openHubDrawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]'); const openHubFrame = document.querySelector("[data-page-ai-openhub-iframe]"); const piDrawer = document.querySelector('[data-page-ai-pi-lab="drawer"]'); return { openHubApiExists: Boolean(api && typeof api.openPageAiDrawer === "function"), openHubDrawerExists: Boolean(openHubDrawer), openHubHost: openHubDrawer?.getAttribute("data-page-ai-openhub-host") || "", openHubFrameExists: Boolean(openHubFrame), piStillIndependent: Boolean(piDrawer && openHubDrawer && !openHubDrawer.contains(piDrawer)), }; }); assert(openHubEvidence.openHubApiExists, "OpenHub drawer API should still exist"); assert(openHubEvidence.openHubDrawerExists, "OpenHub drawer should still open independently"); assert.equal(openHubEvidence.openHubHost, "true", "OpenHub drawer should still be the default host"); assert(openHubEvidence.openHubFrameExists, "OpenHub iframe should still exist outside Pi Lab"); assert(openHubEvidence.piStillIndependent, "Pi Lab drawer should remain outside OpenHub drawer"); console.log(" 7. OpenHub default drawer still works independently"); fs.mkdirSync(path.dirname(SCREENSHOT), { recursive: true }); await page.locator('[data-page-ai-pi-lab="drawer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await page.screenshot({ path: SCREENSHOT, fullPage: false }); console.log(` 8. Screenshot: ${SCREENSHOT}`); const statusResp = await page.request.get(`${BASE}/api/page-ai/pi/status`); assert(statusResp.ok(), `status API should be OK, got ${statusResp.status()}`); const statusData = await statusResp.json(); assert.equal(statusData.enabled, true, "Pi Lab status should be enabled in this smoke"); assert.equal(statusData.defaultModelProvider, "omniroute", "status default provider"); assert.equal(statusData.defaultModelId, "freefirst", "status default model"); console.log(" 9. Status API enabled and default model verified"); console.log("\n✅ Pi Lab browser smoke passed\n"); } catch (err) { console.error(`\n❌ Pi Lab browser smoke failed: ${err.message}`); process.exitCode = 1; } finally { await browser.close(); } } main();