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
297 lines
11 KiB
JavaScript
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);
|
|
});
|