@edgetesting/jmeter 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 +75 -0
- package/bin/edgetesting-jmeter.js +4 -0
- package/package.json +45 -0
- package/src/index.js +275 -0
package/README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# @edgetesting/jmeter
|
|
2
|
+
|
|
3
|
+
CLI wrapper that uploads JMeter `.jtl` result files to [EdgeTesting](https://edgetesting.io), evaluates SLA thresholds, and automatically creates Jira bug reports on violations.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Node.js 18+
|
|
8
|
+
- Apache JMeter (for running tests)
|
|
9
|
+
- An EdgeTesting workspace with an API key
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install -g @edgetesting/jmeter
|
|
15
|
+
|
|
16
|
+
# Or use without installing
|
|
17
|
+
npx @edgetesting/jmeter --help
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Quick start
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
# 1. Run your JMeter test
|
|
24
|
+
jmeter -n -t my-test-plan.jmx -l results.jtl
|
|
25
|
+
|
|
26
|
+
# 2. Upload results with a 500ms SLA
|
|
27
|
+
npx edgetesting-jmeter --file results.jtl --id TC-100 --sla 500
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Options
|
|
31
|
+
|
|
32
|
+
| Flag | Required | Description |
|
|
33
|
+
|---|---|---|
|
|
34
|
+
| `--file <path>` | yes | Path to the `.jtl` file (CSV or XML) |
|
|
35
|
+
| `--id <automation_id>` | yes | EdgeTesting automation ID, e.g. `TC-100` |
|
|
36
|
+
| `--sla <ms>` | no | Latency SLA in ms — bug created if avg or P95 exceeds this |
|
|
37
|
+
| `--sla-error-pct <n>` | no | Error rate SLA — bug created if error % exceeds this |
|
|
38
|
+
| `--issue <jira_key>` | no | Override the Jira issue key on the test case |
|
|
39
|
+
| `--api-key <key>` | no | API key (or set `EDGETESTING_API_KEY`) |
|
|
40
|
+
| `--api-url <url>` | no | Base URL (or set `EDGETESTING_API_URL`) |
|
|
41
|
+
| `--dry-run` | no | Parse and print metrics locally, no upload |
|
|
42
|
+
|
|
43
|
+
## Environment variables
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
EDGETESTING_API_KEY=et_xxxx
|
|
47
|
+
EDGETESTING_API_URL=https://your-edgetesting-instance.com
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Metrics extracted
|
|
51
|
+
|
|
52
|
+
- Average latency (ms)
|
|
53
|
+
- P95 and P99 latency (ms)
|
|
54
|
+
- Min / Max latency
|
|
55
|
+
- Throughput (requests/sec)
|
|
56
|
+
- Error percentage
|
|
57
|
+
- Per-endpoint breakdown
|
|
58
|
+
|
|
59
|
+
## SLA violations
|
|
60
|
+
|
|
61
|
+
When a threshold is breached the CLI:
|
|
62
|
+
- Creates a Bug Report in EdgeTesting
|
|
63
|
+
- Posts a Markdown metrics table as a Jira comment
|
|
64
|
+
- Exits with code `1` to fail your CI build
|
|
65
|
+
|
|
66
|
+
Severity is derived automatically based on how far the result exceeds the threshold.
|
|
67
|
+
|
|
68
|
+
## Supported .jtl formats
|
|
69
|
+
|
|
70
|
+
- **CSV** — default JMeter output (`timeStamp,elapsed,label,success,Latency,...`)
|
|
71
|
+
- **XML** — standard JMeter XML with `<httpSample>` / `<sample>` elements
|
|
72
|
+
|
|
73
|
+
## License
|
|
74
|
+
|
|
75
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@edgetesting/jmeter",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "CLI wrapper to upload JMeter .jtl results to EdgeTesting, evaluate SLA thresholds, and auto-create Jira bug reports on violations",
|
|
5
|
+
"bin": {
|
|
6
|
+
"edgetesting-jmeter": "./bin/edgetesting-jmeter.js"
|
|
7
|
+
},
|
|
8
|
+
"main": "./src/index.js",
|
|
9
|
+
"type": "commonjs",
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"src",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18.0.0"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"prepublishOnly": "node --check src/index.js && node --check bin/edgetesting-jmeter.js && echo 'Syntax OK'"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"commander": "^12.0.0",
|
|
23
|
+
"form-data": "^4.0.0",
|
|
24
|
+
"node-fetch": "^2.7.0"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"jmeter",
|
|
28
|
+
"performance",
|
|
29
|
+
"load-testing",
|
|
30
|
+
"edgetesting",
|
|
31
|
+
"qa",
|
|
32
|
+
"sla",
|
|
33
|
+
"jira",
|
|
34
|
+
"cli"
|
|
35
|
+
],
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "https://github.com/edgetesting/jmeter"
|
|
40
|
+
},
|
|
41
|
+
"homepage": "https://edgetesting.io",
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { program } = require('commander');
|
|
5
|
+
const FormData = require('form-data');
|
|
6
|
+
const fetch = require('node-fetch');
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
// ── Banner ────────────────────────────────────────────────────────────────────
|
|
11
|
+
const RESET = '\x1b[0m';
|
|
12
|
+
const BOLD = '\x1b[1m';
|
|
13
|
+
const GREEN = '\x1b[32m';
|
|
14
|
+
const RED = '\x1b[31m';
|
|
15
|
+
const YELLOW = '\x1b[33m';
|
|
16
|
+
const CYAN = '\x1b[36m';
|
|
17
|
+
const DIM = '\x1b[2m';
|
|
18
|
+
|
|
19
|
+
function log(msg) { console.log(msg); }
|
|
20
|
+
function ok(msg) { console.log(`${GREEN}✔${RESET} ${msg}`); }
|
|
21
|
+
function fail(msg) { console.error(`${RED}✘${RESET} ${msg}`); }
|
|
22
|
+
function warn(msg) { console.warn(`${YELLOW}⚠${RESET} ${msg}`); }
|
|
23
|
+
function info(msg) { console.log(`${CYAN}ℹ${RESET} ${msg}`); }
|
|
24
|
+
function dim(msg) { console.log(`${DIM}${msg}${RESET}`); }
|
|
25
|
+
function bold(msg) { return `${BOLD}${msg}${RESET}`; }
|
|
26
|
+
|
|
27
|
+
function printBanner() {
|
|
28
|
+
log('');
|
|
29
|
+
log(`${BOLD}${CYAN} EdgeTesting JMeter${RESET}`);
|
|
30
|
+
log(`${DIM} Performance results uploader & SLA evaluator${RESET}`);
|
|
31
|
+
log('');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── CLI definition ────────────────────────────────────────────────────────────
|
|
35
|
+
program
|
|
36
|
+
.name('edgetesting-jmeter')
|
|
37
|
+
.description('Upload a JMeter .jtl results file to EdgeTesting and evaluate SLA thresholds.')
|
|
38
|
+
.version('1.0.0')
|
|
39
|
+
.requiredOption(
|
|
40
|
+
'-f, --file <path>',
|
|
41
|
+
'Path to the JMeter .jtl results file (CSV or XML)'
|
|
42
|
+
)
|
|
43
|
+
.requiredOption(
|
|
44
|
+
'-i, --id <automation_id>',
|
|
45
|
+
'EdgeTesting automation ID of the test case (e.g. TC-100)'
|
|
46
|
+
)
|
|
47
|
+
.option(
|
|
48
|
+
'--sla <ms>',
|
|
49
|
+
'Latency SLA in milliseconds. Triggers a bug report if avg or P95 exceeds this value.',
|
|
50
|
+
(v) => parseInt(v, 10)
|
|
51
|
+
)
|
|
52
|
+
.option(
|
|
53
|
+
'--sla-error-pct <pct>',
|
|
54
|
+
'Error rate SLA as a percentage (e.g. 1.0 = fail if >1% errors).',
|
|
55
|
+
(v) => parseFloat(v)
|
|
56
|
+
)
|
|
57
|
+
.option(
|
|
58
|
+
'--issue <jira_issue_key>',
|
|
59
|
+
'Override the Jira issue key stored on the test case (e.g. PROJ-42)'
|
|
60
|
+
)
|
|
61
|
+
.option(
|
|
62
|
+
'--api-key <key>',
|
|
63
|
+
'EdgeTesting API key. Defaults to EDGETESTING_API_KEY env var.'
|
|
64
|
+
)
|
|
65
|
+
.option(
|
|
66
|
+
'--api-url <url>',
|
|
67
|
+
'EdgeTesting base URL. Defaults to EDGETESTING_API_URL env var.'
|
|
68
|
+
)
|
|
69
|
+
.option(
|
|
70
|
+
'--dry-run',
|
|
71
|
+
'Parse the .jtl file and print metrics without uploading to EdgeTesting.'
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
program.parse(process.argv);
|
|
75
|
+
const opts = program.opts();
|
|
76
|
+
|
|
77
|
+
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
78
|
+
(async () => {
|
|
79
|
+
printBanner();
|
|
80
|
+
|
|
81
|
+
// ── Resolve config from opts → env vars ─────────────────────────────────
|
|
82
|
+
const apiKey = opts.apiKey || process.env.EDGETESTING_API_KEY || '';
|
|
83
|
+
const apiUrl = (opts.apiUrl || process.env.EDGETESTING_API_URL || '')
|
|
84
|
+
.replace(/\/api\/v1\/?$/, '')
|
|
85
|
+
.replace(/\/$/, '');
|
|
86
|
+
|
|
87
|
+
const jtlPath = path.resolve(opts.file);
|
|
88
|
+
const automationId = opts.id;
|
|
89
|
+
const slaMs = opts.sla ?? null;
|
|
90
|
+
const slaErrPct = opts.slaErrorPct ?? null;
|
|
91
|
+
const issueKey = opts.issue ?? null;
|
|
92
|
+
const isDryRun = opts.dryRun ?? false;
|
|
93
|
+
|
|
94
|
+
// ── Validate file ────────────────────────────────────────────────────────
|
|
95
|
+
if (!fs.existsSync(jtlPath)) {
|
|
96
|
+
fail(`File not found: ${jtlPath}`);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const stat = fs.statSync(jtlPath);
|
|
101
|
+
const sizeMb = (stat.size / 1024 / 1024).toFixed(2);
|
|
102
|
+
|
|
103
|
+
info(`File: ${bold(jtlPath)}`);
|
|
104
|
+
info(`Size: ${sizeMb} MB`);
|
|
105
|
+
info(`Automation ID: ${bold(automationId)}`);
|
|
106
|
+
if (slaMs) info(`SLA Latency: ${bold(slaMs + 'ms')}`);
|
|
107
|
+
if (slaErrPct) info(`SLA Errors: ${bold(slaErrPct + '%')}`);
|
|
108
|
+
if (issueKey) info(`Jira Issue: ${bold(issueKey)}`);
|
|
109
|
+
log('');
|
|
110
|
+
|
|
111
|
+
// ── Dry run — parse locally and exit ────────────────────────────────────
|
|
112
|
+
if (isDryRun) {
|
|
113
|
+
warn('Dry run mode — parsing locally, not uploading.');
|
|
114
|
+
try {
|
|
115
|
+
const metrics = await parseLocally(jtlPath);
|
|
116
|
+
printMetrics(metrics, slaMs, slaErrPct);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
fail('Parse error: ' + err.message);
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── Validate credentials ─────────────────────────────────────────────────
|
|
125
|
+
if (!apiKey) {
|
|
126
|
+
fail('No API key. Set EDGETESTING_API_KEY or pass --api-key.');
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
if (!apiUrl) {
|
|
130
|
+
fail('No API URL. Set EDGETESTING_API_URL or pass --api-url.');
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Upload ───────────────────────────────────────────────────────────────
|
|
135
|
+
info(`Uploading to: ${apiUrl}/api/v1/performance-executions`);
|
|
136
|
+
log('');
|
|
137
|
+
|
|
138
|
+
const form = new FormData();
|
|
139
|
+
form.append('file', fs.createReadStream(jtlPath), path.basename(jtlPath));
|
|
140
|
+
form.append('automation_id', automationId);
|
|
141
|
+
if (slaMs !== null) form.append('sla_latency_ms', String(slaMs));
|
|
142
|
+
if (slaErrPct !== null) form.append('sla_error_percentage', String(slaErrPct));
|
|
143
|
+
if (issueKey) form.append('jira_issue_key', issueKey);
|
|
144
|
+
|
|
145
|
+
let response;
|
|
146
|
+
try {
|
|
147
|
+
response = await fetch(`${apiUrl}/api/v1/performance-executions`, {
|
|
148
|
+
method: 'POST',
|
|
149
|
+
headers: {
|
|
150
|
+
Authorization: `Bearer ${apiKey}`,
|
|
151
|
+
...form.getHeaders(),
|
|
152
|
+
},
|
|
153
|
+
body: form,
|
|
154
|
+
});
|
|
155
|
+
} catch (err) {
|
|
156
|
+
fail('Network error: ' + err.message);
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let body;
|
|
161
|
+
try {
|
|
162
|
+
body = await response.json();
|
|
163
|
+
} catch {
|
|
164
|
+
const text = await response.text().catch(() => '');
|
|
165
|
+
fail(`HTTP ${response.status} — unexpected response: ${text.slice(0, 200)}`);
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (!response.ok) {
|
|
170
|
+
fail(`HTTP ${response.status}: ${body.error || body.message || JSON.stringify(body)}`);
|
|
171
|
+
process.exit(1);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── Results ──────────────────────────────────────────────────────────────
|
|
175
|
+
printMetrics(body.metrics, slaMs, slaErrPct);
|
|
176
|
+
|
|
177
|
+
log('');
|
|
178
|
+
log(` ${bold('Execution ID:')} ${body.execution_id}`);
|
|
179
|
+
log(` ${bold('Status:')} ${body.status === 'passed' ? GREEN + 'PASSED' + RESET : RED + 'FAILED' + RESET}`);
|
|
180
|
+
|
|
181
|
+
if (body.sla_violated) {
|
|
182
|
+
log('');
|
|
183
|
+
warn(bold('SLA Violations:'));
|
|
184
|
+
for (const reason of (body.sla_reasons || [])) {
|
|
185
|
+
log(` ${RED}•${RESET} ${reason}`);
|
|
186
|
+
}
|
|
187
|
+
if (body.bug_report_id) {
|
|
188
|
+
log('');
|
|
189
|
+
warn(`Bug report #${body.bug_report_id} created in EdgeTesting`);
|
|
190
|
+
warn('Jira comment posted automatically');
|
|
191
|
+
}
|
|
192
|
+
} else {
|
|
193
|
+
log('');
|
|
194
|
+
ok('All SLA thresholds passed.');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
log('');
|
|
198
|
+
|
|
199
|
+
// Exit with non-zero if SLA violated — useful for CI gates
|
|
200
|
+
if (body.sla_violated) process.exit(1);
|
|
201
|
+
})();
|
|
202
|
+
|
|
203
|
+
// ── Local parser (dry-run only) ───────────────────────────────────────────────
|
|
204
|
+
async function parseLocally(filePath) {
|
|
205
|
+
const content = fs.readFileSync(filePath, 'utf8').trim();
|
|
206
|
+
const isXml = content.startsWith('<');
|
|
207
|
+
|
|
208
|
+
if (isXml) {
|
|
209
|
+
throw new Error('Local XML parsing not supported in dry-run. Use CSV format or upload normally.');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const lines = content.split('\n').map(l => l.trim()).filter(Boolean);
|
|
213
|
+
let headers = null;
|
|
214
|
+
const samples = [];
|
|
215
|
+
|
|
216
|
+
for (const line of lines) {
|
|
217
|
+
const row = line.split(',');
|
|
218
|
+
if (!headers) {
|
|
219
|
+
const known = ['timestamp','elapsed','label','success','latency','responsecode'];
|
|
220
|
+
const isHeader = row.some(c => known.includes(c.toLowerCase().trim()));
|
|
221
|
+
if (isHeader) { headers = row.map(h => h.trim()); continue; }
|
|
222
|
+
headers = ['timeStamp','elapsed','label','responseCode','responseMessage',
|
|
223
|
+
'threadName','dataType','success','failureMessage','bytes',
|
|
224
|
+
'sentBytes','grpThreads','allThreads','URL','Latency','IdleTime','Connect'];
|
|
225
|
+
}
|
|
226
|
+
const obj = Object.fromEntries(headers.map((h, i) => [h, (row[i] || '').trim()]));
|
|
227
|
+
const latency = parseFloat(obj.Latency || obj.latency || obj.elapsed || 0);
|
|
228
|
+
const elapsed = parseFloat(obj.elapsed || 0);
|
|
229
|
+
const success = (obj.success || 'true').toLowerCase() === 'true';
|
|
230
|
+
samples.push({ latency_ms: latency || elapsed, elapsed_ms: elapsed, success });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (!samples.length) throw new Error('No samples found.');
|
|
234
|
+
|
|
235
|
+
const latencies = samples.map(s => s.latency_ms).sort((a, b) => a - b);
|
|
236
|
+
const count = latencies.length;
|
|
237
|
+
const errors = samples.filter(s => !s.success).length;
|
|
238
|
+
const avg = latencies.reduce((a, b) => a + b, 0) / count;
|
|
239
|
+
const p95idx = Math.ceil(0.95 * count) - 1;
|
|
240
|
+
const p99idx = Math.ceil(0.99 * count) - 1;
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
sample_count: count,
|
|
244
|
+
error_percentage: parseFloat(((errors / count) * 100).toFixed(2)),
|
|
245
|
+
avg_latency_ms: parseFloat(avg.toFixed(2)),
|
|
246
|
+
p95_latency_ms: latencies[Math.max(0, p95idx)],
|
|
247
|
+
p99_latency_ms: latencies[Math.max(0, p99idx)],
|
|
248
|
+
min_latency_ms: latencies[0],
|
|
249
|
+
max_latency_ms: latencies[count - 1],
|
|
250
|
+
throughput_rps: null,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── Pretty-print metrics table ────────────────────────────────────────────────
|
|
255
|
+
function printMetrics(m, slaMs, slaErrPct) {
|
|
256
|
+
if (!m) return;
|
|
257
|
+
|
|
258
|
+
const slaFlag = (val, threshold) => {
|
|
259
|
+
if (threshold == null) return '';
|
|
260
|
+
return val > threshold ? ` ${RED}↑ exceeds ${threshold}${RESET}` : ` ${GREEN}✔${RESET}`;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
log(` ${DIM}${'─'.repeat(52)}${RESET}`);
|
|
264
|
+
log(` ${bold('Metric')} ${bold('Value')}`);
|
|
265
|
+
log(` ${DIM}${'─'.repeat(52)}${RESET}`);
|
|
266
|
+
log(` Samples ${m.sample_count}`);
|
|
267
|
+
log(` Avg Latency ${m.avg_latency_ms}ms${slaFlag(m.avg_latency_ms, slaMs)}`);
|
|
268
|
+
log(` P95 Latency ${m.p95_latency_ms}ms${slaFlag(m.p95_latency_ms, slaMs)}`);
|
|
269
|
+
log(` P99 Latency ${m.p99_latency_ms}ms`);
|
|
270
|
+
log(` Min / Max ${m.min_latency_ms}ms / ${m.max_latency_ms}ms`);
|
|
271
|
+
if (m.throughput_rps != null)
|
|
272
|
+
log(` Throughput ${m.throughput_rps} req/s`);
|
|
273
|
+
log(` Error Rate ${m.error_percentage}%${slaFlag(m.error_percentage, slaErrPct)}`);
|
|
274
|
+
log(` ${DIM}${'─'.repeat(52)}${RESET}`);
|
|
275
|
+
}
|