@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
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.registerFiles = registerFiles;
|
|
40
|
+
const fs_1 = __importDefault(require("fs"));
|
|
41
|
+
const path_1 = __importDefault(require("path"));
|
|
42
|
+
const cli_table3_1 = __importDefault(require("cli-table3"));
|
|
43
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
44
|
+
const api_1 = require("../api");
|
|
45
|
+
const config_1 = require("../config");
|
|
46
|
+
const ui_1 = require("../ui");
|
|
47
|
+
/** Project comes from --project, ATLAS_PROJECT, or the saved default. */
|
|
48
|
+
function projectName(flag) {
|
|
49
|
+
const name = (0, config_1.loadConfig)({ project: flag }).project;
|
|
50
|
+
if (!name) {
|
|
51
|
+
throw new ui_1.CliError('No project. Pass --project <name>, set ATLAS_PROJECT, or run `atlas config set project <name>`.', ui_1.EXIT.USAGE);
|
|
52
|
+
}
|
|
53
|
+
return name;
|
|
54
|
+
}
|
|
55
|
+
function humanSize(bytes) {
|
|
56
|
+
if (bytes < 1024)
|
|
57
|
+
return `${bytes} B`;
|
|
58
|
+
if (bytes < 1024 * 1024)
|
|
59
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
60
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
61
|
+
}
|
|
62
|
+
function registerFiles(program) {
|
|
63
|
+
const files = program.command('files').description('Manage a project\'s test files');
|
|
64
|
+
files
|
|
65
|
+
.command('list')
|
|
66
|
+
.description('List files stored in the project')
|
|
67
|
+
.option('-p, --project <name>', 'project name')
|
|
68
|
+
.option('--json', 'machine-readable output')
|
|
69
|
+
.action(async (opts) => {
|
|
70
|
+
const api = api_1.AtlasApi.fromConfig();
|
|
71
|
+
const project = await api.projectByName(projectName(opts.project));
|
|
72
|
+
const stored = await api.files(project.id);
|
|
73
|
+
if (opts.json) {
|
|
74
|
+
console.log(JSON.stringify(stored, null, 2));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (stored.length === 0) {
|
|
78
|
+
ui_1.log.warn(`No files in project "${project.name}".`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const table = new cli_table3_1.default({
|
|
82
|
+
head: [chalk_1.default.dim('FILENAME'), chalk_1.default.dim('TYPE'), chalk_1.default.dim('SIZE'), chalk_1.default.dim('UPLOADED')],
|
|
83
|
+
style: { head: [], border: [] },
|
|
84
|
+
});
|
|
85
|
+
for (const f of stored) {
|
|
86
|
+
table.push([
|
|
87
|
+
f.filename,
|
|
88
|
+
f.file_type,
|
|
89
|
+
humanSize(f.size_bytes),
|
|
90
|
+
new Date(f.uploaded_at).toLocaleString(),
|
|
91
|
+
]);
|
|
92
|
+
}
|
|
93
|
+
console.log(table.toString());
|
|
94
|
+
});
|
|
95
|
+
files
|
|
96
|
+
.command('upload <files...>')
|
|
97
|
+
.description('Upload files into the project (replaces same-named files)')
|
|
98
|
+
.option('-p, --project <name>', 'project name')
|
|
99
|
+
.action(async (paths, opts) => {
|
|
100
|
+
const api = api_1.AtlasApi.fromConfig();
|
|
101
|
+
const project = await api.projectByName(projectName(opts.project));
|
|
102
|
+
const missing = paths.filter(p => !fs_1.default.existsSync(p));
|
|
103
|
+
if (missing.length)
|
|
104
|
+
throw new ui_1.CliError(`File(s) not found: ${missing.join(', ')}`, ui_1.EXIT.USAGE);
|
|
105
|
+
await api.uploadFiles(project.id, paths);
|
|
106
|
+
for (const p of paths) {
|
|
107
|
+
ui_1.log.ok(`${path_1.default.basename(p)} → ${project.name} (${humanSize(fs_1.default.statSync(p).size)})`);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
files
|
|
111
|
+
.command('download <filename>')
|
|
112
|
+
.description('Download one file from the project')
|
|
113
|
+
.option('-p, --project <name>', 'project name')
|
|
114
|
+
.option('-o, --out <path>', 'where to write it (default: the filename, in cwd)')
|
|
115
|
+
.action(async (filename, opts) => {
|
|
116
|
+
const api = api_1.AtlasApi.fromConfig();
|
|
117
|
+
const project = await api.projectByName(projectName(opts.project));
|
|
118
|
+
const buf = await api.downloadFile(project.id, filename);
|
|
119
|
+
const dest = opts.out ?? filename;
|
|
120
|
+
fs_1.default.writeFileSync(dest, buf);
|
|
121
|
+
ui_1.log.ok(`${filename} → ${dest} (${humanSize(buf.length)})`);
|
|
122
|
+
});
|
|
123
|
+
files
|
|
124
|
+
.command('rm <filename>')
|
|
125
|
+
.description('Delete a file from the project')
|
|
126
|
+
.option('-p, --project <name>', 'project name')
|
|
127
|
+
.option('-y, --yes', 'skip the confirmation prompt')
|
|
128
|
+
.action(async (filename, opts) => {
|
|
129
|
+
const api = api_1.AtlasApi.fromConfig();
|
|
130
|
+
const project = await api.projectByName(projectName(opts.project));
|
|
131
|
+
// Deleting stored files is destructive and not undoable from the CLI, so
|
|
132
|
+
// it is opt-in when there is no TTY to ask on (i.e. in CI).
|
|
133
|
+
if (!opts.yes) {
|
|
134
|
+
if (!process.stdin.isTTY) {
|
|
135
|
+
throw new ui_1.CliError('Refusing to delete without --yes when not interactive.', ui_1.EXIT.USAGE);
|
|
136
|
+
}
|
|
137
|
+
const readline = await Promise.resolve().then(() => __importStar(require('readline')));
|
|
138
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
139
|
+
const answer = await new Promise(r => rl.question(`Delete "${filename}" from ${project.name}? [y/N] `, a => { rl.close(); r(a.trim()); }));
|
|
140
|
+
if (answer.toLowerCase() !== 'y') {
|
|
141
|
+
ui_1.log.info('Cancelled.');
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
await api.deleteFile(project.id, filename);
|
|
146
|
+
ui_1.log.ok(`Deleted ${filename} from ${project.name}`);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
//# sourceMappingURL=files.js.map
|
|
@@ -0,0 +1,37 @@
|
|
|
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.registerProjects = registerProjects;
|
|
7
|
+
const cli_table3_1 = __importDefault(require("cli-table3"));
|
|
8
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
9
|
+
const api_1 = require("../api");
|
|
10
|
+
const ui_1 = require("../ui");
|
|
11
|
+
function registerProjects(program) {
|
|
12
|
+
program
|
|
13
|
+
.command('projects')
|
|
14
|
+
.description('List projects visible to this credential')
|
|
15
|
+
.option('--json', 'machine-readable output')
|
|
16
|
+
.action(async (opts) => {
|
|
17
|
+
const api = api_1.AtlasApi.fromConfig();
|
|
18
|
+
const projects = await api.projects();
|
|
19
|
+
if (opts.json) {
|
|
20
|
+
console.log(JSON.stringify(projects, null, 2));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (projects.length === 0) {
|
|
24
|
+
ui_1.log.warn('No projects visible to this credential.');
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const table = new cli_table3_1.default({
|
|
28
|
+
head: [chalk_1.default.dim('NAME'), chalk_1.default.dim('ID'), chalk_1.default.dim('DEFAULT JMX')],
|
|
29
|
+
style: { head: [], border: [] },
|
|
30
|
+
});
|
|
31
|
+
for (const p of projects) {
|
|
32
|
+
table.push([p.name, String(p.id), p.jmx_filename ?? chalk_1.default.dim('—')]);
|
|
33
|
+
}
|
|
34
|
+
console.log(table.toString());
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=projects.js.map
|
|
@@ -0,0 +1,105 @@
|
|
|
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.registerRecord = registerRecord;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const child_process_1 = require("child_process");
|
|
10
|
+
const module_1 = require("module");
|
|
11
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
12
|
+
const api_1 = require("../api");
|
|
13
|
+
const config_1 = require("../config");
|
|
14
|
+
const ui_1 = require("../ui");
|
|
15
|
+
const RECORDER_PKG = '@cloudbeat/atlas-recorder';
|
|
16
|
+
/**
|
|
17
|
+
* The recorder is launched, never bundled.
|
|
18
|
+
*
|
|
19
|
+
* It depends on Playwright and installs a Chromium build on postinstall — a few
|
|
20
|
+
* hundred MB. This CLI's main audience is CI, where a browser is never opened,
|
|
21
|
+
* so making it a hard dependency would tax every pipeline install for a feature
|
|
22
|
+
* they don't use. Resolve it at runtime instead and tell the user how to get it.
|
|
23
|
+
*/
|
|
24
|
+
function resolveRecorderBin() {
|
|
25
|
+
const require = (0, module_1.createRequire)(__filename);
|
|
26
|
+
for (const spec of [`${RECORDER_PKG}/package.json`, RECORDER_PKG]) {
|
|
27
|
+
try {
|
|
28
|
+
const pkgPath = spec.endsWith('package.json')
|
|
29
|
+
? require.resolve(spec)
|
|
30
|
+
: path_1.default.join(path_1.default.dirname(require.resolve(spec)), '..', 'package.json');
|
|
31
|
+
const pkg = JSON.parse(fs_1.default.readFileSync(pkgPath, 'utf-8'));
|
|
32
|
+
const rel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.['atlas-rec'];
|
|
33
|
+
if (rel) {
|
|
34
|
+
const bin = path_1.default.resolve(path_1.default.dirname(pkgPath), rel);
|
|
35
|
+
if (fs_1.default.existsSync(bin))
|
|
36
|
+
return bin;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
/* not installed here — try the next resolution strategy */
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
function runRecorder(bin, args) {
|
|
46
|
+
return new Promise(resolve => {
|
|
47
|
+
const child = (0, child_process_1.spawn)(process.execPath, [bin, ...args], { stdio: 'inherit' });
|
|
48
|
+
child.on('exit', code => resolve(code ?? 0));
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function registerRecord(program) {
|
|
52
|
+
program
|
|
53
|
+
.command('record [url]')
|
|
54
|
+
.description('Record a browser session into a JMeter test plan')
|
|
55
|
+
.option('-o, --out <file>', 'output .jmx (merged into if it already exists)', 'scenario.jmx')
|
|
56
|
+
.option('-n, --name <name>', 'scenario / thread-group name')
|
|
57
|
+
.option('--cookies <file>', 'preload cookies to skip SSO')
|
|
58
|
+
.option('--profile <dir>', 'browser profile directory for a persistent session')
|
|
59
|
+
.option('--upload', 'upload the recorded .jmx into the project when done')
|
|
60
|
+
.option('-p, --project <name>', 'project to upload into (with --upload)')
|
|
61
|
+
.action(async (url, opts) => {
|
|
62
|
+
// Fail on an unusable --upload before spending a recording session on it.
|
|
63
|
+
let project = null;
|
|
64
|
+
if (opts.upload) {
|
|
65
|
+
const name = (0, config_1.loadConfig)({ project: opts.project }).project;
|
|
66
|
+
if (!name) {
|
|
67
|
+
throw new ui_1.CliError('--upload needs a project: pass --project <name> or set ATLAS_PROJECT.', ui_1.EXIT.USAGE);
|
|
68
|
+
}
|
|
69
|
+
project = await api_1.AtlasApi.fromConfig().projectByName(name);
|
|
70
|
+
}
|
|
71
|
+
const bin = resolveRecorderBin();
|
|
72
|
+
if (!bin) {
|
|
73
|
+
ui_1.log.error(`The recorder is not installed.`);
|
|
74
|
+
ui_1.log.info('');
|
|
75
|
+
ui_1.log.info(` npm install -g ${RECORDER_PKG}`);
|
|
76
|
+
ui_1.log.info('');
|
|
77
|
+
ui_1.log.dim('It is a separate package because it downloads a Chromium build,');
|
|
78
|
+
ui_1.log.dim('which CI installs of this CLI do not need.');
|
|
79
|
+
throw new ui_1.CliError('Recorder unavailable', ui_1.EXIT.USAGE);
|
|
80
|
+
}
|
|
81
|
+
const args = ['record'];
|
|
82
|
+
if (url)
|
|
83
|
+
args.push(url);
|
|
84
|
+
args.push('-o', opts.out);
|
|
85
|
+
if (opts.name)
|
|
86
|
+
args.push('-n', opts.name);
|
|
87
|
+
if (opts.cookies)
|
|
88
|
+
args.push('--cookies', opts.cookies);
|
|
89
|
+
if (opts.profile)
|
|
90
|
+
args.push('--profile', opts.profile);
|
|
91
|
+
const code = await runRecorder(bin, args);
|
|
92
|
+
if (code !== 0)
|
|
93
|
+
throw new ui_1.CliError(`Recorder exited with code ${code}`, ui_1.EXIT.ERROR);
|
|
94
|
+
if (!fs_1.default.existsSync(opts.out)) {
|
|
95
|
+
throw new ui_1.CliError(`Recorder finished but ${opts.out} was not written.`, ui_1.EXIT.ERROR);
|
|
96
|
+
}
|
|
97
|
+
ui_1.log.ok(`Recorded ${chalk_1.default.bold(opts.out)}`);
|
|
98
|
+
if (project) {
|
|
99
|
+
await api_1.AtlasApi.fromConfig().uploadFiles(project.id, [opts.out]);
|
|
100
|
+
ui_1.log.ok(`Uploaded ${path_1.default.basename(opts.out)} → ${project.name}`);
|
|
101
|
+
ui_1.log.dim(`run it with: atlas run --project ${project.name} --jmx ${path_1.default.basename(opts.out)}`);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
//# sourceMappingURL=record.js.map
|
|
@@ -0,0 +1,127 @@
|
|
|
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.printSummary = printSummary;
|
|
7
|
+
exports.registerResults = registerResults;
|
|
8
|
+
const cli_table3_1 = __importDefault(require("cli-table3"));
|
|
9
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
10
|
+
const api_1 = require("../api");
|
|
11
|
+
const config_1 = require("../config");
|
|
12
|
+
const ui_1 = require("../ui");
|
|
13
|
+
const gates_1 = require("../gates");
|
|
14
|
+
const junit_1 = require("../junit");
|
|
15
|
+
function errorColour(pct) {
|
|
16
|
+
if (pct === 0)
|
|
17
|
+
return chalk_1.default.green;
|
|
18
|
+
if (pct < 5)
|
|
19
|
+
return chalk_1.default.yellow;
|
|
20
|
+
return chalk_1.default.red;
|
|
21
|
+
}
|
|
22
|
+
/** Aggregation report, TOTAL first — same shape the dashboard shows. */
|
|
23
|
+
function printSummary(rows) {
|
|
24
|
+
const table = new cli_table3_1.default({
|
|
25
|
+
head: ['LABEL', 'GROUP', '#', 'AVG', 'P90', 'P95', 'P99', 'MAX', 'REQ/S', 'ERR%'].map(h => chalk_1.default.dim(h)),
|
|
26
|
+
colAligns: ['left', 'left', 'right', 'right', 'right', 'right', 'right', 'right', 'right', 'right'],
|
|
27
|
+
style: { head: [], border: [] },
|
|
28
|
+
});
|
|
29
|
+
for (const r of rows) {
|
|
30
|
+
const isTotal = r.label === '_all';
|
|
31
|
+
const cell = (s) => (isTotal ? chalk_1.default.bold(s) : s);
|
|
32
|
+
table.push([
|
|
33
|
+
cell(isTotal ? 'TOTAL' : r.label),
|
|
34
|
+
cell(isTotal ? '' : r.threadGroup),
|
|
35
|
+
cell(String(r.requests)),
|
|
36
|
+
cell(String(r.avgMs)),
|
|
37
|
+
cell(String(r.p90Ms)),
|
|
38
|
+
cell(String(r.p95Ms)),
|
|
39
|
+
cell(String(r.p99Ms)),
|
|
40
|
+
cell(String(r.maxMs)),
|
|
41
|
+
cell(String(r.throughput)),
|
|
42
|
+
errorColour(r.errorPct)(cell(`${r.errorPct}%`)),
|
|
43
|
+
]);
|
|
44
|
+
}
|
|
45
|
+
console.log(table.toString());
|
|
46
|
+
}
|
|
47
|
+
function registerResults(program) {
|
|
48
|
+
program
|
|
49
|
+
.command('results <testId>')
|
|
50
|
+
.description('Show the aggregation report for a run')
|
|
51
|
+
.option('--json', 'machine-readable output')
|
|
52
|
+
.option('--junit <path>', 'write JUnit XML (with --gate, one case per gate)')
|
|
53
|
+
.option('--gate <expr...>', 'evaluate thresholds against these results')
|
|
54
|
+
.action(async (testId, opts) => {
|
|
55
|
+
const api = api_1.AtlasApi.fromConfig();
|
|
56
|
+
const { rows } = await api.summary(testId);
|
|
57
|
+
if (rows.length === 0) {
|
|
58
|
+
throw new ui_1.CliError(`No metrics found for "${testId}".`, ui_1.EXIT.ERROR);
|
|
59
|
+
}
|
|
60
|
+
const gates = (0, gates_1.parseGates)(opts.gate ?? []);
|
|
61
|
+
const results = gates.length ? (0, gates_1.evaluateGates)(gates, rows) : [];
|
|
62
|
+
const overall = rows.find(r => r.label === '_all');
|
|
63
|
+
if (opts.junit) {
|
|
64
|
+
const project = (0, config_1.loadConfig)().project ?? testId.split('-')[0];
|
|
65
|
+
(0, junit_1.writeJUnit)(opts.junit, (0, junit_1.buildJUnit)({ testId, project, durationSec: 0, gates: results, overall }));
|
|
66
|
+
}
|
|
67
|
+
if (opts.json) {
|
|
68
|
+
console.log(JSON.stringify({ testId, rows, gates: results }, null, 2));
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
printSummary(rows);
|
|
72
|
+
for (const r of results) {
|
|
73
|
+
if (r.passed)
|
|
74
|
+
ui_1.log.ok(chalk_1.default.dim('gate ') + (0, gates_1.formatGate)(r));
|
|
75
|
+
else
|
|
76
|
+
ui_1.log.error(chalk_1.default.bold('gate ') + (0, gates_1.formatGate)(r));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const failed = results.filter(r => !r.passed);
|
|
80
|
+
if (failed.length) {
|
|
81
|
+
throw new ui_1.CliError(`${failed.length} of ${results.length} gate(s) failed.`, ui_1.EXIT.GATE_FAILED);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
program
|
|
85
|
+
.command('status <testId>')
|
|
86
|
+
.description('Whether a run is still going, and its headline numbers')
|
|
87
|
+
.option('--json', 'machine-readable output')
|
|
88
|
+
.action(async (testId, opts) => {
|
|
89
|
+
const api = api_1.AtlasApi.fromConfig();
|
|
90
|
+
const [status, summary] = await Promise.all([
|
|
91
|
+
api.testStatus(testId),
|
|
92
|
+
api.summary(testId).catch(() => ({ rows: [] })),
|
|
93
|
+
]);
|
|
94
|
+
const overall = summary.rows.find(r => r.label === '_all');
|
|
95
|
+
if (opts.json) {
|
|
96
|
+
console.log(JSON.stringify({ testId, ...status, overall }, null, 2));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
ui_1.log.info(`${chalk_1.default.bold(testId)} ${status.running ? chalk_1.default.green('● running') : chalk_1.default.dim('✓ completed')}`);
|
|
100
|
+
if (overall) {
|
|
101
|
+
ui_1.log.info(`requests ${overall.requests} · avg ${overall.avgMs}ms · p95 ${overall.p95Ms}ms · ` +
|
|
102
|
+
`${errorColour(overall.errorPct)(`${overall.errorPct}% errors`)}`);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
ui_1.log.warn('No metrics reported yet.');
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
program
|
|
109
|
+
.command('stop <testId>')
|
|
110
|
+
.description('Stop a running test')
|
|
111
|
+
.action(async (testId) => {
|
|
112
|
+
const api = api_1.AtlasApi.fromConfig();
|
|
113
|
+
const res = await api.stop(testId);
|
|
114
|
+
if (res.unreachable?.length || res.stillRunning?.length) {
|
|
115
|
+
// The API only marks a server stopped once it confirms the process is
|
|
116
|
+
// gone; anything else must be surfaced, not rounded up to success.
|
|
117
|
+
ui_1.log.warn(`Stopped on ${res.ok} server(s), but could not confirm all of them.`);
|
|
118
|
+
if (res.unreachable?.length)
|
|
119
|
+
ui_1.log.warn(`unreachable: ${res.unreachable.join(', ')}`);
|
|
120
|
+
if (res.stillRunning?.length)
|
|
121
|
+
ui_1.log.warn(`still running: ${res.stillRunning.join(', ')}`);
|
|
122
|
+
throw new ui_1.CliError('Run not fully stopped — retry when those servers are reachable.', ui_1.EXIT.ERROR);
|
|
123
|
+
}
|
|
124
|
+
ui_1.log.ok(`Stopped ${testId} on ${res.ok} server(s).`);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
//# sourceMappingURL=results.js.map
|
|
@@ -0,0 +1,173 @@
|
|
|
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.registerRun = registerRun;
|
|
7
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
8
|
+
const api_1 = require("../api");
|
|
9
|
+
const config_1 = require("../config");
|
|
10
|
+
const ui_1 = require("../ui");
|
|
11
|
+
const gates_1 = require("../gates");
|
|
12
|
+
const junit_1 = require("../junit");
|
|
13
|
+
const results_1 = require("./results");
|
|
14
|
+
const TERMINAL = ['done', 'cancelled', 'error'];
|
|
15
|
+
/** Pick which load generators to target: explicit names, a count, or a cluster. */
|
|
16
|
+
async function resolveTarget(api, opts) {
|
|
17
|
+
if (opts.cluster) {
|
|
18
|
+
const clusters = await api.clusters();
|
|
19
|
+
const hit = clusters.find(c => c.name.toLowerCase() === opts.cluster.toLowerCase());
|
|
20
|
+
if (!hit) {
|
|
21
|
+
throw new ui_1.CliError(`No cluster named "${opts.cluster}". Available: ${clusters.map(c => c.name).join(', ') || '(none)'}`, ui_1.EXIT.USAGE);
|
|
22
|
+
}
|
|
23
|
+
const unitCount = parseInt(opts.units ?? '1', 10);
|
|
24
|
+
if (!Number.isFinite(unitCount) || unitCount < 1) {
|
|
25
|
+
throw new ui_1.CliError('--units must be a positive integer', ui_1.EXIT.USAGE);
|
|
26
|
+
}
|
|
27
|
+
return { poolId: hit.id, unitCount, describe: `${unitCount} unit(s) on cluster ${hit.name}` };
|
|
28
|
+
}
|
|
29
|
+
const fleet = await api.servers();
|
|
30
|
+
if (fleet.length === 0) {
|
|
31
|
+
throw new ui_1.CliError('No load generators available to this credential.', ui_1.EXIT.USAGE);
|
|
32
|
+
}
|
|
33
|
+
if (opts.server?.length) {
|
|
34
|
+
const wanted = opts.server.flatMap(s => s.split(','));
|
|
35
|
+
const ids = [];
|
|
36
|
+
for (const name of wanted.map(w => w.trim()).filter(Boolean)) {
|
|
37
|
+
const hit = fleet.find(s => s.name.toLowerCase() === name.toLowerCase());
|
|
38
|
+
if (!hit) {
|
|
39
|
+
throw new ui_1.CliError(`No server named "${name}". Available: ${fleet.map(s => s.name).join(', ')}`, ui_1.EXIT.USAGE);
|
|
40
|
+
}
|
|
41
|
+
ids.push(hit.id);
|
|
42
|
+
}
|
|
43
|
+
return { serverIds: ids, describe: `${ids.length} server(s)` };
|
|
44
|
+
}
|
|
45
|
+
const n = parseInt(opts.servers ?? '1', 10);
|
|
46
|
+
if (!Number.isFinite(n) || n < 1)
|
|
47
|
+
throw new ui_1.CliError('--servers must be a positive integer', ui_1.EXIT.USAGE);
|
|
48
|
+
if (n > fleet.length) {
|
|
49
|
+
throw new ui_1.CliError(`Asked for ${n} servers but only ${fleet.length} are available.`, ui_1.EXIT.USAGE);
|
|
50
|
+
}
|
|
51
|
+
// Off servers are fine: the scheduler powers on whatever it needs.
|
|
52
|
+
const chosen = fleet.slice(0, n);
|
|
53
|
+
return { serverIds: chosen.map(s => s.id), describe: chosen.map(s => s.name).join(', ') };
|
|
54
|
+
}
|
|
55
|
+
/** Poll the queue entry until it reaches a terminal state or the timeout hits. */
|
|
56
|
+
async function waitForRun(api, queueId, timeoutMs, intervalMs, quiet) {
|
|
57
|
+
const deadline = Date.now() + timeoutMs;
|
|
58
|
+
let lastState = null;
|
|
59
|
+
for (;;) {
|
|
60
|
+
const entry = await api.queueEntry(queueId);
|
|
61
|
+
if (entry.state !== lastState) {
|
|
62
|
+
if (!quiet)
|
|
63
|
+
ui_1.log.step(`${entry.state}${entry.testId ? chalk_1.default.dim(` ${entry.testId}`) : ''}`);
|
|
64
|
+
lastState = entry.state;
|
|
65
|
+
}
|
|
66
|
+
if (TERMINAL.includes(entry.state))
|
|
67
|
+
return entry;
|
|
68
|
+
if (Date.now() > deadline) {
|
|
69
|
+
throw new ui_1.CliError(`Timed out after ${(0, ui_1.formatDuration)(timeoutMs)} waiting for the run (last state: ${entry.state}). ` +
|
|
70
|
+
`The test may still be running — check \`atlas status ${entry.testId ?? ''}\`.`, ui_1.EXIT.ERROR);
|
|
71
|
+
}
|
|
72
|
+
await new Promise(r => setTimeout(r, intervalMs));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function registerRun(program) {
|
|
76
|
+
program
|
|
77
|
+
.command('run')
|
|
78
|
+
.description('Start a load test (optionally waiting for it and gating on results)')
|
|
79
|
+
.option('-p, --project <name>', 'project name')
|
|
80
|
+
.option('--jmx <filename>', 'JMX file to run (default: the project\'s)')
|
|
81
|
+
.option('-s, --servers <n>', 'number of load generators to use', '1')
|
|
82
|
+
.option('--server <name...>', 'target specific generators by name')
|
|
83
|
+
.option('--cluster <name>', 'run on an elastic cluster instead of fixed servers')
|
|
84
|
+
.option('--units <n>', 'containers to allocate when using --cluster', '1')
|
|
85
|
+
.option('--args <jmeterArgs>', 'extra JMeter arguments')
|
|
86
|
+
.option('--no-split', 'send whole data files to every generator instead of splitting')
|
|
87
|
+
.option('-w, --wait', 'wait for the run to finish')
|
|
88
|
+
.option('--timeout <duration>', 'how long to wait with --wait', '2h')
|
|
89
|
+
.option('--interval <duration>', 'poll interval with --wait', '10s')
|
|
90
|
+
.option('--gate <expr...>', 'pass/fail thresholds, e.g. error_rate<1,p95<2000 (implies --wait)')
|
|
91
|
+
.option('--junit <path>', 'write JUnit XML for the gates (implies --wait)')
|
|
92
|
+
.option('--json', 'machine-readable output')
|
|
93
|
+
.action(async (opts) => {
|
|
94
|
+
const api = api_1.AtlasApi.fromConfig();
|
|
95
|
+
const name = (0, config_1.loadConfig)({ project: opts.project }).project;
|
|
96
|
+
if (!name) {
|
|
97
|
+
throw new ui_1.CliError('No project. Pass --project <name> or set ATLAS_PROJECT.', ui_1.EXIT.USAGE);
|
|
98
|
+
}
|
|
99
|
+
const gates = (0, gates_1.parseGates)(opts.gate ?? []);
|
|
100
|
+
// Gates and JUnit are meaningless without results, so they imply --wait
|
|
101
|
+
// rather than silently producing nothing.
|
|
102
|
+
const wait = opts.wait || gates.length > 0 || !!opts.junit;
|
|
103
|
+
const project = await api.projectByName(name);
|
|
104
|
+
const target = await resolveTarget(api, opts);
|
|
105
|
+
const submitted = await api.run(project.id, {
|
|
106
|
+
serverIds: target.serverIds,
|
|
107
|
+
poolId: target.poolId,
|
|
108
|
+
unitCount: target.unitCount,
|
|
109
|
+
jmx: opts.jmx,
|
|
110
|
+
jmeterArgs: opts.args,
|
|
111
|
+
splitCsvs: opts.split,
|
|
112
|
+
});
|
|
113
|
+
if (!opts.json) {
|
|
114
|
+
ui_1.log.ok(submitted.status === 'queued'
|
|
115
|
+
? `Queued (position ${submitted.position ?? '?'}) — servers are busy with another run.`
|
|
116
|
+
: `Started on ${target.describe}`);
|
|
117
|
+
if (submitted.testId)
|
|
118
|
+
ui_1.log.dim(`test id: ${submitted.testId}`);
|
|
119
|
+
}
|
|
120
|
+
if (!wait) {
|
|
121
|
+
if (opts.json)
|
|
122
|
+
console.log(JSON.stringify(submitted, null, 2));
|
|
123
|
+
else if (submitted.testId) {
|
|
124
|
+
ui_1.log.dim(`dashboard: ${api.baseUrl}/${project.name}/${submitted.testId}`);
|
|
125
|
+
}
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const startedAt = Date.now();
|
|
129
|
+
const entry = await waitForRun(api, submitted.queueId, (0, ui_1.parseDuration)(opts.timeout), (0, ui_1.parseDuration)(opts.interval), !!opts.json);
|
|
130
|
+
const durationSec = Math.round((Date.now() - startedAt) / 1000);
|
|
131
|
+
if (entry.state === 'error') {
|
|
132
|
+
throw new ui_1.CliError(`Run failed (test id: ${entry.testId ?? 'none'})`, ui_1.EXIT.ERROR);
|
|
133
|
+
}
|
|
134
|
+
if (entry.state === 'cancelled') {
|
|
135
|
+
throw new ui_1.CliError('Run was cancelled.', ui_1.EXIT.ERROR);
|
|
136
|
+
}
|
|
137
|
+
if (!entry.testId) {
|
|
138
|
+
throw new ui_1.CliError('Run finished without a test id — no results to report.', ui_1.EXIT.ERROR);
|
|
139
|
+
}
|
|
140
|
+
const { rows } = await api.summary(entry.testId);
|
|
141
|
+
const overall = rows.find(r => r.label === '_all');
|
|
142
|
+
let results = [];
|
|
143
|
+
if (gates.length)
|
|
144
|
+
results = (0, gates_1.evaluateGates)(gates, rows);
|
|
145
|
+
if (opts.junit) {
|
|
146
|
+
(0, junit_1.writeJUnit)(opts.junit, (0, junit_1.buildJUnit)({ testId: entry.testId, project: project.name, durationSec, gates: results, overall }));
|
|
147
|
+
if (!opts.json)
|
|
148
|
+
ui_1.log.dim(`JUnit written to ${opts.junit}`);
|
|
149
|
+
}
|
|
150
|
+
if (opts.json) {
|
|
151
|
+
console.log(JSON.stringify({
|
|
152
|
+
testId: entry.testId, state: entry.state, durationSec, overall, gates: results,
|
|
153
|
+
}, null, 2));
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
console.log();
|
|
157
|
+
(0, results_1.printSummary)(rows);
|
|
158
|
+
console.log();
|
|
159
|
+
for (const r of results) {
|
|
160
|
+
if (r.passed)
|
|
161
|
+
ui_1.log.ok(chalk_1.default.dim('gate ') + (0, gates_1.formatGate)(r));
|
|
162
|
+
else
|
|
163
|
+
ui_1.log.error(chalk_1.default.bold('gate ') + (0, gates_1.formatGate)(r));
|
|
164
|
+
}
|
|
165
|
+
ui_1.log.dim(`dashboard: ${api.baseUrl}/${project.name}/${entry.testId}`);
|
|
166
|
+
}
|
|
167
|
+
const failed = results.filter(r => !r.passed);
|
|
168
|
+
if (failed.length) {
|
|
169
|
+
throw new ui_1.CliError(`${failed.length} of ${results.length} gate(s) failed.`, ui_1.EXIT.GATE_FAILED);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
//# sourceMappingURL=run.js.map
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
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.DEFAULT_URL = void 0;
|
|
7
|
+
exports.loadConfig = loadConfig;
|
|
8
|
+
exports.saveConfig = saveConfig;
|
|
9
|
+
exports.clearCredentials = clearCredentials;
|
|
10
|
+
exports.configPath = configPath;
|
|
11
|
+
const fs_1 = __importDefault(require("fs"));
|
|
12
|
+
const os_1 = __importDefault(require("os"));
|
|
13
|
+
const path_1 = __importDefault(require("path"));
|
|
14
|
+
const CONFIG_DIR = path_1.default.join(os_1.default.homedir(), '.atlas');
|
|
15
|
+
const CONFIG_FILE = path_1.default.join(CONFIG_DIR, 'config.json');
|
|
16
|
+
exports.DEFAULT_URL = 'https://atlas.cloudbeat.io';
|
|
17
|
+
function readFile() {
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(fs_1.default.readFileSync(CONFIG_FILE, 'utf-8'));
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Effective config: flags beat environment, environment beats the config file.
|
|
27
|
+
* CI sets ATLAS_URL/ATLAS_TOKEN and never runs `atlas login`.
|
|
28
|
+
*/
|
|
29
|
+
function loadConfig(flags = {}) {
|
|
30
|
+
const file = readFile();
|
|
31
|
+
return {
|
|
32
|
+
url: flags.url || process.env.ATLAS_URL || file.url || exports.DEFAULT_URL,
|
|
33
|
+
token: flags.token || process.env.ATLAS_TOKEN || file.token,
|
|
34
|
+
project: flags.project || process.env.ATLAS_PROJECT || file.project,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/** Persist a partial update. The file holds a credential, so it is 0600. */
|
|
38
|
+
function saveConfig(update) {
|
|
39
|
+
fs_1.default.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
40
|
+
const merged = { ...readFile(), ...update };
|
|
41
|
+
fs_1.default.writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2) + '\n', { mode: 0o600 });
|
|
42
|
+
// Re-assert the mode: writeFileSync's mode applies only when creating.
|
|
43
|
+
fs_1.default.chmodSync(CONFIG_FILE, 0o600);
|
|
44
|
+
}
|
|
45
|
+
function clearCredentials() {
|
|
46
|
+
const cfg = readFile();
|
|
47
|
+
delete cfg.token;
|
|
48
|
+
fs_1.default.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
49
|
+
fs_1.default.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
|
|
50
|
+
}
|
|
51
|
+
function configPath() {
|
|
52
|
+
return CONFIG_FILE;
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=config.js.map
|