feat: purge legacy agent hosts and land vault Chrome extension path

Retire ACP/Hermes/OpenCode surfaces and rename hermes_tools to
mnote_agent_tools so Page AI stays on Pi Lab only. Add Chrome vault
extension + extension token route, pre-release purge design, and soft-retire
legacy smokes for the small-group production cut.
This commit is contained in:
Agent Board
2026-07-25 14:25:37 +08:00
parent bc6f8488ee
commit 262e66b02e
137 changed files with 9018 additions and 46049 deletions
+2 -79
View File
@@ -1,15 +1,12 @@
#!/usr/bin/env node
/**
* 热启动 mnote-web 单入口,以及按需启用的 FastAPI / opencode
* 热启动 mnote-web 单入口,以及按需启用的 FastAPI。
* Page AI 仅走 Pi Lab/api/page-ai/pi/*);不再启动 OpenCode。
* 可使用以下环境变量调整行为:
* - ENABLE_BACKEND:设为 "1" or "true" 时启用默认 FastAPI 后端
* - BACKEND_CMD:覆盖 FastAPI 启动命令;设置后即视为显式启用后端
* - SKIP_BACKEND:设为 "1" or "true" 可强制跳过 FastAPI 后端
* - ENABLE_OPENCODE:设为 "1" or "true" 时启用 opencode serve;默认不启动
* - OPENCODE_CMD:覆盖 opencode 启动命令;设置后即视为显式启用 opencode
* - SKIP_OPENCODE:设为 "1" or "true" 可强制跳过 opencode
* - MNOTE_OPENCODE_XDG_ROOT / MNOTE_OPENCODE_HOMEopencode 专用运行目录
* - MNOTE_CONTROL_PLANE_BACKEND:控制面后端,默认 libsql-local;可设 turso-remote / turso-local-replica / turso-synced
* - MNOTE_TURSO_LOCAL_PATHlibsql-local 本地库路径,默认 /mnt/Data1T/Mnote_data/control-plane/control-plane-libsql.db
* - MNOTE_PAGE_AI_PI_WARMUP:设为 "1" or "true" 时,在 mnote-web 可用后预启动 Pi Lab runtime / MCP cache
@@ -54,7 +51,6 @@ function resolveBackendExecutable(envName, fallbackName) {
const pythonBin = resolveBackendExecutable("PYTHON_BIN", "python");
const backendPortFromEnv = Number(process.env.BACKEND_PORT || 8000);
const opencodePortFromEnv = Number(process.env.OPENCODE_PORT || 4096);
const defaultControlPlaneDir = "/mnt/Data1T/Mnote_data/control-plane";
function hasCommand(command) {
@@ -79,41 +75,6 @@ function buildDefaultBackendCommand(port) {
return `${pythonBin} -m uvicorn app.main:app --reload --port ${port}`;
}
function buildDefaultOpencodeCommand(port) {
const opencodeXdgRoot = process.env.MNOTE_OPENCODE_XDG_ROOT || "/mnt/Data1T/Mnote_data/opencode/runtime";
const opencodeHome = process.env.MNOTE_OPENCODE_HOME || "/mnt/Data1T/Mnote_data/opencode/home";
const modelEnvNames = [
"OPENCODE_API_KEY",
"OPENAI_API_KEY",
"OPENAI_BASE_URL",
"ANTHROPIC_API_KEY",
"GOOGLE_API_KEY",
"GOOGLE_GENERATIVE_AI_API_KEY",
"DEEPSEEK_API_KEY",
"GEMINI_API_KEY",
"MISTRAL_API_KEY",
"OPENROUTER_API_KEY",
"GROQ_API_KEY",
"XAI_API_KEY",
"AZURE_OPENAI_API_KEY",
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
];
const opencodeEnv = [
"env",
...modelEnvNames.map((name) => `-u ${name}`),
`HOME=${JSON.stringify(opencodeHome)}`,
`XDG_CONFIG_HOME=${JSON.stringify(path.join(opencodeXdgRoot, "config"))}`,
`XDG_DATA_HOME=${JSON.stringify(path.join(opencodeXdgRoot, "data"))}`,
`XDG_STATE_HOME=${JSON.stringify(path.join(opencodeXdgRoot, "state"))}`,
`XDG_CACHE_HOME=${JSON.stringify(path.join(opencodeXdgRoot, "cache"))}`,
].join(" ");
return [
`mkdir -p ${JSON.stringify(opencodeXdgRoot)} ${JSON.stringify(opencodeHome)}`,
`while true; do script -qfec ${JSON.stringify(`${opencodeEnv} opencode serve --hostname=127.0.0.1 --port ${port} --print-logs`)} /dev/null; sleep 1; done`,
].join(" && ");
}
function isEnabledEnv(value) {
const normalized = String(value || "").toLowerCase();
return normalized === "1" || normalized === "true";
@@ -125,12 +86,6 @@ function shouldStartBackend(env = process.env) {
return isEnabledEnv(env.ENABLE_BACKEND);
}
function shouldStartOpencode(env = process.env) {
if (isEnabledEnv(env.SKIP_OPENCODE)) return false;
if (String(env.OPENCODE_CMD || "").trim()) return true;
return isEnabledEnv(env.ENABLE_OPENCODE);
}
function resolveRuntimePlan(env = process.env) {
const frontendPort = Number(env.FRONTEND_PORT || 3000);
const skipGateway = false;
@@ -162,7 +117,6 @@ function resolveRuntimePlan(env = process.env) {
MNOTE_WEB_BIND: env.MNOTE_WEB_BIND || `0.0.0.0:${publicPort}`,
MNOTE_WEB_PUBLIC_BIND: env.MNOTE_WEB_PUBLIC_BIND || `127.0.0.1:${publicPort}`,
MNOTE_KNOWLEDGE_PROVIDER: env.MNOTE_KNOWLEDGE_PROVIDER || "lightrag_legacy",
MNOTE_OPENCODE_BASE_URL: env.MNOTE_OPENCODE_BASE_URL || `http://127.0.0.1:${opencodePortFromEnv}`,
...controlPlaneEnv,
},
};
@@ -195,17 +149,6 @@ const tasks = [
},
]
: []),
...(shouldStartOpencode(process.env)
? [
{
name: "opencode",
command:
process.env.OPENCODE_CMD ||
buildDefaultOpencodeCommand(opencodePortFromEnv),
cwd: rootDir,
},
]
: []),
];
function findTask(name) {
@@ -707,24 +650,6 @@ async function main() {
}
const desiredOpencodePort = opencodePortFromEnv;
if (shouldStartOpencode(process.env) && !process.env.OPENCODE_CMD) {
const opencodePortOk = await ensurePortFree(desiredOpencodePort, "opencode");
if (!opencodePortOk) {
console.error(`opencode 端口 ${desiredOpencodePort} 无法释放,已中止启动。`);
process.exit(1);
}
const opencodeTask = findTask("opencode");
if (!opencodeTask) {
throw new Error("缺少 opencode 任务配置");
}
opencodeTask.command = buildDefaultOpencodeCommand(desiredOpencodePort);
} else if (isEnabledEnv(process.env.SKIP_OPENCODE)) {
logPrefix("opencode", "已跳过 opencodeSKIP_OPENCODE=1)。");
} else if (!shouldStartOpencode(process.env)) {
}
if (tasks.length === 0) {
console.error("未配置任何可运行的任务,检查环境变量设置。");
process.exit(1);
@@ -744,7 +669,6 @@ if (require.main === module) {
}
module.exports = {
buildDefaultOpencodeCommand,
collectStaleMnoteWebCargoPids,
ensurePortFree,
getListeningPidsByPort,
@@ -757,7 +681,6 @@ module.exports = {
resolveBackendExecutable,
schedulePiLabWarmup,
shouldStartBackend,
shouldStartOpencode,
stopStaleMnoteWebCargoProcesses,
terminatePid,
};
+1 -21
View File
@@ -3,7 +3,6 @@ const { spawn } = require("node:child_process");
const net = require("node:net");
const { test } = require("node:test");
const {
buildDefaultOpencodeCommand,
collectStaleMnoteWebCargoPids,
resolveBackendExecutable,
ensurePortFree,
@@ -11,7 +10,6 @@ const {
isPortFree,
resolveRuntimePlan,
shouldStartBackend,
shouldStartOpencode,
stopStaleMnoteWebCargoProcesses,
} = require("./desktop-hot.js");
@@ -142,10 +140,10 @@ test("默认热启动计划只使用 mnote-web 作为 3000 owner", () => {
MNOTE_WEB_BIND: "0.0.0.0:3000",
MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000",
MNOTE_KNOWLEDGE_PROVIDER: "lightrag_legacy",
MNOTE_OPENCODE_BASE_URL: "http://127.0.0.1:4096",
MNOTE_CONTROL_PLANE_BACKEND: "libsql-local",
MNOTE_TURSO_LOCAL_PATH: "/mnt/Data1T/Mnote_data/control-plane/control-plane-libsql.db",
});
assert.equal(plan.mnoteWebEnv.MNOTE_OPENCODE_BASE_URL, undefined);
});
test("热启动计划支持 libSQL local 控制面后端", () => {
@@ -189,24 +187,6 @@ test("默认跳过 FastAPI 后端,只有显式开启时才启动", () => {
assert.equal(shouldStartBackend({ ENABLE_BACKEND: "1", SKIP_BACKEND: "1" }), false);
});
test("默认跳过 opencode,只有显式开启时才启动", () => {
assert.equal(shouldStartOpencode({}), false);
assert.equal(shouldStartOpencode({ ENABLE_OPENCODE: "1" }), true);
assert.equal(shouldStartOpencode({ ENABLE_OPENCODE: "true" }), true);
assert.equal(shouldStartOpencode({ OPENCODE_CMD: "custom-opencode" }), true);
assert.equal(shouldStartOpencode({ ENABLE_OPENCODE: "1", SKIP_OPENCODE: "1" }), false);
});
test("opencode 默认命令使用 MNote 专用运行目录", () => {
const command = buildDefaultOpencodeCommand(18085);
assert.match(command, /\/mnt\/Data1T\/Mnote_data\/opencode\/runtime/);
assert.match(command, /\/mnt\/Data1T\/Mnote_data\/opencode\/home/);
assert.match(command, /HOME=/);
assert.match(command, /XDG_CONFIG_HOME=/);
assert.doesNotMatch(command, /已检测到现有 opencode/);
assert.match(command, /opencode serve --hostname=127\.0\.0\.1 --port 18085 --print-logs/);
});
test("isHttpHealthy 只把 2xx HTTP health 视为可复用服务", async () => {
const server = net.createServer((socket) => {
socket.once("data", () => {
-3
View File
@@ -60,8 +60,6 @@ function devHotBindAddr(env = process.env) {
}
function buildDevHotEnv(baseEnv = process.env) {
const opencodePort = String(baseEnv.OPENCODE_PORT || "4096").trim();
const opencodeBaseUrl = String(baseEnv.MNOTE_OPENCODE_BASE_URL || `http://127.0.0.1:${opencodePort}`).trim();
const controlPlaneBackend = String(baseEnv.MNOTE_CONTROL_PLANE_BACKEND || "libsql-local").trim() || "libsql-local";
if (controlPlaneBackend === "sqlite") {
throw new Error("dev:hot 不再支持 SQLite control-plane fallback;请使用 libsql-local/turso-local-replica/turso-remote/turso-synced");
@@ -75,7 +73,6 @@ function buildDevHotEnv(baseEnv = process.env) {
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(),
MNOTE_OPENCODE_BASE_URL: opencodeBaseUrl,
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 ?? "0").trim() || "0",
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -27,7 +27,7 @@ const OUT_DIR = path.join(process.cwd(), "tmp", "block-delta-stream-smoke");
const SUFFIX = `bds-${Date.now().toString(36)}`;
async function callTool(request, payload) {
return requestJson(request, "/api/hermes/tools/mnote/call", {
return requestJson(request, "/api/mnote/tools/call", {
method: "POST",
headers: { "x-mnote-actor-id": payload.actorId || "smoke-user" },
data: payload,
@@ -105,7 +105,7 @@ async function main() {
assert.ok(blockB, "文档应包含 B 段");
const blockBId = blockB.blockId;
const toolUrl = new URL("/api/hermes/tools/mnote/call", BASE_URL);
const toolUrl = new URL("/api/mnote/tools/call", BASE_URL);
const replaceRes = await requestJson(request, toolUrl.toString(), {
method: "POST",
headers: { "x-mnote-actor-id": "smoke-user" },
+1 -1
View File
@@ -18,7 +18,7 @@ assert.match(env.MNOTE_WEB_CMD, /crates\/mnote-web\/browser/);
assert.equal(env.FRONTEND_PORT, "3200");
assert.equal(env.MNOTE_WEB_BIND, "0.0.0.0:3200");
assert.equal(env.MNOTE_KNOWLEDGE_PROVIDER, "lightrag_legacy");
assert.equal(env.MNOTE_OPENCODE_BASE_URL, "http://127.0.0.1:4096");
assert.equal(env.MNOTE_OPENCODE_BASE_URL, undefined);
assert.equal(env.ENABLE_OPENHUB, undefined);
assert.equal(env.CHECK_OPENHUB_HEALTH, undefined);
assert.equal(env.OPENHUB_OPENCODE_BASE_URL, undefined);
+1 -1
View File
@@ -29,7 +29,7 @@ const OUT_DIR = path.join(process.cwd(), "tmp", "editor-delta-channel-smoke");
const SUFFIX = `edc-${Date.now().toString(36)}`;
async function callTool(request, payload) {
return requestJson(request, "/api/hermes/tools/mnote/call", {
return requestJson(request, "/api/mnote/tools/call", {
method: "POST",
headers: { "x-mnote-actor-id": payload.actorId || "smoke-user" },
data: payload,
+1 -1
View File
@@ -31,7 +31,7 @@ const OUT_DIR = path.join(process.cwd(), "tmp", "editor-runtime-actor-smoke");
const SUFFIX = `era-${Date.now().toString(36)}`;
async function callTool(request, payload) {
return requestJson(request, "/api/hermes/tools/mnote/call", {
return requestJson(request, "/api/mnote/tools/call", {
method: "POST",
headers: { "x-mnote-actor-id": payload.actorId || "smoke-user" },
data: payload,
@@ -19,7 +19,7 @@ const OUT_DIR = path.join(process.cwd(), "tmp", "page-aggregate-refresh-persiste
const CHROME_EXECUTABLE = process.env.PLAYWRIGHT_CHROME_EXECUTABLE || "";
async function callMnoteTool(requestContext, payload) {
return requestJson(requestContext, "/api/hermes/tools/mnote/call", {
return requestJson(requestContext, "/api/mnote/tools/call", {
method: "POST",
headers: { "x-mnote-actor-id": payload.actorId || "smoke-user" },
data: payload,
@@ -1,235 +1,16 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs/promises");
const path = require("node:path");
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
cleanupDocuments,
createTempDocument,
ensureAuthenticated,
openDocument,
renameDocument,
requestJson,
} = require("./tree-shell-smoke-helpers");
const OUT_DIR = path.join(process.cwd(), "tmp", "page-ai-apply-block-ops-real-smoke");
const HERMES_SESSION_ROOTS = [
"/home/lix/.hermes/profiles/mnoteai/sessions",
"/home/lix/.hermes/sessions",
];
async function listRecentHermesSessions(sinceMs) {
const rows = [];
for (const root of HERMES_SESSION_ROOTS) {
let entries = [];
try {
entries = await fs.readdir(root, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (!entry.isFile() || !entry.name.startsWith("session_") || !entry.name.endsWith(".json")) {
continue;
}
const filePath = path.join(root, entry.name);
const stat = await fs.stat(filePath).catch(() => null);
if (!stat || stat.mtimeMs < sinceMs) continue;
const content = await fs.readFile(filePath, "utf8").catch(() => "");
const parsed = JSON.parse(content || "{}");
rows.push({
path: filePath,
mtimeMs: stat.mtimeMs,
model: parsed.model || "",
platform: parsed.platform || "",
toolCount: Array.isArray(parsed.tools) ? parsed.tools.length : 0,
toolNames: Array.isArray(parsed.tools)
? parsed.tools.map((tool) => tool?.function?.name || tool?.name).filter(Boolean)
: [],
messageCount: parsed.message_count || parsed.messageCount || 0,
hasApplyBlockOps: content.includes("mnote_doc_apply_block_ops") || content.includes("mnote.doc.apply_block_ops"),
});
}
}
return rows.sort((a, b) => b.mtimeMs - a.mtimeMs);
}
async function callMnoteTool(request, payload) {
return requestJson(request, "/api/hermes/tools/mnote/call", {
method: "POST",
headers: { "x-mnote-actor-id": payload.actorId || "smoke-user" },
data: payload,
});
}
async function fetchBlocks(request, target, suffix, actorId) {
const response = await callMnoteTool(request, {
toolName: "mnote.doc.fetch",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_fetch_${suffix}`,
runId: `run_fetch_${suffix}_${Date.now().toString(36)}`,
toolCallId: `call_fetch_${suffix}_${Date.now().toString(36)}`,
traceId: `trace_fetch_${suffix}`,
capabilityScope: ["page.read"],
args: {
scope: "full",
detail: "with_ids",
maxBlocks: 20,
},
});
assert.equal(response.ok, true, "doc.fetch 应成功");
assert(Array.isArray(response.result.blocks), "doc.fetch 应返回 blocks");
return response.result.blocks.map((block) => block.text);
}
async function main() {
const suffix = Date.now().toString(36);
const title = `TEST-PAGE-AI-APPLY-OPS-${suffix}`;
const createdIds = [];
const evidence = {
ok: false,
baseUrl: BASE_URL,
title,
profile: "mnoteai",
timingsMs: {},
requests: [],
};
await fs.mkdir(OUT_DIR, { recursive: true });
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" });
const page = await context.newPage();
page.on("request", (request) => {
const url = request.url();
if (!url.includes("/api/hermes/client/")) return;
evidence.requests.push({
method: request.method(),
url: url.replace(BASE_URL, ""),
postData: request.postDataJSON?.() || null,
atMs: Date.now(),
});
});
const startedAt = Date.now();
try {
const viewer = await ensureAuthenticated(page, context.request);
const actorId = viewer.userId;
const target = await createTempDocument(context.request);
createdIds.push(target.documentId);
evidence.documentId = target.documentId;
evidence.workspaceId = target.workspaceId;
await renameDocument(context.request, target.workspaceId, target.documentId, title);
const seedContent = [
{ id: "p_1", type: "paragraph", content: [{ type: "text", text: `第一段 ${suffix}` }] },
{ id: "p_2", type: "paragraph", content: [{ type: "text", text: `第二段 ${suffix}` }] },
{ id: "p_3", type: "paragraph", content: [{ type: "text", text: `第三段 ${suffix}` }] },
];
const seedStart = Date.now();
const seed = await callMnoteTool(context.request, {
toolName: "mnote.page.save",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_seed_${suffix}`,
runId: `run_seed_${suffix}`,
toolCallId: `call_seed_${suffix}`,
traceId: `trace_seed_${suffix}`,
idempotencyKey: `idem_seed_${suffix}`,
dryRun: false,
capabilityScope: ["page.write"],
args: { mode: "replace", content: seedContent },
});
assert.equal(seed.ok, true, "初始化 page.save 应成功");
evidence.timingsMs.seed = Date.now() - seedStart;
const openStart = Date.now();
await openDocument(page, target.workspaceId, target.documentId);
evidence.timingsMs.openDocument = Date.now() - openStart;
const health = await requestJson(context.request, "/api/hermes/client/gateway/health?profile=mnoteai", {
method: "GET",
});
evidence.gatewayHealth = health;
assert.equal(health.gateway?.ok, true, `mnoteai gateway health 应为 ok: ${JSON.stringify(health)}`);
assert(String(health.gateway?.upstream || "").includes(":8644"), "mnoteai profile 应路由到 8644 gateway");
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
await page.locator('.wolai-page-ai-icon[data-page-ai-tab="agent"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator("[data-page-ai-profile-select]").selectOption("mnoteai", { timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => document.documentElement.getAttribute("data-mnote-page-ai-profile") === "mnoteai",
null,
{ timeout: UI_TIMEOUT_MS },
);
await page.locator('.wolai-page-ai-panel-section:not([hidden]) [data-page-ai-tab="chat"]').click({
timeout: UI_TIMEOUT_MS,
});
const prompt =
`请使用 mnote_doc_apply_block_ops 一次完成三件事并回读验证:` +
`把「第二段 ${suffix}」替换为「第二段已修改 ${suffix}」;` +
`在「第一段 ${suffix}」后插入「插入段 ${suffix}」;` +
`删除「第三段 ${suffix}」。只简短回复结果。`;
const aiStart = Date.now();
await page.locator("[data-page-ai-input]").fill(prompt, { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
let finalTexts = [];
const writeDeadline = Date.now() + Number(process.env.MNOTE_PAGE_AI_REAL_TIMEOUT_MS || 120_000);
while (Date.now() < writeDeadline) {
finalTexts = await fetchBlocks(context.request, target, suffix, actorId);
if (
finalTexts.includes(`第二段已修改 ${suffix}`) &&
finalTexts.includes(`插入段 ${suffix}`) &&
!finalTexts.includes(`第三段 ${suffix}`)
) {
break;
}
await page.waitForTimeout(1000);
}
assert(finalTexts.includes(`第二段已修改 ${suffix}`), "回读未包含替换后的文本");
assert(finalTexts.includes(`插入段 ${suffix}`), "回读未包含插入文本");
assert(!finalTexts.includes(`第三段 ${suffix}`), "回读仍包含应删除文本");
evidence.timingsMs.pageAiWriteVisible = Date.now() - aiStart;
await page.waitForFunction(
() => ["completed", "failed"].includes(document.documentElement.getAttribute("data-mnote-page-ai-run-status") || ""),
null,
{ timeout: Number(process.env.MNOTE_PAGE_AI_REAL_TIMEOUT_MS || 120_000) },
).catch(() => undefined);
evidence.pageAiRunStatus = await page.evaluate(() =>
document.documentElement.getAttribute("data-mnote-page-ai-run-status") || "",
);
evidence.timingsMs.pageAiRunSettled = Date.now() - aiStart;
evidence.finalTexts = finalTexts;
evidence.conversationText = await page
.locator('.wolai-page-ai-panel-section:not([hidden]) [data-page-ai-conversation]')
.textContent({ timeout: UI_TIMEOUT_MS });
evidence.screenshot = path.join(OUT_DIR, `${suffix}.png`);
await page.screenshot({ path: evidence.screenshot, fullPage: true });
evidence.hermesSessions = await listRecentHermesSessions(startedAt);
evidence.ok = true;
const evidencePath = path.join(OUT_DIR, `${suffix}.json`);
await fs.writeFile(evidencePath, JSON.stringify(evidence, null, 2), "utf8");
console.log(JSON.stringify({ ...evidence, evidencePath }, null, 2));
} finally {
await cleanupDocuments(context.request, createdIds).catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
/**
* RETIRED (Wave 10 / pre-release legacy purge)
* 依赖已删除的 /api/hermes/client/* 或 OpenCode Page AI host。
* 产品 Page AI 仅 Pi Lab/api/page-ai/pi/*);agent tools 为 /api/mnote/tools/*。
* 本文件保留作历史对照,直接 exit 0,不再执行浏览器/静态断言。
*/
console.log(JSON.stringify({
ok: true,
retired: true,
reason: "legacy hermes client / opencode page-ai path deleted; use Pi Lab + /api/mnote/tools",
script: 'scripts/task-page-ai-apply-block-ops-real-smoke.js'.split("/").pop(),
}, null, 2));
process.exit(0);
+13 -196
View File
@@ -1,199 +1,16 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs/promises");
const path = require("node:path");
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
cleanupDocuments,
createTempDocument,
ensureAuthenticated,
openDocument,
renameDocument,
requestJson,
} = require("./tree-shell-smoke-helpers");
const OUT_DIR = path.join(process.cwd(), "tmp", "page-ai-block-edit-workflow-smoke");
async function callMnoteTool(request, payload) {
return requestJson(request, "/api/hermes/tools/mnote/call", {
method: "POST",
headers: { "x-mnote-actor-id": payload.actorId || "smoke-user" },
data: payload,
});
}
async function fetchBlocks(request, target, suffix, actorId) {
const response = await callMnoteTool(request, {
toolName: "mnote.doc.fetch",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_fetch_${suffix}`,
runId: `run_fetch_${suffix}_${Date.now().toString(36)}`,
toolCallId: `call_fetch_${suffix}_${Date.now().toString(36)}`,
traceId: `trace_fetch_${suffix}`,
capabilityScope: ["page.read"],
args: {
scope: "full",
detail: "with_ids",
maxBlocks: 20,
},
});
assert.equal(response.ok, true, "doc.fetch 应成功");
return response.result.blocks.map((block) => block.text);
}
async function main() {
const suffix = Date.now().toString(36);
const title = `TEST-PAGE-AI-FAST-BLOCK-${suffix}`;
const createdIds = [];
const evidence = {
ok: false,
baseUrl: BASE_URL,
title,
timingsMs: {},
requests: [],
};
await fs.mkdir(OUT_DIR, { recursive: true });
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" });
const page = await context.newPage();
page.on("request", (request) => {
const url = request.url();
if (!url.includes("/api/page-ai/") && !url.includes("/api/hermes/client/")) return;
evidence.requests.push({
method: request.method(),
url: url.replace(BASE_URL, ""),
atMs: Date.now(),
});
});
page.on("response", async (response) => {
const url = response.url();
if (!url.includes("/api/page-ai/") && !url.includes("/api/hermes/client/")) return;
const entry = {
method: response.request().method(),
url: url.replace(BASE_URL, ""),
status: response.status(),
atMs: Date.now(),
};
const contentType = response.headers()["content-type"] || "";
if (contentType.includes("application/json")) {
entry.body = await response.json().catch(() => null);
}
evidence.responses = evidence.responses || [];
evidence.responses.push(entry);
});
try {
const viewer = await ensureAuthenticated(page, context.request);
const actorId = viewer.userId;
const target = await createTempDocument(context.request);
createdIds.push(target.documentId);
evidence.documentId = target.documentId;
evidence.workspaceId = target.workspaceId;
await renameDocument(context.request, target.workspaceId, target.documentId, title);
const seed = await callMnoteTool(context.request, {
toolName: "mnote.page.save",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_seed_${suffix}`,
runId: `run_seed_${suffix}`,
toolCallId: `call_seed_${suffix}`,
traceId: `trace_seed_${suffix}`,
idempotencyKey: `idem_seed_${suffix}`,
dryRun: false,
capabilityScope: ["page.write"],
args: {
mode: "replace",
content: [
{ id: "p_1", type: "paragraph", content: [{ type: "text", text: `第一段 ${suffix}` }] },
{ id: "p_2", type: "paragraph", content: [{ type: "text", text: `第二段 ${suffix}` }] },
{ id: "p_3", type: "paragraph", content: [{ type: "text", text: `第三段 ${suffix}` }] },
],
},
});
assert.equal(seed.ok, true, "初始化 page.save 应成功");
await openDocument(page, target.workspaceId, target.documentId);
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
await page.locator('.wolai-page-ai-icon[data-page-ai-tab="agent"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator("[data-page-ai-profile-select]").selectOption("mnoteai", { timeout: UI_TIMEOUT_MS });
await page.locator('.wolai-page-ai-panel-section:not([hidden]) [data-page-ai-tab="chat"]').click({
timeout: UI_TIMEOUT_MS,
});
const prompt =
`把「第二段 ${suffix}」替换为「第二段已修改 ${suffix}」;` +
`在「第一段 ${suffix}」后插入「插入段 ${suffix}」;` +
`删除「第三段 ${suffix}」。只简短回复结果。`;
const aiStart = Date.now();
await page.locator("[data-page-ai-input]").fill(prompt, { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
let finalTexts = [];
const writeDeadline = Date.now() + Number(process.env.MNOTE_PAGE_AI_FAST_TIMEOUT_MS || 60_000);
while (Date.now() < writeDeadline) {
finalTexts = await fetchBlocks(context.request, target, suffix, actorId);
if (
finalTexts.includes(`第二段已修改 ${suffix}`) &&
finalTexts.includes(`插入段 ${suffix}`) &&
!finalTexts.includes(`第三段 ${suffix}`)
) {
break;
}
await page.waitForTimeout(500);
}
evidence.timingsMs.pageAiWriteVisible = Date.now() - aiStart;
evidence.finalTexts = finalTexts;
assert(finalTexts.includes(`第二段已修改 ${suffix}`), "回读未包含替换后的文本");
assert(finalTexts.includes(`插入段 ${suffix}`), "回读未包含插入文本");
assert(!finalTexts.includes(`第三段 ${suffix}`), "回读仍包含应删除文本");
await page.waitForFunction(
() => ["completed", "failed"].includes(document.documentElement.getAttribute("data-mnote-page-ai-run-status") || ""),
null,
{ timeout: Number(process.env.MNOTE_PAGE_AI_FAST_TIMEOUT_MS || 60_000) },
).catch(() => undefined);
evidence.pageAiRunStatus = await page.evaluate(() =>
document.documentElement.getAttribute("data-mnote-page-ai-run-status") || "",
);
evidence.conversationText = await page
.locator('.wolai-page-ai-panel-section:not([hidden]) [data-page-ai-conversation]')
.textContent({ timeout: UI_TIMEOUT_MS });
evidence.usedFastWorkflow = evidence.requests.some((request) =>
request.url.includes("/api/page-ai/block-edit-workflow"),
);
evidence.usedHermesRun = evidence.requests.some((request) =>
request.url.includes("/api/hermes/client/runs"),
);
assert.equal(evidence.usedFastWorkflow, true, "页面 AI 应调用 block-edit-workflow 快路径");
assert.equal(evidence.usedHermesRun, false, "块编辑快路径成功时不应进入 Hermes agent run");
evidence.screenshot = path.join(OUT_DIR, `${suffix}.png`);
await page.screenshot({ path: evidence.screenshot, fullPage: true });
evidence.ok = true;
const evidencePath = path.join(OUT_DIR, `${suffix}.json`);
await fs.writeFile(evidencePath, JSON.stringify(evidence, null, 2), "utf8");
console.log(JSON.stringify({ ...evidence, evidencePath }, null, 2));
} finally {
const evidencePath = path.join(OUT_DIR, `${suffix}.json`);
evidence.evidencePath = evidencePath;
await fs.writeFile(evidencePath, JSON.stringify(evidence, null, 2), "utf8").catch(() => undefined);
await cleanupDocuments(context.request, createdIds).catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
/**
* RETIRED (Wave 10 / pre-release legacy purge)
* 依赖已删除的 /api/hermes/client/* 或 OpenCode Page AI host。
* 产品 Page AI 仅 Pi Lab/api/page-ai/pi/*);agent tools 为 /api/mnote/tools/*。
* 本文件保留作历史对照,直接 exit 0,不再执行浏览器/静态断言。
*/
console.log(JSON.stringify({
ok: true,
retired: true,
reason: "legacy hermes client / opencode page-ai path deleted; use Pi Lab + /api/mnote/tools",
script: 'scripts/task-page-ai-block-edit-workflow-smoke.js'.split("/").pop(),
}, null, 2));
process.exit(0);
@@ -19,7 +19,7 @@ const OUT_DIR = path.join(process.cwd(), "tmp", "page-block-ai-conflict-idempote
const CHROME_EXECUTABLE = process.env.PLAYWRIGHT_CHROME_EXECUTABLE || "";
async function callMnoteTool(request, payload) {
return requestJson(request, "/api/hermes/tools/mnote/call", {
return requestJson(request, "/api/mnote/tools/call", {
method: "POST",
headers: { "x-mnote-actor-id": payload.actorId || "smoke-user" },
data: payload,
@@ -27,7 +27,7 @@ async function callMnoteTool(request, payload) {
}
async function callMnoteToolRaw(request, payload) {
const response = await request.fetch(`${BASE_URL}/api/hermes/tools/mnote/call`, {
const response = await request.fetch(`${BASE_URL}/api/mnote/tools/call`, {
method: "POST",
headers: {
"content-type": "application/json",
@@ -18,7 +18,7 @@ const {
const OUT_DIR = path.join(process.cwd(), "tmp", "page-block-ai-context-format-smoke");
async function callMnoteTool(request, payload) {
return requestJson(request, "/api/hermes/tools/mnote/call", {
return requestJson(request, "/api/mnote/tools/call", {
method: "POST",
headers: { "x-mnote-actor-id": payload.actorId || "smoke-user" },
data: payload,
@@ -84,7 +84,7 @@ async function main() {
evidence.workspaceId = target.workspaceId;
await renameDocument(context.request, target.workspaceId, target.documentId, title);
const manifest = await requestJson(context.request, "/api/hermes/tools/mnote/manifest", {
const manifest = await requestJson(context.request, "/api/mnote/tools/manifest", {
method: "GET",
headers: { "x-mnote-actor-id": actorId },
});
@@ -253,7 +253,7 @@ async function main() {
contextAfter: blockXml.result.context.after.map((block) => block.blockId),
});
const outOfScopeResponse = await context.request.fetch(`${BASE_URL}/api/hermes/tools/mnote/call`, {
const outOfScopeResponse = await context.request.fetch(`${BASE_URL}/api/mnote/tools/call`, {
method: "POST",
headers: {
"content-type": "application/json",
+1 -1
View File
@@ -67,7 +67,7 @@ async function waitForVisibleTexts(page, expectedTexts) {
}
async function callMnoteTool(request, payload) {
return requestJson(request, "/api/hermes/tools/mnote/call", {
return requestJson(request, "/api/mnote/tools/call", {
method: "POST",
headers: { "x-mnote-actor-id": payload.actorId || "smoke-user" },
data: payload,
+20 -4
View File
@@ -14,7 +14,11 @@ const files = {
runtime: path.join(repoRoot, 'rust/crates/mnote-web/browser/sidebar-page-ai-pi-lab-runtime.js'),
legacyPageAiRuntime: path.join(repoRoot, 'rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js'),
layout: path.join(repoRoot, 'rust/crates/mnote-web/src/ssr/pages/layout.rs'),
route: path.join(repoRoot, 'rust/crates/mnote-web/src/routes/page_ai_pi.rs'),
// page_ai_pi 已拆为目录模块(constants.rs / runtime.rs / mod.rs
routeDir: path.join(repoRoot, 'rust/crates/mnote-web/src/routes/page_ai_pi'),
routeMod: path.join(repoRoot, 'rust/crates/mnote-web/src/routes/page_ai_pi/mod.rs'),
routeConstants: path.join(repoRoot, 'rust/crates/mnote-web/src/routes/page_ai_pi/constants.rs'),
routeRuntime: path.join(repoRoot, 'rust/crates/mnote-web/src/routes/page_ai_pi/runtime.rs'),
mod: path.join(repoRoot, 'rust/crates/mnote-web/src/routes/mod.rs'),
webShell: path.join(repoRoot, 'rust/crates/mnote-web/src/routes/web_shell.rs'),
gateway: path.join(repoRoot, 'rust/crates/mnote-web/src/routes/gateway.rs'),
@@ -32,7 +36,12 @@ function readFile(p) {
const runtime = readFile(files.runtime);
const legacyPageAiRuntime = readFile(files.legacyPageAiRuntime);
const layout = readFile(files.layout);
const route = readFile(files.route);
// 合并目录模块,保持后续 route.includes(...) 检查不变
const route = [
readFile(files.routeMod),
readFile(files.routeConstants),
readFile(files.routeRuntime),
].join('\n');
const routesMod = readFile(files.mod);
const webShell = readFile(files.webShell);
const gateway = readFile(files.gateway);
@@ -159,7 +168,13 @@ const checks = [
['runtime hides Pi Lab launcher while drawer is active', runtime.includes('piLabLauncherEl.hidden = !!piLabState.active')],
['layout maps old floating AI slot to Pi Lab', layout.includes('data-testid="wolai-floating-ai"') && layout.includes('data-mnote-action="open-page-ai-pi-lab"') && layout.includes('{"\\u{03c0}"}')],
['layout no longer exposes legacy Page AI/OpenHub action from floating button', !layout.includes('data-mnote-action="open-page-ai"><span')],
['legacy Page AI opencode host requires explicit debug opt-in', legacyPageAiRuntime.includes("getItem('mnote.page_ai.opencode_host') === '1'") && !legacyPageAiRuntime.includes("getItem('mnote.page_ai.opencode_host') !== '0' : true")],
// Wave 8legacy page-ai runtime 已瘦身为 Pi-only stub,不再含 OpenCode host
['legacy Page AI runtime is Pi-only stub (no OpenCode host)',
legacyPageAiRuntime.includes('Pi Lab only')
&& legacyPageAiRuntime.includes('openPageAiDrawer')
&& legacyPageAiRuntime.includes('mnote:pi-lab-show')
&& !legacyPageAiRuntime.includes('opencode_host')
&& !legacyPageAiRuntime.includes('page-ai-opencode')],
['runtime documents pi-web-ui evidence', runtime.includes('@earendil-works/pi-web-ui@0.75.3')],
['runtime uses MNote-native adapter boundary', runtime.includes('MNote-native adapter')],
@@ -359,7 +374,8 @@ const checks = [
!runtime.includes('sidebarPageAiRuntime')],
['no import of main page AI runtime', !runtime.includes('import.*sidebar-page-ai')],
// === B2: JSONL reading constants in page_ai_pi.rs ===
// === B2: JSONL reading constants in page_ai_pi/ ===
["page_ai_pi module directory exists", fs.existsSync(files.routeDir) && fs.statSync(files.routeDir).isDirectory()],
["route has PI_LAB_JSONL_MAX_FILE_BYTES constant", route.includes("PI_LAB_JSONL_MAX_FILE_BYTES")],
["route has PI_LAB_JSONL_MAX_LINE_BYTES constant", route.includes("PI_LAB_JSONL_MAX_LINE_BYTES")],
["route has PI_LAB_JSONL_MAX_ENTRIES constant", route.includes("PI_LAB_JSONL_MAX_ENTRIES")],
+11 -50
View File
@@ -29,43 +29,6 @@ async function readTextResponse(path, init) {
return { response, text };
}
function runtimeInputToolPlan() {
return {
kind: "tool",
context: {
deploymentId: null,
projectId: null,
workspaceId: "ws_task116",
requestId: "req_task116",
traceId: "trace_task116",
actor: {
actorType: "user",
actorId: "task116-user",
sessionId: null,
},
source: {
channel: "rust-web",
client: "task116-smoke",
},
tenantId: null,
authToken: null,
idempotencyKey: null,
validateOnly: false,
dryRun: false,
},
tool: {
tool: "mnote.knowledge_rag.query",
kind: "query",
mode: "plan",
argsJson: { query: "Rust Web islands" },
target: null,
reason: "task116 owner smoke",
refs: ["task-025"],
},
data: null,
};
}
async function checkSearchShell() {
const { response, text } = await readTextResponse("/search?workspaceId=ws_task116&q=Rust");
assert(response.headers.get("x-mnote-web-owner") === "mnote-web", "Search shell 缺少 mnote-web owner header");
@@ -75,18 +38,16 @@ async function checkSearchShell() {
return { owner: "mnote-web", shell: "search", island: "search_interaction_island" };
}
async function checkAiBridge() {
const { response, text } = await readTextResponse("/api/hermes/bridge", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(runtimeInputToolPlan()),
});
assert(response.headers.get("x-mnote-web-owner") === "mnote-web", "AI bridge 缺少 mnote-web owner header");
assert(response.headers.get("x-mnote-ai-bridge-owner") === "rust-web-hermes", "AI bridge 缺少 rust-web-hermes owner header");
async function checkAgentTools() {
const { response, text } = await readTextResponse("/api/mnote/tools/manifest");
const payload = JSON.parse(text);
assert(payload?.contract?.schema === "mnote.ai_bridge.v1", "AI bridge 缺少 contract schema");
assert(payload?.contract?.toolEventOwner === "rust-web-hermes", "AI bridge tool event owner 未固定到 Hermes");
return { owner: "mnote-web", bridgeOwner: "rust-web-hermes", schema: payload.contract.schema };
const schema = payload?.manifest?.schemaVersion || payload?.schema || payload?.contract?.schema || "";
assert(
schema === "mnote.agent_tool_manifest.v1" || String(text).includes("mnote.agent_tool_manifest.v1"),
`tools manifest schema 不正确: ${schema || text.slice(0, 200)}`,
);
const owner = response.headers.get("x-mnote-agent-tool-owner") || payload?.owner || "";
return { owner: owner || "mnote-web-agent-tools", schema: "mnote.agent_tool_manifest.v1" };
}
async function checkMindmapShell() {
@@ -100,7 +61,7 @@ async function checkMindmapShell() {
async function main() {
const search = await checkSearchShell();
const aiBridge = await checkAiBridge();
const agentTools = await checkAgentTools();
const mindmap = await checkMindmapShell();
console.log(
JSON.stringify(
@@ -109,7 +70,7 @@ async function main() {
baseUrl: BASE_URL,
results: {
search,
aiBridge,
agentTools,
mindmap,
},
},
@@ -1,6 +1,12 @@
#!/usr/bin/env node
"use strict";
/**
* 历史:曾验证 /api/hermes/bridge structured write 合同。
* Wave 10Hermes bridge 已删除;改为负向门禁(404),主链用 /api/mnote/tools + Pi Lab。
* tools manifest 可能需登录(401 仍表示路由挂载,区别于 404)。
*/
const assert = require("node:assert");
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/$/, "");
@@ -16,19 +22,43 @@ async function main() {
context: { documentId: "doc_1", workspaceId: "ws_demo" },
}),
});
const payload = await response.json();
assert.equal(response.status, 200, `/api/hermes/bridge 请求失败: ${response.status}`);
assert.equal(response.headers.get("x-mnote-ai-bridge-owner"), "rust-web-hermes");
assert.equal(payload.canonicalRoute, "/api/hermes/bridge");
assert.match(payload.eventStreamEndpoint || "", /\/api\/hermes\/events\//);
assert.equal(payload.contract.structuredWriteOwner, "rust-web-hermes");
assert.deepEqual(payload.structuredWrite.allowedCommands, [
"page.body.save",
"tree.node.create",
"kernel.edge.attach",
]);
const text = await response.text();
assert.equal(
response.status,
404,
`legacy /api/hermes/bridge 应已删除,实际 ${response.status}: ${text.slice(0, 300)}`,
);
console.log(JSON.stringify({ ok: true, bridgeOwner: "rust-web-hermes" }, null, 2));
const tools = await fetch(`${BASE_URL}/api/mnote/tools/manifest`);
const toolsText = await tools.text();
assert.notEqual(
tools.status,
404,
`/api/mnote/tools/manifest 路由应挂载(200 或 401),实际 ${tools.status}`,
);
// 未登录时 401;已登录时 200 且含 schema
if (tools.status === 200) {
assert.ok(
toolsText.includes("mnote.agent_tool_manifest.v1"),
"tools manifest 应使用 mnote.agent_tool_manifest.v1",
);
}
console.log(
JSON.stringify(
{
ok: true,
legacyHermesBridge: { status: 404 },
agentTools: {
status: tools.status,
mounted: true,
schemaOk: tools.status === 200 ? toolsText.includes("mnote.agent_tool_manifest.v1") : null,
},
},
null,
2,
),
);
}
main().catch((error) => {
@@ -118,7 +118,7 @@ async function typeDirtyText(page, text) {
}
async function callMarkdownEdit(root, relativePath, search, replace) {
const response = await fetch(`${BASE_URL}/api/hermes/tools/mnote/call`, {
const response = await fetch(`${BASE_URL}/api/mnote/tools/call`, {
method: "POST",
headers: {
"content-type": "application/json",
@@ -1,483 +1,16 @@
#!/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_URL,
UI_TIMEOUT_MS,
} = require("./tree-shell-smoke-helpers");
const TASK = "task453-local-folder-page-ai-changed-files-smoke";
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function writeWorkspaceManifest(root, ownerId, workspaceId) {
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(
path.join(root, ".mnote", "workspace.json"),
`${JSON.stringify({
workspaceId,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "ai_sessions", "markdown_edit"],
}, null, 2)}\n`,
"utf8",
);
}
async function fetchPageAggregate(page, documentId, rootUri) {
return await page.evaluate(async ({ id, uri }) => {
const url = new URL(`/api/page-aggregate/${encodeURIComponent(id)}`, window.location.origin);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", uri);
const response = await fetch(url.toString(), { headers: { accept: "application/json" } });
return {
ok: response.ok,
status: response.status,
payload: await response.json().catch(() => null),
};
}, { id: documentId, uri: rootUri });
}
async function waitForEditorText(page, expected) {
await page.waitForFunction(
(text) => {
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
return (editor?.textContent || "").includes(text);
},
expected,
{ timeout: UI_TIMEOUT_MS },
);
}
async function typeDirtyText(page, text) {
const editor = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first();
await editor.click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.type(text, { delay: 8 });
await waitForEditorText(page, text.trim());
}
async function waitForEditorStatus(page, status) {
await page.waitForFunction(
(expected) => {
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
return root?.getAttribute("data-runtime-editor-status") === expected;
},
status,
{ timeout: UI_TIMEOUT_MS },
);
}
async function conflictEnvelope(page, documentId) {
return await page.evaluate((docId) => {
const snapshot = window.__mnoteDebugDocumentSessions?.snapshot?.();
if (!snapshot) return null;
const session = snapshot.sessions.find((item) => item.documentId === docId);
return session?.lastExternalConflictEnvelope || null;
}, documentId);
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const suffix = Date.now().toString(36);
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-local-ai-changed-files-"));
const documentId = localMdDocumentId("README.md");
const dirtyDocumentId = localMdDocumentId("Dirty.md");
const actorId = "user_real";
const sessionId = `mnote_local_ai_changed_${suffix}`;
const runId = `run_local_ai_changed_${suffix}`;
const dirtySessionId = `mnote_local_ai_dirty_${suffix}`;
const dirtyRunId = `run_local_ai_dirty_${suffix}`;
const marker = `LOCAL-AI-CHANGED-FILES-${suffix}`;
const dirtyMarker = `LOCAL-AI-DIRTY-FILES-${suffix}`;
const dirtyLocalToken = `LOCAL-UNSAVED-DIRTY-${suffix}`;
const readmePath = path.join(root, "README.md");
const dirtyPath = path.join(root, "Dirty.md");
const captured = [];
let currentScenario = "clean";
const browser = await chromium.launch({
headless: true,
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
extraHTTPHeaders: {
"x-mnote-actor-id": actorId,
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
try {
const workspaceId = `local-ws:${actorId}:task453`;
writeWorkspaceManifest(root, actorId, workspaceId);
fs.writeFileSync(readmePath, `# Local AI Changed Files\n初始内容 ${suffix}\n`, "utf8");
fs.writeFileSync(dirtyPath, `# Dirty AI Changed Files\n初始 dirty 内容 ${suffix}\n`, "utf8");
const rootUri = fileUrl(root);
const scenarioConfig = () => currentScenario === "dirty"
? {
sessionId: dirtySessionId,
runId: dirtyRunId,
documentId: dirtyDocumentId,
filePath: dirtyPath,
relativePath: "Dirty.md",
marker: dirtyMarker,
message: "已修改本地 Dirty。",
}
: {
sessionId,
runId,
documentId,
filePath: readmePath,
relativePath: "README.md",
marker,
message: "已修改本地 README。",
};
await page.route("**/api/ai-agent/run", async (route) => {
throw new Error(`页面 AI 不应请求旧 /api/ai-agent/run: ${route.request().url()}`);
});
await page.route("**/api/documents/save", async (route) => {
throw new Error(`local-first AI smoke 不应请求 compat /api/documents/save: ${route.request().url()}`);
});
await page.route("**/api/hermes/client/gateway/health**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
gateway: { ok: true, status: "mocked" },
profile: { name: "reasonix", modelConfigured: true, apiKeyConfigured: true },
suggestions: [],
}),
});
});
await page.route("**/api/hermes/client/tools**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, tools: [] }),
});
});
await page.route("**/api/hermes/client/profiles", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
active: "reasonix",
profiles: [{ name: "reasonix", label: "Reasonix", modelConfigured: true, apiKeyConfigured: true }],
}),
});
});
await page.route("**/api/hermes/client/sessions", async (route) => {
const scenario = scenarioConfig();
captured.push({ kind: "session", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
sessionId: scenario.sessionId,
title: "本地 changed files",
traceId: `trace_local_changed_${suffix}`,
persistence: "local_ai_session_jsonl",
sessionStorage: "local_private",
}),
});
});
await page.route("**/api/hermes/client/sessions/*/resume", async (route) => {
const scenario = scenarioConfig();
captured.push({ kind: "session-resume", method: route.request().method(), body: "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
sessionId: scenario.sessionId,
session: { sessionId: scenario.sessionId, messages: [] },
runtime: {
sessionId: scenario.sessionId,
runId: scenario.runId,
status: "completed",
profile: "reasonix",
documentId: scenario.documentId,
traceId: `trace_local_changed_resume_${suffix}`,
},
}),
});
});
await page.route("**/api/hermes/client/runs", async (route) => {
const scenario = scenarioConfig();
captured.push({ kind: "run", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
sessionId: scenario.sessionId,
runId: scenario.runId,
events: [],
traceId: `trace_local_changed_run_${suffix}`,
persistence: "local_ai_session_jsonl",
sessionStorage: "local_private",
}),
});
});
await page.route("**/api/hermes/client/events/*", async (route) => {
const scenario = scenarioConfig();
captured.push({ kind: "events", method: route.request().method(), body: "" });
fs.appendFileSync(scenario.filePath, `\nAI 写入标记:${scenario.marker}\n`, "utf8");
await route.fulfill({
status: 200,
headers: { "content-type": "text/event-stream; charset=utf-8" },
body:
`data: ${JSON.stringify({ event: "message.delta", run_id: scenario.runId, session_id: scenario.sessionId, delta: scenario.message })}\n\n` +
`data: ${JSON.stringify({
event: "run.completed",
run_id: scenario.runId,
session_id: scenario.sessionId,
output: scenario.message,
agentAudit: {
eventId: `audit_local_changed_${currentScenario}_${suffix}`,
actorId,
actorType: "user",
agentKind: "reasonix",
rootUri,
diffSummary: "1 changed file(s)",
changedFiles: [
{
path: scenario.relativePath,
changeType: "modified",
summary: `追加 ${scenario.marker}`,
hashBefore: "111",
hashAfter: "222",
modifiedBeforeMs: 10,
modifiedAfterMs: 20,
},
],
},
})}\n\n`,
});
});
const documentUrl = new URL(`${BASE_URL}/documents/${encodeURIComponent(documentId)}`);
documentUrl.searchParams.set("sourceKind", "local_folder");
documentUrl.searchParams.set("rootUri", rootUri);
await page.goto(documentUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => (document.body.textContent || "").includes("Local AI Changed Files"),
null,
{ timeout: UI_TIMEOUT_MS },
);
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({
state: "attached",
timeout: UI_TIMEOUT_MS,
}).catch(() => undefined);
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
await page.locator("[data-page-ai-input]").fill(`请修改 README 并记录 changed files ${marker}`, { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => {
const drawerText = document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "";
return drawerText.includes("agent.changed_files") && drawerText.includes("README.md");
},
null,
{ timeout: UI_TIMEOUT_MS },
);
const cards = await page.$$eval("[data-page-ai-tool-card]", (nodes) =>
nodes.map((node) => ({
status: node.getAttribute("data-page-ai-tool-status"),
text: node.textContent || "",
})),
);
assert(
cards.some((card) =>
card.status === "completed"
&& card.text.includes("agent.changed_files")
&& card.text.includes("README.md")
&& card.text.includes(marker)
&& card.text.includes("hash 111→222")
&& card.text.includes("reasonix/user/user_real")
),
`本地 AI changed files 工具卡未显示 README.md 与 diff 摘要: ${JSON.stringify(cards)}`,
);
const diskText = fs.readFileSync(readmePath, "utf8");
assert(diskText.includes(marker), "本地 README.md 未写入 smoke 标记");
const aggregate = await fetchPageAggregate(page, documentId, rootUri);
assert.equal(aggregate.ok, true, `Page Aggregate 应能读取 local_folder 文档: ${JSON.stringify(aggregate)}`);
assert(
JSON.stringify(aggregate.payload || {}).includes(marker),
`Page Aggregate 应读回 AI 写入标记: ${JSON.stringify(aggregate)}`,
);
await waitForEditorText(page, marker);
const editorState = await page.evaluate(() => {
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
const conflictPanel = document.querySelector('[data-testid="mnote-editor-conflict-panel"]');
return {
status: root?.getAttribute("data-runtime-editor-status") || "",
text: editor?.textContent || "",
conflictVisible: Boolean(conflictPanel && conflictPanel.getClientRects().length > 0),
};
});
assert.notEqual(editorState.status, "external-change-conflict", `clean AI 写入不应触发冲突态: ${JSON.stringify(editorState)}`);
assert.equal(editorState.conflictVisible, false, `clean AI 写入不应显示冲突面板: ${JSON.stringify(editorState)}`);
assert(captured.some((entry) => entry.kind === "run"), "未捕获 page AI run 请求");
const runBody = JSON.parse(captured.find((entry) => entry.kind === "run")?.body || "{}");
assert.equal(runBody.documentId, documentId, `Hermes run 应携带 local documentId: ${JSON.stringify(runBody)}`);
assert.equal(runBody.sourceKind, "local_folder", `Hermes run 应携带 local_folder sourceKind: ${JSON.stringify(runBody)}`);
assert.equal(runBody.rootUri, rootUri, `Hermes run 应携带 rootUri: ${JSON.stringify(runBody)}`);
assert.equal(runBody.editorTarget?.schema, "mnote.ai_editor_target.v1", `Hermes run 应携带 AI editor target: ${JSON.stringify(runBody)}`);
assert.equal(runBody.editorTarget?.source, "open_editors_snapshot", `AI editor target 应来自 OpenEditorsSnapshot: ${JSON.stringify(runBody.editorTarget)}`);
assert.equal(runBody.editorTarget?.documentId, documentId, `AI editor target 应指向当前文档: ${JSON.stringify(runBody.editorTarget)}`);
assert.equal(runBody.runTargetSnapshot?.schema, "mnote.page_ai_run_target_snapshot.v1", `Hermes run 应携带冻结 target snapshot: ${JSON.stringify(runBody.runTargetSnapshot)}`);
assert.equal(runBody.runTargetSnapshot?.editorTarget?.documentId, documentId, `冻结 target snapshot 应指向发起 run 时的文档: ${JSON.stringify(runBody.runTargetSnapshot)}`);
assert.equal(runBody.runTargetSnapshot?.editorTarget?.workspacePath?.rootUri, rootUri, `冻结 target snapshot 应保留发起 run 时的 rootUri: ${JSON.stringify(runBody.runTargetSnapshot)}`);
assert.equal(runBody.pageContext?.aiContext?.runTargetSnapshot?.editorTarget?.documentId, documentId, `pageContext.aiContext 应保留冻结 target snapshot: ${JSON.stringify(runBody.pageContext?.aiContext)}`);
assert.equal(runBody.pageContext?.aiContext?.activeEditorTarget?.source, "open_editors_snapshot", `pageContext.aiContext 应包含 activeEditorTarget: ${JSON.stringify(runBody.pageContext?.aiContext)}`);
assert.equal(runBody.pageContext?.aiContext?.openEditorsSnapshot?.activeEditor?.documentId, documentId, `aiContext.openEditorsSnapshot 应包含 active editor: ${JSON.stringify(runBody.pageContext?.aiContext?.openEditorsSnapshot)}`);
assert(runBody.pageContext?.aiContext?.openEditorsSnapshot?.groups?.primary, `aiContext.openEditorsSnapshot 应保留 primary group: ${JSON.stringify(runBody.pageContext?.aiContext?.openEditorsSnapshot)}`);
assert(runBody.pageContext?.aiContext?.openEditorsSnapshot?.groups?.secondary, `aiContext.openEditorsSnapshot 应保留 secondary group: ${JSON.stringify(runBody.pageContext?.aiContext?.openEditorsSnapshot)}`);
const cleanCapturedKinds = captured.map((entry) => entry.kind);
captured.length = 0;
await page.evaluate(({ otherRootUri, otherWorkspaceId }) => {
const runtime = window.__mnoteDocumentPaneRuntime;
if (!runtime || typeof runtime.getOpenEditorsSnapshot !== "function") {
throw new Error("missing_open_editors_snapshot_runtime");
}
window.__task453OriginalOpenEditorsSnapshot = runtime.getOpenEditorsSnapshot.bind(runtime);
runtime.getOpenEditorsSnapshot = () => {
const snapshot = window.__task453OriginalOpenEditorsSnapshot();
const cloned = JSON.parse(JSON.stringify(snapshot || {}));
const poisonEditor = (entry) => {
if (!entry || typeof entry !== "object") return;
entry.workspaceId = otherWorkspaceId;
entry.workspacePath = Object.assign({}, entry.workspacePath || {}, {
workspaceId: otherWorkspaceId,
rootUri: otherRootUri,
sourceKind: "local_folder",
});
};
poisonEditor(cloned.activeEditor);
(cloned.editors || []).forEach((entry) => {
if (entry && entry.active) poisonEditor(entry);
});
Object.values(cloned.groups || {}).forEach((group) => {
(group.editors || []).forEach((entry) => {
if (entry && entry.active) poisonEditor(entry);
});
});
return cloned;
};
}, { otherRootUri: "file:///tmp/mnote-task453-other-root", otherWorkspaceId: "local-ws:user_real:other" });
await page.locator("[data-page-ai-input]").fill(`请错误写入其他 workspace ${marker}`, { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => {
const drawerText = document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "";
return drawerText.includes("AI target 与当前本地工作区 rootUri 不一致")
|| drawerText.includes("AI target 与当前 workspaceId 不一致");
},
null,
{ timeout: UI_TIMEOUT_MS },
);
assert(!captured.some((entry) => entry.kind === "run"), `跨 workspace target 不应发起 Hermes run: ${JSON.stringify(captured)}`);
await page.evaluate(() => {
const runtime = window.__mnoteDocumentPaneRuntime;
if (runtime && window.__task453OriginalOpenEditorsSnapshot) {
runtime.getOpenEditorsSnapshot = window.__task453OriginalOpenEditorsSnapshot;
}
delete window.__task453OriginalOpenEditorsSnapshot;
});
currentScenario = "dirty";
captured.length = 0;
const dirtyUrl = new URL(`${BASE_URL}/documents/${encodeURIComponent(dirtyDocumentId)}`);
dirtyUrl.searchParams.set("sourceKind", "local_folder");
dirtyUrl.searchParams.set("rootUri", rootUri);
await page.goto(dirtyUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForEditorText(page, "Dirty AI Changed Files");
await typeDirtyText(page, ` ${dirtyLocalToken}`);
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
await page.locator("[data-page-ai-input]").fill(`请修改 Dirty 并记录 changed files ${dirtyMarker}`, { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => {
const drawerText = document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "";
return drawerText.includes("目标文档存在未保存或外部变更状态");
},
null,
{ timeout: UI_TIMEOUT_MS },
);
assert(!captured.some((entry) => entry.kind === "run"), `dirty buffer 下 Page AI 不应发起 Hermes run: ${JSON.stringify(captured)}`);
const dirtyDiskText = fs.readFileSync(dirtyPath, "utf8");
assert(!dirtyDiskText.includes(dirtyMarker), "dirty buffer 阻断后磁盘不应出现 AI 写入标记");
const dirtyEditorState = await page.evaluate(() => {
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
const conflictPanel = document.querySelector('[data-testid="mnote-editor-conflict-panel"]');
return {
status: root?.getAttribute("data-runtime-editor-status") || "",
text: editor?.textContent || "",
conflictVisible: Boolean(conflictPanel && conflictPanel.getClientRects().length > 0),
};
});
assert(dirtyEditorState.text.includes(dirtyLocalToken), `dirty buffer 阻断后本地未保存内容应仍在编辑器中: ${JSON.stringify(dirtyEditorState)}`);
assert.equal(dirtyEditorState.conflictVisible, false, `dirty buffer 阻断不应生成冲突面板: ${JSON.stringify(dirtyEditorState)}`);
const dirtyAggregate = await fetchPageAggregate(page, dirtyDocumentId, rootUri);
assert(
!JSON.stringify(dirtyAggregate.payload || {}).includes(dirtyMarker),
`dirty buffer 阻断后 Page Aggregate 不应读回 AI 写入标记: ${JSON.stringify(dirtyAggregate)}`,
);
const result = {
ok: true,
root,
documentId,
sessionId,
runId,
marker,
aggregateRevision: aggregate.payload?.result?.body?.revision ?? aggregate.payload?.body?.revision ?? null,
editorStatus: editorState.status,
dirtyDocumentId,
dirtyRunId,
dirtyMarker,
dirtyBlocked: true,
dirtyEditorStatus: dirtyEditorState.status,
capturedKinds: cleanCapturedKinds,
dirtyCapturedKinds: captured.map((entry) => entry.kind),
resultPath: RESULT_PATH,
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} finally {
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
fs.rmSync(root, { recursive: true, force: true });
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
/**
* RETIRED (Wave 10 / pre-release legacy purge)
* 依赖已删除的 /api/hermes/client/* OpenCode Page AI host
* 产品 Page AI Pi Lab/api/page-ai/pi/*agent tools /api/mnote/tools/*
* 本文件保留作历史对照直接 exit 0不再执行浏览器/静态断言
*/
console.log(JSON.stringify({
ok: true,
retired: true,
reason: "legacy hermes client / opencode page-ai path deleted; use Pi Lab + /api/mnote/tools",
script: require("node:path").basename(__filename),
}, null, 2));
process.exit(0);
@@ -1,188 +1,16 @@
#!/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_URL,
UI_TIMEOUT_MS,
} = require("./tree-shell-smoke-helpers");
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function writeWorkspaceManifest(root, ownerId, workspaceId) {
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(
path.join(root, ".mnote", "workspace.json"),
`${JSON.stringify({
workspaceId,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "ai_sessions", "markdown_edit"],
}, null, 2)}\n`,
"utf8",
);
}
async function main() {
const suffix = Date.now().toString(36);
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-shared-read-ai-smoke-"));
const documentId = "local-md:README.md";
const actorId = "target_user";
const sessionId = `mnote_shared_read_${suffix}`;
const runId = `run_shared_read_${suffix}`;
const rootUri = fileUrl(root);
const captured = [];
const browser = await chromium.launch({
headless: true,
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
extraHTTPHeaders: {
"x-mnote-actor-id": actorId,
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
try {
writeWorkspaceManifest(root, actorId, `local-ws:${actorId}:task454`);
fs.writeFileSync(path.join(root, "README.md"), `# Shared Read Smoke\n只读共享 ${suffix}\n`, "utf8");
await page.route("**/api/ai-agent/run", async (route) => {
throw new Error(`页面 AI 不应请求旧 /api/ai-agent/run: ${route.request().url()}`);
});
await page.route("**/api/hermes/client/gateway/health**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
gateway: { ok: true, status: "mocked" },
profile: { name: "reasonix", modelConfigured: true, apiKeyConfigured: true },
suggestions: [],
}),
});
});
await page.route("**/api/hermes/client/tools**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, tools: [] }),
});
});
await page.route("**/api/hermes/client/profiles", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
active: "reasonix",
profiles: [{ name: "reasonix", label: "Reasonix", modelConfigured: true, apiKeyConfigured: true }],
}),
});
});
await page.route("**/api/hermes/client/sessions", async (route) => {
captured.push({ kind: "session", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
sessionId,
title: "共享只读会话",
traceId: `trace_shared_read_${suffix}`,
persistence: "local_ai_session_jsonl",
sessionStorage: "local_shared",
permissionLevel: "shared_read",
shareId: "share_read_smoke",
}),
});
});
await page.route(`**/api/hermes/client/sessions/${sessionId}/resume`, async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
sessionId,
session: { sessionId, messages: [] },
runtime: { sessionId, runId, status: "completed", profile: "reasonix", documentId },
}),
});
});
await page.route("**/api/hermes/client/runs", async (route) => {
captured.push({ kind: "run", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 403,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: false,
code: "local_ai_session_shared_read_write_forbidden",
message: "共享只读 AI 会话不能写入正文",
permissionLevel: "shared_read",
shareId: "share_read_smoke",
}),
});
});
const documentUrl = new URL(`${BASE_URL}/documents/${encodeURIComponent(documentId)}`);
documentUrl.searchParams.set("sourceKind", "local_folder");
documentUrl.searchParams.set("rootUri", rootUri);
await page.goto(documentUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => (document.body.textContent || "").includes("Shared Read Smoke"),
null,
{ timeout: UI_TIMEOUT_MS },
);
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({
state: "attached",
timeout: UI_TIMEOUT_MS,
}).catch(() => undefined);
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
await page.locator("[data-page-ai-input]").fill("请修改共享只读页面正文", { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => {
const drawerText = document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "";
return drawerText.includes("共享只读 AI 会话不能写入正文")
|| drawerText.includes("local_ai_session_shared_read_write_forbidden");
},
null,
{ timeout: UI_TIMEOUT_MS },
);
const readme = fs.readFileSync(path.join(root, "README.md"), "utf8");
assert(!readme.includes("请修改共享只读页面正文"), "共享只读 smoke 不应写入 README.md");
assert(captured.some((entry) => entry.kind === "run"), "未捕获 page AI run 请求");
console.log(JSON.stringify({
ok: true,
root,
documentId,
sessionId,
runId,
capturedKinds: captured.map((entry) => entry.kind),
}, null, 2));
} finally {
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
fs.rmSync(root, { recursive: true, force: true });
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
/**
* RETIRED (Wave 10 / pre-release legacy purge)
* 依赖已删除的 /api/hermes/client/* OpenCode Page AI host
* 产品 Page AI Pi Lab/api/page-ai/pi/*agent tools /api/mnote/tools/*
* 本文件保留作历史对照直接 exit 0不再执行浏览器/静态断言
*/
console.log(JSON.stringify({
ok: true,
retired: true,
reason: "legacy hermes client / opencode page-ai path deleted; use Pi Lab + /api/mnote/tools",
script: require("node:path").basename(__filename),
}, null, 2));
process.exit(0);
+13 -377
View File
@@ -1,380 +1,16 @@
#!/usr/bin/env node
"use strict";
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const OUTPUT_DIR = path.resolve(__dirname, "..", "tmp", "task490-runtime-surfaces-smoke");
const TASK = "task490-runtime-surfaces-smoke";
const TARGET_BLOCK_ID = "task490-runtime-surface-target";
function screenshotPath(name) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
return path.join(OUTPUT_DIR, `${name}.png`);
}
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function documentUrl(baseUrl, root, relativePath) {
const url = new URL(`${baseUrl}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
function writeWorkspaceManifest(root, ownerId) {
const metadataDir = path.join(root, ".mnote");
fs.mkdirSync(metadataDir, { recursive: true });
fs.writeFileSync(
path.join(metadataDir, "workspace.json"),
`${JSON.stringify({
workspaceId: `local-ws:${ownerId}:task490`,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "markdown_edit", "asset_upload"],
}, null, 2)}\n`,
"utf8",
);
}
async function saveScreenshot(page, name) {
const target = screenshotPath(name);
await page.screenshot({ path: target, fullPage: false });
return target;
}
async function assertMaterialIconSurface(locator, label, minCount) {
const iconCount = await locator.locator('.material-symbols-outlined, .slash-item-icon, .block-drag-menu-icon').count();
if (iconCount < minCount) {
throw new Error(`${label} 至少应渲染 ${minCount} 个图标容器,实际: ${iconCount}`);
}
const text = ((await locator.textContent()) || '').trim();
const legacyGlyphs = ['⚙', '×', '✕', '⋮', '◣', '↻'];
const leaked = legacyGlyphs.filter((glyph) => text.includes(glyph));
if (leaked.length > 0) {
throw new Error(`${label} 不应泄漏旧 Unicode 图标: ${leaked.join(', ')}`);
}
}
async function waitForRuntimeIsland(page, uiTimeoutMs) {
const root = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first();
await root.waitFor({ state: "visible", timeout: uiTimeoutMs });
const editor = page
.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]')
.first();
await editor.waitFor({ state: "visible", timeout: uiTimeoutMs });
await page.waitForFunction(
() => {
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const editorNode = host?.querySelector('.editor-surface .ProseMirror[contenteditable="true"]');
return host?.getAttribute("data-runtime-editor-status") !== "error"
&& editorNode instanceof HTMLElement
&& editorNode.isContentEditable;
},
null,
{ timeout: uiTimeoutMs },
);
return editor;
}
async function setBlockHandleFixture(page, uiTimeoutMs) {
await page.evaluate((targetBlockId) => {
const editor = document.querySelector(".editor-surface .ProseMirror")?.editor;
if (!editor) {
throw new Error("找不到 Tiptap editor");
}
editor.commands.setContent(
{
type: "doc",
content: [
{
type: "paragraph",
attrs: { blockId: targetBlockId },
content: [{ type: "text", text: "Task490 block handle target" }],
},
{
type: "paragraph",
attrs: { blockId: "task490-second-block" },
content: [{ type: "text", text: "Task490 slash target" }],
},
],
},
true,
);
editor.commands.focus("end");
}, TARGET_BLOCK_ID);
await page.waitForFunction(
(targetBlockId) => document.querySelector(`[data-block-id="${targetBlockId}"]`) instanceof HTMLElement,
TARGET_BLOCK_ID,
{ timeout: uiTimeoutMs },
);
}
async function closePageAiIfOpen(page) {
const closeButton = page.locator('[data-page-ai-action="close"]').first();
if (await closeButton.isVisible().catch(() => false)) {
await closeButton.click({ force: true }).catch(async () => {
await page.evaluate(() => {
const button = document.querySelector('[data-page-ai-action="close"]');
if (button instanceof HTMLElement) button.click();
}).catch(() => undefined);
});
}
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
delete require.cache[require.resolve("./tree-shell-smoke-helpers")];
const {
BASE_URL,
UI_TIMEOUT_MS,
assert,
ensureAuthenticated,
} = require("./tree-shell-smoke-helpers");
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
locale: "zh-CN",
extraHTTPHeaders: {
"x-mnote-actor-id": "mnote-e2e",
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
const screenshots = {};
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task490-"));
const relativePath = "RuntimeSurfaces.md";
const capturedPageAi = {
runs: [],
aborts: [],
};
writeWorkspaceManifest(root, "mnote-e2e");
fs.writeFileSync(
path.join(root, relativePath),
["# Runtime Surfaces", "", "Task490 initial paragraph", ""].join("\n"),
"utf8",
);
let caughtError = null;
try {
await page.route("**/api/hermes/client/gateway/health**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
gateway: { ok: true, status: "mocked" },
profile: { name: "reasonix", modelConfigured: true, apiKeyConfigured: true },
suggestions: [],
}),
});
});
await page.route("**/api/hermes/client/tools**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, tools: [] }),
});
});
await page.route("**/api/hermes/client/profiles**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
active: "mnoteai",
profiles: [{ name: "mnoteai", label: "MNote AI", modelConfigured: true, apiKeyConfigured: true }],
}),
});
});
await page.route("**/api/ai/agent-profiles**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
agentId: "reasonix",
profiles: [],
}),
});
});
await page.route("**/api/hermes/client/skills**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
});
});
await page.route("**/api/hermes/client/capabilities**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, runtime: "mnote", categories: [], archived: [] }),
});
});
await page.route("**/api/hermes/client/sessions", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
sessionId: "mnote_task490",
title: "task490",
traceId: "trace_task490",
persistence: "local_ai_session_jsonl",
sessionStorage: "local_private",
}),
});
});
await page.route("**/api/hermes/client/runs", async (route) => {
capturedPageAi.runs.push(route.request().postData() || "");
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
sessionId: "mnote_task490",
runId: "run_task490_stop",
upstream: { run_id: "run_task490_stop", trace_id: "trace_task490_stop" },
traceId: "trace_task490_stop",
}),
});
});
await page.route("**/api/hermes/client/events/run_task490_stop", async (route) => {
await new Promise((resolve) => setTimeout(resolve, UI_TIMEOUT_MS * 2));
await route.fulfill({
status: 200,
headers: { "content-type": "text/event-stream; charset=utf-8" },
body: `data: ${JSON.stringify({ event: "message.delta", run_id: "run_task490_stop", delta: "Task490 streaming" })}\n\n`,
});
});
await page.route("**/api/hermes/client/runs/run_task490_stop/abort", async (route) => {
capturedPageAi.aborts.push(route.request().postData() || "");
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
status: "aborted",
runtime: { runId: "run_task490_stop", status: "aborted", queueLength: 0 },
events: [
{ event: "abort.started", runId: "run_task490_stop" },
{ event: "abort.completed", runId: "run_task490_stop" },
],
}),
});
});
await ensureAuthenticated(page, context.request);
const url = documentUrl(BASE_URL, root, relativePath);
const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
assert(response, "文档页没有返回响应");
assert(response.status() === 200, `文档页状态码异常: ${response.status()}`);
const editModeToggle = page.locator('[data-document-edit-mode-toggle]').first();
if (await editModeToggle.isVisible({ timeout: 3_000 }).catch(() => false)) {
await editModeToggle.click({ timeout: UI_TIMEOUT_MS });
}
const editor = await waitForRuntimeIsland(page, UI_TIMEOUT_MS);
const moreButton = page.locator('[data-testid="wolai-page-settings-trigger"]').first();
await moreButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await moreButton.click({ timeout: UI_TIMEOUT_MS });
const settingsPopover = page.locator('[data-testid="wolai-page-settings-popover"]').first();
await settingsPopover.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const settingsText = (await settingsPopover.textContent()) || "";
assert(settingsText.includes("页面选项"), `页面设置 popover 未显示页面选项: ${settingsText}`);
screenshots.pageSettings = await saveScreenshot(page, "01-page-settings");
await page.keyboard.press("Escape").catch(() => undefined);
const aiButton = page.locator('[data-testid="wolai-floating-ai"]').first();
await aiButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const floatingAiText = ((await aiButton.textContent()) || "").trim();
assert(floatingAiText === "π", `浮动 AI 入口应退役旧 OpenHub 前端并显示 Pi Lab π: ${floatingAiText}`);
await aiButton.click({ timeout: UI_TIMEOUT_MS });
const piDrawer = page.locator('[data-page-ai-pi-lab="drawer"]').first();
await piDrawer.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert(await page.locator("[data-page-ai-pi-lab-input]").first().isVisible(), "Pi Lab drawer 必须显示输入框");
const retiredFrontend = await page.evaluate(() => ({
legacyDrawerOpen: Boolean(document.querySelector('[data-testid="wolai-page-ai-drawer"]:not([hidden])')),
opencodeHost: Boolean(document.querySelector('[data-page-ai-opencode-host="true"]')),
opencodeIframe: Boolean(document.querySelector('[data-page-ai-opencode-iframe]')),
}));
assert(retiredFrontend.legacyDrawerOpen === false, "浮动 Pi 入口不能打开旧 Page AI/OpenHub drawer");
assert(retiredFrontend.opencodeHost === false, "浮动 Pi 入口不能打开 opencode/OpenHub host 前端");
assert(retiredFrontend.opencodeIframe === false, "浮动 Pi 入口不能保留 opencode/OpenHub iframe");
screenshots.pageAi = await saveScreenshot(page, "02-pi-lab");
await page.locator("[data-page-ai-pi-lab-close]").click({ timeout: UI_TIMEOUT_MS });
await piDrawer.waitFor({ state: "hidden", timeout: UI_TIMEOUT_MS });
await setBlockHandleFixture(page, UI_TIMEOUT_MS);
await editor.click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.type("/");
const slashMenu = page.locator('[data-testid="mnote-leptos-tiptap-slash-menu"]').first();
await slashMenu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const slashText = (await slashMenu.textContent()) || "";
assert(slashText.trim().length > 0, "slash menu 必须显示可选项");
await assertMaterialIconSurface(slashMenu, "slash menu", 4);
screenshots.slashMenu = await saveScreenshot(page, "03-slash-menu");
await page.keyboard.press("Escape");
const target = page.locator(`[data-block-id="${TARGET_BLOCK_ID}"]`).first();
await target.scrollIntoViewIfNeeded({ timeout: UI_TIMEOUT_MS });
const targetBox = await target.boundingBox();
assert(targetBox, "block handle 目标块缺少可测量区域");
await page.mouse.move(targetBox.x + 8, targetBox.y + Math.min(12, Math.max(4, targetBox.height / 2)));
const blockHandle = page.locator('[data-testid="block-drag-handle-trigger"]').first();
await blockHandle.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await blockHandle.click({ timeout: UI_TIMEOUT_MS });
const blockMenu = page.locator('[data-testid="block-drag-menu"]').first();
await blockMenu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const blockMenuText = (await blockMenu.textContent()) || "";
assert(blockMenuText.includes("删除") || blockMenuText.includes("Delete"), `block handle menu 缺少基础操作: ${blockMenuText}`);
await assertMaterialIconSurface(blockMenu, "block handle menu", 8);
screenshots.blockHandleMenu = await saveScreenshot(page, "04-block-handle-menu");
const result = {
ok: true,
task: TASK,
baseUrl: BASE_URL,
root,
relativePath,
documentId: localMdDocumentId(relativePath),
screenshotDir: OUTPUT_DIR,
screenshots,
};
fs.writeFileSync(path.join(OUTPUT_DIR, "result.json"), `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} catch (error) {
caughtError = error;
await saveScreenshot(page, "failure").catch(() => undefined);
} finally {
fs.rmSync(root, { recursive: true, force: true });
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
if (caughtError) {
throw caughtError;
}
}
if (require.main === module) {
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
}
/**
* RETIRED (Wave 10 / pre-release legacy purge)
* 依赖已删除的 /api/hermes/client/* OpenCode Page AI host
* 产品 Page AI Pi Lab/api/page-ai/pi/*agent tools /api/mnote/tools/*
* 本文件保留作历史对照直接 exit 0不再执行浏览器/静态断言
*/
console.log(JSON.stringify({
ok: true,
retired: true,
reason: "legacy hermes client / opencode page-ai path deleted; use Pi Lab + /api/mnote/tools",
script: require("node:path").basename(__filename),
}, null, 2));
process.exit(0);
@@ -1,915 +1,16 @@
#!/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_URL,
UI_TIMEOUT_MS,
ensureAuthenticated,
} = require("./tree-shell-smoke-helpers");
const TASK = "task502-page-ai-agent-selector-context-smoke";
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function writeWorkspaceManifest(root, ownerId, workspaceId) {
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(
path.join(root, ".mnote", "workspace.json"),
`${JSON.stringify({
workspaceId,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "ai_sessions", "markdown_edit"],
}, null, 2)}\n`,
"utf8",
);
}
function documentUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
async function saveScreenshot(page, name) {
const target = path.join(OUTPUT_DIR, `${name}.png`);
await page.screenshot({ path: target, fullPage: false });
return target;
}
async function waitForCapturedRunCount(captured, minCount, timeoutMs = UI_TIMEOUT_MS) {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
if (captured.filter((item) => item.kind === "run").length >= minCount) return;
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(`captured run 数量不足,期望至少 ${minCount},实际 ${captured.filter((item) => item.kind === "run").length}`);
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const suffix = Date.now().toString(36);
const actorId = "mnote-e2e";
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task502-agent-context-"));
const rootUri = fileUrl(root);
const workspaceId = `local-ws:${actorId}:task502`;
const relativePath = "AgentContext.md";
const documentId = localMdDocumentId(relativePath);
const captured = [];
let caughtError = null;
writeWorkspaceManifest(root, actorId, workspaceId);
fs.writeFileSync(
path.join(root, relativePath),
["# Agent Context", "", `Task502 ${suffix}`, ""].join("\n"),
"utf8",
);
const browser = await chromium.launch({
headless: true,
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
locale: "zh-CN",
extraHTTPHeaders: {
"x-mnote-actor-id": actorId,
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
try {
await page.route("**/api/user/access-policy**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
controlPlane: "sqlite",
grants: [{
id: `grant_task502_${suffix}`,
userId: actorId,
workspaceId,
rootUri,
rootPath: root,
permission: "write",
recursive: true,
capabilities: ["ai", "markdown_edit"],
source: "user",
status: "active",
}],
}),
});
});
await page.route("**/api/ui/preferences**", async (route) => {
captured.push({
kind: "ui-preferences",
method: route.request().method(),
body: route.request().postData() || "",
});
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, owner: "mnote-web", result: {} }),
});
});
await page.route("**/api/hermes/client/gateway/health**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
gateway: { ok: true, status: "mocked" },
profile: { name: "reasonix", modelConfigured: true, apiKeyConfigured: true },
suggestions: [],
}),
});
});
await page.route("**/api/hermes/client/tools**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, tools: [] }),
});
});
await page.route("**/api/ai/agent-profiles**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
agentId: "hermes",
profiles: [
{ profileId: "shared_deepseek_chat", kind: "shared", displayName: "DeepSeek Chat", baseProfile: "deepseek-chat", isolatedProfile: "openclaw-deepseek-chat", canRun: true, canManageSkills: false, canManageConfig: false, readonly: true },
{ profileId: "shared_doubao_chat", kind: "shared", displayName: "豆包 Chat", baseProfile: "doubao-chat", isolatedProfile: "openclaw-doubao-chat", canRun: true, canManageSkills: false, canManageConfig: false, readonly: true },
{ profileId: "shared_gemini_chat", kind: "shared", displayName: "Gemini Chat", baseProfile: "gemini-chat", isolatedProfile: "openclaw-gemini-chat", canRun: true, canManageSkills: false, canManageConfig: false, readonly: true },
{ profileId: "usr_task502_default", kind: "personal", displayName: "我的 Hermes", baseProfile: "default", isolatedProfile: "mnote-u-task502-default", canRun: true, canManageSkills: true, canManageConfig: true },
{ profileId: "shared_lite", kind: "shared", displayName: "Lite", baseProfile: "lite", isolatedProfile: "lite", canRun: true, canManageSkills: false, canManageConfig: false, readonly: true },
],
}),
});
});
await page.route("**/api/hermes/client/profiles", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
active: "mnoteai",
profiles: [
{ name: "mnoteai", label: "MNote AI", modelConfigured: true, apiKeyConfigured: true },
{ name: "chemist", label: "Chemist", modelConfigured: true, apiKeyConfigured: true },
],
}),
});
});
await page.route("**/api/hermes/client/profiles/active", async (route) => {
captured.push({ kind: "profile-active", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true }),
});
});
await page.route("**/api/hermes/client/skills/toggle", async (route) => {
captured.push({ kind: "skill-toggle", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true }),
});
});
await page.route("**/api/hermes/client/capabilities/toggle", async (route) => {
captured.push({ kind: "capability-toggle", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true }),
});
});
await page.route("**/api/hermes/client/capabilities**", async (route) => {
const url = new URL(route.request().url());
if (url.pathname.endsWith("/toggle")) {
captured.push({ kind: "capability-toggle", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true }),
});
return;
}
const body = {
ok: true,
runtime: "mnote",
categories: [{
name: "mnote",
title: "MNote AI 能力",
capabilities: [
{ id: "mnote-current-page", name: "mnote-current-page", title: "当前页读取", description: "读取当前页", enabled: true, source: "mnote", origin: "builtin", builtin: true, configurable: true, configScope: "user_sqlite", skillKind: "mnote_capability", uiKind: "ai_capability", toolCount: 1, tools: [{ name: "mnote.context.snapshot", enabled: true }] },
{ id: "mnote-mindmap", name: "mnote-mindmap", title: "思维导图读写", description: "读取、编辑或从 outline 生成思维导图", enabled: true, source: "mnote", origin: "builtin", builtin: true, configurable: true, configScope: "user_sqlite", skillKind: "mnote_capability", uiKind: "ai_capability", toolNames: ["mnote.mindmap.fetch", "mnote.mindmap.create_from_outline"], toolCount: 2, tools: [{ name: "mnote.mindmap.fetch", enabled: true }, { name: "mnote.mindmap.create_from_outline", enabled: true }] },
],
}],
archived: [],
};
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
});
await page.route("**/api/hermes/client/skills**", async (route) => {
const url = new URL(route.request().url());
if (url.pathname.endsWith("/toggle")) {
captured.push({ kind: "skill-toggle", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true }),
});
return;
}
const runtime = url.searchParams.get("runtime");
const profile = url.searchParams.get("profileId") || url.searchParams.get("profile") || "usr_task502_default";
const body = runtime === "mnote"
? {
ok: true,
runtime: "mnote",
categories: [{
name: "mnote",
skills: [
{ id: "mnote-current-page", name: "mnote-current-page", title: "当前页读取", description: "读取当前页", enabled: true, source: "mnote", origin: "builtin", builtin: true, configurable: true, configScope: "user_sqlite", skillKind: "mnote_builtin" },
{ id: "mnote-mindmap", name: "mnote-mindmap", title: "思维导图读写", description: "读取、编辑或从 outline 生成思维导图", enabled: true, source: "mnote", origin: "builtin", builtin: true, configurable: true, configScope: "user_sqlite", skillKind: "mnote_builtin", toolNames: ["mnote.mindmap.fetch", "mnote.mindmap.create_from_outline"] },
],
}],
archived: [],
}
: runtime === "reasonix"
? {
ok: true,
runtime: "reasonix",
categories: [{ name: "project", skills: [{ id: "reasonix-review", name: "reasonix-review", description: "Reasonix review", enabled: true, toggleable: true, source: "reasonix", origin: "project" }] }],
archived: [],
}
: {
ok: true,
profile,
categories: [{
name: "writing",
skills: [
{ id: "hermes-builtin", name: "hermes-builtin", title: "Hermes builtin", description: `Builtin ${profile}`, enabled: true, source: "builtin", origin: "builtin", skillKind: "hermes_profile", profileId: profile, configurable: profile !== "shared_lite" },
{ id: profile === "shared_lite" ? "hermes-lite" : "hermes-writer", name: profile === "shared_lite" ? "hermes-lite" : "hermes-writer", title: profile === "shared_lite" ? "Hermes lite" : "Hermes writer", description: `Hermes ${profile}`, enabled: profile !== "shared_lite", source: "local", origin: "installed", skillKind: "hermes_profile", profileId: profile, configurable: profile !== "shared_lite", readonly: profile === "shared_lite" },
],
}],
archived: [],
};
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
});
await page.route("**/api/hermes/client/sessions", async (route) => {
captured.push({ kind: "session", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
sessionId: `mnote_task502_${suffix}`,
title: "task502",
traceId: `trace_task502_session_${suffix}`,
persistence: "local_ai_session_jsonl",
sessionStorage: "local_private",
}),
});
});
await page.route("**/api/hermes/client/runs", async (route) => {
captured.push({ kind: "run", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
sessionId: `mnote_task502_${suffix}`,
runId: `run_task502_${suffix}`,
events: [],
traceId: `trace_task502_run_${suffix}`,
}),
});
});
await page.route("**/api/hermes/client/events/*", async (route) => {
const includeReceipt = captured.filter((item) => item.kind === "run").length > 1;
const completed = { event: "run.completed", run_id: `run_task502_${suffix}`, output: "Task502 response" };
if (includeReceipt) {
completed.agentAudit = {
rootUri,
actorId: "mnote-e2e",
actorType: "user",
agentKind: "reasonix",
changedFiles: [{ path: relativePath, changeType: "modified", summary: "task502 receipt" }],
agentRunReceipt: {
schema: "mnote.agent_run_receipt.v1",
runId: `run_task502_${suffix}`,
sessionId: `mnote_task502_${suffix}`,
workspaceId,
documentId,
rootUri,
agentKind: "reasonix",
status: "completed",
permission: "write",
changedFiles: [{ path: relativePath, changeType: "modified", summary: "task502 receipt" }],
refresh: {
touchesCurrentFile: true,
currentDocumentId: documentId,
strategy: "refresh_current_file",
},
},
};
}
await route.fulfill({
status: 200,
headers: { "content-type": "text/event-stream; charset=utf-8" },
body:
`data: ${JSON.stringify({ event: "message.delta", run_id: `run_task502_${suffix}`, delta: "Task502 response" })}\n\n` +
`data: ${JSON.stringify(completed)}\n\n`,
});
});
await ensureAuthenticated(page, context.request);
const response = await page.goto(documentUrl(root, relativePath), {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
});
assert(response && response.status() === 200, `文档页状态码异常: ${response && response.status()}`);
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="wolai-page-ai-drawer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const drawer = page.locator('[data-testid="wolai-page-ai-drawer"]');
const defaultChatText = await drawer.innerText({ timeout: UI_TIMEOUT_MS });
assert(!defaultChatText.includes("问 Hermes"), "默认输入区不应继续固定写“问 Hermes”");
assert(!defaultChatText.includes("model.default"), "默认聊天面不应显示 model.default");
assert(!defaultChatText.includes("gateway:"), "默认聊天面不应显示 gateway 技术详情");
assert(!defaultChatText.includes("Hermes profile"), "默认聊天面不应显示 Hermes profile 技术项");
assert.strictEqual(
await page.locator("[data-page-ai-agent-selector]").count(),
0,
"默认输入区不应继续平铺 agent selector,应收敛为一个 agent 按钮",
);
const agentButton = page.locator("[data-page-ai-agent-button]");
await agentButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert(
await agentButton.evaluate((node) => Boolean(node.closest(".wolai-page-ai-composer-bar"))),
"agent 按钮应显示在输入区下方工具栏中",
);
const agentButtonLabel = await agentButton.getAttribute("aria-label");
assert(agentButtonLabel.includes("Agent"), `agent 按钮应提供当前 agent 摘要: ${agentButtonLabel}`);
await agentButton.click({ timeout: UI_TIMEOUT_MS });
const agentPopover = page.locator("[data-page-ai-agent-popover]");
await agentPopover.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await agentPopover.locator('[data-page-ai-agent-section="chat_only"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await agentPopover.locator('[data-page-ai-agent-section="hermes"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert.strictEqual(
await page.locator('[data-page-ai-agent-section="chat_only"] [data-page-ai-profile-id="shared_deepseek_chat"]').count(),
1,
"ChatOnly 二级菜单应包含 DeepSeek",
);
assert.strictEqual(
await page.locator('[data-page-ai-agent-section="chat_only"] [data-page-ai-profile-id="shared_doubao_chat"]').count(),
1,
"ChatOnly 二级菜单应包含豆包",
);
assert.strictEqual(
await page.locator('[data-page-ai-agent-section="chat_only"] [data-page-ai-profile-id="shared_gemini_chat"]').count(),
1,
"ChatOnly 二级菜单应包含 Gemini",
);
assert.strictEqual(
await page.locator('[data-page-ai-agent-section="hermes"] [data-page-ai-profile-id="usr_task502_default"]').count(),
1,
"Hermes 二级菜单应包含个人 profile",
);
assert.strictEqual(
await page.locator('[data-page-ai-agent-section="hermes"] [data-page-ai-profile-id="shared_lite"]').count(),
1,
"Hermes 二级菜单应包含 shared_lite profile",
);
const agentChip = page.locator("[data-page-ai-agent-chip]");
await page.locator('[data-page-ai-agent-section="chat_only"] [data-page-ai-profile-id="shared_gemini_chat"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => document.documentElement.getAttribute("data-mnote-page-ai-agent-id") === "chat_only"
&& document.documentElement.getAttribute("data-mnote-page-ai-profile") === "shared_gemini_chat",
null,
{ timeout: UI_TIMEOUT_MS },
);
assert((await agentChip.innerText({ timeout: UI_TIMEOUT_MS })).includes("ChatOnly / Gemini"), "上下文按钮右侧标签应显示当前 ChatOnly agent");
await agentButton.click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-agent-section="hermes"] [data-page-ai-profile-id="shared_lite"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => document.documentElement.getAttribute("data-mnote-page-ai-agent-id") === "hermes"
&& document.documentElement.getAttribute("data-mnote-page-ai-profile") === "shared_lite",
null,
{ timeout: UI_TIMEOUT_MS },
);
assert((await agentChip.innerText({ timeout: UI_TIMEOUT_MS })).includes("Hermes / Lite"), "上下文按钮右侧标签应显示当前 Hermes profile");
await agentButton.click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-agent-section="reasonix"] [data-page-ai-agent-id="reasonix"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => document.documentElement.getAttribute("data-mnote-page-ai-agent-id") === "reasonix",
null,
{ timeout: UI_TIMEOUT_MS },
);
assert((await agentChip.innerText({ timeout: UI_TIMEOUT_MS })).includes("Reasonix"), "上下文按钮右侧标签应显示当前 Reasonix agent");
const contextRefs = page.locator("[data-page-ai-context-refs]");
assert.strictEqual(
await contextRefs.count(),
0,
"默认输入区不应继续平铺 contextRef chip,应收敛为一个上下文按钮",
);
const contextButton = page.locator("[data-page-ai-context-button]");
await contextButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert(
await contextButton.evaluate((node) => Boolean(node.closest(".wolai-page-ai-composer-bar"))),
"上下文按钮应显示在输入区下方工具栏中",
);
const targetButton = page.locator("[data-page-ai-target-button]");
await targetButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert(
await targetButton.evaluate((node) => Boolean(node.closest(".wolai-page-ai-composer-bar"))),
"目标按钮应显示在输入区下方工具栏中",
);
const targetChip = page.locator("[data-page-ai-target-chip]");
await targetChip.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const targetChipText = (await targetChip.innerText({ timeout: UI_TIMEOUT_MS })).trim();
assert(targetChipText.includes("AgentContext") || targetChipText.includes("当前页"), `目标 chip 应展示当前写入目标: ${targetChipText}`);
await targetButton.click({ timeout: UI_TIMEOUT_MS });
const targetPopover = page.locator("[data-page-ai-target-popover]");
await targetPopover.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert(
await targetPopover.locator("[data-page-ai-target-option]").first().isVisible(),
"目标 popover 应提供至少一个可选目标",
);
await targetPopover.locator('[data-page-ai-action="close-target-popover"]').click({ timeout: UI_TIMEOUT_MS });
await page.evaluate(({ documentId, rootUri, workspaceId }) => {
const runtime = window.__mnoteDocumentPaneRuntime;
if (!runtime || typeof runtime.getOpenEditorsSnapshot !== "function") {
throw new Error("缺少 open editors snapshot runtime");
}
const original = runtime.getOpenEditorsSnapshot.bind(runtime);
runtime.getOpenEditorsSnapshot = () => {
const snapshot = original();
const mindmapResource = {
objectIdentity: "resource:mindmap:task502",
workspacePath: {
schema: "mnote.workspace_path.v1",
workspaceId,
sourceKind: "local_folder",
rootUri,
relativePath: "maps/Task502.mindmap.json",
documentId,
objectIdentity: "resource:mindmap:task502",
assetId: "task502-mindmap",
resourceKind: "mindmap",
},
paneRole: "primary",
documentId,
workspaceId,
title: "Task502 Mindmap",
kind: "mindmap",
editorKind: "mindmap",
active: false,
dirtyState: "",
preview: false,
pinned: true,
lastActiveAt: Date.now(),
assetId: "task502-mindmap",
path: "maps/Task502.mindmap.json",
};
const officeResource = {
objectIdentity: "resource:office:task502",
workspacePath: {
schema: "mnote.workspace_path.v1",
workspaceId,
sourceKind: "local_folder",
rootUri,
relativePath: "office/Task502 Deck.pptx",
documentId,
objectIdentity: "resource:office:task502",
assetId: "task502-office",
resourceKind: "office",
},
paneRole: "primary",
documentId,
workspaceId,
title: "Task502 Deck",
kind: "office",
editorKind: "office",
active: false,
dirtyState: "",
preview: false,
pinned: true,
lastActiveAt: Date.now(),
assetId: "task502-office",
path: "office/Task502 Deck.pptx",
onlyofficeSessionId: "mnote-oo-task502-office",
bridgeSessionId: "mnote-oo-task502-office",
bridgeSessionReady: true,
};
const officePreviewResource = {
objectIdentity: "resource:office-preview:task502",
workspacePath: {
schema: "mnote.workspace_path.v1",
workspaceId,
sourceKind: "local_folder",
rootUri,
relativePath: "office/Task502 Preview.docx",
documentId,
objectIdentity: "resource:office-preview:task502",
assetId: "task502-office-preview",
resourceKind: "attachment",
},
paneRole: "primary",
documentId,
workspaceId,
title: "Task502 Preview DOCX",
kind: "office",
editorKind: "office",
active: false,
dirtyState: "",
preview: true,
pinned: true,
lastActiveAt: Date.now(),
assetId: "task502-office-preview",
path: "office/Task502 Preview.docx",
officeOpenMode: "preview",
onlyofficeSessionId: "",
bridgeSessionId: "",
bridgeSessionReady: false,
};
const resources = [mindmapResource, officeResource, officePreviewResource];
const withoutResource = (items) => (Array.isArray(items) ? items : [])
.filter((item) => !resources.some((resource) => item?.objectIdentity === resource.objectIdentity));
const groups = snapshot.groups || {};
const primary = groups.primary || {};
return {
...snapshot,
editors: [...withoutResource(snapshot.editors), ...resources],
resourceEditors: [...withoutResource(snapshot.resourceEditors), ...resources],
groups: {
...groups,
primary: {
...primary,
resourceEditors: [...withoutResource(primary.resourceEditors), ...resources],
},
},
};
};
}, { documentId, rootUri, workspaceId });
await targetButton.click({ timeout: UI_TIMEOUT_MS });
await targetPopover.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await targetPopover.locator('[data-page-ai-target-option="resource:office:task502"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => (document.querySelector("[data-page-ai-target-chip]")?.textContent || "").includes("Task502 Deck"),
null,
{ timeout: UI_TIMEOUT_MS },
);
assert.strictEqual(
await page.locator('.wolai-page-ai-composer-bar [data-page-ai-action="history"]').count(),
0,
"历史会话不应继续占用输入区下方工具栏位置",
);
assert(
await page.locator('.wolai-page-ai-header-actions [data-page-ai-action="history"]').isVisible(),
"历史会话入口应移动到右上角设置旁边",
);
assert(
await page.locator('.wolai-page-ai-header-actions [data-page-ai-tab="skills"]').isVisible(),
"技能入口应显示在右上角历史按钮左侧",
);
await page.locator('.wolai-page-ai-header-actions [data-page-ai-tab="skills"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="skills"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const skillSourceSelect = page.locator('[data-page-ai-panel="skills"] [data-page-ai-skill-source-select]');
await skillSourceSelect.waitFor({ state: "attached", timeout: UI_TIMEOUT_MS });
const skillSourceOptions = await skillSourceSelect.locator("option").evaluateAll((nodes) =>
nodes.map((node) => ({ value: node.value, text: node.textContent || "" })),
);
assert(skillSourceOptions.some((item) => item.value === "mnote" && item.text.includes("MNote 公共能力")), "能力来源应包含 MNote");
assert(skillSourceOptions.some((item) => item.value === "reasonix" && item.text.includes("Reasonix skill")), "能力来源应包含 Reasonix 自带 skill 查看入口");
assert(skillSourceOptions.some((item) => item.value === "hermes:usr_task502_default" && item.text.includes("Hermes skill")), "能力来源应包含个人 Hermes profile skill 查看入口");
assert(!skillSourceOptions.some((item) => item.value === "hermes:shared_deepseek_chat"), "Chat-only Hermes profile 不应作为能力来源展示");
assert(!skillSourceOptions.some((item) => item.value === "hermes:shared_lite"), "Hermes Lite chat-only profile 不应作为能力来源展示");
await page.locator('[data-page-ai-skill-group="mnote"] [data-page-ai-skill-toggle="mnote-current-page"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-skill-group="mnote"] [data-page-ai-skill-toggle="mnote-mindmap"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const skillPanelText = await page.locator('[data-page-ai-panel="skills"]').innerText({ timeout: UI_TIMEOUT_MS });
assert(skillPanelText.includes("MNote 公共能力"), "能力面板应展示 MNote 来源");
assert(!skillPanelText.includes("user_sqlite"), "MNote 能力面板不应展示内部 SQLite policy 细节");
assert(!skillPanelText.includes("profile_tool_policy"), "MNote 能力面板不应展示内部 profile policy 细节");
assert(!skillPanelText.includes("reasonix-review"), "选择 MNote 时不应同时展示 Reasonix 能力条目");
assert(!skillPanelText.includes("Hermes writer"), "选择 MNote 时不应同时展示 Hermes 能力条目");
const mnoteSkillsScreenshot = await saveScreenshot(page, "00-mnote-skills-panel");
await page.locator('[data-page-ai-skill-group-toggle="mnote"]').click({ timeout: UI_TIMEOUT_MS });
assert.strictEqual(
await page.locator('[data-page-ai-skill-group="mnote"] [data-page-ai-skill-toggle="mnote-current-page"]').count(),
0,
"MNote 技能分组折叠后不应显示组内技能",
);
assert.strictEqual(
await page.locator('[data-page-ai-skill-group="mnote"] [data-page-ai-skill-toggle="mnote-mindmap"]').count(),
0,
"MNote 技能分组折叠后不应显示 mindmap 技能",
);
await page.locator('[data-page-ai-skill-group-toggle="mnote"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-skill-group="mnote"] [data-page-ai-skill-toggle="mnote-current-page"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-skill-group="mnote"] [data-page-ai-skill-toggle="mnote-mindmap"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-skill-group="mnote"] [data-page-ai-skill-toggle="mnote-current-page"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-skill-group="mnote"] [data-page-ai-skill-toggle="mnote-mindmap"]').click({ timeout: UI_TIMEOUT_MS });
await skillSourceSelect.selectOption("reasonix", { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-skill-group="reasonix"] .wolai-page-ai-skill-name', { hasText: "reasonix-review" }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert.strictEqual(
await page.locator('[data-page-ai-skill-group="reasonix"] [data-page-ai-skill-toggle="reasonix-review"]').count(),
0,
"Reasonix 自带 skill 在能力页只读查看,不显示开关",
);
await skillSourceSelect.selectOption("hermes:usr_task502_default", { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-skill-group="hermes"] .wolai-page-ai-skill-name', { hasText: "Hermes writer" }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert.strictEqual(
await page.locator('[data-page-ai-skill-group="hermes"] [data-page-ai-skill-toggle="hermes-writer"]').count(),
0,
"Hermes profile skill 在能力页只读查看,不显示开关",
);
const skillsScreenshot = await saveScreenshot(page, "00-hermes-skills-panel");
await page.locator('[data-page-ai-panel="skills"] [data-page-ai-tab="chat"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="chat"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const contextButtonText = await contextButton.innerText({ timeout: UI_TIMEOUT_MS });
assert(
contextButtonText.trim() === "⇅",
`上下文按钮应显示为单个上下文图标: ${contextButtonText}`,
);
const contextButtonLabel = await contextButton.getAttribute("aria-label");
assert(
contextButtonLabel.includes("当前页") && contextButtonLabel.includes("打开资源"),
`上下文按钮 aria-label 应摘要展示已选上下文: ${contextButtonLabel}`,
);
await contextButton.click({ timeout: UI_TIMEOUT_MS });
const contextPopover = page.locator("[data-page-ai-context-popover]");
await contextPopover.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert(
await contextPopover.locator('input[type="checkbox"][data-page-ai-context-ref="current_page"]:checked').isVisible(),
"当前页 contextRef 应在 popover checkbox 中默认勾选",
);
await contextPopover.locator('[data-page-ai-action="close-context-popover"]').click({ timeout: UI_TIMEOUT_MS });
assert.strictEqual(
await page.locator(".wolai-page-ai-composer > [data-page-ai-allowed-roots]").count(),
0,
"授权区域不应继续在输入区显示黑色 chip,应收敛到上下文 popover 内",
);
await contextButton.click({ timeout: UI_TIMEOUT_MS });
assert(
(await contextPopover.innerText({ timeout: UI_TIMEOUT_MS })).includes("授权区域"),
"SQLite 授权区域应在上下文 popover 内展示",
);
await contextPopover.locator('[data-page-ai-action="close-context-popover"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator("[data-page-ai-input]").fill("收到请回复收到", { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("Task502 response"),
null,
{ timeout: UI_TIMEOUT_MS },
);
assert.strictEqual(
await page.locator("[data-page-ai-tool-card]").count(),
0,
"纯聊天短请求不应显示 MNote tool card",
);
const ackRuns = captured.filter((item) => item.kind === "run");
assert(ackRuns.length >= 1, "未捕获纯聊天 Page AI run payload");
const ackRunBody = JSON.parse(ackRuns[ackRuns.length - 1].body);
assert.strictEqual(ackRunBody.pageContext?.aiContext?.pageText, undefined, "Page AI run 不应默认上传 pageText");
assert.strictEqual(ackRunBody.pageContext?.aiContext?.pageXml, undefined, "Page AI run 不应默认上传 pageXml");
assert.strictEqual(ackRunBody.pageContext?.aiContext?.contextBlocks, undefined, "Page AI run 不应默认上传 contextBlocks");
assert.strictEqual(ackRunBody.pageContext?.evidence, undefined, "Page AI run 不应默认上传选区 evidence 正文");
assert.strictEqual(ackRunBody.selectedText, "", "Page AI run 不应默认上传 selectedText 正文");
assert.strictEqual(ackRunBody.targetPackage?.schema, "mnote.agent_target_package.v1", "纯聊天 run 也应冻结目标包合同");
assert(ackRunBody.targetPackage?.primaryTargetId, "targetPackage 应包含 primaryTargetId");
assert(Array.isArray(ackRunBody.targetPackage?.targets), "targetPackage 应包含 targets 数组");
assert.strictEqual(ackRunBody.targetPackage?.primaryTargetId, "resource:office:task502", "targetPackage 应冻结用户选择的 Office target");
assert.strictEqual(ackRunBody.targetPackage?.onlyofficeSessionId, "mnote-oo-task502-office", "Office targetPackage 应携带 bridge session");
assert(
ackRunBody.targetPackage.targets.some((target) => target.resourceKind === "only_office" && target.relativePath === "office/Task502 Deck.pptx" && target.onlyofficeSessionId === "mnote-oo-task502-office"),
`默认目标应标记 only_office resourceKind: ${JSON.stringify(ackRunBody.targetPackage)}`,
);
assert.strictEqual(ackRunBody.targetPackage?.policy?.writeRequiresExplicitTarget, true, "targetPackage policy 应要求显式目标");
await page.waitForFunction(
() => ["completed", "idle"].includes(document.documentElement.getAttribute("data-mnote-page-ai-run-status") || ""),
null,
{ timeout: UI_TIMEOUT_MS },
);
await targetButton.click({ timeout: UI_TIMEOUT_MS });
await targetPopover.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await targetPopover.locator('[data-page-ai-target-option="resource:office-preview:task502"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => (document.querySelector("[data-page-ai-target-chip]")?.textContent || "").includes("Task502 Preview DOCX"),
null,
{ timeout: UI_TIMEOUT_MS },
);
const previewRunCountBefore = captured.filter((item) => item.kind === "run").length;
await page.locator("[data-page-ai-input]").fill("预览 docx 请正常回复", { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await waitForCapturedRunCount(captured, previewRunCountBefore + 1);
await page.waitForFunction(
() => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("Task502 response"),
null,
{ timeout: UI_TIMEOUT_MS },
);
const previewRuns = captured.filter((item) => item.kind === "run");
assert(previewRuns.length >= 2, "未捕获 Office 预览 Page AI run payload");
const previewRunBody = JSON.parse(previewRuns[previewRuns.length - 1].body);
assert.strictEqual(previewRunBody.targetPackage?.primaryTargetId, "resource:office-preview:task502", "Office 预览 targetPackage 应冻结预览资源");
assert.strictEqual(previewRunBody.targetPackage?.resourceKind, "attachment", `Office 预览不应被归一为 only_office: ${JSON.stringify(previewRunBody.targetPackage)}`);
assert.strictEqual(previewRunBody.targetPackage?.onlyofficeSessionId, "", "Office 预览 targetPackage 不应要求 bridge session");
assert(
previewRunBody.targetPackage.targets.some((target) => target.resourceKind === "attachment" && target.relativePath === "office/Task502 Preview.docx" && !target.onlyofficeSessionId),
`Office 预览 target 应作为普通附件上下文发送: ${JSON.stringify(previewRunBody.targetPackage)}`,
);
await page.locator('.wolai-page-ai-header-actions [data-page-ai-tab="agent"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="agent"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const commonSettingsText = await page.locator('[data-page-ai-panel="agent"]').innerText({ timeout: UI_TIMEOUT_MS });
assert(commonSettingsText.includes("授权区域"), "Common 设置页应展示授权区域");
assert(commonSettingsText.includes("默认上下文"), "Common 设置页应展示默认 contextRefs");
assert(commonSettingsText.includes("审计"), "Common 设置页应展示审计/changed files 共性设置");
assert(!commonSettingsText.includes("Hermes profile"), "Common 设置页不应包含 Hermes profile");
assert(!commonSettingsText.includes("ACP runtime"), "Common 设置页不应包含 agent runtime 差异项");
await page.locator('[data-page-ai-panel="agent"] [data-page-ai-tab="hermes-settings"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="hermes-settings"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const hermesSettingsText = await page.locator('[data-page-ai-panel="hermes-settings"]').innerText({ timeout: UI_TIMEOUT_MS });
assert(hermesSettingsText.includes("Hermes profile"), "Hermes 设置页应只承接 Hermes profile");
assert(!hermesSettingsText.includes("Reasonix 专属设置"), "Hermes 设置页不应混入 Reasonix 设置");
await page.locator('[data-page-ai-panel="hermes-settings"] [data-page-ai-tab="reasonix-settings"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="reasonix-settings"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const reasonixSettingsText = await page.locator('[data-page-ai-panel="reasonix-settings"]').innerText({ timeout: UI_TIMEOUT_MS });
assert(reasonixSettingsText.includes("ACP runtime"), "Reasonix 设置页应展示 ACP runtime");
assert(reasonixSettingsText.includes("Reasonix 专属设置"), "Reasonix 设置页应展示 Reasonix 专属设置");
assert(!reasonixSettingsText.includes("Hermes profile"), "Reasonix 设置页不应混入 Hermes profile");
await page.locator('[data-page-ai-panel="reasonix-settings"] [data-page-ai-tab="chat-only-settings"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="chat-only-settings"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const chatOnlySettingsText = await page.locator('[data-page-ai-panel="chat-only-settings"]').innerText({ timeout: UI_TIMEOUT_MS });
assert(chatOnlySettingsText.includes("默认不申请文件写权限"), "Chat-only 设置页应说明默认不申请写权限");
assert(!chatOnlySettingsText.includes("ACP runtime"), "Chat-only 设置页不应混入 ACP runtime");
await page.locator('[data-page-ai-panel="chat-only-settings"] [data-page-ai-tab="chat"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="chat"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await contextButton.click({ timeout: UI_TIMEOUT_MS });
await contextPopover.locator('input[type="checkbox"][data-page-ai-context-ref="current_page"]').click({ timeout: UI_TIMEOUT_MS });
await contextPopover.locator('input[type="checkbox"][data-page-ai-context-ref="folder"]').click({ timeout: UI_TIMEOUT_MS });
await contextPopover.locator('input[type="checkbox"][data-page-ai-context-ref="changed_files"]').click({ timeout: UI_TIMEOUT_MS });
const updatedContextButtonLabel = await contextButton.getAttribute("aria-label");
assert(
updatedContextButtonLabel.includes("打开资源") && updatedContextButtonLabel.includes("文件夹"),
`勾选变化后上下文按钮摘要应更新: ${updatedContextButtonLabel}`,
);
await contextPopover.locator('[data-page-ai-action="close-context-popover"]').click({ timeout: UI_TIMEOUT_MS });
await targetButton.click({ timeout: UI_TIMEOUT_MS });
await targetPopover.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await targetPopover.locator('[data-page-ai-target-option="resource:mindmap:task502"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => (document.querySelector("[data-page-ai-target-chip]")?.textContent || "").includes("Task502 Mindmap"),
null,
{ timeout: UI_TIMEOUT_MS },
);
const runsBeforeDocumentTask = captured.filter((item) => item.kind === "run").length;
await page.locator("[data-page-ai-input]").fill(`Task502 agent/context ${suffix}`, { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
const runWaitStarted = Date.now();
while (captured.filter((item) => item.kind === "run").length <= runsBeforeDocumentTask) {
assert(Date.now() - runWaitStarted < UI_TIMEOUT_MS, "未捕获第二次 Page AI run payload");
await page.waitForTimeout(50);
}
await page.waitForFunction(
() => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("Task502 response"),
null,
{ timeout: UI_TIMEOUT_MS },
);
const runs = captured.filter((item) => item.kind === "run");
assert(runs.length >= 1, "未捕获 Page AI run payload");
const runBody = JSON.parse(runs[runs.length - 1].body);
assert.strictEqual(runBody.agentId, "reasonix");
assert(Array.isArray(runBody.contextRefs), "run payload 必须包含 contextRefs 数组");
assert(!runBody.contextRefs.some((item) => item.kind === "current_page"), "取消当前页后不应发送 current_page contextRef");
assert.strictEqual(runBody.pageContext?.aiContext?.pageText, undefined, "Page AI run 不应默认上传 pageText");
assert.strictEqual(runBody.pageContext?.aiContext?.pageXml, undefined, "Page AI run 不应默认上传 pageXml");
assert.strictEqual(runBody.pageContext?.aiContext?.contextBlocks, undefined, "Page AI run 不应默认上传 contextBlocks");
assert.strictEqual(runBody.pageContext?.evidence, undefined, "Page AI run 不应默认上传选区 evidence 正文");
assert.strictEqual(runBody.selectedText, "", "Page AI run 不应默认上传 selectedText 正文");
const activeEditorRef = runBody.contextRefs.find((item) => item.kind === "active_editor" && item.documentId === documentId);
assert(activeEditorRef, "run payload 应包含 active_editor contextRef");
assert.strictEqual(activeEditorRef.targetId, "resource:mindmap:task502", `active_editor contextRef 应冻结 mindmap targetId: ${JSON.stringify(activeEditorRef)}`);
assert.strictEqual(activeEditorRef.objectIdentity, "resource:mindmap:task502", `active_editor contextRef 应冻结 mindmap objectIdentity: ${JSON.stringify(activeEditorRef)}`);
assert.strictEqual(activeEditorRef.resourceKind, "mindmap", `active_editor contextRef 应标记 mindmap resourceKind: ${JSON.stringify(activeEditorRef)}`);
assert.strictEqual(activeEditorRef.assetId, "task502-mindmap", `active_editor contextRef 应携带 mindmap assetId: ${JSON.stringify(activeEditorRef)}`);
assert.strictEqual(activeEditorRef.relativePath, "maps/Task502.mindmap.json", `active_editor contextRef 应携带 mindmap relativePath: ${JSON.stringify(activeEditorRef)}`);
assert(runBody.contextRefs.some((item) => item.kind === "folder" && item.rootUri === rootUri));
assert(runBody.contextRefs.some((item) => item.kind === "changed_files"));
assert.strictEqual(runBody.skillPreferences?.mnote?.["mnote-current-page"], false, "MNote skill 开关应进入 run payload");
assert.strictEqual(runBody.skillPreferences?.mnote?.["mnote-mindmap"], false, "MNote mindmap skill 开关应进入 run payload");
assert.strictEqual(runBody.targetPackage?.schema, "mnote.agent_target_package.v1", "文档任务 run 应携带目标包");
assert(runBody.targetPackage?.primaryTargetId, "文档任务 targetPackage 应包含 primaryTargetId");
assert(Array.isArray(runBody.targetPackage?.targets), "文档任务 targetPackage 应包含 targets 数组");
assert.strictEqual(runBody.targetPackage?.primaryTargetId, "resource:mindmap:task502", "文档任务 targetPackage 应冻结用户选择的 mindmap target");
assert(
runBody.targetPackage.targets.some((target) => target.resourceKind === "mindmap" && target.relativePath === "maps/Task502.mindmap.json"),
`文档任务 targetPackage 应包含 mindmap 目标: ${JSON.stringify(runBody.targetPackage)}`,
);
assert.strictEqual(runBody.targetPackage?.policy?.writeRequiresExplicitTarget, true, "文档任务 targetPackage policy 应要求显式目标");
const preferenceBodies = captured
.filter((item) => item.kind === "ui-preferences" && item.method === "PUT")
.map((item) => JSON.parse(item.body || "{}"));
assert(preferenceBodies.some((body) => body.updates?.["ai.agent.hermes.profile_id"] === "shared_lite"), "Agent 内的 Hermes profile 选择应写入 SQLite UI preference");
assert(captured.some((item) => item.kind === "capability-toggle" && JSON.parse(item.body || "{}").id === "mnote-current-page"), "MNote 能力开关应调用服务端 per-user SQLite policy");
assert(preferenceBodies.some((body) => body.updates?.["ai.common.skills.groups.collapsed"]?.mnote === true), "能力分组折叠状态应写入 SQLite UI preference");
assert(!preferenceBodies.some((body) => body.updates?.["ai.agent.reasonix.skills.enabled"]), "能力页不应再写入 Reasonix 自带 skill 偏好");
assert(!preferenceBodies.some((body) => body.updates?.["ai.agent.hermes.skills.hide_builtin"] === true), "能力页不应再写入 Hermes 内置 skill 过滤偏好");
assert(Array.isArray(runBody.allowedRoots), "run payload 必须包含 allowedRoots 数组");
assert(runBody.allowedRoots.some((item) =>
item.rootUri === rootUri
&& item.permission === "write"
&& item.source === "sqlite_directory_grant"
));
assert.strictEqual(runBody.runTargetSnapshot?.source, "open_editors_snapshot");
await page.waitForFunction(
() => document.documentElement.getAttribute("data-mnote-page-ai-receipt-current-refresh") === "true",
null,
{ timeout: UI_TIMEOUT_MS },
);
assert.strictEqual(
await page.evaluate(() => document.documentElement.getAttribute("data-mnote-page-ai-receipt-filetree-refresh")),
"true",
"agentRunReceipt.changedFiles 应触发文件树事件驱动刷新",
);
const screenshot = await saveScreenshot(page, "01-agent-selector-context");
const result = {
ok: true,
task: TASK,
baseUrl: BASE_URL,
root,
rootUri,
documentId,
mnoteSkillsScreenshot,
screenshot,
skillsScreenshot,
captured,
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} catch (error) {
caughtError = error;
await saveScreenshot(page, "failure").catch(() => undefined);
} finally {
fs.rmSync(root, { recursive: true, force: true });
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
if (caughtError) {
throw caughtError;
}
}
if (require.main === module) {
main().catch((error) => {
console.error(error);
process.exit(1);
});
}
/**
* RETIRED (Wave 10 / pre-release legacy purge)
* 依赖已删除的 /api/hermes/client/* OpenCode Page AI host
* 产品 Page AI Pi Lab/api/page-ai/pi/*agent tools /api/mnote/tools/*
* 本文件保留作历史对照直接 exit 0不再执行浏览器/静态断言
*/
console.log(JSON.stringify({
ok: true,
retired: true,
reason: "legacy hermes client / opencode page-ai path deleted; use Pi Lab + /api/mnote/tools",
script: require("node:path").basename(__filename),
}, null, 2));
process.exit(0);
@@ -56,7 +56,7 @@ async function callMnoteTool({ toolName, workspaceId, documentId, rootUri, args,
dryRun: false,
args,
};
const result = await callJson(`${BASE_URL}/api/hermes/tools/mnote/call`, {
const result = await callJson(`${BASE_URL}/api/mnote/tools/call`, {
method: "POST",
headers: {
"content-type": "application/json",
@@ -1,596 +1,16 @@
#!/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_URL,
UI_TIMEOUT_MS,
ensureAuthenticated,
} = require("./tree-shell-smoke-helpers");
const TASK = "task504-page-ai-history-agent-filter-smoke";
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function documentUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
function writeWorkspaceManifest(root, ownerId, workspaceId) {
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(
path.join(root, ".mnote", "workspace.json"),
`${JSON.stringify({
workspaceId,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "ai_sessions", "markdown_edit"],
}, null, 2)}\n`,
"utf8",
);
}
async function saveScreenshot(page, name) {
const target = path.join(OUTPUT_DIR, `${name}.png`);
await page.screenshot({ path: target, fullPage: false });
return target;
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const suffix = Date.now().toString(36);
const actorId = "mnote-e2e";
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task504-history-"));
const rootUri = fileUrl(root);
const workspaceId = `local-ws:${actorId}:task504`;
const relativePath = "HistoryAgentFilter.md";
const documentId = localMdDocumentId(relativePath);
const longGeminiMessage = [
"Gemini 历史预览应只显示摘要,不应把 ChatOnly 的完整长消息塞进历史列表。",
"这段内容用于模拟网页问答返回的完整长回复,历史列表应保留可扫描性。",
"FULL_TAIL_SHOULD_NOT_RENDER",
].join("");
const sessions = [
{
sessionId: `mnote_task504_gemini_${suffix}`,
runId: `run_task504_gemini_${suffix}`,
title: "Gemini 问答",
profile: "shared_gemini_chat",
acpRuntime: "hermes",
status: "completed",
payload: {
agentId: "chat_only",
profileId: "shared_gemini_chat",
profile: "shared_gemini_chat",
acpRuntime: "hermes",
message: longGeminiMessage,
},
createdAt: "2026-05-30T08:00:00Z",
updatedAt: "2026-05-30T08:02:00Z",
persistence: "sqlite_acp_runtime_store",
},
{
sessionId: `mnote_task504_doubao_${suffix}`,
runId: `run_task504_doubao_${suffix}`,
title: "豆包问答",
profile: "shared_doubao_chat",
acpRuntime: "hermes",
status: "completed",
payload: {
agentId: "chat_only",
profileId: "shared_doubao_chat",
profile: "shared_doubao_chat",
acpRuntime: "hermes",
message: "豆包短回复",
},
createdAt: "2026-05-30T08:01:00Z",
updatedAt: "2026-05-30T08:01:30Z",
persistence: "sqlite_acp_runtime_store",
},
{
sessionId: `mnote_task504_stale_chatonly_${suffix}`,
runId: `run_task504_stale_chatonly_${suffix}`,
title: "旧 ChatOnly profile",
profile: "myHermes",
acpRuntime: "hermes",
status: "completed",
payload: {
agentId: "chat_only",
profileId: "myHermes",
profile: "myHermes",
acpRuntime: "hermes",
message: "旧数据里误写成 Hermes profile 的 ChatOnly 会话",
},
createdAt: "2026-05-30T08:00:50Z",
updatedAt: "2026-05-30T08:01:00Z",
persistence: "sqlite_acp_runtime_store",
},
{
sessionId: `mnote_task504_hermes_${suffix}`,
runId: `run_task504_hermes_${suffix}`,
title: "Hermes 问答",
profile: "shared_lite",
acpRuntime: "hermes",
status: "completed",
payload: {
agentId: "hermes",
profileId: "shared_lite",
profile: "shared_lite",
acpRuntime: "hermes",
message: "Hermes 回复",
},
createdAt: "2026-05-30T08:00:30Z",
updatedAt: "2026-05-30T08:00:45Z",
persistence: "sqlite_acp_runtime_store",
},
];
const capturedRuns = [];
const capturedSessionCreates = [];
const splitDeltaDetailText = "历史回答不应按 delta 拆成多个气泡。";
let caughtError = null;
const screenshots = {};
writeWorkspaceManifest(root, actorId, workspaceId);
fs.writeFileSync(path.join(root, relativePath), ["# History Agent Filter", "", suffix, ""].join("\n"), "utf8");
const browser = await chromium.launch({
headless: true,
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
locale: "zh-CN",
extraHTTPHeaders: {
"x-mnote-actor-id": actorId,
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
try {
await page.route("**/api/user/access-policy**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
controlPlane: "sqlite",
grants: [{
id: `grant_task504_${suffix}`,
userId: actorId,
workspaceId,
rootUri,
rootPath: root,
permission: "write",
recursive: true,
capabilities: ["ai", "markdown_edit"],
source: "user",
status: "active",
}],
}),
});
});
await page.route("**/api/ui/preferences**", async (route) => {
if (route.request().method() === "GET") {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
owner: "mnote-web",
result: {
aiPreferences: {
"ai.common.default_agent_id": "chat_only",
"ai.agent.hermes.profile_id": "shared_deepseek_chat",
"ai.common.context_refs.default_selected": {
current_page: true,
selection: false,
active_editor: false,
file: false,
folder: false,
changed_files: false,
},
},
},
}),
});
return;
}
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, owner: "mnote-web", result: {} }),
});
});
await page.route("**/api/documents/buffer-state**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
result: { dirtyState: "", fileVersion: `task504-${suffix}` },
}),
});
});
await page.route("**/api/hermes/client/gateway/health**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
gateway: { ok: true, status: "mocked" },
profile: { name: "reasonix", modelConfigured: true, apiKeyConfigured: true },
suggestions: [],
}),
});
});
await page.route("**/api/hermes/client/tools**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, tools: [] }),
});
});
await page.route("**/api/hermes/client/profiles**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
active: "mnoteai",
profiles: [{ name: "mnoteai", label: "MNote AI", modelConfigured: true, apiKeyConfigured: true }],
}),
});
});
await page.route("**/api/ai/agent-profiles**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
agentId: "hermes",
profiles: [
{ profileId: "shared_deepseek_chat", kind: "shared", displayName: "DeepSeek Chat", baseProfile: "deepseek-chat", isolatedProfile: "openclaw-deepseek-chat", readonly: true },
{ profileId: "shared_doubao_chat", kind: "shared", displayName: "豆包 Chat", baseProfile: "doubao-chat", isolatedProfile: "openclaw-doubao-chat", readonly: true },
{ profileId: "shared_gemini_chat", kind: "shared", displayName: "Gemini Chat", baseProfile: "gemini-chat", isolatedProfile: "openclaw-gemini-chat", readonly: true },
{ profileId: "shared_lite", kind: "shared", displayName: "Lite", baseProfile: "lite", isolatedProfile: "lite", readonly: true },
],
}),
});
});
await page.route("**/api/hermes/client/skills**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
});
});
await page.route("**/api/hermes/client/capabilities**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, runtime: "mnote", categories: [], archived: [] }),
});
});
await page.route("**/api/hermes/client/sessions**", async (route) => {
const requestUrl = new URL(route.request().url());
const detailMatch = requestUrl.pathname.match(/\/api\/hermes\/client\/sessions\/([^/]+)(?:\/resume)?$/);
if (route.request().method() === "GET") {
if (detailMatch) {
const sessionId = decodeURIComponent(detailMatch[1]);
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
persistence: "sqlite_acp_runtime_store",
sessionStorage: "sqlite_control_plane",
sessionId,
session: {
sessionId,
messages: [],
runs: [{
sessionId,
runId: `run_task504_detail_${suffix}`,
title: "Gemini 问答",
profile: "shared_gemini_chat",
acpRuntime: "hermes",
status: "completed",
payload: {
agentId: "chat_only",
profileId: "shared_gemini_chat",
profile: "shared_gemini_chat",
acpRuntime: "hermes",
message: "历史详情测试",
},
createdAt: "2026-05-30T08:00:00Z",
updatedAt: "2026-05-30T08:02:00Z",
persistence: "sqlite_acp_runtime_store",
}],
},
events: Array.from(splitDeltaDetailText).map((delta, index) => ({
eventId: `evt_task504_detail_${index}`,
sessionId,
runId: `run_task504_detail_${suffix}`,
eventType: "message.delta",
payload: { delta },
createdAt: `2026-05-30T08:01:${String(index).padStart(2, "0")}Z`,
persistence: "sqlite_acp_runtime_store",
})),
}),
});
return;
}
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
persistence: "sqlite_acp_runtime_store",
sessions,
}),
});
return;
}
if (route.request().method() === "POST" && detailMatch && requestUrl.pathname.endsWith("/resume")) {
const sessionId = decodeURIComponent(detailMatch[1]);
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
persistence: "sqlite_acp_runtime_store",
sessionStorage: "sqlite_control_plane",
sessionId,
session: {
sessionId,
messages: [{ role: "assistant", content: splitDeltaDetailText }],
runs: [],
},
events: [],
}),
});
return;
}
capturedSessionCreates.push(JSON.parse(route.request().postData() || "{}"));
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
sessionId: `mnote_task504_new_${suffix}`,
title: "当前页问答",
persistence: "sqlite_acp_runtime_store",
}),
});
});
await page.route("**/api/hermes/client/runs", async (route) => {
const body = JSON.parse(route.request().postData() || "{}");
capturedRuns.push(body);
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
upstream: {
runId: `run_task504_current_page_${suffix}`,
traceId: `trace_task504_current_page_${suffix}`,
},
}),
});
});
await page.route("**/api/hermes/client/events/run_task504_current_page_*", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "text/event-stream; charset=utf-8" },
body: [
"event: message.delta",
"data: {\"text\":\"### TASK504_CURRENT_PAGE_OK\\n- **Markdown 渲染**\"}",
"",
"event: run.completed",
"data: {\"output\":\"### TASK504_CURRENT_PAGE_OK\\n- **Markdown 渲染**\"}",
"",
].join("\n"),
});
});
await ensureAuthenticated(page, context.request);
const response = await page.goto(documentUrl(root, relativePath), {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
});
assert(response && response.status() === 200, `文档页状态码异常: ${response && response.status()}`);
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="wolai-page-ai-drawer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('.wolai-page-ai-header-actions [data-page-ai-action="history"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="history"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(`[data-page-ai-session-row="mnote_task504_gemini_${suffix}"]`).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const historyPanel = page.locator('[data-page-ai-panel="history"]');
const historyText = await historyPanel.innerText({ timeout: UI_TIMEOUT_MS });
assert(historyText.includes("ChatOnly / Gemini"), "历史会话应显示 Gemini 所属 agent");
assert(historyText.includes("ChatOnly / 豆包"), "历史会话应显示豆包所属 agent");
assert(historyText.includes("Hermes / Lite"), "历史会话应显示 Hermes profile");
assert(!historyText.includes("ChatOnly / myHermes"), "ChatOnly 历史不应显示 Hermes profile 标签");
assert(!historyText.includes("FULL_TAIL_SHOULD_NOT_RENDER"), "历史预览不应显示 ChatOnly 完整长消息尾部");
assert.strictEqual(capturedSessionCreates.length, 0, "打开 Page AI 和历史列表不应创建空白后端会话");
const filter = page.locator('[data-page-ai-session-agent-filter]');
await filter.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await filter.selectOption("chat_only:shared_gemini_chat", { timeout: UI_TIMEOUT_MS });
await page.locator(`[data-page-ai-session-row="mnote_task504_gemini_${suffix}"]`).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert.strictEqual(
await page.locator(`[data-page-ai-session-row="mnote_task504_doubao_${suffix}"]`).count(),
0,
"筛选 Gemini 后不应显示豆包会话",
);
await filter.selectOption("chat_only:shared_doubao_chat", { timeout: UI_TIMEOUT_MS });
await page.locator(`[data-page-ai-session-row="mnote_task504_doubao_${suffix}"]`).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert.strictEqual(
await page.locator(`[data-page-ai-session-row="mnote_task504_gemini_${suffix}"]`).count(),
0,
"筛选豆包后不应显示 Gemini 会话",
);
screenshots.history = await saveScreenshot(page, "history-filter");
await page.locator('.wolai-page-ai-header-actions [data-page-ai-action="history"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="chat"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('.wolai-page-ai-header-actions [data-page-ai-action="new-session"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => document.querySelector('[data-page-ai-agent-chip]')?.textContent?.includes("DeepSeek"),
null,
{ timeout: UI_TIMEOUT_MS },
);
await page.evaluate(({ staleWorkspaceId, documentId, rootUri: currentRootUri }) => {
const staleSnapshot = {
schema: "mnote.open_editors_snapshot.v1",
generatedAt: Date.now(),
activeObjectIdentity: "page:primary",
activeEditor: {
objectIdentity: "page:primary",
workspacePath: {
schema: "mnote.workspace_path.v1",
workspaceId: staleWorkspaceId,
sourceKind: "local_folder",
rootUri: currentRootUri,
relativePath: "HistoryAgentFilter.md",
documentId,
objectIdentity: "page:primary",
assetId: "",
resourceKind: "page",
},
paneRole: "primary",
documentId,
workspaceId: staleWorkspaceId,
title: "Stale page target",
kind: "page",
editorKind: "page",
active: true,
dirtyState: "",
preview: false,
pinned: true,
lastActiveAt: Date.now(),
assetId: "",
path: "HistoryAgentFilter.md",
},
editors: [],
resourceEditors: [],
groups: { primary: { paneRole: "primary", activeObjectIdentity: "page:primary", editors: [], resourceEditors: [] }, secondary: { paneRole: "secondary", activeObjectIdentity: "", editors: [], resourceEditors: [] } },
};
staleSnapshot.editors = [staleSnapshot.activeEditor];
staleSnapshot.groups.primary.editors = [staleSnapshot.activeEditor];
window.__mnoteOpenEditorsSnapshot = staleSnapshot;
const previous = window.__mnoteDocumentPaneRuntime || {};
window.__mnoteDocumentPaneRuntime = {
...previous,
getOpenEditorsSnapshot: () => staleSnapshot,
};
}, {
staleWorkspaceId: `${workspaceId}:stale`,
documentId,
rootUri,
});
await page.locator("[data-page-ai-input]").fill(`task504 current page target ${suffix}`, { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="chat"] [data-page-ai-conversation]').getByText("TASK504_CURRENT_PAGE_OK").waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
const markdownState = await page.locator('[data-page-ai-panel="chat"] [data-page-ai-conversation]').evaluate((root) => ({
heading: Boolean(root.querySelector(".wolai-page-ai-message--assistant .wolai-page-ai-message-text h3")),
strong: Boolean(root.querySelector(".wolai-page-ai-message--assistant .wolai-page-ai-message-text strong")),
rawHeadingMarkers: root.textContent?.includes("### TASK504_CURRENT_PAGE_OK") || false,
rawStrongMarkers: root.textContent?.includes("**Markdown 渲染**") || false,
}));
assert.deepStrictEqual(markdownState, {
heading: true,
strong: true,
rawHeadingMarkers: false,
rawStrongMarkers: false,
}, `AI 面板应渲染 Markdown,而不是显示原始标记: ${JSON.stringify(markdownState)}`);
assert.strictEqual(capturedRuns.length, 1, "当前页 ChatOnly 请求应通过前置 target 校验并到达 runs API");
assert.strictEqual(capturedSessionCreates.length, 1, "只有真实发送消息时才应创建后端会话");
assert.strictEqual(capturedRuns[0].agentId, "chat_only", "应使用 ChatOnly agent");
assert.strictEqual(capturedRuns[0].profile, "shared_deepseek_chat", "应使用 DeepSeek ChatOnly profile");
assert.strictEqual(
capturedRuns[0].runTargetSnapshot?.editorTarget?.workspaceId,
workspaceId,
"只选择当前页时 runTargetSnapshot 应使用当前 workspaceId,而不是 stale active editor workspaceId",
);
assert(
capturedRuns[0].contextRefs.some((item) => item.kind === "current_page" && item.workspaceId === workspaceId),
"当前页 contextRef 应保留当前 workspaceId",
);
assert(
!capturedRuns[0].contextRefs.some((item) => item.kind === "active_editor"),
"取消打开资源后不应发送 active_editor contextRef",
);
screenshots.currentPageTarget = await saveScreenshot(page, "current-page-target");
await page.locator('.wolai-page-ai-header-actions [data-page-ai-action="history"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="history"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-session-agent-filter]').selectOption("all", { timeout: UI_TIMEOUT_MS });
await page.locator(`[data-page-ai-session-resume="mnote_task504_gemini_${suffix}"]`).click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="chat"] [data-page-ai-conversation]').getByText(splitDeltaDetailText).waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
const restoredAssistantMessages = await page.locator(".wolai-page-ai-message--assistant").evaluateAll((nodes) => (
nodes.map((node) => node.textContent || "").filter((text) => text.includes("历史回答") || text.includes("不应按"))
));
assert.deepStrictEqual(
restoredAssistantMessages,
[`AI${splitDeltaDetailText}`],
"恢复历史详情时 message.delta 必须合并为一条 assistant 消息,不能按字拆气泡",
);
screenshots.historyRestore = await saveScreenshot(page, "history-restore");
} catch (error) {
caughtError = error;
try {
screenshots.failure = await saveScreenshot(page, "failure");
} catch (_) {}
} finally {
await browser.close();
const result = {
ok: !caughtError,
error: caughtError ? String(caughtError && caughtError.stack || caughtError) : null,
screenshots,
root,
capturedRuns,
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
if (caughtError) {
console.error(JSON.stringify(result, null, 2));
process.exit(1);
}
console.log(JSON.stringify(result, null, 2));
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
/**
* RETIRED (Wave 10 / pre-release legacy purge)
* 依赖已删除的 /api/hermes/client/* OpenCode Page AI host
* 产品 Page AI Pi Lab/api/page-ai/pi/*agent tools /api/mnote/tools/*
* 本文件保留作历史对照直接 exit 0不再执行浏览器/静态断言
*/
console.log(JSON.stringify({
ok: true,
retired: true,
reason: "legacy hermes client / opencode page-ai path deleted; use Pi Lab + /api/mnote/tools",
script: require("node:path").basename(__filename),
}, null, 2));
process.exit(0);
+13 -471
View File
@@ -1,474 +1,16 @@
#!/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_URL,
UI_TIMEOUT_MS,
ensureAuthenticated,
} = require("./tree-shell-smoke-helpers");
const {
setupWorkspaceAccess,
findExternalConversationBinding,
} = require("./lib/control-plane-dev-seed");
const TASK = "task512-chatonly-doubao-sync-smoke";
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const DOUBAO_CDP_URL = process.env.MNOTE_DOUBAO_CDP_URL || "http://127.0.0.1:9233";
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function documentUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
function writeWorkspaceManifest(root, ownerId, workspaceId) {
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(
path.join(root, ".mnote", "workspace.json"),
`${JSON.stringify({
workspaceId,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "ai_sessions", "markdown_edit"],
}, null, 2)}\n`,
"utf8",
);
}
async function firstDoubaoPage(cdpBrowser) {
for (const context of cdpBrowser.contexts()) {
const page = context.pages().find((candidate) => candidate.url().includes("doubao.com"));
if (page) return page;
}
const context = cdpBrowser.contexts()[0] || await cdpBrowser.newContext();
const page = await context.newPage();
await page.goto("https://www.doubao.com/chat/", { waitUntil: "domcontentloaded" });
return page;
}
async function installDoubaoFetchProbe(page) {
await page.evaluate(() => {
const win = window;
const shouldTrack = (url) => url.includes("/samantha/chat/completion")
|| url.includes("/im/conversation/batch_del_user_conv");
if (!win.__mnoteDoubaoOriginalFetch) {
win.__mnoteDoubaoOriginalFetch = win.fetch.bind(win);
}
win.__mnoteDoubaoFetchLog = [];
win.fetch = async function patchedMnoteDoubaoFetch(input, init) {
const url = String((input && input.url) || input || "");
const method = String((init && init.method) || "GET").toUpperCase();
if (shouldTrack(url)) {
win.__mnoteDoubaoFetchLog.push({
transport: "fetch",
url,
method,
body: init && init.body ? String(init.body) : "",
ts: Date.now(),
});
}
return win.__mnoteDoubaoOriginalFetch(input, init);
};
if (!win.__mnoteDoubaoOriginalXHROpen && win.XMLHttpRequest) {
win.__mnoteDoubaoOriginalXHROpen = win.XMLHttpRequest.prototype.open;
win.__mnoteDoubaoOriginalXHRSend = win.XMLHttpRequest.prototype.send;
win.XMLHttpRequest.prototype.open = function patchedMnoteDoubaoXHROpen(method, url, ...rest) {
this.__mnoteDoubaoProbeMethod = String(method || "GET").toUpperCase();
this.__mnoteDoubaoProbeUrl = String(url || "");
return win.__mnoteDoubaoOriginalXHROpen.call(this, method, url, ...rest);
};
win.XMLHttpRequest.prototype.send = function patchedMnoteDoubaoXHRSend(body) {
const url = String(this.__mnoteDoubaoProbeUrl || "");
if (shouldTrack(url)) {
win.__mnoteDoubaoFetchLog.push({
transport: "xhr",
url,
method: String(this.__mnoteDoubaoProbeMethod || "GET").toUpperCase(),
body: body ? String(body) : "",
ts: Date.now(),
});
}
return win.__mnoteDoubaoOriginalXHRSend.call(this, body);
};
}
});
}
async function readDoubaoFetchLog(page) {
return await page.evaluate(() => Array.isArray(window.__mnoteDoubaoFetchLog)
? window.__mnoteDoubaoFetchLog
: []);
}
async function readDoubaoConversationStats(page, { marker, prompt }) {
return await page.evaluate(({ marker, prompt }) => {
const normalize = (value) => String(value || "").replace(/\s+/g, " ").trim();
const markerText = String(marker || "");
const promptText = String(prompt || "");
const userBubbleTexts = Array.from(document.querySelectorAll(".bg-g-send-msg-bubble-bg"))
.map((node) => normalize(node.textContent));
const assistantMarkdownTexts = Array.from(document.querySelectorAll(".md-box-root"))
.map((node) => normalize(node.textContent));
return {
userPromptCount: userBubbleTexts.filter((text) => text === normalize(promptText)).length,
userMarkerCount: userBubbleTexts.filter((text) => text.includes(markerText)).length,
assistantMarkerCount: assistantMarkdownTexts.filter((text) => text.includes(markerText)).length,
userBubbleTexts,
assistantMarkdownTexts,
};
}, { marker, prompt });
}
async function saveScreenshot(page, name) {
const target = path.join(OUTPUT_DIR, `${name}.png`);
await page.screenshot({ path: target, fullPage: false });
return target;
}
function doubaoConversationUrl(remoteConversationId) {
return `https://www.doubao.com/chat/${encodeURIComponent(remoteConversationId)}`;
}
async function captureDoubaoPage(page, { name, url, remoteConversationId = "" }) {
let navigationError = "";
if (url) {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60_000 }).catch((error) => {
navigationError = error instanceof Error ? error.message : String(error);
});
}
await page.bringToFront().catch(() => {});
await page.waitForTimeout(2500);
const screenshot = await saveScreenshot(page, name);
const title = await page.title().catch(() => "");
const pageUrl = page.url();
const visibleText = await page.locator("body").innerText({ timeout: 5000 }).catch(() => "");
const conversationRowCount = remoteConversationId
? await page.locator(`#conversation_${remoteConversationId}`).count().catch(() => -1)
: null;
return {
screenshot,
requestedUrl: url || "",
pageUrl,
title,
navigationError,
conversationRowCount,
visibleText: visibleText.slice(0, 2000),
};
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const suffix = Date.now().toString(36);
const actorId = "mnote-e2e";
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task512-doubao-"));
const rootUri = fileUrl(root);
const workspaceId = `local-ws:${actorId}:task512-${suffix}`;
const relativePath = "DoubaoChatOnlySync.md";
const marker = `MNOTE_DOUBAO_SYNC_${suffix.toUpperCase()}`;
const prompt = `请只回复以下字符串,不要添加空格或其他内容:${marker}`;
const screenshots = {};
const providerCaptures = {};
const runRequests = [];
const deleteResponses = [];
let caughtError = null;
writeWorkspaceManifest(root, actorId, workspaceId);
fs.writeFileSync(path.join(root, relativePath), ["# Doubao ChatOnly Sync", "", marker, ""].join("\n"), "utf8");
const doubaoBrowser = await chromium.connectOverCDP(DOUBAO_CDP_URL);
const doubaoPage = await firstDoubaoPage(doubaoBrowser);
await doubaoPage.goto("https://www.doubao.com/chat/", {
waitUntil: "domcontentloaded",
timeout: 60_000,
}).catch(() => {});
await doubaoPage.keyboard.press("Escape").catch(() => {});
await installDoubaoFetchProbe(doubaoPage);
const browser = await chromium.launch({
headless: true,
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
locale: "zh-CN",
extraHTTPHeaders: {
"x-mnote-actor-id": actorId,
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
try {
await page.route("**/api/hermes/client/runs", async (route) => {
runRequests.push(JSON.parse(route.request().postData() || "{}"));
await route.continue();
});
page.on("response", async (response) => {
const url = response.url();
const request = response.request();
if (request.method() === "DELETE" && url.includes("/api/hermes/client/sessions/")) {
deleteResponses.push({
url,
status: response.status(),
body: await response.text().catch(() => ""),
});
}
});
await ensureAuthenticated(page, context.request);
await setupWorkspaceAccess(context.request, BASE_URL, {
actorId,
workspaceId,
workspaceName: "Task512 Doubao Smoke",
rootPath: root,
rootUri,
capabilities: ["ai", "markdown_edit"],
timeoutMs: UI_TIMEOUT_MS,
});
const response = await page.goto(documentUrl(root, relativePath), {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
});
assert(response && response.status() === 200, `文档页状态码异常: ${response && response.status()}`);
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="wolai-page-ai-drawer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('.wolai-page-ai-header-actions [data-page-ai-action="new-session"]').click({
timeout: UI_TIMEOUT_MS,
});
await page.locator("[data-page-ai-agent-button]").click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-agent-id="chat_only"][data-page-ai-profile-id="shared_doubao_chat"]').click({
timeout: UI_TIMEOUT_MS,
});
await page.waitForFunction(
() => document.querySelector("[data-page-ai-agent-chip]")?.textContent?.includes("ChatOnly / 豆包"),
null,
{ timeout: UI_TIMEOUT_MS },
);
await page.locator("[data-page-ai-input]").fill(prompt, { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
const replyState = await page.waitForFunction(
(expectedMarker) => {
const assistantText = Array.from(document.querySelectorAll(".wolai-page-ai-message--assistant"))
.map((node) => node.textContent || "")
.join("\n");
if (assistantText.includes(expectedMarker)) return "marker";
if (assistantText.includes("豆包暂时无法回复")) return "provider_error";
return "";
},
marker,
{ timeout: 180_000 },
).then((handle) => handle.jsonValue());
await page.waitForFunction(
() => (document.querySelector("[data-page-ai-run-status]")?.textContent || "").includes("完成"),
null,
{ timeout: 180_000 },
).catch(() => {});
screenshots.afterMessage = await saveScreenshot(page, "after-message");
assert.strictEqual(runRequests.length, 1, "MNote 本轮应只创建一个 run");
const run = runRequests[0];
assert.strictEqual(run.agentId, "chat_only", "应使用 ChatOnly agent");
assert.strictEqual(run.profileId, "shared_doubao_chat", "应使用豆包 ChatOnly profile");
assert.strictEqual(run.acpRuntime, "hermes", "豆包 ChatOnly 应走 Hermes/OpenClaw runtime");
assert(run.sessionId, "run payload 应包含 MNote sessionId");
const logAfterMessage = await readDoubaoFetchLog(doubaoPage);
const completionCalls = logAfterMessage.filter((entry) => entry.url.includes("/samantha/chat/completion"));
assert.strictEqual(completionCalls.length, 0, "豆包 ChatOnly 应走真实 UI 发送,不应再直调 samantha completion");
assert.notStrictEqual(replyState, "provider_error", "豆包返回限流/风控错误,未产生本轮 marker 回复");
const visibleAssistantTexts = await page.locator(".wolai-page-ai-message--assistant").allTextContents();
const markerAssistantCount = visibleAssistantTexts.filter((text) => text.includes(marker)).length;
assert.strictEqual(markerAssistantCount, 1, "MNote 可见豆包回复应只有一条");
const sessionId = String(run.sessionId);
const bindingBefore = await findExternalConversationBinding(context.request, BASE_URL, {
userId: actorId,
workspaceId,
mnoteSessionId: sessionId,
provider: "doubao-web",
timeoutMs: UI_TIMEOUT_MS,
});
assert(bindingBefore, "control-plane 应保存豆包远端会话绑定");
assert.strictEqual(bindingBefore.status, "active", "删除前 binding 应为 active");
const remoteConversationId = bindingBefore.remoteConversationId;
assert(remoteConversationId, "binding 应包含 remote_conversation_id");
providerCaptures.afterMessage = await captureDoubaoPage(doubaoPage, {
name: "doubao-after-message",
url: doubaoConversationUrl(remoteConversationId),
remoteConversationId,
});
const doubaoMessageStats = await readDoubaoConversationStats(doubaoPage, { marker, prompt });
screenshots.doubaoAfterMessage = providerCaptures.afterMessage.screenshot;
assert(
providerCaptures.afterMessage.pageUrl.includes(remoteConversationId),
"豆包截图应定位到本轮远端 conversation_id",
);
assert(
providerCaptures.afterMessage.visibleText.includes(marker),
"豆包本轮远端会话页面应显示 marker",
);
assert.strictEqual(
providerCaptures.afterMessage.conversationRowCount,
1,
"豆包删除前左侧历史列表应存在本轮 conversation 行",
);
assert.strictEqual(
doubaoMessageStats.userPromptCount,
1,
"豆包网页端本轮用户消息应只有一条",
);
assert.strictEqual(
doubaoMessageStats.userMarkerCount,
1,
"豆包网页端不应把豆包回复再次作为用户消息发送",
);
assert.strictEqual(
doubaoMessageStats.assistantMarkerCount,
1,
"豆包网页端本轮助手回复应只有一条",
);
await installDoubaoFetchProbe(doubaoPage);
await page.locator('.wolai-page-ai-header-actions [data-page-ai-action="history"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="history"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(`[data-page-ai-session-row="${sessionId}"]`).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
page.once("dialog", async (dialog) => {
await dialog.accept();
});
await page.locator(`[data-page-ai-session-delete="${sessionId}"]`).click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
(id) => !document.querySelector(`[data-page-ai-session-row="${CSS.escape(id)}"]`),
sessionId,
{ timeout: UI_TIMEOUT_MS },
);
screenshots.afterDelete = await saveScreenshot(page, "after-delete");
await page.waitForTimeout(1000);
const logAfterDelete = await readDoubaoFetchLog(doubaoPage);
const deleteCalls = logAfterDelete.filter((entry) => entry.url.includes("/im/conversation/batch_del_user_conv"));
assert.strictEqual(deleteCalls.length, 1, "豆包端本轮只能收到一次会话删除请求");
assert(deleteCalls[0].body.includes(remoteConversationId), "豆包删除请求应包含绑定的远端 conversation_id");
assert(deleteResponses.length >= 1, "MNote 应发出历史会话 DELETE 请求");
assert.strictEqual(deleteResponses.at(-1).status, 200, "MNote 历史会话 DELETE 应成功");
const deleteBody = JSON.parse(deleteResponses.at(-1).body || "{}");
assert.strictEqual(
deleteBody?.result?.providerConversationDelete?.response?.result?.providerDeleteMode,
"doubao_sidebar_menu",
"豆包远端删除必须走左侧会话三点菜单 + 确认弹窗路径",
);
providerCaptures.afterDelete = await captureDoubaoPage(doubaoPage, {
name: "doubao-after-delete",
url: "https://www.doubao.com/chat/",
remoteConversationId,
});
screenshots.doubaoAfterDelete = providerCaptures.afterDelete.screenshot;
assert(
!providerCaptures.afterDelete.visibleText.includes(marker),
"豆包删除后聊天入口不应继续显示本轮 marker",
);
assert.strictEqual(
providerCaptures.afterDelete.conversationRowCount,
0,
"豆包删除后左侧历史列表不应继续存在本轮 conversation 行",
);
const bindingAfter = await findExternalConversationBinding(context.request, BASE_URL, {
userId: actorId,
workspaceId,
mnoteSessionId: sessionId,
provider: "doubao-web",
timeoutMs: UI_TIMEOUT_MS,
});
assert(bindingAfter, "删除后 binding 仍应可审计");
assert.strictEqual(bindingAfter.status, "remote_deleted", "删除后 binding 应标记 remote_deleted");
fs.writeFileSync(
RESULT_PATH,
`${JSON.stringify({
ok: true,
marker,
sessionId,
remoteConversationId,
samanthaCompletionCallCount: completionCalls.length,
doubaoMessageStats,
deleteCallCount: deleteCalls.length,
deleteResponse: deleteResponses.at(-1),
bindingBefore,
bindingAfter,
screenshots,
providerCaptures,
}, null, 2)}\n`,
"utf8",
);
} catch (error) {
caughtError = error;
screenshots.failure = await saveScreenshot(page, "failure").catch(() => "");
providerCaptures.failure = await captureDoubaoPage(doubaoPage, {
name: "doubao-failure",
url: "",
}).catch((captureError) => ({
screenshot: "",
requestedUrl: "",
pageUrl: "",
title: "",
navigationError: captureError instanceof Error ? captureError.message : String(captureError),
visibleText: "",
}));
if (providerCaptures.failure.screenshot) {
screenshots.doubaoFailure = providerCaptures.failure.screenshot;
}
fs.writeFileSync(
RESULT_PATH,
`${JSON.stringify({
ok: false,
marker,
error: error instanceof Error ? error.stack || error.message : String(error),
runRequests,
deleteResponses,
doubaoFetchLog: await readDoubaoFetchLog(doubaoPage).catch(() => []),
screenshots,
providerCaptures,
}, null, 2)}\n`,
"utf8",
);
} finally {
await browser.close().catch(() => {});
await doubaoBrowser.close().catch(() => {});
}
if (caughtError) throw caughtError;
console.log(JSON.stringify(JSON.parse(fs.readFileSync(RESULT_PATH, "utf8")), null, 2));
}
main().catch((error) => {
console.error(error && error.stack ? error.stack : error);
process.exit(1);
});
/**
* RETIRED (Wave 10 / pre-release legacy purge)
* 依赖已删除的 /api/hermes/client/* OpenCode Page AI host
* 产品 Page AI Pi Lab/api/page-ai/pi/*agent tools /api/mnote/tools/*
* 本文件保留作历史对照直接 exit 0不再执行浏览器/静态断言
*/
console.log(JSON.stringify({
ok: true,
retired: true,
reason: "legacy hermes client / opencode page-ai path deleted; use Pi Lab + /api/mnote/tools",
script: require("node:path").basename(__filename),
}, null, 2));
process.exit(0);
+13 -484
View File
@@ -1,487 +1,16 @@
#!/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_URL,
UI_TIMEOUT_MS,
ensureAuthenticated,
} = require("./tree-shell-smoke-helpers");
const {
setupWorkspaceAccess,
findExternalConversationBinding,
} = require("./lib/control-plane-dev-seed");
const PROVIDERS = {
deepseek: {
taskName: "task513-chatonly-deepseek-sync-smoke",
title: "DeepSeek ChatOnly Sync",
agentProfileId: "shared_deepseek_chat",
chipText: "ChatOnly / DeepSeek",
expectedProvider: "deepseek-web",
gatewayLog: "/home/lix/.openclaw-mnote-deepseek-chat/logs/gateway.out",
sendLogPattern: /\[DeepSeekWebClient\] Sending chat completion request/g,
configPath: "/home/lix/.openclaw-mnote-deepseek-chat/openclaw.json",
},
gemini: {
taskName: "task513-chatonly-gemini-sync-smoke",
title: "Gemini ChatOnly Sync",
agentProfileId: "shared_gemini_chat",
chipText: "ChatOnly / Gemini",
expectedProvider: "gemini-web",
gatewayLog: "/home/lix/.openclaw-mnote-gemini-chat/logs/gateway.out",
sendLogPattern: /\[Gemini Web Browser\] DOM: typed message and pressed Enter/g,
cdpUrl: process.env.MNOTE_GEMINI_CDP_URL || "http://127.0.0.1:9232",
},
};
const providerKey = process.argv[2] || process.env.MNOTE_CHATONLY_PROVIDER || "deepseek";
const provider = PROVIDERS[providerKey];
if (!provider) {
throw new Error(`未知 provider: ${providerKey}`);
}
const OUTPUT_DIR = path.join(process.cwd(), "tmp", provider.taskName);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const CHROMIUM_EXECUTABLE_PATH =
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH ||
[
"/usr/bin/google-chrome-stable",
"/usr/bin/google-chrome",
"/snap/bin/chromium",
"/usr/bin/chromium",
].find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function documentUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
function writeWorkspaceManifest(root, ownerId, workspaceId) {
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(
path.join(root, ".mnote", "workspace.json"),
`${JSON.stringify({
workspaceId,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "ai_sessions", "markdown_edit"],
}, null, 2)}\n`,
"utf8",
);
}
function logSize(logPath) {
try {
return fs.statSync(logPath).size;
} catch {
return 0;
}
}
function readLogSince(logPath, offset) {
try {
const fd = fs.openSync(logPath, "r");
const stat = fs.fstatSync(fd);
const start = offset > stat.size ? 0 : offset;
const buffer = Buffer.alloc(stat.size - start);
fs.readSync(fd, buffer, 0, buffer.length, start);
fs.closeSync(fd);
return buffer.toString("utf8");
} catch {
return "";
}
}
function countMatches(text, pattern) {
return Array.from(String(text || "").matchAll(pattern)).length;
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function parseCookieString(cookieString, domain) {
return String(cookieString || "")
.split(";")
.filter((cookie) => cookie.trim().includes("="))
.map((cookie) => {
const [name, ...valueParts] = cookie.trim().split("=");
return {
name: name.trim(),
value: valueParts.join("=").trim(),
domain,
path: "/",
};
})
.filter((cookie) => cookie.name);
}
function deepseekAuth() {
const config = JSON.parse(fs.readFileSync(provider.configPath, "utf8"));
return JSON.parse(config.models.providers["deepseek-web"].apiKey || "{}");
}
async function saveScreenshot(page, name) {
const target = path.join(OUTPUT_DIR, `${name}.png`);
await page.screenshot({ path: target, fullPage: false });
return target;
}
async function captureDeepseek(remoteConversationId, name) {
const auth = deepseekAuth();
const browser = await chromium.launch({
headless: true,
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
locale: "zh-CN",
userAgent: auth.userAgent,
});
await context.addCookies(parseCookieString(auth.cookie, ".deepseek.com"));
if (auth.bearer) {
await context.addInitScript((token) => {
localStorage.setItem("userToken", JSON.stringify({ value: token, __version: "0" }));
}, auth.bearer);
}
const page = await context.newPage();
if (auth.bearer) {
await page.route("https://chat.deepseek.com/api/**", (route) => {
route.continue({
headers: {
...route.request().headers(),
authorization: `Bearer ${auth.bearer}`,
},
});
});
}
const url = `https://chat.deepseek.com/a/chat/s/${remoteConversationId}`;
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60_000 }).catch(() => {});
await page.waitForTimeout(2500);
const screenshot = await saveScreenshot(page, name);
const title = await page.title().catch(() => "");
const visibleText = await page.locator("body").innerText({ timeout: 5000 }).catch(() => "");
const conversationLinkCount = await page
.locator(`a[href*="/a/chat/s/${remoteConversationId}"]`)
.count()
.catch(() => -1);
await browser.close().catch(() => {});
return {
screenshot,
url,
title,
conversationLinkCount,
conversationMissingText: visibleText.includes("该对话不存在"),
visibleText: visibleText.slice(0, 1000),
};
}
async function firstGeminiPage(cdpBrowser, remoteConversationId) {
const targetUrl = String(remoteConversationId || "");
for (const context of cdpBrowser.contexts()) {
const exact = context.pages().find((candidate) => candidate.url().split("#")[0] === targetUrl);
if (exact) return exact;
const gemini = context.pages().find((candidate) => candidate.url().includes("gemini.google.com"));
if (gemini) return gemini;
}
const context = cdpBrowser.contexts()[0] || await cdpBrowser.newContext();
return await context.newPage();
}
async function captureGemini(remoteConversationId, name) {
const browser = await chromium.connectOverCDP(provider.cdpUrl);
const page = await firstGeminiPage(browser, remoteConversationId);
const targetUrl = String(remoteConversationId || "");
if (targetUrl && page.url().split("#")[0] !== targetUrl) {
await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 60_000 }).catch(() => {});
await page.waitForTimeout(1500);
}
await page.bringToFront().catch(() => {});
await page.waitForTimeout(1000);
const screenshot = await saveScreenshot(page, name);
const title = await page.title().catch(() => "");
const visibleText = await page.locator("body").innerText({ timeout: 5000 }).catch(() => "");
const conversationLinkCount = await page.evaluate((target) => {
let targetPath = "";
try {
targetPath = new URL(target).pathname;
} catch {
return -1;
}
return Array.from(document.querySelectorAll("a[href]")).filter((anchor) => {
try {
return new URL(anchor.href, location.href).pathname === targetPath;
} catch {
return false;
}
}).length;
}, targetUrl).catch(() => -1);
await browser.close().catch(() => {});
return {
screenshot,
url: page.url(),
title,
conversationLinkCount,
visibleText: visibleText.slice(0, 1000),
};
}
async function captureProvider(remoteConversationId, name) {
if (providerKey === "deepseek") return await captureDeepseek(remoteConversationId, name);
return await captureGemini(remoteConversationId, name);
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const suffix = Date.now().toString(36);
const actorId = "mnote-e2e";
const root = fs.mkdtempSync(path.join(os.tmpdir(), `mnote-task513-${providerKey}-`));
const rootUri = fileUrl(root);
const workspaceId = `local-ws:${actorId}:task513-${providerKey}-${suffix}`;
const relativePath = `${providerKey}-ChatOnlySync.md`;
const marker = `MNOTE_CHATONLY_SYNC_${suffix}`;
const screenshots = {};
const providerCaptures = {};
const runRequests = [];
const deleteResponses = [];
const gatewayLogOffset = logSize(provider.gatewayLog);
let caughtError = null;
writeWorkspaceManifest(root, actorId, workspaceId);
fs.writeFileSync(path.join(root, relativePath), [`# ${provider.title}`, "", marker, ""].join("\n"), "utf8");
const browser = await chromium.launch({
headless: true,
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
locale: "zh-CN",
extraHTTPHeaders: {
"x-mnote-actor-id": actorId,
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
try {
await page.route("**/api/hermes/client/runs", async (route) => {
runRequests.push(JSON.parse(route.request().postData() || "{}"));
await route.continue();
});
page.on("response", async (response) => {
const url = response.url();
const request = response.request();
if (request.method() === "DELETE" && url.includes("/api/hermes/client/sessions/")) {
deleteResponses.push({
url,
status: response.status(),
body: await response.text().catch(() => ""),
});
}
});
await ensureAuthenticated(page, context.request);
await setupWorkspaceAccess(context.request, BASE_URL, {
actorId,
workspaceId,
workspaceName: actorId,
rootPath: root,
rootUri,
capabilities: ["ai", "markdown_edit"],
timeoutMs: UI_TIMEOUT_MS,
});
const response = await page.goto(documentUrl(root, relativePath), {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
});
assert(response && response.status() === 200, `文档页状态码异常: ${response && response.status()}`);
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="wolai-page-ai-drawer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('.wolai-page-ai-header-actions [data-page-ai-action="new-session"]').click({
timeout: UI_TIMEOUT_MS,
});
await page.locator("[data-page-ai-agent-button]").click({ timeout: UI_TIMEOUT_MS });
await page.locator(`[data-page-ai-agent-id="chat_only"][data-page-ai-profile-id="${provider.agentProfileId}"]`).click({
timeout: UI_TIMEOUT_MS,
});
await page.waitForFunction(
(expected) => document.querySelector("[data-page-ai-agent-chip]")?.textContent?.includes(expected),
provider.chipText,
{ timeout: UI_TIMEOUT_MS },
);
await page.locator("[data-page-ai-input]").fill(`请只回复:${marker}`, { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
(expectedMarker) => {
const assistantText = Array.from(document.querySelectorAll(".wolai-page-ai-message--assistant"))
.map((node) => node.textContent || "")
.join("\n");
return assistantText.includes(expectedMarker);
},
marker,
{ timeout: 180_000 },
);
await page.waitForFunction(
() => (document.querySelector("[data-page-ai-run-status]")?.textContent || "").includes("完成"),
null,
{ timeout: 180_000 },
).catch(() => {});
screenshots.afterMessage = await saveScreenshot(page, "after-message");
assert.strictEqual(runRequests.length, 1, "MNote 本轮应只创建一个 run");
const run = runRequests[0];
assert.strictEqual(run.agentId, "chat_only", "应使用 ChatOnly agent");
assert.strictEqual(run.profileId, provider.agentProfileId, `应使用 ${provider.title} profile`);
assert.strictEqual(run.acpRuntime, "hermes", "ChatOnly 网页 provider 应走 Hermes/OpenClaw runtime");
assert(run.sessionId, "run payload 应包含 MNote sessionId");
const visibleAssistantTexts = await page.locator(".wolai-page-ai-message--assistant").allTextContents();
const markerAssistantCount = visibleAssistantTexts.filter((text) => text.includes(marker)).length;
assert.strictEqual(markerAssistantCount, 1, "MNote 可见 provider 回复应只有一条");
const sessionId = String(run.sessionId);
const bindingBefore = await findExternalConversationBinding(context.request, BASE_URL, {
userId: actorId,
workspaceId,
mnoteSessionId: sessionId,
provider: provider.expectedProvider,
timeoutMs: UI_TIMEOUT_MS,
});
assert(bindingBefore, "control-plane 应保存远端会话绑定");
assert.strictEqual(bindingBefore.status, "active", "删除前 binding 应为 active");
const remoteConversationId = bindingBefore.remoteConversationId;
assert(remoteConversationId, "binding 应包含 remote_conversation_id");
providerCaptures.afterMessage = await captureProvider(remoteConversationId, `${providerKey}-after-message`);
await page.locator('.wolai-page-ai-header-actions [data-page-ai-action="history"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="history"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(`[data-page-ai-session-row="${sessionId}"]`).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
page.once("dialog", async (dialog) => {
await dialog.accept();
});
await page.locator(`[data-page-ai-session-delete="${sessionId}"]`).click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
(id) => !document.querySelector(`[data-page-ai-session-row="${CSS.escape(id)}"]`),
sessionId,
{ timeout: UI_TIMEOUT_MS },
);
screenshots.afterDelete = await saveScreenshot(page, "after-delete");
assert(deleteResponses.length >= 1, "MNote 应发出历史会话 DELETE 请求");
assert.strictEqual(deleteResponses.at(-1).status, 200, "MNote 历史会话 DELETE 应成功");
const deleteBody = JSON.parse(deleteResponses.at(-1).body || "{}");
assert.strictEqual(
deleteBody?.result?.providerConversationDelete?.status,
"remote_deleted",
"provider 删除应返回 remote_deleted",
);
const providerDeleteMode = deleteBody?.result?.providerConversationDelete?.response?.result?.providerDeleteMode;
if (providerKey === "deepseek") {
assert.strictEqual(
providerDeleteMode,
"deepseek_chat_session_delete_api",
"DeepSeek 删除应明确走 chat_session_delete_api 并由网页复核",
);
} else {
assert.strictEqual(
providerDeleteMode,
"gemini_conversation_menu",
"Gemini 删除应走网页对话菜单确认路径",
);
}
await page.waitForTimeout(1500);
providerCaptures.afterDelete = await captureProvider(remoteConversationId, `${providerKey}-after-delete`);
assert.strictEqual(
providerCaptures.afterDelete.conversationLinkCount,
0,
"provider 删除后网页历史中不应继续存在本轮远端会话链接",
);
const bindingAfter = await findExternalConversationBinding(context.request, BASE_URL, {
userId: actorId,
workspaceId,
mnoteSessionId: sessionId,
provider: provider.expectedProvider,
timeoutMs: UI_TIMEOUT_MS,
});
assert(bindingAfter, "删除后 binding 仍应可审计");
assert.strictEqual(bindingAfter.status, "remote_deleted", "删除后 binding 应标记 remote_deleted");
const providerLog = readLogSince(provider.gatewayLog, gatewayLogOffset);
let providerSendCount = countMatches(providerLog, provider.sendLogPattern);
if (providerKey === "gemini" && providerSendCount === 0) {
const fullProviderLog = readLogSince(provider.gatewayLog, 0);
providerSendCount = countMatches(fullProviderLog, new RegExp(escapeRegExp(marker), "g"));
}
assert.strictEqual(providerSendCount, 1, "provider 本轮只能收到一次发送动作");
fs.writeFileSync(
RESULT_PATH,
`${JSON.stringify({
ok: true,
provider: providerKey,
marker,
sessionId,
remoteConversationId,
providerSendCount,
deleteResponse: deleteResponses.at(-1),
bindingBefore,
bindingAfter,
screenshots,
providerCaptures,
}, null, 2)}\n`,
"utf8",
);
} catch (error) {
caughtError = error;
screenshots.failure = await saveScreenshot(page, "failure").catch(() => "");
fs.writeFileSync(
RESULT_PATH,
`${JSON.stringify({
ok: false,
provider: providerKey,
marker,
error: error instanceof Error ? error.stack || error.message : String(error),
runRequests,
deleteResponses,
screenshots,
gatewayLog: readLogSince(provider.gatewayLog, gatewayLogOffset).slice(-8000),
}, null, 2)}\n`,
"utf8",
);
} finally {
await browser.close().catch(() => {});
}
if (caughtError) throw caughtError;
console.log(JSON.stringify(JSON.parse(fs.readFileSync(RESULT_PATH, "utf8")), null, 2));
}
main().catch((error) => {
console.error(error && error.stack ? error.stack : error);
process.exit(1);
});
/**
* RETIRED (Wave 10 / pre-release legacy purge)
* 依赖已删除的 /api/hermes/client/* OpenCode Page AI host
* 产品 Page AI Pi Lab/api/page-ai/pi/*agent tools /api/mnote/tools/*
* 本文件保留作历史对照直接 exit 0不再执行浏览器/静态断言
*/
console.log(JSON.stringify({
ok: true,
retired: true,
reason: "legacy hermes client / opencode page-ai path deleted; use Pi Lab + /api/mnote/tools",
script: require("node:path").basename(__filename),
}, null, 2));
process.exit(0);
@@ -63,7 +63,7 @@ async function main() {
assert.equal(register.status, 200, JSON.stringify(register));
assert.equal(register.payload.sessionId, bridgeSessionId);
const implicit = await requestJson("/api/hermes/tools/mnote/call", {
const implicit = await requestJson("/api/mnote/tools/call", {
method: "POST",
body: JSON.stringify(callPayload("sess_http_implicit", {
address: "A1",
@@ -73,7 +73,7 @@ async function main() {
assert.equal(implicit.status, 400, JSON.stringify(implicit));
assert.equal(implicit.headers["x-error-code"], "mnote_onlyoffice_session_explicit_required");
const forbidden = await requestJson("/api/hermes/tools/mnote/call", {
const forbidden = await requestJson("/api/mnote/tools/call", {
method: "POST",
body: JSON.stringify(callPayload("sess_http_forbidden", {
onlyofficeSessionId: bridgeSessionId,
@@ -88,7 +88,7 @@ async function main() {
assert.equal(forbidden.status, 403, JSON.stringify(forbidden));
assert.equal(forbidden.headers["x-error-code"], "mnote_onlyoffice_resource_scope_forbidden");
const missingScope = await requestJson("/api/hermes/tools/mnote/call", {
const missingScope = await requestJson("/api/mnote/tools/call", {
method: "POST",
body: JSON.stringify(callPayload("sess_http_missing_scope", {
onlyofficeSessionId: bridgeSessionId,
@@ -99,7 +99,7 @@ async function main() {
assert.equal(missingScope.status, 403, JSON.stringify(missingScope));
assert.equal(missingScope.headers["x-error-code"], "mnote_onlyoffice_resource_scope_required");
const allowed = await requestJson("/api/hermes/tools/mnote/call", {
const allowed = await requestJson("/api/mnote/tools/call", {
method: "POST",
body: JSON.stringify(callPayload("sess_http_allowed", {
onlyofficeSessionId: bridgeSessionId,
@@ -117,7 +117,7 @@ async function main() {
assert.equal(allowed.payload.result.action, "sheet.set_value");
assert.equal(allowed.payload.audit.effect, "dry_run");
const currentImplicit = await requestJson("/api/hermes/tools/mnote/call", {
const currentImplicit = await requestJson("/api/mnote/tools/call", {
method: "POST",
body: JSON.stringify(callPayload("sess_http_current_implicit", {
aiAccessScope: {
@@ -129,7 +129,7 @@ async function main() {
assert.equal(currentImplicit.status, 400, JSON.stringify(currentImplicit));
assert.equal(currentImplicit.headers["x-error-code"], "mnote_onlyoffice_session_explicit_required");
const currentForbidden = await requestJson("/api/hermes/tools/mnote/call", {
const currentForbidden = await requestJson("/api/mnote/tools/call", {
method: "POST",
body: JSON.stringify(callPayload("sess_http_current_forbidden", {
onlyofficeSessionId: bridgeSessionId,
@@ -142,7 +142,7 @@ async function main() {
assert.equal(currentForbidden.status, 403, JSON.stringify(currentForbidden));
assert.equal(currentForbidden.headers["x-error-code"], "mnote_onlyoffice_resource_scope_forbidden");
const currentAllowed = await requestJson("/api/hermes/tools/mnote/call", {
const currentAllowed = await requestJson("/api/mnote/tools/call", {
method: "POST",
body: JSON.stringify(callPayload("sess_http_current_allowed", {
onlyofficeSessionId: bridgeSessionId,
@@ -140,7 +140,7 @@ async function runBridgeExport(page, format = "html") {
}
async function postToolCall(context, payload) {
const response = await context.request.fetch(`${BASE_URL}/api/hermes/tools/mnote/call`, {
const response = await context.request.fetch(`${BASE_URL}/api/mnote/tools/call`, {
method: "POST",
headers: {
"content-type": "application/json",
@@ -1,318 +1,16 @@
#!/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_URL,
UI_TIMEOUT_MS,
ensureAuthenticated,
} = require("./tree-shell-smoke-helpers");
const TASK = "task520-page-ai-raw-resource-target-smoke";
const OUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUT_DIR, "result.json");
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function writeWorkspaceManifest(root, ownerId, workspaceId) {
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(
path.join(root, ".mnote", "workspace.json"),
`${JSON.stringify({
workspaceId,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "ai_sessions", "markdown_edit"],
}, null, 2)}\n`,
"utf8",
);
}
function documentUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
async function quickLogin(page, request) {
await ensureAuthenticated(page, request);
}
async function waitFiletreeRow(page, relativePath) {
return await page.waitForFunction(
(expectedRelativePath) => Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-shell-mode=\"filetree\"]"))
.some((row) => row.getAttribute("data-local-relative-path") === expectedRelativePath),
relativePath,
{ timeout: UI_TIMEOUT_MS },
);
}
async function clickFiletreeOpen(page, relativePath) {
const handle = await page.waitForFunction(
(expectedRelativePath) => {
const row = Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-shell-mode=\"filetree\"]"))
.find((candidate) => candidate.getAttribute("data-local-relative-path") === expectedRelativePath);
return row?.querySelector('[data-rust-action="open"], .tree-link') || null;
},
relativePath,
{ timeout: UI_TIMEOUT_MS },
);
await handle.asElement().click();
}
async function expandFiletreeFolder(page, relativePath) {
await waitFiletreeRow(page, relativePath);
const expanded = await page.evaluate((expectedRelativePath) => {
const row = Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-shell-mode=\"filetree\"]"))
.find((candidate) => candidate.getAttribute("data-local-relative-path") === expectedRelativePath);
return row?.getAttribute("aria-expanded") === "true";
}, relativePath);
if (!expanded) {
await clickFiletreeOpen(page, relativePath);
}
await page.waitForFunction(
(expectedRelativePath) => {
const row = Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-shell-mode=\"filetree\"]"))
.find((candidate) => candidate.getAttribute("data-local-relative-path") === expectedRelativePath);
return row?.getAttribute("aria-expanded") === "true";
},
relativePath,
{ timeout: UI_TIMEOUT_MS },
);
}
async function saveScreenshot(page, name) {
const target = path.join(OUT_DIR, `${name}.png`);
await page.screenshot({ path: target, fullPage: false });
return target;
}
function cleanupRoot(root) {
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
fs.rmSync(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
return;
} catch (error) {
if (attempt === 4) throw error;
}
}
}
async function main() {
fs.mkdirSync(OUT_DIR, { recursive: true });
const actorId = "mnote-e2e";
const workspaceId = `local-ws:${actorId}:task520`;
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task520-raw-target-"));
const rootUri = fileUrl(root);
const pagePath = "Page.md";
const rawPath = "Page/notes.txt";
const documentId = localMdDocumentId(pagePath);
const captured = [];
let caughtError = null;
fs.mkdirSync(path.join(root, "Page"), { recursive: true });
writeWorkspaceManifest(root, actorId, workspaceId);
fs.writeFileSync(path.join(root, pagePath), "# Page\n\nTask520 page\n", "utf8");
fs.writeFileSync(path.join(root, rawPath), "Task520 raw resource target\n", "utf8");
const browser = await chromium.launch({
headless: process.env.HEADFUL !== "1",
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
locale: "zh-CN",
extraHTTPHeaders: {
"x-mnote-actor-id": actorId,
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
try {
await page.route("**/api/user/access-policy**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
grants: [{
id: "grant_task520",
userId: actorId,
workspaceId,
rootUri,
rootPath: root,
permission: "write",
recursive: true,
capabilities: ["ai", "markdown_edit"],
source: "user",
status: "active",
}],
}),
});
});
await page.route("**/api/ui/preferences**", async (route) => {
captured.push({ kind: "ui-preferences", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, owner: "mnote-web", result: {} }),
});
});
await page.route("**/api/hermes/client/gateway/health**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, gateway: { ok: true }, profile: { name: "reasonix", modelConfigured: true, apiKeyConfigured: true } }),
});
});
await page.route("**/api/hermes/client/tools**", async (route) => {
await route.fulfill({ status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true, tools: [] }) });
});
await page.route("**/api/ai/agent-profiles**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, agentId: "reasonix", profiles: [] }),
});
});
await page.route("**/api/hermes/client/profiles**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, active: "mnoteai", profiles: [{ name: "mnoteai", label: "MNote AI", modelConfigured: true, apiKeyConfigured: true }] }),
});
});
await page.route("**/api/hermes/client/skills**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
});
});
await page.route("**/api/hermes/client/capabilities**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, runtime: "mnote", categories: [], archived: [] }),
});
});
await page.route("**/api/hermes/client/sessions", async (route) => {
captured.push({ kind: "session", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, sessionId: "mnote_task520", title: "task520", traceId: "trace_task520" }),
});
});
await page.route("**/api/hermes/client/runs", async (route) => {
captured.push({ kind: "run", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, sessionId: "mnote_task520", runId: "run_task520", events: [], traceId: "trace_run_task520" }),
});
});
await page.route("**/api/hermes/client/events/*", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "text/event-stream; charset=utf-8" },
body: `data: ${JSON.stringify({ event: "message.delta", run_id: "run_task520", delta: "Task520 response" })}\n\n`
+ `data: ${JSON.stringify({ event: "run.completed", run_id: "run_task520", output: "Task520 response" })}\n\n`,
});
});
await quickLogin(page, context.request);
const response = await page.goto(documentUrl(root, pagePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
assert(response && response.status() === 200, `文档页状态码异常: ${response && response.status()}`);
await page.locator(".document-pane[data-pane-role=\"primary\"] .editor-surface .ProseMirror").first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await expandFiletreeFolder(page, "Page");
await waitFiletreeRow(page, rawPath);
await clickFiletreeOpen(page, rawPath);
await page.waitForFunction(
(expectedPath) => {
const snapshot = window.__mnoteDocumentPaneRuntime?.getOpenEditorsSnapshot?.() || window.__mnoteOpenEditorsSnapshot || null;
return (snapshot?.resourceEditors || []).some((entry) => entry?.path === expectedPath && entry?.active === true);
},
rawPath,
{ timeout: UI_TIMEOUT_MS },
);
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="wolai-page-ai-drawer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const targetChip = page.locator("[data-page-ai-target-chip]");
await targetChip.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const chipText = (await targetChip.innerText({ timeout: UI_TIMEOUT_MS })).trim();
const expectedName = expectOcrContext ? "photo.png" : "notes.txt";
assert(chipText.includes(expectedName), `raw resource 打开后 target chip 应指向 ${expectedName}: ${chipText}`);
await page.locator("[data-page-ai-input]").fill("Task520 raw target", { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("Task520 response"),
null,
{ timeout: UI_TIMEOUT_MS },
);
const runs = captured.filter((item) => item.kind === "run");
assert(runs.length >= 1, "未捕获 Page AI run payload");
const runBody = JSON.parse(runs[runs.length - 1].body || "{}");
const activeEditorRef = runBody.contextRefs?.find((item) => item.kind === "active_editor");
assert(activeEditorRef, `run payload 应包含 active_editor contextRef: ${JSON.stringify(runBody.contextRefs)}`);
assert.strictEqual(activeEditorRef.relativePath, rawPath, `active_editor 应指向 raw resource: ${JSON.stringify(activeEditorRef)}`);
assert.strictEqual(activeEditorRef.resourceKind, "attachment", `raw resource contextRef 应保留 attachment resourceKind: ${JSON.stringify(activeEditorRef)}`);
assert.strictEqual(activeEditorRef.objectIdentity, "resource:file:" + rootUri + ":" + rawPath, `active_editor objectIdentity 不应退化为 [object Object]: ${JSON.stringify(activeEditorRef)}`);
assert.strictEqual(runBody.targetPackage?.schema, "mnote.agent_target_package.v1", "run payload 应携带 targetPackage");
assert.strictEqual(runBody.targetPackage?.primaryTargetId, "resource:file:" + rootUri + ":" + rawPath, `targetPackage 应冻结 raw resource objectIdentity: ${JSON.stringify(runBody.targetPackage)}`);
assert.strictEqual(runBody.targetPackage?.objectIdentity, "resource:file:" + rootUri + ":" + rawPath, `targetPackage objectIdentity 不应退化为 [object Object]: ${JSON.stringify(runBody.targetPackage)}`);
assert.strictEqual(runBody.targetPackage?.resourceKind, "attachment", `targetPackage 应保留 raw resource kind: ${JSON.stringify(runBody.targetPackage)}`);
assert.strictEqual(runBody.targetPackage?.currentFile?.relativePath, rawPath, `targetPackage currentFile 应指向 raw resource: ${JSON.stringify(runBody.targetPackage)}`);
assert.strictEqual(runBody.targetPackage?.currentFile?.objectIdentity, "resource:file:" + rootUri + ":" + rawPath, `currentFile objectIdentity 不应退化为 [object Object]: ${JSON.stringify(runBody.targetPackage?.currentFile)}`);
assert(runBody.targetPackage?.allowedFiles?.includes(rawPath), `allowedFiles 应只包含 raw resource: ${JSON.stringify(runBody.targetPackage?.allowedFiles)}`);
assert.strictEqual(activeEditorRef.ocrContext, undefined, `OCR sidecar context 已退役,active_editor 不应携带 ocrContext: ${JSON.stringify(activeEditorRef)}`);
assert.strictEqual(runBody.targetPackage?.ocrContext, undefined, `OCR sidecar context 已退役,targetPackage 不应携带 ocrContext: ${JSON.stringify(runBody.targetPackage)}`);
assert.strictEqual(runBody.targetPackage?.currentFile?.ocrRootRelativePath, undefined, `OCR sidecar path 已退役,currentFile 不应携带 ocrRootRelativePath: ${JSON.stringify(runBody.targetPackage?.currentFile)}`);
const screenshot = await saveScreenshot(page, "01-raw-resource-target");
const result = { ok: true, task: TASK, baseUrl: BASE_URL, root, rootUri, documentId, rawPath, screenshot, captured };
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} catch (error) {
caughtError = error;
await saveScreenshot(page, "failure").catch(() => undefined);
} finally {
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
cleanupRoot(root);
}
if (caughtError) {
throw caughtError;
}
}
if (require.main === module) {
main().catch((error) => {
console.error(error);
process.exit(1);
});
}
/**
* RETIRED (Wave 10 / pre-release legacy purge)
* 依赖已删除的 /api/hermes/client/* OpenCode Page AI host
* 产品 Page AI Pi Lab/api/page-ai/pi/*agent tools /api/mnote/tools/*
* 本文件保留作历史对照直接 exit 0不再执行浏览器/静态断言
*/
console.log(JSON.stringify({
ok: true,
retired: true,
reason: "legacy hermes client / opencode page-ai path deleted; use Pi Lab + /api/mnote/tools",
script: require("node:path").basename(__filename),
}, null, 2));
process.exit(0);
@@ -1,423 +1,16 @@
#!/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_URL,
UI_TIMEOUT_MS,
ensureAuthenticated,
} = require("./tree-shell-smoke-helpers");
const TASK = "task523-page-ai-onlyoffice-real-target-session-smoke";
const OUT_DIR = path.join(process.cwd(), "tmp", TASK);
const SCREENSHOT_DIR = path.join(OUT_DIR, "screenshots");
const RESULT_PATH = path.join(OUT_DIR, "result.json");
const PROBE_DOCX_PATH = process.env.MNOTE_ONLYOFFICE_PROBE_DOCX
|| "/tmp/mnote-onlyoffice-manual/mnote-onlyoffice-upload-test.docx";
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function writeWorkspaceManifest(root, ownerId, workspaceId) {
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(
path.join(root, ".mnote", "workspace.json"),
`${JSON.stringify({
workspaceId,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "ai_sessions", "markdown_edit"],
}, null, 2)}\n`,
"utf8",
);
}
function documentUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
function localOfficeFileUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/api/local-folder/files/open`);
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("path", relativePath);
return url.toString();
}
function onlyofficeUrl(root, pageRelativePath, officeRelativePath, assetId) {
const url = new URL(`${BASE_URL}/onlyoffice`);
url.searchParams.set("fileUrl", localOfficeFileUrl(root, officeRelativePath));
url.searchParams.set("fileName", path.basename(officeRelativePath));
url.searchParams.set("fileType", "docx");
url.searchParams.set("assetId", assetId);
url.searchParams.set("documentId", localMdDocumentId(pageRelativePath));
url.searchParams.set("mode", "edit");
return url.toString();
}
async function saveScreenshot(page, name) {
const target = path.join(SCREENSHOT_DIR, `${name}.png`);
await page.screenshot({ path: target, fullPage: true });
return target;
}
async function waitForOfficeSnapshot(page, objectIdentity) {
return await page.waitForFunction(
(targetId) => {
const runtime = window.__mnoteDocumentPaneRuntime;
if (!runtime || typeof runtime.getOpenEditorsSnapshot !== "function") return null;
const snapshot = runtime.getOpenEditorsSnapshot();
const resources = Array.isArray(snapshot && snapshot.resourceEditors) ? snapshot.resourceEditors : [];
const entry = resources.find((item) => item && item.objectIdentity === targetId);
if (!entry || !entry.bridgeSessionReady || !(entry.onlyofficeSessionId || entry.bridgeSessionId)) return null;
return entry;
},
objectIdentity,
{ timeout: UI_TIMEOUT_MS },
);
}
async function main() {
assert(fs.existsSync(PROBE_DOCX_PATH), `缺少探测 docx: ${PROBE_DOCX_PATH}`);
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
const suffix = Date.now().toString(36);
const actorId = "mnote-e2e";
const workspaceId = `local-ws:${actorId}:task523`;
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task523-onlyoffice-target-"));
const rootUri = fileUrl(root);
const pageRelativePath = "Page/Page.md";
const officeRelativePath = "Page/office-a.docx";
const documentId = localMdDocumentId(pageRelativePath);
const assetId = `local-file:${officeRelativePath}`;
const objectIdentity = `resource:office:${documentId}:${assetId}`;
const captured = [];
const consoleErrors = [];
const networkFailures = [];
const httpErrors = [];
const result = {
ok: false,
task: TASK,
baseUrl: BASE_URL,
root,
documentId,
officeRelativePath,
objectIdentity,
screenshots: [],
consoleErrors,
networkFailures,
httpErrors,
};
fs.mkdirSync(path.join(root, "Page"), { recursive: true });
writeWorkspaceManifest(root, actorId, workspaceId);
fs.writeFileSync(
path.join(root, pageRelativePath),
["# Page", "", `Task523 ${suffix}`, ""].join("\n"),
"utf8",
);
fs.copyFileSync(PROBE_DOCX_PATH, path.join(root, officeRelativePath));
const browser = await chromium.launch({
headless: process.env.HEADFUL !== "1",
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
locale: "zh-CN",
extraHTTPHeaders: {
"x-mnote-actor-id": actorId,
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
page.on("console", (message) => {
if (message.type() === "error") consoleErrors.push(message.text());
});
page.on("requestfailed", (request) => {
networkFailures.push({ url: request.url(), failure: request.failure()?.errorText || "" });
});
page.on("response", (response) => {
if (response.status() >= 400) {
httpErrors.push({ url: response.url(), status: response.status() });
}
});
try {
await page.route("**/api/user/access-policy**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
controlPlane: "sqlite",
grants: [{
id: `grant_task523_${suffix}`,
userId: actorId,
workspaceId,
rootUri,
rootPath: root,
permission: "write",
recursive: true,
capabilities: ["ai", "markdown_edit", "office.write"],
source: "user",
status: "active",
}],
}),
});
});
await page.route("**/api/ui/preferences**", async (route) => {
captured.push({ kind: "ui-preferences", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, owner: "mnote-web", result: {} }),
});
});
await page.route("**/api/hermes/client/gateway/health**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
gateway: { ok: true, status: "mocked" },
profile: { name: "reasonix", modelConfigured: true, apiKeyConfigured: true },
suggestions: [],
}),
});
});
await page.route("**/api/hermes/client/tools**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, tools: [] }),
});
});
await page.route("**/api/ai/agent-profiles**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
agentId: "reasonix",
profiles: [
{ profileId: "shared_doubao_chat", kind: "shared", displayName: "豆包 Chat", baseProfile: "doubao-chat", isolatedProfile: "openclaw-doubao-chat", canRun: true, canManageSkills: false, canManageConfig: false, readonly: true },
{ profileId: "usr_task523_default", kind: "personal", displayName: "我的 Hermes", baseProfile: "default", isolatedProfile: "mnote-u-task523-default", canRun: true, canManageSkills: true, canManageConfig: true },
],
}),
});
});
await page.route("**/api/hermes/client/profiles", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
active: "mnoteai",
profiles: [{ name: "mnoteai", label: "MNote AI", modelConfigured: true, apiKeyConfigured: true }],
}),
});
});
await page.route("**/api/hermes/client/skills**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
});
});
await page.route("**/api/hermes/client/capabilities**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, runtime: "mnote", categories: [], archived: [] }),
});
});
await page.route("**/api/hermes/client/sessions", async (route) => {
captured.push({ kind: "session", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
sessionId: `mnote_task523_${suffix}`,
title: "task523",
traceId: `trace_task523_session_${suffix}`,
persistence: "local_ai_session_jsonl",
sessionStorage: "local_private",
}),
});
});
await page.route("**/api/hermes/client/runs", async (route) => {
captured.push({ kind: "run", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
sessionId: `mnote_task523_${suffix}`,
runId: `run_task523_${suffix}`,
events: [],
traceId: `trace_task523_run_${suffix}`,
}),
});
});
await page.route("**/api/hermes/client/events/*", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "text/event-stream; charset=utf-8" },
body:
`data: ${JSON.stringify({ event: "message.delta", run_id: `run_task523_${suffix}`, delta: "Task523 response" })}\n\n` +
`data: ${JSON.stringify({ event: "run.completed", run_id: `run_task523_${suffix}`, output: "Task523 response" })}\n\n`,
});
});
await ensureAuthenticated(page, context.request);
const response = await page.goto(documentUrl(root, pageRelativePath), {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
});
assert(response && response.status() === 200, `文档页状态码异常: ${response && response.status()}`);
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
const officeHref = onlyofficeUrl(root, pageRelativePath, officeRelativePath, assetId);
const openResult = await page.evaluate(async ({ objectIdentity, assetId, documentId, workspaceId, rootUri, officeRelativePath, officeHref }) => {
const runtime = window.__mnoteDocumentPaneRuntime;
if (!runtime || typeof runtime.openResourceInActiveTab !== "function") {
throw new Error("缺少 openResourceInActiveTab runtime");
}
return await runtime.openResourceInActiveTab({
objectIdentity,
assetId,
title: "Task523 Office",
fileName: "office-a.docx",
kind: "office",
editorKind: "office",
href: officeHref,
officeUrl: officeHref,
documentId,
workspaceId,
rootUri,
sourceKind: "local_folder",
path: officeRelativePath,
workspacePath: {
schema: "mnote.workspace_path.v1",
workspaceId,
sourceKind: "local_folder",
rootUri,
relativePath: officeRelativePath,
documentId,
objectIdentity,
assetId,
resourceKind: "only_office",
},
paneRole: "primary",
});
}, { objectIdentity, assetId, documentId, workspaceId, rootUri, officeRelativePath, officeHref });
assert.strictEqual(openResult, true, "openResourceInActiveTab 应成功打开 Office resource tab");
const officeHandle = await waitForOfficeSnapshot(page, objectIdentity);
const officeSnapshot = await officeHandle.jsonValue();
const onlyofficeSessionId = String(officeSnapshot.onlyofficeSessionId || officeSnapshot.bridgeSessionId || "").trim();
assert(onlyofficeSessionId, `Office snapshot 必须携带 bridge session: ${JSON.stringify(officeSnapshot)}`);
assert.strictEqual(officeSnapshot.bridgeSessionReady, true, "Office snapshot 应标记 bridgeSessionReady");
assert.strictEqual(officeSnapshot.assetId, assetId, `Office snapshot assetId 不匹配: ${JSON.stringify(officeSnapshot)}`);
assert.strictEqual(officeSnapshot.bridgeAssetId, assetId, `Office iframe debug assetId 应透传到 snapshot: ${JSON.stringify(officeSnapshot)}`);
result.officeSnapshot = officeSnapshot;
result.screenshots.push(await saveScreenshot(page, "00-office-resource-tab-ready"));
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="wolai-page-ai-drawer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const targetButton = page.locator("[data-page-ai-target-button]");
await targetButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await targetButton.click({ timeout: UI_TIMEOUT_MS });
const targetPopover = page.locator("[data-page-ai-target-popover]");
await targetPopover.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await targetPopover.locator(`[data-page-ai-target-option="${objectIdentity}"]`).click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => (document.querySelector("[data-page-ai-target-chip]")?.textContent || "").includes("Task523 Office"),
null,
{ timeout: UI_TIMEOUT_MS },
);
result.screenshots.push(await saveScreenshot(page, "01-page-ai-office-target-selected"));
await page.locator("[data-page-ai-input]").fill(`Task523 OnlyOffice target ${suffix}`, { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("Task523 response"),
null,
{ timeout: UI_TIMEOUT_MS },
);
const runPayloads = captured.filter((item) => item.kind === "run");
assert(runPayloads.length >= 1, "未捕获 Page AI run payload");
const runBody = JSON.parse(runPayloads[runPayloads.length - 1].body);
const targetPackage = runBody.targetPackage || {};
const packageTarget = Array.isArray(targetPackage.targets)
? targetPackage.targets.find((target) => target.targetId === objectIdentity)
: null;
assert.strictEqual(runBody.editorTarget?.targetId, objectIdentity, `editorTarget 应冻结 Office target: ${JSON.stringify(runBody.editorTarget)}`);
assert.strictEqual(runBody.editorTarget?.resourceKind, "only_office", `editorTarget 应归一为 only_office: ${JSON.stringify(runBody.editorTarget)}`);
assert.strictEqual(runBody.editorTarget?.onlyofficeSessionId, onlyofficeSessionId, `editorTarget 应携带 iframe live session: ${JSON.stringify(runBody.editorTarget)}`);
assert.strictEqual(targetPackage.schema, "mnote.agent_target_package.v1", "targetPackage schema 不匹配");
assert.strictEqual(targetPackage.primaryTargetId, objectIdentity, `targetPackage 应冻结 Office target: ${JSON.stringify(targetPackage)}`);
assert.strictEqual(targetPackage.onlyofficeSessionId, onlyofficeSessionId, `targetPackage 顶层应携带 live session: ${JSON.stringify(targetPackage)}`);
assert(packageTarget, `targetPackage.targets 应包含 Office target: ${JSON.stringify(targetPackage)}`);
assert.strictEqual(packageTarget.resourceKind, "only_office", `targetPackage target 应归一为 only_office: ${JSON.stringify(packageTarget)}`);
assert.strictEqual(packageTarget.relativePath, officeRelativePath, `targetPackage target 应携带 Office 相对路径: ${JSON.stringify(packageTarget)}`);
assert.strictEqual(packageTarget.assetId, assetId, `targetPackage target 应携带 assetId: ${JSON.stringify(packageTarget)}`);
assert.strictEqual(packageTarget.onlyofficeSessionId, onlyofficeSessionId, `targetPackage target 应携带 live session: ${JSON.stringify(packageTarget)}`);
assert(
Array.isArray(targetPackage.allowedFiles) && targetPackage.allowedFiles.includes(officeRelativePath),
`targetPackage.allowedFiles 应只按选中 Office target 授权: ${JSON.stringify(targetPackage)}`,
);
assert.strictEqual(
httpErrors.filter((item) => item.url.includes("/api/documents/buffer-state")).length,
0,
`Office target 不应触发 Markdown buffer-state 查询: ${JSON.stringify(httpErrors)}`,
);
Object.assign(result, {
ok: true,
onlyofficeSessionId,
runBody: {
agentId: runBody.agentId,
editorTarget: runBody.editorTarget,
targetPackage,
},
});
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} catch (error) {
result.error = error && error.stack ? error.stack : String(error);
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
throw error;
} finally {
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
}
main().catch((error) => {
console.error(error && error.stack ? error.stack : error);
process.exit(1);
});
/**
* RETIRED (Wave 10 / pre-release legacy purge)
* 依赖已删除的 /api/hermes/client/* OpenCode Page AI host
* 产品 Page AI Pi Lab/api/page-ai/pi/*agent tools /api/mnote/tools/*
* 本文件保留作历史对照直接 exit 0不再执行浏览器/静态断言
*/
console.log(JSON.stringify({
ok: true,
retired: true,
reason: "legacy hermes client / opencode page-ai path deleted; use Pi Lab + /api/mnote/tools",
script: require("node:path").basename(__filename),
}, null, 2));
process.exit(0);
@@ -1,306 +1,16 @@
#!/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_URL,
UI_TIMEOUT_MS,
ensureAuthenticated,
} = require("./tree-shell-smoke-helpers");
const TASK = "task525-page-ai-mindmap-resource-target-smoke";
const OUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUT_DIR, "result.json");
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function writeWorkspaceManifest(root, ownerId, workspaceId) {
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(
path.join(root, ".mnote", "workspace.json"),
`${JSON.stringify({
workspaceId,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "ai_sessions", "markdown_edit"],
}, null, 2)}\n`,
"utf8",
);
}
function documentUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
async function waitFiletreeRow(page, relativePath) {
await page.waitForFunction(
(expectedRelativePath) => Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-shell-mode=\"filetree\"]"))
.some((row) => row.getAttribute("data-local-relative-path") === expectedRelativePath),
relativePath,
{ timeout: UI_TIMEOUT_MS },
);
}
async function clickFiletreeOpen(page, relativePath) {
const handle = await page.waitForFunction(
(expectedRelativePath) => {
const row = Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-shell-mode=\"filetree\"]"))
.find((candidate) => candidate.getAttribute("data-local-relative-path") === expectedRelativePath);
return row?.querySelector('[data-rust-action="open"], .tree-link') || null;
},
relativePath,
{ timeout: UI_TIMEOUT_MS },
);
await handle.asElement().click();
}
async function expandFiletreeFolder(page, relativePath) {
await waitFiletreeRow(page, relativePath);
const expanded = await page.evaluate((expectedRelativePath) => {
const row = Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-shell-mode=\"filetree\"]"))
.find((candidate) => candidate.getAttribute("data-local-relative-path") === expectedRelativePath);
return row?.getAttribute("aria-expanded") === "true";
}, relativePath);
if (!expanded) {
await clickFiletreeOpen(page, relativePath);
}
await page.waitForFunction(
(expectedRelativePath) => {
const row = Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-shell-mode=\"filetree\"]"))
.find((candidate) => candidate.getAttribute("data-local-relative-path") === expectedRelativePath);
return row?.getAttribute("aria-expanded") === "true";
},
relativePath,
{ timeout: UI_TIMEOUT_MS },
);
}
async function saveScreenshot(page, name) {
const target = path.join(OUT_DIR, `${name}.png`);
await page.screenshot({ path: target, fullPage: false });
return target;
}
async function main() {
fs.mkdirSync(OUT_DIR, { recursive: true });
const actorId = "mnote-e2e";
const workspaceId = `local-ws:${actorId}:task525`;
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task525-mindmap-target-"));
const rootUri = fileUrl(root);
const pagePath = "Page/Page.md";
const mindmapPath = "Page/map.mindmap.json";
const documentId = localMdDocumentId(pagePath);
const assetId = `local-file:${mindmapPath}`;
const expectedTargetId = `resource:mindmap:${documentId}:${assetId}`;
const captured = [];
let caughtError = null;
fs.mkdirSync(path.join(root, "Page"), { recursive: true });
writeWorkspaceManifest(root, actorId, workspaceId);
fs.writeFileSync(path.join(root, pagePath), "# Page\n\n[思维导图](map.mindmap.json)\n", "utf8");
fs.writeFileSync(
path.join(root, mindmapPath),
`${JSON.stringify({ data: { text: "Task525 mindmap" }, children: [] }, null, 2)}\n`,
"utf8",
);
const browser = await chromium.launch({
headless: process.env.HEADFUL !== "1",
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
locale: "zh-CN",
extraHTTPHeaders: {
"x-mnote-actor-id": actorId,
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
try {
await page.route("**/api/user/access-policy**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
grants: [{
id: "grant_task525",
userId: actorId,
workspaceId,
rootUri,
rootPath: root,
permission: "write",
recursive: true,
capabilities: ["ai", "markdown_edit"],
source: "user",
status: "active",
}],
}),
});
});
await page.route("**/api/ui/preferences**", async (route) => {
captured.push({ kind: "ui-preferences", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, owner: "mnote-web", result: {} }),
});
});
await page.route("**/api/hermes/client/gateway/health**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, gateway: { ok: true }, profile: { name: "reasonix", modelConfigured: true, apiKeyConfigured: true } }),
});
});
await page.route("**/api/hermes/client/tools**", async (route) => {
await route.fulfill({ status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true, tools: [] }) });
});
await page.route("**/api/ai/agent-profiles**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, agentId: "reasonix", profiles: [] }),
});
});
await page.route("**/api/hermes/client/profiles**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, active: "mnoteai", profiles: [{ name: "mnoteai", label: "MNote AI", modelConfigured: true, apiKeyConfigured: true }] }),
});
});
await page.route("**/api/hermes/client/skills**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
});
});
await page.route("**/api/hermes/client/capabilities**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, runtime: "mnote", categories: [], archived: [] }),
});
});
await page.route("**/api/hermes/client/sessions", async (route) => {
captured.push({ kind: "session", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, sessionId: "mnote_task525", title: "task525", traceId: "trace_task525" }),
});
});
await page.route("**/api/hermes/client/runs", async (route) => {
captured.push({ kind: "run", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, sessionId: "mnote_task525", runId: "run_task525", events: [], traceId: "trace_run_task525" }),
});
});
await page.route("**/api/hermes/client/events/*", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "text/event-stream; charset=utf-8" },
body: `data: ${JSON.stringify({ event: "message.delta", run_id: "run_task525", delta: "Task525 response" })}\n\n`
+ `data: ${JSON.stringify({ event: "run.completed", run_id: "run_task525", output: "Task525 response" })}\n\n`,
});
});
await ensureAuthenticated(page, context.request);
const response = await page.goto(documentUrl(root, pagePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
assert(response && response.status() === 200, `文档页状态码异常: ${response && response.status()}`);
await page.locator(".document-pane[data-pane-role=\"primary\"] .editor-surface .ProseMirror").first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await expandFiletreeFolder(page, "Page");
await waitFiletreeRow(page, mindmapPath);
await clickFiletreeOpen(page, mindmapPath);
await page.waitForFunction(
() => {
return Boolean(document.querySelector('[data-testid="mnote-mindmap-editor-root"]'))
&& (document.body?.innerText || "").includes("map.mindmap.json");
},
null,
{ timeout: UI_TIMEOUT_MS },
);
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="wolai-page-ai-drawer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const targetChip = page.locator("[data-page-ai-target-chip]");
await targetChip.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const chipText = (await targetChip.innerText({ timeout: UI_TIMEOUT_MS })).trim();
assert(chipText.includes("map.mindmap.json"), `mindmap resource 打开后 target chip 应指向 map.mindmap.json: ${chipText}`);
await page.locator("[data-page-ai-input]").fill("Task525 mindmap target", { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("Task525 response"),
null,
{ timeout: UI_TIMEOUT_MS },
);
const runs = captured.filter((item) => item.kind === "run");
assert(runs.length >= 1, "未捕获 Page AI run payload");
const runBody = JSON.parse(runs[runs.length - 1].body || "{}");
const activeEditorRef = runBody.contextRefs?.find((item) => item.kind === "active_editor");
assert(activeEditorRef, `run payload 应包含 active_editor contextRef: ${JSON.stringify(runBody.contextRefs)}`);
assert.strictEqual(activeEditorRef.resourceKind, "mindmap", `active_editor 应标记 mindmap resourceKind: ${JSON.stringify(activeEditorRef)}`);
assert.strictEqual(activeEditorRef.assetId, assetId, `active_editor 应携带 mindmap assetId: ${JSON.stringify(activeEditorRef)}`);
assert.strictEqual(activeEditorRef.objectIdentity, expectedTargetId, `active_editor objectIdentity 不应退化: ${JSON.stringify(activeEditorRef)}`);
assert.strictEqual(activeEditorRef.relativePath, mindmapPath, `active_editor 应携带 mindmap relativePath: ${JSON.stringify(activeEditorRef)}`);
assert.strictEqual(runBody.targetPackage?.schema, "mnote.agent_target_package.v1", "run payload 应携带 targetPackage");
assert.strictEqual(runBody.targetPackage?.primaryTargetId, expectedTargetId, `targetPackage 应冻结 mindmap target: ${JSON.stringify(runBody.targetPackage)}`);
assert.strictEqual(runBody.targetPackage?.objectIdentity, expectedTargetId, `targetPackage objectIdentity 不应退化: ${JSON.stringify(runBody.targetPackage)}`);
assert.strictEqual(runBody.targetPackage?.resourceKind, "mindmap", `targetPackage 应保留 mindmap resourceKind: ${JSON.stringify(runBody.targetPackage)}`);
assert.strictEqual(runBody.targetPackage?.currentFile?.relativePath, mindmapPath, `targetPackage currentFile 应指向 mindmap resource: ${JSON.stringify(runBody.targetPackage)}`);
assert.strictEqual(runBody.targetPackage?.currentFile?.objectIdentity, expectedTargetId, `currentFile objectIdentity 不应退化: ${JSON.stringify(runBody.targetPackage?.currentFile)}`);
assert(runBody.targetPackage?.allowedFiles?.includes(mindmapPath), `allowedFiles 应包含 mindmap resource: ${JSON.stringify(runBody.targetPackage?.allowedFiles)}`);
const screenshot = await saveScreenshot(page, "01-mindmap-resource-target");
const result = { ok: true, task: TASK, baseUrl: BASE_URL, root, rootUri, documentId, mindmapPath, assetId, expectedTargetId, screenshot, captured };
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} catch (error) {
caughtError = error;
await saveScreenshot(page, "failure").catch(() => undefined);
} finally {
fs.rmSync(root, { recursive: true, force: true });
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
if (caughtError) {
throw caughtError;
}
}
if (require.main === module) {
main().catch((error) => {
console.error(error);
process.exit(1);
});
}
/**
* RETIRED (Wave 10 / pre-release legacy purge)
* 依赖已删除的 /api/hermes/client/* OpenCode Page AI host
* 产品 Page AI Pi Lab/api/page-ai/pi/*agent tools /api/mnote/tools/*
* 本文件保留作历史对照直接 exit 0不再执行浏览器/静态断言
*/
console.log(JSON.stringify({
ok: true,
retired: true,
reason: "legacy hermes client / opencode page-ai path deleted; use Pi Lab + /api/mnote/tools",
script: require("node:path").basename(__filename),
}, null, 2));
process.exit(0);
+13 -339
View File
@@ -1,342 +1,16 @@
#!/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_URL,
UI_TIMEOUT_MS,
ensureAuthenticated,
} = require("./tree-shell-smoke-helpers");
const {
setupWorkspaceAccess,
listAiRuntimeRuns,
listExternalConversationBindings,
} = require("./lib/control-plane-dev-seed");
const CHROMIUM_EXECUTABLE_PATH =
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH ||
[
"/usr/bin/google-chrome-stable",
"/usr/bin/google-chrome",
"/snap/bin/chromium",
"/usr/bin/chromium",
].find((candidate) => fs.existsSync(candidate));
const PROVIDERS = [
{
key: "gpt",
profileId: "shared_api_gpt_chat",
chipText: "ChatOnly / GPT",
markerPrefix: "MNOTE_API_CHAT_GPT",
expectedProfile: "api-gpt-chat",
expectedModel: "aisz-chat/gpt-5.5-extra-high-fast",
},
{
key: "deepseek-flash",
profileId: "shared_api_deepseek_flash_chat",
chipText: "ChatOnly / DeepSeek Flash",
markerPrefix: "MNOTE_API_CHAT_DEEPSEEK_FLASH",
expectedProfile: "api-deepseek-flash-chat",
expectedModel: "deepseek-v4-flash",
},
];
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task527-chatonly-api-provider-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function documentUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
function writeWorkspaceManifest(root, ownerId, workspaceId) {
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(
path.join(root, ".mnote", "workspace.json"),
`${JSON.stringify(
{
workspaceId,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "ai_sessions", "markdown_edit"],
},
null,
2,
)}\n`,
"utf8",
);
}
async function saveScreenshot(page, name) {
const target = path.join(OUTPUT_DIR, `${name}.png`);
await page.screenshot({ path: target, fullPage: false });
return target;
}
async function waitForAssistantMarker(page, marker) {
await page.waitForFunction(
(expectedMarker) => {
const assistantText = Array.from(document.querySelectorAll(".wolai-page-ai-message--assistant"))
.map((node) => node.textContent || "")
.join("\n");
return assistantText.includes(expectedMarker);
},
marker,
{ timeout: 180_000 },
);
}
async function ensurePageAiDrawerOpen(page) {
const drawer = page.locator('[data-testid="wolai-page-ai-drawer"]');
if (await drawer.isVisible().catch(() => false)) return;
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
await drawer.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
}
async function selectProvider(page, provider) {
await page.locator("[data-page-ai-agent-button]").click({ timeout: UI_TIMEOUT_MS });
await page.locator(`[data-page-ai-agent-id="chat_only"][data-page-ai-profile-id="${provider.profileId}"]`).click({
timeout: UI_TIMEOUT_MS,
});
await page.waitForFunction(
(expected) => document.querySelector("[data-page-ai-agent-chip]")?.textContent?.includes(expected),
provider.chipText,
{ timeout: UI_TIMEOUT_MS },
);
}
async function runProviderSmoke(page, provider, suffix, { actorId, workspaceId }) {
const marker = `${provider.markerPrefix}_${suffix}`;
const runRequests = [];
const runResponses = [];
const deleteResponses = [];
const runRoute = async (route) => {
runRequests.push(JSON.parse(route.request().postData() || "{}"));
await route.continue();
};
await page.route("**/api/hermes/client/runs", runRoute);
const responseListener = async (response) => {
const url = response.url();
const request = response.request();
if (request.method() === "POST" && url.includes("/api/hermes/client/runs")) {
runResponses.push({
url,
status: response.status(),
body: await response.text().catch(() => ""),
});
}
if (request.method() === "DELETE" && url.includes("/api/hermes/client/sessions/")) {
deleteResponses.push({
url,
status: response.status(),
body: await response.text().catch(() => ""),
});
}
};
page.on("response", responseListener);
try {
await page.locator('.wolai-page-ai-header-actions [data-page-ai-action="new-session"]').click({
timeout: UI_TIMEOUT_MS,
});
await selectProvider(page, provider);
await page.locator("[data-page-ai-input]").fill(`请只回复:${marker}`, { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await waitForAssistantMarker(page, marker);
await page.waitForFunction(
() => (document.querySelector("[data-page-ai-run-status]")?.textContent || "").includes("完成"),
null,
{ timeout: 180_000 },
).catch(() => {});
const afterMessageScreenshot = await saveScreenshot(page, `${provider.key}-after-message`);
assert.strictEqual(runRequests.length, 1, `${provider.key} 本轮应只创建一个 run`);
const run = runRequests[0];
assert.strictEqual(run.agentId, "chat_only", `${provider.key} 应使用 ChatOnly agent`);
assert.strictEqual(run.profileId, provider.profileId, `${provider.key} 应使用 API ChatOnly profile`);
assert(run.sessionId, `${provider.key} run payload 应包含 MNote sessionId`);
assert.strictEqual(runResponses.length, 1, `${provider.key} 应返回一个 run response`);
assert.strictEqual(runResponses[0].status, 200, `${provider.key} run response 应成功`);
const runResponse = JSON.parse(runResponses[0].body || "{}");
assert.strictEqual(runResponse.providerKind, "api-chat", `${provider.key} 后端应分流到 api-chat`);
assert.strictEqual(runResponse.runtime?.transport, "api-chat", `${provider.key} 不应启动 ACP/OpenClaw runtime`);
assert.strictEqual(runResponse.runtime?.model, provider.expectedModel, `${provider.key} model 应匹配 registry`);
assert.strictEqual(runResponse.profile, provider.expectedProfile, `${provider.key} 应使用 isolated API profile`);
const assistantTexts = await page.locator(".wolai-page-ai-message--assistant").allTextContents();
const markerAssistantCount = assistantTexts.filter((text) => text.includes(marker)).length;
assert.strictEqual(markerAssistantCount, 1, `${provider.key} 可见 API 回复应只有一条`);
const sessionId = String(run.sessionId);
const bindingRows = await listExternalConversationBindings(page.context().request, BASE_URL, {
userId: actorId,
workspaceId,
mnoteSessionId: sessionId,
limit: 5,
timeoutMs: UI_TIMEOUT_MS,
});
assert.strictEqual(bindingRows.length, 0, `${provider.key} API ChatOnly 不应写网页 provider conversation binding`);
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await ensurePageAiDrawerOpen(page);
await waitForAssistantMarker(page, marker);
const afterReloadScreenshot = await saveScreenshot(page, `${provider.key}-after-reload`);
const reloadedAssistantTexts = await page.locator(".wolai-page-ai-message--assistant").allTextContents();
assert.strictEqual(
reloadedAssistantTexts.filter((text) => text.includes(marker)).length,
1,
`${provider.key} 刷新恢复后仍应只有一条助手回复`,
);
await page.locator('.wolai-page-ai-header-actions [data-page-ai-action="history"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="history"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(`[data-page-ai-session-row="${sessionId}"]`).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
page.once("dialog", async (dialog) => {
await dialog.accept();
});
await page.locator(`[data-page-ai-session-delete="${sessionId}"]`).click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
(id) => !document.querySelector(`[data-page-ai-session-row="${CSS.escape(id)}"]`),
sessionId,
{ timeout: UI_TIMEOUT_MS },
);
const afterDeleteScreenshot = await saveScreenshot(page, `${provider.key}-after-delete`);
assert(deleteResponses.length >= 1, `${provider.key} 应发出本地 session DELETE 请求`);
assert.strictEqual(deleteResponses.at(-1).status, 200, `${provider.key} DELETE 应成功`);
const deleteBody = JSON.parse(deleteResponses.at(-1).body || "{}");
assert.strictEqual(deleteBody?.result?.remoteDelete?.attempted, false, `${provider.key} 不应调用网页远端删除`);
assert.strictEqual(
deleteBody?.result?.remoteDelete?.reason,
"api_chat_has_no_remote_conversation",
`${provider.key} remoteDelete reason 应说明 API Chat 无远端会话`,
);
const remainingRows = await listAiRuntimeRuns(page.context().request, BASE_URL, {
userId: actorId,
workspaceId,
sessionId,
limit: 5,
timeoutMs: UI_TIMEOUT_MS,
});
assert.strictEqual(remainingRows.length, 0, `${provider.key} 删除后 SQLite active run 不应残留`);
return {
provider: provider.key,
profileId: provider.profileId,
model: provider.expectedModel,
sessionId,
runId: runResponse.runId,
screenshots: {
afterMessage: afterMessageScreenshot,
afterReload: afterReloadScreenshot,
afterDelete: afterDeleteScreenshot,
},
};
} finally {
await page.unroute("**/api/hermes/client/runs", runRoute).catch(() => {});
page.off("response", responseListener);
}
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const suffix = Date.now().toString(36);
const actorId = "mnote-e2e";
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task527-api-chat-"));
const rootUri = fileUrl(root);
const workspaceId = `local-ws:${actorId}:task527-api-chat-${suffix}`;
const relativePath = "ApiChatOnly.md";
writeWorkspaceManifest(root, actorId, workspaceId);
fs.writeFileSync(path.join(root, relativePath), ["# API ChatOnly", "", `MNOTE_API_CHAT_WORKSPACE_${suffix}`, ""].join("\n"), "utf8");
const browser = await chromium.launch({
headless: true,
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
locale: "zh-CN",
extraHTTPHeaders: {
"x-mnote-actor-id": actorId,
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
let caughtError = null;
const results = [];
try {
await ensureAuthenticated(page, context.request);
await setupWorkspaceAccess(context.request, BASE_URL, {
actorId,
workspaceId,
workspaceName: actorId,
rootPath: root,
rootUri,
capabilities: ["ai", "markdown_edit"],
timeoutMs: UI_TIMEOUT_MS,
});
const response = await page.goto(documentUrl(root, relativePath), {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
});
assert(response && response.status() === 200, `文档页状态码异常: ${response && response.status()}`);
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await ensurePageAiDrawerOpen(page);
await saveScreenshot(page, "initial-drawer");
for (const provider of PROVIDERS) {
results.push(await runProviderSmoke(page, provider, suffix, { actorId, workspaceId }));
}
} catch (error) {
caughtError = error;
await saveScreenshot(page, "failure").catch(() => undefined);
} finally {
await browser.close().catch(() => {});
}
const resultPayload = {
ok: !caughtError,
error: caughtError ? String(caughtError && caughtError.stack || caughtError) : "",
root,
workspaceId,
relativePath,
providers: results,
outputDir: OUTPUT_DIR,
resultPath: RESULT_PATH,
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(resultPayload, null, 2)}\n`, "utf8");
if (caughtError) {
console.error(JSON.stringify(resultPayload, null, 2));
process.exit(1);
}
console.log(JSON.stringify(resultPayload, null, 2));
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
/**
* RETIRED (Wave 10 / pre-release legacy purge)
* 依赖已删除的 /api/hermes/client/* OpenCode Page AI host
* 产品 Page AI Pi Lab/api/page-ai/pi/*agent tools /api/mnote/tools/*
* 本文件保留作历史对照直接 exit 0不再执行浏览器/静态断言
*/
console.log(JSON.stringify({
ok: true,
retired: true,
reason: "legacy hermes client / opencode page-ai path deleted; use Pi Lab + /api/mnote/tools",
script: require("node:path").basename(__filename),
}, null, 2));
process.exit(0);
@@ -1,399 +1,16 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const path = require("node:path");
const { chromium } = require("playwright");
const ROOT = process.env.MNOTE_REPO_ROOT || "/mnt/Data1T/mnote";
const { BASE_URL, UI_TIMEOUT_MS } = require(path.join(ROOT, "scripts", "tree-shell-smoke-helpers"));
const {
setupWorkspaceAccess,
} = require(path.join(ROOT, "scripts", "lib", "control-plane-dev-seed"));
const OUTPUT_DIR = path.join(ROOT, "tmp", "task530-knowledge-rag-page-ai-final-answer-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "page-ai-knowledge-rag-answer.png");
const CITATION_OPEN_SCREENSHOT_PATH = path.join(OUTPUT_DIR, "page-ai-knowledge-rag-citation-open.png");
const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome";
const ACTOR_ID = "mnote-e2e";
const WORKSPACE_ID = "local-ws:mnote-e2e:my-space";
const ROOT_PATH = "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
const ROOT_URI = `file://${ROOT_PATH}`;
const OWNER_REL = "knowledge-rag-fixtures-7-50/PageAiKnowledgeRagSmoke.md";
const EXPECTED_RESOURCE = "新页面233155/image copy 6.png";
const DOCUMENT_ID = `local-md:${OWNER_REL.replaceAll("/", "~2F")}`;
function ensureOwnerPage() {
const ownerPath = path.join(ROOT_PATH, OWNER_REL);
fs.mkdirSync(path.dirname(ownerPath), { recursive: true });
if (!fs.existsSync(ownerPath)) {
fs.writeFileSync(
ownerPath,
["# Page AI Knowledge RAG Smoke", "", "This page is a stable Page AI smoke target for WeKnora retrieval.", ""].join("\n"),
"utf8",
);
}
}
async function signIn(context) {
const response = await context.request.post(`${BASE_URL}/api/auth`, {
data: {
action: "auth:signIn",
args: {
provider: "password",
params: {
account: ACTOR_ID,
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
flow: "signIn",
},
},
},
timeout: UI_TIMEOUT_MS,
});
const text = await response.text();
assert(response.ok(), `/api/auth 登录失败: ${response.status()} ${text.slice(0, 500)}`);
}
async function assertKnowledgeRagDescriptor(context) {
const response = await context.request.get(`${BASE_URL}/api/page-ai/agents/descriptors?profile=reasonix`, {
headers: {
"x-mnote-actor-id": ACTOR_ID,
"x-mnote-actor-type": "user",
accept: "application/json",
},
});
assert(response.ok(), `descriptor API 失败: ${response.status()} ${await response.text()}`);
const payload = await response.json();
const reasonix = (payload.descriptors || []).find((descriptor) => descriptor.agentId === "reasonix");
assert(reasonix, "descriptor 缺少 Reasonix");
assert.equal(reasonix.capabilityStates?.knowledge_rag?.enabled, true, "Reasonix descriptor 应声明 knowledge_rag enabled");
assert((reasonix.capabilities || []).includes("knowledge_rag"), "Reasonix descriptor capabilities 应包含 knowledge_rag");
const toolNames = new Set((reasonix.tools || []).map((tool) => tool.name));
for (const toolName of ["mnote.knowledge_rag.status", "mnote.knowledge_rag.query", "mnote.knowledge_rag.open_reference"]) {
assert(toolNames.has(toolName), `Reasonix descriptor 缺少 ${toolName}`);
}
}
async function knowledgeRagStatus(context) {
const params = new URLSearchParams({ rootUri: ROOT_URI, workspaceId: WORKSPACE_ID });
const response = await context.request.get(`${BASE_URL}/api/knowledge-rag/status?${params.toString()}`);
assert(response.ok(), `knowledge-rag status 失败: ${response.status()} ${await response.text()}`);
return await response.json();
}
async function ensureKnowledgeRagSourceIndexed(context) {
const response = await context.request.post(`${BASE_URL}/api/knowledge-rag/ingest`, {
data: {
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
sources: [{ sourcePath: EXPECTED_RESOURCE }],
},
});
assert(response.ok(), `knowledge-rag ingest 失败: ${response.status()} ${await response.text()}`);
const startedAt = Date.now();
let lastEntry = null;
while (Date.now() - startedAt < 90_000) {
const payload = await knowledgeRagStatus(context);
lastEntry = (payload.registry?.entries || []).find((entry) => entry.sourceRootRelativePath === EXPECTED_RESOURCE) || null;
if (lastEntry?.indexedAtMs && lastEntry?.lightRagDocId && !lastEntry?.stale && !lastEntry?.deletedAtMs) return lastEntry;
await new Promise((resolve) => setTimeout(resolve, 5_000));
}
throw new Error(`等待目标资料入库超时: ${JSON.stringify(lastEntry, null, 2)}`);
}
async function assertKnowledgeRagQueryReturnsCitationMarkdown(context) {
const response = await context.request.post(`${BASE_URL}/api/knowledge-rag/query`, {
data: {
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
query: "这个图片资料在资料库里是什么内容?",
mode: "mix",
topK: 8,
chunkTopK: 8,
includeChunkContent: true,
sourcePaths: [EXPECTED_RESOURCE],
},
});
assert(response.ok(), `knowledge-rag query 失败: ${response.status()} ${await response.text()}`);
const payload = await response.json();
const references = Array.isArray(payload.references) ? payload.references : [];
assert(
references.some((reference) => String(reference.citationMarkdown || "").trim() && String(reference.citationUrl || "").includes("resourceTab=")),
`query 输出缺少 citationMarkdownPage AI 最终回答 smoke 不能继续: ${JSON.stringify(references, null, 2).slice(0, 3000)}`,
);
}
async function selectReasonix(page) {
await page.locator("[data-page-ai-agent-button]").click({ timeout: UI_TIMEOUT_MS });
await page.locator("[data-page-ai-agent-popover]").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-agent-id="reasonix"]').first().click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.getAttribute("data-mnote-acp-runtime") === "reasonix",
null,
{ timeout: UI_TIMEOUT_MS },
);
}
async function newestAssistantText(page, initialCount) {
return await page.evaluate((countBefore) => {
const nodes = Array.from(
document.querySelectorAll('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant'),
);
const node = nodes.slice(countBefore).at(-1) || nodes.at(-1);
const content = node?.querySelector(".wolai-page-ai-message-text");
return content ? content.textContent || "" : "";
}, initialCount);
}
async function newestAssistantLinks(page, initialCount) {
return await page.evaluate((countBefore) => {
const nodes = Array.from(
document.querySelectorAll('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant'),
);
const node = nodes.slice(countBefore).at(-1) || nodes.at(-1);
if (!node) return [];
return Array.from(node.querySelectorAll("a")).map((anchor) => ({
text: anchor.textContent || "",
href: anchor.getAttribute("href") || "",
}));
}, initialCount);
}
async function clickNewestAssistantCitation(page, initialCount, expectedResource) {
const total = await page
.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant')
.count();
const latestIndex = Math.max(initialCount, total - 1);
const latestMessage = page
.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant')
.nth(latestIndex);
const citationLink = latestMessage.locator('a[data-page-ai-citation-link="true"]').first();
await citationLink.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const popupPromise = page.waitForEvent("popup", { timeout: 5_000 }).catch(() => null);
await citationLink.click({ timeout: UI_TIMEOUT_MS });
const openedPage = (await popupPromise) || page;
await openedPage.waitForLoadState("domcontentloaded", { timeout: UI_TIMEOUT_MS }).catch(() => undefined);
await openedPage.waitForFunction(
(resourcePath) => {
const panel = document.querySelector(
`[data-mnote-resource-tab-panel][data-resource-path="${CSS.escape(resourcePath)}"]`,
);
return panel && !panel.hidden;
},
expectedResource,
{ timeout: UI_TIMEOUT_MS },
);
await openedPage.waitForTimeout(1500);
const state = await openedPage.evaluate((resourcePath) => {
const panel = document.querySelector(
`[data-mnote-resource-tab-panel][data-resource-path="${CSS.escape(resourcePath)}"]`,
);
const activeTab = document.querySelector(".mnote-main-tab.is-active, [data-mnote-tab-kind].is-active");
const image = panel ? panel.querySelector("img, [data-mnote-image-viewer], [data-mnote-resource-image]") : null;
return {
url: location.href,
openedInPopup: window.opener != null,
activeTabText: activeTab ? activeTab.textContent.trim().slice(0, 120) : "",
activeTabKind: activeTab ? activeTab.getAttribute("data-mnote-tab-kind") : "",
panelVisible: !!panel && !panel.hidden,
panelResourcePath: panel ? panel.getAttribute("data-resource-path") : "",
panelLocator: panel ? panel.getAttribute("data-mnote-evidence-locator") : "",
panelBlockId: panel ? panel.getAttribute("data-mnote-evidence-block-id") : "",
imageVisible: !!image,
};
}, expectedResource);
await openedPage.screenshot({ path: CITATION_OPEN_SCREENSHOT_PATH, fullPage: true });
assert.equal(state.panelVisible, true, `点击 AI citation 后未打开资源 panel: ${JSON.stringify(state, null, 2)}`);
assert.equal(state.panelResourcePath, expectedResource, `点击 AI citation 后资源路径不匹配: ${JSON.stringify(state, null, 2)}`);
assert(
state.url.includes("resourceTab=") || state.url.includes("resourcePath="),
`点击 AI citation 后 URL 缺少资源定位参数: ${JSON.stringify(state, null, 2)}`,
);
return state;
}
function summarizeRun(body) {
return {
workspaceId: body.workspaceId || "",
documentId: body.documentId || "",
sourceKind: body.sourceKind || "",
rootUri: body.rootUri || "",
agentId: body.agentId || "",
profile: body.profile || "",
acpRuntime: body.acpRuntime || "",
contextRefs: Array.isArray(body.contextRefs)
? body.contextRefs.map((item) => (typeof item === "string" ? item : item?.kind || "")).filter(Boolean)
: [],
allowedRootCount: Array.isArray(body.allowedRoots) ? body.allowedRoots.length : 0,
mnoteKnowledgeRagDisabled: body.skillPreferences?.mnote?.["mnote-knowledge-rag"] === false,
message: body.message || "",
};
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
ensureOwnerPage();
const browser = await chromium.launch({
headless: process.env.MNOTE_PAGE_AI_VERIFY_HEADED !== "1",
executablePath: fs.existsSync(CHROME) ? CHROME : undefined,
});
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
const page = await context.newPage();
const capturedRuns = [];
const consoleErrors = [];
const pageErrors = [];
page.on("console", (message) => {
if (["error", "warning"].includes(message.type())) {
consoleErrors.push({ type: message.type(), text: message.text() });
}
});
page.on("pageerror", (error) => pageErrors.push(String(error?.message || error)));
page.on("request", (request) => {
if (!request.url().includes("/api/hermes/client/runs") || request.method() !== "POST") return;
try {
capturedRuns.push(JSON.parse(request.postData() || "{}"));
} catch {
capturedRuns.push({ raw: request.postData() || "" });
}
});
try {
await signIn(context);
await setupWorkspaceAccess(context.request, BASE_URL, {
actorId: ACTOR_ID,
email: "mnote.e2e@example.com",
username: ACTOR_ID,
displayName: ACTOR_ID,
role: "user",
workspaceId: WORKSPACE_ID,
workspaceName: "MNote E2E Space",
rootPath: ROOT_PATH,
rootUri: ROOT_URI,
capabilities: ["ai"],
timeoutMs: UI_TIMEOUT_MS,
});
await assertKnowledgeRagDescriptor(context);
await ensureKnowledgeRagSourceIndexed(context);
await assertKnowledgeRagQueryReturnsCitationMarkdown(context);
const documentUrl = new URL(`${BASE_URL}/documents/${encodeURIComponent(DOCUMENT_ID)}`);
documentUrl.searchParams.set("sourceKind", "local_folder");
documentUrl.searchParams.set("rootUri", ROOT_URI);
documentUrl.searchParams.set("workspaceId", WORKSPACE_ID);
await page.goto(documentUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
await selectReasonix(page);
const assistantCount = await page
.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant')
.count();
const prompt = [
"请调用 mnote_knowledge_rag_query 检索资料库。",
`问题:这个图片资料在资料库里是什么内容?调用工具时请把 sourcePaths 参数设为 ["${EXPECTED_RESOURCE}"]。`,
"最终只输出一句中文结论,必须包含返回的 citationMarkdown 链接;如果 locatorDegraded=true,必须说明来源定位降级,不要描述检索过程,不要输出 raw JSON,不要编造页码或 bbox。",
].join("\n");
await page.locator("[data-page-ai-input]").fill(prompt, { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => document.documentElement.getAttribute("data-mnote-page-ai-run-status") === "completed",
null,
{ timeout: Math.max(UI_TIMEOUT_MS, 180_000) },
);
await page.waitForFunction(
(countBefore) => {
const nodes = Array.from(
document.querySelectorAll('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant'),
);
const node = nodes.slice(countBefore).at(-1) || nodes.at(-1);
const text = node?.querySelector(".wolai-page-ai-message-text")?.textContent || "";
return text.includes("来源定位降级") && text.includes("image copy 6.png");
},
assistantCount,
{ timeout: Math.max(UI_TIMEOUT_MS, 180_000) },
);
const assistantText = await newestAssistantText(page, assistantCount);
const assistantLinks = await newestAssistantLinks(page, assistantCount);
assert(assistantText.includes("image copy 6.png"), `可见回答缺少当前资料来源文件名: ${assistantText}`);
assert(assistantText.includes("来源定位降级"), `可见回答缺少 degraded citation 口径: ${assistantText}`);
assert(!/\bp\.\d+\b/i.test(assistantText), `degraded citation 不应编造页码: ${assistantText}`);
assert(!/bbox/i.test(assistantText), `degraded citation 不应编造 bbox: ${assistantText}`);
assert(
assistantLinks.some((link) => link.text.includes("来源定位降级") && link.href.includes("resourceTab=")),
`可见回答缺少可点击 resourceTab citation: ${JSON.stringify(assistantLinks)}`,
);
assert(!assistantText.includes("citationMarkdown"), `可见回答泄漏工具字段名: ${assistantText}`);
assert(!assistantText.includes('"raw"'), `可见回答泄漏 raw JSON: ${assistantText}`);
assert(!assistantText.includes("mnote_knowledge_rag_query"), `可见回答泄漏工具名: ${assistantText}`);
assert(!/让我|我来|我先|查询返回|找到了|检索资料库/.test(assistantText), `可见回答包含检索过程叙述: ${assistantText}`);
assert(
capturedRuns.some((body) => body.acpRuntime === "reasonix" && body.profile === "reasonix"),
"未捕获到 Reasonix Page AI run",
);
assert(
capturedRuns.every((body) => body.sourceKind === "local_folder" && body.rootUri === ROOT_URI),
"Page AI run 未保持 local_folder/rootUri 上下文",
);
assert(
capturedRuns.some((body) => body.skillPreferences?.mnote?.["mnote-knowledge-rag"] !== false),
"Page AI run 禁用了 mnote-knowledge-rag",
);
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
const citationOpenState = await clickNewestAssistantCitation(page, assistantCount, "新页面233155/image copy 6.png");
const result = {
ok: true,
baseUrl: BASE_URL,
workspaceId: WORKSPACE_ID,
rootUri: ROOT_URI,
documentId: DOCUMENT_ID,
assistantText,
assistantLinks,
capturedRuns: capturedRuns.map(summarizeRun),
capturedRunsFullPath: path.join(OUTPUT_DIR, "captured-runs-full.json"),
citationOpenState,
pageErrors,
consoleErrors,
screenshots: {
answer: SCREENSHOT_PATH,
citationOpen: CITATION_OPEN_SCREENSHOT_PATH,
},
};
fs.writeFileSync(
path.join(OUTPUT_DIR, "captured-runs-full.json"),
`${JSON.stringify(capturedRuns, null, 2)}\n`,
"utf8",
);
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} catch (error) {
await page.screenshot({ path: path.join(OUTPUT_DIR, "failure.png"), fullPage: true }).catch(() => undefined);
fs.writeFileSync(
path.join(OUTPUT_DIR, "failure.json"),
`${JSON.stringify(
{
ok: false,
error: error instanceof Error ? error.stack || error.message : String(error),
capturedRuns,
pageErrors,
consoleErrors,
},
null,
2,
)}\n`,
"utf8",
);
throw error;
} finally {
await browser.close().catch(() => undefined);
}
}
main().catch((error) => {
console.error(error.stack || error.message || String(error));
process.exit(1);
});
/**
* RETIRED (Wave 10 / pre-release legacy purge)
* 依赖已删除的 /api/hermes/client/* OpenCode Page AI host
* 产品 Page AI Pi Lab/api/page-ai/pi/*agent tools /api/mnote/tools/*
* 本文件保留作历史对照直接 exit 0不再执行浏览器/静态断言
*/
console.log(JSON.stringify({
ok: true,
retired: true,
reason: "legacy hermes client / opencode page-ai path deleted; use Pi Lab + /api/mnote/tools",
script: require("node:path").basename(__filename),
}, null, 2));
process.exit(0);
@@ -1,381 +1,16 @@
#!/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_URL,
UI_TIMEOUT_MS,
ensureAuthenticated,
} = require("./tree-shell-smoke-helpers");
const TASK = "task535-page-ai-local-agent-clean-edit-smoke";
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function writeWorkspaceManifest(root, ownerId, workspaceId) {
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(
path.join(root, ".mnote", "workspace.json"),
`${JSON.stringify({
workspaceId,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "ai_sessions", "markdown_edit"],
}, null, 2)}\n`,
"utf8",
);
}
function documentUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
async function saveScreenshot(page, name) {
const target = path.join(OUTPUT_DIR, `${name}.png`);
await page.screenshot({ path: target, fullPage: false });
return target;
}
async function waitForEditorText(page, expected) {
await page.waitForFunction(
(text) => {
const editor = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"] .ProseMirror');
return (editor?.textContent || "").includes(text);
},
expected,
{ timeout: UI_TIMEOUT_MS },
);
}
function parseJsonBody(record) {
try {
return JSON.parse(record.body || "{}");
} catch (error) {
throw new Error(`无法解析 JSON body: ${error instanceof Error ? error.message : String(error)}`);
}
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const suffix = Date.now().toString(36);
const actorId = "mnote-e2e";
const workspaceId = `local-ws:${actorId}:task535`;
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task535-agent-clean-"));
const rootUri = fileUrl(root);
const relativePath = "AgentClean.md";
const documentId = localMdDocumentId(relativePath);
const filePath = path.join(root, relativePath);
const initialToken = `task535-initial-${suffix}`;
const patchedToken = `task535-agent-patched-${suffix}`;
const runId = `run_task535_${suffix}`;
const sessionId = `mnote_task535_${suffix}`;
const captured = [];
const blockedRequests = [];
let eventStreamRequested = false;
let caughtError = null;
writeWorkspaceManifest(root, actorId, workspaceId);
fs.writeFileSync(
filePath,
["# Agent Clean", "", initialToken, ""].join("\n"),
"utf8",
);
const browser = await chromium.launch({
headless: process.env.HEADFUL !== "1",
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
locale: "zh-CN",
extraHTTPHeaders: {
"x-mnote-actor-id": actorId,
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
page.on("request", (request) => {
const url = request.url();
if (url.includes("/api/documents/save") || url.includes("/api/page-body/write")) {
blockedRequests.push({ url, method: request.method(), body: request.postData() || "" });
}
});
try {
await page.route("**/api/user/access-policy**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
controlPlane: "sqlite",
grants: [{
id: `grant_task535_${suffix}`,
userId: actorId,
workspaceId,
rootUri,
rootPath: root,
permission: "write",
recursive: true,
capabilities: ["ai", "markdown_edit"],
source: "user",
status: "active",
}],
}),
});
});
await page.route("**/api/ui/preferences**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, owner: "mnote-web", result: {} }),
});
});
await page.route("**/api/hermes/client/gateway/health**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
gateway: { ok: true, status: "mocked" },
profile: { name: "reasonix", modelConfigured: true, apiKeyConfigured: true },
suggestions: [],
}),
});
});
await page.route("**/api/hermes/client/tools**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, tools: [] }),
});
});
await page.route("**/api/ai/agent-profiles**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
agentId: "reasonix",
profiles: [
{ profileId: "shared_gemini_chat", kind: "shared", displayName: "Gemini Chat", canRun: true, readonly: true },
{ profileId: "usr_task535_default", kind: "personal", displayName: "我的 Hermes", baseProfile: "default", isolatedProfile: "mnote-u-task535-default", canRun: true },
],
}),
});
});
await page.route("**/api/hermes/client/profiles**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
active: "mnoteai",
profiles: [{ name: "mnoteai", label: "MNote AI", modelConfigured: true, apiKeyConfigured: true }],
}),
});
});
await page.route("**/api/hermes/client/skills**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
});
});
await page.route("**/api/hermes/client/capabilities**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, runtime: "mnote", categories: [], archived: [] }),
});
});
await page.route("**/api/hermes/client/sessions", async (route) => {
captured.push({ kind: "session", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
sessionId,
title: "task535",
traceId: `trace_task535_session_${suffix}`,
persistence: "local_ai_session_jsonl",
sessionStorage: "local_private",
}),
});
});
await page.route("**/api/hermes/client/runs", async (route) => {
captured.push({ kind: "run", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
sessionId,
runId,
events: [],
traceId: `trace_task535_run_${suffix}`,
}),
});
});
await page.route("**/api/hermes/client/events/*", async (route) => {
eventStreamRequested = true;
fs.writeFileSync(
filePath,
["# Agent Clean", "", initialToken, "", patchedToken, ""].join("\n"),
"utf8",
);
const completed = {
event: "run.completed",
run_id: runId,
output: "Task535 response",
agentAudit: {
rootUri,
actorId,
actorType: "user",
agentKind: "reasonix",
changedFiles: [{ path: relativePath, changeType: "modified", summary: "task535 native file patch" }],
agentRunReceipt: {
schema: "mnote.agent_run_receipt.v1",
runId,
sessionId,
workspaceId,
documentId,
rootUri,
agentKind: "reasonix",
status: "completed",
permission: "write",
changedFiles: [{ path: relativePath, changeType: "modified", summary: "task535 native file patch" }],
refresh: {
touchesCurrentFile: true,
currentDocumentId: documentId,
strategy: "refresh_current_file",
},
},
},
};
await route.fulfill({
status: 200,
headers: { "content-type": "text/event-stream; charset=utf-8" },
body:
`data: ${JSON.stringify({ event: "message.delta", run_id: runId, delta: "Task535 response" })}\n\n`
+ `data: ${JSON.stringify(completed)}\n\n`,
});
});
await ensureAuthenticated(page, context.request);
const response = await page.goto(documentUrl(root, relativePath), {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
});
assert(response && response.status() === 200, `文档页状态码异常: ${response && response.status()}`);
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await waitForEditorText(page, initialToken);
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="wolai-page-ai-drawer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator("[data-page-ai-input]").fill("请用原生文件编辑能力追加 task535 标记", { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("Task535 response"),
null,
{ timeout: UI_TIMEOUT_MS },
);
await page.waitForFunction(
() => document.documentElement.getAttribute("data-mnote-page-ai-receipt-current-refresh") === "true"
&& document.documentElement.getAttribute("data-mnote-page-ai-receipt-filetree-refresh") === "true",
null,
{ timeout: UI_TIMEOUT_MS },
);
await waitForEditorText(page, patchedToken);
const runs = captured.filter((item) => item.kind === "run");
assert.strictEqual(runs.length, 1, `应只启动一次 Page AI run,实际 ${runs.length}`);
assert(eventStreamRequested, "Page AI run 应继续读取 SSE events");
const runBody = parseJsonBody(runs[0]);
assert.strictEqual(runBody.sourceKind, "local_folder", `run sourceKind 应为 local_folder: ${JSON.stringify(runBody)}`);
assert.strictEqual(runBody.rootUri, rootUri, `run rootUri 应指向测试工作区: ${JSON.stringify(runBody)}`);
assert.strictEqual(runBody.targetPackage?.schema, "mnote.agent_target_package.v1", "run payload 应携带 targetPackage");
assert.strictEqual(runBody.targetPackage?.currentFile?.relativePath, relativePath, `currentFile 应指向当前 Markdown: ${JSON.stringify(runBody.targetPackage)}`);
assert(runBody.targetPackage?.allowedFiles?.includes(relativePath), `allowedFiles 应包含当前 Markdown: ${JSON.stringify(runBody.targetPackage?.allowedFiles)}`);
assert.strictEqual(
runBody.targetPackage?.targets?.[0]?.policy?.permission,
"read_write",
`write grant 下 target policy 应为 read_write: ${JSON.stringify(runBody.targetPackage?.targets?.[0]?.policy)}`,
);
const serializedRunBody = JSON.stringify(runBody);
const usedMarkdownEdit = serializedRunBody.includes("mnote.doc.markdown_edit");
const usedPageSave = serializedRunBody.includes("mnote.page.save");
const usedDocumentsSave = blockedRequests.length > 0;
assert(!usedMarkdownEdit, "local-first 普通 Markdown run payload 不应要求 mnote.doc.markdown_edit");
assert(!usedPageSave, "local-first 普通 Markdown run payload 不应要求 mnote.page.save");
assert(!usedDocumentsSave, `clean agent 原生文件编辑 smoke 不应调用页面保存接口: ${JSON.stringify(blockedRequests)}`);
const finalDiskContent = fs.readFileSync(filePath, "utf8");
assert(finalDiskContent.includes(patchedToken), "磁盘文件应包含 agent 原生写入内容");
const screenshot = await saveScreenshot(page, "01-clean-agent-edit");
const result = {
ok: true,
task: TASK,
baseUrl: BASE_URL,
root,
rootUri,
documentId,
relativePath,
patchedToken,
screenshot,
captured,
blockedRequests,
usedDocumentsSave,
usedMarkdownEdit,
usedPageSave,
currentRefresh: true,
filetreeRefresh: true,
finalDiskContent,
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} catch (error) {
caughtError = error;
await saveScreenshot(page, "failure").catch(() => undefined);
} finally {
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
if (caughtError) {
throw caughtError;
}
}
if (require.main === module) {
main().catch((error) => {
console.error(error);
process.exit(1);
});
}
/**
* RETIRED (Wave 10 / pre-release legacy purge)
* 依赖已删除的 /api/hermes/client/* OpenCode Page AI host
* 产品 Page AI Pi Lab/api/page-ai/pi/*agent tools /api/mnote/tools/*
* 本文件保留作历史对照直接 exit 0不再执行浏览器/静态断言
*/
console.log(JSON.stringify({
ok: true,
retired: true,
reason: "legacy hermes client / opencode page-ai path deleted; use Pi Lab + /api/mnote/tools",
script: require("node:path").basename(__filename),
}, null, 2));
process.exit(0);
@@ -1,309 +1,16 @@
#!/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_URL,
UI_TIMEOUT_MS,
ensureAuthenticated,
} = require("./tree-shell-smoke-helpers");
const TASK = "task536-page-ai-local-agent-dirty-guard-smoke";
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function writeWorkspaceManifest(root, ownerId, workspaceId) {
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(
path.join(root, ".mnote", "workspace.json"),
`${JSON.stringify({
workspaceId,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "ai_sessions", "markdown_edit"],
}, null, 2)}\n`,
"utf8",
);
}
function documentUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
async function saveScreenshot(page, name) {
const target = path.join(OUTPUT_DIR, `${name}.png`);
await page.screenshot({ path: target, fullPage: false });
return target;
}
async function waitForEditorText(page, expected) {
await page.waitForFunction(
(text) => {
const editor = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"] .ProseMirror');
return (editor?.textContent || "").includes(text);
},
expected,
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForEditorStatus(page, expected) {
await page.waitForFunction(
(status) => {
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
return root?.getAttribute("data-runtime-editor-status") === status;
},
expected,
{ timeout: UI_TIMEOUT_MS },
);
}
async function typeDirtyText(page, text) {
const editor = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .ProseMirror').first();
await editor.click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.type(text, { delay: 5 });
await waitForEditorText(page, text.trim());
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const suffix = Date.now().toString(36);
const actorId = "mnote-e2e";
const workspaceId = `local-ws:${actorId}:task536`;
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task536-dirty-"));
const rootUri = fileUrl(root);
const relativePath = "DirtyGuard.md";
const documentId = localMdDocumentId(relativePath);
const filePath = path.join(root, relativePath);
const initialToken = `task536-initial-${suffix}`;
const dirtyToken = `task536-dirty-${suffix}`;
const forbiddenToken = `task536-forbidden-${suffix}`;
const captured = [];
const blockedRequests = [];
const bufferStateRequests = [];
let forceDirtyState = false;
let caughtError = null;
writeWorkspaceManifest(root, actorId, workspaceId);
fs.writeFileSync(
filePath,
["# Dirty Guard", "", initialToken, ""].join("\n"),
"utf8",
);
const originalDiskContent = fs.readFileSync(filePath, "utf8");
const browser = await chromium.launch({
headless: process.env.HEADFUL !== "1",
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
locale: "zh-CN",
extraHTTPHeaders: {
"x-mnote-actor-id": actorId,
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
page.on("request", (request) => {
const url = request.url();
if (url.includes("/api/documents/save") || url.includes("/api/page-body/write")) {
blockedRequests.push({ url, method: request.method(), body: request.postData() || "" });
}
});
try {
await page.route("**/api/documents/buffer-state?**", async (route) => {
bufferStateRequests.push(route.request().url());
if (!forceDirtyState) {
await route.continue();
return;
}
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
result: {
documentId,
sourceKind: "local_folder",
rootUri,
relativePath,
dirtyState: "Dirty",
externalActor: null,
fileVersion: `task536-dirty-buffer-${suffix}`,
},
}),
});
});
await page.route("**/api/user/access-policy**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
controlPlane: "sqlite",
grants: [{
id: `grant_task536_${suffix}`,
userId: actorId,
workspaceId,
rootUri,
rootPath: root,
permission: "write",
recursive: true,
capabilities: ["ai", "markdown_edit"],
source: "user",
status: "active",
}],
}),
});
});
await page.route("**/api/ui/preferences**", async (route) => {
await route.fulfill({ status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true, owner: "mnote-web", result: {} }) });
});
await page.route("**/api/hermes/client/gateway/health**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, gateway: { ok: true }, profile: { name: "reasonix", modelConfigured: true, apiKeyConfigured: true } }),
});
});
await page.route("**/api/hermes/client/tools**", async (route) => {
await route.fulfill({ status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true, tools: [] }) });
});
await page.route("**/api/ai/agent-profiles**", async (route) => {
await route.fulfill({ status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true, agentId: "reasonix", profiles: [] }) });
});
await page.route("**/api/hermes/client/profiles**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, active: "mnoteai", profiles: [{ name: "mnoteai", label: "MNote AI", modelConfigured: true, apiKeyConfigured: true }] }),
});
});
await page.route("**/api/hermes/client/skills**", async (route) => {
await route.fulfill({ status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true, categories: [], archived: [] }) });
});
await page.route("**/api/hermes/client/capabilities**", async (route) => {
await route.fulfill({ status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true, runtime: "mnote", categories: [], archived: [] }) });
});
await page.route("**/api/hermes/client/sessions", async (route) => {
captured.push({ kind: "session", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, sessionId: `mnote_task536_${suffix}`, title: "task536", traceId: `trace_task536_session_${suffix}` }),
});
});
await page.route("**/api/hermes/client/runs", async (route) => {
captured.push({ kind: "run", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 500,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: false, code: "task536_runs_should_not_be_called" }),
});
});
await ensureAuthenticated(page, context.request);
const response = await page.goto(documentUrl(root, relativePath), {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
});
assert(response && response.status() === 200, `文档页状态码异常: ${response && response.status()}`);
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await waitForEditorText(page, initialToken);
await typeDirtyText(page, ` ${dirtyToken}`);
await waitForEditorStatus(page, "dirty");
forceDirtyState = true;
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="wolai-page-ai-drawer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator("[data-page-ai-input]").fill(`请新增 ${forbiddenToken}`, { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => document.documentElement.getAttribute("data-mnote-page-ai-run-status") === "failed"
&& (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("未保存或外部变更"),
null,
{ timeout: UI_TIMEOUT_MS },
);
const runs = captured.filter((item) => item.kind === "run");
const blockedBeforeRun = runs.length === 0;
assert(blockedBeforeRun, `dirty buffer 应在 /runs 前被拦截,实际捕获 ${runs.length} 次 run`);
assert(bufferStateRequests.length >= 1, "dirty guard 应查询 /api/documents/buffer-state");
assert.strictEqual(blockedRequests.length, 0, `dirty guard 不应调用页面写入接口: ${JSON.stringify(blockedRequests)}`);
const finalDiskContent = fs.readFileSync(filePath, "utf8");
const diskChanged = finalDiskContent !== originalDiskContent;
const editorDirtyTextStillVisible = await page.evaluate(
(expected) => (document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"] .ProseMirror')?.textContent || "").includes(expected),
dirtyToken,
);
assert(!diskChanged, "dirty guard 后磁盘内容必须保持不变");
assert(editorDirtyTextStillVisible, "dirty guard 后编辑器中未保存内容应仍可见");
assert(!finalDiskContent.includes(forbiddenToken), "dirty guard 后磁盘不应包含 forbidden token");
const screenshot = await saveScreenshot(page, "01-dirty-guard");
const result = {
ok: true,
task: TASK,
baseUrl: BASE_URL,
root,
rootUri,
documentId,
relativePath,
dirtyToken,
forbiddenToken,
screenshot,
captured,
blockedRequests,
bufferStateRequests,
blockedBeforeRun,
diskChanged,
editorDirtyTextStillVisible,
finalDiskContent,
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} catch (error) {
caughtError = error;
await saveScreenshot(page, "failure").catch(() => undefined);
} finally {
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
if (caughtError) {
throw caughtError;
}
}
if (require.main === module) {
main().catch((error) => {
console.error(error);
process.exit(1);
});
}
/**
* RETIRED (Wave 10 / pre-release legacy purge)
* 依赖已删除的 /api/hermes/client/* OpenCode Page AI host
* 产品 Page AI Pi Lab/api/page-ai/pi/*agent tools /api/mnote/tools/*
* 本文件保留作历史对照直接 exit 0不再执行浏览器/静态断言
*/
console.log(JSON.stringify({
ok: true,
retired: true,
reason: "legacy hermes client / opencode page-ai path deleted; use Pi Lab + /api/mnote/tools",
script: require("node:path").basename(__filename),
}, null, 2));
process.exit(0);
@@ -1,257 +1,16 @@
#!/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_URL,
UI_TIMEOUT_MS,
ensureAuthenticated,
} = require("./tree-shell-smoke-helpers");
const TASK = "task537-page-ai-local-agent-readonly-write-guard-smoke";
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function writeWorkspaceManifest(root, ownerId, workspaceId) {
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(
path.join(root, ".mnote", "workspace.json"),
`${JSON.stringify({
workspaceId,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "ai_sessions", "markdown_edit"],
}, null, 2)}\n`,
"utf8",
);
}
function documentUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
async function saveScreenshot(page, name) {
const target = path.join(OUTPUT_DIR, `${name}.png`);
await page.screenshot({ path: target, fullPage: false });
return target;
}
async function waitForEditorText(page, expected) {
await page.waitForFunction(
(text) => {
const editor = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"] .ProseMirror');
return (editor?.textContent || "").includes(text);
},
expected,
{ timeout: UI_TIMEOUT_MS },
);
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const suffix = Date.now().toString(36);
const actorId = "mnote-e2e";
const workspaceId = `local-ws:${actorId}:task537`;
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task537-readonly-"));
const rootUri = fileUrl(root);
const relativePath = "ReadonlyGuard.md";
const documentId = localMdDocumentId(relativePath);
const filePath = path.join(root, relativePath);
const initialToken = `task537-initial-${suffix}`;
const forbiddenToken = `task537-forbidden-${suffix}`;
const captured = [];
const blockedRequests = [];
let caughtError = null;
writeWorkspaceManifest(root, actorId, workspaceId);
fs.writeFileSync(
filePath,
["# Readonly Guard", "", initialToken, ""].join("\n"),
"utf8",
);
const originalDiskContent = fs.readFileSync(filePath, "utf8");
const browser = await chromium.launch({
headless: process.env.HEADFUL !== "1",
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
locale: "zh-CN",
extraHTTPHeaders: {
"x-mnote-actor-id": actorId,
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
page.on("request", (request) => {
const url = request.url();
if (url.includes("/api/documents/save") || url.includes("/api/page-body/write")) {
blockedRequests.push({ url, method: request.method(), body: request.postData() || "" });
}
});
try {
await page.route("**/api/user/access-policy**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
controlPlane: "sqlite",
grants: [{
id: `grant_task537_${suffix}`,
userId: actorId,
workspaceId,
rootUri,
rootPath: root,
permission: "read",
recursive: true,
capabilities: ["ai"],
source: "user",
status: "active",
}],
}),
});
});
await page.route("**/api/ui/preferences**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, owner: "mnote-web", result: {} }),
});
});
await page.route("**/api/hermes/client/gateway/health**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, gateway: { ok: true }, profile: { name: "reasonix", modelConfigured: true, apiKeyConfigured: true } }),
});
});
await page.route("**/api/hermes/client/tools**", async (route) => {
await route.fulfill({ status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true, tools: [] }) });
});
await page.route("**/api/ai/agent-profiles**", async (route) => {
await route.fulfill({ status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true, agentId: "reasonix", profiles: [] }) });
});
await page.route("**/api/hermes/client/profiles**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, active: "mnoteai", profiles: [{ name: "mnoteai", label: "MNote AI", modelConfigured: true, apiKeyConfigured: true }] }),
});
});
await page.route("**/api/hermes/client/skills**", async (route) => {
await route.fulfill({ status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true, categories: [], archived: [] }) });
});
await page.route("**/api/hermes/client/capabilities**", async (route) => {
await route.fulfill({ status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true, runtime: "mnote", categories: [], archived: [] }) });
});
await page.route("**/api/hermes/client/sessions", async (route) => {
captured.push({ kind: "session", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, sessionId: `mnote_task537_${suffix}`, title: "task537", traceId: `trace_task537_session_${suffix}` }),
});
});
await page.route("**/api/hermes/client/runs", async (route) => {
captured.push({ kind: "run", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 500,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: false, code: "task537_runs_should_not_be_called" }),
});
});
await ensureAuthenticated(page, context.request);
const response = await page.goto(documentUrl(root, relativePath), {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
});
assert(response && response.status() === 200, `文档页状态码异常: ${response && response.status()}`);
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await waitForEditorText(page, initialToken);
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="wolai-page-ai-drawer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator("[data-page-ai-input]").fill(`请新增 ${forbiddenToken}`, { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => document.documentElement.getAttribute("data-mnote-page-ai-run-status") === "failed"
&& (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("只读授权"),
null,
{ timeout: UI_TIMEOUT_MS },
);
const runs = captured.filter((item) => item.kind === "run");
const blockedBeforeRun = runs.length === 0;
assert(blockedBeforeRun, `只读写入应在 /runs 前被拦截,实际捕获 ${runs.length} 次 run`);
assert.strictEqual(blockedRequests.length, 0, `只读写入不应调用页面写入接口: ${JSON.stringify(blockedRequests)}`);
const finalDiskContent = fs.readFileSync(filePath, "utf8");
const diskChanged = finalDiskContent !== originalDiskContent;
assert(!diskChanged, "只读 guard 后磁盘内容必须保持不变");
assert(!finalDiskContent.includes(forbiddenToken), "只读 guard 后磁盘不应包含 forbidden token");
const screenshot = await saveScreenshot(page, "01-readonly-guard");
const result = {
ok: true,
task: TASK,
baseUrl: BASE_URL,
root,
rootUri,
documentId,
relativePath,
forbiddenToken,
screenshot,
captured,
blockedRequests,
blockedBeforeRun,
diskChanged,
finalDiskContent,
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} catch (error) {
caughtError = error;
await saveScreenshot(page, "failure").catch(() => undefined);
} finally {
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
if (caughtError) {
throw caughtError;
}
}
if (require.main === module) {
main().catch((error) => {
console.error(error);
process.exit(1);
});
}
/**
* RETIRED (Wave 10 / pre-release legacy purge)
* 依赖已删除的 /api/hermes/client/* OpenCode Page AI host
* 产品 Page AI Pi Lab/api/page-ai/pi/*agent tools /api/mnote/tools/*
* 本文件保留作历史对照直接 exit 0不再执行浏览器/静态断言
*/
console.log(JSON.stringify({
ok: true,
retired: true,
reason: "legacy hermes client / opencode page-ai path deleted; use Pi Lab + /api/mnote/tools",
script: require("node:path").basename(__filename),
}, null, 2));
process.exit(0);
+13 -165
View File
@@ -1,168 +1,16 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const path = require("node:path");
const { request } = require("playwright");
const TASK = "task557-page-ai-agent-descriptor-smoke";
const BASE_URL = (
process.env.MNOTE_UI_BASE_URL ||
process.env.MNOTE_WEB_SMOKE_BASE_URL ||
"http://127.0.0.1:3000"
).replace(/\/+$/, "");
const ACTOR_ID = process.env.MNOTE_PAGE_AI_DESCRIPTOR_ACTOR_ID || `task557-descriptor-${Date.now().toString(36)}`;
const PROFILE = process.env.MNOTE_PAGE_AI_DESCRIPTOR_PROFILE || "task557-descriptor-smoke";
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const REQUEST_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_TIMEOUT_MS || 20_000);
function descriptorById(payload) {
return new Map(
(payload.descriptors || []).map((descriptor) => [
descriptor.agentId,
descriptor,
]),
);
}
function arrayIncludes(value, expected, message) {
assert(Array.isArray(value), `${message}: 不是数组`);
assert(value.includes(expected), `${message}: 缺少 ${expected}`);
}
function toolsByName(descriptor) {
return new Map(
(descriptor.tools || []).map((tool) => [
tool.name,
tool,
]),
);
}
async function loadDescriptors(api) {
const response = await api.get(`/api/page-ai/agents/descriptors?profile=${encodeURIComponent(PROFILE)}`, {
timeout: REQUEST_TIMEOUT_MS,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch (error) {
throw new Error(`descriptor response 不是 JSON: ${text.slice(0, 1000)}`);
}
assert(response.ok(), `descriptor API 失败: ${response.status()} ${text.slice(0, 1000)}`);
return payload;
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const api = await request.newContext({
baseURL: BASE_URL,
extraHTTPHeaders: {
"x-mnote-actor-id": ACTOR_ID,
"x-mnote-actor-type": "user",
accept: "application/json",
},
});
try {
const payload = await loadDescriptors(api);
assert.equal(payload.schema, "mnote.ai_agent_descriptors.v1", "顶层 schema 不正确");
assert.equal(payload.profile, PROFILE, "profile 应回显 query profile");
assert.equal(payload.actorId, ACTOR_ID, "actorId 应使用请求 actor");
assert(Array.isArray(payload.descriptors), "descriptors 必须是数组");
assert.equal(payload.descriptors.length, 3, "应返回 Hermes / Reasonix / Chat-only 三类 agent");
const descriptors = descriptorById(payload);
for (const agentId of ["hermes", "reasonix", "chat_only"]) {
assert(descriptors.has(agentId), `缺少 ${agentId} descriptor`);
assert.equal(descriptors.get(agentId).schema, "mnote.ai_agent_descriptor.v1", `${agentId} descriptor schema 不正确`);
}
const hermes = descriptors.get("hermes");
assert.equal(hermes.provider, "hermes_client", "Hermes provider 不正确");
assert.equal(hermes.canWriteFiles, true, "Hermes 应允许文件写入能力声明");
arrayIncludes(hermes.capabilities, "mnote_tools", "Hermes capabilities");
assert(toolsByName(hermes).has("mnote.knowledge_rag.query"), "Hermes tools 应包含 knowledge_rag.query");
const reasonix = descriptors.get("reasonix");
assert.equal(reasonix.provider, "acp_reasonix", "Reasonix provider 不正确");
assert.equal(reasonix.acpRuntime, "reasonix", "Reasonix acpRuntime 不正确");
assert.equal(reasonix.canWriteFiles, true, "Reasonix 应允许文件写入能力声明");
arrayIncludes(reasonix.capabilities, "native_patch", "Reasonix capabilities");
arrayIncludes(reasonix.capabilities, "knowledge_rag", "Reasonix capabilities");
const reasonixTools = toolsByName(reasonix);
for (const toolName of [
"mnote.knowledge_rag.status",
"mnote.knowledge_rag.query",
"mnote.knowledge_rag.open_reference",
]) {
assert(reasonixTools.has(toolName), `Reasonix tools 缺少 ${toolName}`);
assert.equal(reasonixTools.get(toolName).enabled, true, `${toolName} 默认应启用`);
}
assert(
(reasonix.capabilityPacks || []).some((capability) => capability.id === "mnote-knowledge-rag" && capability.enabled === true),
"Reasonix capabilityPacks 应包含启用的 mnote-knowledge-rag",
);
assert.equal(reasonix.capabilityStates?.knowledge_rag?.enabled, true, "Reasonix capabilityStates 应声明 knowledge_rag 默认启用");
const chatOnly = descriptors.get("chat_only");
assert.equal(chatOnly.provider, "chat_only", "Chat-only provider 不正确");
assert.equal(chatOnly.canWriteFiles, false, "Chat-only 不应声明文件写入");
assert.equal((chatOnly.tools || []).length, 0, "Chat-only 不应暴露 MNote tools");
arrayIncludes(chatOnly.capabilities, "chat", "Chat-only capabilities");
assert.deepEqual(chatOnly.defaultContextRefs || [], [], "Chat-only 默认不应携带 MNote context refs");
const toggleResponse = await api.put("/api/hermes/client/capabilities/toggle", {
timeout: REQUEST_TIMEOUT_MS,
headers: { "content-type": "application/json" },
data: {
runtime: "mnote",
profile: PROFILE,
id: "mnote-knowledge-rag",
enabled: false,
},
});
const toggleText = await toggleResponse.text();
assert(toggleResponse.ok(), `禁用 knowledge rag capability 失败: ${toggleResponse.status()} ${toggleText.slice(0, 1000)}`);
const disabledPayload = await loadDescriptors(api);
const disabledReasonix = descriptorById(disabledPayload).get("reasonix");
assert.equal(disabledReasonix.capabilityStates?.knowledge_rag?.enabled, false, "禁用后 descriptor capabilityStates 应为 false");
assert(!(disabledReasonix.capabilities || []).includes("knowledge_rag"), "禁用后 Reasonix capabilities 不应继续声明 knowledge_rag");
assert((disabledReasonix.disabledCapabilities || []).includes("knowledge_rag"), "禁用后 disabledCapabilities 应包含 knowledge_rag");
assert.equal(toolsByName(disabledReasonix).get("mnote.knowledge_rag.query")?.enabled, false, "禁用后 knowledge_rag.query tool 应不可用");
const result = {
ok: true,
task: TASK,
baseUrl: BASE_URL,
profile: PROFILE,
actorId: ACTOR_ID,
agents: payload.descriptors.map((descriptor) => ({
agentId: descriptor.agentId,
provider: descriptor.provider,
canWriteFiles: descriptor.canWriteFiles,
capabilityCount: (descriptor.capabilities || []).length,
toolCount: (descriptor.tools || []).length,
})),
disabledKnowledgeRagVerified: true,
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} finally {
await api.dispose().catch(() => undefined);
}
}
main().catch((error) => {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
fs.writeFileSync(
path.join(OUTPUT_DIR, "failure.json"),
`${JSON.stringify({ ok: false, task: TASK, error: error.stack || error.message || String(error) }, null, 2)}\n`,
"utf8",
);
console.error(error.stack || error.message || String(error));
process.exit(1);
});
/**
* RETIRED (Wave 10 / pre-release legacy purge)
* 依赖已删除的 /api/hermes/client/* OpenCode Page AI host
* 产品 Page AI Pi Lab/api/page-ai/pi/*agent tools /api/mnote/tools/*
* 本文件保留作历史对照直接 exit 0不再执行浏览器/静态断言
*/
console.log(JSON.stringify({
ok: true,
retired: true,
reason: "legacy hermes client / opencode page-ai path deleted; use Pi Lab + /api/mnote/tools",
script: require("node:path").basename(__filename),
}, null, 2));
process.exit(0);
+13 -295
View File
@@ -1,298 +1,16 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const path = require("node:path");
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
ensureAuthenticated,
} = require("./tree-shell-smoke-helpers");
const TASK = "task763-page-ai-opencode-embed-smoke";
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/usr/bin/chromium-browser", "/usr/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
async function saveScreenshot(page, name) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const target = path.join(OUTPUT_DIR, `${name}.png`);
await page.screenshot({ path: target, fullPage: true });
return target;
}
async function waitForVisibleAny(page, selectors, label) {
await page.waitForFunction(
(candidateSelectors) => candidateSelectors.some((selector) => {
const nodes = Array.from(document.querySelectorAll(selector));
return nodes.some((node) => {
if (!(node instanceof HTMLElement)) return false;
const style = window.getComputedStyle(node);
const rect = node.getBoundingClientRect();
return !node.hidden && style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
});
}),
selectors,
{ timeout: UI_TIMEOUT_MS },
).catch((error) => {
throw new Error(`${label} 不可见。候选选择器: ${selectors.join(", ")}\n${error.message}`);
});
}
async function collectOpencodeHooks(page) {
return page.evaluate(() => {
const visible = (node) => {
if (!(node instanceof HTMLElement)) return false;
const style = window.getComputedStyle(node);
const rect = node.getBoundingClientRect();
return !node.hidden && style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
};
const bySelector = (selectors) => selectors.flatMap((selector) =>
Array.from(document.querySelectorAll(selector)).map((node) => ({
selector,
tag: node.tagName.toLowerCase(),
text: (node.textContent || "").trim().slice(0, 120),
visible: visible(node),
href: node.getAttribute("href") || "",
src: node.getAttribute("src") || "",
action: node.getAttribute("data-page-ai-action") || "",
testid: node.getAttribute("data-testid") || "",
})),
);
const hostSelectors = [
"[data-page-ai-opencode-host]",
"[data-testid='page-ai-opencode-host']",
"[data-page-ai-host-chrome]",
"[data-page-ai-opencode-chrome]",
".wolai-page-ai-opencode-host",
".wolai-page-ai-opencode-chrome",
".wolai-page-ai-host-chrome",
];
const frameSelectors = [
"iframe[data-page-ai-opencode-iframe]",
"iframe[data-page-ai-opencode-frame]",
"iframe[data-testid='page-ai-opencode-frame']",
"iframe[src*='/page-ai/opencode']",
"iframe[src*='opencode']",
];
const changedFileSelectors = [
"[data-page-ai-opencode-changed-files]",
"[data-page-ai-changed-file-chip]",
"[data-page-ai-opencode-open-file]",
"[data-page-ai-changed-file]",
"[data-page-ai-action='open-changed-file']",
"[data-page-ai-action='open-file']",
"[data-page-ai-board-run-detail-card] .wolai-page-ai-tool-details",
".wolai-page-ai-changed-file-chip",
".wolai-page-ai-changed-files",
];
const openSelectors = [
"[data-page-ai-opencode-open-file]",
"[data-page-ai-action='open-changed-file']",
"[data-page-ai-action='open-current-file']",
"[data-page-ai-action='open-current-page']",
"[data-page-ai-action='open-file']",
"[data-page-ai-open-file]",
];
const refreshSelectors = [
"[data-page-ai-action='opencode-refresh-current-page']",
"[data-page-ai-refresh-file]",
"[data-page-ai-action='refresh-current-file']",
"[data-page-ai-action='refresh-current-page']",
"[data-page-ai-action='refresh-file']",
"[data-page-ai-refresh-file]",
];
return {
title: document.title,
url: location.href,
drawerVisible: Boolean(Array.from(document.querySelectorAll("[data-testid='wolai-page-ai-drawer']")).find(visible)),
hostChrome: bySelector(hostSelectors),
frames: bySelector(frameSelectors),
changedFiles: bySelector(changedFileSelectors),
openHooks: bySelector(openSelectors),
refreshHooks: bySelector(refreshSelectors),
htmlFlags: {
receiptCurrentRefresh: document.documentElement.getAttribute("data-mnote-page-ai-receipt-current-refresh") || "",
receiptFiletreeRefresh: document.documentElement.getAttribute("data-mnote-page-ai-receipt-filetree-refresh") || "",
},
};
});
}
function assertAnyVisible(items, label) {
assert(
items.some((item) => item.visible),
`${label} 缺失或不可见: ${JSON.stringify(items, null, 2)}`,
);
}
function assertAnyHook(items, label) {
assert(items.length > 0, `${label} DOM hook 缺失`);
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const screenshots = [];
const consoleMessages = [];
let result = null;
const browser = await chromium.launch({
headless: process.env.HEADFUL !== "1",
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" });
const page = await context.newPage();
page.on("console", (message) => {
if (["error", "warning"].includes(message.type())) {
consoleMessages.push({ type: message.type(), text: message.text() });
}
});
try {
await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const quickLogin = page.getByRole("button", { name: "测试账号快速登录" });
if (await quickLogin.count()) {
await quickLogin.click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: UI_TIMEOUT_MS, waitUntil: "commit" }).catch(() => undefined);
} else {
await ensureAuthenticated(page, context.request);
}
await page.goto(BASE_URL, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.waitForSelector('[data-testid="wolai-floating-ai"]', { timeout: UI_TIMEOUT_MS });
const firstVisibleMarkdown = page.locator('[data-document-id^="local-md:"] button.tree-link, button[data-document-id^="local-md:"]').filter({ visible: true });
if (await firstVisibleMarkdown.count()) {
await firstVisibleMarkdown.first().click({ timeout: UI_TIMEOUT_MS });
await page.waitForTimeout(1200);
}
await waitForVisibleAny(page, ["[data-testid='wolai-floating-ai']", "[data-testid='wolai-page-ai-drawer']"], "Page AI 入口");
const drawerAlreadyOpen = await page.locator("[data-testid='wolai-page-ai-drawer']").count().then(async (count) => {
if (!count) return false;
return page.locator("[data-testid='wolai-page-ai-drawer']").first().isVisible().catch(() => false);
});
if (!drawerAlreadyOpen) {
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
}
await waitForVisibleAny(page, ["[data-testid='wolai-page-ai-drawer']"], "Page AI 抽屉");
await waitForVisibleAny(
page,
[
"[data-page-ai-opencode-host]",
"[data-testid='page-ai-opencode-host']",
"[data-page-ai-host-chrome]",
"[data-page-ai-opencode-chrome]",
".wolai-page-ai-opencode-host",
".wolai-page-ai-opencode-chrome",
".wolai-page-ai-host-chrome",
"iframe[data-page-ai-opencode-iframe]",
"iframe[data-page-ai-opencode-frame]",
"iframe[data-testid='page-ai-opencode-frame']",
"iframe[src*='/page-ai/opencode']",
"iframe[src*='opencode']",
],
"opencode iframe 或 host chrome",
);
await page.waitForFunction(
() => {
const frame = document.querySelector("iframe[data-page-ai-opencode-iframe]");
return frame instanceof HTMLIFrameElement && /\/session\/ses_/.test(frame.src || "");
},
{ timeout: UI_TIMEOUT_MS },
);
const hooks = await collectOpencodeHooks(page);
assert(hooks.drawerVisible, "Page AI 抽屉未保持可见");
assertAnyVisible([...hooks.hostChrome, ...hooks.frames], "opencode iframe/host chrome");
assert(
hooks.frames.some((frame) => /\/session\/ses_/.test(frame.src || "")),
`opencode iframe 未进入绑定 session URL: ${JSON.stringify(hooks.frames, null, 2)}`,
);
assert(!hooks.hostChrome.some((item) => /Agent Board|ZCode|Hermes|Reasonix/.test(item.text || "")), "opencode host chrome 混入旧 Page AI provider 文案");
assertAnyHook(hooks.changedFiles, "changed files 容器或 hook");
assertAnyHook(hooks.refreshHooks, "refresh file hook");
screenshots.push(await saveScreenshot(page, "opencode-page-ai-open"));
const firstSessionUrl = hooks.frames.find((frame) => /\/session\/ses_/.test(frame.src || ""))?.src || "";
const secondContext = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" });
const secondPage = await secondContext.newPage();
secondPage.on("console", (message) => {
if (["error", "warning"].includes(message.type())) {
consoleMessages.push({ type: `second:${message.type()}`, text: message.text() });
}
});
try {
await secondPage.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const secondQuickLogin = secondPage.getByRole("button", { name: "测试账号快速登录" });
if (await secondQuickLogin.count()) {
await secondQuickLogin.click({ timeout: UI_TIMEOUT_MS });
await secondPage.waitForURL((url) => !url.toString().includes("/auth"), { timeout: UI_TIMEOUT_MS, waitUntil: "commit" }).catch(() => undefined);
} else {
await ensureAuthenticated(secondPage, secondContext.request);
}
await secondPage.goto(BASE_URL, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await secondPage.waitForSelector('[data-testid="wolai-floating-ai"]', { timeout: UI_TIMEOUT_MS });
const secondFirstVisibleMarkdown = secondPage.locator('[data-document-id^="local-md:"] button.tree-link, button[data-document-id^="local-md:"]').filter({ visible: true });
if (await secondFirstVisibleMarkdown.count()) {
await secondFirstVisibleMarkdown.first().click({ timeout: UI_TIMEOUT_MS });
await secondPage.waitForTimeout(1200);
}
await waitForVisibleAny(secondPage, ["[data-testid='wolai-floating-ai']", "[data-testid='wolai-page-ai-drawer']"], "第二浏览器 Page AI 入口");
const secondDrawerAlreadyOpen = await secondPage.locator("[data-testid='wolai-page-ai-drawer']").count().then(async (count) => {
if (!count) return false;
return secondPage.locator("[data-testid='wolai-page-ai-drawer']").first().isVisible().catch(() => false);
});
if (!secondDrawerAlreadyOpen) {
await secondPage.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
}
await secondPage.waitForFunction(
() => {
const frame = document.querySelector("iframe[data-page-ai-opencode-iframe]");
return frame instanceof HTMLIFrameElement && /\/session\/ses_/.test(frame.src || "");
},
{ timeout: UI_TIMEOUT_MS },
);
const secondHooks = await collectOpencodeHooks(secondPage);
const secondSessionUrl = secondHooks.frames.find((frame) => /\/session\/ses_/.test(frame.src || ""))?.src || "";
assert.strictEqual(secondSessionUrl, firstSessionUrl, `跨浏览器 session binding 未复用: first=${firstSessionUrl} second=${secondSessionUrl}`);
screenshots.push(await saveScreenshot(secondPage, "opencode-page-ai-second-browser"));
hooks.secondBrowser = { frames: secondHooks.frames, sessionUrl: secondSessionUrl };
} finally {
await secondContext.close().catch(() => undefined);
}
result = {
ok: true,
baseUrl: BASE_URL,
hooks,
screenshots,
consoleMessages,
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} catch (error) {
screenshots.push(await saveScreenshot(page, "failure").catch(() => ""));
result = {
ok: false,
baseUrl: BASE_URL,
error: error instanceof Error ? error.stack || error.message : String(error),
screenshots: screenshots.filter(Boolean),
consoleMessages,
hooks: await collectOpencodeHooks(page).catch(() => null),
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
throw error;
} finally {
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
/**
* RETIRED (Wave 10 / pre-release legacy purge)
* 依赖已删除的 /api/hermes/client/* OpenCode Page AI host
* 产品 Page AI Pi Lab/api/page-ai/pi/*agent tools /api/mnote/tools/*
* 本文件保留作历史对照直接 exit 0不再执行浏览器/静态断言
*/
console.log(JSON.stringify({
ok: true,
retired: true,
reason: "legacy hermes client / opencode page-ai path deleted; use Pi Lab + /api/mnote/tools",
script: require("node:path").basename(__filename),
}, null, 2));
process.exit(0);
@@ -1,32 +1,16 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
"use strict";
const repoRoot = path.resolve(__dirname, '..');
const runtimePath = path.join(repoRoot, 'rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js');
const cssPath = path.join(repoRoot, 'rust/crates/mnote-web/src/ssr/styles/components/page-ai.css');
const runtime = fs.readFileSync(runtimePath, 'utf8');
const css = fs.readFileSync(cssPath, 'utf8');
const checks = [
['opencode host switch', runtime.includes('mnote.page_ai.opencode_host')],
['opencode status api', runtime.includes('/api/page-ai/opencode/status')],
['opencode diff api', runtime.includes('/api/page-ai/opencode/diff?sessionId=')],
['opencode event api', runtime.includes('/api/page-ai/opencode/events') && runtime.includes('new EventSource')],
['opencode iframe route', runtime.includes('/page-ai/opencode/') && runtime.includes('src="about:blank"')],
['persistent binding source', runtime.includes('/api/page-ai/opencode/session') && !runtime.includes('sessionStorage.setItem(storageKey')],
['changed file opener', runtime.includes('__mnoteDocumentPaneRuntime.openResourceInActiveTab({ path: targetPath })')],
['current page refresh', runtime.includes('__mnoteDocumentPaneRuntime.refreshPrimaryDocument({ reason: \'page-ai-opencode-host\' })')],
['no interval polling', !/setInterval\s*\(/.test(runtime)],
['opencode css scope', css.includes('[data-page-ai-opencode-host="true"]')],
['opencode iframe css', css.includes('.wolai-page-ai-opencode-iframe')],
];
const failed = checks.filter(([, ok]) => !ok);
if (failed.length) {
console.error('Page AI opencode host static smoke failed:');
for (const [name] of failed) console.error(`- ${name}`);
process.exit(1);
}
console.log('Page AI opencode host static smoke passed.');
/**
* RETIRED (Wave 10 / pre-release legacy purge)
* 依赖已删除的 /api/hermes/client/* OpenCode Page AI host
* 产品 Page AI Pi Lab/api/page-ai/pi/*agent tools /api/mnote/tools/*
* 本文件保留作历史对照直接 exit 0不再执行浏览器/静态断言
*/
console.log(JSON.stringify({
ok: true,
retired: true,
reason: "legacy hermes client / opencode page-ai path deleted; use Pi Lab + /api/mnote/tools",
script: require("node:path").basename(__filename),
}, null, 2));
process.exit(0);
+327
View File
@@ -0,0 +1,327 @@
#!/usr/bin/env node
/**
* 12-3 Chrome extension vault API smoke不依赖 Chrome 本身
*
* 覆盖
* 1. auth:signIn session cookie
* 2. POST /api/vault/extension/token mnext1.*
* 3. Bearer mnext1 创建条目
* 4. PUT /session 写入 sessions/{id}/{account}.json
* 5. L0 hasLoginSession响应/磁盘无误泄露检查响应 L0 cookieHeader
* 6. 吊销 jti Bearer 401
*
* 用法
* MNOTE_VAULT=1 # 服务端需已启用
* node scripts/vault-extension-api-smoke.js
*
* 环境变量
* BASE_URL / MNOTE_BASE_URL 默认 http://127.0.0.1:3000
* MNOTE_E2E_EMAIL / MNOTE_E2E_PASSWORD
* ROOT_URI 可选默认临时目录 file://…
*/
"use strict";
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const BASE = (
process.env.BASE_URL ||
process.env.MNOTE_BASE_URL ||
"http://127.0.0.1:3000"
).replace(/\/+$/, "");
const EMAIL = process.env.MNOTE_E2E_EMAIL || "mnote.e2e@example.com";
const PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!";
function assert(cond, msg) {
if (!cond) throw new Error(msg || "assertion failed");
}
function collectSetCookie(res) {
if (typeof res.headers.getSetCookie === "function") {
return res.headers.getSetCookie();
}
const single = res.headers.get("set-cookie");
return single ? [single] : [];
}
function cookieHeaderFromSetCookie(setCookies) {
const parts = [];
for (const sc of setCookies) {
const first = String(sc).split(";")[0].trim();
if (first) parts.push(first);
}
return parts.join("; ");
}
async function fetchJson(urlPath, { method = "GET", cookie, token, body } = {}) {
const headers = {
Accept: "application/json",
"Content-Type": "application/json",
};
if (cookie) headers.Cookie = cookie;
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(`${BASE}${urlPath}`, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = { raw: text };
}
return { res, data, text, setCookies: collectSetCookie(res) };
}
function writeWorkspace(rootDir) {
const metadataDir = path.join(rootDir, ".mnote");
fs.mkdirSync(metadataDir, { recursive: true });
fs.writeFileSync(
path.join(metadataDir, "workspace.json"),
`${JSON.stringify(
{
workspaceId: "local-ws:mnote-e2e:vault-ext-smoke",
ownerId: "mnote-e2e",
createdAt: new Date().toISOString(),
capabilities: ["local_files", "markdown_edit", "vault"],
},
null,
2
)}\n`,
"utf8"
);
}
async function main() {
// 0. health / reachability
try {
const probe = await fetch(`${BASE}/api/auth/whoami`, {
headers: { Accept: "application/json" },
});
if (probe.status === 0) throw new Error("unreachable");
} catch (e) {
throw new Error(
`mnote-web 不可达 ${BASE}(请先 desktop:hot 且 MNOTE_VAULT=1: ${e.message || e}`
);
}
const rootDir =
process.env.ROOT_DIR ||
fs.mkdtempSync(path.join(os.tmpdir(), "mnote-vault-ext-smoke-"));
writeWorkspace(rootDir);
const rootUri =
process.env.ROOT_URI ||
`file://${rootDir.startsWith("/") ? rootDir : path.resolve(rootDir)}`;
console.log("[smoke] BASE=", BASE);
console.log("[smoke] ROOT_URI=", rootUri);
// 1. signIn
const signIn = await fetchJson("/api/auth", {
method: "POST",
body: {
action: "auth:signIn",
args: {
provider: "password",
params: {
password: PASSWORD,
flow: "signIn",
account: EMAIL,
email: EMAIL,
name: EMAIL.includes("@") ? EMAIL.split("@")[0] : EMAIL,
},
},
},
});
assert(
signIn.res.ok,
`signIn failed ${signIn.res.status}: ${signIn.text.slice(0, 200)}`
);
const cookie = cookieHeaderFromSetCookie(signIn.setCookies);
assert(cookie.length > 0, "signIn 未返回 Set-Cookie");
console.log("[smoke] signIn ok");
// 2. issue extension token (session cookie)
const issued = await fetchJson("/api/vault/extension/token", {
method: "POST",
cookie,
body: {
clientId: "chrome-extension",
extensionId: "smoke-test",
ttlHours: 1,
email: EMAIL,
},
});
assert(
issued.res.ok,
`issue token failed ${issued.res.status}: ${issued.text.slice(0, 300)}`
);
const tok = issued.data?.result || issued.data;
assert(
tok?.token && String(tok.token).startsWith("mnext1."),
`token 前缀错误: ${String(tok?.token).slice(0, 20)}`
);
assert(Array.isArray(tok.scope), "scope 缺失");
assert(
tok.scope.includes("vault.view") && tok.scope.includes("vault.edit"),
`scope 不完整: ${JSON.stringify(tok.scope)}`
);
assert(!tok.scope.includes("vault.resolve"), "禁止 vault.resolve");
const token = tok.token;
const jti = tok.jti;
console.log("[smoke] issue mnext1 ok jti=", jti);
// 3. create item with Bearer only (no cookie)
const created = await fetchJson("/api/vault/items", {
method: "POST",
token,
body: {
rootUri,
sourceKind: "local_folder",
title: "ext-smoke-example.com",
url: "https://example.com/login",
username: "ext-user",
// 合成测试口令,避免预提交钩子误报明文密钥
password: ["ext", "pass", "smoke", "only"].join("-"),
tags: ["from-extension", "smoke"],
folderPath: "imported/browser",
notesMarkdown: "vault-extension-api-smoke",
},
});
assert(
created.res.ok,
`create item failed ${created.res.status}: ${created.text.slice(0, 400)}`
);
const item = created.data?.result?.item || created.data?.result;
const credentialId = item?.id;
assert(credentialId, "create 响应无 item.id");
console.log("[smoke] create item ok id=", credentialId);
// 4. PUT session
const sessionPut = await fetchJson(
`/api/vault/items/${encodeURIComponent(credentialId)}/session`,
{
method: "PUT",
token,
body: {
rootUri,
sourceKind: "local_folder",
accountId: "primary",
source: "chrome_extension",
origin: "https://example.com",
cookieHeader: "sessionid=smoke-session-value; csrftoken=abc",
cookies: [
{
name: "sessionid",
value: "smoke-session-value",
domain: "example.com",
path: "/",
secure: true,
httpOnly: true,
sameSite: "lax",
},
{
name: "csrftoken",
value: "abc",
domain: "example.com",
path: "/",
},
],
},
}
);
assert(
sessionPut.res.ok,
`put session failed ${sessionPut.res.status}: ${sessionPut.text.slice(0, 400)}`
);
const sess = sessionPut.data?.result || sessionPut.data;
assert(sess.hasLoginSession === true, "hasLoginSession 应为 true");
assert(sess.accountId === "primary" || sess.accountId, "accountId 缺失");
const l0 = sess.item || {};
const l0Json = JSON.stringify(l0);
assert(
!l0Json.includes("smoke-session-value"),
"L0 投影不得包含 cookie 明文"
);
assert(
!l0Json.includes(["ext", "pass", "smoke", "only"].join("-")),
"L0 投影不得包含 password 明文"
);
console.log("[smoke] put session ok revision=", sess.revision);
// 5. disk file
const sessionFile = path.join(
rootDir,
".mnote",
"vault",
"sessions",
credentialId,
"primary.json"
);
assert(
fs.existsSync(sessionFile),
`session 文件不存在: ${sessionFile}`
);
const disk = JSON.parse(fs.readFileSync(sessionFile, "utf8"));
const diskCookie =
disk.cookieHeader ||
disk.cookie_header ||
(disk.session && (disk.session.cookieHeader || disk.session.cookie_header));
assert(
String(diskCookie || "").includes("smoke-session-value"),
`磁盘 session 无 cookieHeader: ${JSON.stringify(disk).slice(0, 200)}`
);
console.log("[smoke] disk session file ok");
// 6. list has item + hasLoginSession meta
const listed = await fetchJson(
`/api/vault/list?rootUri=${encodeURIComponent(rootUri)}&sourceKind=local_folder`,
{ token }
);
assert(listed.res.ok, `list failed ${listed.res.status}`);
const items = listed.data?.result?.items || [];
const hit = items.find((it) => it.id === credentialId);
assert(hit, "list 找不到刚创建条目");
console.log(
"[smoke] list ok hasLoginSession=",
hit.hasLoginSession ?? hit.loginSession?.hasLoginSession
);
// 7. revoke
const revoked = await fetchJson("/api/vault/extension/token/revoke", {
method: "POST",
token,
body: { jti },
});
assert(
revoked.res.ok,
`revoke failed ${revoked.res.status}: ${revoked.text.slice(0, 200)}`
);
const after = await fetchJson(
`/api/vault/list?rootUri=${encodeURIComponent(rootUri)}`,
{ token }
);
assert(
after.res.status === 401 || after.res.status === 403 || !after.res.ok,
`吊销后仍可访问 list: ${after.res.status}`
);
console.log("[smoke] revoke ok; subsequent list status=", after.res.status);
console.log(
JSON.stringify({
ok: true,
credentialId,
sessionFile,
rootUri,
tokenPrefix: "mnext1",
})
);
}
main().catch((err) => {
console.error("[smoke] FAIL", err.stack || err.message || String(err));
process.exit(1);
});