Files
mnote/scripts/task-pi-lab-real-skill-mcp-smoke.js

520 lines
26 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
"use strict";
const { loginViaAuthForm, withAdminBrowserSession } = 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_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) {
// 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 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(browser) {
// 方案 Aadmin 能力仅 mnote-admine2e 为 AI 主体,不可调 /api/admin/*
return withAdminBrowserSession(browser, async (adminPage) => {
const response = await adminPage.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)}`);
}, { baseUrl: BASE, timeoutMs: TIMEOUT });
}
async function seedRuntimePolicy(browser) {
mkdirp(ROOT_PATH);
await ensureDirectoryGrant(browser);
const policy = policyForAllSkillsAndMcp();
await withAdminBrowserSession(browser, async (adminPage) => {
await requestJson(adminPage, "/api/ai-admin/settings", {
method: "PUT",
data: {
...policy,
quota: { daily: 200 },
},
});
}, { baseUrl: BASE, timeoutMs: TIMEOUT });
}
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(browser);
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();