#!/usr/bin/env node // Pi Lab RPC browser smoke // 验证真实 Pi RPC + Omniroute/freefirst 在 MNote-native Pi Lab 抽屉中的可见 stream。 // 需要 mnote-web 已以 MNOTE_PAGE_AI_PI_LAB_RUNTIME=rpc 启动,并配置 MNOTE_PAGE_AI_PI_BIN / Omniroute key。 "use strict"; const assert = require("node:assert"); const fs = require("node:fs"); const os = require("node:os"); 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 = process.env.MNOTE_PI_LAB_AUTH || "Bearer pi-lab-smoke"; const ROOT = process.env.MNOTE_PI_LAB_BROWSER_ROOT || fs.mkdtempSync(path.join(os.tmpdir(), "mnote-pi-rpc-browser-")); const UI_TIMEOUT_MS = Number.parseInt(process.env.UI_TIMEOUT_MS || "45000", 10); const MARKER = process.env.MNOTE_PI_LAB_RPC_MARKER || `REAL_PI_BROWSER_OK_${Date.now()}`; const SCREENSHOT = process.env.MNOTE_PI_LAB_SCREENSHOT || path.join( __dirname, "..", "tmp", `page-ai-pi-lab-rpc-browser-${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" : ""); function authHeaders() { if (!AUTH) return {}; if (AUTH.toLowerCase().startsWith("bearer ")) return { Authorization: AUTH }; return { Cookie: AUTH }; } function pathMatchesPage(value, expectedPagePath) { const normalized = String(value || "").replace(/\\/g, "/"); return normalized === expectedPagePath || normalized.endsWith(`/${expectedPagePath}`); } function readSessionEvidence(sessionDir) { const pending = [sessionDir]; const files = []; while (pending.length) { const current = pending.pop(); if (!current || !fs.existsSync(current)) continue; for (const entry of fs.readdirSync(current, { withFileTypes: true })) { const target = path.join(current, entry.name); if (entry.isDirectory()) pending.push(target); else if (entry.isFile() && entry.name.endsWith(".jsonl")) files.push(target); } } return files.sort().map((file) => fs.readFileSync(file, "utf8")).join("\n"); } async function addAuth(context) { const headers = authHeaders(); if (headers.Authorization) await context.setExtraHTTPHeaders({ Authorization: headers.Authorization }); if (!headers.Cookie) return; const cookies = headers.Cookie.split(";").map((cookie) => { const [name, ...rest] = cookie.trim().split("="); return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" }; }); await context.addCookies(cookies); } async function quickLoginIfNeeded(page) { const authResponse = await page.goto(`${BASE}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); if (!authResponse || authResponse.status() >= 400) return; 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() { let browserRoot = ROOT; fs.mkdirSync(browserRoot, { recursive: true }); const pagePath = "__pi_lab_rpc_browser_smoke.md"; let pageFile = path.join(browserRoot, pagePath); fs.writeFileSync(pageFile, "# Pi Lab RPC browser smoke\n\nBrowser RPC Original\n", "utf8"); let rootUri = `file://${browserRoot}`; const documentId = `local-md:${pagePath}`; console.log(`\n🧪 Pi Lab RPC browser smoke (base: ${BASE}, root: ${ROOT})`); console.log(` marker: ${MARKER}\n`); 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(); const consoleErrors = []; page.on("console", (message) => { if (["error", "warning"].includes(message.type())) { consoleErrors.push(`${message.type()}: ${message.text()}`); } }); page.on("pageerror", (error) => consoleErrors.push(`pageerror: ${error.message}`)); try { const statusBefore = await page.request.get(`${BASE}/api/page-ai/pi/status`, { headers: authHeaders() }); assert(statusBefore.ok(), `status before browser should be OK, got ${statusBefore.status()}`); const statusBeforeJson = await statusBefore.json(); assert.equal(statusBeforeJson.enabled, true, "Pi Lab must be enabled"); assert.equal(statusBeforeJson.runtimeMode, "rpc", `runtimeMode must be rpc, got ${statusBeforeJson.runtimeMode}`); assert.equal(statusBeforeJson.defaultModelProvider, "omniroute", "default provider must be omniroute"); assert.equal(statusBeforeJson.defaultModelId, "freefirst", "default model must be freefirst"); console.log(" ✅ status reports rpc + omniroute/freefirst"); await quickLoginIfNeeded(page); if (!process.env.MNOTE_PI_LAB_BROWSER_URL) { const defaultE2eRoot = "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space"; if (fs.existsSync(defaultE2eRoot)) { browserRoot = defaultE2eRoot; rootUri = `file://${browserRoot}`; pageFile = path.join(browserRoot, pagePath); fs.mkdirSync(browserRoot, { recursive: true }); fs.writeFileSync(pageFile, "# Pi Lab RPC browser smoke\n\nBrowser RPC Original\n", "utf8"); } const loginStatus = await page.request.get(`${BASE}/api/page-ai/pi/status`); if (loginStatus.ok()) { const loginStatusJson = await loginStatus.json(); const firstAllowedRoot = loginStatusJson.session?.allowedRootsSnapshot?.roots?.[0] || loginStatusJson.allowedRootsSnapshot?.roots?.[0] || null; if (!fs.existsSync(defaultE2eRoot) && firstAllowedRoot?.rootPath) { browserRoot = String(firstAllowedRoot.rootPath); rootUri = String(firstAllowedRoot.rootUri || `file://${browserRoot}`); pageFile = path.join(browserRoot, pagePath); fs.mkdirSync(browserRoot, { recursive: true }); fs.writeFileSync(pageFile, "# Pi Lab RPC browser smoke\n\nBrowser RPC Original\n", "utf8"); } } } const targetUrl = process.env.MNOTE_PI_LAB_BROWSER_URL || `${BASE}/documents/${documentId}?sourceKind=local_folder&rootUri=${encodeURIComponent(rootUri)}`; const response = await page.goto(targetUrl, { waitUntil: "commit", timeout: UI_TIMEOUT_MS }); assert(response && response.status() >= 200 && response.status() < 400, `MNote shell load failed: ${response && response.status()}`); await page.locator(".ProseMirror").first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }).catch(() => null); 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 }); 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 }); console.log(" ✅ independent Pi Lab launcher and drawer visible"); await page.waitForFunction(() => { const text = document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent || ""; return text.includes("ready"); }, null, { timeout: UI_TIMEOUT_MS }); const statusAfterStart = await page.request.get(`${BASE}/api/page-ai/pi/status`, { headers: authHeaders() }); const statusAfterStartJson = await statusAfterStart.json(); const activeSession = statusAfterStartJson.session || {}; const sessionId = activeSession.sessionId || statusAfterStartJson.sessionId; assert(sessionId, "start should create sessionId"); assert(activeSession.runtimePid || statusAfterStartJson.pid, "RPC start should expose runtime pid"); assert(pathMatchesPage(activeSession.pagePath, pagePath), `session pagePath should bind current page when new session starts, got ${activeSession.pagePath}`); const firstAllowedRoot = activeSession.allowedRootsSnapshot?.roots?.[0] || null; if (firstAllowedRoot?.rootPath) { browserRoot = String(firstAllowedRoot.rootPath); rootUri = String(firstAllowedRoot.rootUri || `file://${browserRoot}`); pageFile = path.join(browserRoot, pagePath); fs.mkdirSync(browserRoot, { recursive: true }); fs.writeFileSync(pageFile, "# Pi Lab RPC browser smoke\n\nBrowser RPC Original\n", "utf8"); } console.log(` ✅ Pi RPC runtime started, session=${sessionId}, pid=${activeSession.runtimePid || statusAfterStartJson.pid}`); const currentPageChip = await page.locator("[data-page-ai-pi-lab-current-page]").textContent(); assert(currentPageChip && !/未绑定/.test(currentPageChip), `current page chip should be bound, got ${currentPageChip}`); const editor = page.locator(".ProseMirror").first(); if (await editor.isVisible({ timeout: 5000 }).catch(() => false)) { await page.evaluate(() => { const root = document.querySelector(".ProseMirror"); if (!root) return; const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); let node = null; while ((node = walker.nextNode())) { const index = String(node.textContent || "").indexOf("Browser RPC Original"); if (index >= 0) { const range = document.createRange(); range.setStart(node, index); range.setEnd(node, index + "Browser RPC Original".length); const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); break; } } }); const selectionDetected = await page.waitForFunction(() => { const text = document.querySelector("[data-page-ai-pi-lab-selection]")?.textContent || ""; return text.includes("已选中"); }, null, { timeout: 5000 }).then(() => true).catch(() => false); const selectionButton = page.locator('[data-page-ai-pi-lab-quick="selection"]'); if (selectionDetected && await selectionButton.isVisible({ timeout: 2000 }).catch(() => false)) { await selectionButton.click(); await page.waitForFunction(() => { return document.querySelector('[data-page-ai-pi-lab-quick="selection"]')?.getAttribute("aria-pressed") === "true"; }, null, { timeout: UI_TIMEOUT_MS }); console.log(" ✅ selection quick action enables live tiptap selection context"); } } const currentPageButton = page.locator('[data-page-ai-pi-lab-quick="read-page"]'); await currentPageButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await currentPageButton.click(); await page.waitForFunction(() => { return document.querySelector('[data-page-ai-pi-lab-quick="read-page"]')?.getAttribute("aria-pressed") === "true"; }, null, { timeout: UI_TIMEOUT_MS }); const currentPageMenuAction = page.locator('[data-page-ai-pi-lab-menu-action="read-page"]'); assert.equal( await currentPageMenuAction.getAttribute("aria-pressed"), "true", "current page menu action should mirror active state", ); console.log(" ✅ clicked 使用当前页 and enabled current-page context"); const denyResp = await page.request.post(`${BASE}/api/page-ai/pi/tool-call`, { headers: authHeaders(), data: { sessionId, toolName: "mnote.local_file.read", params: { path: "/etc/hosts" } }, }); assert(denyResp.ok(), `deny tool call HTTP ${denyResp.status()}`); const denyJson = await denyResp.json(); assert.equal(denyJson.ok, false, "out-of-root read must be denied"); const patchResp = await page.request.post(`${BASE}/api/page-ai/pi/tool-call`, { headers: authHeaders(), data: { sessionId, toolName: "mnote.local_file.patch", params: { path: pageFile, operations: [{ op: "replace", old: "Browser RPC Original", new: "Browser RPC Patched" }], }, }, }); assert(patchResp.ok(), `patch tool call HTTP ${patchResp.status()}`); const patchJson = await patchResp.json(); assert.equal(patchJson.ok, true, "patch should be allowed"); assert.equal(patchJson.result.polling, false, "patch must not request polling"); assert(String(patchJson.result.refresh || "").includes("watcher"), "patch should declare watcher refresh"); assert(fs.readFileSync(pageFile, "utf8").includes("Browser RPC Patched"), "patch should update markdown file"); if (await editor.isVisible({ timeout: 1000 }).catch(() => false)) { await page.waitForFunction(() => { const editorNode = document.querySelector(".ProseMirror"); return (editorNode?.textContent || "").includes("Browser RPC Patched"); }, null, { timeout: UI_TIMEOUT_MS }); } console.log(" ✅ allowed-roots deny and markdown patch receipt exercised"); await page.waitForFunction(() => { const text = document.querySelector("[data-page-ai-pi-lab-receipts]")?.textContent || ""; return text.includes("denied") && text.includes("diff"); }, null, { timeout: UI_TIMEOUT_MS }); await page.locator("[data-page-ai-pi-lab-input]").fill( `必须调用 mnote_current_page_read。工具结果 content 包含 "Browser RPC Patched" 后,只回复 ${MARKER},不要解释。`, ); await page.locator("[data-page-ai-pi-lab-btn-send]").click(); const assistantMarker = page .locator( '[data-page-ai-pi-lab-message-role="assistant"] .wolai-page-ai-pi-lab-text, ' + '[data-page-ai-pi-lab-message-role="assistant"] .wolai-page-ai-pi-lab-markdown', ) .filter({ hasText: MARKER }) .last(); await assistantMarker.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); const visibleText = await assistantMarker.textContent(); assert.equal(visibleText.trim(), MARKER, "visible assistant bubble should equal marker"); assert(!visibleText.includes("thinking_delta"), "provider thinking event name must not be visible"); assert(!visibleText.includes("我们被问到"), "provider reasoning text must not leak into final visible reply"); const sessionEvidence = readSessionEvidence(activeSession.piSessionDir); assert(sessionEvidence.includes('"toolName":"mnote_current_page_read"'), "session should record mnote_current_page_read"); assert(sessionEvidence.includes('"transport":"pi-rust-native-fs"'), "session should record Pi Rust native fs transport"); const toolTimelines = await page.locator("[data-page-ai-pi-lab-tool-timeline], [data-page-ai-pi-tool-timeline]").all(); for (const timeline of toolTimelines) { await timeline.evaluate((node) => { node.open = true; }).catch(() => {}); } console.log(" ✅ real browser conversation used mnote_current_page_read via pi-rust-native-fs"); fs.mkdirSync(path.dirname(SCREENSHOT), { recursive: true }); await page.screenshot({ path: SCREENSHOT, fullPage: false }); console.log(` ✅ screenshot: ${SCREENSHOT}`); const abortResp = await page.request.post(`${BASE}/api/page-ai/pi/abort`, { headers: authHeaders(), data: { sessionId }, }); assert(abortResp.ok(), `abort HTTP ${abortResp.status()}`); await page.waitForFunction(() => { const text = document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent || ""; return text.includes("aborted"); }, null, { timeout: UI_TIMEOUT_MS }); console.log(" ✅ abort reflected in UI state"); const domEvidence = await page.evaluate(() => { const openHubDrawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]'); const piDrawer = document.querySelector('[data-page-ai-pi-lab="drawer"]'); return { piDrawerVisible: Boolean(piDrawer && getComputedStyle(piDrawer).display !== "none"), piDrawerHasIframe: Boolean(piDrawer && piDrawer.querySelector("iframe")), piInsideOpenHub: Boolean(openHubDrawer && piDrawer && openHubDrawer.contains(piDrawer)), model: document.querySelector("[data-page-ai-pi-lab-model-chip]")?.textContent || "", }; }); assert(domEvidence.piDrawerVisible, "Pi drawer should remain visible"); assert.equal(domEvidence.piDrawerHasIframe, false, "Pi drawer must not iframe a second app"); assert.equal(domEvidence.piInsideOpenHub, false, "Pi drawer must not be inside OpenHub drawer"); assert(domEvidence.model.includes("omniroute/freefirst"), `model chip mismatch: ${domEvidence.model}`); console.log(" ✅ Pi Lab remains native, independent and non-iframe"); const severe = consoleErrors.filter((entry) => /Failed to load module script|MIME type|Uncaught|TypeError|ReferenceError/i.test(entry)); assert.equal(severe.length, 0, `severe console errors: ${severe.join(" | ")}`); console.log("\n✅ Pi Lab RPC browser smoke passed\n"); } catch (error) { console.error(`\n❌ Pi Lab RPC browser smoke failed: ${error.message}`); if (consoleErrors.length) console.error(consoleErrors.slice(0, 10).join("\n")); process.exitCode = 1; } finally { await browser.close(); } } main();