#!/usr/bin/env node "use strict"; const { loginViaAuthForm } = require('./lib/browser-auth-login'); 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_UI_BASE_URL || "http://127.0.0.1:3000"; const STAMP = Date.now(); const OUT = process.env.MNOTE_PI_UI_COMPLETION_OUT || path.join(os.tmpdir(), `mnote-pi-ui-completion-${STAMP}`); const TIMEOUT = Number.parseInt(process.env.UI_TIMEOUT_MS || "180000", 10); 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 }); } async function quickLogin(page) { // 7-76 P0: 标准表单登录(无测试快速登录按钮) const base = (typeof BASE_URL !== "undefined" && BASE_URL) || (typeof baseUrl !== "undefined" && baseUrl) || process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000"; const timeout = (typeof UI_TIMEOUT_MS !== "undefined" && UI_TIMEOUT_MS) || (typeof TIMEOUT !== "undefined" && TIMEOUT) || 30_000; if (!String(page.url() || "").includes("/auth")) { await page.goto(String(base).replace(/\/+$/, "") + "/auth", { waitUntil: "commit", timeout, }); } await loginViaAuthForm(page, { baseUrl: base, timeoutMs: timeout, gotoAuth: false, }); await page .waitForURL((url) => !String(url).includes("/auth"), { timeout }) .catch(() => {}); } async function startPi(page) { await page.goto(`${BASE}/page-ai/pi`, { waitUntil: "commit", timeout: TIMEOUT }); await page.locator('[data-page-ai-pi-lab="drawer"]').waitFor({ state: "visible", timeout: TIMEOUT }); await page.waitForFunction(() => document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent?.includes("ready"), null, { timeout: TIMEOUT, }); const status = await page.request.get(`${BASE}/api/page-ai/pi/status`); assert(status.ok(), `Pi status failed: ${status.status()}`); return status.json(); } async function emit(page, payload) { await page.evaluate((eventPayload) => { window.__mnotePiLabTest.emitRpcEvent(eventPayload); }, payload); } async function main() { mkdirp(OUT); const browser = await chromium.launch({ headless: process.env.MNOTE_PI_UI_COMPLETION_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 = { base: BASE, outputDir: OUT, screenshots: {}, checks: {}, consoleMessages, }; try { await quickLogin(page); const startJson = await startPi(page); const session = startJson.session || startJson.session || {}; const sessionId = session.sessionId || startJson.sessionId || (startJson.session && startJson.session.sessionId); const sessionRootUri = session.rootUri || (startJson.session && startJson.session.rootUri) || "file:///tmp/mnote-pi-ui-completion"; result.sessionId = sessionId; assert(sessionId, "Pi sessionId missing"); await page.waitForFunction(() => !!window.__mnotePiLabTest, null, { timeout: TIMEOUT }); result.checks.composerQuickButtonCount = await page.locator(".wolai-page-ai-pi-lab-composer-bar [data-page-ai-pi-lab-quick]").count(); result.checks.composerBarCount = await page.locator(".wolai-page-ai-pi-lab-composer-bar").count(); result.checks.sendModeButtonCount = await page.locator("[data-page-ai-pi-lab-send-mode]").count(); result.checks.sendInsideInputWrap = await page.locator(".wolai-page-ai-pi-lab-input-wrap [data-page-ai-pi-lab-btn-send]").count() === 1; result.checks.bottomQuickKinds = await page.locator(".wolai-page-ai-pi-lab-modebar [data-page-ai-pi-lab-quick]").evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-page-ai-pi-lab-quick"))); result.checks.actionMenuToggleCount = await page.locator("[data-page-ai-pi-lab-action-menu-toggle]").count(); result.checks.thinkingControlVisible = await page.locator("[data-page-ai-pi-lab-thinking-menu-wrap]").isVisible(); result.checks.permissionControlVisible = await page.locator("[data-page-ai-pi-lab-permission]").isVisible(); assert.equal(result.checks.composerQuickButtonCount, 0, "输入框下方不应再重复显示当前页/选区/LightRAG"); assert.equal(result.checks.composerBarCount, 0, "发送按钮不应单独占用一整行 composer bar"); assert.equal(result.checks.sendModeButtonCount, 0, "流式插队模式不应作为常驻工具栏按钮"); assert(result.checks.sendInsideInputWrap, "发送按钮应收进输入框区域"); assert.deepEqual(result.checks.bottomQuickKinds, ["read-page", "current-folder", "selection", "rag"], "底部工具栏应保留 Pi 实际上下文工具"); assert.equal(result.checks.actionMenuToggleCount, 1, "+ 菜单应作为输入区操作入口"); assert(result.checks.thinkingControlVisible, "Pi 官方 thinking level 应在输入区可见"); assert(result.checks.permissionControlVisible, "MNote 权限/审批状态应在输入区可见"); await page.locator("[data-page-ai-pi-lab-action-menu-toggle]").click(); const actionMenu = page.locator("[data-page-ai-pi-lab-action-menu]"); await actionMenu.waitFor({ state: "visible", timeout: TIMEOUT }); result.checks.actionMenuVisible = await actionMenu.isVisible(); result.checks.actionMenuItems = await actionMenu.locator("[data-page-ai-pi-lab-menu-action]").evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-page-ai-pi-lab-menu-action"))); result.checks.planModeAvailable = await actionMenu.locator('[data-page-ai-pi-lab-menu-action="plan-mode"]:not(:disabled)', { hasText: "计划评审" }).count() === 1; await page.screenshot({ path: path.join(OUT, "00a-plus-permission-thinking-menu.png"), fullPage: false }); result.screenshots.plusPermissionThinkingMenu = path.join(OUT, "00a-plus-permission-thinking-menu.png"); assert(result.checks.actionMenuVisible, "+ 菜单应能打开"); assert(result.checks.actionMenuItems.includes("directory-permission"), "+ 菜单应包含目录权限入口"); assert(result.checks.actionMenuItems.includes("send-steer"), "+ 菜单应包含执行中引导发送入口"); assert(result.checks.actionMenuItems.includes("send-followup"), "+ 菜单应包含执行中排队追问入口"); assert(result.checks.actionMenuItems.includes("plan-mode"), "+ 菜单应包含 Pi Rust plan-mode 计划评审入口"); assert(result.checks.planModeAvailable, "计划评审应由 Pi Rust 官方 plan-mode 扩展接管并可用"); await page.locator("[data-page-ai-pi-lab-action-menu-toggle]").click(); await emit(page, { type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "UI_POLISH_MARKDOWN_ACTION" }] }], }); const assistantBubble = page.locator(".wolai-page-ai-pi-lab-message[data-role='assistant'] .wolai-page-ai-pi-lab-bubble").last(); const copyActions = assistantBubble.locator(".wolai-page-ai-pi-lab-message-actions"); await copyActions.waitFor({ state: "attached", timeout: TIMEOUT }); result.checks.copyActionOpacityBeforeHover = await copyActions.evaluate((node) => getComputedStyle(node).opacity); result.checks.followupActionCount = await page.locator("[data-page-ai-pi-followup-controls]").count(); await assistantBubble.hover(); await page.waitForFunction((selector) => getComputedStyle(document.querySelector(selector)).opacity === "1", ".wolai-page-ai-pi-lab-message[data-role='assistant']:last-of-type .wolai-page-ai-pi-lab-message-actions", { timeout: TIMEOUT }).catch(() => null); result.checks.copyActionOpacityAfterHover = await copyActions.evaluate((node) => getComputedStyle(node).opacity); await page.screenshot({ path: path.join(OUT, "00-hover-copy-markdown.png"), fullPage: false }); result.screenshots.hoverCopyMarkdown = path.join(OUT, "00-hover-copy-markdown.png"); assert.equal(result.checks.copyActionOpacityBeforeHover, "0", "复制 Markdown 默认应隐藏"); assert.equal(result.checks.copyActionOpacityAfterHover, "1", "复制 Markdown hover 时应显示"); assert.equal(result.checks.followupActionCount, 0, "页末继续追问/调整回答不应默认显示"); await emit(page, { type: "extension_ui_request", id: "approval-dialog-smoke", method: "confirm", title: "审批 Pi 工具调用", message: "MNote Codex rescue\nmnote.codex_rescue.request", mnoteApproval: { approvalId: "approval-dialog-smoke", toolName: "mnote.codex_rescue.request", paramsHash: "smoke" }, }); const approvalDialog = page.locator("[data-page-ai-pi-lab-ui-dialog='confirm']"); await approvalDialog.waitFor({ state: "visible", timeout: TIMEOUT }); result.checks.approvalDialogVisible = await approvalDialog.isVisible(); await page.screenshot({ path: path.join(OUT, "00b-approval-dialog.png"), fullPage: false }); result.screenshots.approvalDialog = path.join(OUT, "00b-approval-dialog.png"); assert(result.checks.approvalDialogVisible, "Pi 工具审批应显示在 Pi 界面内"); await approvalDialog.locator("[data-page-ai-pi-lab-ui-cancel]").click(); await emit(page, { type: "extension_ui_request", id: "ask-user-bottom-panel-smoke", method: "ask_user", title: "选择设置页方案", message: "这个请求模拟 Pi ask_user 的输入区上方交互。", questions: [{ header: "布局", tab: "layout", prompt: "选择 Pi 输入区的交互位置。", options: [ { label: "底部面板", description: "在输入框上方,不遮住对话。" }, { label: "居中弹窗", description: "覆盖 transcript。" }, ], allowSkip: false, }], }); const askPanel = page.locator("[data-page-ai-pi-lab-ui-dialog='ask_user']"); await askPanel.waitFor({ state: "visible", timeout: TIMEOUT }); const askPanelBox = await askPanel.boundingBox(); const inputWrapBox = await page.locator(".wolai-page-ai-pi-lab-input-wrap").boundingBox(); const composerContainsAskPanel = await page.locator(".wolai-page-ai-pi-lab-composer [data-page-ai-pi-lab-ui-dialog='ask_user']").count(); result.checks.askUserBottomPanelVisible = await askPanel.isVisible(); result.checks.askUserPanelInComposer = composerContainsAskPanel > 0; result.checks.askUserPanelAboveInput = !!askPanelBox && !!inputWrapBox && askPanelBox.y + askPanelBox.height <= inputWrapBox.y + 1; await page.screenshot({ path: path.join(OUT, "00c-ask-user-bottom-panel.png"), fullPage: false }); result.screenshots.askUserBottomPanel = path.join(OUT, "00c-ask-user-bottom-panel.png"); assert(result.checks.askUserBottomPanelVisible, "ask_user 应显示在 Pi 界面内"); assert(result.checks.askUserPanelInComposer, "ask_user 应挂在 composer 内,而不是页面级弹窗"); assert(result.checks.askUserPanelAboveInput, "ask_user 面板应贴在输入框上方且不遮住输入框"); await askPanel.locator("[data-page-ai-pi-lab-ui-cancel]").click(); await page.locator("[data-page-ai-pi-lab-history]").click(); const historyRow = page.locator(`[data-page-ai-pi-lab-history-row="${sessionId}"]`); await historyRow.waitFor({ state: "visible", timeout: TIMEOUT }); result.checks.hasHistoryOpen = await historyRow.locator(`[data-page-ai-pi-lab-history-session="${sessionId}"]`).count() > 0; result.checks.hasHistoryRename = await historyRow.locator(`[data-page-ai-pi-lab-history-rename="${sessionId}"]`).isVisible(); result.checks.hasHistoryFork = await historyRow.locator(`[data-page-ai-pi-lab-history-fork="${sessionId}"]`).isVisible(); result.checks.hasHistoryExport = await historyRow.locator(`[data-page-ai-pi-lab-history-export="${sessionId}"]`).isVisible(); result.checks.hasHistoryDelete = await historyRow.locator(`[data-page-ai-pi-lab-history-delete="${sessionId}"]`).isVisible(); result.checks.hasHistoryRefresh = await page.locator("[data-page-ai-pi-lab-history-refresh]").isVisible(); result.checks.hasHistoryClear = await page.locator("[data-page-ai-pi-lab-history-clear]").isVisible(); result.checks.historyLayerVisible = await page.locator("[data-page-ai-pi-lab-history-layer]").isVisible(); const historyBox = await page.locator("[data-page-ai-pi-lab-history-panel]").boundingBox(); const drawerBox = await page.locator(".wolai-page-ai-pi-lab-drawer").boundingBox(); result.checks.historyDrawerFromLeft = !!historyBox && !!drawerBox && Math.abs(historyBox.x - drawerBox.x) <= 2 && historyBox.height >= drawerBox.height - 4; result.checks.commandbarIconCount = await page.locator(".wolai-page-ai-pi-lab-commandbar svg").count(); result.checks.commandbarRemovedNoopButtons = await page.locator("[data-page-ai-pi-lab-open], [data-page-ai-pi-lab-toggle-artifacts], [data-page-ai-pi-lab-clock], [data-page-ai-pi-lab-notify]").count() === 0; await page.screenshot({ path: path.join(OUT, "01-history-session-actions.png"), fullPage: false }); result.screenshots.historyActions = path.join(OUT, "01-history-session-actions.png"); assert(result.checks.hasHistoryOpen, "history 缺少打开入口"); assert(result.checks.hasHistoryRename, "history 缺少重命名入口"); assert(result.checks.hasHistoryFork, "history 缺少 fork 入口"); assert(result.checks.hasHistoryExport, "history 缺少导出入口"); assert(result.checks.hasHistoryDelete, "history 缺少删除入口"); assert(result.checks.hasHistoryRefresh, "history 缺少刷新入口"); assert(result.checks.hasHistoryClear, "history 缺少清空入口"); assert(result.checks.historyLayerVisible, "history 应以左侧抽屉层打开"); assert(result.checks.historyDrawerFromLeft, "history 应从 Pi 面板左侧弹出"); assert(result.checks.commandbarRemovedNoopButtons, "顶部工具栏不应保留无实际意义按钮"); assert(result.checks.commandbarIconCount <= 6, "顶部工具栏应只保留少量有效 SVG 图标"); await historyRow.locator(`[data-page-ai-pi-lab-history-session="${sessionId}"]`).first().click(); await page.locator("[data-page-ai-pi-lab-replay-banner]").waitFor({ state: "hidden", timeout: TIMEOUT }); await page.locator("[data-page-ai-pi-lab-input]").fill("history continue smoke"); result.checks.historyReplayBannerVisible = await page.locator("[data-page-ai-pi-lab-replay-banner]").isVisible().catch(() => false); result.checks.historyReplayInputDisabled = await page.locator("[data-page-ai-pi-lab-input]").isDisabled(); result.checks.historyReplaySendDisabled = await page.locator("[data-page-ai-pi-lab-btn-send]").isDisabled(); await page.screenshot({ path: path.join(OUT, "01b-history-session-continue.png"), fullPage: false }); result.screenshots.historyReplay = path.join(OUT, "01b-history-session-continue.png"); assert.equal(result.checks.historyReplayBannerVisible, false, "打开历史 session 后不应显示只读 replay 提示"); assert.equal(result.checks.historyReplayInputDisabled, false, "打开历史 session 后输入框应可继续编辑"); assert.equal(result.checks.historyReplaySendDisabled, false, "打开历史 session 后发送按钮应可用"); await page.locator("[data-page-ai-pi-lab-new]").click(); await page.locator("[data-page-ai-pi-lab-replay-banner]").waitFor({ state: "hidden", timeout: TIMEOUT }); const confirmMode = await page.request.fetch(`${BASE}/api/page-ai/pi/configure`, { method: "POST", headers: { "content-type": "application/json", accept: "application/json" }, data: { sessionId, permissionMode: "confirm", }, }); const confirmModeJson = await confirmMode.json(); result.checks.confirmModeConfigured = confirmModeJson.ok === true && ((confirmModeJson.session && confirmModeJson.session.permissionMode === "confirm") || (confirmModeJson.session && confirmModeJson.session.runtimePolicySnapshot && confirmModeJson.session.runtimePolicySnapshot.permissionMode === "confirm")); assert(confirmMode.ok(), `configure confirm request failed: ${confirmMode.status()}`); assert(result.checks.confirmModeConfigured, "安全审批 smoke 必须先显式切到 confirm 模式"); const unapproved = await page.request.fetch(`${BASE}/api/page-ai/pi/tool-call`, { method: "POST", headers: { "content-type": "application/json", accept: "application/json" }, data: { sessionId, toolName: "mnote.local_file.patch", params: { rootUri: sessionRootUri, path: "note.md", operations: [{ op: "append", content: "approval smoke" }], }, }, }); const unapprovedJson = await unapproved.json(); result.checks.unapprovedToolOk = unapprovedJson.ok; result.checks.unapprovedToolCode = unapprovedJson.result && unapprovedJson.result.code; result.checks.unapprovedApprovalRequired = unapprovedJson.approvalRequired === true; assert(unapproved.ok(), `tool-call request failed: ${unapproved.status()}`); assert.equal(unapprovedJson.ok, false, "未审批高风险工具不应成功"); assert.equal(unapprovedJson.result && unapprovedJson.result.code, "page_ai_pi_lab_tool_approval_required"); assert.equal(unapprovedJson.approvalRequired, true); await emit(page, { type: "tool_execution_start", toolCallId: "approval-smoke", toolName: "mnote.local_file.read", args: { path: "note.md" }, }); await page.evaluate(() => { window.__mnotePiLabTest.emitToolCall({ toolCallId: "approval-smoke", toolName: "mnote.local_file.read", allowed: false, denyReason: "page_ai_pi_lab_tool_approval_required: Pi 工具 mnote.local_file.read 需要用户审批", approvalRequired: true, approvalConfirmed: false, toolPolicy: "ask", }); }); await page.locator("[data-page-ai-pi-lab-body]").evaluate((node) => { node.setAttribute("data-rail-open", "true"); }); await page.locator("[data-page-ai-pi-lab-receipts-section]").evaluate((node) => { node.open = true; }); await page.locator("[data-page-ai-pi-lab-receipts]").filter({ hasText: "approval required" }).waitFor({ state: "visible", timeout: TIMEOUT }); result.checks.hasApprovalReceipt = await page.locator("[data-page-ai-pi-lab-receipts]").filter({ hasText: "approval required" }).count() > 0; await page.screenshot({ path: path.join(OUT, "02-approval-audit-ui.png"), fullPage: false }); result.screenshots.approvalAudit = path.join(OUT, "02-approval-audit-ui.png"); assert(result.checks.hasApprovalReceipt, "approval receipt 未显示"); await emit(page, { type: "queue_update", steering: [{ id: "steer-1", text: "调整语气" }], followUp: [{ id: "follow-1", text: "继续回答" }], }); await emit(page, { type: "message_update", assistantMessageEvent: { type: "text_start" }, }); await page.locator("[data-page-ai-pi-lab-btn-abort]").click(); await page.locator("[data-page-ai-pi-lab-abort-note]").waitFor({ state: "visible", timeout: TIMEOUT }); result.checks.abortNoteText = await page.locator("[data-page-ai-pi-lab-abort-note]").last().textContent(); await page.screenshot({ path: path.join(OUT, "03-abort-stopreason-queue.png"), fullPage: false }); result.screenshots.abort = path.join(OUT, "03-abort-stopreason-queue.png"); assert(/stopReason=aborted/.test(result.checks.abortNoteText || ""), "abort note 未显示 stopReason"); assert(/queued messages=2/.test(result.checks.abortNoteText || ""), "abort note 未显示 queued messages"); result.ok = true; } catch (error) { result.ok = false; result.error = error && error.stack ? error.stack : String(error); 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") }, null, 2)); } } main();