bandit-cli 1.0.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/README.md +1 -0
- package/dist/cli/api.js +374 -0
- package/dist/cli/bench.js +135 -0
- package/dist/cli/doctor.js +231 -0
- package/dist/cli/env.js +108 -0
- package/dist/cli/output.js +138 -0
- package/dist/cli/ports.js +177 -0
- package/dist/core/auditor.js +32 -0
- package/dist/core/context.js +7 -0
- package/dist/core/types.js +1 -0
- package/dist/index.js +92 -0
- package/dist/rules/index.js +28 -0
- package/dist/rules/mvp.rules.js +138 -0
- package/dist/rules/phase2.rules.js +233 -0
- package/dist/rules/phase3.rules.js +261 -0
- package/dist/utils/codeScanner.js +66 -0
- package/dist/utils/fs.js +13 -0
- package/dist/utils/packageJson.js +54 -0
- package/package.json +38 -0
package/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Bandit - a cli tool which can audit your backend project for the important and best practices of creating an industry grade backend by checking for require package files and dependencies , whether your env variables are ignored in .gitignore or not , what type of framework is being used , does it contain src/ file structure and many more comprehensive checks which i am constantly adding to make the developers aware of their backend system
|
package/dist/cli/api.js
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import fg from "fast-glob";
|
|
4
|
+
import * as clack from "@clack/prompts";
|
|
5
|
+
import chalk from "chalk";
|
|
6
|
+
// Scans files for common route definitions
|
|
7
|
+
async function scanForRoutes(projectPath) {
|
|
8
|
+
const routes = [];
|
|
9
|
+
const files = await fg(["src/**/*.{ts,js}", "*.{ts,js}", "routes/**/*.{ts,js}", "controllers/**/*.{ts,js}"], {
|
|
10
|
+
cwd: projectPath,
|
|
11
|
+
absolute: true,
|
|
12
|
+
ignore: ["**/node_modules/**", "**/dist/**", "**/*.test.{ts,js}", "**/*.spec.{ts,js}"],
|
|
13
|
+
});
|
|
14
|
+
// Regex patterns
|
|
15
|
+
// 1. Express/Fastify/Hono: router.get('/path', ...) or app.post('/path', ...)
|
|
16
|
+
const expressPattern = /\b(app|router|route|fastify|hono|api)\.(get|post|put|delete|patch|options|head)\s*\(\s*['"`]([^'"`\s]+)['"`]/gi;
|
|
17
|
+
// 2. NestJS decorators: @Get('/path') or @Post('/path')
|
|
18
|
+
const nestPattern = /@(Get|Post|Put|Delete|Patch)\s*\(\s*['"`]([^'"`\s]+)['"`]/gi;
|
|
19
|
+
// 3. NestJS controllers to prefix: @Controller('/prefix')
|
|
20
|
+
const controllerPattern = /@Controller\s*\(\s*['"`]([^'"`\s]+)['"`]/gi;
|
|
21
|
+
for (const file of files) {
|
|
22
|
+
try {
|
|
23
|
+
const content = fs.readFileSync(file, "utf-8");
|
|
24
|
+
const relativePath = path.relative(projectPath, file);
|
|
25
|
+
// Check NestJS Controller prefix if any
|
|
26
|
+
let controllerPrefix = "";
|
|
27
|
+
const cMatch = [...content.matchAll(controllerPattern)];
|
|
28
|
+
if (cMatch.length > 0) {
|
|
29
|
+
controllerPrefix = cMatch[0][1];
|
|
30
|
+
if (controllerPrefix === "/")
|
|
31
|
+
controllerPrefix = "";
|
|
32
|
+
}
|
|
33
|
+
// 1. Check Express-style matches
|
|
34
|
+
let match;
|
|
35
|
+
expressPattern.lastIndex = 0;
|
|
36
|
+
while ((match = expressPattern.exec(content)) !== null) {
|
|
37
|
+
const method = match[2].toUpperCase();
|
|
38
|
+
let rPath = match[3];
|
|
39
|
+
// Sanitize path variable names if necessary
|
|
40
|
+
routes.push({
|
|
41
|
+
method,
|
|
42
|
+
routePath: rPath,
|
|
43
|
+
sourceFile: relativePath,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
// 2. Check NestJS matches
|
|
47
|
+
nestPattern.lastIndex = 0;
|
|
48
|
+
while ((match = nestPattern.exec(content)) !== null) {
|
|
49
|
+
const method = match[1].toUpperCase();
|
|
50
|
+
let subPath = match[2];
|
|
51
|
+
if (subPath === "/")
|
|
52
|
+
subPath = "";
|
|
53
|
+
const fullPath = `${controllerPrefix.replace(/\/$/, "")}/${subPath.replace(/^\//, "")}`;
|
|
54
|
+
const sanitizedFullPath = fullPath.startsWith("/") ? fullPath : `/${fullPath}`;
|
|
55
|
+
routes.push({
|
|
56
|
+
method: method === "DELETE" ? "DELETE" : method,
|
|
57
|
+
routePath: sanitizedFullPath,
|
|
58
|
+
sourceFile: relativePath,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
// Ignore reading errors
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Deduplicate routes by method + routePath
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
const uniqueRoutes = [];
|
|
69
|
+
for (const r of routes) {
|
|
70
|
+
const key = `${r.method}-${r.routePath}`;
|
|
71
|
+
if (!seen.has(key)) {
|
|
72
|
+
seen.add(key);
|
|
73
|
+
uniqueRoutes.push(r);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return uniqueRoutes.sort((a, b) => a.routePath.localeCompare(b.routePath));
|
|
77
|
+
}
|
|
78
|
+
// Detect potential local server port from project files
|
|
79
|
+
function detectLocalPort(projectPath) {
|
|
80
|
+
try {
|
|
81
|
+
const envPath = path.join(projectPath, ".env");
|
|
82
|
+
if (fs.existsSync(envPath)) {
|
|
83
|
+
const content = fs.readFileSync(envPath, "utf-8");
|
|
84
|
+
const portMatch = content.match(/^PORT\s*=\s*(\d+)/m);
|
|
85
|
+
if (portMatch) {
|
|
86
|
+
return parseInt(portMatch[1], 10);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch { }
|
|
91
|
+
return 3000;
|
|
92
|
+
}
|
|
93
|
+
export async function runApiCommand(projectPath = ".") {
|
|
94
|
+
clack.intro(chalk.bold.bgBlue.white(" Bandit Interactive API Client "));
|
|
95
|
+
const absoluteProjectPath = path.resolve(projectPath);
|
|
96
|
+
const s = clack.spinner();
|
|
97
|
+
s.start("Scanning codebase for API endpoints...");
|
|
98
|
+
const discoveredRoutes = await scanForRoutes(absoluteProjectPath);
|
|
99
|
+
s.stop(`Scan complete. Discovered ${chalk.bold.cyan(discoveredRoutes.length)} unique route(s).`);
|
|
100
|
+
const detectedPort = detectLocalPort(absoluteProjectPath);
|
|
101
|
+
const detectedUrl = `http://localhost:${detectedPort}`;
|
|
102
|
+
// Common ports to offer
|
|
103
|
+
const defaultPorts = [3000, 8080, 5000, 5500, 8000];
|
|
104
|
+
const urlOptions = [{ value: detectedUrl, label: `${detectedUrl} (Detected)` }];
|
|
105
|
+
for (const p of defaultPorts) {
|
|
106
|
+
const url = `http://localhost:${p}`;
|
|
107
|
+
if (p !== detectedPort) {
|
|
108
|
+
urlOptions.push({ value: url, label: url });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const serverUrlSelection = await clack.select({
|
|
112
|
+
message: "Select your server's base URL:",
|
|
113
|
+
options: [
|
|
114
|
+
...urlOptions,
|
|
115
|
+
{ value: "custom", label: "+ Enter a custom base URL manually" },
|
|
116
|
+
],
|
|
117
|
+
});
|
|
118
|
+
if (clack.isCancel(serverUrlSelection)) {
|
|
119
|
+
clack.outro("Execution stopped.");
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
let baseUrl = "";
|
|
123
|
+
if (serverUrlSelection === "custom") {
|
|
124
|
+
const customUrlInput = await clack.text({
|
|
125
|
+
message: "Enter custom base URL:",
|
|
126
|
+
placeholder: "http://localhost:3000",
|
|
127
|
+
validate: (v) => {
|
|
128
|
+
if (!v || !v.trim())
|
|
129
|
+
return "Base URL cannot be empty";
|
|
130
|
+
if (!v.startsWith("http://") && !v.startsWith("https://")) {
|
|
131
|
+
return "URL must start with http:// or https://";
|
|
132
|
+
}
|
|
133
|
+
return undefined;
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
if (clack.isCancel(customUrlInput)) {
|
|
137
|
+
clack.outro("Execution stopped.");
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
baseUrl = customUrlInput.trim();
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
baseUrl = serverUrlSelection;
|
|
144
|
+
}
|
|
145
|
+
baseUrl = baseUrl.replace(/\/$/, "");
|
|
146
|
+
// Compile options
|
|
147
|
+
const routeOptions = discoveredRoutes.map((r) => ({
|
|
148
|
+
value: r,
|
|
149
|
+
label: `[${chalk.bold.green(r.method)}] ${r.routePath} ${chalk.gray(`(${r.sourceFile})`)}`,
|
|
150
|
+
}));
|
|
151
|
+
const selectedRoute = (await clack.select({
|
|
152
|
+
message: "Choose an endpoint to request:",
|
|
153
|
+
options: [
|
|
154
|
+
...routeOptions,
|
|
155
|
+
{ value: "custom", label: chalk.yellow("+ Enter a custom route path manually") },
|
|
156
|
+
],
|
|
157
|
+
}));
|
|
158
|
+
if (clack.isCancel(selectedRoute)) {
|
|
159
|
+
clack.outro("Execution stopped.");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
let finalMethod = "GET";
|
|
163
|
+
let finalPath = "";
|
|
164
|
+
if (selectedRoute === "custom") {
|
|
165
|
+
const customMethod = (await clack.select({
|
|
166
|
+
message: "Select HTTP Method:",
|
|
167
|
+
options: [
|
|
168
|
+
{ value: "GET", label: "GET" },
|
|
169
|
+
{ value: "POST", label: "POST" },
|
|
170
|
+
{ value: "PUT", label: "PUT" },
|
|
171
|
+
{ value: "PATCH", label: "PATCH" },
|
|
172
|
+
{ value: "DELETE", label: "DELETE" },
|
|
173
|
+
],
|
|
174
|
+
}));
|
|
175
|
+
if (clack.isCancel(customMethod)) {
|
|
176
|
+
clack.outro("Execution stopped.");
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
finalMethod = customMethod;
|
|
180
|
+
const customPath = await clack.text({
|
|
181
|
+
message: "Enter route path:",
|
|
182
|
+
placeholder: "/api/v1/users",
|
|
183
|
+
validate: (v) => (!v || !v.startsWith("/") ? "Route path must start with a slash '/'" : undefined),
|
|
184
|
+
});
|
|
185
|
+
if (clack.isCancel(customPath)) {
|
|
186
|
+
clack.outro("Execution stopped.");
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
finalPath = customPath;
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
finalMethod = selectedRoute.method;
|
|
193
|
+
finalPath = selectedRoute.routePath;
|
|
194
|
+
}
|
|
195
|
+
// 1. Process Path Parameters if any (e.g. /users/:id or /users/{id})
|
|
196
|
+
// We look for :name or {name}
|
|
197
|
+
const pathParamPattern = /:([a-zA-Z0-9_]+)|\{([a-zA-Z0-9_]+)\}/g;
|
|
198
|
+
let matches;
|
|
199
|
+
const pathParams = [];
|
|
200
|
+
while ((matches = pathParamPattern.exec(finalPath)) !== null) {
|
|
201
|
+
const paramName = matches[1] || matches[2];
|
|
202
|
+
if (paramName && !pathParams.includes(paramName)) {
|
|
203
|
+
pathParams.push(paramName);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
let resolvedPath = finalPath;
|
|
207
|
+
for (const param of pathParams) {
|
|
208
|
+
const val = await clack.text({
|
|
209
|
+
message: `Enter value for path parameter [${chalk.bold.yellow(param)}]:`,
|
|
210
|
+
validate: (v) => (!v || !v.trim() ? "Parameter value cannot be empty" : undefined),
|
|
211
|
+
});
|
|
212
|
+
if (clack.isCancel(val)) {
|
|
213
|
+
clack.outro("Execution stopped.");
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
// Replace :param or {param}
|
|
217
|
+
resolvedPath = resolvedPath
|
|
218
|
+
.replace(`:${param}`, val)
|
|
219
|
+
.replace(`{${param}}`, val);
|
|
220
|
+
}
|
|
221
|
+
// 2. Process Query Params if needed
|
|
222
|
+
const addQueryParams = await clack.confirm({
|
|
223
|
+
message: "Do you want to add query parameters?",
|
|
224
|
+
initialValue: false,
|
|
225
|
+
});
|
|
226
|
+
if (clack.isCancel(addQueryParams)) {
|
|
227
|
+
clack.outro("Execution stopped.");
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
let queryStr = "";
|
|
231
|
+
if (addQueryParams) {
|
|
232
|
+
const qParams = await clack.text({
|
|
233
|
+
message: "Enter query string:",
|
|
234
|
+
placeholder: "limit=10&page=1",
|
|
235
|
+
});
|
|
236
|
+
if (clack.isCancel(qParams)) {
|
|
237
|
+
clack.outro("Execution stopped.");
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const trimmed = qParams.trim();
|
|
241
|
+
if (trimmed) {
|
|
242
|
+
queryStr = trimmed.startsWith("?") ? trimmed : `?${trimmed}`;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// 3. Process Request Body if POST/PUT/PATCH
|
|
246
|
+
let requestBody;
|
|
247
|
+
if (["POST", "PUT", "PATCH"].includes(finalMethod)) {
|
|
248
|
+
const enterBody = await clack.confirm({
|
|
249
|
+
message: "Do you want to send a JSON request body?",
|
|
250
|
+
initialValue: true,
|
|
251
|
+
});
|
|
252
|
+
if (clack.isCancel(enterBody)) {
|
|
253
|
+
clack.outro("Execution stopped.");
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (enterBody) {
|
|
257
|
+
const bodyInput = await clack.text({
|
|
258
|
+
message: "Enter JSON payload:",
|
|
259
|
+
placeholder: '{"name": "Alice", "email": "alice@gmail.com"}',
|
|
260
|
+
validate: (v) => {
|
|
261
|
+
if (!v || !v.trim())
|
|
262
|
+
return undefined;
|
|
263
|
+
try {
|
|
264
|
+
JSON.parse(v);
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
return "Invalid JSON syntax. Please enter valid JSON.";
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
if (clack.isCancel(bodyInput)) {
|
|
273
|
+
clack.outro("Execution stopped.");
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const val = bodyInput.trim();
|
|
277
|
+
if (val) {
|
|
278
|
+
requestBody = val;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// 4. Prompt for Authorization Header (Bearer token)
|
|
283
|
+
const addAuth = await clack.confirm({
|
|
284
|
+
message: "Do you want to add an Authorization Bearer token?",
|
|
285
|
+
initialValue: false,
|
|
286
|
+
});
|
|
287
|
+
if (clack.isCancel(addAuth)) {
|
|
288
|
+
clack.outro("Execution stopped.");
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
let bearerToken;
|
|
292
|
+
if (addAuth) {
|
|
293
|
+
const tokenInput = await clack.text({
|
|
294
|
+
message: "Enter Bearer Token:",
|
|
295
|
+
placeholder: "eyJhbGciOi...",
|
|
296
|
+
validate: (v) => (!v || !v.trim() ? "Token cannot be empty" : undefined),
|
|
297
|
+
});
|
|
298
|
+
if (clack.isCancel(tokenInput)) {
|
|
299
|
+
clack.outro("Execution stopped.");
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
bearerToken = tokenInput.trim();
|
|
303
|
+
}
|
|
304
|
+
// Confirm and Execute Request
|
|
305
|
+
const finalUrl = `${baseUrl}${resolvedPath}${queryStr}`;
|
|
306
|
+
clack.log.info(`Ready to request: ${chalk.bold.green(finalMethod)} ${chalk.cyan(finalUrl)}`);
|
|
307
|
+
if (bearerToken) {
|
|
308
|
+
const preview = bearerToken.length > 12 ? `${bearerToken.slice(0, 8)}...` : bearerToken;
|
|
309
|
+
clack.log.info(`Auth: Bearer ${preview} (masked)`);
|
|
310
|
+
}
|
|
311
|
+
if (requestBody) {
|
|
312
|
+
clack.log.info(`Body:\n${chalk.gray(JSON.stringify(JSON.parse(requestBody), null, 2))}`);
|
|
313
|
+
}
|
|
314
|
+
const proceed = await clack.confirm({
|
|
315
|
+
message: "Execute request?",
|
|
316
|
+
initialValue: true,
|
|
317
|
+
});
|
|
318
|
+
if (clack.isCancel(proceed) || !proceed) {
|
|
319
|
+
clack.outro("Aborted.");
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
s.start(`Sending ${finalMethod} request to ${finalUrl}...`);
|
|
323
|
+
try {
|
|
324
|
+
const start = performance.now();
|
|
325
|
+
const headers = {
|
|
326
|
+
Accept: "application/json, text/plain, */*",
|
|
327
|
+
...(requestBody ? { "Content-Type": "application/json" } : {}),
|
|
328
|
+
};
|
|
329
|
+
if (bearerToken) {
|
|
330
|
+
headers["Authorization"] = `Bearer ${bearerToken}`;
|
|
331
|
+
}
|
|
332
|
+
const fetchOptions = {
|
|
333
|
+
method: finalMethod,
|
|
334
|
+
headers,
|
|
335
|
+
...(requestBody ? { body: requestBody } : {}),
|
|
336
|
+
};
|
|
337
|
+
const res = await fetch(finalUrl, fetchOptions);
|
|
338
|
+
const end = performance.now();
|
|
339
|
+
const duration = end - start;
|
|
340
|
+
s.stop(`Request completed in ${chalk.bold.yellow(duration.toFixed(1))}ms.`);
|
|
341
|
+
// Read response text or json
|
|
342
|
+
const contentType = res.headers.get("content-type") || "";
|
|
343
|
+
const resText = await res.text();
|
|
344
|
+
console.log("\n" + chalk.bold.cyan("📬 RESPONSE"));
|
|
345
|
+
console.log(chalk.gray("──────────────────────────────────────────────────"));
|
|
346
|
+
console.log(`Status: ${res.status >= 200 && res.status < 300 ? chalk.bold.green(res.status) : chalk.bold.red(res.status)} ${res.statusText}`);
|
|
347
|
+
console.log(`Time: ${duration.toFixed(1)} ms`);
|
|
348
|
+
console.log(chalk.gray("──────────────────────────────────────────────────"));
|
|
349
|
+
console.log(chalk.bold.cyan("Headers:"));
|
|
350
|
+
res.headers.forEach((val, key) => {
|
|
351
|
+
console.log(` ${chalk.gray(key)}: ${val}`);
|
|
352
|
+
});
|
|
353
|
+
console.log(chalk.gray("──────────────────────────────────────────────────"));
|
|
354
|
+
console.log(chalk.bold.cyan("Body:"));
|
|
355
|
+
if (contentType.includes("application/json")) {
|
|
356
|
+
try {
|
|
357
|
+
const parsed = JSON.parse(resText);
|
|
358
|
+
console.log(chalk.green(JSON.stringify(parsed, null, 2)));
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
console.log(resText);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
console.log(resText || chalk.italic.gray("<empty body>"));
|
|
366
|
+
}
|
|
367
|
+
console.log(chalk.gray("──────────────────────────────────────────────────"));
|
|
368
|
+
}
|
|
369
|
+
catch (err) {
|
|
370
|
+
s.stop("Request failed.");
|
|
371
|
+
clack.log.error(`Fetch execution error: ${err.message}`);
|
|
372
|
+
}
|
|
373
|
+
clack.outro(chalk.bold.green("API Playfield session complete."));
|
|
374
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { performance } from "node:perf_hooks";
|
|
2
|
+
import * as clack from "@clack/prompts";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
export async function runBenchCommand(targetUrl, opts) {
|
|
5
|
+
clack.intro(chalk.bold.bgBlue.white(" Bandit Load Benchmarker "));
|
|
6
|
+
clack.log.info(`Target URL: ${chalk.bold.cyan(targetUrl)}`);
|
|
7
|
+
clack.log.info(`Config: ${chalk.bold(opts.requests)} requests total, ${chalk.bold(opts.connections)} concurrent connections`);
|
|
8
|
+
const s = clack.spinner();
|
|
9
|
+
s.start("Pre-flight ping to check endpoint availability...");
|
|
10
|
+
try {
|
|
11
|
+
const res = await fetch(targetUrl);
|
|
12
|
+
s.stop(`Ping successful (Status ${res.status}). Starting load test...`);
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
s.stop("Pre-flight ping failed.");
|
|
16
|
+
clack.log.error(`Cannot contact target: ${err.message}`);
|
|
17
|
+
clack.outro(chalk.red("Benchmark aborted."));
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const latencies = [];
|
|
21
|
+
let successCount = 0;
|
|
22
|
+
let failCount = 0;
|
|
23
|
+
let completedCount = 0;
|
|
24
|
+
const requestTimes = [];
|
|
25
|
+
const startBenchmark = performance.now();
|
|
26
|
+
// Create a queue of requests
|
|
27
|
+
const runWorker = async () => {
|
|
28
|
+
while (completedCount < opts.requests) {
|
|
29
|
+
const currentReqIndex = completedCount++;
|
|
30
|
+
if (currentReqIndex >= opts.requests)
|
|
31
|
+
break;
|
|
32
|
+
const reqStart = performance.now();
|
|
33
|
+
try {
|
|
34
|
+
const res = await fetch(targetUrl);
|
|
35
|
+
const reqEnd = performance.now();
|
|
36
|
+
const duration = reqEnd - reqStart;
|
|
37
|
+
latencies.push(duration);
|
|
38
|
+
if (res.ok) {
|
|
39
|
+
successCount++;
|
|
40
|
+
requestTimes.push({ start: reqStart, end: reqEnd, success: true });
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
failCount++;
|
|
44
|
+
requestTimes.push({ start: reqStart, end: reqEnd, success: false });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
const reqEnd = performance.now();
|
|
49
|
+
const duration = reqEnd - reqStart;
|
|
50
|
+
latencies.push(duration);
|
|
51
|
+
failCount++;
|
|
52
|
+
requestTimes.push({ start: reqStart, end: reqEnd, success: false });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
// Run workers concurrently
|
|
57
|
+
const workers = [];
|
|
58
|
+
for (let i = 0; i < opts.connections; i++) {
|
|
59
|
+
workers.push(runWorker());
|
|
60
|
+
}
|
|
61
|
+
s.start(`Firing requests... (Completed: 0/${opts.requests})`);
|
|
62
|
+
// Periodically update progress
|
|
63
|
+
const progressInterval = setInterval(() => {
|
|
64
|
+
s.message(`Firing requests... (Completed: ${Math.min(completedCount, opts.requests)}/${opts.requests})`);
|
|
65
|
+
}, 100);
|
|
66
|
+
await Promise.all(workers);
|
|
67
|
+
clearInterval(progressInterval);
|
|
68
|
+
const endBenchmark = performance.now();
|
|
69
|
+
s.stop(`Load test completed!`);
|
|
70
|
+
const totalTimeSec = (endBenchmark - startBenchmark) / 1000;
|
|
71
|
+
const rps = opts.requests / totalTimeSec;
|
|
72
|
+
if (latencies.length === 0) {
|
|
73
|
+
clack.outro(chalk.red("No requests completed successfully."));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
// Calculate statistics
|
|
77
|
+
latencies.sort((a, b) => a - b);
|
|
78
|
+
const min = latencies[0];
|
|
79
|
+
const max = latencies[latencies.length - 1];
|
|
80
|
+
const sum = latencies.reduce((a, b) => a + b, 0);
|
|
81
|
+
const avg = sum / latencies.length;
|
|
82
|
+
const getPercentile = (p) => {
|
|
83
|
+
const idx = Math.floor((p / 100) * (latencies.length - 1));
|
|
84
|
+
return latencies[idx];
|
|
85
|
+
};
|
|
86
|
+
const p50 = getPercentile(50);
|
|
87
|
+
const p90 = getPercentile(90);
|
|
88
|
+
const p95 = getPercentile(95);
|
|
89
|
+
const p99 = getPercentile(99);
|
|
90
|
+
// Render Stats
|
|
91
|
+
console.log("");
|
|
92
|
+
console.log(chalk.bold.cyan("📊 BENCHMARK SUMMARY"));
|
|
93
|
+
console.log(chalk.gray("──────────────────────────────────────────────────"));
|
|
94
|
+
console.log(` Total Time Taken: ${chalk.bold(totalTimeSec.toFixed(3))} seconds`);
|
|
95
|
+
console.log(` Throughput: ${chalk.bold.green(rps.toFixed(2))} req/sec`);
|
|
96
|
+
console.log(` Successful Reqs: ${chalk.green(successCount)}`);
|
|
97
|
+
console.log(` Failed Reqs: ${failCount > 0 ? chalk.red(failCount) : chalk.gray(failCount)}`);
|
|
98
|
+
console.log(chalk.gray("──────────────────────────────────────────────────"));
|
|
99
|
+
console.log(chalk.bold.cyan("⏱ LATENCY STATS"));
|
|
100
|
+
console.log(chalk.gray("──────────────────────────────────────────────────"));
|
|
101
|
+
console.log(` Min Latency: ${min.toFixed(2)} ms`);
|
|
102
|
+
console.log(` Avg Latency: ${avg.toFixed(2)} ms`);
|
|
103
|
+
console.log(` Max Latency: ${max.toFixed(2)} ms`);
|
|
104
|
+
console.log(` p50 (Median): ${chalk.yellow(p50.toFixed(2))} ms`);
|
|
105
|
+
console.log(` p90: ${p90.toFixed(2)} ms`);
|
|
106
|
+
console.log(` p95: ${p95.toFixed(2)} ms`);
|
|
107
|
+
console.log(` p99 (Tail): ${chalk.red(p99.toFixed(2))} ms`);
|
|
108
|
+
console.log(chalk.gray("──────────────────────────────────────────────────"));
|
|
109
|
+
// Draw ASCII Latency Distribution Histogram
|
|
110
|
+
console.log(chalk.bold.cyan("📈 LATENCY DISTRIBUTION"));
|
|
111
|
+
console.log(chalk.gray("──────────────────────────────────────────────────"));
|
|
112
|
+
const bucketCount = 8;
|
|
113
|
+
const bucketSize = (max - min) / bucketCount || 1;
|
|
114
|
+
const buckets = new Array(bucketCount).fill(0);
|
|
115
|
+
for (const lat of latencies) {
|
|
116
|
+
let bIdx = Math.floor((lat - min) / bucketSize);
|
|
117
|
+
if (bIdx >= bucketCount)
|
|
118
|
+
bIdx = bucketCount - 1;
|
|
119
|
+
buckets[bIdx]++;
|
|
120
|
+
}
|
|
121
|
+
const maxBucketVal = Math.max(...buckets) || 1;
|
|
122
|
+
const maxBarWidth = 35;
|
|
123
|
+
for (let i = 0; i < bucketCount; i++) {
|
|
124
|
+
const rangeStart = min + i * bucketSize;
|
|
125
|
+
const rangeEnd = rangeStart + bucketSize;
|
|
126
|
+
const count = buckets[i];
|
|
127
|
+
const percentage = (count / latencies.length) * 100;
|
|
128
|
+
const barWidth = Math.round((count / maxBucketVal) * maxBarWidth);
|
|
129
|
+
const bar = "█".repeat(barWidth) + "░".repeat(Math.max(0, maxBarWidth - barWidth));
|
|
130
|
+
const label = `${rangeStart.toFixed(1).padStart(6)}ms - ${rangeEnd.toFixed(1).padStart(6)}ms`;
|
|
131
|
+
console.log(` ${chalk.gray(label)} : [${chalk.green(bar)}] ${count.toString().padStart(4)} (${percentage.toFixed(1)}%)`);
|
|
132
|
+
}
|
|
133
|
+
console.log(chalk.gray("──────────────────────────────────────────────────"));
|
|
134
|
+
clack.outro(chalk.bold.green("Benchmark complete!"));
|
|
135
|
+
}
|