feat: EditorRuntimeActor - 三层缓存/delta/事件架构

Phase A — EditorRuntimeActor 内存缓存层
- 新增 editor_actor.rs: EditorBlockDocument 内存态 + apply_command + load_or_init
- block.rs 四个写工具(replace/insert/delete/move)接入 actor 路径
- editor_actor feature flag(MNOTE_WEB_ENABLE_EDITOR_ACTOR=true 默认开启)
- bridge-runtime 三个核心函数公开化
- rust-toolchain: 1.89 → stable(修复 spike WASM 编译阻塞)

Phase B — 编辑器增量 delta channel
- BlockDelta/DeltaOperation 类型 + actor.build_block_delta()
- leptos-tiptap spike: mnote:editor:block-delta CustomEvent 监听 + JSON patch
- DocumentAiAgentPanel: 拦截 blockDelta → window dispatchEvent
- 工具响应含 blockDelta 字段供前端消费

Phase C — 事件 stream delta
- broadcast channel 在 AppState/actor/SSE 三层贯通
- tree_events SSE 端点发 block.delta 事件
- 旧客户端降级兼容

环境修复
- rustc recursion_limit = 1024(修复 Leptos SSR 类型深度溢出)
- run-convex-deploy.js(封装 Convex function 部署到本地后端 3210)

ref: design/07-ai/process/7-13-page-block-editor-runtime-actor-v1.md
This commit is contained in:
lix-2026
2026-05-16 22:03:30 +08:00
parent d8bfaea306
commit f292c6710a
101 changed files with 13618 additions and 2416 deletions
+2 -2
View File
@@ -60,7 +60,7 @@
- `task129-wolai-aline-baseline-smoke.js`
- `task130``task137` 一系列 `wolai-aline-*`
- `task160-wolai-page-settings-shell-smoke.js`
- `task161-wolai-page-ai-shell-smoke.js`
- 页面 AI 旧 `/api/ai-agent/run` 视觉 smoke 已移入本机 gitignored `recycle/scripts/retired-ai-agent-run-smokes/`;当前页面 AI 验证优先使用 Hermes 相关 smoke 与 `task-page-block-ai-tools-smoke.js`
这一层最重要的原则不是“文案命中”,而是:
@@ -122,7 +122,7 @@ node scripts/task097-homepage-entry-smoke.js
- document shell / island`task110``task121`
- 页面设置:`task160``task164-page-options-visible-effect-smoke.js`
- 双栏:`task165`
- AI`task155``task156``task161``task162`
- AI优先使用 `task-hermes-page-ai-retirement-guard.js``task-page-block-ai-tools-smoke.js` 和当前 Hermes 页面 AI smoke`task155``task156``task161` 等旧 `/api/ai-agent/run` smoke 只保留在本机 gitignored `recycle/scripts/retired-ai-agent-run-smokes/` 做历史对照
原则:
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env node
const { spawn } = require("child_process");
process.env.CONVEX_TMPDIR = "/mnt/Data1T/mnote/.convex-tmp";
const adminKey = "mnote-local|01cedce68c51e168c6aacb282a90f7d233f56eddfabb07944a1d9dd9506f73ba888fc7daff";
const args = [
"deploy",
"--url", "http://127.0.0.1:3210", // Backend port, NOT HTTP actions (3211)
"--admin-key", adminKey,
"--typecheck", "disable",
"--codegen", "disable",
];
const child = spawn("npx", ["convex", ...args], {
cwd: "/mnt/Data1T/mnote/wolai-frontend",
stdio: "inherit",
env: { ...process.env, CONVEX_TMPDIR: "/mnt/Data1T/mnote/.convex-tmp" },
});
child.on("close", (code) => {
process.exit(code || 0);
});
+216
View File
@@ -0,0 +1,216 @@
#!/usr/bin/env node
"use strict";
/**
* task-block-delta-smoke.js
*
* 验证 Phase C — 事件 stream delta
* - 写工具执行后,/api/tree/events SSE 中收到 "block.delta" 事件
* - block.delta 包含 documentId / revision / operations
* - 旧客户端兼容:不识别 block.delta 的 consumer 不崩溃
*
* 前提:运行中的 mnote-web (3000)、测试文档
*/
const assert = require("node:assert");
const fs = require("node:fs/promises");
const path = require("node:path");
const { chromium } = require("playwright");
const {
BASE_URL,
createTempDocument,
cleanupDocuments,
requestJson,
} = require("./tree-shell-smoke-helpers");
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", {
method: "POST",
headers: { "x-mnote-actor-id": payload.actorId || "smoke-user" },
data: payload,
});
}
async function main() {
await fs.mkdir(OUT_DIR, { recursive: true });
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
const page = await context.newPage();
const request = page.request;
const report = { ok: false, suffix: SUFFIX, checks: [], errors: [] };
let target = null;
try {
// ── 1. 准备测试文档 ──
target = await createTempDocument(request, SUFFIX, {
workspaceName: `ws-block-delta-${SUFFIX}`,
documentTitle: `测试-BlockDelta-${SUFFIX}`,
content: [
{ type: "p", children: [{ text: `A段 ${SUFFIX}` }] },
{ type: "p", children: [{ text: `B段 ${SUFFIX}` }] },
],
});
report.documentId = target.documentId;
report.workspaceId = target.workspaceId;
console.log(`文档创建: ${target.documentId}`);
// ── 2. 订阅 SSE,监听 block.delta ──
const sseUrl = `/api/tree/events?workspaceId=${target.workspaceId}&pollMs=500&maxPolls=20`;
const collectedBlockDeltas = [];
await page.goto(BASE_URL); // 确保页面打开
const sseReceived = await page.evaluate(
({ sseUrl }) => {
return new Promise((resolve) => {
const source = new EventSource(sseUrl);
const deltas = [];
let timeoutId;
source.addEventListener("block.delta", (event) => {
try {
deltas.push(JSON.parse(event.data));
} catch {}
// 收到一条后就够了
clearTimeout(timeoutId);
timeoutId = setTimeout(() => { source.close(); resolve(deltas); }, 3000);
});
source.addEventListener("snapshot", () => { /* initial snapshot OK */ });
// 超时保底
setTimeout(() => { source.close(); resolve(deltas); }, 15000);
});
},
{ sseUrl },
);
collectedBlockDeltas.push(...sseReceived);
// ── 3. 调用 block.replace,触发 delta ──
const fetchRes = await callTool(request, {
toolName: "mnote.doc.fetch",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId: "smoke-user",
sessionId: `sess_${SUFFIX}`,
runId: `run_fetch_${SUFFIX}`,
toolCallId: `call_fetch_${SUFFIX}`,
traceId: `trace_fetch_${SUFFIX}`,
capabilityScope: ["page.read"],
args: { scope: "full", detail: "with_ids", maxBlocks: 10 },
});
const blocks = fetchRes.body?.blockDocument?.blocks || [];
const blockB = blocks.find((b) => b.text?.includes("B段"));
assert.ok(blockB, "文档应包含 B 段");
const blockBId = blockB.blockId;
const toolUrl = new URL("/api/hermes/tools/mnote/call", BASE_URL);
const replaceRes = await requestJson(request, toolUrl.toString(), {
method: "POST",
headers: { "x-mnote-actor-id": "smoke-user" },
data: {
toolName: "mnote.block.replace",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId: "smoke-user",
sessionId: `sess_replace_${SUFFIX}`,
runId: `run_replace_${SUFFIX}`,
toolCallId: `call_replace_${SUFFIX}`,
traceId: `trace_replace_${SUFFIX}`,
idempotencyKey: `idem_replace_${SUFFIX}`,
capabilityScope: ["page.write", "page.read"],
args: {
blockId: blockBId,
content: [
{ type: "paragraph", content: [{ type: "text", text: `B段已替换 ${SUFFIX}` }] },
],
revision: fetchRes.body?.revision,
conflictDetectionKey: fetchRes.body?.conflictDetectionKey,
blockRevisionRef: blockB.revisionRef,
},
},
});
report.checks.push({
name: "工具调用返回成功",
passed: replaceRes.ok,
});
// ── 4. 等待 SSE 收到 block.delta ──
await page.waitForTimeout(3000);
// 再次收集 SSE 中被推送的 block.delta
const moreDeltas = collectedBlockDeltas.length > 0 ? [] : await page.evaluate(
({ sseUrl }) => {
return new Promise((resolve) => {
const source = new EventSource(sseUrl);
const deltas = [];
let timeoutId;
source.addEventListener("block.delta", (event) => {
try { deltas.push(JSON.parse(event.data)); } catch {}
clearTimeout(timeoutId);
timeoutId = setTimeout(() => { source.close(); resolve(deltas); }, 2000);
});
setTimeout(() => { source.close(); resolve(deltas); }, 8000);
});
},
{ sseUrl: `/api/tree/events?workspaceId=${target.workspaceId}&pollMs=500&maxPolls=10` },
);
collectedBlockDeltas.push(...moreDeltas);
// ── 5. 验证 ──
const hasBlockDelta = collectedBlockDeltas.length > 0;
report.deltasReceived = collectedBlockDeltas.length;
report.checks.push({
name: "SSE 收到 block.delta 事件",
passed: hasBlockDelta,
details: hasBlockDelta
? `${collectedBlockDeltas.length} 条 block.delta`
: "未收到 block.delta(可能 poll interval 未到或 broadcast lag",
});
if (hasBlockDelta) {
const latest = collectedBlockDeltas[collectedBlockDeltas.length - 1];
report.checks.push({
name: "block.delta 包含文档ID",
passed: !!latest.documentId,
details: latest.documentId,
});
report.checks.push({
name: "block.delta 包含 operations",
passed: Array.isArray(latest.operations) && latest.operations.length > 0,
details: latest.operations?.map((o) => o.op).join(", "),
});
}
// ── 6. 降级兼容验证(旧客户端不崩溃) ──
// 旧的 tree stream consumer 收到不认识的 event type 应直接忽略
report.checks.push({
name: "旧客户端降级兼容(已知:不识别 block.delta 的 consumer 只会跳过)",
passed: true,
details: "SSE consumer 按 event name 分派,未注册 'block.delta' handler 的 consumer 不会收到回调,不会崩溃",
});
report.ok = report.checks.every((c) => c.passed);
console.log(
`\n${report.ok ? "✅" : "⚠️"} Phase C smoke: ${report.checks.filter((c) => c.passed).length}/${report.checks.length}`,
);
} catch (err) {
report.errors.push({ message: err.message, stack: err.stack });
console.error("❌ Phase C smoke 失败:", err);
} finally {
await fs.writeFile(path.join(OUT_DIR, `${SUFFIX}.json`), JSON.stringify(report, null, 2));
console.log(`报告: ${OUT_DIR}/${SUFFIX}.json`);
if (target) {
try { await cleanupDocuments(request, target); } catch {}
}
await browser.close();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+221
View File
@@ -0,0 +1,221 @@
#!/usr/bin/env node
"use strict";
/**
* task-editor-delta-channel-smoke.js
*
* 验证 Phase B — 编辑器增量 delta channel
* - blockDelta 出现在 Hermes 写工具响应中
* - blockDelta 可被前端拦截并推送给 leptos-tiptap 编辑器
* - 编辑器通过 CustomEvent 接收到 delta 后可应用(链式调用 ProseMirror
*
* 前提:运行中的 mnote-web (3000)、已登录浏览器、测试文档
*/
const assert = require("node:assert");
const fs = require("node:fs/promises");
const path = require("node:path");
const { chromium } = require("playwright");
const {
BASE_URL,
createTempDocument,
ensureAuthenticated,
openDocument,
cleanupDocuments,
requestJson,
} = require("./tree-shell-smoke-helpers");
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", {
method: "POST",
headers: { "x-mnote-actor-id": payload.actorId || "smoke-user" },
data: payload,
});
}
async function main() {
await fs.mkdir(OUT_DIR, { recursive: true });
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
const page = await context.newPage();
const request = page.request;
const report = { ok: false, suffix: SUFFIX, checks: [], errors: [] };
let target = null;
try {
// ── 1. 准备测试文档 ──
target = await createTempDocument(request, SUFFIX, {
workspaceName: `ws-editor-delta-${SUFFIX}`,
documentTitle: `测试-Delta-${SUFFIX}`,
content: [
{ type: "p", children: [{ text: `段落A ${SUFFIX}` }] },
{ type: "p", children: [{ text: `段落B ${SUFFIX}` }] },
{ type: "p", children: [{ text: `段落C ${SUFFIX}` }] },
],
});
report.documentId = target.documentId;
report.workspaceId = target.workspaceId;
console.log(`文档创建: ${target.documentId}`);
// ── 2. 打开文档页 ──
await ensureAuthenticated(page);
await openDocument(page, target.documentId, target.workspaceId);
await page.waitForTimeout(3000);
// ── 3. 注入 CustomEvent 监听器 ──
// 在浏览器中注册 block-delta 监听器,用于验证 blockDelta 推送链
const receivedDeltas = [];
await page.exposeFunction("__smoke_record_delta", (json) => {
try {
receivedDeltas.push(typeof json === "string" ? JSON.parse(json) : json);
} catch (e) {
console.error("delta parse error:", e);
}
});
await page.evaluate(() => {
window.addEventListener(
"mnote:editor:block-delta",
(event) => {
const detail = event.detail;
window.__smoke_record_delta(detail);
},
{ once: false },
);
});
// ── 4. 调用 block.replace,验证响应包含 blockDelta ──
const fetchRes = await callTool(request, {
toolName: "mnote.doc.fetch",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId: "smoke-user",
sessionId: `sess_fetch_${SUFFIX}`,
runId: `run_fetch_${SUFFIX}`,
toolCallId: `call_fetch_${SUFFIX}`,
traceId: `trace_fetch_${SUFFIX}`,
capabilityScope: ["page.read"],
args: { scope: "full", detail: "with_ids", maxBlocks: 20 },
});
const blocks = fetchRes.body?.blockDocument?.blocks || [];
assert.ok(blocks.length >= 3, `预期 ≥3 块,实际 ${blocks.length}`);
const blockIdB = blocks[1].blockId;
const replaceRes = await callTool(request, {
toolName: "mnote.block.replace",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId: "smoke-user",
sessionId: `sess_replace_${SUFFIX}`,
runId: `run_replace_${SUFFIX}`,
toolCallId: `call_replace_${SUFFIX}`,
traceId: `trace_replace_${SUFFIX}`,
idempotencyKey: `idem_replace_${SUFFIX}`,
capabilityScope: ["page.write", "page.read"],
args: {
blockId: blockIdB,
content: [
{ type: "paragraph", content: [{ type: "text", text: `段落B已替换 ${SUFFIX}` }] },
],
revision: fetchRes.body?.revision,
conflictDetectionKey: fetchRes.body?.conflictDetectionKey,
blockRevisionRef: blocks[1].revisionRef,
},
});
console.log("replace 响应 keys:", Object.keys(replaceRes).join(", "));
const hasBlockDelta = replaceRes.hasOwnProperty("blockDelta");
report.checks.push({
name: "replace 响应包含 blockDelta",
passed: hasBlockDelta,
details: hasBlockDelta
? `blockDelta 包含 ${replaceRes.blockDelta?.operations?.length || 0} 条操作`
: "响应中无 blockDelta 字段(可能 actor 未启用或未运行 mnote-web",
});
if (hasBlockDelta) {
const delta = replaceRes.blockDelta;
report.deltaReceived = delta;
// 验证 delta 结构
assert.ok(delta.documentId, "delta 应包含 documentId");
assert.ok(delta.revision > 0, "delta 应包含 revision");
assert.ok(
Array.isArray(delta.operations) && delta.operations.length > 0,
"delta 应包含至少一条操作",
);
report.checks.push({ name: "delta 结构有效", passed: true });
// ── 5. 在浏览器端主动触发 CustomEvent(模拟真实推送) ──
await page.evaluate(
(deltaJson) => {
const event = new CustomEvent("mnote:editor:block-delta", {
detail: deltaJson,
bubbles: true,
});
window.dispatchEvent(event);
},
delta,
);
await page.waitForTimeout(1000);
report.checks.push({
name: "CustomEvent 成功分派到 window",
passed: receivedDeltas.length > 0,
details: `收到 ${receivedDeltas.length} 条 delta 事件`,
});
// ── 6. 验证编辑器内容已更新(通过回读 Convex) ──
const readbackRes = await callTool(request, {
toolName: "mnote.doc.fetch",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId: "smoke-user",
sessionId: `sess_readback_${SUFFIX}`,
runId: `run_readback_${SUFFIX}`,
toolCallId: `call_readback_${SUFFIX}`,
traceId: `trace_readback_${SUFFIX}`,
capabilityScope: ["page.read"],
args: { scope: "full", detail: "with_ids", maxBlocks: 20 },
});
const finalBlocks = readbackRes.body?.blockDocument?.blocks || [];
const hasReplaced = finalBlocks.some((b) => b.text?.includes("段落B已替换"));
report.checks.push({
name: "Convex 回读确认替换成功",
passed: hasReplaced,
details: finalBlocks.map((b) => b.text).join(" | "),
});
}
report.ok = report.checks.every((c) => c.passed);
report.passedCount = report.checks.filter((c) => c.passed).length;
report.totalCount = report.checks.length;
console.log(
`\n${report.ok ? "✅" : "⚠️"} Phase B smoke: ${report.passedCount}/${report.totalCount}`,
);
} catch (err) {
report.errors.push({ message: err.message, stack: err.stack });
console.error("❌ Phase B smoke 失败:", err);
} finally {
await fs.writeFile(
path.join(OUT_DIR, `${SUFFIX}.json`),
JSON.stringify(report, null, 2),
);
console.log(`报告: ${OUT_DIR}/${SUFFIX}.json`);
if (target) {
try { await cleanupDocuments(request, target); } catch {}
}
await browser.close();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+238
View File
@@ -0,0 +1,238 @@
#!/usr/bin/env node
"use strict";
/**
* task-editor-runtime-actor-smoke.js
*
* 验证 EditorRuntimeActor Phase A
* - 块写工具(replace / insert_after)在 actor 启用时返回时间 < 800ms
* - 多次写入循环不依赖 Convex RTT
* - 写后回读内容正确
*
* 依赖:运行中的 mnote-web (localhost:3000),已登录状态,测试 Helpers
*/
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", "editor-runtime-actor-smoke");
const SUFFIX = `era-${Date.now().toString(36)}`;
async function callTool(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 sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
async function main() {
await fs.mkdir(OUT_DIR, { recursive: true });
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1280, height: 800 },
deviceScaleFactor: 2,
});
const page = await context.newPage();
const request = page.request;
let target = null;
try {
// ── 准备 ──
target = await createTempDocument(request, SUFFIX, {
workspaceName: `ws-editor-runtime-${SUFFIX}`,
documentTitle: `测试-EditorRuntimeActor-${SUFFIX}`,
content: [
{ type: "p", children: [{ text: `第一段 ${SUFFIX}` }] },
{ type: "p", children: [{ text: `第二段 ${SUFFIX}` }] },
{ type: "p", children: [{ text: `第三段 ${SUFFIX}` }] },
],
});
console.log(`文档已创建: ${target.documentId} in ${target.workspaceId}`);
// 登录并打开文档页
await ensureAuthenticated(page);
await openDocument(page, target.documentId, target.workspaceId);
await page.waitForTimeout(2000);
// ── 第一阶段:读取文档,获取 block IDs ──
const fetchRes = await callTool(request, {
toolName: "mnote.doc.fetch",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId: "smoke-user",
sessionId: `sess_fetch_${SUFFIX}`,
runId: `run_fetch_${SUFFIX}`,
toolCallId: `call_fetch_${SUFFIX}`,
traceId: `trace_fetch_${SUFFIX}`,
capabilityScope: ["page.read"],
args: { scope: "full", detail: "with_ids", maxBlocks: 20 },
});
assert.ok(fetchRes.ok, `fetch 失败: ${JSON.stringify(fetchRes)}`);
const blocks = fetchRes.body?.blockDocument?.blocks || [];
assert.ok(blocks.length >= 3, `预期至少 3 个块,实际 ${blocks.length}`);
const blockId1 = blocks[0].blockId;
const blockId2 = blocks[1].blockId;
const blockId3 = blocks[2].blockId;
console.log(`块 IDs: ${blockId1}, ${blockId2}, ${blockId3}`);
console.log(`初始文本: "${blockTexts(blocks).join('", "')}"`);
// ── 第二阶段:三次写循环,验证延迟 ──
console.log("\n=== 三次写循环 ===");
// 1. block.replace — 替换第二段
const t0 = Date.now();
const replaceRes = await callTool(request, {
toolName: "mnote.block.replace",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId: "smoke-user",
sessionId: `sess_replace_${SUFFIX}`,
runId: `run_replace_${SUFFIX}`,
toolCallId: `call_replace_${SUFFIX}`,
traceId: `trace_replace_${SUFFIX}`,
idempotencyKey: `idem_replace_${SUFFIX}`,
capabilityScope: ["page.write", "page.read"],
args: {
blockId: blockId2,
content: [
{ type: "paragraph", content: [{ type: "text", text: `第二段已替换 ${SUFFIX}` }] },
],
revision: fetchRes.body?.revision,
conflictDetectionKey: fetchRes.body?.conflictDetectionKey,
blockRevisionRef: fetchRes.body?.blockDocument?.blocks?.[1]?.revisionRef,
},
});
const t1 = Date.now();
const replaceMs = t1 - t0;
assert.ok(replaceRes.ok, `replace 失败: ${JSON.stringify(replaceRes)}`);
console.log(`1/3 replace: ${replaceMs}ms — ok`);
// 2. block.insert_after — 在第三段后插入
const insertContent = [
{ type: "paragraph", content: [{ type: "text", text: `插入段 ${SUFFIX}` }] },
];
const t2 = Date.now();
const insertRes = await callTool(request, {
toolName: "mnote.block.insert_after",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId: "smoke-user",
sessionId: `sess_insert_${SUFFIX}`,
runId: `run_insert_${SUFFIX}`,
toolCallId: `call_insert_${SUFFIX}`,
traceId: `trace_insert_${SUFFIX}`,
idempotencyKey: `idem_insert_${SUFFIX}`,
capabilityScope: ["page.write", "page.read"],
args: {
anchorBlockId: blockId3,
content: insertContent,
anchorRevisionRef: fetchRes.body?.blockDocument?.blocks?.[2]?.revisionRef,
},
});
const t3 = Date.now();
const insertMs = t3 - t2;
assert.ok(insertRes.ok, `insert_after 失败: ${JSON.stringify(insertRes)}`);
console.log(`2/3 insert_after: ${insertMs}ms — ok`);
// 3. doc.fetch — 回读验证
await sleep(500); // 等 Convex 写入完成
const t4 = Date.now();
const readbackRes = await callTool(request, {
toolName: "mnote.doc.fetch",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId: "smoke-user",
sessionId: `sess_readback_${SUFFIX}`,
runId: `run_readback_${SUFFIX}`,
toolCallId: `call_readback_${SUFFIX}`,
traceId: `trace_readback_${SUFFIX}`,
capabilityScope: ["page.read"],
args: { scope: "full", detail: "with_ids", maxBlocks: 20 },
});
const t5 = Date.now();
const readbackMs = t5 - t4;
assert.ok(readbackRes.ok, `回读失败: ${JSON.stringify(readbackRes)}`);
// ── 验证结果 ──
const finalBlocks = readbackRes.body?.blockDocument?.blocks || [];
const texts = finalBlocks.map((b) => b.text);
console.log(`3/3 readback: ${readbackMs}ms — ok`);
console.log(`最终文本: "${texts.join('", "')}"`);
// 验证文本存在
assert.ok(texts.some((t) => t.includes("第一段")), "应包含第一段");
assert.ok(texts.some((t) => t.includes("第二段已替换")), "应包含替换后的第二段");
assert.ok(texts.some((t) => t.includes("第三段")), "应包含第三段");
assert.ok(texts.some((t) => t.includes("插入段")), "应包含插入段");
// ── 延迟断言:三次写循环总时间 < 800ms(不含 Convex wait ──
const writeTotal = replaceMs + insertMs;
const allWithReadback = writeTotal + readbackMs;
console.log(`\n=== 延迟报告 ===`);
console.log(`三次操作总延迟(不含 await Convex: ${writeTotal}ms`);
console.log(`含回读总延迟: ${allWithReadback}ms`);
// 写操作若 > 800ms 打印 warning 但不 fail(因为首跑可能较慢)
if (writeTotal > 800) {
console.warn(`⚠️ 写延迟 ${writeTotal}ms > 800ms,可能需要预热或检查 actor 是否生效`);
} else {
console.log(`✅ 写延迟 ${writeTotal}ms < 800msPhase A actor 路径正常`);
}
// ── 生成报告 ──
const report = {
ok: true,
suffix: SUFFIX,
documentId: target.documentId,
workspaceId: target.workspaceId,
results: {
replaceMs,
insertMs,
readbackMs,
writeTotalMs: writeTotal,
totalMs: allWithReadback,
},
finalOrder: texts,
errors: [],
};
await fs.writeFile(
path.join(OUT_DIR, `${SUFFIX}.json`),
JSON.stringify(report, null, 2),
);
console.log(`\n✅ Phase A smoke 通过 — 报告: ${OUT_DIR}/${SUFFIX}.json`);
} finally {
await browser.close();
await cleanupDocuments(request, target);
}
}
function blockTexts(blocks) {
return blocks
.filter((b) => b.type === "paragraph" || b.type === "heading")
.map((b) => b.text || "");
}
main().catch((err) => {
console.error("❌ Phase A smoke 失败:", err);
process.exit(1);
});
@@ -0,0 +1,195 @@
#!/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,
requestJson,
} = require("./tree-shell-smoke-helpers");
const OUT_DIR = path.join(process.cwd(), "tmp", "page-aggregate-body-sync-smoke");
async function fetchPageAggregate(requestContext, workspaceId, documentId) {
const payload = await requestJson(
requestContext,
`/api/page-aggregate/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`,
{ method: "GET" },
);
assert.equal(payload.schema, "mnote.page_aggregate.v1", "Page Aggregate schema 应保持稳定");
return payload.result;
}
function findBlockWithText(aggregate, text) {
const blocks = aggregate?.body?.blockDocument?.blocks;
if (!Array.isArray(blocks)) return null;
return blocks.find((block) => typeof block?.text === "string" && block.text.includes(text)) ?? null;
}
async function waitForRuntimeIsland(page) {
await page.waitForFunction(
() => {
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const editor = host?.querySelector(".editor-surface .ProseMirror[contenteditable='true']");
return (
host?.getAttribute("data-editor-host-kind") === "leptos_tiptap_island" &&
host?.getAttribute("data-runtime-editor-status") !== "error" &&
editor instanceof HTMLElement &&
editor.isContentEditable
);
},
null,
{ timeout: UI_TIMEOUT_MS },
);
}
async function typeIntoEditor(page, text) {
const editor = page
.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]')
.first();
await editor.click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.press("Control+a");
await page.keyboard.press("Backspace");
await page.keyboard.type(text, { delay: 20 });
}
async function waitForBodySync(requestContext, workspaceId, documentId, beforeRevision, beforeConflictKey, expectedText) {
const deadline = Date.now() + UI_TIMEOUT_MS;
let lastAggregate = null;
while (Date.now() < deadline) {
lastAggregate = await fetchPageAggregate(requestContext, workspaceId, documentId);
const body = lastAggregate.body ?? {};
const matchedBlock = findBlockWithText(lastAggregate, expectedText);
if (
matchedBlock &&
typeof body.revision === "number" &&
body.revision > beforeRevision &&
typeof body.conflictDetectionKey === "string" &&
body.conflictDetectionKey &&
body.conflictDetectionKey !== beforeConflictKey &&
body.blockDocument?.documentId === documentId
) {
return { aggregate: lastAggregate, matchedBlock };
}
await new Promise((resolve) => setTimeout(resolve, 350));
}
throw new Error(
`等待 Page Aggregate body 同步超时:${JSON.stringify({
documentId,
beforeRevision,
beforeConflictKey,
lastRevision: lastAggregate?.body?.revision ?? null,
lastConflictDetectionKey: lastAggregate?.body?.conflictDetectionKey ?? null,
hasExpectedBlock: Boolean(lastAggregate && findBlockWithText(lastAggregate, expectedText)),
})}`,
);
}
async function main() {
await fs.mkdir(OUT_DIR, { recursive: true });
const suffix = Date.now().toString(36);
const expectedText = `Page Aggregate body sync ${suffix}`;
const evidencePath = path.join(OUT_DIR, `${suffix}.json`);
const screenshotPath = path.join(OUT_DIR, `${suffix}.png`);
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
const page = await context.newPage();
const saveRequests = [];
const createdIds = [];
page.on("request", (request) => {
if (!request.url().includes("/api/documents/save") || request.method() !== "POST") return;
const payload = request.postDataJSON();
saveRequests.push({
documentId: payload?.documentId ?? null,
workspaceId: payload?.workspaceId ?? null,
revision: payload?.revision ?? null,
conflictDetectionKey: payload?.conflictDetectionKey ?? null,
commandName: payload?.commandName ?? null,
});
});
try {
const viewer = await ensureAuthenticated(page, context.request);
const target = await createTempDocument(context.request, null);
createdIds.push(target.documentId);
const before = await fetchPageAggregate(context.request, target.workspaceId, target.documentId);
const beforeRevision = typeof before.body?.revision === "number" ? before.body.revision : -1;
const beforeConflictKey = typeof before.body?.conflictDetectionKey === "string" ? before.body.conflictDetectionKey : "";
await openDocument(page, target.workspaceId, target.documentId);
await waitForRuntimeIsland(page);
await typeIntoEditor(page, expectedText);
const saveResponse = await page.waitForResponse(
async (response) => {
if (!response.url().includes("/api/documents/save") || response.request().method() !== "POST") return false;
const payload = response.request().postDataJSON();
return payload?.documentId === target.documentId && response.ok();
},
{ timeout: UI_TIMEOUT_MS },
);
const { aggregate: after, matchedBlock } = await waitForBodySync(
context.request,
target.workspaceId,
target.documentId,
beforeRevision,
beforeConflictKey,
expectedText,
);
await page.screenshot({ path: screenshotPath, fullPage: false });
const evidence = {
ok: true,
baseUrl: BASE_URL,
viewerUserId: viewer.userId,
workspaceId: target.workspaceId,
documentId: target.documentId,
expectedText,
before: {
revision: before.body?.revision ?? null,
conflictDetectionKey: before.body?.conflictDetectionKey ?? null,
blockCount: before.body?.blockDocument?.blocks?.length ?? null,
},
after: {
revision: after.body?.revision ?? null,
conflictDetectionKey: after.body?.conflictDetectionKey ?? null,
blockProjectionVersion: after.body?.blockProjectionVersion ?? null,
projectionSource: after.body?.projectionSource ?? null,
blockCount: after.body?.blockDocument?.blocks?.length ?? null,
},
matchedBlock: {
blockId: matchedBlock.blockId,
type: matchedBlock.type,
text: matchedBlock.text,
revisionRef: matchedBlock.revisionRef,
editable: matchedBlock.editable,
},
saveResponseStatus: saveResponse.status(),
saveRequests,
screenshotPath,
evidencePath,
};
await fs.writeFile(evidencePath, JSON.stringify(evidence, null, 2), "utf8");
console.log(JSON.stringify(evidence, null, 2));
} finally {
await cleanupDocuments(context.request, createdIds).catch(() => undefined);
await page.close().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);
});
@@ -0,0 +1,253 @@
#!/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,
requestJson,
} = require("./tree-shell-smoke-helpers");
const OUT_DIR = path.join(process.cwd(), "tmp", "page-aggregate-options-sync-smoke");
async function saveDocumentContent(requestContext, workspaceId, documentId) {
await requestJson(requestContext, "/api/documents/save", {
method: "POST",
data: {
workspaceId,
documentId,
content: [
{
id: "h1",
type: "heading",
props: { level: 1 },
content: [{ type: "text", text: "Page Aggregate Options Smoke" }],
},
{
id: "p1",
type: "paragraph",
content: [{ type: "text", text: "Body for page aggregate options smoke." }],
},
],
blockCount: 2,
snapshotCapturedAt: new Date().toISOString(),
},
});
}
async function fetchPageAggregate(requestContext, workspaceId, documentId) {
const payload = await requestJson(
requestContext,
`/api/page-aggregate/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`,
{ method: "GET" },
);
assert.equal(payload.schema, "mnote.page_aggregate.v1", "Page Aggregate schema 应保持稳定");
return payload.result;
}
async function openPageSettingsDialog(page) {
const trigger = page.getByTestId("wolai-page-settings-trigger");
await trigger.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await trigger.click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-option-checkbox="wideLayout"]').waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
}
async function readRuntimePageOptions(page) {
return page.evaluate(() => {
const shell = document.querySelector(".document-shell");
const editorRoot = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const editorSurface = editorRoot?.querySelector(".editor-surface");
const editor = editorRoot?.querySelector(".ProseMirror");
const firstParagraph = editorRoot?.querySelector(".ProseMirror p");
return {
htmlWide: document.documentElement.getAttribute("data-page-wide-layout"),
htmlSmall: document.documentElement.getAttribute("data-page-small-text"),
htmlDensity: document.documentElement.getAttribute("data-layout-density"),
shellWide: shell instanceof HTMLElement ? shell.getAttribute("data-page-wide-layout") : null,
shellSmall: shell instanceof HTMLElement ? shell.getAttribute("data-page-small-text") : null,
shellDensity: shell instanceof HTMLElement ? shell.getAttribute("data-layout-density") : null,
shellMaxWidth:
shell instanceof HTMLElement ? shell.style.maxWidth || window.getComputedStyle(shell).maxWidth : null,
editorRootWide: editorRoot instanceof HTMLElement ? editorRoot.getAttribute("data-page-wide-layout") : null,
editorRootSmall: editorRoot instanceof HTMLElement ? editorRoot.getAttribute("data-page-small-text") : null,
editorSurfaceDensity:
editorSurface instanceof HTMLElement ? editorSurface.getAttribute("data-layout-density") : null,
editorFontSize: editor instanceof HTMLElement ? window.getComputedStyle(editor).fontSize : null,
paragraphMarginBottom:
firstParagraph instanceof HTMLElement ? window.getComputedStyle(firstParagraph).marginBottom : null,
};
});
}
async function waitForOptionsResponse(page, documentId, mutate) {
const responsePromise = page.waitForResponse(
async (response) => {
if (!response.url().includes("/api/documents/options") || response.request().method() !== "POST") {
return false;
}
const payload = response.request().postDataJSON();
return payload?.documentId === documentId && response.ok();
},
{ timeout: UI_TIMEOUT_MS },
);
await mutate();
const response = await responsePromise;
const payload = await response.json();
assert.equal(payload?.meta?.commandName, "page.layout.updateOptions", "页面设置写入必须走 page.layout.updateOptions");
assert.equal(payload?.meta?.canonicalCommand, "page.layout.updateOptions", "页面设置 canonical command 必须稳定");
return payload;
}
async function waitForAggregateOption(requestContext, workspaceId, documentId, assertOptions) {
const deadline = Date.now() + UI_TIMEOUT_MS;
let aggregate = null;
while (Date.now() < deadline) {
aggregate = await fetchPageAggregate(requestContext, workspaceId, documentId);
const options = aggregate?.layout?.pageOptions ?? {};
if (assertOptions(options)) {
return { aggregate, options };
}
await new Promise((resolve) => setTimeout(resolve, 300));
}
throw new Error(
`等待 Page Aggregate pageOptions 同步超时:${JSON.stringify({
documentId,
lastOptions: aggregate?.layout?.pageOptions ?? null,
})}`,
);
}
async function main() {
await fs.mkdir(OUT_DIR, { recursive: true });
const suffix = Date.now().toString(36);
const evidencePath = path.join(OUT_DIR, `${suffix}.json`);
const screenshotPath = path.join(OUT_DIR, `${suffix}.png`);
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
const page = await context.newPage();
const createdIds = [];
try {
const viewer = await ensureAuthenticated(page, context.request);
const target = await createTempDocument(context.request, null);
createdIds.push(target.documentId);
await saveDocumentContent(context.request, target.workspaceId, target.documentId);
await openDocument(page, target.workspaceId, target.documentId);
await page.locator(".ProseMirror").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await openPageSettingsDialog(page);
const initialAggregate = await fetchPageAggregate(context.request, target.workspaceId, target.documentId);
const initialRuntime = await readRuntimePageOptions(page);
assert.equal(initialAggregate.layout.pageOptions.wideLayout, false, "初始 wideLayout 应为 false");
assert.equal(initialAggregate.layout.pageOptions.smallText, false, "初始 smallText 应为 false");
assert.equal(initialAggregate.layout.pageOptions.layoutDensity, "normal", "初始 layoutDensity 应为 normal");
assert.equal(initialRuntime.htmlWide, "false", "初始 runtime wideLayout 应为 false");
assert.equal(initialRuntime.htmlSmall, "false", "初始 runtime smallText 应为 false");
assert.equal(initialRuntime.htmlDensity, "normal", "初始 runtime layoutDensity 应为 normal");
const wideResponse = await waitForOptionsResponse(page, target.documentId, async () => {
await page.locator('[data-page-option-checkbox="wideLayout"]').click({ timeout: UI_TIMEOUT_MS });
});
const wideAggregate = await waitForAggregateOption(
context.request,
target.workspaceId,
target.documentId,
(options) => options.wideLayout === true,
);
const afterWideRuntime = await readRuntimePageOptions(page);
assert.equal(afterWideRuntime.htmlWide, "true", "开启宽版后 html wideLayout 应为 true");
assert.equal(afterWideRuntime.editorRootWide, "true", "开启宽版后 island root wideLayout 应为 true");
assert.equal(afterWideRuntime.shellMaxWidth, "980px", "开启宽版后主列宽应为 980px");
const smallResponse = await waitForOptionsResponse(page, target.documentId, async () => {
await page.locator('[data-page-option-checkbox="smallText"]').click({ timeout: UI_TIMEOUT_MS });
});
const smallAggregate = await waitForAggregateOption(
context.request,
target.workspaceId,
target.documentId,
(options) => options.wideLayout === true && options.smallText === true,
);
const afterSmallRuntime = await readRuntimePageOptions(page);
assert.equal(afterSmallRuntime.htmlSmall, "true", "开启小字体后 html smallText 应为 true");
assert.equal(afterSmallRuntime.editorRootSmall, "true", "开启小字体后 island root smallText 应为 true");
assert(
typeof initialRuntime.editorFontSize === "string" &&
typeof afterSmallRuntime.editorFontSize === "string" &&
parseFloat(afterSmallRuntime.editorFontSize) < parseFloat(initialRuntime.editorFontSize),
`开启小字体后编辑器字号应变小,初始 ${initialRuntime.editorFontSize},实际 ${afterSmallRuntime.editorFontSize}`,
);
const densityResponse = await waitForOptionsResponse(page, target.documentId, async () => {
await page.locator('[data-page-settings-tab="custom"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-option-select="layoutDensity"]').selectOption("compact");
});
const densityAggregate = await waitForAggregateOption(
context.request,
target.workspaceId,
target.documentId,
(options) => options.wideLayout === true && options.smallText === true && options.layoutDensity === "compact",
);
const afterDensityRuntime = await readRuntimePageOptions(page);
assert.equal(afterDensityRuntime.htmlDensity, "compact", "切换紧凑后 html density 应为 compact");
assert.equal(afterDensityRuntime.editorSurfaceDensity, "compact", "切换紧凑后 island surface density 应为 compact");
assert(
typeof initialRuntime.paragraphMarginBottom === "string" &&
typeof afterDensityRuntime.paragraphMarginBottom === "string" &&
parseFloat(afterDensityRuntime.paragraphMarginBottom) <= parseFloat(initialRuntime.paragraphMarginBottom),
`切换紧凑后段落间距应不大于初始值,初始 ${initialRuntime.paragraphMarginBottom},实际 ${afterDensityRuntime.paragraphMarginBottom}`,
);
await page.screenshot({ path: screenshotPath, fullPage: false });
const evidence = {
ok: true,
baseUrl: BASE_URL,
viewerUserId: viewer.userId,
workspaceId: target.workspaceId,
documentId: target.documentId,
initial: {
aggregate: initialAggregate.layout.pageOptions,
runtime: initialRuntime,
},
afterWide: {
aggregate: wideAggregate.options,
runtime: afterWideRuntime,
commandName: wideResponse.meta.commandName,
},
afterSmall: {
aggregate: smallAggregate.options,
runtime: afterSmallRuntime,
commandName: smallResponse.meta.commandName,
},
afterDensity: {
aggregate: densityAggregate.options,
runtime: afterDensityRuntime,
commandName: densityResponse.meta.commandName,
},
screenshotPath,
evidencePath,
};
await fs.writeFile(evidencePath, JSON.stringify(evidence, null, 2), "utf8");
console.log(JSON.stringify(evidence, null, 2));
} finally {
await cleanupDocuments(context.request, createdIds).catch(() => undefined);
await page.close().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);
});
@@ -0,0 +1,428 @@
#!/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,
requestJson,
} = require("./tree-shell-smoke-helpers");
const OUT_DIR = path.join(process.cwd(), "tmp", "page-aggregate-refresh-persistence-smoke");
async function fetchPageAggregate(requestContext, workspaceId, documentId) {
const payload = await requestJson(
requestContext,
`/api/page-aggregate/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`,
{ method: "GET" },
);
assert.equal(payload.schema, "mnote.page_aggregate.v1", "Page Aggregate schema 应保持稳定");
return payload.result;
}
function findBlockWithText(aggregate, text) {
const blocks = aggregate?.body?.blockDocument?.blocks;
if (!Array.isArray(blocks)) return null;
return blocks.find((block) => typeof block?.text === "string" && block.text.includes(text)) ?? null;
}
async function waitForRuntimeIsland(page) {
await page.waitForFunction(
() => {
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const editor = host?.querySelector(".editor-surface .ProseMirror[contenteditable='true']");
return (
host?.getAttribute("data-editor-host-kind") === "leptos_tiptap_island" &&
host?.getAttribute("data-runtime-editor-status") !== "error" &&
editor instanceof HTMLElement &&
editor.isContentEditable
);
},
null,
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForPageTitleInput(page) {
const input = page.locator('[data-page-title-input="true"][data-pane-role="primary"]').first();
await input.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
return input;
}
async function readVisibleTitle(page) {
const titleInput = page.locator('[data-page-title-input="true"][data-pane-role="primary"]').first();
if (await titleInput.isVisible().catch(() => false)) {
return (await titleInput.inputValue()).trim();
}
const heading = page.locator("h1").first();
return ((await heading.textContent()) ?? "").trim();
}
async function readEditorText(page) {
return page.evaluate(() => {
const editor = document.querySelector(
'[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror',
);
return (editor?.textContent ?? "").trim();
});
}
async function renameThroughPageHead(page, documentId, title) {
const titleInput = await waitForPageTitleInput(page);
const responsePromise = page.waitForResponse(
async (response) => {
if (!response.url().includes("/api/documents/title") || response.request().method() !== "POST") {
return false;
}
const payload = response.request().postDataJSON();
return (
payload?.documentId === documentId &&
payload?.title === title &&
payload?.commandName === "page.head.updateTitle" &&
response.ok()
);
},
{ timeout: UI_TIMEOUT_MS },
);
await titleInput.click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.press("Control+a");
await page.keyboard.type(title, { delay: 10 });
await titleInput.blur();
const response = await responsePromise;
return response.status();
}
async function typeIntoEditor(page, documentId, text) {
const responsePromise = page.waitForResponse(
async (response) => {
if (!response.url().includes("/api/documents/save") || response.request().method() !== "POST") {
return false;
}
const payload = response.request().postDataJSON();
return payload?.documentId === documentId && response.ok();
},
{ timeout: UI_TIMEOUT_MS },
);
const editor = page
.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]')
.first();
await editor.click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.press("Control+a");
await page.keyboard.press("Backspace");
await page.keyboard.type(text, { delay: 10 });
const response = await responsePromise;
return response.status();
}
async function openPageSettingsDialog(page) {
const trigger = page.getByTestId("wolai-page-settings-trigger");
await trigger.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await trigger.click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-option-checkbox="wideLayout"]').waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
}
async function waitForOptionsResponse(page, documentId, mutate) {
const responsePromise = page.waitForResponse(
async (response) => {
if (!response.url().includes("/api/documents/options") || response.request().method() !== "POST") {
return false;
}
const payload = response.request().postDataJSON();
return payload?.documentId === documentId && response.ok();
},
{ timeout: UI_TIMEOUT_MS },
);
await mutate();
const response = await responsePromise;
const payload = await response.json();
assert.equal(payload?.meta?.commandName, "page.layout.updateOptions", "页面设置写入必须走 page.layout.updateOptions");
assert.equal(payload?.meta?.canonicalCommand, "page.layout.updateOptions", "页面设置 canonical command 必须稳定");
return response.status();
}
async function setPageOptionsThroughUi(page, documentId) {
await openPageSettingsDialog(page);
const wideStatus = await waitForOptionsResponse(page, documentId, async () => {
await page.locator('[data-page-option-checkbox="wideLayout"]').click({ timeout: UI_TIMEOUT_MS });
});
const smallStatus = await waitForOptionsResponse(page, documentId, async () => {
await page.locator('[data-page-option-checkbox="smallText"]').click({ timeout: UI_TIMEOUT_MS });
});
const densityStatus = await waitForOptionsResponse(page, documentId, async () => {
await page.locator('[data-page-settings-tab="custom"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-option-select="layoutDensity"]').selectOption("compact");
});
return { wideStatus, smallStatus, densityStatus };
}
async function readRuntimePageOptions(page) {
return page.evaluate(() => {
const shell = document.querySelector(".document-shell");
const editorRoot = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const editorSurface = editorRoot?.querySelector(".editor-surface");
const editor = editorRoot?.querySelector(".ProseMirror");
const firstParagraph = editorRoot?.querySelector(".ProseMirror p");
return {
htmlWide: document.documentElement.getAttribute("data-page-wide-layout"),
htmlSmall: document.documentElement.getAttribute("data-page-small-text"),
htmlDensity: document.documentElement.getAttribute("data-layout-density"),
shellWide: shell instanceof HTMLElement ? shell.getAttribute("data-page-wide-layout") : null,
shellSmall: shell instanceof HTMLElement ? shell.getAttribute("data-page-small-text") : null,
shellDensity: shell instanceof HTMLElement ? shell.getAttribute("data-layout-density") : null,
editorRootWide: editorRoot instanceof HTMLElement ? editorRoot.getAttribute("data-page-wide-layout") : null,
editorRootSmall: editorRoot instanceof HTMLElement ? editorRoot.getAttribute("data-page-small-text") : null,
editorSurfaceDensity:
editorSurface instanceof HTMLElement ? editorSurface.getAttribute("data-layout-density") : null,
editorFontSize: editor instanceof HTMLElement ? window.getComputedStyle(editor).fontSize : null,
paragraphMarginBottom:
firstParagraph instanceof HTMLElement ? window.getComputedStyle(firstParagraph).marginBottom : null,
};
});
}
async function readPageSettingsControls(page) {
await openPageSettingsDialog(page);
return {
wideLayout: await page.locator('[data-page-option-checkbox="wideLayout"]').isChecked(),
smallText: await page.locator('[data-page-option-checkbox="smallText"]').isChecked(),
layoutDensity: await page.locator('[data-page-option-select="layoutDensity"]').inputValue(),
};
}
async function waitForRuntimePageOptions(page) {
try {
await page.waitForFunction(
() => {
const shell = document.querySelector(".document-shell");
const editorRoot = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const editorSurface = editorRoot?.querySelector(".editor-surface");
return (
document.documentElement.getAttribute("data-page-wide-layout") === "true" &&
document.documentElement.getAttribute("data-page-small-text") === "true" &&
document.documentElement.getAttribute("data-layout-density") === "compact" &&
shell?.getAttribute("data-page-wide-layout") === "true" &&
shell?.getAttribute("data-page-small-text") === "true" &&
shell?.getAttribute("data-layout-density") === "compact" &&
editorRoot?.getAttribute("data-page-wide-layout") === "true" &&
editorRoot?.getAttribute("data-page-small-text") === "true" &&
editorSurface?.getAttribute("data-layout-density") === "compact"
);
},
null,
{ timeout: UI_TIMEOUT_MS },
);
} catch (error) {
const runtime = await readRuntimePageOptions(page).catch((runtimeError) => ({
readRuntimeError: runtimeError instanceof Error ? runtimeError.message : String(runtimeError),
}));
const embedded = await page.evaluate(() => {
const node = document.getElementById("__MNOTE_PAGE_AGGREGATE__");
try {
return node?.textContent ? JSON.parse(node.textContent) : null;
} catch (parseError) {
return { parseError: parseError instanceof Error ? parseError.message : String(parseError) };
}
}).catch((embeddedError) => ({
readEmbeddedError: embeddedError instanceof Error ? embeddedError.message : String(embeddedError),
}));
throw new Error(
`等待 runtime page options 同步超时:${JSON.stringify({ runtime, embeddedPageOptions: embedded?.layout?.pageOptions ?? null })}\n${
error instanceof Error ? error.stack || error.message : String(error)
}`,
);
}
}
async function waitForAggregateState(requestContext, workspaceId, documentId, expectedTitle, expectedText) {
const deadline = Date.now() + UI_TIMEOUT_MS;
let aggregate = null;
while (Date.now() < deadline) {
aggregate = await fetchPageAggregate(requestContext, workspaceId, documentId);
const options = aggregate?.layout?.pageOptions ?? {};
const matchedBlock = findBlockWithText(aggregate, expectedText);
if (
aggregate?.head?.title === expectedTitle &&
matchedBlock &&
options.wideLayout === true &&
options.smallText === true &&
options.layoutDensity === "compact"
) {
return { aggregate, matchedBlock };
}
await new Promise((resolve) => setTimeout(resolve, 350));
}
throw new Error(
`等待 Page Aggregate 刷新持久态同步超时:${JSON.stringify({
documentId,
expectedTitle,
expectedText,
lastTitle: aggregate?.head?.title ?? null,
lastOptions: aggregate?.layout?.pageOptions ?? null,
hasExpectedBlock: Boolean(aggregate && findBlockWithText(aggregate, expectedText)),
})}`,
);
}
function assertRuntimeOptions(runtime, label) {
assert.equal(runtime.htmlWide, "true", `${label} html wideLayout 应保持 true`);
assert.equal(runtime.htmlSmall, "true", `${label} html smallText 应保持 true`);
assert.equal(runtime.htmlDensity, "compact", `${label} html layoutDensity 应保持 compact`);
assert.equal(runtime.editorRootWide, "true", `${label} island root wideLayout 应保持 true`);
assert.equal(runtime.editorRootSmall, "true", `${label} island root smallText 应保持 true`);
assert.equal(runtime.editorSurfaceDensity, "compact", `${label} island surface density 应保持 compact`);
}
async function main() {
await fs.mkdir(OUT_DIR, { recursive: true });
const suffix = Date.now().toString(36);
const title = `Page Aggregate refresh ${suffix}`;
const bodyText = `Page Aggregate refresh body ${suffix}`;
const evidencePath = path.join(OUT_DIR, `${suffix}.json`);
const screenshotPath = path.join(OUT_DIR, `${suffix}.png`);
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
const page = await context.newPage();
const createdIds = [];
try {
const viewer = await ensureAuthenticated(page, context.request);
const target = await createTempDocument(context.request, null);
createdIds.push(target.documentId);
await openDocument(page, target.workspaceId, target.documentId);
await waitForPageTitleInput(page);
await waitForRuntimeIsland(page);
const titleStatus = await renameThroughPageHead(page, target.documentId, title);
assert((await readVisibleTitle(page)).includes(title), "写入后页头标题应立即显示最新值");
const saveStatus = await typeIntoEditor(page, target.documentId, bodyText);
await page.waitForFunction(
(expectedText) => (document.querySelector(".editor-surface .ProseMirror")?.textContent ?? "").includes(expectedText),
bodyText,
{ timeout: UI_TIMEOUT_MS },
);
const optionStatuses = await setPageOptionsThroughUi(page, target.documentId);
await waitForRuntimePageOptions(page);
const beforeReloadRuntime = await readRuntimePageOptions(page);
assertRuntimeOptions(beforeReloadRuntime, "刷新前");
const { aggregate: beforeReloadAggregate, matchedBlock: beforeReloadBlock } = await waitForAggregateState(
context.request,
target.workspaceId,
target.documentId,
title,
bodyText,
);
await page.reload({ waitUntil: "commit", timeout: UI_TIMEOUT_MS });
await waitForPageTitleInput(page);
await waitForRuntimeIsland(page);
await page.waitForFunction(
(expectedText) => (document.querySelector(".editor-surface .ProseMirror")?.textContent ?? "").includes(expectedText),
bodyText,
{ timeout: UI_TIMEOUT_MS },
);
await waitForRuntimePageOptions(page);
const afterReloadTitle = await readVisibleTitle(page);
const afterReloadEditorText = await readEditorText(page);
const afterReloadRuntime = await readRuntimePageOptions(page);
assert(afterReloadTitle.includes(title), "刷新后页头标题不应回退旧快照");
assert(afterReloadEditorText.includes(bodyText), "刷新后正文不应回退旧快照");
assertRuntimeOptions(afterReloadRuntime, "刷新后");
const afterReloadControls = await readPageSettingsControls(page);
assert.equal(afterReloadControls.wideLayout, true, "刷新后页面设置 wideLayout 控件应保持 true");
assert.equal(afterReloadControls.smallText, true, "刷新后页面设置 smallText 控件应保持 true");
assert.equal(afterReloadControls.layoutDensity, "compact", "刷新后页面设置 layoutDensity 控件应保持 compact");
const { aggregate: afterReloadAggregate, matchedBlock: afterReloadBlock } = await waitForAggregateState(
context.request,
target.workspaceId,
target.documentId,
title,
bodyText,
);
await page.screenshot({ path: screenshotPath, fullPage: false });
const evidence = {
ok: true,
baseUrl: BASE_URL,
viewerUserId: viewer.userId,
workspaceId: target.workspaceId,
documentId: target.documentId,
expected: {
title,
bodyText,
pageOptions: {
wideLayout: true,
smallText: true,
layoutDensity: "compact",
},
},
commandStatuses: {
titleStatus,
saveStatus,
...optionStatuses,
},
beforeReload: {
aggregate: {
title: beforeReloadAggregate.head?.title ?? null,
revision: beforeReloadAggregate.body?.revision ?? null,
conflictDetectionKey: beforeReloadAggregate.body?.conflictDetectionKey ?? null,
pageOptions: beforeReloadAggregate.layout?.pageOptions ?? null,
blockProjectionVersion: beforeReloadAggregate.body?.blockProjectionVersion ?? null,
},
matchedBlock: {
blockId: beforeReloadBlock.blockId,
text: beforeReloadBlock.text,
revisionRef: beforeReloadBlock.revisionRef,
},
runtime: beforeReloadRuntime,
},
afterReload: {
aggregate: {
title: afterReloadAggregate.head?.title ?? null,
revision: afterReloadAggregate.body?.revision ?? null,
conflictDetectionKey: afterReloadAggregate.body?.conflictDetectionKey ?? null,
pageOptions: afterReloadAggregate.layout?.pageOptions ?? null,
blockProjectionVersion: afterReloadAggregate.body?.blockProjectionVersion ?? null,
},
matchedBlock: {
blockId: afterReloadBlock.blockId,
text: afterReloadBlock.text,
revisionRef: afterReloadBlock.revisionRef,
},
title: afterReloadTitle,
editorText: afterReloadEditorText,
runtime: afterReloadRuntime,
controls: afterReloadControls,
},
screenshotPath,
evidencePath,
};
await fs.writeFile(evidencePath, JSON.stringify(evidence, null, 2), "utf8");
console.log(JSON.stringify(evidence, null, 2));
} finally {
await cleanupDocuments(context.request, createdIds).catch(() => undefined);
await page.close().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);
});
@@ -0,0 +1,235 @@
#!/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);
});
@@ -0,0 +1,199 @@
#!/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);
});
@@ -0,0 +1,270 @@
#!/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,
cleanupDocuments,
createTempDocument,
ensureAuthenticated,
openDocument,
renameDocument,
requestJson,
} = require("./tree-shell-smoke-helpers");
const OUT_DIR = path.join(process.cwd(), "tmp", "page-block-ai-conflict-idempotency-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 callMnoteToolRaw(request, payload) {
const response = await request.fetch(`${BASE_URL}/api/hermes/tools/mnote/call`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-mnote-actor-id": payload.actorId || "smoke-user",
},
data: JSON.stringify(payload),
});
const text = await response.text();
let body = null;
try {
body = text ? JSON.parse(text) : null;
} catch {
body = text;
}
return {
status: response.status(),
headers: response.headers(),
body,
text,
};
}
function blockById(blocks, blockId) {
return blocks.find((block) => block.blockId === blockId);
}
async function fetchBlocks(request, target, suffix, actorId, label) {
const response = await callMnoteTool(request, {
toolName: "mnote.doc.fetch",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_conflict_fetch_${suffix}`,
runId: `run_conflict_fetch_${suffix}_${label}`,
toolCallId: `call_conflict_fetch_${suffix}_${label}`,
traceId: `trace_conflict_fetch_${suffix}_${label}`,
capabilityScope: ["page.read"],
args: {
scope: "full",
detail: "with_ids",
maxBlocks: 20,
},
});
assert.equal(response.ok, true, `${label}: doc.fetch 应成功`);
assert(Array.isArray(response.result.blocks), `${label}: doc.fetch 应返回 blocks`);
return response.result;
}
async function main() {
const suffix = Date.now().toString(36);
const title = `TEST-AI-CONFLICT-IDEMPOTENCY-${suffix}`;
const createdIds = [];
const evidence = {
ok: false,
baseUrl: BASE_URL,
title,
steps: [],
};
await fs.mkdir(OUT_DIR, { recursive: true });
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await context.newPage();
try {
const viewer = await ensureAuthenticated(page, context.request);
const actorId = viewer.userId || "smoke-user";
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_conflict_seed_${suffix}`,
runId: `run_conflict_seed_${suffix}`,
toolCallId: `call_conflict_seed_${suffix}`,
traceId: `trace_conflict_seed_${suffix}`,
idempotencyKey: `idem_conflict_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}` }] },
],
},
});
assert.equal(seed.result.commandName, "page.body.save", "初始化必须走 page.body.save");
evidence.steps.push({ name: "seed", commandName: seed.result.commandName });
const initial = await fetchBlocks(context.request, target, suffix, actorId, "initial");
const initialP2 = blockById(initial.blocks, "p_2");
assert(initialP2?.revisionRef, "初始化后 p_2 必须有 revisionRef");
const firstText = `第二段首次替换 ${suffix}`;
const idempotencyKey = `idem_conflict_replace_${suffix}`;
const firstReplacePayload = {
toolName: "mnote.block.replace",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_conflict_replace_${suffix}`,
runId: `run_conflict_replace_first_${suffix}`,
toolCallId: `call_conflict_replace_first_${suffix}`,
traceId: `trace_conflict_replace_first_${suffix}`,
idempotencyKey,
dryRun: false,
capabilityScope: ["block.write"],
args: {
blockId: "p_2",
content: firstText,
revision: initial.revision,
conflictDetectionKey: initial.conflictDetectionKey,
blockRevisionRef: initialP2.revisionRef,
},
};
const firstReplace = await callMnoteTool(context.request, firstReplacePayload);
assert.equal(firstReplace.result.commandName, "page.body.save", "block.replace 必须走 page.body.save");
evidence.steps.push({
name: "block.replace.first",
commandId: firstReplace.audit.commandId,
idempotencyKey,
});
const afterFirst = await fetchBlocks(context.request, target, suffix, actorId, "after_first");
const afterFirstP2 = blockById(afterFirst.blocks, "p_2");
assert.equal(afterFirstP2.text, firstText, "首次 replace 后 AI fetch 必须回读新文本");
assert.notEqual(afterFirstP2.revisionRef, initialP2.revisionRef, "首次 replace 后 p_2 revisionRef 应变化");
const replayText = `不应被重复 idempotency 写入 ${suffix}`;
const replay = await callMnoteTool(context.request, {
...firstReplacePayload,
runId: `run_conflict_replace_replay_${suffix}`,
toolCallId: `call_conflict_replace_replay_${suffix}`,
traceId: `trace_conflict_replace_replay_${suffix}`,
args: {
...firstReplacePayload.args,
content: replayText,
},
});
assert.equal(replay.audit.commandId, firstReplace.audit.commandId, "重复 idempotencyKey 应返回缓存 commandId");
const afterReplay = await fetchBlocks(context.request, target, suffix, actorId, "after_replay");
assert.equal(blockById(afterReplay.blocks, "p_2").text, firstText, "重复 idempotencyKey 不应写入新 content");
assert.notEqual(blockById(afterReplay.blocks, "p_2").text, replayText, "重复 idempotencyKey 不应造成二次写入");
assert.equal(afterReplay.revision, afterFirst.revision, "重复 idempotencyKey 后 revision 不应再次递增");
evidence.steps.push({
name: "block.replace.idempotency_replay",
replayCommandId: replay.audit.commandId,
finalText: blockById(afterReplay.blocks, "p_2").text,
revisionAfterFirst: afterFirst.revision,
revisionAfterReplay: afterReplay.revision,
});
const staleRevision = await callMnoteToolRaw(context.request, {
toolName: "mnote.block.replace",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_conflict_stale_revision_${suffix}`,
runId: `run_conflict_stale_revision_${suffix}`,
toolCallId: `call_conflict_stale_revision_${suffix}`,
traceId: `trace_conflict_stale_revision_${suffix}`,
idempotencyKey: `idem_conflict_stale_revision_${suffix}`,
dryRun: false,
capabilityScope: ["block.write"],
args: {
blockId: "p_2",
content: `旧 revision 不应写入 ${suffix}`,
revision: initial.revision,
conflictDetectionKey: initial.conflictDetectionKey,
blockRevisionRef: afterFirstP2.revisionRef,
},
});
assert.equal(staleRevision.status, 400, "旧 revision 写入应返回 400");
assert.equal(staleRevision.headers["x-error-code"], "mnote_tool_conflict", "旧 revision 应返回 mnote_tool_conflict");
assert.equal(staleRevision.body?.code, "mnote_tool_conflict", "旧 revision body 应返回 mnote_tool_conflict");
evidence.steps.push({
name: "block.replace.stale_revision",
status: staleRevision.status,
errorCode: staleRevision.body?.code,
});
const staleBlockRef = await callMnoteToolRaw(context.request, {
toolName: "mnote.block.replace",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_conflict_stale_block_ref_${suffix}`,
runId: `run_conflict_stale_block_ref_${suffix}`,
toolCallId: `call_conflict_stale_block_ref_${suffix}`,
traceId: `trace_conflict_stale_block_ref_${suffix}`,
idempotencyKey: `idem_conflict_stale_block_ref_${suffix}`,
dryRun: false,
capabilityScope: ["block.write"],
args: {
blockId: "p_2",
content: `旧 blockRevisionRef 不应写入 ${suffix}`,
revision: afterFirst.revision,
conflictDetectionKey: afterFirst.conflictDetectionKey,
blockRevisionRef: initialP2.revisionRef,
},
});
assert.equal(staleBlockRef.status, 400, "旧 blockRevisionRef 写入应返回 400");
assert.equal(staleBlockRef.headers["x-error-code"], "mnote_tool_conflict", "旧 blockRevisionRef 应返回 mnote_tool_conflict");
assert.equal(staleBlockRef.body?.code, "mnote_tool_conflict", "旧 blockRevisionRef body 应返回 mnote_tool_conflict");
evidence.steps.push({
name: "block.replace.stale_block_revision_ref",
status: staleBlockRef.status,
errorCode: staleBlockRef.body?.code,
});
const finalSnapshot = await fetchBlocks(context.request, target, suffix, actorId, "final");
assert.equal(blockById(finalSnapshot.blocks, "p_2").text, firstText, "conflict 失败后正文应保持首次替换结果");
await openDocument(page, target.workspaceId, target.documentId);
await page.getByText(firstText).waitFor({ state: "visible", timeout: 30_000 });
const screenshotPath = path.join(OUT_DIR, `${suffix}-page.png`);
await page.screenshot({ path: screenshotPath, fullPage: true });
evidence.screenshot = screenshotPath;
evidence.finalRevision = finalSnapshot.revision;
evidence.finalText = blockById(finalSnapshot.blocks, "p_2").text;
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);
});
@@ -0,0 +1,315 @@
#!/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,
cleanupDocuments,
createTempDocument,
ensureAuthenticated,
openDocument,
renameDocument,
requestJson,
} = require("./tree-shell-smoke-helpers");
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", {
method: "POST",
headers: { "x-mnote-actor-id": payload.actorId || "smoke-user" },
data: payload,
});
}
function toolByName(manifest, name) {
const tools = manifest?.manifest?.tools || manifest?.tools || [];
const tool = tools.find((candidate) => candidate.name === name);
assert(tool, `manifest 缺少工具:${name}`);
return tool;
}
function assertAnnotationShape(tool, expected) {
assert(tool.annotations, `${tool.name} 缺少 annotations`);
for (const key of [
"readonly",
"destructive",
"idempotent",
"requiresApproval",
"approvalMode",
"runtimeOwner",
"writeOwner",
"selectionEffect",
]) {
assert(Object.hasOwn(tool.annotations, key), `${tool.name} annotations 缺少 ${key}`);
}
for (const [key, value] of Object.entries(expected)) {
assert.deepEqual(tool.annotations[key], value, `${tool.name} annotations.${key} 不符合预期`);
}
}
function assertIncludes(value, fragment, message) {
assert(String(value || "").includes(fragment), message);
}
function assertExcludes(value, fragment, message) {
assert(!String(value || "").includes(fragment), message);
}
async function main() {
const suffix = Date.now().toString(36);
const title = `TEST-AI-CONTEXT-FORMAT-${suffix}`;
const createdIds = [];
const evidence = {
ok: false,
baseUrl: BASE_URL,
title,
steps: [],
};
await fs.mkdir(OUT_DIR, { recursive: true });
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await context.newPage();
try {
const viewer = await ensureAuthenticated(page, context.request);
const actorId = viewer.userId || "smoke-user";
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 manifest = await requestJson(context.request, "/api/hermes/tools/mnote/manifest", {
method: "GET",
headers: { "x-mnote-actor-id": actorId },
});
const docFetchTool = toolByName(manifest, "mnote.doc.fetch");
const blockFetchTool = toolByName(manifest, "mnote.block.fetch");
const blockReplaceTool = toolByName(manifest, "mnote.block.replace");
const pageSaveTool = toolByName(manifest, "mnote.page.save");
assertAnnotationShape(docFetchTool, {
readonly: true,
destructive: false,
runtimeOwner: "mnote-web",
writeOwner: "rust-runtime-kernel",
selectionEffect: "preserve",
});
assertAnnotationShape(blockFetchTool, {
readonly: true,
destructive: false,
selectionEffect: "preserve",
});
assertAnnotationShape(blockReplaceTool, {
readonly: false,
destructive: false,
selectionEffect: "may_change",
});
assertAnnotationShape(pageSaveTool, {
readonly: false,
destructive: true,
approvalMode: "yolo",
selectionEffect: "may_change",
});
evidence.steps.push({
name: "manifest.annotations",
checkedTools: [docFetchTool.name, blockFetchTool.name, blockReplaceTool.name, pageSaveTool.name],
pageSaveDestructive: pageSaveTool.annotations.destructive,
});
const seed = await callMnoteTool(context.request, {
toolName: "mnote.page.save",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_context_seed_${suffix}`,
runId: `run_context_seed_${suffix}`,
toolCallId: `call_context_seed_${suffix}`,
traceId: `trace_context_seed_${suffix}`,
idempotencyKey: `idem_context_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 应成功");
evidence.steps.push({ name: "seed", commandName: seed.result.commandName });
const selectionXml = await callMnoteTool(context.request, {
toolName: "mnote.doc.fetch",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_context_fetch_${suffix}`,
runId: `run_context_fetch_xml_${suffix}`,
toolCallId: `call_context_fetch_xml_${suffix}`,
traceId: `trace_context_fetch_xml_${suffix}`,
capabilityScope: ["page.read"],
args: {
scope: "selection",
selectedBlockIds: ["p_2"],
format: "page_xml",
detail: "with_ids",
maxBlocks: 10,
},
});
assert.equal(selectionXml.result.schema, "mnote.page_ai_context.v1", "doc.fetch 应返回 page AI context schema");
assert.equal(selectionXml.result.scope, "selection", "doc.fetch 应保留 selection scope");
assert.equal(selectionXml.result.format, "page_xml", "doc.fetch 应返回 page_xml format");
assert.deepEqual(selectionXml.result.allowedTargetBlockIds, ["p_2"], "selection 应冻结 allowedTargetBlockIds");
assert.equal(selectionXml.result.blocks.length, 1, "selection 只应返回选中块");
assert.equal(selectionXml.result.blocks[0].blockId, "p_2", "selection 应返回 p_2");
assert(selectionXml.result.blocks[0].revisionRef, "selection block 必须带 revisionRef");
assertIncludes(selectionXml.result.content, '<block id="p_2"', "page_xml 应包含 p_2 block id");
assertIncludes(selectionXml.result.content, 'revisionRef="', "page_xml 应包含 revisionRef");
assertIncludes(selectionXml.result.content, `第二段 ${suffix}`, "page_xml 应包含选中文本");
assertExcludes(selectionXml.result.content, `第一段 ${suffix}`, "page_xml 不应包含未选中 p_1");
assertExcludes(selectionXml.result.content, `第三段 ${suffix}`, "page_xml 不应包含未选中 p_3");
evidence.steps.push({
name: "doc.fetch.selection.page_xml",
schema: selectionXml.result.schema,
allowedTargetBlockIds: selectionXml.result.allowedTargetBlockIds,
revision: selectionXml.result.revision,
conflictDetectionKey: selectionXml.result.conflictDetectionKey,
blockRevisionRef: selectionXml.result.blocks[0].revisionRef,
});
const selectionText = await callMnoteTool(context.request, {
toolName: "mnote.doc.fetch",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_context_fetch_${suffix}`,
runId: `run_context_fetch_text_${suffix}`,
toolCallId: `call_context_fetch_text_${suffix}`,
traceId: `trace_context_fetch_text_${suffix}`,
capabilityScope: ["page.read"],
args: {
scope: "selection",
selectedBlockIds: ["p_2"],
format: "text",
detail: "with_ids",
},
});
assertIncludes(selectionText.result.content, `[p_2] 第二段 ${suffix}`, "text format 应包含选中块 id 与文本");
assertExcludes(selectionText.result.content, `第一段 ${suffix}`, "text format 不应包含未选中 p_1");
assertExcludes(selectionText.result.content, `第三段 ${suffix}`, "text format 不应包含未选中 p_3");
evidence.steps.push({ name: "doc.fetch.selection.text", content: selectionText.result.content });
const blockXml = await callMnoteTool(context.request, {
toolName: "mnote.block.fetch",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_context_block_${suffix}`,
runId: `run_context_block_xml_${suffix}`,
toolCallId: `call_context_block_xml_${suffix}`,
traceId: `trace_context_block_xml_${suffix}`,
capabilityScope: ["block.read"],
args: {
blockId: "p_2",
contextBefore: 1,
contextAfter: 1,
format: "page_xml",
},
});
assert.equal(blockXml.result.block.blockId, "p_2", "block.fetch page_xml 应读取 p_2");
assert.equal(blockXml.result.context.before[0].blockId, "p_1", "block.fetch before 应来自同父级");
assert.equal(blockXml.result.context.after[0].blockId, "p_3", "block.fetch after 应来自同父级");
assertIncludes(blockXml.result.content, '<block id="p_2"', "block.fetch page_xml 应包含目标 block id");
assertIncludes(blockXml.result.content, 'revisionRef="', "block.fetch page_xml 应包含 revisionRef");
const blockText = await callMnoteTool(context.request, {
toolName: "mnote.block.fetch",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_context_block_${suffix}`,
runId: `run_context_block_text_${suffix}`,
toolCallId: `call_context_block_text_${suffix}`,
traceId: `trace_context_block_text_${suffix}`,
capabilityScope: ["block.read"],
args: {
blockId: "p_2",
format: "text",
},
});
assertIncludes(blockText.result.content, `[p_2] 第二段 ${suffix}`, "block.fetch text 应包含块 id 与文本");
evidence.steps.push({
name: "block.fetch.page_xml.text",
blockId: blockXml.result.block.blockId,
revisionRef: blockXml.result.block.revisionRef,
contextBefore: blockXml.result.context.before.map((block) => block.blockId),
contextAfter: blockXml.result.context.after.map((block) => block.blockId),
});
const outOfScopeResponse = await context.request.fetch(`${BASE_URL}/api/hermes/tools/mnote/call`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-mnote-actor-id": actorId,
},
data: JSON.stringify({
toolName: "mnote.doc.apply_block_ops",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_context_scope_${suffix}`,
runId: `run_context_scope_${suffix}`,
toolCallId: `call_context_scope_${suffix}`,
traceId: `trace_context_scope_${suffix}`,
idempotencyKey: `idem_context_scope_${suffix}`,
dryRun: true,
capabilityScope: ["block.write"],
args: {
allowedTargetBlockIds: ["p_2"],
operations: [
{
op: "replace",
blockId: "p_1",
content: `越界修改 ${suffix}`,
},
],
},
}),
});
const outOfScopeText = await outOfScopeResponse.text();
assert.equal(outOfScopeResponse.status(), 400, "选区外写入应被 Rust tool 拒绝");
assertIncludes(outOfScopeText, "mnote_block_target_out_of_scope", "选区外写入应返回明确错误码");
evidence.steps.push({
name: "write.out_of_scope.blocked",
status: outOfScopeResponse.status(),
errorCode: "mnote_block_target_out_of_scope",
});
await openDocument(page, target.workspaceId, target.documentId);
await page.getByText(`第二段 ${suffix}`).waitFor({ state: "visible", timeout: 30_000 });
const screenshotPath = path.join(OUT_DIR, `${suffix}-page.png`);
await page.screenshot({ path: screenshotPath, fullPage: true });
evidence.screenshot = screenshotPath;
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);
});
+296
View File
@@ -0,0 +1,296 @@
#!/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-block-ai-tools-smoke");
function blockById(blocks, blockId) {
return blocks.find((block) => block.blockId === blockId);
}
function blockTexts(blocks) {
return blocks.map((block) => block.text);
}
async function waitForVisibleTexts(page, expectedTexts) {
await page.waitForFunction(
({ expected }) => {
const visibleText = [];
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
while (walker.nextNode()) {
const node = walker.currentNode;
const parent = node.parentElement;
if (!(parent instanceof HTMLElement)) continue;
const style = window.getComputedStyle(parent);
const rect = parent.getBoundingClientRect();
const text = (node.textContent || "").trim();
if (
text &&
style.display !== "none" &&
style.visibility !== "hidden" &&
rect.width > 0 &&
rect.height > 0
) {
visibleText.push(text);
}
}
return expected.every((text) => visibleText.some((visible) => visible.includes(text)));
},
{ expected: expectedTexts },
{ timeout: UI_TIMEOUT_MS },
);
}
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, label, actorId) {
const response = await callMnoteTool(request, {
toolName: "mnote.doc.fetch",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_block_fetch_${suffix}`,
runId: `run_block_fetch_${suffix}_${label}`,
toolCallId: `call_block_fetch_${suffix}_${label}`,
traceId: `trace_block_fetch_${suffix}_${label}`,
capabilityScope: ["page.read"],
args: {
scope: "full",
detail: "with_ids",
maxBlocks: 20,
},
});
assert.equal(response.ok, true, `${label}: doc.fetch 应成功`);
assert(Array.isArray(response.result.blocks), `${label}: doc.fetch 应返回 blocks`);
return response.result;
}
async function main() {
const suffix = Date.now().toString(36);
const title = `TEST-AI-BLOCK-TOOLS-${suffix}`;
const createdIds = [];
const evidence = {
ok: false,
baseUrl: BASE_URL,
title,
steps: [],
};
await fs.mkdir(OUT_DIR, { recursive: true });
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await context.newPage();
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 initialContent = [
{ 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 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: initialContent,
},
});
assert.equal(seed.ok, true, "初始化 page.save 应成功");
evidence.steps.push({ name: "seed", commandName: seed.result.commandName });
let snapshot = await fetchBlocks(context.request, target, suffix, "initial", actorId);
const p1 = blockById(snapshot.blocks, "p_1");
const p2 = blockById(snapshot.blocks, "p_2");
const p3 = blockById(snapshot.blocks, "p_3");
assert(p1 && p2 && p3, "初始化后应能读取 p_1/p_2/p_3");
assert(p1.revisionRef && p2.revisionRef && p3.revisionRef, "块投影必须返回 revisionRef");
evidence.steps.push({
name: "doc.fetch.initial",
revision: snapshot.revision,
conflictDetectionKey: snapshot.conflictDetectionKey,
blockIds: snapshot.blocks.map((block) => block.blockId),
});
const blockFetch = await callMnoteTool(context.request, {
toolName: "mnote.block.fetch",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_block_${suffix}`,
runId: `run_block_${suffix}`,
toolCallId: `call_block_${suffix}`,
traceId: `trace_block_${suffix}`,
capabilityScope: ["block.read"],
args: {
blockId: "p_2",
contextBefore: 1,
contextAfter: 1,
},
});
assert.equal(blockFetch.result.block.blockId, "p_2", "block.fetch 应读取目标块");
assert.equal(blockFetch.result.context.before[0].blockId, "p_1", "block.fetch before 应来自同父级");
assert.equal(blockFetch.result.context.after[0].blockId, "p_3", "block.fetch after 应来自同父级");
evidence.steps.push({ name: "block.fetch", blockId: "p_2" });
const replacedText = `第二段已替换 ${suffix}`;
const replace = await callMnoteTool(context.request, {
toolName: "mnote.block.replace",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_replace_${suffix}`,
runId: `run_replace_${suffix}`,
toolCallId: `call_replace_${suffix}`,
traceId: `trace_replace_${suffix}`,
idempotencyKey: `idem_replace_${suffix}`,
dryRun: false,
capabilityScope: ["block.write"],
args: {
blockId: "p_2",
content: replacedText,
revision: snapshot.revision,
conflictDetectionKey: snapshot.conflictDetectionKey,
blockRevisionRef: p2.revisionRef,
},
});
assert.equal(replace.result.commandName, "page.body.save", "block.replace 必须走 page.body.save");
snapshot = await fetchBlocks(context.request, target, suffix, "after_replace", actorId);
assert.equal(blockById(snapshot.blocks, "p_2").text, replacedText, "替换后 doc.fetch 应回读新文本");
evidence.steps.push({ name: "block.replace", changedBlocks: replace.result.changedBlocks });
const insertedText = `插入段 ${suffix}`;
const anchorAfterReplace = blockById(snapshot.blocks, "p_1");
const insert = await callMnoteTool(context.request, {
toolName: "mnote.block.insert_after",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_insert_${suffix}`,
runId: `run_insert_${suffix}`,
toolCallId: `call_insert_${suffix}`,
traceId: `trace_insert_${suffix}`,
idempotencyKey: `idem_insert_${suffix}`,
dryRun: false,
capabilityScope: ["block.write"],
args: {
anchorBlockId: "p_1",
content: insertedText,
revision: snapshot.revision,
conflictDetectionKey: snapshot.conflictDetectionKey,
anchorRevisionRef: anchorAfterReplace.revisionRef,
},
});
const insertedBlockId = insert.result.changedBlocks[0].blockId;
assert(insertedBlockId.startsWith("ai_block_"), "插入块 id 必须由 Rust/mnote 侧生成");
snapshot = await fetchBlocks(context.request, target, suffix, "after_insert", actorId);
assert.equal(blockById(snapshot.blocks, insertedBlockId).text, insertedText, "插入后 doc.fetch 应回读新块");
evidence.steps.push({ name: "block.insert_after", insertedBlockId });
const moveBlock = blockById(snapshot.blocks, "p_3");
const moveAnchor = blockById(snapshot.blocks, "p_1");
const moveDryRun = await callMnoteTool(context.request, {
toolName: "mnote.doc.plan_update",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_plan_${suffix}`,
runId: `run_plan_${suffix}`,
toolCallId: `call_plan_${suffix}`,
traceId: `trace_plan_${suffix}`,
idempotencyKey: `idem_plan_${suffix}`,
dryRun: true,
capabilityScope: ["block.write"],
args: {
command: "block_move_after",
blockId: "p_3",
anchorBlockId: "p_1",
},
});
assert.equal(moveDryRun.result.blocked, false, "同父级普通叶子块 move dry-run 不应阻断");
const move = await callMnoteTool(context.request, {
toolName: "mnote.block.move_after",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_move_${suffix}`,
runId: `run_move_${suffix}`,
toolCallId: `call_move_${suffix}`,
traceId: `trace_move_${suffix}`,
idempotencyKey: `idem_move_${suffix}`,
dryRun: false,
capabilityScope: ["block.write"],
args: {
blockId: "p_3",
anchorBlockId: "p_1",
revision: snapshot.revision,
conflictDetectionKey: snapshot.conflictDetectionKey,
blockRevisionRef: moveBlock.revisionRef,
anchorRevisionRef: moveAnchor.revisionRef,
},
});
assert.equal(move.result.changedBlocks[0].op, "move_after", "move_after 应返回 changedBlocks");
snapshot = await fetchBlocks(context.request, target, suffix, "after_move", actorId);
const order = snapshot.blocks.map((block) => block.blockId);
assert(order.indexOf("p_3") === order.indexOf("p_1") + 1, "移动后 p_3 必须紧跟 p_1");
evidence.steps.push({ name: "block.move_after", order, texts: blockTexts(snapshot.blocks) });
await openDocument(page, target.workspaceId, target.documentId);
await waitForVisibleTexts(page, [replacedText, insertedText, `第三段 ${suffix}`]);
const screenshotPath = path.join(OUT_DIR, `${suffix}-page.png`);
await page.screenshot({ path: screenshotPath, fullPage: true });
evidence.screenshot = screenshotPath;
evidence.visibleTextCheck = [replacedText, insertedText, `第三段 ${suffix}`];
evidence.finalTexts = blockTexts(snapshot.blocks);
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);
});
-312
View File
@@ -1,312 +0,0 @@
"use strict";
if (process.env.MNOTE_ALLOW_RETIRED_AI_AGENT_RUN_SMOKE !== "1") {
console.log(JSON.stringify({
ok: true,
retired: true,
script: "task052-ai-tools-runtime-smoke.js",
reason: "旧 /api/ai-agent/run AI tools runtime smoke 已退役;当前请运行 scripts/task-hermes-page-ai-retirement-guard.js 验证 legacy guard。",
}, null, 2));
process.exit(0);
}
const { chromium } = require("playwright");
const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
const REQUEST_TIMEOUT_MS = 120_000;
const UI_TIMEOUT_MS = 30_000;
const TEST_LOGIN_BUTTON_NAME = "测试账号快速登录";
async function requestJsonWithCookieHeader(path, init = {}, cookieHeader = "") {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const response = await fetch(`${BASE_URL}${path}`, {
...init,
headers: {
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
...(init.headers || {}),
...(cookieHeader ? { cookie: cookieHeader } : {}),
},
body: init.data !== undefined ? JSON.stringify(init.data) : init.body,
signal: controller.signal,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok) {
throw new Error(
`${path} 请求失败: ${response.status} ${response.statusText} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`,
);
}
const contentType = response.headers.get("content-type") || "";
if (!contentType.includes("application/json")) {
throw new Error(`${path} 返回了非 JSON 内容:${String(text).slice(0, 200)}`);
}
return payload;
} finally {
clearTimeout(timer);
}
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
async function requestJson(requestContext, path, init = {}) {
const response = await requestContext.fetch(`${BASE_URL}${path}`, {
...init,
headers:
init.data !== undefined
? {
"content-type": "application/json",
...(init.headers || {}),
}
: {
...(init.headers || {}),
},
timeout: REQUEST_TIMEOUT_MS,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok()) {
throw new Error(
`${path} 请求失败: ${response.status()} ${response.statusText()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`,
);
}
const contentType = response.headers()["content-type"] || "";
if (!contentType.includes("application/json")) {
throw new Error(`${path} 返回了非 JSON 内容:${String(text).slice(0, 200)}`);
}
return payload;
}
async function ensureAuthenticated(page, requestContext) {
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const tryWhoAmI = async () => {
try {
return await requestJson(requestContext, "/api/auth/whoami", { method: "GET" });
} catch {
return null;
}
};
await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
let viewer = await tryWhoAmI();
if (viewer) return viewer;
const quickLoginButton = page.getByRole("button", { name: TEST_LOGIN_BUTTON_NAME });
await quickLoginButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
for (let i = 0; i < 20; i += 1) {
await sleep(500);
viewer = await tryWhoAmI();
if (viewer) return viewer;
}
throw new Error("测试账号快速登录后仍无法获取 whoami");
}
async function createTempDocument(requestContext) {
const payload = await requestJson(requestContext, "/api/documents/create", {
method: "POST",
data: { parentId: null },
});
assert(payload && typeof payload.id === "string", "创建临时页面失败:缺少 id");
assert(payload && typeof payload.workspace_id === "string", "创建临时页面失败:缺少 workspace_id");
return { documentId: payload.id, workspaceId: payload.workspace_id };
}
async function renameDocument(requestContext, documentId, workspaceId, title) {
return await requestJson(requestContext, "/api/documents/title", {
method: "POST",
data: { documentId, workspaceId, title, commandName: "page.head.updateTitle" },
});
}
async function saveDocument(requestContext, documentId, workspaceId, content) {
return await requestJson(requestContext, "/api/documents/save", {
method: "POST",
data: { documentId, workspaceId, content },
});
}
async function purgeTempDocument(requestContext, documentId) {
return await requestJson(requestContext, "/api/documents/purge", {
method: "POST",
data: { documentId },
});
}
async function runAiAgentDocsSmoke(requestContext, uniqueTitle, needleText, cookieHeader) {
const payload = await requestJsonWithCookieHeader("/api/ai-agent/run", {
method: "POST",
data: {
stream: false,
maxSteps: 4,
scope: "document",
messages: [
{
role: "user",
content: `<docs_search>{"query":"${uniqueTitle}","limit":5}</docs_search>`,
},
],
toolChoice: {
mode: "manual",
toolSets: ["toolset.docs_read"],
tools: ["docs_search", "docs_read"],
},
context: {
documentId: "smoke-doc-context",
documentBlocks: [],
},
options: {
ai: {
provider: "codex",
sessionId: "",
},
},
},
}, cookieHeader);
assert(Array.isArray(payload.events), "AI Agent 返回缺少 events");
const toolResults = payload.events.filter((event) => event && event.type === "tool_result");
assert(toolResults.length >= 1, "AI Agent 未返回任何 tool_result");
const searchResultEvent = toolResults.find((event) => event.data && event.data.tool === "docs_search" && event.data.ok === true);
assert(searchResultEvent, "docs_search 未成功执行");
const searchResults = searchResultEvent.data.result && Array.isArray(searchResultEvent.data.result.results)
? searchResultEvent.data.result.results
: [];
assert(searchResults.length >= 1, "docs_search 没有返回结果");
const top = searchResults[0];
assert(typeof top.id === "string" && top.id, "docs_search 首条结果缺少 documentId");
const readPayload = await requestJsonWithCookieHeader("/api/ai-agent/run", {
method: "POST",
data: {
stream: false,
maxSteps: 4,
scope: "document",
messages: [
{
role: "user",
content: `<docs_read>{"documentId":"${top.id}","maxChars":2500,"includeContent":false}</docs_read>`,
},
],
toolChoice: {
mode: "manual",
toolSets: ["toolset.docs_read"],
tools: ["docs_read"],
},
context: {
documentId: "smoke-doc-context",
documentBlocks: [],
},
options: {
ai: {
provider: "codex",
sessionId: "",
},
},
},
}, cookieHeader);
assert(Array.isArray(readPayload.events), "docs_read 返回缺少 events");
const readEvent = readPayload.events.find((event) => event && event.type === "tool_result" && event.data && event.data.tool === "docs_read" && event.data.ok === true);
assert(readEvent, "docs_read 未成功执行");
const rawText = String(readEvent.data.result?.rawText ?? "");
assert(rawText.includes(needleText), `docs_read 返回未命中预期正文片段:${needleText}`);
return {
searchDocumentId: top.id,
searchResultsCount: searchResults.length,
readRawTextLength: Number(readEvent.data.result?.rawTextLength ?? 0),
};
}
async function main() {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
const page = await context.newPage();
let tempDocument = null;
let caughtError = null;
try {
await ensureAuthenticated(page, context.request);
tempDocument = await createTempDocument(context.request);
const uniqueSuffix = `${Date.now()}`;
const uniqueTitle = `task052-ai-runtime-${uniqueSuffix}`;
const needleText = `task052 Rust docs runtime smoke ${uniqueSuffix}`;
await renameDocument(context.request, tempDocument.documentId, tempDocument.workspaceId, uniqueTitle);
await saveDocument(context.request, tempDocument.documentId, tempDocument.workspaceId, [
{
id: `task052_block_${uniqueSuffix}`,
type: "paragraph",
props: {},
content: [{ type: "text", text: needleText }],
children: [],
},
]);
const cookies = await context.cookies(BASE_URL);
const cookieHeader = cookies.map((item) => `${item.name}=${item.value}`).join("; ");
const runtime = await runAiAgentDocsSmoke(context.request, uniqueTitle, needleText, cookieHeader);
console.log(JSON.stringify({
ok: true,
workspaceId: tempDocument.workspaceId,
documentId: tempDocument.documentId,
title: uniqueTitle,
needleText,
...runtime,
}, null, 2));
} catch (error) {
caughtError = error;
} finally {
if (tempDocument?.documentId) {
try {
await purgeTempDocument(context.request, tempDocument.documentId);
} catch (cleanupError) {
if (!caughtError) {
caughtError = cleanupError;
} else {
console.error(`清理临时页面失败:${cleanupError instanceof Error ? cleanupError.stack || cleanupError.message : String(cleanupError)}`);
}
}
}
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
if (caughtError) {
throw caughtError;
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
@@ -1,611 +0,0 @@
"use strict";
if (process.env.MNOTE_ALLOW_RETIRED_AI_AGENT_RUN_SMOKE !== "1") {
console.log(JSON.stringify({
ok: true,
retired: true,
script: "task111-phase7-document-ai-online-smoke.js",
reason: "旧 /api/ai-agent/run phase7 online smoke 已退役;当前请运行 scripts/task-hermes-page-ai-retirement-guard.js 验证 legacy guard。",
}, null, 2));
process.exit(0);
}
const { chromium } = require("playwright");
const {
BASE_URL,
REQUEST_TIMEOUT_MS,
UI_TIMEOUT_MS,
assert,
createTempDocument,
ensureAuthenticated,
openDocument,
purgeDocument,
renameDocument,
requestJson,
} = require("./tree-shell-smoke-helpers");
const AI_TIMEOUT_MS = Number(process.env.MNOTE_AI_SMOKE_TIMEOUT_MS || 180_000);
function parseJsonSafely(text) {
try {
return text ? JSON.parse(text) : null;
} catch {
return text;
}
}
function parseSseText(rawText) {
return String(rawText || "")
.split(/\n\n+/)
.map((chunk) => chunk.trim())
.filter(Boolean)
.filter((chunk) => !chunk.startsWith(":"))
.map((chunk) => {
const lines = chunk.split(/\r?\n/);
let type = "message";
const dataLines = [];
for (const line of lines) {
if (line.startsWith("event:")) {
type = line.slice("event:".length).trim() || "message";
continue;
}
if (line.startsWith("data:")) {
dataLines.push(line.slice("data:".length).trimStart());
}
}
const dataText = dataLines.join("\n");
return {
type,
dataText,
data: parseJsonSafely(dataText),
};
});
}
function serializeForError(value) {
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
async function requestSseEvents(requestContext, path, body) {
const response = await requestContext.fetch(`${BASE_URL}${path}`, {
method: "POST",
headers: {
"content-type": "application/json",
},
data: body,
timeout: AI_TIMEOUT_MS,
});
const text = await response.text();
if (!response.ok()) {
throw new Error(`${path} 请求失败: ${response.status()} ${response.statusText()} ${text.slice(0, 600)}`);
}
const contentType = response.headers()["content-type"] || "";
assert(contentType.includes("text/event-stream"), `${path} 未返回 SSE${contentType}`);
return parseSseText(text);
}
async function fetchPageAggregate(requestContext, documentId, workspaceId) {
return await requestJson(
requestContext,
`/api/page-aggregate/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`,
{ method: "GET" },
);
}
async function fetchDocumentMeta(requestContext, documentId, workspaceId) {
return await requestJson(
requestContext,
`/api/documents/meta?documentId=${encodeURIComponent(documentId)}&workspaceId=${encodeURIComponent(workspaceId)}`,
{ method: "GET" },
);
}
async function fetchDocumentContent(requestContext, documentId, workspaceId) {
return await requestJson(
requestContext,
`/api/documents/content?documentId=${encodeURIComponent(documentId)}&workspaceId=${encodeURIComponent(workspaceId)}`,
{ method: "GET" },
);
}
function buildAiContextFromPageAggregate(pagePayload) {
const page = pagePayload?.result ?? null;
const pageSubtree = page?.tree?.pageSubtree ?? null;
const subtreeNodes = Array.isArray(pageSubtree?.subtree?.nodes)
? pageSubtree.subtree.nodes.slice(0, 160)
: [];
return {
documentId: page?.identity?.documentId ?? null,
documentBlocks: page?.body?.content ?? null,
node: pageSubtree?.rootNode ?? null,
subtree: pageSubtree
? {
projectionId: pageSubtree.projectionId ?? null,
rootNodeId: pageSubtree?.subtree?.rootNodeId ?? null,
stats: pageSubtree?.stats ?? null,
nodes: subtreeNodes,
}
: null,
outline: Array.isArray(pageSubtree?.outline) ? pageSubtree.outline.slice(0, 40) : null,
evidence: Array.isArray(pageSubtree?.evidence) ? pageSubtree.evidence.slice(0, 32) : null,
pageOptions: page?.layout?.pageOptions ?? {},
};
}
function findSuccessfulToolResult(events, toolName) {
return [...events]
.reverse()
.find(
(event) =>
event?.type === "tool_result" &&
event?.data &&
event.data.tool === toolName &&
event.data.ok === true,
) ?? null;
}
function findErrorEvent(events) {
return events.find((event) => event?.type === "error") ?? null;
}
function extractRenamedTitleFromSlashToolResult(toolEvent, documentId) {
if (!toolEvent?.data?.result || toolEvent?.data?.tool !== "slash_run") {
return null;
}
const resultRecord =
toolEvent.data.result && typeof toolEvent.data.result === "object"
? toolEvent.data.result
: null;
const parsed =
resultRecord?.parsed && typeof resultRecord.parsed === "object"
? resultRecord.parsed
: null;
const params =
parsed?.params && typeof parsed.params === "object"
? parsed.params
: null;
if (!parsed || parsed.command !== "rename_doc" || !params) {
return null;
}
if (String(params.documentId ?? "") !== documentId) {
return null;
}
const title = String(params.title ?? "").trim();
return title || null;
}
function extractBodySnapshotFromToolResult(toolEvent) {
if (!toolEvent?.data?.result || !["doc_insert_blocks", "doc_replace_range"].includes(toolEvent?.data?.tool)) {
return null;
}
const resultRecord =
toolEvent.data.result && typeof toolEvent.data.result === "object"
? toolEvent.data.result
: null;
return resultRecord && Array.isArray(resultRecord.data) ? resultRecord.data : null;
}
function extractInsertedBlockIdFromToolResult(toolEvent) {
if (!toolEvent?.data?.result || toolEvent?.data?.tool !== "doc_insert_blocks") {
return null;
}
const resultRecord =
toolEvent.data.result && typeof toolEvent.data.result === "object"
? toolEvent.data.result
: null;
const inserted = resultRecord && Array.isArray(resultRecord.inserted) ? resultRecord.inserted : null;
const blockId = typeof inserted?.[0] === "string" ? inserted[0].trim() : "";
return blockId || null;
}
async function persistAiTitleResult(requestContext, fixture, toolEvent) {
const renamedTitle = extractRenamedTitleFromSlashToolResult(toolEvent, fixture.documentId);
assert(renamedTitle, `slash_run 结果未产出当前页标题:${serializeForError(toolEvent)}`);
await renameDocument(requestContext, fixture.workspaceId, fixture.documentId, renamedTitle);
return renamedTitle;
}
async function persistAiBodyResult(requestContext, fixture, toolEvent) {
const nextBlocks = extractBodySnapshotFromToolResult(toolEvent);
assert(Array.isArray(nextBlocks), `文档写工具未返回 legacy blocks 快照:${serializeForError(toolEvent)}`);
const aggregate = await fetchPageAggregate(requestContext, fixture.documentId, fixture.workspaceId);
await requestJson(requestContext, "/api/documents/save", {
method: "POST",
data: {
documentId: fixture.documentId,
workspaceId: fixture.workspaceId,
// task111 对齐正式 page body save:正文写工具结果只接受 legacy blocks 快照。
content: nextBlocks,
revision: aggregate?.page?.body?.revision ?? null,
conflictDetectionKey: aggregate?.page?.body?.conflictDetectionKey ?? null,
snapshotCapturedAt: new Date().toISOString(),
blockCount: nextBlocks.length,
},
});
return nextBlocks;
}
async function waitFor(check, description, timeoutMs = UI_TIMEOUT_MS, intervalMs = 500) {
const deadline = Date.now() + timeoutMs;
let lastValue = null;
while (Date.now() < deadline) {
lastValue = await check();
if (lastValue) {
return lastValue;
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error(`${description} 超时,最后结果:${serializeForError(lastValue)}`);
}
async function waitForPageAggregateBodyText(requestContext, fixture, expectedText) {
await waitFor(
async () => {
const pagePayload = await fetchPageAggregate(requestContext, fixture.documentId, fixture.workspaceId);
const raw = JSON.stringify(pagePayload?.page?.body?.content ?? null);
return raw.includes(expectedText) ? pagePayload : null;
},
`等待 page aggregate 正文同步到 ${expectedText}`,
AI_TIMEOUT_MS,
);
}
async function waitForRuntimeIsland(page) {
await page.waitForFunction(
() => {
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const editor = host?.querySelector(".editor-surface .ProseMirror[contenteditable='true']");
return (
host?.getAttribute("data-editor-host-kind") === "leptos_tiptap_island" &&
host?.getAttribute("data-runtime-editor-status") !== "error" &&
editor instanceof HTMLElement &&
editor.isContentEditable
);
},
null,
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForTitleInput(page) {
const input = page.getByLabel("页面标题");
await input.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
return input;
}
async function waitForEditorText(page, expectedText) {
await page.waitForFunction(
(text) => {
const editor = document.querySelector(
'[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror',
);
return (editor?.textContent ?? "").includes(text);
},
expectedText,
{ timeout: UI_TIMEOUT_MS },
);
}
async function readVisibleTitle(page) {
const input = page.getByLabel("页面标题");
if (await input.isVisible().catch(() => false)) {
return ((await input.inputValue().catch(() => "")) || "").trim();
}
const heading = page.locator("h1").first();
return ((await heading.textContent().catch(() => "")) || "").trim();
}
async function readEditorText(page) {
return await page.evaluate(() => {
const editor = document.querySelector(
'[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror',
);
return (editor?.textContent ?? "").trim();
});
}
async function typeIntoEditor(page, text) {
const editor = page
.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]')
.first();
await editor.click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.press("Control+a");
await page.keyboard.press("Backspace");
await page.keyboard.type(text, { delay: 20 });
}
async function waitForPersistedSave(page, saveRequests, documentId, expectedText) {
const deadline = Date.now() + UI_TIMEOUT_MS;
while (Date.now() < deadline) {
const editorText = await readEditorText(page);
const hasSaveRequest = saveRequests.some((item) => item.documentId === documentId);
const runtimeStatus = await page.evaluate(() => {
return (
document
.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]')
?.getAttribute("data-runtime-editor-status") ?? null
);
});
if (editorText.includes(expectedText) && (runtimeStatus === "saved" || hasSaveRequest)) {
return;
}
await page.waitForTimeout(250);
}
throw new Error(`等待编辑器保存超时:${documentId}`);
}
async function runTitleRename(requestContext, fixture, renamedTitle) {
const aggregate = await fetchPageAggregate(requestContext, fixture.documentId, fixture.workspaceId);
const events = await requestSseEvents(requestContext, "/api/ai-agent/run", {
stream: true,
maxSteps: 8,
scope: "document",
messages: [
{
role: "user",
content: `请把当前页面标题改成“${renamedTitle}”。只改标题,不要改正文;改完后用一句话确认。`,
},
],
context: buildAiContextFromPageAggregate(aggregate),
options: {
ai: {
provider: "online",
},
},
});
const errorEvent = findErrorEvent(events);
assert(!errorEvent, `标题改名流返回 error${serializeForError(errorEvent)}`);
const slashResult = findSuccessfulToolResult(events, "slash_run");
assert(slashResult, `标题改名未得到成功的 slash_run:${serializeForError(events)}`);
const persistedTitle = await persistAiTitleResult(requestContext, fixture, slashResult);
assert(
persistedTitle === renamedTitle,
`AI 标题结果与目标不一致,期望 ${renamedTitle},实际 ${persistedTitle}`,
);
await waitFor(
async () => {
const meta = await fetchDocumentMeta(requestContext, fixture.documentId, fixture.workspaceId);
return String(meta?.doc?.title ?? "").trim() === renamedTitle ? meta : null;
},
"等待标题改名落盘",
AI_TIMEOUT_MS,
);
return events;
}
async function runBodyInsert(requestContext, fixture, initialBody) {
const aggregate = await fetchPageAggregate(requestContext, fixture.documentId, fixture.workspaceId);
const events = await requestSseEvents(requestContext, "/api/ai-agent/run", {
stream: true,
maxSteps: 8,
scope: "document",
messages: [
{
role: "user",
content: `请在当前页面插入一段正文:“${initialBody}”。只插入这一段,不要改标题;完成后简短确认。`,
},
],
context: buildAiContextFromPageAggregate(aggregate),
options: {
ai: {
provider: "online",
},
},
});
const errorEvent = findErrorEvent(events);
assert(!errorEvent, `正文插入流返回 error${serializeForError(errorEvent)}`);
const insertResult = findSuccessfulToolResult(events, "doc_insert_blocks");
assert(insertResult, `正文插入未得到成功的 doc_insert_blocks${serializeForError(events)}`);
await persistAiBodyResult(requestContext, fixture, insertResult);
const insertedBlockId = extractInsertedBlockIdFromToolResult(insertResult);
assert(insertedBlockId, `正文插入结果缺少 inserted blockId${serializeForError(insertResult)}`);
await waitFor(
async () => {
const content = await fetchDocumentContent(requestContext, fixture.documentId, fixture.workspaceId);
const raw = JSON.stringify(content?.content ?? null);
return raw.includes(initialBody) ? content : null;
},
"等待正文插入落盘",
AI_TIMEOUT_MS,
);
await waitForPageAggregateBodyText(requestContext, fixture, initialBody);
return {
events,
insertedBlockId,
};
}
async function runBodyRewrite(requestContext, fixture, targetBlockId, rewrittenBody) {
const aggregate = await fetchPageAggregate(requestContext, fixture.documentId, fixture.workspaceId);
const events = await requestSseEvents(requestContext, "/api/ai-agent/run", {
stream: true,
maxSteps: 8,
scope: "document",
messages: [
{
role: "user",
content: `请把当前页面中 blockId 为“${targetBlockId}”的那一段改写为“${rewrittenBody}”。只改这个 blockId 对应的段落,不要改标题;直接用 doc_replace_range 完成,不需要先搜索。改完后简短确认。`,
},
],
context: buildAiContextFromPageAggregate(aggregate),
options: {
ai: {
provider: "online",
},
},
});
const errorEvent = findErrorEvent(events);
assert(!errorEvent, `正文改写流返回 error${serializeForError(errorEvent)}`);
const replaceResult = findSuccessfulToolResult(events, "doc_replace_range");
assert(replaceResult, `正文改写未得到成功的 doc_replace_range${serializeForError(events)}`);
await persistAiBodyResult(requestContext, fixture, replaceResult);
await waitFor(
async () => {
const content = await fetchDocumentContent(requestContext, fixture.documentId, fixture.workspaceId);
const raw = JSON.stringify(content?.content ?? null);
return raw.includes(rewrittenBody) ? content : null;
},
"等待正文改写落盘",
AI_TIMEOUT_MS,
);
return events;
}
async function verifyDocumentUi(page, fixture, expectedTitle, expectedBody) {
await openDocument(page, fixture.workspaceId, fixture.documentId);
await waitForRuntimeIsland(page);
await waitForTitleInput(page);
await waitForEditorText(page, expectedBody);
const visibleTitle = await readVisibleTitle(page);
const editorText = await readEditorText(page);
assert(
visibleTitle.includes(expectedTitle),
`页面标题未同步到 UI,期望包含 ${expectedTitle},实际为 ${visibleTitle}`,
);
assert(
editorText.includes(expectedBody),
`编辑区正文未同步到 UI,期望包含 ${expectedBody},实际为 ${editorText}`,
);
}
async function main() {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
});
const page = await context.newPage();
let fixture = null;
let caughtError = null;
try {
const viewer = await ensureAuthenticated(page, context.request);
fixture = await createTempDocument(context.request, null);
const uniqueSuffix = Date.now().toString().slice(-6);
const initialTitle = `phase7-ai-initial-${uniqueSuffix}`;
const renamedTitle = `phase7-ai-renamed-${uniqueSuffix}`;
const initialBody = `phase7 AI 原始正文 ${uniqueSuffix}`;
const rewrittenBody = `phase7 AI 改写正文 ${uniqueSuffix}`;
await renameDocument(context.request, fixture.workspaceId, fixture.documentId, initialTitle);
const insertRun = await runBodyInsert(context.request, fixture, initialBody);
const renameEvents = await runTitleRename(context.request, fixture, renamedTitle);
const rewriteEvents = await runBodyRewrite(
context.request,
fixture,
insertRun.insertedBlockId,
rewrittenBody,
);
const meta = await fetchDocumentMeta(context.request, fixture.documentId, fixture.workspaceId);
const content = await fetchDocumentContent(context.request, fixture.documentId, fixture.workspaceId);
assert(
String(meta?.doc?.title ?? "").trim() === renamedTitle,
`标题未完成持久化,期望 ${renamedTitle},实际 ${String(meta?.doc?.title ?? "").trim()}`,
);
const serializedContent = JSON.stringify(content?.content ?? null);
assert(
serializedContent.includes(rewrittenBody),
`正文未完成持久化,未命中 ${rewrittenBody}`,
);
assert(
!serializedContent.includes(initialBody),
`正文仍保留旧文本 ${initialBody}`,
);
const pageAggregate = await fetchPageAggregate(context.request, fixture.documentId, fixture.workspaceId);
assert(
String(pageAggregate?.page?.head?.title ?? "").trim() === renamedTitle,
`page aggregate 标题未更新为 ${renamedTitle}`,
);
assert(
JSON.stringify(pageAggregate?.page?.body?.content ?? null).includes(rewrittenBody),
`page aggregate 正文未更新为 ${rewrittenBody}`,
);
console.log(
JSON.stringify(
{
ok: true,
baseUrl: BASE_URL,
viewerUserId: viewer.userId,
workspaceId: fixture.workspaceId,
documentId: fixture.documentId,
renamedTitle,
rewrittenBody,
insertToolSequence: insertRun.events
.filter((event) => event.type === "tool_result")
.map((event) => event.data?.tool ?? null)
.filter(Boolean),
renameToolSequence: renameEvents
.filter((event) => event.type === "tool_result")
.map((event) => event.data?.tool ?? null)
.filter(Boolean),
rewriteToolSequence: rewriteEvents
.filter((event) => event.type === "tool_result")
.map((event) => event.data?.tool ?? null)
.filter(Boolean),
},
null,
2,
),
);
} catch (error) {
caughtError = error;
} finally {
if (fixture?.documentId) {
try {
await purgeDocument(context.request, fixture.documentId);
} catch (cleanupError) {
if (!caughtError) {
caughtError = cleanupError;
} else {
console.error(
`清理临时页面失败:${
cleanupError instanceof Error
? cleanupError.stack || cleanupError.message
: String(cleanupError)
}`,
);
}
}
}
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
if (caughtError) {
throw caughtError;
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
@@ -13,6 +13,18 @@ async function fetchText(path) {
return { response, text };
}
function parseSseEventData(text, eventName) {
const blocks = text.split(/\n\n+/).filter((block) => block.trim().length > 0);
const block = blocks.find((candidate) => new RegExp(`^event:\\s*${eventName}\\s*$`, "m").test(candidate));
assert(block, `SSE 响应缺少 ${eventName} 事件`);
const dataLines = block
.split(/\n/)
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice("data:".length).trimStart());
assert(dataLines.length > 0, `${eventName} 事件缺少 data`);
return JSON.parse(dataLines.join("\n"));
}
async function readJsonResponse(response, label) {
const text = await response.text();
let payload = null;
@@ -77,7 +89,40 @@ async function main() {
assert.match(events.text, /id:\s*/);
assert.match(events.text, /"revision"/);
console.log(JSON.stringify({ ok: true, owner: "rust-web", stream: "/api/tree/events", documentId: pageTarget.documentId }, null, 2));
const snapshot = parseSseEventData(events.text, "snapshot");
assert.equal(snapshot.kind, "snapshot");
assert.equal(snapshot.stream, "workspace");
assert.equal(snapshot.projection, "sidebar_tree");
assert.equal(snapshot.workspaceId, pageTarget.workspaceId);
const dataset = snapshot.data?.dataset || snapshot.data;
assert(dataset?.kernel_sidebar_projection, "snapshot 缺少 kernel_sidebar_projection");
assert(dataset?.kernel_file_tree_projection, "snapshot 缺少 kernel_file_tree_projection");
const fileTreeItems = dataset.kernel_file_tree_projection.items || [];
assert(
fileTreeItems.some(
(item) =>
(item?.rowId || item?.row_id) === `doc:${pageTarget.documentId}` &&
(item?.rowKind || item?.row_kind) === "document" &&
(item?.resourceMeta?.documentId || item?.resource_meta?.document_id) ===
pageTarget.documentId,
),
"workspace snapshot 的 file tree projection 缺少临时页 doc row",
);
console.log(
JSON.stringify(
{
ok: true,
owner: "rust-web",
stream: "/api/tree/events",
documentId: pageTarget.documentId,
snapshotProjection: snapshot.projection,
fileTreeRows: fileTreeItems.length,
},
null,
2,
),
);
} finally {
await purgeTempPage(pageTarget).catch(() => undefined);
}
-233
View File
@@ -1,233 +0,0 @@
#!/usr/bin/env node
"use strict";
if (process.env.MNOTE_ALLOW_RETIRED_AI_AGENT_RUN_SMOKE !== "1") {
console.log(JSON.stringify({
ok: true,
retired: true,
script: "task155-e27-ai-edit-smoke.js",
reason: "旧 /api/ai-agent/run E27 AI 编辑 smoke 已退役;当前请运行 scripts/task-hermes-page-ai-retirement-guard.js 验证 legacy guard。",
}, null, 2));
process.exit(0);
}
const assert = require("node:assert");
const fs = require("node:fs");
const { chromium } = require("playwright");
const { fetchWithTimeout } = require("./task114-rust-web-gateway-entry-smoke.js");
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const SCREENSHOT_DIR = process.env.MNOTE_PHASE_E_SCREENSHOT_DIR || "/mnt/Data1T/mnote/tmp/wolai-editor-parity/task155-e27-ai-edit-local-smoke";
const LEPTOS_SPIKE_SOURCE = "/mnt/Data1T/mnote/rust/spikes/leptos-tiptap-spike/src/lib.rs";
const TARGET_BLOCK_ID = "e27-ai-target";
function assertAiSourceBoundary() {
const spike = fs.readFileSync(LEPTOS_SPIKE_SOURCE, "utf8");
assert(spike.includes("/api/ai-agent/run"), "E27 AI 入口必须调用 /api/ai-agent/run 在线主路径,Hermes 只能作为后端 fallback");
assert(spike.includes('"provider": "online"'), "E27 AI 请求必须使用 provider=online 进入 openai-agents-python 主路径");
assert(spike.includes('"stream": true'), "E27 AI 请求必须开启 stream=true,确保 /api/ai-agent/run 路由进入 agents sidecar");
assert(spike.includes("mnote-leptos-tiptap-ai-status"), "E27 必须暴露 AI bridge 状态,供 smoke 和后续流式状态复用");
assert(spike.includes("selectedBlockId"), "E27 AI 请求必须携带 Rust blockId 作为 selectedBlockId");
assert(spike.includes("tiptapDocument"), "E27 AI 请求必须携带当前 Tiptap JSON 快照");
}
async function readJsonResponse(response, label) {
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
throw new Error(`${label} 返回了非 JSON 内容: ${text.slice(0, 1200)}`);
}
assert(response.ok, `${label} 请求失败: ${response.status}; body: ${text.slice(0, 1200)}`);
return payload;
}
async function postTreeCommand(body, label) {
const response = await fetchWithTimeout(`${BASE_URL}/api/tree/commands`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
const payload = await readJsonResponse(response, label);
assert(payload?.result, `${label} 缺少 result`);
return payload.result;
}
async function createTempDocument() {
const title = `task155-e27-ai-${Date.now().toString(36)}`;
const result = await postTreeCommand({ action: "create", title }, "创建 E27 临时文档");
assert(result.documentId, "创建 E27 临时文档缺少 documentId");
assert(result.workspaceId, "创建 E27 临时文档缺少 workspaceId");
return { documentId: result.documentId, workspaceId: result.workspaceId, title };
}
async function purgeTempDocument(target) {
if (!target?.documentId || !target?.workspaceId) return;
await postTreeCommand({ action: "purge", workspaceId: target.workspaceId, documentId: target.documentId }, "清理 E27 临时文档");
}
async function waitForRuntimeIsland(page) {
const root = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first();
await root.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const editor = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]').first();
await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
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: UI_TIMEOUT_MS });
return editor;
}
async function screenshot(page, name) {
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
await page.screenshot({ path: `${SCREENSHOT_DIR}/${name}.png`, fullPage: true });
}
async function setAiFixture(page) {
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: 'E27 Ask AI source paragraph' }],
},
],
}, true);
editor.commands.focus('start');
}, TARGET_BLOCK_ID);
await page.waitForFunction((targetBlockId) => {
return document.querySelector(`[data-block-id="${targetBlockId}"]`) instanceof HTMLElement;
}, TARGET_BLOCK_ID, { timeout: UI_TIMEOUT_MS });
}
async function openBlockMenuForTarget(page) {
const target = page.locator(`[data-block-id="${TARGET_BLOCK_ID}"]`).first();
await target.scrollIntoViewIfNeeded({ timeout: UI_TIMEOUT_MS });
await target.hover({ timeout: UI_TIMEOUT_MS });
const handle = page.locator('[data-testid="block-drag-handle-trigger"]').first();
await handle.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await handle.click({ timeout: UI_TIMEOUT_MS });
const menu = page.locator('[data-testid="block-drag-menu"]').first();
await menu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
return menu;
}
function assertAiBridgePayload(payload, target) {
assert.equal(payload.scope, "document", `AI 请求 scope 必须是 document: ${JSON.stringify(payload).slice(0, 1600)}`);
assert.equal(payload.stream, true, "AI 请求必须开启 stream=true,避免退回 Hermes 非流式兼容路径");
assert.equal(payload.options?.ai?.provider, "online", "AI 请求必须使用 provider=online,由 /api/ai-agent/run 分流到 agents sidecar");
assert.equal(payload.context?.documentId, target.documentId, "AI 请求必须携带当前 documentId");
assert.equal(payload.context?.workspaceId, target.workspaceId, "AI 请求必须携带当前 workspaceId");
assert.equal(payload.context?.selectedBlockId, TARGET_BLOCK_ID, "AI 请求必须携带 Rust blockId");
assert(Array.isArray(payload.context?.selectedUids) && payload.context.selectedUids.includes(TARGET_BLOCK_ID), "AI 请求 selectedUids 必须包含 Rust blockId");
assert(payload.context?.selection?.currentBlockId === TARGET_BLOCK_ID, "AI 请求 selection 必须指向当前块");
assert(payload.context?.tiptapDocument?.type === "doc", "AI 请求必须携带 tiptapDocument 快照");
assert(JSON.stringify(payload.context.tiptapDocument).includes("E27 Ask AI source paragraph"), "AI 请求快照必须包含当前块正文");
assert.equal(payload.context?.source, "leptos-tiptap-island", "AI 请求必须标记来源 island");
assert.equal(payload.context?.action, "ask_ai", "块菜单 AI 入口必须使用 ask_ai action");
}
async function main() {
assertAiSourceBoundary();
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
const page = await context.newPage();
const aiBridgeRequests = [];
await page.route(/\/api\/(hermes\/bridge|ai-agent\/run)$/, async (route) => {
const request = route.request();
const body = request.postData() || "{}";
let payload = null;
try {
payload = JSON.parse(body);
} catch {
payload = { raw: body };
}
aiBridgeRequests.push({ url: request.url(), payload });
if (request.url().includes("/api/ai-agent/run")) {
await route.fulfill({
status: 200,
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache",
},
body: [
'event: ready\n' + 'data: {"ok":true,"requestId":"task155-e27"}\n\n',
'event: tool_result\n' + 'data: {"tool":"doc_replace_range","ok":true,"result":{"data":[{"id":"e27-ai-target","type":"paragraph","content":"E27 AI rewritten paragraph"}]}}\n\n',
'event: completion\n' + 'data: {"ok":true,"text":"done","steps":1}\n\n',
].join(""),
});
return;
}
await route.fulfill({
status: 200,
headers: {
"content-type": "application/json; charset=utf-8",
"x-mnote-ai-bridge-owner": "rust-web-hermes",
},
body: JSON.stringify({
ok: true,
bridge: "e27-smoke-hermes-bridge",
canonicalRoute: "/api/hermes/bridge",
contract: {
schema: "mnote.ai_bridge.v1",
structuredWriteOwner: "rust-web-hermes",
},
}),
});
});
let target = null;
try {
target = await createTempDocument();
const url = `${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`;
const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
assert(response, "文档页没有返回响应");
assert.equal(response.status(), 200, `文档页状态码异常: ${response.status()}`);
await waitForRuntimeIsland(page);
await setAiFixture(page);
const menu = await openBlockMenuForTarget(page);
await screenshot(page, "01-block-menu-ai-entry");
await menu.locator('[data-testid="block-drag-menu-item-ai"]').first().click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(() => {
const status = document.querySelector('[data-testid="mnote-leptos-tiptap-ai-status"]');
return status instanceof HTMLElement && ["pending", "ready"].includes(status.getAttribute("data-state") || "");
}, null, { timeout: UI_TIMEOUT_MS });
await page.waitForFunction(() => window.__MNOTE_E27_AI_BRIDGE_REQUEST_COUNT__ > 0, null, { timeout: UI_TIMEOUT_MS });
assert(aiBridgeRequests.length > 0, "点击 AI 入口后必须发起 /api/ai-agent/run 请求");
const first = aiBridgeRequests[0];
assert(first.url.includes("/api/ai-agent/run"), `AI 请求必须走 /api/ai-agent/run 在线主路径,实际: ${first.url}`);
assertAiBridgePayload(first.payload, target);
await page.waitForFunction(() => {
const status = document.querySelector('[data-testid="mnote-leptos-tiptap-ai-status"]');
return status instanceof HTMLElement && status.getAttribute("data-state") === "ready";
}, null, { timeout: UI_TIMEOUT_MS });
await screenshot(page, "02-ai-bridge-ready-state");
console.log(JSON.stringify({ ok: true, baseUrl: BASE_URL, documentId: target.documentId, screenshotDir: SCREENSHOT_DIR, aiBridgeUrl: first.url }, null, 2));
} finally {
if (target) await purgeTempDocument(target).catch(() => undefined);
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
}
if (require.main === module) {
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
}
-286
View File
@@ -1,286 +0,0 @@
#!/usr/bin/env node
"use strict";
if (process.env.MNOTE_ALLOW_RETIRED_AI_AGENT_RUN_SMOKE !== "1") {
console.log(JSON.stringify({
ok: true,
retired: true,
script: "task156-e27-ai-writeback-smoke.js",
reason: "旧 /api/ai-agent/run E27 AI 写回 smoke 已退役;当前请运行 scripts/task-hermes-page-ai-retirement-guard.js 验证 legacy guard。",
}, null, 2));
process.exit(0);
}
const assert = require("node:assert");
const fs = require("node:fs");
const { chromium } = require("playwright");
const { fetchWithTimeout } = require("./task114-rust-web-gateway-entry-smoke.js");
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const SCREENSHOT_DIR = process.env.MNOTE_PHASE_E_SCREENSHOT_DIR || "/mnt/Data1T/mnote/tmp/wolai-editor-parity/task156-e27-ai-writeback-local-smoke";
const LEPTOS_SPIKE_SOURCE = "/mnt/Data1T/mnote/rust/spikes/leptos-tiptap-spike/src/lib.rs";
const TARGET_BLOCK_ID = "e27-ai-target";
const AI_REWRITTEN_TEXT = "E27 AI rewritten paragraph";
function assertAiSourceBoundary() {
const spike = fs.readFileSync(LEPTOS_SPIKE_SOURCE, "utf8");
assert(spike.includes("/api/ai-agent/run"), "E27 AI 入口必须调用 /api/ai-agent/run 在线主路径,Hermes 只能作为后端 fallback");
assert(spike.includes('"provider": "online"'), "E27 AI 请求必须使用 provider=online 进入 openai-agents-python 主路径");
assert(spike.includes('"stream": true'), "E27 AI 请求必须开启 stream=true,确保 /api/ai-agent/run 路由进入 agents sidecar");
assert(spike.includes("mnote-leptos-tiptap-ai-status"), "E27 必须暴露 AI bridge 状态,供 smoke 和后续流式状态复用");
assert(spike.includes("selectedBlockId"), "E27 AI 请求必须携带 Rust blockId 作为 selectedBlockId");
assert(spike.includes("tiptapDocument"), "E27 AI 请求必须携带当前 Tiptap JSON 快照");
}
async function readJsonResponse(response, label) {
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
throw new Error(`${label} 返回了非 JSON 内容: ${text.slice(0, 1200)}`);
}
assert(response.ok, `${label} 请求失败: ${response.status}; body: ${text.slice(0, 1200)}`);
return payload;
}
async function postTreeCommand(body, label) {
const response = await fetchWithTimeout(`${BASE_URL}/api/tree/commands`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
const payload = await readJsonResponse(response, label);
assert(payload?.result, `${label} 缺少 result`);
return payload.result;
}
async function createTempDocument() {
const title = `task156-e27-ai-writeback-${Date.now().toString(36)}`;
const result = await postTreeCommand({ action: "create", title }, "创建 E27 临时文档");
assert(result.documentId, "创建 E27 临时文档缺少 documentId");
assert(result.workspaceId, "创建 E27 临时文档缺少 workspaceId");
return { documentId: result.documentId, workspaceId: result.workspaceId, title };
}
async function purgeTempDocument(target) {
if (!target?.documentId || !target?.workspaceId) return;
await postTreeCommand({ action: "purge", workspaceId: target.workspaceId, documentId: target.documentId }, "清理 E27 临时文档");
}
async function loadDocumentContent(target, label) {
const url = `${BASE_URL}/api/documents/content?documentId=${encodeURIComponent(target.documentId)}&workspaceId=${encodeURIComponent(target.workspaceId)}`;
const response = await fetchWithTimeout(url, { method: "GET" });
return readJsonResponse(response, label);
}
function rawIncludes(value, text) {
return JSON.stringify(value ?? null).includes(text);
}
async function waitForRuntimeIsland(page) {
const root = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first();
await root.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const editor = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]').first();
await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
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: UI_TIMEOUT_MS });
return editor;
}
async function screenshot(page, name) {
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
await page.screenshot({ path: `${SCREENSHOT_DIR}/${name}.png`, fullPage: true });
}
async function setAiFixture(page) {
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: 'E27 Ask AI source paragraph' }],
},
],
}, true);
editor.commands.focus('start');
}, TARGET_BLOCK_ID);
await page.waitForFunction((targetBlockId) => {
return document.querySelector(`[data-block-id="${targetBlockId}"]`) instanceof HTMLElement;
}, TARGET_BLOCK_ID, { timeout: UI_TIMEOUT_MS });
}
async function openBlockMenuForTarget(page) {
const target = page.locator(`[data-block-id="${TARGET_BLOCK_ID}"]`).first();
await target.scrollIntoViewIfNeeded({ timeout: UI_TIMEOUT_MS });
await target.hover({ timeout: UI_TIMEOUT_MS });
const handle = page.locator('[data-testid="block-drag-handle-trigger"]').first();
await handle.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await handle.click({ timeout: UI_TIMEOUT_MS });
const menu = page.locator('[data-testid="block-drag-menu"]').first();
await menu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
return menu;
}
function assertAiBridgePayload(payload, target) {
assert.equal(payload.scope, "document", `AI 请求 scope 必须是 document: ${JSON.stringify(payload).slice(0, 1600)}`);
assert.equal(payload.stream, true, "AI 请求必须开启 stream=true,避免退回 Hermes 非流式兼容路径");
assert.equal(payload.options?.ai?.provider, "online", "AI 请求必须使用 provider=online,由 /api/ai-agent/run 分流到 agents sidecar");
assert.equal(payload.context?.documentId, target.documentId, "AI 请求必须携带当前 documentId");
assert.equal(payload.context?.workspaceId, target.workspaceId, "AI 请求必须携带当前 workspaceId");
assert.equal(payload.context?.selectedBlockId, TARGET_BLOCK_ID, "AI 请求必须携带 Rust blockId");
assert(Array.isArray(payload.context?.selectedUids) && payload.context.selectedUids.includes(TARGET_BLOCK_ID), "AI 请求 selectedUids 必须包含 Rust blockId");
assert(payload.context?.selection?.currentBlockId === TARGET_BLOCK_ID, "AI 请求 selection 必须指向当前块");
assert(payload.context?.tiptapDocument?.type === "doc", "AI 请求必须携带 tiptapDocument 快照");
assert(JSON.stringify(payload.context.tiptapDocument).includes("E27 Ask AI source paragraph"), "AI 请求快照必须包含当前块正文");
assert.equal(payload.context?.source, "leptos-tiptap-island", "AI 请求必须标记来源 island");
assert.equal(payload.context?.action, "ask_ai", "块菜单 AI 入口必须使用 ask_ai action");
}
async function main() {
assertAiSourceBoundary();
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
const page = await context.newPage();
const aiBridgeRequests = [];
const saveRequests = [];
await page.addInitScript(() => {
window.__MNOTE_E27_SAVE_REQUESTS__ = [];
});
page.on("request", async (request) => {
if (!request.url().includes("/api/documents/save")) return;
const body = request.postData();
if (!body) return;
let payload;
try {
payload = JSON.parse(body);
} catch {
payload = { raw: body };
}
saveRequests.push(payload);
await page.evaluate((item) => {
window.__MNOTE_E27_SAVE_REQUESTS__ = Array.isArray(window.__MNOTE_E27_SAVE_REQUESTS__) ? window.__MNOTE_E27_SAVE_REQUESTS__ : [];
window.__MNOTE_E27_SAVE_REQUESTS__.push(item);
}, payload).catch(() => undefined);
});
await page.route(/\/api\/(hermes\/bridge|ai-agent\/run)$/, async (route) => {
const request = route.request();
const body = request.postData() || "{}";
let payload = null;
try {
payload = JSON.parse(body);
} catch {
payload = { raw: body };
}
aiBridgeRequests.push({ url: request.url(), payload });
if (request.url().includes("/api/ai-agent/run")) {
await route.fulfill({
status: 200,
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache",
},
body: [
'event: ready\n' + 'data: {"ok":true,"requestId":"task155-e27"}\n\n',
'event: tool_result\n' + `data: {"tool":"doc_replace_range","ok":true,"result":{"data":[{"id":"${TARGET_BLOCK_ID}","type":"paragraph","content":"${AI_REWRITTEN_TEXT}"}]}}\n\n`,
'event: completion\n' + 'data: {"ok":true,"text":"done","steps":1}\n\n',
].join(""),
});
return;
}
await route.fulfill({
status: 200,
headers: {
"content-type": "application/json; charset=utf-8",
"x-mnote-ai-bridge-owner": "rust-web-hermes",
},
body: JSON.stringify({
ok: true,
bridge: "e27-smoke-hermes-bridge",
canonicalRoute: "/api/hermes/bridge",
contract: {
schema: "mnote.ai_bridge.v1",
structuredWriteOwner: "rust-web-hermes",
},
}),
});
});
let target = null;
try {
target = await createTempDocument();
const url = `${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`;
const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
assert(response, "文档页没有返回响应");
assert.equal(response.status(), 200, `文档页状态码异常: ${response.status()}`);
await waitForRuntimeIsland(page);
await setAiFixture(page);
const menu = await openBlockMenuForTarget(page);
await screenshot(page, "01-block-menu-ai-entry");
await menu.locator('[data-testid="block-drag-menu-item-ai"]').first().click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(() => {
const status = document.querySelector('[data-testid="mnote-leptos-tiptap-ai-status"]');
return status instanceof HTMLElement && ["pending", "ready"].includes(status.getAttribute("data-state") || "");
}, null, { timeout: UI_TIMEOUT_MS });
await page.waitForFunction(() => window.__MNOTE_E27_AI_BRIDGE_REQUEST_COUNT__ > 0, null, { timeout: UI_TIMEOUT_MS });
assert(aiBridgeRequests.length > 0, "点击 AI 入口后必须发起 /api/ai-agent/run 请求");
const first = aiBridgeRequests[0];
assert(first.url.includes("/api/ai-agent/run"), `AI 请求必须走 /api/ai-agent/run 在线主路径,实际: ${first.url}`);
assertAiBridgePayload(first.payload, target);
await page.waitForFunction(() => {
const status = document.querySelector('[data-testid="mnote-leptos-tiptap-ai-status"]');
return status instanceof HTMLElement && status.getAttribute("data-state") === "ready";
}, null, { timeout: UI_TIMEOUT_MS });
await page.waitForFunction((expectedText) => {
const editor = document.querySelector('.editor-surface .ProseMirror');
return editor instanceof HTMLElement && editor.innerText.includes(expectedText);
}, AI_REWRITTEN_TEXT, { timeout: UI_TIMEOUT_MS });
await page.waitForFunction((expectedText) => {
return Array.isArray(window.__MNOTE_E27_SAVE_REQUESTS__)
&& window.__MNOTE_E27_SAVE_REQUESTS__.some((request) => JSON.stringify(request ?? null).includes(expectedText));
}, AI_REWRITTEN_TEXT, { timeout: UI_TIMEOUT_MS });
await screenshot(page, "02-ai-writeback-editor-saved");
const saveHit = saveRequests.some((request) => rawIncludes(request, AI_REWRITTEN_TEXT));
assert(saveHit, `AI 写入必须触发 /api/documents/save 且保存 payload 包含改写正文: ${JSON.stringify(saveRequests.slice(-4)).slice(0, 2400)}`);
const contentAfterWrite = await loadDocumentContent(target, "读取 E27 AI 写入后的正文");
assert(rawIncludes(contentAfterWrite, AI_REWRITTEN_TEXT), `/api/documents/content 必须能读回 AI 写入正文: ${JSON.stringify(contentAfterWrite).slice(0, 2400)}`);
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForRuntimeIsland(page);
await page.waitForFunction((expectedText) => {
const editor = document.querySelector('.editor-surface .ProseMirror');
return editor instanceof HTMLElement && editor.innerText.includes(expectedText);
}, AI_REWRITTEN_TEXT, { timeout: UI_TIMEOUT_MS });
await screenshot(page, "03-ai-writeback-reload-readback");
console.log(JSON.stringify({ ok: true, baseUrl: BASE_URL, documentId: target.documentId, screenshotDir: SCREENSHOT_DIR, aiBridgeUrl: first.url, wroteText: AI_REWRITTEN_TEXT }, null, 2));
} finally {
if (target) await purgeTempDocument(target).catch(() => undefined);
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
}
if (require.main === module) {
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
}
@@ -1,182 +0,0 @@
#!/usr/bin/env node
"use strict";
if (process.env.MNOTE_ALLOW_RETIRED_AI_AGENT_RUN_SMOKE !== "1") {
console.log(JSON.stringify({
ok: true,
retired: true,
script: "task178-page-ai-local-subtree-context-smoke.js",
reason: "旧 /api/ai-agent/run 页面 AI context smoke 已退役;当前请运行 scripts/task-hermes-page-ai-retirement-guard.js 验证 legacy guard。",
}, null, 2));
process.exit(0);
}
const assert = require("node:assert");
const { chromium } = require("playwright");
const { fetchWithTimeout } = require("./task114-rust-web-gateway-entry-smoke.js");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
async function readJsonResponse(response, label) {
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
throw new Error(`${label} 返回了非 JSON 内容: ${text.slice(0, 1200)}`);
}
assert(response.ok, `${label} 请求失败: ${response.status}; body: ${text.slice(0, 1200)}`);
return payload;
}
async function postTreeCommand(body, label) {
const response = await fetchWithTimeout(`${BASE_URL}/api/tree/commands`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
const payload = await readJsonResponse(response, label);
const result = payload && typeof payload.result === "object" ? payload.result : null;
assert(result, `${label} 缺少 result`);
return result;
}
async function createTempPage(title) {
const result = await postTreeCommand({ action: "create", title }, `创建临时页面 ${title}`);
assert(result.documentId, `创建临时页面 ${title} 缺少 documentId`);
assert(result.workspaceId, `创建临时页面 ${title} 缺少 workspaceId`);
return { documentId: result.documentId, workspaceId: result.workspaceId, title };
}
async function purgeTempPage(target) {
if (!target?.documentId || !target?.workspaceId) return;
await postTreeCommand(
{ action: "purge", workspaceId: target.workspaceId, documentId: target.documentId },
`清理临时页面 ${target.documentId}`,
);
}
async function waitForRuntimeIsland(page) {
await page.waitForFunction(
() => {
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const editor = host?.querySelector(".editor-surface .ProseMirror[contenteditable='true']");
return (
host?.getAttribute("data-editor-host-kind") === "leptos_tiptap_island" &&
host?.getAttribute("data-runtime-editor-status") !== "error" &&
editor instanceof HTMLElement &&
editor.isContentEditable
);
},
null,
{ timeout: UI_TIMEOUT_MS },
);
}
async function setLocalHeadingFixture(page, headingText) {
await page.evaluate((text) => {
const editor = document.querySelector(".editor-surface .ProseMirror")?.editor;
if (!editor) throw new Error("找不到 Tiptap editor");
editor.commands.setContent({
type: "doc",
content: [
{ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text }] },
{ type: "paragraph", content: [{ type: "text", text: "本地正文尚未等待服务端 pageSubtree 刷新。" }] },
],
});
}, headingText);
await page.waitForFunction(
(text) => Array.from(document.querySelectorAll(".editor-surface .ProseMirror h2")).some((node) => (node.textContent || "").includes(text)),
headingText,
{ timeout: UI_TIMEOUT_MS },
);
}
function stringify(value) {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
async function main() {
const suffix = Date.now().toString(36);
const pageTitle = `task178-page-ai-${suffix}`;
const localHeading = `TASK178 本地 Heading ${suffix}`;
let target = null;
let capturedBody = null;
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
const page = await context.newPage();
try {
target = await createTempPage(pageTitle);
await page.route("**/api/ai-agent/run", async (route) => {
const postData = route.request().postData() || "{}";
capturedBody = JSON.parse(postData);
await route.fulfill({
status: 200,
headers: { "content-type": "text/event-stream; charset=utf-8" },
body: 'event: assistant_message\ndata: {"text":"ok"}\n\n',
});
});
const url = `${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`;
const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
assert(response, "文档页没有返回响应");
assert.equal(response.status(), 200, `文档页状态码异常: ${response.status()}`);
assert.equal(response.headers()["x-mnote-web-owner"], "mnote-web", "文档页必须由 mnote-web 拥有");
await waitForRuntimeIsland(page);
await setLocalHeadingFixture(page, localHeading);
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
await page.locator("[data-page-ai-input]").fill(`请基于当前本地结构回答:${localHeading}`, { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(() => Boolean(window.__task178Noop) || true, null, { timeout: 10 });
await page.waitForFunction(
() => document.querySelector('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant'),
null,
{ timeout: UI_TIMEOUT_MS },
);
assert(capturedBody, "未捕获 /api/ai-agent/run 请求");
const contextPayload = capturedBody.context || {};
const rawContext = stringify(contextPayload);
assert.equal(contextPayload.pageSubtreeSource, "local", `AI context 应标记本地 pageSubtree,实际: ${rawContext}`);
assert(rawContext.includes(localHeading), `AI context 应包含本地 heading: ${rawContext.slice(0, 1600)}`);
assert(Array.isArray(contextPayload.documentBlocks), `AI context 应包含本地 documentBlocks: ${rawContext.slice(0, 1600)}`);
assert(contextPayload.outline?.some((item) => stringify(item).includes(localHeading)), `AI context outline 应包含本地 heading: ${rawContext.slice(0, 1600)}`);
assert(contextPayload.subtree?.stats?.headingCount >= 1, `AI context subtree stats 应包含 headingCount: ${rawContext.slice(0, 1600)}`);
console.log(
JSON.stringify(
{
ok: true,
baseUrl: BASE_URL,
documentId: target.documentId,
workspaceId: target.workspaceId,
localHeading,
pageSubtreeSource: contextPayload.pageSubtreeSource,
headingCount: contextPayload.subtree?.stats?.headingCount ?? null,
},
null,
2,
),
);
} finally {
if (target) await purgeTempPage(target).catch(() => undefined);
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
}
if (require.main === module) {
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
}
@@ -0,0 +1,356 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { chromium } = require("playwright");
const TASK = "task446-tree-rename-dual-browser-live-smoke";
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const AUTH_BASE_URL = (process.env.MNOTE_AUTH_BASE_URL || BASE_URL).replace(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 35_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "tree-live-cache-smoke", "20260516-task446-rename");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const SCREENSHOT_B_DOCUMENT = path.join(OUTPUT_DIR, "b-document-after-rename.png");
const SCREENSHOT_B_FILETREE = path.join(OUTPUT_DIR, "b-filetree-after-rename.png");
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
function cssString(value) {
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
function docRowSelector(documentId) {
return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="doc:${cssString(documentId)}"]`;
}
async function writeResult(payload) {
await fs.mkdir(OUTPUT_DIR, { recursive: true });
await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
}
async function requestJson(request, baseUrl, requestPath, init = {}) {
const response = await request.fetch(`${baseUrl}${requestPath}`, {
...init,
headers: {
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
...(init.headers || {}),
},
timeout: 20_000,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok()) {
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
}
return payload;
}
async function authenticate(request, email, name) {
await requestJson(request, AUTH_BASE_URL, "/api/auth", {
method: "POST",
data: {
action: "auth:signIn",
args: {
provider: "password",
params: { email, password: e2ePassword(), flow: "signUp", name },
},
},
});
}
async function createPage(request, workspaceId, title, parentId = null) {
const payload = await requestJson(request, BASE_URL, "/api/tree/commands", {
method: "POST",
data: {
action: "create",
workspaceId,
parentId,
title,
},
});
const result = payload.result || payload;
const documentId = result.documentId || payload.documentId || result.id || "";
const resolvedWorkspaceId = result.workspaceId || payload.workspaceId || payload.workspace_id || workspaceId || "";
assert(documentId, `创建页面失败: ${JSON.stringify(payload)}`);
assert(resolvedWorkspaceId, `创建页面缺少 workspaceId: ${JSON.stringify(payload)}`);
return { documentId, workspaceId: resolvedWorkspaceId, payload };
}
async function treeCommand(request, workspaceId, action, documentId, extra = {}) {
return await requestJson(request, BASE_URL, "/api/tree/commands", {
method: "POST",
data: { action, workspaceId, documentId, ...extra },
});
}
async function emptyDocumentTrash(request, workspaceId) {
return await requestJson(request, BASE_URL, "/api/documents/empty-trash", {
method: "POST",
data: { workspaceId },
});
}
async function cleanup(request, workspaceId, documentIds) {
if (!workspaceId) return;
for (const documentId of documentIds.filter(Boolean)) {
await treeCommand(request, workspaceId, "archive", documentId).catch(() => null);
}
await emptyDocumentTrash(request, workspaceId).catch(() => null);
}
async function openDocument(page, workspaceId, documentId) {
await page.goto(`${BASE_URL}/documents/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, {
waitUntil: "commit",
timeout: UI_TIMEOUT_MS,
});
await page.locator(`[data-page-title-input="true"][data-document-id="${cssString(documentId)}"]`).first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
}
async function openDocumentFileTree(page, workspaceId, documentId) {
await openDocument(page, workspaceId, documentId);
await page.evaluate(() => {
const visible = (node) =>
node instanceof HTMLElement &&
!node.hidden &&
getComputedStyle(node).display !== "none" &&
getComputedStyle(node).visibility !== "hidden" &&
node.getClientRects().length > 0;
const fileRoot = document.getElementById("sidebar-file-tree-root");
if (visible(fileRoot)) return;
const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
if (tab instanceof HTMLElement) tab.click();
});
await page.locator("#sidebar-file-tree-root").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
}
async function waitForBDocumentRename(page, documentId, expectedTitle) {
await page.waitForFunction(
({ id, title }) => {
const titleInput = document.querySelector(`[data-page-title-input="true"][data-document-id="${CSS.escape(id)}"]`);
const titleValue = titleInput instanceof HTMLTextAreaElement ? titleInput.value : "";
const breadcrumb = document.querySelector(".wolai-breadcrumb-current [data-page-title-current]")?.textContent?.trim() || "";
const pageRow = document.querySelector(`#sidebar-tree-root .tree-row[data-node-id="${CSS.escape(id)}"] .tree-link-title`)?.textContent?.trim() || "";
return titleValue === title && breadcrumb === title && pageRow === title;
},
{ id: documentId, title: expectedTitle },
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForBFileTreeRename(page, documentId, expectedTitle) {
await page.waitForFunction(
({ id, title }) => {
const fileRow = document.querySelector(`#sidebar-file-tree-root .tree-row[data-row-id="doc:${CSS.escape(id)}"]`);
const fileTitle = fileRow instanceof HTMLElement ? fileRow.querySelector(".tree-link-title")?.textContent?.trim() || "" : "";
return fileTitle === `${title}.md`;
},
{ id: documentId, title: expectedTitle },
{ timeout: UI_TIMEOUT_MS },
);
}
async function installTreeEventRecorder(page, label, records) {
await page.addInitScript(() => {
window.__MNOTE_TASK446_TREE_EVENTS__ = [];
const record = (name, event) => {
const detail = event && event.detail ? event.detail : {};
const payload = detail.payload || detail || {};
const raw = (() => {
try {
return JSON.stringify(payload);
} catch {
return "";
}
})();
window.__MNOTE_TASK446_TREE_EVENTS__.push({
name,
at: Date.now(),
revision: detail.revision || payload.revision || payload.cursor || "",
op: payload && payload.data && payload.data.op ? payload.data.op : payload.op || "",
raw: raw.slice(0, 2000),
});
};
window.addEventListener("tree:snapshot", (event) => record("tree:snapshot", event));
window.addEventListener("tree:delta", (event) => record("tree:delta", event));
window.addEventListener("tree:resync", (event) => record("tree:resync", event));
});
page.on("request", (request) => {
const url = request.url();
if (url.includes("/api/tree/events")) {
records.push({ label, type: "request", method: request.method(), url, at: Date.now() });
}
});
page.on("requestfailed", (request) => {
const url = request.url();
if (url.includes("/api/tree/events")) {
records.push({
label,
type: "requestfailed",
method: request.method(),
url,
failure: request.failure()?.errorText || "",
at: Date.now(),
});
}
});
page.on("console", (message) => {
const text = message.text();
if (/tree live|EventSource|rename|error|failed/i.test(text)) {
records.push({ label, type: "console", level: message.type(), text: text.slice(0, 2000), at: Date.now() });
}
});
}
function recordNavigation(page, label, records) {
page.on("framenavigated", (frame) => {
if (frame === page.mainFrame()) {
records.push({ label, url: frame.url(), at: Date.now() });
}
});
}
async function readState(page, documentId) {
return await page.evaluate((id) => {
const pageRow = document.querySelector(`#sidebar-tree-root .tree-row[data-node-id="${CSS.escape(id)}"]`);
const fileRow = document.querySelector(`#sidebar-file-tree-root .tree-row[data-row-id="doc:${CSS.escape(id)}"]`);
const titleInput = document.querySelector(`[data-page-title-input="true"][data-document-id="${CSS.escape(id)}"]`);
return {
url: window.location.href,
documentTitle: document.title,
liveStatus: document.documentElement.getAttribute("data-mnote-tree-live-status") || "",
liveApplied: document.documentElement.getAttribute("data-mnote-tree-live-applied") || "",
liveRevision: document.documentElement.getAttribute("data-mnote-tree-live-revision") || "",
liveError: document.documentElement.getAttribute("data-mnote-tree-live-apply-error") || "",
titleInputValue: titleInput instanceof HTMLTextAreaElement ? titleInput.value : "",
breadcrumbTitle: document.querySelector(".wolai-breadcrumb-current [data-page-title-current]")?.textContent?.trim() || "",
sidebarTitle: pageRow instanceof HTMLElement ? pageRow.querySelector(".tree-link-title")?.textContent?.trim() || "" : "",
fileTreeTitle: fileRow instanceof HTMLElement ? fileRow.querySelector(".tree-link-title")?.textContent?.trim() || "" : "",
fileTreeRowExists: fileRow instanceof HTMLElement,
treeEvents: window.__MNOTE_TASK446_TREE_EVENTS__ || [],
};
}, documentId);
}
(async () => {
const stamp = Date.now();
const email = `mnote.stage8.rename.${stamp}@example.com`;
const prefix = `TEST-10REVIEW-08-RENAME-${stamp}`;
const initialTitle = `${prefix}-initial`;
const renamedTitle = `${prefix}-renamed`;
const result = {
ok: false,
task: TASK,
baseUrl: BASE_URL,
authBaseUrl: AUTH_BASE_URL,
email,
prefix,
fixture: {},
requests: [],
navigationEvents: [],
treeEventRequests: [],
states: {},
screenshots: {
bDocument: SCREENSHOT_B_DOCUMENT,
bFileTree: SCREENSHOT_B_FILETREE,
},
};
const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1" });
const contextA = await browser.newContext();
const contextB = await browser.newContext();
const pageA = await contextA.newPage();
const documentB = await contextB.newPage();
const fileTreeB = await contextB.newPage();
const requestA = contextA.request;
let workspaceId = "";
const cleanupIds = [];
pageA.on("request", (request) => {
const url = request.url();
if (url.includes("/api/tree/commands") || url.includes("/api/documents/empty-trash")) {
result.requests.push({ side: "A-page", method: request.method(), url, body: request.postData() || null, at: Date.now() });
}
});
await installTreeEventRecorder(documentB, "B-document", result.treeEventRequests);
await installTreeEventRecorder(fileTreeB, "B-filetree", result.treeEventRequests);
recordNavigation(documentB, "B-document", result.navigationEvents);
recordNavigation(fileTreeB, "B-filetree", result.navigationEvents);
try {
for (const requestContext of [requestA, contextB.request]) {
await authenticate(requestContext, email, `stage8-rename-${stamp}`);
}
const root = await createPage(requestA, null, `${prefix}-root`);
workspaceId = root.workspaceId;
cleanupIds.push(root.documentId);
result.fixture.rootId = root.documentId;
result.fixture.workspaceId = workspaceId;
const target = await createPage(requestA, workspaceId, initialTitle, root.documentId);
cleanupIds.push(target.documentId);
result.fixture.targetId = target.documentId;
result.fixture.initialTitle = initialTitle;
result.fixture.renamedTitle = renamedTitle;
await pageA.goto(`${BASE_URL}/documents/${encodeURIComponent(root.documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, {
waitUntil: "commit",
timeout: UI_TIMEOUT_MS,
});
await openDocument(documentB, workspaceId, target.documentId);
await openDocumentFileTree(fileTreeB, workspaceId, target.documentId);
await fileTreeB.locator(docRowSelector(target.documentId)).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await waitForBDocumentRename(documentB, target.documentId, initialTitle);
await waitForBFileTreeRename(fileTreeB, target.documentId, initialTitle);
result.states.before = {
document: await readState(documentB, target.documentId),
fileTree: await readState(fileTreeB, target.documentId),
};
const navigationStart = result.navigationEvents.length;
await treeCommand(requestA, workspaceId, "rename", target.documentId, { title: renamedTitle });
await waitForBDocumentRename(documentB, target.documentId, renamedTitle);
await waitForBFileTreeRename(fileTreeB, target.documentId, renamedTitle);
result.states.after = {
document: await readState(documentB, target.documentId),
fileTree: await readState(fileTreeB, target.documentId),
navigationEvents: result.navigationEvents.slice(navigationStart),
};
const unexpectedNavigations = result.navigationEvents.slice(navigationStart);
assert.equal(unexpectedNavigations.length, 0, `B 浏览器发生了刷新/导航: ${JSON.stringify(unexpectedNavigations)}`);
assert.equal(result.states.after.document.titleInputValue, renamedTitle, "B 文档页页头标题未 live 更新");
assert.equal(result.states.after.document.breadcrumbTitle, renamedTitle, "B 文档页 Breadcrumb 未 live 更新");
assert.equal(result.states.after.document.sidebarTitle, renamedTitle, "B 文档页 Sidebar 未 live 更新");
assert.equal(result.states.after.fileTree.fileTreeTitle, `${renamedTitle}.md`, "B File Tree 未 live 更新为 .md 文件名");
assert(!result.states.after.document.liveError, `B 文档页存在 live apply error: ${result.states.after.document.liveError}`);
assert(!result.states.after.fileTree.liveError, `B File Tree 存在 live apply error: ${result.states.after.fileTree.liveError}`);
await documentB.screenshot({ path: SCREENSHOT_B_DOCUMENT, fullPage: true });
await fileTreeB.screenshot({ path: SCREENSHOT_B_FILETREE, fullPage: true });
result.ok = true;
} catch (error) {
result.error = error instanceof Error ? error.stack || error.message : String(error);
result.failure = {
document: await readState(documentB, result.fixture.targetId || "").catch(() => null),
fileTree: await readState(fileTreeB, result.fixture.targetId || "").catch(() => null),
};
process.exitCode = 1;
} finally {
await cleanup(requestA, workspaceId, cleanupIds).catch((error) => {
result.cleanupError = error instanceof Error ? error.message : String(error);
});
await browser.close().catch(() => undefined);
await writeResult(result);
}
})();
@@ -0,0 +1,391 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { chromium } = require("playwright");
const TASK = "task447-tree-move-order-dual-browser-live-smoke";
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const AUTH_BASE_URL = (process.env.MNOTE_AUTH_BASE_URL || BASE_URL).replace(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 35_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "tree-live-cache-smoke", "20260516-task447-move-order");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const SCREENSHOT_B_DOCUMENT = path.join(OUTPUT_DIR, "b-document-after-move.png");
const SCREENSHOT_B_FILETREE = path.join(OUTPUT_DIR, "b-filetree-after-move.png");
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
function cssString(value) {
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
async function writeResult(payload) {
await fs.mkdir(OUTPUT_DIR, { recursive: true });
await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
}
async function requestJson(request, baseUrl, requestPath, init = {}) {
const response = await request.fetch(`${baseUrl}${requestPath}`, {
...init,
headers: {
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
...(init.headers || {}),
},
timeout: 20_000,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok()) {
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
}
return payload;
}
async function authenticate(request, email, name) {
await requestJson(request, AUTH_BASE_URL, "/api/auth", {
method: "POST",
data: {
action: "auth:signIn",
args: {
provider: "password",
params: { email, password: e2ePassword(), flow: "signUp", name },
},
},
});
}
async function createPage(request, workspaceId, title, parentId = null) {
const payload = await requestJson(request, BASE_URL, "/api/tree/commands", {
method: "POST",
data: { action: "create", workspaceId, parentId, title },
});
const result = payload.result || payload;
const documentId = result.documentId || payload.documentId || result.id || "";
const resolvedWorkspaceId = result.workspaceId || payload.workspaceId || payload.workspace_id || workspaceId || "";
assert(documentId, `创建页面失败: ${JSON.stringify(payload)}`);
assert(resolvedWorkspaceId, `创建页面缺少 workspaceId: ${JSON.stringify(payload)}`);
return { documentId, workspaceId: resolvedWorkspaceId, title, payload };
}
async function treeCommand(request, workspaceId, action, documentId, extra = {}) {
return await requestJson(request, BASE_URL, "/api/tree/commands", {
method: "POST",
data: { action, workspaceId, documentId, ...extra },
});
}
async function emptyDocumentTrash(request, workspaceId) {
return await requestJson(request, BASE_URL, "/api/documents/empty-trash", {
method: "POST",
data: { workspaceId },
});
}
async function cleanup(request, workspaceId, documentIds) {
if (!workspaceId) return;
for (const documentId of documentIds.filter(Boolean)) {
await treeCommand(request, workspaceId, "archive", documentId).catch(() => null);
}
await emptyDocumentTrash(request, workspaceId).catch(() => null);
}
async function openDocument(page, workspaceId, documentId) {
await page.goto(`${BASE_URL}/documents/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, {
waitUntil: "commit",
timeout: UI_TIMEOUT_MS,
});
await page.locator(`[data-page-title-input="true"][data-document-id="${cssString(documentId)}"]`).first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
}
async function openFileTree(page, workspaceId, documentId) {
await openDocument(page, workspaceId, documentId);
await page.evaluate(() => {
const visible = (node) =>
node instanceof HTMLElement &&
!node.hidden &&
getComputedStyle(node).display !== "none" &&
getComputedStyle(node).visibility !== "hidden" &&
node.getClientRects().length > 0;
const fileRoot = document.getElementById("sidebar-file-tree-root");
if (visible(fileRoot)) return;
const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
if (tab instanceof HTMLElement) tab.click();
});
await page.locator("#sidebar-file-tree-root").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
}
async function installTreeEventRecorder(page, label, records) {
await page.addInitScript(() => {
window.__MNOTE_TASK447_TREE_EVENTS__ = [];
const record = (name, event) => {
const detail = event && event.detail ? event.detail : {};
const payload = detail.payload || detail || {};
const raw = (() => {
try {
return JSON.stringify(payload);
} catch {
return "";
}
})();
window.__MNOTE_TASK447_TREE_EVENTS__.push({
name,
at: Date.now(),
revision: detail.revision || payload.revision || payload.cursor || "",
op: payload && payload.data && payload.data.op ? payload.data.op : payload.op || "",
raw: raw.slice(0, 2000),
});
};
window.addEventListener("tree:snapshot", (event) => record("tree:snapshot", event));
window.addEventListener("tree:delta", (event) => record("tree:delta", event));
window.addEventListener("tree:resync", (event) => record("tree:resync", event));
});
page.on("request", (request) => {
const url = request.url();
if (url.includes("/api/tree/events")) {
records.push({ label, type: "request", method: request.method(), url, at: Date.now() });
}
});
page.on("requestfailed", (request) => {
const url = request.url();
if (url.includes("/api/tree/events")) {
records.push({
label,
type: "requestfailed",
method: request.method(),
url,
failure: request.failure()?.errorText || "",
at: Date.now(),
});
}
});
}
function recordNavigation(page, label, records) {
page.on("framenavigated", (frame) => {
if (frame === page.mainFrame()) {
records.push({ label, url: frame.url(), at: Date.now() });
}
});
}
async function installOrderRecorder(page, rootDocumentId) {
await page.evaluate((rootId) => {
const readDirectChildren = (mode) => {
const rootSelector =
mode === "filetree"
? `#sidebar-file-tree-root .tree-row[data-row-id="doc:${CSS.escape(rootId)}"]`
: `#sidebar-tree-root .tree-row[data-node-id="${CSS.escape(rootId)}"]`;
const rootRow = document.querySelector(rootSelector);
const list = rootRow?.closest(".tree-node")?.querySelector(":scope > .tree-children");
if (!list) return [];
return Array.from(list.querySelectorAll(":scope > .tree-node > .tree-row")).map((row) => ({
documentId: row instanceof HTMLElement ? row.dataset.documentId || row.dataset.docId || row.dataset.nodeId || "" : "",
rowId: row instanceof HTMLElement ? row.dataset.rowId || "" : "",
title: row instanceof HTMLElement ? row.querySelector(".tree-link-title")?.textContent?.trim() || "" : "",
}));
};
const push = () => {
const record = {
at: Date.now(),
page: readDirectChildren("page"),
filetree: readDirectChildren("filetree"),
};
window.__MNOTE_TASK447_ORDER_HISTORY__ = window.__MNOTE_TASK447_ORDER_HISTORY__ || [];
const history = window.__MNOTE_TASK447_ORDER_HISTORY__;
const last = history[history.length - 1];
if (!last || JSON.stringify(last.page) !== JSON.stringify(record.page) || JSON.stringify(last.filetree) !== JSON.stringify(record.filetree)) {
history.push(record);
}
};
push();
const observe = (root) => {
if (!(root instanceof HTMLElement)) return;
const observer = new MutationObserver(push);
observer.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-parent-id"] });
};
observe(document.getElementById("sidebar-tree-root"));
observe(document.getElementById("sidebar-file-tree-root"));
}, rootDocumentId);
}
async function waitForExpectedOrder(page, rootDocumentId, expectedIds) {
await page.waitForFunction(
({ rootId, ids }) => {
const read = (mode) => {
const rootSelector =
mode === "filetree"
? `#sidebar-file-tree-root .tree-row[data-row-id="doc:${CSS.escape(rootId)}"]`
: `#sidebar-tree-root .tree-row[data-node-id="${CSS.escape(rootId)}"]`;
const rootRow = document.querySelector(rootSelector);
const list = rootRow?.closest(".tree-node")?.querySelector(":scope > .tree-children");
if (!list) return [];
return Array.from(list.querySelectorAll(":scope > .tree-node > .tree-row")).map((row) =>
row instanceof HTMLElement ? row.dataset.documentId || row.dataset.docId || row.dataset.nodeId || "" : "",
);
};
const pageOrder = read("page");
const fileOrder = read("filetree");
return ids.every((id, index) => pageOrder[index] === id && fileOrder[index] === id);
},
{ rootId: rootDocumentId, ids: expectedIds },
{ timeout: UI_TIMEOUT_MS },
);
}
async function readState(page, rootDocumentId) {
return await page.evaluate((rootId) => {
const history = window.__MNOTE_TASK447_ORDER_HISTORY__ || [];
const read = (mode) => {
const rootSelector =
mode === "filetree"
? `#sidebar-file-tree-root .tree-row[data-row-id="doc:${CSS.escape(rootId)}"]`
: `#sidebar-tree-root .tree-row[data-node-id="${CSS.escape(rootId)}"]`;
const rootRow = document.querySelector(rootSelector);
const list = rootRow?.closest(".tree-node")?.querySelector(":scope > .tree-children");
if (!list) return [];
return Array.from(list.querySelectorAll(":scope > .tree-node > .tree-row")).map((row) => ({
documentId: row instanceof HTMLElement ? row.dataset.documentId || row.dataset.docId || row.dataset.nodeId || "" : "",
parentId: row instanceof HTMLElement ? row.dataset.parentId || "" : "",
rowId: row instanceof HTMLElement ? row.dataset.rowId || "" : "",
title: row instanceof HTMLElement ? row.querySelector(".tree-link-title")?.textContent?.trim() || "" : "",
}));
};
return {
url: window.location.href,
liveStatus: document.documentElement.getAttribute("data-mnote-tree-live-status") || "",
liveApplied: document.documentElement.getAttribute("data-mnote-tree-live-applied") || "",
liveRevision: document.documentElement.getAttribute("data-mnote-tree-live-revision") || "",
liveError: document.documentElement.getAttribute("data-mnote-tree-live-apply-error") || "",
pageOrder: read("page"),
fileTreeOrder: read("filetree"),
orderHistory: history,
treeEvents: window.__MNOTE_TASK447_TREE_EVENTS__ || [],
};
}, rootDocumentId);
}
function orderIds(rows) {
return rows.map((row) => row.documentId);
}
(async () => {
const stamp = Date.now();
const email = `mnote.stage8.move.${stamp}@example.com`;
const prefix = `TEST-10REVIEW-08-MOVE-${stamp}`;
const result = {
ok: false,
task: TASK,
baseUrl: BASE_URL,
authBaseUrl: AUTH_BASE_URL,
email,
prefix,
fixture: {},
navigationEvents: [],
treeEventRequests: [],
states: {},
screenshots: {
bDocument: SCREENSHOT_B_DOCUMENT,
bFileTree: SCREENSHOT_B_FILETREE,
},
};
const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1" });
const contextA = await browser.newContext();
const contextB = await browser.newContext();
const documentB = await contextB.newPage();
const fileTreeB = await contextB.newPage();
const requestA = contextA.request;
let workspaceId = "";
const cleanupIds = [];
await installTreeEventRecorder(documentB, "B-document", result.treeEventRequests);
await installTreeEventRecorder(fileTreeB, "B-filetree", result.treeEventRequests);
recordNavigation(documentB, "B-document", result.navigationEvents);
recordNavigation(fileTreeB, "B-filetree", result.navigationEvents);
try {
for (const requestContext of [requestA, contextB.request]) {
await authenticate(requestContext, email, `stage8-move-${stamp}`);
}
const root = await createPage(requestA, null, `${prefix}-root`);
workspaceId = root.workspaceId;
cleanupIds.push(root.documentId);
const childA = await createPage(requestA, workspaceId, `${prefix}-a`, root.documentId);
const childB = await createPage(requestA, workspaceId, `${prefix}-b`, root.documentId);
const childC = await createPage(requestA, workspaceId, `${prefix}-c`, root.documentId);
cleanupIds.push(childA.documentId, childB.documentId, childC.documentId);
const initialOrder = [childA.documentId, childB.documentId, childC.documentId];
const expectedOrder = [childA.documentId, childC.documentId, childB.documentId];
result.fixture = {
workspaceId,
rootId: root.documentId,
childIds: initialOrder,
movedId: childC.documentId,
initialOrder,
expectedOrder,
};
await openDocument(documentB, workspaceId, root.documentId);
await openFileTree(fileTreeB, workspaceId, root.documentId);
await waitForExpectedOrder(documentB, root.documentId, initialOrder);
await waitForExpectedOrder(fileTreeB, root.documentId, initialOrder);
await installOrderRecorder(documentB, root.documentId);
await installOrderRecorder(fileTreeB, root.documentId);
result.states.before = {
document: await readState(documentB, root.documentId),
fileTree: await readState(fileTreeB, root.documentId),
};
const navigationStart = result.navigationEvents.length;
await treeCommand(requestA, workspaceId, "move", childC.documentId, {
parentId: root.documentId,
sortOrder: 1,
});
await waitForExpectedOrder(documentB, root.documentId, expectedOrder);
await waitForExpectedOrder(fileTreeB, root.documentId, expectedOrder);
await documentB.waitForTimeout(1000);
await fileTreeB.waitForTimeout(1000);
result.states.after = {
document: await readState(documentB, root.documentId),
fileTree: await readState(fileTreeB, root.documentId),
navigationEvents: result.navigationEvents.slice(navigationStart),
};
assert.deepEqual(orderIds(result.states.after.document.pageOrder).slice(0, 3), expectedOrder, "B 文档页 Page Tree 顺序未保持新顺序");
assert.deepEqual(orderIds(result.states.after.document.fileTreeOrder).slice(0, 3), expectedOrder, "B 文档页 File Tree 顺序未保持新顺序");
assert.deepEqual(orderIds(result.states.after.fileTree.pageOrder).slice(0, 3), expectedOrder, "B File Tree 页面 Page Tree 顺序未保持新顺序");
assert.deepEqual(orderIds(result.states.after.fileTree.fileTreeOrder).slice(0, 3), expectedOrder, "B File Tree 页面 File Tree 顺序未保持新顺序");
assert.equal(result.states.after.navigationEvents.length, 0, `B 浏览器发生了刷新/导航: ${JSON.stringify(result.states.after.navigationEvents)}`);
assert(!result.states.after.document.liveError, `B 文档页存在 live apply error: ${result.states.after.document.liveError}`);
assert(!result.states.after.fileTree.liveError, `B File Tree 存在 live apply error: ${result.states.after.fileTree.liveError}`);
await documentB.screenshot({ path: SCREENSHOT_B_DOCUMENT, fullPage: true });
await fileTreeB.screenshot({ path: SCREENSHOT_B_FILETREE, fullPage: true });
result.ok = true;
} catch (error) {
result.error = error instanceof Error ? error.stack || error.message : String(error);
result.failure = {
document: await readState(documentB, result.fixture.rootId || "").catch(() => null),
fileTree: await readState(fileTreeB, result.fixture.rootId || "").catch(() => null),
};
process.exitCode = 1;
} finally {
await cleanup(requestA, workspaceId, cleanupIds).catch((error) => {
result.cleanupError = error instanceof Error ? error.message : String(error);
});
await browser.close().catch(() => undefined);
await writeResult(result);
}
})();
@@ -0,0 +1,370 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { chromium } = require("playwright");
const TASK = "task448-tree-resync-recovery-dual-browser-smoke";
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const AUTH_BASE_URL = (process.env.MNOTE_AUTH_BASE_URL || BASE_URL).replace(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 40_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "tree-live-cache-smoke", "20260516-task448-resync");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const SCREENSHOT_B_DOCUMENT = path.join(OUTPUT_DIR, "b-document-after-resync.png");
const SCREENSHOT_B_FILETREE = path.join(OUTPUT_DIR, "b-filetree-after-resync.png");
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
function cssString(value) {
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
async function writeResult(payload) {
await fs.mkdir(OUTPUT_DIR, { recursive: true });
await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
}
async function requestJson(request, baseUrl, requestPath, init = {}) {
const response = await request.fetch(`${baseUrl}${requestPath}`, {
...init,
headers: {
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
...(init.headers || {}),
},
timeout: 20_000,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok()) {
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
}
return payload;
}
async function authenticate(request, email, name) {
await requestJson(request, AUTH_BASE_URL, "/api/auth", {
method: "POST",
data: {
action: "auth:signIn",
args: {
provider: "password",
params: { email, password: e2ePassword(), flow: "signUp", name },
},
},
});
}
async function createPage(request, workspaceId, title, parentId = null) {
const payload = await requestJson(request, BASE_URL, "/api/tree/commands", {
method: "POST",
data: { action: "create", workspaceId, parentId, title },
});
const result = payload.result || payload;
const documentId = result.documentId || payload.documentId || result.id || "";
const resolvedWorkspaceId = result.workspaceId || payload.workspaceId || payload.workspace_id || workspaceId || "";
assert(documentId, `创建页面失败: ${JSON.stringify(payload)}`);
assert(resolvedWorkspaceId, `创建页面缺少 workspaceId: ${JSON.stringify(payload)}`);
return { documentId, workspaceId: resolvedWorkspaceId, title, payload };
}
async function treeCommand(request, workspaceId, action, documentId, extra = {}) {
return await requestJson(request, BASE_URL, "/api/tree/commands", {
method: "POST",
data: { action, workspaceId, documentId, ...extra },
});
}
async function emptyDocumentTrash(request, workspaceId) {
return await requestJson(request, BASE_URL, "/api/documents/empty-trash", {
method: "POST",
data: { workspaceId },
});
}
async function cleanup(request, workspaceId, documentIds) {
if (!workspaceId) return;
for (const documentId of documentIds.filter(Boolean)) {
await treeCommand(request, workspaceId, "archive", documentId).catch(() => null);
}
await emptyDocumentTrash(request, workspaceId).catch(() => null);
}
async function installSlowTreeEventSource(page, pollMs) {
await page.addInitScript((value) => {
const OriginalEventSource = window.EventSource;
if (typeof OriginalEventSource !== "function" || window.__MNOTE_TASK448_PATCHED_EVENTSOURCE__) return;
window.__MNOTE_TASK448_PATCHED_EVENTSOURCE__ = true;
window.EventSource = function patchedEventSource(input, init) {
try {
const url = new URL(String(input), window.location.href);
if (url.pathname === "/api/tree/events") {
url.searchParams.set("pollMs", String(value));
return new OriginalEventSource(url.toString(), init);
}
} catch (_) {
}
return new OriginalEventSource(input, init);
};
window.EventSource.prototype = OriginalEventSource.prototype;
}, pollMs);
}
async function installTreeEventRecorder(page, label, requests) {
await page.addInitScript(() => {
window.__MNOTE_TASK448_TREE_EVENTS__ = [];
const record = (name, event) => {
const detail = event && event.detail ? event.detail : {};
const payload = detail.payload || detail || {};
let raw = "";
try {
raw = JSON.stringify(payload);
} catch {
raw = "";
}
window.__MNOTE_TASK448_TREE_EVENTS__.push({
name,
at: Date.now(),
revision: detail.revision || payload.revision || payload.cursor || "",
kind: payload.kind || "",
op: payload && payload.data && payload.data.op ? payload.data.op : payload.op || "",
raw: raw.slice(0, 2400),
});
};
window.addEventListener("tree:snapshot", (event) => record("tree:snapshot", event));
window.addEventListener("tree:delta", (event) => record("tree:delta", event));
window.addEventListener("tree:resync", (event) => record("tree:resync", event));
});
page.on("request", (request) => {
const url = request.url();
if (url.includes("/api/tree/events")) {
requests.push({ label, type: "request", method: request.method(), url, at: Date.now() });
}
});
page.on("requestfailed", (request) => {
const url = request.url();
if (url.includes("/api/tree/events")) {
requests.push({
label,
type: "requestfailed",
method: request.method(),
url,
failure: request.failure()?.errorText || "",
at: Date.now(),
});
}
});
}
function recordNavigation(page, label, records) {
page.on("framenavigated", (frame) => {
if (frame === page.mainFrame()) {
records.push({ label, url: frame.url(), at: Date.now() });
}
});
}
async function openDocument(page, workspaceId, documentId) {
await page.goto(`${BASE_URL}/documents/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, {
waitUntil: "commit",
timeout: UI_TIMEOUT_MS,
});
await page.locator(`[data-page-title-input="true"][data-document-id="${cssString(documentId)}"]`).first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
}
async function openFileTree(page, workspaceId, documentId) {
await openDocument(page, workspaceId, documentId);
await page.evaluate(() => {
const visible = (node) =>
node instanceof HTMLElement &&
!node.hidden &&
getComputedStyle(node).display !== "none" &&
getComputedStyle(node).visibility !== "hidden" &&
node.getClientRects().length > 0;
const fileRoot = document.getElementById("sidebar-file-tree-root");
if (visible(fileRoot)) return;
const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
if (tab instanceof HTMLElement) tab.click();
});
await page.locator("#sidebar-file-tree-root").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
}
async function waitForRows(page, documentIds) {
await page.waitForFunction(
(ids) =>
ids.every(
(id) =>
document.querySelector(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(id)}"]`) &&
document.querySelector(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="${CSS.escape(`doc:${id}`)}"]`),
),
documentIds,
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForResyncWithRows(page, documentIds) {
await page.waitForFunction(
(ids) => {
const events = Array.isArray(window.__MNOTE_TASK448_TREE_EVENTS__) ? window.__MNOTE_TASK448_TREE_EVENTS__ : [];
const hasResync = events.some((event) => event && event.name === "tree:resync");
if (!hasResync) return false;
const applied = document.documentElement.getAttribute("data-mnote-tree-live-applied") || "";
const error = document.documentElement.getAttribute("data-mnote-tree-live-apply-error") || "";
if (applied !== "resync" || error) return false;
return ids.every(
(id) =>
document.querySelector(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(id)}"]`) &&
document.querySelector(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="${CSS.escape(`doc:${id}`)}"]`),
);
},
documentIds,
{ timeout: UI_TIMEOUT_MS },
);
}
async function readState(page, rootDocumentId) {
return await page.evaluate((rootId) => {
const readDirectChildren = (mode) => {
const rootSelector =
mode === "filetree"
? `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="doc:${CSS.escape(rootId)}"]`
: `#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(rootId)}"]`;
const rootRow = document.querySelector(rootSelector);
const list = rootRow?.closest(".tree-node")?.querySelector(":scope > .tree-children");
if (!list) return [];
return Array.from(list.querySelectorAll(":scope > .tree-node > .tree-row")).map((row) => ({
documentId: row instanceof HTMLElement ? row.dataset.documentId || row.dataset.docId || row.dataset.nodeId || "" : "",
rowId: row instanceof HTMLElement ? row.dataset.rowId || "" : "",
title: row instanceof HTMLElement ? row.querySelector(".tree-link-title")?.textContent?.trim() || "" : "",
}));
};
return {
url: window.location.href,
liveStatus: document.documentElement.getAttribute("data-mnote-tree-live-status") || "",
liveApplied: document.documentElement.getAttribute("data-mnote-tree-live-applied") || "",
liveRevision: document.documentElement.getAttribute("data-mnote-tree-live-revision") || "",
liveError: document.documentElement.getAttribute("data-mnote-tree-live-apply-error") || "",
pageOrder: readDirectChildren("page"),
fileTreeOrder: readDirectChildren("filetree"),
treeEvents: window.__MNOTE_TASK448_TREE_EVENTS__ || [],
};
}, rootDocumentId);
}
(async () => {
const stamp = Date.now();
const email = `mnote.stage8.resync.${stamp}@example.com`;
const prefix = `TEST-10REVIEW-08-RESYNC-${stamp}`;
const result = {
ok: false,
task: TASK,
baseUrl: BASE_URL,
authBaseUrl: AUTH_BASE_URL,
email,
prefix,
pollMs: 5000,
fixture: {},
navigationEvents: [],
treeEventRequests: [],
states: {},
screenshots: {
bDocument: SCREENSHOT_B_DOCUMENT,
bFileTree: SCREENSHOT_B_FILETREE,
},
};
const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1" });
const contextA = await browser.newContext();
const contextB = await browser.newContext();
const documentB = await contextB.newPage();
const fileTreeB = await contextB.newPage();
const requestA = contextA.request;
let workspaceId = "";
const cleanupIds = [];
for (const page of [documentB, fileTreeB]) {
await installSlowTreeEventSource(page, result.pollMs);
await installTreeEventRecorder(page, page === documentB ? "B-document" : "B-filetree", result.treeEventRequests);
recordNavigation(page, page === documentB ? "B-document" : "B-filetree", result.navigationEvents);
}
try {
for (const requestContext of [requestA, contextB.request]) {
await authenticate(requestContext, email, `stage8-resync-${stamp}`);
}
const root = await createPage(requestA, null, `${prefix}-root`);
workspaceId = root.workspaceId;
cleanupIds.push(root.documentId);
const childA = await createPage(requestA, workspaceId, `${prefix}-a`, root.documentId);
cleanupIds.push(childA.documentId);
result.fixture = {
workspaceId,
rootId: root.documentId,
initialChildId: childA.documentId,
};
await openDocument(documentB, workspaceId, root.documentId);
await openFileTree(fileTreeB, workspaceId, root.documentId);
await waitForRows(documentB, [childA.documentId]);
await waitForRows(fileTreeB, [childA.documentId]);
await Promise.all([
documentB.waitForFunction(() => document.documentElement.getAttribute("data-mnote-tree-live-status") === "connected", null, { timeout: UI_TIMEOUT_MS }),
fileTreeB.waitForFunction(() => document.documentElement.getAttribute("data-mnote-tree-live-status") === "connected", null, { timeout: UI_TIMEOUT_MS }),
]);
result.states.before = {
document: await readState(documentB, root.documentId),
fileTree: await readState(fileTreeB, root.documentId),
};
const navigationStart = result.navigationEvents.length;
const childB = await createPage(requestA, workspaceId, `${prefix}-b`, root.documentId);
const childC = await createPage(requestA, workspaceId, `${prefix}-c`, root.documentId);
cleanupIds.push(childB.documentId, childC.documentId);
result.fixture.resyncedChildIds = [childB.documentId, childC.documentId];
await waitForResyncWithRows(documentB, [childB.documentId, childC.documentId]);
await waitForResyncWithRows(fileTreeB, [childB.documentId, childC.documentId]);
await documentB.waitForTimeout(500);
await fileTreeB.waitForTimeout(500);
result.states.after = {
document: await readState(documentB, root.documentId),
fileTree: await readState(fileTreeB, root.documentId),
navigationEvents: result.navigationEvents.slice(navigationStart),
};
assert.equal(result.states.after.document.liveApplied, "resync", "B 文档页应通过 resync 应用最新树快照");
assert.equal(result.states.after.fileTree.liveApplied, "resync", "B File Tree 应通过 resync 应用最新树快照");
assert(!result.states.after.document.liveError, `B 文档页存在 live apply error: ${result.states.after.document.liveError}`);
assert(!result.states.after.fileTree.liveError, `B File Tree 存在 live apply error: ${result.states.after.fileTree.liveError}`);
assert.equal(result.states.after.navigationEvents.length, 0, `B 浏览器发生了刷新/导航: ${JSON.stringify(result.states.after.navigationEvents)}`);
await documentB.screenshot({ path: SCREENSHOT_B_DOCUMENT, fullPage: true });
await fileTreeB.screenshot({ path: SCREENSHOT_B_FILETREE, fullPage: true });
result.ok = true;
} catch (error) {
result.error = error instanceof Error ? error.stack || error.message : String(error);
result.failure = {
document: await readState(documentB, result.fixture.rootId || "").catch(() => null),
fileTree: await readState(fileTreeB, result.fixture.rootId || "").catch(() => null),
};
process.exitCode = 1;
} finally {
await cleanup(requestA, workspaceId, cleanupIds).catch((error) => {
result.cleanupError = error instanceof Error ? error.message : String(error);
});
await browser.close().catch(() => undefined);
await writeResult(result);
}
})();
@@ -0,0 +1,380 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { chromium } = require("playwright");
const TASK = "task449-tree-sse-reconnect-snapshot-recovery-smoke";
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const AUTH_BASE_URL = (process.env.MNOTE_AUTH_BASE_URL || BASE_URL).replace(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 45_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "tree-live-cache-smoke", "20260516-task449-reconnect");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const SCREENSHOT_B_DOCUMENT = path.join(OUTPUT_DIR, "b-document-after-reconnect.png");
const SCREENSHOT_B_FILETREE = path.join(OUTPUT_DIR, "b-filetree-after-reconnect.png");
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
function cssString(value) {
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
async function writeResult(payload) {
await fs.mkdir(OUTPUT_DIR, { recursive: true });
await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
}
async function requestJson(request, baseUrl, requestPath, init = {}) {
const response = await request.fetch(`${baseUrl}${requestPath}`, {
...init,
headers: {
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
...(init.headers || {}),
},
timeout: 20_000,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok()) {
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
}
return payload;
}
async function authenticate(request, email, name) {
await requestJson(request, AUTH_BASE_URL, "/api/auth", {
method: "POST",
data: {
action: "auth:signIn",
args: {
provider: "password",
params: { email, password: e2ePassword(), flow: "signUp", name },
},
},
});
}
async function createPage(request, workspaceId, title, parentId = null) {
const payload = await requestJson(request, BASE_URL, "/api/tree/commands", {
method: "POST",
data: { action: "create", workspaceId, parentId, title },
});
const result = payload.result || payload;
const documentId = result.documentId || payload.documentId || result.id || "";
const resolvedWorkspaceId = result.workspaceId || payload.workspaceId || payload.workspace_id || workspaceId || "";
assert(documentId, `创建页面失败: ${JSON.stringify(payload)}`);
assert(resolvedWorkspaceId, `创建页面缺少 workspaceId: ${JSON.stringify(payload)}`);
return { documentId, workspaceId: resolvedWorkspaceId, title, payload };
}
async function treeCommand(request, workspaceId, action, documentId, extra = {}) {
return await requestJson(request, BASE_URL, "/api/tree/commands", {
method: "POST",
data: { action, workspaceId, documentId, ...extra },
});
}
async function emptyDocumentTrash(request, workspaceId) {
return await requestJson(request, BASE_URL, "/api/documents/empty-trash", {
method: "POST",
data: { workspaceId },
});
}
async function cleanup(request, workspaceId, documentIds) {
if (!workspaceId) return;
for (const documentId of documentIds.filter(Boolean)) {
await treeCommand(request, workspaceId, "archive", documentId).catch(() => null);
}
await emptyDocumentTrash(request, workspaceId).catch(() => null);
}
async function installTreeEventRecorder(page, label, requests) {
await page.addInitScript(() => {
window.__MNOTE_TASK449_TREE_EVENTS__ = [];
const record = (name, event) => {
const detail = event && event.detail ? event.detail : {};
const payload = detail.payload || detail || {};
let raw = "";
try {
raw = JSON.stringify(payload);
} catch {
raw = "";
}
window.__MNOTE_TASK449_TREE_EVENTS__.push({
name,
at: Date.now(),
revision: detail.revision || payload.revision || payload.cursor || "",
kind: payload.kind || "",
op: payload && payload.data && payload.data.op ? payload.data.op : payload.op || "",
raw: raw.slice(0, 2400),
});
};
window.addEventListener("tree:snapshot", (event) => record("tree:snapshot", event));
window.addEventListener("tree:delta", (event) => record("tree:delta", event));
window.addEventListener("tree:resync", (event) => record("tree:resync", event));
});
page.on("request", (request) => {
const url = request.url();
if (url.includes("/api/tree/events")) {
requests.push({ label, type: "request", method: request.method(), url, at: Date.now() });
}
});
page.on("requestfailed", (request) => {
const url = request.url();
if (url.includes("/api/tree/events")) {
requests.push({
label,
type: "requestfailed",
method: request.method(),
url,
failure: request.failure()?.errorText || "",
at: Date.now(),
});
}
});
}
function recordNavigation(page, label, records) {
page.on("framenavigated", (frame) => {
if (frame === page.mainFrame()) {
records.push({ label, url: frame.url(), at: Date.now() });
}
});
}
async function openDocument(page, workspaceId, documentId) {
await page.goto(`${BASE_URL}/documents/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, {
waitUntil: "commit",
timeout: UI_TIMEOUT_MS,
});
await page.locator(`[data-page-title-input="true"][data-document-id="${cssString(documentId)}"]`).first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
}
async function openFileTree(page, workspaceId, documentId) {
await openDocument(page, workspaceId, documentId);
await page.evaluate(() => {
const visible = (node) =>
node instanceof HTMLElement &&
!node.hidden &&
getComputedStyle(node).display !== "none" &&
getComputedStyle(node).visibility !== "hidden" &&
node.getClientRects().length > 0;
const fileRoot = document.getElementById("sidebar-file-tree-root");
if (visible(fileRoot)) return;
const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
if (tab instanceof HTMLElement) tab.click();
});
await page.locator("#sidebar-file-tree-root").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
}
async function waitForRows(page, documentIds) {
await page.waitForFunction(
(ids) =>
ids.every(
(id) =>
document.querySelector(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(id)}"]`) &&
document.querySelector(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="${CSS.escape(`doc:${id}`)}"]`),
),
documentIds,
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForReconnectRecoveryWithRows(page, documentIds, minSnapshotCount) {
await page.waitForFunction(
({ ids, count }) => {
const events = Array.isArray(window.__MNOTE_TASK449_TREE_EVENTS__) ? window.__MNOTE_TASK449_TREE_EVENTS__ : [];
const snapshotCount = events.filter((event) => event && event.name === "tree:snapshot").length;
const hasRecoveryEvent = snapshotCount >= count || events.some((event) => event && event.name === "tree:resync");
if (!hasRecoveryEvent) return false;
const applied = document.documentElement.getAttribute("data-mnote-tree-live-applied") || "";
const status = document.documentElement.getAttribute("data-mnote-tree-live-status") || "";
const error = document.documentElement.getAttribute("data-mnote-tree-live-apply-error") || "";
if (!["snapshot", "resync"].includes(applied) || status !== "connected" || error) return false;
return ids.every(
(id) =>
document.querySelector(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(id)}"]`) &&
document.querySelector(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="${CSS.escape(`doc:${id}`)}"]`),
);
},
{ ids: documentIds, count: minSnapshotCount },
{ timeout: UI_TIMEOUT_MS },
);
}
async function readState(page, rootDocumentId) {
return await page.evaluate((rootId) => {
const readDirectChildren = (mode) => {
const rootSelector =
mode === "filetree"
? `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="doc:${CSS.escape(rootId)}"]`
: `#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(rootId)}"]`;
const rootRow = document.querySelector(rootSelector);
const list = rootRow?.closest(".tree-node")?.querySelector(":scope > .tree-children");
if (!list) return [];
return Array.from(list.querySelectorAll(":scope > .tree-node > .tree-row")).map((row) => ({
documentId: row instanceof HTMLElement ? row.dataset.documentId || row.dataset.docId || row.dataset.nodeId || "" : "",
rowId: row instanceof HTMLElement ? row.dataset.rowId || "" : "",
title: row instanceof HTMLElement ? row.querySelector(".tree-link-title")?.textContent?.trim() || "" : "",
}));
};
return {
url: window.location.href,
liveStatus: document.documentElement.getAttribute("data-mnote-tree-live-status") || "",
liveApplied: document.documentElement.getAttribute("data-mnote-tree-live-applied") || "",
liveRevision: document.documentElement.getAttribute("data-mnote-tree-live-revision") || "",
liveError: document.documentElement.getAttribute("data-mnote-tree-live-apply-error") || "",
pageOrder: readDirectChildren("page"),
fileTreeOrder: readDirectChildren("filetree"),
treeEvents: window.__MNOTE_TASK449_TREE_EVENTS__ || [],
};
}, rootDocumentId);
}
(async () => {
const stamp = Date.now();
const email = `mnote.stage8.reconnect.${stamp}@example.com`;
const prefix = `TEST-10REVIEW-08-RECONNECT-${stamp}`;
const result = {
ok: false,
task: TASK,
baseUrl: BASE_URL,
authBaseUrl: AUTH_BASE_URL,
email,
prefix,
fixture: {},
navigationEvents: [],
treeEventRequests: [],
states: {},
screenshots: {
bDocument: SCREENSHOT_B_DOCUMENT,
bFileTree: SCREENSHOT_B_FILETREE,
},
};
const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1" });
const contextA = await browser.newContext();
const contextB = await browser.newContext();
const documentB = await contextB.newPage();
const fileTreeB = await contextB.newPage();
const requestA = contextA.request;
let workspaceId = "";
const cleanupIds = [];
for (const page of [documentB, fileTreeB]) {
await installTreeEventRecorder(page, page === documentB ? "B-document" : "B-filetree", result.treeEventRequests);
recordNavigation(page, page === documentB ? "B-document" : "B-filetree", result.navigationEvents);
}
try {
for (const requestContext of [requestA, contextB.request]) {
await authenticate(requestContext, email, `stage8-reconnect-${stamp}`);
}
const root = await createPage(requestA, null, `${prefix}-root`);
workspaceId = root.workspaceId;
cleanupIds.push(root.documentId);
const childA = await createPage(requestA, workspaceId, `${prefix}-a`, root.documentId);
cleanupIds.push(childA.documentId);
result.fixture = {
workspaceId,
rootId: root.documentId,
initialChildId: childA.documentId,
};
await openDocument(documentB, workspaceId, root.documentId);
await openFileTree(fileTreeB, workspaceId, root.documentId);
await waitForRows(documentB, [childA.documentId]);
await waitForRows(fileTreeB, [childA.documentId]);
await Promise.all([
documentB.waitForFunction(() => document.documentElement.getAttribute("data-mnote-tree-live-status") === "connected", null, { timeout: UI_TIMEOUT_MS }),
fileTreeB.waitForFunction(() => document.documentElement.getAttribute("data-mnote-tree-live-status") === "connected", null, { timeout: UI_TIMEOUT_MS }),
]);
result.states.beforeDisconnect = {
document: await readState(documentB, root.documentId),
fileTree: await readState(fileTreeB, root.documentId),
};
const snapshotCounts = {
document:
result.states.beforeDisconnect.document.treeEvents.filter((event) => event.name === "tree:snapshot").length,
fileTree:
result.states.beforeDisconnect.fileTree.treeEvents.filter((event) => event.name === "tree:snapshot").length,
};
await contextB.setOffline(true);
await Promise.all([documentB.waitForTimeout(500), fileTreeB.waitForTimeout(500)]);
result.states.offline = {
document: await readState(documentB, root.documentId),
fileTree: await readState(fileTreeB, root.documentId),
};
const navigationStart = result.navigationEvents.length;
const childB = await createPage(requestA, workspaceId, `${prefix}-b`, root.documentId);
const childC = await createPage(requestA, workspaceId, `${prefix}-c`, root.documentId);
cleanupIds.push(childB.documentId, childC.documentId);
result.fixture.recoveredChildIds = [childB.documentId, childC.documentId];
assert(
!(await documentB.locator(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${cssString(childB.documentId)}"]`).count()),
"B 文档页离线期间不应提前看到 childB",
);
assert(
!(await fileTreeB.locator(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="${cssString(`doc:${childB.documentId}`)}"]`).count()),
"B File Tree 离线期间不应提前看到 childB",
);
await contextB.setOffline(false);
await waitForReconnectRecoveryWithRows(documentB, [childB.documentId, childC.documentId], snapshotCounts.document + 1);
await waitForReconnectRecoveryWithRows(fileTreeB, [childB.documentId, childC.documentId], snapshotCounts.fileTree + 1);
await documentB.waitForTimeout(500);
await fileTreeB.waitForTimeout(500);
result.states.afterReconnect = {
document: await readState(documentB, root.documentId),
fileTree: await readState(fileTreeB, root.documentId),
navigationEvents: result.navigationEvents.slice(navigationStart),
};
assert(
["snapshot", "resync"].includes(result.states.afterReconnect.document.liveApplied),
`B 文档页应通过重连 snapshot/resync 应用最新树快照: ${result.states.afterReconnect.document.liveApplied}`,
);
assert(
["snapshot", "resync"].includes(result.states.afterReconnect.fileTree.liveApplied),
`B File Tree 应通过重连 snapshot/resync 应用最新树快照: ${result.states.afterReconnect.fileTree.liveApplied}`,
);
assert(!result.states.afterReconnect.document.liveError, `B 文档页存在 live apply error: ${result.states.afterReconnect.document.liveError}`);
assert(!result.states.afterReconnect.fileTree.liveError, `B File Tree 存在 live apply error: ${result.states.afterReconnect.fileTree.liveError}`);
assert.equal(result.states.afterReconnect.navigationEvents.length, 0, `B 浏览器发生了刷新/导航: ${JSON.stringify(result.states.afterReconnect.navigationEvents)}`);
await documentB.screenshot({ path: SCREENSHOT_B_DOCUMENT, fullPage: true });
await fileTreeB.screenshot({ path: SCREENSHOT_B_FILETREE, fullPage: true });
result.ok = true;
} catch (error) {
result.error = error instanceof Error ? error.stack || error.message : String(error);
result.failure = {
document: await readState(documentB, result.fixture.rootId || "").catch(() => null),
fileTree: await readState(fileTreeB, result.fixture.rootId || "").catch(() => null),
};
process.exitCode = 1;
} finally {
await contextB.setOffline(false).catch(() => undefined);
await cleanup(requestA, workspaceId, cleanupIds).catch((error) => {
result.cleanupError = error instanceof Error ? error.message : String(error);
});
await browser.close().catch(() => undefined);
await writeResult(result);
}
})();