#!/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, request } = 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 runPrefix = process.env.IMAGEFIND_LIVE_RUN_PREFIX || ""; const outputDir = path.resolve(process.env.IMAGEFIND_LIVE_OUTPUT || "dist/live-profile-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 }); const authHeaders = token ? { authorization: `Bearer ${token}` } : {}; const api = await request.newContext({ baseURL: baseUrl, extraHTTPHeaders: authHeaders }); let csrfToken = ""; if (password) { const login = await api.post("/api/v1/auth/login", { data: { password, remember_device: false }, }); if (!login.ok()) throw new Error(`login failed: ${login.status()} ${await login.text()}`); csrfToken = String((await login.json()).csrf_token || ""); if (!csrfToken) throw new Error("login response omitted CSRF token"); } const mutationHeaders = csrfToken ? { "X-CSRF-Token": csrfToken } : {}; const response = await api.get("/api/v1/videos?limit=500"); if (!response.ok()) throw new Error(`unable to list videos: ${response.status()}`); const videos = await response.json(); if (videos.length < 2) throw new Error("at least two disposable videos are required"); const selectedVideos = (runPrefix ? videos.filter(video => String(video.source_key || "").includes(runPrefix)) : videos ).slice(0, 2); if (selectedVideos.length < 2) throw new Error("at least two matching disposable videos are required"); const snapshots = videos.map(video => ({ id: video.id, liked: Boolean(video.liked), favorited: Boolean(video.favorited), progress_ms: video.progress_ms || 0, completed: Boolean(video.completed), })); async function patchState(id, state) { const result = await api.patch(`/api/v1/videos/${id}/state`, { data: state, headers: mutationHeaders }); if (!result.ok()) throw new Error(`state patch failed: ${result.status()} ${await result.text()}`); } async function seed(state) { await Promise.all(selectedVideos.map(video => patchState(video.id, state))); } async function openPage(browser, viewport) { const context = await browser.newContext({ viewport, locale: "zh-CN" }); 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(), ...authHeaders } }); return; } await route.continue(); }); } const page = await context.newPage(); await page.goto(baseUrl, { waitUntil: "domcontentloaded", timeout: 30_000 }); await page.waitForTimeout(1_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.locator(".app-shell").waitFor({ timeout: 30_000 }); if (viewport.width <= 720) { await page.locator(".mobile-profile-button").click(); } else { await page.getByRole("button", { name: "账户菜单" }).click(); await page.getByRole("button", { name: "个人中心", exact: true }).click(); } await page.locator(".profile-page").waitFor({ timeout: 20_000 }); return { context, page }; } async function bulkAction(page, tab, count, actionLabel) { await page.getByRole("button", { name: tab, exact: true }).click(); const section = page.locator(".profile-videos"); await section.locator("article.video-card").first().waitFor({ timeout: 20_000 }); const before = await section.locator("article.video-card").count(); await section.getByRole("button", { name: "管理", exact: true }).click(); const choices = section.locator('article.video-card[aria-label^="选择 "]'); if ((await choices.count()) < count) throw new Error(`${tab}: not enough selectable cards`); for (let index = 0; index < count; index += 1) await choices.nth(index).click(); await section.getByRole("button", { name: actionLabel, exact: true }).click(); const dialog = page.getByRole("alertdialog"); await dialog.getByRole("button", { name: "继续", exact: true }).click(); await section.locator(".loading").waitFor({ state: "hidden", timeout: 20_000 }).catch(() => {}); await page.waitForFunction( ({ selector, expected }) => document.querySelectorAll(selector).length === expected, { selector: ".profile-videos article.video-card", expected: before - count }, ); return { before, after: await section.locator("article.video-card").count(), selected: count }; } const browser = await chromium.launch({ headless: true }); const reports = []; try { await seed({ favorited: true }); { const { context, page } = await openPage(browser, { width: 1440, height: 900 }); const errors = []; page.on("pageerror", error => errors.push(error.message)); const result = await bulkAction(page, "收藏", 1, "取消收藏"); await page.screenshot({ path: path.join(outputDir, "desktop-favorite-single.png"), fullPage: true }); reports.push({ name: "desktop-favorite-single", ...result, errors }); await context.close(); } await seed({ liked: true }); { const { context, page } = await openPage(browser, { width: 390, height: 844 }); const errors = []; page.on("pageerror", error => errors.push(error.message)); const likes = await bulkAction(page, "喜欢", 2, "取消喜欢"); await page.screenshot({ path: path.join(outputDir, "mobile-like-multi.png"), fullPage: true }); reports.push({ name: "mobile-like-multi", ...likes, errors }); await context.close(); } await seed({ progress_ms: 3_000, completed: false }); { const { context, page } = await openPage(browser, { width: 390, height: 844 }); const errors = []; page.on("pageerror", error => errors.push(error.message)); const history = await bulkAction(page, "观看记录", 2, "清除记录"); await page.screenshot({ path: path.join(outputDir, "mobile-history-multi.png"), fullPage: true }); reports.push({ name: "mobile-history-multi", ...history, errors }); await context.close(); } } finally { await Promise.all(snapshots.map(snapshot => patchState(snapshot.id, snapshot))); await browser.close(); await api.dispose(); } fs.writeFileSync(path.join(outputDir, "report.json"), `${JSON.stringify(reports, null, 2)}\n`); console.log(JSON.stringify(reports, null, 2));