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

190 lines
8.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// Pi Lab mock-mode API smoke
// 需要以 MNOTE_PAGE_AI_PI_LAB_RUNTIME=mock 启动 mnote-webPi Lab 默认开启。
// 验证 start/send/abort/events、allowed roots、越界拒绝、当前页 read、文件 patch、receipt。
"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";
const ROOT = process.env.MNOTE_PI_LAB_SMOKE_ROOT || fs.mkdtempSync(path.join(os.tmpdir(), "mnote-pi-lab-"));
const DIRECT_TOOL_CALL_APPROVAL = process.env.MNOTE_PI_LAB_DIRECT_TOOL_APPROVAL === "1";
function assert(condition, message) {
if (!condition) throw new Error(message);
}
async function fetchJson(url, options = {}) {
const headers = {
"Content-Type": "application/json",
Authorization: AUTH,
...(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 };
}
async function main() {
fs.mkdirSync(ROOT, { recursive: true });
const pagePath = "page.md";
const pageFile = path.join(ROOT, pagePath);
fs.writeFileSync(pageFile, "# Pi Lab smoke\n\nOriginal body\n", "utf8");
const rootUri = `file://${ROOT}`;
console.log(`\n🧪 Pi Lab mock API smoke (base: ${BASE}, root: ${ROOT})\n`);
const status = await fetchJson(`${BASE}/api/page-ai/pi/status`);
assert(status.status === 200, `status endpoint returned ${status.status}`);
assert(status.body.schema === "mnote.page_ai_pi.status.v1", "status schema mismatch");
assert(status.body.enabled === true, "Pi Lab must be enabled for mock smoke");
assert(status.body.uiMode === "independent_mnote_native_drawer", "Pi Lab should report independent native drawer UI mode");
assert(status.body.runtimeMode === "mock", `runtimeMode must be mock, got ${status.body.runtimeMode}`);
console.log(" ✅ status enabled/mock");
const start = await fetchJson(`${BASE}/api/page-ai/pi/start`, {
method: "POST",
body: JSON.stringify({
rootUri,
workspaceId: "pi-lab-smoke",
pagePath,
pageTitle: "Pi Lab smoke",
}),
});
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");
const sessionId = start.body.session && start.body.session.sessionId;
assert(sessionId, "start did not return sessionId");
assert(start.body.session.allowedRootsSnapshot, "start did not capture allowed roots snapshot");
console.log(` ✅ start session ${sessionId}`);
const events = await fetch(`${BASE}/api/page-ai/pi/events?sessionId=${encodeURIComponent(sessionId)}`, {
headers: { Authorization: AUTH, Accept: "text/event-stream" },
});
assert(events.status === 200, `events returned ${events.status}`);
assert((events.headers.get("content-type") || "").includes("text/event-stream"), "events is not SSE");
if (events.body && events.body.cancel) await events.body.cancel();
console.log(" ✅ events SSE");
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");
console.log(" ✅ allowed roots describe");
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");
console.log(" ✅ current page read");
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.receipt, "denied read should still write receipt");
assert(denied.body.approvalRequired === true, "direct local file read should require approval before path checks");
console.log(" ✅ out-of-root read blocked by ask policy with receipt");
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 body" }],
},
}),
});
if (DIRECT_TOOL_CALL_APPROVAL) {
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(fs.readFileSync(pageFile, "utf8").includes("Patched body"), "patched file content mismatch");
console.log(" ✅ local file patch + watcher refresh metadata");
} else {
assert(patch.status === 200, `patch returned ${patch.status}`);
assert(patch.body.ok === false, "direct local file patch should require UI/bridge approval");
assert(patch.body.approvalRequired === true, "patch should expose approvalRequired");
assert(String(patch.body.result?.code || "") === "page_ai_pi_lab_tool_approval_required", "patch should be blocked by ask policy");
assert(fs.readFileSync(pageFile, "utf8").includes("Original body"), "unapproved patch must not modify file");
console.log(" ✅ direct local file patch blocked by ask policy");
}
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 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");
console.log(` ✅ LightRAG facade exercised (${rag.body.ok ? "ok" : "provider unavailable/denied"})`);
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");
console.log(` ✅ citation/open-reference facade exercised (${reference.body.ok ? "ok" : "provider unavailable/denied"})`);
const send = await fetchJson(`${BASE}/api/page-ai/pi/send`, {
method: "POST",
body: JSON.stringify({ sessionId, message: "hello from smoke" }),
});
assert(send.status === 200 && send.body.accepted === true, "send did not accept prompt");
console.log(" ✅ send prompt");
const abort = await fetchJson(`${BASE}/api/page-ai/pi/abort`, {
method: "POST",
body: JSON.stringify({ sessionId }),
});
assert(abort.status === 200 && abort.body.aborted === true, "abort failed");
console.log(" ✅ abort");
console.log("\n✅ Pi Lab mock API smoke passed.\n");
}
main().catch((error) => {
console.error(`\n❌ Pi Lab mock API smoke failed: ${error.message}`);
process.exit(1);
});