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