Files
mnote/scripts/task109-leptos-island-multipage-save-smoke.js
T
lix-2026 8353aea2f9 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
2026-04-22 05:57:06 +08:00

218 lines
6.9 KiB
JavaScript

"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);
});