Files
mnote/scripts/task-page-block-ai-tools-smoke.js
T
lix-2026 f292c6710a 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
2026-05-16 22:03:30 +08:00

297 lines
11 KiB
JavaScript

#!/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);
});