Files
mnote/scripts/task-editor-runtime-actor-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

239 lines
8.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);
});