Files
UHD_Media_Tool/apps/web/scripts/smoke.mjs
T
rootandClaude Opus 5.5 393a4cadcc feat(web): global search — top bar entry, Ctrl/Cmd+K palette and /console/search results (FE-3)
- palette queries media + actions: local works open, TMDB candidates view/subscribe, page jumps
- results page: works / releases / actions; release filters, facets, sort, cursor, partial-site warning
- release rows: download via /search/releases/{siteId}/{siteReleaseId}/plan, delegate to Agent
- smoke: rules tabs, preferences redirect, search page

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 16:06:16 +08:00

252 lines
11 KiB
JavaScript

// 前端全页面冒烟:登录 → 逐个路由打开 → 页面渲染出来、无控制台错误 / 未捕获异常 / 失败的 API 请求 → 截图。
//
// 前置:后端与 web 已启动,管理员已初始化(见 README「前端冒烟」)。
// SMOKE_BASE_URL web 地址,缺省 http://127.0.0.1:5180(dev 代理 /api 到后端;单容器部署直接用 4380)
// SMOKE_USER 管理员用户名(必填)
// SMOKE_PASSWORD 管理员密码(必填)
// SMOKE_OUT 截图目录,缺省 apps/web/test/screenshots/smoke(已 gitignore)
// SMOKE_CHROMIUM 浏览器可执行文件,缺省 /usr/bin/chromium → /usr/bin/google-chrome
// SMOKE_SCREENSHOTS=0 不截图
// 退出码:全部通过 0,有失败 1,前置条件不满足 2。
import { existsSync, mkdirSync } from "node:fs";
import { join, resolve } from "node:path";
import process from "node:process";
import { chromium } from "playwright-core";
const BASE = (process.env.SMOKE_BASE_URL ?? "http://127.0.0.1:5180").replace(/\/+$/, "");
const USER = process.env.SMOKE_USER ?? "";
const PASSWORD = process.env.SMOKE_PASSWORD ?? "";
const OUT = resolve(process.env.SMOKE_OUT ?? join(import.meta.dirname, "../test/screenshots/smoke"));
const SHOTS = process.env.SMOKE_SCREENSHOTS !== "0";
/** 静态路由 + 需要真实 ID 的详情路由(从列表接口取第一条;没有数据时跳过) */
const STATIC_ROUTES = [
"/console",
"/console/inbox",
"/console/library",
"/console/identity",
"/console/organize",
"/console/files",
"/console/files/trash",
"/console/subscriptions",
"/console/rules",
"/console/rules?tab=atoms",
"/console/rules?tab=defaults",
"/console/rules?tab=policies",
"/console/rules?tab=suppressions",
"/console/sites",
"/console/sources",
"/console/downloads",
"/console/downloads/plans",
"/console/tasks",
"/console/agent",
"/console/agent?mock=1",
"/console/engine",
"/console/audit",
"/console/schedules",
"/console/search?q=沙丘",
"/console/settings",
"/console/settings?tab=pi",
// 设置各分区(hash 定位,IA-2)
"/console/settings#sites",
"/console/settings#downloaders",
"/console/settings#library",
"/console/settings#media-servers",
"/console/settings#metadata",
"/console/settings#agent",
"/console/settings#api-keys",
"/console/settings#account",
];
const DETAIL_ROUTES = [
{ route: "/console/library/:id", list: "/api/v1/library" },
{ route: "/console/organize/:id", list: "/api/v1/organization-records" },
{ route: "/console/sites/:id", list: "/api/v1/sites" },
{ route: "/console/sites/:id", list: "/api/v1/sites", after: "?tab=snapshots" },
{ route: "/console/sites/:id", list: "/api/v1/sites", after: "?tab=accounts" },
{ route: "/console/downloads/:id", list: "/api/v1/download-tasks" },
{ route: "/console/tasks/:id", list: "/api/v1/tasks" },
{ route: "/console/agent/:id", list: "/api/v1/agent/runs", after: "?mock=0" },
{ route: "/console/engine/:id", list: "/api/v1/engine/runs" },
];
/**
* IA 重构后的旧链接(docs/design/console-ia.md §3.3):打开旧地址,最终落到新地址且页面 200。
* 与 app/routes/console/redirects.ts 的 LEGACY_REDIRECTS 同步;:id 从列表接口取。
*/
const REDIRECT_ROUTES = [
{ from: "/console/tasks?tab=downloads", to: "/console/downloads" },
{ from: "/console/tasks?tab=downloads&status=paused", to: "/console/downloads?status=paused" },
{ from: "/console/tasks?tab=plans", to: "/console/downloads/plans" },
{ from: "/console/tasks?tab=tasks", to: "/console/tasks" },
{ from: "/console/subscriptions?tab=policies", to: "/console/rules" },
// 偏好标签并入规则页(origin 筛选透传)
{ from: "/console/preferences", to: "/console/rules" },
{ from: "/console/preferences?origin=agent_inferred", to: "/console/rules?origin=agent_inferred" },
{ from: "/console/sources?tab=snapshots", to: "/console/sources" },
{ from: "/console/sources?tab=snapshots&siteId=:id", to: "/console/sites/:id?tab=snapshots", list: "/api/v1/sites" },
// 旧的下载详情地址:先查业务任务(404 属预期),再跳到下载详情
{ from: "/console/tasks/:id", to: "/console/downloads/:id", list: "/api/v1/download-tasks", expect404: true },
// 设置旧页签 ?tab= → 分区 hash(IA-2)
{ from: "/console/settings?tab=pi", to: "/console/settings#agent" },
{ from: "/console/settings?tab=storage", to: "/console/settings#automation/cache" },
{ from: "/console/settings?tab=path-mappings", to: "/console/settings#downloaders" },
{ from: "/console/settings?tab=site-credentials", to: "/console/settings#sites" },
];
/** 允许的控制台噪音(React Router 开发模式提示等,不算错误) */
const IGNORED = [/Download the React DevTools/i, /React Router Future Flag Warning/i];
function pickBrowser() {
const cands = [process.env.SMOKE_CHROMIUM, "/usr/bin/chromium", "/usr/bin/google-chrome"].filter(Boolean);
return cands.find((p) => existsSync(p));
}
async function main() {
if (!USER || !PASSWORD) {
console.error("缺少 SMOKE_USER / SMOKE_PASSWORD(已初始化的管理员账号)");
process.exit(2);
}
const executablePath = pickBrowser();
if (!executablePath) {
console.error("找不到浏览器:设置 SMOKE_CHROMIUM 指向 chromium / chrome 可执行文件");
process.exit(2);
}
if (SHOTS) mkdirSync(OUT, { recursive: true });
const browser = await chromium.launch({ executablePath, headless: true, args: ["--no-sandbox"] });
const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, locale: "zh-CN" });
const page = await context.newPage();
/** 当前路由收集到的问题 */
let problems = [];
page.on("console", (m) => {
if (m.type() !== "error") return;
const text = m.text();
if (IGNORED.some((r) => r.test(text))) return;
problems.push(`console.error: ${text.slice(0, 300)}`);
});
page.on("pageerror", (e) => problems.push(`pageerror: ${String(e.message ?? e).slice(0, 300)}`));
page.on("response", (r) => {
const url = r.url();
if (!url.includes("/api/")) return;
// /events 是 SSE 长连接;401 探测(/auth/session)不算
if (url.includes("/api/v1/events")) return;
if (r.status() >= 400 && !(r.status() === 401 && url.includes("/auth/session")))
problems.push(`HTTP ${r.status()} ${r.request().method()} ${url.replace(BASE, "")}`);
});
// ── 登录 ──
const loginRes = await page.goto(`${BASE}/login`, { waitUntil: "networkidle" });
if (loginRes?.status() !== 200) {
console.error(`登录页不可达:${BASE}/login → ${loginRes?.status()}`);
await browser.close();
process.exit(2);
}
await page.fill("#username", USER);
await page.fill("#password", PASSWORD);
await page.click('button[type="submit"]');
try {
await page.waitForURL((u) => u.pathname.startsWith("/console"), { timeout: 15_000 });
} catch {
console.error("登录失败(没有跳到 /console),检查账号密码或后端是否启动");
if (SHOTS) await page.screenshot({ path: join(OUT, "login-failed.png"), fullPage: true });
await browser.close();
process.exit(2);
}
problems = [];
// ── 详情路由取真实 ID ──
const routes = [...STATIC_ROUTES];
const skipped = [];
for (const d of DETAIL_ROUTES) {
const r = await page.request.get(`${BASE}${d.list}?limit=1`);
const body = r.ok() ? await r.json().catch(() => null) : null;
const id = body?.data?.items?.[0]?.id ?? body?.data?.items?.[0]?.entity?.id;
if (id) routes.push(d.route.replace(":id", encodeURIComponent(id)) + (d.after ?? ""));
else skipped.push(`${d.route}(${d.list} 无数据)`);
}
// ── 逐个路由 ──
const results = [];
for (const path of routes) {
problems = [];
const t0 = Date.now();
let status = 0;
try {
// 只差 hash 的地址是同文档跳转(没有响应);先离开,保证整页加载
if (path.includes("#")) await page.goto("about:blank");
const res = await page.goto(`${BASE}${path}`, { waitUntil: "networkidle", timeout: 30_000 });
status = res?.status() ?? 0;
// SPA 外壳已渲染(主内容区出现),不是登录页 / 404 页
await page.waitForSelector("main", { timeout: 10_000 });
await page.waitForTimeout(400);
if (new URL(page.url()).pathname === "/login") problems.push("被重定向到登录页");
const notFound = await page.locator("text=页面不存在").count();
if (notFound) problems.push("渲染成 404 页");
} catch (err) {
problems.push(`加载失败:${String(err?.message ?? err).split("\n")[0]}`);
}
if (status !== 200) problems.push(`HTTP ${status}`);
const name = path.replace(/^\/console\/?/, "").replace(/[/?=&:]+/g, "_") || "home";
if (SHOTS) await page.screenshot({ path: join(OUT, `${name}.png`), fullPage: true }).catch(() => {});
results.push({ path, ms: Date.now() - t0, problems: [...new Set(problems)] });
}
// ── 旧链接重定向 ──
for (const r of REDIRECT_ROUTES) {
let from = r.from;
let to = r.to;
if (r.list) {
const res = await page.request.get(`${BASE}${r.list}?limit=1`);
const id = res.ok() ? (await res.json().catch(() => null))?.data?.items?.[0]?.id : null;
if (!id) {
skipped.push(`${r.from}(${r.list} 无数据)`);
continue;
}
from = from.replace(":id", encodeURIComponent(id));
to = to.replace(":id", encodeURIComponent(id));
}
problems = [];
const t0 = Date.now();
let status = 0;
try {
const res = await page.goto(`${BASE}${from}`, { waitUntil: "networkidle", timeout: 30_000 });
status = res?.status() ?? 0;
await page.waitForURL((u) => `${u.pathname}${u.search}${to.includes("#") ? u.hash : ""}` === to, {
timeout: 10_000,
});
await page.waitForSelector("main", { timeout: 10_000 });
} catch (err) {
const u = new URL(page.url());
problems.push(
`没有跳到 ${to}(停在 ${u.pathname}${u.search}${u.hash}):${String(err?.message ?? err).split("\n")[0]}`,
);
}
if (status !== 200) problems.push(`HTTP ${status}`);
// 旧下载详情先探测业务任务,那次 404 与浏览器随之打印的资源错误是预期内的
const kept = r.expect404 ? problems.filter((p) => !/404/.test(p)) : problems;
results.push({ path: `${from} → ${to}`, ms: Date.now() - t0, problems: [...new Set(kept)] });
}
await browser.close();
// ── 汇报 ──
let failed = 0;
for (const r of results) {
const ok = r.problems.length === 0;
if (!ok) failed++;
console.log(`${ok ? "PASS" : "FAIL"} ${r.path} (${r.ms}ms)`);
for (const p of r.problems) console.log(` - ${p}`);
}
for (const s of skipped) console.log(`SKIP ${s}`);
console.log(
`\n${results.length - failed}/${results.length} 通过${skipped.length ? `,跳过 ${skipped.length}` : ""}${SHOTS ? `;截图:${OUT}` : ""}`,
);
process.exit(failed ? 1 : 0);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});