#!/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-body-sync-smoke"); 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 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 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: 20 }); } async function waitForBodySync(requestContext, workspaceId, documentId, beforeRevision, beforeConflictKey, expectedText) { const deadline = Date.now() + UI_TIMEOUT_MS; let lastAggregate = null; while (Date.now() < deadline) { lastAggregate = await fetchPageAggregate(requestContext, workspaceId, documentId); const body = lastAggregate.body ?? {}; const matchedBlock = findBlockWithText(lastAggregate, expectedText); if ( matchedBlock && typeof body.revision === "number" && body.revision > beforeRevision && typeof body.conflictDetectionKey === "string" && body.conflictDetectionKey && body.conflictDetectionKey !== beforeConflictKey && body.blockDocument?.documentId === documentId ) { return { aggregate: lastAggregate, matchedBlock }; } await new Promise((resolve) => setTimeout(resolve, 350)); } throw new Error( `等待 Page Aggregate body 同步超时:${JSON.stringify({ documentId, beforeRevision, beforeConflictKey, lastRevision: lastAggregate?.body?.revision ?? null, lastConflictDetectionKey: lastAggregate?.body?.conflictDetectionKey ?? null, hasExpectedBlock: Boolean(lastAggregate && findBlockWithText(lastAggregate, expectedText)), })}`, ); } async function main() { await fs.mkdir(OUT_DIR, { recursive: true }); const suffix = Date.now().toString(36); const expectedText = `Page Aggregate body sync ${suffix}`; 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 saveRequests = []; const createdIds = []; 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, conflictDetectionKey: payload?.conflictDetectionKey ?? null, commandName: payload?.commandName ?? null, }); }); try { const viewer = await ensureAuthenticated(page, context.request); const target = await createTempDocument(context.request, null); createdIds.push(target.documentId); const before = await fetchPageAggregate(context.request, target.workspaceId, target.documentId); const beforeRevision = typeof before.body?.revision === "number" ? before.body.revision : -1; const beforeConflictKey = typeof before.body?.conflictDetectionKey === "string" ? before.body.conflictDetectionKey : ""; await openDocument(page, target.workspaceId, target.documentId); await waitForRuntimeIsland(page); await typeIntoEditor(page, expectedText); const saveResponse = await 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 === target.documentId && response.ok(); }, { timeout: UI_TIMEOUT_MS }, ); const { aggregate: after, matchedBlock } = await waitForBodySync( context.request, target.workspaceId, target.documentId, beforeRevision, beforeConflictKey, expectedText, ); await page.screenshot({ path: screenshotPath, fullPage: false }); const evidence = { ok: true, baseUrl: BASE_URL, viewerUserId: viewer.userId, workspaceId: target.workspaceId, documentId: target.documentId, expectedText, before: { revision: before.body?.revision ?? null, conflictDetectionKey: before.body?.conflictDetectionKey ?? null, blockCount: before.body?.blockDocument?.blocks?.length ?? null, }, after: { revision: after.body?.revision ?? null, conflictDetectionKey: after.body?.conflictDetectionKey ?? null, blockProjectionVersion: after.body?.blockProjectionVersion ?? null, projectionSource: after.body?.projectionSource ?? null, blockCount: after.body?.blockDocument?.blocks?.length ?? null, }, matchedBlock: { blockId: matchedBlock.blockId, type: matchedBlock.type, text: matchedBlock.text, revisionRef: matchedBlock.revisionRef, editable: matchedBlock.editable, }, saveResponseStatus: saveResponse.status(), saveRequests, 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); });