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");
|
2026-06-05 23:00:53 +08:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
}
|