feat: add ImageFind application and release pipelines

This commit is contained in:
2026-08-11 18:02:40 +08:00
commit 16239d7525
270 changed files with 59163 additions and 0 deletions
+219
View File
@@ -0,0 +1,219 @@
#!/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();
}