chore: checkpoint pi lab rust integration work
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
// Pi Lab RPC browser smoke
|
||||
// 验证真实 Pi RPC + Omniroute/freefirst 在 MNote-native Pi Lab 抽屉中的可见 stream。
|
||||
// 验证真实 Pi RPC + Omniroute/gpt-5.4-mini 在 MNote-native Pi Lab 抽屉中的可见 stream。
|
||||
// 需要 mnote-web 已以 MNOTE_PAGE_AI_PI_LAB_RUNTIME=rpc 启动,并配置 MNOTE_PAGE_AI_PI_BIN / Omniroute key。
|
||||
|
||||
"use strict";
|
||||
@@ -92,6 +92,9 @@ async function main() {
|
||||
executablePath: CHROMIUM_EXECUTABLE || undefined,
|
||||
});
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||
await context.addInitScript(() => {
|
||||
window.__MNOTE_PI_LAB_TEST__ = true;
|
||||
});
|
||||
await addAuth(context);
|
||||
const page = await context.newPage();
|
||||
const consoleErrors = [];
|
||||
@@ -109,8 +112,15 @@ async function main() {
|
||||
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");
|
||||
assert.equal(statusBeforeJson.defaultModelId, "freefirst", "default model must be freefirst");
|
||||
console.log(" ✅ status reports rpc + omniroute/freefirst");
|
||||
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");
|
||||
|
||||
await quickLoginIfNeeded(page);
|
||||
if (!process.env.MNOTE_PI_LAB_BROWSER_URL) {
|
||||
@@ -150,26 +160,12 @@ async function main() {
|
||||
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");
|
||||
|
||||
await page.locator("[data-page-ai-pi-lab-new]").click();
|
||||
await page.waitForFunction(() => {
|
||||
const text = document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent || "";
|
||||
return text.includes("ready");
|
||||
const state = window.__mnotePiLabTest?.getState?.();
|
||||
return state && !state.sessionId && state.status === "idle";
|
||||
}, null, { timeout: UI_TIMEOUT_MS });
|
||||
const statusAfterStart = await page.request.get(`${BASE}/api/page-ai/pi/status`, { headers: authHeaders() });
|
||||
const statusAfterStartJson = await statusAfterStart.json();
|
||||
const activeSession = statusAfterStartJson.session || {};
|
||||
const sessionId = activeSession.sessionId || statusAfterStartJson.sessionId;
|
||||
assert(sessionId, "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 new session starts, got ${activeSession.pagePath}`);
|
||||
const firstAllowedRoot = activeSession.allowedRootsSnapshot?.roots?.[0] || null;
|
||||
if (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");
|
||||
}
|
||||
console.log(` ✅ Pi RPC runtime started, session=${sessionId}, pid=${activeSession.runtimePid || statusAfterStartJson.pid}`);
|
||||
console.log(" ✅ reset to a fresh Pi Lab conversation before auto-start assertions");
|
||||
|
||||
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}`);
|
||||
@@ -222,6 +218,65 @@ async function main() {
|
||||
);
|
||||
console.log(" ✅ clicked 使用当前页 and enabled current-page context");
|
||||
|
||||
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");
|
||||
|
||||
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" } },
|
||||
@@ -230,23 +285,24 @@ async function main() {
|
||||
const denyJson = await denyResp.json();
|
||||
assert.equal(denyJson.ok, false, "out-of-root read must be denied");
|
||||
|
||||
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" }],
|
||||
},
|
||||
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" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
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");
|
||||
assert(String(patchJson.result.refresh || "").includes("watcher"), "patch should declare watcher refresh");
|
||||
assert(fs.readFileSync(pageFile, "utf8").includes("Browser RPC Patched"), "patch should update markdown file");
|
||||
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");
|
||||
if (await editor.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
await page.waitForFunction(() => {
|
||||
const editorNode = document.querySelector(".ProseMirror");
|
||||
@@ -260,30 +316,23 @@ async function main() {
|
||||
return text.includes("denied") && text.includes("diff");
|
||||
}, null, { timeout: UI_TIMEOUT_MS });
|
||||
|
||||
await page.locator("[data-page-ai-pi-lab-input]").fill(
|
||||
`必须调用 mnote_current_page_read。工具结果 content 包含 "Browser RPC Patched" 后,只回复 ${MARKER},不要解释。`,
|
||||
);
|
||||
await page.locator("[data-page-ai-pi-lab-btn-send]").click();
|
||||
const assistantMarker = 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();
|
||||
await assistantMarker.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const visibleText = await assistantMarker.textContent();
|
||||
assert.equal(visibleText.trim(), MARKER, "visible assistant bubble should equal marker");
|
||||
assert(!visibleText.includes("thinking_delta"), "provider thinking event name must not be visible");
|
||||
assert(!visibleText.includes("我们被问到"), "provider reasoning text must not leak into final visible reply");
|
||||
const sessionEvidence = readSessionEvidence(activeSession.piSessionDir);
|
||||
assert(sessionEvidence.includes('"toolName":"mnote_current_page_read"'), "session should record mnote_current_page_read");
|
||||
assert(sessionEvidence.includes('"transport":"pi-rust-native-fs"'), "session should record Pi Rust native fs transport");
|
||||
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");
|
||||
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(() => {});
|
||||
}
|
||||
console.log(" ✅ real browser conversation used mnote_current_page_read via pi-rust-native-fs");
|
||||
console.log(" ✅ current-page tool reads patched file content");
|
||||
|
||||
fs.mkdirSync(path.dirname(SCREENSHOT), { recursive: true });
|
||||
await page.screenshot({ path: SCREENSHOT, fullPage: false });
|
||||
@@ -313,7 +362,7 @@ async function main() {
|
||||
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");
|
||||
assert(domEvidence.model.includes("omniroute/freefirst"), `model chip mismatch: ${domEvidence.model}`);
|
||||
assert(domEvidence.model.includes("omniroute/gpt-5.4-mini"), `model chip mismatch: ${domEvidence.model}`);
|
||||
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));
|
||||
|
||||
Reference in New Issue
Block a user