itworksbut 0.6.0 → 0.7.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 +21 -0
- package/bin/itworksbut.js +7 -1
- package/package.json +2 -1
- package/src/cli/output.js +7 -0
- package/src/cli/parseArgs.js +12 -2
- package/src/commands/stress.js +114 -0
- package/src/core/scanner.js +9 -0
- package/src/reporters/stressMarkdownReport.js +157 -0
- package/src/stress/artilleryRunner.js +83 -0
- package/src/stress/discoverEndpoints.js +200 -0
- package/src/stress/stressConfig.js +31 -0
- package/src/stress/stressRenderer.js +80 -0
- package/src/stress/stressResultParser.js +155 -0
- package/src/utils/targetSafety.js +90 -0
package/README.md
CHANGED
|
@@ -49,6 +49,8 @@ Common commands:
|
|
|
49
49
|
|
|
50
50
|
```sh
|
|
51
51
|
itworksbut scan --path .
|
|
52
|
+
itworksbut deps
|
|
53
|
+
itworksbut stress
|
|
52
54
|
itworksbut scan --fail-on high
|
|
53
55
|
itworksbut scan --json
|
|
54
56
|
itworksbut scan --sarif > itworksbut.sarif
|
|
@@ -63,11 +65,20 @@ itworksbut --version
|
|
|
63
65
|
|
|
64
66
|
```text
|
|
65
67
|
itworksbut scan [options]
|
|
68
|
+
itworksbut deps [options]
|
|
69
|
+
itworksbut stress [options]
|
|
66
70
|
```
|
|
67
71
|
|
|
72
|
+
- `deps`: Run only dependency checks, including lockfile hygiene, install-script risk, audit script availability, and outdated packages.
|
|
73
|
+
- `stress`: Discover API endpoints and run a controlled Artillery load test against local or explicitly authorized targets.
|
|
68
74
|
- `--path <path>`: Scan a specific project directory. Defaults to the current directory.
|
|
69
75
|
- `--config <path>`: Use a custom config file. Defaults to `itworksbut.config.json` when present.
|
|
70
76
|
- `--fail-on <severity>`: Exit with code `1` when a finding at or above the severity exists. Levels: `critical`, `high`, `medium`, `low`, `info`. Default: `low`.
|
|
77
|
+
- `--target <url>`: Stress-test target. Defaults to `http://localhost:3000`. External targets require `--i-own-this`.
|
|
78
|
+
- `--duration <seconds>`: Stress-test duration. Default `30`, maximum `300`.
|
|
79
|
+
- `--arrival-rate <number>`: Arrival rate in requests per second. Default `5`, maximum `50`.
|
|
80
|
+
- `--max-vusers <number>`: Virtual user cap. Default `50`, maximum `100`.
|
|
81
|
+
- `--i-own-this`: Required for non-local stress-test targets.
|
|
71
82
|
- `--json`: Print machine-readable JSON only. No banner, colors, spinner, table, or extra text.
|
|
72
83
|
- `--sarif`: Print SARIF JSON for GitHub Code Scanning. No banner, colors, spinner, table, or extra text.
|
|
73
84
|
- `--todo`: Write an AI-ready `todo.md` into the scanned project with prioritized findings, fix prompts, and acceptance criteria.
|
|
@@ -119,6 +130,16 @@ itworksbut scan --report
|
|
|
119
130
|
|
|
120
131
|
This writes `report.md` to the current working directory with check statuses, summaries, details, and recommendations.
|
|
121
132
|
|
|
133
|
+
To run a controlled API stress test:
|
|
134
|
+
|
|
135
|
+
```sh
|
|
136
|
+
itworksbut stress
|
|
137
|
+
itworksbut stress --target https://my-own-api.example --i-own-this
|
|
138
|
+
itworksbut stress --report
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`stress` only tests local targets by default. For external hosts, pass `--i-own-this` to confirm that you own the target or have explicit authorization. Mutating endpoints such as `POST`, `PUT`, `PATCH`, and `DELETE` are discovered but skipped automatically.
|
|
142
|
+
|
|
122
143
|
## GitHub Actions
|
|
123
144
|
|
|
124
145
|
```yaml
|
package/bin/itworksbut.js
CHANGED
|
@@ -11,6 +11,7 @@ import { reportJson } from '../src/reporters/jsonReporter.js';
|
|
|
11
11
|
import { reportSarif } from '../src/reporters/sarifReporter.js';
|
|
12
12
|
import { writeTodoReport } from '../src/reporters/todoReporter.js';
|
|
13
13
|
import { writeMarkdownReport } from '../src/reporters/markdownReport.js';
|
|
14
|
+
import { runStressCommand } from '../src/commands/stress.js';
|
|
14
15
|
|
|
15
16
|
async function main() {
|
|
16
17
|
const args = parseArgs(process.argv.slice(2));
|
|
@@ -25,7 +26,11 @@ async function main() {
|
|
|
25
26
|
return 0;
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
if (args.command
|
|
29
|
+
if (args.command === 'stress') {
|
|
30
|
+
return await runStressCommand(args);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!['scan', 'deps'].includes(args.command)) {
|
|
29
34
|
printUsage();
|
|
30
35
|
return 2;
|
|
31
36
|
}
|
|
@@ -42,6 +47,7 @@ async function main() {
|
|
|
42
47
|
configPath: args.config,
|
|
43
48
|
failOn: args.failOn,
|
|
44
49
|
verbose: args.verbose,
|
|
50
|
+
categories: args.command === 'deps' ? ['dependencies'] : undefined,
|
|
45
51
|
});
|
|
46
52
|
if (spinner) spinner.succeed('Scan complete. Now the receipts.');
|
|
47
53
|
} catch (error) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "itworksbut",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Static CI checks for common security, repo, dependency, build, and deployment risks in JavaScript vibe coding projects.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
],
|
|
37
37
|
"license": "MIT",
|
|
38
38
|
"dependencies": {
|
|
39
|
+
"artillery": "^2.0.30",
|
|
39
40
|
"boxen": "^8.0.1",
|
|
40
41
|
"chalk": "^5.6.2",
|
|
41
42
|
"cli-table3": "^0.6.5",
|
package/src/cli/output.js
CHANGED
|
@@ -5,12 +5,19 @@ export function printUsage() {
|
|
|
5
5
|
|
|
6
6
|
Usage:
|
|
7
7
|
itworksbut scan [options]
|
|
8
|
+
itworksbut deps [options]
|
|
9
|
+
itworksbut stress [options]
|
|
8
10
|
|
|
9
11
|
Options:
|
|
10
12
|
--path <path> Project path to scan. Defaults to current directory.
|
|
11
13
|
--config <path> Optional itworksbut.config.json path.
|
|
12
14
|
--fail-on <level> Exit 1 when findings meet or exceed this severity.
|
|
13
15
|
Levels: critical, high, medium, low, info. Default: low.
|
|
16
|
+
--target <url> Stress-test target. Defaults to http://localhost:3000.
|
|
17
|
+
--duration <sec> Stress-test duration. Default: 30, max: 300.
|
|
18
|
+
--arrival-rate <n> Stress-test arrival rate. Default: 5, max: 50.
|
|
19
|
+
--max-vusers <n> Stress-test virtual user cap. Default: 50, max: 100.
|
|
20
|
+
--i-own-this Required to stress-test non-local targets.
|
|
14
21
|
--json Print machine-readable JSON.
|
|
15
22
|
--sarif Print SARIF for GitHub Code Scanning.
|
|
16
23
|
--todo Write an AI-ready todo.md to the scanned project.
|
package/src/cli/parseArgs.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
const FLAG_WITH_VALUE = new Set(["--fail-on", "--config", "--path"]);
|
|
2
|
-
const BOOLEAN_FLAGS = new Set(["--json", "--sarif", "--todo", "--report", "--verbose", "--help", "-h", "--version", "-v", "--no-color", "--no-banner", "--quiet"]);
|
|
1
|
+
const FLAG_WITH_VALUE = new Set(["--fail-on", "--config", "--path", "--target", "--duration", "--arrival-rate", "--max-vusers"]);
|
|
2
|
+
const BOOLEAN_FLAGS = new Set(["--json", "--sarif", "--todo", "--report", "--i-own-this", "--verbose", "--help", "-h", "--version", "-v", "--no-color", "--no-banner", "--quiet"]);
|
|
3
3
|
|
|
4
4
|
export function parseArgs(argv) {
|
|
5
5
|
const args = {
|
|
@@ -11,6 +11,11 @@ export function parseArgs(argv) {
|
|
|
11
11
|
sarif: false,
|
|
12
12
|
todo: false,
|
|
13
13
|
report: false,
|
|
14
|
+
target: undefined,
|
|
15
|
+
duration: undefined,
|
|
16
|
+
arrivalRate: undefined,
|
|
17
|
+
maxVusers: undefined,
|
|
18
|
+
iOwnThis: false,
|
|
14
19
|
verbose: false,
|
|
15
20
|
noColor: false,
|
|
16
21
|
noBanner: false,
|
|
@@ -50,6 +55,7 @@ export function parseArgs(argv) {
|
|
|
50
55
|
if (token === "--sarif") args.sarif = true;
|
|
51
56
|
if (token === "--todo") args.todo = true;
|
|
52
57
|
if (token === "--report") args.report = true;
|
|
58
|
+
if (token === "--i-own-this") args.iOwnThis = true;
|
|
53
59
|
if (token === "--verbose") args.verbose = true;
|
|
54
60
|
if (token === "--no-color") args.noColor = true;
|
|
55
61
|
if (token === "--no-banner") args.noBanner = true;
|
|
@@ -71,5 +77,9 @@ function assignValue(args, flag, value) {
|
|
|
71
77
|
if (flag === "--fail-on") args.failOn = value;
|
|
72
78
|
else if (flag === "--config") args.config = value;
|
|
73
79
|
else if (flag === "--path") args.path = value;
|
|
80
|
+
else if (flag === "--target") args.target = value;
|
|
81
|
+
else if (flag === "--duration") args.duration = value;
|
|
82
|
+
else if (flag === "--arrival-rate") args.arrivalRate = value;
|
|
83
|
+
else if (flag === "--max-vusers") args.maxVusers = value;
|
|
74
84
|
else throw new Error(`Unknown argument: ${flag}`);
|
|
75
85
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { validateStressOptions } from "../utils/targetSafety.js";
|
|
3
|
+
import { discoverEndpoints } from "../stress/discoverEndpoints.js";
|
|
4
|
+
import { createArtilleryConfig } from "../stress/stressConfig.js";
|
|
5
|
+
import { runArtillery } from "../stress/artilleryRunner.js";
|
|
6
|
+
import { parseArtilleryResult } from "../stress/stressResultParser.js";
|
|
7
|
+
import { reportStressConsole } from "../stress/stressRenderer.js";
|
|
8
|
+
import { writeStressMarkdownReport } from "../reporters/stressMarkdownReport.js";
|
|
9
|
+
|
|
10
|
+
export async function runStressCommand(args, options = {}) {
|
|
11
|
+
if (args.todo || args.sarif) {
|
|
12
|
+
throw new Error("The stress command supports console output, --json, and --report.");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const result = await runStress({
|
|
16
|
+
rootPath: path.resolve(args.path || "."),
|
|
17
|
+
...validateStressOptions(args)
|
|
18
|
+
}, options);
|
|
19
|
+
|
|
20
|
+
if (args.json) {
|
|
21
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
22
|
+
} else {
|
|
23
|
+
reportStressConsole(result, args);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (args.report) {
|
|
27
|
+
const report = await writeStressMarkdownReport(result);
|
|
28
|
+
if (!args.json && !args.quiet) {
|
|
29
|
+
const verb = report.overwritten ? "Overwrote" : "Wrote";
|
|
30
|
+
process.stdout.write(`${verb} stress report: ${report.filePath}\n`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return result.status === "fail" ? 1 : 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function runStress(options, runnerOptions = {}) {
|
|
38
|
+
const startedAt = new Date();
|
|
39
|
+
const discovery = await discoverEndpoints(options.rootPath);
|
|
40
|
+
const baseDetails = {
|
|
41
|
+
target: options.target,
|
|
42
|
+
duration: options.duration,
|
|
43
|
+
arrivalRate: options.arrivalRate,
|
|
44
|
+
maxVusers: options.maxVusers,
|
|
45
|
+
endpointsFound: discovery.endpoints.length,
|
|
46
|
+
safeEndpoints: discovery.safeEndpoints.length,
|
|
47
|
+
skippedEndpoints: discovery.skippedEndpoints,
|
|
48
|
+
startedAt: startedAt.toISOString()
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
if (discovery.endpoints.length === 0) {
|
|
52
|
+
return stressResult({
|
|
53
|
+
status: "skip",
|
|
54
|
+
summary: "No API endpoints found.",
|
|
55
|
+
details: {
|
|
56
|
+
...baseDetails,
|
|
57
|
+
testedEndpoints: [],
|
|
58
|
+
warnings: 0,
|
|
59
|
+
failed: 0,
|
|
60
|
+
completedAt: new Date().toISOString()
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (discovery.safeEndpoints.length === 0) {
|
|
66
|
+
return stressResult({
|
|
67
|
+
status: "fail",
|
|
68
|
+
summary: "No safe GET/HEAD endpoints can be tested automatically.",
|
|
69
|
+
details: {
|
|
70
|
+
...baseDetails,
|
|
71
|
+
testedEndpoints: [],
|
|
72
|
+
warnings: 0,
|
|
73
|
+
failed: 1,
|
|
74
|
+
completedAt: new Date().toISOString()
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const config = createArtilleryConfig({
|
|
80
|
+
target: options.target,
|
|
81
|
+
duration: options.duration,
|
|
82
|
+
arrivalRate: options.arrivalRate,
|
|
83
|
+
maxVusers: options.maxVusers,
|
|
84
|
+
endpoints: discovery.safeEndpoints
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const artillery = await runArtillery(config, {
|
|
88
|
+
rootPath: options.rootPath,
|
|
89
|
+
duration: options.duration,
|
|
90
|
+
...runnerOptions
|
|
91
|
+
});
|
|
92
|
+
const parsed = parseArtilleryResult(artillery, discovery.safeEndpoints);
|
|
93
|
+
|
|
94
|
+
return stressResult({
|
|
95
|
+
status: parsed.status,
|
|
96
|
+
summary: parsed.summary,
|
|
97
|
+
details: {
|
|
98
|
+
...baseDetails,
|
|
99
|
+
testedEndpoints: parsed.testedEndpoints,
|
|
100
|
+
warnings: parsed.warnings,
|
|
101
|
+
failed: parsed.failed,
|
|
102
|
+
artilleryError: parsed.error,
|
|
103
|
+
completedAt: new Date().toISOString()
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function stressResult(result) {
|
|
109
|
+
return {
|
|
110
|
+
id: "stress-test",
|
|
111
|
+
title: "API stress test",
|
|
112
|
+
...result
|
|
113
|
+
};
|
|
114
|
+
}
|
package/src/core/scanner.js
CHANGED
|
@@ -7,11 +7,14 @@ import { packageInfo } from "./packageInfo.js";
|
|
|
7
7
|
export async function scanProject(options = {}) {
|
|
8
8
|
const startedAt = new Date();
|
|
9
9
|
const context = await createContext(options);
|
|
10
|
+
const includedCategories = normalizeFilter(options.categories);
|
|
10
11
|
const findings = [];
|
|
11
12
|
const checkResults = [];
|
|
12
13
|
const warnings = [];
|
|
13
14
|
|
|
14
15
|
for (const check of checks) {
|
|
16
|
+
if (includedCategories && !includedCategories.has(check.category)) continue;
|
|
17
|
+
|
|
15
18
|
if (context.config.checks[check.id] === false) {
|
|
16
19
|
checkResults.push(normalizeCheckResult(check, {
|
|
17
20
|
status: "skip",
|
|
@@ -75,6 +78,7 @@ export async function scanProject(options = {}) {
|
|
|
75
78
|
version: packageInfo.version,
|
|
76
79
|
rootPath: context.rootPath,
|
|
77
80
|
packageName: context.packageJson?.name,
|
|
81
|
+
categories: includedCategories ? [...includedCategories] : undefined,
|
|
78
82
|
packageManager: context.packageManager,
|
|
79
83
|
gitAvailable: context.gitAvailable,
|
|
80
84
|
filesScanned: context.allFiles.length,
|
|
@@ -84,3 +88,8 @@ export async function scanProject(options = {}) {
|
|
|
84
88
|
}
|
|
85
89
|
};
|
|
86
90
|
}
|
|
91
|
+
|
|
92
|
+
function normalizeFilter(values) {
|
|
93
|
+
if (!Array.isArray(values) || values.length === 0) return null;
|
|
94
|
+
return new Set(values.map((value) => String(value).trim()).filter(Boolean));
|
|
95
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const ANSI_PATTERN = /\u001B\[[0-?]*[ -/]*[@-~]/g;
|
|
5
|
+
|
|
6
|
+
export async function writeStressMarkdownReport(result, options = {}) {
|
|
7
|
+
const filePath = options.filePath || path.join(options.directoryPath || process.cwd(), "stress-report.md");
|
|
8
|
+
let overwritten = false;
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
await fs.access(filePath);
|
|
12
|
+
overwritten = true;
|
|
13
|
+
} catch {
|
|
14
|
+
overwritten = false;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
await fs.writeFile(filePath, reportStressMarkdown(result), "utf8");
|
|
18
|
+
return { filePath, overwritten };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function reportStressMarkdown(result) {
|
|
22
|
+
const details = result.details || {};
|
|
23
|
+
const tested = details.testedEndpoints || [];
|
|
24
|
+
const skipped = details.skippedEndpoints || [];
|
|
25
|
+
|
|
26
|
+
return stripAnsi(`${[
|
|
27
|
+
"# ItWorksBut Stress Report",
|
|
28
|
+
"",
|
|
29
|
+
`Generated: ${formatTimestamp(details.completedAt || new Date())}`,
|
|
30
|
+
"",
|
|
31
|
+
`Target: ${details.target || "unknown"}`,
|
|
32
|
+
`Duration: ${details.duration ?? "unknown"}s`,
|
|
33
|
+
`Arrival rate: ${details.arrivalRate ?? "unknown"} req/s`,
|
|
34
|
+
`Max virtual users: ${details.maxVusers ?? "unknown"}`,
|
|
35
|
+
"",
|
|
36
|
+
"## Summary",
|
|
37
|
+
"",
|
|
38
|
+
"| Metric | Value |",
|
|
39
|
+
"|---|---:|",
|
|
40
|
+
`| Endpoints found | ${details.endpointsFound || 0} |`,
|
|
41
|
+
`| Endpoints tested | ${tested.length} |`,
|
|
42
|
+
`| Endpoints skipped | ${skipped.length} |`,
|
|
43
|
+
`| Warnings | ${details.warnings || 0} |`,
|
|
44
|
+
`| Failed | ${details.failed || 0} |`,
|
|
45
|
+
"",
|
|
46
|
+
"## Tested Endpoints",
|
|
47
|
+
"",
|
|
48
|
+
renderTestedEndpoints(tested),
|
|
49
|
+
"",
|
|
50
|
+
"## Skipped Endpoints",
|
|
51
|
+
"",
|
|
52
|
+
renderSkippedEndpoints(skipped),
|
|
53
|
+
renderArtilleryError(details.artilleryError),
|
|
54
|
+
renderRecommendations(result),
|
|
55
|
+
].filter((line) => line !== null && line !== undefined).join("\n")}\n`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function renderTestedEndpoints(endpoints) {
|
|
59
|
+
if (!endpoints.length) return "None.";
|
|
60
|
+
|
|
61
|
+
return [
|
|
62
|
+
"| Method | Path | Status | p95 | p99 | Errors | Error Rate |",
|
|
63
|
+
"|---|---|---|---:|---:|---:|---:|",
|
|
64
|
+
...endpoints.map((endpoint) => (
|
|
65
|
+
`| ${escapeTable(endpoint.method)} | ${escapeTable(endpoint.path)} | ${escapeTable(endpoint.status)} | ${formatMs(endpoint.p95)} | ${formatMs(endpoint.p99)} | ${endpoint.errors || 0} | ${formatPercent(endpoint.errorRate)} |`
|
|
66
|
+
))
|
|
67
|
+
].join("\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function renderSkippedEndpoints(endpoints) {
|
|
71
|
+
if (!endpoints.length) return "None.";
|
|
72
|
+
|
|
73
|
+
return [
|
|
74
|
+
"| Method | Path | Reason |",
|
|
75
|
+
"|---|---|---|",
|
|
76
|
+
...endpoints.map((endpoint) => (
|
|
77
|
+
`| ${escapeTable(endpoint.method)} | ${escapeTable(endpoint.path)} | ${escapeTable(endpoint.reason)} |`
|
|
78
|
+
))
|
|
79
|
+
].join("\n");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function renderRecommendations(result) {
|
|
83
|
+
const details = result.details || {};
|
|
84
|
+
const recommendations = [];
|
|
85
|
+
|
|
86
|
+
if ((details.warnings || 0) > 0) {
|
|
87
|
+
recommendations.push("Review slow endpoints.");
|
|
88
|
+
recommendations.push("Add caching or pagination where needed.");
|
|
89
|
+
}
|
|
90
|
+
if ((details.failed || 0) > 0 || result.status === "fail") {
|
|
91
|
+
recommendations.push("Investigate failed requests before increasing load.");
|
|
92
|
+
}
|
|
93
|
+
if ((details.skippedEndpoints || []).some((endpoint) => endpoint.reason === "unsafe method")) {
|
|
94
|
+
recommendations.push("Test mutating endpoints manually in a safe staging environment.");
|
|
95
|
+
}
|
|
96
|
+
recommendations.push("Add rate limits before public deployment.");
|
|
97
|
+
|
|
98
|
+
return [
|
|
99
|
+
"",
|
|
100
|
+
"## Recommendations",
|
|
101
|
+
"",
|
|
102
|
+
...unique(recommendations).map((recommendation) => `- ${recommendation}`),
|
|
103
|
+
""
|
|
104
|
+
].join("\n");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function renderArtilleryError(error) {
|
|
108
|
+
if (!error) return "";
|
|
109
|
+
|
|
110
|
+
return [
|
|
111
|
+
"",
|
|
112
|
+
"## Artillery Error",
|
|
113
|
+
"",
|
|
114
|
+
error,
|
|
115
|
+
""
|
|
116
|
+
].join("\n");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function formatTimestamp(value) {
|
|
120
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
121
|
+
if (Number.isNaN(date.getTime())) return String(value);
|
|
122
|
+
|
|
123
|
+
const pad = (number) => String(number).padStart(2, "0");
|
|
124
|
+
return [
|
|
125
|
+
date.getFullYear(),
|
|
126
|
+
"-",
|
|
127
|
+
pad(date.getMonth() + 1),
|
|
128
|
+
"-",
|
|
129
|
+
pad(date.getDate()),
|
|
130
|
+
" ",
|
|
131
|
+
pad(date.getHours()),
|
|
132
|
+
":",
|
|
133
|
+
pad(date.getMinutes()),
|
|
134
|
+
":",
|
|
135
|
+
pad(date.getSeconds())
|
|
136
|
+
].join("");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function formatMs(value) {
|
|
140
|
+
return value === null || value === undefined ? "n/a" : `${Math.round(value)} ms`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function formatPercent(value) {
|
|
144
|
+
return `${Number(value || 0).toFixed(Number.isInteger(value) ? 0 : 2).replace(/\.00$/, "")}%`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function escapeTable(value) {
|
|
148
|
+
return stripAnsi(String(value ?? "unknown")).replace(/\|/g, "\\|");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function stripAnsi(value) {
|
|
152
|
+
return String(value).replace(ANSI_PATTERN, "");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function unique(values) {
|
|
156
|
+
return [...new Set(values.filter(Boolean))];
|
|
157
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { fileExists } from "../utils/fs.js";
|
|
8
|
+
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
const TOOL_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
11
|
+
|
|
12
|
+
export async function runArtillery(config, options = {}) {
|
|
13
|
+
if (options.execute) return await options.execute(config, options);
|
|
14
|
+
|
|
15
|
+
const rootPath = options.rootPath || process.cwd();
|
|
16
|
+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "itworksbut-stress-"));
|
|
17
|
+
const configPath = path.join(tempDir, "artillery-config.json");
|
|
18
|
+
const outputPath = path.join(tempDir, "artillery-report.json");
|
|
19
|
+
await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
20
|
+
|
|
21
|
+
const { command, args } = await resolveArtilleryCommand(rootPath, configPath, outputPath);
|
|
22
|
+
const timeout = Math.max(90_000, ((options.duration || 30) + 60) * 1000);
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const result = await execFileAsync(command, args, {
|
|
26
|
+
cwd: rootPath,
|
|
27
|
+
timeout,
|
|
28
|
+
maxBuffer: 10 * 1024 * 1024
|
|
29
|
+
});
|
|
30
|
+
return {
|
|
31
|
+
ok: true,
|
|
32
|
+
command,
|
|
33
|
+
args,
|
|
34
|
+
stdout: result.stdout || "",
|
|
35
|
+
stderr: result.stderr || "",
|
|
36
|
+
exitCode: 0,
|
|
37
|
+
report: await readJsonFile(outputPath)
|
|
38
|
+
};
|
|
39
|
+
} catch (error) {
|
|
40
|
+
return {
|
|
41
|
+
ok: false,
|
|
42
|
+
command,
|
|
43
|
+
args,
|
|
44
|
+
stdout: error?.stdout || "",
|
|
45
|
+
stderr: error?.stderr || error?.message || "",
|
|
46
|
+
exitCode: error?.code ?? null,
|
|
47
|
+
signal: error?.signal,
|
|
48
|
+
report: await readJsonFile(outputPath)
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function resolveArtilleryCommand(rootPath, configPath, outputPath) {
|
|
54
|
+
const binaryName = process.platform === "win32" ? "artillery.cmd" : "artillery";
|
|
55
|
+
const toolBin = path.join(TOOL_ROOT, "node_modules", ".bin", binaryName);
|
|
56
|
+
if (await fileExists(toolBin)) {
|
|
57
|
+
return {
|
|
58
|
+
command: toolBin,
|
|
59
|
+
args: ["run", configPath, "--output", outputPath]
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const projectBin = path.join(rootPath, "node_modules", ".bin", binaryName);
|
|
64
|
+
if (await fileExists(projectBin)) {
|
|
65
|
+
return {
|
|
66
|
+
command: projectBin,
|
|
67
|
+
args: ["run", configPath, "--output", outputPath]
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
command: process.platform === "win32" ? "npm.cmd" : "npm",
|
|
73
|
+
args: ["exec", "--yes", "artillery", "--", "run", configPath, "--output", outputPath]
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function readJsonFile(filePath) {
|
|
78
|
+
try {
|
|
79
|
+
return JSON.parse(await fs.readFile(filePath, "utf8"));
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { DEFAULT_IGNORE } from "../core/config.js";
|
|
3
|
+
import { walkProject } from "../core/fileWalker.js";
|
|
4
|
+
import { readFileSafe } from "../utils/fs.js";
|
|
5
|
+
|
|
6
|
+
const HTTP_METHODS = ["get", "head", "post", "put", "patch", "delete"];
|
|
7
|
+
const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
8
|
+
const ROUTE_EXTENSIONS = new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"]);
|
|
9
|
+
const DISCOVERY_IGNORE = [
|
|
10
|
+
...DEFAULT_IGNORE,
|
|
11
|
+
"test/**",
|
|
12
|
+
"tests/**",
|
|
13
|
+
"__tests__/**",
|
|
14
|
+
"**/*.test.js",
|
|
15
|
+
"**/*.test.ts",
|
|
16
|
+
"**/*.spec.js",
|
|
17
|
+
"**/*.spec.ts"
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
export async function discoverEndpoints(rootPath) {
|
|
21
|
+
const { textFiles } = await walkProject(rootPath, DISCOVERY_IGNORE);
|
|
22
|
+
return await discoverEndpointsFromFiles({
|
|
23
|
+
rootPath,
|
|
24
|
+
files: textFiles,
|
|
25
|
+
readFile: async (relativePath) => await readFileSafe(path.join(rootPath, relativePath))
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function discoverEndpointsFromFiles({ rootPath, files, readFile }) {
|
|
30
|
+
const endpoints = [];
|
|
31
|
+
|
|
32
|
+
for (const file of files.filter(isSourceFile)) {
|
|
33
|
+
const content = await readFile(file);
|
|
34
|
+
if (content === null || content === undefined) continue;
|
|
35
|
+
|
|
36
|
+
endpoints.push(...discoverExpressEndpoints(file, content));
|
|
37
|
+
endpoints.push(...discoverFetchReferences(file, content));
|
|
38
|
+
endpoints.push(...discoverNextAppRouterEndpoints(file, content));
|
|
39
|
+
endpoints.push(...discoverNextPagesRouterEndpoints(file, content));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return classifyEndpoints(dedupeEndpoints(endpoints), rootPath);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function classifyEndpoints(endpoints) {
|
|
46
|
+
const all = endpoints.map((endpoint) => {
|
|
47
|
+
const method = endpoint.method.toUpperCase();
|
|
48
|
+
const dynamic = isDynamicRoute(endpoint.path);
|
|
49
|
+
const unsafe = MUTATING_METHODS.has(method);
|
|
50
|
+
let status = "selected";
|
|
51
|
+
let reason;
|
|
52
|
+
|
|
53
|
+
if (unsafe) {
|
|
54
|
+
status = "skipped";
|
|
55
|
+
reason = "unsafe method";
|
|
56
|
+
} else if (dynamic) {
|
|
57
|
+
status = "skipped";
|
|
58
|
+
reason = "dynamic route requires parameter";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
...endpoint,
|
|
63
|
+
method,
|
|
64
|
+
dynamic,
|
|
65
|
+
status,
|
|
66
|
+
reason
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
status: all.length === 0 ? "skip" : "pass",
|
|
72
|
+
endpoints: all,
|
|
73
|
+
safeEndpoints: all.filter((endpoint) => endpoint.status === "selected"),
|
|
74
|
+
skippedEndpoints: all
|
|
75
|
+
.filter((endpoint) => endpoint.status === "skipped")
|
|
76
|
+
.map(({ method, path, reason, source, type }) => ({ method, path, reason, source, type }))
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function discoverExpressEndpoints(file, content) {
|
|
81
|
+
const endpoints = [];
|
|
82
|
+
const methods = HTTP_METHODS.join("|");
|
|
83
|
+
const regex = new RegExp(`\\b(?:app|router|server)\\s*\\.\\s*(${methods})\\s*\\(\\s*(['"\`])([^'"\`]+)\\2`, "gi");
|
|
84
|
+
let match;
|
|
85
|
+
|
|
86
|
+
while ((match = regex.exec(content)) !== null) {
|
|
87
|
+
const routePath = normalizeRoutePath(match[3]);
|
|
88
|
+
if (!routePath) continue;
|
|
89
|
+
endpoints.push(endpoint(match[1], routePath, file, "express"));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return endpoints;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function discoverFetchReferences(file, content) {
|
|
96
|
+
const endpoints = [];
|
|
97
|
+
const regex = /\bfetch\s*\(\s*(['"`])(\/api\/[^'"`]+)\1/gi;
|
|
98
|
+
let match;
|
|
99
|
+
|
|
100
|
+
while ((match = regex.exec(content)) !== null) {
|
|
101
|
+
const routePath = normalizeRoutePath(match[2]);
|
|
102
|
+
if (!routePath) continue;
|
|
103
|
+
endpoints.push(endpoint("GET", routePath, file, "fetch-reference"));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return endpoints;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function discoverNextAppRouterEndpoints(file, content) {
|
|
110
|
+
const normalized = normalizeFilePath(file);
|
|
111
|
+
const match = normalized.match(/(?:^|\/)(?:src\/)?app\/api\/(.+)\/route\.(?:js|jsx|ts|tsx|mjs|cjs)$/);
|
|
112
|
+
if (!match) return [];
|
|
113
|
+
|
|
114
|
+
const routePath = normalizeRoutePath(`/api/${routeSegmentsToPath(match[1])}`);
|
|
115
|
+
const exportedMethods = discoverExportedRouteMethods(content);
|
|
116
|
+
const methods = exportedMethods.length > 0 ? exportedMethods : ["GET"];
|
|
117
|
+
|
|
118
|
+
return methods.map((method) => endpoint(method, routePath, file, "next-app-router"));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function discoverNextPagesRouterEndpoints(file, content) {
|
|
122
|
+
const normalized = normalizeFilePath(file);
|
|
123
|
+
const match = normalized.match(/(?:^|\/)(?:src\/)?pages\/api\/(.+)\.(?:js|jsx|ts|tsx|mjs|cjs)$/);
|
|
124
|
+
if (!match) return [];
|
|
125
|
+
|
|
126
|
+
const routePath = normalizeRoutePath(`/api/${routeSegmentsToPath(match[1])}`);
|
|
127
|
+
const guardedMethods = discoverMethodGuards(content);
|
|
128
|
+
const methods = guardedMethods.length > 0 ? guardedMethods : ["GET"];
|
|
129
|
+
|
|
130
|
+
return methods.map((method) => endpoint(method, routePath, file, "next-pages-router"));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function discoverExportedRouteMethods(content) {
|
|
134
|
+
const methods = new Set();
|
|
135
|
+
const regex = /\bexport\s+(?:async\s+)?function\s+(GET|HEAD|POST|PUT|PATCH|DELETE)\b/g;
|
|
136
|
+
let match;
|
|
137
|
+
|
|
138
|
+
while ((match = regex.exec(content)) !== null) {
|
|
139
|
+
methods.add(match[1]);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return [...methods];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function discoverMethodGuards(content) {
|
|
146
|
+
const methods = new Set();
|
|
147
|
+
const regex = /\b(?:req|request)\.method\s*(?:===|!==|==|!=)\s*['"`](GET|HEAD|POST|PUT|PATCH|DELETE)['"`]/gi;
|
|
148
|
+
let match;
|
|
149
|
+
|
|
150
|
+
while ((match = regex.exec(content)) !== null) {
|
|
151
|
+
methods.add(match[1].toUpperCase());
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return [...methods];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function endpoint(method, routePath, file, type) {
|
|
158
|
+
return {
|
|
159
|
+
method: method.toUpperCase(),
|
|
160
|
+
path: routePath,
|
|
161
|
+
source: file,
|
|
162
|
+
type
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function dedupeEndpoints(endpoints) {
|
|
167
|
+
const byKey = new Map();
|
|
168
|
+
for (const endpoint of endpoints) {
|
|
169
|
+
const key = `${endpoint.method.toUpperCase()} ${endpoint.path}`;
|
|
170
|
+
if (!byKey.has(key)) byKey.set(key, endpoint);
|
|
171
|
+
}
|
|
172
|
+
return [...byKey.values()].sort((a, b) => `${a.method} ${a.path}`.localeCompare(`${b.method} ${b.path}`));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isSourceFile(file) {
|
|
176
|
+
const normalized = normalizeFilePath(file);
|
|
177
|
+
return ROUTE_EXTENSIONS.has(path.extname(normalized));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function normalizeFilePath(file) {
|
|
181
|
+
return String(file).replace(/\\/g, "/");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function normalizeRoutePath(value) {
|
|
185
|
+
if (!value || value.includes("${")) return null;
|
|
186
|
+
const pathValue = value.startsWith("/") ? value : `/${value}`;
|
|
187
|
+
return pathValue.replace(/\/+/g, "/").replace(/\/$/, "") || "/";
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function routeSegmentsToPath(value) {
|
|
191
|
+
return value
|
|
192
|
+
.split("/")
|
|
193
|
+
.filter(Boolean)
|
|
194
|
+
.map((segment) => segment.replace(/^\[(\.\.\.)?(.+)]$/, ":$2"))
|
|
195
|
+
.join("/");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function isDynamicRoute(routePath) {
|
|
199
|
+
return /(^|\/):[^/]+|\[[^/]+]|\*/.test(routePath);
|
|
200
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export function createArtilleryConfig({ target, duration, arrivalRate, maxVusers, endpoints }) {
|
|
2
|
+
const flow = endpoints.map((endpoint) => ({
|
|
3
|
+
[endpoint.method.toLowerCase()]: {
|
|
4
|
+
url: endpoint.path
|
|
5
|
+
}
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
return {
|
|
9
|
+
config: {
|
|
10
|
+
target,
|
|
11
|
+
phases: [
|
|
12
|
+
{
|
|
13
|
+
duration,
|
|
14
|
+
arrivalRate,
|
|
15
|
+
maxVusers
|
|
16
|
+
}
|
|
17
|
+
],
|
|
18
|
+
defaults: {
|
|
19
|
+
headers: {
|
|
20
|
+
"user-agent": "ItWorksBut Stress"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
scenarios: [
|
|
25
|
+
{
|
|
26
|
+
name: "Discovered API endpoints",
|
|
27
|
+
flow
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { getChalk } from "../cli/terminal.js";
|
|
2
|
+
|
|
3
|
+
export function reportStressConsole(result, options = {}) {
|
|
4
|
+
const colors = getChalk(options);
|
|
5
|
+
const details = result.details || {};
|
|
6
|
+
|
|
7
|
+
if (!options.quiet) {
|
|
8
|
+
process.stdout.write(`${colors.bold("ItWorksBut Stress")}\n\n`);
|
|
9
|
+
process.stdout.write("Only run this against systems you own or are explicitly authorized to test.\n\n");
|
|
10
|
+
process.stdout.write(`Target: ${details.target}\n`);
|
|
11
|
+
process.stdout.write(`Duration: ${details.duration}s\n`);
|
|
12
|
+
process.stdout.write(`Arrival rate: ${details.arrivalRate} req/s\n`);
|
|
13
|
+
process.stdout.write(`Max virtual users: ${details.maxVusers}\n\n`);
|
|
14
|
+
|
|
15
|
+
writeDiscovery(details, colors);
|
|
16
|
+
writeEndpoints(details, colors);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
writeSummary(result, colors);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function writeDiscovery(details, colors) {
|
|
23
|
+
const found = details.endpointsFound || 0;
|
|
24
|
+
const safe = details.safeEndpoints || 0;
|
|
25
|
+
const skipped = details.skippedEndpoints?.length || 0;
|
|
26
|
+
const unsafe = details.skippedEndpoints?.filter((endpoint) => endpoint.reason === "unsafe method").length || 0;
|
|
27
|
+
const dynamic = details.skippedEndpoints?.filter((endpoint) => endpoint.reason === "dynamic route requires parameter").length || 0;
|
|
28
|
+
|
|
29
|
+
process.stdout.write(`${colors.green("✓")} Endpoint discovery: ${found} ${found === 1 ? "endpoint" : "endpoints"} found\n`);
|
|
30
|
+
process.stdout.write(`${colors.green("✓")} Safe endpoints: ${safe} GET/HEAD ${safe === 1 ? "endpoint" : "endpoints"} selected\n`);
|
|
31
|
+
if (skipped > 0) {
|
|
32
|
+
process.stdout.write(`- Skipped: ${unsafe} unsafe methods, ${dynamic} dynamic routes\n`);
|
|
33
|
+
}
|
|
34
|
+
process.stdout.write("\n");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function writeEndpoints(details, colors) {
|
|
38
|
+
const testedEndpoints = details.testedEndpoints || [];
|
|
39
|
+
if (testedEndpoints.length === 0) {
|
|
40
|
+
process.stdout.write("Running Artillery: skipped\n\n");
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
process.stdout.write("Running Artillery: complete\n\n");
|
|
45
|
+
for (const endpoint of testedEndpoints) {
|
|
46
|
+
const symbol = endpoint.status === "pass" ? colors.green("✓") : endpoint.status === "warn" ? colors.yellow("⚠") : colors.red("✕");
|
|
47
|
+
process.stdout.write(`${symbol} ${endpoint.method} ${endpoint.path}\n`);
|
|
48
|
+
process.stdout.write(` p95: ${formatMs(endpoint.p95)}\n`);
|
|
49
|
+
process.stdout.write(` p99: ${formatMs(endpoint.p99)}\n`);
|
|
50
|
+
process.stdout.write(` errors: ${endpoint.errors}\n`);
|
|
51
|
+
process.stdout.write(` error rate: ${formatPercent(endpoint.errorRate)}\n\n`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function writeSummary(result, colors) {
|
|
56
|
+
const details = result.details || {};
|
|
57
|
+
const warnings = details.warnings || 0;
|
|
58
|
+
const failed = details.failed || 0;
|
|
59
|
+
const tested = details.testedEndpoints?.length || 0;
|
|
60
|
+
const skipped = details.skippedEndpoints?.length || 0;
|
|
61
|
+
|
|
62
|
+
process.stdout.write("Summary:\n");
|
|
63
|
+
process.stdout.write(`${colors.green("✓")} Tested endpoints: ${tested}\n`);
|
|
64
|
+
process.stdout.write(`${colors.yellow("⚠")} Warnings: ${warnings}\n`);
|
|
65
|
+
process.stdout.write(`${colors.red("✕")} Failed: ${failed}\n`);
|
|
66
|
+
process.stdout.write(`- Skipped: ${skipped}\n`);
|
|
67
|
+
process.stdout.write(`- Status: ${result.status}\n`);
|
|
68
|
+
process.stdout.write(`- ${result.summary}\n`);
|
|
69
|
+
if (details.artilleryError) {
|
|
70
|
+
process.stdout.write(`- Artillery error: ${details.artilleryError}\n`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function formatMs(value) {
|
|
75
|
+
return value === null || value === undefined ? "n/a" : `${Math.round(value)} ms`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function formatPercent(value) {
|
|
79
|
+
return `${Number(value || 0).toFixed(Number.isInteger(value) ? 0 : 2).replace(/\.00$/, "")}%`;
|
|
80
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
const DEFAULT_THRESHOLDS = {
|
|
2
|
+
p95WarnMs: 500,
|
|
3
|
+
p95FailMs: 2000,
|
|
4
|
+
p99WarnMs: 1500,
|
|
5
|
+
errorRateWarn: 0,
|
|
6
|
+
errorRateFail: 10
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function parseArtilleryResult(artilleryResult, endpoints, options = {}) {
|
|
10
|
+
const thresholds = { ...DEFAULT_THRESHOLDS, ...(options.thresholds || {}) };
|
|
11
|
+
|
|
12
|
+
if (!artilleryResult?.report) {
|
|
13
|
+
return {
|
|
14
|
+
status: "fail",
|
|
15
|
+
summary: "Artillery did not produce a parseable report.",
|
|
16
|
+
testedEndpoints: endpoints.map((endpoint) => ({
|
|
17
|
+
method: endpoint.method,
|
|
18
|
+
path: endpoint.path,
|
|
19
|
+
status: "fail",
|
|
20
|
+
requests: 0,
|
|
21
|
+
p95: null,
|
|
22
|
+
p99: null,
|
|
23
|
+
errors: 1,
|
|
24
|
+
errorRate: 100
|
|
25
|
+
})),
|
|
26
|
+
warnings: 0,
|
|
27
|
+
failed: endpoints.length,
|
|
28
|
+
error: trimText(artilleryResult?.stderr || artilleryResult?.stdout || "Artillery failed.")
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const aggregate = getAggregate(artilleryResult.report);
|
|
33
|
+
const counters = aggregate.counters || {};
|
|
34
|
+
const summaries = aggregate.summaries || {};
|
|
35
|
+
const fallbackRequests = countRequests(counters);
|
|
36
|
+
const fallbackErrors = countErrors(counters);
|
|
37
|
+
|
|
38
|
+
const testedEndpoints = endpoints.map((endpoint) => {
|
|
39
|
+
const summary = findEndpointSummary(summaries, endpoint) || summaries["http.response_time"] || {};
|
|
40
|
+
const requests = countEndpointRequests(counters, endpoint) ?? fallbackRequests;
|
|
41
|
+
const errors = countEndpointErrors(counters, endpoint) ?? fallbackErrors;
|
|
42
|
+
const errorRate = requests > 0 ? round((errors / requests) * 100, 2) : (errors > 0 ? 100 : 0);
|
|
43
|
+
const p95 = numeric(summary.p95);
|
|
44
|
+
const p99 = numeric(summary.p99);
|
|
45
|
+
const status = classifyEndpoint({ p95, p99, errorRate, errors }, thresholds);
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
method: endpoint.method,
|
|
49
|
+
path: endpoint.path,
|
|
50
|
+
status,
|
|
51
|
+
requests,
|
|
52
|
+
p95,
|
|
53
|
+
p99,
|
|
54
|
+
errors,
|
|
55
|
+
errorRate
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const warnings = testedEndpoints.filter((endpoint) => endpoint.status === "warn").length;
|
|
60
|
+
const failed = testedEndpoints.filter((endpoint) => endpoint.status === "fail").length;
|
|
61
|
+
const status = !artilleryResult.ok || failed > 0 ? "fail" : warnings > 0 ? "warn" : "pass";
|
|
62
|
+
const summary = summarize(testedEndpoints.length, warnings, failed, artilleryResult);
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
status,
|
|
66
|
+
summary,
|
|
67
|
+
testedEndpoints,
|
|
68
|
+
warnings,
|
|
69
|
+
failed,
|
|
70
|
+
error: artilleryResult.ok ? undefined : trimText(artilleryResult.stderr || artilleryResult.stdout)
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function getAggregate(report) {
|
|
75
|
+
if (report?.aggregate) return report.aggregate;
|
|
76
|
+
if (Array.isArray(report?.intermediate) && report.intermediate.length > 0) {
|
|
77
|
+
return report.intermediate[report.intermediate.length - 1] || {};
|
|
78
|
+
}
|
|
79
|
+
return report || {};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function findEndpointSummary(summaries, endpoint) {
|
|
83
|
+
const methodPath = `${endpoint.method} ${endpoint.path}`;
|
|
84
|
+
const candidates = Object.entries(summaries);
|
|
85
|
+
const match = candidates.find(([key]) => key.includes(methodPath));
|
|
86
|
+
if (match) return match[1];
|
|
87
|
+
|
|
88
|
+
const pathMatch = candidates.find(([key]) => key.includes(endpoint.path));
|
|
89
|
+
return pathMatch ? pathMatch[1] : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function countRequests(counters) {
|
|
93
|
+
if (Number.isFinite(counters["http.requests"])) return counters["http.requests"];
|
|
94
|
+
if (Number.isFinite(counters["http.responses"])) return counters["http.responses"];
|
|
95
|
+
|
|
96
|
+
return Object.entries(counters)
|
|
97
|
+
.filter(([key]) => /^http\.codes\.\d+$/.test(key))
|
|
98
|
+
.reduce((total, [, value]) => total + numeric(value), 0);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function countErrors(counters) {
|
|
102
|
+
const explicit = Object.entries(counters)
|
|
103
|
+
.filter(([key]) => key.startsWith("http.errors"))
|
|
104
|
+
.reduce((total, [, value]) => total + numeric(value), 0);
|
|
105
|
+
const statusErrors = Object.entries(counters)
|
|
106
|
+
.filter(([key]) => /^http\.codes\.[45]\d\d$/.test(key))
|
|
107
|
+
.reduce((total, [, value]) => total + numeric(value), 0);
|
|
108
|
+
|
|
109
|
+
return explicit + statusErrors;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function countEndpointRequests(counters, endpoint) {
|
|
113
|
+
return countEndpointMetric(counters, endpoint, ["requests", "responses"]);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function countEndpointErrors(counters, endpoint) {
|
|
117
|
+
return countEndpointMetric(counters, endpoint, ["errors"]);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function countEndpointMetric(counters, endpoint, names) {
|
|
121
|
+
const methodPath = `${endpoint.method} ${endpoint.path}`;
|
|
122
|
+
const matches = Object.entries(counters).filter(([key]) => {
|
|
123
|
+
return key.includes(methodPath) && names.some((name) => key.toLowerCase().includes(name));
|
|
124
|
+
});
|
|
125
|
+
if (!matches.length) return null;
|
|
126
|
+
return matches.reduce((total, [, value]) => total + numeric(value), 0);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function classifyEndpoint({ p95, p99, errorRate, errors }, thresholds) {
|
|
130
|
+
if (errorRate >= thresholds.errorRateFail || p95 >= thresholds.p95FailMs) return "fail";
|
|
131
|
+
if (errorRate > thresholds.errorRateWarn || errors > 0 || p95 > thresholds.p95WarnMs || p99 > thresholds.p99WarnMs) {
|
|
132
|
+
return "warn";
|
|
133
|
+
}
|
|
134
|
+
return "pass";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function summarize(tested, warnings, failed, artilleryResult) {
|
|
138
|
+
if (!artilleryResult.ok) return `Artillery failed after testing ${tested} ${tested === 1 ? "endpoint" : "endpoints"}.`;
|
|
139
|
+
return `${tested} ${tested === 1 ? "endpoint" : "endpoints"} tested, ${warnings} ${warnings === 1 ? "warning" : "warnings"}, ${failed} failed`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function numeric(value) {
|
|
143
|
+
const number = Number(value);
|
|
144
|
+
return Number.isFinite(number) ? number : 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function round(value, digits) {
|
|
148
|
+
const factor = 10 ** digits;
|
|
149
|
+
return Math.round(value * factor) / factor;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function trimText(value) {
|
|
153
|
+
const normalized = String(value || "").trim();
|
|
154
|
+
return normalized.length > 1000 ? `${normalized.slice(0, 1000)}...` : normalized;
|
|
155
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "0.0.0.0"]);
|
|
2
|
+
|
|
3
|
+
export const STRESS_DEFAULTS = {
|
|
4
|
+
target: "http://localhost:3000",
|
|
5
|
+
duration: 30,
|
|
6
|
+
arrivalRate: 5,
|
|
7
|
+
maxVusers: 50
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const STRESS_LIMITS = {
|
|
11
|
+
duration: 300,
|
|
12
|
+
arrivalRate: 50,
|
|
13
|
+
maxVusers: 100
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function validateStressOptions(args = {}) {
|
|
17
|
+
const target = normalizeTarget(args.target || STRESS_DEFAULTS.target);
|
|
18
|
+
const duration = parsePositiveNumber(args.duration, STRESS_DEFAULTS.duration, "duration");
|
|
19
|
+
const arrivalRate = parsePositiveNumber(args.arrivalRate, STRESS_DEFAULTS.arrivalRate, "arrival-rate");
|
|
20
|
+
const maxVusers = parsePositiveInteger(args.maxVusers, STRESS_DEFAULTS.maxVusers, "max-vusers");
|
|
21
|
+
|
|
22
|
+
assertWithinLimit(duration, STRESS_LIMITS.duration, "duration", "seconds");
|
|
23
|
+
assertWithinLimit(arrivalRate, STRESS_LIMITS.arrivalRate, "arrival-rate", "requests/second");
|
|
24
|
+
assertWithinLimit(maxVusers, STRESS_LIMITS.maxVusers, "max-vusers", "virtual users");
|
|
25
|
+
|
|
26
|
+
const local = isLocalTarget(target);
|
|
27
|
+
if (!local && !args.iOwnThis) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
"Refusing to stress-test an external target without --i-own-this. Only run this against systems you own or are explicitly authorized to test."
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
target,
|
|
35
|
+
duration,
|
|
36
|
+
arrivalRate,
|
|
37
|
+
maxVusers,
|
|
38
|
+
iOwnThis: Boolean(args.iOwnThis),
|
|
39
|
+
local
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function isLocalTarget(target) {
|
|
44
|
+
let parsed;
|
|
45
|
+
try {
|
|
46
|
+
parsed = new URL(target);
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return LOCAL_HOSTS.has(parsed.hostname.toLowerCase());
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normalizeTarget(value) {
|
|
55
|
+
let parsed;
|
|
56
|
+
try {
|
|
57
|
+
parsed = new URL(value);
|
|
58
|
+
} catch {
|
|
59
|
+
throw new Error(`Invalid stress target URL: ${value}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
63
|
+
throw new Error("Stress target must use http:// or https://.");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return parsed.toString().replace(/\/$/, "");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parsePositiveNumber(value, fallback, label) {
|
|
70
|
+
if (value === undefined || value === null || value === "") return fallback;
|
|
71
|
+
const number = Number(value);
|
|
72
|
+
if (!Number.isFinite(number) || number <= 0) {
|
|
73
|
+
throw new Error(`Invalid --${label}: expected a positive number.`);
|
|
74
|
+
}
|
|
75
|
+
return number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function parsePositiveInteger(value, fallback, label) {
|
|
79
|
+
const number = parsePositiveNumber(value, fallback, label);
|
|
80
|
+
if (!Number.isInteger(number)) {
|
|
81
|
+
throw new Error(`Invalid --${label}: expected a positive integer.`);
|
|
82
|
+
}
|
|
83
|
+
return number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function assertWithinLimit(value, limit, label, unit) {
|
|
87
|
+
if (value > limit) {
|
|
88
|
+
throw new Error(`Refusing --${label} ${value}. Maximum allowed is ${limit} ${unit}.`);
|
|
89
|
+
}
|
|
90
|
+
}
|