Files

483 lines
21 KiB
JavaScript
Raw Permalink Normal View History

#!/usr/bin/env node
"use strict";
const { loginViaAuthForm } = require('./lib/browser-auth-login');
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 {
setupWorkspaceAccess,
seedAiPolicy,
} = require("./lib/control-plane-dev-seed");
const BASE = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
const STAMP = Date.now();
const OUT = process.env.MNOTE_PI_USER_EXACT_WEB_OUT || path.join(os.tmpdir(), `mnote-pi-user-exact-web-${STAMP}`);
const TIMEOUT = Number.parseInt(process.env.UI_TIMEOUT_MS || "420000", 10);
const ACTOR_ID = process.env.MNOTE_E2E_ACTOR_ID || "mnote-e2e";
const WORKSPACE_ID = process.env.MNOTE_PI_USER_EXACT_WORKSPACE_ID || `local-ws:${ACTOR_ID}:pi-user-exact-${STAMP}`;
const ROOT_PATH = process.env.MNOTE_PI_USER_EXACT_ROOT_PATH || path.join(OUT, "workspace");
const ROOT_URI = process.env.MNOTE_PI_USER_EXACT_ROOT_URI || `file://${ROOT_PATH}`;
const PAGE_PATH = `pi-user-exact-${STAMP}.md`;
const MODEL_PROVIDER = process.env.MNOTE_PI_USER_EXACT_MODEL_PROVIDER || "omniroute";
const MODEL_ID = process.env.MNOTE_PI_USER_EXACT_MODEL_ID || "gpt-5.4-mini";
2026-07-12 14:18:26 +08:00
const USER_PROMPT = "我们当前已经切换到 Pi Rust 官方版,我希望你全面测试当前的 skill/扩展/mcp/工具等是否正常,我当前已经是授权完全访问了。";
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 mkdirp(dir) {
fs.mkdirSync(dir, { recursive: true });
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function documentUrl() {
const url = new URL(`${BASE}/documents/${encodeURIComponent(localMdDocumentId(PAGE_PATH))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", ROOT_URI);
url.searchParams.set("workspaceId", WORKSPACE_ID);
return url.toString();
}
function skillConfig(name, description, source, riskLevel, requiredScopes = []) {
return { name, description, source, riskLevel, requiredScopes, enabled: true };
}
function mcpConfig(name, description, transport, command, url, networkPolicy, secretRefs, riskLevel, requiredScopes = []) {
return {
name,
description,
transport,
command,
url,
networkPolicy,
secretRefs,
riskLevel,
requiredScopes,
enabled: true,
facadeOnly: true,
sandbox: true,
};
}
function piExtensionConfig(name, description, source, toolNames, riskLevel, requiredScopes = []) {
return { name, description, source, toolNames, riskLevel, requiredScopes, enabled: true };
}
function policyForExactPrompt() {
return {
defaultModel: `${MODEL_PROVIDER}/${MODEL_ID}`,
allowedModels: [`${MODEL_PROVIDER}/${MODEL_ID}`],
tools: {
"mnote.current_page.read": "allow",
"mnote.selection.read": "allow",
"mnote.allowed_roots.describe": "allow",
"mnote.local_file.read": "allow",
"mnote.local_file.patch": "ask",
"mnote.knowledge_rag.status": "allow",
"mnote.knowledge_rag.query": "allow",
"mnote.knowledge_rag.section_context": "allow",
"mnote.knowledge_rag.open_reference": "allow",
"mnote.reference.open": "allow",
"mnote.tool_receipt.write": "allow",
"mnote.codex_rescue.request": "ask",
},
skills: {
codegraph: skillConfig("CodeGraph", "读取项目代码图、符号和调用关系。", "mcp://codegraph", "medium", ["workspace:code-read"]),
searxng: skillConfig("SearXNG Search", "通过本地 SearXNG MCP 做网页检索。", "mcp://searxng", "medium", ["network:search"]),
"chrome-bridge": skillConfig("Chrome Bridge", "通过受控浏览器桥接执行页面验证。", "mcp://chrome-bridge", "high", ["browser:automation", "qa:browser"]),
},
mcpServers: {
codegraph: mcpConfig("CodeGraph", "代码图 MCP。", "stdio", "codegraph serve --mcp", "", "deny-all", [], "medium", ["workspace:code-read"]),
searxng: mcpConfig("SearXNG", "本地 SearXNG 检索 MCP。", "stdio", "node /home/lix/.agent-infra/searxng/codex-searxng-mcp.cjs", "", "allow-local", [], "medium", ["network:search"]),
"chrome-bridge": mcpConfig("Chrome Bridge", "本机 Chromium/Chrome 桥接。", "stdio", "node /home/lix/apps/codex-chrome-bridge/mcp/chrome-bridge-mcp.mjs", "", "allow-local", [], "high", ["browser:automation", "qa:browser"]),
},
piExtensions: {
"pi-rust-official-todo": piExtensionConfig("Pi Rust Official Todo", "Pi Rust 官方 todo 扩展。", "pi-rust-official:todo", ["todo"], "medium", ["workflow:todo"]),
"pi-rust-official-subagent": piExtensionConfig("Pi Rust Official Subagent", "Pi Rust 官方 subagent 扩展。", "pi-rust-official:subagent", ["subagent"], "high", ["agent:delegate"]),
"pi-rust-official-permission-gate": piExtensionConfig("Pi Rust Official Permission Gate", "Pi Rust 官方 permission-gate 扩展。", "pi-rust-official:permission-gate", [], "high", ["tool:policy"]),
},
};
}
async function quickLogin(page) {
// 7-76 P0: 标准表单登录(无测试快速登录按钮)
const base =
(typeof BASE_URL !== "undefined" && BASE_URL) ||
(typeof baseUrl !== "undefined" && baseUrl) ||
process.env.MNOTE_UI_BASE_URL ||
"http://127.0.0.1:3000";
const timeout =
(typeof UI_TIMEOUT_MS !== "undefined" && UI_TIMEOUT_MS) ||
(typeof TIMEOUT !== "undefined" && TIMEOUT) ||
30_000;
if (!String(page.url() || "").includes("/auth")) {
await page.goto(String(base).replace(/\/+$/, "") + "/auth", {
waitUntil: "commit",
timeout,
});
}
await loginViaAuthForm(page, {
baseUrl: base,
timeoutMs: timeout,
gotoAuth: false,
});
await page
.waitForURL((url) => !String(url).includes("/auth"), { timeout })
.catch(() => {});
}
async function requestJson(page, url, options = {}) {
const response = await page.request.fetch(`${BASE}${url}`, {
...options,
headers: {
accept: "application/json",
"content-type": "application/json",
...(options.headers || {}),
},
timeout: options.timeout || TIMEOUT,
});
const text = await response.text();
let body = {};
try {
body = text ? JSON.parse(text) : {};
} catch {
body = { raw: text };
}
assert(response.ok(), `${options.method || "GET"} ${url} failed: ${response.status()} ${text.slice(0, 1000)}`);
return body;
}
async function seedWorkspace(page) {
mkdirp(ROOT_PATH);
fs.writeFileSync(
path.join(ROOT_PATH, PAGE_PATH),
[
"# Pi exact user web smoke",
"",
"PI_EXACT_USER_WEB_CONTEXT_OK",
"",
].join("\n"),
"utf8",
);
await setupWorkspaceAccess(page.request, BASE, {
actorId: ACTOR_ID,
email: "mnote.e2e@example.com",
username: ACTOR_ID,
displayName: ACTOR_ID,
role: "admin",
workspaceId: WORKSPACE_ID,
workspaceName: "Pi exact user web smoke",
rootPath: ROOT_PATH,
rootUri: ROOT_URI,
permission: "write",
capabilities: ["ai", "read", "write"],
timeoutMs: TIMEOUT,
});
await seedAiPolicy(page.request, BASE, {
id: `pi-user-exact-${ACTOR_ID}-${WORKSPACE_ID}`,
userId: ACTOR_ID,
workspaceId: WORKSPACE_ID,
allowedRootsJson: [{ rootUri: ROOT_URI, rootPath: ROOT_PATH, permission: "write" }],
modelPolicyJson: policyForExactPrompt(),
quotaJson: { daily: 200 },
timeoutMs: TIMEOUT,
});
}
async function abortExistingSession(page) {
const status = await requestJson(page, "/api/page-ai/pi/status").catch(() => null);
const sessionId = status?.session?.sessionId;
if (!sessionId) return null;
await requestJson(page, "/api/page-ai/pi/abort", {
method: "POST",
data: { sessionId },
}).catch(() => null);
return sessionId;
}
function eventPayload(event) {
return event && event.payload ? event.payload : event;
}
function summarizeEvents(events) {
const summary = {
eventCount: events.length,
toolCalls: [],
toolStarts: [],
toolEnds: [],
assistantTexts: [],
runtimeClosed: [],
agentEnd: false,
};
for (const event of events) {
const payload = eventPayload(event) || {};
const type = payload.type || event.kind;
if (type === "runtime_stdout_closed") summary.runtimeClosed.push(payload);
if (type === "agent_end") summary.agentEnd = true;
if (type === "message" || type === "message_end") {
const message = payload.message || {};
if (message.role === "assistant") {
const text = (message.content || [])
.filter((part) => part && part.type === "text" && typeof part.text === "string")
.map((part) => part.text)
.join("");
if (text.trim()) summary.assistantTexts.push(text.trim());
for (const part of message.content || []) {
if (part && part.type === "toolCall") {
summary.toolCalls.push({ name: part.name || part.toolName, id: part.id || part.toolCallId });
}
}
}
if (message.role === "toolResult") {
summary.toolEnds.push({
name: message.toolName || message.tool_name,
id: message.toolCallId || message.tool_call_id,
isError: message.isError === true,
});
}
}
if (type === "tool_execution_start" || type === "tool_call_start") {
summary.toolStarts.push({ name: payload.toolName || payload.name, id: payload.toolCallId || payload.id });
}
if (type === "tool_execution_end" || type === "tool_call_end") {
summary.toolEnds.push({ name: payload.toolName || payload.name, id: payload.toolCallId || payload.id, isError: payload.isError === true });
}
}
return summary;
}
async function visibleAssistantTexts(page) {
const texts = await page
.locator('[data-page-ai-pi-lab-message-role="assistant"]:not([data-page-ai-pi-lab-streaming])')
.allTextContents()
.catch(() => []);
return texts.map((text) => String(text || "").trim()).filter(Boolean);
}
async function visibleToolCards(page) {
return await page
.locator("[data-page-ai-pi-lab-tool-card]")
.evaluateAll((cards) => cards.map((card) => {
const name = card.getAttribute("data-page-ai-pi-lab-tool-call")
|| card.getAttribute("data-page-ai-pi-lab-tool-card")
|| "";
const status = card.querySelector("[data-page-ai-pi-lab-tool-status]")?.textContent || "";
return {
name: String(name || "").trim(),
status: String(status || "").trim(),
text: String(card.textContent || "").slice(0, 500),
};
}).filter((tool) => tool.name))
.catch(() => []);
}
function mergeVisibleToolCards(summary, tools) {
for (const tool of tools || []) {
if (!summary.toolCalls.some((existing) => existing.name === tool.name)) {
summary.toolCalls.push({ name: tool.name, id: tool.name, source: "visible_dom" });
}
const statusText = `${tool.status || ""} ${tool.text || ""}`;
if (/done|完成/i.test(statusText) && !summary.toolEnds.some((existing) => existing.name === tool.name)) {
summary.toolEnds.push({ name: tool.name, id: tool.name, isError: /error|失败/i.test(statusText), source: "visible_dom" });
}
}
}
async function waitForSessionEvents(page, sessionId) {
const deadline = Date.now() + TIMEOUT;
let events = [];
while (Date.now() < deadline) {
const visibleTexts = await visibleAssistantTexts(page);
const visibleFinalText = visibleTexts.find((text) => (
text.length > 40
&& /MCP|扩展|skill|工具|测试|Chrome Bridge|总体结论/i.test(text)
&& !/Pi runtime 返回了空回复|empty response/i.test(text)
));
if (visibleFinalText) {
const summary = summarizeEvents(events);
mergeVisibleToolCards(summary, await visibleToolCards(page));
summary.assistantTexts.push(visibleFinalText);
return { events, summary };
}
const payload = await requestJson(
page,
`/api/page-ai/pi/sessions/${encodeURIComponent(sessionId)}/events?limit=1000`,
{ timeout: Math.min(15000, TIMEOUT) },
).catch(() => null);
events = payload && Array.isArray(payload.events) ? payload.events : events;
const summary = summarizeEvents(events);
mergeVisibleToolCards(summary, await visibleToolCards(page));
if (summary.agentEnd || summary.assistantTexts.length > 0) return { events, summary };
await page.waitForTimeout(1000);
}
const summary = summarizeEvents(events);
mergeVisibleToolCards(summary, await visibleToolCards(page));
return { events, summary };
}
function hasBadToolBridgeResult(text) {
return /page_ai_pi_lab_session_not_found|page_ai_pi_lab_bridge_token_invalid|mnote_pi_rust_service_bridge_unavailable|mnote_pi_bridge_session_id_missing/i.test(String(text || ""));
}
async function main() {
mkdirp(OUT);
const browser = await chromium.launch({
headless: process.env.MNOTE_PI_USER_EXACT_WEB_HEADED === "1" ? false : true,
executablePath: CHROMIUM_EXECUTABLE || undefined,
});
const context = await browser.newContext({ viewport: { width: 1440, height: 980 } });
await context.addInitScript(() => {
window.__MNOTE_PI_LAB_TEST__ = true;
});
const page = await context.newPage();
const consoleMessages = [];
page.on("console", (message) => {
if (["error", "warning"].includes(message.type())) consoleMessages.push(`${message.type()}: ${message.text()}`);
});
page.on("pageerror", (error) => consoleMessages.push(`pageerror: ${error.message}`));
const result = {
ok: false,
base: BASE,
outputDir: OUT,
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
pagePath: PAGE_PATH,
prompt: USER_PROMPT,
screenshots: {},
checks: {},
consoleMessages,
};
try {
await quickLogin(page);
await seedWorkspace(page);
await abortExistingSession(page);
let startCount = 0;
page.on("request", (request) => {
if (request.url().includes("/api/page-ai/pi/start") && request.method() === "POST") startCount += 1;
});
await page.goto(documentUrl(), { waitUntil: "commit", timeout: TIMEOUT });
await page.locator("[data-page-ai-pi-lab-launcher]").waitFor({ state: "visible", timeout: TIMEOUT });
await page.locator("[data-page-ai-pi-lab-launcher]").click();
await page.locator('[data-page-ai-pi-lab="drawer"]').waitFor({ state: "visible", timeout: TIMEOUT });
await page.waitForFunction(() => !!window.__mnotePiLabTest, null, { timeout: TIMEOUT });
await page.waitForTimeout(800);
result.checks.startCountAfterOpen = startCount;
assert.equal(result.checks.startCountAfterOpen, 0, "opening Pi Lab must not auto-start runtime");
await page.locator("[data-page-ai-pi-lab-new]").click();
await page.waitForTimeout(500);
result.checks.startCountAfterNew = startCount;
assert.equal(result.checks.startCountAfterNew, 0, "new conversation must not auto-start runtime");
await page.locator("[data-page-ai-pi-lab-permission]").click();
await page.locator("[data-page-ai-pi-lab-permission-menu]").waitFor({ state: "visible", timeout: TIMEOUT });
await page.locator('[data-page-ai-pi-lab-permission-mode="full_access"]').click();
await page.waitForFunction(() => /完全访问/.test(document.querySelector("[data-page-ai-pi-lab-permission-label]")?.textContent || ""), null, { timeout: TIMEOUT });
await page.waitForTimeout(300);
result.checks.startCountAfterFullAccessClick = startCount;
assert.equal(result.checks.startCountAfterFullAccessClick, 0, "selecting full_access before send must not auto-start runtime");
const startResponsePromise = page.waitForResponse(
(response) => response.url().includes("/api/page-ai/pi/start") && response.request().method() === "POST",
{ timeout: TIMEOUT },
);
const sendResponsePromise = page.waitForResponse(
(response) => response.url().includes("/api/page-ai/pi/send") && response.request().method() === "POST",
{ timeout: TIMEOUT },
);
await page.locator("[data-page-ai-pi-lab-input]").fill(USER_PROMPT);
await page.locator("[data-page-ai-pi-lab-btn-send]").click();
const startResponse = await startResponsePromise;
const startBody = await startResponse.json();
const startRequestBody = startResponse.request().postDataJSON();
result.startRequest = startRequestBody;
result.startResponse = startBody;
assert.equal(startResponse.ok(), true, `start response should be HTTP OK: ${startResponse.status()}`);
assert.equal(startRequestBody.permissionMode, "full_access", "web send must start with full_access");
assert.equal(startRequestBody.modelProvider, MODEL_PROVIDER, "web send must use configured provider");
assert.equal(startRequestBody.modelId, MODEL_ID, "web send must use tool-capable model");
assert.equal(startRequestBody.rootUri, ROOT_URI, "web send must keep current document rootUri");
assert.equal(startRequestBody.workspaceId, WORKSPACE_ID, "web send must keep current document workspaceId");
assert.equal(startRequestBody.pagePath, PAGE_PATH, "web send must keep current document pagePath");
assert.equal(startBody.runtimeImplementation, "pi-rust", "runtime implementation should be pi-rust");
assert.deepEqual(
[...(startBody.managedPiBuiltinTools || [])].sort(),
["bash", "edit", "find", "grep", "hashline_edit", "ls", "read", "write"].sort(),
"full_access should expose Pi Rust builtins instead of replacing them with MNote file tools",
);
const sessionId = startBody.session?.sessionId || startBody.sessionId;
assert(sessionId, "start response missing sessionId");
result.sessionId = sessionId;
result.piSessionDir = startBody.session?.piSessionDir;
const sendResponse = await sendResponsePromise;
const sendBody = await sendResponse.json();
result.sendResponse = sendBody;
assert.equal(sendResponse.ok(), true, `send response should be HTTP OK: ${sendResponse.status()}`);
assert.equal(sendBody.accepted, true, "send response should accept the exact prompt");
const { events, summary } = await waitForSessionEvents(page, sessionId);
result.events = events;
result.eventSummary = summary;
result.visibleToolCards = await visibleToolCards(page);
mergeVisibleToolCards(summary, result.visibleToolCards);
const pageText = await page.locator('[data-page-ai-pi-lab="drawer"]').textContent({ timeout: TIMEOUT });
result.visibleTextSample = String(pageText || "").slice(0, 4000);
await page.screenshot({ path: path.join(OUT, "01-exact-prompt-result.png"), fullPage: true });
result.screenshots.exactPrompt = path.join(OUT, "01-exact-prompt-result.png");
const allText = `${JSON.stringify(summary)}\n${result.visibleTextSample}\n${JSON.stringify(events)}`;
result.checks.hasToolCall = summary.toolCalls.length > 0 || summary.toolStarts.length > 0 || summary.toolEnds.length > 0;
result.checks.hasSuccessfulToolEnd = summary.toolEnds.some((tool) => tool.isError === false);
result.checks.hasMnoteOrMcpOrExtensionTool = /mnote_|mcp|todo|subagent|knowledge/i.test(JSON.stringify(summary));
result.checks.noRawBuiltinToolCall = !/"name":"(ls|find|bash|read|write|edit|grep|hashline_edit)"/.test(JSON.stringify(events));
result.checks.noEmptyReplyError = !/Pi runtime 返回了空回复|empty response/i.test(allText);
result.checks.noBridgeSessionFailure = !hasBadToolBridgeResult(allText);
result.checks.hasAssistantReply = summary.assistantTexts.length > 0 || /Pi|测试|工具|MCP|扩展|skill/i.test(result.visibleTextSample);
assert.equal(result.checks.hasToolCall, true, "exact web prompt should trigger at least one real tool call");
assert.equal(result.checks.hasSuccessfulToolEnd, true, "at least one tool call should finish successfully");
assert.equal(result.checks.hasMnoteOrMcpOrExtensionTool, true, "tool call should be MNote/MCP/extension controlled");
assert.equal(result.checks.noRawBuiltinToolCall, true, "full_access must not expose raw builtin tools");
assert.equal(result.checks.noEmptyReplyError, true, "UI must not show empty reply error");
assert.equal(result.checks.noBridgeSessionFailure, true, "tool result must not contain bridge/session failures");
assert.equal(result.checks.hasAssistantReply, true, "UI should show an assistant reply");
await requestJson(page, "/api/page-ai/pi/abort", { method: "POST", data: { sessionId } }).catch(() => ({}));
result.ok = true;
} catch (error) {
result.ok = false;
result.error = error && error.stack ? error.stack : String(error);
await abortExistingSession(page).catch(() => null);
try {
await page.screenshot({ path: path.join(OUT, "99-failure.png"), fullPage: true });
result.screenshots.failure = path.join(OUT, "99-failure.png");
} catch {}
process.exitCode = 1;
} finally {
fs.writeFileSync(path.join(OUT, "result.json"), JSON.stringify(result, null, 2), "utf8");
await browser.close();
console.log(JSON.stringify({
ok: result.ok,
outputDir: OUT,
result: path.join(OUT, "result.json"),
sessionId: result.sessionId,
checks: result.checks,
error: result.error,
}, null, 2));
}
}
main();