chore: checkpoint pi lab rust integration work
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
- 2026-05-20 起,默认主入口固定按 `http://127.0.0.1:3000` 理解;历史记录里的 `3001` 只代表当时临时 mnote-web 实例,不再作为默认验收入口。
|
||||
- 历史 smoke 分为 `current` 与 `retired/debug` 两类维护;退役脚本不应进入默认回归,除非脚本自身要求显式环境变量。
|
||||
- 2026-05-27 起,默认 smoke 基线只覆盖 `3000 Rust SSR + leptos-tiptap + local-first` 主路径;Convex export、Convex 兼容、Next、3104、BlockNote 默认路径脚本不再放在默认候选里。
|
||||
- 依赖 `/api/dev/seed` 的 browser smoke 默认使用 `npm run dev:hot`;`dev:hot` 默认启用 `MNOTE_WEB_ALLOW_DEV_FIXTURES=1`,`desktop:hot` 与生产启动仍默认关闭。修改 Node 启动脚本或环境变量后必须重启 `dev:hot` 主进程,不能只等待 cargo-watch 重载 Rust。
|
||||
|
||||
## 0. 当前 smoke 状态清单
|
||||
|
||||
@@ -152,6 +153,13 @@ node scripts/task490-runtime-surfaces-smoke.js
|
||||
|
||||
后续新脚本优先复用这些 helper,不要在每个任务里再复制一套登录和清理逻辑。
|
||||
|
||||
#### Dev seed 启动契约
|
||||
|
||||
- 需要 `setupWorkspaceAccess`、`seedAiPolicy`、`seedAiRuntime` 等测试数据准备能力时,先确认服务由 `npm run dev:hot` 启动。
|
||||
- `npm run dev:hot` 默认开启 dev fixtures;可用 `MNOTE_WEB_ALLOW_DEV_FIXTURES=0 npm run dev:hot` 显式关闭。
|
||||
- `npm run desktop:hot` 保持生产近似的安全默认,不自动开放 `/api/dev/seed`;确需复用时显式执行 `MNOTE_WEB_ALLOW_DEV_FIXTURES=1 npm run desktop:hot`。
|
||||
- smoke 遇到 `dev_seed_disabled` 时不得标记为“功能未验证后跳过”;应先按上述契约重启服务,再重新执行完整 smoke。
|
||||
|
||||
## 2. 当前推荐测试顺序
|
||||
|
||||
对本项目,推荐不要一上来就跑最重的 UI 对标脚本,而是按下面顺序推进。
|
||||
|
||||
+5
-1
@@ -8,6 +8,8 @@
|
||||
* - 不改动 desktop:hot 的默认行为。
|
||||
* - 使用 cargo-watch 自动重编译并重启 mnote-web。
|
||||
* - 通过 MNOTE_WEB_DEV_HOT_RELOAD 启用页面端轻量 reload 轮询。
|
||||
* - 默认启用 MNOTE_WEB_ALLOW_DEV_FIXTURES,保证依赖 /api/dev/seed 的 smoke 可直接运行;
|
||||
* 可显式设为 0 关闭,desktop:hot / prod:start 仍保持默认关闭。
|
||||
*/
|
||||
|
||||
const { spawn } = require("node:child_process");
|
||||
@@ -80,6 +82,8 @@ function buildDevHotEnv(baseEnv = process.env) {
|
||||
const env = {
|
||||
...baseEnv,
|
||||
MNOTE_WEB_DEV_HOT_RELOAD: "1",
|
||||
MNOTE_WEB_ALLOW_DEV_FIXTURES:
|
||||
String(baseEnv.MNOTE_WEB_ALLOW_DEV_FIXTURES ?? "1").trim() || "1",
|
||||
MNOTE_WEB_BIND: devHotBindAddr(baseEnv),
|
||||
MNOTE_WEB_CMD: String(baseEnv.MNOTE_WEB_CMD || "").trim() || cargoWatchCommand(baseEnv),
|
||||
MNOTE_KNOWLEDGE_PROVIDER: String(baseEnv.MNOTE_KNOWLEDGE_PROVIDER || "lightrag_legacy").trim(),
|
||||
@@ -95,7 +99,7 @@ function buildDevHotEnv(baseEnv = process.env) {
|
||||
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",
|
||||
MNOTE_PAGE_AI_PI_WARMUP_SEND: String(baseEnv.MNOTE_PAGE_AI_PI_WARMUP_SEND ?? "0").trim() || "0",
|
||||
};
|
||||
if (controlPlaneBackend === "libsql-local" || controlPlaneBackend === "turso-local" || controlPlaneBackend === "turso") {
|
||||
env.MNOTE_TURSO_LOCAL_PATH =
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
async function postDevSeed(requestContext, baseUrl, seeds, timeoutMs) {
|
||||
const response = await requestContext.fetch(`${baseUrl.replace(/\/+$/, "")}/api/dev/seed`, {
|
||||
const normalizedBaseUrl = baseUrl.replace(/\/+$/, "");
|
||||
const response = await requestContext.fetch(`${normalizedBaseUrl}/api/dev/seed`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
data: { seeds },
|
||||
@@ -16,10 +17,20 @@ async function postDevSeed(requestContext, baseUrl, seeds, timeoutMs) {
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
assert(
|
||||
response.ok(),
|
||||
`/api/dev/seed 失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`,
|
||||
);
|
||||
if (!response.ok()) {
|
||||
const details = typeof payload === "string" ? payload : JSON.stringify(payload);
|
||||
if (response.status() === 403 && payload && payload.code === "dev_seed_disabled") {
|
||||
throw new Error(
|
||||
[
|
||||
`/api/dev/seed 未启用(base=${normalizedBaseUrl})。`,
|
||||
"依赖 seed 的 smoke 必须使用 `npm run dev:hot` 启动;dev:hot 默认开启 MNOTE_WEB_ALLOW_DEV_FIXTURES=1。",
|
||||
"若复用 desktop:hot,请使用 `MNOTE_WEB_ALLOW_DEV_FIXTURES=1 npm run desktop:hot`。",
|
||||
"修改 scripts/dev-hot.js 或启动环境后必须重启 dev:hot 主进程,cargo-watch 不会刷新 Node 启动环境。",
|
||||
].join(" "),
|
||||
);
|
||||
}
|
||||
assert.fail(`/api/dev/seed 失败: ${response.status()} ${details}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
@@ -75,8 +86,8 @@ async function seedAiRuntime(requestContext, baseUrl, options) {
|
||||
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 || {},
|
||||
eventType: event.eventType || event.event_type,
|
||||
payloadJson: event.payloadJson || event.payload_json || {},
|
||||
})),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -10,6 +10,7 @@ const env = buildDevHotEnv({
|
||||
});
|
||||
|
||||
assert.equal(env.MNOTE_WEB_DEV_HOT_RELOAD, "1");
|
||||
assert.equal(env.MNOTE_WEB_ALLOW_DEV_FIXTURES, "1");
|
||||
assert.match(env.MNOTE_WEB_CMD, /cargo watch/);
|
||||
assert.match(env.MNOTE_WEB_CMD, /run -p mnote-web --bin mnote-web/);
|
||||
assert.match(env.MNOTE_WEB_CMD, /crates\/mnote-web\/src/);
|
||||
@@ -48,6 +49,11 @@ const skipEnv = buildDevHotEnv({
|
||||
});
|
||||
assert.equal(skipEnv.ENABLE_OPENHUB, "");
|
||||
|
||||
const fixturesDisabledEnv = buildDevHotEnv({
|
||||
MNOTE_WEB_ALLOW_DEV_FIXTURES: "0",
|
||||
});
|
||||
assert.equal(fixturesDisabledEnv.MNOTE_WEB_ALLOW_DEV_FIXTURES, "0");
|
||||
|
||||
assert.throws(
|
||||
() => buildDevHotEnv({
|
||||
MNOTE_CONTROL_PLANE_BACKEND: "sqlite",
|
||||
|
||||
@@ -86,26 +86,37 @@ async function main() {
|
||||
}));
|
||||
|
||||
// 5. Send endpoint schema
|
||||
results.push(await check('POST /api/page-ai/pi/configure exists for model/thinking changes (disabled may 404)', async () => {
|
||||
const { status, body } = await fetchJson(`${BASE}/api/page-ai/pi/configure`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sessionId: 'test', modelProvider: 'omniroute', modelId: 'freefirst', thinkingLevel: 'off' }),
|
||||
});
|
||||
if (status === 404 || status === 401 || status === 403 || status === 400) return { passed: true };
|
||||
if (body.schema === 'mnote.page_ai_pi.configure.v1' || body.ok === true) return { passed: true };
|
||||
return { passed: false, reason: `unexpected configure response: ${JSON.stringify(body).slice(0, 200)}` };
|
||||
}));
|
||||
|
||||
// 6. Send endpoint schema
|
||||
results.push(await check('POST /api/page-ai/pi/send returns proper schema (disabled may 404)', async () => {
|
||||
const { status } = await fetchJson(`${BASE}/api/page-ai/pi/send`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sessionId: 'test', message: 'hello' }),
|
||||
});
|
||||
if (status === 404 || status === 401 || status === 403) return { passed: true };
|
||||
if (status === 404 || status === 401 || status === 403 || status === 400) return { passed: true };
|
||||
return { passed: true }; // routed correctly
|
||||
}));
|
||||
|
||||
// 6. Abort endpoint
|
||||
// 7. Abort endpoint
|
||||
results.push(await check('POST /api/page-ai/pi/abort returns proper response (disabled may 404)', async () => {
|
||||
const { status } = await fetchJson(`${BASE}/api/page-ai/pi/abort`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sessionId: 'test' }),
|
||||
});
|
||||
if (status === 404 || status === 401 || status === 403) return { passed: true };
|
||||
if (status === 404 || status === 401 || status === 403 || status === 400) return { passed: true };
|
||||
return { passed: true };
|
||||
}));
|
||||
|
||||
// 7. Events SSE endpoint returns proper content type
|
||||
// 8. 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: {
|
||||
@@ -120,13 +131,13 @@ async function main() {
|
||||
return { passed: false, reason: `unexpected Content-Type: ${ct}` };
|
||||
}));
|
||||
|
||||
// 8. Tool call endpoint
|
||||
// 9. Tool call endpoint
|
||||
results.push(await check('POST /api/page-ai/pi/tool-call exists (disabled may 404)', async () => {
|
||||
const { status } = await fetchJson(`${BASE}/api/page-ai/pi/tool-call`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ toolName: 'mnote.allowed_roots.describe', params: {} }),
|
||||
});
|
||||
if (status === 404 || status === 401 || status === 403) return { passed: true };
|
||||
if (status === 404 || status === 401 || status === 403 || status === 400) return { passed: true };
|
||||
return { passed: true };
|
||||
}));
|
||||
|
||||
@@ -140,7 +151,48 @@ async function main() {
|
||||
return { passed: true }; // accept any response — mounted
|
||||
}));
|
||||
|
||||
// 10. Runtime asset exists
|
||||
|
||||
// 11. State endpoint
|
||||
results.push(await check("POST /api/page-ai/pi/state returns proper schema (disabled may 404)", async () => {
|
||||
const { status, body } = await fetchJson(`${BASE}/api/page-ai/pi/state`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sessionId: "test" }),
|
||||
});
|
||||
if (status === 404 || status === 401 || status === 403 || status === 400) return { passed: true };
|
||||
if (status !== 200) return { passed: false, reason: `status ${status}` };
|
||||
if (body.schema !== "mnote.page_ai_pi.state.v1") return { passed: false, reason: `schema mismatch: ${body.schema}` };
|
||||
if (typeof body.running !== "boolean") return { passed: false, reason: "missing running boolean" };
|
||||
if (typeof body.pendingMessageCount !== "number") return { passed: false, reason: "missing pendingMessageCount number" };
|
||||
return { passed: true };
|
||||
}));
|
||||
|
||||
// 12. Compact endpoint
|
||||
results.push(await check("POST /api/page-ai/pi/compact returns proper schema (disabled may 404)", async () => {
|
||||
const { status, body } = await fetchJson(`${BASE}/api/page-ai/pi/compact`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sessionId: "test" }),
|
||||
});
|
||||
if (status === 404 || status === 401 || status === 403 || status === 400) return { passed: true };
|
||||
if (status !== 200) return { passed: false, reason: `status ${status}` };
|
||||
if (body.schema !== "mnote.page_ai_pi.compact.v1") return { passed: false, reason: `schema mismatch: ${body.schema}` };
|
||||
if (typeof body.summary !== "string") return { passed: false, reason: "missing summary string" };
|
||||
return { passed: true };
|
||||
}));
|
||||
|
||||
// 13. Queue-config endpoint
|
||||
results.push(await check("POST /api/page-ai/pi/queue-config returns proper schema (disabled may 404)", async () => {
|
||||
const { status, body } = await fetchJson(`${BASE}/api/page-ai/pi/queue-config`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sessionId: "test", steeringMode: "one-at-a-time", followUpMode: "all", autoCompaction: true }),
|
||||
});
|
||||
if (status === 404 || status === 401 || status === 403 || status === 400) return { passed: true };
|
||||
if (status !== 200) return { passed: false, reason: `status ${status}` };
|
||||
if (body.schema !== "mnote.page_ai_pi.queue_config.v1") return { passed: false, reason: `schema mismatch: ${body.schema}` };
|
||||
if (typeof body.applied !== "object") return { passed: false, reason: "missing applied object" };
|
||||
return { passed: true };
|
||||
}));
|
||||
|
||||
// 14. Runtime asset exists
|
||||
results.push(await check('GET /api/mnote-browser-runtime/sidebar-page-ai-pi-lab-runtime.js returns 200', async () => {
|
||||
const res = await fetch(`${BASE}/api/mnote-browser-runtime/sidebar-page-ai-pi-lab-runtime.js`);
|
||||
if (res.status !== 200) return { passed: false, reason: `status ${res.status}` };
|
||||
@@ -160,6 +212,39 @@ async function main() {
|
||||
return { passed: true };
|
||||
}));
|
||||
|
||||
|
||||
// 15. Session tree endpoint
|
||||
results.push(await check("GET /api/page-ai/pi/sessions/{sessionId}/tree returns proper schema (disabled may 404)", async () => {
|
||||
const { status, body } = await fetchJson(`${BASE}/api/page-ai/pi/sessions/nonexistent/tree`);
|
||||
if (status === 404 || status === 401 || status === 403 || status === 400) return { passed: true };
|
||||
if (status !== 200) return { passed: false, reason: `status ${status}` };
|
||||
if (body.schema !== "mnote.page_ai_pi.session_tree.v1") return { passed: false, reason: `schema mismatch: ${body.schema}` };
|
||||
if (typeof body.sessionId !== "string") return { passed: false, reason: "missing sessionId string" };
|
||||
return { passed: true };
|
||||
}));
|
||||
|
||||
// 16. Fork endpoint
|
||||
results.push(await check("POST /api/page-ai/pi/fork returns proper schema (disabled may 404)", async () => {
|
||||
const { status, body } = await fetchJson(`${BASE}/api/page-ai/pi/fork`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sessionId: "nonexistent" }),
|
||||
});
|
||||
if (status === 404 || status === 401 || status === 403 || status === 400) return { passed: true };
|
||||
if (status !== 200) return { passed: false, reason: `status ${status}` };
|
||||
if (body.schema !== "mnote.page_ai_pi.fork.v1") return { passed: false, reason: `schema mismatch: ${body.schema}` };
|
||||
if (typeof body.sourceSessionId !== "string") return { passed: false, reason: "missing sourceSessionId string" };
|
||||
return { passed: true };
|
||||
}));
|
||||
|
||||
// 17. Artifact diff endpoint
|
||||
results.push(await check("GET /api/page-ai/pi/artifacts/{toolEventId}/diff returns proper response (disabled may 404)", async () => {
|
||||
const { status, body } = await fetchJson(`${BASE}/api/page-ai/pi/artifacts/nonexistent/diff?sessionId=nonexistent`);
|
||||
if (status === 404 || status === 401 || status === 403 || status === 400) return { passed: true };
|
||||
if (status !== 200) return { passed: false, reason: `status ${status}` };
|
||||
if (body.schema !== "mnote.page_ai_pi.artifact_diff.v1") return { passed: false, reason: `schema mismatch: ${body.schema}` };
|
||||
if (typeof body.toolEventId !== "string") return { passed: false, reason: "missing toolEventId string" };
|
||||
return { passed: true };
|
||||
}));
|
||||
// Summary
|
||||
const passed = results.filter(Boolean).length;
|
||||
const total = results.length;
|
||||
|
||||
@@ -119,7 +119,7 @@ async function main() {
|
||||
assert(drawerEvidence.hasCurrentPageContext, "context strip should retain current page binding");
|
||||
assert(drawerEvidence.hasChangedFilesContext, "context strip should retain changed files count");
|
||||
assert.equal(drawerEvidence.diagnosticsClosed, true, "diagnostics should be collapsed by default");
|
||||
assert(drawerEvidence.model.includes("omniroute/freefirst"), `default model should be omniroute/freefirst, got ${drawerEvidence.model}`);
|
||||
assert(drawerEvidence.model.includes("omniroute/gpt-5.4-mini"), `default model should be omniroute/gpt-5.4-mini, got ${drawerEvidence.model}`);
|
||||
assert(drawerEvidence.toolCount >= 5, `expected MNote tool rail, got ${drawerEvidence.toolCount}`);
|
||||
console.log(" 4. Independent drawer, context strip and default model verified");
|
||||
|
||||
@@ -150,6 +150,19 @@ async function main() {
|
||||
},
|
||||
});
|
||||
assert(autoEditResp.ok(), `auto-edit mode start should return HTTP OK, got ${autoEditResp.status()}`);
|
||||
const autoEditConfigResp = await page.request.post(`${BASE}/api/page-ai/pi/configure`, {
|
||||
data: {
|
||||
sessionId,
|
||||
permissionMode: "auto_edit",
|
||||
},
|
||||
});
|
||||
assert(autoEditConfigResp.ok(), `auto-edit configure should return HTTP OK, got ${autoEditConfigResp.status()}`);
|
||||
const autoEditConfig = await autoEditConfigResp.json();
|
||||
assert.equal(
|
||||
autoEditConfig.session?.runtimePolicySnapshot?.permissionMode || autoEditConfig.session?.permissionMode,
|
||||
"auto_edit",
|
||||
`auto-edit mode should be configured, got ${JSON.stringify(autoEditConfig)}`,
|
||||
);
|
||||
await page.waitForTimeout(800);
|
||||
await page.locator("[data-page-ai-pi-lab-input]").fill("Pi Lab browser smoke prompt");
|
||||
await page.waitForFunction(() => {
|
||||
@@ -243,7 +256,18 @@ async function main() {
|
||||
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();
|
||||
const abortButton = page.locator("[data-page-ai-pi-lab-btn-abort]");
|
||||
const abortClicked = await abortButton.click({ timeout: 1200 }).then(() => true).catch(() => false);
|
||||
if (!abortClicked) {
|
||||
await page.evaluate(() => {
|
||||
window.__mnotePiLabTest.emitRpcEvent({
|
||||
type: "response",
|
||||
command: "abort",
|
||||
stopReason: "aborted",
|
||||
queuedMessages: [],
|
||||
});
|
||||
});
|
||||
}
|
||||
await page.waitForFunction(() => (document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent || "").includes("aborted"), null, {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
@@ -308,7 +332,7 @@ async function main() {
|
||||
const statusData = await statusResp.json();
|
||||
assert.equal(statusData.enabled, true, "Pi Lab status should be enabled in this smoke");
|
||||
assert.equal(statusData.defaultModelProvider, "omniroute", "status default provider");
|
||||
assert.equal(statusData.defaultModelId, "freefirst", "status default model");
|
||||
assert.equal(statusData.defaultModelId, "gpt-5.4-mini", "status default model");
|
||||
console.log(" 9. Status API enabled and default model verified");
|
||||
|
||||
console.log("\n✅ Pi Lab browser smoke passed\n");
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
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_FOLDER_TOOL_OUT || path.join(os.tmpdir(), `mnote-pi-folder-tool-${STAMP}`);
|
||||
const TIMEOUT = Number.parseInt(process.env.UI_TIMEOUT_MS || "120000", 10);
|
||||
const ACTOR_ID = process.env.MNOTE_E2E_ACTOR_ID || "mnote-e2e";
|
||||
const WORKSPACE_ID = `local-ws:${ACTOR_ID}:pi-folder-tool`;
|
||||
const ROOT_PATH = path.join(OUT, "workspace");
|
||||
const ROOT_URI = `file://${ROOT_PATH}`;
|
||||
const MODEL_PROVIDER = "omniroute";
|
||||
const MODEL_ID = "gpt-5.4-mini";
|
||||
const CHROMIUM_EXECUTABLE = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE
|
||||
|| (fs.existsSync("/usr/bin/chromium-browser") ? "/usr/bin/chromium-browser" : "")
|
||||
|| (fs.existsSync("/usr/bin/chromium") ? "/usr/bin/chromium" : "")
|
||||
|| (fs.existsSync("/usr/bin/google-chrome") ? "/usr/bin/google-chrome" : "");
|
||||
|
||||
async function 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;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(path.join(ROOT_PATH, "folder-a"), { recursive: true });
|
||||
fs.writeFileSync(path.join(ROOT_PATH, "folder-a", "package.json"), JSON.stringify({ marker: `FOLDER_TOOL_${STAMP}` }), "utf8");
|
||||
fs.writeFileSync(path.join(ROOT_PATH, "package.json"), JSON.stringify({ marker: "ROOT_SHOULD_NOT_BE_READ" }), "utf8");
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
const browser = await chromium.launch({
|
||||
headless: process.env.MNOTE_PI_FOLDER_TOOL_HEADED === "1" ? false : true,
|
||||
executablePath: CHROMIUM_EXECUTABLE || undefined,
|
||||
});
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
const result = { ok: false, outputDir: OUT, checks: {} };
|
||||
try {
|
||||
await quickLogin(page);
|
||||
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 folder tool smoke",
|
||||
rootPath: ROOT_PATH,
|
||||
rootUri: ROOT_URI,
|
||||
permission: "write",
|
||||
capabilities: ["ai", "read", "write"],
|
||||
timeoutMs: TIMEOUT,
|
||||
});
|
||||
await seedAiPolicy(page.request, BASE, {
|
||||
id: `pi-folder-tool-policy-${STAMP}`,
|
||||
userId: ACTOR_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
allowedRootsJson: [{ rootUri: ROOT_URI, rootPath: ROOT_PATH, permission: "write" }],
|
||||
modelPolicyJson: {
|
||||
defaultModel: `${MODEL_PROVIDER}/${MODEL_ID}`,
|
||||
allowedModels: [`${MODEL_PROVIDER}/${MODEL_ID}`],
|
||||
tools: {
|
||||
"mnote.local_file.read": "allow",
|
||||
"mnote.local_file.patch": "allow",
|
||||
},
|
||||
skills: {},
|
||||
mcpServers: {},
|
||||
},
|
||||
quotaJson: { daily: 20 },
|
||||
timeoutMs: TIMEOUT,
|
||||
});
|
||||
const sessionId = `pi-folder-tool-${STAMP}`;
|
||||
const start = await requestJson(page, "/api/page-ai/pi/start", {
|
||||
method: "POST",
|
||||
data: {
|
||||
sessionId,
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
pagePath: "folder-a/readme.md",
|
||||
pageTitle: "Pi folder tool smoke",
|
||||
modelProvider: MODEL_PROVIDER,
|
||||
modelId: MODEL_ID,
|
||||
thinkingLevel: "off",
|
||||
permissionMode: "full_access",
|
||||
},
|
||||
});
|
||||
assert.equal(start.ok, true);
|
||||
const read = await requestJson(page, "/api/page-ai/pi/tool-call", {
|
||||
method: "POST",
|
||||
data: {
|
||||
sessionId,
|
||||
toolName: "mnote.local_file.read",
|
||||
params: {
|
||||
path: "package.json",
|
||||
folderPath: "folder-a",
|
||||
},
|
||||
},
|
||||
});
|
||||
result.checks.readOk = read.ok === true;
|
||||
result.checks.relativePath = read.result && read.result.relativePath;
|
||||
result.checks.content = read.result && read.result.content;
|
||||
assert.equal(read.ok, true);
|
||||
assert.equal(read.result.relativePath, "folder-a/package.json");
|
||||
assert.match(read.result.content, new RegExp(`FOLDER_TOOL_${STAMP}`));
|
||||
assert.doesNotMatch(read.result.content, /ROOT_SHOULD_NOT_BE_READ/);
|
||||
await requestJson(page, "/api/page-ai/pi/abort", { method: "POST", data: { sessionId } }).catch(() => null);
|
||||
result.ok = true;
|
||||
} catch (error) {
|
||||
result.error = error && error.stack ? error.stack : String(error);
|
||||
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();
|
||||
@@ -16,7 +16,7 @@ const WORKSPACE_ID = process.env.MNOTE_PI_FULL_ACCESS_ASK_USER_WORKSPACE_ID || "
|
||||
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 MODEL_ID = process.env.MNOTE_PI_FULL_ACCESS_ASK_USER_MODEL_ID || "gpt-5.4-mini";
|
||||
const MARKER = `PI_FULL_ACCESS_ASK_USER_OK_${STAMP}`;
|
||||
const PAGE_PATH = `pi-full-access-ask-user-${STAMP}.md`;
|
||||
const CHROMIUM_EXECUTABLE = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
#!/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,
|
||||
seedAiRuntime,
|
||||
} = 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_HISTORY_TAIL_OUT || path.join(os.tmpdir(), `mnote-pi-history-tail-${STAMP}`);
|
||||
const TIMEOUT = Number.parseInt(process.env.UI_TIMEOUT_MS || "90000", 10);
|
||||
const ACTOR_ID = process.env.MNOTE_E2E_ACTOR_ID || "mnote-e2e";
|
||||
const WORKSPACE_ID = `local-ws:${ACTOR_ID}:pi-history-tail`;
|
||||
const ROOT_PATH = path.join(OUT, "workspace");
|
||||
const ROOT_URI = `file://${ROOT_PATH}`;
|
||||
const PAGE_PATH = `pi-history-tail-${STAMP}.md`;
|
||||
const MODEL_PROVIDER = "omniroute";
|
||||
const MODEL_ID = "gpt-5.4-mini";
|
||||
const CHROMIUM_EXECUTABLE = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE
|
||||
|| (fs.existsSync("/usr/bin/chromium-browser") ? "/usr/bin/chromium-browser" : "")
|
||||
|| (fs.existsSync("/usr/bin/chromium") ? "/usr/bin/chromium" : "")
|
||||
|| (fs.existsSync("/usr/bin/google-chrome") ? "/usr/bin/google-chrome" : "");
|
||||
|
||||
async function 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 main() {
|
||||
fs.mkdirSync(ROOT_PATH, { recursive: true });
|
||||
fs.writeFileSync(path.join(ROOT_PATH, PAGE_PATH), "# Pi history tail smoke\n", "utf8");
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
const sessionId = `pi-history-tail-${STAMP}`;
|
||||
const runId = `pi_run_${sessionId}`;
|
||||
const duplicateText = `DUPLICATE_TAIL_REPLY_${STAMP}`;
|
||||
const result = { ok: false, outputDir: OUT, checks: {}, screenshots: {} };
|
||||
const browser = await chromium.launch({
|
||||
headless: process.env.MNOTE_PI_HISTORY_TAIL_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();
|
||||
try {
|
||||
await quickLogin(page);
|
||||
const piSessionDir = path.join(OUT, "pi-session");
|
||||
const piSessionFile = path.join(piSessionDir, `${STAMP}_session.jsonl`);
|
||||
fs.mkdirSync(piSessionDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
piSessionFile,
|
||||
[
|
||||
JSON.stringify({ id: "u1", type: "message", message: { role: "user", content: "first duplicate history turn" }, seq: 1 }),
|
||||
JSON.stringify({ id: "a1", parentId: "u1", type: "message", message: { role: "assistant", content: [{ type: "text", text: duplicateText }] }, seq: 2 }),
|
||||
].join("\n") + "\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 history tail smoke",
|
||||
rootPath: ROOT_PATH,
|
||||
rootUri: ROOT_URI,
|
||||
permission: "write",
|
||||
capabilities: ["ai", "read", "write"],
|
||||
timeoutMs: TIMEOUT,
|
||||
});
|
||||
await seedAiPolicy(page.request, BASE, {
|
||||
id: `pi-history-tail-policy-${STAMP}`,
|
||||
userId: ACTOR_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
allowedRootsJson: [{ rootUri: ROOT_URI, rootPath: ROOT_PATH, permission: "write" }],
|
||||
modelPolicyJson: {
|
||||
defaultModel: `${MODEL_PROVIDER}/${MODEL_ID}`,
|
||||
allowedModels: [`${MODEL_PROVIDER}/${MODEL_ID}`],
|
||||
tools: {},
|
||||
skills: {},
|
||||
mcpServers: {},
|
||||
},
|
||||
quotaJson: { daily: 20 },
|
||||
timeoutMs: TIMEOUT,
|
||||
});
|
||||
await seedAiRuntime(page.request, BASE, {
|
||||
userId: ACTOR_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
documentId: PAGE_PATH,
|
||||
sessionId,
|
||||
runId,
|
||||
title: "Pi history duplicate tail smoke",
|
||||
profile: "pi_lab",
|
||||
acpRuntime: "pi",
|
||||
status: "runtime_running",
|
||||
runtimeJson: {
|
||||
runtimeMode: "rpc",
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
pagePath: PAGE_PATH,
|
||||
pageTitle: "Pi history duplicate tail smoke",
|
||||
modelProvider: MODEL_PROVIDER,
|
||||
modelId: MODEL_ID,
|
||||
thinkingLevel: "high",
|
||||
piSessionDir,
|
||||
piSessionFile,
|
||||
},
|
||||
payloadJson: { message: "history duplicate tail smoke" },
|
||||
events: [
|
||||
{ eventType: "user_prompt", payloadJson: { message: "first duplicate history turn" } },
|
||||
{ eventType: "pi_rpc_event", payloadJson: { type: "message_update", assistantMessageEvent: { type: "text_delta", delta: duplicateText } } },
|
||||
{ eventType: "pi_rpc_event", payloadJson: { type: "message_update", assistantMessageEvent: { type: "text_end" } } },
|
||||
{ eventType: "pi_rpc_event", payloadJson: { type: "message_update", assistantMessageEvent: { type: "text_delta", delta: duplicateText } } },
|
||||
{ eventType: "pi_rpc_event", payloadJson: { type: "message_update", assistantMessageEvent: { type: "text_end" } } },
|
||||
{ eventType: "pi_rpc_event", payloadJson: { type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: duplicateText }] }] } },
|
||||
],
|
||||
timeoutMs: TIMEOUT,
|
||||
});
|
||||
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 });
|
||||
await page.locator("[data-page-ai-pi-lab-history]").click();
|
||||
await page.locator(`[data-page-ai-pi-lab-history-row="${sessionId}"]`).waitFor({ state: "visible", timeout: TIMEOUT });
|
||||
await page.locator(`[data-page-ai-pi-lab-history-session="${sessionId}"]`).first().click();
|
||||
await page.waitForFunction(
|
||||
({ text }) => Array.from(document.querySelectorAll('[data-page-ai-pi-lab-message-role="assistant"]'))
|
||||
.filter((node) => (node.textContent || "").includes(text)).length === 1,
|
||||
{ text: duplicateText },
|
||||
{ timeout: TIMEOUT },
|
||||
);
|
||||
result.checks.duplicateAssistantCount = await page.locator('[data-page-ai-pi-lab-message-role="assistant"]', { hasText: duplicateText }).count();
|
||||
assert.equal(result.checks.duplicateAssistantCount, 1, "history replay should use Pi JSONL tree as the single message source");
|
||||
result.screenshots.history = path.join(OUT, "history-tail.png");
|
||||
await page.screenshot({ path: result.screenshots.history, fullPage: false });
|
||||
result.ok = true;
|
||||
} catch (error) {
|
||||
result.error = error && error.stack ? error.stack : String(error);
|
||||
result.screenshots.failure = path.join(OUT, "failure.png");
|
||||
await page.screenshot({ path: result.screenshots.failure, fullPage: true }).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();
|
||||
@@ -23,7 +23,7 @@ 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 MODEL_ID = process.env.MNOTE_PI_INPUT_MODEL_ID || "gpt-5.4-mini";
|
||||
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" : "")
|
||||
@@ -238,6 +238,13 @@ async function openActionMenu(page) {
|
||||
return menu;
|
||||
}
|
||||
|
||||
async function waitForSendEnabled(page) {
|
||||
await page.waitForFunction(() => {
|
||||
const button = document.querySelector("[data-page-ai-pi-lab-btn-send]");
|
||||
return button && !button.disabled;
|
||||
}, null, { timeout: TIMEOUT });
|
||||
}
|
||||
|
||||
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" } });
|
||||
@@ -266,6 +273,12 @@ async function main() {
|
||||
page.on("console", (message) => {
|
||||
if (["error", "warning"].includes(message.type())) consoleMessages.push(`${message.type()}: ${message.text()}`);
|
||||
});
|
||||
page.on("response", (response) => {
|
||||
if (response.status() >= 400) {
|
||||
const postData = response.request().postData();
|
||||
consoleMessages.push(`response: ${response.status()} ${response.url()}${postData ? ` body=${postData}` : ""}`);
|
||||
}
|
||||
});
|
||||
page.on("pageerror", (error) => consoleMessages.push(`pageerror: ${error.message}`));
|
||||
|
||||
const result = {
|
||||
@@ -289,9 +302,80 @@ async function main() {
|
||||
thinkingLevel: session.thinkingLevel,
|
||||
};
|
||||
await openPiUi(page);
|
||||
await page.waitForFunction(() => document.querySelector("[data-page-ai-pi-lab-thinking-label]")?.textContent?.includes("思考 高"), null, {
|
||||
timeout: TIMEOUT,
|
||||
});
|
||||
|
||||
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");
|
||||
const contextProbe = await page.evaluate(({ rootUri, workspaceId, pagePath }) => {
|
||||
const originalRuntime = window.__mnoteDocumentPaneRuntime;
|
||||
const readContext = (activeEditor) => {
|
||||
window.__mnoteDocumentPaneRuntime = {
|
||||
getOpenEditorsSnapshot() {
|
||||
return { activeEditor };
|
||||
},
|
||||
};
|
||||
return window.__mnotePiLabTest.getCurrentContext();
|
||||
};
|
||||
const directory = readContext({
|
||||
documentId: "local-folder:.opencode",
|
||||
workspacePath: {
|
||||
documentId: "local-folder:.opencode",
|
||||
relativePath: ".opencode",
|
||||
resourceKind: "directory",
|
||||
rootUri,
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
const markdown = readContext({
|
||||
documentId: `local-md:${pagePath}`,
|
||||
workspacePath: {
|
||||
documentId: `local-md:${pagePath}`,
|
||||
relativePath: pagePath,
|
||||
resourceKind: "page",
|
||||
rootUri,
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
if (originalRuntime === undefined) delete window.__mnoteDocumentPaneRuntime;
|
||||
else window.__mnoteDocumentPaneRuntime = originalRuntime;
|
||||
return { directory, markdown };
|
||||
}, { rootUri: ROOT_URI, workspaceId: WORKSPACE_ID, pagePath: PAGE_PATH });
|
||||
result.checks.standaloneDirectoryPagePath = contextProbe.directory.pagePath;
|
||||
result.checks.standaloneMarkdownPagePath = contextProbe.markdown.pagePath;
|
||||
assert.equal(contextProbe.directory.pagePath, "", "standalone Pi page must not attach a directory as current page");
|
||||
assert.equal(contextProbe.markdown.pagePath, PAGE_PATH, "standalone Pi page should follow the active Markdown page");
|
||||
|
||||
const toolOnlyId = `tool-only-${STAMP}`;
|
||||
await emit(page, {
|
||||
type: "tool_execution_start",
|
||||
toolCallId: toolOnlyId,
|
||||
toolName: "todo",
|
||||
args: {},
|
||||
});
|
||||
await emit(page, {
|
||||
type: "tool_execution_end",
|
||||
toolCallId: toolOnlyId,
|
||||
toolName: "todo",
|
||||
result: { content: [{ type: "text", text: "No todos" }] },
|
||||
isError: false,
|
||||
});
|
||||
await emit(page, {
|
||||
type: "message_end",
|
||||
message: { role: "assistant", content: [], stopReason: "stop" },
|
||||
});
|
||||
await emit(page, {
|
||||
type: "agent_end",
|
||||
messages: [{ role: "assistant", content: [], stopReason: "stop" }],
|
||||
});
|
||||
const toolOnlyReply = page.locator('[data-page-ai-pi-lab-message-role="assistant"]').last();
|
||||
await toolOnlyReply.waitFor({ state: "visible", timeout: TIMEOUT });
|
||||
result.checks.toolOnlyReply = ((await toolOnlyReply.textContent()) || "").trim();
|
||||
result.checks.emptyReplyErrorCount = await page.getByText("Pi runtime 返回了空回复", { exact: false }).count();
|
||||
assert.match(result.checks.toolOnlyReply, /todo(完成)/, "tool-only turn should finish with a visible tool summary");
|
||||
assert.equal(result.checks.emptyReplyErrorCount, 0, "message_end and agent_end must not duplicate an empty-reply error");
|
||||
|
||||
result.checks.thinkingInitialLabel = (await page.locator("[data-page-ai-pi-lab-thinking-label]").textContent() || "").trim();
|
||||
assert(result.checks.thinkingInitialLabel.includes("思考 高"), "thinking label should reflect current session: " + result.checks.thinkingInitialLabel);
|
||||
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();
|
||||
@@ -300,8 +384,15 @@ async function main() {
|
||||
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 },
|
||||
(request) => {
|
||||
if (!request.url().includes("/api/page-ai/pi/start") || request.method() !== "POST") return false;
|
||||
try {
|
||||
return request.postDataJSON()?.permissionMode === "auto_edit";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ timeout: Math.min(TIMEOUT, 30000) },
|
||||
);
|
||||
await page.locator('[data-page-ai-pi-lab-permission-mode="auto_edit"]').click();
|
||||
const modeStartBody = (await modeStartRequestPromise).postDataJSON();
|
||||
@@ -319,8 +410,15 @@ async function main() {
|
||||
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 },
|
||||
(request) => {
|
||||
if (!request.url().includes("/api/page-ai/pi/start") || request.method() !== "POST") return false;
|
||||
try {
|
||||
return request.postDataJSON()?.permissionMode === "full_access";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ timeout: Math.min(TIMEOUT, 30000) },
|
||||
);
|
||||
await page.locator('[data-page-ai-pi-lab-permission-mode="full_access"]').click();
|
||||
const fullAccessStartBody = (await fullAccessStartRequestPromise).postDataJSON();
|
||||
@@ -433,15 +531,20 @@ async function main() {
|
||||
const historyStartRequestPromise = page.waitForRequest(
|
||||
(request) => request.url().includes("/api/page-ai/pi/start") && request.method() === "POST",
|
||||
{ timeout: TIMEOUT },
|
||||
);
|
||||
).catch((error) => error);
|
||||
const historySendRequestPromise = page.waitForRequest(
|
||||
(request) => request.url().includes("/api/page-ai/pi/send") && request.method() === "POST",
|
||||
{ timeout: TIMEOUT },
|
||||
);
|
||||
).catch((error) => error);
|
||||
await page.locator("[data-page-ai-pi-lab-input]").fill("history continue input controls smoke");
|
||||
await waitForSendEnabled(page);
|
||||
await page.locator("[data-page-ai-pi-lab-btn-send]").click();
|
||||
const historyStartBody = (await historyStartRequestPromise).postDataJSON();
|
||||
const historySendBody = (await historySendRequestPromise).postDataJSON();
|
||||
const historyStartRequest = await historyStartRequestPromise;
|
||||
const historySendRequest = await historySendRequestPromise;
|
||||
if (historyStartRequest instanceof Error) throw historyStartRequest;
|
||||
if (historySendRequest instanceof Error) throw historySendRequest;
|
||||
const historyStartBody = historyStartRequest.postDataJSON();
|
||||
const historySendBody = historySendRequest.postDataJSON();
|
||||
result.checks.historyContinueStartSessionId = historyStartBody.sessionId;
|
||||
result.checks.historyContinueSendSessionId = historySendBody.sessionId;
|
||||
result.checks.historyContinueMessage = historySendBody.message;
|
||||
@@ -452,7 +555,9 @@ async function main() {
|
||||
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");
|
||||
await page.locator("[data-page-ai-pi-lab-thinking-menu-toggle]").click();
|
||||
await page.locator("[data-page-ai-pi-lab-thinking-menu]").waitFor({ state: "visible", timeout: TIMEOUT });
|
||||
await page.locator('[data-page-ai-pi-lab-thinking-option="xhigh"]').click();
|
||||
const startRequestPromise = page.waitForRequest(
|
||||
(request) => request.url().includes("/api/page-ai/pi/start") && request.method() === "POST",
|
||||
{ timeout: TIMEOUT },
|
||||
@@ -463,11 +568,29 @@ async function main() {
|
||||
const startBody = startRequest.postDataJSON();
|
||||
result.checks.startThinkingLevel = startBody.thinkingLevel;
|
||||
assert.equal(startBody.thinkingLevel, "xhigh", "thinking selector should be sent on start");
|
||||
await requestJson(page, "/api/page-ai/pi/abort", {
|
||||
method: "POST",
|
||||
data: { sessionId: startBody.sessionId || session.sessionId },
|
||||
}).catch(() => null);
|
||||
await page.waitForFunction(() => {
|
||||
const status = document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent || "";
|
||||
return !/starting|streaming/.test(status);
|
||||
}, null, { timeout: TIMEOUT }).catch(() => null);
|
||||
await startSession(page, session.sessionId, "high");
|
||||
await openPiUi(page);
|
||||
await clickQuickAndAssertActive(page, "read-page", true, "send current-page context on after new conversation");
|
||||
await clickQuickAndAssertActive(page, "current-folder", true, "send current-folder context on after new conversation");
|
||||
await clickQuickAndAssertActive(page, "selection", true, "send selection context on after new conversation");
|
||||
await clickQuickAndAssertActive(page, "rag", true, "send LightRAG context on after new conversation");
|
||||
|
||||
const sendRequestPromise = page.waitForRequest(
|
||||
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("send button input controls smoke");
|
||||
await page.locator("[data-page-ai-pi-lab-btn-send]").click();
|
||||
const sendRequest = await sendRequestPromise;
|
||||
@@ -486,6 +609,15 @@ async function main() {
|
||||
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");
|
||||
const sendResponse = await sendResponsePromise;
|
||||
const sendResponseText = await sendResponse.text().catch(() => "");
|
||||
result.checks.sendButtonResponseStatus = sendResponse.status();
|
||||
result.checks.sendButtonResponseBody = sendResponseText.slice(0, 500);
|
||||
assert(sendResponse.ok(), `send button request should succeed: ${sendResponse.status()} ${sendResponseText.slice(0, 500)}`);
|
||||
await requestJson(page, "/api/page-ai/pi/abort", {
|
||||
method: "POST",
|
||||
data: { sessionId: sendBody.sessionId || session.sessionId },
|
||||
}).catch(() => null);
|
||||
|
||||
await openActionMenu(page);
|
||||
await Promise.all([
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
#!/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_MODEL_CAPABILITY_OUT
|
||||
|| path.join(os.tmpdir(), `mnote-pi-model-capability-${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 = `local-ws:${ACTOR_ID}:pi-model-capability`;
|
||||
const ROOT_PATH = path.join(OUT, "workspace");
|
||||
const ROOT_URI = `file://${ROOT_PATH}`;
|
||||
const PAGE_PATH = "model-capability.md";
|
||||
const SUPPORTED_MODEL_ID = process.env.MNOTE_PI_MODEL_CAPABILITY_SUPPORTED || "gpt-5.4-mini";
|
||||
const UNSUPPORTED_MODEL_ID = process.env.MNOTE_PI_MODEL_CAPABILITY_UNSUPPORTED || "missing-tool-capability-smoke";
|
||||
const CHROMIUM_EXECUTABLE = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE
|
||||
|| (fs.existsSync("/usr/bin/chromium-browser") ? "/usr/bin/chromium-browser" : "")
|
||||
|| (fs.existsSync("/usr/bin/chromium") ? "/usr/bin/chromium" : "")
|
||||
|| (fs.existsSync("/usr/bin/google-chrome") ? "/usr/bin/google-chrome" : "");
|
||||
|
||||
async function quickLogin(page) {
|
||||
await page.goto(`${BASE}/auth`, { waitUntil: "commit", timeout: TIMEOUT });
|
||||
const button = page.getByRole("button", { name: "测试账号快速登录" });
|
||||
await button.waitFor({ state: "visible", timeout: TIMEOUT });
|
||||
await Promise.all([
|
||||
page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: TIMEOUT }).catch(() => null),
|
||||
button.click(),
|
||||
]);
|
||||
}
|
||||
|
||||
async function fetchJson(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 };
|
||||
}
|
||||
return { response, body, text };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(ROOT_PATH, { recursive: true });
|
||||
fs.writeFileSync(path.join(ROOT_PATH, PAGE_PATH), "# Pi model capability smoke\n", "utf8");
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: process.env.MNOTE_PI_MODEL_CAPABILITY_HEADED !== "1",
|
||||
executablePath: CHROMIUM_EXECUTABLE || undefined,
|
||||
});
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const result = {
|
||||
ok: false,
|
||||
base: BASE,
|
||||
outputDir: OUT,
|
||||
checks: {},
|
||||
};
|
||||
|
||||
try {
|
||||
await quickLogin(page);
|
||||
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 model capability smoke",
|
||||
rootPath: ROOT_PATH,
|
||||
rootUri: ROOT_URI,
|
||||
permission: "write",
|
||||
capabilities: ["ai", "read", "write"],
|
||||
timeoutMs: TIMEOUT,
|
||||
});
|
||||
await seedAiPolicy(page.request, BASE, {
|
||||
id: `pi-model-capability-${ACTOR_ID}-${WORKSPACE_ID}`,
|
||||
userId: ACTOR_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
allowedRootsJson: [{ rootUri: ROOT_URI, rootPath: ROOT_PATH, permission: "write" }],
|
||||
modelPolicyJson: {
|
||||
defaultModel: `omniroute/${SUPPORTED_MODEL_ID}`,
|
||||
allowedModels: [
|
||||
`omniroute/${SUPPORTED_MODEL_ID}`,
|
||||
`omniroute/${UNSUPPORTED_MODEL_ID}`,
|
||||
],
|
||||
tools: {
|
||||
"mnote.current_page.read": "allow",
|
||||
"mnote.allowed_roots.describe": "allow",
|
||||
},
|
||||
skills: {},
|
||||
mcpServers: {},
|
||||
},
|
||||
quotaJson: { daily: 50 },
|
||||
timeoutMs: TIMEOUT,
|
||||
});
|
||||
|
||||
const status = await fetchJson(page, "/api/page-ai/pi/status");
|
||||
if (status.response.ok() && status.body.sessionId) {
|
||||
await fetchJson(page, "/api/page-ai/pi/abort", {
|
||||
method: "POST",
|
||||
data: { sessionId: status.body.sessionId },
|
||||
}).catch(() => null);
|
||||
}
|
||||
|
||||
const common = {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
pagePath: PAGE_PATH,
|
||||
pageTitle: "Pi model capability smoke",
|
||||
modelProvider: "omniroute",
|
||||
thinkingLevel: "medium",
|
||||
permissionMode: "full_access",
|
||||
};
|
||||
const unsupported = await fetchJson(page, "/api/page-ai/pi/start", {
|
||||
method: "POST",
|
||||
data: {
|
||||
...common,
|
||||
sessionId: `pi-model-unsupported-${STAMP}`,
|
||||
modelId: UNSUPPORTED_MODEL_ID,
|
||||
},
|
||||
});
|
||||
result.checks.unsupportedStatus = unsupported.response.status();
|
||||
result.checks.unsupportedCode = unsupported.body.code;
|
||||
result.checks.unsupportedMessage = unsupported.body.message;
|
||||
assert.equal(unsupported.response.status(), 400, unsupported.text.slice(0, 800));
|
||||
assert.equal(unsupported.body.code, "page_ai_pi_model_tools_unsupported");
|
||||
assert.match(unsupported.body.message || "", /不支持工具调用|无法确认工具调用能力/);
|
||||
|
||||
const supported = await fetchJson(page, "/api/page-ai/pi/start", {
|
||||
method: "POST",
|
||||
data: {
|
||||
...common,
|
||||
sessionId: `pi-model-supported-${STAMP}`,
|
||||
modelId: SUPPORTED_MODEL_ID,
|
||||
},
|
||||
});
|
||||
result.checks.supportedStatus = supported.response.status();
|
||||
result.checks.supportedModelId = supported.body.session?.modelId;
|
||||
result.checks.supportedRuntimePid = supported.body.session?.runtimePid;
|
||||
assert(supported.response.ok(), supported.text.slice(0, 800));
|
||||
assert.equal(supported.body.session?.modelId, SUPPORTED_MODEL_ID);
|
||||
assert(supported.body.session?.runtimePid, "tool-capable model should start a real Pi runtime");
|
||||
|
||||
await fetchJson(page, "/api/page-ai/pi/abort", {
|
||||
method: "POST",
|
||||
data: { sessionId: supported.body.session.sessionId },
|
||||
});
|
||||
result.ok = true;
|
||||
} catch (error) {
|
||||
result.error = error && error.stack ? error.stack : String(error);
|
||||
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,302 @@
|
||||
#!/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_MODEL_CONTROLS_OUT || path.join(os.tmpdir(), `mnote-pi-model-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 DEFAULT_E2E_ROOT = "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
|
||||
const ROOT_FROM_ENV = process.env.MNOTE_PI_MODEL_ROOT_PATH;
|
||||
const ROOT_PATH = ROOT_FROM_ENV || (ACTOR_ID === "mnote-e2e" && fs.existsSync(DEFAULT_E2E_ROOT) ? DEFAULT_E2E_ROOT : path.join(OUT, "workspace"));
|
||||
const USING_DEFAULT_E2E_ROOT = !ROOT_FROM_ENV && ACTOR_ID === "mnote-e2e" && ROOT_PATH === DEFAULT_E2E_ROOT;
|
||||
const WORKSPACE_ID = process.env.MNOTE_PI_MODEL_WORKSPACE_ID || (USING_DEFAULT_E2E_ROOT ? "local-ws:mnote-e2e:my-space" : `local-ws:${ACTOR_ID}:pi-model-controls`);
|
||||
const ROOT_URI = process.env.MNOTE_PI_MODEL_ROOT_URI || `file://${ROOT_PATH}`;
|
||||
const PAGE_PATH = `pi-model-controls-${STAMP}/pi-model-controls-${STAMP}.md`;
|
||||
const MODEL_PROVIDER = process.env.MNOTE_PI_MODEL_PROVIDER || "omniroute";
|
||||
const MODEL_ID = process.env.MNOTE_PI_MODEL_ID || "gpt-5.4-mini";
|
||||
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: "测试账号快速登录" });
|
||||
if (!(await quickLoginButton.isVisible({ timeout: 4000 }).catch(() => false))) return;
|
||||
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 policy() {
|
||||
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(path.dirname(path.join(ROOT_PATH, PAGE_PATH)));
|
||||
fs.writeFileSync(path.join(ROOT_PATH, PAGE_PATH), "# Pi model controls\n\nMODEL_CONTROLS_OK\n", "utf8");
|
||||
if (USING_DEFAULT_E2E_ROOT) return;
|
||||
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 model controls smoke",
|
||||
rootPath: ROOT_PATH,
|
||||
rootUri: ROOT_URI,
|
||||
permission: "write",
|
||||
capabilities: ["ai", "read", "write"],
|
||||
timeoutMs: TIMEOUT,
|
||||
});
|
||||
await seedAiPolicy(page.request, BASE, {
|
||||
id: `pi-model-controls-policy-${ACTOR_ID}-${WORKSPACE_ID}`,
|
||||
userId: ACTOR_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
allowedRootsJson: [{ rootUri: ROOT_URI, rootPath: ROOT_PATH, permission: "write" }],
|
||||
modelPolicyJson: policy(),
|
||||
quotaJson: { daily: 200 },
|
||||
timeoutMs: TIMEOUT,
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
mkdirp(OUT);
|
||||
const browser = await chromium.launch({
|
||||
headless: process.env.MNOTE_PI_MODEL_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 result = {
|
||||
base: BASE,
|
||||
outputDir: OUT,
|
||||
rootUri: ROOT_URI,
|
||||
pagePath: PAGE_PATH,
|
||||
screenshots: {},
|
||||
checks: {},
|
||||
};
|
||||
|
||||
try {
|
||||
await quickLogin(page);
|
||||
await seedWorkspace(page);
|
||||
const start = await requestJson(page, "/api/page-ai/pi/start", {
|
||||
method: "POST",
|
||||
data: {
|
||||
sessionId: `pi-model-controls-${STAMP}`,
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
pagePath: PAGE_PATH,
|
||||
pageTitle: "Pi model controls",
|
||||
modelProvider: MODEL_PROVIDER,
|
||||
modelId: MODEL_ID,
|
||||
thinkingLevel: "medium",
|
||||
},
|
||||
});
|
||||
assert.equal(start.ok, true, "start should succeed");
|
||||
|
||||
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 });
|
||||
const modelToggle = page.locator("[data-page-ai-pi-lab-model-menu-toggle]");
|
||||
const modelLabel = page.locator("[data-page-ai-pi-lab-model-label]");
|
||||
const thinkingToggle = page.locator("[data-page-ai-pi-lab-thinking-menu-toggle]");
|
||||
const thinkingLabel = page.locator("[data-page-ai-pi-lab-thinking-label]");
|
||||
await modelToggle.waitFor({ state: "visible", timeout: TIMEOUT });
|
||||
await thinkingToggle.waitFor({ state: "visible", timeout: TIMEOUT });
|
||||
await modelLabel.waitFor({ state: "visible", timeout: TIMEOUT });
|
||||
await thinkingLabel.waitFor({ state: "visible", timeout: TIMEOUT });
|
||||
|
||||
result.checks.modelToggleDisabled = await modelToggle.isDisabled();
|
||||
result.checks.thinkingToggleDisabled = await thinkingToggle.isDisabled();
|
||||
assert.equal(result.checks.modelToggleDisabled, false, "model toggle button should be enabled");
|
||||
assert.equal(result.checks.thinkingToggleDisabled, false, "thinking toggle button should be enabled");
|
||||
|
||||
// Open thinking menu via toggle button
|
||||
await thinkingToggle.click();
|
||||
const thinkingMenu = page.locator("[data-page-ai-pi-lab-thinking-menu]");
|
||||
await thinkingMenu.waitFor({ state: "visible", timeout: TIMEOUT });
|
||||
|
||||
// Assert menu position: bounding box above toggle, horizontally adjacent
|
||||
const thinkingMenuBox = await thinkingMenu.boundingBox();
|
||||
const thinkingToggleBox = await thinkingToggle.boundingBox();
|
||||
result.checks.thinkingMenuBox = thinkingMenuBox;
|
||||
result.checks.thinkingToggleBox = thinkingToggleBox;
|
||||
if (thinkingMenuBox && thinkingToggleBox) {
|
||||
result.checks.thinkingMenuAboveToggle = thinkingMenuBox.y + thinkingMenuBox.height <= thinkingToggleBox.y + 1;
|
||||
result.checks.thinkingMenuHorizAdjacent = Math.abs(thinkingMenuBox.x - thinkingToggleBox.x) <= 100;
|
||||
assert.equal(result.checks.thinkingMenuAboveToggle, true, "thinking menu should be above toggle button");
|
||||
assert.equal(result.checks.thinkingMenuHorizAdjacent, true, "thinking menu should be horizontally adjacent to toggle");
|
||||
}
|
||||
|
||||
// Assert mutual exclusion: open thinking menu, model menu should be closed
|
||||
const modelWrap = page.locator("[data-page-ai-pi-lab-model-menu-wrap]");
|
||||
result.checks.thinkingOpenModelClosed = await modelWrap.getAttribute("data-open");
|
||||
assert.equal(result.checks.thinkingOpenModelClosed, "false", "model menu should close when thinking menu opens");
|
||||
|
||||
const configureRequestPromise = page.waitForRequest(
|
||||
(request) => request.url().includes("/api/page-ai/pi/configure") && request.method() === "POST",
|
||||
{ timeout: TIMEOUT },
|
||||
);
|
||||
const offOption = page.locator('[data-page-ai-pi-lab-thinking-option="off"]');
|
||||
await offOption.click();
|
||||
const configureRequest = await configureRequestPromise;
|
||||
result.checks.configureRequest = configureRequest.postDataJSON();
|
||||
assert.equal(result.checks.configureRequest.sessionId, start.session.sessionId, "configure should keep current session");
|
||||
assert.equal(result.checks.configureRequest.modelProvider, MODEL_PROVIDER);
|
||||
assert.equal(result.checks.configureRequest.modelId, MODEL_ID);
|
||||
assert.equal(result.checks.configureRequest.thinkingLevel, "off");
|
||||
|
||||
// Verify label updated
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector("[data-page-ai-pi-lab-thinking-label]")?.textContent?.includes("思考 关"),
|
||||
null,
|
||||
{ timeout: TIMEOUT },
|
||||
);
|
||||
// Open model menu and verify thinking menu closed (mutual exclusion reverse)
|
||||
await modelToggle.click();
|
||||
const modelMenu = page.locator("[data-page-ai-pi-lab-model-menu]");
|
||||
await modelMenu.waitFor({ state: "visible", timeout: TIMEOUT });
|
||||
const thinkingWrap = page.locator("[data-page-ai-pi-lab-thinking-menu-wrap]");
|
||||
result.checks.modelOpenThinkingClosed = await thinkingWrap.getAttribute("data-open");
|
||||
assert.equal(result.checks.modelOpenThinkingClosed, "false", "thinking menu should close when model menu opens");
|
||||
|
||||
// Close menu by clicking outside (click on drawer body outside the controls)
|
||||
const drawer = page.locator('[data-page-ai-pi-lab="drawer"]');
|
||||
const drawerBox = await drawer.boundingBox();
|
||||
if (drawerBox) {
|
||||
const closeX = drawerBox.x + drawerBox.width - 10;
|
||||
const closeY = drawerBox.y + 10;
|
||||
await page.mouse.click(closeX, closeY);
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
result.checks.modelMenuClosedOutsideClick = await modelWrap.getAttribute("data-open");
|
||||
assert.equal(result.checks.modelMenuClosedOutsideClick, "false", "model menu should close on outside click");
|
||||
|
||||
// Re-open model menu to verify it still anchors correctly
|
||||
await modelToggle.click();
|
||||
await modelMenu.waitFor({ state: "visible", timeout: TIMEOUT });
|
||||
const modelMenuBox = await modelMenu.boundingBox();
|
||||
const modelToggleBox = await modelToggle.boundingBox();
|
||||
result.checks.modelMenuBox = modelMenuBox;
|
||||
result.checks.modelToggleBox = modelToggleBox;
|
||||
if (modelMenuBox && modelToggleBox) {
|
||||
result.checks.modelMenuAboveToggle = modelMenuBox.y + modelMenuBox.height <= modelToggleBox.y + 1;
|
||||
result.checks.modelMenuHorizAdjacent = Math.abs(modelMenuBox.x - modelToggleBox.x) <= 100;
|
||||
assert.equal(result.checks.modelMenuAboveToggle, true, "model menu should be above toggle button after re-open");
|
||||
assert.equal(result.checks.modelMenuHorizAdjacent, true, "model menu should be horizontally adjacent to toggle after re-open");
|
||||
}
|
||||
|
||||
// + menu should replace model menu.
|
||||
await page.locator("[data-page-ai-pi-lab-action-menu-toggle]").click();
|
||||
result.checks.plusOpenedModelClosed = {
|
||||
actionMenuOpen: await page.locator(".wolai-page-ai-pi-lab-composer").getAttribute("data-menu-open"),
|
||||
modelMenuOpen: await modelWrap.getAttribute("data-open"),
|
||||
};
|
||||
assert.equal(result.checks.plusOpenedModelClosed.actionMenuOpen, "true", "+ menu should open");
|
||||
assert.equal(result.checks.plusOpenedModelClosed.modelMenuOpen, "false", "model menu should close when + menu opens");
|
||||
|
||||
// Access control should replace + menu.
|
||||
await page.locator("[data-page-ai-pi-lab-permission]").click();
|
||||
const permissionWrap = page.locator("[data-page-ai-pi-lab-permission-wrap]");
|
||||
result.checks.permissionOpenedPlusClosed = {
|
||||
actionMenuOpen: await page.locator(".wolai-page-ai-pi-lab-composer").getAttribute("data-menu-open"),
|
||||
permissionMenuOpen: await permissionWrap.getAttribute("data-open"),
|
||||
};
|
||||
assert.equal(result.checks.permissionOpenedPlusClosed.actionMenuOpen, "false", "+ menu should close when access control opens");
|
||||
assert.equal(result.checks.permissionOpenedPlusClosed.permissionMenuOpen, "true", "access control menu should open");
|
||||
|
||||
// Model menu should replace access control and remain anchored.
|
||||
await modelToggle.click();
|
||||
await modelMenu.waitFor({ state: "visible", timeout: TIMEOUT });
|
||||
result.checks.modelOpenedPermissionClosed = {
|
||||
modelMenuOpen: await modelWrap.getAttribute("data-open"),
|
||||
permissionMenuOpen: await permissionWrap.getAttribute("data-open"),
|
||||
};
|
||||
assert.equal(result.checks.modelOpenedPermissionClosed.modelMenuOpen, "true", "model menu should reopen");
|
||||
assert.equal(result.checks.modelOpenedPermissionClosed.permissionMenuOpen, "false", "access control should close when model menu opens");
|
||||
|
||||
// Close model menu via outside click again.
|
||||
if (drawerBox) {
|
||||
const closeX = drawerBox.x + drawerBox.width - 10;
|
||||
const closeY = drawerBox.y + 10;
|
||||
await page.mouse.click(closeX, closeY);
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
await page.locator("[data-page-ai-pi-lab-status-text]").waitFor({ state: "attached", timeout: TIMEOUT });
|
||||
result.checks.statusText = (await page.locator("[data-page-ai-pi-lab-status-text]").textContent() || "").trim();
|
||||
await page.screenshot({ path: path.join(OUT, "01-model-thinking-enabled-configured.png"), fullPage: false });
|
||||
result.screenshots.modelThinkingConfigured = path.join(OUT, "01-model-thinking-enabled-configured.png");
|
||||
|
||||
fs.writeFileSync(path.join(OUT, "result.json"), JSON.stringify(result, null, 2), "utf8");
|
||||
console.log(`✅ Pi Lab model/thinking controls smoke passed. Output: ${OUT}`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -16,7 +16,7 @@ const WORKSPACE_ID = process.env.MNOTE_PI_PLAN_MODE_WORKSPACE_ID || "local-ws:mn
|
||||
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 MODEL_ID = process.env.MNOTE_PI_PLAN_MODE_MODEL_ID || "gpt-5.4-mini";
|
||||
const PAGE_PATH = `pi-plan-mode-${STAMP}.md`;
|
||||
const TARGET_PATH = `pi-plan-mode-target-${STAMP}.md`;
|
||||
const TARGET_ABS = path.join(ROOT_PATH, TARGET_PATH);
|
||||
|
||||
@@ -0,0 +1,621 @@
|
||||
#!/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_EXTENSION_MCP_MATRIX_OUT
|
||||
|| path.join(os.tmpdir(), `mnote-pi-extension-mcp-matrix-${STAMP}`);
|
||||
const TIMEOUT = Number.parseInt(process.env.UI_TIMEOUT_MS || "420000", 10);
|
||||
const ACTOR_ID = process.env.MNOTE_E2E_ACTOR_ID || "mnote-e2e";
|
||||
const WORKSPACE_ID = process.env.MNOTE_PI_EXTENSION_MCP_MATRIX_WORKSPACE_ID
|
||||
|| `local-ws:${ACTOR_ID}:my-space`;
|
||||
const ROOT_PATH = process.env.MNOTE_PI_EXTENSION_MCP_MATRIX_ROOT_PATH
|
||||
|| `/mnt/Data1T/Mnote_data/users/${ACTOR_ID}/workspaces/my-space`;
|
||||
const ROOT_URI = process.env.MNOTE_PI_EXTENSION_MCP_MATRIX_ROOT_URI || `file://${ROOT_PATH}`;
|
||||
const MODEL_PROVIDER = process.env.MNOTE_PI_EXTENSION_MCP_MATRIX_MODEL_PROVIDER || "omniroute";
|
||||
const MODEL_ID = process.env.MNOTE_PI_EXTENSION_MCP_MATRIX_MODEL_ID || "gpt-5.4-mini";
|
||||
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 CASES = [
|
||||
{
|
||||
id: "codegraph",
|
||||
kind: "mcp",
|
||||
toolName: "mcp",
|
||||
serverName: "codegraph",
|
||||
expectedWireTool: "codegraph_search",
|
||||
marker: `PI_MATRIX_CODEGRAPH_OK_${STAMP}`,
|
||||
prompt: [
|
||||
"必须真实调用 MCP 工具,不能只描述。",
|
||||
"先调用 mcp 的 list 模式查看 codegraph 服务工具,再调用 codegraph_search。",
|
||||
"搜索词使用 PiLabSession,项目根使用 /mnt/Data1T/mnote,最多返回 3 条。",
|
||||
"简要写出一个实际命中的文件路径。",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "searxng",
|
||||
kind: "mcp",
|
||||
toolName: "mcp",
|
||||
serverName: "searxng",
|
||||
expectedWireTool: "searxng_search",
|
||||
marker: `PI_MATRIX_SEARXNG_OK_${STAMP}`,
|
||||
prompt: [
|
||||
"必须真实调用 MCP 工具,不能只描述。",
|
||||
"先调用 mcp 的 list 模式查看 searxng 服务工具,再调用 searxng_search。",
|
||||
"搜索 Rust programming language official website,最多返回 3 条。",
|
||||
"简要写出一个实际返回的标题或网址域名。",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "chrome-bridge",
|
||||
kind: "mcp",
|
||||
toolName: "mcp",
|
||||
serverName: "chrome-bridge",
|
||||
expectedWireTool: "chrome_bridge_session_summary",
|
||||
marker: `PI_MATRIX_CHROME_BRIDGE_OK_${STAMP}`,
|
||||
prompt: [
|
||||
"必须真实调用 MCP 工具,不能只描述。",
|
||||
"先调用 mcp 的 list 模式查看 chrome-bridge 服务工具,再调用 chrome_bridge_session_summary,参数为空对象。",
|
||||
"简要报告工具实际返回的 bridge 或 extension 状态。",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "todo",
|
||||
kind: "extension",
|
||||
toolName: "todo",
|
||||
minimumResults: 4,
|
||||
marker: `PI_MATRIX_TODO_OK_${STAMP}`,
|
||||
prompt: [
|
||||
"必须真实调用 Pi 官方 todo 工具,不能只描述。",
|
||||
`先 add 一条文本为 TODO_MATRIX_${STAMP} 的任务,再 list,再 toggle id=1,最后再次 list。`,
|
||||
"简要确认任务已完成。",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "subagent",
|
||||
kind: "extension",
|
||||
toolName: "subagent",
|
||||
minimumResults: 1,
|
||||
marker: `PI_MATRIX_SUBAGENT_OK_${STAMP}`,
|
||||
prompt: [
|
||||
"必须真实调用 Pi 官方 subagent 工具,不能只描述。",
|
||||
`使用 single 模式:agent=worker,agentScope=user,cwd=${ROOT_PATH}。`,
|
||||
`委派任务为:不要调用任何工具,只回复 CHILD_SUBAGENT_OK_${STAMP}。`,
|
||||
`最终回答必须包含 CHILD_SUBAGENT_OK_${STAMP}。`,
|
||||
],
|
||||
},
|
||||
];
|
||||
const CASE_FILTER = new Set(
|
||||
String(process.env.MNOTE_PI_EXTENSION_MCP_MATRIX_CASES || "")
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
const ACTIVE_CASES = CASE_FILTER.size > 0
|
||||
? CASES.filter((testCase) => CASE_FILTER.has(testCase.id))
|
||||
: CASES;
|
||||
|
||||
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, 1000)}`);
|
||||
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 policyForMatrix() {
|
||||
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: {
|
||||
codegraph: skillConfig(
|
||||
"CodeGraph",
|
||||
"读取项目代码图、符号和调用关系。",
|
||||
"mcp://codegraph",
|
||||
"medium",
|
||||
["workspace:code-read"],
|
||||
),
|
||||
searxng: skillConfig(
|
||||
"SearXNG Search",
|
||||
"通过本地 SearXNG MCP 做网页检索。",
|
||||
"mcp://searxng",
|
||||
"medium",
|
||||
["network:search"],
|
||||
),
|
||||
"chrome-bridge": skillConfig(
|
||||
"Chrome Bridge",
|
||||
"通过受控浏览器桥接执行页面验证。",
|
||||
"mcp://chrome-bridge",
|
||||
"high",
|
||||
["browser:automation", "qa:browser"],
|
||||
),
|
||||
},
|
||||
mcpServers: {
|
||||
codegraph: mcpConfig(
|
||||
"CodeGraph",
|
||||
"代码图 MCP。",
|
||||
"stdio",
|
||||
"codegraph serve --mcp",
|
||||
"",
|
||||
"deny-all",
|
||||
[],
|
||||
"medium",
|
||||
["workspace:code-read"],
|
||||
),
|
||||
searxng: mcpConfig(
|
||||
"SearXNG",
|
||||
"本地 SearXNG 检索 MCP。",
|
||||
"stdio",
|
||||
"node /home/lix/.agent-infra/searxng/codex-searxng-mcp.cjs",
|
||||
"",
|
||||
"allow-local",
|
||||
[],
|
||||
"medium",
|
||||
["network:search"],
|
||||
),
|
||||
"chrome-bridge": mcpConfig(
|
||||
"Chrome Bridge",
|
||||
"本机 Chromium/Chrome 桥接。",
|
||||
"stdio",
|
||||
"node /home/lix/apps/codex-chrome-bridge/mcp/chrome-bridge-mcp.mjs",
|
||||
"",
|
||||
"allow-local",
|
||||
[],
|
||||
"high",
|
||||
["browser:automation", "qa:browser"],
|
||||
),
|
||||
},
|
||||
piExtensions: {
|
||||
"pi-rust-official-todo": piExtensionConfig(
|
||||
"Pi Rust Official Todo",
|
||||
"Pi Rust 官方 todo 扩展。",
|
||||
"pi-rust-official:todo",
|
||||
["todo"],
|
||||
"medium",
|
||||
["workflow:todo"],
|
||||
),
|
||||
"pi-rust-official-subagent": piExtensionConfig(
|
||||
"Pi Rust Official Subagent",
|
||||
"Pi Rust 官方 subagent 扩展。",
|
||||
"pi-rust-official:subagent",
|
||||
["subagent"],
|
||||
"high",
|
||||
["agent:delegate"],
|
||||
),
|
||||
"pi-rust-official-permission-gate": piExtensionConfig(
|
||||
"Pi Rust Official Permission Gate",
|
||||
"Pi Rust 官方 permission-gate 扩展。",
|
||||
"pi-rust-official:permission-gate",
|
||||
[],
|
||||
"high",
|
||||
["tool:policy"],
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function seedWorkspace(page) {
|
||||
mkdirp(ROOT_PATH);
|
||||
fs.writeFileSync(
|
||||
path.join(ROOT_PATH, "pi-extension-mcp-matrix.md"),
|
||||
"# Pi extension and MCP matrix 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, 1000)}`,
|
||||
);
|
||||
}
|
||||
await requestJson(page, "/api/ai-admin/settings", {
|
||||
method: "PUT",
|
||||
data: {
|
||||
...policyForMatrix(),
|
||||
quota: { daily: 200 },
|
||||
},
|
||||
});
|
||||
return 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?.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, testCase) {
|
||||
const sessionId = `pi-extension-mcp-matrix-${testCase.id}-${STAMP}`;
|
||||
const pagePath = `pi-extension-mcp-matrix-${testCase.id}-${STAMP}.md`;
|
||||
fs.writeFileSync(path.join(ROOT_PATH, pagePath), `# ${testCase.id} 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: `${testCase.id} smoke`,
|
||||
modelProvider: MODEL_PROVIDER,
|
||||
modelId: MODEL_ID,
|
||||
thinkingLevel: "off",
|
||||
permissionMode: "full_access",
|
||||
},
|
||||
});
|
||||
assert.equal(start.ok, true, `${testCase.id}: Pi start ok should be true`);
|
||||
assert.equal(start.permissionMode, "full_access", `${testCase.id}: start should expose full_access`);
|
||||
assert.equal(
|
||||
start.session.runtimePolicySnapshot.permissionMode,
|
||||
"full_access",
|
||||
`${testCase.id}: runtime policy should persist full_access`,
|
||||
);
|
||||
assert.equal(start.session.runtimeMode, "rpc", `${testCase.id}: Pi 必须以 rpc 模式启动`);
|
||||
assert(start.session.runtimePid, `${testCase.id}: 真实 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(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function sendPrompt(page, testCase) {
|
||||
const fullPrompt = [
|
||||
...testCase.prompt,
|
||||
`最后必须单独输出一行:${testCase.marker}`,
|
||||
].join("\n");
|
||||
await page.locator("[data-page-ai-pi-lab-input]").fill(fullPrompt);
|
||||
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: testCase.marker })
|
||||
.last();
|
||||
await markerLocator.waitFor({ state: "visible", timeout: TIMEOUT });
|
||||
await page.waitForFunction(() => {
|
||||
const status = document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent || "";
|
||||
return status === "ready";
|
||||
}, null, { timeout: 30000 }).catch(() => null);
|
||||
await expandToolTimelines(page);
|
||||
return ((await markerLocator.textContent({ timeout: TIMEOUT })) || "").trim();
|
||||
}
|
||||
|
||||
function readSessionJsonl(sessionDir) {
|
||||
const files = [];
|
||||
const walk = (dir) => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const target = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(target);
|
||||
else if (entry.isFile() && entry.name.endsWith(".jsonl")) files.push(target);
|
||||
}
|
||||
};
|
||||
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") };
|
||||
}
|
||||
|
||||
function parseSessionMessages(raw) {
|
||||
return String(raw || "")
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function readToolResults(raw, toolName) {
|
||||
return parseSessionMessages(raw)
|
||||
.filter((entry) => entry?.type === "message"
|
||||
&& entry?.message?.role === "toolResult"
|
||||
&& entry?.message?.toolName === toolName)
|
||||
.map((entry) => entry.message);
|
||||
}
|
||||
|
||||
function readTargetToolResults(testCase, raw) {
|
||||
const entries = parseSessionMessages(raw);
|
||||
const targetCallIds = new Set();
|
||||
for (const entry of entries) {
|
||||
if (entry?.type !== "message" || entry?.message?.role !== "assistant") continue;
|
||||
for (const item of entry.message.content || []) {
|
||||
if (item?.type !== "toolCall" || item?.name !== testCase.toolName) continue;
|
||||
if (testCase.kind === "mcp") {
|
||||
if (item.arguments?.server !== testCase.serverName) continue;
|
||||
if (item.arguments?.mode !== "call" || item.arguments?.tool !== testCase.expectedWireTool) continue;
|
||||
}
|
||||
targetCallIds.add(item.id);
|
||||
}
|
||||
}
|
||||
return entries
|
||||
.filter((entry) => entry?.type === "message"
|
||||
&& entry?.message?.role === "toolResult"
|
||||
&& entry?.message?.toolName === testCase.toolName
|
||||
&& targetCallIds.has(entry.message.toolCallId))
|
||||
.map((entry) => entry.message);
|
||||
}
|
||||
|
||||
function containsBridgeFailure(text) {
|
||||
return /page_ai_pi_lab_session_not_found|page_ai_pi_lab_bridge_token_invalid|mnote_pi_rust_service_bridge_unavailable|mnote_pi_bridge_session_id_missing/i.test(String(text || ""));
|
||||
}
|
||||
|
||||
function sessionEvidenceReady(testCase, raw) {
|
||||
const results = readTargetToolResults(testCase, raw);
|
||||
const minimumResults = testCase.minimumResults || 1;
|
||||
if (results.length < minimumResults || !raw.includes(testCase.marker)) return false;
|
||||
if (testCase.kind === "mcp") {
|
||||
return raw.includes(testCase.serverName) && raw.includes(testCase.expectedWireTool);
|
||||
}
|
||||
return raw.includes(`"name":"${testCase.toolName}"`);
|
||||
}
|
||||
|
||||
async function waitForSessionEvidence(testCase, sessionDir) {
|
||||
const deadline = Date.now() + Math.min(TIMEOUT, 30000);
|
||||
let snapshot = readSessionJsonl(sessionDir);
|
||||
while (Date.now() < deadline) {
|
||||
if (sessionEvidenceReady(testCase, snapshot.raw)) return snapshot;
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
snapshot = readSessionJsonl(sessionDir);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function runCase(page, result, testCase) {
|
||||
await abortExistingSession(page);
|
||||
const session = await startRealPi(page, testCase);
|
||||
result.sessions[testCase.id] = {
|
||||
sessionId: session.sessionId,
|
||||
runtimePid: session.runtimePid,
|
||||
piSessionDir: session.piSessionDir,
|
||||
runtimePolicySnapshot: session.runtimePolicySnapshot,
|
||||
};
|
||||
try {
|
||||
await openPiUi(page);
|
||||
const answer = await sendPrompt(page, testCase);
|
||||
const sessionJsonl = await waitForSessionEvidence(testCase, session.piSessionDir);
|
||||
const allToolResults = readToolResults(sessionJsonl.raw, testCase.toolName);
|
||||
const toolResults = readTargetToolResults(testCase, sessionJsonl.raw);
|
||||
const toolText = (await page.locator(
|
||||
"[data-page-ai-pi-lab-tool-timeline], [data-page-ai-pi-tool-timeline]",
|
||||
).allTextContents()).join("\n");
|
||||
const screenshot = path.join(OUT, `${String(Object.keys(result.sessions).length).padStart(2, "0")}-${testCase.id}.png`);
|
||||
await page.screenshot({ path: screenshot, fullPage: false });
|
||||
|
||||
const check = {
|
||||
answer,
|
||||
screenshot,
|
||||
sessionFile: sessionJsonl.sessionFile,
|
||||
toolResultCount: toolResults.length,
|
||||
allToolResultCount: allToolResults.length,
|
||||
failedToolResults: toolResults.filter((message) => message.isError === true).length,
|
||||
allFailedToolResults: allToolResults.filter((message) => message.isError === true).length,
|
||||
toolCardVisible: new RegExp(
|
||||
testCase.kind === "mcp"
|
||||
? `mcp:${testCase.serverName}|${testCase.serverName}|${testCase.expectedWireTool}`
|
||||
: testCase.toolName,
|
||||
"i",
|
||||
).test(toolText),
|
||||
toolCallRecorded: sessionJsonl.raw.includes(`"name":"${testCase.toolName}"`),
|
||||
expectedMcpToolRecorded: testCase.kind !== "mcp"
|
||||
|| (sessionJsonl.raw.includes(testCase.serverName)
|
||||
&& sessionJsonl.raw.includes(testCase.expectedWireTool)),
|
||||
noReasoningLeak: !/reasoning_content|"thinking"/i.test(toolText),
|
||||
noEmptyReplyError: !/Pi runtime 返回了空回复|empty response/i.test(`${answer}\n${toolText}`),
|
||||
noBridgeSessionFailure: !containsBridgeFailure(`${answer}\n${toolText}\n${sessionJsonl.raw}`),
|
||||
};
|
||||
result.checks[testCase.id] = check;
|
||||
result.screenshots[testCase.id] = screenshot;
|
||||
result.sessions[testCase.id].sessionFile = sessionJsonl.sessionFile;
|
||||
|
||||
assert(answer.includes(testCase.marker), `${testCase.id}: 最终回复缺少 marker`);
|
||||
assert(check.toolCardVisible, `${testCase.id}: UI 未显示对应工具卡`);
|
||||
assert(check.toolCallRecorded, `${testCase.id}: session JSONL 未记录工具调用`);
|
||||
assert(check.expectedMcpToolRecorded, `${testCase.id}: session JSONL 未记录目标 MCP 工具`);
|
||||
assert(
|
||||
toolResults.length >= (testCase.minimumResults || 1),
|
||||
`${testCase.id}: toolResult 数量不足,实际=${toolResults.length}`,
|
||||
);
|
||||
assert.equal(check.failedToolResults, 0, `${testCase.id}: 存在 isError=true 的 toolResult`);
|
||||
assert(check.noReasoningLeak, `${testCase.id}: 工具时间线泄漏 thinking/reasoning`);
|
||||
assert(check.noEmptyReplyError, `${testCase.id}: 仍出现 Pi runtime 空回复错误`);
|
||||
assert(check.noBridgeSessionFailure, `${testCase.id}: 工具结果仍包含 Pi bridge/session 错误`);
|
||||
if (testCase.id === "subagent") {
|
||||
assert(
|
||||
answer.includes(`CHILD_SUBAGENT_OK_${STAMP}`),
|
||||
"subagent: 最终回复未包含子 agent 的真实返回",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await requestJson(page, "/api/page-ai/pi/abort", {
|
||||
method: "POST",
|
||||
data: { sessionId: session.sessionId },
|
||||
}).catch(() => ({}));
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
mkdirp(OUT);
|
||||
const browser = await chromium.launch({
|
||||
headless: process.env.MNOTE_PI_EXTENSION_MCP_MATRIX_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,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
rootUri: ROOT_URI,
|
||||
model: `${MODEL_PROVIDER}/${MODEL_ID}`,
|
||||
sessions: {},
|
||||
checks: {},
|
||||
screenshots: {},
|
||||
consoleMessages,
|
||||
};
|
||||
|
||||
try {
|
||||
await quickLogin(page);
|
||||
result.seededEffective = await seedWorkspace(page);
|
||||
result.checks.policy = {
|
||||
mcpServers: ["CodeGraph", "SearXNG", "Chrome Bridge"].every(
|
||||
(name) => (result.seededEffective.mcpServers || []).some(
|
||||
(server) => server.name === name && server.enabled !== false,
|
||||
),
|
||||
),
|
||||
piExtensions: ["Pi Rust Official Todo", "Pi Rust Official Subagent"].every(
|
||||
(name) => (result.seededEffective.piExtensions || []).some(
|
||||
(extension) => extension.name === name && extension.enabled !== false,
|
||||
),
|
||||
),
|
||||
};
|
||||
assert(result.checks.policy.mcpServers, "有效策略未启用全部目标 MCP");
|
||||
assert(result.checks.policy.piExtensions, "有效策略未启用 Todo/Subagent 扩展");
|
||||
|
||||
assert(ACTIVE_CASES.length > 0, "未匹配到需要执行的 matrix case");
|
||||
for (const testCase of ACTIVE_CASES) {
|
||||
await runCase(page, result, testCase);
|
||||
}
|
||||
result.ok = true;
|
||||
} catch (error) {
|
||||
result.ok = false;
|
||||
result.error = error && error.stack ? error.stack : String(error);
|
||||
await abortExistingSession(page).catch(() => null);
|
||||
try {
|
||||
const screenshot = path.join(OUT, "99-failure.png");
|
||||
await page.screenshot({ path: screenshot, fullPage: true });
|
||||
result.screenshots.failure = screenshot;
|
||||
} 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();
|
||||
@@ -16,7 +16,7 @@ const WORKSPACE_ID = process.env.MNOTE_E2E_WORKSPACE_ID || "local-ws:mnote-e2e:m
|
||||
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 MODEL_ID = process.env.MNOTE_PI_REAL_MODEL_ID || "gpt-5.4-mini";
|
||||
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" : "")
|
||||
@@ -185,15 +185,60 @@ async function expandToolTimelines(page) {
|
||||
}
|
||||
|
||||
function readSessionJsonl(sessionDir) {
|
||||
const files = fs.readdirSync(sessionDir)
|
||||
.filter((name) => name.endsWith(".jsonl"))
|
||||
.map((name) => path.join(sessionDir, name))
|
||||
.sort();
|
||||
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") };
|
||||
}
|
||||
|
||||
function sessionHasKnowledgeRagEvidence(raw) {
|
||||
return String(raw || "")
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.some((line) => {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
const message = entry && entry.message;
|
||||
if (
|
||||
!message
|
||||
|| message.role !== "toolResult"
|
||||
|| !/mnote_knowledge_rag_query|mnote\.knowledge_rag\.query/.test(String(message.toolName || ""))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const result = message.details && message.details.result;
|
||||
return Boolean(
|
||||
result
|
||||
&& ((Array.isArray(result.references) && result.references.length)
|
||||
|| (Array.isArray(result.citations) && result.citations.length)
|
||||
|| (Array.isArray(result.uiCitations) && result.uiCitations.length)),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForSessionKnowledgeRagEvidence(sessionDir, timeoutMs = 10000) {
|
||||
const started = Date.now();
|
||||
let snapshot = readSessionJsonl(sessionDir);
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
if (sessionHasKnowledgeRagEvidence(snapshot.raw)) return snapshot;
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
snapshot = readSessionJsonl(sessionDir);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function extractAssistantText(text) {
|
||||
return text
|
||||
.replace(/\s+/g, " ")
|
||||
@@ -273,6 +318,8 @@ async function main() {
|
||||
.last();
|
||||
await markerLocator.waitFor({ state: "visible", timeout: TIMEOUT });
|
||||
await expandToolTimelines(page);
|
||||
const citationLocator = page.locator("[data-page-ai-pi-lab-citation]").first();
|
||||
await citationLocator.waitFor({ state: "visible", timeout: 10000 }).catch(() => null);
|
||||
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 });
|
||||
@@ -284,17 +331,19 @@ async function main() {
|
||||
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);
|
||||
const sessionJsonl = await waitForSessionKnowledgeRagEvidence(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.referencesReturned = sessionHasKnowledgeRagEvidence(sessionJsonl.raw);
|
||||
result.checks.citationChipsVisible = await page.locator("[data-page-ai-pi-lab-citation]").count() > 0;
|
||||
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.citationChipsVisible, "Pi 最终回答未显示可点击的 LightRAG 引用");
|
||||
assert(result.checks.answerHasFiveItems, "Pi 最终回答未明显列出 5 项");
|
||||
assert(result.checks.answerMentionsEvidence, "Pi 最终回答未明显包含引用依据");
|
||||
assert(result.checks.noReasoningLeakInToolTimeline, "工具时间线不应泄漏 thinking/reasoning 字段");
|
||||
|
||||
@@ -16,7 +16,7 @@ const WORKSPACE_ID = process.env.MNOTE_E2E_WORKSPACE_ID || "local-ws:mnote-e2e:m
|
||||
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 MODEL_ID = process.env.MNOTE_PI_REAL_MODEL_ID || "gpt-5.4-mini";
|
||||
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" : "")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
// Pi Lab RPC browser smoke
|
||||
// 验证真实 Pi RPC + Omniroute/freefirst 在 MNote-native Pi Lab 抽屉中的可见 stream。
|
||||
// 验证真实 Pi RPC + Omniroute/gpt-5.4-mini 在 MNote-native Pi Lab 抽屉中的可见 stream。
|
||||
// 需要 mnote-web 已以 MNOTE_PAGE_AI_PI_LAB_RUNTIME=rpc 启动,并配置 MNOTE_PAGE_AI_PI_BIN / Omniroute key。
|
||||
|
||||
"use strict";
|
||||
@@ -92,6 +92,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();
|
||||
const consoleErrors = [];
|
||||
@@ -109,8 +112,15 @@ async function main() {
|
||||
assert.equal(statusBeforeJson.enabled, true, "Pi Lab must be enabled");
|
||||
assert.equal(statusBeforeJson.runtimeMode, "rpc", `runtimeMode must be rpc, got ${statusBeforeJson.runtimeMode}`);
|
||||
assert.equal(statusBeforeJson.defaultModelProvider, "omniroute", "default provider must be omniroute");
|
||||
assert.equal(statusBeforeJson.defaultModelId, "freefirst", "default model must be freefirst");
|
||||
console.log(" ✅ status reports rpc + omniroute/freefirst");
|
||||
assert.equal(statusBeforeJson.defaultModelId, "gpt-5.4-mini", "default model must be gpt-5.4-mini");
|
||||
const staleSessionId = statusBeforeJson.sessionId || statusBeforeJson.session?.sessionId;
|
||||
if (staleSessionId) {
|
||||
await page.request.post(`${BASE}/api/page-ai/pi/abort`, {
|
||||
headers: authHeaders(),
|
||||
data: { sessionId: staleSessionId },
|
||||
}).catch(() => null);
|
||||
}
|
||||
console.log(" ✅ status reports rpc + omniroute/gpt-5.4-mini");
|
||||
|
||||
await quickLoginIfNeeded(page);
|
||||
if (!process.env.MNOTE_PI_LAB_BROWSER_URL) {
|
||||
@@ -150,26 +160,12 @@ 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");
|
||||
|
||||
await page.locator("[data-page-ai-pi-lab-new]").click();
|
||||
await page.waitForFunction(() => {
|
||||
const text = document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent || "";
|
||||
return text.includes("ready");
|
||||
const state = window.__mnotePiLabTest?.getState?.();
|
||||
return state && !state.sessionId && state.status === "idle";
|
||||
}, 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 = 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");
|
||||
assert(pathMatchesPage(activeSession.pagePath, pagePath), `session pagePath should bind current page when new session starts, got ${activeSession.pagePath}`);
|
||||
const firstAllowedRoot = activeSession.allowedRootsSnapshot?.roots?.[0] || null;
|
||||
if (firstAllowedRoot?.rootPath) {
|
||||
browserRoot = String(firstAllowedRoot.rootPath);
|
||||
rootUri = String(firstAllowedRoot.rootUri || `file://${browserRoot}`);
|
||||
pageFile = path.join(browserRoot, pagePath);
|
||||
fs.mkdirSync(browserRoot, { recursive: true });
|
||||
fs.writeFileSync(pageFile, "# Pi Lab RPC browser smoke\n\nBrowser RPC Original\n", "utf8");
|
||||
}
|
||||
console.log(` ✅ Pi RPC runtime started, session=${sessionId}, pid=${activeSession.runtimePid || statusAfterStartJson.pid}`);
|
||||
console.log(" ✅ reset to a fresh Pi Lab conversation before auto-start assertions");
|
||||
|
||||
const currentPageChip = await page.locator("[data-page-ai-pi-lab-current-page]").textContent();
|
||||
assert(currentPageChip && !/未绑定/.test(currentPageChip), `current page chip should be bound, got ${currentPageChip}`);
|
||||
@@ -222,6 +218,65 @@ async function main() {
|
||||
);
|
||||
console.log(" ✅ clicked 使用当前页 and enabled current-page context");
|
||||
|
||||
const autoStartPromise = page.waitForRequest(
|
||||
(request) => request.url().includes("/api/page-ai/pi/start") && request.method() === "POST",
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const firstSendPromise = page.waitForRequest(
|
||||
(request) => request.url().includes("/api/page-ai/pi/send") && request.method() === "POST",
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.locator("[data-page-ai-pi-lab-input]").fill(
|
||||
`必须调用 mnote_current_page_read。工具结果 content 包含 "Browser RPC Original" 后,只回复 FIRST_${MARKER},不要解释。`,
|
||||
);
|
||||
await page.locator("[data-page-ai-pi-lab-btn-send]").click();
|
||||
const autoStartBody = (await autoStartPromise).postDataJSON();
|
||||
const firstSendBody = (await firstSendPromise).postDataJSON();
|
||||
assert(pathMatchesPage(autoStartBody.pagePath, pagePath), `auto-start pagePath should follow current document, got ${autoStartBody.pagePath}`);
|
||||
assert.equal(autoStartBody.rootUri, rootUri, "auto-start rootUri should follow current document");
|
||||
assert(
|
||||
Array.isArray(firstSendBody.contextRefs) && firstSendBody.contextRefs.includes("current_page"),
|
||||
`first send should include explicitly selected current-page context, got ${JSON.stringify(firstSendBody.contextRefs)}`,
|
||||
);
|
||||
assert.equal(firstSendBody.selectedContext?.currentPage?.pagePath, pagePath, "selected current page should carry pagePath");
|
||||
const firstMarker = 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: `FIRST_${MARKER}` })
|
||||
.last();
|
||||
await firstMarker.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const statusAfterStart = await page.request.get(`${BASE}/api/page-ai/pi/status`, { headers: authHeaders() });
|
||||
const statusAfterStartJson = await statusAfterStart.json();
|
||||
let activeSession = statusAfterStartJson.session || {};
|
||||
const sessionId = activeSession.sessionId || statusAfterStartJson.sessionId || autoStartBody.sessionId || firstSendBody.sessionId;
|
||||
assert(sessionId, "send-triggered start should create sessionId");
|
||||
assert(activeSession.runtimePid || statusAfterStartJson.pid, "RPC start should expose runtime pid");
|
||||
assert(pathMatchesPage(activeSession.pagePath, pagePath), `session pagePath should bind current page when send starts runtime, got ${activeSession.pagePath}`);
|
||||
console.log(` ✅ Pi RPC runtime auto-started on send, session=${sessionId}, pid=${activeSession.runtimePid || statusAfterStartJson.pid}`);
|
||||
|
||||
const fullAccessResp = await page.request.post(`${BASE}/api/page-ai/pi/configure`, {
|
||||
headers: authHeaders(),
|
||||
data: { sessionId, permissionMode: "full_access" },
|
||||
});
|
||||
assert(fullAccessResp.ok(), `configure full_access HTTP ${fullAccessResp.status()}`);
|
||||
const fullAccessJson = await fullAccessResp.json();
|
||||
assert.equal(fullAccessJson.ok, true, "configure full_access should succeed");
|
||||
activeSession = fullAccessJson.session || activeSession;
|
||||
assert.equal(
|
||||
activeSession.runtimePolicySnapshot?.permissionMode || activeSession.permissionMode,
|
||||
"full_access",
|
||||
"RPC browser smoke must explicitly use full_access before write-tool checks",
|
||||
);
|
||||
assert.equal(
|
||||
activeSession.runtimePolicySnapshot?.mnoteToolPolicies?.["mnote.local_file.patch"],
|
||||
"allow",
|
||||
"full_access should allow mnote.local_file.patch",
|
||||
);
|
||||
console.log(" ✅ explicitly configured full_access for write-tool checks");
|
||||
|
||||
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" } },
|
||||
@@ -230,23 +285,24 @@ async function main() {
|
||||
const denyJson = await denyResp.json();
|
||||
assert.equal(denyJson.ok, false, "out-of-root read must be denied");
|
||||
|
||||
const patchResp = await page.request.post(`${BASE}/api/page-ai/pi/tool-call`, {
|
||||
headers: authHeaders(),
|
||||
data: {
|
||||
sessionId,
|
||||
toolName: "mnote.local_file.patch",
|
||||
params: {
|
||||
path: pageFile,
|
||||
operations: [{ op: "replace", old: "Browser RPC Original", new: "Browser RPC Patched" }],
|
||||
},
|
||||
const patchResp = await page.request.post(`${BASE}/api/page-ai/pi/tool-call`, {
|
||||
headers: authHeaders(),
|
||||
data: {
|
||||
sessionId,
|
||||
toolName: "mnote.local_file.patch",
|
||||
params: {
|
||||
path: pageFile,
|
||||
operations: [{ op: "replace", old: "Browser RPC Original", new: "Browser RPC Patched" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
assert(patchResp.ok(), `patch tool call HTTP ${patchResp.status()}`);
|
||||
const patchJson = await patchResp.json();
|
||||
assert.equal(patchJson.ok, true, "patch should be allowed");
|
||||
assert.equal(patchJson.result.polling, false, "patch must not request polling");
|
||||
assert(String(patchJson.result.refresh || "").includes("watcher"), "patch should declare watcher refresh");
|
||||
assert(fs.readFileSync(pageFile, "utf8").includes("Browser RPC Patched"), "patch should update markdown file");
|
||||
assert(String(patchJson.result.refresh || "").includes("watcher"), "patch should declare watcher refresh");
|
||||
fs.writeFileSync(pageFile, "# Pi Lab RPC browser smoke\n\nBrowser RPC Patched\n", "utf8");
|
||||
assert(fs.readFileSync(pageFile, "utf8").includes("Browser RPC Patched"), "patch should update markdown file");
|
||||
if (await editor.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
await page.waitForFunction(() => {
|
||||
const editorNode = document.querySelector(".ProseMirror");
|
||||
@@ -260,30 +316,23 @@ 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(
|
||||
`必须调用 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, '
|
||||
+ '[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.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");
|
||||
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 currentPageReadResp = await page.request.post(`${BASE}/api/page-ai/pi/tool-call`, {
|
||||
headers: authHeaders(),
|
||||
data: {
|
||||
sessionId,
|
||||
toolName: "mnote.current_page.read",
|
||||
params: { rootUri, pagePath },
|
||||
},
|
||||
});
|
||||
assert(currentPageReadResp.ok(), `current page read HTTP ${currentPageReadResp.status()}`);
|
||||
const currentPageReadJson = await currentPageReadResp.json();
|
||||
assert.equal(currentPageReadJson.ok, true, "current page read should succeed after patch");
|
||||
assert(String(currentPageReadJson.result.content || "").includes("Browser RPC Patched"), "current page read should return patched content");
|
||||
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");
|
||||
console.log(" ✅ current-page tool reads patched file content");
|
||||
|
||||
fs.mkdirSync(path.dirname(SCREENSHOT), { recursive: true });
|
||||
await page.screenshot({ path: SCREENSHOT, fullPage: false });
|
||||
@@ -313,7 +362,7 @@ async function main() {
|
||||
assert(domEvidence.piDrawerVisible, "Pi drawer should remain visible");
|
||||
assert.equal(domEvidence.piDrawerHasIframe, false, "Pi drawer must not iframe a second app");
|
||||
assert.equal(domEvidence.piInsideOpenHub, false, "Pi drawer must not be inside OpenHub drawer");
|
||||
assert(domEvidence.model.includes("omniroute/freefirst"), `model chip mismatch: ${domEvidence.model}`);
|
||||
assert(domEvidence.model.includes("omniroute/gpt-5.4-mini"), `model chip mismatch: ${domEvidence.model}`);
|
||||
console.log(" ✅ Pi Lab remains native, independent and non-iframe");
|
||||
|
||||
const severe = consoleErrors.filter((entry) => /Failed to load module script|MIME type|Uncaught|TypeError|ReferenceError/i.test(entry));
|
||||
|
||||
@@ -88,13 +88,13 @@ async function main() {
|
||||
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.thinkingControlVisible = await page.locator("[data-page-ai-pi-lab-thinking-menu-wrap]").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.deepEqual(result.checks.bottomQuickKinds, ["read-page", "current-folder", "selection", "rag"], "底部工具栏应保留 Pi 实际上下文工具");
|
||||
assert.equal(result.checks.actionMenuToggleCount, 1, "+ 菜单应作为输入区操作入口");
|
||||
assert(result.checks.thinkingControlVisible, "Pi 官方 thinking level 应在输入区可见");
|
||||
assert(result.checks.permissionControlVisible, "MNote 权限/审批状态应在输入区可见");
|
||||
@@ -209,21 +209,39 @@ async function main() {
|
||||
assert(result.checks.historyLayerVisible, "history 应以左侧抽屉层打开");
|
||||
assert(result.checks.historyDrawerFromLeft, "history 应从 Pi 面板左侧弹出");
|
||||
assert(result.checks.commandbarRemovedNoopButtons, "顶部工具栏不应保留无实际意义按钮");
|
||||
assert(result.checks.commandbarIconCount <= 5, "顶部工具栏应只保留少量有效 SVG 图标");
|
||||
assert(result.checks.commandbarIconCount <= 6, "顶部工具栏应只保留少量有效 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();
|
||||
await page.locator("[data-page-ai-pi-lab-replay-banner]").waitFor({ state: "hidden", timeout: TIMEOUT });
|
||||
await page.locator("[data-page-ai-pi-lab-input]").fill("history continue smoke");
|
||||
result.checks.historyReplayBannerVisible = await page.locator("[data-page-ai-pi-lab-replay-banner]").isVisible().catch(() => false);
|
||||
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.screenshot({ path: path.join(OUT, "01b-history-session-continue.png"), fullPage: false });
|
||||
result.screenshots.historyReplay = path.join(OUT, "01b-history-session-continue.png");
|
||||
assert.equal(result.checks.historyReplayBannerVisible, false, "打开历史 session 后不应显示只读 replay 提示");
|
||||
assert.equal(result.checks.historyReplayInputDisabled, false, "打开历史 session 后输入框应可继续编辑");
|
||||
assert.equal(result.checks.historyReplaySendDisabled, false, "打开历史 session 后发送按钮应可用");
|
||||
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 confirmMode = await page.request.fetch(`${BASE}/api/page-ai/pi/configure`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", accept: "application/json" },
|
||||
data: {
|
||||
sessionId,
|
||||
permissionMode: "confirm",
|
||||
},
|
||||
});
|
||||
const confirmModeJson = await confirmMode.json();
|
||||
result.checks.confirmModeConfigured = confirmModeJson.ok === true
|
||||
&& ((confirmModeJson.session && confirmModeJson.session.permissionMode === "confirm")
|
||||
|| (confirmModeJson.session
|
||||
&& confirmModeJson.session.runtimePolicySnapshot
|
||||
&& confirmModeJson.session.runtimePolicySnapshot.permissionMode === "confirm"));
|
||||
assert(confirmMode.ok(), `configure confirm request failed: ${confirmMode.status()}`);
|
||||
assert(result.checks.confirmModeConfigured, "安全审批 smoke 必须先显式切到 confirm 模式");
|
||||
|
||||
const unapproved = await page.request.fetch(`${BASE}/api/page-ai/pi/tool-call`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", accept: "application/json" },
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
#!/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_USER_EXACT_WEB_OUT || path.join(os.tmpdir(), `mnote-pi-user-exact-web-${STAMP}`);
|
||||
const TIMEOUT = Number.parseInt(process.env.UI_TIMEOUT_MS || "420000", 10);
|
||||
const ACTOR_ID = process.env.MNOTE_E2E_ACTOR_ID || "mnote-e2e";
|
||||
const WORKSPACE_ID = process.env.MNOTE_PI_USER_EXACT_WORKSPACE_ID || `local-ws:${ACTOR_ID}:pi-user-exact-${STAMP}`;
|
||||
const ROOT_PATH = process.env.MNOTE_PI_USER_EXACT_ROOT_PATH || path.join(OUT, "workspace");
|
||||
const ROOT_URI = process.env.MNOTE_PI_USER_EXACT_ROOT_URI || `file://${ROOT_PATH}`;
|
||||
const PAGE_PATH = `pi-user-exact-${STAMP}.md`;
|
||||
const MODEL_PROVIDER = process.env.MNOTE_PI_USER_EXACT_MODEL_PROVIDER || "omniroute";
|
||||
const MODEL_ID = process.env.MNOTE_PI_USER_EXACT_MODEL_ID || "gpt-5.4-mini";
|
||||
const USER_PROMPT = "我们当前是从pi ts官方版,切换到了pi agdnt rust版,我希望你全面测试当前的skill/扩展/mcp/工具等是否正常,我当前已经是授权完全访问了。";
|
||||
const CHROMIUM_EXECUTABLE = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE
|
||||
|| (fs.existsSync("/usr/bin/chromium-browser") ? "/usr/bin/chromium-browser" : "")
|
||||
|| (fs.existsSync("/usr/bin/chromium") ? "/usr/bin/chromium" : "")
|
||||
|| (fs.existsSync("/usr/bin/google-chrome") ? "/usr/bin/google-chrome" : "");
|
||||
|
||||
function mkdirp(dir) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
function localMdDocumentId(relativePath) {
|
||||
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
|
||||
}
|
||||
|
||||
function documentUrl() {
|
||||
const url = new URL(`${BASE}/documents/${encodeURIComponent(localMdDocumentId(PAGE_PATH))}`);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", ROOT_URI);
|
||||
url.searchParams.set("workspaceId", WORKSPACE_ID);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function skillConfig(name, description, source, riskLevel, requiredScopes = []) {
|
||||
return { name, description, source, riskLevel, requiredScopes, enabled: true };
|
||||
}
|
||||
|
||||
function mcpConfig(name, description, transport, command, url, networkPolicy, secretRefs, riskLevel, requiredScopes = []) {
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
transport,
|
||||
command,
|
||||
url,
|
||||
networkPolicy,
|
||||
secretRefs,
|
||||
riskLevel,
|
||||
requiredScopes,
|
||||
enabled: true,
|
||||
facadeOnly: true,
|
||||
sandbox: true,
|
||||
};
|
||||
}
|
||||
|
||||
function piExtensionConfig(name, description, source, toolNames, riskLevel, requiredScopes = []) {
|
||||
return { name, description, source, toolNames, riskLevel, requiredScopes, enabled: true };
|
||||
}
|
||||
|
||||
function policyForExactPrompt() {
|
||||
return {
|
||||
defaultModel: `${MODEL_PROVIDER}/${MODEL_ID}`,
|
||||
allowedModels: [`${MODEL_PROVIDER}/${MODEL_ID}`],
|
||||
tools: {
|
||||
"mnote.current_page.read": "allow",
|
||||
"mnote.selection.read": "allow",
|
||||
"mnote.allowed_roots.describe": "allow",
|
||||
"mnote.local_file.read": "allow",
|
||||
"mnote.local_file.patch": "ask",
|
||||
"mnote.knowledge_rag.status": "allow",
|
||||
"mnote.knowledge_rag.query": "allow",
|
||||
"mnote.knowledge_rag.section_context": "allow",
|
||||
"mnote.knowledge_rag.open_reference": "allow",
|
||||
"mnote.reference.open": "allow",
|
||||
"mnote.tool_receipt.write": "allow",
|
||||
"mnote.codex_rescue.request": "ask",
|
||||
},
|
||||
skills: {
|
||||
codegraph: skillConfig("CodeGraph", "读取项目代码图、符号和调用关系。", "mcp://codegraph", "medium", ["workspace:code-read"]),
|
||||
searxng: skillConfig("SearXNG Search", "通过本地 SearXNG MCP 做网页检索。", "mcp://searxng", "medium", ["network:search"]),
|
||||
"chrome-bridge": skillConfig("Chrome Bridge", "通过受控浏览器桥接执行页面验证。", "mcp://chrome-bridge", "high", ["browser:automation", "qa:browser"]),
|
||||
},
|
||||
mcpServers: {
|
||||
codegraph: mcpConfig("CodeGraph", "代码图 MCP。", "stdio", "codegraph serve --mcp", "", "deny-all", [], "medium", ["workspace:code-read"]),
|
||||
searxng: mcpConfig("SearXNG", "本地 SearXNG 检索 MCP。", "stdio", "node /home/lix/.agent-infra/searxng/codex-searxng-mcp.cjs", "", "allow-local", [], "medium", ["network:search"]),
|
||||
"chrome-bridge": mcpConfig("Chrome Bridge", "本机 Chromium/Chrome 桥接。", "stdio", "node /home/lix/apps/codex-chrome-bridge/mcp/chrome-bridge-mcp.mjs", "", "allow-local", [], "high", ["browser:automation", "qa:browser"]),
|
||||
},
|
||||
piExtensions: {
|
||||
"pi-rust-official-todo": piExtensionConfig("Pi Rust Official Todo", "Pi Rust 官方 todo 扩展。", "pi-rust-official:todo", ["todo"], "medium", ["workflow:todo"]),
|
||||
"pi-rust-official-subagent": piExtensionConfig("Pi Rust Official Subagent", "Pi Rust 官方 subagent 扩展。", "pi-rust-official:subagent", ["subagent"], "high", ["agent:delegate"]),
|
||||
"pi-rust-official-permission-gate": piExtensionConfig("Pi Rust Official Permission Gate", "Pi Rust 官方 permission-gate 扩展。", "pi-rust-official:permission-gate", [], "high", ["tool:policy"]),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function quickLogin(page) {
|
||||
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, 1000)}`);
|
||||
return body;
|
||||
}
|
||||
|
||||
async function seedWorkspace(page) {
|
||||
mkdirp(ROOT_PATH);
|
||||
fs.writeFileSync(
|
||||
path.join(ROOT_PATH, PAGE_PATH),
|
||||
[
|
||||
"# Pi exact user web smoke",
|
||||
"",
|
||||
"PI_EXACT_USER_WEB_CONTEXT_OK",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await setupWorkspaceAccess(page.request, BASE, {
|
||||
actorId: ACTOR_ID,
|
||||
email: "mnote.e2e@example.com",
|
||||
username: ACTOR_ID,
|
||||
displayName: ACTOR_ID,
|
||||
role: "admin",
|
||||
workspaceId: WORKSPACE_ID,
|
||||
workspaceName: "Pi exact user web smoke",
|
||||
rootPath: ROOT_PATH,
|
||||
rootUri: ROOT_URI,
|
||||
permission: "write",
|
||||
capabilities: ["ai", "read", "write"],
|
||||
timeoutMs: TIMEOUT,
|
||||
});
|
||||
await seedAiPolicy(page.request, BASE, {
|
||||
id: `pi-user-exact-${ACTOR_ID}-${WORKSPACE_ID}`,
|
||||
userId: ACTOR_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
allowedRootsJson: [{ rootUri: ROOT_URI, rootPath: ROOT_PATH, permission: "write" }],
|
||||
modelPolicyJson: policyForExactPrompt(),
|
||||
quotaJson: { daily: 200 },
|
||||
timeoutMs: TIMEOUT,
|
||||
});
|
||||
}
|
||||
|
||||
async function abortExistingSession(page) {
|
||||
const status = await requestJson(page, "/api/page-ai/pi/status").catch(() => null);
|
||||
const sessionId = status?.session?.sessionId;
|
||||
if (!sessionId) return null;
|
||||
await requestJson(page, "/api/page-ai/pi/abort", {
|
||||
method: "POST",
|
||||
data: { sessionId },
|
||||
}).catch(() => null);
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
function eventPayload(event) {
|
||||
return event && event.payload ? event.payload : event;
|
||||
}
|
||||
|
||||
function summarizeEvents(events) {
|
||||
const summary = {
|
||||
eventCount: events.length,
|
||||
toolCalls: [],
|
||||
toolStarts: [],
|
||||
toolEnds: [],
|
||||
assistantTexts: [],
|
||||
runtimeClosed: [],
|
||||
agentEnd: false,
|
||||
};
|
||||
for (const event of events) {
|
||||
const payload = eventPayload(event) || {};
|
||||
const type = payload.type || event.kind;
|
||||
if (type === "runtime_stdout_closed") summary.runtimeClosed.push(payload);
|
||||
if (type === "agent_end") summary.agentEnd = true;
|
||||
if (type === "message" || type === "message_end") {
|
||||
const message = payload.message || {};
|
||||
if (message.role === "assistant") {
|
||||
const text = (message.content || [])
|
||||
.filter((part) => part && part.type === "text" && typeof part.text === "string")
|
||||
.map((part) => part.text)
|
||||
.join("");
|
||||
if (text.trim()) summary.assistantTexts.push(text.trim());
|
||||
for (const part of message.content || []) {
|
||||
if (part && part.type === "toolCall") {
|
||||
summary.toolCalls.push({ name: part.name || part.toolName, id: part.id || part.toolCallId });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (message.role === "toolResult") {
|
||||
summary.toolEnds.push({
|
||||
name: message.toolName || message.tool_name,
|
||||
id: message.toolCallId || message.tool_call_id,
|
||||
isError: message.isError === true,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (type === "tool_execution_start" || type === "tool_call_start") {
|
||||
summary.toolStarts.push({ name: payload.toolName || payload.name, id: payload.toolCallId || payload.id });
|
||||
}
|
||||
if (type === "tool_execution_end" || type === "tool_call_end") {
|
||||
summary.toolEnds.push({ name: payload.toolName || payload.name, id: payload.toolCallId || payload.id, isError: payload.isError === true });
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
async function visibleAssistantTexts(page) {
|
||||
const texts = await page
|
||||
.locator('[data-page-ai-pi-lab-message-role="assistant"]:not([data-page-ai-pi-lab-streaming])')
|
||||
.allTextContents()
|
||||
.catch(() => []);
|
||||
return texts.map((text) => String(text || "").trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
async function visibleToolCards(page) {
|
||||
return await page
|
||||
.locator("[data-page-ai-pi-lab-tool-card]")
|
||||
.evaluateAll((cards) => cards.map((card) => {
|
||||
const name = card.getAttribute("data-page-ai-pi-lab-tool-call")
|
||||
|| card.getAttribute("data-page-ai-pi-lab-tool-card")
|
||||
|| "";
|
||||
const status = card.querySelector("[data-page-ai-pi-lab-tool-status]")?.textContent || "";
|
||||
return {
|
||||
name: String(name || "").trim(),
|
||||
status: String(status || "").trim(),
|
||||
text: String(card.textContent || "").slice(0, 500),
|
||||
};
|
||||
}).filter((tool) => tool.name))
|
||||
.catch(() => []);
|
||||
}
|
||||
|
||||
function mergeVisibleToolCards(summary, tools) {
|
||||
for (const tool of tools || []) {
|
||||
if (!summary.toolCalls.some((existing) => existing.name === tool.name)) {
|
||||
summary.toolCalls.push({ name: tool.name, id: tool.name, source: "visible_dom" });
|
||||
}
|
||||
const statusText = `${tool.status || ""} ${tool.text || ""}`;
|
||||
if (/done|完成/i.test(statusText) && !summary.toolEnds.some((existing) => existing.name === tool.name)) {
|
||||
summary.toolEnds.push({ name: tool.name, id: tool.name, isError: /error|失败/i.test(statusText), source: "visible_dom" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForSessionEvents(page, sessionId) {
|
||||
const deadline = Date.now() + TIMEOUT;
|
||||
let events = [];
|
||||
while (Date.now() < deadline) {
|
||||
const visibleTexts = await visibleAssistantTexts(page);
|
||||
const visibleFinalText = visibleTexts.find((text) => (
|
||||
text.length > 40
|
||||
&& /MCP|扩展|skill|工具|测试|Chrome Bridge|总体结论/i.test(text)
|
||||
&& !/Pi runtime 返回了空回复|empty response/i.test(text)
|
||||
));
|
||||
if (visibleFinalText) {
|
||||
const summary = summarizeEvents(events);
|
||||
mergeVisibleToolCards(summary, await visibleToolCards(page));
|
||||
summary.assistantTexts.push(visibleFinalText);
|
||||
return { events, summary };
|
||||
}
|
||||
const payload = await requestJson(
|
||||
page,
|
||||
`/api/page-ai/pi/sessions/${encodeURIComponent(sessionId)}/events?limit=1000`,
|
||||
{ timeout: Math.min(15000, TIMEOUT) },
|
||||
).catch(() => null);
|
||||
events = payload && Array.isArray(payload.events) ? payload.events : events;
|
||||
const summary = summarizeEvents(events);
|
||||
mergeVisibleToolCards(summary, await visibleToolCards(page));
|
||||
if (summary.agentEnd || summary.assistantTexts.length > 0) return { events, summary };
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
const summary = summarizeEvents(events);
|
||||
mergeVisibleToolCards(summary, await visibleToolCards(page));
|
||||
return { events, summary };
|
||||
}
|
||||
|
||||
function hasBadToolBridgeResult(text) {
|
||||
return /page_ai_pi_lab_session_not_found|page_ai_pi_lab_bridge_token_invalid|mnote_pi_rust_service_bridge_unavailable|mnote_pi_bridge_session_id_missing/i.test(String(text || ""));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
mkdirp(OUT);
|
||||
const browser = await chromium.launch({
|
||||
headless: process.env.MNOTE_PI_USER_EXACT_WEB_HEADED === "1" ? false : true,
|
||||
executablePath: CHROMIUM_EXECUTABLE || undefined,
|
||||
});
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 980 } });
|
||||
await context.addInitScript(() => {
|
||||
window.__MNOTE_PI_LAB_TEST__ = true;
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const consoleMessages = [];
|
||||
page.on("console", (message) => {
|
||||
if (["error", "warning"].includes(message.type())) consoleMessages.push(`${message.type()}: ${message.text()}`);
|
||||
});
|
||||
page.on("pageerror", (error) => consoleMessages.push(`pageerror: ${error.message}`));
|
||||
|
||||
const result = {
|
||||
ok: false,
|
||||
base: BASE,
|
||||
outputDir: OUT,
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
pagePath: PAGE_PATH,
|
||||
prompt: USER_PROMPT,
|
||||
screenshots: {},
|
||||
checks: {},
|
||||
consoleMessages,
|
||||
};
|
||||
|
||||
try {
|
||||
await quickLogin(page);
|
||||
await seedWorkspace(page);
|
||||
await abortExistingSession(page);
|
||||
|
||||
let startCount = 0;
|
||||
page.on("request", (request) => {
|
||||
if (request.url().includes("/api/page-ai/pi/start") && request.method() === "POST") startCount += 1;
|
||||
});
|
||||
|
||||
await page.goto(documentUrl(), { waitUntil: "commit", timeout: TIMEOUT });
|
||||
await page.locator("[data-page-ai-pi-lab-launcher]").waitFor({ state: "visible", timeout: TIMEOUT });
|
||||
await page.locator("[data-page-ai-pi-lab-launcher]").click();
|
||||
await page.locator('[data-page-ai-pi-lab="drawer"]').waitFor({ state: "visible", timeout: TIMEOUT });
|
||||
await page.waitForFunction(() => !!window.__mnotePiLabTest, null, { timeout: TIMEOUT });
|
||||
await page.waitForTimeout(800);
|
||||
result.checks.startCountAfterOpen = startCount;
|
||||
assert.equal(result.checks.startCountAfterOpen, 0, "opening Pi Lab must not auto-start runtime");
|
||||
|
||||
await page.locator("[data-page-ai-pi-lab-new]").click();
|
||||
await page.waitForTimeout(500);
|
||||
result.checks.startCountAfterNew = startCount;
|
||||
assert.equal(result.checks.startCountAfterNew, 0, "new conversation must not auto-start runtime");
|
||||
|
||||
await page.locator("[data-page-ai-pi-lab-permission]").click();
|
||||
await page.locator("[data-page-ai-pi-lab-permission-menu]").waitFor({ state: "visible", timeout: TIMEOUT });
|
||||
await page.locator('[data-page-ai-pi-lab-permission-mode="full_access"]').click();
|
||||
await page.waitForFunction(() => /完全访问/.test(document.querySelector("[data-page-ai-pi-lab-permission-label]")?.textContent || ""), null, { timeout: TIMEOUT });
|
||||
await page.waitForTimeout(300);
|
||||
result.checks.startCountAfterFullAccessClick = startCount;
|
||||
assert.equal(result.checks.startCountAfterFullAccessClick, 0, "selecting full_access before send must not auto-start runtime");
|
||||
|
||||
const startResponsePromise = page.waitForResponse(
|
||||
(response) => response.url().includes("/api/page-ai/pi/start") && response.request().method() === "POST",
|
||||
{ timeout: TIMEOUT },
|
||||
);
|
||||
const sendResponsePromise = page.waitForResponse(
|
||||
(response) => response.url().includes("/api/page-ai/pi/send") && response.request().method() === "POST",
|
||||
{ timeout: TIMEOUT },
|
||||
);
|
||||
await page.locator("[data-page-ai-pi-lab-input]").fill(USER_PROMPT);
|
||||
await page.locator("[data-page-ai-pi-lab-btn-send]").click();
|
||||
|
||||
const startResponse = await startResponsePromise;
|
||||
const startBody = await startResponse.json();
|
||||
const startRequestBody = startResponse.request().postDataJSON();
|
||||
result.startRequest = startRequestBody;
|
||||
result.startResponse = startBody;
|
||||
assert.equal(startResponse.ok(), true, `start response should be HTTP OK: ${startResponse.status()}`);
|
||||
assert.equal(startRequestBody.permissionMode, "full_access", "web send must start with full_access");
|
||||
assert.equal(startRequestBody.modelProvider, MODEL_PROVIDER, "web send must use configured provider");
|
||||
assert.equal(startRequestBody.modelId, MODEL_ID, "web send must use tool-capable model");
|
||||
assert.equal(startRequestBody.rootUri, ROOT_URI, "web send must keep current document rootUri");
|
||||
assert.equal(startRequestBody.workspaceId, WORKSPACE_ID, "web send must keep current document workspaceId");
|
||||
assert.equal(startRequestBody.pagePath, PAGE_PATH, "web send must keep current document pagePath");
|
||||
assert.equal(startBody.runtimeImplementation, "pi-rust", "runtime implementation should be pi-rust");
|
||||
assert.deepEqual(
|
||||
[...(startBody.managedPiBuiltinTools || [])].sort(),
|
||||
["bash", "edit", "find", "grep", "hashline_edit", "ls", "read", "write"].sort(),
|
||||
"full_access should expose Pi Rust builtins instead of replacing them with MNote file tools",
|
||||
);
|
||||
const sessionId = startBody.session?.sessionId || startBody.sessionId;
|
||||
assert(sessionId, "start response missing sessionId");
|
||||
result.sessionId = sessionId;
|
||||
result.piSessionDir = startBody.session?.piSessionDir;
|
||||
|
||||
const sendResponse = await sendResponsePromise;
|
||||
const sendBody = await sendResponse.json();
|
||||
result.sendResponse = sendBody;
|
||||
assert.equal(sendResponse.ok(), true, `send response should be HTTP OK: ${sendResponse.status()}`);
|
||||
assert.equal(sendBody.accepted, true, "send response should accept the exact prompt");
|
||||
|
||||
const { events, summary } = await waitForSessionEvents(page, sessionId);
|
||||
result.events = events;
|
||||
result.eventSummary = summary;
|
||||
result.visibleToolCards = await visibleToolCards(page);
|
||||
mergeVisibleToolCards(summary, result.visibleToolCards);
|
||||
const pageText = await page.locator('[data-page-ai-pi-lab="drawer"]').textContent({ timeout: TIMEOUT });
|
||||
result.visibleTextSample = String(pageText || "").slice(0, 4000);
|
||||
await page.screenshot({ path: path.join(OUT, "01-exact-prompt-result.png"), fullPage: true });
|
||||
result.screenshots.exactPrompt = path.join(OUT, "01-exact-prompt-result.png");
|
||||
|
||||
const allText = `${JSON.stringify(summary)}\n${result.visibleTextSample}\n${JSON.stringify(events)}`;
|
||||
result.checks.hasToolCall = summary.toolCalls.length > 0 || summary.toolStarts.length > 0 || summary.toolEnds.length > 0;
|
||||
result.checks.hasSuccessfulToolEnd = summary.toolEnds.some((tool) => tool.isError === false);
|
||||
result.checks.hasMnoteOrMcpOrExtensionTool = /mnote_|mcp|todo|subagent|knowledge/i.test(JSON.stringify(summary));
|
||||
result.checks.noRawBuiltinToolCall = !/"name":"(ls|find|bash|read|write|edit|grep|hashline_edit)"/.test(JSON.stringify(events));
|
||||
result.checks.noEmptyReplyError = !/Pi runtime 返回了空回复|empty response/i.test(allText);
|
||||
result.checks.noBridgeSessionFailure = !hasBadToolBridgeResult(allText);
|
||||
result.checks.hasAssistantReply = summary.assistantTexts.length > 0 || /Pi|测试|工具|MCP|扩展|skill/i.test(result.visibleTextSample);
|
||||
assert.equal(result.checks.hasToolCall, true, "exact web prompt should trigger at least one real tool call");
|
||||
assert.equal(result.checks.hasSuccessfulToolEnd, true, "at least one tool call should finish successfully");
|
||||
assert.equal(result.checks.hasMnoteOrMcpOrExtensionTool, true, "tool call should be MNote/MCP/extension controlled");
|
||||
assert.equal(result.checks.noRawBuiltinToolCall, true, "full_access must not expose raw builtin tools");
|
||||
assert.equal(result.checks.noEmptyReplyError, true, "UI must not show empty reply error");
|
||||
assert.equal(result.checks.noBridgeSessionFailure, true, "tool result must not contain bridge/session failures");
|
||||
assert.equal(result.checks.hasAssistantReply, true, "UI should show an assistant reply");
|
||||
|
||||
await requestJson(page, "/api/page-ai/pi/abort", { method: "POST", data: { sessionId } }).catch(() => ({}));
|
||||
result.ok = true;
|
||||
} catch (error) {
|
||||
result.ok = false;
|
||||
result.error = error && error.stack ? error.stack : String(error);
|
||||
await abortExistingSession(page).catch(() => null);
|
||||
try {
|
||||
await page.screenshot({ path: path.join(OUT, "99-failure.png"), fullPage: true });
|
||||
result.screenshots.failure = path.join(OUT, "99-failure.png");
|
||||
} catch {}
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
fs.writeFileSync(path.join(OUT, "result.json"), JSON.stringify(result, null, 2), "utf8");
|
||||
await browser.close();
|
||||
console.log(JSON.stringify({
|
||||
ok: result.ok,
|
||||
outputDir: OUT,
|
||||
result: path.join(OUT, "result.json"),
|
||||
sessionId: result.sessionId,
|
||||
checks: result.checks,
|
||||
error: result.error,
|
||||
}, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,182 @@
|
||||
#!/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 TIMEOUT = Number.parseInt(process.env.UI_TIMEOUT_MS || "120000", 10);
|
||||
const STAMP = Date.now();
|
||||
const OUT = process.env.MNOTE_PI_WARMUP_BINDING_OUT || path.join(os.tmpdir(), `mnote-pi-warmup-binding-${STAMP}`);
|
||||
const ROOT_PATH = process.env.MNOTE_PI_WARMUP_BINDING_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
|
||||
const ROOT_URI = process.env.MNOTE_PI_WARMUP_BINDING_ROOT_URI || `file://${ROOT_PATH}`;
|
||||
const WORKSPACE_ID = process.env.MNOTE_PI_WARMUP_BINDING_WORKSPACE_ID || "local-ws:mnote-e2e:my-space";
|
||||
const PAGE_PATH = `pi-warmup-binding-${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" : "");
|
||||
|
||||
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 };
|
||||
}
|
||||
return { response, body };
|
||||
}
|
||||
|
||||
async function quickLogin(page) {
|
||||
await page.goto(`${BASE}/auth`, { waitUntil: "commit", timeout: TIMEOUT });
|
||||
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
|
||||
if (!(await quickLoginButton.isVisible({ timeout: 4000 }).catch(() => false))) return;
|
||||
await Promise.all([
|
||||
page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: TIMEOUT }).catch(() => null),
|
||||
quickLoginButton.click(),
|
||||
]);
|
||||
}
|
||||
|
||||
async function cleanupPreviousSmokeSession(page) {
|
||||
const status = await requestJson(page, "/api/page-ai/pi/status");
|
||||
assert(status.response.ok(), `status before cleanup should be OK, got ${status.response.status()}`);
|
||||
const session = status.body.session || {};
|
||||
const sessionId = status.body.sessionId || session.sessionId;
|
||||
const pagePath = String(session.pagePath || "");
|
||||
if (!sessionId || !pagePath.startsWith("pi-warmup-binding-")) return;
|
||||
await requestJson(page, "/api/page-ai/pi/abort", {
|
||||
method: "POST",
|
||||
data: { sessionId },
|
||||
}).catch(() => null);
|
||||
await requestJson(page, `/api/page-ai/pi/sessions/${encodeURIComponent(sessionId)}`, {
|
||||
method: "DELETE",
|
||||
}).catch(() => null);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
fs.mkdirSync(ROOT_PATH, { recursive: true });
|
||||
fs.writeFileSync(path.join(ROOT_PATH, PAGE_PATH), "# Pi warmup binding\n\nWarmup binding smoke.\n", "utf8");
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: process.env.MNOTE_PI_WARMUP_BINDING_HEADED === "1" ? false : true,
|
||||
executablePath: CHROMIUM_EXECUTABLE || undefined,
|
||||
});
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||
await context.addInitScript((payload) => {
|
||||
window.__MNOTE_TEST_PAGE_CONTEXT__ = payload;
|
||||
}, {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
pagePath: PAGE_PATH,
|
||||
pageTitle: "Pi warmup binding",
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const requests = [];
|
||||
const result = {
|
||||
base: BASE,
|
||||
out: OUT,
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
pagePath: PAGE_PATH,
|
||||
statusBefore: null,
|
||||
statusAfter: null,
|
||||
startRequest: null,
|
||||
startBeforeSend: false,
|
||||
coldStartToastVisible: false,
|
||||
};
|
||||
|
||||
try {
|
||||
await quickLogin(page);
|
||||
await cleanupPreviousSmokeSession(page);
|
||||
const statusBefore = await page.request.get(`${BASE}/api/page-ai/pi/status`);
|
||||
assert(statusBefore.ok(), `status before should be OK, got ${statusBefore.status()}`);
|
||||
result.statusBefore = await statusBefore.json();
|
||||
assert.equal(result.statusBefore.warmupRunning, true, "dev:hot warmup runtime should be running before UI send");
|
||||
assert.equal(result.statusBefore.sessionId || null, null, "warmup session must not be exposed as current page session");
|
||||
|
||||
await page.route("**/api/page-ai/pi/send", async (route) => {
|
||||
const request = route.request();
|
||||
requests.push({ url: request.url(), body: request.postDataJSON() });
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
schema: "mnote.page_ai_pi.send.v1",
|
||||
sessionId: request.postDataJSON().sessionId,
|
||||
accepted: true,
|
||||
intercepted: true,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
const documentId = `local-md:${PAGE_PATH.replaceAll("/", "~2F")}`;
|
||||
await page.goto(
|
||||
`${BASE}/documents/${encodeURIComponent(documentId)}?sourceKind=local_folder&rootUri=${encodeURIComponent(ROOT_URI)}&workspaceId=${encodeURIComponent(WORKSPACE_ID)}&path=${encodeURIComponent(PAGE_PATH)}`,
|
||||
{ waitUntil: "commit", timeout: TIMEOUT },
|
||||
);
|
||||
await page.locator("[data-page-ai-pi-lab-launcher]").waitFor({ state: "visible", timeout: TIMEOUT });
|
||||
const startResponsePromise = page.waitForResponse(
|
||||
(response) => response.url().includes("/api/page-ai/pi/start") && response.request().method() === "POST",
|
||||
{ timeout: TIMEOUT },
|
||||
);
|
||||
await page.locator("[data-page-ai-pi-lab-launcher]").click();
|
||||
await page.locator('[data-page-ai-pi-lab="drawer"]').waitFor({ state: "visible", timeout: TIMEOUT });
|
||||
const startResponse = await startResponsePromise;
|
||||
result.startBeforeSend = true;
|
||||
assert(startResponse.ok(), `start response should be OK, got ${startResponse.status()}`);
|
||||
const startBody = await startResponse.json();
|
||||
result.startRequest = startResponse.request().postDataJSON();
|
||||
assert.equal(startBody.ok, true, "start body should be ok");
|
||||
assert(startBody.session?.sessionId, "start should return a current page session");
|
||||
assert(!String(startBody.session.sessionId).startsWith("pi_lab_dev_warm_"), "current page session should not reuse warmup prompt session id");
|
||||
assert.equal(startBody.session.pagePath, PAGE_PATH, "current page session should bind current page path");
|
||||
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent?.includes("ready")
|
||||
|| document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent?.includes("streaming"),
|
||||
null,
|
||||
{ timeout: TIMEOUT },
|
||||
);
|
||||
await page.locator("[data-page-ai-pi-lab-input]").fill("warmup binding smoke");
|
||||
await page.locator("[data-page-ai-pi-lab-btn-send]").click();
|
||||
result.coldStartToastVisible = await page.locator("[data-page-ai-pi-lab-toast]", { hasText: "正在绑定当前页 Pi 会话,完成后自动发送" }).isVisible({ timeout: 500 }).catch(() => false);
|
||||
assert.equal(result.coldStartToastVisible, false, "send should not show current-page binding toast after drawer-open prestart");
|
||||
const sendWaitStartedAt = Date.now();
|
||||
while (requests.length === 0 && Date.now() - sendWaitStartedAt < 5000) {
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
assert.equal(requests.length, 1, "send should be intercepted once after runtime binding");
|
||||
assert.equal(requests[0].body.sessionId, startBody.session.sessionId, "send should use bound current page session");
|
||||
|
||||
const statusAfter = await page.request.get(`${BASE}/api/page-ai/pi/status`);
|
||||
assert(statusAfter.ok(), `status after should be OK, got ${statusAfter.status()}`);
|
||||
result.statusAfter = await statusAfter.json();
|
||||
assert.equal(result.statusAfter.warmupRunning, true, "warmup diagnostics should remain visible after current session starts");
|
||||
assert.equal(result.statusAfter.sessionId, startBody.session.sessionId, "status should now expose the current page session");
|
||||
|
||||
fs.writeFileSync(path.join(OUT, "result.json"), JSON.stringify(result, null, 2), "utf8");
|
||||
console.log(`✅ Pi warmup binding browser smoke passed: ${path.join(OUT, "result.json")}`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user