Files
mnote/scripts/task-page-aggregate-body-sync-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

196 lines
7.2 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,
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);
});