@cellajs/cli 0.6.2 → 0.6.5
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/README.md +279 -9
- package/dist/a11y.d.ts +80 -0
- package/dist/a11y.js +309 -0
- package/dist/a11y.js.map +1 -0
- package/dist/adapters-CxHOtWog.d.ts +16 -0
- package/dist/bench.d.ts +179 -0
- package/dist/bench.js +1480 -0
- package/dist/bench.js.map +1 -0
- package/dist/config.js.map +1 -1
- package/dist/drive.d.ts +82 -0
- package/dist/drive.js +141 -0
- package/dist/drive.js.map +1 -0
- package/dist/index.js +6381 -782
- package/dist/index.js.map +1 -1
- package/dist/pentest.d.ts +39 -0
- package/dist/pentest.js +32 -0
- package/dist/pentest.js.map +1 -0
- package/dist/screenshots.d.ts +63 -0
- package/dist/screenshots.js +104 -0
- package/dist/screenshots.js.map +1 -0
- package/dist/style.d.ts +92 -0
- package/dist/style.js +268 -0
- package/dist/style.js.map +1 -0
- package/package.json +29 -1
package/dist/bench.js
ADDED
|
@@ -0,0 +1,1480 @@
|
|
|
1
|
+
// src/tools/bench/run.ts
|
|
2
|
+
import { appendFileSync, mkdirSync as mkdirSync2, readdirSync, rmSync } from "fs";
|
|
3
|
+
import { join as join2 } from "path";
|
|
4
|
+
import { monitorEventLoopDelay, performance as performance4 } from "perf_hooks";
|
|
5
|
+
import process5 from "process";
|
|
6
|
+
import { setTimeout as delay } from "timers/promises";
|
|
7
|
+
|
|
8
|
+
// src/utils/colors.ts
|
|
9
|
+
import process2 from "process";
|
|
10
|
+
import { styleText } from "util";
|
|
11
|
+
|
|
12
|
+
// src/utils/env.ts
|
|
13
|
+
import process from "process";
|
|
14
|
+
function getEnv(name) {
|
|
15
|
+
return process.env[name];
|
|
16
|
+
}
|
|
17
|
+
function hasEnv(name) {
|
|
18
|
+
return name in process.env;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// src/utils/colors.ts
|
|
22
|
+
function colorsEnabled() {
|
|
23
|
+
if (hasEnv("NO_COLOR")) return false;
|
|
24
|
+
if (hasEnv("FORCE_COLOR")) return true;
|
|
25
|
+
return !!process2.stdout.isTTY;
|
|
26
|
+
}
|
|
27
|
+
function applyStyle(style, text) {
|
|
28
|
+
if (!colorsEnabled()) return text;
|
|
29
|
+
return styleText(style, text, { validateStream: false });
|
|
30
|
+
}
|
|
31
|
+
var pc = {
|
|
32
|
+
blue: (text) => applyStyle("blue", text),
|
|
33
|
+
bold: (text) => applyStyle("bold", text),
|
|
34
|
+
cyan: (text) => applyStyle("cyan", text),
|
|
35
|
+
dim: (text) => applyStyle("dim", text),
|
|
36
|
+
gray: (text) => applyStyle("gray", text),
|
|
37
|
+
green: (text) => applyStyle("green", text),
|
|
38
|
+
magenta: (text) => applyStyle("magenta", text),
|
|
39
|
+
red: (text) => applyStyle("red", text),
|
|
40
|
+
white: (text) => applyStyle("white", text),
|
|
41
|
+
yellow: (text) => applyStyle("yellow", text)
|
|
42
|
+
};
|
|
43
|
+
var colors_default = pc;
|
|
44
|
+
|
|
45
|
+
// src/tools/bench/baseline.ts
|
|
46
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
47
|
+
import { join } from "path";
|
|
48
|
+
var RUNNER_EPOCH = "cella-bench@1";
|
|
49
|
+
var isComparable = (entry) => entry?.runner === RUNNER_EPOCH;
|
|
50
|
+
function readEntries(file) {
|
|
51
|
+
if (!existsSync(file)) return [];
|
|
52
|
+
try {
|
|
53
|
+
const data = JSON.parse(readFileSync(file, "utf8"));
|
|
54
|
+
return Array.isArray(data) ? data : [data];
|
|
55
|
+
} catch {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
var baselineFile = (dir, scenario) => join(dir, "baselines", `${scenario}.json`);
|
|
60
|
+
function loadBaseline(dir, scenario) {
|
|
61
|
+
const entries = readEntries(baselineFile(dir, scenario));
|
|
62
|
+
const comparable = entries.filter(isComparable);
|
|
63
|
+
return { baseline: comparable.at(-1) ?? null, older: entries.length - comparable.length };
|
|
64
|
+
}
|
|
65
|
+
function saveBaseline(dir, entry) {
|
|
66
|
+
const file = baselineFile(dir, entry.scenario);
|
|
67
|
+
const entries = readEntries(file);
|
|
68
|
+
entries.push(entry);
|
|
69
|
+
mkdirSync(join(dir, "baselines"), { recursive: true });
|
|
70
|
+
writeFileSync(file, `${JSON.stringify(entries, null, 2)}
|
|
71
|
+
`);
|
|
72
|
+
return entries.length;
|
|
73
|
+
}
|
|
74
|
+
function compareBaseline(current, baseline) {
|
|
75
|
+
const delta = (label, now, before, lowerIsBetter = true) => ({
|
|
76
|
+
label,
|
|
77
|
+
current: now,
|
|
78
|
+
baseline: before,
|
|
79
|
+
pct: before === 0 ? null : (now - before) / before * 100,
|
|
80
|
+
lowerIsBetter
|
|
81
|
+
});
|
|
82
|
+
const deltas = [];
|
|
83
|
+
if (current.primary.metric === baseline.primary.metric)
|
|
84
|
+
deltas.push(delta(current.primary.metric, current.primary.value, baseline.primary.value));
|
|
85
|
+
if (current.requests > 0) {
|
|
86
|
+
if (current.primary.metric !== "http.response_time.p50") deltas.push(delta("p50", current.p50, baseline.p50));
|
|
87
|
+
if (current.primary.metric !== "http.response_time.p95") deltas.push(delta("p95", current.p95, baseline.p95));
|
|
88
|
+
deltas.push(
|
|
89
|
+
delta("p99", current.p99, baseline.p99),
|
|
90
|
+
delta("req/s", current.requestRate, baseline.requestRate, false)
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return deltas;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// src/tools/bench/report.ts
|
|
97
|
+
import process4 from "process";
|
|
98
|
+
import { stripVTControlCharacters as stripVTControlCharacters2 } from "util";
|
|
99
|
+
|
|
100
|
+
// src/utils/layout.ts
|
|
101
|
+
import process3 from "process";
|
|
102
|
+
import { stripVTControlCharacters } from "util";
|
|
103
|
+
function visibleWidth(text) {
|
|
104
|
+
return stripVTControlCharacters(text).length;
|
|
105
|
+
}
|
|
106
|
+
function padVisible(text, width, align = "start") {
|
|
107
|
+
const padding = " ".repeat(Math.max(0, width - visibleWidth(text)));
|
|
108
|
+
return align === "start" ? `${text}${padding}` : `${padding}${text}`;
|
|
109
|
+
}
|
|
110
|
+
var AGE_STEPS = [
|
|
111
|
+
[90 * 60, 60, "m"],
|
|
112
|
+
[36 * 3600, 3600, "h"],
|
|
113
|
+
[14 * 86400, 86400, "d"],
|
|
114
|
+
[70 * 86400, 7 * 86400, "w"],
|
|
115
|
+
[365 * 86400, 30 * 86400, "mo"]
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
// src/tools/bench/report.ts
|
|
119
|
+
var visible = (text) => stripVTControlCharacters2(text).length;
|
|
120
|
+
var count = (n) => Math.round(n).toLocaleString("en-US");
|
|
121
|
+
var ms = (n) => n < 10 ? `${n.toFixed(1)}ms` : `${Math.round(n)}ms`;
|
|
122
|
+
var seconds = (n) => `${Math.round(n)}s`;
|
|
123
|
+
var LiveDisplay = class {
|
|
124
|
+
constructor(tty, out = process4.stdout) {
|
|
125
|
+
this.tty = tty;
|
|
126
|
+
this.out = out;
|
|
127
|
+
}
|
|
128
|
+
tty;
|
|
129
|
+
out;
|
|
130
|
+
rows = 0;
|
|
131
|
+
lines = [];
|
|
132
|
+
render(lines) {
|
|
133
|
+
this.lines = lines;
|
|
134
|
+
if (!this.tty) {
|
|
135
|
+
for (const line of lines) this.out.write(`${line}
|
|
136
|
+
`);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
this.clear();
|
|
140
|
+
this.draw();
|
|
141
|
+
}
|
|
142
|
+
log(text) {
|
|
143
|
+
if (!this.tty) {
|
|
144
|
+
this.out.write(`${text}
|
|
145
|
+
`);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
this.clear();
|
|
149
|
+
this.out.write(`${text}
|
|
150
|
+
`);
|
|
151
|
+
this.draw();
|
|
152
|
+
}
|
|
153
|
+
/** Removes the block from a terminal; piped output keeps what it printed. */
|
|
154
|
+
clear() {
|
|
155
|
+
if (this.rows > 0) this.out.write(`\x1B[${this.rows}F\x1B[J`);
|
|
156
|
+
this.rows = 0;
|
|
157
|
+
}
|
|
158
|
+
draw() {
|
|
159
|
+
const columns = this.out.columns || 80;
|
|
160
|
+
for (const line of this.lines) this.out.write(`${line}
|
|
161
|
+
`);
|
|
162
|
+
this.rows = this.lines.reduce((sum, line) => sum + Math.max(1, Math.ceil(visible(line) / columns)), 0);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
function liveLines(state) {
|
|
166
|
+
const { vus, counters } = state;
|
|
167
|
+
const time = state.plannedS === null ? seconds(state.elapsedS) : `${seconds(state.elapsedS)}/${seconds(state.plannedS)}`;
|
|
168
|
+
const head = `${colors_default.bold(state.scenario)} ${colors_default.dim(`\xB7 ${state.phase} \xB7 ${time}`)}`;
|
|
169
|
+
const parts = [
|
|
170
|
+
`vus +${count(vus.started)} \u25B8${count(vus.active)} \u2713${count(vus.completed)} ${vus.failed > 0 ? colors_default.red(`\u2717${count(vus.failed)}`) : "\u27170"}`
|
|
171
|
+
];
|
|
172
|
+
const requests = counters["http.requests"] ?? 0;
|
|
173
|
+
if (requests > 0 || state.http) {
|
|
174
|
+
parts.push(`${count(requests / state.intervalS)} req/s`);
|
|
175
|
+
if (state.http) parts.push(`p50 ${ms(state.http.p50)} p95 ${ms(state.http.p95)} p99 ${ms(state.http.p99)}`);
|
|
176
|
+
const classes = /* @__PURE__ */ new Map();
|
|
177
|
+
for (const [name, n] of Object.entries(counters)) {
|
|
178
|
+
const status = /^http\.codes\.(\d)\d\d$/.exec(name)?.[1];
|
|
179
|
+
if (status) classes.set(`${status}xx`, (classes.get(`${status}xx`) ?? 0) + n);
|
|
180
|
+
}
|
|
181
|
+
const codes = [...classes].sort().map(([cls, n]) => cls === "2xx" ? `${cls} ${count(n)}` : colors_default.yellow(`${cls} ${count(n)}`));
|
|
182
|
+
if (codes.length) parts.push(codes.join(" "));
|
|
183
|
+
}
|
|
184
|
+
const errors = Object.entries(counters).filter(([name]) => name.startsWith("errors."));
|
|
185
|
+
if (errors.length) parts.push(colors_default.red(errors.map(([name, n]) => `${name.slice(7)} ${count(n)}`).join(" ")));
|
|
186
|
+
if (state.primary) {
|
|
187
|
+
const d = state.primary.distribution;
|
|
188
|
+
parts.push(`${state.primary.name} p50 ${ms(d.p50)} p95 ${ms(d.p95)}`);
|
|
189
|
+
}
|
|
190
|
+
for (const [name, n] of state.custom) parts.push(`${name} +${count(n)}`);
|
|
191
|
+
parts.push(colors_default.dim(`gen lag ${ms(state.lagP99)}`));
|
|
192
|
+
return [head, ` ${parts.join(colors_default.dim(" \xB7 "))}`];
|
|
193
|
+
}
|
|
194
|
+
var pad = (text, width, left = false) => left ? text.padEnd(width) : text.padStart(width);
|
|
195
|
+
function deltaText(pct2, lowerIsBetter) {
|
|
196
|
+
if (pct2 === null) return colors_default.dim("n/a");
|
|
197
|
+
if (Math.abs(pct2) < 1) return colors_default.dim("~0%");
|
|
198
|
+
const label = `${pct2 > 0 ? "+" : "\u2212"}${Math.abs(pct2).toFixed(0)}%`;
|
|
199
|
+
return (lowerIsBetter ? pct2 < 0 : pct2 > 0) ? colors_default.green(label) : colors_default.red(label);
|
|
200
|
+
}
|
|
201
|
+
function summaryLines(view) {
|
|
202
|
+
const lines = [];
|
|
203
|
+
const vus = `${count(view.vus.created)} VUs (\u2713${count(view.vus.completed)} \u2717${count(view.vus.failed)})`;
|
|
204
|
+
const reqs = view.requests > 0 ? ` \xB7 ${count(view.requests)} requests \xB7 ${count(view.requestRate)} req/s` : "";
|
|
205
|
+
lines.push(
|
|
206
|
+
`${colors_default.bold(view.scenario)}${view.short ? colors_default.dim(" (short)") : ""} \xB7 ${view.durationS.toFixed(1)} s \xB7 ${vus}${reqs}`
|
|
207
|
+
);
|
|
208
|
+
const labels = Object.keys(view.endpoints);
|
|
209
|
+
if (labels.length) {
|
|
210
|
+
const width = Math.max(8, ...labels.map((label) => label.length));
|
|
211
|
+
lines.push(
|
|
212
|
+
colors_default.dim(
|
|
213
|
+
` ${pad("endpoint", width, true)} ${pad("req", 8)} ${pad("p50", 8)} ${pad("p95", 8)} ${pad("p99", 8)} ${pad("max", 8)} ${pad("non-2xx", 8)} ${pad("timeouts", 9)}`
|
|
214
|
+
)
|
|
215
|
+
);
|
|
216
|
+
for (const [label, e] of Object.entries(view.endpoints)) {
|
|
217
|
+
const d = e.ttfb;
|
|
218
|
+
const nonOk = e.nonOk > 0 ? colors_default.yellow(pad(count(e.nonOk), 8)) : pad("0", 8);
|
|
219
|
+
const timeouts = e.timeouts > 0 ? colors_default.red(pad(count(e.timeouts), 9)) : pad("0", 9);
|
|
220
|
+
lines.push(
|
|
221
|
+
` ${pad(label, width, true)} ${pad(count(e.requests), 8)} ${pad(ms(d.p50), 8)} ${pad(ms(d.p95), 8)} ${pad(ms(d.p99), 8)} ${pad(ms(d.max), 8)} ${nonOk} ${timeouts}`
|
|
222
|
+
);
|
|
223
|
+
const rejected = Object.entries(e.codes).filter(([code]) => !code.startsWith("2"));
|
|
224
|
+
if (rejected.length)
|
|
225
|
+
lines.push(colors_default.dim(` ${" ".repeat(width)} ${rejected.map(([code, n]) => `${code} \xD7${count(n)}`).join(", ")}`));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const histograms = Object.entries(view.histograms);
|
|
229
|
+
if (histograms.length) {
|
|
230
|
+
const width = Math.max(9, ...histograms.map(([name]) => name.length));
|
|
231
|
+
lines.push(
|
|
232
|
+
colors_default.dim(
|
|
233
|
+
` ${pad("histogram", width, true)} ${pad("count", 8)} ${pad("p50", 8)} ${pad("p95", 8)} ${pad("p99", 8)} ${pad("max", 8)}`
|
|
234
|
+
)
|
|
235
|
+
);
|
|
236
|
+
for (const [name, d] of histograms) {
|
|
237
|
+
lines.push(
|
|
238
|
+
` ${pad(name, width, true)} ${pad(count(d.count), 8)} ${pad(ms(d.p50), 8)} ${pad(ms(d.p95), 8)} ${pad(ms(d.p99), 8)} ${pad(ms(d.max), 8)}`
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (view.counters.length)
|
|
243
|
+
lines.push(` ${view.counters.map(([name, n]) => `${name} ${colors_default.bold(count(n))}`).join(colors_default.dim(" \xB7 "))}`);
|
|
244
|
+
for (const [code, { count: n, message }] of Object.entries(view.errors)) {
|
|
245
|
+
lines.push(colors_default.red(` errors.${code} ${count(n)}${message ? `: ${message}` : ""}`));
|
|
246
|
+
}
|
|
247
|
+
if (view.lagWarning) lines.push(colors_default.yellow(` \u26A0 ${view.lagWarning}`));
|
|
248
|
+
for (const check of view.checks) lines.push(` ${check.ok ? colors_default.green("\u2713") : colors_default.red("\u2717")} ${check.text}`);
|
|
249
|
+
if (view.compared) {
|
|
250
|
+
if (view.baseline) {
|
|
251
|
+
const deltas = compareBaseline(view.current, view.baseline).map(
|
|
252
|
+
(d) => `${d.label} ${deltaText(d.pct, d.lowerIsBetter)}`
|
|
253
|
+
);
|
|
254
|
+
lines.push(
|
|
255
|
+
` baseline ${view.baseline.timestamp.slice(0, 16).replace("T", " ")} (${RUNNER_EPOCH}): ${deltas.join(", ")}`
|
|
256
|
+
);
|
|
257
|
+
} else {
|
|
258
|
+
const older = view.olderBaselines > 0 ? `, ${view.olderBaselines} older-runner entries kept` : "";
|
|
259
|
+
lines.push(colors_default.dim(` no comparable baseline: first ${RUNNER_EPOCH} run${older}`));
|
|
260
|
+
}
|
|
261
|
+
if (view.saved !== null) lines.push(colors_default.dim(` baseline saved (${view.saved} run${view.saved === 1 ? "" : "s"})`));
|
|
262
|
+
}
|
|
263
|
+
lines.push(colors_default.dim(` log ${view.logFile}`));
|
|
264
|
+
return lines;
|
|
265
|
+
}
|
|
266
|
+
function compactLine(view) {
|
|
267
|
+
const failed = view.checks.filter((check) => !check.ok);
|
|
268
|
+
const mark = failed.length ? colors_default.red("\u2717") : colors_default.green("\u2713");
|
|
269
|
+
const reqs = view.requests > 0 ? ` \xB7 ${count(view.requests)} req \xB7 p95 ${ms(view.http?.p95 ?? 0)}` : "";
|
|
270
|
+
const primary = view.current.primary.metric.startsWith("http.") ? "" : ` \xB7 ${view.current.primary.metric} ${ms(view.current.primary.value)}`;
|
|
271
|
+
const reasons = failed.length ? colors_default.red(` \xB7 ${failed.map((check) => check.text).join("; ")}`) : "";
|
|
272
|
+
return `${mark} ${view.scenario}${colors_default.dim(`${reqs}${primary}`)}${reasons}`;
|
|
273
|
+
}
|
|
274
|
+
function suiteLines(rows) {
|
|
275
|
+
const extra = [...new Set(rows.flatMap((row) => Object.keys(row.columns)))];
|
|
276
|
+
const heads = ["Req/s", "p50", "p95", "p99", "Errors", ...extra];
|
|
277
|
+
const num = (n) => n === 0 ? "-" : n < 10 ? n.toFixed(1) : String(Math.round(n));
|
|
278
|
+
const lines = ["", `${colors_default.bold("Summary")} ${colors_default.dim("\xB7 all scenarios")}`, ""];
|
|
279
|
+
lines.push(
|
|
280
|
+
` ${colors_default.bold(pad("Scenario", 22, true))} ${heads.map((head) => colors_default.bold(pad(head, 8))).join(" ")} ${colors_default.bold(pad("Primary \u0394", 10))}`
|
|
281
|
+
);
|
|
282
|
+
lines.push(` ${"\u2500".repeat(22 + heads.length * 9 + 11)}`);
|
|
283
|
+
for (const row of rows) {
|
|
284
|
+
const { current, baseline } = row;
|
|
285
|
+
const delta = baseline ? deltaText(
|
|
286
|
+
baseline.primary.value === 0 ? null : (current.primary.value - baseline.primary.value) / baseline.primary.value * 100,
|
|
287
|
+
true
|
|
288
|
+
) : colors_default.dim(row.ok ? "new" : "-");
|
|
289
|
+
const cells = [current.requestRate, current.p50, current.p95, current.p99].map(num);
|
|
290
|
+
cells.push(String(current.errors), ...extra.map((column) => row.columns[column] ?? "-"));
|
|
291
|
+
lines.push(
|
|
292
|
+
` ${pad(row.name, 22, true)} ${cells.map((cell) => pad(cell, 8)).join(" ")} ${padVisible(delta, 10, "end")}${row.ok ? "" : colors_default.red(" \u2717")}`
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
lines.push("");
|
|
296
|
+
return lines;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// src/tools/bench/types.ts
|
|
300
|
+
var stats = ["p50", "p95", "p99", "max", "min", "mean"];
|
|
301
|
+
function splitMetricKey(key) {
|
|
302
|
+
const dot = key.lastIndexOf(".");
|
|
303
|
+
const tail = key.slice(dot + 1);
|
|
304
|
+
return { name: key.slice(0, dot), stat: stats.find((stat) => stat === tail) ?? null };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// src/tools/bench/checks.ts
|
|
308
|
+
var DEFAULT_MAX_REJECTED_SHARE = 1;
|
|
309
|
+
var pct = (n) => `${n.toFixed(1)}%`;
|
|
310
|
+
function statusCounts(counters) {
|
|
311
|
+
return Object.entries(counters).flatMap(([name, n]) => {
|
|
312
|
+
const status = /^http\.codes\.(\d{3})$/.exec(name)?.[1];
|
|
313
|
+
return status ? [[status, n]] : [];
|
|
314
|
+
}).sort(([a], [b]) => a.localeCompare(b));
|
|
315
|
+
}
|
|
316
|
+
function evaluateChecks(checks, { counters, distributions, short }) {
|
|
317
|
+
const results = [];
|
|
318
|
+
const byStatus = statusCounts(counters);
|
|
319
|
+
const responses = byStatus.reduce((sum, [, n]) => sum + n, 0);
|
|
320
|
+
const rejected = byStatus.filter(([status]) => !status.startsWith("2"));
|
|
321
|
+
const rejectedCount = rejected.reduce((sum, [, n]) => sum + n, 0);
|
|
322
|
+
const maxRejected = checks.maxRejectedShare ?? DEFAULT_MAX_REJECTED_SHARE;
|
|
323
|
+
if (responses > 0) {
|
|
324
|
+
const ok = rejectedCount <= responses * maxRejected / 100;
|
|
325
|
+
const detail = rejected.length ? ` (${rejected.map(([status, n]) => `${status} \xD7${n}`).join(", ")})` : "";
|
|
326
|
+
results.push({
|
|
327
|
+
id: "responses",
|
|
328
|
+
ok,
|
|
329
|
+
text: `${count(rejectedCount)} of ${count(responses)} responses not 2xx, ${pct(rejectedCount / responses * 100)} ${ok ? "\u2264" : ">"} ${maxRejected}%${detail}`
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
if (checks.maxErrorRate !== void 0) {
|
|
333
|
+
const created = counters["vusers.created"] ?? 0;
|
|
334
|
+
const completed = counters["vusers.completed"] ?? 0;
|
|
335
|
+
const rate = created === 0 ? 0 : (created - completed) / created * 100;
|
|
336
|
+
const ok = rate <= checks.maxErrorRate;
|
|
337
|
+
results.push({
|
|
338
|
+
id: "errorRate",
|
|
339
|
+
ok,
|
|
340
|
+
text: `VU failures ${pct(rate)} ${ok ? "\u2264" : ">"} ${checks.maxErrorRate}% (${count(created - completed)} of ${count(created)})`
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
if (!short) {
|
|
344
|
+
for (const [key, limit] of Object.entries(checks.thresholds ?? {})) {
|
|
345
|
+
if (limit === void 0) continue;
|
|
346
|
+
const { name, stat } = splitMetricKey(key);
|
|
347
|
+
const value = stat ? distributions[name]?.[stat] : void 0;
|
|
348
|
+
if (value === void 0) {
|
|
349
|
+
results.push({ id: "threshold", ok: false, text: `${key}: nothing recorded` });
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
const ok = value <= limit;
|
|
353
|
+
results.push({ id: "threshold", ok, text: `${key} ${ms(value)} ${ok ? "\u2264" : ">"} ${ms(limit)}` });
|
|
354
|
+
}
|
|
355
|
+
for (const name of checks.expect ?? []) {
|
|
356
|
+
const n = counters[name] ?? 0;
|
|
357
|
+
results.push({ id: "expect", ok: n > 0, text: n > 0 ? `${name} counted ${count(n)}` : `${name} stayed at 0` });
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
for (const name of checks.forbid ?? []) {
|
|
361
|
+
const n = counters[name] ?? 0;
|
|
362
|
+
results.push({ id: "forbid", ok: n === 0, text: n === 0 ? `${name} stayed at 0` : `${name} counted ${count(n)}` });
|
|
363
|
+
}
|
|
364
|
+
return results;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// src/tools/bench/http.ts
|
|
368
|
+
import http from "http";
|
|
369
|
+
import https from "https";
|
|
370
|
+
import { performance } from "perf_hooks";
|
|
371
|
+
var DEFAULT_TIMEOUT_MS = 1e4;
|
|
372
|
+
var COUNTED = /* @__PURE__ */ Symbol("counted");
|
|
373
|
+
function errorCode(error) {
|
|
374
|
+
if (error instanceof Error) return error.code ?? error.name;
|
|
375
|
+
return "unknown";
|
|
376
|
+
}
|
|
377
|
+
function codedError(code, message) {
|
|
378
|
+
return Object.assign(new Error(message), { code });
|
|
379
|
+
}
|
|
380
|
+
function defaultLabel(method, url) {
|
|
381
|
+
return `${method} ${url.pathname}`;
|
|
382
|
+
}
|
|
383
|
+
function createHttpClient(target, metrics, signal) {
|
|
384
|
+
const agents = /* @__PURE__ */ new Map();
|
|
385
|
+
const agentFor = (url) => {
|
|
386
|
+
let agent = agents.get(url.protocol);
|
|
387
|
+
if (!agent) {
|
|
388
|
+
agent = url.protocol === "https:" ? new https.Agent({ keepAlive: true, maxSockets: 1 }) : new http.Agent({ keepAlive: true, maxSockets: 1 });
|
|
389
|
+
agents.set(url.protocol, agent);
|
|
390
|
+
}
|
|
391
|
+
return agent;
|
|
392
|
+
};
|
|
393
|
+
const inFlight = /* @__PURE__ */ new Set();
|
|
394
|
+
const send = (method, path, options = {}) => {
|
|
395
|
+
const url = new URL(/^https?:\/\//.test(path) ? path : `${target}${path}`);
|
|
396
|
+
const label = options.name ?? defaultLabel(method, url);
|
|
397
|
+
const stats2 = metrics.endpoint(label);
|
|
398
|
+
const headers = { ...options.headers };
|
|
399
|
+
let body = options.body;
|
|
400
|
+
if (options.json !== void 0) {
|
|
401
|
+
body = JSON.stringify(options.json);
|
|
402
|
+
headers["content-type"] ??= "application/json";
|
|
403
|
+
}
|
|
404
|
+
if (body !== void 0) headers["content-length"] = String(Buffer.byteLength(body));
|
|
405
|
+
return new Promise((resolve3, reject) => {
|
|
406
|
+
if (signal.aborted) return reject(codedError("EABORTED", "the run stopped"));
|
|
407
|
+
const started = performance.now();
|
|
408
|
+
let connectedAt = 0;
|
|
409
|
+
let sentAt = 0;
|
|
410
|
+
let settled = false;
|
|
411
|
+
const lib = url.protocol === "https:" ? https : http;
|
|
412
|
+
const req = lib.request(url, { method, headers, agent: agentFor(url) });
|
|
413
|
+
const fail = (error) => {
|
|
414
|
+
if (settled) return;
|
|
415
|
+
settled = true;
|
|
416
|
+
clearTimeout(timer);
|
|
417
|
+
signal.removeEventListener("abort", onAbort);
|
|
418
|
+
if (!signal.aborted) {
|
|
419
|
+
const code = errorCode(error);
|
|
420
|
+
if (code === "ETIMEDOUT") {
|
|
421
|
+
stats2.timeouts++;
|
|
422
|
+
metrics.counter("http.timeouts");
|
|
423
|
+
} else stats2.errors++;
|
|
424
|
+
metrics.counter(`errors.${code}`);
|
|
425
|
+
Object.assign(error, { [COUNTED]: true });
|
|
426
|
+
}
|
|
427
|
+
reject(error);
|
|
428
|
+
};
|
|
429
|
+
const timeoutMs = options.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
430
|
+
const timer = setTimeout(
|
|
431
|
+
() => req.destroy(codedError("ETIMEDOUT", `${label} took longer than ${timeoutMs} ms`)),
|
|
432
|
+
timeoutMs
|
|
433
|
+
);
|
|
434
|
+
const onAbort = () => req.destroy(codedError("EABORTED", "the run stopped"));
|
|
435
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
436
|
+
req.on("socket", (socket) => {
|
|
437
|
+
if (socket.connecting) socket.once("connect", () => connectedAt = performance.now());
|
|
438
|
+
});
|
|
439
|
+
req.on("finish", () => sentAt = performance.now());
|
|
440
|
+
req.on("error", fail);
|
|
441
|
+
req.on("response", (res) => {
|
|
442
|
+
const firstByteAt = performance.now();
|
|
443
|
+
const chunks = [];
|
|
444
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
445
|
+
res.on("error", fail);
|
|
446
|
+
res.on("end", () => {
|
|
447
|
+
if (settled) return;
|
|
448
|
+
settled = true;
|
|
449
|
+
clearTimeout(timer);
|
|
450
|
+
signal.removeEventListener("abort", onAbort);
|
|
451
|
+
const status = res.statusCode ?? 0;
|
|
452
|
+
const ttfb = firstByteAt - Math.max(sentAt || started, connectedAt);
|
|
453
|
+
metrics.response(label, status, ttfb, performance.now() - started);
|
|
454
|
+
const buffer = Buffer.concat(chunks);
|
|
455
|
+
resolve3({
|
|
456
|
+
status,
|
|
457
|
+
ok: status >= 200 && status < 300,
|
|
458
|
+
headers: res.headers,
|
|
459
|
+
body: buffer,
|
|
460
|
+
text: () => buffer.toString("utf8"),
|
|
461
|
+
json: () => JSON.parse(buffer.toString("utf8"))
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
stats2.requests++;
|
|
466
|
+
metrics.counter("http.requests");
|
|
467
|
+
req.end(body);
|
|
468
|
+
});
|
|
469
|
+
};
|
|
470
|
+
const request = (method, path, options) => {
|
|
471
|
+
const pending = send(method, path, options);
|
|
472
|
+
const settled = pending.then(
|
|
473
|
+
() => void inFlight.delete(settled),
|
|
474
|
+
() => void inFlight.delete(settled)
|
|
475
|
+
);
|
|
476
|
+
inFlight.add(settled);
|
|
477
|
+
return pending;
|
|
478
|
+
};
|
|
479
|
+
return {
|
|
480
|
+
request,
|
|
481
|
+
get: (url, options) => request("GET", url, options),
|
|
482
|
+
post: (url, options) => request("POST", url, options),
|
|
483
|
+
put: (url, options) => request("PUT", url, options),
|
|
484
|
+
patch: (url, options) => request("PATCH", url, options),
|
|
485
|
+
delete: (url, options) => request("DELETE", url, options),
|
|
486
|
+
/** Closes the connections once every request has settled: destroying a socket would reset a request still on it. */
|
|
487
|
+
close: async () => {
|
|
488
|
+
await Promise.all(inFlight);
|
|
489
|
+
for (const agent of agents.values()) agent.destroy();
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// src/tools/bench/metrics.ts
|
|
495
|
+
import { createHistogram } from "perf_hooks";
|
|
496
|
+
var toMicros = (ms2) => Math.max(1, Math.round(ms2 * 1e3));
|
|
497
|
+
function distribution(histogram) {
|
|
498
|
+
if (histogram.count === 0) return { count: 0, min: 0, mean: 0, p50: 0, p95: 0, p99: 0, max: 0 };
|
|
499
|
+
const ms2 = (micros) => micros / 1e3;
|
|
500
|
+
return {
|
|
501
|
+
count: histogram.count,
|
|
502
|
+
min: ms2(histogram.min),
|
|
503
|
+
mean: ms2(histogram.mean),
|
|
504
|
+
p50: ms2(histogram.percentile(50)),
|
|
505
|
+
p95: ms2(histogram.percentile(95)),
|
|
506
|
+
p99: ms2(histogram.percentile(99)),
|
|
507
|
+
max: ms2(histogram.max)
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
var Metrics = class {
|
|
511
|
+
counters = /* @__PURE__ */ new Map();
|
|
512
|
+
intervalCounters = /* @__PURE__ */ new Map();
|
|
513
|
+
histograms = /* @__PURE__ */ new Map();
|
|
514
|
+
endpoints = /* @__PURE__ */ new Map();
|
|
515
|
+
counter(name, n = 1) {
|
|
516
|
+
this.counters.set(name, (this.counters.get(name) ?? 0) + n);
|
|
517
|
+
this.intervalCounters.set(name, (this.intervalCounters.get(name) ?? 0) + n);
|
|
518
|
+
}
|
|
519
|
+
histogram(name, ms2) {
|
|
520
|
+
let pair = this.histograms.get(name);
|
|
521
|
+
if (!pair) {
|
|
522
|
+
pair = { total: createHistogram(), interval: createHistogram() };
|
|
523
|
+
this.histograms.set(name, pair);
|
|
524
|
+
}
|
|
525
|
+
const value = toMicros(ms2);
|
|
526
|
+
pair.total.record(value);
|
|
527
|
+
pair.interval.record(value);
|
|
528
|
+
}
|
|
529
|
+
endpoint(label) {
|
|
530
|
+
let stats2 = this.endpoints.get(label);
|
|
531
|
+
if (!stats2) {
|
|
532
|
+
stats2 = {
|
|
533
|
+
requests: 0,
|
|
534
|
+
ttfb: createHistogram(),
|
|
535
|
+
total: createHistogram(),
|
|
536
|
+
codes: /* @__PURE__ */ new Map(),
|
|
537
|
+
timeouts: 0,
|
|
538
|
+
errors: 0
|
|
539
|
+
};
|
|
540
|
+
this.endpoints.set(label, stats2);
|
|
541
|
+
}
|
|
542
|
+
return stats2;
|
|
543
|
+
}
|
|
544
|
+
/** Records a response: `http.codes.<status>`, `http.response_time` (first byte) and, when the body was read, `http.total_time`. */
|
|
545
|
+
response(label, status, ttfbMs, totalMs) {
|
|
546
|
+
const stats2 = this.endpoint(label);
|
|
547
|
+
stats2.codes.set(status, (stats2.codes.get(status) ?? 0) + 1);
|
|
548
|
+
stats2.ttfb.record(toMicros(ttfbMs));
|
|
549
|
+
this.counter("http.responses");
|
|
550
|
+
this.counter(`http.codes.${status}`);
|
|
551
|
+
this.histogram("http.response_time", ttfbMs);
|
|
552
|
+
if (totalMs !== void 0) {
|
|
553
|
+
stats2.total.record(toMicros(totalMs));
|
|
554
|
+
this.histogram("http.total_time", totalMs);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
get(name) {
|
|
558
|
+
return this.counters.get(name) ?? 0;
|
|
559
|
+
}
|
|
560
|
+
/** Whole-run distribution of a histogram; undefined for a name nothing recorded. */
|
|
561
|
+
distribution(name) {
|
|
562
|
+
const pair = this.histograms.get(name);
|
|
563
|
+
return pair && distribution(pair.total);
|
|
564
|
+
}
|
|
565
|
+
histogramNames() {
|
|
566
|
+
return [...this.histograms.keys()];
|
|
567
|
+
}
|
|
568
|
+
/** The interval's counters and histograms, then a fresh interval. */
|
|
569
|
+
tick() {
|
|
570
|
+
const counters = Object.fromEntries(this.intervalCounters);
|
|
571
|
+
this.intervalCounters.clear();
|
|
572
|
+
const histograms = {};
|
|
573
|
+
for (const [name, pair] of this.histograms) {
|
|
574
|
+
if (pair.interval.count > 0) histograms[name] = distribution(pair.interval);
|
|
575
|
+
pair.interval.reset();
|
|
576
|
+
}
|
|
577
|
+
return { counters, histograms };
|
|
578
|
+
}
|
|
579
|
+
endpointSummaries() {
|
|
580
|
+
const result = {};
|
|
581
|
+
for (const [label, stats2] of this.endpoints) {
|
|
582
|
+
const codes = Object.fromEntries(
|
|
583
|
+
[...stats2.codes].sort(([a], [b]) => a - b).map(([code, n]) => [String(code), n])
|
|
584
|
+
);
|
|
585
|
+
const nonOk = [...stats2.codes].filter(([code]) => code < 200 || code > 299).reduce((sum, [, n]) => sum + n, 0);
|
|
586
|
+
result[label] = {
|
|
587
|
+
requests: stats2.requests,
|
|
588
|
+
ttfb: distribution(stats2.ttfb),
|
|
589
|
+
total: distribution(stats2.total),
|
|
590
|
+
codes,
|
|
591
|
+
nonOk,
|
|
592
|
+
timeouts: stats2.timeouts,
|
|
593
|
+
errors: stats2.errors
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
return result;
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
// src/tools/bench/scheduler.ts
|
|
601
|
+
import { performance as performance2 } from "perf_hooks";
|
|
602
|
+
function rampedAt(i, from, to, duration) {
|
|
603
|
+
const slope = (to - from) / (2 * duration);
|
|
604
|
+
if (slope === 0) return i / from;
|
|
605
|
+
return (-from + Math.sqrt(from * from + 4 * slope * i)) / (2 * slope);
|
|
606
|
+
}
|
|
607
|
+
function phaseArrivals(phase) {
|
|
608
|
+
if (phase.arrivalCount !== void 0) return phase.arrivalCount;
|
|
609
|
+
const rate = phase.arrivalRate ?? 0;
|
|
610
|
+
return Math.round((rate + (phase.rampTo ?? rate)) / 2 * phase.duration);
|
|
611
|
+
}
|
|
612
|
+
function* schedule(phases) {
|
|
613
|
+
let offset = 0;
|
|
614
|
+
for (const [index, phase] of phases.entries()) {
|
|
615
|
+
yield { kind: "phase", at: offset, phase: index };
|
|
616
|
+
const count2 = phaseArrivals(phase);
|
|
617
|
+
const rate = phase.arrivalRate ?? 0;
|
|
618
|
+
for (let i = 0; i < count2; i++) {
|
|
619
|
+
let seconds2;
|
|
620
|
+
if (phase.arrivalCount !== void 0) seconds2 = i * phase.duration / count2;
|
|
621
|
+
else if (phase.rampTo !== void 0 && phase.rampTo !== rate)
|
|
622
|
+
seconds2 = rampedAt(i, rate, phase.rampTo, phase.duration);
|
|
623
|
+
else seconds2 = i / rate;
|
|
624
|
+
yield { kind: "arrival", at: offset + Math.min(seconds2, phase.duration) * 1e3, phase: index };
|
|
625
|
+
}
|
|
626
|
+
offset += phase.duration * 1e3;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
function roleSequencer(roles) {
|
|
630
|
+
const total = roles.reduce((sum, role) => sum + role.weight, 0);
|
|
631
|
+
const current = roles.map(() => 0);
|
|
632
|
+
return () => {
|
|
633
|
+
let best = 0;
|
|
634
|
+
for (let i = 0; i < roles.length; i++) {
|
|
635
|
+
current[i] += roles[i].weight;
|
|
636
|
+
if (current[i] > current[best]) best = i;
|
|
637
|
+
}
|
|
638
|
+
current[best] -= total;
|
|
639
|
+
return roles[best];
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
function play(events, onEvent, signal) {
|
|
643
|
+
const iterator = events[Symbol.iterator]();
|
|
644
|
+
const start = performance2.now();
|
|
645
|
+
let next = iterator.next();
|
|
646
|
+
return new Promise((resolve3) => {
|
|
647
|
+
let timer;
|
|
648
|
+
const stop = () => {
|
|
649
|
+
clearTimeout(timer);
|
|
650
|
+
resolve3();
|
|
651
|
+
};
|
|
652
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
653
|
+
const tick = () => {
|
|
654
|
+
const now = performance2.now() - start;
|
|
655
|
+
while (!next.done && next.value.at <= now && !signal.aborted) {
|
|
656
|
+
onEvent(next.value);
|
|
657
|
+
next = iterator.next();
|
|
658
|
+
}
|
|
659
|
+
if (next.done || signal.aborted) {
|
|
660
|
+
signal.removeEventListener("abort", stop);
|
|
661
|
+
return resolve3();
|
|
662
|
+
}
|
|
663
|
+
timer = setTimeout(tick, Math.max(0, next.value.at - (performance2.now() - start)));
|
|
664
|
+
};
|
|
665
|
+
tick();
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// src/tools/bench/sse.ts
|
|
670
|
+
import { performance as performance3 } from "perf_hooks";
|
|
671
|
+
var CONNECT_TIMEOUT_MS = 1e4;
|
|
672
|
+
function createSseParser(onEvent) {
|
|
673
|
+
let buffer = "";
|
|
674
|
+
let event = "";
|
|
675
|
+
let data = [];
|
|
676
|
+
let id;
|
|
677
|
+
const line = (text) => {
|
|
678
|
+
if (text === "") {
|
|
679
|
+
if (data.length > 0)
|
|
680
|
+
onEvent({ event: event || "message", data: data.join("\n"), ...id !== void 0 && { id } });
|
|
681
|
+
event = "";
|
|
682
|
+
data = [];
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
if (text.startsWith(":")) return;
|
|
686
|
+
const colon = text.indexOf(":");
|
|
687
|
+
const field = colon === -1 ? text : text.slice(0, colon);
|
|
688
|
+
const value = colon === -1 ? "" : text.slice(colon + 1).replace(/^ /, "");
|
|
689
|
+
if (field === "event") event = value;
|
|
690
|
+
else if (field === "data") data.push(value);
|
|
691
|
+
else if (field === "id") id = value;
|
|
692
|
+
};
|
|
693
|
+
return (chunk) => {
|
|
694
|
+
buffer += chunk;
|
|
695
|
+
let start = 0;
|
|
696
|
+
for (let i = 0; i < buffer.length; i++) {
|
|
697
|
+
const char = buffer[i];
|
|
698
|
+
if (char !== "\n" && char !== "\r") continue;
|
|
699
|
+
if (char === "\r" && i === buffer.length - 1) break;
|
|
700
|
+
line(buffer.slice(start, i));
|
|
701
|
+
if (char === "\r" && buffer[i + 1] === "\n") i++;
|
|
702
|
+
start = i + 1;
|
|
703
|
+
}
|
|
704
|
+
buffer = buffer.slice(start);
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
async function readSse(url, onEvent, options, metrics, vuSignal) {
|
|
708
|
+
const label = options.name ?? `SSE ${new URL(url).pathname}`;
|
|
709
|
+
const stats2 = metrics.endpoint(label);
|
|
710
|
+
const connect = new AbortController();
|
|
711
|
+
const signal = AbortSignal.any([vuSignal, connect.signal, ...options.signal ? [options.signal] : []]);
|
|
712
|
+
const timer = setTimeout(
|
|
713
|
+
() => connect.abort(
|
|
714
|
+
Object.assign(new Error(`${label} sent no headers in ${CONNECT_TIMEOUT_MS} ms`), { code: "ETIMEDOUT" })
|
|
715
|
+
),
|
|
716
|
+
CONNECT_TIMEOUT_MS
|
|
717
|
+
);
|
|
718
|
+
const started = performance3.now();
|
|
719
|
+
stats2.requests++;
|
|
720
|
+
metrics.counter("http.requests");
|
|
721
|
+
let res;
|
|
722
|
+
try {
|
|
723
|
+
res = await fetch(url, { headers: { accept: "text/event-stream", ...options.headers }, signal });
|
|
724
|
+
} catch (error) {
|
|
725
|
+
clearTimeout(timer);
|
|
726
|
+
if (options.signal?.aborted || vuSignal.aborted) return 0;
|
|
727
|
+
const cause = connect.signal.aborted ? connect.signal.reason : error;
|
|
728
|
+
const code = errorCode(cause);
|
|
729
|
+
if (code === "ETIMEDOUT") {
|
|
730
|
+
stats2.timeouts++;
|
|
731
|
+
metrics.counter("http.timeouts");
|
|
732
|
+
} else stats2.errors++;
|
|
733
|
+
metrics.counter(`errors.${code}`);
|
|
734
|
+
throw Object.assign(cause instanceof Error ? cause : new Error(String(cause)), { [COUNTED]: true });
|
|
735
|
+
}
|
|
736
|
+
clearTimeout(timer);
|
|
737
|
+
metrics.response(label, res.status, performance3.now() - started);
|
|
738
|
+
if (!res.ok || !res.body) {
|
|
739
|
+
await res.body?.cancel();
|
|
740
|
+
return res.status;
|
|
741
|
+
}
|
|
742
|
+
const push = createSseParser(onEvent);
|
|
743
|
+
const decoder = new TextDecoder();
|
|
744
|
+
try {
|
|
745
|
+
for await (const chunk of res.body) push(decoder.decode(chunk, { stream: true }));
|
|
746
|
+
} catch (error) {
|
|
747
|
+
if (!signal.aborted) throw error;
|
|
748
|
+
}
|
|
749
|
+
return res.status;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// src/tools/bench/run.ts
|
|
753
|
+
var DEFAULT_INTERVAL_MS = 5e3;
|
|
754
|
+
var LAG_WARN_MS = 50;
|
|
755
|
+
var LAG_RESOLUTION_MS = 10;
|
|
756
|
+
var DEFAULT_DRAIN_TIMEOUT_S = 300;
|
|
757
|
+
var ABORT_GRACE_S = 5;
|
|
758
|
+
var LOGS_KEPT = 30;
|
|
759
|
+
function defineScenario(scenario) {
|
|
760
|
+
return scenario;
|
|
761
|
+
}
|
|
762
|
+
function plan(scenario, short) {
|
|
763
|
+
if (scenario.run) {
|
|
764
|
+
const fn = scenario.run;
|
|
765
|
+
return {
|
|
766
|
+
events: [
|
|
767
|
+
{ kind: "phase", at: 0, phase: 0 },
|
|
768
|
+
{ kind: "arrival", at: 0, phase: 0 }
|
|
769
|
+
],
|
|
770
|
+
phases: [{ name: "run", duration: 0 }],
|
|
771
|
+
plannedS: null,
|
|
772
|
+
pick: () => ({ role: null, fn })
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
const roles = scenario.roles ?? [{ name: "", weight: 1, vu: scenario.vu }];
|
|
776
|
+
if (roles.some((role) => !(role.weight > 0))) throw new Error("every role needs a weight above 0");
|
|
777
|
+
const next = roleSequencer(roles);
|
|
778
|
+
const pick = () => {
|
|
779
|
+
const role = next();
|
|
780
|
+
return { role: scenario.roles ? role.name : null, fn: role.vu };
|
|
781
|
+
};
|
|
782
|
+
if (short) {
|
|
783
|
+
let i = 0;
|
|
784
|
+
const events = [
|
|
785
|
+
{ kind: "phase", at: 0, phase: 0 },
|
|
786
|
+
...roles.map(() => ({ kind: "arrival", at: 0, phase: 0 }))
|
|
787
|
+
];
|
|
788
|
+
const pickEach = () => {
|
|
789
|
+
const role = roles[i++ % roles.length];
|
|
790
|
+
return { role: scenario.roles ? role.name : null, fn: role.vu };
|
|
791
|
+
};
|
|
792
|
+
return { events, phases: [{ name: "smoke", duration: 1 }], plannedS: 1, pick: pickEach };
|
|
793
|
+
}
|
|
794
|
+
const plannedS = scenario.phases.reduce((sum, phase) => sum + phase.duration, 0);
|
|
795
|
+
return { events: schedule(scenario.phases), phases: scenario.phases, plannedS, pick };
|
|
796
|
+
}
|
|
797
|
+
function logFileFor(dataDir, name, startedAt) {
|
|
798
|
+
const dir = join2(dataDir, "logs");
|
|
799
|
+
mkdirSync2(dir, { recursive: true });
|
|
800
|
+
const old = readdirSync(dir).filter((file) => file.startsWith(`${name}-`) && file.endsWith(".jsonl")).sort();
|
|
801
|
+
for (const file of old.slice(0, Math.max(0, old.length - LOGS_KEPT + 1))) rmSync(join2(dir, file), { force: true });
|
|
802
|
+
return join2(dir, `${name}-${startedAt.toISOString().slice(0, 19).replaceAll(":", "-")}.jsonl`);
|
|
803
|
+
}
|
|
804
|
+
function phaseText(phase) {
|
|
805
|
+
if (phase.arrivalCount !== void 0) return `${phase.arrivalCount} VUs over ${phase.duration}s`;
|
|
806
|
+
const rate = phase.arrivalRate ?? 0;
|
|
807
|
+
if (phase.rampTo !== void 0 && phase.rampTo !== rate) return `${rate} \u2192 ${phase.rampTo}/s over ${phase.duration}s`;
|
|
808
|
+
return `${rate}/s for ${phase.duration}s`;
|
|
809
|
+
}
|
|
810
|
+
async function runScenario(scenario, options) {
|
|
811
|
+
const { name, target, dataDir, short = false, quiet = false } = options;
|
|
812
|
+
const metrics = new Metrics();
|
|
813
|
+
const output = options.output ?? process5.stdout;
|
|
814
|
+
const display = new LiveDisplay(Boolean(output.isTTY), output);
|
|
815
|
+
const startedAt = /* @__PURE__ */ new Date();
|
|
816
|
+
const logFile = logFileFor(dataDir, name, startedAt);
|
|
817
|
+
const log = (entry) => appendFileSync(logFile, `${JSON.stringify(entry)}
|
|
818
|
+
`);
|
|
819
|
+
const { events, phases, plannedS, pick } = plan(scenario, short);
|
|
820
|
+
const declared = new Set(scenario.counters ?? []);
|
|
821
|
+
const primaryKey = scenario.primary ?? "http.response_time.p95";
|
|
822
|
+
const { name: primaryName, stat: primaryStat } = splitMetricKey(primaryKey);
|
|
823
|
+
const attachments = {};
|
|
824
|
+
const errors = {};
|
|
825
|
+
const showPhases = !(quiet && output.isTTY);
|
|
826
|
+
const stop = new AbortController();
|
|
827
|
+
const lag = monitorEventLoopDelay({ resolution: LAG_RESOLUTION_MS });
|
|
828
|
+
lag.enable();
|
|
829
|
+
let lagP99Max = 0;
|
|
830
|
+
let lagWarning = null;
|
|
831
|
+
let phaseName = phases[0]?.name ?? "";
|
|
832
|
+
let active = 0;
|
|
833
|
+
let index = 0;
|
|
834
|
+
let onIdle = null;
|
|
835
|
+
const started = performance4.now();
|
|
836
|
+
let lastTick = started;
|
|
837
|
+
const launch = () => {
|
|
838
|
+
const { role, fn } = pick();
|
|
839
|
+
const client = createHttpClient(target, metrics, stop.signal);
|
|
840
|
+
const sockets = [];
|
|
841
|
+
const resolveUrl = (url) => /^[a-z]+:\/\//.test(url) ? url : `${target}${url}`;
|
|
842
|
+
const vu = {
|
|
843
|
+
index: index++,
|
|
844
|
+
role,
|
|
845
|
+
short,
|
|
846
|
+
signal: stop.signal,
|
|
847
|
+
http: client,
|
|
848
|
+
sse: (url, onEvent, sseOptions = {}) => readSse(resolveUrl(url), onEvent, sseOptions, metrics, stop.signal),
|
|
849
|
+
ws: (url, protocols) => new Promise((resolve3, reject) => {
|
|
850
|
+
const socket = new WebSocket(resolveUrl(url).replace(/^http/, "ws"), protocols);
|
|
851
|
+
sockets.push(socket);
|
|
852
|
+
socket.addEventListener("open", () => resolve3(socket), { once: true });
|
|
853
|
+
socket.addEventListener(
|
|
854
|
+
"error",
|
|
855
|
+
() => reject(Object.assign(new Error(`WebSocket ${url} did not open`), { code: "EWEBSOCKET" })),
|
|
856
|
+
{
|
|
857
|
+
once: true
|
|
858
|
+
}
|
|
859
|
+
);
|
|
860
|
+
}),
|
|
861
|
+
think: (thinkMs) => delay(thinkMs, void 0, { signal: stop.signal }),
|
|
862
|
+
counter: (counter, n) => metrics.counter(counter, n),
|
|
863
|
+
histogram: (histogram, value) => metrics.histogram(histogram, value),
|
|
864
|
+
log: (text) => display.log(text),
|
|
865
|
+
attach: (key, data) => {
|
|
866
|
+
attachments[key] = data;
|
|
867
|
+
}
|
|
868
|
+
};
|
|
869
|
+
metrics.counter("vusers.created");
|
|
870
|
+
active++;
|
|
871
|
+
Promise.resolve().then(() => fn(vu)).then(
|
|
872
|
+
() => metrics.counter("vusers.completed"),
|
|
873
|
+
(error) => {
|
|
874
|
+
metrics.counter("vusers.failed");
|
|
875
|
+
const code = stop.signal.aborted ? "aborted" : errorCode(error);
|
|
876
|
+
if (!error?.[COUNTED] || stop.signal.aborted) metrics.counter(`errors.${code}`);
|
|
877
|
+
errors[code] ??= { message: error instanceof Error ? error.message : String(error) };
|
|
878
|
+
}
|
|
879
|
+
).finally(async () => {
|
|
880
|
+
await client.close();
|
|
881
|
+
for (const socket of sockets) socket.close();
|
|
882
|
+
active--;
|
|
883
|
+
if (active === 0) onIdle?.();
|
|
884
|
+
});
|
|
885
|
+
};
|
|
886
|
+
const tick = () => {
|
|
887
|
+
const now = performance4.now();
|
|
888
|
+
const intervalS = (now - lastTick) / 1e3;
|
|
889
|
+
lastTick = now;
|
|
890
|
+
const { counters: counters2, histograms } = metrics.tick();
|
|
891
|
+
const lagP99 = Math.max(0, lag.percentile(99) / 1e6 - LAG_RESOLUTION_MS);
|
|
892
|
+
lag.reset();
|
|
893
|
+
lagP99Max = Math.max(lagP99Max, lagP99);
|
|
894
|
+
const elapsedS = (now - started) / 1e3;
|
|
895
|
+
if (lagP99 > LAG_WARN_MS && !lagWarning) {
|
|
896
|
+
lagWarning = `generator event-loop lag p99 ${Math.round(lagP99)} ms (over ${LAG_WARN_MS} ms): the load generator is saturated, latencies include its own queueing`;
|
|
897
|
+
display.log(` \u26A0 ${lagWarning}`);
|
|
898
|
+
}
|
|
899
|
+
const custom = Object.entries(counters2).filter(([counter, n]) => declared.has(counter) && n > 0);
|
|
900
|
+
const primary = primaryName.startsWith("http.") ? void 0 : histograms[primaryName];
|
|
901
|
+
const vus = {
|
|
902
|
+
started: counters2["vusers.created"] ?? 0,
|
|
903
|
+
active,
|
|
904
|
+
completed: metrics.get("vusers.completed"),
|
|
905
|
+
failed: metrics.get("vusers.failed")
|
|
906
|
+
};
|
|
907
|
+
log({
|
|
908
|
+
type: "interval",
|
|
909
|
+
t: Math.round(elapsedS * 10) / 10,
|
|
910
|
+
phase: phaseName,
|
|
911
|
+
vus,
|
|
912
|
+
counters: counters2,
|
|
913
|
+
histograms,
|
|
914
|
+
lagP99Ms: lagP99
|
|
915
|
+
});
|
|
916
|
+
display.render(
|
|
917
|
+
liveLines({
|
|
918
|
+
scenario: name,
|
|
919
|
+
phase: phaseName,
|
|
920
|
+
elapsedS,
|
|
921
|
+
plannedS,
|
|
922
|
+
vus,
|
|
923
|
+
intervalS: Math.max(intervalS, 1e-3),
|
|
924
|
+
counters: counters2,
|
|
925
|
+
http: histograms["http.response_time"],
|
|
926
|
+
primary: primary && { name: primaryName, distribution: primary },
|
|
927
|
+
custom,
|
|
928
|
+
lagP99
|
|
929
|
+
})
|
|
930
|
+
);
|
|
931
|
+
};
|
|
932
|
+
if (!quiet) display.log(`${colors_default.dim("\u25B8")} running ${name}${short ? " (short)" : ""}: ${scenario.description}`);
|
|
933
|
+
const ticker = setInterval(tick, DEFAULT_INTERVAL_MS);
|
|
934
|
+
await play(
|
|
935
|
+
events,
|
|
936
|
+
(event) => {
|
|
937
|
+
if (event.kind === "arrival") return launch();
|
|
938
|
+
phaseName = phases[event.phase].name;
|
|
939
|
+
if (showPhases && !scenario.run && !short)
|
|
940
|
+
display.log(` ${colors_default.dim("phase")} ${phaseName} \xB7 ${phaseText(phases[event.phase])}`);
|
|
941
|
+
},
|
|
942
|
+
stop.signal
|
|
943
|
+
);
|
|
944
|
+
if (active > 0) {
|
|
945
|
+
if (!scenario.run) phaseName = "drain";
|
|
946
|
+
const drainS = scenario.drainTimeout ?? (scenario.run ? Number.POSITIVE_INFINITY : DEFAULT_DRAIN_TIMEOUT_S);
|
|
947
|
+
const idle = new Promise((resolve3) => {
|
|
948
|
+
onIdle = resolve3;
|
|
949
|
+
});
|
|
950
|
+
const timers = new AbortController();
|
|
951
|
+
const wait = (seconds2) => delay(seconds2 * 1e3, "timeout", { signal: timers.signal }).catch(() => null);
|
|
952
|
+
const timedOut = Number.isFinite(drainS) ? wait(drainS) : new Promise(() => {
|
|
953
|
+
});
|
|
954
|
+
if (await Promise.race([idle, timedOut]) === "timeout") {
|
|
955
|
+
display.log(` \u26A0 ${active} VUs still running after ${drainS} s: aborted`);
|
|
956
|
+
stop.abort();
|
|
957
|
+
await Promise.race([idle, wait(ABORT_GRACE_S)]);
|
|
958
|
+
}
|
|
959
|
+
timers.abort();
|
|
960
|
+
}
|
|
961
|
+
clearInterval(ticker);
|
|
962
|
+
tick();
|
|
963
|
+
lag.disable();
|
|
964
|
+
const durationS = (performance4.now() - started) / 1e3;
|
|
965
|
+
const counters = Object.fromEntries(metrics.counters);
|
|
966
|
+
const distributions = Object.fromEntries(
|
|
967
|
+
metrics.histogramNames().flatMap((histogram) => {
|
|
968
|
+
const d = metrics.distribution(histogram);
|
|
969
|
+
return d ? [[histogram, d]] : [];
|
|
970
|
+
})
|
|
971
|
+
);
|
|
972
|
+
const checks = evaluateChecks(scenario.checks ?? {}, { counters, distributions, short });
|
|
973
|
+
if (options.afterRun) {
|
|
974
|
+
display.render([`${name} ${colors_default.dim("\xB7 checking what the app recorded")}`]);
|
|
975
|
+
checks.push(...await options.afterRun({ counters, checks: [...checks], short }));
|
|
976
|
+
}
|
|
977
|
+
display.clear();
|
|
978
|
+
const ok = checks.every((check) => check.ok);
|
|
979
|
+
const requests = metrics.get("http.requests");
|
|
980
|
+
const http2 = distributions["http.response_time"] ?? null;
|
|
981
|
+
const endpoints = metrics.endpointSummaries();
|
|
982
|
+
const current = {
|
|
983
|
+
runner: RUNNER_EPOCH,
|
|
984
|
+
timestamp: startedAt.toISOString(),
|
|
985
|
+
scenario: name,
|
|
986
|
+
requests,
|
|
987
|
+
requestRate: Math.round(requests / durationS * 10) / 10,
|
|
988
|
+
mean: http2?.mean ?? 0,
|
|
989
|
+
p50: http2?.p50 ?? 0,
|
|
990
|
+
p95: http2?.p95 ?? 0,
|
|
991
|
+
p99: http2?.p99 ?? 0,
|
|
992
|
+
errors: Object.entries(counters).reduce((sum, [counter, n]) => counter.startsWith("errors.") ? sum + n : sum, 0),
|
|
993
|
+
vusersCreated: metrics.get("vusers.created"),
|
|
994
|
+
vusersFailed: metrics.get("vusers.failed"),
|
|
995
|
+
primary: { metric: primaryKey, value: primaryStat ? distributions[primaryName]?.[primaryStat] ?? 0 : 0 },
|
|
996
|
+
endpoints: Object.fromEntries(
|
|
997
|
+
Object.entries(endpoints).map(([label, e]) => [
|
|
998
|
+
label,
|
|
999
|
+
{
|
|
1000
|
+
requests: e.requests,
|
|
1001
|
+
p50: e.ttfb.p50,
|
|
1002
|
+
p95: e.ttfb.p95,
|
|
1003
|
+
p99: e.ttfb.p99,
|
|
1004
|
+
nonOk: e.nonOk,
|
|
1005
|
+
timeouts: e.timeouts
|
|
1006
|
+
}
|
|
1007
|
+
])
|
|
1008
|
+
)
|
|
1009
|
+
};
|
|
1010
|
+
const { baseline, older } = short ? { baseline: null, older: 0 } : loadBaseline(dataDir, name);
|
|
1011
|
+
const saved = !short && ok ? saveBaseline(dataDir, current) : null;
|
|
1012
|
+
const builtin = /^(vusers|http|errors)\./;
|
|
1013
|
+
const summary = {
|
|
1014
|
+
type: "summary",
|
|
1015
|
+
runner: RUNNER_EPOCH,
|
|
1016
|
+
scenario: name,
|
|
1017
|
+
startedAt: startedAt.toISOString(),
|
|
1018
|
+
durationS,
|
|
1019
|
+
short,
|
|
1020
|
+
vus: { created: current.vusersCreated, completed: metrics.get("vusers.completed"), failed: current.vusersFailed },
|
|
1021
|
+
requests,
|
|
1022
|
+
requestRate: current.requestRate,
|
|
1023
|
+
http: http2,
|
|
1024
|
+
endpoints,
|
|
1025
|
+
histograms: Object.fromEntries(
|
|
1026
|
+
Object.entries(distributions).filter(([histogram]) => !histogram.startsWith("http."))
|
|
1027
|
+
),
|
|
1028
|
+
counters,
|
|
1029
|
+
errors: Object.fromEntries(
|
|
1030
|
+
Object.entries(counters).flatMap(
|
|
1031
|
+
([counter, n]) => counter.startsWith("errors.") ? [[counter.slice(7), { count: n, message: errors[counter.slice(7)]?.message ?? "" }]] : []
|
|
1032
|
+
)
|
|
1033
|
+
),
|
|
1034
|
+
checks,
|
|
1035
|
+
generatorLagP99MaxMs: lagP99Max,
|
|
1036
|
+
attachments
|
|
1037
|
+
};
|
|
1038
|
+
log(summary);
|
|
1039
|
+
const view = {
|
|
1040
|
+
...summary,
|
|
1041
|
+
counters: Object.entries(counters).filter(([counter]) => !builtin.test(counter)),
|
|
1042
|
+
compared: !short && ok,
|
|
1043
|
+
baseline,
|
|
1044
|
+
olderBaselines: older,
|
|
1045
|
+
current,
|
|
1046
|
+
saved,
|
|
1047
|
+
logFile,
|
|
1048
|
+
lagWarning
|
|
1049
|
+
};
|
|
1050
|
+
if (quiet) display.log(compactLine(view));
|
|
1051
|
+
else for (const line of ["", ...summaryLines(view), ""]) display.log(line);
|
|
1052
|
+
return { ok, summary, current, baseline: ok ? baseline : null, logFile };
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// src/tools/bench/suite.ts
|
|
1056
|
+
import { readdirSync as readdirSync3 } from "fs";
|
|
1057
|
+
import { basename, join as join6 } from "path";
|
|
1058
|
+
import process8 from "process";
|
|
1059
|
+
import { setTimeout as sleep } from "timers/promises";
|
|
1060
|
+
import { pathToFileURL } from "url";
|
|
1061
|
+
import { select } from "@inquirer/prompts";
|
|
1062
|
+
|
|
1063
|
+
// src/utils/display.ts
|
|
1064
|
+
import { resolve as resolve2 } from "path";
|
|
1065
|
+
import process6 from "process";
|
|
1066
|
+
|
|
1067
|
+
// package.json
|
|
1068
|
+
var package_default = {
|
|
1069
|
+
name: "@cellajs/cli",
|
|
1070
|
+
version: "0.6.5",
|
|
1071
|
+
private: false,
|
|
1072
|
+
license: "MIT",
|
|
1073
|
+
description: "CLI for managing your monorepo and upgrading it with upstream Cella.",
|
|
1074
|
+
repository: {
|
|
1075
|
+
type: "git",
|
|
1076
|
+
url: "https://github.com/cellajs/cella-cli"
|
|
1077
|
+
},
|
|
1078
|
+
homepage: "https://cellajs.com",
|
|
1079
|
+
author: "CellaJS <info@cellajs.com>",
|
|
1080
|
+
engines: {
|
|
1081
|
+
node: "26.x"
|
|
1082
|
+
},
|
|
1083
|
+
type: "module",
|
|
1084
|
+
main: "./dist/index.js",
|
|
1085
|
+
exports: {
|
|
1086
|
+
".": "./dist/index.js",
|
|
1087
|
+
"./config": {
|
|
1088
|
+
types: "./dist/config.d.ts",
|
|
1089
|
+
import: "./dist/config.js"
|
|
1090
|
+
},
|
|
1091
|
+
"./bench": {
|
|
1092
|
+
types: "./dist/bench.d.ts",
|
|
1093
|
+
import: "./dist/bench.js"
|
|
1094
|
+
},
|
|
1095
|
+
"./style": {
|
|
1096
|
+
types: "./dist/style.d.ts",
|
|
1097
|
+
import: "./dist/style.js"
|
|
1098
|
+
},
|
|
1099
|
+
"./drive": {
|
|
1100
|
+
types: "./dist/drive.d.ts",
|
|
1101
|
+
import: "./dist/drive.js"
|
|
1102
|
+
},
|
|
1103
|
+
"./a11y": {
|
|
1104
|
+
types: "./dist/a11y.d.ts",
|
|
1105
|
+
import: "./dist/a11y.js"
|
|
1106
|
+
},
|
|
1107
|
+
"./screenshots": {
|
|
1108
|
+
types: "./dist/screenshots.d.ts",
|
|
1109
|
+
import: "./dist/screenshots.js"
|
|
1110
|
+
},
|
|
1111
|
+
"./pentest": {
|
|
1112
|
+
types: "./dist/pentest.d.ts",
|
|
1113
|
+
import: "./dist/pentest.js"
|
|
1114
|
+
},
|
|
1115
|
+
"./package.json": "./package.json"
|
|
1116
|
+
},
|
|
1117
|
+
files: [
|
|
1118
|
+
"dist",
|
|
1119
|
+
"index.js",
|
|
1120
|
+
"README.md"
|
|
1121
|
+
],
|
|
1122
|
+
bin: {
|
|
1123
|
+
cella: "index.js"
|
|
1124
|
+
},
|
|
1125
|
+
imports: {
|
|
1126
|
+
"#/*": "./src/*"
|
|
1127
|
+
},
|
|
1128
|
+
scripts: {
|
|
1129
|
+
start: "tsx index.ts",
|
|
1130
|
+
clean: "rimraf ./dist",
|
|
1131
|
+
build: "tsup",
|
|
1132
|
+
"test-build": "pnpm run build && node index.js --help",
|
|
1133
|
+
prepublishOnly: "pnpm run test:release",
|
|
1134
|
+
check: "pnpm ts && pnpm biome check --write .",
|
|
1135
|
+
ts: "tsgo --pretty",
|
|
1136
|
+
lint: "biome check .",
|
|
1137
|
+
"lint:fix": "biome check --write .",
|
|
1138
|
+
test: "vitest run",
|
|
1139
|
+
"test:release": "pnpm run build && vitest run --config vitest.release.config.ts",
|
|
1140
|
+
"test:watch": "vitest",
|
|
1141
|
+
prepare: "lefthook install"
|
|
1142
|
+
},
|
|
1143
|
+
dependencies: {
|
|
1144
|
+
"@inquirer/core": "^12.0.3",
|
|
1145
|
+
"@inquirer/prompts": "^8.7.2",
|
|
1146
|
+
"@pierre/diffs": "^1.5.1",
|
|
1147
|
+
"axe-core": "^4.14.0",
|
|
1148
|
+
commander: "^15.0.0",
|
|
1149
|
+
"ts-morph": "^28.0.0",
|
|
1150
|
+
yaml: "^2.9.1",
|
|
1151
|
+
zod: "^4.6.5"
|
|
1152
|
+
},
|
|
1153
|
+
devDependencies: {
|
|
1154
|
+
"@biomejs/biome": "^2.5.15",
|
|
1155
|
+
"@commitlint/cli": "^21.2.3",
|
|
1156
|
+
"@commitlint/config-conventional": "^21.2.3",
|
|
1157
|
+
"@evilmartians/lefthook": "^2.1.15",
|
|
1158
|
+
"@tailwindcss/node": "^4.3.3",
|
|
1159
|
+
"@types/node": "26.6.3",
|
|
1160
|
+
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
|
1161
|
+
playwright: "^1.64.1",
|
|
1162
|
+
rimraf: "^6.1.3",
|
|
1163
|
+
tailwindcss: "^4.3.3",
|
|
1164
|
+
tsup: "^8.5.1",
|
|
1165
|
+
tsx: "^4.23.15",
|
|
1166
|
+
typescript: "6.0.3",
|
|
1167
|
+
vitest: "^5.0.3"
|
|
1168
|
+
},
|
|
1169
|
+
publishConfig: {
|
|
1170
|
+
access: "public"
|
|
1171
|
+
},
|
|
1172
|
+
keywords: [
|
|
1173
|
+
"cella",
|
|
1174
|
+
"cellajs",
|
|
1175
|
+
"cli",
|
|
1176
|
+
"sync",
|
|
1177
|
+
"template"
|
|
1178
|
+
],
|
|
1179
|
+
packageManager: "pnpm@11.28.7",
|
|
1180
|
+
commitlint: {
|
|
1181
|
+
extends: [
|
|
1182
|
+
"@commitlint/config-conventional"
|
|
1183
|
+
],
|
|
1184
|
+
rules: {
|
|
1185
|
+
"type-enum": [
|
|
1186
|
+
2,
|
|
1187
|
+
"always",
|
|
1188
|
+
[
|
|
1189
|
+
"feat",
|
|
1190
|
+
"fix",
|
|
1191
|
+
"perf",
|
|
1192
|
+
"refactor",
|
|
1193
|
+
"revert",
|
|
1194
|
+
"docs",
|
|
1195
|
+
"chore",
|
|
1196
|
+
"build",
|
|
1197
|
+
"ci",
|
|
1198
|
+
"style",
|
|
1199
|
+
"test"
|
|
1200
|
+
]
|
|
1201
|
+
],
|
|
1202
|
+
"header-max-length": [
|
|
1203
|
+
2,
|
|
1204
|
+
"always",
|
|
1205
|
+
120
|
|
1206
|
+
],
|
|
1207
|
+
"body-max-line-length": [
|
|
1208
|
+
0,
|
|
1209
|
+
"always",
|
|
1210
|
+
200
|
|
1211
|
+
],
|
|
1212
|
+
"footer-max-line-length": [
|
|
1213
|
+
0,
|
|
1214
|
+
"always",
|
|
1215
|
+
200
|
|
1216
|
+
]
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
};
|
|
1220
|
+
|
|
1221
|
+
// src/utils/git.ts
|
|
1222
|
+
import { execFile } from "child_process";
|
|
1223
|
+
import { existsSync as existsSync2 } from "fs";
|
|
1224
|
+
import { mkdir as mkdir2, readdir, rmdir, unlink } from "fs/promises";
|
|
1225
|
+
import { dirname as dirname2, join as join4, resolve } from "path";
|
|
1226
|
+
import { promisify } from "util";
|
|
1227
|
+
|
|
1228
|
+
// src/utils/manifest.ts
|
|
1229
|
+
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
1230
|
+
import { dirname, join as join3 } from "path";
|
|
1231
|
+
|
|
1232
|
+
// src/utils/git.ts
|
|
1233
|
+
var execFileAsync = promisify(execFile);
|
|
1234
|
+
var MAX_BUFFER = 50 * 1024 * 1024;
|
|
1235
|
+
|
|
1236
|
+
// src/utils/display.ts
|
|
1237
|
+
var VERSION = package_default.version;
|
|
1238
|
+
var DIVIDER = "\u2500".repeat(60);
|
|
1239
|
+
var MENU_DIVIDER = "\u2500".repeat(40);
|
|
1240
|
+
var STEP_LABEL_WIDTH = 9;
|
|
1241
|
+
var STEP_INDENT = 2 + STEP_LABEL_WIDTH + 2;
|
|
1242
|
+
var warningMark = colors_default.yellow("\u26A0");
|
|
1243
|
+
var checkMark = colors_default.green("\u2713");
|
|
1244
|
+
var activeSpinner = null;
|
|
1245
|
+
var jsonMode = false;
|
|
1246
|
+
function createSpinner(text) {
|
|
1247
|
+
stopSpinner();
|
|
1248
|
+
const enabled = !jsonMode && !getEnv("VITEST") && getEnv("NODE_ENV") !== "test" && !!process6.stdout.isTTY;
|
|
1249
|
+
const frames = ["-", "\\", "|", "/"];
|
|
1250
|
+
let frame = 0;
|
|
1251
|
+
let timer;
|
|
1252
|
+
const spinner = {
|
|
1253
|
+
text,
|
|
1254
|
+
stop() {
|
|
1255
|
+
clearInterval(timer);
|
|
1256
|
+
if (enabled) process6.stdout.write("\r\x1B[2K");
|
|
1257
|
+
}
|
|
1258
|
+
};
|
|
1259
|
+
const render = () => process6.stdout.write(`\r${colors_default.cyan(frames[frame++ % frames.length])} ${spinner.text}`);
|
|
1260
|
+
if (enabled) {
|
|
1261
|
+
render();
|
|
1262
|
+
timer = setInterval(render, 80);
|
|
1263
|
+
}
|
|
1264
|
+
activeSpinner = spinner;
|
|
1265
|
+
return spinner;
|
|
1266
|
+
}
|
|
1267
|
+
function printStep(label, detail, mark = checkMark) {
|
|
1268
|
+
stopSpinner();
|
|
1269
|
+
const [first = "", ...rest] = (detail ?? "").split("\n");
|
|
1270
|
+
console.info(`${mark} ${colors_default.bold(label.padEnd(STEP_LABEL_WIDTH))} ${first}`.trimEnd());
|
|
1271
|
+
for (const line of rest) console.info(line ? `${" ".repeat(STEP_INDENT)}${line}` : "");
|
|
1272
|
+
}
|
|
1273
|
+
function spinnerFail(message) {
|
|
1274
|
+
stopSpinner();
|
|
1275
|
+
console.info(`${colors_default.red("\u2717")} ${message}`);
|
|
1276
|
+
}
|
|
1277
|
+
function stopSpinner() {
|
|
1278
|
+
if (activeSpinner) {
|
|
1279
|
+
activeSpinner.stop();
|
|
1280
|
+
activeSpinner = null;
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
var statusConfig = {
|
|
1284
|
+
behind: { icon: colors_default.cyan("\u2193"), label: "behind", color: colors_default.cyan, description: "upstream changed, will update" },
|
|
1285
|
+
diverged: { icon: colors_default.magenta("\u21C5"), label: "diverged", color: colors_default.magenta, description: "both changed, will merge" },
|
|
1286
|
+
drifted: { icon: colors_default.yellow("!"), label: "drifted", color: colors_default.yellow, description: "fork changed (at risk)" },
|
|
1287
|
+
ahead: { icon: colors_default.blue("\u2191"), label: "ahead", color: colors_default.blue, description: "fork changed (protected)" },
|
|
1288
|
+
local: { icon: colors_default.green("+"), label: "local", color: colors_default.green, description: "local file in cella folders" },
|
|
1289
|
+
pinned: { icon: colors_default.green("\u2A00"), label: "pinned", color: colors_default.green, description: "both changed, fork wins" },
|
|
1290
|
+
ignored: { icon: colors_default.gray("\u2A02"), label: "ignored", color: colors_default.gray },
|
|
1291
|
+
identical: { icon: colors_default.gray("\u2713"), label: "identical", color: colors_default.gray, description: "no changes" },
|
|
1292
|
+
deleted: { icon: colors_default.red("\u2717"), label: "deleted", color: colors_default.red },
|
|
1293
|
+
renamed: { icon: colors_default.blue("\u2192"), label: "renamed", color: colors_default.blue }
|
|
1294
|
+
};
|
|
1295
|
+
var summaryStatusConfig = {
|
|
1296
|
+
...statusConfig,
|
|
1297
|
+
managed: {
|
|
1298
|
+
icon: colors_default.cyan("\u25C7"),
|
|
1299
|
+
label: "managed",
|
|
1300
|
+
color: colors_default.cyan,
|
|
1301
|
+
description: "package.jsons & cella/cella.config.ts handled separately"
|
|
1302
|
+
},
|
|
1303
|
+
ignored: { ...statusConfig.ignored, description: "protected by ignored config" }
|
|
1304
|
+
};
|
|
1305
|
+
|
|
1306
|
+
// src/utils/errors.ts
|
|
1307
|
+
function errorMessage(error) {
|
|
1308
|
+
return error instanceof Error ? error.message : String(error);
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// src/tools/bench/lock.ts
|
|
1312
|
+
import { mkdirSync as mkdirSync3, readdirSync as readdirSync2, readFileSync as readFileSync2, unlinkSync, writeFileSync as writeFileSync2 } from "fs";
|
|
1313
|
+
import { join as join5 } from "path";
|
|
1314
|
+
import process7 from "process";
|
|
1315
|
+
var isAlive = (pid) => {
|
|
1316
|
+
try {
|
|
1317
|
+
process7.kill(pid, 0);
|
|
1318
|
+
return true;
|
|
1319
|
+
} catch (error) {
|
|
1320
|
+
return error.code === "EPERM";
|
|
1321
|
+
}
|
|
1322
|
+
};
|
|
1323
|
+
var readLock = (file) => {
|
|
1324
|
+
try {
|
|
1325
|
+
return JSON.parse(readFileSync2(file, "utf8"));
|
|
1326
|
+
} catch {
|
|
1327
|
+
return null;
|
|
1328
|
+
}
|
|
1329
|
+
};
|
|
1330
|
+
function acquireLock(dataDir, target, scenario) {
|
|
1331
|
+
const dir = join5(dataDir, "locks");
|
|
1332
|
+
mkdirSync3(dir, { recursive: true });
|
|
1333
|
+
const { host } = new URL(target);
|
|
1334
|
+
const file = join5(dir, `${host.replace(/[^a-z0-9.-]/gi, "_")}.json`);
|
|
1335
|
+
const info = { pid: process7.pid, scenario, startedAt: (/* @__PURE__ */ new Date()).toISOString(), target };
|
|
1336
|
+
for (let attempt = 0; ; attempt++) {
|
|
1337
|
+
try {
|
|
1338
|
+
writeFileSync2(file, JSON.stringify(info), { flag: "wx" });
|
|
1339
|
+
break;
|
|
1340
|
+
} catch (error) {
|
|
1341
|
+
if (error.code !== "EEXIST" || attempt > 2) throw error;
|
|
1342
|
+
const holder = readLock(file);
|
|
1343
|
+
if (holder && isAlive(holder.pid)) return { holder };
|
|
1344
|
+
try {
|
|
1345
|
+
unlinkSync(file);
|
|
1346
|
+
} catch {
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
const release = () => {
|
|
1351
|
+
if (readLock(file)?.pid !== process7.pid) return;
|
|
1352
|
+
try {
|
|
1353
|
+
unlinkSync(file);
|
|
1354
|
+
} catch {
|
|
1355
|
+
}
|
|
1356
|
+
};
|
|
1357
|
+
process7.once("exit", release);
|
|
1358
|
+
const others = readdirSync2(dir).map((name) => join5(dir, name)).filter((path) => path !== file).flatMap((path) => readLock(path) ?? []).filter((other) => isAlive(other.pid));
|
|
1359
|
+
return {
|
|
1360
|
+
lock: {
|
|
1361
|
+
update: (name) => writeFileSync2(file, JSON.stringify({ ...info, scenario: name })),
|
|
1362
|
+
release,
|
|
1363
|
+
others
|
|
1364
|
+
}
|
|
1365
|
+
};
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
// src/tools/bench/request.ts
|
|
1369
|
+
var BENCH_REQUEST_ENV = "CELLA_BENCH";
|
|
1370
|
+
|
|
1371
|
+
// src/tools/bench/suite.ts
|
|
1372
|
+
var DEFAULT_COOLDOWN_S = 15;
|
|
1373
|
+
function readRequest() {
|
|
1374
|
+
const raw = getEnv(BENCH_REQUEST_ENV);
|
|
1375
|
+
if (!raw) throw new Error("this is the app side of `pnpm cella bench`: run that instead");
|
|
1376
|
+
return JSON.parse(raw);
|
|
1377
|
+
}
|
|
1378
|
+
var isScenario = (value) => typeof value === "object" && value !== null && "description" in value && typeof value.description === "string";
|
|
1379
|
+
async function loadScenario(dir, name) {
|
|
1380
|
+
const { default: scenario } = await import(pathToFileURL(join6(dir, `${name}.ts`)).href);
|
|
1381
|
+
if (!isScenario(scenario)) throw new Error(`scenarios/${name}.ts has no \`export default defineScenario(...)\``);
|
|
1382
|
+
return scenario;
|
|
1383
|
+
}
|
|
1384
|
+
async function pickScenario(dir) {
|
|
1385
|
+
const names = readdirSync3(dir).filter((file) => file.endsWith(".ts")).map((file) => basename(file, ".ts")).sort();
|
|
1386
|
+
const choices = [];
|
|
1387
|
+
for (const name of names) {
|
|
1388
|
+
const { description } = await loadScenario(dir, name);
|
|
1389
|
+
choices.push({ value: name, name: `${name.padEnd(22)}${colors_default.dim(description)}` });
|
|
1390
|
+
}
|
|
1391
|
+
const picked = await select({
|
|
1392
|
+
message: "select a scenario",
|
|
1393
|
+
choices: [
|
|
1394
|
+
...choices,
|
|
1395
|
+
{ type: "separator", separator: MENU_DIVIDER },
|
|
1396
|
+
{ value: "", name: colors_default.red(`${"exit".padEnd(22)}${colors_default.dim("quit without running")}`) }
|
|
1397
|
+
],
|
|
1398
|
+
loop: false
|
|
1399
|
+
});
|
|
1400
|
+
return picked || void 0;
|
|
1401
|
+
}
|
|
1402
|
+
async function runRequest(bench, request) {
|
|
1403
|
+
const picked = request.scenarios.length > 0 ? void 0 : await pickScenario(request.scenariosDir);
|
|
1404
|
+
const names = picked ? [picked] : request.scenarios;
|
|
1405
|
+
if (names.length === 0) return 0;
|
|
1406
|
+
const scenarios = [];
|
|
1407
|
+
for (const name of names) scenarios.push([name, await loadScenario(request.scenariosDir, name)]);
|
|
1408
|
+
if (bench.preflight) {
|
|
1409
|
+
createSpinner(`checking the stack at ${bench.target}`);
|
|
1410
|
+
await bench.preflight();
|
|
1411
|
+
}
|
|
1412
|
+
printStep("stack", bench.target);
|
|
1413
|
+
const claimed = acquireLock(request.dataDir, bench.target, request.all ? "--all" : names[0]);
|
|
1414
|
+
if ("holder" in claimed) {
|
|
1415
|
+
const { pid, scenario, startedAt } = claimed.holder;
|
|
1416
|
+
const message = `another bench run holds ${bench.target}: pid ${pid} runs ${scenario} since ${new Date(startedAt).toLocaleTimeString("en-GB")}`;
|
|
1417
|
+
throw new Error(message);
|
|
1418
|
+
}
|
|
1419
|
+
const { lock } = claimed;
|
|
1420
|
+
for (const other of lock.others) {
|
|
1421
|
+
console.info(
|
|
1422
|
+
`${warningMark} bench pid ${other.pid} runs ${other.scenario} on ${other.target}: it shares this machine, numbers will be skewed`
|
|
1423
|
+
);
|
|
1424
|
+
}
|
|
1425
|
+
if (bench.seed && !request.skipSeed) {
|
|
1426
|
+
createSpinner("seeding the bench data");
|
|
1427
|
+
await bench.seed();
|
|
1428
|
+
printStep("seeded", colors_default.dim("bench data written again"));
|
|
1429
|
+
}
|
|
1430
|
+
await bench.beforeAll?.({ skipSeed: request.skipSeed });
|
|
1431
|
+
const cooldownS = request.all && !request.short ? bench.cooldownSeconds ?? DEFAULT_COOLDOWN_S : 0;
|
|
1432
|
+
const rows = [];
|
|
1433
|
+
for (const [index, [name, scenario]] of scenarios.entries()) {
|
|
1434
|
+
lock.update(name);
|
|
1435
|
+
const state = await bench.beforeEach?.(name);
|
|
1436
|
+
let columns = {};
|
|
1437
|
+
const result = await runScenario(scenario, {
|
|
1438
|
+
name,
|
|
1439
|
+
target: bench.target,
|
|
1440
|
+
dataDir: request.dataDir,
|
|
1441
|
+
short: request.short,
|
|
1442
|
+
quiet: request.all,
|
|
1443
|
+
afterRun: async (outcome) => {
|
|
1444
|
+
if (!bench.afterEach) return [];
|
|
1445
|
+
const app = await bench.afterEach(outcome, state);
|
|
1446
|
+
columns = app.columns ?? {};
|
|
1447
|
+
return app.checks;
|
|
1448
|
+
}
|
|
1449
|
+
});
|
|
1450
|
+
rows.push({ name, ok: result.ok, current: result.current, baseline: result.baseline, columns });
|
|
1451
|
+
if (cooldownS > 0 && index < scenarios.length - 1) {
|
|
1452
|
+
const spinner = createSpinner(`cooling down ${cooldownS} s`);
|
|
1453
|
+
await sleep(cooldownS * 1e3);
|
|
1454
|
+
spinner.stop();
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
lock.release();
|
|
1458
|
+
const failed = rows.filter((row) => !row.ok).map((row) => row.name);
|
|
1459
|
+
if (request.all && !request.short) for (const line of suiteLines(rows)) console.info(line);
|
|
1460
|
+
else if (request.all && failed.length === 0) console.info(`
|
|
1461
|
+
${colors_default.green("\u2713")} every scenario passed its short run`);
|
|
1462
|
+
else if (request.all) console.info(`
|
|
1463
|
+
${colors_default.red("\u2717")} the short run failed for ${failed.join(", ")}`);
|
|
1464
|
+
return failed.length > 0 ? 1 : 0;
|
|
1465
|
+
}
|
|
1466
|
+
async function runBench(bench) {
|
|
1467
|
+
process8.once("SIGINT", () => process8.exit(130));
|
|
1468
|
+
process8.once("SIGTERM", () => process8.exit(143));
|
|
1469
|
+
try {
|
|
1470
|
+
process8.exitCode = await runRequest(bench, readRequest());
|
|
1471
|
+
} catch (error) {
|
|
1472
|
+
spinnerFail(errorMessage(error));
|
|
1473
|
+
process8.exitCode = 1;
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
export {
|
|
1477
|
+
defineScenario,
|
|
1478
|
+
runBench
|
|
1479
|
+
};
|
|
1480
|
+
//# sourceMappingURL=bench.js.map
|