@cloudbeat/atlas-cli 0.4.2 → 0.5.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 CHANGED
@@ -101,9 +101,9 @@ Without it, the command returns as soon as the test has been submitted, and you
101
101
  can check on it later:
102
102
 
103
103
  ```bash
104
- atlas status SHOP-20260818-194201
105
- atlas results SHOP-20260818-194201
106
- atlas stop SHOP-20260818-194201
104
+ atlas status SHOP-20260818-K3F9
105
+ atlas results SHOP-20260818-K3F9
106
+ atlas stop SHOP-20260818-K3F9
107
107
  ```
108
108
 
109
109
  `--jmx` is only needed when a project holds more than one test plan.
package/dist/api.js CHANGED
@@ -91,7 +91,7 @@ class AtlasApi {
91
91
  // 401/403 are almost always a credential problem the user must fix, not a
92
92
  // transient failure worth retrying — surface them as usage errors.
93
93
  const code = res.status === 401 || res.status === 403 ? ui_1.EXIT.USAGE : ui_1.EXIT.ERROR;
94
- throw new ui_1.CliError(`${method} ${routePath} → ${res.status}: ${message}`, code);
94
+ throw new ui_1.CliError(`${method} ${routePath} → ${res.status}: ${message}`, code, res.status);
95
95
  }
96
96
  return payload;
97
97
  }
@@ -122,12 +122,18 @@ class AtlasApi {
122
122
  /** Projects are addressed by name on the CLI; the API wants the id. */
123
123
  async projectByName(name) {
124
124
  const all = await this.projects();
125
- const hit = all.find(p => p.name.toLowerCase() === name.toLowerCase());
126
- if (!hit) {
125
+ const hits = all.filter(p => p.name.toLowerCase() === name.toLowerCase());
126
+ if (!hits.length) {
127
127
  const known = all.map(p => p.name).join(', ') || '(none visible to this token)';
128
128
  throw new ui_1.CliError(`No project named "${name}". Available: ${known}`, ui_1.EXIT.USAGE);
129
129
  }
130
- return hit;
130
+ // Names are unique within an account, not across them: a login that sees
131
+ // several accounts can see two. An API token belongs to one account.
132
+ if (hits.length > 1) {
133
+ throw new ui_1.CliError(`${hits.length} projects named "${name}" are visible to this login, in different accounts. ` +
134
+ 'Use an API token from the account you mean.', ui_1.EXIT.USAGE);
135
+ }
136
+ return hits[0];
131
137
  }
132
138
  async files(projectId) {
133
139
  return this.request('GET', `${this.api}/projects/${projectId}/files`);
@@ -12,6 +12,13 @@ const gates_1 = require("../gates");
12
12
  const junit_1 = require("../junit");
13
13
  const results_1 = require("./results");
14
14
  const TERMINAL = ['done', 'cancelled', 'error'];
15
+ /**
16
+ * Public dashboards live under /app/public since 2026-10. Older CLIs still print
17
+ * the root form (/<project>/<testId>); the dashboard redirects it.
18
+ */
19
+ function dashboardUrl(baseUrl, project, testId) {
20
+ return `${baseUrl}/app/public/${encodeURIComponent(project)}/${encodeURIComponent(testId)}`;
21
+ }
15
22
  /** Pick which load generators to target: explicit names, a count, or a cluster. */
16
23
  async function resolveTarget(api, opts) {
17
24
  if (opts.cluster) {
@@ -26,25 +33,30 @@ async function resolveTarget(api, opts) {
26
33
  }
27
34
  return { poolId: hit.id, unitCount, describe: `${unitCount} unit(s) on cluster ${hit.name}` };
28
35
  }
36
+ // A bare count: let Atlas pick N available machines (see submit()).
37
+ if (!opts.server?.length) {
38
+ const n = parseInt(opts.servers ?? '1', 10);
39
+ if (!Number.isFinite(n) || n < 1)
40
+ throw new ui_1.CliError('--servers must be a positive integer', ui_1.EXIT.USAGE);
41
+ return { unitCount: n, describe: `${n} machine${n === 1 ? '' : 's'}` };
42
+ }
29
43
  const fleet = await api.servers();
30
44
  if (fleet.length === 0) {
31
45
  throw new ui_1.CliError('No load generators available to this credential.', ui_1.EXIT.USAGE);
32
46
  }
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);
47
+ const wanted = opts.server.flatMap(s => s.split(','));
48
+ const ids = [];
49
+ for (const name of wanted.map(w => w.trim()).filter(Boolean)) {
50
+ const hit = fleet.find(s => s.name.toLowerCase() === name.toLowerCase());
51
+ if (!hit) {
52
+ throw new ui_1.CliError(`No server named "${name}". Available: ${fleet.map(s => s.name).join(', ')}`, ui_1.EXIT.USAGE);
42
53
  }
43
- return { serverIds: ids, describe: `${ids.length} server(s)` };
54
+ ids.push(hit.id);
44
55
  }
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);
56
+ return { serverIds: ids, describe: `${ids.length} server(s)` };
57
+ }
58
+ /** `--servers <n>` before Atlas could pick by count: the first N visible. */
59
+ function pickFirst(fleet, n) {
48
60
  if (n > fleet.length) {
49
61
  throw new ui_1.CliError(`Asked for ${n} servers but only ${fleet.length} are available.`, ui_1.EXIT.USAGE);
50
62
  }
@@ -101,15 +113,34 @@ function registerRun(program) {
101
113
  // rather than silently producing nothing.
102
114
  const wait = opts.wait || gates.length > 0 || !!opts.junit;
103
115
  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,
116
+ let target = await resolveTarget(api, opts);
117
+ const submit = (t) => api.run(project.id, {
118
+ serverIds: t.serverIds,
119
+ poolId: t.poolId,
120
+ unitCount: t.unitCount,
109
121
  jmx: opts.jmx,
110
122
  jmeterArgs: opts.args,
111
123
  splitCsvs: opts.split,
112
124
  });
125
+ let submitted;
126
+ try {
127
+ submitted = await submit(target);
128
+ }
129
+ catch (err) {
130
+ // A bare count that Atlas cannot place: an Atlas from before
131
+ // count-based runs ("serverIds array … required"), or an account whose
132
+ // machines span several pools ("Say which machines"). Fall back to
133
+ // choosing the first N visible servers, as this CLI always did.
134
+ const bareCount = target.unitCount != null && target.poolId == null && !target.serverIds;
135
+ if (!(bareCount && err instanceof ui_1.CliError && err.httpStatus === 400
136
+ && /serverIds array|Say which machines/.test(err.message)))
137
+ throw err;
138
+ const fleet = await api.servers();
139
+ if (fleet.length === 0)
140
+ throw new ui_1.CliError('No load generators available to this credential.', ui_1.EXIT.USAGE);
141
+ target = pickFirst(fleet, target.unitCount);
142
+ submitted = await submit(target);
143
+ }
113
144
  if (!opts.json) {
114
145
  ui_1.log.ok(submitted.status === 'queued'
115
146
  ? `Queued (position ${submitted.position ?? '?'}) — servers are busy with another run.`
@@ -121,7 +152,7 @@ function registerRun(program) {
121
152
  if (opts.json)
122
153
  console.log(JSON.stringify(submitted, null, 2));
123
154
  else if (submitted.testId) {
124
- ui_1.log.dim(`dashboard: ${api.baseUrl}/${project.name}/${submitted.testId}`);
155
+ ui_1.log.dim(`dashboard: ${dashboardUrl(api.baseUrl, project.name, submitted.testId)}`);
125
156
  }
126
157
  return;
127
158
  }
@@ -162,7 +193,7 @@ function registerRun(program) {
162
193
  else
163
194
  ui_1.log.error(chalk_1.default.bold('gate ') + (0, gates_1.formatGate)(r));
164
195
  }
165
- ui_1.log.dim(`dashboard: ${api.baseUrl}/${project.name}/${entry.testId}`);
196
+ ui_1.log.dim(`dashboard: ${dashboardUrl(api.baseUrl, project.name, entry.testId)}`);
166
197
  }
167
198
  const failed = results.filter(r => !r.passed);
168
199
  if (failed.length) {
@@ -9,7 +9,7 @@ and everything it does is scriptable.
9
9
  ```sh
10
10
  atlas projects --json
11
11
  atlas capacity --json
12
- atlas results SHOP-20260917-101500 --json
12
+ atlas results SHOP-20260917-7QKD --json
13
13
  ```
14
14
 
15
15
  Every command takes `--json`. On failure it prints the error as JSON on
@@ -72,10 +72,10 @@ the test ID.
72
72
  ## Checking on a run
73
73
 
74
74
  ```sh
75
- atlas status SHOP-20260818-194201
76
- atlas results SHOP-20260818-194201
77
- atlas results SHOP-20260818-194201 --json
78
- atlas stop SHOP-20260818-194201
75
+ atlas status SHOP-20260818-K3F9
76
+ atlas results SHOP-20260818-K3F9
77
+ atlas results SHOP-20260818-K3F9 --json
78
+ atlas stop SHOP-20260818-K3F9
79
79
  ```
80
80
 
81
81
  `results` prints the aggregation report — one row per request, plus totals.
@@ -105,9 +105,9 @@ and GUI listeners left switched on.
105
105
 
106
106
  ```sh
107
107
  atlas tests -p SHOP --limit 10 # recent runs, newest first
108
- atlas timeseries SHOP-20260818-194201 # users, response time and errors over time
109
- atlas errors SHOP-20260818-194201 # every distinct error, with its share
110
- atlas errors SHOP-20260818-194201 --full --label "01.01 /home"
108
+ atlas timeseries SHOP-20260818-K3F9 # users, response time and errors over time
109
+ atlas errors SHOP-20260818-K3F9 # every distinct error, with its share
110
+ atlas errors SHOP-20260818-K3F9 --full --label "01.01 /home"
111
111
  ```
112
112
 
113
113
  `timeseries` shows the shape of a run — where users peaked and where response
@@ -26,7 +26,7 @@
26
26
  {
27
27
  "topic": "results",
28
28
  "title": "Reading Results",
29
- "summary": "Every run has a dashboard. Open it from a project's **Results** tab, or from the test list at `/<project>`.",
29
+ "summary": "Every run has a dashboard. Open it from a project's **Results** tab, or from the test list at `/app/public/<project>`.",
30
30
  "source": "guide/results.md"
31
31
  },
32
32
  {
@@ -18,8 +18,8 @@ long it ran, and the peak number of concurrent users it reached. Click a run to
18
18
  open its dashboard.
19
19
 
20
20
  You can give a run a **name** — click the name cell and type. This is worth
21
- doing when you run variations against each other, since `SHOP-20260818-194201`
22
- tells you when a run happened but not what it was for. "Baseline, 500 users" is
21
+ doing when you run variations against each other, since `SHOP-20260818-K3F9`
22
+ tells you which day a run happened but not what it was for. "Baseline, 500 users" is
23
23
  easier to find three weeks later.
24
24
 
25
25
  ### Files
@@ -50,7 +50,10 @@ While recording you can:
50
50
 
51
51
  ## What's kept, and what isn't
52
52
 
53
- **Kept:** the requests your application makes — pages, API calls, form posts.
53
+ **Kept:** the requests your application makes — pages, API calls, form posts —
54
+ and the CORS preflight a browser sends before calling an API on another domain.
55
+ Preflights are marked `(OPTIONS)` and recorded once per address, the way a real
56
+ visitor's browser sends them.
54
57
 
55
58
  **Skipped:** images, stylesheets, fonts and known analytics traffic. These
56
59
  inflate a load test without telling you anything about your application. Include
@@ -64,9 +67,32 @@ and pulls it out of the live response at run time instead of replaying the
64
67
  recorded one. Without this, a recorded plan usually fails on the second run,
65
68
  when the token has changed.
66
69
 
70
+ This covers values wherever they are sent and wherever they came from:
71
+
72
+ - in the query, a form, a JSON body or a header;
73
+ - **inside the address itself** — the cart or order your session created, as in
74
+ `/cart/{id}/items/{id}`. Only parts of the address that are clearly generated
75
+ IDs are touched, and only when your application's API returned them; the fixed
76
+ parts of an address never are;
77
+ - from a response body, a response header, or a redirect on the way.
78
+
67
79
  Each of these gets a check attached, so if the value ever stops coming back the
68
80
  sample fails loudly rather than quietly posting a stale token.
69
81
 
82
+ ## Errors you meant to get
83
+
84
+ A request that answered with an error while you recorded — "who am I?" returning
85
+ 401 before you signed in — is part of the flow, not a failure. The plan expects
86
+ that same status and checks for it, so it doesn't count as an error, while a
87
+ different code (a 500 where a 401 was recorded) still fails the sample.
88
+
89
+ ## Every page is checked by its title
90
+
91
+ When a page you visited had a title, the plan checks the same title comes back on
92
+ every run, so a login screen or error page served in place of the real one fails
93
+ the sample instead of counting as a success. If your titles change from run to run
94
+ (a user name, a date), record with `--no-title-assertions`.
95
+
70
96
  ## Adding to an existing plan
71
97
 
72
98
  Point `-o` at a plan that already exists and the new recording is **added** to it
@@ -105,6 +131,7 @@ atlas-rec record https://app.example.com/home -o app.jmx --cookies ./cookies.jso
105
131
  | `--correlations <file>` | Pin named parameters to a value of your choosing |
106
132
  | `--no-correlate` | Replay recorded values as-is |
107
133
  | `--no-timestamps` | Keep recorded timestamps instead of using the current time |
134
+ | `--no-title-assertions` | Don't check that each page returns its recorded title |
108
135
 
109
136
  ## Check the plan before running it at scale
110
137
 
@@ -117,6 +144,11 @@ on its own, so that step can hit the destination twice per iteration. Delete the
117
144
  duplicate from the control panel, or from the plan afterwards, if it would skew
118
145
  your numbers.
119
146
 
147
+ Start from a browser that has never visited the site, or sign out and clear the
148
+ site's data first. A browser that has been there before can carry things over —
149
+ a cart it remembers, a stored sign-in token — and the recording then depends on
150
+ them, while every simulated user starts from nothing.
151
+
120
152
  **Think-time pauses** are recorded but switched off, so the plan runs flat out
121
153
  until you decide otherwise. Enable the `Settings - Sleeps` block in the plan and
122
154
  set the durations you want.
@@ -1,7 +1,7 @@
1
1
  # Reading Results
2
2
 
3
3
  Every run has a dashboard. Open it from a project's **Results** tab, or from the
4
- test list at `/<project>`.
4
+ test list at `/app/public/<project>`.
5
5
 
6
6
  The dashboard refreshes itself while a test is running, so you can leave it open
7
7
  from start to finish.
@@ -49,7 +49,7 @@ producing load.
49
49
  From the CLI:
50
50
 
51
51
  ```bash
52
- atlas status SHOP-20260818-194201
52
+ atlas status SHOP-20260818-K3F9
53
53
  ```
54
54
 
55
55
  ## If a machine is busy
@@ -63,7 +63,7 @@ don't need to retry.
63
63
  Press **Stop**, or:
64
64
 
65
65
  ```bash
66
- atlas stop SHOP-20260818-194201
66
+ atlas stop SHOP-20260818-K3F9
67
67
  ```
68
68
 
69
69
  Atlas asks JMeter to shut down cleanly so the final results are recorded, and
@@ -73,7 +73,20 @@ Usually a dynamic value that wasn't correlated — a token that was valid when y
73
73
  recorded and isn't now. Re-record with correlation enabled (the default), and
74
74
  check that the step which issues the token is part of the recording.
75
75
 
76
+ If the value is sent but never appeared in an earlier response, the browser
77
+ brought it with it — a cart or sign-in token remembered from a previous visit.
78
+ Record again from a browser with no history for the site.
79
+
80
+ ## Every request gets a blank page or a "Request Not Allowed" response
81
+
82
+ The site is protected against bots, and the load generators look like one. The
83
+ recorder's browser can be turned away the same way. This is not something to work
84
+ around in the plan: ask the site's owner to allow the load generators' addresses
85
+ (and yours, for recording), or to give you a test environment without the
86
+ protection. A captcha on sign-in needs the same treatment — a recorded captcha
87
+ answer is single-use and expires within minutes.
88
+
76
89
  ## Getting help
77
90
 
78
- When reporting a problem, include the **test ID** (`SHOP-20260818-194201`). It
91
+ When reporting a problem, include the **test ID** (`SHOP-20260818-K3F9`). It
79
92
  identifies the run and everything recorded about it.
@@ -32,7 +32,32 @@ Judge them:
32
32
  - A value that is sent but appears in **no** earlier response → the recorder
33
33
  cannot correlate it, and it is exactly the kind that breaks. Find out what it
34
34
  is: generated in the browser (a timestamp, a random id, a fingerprint), or
35
- supposed to vary per user (a login, an account number).
35
+ supposed to vary per user (a login, an account number), or **left over from an
36
+ earlier visit** — a cart id or bearer token the browser had stored. The last
37
+ kind means the recording was not made as a first-time visitor; the clean fix is
38
+ to record again from a browser with no history for the site.
39
+
40
+ What the recorder covers, so you know where to look for gaps:
41
+
42
+ - Values in the query, a form, a JSON body, dynamic headers, and **generated IDs
43
+ in the URL path** (`/cart/{uuid}`) — paths only for UUIDs, long hex or opaque
44
+ tokens that an API JSON response or a response header returned.
45
+ - Sources in a response body, a response header (matched case-insensitively), or
46
+ the first hop of a redirect (the extractor covers sub-samples).
47
+
48
+ What it does not:
49
+
50
+ - **A list sent back joined** (`uuid_list=a,b,c` built from a JSON array). Add a
51
+ JSON Extractor on the array with match number `-1` and *Compute concatenation
52
+ var* on, and send `${name_ALL}`. If the array can be empty, put the consuming
53
+ request under an If Controller on `${name_matchNr}` — the application would
54
+ not make that call at all.
55
+ - **Values the user chose** — a search term, a date, a city. These are input, not
56
+ correlation; if the recorder tied one to a tracker's response, make it a literal
57
+ or a CSV column.
58
+ - **Browser-generated security data** — a bot-protection sensor post, a captcha
59
+ token. Do not correlate or replay these; they need the site owner to exempt the
60
+ load generators.
36
61
 
37
62
  ## Pin the rest yourself
38
63
 
@@ -152,6 +152,13 @@ The same capture always produces the same plan, so this is safe to repeat.
152
152
 
153
153
  ## Things that will bite you
154
154
 
155
+ - **A blank page or "Request Not Allowed" on the first load** means the site's
156
+ bot protection turned the recorder's browser away. Stop and tell the person.
157
+ Never try to get past it — it is the site owner's control, and the load test
158
+ will meet it too. The fix is for the owner to allow the person's addresses.
159
+ - **Record as a first-time visitor.** A browser with history for the site can
160
+ bring a stored cart or sign-in token along, and the recording then depends on
161
+ it. Check `suggest` for values that appear in no earlier response.
155
162
  - **Never trigger a native dialog** (`alert`, `confirm`, a file picker). They
156
163
  block the browser and the session stops responding. Avoid clicking anything
157
164
  that might delete or confirm.
package/dist/ui.js CHANGED
@@ -24,9 +24,12 @@ exports.EXIT = {
24
24
  /** Thrown for expected failures — reported without a stack trace. */
25
25
  class CliError extends Error {
26
26
  code;
27
- constructor(message, code = exports.EXIT.ERROR) {
27
+ httpStatus;
28
+ /** `httpStatus`: the API's status when the error is an API refusal. */
29
+ constructor(message, code = exports.EXIT.ERROR, httpStatus) {
28
30
  super(message);
29
31
  this.code = code;
32
+ this.httpStatus = httpStatus;
30
33
  this.name = 'CliError';
31
34
  }
32
35
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudbeat/atlas-cli",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "Atlas CLI — run JMeter load tests from CI and record scenarios",
5
5
  "main": "dist/cli.js",
6
6
  "bin": {