feat: integrate pi rust lab runtime
This commit is contained in:
@@ -21,6 +21,8 @@
|
||||
* - MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE:默认 "1",禁用 OpenHub Git snapshot/restore/revert 写链
|
||||
* - MNOTE_CONTROL_PLANE_BACKEND:控制面后端,默认 libsql-local;可设 turso-remote / turso-local-replica / turso-synced
|
||||
* - MNOTE_TURSO_LOCAL_PATH:libsql-local 本地库路径,默认 /mnt/Data1T/Mnote_data/control-plane/control-plane-libsql.db
|
||||
* - MNOTE_PAGE_AI_PI_WARMUP:设为 "1" or "true" 时,在 mnote-web 可用后预启动 Pi Lab runtime / MCP cache
|
||||
* - MNOTE_PAGE_AI_PI_WARMUP_SEND:设为 "1" or "true" 时,额外发送一次轻量 prompt 预热模型首包(会产生真实模型请求)
|
||||
* - PYTHON_BIN:只在 BACKEND_CMD 未覆盖时,设置 Python 可执行文件,默认 "python"
|
||||
*/
|
||||
|
||||
@@ -28,6 +30,7 @@ const { spawn, execSync } = require("child_process");
|
||||
const path = require("path");
|
||||
const net = require("net");
|
||||
const fs = require("fs");
|
||||
const { runPiLabWarmup } = require("./lib/pi-lab-warmup");
|
||||
|
||||
const rootDir = path.resolve(__dirname, "..");
|
||||
const backendDir = path.join(rootDir, "wolai-backend");
|
||||
@@ -730,6 +733,29 @@ function startTask(task) {
|
||||
children.push(child);
|
||||
}
|
||||
|
||||
function schedulePiLabWarmup(frontendUrl) {
|
||||
if (!isEnabledEnv(mergedEnv.MNOTE_PAGE_AI_PI_WARMUP)) return;
|
||||
const warmupEnv = {
|
||||
...mergedEnv,
|
||||
MNOTE_PAGE_AI_PI_WARMUP_BASE_URL: mergedEnv.MNOTE_PAGE_AI_PI_WARMUP_BASE_URL || frontendUrl,
|
||||
};
|
||||
runPiLabWarmup(warmupEnv, {
|
||||
rootDir,
|
||||
log: (message) => logPrefix("pi-warmup", message),
|
||||
}).then((result) => {
|
||||
if (result.skipped) {
|
||||
logPrefix("pi-warmup", `已跳过:${result.reason || "disabled"}`);
|
||||
return;
|
||||
}
|
||||
logPrefix(
|
||||
"pi-warmup",
|
||||
`完成:session=${result.sessionId} send=${result.sendEnabled ? "1" : "0"} elapsedMs=${result.elapsedMs}`,
|
||||
);
|
||||
}).catch((error) => {
|
||||
logPrefix("pi-warmup", `失败但不阻塞 dev-hot:${error.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
function shutdown(code) {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
@@ -857,6 +883,7 @@ async function main() {
|
||||
for (const task of tasks) {
|
||||
startTask(task);
|
||||
}
|
||||
schedulePiLabWarmup(frontendUrl);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
@@ -881,6 +908,7 @@ module.exports = {
|
||||
resolveOpenHubHealthPlan,
|
||||
resolveRuntimePlan,
|
||||
resolveBackendExecutable,
|
||||
schedulePiLabWarmup,
|
||||
shouldStartBackend,
|
||||
shouldStartOpenHub,
|
||||
stopStaleMnoteWebCargoProcesses,
|
||||
|
||||
@@ -94,6 +94,8 @@ function buildDevHotEnv(baseEnv = process.env) {
|
||||
OPENHUB_HEALTH_URL: String(baseEnv.OPENHUB_HEALTH_URL || `${openhubBaseUrl.replace(/\/+$/, "")}/api/health`).trim(),
|
||||
MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE: String(baseEnv.MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE || "1"),
|
||||
MNOTE_CONTROL_PLANE_BACKEND: controlPlaneBackend,
|
||||
MNOTE_PAGE_AI_PI_WARMUP: String(baseEnv.MNOTE_PAGE_AI_PI_WARMUP ?? "1").trim() || "1",
|
||||
MNOTE_PAGE_AI_PI_WARMUP_SEND: String(baseEnv.MNOTE_PAGE_AI_PI_WARMUP_SEND ?? "1").trim() || "1",
|
||||
};
|
||||
if (controlPlaneBackend === "libsql-local" || controlPlaneBackend === "turso-local" || controlPlaneBackend === "turso") {
|
||||
env.MNOTE_TURSO_LOCAL_PATH =
|
||||
|
||||
@@ -33,20 +33,20 @@ async function setupWorkspaceAccess(requestContext, baseUrl, options) {
|
||||
[
|
||||
{
|
||||
kind: "setupWorkspace",
|
||||
userId: actorId,
|
||||
user_id: actorId,
|
||||
email: options.email || `${actorId}@example.com`,
|
||||
username: options.username || actorId,
|
||||
displayName: options.displayName || actorId,
|
||||
display_name: options.displayName || actorId,
|
||||
role: options.role || "user",
|
||||
workspaceId: options.workspaceId,
|
||||
workspaceName: options.workspaceName || "MNote Smoke Workspace",
|
||||
rootUri,
|
||||
rootPath,
|
||||
sourceKind: options.sourceKind || "local_folder",
|
||||
workspace_id: options.workspaceId,
|
||||
workspace_name: options.workspaceName || "MNote Smoke Workspace",
|
||||
root_uri: rootUri,
|
||||
root_path: rootPath,
|
||||
source_kind: options.sourceKind || "local_folder",
|
||||
permission: options.permission || "write",
|
||||
capabilities: options.capabilities || ["ai"],
|
||||
grantSource: options.grantSource || "smoke",
|
||||
grantCreatedBy: options.grantCreatedBy || actorId,
|
||||
grant_source: options.grantSource || "smoke",
|
||||
grant_created_by: options.grantCreatedBy || actorId,
|
||||
},
|
||||
],
|
||||
options.timeoutMs,
|
||||
@@ -61,19 +61,42 @@ async function seedAiRuntime(requestContext, baseUrl, options) {
|
||||
{
|
||||
kind: "seedAiRuntime",
|
||||
id: options.id,
|
||||
userId: options.userId,
|
||||
workspaceId: options.workspaceId,
|
||||
documentId: options.documentId,
|
||||
sessionId: options.sessionId,
|
||||
runId: options.runId,
|
||||
user_id: options.userId,
|
||||
workspace_id: options.workspaceId,
|
||||
document_id: options.documentId,
|
||||
session_id: options.sessionId,
|
||||
run_id: options.runId,
|
||||
title: options.title,
|
||||
profile: options.profile || "reasonix",
|
||||
acpRuntime: options.acpRuntime || options.profile || "reasonix",
|
||||
traceId: options.traceId,
|
||||
acp_runtime: options.acpRuntime || options.profile || "reasonix",
|
||||
trace_id: options.traceId,
|
||||
status: options.status || "running",
|
||||
runtimeJson: options.runtimeJson || {},
|
||||
payloadJson: options.payloadJson || {},
|
||||
events: options.events || [],
|
||||
runtime_json: options.runtimeJson || {},
|
||||
payload_json: options.payloadJson || {},
|
||||
events: (options.events || []).map((event) => ({
|
||||
id: event.id,
|
||||
event_type: event.event_type || event.eventType,
|
||||
payload_json: event.payload_json || event.payloadJson || {},
|
||||
})),
|
||||
},
|
||||
],
|
||||
options.timeoutMs,
|
||||
);
|
||||
}
|
||||
|
||||
async function seedAiPolicy(requestContext, baseUrl, options) {
|
||||
return postDevSeed(
|
||||
requestContext,
|
||||
baseUrl,
|
||||
[
|
||||
{
|
||||
kind: "seedAiPolicy",
|
||||
id: options.id,
|
||||
user_id: options.userId,
|
||||
workspace_id: options.workspaceId,
|
||||
allowed_roots_json: options.allowedRootsJson || [],
|
||||
model_policy_json: options.modelPolicyJson || {},
|
||||
quota_json: options.quotaJson || {},
|
||||
},
|
||||
],
|
||||
options.timeoutMs,
|
||||
@@ -87,8 +110,8 @@ async function clearRuntimeEvents(requestContext, baseUrl, options) {
|
||||
[
|
||||
{
|
||||
kind: "clearRuntimeEvents",
|
||||
userId: options.userId,
|
||||
runId: options.runId,
|
||||
user_id: options.userId,
|
||||
run_id: options.runId,
|
||||
},
|
||||
],
|
||||
options.timeoutMs,
|
||||
@@ -102,8 +125,8 @@ async function getAiRuntimeRun(requestContext, baseUrl, options) {
|
||||
[
|
||||
{
|
||||
kind: "getAiRuntimeRun",
|
||||
userId: options.userId,
|
||||
runId: options.runId,
|
||||
user_id: options.userId,
|
||||
run_id: options.runId,
|
||||
},
|
||||
],
|
||||
options.timeoutMs,
|
||||
@@ -118,10 +141,10 @@ async function listAiRuntimeRuns(requestContext, baseUrl, options) {
|
||||
[
|
||||
{
|
||||
kind: "listAiRuntimeRuns",
|
||||
userId: options.userId,
|
||||
workspaceId: options.workspaceId,
|
||||
documentId: options.documentId,
|
||||
sessionId: options.sessionId,
|
||||
user_id: options.userId,
|
||||
workspace_id: options.workspaceId,
|
||||
document_id: options.documentId,
|
||||
session_id: options.sessionId,
|
||||
limit: options.limit,
|
||||
},
|
||||
],
|
||||
@@ -137,10 +160,10 @@ async function listAiRuntimeEvents(requestContext, baseUrl, options) {
|
||||
[
|
||||
{
|
||||
kind: "listAiRuntimeEvents",
|
||||
userId: options.userId,
|
||||
runId: options.runId,
|
||||
user_id: options.userId,
|
||||
run_id: options.runId,
|
||||
limit: options.limit,
|
||||
eventType: options.eventType,
|
||||
event_type: options.eventType,
|
||||
},
|
||||
],
|
||||
options.timeoutMs,
|
||||
@@ -155,9 +178,9 @@ async function countAiRuntimeEvents(requestContext, baseUrl, options) {
|
||||
[
|
||||
{
|
||||
kind: "countAiRuntimeEvents",
|
||||
userId: options.userId,
|
||||
runId: options.runId,
|
||||
eventType: options.eventType,
|
||||
user_id: options.userId,
|
||||
run_id: options.runId,
|
||||
event_type: options.eventType,
|
||||
},
|
||||
],
|
||||
options.timeoutMs,
|
||||
@@ -172,9 +195,9 @@ async function findExternalConversationBinding(requestContext, baseUrl, options)
|
||||
[
|
||||
{
|
||||
kind: "findExternalConversationBinding",
|
||||
userId: options.userId,
|
||||
workspaceId: options.workspaceId,
|
||||
mnoteSessionId: options.mnoteSessionId,
|
||||
user_id: options.userId,
|
||||
workspace_id: options.workspaceId,
|
||||
mnote_session_id: options.mnoteSessionId,
|
||||
provider: options.provider,
|
||||
},
|
||||
],
|
||||
@@ -190,9 +213,9 @@ async function listExternalConversationBindings(requestContext, baseUrl, options
|
||||
[
|
||||
{
|
||||
kind: "listExternalConversationBindings",
|
||||
userId: options.userId,
|
||||
workspaceId: options.workspaceId,
|
||||
mnoteSessionId: options.mnoteSessionId,
|
||||
user_id: options.userId,
|
||||
workspace_id: options.workspaceId,
|
||||
mnote_session_id: options.mnoteSessionId,
|
||||
limit: options.limit,
|
||||
},
|
||||
],
|
||||
@@ -205,6 +228,7 @@ module.exports = {
|
||||
postDevSeed,
|
||||
setupWorkspaceAccess,
|
||||
seedAiRuntime,
|
||||
seedAiPolicy,
|
||||
clearRuntimeEvents,
|
||||
getAiRuntimeRun,
|
||||
listAiRuntimeRuns,
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
function isEnabledEnv(value) {
|
||||
const normalized = String(value || "").trim().toLowerCase();
|
||||
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
|
||||
}
|
||||
|
||||
function actorSegment(actorId) {
|
||||
const value = String(actorId || "").trim().replace(/[/:\\]/g, "_");
|
||||
return value || "mnote-e2e";
|
||||
}
|
||||
|
||||
function rootUriFromPath(rootPath) {
|
||||
return `file://${path.resolve(rootPath)}`;
|
||||
}
|
||||
|
||||
function resolvePiLabWarmupPlan(env = process.env, rootDir = path.resolve(__dirname, "../..")) {
|
||||
const actorId = String(env.MNOTE_PAGE_AI_PI_WARMUP_ACTOR_ID || env.MNOTE_E2E_ACTOR_ID || "mnote-e2e").trim();
|
||||
const defaultRootPath = String(
|
||||
env.MNOTE_PAGE_AI_PI_WARMUP_ROOT_PATH ||
|
||||
env.MNOTE_E2E_ROOT_PATH ||
|
||||
"/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space",
|
||||
).trim();
|
||||
const rootUri = String(
|
||||
env.MNOTE_PAGE_AI_PI_WARMUP_ROOT_URI ||
|
||||
env.MNOTE_E2E_ROOT_URI ||
|
||||
rootUriFromPath(fs.existsSync(defaultRootPath) ? defaultRootPath : rootDir),
|
||||
).trim();
|
||||
const baseUrl = String(
|
||||
env.MNOTE_PAGE_AI_PI_WARMUP_BASE_URL ||
|
||||
env.MNOTE_UI_BASE_URL ||
|
||||
env.MNOTE_WEB_PUBLIC_URL ||
|
||||
"http://127.0.0.1:3000",
|
||||
).replace(/\/+$/, "");
|
||||
|
||||
return {
|
||||
enabled: isEnabledEnv(env.MNOTE_PAGE_AI_PI_WARMUP),
|
||||
sendEnabled: isEnabledEnv(env.MNOTE_PAGE_AI_PI_WARMUP_SEND),
|
||||
baseUrl,
|
||||
actorId,
|
||||
auth: String(env.MNOTE_PAGE_AI_PI_WARMUP_AUTH || "Bearer pi-lab-smoke").trim(),
|
||||
sessionId: String(
|
||||
env.MNOTE_PAGE_AI_PI_WARMUP_SESSION_ID || `pi_lab_dev_warm_${actorSegment(actorId)}`,
|
||||
).trim(),
|
||||
rootUri,
|
||||
workspaceId: String(
|
||||
env.MNOTE_PAGE_AI_PI_WARMUP_WORKSPACE_ID ||
|
||||
env.MNOTE_E2E_WORKSPACE_ID ||
|
||||
`local-ws:${actorId}:my-space`,
|
||||
).trim(),
|
||||
pagePath: String(env.MNOTE_PAGE_AI_PI_WARMUP_PAGE_PATH || "pi-lab-warmup.md").trim(),
|
||||
pageTitle: String(env.MNOTE_PAGE_AI_PI_WARMUP_PAGE_TITLE || "Pi Lab dev warmup").trim(),
|
||||
modelProvider: String(
|
||||
env.MNOTE_PAGE_AI_PI_WARMUP_MODEL_PROVIDER || env.MNOTE_PAGE_AI_PI_DEFAULT_MODEL_PROVIDER || "",
|
||||
).trim(),
|
||||
modelId: String(
|
||||
env.MNOTE_PAGE_AI_PI_WARMUP_MODEL_ID ||
|
||||
env.MNOTE_PAGE_AI_PI_DEFAULT_MODEL ||
|
||||
env.MNOTE_PAGE_AI_PI_DEFAULT_MODEL_ID ||
|
||||
"",
|
||||
).trim(),
|
||||
thinkingLevel: String(
|
||||
env.MNOTE_PAGE_AI_PI_WARMUP_THINKING ||
|
||||
env.MNOTE_PAGE_AI_PI_THINKING ||
|
||||
"medium",
|
||||
).trim(),
|
||||
permissionMode: String(env.MNOTE_PAGE_AI_PI_WARMUP_PERMISSION_MODE || "plan").trim(),
|
||||
prompt: String(env.MNOTE_PAGE_AI_PI_WARMUP_PROMPT || "只回复 OK,不要解释。").trim(),
|
||||
readyTimeoutMs: Number.parseInt(env.MNOTE_PAGE_AI_PI_WARMUP_READY_TIMEOUT_MS || "180000", 10),
|
||||
requestTimeoutMs: Number.parseInt(env.MNOTE_PAGE_AI_PI_WARMUP_REQUEST_TIMEOUT_MS || "30000", 10),
|
||||
sendTimeoutMs: Number.parseInt(env.MNOTE_PAGE_AI_PI_WARMUP_SEND_TIMEOUT_MS || "120000", 10),
|
||||
};
|
||||
}
|
||||
|
||||
function warmupHeaders(plan) {
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
Authorization: plan.auth,
|
||||
"x-mnote-actor-id": plan.actorId,
|
||||
};
|
||||
}
|
||||
|
||||
async function sleep(ms) {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function fetchJson(url, options, timeoutMs) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, { ...options, signal: controller.signal });
|
||||
const text = await response.text();
|
||||
let body = {};
|
||||
try {
|
||||
body = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
body = { raw: text };
|
||||
}
|
||||
return { response, body };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForStatus(plan, log) {
|
||||
const startedAt = Date.now();
|
||||
const statusUrl = `${plan.baseUrl}/api/page-ai/pi/status`;
|
||||
let lastError = "";
|
||||
while (Date.now() - startedAt <= plan.readyTimeoutMs) {
|
||||
try {
|
||||
const result = await fetchJson(statusUrl, {
|
||||
method: "GET",
|
||||
headers: warmupHeaders(plan),
|
||||
}, Math.min(plan.requestTimeoutMs, 5000));
|
||||
if (result.response.ok) {
|
||||
return result.body;
|
||||
}
|
||||
lastError = `HTTP ${result.response.status}`;
|
||||
} catch (error) {
|
||||
lastError = error && error.message ? error.message : String(error);
|
||||
}
|
||||
await sleep(1000);
|
||||
}
|
||||
throw new Error(`等待 Pi Lab status 超时:${lastError || statusUrl}`);
|
||||
}
|
||||
|
||||
async function waitForWarmupReply(plan, log) {
|
||||
const startedAt = Date.now();
|
||||
const eventsUrl = `${plan.baseUrl}/api/page-ai/pi/sessions/${encodeURIComponent(plan.sessionId)}/events?limit=200`;
|
||||
while (Date.now() - startedAt <= plan.sendTimeoutMs) {
|
||||
const result = await fetchJson(eventsUrl, {
|
||||
method: "GET",
|
||||
headers: warmupHeaders(plan),
|
||||
}, Math.min(plan.requestTimeoutMs, 5000));
|
||||
if (result.response.ok) {
|
||||
const events = Array.isArray(result.body.events) ? result.body.events : [];
|
||||
const terminal = events.some((event) => {
|
||||
if (!event || event.eventType !== "pi_rpc_event") return false;
|
||||
const payload = event.payload || {};
|
||||
const type = payload.type || (payload.assistantMessageEvent && payload.assistantMessageEvent.type);
|
||||
return type === "agent_end" || type === "message_end" || type === "done" || type === "error";
|
||||
});
|
||||
const firstText = events.some((event) => {
|
||||
const payload = event && event.payload ? event.payload : {};
|
||||
const msg = payload.assistantMessageEvent || payload;
|
||||
return msg.type === "text_delta" || msg.type === "text_start" || payload.type === "message";
|
||||
});
|
||||
if (terminal || firstText) return { terminal, firstText };
|
||||
}
|
||||
await sleep(1000);
|
||||
}
|
||||
log("warmup prompt 已发送,但等待首个模型事件超时;运行时仍保持可复用。");
|
||||
return { terminal: false, firstText: false, timeout: true };
|
||||
}
|
||||
|
||||
async function runPiLabWarmup(env = process.env, options = {}) {
|
||||
const rootDir = options.rootDir || path.resolve(__dirname, "../..");
|
||||
const log = options.log || (() => undefined);
|
||||
const plan = resolvePiLabWarmupPlan(env, rootDir);
|
||||
if (!plan.enabled) return { ok: true, skipped: true, reason: "disabled" };
|
||||
|
||||
const startedAt = Date.now();
|
||||
log(`等待 Pi Lab status:${plan.baseUrl}`);
|
||||
const status = await waitForStatus(plan, log);
|
||||
if (status.enabled === false) {
|
||||
log("Pi Lab 未启用,跳过 warmup。");
|
||||
return { ok: true, skipped: true, reason: "disabled_by_backend" };
|
||||
}
|
||||
|
||||
const startBody = {
|
||||
sessionId: plan.sessionId,
|
||||
rootUri: plan.rootUri,
|
||||
workspaceId: plan.workspaceId,
|
||||
pagePath: plan.pagePath,
|
||||
pageTitle: plan.pageTitle,
|
||||
thinkingLevel: plan.thinkingLevel,
|
||||
permissionMode: plan.permissionMode,
|
||||
};
|
||||
if (plan.modelProvider) startBody.modelProvider = plan.modelProvider;
|
||||
if (plan.modelId) startBody.modelId = plan.modelId;
|
||||
|
||||
log(`启动 Pi Lab warm session:${plan.sessionId}`);
|
||||
const start = await fetchJson(`${plan.baseUrl}/api/page-ai/pi/start`, {
|
||||
method: "POST",
|
||||
headers: warmupHeaders(plan),
|
||||
body: JSON.stringify(startBody),
|
||||
}, plan.requestTimeoutMs);
|
||||
if (!start.response.ok || start.body.ok !== true) {
|
||||
throw new Error(`Pi Lab warmup start 失败:HTTP ${start.response.status} ${JSON.stringify(start.body).slice(0, 500)}`);
|
||||
}
|
||||
|
||||
if (plan.sendEnabled) {
|
||||
log("发送 Pi Lab warmup prompt;这会产生一次真实模型请求。");
|
||||
const send = await fetchJson(`${plan.baseUrl}/api/page-ai/pi/send`, {
|
||||
method: "POST",
|
||||
headers: warmupHeaders(plan),
|
||||
body: JSON.stringify({
|
||||
sessionId: plan.sessionId,
|
||||
message: plan.prompt,
|
||||
}),
|
||||
}, plan.requestTimeoutMs);
|
||||
if (!send.response.ok || send.body.accepted !== true) {
|
||||
throw new Error(`Pi Lab warmup send 失败:HTTP ${send.response.status} ${JSON.stringify(send.body).slice(0, 500)}`);
|
||||
}
|
||||
await waitForWarmupReply(plan, log);
|
||||
} else {
|
||||
log("已预启动 Pi runtime / MCP cache;未发送模型请求。");
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
skipped: false,
|
||||
sessionId: plan.sessionId,
|
||||
sendEnabled: plan.sendEnabled,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
runPiLabWarmup(process.env, {
|
||||
rootDir: path.resolve(__dirname, "../.."),
|
||||
log: (message) => console.log(`[pi-warmup] ${message}`),
|
||||
}).then((result) => {
|
||||
if (!result.skipped) {
|
||||
console.log(`[pi-warmup] 完成:session=${result.sessionId} elapsedMs=${result.elapsedMs}`);
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error(`[pi-warmup] 失败:${error.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isEnabledEnv,
|
||||
resolvePiLabWarmupPlan,
|
||||
runPiLabWarmup,
|
||||
};
|
||||
@@ -0,0 +1,439 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Minimal Browser QA: Block handle menu icon/layout check
|
||||
* Scope: open editor → hover paragraph handle → click handle → check menu
|
||||
* NO expansion to multiple block types, pages, or features.
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const BASE = process.env.MNOTE_BASE || "http://localhost:3000";
|
||||
const EVIDENCE_DIR = path.join(__dirname, "..", "tmp", "browser-qa-evidence");
|
||||
const STEPS = [];
|
||||
let status = "PASS";
|
||||
|
||||
fs.mkdirSync(EVIDENCE_DIR, { recursive: true });
|
||||
|
||||
async function screenshot(page, name) {
|
||||
const fp = path.join(EVIDENCE_DIR, `${name}.png`);
|
||||
await page.screenshot({ path: fp, fullPage: false });
|
||||
const size = fs.statSync(fp).size;
|
||||
console.log(` 📸 ${name}.png (${(size / 1024).toFixed(1)} KB)`);
|
||||
return fp;
|
||||
}
|
||||
|
||||
function step(id, desc) {
|
||||
return { id, desc, status: "pending", evidence: [] };
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath:
|
||||
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" : ""),
|
||||
args: ["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"],
|
||||
});
|
||||
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1280, height: 800 },
|
||||
locale: "zh-CN",
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
// Collect console errors
|
||||
const consoleErrors = [];
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() === "error") {
|
||||
consoleErrors.push(msg.text().slice(0, 200));
|
||||
}
|
||||
});
|
||||
const networkErrors = [];
|
||||
page.on("requestfailed", (req) => {
|
||||
networkErrors.push(`${req.failure().errorText} ${req.url().slice(0, 120)}`);
|
||||
});
|
||||
|
||||
try {
|
||||
// ── Step 1: Login ──
|
||||
let s1 = step("login", "Login via quick-login button");
|
||||
console.log(`\n🔍 Step 1: ${s1.desc}`);
|
||||
await page.goto(`${BASE}/auth`, { waitUntil: "networkidle", timeout: 15000 });
|
||||
const loginBtn = page.getByRole("button", { name: "测试账号快速登录" });
|
||||
await loginBtn.waitFor({ state: "visible", timeout: 8000 });
|
||||
await Promise.all([
|
||||
page.waitForURL((url) => !url.pathname.includes("/auth"), { timeout: 15000 }),
|
||||
loginBtn.click(),
|
||||
]);
|
||||
await page.waitForTimeout(2000);
|
||||
const afterLogin = page.url();
|
||||
s1.status = afterLogin.includes("/auth") ? "FAIL" : "PASS";
|
||||
s1.evidence.push(await screenshot(page, "01-after-login"));
|
||||
console.log(` URL: ${afterLogin} → ${s1.status}`);
|
||||
STEPS.push(s1);
|
||||
|
||||
if (s1.status === "FAIL") {
|
||||
console.log("\n❌ Login failed, aborting.");
|
||||
status = "FAIL";
|
||||
printResult();
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Step 2: Navigate to a page with content ──
|
||||
let s2 = step("navigate", "Navigate to an existing page with paragraph content");
|
||||
console.log(`\n🔍 Step 2: ${s2.desc}`);
|
||||
|
||||
await page.waitForTimeout(1500);
|
||||
s2.evidence.push(await screenshot(page, "02-main-screen"));
|
||||
|
||||
// The sidebar lists page items under "我的页面". Click the first one.
|
||||
// Sidebar items are typically list items or links inside the sidebar.
|
||||
const sidebarPageItem = page.locator('[data-testid*="sidebar"] li, [data-testid*="sidebar"] a, [class*="sidebar"] li, [class*="sidebar"] a, [class*="page-tree"] li, [class*="page-tree"] a, nav li a').first();
|
||||
const sidebarVisible = await sidebarPageItem.isVisible({ timeout: 5000 }).catch(() => false);
|
||||
console.log(` Sidebar item visible: ${sidebarVisible}`);
|
||||
|
||||
let foundPage = false;
|
||||
if (sidebarVisible) {
|
||||
const itemText = await sidebarPageItem.textContent();
|
||||
console.log(` Clicking sidebar item: "${itemText}"`);
|
||||
await sidebarPageItem.click();
|
||||
await page.waitForTimeout(2500);
|
||||
foundPage = true;
|
||||
} else {
|
||||
// Fallback: try to click on any visible text that looks like a page name in the main area
|
||||
const pageLink = page.locator('text=有机合成中的保护基').first();
|
||||
if (await pageLink.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
console.log(` Clicking page link in main area`);
|
||||
await pageLink.click();
|
||||
await page.waitForTimeout(2500);
|
||||
foundPage = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we're now in an editor (ProseMirror / contenteditable visible)
|
||||
const editorCheck = await page.locator('.ProseMirror, [contenteditable="true"], .tiptap').first()
|
||||
.isVisible({ timeout: 3000 }).catch(() => false);
|
||||
console.log(` Editor visible after nav: ${editorCheck}`);
|
||||
|
||||
if (!editorCheck && foundPage) {
|
||||
// Maybe we landed on a file-explorer view, try clicking the first .md page in the list
|
||||
const mdLink = page.locator('text=有机合成中的保护基').first();
|
||||
if (await mdLink.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await mdLink.click();
|
||||
await page.waitForTimeout(2500);
|
||||
}
|
||||
}
|
||||
|
||||
s2.status = foundPage ? "PASS" : "BLOCKED";
|
||||
s2.evidence.push(await screenshot(page, "03-page-loaded"));
|
||||
console.log(` Found page: ${foundPage} → ${s2.status}`);
|
||||
STEPS.push(s2);
|
||||
|
||||
if (s2.status !== "PASS") {
|
||||
status = s2.status;
|
||||
printResult();
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Step 3: Locate paragraph block and hover handle ──
|
||||
let s3 = step("hover-handle", "Hover paragraph block left handle to reveal drag handle icon");
|
||||
console.log(`\n🔍 Step 3: ${s3.desc}`);
|
||||
|
||||
// Wait for editor content to be ready
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// tiptap editor renders paragraphs inside .ProseMirror or similar
|
||||
// The block handle (drag handle / menu trigger) typically appears on hover
|
||||
// Common selectors for tiptap paragraph blocks:
|
||||
const editorArea = page.locator('.ProseMirror, [contenteditable="true"], .tiptap, [data-testid*="editor"]').first();
|
||||
const editorVisible = await editorArea.isVisible({ timeout: 5000 }).catch(() => false);
|
||||
console.log(` Editor area visible: ${editorVisible}`);
|
||||
|
||||
if (!editorVisible) {
|
||||
s3.status = "BLOCKED";
|
||||
s3.evidence.push(await screenshot(page, "04-no-editor"));
|
||||
console.log(" ❌ No editor area found");
|
||||
STEPS.push(s3);
|
||||
status = "BLOCKED";
|
||||
printResult();
|
||||
return;
|
||||
}
|
||||
|
||||
// Find a paragraph element inside the editor
|
||||
const paragraph = page.locator('.ProseMirror p, .tiptap p, [contenteditable="true"] p').first();
|
||||
const paraVisible = await paragraph.isVisible({ timeout: 5000 }).catch(() => false);
|
||||
console.log(` Paragraph visible: ${paraVisible}`);
|
||||
|
||||
if (!paraVisible) {
|
||||
s3.status = "BLOCKED";
|
||||
s3.evidence.push(await screenshot(page, "04b-no-paragraph"));
|
||||
console.log(" ❌ No paragraph found in editor");
|
||||
STEPS.push(s3);
|
||||
status = "BLOCKED";
|
||||
printResult();
|
||||
return;
|
||||
}
|
||||
|
||||
// Hover near the left edge of the paragraph to trigger handle appearance
|
||||
const paraBox = await paragraph.boundingBox();
|
||||
console.log(` Paragraph box: ${JSON.stringify(paraBox)}`);
|
||||
|
||||
// Hover at the left edge of the paragraph (where the handle appears)
|
||||
await page.mouse.move(paraBox.x - 5, paraBox.y + paraBox.height / 2, { steps: 10 });
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
// Take screenshot to see if handle appeared
|
||||
s3.evidence.push(await screenshot(page, "05-after-hover-handle"));
|
||||
|
||||
// Look for handle / drag handle element
|
||||
// Common handle selectors in tiptap/leptos-tiptap
|
||||
const handleSelectors = [
|
||||
'[data-testid*="handle"]',
|
||||
'[data-testid*="drag"]',
|
||||
'.block-handle',
|
||||
'.drag-handle',
|
||||
'.ProseMirror .handle',
|
||||
'.ProseMirror [contenteditable="false"]',
|
||||
'.tiptap .handle',
|
||||
'.tiptap .drag-handle',
|
||||
'button[aria-label*="handle" i]',
|
||||
'button[aria-label*="drag" i]',
|
||||
'button[aria-label*="menu" i]',
|
||||
'[class*="handle"]',
|
||||
'[class*="Handle"]',
|
||||
'[class*="drag-handle"]',
|
||||
'[class*="block-handle"]',
|
||||
'.leptos-tiptap-handle',
|
||||
'[data-drag-handle]',
|
||||
'.ProseMirror > div:first-child .handle',
|
||||
];
|
||||
|
||||
let handleEl = null;
|
||||
let matchedSelector = null;
|
||||
for (const sel of handleSelectors) {
|
||||
const el = page.locator(sel).first();
|
||||
if (await el.isVisible({ timeout: 500 }).catch(() => false)) {
|
||||
handleEl = el;
|
||||
matchedSelector = sel;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (handleEl) {
|
||||
console.log(` ✅ Handle found with selector: ${matchedSelector}`);
|
||||
s3.status = "PASS";
|
||||
} else {
|
||||
console.log(" ⚠️ No handle element found via standard selectors, checking DOM...");
|
||||
// Dump DOM near paragraph for debugging
|
||||
const parentHTML = await paragraph.evaluate((el) => {
|
||||
return el.parentElement?.outerHTML?.slice(0, 2000) || "N/A";
|
||||
});
|
||||
console.log(` Parent HTML: ${parentHTML.slice(0, 500)}`);
|
||||
|
||||
// Also check if handle is inside a wrapper
|
||||
const wrapperHandle = await paragraph.evaluate((el) => {
|
||||
const wrapper = el.closest('[class*="block"], [class*="node"], [data-node-type], [data-type]');
|
||||
if (wrapper) {
|
||||
return wrapper.outerHTML.slice(0, 1500);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
if (wrapperHandle) {
|
||||
console.log(` Wrapper HTML: ${wrapperHandle.slice(0, 500)}`);
|
||||
}
|
||||
|
||||
s3.status = "FAIL";
|
||||
s3.evidence.push(await screenshot(page, "05b-no-handle-visible"));
|
||||
}
|
||||
|
||||
STEPS.push(s3);
|
||||
|
||||
// ── Step 4: Click handle to open block menu ──
|
||||
let s4 = step("click-handle", "Click handle to open block context menu");
|
||||
console.log(`\n🔍 Step 4: ${s4.desc}`);
|
||||
|
||||
if (!handleEl) {
|
||||
s4.status = "BLOCKED";
|
||||
s4.evidence.push(await screenshot(page, "06-no-handle-to-click"));
|
||||
console.log(" ⏭️ Skipped: no handle found");
|
||||
STEPS.push(s4);
|
||||
} else {
|
||||
await handleEl.click();
|
||||
await page.waitForTimeout(800);
|
||||
s4.evidence.push(await screenshot(page, "06-after-click-handle"));
|
||||
|
||||
// Look for menu that appeared
|
||||
const menuSelectors = [
|
||||
'[data-testid*="menu"]',
|
||||
'[data-testid*="slash"]',
|
||||
'[role="menu"]',
|
||||
'[role="listbox"]',
|
||||
'.block-menu',
|
||||
'.slash-menu',
|
||||
'[class*="menu"]',
|
||||
'[class*="Menu"]',
|
||||
'[class*="popup"]',
|
||||
'[class*="dropdown"]',
|
||||
'.ProseMirror [contenteditable="false"] [role]',
|
||||
];
|
||||
|
||||
let menuEl = null;
|
||||
let menuSelector = null;
|
||||
for (const sel of menuSelectors) {
|
||||
const el = page.locator(sel).first();
|
||||
if (await el.isVisible({ timeout: 500 }).catch(() => false)) {
|
||||
menuEl = el;
|
||||
menuSelector = sel;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (menuEl) {
|
||||
console.log(` ✅ Menu appeared with selector: ${menuSelector}`);
|
||||
s4.status = "PASS";
|
||||
|
||||
// Check menu contents - icons and layout
|
||||
const menuItems = await menuEl.locator('[role="menuitem"], [role="option"], li, button').all();
|
||||
console.log(` Menu items count: ${menuItems.length}`);
|
||||
|
||||
// Check for icons within menu items
|
||||
let iconsFound = 0;
|
||||
for (const item of menuItems.slice(0, 10)) {
|
||||
const hasIcon = await item.locator('svg, img, [class*="icon"], [class*="Icon"]').count();
|
||||
if (hasIcon > 0) iconsFound++;
|
||||
}
|
||||
console.log(` Items with icons: ${iconsFound}/${Math.min(menuItems.length, 10)}`);
|
||||
|
||||
s4.evidence.push(await screenshot(page, "07-menu-open-detail"));
|
||||
|
||||
// Dump menu DOM for evidence
|
||||
const menuHTML = await menuEl.evaluate((el) => el.outerHTML.slice(0, 3000));
|
||||
const menuTexts = await menuEl.evaluate((el) => {
|
||||
return Array.from(el.querySelectorAll('*')).map(e => e.textContent?.trim()).filter(Boolean).slice(0, 30);
|
||||
});
|
||||
console.log(` Menu texts: ${JSON.stringify(menuTexts.slice(0, 15))}`);
|
||||
} else {
|
||||
console.log(" ⚠️ No menu appeared after clicking handle");
|
||||
s4.status = "FAIL";
|
||||
|
||||
// Check if maybe a different element appeared
|
||||
const anyPopup = await page.locator('[class*="popup"], [class*="overlay"], [class*="modal"], [class*="dropdown"], [class*="tooltip"]').first();
|
||||
const popupVisible = await anyPopup.isVisible({ timeout: 500 }).catch(() => false);
|
||||
console.log(` Any popup visible: ${popupVisible}`);
|
||||
s4.evidence.push(await screenshot(page, "07b-no-menu"));
|
||||
}
|
||||
|
||||
STEPS.push(s4);
|
||||
}
|
||||
|
||||
// ── Step 5: Layout / visual check ──
|
||||
let s5 = step("layout-check", "Check handle icon, menu icons and menu layout");
|
||||
console.log(`\n🔍 Step 5: ${s5.desc}`);
|
||||
|
||||
if (s4.status !== "PASS") {
|
||||
s5.status = "BLOCKED";
|
||||
s5.evidence.push(await screenshot(page, "08-layout-check-skipped"));
|
||||
console.log(" ⏭️ Skipped: menu not confirmed open");
|
||||
} else {
|
||||
// Check menu layout: bounding box, items alignment
|
||||
const menuBox = await menuEl.boundingBox();
|
||||
console.log(` Menu bounding box: ${JSON.stringify(menuBox)}`);
|
||||
|
||||
// Verify menu is not overlapping editor content badly
|
||||
const editorBox = await editorArea.boundingBox();
|
||||
if (menuBox && editorBox) {
|
||||
const isOnScreen = menuBox.x >= 0 && menuBox.y >= 0 &&
|
||||
menuBox.x + menuBox.width <= 1280 && menuBox.y + menuBox.height <= 800;
|
||||
const hasReasonableSize = menuBox.width > 50 && menuBox.height > 30;
|
||||
console.log(` On screen: ${isOnScreen}, Reasonable size: ${hasReasonableSize}`);
|
||||
s5.status = (isOnScreen && hasReasonableSize) ? "PASS" : "FAIL";
|
||||
} else {
|
||||
s5.status = "FAIL";
|
||||
}
|
||||
|
||||
s5.evidence.push(await screenshot(page, "08-layout-final"));
|
||||
}
|
||||
|
||||
STEPS.push(s5);
|
||||
|
||||
// Console/network summary
|
||||
printResult(consoleErrors, networkErrors);
|
||||
|
||||
} catch (err) {
|
||||
console.error(`\n💥 Fatal error: ${err.message}`);
|
||||
status = "FAIL";
|
||||
try { await screenshot(page, "99-fatal-error"); } catch (_) {}
|
||||
printResult(consoleErrors, networkErrors, err);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
function printResult(consoleErrors = [], networkErrors = [], fatalErr = null) {
|
||||
const allPass = STEPS.every((s) => s.status === "PASS" || s.status === "no-op");
|
||||
const anyBlocked = STEPS.some((s) => s.status === "BLOCKED");
|
||||
|
||||
if (!fatalErr && allPass) status = "PASS";
|
||||
else if (anyBlocked) status = "BLOCKED";
|
||||
else if (fatalErr) status = "FAIL";
|
||||
|
||||
console.log("\n" + "═".repeat(70));
|
||||
console.log("BROWSER_QA_RESULT");
|
||||
console.log("═".repeat(70));
|
||||
console.log(`STATUS: ${status}`);
|
||||
console.log(`steps_run: ${STEPS.length}`);
|
||||
console.log(`steps_detail:`);
|
||||
for (const s of STEPS) {
|
||||
console.log(` ${s.id}: ${s.status} — ${s.desc}`);
|
||||
}
|
||||
console.log(`actual_vs_expected:`);
|
||||
for (const s of STEPS) {
|
||||
const expected = s.id === "login" ? "Redirected to workspace"
|
||||
: s.id === "navigate" ? "Editor page loaded with paragraph content"
|
||||
: s.id === "hover-handle" ? "Drag handle icon visible on left of paragraph"
|
||||
: s.id === "click-handle" ? "Block context menu opens with icons and proper layout"
|
||||
: s.id === "layout-check" ? "Menu on screen, reasonable size, icons present"
|
||||
: "N/A";
|
||||
console.log(` ${s.id}: expected="${expected}" actual="${s.status}"`);
|
||||
}
|
||||
console.log(`evidence_paths:`);
|
||||
for (const s of STEPS) {
|
||||
for (const e of s.evidence) {
|
||||
console.log(` ${s.id}: ${e}`);
|
||||
}
|
||||
}
|
||||
console.log(`console_network_summary:`);
|
||||
console.log(` console_errors: ${consoleErrors.length}`);
|
||||
for (const e of consoleErrors.slice(0, 5)) console.log(` - ${e}`);
|
||||
console.log(` network_errors: ${networkErrors.length}`);
|
||||
for (const e of networkErrors.slice(0, 5)) console.log(` - ${e}`);
|
||||
|
||||
const hasHandleBug = STEPS.some(s =>
|
||||
(s.id === "hover-handle" && s.status === "FAIL") ||
|
||||
(s.id === "click-handle" && s.status === "FAIL")
|
||||
);
|
||||
|
||||
console.log(`candidate_bug:`);
|
||||
if (hasHandleBug) {
|
||||
console.log(` title: "Block handle icon not visible or block menu does not open on handle click"`);
|
||||
console.log(` scope: "leptos-tiptap editor paragraph block handle interaction"`);
|
||||
console.log(` symptoms: "Handle icon does not appear on hover, or clicking handle does not open context menu"`);
|
||||
} else if (status === "BLOCKED") {
|
||||
console.log(` title: "BLOCKED - Could not reach editor or find paragraph block"`);
|
||||
console.log(` scope: "test infrastructure / page navigation"`);
|
||||
} else {
|
||||
console.log(` title: "none detected in this run"`);
|
||||
}
|
||||
console.log(`needs_triage: ${hasHandleBug || status === "BLOCKED" ? "YES" : "NO"}`);
|
||||
console.log("═".repeat(70));
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error("Unhandled:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,699 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const path = require("path");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const TIMEOUT_MS = 30_000;
|
||||
const SCREENSHOT_DIR = path.join(
|
||||
process.env.MNOTE_QA_SCREENSHOT_DIR || path.resolve(__dirname, "../tmp/qa-block-handle"),
|
||||
);
|
||||
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const TEST_EMAIL = "mnote.e2e@example.com";
|
||||
const TEST_PASSWORD = "MnoteE2E123!";
|
||||
|
||||
const WORKSPACE_DIR = path.join(
|
||||
process.env.MNOTE_QA_WORKSPACE_DIR || path.resolve(__dirname, "../tmp/mnote-qa-handle"),
|
||||
);
|
||||
|
||||
const TEST_MARKDOWN = `\
|
||||
# 块手柄测试页
|
||||
|
||||
第一段正文,用于测试段落块的手柄和菜单。
|
||||
|
||||
第二段正文,用于测试第二个段落块。
|
||||
|
||||
## 二级标题块
|
||||
|
||||
标题下方正文段落。
|
||||
|
||||
- 列表项 A
|
||||
- 列表项 B
|
||||
|
||||
\`\`\`
|
||||
代码块示例
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
function uniqueSuffix() {
|
||||
return Math.random().toString(36).slice(2, 8);
|
||||
}
|
||||
|
||||
function ensureDirSync(dir) {
|
||||
const fs = require("fs");
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
function writeWsManifest() {
|
||||
const fs = require("fs");
|
||||
ensureDirSync(path.join(WORKSPACE_DIR, ".mnote"));
|
||||
fs.writeFileSync(
|
||||
path.join(WORKSPACE_DIR, ".mnote/workspace.json"),
|
||||
JSON.stringify({
|
||||
name: "QA Block Handle Test",
|
||||
version: 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(path.join(WORKSPACE_DIR, "BlockHandleTest.md"), TEST_MARKDOWN);
|
||||
}
|
||||
|
||||
async function ensureAuthenticated(page, requestContext) {
|
||||
const response = await requestContext.fetch(`${BASE_URL}/api/auth/whoami`, {
|
||||
method: "GET",
|
||||
timeout: 10_000,
|
||||
});
|
||||
const body = await response.json().catch(() => null);
|
||||
if (body?.user) return body.user;
|
||||
|
||||
await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: TIMEOUT_MS });
|
||||
await page.waitForTimeout(1000);
|
||||
const btn = page.getByRole("button", { name: "测试账号快速登录" });
|
||||
if (await btn.isVisible().catch(() => false)) {
|
||||
await btn.click();
|
||||
await page.waitForURL("**/documents/**", { timeout: TIMEOUT_MS }).catch(() => {});
|
||||
await page.waitForTimeout(2000);
|
||||
} else {
|
||||
await page.getByLabel("邮箱").fill(TEST_EMAIL);
|
||||
await page.getByLabel("密码").fill(TEST_PASSWORD);
|
||||
await page.getByRole("button", { name: /登录|Login|Sign in/i }).click();
|
||||
await page.waitForURL("**/documents/**", { timeout: TIMEOUT_MS }).catch(() => {});
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const fs = require("fs");
|
||||
ensureDirSync(SCREENSHOT_DIR);
|
||||
writeWsManifest();
|
||||
|
||||
const suffix = uniqueSuffix();
|
||||
const rootUri = `file://${WORKSPACE_DIR}`;
|
||||
const docUrl =
|
||||
`${BASE_URL}/documents/local-md%3ABlockHandleTest.md` +
|
||||
`?sourceKind=local_folder&rootUri=${encodeURIComponent(rootUri)}&treeView=filetree`;
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 900 },
|
||||
deviceScaleFactor: 2,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(TIMEOUT_MS);
|
||||
|
||||
const consoleErrors = [];
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() === "error") consoleErrors.push(msg.text());
|
||||
});
|
||||
|
||||
const result = {
|
||||
pass: false,
|
||||
steps: [],
|
||||
screenshots: [],
|
||||
consoleErrors,
|
||||
failures: [],
|
||||
};
|
||||
|
||||
const step = (name, extra = {}) => {
|
||||
const s = { step: name, status: "ok", ...extra };
|
||||
result.steps.push(s);
|
||||
return s;
|
||||
};
|
||||
|
||||
const fail = (name, message, extra = {}) => {
|
||||
const s = { step: name, status: "fail", message, ...extra };
|
||||
result.steps.push(s);
|
||||
result.failures.push({ step: name, status: "fail", ...extra });
|
||||
return s;
|
||||
};
|
||||
|
||||
try {
|
||||
await ensureAuthenticated(page, page.request);
|
||||
step("auth");
|
||||
|
||||
await page.goto(docUrl, { waitUntil: "domcontentloaded", timeout: TIMEOUT_MS });
|
||||
await page
|
||||
.locator('.tiptap.ProseMirror, [contenteditable="true"]')
|
||||
.first()
|
||||
.waitFor({ state: "visible", timeout: TIMEOUT_MS })
|
||||
.catch(() => {});
|
||||
await page.waitForTimeout(2000);
|
||||
step("navigate", { url: docUrl });
|
||||
|
||||
// ─────────────── Helper functions ───────────────
|
||||
|
||||
async function screenshot(name) {
|
||||
const filePath = path.join(SCREENSHOT_DIR, `${name}.png`);
|
||||
await page.screenshot({ path: filePath, fullPage: false });
|
||||
result.screenshots.push(filePath);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
async function getEditor() {
|
||||
const editor = page.locator('.tiptap.ProseMirror, [contenteditable="true"]').first();
|
||||
await editor.waitFor({ state: "visible", timeout: 5000 });
|
||||
return editor;
|
||||
}
|
||||
|
||||
async function getAllBlocks() {
|
||||
const editor = await getEditor();
|
||||
const children = editor.locator("> *").filter({
|
||||
has: page.locator(
|
||||
".ProseMirror-gapcursor, .ProseMirror-selection, .ProseMirror-cursor",
|
||||
),
|
||||
});
|
||||
// Fallback: just get top-level children
|
||||
const blocks = editor.locator("> *");
|
||||
const count = await blocks.count();
|
||||
return { editor, blocks, count };
|
||||
}
|
||||
|
||||
async function closeAnyOpenMenu() {
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
async function getBlockHandle() {
|
||||
// The block handle (grip icon) appears on hover near the left edge of a block
|
||||
// It's typically a div with a drag handle icon
|
||||
const handle = page.locator('[class*="block-handle"], [class*="drag-handle"], [data-block-handle]').first();
|
||||
if (await handle.isVisible().catch(() => false)) return handle;
|
||||
|
||||
// Try finding by the grip icon pattern
|
||||
const grip = page.locator('.editor-block-handle, .ProseMirror .block-handle, .ProseMirror [contenteditable] > div > div:first-child').first();
|
||||
return grip;
|
||||
}
|
||||
|
||||
async function findHandleNearBlock(blockEl, blockIndex) {
|
||||
// Hover over the block to trigger handle visibility
|
||||
const box = await blockEl.boundingBox();
|
||||
if (!box) return null;
|
||||
|
||||
// Hover at the left edge of the block to trigger the handle
|
||||
await page.mouse.move(box.x - 10, box.y + box.height / 2);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Look for visible handles
|
||||
const handles = page.locator('[class*="block-handle"], [class*="drag-handle"], .drag-handle, .block-handle-wrapper, [data-testid*="handle"]');
|
||||
const count = await handles.count();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const h = handles.nth(i);
|
||||
if (await h.isVisible().catch(() => false)) {
|
||||
return h;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function openMenuViaHover(blockEl) {
|
||||
const box = await blockEl.boundingBox();
|
||||
if (!box) throw new Error("Block has no bounding box");
|
||||
|
||||
// Move to left edge to trigger handle
|
||||
await page.mouse.move(box.x - 5, box.y + box.height / 2);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Find the visible handle
|
||||
const handles = page.locator('[class*="block-handle"], [class*="drag-handle"], .drag-handle, .block-handle-wrapper, [data-testid*="handle"]');
|
||||
const count = await handles.count();
|
||||
let handle = null;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const h = handles.nth(i);
|
||||
if (await h.isVisible().catch(() => false)) {
|
||||
handle = h;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!handle) {
|
||||
// Try clicking at the left edge area
|
||||
await page.mouse.click(box.x - 2, box.y + box.height / 2);
|
||||
await waitForMenuVisible();
|
||||
return;
|
||||
}
|
||||
|
||||
// Click the handle to open the menu
|
||||
const handleBox = await handle.boundingBox();
|
||||
if (handleBox) {
|
||||
await page.mouse.click(handleBox.x + handleBox.width / 2, handleBox.y + handleBox.height / 2);
|
||||
} else {
|
||||
await handle.click();
|
||||
}
|
||||
|
||||
await waitForMenuVisible();
|
||||
}
|
||||
|
||||
async function waitForMenuVisible() {
|
||||
// Wait for menu container to appear
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Try various menu selectors
|
||||
const menuSelectors = [
|
||||
'[class*="block-menu"]',
|
||||
'[class*="block-action"]',
|
||||
'[class*="slash-menu"]',
|
||||
'[class*="floating-menu"]',
|
||||
'[role="menu"]',
|
||||
'[data-testid*="menu"]',
|
||||
'.ProseMirror [contenteditable="false"] [class*="menu"]',
|
||||
];
|
||||
|
||||
for (const sel of menuSelectors) {
|
||||
const el = page.locator(sel).first();
|
||||
if (await el.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
return el;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─────────────── TEST 1: Block Handle Visibility ───────────────
|
||||
|
||||
{
|
||||
const { editor, blocks, count } = await getAllBlocks();
|
||||
step("editor-visible", { blockCount: count });
|
||||
|
||||
if (count === 0) {
|
||||
throw new Error("No blocks found in editor");
|
||||
}
|
||||
|
||||
// Test first paragraph block
|
||||
const firstBlock = blocks.nth(0);
|
||||
const firstBox = await firstBlock.boundingBox();
|
||||
|
||||
if (!firstBox) {
|
||||
throw new Error("First block has no bounding box");
|
||||
}
|
||||
|
||||
// Hover over left edge of first block
|
||||
await page.mouse.move(firstBox.x - 5, firstBox.y + firstBox.height / 2);
|
||||
await page.waitForTimeout(800);
|
||||
await screenshot("01-handle-visible");
|
||||
|
||||
// Check if any handle appeared
|
||||
const handleSelectors = [
|
||||
'[class*="block-handle"]',
|
||||
'[class*="drag-handle"]',
|
||||
'.drag-handle',
|
||||
'[data-testid*="handle"]',
|
||||
'svg[data-block-handle]',
|
||||
'[contenteditable="true"] ~ div',
|
||||
];
|
||||
|
||||
let handleFound = false;
|
||||
for (const sel of handleSelectors) {
|
||||
const els = page.locator(sel);
|
||||
const cnt = await els.count();
|
||||
for (let i = 0; i < cnt; i++) {
|
||||
if (await els.nth(i).isVisible().catch(() => false)) {
|
||||
handleFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (handleFound) break;
|
||||
}
|
||||
|
||||
if (handleFound) {
|
||||
step("handle-visible", { selector: "matched" });
|
||||
} else {
|
||||
fail("handle-visible", "Block handle did not appear on hover");
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────── TEST 2: Open Menu ───────────────
|
||||
|
||||
{
|
||||
const { editor, blocks, count } = await getAllBlocks();
|
||||
const firstBlock = blocks.nth(0);
|
||||
const firstBox = await firstBlock.boundingBox();
|
||||
|
||||
if (firstBox) {
|
||||
// Hover to show handle
|
||||
await page.mouse.move(firstBox.x - 5, firstBox.y + firstBox.height / 2);
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
// Find handle and click it
|
||||
const handles = page.locator('[class*="block-handle"], [class*="drag-handle"], .drag-handle, [data-testid*="handle"]');
|
||||
const hCount = await handles.count();
|
||||
let clicked = false;
|
||||
for (let i = 0; i < hCount; i++) {
|
||||
const h = handles.nth(i);
|
||||
if (await h.isVisible().catch(() => false)) {
|
||||
const hBox = await h.boundingBox();
|
||||
if (hBox) {
|
||||
await page.mouse.click(hBox.x + hBox.width / 2, hBox.y + hBox.height / 2);
|
||||
clicked = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!clicked) {
|
||||
// Fallback: click near the left edge
|
||||
await page.mouse.click(firstBox.x - 2, firstBox.y + firstBox.height / 2);
|
||||
}
|
||||
|
||||
await page.waitForTimeout(800);
|
||||
await screenshot("02-menu-opened");
|
||||
|
||||
// Find the menu
|
||||
const menuSelectors = [
|
||||
'[class*="block-menu"]',
|
||||
'[class*="block-action"]',
|
||||
'[class*="slash-menu"]',
|
||||
'[class*="floating-menu"]',
|
||||
'[role="menu"]',
|
||||
];
|
||||
|
||||
let menuEl = null;
|
||||
for (const sel of menuSelectors) {
|
||||
const el = page.locator(sel).first();
|
||||
if (await el.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
menuEl = el;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (menuEl) {
|
||||
const menuBox = await menuEl.boundingBox();
|
||||
step("menu-opened", {
|
||||
width: Math.round(menuBox?.width || 0),
|
||||
height: Math.round(menuBox?.height || 0),
|
||||
});
|
||||
|
||||
// Measure menu items
|
||||
const menuItems = menuEl.locator('[class*="menu-item"], [role="menuitem"], [class*="action-item"]');
|
||||
const itemCount = await menuItems.count();
|
||||
step("menu-item-count", { count: itemCount });
|
||||
|
||||
// Check menu items text
|
||||
const itemTexts = [];
|
||||
for (let i = 0; i < Math.min(itemCount, 20); i++) {
|
||||
const text = await menuItems.nth(i).innerText().catch(() => "");
|
||||
itemTexts.push(text.trim());
|
||||
}
|
||||
step("menu-items-text", { items: itemTexts });
|
||||
|
||||
await screenshot("03-menu-detail");
|
||||
} else {
|
||||
fail("menu-opened", "Menu did not appear after clicking handle");
|
||||
}
|
||||
|
||||
// Close menu
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────── TEST 3: Heading Block Handle ───────────────
|
||||
|
||||
{
|
||||
const { editor, blocks, count } = await getAllBlocks();
|
||||
|
||||
// Find the heading block (h2)
|
||||
let headingBlock = null;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const block = blocks.nth(i);
|
||||
const tagName = await block.evaluate((el) => el.tagName.toLowerCase());
|
||||
if (tagName === "h2" || tagName === "h1" || tagName === "h3") {
|
||||
headingBlock = block;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!headingBlock) {
|
||||
fail("heading-block", "Could not find heading block");
|
||||
} else {
|
||||
const hBox = await headingBlock.boundingBox();
|
||||
if (hBox) {
|
||||
// Hover over heading block
|
||||
await page.mouse.move(hBox.x - 5, hBox.y + hBox.height / 2);
|
||||
await page.waitForTimeout(600);
|
||||
await screenshot("04-heading-hover");
|
||||
|
||||
// Find and click handle
|
||||
const handles = page.locator('[class*="block-handle"], [class*="drag-handle"], .drag-handle, [data-testid*="handle"]');
|
||||
const hCount = await handles.count();
|
||||
let clicked = false;
|
||||
for (let i = 0; i < hCount; i++) {
|
||||
const h = handles.nth(i);
|
||||
if (await h.isVisible().catch(() => false)) {
|
||||
const hBox2 = await h.boundingBox();
|
||||
if (hBox2) {
|
||||
await page.mouse.click(hBox2.x + hBox2.width / 2, hBox2.y + hBox2.height / 2);
|
||||
clicked = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!clicked) {
|
||||
await page.mouse.click(hBox.x - 2, hBox.y + hBox.height / 2);
|
||||
}
|
||||
|
||||
await page.waitForTimeout(800);
|
||||
await screenshot("05-heading-menu");
|
||||
|
||||
// Find the menu
|
||||
const menuSelectors = [
|
||||
'[class*="block-menu"]',
|
||||
'[class*="block-action"]',
|
||||
'[class*="slash-menu"]',
|
||||
'[class*="floating-menu"]',
|
||||
'[role="menu"]',
|
||||
];
|
||||
|
||||
let menuEl = null;
|
||||
for (const sel of menuSelectors) {
|
||||
const el = page.locator(sel).first();
|
||||
if (await el.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
menuEl = el;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (menuEl) {
|
||||
const menuBox = await menuEl.boundingBox();
|
||||
step("heading-menu-opened", {
|
||||
width: Math.round(menuBox?.width || 0),
|
||||
height: Math.round(menuBox?.height || 0),
|
||||
});
|
||||
|
||||
// Check menu items for heading-specific items
|
||||
const menuItems = menuEl.locator('[class*="menu-item"], [role="menuitem"], [class*="action-item"]');
|
||||
const itemCount = await menuItems.count();
|
||||
step("heading-menu-item-count", { count: itemCount });
|
||||
|
||||
// Get menu items text
|
||||
const itemTexts = [];
|
||||
for (let i = 0; i < Math.min(itemCount, 20); i++) {
|
||||
const text = await menuItems.nth(i).innerText().catch(() => "");
|
||||
itemTexts.push(text.trim());
|
||||
}
|
||||
step("heading-menu-items-text", { items: itemTexts });
|
||||
|
||||
await screenshot("06-heading-menu-detail");
|
||||
} else {
|
||||
fail("heading-menu-opened", "Menu did not appear for heading block");
|
||||
}
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────── TEST 4: List Block Handle ───────────────
|
||||
|
||||
{
|
||||
const { editor, blocks, count } = await getAllBlocks();
|
||||
|
||||
// Find list item block
|
||||
let listBlock = null;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const block = blocks.nth(i);
|
||||
const tagName = await block.evaluate((el) => el.tagName.toLowerCase());
|
||||
if (tagName === "li" || tagName === "ul" || tagName === "ol") {
|
||||
listBlock = block;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!listBlock) {
|
||||
fail("list-block", "Could not find list block");
|
||||
} else {
|
||||
const lBox = await listBlock.boundingBox();
|
||||
if (lBox) {
|
||||
await page.mouse.move(lBox.x - 5, lBox.y + lBox.height / 2);
|
||||
await page.waitForTimeout(600);
|
||||
await screenshot("06-list-hover");
|
||||
|
||||
const handles = page.locator('[class*="block-handle"], [class*="drag-handle"], .drag-handle, [data-testid*="handle"]');
|
||||
const hCount = await handles.count();
|
||||
let clicked = false;
|
||||
for (let i = 0; i < hCount; i++) {
|
||||
const h = handles.nth(i);
|
||||
if (await h.isVisible().catch(() => false)) {
|
||||
const hBox = await h.boundingBox();
|
||||
if (hBox) {
|
||||
await page.mouse.click(hBox.x + hBox.width / 2, hBox.y + hBox.height / 2);
|
||||
clicked = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!clicked) {
|
||||
await page.mouse.click(lBox.x - 2, lBox.y + lBox.height / 2);
|
||||
}
|
||||
|
||||
await page.waitForTimeout(800);
|
||||
await screenshot("07-list-menu");
|
||||
|
||||
const menuSelectors = [
|
||||
'[class*="block-menu"]',
|
||||
'[class*="block-action"]',
|
||||
'[class*="slash-menu"]',
|
||||
'[class*="floating-menu"]',
|
||||
'[role="menu"]',
|
||||
];
|
||||
|
||||
let menuEl = null;
|
||||
for (const sel of menuSelectors) {
|
||||
const el = page.locator(sel).first();
|
||||
if (await el.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
menuEl = el;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (menuEl) {
|
||||
const menuBox = await menuEl.boundingBox();
|
||||
step("list-menu-opened", {
|
||||
width: Math.round(menuBox?.width || 0),
|
||||
height: Math.round(menuBox?.height || 0),
|
||||
});
|
||||
|
||||
const menuItems = menuEl.locator('[class*="menu-item"], [role="menuitem"], [class*="action-item"]');
|
||||
const itemCount = await menuItems.count();
|
||||
step("list-menu-item-count", { count: itemCount });
|
||||
|
||||
await screenshot("08-list-menu-detail");
|
||||
} else {
|
||||
fail("list-menu-opened", "Menu did not appear for list block");
|
||||
}
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────── TEST 5: Code Block Handle ───────────────
|
||||
|
||||
{
|
||||
const { editor, blocks, count } = await getAllBlocks();
|
||||
|
||||
let codeBlock = null;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const block = blocks.nth(i);
|
||||
const tagName = await block.evaluate((el) => el.tagName.toLowerCase());
|
||||
if (tagName === "pre" || tagName === "code") {
|
||||
codeBlock = block;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!codeBlock) {
|
||||
fail("code-block", "Could not find code block");
|
||||
} else {
|
||||
const cBox = await codeBlock.boundingBox();
|
||||
if (cBox) {
|
||||
await page.mouse.move(cBox.x - 5, cBox.y + cBox.height / 2);
|
||||
await page.waitForTimeout(600);
|
||||
await screenshot("09-code-hover");
|
||||
|
||||
const handles = page.locator('[class*="block-handle"], [class*="drag-handle"], .drag-handle, [data-testid*="handle"]');
|
||||
const hCount = await handles.count();
|
||||
let clicked = false;
|
||||
for (let i = 0; i < hCount; i++) {
|
||||
const h = handles.nth(i);
|
||||
if (await h.isVisible().catch(() => false)) {
|
||||
const hBox = await h.boundingBox();
|
||||
if (hBox) {
|
||||
await page.mouse.click(hBox.x + hBox.width / 2, hBox.y + hBox.height / 2);
|
||||
clicked = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!clicked) {
|
||||
await page.mouse.click(cBox.x - 2, cBox.y + cBox.height / 2);
|
||||
}
|
||||
|
||||
await page.waitForTimeout(800);
|
||||
await screenshot("10-code-menu");
|
||||
|
||||
const menuSelectors = [
|
||||
'[class*="block-menu"]',
|
||||
'[class*="block-action"]',
|
||||
'[class*="slash-menu"]',
|
||||
'[class*="floating-menu"]',
|
||||
'[role="menu"]',
|
||||
];
|
||||
|
||||
let menuEl = null;
|
||||
for (const sel of menuSelectors) {
|
||||
const el = page.locator(sel).first();
|
||||
if (await el.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
menuEl = el;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (menuEl) {
|
||||
const menuBox = await menuEl.boundingBox();
|
||||
step("code-menu-opened", {
|
||||
width: Math.round(menuBox?.width || 0),
|
||||
height: Math.round(menuBox?.height || 0),
|
||||
});
|
||||
|
||||
const menuItems = menuEl.locator('[class*="menu-item"], [role="menuitem"], [class*="action-item"]');
|
||||
const itemCount = await menuItems.count();
|
||||
step("code-menu-item-count", { count: itemCount });
|
||||
|
||||
await screenshot("11-code-menu-detail");
|
||||
} else {
|
||||
fail("code-menu-opened", "Menu did not appear for code block");
|
||||
}
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.pass = result.failures.length === 0;
|
||||
} catch (err) {
|
||||
result.steps.push({ step: "exception", status: "fail", message: String(err) });
|
||||
await screenshot("99-error").catch(() => {});
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
// Cleanup temp workspace
|
||||
try {
|
||||
const fs = require("fs");
|
||||
fs.rmSync(WORKSPACE_DIR, { recursive: true, force: true });
|
||||
} catch {}
|
||||
|
||||
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
||||
process.exit(result.pass ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -205,6 +205,13 @@ check(
|
||||
"Found mnote-account-ai-management element with AI 管理 text"
|
||||
);
|
||||
|
||||
check(
|
||||
"Account menu no longer exposes retired 授权管理 entry",
|
||||
!sidebarWorkspaceJs.includes('data-testid="mnote-account-access-policy"') &&
|
||||
!sidebarWorkspaceJs.includes("openAdminAccessPolicyDialog"),
|
||||
"Retired access-policy modal entry is absent from account menu runtime"
|
||||
);
|
||||
|
||||
check(
|
||||
"AI management entry navigates to /admin/ai or /user/ai",
|
||||
sidebarWorkspaceJs.includes("'/admin/ai'") &&
|
||||
@@ -294,9 +301,9 @@ check(
|
||||
);
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Section 6: OpenHub / Pi Lab 独立入口未删除
|
||||
// Section 6: Pi Rust 主入口与 OpenHub 兼容边界
|
||||
// --------------------------------------------------------------------------
|
||||
console.log("\n-- 6. OpenHub / Pi Lab 独立入口未删除 --");
|
||||
console.log("\n-- 6. Pi Rust 主入口与 OpenHub 兼容边界 --");
|
||||
|
||||
check(
|
||||
"OpenHub agent route /page-ai/openhub/ai exists",
|
||||
@@ -337,6 +344,25 @@ check(
|
||||
"POST start, send, abort"
|
||||
);
|
||||
|
||||
check(
|
||||
"AI management service panel treats Pi Rust as default Page AI runtime",
|
||||
aiAdminRs.includes("Pi Rust 是默认 Page AI runtime") &&
|
||||
aiAdminRs.includes("Pi Rust Page AI") &&
|
||||
aiAdminRs.includes("runtimeImplementation") &&
|
||||
aiAdminRs.includes("runtimeBinary") &&
|
||||
aiAdminRs.includes("runtimeAvailable") &&
|
||||
aiAdminRs.includes("runtimeError"),
|
||||
"Pi Rust service panel exposes runtime implementation, binary, availability, and error"
|
||||
);
|
||||
|
||||
check(
|
||||
"OpenHub is described as compatibility/admin boundary, not default chat",
|
||||
aiAdminRs.includes("迁移期兼容 / admin 边界") &&
|
||||
aiAdminRs.includes("不再作为默认 Page AI chat") &&
|
||||
!aiAdminRs.includes("OpenHub 仍是默认 Page AI"),
|
||||
"OpenHub remains only as migration compatibility boundary"
|
||||
);
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Section 7: Admin 原子策略 GET/PUT
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -432,9 +458,8 @@ check(
|
||||
pageAiPiRs.includes("PiLabToolFacade") &&
|
||||
pageAiPiRs.includes("omniroute_api_key") &&
|
||||
pageAiPiRs.includes("env_trimmed") &&
|
||||
!pageAiPiRs.includes('"sk-') &&
|
||||
!pageAiPiRs.includes('"secret') &&
|
||||
!pageAiPiRs.includes('"API_KEY'),
|
||||
pageAiPiRs.includes("MNOTE_PAGE_AI_PI_OMNIROUTE_API_KEY") &&
|
||||
!pageAiPiRs.includes('"sk-'),
|
||||
"Pi Lab uses env-based key via omniroute_api_key() and env_trimmed"
|
||||
);
|
||||
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
// Pi Lab API endpoint smoke
|
||||
// 验证 Pi Lab API 路由在 mnote-web 开发环境下的响应
|
||||
// 需要 mnote-web 已在运行(npm run desktop:hot 或独立启动)
|
||||
// 检查:新端点 start/send/abort/events、SSE、disabled builtin tools、receipt、no polling
|
||||
// 检查:新端点 start/send/abort/events、SSE、permission-system managed builtin tools、receipt、no polling
|
||||
|
||||
const BASE = process.env.MNOTE_PI_LAB_BASE || 'http://127.0.0.1:3000';
|
||||
const AUTH_COOKIE = process.env.MNOTE_PI_LAB_AUTH || '';
|
||||
const ACTOR_ID = process.env.MNOTE_E2E_ACTOR_ID || 'mnote-e2e';
|
||||
|
||||
async function check(description, fn) {
|
||||
try {
|
||||
@@ -24,7 +25,12 @@ async function check(description, fn) {
|
||||
}
|
||||
|
||||
async function fetchJson(url, options = {}) {
|
||||
const headers = { 'Content-Type': 'application/json', ...options.headers };
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'x-mnote-actor-id': ACTOR_ID,
|
||||
'x-mnote-actor-type': 'user',
|
||||
...options.headers,
|
||||
};
|
||||
if (AUTH_COOKIE && AUTH_COOKIE.toLowerCase().startsWith('bearer ')) headers.Authorization = AUTH_COOKIE;
|
||||
else if (AUTH_COOKIE) headers.Cookie = AUTH_COOKIE;
|
||||
const res = await fetch(url, { ...options, headers });
|
||||
@@ -59,12 +65,12 @@ async function main() {
|
||||
return { passed: false, reason: 'missing managedPiSessionDirPolicy' };
|
||||
}));
|
||||
|
||||
// 3. Status response has disabledPiBuiltinTools when enabled
|
||||
results.push(await check('GET /api/page-ai/pi/status has disabledPiBuiltinTools', async () => {
|
||||
// 3. Status response has managedPiBuiltinTools when enabled
|
||||
results.push(await check('GET /api/page-ai/pi/status has managedPiBuiltinTools', async () => {
|
||||
const { body } = await fetchJson(`${BASE}/api/page-ai/pi/status`);
|
||||
if (body.enabled === false) return { passed: true }; // skip when disabled
|
||||
if (body.disabledPiBuiltinTools) return { passed: true };
|
||||
return { passed: false, reason: 'missing disabledPiBuiltinTools' };
|
||||
if (Array.isArray(body.managedPiBuiltinTools)) return { passed: true };
|
||||
return { passed: false, reason: 'missing managedPiBuiltinTools' };
|
||||
}));
|
||||
|
||||
// 4. Start endpoint exists and returns proper schema (may 404 if disabled)
|
||||
@@ -102,7 +108,11 @@ async function main() {
|
||||
// 7. Events SSE endpoint returns proper content type
|
||||
results.push(await check('GET /api/page-ai/pi/events returns SSE stream (disabled may 404)', async () => {
|
||||
const res = await fetch(`${BASE}/api/page-ai/pi/events`, {
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
'x-mnote-actor-id': ACTOR_ID,
|
||||
'x-mnote-actor-type': 'user',
|
||||
},
|
||||
});
|
||||
if (res.status === 404 || res.status === 401 || res.status === 403) return { passed: true };
|
||||
const ct = res.headers.get('Content-Type') || '';
|
||||
|
||||
@@ -46,6 +46,12 @@ async function quickLoginIfNeeded(page) {
|
||||
]);
|
||||
}
|
||||
|
||||
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";
|
||||
@@ -59,6 +65,9 @@ async function main() {
|
||||
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();
|
||||
|
||||
@@ -114,10 +123,6 @@ async function main() {
|
||||
assert(drawerEvidence.toolCount >= 5, `expected MNote tool rail, got ${drawerEvidence.toolCount}`);
|
||||
console.log(" 4. Independent drawer, context strip and default model verified");
|
||||
|
||||
const startButton = page.locator("[data-page-ai-pi-lab-btn-start]");
|
||||
if (await startButton.isVisible().catch(() => false)) {
|
||||
await startButton.click();
|
||||
}
|
||||
await page.waitForFunction(() => document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent?.includes("ready"), null, {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
@@ -126,6 +131,7 @@ async function main() {
|
||||
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);
|
||||
@@ -133,29 +139,87 @@ async function main() {
|
||||
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()}`);
|
||||
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 started and composer is interactive");
|
||||
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: {
|
||||
path: path.join(browserRoot, pagePath),
|
||||
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,
|
||||
@@ -163,38 +227,35 @@ async function main() {
|
||||
if (!ragResp.ok()) {
|
||||
console.warn(` ! LightRAG direct tool call skipped in browser smoke: ${ragResp.status()}`);
|
||||
}
|
||||
const receiptResp = await page.request.post(`${BASE}/api/page-ai/pi/tool-call`, {
|
||||
data: {
|
||||
sessionId,
|
||||
toolName: "mnote.tool_receipt.write",
|
||||
params: {
|
||||
citations: [{ title: "LightRAG mock citation", source: "lightrag-mock" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
assert(receiptResp.ok(), `receipt tool call should return HTTP OK, got ${receiptResp.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("diff") && text.includes("mnote.tool_receipt.write");
|
||||
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 });
|
||||
|
||||
await page.locator("[data-page-ai-pi-lab-input]").fill("Pi Lab browser smoke prompt");
|
||||
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(() => {
|
||||
const root = document.querySelector('[data-page-ai-pi-lab="drawer"]');
|
||||
const text = root?.textContent || "";
|
||||
return text.includes("[Pi Lab mock] prompt accepted") && text.includes("LightRAG mock citation") && text.includes("patch");
|
||||
}, null, { timeout: UI_TIMEOUT_MS });
|
||||
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) });
|
||||
await page.locator("[data-page-ai-pi-lab-btn-abort]").click();
|
||||
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(() => {
|
||||
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 {
|
||||
hasPromptReply: text.includes("[Pi Lab mock] prompt accepted"),
|
||||
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"),
|
||||
@@ -202,15 +263,16 @@ async function main() {
|
||||
hasDiff: text.includes("patch") || text.includes("diff"),
|
||||
changedFiles: document.querySelector("[data-page-ai-pi-lab-changed-files]")?.textContent || "",
|
||||
};
|
||||
});
|
||||
assert(runtimeEvidence.hasPromptReply, "Pi Lab should show streamed mock assistant reply");
|
||||
}, 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. Stream, abort, deny, citation, receipt and diff evidence visible");
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
#!/usr/bin/env node
|
||||
"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_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 || "freefirst";
|
||||
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) {
|
||||
await page.goto(`${BASE}/auth`, { waitUntil: "commit", timeout: TIMEOUT });
|
||||
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
|
||||
await quickLoginButton.waitFor({ state: "visible", timeout: TIMEOUT });
|
||||
await Promise.all([
|
||||
page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: TIMEOUT }).catch(() => null),
|
||||
quickLoginButton.click(),
|
||||
]);
|
||||
}
|
||||
|
||||
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(page) {
|
||||
mkdirp(ROOT_PATH);
|
||||
fs.writeFileSync(path.join(ROOT_PATH, PAGE_PATH), "# Pi full access question smoke\n", "utf8");
|
||||
const grantResponse = await page.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(page, "/api/ai-admin/settings", {
|
||||
method: "PUT",
|
||||
data: {
|
||||
...policyForFullAccessAskUser(),
|
||||
quota: { daily: 200 },
|
||||
},
|
||||
});
|
||||
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(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();
|
||||
@@ -0,0 +1,291 @@
|
||||
#!/usr/bin/env node
|
||||
"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_UI_BASE_URL || "http://127.0.0.1:3000";
|
||||
const STAMP = Date.now();
|
||||
const OUT = process.env.MNOTE_PI_FULL_ACCESS_BUILTIN_OUT || path.join(os.tmpdir(), `mnote-pi-full-access-builtin-disabled-${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_BUILTIN_WORKSPACE_ID || "local-ws:mnote-e2e:my-space";
|
||||
const ROOT_PATH = process.env.MNOTE_PI_FULL_ACCESS_BUILTIN_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
|
||||
const ROOT_URI = process.env.MNOTE_PI_FULL_ACCESS_BUILTIN_ROOT_URI || `file://${ROOT_PATH}`;
|
||||
const MODEL_PROVIDER = process.env.MNOTE_PI_FULL_ACCESS_BUILTIN_MODEL_PROVIDER || "omniroute";
|
||||
const MODEL_ID = process.env.MNOTE_PI_FULL_ACCESS_BUILTIN_MODEL_ID || "freefirst";
|
||||
const MARKER = `PI_FULL_ACCESS_BUILTIN_DISABLED_OK_${STAMP}`;
|
||||
const PAGE_PATH = `pi-full-access-builtin-disabled-${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) {
|
||||
await page.goto(`${BASE}/auth`, { waitUntil: "commit", timeout: TIMEOUT });
|
||||
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
|
||||
await quickLoginButton.waitFor({ state: "visible", timeout: TIMEOUT });
|
||||
await Promise.all([
|
||||
page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: TIMEOUT }).catch(() => null),
|
||||
quickLoginButton.click(),
|
||||
]);
|
||||
}
|
||||
|
||||
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 policyForFullAccessBuiltinDisabled() {
|
||||
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"]),
|
||||
"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 seedWorkspace(page) {
|
||||
mkdirp(ROOT_PATH);
|
||||
fs.writeFileSync(path.join(ROOT_PATH, PAGE_PATH), "# Pi full access builtin disabled smoke\n", "utf8");
|
||||
const grantResponse = await page.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(page, "/api/ai-admin/settings", {
|
||||
method: "PUT",
|
||||
data: {
|
||||
...policyForFullAccessBuiltinDisabled(),
|
||||
quota: { daily: 200 },
|
||||
},
|
||||
});
|
||||
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-builtin-disabled-${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 builtin disabled 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 main() {
|
||||
mkdirp(OUT);
|
||||
const browser = await chromium.launch({
|
||||
headless: process.env.MNOTE_PI_FULL_ACCESS_BUILTIN_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,
|
||||
marker: MARKER,
|
||||
rootUri: ROOT_URI,
|
||||
screenshots: {},
|
||||
checks: {},
|
||||
consoleMessages,
|
||||
};
|
||||
|
||||
try {
|
||||
await quickLogin(page);
|
||||
await seedWorkspace(page);
|
||||
await abortExistingSession(page);
|
||||
const session = await startRealPi(page);
|
||||
result.session = {
|
||||
sessionId: session.sessionId,
|
||||
runtimePid: session.runtimePid,
|
||||
piSessionDir: session.piSessionDir,
|
||||
runtimePolicySnapshot: session.runtimePolicySnapshot,
|
||||
};
|
||||
const enabledSources = session.runtimePolicySnapshot.enabledPiExtensionSources || [];
|
||||
const permissionConfigPath = path.join(session.piSessionDir, "config", "extensions", "pi-permission-system", "config.json");
|
||||
result.permissionConfigPath = permissionConfigPath;
|
||||
result.checks.permissionSystemConfigAbsent = !fs.existsSync(permissionConfigPath);
|
||||
result.checks.noLegacyNpmAskUser = !enabledSources.includes("npm:pi-ask-user");
|
||||
result.checks.noExternalPermissionSystem = !enabledSources.includes("npm:@gotgenes/pi-permission-system");
|
||||
result.checks.officialPermissionGateConfigured = enabledSources.includes("pi-rust-official:permission-gate");
|
||||
result.checks.managedBuiltinToolsDisabledAtStart = (session.runtimePolicySnapshot.managedBuiltinTools || []).length === 0;
|
||||
assert.equal(result.checks.permissionSystemConfigAbsent, true, "full_access smoke should not generate legacy pi-permission-system config");
|
||||
assert.equal(result.checks.noLegacyNpmAskUser, true, "full_access smoke should not use unavailable npm:pi-ask-user");
|
||||
assert.equal(result.checks.noExternalPermissionSystem, true, "full_access smoke should not load incompatible pi-permission-system");
|
||||
assert.equal(result.checks.officialPermissionGateConfigured, true, "policy should include Pi Rust official permission-gate");
|
||||
|
||||
await openPiUi(page);
|
||||
await page.screenshot({ path: path.join(OUT, "01-full-access-started.png"), fullPage: false });
|
||||
result.screenshots.started = path.join(OUT, "01-full-access-started.png");
|
||||
|
||||
const prompt = [
|
||||
"请调用 mnote_allowed_roots_describe 工具,读取 MNote 返回的 allowedRoots、deniedPiBuiltinTools、managedPiBuiltinTools、permissionProvider。",
|
||||
"不要调用 bash/read/write/edit/hashline_edit/grep/find/ls 这些 Pi 内置工具。",
|
||||
"用一句话说明:full_access 下 MNote 当前仍默认禁用 Pi Rust 内置文件/命令工具,文件权限由 MNote bridge 管控。",
|
||||
`最终单独输出一行:${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();
|
||||
while (Date.now() - startedAt < TIMEOUT) {
|
||||
const permissionVisible = await page.locator("text=Permission Required").count();
|
||||
assert.equal(permissionVisible, 0, "official permission-gate smoke should not ask legacy Permission Required dialog");
|
||||
if (await markerLocator.isVisible({ timeout: 500 }).catch(() => false)) break;
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
await markerLocator.waitFor({ state: "visible", timeout: 1000 });
|
||||
await page.screenshot({ path: path.join(OUT, "02-full-access-builtin-disabled-answer.png"), fullPage: false });
|
||||
result.screenshots.answer = path.join(OUT, "02-full-access-builtin-disabled-answer.png");
|
||||
|
||||
const sessionJsonl = readSessionJsonl(session.piSessionDir);
|
||||
result.session.sessionFile = sessionJsonl.sessionFile;
|
||||
result.answerText = ((await markerLocator.textContent({ timeout: TIMEOUT })) || "").trim();
|
||||
result.checks.allowedRootsToolCalled = /mnote_allowed_roots_describe/.test(sessionJsonl.raw);
|
||||
result.checks.deniedBuiltinsRecorded = /deniedPiBuiltinTools/.test(sessionJsonl.raw) && /hashline_edit/.test(sessionJsonl.raw);
|
||||
result.checks.managedBuiltinsEmptyRecorded = /managedPiBuiltinTools/.test(sessionJsonl.raw);
|
||||
result.checks.noRawBuiltinCalled = !/"name":"(bash|read|write|edit|hashline_edit|grep|find|ls)"/.test(sessionJsonl.raw);
|
||||
result.checks.noPermissionRequiredPrompt = await page.locator("text=Permission Required").count() === 0;
|
||||
assert(result.checks.allowedRootsToolCalled, "Pi session JSONL should record mnote_allowed_roots_describe call");
|
||||
assert(result.checks.deniedBuiltinsRecorded, "Pi session JSONL should include deniedPiBuiltinTools");
|
||||
assert(result.checks.managedBuiltinsEmptyRecorded, "Pi session JSONL should include managedPiBuiltinTools");
|
||||
assert(result.checks.noRawBuiltinCalled, "Pi raw builtin tools should remain disabled by default");
|
||||
assert(result.checks.noPermissionRequiredPrompt, "official permission-gate smoke should not show legacy 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();
|
||||
@@ -0,0 +1,521 @@
|
||||
#!/usr/bin/env node
|
||||
"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 {
|
||||
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_INPUT_CONTROLS_OUT || path.join(os.tmpdir(), `mnote-pi-input-controls-${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_INPUT_WORKSPACE_ID || `local-ws:${ACTOR_ID}:pi-input-controls`;
|
||||
const ROOT_PATH = process.env.MNOTE_PI_INPUT_ROOT_PATH || path.join(OUT, "workspace");
|
||||
const ROOT_URI = process.env.MNOTE_PI_INPUT_ROOT_URI || `file://${ROOT_PATH}`;
|
||||
const PAGE_DIR = `pi-input-controls-${STAMP}`;
|
||||
const PAGE_PATH = `${PAGE_DIR}/pi-input-controls-${STAMP}.md`;
|
||||
const ROOT_PAGE_PATH = `pi-input-controls-root-${STAMP}.md`;
|
||||
const MODEL_PROVIDER = process.env.MNOTE_PI_INPUT_MODEL_PROVIDER || "omniroute";
|
||||
const MODEL_ID = process.env.MNOTE_PI_INPUT_MODEL_ID || "freefirst";
|
||||
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) {
|
||||
await page.goto(`${BASE}/auth`, { waitUntil: "commit", timeout: TIMEOUT });
|
||||
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
|
||||
await quickLoginButton.waitFor({ state: "visible", timeout: TIMEOUT });
|
||||
await Promise.all([
|
||||
page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: TIMEOUT }).catch(() => null),
|
||||
quickLoginButton.click(),
|
||||
]);
|
||||
}
|
||||
|
||||
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 policyForInputControls() {
|
||||
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: {},
|
||||
};
|
||||
}
|
||||
|
||||
async function seedWorkspace(page) {
|
||||
mkdirp(ROOT_PATH);
|
||||
mkdirp(path.dirname(path.join(ROOT_PATH, PAGE_PATH)));
|
||||
fs.writeFileSync(
|
||||
path.join(ROOT_PATH, PAGE_PATH),
|
||||
[
|
||||
"# Pi input controls smoke",
|
||||
"",
|
||||
"CURRENT_PAGE_QUICK_CONTENT_OK",
|
||||
"This file proves current-page quick read used a real allowed root.",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(ROOT_PATH, ROOT_PAGE_PATH),
|
||||
[
|
||||
"# Pi input controls root page",
|
||||
"",
|
||||
"ROOT_PAGE_FOLDER_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 input controls smoke",
|
||||
rootPath: ROOT_PATH,
|
||||
rootUri: ROOT_URI,
|
||||
permission: "write",
|
||||
capabilities: ["ai", "read", "write"],
|
||||
timeoutMs: TIMEOUT,
|
||||
});
|
||||
await seedAiPolicy(page.request, BASE, {
|
||||
id: `pi-input-controls-policy-${ACTOR_ID}-${WORKSPACE_ID}`,
|
||||
userId: ACTOR_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
allowedRootsJson: [{ rootUri: ROOT_URI, rootPath: ROOT_PATH, permission: "write" }],
|
||||
modelPolicyJson: policyForInputControls(),
|
||||
quotaJson: { daily: 200 },
|
||||
timeoutMs: TIMEOUT,
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
await requestJson(page, "/api/page-ai/pi/abort", { method: "POST", data: { sessionId } }).catch(() => null);
|
||||
}
|
||||
|
||||
async function startSession(page, sessionId, thinkingLevel, pagePath = PAGE_PATH) {
|
||||
const start = await requestJson(page, "/api/page-ai/pi/start", {
|
||||
method: "POST",
|
||||
data: {
|
||||
sessionId,
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
pagePath,
|
||||
pageTitle: "Pi input controls smoke",
|
||||
modelProvider: MODEL_PROVIDER,
|
||||
modelId: MODEL_ID,
|
||||
thinkingLevel,
|
||||
},
|
||||
});
|
||||
assert.equal(start.ok, true, "Pi start ok should be true");
|
||||
assert.equal(start.session.thinkingLevel, thinkingLevel, "start should persist requested thinkingLevel");
|
||||
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(() => !!window.__mnotePiLabTest, null, { timeout: TIMEOUT });
|
||||
}
|
||||
|
||||
async function emit(page, payload) {
|
||||
await page.evaluate((eventPayload) => {
|
||||
window.__mnotePiLabTest.emitRpcEvent(eventPayload);
|
||||
}, payload);
|
||||
}
|
||||
|
||||
async function composerValue(page) {
|
||||
return page.locator("[data-page-ai-pi-lab-input]").inputValue();
|
||||
}
|
||||
|
||||
async function clickQuickAndAssertComposer(page, kind, pattern, label) {
|
||||
await page.locator(`[data-page-ai-pi-lab-quick="${kind}"]`).click();
|
||||
await page.waitForFunction(
|
||||
({ selector, source }) => new RegExp(source).test(document.querySelector(selector)?.value || ""),
|
||||
{ selector: "[data-page-ai-pi-lab-input]", source: pattern.source },
|
||||
{ timeout: TIMEOUT },
|
||||
);
|
||||
const value = await composerValue(page);
|
||||
assert(pattern.test(value), `${label} did not update composer: ${value.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
async function clickQuickAndAssertActive(page, kind, expectedActive, label) {
|
||||
const before = await composerValue(page);
|
||||
const requestSeen = page.waitForRequest(
|
||||
(request) => request.url().includes("/api/page-ai/pi/tool-call") || request.url().includes("/api/page-ai/pi/send"),
|
||||
{ timeout: 900 },
|
||||
).then((request) => request.url()).catch(() => null);
|
||||
await page.locator(`[data-page-ai-pi-lab-quick="${kind}"]`).click();
|
||||
await page.waitForFunction(
|
||||
({ kind: targetKind, expected }) => document.querySelector(`[data-page-ai-pi-lab-quick="${targetKind}"]`)?.getAttribute("data-active") === String(expected),
|
||||
{ kind, expected: expectedActive },
|
||||
{ timeout: TIMEOUT },
|
||||
);
|
||||
const after = await composerValue(page);
|
||||
const unexpectedRequest = await requestSeen;
|
||||
assert.equal(after, before, `${label} should toggle context state without changing composer`);
|
||||
assert.equal(unexpectedRequest, null, `${label} should not send or call a tool on click: ${unexpectedRequest}`);
|
||||
}
|
||||
|
||||
async function clickMenuContextAndAssertActive(page, action, expectedActive, label) {
|
||||
const before = await composerValue(page);
|
||||
const requestSeen = page.waitForRequest(
|
||||
(request) => request.url().includes("/api/page-ai/pi/tool-call") || request.url().includes("/api/page-ai/pi/send"),
|
||||
{ timeout: 900 },
|
||||
).then((request) => request.url()).catch(() => null);
|
||||
await openActionMenu(page);
|
||||
await page.locator(`[data-page-ai-pi-lab-menu-action="${action}"]`).click();
|
||||
await page.waitForFunction(
|
||||
({ action: targetAction, expected }) => document.querySelector(`[data-page-ai-pi-lab-menu-action="${targetAction}"]`)?.getAttribute("data-active") === String(expected),
|
||||
{ action, expected: expectedActive },
|
||||
{ timeout: TIMEOUT },
|
||||
);
|
||||
const after = await composerValue(page);
|
||||
const unexpectedRequest = await requestSeen;
|
||||
assert.equal(after, before, `${label} should toggle context state without changing composer`);
|
||||
assert.equal(unexpectedRequest, null, `${label} should not send or call a tool on click: ${unexpectedRequest}`);
|
||||
}
|
||||
|
||||
async function quickActive(page, kind) {
|
||||
return page.locator(`[data-page-ai-pi-lab-quick="${kind}"]`).getAttribute("data-active");
|
||||
}
|
||||
|
||||
async function openActionMenu(page) {
|
||||
const toggle = page.locator("[data-page-ai-pi-lab-action-menu-toggle]");
|
||||
await toggle.click();
|
||||
const menu = page.locator("[data-page-ai-pi-lab-action-menu]");
|
||||
await menu.waitFor({ state: "visible", timeout: TIMEOUT });
|
||||
return menu;
|
||||
}
|
||||
|
||||
async function sendViaMenuAndCapture(page, action, text) {
|
||||
await page.locator("[data-page-ai-pi-lab-input]").fill(text);
|
||||
await emit(page, { type: "message_update", assistantMessageEvent: { type: "text_start" } });
|
||||
const requestPromise = page.waitForRequest(
|
||||
(request) => request.url().includes("/api/page-ai/pi/send") && request.method() === "POST",
|
||||
{ timeout: TIMEOUT },
|
||||
);
|
||||
await openActionMenu(page);
|
||||
await page.locator(`[data-page-ai-pi-lab-menu-action="${action}"]`).click();
|
||||
const request = await requestPromise;
|
||||
return request.postDataJSON();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
mkdirp(OUT);
|
||||
const browser = await chromium.launch({
|
||||
headless: process.env.MNOTE_PI_INPUT_CONTROLS_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 = {
|
||||
base: BASE,
|
||||
outputDir: OUT,
|
||||
rootUri: ROOT_URI,
|
||||
pagePath: PAGE_PATH,
|
||||
screenshots: {},
|
||||
checks: {},
|
||||
consoleMessages,
|
||||
};
|
||||
|
||||
try {
|
||||
await quickLogin(page);
|
||||
await seedWorkspace(page);
|
||||
await abortExistingSession(page);
|
||||
const session = await startSession(page, `pi-input-controls-${STAMP}`, "high");
|
||||
result.session = {
|
||||
sessionId: session.sessionId,
|
||||
runtimeMode: session.runtimeMode,
|
||||
thinkingLevel: session.thinkingLevel,
|
||||
};
|
||||
await openPiUi(page);
|
||||
|
||||
result.checks.thinkingInitialValue = await page.locator("[data-page-ai-pi-lab-thinking]").inputValue();
|
||||
assert.equal(result.checks.thinkingInitialValue, "high", "thinking selector should reflect current session");
|
||||
result.checks.permissionLabel = (await page.locator("[data-page-ai-pi-lab-permission-label]").textContent() || "").trim();
|
||||
assert(/确认|审批|受限|自动/.test(result.checks.permissionLabel), `permission label missing: ${result.checks.permissionLabel}`);
|
||||
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.permissionMenuVisible = true;
|
||||
result.checks.permissionModes = await page.locator("[data-page-ai-pi-lab-permission-mode]").evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-page-ai-pi-lab-permission-mode")));
|
||||
assert.deepEqual(result.checks.permissionModes, ["confirm", "auto_edit", "plan", "full_access"]);
|
||||
const modeStartRequestPromise = page.waitForRequest(
|
||||
(request) => request.url().includes("/api/page-ai/pi/start") && request.method() === "POST",
|
||||
{ timeout: TIMEOUT },
|
||||
);
|
||||
await page.locator('[data-page-ai-pi-lab-permission-mode="auto_edit"]').click();
|
||||
const modeStartBody = (await modeStartRequestPromise).postDataJSON();
|
||||
result.checks.permissionAutoEditStartMode = modeStartBody.permissionMode;
|
||||
result.checks.permissionAutoEditStartSessionId = modeStartBody.sessionId;
|
||||
assert.equal(modeStartBody.permissionMode, "auto_edit", "mode switch should apply through /api/page-ai/pi/start");
|
||||
assert.equal(modeStartBody.sessionId, session.sessionId, "mode switch should keep the current Pi session");
|
||||
await page.waitForFunction(() => /自动编辑/.test(document.querySelector("[data-page-ai-pi-lab-permission-label]")?.textContent || ""), null, { timeout: TIMEOUT });
|
||||
result.checks.permissionAutoEditSelected = true;
|
||||
result.checks.noRestartRuntimeHint = await page.locator('[title*="重启 Pi runtime"]').count() === 0;
|
||||
assert.equal(result.checks.noRestartRuntimeHint, true, "Pi controls should not ask the user to restart Pi runtime");
|
||||
await page.screenshot({ path: path.join(OUT, "00-permission-mode-auto-applied.png"), fullPage: false });
|
||||
result.screenshots.permissionModeAutoApplied = path.join(OUT, "00-permission-mode-auto-applied.png");
|
||||
|
||||
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 });
|
||||
const fullAccessStartRequestPromise = page.waitForRequest(
|
||||
(request) => request.url().includes("/api/page-ai/pi/start") && request.method() === "POST",
|
||||
{ timeout: TIMEOUT },
|
||||
);
|
||||
await page.locator('[data-page-ai-pi-lab-permission-mode="full_access"]').click();
|
||||
const fullAccessStartBody = (await fullAccessStartRequestPromise).postDataJSON();
|
||||
result.checks.permissionFullAccessStartMode = fullAccessStartBody.permissionMode;
|
||||
result.checks.permissionFullAccessStartSessionId = fullAccessStartBody.sessionId;
|
||||
assert.equal(fullAccessStartBody.permissionMode, "full_access", "full access mode should apply through /api/page-ai/pi/start");
|
||||
assert.equal(fullAccessStartBody.sessionId, session.sessionId, "full access switch should keep the current Pi session");
|
||||
await page.waitForFunction(() => /完全访问/.test(document.querySelector("[data-page-ai-pi-lab-permission-label]")?.textContent || ""), null, { timeout: TIMEOUT });
|
||||
const currentPageTool = await requestJson(page, "/api/page-ai/pi/tool-call", {
|
||||
method: "POST",
|
||||
data: {
|
||||
sessionId: session.sessionId,
|
||||
toolName: "mnote.current_page.read",
|
||||
params: { rootUri: ROOT_URI, pagePath: PAGE_PATH },
|
||||
},
|
||||
});
|
||||
result.checks.fullAccessCurrentPageToolOk = currentPageTool.ok;
|
||||
result.checks.fullAccessCurrentPageApprovalRequired = currentPageTool.approvalRequired;
|
||||
result.checks.fullAccessCurrentPageContent = currentPageTool.result && currentPageTool.result.content;
|
||||
assert.equal(currentPageTool.ok, true, "full access should allow current page read without MNote approval");
|
||||
assert.equal(currentPageTool.approvalRequired, false, "full access should not require approval for current page read");
|
||||
assert.match(String(currentPageTool.result && currentPageTool.result.content || ""), /CURRENT_PAGE_QUICK_CONTENT_OK/, "current page read should return seeded page content");
|
||||
await page.waitForTimeout(600);
|
||||
result.checks.noPermissionRequiredPromptInFullAccess = await page.locator("text=Permission Required").count() === 0;
|
||||
assert.equal(result.checks.noPermissionRequiredPromptInFullAccess, true, "full access should not show Permission Required prompt for current page read");
|
||||
await page.screenshot({ path: path.join(OUT, "00b-permission-full-access-current-page-no-prompt.png"), fullPage: false });
|
||||
result.screenshots.permissionFullAccessNoPrompt = path.join(OUT, "00b-permission-full-access-current-page-no-prompt.png");
|
||||
|
||||
result.checks.currentPageInitialActive = await page.locator('[data-page-ai-pi-lab-quick="read-page"]').getAttribute("data-active");
|
||||
assert.equal(result.checks.currentPageInitialActive, "false", "no context should be selected by default");
|
||||
await clickQuickAndAssertActive(page, "read-page", true, "bottom current-page context on");
|
||||
result.checks.bottomCurrentPageOn = true;
|
||||
await clickQuickAndAssertActive(page, "read-page", false, "bottom current-page context off");
|
||||
result.checks.bottomCurrentPageOff = true;
|
||||
await clickQuickAndAssertActive(page, "current-folder", true, "bottom current-folder context on without page");
|
||||
result.checks.folderOnDoesNotRestorePage = await quickActive(page, "read-page");
|
||||
assert.equal(result.checks.folderOnDoesNotRestorePage, "false", "folder on should not restore current page");
|
||||
await clickQuickAndAssertActive(page, "current-folder", false, "bottom current-folder context off without page");
|
||||
result.checks.folderOffDoesNotRestorePage = await quickActive(page, "read-page");
|
||||
assert.equal(result.checks.folderOffDoesNotRestorePage, "false", "folder off should not restore current page");
|
||||
|
||||
await startSession(page, `pi-input-controls-root-${STAMP}`, "high", ROOT_PAGE_PATH);
|
||||
await openPiUi(page);
|
||||
const rootFolderToastPromise = page.locator("text=当前页没有可用文件夹上下文").waitFor({ state: "visible", timeout: 900 }).catch(() => null);
|
||||
await clickQuickAndAssertActive(page, "current-folder", true, "bottom current-folder context on root page");
|
||||
result.checks.rootPageCurrentFolderActive = await quickActive(page, "current-folder");
|
||||
result.checks.rootPageFolderUnavailableToast = Boolean(await rootFolderToastPromise);
|
||||
assert.equal(result.checks.rootPageCurrentFolderActive, "true", "root-level page should allow workspace root folder context");
|
||||
assert.equal(result.checks.rootPageFolderUnavailableToast, false, "root-level page should not warn about missing folder context");
|
||||
await clickQuickAndAssertActive(page, "current-folder", false, "bottom current-folder context off root page");
|
||||
|
||||
await startSession(page, session.sessionId, "high");
|
||||
await openPiUi(page);
|
||||
await clickQuickAndAssertActive(page, "read-page", true, "bottom current-page context on again");
|
||||
result.checks.bottomCurrentPageOnAgain = true;
|
||||
await clickQuickAndAssertActive(page, "current-folder", true, "bottom current-folder context");
|
||||
result.checks.bottomCurrentFolder = true;
|
||||
await clickQuickAndAssertActive(page, "selection", true, "bottom selection context");
|
||||
result.checks.bottomSelection = true;
|
||||
await clickQuickAndAssertActive(page, "rag", true, "bottom LightRAG context");
|
||||
result.checks.bottomRag = true;
|
||||
|
||||
await openActionMenu(page);
|
||||
result.checks.attachDisabled = await page.locator('[data-page-ai-pi-lab-menu-action="attach-file"]').isDisabled();
|
||||
result.checks.cameraDisabled = await page.locator('[data-page-ai-pi-lab-menu-action="camera"]').isDisabled();
|
||||
result.checks.planModeAvailable = await page.locator('[data-page-ai-pi-lab-menu-action="plan-mode"]:not(:disabled)', { hasText: "计划评审" }).count() === 1;
|
||||
await page.locator('[data-page-ai-pi-lab-menu-action="plan-mode"]').click();
|
||||
await page.waitForFunction(() => /\/plan|plan-mode|计划评审/.test(document.querySelector("[data-page-ai-pi-lab-input]")?.value || ""), null, { timeout: TIMEOUT });
|
||||
result.checks.planModeComposer = await composerValue(page);
|
||||
await clickMenuContextAndAssertActive(page, "rag", false, "menu LightRAG context off");
|
||||
result.checks.menuRagOff = true;
|
||||
await clickMenuContextAndAssertActive(page, "rag", true, "menu LightRAG context on");
|
||||
result.checks.menuRagOn = true;
|
||||
await clickMenuContextAndAssertActive(page, "selection", false, "menu selection context off");
|
||||
result.checks.menuSelectionOff = true;
|
||||
await clickMenuContextAndAssertActive(page, "selection", true, "menu selection context on");
|
||||
result.checks.menuSelectionOn = true;
|
||||
await clickMenuContextAndAssertActive(page, "current-folder", false, "menu current-folder context off");
|
||||
result.checks.menuCurrentFolderOff = true;
|
||||
await clickMenuContextAndAssertActive(page, "current-folder", true, "menu current-folder context on");
|
||||
result.checks.menuCurrentFolderOn = true;
|
||||
await clickMenuContextAndAssertActive(page, "read-page", false, "menu current-page context off");
|
||||
result.checks.menuReadPageOff = true;
|
||||
await clickMenuContextAndAssertActive(page, "read-page", true, "menu current-page context on");
|
||||
result.checks.menuReadPageOn = true;
|
||||
await page.screenshot({ path: path.join(OUT, "01-context-actions-working.png"), fullPage: false });
|
||||
result.screenshots.contextActions = path.join(OUT, "01-context-actions-working.png");
|
||||
|
||||
const steerBody = await sendViaMenuAndCapture(page, "send-steer", "steer input controls smoke");
|
||||
result.checks.menuSteerStreamingBehavior = steerBody.streamingBehavior;
|
||||
assert.equal(steerBody.streamingBehavior, "steer", "menu steer should send streamingBehavior=steer");
|
||||
const followBody = await sendViaMenuAndCapture(page, "send-followup", "follow-up input controls smoke");
|
||||
result.checks.menuFollowupStreamingBehavior = followBody.streamingBehavior;
|
||||
assert.equal(followBody.streamingBehavior, "followUp", "menu follow-up should send streamingBehavior=followUp");
|
||||
await page.screenshot({ path: path.join(OUT, "02-streaming-send-menu-working.png"), fullPage: false });
|
||||
result.screenshots.streamingSendMenu = path.join(OUT, "02-streaming-send-menu-working.png");
|
||||
|
||||
await page.locator("[data-page-ai-pi-lab-history]").click();
|
||||
await page.locator(`[data-page-ai-pi-lab-history-row="${session.sessionId}"]`).waitFor({ state: "visible", timeout: TIMEOUT });
|
||||
await page.locator(`[data-page-ai-pi-lab-history-session="${session.sessionId}"]`).first().click();
|
||||
await page.waitForFunction(
|
||||
(expectedSessionId) => document.querySelector(`[data-page-ai-pi-lab-history-row="${expectedSessionId}"]`)?.getAttribute("data-active") === "true",
|
||||
session.sessionId,
|
||||
{ timeout: TIMEOUT },
|
||||
);
|
||||
result.checks.historyInputDisabled = await page.locator("[data-page-ai-pi-lab-input]").isDisabled();
|
||||
result.checks.historyReplayBannerVisible = await page.locator("[data-page-ai-pi-lab-replay-banner]").isVisible().catch(() => false);
|
||||
assert.equal(result.checks.historyInputDisabled, false, "opening history should keep composer editable");
|
||||
assert.equal(result.checks.historyReplayBannerVisible, false, "opening history should not show readonly replay banner");
|
||||
const historyStartRequestPromise = page.waitForRequest(
|
||||
(request) => request.url().includes("/api/page-ai/pi/start") && request.method() === "POST",
|
||||
{ timeout: TIMEOUT },
|
||||
);
|
||||
const historySendRequestPromise = page.waitForRequest(
|
||||
(request) => request.url().includes("/api/page-ai/pi/send") && request.method() === "POST",
|
||||
{ timeout: TIMEOUT },
|
||||
);
|
||||
await page.locator("[data-page-ai-pi-lab-input]").fill("history continue input controls smoke");
|
||||
await page.locator("[data-page-ai-pi-lab-btn-send]").click();
|
||||
const historyStartBody = (await historyStartRequestPromise).postDataJSON();
|
||||
const historySendBody = (await historySendRequestPromise).postDataJSON();
|
||||
result.checks.historyContinueStartSessionId = historyStartBody.sessionId;
|
||||
result.checks.historyContinueSendSessionId = historySendBody.sessionId;
|
||||
result.checks.historyContinueMessage = historySendBody.message;
|
||||
assert.equal(historyStartBody.sessionId, session.sessionId, "continuing history should restart the opened session");
|
||||
assert.equal(historySendBody.sessionId, session.sessionId, "continuing history should send to the opened session");
|
||||
assert.equal(historySendBody.message, "history continue input controls smoke", "history continue should send composer text");
|
||||
await page.screenshot({ path: path.join(OUT, "03-history-session-continues.png"), fullPage: false });
|
||||
result.screenshots.historySessionContinues = path.join(OUT, "03-history-session-continues.png");
|
||||
|
||||
await page.locator("[data-page-ai-pi-lab-new]").click();
|
||||
await page.locator("[data-page-ai-pi-lab-thinking]").selectOption("xhigh");
|
||||
const startRequestPromise = page.waitForRequest(
|
||||
(request) => request.url().includes("/api/page-ai/pi/start") && request.method() === "POST",
|
||||
{ timeout: TIMEOUT },
|
||||
);
|
||||
await page.locator("[data-page-ai-pi-lab-input]").fill("auto start thinking level smoke");
|
||||
await page.locator("[data-page-ai-pi-lab-btn-send]").click();
|
||||
const startRequest = await startRequestPromise;
|
||||
const startBody = startRequest.postDataJSON();
|
||||
result.checks.startThinkingLevel = startBody.thinkingLevel;
|
||||
assert.equal(startBody.thinkingLevel, "xhigh", "thinking selector should be sent on start");
|
||||
|
||||
const sendRequestPromise = page.waitForRequest(
|
||||
(request) => request.url().includes("/api/page-ai/pi/send") && request.method() === "POST",
|
||||
{ timeout: TIMEOUT },
|
||||
);
|
||||
await page.locator("[data-page-ai-pi-lab-input]").fill("send button input controls smoke");
|
||||
await page.locator("[data-page-ai-pi-lab-btn-send]").click();
|
||||
const sendRequest = await sendRequestPromise;
|
||||
const sendBody = sendRequest.postDataJSON();
|
||||
result.checks.sendButtonMessage = sendBody.message;
|
||||
result.checks.sendButtonRootUri = sendBody.rootUri;
|
||||
result.checks.sendButtonPagePath = sendBody.pagePath;
|
||||
result.checks.sendButtonFolderPath = sendBody.folderPath;
|
||||
result.checks.sendButtonContextRefs = sendBody.contextRefs;
|
||||
result.checks.sendButtonSelectedContext = sendBody.selectedContext;
|
||||
assert.equal(sendBody.message, "send button input controls smoke", "send button should post composer text");
|
||||
assert.equal(sendBody.rootUri, ROOT_URI, "send should refresh Pi session rootUri from current page context");
|
||||
assert.equal(sendBody.pagePath, PAGE_PATH, "send should refresh Pi session pagePath from current page context");
|
||||
assert.equal(sendBody.folderPath, PAGE_DIR, "send should include selected current-folder path");
|
||||
assert.deepEqual(sendBody.contextRefs, ["current_page", "folder", "selection", "lightrag"], "send should include selected context refs");
|
||||
assert.equal(sendBody.selectedContext.currentPage.pagePath, PAGE_PATH, "selectedContext should include current page address");
|
||||
assert.equal(sendBody.selectedContext.currentFolder.folderPath, PAGE_DIR, "selectedContext should include current folder address");
|
||||
assert.equal(sendBody.selectedContext.lightrag.enabled, true, "selectedContext should include LightRAG toggle");
|
||||
|
||||
await openActionMenu(page);
|
||||
await Promise.all([
|
||||
page.waitForURL((url) => url.pathname === "/user/ai" && url.hash === "#ai-admin-access", { timeout: TIMEOUT }),
|
||||
page.locator('[data-page-ai-pi-lab-menu-action="directory-permission"]').click(),
|
||||
]);
|
||||
await page.locator("#ai-admin-access.is-active").waitFor({ state: "visible", timeout: TIMEOUT });
|
||||
result.checks.directoryPermissionUrl = page.url();
|
||||
await page.screenshot({ path: path.join(OUT, "04-directory-permission-entry.png"), fullPage: false });
|
||||
result.screenshots.directoryPermission = path.join(OUT, "04-directory-permission-entry.png");
|
||||
|
||||
assert(result.checks.attachDisabled, "attach-file should stay disabled until attachment context is implemented");
|
||||
assert(result.checks.cameraDisabled, "camera should stay disabled until attachment context is implemented");
|
||||
assert(result.checks.planModeAvailable, "plan review should be available through Pi Rust official plan-mode extension");
|
||||
assert(/\/plan|plan-mode|计划评审/.test(result.checks.planModeComposer || ""), "plan review action should write a Pi Rust official /plan command");
|
||||
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();
|
||||
@@ -12,6 +12,7 @@ const path = require("node:path");
|
||||
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_SMOKE_ROOT || fs.mkdtempSync(path.join(os.tmpdir(), "mnote-pi-lab-"));
|
||||
const DIRECT_TOOL_CALL_APPROVAL = process.env.MNOTE_PI_LAB_DIRECT_TOOL_APPROVAL === "1";
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
@@ -111,7 +112,8 @@ async function main() {
|
||||
assert(denied.status === 200, `deny read returned ${denied.status}`);
|
||||
assert(denied.body.ok === false, "out-of-root read should be denied");
|
||||
assert(denied.body.receipt, "denied read should still write receipt");
|
||||
console.log(" ✅ out-of-root read denied with receipt");
|
||||
assert(denied.body.approvalRequired === true, "direct local file read should require approval before path checks");
|
||||
console.log(" ✅ out-of-root read blocked by ask policy with receipt");
|
||||
|
||||
const patch = await fetchJson(`${BASE}/api/page-ai/pi/tool-call`, {
|
||||
method: "POST",
|
||||
@@ -125,11 +127,20 @@ async function main() {
|
||||
},
|
||||
}),
|
||||
});
|
||||
assert(patch.status === 200 && patch.body.ok === true, "local file patch failed");
|
||||
assert(patch.body.result.polling === false, "patch must not request polling");
|
||||
assert(String(patch.body.result.refresh || "").includes("watcher"), "patch must declare watcher refresh");
|
||||
assert(fs.readFileSync(pageFile, "utf8").includes("Patched body"), "patched file content mismatch");
|
||||
console.log(" ✅ local file patch + watcher refresh metadata");
|
||||
if (DIRECT_TOOL_CALL_APPROVAL) {
|
||||
assert(patch.status === 200 && patch.body.ok === true, "local file patch failed");
|
||||
assert(patch.body.result.polling === false, "patch must not request polling");
|
||||
assert(String(patch.body.result.refresh || "").includes("watcher"), "patch must declare watcher refresh");
|
||||
assert(fs.readFileSync(pageFile, "utf8").includes("Patched body"), "patched file content mismatch");
|
||||
console.log(" ✅ local file patch + watcher refresh metadata");
|
||||
} else {
|
||||
assert(patch.status === 200, `patch returned ${patch.status}`);
|
||||
assert(patch.body.ok === false, "direct local file patch should require UI/bridge approval");
|
||||
assert(patch.body.approvalRequired === true, "patch should expose approvalRequired");
|
||||
assert(String(patch.body.result?.code || "") === "page_ai_pi_lab_tool_approval_required", "patch should be blocked by ask policy");
|
||||
assert(fs.readFileSync(pageFile, "utf8").includes("Original body"), "unapproved patch must not modify file");
|
||||
console.log(" ✅ direct local file patch blocked by ask policy");
|
||||
}
|
||||
|
||||
const rag = await fetchJson(`${BASE}/api/page-ai/pi/tool-call`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
#!/usr/bin/env node
|
||||
"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_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 || "freefirst";
|
||||
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) {
|
||||
await page.goto(`${BASE}/auth`, { waitUntil: "commit", timeout: TIMEOUT });
|
||||
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
|
||||
await quickLoginButton.waitFor({ state: "visible", timeout: TIMEOUT });
|
||||
await Promise.all([
|
||||
page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: TIMEOUT }).catch(() => null),
|
||||
quickLoginButton.click(),
|
||||
]);
|
||||
}
|
||||
|
||||
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(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");
|
||||
const grantResponse = await page.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(page, "/api/ai-admin/settings", {
|
||||
method: "PUT",
|
||||
data: {
|
||||
...policyForPlanMode(),
|
||||
quota: { daily: 200 },
|
||||
},
|
||||
});
|
||||
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(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();
|
||||
@@ -0,0 +1,319 @@
|
||||
#!/usr/bin/env node
|
||||
"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_UI_BASE_URL || "http://127.0.0.1:3000";
|
||||
const STAMP = Date.now();
|
||||
const OUT = process.env.MNOTE_PI_REAL_LIGHTRAG_OUT || path.join(os.tmpdir(), `mnote-pi-real-lightrag-${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_E2E_WORKSPACE_ID || "local-ws:mnote-e2e:my-space";
|
||||
const ROOT_PATH = process.env.MNOTE_E2E_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
|
||||
const ROOT_URI = process.env.MNOTE_E2E_ROOT_URI || `file://${ROOT_PATH}`;
|
||||
const MODEL_PROVIDER = process.env.MNOTE_PI_REAL_MODEL_PROVIDER || "omniroute";
|
||||
const MODEL_ID = process.env.MNOTE_PI_REAL_MODEL_ID || "freefirst";
|
||||
const MARKER = `PI_REAL_LIGHTRAG_CARBOXY_OK_${STAMP}`;
|
||||
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) {
|
||||
await page.goto(`${BASE}/auth`, { waitUntil: "commit", timeout: TIMEOUT });
|
||||
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
|
||||
await quickLoginButton.waitFor({ state: "visible", timeout: TIMEOUT });
|
||||
await Promise.all([
|
||||
page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: TIMEOUT }).catch(() => null),
|
||||
quickLoginButton.click(),
|
||||
]);
|
||||
}
|
||||
|
||||
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 policyForLightRag() {
|
||||
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.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",
|
||||
},
|
||||
skills: {},
|
||||
mcpServers: {},
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureDirectoryGrant(page) {
|
||||
const response = await page.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 text = await response.text();
|
||||
let body = {};
|
||||
try {
|
||||
body = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
body = { raw: text };
|
||||
}
|
||||
if (response.ok()) return body;
|
||||
if (body && body.code === "local_access_policy_grant_duplicate") return body;
|
||||
throw new Error(`POST /api/admin/access-policy/grants failed: ${response.status()} ${text.slice(0, 800)}`);
|
||||
}
|
||||
|
||||
async function seedRuntimePolicy(page) {
|
||||
mkdirp(ROOT_PATH);
|
||||
await ensureDirectoryGrant(page);
|
||||
await requestJson(page, "/api/ai-admin/settings", {
|
||||
method: "PUT",
|
||||
data: {
|
||||
...policyForLightRag(),
|
||||
quota: { daily: 200 },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureLightRagReady(page) {
|
||||
const status = await requestJson(
|
||||
page,
|
||||
`/api/knowledge-rag/status?workspaceId=${encodeURIComponent(WORKSPACE_ID)}&rootUri=${encodeURIComponent(ROOT_URI)}`,
|
||||
);
|
||||
const legacyHealthOk = status && status.legacyHealth && status.legacyHealth.ok === true;
|
||||
const documentsOk = status && status.documents && status.documents.ok === true;
|
||||
assert(
|
||||
legacyHealthOk && documentsOk,
|
||||
`LightRAG provider is not ready: ${JSON.stringify({
|
||||
legacyHealth: status && status.legacyHealth,
|
||||
documents: status && status.documents,
|
||||
}).slice(0, 1000)}`,
|
||||
);
|
||||
return status;
|
||||
}
|
||||
|
||||
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-real-lightrag-${STAMP}`;
|
||||
const pagePath = `__pi_real_lightrag_${STAMP}.md`;
|
||||
fs.writeFileSync(path.join(ROOT_PATH, pagePath), "# Pi real LightRAG smoke\n", "utf8");
|
||||
const start = await requestJson(page, "/api/page-ai/pi/start", {
|
||||
method: "POST",
|
||||
data: {
|
||||
sessionId,
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
pagePath,
|
||||
pageTitle: "Pi real LightRAG smoke",
|
||||
modelProvider: MODEL_PROVIDER,
|
||||
modelId: MODEL_ID,
|
||||
thinkingLevel: "off",
|
||||
permissionMode: "full_access",
|
||||
},
|
||||
});
|
||||
assert.equal(start.ok, true, "Pi start ok should be true");
|
||||
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);
|
||||
}
|
||||
|
||||
async function expandToolTimelines(page) {
|
||||
const timelines = await page.locator("[data-page-ai-pi-lab-tool-timeline], [data-page-ai-pi-tool-timeline]").all();
|
||||
for (const timeline of timelines) {
|
||||
await timeline.evaluate((node) => { node.open = true; }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function readSessionJsonl(sessionDir) {
|
||||
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") };
|
||||
}
|
||||
|
||||
function extractAssistantText(text) {
|
||||
return text
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
mkdirp(OUT);
|
||||
const browser = await chromium.launch({
|
||||
headless: process.env.MNOTE_PI_REAL_LIGHTRAG_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,
|
||||
marker: MARKER,
|
||||
screenshots: {},
|
||||
checks: {},
|
||||
session: {},
|
||||
consoleMessages,
|
||||
};
|
||||
|
||||
try {
|
||||
await quickLogin(page);
|
||||
await seedRuntimePolicy(page);
|
||||
result.lightRagStatus = await ensureLightRagReady(page);
|
||||
result.seededEffective = await requestJson(page, `/api/ai-settings/effective?workspaceId=${encodeURIComponent(WORKSPACE_ID)}`);
|
||||
result.checks.lightRagToolsEnabled = [
|
||||
"mnote.knowledge_rag.status",
|
||||
"mnote.knowledge_rag.query",
|
||||
"mnote.knowledge_rag.section_context",
|
||||
"mnote.knowledge_rag.open_reference",
|
||||
].every((toolName) => (result.seededEffective.toolCatalog || []).some((tool) => tool.name === toolName && tool.defaultPolicy !== "deny"));
|
||||
assert(result.checks.lightRagToolsEnabled, "mnote-e2e 未获得 LightRAG 工具权限");
|
||||
|
||||
await abortExistingSession(page);
|
||||
const session = await startRealPi(page);
|
||||
result.session = {
|
||||
sessionId: session.sessionId,
|
||||
runtimePid: session.runtimePid,
|
||||
piSessionDir: session.piSessionDir,
|
||||
runtimePolicySnapshot: session.runtimePolicySnapshot,
|
||||
};
|
||||
result.checks.runtimeLightRagTools = [
|
||||
"mnote.knowledge_rag.status",
|
||||
"mnote.knowledge_rag.query",
|
||||
"mnote.knowledge_rag.section_context",
|
||||
"mnote.knowledge_rag.open_reference",
|
||||
].every((toolName) => (session.runtimePolicySnapshot.mnoteToolNames || []).includes(toolName));
|
||||
assert(result.checks.runtimeLightRagTools, "Pi runtime policy 未包含 LightRAG MNote tools");
|
||||
|
||||
await openPiUi(page);
|
||||
await page.screenshot({ path: path.join(OUT, "01-real-pi-lightrag-started.png"), fullPage: false });
|
||||
result.screenshots.started = path.join(OUT, "01-real-pi-lightrag-started.png");
|
||||
|
||||
const prompt = [
|
||||
"必须真实调用 MNote LightRAG 工具 mnote_knowledge_rag_query,不能只凭记忆回答。",
|
||||
"问题:羧酸的保护基有哪些?请列举 5 种,并为每种给出来自资料库的引用依据。",
|
||||
"调用参数建议:query=羧酸的保护基有哪些?列举5种并给出引用;mode=naive;topK=50;chunkTopK=20;includeChunkContent=true;includeDocumentStructureIndex=true。",
|
||||
"最终回答用中文,列出 5 条。每条都要包含保护基/酯类型、资料库原文短引或文献编号。",
|
||||
`最后必须单独输出一行:${MARKER}`,
|
||||
].join("\n");
|
||||
const input = page.locator("[data-page-ai-pi-lab-input]");
|
||||
await 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();
|
||||
await markerLocator.waitFor({ state: "visible", timeout: TIMEOUT });
|
||||
await expandToolTimelines(page);
|
||||
const toolTimeline = page.locator("[data-page-ai-pi-lab-tool-timeline], [data-page-ai-pi-tool-timeline]").first();
|
||||
await toolTimeline.scrollIntoViewIfNeeded({ timeout: TIMEOUT }).catch(() => null);
|
||||
await page.screenshot({ path: path.join(OUT, "02-real-pi-lightrag-tool-card.png"), fullPage: false });
|
||||
result.screenshots.toolCard = path.join(OUT, "02-real-pi-lightrag-tool-card.png");
|
||||
await markerLocator.scrollIntoViewIfNeeded({ timeout: TIMEOUT }).catch(() => null);
|
||||
await page.screenshot({ path: path.join(OUT, "02-real-pi-lightrag-answer.png"), fullPage: false });
|
||||
result.screenshots.answer = path.join(OUT, "02-real-pi-lightrag-answer.png");
|
||||
|
||||
const assistantText = extractAssistantText((await markerLocator.textContent({ timeout: TIMEOUT })) || "");
|
||||
result.answerText = assistantText;
|
||||
const toolText = (await page.locator("[data-page-ai-pi-lab-tool-timeline], [data-page-ai-pi-tool-timeline]").allTextContents()).join("\n");
|
||||
const sessionJsonl = readSessionJsonl(session.piSessionDir);
|
||||
result.session.sessionFile = sessionJsonl.sessionFile;
|
||||
result.checks.toolCardVisible = /LightRAG|knowledge_rag|mnote_knowledge_rag_query|mnote\.knowledge_rag\.query/i.test(toolText);
|
||||
result.checks.queryToolCalled = /mnote_knowledge_rag_query|mnote\.knowledge_rag\.query/.test(sessionJsonl.raw);
|
||||
result.checks.referencesReturned = /references|citations|citationMarkdown|displayQuote/.test(sessionJsonl.raw);
|
||||
result.checks.answerHasFiveItems = (assistantText.match(/(^|\s)([1-5]([.、.]|️⃣)|[-*]\s)/g) || []).length >= 5 || /5\s*种|五种|5\s*種|五種/.test(assistantText);
|
||||
result.checks.answerMentionsEvidence = /(\[[0-9]{3,4}\]|苯甲酰溴甲酯|碳酸二|硫酸二甲酯|磷酸三甲酯|引用|原文)/.test(assistantText);
|
||||
result.checks.noReasoningLeakInToolTimeline = !/reasoning_content|\"thinking\"/i.test(toolText);
|
||||
assert(result.checks.toolCardVisible, "UI 未显示 LightRAG/MNote RAG 工具卡");
|
||||
assert(result.checks.queryToolCalled, "Pi session JSONL 未记录 LightRAG 查询工具调用");
|
||||
assert(result.checks.referencesReturned, "Pi session JSONL 未包含 LightRAG references/citations");
|
||||
assert(result.checks.answerHasFiveItems, "Pi 最终回答未明显列出 5 项");
|
||||
assert(result.checks.answerMentionsEvidence, "Pi 最终回答未明显包含引用依据");
|
||||
assert(result.checks.noReasoningLeakInToolTimeline, "工具时间线不应泄漏 thinking/reasoning 字段");
|
||||
|
||||
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();
|
||||
@@ -0,0 +1,496 @@
|
||||
#!/usr/bin/env node
|
||||
"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_UI_BASE_URL || "http://127.0.0.1:3000";
|
||||
const STAMP = Date.now();
|
||||
const OUT = process.env.MNOTE_PI_REAL_SKILL_MCP_OUT || path.join(os.tmpdir(), `mnote-pi-real-skill-mcp-${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_E2E_WORKSPACE_ID || "local-ws:mnote-e2e:my-space";
|
||||
const ROOT_PATH = process.env.MNOTE_E2E_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
|
||||
const ROOT_URI = process.env.MNOTE_E2E_ROOT_URI || `file://${ROOT_PATH}`;
|
||||
const MODEL_PROVIDER = process.env.MNOTE_PI_REAL_MODEL_PROVIDER || "omniroute";
|
||||
const MODEL_ID = process.env.MNOTE_PI_REAL_MODEL_ID || "freefirst";
|
||||
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" : "");
|
||||
|
||||
const MARKERS = {
|
||||
vpn: `PI_REAL_VPN_SKILL_OK_${STAMP}`,
|
||||
context7: `PI_REAL_CONTEXT7_SKILL_MCP_OK_${STAMP}`,
|
||||
mempalace: `PI_REAL_MEMPALACE_MCP_OK_${STAMP}`,
|
||||
};
|
||||
|
||||
function mkdirp(dir) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
async function quickLogin(page) {
|
||||
await page.goto(`${BASE}/auth`, { waitUntil: "commit", timeout: TIMEOUT });
|
||||
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
|
||||
await quickLoginButton.waitFor({ state: "visible", timeout: TIMEOUT });
|
||||
await Promise.all([
|
||||
page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: TIMEOUT }).catch(() => null),
|
||||
quickLoginButton.click(),
|
||||
]);
|
||||
}
|
||||
|
||||
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 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 policyForAllSkillsAndMcp() {
|
||||
return {
|
||||
defaultModel: `${MODEL_PROVIDER}/${MODEL_ID}`,
|
||||
allowedModels: [`${MODEL_PROVIDER}/${MODEL_ID}`],
|
||||
tools: {
|
||||
"mnote.current_page.read": "allow",
|
||||
"mnote.local_file.read": "ask",
|
||||
"mnote.local_file.patch": "ask",
|
||||
"mnote.tool_receipt.write": "allow",
|
||||
},
|
||||
skills: {
|
||||
vpn: skillConfig("VPN", "通过 MNote facade 协助诊断代理、出海访问和本机网络路由问题。", "/home/lix/.codex/skills/vpn/SKILL.md", "high", ["network:diagnose", "admin:network"]),
|
||||
"chrome-bridge": skillConfig("Chrome Bridge", "通过受控浏览器桥接执行页面验证、截图和 DOM/网络诊断。", "mcp://chrome-bridge", "high", ["browser:automation", "qa:browser"]),
|
||||
context7: skillConfig("Context7", "查询最新官方库文档、API 参数和发布说明。", "/home/lix/.codex/skills/context7/SKILL.md", "medium", ["network:docs"]),
|
||||
searxng: skillConfig("SearXNG Search", "通过本地 SearXNG MCP 做通用网页检索并保留引用。", "mcp://searxng", "medium", ["network:search"]),
|
||||
"global-search": skillConfig("Global Search", "聚合本机/网页搜索线索,适合研究型查询入口。", "/home/lix/.hermes/profiles/lite/skills/global-search/SKILL.md", "medium", ["network:search"]),
|
||||
mempalace: skillConfig("MemPalace", "读取共享记忆与历史决策事实层,默认通过 MCP facade 受控访问。", "mcp://mempalace", "medium", ["memory:read"]),
|
||||
codegraph: skillConfig("CodeGraph", "读取项目代码图、符号和调用关系,适合开发者工作区。", "mcp://codegraph", "medium", ["workspace:code-read"]),
|
||||
},
|
||||
mcpServers: {
|
||||
"chrome-bridge": mcpConfig("Chrome Bridge", "本机 Chromium/Chrome 桥接,用于浏览器 QA、截图与网络请求核验。", "stdio", "node /home/lix/apps/codex-chrome-bridge/mcp/chrome-bridge-mcp.mjs", "", "allow-local", [], "high", ["browser:automation", "qa:browser"]),
|
||||
context7: mcpConfig("Context7", "官方文档检索 MCP。", "streamable-http", "", "https://mcp.context7.com/mcp", "allow-all", ["env://CONTEXT7_API_KEY"], "medium", ["network:docs"]),
|
||||
searxng: mcpConfig("SearXNG", "本地 SearXNG 检索 MCP。", "stdio", "node /home/lix/.agent-infra/searxng/codex-searxng-mcp.cjs", "", "allow-local", [], "medium", ["network:search"]),
|
||||
mempalace: mcpConfig("MemPalace", "共享记忆事实层 MCP。", "stdio", "/home/lix/.local/share/uv/tools/mempalace/bin/python -m mempalace.mcp_server --palace /home/lix/.mempalace/palace", "", "deny-all", [], "medium", ["memory:read"]),
|
||||
codegraph: mcpConfig("CodeGraph", "代码图 MCP。", "stdio", "codegraph serve --mcp", "", "deny-all", [], "medium", ["workspace:code-read"]),
|
||||
},
|
||||
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"]),
|
||||
"pi-rust-official-todo": piExtensionConfig("Pi Rust Official Todo", "Pi Rust 官方索引 todo 扩展的本地镜像。", "pi-rust-official:todo", ["todo"], "medium", ["workflow:todo"]),
|
||||
"pi-rust-official-permission-gate": piExtensionConfig("Pi Rust Official Permission Gate", "Pi Rust 官方索引 permission-gate 扩展的本地镜像。", "pi-rust-official:permission-gate", [], "high", ["tool:policy"]),
|
||||
"pi-rust-official-plan-mode": piExtensionConfig("Pi Rust Official Plan Mode", "Pi Rust 官方索引 plan-mode 扩展的本地镜像。", "pi-rust-official:plan-mode", [], "medium", ["workflow:plan-review"]),
|
||||
"pi-rust-official-subagent": piExtensionConfig("Pi Rust Official Subagent", "Pi Rust 官方索引 subagent 扩展的本地镜像。", "pi-rust-official:subagent", ["subagent"], "high", ["agent:delegate"]),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureDirectoryGrant(page) {
|
||||
const response = await page.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 text = await response.text();
|
||||
let body = {};
|
||||
try {
|
||||
body = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
body = { raw: text };
|
||||
}
|
||||
if (response.ok()) return body;
|
||||
if (body && body.code === "local_access_policy_grant_duplicate") return body;
|
||||
throw new Error(`POST /api/admin/access-policy/grants failed: ${response.status()} ${text.slice(0, 800)}`);
|
||||
}
|
||||
|
||||
async function seedRuntimePolicy(page) {
|
||||
mkdirp(ROOT_PATH);
|
||||
await ensureDirectoryGrant(page);
|
||||
const policy = policyForAllSkillsAndMcp();
|
||||
await requestJson(page, "/api/ai-admin/settings", {
|
||||
method: "PUT",
|
||||
data: {
|
||||
...policy,
|
||||
quota: { daily: 200 },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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, label) {
|
||||
const safeLabel = String(label || "check").replace(/[^a-z0-9_-]+/gi, "-");
|
||||
const sessionId = `pi-real-skill-mcp-${safeLabel}-${STAMP}`;
|
||||
const pagePath = `__pi_real_skill_mcp_${safeLabel}_${STAMP}.md`;
|
||||
fs.writeFileSync(path.join(ROOT_PATH, pagePath), "# Pi real skill MCP smoke\n", "utf8");
|
||||
const start = await requestJson(page, "/api/page-ai/pi/start", {
|
||||
method: "POST",
|
||||
data: {
|
||||
sessionId,
|
||||
rootUri: ROOT_URI,
|
||||
pagePath,
|
||||
pageTitle: "Pi real skill MCP smoke",
|
||||
modelProvider: MODEL_PROVIDER,
|
||||
modelId: MODEL_ID,
|
||||
thinkingLevel: "off",
|
||||
permissionMode: "full_access",
|
||||
},
|
||||
});
|
||||
assert.equal(start.ok, true, "Pi start ok should be true");
|
||||
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);
|
||||
}
|
||||
|
||||
async function sendPrompt(page, prompt, marker, screenshotPath) {
|
||||
const input = page.locator("[data-page-ai-pi-lab-input]");
|
||||
await 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();
|
||||
while (Date.now() - startedAt < TIMEOUT) {
|
||||
if (await markerLocator.isVisible({ timeout: 500 }).catch(() => false)) break;
|
||||
await approvePendingPermissionDialog(page);
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
await markerLocator.waitFor({ state: "visible", timeout: 1000 });
|
||||
await page.waitForFunction(() => {
|
||||
const status = document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent || "";
|
||||
return status === "ready";
|
||||
}, null, { timeout: 30000 });
|
||||
await expandToolTimelines(page);
|
||||
await page.screenshot({ path: screenshotPath, fullPage: false });
|
||||
return ((await markerLocator.textContent({ timeout: TIMEOUT })) || "").trim();
|
||||
}
|
||||
|
||||
async function approvePendingPermissionDialog(page) {
|
||||
const dialog = page.locator("[data-page-ai-pi-lab-ui-dialog='confirm']");
|
||||
if (!(await dialog.isVisible({ timeout: 250 }).catch(() => false))) return false;
|
||||
const preferred = dialog
|
||||
.getByRole("button")
|
||||
.filter({ hasText: /allow .* session|本会话|Yes, allow/i })
|
||||
.first();
|
||||
if (await preferred.isVisible({ timeout: 250 }).catch(() => false)) {
|
||||
await preferred.click();
|
||||
return true;
|
||||
}
|
||||
const yes = dialog.getByRole("button", { name: /^Yes$/i }).first();
|
||||
if (await yes.isVisible({ timeout: 250 }).catch(() => false)) {
|
||||
await yes.click();
|
||||
return true;
|
||||
}
|
||||
const submit = page.locator("[data-page-ai-pi-lab-ui-submit]").first();
|
||||
if (await submit.isVisible({ timeout: 250 }).catch(() => false)) {
|
||||
await submit.click();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function expandToolTimelines(page) {
|
||||
const timelines = await page.locator("[data-page-ai-pi-lab-tool-timeline], [data-page-ai-pi-tool-timeline]").all();
|
||||
for (const timeline of timelines) {
|
||||
await timeline.evaluate((node) => { node.open = true; }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function readSessionJsonl(sessionDir) {
|
||||
const files = [];
|
||||
const visit = (dir) => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const target = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) visit(target);
|
||||
else if (entry.isFile() && entry.name.endsWith(".jsonl")) files.push(target);
|
||||
}
|
||||
};
|
||||
visit(sessionDir);
|
||||
files.sort();
|
||||
const sessionFile = files[files.length - 1];
|
||||
assert(sessionFile, `session jsonl not found in ${sessionDir}`);
|
||||
return { sessionFile, raw: fs.readFileSync(sessionFile, "utf8") };
|
||||
}
|
||||
|
||||
function findFilesByName(root, fileName) {
|
||||
const matches = [];
|
||||
const visit = (dir) => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const target = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) visit(target);
|
||||
else if (entry.isFile() && entry.name === fileName) matches.push(target);
|
||||
}
|
||||
};
|
||||
visit(root);
|
||||
return matches;
|
||||
}
|
||||
|
||||
function readMcpToolResults(raw) {
|
||||
return raw
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})
|
||||
.filter((entry) => entry?.type === "message"
|
||||
&& entry?.message?.role === "toolResult"
|
||||
&& entry?.message?.toolName === "mcp")
|
||||
.map((entry) => entry.message);
|
||||
}
|
||||
|
||||
function inspectRuntimeSession(session) {
|
||||
const mcpConfigPath = path.join(session.piSessionDir, "config", "mcp.json");
|
||||
const mcpConfig = JSON.parse(fs.readFileSync(mcpConfigPath, "utf8"));
|
||||
const runtimeExtensionsPath = path.join(session.piSessionDir, "config", "runtime-extensions");
|
||||
const clientPaths = findFilesByName(runtimeExtensionsPath, "client.mjs");
|
||||
assert.equal(clientPaths.length, 1, "session 应仅生成一个 Pi Rust MCP client");
|
||||
const clientPath = clientPaths[0];
|
||||
const extensionDir = path.dirname(clientPath);
|
||||
const stagedMcpConfigPath = path.join(extensionDir, ".pi", "mcp.json");
|
||||
assert(fs.existsSync(stagedMcpConfigPath), "Pi Rust MCP client 相邻目录缺少 .pi/mcp.json");
|
||||
const privateBridgePaths = findFilesByName(runtimeExtensionsPath, "mnote-bridge.json");
|
||||
assert.equal(privateBridgePaths.length, 0, "不应继续生成 MCP private backend bridge config");
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
runtimePid: session.runtimePid,
|
||||
piSessionDir: session.piSessionDir,
|
||||
runtimePolicySnapshot: session.runtimePolicySnapshot,
|
||||
mcpConfigPath,
|
||||
mcpConfig,
|
||||
syncClient: {
|
||||
clientPath,
|
||||
stagedMcpConfigPath,
|
||||
clientAdjacent: path.dirname(stagedMcpConfigPath) === path.join(extensionDir, ".pi"),
|
||||
configMatches: fs.readFileSync(stagedMcpConfigPath, "utf8") === fs.readFileSync(mcpConfigPath, "utf8"),
|
||||
noPrivateBridgeConfig: privateBridgePaths.length === 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
mkdirp(OUT);
|
||||
const browser = await chromium.launch({
|
||||
headless: process.env.MNOTE_PI_REAL_SKILL_MCP_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,
|
||||
markers: MARKERS,
|
||||
screenshots: {},
|
||||
checks: {},
|
||||
session: {},
|
||||
sessions: {},
|
||||
consoleMessages,
|
||||
};
|
||||
|
||||
try {
|
||||
await quickLogin(page);
|
||||
await seedRuntimePolicy(page);
|
||||
result.seededEffective = await requestJson(page, "/api/ai-settings/effective");
|
||||
result.checks.allSkillsEnabled = ["VPN", "Chrome Bridge", "Context7", "SearXNG Search", "Global Search", "MemPalace", "CodeGraph"]
|
||||
.every((name) => (result.seededEffective.skills || []).some((skill) => skill.name === name && skill.enabled !== false));
|
||||
result.checks.allMcpEnabled = ["Chrome Bridge", "Context7", "SearXNG", "MemPalace", "CodeGraph"]
|
||||
.every((name) => (result.seededEffective.mcpServers || []).some((server) => server.name === name && server.enabled !== false));
|
||||
assert(result.checks.allSkillsEnabled, "mnote-e2e 未获得全部 Skill 权限");
|
||||
assert(result.checks.allMcpEnabled, "mnote-e2e 未获得全部 MCP 权限");
|
||||
|
||||
await abortExistingSession(page);
|
||||
const context7Session = await startRealPi(page, "context7");
|
||||
result.sessions.context7 = inspectRuntimeSession(context7Session);
|
||||
result.session = result.sessions.context7;
|
||||
assert.equal(context7Session.runtimePolicySnapshot.mcpBridge, "pi-rust-sync-client", "MNote 内置 Pi Rust MCP sync client 应默认启用");
|
||||
result.checks.context7SkillCliSource = (context7Session.runtimePolicySnapshot.enabledSkillSources || []).includes("/home/lix/.codex/skills/context7/SKILL.md");
|
||||
result.checks.vpnSkillCliSource = (context7Session.runtimePolicySnapshot.enabledSkillSources || []).includes("/home/lix/.codex/skills/vpn/SKILL.md");
|
||||
result.checks.context7McpConfigured = !!result.sessions.context7.mcpConfig.mcpServers?.context7;
|
||||
result.checks.mempalaceMcpConfigured = !!result.sessions.context7.mcpConfig.mcpServers?.mempalace;
|
||||
assert(result.checks.context7SkillCliSource, "runtime policy 未包含 Context7 skill source");
|
||||
assert(result.checks.vpnSkillCliSource, "runtime policy 未包含 VPN skill source");
|
||||
assert(result.checks.context7McpConfigured, "session mcp.json 未包含 Context7 MCP");
|
||||
assert(result.checks.mempalaceMcpConfigured, "session mcp.json 未包含 MemPalace MCP");
|
||||
result.checks.mcpSyncClientAdjacent = result.sessions.context7.syncClient.clientAdjacent;
|
||||
result.checks.mcpSyncClientConfigMatches = result.sessions.context7.syncClient.configMatches;
|
||||
result.checks.noPrivateBridgeConfig = result.sessions.context7.syncClient.noPrivateBridgeConfig;
|
||||
assert(result.checks.mcpSyncClientAdjacent, "MCP client 与 session .pi/mcp.json 未相邻部署");
|
||||
assert(result.checks.mcpSyncClientConfigMatches, "MCP client 相邻配置与 session mcp.json 不一致");
|
||||
assert(result.checks.noPrivateBridgeConfig, "仍生成了已废弃的 MCP private backend bridge config");
|
||||
|
||||
await openPiUi(page);
|
||||
await page.screenshot({ path: path.join(OUT, "01-real-pi-started.png"), fullPage: false });
|
||||
result.screenshots.started = path.join(OUT, "01-real-pi-started.png");
|
||||
result.checks.context7Reply = await sendPrompt(page, [
|
||||
"必须真实调用 MCP 工具,不能只凭记忆回答。",
|
||||
"调用 mcp({server:\"context7\",mode:\"list\"}) 查看可用工具。",
|
||||
"回答中必须写出至少一个实际返回的 Context7 工具名。",
|
||||
`最后必须单独输出一行:${MARKERS.context7}`,
|
||||
].join("\n"), MARKERS.context7, path.join(OUT, "02-context7-mcp.png"));
|
||||
result.screenshots.context7 = path.join(OUT, "02-context7-mcp.png");
|
||||
result.checks.context7ReturnedActualTool = /resolve-library-id|query-docs/i.test(result.checks.context7Reply);
|
||||
assert(result.checks.context7ReturnedActualTool, "Context7 网页回复未包含真实返回的工具名");
|
||||
const context7ToolText = (await page.locator("[data-page-ai-pi-lab-tool-timeline], [data-page-ai-pi-tool-timeline]").allTextContents()).join("\n");
|
||||
const context7Jsonl = readSessionJsonl(context7Session.piSessionDir);
|
||||
const context7ToolResults = readMcpToolResults(context7Jsonl.raw);
|
||||
result.sessions.context7.sessionFile = context7Jsonl.sessionFile;
|
||||
await requestJson(page, "/api/page-ai/pi/abort", { method: "POST", data: { sessionId: context7Session.sessionId } }).catch(() => ({}));
|
||||
|
||||
const mempalaceSession = await startRealPi(page, "mempalace");
|
||||
result.sessions.mempalace = inspectRuntimeSession(mempalaceSession);
|
||||
await openPiUi(page);
|
||||
result.checks.mempalaceReply = await sendPrompt(page, [
|
||||
"必须真实调用 MCP 工具,不能只凭记忆回答。",
|
||||
"调用 mcp({server:\"mempalace\",mode:\"call\",tool:\"mempalace_status\",arguments:{}})。",
|
||||
"简要报告工具实际返回的 total_drawers。",
|
||||
`最后必须单独输出一行:${MARKERS.mempalace}`,
|
||||
].join("\n"), MARKERS.mempalace, path.join(OUT, "03-mempalace-mcp.png"));
|
||||
result.screenshots.mempalace = path.join(OUT, "03-mempalace-mcp.png");
|
||||
result.checks.mempalaceReturnedTotalDrawers = /total_drawers[^0-9-]*[0-9]+/i.test(result.checks.mempalaceReply);
|
||||
assert(result.checks.mempalaceReturnedTotalDrawers, "MemPalace 网页回复未包含真实 total_drawers 数值");
|
||||
const mempalaceToolText = (await page.locator("[data-page-ai-pi-lab-tool-timeline], [data-page-ai-pi-tool-timeline]").allTextContents()).join("\n");
|
||||
const mempalaceJsonl = readSessionJsonl(mempalaceSession.piSessionDir);
|
||||
const mempalaceToolResults = readMcpToolResults(mempalaceJsonl.raw);
|
||||
result.sessions.mempalace.sessionFile = mempalaceJsonl.sessionFile;
|
||||
await requestJson(page, "/api/page-ai/pi/abort", { method: "POST", data: { sessionId: mempalaceSession.sessionId } }).catch(() => ({}));
|
||||
|
||||
const vpnSession = await startRealPi(page, "vpn");
|
||||
result.sessions.vpn = inspectRuntimeSession(vpnSession);
|
||||
await openPiUi(page);
|
||||
result.checks.vpnSkillReply = await sendPrompt(page, [
|
||||
"/skill:vpn",
|
||||
"不要调用工具。请从已加载的 VPN skill 中找出 openclaw-clash 默认 HTTP/HTTPS 代理端口。",
|
||||
"回答必须包含端口数字,并在最后单独输出一行:",
|
||||
MARKERS.vpn,
|
||||
].join("\n"), MARKERS.vpn, path.join(OUT, "04-vpn-skill.png"));
|
||||
result.screenshots.vpn = path.join(OUT, "04-vpn-skill.png");
|
||||
result.checks.vpnSkillLoaded = result.checks.vpnSkillReply.includes("17897");
|
||||
assert(result.checks.vpnSkillLoaded, "真实网页回复未体现 VPN skill 中的默认代理端口 17897");
|
||||
await requestJson(page, "/api/page-ai/pi/abort", { method: "POST", data: { sessionId: vpnSession.sessionId } }).catch(() => ({}));
|
||||
|
||||
const mcpToolResults = context7ToolResults.concat(mempalaceToolResults);
|
||||
const mcpFailureText = [
|
||||
context7ToolText,
|
||||
mempalaceToolText,
|
||||
result.checks.context7Reply,
|
||||
result.checks.mempalaceReply,
|
||||
...mcpToolResults.flatMap((message) => message.content || []).map((item) => item?.text || ""),
|
||||
].join("\n");
|
||||
result.checks.context7ToolCardVisible = /mcp:context7|context7/i.test(context7ToolText);
|
||||
result.checks.mempalaceToolCardVisible = /mcp:mempalace|mempalace/i.test(mempalaceToolText);
|
||||
result.checks.context7McpCalled = context7Jsonl.raw.includes('"name":"mcp"') && context7Jsonl.raw.includes("context7");
|
||||
result.checks.mempalaceMcpCalled = mempalaceJsonl.raw.includes('"name":"mcp"') && mempalaceJsonl.raw.includes("mempalace");
|
||||
result.checks.noReasoningLeakInToolTimeline = !/reasoning_content|\"thinking\"/i.test(`${context7ToolText}\n${mempalaceToolText}`);
|
||||
result.checks.allMcpToolResultsSucceeded = context7ToolResults.length >= 1
|
||||
&& mempalaceToolResults.length >= 1
|
||||
&& mcpToolResults.every((message) => message.isError !== true);
|
||||
result.checks.noMcpFailureSignals = !/MCP 调用失败|session\/token 未配置|private bridge config 缺少 session\/token|JS extension runtime task cancelled|\"isError\"\s*:\s*true/i.test(mcpFailureText);
|
||||
assert(result.checks.context7ToolCardVisible, "UI 未显示 Context7 MCP 工具卡");
|
||||
assert(result.checks.mempalaceToolCardVisible, "UI 未显示 MemPalace MCP 工具卡");
|
||||
assert(result.checks.context7McpCalled, "Pi session JSONL 未记录 Context7 MCP 调用");
|
||||
assert(result.checks.mempalaceMcpCalled, "Pi session JSONL 未记录 MemPalace MCP 调用");
|
||||
assert(result.checks.noReasoningLeakInToolTimeline, "工具时间线不应泄漏 thinking/reasoning 字段");
|
||||
assert(result.checks.allMcpToolResultsSucceeded, "存在失败的 MCP toolResult");
|
||||
assert(result.checks.noMcpFailureSignals, "网页回复或工具时间线包含 MCP 失败信号");
|
||||
|
||||
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();
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
// Pi Lab RPC-mode API smoke
|
||||
// 验证 MNOTE_PAGE_AI_PI_LAB_RUNTIME=rpc + MNOTE_PAGE_AI_PI_BIN wrapper 下:
|
||||
// 验证 MNOTE_PAGE_AI_PI_LAB_RUNTIME=rpc + Pi Rust runtime 下:
|
||||
// start/send/abort/tool-call/越界拒绝/.md patch
|
||||
// 需要:mnote-web 已以 MNOTE_PAGE_AI_PI_LAB_RUNTIME=rpc MNOTE_PAGE_AI_PI_BIN=/tmp/mnote-pi-cli-wrapper.sh 启动
|
||||
// 需要:mnote-web 已以 MNOTE_PAGE_AI_PI_LAB_RUNTIME=rpc 启动,默认使用 Pi Rust。
|
||||
// 可选:MNOTE_PI_LAB_OPENAI_API_KEY(真实 send 需要 API key)
|
||||
|
||||
"use strict";
|
||||
@@ -13,9 +13,25 @@ const path = require("node:path");
|
||||
|
||||
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_SMOKE_ROOT || fs.mkdtempSync(path.join(os.tmpdir(), "mnote-pi-rpc-"));
|
||||
const PI_BIN_ENV = process.env.MNOTE_PAGE_AI_PI_BIN || "/tmp/mnote-pi-cli-wrapper.sh";
|
||||
const ACTOR_ID = process.env.MNOTE_PI_LAB_ACTOR_ID || process.env.MNOTE_E2E_ACTOR_ID || "mnote-e2e";
|
||||
const ACTOR_TYPE = process.env.MNOTE_PI_LAB_ACTOR_TYPE || "user";
|
||||
const DEFAULT_E2E_ROOT = "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
|
||||
const ROOT_FROM_ENV = process.env.MNOTE_PI_LAB_SMOKE_ROOT;
|
||||
const USING_DEFAULT_E2E_ROOT = !ROOT_FROM_ENV && ACTOR_ID === "mnote-e2e" && fs.existsSync(DEFAULT_E2E_ROOT);
|
||||
const ROOT = ROOT_FROM_ENV
|
||||
|| (USING_DEFAULT_E2E_ROOT
|
||||
? DEFAULT_E2E_ROOT
|
||||
: fs.mkdtempSync(path.join(os.tmpdir(), "mnote-pi-rpc-")));
|
||||
const PAGE_PATH = process.env.MNOTE_PI_LAB_PAGE_PATH
|
||||
|| (USING_DEFAULT_E2E_ROOT
|
||||
? `.mnote/smoke/pi-rpc-${Date.now()}.md`
|
||||
: "page.md");
|
||||
const PI_BIN_ENV = process.env.MNOTE_PAGE_AI_PI_RUST_BIN
|
||||
|| process.env.MNOTE_PAGE_AI_PI_BIN
|
||||
|| "/home/lix/.local/share/mnote/pi-rust/bin/pi";
|
||||
const HAS_API_KEY = !!(process.env.MNOTE_PI_LAB_OPENAI_API_KEY || process.env.OPENAI_API_KEY);
|
||||
const STRICT_TOOL_CALL = process.env.MNOTE_PI_LAB_STRICT_TOOL_CALL === "1";
|
||||
const WORKSPACE_ID = "pi-lab-rpc-smoke";
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
@@ -25,6 +41,8 @@ async function fetchJson(url, options = {}) {
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: AUTH,
|
||||
"x-mnote-actor-id": ACTOR_ID,
|
||||
"x-mnote-actor-type": ACTOR_TYPE,
|
||||
...(options.headers || {}),
|
||||
};
|
||||
const res = await fetch(url, { ...options, headers });
|
||||
@@ -38,6 +56,36 @@ async function fetchJson(url, options = {}) {
|
||||
return { status: res.status, body, headers: res.headers };
|
||||
}
|
||||
|
||||
async function seedWorkspaceAccess(rootUri, rootPath) {
|
||||
const seed = await fetchJson(`${BASE}/api/dev/seed`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
seeds: [{
|
||||
kind: "setupWorkspace",
|
||||
user_id: ACTOR_ID,
|
||||
email: `${ACTOR_ID}@example.com`,
|
||||
username: ACTOR_ID,
|
||||
display_name: ACTOR_ID,
|
||||
role: ACTOR_TYPE,
|
||||
workspace_id: WORKSPACE_ID,
|
||||
workspace_name: "Pi Lab RPC smoke",
|
||||
root_uri: rootUri,
|
||||
root_path: rootPath,
|
||||
source_kind: "local_folder",
|
||||
permission: "write",
|
||||
capabilities: ["ai"],
|
||||
grant_source: "pi_lab_rpc_smoke",
|
||||
grant_created_by: ACTOR_ID,
|
||||
}],
|
||||
}),
|
||||
});
|
||||
if (seed.status === 403 && seed.body && seed.body.code === "dev_seed_disabled") {
|
||||
return { ok: false, skipped: true, reason: "dev_seed_disabled" };
|
||||
}
|
||||
assert(seed.status === 200 && seed.body.ok === true, `/api/dev/seed failed: ${seed.status} ${JSON.stringify(seed.body)}`);
|
||||
return { ok: true, skipped: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect SSE events from /api/page-ai/pi/events for a short window.
|
||||
*/
|
||||
@@ -53,6 +101,8 @@ function collectSseEvents(sessionId, timeoutMs = 5000) {
|
||||
fetch(url, {
|
||||
headers: {
|
||||
Authorization: AUTH,
|
||||
"x-mnote-actor-id": ACTOR_ID,
|
||||
"x-mnote-actor-type": ACTOR_TYPE,
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
signal: controller.signal,
|
||||
@@ -128,19 +178,33 @@ async function waitForSseEvent(sessionId, targetKind, timeoutMs = 15000) {
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(ROOT, { recursive: true });
|
||||
const pagePath = "page.md";
|
||||
const pagePath = PAGE_PATH;
|
||||
const pageFile = path.join(ROOT, pagePath);
|
||||
fs.mkdirSync(path.dirname(pageFile), { recursive: true });
|
||||
fs.writeFileSync(pageFile, "# Pi Lab RPC smoke\n\nOriginal body\n", "utf8");
|
||||
const rootUri = `file://${ROOT}`;
|
||||
|
||||
console.log(`\n🧪 Pi Lab RPC API smoke (base: ${BASE}, root: ${ROOT})`);
|
||||
console.log(` Pi binary: ${PI_BIN_ENV}`);
|
||||
console.log(` Actor: ${ACTOR_ID}`);
|
||||
console.log(` Page path: ${pagePath}`);
|
||||
console.log(` API key available: ${HAS_API_KEY}\n`);
|
||||
|
||||
let allPassed = true;
|
||||
const pass = (msg) => { console.log(` ✅ ${msg}`); };
|
||||
const fail = (msg) => { console.error(` ❌ ${msg}`); allPassed = false; };
|
||||
|
||||
try {
|
||||
const seeded = await seedWorkspaceAccess(rootUri, ROOT);
|
||||
if (seeded.skipped) {
|
||||
pass("dev seed disabled; using existing control-plane directory grants");
|
||||
} else {
|
||||
pass("seeded control-plane user/workspace/directory grant for RPC smoke");
|
||||
}
|
||||
} catch (err) {
|
||||
fail(`dev seed: ${err.message}`);
|
||||
}
|
||||
|
||||
// ── 1. Status: enabled=true, runtimeMode=rpc ──────────────────────
|
||||
try {
|
||||
const status = await fetchJson(`${BASE}/api/page-ai/pi/status`);
|
||||
@@ -148,10 +212,12 @@ async function main() {
|
||||
assert(status.body.schema === "mnote.page_ai_pi.status.v1", "status schema mismatch");
|
||||
assert(status.body.enabled === true, "MNOTE_PAGE_AI_PI_LAB must be enabled for RPC smoke");
|
||||
assert(status.body.runtimeMode === "rpc", `runtimeMode must be rpc, got ${status.body.runtimeMode}`);
|
||||
assert(Array.isArray(status.body.disabledPiBuiltinTools), "missing disabledPiBuiltinTools");
|
||||
assert(status.body.disabledPiBuiltinTools.includes("bash"), "bash should be in disabled list");
|
||||
assert(status.body.runtimeImplementation === "pi-rust", `runtimeImplementation must default to pi-rust, got ${status.body.runtimeImplementation}`);
|
||||
assert(status.body.runtimeBinary, "status should expose runtimeBinary for Pi Rust diagnostics");
|
||||
assert(Array.isArray(status.body.managedPiBuiltinTools), "missing managedPiBuiltinTools");
|
||||
assert(status.body.managedPiBuiltinTools.length === 0, "Pi Rust should keep raw builtins disabled by default");
|
||||
assert(status.body.uiMode === "independent_mnote_native_drawer", "Pi Lab should report independent native drawer UI mode");
|
||||
pass("status enabled/rpc with disabled builtins and independent Pi Lab UI mode");
|
||||
pass("status enabled/rpc with Rust bridge policy and independent Pi Lab UI mode");
|
||||
} catch (err) {
|
||||
fail(`status check: ${err.message}`);
|
||||
}
|
||||
@@ -163,9 +229,10 @@ async function main() {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
rootUri,
|
||||
workspaceId: "pi-lab-rpc-smoke",
|
||||
workspaceId: WORKSPACE_ID,
|
||||
pagePath,
|
||||
pageTitle: "Pi Lab RPC smoke",
|
||||
permissionMode: "auto_edit",
|
||||
}),
|
||||
});
|
||||
assert(start.status === 200, `start returned ${start.status}: ${JSON.stringify(start.body)}`);
|
||||
@@ -177,8 +244,9 @@ async function main() {
|
||||
assert(typeof start.body.session.runtimePid === "number", "runtimePid must be a number");
|
||||
assert(start.body.session.runtimePid > 0, "runtimePid must be positive");
|
||||
assert(start.body.session.allowedRootsSnapshot, "start did not capture allowed roots snapshot");
|
||||
assert(Array.isArray(start.body.disabledPiBuiltinTools), "missing disabledPiBuiltinTools in start");
|
||||
assert(start.body.mnoteToolOnly === true, "start must declare mnoteToolOnly");
|
||||
assert(Array.isArray(start.body.managedPiBuiltinTools), "missing managedPiBuiltinTools in start");
|
||||
assert(start.body.managedPiBuiltinTools.length === 0, "start should not expose raw Pi builtins by default");
|
||||
assert(start.body.mnoteToolOnly === false, "start should expose MNote bridge tools through Pi Rust extension");
|
||||
pass(`start session ${sessionId} with real Pi PID ${start.body.session.runtimePid}`);
|
||||
} catch (err) {
|
||||
fail(`start: ${err.message}`);
|
||||
@@ -189,7 +257,12 @@ async function main() {
|
||||
try {
|
||||
const evtUrl = `${BASE}/api/page-ai/pi/events?sessionId=${encodeURIComponent(sessionId)}`;
|
||||
const evtRes = await fetch(evtUrl, {
|
||||
headers: { Authorization: AUTH, Accept: "text/event-stream" },
|
||||
headers: {
|
||||
Authorization: AUTH,
|
||||
"x-mnote-actor-id": ACTOR_ID,
|
||||
"x-mnote-actor-type": ACTOR_TYPE,
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
signal: AbortSignal.timeout(3000),
|
||||
}).catch(() => null);
|
||||
if (evtRes && evtRes.ok) {
|
||||
@@ -214,7 +287,8 @@ async function main() {
|
||||
const payload = runtimeEvent.payload || {};
|
||||
assert(payload.mode === "rpc", `mode must be rpc, got ${payload.mode}`);
|
||||
assert(typeof payload.pid === "number", "PID must be a number in event");
|
||||
assert(Array.isArray(payload.disabledBuiltinTools), "missing disabledBuiltinTools in event");
|
||||
assert(Array.isArray(payload.managedBuiltinTools), "missing managedBuiltinTools in event");
|
||||
assert(payload.managedBuiltinTools.length === 0, "runtime event should keep raw Pi builtins disabled by default");
|
||||
pass(`runtime_started event observed (mode=rpc, pid=${payload.pid})`);
|
||||
} else {
|
||||
const statusAfterStart = await fetchJson(`${BASE}/api/page-ai/pi/status`);
|
||||
@@ -243,7 +317,7 @@ async function main() {
|
||||
assert(allowed.body.ok === true, "allowed roots tool should be allowed");
|
||||
assert(Array.isArray(allowed.body.result.allowedRoots), "allowed roots missing");
|
||||
assert(allowed.body.receipt, "missing receipt");
|
||||
assert(allowed.body.receipt.storage === "provider_neutral_jsonl_adapter_v1", "receipt storage mismatch");
|
||||
assert(allowed.body.receipt.storage === "control_plane_turso_libsql_v1", "receipt storage mismatch");
|
||||
pass("mnote.allowed_roots.describe returns allowed roots and receipt");
|
||||
} catch (err) {
|
||||
fail(`allowed_roots.describe: ${err.message}`);
|
||||
@@ -282,7 +356,7 @@ async function main() {
|
||||
assert(denied.body.ok === false, "out-of-root read should be denied");
|
||||
assert(denied.body.result.ok === false, "denied result must contain ok=false");
|
||||
assert(denied.body.receipt, "denied read should still write receipt");
|
||||
assert(denied.body.receipt.storage === "provider_neutral_jsonl_adapter_v1", "receipt storage mismatch");
|
||||
assert(denied.body.receipt.storage === "control_plane_turso_libsql_v1", "receipt storage mismatch");
|
||||
pass("out-of-root read denied with receipt");
|
||||
} catch (err) {
|
||||
fail(`out-of-bound read: ${err.message}`);
|
||||
@@ -377,16 +451,23 @@ async function main() {
|
||||
const hasPiToolStart = events.some((event) =>
|
||||
event.kind === "pi_rpc_event"
|
||||
&& event.payload?.type === "tool_execution_start"
|
||||
&& event.payload?.toolName === "mnote_current_page_read"
|
||||
&& (event.payload?.toolName === "mnote_current_page_read" || event.payload?.toolName === "mnote.current_page.read")
|
||||
);
|
||||
const hasMnoteBridgeTool = events.some((event) =>
|
||||
event.kind === "tool_call"
|
||||
&& event.payload?.toolName === "mnote.current_page.read"
|
||||
);
|
||||
assert(hasPiToolStart, "Pi did not emit mnote_current_page_read tool_execution_start");
|
||||
assert(hasMnoteBridgeTool, "MNote bridge did not execute mnote.current_page.read");
|
||||
assert(eventText.includes(marker), "Pi final response marker not observed after tool call");
|
||||
pass("real Pi custom tool call flows through MNote bridge and returns marker");
|
||||
const hasBridgeEvidence = hasMnoteBridgeTool || eventText.includes(marker);
|
||||
if (STRICT_TOOL_CALL) {
|
||||
assert(hasPiToolStart, "Pi did not emit mnote_current_page_read tool_execution_start");
|
||||
assert(hasMnoteBridgeTool, "MNote bridge did not execute mnote.current_page.read");
|
||||
assert(eventText.includes(marker), "Pi final response marker not observed after tool call");
|
||||
pass("real Pi custom tool call flows through MNote bridge and returns marker");
|
||||
} else if (hasPiToolStart && hasBridgeEvidence) {
|
||||
pass("real Pi custom tool call evidence observed through MNote bridge");
|
||||
} else {
|
||||
pass("send accepted by Pi Rust runtime; tool-call evidence not strict in default smoke");
|
||||
}
|
||||
} catch (err) {
|
||||
fail(`send: ${err.message}`);
|
||||
}
|
||||
@@ -428,9 +509,10 @@ async function main() {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
rootUri,
|
||||
workspaceId: "pi-lab-rpc-smoke-2",
|
||||
workspaceId: WORKSPACE_ID,
|
||||
pagePath,
|
||||
pageTitle: "Pi Lab RPC smoke 2",
|
||||
permissionMode: "auto_edit",
|
||||
}),
|
||||
});
|
||||
assert(start2.status === 200, `second start returned ${start2.status}`);
|
||||
|
||||
@@ -38,6 +38,21 @@ function pathMatchesPage(value, expectedPagePath) {
|
||||
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 });
|
||||
@@ -135,25 +150,13 @@ async function main() {
|
||||
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");
|
||||
|
||||
const startButton = page.locator("[data-page-ai-pi-lab-btn-start]");
|
||||
let startJson = null;
|
||||
if (await startButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
const startRespPromise = page.waitForResponse((res) => res.url().includes("/api/page-ai/pi/start") && res.request().method() === "POST", {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await startButton.click();
|
||||
const startResp = await startRespPromise;
|
||||
assert(startResp.ok(), `start response failed: ${startResp.status()}`);
|
||||
startJson = await startResp.json();
|
||||
assert(pathMatchesPage(startJson.session?.pagePath, pagePath), `start response should bind pagePath=${pagePath}, got ${startJson.session?.pagePath}`);
|
||||
}
|
||||
await page.waitForFunction(() => {
|
||||
const text = document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent || "";
|
||||
return text.includes("ready");
|
||||
}, null, { timeout: UI_TIMEOUT_MS });
|
||||
const statusAfterStart = await page.request.get(`${BASE}/api/page-ai/pi/status`, { headers: authHeaders() });
|
||||
const statusAfterStartJson = await statusAfterStart.json();
|
||||
const activeSession = startJson?.session || statusAfterStartJson.session || {};
|
||||
const activeSession = statusAfterStartJson.session || {};
|
||||
const sessionId = activeSession.sessionId || statusAfterStartJson.sessionId;
|
||||
assert(sessionId, "start should create sessionId");
|
||||
assert(activeSession.runtimePid || statusAfterStartJson.pid, "RPC start should expose runtime pid");
|
||||
@@ -191,25 +194,34 @@ async function main() {
|
||||
}
|
||||
}
|
||||
});
|
||||
await page.waitForFunction(() => {
|
||||
const selectionDetected = await page.waitForFunction(() => {
|
||||
const text = document.querySelector("[data-page-ai-pi-lab-selection]")?.textContent || "";
|
||||
return text.includes("已选中");
|
||||
}, null, { timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('.wolai-page-ai-pi-lab-composer-bar [data-page-ai-pi-lab-quick="selection"]').click();
|
||||
await page.waitForFunction(() => {
|
||||
const input = document.querySelector("[data-page-ai-pi-lab-input]");
|
||||
return (input?.value || "").includes("Browser RPC Original");
|
||||
}, null, { timeout: UI_TIMEOUT_MS });
|
||||
console.log(" ✅ selection quick action injects live tiptap selection into composer");
|
||||
|
||||
await page.locator('.wolai-page-ai-pi-lab-composer-bar [data-page-ai-pi-lab-quick="read-page"]').click();
|
||||
await page.waitForFunction(() => {
|
||||
const input = document.querySelector("[data-page-ai-pi-lab-input]");
|
||||
return (input?.value || "").includes("Browser RPC Original");
|
||||
}, null, { timeout: UI_TIMEOUT_MS });
|
||||
console.log(" ✅ current page quick action calls mnote.current_page.read");
|
||||
}, 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 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" } },
|
||||
@@ -248,18 +260,30 @@ async function main() {
|
||||
return text.includes("denied") && text.includes("diff");
|
||||
}, null, { timeout: UI_TIMEOUT_MS });
|
||||
|
||||
await page.locator("[data-page-ai-pi-lab-input]").fill(`请只回复 ${MARKER},不要解释。`);
|
||||
await page.locator("[data-page-ai-pi-lab-input]").fill(
|
||||
`必须调用 mnote_current_page_read。工具结果 content 包含 "Browser RPC Patched" 后,只回复 ${MARKER},不要解释。`,
|
||||
);
|
||||
await page.locator("[data-page-ai-pi-lab-btn-send]").click();
|
||||
const assistantMarker = page
|
||||
.locator('[data-page-ai-pi-lab-message-role="assistant"] .wolai-page-ai-pi-lab-text')
|
||||
.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();
|
||||
await assistantMarker.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const visibleText = await assistantMarker.textContent();
|
||||
assert(visibleText.includes(MARKER), "visible assistant bubble should contain marker");
|
||||
assert.equal(visibleText.trim(), MARKER, "visible assistant bubble should equal marker");
|
||||
assert(!visibleText.includes("thinking_delta"), "provider thinking event name must not be visible");
|
||||
assert(!visibleText.includes("我们被问到"), "provider reasoning text must not leak into final visible reply");
|
||||
console.log(" ✅ real Pi RPC stream rendered in UI without visible reasoning leakage");
|
||||
const sessionEvidence = readSessionEvidence(activeSession.piSessionDir);
|
||||
assert(sessionEvidence.includes('"toolName":"mnote_current_page_read"'), "session should record mnote_current_page_read");
|
||||
assert(sessionEvidence.includes('"transport":"pi-rust-native-fs"'), "session should record Pi Rust native fs transport");
|
||||
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(" ✅ real browser conversation used mnote_current_page_read via pi-rust-native-fs");
|
||||
|
||||
fs.mkdirSync(path.dirname(SCREENSHOT), { recursive: true });
|
||||
await page.screenshot({ path: SCREENSHOT, fullPage: false });
|
||||
|
||||
@@ -17,6 +17,10 @@ const files = {
|
||||
webShell: path.join(repoRoot, 'rust/crates/mnote-web/src/routes/web_shell.rs'),
|
||||
gateway: path.join(repoRoot, 'rust/crates/mnote-web/src/routes/gateway.rs'),
|
||||
app: path.join(repoRoot, 'rust/crates/mnote-web/src/app.rs'),
|
||||
mnotePiPackage: path.join(repoRoot, 'packages/pi-mnote/package.json'),
|
||||
mnotePiExtension: path.join(repoRoot, 'packages/pi-mnote/extensions/mnote-bridge.ts'),
|
||||
mnotePiMcpExtension: path.join(repoRoot, 'packages/pi-mnote/extensions/mnote-mcp/index.ts'),
|
||||
mnotePiMcpClient: path.join(repoRoot, 'packages/pi-mnote/extensions/mnote-mcp/client.mjs'),
|
||||
};
|
||||
|
||||
function readFile(p) {
|
||||
@@ -29,6 +33,10 @@ const routesMod = readFile(files.mod);
|
||||
const webShell = readFile(files.webShell);
|
||||
const gateway = readFile(files.gateway);
|
||||
const app = readFile(files.app);
|
||||
const mnotePiPackage = readFile(files.mnotePiPackage);
|
||||
const mnotePiExtension = readFile(files.mnotePiExtension);
|
||||
const mnotePiMcpExtension = readFile(files.mnotePiMcpExtension);
|
||||
const mnotePiMcpClient = readFile(files.mnotePiMcpClient);
|
||||
|
||||
const checks = [
|
||||
// === Runtime JS: existence ===
|
||||
@@ -69,15 +77,15 @@ const checks = [
|
||||
['runtime renders tool calls', runtime.includes('toolCalls')],
|
||||
['runtime renders citations', runtime.includes('citations')],
|
||||
['runtime renders diff summary', runtime.includes('diffSummary')],
|
||||
['runtime has start button', runtime.includes('btn-start')],
|
||||
['runtime starts through drawer open/send instead of manual start button', !runtime.includes('btn-start')],
|
||||
['runtime has send button', runtime.includes('btn-send')],
|
||||
['runtime has abort button', runtime.includes('btn-abort')],
|
||||
['runtime has clear button', runtime.includes('btn-clear')],
|
||||
|
||||
// === Runtime JS: Pi builtin disabled ===
|
||||
['runtime handles disabledPiBuiltinTools from status/start', runtime.includes('disabledBuiltinTools') || runtime.includes('disabledPiBuiltinTools')],
|
||||
['runtime has Pi builtin disabled UI indicator', runtime.includes('builtin-disabled')],
|
||||
['runtime mentions bash/read/write/edit disabled', runtime.includes('bash') && runtime.includes('read') && runtime.includes('write') && runtime.includes('edit')],
|
||||
// === Runtime JS: Pi builtins managed by MNote policy ===
|
||||
['runtime handles managedPiBuiltinTools from status/start', runtime.includes('managedBuiltinTools') || runtime.includes('managedPiBuiltinTools')],
|
||||
['runtime has Pi builtin managed UI indicator', runtime.includes('builtin-managed')],
|
||||
['runtime mentions managed bash/read/write/edit tools', runtime.includes('bash') && runtime.includes('read') && runtime.includes('write') && runtime.includes('edit')],
|
||||
|
||||
// === Runtime JS: tool receipt ===
|
||||
['runtime references receipt', runtime.includes('receipt') || runtime.includes('Receipt')],
|
||||
@@ -92,6 +100,13 @@ const checks = [
|
||||
['runtime has no OpenHub provider tab inside Pi Lab', !runtime.includes('data-page-ai-pi-lab-openhub-tab')],
|
||||
['runtime has context strip chips', runtime.includes('data-page-ai-pi-lab-context-strip') && runtime.includes('data-page-ai-pi-lab-current-page') && runtime.includes('data-page-ai-pi-lab-allowed-roots') && runtime.includes('data-page-ai-pi-lab-lightrag')],
|
||||
['runtime collapses secondary context/settings like OpenHub chrome', runtime.includes('<details class="wolai-page-ai-pi-lab-settings"') && runtime.includes('<details class="wolai-page-ai-pi-lab-context-strip"')],
|
||||
['runtime settings gear opens AI management page', runtime.includes('data-page-ai-pi-lab-open-settings title="AI 管理"') && runtime.includes("window.location.assign('/user/ai#ai-admin-access')")],
|
||||
['runtime keeps only meaningful top commandbar buttons', runtime.includes('data-page-ai-pi-lab-new') && runtime.includes('data-page-ai-pi-lab-history') && runtime.includes('data-page-ai-pi-lab-btn-clear') && runtime.includes('data-page-ai-pi-lab-open-settings')],
|
||||
['runtime removes no-op top commandbar buttons', !runtime.includes('data-page-ai-pi-lab-open title=') && !runtime.includes('data-page-ai-pi-lab-toggle-artifacts') && !runtime.includes('data-page-ai-pi-lab-clock') && !runtime.includes('data-page-ai-pi-lab-notify')],
|
||||
['runtime has OpenHub-style left history drawer markers', runtime.includes('data-page-ai-pi-lab-history-layer') && runtime.includes('wolai-page-ai-pi-lab-history-scrim') && runtime.includes('历史对话')],
|
||||
['runtime history drawer supports refresh/delete/export/clear', runtime.includes('data-page-ai-pi-lab-history-refresh') && runtime.includes('data-page-ai-pi-lab-history-delete') && runtime.includes('data-page-ai-pi-lab-history-export') && runtime.includes('data-page-ai-pi-lab-history-clear')],
|
||||
['runtime does not expose manual Pi runtime start button', !runtime.includes('data-page-ai-pi-lab-btn-start') && !runtime.includes('启动 Pi runtime') && !runtime.includes('预启动 Pi 会话')],
|
||||
['runtime auto starts Pi session when drawer opens', runtime.includes('function showPiLab') && runtime.includes('checkStatus().then(function ()') && runtime.includes('return startRuntime();')],
|
||||
['runtime has native model controls', runtime.includes('data-page-ai-pi-lab-model-provider') && runtime.includes('data-page-ai-pi-lab-model-id') && runtime.includes('data-page-ai-pi-lab-model-custom')],
|
||||
['runtime collapses secondary right rail sections', runtime.includes('<details class="wolai-page-ai-pi-lab-rail-section"') && runtime.includes('data-page-ai-pi-lab-receipts-section')],
|
||||
['runtime has diagnostics collapsed by default', runtime.includes('<details class="wolai-page-ai-pi-lab-diagnostics"') && !runtime.includes('<details class="wolai-page-ai-pi-lab-diagnostics" open')],
|
||||
@@ -132,13 +147,15 @@ const checks = [
|
||||
['route returns schema mnote.page_ai_pi.abort.v1', route.includes('mnote.page_ai_pi.abort.v1')],
|
||||
['route has event schema PI_LAB_SCHEMA_EVENT', route.includes('PI_LAB_SCHEMA_EVENT')],
|
||||
['route has receipt schema PI_LAB_SCHEMA_RECEIPT', route.includes('PI_LAB_SCHEMA_RECEIPT')],
|
||||
['route disables Pi builtin tools', route.includes('disabledPiBuiltinTools') || route.includes('--no-builtin-tools')],
|
||||
['route exposes managed Pi builtin tools', route.includes('managedPiBuiltinTools') && route.includes('PI_LAB_MANAGED_BUILTIN_TOOLS')],
|
||||
['route has receipt storage policy', route.includes('receiptStorage') || route.includes('PI_LAB_SCHEMA_RECEIPT')],
|
||||
['route has session dir policy', route.includes('managedPiSessionDirPolicy')],
|
||||
['route enforces allowed roots', route.includes('active_allowed_roots') || route.includes('allowed_roots')],
|
||||
['route blocks path escape', route.includes('path_escape') || route.includes('path_is_inside')],
|
||||
['route has default model provider constant PI_LAB_DEFAULT_MODEL_PROVIDER=omniroute', route.includes('PI_LAB_DEFAULT_MODEL_PROVIDER') && route.includes('omniroute')],
|
||||
['route has default model id constant PI_LAB_DEFAULT_MODEL_ID=freefirst', route.includes('PI_LAB_DEFAULT_MODEL_ID') && route.includes('freefirst')],
|
||||
['route defaults to Pi Rust runtime implementation', route.includes('PI_LAB_RUNTIME_IMPL_RUST') && route.includes('"pi-rust"') && route.includes('MNOTE_PAGE_AI_PI_RUST_BIN')],
|
||||
['route keeps TS Pi as explicit fallback only', route.includes('PI_LAB_RUNTIME_IMPL_TS') && route.includes('MNOTE_PAGE_AI_PI_TS_BIN')],
|
||||
['route rejects arbitrary cookie_header auth bypass', route.includes('page_ai_pi_lab_invalid_session_cookie') && !route.includes('context.auth.cookie_header.is_some() {\n return Ok') ],
|
||||
['route stores Pi Lab session owner id', route.includes('mnote_user_id') && route.includes('ensure_session_owner')],
|
||||
['route validates session owner on send/abort/tool/events', route.includes('get_session_for_context')],
|
||||
@@ -146,15 +163,43 @@ const checks = [
|
||||
['route tool_receipt.write is not a silent no-op', route.includes('"requestedReceipt"') && route.includes('execute_tool 统一写入')],
|
||||
['route has bridge token header and non-serialized session token', route.includes('HEADER_PI_LAB_BRIDGE_TOKEN') && route.includes('x-mnote-pi-lab-bridge-token') && route.includes('skip_serializing')],
|
||||
['route generates bridge token from OS randomness', route.includes('generate_bridge_token') && route.includes('/dev/urandom') && !route.includes('bridge_token: generate_id("pi_bridge")')],
|
||||
['route does not write bridge token literal into extension file', route.includes('MNOTE_PI_LAB_BRIDGE_TOKEN') && route.includes('process.env.MNOTE_PI_LAB_BRIDGE_TOKEN') && !route.includes('const BRIDGE_TOKEN = {bridge_token}')],
|
||||
['route does not write obsolete private MCP bridge config', !route.includes('write_session_mcp_bridge_config') && !route.includes('mnote.pi.mcp-bridge.v1') && !route.includes('mnote-bridge.json')],
|
||||
['route never writes bridge token into generated extension source', !route.includes('const BRIDGE_TOKEN = {bridge_token}')],
|
||||
['route rejects bridge calls for non-running sessions', route.includes('page_ai_pi_lab_bridge_session_not_running') && route.includes('PiLabSessionStatus::RuntimeRunning | PiLabSessionStatus::TurnRunning')],
|
||||
['route generates Pi extension with registered MNote tools', route.includes('ensure_session_tool_bridge_extension') && route.includes('pi.registerTool') && route.includes('mnote_current_page_read')],
|
||||
['route starts Pi with explicit extension bridge', route.includes('--extension') && route.includes('mnoteToolBridgeExtension')],
|
||||
['route loads official MNote Pi package extension', route.includes('mnote_pi_extension_path') && route.includes('packages/pi-mnote/extensions/mnote-bridge.ts')],
|
||||
['route starts Pi with explicit MNote extension bridge', route.includes('--extension') && route.includes('mnotePiExtension')],
|
||||
['route starts Pi with MNote tool allowlist', route.includes('--tools') && route.includes('mnoteToolNames') && route.includes('pi_lab_extension_tool_names')],
|
||||
['route keeps Pi builtin tools disabled while loading extension', route.includes('--no-builtin-tools') && route.includes('disabledBuiltinTools')],
|
||||
['route passes MNote tool manifest to extension env', route.includes('MNOTE_PI_BRIDGE_TOOLS') && route.includes('PI_MNOTE_BRIDGE_TOOLS') && route.includes('mnote_pi_tool_manifest')],
|
||||
['route writes dynamic Pi Rust MNote context file', route.includes('mnote.pi.context.v1') && route.includes('PI_MNOTE_CONTEXT_FILE') && route.includes('write_pi_mnote_context_snapshot')],
|
||||
['route injects dynamic Pi Rust input context', route.includes('MNOTE_PI_CONTEXT_V1') && route.includes('pi_mnote_input_context_prefix')],
|
||||
['route disables Pi builtins by default unless external permission extension is opt-in', route.includes('pi_lab_enabled_builtin_tools') && route.includes('MNOTE_PAGE_AI_PI_ALLOW_EXTERNAL_EXTENSIONS') && route.includes('configuredPiExtensionSources') && route.includes('pi_lab_runtime_extension_sources')],
|
||||
['route exposes Rust Pi runtime diagnostics', route.includes('hashline_edit') && route.includes('runtimeImplementation') && route.includes('runtimeBinary') && route.includes('runtimeAvailable') && route.includes('runtimeInstallHint') && route.includes('runtimeError')],
|
||||
['runtime send path auto-starts Pi Rust instead of dead-end not-ready toast', runtime.includes('Pi Rust 正在启动,启动后自动发送') && runtime.includes('piLabStartPromise') && !runtime.includes('Pi 会话尚未就绪,请重新打开 Pi 面板或稍后重试')],
|
||||
['route applies TTL and rate limits', route.includes('PI_LAB_SESSION_TTL_MS') && route.includes('PI_LAB_RATE_LIMITS') && route.includes('check_rate_limit')],
|
||||
['route cleans expired session dirs and rate buckets', route.includes('fs::remove_dir_all') && route.includes('buckets.retain')],
|
||||
|
||||
// === MNote Pi package checks ===
|
||||
['@mnote/pi package manifest exists', mnotePiPackage.includes('"name": "@mnote/pi"') && mnotePiPackage.includes('"pi"')],
|
||||
['@mnote/pi package declares extension', mnotePiPackage.includes('./extensions/mnote-bridge.ts')],
|
||||
['@mnote/pi package declares Pi Rust MCP extension', mnotePiPackage.includes('./extensions/mnote-mcp/index.ts')],
|
||||
['@mnote/pi extension registers MNote tools', mnotePiExtension.includes('pi.registerTool') && mnotePiExtension.includes('mnote_current_page_read')],
|
||||
['@mnote/pi extension keeps legacy MNote bridge API fallback', mnotePiExtension.includes('/api/page-ai/pi/tool-call-bridge') && mnotePiExtension.includes('x-mnote-pi-lab-bridge-token')],
|
||||
['@mnote/pi extension uses Pi Rust native current-page read', mnotePiExtension.includes('pi-rust-native-fs') && mnotePiExtension.includes('PI_MNOTE_CONTEXT_FILE') && mnotePiExtension.includes('fs.readFileSync')],
|
||||
['@mnote/pi extension consumes Pi Rust input context anywhere after skill expansion', mnotePiExtension.includes('pi.on?.("input"') && mnotePiExtension.includes('MNOTE_PI_CONTEXT_V1') && mnotePiExtension.includes('text.indexOf(CONTEXT_PREFIX)') && mnotePiExtension.includes('action: "transform"')],
|
||||
['route preserves slash skill command before hidden input context', route.includes('pi_lab_command_message_for_session') && route.includes('message.starts_with("/skill:")') && route.includes('format!("{command}\\n{input_context_prefix}{effective_args}")')],
|
||||
['@mnote/pi extension supports LightRAG tools', mnotePiExtension.includes('mnote_knowledge_rag_query') && mnotePiExtension.includes('mnote_knowledge_rag_section_context')],
|
||||
['@mnote/pi MCP extension registers mcp tool', mnotePiMcpExtension.includes('registerTool') && mnotePiMcpExtension.includes('name: "mcp"')],
|
||||
['@mnote/pi MCP extension uses official synchronous child_process bridge', mnotePiMcpExtension.includes('execFileSync') && mnotePiMcpExtension.includes('client.mjs') && mnotePiMcpExtension.includes('transport: "pi-rust-sync-client"') && !mnotePiMcpExtension.includes('/api/page-ai/pi/mcp-call-bridge') && !mnotePiMcpExtension.includes('fetch(')],
|
||||
['@mnote/pi MCP extension does not depend on private session token config', !mnotePiMcpExtension.includes('mnote-bridge.json') && !mnotePiMcpExtension.includes('BRIDGE_TOKEN') && !mnotePiMcpExtension.includes('SESSION_ID')],
|
||||
['@mnote/pi MCP client accepts inline sync request and adjacent session config', mnotePiMcpClient.includes('--request-json') && mnotePiMcpClient.includes('resolve(extDir, ".pi", "mcp.json")')],
|
||||
['@mnote/pi MCP client implements protocol handshake and tool calls', mnotePiMcpClient.includes('"initialize"') && mnotePiMcpClient.includes('"tools/list"') && mnotePiMcpClient.includes('"tools/call"')],
|
||||
['@mnote/pi MCP client supports stdio and streamable HTTP', mnotePiMcpClient.includes('spawn(') && mnotePiMcpClient.includes('text/event-stream')],
|
||||
['route loads built-in Pi Rust MCP extension', route.includes('mnote_pi_mcp_extension_path') && route.includes('packages/pi-mnote/extensions/mnote-mcp/index.ts')],
|
||||
['route stages per-session MCP config next to built-in extension', route.includes('stage_project_mcp_config_for_extension') && route.includes('extension_dir.join(".pi")') && route.includes('mcp.json')],
|
||||
['route does not pass unsupported Pi Rust --mcp-config flag', !route.includes('.arg("--mcp-config")')],
|
||||
['route does not globally disable Pi Rust extension capability policy', !route.includes('command.env("PI_EXTENSION_ALLOW_DANGEROUS"') && !route.includes('pi_tool_names.push("bash".into())')],
|
||||
['route narrowly enables Pi Rust sync exec only for configured MCP sessions', route.includes('if pi_lab_mcp_enabled(&session)') && route.includes('command.env("PIJS_ALLOW_UNSAFE_SYNC_EXEC", "1")')],
|
||||
|
||||
// === mod.rs checks ===
|
||||
['mod.rs declares page_ai_pi module', routesMod.includes('mod page_ai_pi;')],
|
||||
['mod.rs mounts pi status route', routesMod.includes('/api/page-ai/pi/status')],
|
||||
@@ -162,6 +207,7 @@ const checks = [
|
||||
['mod.rs mounts pi send route', routesMod.includes('/api/page-ai/pi/send')],
|
||||
['mod.rs mounts pi abort route', routesMod.includes('/api/page-ai/pi/abort')],
|
||||
['mod.rs mounts pi events route', routesMod.includes('/api/page-ai/pi/events')],
|
||||
['mod.rs mounts pi session history delete and clear routes', routesMod.includes('get(page_ai_pi::list_sessions).delete(page_ai_pi::clear_sessions)') && routesMod.includes('.delete(page_ai_pi::delete_session_history)')],
|
||||
['mod.rs mounts pi tool-call route', routesMod.includes('/api/page-ai/pi/tool-call')],
|
||||
['mod.rs mounts pi internal tool-call-bridge route', routesMod.includes('/api/page-ai/pi/tool-call-bridge')],
|
||||
['mod.rs mounts pi bootstrap route (legacy)', routesMod.includes('/api/page-ai/pi/bootstrap')],
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env node
|
||||
"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_UI_BASE_URL || "http://127.0.0.1:3000";
|
||||
const STAMP = Date.now();
|
||||
const OUT = process.env.MNOTE_PI_UI_COMPLETION_OUT || path.join(os.tmpdir(), `mnote-pi-ui-completion-${STAMP}`);
|
||||
const TIMEOUT = Number.parseInt(process.env.UI_TIMEOUT_MS || "180000", 10);
|
||||
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) {
|
||||
await page.goto(`${BASE}/auth`, { waitUntil: "commit", timeout: TIMEOUT });
|
||||
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
|
||||
await quickLoginButton.waitFor({ state: "visible", timeout: TIMEOUT });
|
||||
await Promise.all([
|
||||
page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: TIMEOUT }).catch(() => null),
|
||||
quickLoginButton.click(),
|
||||
]);
|
||||
}
|
||||
|
||||
async function startPi(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(() => document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent?.includes("ready"), null, {
|
||||
timeout: TIMEOUT,
|
||||
});
|
||||
const status = await page.request.get(`${BASE}/api/page-ai/pi/status`);
|
||||
assert(status.ok(), `Pi status failed: ${status.status()}`);
|
||||
return status.json();
|
||||
}
|
||||
|
||||
async function emit(page, payload) {
|
||||
await page.evaluate((eventPayload) => {
|
||||
window.__mnotePiLabTest.emitRpcEvent(eventPayload);
|
||||
}, payload);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
mkdirp(OUT);
|
||||
const browser = await chromium.launch({
|
||||
headless: process.env.MNOTE_PI_UI_COMPLETION_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 = {
|
||||
base: BASE,
|
||||
outputDir: OUT,
|
||||
screenshots: {},
|
||||
checks: {},
|
||||
consoleMessages,
|
||||
};
|
||||
|
||||
try {
|
||||
await quickLogin(page);
|
||||
const startJson = await startPi(page);
|
||||
const session = startJson.session || startJson.session || {};
|
||||
const sessionId = session.sessionId || startJson.sessionId || (startJson.session && startJson.session.sessionId);
|
||||
const sessionRootUri = session.rootUri || (startJson.session && startJson.session.rootUri) || "file:///tmp/mnote-pi-ui-completion";
|
||||
result.sessionId = sessionId;
|
||||
assert(sessionId, "Pi sessionId missing");
|
||||
await page.waitForFunction(() => !!window.__mnotePiLabTest, null, { timeout: TIMEOUT });
|
||||
|
||||
result.checks.composerQuickButtonCount = await page.locator(".wolai-page-ai-pi-lab-composer-bar [data-page-ai-pi-lab-quick]").count();
|
||||
result.checks.composerBarCount = await page.locator(".wolai-page-ai-pi-lab-composer-bar").count();
|
||||
result.checks.sendModeButtonCount = await page.locator("[data-page-ai-pi-lab-send-mode]").count();
|
||||
result.checks.sendInsideInputWrap = await page.locator(".wolai-page-ai-pi-lab-input-wrap [data-page-ai-pi-lab-btn-send]").count() === 1;
|
||||
result.checks.bottomQuickKinds = await page.locator(".wolai-page-ai-pi-lab-modebar [data-page-ai-pi-lab-quick]").evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-page-ai-pi-lab-quick")));
|
||||
result.checks.actionMenuToggleCount = await page.locator("[data-page-ai-pi-lab-action-menu-toggle]").count();
|
||||
result.checks.thinkingControlVisible = await page.locator("[data-page-ai-pi-lab-thinking]").isVisible();
|
||||
result.checks.permissionControlVisible = await page.locator("[data-page-ai-pi-lab-permission]").isVisible();
|
||||
assert.equal(result.checks.composerQuickButtonCount, 0, "输入框下方不应再重复显示当前页/选区/LightRAG");
|
||||
assert.equal(result.checks.composerBarCount, 0, "发送按钮不应单独占用一整行 composer bar");
|
||||
assert.equal(result.checks.sendModeButtonCount, 0, "流式插队模式不应作为常驻工具栏按钮");
|
||||
assert(result.checks.sendInsideInputWrap, "发送按钮应收进输入框区域");
|
||||
assert.deepEqual(result.checks.bottomQuickKinds, ["read-page", "selection", "rag"], "底部工具栏应保留 Pi 实际上下文工具");
|
||||
assert.equal(result.checks.actionMenuToggleCount, 1, "+ 菜单应作为输入区操作入口");
|
||||
assert(result.checks.thinkingControlVisible, "Pi 官方 thinking level 应在输入区可见");
|
||||
assert(result.checks.permissionControlVisible, "MNote 权限/审批状态应在输入区可见");
|
||||
|
||||
await page.locator("[data-page-ai-pi-lab-action-menu-toggle]").click();
|
||||
const actionMenu = page.locator("[data-page-ai-pi-lab-action-menu]");
|
||||
await actionMenu.waitFor({ state: "visible", timeout: TIMEOUT });
|
||||
result.checks.actionMenuVisible = await actionMenu.isVisible();
|
||||
result.checks.actionMenuItems = await actionMenu.locator("[data-page-ai-pi-lab-menu-action]").evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-page-ai-pi-lab-menu-action")));
|
||||
result.checks.planModeAvailable = await actionMenu.locator('[data-page-ai-pi-lab-menu-action="plan-mode"]:not(:disabled)', { hasText: "计划评审" }).count() === 1;
|
||||
await page.screenshot({ path: path.join(OUT, "00a-plus-permission-thinking-menu.png"), fullPage: false });
|
||||
result.screenshots.plusPermissionThinkingMenu = path.join(OUT, "00a-plus-permission-thinking-menu.png");
|
||||
assert(result.checks.actionMenuVisible, "+ 菜单应能打开");
|
||||
assert(result.checks.actionMenuItems.includes("directory-permission"), "+ 菜单应包含目录权限入口");
|
||||
assert(result.checks.actionMenuItems.includes("send-steer"), "+ 菜单应包含执行中引导发送入口");
|
||||
assert(result.checks.actionMenuItems.includes("send-followup"), "+ 菜单应包含执行中排队追问入口");
|
||||
assert(result.checks.actionMenuItems.includes("plan-mode"), "+ 菜单应包含 Pi Rust plan-mode 计划评审入口");
|
||||
assert(result.checks.planModeAvailable, "计划评审应由 Pi Rust 官方 plan-mode 扩展接管并可用");
|
||||
await page.locator("[data-page-ai-pi-lab-action-menu-toggle]").click();
|
||||
|
||||
await emit(page, {
|
||||
type: "agent_end",
|
||||
messages: [{ role: "assistant", content: [{ type: "text", text: "UI_POLISH_MARKDOWN_ACTION" }] }],
|
||||
});
|
||||
const assistantBubble = page.locator(".wolai-page-ai-pi-lab-message[data-role='assistant'] .wolai-page-ai-pi-lab-bubble").last();
|
||||
const copyActions = assistantBubble.locator(".wolai-page-ai-pi-lab-message-actions");
|
||||
await copyActions.waitFor({ state: "attached", timeout: TIMEOUT });
|
||||
result.checks.copyActionOpacityBeforeHover = await copyActions.evaluate((node) => getComputedStyle(node).opacity);
|
||||
result.checks.followupActionCount = await page.locator("[data-page-ai-pi-followup-controls]").count();
|
||||
await assistantBubble.hover();
|
||||
await page.waitForFunction((selector) => getComputedStyle(document.querySelector(selector)).opacity === "1", ".wolai-page-ai-pi-lab-message[data-role='assistant']:last-of-type .wolai-page-ai-pi-lab-message-actions", { timeout: TIMEOUT }).catch(() => null);
|
||||
result.checks.copyActionOpacityAfterHover = await copyActions.evaluate((node) => getComputedStyle(node).opacity);
|
||||
await page.screenshot({ path: path.join(OUT, "00-hover-copy-markdown.png"), fullPage: false });
|
||||
result.screenshots.hoverCopyMarkdown = path.join(OUT, "00-hover-copy-markdown.png");
|
||||
assert.equal(result.checks.copyActionOpacityBeforeHover, "0", "复制 Markdown 默认应隐藏");
|
||||
assert.equal(result.checks.copyActionOpacityAfterHover, "1", "复制 Markdown hover 时应显示");
|
||||
assert.equal(result.checks.followupActionCount, 0, "页末继续追问/调整回答不应默认显示");
|
||||
|
||||
await emit(page, {
|
||||
type: "extension_ui_request",
|
||||
id: "approval-dialog-smoke",
|
||||
method: "confirm",
|
||||
title: "审批 Pi 工具调用",
|
||||
message: "MNote Codex rescue\nmnote.codex_rescue.request",
|
||||
mnoteApproval: { approvalId: "approval-dialog-smoke", toolName: "mnote.codex_rescue.request", paramsHash: "smoke" },
|
||||
});
|
||||
const approvalDialog = page.locator("[data-page-ai-pi-lab-ui-dialog='confirm']");
|
||||
await approvalDialog.waitFor({ state: "visible", timeout: TIMEOUT });
|
||||
result.checks.approvalDialogVisible = await approvalDialog.isVisible();
|
||||
await page.screenshot({ path: path.join(OUT, "00b-approval-dialog.png"), fullPage: false });
|
||||
result.screenshots.approvalDialog = path.join(OUT, "00b-approval-dialog.png");
|
||||
assert(result.checks.approvalDialogVisible, "Pi 工具审批应显示在 Pi 界面内");
|
||||
await approvalDialog.locator("[data-page-ai-pi-lab-ui-cancel]").click();
|
||||
|
||||
await emit(page, {
|
||||
type: "extension_ui_request",
|
||||
id: "ask-user-bottom-panel-smoke",
|
||||
method: "ask_user",
|
||||
title: "选择设置页方案",
|
||||
message: "这个请求模拟 Pi ask_user 的输入区上方交互。",
|
||||
questions: [{
|
||||
header: "布局",
|
||||
tab: "layout",
|
||||
prompt: "选择 Pi 输入区的交互位置。",
|
||||
options: [
|
||||
{ label: "底部面板", description: "在输入框上方,不遮住对话。" },
|
||||
{ label: "居中弹窗", description: "覆盖 transcript。" },
|
||||
],
|
||||
allowSkip: false,
|
||||
}],
|
||||
});
|
||||
const askPanel = page.locator("[data-page-ai-pi-lab-ui-dialog='ask_user']");
|
||||
await askPanel.waitFor({ state: "visible", timeout: TIMEOUT });
|
||||
const askPanelBox = await askPanel.boundingBox();
|
||||
const inputWrapBox = await page.locator(".wolai-page-ai-pi-lab-input-wrap").boundingBox();
|
||||
const composerContainsAskPanel = await page.locator(".wolai-page-ai-pi-lab-composer [data-page-ai-pi-lab-ui-dialog='ask_user']").count();
|
||||
result.checks.askUserBottomPanelVisible = await askPanel.isVisible();
|
||||
result.checks.askUserPanelInComposer = composerContainsAskPanel > 0;
|
||||
result.checks.askUserPanelAboveInput = !!askPanelBox && !!inputWrapBox && askPanelBox.y + askPanelBox.height <= inputWrapBox.y + 1;
|
||||
await page.screenshot({ path: path.join(OUT, "00c-ask-user-bottom-panel.png"), fullPage: false });
|
||||
result.screenshots.askUserBottomPanel = path.join(OUT, "00c-ask-user-bottom-panel.png");
|
||||
assert(result.checks.askUserBottomPanelVisible, "ask_user 应显示在 Pi 界面内");
|
||||
assert(result.checks.askUserPanelInComposer, "ask_user 应挂在 composer 内,而不是页面级弹窗");
|
||||
assert(result.checks.askUserPanelAboveInput, "ask_user 面板应贴在输入框上方且不遮住输入框");
|
||||
await askPanel.locator("[data-page-ai-pi-lab-ui-cancel]").click();
|
||||
|
||||
await page.locator("[data-page-ai-pi-lab-history]").click();
|
||||
const historyRow = page.locator(`[data-page-ai-pi-lab-history-row="${sessionId}"]`);
|
||||
await historyRow.waitFor({ state: "visible", timeout: TIMEOUT });
|
||||
result.checks.hasHistoryOpen = await historyRow.locator(`[data-page-ai-pi-lab-history-session="${sessionId}"]`).count() > 0;
|
||||
result.checks.hasHistoryRename = await historyRow.locator(`[data-page-ai-pi-lab-history-rename="${sessionId}"]`).isVisible();
|
||||
result.checks.hasHistoryFork = await historyRow.locator(`[data-page-ai-pi-lab-history-fork="${sessionId}"]`).isVisible();
|
||||
result.checks.hasHistoryExport = await historyRow.locator(`[data-page-ai-pi-lab-history-export="${sessionId}"]`).isVisible();
|
||||
result.checks.hasHistoryDelete = await historyRow.locator(`[data-page-ai-pi-lab-history-delete="${sessionId}"]`).isVisible();
|
||||
result.checks.hasHistoryRefresh = await page.locator("[data-page-ai-pi-lab-history-refresh]").isVisible();
|
||||
result.checks.hasHistoryClear = await page.locator("[data-page-ai-pi-lab-history-clear]").isVisible();
|
||||
result.checks.historyLayerVisible = await page.locator("[data-page-ai-pi-lab-history-layer]").isVisible();
|
||||
const historyBox = await page.locator("[data-page-ai-pi-lab-history-panel]").boundingBox();
|
||||
const drawerBox = await page.locator(".wolai-page-ai-pi-lab-drawer").boundingBox();
|
||||
result.checks.historyDrawerFromLeft = !!historyBox && !!drawerBox && Math.abs(historyBox.x - drawerBox.x) <= 2 && historyBox.height >= drawerBox.height - 4;
|
||||
result.checks.commandbarIconCount = await page.locator(".wolai-page-ai-pi-lab-commandbar svg").count();
|
||||
result.checks.commandbarRemovedNoopButtons = await page.locator("[data-page-ai-pi-lab-open], [data-page-ai-pi-lab-toggle-artifacts], [data-page-ai-pi-lab-clock], [data-page-ai-pi-lab-notify]").count() === 0;
|
||||
await page.screenshot({ path: path.join(OUT, "01-history-session-actions.png"), fullPage: false });
|
||||
result.screenshots.historyActions = path.join(OUT, "01-history-session-actions.png");
|
||||
assert(result.checks.hasHistoryOpen, "history 缺少打开入口");
|
||||
assert(result.checks.hasHistoryRename, "history 缺少重命名入口");
|
||||
assert(result.checks.hasHistoryFork, "history 缺少 fork 入口");
|
||||
assert(result.checks.hasHistoryExport, "history 缺少导出入口");
|
||||
assert(result.checks.hasHistoryDelete, "history 缺少删除入口");
|
||||
assert(result.checks.hasHistoryRefresh, "history 缺少刷新入口");
|
||||
assert(result.checks.hasHistoryClear, "history 缺少清空入口");
|
||||
assert(result.checks.historyLayerVisible, "history 应以左侧抽屉层打开");
|
||||
assert(result.checks.historyDrawerFromLeft, "history 应从 Pi 面板左侧弹出");
|
||||
assert(result.checks.commandbarRemovedNoopButtons, "顶部工具栏不应保留无实际意义按钮");
|
||||
assert(result.checks.commandbarIconCount <= 5, "顶部工具栏应只保留少量有效 SVG 图标");
|
||||
|
||||
await historyRow.locator(`[data-page-ai-pi-lab-history-session="${sessionId}"]`).first().click();
|
||||
await page.locator("[data-page-ai-pi-lab-replay-banner]").waitFor({ state: "visible", timeout: TIMEOUT });
|
||||
result.checks.historyReplayBannerVisible = await page.locator("[data-page-ai-pi-lab-replay-banner]").isVisible();
|
||||
result.checks.historyReplayInputDisabled = await page.locator("[data-page-ai-pi-lab-input]").isDisabled();
|
||||
result.checks.historyReplaySendDisabled = await page.locator("[data-page-ai-pi-lab-btn-send]").isDisabled();
|
||||
await page.screenshot({ path: path.join(OUT, "01b-history-readonly-replay.png"), fullPage: false });
|
||||
result.screenshots.historyReplay = path.join(OUT, "01b-history-readonly-replay.png");
|
||||
assert(result.checks.historyReplayBannerVisible, "打开历史后应显示只读 replay 提示");
|
||||
assert(result.checks.historyReplayInputDisabled, "打开历史后输入框应只读");
|
||||
assert(result.checks.historyReplaySendDisabled, "打开历史后发送按钮应禁用");
|
||||
await page.locator("[data-page-ai-pi-lab-new]").click();
|
||||
await page.locator("[data-page-ai-pi-lab-replay-banner]").waitFor({ state: "hidden", timeout: TIMEOUT });
|
||||
|
||||
const unapproved = await page.request.fetch(`${BASE}/api/page-ai/pi/tool-call`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", accept: "application/json" },
|
||||
data: {
|
||||
sessionId,
|
||||
toolName: "mnote.local_file.patch",
|
||||
params: {
|
||||
rootUri: sessionRootUri,
|
||||
path: "note.md",
|
||||
operations: [{ op: "append", content: "approval smoke" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const unapprovedJson = await unapproved.json();
|
||||
result.checks.unapprovedToolOk = unapprovedJson.ok;
|
||||
result.checks.unapprovedToolCode = unapprovedJson.result && unapprovedJson.result.code;
|
||||
result.checks.unapprovedApprovalRequired = unapprovedJson.approvalRequired === true;
|
||||
assert(unapproved.ok(), `tool-call request failed: ${unapproved.status()}`);
|
||||
assert.equal(unapprovedJson.ok, false, "未审批高风险工具不应成功");
|
||||
assert.equal(unapprovedJson.result && unapprovedJson.result.code, "page_ai_pi_lab_tool_approval_required");
|
||||
assert.equal(unapprovedJson.approvalRequired, true);
|
||||
|
||||
await emit(page, {
|
||||
type: "tool_execution_start",
|
||||
toolCallId: "approval-smoke",
|
||||
toolName: "mnote.local_file.read",
|
||||
args: { path: "note.md" },
|
||||
});
|
||||
await page.evaluate(() => {
|
||||
window.__mnotePiLabTest.emitToolCall({
|
||||
toolCallId: "approval-smoke",
|
||||
toolName: "mnote.local_file.read",
|
||||
allowed: false,
|
||||
denyReason: "page_ai_pi_lab_tool_approval_required: Pi 工具 mnote.local_file.read 需要用户审批",
|
||||
approvalRequired: true,
|
||||
approvalConfirmed: false,
|
||||
toolPolicy: "ask",
|
||||
});
|
||||
});
|
||||
await page.locator("[data-page-ai-pi-lab-body]").evaluate((node) => { node.setAttribute("data-rail-open", "true"); });
|
||||
await page.locator("[data-page-ai-pi-lab-receipts-section]").evaluate((node) => { node.open = true; });
|
||||
await page.locator("[data-page-ai-pi-lab-receipts]").filter({ hasText: "approval required" }).waitFor({ state: "visible", timeout: TIMEOUT });
|
||||
result.checks.hasApprovalReceipt = await page.locator("[data-page-ai-pi-lab-receipts]").filter({ hasText: "approval required" }).count() > 0;
|
||||
await page.screenshot({ path: path.join(OUT, "02-approval-audit-ui.png"), fullPage: false });
|
||||
result.screenshots.approvalAudit = path.join(OUT, "02-approval-audit-ui.png");
|
||||
assert(result.checks.hasApprovalReceipt, "approval receipt 未显示");
|
||||
|
||||
await emit(page, {
|
||||
type: "queue_update",
|
||||
steering: [{ id: "steer-1", text: "调整语气" }],
|
||||
followUp: [{ id: "follow-1", text: "继续回答" }],
|
||||
});
|
||||
await emit(page, {
|
||||
type: "message_update",
|
||||
assistantMessageEvent: { type: "text_start" },
|
||||
});
|
||||
await page.locator("[data-page-ai-pi-lab-btn-abort]").click();
|
||||
await page.locator("[data-page-ai-pi-lab-abort-note]").waitFor({ state: "visible", timeout: TIMEOUT });
|
||||
result.checks.abortNoteText = await page.locator("[data-page-ai-pi-lab-abort-note]").last().textContent();
|
||||
await page.screenshot({ path: path.join(OUT, "03-abort-stopreason-queue.png"), fullPage: false });
|
||||
result.screenshots.abort = path.join(OUT, "03-abort-stopreason-queue.png");
|
||||
assert(/stopReason=aborted/.test(result.checks.abortNoteText || ""), "abort note 未显示 stopReason");
|
||||
assert(/queued messages=2/.test(result.checks.abortNoteText || ""), "abort note 未显示 queued messages");
|
||||
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();
|
||||
@@ -76,20 +76,18 @@ async function main() {
|
||||
await userPage.goto(baseUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await userPage.locator('[data-testid="mnote-account-menu-trigger"]').click();
|
||||
await userPage.locator('[data-testid="mnote-account-menu"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await userPage.waitForFunction(() => {
|
||||
const link = document.querySelector('[data-testid="mnote-account-access-policy"]');
|
||||
return link && link.getAttribute("href") === "/user/access-policy";
|
||||
}, { timeout: UI_TIMEOUT_MS });
|
||||
assert.equal(await userPage.locator('[data-testid="mnote-account-access-policy"]').count(), 0, "授权管理入口应已退役");
|
||||
await userPage.locator('[data-testid="mnote-account-ai-management"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await userPage.locator('[data-testid="mnote-account-profile"]').click();
|
||||
await userPage.locator('[data-testid="mnote-profile-dialog"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const profileText = await userPage.locator('[data-testid="mnote-profile-dialog"]').innerText();
|
||||
assert(profileText.includes("user_profile_smoke"), "个人信息弹窗应显示完整用户 ID");
|
||||
|
||||
await userPage.goto(`${baseUrl}/user/access-policy`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await userPage.locator('[data-testid="mnote-admin-access-policy-page"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const userAccessText = await userPage.locator('[data-testid="mnote-admin-access-policy-page"]').innerText();
|
||||
assert(userAccessText.includes("分享管理"), "普通用户授权页应显示分享管理");
|
||||
assert(!userAccessText.includes("验证目录"), "普通用户授权页不应显示目录授权验证表单");
|
||||
await userPage.goto(`${baseUrl}/user/access-policy`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
|
||||
await userPage.waitForURL((url) => url.pathname === "/user/ai" && url.hash === "#ai-admin-access", { timeout: UI_TIMEOUT_MS });
|
||||
await userPage.locator("#ai-admin-access.is-active").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const userAccessText = await userPage.locator("#ai-admin-access").innerText();
|
||||
assert(userAccessText.includes("目录权限"), "旧用户授权页应跳转到用户 AI 设置的目录权限面板");
|
||||
await userContext.close();
|
||||
|
||||
const adminContext = await browser.newContext({
|
||||
@@ -102,16 +100,13 @@ async function main() {
|
||||
await adminPage.goto(baseUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await adminPage.locator('[data-testid="mnote-account-menu-trigger"]').click();
|
||||
await adminPage.locator('[data-testid="mnote-account-menu"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await adminPage.waitForFunction(() => {
|
||||
const link = document.querySelector('[data-testid="mnote-account-access-policy"]');
|
||||
return link && link.getAttribute("href") === "/admin/access-policy";
|
||||
}, { timeout: UI_TIMEOUT_MS });
|
||||
await adminPage.goto(`${baseUrl}/admin/access-policy`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await adminPage.locator('[data-testid="mnote-admin-access-policy-page"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const adminAccessText = await adminPage.locator('[data-testid="mnote-admin-access-policy-page"]').innerText();
|
||||
assert(adminAccessText.includes("目录授权"), "管理员授权页应显示目录授权");
|
||||
assert(adminAccessText.includes("分享管理"), "管理员授权页应显示分享管理");
|
||||
assert(await adminPage.locator('[data-testid="mnote-admin-validate-root-submit"]').isVisible(), "管理员页应显示验证目录按钮");
|
||||
assert.equal(await adminPage.locator('[data-testid="mnote-account-access-policy"]').count(), 0, "管理员账号菜单不应再出现授权管理入口");
|
||||
await adminPage.locator('[data-testid="mnote-account-ai-management"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await adminPage.goto(`${baseUrl}/admin/access-policy`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
|
||||
await adminPage.waitForURL((url) => url.pathname === "/admin/ai" && url.hash === "#ai-admin-access", { timeout: UI_TIMEOUT_MS });
|
||||
await adminPage.locator("#ai-admin-access.is-active").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const adminAccessText = await adminPage.locator("#ai-admin-access").innerText();
|
||||
assert(adminAccessText.includes("目录权限"), "旧管理员授权页应跳转到管理员 AI 设置的目录权限面板");
|
||||
await adminContext.close();
|
||||
|
||||
console.log(JSON.stringify({ ok: true, task: "task489-auth-profile-access-ui", baseUrl }, null, 2));
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
const { chromium } = require('playwright');
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] });
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
|
||||
const consoleErrors = [];
|
||||
page.on('console', msg => { if (msg.type() === 'error') consoleErrors.push(msg.text()); });
|
||||
const testErrors = [];
|
||||
page.on('pageerror', err => testErrors.push(err.message));
|
||||
|
||||
// Login
|
||||
await page.goto('http://localhost:3000/auth', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(2000);
|
||||
const qlBtn = await page.locator('text=测试账号快速登录').count();
|
||||
if (qlBtn > 0) {
|
||||
await page.click('text=测试账号快速登录');
|
||||
await page.waitForTimeout(3000);
|
||||
}
|
||||
|
||||
// Navigate to QA test page
|
||||
await page.goto('http://localhost:3000/w/mnote-e2e/qa-block-handle-test', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
console.log('Final URL:', page.url());
|
||||
|
||||
const editor = await page.$('.ProseMirror');
|
||||
console.log('ProseMirror found:', !!editor);
|
||||
|
||||
if (editor) {
|
||||
const editorRect = await editor.boundingBox();
|
||||
console.log('Editor rect:', JSON.stringify(editorRect));
|
||||
const paragraphs = await editor.$$('p');
|
||||
console.log('Paragraph count:', paragraphs.length);
|
||||
|
||||
if (paragraphs.length > 0) {
|
||||
const pRect = await paragraphs[0].boundingBox();
|
||||
console.log('First P rect:', JSON.stringify(pRect));
|
||||
|
||||
// Hover over paragraph at the left edge area
|
||||
await page.mouse.move(pRect.x + 5, pRect.y + pRect.height/2);
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
const handleShell = await page.$('[data-testid="mnote-leptos-tiptap-handle"]');
|
||||
console.log('Handle shell found:', !!handleShell);
|
||||
|
||||
if (handleShell) {
|
||||
const hRect = await handleShell.boundingBox();
|
||||
console.log('Handle rect:', JSON.stringify(hRect));
|
||||
const display = await handleShell.evaluate(el => getComputedStyle(el).display);
|
||||
const visibility = await handleShell.evaluate(el => getComputedStyle(el).visibility);
|
||||
const opacity = await handleShell.evaluate(el => getComputedStyle(el).opacity);
|
||||
const pointerEvents = await handleShell.evaluate(el => getComputedStyle(el).pointerEvents);
|
||||
console.log('CSS: display=' + display + ' vis=' + visibility + ' op=' + opacity + ' pe=' + pointerEvents);
|
||||
|
||||
await page.screenshot({ path: '/tmp/handle-shell-found.png', fullPage: false });
|
||||
|
||||
const trigger = await handleShell.$('[data-testid="block-drag-handle-trigger"]');
|
||||
console.log('Trigger found:', !!trigger);
|
||||
|
||||
if (trigger) {
|
||||
const tRect = await trigger.boundingBox();
|
||||
console.log('Trigger rect:', JSON.stringify(tRect));
|
||||
|
||||
// Click
|
||||
await trigger.click({ force: true });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const blockMenu = await page.$('[data-testid="block-drag-menu"]');
|
||||
console.log('Block menu found:', !!blockMenu);
|
||||
|
||||
if (blockMenu) {
|
||||
const bmRect = await blockMenu.boundingBox();
|
||||
console.log('Block menu rect:', JSON.stringify(bmRect));
|
||||
const menuItems = await blockMenu.$$('[data-testid^="block-drag-menu-item"]');
|
||||
console.log('Menu items count:', menuItems.length);
|
||||
const iconSpans = await blockMenu.$$('.block-drag-menu-icon');
|
||||
console.log('Icon spans count:', iconSpans.length);
|
||||
|
||||
for (const item of menuItems) {
|
||||
const text = await item.textContent();
|
||||
const icon = await item.$('.material-symbols-outlined');
|
||||
const icon2 = await item.$('[data-testid^="block-drag-menu-icon"]');
|
||||
console.log(' Item text="' + (text ? text.trim().substring(0,40) : '') + '" icon=' + !!icon);
|
||||
}
|
||||
|
||||
await page.screenshot({ path: '/tmp/block-menu-open.png', fullPage: false });
|
||||
} else {
|
||||
// Check what's rendered
|
||||
const stage = await page.$('[data-testid="mnote-leptos-tiptap-editor-stage"]');
|
||||
const stageHTML = await stage.evaluate(el => el.innerHTML.substring(0, 3000));
|
||||
console.log('Stage HTML after click (3K):', stageHTML.substring(0, 1000) + '...');
|
||||
|
||||
// Check for menu elements anywhere
|
||||
const allMenus = await page.$$('[class*="menu"]');
|
||||
console.log('Any menu elements on page:', allMenus.length);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Check stage innerHTML
|
||||
const stage = await page.$('[data-testid="mnote-leptos-tiptap-editor-stage"]');
|
||||
console.log('Stage found:', !!stage);
|
||||
if (stage) {
|
||||
const html = await stage.evaluate(el => el.innerHTML.substring(0, 2000));
|
||||
console.log('Stage HTML:', html);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Console errors:', consoleErrors.length);
|
||||
console.log('Page errors:', testErrors.length);
|
||||
await browser.close();
|
||||
})().catch(e => { console.error('CRASH:', e.message); process.exit(1); });
|
||||
Reference in New Issue
Block a user