@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/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, '&amp;')
14
+ .replace(/</g, '&lt;')
15
+ .replace(/>/g, '&gt;')
16
+ .replace(/"/g, '&quot;')
17
+ .replace(/'/g, '&apos;');
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
+ }