Files

346 lines
14 KiB
JavaScript
Executable File

#!/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));