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