#!/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 BASE = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000"; const STAMP = Date.now(); const OUT = process.env.MNOTE_PI_REAL_SKILL_MCP_OUT || path.join(os.tmpdir(), `mnote-pi-real-skill-mcp-${STAMP}`); const TIMEOUT = Number.parseInt(process.env.UI_TIMEOUT_MS || "360000", 10); const ACTOR_ID = process.env.MNOTE_E2E_ACTOR_ID || "mnote-e2e"; const WORKSPACE_ID = process.env.MNOTE_E2E_WORKSPACE_ID || "local-ws:mnote-e2e:my-space"; const ROOT_PATH = process.env.MNOTE_E2E_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space"; const ROOT_URI = process.env.MNOTE_E2E_ROOT_URI || `file://${ROOT_PATH}`; const MODEL_PROVIDER = process.env.MNOTE_PI_REAL_MODEL_PROVIDER || "omniroute"; const MODEL_ID = process.env.MNOTE_PI_REAL_MODEL_ID || "gpt-5.4-mini"; 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" : ""); const MARKERS = { vpn: `PI_REAL_VPN_SKILL_OK_${STAMP}`, context7: `PI_REAL_CONTEXT7_SKILL_MCP_OK_${STAMP}`, mempalace: `PI_REAL_MEMPALACE_MCP_OK_${STAMP}`, }; function mkdirp(dir) { fs.mkdirSync(dir, { recursive: true }); } 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, 800)}`); return body; } 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 policyForAllSkillsAndMcp() { return { defaultModel: `${MODEL_PROVIDER}/${MODEL_ID}`, allowedModels: [`${MODEL_PROVIDER}/${MODEL_ID}`], tools: { "mnote.current_page.read": "allow", "mnote.local_file.read": "ask", "mnote.local_file.patch": "ask", "mnote.tool_receipt.write": "allow", }, skills: { vpn: skillConfig("VPN", "通过 MNote facade 协助诊断代理、出海访问和本机网络路由问题。", "/home/lix/.codex/skills/vpn/SKILL.md", "high", ["network:diagnose", "admin:network"]), "chrome-bridge": skillConfig("Chrome Bridge", "通过受控浏览器桥接执行页面验证、截图和 DOM/网络诊断。", "mcp://chrome-bridge", "high", ["browser:automation", "qa:browser"]), context7: skillConfig("Context7", "查询最新官方库文档、API 参数和发布说明。", "/home/lix/.codex/skills/context7/SKILL.md", "medium", ["network:docs"]), searxng: skillConfig("SearXNG Search", "通过本地 SearXNG MCP 做通用网页检索并保留引用。", "mcp://searxng", "medium", ["network:search"]), "global-search": skillConfig("Global Search", "聚合本机/网页搜索线索,适合研究型查询入口。", "/home/lix/.hermes/profiles/lite/skills/global-search/SKILL.md", "medium", ["network:search"]), mempalace: skillConfig("MemPalace", "读取共享记忆与历史决策事实层,默认通过 MCP facade 受控访问。", "mcp://mempalace", "medium", ["memory:read"]), codegraph: skillConfig("CodeGraph", "读取项目代码图、符号和调用关系,适合开发者工作区。", "mcp://codegraph", "medium", ["workspace:code-read"]), }, mcpServers: { "chrome-bridge": mcpConfig("Chrome Bridge", "本机 Chromium/Chrome 桥接,用于浏览器 QA、截图与网络请求核验。", "stdio", "node /home/lix/apps/codex-chrome-bridge/mcp/chrome-bridge-mcp.mjs", "", "allow-local", [], "high", ["browser:automation", "qa:browser"]), context7: mcpConfig("Context7", "官方文档检索 MCP。", "streamable-http", "", "https://mcp.context7.com/mcp", "allow-all", ["env://CONTEXT7_API_KEY"], "medium", ["network:docs"]), searxng: mcpConfig("SearXNG", "本地 SearXNG 检索 MCP。", "stdio", "node /home/lix/.agent-infra/searxng/codex-searxng-mcp.cjs", "", "allow-local", [], "medium", ["network:search"]), mempalace: mcpConfig("MemPalace", "共享记忆事实层 MCP。", "stdio", "/home/lix/.local/share/uv/tools/mempalace/bin/python -m mempalace.mcp_server --palace /home/lix/.mempalace/palace", "", "deny-all", [], "medium", ["memory:read"]), codegraph: mcpConfig("CodeGraph", "代码图 MCP。", "stdio", "codegraph serve --mcp", "", "deny-all", [], "medium", ["workspace:code-read"]), }, piExtensions: { "pi-rust-official-question": piExtensionConfig("Pi Rust Official Question", "Pi Rust 官方索引 question 扩展的本地镜像。", "pi-rust-official:question", ["question"], "low", ["ui:ask"]), "pi-rust-official-questionnaire": piExtensionConfig("Pi Rust Official Questionnaire", "Pi Rust 官方索引 questionnaire 扩展的本地镜像。", "pi-rust-official:questionnaire", ["questionnaire"], "low", ["ui:ask"]), "pi-rust-official-todo": piExtensionConfig("Pi Rust Official Todo", "Pi Rust 官方索引 todo 扩展的本地镜像。", "pi-rust-official:todo", ["todo"], "medium", ["workflow:todo"]), "pi-rust-official-permission-gate": piExtensionConfig("Pi Rust Official Permission Gate", "Pi Rust 官方索引 permission-gate 扩展的本地镜像。", "pi-rust-official:permission-gate", [], "high", ["tool:policy"]), "pi-rust-official-plan-mode": piExtensionConfig("Pi Rust Official Plan Mode", "Pi Rust 官方索引 plan-mode 扩展的本地镜像。", "pi-rust-official:plan-mode", [], "medium", ["workflow:plan-review"]), "pi-rust-official-subagent": piExtensionConfig("Pi Rust Official Subagent", "Pi Rust 官方索引 subagent 扩展的本地镜像。", "pi-rust-official:subagent", ["subagent"], "high", ["agent:delegate"]), }, }; } async function ensureDirectoryGrant(page) { const response = await page.request.fetch(`${BASE}/api/admin/access-policy/grants`, { method: "POST", headers: { accept: "application/json", "content-type": "application/json", }, data: { userId: ACTOR_ID, rootUri: ROOT_URI, rootPath: ROOT_PATH, permission: "write", recursive: true, capabilities: ["ai"], }, timeout: TIMEOUT, }); const text = await response.text(); let body = {}; try { body = text ? JSON.parse(text) : {}; } catch { body = { raw: text }; } if (response.ok()) return body; if (body && body.code === "local_access_policy_grant_duplicate") return body; throw new Error(`POST /api/admin/access-policy/grants failed: ${response.status()} ${text.slice(0, 800)}`); } async function seedRuntimePolicy(page) { mkdirp(ROOT_PATH); await ensureDirectoryGrant(page); const policy = policyForAllSkillsAndMcp(); await requestJson(page, "/api/ai-admin/settings", { method: "PUT", data: { ...policy, quota: { daily: 200 }, }, }); } async function abortExistingSession(page) { const status = await requestJson(page, "/api/page-ai/pi/status").catch(() => null); const sessionId = status && status.session && status.session.sessionId; if (!sessionId) return null; await requestJson(page, "/api/page-ai/pi/abort", { method: "POST", data: { sessionId } }).catch(() => null); return sessionId; } async function startRealPi(page, label) { const safeLabel = String(label || "check").replace(/[^a-z0-9_-]+/gi, "-"); const sessionId = `pi-real-skill-mcp-${safeLabel}-${STAMP}`; const pagePath = `__pi_real_skill_mcp_${safeLabel}_${STAMP}.md`; fs.writeFileSync(path.join(ROOT_PATH, pagePath), "# Pi real skill MCP smoke\n", "utf8"); const start = await requestJson(page, "/api/page-ai/pi/start", { method: "POST", data: { sessionId, rootUri: ROOT_URI, pagePath, pageTitle: "Pi real skill MCP smoke", modelProvider: MODEL_PROVIDER, modelId: MODEL_ID, thinkingLevel: "off", permissionMode: "full_access", }, }); assert.equal(start.ok, true, "Pi start ok should be true"); assert.equal(start.session.runtimeMode, "rpc", `Pi 必须以 rpc 模式启动,实际=${start.session.runtimeMode}`); assert(start.session.runtimePid, "真实 Pi RPC 启动后应有 runtimePid"); return start.session; } async function openPiUi(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(() => { const text = document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent || ""; return /ready|running|streaming/.test(text); }, null, { timeout: TIMEOUT }).catch(() => null); } async function sendPrompt(page, prompt, marker, screenshotPath) { const input = page.locator("[data-page-ai-pi-lab-input]"); await input.fill(prompt); await page.locator("[data-page-ai-pi-lab-btn-send]").click(); const markerLocator = 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(); const startedAt = Date.now(); while (Date.now() - startedAt < TIMEOUT) { if (await markerLocator.isVisible({ timeout: 500 }).catch(() => false)) break; await approvePendingPermissionDialog(page); await page.waitForTimeout(500); } await markerLocator.waitFor({ state: "visible", timeout: 1000 }); await page.waitForFunction(() => { const status = document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent || ""; return status === "ready"; }, null, { timeout: 30000 }); await expandToolTimelines(page); await page.screenshot({ path: screenshotPath, fullPage: false }); return ((await markerLocator.textContent({ timeout: TIMEOUT })) || "").trim(); } async function approvePendingPermissionDialog(page) { const dialog = page.locator("[data-page-ai-pi-lab-ui-dialog='confirm']"); if (!(await dialog.isVisible({ timeout: 250 }).catch(() => false))) return false; const preferred = dialog .getByRole("button") .filter({ hasText: /allow .* session|本会话|Yes, allow/i }) .first(); if (await preferred.isVisible({ timeout: 250 }).catch(() => false)) { await preferred.click(); return true; } const yes = dialog.getByRole("button", { name: /^Yes$/i }).first(); if (await yes.isVisible({ timeout: 250 }).catch(() => false)) { await yes.click(); return true; } const submit = page.locator("[data-page-ai-pi-lab-ui-submit]").first(); if (await submit.isVisible({ timeout: 250 }).catch(() => false)) { await submit.click(); return true; } return false; } async function expandToolTimelines(page) { const timelines = await page.locator("[data-page-ai-pi-lab-tool-timeline], [data-page-ai-pi-tool-timeline]").all(); for (const timeline of timelines) { await timeline.evaluate((node) => { node.open = true; }).catch(() => {}); } } function readSessionJsonl(sessionDir) { const files = []; const visit = (dir) => { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const target = path.join(dir, entry.name); if (entry.isDirectory()) visit(target); else if (entry.isFile() && entry.name.endsWith(".jsonl")) files.push(target); } }; visit(sessionDir); files.sort(); const sessionFile = files[files.length - 1]; assert(sessionFile, `session jsonl not found in ${sessionDir}`); return { sessionFile, raw: fs.readFileSync(sessionFile, "utf8") }; } function findFilesByName(root, fileName) { const matches = []; const visit = (dir) => { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const target = path.join(dir, entry.name); if (entry.isDirectory()) visit(target); else if (entry.isFile() && entry.name === fileName) matches.push(target); } }; visit(root); return matches; } function readMcpToolResults(raw) { return raw .split("\n") .filter(Boolean) .map((line) => { try { return JSON.parse(line); } catch { return undefined; } }) .filter((entry) => entry?.type === "message" && entry?.message?.role === "toolResult" && entry?.message?.toolName === "mcp") .map((entry) => entry.message); } function inspectRuntimeSession(session) { const mcpConfigPath = path.join(session.piSessionDir, "config", "mcp.json"); const mcpConfig = JSON.parse(fs.readFileSync(mcpConfigPath, "utf8")); const runtimeExtensionsPath = path.join(session.piSessionDir, "config", "runtime-extensions"); const clientPaths = findFilesByName(runtimeExtensionsPath, "client.mjs"); assert.equal(clientPaths.length, 1, "session 应仅生成一个 Pi Rust MCP client"); const clientPath = clientPaths[0]; const extensionDir = path.dirname(clientPath); const stagedMcpConfigPath = path.join(extensionDir, ".pi", "mcp.json"); assert(fs.existsSync(stagedMcpConfigPath), "Pi Rust MCP client 相邻目录缺少 .pi/mcp.json"); const privateBridgePaths = findFilesByName(runtimeExtensionsPath, "mnote-bridge.json"); assert.equal(privateBridgePaths.length, 0, "不应继续生成 MCP private backend bridge config"); return { sessionId: session.sessionId, runtimePid: session.runtimePid, piSessionDir: session.piSessionDir, runtimePolicySnapshot: session.runtimePolicySnapshot, mcpConfigPath, mcpConfig, syncClient: { clientPath, stagedMcpConfigPath, clientAdjacent: path.dirname(stagedMcpConfigPath) === path.join(extensionDir, ".pi"), configMatches: fs.readFileSync(stagedMcpConfigPath, "utf8") === fs.readFileSync(mcpConfigPath, "utf8"), noPrivateBridgeConfig: privateBridgePaths.length === 0, }, }; } async function main() { mkdirp(OUT); const browser = await chromium.launch({ headless: process.env.MNOTE_PI_REAL_SKILL_MCP_HEADED === "1" ? false : true, executablePath: CHROMIUM_EXECUTABLE || undefined, }); const context = await browser.newContext({ viewport: { width: 1440, height: 980 } }); 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, markers: MARKERS, screenshots: {}, checks: {}, session: {}, sessions: {}, consoleMessages, }; try { await quickLogin(page); await seedRuntimePolicy(page); result.seededEffective = await requestJson(page, "/api/ai-settings/effective"); result.checks.allSkillsEnabled = ["VPN", "Chrome Bridge", "Context7", "SearXNG Search", "Global Search", "MemPalace", "CodeGraph"] .every((name) => (result.seededEffective.skills || []).some((skill) => skill.name === name && skill.enabled !== false)); result.checks.allMcpEnabled = ["Chrome Bridge", "Context7", "SearXNG", "MemPalace", "CodeGraph"] .every((name) => (result.seededEffective.mcpServers || []).some((server) => server.name === name && server.enabled !== false)); assert(result.checks.allSkillsEnabled, "mnote-e2e 未获得全部 Skill 权限"); assert(result.checks.allMcpEnabled, "mnote-e2e 未获得全部 MCP 权限"); await abortExistingSession(page); const context7Session = await startRealPi(page, "context7"); result.sessions.context7 = inspectRuntimeSession(context7Session); result.session = result.sessions.context7; assert.equal(context7Session.runtimePolicySnapshot.mcpBridge, "pi-rust-sync-client", "MNote 内置 Pi Rust MCP sync client 应默认启用"); result.checks.context7SkillCliSource = (context7Session.runtimePolicySnapshot.enabledSkillSources || []).includes("/home/lix/.codex/skills/context7/SKILL.md"); result.checks.vpnSkillCliSource = (context7Session.runtimePolicySnapshot.enabledSkillSources || []).includes("/home/lix/.codex/skills/vpn/SKILL.md"); result.checks.context7McpConfigured = !!result.sessions.context7.mcpConfig.mcpServers?.context7; result.checks.mempalaceMcpConfigured = !!result.sessions.context7.mcpConfig.mcpServers?.mempalace; assert(result.checks.context7SkillCliSource, "runtime policy 未包含 Context7 skill source"); assert(result.checks.vpnSkillCliSource, "runtime policy 未包含 VPN skill source"); assert(result.checks.context7McpConfigured, "session mcp.json 未包含 Context7 MCP"); assert(result.checks.mempalaceMcpConfigured, "session mcp.json 未包含 MemPalace MCP"); result.checks.mcpSyncClientAdjacent = result.sessions.context7.syncClient.clientAdjacent; result.checks.mcpSyncClientConfigMatches = result.sessions.context7.syncClient.configMatches; result.checks.noPrivateBridgeConfig = result.sessions.context7.syncClient.noPrivateBridgeConfig; assert(result.checks.mcpSyncClientAdjacent, "MCP client 与 session .pi/mcp.json 未相邻部署"); assert(result.checks.mcpSyncClientConfigMatches, "MCP client 相邻配置与 session mcp.json 不一致"); assert(result.checks.noPrivateBridgeConfig, "仍生成了已废弃的 MCP private backend bridge config"); await openPiUi(page); await page.screenshot({ path: path.join(OUT, "01-real-pi-started.png"), fullPage: false }); result.screenshots.started = path.join(OUT, "01-real-pi-started.png"); result.checks.context7Reply = await sendPrompt(page, [ "必须真实调用 MCP 工具,不能只凭记忆回答。", "调用 mcp({server:\"context7\",mode:\"list\"}) 查看可用工具。", "回答中必须写出至少一个实际返回的 Context7 工具名。", `最后必须单独输出一行:${MARKERS.context7}`, ].join("\n"), MARKERS.context7, path.join(OUT, "02-context7-mcp.png")); result.screenshots.context7 = path.join(OUT, "02-context7-mcp.png"); result.checks.context7ReturnedActualTool = /resolve-library-id|query-docs/i.test(result.checks.context7Reply); assert(result.checks.context7ReturnedActualTool, "Context7 网页回复未包含真实返回的工具名"); const context7ToolText = (await page.locator("[data-page-ai-pi-lab-tool-timeline], [data-page-ai-pi-tool-timeline]").allTextContents()).join("\n"); const context7Jsonl = readSessionJsonl(context7Session.piSessionDir); const context7ToolResults = readMcpToolResults(context7Jsonl.raw); result.sessions.context7.sessionFile = context7Jsonl.sessionFile; await requestJson(page, "/api/page-ai/pi/abort", { method: "POST", data: { sessionId: context7Session.sessionId } }).catch(() => ({})); const mempalaceSession = await startRealPi(page, "mempalace"); result.sessions.mempalace = inspectRuntimeSession(mempalaceSession); await openPiUi(page); result.checks.mempalaceReply = await sendPrompt(page, [ "必须真实调用 MCP 工具,不能只凭记忆回答。", "调用 mcp({server:\"mempalace\",mode:\"call\",tool:\"mempalace_status\",arguments:{}})。", "简要报告工具实际返回的 total_drawers。", `最后必须单独输出一行:${MARKERS.mempalace}`, ].join("\n"), MARKERS.mempalace, path.join(OUT, "03-mempalace-mcp.png")); result.screenshots.mempalace = path.join(OUT, "03-mempalace-mcp.png"); result.checks.mempalaceReturnedTotalDrawers = /total_drawers[^0-9-]*[0-9]+/i.test(result.checks.mempalaceReply); assert(result.checks.mempalaceReturnedTotalDrawers, "MemPalace 网页回复未包含真实 total_drawers 数值"); const mempalaceToolText = (await page.locator("[data-page-ai-pi-lab-tool-timeline], [data-page-ai-pi-tool-timeline]").allTextContents()).join("\n"); const mempalaceJsonl = readSessionJsonl(mempalaceSession.piSessionDir); const mempalaceToolResults = readMcpToolResults(mempalaceJsonl.raw); result.sessions.mempalace.sessionFile = mempalaceJsonl.sessionFile; await requestJson(page, "/api/page-ai/pi/abort", { method: "POST", data: { sessionId: mempalaceSession.sessionId } }).catch(() => ({})); const vpnSession = await startRealPi(page, "vpn"); result.sessions.vpn = inspectRuntimeSession(vpnSession); await openPiUi(page); result.checks.vpnSkillReply = await sendPrompt(page, [ "/skill:vpn", "不要调用工具。请从已加载的 VPN skill 中找出 openclaw-clash 默认 HTTP/HTTPS 代理端口。", "回答必须包含端口数字,并在最后单独输出一行:", MARKERS.vpn, ].join("\n"), MARKERS.vpn, path.join(OUT, "04-vpn-skill.png")); result.screenshots.vpn = path.join(OUT, "04-vpn-skill.png"); result.checks.vpnSkillLoaded = result.checks.vpnSkillReply.includes("17897"); assert(result.checks.vpnSkillLoaded, "真实网页回复未体现 VPN skill 中的默认代理端口 17897"); await requestJson(page, "/api/page-ai/pi/abort", { method: "POST", data: { sessionId: vpnSession.sessionId } }).catch(() => ({})); const mcpToolResults = context7ToolResults.concat(mempalaceToolResults); const mcpFailureText = [ context7ToolText, mempalaceToolText, result.checks.context7Reply, result.checks.mempalaceReply, ...mcpToolResults.flatMap((message) => message.content || []).map((item) => item?.text || ""), ].join("\n"); result.checks.context7ToolCardVisible = /mcp:context7|context7/i.test(context7ToolText); result.checks.mempalaceToolCardVisible = /mcp:mempalace|mempalace/i.test(mempalaceToolText); result.checks.context7McpCalled = context7Jsonl.raw.includes('"name":"mcp"') && context7Jsonl.raw.includes("context7"); result.checks.mempalaceMcpCalled = mempalaceJsonl.raw.includes('"name":"mcp"') && mempalaceJsonl.raw.includes("mempalace"); result.checks.noReasoningLeakInToolTimeline = !/reasoning_content|\"thinking\"/i.test(`${context7ToolText}\n${mempalaceToolText}`); result.checks.allMcpToolResultsSucceeded = context7ToolResults.length >= 1 && mempalaceToolResults.length >= 1 && mcpToolResults.every((message) => message.isError !== true); result.checks.noMcpFailureSignals = !/MCP 调用失败|session\/token 未配置|private bridge config 缺少 session\/token|JS extension runtime task cancelled|\"isError\"\s*:\s*true/i.test(mcpFailureText); assert(result.checks.context7ToolCardVisible, "UI 未显示 Context7 MCP 工具卡"); assert(result.checks.mempalaceToolCardVisible, "UI 未显示 MemPalace MCP 工具卡"); assert(result.checks.context7McpCalled, "Pi session JSONL 未记录 Context7 MCP 调用"); assert(result.checks.mempalaceMcpCalled, "Pi session JSONL 未记录 MemPalace MCP 调用"); assert(result.checks.noReasoningLeakInToolTimeline, "工具时间线不应泄漏 thinking/reasoning 字段"); assert(result.checks.allMcpToolResultsSucceeded, "存在失败的 MCP toolResult"); assert(result.checks.noMcpFailureSignals, "网页回复或工具时间线包含 MCP 失败信号"); 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();