#!/usr/bin/env node "use strict"; const assert = require("node:assert"); const fs = require("node:fs"); const path = require("node:path"); const { chromium } = require("playwright"); const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000); const OUTPUT_DIR = path.resolve(__dirname, "..", "test-results"); const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "task119-rust-web-wolai-visual-regression-root.png"); function assertBox(box, label) { assert(box, `${label} 缺少可见布局盒`); assert(box.width > 0 && box.height > 0, `${label} 尺寸异常: ${JSON.stringify(box)}`); } async function boundingBox(locator, label) { await locator.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); const box = await locator.boundingBox(); assertBox(box, label); return box; } function boxesIntersect(a, b) { return !( a.x + a.width <= b.x || b.x + b.width <= a.x || a.y + a.height <= b.y || b.y + b.height <= a.y ); } async function main() { fs.mkdirSync(OUTPUT_DIR, { recursive: true }); const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 1440, height: 960 } }); try { const response = await page.goto(`${BASE_URL}/`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS, }); assert(response, "根页没有返回响应"); assert.equal(response.status(), 200, `根页状态码异常: ${response.status()}`); assert.equal(response.headers()["x-mnote-web-owner"], "mnote-web", "根页必须由 mnote-web 拥有"); await page.waitForSelector('[data-mnote-shell="workspace"]', { timeout: UI_TIMEOUT_MS }); const bodyText = await page.locator("body").innerText({ timeout: UI_TIMEOUT_MS }); assert(!bodyText.includes("欢迎使用 MNOTE 知识管理平台"), "根页仍停留在极简欢迎页"); const sidebar = page.getByTestId("wolai-sidebar"); const topbar = page.getByTestId("wolai-topbar"); const floatingAi = page.getByTestId("wolai-floating-ai"); const floatingHelp = page.getByTestId("mnote-floating-task-toggle"); const sidebarTreeTabs = page.getByTestId("wolai-sidebar-tree-tabs"); const pageTreePanel = page.locator('[data-mnote-sidebar-tree-panel="page"]').first(); const fileTreePanel = page.locator('[data-mnote-sidebar-tree-panel="filetree"]').first(); const pageTreeTab = page.locator('[data-mnote-sidebar-tree-tab="page"]').first(); const fileTreeTab = page.locator('[data-mnote-sidebar-tree-tab="filetree"]').first(); const content = page.locator(".mnote-content").first(); const staticHomeLinks = page.locator(".mnote-home-links"); const documentShell = page.locator(".document-shell").first(); const emptyState = page.locator('[data-testid="mnote-workspace-empty-state"]').first(); assert.equal(await staticHomeLinks.count(), 0, "根页仍使用 .mnote-home-links 静态设计列表作为主内容"); const hasDocumentShell = (await documentShell.count()) > 0; const hasEmptyState = (await emptyState.count()) > 0; assert(!hasEmptyState, "空工作区不应再渲染可见占位空态"); const sidebarBox = await boundingBox(sidebar, "左侧栏"); const topbarBox = await boundingBox(topbar, "顶栏"); await boundingBox(sidebarTreeTabs, "页面树/文件树切换栏"); const contentBox = await boundingBox(content, "主内容区"); const primaryContentBox = hasDocumentShell ? await boundingBox(documentShell, "active page shell") : contentBox; const floatingAiBox = await boundingBox(floatingAi, "AI 浮动按钮"); const floatingHelpBox = await boundingBox(floatingHelp, "帮助浮动按钮"); assert(sidebarBox.width >= 240 && sidebarBox.width <= 340, `左栏宽度应稳定在 Wolai 范围内: ${sidebarBox.width}`); assert(sidebarBox.x >= -1 && sidebarBox.x <= 1, `左栏应贴齐视口左侧: ${sidebarBox.x}`); assert(topbarBox.x >= sidebarBox.width - 2, "顶栏不应覆盖左侧栏"); assert(contentBox.x >= sidebarBox.width - 2, "主内容区不应被侧栏覆盖"); assert(topbarBox.y >= -1 && topbarBox.height >= 36 && topbarBox.height <= 72, `顶栏高度异常: ${topbarBox.height}`); assert(contentBox.y >= topbarBox.y + topbarBox.height - 2, "主内容区不应被顶栏遮挡"); assert(floatingAiBox.x > sidebarBox.width, "AI 浮动按钮不应落入左侧栏"); assert(floatingHelpBox.x > sidebarBox.width, "帮助浮动按钮不应落入左侧栏"); assert(!boxesIntersect(floatingAiBox, primaryContentBox), "AI 浮动按钮不应遮挡主内容"); assert(!boxesIntersect(floatingHelpBox, primaryContentBox), "帮助浮动按钮不应遮挡主内容"); assert(floatingAiBox.y < floatingHelpBox.y, "AI 按钮应位于帮助按钮上方,匹配当前 Wolai 基线"); assert(Math.abs((floatingHelpBox.y - (floatingAiBox.y + floatingAiBox.height)) - 16) <= 2, "AI/帮助按钮间距应接近 Wolai 16px"); assert(Math.abs((960 - (floatingHelpBox.y + floatingHelpBox.height)) - 44) <= 4, "帮助按钮底部留白应接近 Wolai 44px"); assert.equal(await page.locator('[data-mnote-sidebar-tree-tab="filetree"]').count(), 1, "Explorer 只能作为一个切换 tab 出现"); assert.equal(await page.locator('[data-testid="wolai-sidebar-file-tree-section"]').count(), 0, "文件树不应再渲染成第二个独立 Explorer section"); assert.equal(await pageTreeTab.getAttribute("aria-selected"), "true", "默认应选中我的页面 tab"); assert.equal(await fileTreeTab.getAttribute("aria-selected"), "false", "默认不应选中 Explorer tab"); assert.equal(await pageTreePanel.evaluate((node) => node.hidden), false, "默认应显示页面树 panel"); assert.equal(await fileTreePanel.evaluate((node) => node.hidden), true, "默认应隐藏文件树 panel"); await fileTreeTab.click({ timeout: UI_TIMEOUT_MS }); await page.waitForFunction(() => { const filePanel = document.querySelector('[data-mnote-sidebar-tree-panel="filetree"]'); const pagePanel = document.querySelector('[data-mnote-sidebar-tree-panel="page"]'); return filePanel instanceof HTMLElement && pagePanel instanceof HTMLElement && !filePanel.hidden && pagePanel.hidden; }, null, { timeout: UI_TIMEOUT_MS }); await boundingBox(fileTreePanel, "Explorer 文件树 panel"); assert.equal(await fileTreeTab.getAttribute("aria-selected"), "true", "点击后应选中 Explorer tab"); await pageTreeTab.click({ timeout: UI_TIMEOUT_MS }); await page.waitForFunction(() => { const filePanel = document.querySelector('[data-mnote-sidebar-tree-panel="filetree"]'); const pagePanel = document.querySelector('[data-mnote-sidebar-tree-panel="page"]'); return filePanel instanceof HTMLElement && pagePanel instanceof HTMLElement && filePanel.hidden && !pagePanel.hidden; }, null, { timeout: UI_TIMEOUT_MS }); await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true }); console.log( JSON.stringify( { ok: true, baseUrl: BASE_URL, screenshot: SCREENSHOT_PATH, sidebarWidth: Math.round(sidebarBox.width), topbarHeight: Math.round(topbarBox.height), }, null, 2, ), ); } finally { await page.close().catch(() => undefined); await browser.close().catch(() => undefined); } } if (require.main === module) { main().catch((error) => { console.error(error instanceof Error ? error.stack || error.message : String(error)); process.exit(1); }); }