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