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:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user