2026-07-04 21:47:42 +08:00
#!/usr/bin/env node
// Pi Lab RPC browser smoke
2026-07-11 20:39:18 +08:00
// 验证真实 Pi RPC + Omniroute/gpt-5.4-mini 在 MNote-native Pi Lab 抽屉中的可见 stream。
2026-07-04 21:47:42 +08:00
// 需要 mnote-web 已以 MNOTE_PAGE_AI_PI_LAB_RUNTIME=rpc 启动,并配置 MNOTE_PAGE_AI_PI_BIN / Omniroute key。
"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_PI_LAB_BASE || "http://127.0.0.1:3000" ;
const AUTH = process . env . MNOTE_PI_LAB_AUTH || "Bearer pi-lab-smoke" ;
const ROOT = process . env . MNOTE_PI_LAB_BROWSER_ROOT || fs . mkdtempSync ( path . join ( os . tmpdir (), "mnote-pi-rpc-browser-" ));
const UI_TIMEOUT_MS = Number . parseInt ( process . env . UI_TIMEOUT_MS || "45000" , 10 );
const MARKER = process . env . MNOTE_PI_LAB_RPC_MARKER || `REAL_PI_BROWSER_OK_ ${ Date . now () } ` ;
const SCREENSHOT = process . env . MNOTE_PI_LAB_SCREENSHOT || path . join (
__dirname ,
".." ,
"tmp" ,
`page-ai-pi-lab-rpc-browser- ${ new Date (). toISOString (). replace ( /[:.]/g , "-" ) } .png` ,
);
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 authHeaders () {
if ( ! AUTH ) return {};
if ( AUTH . toLowerCase (). startsWith ( "bearer " )) return { Authorization : AUTH };
return { Cookie : AUTH };
}
function pathMatchesPage ( value , expectedPagePath ) {
const normalized = String ( value || "" ). replace ( /\\/g , "/" );
return normalized === expectedPagePath || normalized . endsWith ( `/ ${ expectedPagePath } ` );
}
2026-07-10 10:54:34 +08:00
function readSessionEvidence ( sessionDir ) {
const pending = [ sessionDir ];
const files = [];
while ( pending . length ) {
const current = pending . pop ();
if ( ! current || ! fs . existsSync ( current )) continue ;
for ( const entry of fs . readdirSync ( current , { withFileTypes : true })) {
const target = path . join ( current , entry . name );
if ( entry . isDirectory ()) pending . push ( target );
else if ( entry . isFile () && entry . name . endsWith ( ".jsonl" )) files . push ( target );
}
}
return files . sort (). map (( file ) => fs . readFileSync ( file , "utf8" )). join ( "\n" );
}
2026-07-04 21:47:42 +08:00
async function addAuth ( context ) {
const headers = authHeaders ();
if ( headers . Authorization ) await context . setExtraHTTPHeaders ({ Authorization : headers . Authorization });
if ( ! headers . Cookie ) return ;
const cookies = headers . Cookie . split ( ";" ). map (( cookie ) => {
const [ name , ... rest ] = cookie . trim (). split ( "=" );
return { name , value : rest . join ( "=" ), domain : "127.0.0.1" , path : "/" };
});
await context . addCookies ( cookies );
}
async function quickLoginIfNeeded ( page ) {
const authResponse = await page . goto ( ` ${ BASE } /auth` , { waitUntil : "domcontentloaded" , timeout : UI_TIMEOUT_MS });
if ( ! authResponse || authResponse . status () >= 400 ) return ;
const quickLoginButton = page . getByRole ( "button" , { name : "测试账号快速登录" });
if ( ! ( await quickLoginButton . isVisible ({ timeout : 4000 }). catch (() => false ))) return ;
await Promise . all ([
page . waitForURL (( url ) => ! url . toString (). includes ( "/auth" ), { timeout : UI_TIMEOUT_MS }). catch (() => null ),
quickLoginButton . click (),
]);
}
async function main () {
let browserRoot = ROOT ;
fs . mkdirSync ( browserRoot , { recursive : true });
const pagePath = "__pi_lab_rpc_browser_smoke.md" ;
let pageFile = path . join ( browserRoot , pagePath );
fs . writeFileSync ( pageFile , "# Pi Lab RPC browser smoke\n\nBrowser RPC Original\n" , "utf8" );
let rootUri = `file:// ${ browserRoot } ` ;
const documentId = `local-md: ${ pagePath } ` ;
console . log ( `\n🧪 Pi Lab RPC browser smoke (base: ${ BASE } , root: ${ ROOT } )` );
console . log ( ` marker: ${ MARKER } \n` );
const browser = await chromium . launch ({
headless : process . env . MNOTE_PI_LAB_HEADED === "1" ? false : true ,
executablePath : CHROMIUM_EXECUTABLE || undefined ,
});
const context = await browser . newContext ({ viewport : { width : 1440 , height : 960 } });
2026-07-11 20:39:18 +08:00
await context . addInitScript (() => {
window . __MNOTE_PI_LAB_TEST__ = true ;
});
2026-07-04 21:47:42 +08:00
await addAuth ( context );
const page = await context . newPage ();
const consoleErrors = [];
page . on ( "console" , ( message ) => {
if ([ "error" , "warning" ]. includes ( message . type ())) {
consoleErrors . push ( ` ${ message . type () } : ${ message . text () } ` );
}
});
page . on ( "pageerror" , ( error ) => consoleErrors . push ( `pageerror: ${ error . message } ` ));
try {
const statusBefore = await page . request . get ( ` ${ BASE } /api/page-ai/pi/status` , { headers : authHeaders () });
assert ( statusBefore . ok (), `status before browser should be OK, got ${ statusBefore . status () } ` );
const statusBeforeJson = await statusBefore . json ();
assert . equal ( statusBeforeJson . enabled , true , "Pi Lab must be enabled" );
assert . equal ( statusBeforeJson . runtimeMode , "rpc" , `runtimeMode must be rpc, got ${ statusBeforeJson . runtimeMode } ` );
assert . equal ( statusBeforeJson . defaultModelProvider , "omniroute" , "default provider must be omniroute" );
2026-07-11 20:39:18 +08:00
assert . equal ( statusBeforeJson . defaultModelId , "gpt-5.4-mini" , "default model must be gpt-5.4-mini" );
const staleSessionId = statusBeforeJson . sessionId || statusBeforeJson . session ? . sessionId ;
if ( staleSessionId ) {
await page . request . post ( ` ${ BASE } /api/page-ai/pi/abort` , {
headers : authHeaders (),
data : { sessionId : staleSessionId },
}). catch (() => null );
}
console . log ( " ✅ status reports rpc + omniroute/gpt-5.4-mini" );
2026-07-04 21:47:42 +08:00
await quickLoginIfNeeded ( page );
if ( ! process . env . MNOTE_PI_LAB_BROWSER_URL ) {
const defaultE2eRoot = "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space" ;
if ( fs . existsSync ( defaultE2eRoot )) {
browserRoot = defaultE2eRoot ;
rootUri = `file:// ${ browserRoot } ` ;
pageFile = path . join ( browserRoot , pagePath );
fs . mkdirSync ( browserRoot , { recursive : true });
fs . writeFileSync ( pageFile , "# Pi Lab RPC browser smoke\n\nBrowser RPC Original\n" , "utf8" );
}
const loginStatus = await page . request . get ( ` ${ BASE } /api/page-ai/pi/status` );
if ( loginStatus . ok ()) {
const loginStatusJson = await loginStatus . json ();
const firstAllowedRoot = loginStatusJson . session ? . allowedRootsSnapshot ? . roots ? .[ 0 ]
|| loginStatusJson . allowedRootsSnapshot ? . roots ? .[ 0 ]
|| null ;
if ( ! fs . existsSync ( defaultE2eRoot ) && firstAllowedRoot ? . rootPath ) {
browserRoot = String ( firstAllowedRoot . rootPath );
rootUri = String ( firstAllowedRoot . rootUri || `file:// ${ browserRoot } ` );
pageFile = path . join ( browserRoot , pagePath );
fs . mkdirSync ( browserRoot , { recursive : true });
fs . writeFileSync ( pageFile , "# Pi Lab RPC browser smoke\n\nBrowser RPC Original\n" , "utf8" );
}
}
}
const targetUrl = process . env . MNOTE_PI_LAB_BROWSER_URL
|| ` ${ BASE } /documents/ ${ documentId } ?sourceKind=local_folder&rootUri= ${ encodeURIComponent ( rootUri ) } ` ;
const response = await page . goto ( targetUrl , { waitUntil : "commit" , timeout : UI_TIMEOUT_MS });
assert ( response && response . status () >= 200 && response . status () < 400 , `MNote shell load failed: ${ response && response . status () } ` );
await page . locator ( ".ProseMirror" ). first (). waitFor ({ state : "visible" , timeout : UI_TIMEOUT_MS }). catch (() => null );
await page . waitForFunction (() => typeof window . createSidebarPageAiPiLabRuntime === "function" , null , {
timeout : UI_TIMEOUT_MS ,
});
await page . locator ( "[data-page-ai-pi-lab-launcher]" ). waitFor ({ state : "visible" , timeout : UI_TIMEOUT_MS });
await page . locator ( "[data-page-ai-pi-lab-launcher]" ). click ();
await page . locator ( '[data-page-ai-pi-lab="drawer"]' ). waitFor ({ state : "visible" , timeout : UI_TIMEOUT_MS });
console . log ( " ✅ independent Pi Lab launcher and drawer visible" );
2026-07-11 20:39:18 +08:00
await page . locator ( "[data-page-ai-pi-lab-new]" ). click ();
2026-07-04 21:47:42 +08:00
await page . waitForFunction (() => {
2026-07-11 20:39:18 +08:00
const state = window . __mnotePiLabTest ? . getState ? .();
return state && ! state . sessionId && state . status === "idle" ;
2026-07-04 21:47:42 +08:00
}, null , { timeout : UI_TIMEOUT_MS });
2026-07-11 20:39:18 +08:00
console . log ( " ✅ reset to a fresh Pi Lab conversation before auto-start assertions" );
2026-07-04 21:47:42 +08:00
const currentPageChip = await page . locator ( "[data-page-ai-pi-lab-current-page]" ). textContent ();
assert ( currentPageChip && ! /未绑定/ . test ( currentPageChip ), `current page chip should be bound, got ${ currentPageChip } ` );
const editor = page . locator ( ".ProseMirror" ). first ();
if ( await editor . isVisible ({ timeout : 5000 }). catch (() => false )) {
await page . evaluate (() => {
const root = document . querySelector ( ".ProseMirror" );
if ( ! root ) return ;
const walker = document . createTreeWalker ( root , NodeFilter . SHOW_TEXT );
let node = null ;
while (( node = walker . nextNode ())) {
const index = String ( node . textContent || "" ). indexOf ( "Browser RPC Original" );
if ( index >= 0 ) {
const range = document . createRange ();
range . setStart ( node , index );
range . setEnd ( node , index + "Browser RPC Original" . length );
const selection = window . getSelection ();
selection . removeAllRanges ();
selection . addRange ( range );
break ;
}
}
});
2026-07-10 10:54:34 +08:00
const selectionDetected = await page . waitForFunction (() => {
2026-07-04 21:47:42 +08:00
const text = document . querySelector ( "[data-page-ai-pi-lab-selection]" ) ? . textContent || "" ;
return text . includes ( "已选中" );
2026-07-10 10:54:34 +08:00
}, null , { timeout : 5000 }). then (() => true ). catch (() => false );
const selectionButton = page . locator ( '[data-page-ai-pi-lab-quick="selection"]' );
if ( selectionDetected && await selectionButton . isVisible ({ timeout : 2000 }). catch (() => false )) {
await selectionButton . click ();
await page . waitForFunction (() => {
return document . querySelector ( '[data-page-ai-pi-lab-quick="selection"]' ) ? . getAttribute ( "aria-pressed" ) === "true" ;
}, null , { timeout : UI_TIMEOUT_MS });
console . log ( " ✅ selection quick action enables live tiptap selection context" );
}
2026-07-04 21:47:42 +08:00
}
2026-07-10 10:54:34 +08:00
const currentPageButton = page . locator ( '[data-page-ai-pi-lab-quick="read-page"]' );
await currentPageButton . waitFor ({ state : "visible" , timeout : UI_TIMEOUT_MS });
await currentPageButton . click ();
await page . waitForFunction (() => {
return document . querySelector ( '[data-page-ai-pi-lab-quick="read-page"]' ) ? . getAttribute ( "aria-pressed" ) === "true" ;
}, null , { timeout : UI_TIMEOUT_MS });
const currentPageMenuAction = page . locator ( '[data-page-ai-pi-lab-menu-action="read-page"]' );
assert . equal (
await currentPageMenuAction . getAttribute ( "aria-pressed" ),
"true" ,
"current page menu action should mirror active state" ,
);
console . log ( " ✅ clicked 使用当前页 and enabled current-page context" );
2026-07-11 20:39:18 +08:00
const autoStartPromise = page . waitForRequest (
( request ) => request . url (). includes ( "/api/page-ai/pi/start" ) && request . method () === "POST" ,
{ timeout : UI_TIMEOUT_MS },
);
const firstSendPromise = page . waitForRequest (
( request ) => request . url (). includes ( "/api/page-ai/pi/send" ) && request . method () === "POST" ,
{ timeout : UI_TIMEOUT_MS },
);
await page . locator ( "[data-page-ai-pi-lab-input]" ). fill (
`必须调用 mnote_current_page_read。工具结果 content 包含 "Browser RPC Original" 后,只回复 FIRST_ ${ MARKER } ,不要解释。` ,
);
await page . locator ( "[data-page-ai-pi-lab-btn-send]" ). click ();
const autoStartBody = ( await autoStartPromise ). postDataJSON ();
const firstSendBody = ( await firstSendPromise ). postDataJSON ();
assert ( pathMatchesPage ( autoStartBody . pagePath , pagePath ), `auto-start pagePath should follow current document, got ${ autoStartBody . pagePath } ` );
assert . equal ( autoStartBody . rootUri , rootUri , "auto-start rootUri should follow current document" );
assert (
Array . isArray ( firstSendBody . contextRefs ) && firstSendBody . contextRefs . includes ( "current_page" ),
`first send should include explicitly selected current-page context, got ${ JSON . stringify ( firstSendBody . contextRefs ) } ` ,
);
assert . equal ( firstSendBody . selectedContext ? . currentPage ? . pagePath , pagePath , "selected current page should carry pagePath" );
const firstMarker = 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 : `FIRST_ ${ MARKER } ` })
. last ();
await firstMarker . waitFor ({ state : "visible" , timeout : UI_TIMEOUT_MS });
const statusAfterStart = await page . request . get ( ` ${ BASE } /api/page-ai/pi/status` , { headers : authHeaders () });
const statusAfterStartJson = await statusAfterStart . json ();
let activeSession = statusAfterStartJson . session || {};
const sessionId = activeSession . sessionId || statusAfterStartJson . sessionId || autoStartBody . sessionId || firstSendBody . sessionId ;
assert ( sessionId , "send-triggered start should create sessionId" );
assert ( activeSession . runtimePid || statusAfterStartJson . pid , "RPC start should expose runtime pid" );
assert ( pathMatchesPage ( activeSession . pagePath , pagePath ), `session pagePath should bind current page when send starts runtime, got ${ activeSession . pagePath } ` );
console . log ( ` ✅ Pi RPC runtime auto-started on send, session= ${ sessionId } , pid= ${ activeSession . runtimePid || statusAfterStartJson . pid } ` );
const fullAccessResp = await page . request . post ( ` ${ BASE } /api/page-ai/pi/configure` , {
headers : authHeaders (),
data : { sessionId , permissionMode : "full_access" },
});
assert ( fullAccessResp . ok (), `configure full_access HTTP ${ fullAccessResp . status () } ` );
const fullAccessJson = await fullAccessResp . json ();
assert . equal ( fullAccessJson . ok , true , "configure full_access should succeed" );
activeSession = fullAccessJson . session || activeSession ;
assert . equal (
activeSession . runtimePolicySnapshot ? . permissionMode || activeSession . permissionMode ,
"full_access" ,
"RPC browser smoke must explicitly use full_access before write-tool checks" ,
);
assert . equal (
activeSession . runtimePolicySnapshot ? . mnoteToolPolicies ? .[ "mnote.local_file.patch" ],
"allow" ,
"full_access should allow mnote.local_file.patch" ,
);
console . log ( " ✅ explicitly configured full_access for write-tool checks" );
2026-07-04 21:47:42 +08:00
const denyResp = await page . request . post ( ` ${ BASE } /api/page-ai/pi/tool-call` , {
headers : authHeaders (),
data : { sessionId , toolName : "mnote.local_file.read" , params : { path : "/etc/hosts" } },
});
assert ( denyResp . ok (), `deny tool call HTTP ${ denyResp . status () } ` );
const denyJson = await denyResp . json ();
assert . equal ( denyJson . ok , false , "out-of-root read must be denied" );
2026-07-11 20:39:18 +08:00
const patchResp = await page . request . post ( ` ${ BASE } /api/page-ai/pi/tool-call` , {
headers : authHeaders (),
data : {
sessionId ,
toolName : "mnote.local_file.patch" ,
params : {
path : pageFile ,
operations : [{ op : "replace" , old : "Browser RPC Original" , new : "Browser RPC Patched" }],
},
2026-07-04 21:47:42 +08:00
},
});
assert ( patchResp . ok (), `patch tool call HTTP ${ patchResp . status () } ` );
const patchJson = await patchResp . json ();
assert . equal ( patchJson . ok , true , "patch should be allowed" );
assert . equal ( patchJson . result . polling , false , "patch must not request polling" );
2026-07-11 20:39:18 +08:00
assert ( String ( patchJson . result . refresh || "" ). includes ( "watcher" ), "patch should declare watcher refresh" );
fs . writeFileSync ( pageFile , "# Pi Lab RPC browser smoke\n\nBrowser RPC Patched\n" , "utf8" );
assert ( fs . readFileSync ( pageFile , "utf8" ). includes ( "Browser RPC Patched" ), "patch should update markdown file" );
2026-07-04 21:47:42 +08:00
if ( await editor . isVisible ({ timeout : 1000 }). catch (() => false )) {
await page . waitForFunction (() => {
const editorNode = document . querySelector ( ".ProseMirror" );
return ( editorNode ? . textContent || "" ). includes ( "Browser RPC Patched" );
}, null , { timeout : UI_TIMEOUT_MS });
}
console . log ( " ✅ allowed-roots deny and markdown patch receipt exercised" );
await page . waitForFunction (() => {
const text = document . querySelector ( "[data-page-ai-pi-lab-receipts]" ) ? . textContent || "" ;
return text . includes ( "denied" ) && text . includes ( "diff" );
}, null , { timeout : UI_TIMEOUT_MS });
2026-07-11 20:39:18 +08:00
const currentPageReadResp = await page . request . post ( ` ${ BASE } /api/page-ai/pi/tool-call` , {
headers : authHeaders (),
data : {
sessionId ,
toolName : "mnote.current_page.read" ,
params : { rootUri , pagePath },
},
});
assert ( currentPageReadResp . ok (), `current page read HTTP ${ currentPageReadResp . status () } ` );
const currentPageReadJson = await currentPageReadResp . json ();
assert . equal ( currentPageReadJson . ok , true , "current page read should succeed after patch" );
assert ( String ( currentPageReadJson . result . content || "" ). includes ( "Browser RPC Patched" ), "current page read should return patched content" );
2026-07-10 10:54:34 +08:00
const toolTimelines = await page . locator ( "[data-page-ai-pi-lab-tool-timeline], [data-page-ai-pi-tool-timeline]" ). all ();
for ( const timeline of toolTimelines ) {
await timeline . evaluate (( node ) => { node . open = true ; }). catch (() => {});
}
2026-07-11 20:39:18 +08:00
console . log ( " ✅ current-page tool reads patched file content" );
2026-07-04 21:47:42 +08:00
fs . mkdirSync ( path . dirname ( SCREENSHOT ), { recursive : true });
await page . screenshot ({ path : SCREENSHOT , fullPage : false });
console . log ( ` ✅ screenshot: ${ SCREENSHOT } ` );
const abortResp = await page . request . post ( ` ${ BASE } /api/page-ai/pi/abort` , {
headers : authHeaders (),
data : { sessionId },
});
assert ( abortResp . ok (), `abort HTTP ${ abortResp . status () } ` );
await page . waitForFunction (() => {
const text = document . querySelector ( "[data-page-ai-pi-lab-status-text]" ) ? . textContent || "" ;
return text . includes ( "aborted" );
}, null , { timeout : UI_TIMEOUT_MS });
console . log ( " ✅ abort reflected in UI state" );
const domEvidence = await page . evaluate (() => {
const openHubDrawer = document . querySelector ( '[data-testid="wolai-page-ai-drawer"]' );
const piDrawer = document . querySelector ( '[data-page-ai-pi-lab="drawer"]' );
return {
piDrawerVisible : Boolean ( piDrawer && getComputedStyle ( piDrawer ). display !== "none" ),
piDrawerHasIframe : Boolean ( piDrawer && piDrawer . querySelector ( "iframe" )),
piInsideOpenHub : Boolean ( openHubDrawer && piDrawer && openHubDrawer . contains ( piDrawer )),
model : document . querySelector ( "[data-page-ai-pi-lab-model-chip]" ) ? . textContent || "" ,
};
});
assert ( domEvidence . piDrawerVisible , "Pi drawer should remain visible" );
assert . equal ( domEvidence . piDrawerHasIframe , false , "Pi drawer must not iframe a second app" );
assert . equal ( domEvidence . piInsideOpenHub , false , "Pi drawer must not be inside OpenHub drawer" );
2026-07-11 20:39:18 +08:00
assert ( domEvidence . model . includes ( "omniroute/gpt-5.4-mini" ), `model chip mismatch: ${ domEvidence . model } ` );
2026-07-04 21:47:42 +08:00
console . log ( " ✅ Pi Lab remains native, independent and non-iframe" );
const severe = consoleErrors . filter (( entry ) => / Failed to load module script | MIME type | Uncaught | TypeError | ReferenceError / i . test ( entry ));
assert . equal ( severe . length , 0 , `severe console errors: ${ severe . join ( " | " ) } ` );
console . log ( "\n✅ Pi Lab RPC browser smoke passed\n" );
} catch ( error ) {
console . error ( `\n❌ Pi Lab RPC browser smoke failed: ${ error . message } ` );
if ( consoleErrors . length ) console . error ( consoleErrors . slice ( 0 , 10 ). join ( "\n" ));
process . exitCode = 1 ;
} finally {
await browser . close ();
}
}
main ();