From 269a2269a15e94205bf4a6dfc8eae1e21371ee5e Mon Sep 17 00:00:00 2001 From: root Date: Sun, 27 Sep 2026 21:32:18 +0800 Subject: [PATCH] test(web): Playwright full-route smoke as deno task test:web:smoke Logs in, opens every console route (detail routes resolved from list endpoints), fails on console errors, uncaught exceptions or 4xx/5xx API responses, and screenshots each page. Uses playwright-core with the local Chromium; documented in README. Co-Authored-By: Claude Opus 5.5 --- README.md | 11 +++ apps/web/scripts/smoke.mjs | 168 +++++++++++++++++++++++++++++++++++++ deno.json | 5 ++ deno.lock | 6 ++ 4 files changed, 190 insertions(+) create mode 100644 apps/web/scripts/smoke.mjs diff --git a/README.md b/README.md index b21b92f7..415e2aab 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,17 @@ deno task dev # API http://localhost:4380 控制台 http://localhost:51 提交前:`deno task ci`(typecheck:tsc 7.0.2 + deno check → biome → vitest → check:tokens → web 构建 + server 编译)。 +### 前端冒烟(`deno task test:web:smoke`) + +Playwright(`playwright-core`,用本机 Chromium)登录后逐个打开控制台全部路由(详情页从列表接口取第一条真实 ID),检查页面渲染出来、没有控制台报错 / 未捕获异常 / 4xx·5xx 的 API 请求,并逐页截图。不在 `deno task ci` 里,需要先起好后端与 web、并已初始化管理员: + +```sh +deno task dev # 或单容器部署后直接用 4380 +SMOKE_BASE_URL=http://127.0.0.1:5180 SMOKE_USER=<管理员> SMOKE_PASSWORD=<密码> deno task test:web:smoke +``` + +可选:`SMOKE_OUT`(截图目录,缺省 `apps/web/test/screenshots/smoke`,已 gitignore)、`SMOKE_CHROMIUM`(浏览器路径,缺省 `/usr/bin/chromium`)、`SMOKE_SCREENSHOTS=0`。全部通过退出 0,有失败 1,前置条件不满足 2。 + ## 部署 除 PostgreSQL 外全部在一个容器里(api + worker + web 静态,单端口 4380);compose 只有 `app` + `postgres`。 diff --git a/apps/web/scripts/smoke.mjs b/apps/web/scripts/smoke.mjs new file mode 100644 index 00000000..8b84f020 --- /dev/null +++ b/apps/web/scripts/smoke.mjs @@ -0,0 +1,168 @@ +// 前端全页面冒烟:登录 → 逐个路由打开 → 页面渲染出来、无控制台错误 / 未捕获异常 / 失败的 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/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); +}); diff --git a/deno.json b/deno.json index eba27fed..5a4ee0da 100644 --- a/deno.json +++ b/deno.json @@ -37,6 +37,7 @@ "pg": "npm:pg@8.23.0", "pino": "npm:pino@10.3.1", "pino-pretty": "npm:pino-pretty@13.1.3", + "playwright-core": "npm:playwright-core@1.63.0", "postgres": "npm:postgres@3.4.9", "typebox": "npm:typebox@1.3.27", "typescript": "npm:typescript@7.0.2", @@ -71,6 +72,10 @@ "lint": "deno run -A npm:@biomejs/biome check .", "fmt": "deno run -A npm:@biomejs/biome check --write .", "test": "deno task --members -j 1 test", + "test:web:smoke": { + "description": "前端全页面冒烟(Playwright):需要已启动的后端 + web 与已初始化的管理员,见 README", + "command": "deno run -A apps/web/scripts/smoke.mjs" + }, "check:tokens": "cd apps/web && deno task check:tokens", "build": "cd apps/web && deno task build && cd ../server && deno task compile", "ci": "deno task typecheck && deno task lint && deno task test && deno task check:tokens && deno task build", diff --git a/deno.lock b/deno.lock index e59723dd..2a9e5503 100644 --- a/deno.lock +++ b/deno.lock @@ -36,6 +36,7 @@ "npm:pg@8.23.0": "8.23.0", "npm:pino-pretty@13.1.3": "13.1.3", "npm:pino@10.3.1": "10.3.1", + "npm:playwright-core@1.63.0": "1.63.0", "npm:postgres@3.4.9": "3.4.9", "npm:radix-ui@^1.4.3": "1.6.7_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0", "npm:react-day-picker@^10.0.1": "10.0.1_@types+react@19.3.0_react@19.3.0", @@ -4373,6 +4374,10 @@ "find-up" ] }, + "playwright-core@1.63.0": { + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "bin": true + }, "postcss-selector-parser@7.1.6": { "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", "dependencies": [ @@ -5396,6 +5401,7 @@ "npm:pg@8.23.0", "npm:pino-pretty@13.1.3", "npm:pino@10.3.1", + "npm:playwright-core@1.63.0", "npm:postgres@3.4.9", "npm:typebox@1.3.27", "npm:typescript@7.0.2",