#!/usr/bin/env node const { loginViaAuthForm } = require('./lib/browser-auth-login'); // Pi Lab browser smoke // 验证 Pi Lab 默认独立悬浮入口 + MNote-native drawer,不复用已退役 host 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 accountReady = await page .locator("#account") .first() .isVisible({ timeout: 4000 }) .catch(() => false); if (!accountReady) return; await loginViaAuthForm(page, { baseUrl: BASE, timeoutMs: UI_TIMEOUT_MS, gotoAuth: false }); await page .waitForURL((url) => !url.toString().includes("/auth"), { timeout: UI_TIMEOUT_MS }) .catch(() => null); } async function approveVisiblePiDialog(page) { const dialog = page.locator("[data-page-ai-pi-lab-ui-dialog='confirm']"); await dialog.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await page.locator("[data-page-ai-pi-lab-ui-submit]").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 context.addInitScript(() => { window.__MNOTE_PI_LAB_TEST__ = true; }); 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 legacyDrawer = 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)), panelInLegacyDrawer: Boolean(legacyDrawer && piPanel && legacyDrawer.contains(piPanel)), piDrawerHasIframe: Boolean(piDrawer && piDrawer.querySelector("iframe")), retiredHostTabInPi: 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.panelInLegacyDrawer, false, "Pi Lab panel must not be inside retired host drawer"); assert.equal(drawerEvidence.piDrawerHasIframe, false, "Pi Lab drawer must not iframe a second app"); assert.equal(drawerEvidence.retiredHostTabInPi, false, "Pi Lab drawer must not expose retired host 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/gpt-5.4-mini"), `default model should be omniroute/gpt-5.4-mini, 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"); 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 workspaceId = startStatusData.session?.workspaceId || startStatusData.workspaceId || undefined; 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"); } const autoEditResp = await page.request.post(`${BASE}/api/page-ai/pi/start`, { data: { sessionId, rootUri, workspaceId, pagePath, pageTitle: "Pi Lab browser smoke", permissionMode: "auto_edit", }, }); assert(autoEditResp.ok(), `auto-edit mode start should return HTTP OK, got ${autoEditResp.status()}`); const autoEditConfigResp = await page.request.post(`${BASE}/api/page-ai/pi/configure`, { data: { sessionId, permissionMode: "auto_edit", }, }); assert(autoEditConfigResp.ok(), `auto-edit configure should return HTTP OK, got ${autoEditConfigResp.status()}`); const autoEditConfig = await autoEditConfigResp.json(); assert.equal( autoEditConfig.session?.runtimePolicySnapshot?.permissionMode || autoEditConfig.session?.permissionMode, "auto_edit", `auto-edit mode should be configured, got ${JSON.stringify(autoEditConfig)}`, ); 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 auto-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()}`); await page.evaluate(() => { window.__mnotePiLabTest.emitRpcEvent({ type: "extension_ui_request", id: "browser_smoke_approval", method: "confirm", title: "审批 Pi 工具调用", message: "Browser smoke approval request", mnoteApproval: { approvalId: "browser_smoke_approval", toolName: "mnote.local_file.patch", paramsHash: "browser-smoke", }, }); }); const uiResponsePromise = page.waitForResponse( (res) => res.url().includes("/api/page-ai/pi/ui-response") && res.request().method() === "POST", { timeout: UI_TIMEOUT_MS }, ); const approvalBox = await page.locator("[data-page-ai-pi-lab-ui-dialog='confirm']").boundingBox(); const inputWrapBox = await page.locator(".wolai-page-ai-pi-lab-input-wrap").boundingBox(); assert( approvalBox && inputWrapBox && approvalBox.y + approvalBox.height <= inputWrapBox.y + 1, "approval dialog should be mounted above the input and must not cover the composer", ); await approveVisiblePiDialog(page); const uiResponse = await uiResponsePromise; assert(uiResponse.ok(), `UI response should return HTTP OK, got ${uiResponse.status()}`); const uiResponseBody = await uiResponse.json(); assert.equal(uiResponseBody.ok, true, "Pi approval dialog should confirm through composer-local UI"); console.log(" 5b. Composer-local approval dialog verified"); const receiptResp = await page.request.post(`${BASE}/api/page-ai/pi/tool-call`, { data: { sessionId, toolName: "mnote.tool_receipt.write", params: { diffSummary: "browser smoke synthetic diff", citations: [{ title: "LightRAG mock citation", source: "lightrag-mock" }], }, }, }); assert(receiptResp.ok(), `receipt tool call should return HTTP OK, got ${receiptResp.status()}`); const patchResp = await page.request.post(`${BASE}/api/page-ai/pi/tool-call`, { data: { sessionId, toolName: "mnote.local_file.patch", params: { rootUri, path: pagePath, operations: [{ op: "replace", old: "Browser Original", new: "Browser Patched" }], }, }, }); assert(patchResp.ok(), `patch tool call should return HTTP OK, got ${patchResp.status()}`); const patchPayload = await patchResp.json(); assert.equal(patchPayload.ok, true, `patch tool call should be allowed, got ${JSON.stringify(patchPayload)}`); assert(fs.readFileSync(path.join(browserRoot, pagePath), "utf8").includes("Browser Patched"), "browser smoke patch should update markdown file"); 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()}`); } await page.waitForTimeout(800); await page.waitForFunction(() => { const text = document.querySelector("[data-page-ai-pi-lab-receipts]")?.textContent || ""; return (text.includes("denied") || text.includes("approval required")) && text.includes("diff") && text.includes("mnote.local_file.patch") && text.includes("mnote.tool_receipt.write"); }, null, { timeout: UI_TIMEOUT_MS }); const replyMarker = `MNOTE_PI_BROWSER_OK_${Date.now()}`; await page.locator("[data-page-ai-pi-lab-input]").fill(`请只回复 ${replyMarker}`); await page.locator("[data-page-ai-pi-lab-btn-send]").click(); await page.waitForFunction((marker) => { return Array.from(document.querySelectorAll('[data-page-ai-pi-lab-message-role="assistant"]')) .some((node) => (node.textContent || "").includes(marker)); }, replyMarker, { timeout: Math.max(UI_TIMEOUT_MS, 45000) }); const abortButton = page.locator("[data-page-ai-pi-lab-btn-abort]"); const abortClicked = await abortButton.click({ timeout: 1200 }).then(() => true).catch(() => false); if (!abortClicked) { await page.evaluate(() => { window.__mnotePiLabTest.emitRpcEvent({ type: "response", command: "abort", stopReason: "aborted", queuedMessages: [], }); }); } 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((marker) => { const root = document.querySelector('[data-page-ai-pi-lab="drawer"]'); const text = root?.textContent || ""; const assistantText = Array.from(document.querySelectorAll('[data-page-ai-pi-lab-message-role="assistant"]')) .map((node) => node.textContent || "") .join("\n"); return { hasPromptSubmitted: text.includes(marker), hasPromptReply: assistantText.includes(marker), 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 || "", }; }, replyMarker); assert(runtimeEvidence.hasPromptSubmitted, "Pi Lab should show submitted prompt in the real Pi Rust run"); assert(runtimeEvidence.hasPromptReply, "Pi Lab should show a real assistant reply marker"); 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. Real Pi Rust send, abort, deny, citation, receipt and diff evidence visible"); 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, "gpt-5.4-mini", "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();