Files
mnote/scripts/task-pi-lab-rpc-api-smoke.js
T

581 lines
26 KiB
JavaScript
Raw Normal View History

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);
});