"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; const TEST_LOGIN_BUTTON_NAME = "测试账号快速登录"; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); function assert(condition, message) { if (!condition) { throw new Error(message); } } async function requestJson(requestContext, path, init = {}) { const response = await requestContext.fetch(`${BASE_URL}${path}`, { ...init, headers: init.data !== undefined ? { "content-type": "application/json", ...(init.headers || {}), } : { ...(init.headers || {}), }, timeout: REQUEST_TIMEOUT_MS, }); 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(requestContext) { const payload = await requestJson(requestContext, "/api/documents/create", { method: "POST", data: { 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(requestContext, documentId, mindmapId) { await requestJson(requestContext, `/api/mindmap/${documentId}/${mindmapId}`, { method: "POST", data: { createOnly: true, data: { data: { text: "中心主题" }, children: [], }, }, }); } async function cleanupTempMindmap(requestContext, documentId, mindmapId) { try { await requestJson(requestContext, `/api/mindmap/${documentId}/${mindmapId}`, { method: "DELETE", }); } catch { // 忽略清理失败,继续尝试 purge 文档。 } } async function purgeTempDocument(requestContext, documentId) { await requestJson(requestContext, "/api/documents/purge", { method: "POST", data: { documentId }, }); } async function getViewerIdentity(requestContext) { const payload = await requestJson(requestContext, "/api/auth/whoami", { method: "GET" }); assert(payload && typeof payload.userId === "string" && payload.userId, "获取当前用户失败:缺少 userId"); return payload; } async function ensureAuthenticated(page, requestContext) { await page.goto(`${BASE_URL}/auth`, { waitUntil: "networkidle", timeout: UI_TIMEOUT_MS }); if (page.url().includes("/auth")) { const quickLoginButton = page.getByRole("button", { name: TEST_LOGIN_BUTTON_NAME }); await quickLoginButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await quickLoginButton.click({ timeout: UI_TIMEOUT_MS }); await page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: UI_TIMEOUT_MS, }); } return await getViewerIdentity(requestContext); } async function waitForMindmapInstance(page, mindmapId) { await page.waitForFunction( (id) => Boolean(window.__mindmapInstancesById?.[id] || window.__mindmapInstance), mindmapId, { timeout: UI_TIMEOUT_MS }, ); } async function waitForMindmapReady(page, mindmapId) { await page.waitForFunction( (id) => { const instance = window.__mindmapInstancesById?.[id] || window.__mindmapInstance; const persist = window.__mindmapPersistById?.[id]; const fullscreen = document.querySelector("[data-testid=\"mindmap-fullscreen\"]"); const requestId = fullscreen?.getAttribute("data-request-id"); const traceId = fullscreen?.getAttribute("data-trace-id"); return Boolean(instance && persist && requestId && traceId); }, 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 }, ); } async function waitForMetaMutation(page, previousMeta) { await page.waitForFunction( ({ requestId, traceId }) => { const el = document.querySelector("[data-testid=\"mindmap-fullscreen\"]"); if (!el) return false; const nextRequestId = el.getAttribute("data-request-id"); const nextTraceId = el.getAttribute("data-trace-id"); return Boolean( nextRequestId && nextTraceId && nextRequestId !== requestId && nextTraceId !== traceId, ); }, { requestId: previousMeta.requestId, traceId: previousMeta.traceId, }, { timeout: UI_TIMEOUT_MS }, ); return await readMindmapMetaAttrs(page); } async function waitForMindmapState(requestContext, documentId, mindmapId, check, description) { const deadline = Date.now() + UI_TIMEOUT_MS; let lastPayload = null; while (Date.now() < deadline) { lastPayload = await requestJson(requestContext, `/api/mindmap/${documentId}/${mindmapId}`); if (check(lastPayload)) { return lastPayload; } await sleep(500); } throw new Error(`${description} 超时:${JSON.stringify(lastPayload)}`); } 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) { return page.evaluate( ({ currentMindmapId }) => { 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("插入子节点后未拿到快照"); } const persist = window.__mindmapPersistById?.[currentMindmapId]; if (!persist) { throw new Error("未找到 mindmap 持久化回调"); } persist(snapshot); return { childText: String(snapshot.root.children[0].data.text ?? ""), childCount: snapshot.root.children.length, }; }, { currentMindmapId: mindmapId, }, ); } async function persistDeleteChild(page, mindmapId, childUid) { return page.evaluate(async ({ currentMindmapId, currentChildUid }) => { const instance = window.__mindmapInstancesById?.[currentMindmapId] ?? window.__mindmapInstance; if (!instance) { throw new Error("未找到 mindmap 实例"); } const renderer = instance.renderer; const child = typeof renderer?.findNodeByUid === "function" ? renderer.findNodeByUid(currentChildUid) : null; if (!child) { throw new Error("删除子节点时未找到目标节点"); } renderer?.clearActiveNodeList?.(); renderer?.addNodeToActiveList?.(child, true); renderer.lastActiveNodeList = [child]; renderer?.emitNodeActiveEvent?.(child); instance.execCommand?.("SET_NODE_ACTIVE", child, true); await new Promise((resolve) => window.setTimeout(resolve, 0)); instance.execCommand?.("REMOVE_NODE"); await new Promise((resolve) => window.setTimeout(resolve, 0)); const snapshot = instance.getData?.(true) ?? instance.getData?.() ?? null; const root = snapshot?.root ?? snapshot; return { childCount: root && typeof root === "object" && Array.isArray(root.children) ? root.children.length : -1, }; }, { currentMindmapId: mindmapId, currentChildUid: childUid }); } 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(page, requestContext, target) { const uniqueSuffix = Date.now().toString(); const mindmapId = `task021-${uniqueSuffix}`; const defaultChildText = "二级节点"; try { await createTempMindmap(requestContext, 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); await waitForMindmapReady(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 insertMutation = await persistInsertAndRename(page, mindmapId); assert(insertMutation.childCount === 1, `插入子节点后数量异常:${insertMutation.childCount}`); assert(insertMutation.childText, "插入子节点后名称为空"); const insertSaveMeta = await waitForMetaMutation(page, initialMetaAttrs); assertRouteMeta(insertSaveMeta, { documentId: target.documentId, mindmapId, workspaceId: target.workspaceId, }); await waitForMindmapState( requestContext, target.documentId, mindmapId, (payload) => Array.isArray(payload?.data?.children) && payload.data.children.length === 1, "插入子节点后后端回查", ); 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 waitForMindmapReady(page, mindmapId); const beforeDeleteMeta = await readMindmapMetaAttrs(page); const insertSavedMindmap = await requestJson(requestContext, `/api/mindmap/${target.documentId}/${mindmapId}`); assert( Array.isArray(insertSavedMindmap.data?.children) && insertSavedMindmap.data.children.length === 1, "刷新后导图子节点数量不正确", ); assert( typeof insertSavedMindmap.data.children[0]?.data?.text === "string" && insertSavedMindmap.data.children[0].data.text.trim(), "刷新后导图子节点名称为空", ); const persistedChildText = String(insertSavedMindmap.data.children[0]?.data?.text ?? "") .replace(/<[^>]+>/g, "") .trim() || defaultChildText; const persistedChildUid = String(insertSavedMindmap.data.children[0]?.data?.uid ?? ""); assert(persistedChildUid, "刷新后导图子节点缺少 uid"); await openOutlinePanel(page); await page.getByText(persistedChildText).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS, }); const deleteMutation = await persistDeleteChild(page, mindmapId, persistedChildUid); assert(deleteMutation.childCount === 0, `删除子节点后数量异常:${deleteMutation.childCount}`); const deleteSaveMeta = await waitForMetaMutation(page, beforeDeleteMeta); assertRouteMeta(deleteSaveMeta, { documentId: target.documentId, mindmapId, workspaceId: target.workspaceId, }); await waitForMindmapState( requestContext, target.documentId, mindmapId, (payload) => Array.isArray(payload?.data?.children) && payload.data.children.length === 0, "删除子节点后后端回查", ); 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 waitForMindmapReady(page, mindmapId); await openOutlinePanel(page); await page.getByText("中心主题").first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); const deleteSavedMindmap = await requestJson(requestContext, `/api/mindmap/${target.documentId}/${mindmapId}`); assert( Array.isArray(deleteSavedMindmap.data?.children) && deleteSavedMindmap.data.children.length === 0, "删除子节点后后端仍保留子节点", ); const canvasText = await canvas.innerText({ timeout: UI_TIMEOUT_MS }); assert(!canvasText.includes(persistedChildText), "删除子节点后画布仍残留旧节点文本"); return { mindmapUrl, mindmapId, childText: persistedChildText, initialMetaAttrs, insertMeta: insertSaveMeta, deleteMeta: deleteSaveMeta, }; } finally { await cleanupTempMindmap(requestContext, 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 browser = await chromium.launch({ headless: true }); const context = await browser.newContext({ viewport: { width: 1440, height: 960 }, }); const page = await context.newPage(); let tempDocument = null; let regressionResult = null; let caughtError = null; try { await ensureAuthenticated(page, context.request); tempDocument = await createTempDocument(context.request); regressionResult = await runBrowserRegression(page, context.request, tempDocument); console.log( JSON.stringify( { ok: true, workspaceId: tempDocument.workspaceId, documentId: tempDocument.documentId, ...regressionResult, }, null, 2, ), ); } catch (error) { caughtError = error; } finally { if (tempDocument?.documentId) { try { await purgeTempDocument(context.request, tempDocument.documentId); } catch (cleanupError) { if (!caughtError) { caughtError = cleanupError; } else { console.error( `清理临时页面失败:${cleanupError instanceof Error ? cleanupError.stack || cleanupError.message : String(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); });