#!/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-options-sync-smoke"); async function saveDocumentContent(requestContext, workspaceId, documentId) { await requestJson(requestContext, "/api/documents/save", { method: "POST", data: { workspaceId, documentId, content: [ { id: "h1", type: "heading", props: { level: 1 }, content: [{ type: "text", text: "Page Aggregate Options Smoke" }], }, { id: "p1", type: "paragraph", content: [{ type: "text", text: "Body for page aggregate options smoke." }], }, ], blockCount: 2, snapshotCapturedAt: new Date().toISOString(), }, }); } 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; } 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 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, shellMaxWidth: shell instanceof HTMLElement ? shell.style.maxWidth || window.getComputedStyle(shell).maxWidth : 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 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 payload; } async function waitForAggregateOption(requestContext, workspaceId, documentId, assertOptions) { 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 ?? {}; if (assertOptions(options)) { return { aggregate, options }; } await new Promise((resolve) => setTimeout(resolve, 300)); } throw new Error( `等待 Page Aggregate pageOptions 同步超时:${JSON.stringify({ documentId, lastOptions: aggregate?.layout?.pageOptions ?? null, })}`, ); } async function main() { await fs.mkdir(OUT_DIR, { recursive: true }); const suffix = Date.now().toString(36); 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 createdIds = []; try { const viewer = await ensureAuthenticated(page, context.request); const target = await createTempDocument(context.request, null); createdIds.push(target.documentId); await saveDocumentContent(context.request, target.workspaceId, target.documentId); await openDocument(page, target.workspaceId, target.documentId); await page.locator(".ProseMirror").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await openPageSettingsDialog(page); const initialAggregate = await fetchPageAggregate(context.request, target.workspaceId, target.documentId); const initialRuntime = await readRuntimePageOptions(page); assert.equal(initialAggregate.layout.pageOptions.wideLayout, false, "初始 wideLayout 应为 false"); assert.equal(initialAggregate.layout.pageOptions.smallText, false, "初始 smallText 应为 false"); assert.equal(initialAggregate.layout.pageOptions.layoutDensity, "normal", "初始 layoutDensity 应为 normal"); assert.equal(initialRuntime.htmlWide, "false", "初始 runtime wideLayout 应为 false"); assert.equal(initialRuntime.htmlSmall, "false", "初始 runtime smallText 应为 false"); assert.equal(initialRuntime.htmlDensity, "normal", "初始 runtime layoutDensity 应为 normal"); const wideResponse = await waitForOptionsResponse(page, target.documentId, async () => { await page.locator('[data-page-option-checkbox="wideLayout"]').click({ timeout: UI_TIMEOUT_MS }); }); const wideAggregate = await waitForAggregateOption( context.request, target.workspaceId, target.documentId, (options) => options.wideLayout === true, ); const afterWideRuntime = await readRuntimePageOptions(page); assert.equal(afterWideRuntime.htmlWide, "true", "开启宽版后 html wideLayout 应为 true"); assert.equal(afterWideRuntime.editorRootWide, "true", "开启宽版后 island root wideLayout 应为 true"); assert.equal(afterWideRuntime.shellMaxWidth, "980px", "开启宽版后主列宽应为 980px"); const smallResponse = await waitForOptionsResponse(page, target.documentId, async () => { await page.locator('[data-page-option-checkbox="smallText"]').click({ timeout: UI_TIMEOUT_MS }); }); const smallAggregate = await waitForAggregateOption( context.request, target.workspaceId, target.documentId, (options) => options.wideLayout === true && options.smallText === true, ); const afterSmallRuntime = await readRuntimePageOptions(page); assert.equal(afterSmallRuntime.htmlSmall, "true", "开启小字体后 html smallText 应为 true"); assert.equal(afterSmallRuntime.editorRootSmall, "true", "开启小字体后 island root smallText 应为 true"); assert( typeof initialRuntime.editorFontSize === "string" && typeof afterSmallRuntime.editorFontSize === "string" && parseFloat(afterSmallRuntime.editorFontSize) < parseFloat(initialRuntime.editorFontSize), `开启小字体后编辑器字号应变小,初始 ${initialRuntime.editorFontSize},实际 ${afterSmallRuntime.editorFontSize}`, ); const densityResponse = await waitForOptionsResponse(page, target.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"); }); const densityAggregate = await waitForAggregateOption( context.request, target.workspaceId, target.documentId, (options) => options.wideLayout === true && options.smallText === true && options.layoutDensity === "compact", ); const afterDensityRuntime = await readRuntimePageOptions(page); assert.equal(afterDensityRuntime.htmlDensity, "compact", "切换紧凑后 html density 应为 compact"); assert.equal(afterDensityRuntime.editorSurfaceDensity, "compact", "切换紧凑后 island surface density 应为 compact"); assert( typeof initialRuntime.paragraphMarginBottom === "string" && typeof afterDensityRuntime.paragraphMarginBottom === "string" && parseFloat(afterDensityRuntime.paragraphMarginBottom) <= parseFloat(initialRuntime.paragraphMarginBottom), `切换紧凑后段落间距应不大于初始值,初始 ${initialRuntime.paragraphMarginBottom},实际 ${afterDensityRuntime.paragraphMarginBottom}`, ); await page.screenshot({ path: screenshotPath, fullPage: false }); const evidence = { ok: true, baseUrl: BASE_URL, viewerUserId: viewer.userId, workspaceId: target.workspaceId, documentId: target.documentId, initial: { aggregate: initialAggregate.layout.pageOptions, runtime: initialRuntime, }, afterWide: { aggregate: wideAggregate.options, runtime: afterWideRuntime, commandName: wideResponse.meta.commandName, }, afterSmall: { aggregate: smallAggregate.options, runtime: afterSmallRuntime, commandName: smallResponse.meta.commandName, }, afterDensity: { aggregate: densityAggregate.options, runtime: afterDensityRuntime, commandName: densityResponse.meta.commandName, }, 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); });