348 lines
19 KiB
JavaScript
348 lines
19 KiB
JavaScript
#!/usr/bin/env node
|
|
// Pi Lab browser smoke
|
|
// 验证 Pi Lab 默认独立悬浮入口 + MNote-native drawer,不复用 OpenHub drawer/provider tab/iframe。
|
|
// 需要 mnote-web 已在运行;MNOTE_PAGE_AI_PI_LAB 默认开启,设为 0 时才强制关闭。
|
|
|
|
"use strict";
|
|
|
|
const assert = require("node:assert");
|
|
const fs = require("node:fs");
|
|
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_COOKIE = process.env.MNOTE_PI_LAB_AUTH || "";
|
|
const AUTH_HEADER = process.env.MNOTE_PI_LAB_AUTH_HEADER || "";
|
|
const TARGET_URL = process.env.MNOTE_PI_LAB_BROWSER_URL || `${BASE}/`;
|
|
const UI_TIMEOUT_MS = parseInt(process.env.UI_TIMEOUT_MS || "20000", 10);
|
|
const SCREENSHOT = process.env.MNOTE_PI_LAB_SCREENSHOT || path.join(
|
|
__dirname,
|
|
"..",
|
|
"tmp",
|
|
`page-ai-pi-lab-drawer-${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" : "");
|
|
|
|
async function addAuth(context) {
|
|
if (AUTH_HEADER) await context.setExtraHTTPHeaders({ Authorization: AUTH_HEADER });
|
|
if (!AUTH_COOKIE) return;
|
|
const cookies = AUTH_COOKIE.split(";").map((c) => {
|
|
const [name, ...rest] = c.trim().split("=");
|
|
return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" };
|
|
});
|
|
await context.addCookies(cookies);
|
|
}
|
|
|
|
async function quickLoginIfNeeded(page) {
|
|
await page.goto(`${BASE}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
|
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 approveVisiblePiDialog(page) {
|
|
const dialog = page.locator("[data-page-ai-pi-lab-ui-dialog='confirm']");
|
|
await dialog.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
await page.locator("[data-page-ai-pi-lab-ui-submit]").click();
|
|
}
|
|
|
|
async function main() {
|
|
console.log(`\n🧪 Pi Lab browser smoke (base: ${BASE})\n`);
|
|
let browserRoot = process.env.MNOTE_PI_LAB_BROWSER_ROOT || "/tmp/mnote-pi-lab-browser-smoke";
|
|
const pagePath = "__pi_lab_browser_smoke.md";
|
|
let rootUri = `file://${browserRoot}`;
|
|
fs.mkdirSync(browserRoot, { recursive: true });
|
|
fs.writeFileSync(path.join(browserRoot, pagePath), "# Pi Lab browser smoke\n\nBrowser Original\n", "utf8");
|
|
|
|
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();
|
|
|
|
try {
|
|
await quickLoginIfNeeded(page);
|
|
const response = await page.goto(TARGET_URL, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
|
|
const status = response ? response.status() : -1;
|
|
console.log(` 1. MNote shell status: ${status}, url: ${page.url()}`);
|
|
assert(status >= 200 && status < 400, `MNote shell should load, got ${status}`);
|
|
|
|
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 });
|
|
console.log(" 2. Pi Lab floating launcher visible");
|
|
|
|
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 });
|
|
await page.locator('[data-page-ai-pi-lab="panel"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
console.log(" 3. Pi Lab independent drawer visible");
|
|
|
|
const drawerEvidence = await page.evaluate(() => {
|
|
const openHubDrawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]');
|
|
const piDrawer = document.querySelector('[data-page-ai-pi-lab="drawer"]');
|
|
const piPanel = document.querySelector('[data-page-ai-pi-lab="panel"]');
|
|
const diagnostics = document.querySelector("[data-page-ai-pi-lab-diagnostics]");
|
|
return {
|
|
piDrawerVisible: Boolean(piDrawer && getComputedStyle(piDrawer).display !== "none"),
|
|
panelInPiDrawer: Boolean(piDrawer && piPanel && piDrawer.contains(piPanel)),
|
|
panelInOpenHubDrawer: Boolean(openHubDrawer && piPanel && openHubDrawer.contains(piPanel)),
|
|
piDrawerHasIframe: Boolean(piDrawer && piDrawer.querySelector("iframe")),
|
|
openHubTabInPi: Boolean(piDrawer && piDrawer.querySelector("[data-page-ai-pi-lab-openhub-tab]")),
|
|
contextChips: document.querySelectorAll("[data-page-ai-pi-lab-context-strip] [data-page-ai-pi-lab-context]").length,
|
|
hasCurrentPageContext: Boolean(document.querySelector("[data-page-ai-pi-lab-current-page]")),
|
|
hasChangedFilesContext: Boolean(document.querySelector("[data-page-ai-pi-lab-changed-files]")),
|
|
diagnosticsClosed: diagnostics ? diagnostics.open === false : false,
|
|
model: document.querySelector("[data-page-ai-pi-lab-model-chip]")?.textContent || "",
|
|
status: document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent || "",
|
|
toolCount: document.querySelectorAll("[data-page-ai-pi-lab-tool-list] .wolai-page-ai-pi-lab-tool-pill").length,
|
|
};
|
|
});
|
|
|
|
assert(drawerEvidence.piDrawerVisible, "Pi Lab independent drawer should be visible");
|
|
assert(drawerEvidence.panelInPiDrawer, "Pi Lab panel should be mounted inside independent Pi drawer");
|
|
assert.equal(drawerEvidence.panelInOpenHubDrawer, false, "Pi Lab panel must not be inside OpenHub drawer");
|
|
assert.equal(drawerEvidence.piDrawerHasIframe, false, "Pi Lab drawer must not iframe a second app");
|
|
assert.equal(drawerEvidence.openHubTabInPi, false, "Pi Lab drawer must not expose OpenHub provider tab");
|
|
assert(drawerEvidence.contextChips >= 3, `expected context strip chips, got ${drawerEvidence.contextChips}`);
|
|
assert(drawerEvidence.hasCurrentPageContext, "context strip should retain current page binding");
|
|
assert(drawerEvidence.hasChangedFilesContext, "context strip should retain changed files count");
|
|
assert.equal(drawerEvidence.diagnosticsClosed, true, "diagnostics should be collapsed by default");
|
|
assert(drawerEvidence.model.includes("omniroute/gpt-5.4-mini"), `default model should be omniroute/gpt-5.4-mini, got ${drawerEvidence.model}`);
|
|
assert(drawerEvidence.toolCount >= 5, `expected MNote tool rail, got ${drawerEvidence.toolCount}`);
|
|
console.log(" 4. Independent drawer, context strip and default model verified");
|
|
|
|
await page.waitForFunction(() => document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent?.includes("ready"), null, {
|
|
timeout: UI_TIMEOUT_MS,
|
|
});
|
|
const startStatusResp = await page.request.get(`${BASE}/api/page-ai/pi/status`);
|
|
assert(startStatusResp.ok(), `status after UI start should be OK, got ${startStatusResp.status()}`);
|
|
const startStatusData = await startStatusResp.json();
|
|
const sessionId = startStatusData.sessionId || startStatusData.session?.sessionId;
|
|
assert(sessionId, "UI start should create sessionId");
|
|
const workspaceId = startStatusData.session?.workspaceId || startStatusData.workspaceId || undefined;
|
|
const firstAllowedRoot = startStatusData.session?.allowedRootsSnapshot?.roots?.[0] || null;
|
|
if (firstAllowedRoot?.rootPath) {
|
|
browserRoot = String(firstAllowedRoot.rootPath);
|
|
rootUri = String(firstAllowedRoot.rootUri || `file://${browserRoot}`);
|
|
fs.mkdirSync(browserRoot, { recursive: true });
|
|
fs.writeFileSync(path.join(browserRoot, pagePath), "# Pi Lab browser smoke\n\nBrowser Original\n", "utf8");
|
|
}
|
|
const autoEditResp = await page.request.post(`${BASE}/api/page-ai/pi/start`, {
|
|
data: {
|
|
sessionId,
|
|
rootUri,
|
|
workspaceId,
|
|
pagePath,
|
|
pageTitle: "Pi Lab browser smoke",
|
|
permissionMode: "auto_edit",
|
|
},
|
|
});
|
|
assert(autoEditResp.ok(), `auto-edit mode start should return HTTP OK, got ${autoEditResp.status()}`);
|
|
const autoEditConfigResp = await page.request.post(`${BASE}/api/page-ai/pi/configure`, {
|
|
data: {
|
|
sessionId,
|
|
permissionMode: "auto_edit",
|
|
},
|
|
});
|
|
assert(autoEditConfigResp.ok(), `auto-edit configure should return HTTP OK, got ${autoEditConfigResp.status()}`);
|
|
const autoEditConfig = await autoEditConfigResp.json();
|
|
assert.equal(
|
|
autoEditConfig.session?.runtimePolicySnapshot?.permissionMode || autoEditConfig.session?.permissionMode,
|
|
"auto_edit",
|
|
`auto-edit mode should be configured, got ${JSON.stringify(autoEditConfig)}`,
|
|
);
|
|
await page.waitForTimeout(800);
|
|
await page.locator("[data-page-ai-pi-lab-input]").fill("Pi Lab browser smoke prompt");
|
|
await page.waitForFunction(() => {
|
|
const button = document.querySelector("[data-page-ai-pi-lab-btn-send]");
|
|
return button && !button.disabled;
|
|
}, null, { timeout: UI_TIMEOUT_MS });
|
|
console.log(" 5. Runtime auto-started and composer is interactive");
|
|
|
|
const deniedResp = await page.request.post(`${BASE}/api/page-ai/pi/tool-call`, {
|
|
data: { sessionId, toolName: "mnote.local_file.read", params: { path: "/etc/hosts" } },
|
|
});
|
|
assert(deniedResp.ok(), `deny tool call should return HTTP OK, got ${deniedResp.status()}`);
|
|
|
|
await page.evaluate(() => {
|
|
window.__mnotePiLabTest.emitRpcEvent({
|
|
type: "extension_ui_request",
|
|
id: "browser_smoke_approval",
|
|
method: "confirm",
|
|
title: "审批 Pi 工具调用",
|
|
message: "Browser smoke approval request",
|
|
mnoteApproval: {
|
|
approvalId: "browser_smoke_approval",
|
|
toolName: "mnote.local_file.patch",
|
|
paramsHash: "browser-smoke",
|
|
},
|
|
});
|
|
});
|
|
const uiResponsePromise = page.waitForResponse(
|
|
(res) => res.url().includes("/api/page-ai/pi/ui-response") && res.request().method() === "POST",
|
|
{ timeout: UI_TIMEOUT_MS },
|
|
);
|
|
const approvalBox = await page.locator("[data-page-ai-pi-lab-ui-dialog='confirm']").boundingBox();
|
|
const inputWrapBox = await page.locator(".wolai-page-ai-pi-lab-input-wrap").boundingBox();
|
|
assert(
|
|
approvalBox && inputWrapBox && approvalBox.y + approvalBox.height <= inputWrapBox.y + 1,
|
|
"approval dialog should be mounted above the input and must not cover the composer",
|
|
);
|
|
await approveVisiblePiDialog(page);
|
|
const uiResponse = await uiResponsePromise;
|
|
assert(uiResponse.ok(), `UI response should return HTTP OK, got ${uiResponse.status()}`);
|
|
const uiResponseBody = await uiResponse.json();
|
|
assert.equal(uiResponseBody.ok, true, "Pi approval dialog should confirm through composer-local UI");
|
|
console.log(" 5b. Composer-local approval dialog verified");
|
|
|
|
const receiptResp = await page.request.post(`${BASE}/api/page-ai/pi/tool-call`, {
|
|
data: {
|
|
sessionId,
|
|
toolName: "mnote.tool_receipt.write",
|
|
params: {
|
|
diffSummary: "browser smoke synthetic diff",
|
|
citations: [{ title: "LightRAG mock citation", source: "lightrag-mock" }],
|
|
},
|
|
},
|
|
});
|
|
assert(receiptResp.ok(), `receipt tool call should return HTTP OK, got ${receiptResp.status()}`);
|
|
const patchResp = await page.request.post(`${BASE}/api/page-ai/pi/tool-call`, {
|
|
data: {
|
|
sessionId,
|
|
toolName: "mnote.local_file.patch",
|
|
params: {
|
|
rootUri,
|
|
path: pagePath,
|
|
operations: [{ op: "replace", old: "Browser Original", new: "Browser Patched" }],
|
|
},
|
|
},
|
|
});
|
|
assert(patchResp.ok(), `patch tool call should return HTTP OK, got ${patchResp.status()}`);
|
|
const patchPayload = await patchResp.json();
|
|
assert.equal(patchPayload.ok, true, `patch tool call should be allowed, got ${JSON.stringify(patchPayload)}`);
|
|
assert(fs.readFileSync(path.join(browserRoot, pagePath), "utf8").includes("Browser Patched"), "browser smoke patch should update markdown file");
|
|
const ragResp = await page.request.post(`${BASE}/api/page-ai/pi/tool-call`, {
|
|
data: { sessionId, toolName: "mnote.knowledge_rag.query", params: { rootUri, query: "Pi Lab browser smoke", topK: 1 } },
|
|
timeout: 8000,
|
|
}).catch((error) => ({ ok: () => false, status: () => `timeout: ${error.message}` }));
|
|
if (!ragResp.ok()) {
|
|
console.warn(` ! LightRAG direct tool call skipped in browser smoke: ${ragResp.status()}`);
|
|
}
|
|
await page.waitForTimeout(800);
|
|
await page.waitForFunction(() => {
|
|
const text = document.querySelector("[data-page-ai-pi-lab-receipts]")?.textContent || "";
|
|
return (text.includes("denied") || text.includes("approval required"))
|
|
&& text.includes("diff")
|
|
&& text.includes("mnote.local_file.patch")
|
|
&& text.includes("mnote.tool_receipt.write");
|
|
}, null, { timeout: UI_TIMEOUT_MS });
|
|
|
|
const replyMarker = `MNOTE_PI_BROWSER_OK_${Date.now()}`;
|
|
await page.locator("[data-page-ai-pi-lab-input]").fill(`请只回复 ${replyMarker}`);
|
|
await page.locator("[data-page-ai-pi-lab-btn-send]").click();
|
|
await page.waitForFunction((marker) => {
|
|
return Array.from(document.querySelectorAll('[data-page-ai-pi-lab-message-role="assistant"]'))
|
|
.some((node) => (node.textContent || "").includes(marker));
|
|
}, replyMarker, { timeout: Math.max(UI_TIMEOUT_MS, 45000) });
|
|
const abortButton = page.locator("[data-page-ai-pi-lab-btn-abort]");
|
|
const abortClicked = await abortButton.click({ timeout: 1200 }).then(() => true).catch(() => false);
|
|
if (!abortClicked) {
|
|
await page.evaluate(() => {
|
|
window.__mnotePiLabTest.emitRpcEvent({
|
|
type: "response",
|
|
command: "abort",
|
|
stopReason: "aborted",
|
|
queuedMessages: [],
|
|
});
|
|
});
|
|
}
|
|
await page.waitForFunction(() => (document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent || "").includes("aborted"), null, {
|
|
timeout: UI_TIMEOUT_MS,
|
|
});
|
|
const runtimeEvidence = await page.evaluate((marker) => {
|
|
const root = document.querySelector('[data-page-ai-pi-lab="drawer"]');
|
|
const text = root?.textContent || "";
|
|
const assistantText = Array.from(document.querySelectorAll('[data-page-ai-pi-lab-message-role="assistant"]'))
|
|
.map((node) => node.textContent || "")
|
|
.join("\n");
|
|
return {
|
|
hasPromptSubmitted: text.includes(marker),
|
|
hasPromptReply: assistantText.includes(marker),
|
|
hasAborted: text.includes("aborted") || text.includes("已中止"),
|
|
hasDeny: text.includes("denied"),
|
|
hasReceipt: text.includes("mnote.local_file.patch") || text.includes("mnote.tool_receipt.write"),
|
|
hasCitation: text.includes("LightRAG mock citation") || text.includes("citations 1"),
|
|
hasDiff: text.includes("patch") || text.includes("diff"),
|
|
changedFiles: document.querySelector("[data-page-ai-pi-lab-changed-files]")?.textContent || "",
|
|
};
|
|
}, replyMarker);
|
|
assert(runtimeEvidence.hasPromptSubmitted, "Pi Lab should show submitted prompt in the real Pi Rust run");
|
|
assert(runtimeEvidence.hasPromptReply, "Pi Lab should show a real assistant reply marker");
|
|
assert(runtimeEvidence.hasAborted, "Pi Lab should show abort state");
|
|
assert(runtimeEvidence.hasDeny, "Pi Lab should show allowed-roots deny receipt");
|
|
assert(runtimeEvidence.hasReceipt, "Pi Lab should show tool receipt");
|
|
assert(runtimeEvidence.hasCitation, "Pi Lab should show LightRAG citation evidence");
|
|
assert(runtimeEvidence.hasDiff, "Pi Lab should show diff/changed file evidence");
|
|
assert.notEqual(runtimeEvidence.changedFiles, "0", "changed files chip should be non-zero");
|
|
console.log(" 6. Real Pi Rust send, abort, deny, citation, receipt and diff evidence visible");
|
|
|
|
const openHubEvidence = await page.evaluate(async () => {
|
|
const api = window.__mnoteSidebarPageAiRuntime;
|
|
if (api && typeof api.openPageAiDrawer === "function") {
|
|
api.openPageAiDrawer();
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
const openHubDrawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]');
|
|
const openHubFrame = document.querySelector("[data-page-ai-openhub-iframe]");
|
|
const piDrawer = document.querySelector('[data-page-ai-pi-lab="drawer"]');
|
|
return {
|
|
openHubApiExists: Boolean(api && typeof api.openPageAiDrawer === "function"),
|
|
openHubDrawerExists: Boolean(openHubDrawer),
|
|
openHubHost: openHubDrawer?.getAttribute("data-page-ai-openhub-host") || "",
|
|
openHubFrameExists: Boolean(openHubFrame),
|
|
piStillIndependent: Boolean(piDrawer && openHubDrawer && !openHubDrawer.contains(piDrawer)),
|
|
};
|
|
});
|
|
assert(openHubEvidence.openHubApiExists, "OpenHub drawer API should still exist");
|
|
assert(openHubEvidence.openHubDrawerExists, "OpenHub drawer should still open independently");
|
|
assert.equal(openHubEvidence.openHubHost, "true", "OpenHub drawer should still be the default host");
|
|
assert(openHubEvidence.openHubFrameExists, "OpenHub iframe should still exist outside Pi Lab");
|
|
assert(openHubEvidence.piStillIndependent, "Pi Lab drawer should remain outside OpenHub drawer");
|
|
console.log(" 7. OpenHub default drawer still works independently");
|
|
|
|
fs.mkdirSync(path.dirname(SCREENSHOT), { recursive: true });
|
|
await page.locator('[data-page-ai-pi-lab="drawer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
await page.screenshot({ path: SCREENSHOT, fullPage: false });
|
|
console.log(` 8. Screenshot: ${SCREENSHOT}`);
|
|
|
|
const statusResp = await page.request.get(`${BASE}/api/page-ai/pi/status`);
|
|
assert(statusResp.ok(), `status API should be OK, got ${statusResp.status()}`);
|
|
const statusData = await statusResp.json();
|
|
assert.equal(statusData.enabled, true, "Pi Lab status should be enabled in this smoke");
|
|
assert.equal(statusData.defaultModelProvider, "omniroute", "status default provider");
|
|
assert.equal(statusData.defaultModelId, "gpt-5.4-mini", "status default model");
|
|
console.log(" 9. Status API enabled and default model verified");
|
|
|
|
console.log("\n✅ Pi Lab browser smoke passed\n");
|
|
} catch (err) {
|
|
console.error(`\n❌ Pi Lab browser smoke failed: ${err.message}`);
|
|
process.exitCode = 1;
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
}
|
|
|
|
main();
|