Files
mnote/scripts/task-pi-lab-full-access-ask-user-smoke.js

378 lines
17 KiB
JavaScript
Raw Permalink 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_FULL_ACCESS_ASK_USER_OUT || path.join(os.tmpdir(), `mnote-pi-full-access-ask-user-${STAMP}`);
const TIMEOUT = Number.parseInt(process.env.UI_TIMEOUT_MS || "360000", 10);
const ACTOR_ID = process.env.MNOTE_E2E_ACTOR_ID || "mnote-e2e";
const WORKSPACE_ID = process.env.MNOTE_PI_FULL_ACCESS_ASK_USER_WORKSPACE_ID || "local-ws:mnote-e2e:my-space";
const ROOT_PATH = process.env.MNOTE_PI_FULL_ACCESS_ASK_USER_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
const ROOT_URI = process.env.MNOTE_PI_FULL_ACCESS_ASK_USER_ROOT_URI || `file://${ROOT_PATH}`;
const MODEL_PROVIDER = process.env.MNOTE_PI_FULL_ACCESS_ASK_USER_MODEL_PROVIDER || "omniroute";
const MODEL_ID = process.env.MNOTE_PI_FULL_ACCESS_ASK_USER_MODEL_ID || "gpt-5.4-mini";
const MARKER = `PI_FULL_ACCESS_ASK_USER_OK_${STAMP}`;
const PAGE_PATH = `pi-full-access-ask-user-${STAMP}.md`;
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 };
}
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 policyForFullAccessAskUser() {
return {
defaultModel: `${MODEL_PROVIDER}/${MODEL_ID}`,
allowedModels: [`${MODEL_PROVIDER}/${MODEL_ID}`],
tools: {
"mnote.current_page.read": "allow",
"mnote.allowed_roots.describe": "allow",
"mnote.tool_receipt.write": "allow",
},
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 full access question smoke\n", "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: {
...policyForFullAccessAskUser(),
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 startRealPi(page) {
const sessionId = `pi-full-access-ask-user-${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 full access question smoke",
modelProvider: MODEL_PROVIDER,
modelId: MODEL_ID,
thinkingLevel: "medium",
permissionMode: "full_access",
},
});
assert.equal(start.ok, true, "Pi start ok should be true");
assert.equal(start.permissionMode, "full_access", "start response should expose full_access mode");
assert.equal(start.session.runtimePolicySnapshot.permissionMode, "full_access", "runtime policy should persist full_access");
assert.equal(start.session.runtimeMode, "rpc", `Pi 必须以 rpc 模式启动,实际=${start.session.runtimeMode}`);
assert(start.session.runtimePid, "真实 Pi RPC 启动后应有 runtimePid");
return start.session;
}
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(() => {
const text = document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent || "";
return /ready|running|streaming/.test(text);
}, null, { timeout: TIMEOUT }).catch(() => null);
}
function readSessionJsonl(sessionDir) {
const files = [];
const walk = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(full);
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
files.push(full);
}
}
};
walk(sessionDir);
files.sort();
const sessionFile = files[files.length - 1];
assert(sessionFile, `session jsonl not found in ${sessionDir}`);
return { sessionFile, raw: fs.readFileSync(sessionFile, "utf8") };
}
async function answerVisibleDialog(page, result) {
const dialog = page.locator("[data-page-ai-pi-lab-ui-dialog]").first();
await dialog.waitFor({ state: "visible", timeout: TIMEOUT });
const method = await dialog.getAttribute("data-page-ai-pi-lab-ui-dialog");
if (!result.screenshots.askUserDialog) {
await page.screenshot({ path: path.join(OUT, "02-ask-user-dialog.png"), fullPage: false });
result.screenshots.askUserDialog = path.join(OUT, "02-ask-user-dialog.png");
const dialogBox = await dialog.boundingBox();
const inputWrapBox = await page.locator(".wolai-page-ai-pi-lab-input-wrap").boundingBox();
result.checks.askUserDialogMethod = method;
result.checks.askUserDialogAboveInput = !!dialogBox && !!inputWrapBox && dialogBox.y + dialogBox.height <= inputWrapBox.y + 1;
result.checks.askUserDialogInComposer = await page.locator(".wolai-page-ai-pi-lab-composer [data-page-ai-pi-lab-ui-dialog]").count() > 0;
}
if (method === "select") {
await dialog.locator("[data-page-ai-pi-lab-ui-option]").first().click();
return;
}
if (method === "input" || method === "editor") {
await dialog.locator("[data-page-ai-pi-lab-ui-input]").fill("继续验证");
await dialog.locator("[data-page-ai-pi-lab-ui-submit]").click();
return;
}
if (method === "confirm") {
await dialog.locator("[data-page-ai-pi-lab-ui-submit]").click();
return;
}
if (method === "ask_user" || method === "ask-user" || method === "questionnaire" || method === "custom") {
await dialog.locator("[data-page-ai-pi-lab-ui-option]").first().click();
await dialog.locator("[data-page-ai-pi-lab-ui-submit]").click();
return;
}
throw new Error(`unsupported extension UI dialog method: ${method}`);
}
async function main() {
mkdirp(OUT);
const browser = await chromium.launch({
headless: process.env.MNOTE_PI_FULL_ACCESS_ASK_USER_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 = [];
const uiResponses = [];
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}`));
page.on("request", (request) => {
if (request.url().includes("/api/page-ai/pi/ui-response") && request.method() === "POST") {
try {
uiResponses.push(JSON.parse(request.postData() || "{}"));
} catch {
uiResponses.push({ raw: request.postData() || "" });
}
}
});
const result = {
base: BASE,
outputDir: OUT,
marker: MARKER,
rootUri: ROOT_URI,
screenshots: {},
checks: {},
consoleMessages,
uiResponses,
};
try {
await quickLogin(page);
await seedWorkspace(browser, page);
await abortExistingSession(page);
const session = await startRealPi(page);
result.session = {
sessionId: session.sessionId,
runtimePid: session.runtimePid,
piSessionDir: session.piSessionDir,
runtimePolicySnapshot: session.runtimePolicySnapshot,
};
const sources = session.runtimePolicySnapshot.enabledPiExtensionSources || [];
const piExtensionToolNames = session.runtimePolicySnapshot.piExtensionToolNames || [];
result.checks.usesPiRustOfficialQuestion = sources.includes("pi-rust-official:question");
result.checks.usesPiRustOfficialQuestionnaire = sources.includes("pi-rust-official:questionnaire");
result.checks.questionToolAdvertised = piExtensionToolNames.includes("question");
result.checks.questionnaireToolAdvertised = piExtensionToolNames.includes("questionnaire");
result.checks.noLegacyNpmAskUser = !sources.includes("npm:pi-ask-user");
result.checks.retiredAskUserRemoved = !sources.includes("npm:@d3ara1n/pi-ask-user");
assert(result.checks.usesPiRustOfficialQuestion, "runtime should use Pi Rust official question extension");
assert(result.checks.usesPiRustOfficialQuestionnaire, "runtime should use Pi Rust official questionnaire extension");
assert(result.checks.questionToolAdvertised, "runtime policy should advertise question");
assert(result.checks.questionnaireToolAdvertised, "runtime policy should advertise questionnaire");
assert(result.checks.noLegacyNpmAskUser, "runtime should not use unavailable npm:pi-ask-user");
assert(result.checks.retiredAskUserRemoved, "runtime should not use @d3ara1n/pi-ask-user");
await openPiUi(page);
await page.screenshot({ path: path.join(OUT, "01-full-access-ask-user-started.png"), fullPage: false });
result.screenshots.started = path.join(OUT, "01-full-access-ask-user-started.png");
const prompt = [
"这是 MNote Pi Rust official question 真实链路 smoke。",
"你必须调用 question 工具向我提问,不要用普通文本直接问。",
"问题:是否继续验证 MNote Pi 完全访问下的用户问答?",
"提供两个选项:继续验证、停止验证。",
`收到我的回答后,最终单独输出一行:${MARKER}`,
].join("\n");
await page.locator("[data-page-ai-pi-lab-input]").fill(prompt);
await page.locator("[data-page-ai-pi-lab-btn-send]").click();
const markerLocator = 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();
const startedAt = Date.now();
let answeredDialogs = 0;
while (Date.now() - startedAt < TIMEOUT) {
const permissionVisible = await page.locator("text=Permission Required").count();
assert.equal(permissionVisible, 0, "full_access should not show Permission Required for question");
if (await markerLocator.isVisible({ timeout: 500 }).catch(() => false)) break;
const dialogCount = await page.locator("[data-page-ai-pi-lab-ui-dialog]").count();
if (dialogCount > 0) {
answeredDialogs += 1;
assert(answeredDialogs <= 5, "question smoke should not require more than 5 dialogs");
await answerVisibleDialog(page, result);
}
await page.waitForTimeout(500);
}
await markerLocator.waitFor({ state: "visible", timeout: 1000 });
await page.screenshot({ path: path.join(OUT, "03-ask-user-final-answer.png"), fullPage: false });
result.screenshots.answer = path.join(OUT, "03-ask-user-final-answer.png");
const sessionJsonl = readSessionJsonl(session.piSessionDir);
result.session.sessionFile = sessionJsonl.sessionFile;
result.answerText = ((await markerLocator.textContent({ timeout: TIMEOUT })) || "").trim();
result.checks.answeredDialogs = answeredDialogs;
result.checks.questionToolCalled = sessionJsonl.raw.includes("question");
result.checks.extensionUiRequestRecorded = sessionJsonl.raw.includes("extension_ui_request");
result.checks.extensionUiRoundTripObserved = result.checks.answeredDialogs >= 1 && result.answerText.includes(MARKER);
result.checks.noAnswersUndefinedError = !sessionJsonl.raw.includes("Cannot read properties of undefined") && !(await page.locator("text=Cannot read properties of undefined").count());
result.checks.noPermissionRequiredPrompt = await page.locator("text=Permission Required").count() === 0;
assert(result.checks.answeredDialogs >= 1, "question should surface at least one extension UI dialog");
assert(result.checks.questionToolCalled, "Pi session JSONL should record question tool call");
assert(result.checks.extensionUiRoundTripObserved, "question should surface UI, accept an answer, and let Pi continue to final output");
assert(result.checks.askUserDialogInComposer, "question dialog should render inside Pi composer");
assert(result.checks.askUserDialogAboveInput, "question dialog should sit above the input without covering it");
assert(result.checks.noAnswersUndefinedError, "question should not crash with answers undefined");
assert(result.checks.noPermissionRequiredPrompt, "full_access should not show Permission Required prompt");
await requestJson(page, "/api/page-ai/pi/abort", { method: "POST", data: { sessionId: session.sessionId } }).catch(() => ({}));
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();