@xano/cli 1.1.0 → 1.2.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
@@ -487,7 +487,7 @@ Every `run_all` command takes `--concurrency` (default `1`):
487
487
 
488
488
  ```bash
489
489
  xano unit_test run_all --concurrency 4
490
- xano tenant unit_test run_all my-tenant --concurrency 8
490
+ xano tenant unit_test run_all --tenant my-tenant --concurrency 8
491
491
  ```
492
492
 
493
493
  Output stays in test order regardless of concurrency, so a parallel run is still diffable against a sequential one, and the JSON `results` array keeps a stable order.
@@ -496,6 +496,48 @@ Output stays in test order regardless of concurrency, so a parallel run is still
496
496
 
497
497
  **The default is 1 on purpose.** These tests execute against a shared workspace database, so tests that touch the same tables or records can interfere with each other when run at the same time — producing failures that look like flakiness rather than a real regression. Raise `--concurrency` once you know your tests are independent.
498
498
 
499
+ #### Exit codes
500
+
501
+ Every `run` and `run_all` command — on all four surfaces (`unit_test`, `workflow_test`, `sandbox`, `tenant`) — uses the same exit codes:
502
+
503
+ | Code | Meaning |
504
+ |------|---------|
505
+ | `0` | All tests passed, **or** there were no tests to run |
506
+ | `1` | At least one test failed |
507
+ | `2` | The command could not run the tests at all — bad workspace, auth failure, unreadable profile, or the test-list request failed |
508
+
509
+ **This is identical for `-o json` and summary output.** Adding `-o json` to get machine-readable output does not change the exit code, so a CI job can rely on it in either mode.
510
+
511
+ ```bash
512
+ xano workflow_test run_all -o json || echo "tests failed or the command errored"
513
+ ```
514
+
515
+ **One asymmetry worth knowing about.** In `run`, a non-2xx response from the run endpoint is a CLI error and exits `2`. In `run_all`, a non-2xx response for an *individual* test is recorded as a failed test result with an `API error <status>` message and contributes to exit `1` — because `run_all`'s job is to finish the batch and report a roll-up rather than abort on one test's transport error. Exit `2` in `run_all` is reserved for failures that stop the batch from starting at all.
516
+
517
+ If you need to tell a backend outage apart from a genuine assertion failure, the exit code alone will not do it — inspect `results[].message` for the `API error` prefix:
518
+
519
+ ```bash
520
+ xano workflow_test run_all -o json | jq '[.results[] | select(.message // "" | startswith("API error"))] | length'
521
+ ```
522
+
523
+ #### JSON output
524
+
525
+ `run` returns the API's result object unchanged. Treat **any** `status` other than `"ok"` as a failure — several distinct non-`ok` values are returned in practice, so match on "not `ok`" rather than enumerating known failure values:
526
+
527
+ ```json
528
+ { "status": "exception", "timing": 0.02, "message": "Precondition failed." }
529
+ ```
530
+
531
+ `run_all` returns a roll-up. A branch with no tests is a normal state — freshly created branches routinely have none — and still emits valid JSON rather than plain text, so `JSON.parse` is always safe:
532
+
533
+ ```json
534
+ { "passed": 0, "failed": 0, "results": [], "total_timing": 0 }
535
+ ```
536
+
537
+ The `total_timing` field is currently emitted by `workflow_test run_all` only; the `unit_test`, `tenant`, and `sandbox` variants omit it. The empty-suite envelope always carries the same keys as that command's populated envelope.
538
+
539
+ > **Changed in this release:** a failing `run` in summary mode previously exited `2` and printed a spurious `EEXIT: 1` line; it now exits `1` with clean output. A failing `run` with `-o json` previously exited `0`, which silently hid failures from CI. If you match on exit code `2` specifically to detect test failures, switch to a non-zero check.
540
+
499
541
  ### Tenants
500
542
 
501
543
  Manage tenants, their environment variables, backups, deployments, and clusters.
@@ -46,24 +46,30 @@ Result: PASS
46
46
  this.error(message);
47
47
  }
48
48
  const result = (await response.json());
49
+ // Anything other than 'ok' is a failure -- the API has been observed
50
+ // returning several distinct non-ok statuses, so never enumerate them.
51
+ const passed = result.status === 'ok';
49
52
  if (flags.output === 'json') {
50
53
  this.log(JSON.stringify(result, null, 2));
51
54
  }
55
+ else if (passed) {
56
+ this.log('Result: PASS');
57
+ }
52
58
  else {
53
- if (result.status === 'ok') {
54
- this.log('Result: PASS');
55
- }
56
- else {
57
- this.log('Result: FAIL');
58
- const failedExpects = result.results?.filter((r) => r.status === 'fail') ?? [];
59
- for (const expect of failedExpects) {
60
- if (expect.message) {
61
- this.log(` Error: ${expect.message}`);
62
- }
59
+ this.log('Result: FAIL');
60
+ const failedExpects = result.results?.filter((r) => r.status === 'fail') ?? [];
61
+ for (const expect of failedExpects) {
62
+ if (expect.message) {
63
+ this.log(` Error: ${expect.message}`);
63
64
  }
64
- this.exit(1);
65
65
  }
66
66
  }
67
+ // Set the code rather than throwing via this.exit(): the exit must apply
68
+ // in both output modes, and process.exitCode cannot be swallowed by the
69
+ // surrounding catch. Mirrors what run_all already does.
70
+ if (!passed) {
71
+ process.exitCode = 1;
72
+ }
67
73
  }
68
74
  catch (error) {
69
75
  if (error instanceof Error && 'oclif' in error)
@@ -78,7 +78,15 @@ Results: 4 passed, 1 failed
78
78
  },
79
79
  });
80
80
  if (tests.length === 0) {
81
- this.log('No unit tests found');
81
+ // An empty suite is a normal state (fresh branches have no tests) and
82
+ // is a success, not a failure. Under -o json it must still be parseable,
83
+ // with the same keys the populated path emits below.
84
+ if (flags.output === 'json') {
85
+ this.log(JSON.stringify({ failed: 0, passed: 0, results: [] }, null, 2));
86
+ }
87
+ else {
88
+ this.log('No unit tests found');
89
+ }
82
90
  return;
83
91
  }
84
92
  if (flags.output === 'summary') {
@@ -46,22 +46,25 @@ Result: PASS (0.25s)
46
46
  this.error(message);
47
47
  }
48
48
  const result = (await response.json());
49
+ // Anything other than 'ok' is a failure -- the API has been observed
50
+ // returning several distinct non-ok statuses, so never enumerate them.
51
+ const passed = result.status === 'ok';
49
52
  if (flags.output === 'json') {
50
53
  this.log(JSON.stringify(result, null, 2));
51
54
  }
52
55
  else {
53
56
  const timing = result.timing ? ` (${result.timing}s)` : '';
54
- if (result.status === 'ok') {
55
- this.log(`Result: PASS${timing}`);
56
- }
57
- else {
58
- this.log(`Result: FAIL${timing}`);
59
- if (result.message) {
60
- this.log(` Error: ${result.message}`);
61
- }
62
- this.exit(1);
57
+ this.log(`Result: ${passed ? 'PASS' : 'FAIL'}${timing}`);
58
+ if (!passed && result.message) {
59
+ this.log(` Error: ${result.message}`);
63
60
  }
64
61
  }
62
+ // Set the code rather than throwing via this.exit(): the exit must apply
63
+ // in both output modes, and process.exitCode cannot be swallowed by the
64
+ // surrounding catch. Mirrors what run_all already does.
65
+ if (!passed) {
66
+ process.exitCode = 1;
67
+ }
65
68
  }
66
69
  catch (error) {
67
70
  if (error instanceof Error && 'oclif' in error)
@@ -71,7 +71,15 @@ Results: 2 passed, 1 failed
71
71
  },
72
72
  });
73
73
  if (tests.length === 0) {
74
- this.log('No workflow tests found');
74
+ // An empty suite is a normal state (fresh branches have no tests) and
75
+ // is a success, not a failure. Under -o json it must still be parseable,
76
+ // with the same keys the populated path emits below.
77
+ if (flags.output === 'json') {
78
+ this.log(JSON.stringify({ failed: 0, passed: 0, results: [] }, null, 2));
79
+ }
80
+ else {
81
+ this.log('No workflow tests found');
82
+ }
75
83
  return;
76
84
  }
77
85
  if (flags.output === 'summary') {
@@ -68,26 +68,34 @@ Result: PASS
68
68
  this.error(`API request failed with status ${response.status}: ${response.statusText}\n${errorText}`);
69
69
  }
70
70
  const result = (await response.json());
71
+ // Anything other than 'ok' is a failure -- the API has been observed
72
+ // returning several distinct non-ok statuses, so never enumerate them.
73
+ const passed = result.status === 'ok';
71
74
  if (flags.output === 'json') {
72
75
  this.log(JSON.stringify(result, null, 2));
73
76
  }
77
+ else if (passed) {
78
+ this.log('Result: PASS');
79
+ }
74
80
  else {
75
- if (result.status === 'ok') {
76
- this.log('Result: PASS');
77
- }
78
- else {
79
- this.log('Result: FAIL');
80
- const failedExpects = result.results?.filter((r) => r.status === 'fail') ?? [];
81
- for (const expect of failedExpects) {
82
- if (expect.message) {
83
- this.log(` Error: ${expect.message}`);
84
- }
81
+ this.log('Result: FAIL');
82
+ const failedExpects = result.results?.filter((r) => r.status === 'fail') ?? [];
83
+ for (const expect of failedExpects) {
84
+ if (expect.message) {
85
+ this.log(` Error: ${expect.message}`);
85
86
  }
86
- this.exit(1);
87
87
  }
88
88
  }
89
+ // Set the code rather than throwing via this.exit(): the exit must apply
90
+ // in both output modes, and process.exitCode cannot be swallowed by the
91
+ // surrounding catch. Mirrors what run_all already does.
92
+ if (!passed) {
93
+ process.exitCode = 1;
94
+ }
89
95
  }
90
96
  catch (error) {
97
+ if (error instanceof Error && 'oclif' in error)
98
+ throw error;
91
99
  if (error instanceof Error) {
92
100
  this.error(`Failed to run unit test: ${error.message}`);
93
101
  }
@@ -95,7 +95,15 @@ Results: 4 passed, 1 failed
95
95
  },
96
96
  });
97
97
  if (tests.length === 0) {
98
- this.log('No unit tests found');
98
+ // An empty suite is a normal state (fresh branches have no tests) and
99
+ // is a success, not a failure. Under -o json it must still be parseable,
100
+ // with the same keys the populated path emits below.
101
+ if (flags.output === 'json') {
102
+ this.log(JSON.stringify({ failed: 0, passed: 0, results: [] }, null, 2));
103
+ }
104
+ else {
105
+ this.log('No unit tests found');
106
+ }
99
107
  return;
100
108
  }
101
109
  if (flags.output === 'summary') {
@@ -162,6 +170,8 @@ Results: 4 passed, 1 failed
162
170
  }
163
171
  }
164
172
  catch (error) {
173
+ if (error instanceof Error && 'oclif' in error)
174
+ throw error;
165
175
  const message = error instanceof Error ? error.message : String(error);
166
176
  results.push({
167
177
  message,
@@ -202,6 +212,8 @@ Results: 4 passed, 1 failed
202
212
  }
203
213
  }
204
214
  catch (error) {
215
+ if (error instanceof Error && 'oclif' in error)
216
+ throw error;
205
217
  if (error instanceof Error) {
206
218
  this.error(`Failed to run unit tests: ${error.message}`);
207
219
  }
@@ -61,24 +61,29 @@ Result: PASS (0.25s)
61
61
  this.error(`API request failed with status ${response.status}: ${response.statusText}\n${errorText}`);
62
62
  }
63
63
  const result = (await response.json());
64
+ // Anything other than 'ok' is a failure -- the API has been observed
65
+ // returning several distinct non-ok statuses, so never enumerate them.
66
+ const passed = result.status === 'ok';
64
67
  if (flags.output === 'json') {
65
68
  this.log(JSON.stringify(result, null, 2));
66
69
  }
67
70
  else {
68
71
  const timing = result.timing ? ` (${result.timing}s)` : '';
69
- if (result.status === 'ok') {
70
- this.log(`Result: PASS${timing}`);
71
- }
72
- else {
73
- this.log(`Result: FAIL${timing}`);
74
- if (result.message) {
75
- this.log(` Error: ${result.message}`);
76
- }
77
- this.exit(1);
72
+ this.log(`Result: ${passed ? 'PASS' : 'FAIL'}${timing}`);
73
+ if (!passed && result.message) {
74
+ this.log(` Error: ${result.message}`);
78
75
  }
79
76
  }
77
+ // Set the code rather than throwing via this.exit(): the exit must apply
78
+ // in both output modes, and process.exitCode cannot be swallowed by the
79
+ // surrounding catch. Mirrors what run_all already does.
80
+ if (!passed) {
81
+ process.exitCode = 1;
82
+ }
80
83
  }
81
84
  catch (error) {
85
+ if (error instanceof Error && 'oclif' in error)
86
+ throw error;
82
87
  if (error instanceof Error) {
83
88
  this.error(`Failed to run workflow test: ${error.message}`);
84
89
  }
@@ -85,7 +85,15 @@ Results: 2 passed, 1 failed
85
85
  },
86
86
  });
87
87
  if (tests.length === 0) {
88
- this.log('No workflow tests found');
88
+ // An empty suite is a normal state (fresh branches have no tests) and
89
+ // is a success, not a failure. Under -o json it must still be parseable,
90
+ // with the same keys the populated path emits below.
91
+ if (flags.output === 'json') {
92
+ this.log(JSON.stringify({ failed: 0, passed: 0, results: [] }, null, 2));
93
+ }
94
+ else {
95
+ this.log('No workflow tests found');
96
+ }
89
97
  return;
90
98
  }
91
99
  if (flags.output === 'summary') {
@@ -146,6 +154,8 @@ Results: 2 passed, 1 failed
146
154
  }
147
155
  }
148
156
  catch (error) {
157
+ if (error instanceof Error && 'oclif' in error)
158
+ throw error;
149
159
  const message = error instanceof Error ? error.message : String(error);
150
160
  results.push({
151
161
  message,
@@ -184,6 +194,8 @@ Results: 2 passed, 1 failed
184
194
  }
185
195
  }
186
196
  catch (error) {
197
+ if (error instanceof Error && 'oclif' in error)
198
+ throw error;
187
199
  if (error instanceof Error) {
188
200
  this.error(`Failed to run workflow tests: ${error.message}`);
189
201
  }
@@ -63,26 +63,34 @@ Result: PASS
63
63
  this.error(`API request failed with status ${response.status}: ${response.statusText}\n${errorText}`);
64
64
  }
65
65
  const result = await response.json();
66
+ // Anything other than 'ok' is a failure -- the API has been observed
67
+ // returning several distinct non-ok statuses, so never enumerate them.
68
+ const passed = result.status === 'ok';
66
69
  if (flags.output === 'json') {
67
70
  this.log(JSON.stringify(result, null, 2));
68
71
  }
72
+ else if (passed) {
73
+ this.log('Result: PASS');
74
+ }
69
75
  else {
70
- if (result.status === 'ok') {
71
- this.log('Result: PASS');
72
- }
73
- else {
74
- this.log('Result: FAIL');
75
- const failedExpects = result.results?.filter(r => r.status === 'fail') ?? [];
76
- for (const expect of failedExpects) {
77
- if (expect.message) {
78
- this.log(` Error: ${expect.message}`);
79
- }
76
+ this.log('Result: FAIL');
77
+ const failedExpects = result.results?.filter(r => r.status === 'fail') ?? [];
78
+ for (const expect of failedExpects) {
79
+ if (expect.message) {
80
+ this.log(` Error: ${expect.message}`);
80
81
  }
81
- this.exit(1);
82
82
  }
83
83
  }
84
+ // Set the code rather than throwing via this.exit(): the exit must apply
85
+ // in both output modes, and process.exitCode cannot be swallowed by the
86
+ // surrounding catch. Mirrors what run_all already does.
87
+ if (!passed) {
88
+ process.exitCode = 1;
89
+ }
84
90
  }
85
91
  catch (error) {
92
+ if (error instanceof Error && 'oclif' in error)
93
+ throw error;
86
94
  if (error instanceof Error) {
87
95
  this.error(`Failed to run unit test: ${error.message}`);
88
96
  }
@@ -91,7 +91,15 @@ Results: 2 passed, 1 failed
91
91
  },
92
92
  });
93
93
  if (tests.length === 0) {
94
- this.log('No unit tests found');
94
+ // An empty suite is a normal state (fresh branches have no tests) and
95
+ // is a success, not a failure. Under -o json it must still be parseable,
96
+ // with the same keys the populated path emits below.
97
+ if (flags.output === 'json') {
98
+ this.log(JSON.stringify({ failed: 0, passed: 0, results: [] }, null, 2));
99
+ }
100
+ else {
101
+ this.log('No unit tests found');
102
+ }
95
103
  return;
96
104
  }
97
105
  if (flags.output === 'summary') {
@@ -161,6 +169,8 @@ Results: 2 passed, 1 failed
161
169
  }
162
170
  }
163
171
  catch (error) {
172
+ if (error instanceof Error && 'oclif' in error)
173
+ throw error;
164
174
  const message = error instanceof Error ? error.message : String(error);
165
175
  results.push({
166
176
  message,
@@ -202,6 +212,8 @@ Results: 2 passed, 1 failed
202
212
  }
203
213
  }
204
214
  catch (error) {
215
+ if (error instanceof Error && 'oclif' in error)
216
+ throw error;
205
217
  if (error instanceof Error) {
206
218
  this.error(`Failed to run unit tests: ${error.message}`);
207
219
  }
@@ -55,24 +55,29 @@ Result: PASS (1.234s)
55
55
  this.error(`API request failed with status ${response.status}: ${response.statusText}\n${errorText}`);
56
56
  }
57
57
  const result = await response.json();
58
+ // Anything other than 'ok' is a failure -- the API has been observed
59
+ // returning several distinct non-ok statuses, so never enumerate them.
60
+ const passed = result.status === 'ok';
58
61
  if (flags.output === 'json') {
59
62
  this.log(JSON.stringify(result, null, 2));
60
63
  }
61
64
  else {
62
- const timing = `(${result.timing.toFixed(3)}s)`;
63
- if (result.status === 'ok') {
64
- this.log(`Result: PASS ${timing}`);
65
- }
66
- else {
67
- this.log(`Result: FAIL ${timing}`);
68
- if (result.message) {
69
- this.log(` Error: ${result.message}`);
70
- }
71
- this.exit(1);
65
+ const timing = typeof result.timing === 'number' ? ` (${result.timing.toFixed(3)}s)` : '';
66
+ this.log(`Result: ${passed ? 'PASS' : 'FAIL'}${timing}`);
67
+ if (!passed && result.message) {
68
+ this.log(` Error: ${result.message}`);
72
69
  }
73
70
  }
71
+ // Set the code rather than throwing via this.exit(): the exit must apply
72
+ // in both output modes, and process.exitCode cannot be swallowed by the
73
+ // surrounding catch. Mirrors what run_all already does.
74
+ if (!passed) {
75
+ process.exitCode = 1;
76
+ }
74
77
  }
75
78
  catch (error) {
79
+ if (error instanceof Error && 'oclif' in error)
80
+ throw error;
76
81
  if (error instanceof Error) {
77
82
  this.error(`Failed to run workflow test: ${error.message}`);
78
83
  }
@@ -82,7 +82,15 @@ Results: 2 passed, 1 failed (2.691s total)
82
82
  },
83
83
  });
84
84
  if (tests.length === 0) {
85
- this.log('No workflow tests found');
85
+ // An empty suite is a normal state (fresh branches have no tests) and
86
+ // is a success, not a failure. Under -o json it must still be parseable,
87
+ // with the same keys the populated path emits below.
88
+ if (flags.output === 'json') {
89
+ this.log(JSON.stringify({ failed: 0, passed: 0, results: [], total_timing: 0 }, null, 2));
90
+ }
91
+ else {
92
+ this.log('No workflow tests found');
93
+ }
86
94
  return;
87
95
  }
88
96
  if (flags.output === 'summary') {
@@ -134,9 +142,9 @@ Results: 2 passed, 1 failed (2.691s total)
134
142
  timing: runResult.timing,
135
143
  };
136
144
  results.push(result);
137
- totalTiming += runResult.timing;
145
+ totalTiming += runResult.timing ?? 0;
138
146
  if (flags.output === 'summary') {
139
- const timing = `(${runResult.timing.toFixed(3)}s)`;
147
+ const timing = `(${(runResult.timing ?? 0).toFixed(3)}s)`;
140
148
  if (passed) {
141
149
  log(`PASS ${test.name} ${timing}`);
142
150
  }
@@ -149,6 +157,8 @@ Results: 2 passed, 1 failed (2.691s total)
149
157
  }
150
158
  }
151
159
  catch (error) {
160
+ if (error instanceof Error && 'oclif' in error)
161
+ throw error;
152
162
  const message = error instanceof Error ? error.message : String(error);
153
163
  results.push({
154
164
  message,
@@ -189,6 +199,8 @@ Results: 2 passed, 1 failed (2.691s total)
189
199
  }
190
200
  }
191
201
  catch (error) {
202
+ if (error instanceof Error && 'oclif' in error)
203
+ throw error;
192
204
  if (error instanceof Error) {
193
205
  this.error(`Failed to run workflow tests: ${error.message}`);
194
206
  }