@bitclaw/sqlite 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Unlike benchmark.ts (which tests raw SQLite pool.exec() calls), these utilities
|
|
6
6
|
* measure end-to-end HTTP performance through the full stack: HTTP server, middleware,
|
|
7
|
-
* ORM
|
|
7
|
+
* the ORM/query layer, SSR rendering, etc.
|
|
8
8
|
*
|
|
9
9
|
* Usage:
|
|
10
10
|
* Import into app-specific load tests:
|
|
@@ -24,6 +24,14 @@ export type LoadTestConfig = {
|
|
|
24
24
|
durationSec: number;
|
|
25
25
|
/** Optional: warm-up requests before timing */
|
|
26
26
|
warmupRequests?: number;
|
|
27
|
+
/**
|
|
28
|
+
* Optional: number of times to repeat each scenario (default 1).
|
|
29
|
+
* When > 1, each (endpoint, concurrency) runs N times and results are
|
|
30
|
+
* aggregated — medians for throughput/latency, plus a coefficient of
|
|
31
|
+
* variation so run-to-run dispersion is visible. Defends against the
|
|
32
|
+
* ~±30% single-run variance seen on virtualized hosts (e.g. WSL2).
|
|
33
|
+
*/
|
|
34
|
+
repeat?: number;
|
|
27
35
|
};
|
|
28
36
|
export type EndpointConfig = {
|
|
29
37
|
/** Path relative to baseUrl (e.g., '/healthcheck') */
|
|
@@ -63,6 +71,20 @@ export type ScenarioResult = {
|
|
|
63
71
|
statusCodes: Record<number, number>;
|
|
64
72
|
avgBodySize: number;
|
|
65
73
|
via?: 'cdn' | 'direct';
|
|
74
|
+
/**
|
|
75
|
+
* Variance fields — only populated when the scenario was run more than
|
|
76
|
+
* once (LoadTestConfig.repeat > 1). Absent for single-run scenarios, so
|
|
77
|
+
* the single-run output shape is unchanged.
|
|
78
|
+
*/
|
|
79
|
+
runs?: number;
|
|
80
|
+
/** Lowest per-run throughput (req/s) across the N runs. */
|
|
81
|
+
throughputMin?: number;
|
|
82
|
+
/** Highest per-run throughput (req/s) across the N runs. */
|
|
83
|
+
throughputMax?: number;
|
|
84
|
+
/** Coefficient of variation of throughput across runs, as a percent. */
|
|
85
|
+
throughputCoV?: number;
|
|
86
|
+
/** Median of the per-run p95 values (NOT p95 of pooled latencies). */
|
|
87
|
+
p95Median?: number;
|
|
66
88
|
};
|
|
67
89
|
export type LoadTestResults = {
|
|
68
90
|
baseUrl: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"load-test-utils.d.ts","sourceRoot":"","sources":["../../scripts/load-test-utils.ts"],"names":[],"mappings":";AACA;;;;;;;;;;;;;GAaG;AAKH,MAAM,MAAM,cAAc,GAAG;IAC3B,gEAAgE;IAChE,OAAO,EAAE,MAAM,CAAC;IAChB,6CAA6C;IAC7C,SAAS,EAAE,cAAc,EAAE,CAAC;IAC5B,iCAAiC;IACjC,iBAAiB,EAAE,MAAM,EAAE,CAAC;IAC5B,uCAAuC;IACvC,WAAW,EAAE,MAAM,CAAC;IACpB,+CAA+C;IAC/C,cAAc,CAAC,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"load-test-utils.d.ts","sourceRoot":"","sources":["../../scripts/load-test-utils.ts"],"names":[],"mappings":"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"}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Unlike benchmark.ts (which tests raw SQLite pool.exec() calls), these utilities
|
|
6
6
|
* measure end-to-end HTTP performance through the full stack: HTTP server, middleware,
|
|
7
|
-
* ORM
|
|
7
|
+
* the ORM/query layer, SSR rendering, etc.
|
|
8
8
|
*
|
|
9
9
|
* Usage:
|
|
10
10
|
* Import into app-specific load tests:
|
|
@@ -119,6 +119,72 @@ async function runScenario(baseUrl, endpoint, concurrency, durationSec, warmupRe
|
|
|
119
119
|
avgBodySize: results.length > 0 ? totalBodySize / results.length : 0
|
|
120
120
|
};
|
|
121
121
|
}
|
|
122
|
+
/* ------------------------------------------------------------------
|
|
123
|
+
* Multi-run aggregation
|
|
124
|
+
* ------------------------------------------------------------------ */
|
|
125
|
+
function median(values) {
|
|
126
|
+
if (values.length === 0)
|
|
127
|
+
return 0;
|
|
128
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
129
|
+
const mid = Math.floor(sorted.length / 2);
|
|
130
|
+
return sorted.length % 2 !== 0
|
|
131
|
+
? sorted[mid]
|
|
132
|
+
: (sorted[mid - 1] + sorted[mid]) / 2;
|
|
133
|
+
}
|
|
134
|
+
function coefficientOfVariation(values) {
|
|
135
|
+
if (values.length < 2)
|
|
136
|
+
return 0;
|
|
137
|
+
const mean = values.reduce((a, b) => a + b, 0) / values.length;
|
|
138
|
+
if (mean === 0)
|
|
139
|
+
return 0;
|
|
140
|
+
const variance = values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / values.length;
|
|
141
|
+
return (Math.sqrt(variance) / mean) * 100;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Collapse N per-run ScenarioResults into one. Threshold-checked fields
|
|
145
|
+
* (throughput, successRate) use the median so a single outlier run does not
|
|
146
|
+
* flip pass/fail; counts are summed; variance fields expose dispersion.
|
|
147
|
+
* A single run is returned unchanged (no variance fields → identical shape).
|
|
148
|
+
*/
|
|
149
|
+
function aggregateRuns(runs) {
|
|
150
|
+
if (runs.length === 1)
|
|
151
|
+
return runs[0];
|
|
152
|
+
const first = runs[0];
|
|
153
|
+
const throughputs = runs.map(r => r.throughput);
|
|
154
|
+
const statusCodes = {};
|
|
155
|
+
for (const r of runs) {
|
|
156
|
+
for (const [code, count] of Object.entries(r.statusCodes)) {
|
|
157
|
+
statusCodes[Number(code)] = (statusCodes[Number(code)] ?? 0) + count;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const p95Median = median(runs.map(r => r.p95));
|
|
161
|
+
return {
|
|
162
|
+
endpoint: first.endpoint,
|
|
163
|
+
label: first.label,
|
|
164
|
+
method: first.method,
|
|
165
|
+
concurrency: first.concurrency,
|
|
166
|
+
durationSec: first.durationSec,
|
|
167
|
+
totalRequests: runs.reduce((s, r) => s + r.totalRequests, 0),
|
|
168
|
+
successCount: runs.reduce((s, r) => s + r.successCount, 0),
|
|
169
|
+
failCount: runs.reduce((s, r) => s + r.failCount, 0),
|
|
170
|
+
successRate: median(runs.map(r => r.successRate)),
|
|
171
|
+
throughput: median(throughputs),
|
|
172
|
+
p50: median(runs.map(r => r.p50)),
|
|
173
|
+
p95: p95Median,
|
|
174
|
+
p99: median(runs.map(r => r.p99)),
|
|
175
|
+
min: Math.min(...runs.map(r => r.min)),
|
|
176
|
+
max: Math.max(...runs.map(r => r.max)),
|
|
177
|
+
avg: runs.reduce((s, r) => s + r.avg, 0) / runs.length,
|
|
178
|
+
statusCodes,
|
|
179
|
+
avgBodySize: runs.reduce((s, r) => s + r.avgBodySize, 0) / runs.length,
|
|
180
|
+
via: first.via,
|
|
181
|
+
runs: runs.length,
|
|
182
|
+
throughputMin: Math.min(...throughputs),
|
|
183
|
+
throughputMax: Math.max(...throughputs),
|
|
184
|
+
throughputCoV: coefficientOfVariation(throughputs),
|
|
185
|
+
p95Median
|
|
186
|
+
};
|
|
187
|
+
}
|
|
122
188
|
/* ------------------------------------------------------------------
|
|
123
189
|
* Main load test runner
|
|
124
190
|
* ------------------------------------------------------------------ */
|
|
@@ -126,11 +192,14 @@ export async function runLoadTest(config) {
|
|
|
126
192
|
const startedAt = new Date().toISOString();
|
|
127
193
|
const scenarios = [];
|
|
128
194
|
const warmup = config.warmupRequests ?? 5;
|
|
195
|
+
const repeat = Math.max(1, config.repeat ?? 1);
|
|
129
196
|
for (const endpoint of config.endpoints) {
|
|
130
197
|
for (const concurrency of config.concurrencyLevels) {
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
198
|
+
const runs = [];
|
|
199
|
+
for (let i = 0; i < repeat; i++) {
|
|
200
|
+
runs.push(await runScenario(config.baseUrl, endpoint, concurrency, config.durationSec, warmup));
|
|
201
|
+
}
|
|
202
|
+
scenarios.push(aggregateRuns(runs));
|
|
134
203
|
}
|
|
135
204
|
}
|
|
136
205
|
return {
|
|
@@ -153,8 +222,11 @@ export function formatResults(results) {
|
|
|
153
222
|
lines.push(` Completed: ${results.completedAt}`);
|
|
154
223
|
lines.push('='.repeat(100));
|
|
155
224
|
lines.push('');
|
|
225
|
+
// Show the variance column only when at least one scenario was repeated.
|
|
226
|
+
// Single-run output keeps its original columns unchanged.
|
|
227
|
+
const showCoV = results.scenarios.some(s => (s.runs ?? 1) > 1);
|
|
156
228
|
// Summary table header
|
|
157
|
-
const
|
|
229
|
+
const headerCols = [
|
|
158
230
|
'Endpoint'.padEnd(25),
|
|
159
231
|
'Conc'.padStart(5),
|
|
160
232
|
'Req/s'.padStart(8),
|
|
@@ -164,11 +236,15 @@ export function formatResults(results) {
|
|
|
164
236
|
'P99ms'.padStart(8),
|
|
165
237
|
'Success'.padStart(8),
|
|
166
238
|
'AvgBody'.padStart(8)
|
|
167
|
-
]
|
|
239
|
+
];
|
|
240
|
+
if (showCoV) {
|
|
241
|
+
headerCols.push('Runs'.padStart(5), 'CoV%'.padStart(7));
|
|
242
|
+
}
|
|
243
|
+
const header = headerCols.join(' | ');
|
|
168
244
|
lines.push(header);
|
|
169
245
|
lines.push('-'.repeat(header.length));
|
|
170
246
|
for (const s of results.scenarios) {
|
|
171
|
-
const
|
|
247
|
+
const cols = [
|
|
172
248
|
s.label.padEnd(25).slice(0, 25),
|
|
173
249
|
String(s.concurrency).padStart(5),
|
|
174
250
|
s.throughput.toFixed(0).padStart(8),
|
|
@@ -178,15 +254,24 @@ export function formatResults(results) {
|
|
|
178
254
|
s.p99.toFixed(1).padStart(8),
|
|
179
255
|
`${s.successRate.toFixed(1)}%`.padStart(8),
|
|
180
256
|
formatBytes(s.avgBodySize).padStart(8)
|
|
181
|
-
]
|
|
182
|
-
|
|
257
|
+
];
|
|
258
|
+
if (showCoV) {
|
|
259
|
+
cols.push(String(s.runs ?? 1).padStart(5), (s.throughputCoV !== undefined
|
|
260
|
+
? `±${s.throughputCoV.toFixed(0)}%`
|
|
261
|
+
: '-').padStart(7));
|
|
262
|
+
}
|
|
263
|
+
lines.push(cols.join(' | '));
|
|
183
264
|
}
|
|
184
265
|
lines.push('');
|
|
185
|
-
//
|
|
266
|
+
// Stack-overhead note
|
|
186
267
|
lines.push('-'.repeat(100));
|
|
187
|
-
lines.push(' NOTE:
|
|
188
|
-
lines.push('
|
|
189
|
-
lines.push('
|
|
268
|
+
lines.push(' NOTE: Application-level throughput (full HTTP stack) is lower than raw');
|
|
269
|
+
lines.push(' pool.exec() benchmarks due to HTTP overhead, middleware, the ORM/query');
|
|
270
|
+
lines.push(' layer, SSR rendering, and serialization.');
|
|
271
|
+
if (showCoV) {
|
|
272
|
+
lines.push(' CoV% = coefficient of variation of throughput across repeated runs');
|
|
273
|
+
lines.push(' (higher = noisier host; treat deltas below CoV% as noise).');
|
|
274
|
+
}
|
|
190
275
|
lines.push('-'.repeat(100));
|
|
191
276
|
lines.push('');
|
|
192
277
|
// Status code breakdown
|
package/package.json
CHANGED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
formatResults,
|
|
4
|
+
type LoadTestResults,
|
|
5
|
+
type ScenarioResult
|
|
6
|
+
} from './load-test-utils';
|
|
7
|
+
|
|
8
|
+
function makeScenario(overrides: Partial<ScenarioResult> = {}): ScenarioResult {
|
|
9
|
+
return {
|
|
10
|
+
endpoint: '/dashboard',
|
|
11
|
+
label: 'Dashboard',
|
|
12
|
+
method: 'GET',
|
|
13
|
+
concurrency: 100,
|
|
14
|
+
durationSec: 10,
|
|
15
|
+
totalRequests: 12_000,
|
|
16
|
+
successCount: 12_000,
|
|
17
|
+
failCount: 0,
|
|
18
|
+
successRate: 100,
|
|
19
|
+
throughput: 1200,
|
|
20
|
+
p50: 60,
|
|
21
|
+
p95: 150,
|
|
22
|
+
p99: 240,
|
|
23
|
+
min: 5,
|
|
24
|
+
max: 400,
|
|
25
|
+
avg: 70,
|
|
26
|
+
statusCodes: { 200: 12_000 },
|
|
27
|
+
avgBodySize: 6400,
|
|
28
|
+
...overrides
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function makeResults(scenarios: ScenarioResult[]): LoadTestResults {
|
|
33
|
+
return {
|
|
34
|
+
baseUrl: 'http://localhost:3000',
|
|
35
|
+
startedAt: new Date().toISOString(),
|
|
36
|
+
completedAt: new Date().toISOString(),
|
|
37
|
+
scenarios
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('formatResults — stack-overhead note', () => {
|
|
42
|
+
test('given any results, when formatted, then note does not mention Prisma', () => {
|
|
43
|
+
const report = formatResults(makeResults([makeScenario()]));
|
|
44
|
+
expect(report).not.toContain('Prisma');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('given any results, when formatted, then note omits stale hardcoded pool numbers', () => {
|
|
48
|
+
const report = formatResults(makeResults([makeScenario()]));
|
|
49
|
+
expect(report).not.toContain('6,102-13,781');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('given any results, when formatted, then note credits the ORM/query layer generically', () => {
|
|
53
|
+
const report = formatResults(makeResults([makeScenario()]));
|
|
54
|
+
expect(report).toContain('ORM/query');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('formatResults — variance (CoV) column', () => {
|
|
59
|
+
test('given single-run scenarios, when formatted, then no CoV column is shown', () => {
|
|
60
|
+
const report = formatResults(makeResults([makeScenario()]));
|
|
61
|
+
expect(report).not.toContain('CoV%');
|
|
62
|
+
expect(report).not.toContain('Runs');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('given a repeated scenario, when formatted, then a CoV column appears', () => {
|
|
66
|
+
const report = formatResults(
|
|
67
|
+
makeResults([
|
|
68
|
+
makeScenario({ runs: 3, throughputCoV: 18, p95Median: 150 })
|
|
69
|
+
])
|
|
70
|
+
);
|
|
71
|
+
expect(report).toContain('CoV%');
|
|
72
|
+
expect(report).toContain('±18%');
|
|
73
|
+
expect(report).toContain('coefficient of variation');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Unlike benchmark.ts (which tests raw SQLite pool.exec() calls), these utilities
|
|
6
6
|
* measure end-to-end HTTP performance through the full stack: HTTP server, middleware,
|
|
7
|
-
* ORM
|
|
7
|
+
* the ORM/query layer, SSR rendering, etc.
|
|
8
8
|
*
|
|
9
9
|
* Usage:
|
|
10
10
|
* Import into app-specific load tests:
|
|
@@ -28,6 +28,14 @@ export type LoadTestConfig = {
|
|
|
28
28
|
durationSec: number;
|
|
29
29
|
/** Optional: warm-up requests before timing */
|
|
30
30
|
warmupRequests?: number;
|
|
31
|
+
/**
|
|
32
|
+
* Optional: number of times to repeat each scenario (default 1).
|
|
33
|
+
* When > 1, each (endpoint, concurrency) runs N times and results are
|
|
34
|
+
* aggregated — medians for throughput/latency, plus a coefficient of
|
|
35
|
+
* variation so run-to-run dispersion is visible. Defends against the
|
|
36
|
+
* ~±30% single-run variance seen on virtualized hosts (e.g. WSL2).
|
|
37
|
+
*/
|
|
38
|
+
repeat?: number;
|
|
31
39
|
};
|
|
32
40
|
|
|
33
41
|
export type EndpointConfig = {
|
|
@@ -75,6 +83,21 @@ export type ScenarioResult = {
|
|
|
75
83
|
avgBodySize: number;
|
|
76
84
|
|
|
77
85
|
via?: 'cdn' | 'direct';
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Variance fields — only populated when the scenario was run more than
|
|
89
|
+
* once (LoadTestConfig.repeat > 1). Absent for single-run scenarios, so
|
|
90
|
+
* the single-run output shape is unchanged.
|
|
91
|
+
*/
|
|
92
|
+
runs?: number;
|
|
93
|
+
/** Lowest per-run throughput (req/s) across the N runs. */
|
|
94
|
+
throughputMin?: number;
|
|
95
|
+
/** Highest per-run throughput (req/s) across the N runs. */
|
|
96
|
+
throughputMax?: number;
|
|
97
|
+
/** Coefficient of variation of throughput across runs, as a percent. */
|
|
98
|
+
throughputCoV?: number;
|
|
99
|
+
/** Median of the per-run p95 values (NOT p95 of pooled latencies). */
|
|
100
|
+
p95Median?: number;
|
|
78
101
|
};
|
|
79
102
|
|
|
80
103
|
export type LoadTestResults = {
|
|
@@ -226,6 +249,82 @@ async function runScenario(
|
|
|
226
249
|
};
|
|
227
250
|
}
|
|
228
251
|
|
|
252
|
+
/* ------------------------------------------------------------------
|
|
253
|
+
* Multi-run aggregation
|
|
254
|
+
* ------------------------------------------------------------------ */
|
|
255
|
+
function median(values: number[]): number {
|
|
256
|
+
if (values.length === 0) return 0;
|
|
257
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
258
|
+
const mid = Math.floor(sorted.length / 2);
|
|
259
|
+
return sorted.length % 2 !== 0
|
|
260
|
+
? sorted[mid]!
|
|
261
|
+
: (sorted[mid - 1]! + sorted[mid]!) / 2;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function coefficientOfVariation(values: number[]): number {
|
|
265
|
+
if (values.length < 2) return 0;
|
|
266
|
+
const mean = values.reduce((a, b) => a + b, 0) / values.length;
|
|
267
|
+
if (mean === 0) return 0;
|
|
268
|
+
const variance =
|
|
269
|
+
values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / values.length;
|
|
270
|
+
return (Math.sqrt(variance) / mean) * 100;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Collapse N per-run ScenarioResults into one. Threshold-checked fields
|
|
275
|
+
* (throughput, successRate) use the median so a single outlier run does not
|
|
276
|
+
* flip pass/fail; counts are summed; variance fields expose dispersion.
|
|
277
|
+
* A single run is returned unchanged (no variance fields → identical shape).
|
|
278
|
+
*/
|
|
279
|
+
function aggregateRuns(runs: ScenarioResult[]): ScenarioResult {
|
|
280
|
+
if (runs.length === 1) return runs[0]!;
|
|
281
|
+
|
|
282
|
+
const first = runs[0]!;
|
|
283
|
+
const throughputs = runs.map(r => r.throughput);
|
|
284
|
+
|
|
285
|
+
const statusCodes: Record<number, number> = {};
|
|
286
|
+
for (const r of runs) {
|
|
287
|
+
for (const [code, count] of Object.entries(r.statusCodes)) {
|
|
288
|
+
statusCodes[Number(code)] = (statusCodes[Number(code)] ?? 0) + count;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const p95Median = median(runs.map(r => r.p95));
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
endpoint: first.endpoint,
|
|
296
|
+
label: first.label,
|
|
297
|
+
method: first.method,
|
|
298
|
+
concurrency: first.concurrency,
|
|
299
|
+
durationSec: first.durationSec,
|
|
300
|
+
|
|
301
|
+
totalRequests: runs.reduce((s, r) => s + r.totalRequests, 0),
|
|
302
|
+
successCount: runs.reduce((s, r) => s + r.successCount, 0),
|
|
303
|
+
failCount: runs.reduce((s, r) => s + r.failCount, 0),
|
|
304
|
+
successRate: median(runs.map(r => r.successRate)),
|
|
305
|
+
|
|
306
|
+
throughput: median(throughputs),
|
|
307
|
+
|
|
308
|
+
p50: median(runs.map(r => r.p50)),
|
|
309
|
+
p95: p95Median,
|
|
310
|
+
p99: median(runs.map(r => r.p99)),
|
|
311
|
+
min: Math.min(...runs.map(r => r.min)),
|
|
312
|
+
max: Math.max(...runs.map(r => r.max)),
|
|
313
|
+
avg: runs.reduce((s, r) => s + r.avg, 0) / runs.length,
|
|
314
|
+
|
|
315
|
+
statusCodes,
|
|
316
|
+
avgBodySize: runs.reduce((s, r) => s + r.avgBodySize, 0) / runs.length,
|
|
317
|
+
|
|
318
|
+
via: first.via,
|
|
319
|
+
|
|
320
|
+
runs: runs.length,
|
|
321
|
+
throughputMin: Math.min(...throughputs),
|
|
322
|
+
throughputMax: Math.max(...throughputs),
|
|
323
|
+
throughputCoV: coefficientOfVariation(throughputs),
|
|
324
|
+
p95Median
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
229
328
|
/* ------------------------------------------------------------------
|
|
230
329
|
* Main load test runner
|
|
231
330
|
* ------------------------------------------------------------------ */
|
|
@@ -235,19 +334,23 @@ export async function runLoadTest(
|
|
|
235
334
|
const startedAt = new Date().toISOString();
|
|
236
335
|
const scenarios: ScenarioResult[] = [];
|
|
237
336
|
const warmup = config.warmupRequests ?? 5;
|
|
337
|
+
const repeat = Math.max(1, config.repeat ?? 1);
|
|
238
338
|
|
|
239
339
|
for (const endpoint of config.endpoints) {
|
|
240
340
|
for (const concurrency of config.concurrencyLevels) {
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
341
|
+
const runs: ScenarioResult[] = [];
|
|
342
|
+
for (let i = 0; i < repeat; i++) {
|
|
343
|
+
runs.push(
|
|
344
|
+
await runScenario(
|
|
345
|
+
config.baseUrl,
|
|
346
|
+
endpoint,
|
|
347
|
+
concurrency,
|
|
348
|
+
config.durationSec,
|
|
349
|
+
warmup
|
|
350
|
+
)
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
scenarios.push(aggregateRuns(runs));
|
|
251
354
|
}
|
|
252
355
|
}
|
|
253
356
|
|
|
@@ -274,8 +377,12 @@ export function formatResults(results: LoadTestResults): string {
|
|
|
274
377
|
lines.push('='.repeat(100));
|
|
275
378
|
lines.push('');
|
|
276
379
|
|
|
380
|
+
// Show the variance column only when at least one scenario was repeated.
|
|
381
|
+
// Single-run output keeps its original columns unchanged.
|
|
382
|
+
const showCoV = results.scenarios.some(s => (s.runs ?? 1) > 1);
|
|
383
|
+
|
|
277
384
|
// Summary table header
|
|
278
|
-
const
|
|
385
|
+
const headerCols = [
|
|
279
386
|
'Endpoint'.padEnd(25),
|
|
280
387
|
'Conc'.padStart(5),
|
|
281
388
|
'Req/s'.padStart(8),
|
|
@@ -285,13 +392,17 @@ export function formatResults(results: LoadTestResults): string {
|
|
|
285
392
|
'P99ms'.padStart(8),
|
|
286
393
|
'Success'.padStart(8),
|
|
287
394
|
'AvgBody'.padStart(8)
|
|
288
|
-
]
|
|
395
|
+
];
|
|
396
|
+
if (showCoV) {
|
|
397
|
+
headerCols.push('Runs'.padStart(5), 'CoV%'.padStart(7));
|
|
398
|
+
}
|
|
399
|
+
const header = headerCols.join(' | ');
|
|
289
400
|
|
|
290
401
|
lines.push(header);
|
|
291
402
|
lines.push('-'.repeat(header.length));
|
|
292
403
|
|
|
293
404
|
for (const s of results.scenarios) {
|
|
294
|
-
const
|
|
405
|
+
const cols = [
|
|
295
406
|
s.label.padEnd(25).slice(0, 25),
|
|
296
407
|
String(s.concurrency).padStart(5),
|
|
297
408
|
s.throughput.toFixed(0).padStart(8),
|
|
@@ -301,21 +412,36 @@ export function formatResults(results: LoadTestResults): string {
|
|
|
301
412
|
s.p99.toFixed(1).padStart(8),
|
|
302
413
|
`${s.successRate.toFixed(1)}%`.padStart(8),
|
|
303
414
|
formatBytes(s.avgBodySize).padStart(8)
|
|
304
|
-
]
|
|
305
|
-
|
|
415
|
+
];
|
|
416
|
+
if (showCoV) {
|
|
417
|
+
cols.push(
|
|
418
|
+
String(s.runs ?? 1).padStart(5),
|
|
419
|
+
(s.throughputCoV !== undefined
|
|
420
|
+
? `±${s.throughputCoV.toFixed(0)}%`
|
|
421
|
+
: '-'
|
|
422
|
+
).padStart(7)
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
lines.push(cols.join(' | '));
|
|
306
426
|
}
|
|
307
427
|
|
|
308
428
|
lines.push('');
|
|
309
429
|
|
|
310
|
-
//
|
|
430
|
+
// Stack-overhead note
|
|
311
431
|
lines.push('-'.repeat(100));
|
|
312
432
|
lines.push(
|
|
313
|
-
' NOTE:
|
|
433
|
+
' NOTE: Application-level throughput (full HTTP stack) is lower than raw'
|
|
314
434
|
);
|
|
315
435
|
lines.push(
|
|
316
|
-
'
|
|
436
|
+
' pool.exec() benchmarks due to HTTP overhead, middleware, the ORM/query'
|
|
317
437
|
);
|
|
318
|
-
lines.push('
|
|
438
|
+
lines.push(' layer, SSR rendering, and serialization.');
|
|
439
|
+
if (showCoV) {
|
|
440
|
+
lines.push(
|
|
441
|
+
' CoV% = coefficient of variation of throughput across repeated runs'
|
|
442
|
+
);
|
|
443
|
+
lines.push(' (higher = noisier host; treat deltas below CoV% as noise).');
|
|
444
|
+
}
|
|
319
445
|
lines.push('-'.repeat(100));
|
|
320
446
|
lines.push('');
|
|
321
447
|
|