2026-07-10 10:54:34 +08:00
#!/usr/bin/env node
"use strict" ;
2026-07-28 17:04:27 +08:00
const { loginViaAuthForm , withAdminBrowserSession } = 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_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" ;
2026-07-11 20:39:18 +08:00
const MODEL_ID = process . env . MNOTE_PI_REAL_MODEL_ID || "gpt-5.4-mini" ;
2026-07-10 10:54:34 +08:00
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 ) {
2026-07-28 17:04:27 +08:00
// 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 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" ]),
},
};
}
2026-07-28 17:04:27 +08:00
async function ensureDirectoryGrant ( browser ) {
// 方案 A: admin 能力仅 mnote-admin; e2e 为 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 });
2026-07-10 10:54:34 +08:00
}
2026-07-28 17:04:27 +08:00
async function seedRuntimePolicy ( browser ) {
2026-07-10 10:54:34 +08:00
mkdirp ( ROOT_PATH );
2026-07-28 17:04:27 +08:00
await ensureDirectoryGrant ( browser );
2026-07-10 10:54:34 +08:00
const policy = policyForAllSkillsAndMcp ();
2026-07-28 17:04:27 +08:00
await withAdminBrowserSession ( browser , async ( adminPage ) => {
await requestJson ( adminPage , "/api/ai-admin/settings" , {
method : "PUT" ,
data : {
... policy ,
quota : { daily : 200 },
},
});
}, { baseUrl : BASE , timeoutMs : TIMEOUT });
2026-07-10 10:54:34 +08:00
}
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 );
2026-07-28 17:04:27 +08:00
await seedRuntimePolicy ( browser );
2026-07-10 10:54:34 +08:00
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 ();