Files

343 lines
21 KiB
JavaScript
Raw Permalink Normal View History

2026-07-10 10:54:34 +08:00
#!/usr/bin/env node
"use strict";
const { loginViaAuthForm } = require('./lib/browser-auth-login');
2026-07-10 10:54:34 +08:00
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(() => {});
2026-07-10 10:54:34 +08:00
}
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();
2026-07-10 10:54:34 +08:00
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 实际上下文工具");
2026-07-10 10:54:34 +08:00
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 图标");
2026-07-10 10:54:34 +08:00
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);
2026-07-10 10:54:34 +08:00
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 后发送按钮应可用");
2026-07-10 10:54:34 +08:00
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 模式");
2026-07-10 10:54:34 +08:00
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();