#!/usr/bin/env node import { performance } from 'node:perf_hooks'; const DEFAULT_THRESHOLDS = { healthVersion: 500, requirementPool: 1000, versionDetail: 1500, workspace: 1200, xiaobaoWarning: 1000, }; const DEFAULTS = { baseUrl: 'http://localhost:3001/api/v1', productId: 'perf-product-001', versionId: 'perf-version-001-001-001', userId: 'perf-user-dev-01', iterations: 5, warmup: 1, dryRun: false, }; function parseArgs(argv) { const args = { ...DEFAULTS }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === '--dry-run') { args.dryRun = true; continue; } if (arg === '--base-url') { args.baseUrl = argv[++index] ?? args.baseUrl; continue; } if (arg.startsWith('--base-url=')) { args.baseUrl = arg.slice('--base-url='.length); continue; } if (arg === '--product-id') { args.productId = argv[++index] ?? args.productId; continue; } if (arg === '--version-id') { args.versionId = argv[++index] ?? args.versionId; continue; } if (arg === '--user-id') { args.userId = argv[++index] ?? args.userId; continue; } if (arg === '--iterations') { args.iterations = parsePositiveInt(argv[++index], args.iterations); continue; } if (arg === '--warmup') { args.warmup = parsePositiveInt(argv[++index], args.warmup, 0); continue; } if (arg === '--help' || arg === '-h') { printHelp(); process.exit(0); } throw new Error(`Unknown argument: ${arg}`); } args.baseUrl = args.baseUrl.replace(/\/+$/, ''); return args; } function printHelp() { console.log(`Usage: node scripts/perf-check.mjs [--base-url URL] [--dry-run] Runs the V2.6 hot API performance harness. Options: --base-url API root. Defaults to ${DEFAULTS.baseUrl} --product-id Requirement pool product id. Defaults to ${DEFAULTS.productId} --version-id Version detail id. Defaults to ${DEFAULTS.versionId} --user-id Workspace user id. Defaults to ${DEFAULTS.userId} --iterations Timed iterations per probe. Defaults to ${DEFAULTS.iterations} --warmup Warmup iterations per probe. Defaults to ${DEFAULTS.warmup} --dry-run Print probes without making HTTP requests. `); } function parsePositiveInt(raw, fallback, min = 1) { const parsed = Number(raw); if (!Number.isFinite(parsed)) return fallback; return Math.max(min, Math.floor(parsed)); } function buildProbes(args) { const requirementQuery = new URLSearchParams({ productId: args.productId, q: 'REQ', limit: '50', }); const workspaceQuery = new URLSearchParams({ userId: args.userId }); const xiaobaoQuery = new URLSearchParams({ manager: 'true' }); return [ { key: 'healthVersion', label: 'health/version', method: 'GET', path: '/health/version', thresholdMs: DEFAULT_THRESHOLDS.healthVersion, }, { key: 'requirementPool', label: 'v2.2 requirement query', method: 'GET', path: `/v2.2/requirements?${requirementQuery.toString()}`, thresholdMs: DEFAULT_THRESHOLDS.requirementPool, }, { key: 'versionDetail', label: 'v2.2 version detail', method: 'GET', path: `/v2.2/versions/${encodeURIComponent(args.versionId)}/detail-data`, thresholdMs: DEFAULT_THRESHOLDS.versionDetail, }, { key: 'workspace', label: 'v2.2 workspace', method: 'GET', path: `/v2.2/workspace?${workspaceQuery.toString()}`, thresholdMs: DEFAULT_THRESHOLDS.workspace, }, { key: 'xiaobaoWarning', label: 'v2.2 xiaobao warning', method: 'GET', path: `/v2.2/xiaobao-warning?${xiaobaoQuery.toString()}`, thresholdMs: DEFAULT_THRESHOLDS.xiaobaoWarning, }, ].map((probe) => ({ ...probe, url: `${args.baseUrl}${probe.path}`, })); } async function measureProbe(probe, args) { for (let index = 0; index < args.warmup; index += 1) { await requestOnce(probe); } const samples = []; const statuses = new Map(); const errors = []; for (let index = 0; index < args.iterations; index += 1) { const result = await requestOnce(probe); samples.push(result.durationMs); statuses.set(result.status, (statuses.get(result.status) ?? 0) + 1); if (result.error) errors.push(result.error); if (result.status < 200 || result.status >= 300) { errors.push(`HTTP ${result.status}`); } } const p50 = percentile(samples, 50); const p95 = percentile(samples, 95); if (p95 > probe.thresholdMs) { errors.push(`p95 ${p95.toFixed(1)}ms > budget ${probe.thresholdMs}ms`); } return { ...probe, count: samples.length, p50, p95, statuses: [...statuses.entries()].sort(([a], [b]) => a - b), ok: errors.length === 0, errors, }; } async function requestOnce(probe) { const startedAt = performance.now(); try { const response = await fetch(probe.url, { method: probe.method }); await response.arrayBuffer(); return { status: response.status, durationMs: performance.now() - startedAt, }; } catch (error) { return { status: 0, durationMs: performance.now() - startedAt, error: error instanceof Error ? error.message : String(error), }; } } function percentile(values, pct) { if (values.length === 0) return 0; const sorted = [...values].sort((a, b) => a - b); const index = Math.ceil((pct / 100) * sorted.length) - 1; return sorted[Math.max(0, Math.min(sorted.length - 1, index))]; } function formatStatuses(statuses) { return statuses.map(([status, count]) => `${status}:${count}`).join(', '); } function printProbePlan(probes) { console.log('Performance harness dry-run'); for (const probe of probes) { console.log(`${probe.key}\t${probe.method}\t${probe.url}\tbudget_p95_ms=${probe.thresholdMs}`); } } function printResults(results) { console.log('probe\tstatus\tp50_ms\tp95_ms\tbudget_p95_ms\tok'); for (const result of results) { console.log([ result.key, formatStatuses(result.statuses), result.p50.toFixed(1), result.p95.toFixed(1), result.thresholdMs, result.ok ? 'yes' : 'no', ].join('\t')); if (result.errors.length > 0) { for (const error of result.errors) console.error(`${result.key}: ${error}`); } } } async function main() { const args = parseArgs(process.argv.slice(2)); const probes = buildProbes(args); if (args.dryRun) { printProbePlan(probes); return; } const results = []; for (const probe of probes) { results.push(await measureProbe(probe, args)); } printResults(results); if (results.some((result) => !result.ok)) { process.exitCode = 1; } } main().catch((error) => { console.error(error instanceof Error ? error.message : error); process.exitCode = 1; });