"use strict"; // 说明: // - 这是 task-021 的最小真实浏览器回归脚本。 // - 目标覆盖 Mindmap 全屏页、节点新增/删除、保存链与 requestId/traceId 元信息同步。 // - 脚本会创建临时页面和临时导图,回归结束后清理,避免污染现有数据。 const { chromium } = require("playwright"); const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000"; const REQUEST_TIMEOUT_MS = 20_000; const UI_TIMEOUT_MS = 30_000; function assert(condition, message) { if (!condition) { throw new Error(message); } } async function requestJson(path, init = {}) { const response = await fetch(`${BASE_URL}${path}`, { ...init, headers: { "content-type": "application/json", ...(init.headers || {}), }, }); const text = await response.text(); let payload = null; try { payload = text ? JSON.parse(text) : null; } catch { payload = text; } if (!response.ok) { throw new Error( `${path} 请求失败: ${response.status} ${response.statusText} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`, ); } return payload; } async function createTempDocument() { const payload = await requestJson("/api/documents/create", { method: "POST", body: JSON.stringify({ parentId: null }), }); assert(payload && typeof payload.id === "string", "创建临时页面失败:缺少 id"); assert(payload && typeof payload.workspace_id === "string", "创建临时页面失败:缺少 workspace_id"); return { documentId: payload.id, workspaceId: payload.workspace_id, }; } async function createTempMindmap(documentId, mindmapId) { await requestJson(`/api/mindmap/${documentId}/${mindmapId}`, { method: "POST", body: JSON.stringify({ createOnly: true, data: { data: { text: "中心主题" }, children: [], }, }), }); } async function cleanupTempMindmap(documentId, mindmapId) { try { await requestJson(`/api/mindmap/${documentId}/${mindmapId}`, { method: "DELETE", }); } catch { // 忽略清理失败,继续尝试 purge 文档。 } } async function purgeTempDocument(documentId) { await requestJson("/api/documents/purge", { method: "POST", body: JSON.stringify({ documentId }), }); } async function waitForMindmapInstance(page, mindmapId) { await page.waitForFunction( (id) => Boolean(window.__mindmapInstancesById?.[id] || window.__mindmapInstance), mindmapId, { timeout: UI_TIMEOUT_MS }, ); } async function readMindmapMetaAttrs(page) { const fullscreen = page.locator("[data-testid=\"mindmap-fullscreen\"]"); return { documentId: await fullscreen.getAttribute("data-document-id"), pageId: await fullscreen.getAttribute("data-page-id"), attachmentId: await fullscreen.getAttribute("data-attachment-id"), mindmapId: await fullscreen.getAttribute("data-mindmap-id"), workspaceId: await fullscreen.getAttribute("data-workspace-id"), requestId: await fullscreen.getAttribute("data-request-id"), traceId: await fullscreen.getAttribute("data-trace-id"), }; } async function waitForMetaAttrs(page, meta) { await page.waitForFunction( ({ requestId, traceId }) => { const el = document.querySelector("[data-testid=\"mindmap-fullscreen\"]"); if (!el) return false; return ( el.getAttribute("data-request-id") === requestId && el.getAttribute("data-trace-id") === traceId ); }, { requestId: meta.requestId, traceId: meta.traceId, }, { timeout: UI_TIMEOUT_MS }, ); } function assertRouteMeta(meta, expected) { assert(meta && typeof meta.requestId === "string" && meta.requestId, "缺少 meta.requestId"); assert(meta && typeof meta.traceId === "string" && meta.traceId, "缺少 meta.traceId"); assert(meta.documentId === expected.documentId, `documentId 不一致:${meta.documentId}`); assert(meta.pageId === expected.documentId, `pageId 不一致:${meta.pageId}`); assert(meta.mindmapId === expected.mindmapId, `mindmapId 不一致:${meta.mindmapId}`); assert(meta.attachmentId === expected.mindmapId, `attachmentId 不一致:${meta.attachmentId}`); assert(meta.workspaceId === expected.workspaceId, `workspaceId 不一致:${meta.workspaceId}`); } async function persistInsertAndRename(page, mindmapId, childText) { return page.evaluate( ({ currentMindmapId, nextChildText }) => { const instance = window.__mindmapInstancesById?.[currentMindmapId] ?? window.__mindmapInstance; if (!instance) { throw new Error("未找到 mindmap 实例"); } const renderer = instance.renderer; const root = renderer?.root ?? renderer?.renderTree?._node; if (!root) { throw new Error("未找到根节点"); } renderer?.clearActiveNodeList?.(); renderer?.addNodeToActiveList?.(root, true); renderer.lastActiveNodeList = [root]; renderer?.emitNodeActiveEvent?.(root); instance.execCommand?.("SET_NODE_ACTIVE", root, true); instance.execCommand?.("INSERT_CHILD_NODE", false, [root]); const snapshot = instance.getData?.(true) ?? instance.getData?.() ?? null; if (!snapshot?.root?.children?.[0]?.data) { throw new Error("插入子节点后未拿到快照"); } snapshot.root.children[0].data.text = nextChildText; window.__mindmapPersistById?.[currentMindmapId]?.(snapshot); return { childText: snapshot.root.children[0].data.text, childCount: snapshot.root.children.length, }; }, { currentMindmapId: mindmapId, nextChildText: childText, }, ); } async function persistDeleteChild(page, mindmapId) { return page.evaluate((currentMindmapId) => { const instance = window.__mindmapInstancesById?.[currentMindmapId] ?? window.__mindmapInstance; if (!instance) { throw new Error("未找到 mindmap 实例"); } const snapshot = instance.getData?.(true) ?? instance.getData?.() ?? null; if (!snapshot?.root) { throw new Error("删除子节点时未拿到快照"); } snapshot.root.children = []; window.__mindmapPersistById?.[currentMindmapId]?.(snapshot); return { childCount: snapshot.root.children.length, }; }, mindmapId); } async function openOutlinePanel(page) { const outlineButton = page.getByRole("button", { name: "大纲" }); await outlineButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await outlineButton.click(); } async function runBrowserRegression(target) { const uniqueSuffix = Date.now().toString(); const mindmapId = `task021-${uniqueSuffix}`; const childText = `task021-节点-${uniqueSuffix}`; const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 1440, height: 960 }, }); try { await createTempMindmap(target.documentId, mindmapId); const mindmapUrl = `${BASE_URL}/mindmap/${target.documentId}/${mindmapId}`; await page.goto(mindmapUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); const fullscreen = page.locator("[data-testid=\"mindmap-fullscreen\"]"); const canvas = page.locator("[data-testid=\"mindmap-canvas\"]"); const rootText = page.getByText("中心主题").first(); await fullscreen.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await canvas.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await rootText.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await waitForMindmapInstance(page, mindmapId); const initialMetaAttrs = await readMindmapMetaAttrs(page); assert(initialMetaAttrs.documentId === target.documentId, "页面 data-document-id 不正确"); assert(initialMetaAttrs.pageId === target.documentId, "页面 data-page-id 不正确"); assert(initialMetaAttrs.mindmapId === mindmapId, "页面 data-mindmap-id 不正确"); assert(initialMetaAttrs.attachmentId === mindmapId, "页面 data-attachment-id 不正确"); assert(initialMetaAttrs.workspaceId === target.workspaceId, "页面 data-workspace-id 不正确"); const insertSaveResponsePromise = page.waitForResponse( (response) => response.url().includes(`/api/mindmap/${target.documentId}/${mindmapId}`) && response.request().method() === "POST" && response.status() === 200 && (response.request().postData() || "").includes(childText), { timeout: UI_TIMEOUT_MS }, ); const insertMutation = await persistInsertAndRename(page, mindmapId, childText); assert(insertMutation.childCount === 1, `插入子节点后数量异常:${insertMutation.childCount}`); assert(insertMutation.childText === childText, `插入子节点名称异常:${insertMutation.childText}`); const insertSaveResponse = await insertSaveResponsePromise; const insertSavePayload = await insertSaveResponse.json(); assertRouteMeta(insertSavePayload.meta, { documentId: target.documentId, mindmapId, workspaceId: target.workspaceId, }); await waitForMetaAttrs(page, insertSavePayload.meta); await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await fullscreen.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await canvas.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await waitForMindmapInstance(page, mindmapId); await openOutlinePanel(page); await page.getByRole("button", { name: new RegExp(childText) }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS, }); const insertSavedMindmap = await requestJson(`/api/mindmap/${target.documentId}/${mindmapId}`); assert( Array.isArray(insertSavedMindmap.data?.children) && insertSavedMindmap.data.children.length === 1, "刷新后导图子节点数量不正确", ); assert( insertSavedMindmap.data.children[0]?.data?.text === childText, `刷新后导图子节点名称不正确:${insertSavedMindmap.data.children[0]?.data?.text}`, ); const deleteSaveResponsePromise = page.waitForResponse( (response) => response.url().includes(`/api/mindmap/${target.documentId}/${mindmapId}`) && response.request().method() === "POST" && response.status() === 200 && !(response.request().postData() || "").includes(childText), { timeout: UI_TIMEOUT_MS }, ); const deleteMutation = await persistDeleteChild(page, mindmapId); assert(deleteMutation.childCount === 0, `删除子节点后数量异常:${deleteMutation.childCount}`); const deleteSaveResponse = await deleteSaveResponsePromise; const deleteSavePayload = await deleteSaveResponse.json(); assertRouteMeta(deleteSavePayload.meta, { documentId: target.documentId, mindmapId, workspaceId: target.workspaceId, }); await waitForMetaAttrs(page, deleteSavePayload.meta); await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await fullscreen.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await canvas.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await waitForMindmapInstance(page, mindmapId); await openOutlinePanel(page); const bodyText = await page.locator("body").innerText({ timeout: UI_TIMEOUT_MS }); assert(bodyText.includes("中心主题"), "刷新后未渲染根节点"); assert(!bodyText.includes(childText), "删除子节点后页面仍残留旧节点文本"); const deleteSavedMindmap = await requestJson(`/api/mindmap/${target.documentId}/${mindmapId}`); assert( Array.isArray(deleteSavedMindmap.data?.children) && deleteSavedMindmap.data.children.length === 0, "删除子节点后后端仍保留子节点", ); return { mindmapUrl, mindmapId, childText, initialMetaAttrs, insertMeta: insertSavePayload.meta, deleteMeta: deleteSavePayload.meta, }; } finally { await page.close(); await browser.close(); await cleanupTempMindmap(target.documentId, mindmapId); } } async function main() { const health = await fetch(`${BASE_URL}/`, { method: "HEAD", redirect: "manual", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); assert( [200, 307, 308].includes(health.status), `首页探活失败:收到状态码 ${health.status}`, ); const tempDocument = await createTempDocument(); let regressionResult = null; try { regressionResult = await runBrowserRegression(tempDocument); console.log( JSON.stringify( { ok: true, workspaceId: tempDocument.workspaceId, documentId: tempDocument.documentId, ...regressionResult, }, null, 2, ), ); } finally { await purgeTempDocument(tempDocument.documentId); } } main().catch((error) => { console.error(error instanceof Error ? error.stack || error.message : String(error)); process.exit(1); });