#!/usr/bin/env node "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 { setupWorkspaceAccess, seedAiPolicy, } = require("./lib/control-plane-dev-seed"); const BASE = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000"; const STAMP = Date.now(); const OUT = process.env.MNOTE_PI_USER_EXACT_WEB_OUT || path.join(os.tmpdir(), `mnote-pi-user-exact-web-${STAMP}`); const TIMEOUT = Number.parseInt(process.env.UI_TIMEOUT_MS || "420000", 10); const ACTOR_ID = process.env.MNOTE_E2E_ACTOR_ID || "mnote-e2e"; const WORKSPACE_ID = process.env.MNOTE_PI_USER_EXACT_WORKSPACE_ID || `local-ws:${ACTOR_ID}:pi-user-exact-${STAMP}`; const ROOT_PATH = process.env.MNOTE_PI_USER_EXACT_ROOT_PATH || path.join(OUT, "workspace"); const ROOT_URI = process.env.MNOTE_PI_USER_EXACT_ROOT_URI || `file://${ROOT_PATH}`; const PAGE_PATH = `pi-user-exact-${STAMP}.md`; const MODEL_PROVIDER = process.env.MNOTE_PI_USER_EXACT_MODEL_PROVIDER || "omniroute"; const MODEL_ID = process.env.MNOTE_PI_USER_EXACT_MODEL_ID || "gpt-5.4-mini"; const USER_PROMPT = "我们当前已经切换到 Pi Rust 官方版,我希望你全面测试当前的 skill/扩展/mcp/工具等是否正常,我当前已经是授权完全访问了。"; 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 mkdirp(dir) { fs.mkdirSync(dir, { recursive: true }); } function localMdDocumentId(relativePath) { return `local-md:${relativePath.replaceAll("/", "~2F")}`; } function documentUrl() { const url = new URL(`${BASE}/documents/${encodeURIComponent(localMdDocumentId(PAGE_PATH))}`); url.searchParams.set("sourceKind", "local_folder"); url.searchParams.set("rootUri", ROOT_URI); url.searchParams.set("workspaceId", WORKSPACE_ID); return url.toString(); } function skillConfig(name, description, source, riskLevel, requiredScopes = []) { return { name, description, source, riskLevel, requiredScopes, enabled: true }; } function mcpConfig(name, description, transport, command, url, networkPolicy, secretRefs, riskLevel, requiredScopes = []) { return { name, description, transport, command, url, networkPolicy, secretRefs, riskLevel, requiredScopes, enabled: true, facadeOnly: true, sandbox: true, }; } function piExtensionConfig(name, description, source, toolNames, riskLevel, requiredScopes = []) { return { name, description, source, toolNames, riskLevel, requiredScopes, enabled: true }; } function policyForExactPrompt() { return { defaultModel: `${MODEL_PROVIDER}/${MODEL_ID}`, allowedModels: [`${MODEL_PROVIDER}/${MODEL_ID}`], tools: { "mnote.current_page.read": "allow", "mnote.selection.read": "allow", "mnote.allowed_roots.describe": "allow", "mnote.local_file.read": "allow", "mnote.local_file.patch": "ask", "mnote.knowledge_rag.status": "allow", "mnote.knowledge_rag.query": "allow", "mnote.knowledge_rag.section_context": "allow", "mnote.knowledge_rag.open_reference": "allow", "mnote.reference.open": "allow", "mnote.tool_receipt.write": "allow", "mnote.codex_rescue.request": "ask", }, skills: { codegraph: skillConfig("CodeGraph", "读取项目代码图、符号和调用关系。", "mcp://codegraph", "medium", ["workspace:code-read"]), searxng: skillConfig("SearXNG Search", "通过本地 SearXNG MCP 做网页检索。", "mcp://searxng", "medium", ["network:search"]), "chrome-bridge": skillConfig("Chrome Bridge", "通过受控浏览器桥接执行页面验证。", "mcp://chrome-bridge", "high", ["browser:automation", "qa:browser"]), }, mcpServers: { codegraph: mcpConfig("CodeGraph", "代码图 MCP。", "stdio", "codegraph serve --mcp", "", "deny-all", [], "medium", ["workspace:code-read"]), searxng: mcpConfig("SearXNG", "本地 SearXNG 检索 MCP。", "stdio", "node /home/lix/.agent-infra/searxng/codex-searxng-mcp.cjs", "", "allow-local", [], "medium", ["network:search"]), "chrome-bridge": mcpConfig("Chrome Bridge", "本机 Chromium/Chrome 桥接。", "stdio", "node /home/lix/apps/codex-chrome-bridge/mcp/chrome-bridge-mcp.mjs", "", "allow-local", [], "high", ["browser:automation", "qa:browser"]), }, piExtensions: { "pi-rust-official-todo": piExtensionConfig("Pi Rust Official Todo", "Pi Rust 官方 todo 扩展。", "pi-rust-official:todo", ["todo"], "medium", ["workflow:todo"]), "pi-rust-official-subagent": piExtensionConfig("Pi Rust Official Subagent", "Pi Rust 官方 subagent 扩展。", "pi-rust-official:subagent", ["subagent"], "high", ["agent:delegate"]), "pi-rust-official-permission-gate": piExtensionConfig("Pi Rust Official Permission Gate", "Pi Rust 官方 permission-gate 扩展。", "pi-rust-official:permission-gate", [], "high", ["tool:policy"]), }, }; } async function quickLogin(page) { await page.goto(`${BASE}/auth`, { waitUntil: "commit", timeout: TIMEOUT }); const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" }); await quickLoginButton.waitFor({ state: "visible", timeout: TIMEOUT }); await Promise.all([ page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: TIMEOUT }).catch(() => null), quickLoginButton.click(), ]); } async function requestJson(page, url, options = {}) { const response = await page.request.fetch(`${BASE}${url}`, { ...options, headers: { accept: "application/json", "content-type": "application/json", ...(options.headers || {}), }, timeout: options.timeout || TIMEOUT, }); const text = await response.text(); let body = {}; try { body = text ? JSON.parse(text) : {}; } catch { body = { raw: text }; } assert(response.ok(), `${options.method || "GET"} ${url} failed: ${response.status()} ${text.slice(0, 1000)}`); return body; } async function seedWorkspace(page) { mkdirp(ROOT_PATH); fs.writeFileSync( path.join(ROOT_PATH, PAGE_PATH), [ "# Pi exact user web smoke", "", "PI_EXACT_USER_WEB_CONTEXT_OK", "", ].join("\n"), "utf8", ); await setupWorkspaceAccess(page.request, BASE, { actorId: ACTOR_ID, email: "mnote.e2e@example.com", username: ACTOR_ID, displayName: ACTOR_ID, role: "admin", workspaceId: WORKSPACE_ID, workspaceName: "Pi exact user web smoke", rootPath: ROOT_PATH, rootUri: ROOT_URI, permission: "write", capabilities: ["ai", "read", "write"], timeoutMs: TIMEOUT, }); await seedAiPolicy(page.request, BASE, { id: `pi-user-exact-${ACTOR_ID}-${WORKSPACE_ID}`, userId: ACTOR_ID, workspaceId: WORKSPACE_ID, allowedRootsJson: [{ rootUri: ROOT_URI, rootPath: ROOT_PATH, permission: "write" }], modelPolicyJson: policyForExactPrompt(), quotaJson: { daily: 200 }, timeoutMs: TIMEOUT, }); } async function abortExistingSession(page) { const status = await requestJson(page, "/api/page-ai/pi/status").catch(() => null); const sessionId = status?.session?.sessionId; if (!sessionId) return null; await requestJson(page, "/api/page-ai/pi/abort", { method: "POST", data: { sessionId }, }).catch(() => null); return sessionId; } function eventPayload(event) { return event && event.payload ? event.payload : event; } function summarizeEvents(events) { const summary = { eventCount: events.length, toolCalls: [], toolStarts: [], toolEnds: [], assistantTexts: [], runtimeClosed: [], agentEnd: false, }; for (const event of events) { const payload = eventPayload(event) || {}; const type = payload.type || event.kind; if (type === "runtime_stdout_closed") summary.runtimeClosed.push(payload); if (type === "agent_end") summary.agentEnd = true; if (type === "message" || type === "message_end") { const message = payload.message || {}; if (message.role === "assistant") { const text = (message.content || []) .filter((part) => part && part.type === "text" && typeof part.text === "string") .map((part) => part.text) .join(""); if (text.trim()) summary.assistantTexts.push(text.trim()); for (const part of message.content || []) { if (part && part.type === "toolCall") { summary.toolCalls.push({ name: part.name || part.toolName, id: part.id || part.toolCallId }); } } } if (message.role === "toolResult") { summary.toolEnds.push({ name: message.toolName || message.tool_name, id: message.toolCallId || message.tool_call_id, isError: message.isError === true, }); } } if (type === "tool_execution_start" || type === "tool_call_start") { summary.toolStarts.push({ name: payload.toolName || payload.name, id: payload.toolCallId || payload.id }); } if (type === "tool_execution_end" || type === "tool_call_end") { summary.toolEnds.push({ name: payload.toolName || payload.name, id: payload.toolCallId || payload.id, isError: payload.isError === true }); } } return summary; } async function visibleAssistantTexts(page) { const texts = await page .locator('[data-page-ai-pi-lab-message-role="assistant"]:not([data-page-ai-pi-lab-streaming])') .allTextContents() .catch(() => []); return texts.map((text) => String(text || "").trim()).filter(Boolean); } async function visibleToolCards(page) { return await page .locator("[data-page-ai-pi-lab-tool-card]") .evaluateAll((cards) => cards.map((card) => { const name = card.getAttribute("data-page-ai-pi-lab-tool-call") || card.getAttribute("data-page-ai-pi-lab-tool-card") || ""; const status = card.querySelector("[data-page-ai-pi-lab-tool-status]")?.textContent || ""; return { name: String(name || "").trim(), status: String(status || "").trim(), text: String(card.textContent || "").slice(0, 500), }; }).filter((tool) => tool.name)) .catch(() => []); } function mergeVisibleToolCards(summary, tools) { for (const tool of tools || []) { if (!summary.toolCalls.some((existing) => existing.name === tool.name)) { summary.toolCalls.push({ name: tool.name, id: tool.name, source: "visible_dom" }); } const statusText = `${tool.status || ""} ${tool.text || ""}`; if (/done|完成/i.test(statusText) && !summary.toolEnds.some((existing) => existing.name === tool.name)) { summary.toolEnds.push({ name: tool.name, id: tool.name, isError: /error|失败/i.test(statusText), source: "visible_dom" }); } } } async function waitForSessionEvents(page, sessionId) { const deadline = Date.now() + TIMEOUT; let events = []; while (Date.now() < deadline) { const visibleTexts = await visibleAssistantTexts(page); const visibleFinalText = visibleTexts.find((text) => ( text.length > 40 && /MCP|扩展|skill|工具|测试|Chrome Bridge|总体结论/i.test(text) && !/Pi runtime 返回了空回复|empty response/i.test(text) )); if (visibleFinalText) { const summary = summarizeEvents(events); mergeVisibleToolCards(summary, await visibleToolCards(page)); summary.assistantTexts.push(visibleFinalText); return { events, summary }; } const payload = await requestJson( page, `/api/page-ai/pi/sessions/${encodeURIComponent(sessionId)}/events?limit=1000`, { timeout: Math.min(15000, TIMEOUT) }, ).catch(() => null); events = payload && Array.isArray(payload.events) ? payload.events : events; const summary = summarizeEvents(events); mergeVisibleToolCards(summary, await visibleToolCards(page)); if (summary.agentEnd || summary.assistantTexts.length > 0) return { events, summary }; await page.waitForTimeout(1000); } const summary = summarizeEvents(events); mergeVisibleToolCards(summary, await visibleToolCards(page)); return { events, summary }; } function hasBadToolBridgeResult(text) { return /page_ai_pi_lab_session_not_found|page_ai_pi_lab_bridge_token_invalid|mnote_pi_rust_service_bridge_unavailable|mnote_pi_bridge_session_id_missing/i.test(String(text || "")); } async function main() { mkdirp(OUT); const browser = await chromium.launch({ headless: process.env.MNOTE_PI_USER_EXACT_WEB_HEADED === "1" ? false : true, executablePath: CHROMIUM_EXECUTABLE || undefined, }); const context = await browser.newContext({ viewport: { width: 1440, height: 980 } }); await context.addInitScript(() => { window.__MNOTE_PI_LAB_TEST__ = true; }); const page = await context.newPage(); const consoleMessages = []; page.on("console", (message) => { if (["error", "warning"].includes(message.type())) consoleMessages.push(`${message.type()}: ${message.text()}`); }); page.on("pageerror", (error) => consoleMessages.push(`pageerror: ${error.message}`)); const result = { ok: false, base: BASE, outputDir: OUT, rootUri: ROOT_URI, workspaceId: WORKSPACE_ID, pagePath: PAGE_PATH, prompt: USER_PROMPT, screenshots: {}, checks: {}, consoleMessages, }; try { await quickLogin(page); await seedWorkspace(page); await abortExistingSession(page); let startCount = 0; page.on("request", (request) => { if (request.url().includes("/api/page-ai/pi/start") && request.method() === "POST") startCount += 1; }); await page.goto(documentUrl(), { waitUntil: "commit", timeout: TIMEOUT }); await page.locator("[data-page-ai-pi-lab-launcher]").waitFor({ state: "visible", timeout: TIMEOUT }); await page.locator("[data-page-ai-pi-lab-launcher]").click(); await page.locator('[data-page-ai-pi-lab="drawer"]').waitFor({ state: "visible", timeout: TIMEOUT }); await page.waitForFunction(() => !!window.__mnotePiLabTest, null, { timeout: TIMEOUT }); await page.waitForTimeout(800); result.checks.startCountAfterOpen = startCount; assert.equal(result.checks.startCountAfterOpen, 0, "opening Pi Lab must not auto-start runtime"); await page.locator("[data-page-ai-pi-lab-new]").click(); await page.waitForTimeout(500); result.checks.startCountAfterNew = startCount; assert.equal(result.checks.startCountAfterNew, 0, "new conversation must not auto-start runtime"); await page.locator("[data-page-ai-pi-lab-permission]").click(); await page.locator("[data-page-ai-pi-lab-permission-menu]").waitFor({ state: "visible", timeout: TIMEOUT }); await page.locator('[data-page-ai-pi-lab-permission-mode="full_access"]').click(); await page.waitForFunction(() => /完全访问/.test(document.querySelector("[data-page-ai-pi-lab-permission-label]")?.textContent || ""), null, { timeout: TIMEOUT }); await page.waitForTimeout(300); result.checks.startCountAfterFullAccessClick = startCount; assert.equal(result.checks.startCountAfterFullAccessClick, 0, "selecting full_access before send must not auto-start runtime"); const startResponsePromise = page.waitForResponse( (response) => response.url().includes("/api/page-ai/pi/start") && response.request().method() === "POST", { timeout: TIMEOUT }, ); const sendResponsePromise = page.waitForResponse( (response) => response.url().includes("/api/page-ai/pi/send") && response.request().method() === "POST", { timeout: TIMEOUT }, ); await page.locator("[data-page-ai-pi-lab-input]").fill(USER_PROMPT); await page.locator("[data-page-ai-pi-lab-btn-send]").click(); const startResponse = await startResponsePromise; const startBody = await startResponse.json(); const startRequestBody = startResponse.request().postDataJSON(); result.startRequest = startRequestBody; result.startResponse = startBody; assert.equal(startResponse.ok(), true, `start response should be HTTP OK: ${startResponse.status()}`); assert.equal(startRequestBody.permissionMode, "full_access", "web send must start with full_access"); assert.equal(startRequestBody.modelProvider, MODEL_PROVIDER, "web send must use configured provider"); assert.equal(startRequestBody.modelId, MODEL_ID, "web send must use tool-capable model"); assert.equal(startRequestBody.rootUri, ROOT_URI, "web send must keep current document rootUri"); assert.equal(startRequestBody.workspaceId, WORKSPACE_ID, "web send must keep current document workspaceId"); assert.equal(startRequestBody.pagePath, PAGE_PATH, "web send must keep current document pagePath"); assert.equal(startBody.runtimeImplementation, "pi-rust", "runtime implementation should be pi-rust"); assert.deepEqual( [...(startBody.managedPiBuiltinTools || [])].sort(), ["bash", "edit", "find", "grep", "hashline_edit", "ls", "read", "write"].sort(), "full_access should expose Pi Rust builtins instead of replacing them with MNote file tools", ); const sessionId = startBody.session?.sessionId || startBody.sessionId; assert(sessionId, "start response missing sessionId"); result.sessionId = sessionId; result.piSessionDir = startBody.session?.piSessionDir; const sendResponse = await sendResponsePromise; const sendBody = await sendResponse.json(); result.sendResponse = sendBody; assert.equal(sendResponse.ok(), true, `send response should be HTTP OK: ${sendResponse.status()}`); assert.equal(sendBody.accepted, true, "send response should accept the exact prompt"); const { events, summary } = await waitForSessionEvents(page, sessionId); result.events = events; result.eventSummary = summary; result.visibleToolCards = await visibleToolCards(page); mergeVisibleToolCards(summary, result.visibleToolCards); const pageText = await page.locator('[data-page-ai-pi-lab="drawer"]').textContent({ timeout: TIMEOUT }); result.visibleTextSample = String(pageText || "").slice(0, 4000); await page.screenshot({ path: path.join(OUT, "01-exact-prompt-result.png"), fullPage: true }); result.screenshots.exactPrompt = path.join(OUT, "01-exact-prompt-result.png"); const allText = `${JSON.stringify(summary)}\n${result.visibleTextSample}\n${JSON.stringify(events)}`; result.checks.hasToolCall = summary.toolCalls.length > 0 || summary.toolStarts.length > 0 || summary.toolEnds.length > 0; result.checks.hasSuccessfulToolEnd = summary.toolEnds.some((tool) => tool.isError === false); result.checks.hasMnoteOrMcpOrExtensionTool = /mnote_|mcp|todo|subagent|knowledge/i.test(JSON.stringify(summary)); result.checks.noRawBuiltinToolCall = !/"name":"(ls|find|bash|read|write|edit|grep|hashline_edit)"/.test(JSON.stringify(events)); result.checks.noEmptyReplyError = !/Pi runtime 返回了空回复|empty response/i.test(allText); result.checks.noBridgeSessionFailure = !hasBadToolBridgeResult(allText); result.checks.hasAssistantReply = summary.assistantTexts.length > 0 || /Pi|测试|工具|MCP|扩展|skill/i.test(result.visibleTextSample); assert.equal(result.checks.hasToolCall, true, "exact web prompt should trigger at least one real tool call"); assert.equal(result.checks.hasSuccessfulToolEnd, true, "at least one tool call should finish successfully"); assert.equal(result.checks.hasMnoteOrMcpOrExtensionTool, true, "tool call should be MNote/MCP/extension controlled"); assert.equal(result.checks.noRawBuiltinToolCall, true, "full_access must not expose raw builtin tools"); assert.equal(result.checks.noEmptyReplyError, true, "UI must not show empty reply error"); assert.equal(result.checks.noBridgeSessionFailure, true, "tool result must not contain bridge/session failures"); assert.equal(result.checks.hasAssistantReply, true, "UI should show an assistant reply"); await requestJson(page, "/api/page-ai/pi/abort", { method: "POST", data: { sessionId } }).catch(() => ({})); result.ok = true; } catch (error) { result.ok = false; result.error = error && error.stack ? error.stack : String(error); await abortExistingSession(page).catch(() => null); try { await page.screenshot({ path: path.join(OUT, "99-failure.png"), fullPage: true }); result.screenshots.failure = path.join(OUT, "99-failure.png"); } catch {} process.exitCode = 1; } finally { fs.writeFileSync(path.join(OUT, "result.json"), JSON.stringify(result, null, 2), "utf8"); await browser.close(); console.log(JSON.stringify({ ok: result.ok, outputDir: OUT, result: path.join(OUT, "result.json"), sessionId: result.sessionId, checks: result.checks, error: result.error, }, null, 2)); } } main();