// 前端全页面冒烟:登录 → 逐个路由打开 → 页面渲染出来、无控制台错误 / 未捕获异常 / 失败的 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?view=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/logs", "/console/logs?type=auth", "/console/logs?level=error&since=24h", "/console/logs?system=1&q=feeds", "/console/logs?tab=schedules", "/console/logs?tab=schedules&job=trash.purge", "/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#acquire", "/console/settings#organize", "/console/settings#system", "/console/settings#cache", "/console/settings#interface", "/console/settings#account", ]; // 记录抽屉(docs/design/console-ia.md §9):列表里打开的记录是列表页上的抽屉(?open=);带 ?open= / ?run= 的地址要看到抽屉 const DETAIL_ROUTES = [ { route: "/console/library/:id", list: "/api/v1/library" }, { route: "/console/organize?open=:id", list: "/api/v1/organization-records" }, { route: "/console/sites?open=:id", list: "/api/v1/sites" }, { route: "/console/sites?open=:id", list: "/api/v1/sites", after: "&tab=resources" }, // 旧页签"整理视图"落到"资源" { route: "/console/sites?open=:id", list: "/api/v1/sites", after: "&tab=organized" }, { route: "/console/sites?open=:id", list: "/api/v1/sites", after: "&tab=snapshots" }, { route: "/console/sites?open=:id", list: "/api/v1/sites", after: "&tab=accounts" }, { route: "/console/subscriptions?open=:id", list: "/api/v1/subscriptions" }, { route: "/console/downloads?open=:id", list: "/api/v1/download-tasks" }, { route: "/console/tasks?run=:id", list: "/api/v1/engine/runs" }, { route: "/console/logs?type=task&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" }, { from: "/console/tasks?tab=tasks", to: "/console/logs?type=task" }, { 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/sites" }, { from: "/console/sources?tab=snapshots&siteId=:id", to: "/console/sites?open=:id&tab=snapshots", list: "/api/v1/sites", }, // 记录详情的旧地址 → 列表页抽屉(§9) { from: "/console/organize/:id", to: "/console/organize?open=:id", list: "/api/v1/organization-records" }, { from: "/console/subscriptions/:id", to: "/console/subscriptions?open=:id", list: "/api/v1/subscriptions" }, { from: "/console/sites/:id?tab=rss", to: "/console/sites?open=:id&tab=rss", list: "/api/v1/sites" }, { from: "/console/downloads/:id", to: "/console/downloads?open=:id", list: "/api/v1/download-tasks" }, { from: "/console/tasks/:id", to: "/console/logs?type=task&run=:id", list: "/api/v1/engine/runs" }, { from: "/console/files/trash", to: "/console/files?view=trash" }, // 旧的下载详情地址 /console/tasks/<下载任务 id>:日志页抽屉先查引擎运行和业务任务(404 属预期),再换到下载面板抽屉 { from: "/console/tasks/:id", to: "/console/downloads?open=:id", list: "/api/v1/download-tasks", expect404: true }, // 界面第二轮:任务 / 引擎面板 / 审计 / 计划并入日志页;身份与元数据并入媒体概览(IA-3) { from: "/console/tasks?tab=business", to: "/console/logs?type=task" }, { from: "/console/tasks?tab=failed", to: "/console/logs?type=task&level=error" }, { from: "/console/engine", to: "/console/logs?type=task" }, { from: "/console/engine/:id", to: "/console/logs?type=task&run=:id", list: "/api/v1/engine/runs" }, { from: "/console/audit", to: "/console/logs?type=audit" }, { from: "/console/schedules", to: "/console/logs?tab=schedules" }, { from: "/console/tasks?tab=plan", to: "/console/logs?tab=schedules" }, { from: "/console/tasks?tab=plan&job=trash.purge", to: "/console/logs?tab=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#downloaders/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 页"); if (/[?&](open|run)=/.test(path)) await page .locator('[data-slot="sheet-content"]') .first() .waitFor({ timeout: 10_000 }) .catch(() => problems.push("没有打开记录抽屉")); } 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)] }); } } // ── 交互:智能体抽屉「历史」弹层(AGUI-3;演示数据,点一条在抽屉内打开、新对话回到输入框、关了再开仍是那条会话)── { const label = "智能体抽屉 → 历史弹层 → 抽屉内打开会话"; problems = []; const t0 = Date.now(); try { await page.goto("about:blank"); // 起始页用不跳转的日志页(/console/audit 已并进日志页,会跳到 /console/logs?type=audit 且丢掉 ?mock=1) await page.goto(`${BASE}/console/logs?type=audit&mock=1`, { waitUntil: "networkidle", timeout: 30_000 }); const startPath = new URL(page.url()).pathname; const launcher = page.locator("[data-agent-launcher]:visible").last(); await launcher.click(); await page.getByRole("button", { name: "历史会话" }).click(); const list = page.getByRole("navigation", { name: "智能体会话列表" }); await list .getByRole("button") .filter({ hasNotText: /^(全部|等你处理|失败)$/ }) .first() .click(); // 等历史弹层退场后再按 Escape,避免按键被仍在退场的 Popover 消耗。 await list.waitFor({ state: "hidden", timeout: 5_000 }); await page.getByRole("button", { name: "新对话" }).and(page.locator(":enabled")).waitFor({ timeout: 10_000 }); if (new URL(page.url()).pathname !== startPath) problems.push("点历史会话跳走了页面"); await page.keyboard.press("Escape"); await page.locator('[data-slot="sheet-content"]').waitFor({ state: "hidden", timeout: 5_000 }); await launcher.click(); if (!(await page.getByRole("button", { name: "新对话" }).isEnabled())) problems.push("再次打开没有回到那条会话"); await page.getByRole("button", { name: "新对话" }).click(); await page.getByRole("textbox", { name: "目标" }).waitFor({ timeout: 5_000 }); 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?open=${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); });