Files
mnote/scripts/task-pi-lab-plan-mode-browser-smoke.js
T

407 lines
17 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
"use strict";
const { loginViaAuthForm, withAdminBrowserSession } = 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 BASE = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
const STAMP = Date.now();
const OUT = process.env.MNOTE_PI_PLAN_MODE_OUT || path.join(os.tmpdir(), `mnote-pi-plan-mode-${STAMP}`);
const TIMEOUT = Number.parseInt(process.env.UI_TIMEOUT_MS || "240000", 10);
const ACTOR_ID = process.env.MNOTE_E2E_ACTOR_ID || "mnote-e2e";
const WORKSPACE_ID = process.env.MNOTE_PI_PLAN_MODE_WORKSPACE_ID || "local-ws:mnote-e2e:my-space";
const ROOT_PATH = process.env.MNOTE_PI_PLAN_MODE_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
const ROOT_URI = process.env.MNOTE_PI_PLAN_MODE_ROOT_URI || `file://${ROOT_PATH}`;
const MODEL_PROVIDER = process.env.MNOTE_PI_PLAN_MODE_MODEL_PROVIDER || "omniroute";
const MODEL_ID = process.env.MNOTE_PI_PLAN_MODE_MODEL_ID || "gpt-5.4-mini";
const PAGE_PATH = `pi-plan-mode-${STAMP}.md`;
const TARGET_PATH = `pi-plan-mode-target-${STAMP}.md`;
const TARGET_ABS = path.join(ROOT_PATH, TARGET_PATH);
const TARGET_ORIGINAL = `PLAN_MODE_TARGET_ORIGINAL_${STAMP}\n`;
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 });
}
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 };
}
if (options.allowStatus) return { response, body, text };
assert(response.ok(), `${options.method || "GET"} ${url} failed: ${response.status()} ${text.slice(0, 800)}`);
return body;
}
function piExtensionConfig(name, description, source, toolNames, riskLevel, requiredScopes = []) {
return {
name,
description,
source,
toolNames,
riskLevel,
requiredScopes,
enabled: true,
};
}
function messageTextsFromSessionJsonl(raw) {
return raw
.split(/\n+/)
.filter(Boolean)
.flatMap((line) => {
try {
const event = JSON.parse(line);
const content = event && event.message && Array.isArray(event.message.content)
? event.message.content
: [];
return content
.filter((item) => item && item.type === "text" && typeof item.text === "string")
.map((item) => item.text);
} catch {
return [];
}
});
}
function policyForPlanMode() {
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: {},
mcpServers: {},
piExtensions: {
"pi-rust-official-question": piExtensionConfig(
"Pi Rust Official Question",
"Pi Rust 官方索引 question 扩展的本地镜像。",
"pi-rust-official:question",
["question"],
"low",
["ui:ask"],
),
"pi-rust-official-questionnaire": piExtensionConfig(
"Pi Rust Official Questionnaire",
"Pi Rust 官方索引 questionnaire 扩展的本地镜像。",
"pi-rust-official:questionnaire",
["questionnaire"],
"low",
["ui:ask"],
),
},
};
}
async function seedWorkspace(browser, page) {
mkdirp(ROOT_PATH);
fs.writeFileSync(path.join(ROOT_PATH, PAGE_PATH), "# Pi plan mode browser smoke\n", "utf8");
fs.writeFileSync(TARGET_ABS, TARGET_ORIGINAL, "utf8");
// 方案 Aadmin 能力仅 mnote-admin
await withAdminBrowserSession(browser, async (adminPage) => {
const grantResponse = await adminPage.request.fetch(`${BASE}/api/admin/access-policy/grants`, {
method: "POST",
headers: {
accept: "application/json",
"content-type": "application/json",
},
data: {
userId: ACTOR_ID,
rootUri: ROOT_URI,
rootPath: ROOT_PATH,
permission: "write",
recursive: true,
capabilities: ["ai"],
},
timeout: TIMEOUT,
});
const grantText = await grantResponse.text();
let grantBody = {};
try {
grantBody = grantText ? JSON.parse(grantText) : {};
} catch {
grantBody = { raw: grantText };
}
if (!grantResponse.ok() && grantBody.code !== "local_access_policy_grant_duplicate") {
throw new Error(`POST /api/admin/access-policy/grants failed: ${grantResponse.status()} ${grantText.slice(0, 800)}`);
}
await requestJson(adminPage, "/api/ai-admin/settings", {
method: "PUT",
data: {
...policyForPlanMode(),
quota: { daily: 200 },
},
});
}, { baseUrl: BASE, timeoutMs: TIMEOUT });
await requestJson(page, `/api/ai-settings/effective?workspaceId=${encodeURIComponent(WORKSPACE_ID)}`);
}
async function abortExistingSession(page) {
const status = await requestJson(page, "/api/page-ai/pi/status").catch(() => null);
const sessionId = status && status.session && status.session.sessionId;
if (!sessionId) return null;
await requestJson(page, "/api/page-ai/pi/abort", { method: "POST", data: { sessionId } }).catch(() => null);
return sessionId;
}
async function startPlanSession(page) {
const sessionId = `pi-plan-mode-${STAMP}`;
const start = await requestJson(page, "/api/page-ai/pi/start", {
method: "POST",
data: {
sessionId,
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
pagePath: PAGE_PATH,
pageTitle: "Pi plan mode browser smoke",
modelProvider: MODEL_PROVIDER,
modelId: MODEL_ID,
thinkingLevel: "off",
permissionMode: "plan",
},
});
assert.equal(start.ok, true, "Pi start ok should be true");
assert.equal(start.permissionMode, "plan", "start response should expose plan mode");
assert.equal(start.session.runtimePolicySnapshot.permissionMode, "plan", "runtime policy should persist plan mode");
return start.session;
}
function assertPlanPermissionConfig(session, result) {
const configPath = path.join(session.piSessionDir, "config", "extensions", "pi-permission-system", "config.json");
result.permissionConfigPath = configPath;
result.checks.permissionSystemConfigAbsent = !fs.existsSync(configPath);
result.checks.planRuntimePolicyMode = session.runtimePolicySnapshot.permissionMode;
result.checks.planQuestionSource = (session.runtimePolicySnapshot.enabledPiExtensionSources || []).includes("pi-rust-official:question");
result.checks.planQuestionnaireSource = (session.runtimePolicySnapshot.enabledPiExtensionSources || []).includes("pi-rust-official:questionnaire");
result.checks.noLegacyNpmAskUser = !(session.runtimePolicySnapshot.enabledPiExtensionSources || []).includes("npm:pi-ask-user");
result.checks.noExternalPermissionSystem = !(session.runtimePolicySnapshot.enabledPiExtensionSources || []).includes("npm:@gotgenes/pi-permission-system");
assert.equal(result.checks.permissionSystemConfigAbsent, true, "Pi Rust plan smoke should not generate legacy pi-permission-system config");
assert.equal(result.checks.planRuntimePolicyMode, "plan", "plan mode should persist in runtime policy");
assert.equal(result.checks.planQuestionSource, true, "plan smoke should use Pi Rust official question extension");
assert.equal(result.checks.planQuestionnaireSource, true, "plan smoke should use Pi Rust official questionnaire extension");
assert.equal(result.checks.noLegacyNpmAskUser, true, "plan smoke should not use unavailable npm:pi-ask-user");
assert.equal(result.checks.noExternalPermissionSystem, true, "plan smoke should not load incompatible pi-permission-system");
}
async function readLatestSessionJsonl(sessionDir, timeoutMs = 120000) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const files = fs.readdirSync(sessionDir)
.filter((name) => name.endsWith(".jsonl"))
.map((name) => path.join(sessionDir, name))
.sort();
const sessionFile = files[files.length - 1];
if (sessionFile) {
const raw = fs.readFileSync(sessionFile, "utf8");
if (raw.includes("当前是 MNote Pi 计划模式")) {
return { sessionFile, raw };
}
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
const files = fs.readdirSync(sessionDir)
.filter((name) => name.endsWith(".jsonl"))
.map((name) => path.join(sessionDir, name))
.sort();
const sessionFile = files[files.length - 1];
assert(sessionFile, `session jsonl not found in ${sessionDir}`);
return { sessionFile, raw: fs.readFileSync(sessionFile, "utf8") };
}
async function openPiUi(page) {
await page.goto(`${BASE}/page-ai/pi`, { waitUntil: "commit", timeout: TIMEOUT });
await page.locator('[data-page-ai-pi-lab="drawer"]').waitFor({ state: "visible", timeout: TIMEOUT });
await page.waitForFunction(() => /计划模式/.test(document.querySelector("[data-page-ai-pi-lab-permission-label]")?.textContent || ""), null, { timeout: TIMEOUT });
}
async function main() {
mkdirp(OUT);
const browser = await chromium.launch({
headless: process.env.MNOTE_PI_PLAN_MODE_HEADED === "1" ? false : true,
executablePath: CHROMIUM_EXECUTABLE || undefined,
});
const context = await browser.newContext({ viewport: { width: 1440, height: 980 } });
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 = {
base: BASE,
outputDir: OUT,
rootUri: ROOT_URI,
targetPath: TARGET_PATH,
targetAbs: TARGET_ABS,
screenshots: {},
checks: {},
consoleMessages,
};
try {
await quickLogin(page);
await seedWorkspace(browser, page);
await abortExistingSession(page);
const session = await startPlanSession(page);
result.session = {
sessionId: session.sessionId,
runtimeMode: session.runtimeMode,
runtimePid: session.runtimePid,
piSessionDir: session.piSessionDir,
runtimePolicySnapshot: session.runtimePolicySnapshot,
};
assertPlanPermissionConfig(session, result);
await openPiUi(page);
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 });
result.checks.permissionModes = await page.locator("[data-page-ai-pi-lab-permission-mode]").evaluateAll((nodes) => nodes.map((node) => ({
mode: node.getAttribute("data-page-ai-pi-lab-permission-mode"),
text: node.textContent.trim(),
active: node.getAttribute("data-active"),
})));
assert.deepEqual(result.checks.permissionModes.map((item) => item.mode), ["confirm", "auto_edit", "plan", "full_access"]);
assert.equal(result.checks.permissionModes.find((item) => item.mode === "plan").active, "true", "plan menu item should be active");
await page.screenshot({ path: path.join(OUT, "01-plan-mode-menu-active.png"), fullPage: false });
result.screenshots.planModeMenu = path.join(OUT, "01-plan-mode-menu-active.png");
await page.keyboard.press("Escape").catch(() => null);
const patchAttempt = await requestJson(page, "/api/page-ai/pi/tool-call", {
method: "POST",
allowStatus: true,
data: {
sessionId: session.sessionId,
toolName: "mnote.local_file.patch",
params: {
rootUri: ROOT_URI,
path: TARGET_PATH,
operations: [{ op: "replace", content: "PLAN_MODE_SHOULD_NOT_WRITE\n" }],
},
},
});
result.checks.planPatchHttpStatus = patchAttempt.response.status();
result.checks.planPatchBody = patchAttempt.body;
assert.equal(patchAttempt.response.ok(), false, "plan mode should reject local_file.patch before approval");
assert.equal(patchAttempt.body.code, "page_ai_pi_lab_tool_denied_by_permission_mode", "plan mode should deny patch tool by policy");
const prompt = [
"这是计划模式浏览器 smoke。",
`请不要真正修改文件,只分析如果要修改 ${TARGET_PATH} 应该怎么做。`,
"如果当前是计划模式,请说明需要切换到自动编辑或完全访问后才能执行写入。",
].join("\n");
const sendRequestPromise = page.waitForRequest(
(request) => request.url().includes("/api/page-ai/pi/send") && 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(prompt);
await page.locator("[data-page-ai-pi-lab-btn-send]").click();
const sendBody = (await sendRequestPromise).postDataJSON();
const sendResponse = await sendResponsePromise;
const sendPayload = await sendResponse.json();
result.checks.planSendOriginalMessage = sendBody.message;
result.checks.planSendPermissionMode = sendPayload.permissionMode;
result.checks.planModePromptApplied = sendPayload.planModePromptApplied;
assert.equal(sendBody.message, prompt, "UI should send the user's original text");
assert.equal(sendPayload.permissionMode, "plan", "send response should remain in plan mode");
assert.equal(sendPayload.planModePromptApplied, true, "backend should apply plan-mode readonly prompt wrapper");
const sessionJsonl = await readLatestSessionJsonl(session.piSessionDir);
result.session.sessionFile = sessionJsonl.sessionFile;
const sessionTexts = messageTextsFromSessionJsonl(sessionJsonl.raw);
result.checks.rpcCommandContainsPlanWrapper = sessionTexts.some((text) => text.includes("当前是 MNote Pi 计划模式"));
result.checks.rpcCommandPreservesOriginalPrompt = sessionTexts.some((text) => text.includes(prompt));
assert.equal(result.checks.rpcCommandContainsPlanWrapper, true, "RPC command should contain the readonly plan-mode wrapper");
assert.equal(result.checks.rpcCommandPreservesOriginalPrompt, true, "RPC command should include the user's original prompt inside the plan-mode wrapper");
await page.waitForTimeout(2000);
result.checks.targetContentAfterPlan = fs.readFileSync(TARGET_ABS, "utf8");
result.checks.noPermissionRequiredPrompt = await page.locator("text=Permission Required").count() === 0;
assert.equal(result.checks.targetContentAfterPlan, TARGET_ORIGINAL, "plan mode should not mutate the target file");
assert.equal(result.checks.noPermissionRequiredPrompt, true, "plan mode denial should not show an ask prompt");
await page.screenshot({ path: path.join(OUT, "02-plan-mode-send-applied.png"), fullPage: false });
result.screenshots.planModeSend = path.join(OUT, "02-plan-mode-send-applied.png");
await requestJson(page, "/api/page-ai/pi/abort", { method: "POST", data: { sessionId: session.sessionId } }).catch(() => null);
assert.equal(consoleMessages.length, 0, `console errors: ${consoleMessages.join("\n")}`);
result.ok = true;
} catch (error) {
result.ok = false;
result.error = error && error.stack ? error.stack : String(error);
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") }, null, 2));
}
}
main();