diff --git a/docs/operations.md b/docs/operations.md index 0bd122b4b..1c70beaba 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -54,6 +54,15 @@ curl -I --max-time 30 "http://127.0.0.1:3001/api/featured?page=1&pageSize=24" ## 路由回归烟测 +推荐使用脚本化检查: + +```bash +AUTH_TRUST_HOST=true PORT=3001 pnpm dev +SMOKE_BASE_URL=http://127.0.0.1:3001 pnpm smoke:routes +``` + +手动检查等价命令: + ```bash curl -I http://127.0.0.1:3001/ curl -I http://127.0.0.1:3001/classic diff --git a/package.json b/package.json index 8731ae585..39b3f290c 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "start": "next start", "lint": "npx ultracite@latest check", "check:logs": "node scripts/check-log-hygiene.mjs && tsx scripts/check-log-redaction.ts", + "smoke:routes": "node scripts/smoke-routes.mjs", "format": "npx ultracite@latest fix", "db:generate": "drizzle-kit generate", "db:migrate": "npx tsx lib/db/migrate.ts", diff --git a/scripts/smoke-routes.mjs b/scripts/smoke-routes.mjs new file mode 100644 index 000000000..4437d2f2a --- /dev/null +++ b/scripts/smoke-routes.mjs @@ -0,0 +1,124 @@ +#!/usr/bin/env node + +const DEFAULT_BASE_URL = `http://127.0.0.1:${process.env.PORT || "3001"}`; +const BASE_URL = stripTrailingSlash( + process.env.SMOKE_BASE_URL || process.argv[2] || DEFAULT_BASE_URL +); +const REQUEST_TIMEOUT_MS = Number(process.env.SMOKE_TIMEOUT_MS || 20_000); + +const checks = [ + { + path: "/", + method: "HEAD", + expect: { status: [200] }, + }, + { + path: "/classic", + method: "HEAD", + expect: { status: [307, 308], location: "/" }, + }, + { + path: "/chat/test-id", + method: "HEAD", + expect: { status: [307, 308], location: "/" }, + }, + { + path: "/settings", + method: "HEAD", + expect: { + status: [307, 308], + location: "/login?callbackUrl=%2Fsettings", + }, + }, +]; + +let hasFailure = false; + +for (const check of checks) { + const result = await runCheck(check); + if (!result.ok) { + hasFailure = true; + console.error(`FAIL ${check.method} ${check.path} - ${result.message}`); + continue; + } + + console.log(`PASS ${check.method} ${check.path} - ${result.message}`); +} + +if (hasFailure) { + process.exit(1); +} + +async function runCheck(check) { + const startedAt = Date.now(); + const url = `${BASE_URL}${check.path}`; + + try { + const response = await fetch(url, { + method: check.method, + redirect: "manual", + headers: { + "user-agent": "Codex Route Smoke Check", + }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + const durationMs = Date.now() - startedAt; + const location = response.headers.get("location"); + + if (!check.expect.status.includes(response.status)) { + return { + ok: false, + message: `expected status ${check.expect.status.join( + "/" + )}, got ${response.status} in ${durationMs}ms`, + }; + } + + if ( + check.expect.location && + !matchesLocation(location, check.expect.location) + ) { + return { + ok: false, + message: `expected location ${check.expect.location}, got ${ + location || "" + } in ${durationMs}ms`, + }; + } + + const redirectMessage = check.expect.location + ? `, location ${check.expect.location}` + : ""; + + return { + ok: true, + message: `status ${response.status}${redirectMessage} in ${durationMs}ms`, + }; + } catch (error) { + return { + ok: false, + message: error instanceof Error ? error.message : String(error), + }; + } +} + +function matchesLocation(actual, expected) { + if (!actual) { + return false; + } + + if (actual === expected) { + return true; + } + + try { + const actualUrl = new URL(actual, BASE_URL); + return `${actualUrl.pathname}${actualUrl.search}` === expected; + } catch { + return false; + } +} + +function stripTrailingSlash(value) { + return value.endsWith("/") ? value.slice(0, -1) : value; +}