"use strict"; // 说明: // - 这是 task-019 的最小真实浏览器回归脚本。 // - 目标只覆盖文档页元信息、Sidebar、BlockNote 保存链,不扩大到 Mindmap / OnlyOffice。 // - 脚本会先创建一篇临时页面,完成回归后再彻底删除,避免污染现有数据。 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 = "测试账号快速登录"; 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)}`, ); } const contentType = response.headers()["content-type"] || ""; if (!contentType.includes("application/json")) { const snippet = typeof payload === "string" ? payload.slice(0, 200) : JSON.stringify(payload).slice(0, 200); throw new Error( `${path} 返回了非 JSON 内容,当前回归脚本需要可直接调用的 API 会话。` + `如果页面被重定向到 /auth 或返回 HTML,说明前端未启用 MNOTE_DEV_AUTH=1,或当前节点没有带上有效的 Convex Auth 会话。` + `响应片段:${snippet}`, ); } 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 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 runBrowserRegression(page, target) { const uniqueSuffix = Date.now().toString(); const nextTitle = `task019-ui-${uniqueSuffix}`; const nextBody = `task019 正文保存回归 ${uniqueSuffix}`; const documentUrl = `${BASE_URL}/documents/${target.documentId}?workspaceId=${encodeURIComponent(target.workspaceId)}`; await page.goto(documentUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); const sidebarPanel = page.getByText("页面树"); const privateSection = page.getByText("私有 / 我的页面"); const titleInput = page.getByLabel("页面标题"); const editorSurface = page.locator(".wolai-editor [contenteditable=\"true\"]").first(); const saveIndicator = page.locator("text=已保存"); await sidebarPanel.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await privateSection.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await titleInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await editorSurface.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await saveIndicator.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await titleInput.fill(nextTitle); const titleSaveResponse = page.waitForResponse( (response) => response.url().includes("/api/documents/title") && response.request().method() === "POST" && response.status() === 200, { timeout: UI_TIMEOUT_MS }, ); await titleInput.evaluate((node) => { node.blur(); }); await titleSaveResponse; const saveResponse = page.waitForResponse( (response) => response.url().includes("/api/documents/save") && response.request().method() === "POST" && response.status() === 200 && (response.request().postData() || "").includes(nextBody), { timeout: UI_TIMEOUT_MS }, ); await editorSurface.click({ timeout: UI_TIMEOUT_MS }); await editorSurface.fill(nextBody, { timeout: UI_TIMEOUT_MS }); await saveResponse; await saveIndicator.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await sidebarPanel.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await privateSection.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await titleInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await page.locator(`text=${nextBody}`).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); const persistedTitle = await titleInput.inputValue(); assert(persistedTitle === nextTitle, `标题刷新后不一致:期望 ${nextTitle},实际 ${persistedTitle}`); const editorText = await page.locator(".wolai-editor").innerText({ timeout: UI_TIMEOUT_MS }); assert(editorText.includes(nextBody), "正文刷新后未保留刚写入的内容"); return { documentUrl, nextTitle, nextBody, }; } 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, 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); });