Files
imagefind/scripts/live-player-acceptance.mjs
T

220 lines
9.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 tokenFile = process.env.IMAGEFIND_LIVE_TOKEN_FILE || "";
const token = tokenFile ? fs.readFileSync(tokenFile, "utf8").trim() : "";
const password = process.env.IMAGEFIND_LIVE_PASSWORD || "";
const targetVideoText = process.env.IMAGEFIND_LIVE_VIDEO_TEXT || "星河验收影片 729";
const mutateMarkers = process.env.IMAGEFIND_LIVE_MUTATE_MARKERS !== "false";
const outputDir = path.resolve(process.env.IMAGEFIND_LIVE_OUTPUT || "dist/live-player-acceptance");
if (!baseUrl || (!token && !password)) {
throw new Error("IMAGEFIND_LIVE_URL and either IMAGEFIND_LIVE_TOKEN_FILE or IMAGEFIND_LIVE_PASSWORD are required");
}
fs.mkdirSync(outputDir, { recursive: true });
async function newPage(browser, viewport) {
const context = await browser.newContext({ viewport, locale: "zh-CN", acceptDownloads: true });
const origin = new URL(baseUrl).origin;
if (token) {
await context.route("**/*", async route => {
const url = new URL(route.request().url());
if (url.origin === origin && url.pathname.includes("/api/")) {
await route.continue({
headers: { ...route.request().headers(), authorization: `Bearer ${token}` },
});
return;
}
await route.continue();
});
}
const page = await context.newPage();
await page.goto(baseUrl, { waitUntil: "domcontentloaded", timeout: 30_000 });
if (password) {
const passwordInput = page.getByLabel("管理员密码");
if (await passwordInput.isVisible().catch(() => false)) {
await passwordInput.fill(password);
await page.getByRole("button", { name: "登录", exact: true }).click();
}
}
await page.getByRole("button", { name: "首页", exact: true }).waitFor({ timeout: 30_000 });
await page.locator("article.video-card").first().waitFor({ timeout: 30_000 });
return { context, page };
}
async function showControls(page) {
const stage = page.locator(".custom-player");
if ((await stage.getAttribute("class"))?.includes("controls-hidden")) {
await page.locator(".player-gesture-surface").click({ position: { x: 20, y: 20 } });
await page.waitForTimeout(350);
}
}
function attachNetworkAudit(page, errors) {
const issues = [];
page.on("response", response => {
if (response.status() < 400) return;
const request = response.request();
const url = new URL(response.url());
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;
}
async function inspectPlayer(browser, viewport, name, mutateMarkers) {
const { context, page } = await newPage(browser, viewport);
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 networkIssues = attachNetworkAudit(page, errors);
const positive = page.locator("article.video-card").filter({ hasText: targetVideoText });
if (!(await positive.count())) throw new Error(`${name}: target H.264 fixture was not found: ${targetVideoText}`);
await positive.first().click();
await page.locator(".player-page").waitFor({ timeout: 20_000 });
const video = page.locator(".custom-player video");
await page.waitForFunction(() => {
const element = document.querySelector(".custom-player video");
return element instanceof HTMLVideoElement && element.readyState >= 2 && element.duration > 0;
}, null, { timeout: 90_000 });
await showControls(page);
const media = async () => video.evaluate(element => ({
paused: element.paused,
currentTime: element.currentTime,
duration: element.duration,
playbackRate: element.playbackRate,
readyState: element.readyState,
}));
const footerPlay = page.locator('.player-control-row > button[aria-label="播放"],.player-control-row > button[aria-label="暂停"]').first();
if (!(await media()).paused) {
await footerPlay.click();
await page.waitForFunction(() => document.querySelector(".custom-player video")?.paused === true);
}
const beforePlay = await media();
await footerPlay.click();
await page.waitForFunction(() => document.querySelector(".custom-player video")?.paused === false);
await page.waitForTimeout(1_000);
const duringPlay = await media();
if (duringPlay.currentTime <= beforePlay.currentTime) throw new Error(`${name}: playback time did not advance`);
await footerPlay.click();
await page.waitForFunction(() => document.querySelector(".custom-player video")?.paused === true);
await showControls(page);
const seekStart = (await media()).currentTime;
await page.getByRole("button", { name: "快进 10 秒" }).click();
const seekForward = (await media()).currentTime;
if (seekForward <= seekStart) throw new Error(`${name}: forward button did not seek`);
await page.getByRole("button", { name: "快退 10 秒" }).click();
const seekBack = (await media()).currentTime;
if (seekBack >= seekForward) throw new Error(`${name}: back button did not seek`);
await page.locator(".player-rate > button").click();
await page.getByRole("menuitem", { name: "1.5×", exact: true }).click();
if (Math.abs((await media()).playbackRate - 1.5) > 0.01) throw new Error(`${name}: rate did not change to 1.5x`);
await page.getByRole("button", { name: "全屏" }).click();
await page.waitForFunction(() => Boolean(document.fullscreenElement), null, { timeout: 10_000 });
const fullscreen = await page.evaluate(() => document.fullscreenElement?.classList.contains("custom-player"));
await page.evaluate(() => document.exitFullscreen());
await page.waitForFunction(() => !document.fullscreenElement);
await showControls(page);
await page.getByRole("button", { name: "视频时间点" }).click();
const drawer = page.locator(".player-marker-drawer");
await drawer.waitFor({ state: "visible" });
const initialDots = await page.locator(".player-marker-dot").count();
let markerCreated = false;
let markerRenamed = false;
let markerDeleted = false;
if (mutateMarkers) {
await drawer.getByRole("button", { name: "标记当前位置" }).click();
await page.waitForFunction(count => document.querySelectorAll(".player-marker-dot").length > count, initialDots);
markerCreated = true;
const newest = drawer.locator(".player-marker-list article").last();
await newest.getByRole("button", { name: "编辑时间点" }).click();
const dialog = page.getByRole("alertdialog");
await dialog.getByLabel("请输入").fill(`自动验收时间点-${Date.now()}`);
await dialog.getByRole("button", { name: "确定" }).click();
await dialog.waitFor({ state: "hidden" });
markerRenamed = true;
await drawer.locator(".player-marker-list article").last().getByRole("button", { name: "删除时间点" }).click();
const confirm = page.getByRole("alertdialog");
await confirm.getByRole("button", { name: "继续" }).click();
await page.waitForFunction(count => document.querySelectorAll(".player-marker-dot").length === count, initialDots);
markerDeleted = true;
}
const downloadPromise = page.waitForEvent("download", { timeout: 30_000 });
await page.locator(".player-actions a.action").filter({ hasText: "下载" }).click();
const download = await downloadPromise;
const downloadName = download.suggestedFilename();
await download.cancel();
const auditedNetworkIssues = networkIssues.map(issue => ({
...issue,
expected: issue.path.endsWith("/download") && issue.failure === "net::ERR_ABORTED",
}));
const auditedErrors = [...new Set(errors)].filter(
value => !(value.includes("/download") && value.includes("net::ERR_ABORTED")),
);
await page.screenshot({ path: path.join(outputDir, `${name}.png`), fullPage: true });
const report = {
name,
viewport,
media: await media(),
playbackAdvancedSeconds: Number((duringPlay.currentTime - beforePlay.currentTime).toFixed(2)),
seek: { before: seekStart, forward: seekForward, back: seekBack },
fullscreen,
markerDrawer: await drawer.isVisible(),
markerCreated,
markerRenamed,
markerDeleted,
downloadName,
horizontalOverflow: await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth),
networkIssues: auditedNetworkIssues,
errors: auditedErrors,
};
await context.close();
return report;
}
const browser = await chromium.launch({ headless: true });
try {
const results = [
await inspectPlayer(browser, { width: 1440, height: 900 }, "desktop", mutateMarkers),
await inspectPlayer(browser, { width: 390, height: 844 }, "mobile-390", false),
];
fs.writeFileSync(path.join(outputDir, "report.json"), `${JSON.stringify(results, null, 2)}\n`);
console.log(JSON.stringify(results, null, 2));
} finally {
await browser.close();
}