Files
mnote/scripts/task119-rust-web-wolai-visual-regression-smoke.js
T

147 lines
7.5 KiB
JavaScript
Raw Normal View History

2026-04-29 12:24:44 +08:00
#!/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");
2026-04-29 16:23:49 +08:00
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();
2026-04-29 12:24:44 +08:00
const content = page.locator(".mnote-content").first();
2026-04-29 14:36:24 +08:00
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;
2026-05-21 23:53:39 +08:00
assert(!hasEmptyState, "空工作区不应再渲染可见占位空态");
2026-04-29 12:24:44 +08:00
const sidebarBox = await boundingBox(sidebar, "左侧栏");
const topbarBox = await boundingBox(topbar, "顶栏");
2026-04-29 16:23:49 +08:00
await boundingBox(sidebarTreeTabs, "页面树/文件树切换栏");
2026-04-29 12:24:44 +08:00
const contentBox = await boundingBox(content, "主内容区");
2026-04-29 14:36:24 +08:00
const primaryContentBox = hasDocumentShell
? await boundingBox(documentShell, "active page shell")
2026-05-21 23:53:39 +08:00
: contentBox;
2026-04-29 12:24:44 +08:00
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, "帮助浮动按钮不应落入左侧栏");
2026-04-29 14:36:24 +08:00
assert(!boxesIntersect(floatingAiBox, primaryContentBox), "AI 浮动按钮不应遮挡主内容");
assert(!boxesIntersect(floatingHelpBox, primaryContentBox), "帮助浮动按钮不应遮挡主内容");
2026-04-30 18:36:50 +08:00
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");
2026-04-29 16:23:49 +08:00
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 });
2026-04-29 12:24:44 +08:00
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);
});
}