Files
mnote/scripts/task446-tree-rename-dual-browser-live-smoke.js
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

357 lines
15 KiB
JavaScript

#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { chromium } = require("playwright");
const TASK = "task446-tree-rename-dual-browser-live-smoke";
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const AUTH_BASE_URL = (process.env.MNOTE_AUTH_BASE_URL || BASE_URL).replace(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 35_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "tree-live-cache-smoke", "20260516-task446-rename");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const SCREENSHOT_B_DOCUMENT = path.join(OUTPUT_DIR, "b-document-after-rename.png");
const SCREENSHOT_B_FILETREE = path.join(OUTPUT_DIR, "b-filetree-after-rename.png");
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
function cssString(value) {
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
function docRowSelector(documentId) {
return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="doc:${cssString(documentId)}"]`;
}
async function writeResult(payload) {
await fs.mkdir(OUTPUT_DIR, { recursive: true });
await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
}
async function requestJson(request, baseUrl, requestPath, init = {}) {
const response = await request.fetch(`${baseUrl}${requestPath}`, {
...init,
headers: {
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
...(init.headers || {}),
},
timeout: 20_000,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok()) {
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
}
return payload;
}
async function authenticate(request, email, name) {
await requestJson(request, AUTH_BASE_URL, "/api/auth", {
method: "POST",
data: {
action: "auth:signIn",
args: {
provider: "password",
params: { email, password: e2ePassword(), flow: "signUp", name },
},
},
});
}
async function createPage(request, workspaceId, title, parentId = null) {
const payload = await requestJson(request, BASE_URL, "/api/tree/commands", {
method: "POST",
data: {
action: "create",
workspaceId,
parentId,
title,
},
});
const result = payload.result || payload;
const documentId = result.documentId || payload.documentId || result.id || "";
const resolvedWorkspaceId = result.workspaceId || payload.workspaceId || payload.workspace_id || workspaceId || "";
assert(documentId, `创建页面失败: ${JSON.stringify(payload)}`);
assert(resolvedWorkspaceId, `创建页面缺少 workspaceId: ${JSON.stringify(payload)}`);
return { documentId, workspaceId: resolvedWorkspaceId, payload };
}
async function treeCommand(request, workspaceId, action, documentId, extra = {}) {
return await requestJson(request, BASE_URL, "/api/tree/commands", {
method: "POST",
data: { action, workspaceId, documentId, ...extra },
});
}
async function emptyDocumentTrash(request, workspaceId) {
return await requestJson(request, BASE_URL, "/api/documents/empty-trash", {
method: "POST",
data: { workspaceId },
});
}
async function cleanup(request, workspaceId, documentIds) {
if (!workspaceId) return;
for (const documentId of documentIds.filter(Boolean)) {
await treeCommand(request, workspaceId, "archive", documentId).catch(() => null);
}
await emptyDocumentTrash(request, workspaceId).catch(() => null);
}
async function openDocument(page, workspaceId, documentId) {
await page.goto(`${BASE_URL}/documents/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, {
waitUntil: "commit",
timeout: UI_TIMEOUT_MS,
});
await page.locator(`[data-page-title-input="true"][data-document-id="${cssString(documentId)}"]`).first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
}
async function openDocumentFileTree(page, workspaceId, documentId) {
await openDocument(page, workspaceId, documentId);
await page.evaluate(() => {
const visible = (node) =>
node instanceof HTMLElement &&
!node.hidden &&
getComputedStyle(node).display !== "none" &&
getComputedStyle(node).visibility !== "hidden" &&
node.getClientRects().length > 0;
const fileRoot = document.getElementById("sidebar-file-tree-root");
if (visible(fileRoot)) return;
const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
if (tab instanceof HTMLElement) tab.click();
});
await page.locator("#sidebar-file-tree-root").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
}
async function waitForBDocumentRename(page, documentId, expectedTitle) {
await page.waitForFunction(
({ id, title }) => {
const titleInput = document.querySelector(`[data-page-title-input="true"][data-document-id="${CSS.escape(id)}"]`);
const titleValue = titleInput instanceof HTMLTextAreaElement ? titleInput.value : "";
const breadcrumb = document.querySelector(".wolai-breadcrumb-current [data-page-title-current]")?.textContent?.trim() || "";
const pageRow = document.querySelector(`#sidebar-tree-root .tree-row[data-node-id="${CSS.escape(id)}"] .tree-link-title`)?.textContent?.trim() || "";
return titleValue === title && breadcrumb === title && pageRow === title;
},
{ id: documentId, title: expectedTitle },
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForBFileTreeRename(page, documentId, expectedTitle) {
await page.waitForFunction(
({ id, title }) => {
const fileRow = document.querySelector(`#sidebar-file-tree-root .tree-row[data-row-id="doc:${CSS.escape(id)}"]`);
const fileTitle = fileRow instanceof HTMLElement ? fileRow.querySelector(".tree-link-title")?.textContent?.trim() || "" : "";
return fileTitle === `${title}.md`;
},
{ id: documentId, title: expectedTitle },
{ timeout: UI_TIMEOUT_MS },
);
}
async function installTreeEventRecorder(page, label, records) {
await page.addInitScript(() => {
window.__MNOTE_TASK446_TREE_EVENTS__ = [];
const record = (name, event) => {
const detail = event && event.detail ? event.detail : {};
const payload = detail.payload || detail || {};
const raw = (() => {
try {
return JSON.stringify(payload);
} catch {
return "";
}
})();
window.__MNOTE_TASK446_TREE_EVENTS__.push({
name,
at: Date.now(),
revision: detail.revision || payload.revision || payload.cursor || "",
op: payload && payload.data && payload.data.op ? payload.data.op : payload.op || "",
raw: raw.slice(0, 2000),
});
};
window.addEventListener("tree:snapshot", (event) => record("tree:snapshot", event));
window.addEventListener("tree:delta", (event) => record("tree:delta", event));
window.addEventListener("tree:resync", (event) => record("tree:resync", event));
});
page.on("request", (request) => {
const url = request.url();
if (url.includes("/api/tree/events")) {
records.push({ label, type: "request", method: request.method(), url, at: Date.now() });
}
});
page.on("requestfailed", (request) => {
const url = request.url();
if (url.includes("/api/tree/events")) {
records.push({
label,
type: "requestfailed",
method: request.method(),
url,
failure: request.failure()?.errorText || "",
at: Date.now(),
});
}
});
page.on("console", (message) => {
const text = message.text();
if (/tree live|EventSource|rename|error|failed/i.test(text)) {
records.push({ label, type: "console", level: message.type(), text: text.slice(0, 2000), at: Date.now() });
}
});
}
function recordNavigation(page, label, records) {
page.on("framenavigated", (frame) => {
if (frame === page.mainFrame()) {
records.push({ label, url: frame.url(), at: Date.now() });
}
});
}
async function readState(page, documentId) {
return await page.evaluate((id) => {
const pageRow = document.querySelector(`#sidebar-tree-root .tree-row[data-node-id="${CSS.escape(id)}"]`);
const fileRow = document.querySelector(`#sidebar-file-tree-root .tree-row[data-row-id="doc:${CSS.escape(id)}"]`);
const titleInput = document.querySelector(`[data-page-title-input="true"][data-document-id="${CSS.escape(id)}"]`);
return {
url: window.location.href,
documentTitle: document.title,
liveStatus: document.documentElement.getAttribute("data-mnote-tree-live-status") || "",
liveApplied: document.documentElement.getAttribute("data-mnote-tree-live-applied") || "",
liveRevision: document.documentElement.getAttribute("data-mnote-tree-live-revision") || "",
liveError: document.documentElement.getAttribute("data-mnote-tree-live-apply-error") || "",
titleInputValue: titleInput instanceof HTMLTextAreaElement ? titleInput.value : "",
breadcrumbTitle: document.querySelector(".wolai-breadcrumb-current [data-page-title-current]")?.textContent?.trim() || "",
sidebarTitle: pageRow instanceof HTMLElement ? pageRow.querySelector(".tree-link-title")?.textContent?.trim() || "" : "",
fileTreeTitle: fileRow instanceof HTMLElement ? fileRow.querySelector(".tree-link-title")?.textContent?.trim() || "" : "",
fileTreeRowExists: fileRow instanceof HTMLElement,
treeEvents: window.__MNOTE_TASK446_TREE_EVENTS__ || [],
};
}, documentId);
}
(async () => {
const stamp = Date.now();
const email = `mnote.stage8.rename.${stamp}@example.com`;
const prefix = `TEST-10REVIEW-08-RENAME-${stamp}`;
const initialTitle = `${prefix}-initial`;
const renamedTitle = `${prefix}-renamed`;
const result = {
ok: false,
task: TASK,
baseUrl: BASE_URL,
authBaseUrl: AUTH_BASE_URL,
email,
prefix,
fixture: {},
requests: [],
navigationEvents: [],
treeEventRequests: [],
states: {},
screenshots: {
bDocument: SCREENSHOT_B_DOCUMENT,
bFileTree: SCREENSHOT_B_FILETREE,
},
};
const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1" });
const contextA = await browser.newContext();
const contextB = await browser.newContext();
const pageA = await contextA.newPage();
const documentB = await contextB.newPage();
const fileTreeB = await contextB.newPage();
const requestA = contextA.request;
let workspaceId = "";
const cleanupIds = [];
pageA.on("request", (request) => {
const url = request.url();
if (url.includes("/api/tree/commands") || url.includes("/api/documents/empty-trash")) {
result.requests.push({ side: "A-page", method: request.method(), url, body: request.postData() || null, at: Date.now() });
}
});
await installTreeEventRecorder(documentB, "B-document", result.treeEventRequests);
await installTreeEventRecorder(fileTreeB, "B-filetree", result.treeEventRequests);
recordNavigation(documentB, "B-document", result.navigationEvents);
recordNavigation(fileTreeB, "B-filetree", result.navigationEvents);
try {
for (const requestContext of [requestA, contextB.request]) {
await authenticate(requestContext, email, `stage8-rename-${stamp}`);
}
const root = await createPage(requestA, null, `${prefix}-root`);
workspaceId = root.workspaceId;
cleanupIds.push(root.documentId);
result.fixture.rootId = root.documentId;
result.fixture.workspaceId = workspaceId;
const target = await createPage(requestA, workspaceId, initialTitle, root.documentId);
cleanupIds.push(target.documentId);
result.fixture.targetId = target.documentId;
result.fixture.initialTitle = initialTitle;
result.fixture.renamedTitle = renamedTitle;
await pageA.goto(`${BASE_URL}/documents/${encodeURIComponent(root.documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, {
waitUntil: "commit",
timeout: UI_TIMEOUT_MS,
});
await openDocument(documentB, workspaceId, target.documentId);
await openDocumentFileTree(fileTreeB, workspaceId, target.documentId);
await fileTreeB.locator(docRowSelector(target.documentId)).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await waitForBDocumentRename(documentB, target.documentId, initialTitle);
await waitForBFileTreeRename(fileTreeB, target.documentId, initialTitle);
result.states.before = {
document: await readState(documentB, target.documentId),
fileTree: await readState(fileTreeB, target.documentId),
};
const navigationStart = result.navigationEvents.length;
await treeCommand(requestA, workspaceId, "rename", target.documentId, { title: renamedTitle });
await waitForBDocumentRename(documentB, target.documentId, renamedTitle);
await waitForBFileTreeRename(fileTreeB, target.documentId, renamedTitle);
result.states.after = {
document: await readState(documentB, target.documentId),
fileTree: await readState(fileTreeB, target.documentId),
navigationEvents: result.navigationEvents.slice(navigationStart),
};
const unexpectedNavigations = result.navigationEvents.slice(navigationStart);
assert.equal(unexpectedNavigations.length, 0, `B 浏览器发生了刷新/导航: ${JSON.stringify(unexpectedNavigations)}`);
assert.equal(result.states.after.document.titleInputValue, renamedTitle, "B 文档页页头标题未 live 更新");
assert.equal(result.states.after.document.breadcrumbTitle, renamedTitle, "B 文档页 Breadcrumb 未 live 更新");
assert.equal(result.states.after.document.sidebarTitle, renamedTitle, "B 文档页 Sidebar 未 live 更新");
assert.equal(result.states.after.fileTree.fileTreeTitle, `${renamedTitle}.md`, "B File Tree 未 live 更新为 .md 文件名");
assert(!result.states.after.document.liveError, `B 文档页存在 live apply error: ${result.states.after.document.liveError}`);
assert(!result.states.after.fileTree.liveError, `B File Tree 存在 live apply error: ${result.states.after.fileTree.liveError}`);
await documentB.screenshot({ path: SCREENSHOT_B_DOCUMENT, fullPage: true });
await fileTreeB.screenshot({ path: SCREENSHOT_B_FILETREE, fullPage: true });
result.ok = true;
} catch (error) {
result.error = error instanceof Error ? error.stack || error.message : String(error);
result.failure = {
document: await readState(documentB, result.fixture.targetId || "").catch(() => null),
fileTree: await readState(fileTreeB, result.fixture.targetId || "").catch(() => null),
};
process.exitCode = 1;
} finally {
await cleanup(requestA, workspaceId, cleanupIds).catch((error) => {
result.cleanupError = error instanceof Error ? error.message : String(error);
});
await browser.close().catch(() => undefined);
await writeResult(result);
}
})();