220 lines
9.6 KiB
JavaScript
220 lines
9.6 KiB
JavaScript
#!/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();
|
||
}
|