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

156 lines
6.9 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, 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));