@cloudbeat/atlas-cli 0.1.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 +247 -0
- package/dist/api.js +159 -0
- package/dist/cli.js +83 -0
- package/dist/commands/auth.js +89 -0
- package/dist/commands/files.js +149 -0
- package/dist/commands/projects.js +37 -0
- package/dist/commands/record.js +105 -0
- package/dist/commands/results.js +127 -0
- package/dist/commands/run.js +173 -0
- package/dist/config.js +54 -0
- package/dist/gates.js +67 -0
- package/dist/junit.js +63 -0
- package/dist/ui.js +70 -0
- package/package.json +43 -0
package/dist/gates.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.GATE_METRICS = void 0;
|
|
4
|
+
exports.parseGates = parseGates;
|
|
5
|
+
exports.evaluateGates = evaluateGates;
|
|
6
|
+
exports.formatGate = formatGate;
|
|
7
|
+
const ui_1 = require("./ui");
|
|
8
|
+
/**
|
|
9
|
+
* Pass/fail thresholds, so a load test can block a pipeline.
|
|
10
|
+
*
|
|
11
|
+
* v1 evaluates the overall (`_all`) row only. Per-label thresholds are a
|
|
12
|
+
* deliberate later addition — the syntax leaves room for a `label:` qualifier.
|
|
13
|
+
*/
|
|
14
|
+
exports.GATE_METRICS = {
|
|
15
|
+
error_rate: { field: 'errorPct', unit: '%', label: 'error rate' },
|
|
16
|
+
avg: { field: 'avgMs', unit: 'ms', label: 'avg response time' },
|
|
17
|
+
p90: { field: 'p90Ms', unit: 'ms', label: 'p90' },
|
|
18
|
+
p95: { field: 'p95Ms', unit: 'ms', label: 'p95' },
|
|
19
|
+
p99: { field: 'p99Ms', unit: 'ms', label: 'p99' },
|
|
20
|
+
max: { field: 'maxMs', unit: 'ms', label: 'max response time' },
|
|
21
|
+
throughput: { field: 'throughput', unit: '/s', label: 'throughput' },
|
|
22
|
+
requests: { field: 'requests', unit: '', label: 'requests' },
|
|
23
|
+
};
|
|
24
|
+
const GATE_RE = /^([a-z_0-9]+)\s*(<=|>=|<|>)\s*(-?\d+(?:\.\d+)?)$/i;
|
|
25
|
+
/** `--gate "error_rate<1,p95<2000"` (repeatable, comma-separated). */
|
|
26
|
+
function parseGates(specs) {
|
|
27
|
+
const gates = [];
|
|
28
|
+
for (const spec of specs.flatMap(s => s.split(','))) {
|
|
29
|
+
const raw = spec.trim();
|
|
30
|
+
if (!raw)
|
|
31
|
+
continue;
|
|
32
|
+
const m = GATE_RE.exec(raw);
|
|
33
|
+
if (!m) {
|
|
34
|
+
throw new ui_1.CliError(`Invalid gate "${raw}". Expected <metric><op><number>, e.g. error_rate<1 or p95<=2000`, ui_1.EXIT.USAGE);
|
|
35
|
+
}
|
|
36
|
+
const metric = m[1].toLowerCase();
|
|
37
|
+
if (!(metric in exports.GATE_METRICS)) {
|
|
38
|
+
throw new ui_1.CliError(`Unknown gate metric "${m[1]}". Known: ${Object.keys(exports.GATE_METRICS).join(', ')}`, ui_1.EXIT.USAGE);
|
|
39
|
+
}
|
|
40
|
+
gates.push({ metric, operator: m[2], threshold: parseFloat(m[3]), source: raw });
|
|
41
|
+
}
|
|
42
|
+
return gates;
|
|
43
|
+
}
|
|
44
|
+
function compare(actual, operator, threshold) {
|
|
45
|
+
switch (operator) {
|
|
46
|
+
case '<': return actual < threshold;
|
|
47
|
+
case '<=': return actual <= threshold;
|
|
48
|
+
case '>': return actual > threshold;
|
|
49
|
+
case '>=': return actual >= threshold;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Evaluate gates against the run's overall row. */
|
|
53
|
+
function evaluateGates(gates, rows) {
|
|
54
|
+
const overall = rows.find(r => r.label === '_all');
|
|
55
|
+
if (!overall) {
|
|
56
|
+
throw new ui_1.CliError('No overall (_all) metrics for this run — nothing was reported to Elasticsearch, so gates cannot be evaluated.', ui_1.EXIT.ERROR);
|
|
57
|
+
}
|
|
58
|
+
return gates.map(g => {
|
|
59
|
+
const actual = overall[exports.GATE_METRICS[g.metric].field];
|
|
60
|
+
return { ...g, actual, passed: compare(actual, g.operator, g.threshold) };
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
function formatGate(r) {
|
|
64
|
+
const { unit, label } = exports.GATE_METRICS[r.metric];
|
|
65
|
+
return `${label} ${r.actual}${unit} ${r.operator} ${r.threshold}${unit}`;
|
|
66
|
+
}
|
|
67
|
+
//# sourceMappingURL=gates.js.map
|
package/dist/junit.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.buildJUnit = buildJUnit;
|
|
7
|
+
exports.writeJUnit = writeJUnit;
|
|
8
|
+
const fs_1 = __importDefault(require("fs"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
const gates_1 = require("./gates");
|
|
11
|
+
function escapeXml(value) {
|
|
12
|
+
return value
|
|
13
|
+
.replace(/&/g, '&')
|
|
14
|
+
.replace(/</g, '<')
|
|
15
|
+
.replace(/>/g, '>')
|
|
16
|
+
.replace(/"/g, '"')
|
|
17
|
+
.replace(/'/g, ''');
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* One <testcase> per gate, so a CI server renders each threshold as its own
|
|
21
|
+
* pass/fail rather than a single opaque "load test failed".
|
|
22
|
+
*/
|
|
23
|
+
function buildJUnit(params) {
|
|
24
|
+
const { testId, project, durationSec, gates, overall } = params;
|
|
25
|
+
const failures = gates.filter(g => !g.passed).length;
|
|
26
|
+
const suiteName = `atlas.${project}`;
|
|
27
|
+
const cases = gates.map(g => {
|
|
28
|
+
const name = `${g.metric} ${g.operator} ${g.threshold}`;
|
|
29
|
+
if (g.passed) {
|
|
30
|
+
return ` <testcase classname="${escapeXml(suiteName)}" name="${escapeXml(name)}"/>`;
|
|
31
|
+
}
|
|
32
|
+
const message = `Gate breached: ${(0, gates_1.formatGate)(g)} (actual ${g.actual}${gates_1.GATE_METRICS[g.metric].unit})`;
|
|
33
|
+
return (` <testcase classname="${escapeXml(suiteName)}" name="${escapeXml(name)}">\n` +
|
|
34
|
+
` <failure message="${escapeXml(message)}" type="GateFailure">${escapeXml(message)}</failure>\n` +
|
|
35
|
+
` </testcase>`);
|
|
36
|
+
});
|
|
37
|
+
// Overall numbers ride along as properties so they are visible in CI even
|
|
38
|
+
// when every gate passed (or none were set).
|
|
39
|
+
const props = overall
|
|
40
|
+
? [
|
|
41
|
+
` <property name="requests" value="${overall.requests}"/>`,
|
|
42
|
+
` <property name="errorPct" value="${overall.errorPct}"/>`,
|
|
43
|
+
` <property name="avgMs" value="${overall.avgMs}"/>`,
|
|
44
|
+
` <property name="p95Ms" value="${overall.p95Ms}"/>`,
|
|
45
|
+
` <property name="p99Ms" value="${overall.p99Ms}"/>`,
|
|
46
|
+
` <property name="throughput" value="${overall.throughput}"/>`,
|
|
47
|
+
].join('\n')
|
|
48
|
+
: '';
|
|
49
|
+
return (`<?xml version="1.0" encoding="UTF-8"?>\n` +
|
|
50
|
+
`<testsuites>\n` +
|
|
51
|
+
` <testsuite name="${escapeXml(suiteName)}" tests="${gates.length}" failures="${failures}" ` +
|
|
52
|
+
`time="${durationSec}" timestamp="${new Date().toISOString()}" hostname="${escapeXml(testId)}">\n` +
|
|
53
|
+
(props ? ` <properties>\n${props}\n </properties>\n` : '') +
|
|
54
|
+
(cases.length ? cases.join('\n') + '\n' : '') +
|
|
55
|
+
` </testsuite>\n` +
|
|
56
|
+
`</testsuites>\n`);
|
|
57
|
+
}
|
|
58
|
+
function writeJUnit(filePath, xml) {
|
|
59
|
+
const dir = path_1.default.dirname(path_1.default.resolve(filePath));
|
|
60
|
+
fs_1.default.mkdirSync(dir, { recursive: true });
|
|
61
|
+
fs_1.default.writeFileSync(filePath, xml, 'utf-8');
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=junit.js.map
|
package/dist/ui.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.log = exports.CliError = exports.EXIT = void 0;
|
|
7
|
+
exports.json = json;
|
|
8
|
+
exports.formatDuration = formatDuration;
|
|
9
|
+
exports.parseDuration = parseDuration;
|
|
10
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
11
|
+
/**
|
|
12
|
+
* Exit codes are part of the CLI's contract with CI — a pipeline needs to tell
|
|
13
|
+
* "the test ran and breached a threshold" from "the CLI could not run at all".
|
|
14
|
+
*/
|
|
15
|
+
exports.EXIT = {
|
|
16
|
+
OK: 0,
|
|
17
|
+
/** The run completed but a --gate threshold was breached. */
|
|
18
|
+
GATE_FAILED: 1,
|
|
19
|
+
/** Bad flags, missing project, not logged in. */
|
|
20
|
+
USAGE: 2,
|
|
21
|
+
/** The run itself failed, timed out, or the API was unreachable. */
|
|
22
|
+
ERROR: 3,
|
|
23
|
+
};
|
|
24
|
+
/** Thrown for expected failures — reported without a stack trace. */
|
|
25
|
+
class CliError extends Error {
|
|
26
|
+
code;
|
|
27
|
+
constructor(message, code = exports.EXIT.ERROR) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.code = code;
|
|
30
|
+
this.name = 'CliError';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
exports.CliError = CliError;
|
|
34
|
+
exports.log = {
|
|
35
|
+
info: (msg) => console.log(msg),
|
|
36
|
+
step: (msg) => console.log(chalk_1.default.dim('· ') + msg),
|
|
37
|
+
ok: (msg) => console.log(chalk_1.default.green('✓ ') + msg),
|
|
38
|
+
warn: (msg) => console.warn(chalk_1.default.yellow('! ') + msg),
|
|
39
|
+
error: (msg) => console.error(chalk_1.default.red('✗ ') + msg),
|
|
40
|
+
dim: (msg) => console.log(chalk_1.default.dim(msg)),
|
|
41
|
+
};
|
|
42
|
+
function json(value) {
|
|
43
|
+
console.log(JSON.stringify(value, null, 2));
|
|
44
|
+
}
|
|
45
|
+
function formatDuration(ms) {
|
|
46
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
47
|
+
const h = Math.floor(s / 3600);
|
|
48
|
+
const m = Math.floor((s % 3600) / 60);
|
|
49
|
+
const sec = s % 60;
|
|
50
|
+
if (h > 0)
|
|
51
|
+
return `${h}h ${m}m ${sec}s`;
|
|
52
|
+
if (m > 0)
|
|
53
|
+
return `${m}m ${sec}s`;
|
|
54
|
+
return `${sec}s`;
|
|
55
|
+
}
|
|
56
|
+
/** "30m" / "90s" / "2h" / plain seconds → milliseconds. */
|
|
57
|
+
function parseDuration(value) {
|
|
58
|
+
const m = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h)?$/i.exec(value.trim());
|
|
59
|
+
if (!m)
|
|
60
|
+
throw new CliError(`Invalid duration: ${value} (use e.g. 30s, 15m, 2h)`, exports.EXIT.USAGE);
|
|
61
|
+
const n = parseFloat(m[1]);
|
|
62
|
+
switch ((m[2] ?? 's').toLowerCase()) {
|
|
63
|
+
case 'ms': return n;
|
|
64
|
+
case 's': return n * 1000;
|
|
65
|
+
case 'm': return n * 60_000;
|
|
66
|
+
case 'h': return n * 3_600_000;
|
|
67
|
+
default: return n * 1000;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=ui.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cloudbeat/atlas-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Atlas CLI — run JMeter load tests from CI and record scenarios",
|
|
5
|
+
"main": "dist/cli.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"atlas": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md",
|
|
12
|
+
"!dist/**/*.map"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/cloudbeat-io/cb-atlas.git",
|
|
23
|
+
"directory": "cli"
|
|
24
|
+
},
|
|
25
|
+
"license": "UNLICENSED",
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsc",
|
|
28
|
+
"dev": "ts-node src/cli.ts",
|
|
29
|
+
"start": "node dist/cli.js",
|
|
30
|
+
"watch": "tsc --watch",
|
|
31
|
+
"prepublishOnly": "npm run build"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"chalk": "^4.1.2",
|
|
35
|
+
"cli-table3": "^0.6.5",
|
|
36
|
+
"commander": "^12.1.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "^20.14.0",
|
|
40
|
+
"ts-node": "^10.9.2",
|
|
41
|
+
"typescript": "^5.5.0"
|
|
42
|
+
}
|
|
43
|
+
}
|