import fs from "node:fs"; import path from "node:path"; import assert from "node:assert/strict"; import { createRequire } from "node:module"; const require = createRequire(new URL("../frontend/package.json", import.meta.url)); const { chromium } = require("playwright"); const baseUrl = process.env.PROTOTYPE_URL || "http://127.0.0.1:4177"; const output = path.resolve(process.env.PROTOTYPE_AUDIT_DIR || ".artifacts/round-2"); const routes = ["home", "search", "library", "collections", "uploads", "tags", "people", "profile", "settings", "player/v1"]; const cases = [ [390, 844, "mobile-390"], [1440, 900, "desktop-1440"] ]; fs.mkdirSync(output, { recursive: true }); const browser = await chromium.launch({ headless: true }); const report = []; const interactions = []; async function check(name, action) { try { await action(); interactions.push({ name, status: "passed" }); } catch (error) { interactions.push({ name, status: "failed", error: error.message }); } } for (const [width, height, name] of cases) { const context = await browser.newContext({ viewport: { width, height }, locale: "zh-CN", colorScheme: "light" }); const page = await context.newPage(); const errors = []; page.on("pageerror", error => errors.push(`pageerror: ${error.message}`)); page.on("console", message => message.type() === "error" && errors.push(`console: ${message.text()}`)); const audits = []; for (const route of routes) { await page.goto(`${baseUrl}/#/${route}`, { waitUntil: "networkidle" }); await page.waitForTimeout(250); const geometry = await page.evaluate(() => { const buttons = [...document.querySelectorAll("button")].filter(button => { const style = getComputedStyle(button); const box = button.getBoundingClientRect(); return style.display !== "none" && box.width > 0 && box.height > 0; }); return { documentWidth: document.documentElement.scrollWidth, viewportWidth: document.documentElement.clientWidth, bodyWidth: document.body.scrollWidth, horizontalOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth, visibleButtons: buttons.length, buttonsBelow44: buttons.filter(button => { const box = button.getBoundingClientRect(); return box.width < 44 || box.height < 44; }).length }; }); audits.push({ route, ...geometry }); assert.equal(geometry.horizontalOverflow, false, `${name} ${route} 存在横向溢出`); await page.screenshot({ path: path.join(output, `${name}-${route.replace("/", "-")}.png`), fullPage: true }); } await page.goto(`${baseUrl}/#/home`, { waitUntil: "networkidle" }); if (width <= 720) { await check(`${name} 更多面板支持打开、Escape 关闭与页面导航`, async () => { await page.locator(".bottom-nav button").last().click(); await page.locator(".more-sheet").waitFor({ state: "visible" }); await page.screenshot({ path: path.join(output, `${name}-more.png`), fullPage: true }); await page.keyboard.press("Escape"); await page.locator(".more-sheet").waitFor({ state: "detached" }); await page.locator(".bottom-nav button").last().click(); await page.locator(".more-sheet").getByRole("button", { name: "资料库" }).click(); await page.waitForURL(/#\/library$/); await page.goto(`${baseUrl}/#/home`, { waitUntil: "networkidle" }); }); await page.locator(".upload-fab").click(); } else { await page.getByRole("button", { name: "上传中心" }).first().click(); await page.getByRole("button", { name: "继续上传" }).click(); } await page.locator(".upload-sheet").waitFor({ state: "visible" }); await page.screenshot({ path: path.join(output, `${name}-upload-sheet.png`), fullPage: true }); await check(`${name} 上传弹窗可进入上传状态并转入后台`, async () => { await page.getByRole("button", { name: "选择演示文件" }).click(); await page.locator(".selected-upload").waitFor({ state: "visible" }); await page.getByRole("button", { name: "关闭并在后台继续" }).click(); await page.locator(".upload-sheet").waitFor({ state: "detached" }); await page.locator(".toast").waitFor({ state: "visible" }); }); await check(`${name} 搜索结果跳转到播放器`, async () => { await page.goto(`${baseUrl}/#/search`, { waitUntil: "networkidle" }); await page.locator(".result-row").first().click(); await page.waitForURL(/#\/player\//); await page.locator(".player-stage").waitFor({ state: "visible" }); }); await page.goto(`${baseUrl}/#/player/v1`, { waitUntil: "networkidle" }); await check(`${name} 播放、倍速和时间点控件`, async () => { await page.locator(".main-play").click(); await page.locator(".main-play .lucide-pause").waitFor({ state: "visible" }); await page.getByRole("button", { name: "1×", exact: true }).click(); await page.getByRole("button", { name: "1.5×", exact: true }).waitFor({ state: "visible" }); await page.getByRole("button", { name: "时间点", exact: true }).click(); await page.locator(".player-drawer").waitFor({ state: "visible" }); await page.locator(".timeline>button").first().click({ force: true }); assert.equal(await page.locator('.timeline input[type="range"]').inputValue(), "18"); }); await page.screenshot({ path: path.join(output, `${name}-player-markers.png`), fullPage: true }); await page.goto(`${baseUrl}/#/settings`, { waitUntil: "networkidle" }); await page.evaluate(() => { document.documentElement.dataset.theme = "dark"; }); await page.screenshot({ path: path.join(output, `${name}-settings-dark.png`), fullPage: true }); await check(`${name} 原型控制器切换主题与加载/空/错误状态`, async () => { await page.goto(`${baseUrl}/?prototype=1#/settings`, { waitUntil: "networkidle" }); await page.getByRole("button", { name: "原型" }).click(); await page.getByLabel("主题").selectOption("dark"); assert.equal(await page.locator("html").getAttribute("data-theme"), "dark"); await page.getByLabel("数据状态").selectOption("loading"); await page.locator(".skeleton-grid").waitFor({ state: "visible" }); await page.getByLabel("数据状态").selectOption("empty"); await page.locator(".empty-state").waitFor({ state: "visible" }); await page.getByLabel("数据状态").selectOption("error"); await page.locator(".error-state").waitFor({ state: "visible" }); }); report.push({ name, viewport: { width, height }, audits, errors: [...new Set(errors)] }); await context.close(); } await browser.close(); const result = { report, interactions }; fs.writeFileSync(path.join(output, "report.json"), JSON.stringify(result, null, 2)); console.log(JSON.stringify(result, null, 2)); const failures = interactions.filter(item => item.status === "failed"); if (failures.length) process.exitCode = 1;