feat(ops): 添加发布 smoke test
- 新增只读发布 smoke runner 和 dry-run 测试\n- 将生产部署 workflow 从单点版本检查升级为 smoke test\n- 补充 Docker web runtime 脚本复制、package script 和部署文档\n\nCo-Authored-By: GPT-5 Codex <codex@openai.com>
This commit is contained in:
160
scripts/smoke-test-release.mjs
Normal file
160
scripts/smoke-test-release.mjs
Normal file
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env node
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { flag, option, parseArgs } from './ops-utils.mjs';
|
||||
|
||||
function usage() {
|
||||
return `Usage: node scripts/smoke-test-release.mjs --base-url <url> [options]
|
||||
|
||||
Read-only release smoke checks:
|
||||
- backend runtime version
|
||||
- frontend root page
|
||||
- frontend products route
|
||||
- products API
|
||||
- V2.2 requirement read path
|
||||
- AI config public endpoint
|
||||
|
||||
Options:
|
||||
--base-url <url> Deployment base URL (default: http://127.0.0.1)
|
||||
--expected-version <sha> Expected /api/v1/health/version payload version
|
||||
--timeout-ms <number> Per-request timeout (default: 5000)
|
||||
--dry-run Print planned checks without making requests
|
||||
--help Show this help
|
||||
`;
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value) {
|
||||
const url = new URL(value || 'http://127.0.0.1');
|
||||
url.pathname = url.pathname.replace(/\/+$/, '');
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
return url.toString().replace(/\/$/, '');
|
||||
}
|
||||
|
||||
export function buildSmokeChecks(baseUrl, expectedVersion = '') {
|
||||
const checks = [
|
||||
{
|
||||
name: 'backend runtime version',
|
||||
path: '/api/v1/health/version',
|
||||
kind: 'json',
|
||||
validate(payload) {
|
||||
if (payload?.service !== 'server' || typeof payload.version !== 'string') {
|
||||
throw new Error(`unexpected version payload: ${JSON.stringify(payload)}`);
|
||||
}
|
||||
if (expectedVersion && payload.version !== expectedVersion) {
|
||||
throw new Error(`expected version ${expectedVersion}, got ${payload.version}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
{ name: 'frontend root', path: '/', kind: 'text' },
|
||||
{ name: 'frontend products route', path: '/products', kind: 'text' },
|
||||
{
|
||||
name: 'products API',
|
||||
path: '/api/v1/products',
|
||||
kind: 'json',
|
||||
validate(payload) {
|
||||
if (!Array.isArray(payload)) {
|
||||
throw new Error('products API did not return an array');
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'V2.2 requirement scoped read',
|
||||
path: '/api/v1/v2.2/requirements?productId=__smoke__&limit=1',
|
||||
kind: 'json',
|
||||
validate(payload) {
|
||||
if (!payload || !Array.isArray(payload.items)) {
|
||||
throw new Error('V2.2 requirements response missing items array');
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'AI config public endpoint',
|
||||
path: '/api/v1/config/ai',
|
||||
kind: 'json',
|
||||
validate(payload) {
|
||||
if (!payload || !Array.isArray(payload.providers)) {
|
||||
throw new Error('AI config response missing providers array');
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return checks.map((check) => ({
|
||||
...check,
|
||||
url: new URL(check.path, `${baseUrl}/`).toString(),
|
||||
}));
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url, timeoutMs) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(url, { signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function runCheck(check, timeoutMs) {
|
||||
const response = await fetchWithTimeout(check.url, timeoutMs);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
if (check.kind === 'json') {
|
||||
const payload = await response.json();
|
||||
check.validate?.(payload);
|
||||
} else {
|
||||
await response.text();
|
||||
}
|
||||
}
|
||||
|
||||
export async function main(argv = process.argv.slice(2)) {
|
||||
const args = parseArgs(argv);
|
||||
if (flag(args, 'help')) {
|
||||
process.stdout.write(usage());
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrl = normalizeBaseUrl(option(args, 'base-url', 'http://127.0.0.1'));
|
||||
const expectedVersion = option(args, 'expected-version', '');
|
||||
const timeoutMs = Number.parseInt(option(args, 'timeout-ms', '5000'), 10);
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
||||
throw new Error('--timeout-ms must be a positive number');
|
||||
}
|
||||
|
||||
const checks = buildSmokeChecks(baseUrl, expectedVersion);
|
||||
if (flag(args, 'dry-run')) {
|
||||
process.stdout.write(`[dry-run] Release smoke target: ${baseUrl}\n`);
|
||||
for (const check of checks) {
|
||||
process.stdout.write(`GET ${check.url} # ${check.name}\n`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write(`Release smoke target: ${baseUrl}\n`);
|
||||
const failures = [];
|
||||
for (const check of checks) {
|
||||
try {
|
||||
await runCheck(check, timeoutMs);
|
||||
process.stdout.write(`[pass] ${check.name} ${check.url}\n`);
|
||||
} catch (error) {
|
||||
failures.push(`[fail] ${check.name} ${check.url}: ${error.message}`);
|
||||
process.stderr.write(`${failures.at(-1)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
throw new Error(`Release smoke failed: ${failures.length} check(s) failed`);
|
||||
}
|
||||
|
||||
process.stdout.write('Release smoke test passed.\n');
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user