346 lines
14 KiB
JavaScript
Executable File
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));
|