381 lines
19 KiB
JavaScript
381 lines
19 KiB
JavaScript
#!/usr/bin/env node
|
|
// Pi Lab RPC browser smoke
|
|
// 验证真实 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";
|
|
|
|
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}`);
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
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 } });
|
|
await context.addInitScript(() => {
|
|
window.__MNOTE_PI_LAB_TEST__ = true;
|
|
});
|
|
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");
|
|
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) {
|
|
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");
|
|
|
|
await page.locator("[data-page-ai-pi-lab-new]").click();
|
|
await page.waitForFunction(() => {
|
|
const state = window.__mnotePiLabTest?.getState?.();
|
|
return state && !state.sessionId && state.status === "idle";
|
|
}, null, { timeout: UI_TIMEOUT_MS });
|
|
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}`);
|
|
|
|
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;
|
|
}
|
|
}
|
|
});
|
|
const selectionDetected = await page.waitForFunction(() => {
|
|
const text = document.querySelector("[data-page-ai-pi-lab-selection]")?.textContent || "";
|
|
return text.includes("已选中");
|
|
}, 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");
|
|
}
|
|
}
|
|
|
|
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");
|
|
|
|
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" } },
|
|
});
|
|
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");
|
|
|
|
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");
|
|
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");
|
|
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 });
|
|
|
|
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(" ✅ current-page tool reads patched file content");
|
|
|
|
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");
|
|
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));
|
|
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();
|