feat(editor): save leptos island and page aggregate alignment progress

- switch main document flow toward leptos tiptap island host and generated runtime assets

- align page aggregate loading, page head single-source updates, and AI tool result recovery

- add tests and smoke scripts for title sync, AI route recovery, and editor host cutover
This commit is contained in:
lix-2026
2026-04-22 05:57:06 +08:00
parent 5d1c94eb9e
commit 8353aea2f9
105 changed files with 14768 additions and 4186 deletions
@@ -0,0 +1,237 @@
"use strict";
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
assert,
createTempDocument,
ensureAuthenticated,
purgeDocument,
} = require("./tree-shell-smoke-helpers");
function readDocumentIdFromUrl(url) {
try {
const parsed = new URL(url);
const match = parsed.pathname.match(/^\/documents\/([^/]+)$/);
return match ? match[1] : null;
} catch {
return null;
}
}
async function collectRuntimeIslandDiagnostics(page) {
return page.evaluate(() => {
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const observability = document.querySelector("[data-editor-host-observability]");
const editor = host?.querySelector(".editor-surface .ProseMirror");
const textareaCount = host?.querySelectorAll("textarea").length ?? 0;
const contenteditableCount = host?.querySelectorAll(".editor-surface .ProseMirror[contenteditable]").length ?? 0;
return {
hostKind: host?.getAttribute("data-editor-host-kind") ?? null,
runtimeStatus: host?.getAttribute("data-runtime-editor-status") ?? null,
activeHostKind: observability?.getAttribute("data-editor-host-active") ?? null,
observability: observability?.getAttribute("data-editor-host-observability") ?? null,
editorTagName: editor instanceof HTMLElement ? editor.tagName : null,
editorIsContentEditable: editor instanceof HTMLElement ? editor.isContentEditable : false,
editorContentEditableAttr: editor instanceof HTMLElement ? editor.getAttribute("contenteditable") : null,
editorCount: host?.querySelectorAll(".editor-surface .ProseMirror").length ?? 0,
textareaCount,
contenteditableCount,
hostHTML: host?.innerHTML?.slice(0, 2000) ?? null,
};
});
}
async function waitForRuntimeIsland(page) {
const root = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
await root.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
try {
await page.waitForFunction(
() => {
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const observability = document.querySelector("[data-editor-host-observability]");
const editor = host?.querySelector(".editor-surface .ProseMirror");
return (
host?.getAttribute("data-editor-host-kind") === "leptos_tiptap_island" &&
observability?.getAttribute("data-editor-host-active") === "leptos_tiptap_island" &&
host?.getAttribute("data-runtime-editor-status") !== "error" &&
editor instanceof HTMLElement &&
editor.isContentEditable === true
);
},
null,
{ timeout: UI_TIMEOUT_MS },
);
} catch (error) {
const diagnostics = await collectRuntimeIslandDiagnostics(page).catch(() => null);
throw new Error(
`${error instanceof Error ? error.message : String(error)}\n${JSON.stringify(diagnostics, null, 2)}`,
);
}
const editorCount = await root.locator(".editor-surface .ProseMirror").count();
const textareaCount = await root.locator("textarea").count();
const contenteditableCount = await root.locator(".editor-surface .ProseMirror[contenteditable]").count();
if (editorCount === 0 || contenteditableCount === 0 || textareaCount > 0) {
const diagnostics = await collectRuntimeIslandDiagnostics(page).catch(() => null);
throw new Error(
[
editorCount === 0 ? "island 主编辑器根节点内缺少 `.editor-surface .ProseMirror` surface" : null,
contenteditableCount === 0 ? "island 主编辑器 surface 未暴露真实 contenteditable" : null,
textareaCount > 0 ? "island 主编辑器根节点内不应回退为 textarea" : null,
]
.filter(Boolean)
.join("") +
`\n${JSON.stringify(diagnostics, null, 2)}`,
);
}
}
async function waitForSaved(page) {
await page.waitForFunction(
() =>
document
.querySelector('[data-editor-host-kind="leptos_tiptap_island"]')
?.getAttribute("data-runtime-editor-status") === "saved",
null,
{ timeout: UI_TIMEOUT_MS },
);
}
async function readEditorText(page) {
return page.evaluate(() => {
const editor = document.querySelector(
'[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror',
);
return editor?.textContent ?? "";
});
}
async function main() {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
});
const page = await context.newPage();
let caughtError = null;
let createdDocumentId = null;
let createdWorkspaceId = null;
try {
await ensureAuthenticated(page, context.request);
const created = await createTempDocument(context.request, null);
createdDocumentId = created.documentId;
createdWorkspaceId = created.workspaceId;
await page.goto(
`${BASE_URL}/documents/${createdDocumentId}?workspaceId=${encodeURIComponent(createdWorkspaceId)}`,
{ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS },
);
assert(
page.url().includes(`/documents/${createdDocumentId}`),
`未进入新建页面:${page.url()}`,
);
await waitForRuntimeIsland(page);
const editor = page
.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]')
.first();
const text = `task108-island-${Date.now().toString().slice(-6)}`;
await editor.evaluate((el) => {
if (el instanceof HTMLElement) {
el.focus();
}
});
await page.keyboard.type(text, { delay: 30 });
await waitForSaved(page);
assert((await readEditorText(page)).includes(text), "默认 runtime island 未写入文本");
await page.keyboard.press("Control+z");
await page.waitForFunction(
(expected) => {
const editorNode = document.querySelector(
'[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]',
);
return !(editorNode?.textContent ?? "").includes(expected);
},
text,
{ timeout: UI_TIMEOUT_MS },
);
await page.keyboard.press("Control+y");
await page.waitForFunction(
(expected) => {
const editorNode = document.querySelector(
'[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]',
);
return (editorNode?.textContent ?? "").includes(expected);
},
text,
{ timeout: UI_TIMEOUT_MS },
);
await waitForSaved(page);
await page.reload({ waitUntil: "commit", timeout: UI_TIMEOUT_MS });
await waitForRuntimeIsland(page);
assert((await readEditorText(page)).includes(text), "刷新后未回填 runtime island 保存内容");
await page.goto(
`${BASE_URL}/documents/${createdDocumentId}?workspaceId=${encodeURIComponent(createdWorkspaceId)}&editorHost=blocknote`,
{ waitUntil: "commit", timeout: UI_TIMEOUT_MS },
);
await page.waitForFunction(
() =>
document
.querySelector("[data-editor-host-observability]")
?.getAttribute("data-editor-host-active") === "blocknote",
null,
{ timeout: UI_TIMEOUT_MS },
);
const runtimeIslandCount = await page
.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]')
.count();
assert(runtimeIslandCount === 0, "显式 blocknote 回退下不应继续挂载 island 主编辑器");
console.log(
JSON.stringify(
{
ok: true,
baseUrl: BASE_URL,
documentId: createdDocumentId,
workspaceId: createdWorkspaceId,
text,
},
null,
2,
),
);
} catch (error) {
caughtError = error;
} finally {
if (createdDocumentId) {
try {
await purgeDocument(context.request, createdDocumentId);
} catch (cleanupError) {
if (!caughtError) {
caughtError = cleanupError;
}
}
}
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
if (caughtError) {
throw caughtError;
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
@@ -0,0 +1,217 @@
"use strict";
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
assert,
createTempDocument,
ensureAuthenticated,
purgeDocument,
} = require("./tree-shell-smoke-helpers");
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 waitForSaved(page) {
await page.waitForFunction(
() =>
document
.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]')
?.getAttribute("data-runtime-editor-status") === "saved",
null,
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForTextPresent(page, text) {
await page.waitForFunction(
(expected) => {
const editor = document.querySelector(
'[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror',
);
return (editor?.textContent ?? "").includes(expected);
},
text,
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForPersisted(page, saveRequests, documentId, expectedText) {
const deadline = Date.now() + UI_TIMEOUT_MS;
while (Date.now() < deadline) {
const editorText = await readEditorText(page);
const runtimeStatus = await page.evaluate(() => {
return (
document
.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]')
?.getAttribute("data-runtime-editor-status") ?? null
);
});
const hasSaveRequest = saveRequests.some((item) => item.documentId === documentId);
if (editorText.includes(expectedText) && (runtimeStatus === "saved" || hasSaveRequest)) {
return;
}
await page.waitForTimeout(250);
}
throw new Error(`等待持久化超时:${documentId}`);
}
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: 30 });
}
async function readEditorText(page) {
return page.evaluate(() => {
const editor = document.querySelector(
'[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror',
);
return editor?.textContent ?? "";
});
}
async function openDocument(page, documentId, workspaceId) {
await page.goto(
`${BASE_URL}/documents/${documentId}?workspaceId=${encodeURIComponent(workspaceId)}`,
{ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS },
);
await waitForRuntimeIsland(page);
}
async function main() {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
});
const page = await context.newPage();
let docA = null;
let docB = null;
let caughtError = null;
const saveRequests = [];
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,
});
});
try {
await ensureAuthenticated(page, context.request);
docA = await createTempDocument(context.request, null);
docB = await createTempDocument(context.request, null);
const textA = `doc-a-${Date.now().toString().slice(-6)}`;
const textB = `doc-b-${(Date.now() + 1).toString().slice(-6)}`;
await openDocument(page, docA.documentId, docA.workspaceId);
await typeIntoEditor(page, textA);
await waitForTextPresent(page, textA);
await waitForPersisted(page, saveRequests, docA.documentId, textA);
assert((await readEditorText(page)).includes(textA), "A 页文本未进入编辑区");
await openDocument(page, docB.documentId, docB.workspaceId);
await typeIntoEditor(page, textB);
await waitForTextPresent(page, textB);
await waitForPersisted(page, saveRequests, docB.documentId, textB);
assert((await readEditorText(page)).includes(textB), "B 页文本未进入编辑区");
await openDocument(page, docA.documentId, docA.workspaceId);
assert((await readEditorText(page)).includes(textA), "切回 A 页后内容串页或丢失");
await page.reload({ waitUntil: "commit", timeout: UI_TIMEOUT_MS });
await waitForRuntimeIsland(page);
assert((await readEditorText(page)).includes(textA), "A 页刷新后未回填");
await openDocument(page, docB.documentId, docB.workspaceId);
assert((await readEditorText(page)).includes(textB), "切回 B 页后内容串页或丢失");
await page.reload({ waitUntil: "commit", timeout: UI_TIMEOUT_MS });
await waitForRuntimeIsland(page);
assert((await readEditorText(page)).includes(textB), "B 页刷新后未回填");
const savedDocumentIds = new Set(saveRequests.map((item) => item.documentId).filter(Boolean));
assert(savedDocumentIds.has(docA.documentId), "未观察到 A 页保存请求");
assert(savedDocumentIds.has(docB.documentId), "未观察到 B 页保存请求");
assert(
saveRequests.some(
(item) => item.documentId === docA.documentId && item.workspaceId === docA.workspaceId,
),
"A 页保存请求缺少正确 workspaceId/documentId 绑定",
);
assert(
saveRequests.some(
(item) => item.documentId === docB.documentId && item.workspaceId === docB.workspaceId,
),
"B 页保存请求缺少正确 workspaceId/documentId 绑定",
);
console.log(
JSON.stringify(
{
ok: true,
docA,
docB,
saveRequests,
textA,
textB,
},
null,
2,
),
);
} catch (error) {
caughtError = error;
} finally {
for (const doc of [docA, docB]) {
if (!doc?.documentId) {
continue;
}
try {
await purgeDocument(context.request, doc.documentId);
} catch (cleanupError) {
if (!caughtError) {
caughtError = cleanupError;
}
}
}
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
if (caughtError) {
throw caughtError;
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
@@ -0,0 +1,217 @@
"use strict";
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
assert,
cleanupDocuments,
createTempDocument,
ensureAuthenticated,
openDocument,
openFilesystemView,
openSectionView,
renameDocument,
} = require("./tree-shell-smoke-helpers");
async function waitForPageTitleInput(page) {
const input = page.getByLabel("页面标题");
await input.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
return input;
}
async function waitForTitleSave(page, documentId, expectedTitle) {
await page.waitForResponse(
async (response) => {
if (!response.url().includes("/api/documents/title") || response.request().method() !== "POST") {
return false;
}
const payload = response.request().postDataJSON();
return payload?.documentId === documentId && payload?.title === expectedTitle && response.ok();
},
{ timeout: UI_TIMEOUT_MS },
);
}
async function renameThroughPageHead(page, documentId, title) {
const titleInput = await waitForPageTitleInput(page);
await titleInput.click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.press("Control+a");
await page.keyboard.type(title, { delay: 20 });
await titleInput.blur();
await waitForTitleSave(page, documentId, title);
}
async function waitForBreadcrumbTitle(page, title) {
await page.waitForFunction(
(expectedTitle) => {
const nav = document.querySelector("header nav");
return (nav?.textContent ?? "").includes(expectedTitle);
},
title,
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForSidebarRowTitle(page, documentId, title) {
await page.waitForFunction(
({ docId, expectedTitle }) => {
const row = document.querySelector(`aside a[href="/documents/${docId}"]`);
return (row?.textContent ?? "").includes(expectedTitle);
},
{ docId: documentId, expectedTitle: title },
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForPageTreeTitle(page, documentId, title) {
await openSectionView(page);
const shellHost = page.getByTestId("sidebar-page-tree-shell");
await shellHost.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
({ nodeId, expectedTitle }) => {
const row = document.querySelector(
`[data-testid="page-tree-row"][data-node-id="${nodeId}"]`,
);
return (row?.textContent ?? "").includes(expectedTitle);
},
{ nodeId: documentId, expectedTitle: title },
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForFileTreeTitle(page, documentId, title) {
await openFilesystemView(page);
const shellHost = page.getByTestId("sidebar-file-tree-shell");
await shellHost.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
({ docId, expectedTitle }) => {
const row = document.querySelector(
`[data-testid="filetree-doc-row"][data-doc-id="${docId}"]`,
);
return (row?.textContent ?? "").includes(expectedTitle);
},
{ docId: documentId, expectedTitle: title },
{ timeout: UI_TIMEOUT_MS },
);
}
async function readVisibleTitle(page) {
const titleInput = page.getByLabel("页面标题");
if (await titleInput.isVisible().catch(() => false)) {
return (await titleInput.inputValue()).trim();
}
const heading = page.locator("h1").first();
return ((await heading.textContent()) ?? "").trim();
}
async function main() {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
});
const page = await context.newPage();
let caughtError = null;
let fixture = null;
try {
const viewer = await ensureAuthenticated(page, context.request);
const first = await createTempDocument(context.request, null);
const second = await createTempDocument(context.request, null);
const initialTitle = `phase-i-initial-${Date.now().toString().slice(-6)}`;
const renamedTitle = `phase-i-renamed-${(Date.now() + 1).toString().slice(-6)}`;
const siblingTitle = `phase-i-sibling-${(Date.now() + 2).toString().slice(-6)}`;
await renameDocument(context.request, first.workspaceId, first.documentId, initialTitle);
await renameDocument(context.request, second.workspaceId, second.documentId, siblingTitle);
fixture = {
workspaceId: first.workspaceId,
primaryId: first.documentId,
siblingId: second.documentId,
createdIds: [first.documentId, second.documentId],
initialTitle,
renamedTitle,
siblingTitle,
};
await openDocument(page, fixture.workspaceId, fixture.primaryId);
await waitForPageTitleInput(page);
assert((await readVisibleTitle(page)).includes(initialTitle), "页头未回填初始标题");
await renameThroughPageHead(page, fixture.primaryId, fixture.renamedTitle);
assert((await readVisibleTitle(page)).includes(fixture.renamedTitle), "页头未显示最新标题");
await waitForBreadcrumbTitle(page, fixture.renamedTitle);
await waitForSidebarRowTitle(page, fixture.primaryId, fixture.renamedTitle);
await waitForPageTreeTitle(page, fixture.primaryId, fixture.renamedTitle);
await waitForFileTreeTitle(page, fixture.primaryId, fixture.renamedTitle);
await openDocument(page, fixture.workspaceId, fixture.siblingId);
await waitForPageTitleInput(page);
assert((await readVisibleTitle(page)).includes(fixture.siblingTitle), "切到兄弟页后标题不正确");
await openDocument(page, fixture.workspaceId, fixture.primaryId);
await waitForPageTitleInput(page);
await waitForBreadcrumbTitle(page, fixture.renamedTitle);
await waitForSidebarRowTitle(page, fixture.primaryId, fixture.renamedTitle);
assert((await readVisibleTitle(page)).includes(fixture.renamedTitle), "切页往返后页头标题回闪");
await page.reload({ waitUntil: "commit", timeout: UI_TIMEOUT_MS });
await waitForPageTitleInput(page);
await waitForBreadcrumbTitle(page, fixture.renamedTitle);
await waitForSidebarRowTitle(page, fixture.primaryId, fixture.renamedTitle);
await waitForPageTreeTitle(page, fixture.primaryId, fixture.renamedTitle);
await waitForFileTreeTitle(page, fixture.primaryId, fixture.renamedTitle);
assert((await readVisibleTitle(page)).includes(fixture.renamedTitle), "刷新后页头标题未保持一致");
console.log(
JSON.stringify(
{
ok: true,
baseUrl: BASE_URL,
viewerUserId: viewer.userId,
workspaceId: fixture.workspaceId,
primaryId: fixture.primaryId,
siblingId: fixture.siblingId,
renamedTitle: fixture.renamedTitle,
},
null,
2,
),
);
} catch (error) {
caughtError = error;
} finally {
if (fixture) {
try {
await cleanupDocuments(context.request, fixture.createdIds);
} catch (cleanupError) {
if (!caughtError) {
caughtError = cleanupError;
} else {
console.error(
`清理临时页面失败:${
cleanupError instanceof Error
? cleanupError.stack || cleanupError.message
: String(cleanupError)
}`,
);
}
}
}
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
if (caughtError) {
throw caughtError;
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});