Files
UHD_Media_Tool/apps/web/scripts/smoke.mjs
T
rootandClaude Opus 5.5 89ea68465e feat(web): W5-B file manager page /console/files
Roots + lazy dir tree, virtualized grid with status badges and asset/slot/
download links, breadcrumb, sort, glob search, hidden toggle, filter chips,
paging and truncation notice; multi-select (Shift/Ctrl/all), action bar,
context menu, drag-to-move with dryRun preview, keyboard navigation,
offline (503) and empty/error states, mobile layout with tree drawer.
Rename/move/copy/delete dialogs with dryRun previews and reference checks,
4-step organize wizard, trash view (restore/restore-to/purge, expiry).
DEV-only ?mock=1 in-memory backend; smoke route list includes /console/files.

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

171 lines
6.9 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/preferences",
"/console/sites",
"/console/sources",
"/console/tasks",
"/console/agent",
"/console/agent?mock=1",
"/console/engine",
"/console/audit",
"/console/settings",
"/console/settings?tab=pi",
];
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/tasks/:id", list: "/api/v1/download-tasks" },
{ route: "/console/agent/:id", list: "/api/v1/agent/runs", after: "?mock=0" },
{ route: "/console/engine/:id", list: "/api/v1/engine/runs" },
];
/** 允许的控制台噪音(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 {
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)] });
}
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);
});