"use strict"; // 说明: // - 这是 Sidebar / PageTree Rust Web tree shell 的真实网页 smoke 脚本。 // - 目标覆盖:主页面已挂载 iframe tree shell、展开/折叠、在 shell 内创建子页面、重命名、移动、导航。 // - 脚本会创建临时页面并在结束后清理,避免污染现有数据。 const { chromium } = require("playwright"); const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000"; const MNOTE_WEB_BASE_URL = process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3104"; 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 内容:${snippet}`); } return payload; } async function createTempDocument(requestContext, parentId = null) { const payload = await requestJson(requestContext, "/api/documents/create", { method: "POST", data: { parentId }, }); 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 renameDocument(requestContext, workspaceId, documentId, title) { await requestJson(requestContext, "/api/documents/title", { method: "POST", data: { workspaceId, documentId, title, }, }); } async function purgeDocument(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 runTreeShellRegression(page, requestContext, viewer, target) { const uniqueSuffix = Date.now().toString(); const rootTitle = `task091-root-${uniqueSuffix}`; const childTitle = `task091-child-${uniqueSuffix}`; const createdTitle = `task091-created-${uniqueSuffix}`; const renamedTitle = `task091-renamed-${uniqueSuffix}`; await renameDocument(requestContext, target.workspaceId, target.parentId, rootTitle); await renameDocument(requestContext, target.workspaceId, target.childId, childTitle); const documentUrl = `${BASE_URL}/documents/${target.parentId}?workspaceId=${encodeURIComponent(target.workspaceId)}`; await page.goto(documentUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); const groupButton = page.getByRole("button", { name: "分组" }); await groupButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await groupButton.click({ timeout: UI_TIMEOUT_MS }); const sidebarPanel = page.getByText("页面树"); await sidebarPanel.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); const iframe = page.locator('iframe[title="mnote-web tree shell"]'); await iframe.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); const iframeSrc = await iframe.getAttribute("src"); assert(iframeSrc && iframeSrc.includes(`${MNOTE_WEB_BASE_URL}/tree`), `Sidebar 未挂载 mnote-web tree shell:${iframeSrc}`); assert(iframeSrc.includes(`actorId=${encodeURIComponent(viewer.userId)}`), `tree shell 未透传当前用户 actorId:${iframeSrc}`); const getTreeFrame = () => page.frameLocator('iframe[title="mnote-web tree shell"]'); const waitForTreeReady = async () => { const frame = getTreeFrame(); await frame.locator('[data-testid="tree-create-root"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS, }); return frame; }; const getFirstChildId = async (parentId) => { const frame = await waitForTreeReady(); return await frame .locator(`.tree-node[data-node-id="${parentId}"] > .tree-children > .tree-node`) .first() .getAttribute("data-node-id"); }; const waitForFirstChildId = async (parentId, expectedId) => { const deadline = Date.now() + UI_TIMEOUT_MS; while (Date.now() < deadline) { const actualId = await getFirstChildId(parentId); if (actualId === expectedId) { return actualId; } await page.waitForTimeout(250); } return await getFirstChildId(parentId); }; let treeFrame = await waitForTreeReady(); const parentRow = treeFrame.locator(`.tree-row[data-node-id="${target.parentId}"]`); const childRow = treeFrame.locator(`.tree-row[data-node-id="${target.childId}"]`); await parentRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await childRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); const toggleButton = parentRow.locator('[data-testid="tree-node-toggle"]'); await toggleButton.click({ timeout: UI_TIMEOUT_MS }); await childRow.waitFor({ state: "hidden", timeout: UI_TIMEOUT_MS }); await toggleButton.click({ timeout: UI_TIMEOUT_MS }); await childRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); const createResponsePromise = page.waitForResponse( (response) => response.url().includes(`${MNOTE_WEB_BASE_URL}/api/tree/commands`) && response.request().method() === "POST" && response.status() === 200 && (response.request().postData() || "").includes('"action":"create"'), { timeout: UI_TIMEOUT_MS }, ); await parentRow.locator('[data-testid="tree-action-create"]').click({ timeout: UI_TIMEOUT_MS }); const createResponse = await createResponsePromise; const createPayload = await createResponse.json(); const createdDocumentId = createPayload?.result?.documentId; assert(typeof createdDocumentId === "string" && createdDocumentId, "tree shell 创建子页面失败:缺少 documentId"); await page.waitForURL((url) => url.toString().includes(`/documents/${createdDocumentId}`), { timeout: UI_TIMEOUT_MS, }); target.createdIds.push(createdDocumentId); treeFrame = await waitForTreeReady(); const createdRow = treeFrame.locator(`.tree-row[data-node-id="${createdDocumentId}"]`); await createdRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); page.once("dialog", (dialog) => dialog.accept(renamedTitle)); const renameResponsePromise = page.waitForResponse( (response) => response.url().includes(`${MNOTE_WEB_BASE_URL}/api/tree/commands`) && response.request().method() === "POST" && response.status() === 200 && (response.request().postData() || "").includes('"action":"rename"'), { timeout: UI_TIMEOUT_MS }, ); await createdRow.locator('[data-testid="tree-action-rename"]').click({ timeout: UI_TIMEOUT_MS }); await renameResponsePromise; treeFrame = await waitForTreeReady(); const renamedRow = treeFrame.locator(`.tree-row[data-node-id="${createdDocumentId}"]`); await renamedRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); const childOrderBeforeMove = await getFirstChildId(target.parentId); assert( childOrderBeforeMove === target.childId, `移动前的首个子节点异常:期望 ${target.childId},实际 ${childOrderBeforeMove}`, ); const moveResponsePromise = page.waitForResponse( (response) => response.url().includes(`${MNOTE_WEB_BASE_URL}/api/tree/commands`) && response.request().method() === "POST" && response.status() === 200 && (response.request().postData() || "").includes('"action":"move"'), { timeout: UI_TIMEOUT_MS }, ); await renamedRow.locator('[data-testid="tree-action-move-up"]').click({ timeout: UI_TIMEOUT_MS }); await moveResponsePromise; const firstChildAfterMove = await waitForFirstChildId(target.parentId, createdDocumentId); assert( firstChildAfterMove === createdDocumentId, `移动后排序未生效:期望首个子节点为 ${createdDocumentId},实际 ${firstChildAfterMove}`, ); treeFrame = await waitForTreeReady(); const latestChildRow = treeFrame.locator(`.tree-row[data-node-id="${target.childId}"]`); await latestChildRow.locator('[data-testid="tree-node-open"]').click({ timeout: UI_TIMEOUT_MS }); await page.waitForURL((url) => url.toString().includes(`/documents/${target.childId}`), { timeout: UI_TIMEOUT_MS, }); return { documentUrl, rootTitle, childTitle, createdTitle, renamedTitle, createdDocumentId, }; } async function runPickerRegression(page, target) { const documentUrl = `${BASE_URL}/documents/${target.parentId}?workspaceId=${encodeURIComponent(target.workspaceId)}`; await page.goto(documentUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); const inspectorToggle = page.getByRole("button", { name: /显示页面选项|隐藏页面选项/ }); await inspectorToggle.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); const currentLabel = (await inspectorToggle.getAttribute("aria-label")) || ""; if (currentLabel.includes("显示")) { await inspectorToggle.click({ timeout: UI_TIMEOUT_MS }); } const moveEmbedButton = page.getByRole("button", { name: "移动/嵌入到..." }); await moveEmbedButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await moveEmbedButton.click({ timeout: UI_TIMEOUT_MS }); const dialog = page.getByRole("dialog"); await dialog.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); const pickerIframe = dialog.locator('iframe[title="mnote-web tree shell"]'); await pickerIframe.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); const iframeSrc = await pickerIframe.getAttribute("src"); assert(iframeSrc && iframeSrc.includes("mode=picker"), `picker 未以 tree shell 轻量模式挂载:${iframeSrc}`); assert(iframeSrc.includes("allowRootPick=1"), `picker 未透传 allowRootPick:${iframeSrc}`); assert( iframeSrc.includes(`excludeIds=${encodeURIComponent(target.parentId)}`), `picker 未透传 excludeIds:${iframeSrc}`, ); const frame = dialog.frameLocator('iframe[title="mnote-web tree shell"]'); const rootPick = frame.locator('[data-testid="tree-picker-root"]'); await rootPick.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await rootPick.click({ timeout: UI_TIMEOUT_MS }); await dialog.waitFor({ state: "hidden", timeout: UI_TIMEOUT_MS }); } async function main() { const browser = await chromium.launch({ headless: true }); const context = await browser.newContext({ viewport: { width: 1440, height: 960 }, }); const page = await context.newPage(); let caughtError = null; let result = null; const createdIds = []; try { const viewer = await ensureAuthenticated(page, context.request); const parent = await createTempDocument(context.request, null); createdIds.push(parent.documentId); const child = await createTempDocument(context.request, parent.documentId); createdIds.push(child.documentId); result = await runTreeShellRegression(page, context.request, viewer, { workspaceId: parent.workspaceId, parentId: parent.documentId, childId: child.documentId, createdIds, }); await runPickerRegression(page, { workspaceId: parent.workspaceId, parentId: parent.documentId, }); console.log( JSON.stringify( { ok: true, workspaceId: parent.workspaceId, viewerUserId: viewer.userId, parentId: parent.documentId, childId: child.documentId, ...result, }, null, 2, ), ); } catch (error) { caughtError = error; } finally { for (const documentId of [...createdIds].reverse()) { try { await purgeDocument(context.request, 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); });