@sifurai/cli 0.1.0 → 0.1.2

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
@@ -16,19 +16,22 @@ the results and screenshots go to Sifur.
16
16
 
17
17
  ## Install
18
18
 
19
- Nothing to install up front:
19
+ Install it once to get the `sifur` command:
20
20
 
21
21
  ```sh
22
- npx @sifurai/cli login
22
+ npm install -g @sifurai/cli
23
+ sifur --version
23
24
  ```
24
25
 
25
- Or install it globally to get a `sifur` command:
26
+ Update it with:
26
27
 
27
28
  ```sh
28
- npm install -g @sifurai/cli
29
- sifur login
29
+ npm install -g @sifurai/cli@latest
30
30
  ```
31
31
 
32
+ (`npx @sifurai/cli …` works too, but npx can keep running an older version it has cached, so an
33
+ installed `sifur` is the reliable way.)
34
+
32
35
  ## Sign in
33
36
 
34
37
  ```sh
@@ -50,6 +53,36 @@ To sign out (and revoke the session):
50
53
  sifur logout
51
54
  ```
52
55
 
56
+ ## Apply Sifur's work before you deploy
57
+
58
+ Sifur pushes the ticket's branch only when you approve it after acceptance testing, so your CI
59
+ isn't set off by every change along the way. Until then, `sifur apply` puts Sifur's changes in your
60
+ checkout, for you to deploy to your test server and test:
61
+
62
+ ```sh
63
+ cd ~/src/azalea
64
+ sifur apply AZA-12
65
+ ```
66
+
67
+ It works on the checkout you're in when it's the ticket's repository (as for automated cases,
68
+ below), or on `--workdir <repository>=<dir>` for each repository. The checkout needs no uncommitted
69
+ changes (commit or stash them first) and must have the commit Sifur's work starts from; if it
70
+ doesn't, the CLI tells you to `git fetch` and check out the ticket's branch. The changes are
71
+ applied with `git apply --3way` and left uncommitted (staged); the CLI prints how many files each
72
+ repository's changes touch.
73
+
74
+ To take them back out:
75
+
76
+ ```sh
77
+ sifur apply AZA-12 --revert
78
+ ```
79
+
80
+ `apply` keeps a copy of the patch in the cache folder (`<cache>/AZA-12/applied/`) for `--revert`,
81
+ which deletes it once the changes are out.
82
+
83
+ `sifur accept AZA-12 --apply --url …` applies the changes first, then runs the cases. It doesn't
84
+ take them out afterwards.
85
+
53
86
  ## Run a ticket's acceptance cases
54
87
 
55
88
  When a ticket is in **Acceptance testing**, its Overview shows the branch and the commit of each
@@ -66,21 +99,59 @@ sifur accept AZA-12 --url https://test.example.internal
66
99
  it), taking a screenshot after every step. Add `--headed` to watch.
67
100
  3. For a step that needs a person's eye, shows you the screenshot's path and asks whether it looks
68
101
  right.
69
- 4. Runs automated cases in your checkout when you pass `--workdir <repository>=<dir>` (repeat it
70
- for each repository); otherwise asks you for the result.
102
+ 4. Runs automated cases in your checkout of their repository (see below); without one, asks you
103
+ for the result.
71
104
  5. For a case with no script, shows its steps and expected result and asks you for the result:
72
105
  passed, failed or blocked, with a note.
73
106
  6. Uploads the screenshots and results, prints a summary, and links to the ticket.
74
107
 
108
+ Every case passed: the ticket moves on to approval. Any failed or blocked: it goes back to be fixed.
109
+
110
+ `--url` is the root of your test server: the cases' paths are opened under it. The CLI warns when
111
+ it has a path or a query string, since that's rarely what's wanted.
112
+
113
+ Until they're uploaded, the cases, screenshots and results are kept outside your checkout, in a
114
+ cache folder per user:
115
+
116
+ - macOS and Linux: `~/.cache/sifur/AZA-12/` (or `$XDG_CACHE_HOME/sifur/AZA-12/`)
117
+ - Windows: `%LOCALAPPDATA%\sifur\AZA-12\`
118
+
119
+ The CLI prints where. Once Sifur has the results, it deletes that folder, leaving nothing behind
120
+ (except a patch from `sifur apply` that's still applied, which `--revert` needs). If the upload
121
+ fails, everything is kept: send it again with `sifur accept AZA-12 --upload`, from any directory.
122
+
123
+ The CLI never reads or writes a `.sifur` folder in your checkout. Version 0.1.0 kept its files
124
+ there; if one is left over, the CLI says so, and you can delete it.
125
+
126
+ ## Automated cases
127
+
128
+ An automated case runs its test command in your checkout of the case's repository. Run `accept`
129
+ from inside that checkout and the CLI uses it: the top of the git checkout you're in is used for a
130
+ repository when the cases name only that one repository, or when the checkout's folder has the
131
+ repository's name. It prints which folder it uses for which repository. For anything else, name
132
+ the folder with `--workdir <repository>=<dir>`, repeated for each repository; `--workdir` always
133
+ wins:
134
+
75
135
  ```sh
76
136
  sifur accept AZA-12 --url https://test.example.internal \
77
137
  --workdir api=~/src/azalea-api --workdir web=~/src/azalea-web
78
138
  ```
79
139
 
80
- Every case passed: the ticket moves on to approval. Any failed or blocked: it goes back to be fixed.
140
+ A case's command is written for Sifur's sandbox, where the repository is checked out at
141
+ `/workspace/<repository>`. On your machine, a leading `cd /workspace/<repository> &&` is dropped,
142
+ any other `/workspace/<repository>` path becomes your checkout's, and the command runs in your
143
+ checkout.
81
144
 
82
- Screenshots and results are kept in `./.sifur/AZA-12/` (add `.sifur/` to your `.gitignore`). If the
83
- upload fails, send them again with `sifur accept AZA-12 --upload`.
145
+ If the command can't run in your checkout at all (the shell says the program isn't there or isn't
146
+ executable, exit 127 or 126, say because `vendor/` or `node_modules/` isn't installed), that isn't
147
+ recorded as a failure. The CLI says what's missing (with a hint like "run `composer install`?"),
148
+ and asks you for the result as it does for a case with no checkout. This is the same with `--run`.
149
+
150
+ Some test files Sifur writes for a ticket are kept out of the repository, so they never show up in
151
+ your branch. Before a repository's automated cases run, the CLI puts them in your checkout, and
152
+ afterwards it removes them again (also on Ctrl-C), along with any folder it made for them, so
153
+ `git status` is just as it was. A file of yours at the same path is set aside and put back; one
154
+ that's already identical is left alone. A path outside the checkout is refused.
84
155
 
85
156
  ## VPN: split tunnel or full tunnel
86
157
 
@@ -100,16 +171,26 @@ sifur accept AZA-12 --run --url https://test.example.internal
100
171
  sifur accept AZA-12 --upload
101
172
  ```
102
173
 
103
- `--download` saves the cases in `./.sifur/AZA-12/`, `--run` runs them from there and saves the
104
- results next to them, and `--upload` sends them. Run all three from the same directory.
174
+ `--download` saves the cases (and the test files Sifur keeps out of the repository) in the cache
175
+ folder, `--run` runs them from there and saves the results next to them, and `--upload` sends them
176
+ and then deletes them.
177
+ They don't need to run from the same directory; run `--run` from your checkout to have automated
178
+ cases run in it.
105
179
 
106
180
  ## Secrets
107
181
 
108
- A case can use a placeholder such as `{{ADMIN_PASSWORD}}` for a value it types, like a test
109
- account's password. The CLI takes it from the environment variable `SIFUR_SECRET_ADMIN_PASSWORD`
110
- or, when that isn't set, asks you for it without echoing. Secrets stay on your machine: they are
111
- never written to `.sifur/`, never sent to Sifur, and are replaced with `[secret]` in any error
112
- message.
182
+ A UI case can use a placeholder such as `{{APP_USERNAME}}` or `{{APP_PASSWORD}}` for a test
183
+ account's sign-in, in what it types, the page it opens, and the text or URL it checks for. The CLI
184
+ takes each from the environment variable `SIFUR_SECRET_<NAME>` (for example
185
+ `SIFUR_SECRET_APP_PASSWORD`) or, when that isn't set, asks you for it once per run, after saying
186
+ which case and step needs it. A name with `USER`, `EMAIL` or `LOGIN` in it shows what you type;
187
+ any other, and always one with `PASSWORD`, `TOKEN` or `SECRET`, doesn't. Secrets stay on your
188
+ machine: they are never written to disk, never sent to Sifur, and are replaced with `[secret]` in
189
+ any error message.
190
+
191
+ `{{RUN}}` isn't a secret and is never asked for: it's replaced with a short token, new for each
192
+ run and the same in every case and step of it, so a case can create data that's unique each time
193
+ (`qa-{{RUN}}@example.com`) and check for it afterwards.
113
194
 
114
195
  ```sh
115
196
  SIFUR_SECRET_ADMIN_PASSWORD='…' sifur accept AZA-12 --url https://test.example.internal
@@ -121,9 +202,23 @@ SIFUR_SECRET_ADMIN_PASSWORD='…' sifur accept AZA-12 --url https://test.example
121
202
  |---|---|
122
203
  | `--url <url>` | Your test server, where the ticket's branch is deployed |
123
204
  | `--headed` | Show the browser while the cases run |
124
- | `--workdir <repository>=<dir>` | Run that repository's automated cases in `<dir>`; repeatable |
205
+ | `--timeout <seconds>` | How long each browser step may take, page loads included (default 60). Raise it when the test server is slow, for example over a VPN |
206
+ | `--workdir <repository>=<dir>` | Run that repository's automated cases in `<dir>`; repeatable. Default: the checkout you're in, when it's that repository's |
125
207
  | `--download` / `--run` / `--upload` | The three steps for a full-tunnel VPN |
126
- | `--api <url>` | Sifur's API, for `login` (default `https://api.sifur.ai`, or `SIFUR_API_URL`) |
208
+ | `--apply` | With `accept`: put Sifur's changes in your checkouts first, as `sifur apply` does |
209
+ | `--revert` | With `apply`: take the changes back out |
210
+ | `--api <url>` | Sifur's API, for `login` (default `https://api.sifur.ai`, or `SIFUR_API_URL`). Not your test server: that's `--url` |
211
+
212
+ ## Troubleshooting
213
+
214
+ **`<url> isn't Sifur's API`** from `sifur login --api <url>`: `--api` is Sifur's own address (for
215
+ example `https://api.sifur.ai`), and is only needed for a Sifur other than the default. Your test
216
+ server goes in `sifur accept --url`.
217
+
218
+ **`unable to get local issuer certificate`** or **`self-signed certificate`**: Node doesn't trust the
219
+ certificate of the Sifur it's talking to. If this is a local or company Sifur with its own
220
+ certificate, run with `NODE_OPTIONS=--use-system-ca` (Node 22.15+), or point Node at the root
221
+ certificate with `NODE_EXTRA_CA_CERTS=<root cert>`.
127
222
 
128
223
  ## Exit codes
129
224
 
package/dist/accept.js CHANGED
@@ -2,20 +2,22 @@ import { existsSync, statSync } from 'node:fs';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { basename, extname, join, resolve } from 'node:path';
4
4
  import { ApiError, CliError } from './api.js';
5
- import { runAutomated } from './automated.js';
5
+ import { applyChanges } from './apply.js';
6
+ import { COULD_NOT_RUN, missingProgram, runAutomated } from './automated.js';
6
7
  import { launchBrowser } from './browser.js';
7
8
  import { askStatus, confirm } from './io.js';
8
9
  import { resolveTicket } from './resolve.js';
9
10
  import { buildResultsPayload, combine, outcome } from './results.js';
10
11
  import { runUiCase } from './runner.js';
11
- import { collectSecrets, redact, secretNames } from './secrets.js';
12
- import { findTicketDir, loadCases, loadResults, saveCases, saveResults } from './store.js';
12
+ import { collectSecrets, newRunToken, redact, secretUses } from './secrets.js';
13
+ import { findTicketDir, loadCases, loadResults, removeTicketFiles, saveCases, saveResults } from './store.js';
14
+ import { checkFilePaths, defaultWorkdirs, gitTopLevel, localizeCommand, placeTestFiles, removeOnExit } from './workspace.js';
13
15
  export const MAX_SCREENSHOT_BYTES = 5 * 1024 * 1024;
14
16
  export async function download(api, ref) {
15
17
  const { ticketUuid, organization } = await resolveTicket(api, ref);
16
18
  try {
17
19
  const acceptance = await api.request(`tickets/${ticketUuid}/acceptance`, { organization: organization.uuid });
18
- return { api: api.base, organizationUuid: organization.uuid, acceptance };
20
+ return { api: api.base, organizationUuid: organization.uuid, acceptance: { ...acceptance, files: acceptance.files ?? [] } };
19
21
  }
20
22
  catch (error) {
21
23
  if (error instanceof ApiError && error.status === 409) {
@@ -68,6 +70,9 @@ function judgeWith(io) {
68
70
  return { ok: false, note: note || undefined };
69
71
  };
70
72
  }
73
+ function filesFor(testCase, files, workdirs) {
74
+ return files.filter((f) => f.repository === testCase.repository || (!testCase.repository && !workdirs[f.repository]));
75
+ }
71
76
  function workdirFor(testCase, workdirs) {
72
77
  if (testCase.repository && workdirs[testCase.repository])
73
78
  return workdirs[testCase.repository];
@@ -81,6 +86,46 @@ function summarize(status, steps, notes) {
81
86
  return `All ${steps.length} step${steps.length === 1 ? '' : 's'} passed.`;
82
87
  return status === 'passed' ? 'Passed.' : `The case ${status}.`;
83
88
  }
89
+ /**
90
+ * Before anything runs: the checkouts whose cases need dependencies that aren't installed, said once
91
+ * rather than as a prompt for every case. Quitting is the default; continuing asks for those cases.
92
+ */
93
+ async function checkDependencies(cases, workdirs, io) {
94
+ const missing = new Map();
95
+ for (const testCase of cases) {
96
+ if (testCase.check === 'ui' || !testCase.automatedTestRef)
97
+ continue;
98
+ const cwd = workdirFor(testCase, workdirs);
99
+ if (!cwd)
100
+ continue;
101
+ const workdir = resolve(cwd);
102
+ const command = localizeCommand(testCase.automatedTestRef, workdir);
103
+ const needs = [
104
+ /(^|[\s;&|])(npm|npx|yarn|pnpm)\s|node_modules\/\.bin\//.test(command) &&
105
+ !existsSync(join(workdir, 'node_modules'))
106
+ ? { folder: 'node_modules', setup: 'npm install' }
107
+ : null,
108
+ /(^|[\s/])vendor\/bin\//.test(command) && !existsSync(join(workdir, 'vendor'))
109
+ ? { folder: 'vendor', setup: 'composer install' }
110
+ : null,
111
+ ].filter((need) => need !== null);
112
+ for (const need of needs) {
113
+ const key = `${workdir}\0${need.folder}`;
114
+ const entry = missing.get(key) ?? { setup: `${need.setup} in ${workdir} (there's no ${need.folder})`, ids: [] };
115
+ entry.ids.push(testCase.id);
116
+ missing.set(key, entry);
117
+ }
118
+ }
119
+ if (missing.size === 0)
120
+ return;
121
+ io.out('Some cases need dependencies this checkout doesn\'t have yet:');
122
+ for (const entry of missing.values())
123
+ io.out(` ${entry.setup}: ${entry.ids.join(', ')}`);
124
+ const answer = (await io.ask('[q]uit to install them first, or [c]ontinue and answer those cases yourself: ')).trim().toLowerCase();
125
+ if (!answer.startsWith('c')) {
126
+ throw new CliError(`Install them, then run this again: ${[...missing.values()].map((entry) => entry.setup.split(' (')[0]).join('; ')}.`);
127
+ }
128
+ }
84
129
  /** Runs every case on this machine against the engineer's test server. Nothing here talks to Sifur. */
85
130
  export async function runCases(acceptance, options, deps) {
86
131
  const { io } = deps;
@@ -89,8 +134,14 @@ export async function runCases(acceptance, options, deps) {
89
134
  if (!existsSync(dir) || !statSync(dir).isDirectory())
90
135
  throw new CliError(`--workdir ${repository}=${dir}: no such directory.`);
91
136
  }
137
+ const files = acceptance.files ?? [];
138
+ checkFilePaths(files);
139
+ const placed = new Map();
140
+ let stopRemovingOnExit = null;
141
+ await checkDependencies(acceptance.cases, workdirs, io);
92
142
  const scripted = acceptance.cases.filter((c) => c.check !== 'automated' && (c.uiScript?.length ?? 0) > 0);
93
- const secrets = await collectSecrets(secretNames(scripted), io, deps.env);
143
+ const secrets = await collectSecrets(secretUses(scripted), io, deps.env);
144
+ const runToken = newRunToken();
94
145
  let browser = null;
95
146
  const results = [];
96
147
  try {
@@ -100,15 +151,26 @@ export async function runCases(acceptance, options, deps) {
100
151
  const notes = [];
101
152
  let steps = [];
102
153
  let output;
103
- if (testCase.check === 'ui' || testCase.check === 'both') {
154
+ const withoutLogin = secretUses([testCase])
155
+ .map((use) => use.name)
156
+ .filter((name) => secrets[name] === undefined);
157
+ if ((testCase.check === 'ui' || testCase.check === 'both') && withoutLogin.length > 0) {
158
+ const note = `Not run: no ${withoutLogin.join(' or ')} was given.`;
159
+ io.out(` skipped: ${note}`);
160
+ statuses.push('skipped');
161
+ notes.push(note);
162
+ }
163
+ else if (testCase.check === 'ui' || testCase.check === 'both') {
104
164
  if (scripted.includes(testCase)) {
105
165
  browser ??= await (deps.launch ?? launchBrowser)({ headed: options.headed });
106
166
  const ui = await runUiCase(browser, testCase, {
107
167
  baseUrl: options.baseUrl,
108
168
  outDir: options.outDir,
109
169
  secrets,
170
+ runToken,
110
171
  judge: judgeWith(io),
111
172
  timeoutMs: deps.timeoutMs,
173
+ onStep: (n, caption) => io.out(` … ${n}. ${caption}`),
112
174
  });
113
175
  for (const step of ui.steps)
114
176
  io.out(` ${step.ok ? 'ok ' : 'FAIL'} ${step.n}. ${step.caption}${step.error ? `: ${step.error}` : ''}`);
@@ -127,9 +189,24 @@ export async function runCases(acceptance, options, deps) {
127
189
  const cwd = workdirFor(testCase, workdirs);
128
190
  let run;
129
191
  if (testCase.automatedTestRef && cwd) {
130
- io.out(` $ ${testCase.automatedTestRef} (in ${cwd})`);
131
- run = await runAutomated(testCase.automatedTestRef, resolve(cwd), { echo: deps.echo });
132
- run = { ...run, note: redact(run.note, secrets), output: redact(run.output ?? '', secrets) };
192
+ const workdir = resolve(cwd);
193
+ const key = `${testCase.repository ?? ''}\0${workdir}`;
194
+ const needed = filesFor(testCase, files, workdirs);
195
+ if (!placed.has(key) && needed.length > 0) {
196
+ stopRemovingOnExit ??= removeOnExit(() => [...placed.values()]);
197
+ placed.set(key, placeTestFiles(workdir, needed));
198
+ }
199
+ const command = localizeCommand(testCase.automatedTestRef, workdir);
200
+ io.out(` $ ${command} (in ${cwd})`);
201
+ run = await runAutomated(command, workdir, { echo: deps.echo });
202
+ if (run.exitCode !== undefined && run.exitCode !== null && COULD_NOT_RUN.has(run.exitCode)) {
203
+ io.out(` ${missingProgram(command, run.exitCode, workdir, run.output ?? '')}`);
204
+ const answer = await askEngineer(io, testCase, "The test couldn't run here, so it isn't recorded as failed");
205
+ run = { status: answer.status, note: answer.note };
206
+ }
207
+ else {
208
+ run = { ...run, note: redact(run.note, secrets), output: redact(run.output ?? '', secrets) };
209
+ }
133
210
  }
134
211
  else {
135
212
  const why = !testCase.automatedTestRef
@@ -159,6 +236,12 @@ export async function runCases(acceptance, options, deps) {
159
236
  }
160
237
  finally {
161
238
  await browser?.close().catch(() => { });
239
+ for (const files of placed.values()) {
240
+ files.remove();
241
+ const count = `${files.count} test file${files.count === 1 ? '' : 's'}`;
242
+ io.out(`Put ${count} that Sifur keeps out of the repository in ${files.workdir} for the run, and removed ${files.count === 1 ? 'it' : 'them'} again.`);
243
+ }
244
+ stopRemovingOnExit?.();
162
245
  }
163
246
  return { baseUrl: options.baseUrl, ranAt: new Date().toISOString(), results };
164
247
  }
@@ -210,7 +293,7 @@ export function printSummary(io, results) {
210
293
  for (const result of results)
211
294
  io.out(` ${result.status.padEnd(7)} ${result.id}`);
212
295
  const overall = outcome(results);
213
- const counts = ['passed', 'failed', 'blocked']
296
+ const counts = ['passed', 'failed', 'blocked', 'skipped']
214
297
  .map((status) => [status, results.filter((r) => r.status === status).length])
215
298
  .filter(([, count]) => count > 0)
216
299
  .map(([status, count]) => `${count} ${status}`);
@@ -223,11 +306,19 @@ export function ticketLink(consoleOrigin, ticketUuid) {
223
306
  /** The whole `accept` command; returns the exit code (0 all passed, 1 any not passed). Errors throw, for exit 2. */
224
307
  export async function accept(api, args, deps) {
225
308
  const { io } = deps;
309
+ if (args.apply) {
310
+ if (args.mode === 'run' || args.mode === 'upload')
311
+ throw new CliError(`--apply needs Sifur, so it can't go with --${args.mode}.`);
312
+ if (!api)
313
+ throw new CliError('Run `sifur login` first.');
314
+ await applyChanges(api, args.ref, { workdirs: args.workdirs, cwd: args.cwd, store: args.store }, io);
315
+ io.out();
316
+ }
226
317
  if (args.mode === 'download') {
227
318
  if (!api)
228
319
  throw new CliError('Run `sifur login` first.');
229
320
  const saved = await download(api, args.ref);
230
- const dir = await saveCases(args.cwd, saved);
321
+ const dir = await saveCases(args.store, saved);
231
322
  describeDeploy(saved.acceptance, io);
232
323
  io.out(`Saved to ${dir}. Run \`sifur accept ${saved.acceptance.ticket.key} --run --url <test url>\` next.`);
233
324
  return 0;
@@ -235,22 +326,26 @@ export async function accept(api, args, deps) {
235
326
  if (args.mode === 'upload') {
236
327
  if (!api)
237
328
  throw new CliError('Run `sifur login` first.');
238
- const dir = await findTicketDir(args.cwd, args.ref);
329
+ const dir = await findTicketDir(args.store, args.ref);
239
330
  if (!dir)
240
- throw new CliError(`Nothing downloaded for ${args.ref} in ${join(args.cwd, '.sifur')}.`);
331
+ throw new CliError(`Nothing downloaded for ${args.ref} in ${args.store}.`);
241
332
  const saved = await loadCases(dir);
242
333
  if (saved.api !== api.base)
243
334
  throw new CliError(`${args.ref} was downloaded from ${saved.api}, but you're signed in to ${api.base}.`);
244
335
  const results = await loadResults(dir);
245
- return finish(api, saved, results, args, io);
336
+ return finish(api, saved, results, dir, args, io);
246
337
  }
247
338
  if (!args.url)
248
339
  throw new CliError('--url <test url> is required: the test server the branch is deployed to.');
249
340
  const baseUrl = parseBaseUrl(args.url);
341
+ const parsedUrl = new URL(baseUrl);
342
+ if (parsedUrl.pathname !== '/' || parsedUrl.search || parsedUrl.hash) {
343
+ io.out(`Note: the cases' paths are opened under ${baseUrl}, so the root of the site (${parsedUrl.origin}/) is usually what's wanted.`);
344
+ }
250
345
  let saved;
251
346
  let dir;
252
347
  if (args.mode === 'run') {
253
- const found = await findTicketDir(args.cwd, args.ref);
348
+ const found = await findTicketDir(args.store, args.ref);
254
349
  if (!found)
255
350
  throw new CliError(`Nothing downloaded for ${args.ref}. Run with --download first.`);
256
351
  dir = found;
@@ -260,26 +355,34 @@ export async function accept(api, args, deps) {
260
355
  if (!api)
261
356
  throw new CliError('Run `sifur login` first.');
262
357
  saved = await download(api, args.ref);
263
- dir = await saveCases(args.cwd, saved);
358
+ dir = await saveCases(args.store, saved);
264
359
  }
265
360
  describeDeploy(saved.acceptance, io);
266
- const results = await runCases(saved.acceptance, { baseUrl, outDir: join(dir, 'screenshots'), headed: args.headed, workdirs: args.workdirs }, deps);
361
+ const { workdirs, chosen } = defaultWorkdirs(saved.acceptance.cases, args.workdirs ?? {}, gitTopLevel(args.cwd));
362
+ if (chosen)
363
+ io.out(`Running ${chosen.repository}'s automated cases in ${chosen.dir} (the checkout you're in). Use --workdir ${chosen.repository}=<dir> for another.`);
364
+ io.out(`Results and screenshots are kept in ${dir} until they're uploaded.`);
365
+ io.out();
366
+ const results = await runCases(saved.acceptance, { baseUrl, outDir: join(dir, 'screenshots'), headed: args.headed, workdirs }, deps);
267
367
  const resultsPath = await saveResults(dir, results);
268
368
  if (args.mode === 'run') {
269
369
  const overall = printSummary(io, results.results);
270
370
  io.out(`Saved to ${resultsPath}. Run \`sifur accept ${saved.acceptance.ticket.key} --upload\` to send them.`);
271
371
  return overall === 'passed' ? 0 : 1;
272
372
  }
373
+ return finish(api, saved, results, dir, args, io);
374
+ }
375
+ /** Uploads the results; once Sifur has them, nothing kept for the ticket is needed any more. */
376
+ async function finish(api, saved, results, dir, args, io) {
377
+ let response;
273
378
  try {
274
- return await finish(api, saved, results, args, io);
379
+ response = await upload(api, saved, results, io);
275
380
  }
276
381
  catch (error) {
277
- io.out(`The results are saved in ${resultsPath}; send them later with \`sifur accept ${saved.acceptance.ticket.key} --upload\`.`);
382
+ io.out(`The results are kept in ${dir}; send them later with \`sifur accept ${saved.acceptance.ticket.key} --upload\`.`);
278
383
  throw error;
279
384
  }
280
- }
281
- async function finish(api, saved, results, args, io) {
282
- const response = await upload(api, saved, results, io);
385
+ await removeTicketFiles(args.store, dir);
283
386
  io.out();
284
387
  const overall = printSummary(io, results.results);
285
388
  if (response?.run)
@@ -289,6 +392,7 @@ async function finish(api, saved, results, args, io) {
289
392
  const link = ticketLink(args.consoleOrigin, saved.acceptance.ticket.uuid);
290
393
  if (link)
291
394
  io.out(link);
395
+ io.out(`Cleaned up: removed the cases, results and screenshots kept in ${dir}.`);
292
396
  return overall === 'passed' ? 0 : 1;
293
397
  }
294
398
  export function parseBaseUrl(url) {
package/dist/api.js CHANGED
@@ -3,11 +3,19 @@ export class ApiError extends Error {
3
3
  status;
4
4
  code;
5
5
  retryAfterSeconds;
6
- constructor(message, status, code = null, retryAfterSeconds = null) {
6
+ json;
7
+ fromSifur;
8
+ constructor(message, status, code = null, retryAfterSeconds = null,
9
+ /** False when the body wasn't JSON at all: an HTML error page, say, from something that isn't Sifur. */
10
+ json = true,
11
+ /** False when `message` is ours, made up from the status, rather than Sifur's own words. */
12
+ fromSifur = true) {
7
13
  super(message);
8
14
  this.status = status;
9
15
  this.code = code;
10
16
  this.retryAfterSeconds = retryAfterSeconds;
17
+ this.json = json;
18
+ this.fromSifur = fromSifur;
11
19
  this.name = 'ApiError';
12
20
  }
13
21
  }
@@ -18,6 +26,19 @@ export class CliError extends Error {
18
26
  this.name = 'CliError';
19
27
  }
20
28
  }
29
+ const TLS_CODES = new Set([
30
+ 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
31
+ 'UNABLE_TO_GET_ISSUER_CERT',
32
+ 'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
33
+ 'SELF_SIGNED_CERT_IN_CHAIN',
34
+ 'DEPTH_ZERO_SELF_SIGNED_CERT',
35
+ ]);
36
+ export const TLS_HINT = 'If this is a local or company Sifur with its own certificate, run with NODE_OPTIONS=--use-system-ca (Node 22.15+) or NODE_EXTRA_CA_CERTS=<root cert>.';
37
+ export function isTlsError(cause) {
38
+ if (!cause)
39
+ return false;
40
+ return TLS_CODES.has(cause.code ?? '') || /local issuer certificate|self[- ]signed certificate/i.test(cause.message ?? '');
41
+ }
21
42
  export function normalizeApi(api) {
22
43
  return api.replace(/\/+$/, '');
23
44
  }
@@ -56,23 +77,31 @@ export class Api {
56
77
  });
57
78
  }
58
79
  catch (error) {
59
- const cause = error.cause?.message;
60
- throw new CliError(`Couldn't reach Sifur at ${this.base}${cause ? ` (${cause})` : ''}.`);
80
+ const cause = error.cause;
81
+ const reason = cause?.message ? ` (${cause.message})` : '';
82
+ throw new CliError(`Couldn't reach Sifur at ${this.base}${reason}.${isTlsError(cause) ? `\n${TLS_HINT}` : ''}`);
61
83
  }
62
84
  const text = await response.text();
63
85
  let json = null;
86
+ let isJson = true;
64
87
  try {
65
88
  json = text ? JSON.parse(text) : null;
66
89
  }
67
90
  catch {
68
91
  json = null;
92
+ isJson = false;
69
93
  }
94
+ const status = response.status;
95
+ // undici calls a status it has no name for, like Laravel's 419, "unknown status".
96
+ const statusText = response.statusText && response.statusText !== 'unknown status' ? ` ${response.statusText}` : '';
70
97
  if (!response.ok) {
71
- const message = json?.message?.messages?.[0] ?? `Sifur answered ${response.status} ${response.statusText}`.trim();
98
+ const said = json?.message?.messages?.[0];
72
99
  const code = json?.code ?? json?.data?.code ?? null;
73
100
  const retryAfter = Number(response.headers.get('retry-after'));
74
- throw new ApiError(message, response.status, code, Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : null);
101
+ throw new ApiError(said ?? `Sifur answered ${status}${statusText}.`, status, code, Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : null, isJson, said !== undefined);
75
102
  }
103
+ if (!isJson)
104
+ throw new ApiError(`Sifur answered ${status}${statusText}, but not with JSON.`, status, null, null, false, false);
76
105
  return (json?.data ?? null);
77
106
  }
78
107
  }