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 <[email protected]>
This commit is contained in:
1 parent
1023642ef9
commit
269a2269a1
4 files changed
+190
No files matched your search
@@ -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`。
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -37,6 +37,7 @@
|
||||
"pg": "npm:[email protected]",
|
||||
"pino": "npm:[email protected]",
|
||||
"pino-pretty": "npm:[email protected]",
|
||||
"playwright-core": "npm:[email protected]",
|
||||
"postgres": "npm:[email protected]",
|
||||
"typebox": "npm:[email protected]",
|
||||
"typescript": "npm:[email protected]",
|
||||
@@ -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",
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"npm:[email protected]": "8.23.0",
|
||||
"npm:[email protected]": "13.1.3",
|
||||
"npm:[email protected]": "10.3.1",
|
||||
"npm:[email protected]": "1.63.0",
|
||||
"npm:[email protected]": "3.4.9",
|
||||
"npm:radix-ui@^1.4.3": "1.6.7_@[email protected]_@[email protected][email protected][email protected]",
|
||||
"npm:react-day-picker@^10.0.1": "10.0.1_@[email protected][email protected]",
|
||||
@@ -4373,6 +4374,10 @@
|
||||
"find-up"
|
||||
]
|
||||
},
|
||||
"[email protected]": {
|
||||
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||
"bin": true
|
||||
},
|
||||
"[email protected]": {
|
||||
"integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==",
|
||||
"dependencies": [
|
||||
@@ -5396,6 +5401,7 @@
|
||||
"npm:[email protected]",
|
||||
"npm:[email protected]",
|
||||
"npm:[email protected]",
|
||||
"npm:[email protected]",
|
||||
"npm:[email protected]",
|
||||
"npm:[email protected]",
|
||||
"npm:[email protected]",
|
||||
|
||||
Reference in new issue
Block a user