#!/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-refresh-persistence-smoke"); const CHROME_EXECUTABLE = process.env.PLAYWRIGHT_CHROME_EXECUTABLE || ""; async function callMnoteTool(requestContext, payload) { return requestJson(requestContext, "/api/mnote/tools/call", { method: "POST", headers: { "x-mnote-actor-id": payload.actorId || "smoke-user" }, data: payload, }); } 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 pageSubtreeSummary(aggregate) { const subtree = aggregate?.tree?.pageSubtree ?? null; return { rootNodeId: subtree?.rootNodeId ?? null, outlineLength: Array.isArray(subtree?.outline) ? subtree.outline.length : null, }; } async function fetchAiDoc(requestContext, target, actorId, suffix, label) { const response = await callMnoteTool(requestContext, { toolName: "mnote.doc.fetch", workspaceId: target.workspaceId, documentId: target.documentId, actorId, sessionId: `sess_page_aggregate_ai_fetch_${suffix}`, runId: `run_page_aggregate_ai_fetch_${suffix}_${label}`, toolCallId: `call_page_aggregate_ai_fetch_${suffix}_${label}`, traceId: `trace_page_aggregate_ai_fetch_${suffix}_${label}`, capabilityScope: ["page.read"], args: { scope: "full", detail: "with_ids", maxBlocks: 20, }, }); assert.equal(response.ok, true, `${label}: mnote.doc.fetch 应成功`); assert.equal(response.result.schema, "mnote.page_ai_context.v1", `${label}: AI fetch schema 应稳定`); assert(Array.isArray(response.result.blocks), `${label}: AI fetch 应返回 blocks`); return response.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 waitForPageTitleInput(page) { const input = page.locator('[data-page-title-input="true"][data-pane-role="primary"]').first(); await input.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); return input; } async function readVisibleTitle(page) { const titleInput = page.locator('[data-page-title-input="true"][data-pane-role="primary"]').first(); if (await titleInput.isVisible().catch(() => false)) { return (await titleInput.inputValue()).trim(); } const heading = page.locator("h1").first(); return ((await heading.textContent()) ?? "").trim(); } 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 ?? "").trim(); }); } async function renameThroughPageHead(page, documentId, title) { const titleInput = await waitForPageTitleInput(page); const responsePromise = 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 === title && payload?.commandName === "page.head.updateTitle" && response.ok() ); }, { timeout: UI_TIMEOUT_MS }, ); await titleInput.click({ timeout: UI_TIMEOUT_MS }); await page.keyboard.press("Control+a"); await page.keyboard.type(title, { delay: 10 }); await titleInput.blur(); const response = await responsePromise; return response.status(); } async function typeIntoEditor(page, documentId, text) { const responsePromise = 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 === documentId && response.ok(); }, { timeout: UI_TIMEOUT_MS }, ); 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: 10 }); const response = await responsePromise; return response.status(); } async function openPageSettingsDialog(page) { const trigger = page.getByTestId("wolai-page-settings-trigger"); await trigger.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await trigger.click({ timeout: UI_TIMEOUT_MS }); await page.locator('[data-page-option-checkbox="wideLayout"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS, }); } async function waitForOptionsResponse(page, documentId, mutate) { const responsePromise = page.waitForResponse( async (response) => { if (!response.url().includes("/api/ui/preferences") || response.request().method() !== "PUT") { return false; } const payload = response.request().postDataJSON(); return payload?.documentId === documentId && response.ok(); }, { timeout: UI_TIMEOUT_MS }, ); await mutate(); const response = await responsePromise; const payload = await response.json(); assert.equal(payload?.owner, "mnote-web", "页面设置写入必须由 mnote-web 持有"); assert(payload?.result?.pageOptions, "页面设置写入必须返回 SQLite 合并后的 pageOptions"); return response.status(); } async function setPageOptionsThroughUi(page, documentId) { await openPageSettingsDialog(page); const wideStatus = await waitForOptionsResponse(page, documentId, async () => { await page.locator('[data-page-option-checkbox="wideLayout"]').click({ timeout: UI_TIMEOUT_MS }); }); const smallStatus = await waitForOptionsResponse(page, documentId, async () => { await page.locator('[data-page-option-checkbox="smallText"]').click({ timeout: UI_TIMEOUT_MS }); }); const densityStatus = await waitForOptionsResponse(page, documentId, async () => { await page.locator('[data-page-settings-tab="custom"]').click({ timeout: UI_TIMEOUT_MS }); await page.locator('[data-page-option-select="layoutDensity"]').selectOption("compact"); }); return { wideStatus, smallStatus, densityStatus }; } async function readRuntimePageOptions(page) { return page.evaluate(() => { const shell = document.querySelector(".document-shell"); const editorRoot = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]'); const editorSurface = editorRoot?.querySelector(".editor-surface"); const editor = editorRoot?.querySelector(".ProseMirror"); const firstParagraph = editorRoot?.querySelector(".ProseMirror p"); return { htmlWide: document.documentElement.getAttribute("data-page-wide-layout"), htmlSmall: document.documentElement.getAttribute("data-page-small-text"), htmlDensity: document.documentElement.getAttribute("data-layout-density"), shellWide: shell instanceof HTMLElement ? shell.getAttribute("data-page-wide-layout") : null, shellSmall: shell instanceof HTMLElement ? shell.getAttribute("data-page-small-text") : null, shellDensity: shell instanceof HTMLElement ? shell.getAttribute("data-layout-density") : null, editorRootWide: editorRoot instanceof HTMLElement ? editorRoot.getAttribute("data-page-wide-layout") : null, editorRootSmall: editorRoot instanceof HTMLElement ? editorRoot.getAttribute("data-page-small-text") : null, editorSurfaceDensity: editorSurface instanceof HTMLElement ? editorSurface.getAttribute("data-layout-density") : null, editorFontSize: editor instanceof HTMLElement ? window.getComputedStyle(editor).fontSize : null, paragraphMarginBottom: firstParagraph instanceof HTMLElement ? window.getComputedStyle(firstParagraph).marginBottom : null, }; }); } async function readPageSettingsControls(page) { await openPageSettingsDialog(page); return { wideLayout: await page.locator('[data-page-option-checkbox="wideLayout"]').isChecked(), smallText: await page.locator('[data-page-option-checkbox="smallText"]').isChecked(), layoutDensity: await page.locator('[data-page-option-select="layoutDensity"]').inputValue(), }; } async function waitForRuntimePageOptions(page) { try { await page.waitForFunction( () => { const shell = document.querySelector(".document-shell"); const editorRoot = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]'); const editorSurface = editorRoot?.querySelector(".editor-surface"); return ( document.documentElement.getAttribute("data-page-wide-layout") === "true" && document.documentElement.getAttribute("data-page-small-text") === "true" && document.documentElement.getAttribute("data-layout-density") === "compact" && shell?.getAttribute("data-page-wide-layout") === "true" && shell?.getAttribute("data-page-small-text") === "true" && shell?.getAttribute("data-layout-density") === "compact" && editorRoot?.getAttribute("data-page-wide-layout") === "true" && editorRoot?.getAttribute("data-page-small-text") === "true" && editorSurface?.getAttribute("data-layout-density") === "compact" ); }, null, { timeout: UI_TIMEOUT_MS }, ); } catch (error) { const runtime = await readRuntimePageOptions(page).catch((runtimeError) => ({ readRuntimeError: runtimeError instanceof Error ? runtimeError.message : String(runtimeError), })); const embedded = await page.evaluate(() => { const node = document.getElementById("__MNOTE_PAGE_AGGREGATE__"); try { return node?.textContent ? JSON.parse(node.textContent) : null; } catch (parseError) { return { parseError: parseError instanceof Error ? parseError.message : String(parseError) }; } }).catch((embeddedError) => ({ readEmbeddedError: embeddedError instanceof Error ? embeddedError.message : String(embeddedError), })); throw new Error( `等待 runtime page options 同步超时:${JSON.stringify({ runtime, embeddedPageOptions: embedded?.layout?.pageOptions ?? null })}\n${ error instanceof Error ? error.stack || error.message : String(error) }`, ); } } async function waitForAggregateState(requestContext, workspaceId, documentId, expectedTitle, expectedText) { const deadline = Date.now() + UI_TIMEOUT_MS; let aggregate = null; while (Date.now() < deadline) { aggregate = await fetchPageAggregate(requestContext, workspaceId, documentId); const options = aggregate?.layout?.pageOptions ?? {}; const matchedBlock = findBlockWithText(aggregate, expectedText); if ( aggregate?.head?.title === expectedTitle && matchedBlock && options.wideLayout === true && options.smallText === true && options.layoutDensity === "compact" ) { return { aggregate, matchedBlock }; } await new Promise((resolve) => setTimeout(resolve, 350)); } throw new Error( `等待 Page Aggregate 刷新持久态同步超时:${JSON.stringify({ documentId, expectedTitle, expectedText, lastTitle: aggregate?.head?.title ?? null, lastOptions: aggregate?.layout?.pageOptions ?? null, hasExpectedBlock: Boolean(aggregate && findBlockWithText(aggregate, expectedText)), })}`, ); } function assertRuntimeOptions(runtime, label) { assert.equal(runtime.htmlWide, "true", `${label} html wideLayout 应保持 true`); assert.equal(runtime.htmlSmall, "true", `${label} html smallText 应保持 true`); assert.equal(runtime.htmlDensity, "compact", `${label} html layoutDensity 应保持 compact`); assert.equal(runtime.editorRootWide, "true", `${label} island root wideLayout 应保持 true`); assert.equal(runtime.editorRootSmall, "true", `${label} island root smallText 应保持 true`); assert.equal(runtime.editorSurfaceDensity, "compact", `${label} island surface density 应保持 compact`); } async function main() { await fs.mkdir(OUT_DIR, { recursive: true }); const suffix = Date.now().toString(36); const title = `Page Aggregate refresh ${suffix}`; const bodyText = `Page Aggregate refresh body ${suffix}`; const evidencePath = path.join(OUT_DIR, `${suffix}.json`); const screenshotPath = path.join(OUT_DIR, `${suffix}.png`); const browser = await chromium.launch({ headless: true, ...(CHROME_EXECUTABLE ? { executablePath: CHROME_EXECUTABLE } : {}), args: ["--no-sandbox", "--disable-dev-shm-usage"], }); const context = await browser.newContext({ viewport: { width: 1440, height: 960 } }); const page = await context.newPage(); const createdIds = []; try { const viewer = await ensureAuthenticated(page, context.request); const target = await createTempDocument(context.request, null); createdIds.push(target.documentId); await openDocument(page, target.workspaceId, target.documentId); await waitForPageTitleInput(page); await waitForRuntimeIsland(page); const titleStatus = await renameThroughPageHead(page, target.documentId, title); assert((await readVisibleTitle(page)).includes(title), "写入后页头标题应立即显示最新值"); const saveStatus = await typeIntoEditor(page, target.documentId, bodyText); await page.waitForFunction( (expectedText) => (document.querySelector(".editor-surface .ProseMirror")?.textContent ?? "").includes(expectedText), bodyText, { timeout: UI_TIMEOUT_MS }, ); const optionStatuses = await setPageOptionsThroughUi(page, target.documentId); await waitForRuntimePageOptions(page); const beforeReloadRuntime = await readRuntimePageOptions(page); assertRuntimeOptions(beforeReloadRuntime, "刷新前"); const { aggregate: beforeReloadAggregate, matchedBlock: beforeReloadBlock } = await waitForAggregateState( context.request, target.workspaceId, target.documentId, title, bodyText, ); const beforeReloadAiFetch = await fetchAiDoc(context.request, target, viewer.userId, suffix, "before_reload"); const beforeReloadAiBlock = beforeReloadAiFetch.blocks.find((block) => block.blockId === beforeReloadBlock.blockId); assert.equal(beforeReloadAiFetch.revision, beforeReloadAggregate.body?.revision, "刷新前 AI fetch revision 应与 Page Aggregate 一致"); assert.equal( beforeReloadAiFetch.conflictDetectionKey, beforeReloadAggregate.body?.conflictDetectionKey, "刷新前 AI fetch conflictDetectionKey 应与 Page Aggregate 一致", ); assert.equal(beforeReloadAiBlock?.text, bodyText, "刷新前 AI fetch 应回读 Page Aggregate 正文块"); assert.equal( beforeReloadAggregate.tree?.pageSubtree?.rootNodeId, target.documentId, "刷新前 Page Aggregate pageSubtree.rootNodeId 应等于 documentId", ); await page.reload({ waitUntil: "commit", timeout: UI_TIMEOUT_MS }); await waitForPageTitleInput(page); await waitForRuntimeIsland(page); await page.waitForFunction( (expectedText) => (document.querySelector(".editor-surface .ProseMirror")?.textContent ?? "").includes(expectedText), bodyText, { timeout: UI_TIMEOUT_MS }, ); await waitForRuntimePageOptions(page); const afterReloadTitle = await readVisibleTitle(page); const afterReloadEditorText = await readEditorText(page); const afterReloadRuntime = await readRuntimePageOptions(page); assert(afterReloadTitle.includes(title), "刷新后页头标题不应回退旧快照"); assert(afterReloadEditorText.includes(bodyText), "刷新后正文不应回退旧快照"); assertRuntimeOptions(afterReloadRuntime, "刷新后"); const afterReloadControls = await readPageSettingsControls(page); assert.equal(afterReloadControls.wideLayout, true, "刷新后页面设置 wideLayout 控件应保持 true"); assert.equal(afterReloadControls.smallText, true, "刷新后页面设置 smallText 控件应保持 true"); assert.equal(afterReloadControls.layoutDensity, "compact", "刷新后页面设置 layoutDensity 控件应保持 compact"); const { aggregate: afterReloadAggregate, matchedBlock: afterReloadBlock } = await waitForAggregateState( context.request, target.workspaceId, target.documentId, title, bodyText, ); const afterReloadAiFetch = await fetchAiDoc(context.request, target, viewer.userId, suffix, "after_reload"); const afterReloadAiBlock = afterReloadAiFetch.blocks.find((block) => block.blockId === afterReloadBlock.blockId); assert.equal(afterReloadAiFetch.revision, afterReloadAggregate.body?.revision, "刷新后 AI fetch revision 应与 Page Aggregate 一致"); assert.equal( afterReloadAiFetch.conflictDetectionKey, afterReloadAggregate.body?.conflictDetectionKey, "刷新后 AI fetch conflictDetectionKey 应与 Page Aggregate 一致", ); assert.equal(afterReloadAiBlock?.text, bodyText, "刷新后 AI fetch 应回读 Page Aggregate 正文块"); assert.equal( afterReloadAggregate.tree?.pageSubtree?.rootNodeId, target.documentId, "刷新后 Page Aggregate pageSubtree.rootNodeId 应等于 documentId", ); await page.screenshot({ path: screenshotPath, fullPage: false }); const evidence = { ok: true, baseUrl: BASE_URL, viewerUserId: viewer.userId, workspaceId: target.workspaceId, documentId: target.documentId, expected: { title, bodyText, pageOptions: { wideLayout: true, smallText: true, layoutDensity: "compact", }, }, commandStatuses: { titleStatus, saveStatus, ...optionStatuses, }, beforeReload: { aggregate: { title: beforeReloadAggregate.head?.title ?? null, revision: beforeReloadAggregate.body?.revision ?? null, conflictDetectionKey: beforeReloadAggregate.body?.conflictDetectionKey ?? null, pageOptions: beforeReloadAggregate.layout?.pageOptions ?? null, blockProjectionVersion: beforeReloadAggregate.body?.blockProjectionVersion ?? null, pageSubtree: pageSubtreeSummary(beforeReloadAggregate), }, matchedBlock: { blockId: beforeReloadBlock.blockId, text: beforeReloadBlock.text, revisionRef: beforeReloadBlock.revisionRef, }, aiFetch: { schema: beforeReloadAiFetch.schema, revision: beforeReloadAiFetch.revision, conflictDetectionKey: beforeReloadAiFetch.conflictDetectionKey, matchedBlock: { blockId: beforeReloadAiBlock?.blockId ?? null, text: beforeReloadAiBlock?.text ?? null, revisionRef: beforeReloadAiBlock?.revisionRef ?? null, }, }, runtime: beforeReloadRuntime, }, afterReload: { aggregate: { title: afterReloadAggregate.head?.title ?? null, revision: afterReloadAggregate.body?.revision ?? null, conflictDetectionKey: afterReloadAggregate.body?.conflictDetectionKey ?? null, pageOptions: afterReloadAggregate.layout?.pageOptions ?? null, blockProjectionVersion: afterReloadAggregate.body?.blockProjectionVersion ?? null, pageSubtree: pageSubtreeSummary(afterReloadAggregate), }, matchedBlock: { blockId: afterReloadBlock.blockId, text: afterReloadBlock.text, revisionRef: afterReloadBlock.revisionRef, }, aiFetch: { schema: afterReloadAiFetch.schema, revision: afterReloadAiFetch.revision, conflictDetectionKey: afterReloadAiFetch.conflictDetectionKey, matchedBlock: { blockId: afterReloadAiBlock?.blockId ?? null, text: afterReloadAiBlock?.text ?? null, revisionRef: afterReloadAiBlock?.revisionRef ?? null, }, }, title: afterReloadTitle, editorText: afterReloadEditorText, runtime: afterReloadRuntime, controls: afterReloadControls, }, 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); });