2026-07-04 21:47:42 +08:00
#!/usr/bin/env node
// Pi Lab RPC-mode API smoke
2026-07-10 10:54:34 +08:00
// 验证 MNOTE_PAGE_AI_PI_LAB_RUNTIME=rpc + Pi Rust runtime 下:
2026-07-04 21:47:42 +08:00
// start/send/abort/tool-call/越界拒绝/.md patch
2026-07-10 10:54:34 +08:00
// 需要:mnote-web 已以 MNOTE_PAGE_AI_PI_LAB_RUNTIME=rpc 启动,默认使用 Pi Rust。
2026-07-04 21:47:42 +08:00
// 可选:MNOTE_PI_LAB_OPENAI_API_KEY(真实 send 需要 API key)
"use strict" ;
const fs = require ( "node:fs" );
const os = require ( "node:os" );
const path = require ( "node:path" );
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" ;
2026-07-10 10:54:34 +08:00
const ACTOR_ID = process . env . MNOTE_PI_LAB_ACTOR_ID || process . env . MNOTE_E2E_ACTOR_ID || "mnote-e2e" ;
const ACTOR_TYPE = process . env . MNOTE_PI_LAB_ACTOR_TYPE || "user" ;
const DEFAULT_E2E_ROOT = "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space" ;
const ROOT_FROM_ENV = process . env . MNOTE_PI_LAB_SMOKE_ROOT ;
const USING_DEFAULT_E2E_ROOT = ! ROOT_FROM_ENV && ACTOR_ID === "mnote-e2e" && fs . existsSync ( DEFAULT_E2E_ROOT );
const ROOT = ROOT_FROM_ENV
|| ( USING_DEFAULT_E2E_ROOT
? DEFAULT_E2E_ROOT
: fs . mkdtempSync ( path . join ( os . tmpdir (), "mnote-pi-rpc-" )));
const PAGE_PATH = process . env . MNOTE_PI_LAB_PAGE_PATH
|| ( USING_DEFAULT_E2E_ROOT
? `.mnote/smoke/pi-rpc- ${ Date . now () } .md`
: "page.md" );
const PI_BIN_ENV = process . env . MNOTE_PAGE_AI_PI_RUST_BIN
|| process . env . MNOTE_PAGE_AI_PI_BIN
|| "/home/lix/.local/share/mnote/pi-rust/bin/pi" ;
2026-07-04 21:47:42 +08:00
const HAS_API_KEY = !! ( process . env . MNOTE_PI_LAB_OPENAI_API_KEY || process . env . OPENAI_API_KEY );
2026-07-10 10:54:34 +08:00
const STRICT_TOOL_CALL = process . env . MNOTE_PI_LAB_STRICT_TOOL_CALL === "1" ;
const WORKSPACE_ID = "pi-lab-rpc-smoke" ;
2026-07-04 21:47:42 +08:00
function assert ( condition , message ) {
if ( ! condition ) throw new Error ( message );
}
async function fetchJson ( url , options = {}) {
const headers = {
"Content-Type" : "application/json" ,
Authorization : AUTH ,
2026-07-10 10:54:34 +08:00
"x-mnote-actor-id" : ACTOR_ID ,
"x-mnote-actor-type" : ACTOR_TYPE ,
2026-07-04 21:47:42 +08:00
...( options . headers || {}),
};
const res = await fetch ( url , { ... options , headers });
const text = await res . text ();
let body = {};
try {
body = text ? JSON . parse ( text ) : {};
} catch {
body = { raw : text };
}
return { status : res . status , body , headers : res . headers };
}
2026-07-10 10:54:34 +08:00
async function seedWorkspaceAccess ( rootUri , rootPath ) {
const seed = await fetchJson ( ` ${ BASE } /api/dev/seed` , {
method : "POST" ,
body : JSON . stringify ({
seeds : [{
kind : "setupWorkspace" ,
user_id : ACTOR_ID ,
email : ` ${ ACTOR_ID } @example.com` ,
username : ACTOR_ID ,
display_name : ACTOR_ID ,
role : ACTOR_TYPE ,
workspace_id : WORKSPACE_ID ,
workspace_name : "Pi Lab RPC smoke" ,
root_uri : rootUri ,
root_path : rootPath ,
source_kind : "local_folder" ,
permission : "write" ,
capabilities : [ "ai" ],
grant_source : "pi_lab_rpc_smoke" ,
grant_created_by : ACTOR_ID ,
}],
}),
});
if ( seed . status === 403 && seed . body && seed . body . code === "dev_seed_disabled" ) {
2026-07-11 20:34:21 +08:00
throw new Error (
"Pi RPC smoke 需要 /api/dev/seed;请使用 `npm run dev:hot` 启动并重试。dev:hot 默认开启 MNOTE_WEB_ALLOW_DEV_FIXTURES=1,修改启动环境后必须重启 Node 主进程。" ,
);
2026-07-10 10:54:34 +08:00
}
assert ( seed . status === 200 && seed . body . ok === true , `/api/dev/seed failed: ${ seed . status } ${ JSON . stringify ( seed . body ) } ` );
return { ok : true , skipped : false };
}
2026-07-04 21:47:42 +08:00
/**
* Collect SSE events from /api/page-ai/pi/events for a short window.
*/
function collectSseEvents ( sessionId , timeoutMs = 5000 ) {
return new Promise (( resolve ) => {
const events = [];
const url = ` ${ BASE } /api/page-ai/pi/events?sessionId= ${ encodeURIComponent ( sessionId ) } ` ;
const controller = new AbortController ();
const timer = setTimeout (() => {
controller . abort ();
}, timeoutMs );
fetch ( url , {
headers : {
Authorization : AUTH ,
2026-07-10 10:54:34 +08:00
"x-mnote-actor-id" : ACTOR_ID ,
"x-mnote-actor-type" : ACTOR_TYPE ,
2026-07-04 21:47:42 +08:00
Accept : "text/event-stream" ,
},
signal : controller . signal ,
})
. then (( res ) => {
if ( ! res . ok ) {
events . push ({ error : `SSE returned ${ res . status } ` });
clearTimeout ( timer );
resolve ( events );
return ;
}
const reader = res . body . getReader ();
const decoder = new TextDecoder ();
let buffer = "" ;
function read () {
reader
. read ()
. then (({ done , value }) => {
if ( done ) {
clearTimeout ( timer );
resolve ( events );
return ;
}
buffer += decoder . decode ( value , { stream : true });
const lines = buffer . split ( "\n" );
buffer = lines . pop () || "" ;
for ( const line of lines ) {
if ( line . startsWith ( "data: " )) {
try {
const parsed = JSON . parse ( line . slice ( 6 ));
events . push ( parsed );
} catch {
// skip parse errors
}
}
}
read ();
})
. catch (( err ) => {
if ( err . name !== "AbortError" ) {
events . push ({ error : err . message });
}
clearTimeout ( timer );
resolve ( events );
});
}
read ();
})
. catch (( err ) => {
if ( err . name !== "AbortError" ) {
events . push ({ error : err . message });
}
clearTimeout ( timer );
resolve ( events );
});
});
}
/**
* Wait for a specific SSE event kind.
*/
async function waitForSseEvent ( sessionId , targetKind , timeoutMs = 15000 ) {
const deadline = Date . now () + timeoutMs ;
while ( Date . now () < deadline ) {
const events = await collectSseEvents ( sessionId , 3000 );
const matched = events . find (( e ) => e . kind === targetKind );
if ( matched ) return matched ;
await new Promise (( r ) => setTimeout ( r , 500 ));
}
return null ;
}
async function main () {
fs . mkdirSync ( ROOT , { recursive : true });
2026-07-10 10:54:34 +08:00
const pagePath = PAGE_PATH ;
2026-07-04 21:47:42 +08:00
const pageFile = path . join ( ROOT , pagePath );
2026-07-10 10:54:34 +08:00
fs . mkdirSync ( path . dirname ( pageFile ), { recursive : true });
2026-07-04 21:47:42 +08:00
fs . writeFileSync ( pageFile , "# Pi Lab RPC smoke\n\nOriginal body\n" , "utf8" );
const rootUri = `file:// ${ ROOT } ` ;
console . log ( `\n🧪 Pi Lab RPC API smoke (base: ${ BASE } , root: ${ ROOT } )` );
console . log ( ` Pi binary: ${ PI_BIN_ENV } ` );
2026-07-10 10:54:34 +08:00
console . log ( ` Actor: ${ ACTOR_ID } ` );
console . log ( ` Page path: ${ pagePath } ` );
2026-07-04 21:47:42 +08:00
console . log ( ` API key available: ${ HAS_API_KEY } \n` );
let allPassed = true ;
const pass = ( msg ) => { console . log ( ` ✅ ${ msg } ` ); };
const fail = ( msg ) => { console . error ( ` ❌ ${ msg } ` ); allPassed = false ; };
2026-07-10 10:54:34 +08:00
try {
const seeded = await seedWorkspaceAccess ( rootUri , ROOT );
if ( seeded . skipped ) {
pass ( "dev seed disabled; using existing control-plane directory grants" );
} else {
pass ( "seeded control-plane user/workspace/directory grant for RPC smoke" );
}
} catch ( err ) {
fail ( `dev seed: ${ err . message } ` );
}
2026-07-04 21:47:42 +08:00
// ── 1. Status: enabled=true, runtimeMode=rpc ──────────────────────
try {
const status = await fetchJson ( ` ${ BASE } /api/page-ai/pi/status` );
assert ( status . status === 200 , `status returned ${ status . status } ` );
assert ( status . body . schema === "mnote.page_ai_pi.status.v1" , "status schema mismatch" );
assert ( status . body . enabled === true , "MNOTE_PAGE_AI_PI_LAB must be enabled for RPC smoke" );
assert ( status . body . runtimeMode === "rpc" , `runtimeMode must be rpc, got ${ status . body . runtimeMode } ` );
2026-07-10 10:54:34 +08:00
assert ( status . body . runtimeImplementation === "pi-rust" , `runtimeImplementation must default to pi-rust, got ${ status . body . runtimeImplementation } ` );
assert ( status . body . runtimeBinary , "status should expose runtimeBinary for Pi Rust diagnostics" );
assert ( Array . isArray ( status . body . managedPiBuiltinTools ), "missing managedPiBuiltinTools" );
2026-07-11 20:34:21 +08:00
assert ( status . body . managedPiBuiltinTools . length === 0 , "status without an active full_access session should not claim raw builtins are enabled" );
2026-07-04 21:47:42 +08:00
assert ( status . body . uiMode === "independent_mnote_native_drawer" , "Pi Lab should report independent native drawer UI mode" );
2026-07-10 10:54:34 +08:00
pass ( "status enabled/rpc with Rust bridge policy and independent Pi Lab UI mode" );
2026-07-04 21:47:42 +08:00
} catch ( err ) {
fail ( `status check: ${ err . message } ` );
}
// ── 2. Start session (triggers real Pi RPC subprocess) ────────────
let sessionId = null ;
try {
const start = await fetchJson ( ` ${ BASE } /api/page-ai/pi/start` , {
method : "POST" ,
body : JSON . stringify ({
rootUri ,
2026-07-10 10:54:34 +08:00
workspaceId : WORKSPACE_ID ,
2026-07-04 21:47:42 +08:00
pagePath ,
pageTitle : "Pi Lab RPC smoke" ,
2026-07-10 10:54:34 +08:00
permissionMode : "auto_edit" ,
2026-07-04 21:47:42 +08:00
}),
});
assert ( start . status === 200 , `start returned ${ start . status } : ${ JSON . stringify ( start . body ) } ` );
assert ( start . body . schema === "mnote.page_ai_pi.start.v1" , "start schema mismatch" );
sessionId = start . body . session && start . body . session . sessionId ;
assert ( sessionId , "start did not return sessionId" );
assert ( start . body . session . runtimeMode === "rpc" , `runtimeMode must be rpc, got ${ start . body . session . runtimeMode } ` );
assert ( start . body . session . runtimePid , "RPC mode must have runtimePid (real Pi subprocess PID)" );
assert ( typeof start . body . session . runtimePid === "number" , "runtimePid must be a number" );
assert ( start . body . session . runtimePid > 0 , "runtimePid must be positive" );
assert ( start . body . session . allowedRootsSnapshot , "start did not capture allowed roots snapshot" );
2026-07-10 10:54:34 +08:00
assert ( Array . isArray ( start . body . managedPiBuiltinTools ), "missing managedPiBuiltinTools in start" );
2026-07-11 20:34:21 +08:00
assert . deepEqual (
[... start . body . managedPiBuiltinTools ]. sort (),
[ "edit" , "find" , "grep" , "hashline_edit" , "ls" , "read" , "write" ]. sort (),
"auto_edit should expose Pi Rust read/write/edit builtins but keep bash for full_access" ,
);
2026-07-10 10:54:34 +08:00
assert ( start . body . mnoteToolOnly === false , "start should expose MNote bridge tools through Pi Rust extension" );
2026-07-04 21:47:42 +08:00
pass ( `start session ${ sessionId } with real Pi PID ${ start . body . session . runtimePid } ` );
} catch ( err ) {
fail ( `start: ${ err . message } ` );
}
// ── 3. SSE events endpoint ────────────────────────────────────────
if ( sessionId ) {
try {
const evtUrl = ` ${ BASE } /api/page-ai/pi/events?sessionId= ${ encodeURIComponent ( sessionId ) } ` ;
const evtRes = await fetch ( evtUrl , {
2026-07-10 10:54:34 +08:00
headers : {
Authorization : AUTH ,
"x-mnote-actor-id" : ACTOR_ID ,
"x-mnote-actor-type" : ACTOR_TYPE ,
Accept : "text/event-stream" ,
},
2026-07-04 21:47:42 +08:00
signal : AbortSignal . timeout ( 3000 ),
}). catch (() => null );
if ( evtRes && evtRes . ok ) {
const ct = evtRes . headers . get ( "content-type" ) || "" ;
assert ( ct . includes ( "text/event-stream" ), `unexpected Content-Type: ${ ct } ` );
pass ( "events SSE endpoint returns text/event-stream" );
} else {
// May fail if session has no events yet - accept 200 only
assert ( evtRes && evtRes . status === 200 , `events returned ${ evtRes ? evtRes . status : "timeout" } ` );
}
} catch ( err ) {
fail ( `events SSE: ${ err . message } ` );
}
// ── 4. Verify runtime start evidence ───────────────────────────
// runtime_started may be published before the smoke attaches to SSE, so
// treat the start response runtimePid as the hard assertion and use SSE as
// opportunistic event evidence.
try {
const runtimeEvent = await waitForSseEvent ( sessionId , "runtime_started" , 5000 );
if ( runtimeEvent ) {
const payload = runtimeEvent . payload || {};
assert ( payload . mode === "rpc" , `mode must be rpc, got ${ payload . mode } ` );
assert ( typeof payload . pid === "number" , "PID must be a number in event" );
2026-07-10 10:54:34 +08:00
assert ( Array . isArray ( payload . managedBuiltinTools ), "missing managedBuiltinTools in event" );
2026-07-11 20:34:21 +08:00
assert . deepEqual (
[... payload . managedBuiltinTools ]. sort (),
[ "edit" , "find" , "grep" , "hashline_edit" , "ls" , "read" , "write" ]. sort (),
"runtime event should expose auto_edit Pi Rust builtins" ,
);
2026-07-04 21:47:42 +08:00
pass ( `runtime_started event observed (mode=rpc, pid= ${ payload . pid } )` );
} else {
const statusAfterStart = await fetchJson ( ` ${ BASE } /api/page-ai/pi/status` );
assert ( statusAfterStart . status === 200 , `status after start returned ${ statusAfterStart . status } ` );
assert ( statusAfterStart . body . running === true , "status after start must show running=true" );
assert ( statusAfterStart . body . pid , "status after start must expose runtime PID" );
pass ( `runtime start confirmed by status (pid= ${ statusAfterStart . body . pid } ); runtime_started SSE was already consumed` );
}
} catch ( err ) {
fail ( `runtime start evidence: ${ err . message } ` );
}
}
// ── 5. Tool call: mnote.allowed_roots.describe ────────────────────
if ( sessionId ) {
try {
const allowed = await fetchJson ( ` ${ BASE } /api/page-ai/pi/tool-call` , {
method : "POST" ,
body : JSON . stringify ({
sessionId ,
toolName : "mnote.allowed_roots.describe" ,
params : {},
}),
});
assert ( allowed . status === 200 , `allowed roots returned ${ allowed . status } ` );
assert ( allowed . body . ok === true , "allowed roots tool should be allowed" );
assert ( Array . isArray ( allowed . body . result . allowedRoots ), "allowed roots missing" );
assert ( allowed . body . receipt , "missing receipt" );
2026-07-10 10:54:34 +08:00
assert ( allowed . body . receipt . storage === "control_plane_turso_libsql_v1" , "receipt storage mismatch" );
2026-07-04 21:47:42 +08:00
pass ( "mnote.allowed_roots.describe returns allowed roots and receipt" );
} catch ( err ) {
fail ( `allowed_roots.describe: ${ err . message } ` );
}
// ── 6. Tool call: current page read ─────────────────────────────
try {
const currentPage = await fetchJson ( ` ${ BASE } /api/page-ai/pi/tool-call` , {
method : "POST" ,
body : JSON . stringify ({
sessionId ,
toolName : "mnote.current_page.read" ,
params : { rootUri , pagePath },
}),
});
assert ( currentPage . status === 200 && currentPage . body . ok === true , "current page read failed" );
assert ( String ( currentPage . body . result . content ). includes ( "Original body" ), "current page content mismatch" );
assert ( currentPage . body . result . format === "markdown" , "format must be markdown" );
assert ( currentPage . body . result . fileVersion , "missing fileVersion" );
pass ( "mnote.current_page.read returns page content with fileVersion" );
} catch ( err ) {
fail ( `current_page.read: ${ err . message } ` );
}
// ── 7. Tool call: out-of-bounds read denied ─────────────────────
try {
const denied = await fetchJson ( ` ${ BASE } /api/page-ai/pi/tool-call` , {
method : "POST" ,
body : JSON . stringify ({
sessionId ,
toolName : "mnote.local_file.read" ,
params : { path : "/etc/hosts" },
}),
});
assert ( denied . status === 200 , `deny read returned ${ denied . status } ` );
assert ( denied . body . ok === false , "out-of-root read should be denied" );
assert ( denied . body . result . ok === false , "denied result must contain ok=false" );
assert ( denied . body . receipt , "denied read should still write receipt" );
2026-07-10 10:54:34 +08:00
assert ( denied . body . receipt . storage === "control_plane_turso_libsql_v1" , "receipt storage mismatch" );
2026-07-04 21:47:42 +08:00
pass ( "out-of-root read denied with receipt" );
} catch ( err ) {
fail ( `out-of-bound read: ${ err . message } ` );
}
// ── 8. Tool call: patch current .md file ────────────────────────
try {
const patch = await fetchJson ( ` ${ BASE } /api/page-ai/pi/tool-call` , {
method : "POST" ,
body : JSON . stringify ({
sessionId ,
toolName : "mnote.local_file.patch" ,
params : {
rootUri ,
path : pagePath ,
operations : [{ op : "replace" , old : "Original body" , new : "Patched by RPC smoke" }],
},
}),
});
assert ( patch . status === 200 && patch . body . ok === true , "local file patch failed" );
assert ( patch . body . result . polling === false , "patch must not request polling" );
assert ( String ( patch . body . result . refresh || "" ). includes ( "watcher" ), "patch must declare watcher refresh" );
assert ( patch . body . result . beforeFileVersion , "missing beforeFileVersion" );
assert ( patch . body . result . afterFileVersion !== patch . body . result . beforeFileVersion ,
"file version must change after patch" );
assert ( patch . body . result . diffSummary , "missing diffSummary" );
const patchedContent = fs . readFileSync ( pageFile , "utf8" );
assert ( patchedContent . includes ( "Patched by RPC smoke" ), "patched file content mismatch" );
assert ( ! patchedContent . includes ( "Original body" ), "old content should be replaced in file" );
assert ( patch . body . receipt , "missing receipt" );
pass ( "mnote.local_file.patch writes file + watcher refresh + version change + receipt" );
} catch ( err ) {
fail ( `local_file.patch: ${ err . message } ` );
}
// ── 9. Tool call: LightRAG knowledge_rag.query facade ───────────
try {
const rag = await fetchJson ( ` ${ BASE } /api/page-ai/pi/tool-call` , {
method : "POST" ,
body : JSON . stringify ({
sessionId ,
toolName : "mnote.knowledge_rag.query" ,
params : { rootUri , query : "Pi Lab RPC smoke" , topK : 1 },
}),
});
assert ( rag . status === 200 , `knowledge RAG facade returned HTTP ${ rag . status } ` );
assert ( typeof rag . body . ok === "boolean" , "knowledge RAG facade did not return tool envelope" );
assert ( rag . body . receipt , "missing receipt" );
// LightRAG may return empty results if no knowledge base indexed, that's OK
pass ( `knowledge_rag.query facade exercised (ok= ${ rag . body . ok } , receipt present)` );
} catch ( err ) {
fail ( `knowledge_rag.query: ${ err . message } ` );
}
// ── 10. Tool call: reference.open facade ────────────────────────
try {
const reference = await fetchJson ( ` ${ BASE } /api/page-ai/pi/tool-call` , {
method : "POST" ,
body : JSON . stringify ({
sessionId ,
toolName : "mnote.reference.open" ,
params : { rootUri , filePath : pagePath },
}),
});
assert ( reference . status === 200 , `reference.open facade returned HTTP ${ reference . status } ` );
assert ( typeof reference . body . ok === "boolean" , "reference.open facade did not return tool envelope" );
assert ( reference . body . receipt , "missing receipt" );
pass ( `reference.open facade exercised (ok= ${ reference . body . ok } )` );
} catch ( err ) {
fail ( `reference.open: ${ err . message } ` );
}
// ── 11. Send prompt (only if API key available) ─────────────────
if ( HAS_API_KEY ) {
try {
const marker = `REAL_PI_TOOL_BRIDGE_OK_ ${ Date . now () } ` ;
const pendingEvents = collectSseEvents ( sessionId , 45000 );
const send = await fetchJson ( ` ${ BASE } /api/page-ai/pi/send` , {
method : "POST" ,
body : JSON . stringify ({
sessionId ,
message : `You must call the available tool named mnote_current_page_read before answering. After reading the page, reply exactly ${ marker } if the tool result contains "Patched by RPC smoke". Do not explain.` ,
}),
});
assert ( send . status === 200 && send . body . accepted === true , "send did not accept prompt" );
assert ( send . body . schema === "mnote.page_ai_pi.send.v1" , "send schema mismatch" );
assert ( send . body . eventStream , "send must return eventStream path" );
pass ( "send accepts prompt and routes to Pi RPC stdin" );
const events = await pendingEvents ;
const eventText = JSON . stringify ( events );
const hasPiToolStart = events . some (( event ) =>
event . kind === "pi_rpc_event"
&& event . payload ? . type === "tool_execution_start"
2026-07-10 10:54:34 +08:00
&& ( event . payload ? . toolName === "mnote_current_page_read" || event . payload ? . toolName === "mnote.current_page.read" )
2026-07-04 21:47:42 +08:00
);
const hasMnoteBridgeTool = events . some (( event ) =>
event . kind === "tool_call"
&& event . payload ? . toolName === "mnote.current_page.read"
);
2026-07-10 10:54:34 +08:00
const hasBridgeEvidence = hasMnoteBridgeTool || eventText . includes ( marker );
if ( STRICT_TOOL_CALL ) {
assert ( hasPiToolStart , "Pi did not emit mnote_current_page_read tool_execution_start" );
assert ( hasMnoteBridgeTool , "MNote bridge did not execute mnote.current_page.read" );
assert ( eventText . includes ( marker ), "Pi final response marker not observed after tool call" );
pass ( "real Pi custom tool call flows through MNote bridge and returns marker" );
} else if ( hasPiToolStart && hasBridgeEvidence ) {
pass ( "real Pi custom tool call evidence observed through MNote bridge" );
} else {
pass ( "send accepted by Pi Rust runtime; tool-call evidence not strict in default smoke" );
}
2026-07-04 21:47:42 +08:00
} catch ( err ) {
fail ( `send: ${ err . message } ` );
}
} else {
console . log ( " ⏭ Send/abort skipped (set MNOTE_PI_LAB_OPENAI_API_KEY for real Pi RPC prompt test)" );
}
// ── 13. Abort ───────────────────────────────────────────────────
// Abort works even without API key - it kills the Pi subprocess
try {
const abort = await fetchJson ( ` ${ BASE } /api/page-ai/pi/abort` , {
method : "POST" ,
body : JSON . stringify ({ sessionId }),
});
assert ( abort . status === 200 , `abort returned ${ abort . status } ` );
assert ( abort . body . aborted === true , "abort must return aborted=true" );
assert ( abort . body . schema === "mnote.page_ai_pi.abort.v1" , "abort schema mismatch" );
pass ( "abort kills Pi RPC subprocess and returns aborted=true" );
} catch ( err ) {
fail ( `abort: ${ err . message } ` );
}
2026-07-11 20:34:21 +08:00
// ── 14. Verify aborted session leaves active status and persists in history ─
2026-07-04 21:47:42 +08:00
try {
const status2 = await fetchJson ( ` ${ BASE } /api/page-ai/pi/status` );
2026-07-11 20:34:21 +08:00
assert ( status2 . status === 200 , `status returned ${ status2 . status } ` );
assert ( status2 . body . session == null , "aborted session should not remain auto-resumable" );
const history = await fetchJson ( ` ${ BASE } /api/page-ai/pi/sessions/ ${ encodeURIComponent ( sessionId ) } ` );
assert ( history . status === 200 , `session history returned ${ history . status } ` );
2026-07-04 21:47:42 +08:00
assert (
2026-07-11 20:34:21 +08:00
history . body . session ? . status === "aborted" ,
`expected persisted session status aborted, got ${ history . body . session ? . status } `
2026-07-04 21:47:42 +08:00
);
2026-07-11 20:34:21 +08:00
pass ( "aborted session leaves active status and persists as aborted" );
2026-07-04 21:47:42 +08:00
} catch ( err ) {
fail ( `session status aborted: ${ err . message } ` );
}
// ── 15. Start a second session to verify multi-session lifecycle ─
try {
const start2 = await fetchJson ( ` ${ BASE } /api/page-ai/pi/start` , {
method : "POST" ,
body : JSON . stringify ({
rootUri ,
2026-07-10 10:54:34 +08:00
workspaceId : WORKSPACE_ID ,
2026-07-04 21:47:42 +08:00
pagePath ,
pageTitle : "Pi Lab RPC smoke 2" ,
2026-07-10 10:54:34 +08:00
permissionMode : "auto_edit" ,
2026-07-04 21:47:42 +08:00
}),
});
assert ( start2 . status === 200 , `second start returned ${ start2 . status } ` );
const session2Id = start2 . body . session && start2 . body . session . sessionId ;
assert ( session2Id , "second start did not return sessionId" );
assert ( session2Id !== sessionId , "second session must have different ID" );
assert ( start2 . body . session . runtimePid , "second session must also have runtimePid" );
pass ( `second session ${ session2Id } started with PID ${ start2 . body . session . runtimePid } ` );
// Clean up second session
const abort2 = await fetchJson ( ` ${ BASE } /api/page-ai/pi/abort` , {
method : "POST" ,
body : JSON . stringify ({ sessionId : session2Id }),
});
assert ( abort2 . status === 200 , `second abort returned ${ abort2 . status } ` );
assert ( abort2 . body . aborted === true , "second abort must return aborted=true" );
pass ( "second session abort cleans up Pi subprocess" );
} catch ( err ) {
fail ( `multi-session lifecycle: ${ err . message } ` );
}
}
// ── 16. Runtime browser asset integrity ───────────────────────────
try {
const res = await fetch ( ` ${ BASE } /api/mnote-browser-runtime/sidebar-page-ai-pi-lab-runtime.js` );
assert ( res . status === 200 , `runtime asset status ${ res . status } ` );
const text = await res . text ();
assert ( text . includes ( "createSidebarPageAiPiLabRuntime" ), "missing expected export" );
assert ( ! /setInterval\s*\(/ . test ( text ), "runtime should not use setInterval polling" );
assert ( text . includes ( "NO setInterval polling" ), "missing NO setInterval polling comment" );
assert ( text . includes ( "/api/page-ai/pi/start" ), "missing start endpoint" );
assert ( text . includes ( "/api/page-ai/pi/send" ), "missing send endpoint" );
assert ( text . includes ( "/api/page-ai/pi/abort" ), "missing abort endpoint" );
assert ( text . includes ( "/api/page-ai/pi/events" ), "missing events endpoint" );
pass ( "client runtime JS served with correct endpoints and no polling" );
} catch ( err ) {
fail ( `runtime asset: ${ err . message } ` );
}
// ── Summary ───────────────────────────────────────────────────────
if ( allPassed ) {
console . log ( "\n✅ Pi Lab RPC API smoke passed.\n" );
} else {
console . error ( "\n❌ Pi Lab RPC API smoke: some checks failed.\n" );
process . exit ( 1 );
}
}
main (). catch (( error ) => {
console . error ( `\n❌ Pi Lab RPC API smoke failed: ${ error . message } ` );
process . exit ( 1 );
});