#!/usr/bin/env node import fs from "node:fs"; import path from "node:path"; import { createRequire } from "node:module"; const require = createRequire(new URL("../frontend/package.json", import.meta.url)); const { chromium } = require("playwright"); const baseUrl = (process.env.IMAGEFIND_LIVE_URL || "").replace(/\/$/, ""); const password = process.env.IMAGEFIND_LIVE_PASSWORD || ""; const tokenFile = process.env.IMAGEFIND_LIVE_TOKEN_FILE || ""; const apiToken = tokenFile ? fs.readFileSync(tokenFile, "utf8").trim() : ""; const outputDir = path.resolve(process.env.IMAGEFIND_LIVE_OUTPUT || "dist/live-ui-audit"); if (!baseUrl || (!password && !apiToken)) { throw new Error("IMAGEFIND_LIVE_URL and either IMAGEFIND_LIVE_PASSWORD or IMAGEFIND_LIVE_TOKEN_FILE are required"); } fs.mkdirSync(outputDir, { recursive: true }); const auditPages = ["search", "library", "series", "uploads", "tags", "actors", "settings"]; const mobileMenuNames = { library: "资料库", series: "合集", uploads: "上传中心", tags: "分类与标签", actors: "演员与人物", settings: "设置", }; const desktopSelectors = { library: ".nav-item-3", series: ".nav-item-4", uploads: ".sidebar nav > .upload-status", tags: ".nav-item-6", actors: ".nav-item-7", settings: ".nav-item-8", }; async function login(page, name) { await page.goto(baseUrl, { waitUntil: "domcontentloaded", timeout: 30_000 }); await page.waitForTimeout(1_000); const passwordInput = page.getByLabel("管理员密码"); if (await passwordInput.isVisible().catch(() => false)) { await passwordInput.fill(password); await page.getByRole("button", { name: "登录", exact: true }).click(); } try { await page.locator(".app-shell").waitFor({ state: "visible", timeout: 30_000 }); } catch (error) { await page.screenshot({ path: path.join(outputDir, `${name}-login-error.png`), fullPage: true }); const text = (await page.locator("body").innerText().catch(() => "")).slice(0, 2_000); throw new Error(`login did not reach the application: url=${page.url()} body=${JSON.stringify(text)}`, { cause: error, }); } await page.waitForTimeout(2_000); } async function resetHome(page) { await page.evaluate(() => { history.replaceState( { ...history.state, imagefindNavigation: { kind: "page", page: "home", scrollY: 0 } }, "", ); }); await page.reload({ waitUntil: "domcontentloaded", timeout: 30_000 }); await page.locator(".app-shell").waitFor({ timeout: 30_000 }); } function attachNetworkAudit(page, errors) { const issues = []; let allowUnauthenticatedProbe = true; page.on("response", response => { if (response.status() < 400) return; const request = response.request(); const url = new URL(response.url()); const expectedProbe = allowUnauthenticatedProbe && response.status() === 401 && request.method() === "GET" && url.pathname === "/api/v1/auth/me"; if (expectedProbe) return; const issue = { method: request.method(), status: response.status(), path: url.pathname, resourceType: request.resourceType(), }; issues.push(issue); errors.push(`response: ${issue.method} ${issue.status} ${issue.path} [${issue.resourceType}]`); }); page.on("requestfailed", request => { const url = new URL(request.url()); const issue = { method: request.method(), status: null, path: url.pathname, resourceType: request.resourceType(), failure: request.failure()?.errorText || "unknown network failure", }; issues.push(issue); errors.push(`requestfailed: ${issue.method} ${issue.path} [${issue.resourceType}] ${issue.failure}`); }); return { issues, markAuthenticated() { allowUnauthenticatedProbe = false; }, }; } async function inspect(browser, viewport, name) { const context = await browser.newContext({ viewport, locale: "zh-CN" }); if (apiToken) { const origin = new URL(baseUrl).origin; await context.route("**/*", async route => { const url = new URL(route.request().url()); if (url.origin !== origin || !url.pathname.includes("/api/")) { await route.continue(); return; } await route.continue({ headers: { ...route.request().headers(), authorization: `Bearer ${apiToken}` }, }); }); } const page = await context.newPage(); const errors = []; page.on("pageerror", error => errors.push(`pageerror: ${error.message}`)); page.on("console", message => { if (message.type() === "error") errors.push(`console: ${message.text()}`); }); const networkAudit = attachNetworkAudit(page, errors); await login(page, name); networkAudit.markAuthenticated(); const home = await page.evaluate(() => ({ scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth, scrollHeight: document.documentElement.scrollHeight, clientHeight: document.documentElement.clientHeight, })); const cards = page.locator("article.video-card"); const cardCount = await cards.count(); const coverErrors = await page.locator(".thumb-placeholder.error").count(); await page.screenshot({ path: path.join(outputDir, `${name}-home.png`), fullPage: true }); const pageAudits = []; for (const destination of auditPages) { await resetHome(page); if (destination === "search") { if (viewport.width <= 720) { await page.getByRole("button", { name: "搜索", exact: true }).first().click(); } else { await page.locator(".nav-item-2").click(); } } else if (viewport.width <= 720) { await page.locator(".mobile-more-button").click(); await page.locator(".mobile-more-menu") .getByRole("button", { name: new RegExp(mobileMenuNames[destination]) }) .first() .click(); } else { await page.locator(desktopSelectors[destination]).click(); } await page.locator("main .page").waitFor({ state: "visible", timeout: 20_000 }); await page.waitForTimeout(500); const geometry = await page.evaluate(() => { const content = document.querySelector(".content"); const pagination = document.querySelector(".transfer-pagination"); return { documentWidth: document.documentElement.scrollWidth, viewportWidth: document.documentElement.clientWidth, bodyWidth: document.body.scrollWidth, contentWidth: content?.scrollWidth || 0, contentClientWidth: content?.clientWidth || 0, contentHeight: content?.scrollHeight || 0, contentClientHeight: content?.clientHeight || 0, transferRows: document.querySelectorAll(".transfer-list > article").length, pagination: pagination?.textContent?.replace(/\s+/g, " ").trim() || null, paginationButtonHeights: pagination ? [...pagination.querySelectorAll("button")].map(button => Math.round(button.getBoundingClientRect().height)) : [], }; }); let paginationNavigation = null; if (destination === "uploads" && geometry.pagination) { const before = await page.locator(".transfer-list > article").first().innerText(); const next = page.locator(".transfer-pagination").getByRole("button", { name: "下一页" }); if (await next.isEnabled()) { await next.click(); await page.waitForFunction(() => document.querySelector(".transfer-pagination")?.textContent?.includes("第 2 /")); const after = await page.locator(".transfer-list > article").first().innerText(); paginationNavigation = { changed: before !== after, rows: await page.locator(".transfer-list > article").count(), text: (await page.locator(".transfer-pagination").innerText()).replace(/\s+/g, " ").trim(), }; } } pageAudits.push({ destination, ...geometry, paginationNavigation }); await page.screenshot({ path: path.join(outputDir, `${name}-${destination}.png`), // Hundreds of historical tasks can make this page >17,000px tall. The // UI geometry is sampled above; keep the screenshot bounded to avoid a // Chromium bitmap allocation crash on low-memory test hosts. fullPage: destination !== "uploads", }); } await resetHome(page); let scrollY = 0; let mobileMenu = null; if (viewport.width <= 720) { await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight)); await page.waitForTimeout(500); scrollY = await page.evaluate(() => window.scrollY); await page.evaluate(() => window.scrollTo(0, 0)); await page.locator(".mobile-more-button").click(); const sheet = page.locator(".mobile-more-menu"); await sheet.waitFor({ state: "visible" }); // Visibility is reported at the first frame of the slide-in transition. // Geometry sampled there includes the temporary translateY and used to be // misreported as a sheet extending below the viewport. await page.waitForTimeout(300); mobileMenu = await sheet.evaluate(element => { const box = element.getBoundingClientRect(); const buttons = [...element.querySelectorAll(".mobile-more-item")].map(button => { const rect = button.getBoundingClientRect(); const style = getComputedStyle(button); return { text: button.textContent?.trim(), contentCenterDelta: Math.round(Math.max(0, ...[...button.children].map(child => { const childRect = child.getBoundingClientRect(); return Math.abs(rect.left + rect.width / 2 - childRect.left - childRect.width / 2); }))), background: style.backgroundColor, }; }); return { top: Math.round(box.top), bottom: Math.round(box.bottom), viewportBottom: window.innerHeight, transform: getComputedStyle(element).transform, buttons, }; }); await page.screenshot({ path: path.join(outputDir, `${name}-more.png`), fullPage: true }); await page.getByRole("button", { name: "关闭", exact: true }).click(); await page.locator(".mobile-more-menu").waitFor({ state: "hidden" }); await page.locator(".mobile-profile-button").click(); await page.locator(".profile-page").waitFor({ state: "visible" }); await page.screenshot({ path: path.join(outputDir, `${name}-profile.png`), fullPage: true }); await page.getByRole("button", { name: "首页", exact: true }).click(); } let player = null; if (cardCount) { const preferred = cards.filter({ hasText: "e2e-positive" }); await ((await preferred.count()) ? preferred.first() : cards.first()).click(); await page.locator(".player-page").waitFor({ state: "visible", timeout: 20_000 }); await page.waitForTimeout(2_000); const status = await page.locator(".player-center-status").textContent().catch(() => null); const stage = page.locator(".custom-player"); if ((await stage.getAttribute("class"))?.includes("controls-hidden")) { await page.locator(".player-gesture-surface").click({ position: { x: 12, y: 12 } }); await page.waitForTimeout(350); } const markerButton = page.getByRole("button", { name: "视频时间点" }); const hitTest = await markerButton.evaluate(button => { const rect = button.getBoundingClientRect(); const x = rect.left + rect.width / 2; const y = rect.top + rect.height / 2; const controls = button.closest(".player-controls"); const controlsStyle = controls ? getComputedStyle(controls) : null; return { stageClass: button.closest(".custom-player")?.className, controls: controlsStyle ? { opacity: controlsStyle.opacity, pointerEvents: controlsStyle.pointerEvents, zIndex: controlsStyle.zIndex, } : null, stack: document.elementsFromPoint(x, y).slice(0, 6).map(element => ({ tag: element.tagName, className: element.className, pointerEvents: getComputedStyle(element).pointerEvents, zIndex: getComputedStyle(element).zIndex, })), }; }); await markerButton.click(); await page.locator(".player-marker-drawer").waitFor({ state: "visible" }); await page.screenshot({ path: path.join(outputDir, `${name}-player.png`), fullPage: true }); player = { status: status?.trim() || "ready", hitTest, markerDrawer: await page.locator(".player-marker-drawer").isVisible(), progressVisible: await page.getByLabel("播放进度").isVisible(), horizontalOverflow: await page.evaluate( () => document.documentElement.scrollWidth > document.documentElement.clientWidth, ), }; } await context.close(); return { name, viewport, home, cardCount, coverErrors, pageAudits, scrollY, mobileMenu, player, networkIssues: networkAudit.issues, errors: [...new Set(errors)].slice(0, 20), }; } const cases = [ [{ width: 320, height: 780 }, "mobile-320"], [{ width: 390, height: 844 }, "mobile-390"], [{ width: 768, height: 900 }, "tablet-768"], [{ width: 1024, height: 900 }, "desktop-1024"], [{ width: 1440, height: 900 }, "desktop-1440"], ]; const requested = new Set( (process.env.IMAGEFIND_LIVE_VIEWPORTS || "") .split(",") .map(value => value.trim()) .filter(Boolean), ); const results = []; for (const [viewport, name] of cases) { if (requested.size && !requested.has(name)) continue; // Chromium retains decoded full-page screenshots in process caches. A fresh // process per viewport keeps the five-size audit bounded on small CI hosts. const browser = await chromium.launch({ headless: true }); try { results.push(await inspect(browser, viewport, name)); fs.writeFileSync(path.join(outputDir, "report.json"), JSON.stringify(results, null, 2)); } finally { await browser.close(); } } console.log(JSON.stringify(results, null, 2));