484 lines
21 KiB
JavaScript
484 lines
21 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/library?filter=unidentified",
|
|
"/console/library?view=identities",
|
|
"/console/organize",
|
|
"/console/organize?ids=00000000-0000-4000-8000-000000000000",
|
|
"/console/files",
|
|
// 文件管理 v2(FE-4):筛选/排序组合与演示数据(?mock=1 只在开发构建生效,随后 ?mock=0 关掉,免得影响后面的页)
|
|
"/console/files?f=video&sort=size&order=desc&hidden=1",
|
|
"/console/files?mock=1",
|
|
"/console/files?mock=0",
|
|
"/console/files/trash",
|
|
"/console/subscriptions",
|
|
"/console/subscriptions?group=pending&sort=missing",
|
|
"/console/rules",
|
|
"/console/rules?tab=atoms",
|
|
"/console/rules?tab=defaults",
|
|
"/console/rules?tab=policies",
|
|
"/console/rules?tab=suppressions",
|
|
"/console/sites",
|
|
"/console/sites?view=list",
|
|
"/console/sites?filter=attention",
|
|
"/console/sources",
|
|
"/console/downloads",
|
|
"/console/downloads/plans",
|
|
"/console/tasks",
|
|
"/console/tasks?tab=active",
|
|
"/console/tasks?tab=failed",
|
|
"/console/schedules",
|
|
"/console/schedules#checks",
|
|
"/console/schedules?job=trash.purge",
|
|
"/console/agent",
|
|
"/console/agent?mock=1",
|
|
"/console/audit",
|
|
"/console/status",
|
|
"/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#interface",
|
|
"/console/settings#automation/advanced",
|
|
"/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=resources" },
|
|
// 旧页签"整理视图"落到"资源"
|
|
{ route: "/console/sites/:id", list: "/api/v1/sites", after: "?tab=organized" },
|
|
{ route: "/console/sites/:id", list: "/api/v1/sites", after: "?tab=snapshots" },
|
|
{ route: "/console/sites/:id", list: "/api/v1/sites", after: "?tab=accounts" },
|
|
{ route: "/console/subscriptions/:id", list: "/api/v1/subscriptions" },
|
|
{ route: "/console/downloads/:id", list: "/api/v1/download-tasks" },
|
|
{ route: "/console/tasks/:id", list: "/api/v1/engine/runs" },
|
|
{ route: "/console/tasks?run=:id", list: "/api/v1/engine/runs" },
|
|
{ route: "/console/agent/:id", list: "/api/v1/agent/runs", after: "?mock=0" },
|
|
];
|
|
|
|
/**
|
|
* 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 },
|
|
// IA-3:引擎面板 / 定时任务 / 身份与元数据并入任务页与媒体概览
|
|
{ from: "/console/tasks?tab=business", to: "/console/tasks" },
|
|
{ from: "/console/engine", to: "/console/tasks" },
|
|
{ from: "/console/engine/:id", to: "/console/tasks?run=:id", list: "/api/v1/engine/runs" },
|
|
// FE-6:计划从任务页拆回独立的计划页
|
|
{ from: "/console/tasks?tab=plan", to: "/console/schedules" },
|
|
{ from: "/console/tasks?tab=plan&job=trash.purge", to: "/console/schedules?job=trash.purge" },
|
|
{ from: "/console/identity", to: "/console/library?filter=unidentified" },
|
|
{ from: "/console/identity?tab=external", to: "/console/library?view=identities" },
|
|
// 设置旧页签 ?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)] });
|
|
}
|
|
// ── 交互:模型服务抽屉的模型选择(PI-2;只看来源行与可搜索下拉,不点「获取」,避免拿真实凭证出站)──
|
|
{
|
|
const label = "/console/settings#agent → 模型服务抽屉 · 模型选择";
|
|
const up = await page.request.get(`${BASE}/api/v1/pi/upstreams`);
|
|
const first = up.ok() ? (await up.json().catch(() => null))?.data?.items?.[0] : null;
|
|
if (!first) skipped.push(`${label}(没有模型服务)`);
|
|
else {
|
|
problems = [];
|
|
const t0 = Date.now();
|
|
try {
|
|
await page.goto("about:blank");
|
|
await page.goto(`${BASE}/console/settings#agent`, { waitUntil: "networkidle", timeout: 30_000 });
|
|
await page.getByText(first.name, { exact: true }).first().click();
|
|
const input = page.locator("#pu-model");
|
|
await input.waitFor({ timeout: 10_000 });
|
|
await page
|
|
.getByText(/^来源:/)
|
|
.first()
|
|
.waitFor({ timeout: 10_000 });
|
|
const models = (await page.request.get(`${BASE}/api/v1/pi/upstreams/${first.id}/models`).then((r) => r.json()))
|
|
?.data?.models;
|
|
if (models?.length) {
|
|
await input.fill("");
|
|
await input.focus();
|
|
await page.locator('[role="listbox"] [role="option"]').first().waitFor({ timeout: 5_000 });
|
|
}
|
|
await page.keyboard.press("Escape");
|
|
} catch (err) {
|
|
problems.push(`交互失败:${String(err?.message ?? err).split("\n")[0]}`);
|
|
}
|
|
if (SHOTS) await page.screenshot({ path: join(OUT, "settings_agent_upstream_models.png") }).catch(() => {});
|
|
results.push({ path: label, ms: Date.now() - t0, problems: [...new Set(problems)] });
|
|
}
|
|
}
|
|
|
|
// ── 交互:智能体抽屉「历史」Tab(AGUI-2;演示数据,点一条在抽屉内打开、返回列表、关了再开仍停在历史)──
|
|
{
|
|
const label = "智能体抽屉 → 历史 → 抽屉内打开会话";
|
|
problems = [];
|
|
const t0 = Date.now();
|
|
try {
|
|
await page.goto("about:blank");
|
|
await page.goto(`${BASE}/console/audit?mock=1`, { waitUntil: "networkidle", timeout: 30_000 });
|
|
const launcher = page.locator("[data-agent-launcher]:visible").last();
|
|
await launcher.click();
|
|
await page.getByRole("tab", { name: "历史" }).click();
|
|
const list = page.getByRole("navigation", { name: "智能体会话列表" });
|
|
await list.getByRole("button").first().click();
|
|
await page.getByRole("button", { name: "返回列表" }).waitFor({ timeout: 10_000 });
|
|
if (new URL(page.url()).pathname !== "/console/audit") problems.push("点历史会话跳走了页面");
|
|
await page.getByRole("button", { name: "返回列表" }).click();
|
|
await list.waitFor({ timeout: 5_000 });
|
|
await page.keyboard.press("Escape");
|
|
await launcher.click();
|
|
const tab = await page.getByRole("tab", { name: "历史" }).getAttribute("aria-selected");
|
|
if (tab !== "true") problems.push("再次打开没有停在「历史」");
|
|
if (SHOTS) await page.screenshot({ path: join(OUT, "agent_drawer_history.png") }).catch(() => {});
|
|
await page.keyboard.press("Escape");
|
|
await page.goto(`${BASE}/console/agent?mock=0`, { waitUntil: "networkidle", timeout: 30_000 });
|
|
} catch (err) {
|
|
problems.push(`交互失败:${String(err?.message ?? err).split("\n")[0]}`);
|
|
}
|
|
results.push({ path: label, ms: Date.now() - t0, problems: [...new Set(problems)] });
|
|
}
|
|
|
|
// ── 交互:原站视图在浏览器服务重启后自动重连(BR-3;浏览器 API 全部 mock,不连真实 camofox)──
|
|
// 截图先 404(标签页不在了)→ 显示"正在重新连接" → 自动调一次 POST .../reconnect → 截图恢复
|
|
{
|
|
const label = "原站视图 → 浏览器重启后自动重连(mock)";
|
|
const sitesRes = await page.request.get(`${BASE}/api/v1/sites?limit=50`);
|
|
const site = sitesRes.ok()
|
|
? ((await sitesRes.json().catch(() => null))?.data?.items ?? []).find((x) => x.enabled)
|
|
: null;
|
|
if (!site) skipped.push(`${label}(没有启用的站点)`);
|
|
else {
|
|
problems = [];
|
|
const t0 = Date.now();
|
|
const sid = "00000000-0000-4000-8000-00000000b3b3";
|
|
const at = new Date().toISOString();
|
|
const session = {
|
|
id: sid,
|
|
providerId: "00000000-0000-4000-8000-00000000b3b4",
|
|
siteId: site.id,
|
|
accountId: null,
|
|
profileKey: `umt-site-${site.id}-acct-anon`,
|
|
controller: "automation",
|
|
currentUrl: "https://example.invalid/torrents.php",
|
|
lastActivityAt: at,
|
|
upstreamRef: {},
|
|
recoveryState: {},
|
|
closedAt: null,
|
|
closeReason: null,
|
|
createdAt: at,
|
|
updatedAt: at,
|
|
tabs: [],
|
|
currentTabId: "tab-mock",
|
|
lease: null,
|
|
needsSnapshot: false,
|
|
waitingReason: null,
|
|
credential: null,
|
|
needsReconnect: false,
|
|
reconnect: null,
|
|
};
|
|
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==";
|
|
const ok = (data) => ({ status: 200, contentType: "application/json", body: JSON.stringify({ ok: true, data }) });
|
|
let reconnects = 0;
|
|
let shots = 0;
|
|
await page.route("**/api/v1/browser/**", async (route) => {
|
|
const u = new URL(route.request().url());
|
|
const p = u.pathname.replace(/^\/api\/v1/, "");
|
|
if (p === "/browser/providers")
|
|
return route.fulfill(
|
|
ok({
|
|
items: [
|
|
{
|
|
id: session.providerId,
|
|
name: "camofox-mock",
|
|
kind: "camofox",
|
|
baseUrl: "http://camofox.invalid:9377",
|
|
secretRef: null,
|
|
managedBy: null,
|
|
hasAccessKey: true,
|
|
enabled: true,
|
|
capabilities: null,
|
|
verifiedAt: null,
|
|
healthStatus: "healthy",
|
|
lastHealthAt: at,
|
|
health: null,
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
if (p === "/browser/sessions" && route.request().method() === "GET")
|
|
return route.fulfill(ok({ items: [session] }));
|
|
if (p === `/browser/desktop/${sid}/_status`)
|
|
return route.fulfill(
|
|
ok({
|
|
available: false,
|
|
reason: "冒烟 mock",
|
|
role: "viewer",
|
|
leaseExpiresAt: null,
|
|
path: `/api/v1/browser/desktop/${sid}/`,
|
|
requiresSecureContext: true,
|
|
}),
|
|
);
|
|
if (p === `/browser/sessions/${sid}/screenshot`) {
|
|
shots++;
|
|
if (reconnects === 0)
|
|
return route.fulfill({
|
|
status: 404,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ ok: false, error: { code: "not_found", message: "标签页不存在或已关闭" } }),
|
|
});
|
|
return route.fulfill(
|
|
ok({
|
|
mimeType: "image/png",
|
|
dataBase64: png,
|
|
width: 1,
|
|
height: 1,
|
|
url: session.currentUrl,
|
|
modelSafe: true,
|
|
capturedAt: new Date().toISOString(),
|
|
controller: "automation",
|
|
}),
|
|
);
|
|
}
|
|
if (p === `/browser/sessions/${sid}/reconnect`) {
|
|
reconnects++;
|
|
await new Promise((r) => setTimeout(r, 800));
|
|
return route.fulfill(
|
|
ok({ ...session, reconnect: { status: "ok", trigger: "tab_gone", at, reason: null, humanNotice: false } }),
|
|
);
|
|
}
|
|
return route.fulfill({
|
|
status: 404,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ ok: false, error: { code: "not_found", message: `冒烟 mock 没有 ${p}` } }),
|
|
});
|
|
});
|
|
try {
|
|
await page.goto("about:blank");
|
|
await page.goto(`${BASE}/console/sites/${encodeURIComponent(site.id)}?tab=origin`, {
|
|
waitUntil: "domcontentloaded",
|
|
timeout: 30_000,
|
|
});
|
|
await page.getByText("浏览器已重启,正在重新连接…").waitFor({ timeout: 15_000 });
|
|
await page.locator('img[alt="站点页面画面"]').waitFor({ timeout: 15_000 });
|
|
await page.waitForTimeout(2_000);
|
|
if (reconnects !== 1) problems.push(`自动重连应只调用 1 次,实际 ${reconnects} 次`);
|
|
if (shots < 2) problems.push("重连后没有继续轮询截图");
|
|
if (await page.getByText("读取画面失败").count()) problems.push("仍显示读取画面失败");
|
|
} catch (err) {
|
|
problems.push(`交互失败:${String(err?.message ?? err).split("\n")[0]}`);
|
|
}
|
|
if (SHOTS) await page.screenshot({ path: join(OUT, "site_origin_reconnect.png") }).catch(() => {});
|
|
await page.unroute("**/api/v1/browser/**");
|
|
// mock 的首个截图 404 是预期内的
|
|
const kept = problems.filter(
|
|
(x) => !(/HTTP 404/.test(x) && x.includes("/screenshot")) && !/status of 404/.test(x),
|
|
);
|
|
results.push({ path: label, ms: Date.now() - t0, problems: [...new Set(kept)] });
|
|
}
|
|
}
|
|
|
|
// ── 旧链接重定向 ──
|
|
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);
|
|
});
|