80 lines
2.9 KiB
TypeScript
80 lines
2.9 KiB
TypeScript
import autocannon from 'autocannon';
|
|||
|
|
import { mkdirSync, writeFileSync } from 'fs';
|
||
|
|
import path from 'path';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* specs/016-load-concurrency-testing data-model.md "Load test report". Printed to the console
|
||
|
|
* and written as JSON under tests/load/reports/ (gitignored — a run artifact, not a fixture)
|
||
|
|
* for every run, so two runs of the same script can be compared.
|
||
|
|
*/
|
||
|
|
export interface LoadTestReport {
|
||
|
|
endpoint: string;
|
||
|
|
connections: number;
|
||
|
|
durationSec: number;
|
||
|
|
requestsPerSec: number;
|
||
|
|
latencyP50Ms: number;
|
||
|
|
latencyP90Ms: number;
|
||
|
|
latencyP99Ms: number;
|
||
|
|
non2xxCount: number;
|
||
|
|
rateLimitedCount: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* FR-007/FR-008: a thin wrapper over autocannon's programmatic API producing this feature's own
|
||
|
|
* report shape. FR-009: deliberately has NO pass/fail assertion on the numbers — throughput and
|
||
|
|
* latency targets are an `OPEN BUSINESS DECISION` (spec.md Assumptions) this project's roadmap
|
||
|
|
* says must never be invented and shipped as if final. This tool measures and records; wiring an
|
||
|
|
* explicit CI gate is future work once the business sets a real target.
|
||
|
|
*/
|
||
|
|
export async function runLoadTest(
|
||
|
|
endpoint: string,
|
||
|
|
options: autocannon.Options,
|
||
|
|
): Promise<LoadTestReport> {
|
||
|
|
const result = await autocannon(options);
|
||
|
|
|
||
|
|
const report: LoadTestReport = {
|
||
|
|
endpoint,
|
||
|
|
connections: result.connections,
|
||
|
|
durationSec: result.duration,
|
||
|
|
requestsPerSec: Number(result.requests.average.toFixed(2)),
|
||
|
|
latencyP50Ms: result.latency.p50,
|
||
|
|
latencyP90Ms: result.latency.p90,
|
||
|
|
latencyP99Ms: result.latency.p99,
|
||
|
|
non2xxCount: result.non2xx,
|
||
|
|
rateLimitedCount: result.statusCodeStats?.['429']?.count ?? 0,
|
||
|
|
};
|
||
|
|
|
||
|
|
printReport(report);
|
||
|
|
writeReport(report);
|
||
|
|
return report;
|
||
|
|
}
|
||
|
|
|
||
|
|
function printReport(report: LoadTestReport): void {
|
||
|
|
// eslint-disable-next-line no-console
|
||
|
|
console.log(`\n=== Load test report: ${report.endpoint} ===`);
|
||
|
|
// eslint-disable-next-line no-console
|
||
|
|
console.log(`connections: ${report.connections} duration: ${report.durationSec}s`);
|
||
|
|
// eslint-disable-next-line no-console
|
||
|
|
console.log(`requests/sec (avg): ${report.requestsPerSec}`);
|
||
|
|
// eslint-disable-next-line no-console
|
||
|
|
console.log(
|
||
|
|
`latency p50/p90/p99 (ms): ${report.latencyP50Ms}/${report.latencyP90Ms}/${report.latencyP99Ms}`,
|
||
|
|
);
|
||
|
|
// eslint-disable-next-line no-console
|
||
|
|
console.log(`non-2xx: ${report.non2xxCount} rate-limited (429): ${report.rateLimitedCount}`);
|
||
|
|
// eslint-disable-next-line no-console
|
||
|
|
console.log(
|
||
|
|
'No pass/fail threshold applied — throughput/latency targets are an OPEN BUSINESS DECISION.',
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function writeReport(report: LoadTestReport): void {
|
||
|
|
const dir = path.join(__dirname, 'reports');
|
||
|
|
mkdirSync(dir, { recursive: true });
|
||
|
|
const safeName = report.endpoint.replace(/[^a-z0-9-]/gi, '_');
|
||
|
|
const filePath = path.join(dir, `${safeName}-${Date.now()}.json`);
|
||
|
|
writeFileSync(filePath, JSON.stringify(report, null, 2));
|
||
|
|
// eslint-disable-next-line no-console
|
||
|
|
console.log(`Report written to ${filePath}`);
|
||
|
|
}
|