@untestutils/perf 0.5.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.
- package/LICENSE +21 -0
- package/dist/average.d.mts +4 -0
- package/dist/average.d.ts +5 -0
- package/dist/average.d.ts.map +1 -0
- package/dist/average.mjs +92 -0
- package/dist/build.d.mts +2 -0
- package/dist/build.d.ts +3 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.mjs +69 -0
- package/dist/bundle.d.mts +2 -0
- package/dist/bundle.d.ts +3 -0
- package/dist/bundle.d.ts.map +1 -0
- package/dist/bundle.mjs +42 -0
- package/dist/define.d.mts +2 -0
- package/dist/define.d.ts +3 -0
- package/dist/define.d.ts.map +1 -0
- package/dist/define.mjs +3 -0
- package/dist/format.d.mts +2 -0
- package/dist/format.d.ts +3 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.mjs +15 -0
- package/dist/index.d.mts +17 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +15 -0
- package/dist/load/artillery.d.mts +10 -0
- package/dist/load/artillery.d.ts +11 -0
- package/dist/load/artillery.d.ts.map +1 -0
- package/dist/load/artillery.mjs +77 -0
- package/dist/load/autocannon.d.mts +9 -0
- package/dist/load/autocannon.d.ts +10 -0
- package/dist/load/autocannon.d.ts.map +1 -0
- package/dist/load/autocannon.mjs +96 -0
- package/dist/load/index.d.mts +7 -0
- package/dist/load/index.d.ts +8 -0
- package/dist/load/index.d.ts.map +1 -0
- package/dist/load/index.mjs +48 -0
- package/dist/load/parse.d.mts +5 -0
- package/dist/load/parse.d.ts +6 -0
- package/dist/load/parse.d.ts.map +1 -0
- package/dist/load/parse.mjs +8 -0
- package/dist/process-sample.d.ts +31 -0
- package/dist/process-sample.d.ts.map +1 -0
- package/dist/process-sample.mjs +64 -0
- package/dist/report/console.d.mts +2 -0
- package/dist/report/console.d.ts +3 -0
- package/dist/report/console.d.ts.map +1 -0
- package/dist/report/console.mjs +25 -0
- package/dist/report/json.d.mts +4 -0
- package/dist/report/json.d.ts +5 -0
- package/dist/report/json.d.ts.map +1 -0
- package/dist/report/json.mjs +19 -0
- package/dist/run.d.mts +5 -0
- package/dist/run.d.ts +6 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/run.mjs +88 -0
- package/dist/start.d.ts +12 -0
- package/dist/start.d.ts.map +1 -0
- package/dist/start.mjs +114 -0
- package/dist/thresholds.d.mts +8 -0
- package/dist/thresholds.d.ts +9 -0
- package/dist/thresholds.d.ts.map +1 -0
- package/dist/thresholds.mjs +48 -0
- package/dist/types.d.mts +172 -0
- package/dist/types.d.ts +173 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.mjs +1 -0
- package/package.json +60 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { runArtillery } from "./artillery.mjs";
|
|
2
|
+
import { runAutocannon } from "./autocannon.mjs";
|
|
3
|
+
export async function runLoadPhase(opts) {
|
|
4
|
+
const { target, started, artifactsDir } = opts;
|
|
5
|
+
const load = target.load;
|
|
6
|
+
const base = started.takeProcessMetrics();
|
|
7
|
+
if (!load) return base;
|
|
8
|
+
let autocannon;
|
|
9
|
+
if (load.autocannon) {
|
|
10
|
+
const ac = load.autocannon === true ? {} : load.autocannon;
|
|
11
|
+
console.log(` → autocannon ${ac.connections ?? 10}c × ${ac.durationSec ?? 10}s @ ${started.url}`);
|
|
12
|
+
autocannon = await runAutocannon({
|
|
13
|
+
url: started.url,
|
|
14
|
+
connections: ac.connections,
|
|
15
|
+
durationSec: ac.durationSec,
|
|
16
|
+
artifactsDir,
|
|
17
|
+
name: target.id
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
let artillery;
|
|
21
|
+
if (load.artillery?.config) {
|
|
22
|
+
console.log(` → artillery ${load.artillery.config}`);
|
|
23
|
+
artillery = await runArtillery({
|
|
24
|
+
configPath: load.artillery.config,
|
|
25
|
+
artifactsDir,
|
|
26
|
+
name: target.id,
|
|
27
|
+
cwd: target.root,
|
|
28
|
+
targetUrl: started.url
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
const summary = artillery?.aggregate;
|
|
32
|
+
const durationSec = summary ? (summary.lastMetricAt - summary.firstMetricAt) / 1e3 : undefined;
|
|
33
|
+
const rt = summary?.summaries["http.response_time"];
|
|
34
|
+
return {
|
|
35
|
+
...base,
|
|
36
|
+
durationSec,
|
|
37
|
+
responseTimeAvg: rt?.mean ?? autocannon?.latency.average,
|
|
38
|
+
responseTimeMin: rt?.min ?? autocannon?.latency.min,
|
|
39
|
+
responseTimeMax: rt?.max ?? autocannon?.latency.max,
|
|
40
|
+
responseTimeP50: rt?.p50 ?? autocannon?.latency.p50,
|
|
41
|
+
responseTimeP95: rt?.p95 ?? autocannon?.latency.p97_5,
|
|
42
|
+
responseTimeP99: rt?.p99 ?? autocannon?.latency.p99,
|
|
43
|
+
requestsPerSecond: summary?.rates["http.request_rate"] ?? autocannon?.requests.average,
|
|
44
|
+
errorRate: summary?.counters["http.codes.500"] && summary?.counters["http.requests"] ? summary.counters["http.codes.500"] / summary.counters["http.requests"] * 100 : autocannon ? autocannon.errors / Math.max(1, autocannon.requests.total) * 100 : undefined,
|
|
45
|
+
autocannon,
|
|
46
|
+
artillery
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ArtilleryResult, AutocannonResult } from "../types.mjs";
|
|
2
|
+
/** Parse autocannon `-j` stdout into a typed result. */
|
|
3
|
+
export declare function parseAutocannonJson(raw: string): AutocannonResult;
|
|
4
|
+
/** Parse Artillery `--output` JSON into a typed result. */
|
|
5
|
+
export declare function parseArtilleryJson(raw: string): ArtilleryResult;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ArtilleryResult, AutocannonResult } from '../types';
|
|
2
|
+
/** Parse autocannon `-j` stdout into a typed result. */
|
|
3
|
+
export declare function parseAutocannonJson(raw: string): AutocannonResult;
|
|
4
|
+
/** Parse Artillery `--output` JSON into a typed result. */
|
|
5
|
+
export declare function parseArtilleryJson(raw: string): ArtilleryResult;
|
|
6
|
+
//# sourceMappingURL=parse.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"parse.d.ts","sourceRoot":"","sources":["../../src/load/parse.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAC;AAElE,wDAAwD;AACxD,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,gBAAgB,CAEjE;AAED,2DAA2D;AAC3D,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,eAAe,CAE/D"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type ProcessSample = {
|
|
2
|
+
cpu: number;
|
|
3
|
+
memoryMb: number;
|
|
4
|
+
};
|
|
5
|
+
/** Sample CPU% and RSS (MB) for a PID via `ps` (Unix). Returns null if gone. */
|
|
6
|
+
export declare function sampleProcess(pid: number): ProcessSample | null;
|
|
7
|
+
export type SampleAccumulator = {
|
|
8
|
+
maxMemoryMb: number;
|
|
9
|
+
minMemoryMb: number;
|
|
10
|
+
maxCpuPct: number;
|
|
11
|
+
minCpuPct: number;
|
|
12
|
+
totalMemoryMb: number;
|
|
13
|
+
totalCpuPct: number;
|
|
14
|
+
samples: number;
|
|
15
|
+
};
|
|
16
|
+
export declare function createSampleAccumulator(): SampleAccumulator;
|
|
17
|
+
export declare function pushSample(acc: SampleAccumulator, sample: ProcessSample): void;
|
|
18
|
+
export declare function finalizeSamples(acc: SampleAccumulator): {
|
|
19
|
+
maxMemoryMb: number;
|
|
20
|
+
minMemoryMb: number;
|
|
21
|
+
avgMemoryMb: number;
|
|
22
|
+
maxCpuPct: number;
|
|
23
|
+
minCpuPct: number;
|
|
24
|
+
avgCpuPct: number;
|
|
25
|
+
};
|
|
26
|
+
/** Poll `sampleProcess` until stopped. */
|
|
27
|
+
export declare function startProcessMonitor(pid: number, intervalMs?: number, onSample?: (s: ProcessSample) => void): {
|
|
28
|
+
acc: SampleAccumulator;
|
|
29
|
+
stop: () => void;
|
|
30
|
+
};
|
|
31
|
+
//# sourceMappingURL=process-sample.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"process-sample.d.ts","sourceRoot":"","sources":["../src/process-sample.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,aAAa,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC;AAE9D,gFAAgF;AAChF,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,aAAa,GAAG,IAAI,CAiB/D;AAED,MAAM,MAAM,iBAAiB,GAAG;IAC9B,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF,wBAAgB,uBAAuB,IAAI,iBAAiB,CAU3D;AAED,wBAAgB,UAAU,CAAC,GAAG,EAAE,iBAAiB,EAAE,MAAM,EAAE,aAAa,GAAG,IAAI,CAQ9E;AAED,wBAAgB,eAAe,CAAC,GAAG,EAAE,iBAAiB;;;;;;;EASrD;AAED,0CAA0C;AAC1C,wBAAgB,mBAAmB,CACjC,GAAG,EAAE,MAAM,EACX,UAAU,SAAO,EACjB,QAAQ,CAAC,EAAE,CAAC,CAAC,EAAE,aAAa,KAAK,IAAI,GACpC;IAAE,GAAG,EAAE,iBAAiB,CAAC;IAAC,IAAI,EAAE,MAAM,IAAI,CAAA;CAAE,CAY9C"}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export function sampleProcess(pid) {
|
|
4
|
+
if (!pid || pid <= 0) return null;
|
|
5
|
+
try {
|
|
6
|
+
const result = execSync(`ps -p ${pid} -o %cpu,rss`, {
|
|
7
|
+
encoding: "utf-8",
|
|
8
|
+
stdio: "pipe"
|
|
9
|
+
}).toString();
|
|
10
|
+
const lines = result.trim().split("\n");
|
|
11
|
+
if (lines.length < 2 || !lines[1]?.trim()) return null;
|
|
12
|
+
const parts = lines[1].trim().split(/\s+/).map(Number.parseFloat);
|
|
13
|
+
return {
|
|
14
|
+
cpu: parts[0] || 0,
|
|
15
|
+
memoryMb: parts[1] ? parts[1] / 1024 : 0
|
|
16
|
+
};
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function createSampleAccumulator() {
|
|
22
|
+
return {
|
|
23
|
+
maxMemoryMb: 0,
|
|
24
|
+
minMemoryMb: Infinity,
|
|
25
|
+
maxCpuPct: 0,
|
|
26
|
+
minCpuPct: Infinity,
|
|
27
|
+
totalMemoryMb: 0,
|
|
28
|
+
totalCpuPct: 0,
|
|
29
|
+
samples: 0
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export function pushSample(acc, sample) {
|
|
33
|
+
acc.maxCpuPct = Math.max(acc.maxCpuPct, sample.cpu);
|
|
34
|
+
acc.minCpuPct = Math.min(acc.minCpuPct, sample.cpu);
|
|
35
|
+
acc.totalCpuPct += sample.cpu;
|
|
36
|
+
acc.maxMemoryMb = Math.max(acc.maxMemoryMb, sample.memoryMb);
|
|
37
|
+
acc.minMemoryMb = Math.min(acc.minMemoryMb, sample.memoryMb);
|
|
38
|
+
acc.totalMemoryMb += sample.memoryMb;
|
|
39
|
+
acc.samples++;
|
|
40
|
+
}
|
|
41
|
+
export function finalizeSamples(acc) {
|
|
42
|
+
return {
|
|
43
|
+
maxMemoryMb: acc.maxMemoryMb,
|
|
44
|
+
minMemoryMb: Number.isFinite(acc.minMemoryMb) ? acc.minMemoryMb : 0,
|
|
45
|
+
avgMemoryMb: acc.samples > 0 ? acc.totalMemoryMb / acc.samples : 0,
|
|
46
|
+
maxCpuPct: acc.maxCpuPct,
|
|
47
|
+
minCpuPct: Number.isFinite(acc.minCpuPct) ? acc.minCpuPct : 0,
|
|
48
|
+
avgCpuPct: acc.samples > 0 ? acc.totalCpuPct / acc.samples : 0
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function startProcessMonitor(pid, intervalMs = 1e3, onSample) {
|
|
53
|
+
const acc = createSampleAccumulator();
|
|
54
|
+
const timer = setInterval(() => {
|
|
55
|
+
const s = sampleProcess(pid);
|
|
56
|
+
if (!s) return;
|
|
57
|
+
pushSample(acc, s);
|
|
58
|
+
onSample?.(s);
|
|
59
|
+
}, intervalMs);
|
|
60
|
+
return {
|
|
61
|
+
acc,
|
|
62
|
+
stop: () => clearInterval(timer)
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"console.d.ts","sourceRoot":"","sources":["../../src/report/console.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAoB,MAAM,UAAU,CAAC;AAE/D,wBAAgB,eAAe,IAAI,YAAY,CA6B9C"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { formatBytes, formatSec } from "../format.mjs";
|
|
2
|
+
export function consoleReporter() {
|
|
3
|
+
return {
|
|
4
|
+
name: "console",
|
|
5
|
+
onTarget(result) {
|
|
6
|
+
const b = result.build;
|
|
7
|
+
console.log(`\n── ${result.label} (${result.id}) ──`);
|
|
8
|
+
console.log(` build ${formatSec(b.buildTimeSec)} · peak RSS ${b.maxMemoryMb.toFixed(0)} MB · CPU ${b.avgCpuPct.toFixed(0)}%`);
|
|
9
|
+
if (b.bundle) {
|
|
10
|
+
console.log(` bundle ${formatBytes(b.bundle.total)} (code ${formatBytes(b.bundle.code)}, asset ${formatBytes(b.bundle.asset)})`);
|
|
11
|
+
}
|
|
12
|
+
if (result.load) {
|
|
13
|
+
const l = result.load;
|
|
14
|
+
console.log(` load ${(l.requestsPerSecond ?? 0).toFixed(1)} RPS · p95 ${(l.responseTimeP95 ?? 0).toFixed(1)} ms · err ${(l.errorRate ?? 0).toFixed(2)}%`);
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
onEnd({ results, runs }) {
|
|
18
|
+
console.log(`\n══════════ summary (mean of ${runs}) ══════════`);
|
|
19
|
+
for (const r of results) {
|
|
20
|
+
const load = r.load ? ` · ${r.load.requestsPerSecond?.toFixed(0) ?? "—"} RPS` : "";
|
|
21
|
+
console.log(` ${r.label.padEnd(20)} build ${formatSec(r.build.buildTimeSec)}${load}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"json.d.ts","sourceRoot":"","sources":["../../src/report/json.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAE7C,wBAAgB,YAAY,CAAC,IAAI,CAAC,EAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,YAAY,CAclE"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "pathe";
|
|
3
|
+
export function jsonReporter(opts) {
|
|
4
|
+
return {
|
|
5
|
+
name: "json",
|
|
6
|
+
onEnd({ results, suite, runs, skipLoad }) {
|
|
7
|
+
const dir = opts?.dir ?? suite.artifactsDir ?? ".untestutils/perf";
|
|
8
|
+
mkdirSync(dir, { recursive: true });
|
|
9
|
+
const path = join(dir, `results-${Date.now()}.json`);
|
|
10
|
+
writeFileSync(path, JSON.stringify({
|
|
11
|
+
runs,
|
|
12
|
+
skipLoad,
|
|
13
|
+
results,
|
|
14
|
+
generatedAt: new Date().toISOString()
|
|
15
|
+
}, null, 2));
|
|
16
|
+
console.log(`Wrote JSON report: ${path}`);
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
}
|
package/dist/run.d.mts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { consoleReporter } from "./report/console.mjs";
|
|
2
|
+
import { jsonReporter } from "./report/json.mjs";
|
|
3
|
+
import type { PerfSuite, PerfTargetResult, RunPerfOptions } from "./types.mjs";
|
|
4
|
+
export declare function runPerfSuite(suite: PerfSuite, options?: RunPerfOptions): Promise<PerfTargetResult[]>;
|
|
5
|
+
export { consoleReporter, jsonReporter };
|
package/dist/run.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { consoleReporter } from './report/console';
|
|
2
|
+
import { jsonReporter } from './report/json';
|
|
3
|
+
import type { PerfSuite, PerfTargetResult, RunPerfOptions } from './types';
|
|
4
|
+
export declare function runPerfSuite(suite: PerfSuite, options?: RunPerfOptions): Promise<PerfTargetResult[]>;
|
|
5
|
+
export { consoleReporter, jsonReporter };
|
|
6
|
+
//# sourceMappingURL=run.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../src/run.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACnD,OAAO,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAG7C,OAAO,KAAK,EAAE,SAAS,EAAc,gBAAgB,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAsCvF,wBAAsB,YAAY,CAChC,KAAK,EAAE,SAAS,EAChB,OAAO,GAAE,cAAmB,GAC3B,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAuD7B;AAED,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,CAAC"}
|
package/dist/run.mjs
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
|
+
import { resolve } from "pathe";
|
|
4
|
+
import { averageTargetResults } from "./average.mjs";
|
|
5
|
+
import { measureBuild } from "./build.mjs";
|
|
6
|
+
import { runLoadPhase } from "./load/index.mjs";
|
|
7
|
+
import { consoleReporter } from "./report/console.mjs";
|
|
8
|
+
import { jsonReporter } from "./report/json.mjs";
|
|
9
|
+
import { startTarget } from "./start.mjs";
|
|
10
|
+
import { checkThresholds } from "./thresholds.mjs";
|
|
11
|
+
function selectTargets(suite, only) {
|
|
12
|
+
const filter = only ?? suite.only;
|
|
13
|
+
if (!filter || filter === "all") return suite.targets;
|
|
14
|
+
const ids = new Set(Array.isArray(filter) ? filter : [filter]);
|
|
15
|
+
return suite.targets.filter((t) => ids.has(t.id) || ids.has(t.label ?? ""));
|
|
16
|
+
}
|
|
17
|
+
async function runTargetOnce(target, skipLoad, artifactsDir) {
|
|
18
|
+
console.log(`\n══════════ ${target.label ?? target.id} ══════════`);
|
|
19
|
+
console.log(" → build");
|
|
20
|
+
const build = await measureBuild(target);
|
|
21
|
+
let load;
|
|
22
|
+
if (!skipLoad && target.load) {
|
|
23
|
+
await delay(1e3);
|
|
24
|
+
console.log(" → start + load");
|
|
25
|
+
const started = await startTarget(target);
|
|
26
|
+
try {
|
|
27
|
+
load = await runLoadPhase({
|
|
28
|
+
target,
|
|
29
|
+
started,
|
|
30
|
+
artifactsDir
|
|
31
|
+
});
|
|
32
|
+
} finally {
|
|
33
|
+
await started.stop();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
id: target.id,
|
|
38
|
+
label: target.label ?? target.id,
|
|
39
|
+
build,
|
|
40
|
+
load
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export async function runPerfSuite(suite, options = {}) {
|
|
44
|
+
const runs = options.runs ?? suite.runs ?? 1;
|
|
45
|
+
const skipLoad = options.skipLoad ?? suite.skipLoad ?? false;
|
|
46
|
+
const coolDownMs = suite.coolDownMs ?? 3e3;
|
|
47
|
+
const artifactsDir = resolve(suite.artifactsDir ?? ".untestutils/perf");
|
|
48
|
+
mkdirSync(artifactsDir, { recursive: true });
|
|
49
|
+
const targets = selectTargets(suite, options.only);
|
|
50
|
+
if (!targets.length) throw new Error("[untestutils/perf] no targets matched");
|
|
51
|
+
const reporters = [...suite.reporters ?? [consoleReporter()], ...options.json ? [jsonReporter({ dir: artifactsDir })] : []];
|
|
52
|
+
await suite.beforeAll?.(suite);
|
|
53
|
+
for (const r of reporters) await r.onStart?.(suite);
|
|
54
|
+
const averaged = [];
|
|
55
|
+
for (let ti = 0; ti < targets.length; ti++) {
|
|
56
|
+
const target = targets[ti];
|
|
57
|
+
await suite.beforeTarget?.(target);
|
|
58
|
+
const samples = [];
|
|
59
|
+
for (let run = 1; run <= runs; run++) {
|
|
60
|
+
console.log(`\n▸ ${target.label ?? target.id} · run ${run}/${runs}`);
|
|
61
|
+
samples.push(await runTargetOnce(target, skipLoad, artifactsDir));
|
|
62
|
+
if (run < runs) await delay(coolDownMs);
|
|
63
|
+
}
|
|
64
|
+
const mean = averageTargetResults(samples);
|
|
65
|
+
averaged.push(mean);
|
|
66
|
+
await suite.afterTarget?.(target, mean);
|
|
67
|
+
for (const r of reporters) await r.onTarget?.(mean);
|
|
68
|
+
if (ti < targets.length - 1) await delay(coolDownMs);
|
|
69
|
+
}
|
|
70
|
+
const ctx = {
|
|
71
|
+
suite,
|
|
72
|
+
results: averaged,
|
|
73
|
+
runs,
|
|
74
|
+
skipLoad
|
|
75
|
+
};
|
|
76
|
+
for (const r of reporters) await r.onEnd?.(ctx);
|
|
77
|
+
if (suite.thresholds) {
|
|
78
|
+
const failures = checkThresholds(averaged, suite.thresholds);
|
|
79
|
+
if (failures.length) {
|
|
80
|
+
for (const f of failures) {
|
|
81
|
+
console.error(`[untestutils/perf] threshold ${f.rule} failed for ${f.id}: ${f.actual} (limit ${f.limit})`);
|
|
82
|
+
}
|
|
83
|
+
process.exitCode = 1;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return averaged;
|
|
87
|
+
}
|
|
88
|
+
export { consoleReporter, jsonReporter };
|
package/dist/start.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { PerfTarget, ProcessMetrics } from './types';
|
|
2
|
+
export type StartedTarget = {
|
|
3
|
+
url: string;
|
|
4
|
+
port: number;
|
|
5
|
+
pid?: number;
|
|
6
|
+
stop: () => Promise<void>;
|
|
7
|
+
takeProcessMetrics: () => ProcessMetrics;
|
|
8
|
+
};
|
|
9
|
+
/** True when nothing is listening / bound on host:port. */
|
|
10
|
+
export declare function isPortFree(port: number, host?: string): Promise<boolean>;
|
|
11
|
+
export declare function startTarget(target: PerfTarget): Promise<StartedTarget>;
|
|
12
|
+
//# sourceMappingURL=start.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"start.d.ts","sourceRoot":"","sources":["../src/start.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAE1D,MAAM,MAAM,aAAa,GAAG;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1B,kBAAkB,EAAE,MAAM,cAAc,CAAC;CAC1C,CAAC;AAEF,2DAA2D;AAC3D,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,SAAgB,GAAG,OAAO,CAAC,OAAO,CAAC,CAQ/E;AAED,wBAAsB,WAAW,CAAC,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC,aAAa,CAAC,CAyF5E"}
|
package/dist/start.mjs
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
import { getFreePort, LOOPBACK_HOST, waitForHttpReady } from "@untestutils/core";
|
|
4
|
+
import { resolve } from "pathe";
|
|
5
|
+
import { finalizeSamples, startProcessMonitor } from "./process-sample.mjs";
|
|
6
|
+
|
|
7
|
+
export function isPortFree(port, host = LOOPBACK_HOST) {
|
|
8
|
+
return new Promise((resolvePromise) => {
|
|
9
|
+
const server = net.createServer();
|
|
10
|
+
server.once("error", () => resolvePromise(false));
|
|
11
|
+
server.listen(port, host, () => {
|
|
12
|
+
server.close(() => resolvePromise(true));
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
export async function startTarget(target) {
|
|
17
|
+
const cwd = resolve(target.start.cwd ?? target.root);
|
|
18
|
+
const host = target.start.host ?? LOOPBACK_HOST;
|
|
19
|
+
let port = target.start.port;
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
if (!await isPortFree(port, host)) {
|
|
23
|
+
const next = await getFreePort(host);
|
|
24
|
+
console.warn(`[untestutils/perf] ${host}:${port} busy — using ${next}`);
|
|
25
|
+
port = next;
|
|
26
|
+
}
|
|
27
|
+
const url = `http://${host}:${port}`;
|
|
28
|
+
const child = spawn(target.start.command, target.start.args ?? [], {
|
|
29
|
+
cwd,
|
|
30
|
+
env: {
|
|
31
|
+
...process.env,
|
|
32
|
+
PORT: String(port),
|
|
33
|
+
HOST: host,
|
|
34
|
+
NODE_ENV: "production",
|
|
35
|
+
...target.start.env
|
|
36
|
+
},
|
|
37
|
+
stdio: [
|
|
38
|
+
"ignore",
|
|
39
|
+
"pipe",
|
|
40
|
+
"pipe"
|
|
41
|
+
],
|
|
42
|
+
detached: process.platform !== "win32"
|
|
43
|
+
});
|
|
44
|
+
child.stdout?.on("data", (b) => {
|
|
45
|
+
const t = b.toString().trim();
|
|
46
|
+
if (t) console.log(` [server] ${t.slice(0, 200)}`);
|
|
47
|
+
});
|
|
48
|
+
child.stderr?.on("data", (b) => {
|
|
49
|
+
const t = b.toString().trim();
|
|
50
|
+
if (t) console.error(` [server stderr] ${t.slice(0, 200)}`);
|
|
51
|
+
});
|
|
52
|
+
let monitorAcc;
|
|
53
|
+
let stopMonitor;
|
|
54
|
+
if (child.pid) {
|
|
55
|
+
const m = startProcessMonitor(child.pid);
|
|
56
|
+
monitorAcc = m.acc;
|
|
57
|
+
stopMonitor = m.stop;
|
|
58
|
+
}
|
|
59
|
+
const empty = {
|
|
60
|
+
maxMemoryMb: 0,
|
|
61
|
+
minMemoryMb: 0,
|
|
62
|
+
avgMemoryMb: 0,
|
|
63
|
+
maxCpuPct: 0,
|
|
64
|
+
minCpuPct: 0,
|
|
65
|
+
avgCpuPct: 0
|
|
66
|
+
};
|
|
67
|
+
try {
|
|
68
|
+
await new Promise((resolveReady, reject) => {
|
|
69
|
+
const onExit = (code) => {
|
|
70
|
+
reject(new Error(`[untestutils/perf] server exited before ready (code=${code})`));
|
|
71
|
+
};
|
|
72
|
+
child.once("exit", onExit);
|
|
73
|
+
waitForHttpReady(url, {
|
|
74
|
+
path: target.start.readyPath ?? "/",
|
|
75
|
+
timeoutMs: target.start.readyTimeoutMs ?? 6e4
|
|
76
|
+
}).then(() => {
|
|
77
|
+
child.off("exit", onExit);
|
|
78
|
+
resolveReady();
|
|
79
|
+
}).catch((err) => {
|
|
80
|
+
child.off("exit", onExit);
|
|
81
|
+
reject(err);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
} catch (err) {
|
|
85
|
+
stopMonitor?.();
|
|
86
|
+
await killChild(child);
|
|
87
|
+
throw err;
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
url,
|
|
91
|
+
port,
|
|
92
|
+
pid: child.pid,
|
|
93
|
+
takeProcessMetrics: () => monitorAcc ? finalizeSamples(monitorAcc) : empty,
|
|
94
|
+
stop: async () => {
|
|
95
|
+
stopMonitor?.();
|
|
96
|
+
await killChild(child);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
async function killChild(child) {
|
|
101
|
+
if (!child.pid) return;
|
|
102
|
+
try {
|
|
103
|
+
if (process.platform !== "win32") {
|
|
104
|
+
try {
|
|
105
|
+
process.kill(-child.pid, "SIGTERM");
|
|
106
|
+
} catch {
|
|
107
|
+
process.kill(child.pid, "SIGTERM");
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
child.kill("SIGTERM");
|
|
111
|
+
}
|
|
112
|
+
} catch {}
|
|
113
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
114
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { PerfTargetResult, PerfThresholds } from "./types.mjs";
|
|
2
|
+
export type ThresholdFailure = {
|
|
3
|
+
id: string;
|
|
4
|
+
rule: string;
|
|
5
|
+
actual: number;
|
|
6
|
+
limit: number;
|
|
7
|
+
};
|
|
8
|
+
export declare function checkThresholds(results: PerfTargetResult[], thresholds: PerfThresholds): ThresholdFailure[];
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { PerfTargetResult, PerfThresholds } from './types';
|
|
2
|
+
export type ThresholdFailure = {
|
|
3
|
+
id: string;
|
|
4
|
+
rule: string;
|
|
5
|
+
actual: number;
|
|
6
|
+
limit: number;
|
|
7
|
+
};
|
|
8
|
+
export declare function checkThresholds(results: PerfTargetResult[], thresholds: PerfThresholds): ThresholdFailure[];
|
|
9
|
+
//# sourceMappingURL=thresholds.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"thresholds.d.ts","sourceRoot":"","sources":["../src/thresholds.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAEhE,MAAM,MAAM,gBAAgB,GAAG;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAE3F,wBAAgB,eAAe,CAC7B,OAAO,EAAE,gBAAgB,EAAE,EAC3B,UAAU,EAAE,cAAc,GACzB,gBAAgB,EAAE,CAmEpB"}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export function checkThresholds(results, thresholds) {
|
|
2
|
+
const failures = [];
|
|
3
|
+
for (const r of results) {
|
|
4
|
+
if (thresholds.buildTimeSec !== null && thresholds.buildTimeSec !== undefined && r.build.buildTimeSec > thresholds.buildTimeSec) {
|
|
5
|
+
failures.push({
|
|
6
|
+
id: r.id,
|
|
7
|
+
rule: "buildTimeSec",
|
|
8
|
+
actual: r.build.buildTimeSec,
|
|
9
|
+
limit: thresholds.buildTimeSec
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
if (thresholds.maxMemoryMb !== null && thresholds.maxMemoryMb !== undefined && r.build.maxMemoryMb > thresholds.maxMemoryMb) {
|
|
13
|
+
failures.push({
|
|
14
|
+
id: r.id,
|
|
15
|
+
rule: "maxMemoryMb",
|
|
16
|
+
actual: r.build.maxMemoryMb,
|
|
17
|
+
limit: thresholds.maxMemoryMb
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
if (r.load) {
|
|
21
|
+
if (thresholds.requestsPerSecond !== null && thresholds.requestsPerSecond !== undefined && (r.load.requestsPerSecond ?? 0) < thresholds.requestsPerSecond) {
|
|
22
|
+
failures.push({
|
|
23
|
+
id: r.id,
|
|
24
|
+
rule: "requestsPerSecond",
|
|
25
|
+
actual: r.load.requestsPerSecond ?? 0,
|
|
26
|
+
limit: thresholds.requestsPerSecond
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
if (thresholds.responseTimeP95 !== null && thresholds.responseTimeP95 !== undefined && (r.load.responseTimeP95 ?? 0) > thresholds.responseTimeP95) {
|
|
30
|
+
failures.push({
|
|
31
|
+
id: r.id,
|
|
32
|
+
rule: "responseTimeP95",
|
|
33
|
+
actual: r.load.responseTimeP95 ?? 0,
|
|
34
|
+
limit: thresholds.responseTimeP95
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
if (thresholds.errorRate !== null && thresholds.errorRate !== undefined && (r.load.errorRate ?? 0) > thresholds.errorRate) {
|
|
38
|
+
failures.push({
|
|
39
|
+
id: r.id,
|
|
40
|
+
rule: "errorRate",
|
|
41
|
+
actual: r.load.errorRate ?? 0,
|
|
42
|
+
limit: thresholds.errorRate
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return failures;
|
|
48
|
+
}
|