@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 +114 -19
- package/dist/accept.js +126 -22
- package/dist/api.js +34 -5
- package/dist/apply.js +143 -0
- package/dist/automated.js +35 -0
- package/dist/bin.js +0 -0
- package/dist/cli.js +55 -17
- package/dist/config.js +6 -0
- package/dist/login.js +16 -5
- package/dist/resolve.js +2 -1
- package/dist/results.js +5 -1
- package/dist/runner.js +83 -18
- package/dist/secrets.js +66 -12
- package/dist/store.js +25 -6
- package/dist/workspace.js +142 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,19 +16,22 @@ the results and screenshots go to Sifur.
|
|
|
16
16
|
|
|
17
17
|
## Install
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
Install it once to get the `sifur` command:
|
|
20
20
|
|
|
21
21
|
```sh
|
|
22
|
-
|
|
22
|
+
npm install -g @sifurai/cli
|
|
23
|
+
sifur --version
|
|
23
24
|
```
|
|
24
25
|
|
|
25
|
-
|
|
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
|
|
70
|
-
for
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
|
104
|
-
results next to them, and `--upload` sends them
|
|
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 `{{
|
|
109
|
-
account's
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
| `--
|
|
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
|
-
| `--
|
|
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 {
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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.
|
|
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.
|
|
329
|
+
const dir = await findTicketDir(args.store, args.ref);
|
|
239
330
|
if (!dir)
|
|
240
|
-
throw new CliError(`Nothing downloaded for ${args.ref} in ${
|
|
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.
|
|
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.
|
|
358
|
+
dir = await saveCases(args.store, saved);
|
|
264
359
|
}
|
|
265
360
|
describeDeploy(saved.acceptance, io);
|
|
266
|
-
const
|
|
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
|
-
|
|
379
|
+
response = await upload(api, saved, results, io);
|
|
275
380
|
}
|
|
276
381
|
catch (error) {
|
|
277
|
-
io.out(`The results are
|
|
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
|
-
|
|
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
|
|
60
|
-
|
|
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
|
|
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(
|
|
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
|
}
|