@sifurai/cli 0.1.0 → 0.1.1
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 +115 -20
- 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/runner.js +83 -18
- package/dist/secrets.js +63 -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 {
|
|
@@ -107,8 +158,10 @@ export async function runCases(acceptance, options, deps) {
|
|
|
107
158
|
baseUrl: options.baseUrl,
|
|
108
159
|
outDir: options.outDir,
|
|
109
160
|
secrets,
|
|
161
|
+
runToken,
|
|
110
162
|
judge: judgeWith(io),
|
|
111
163
|
timeoutMs: deps.timeoutMs,
|
|
164
|
+
onStep: (n, caption) => io.out(` … ${n}. ${caption}`),
|
|
112
165
|
});
|
|
113
166
|
for (const step of ui.steps)
|
|
114
167
|
io.out(` ${step.ok ? 'ok ' : 'FAIL'} ${step.n}. ${step.caption}${step.error ? `: ${step.error}` : ''}`);
|
|
@@ -127,9 +180,24 @@ export async function runCases(acceptance, options, deps) {
|
|
|
127
180
|
const cwd = workdirFor(testCase, workdirs);
|
|
128
181
|
let run;
|
|
129
182
|
if (testCase.automatedTestRef && cwd) {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
183
|
+
const workdir = resolve(cwd);
|
|
184
|
+
const key = `${testCase.repository ?? ''}\0${workdir}`;
|
|
185
|
+
const needed = filesFor(testCase, files, workdirs);
|
|
186
|
+
if (!placed.has(key) && needed.length > 0) {
|
|
187
|
+
stopRemovingOnExit ??= removeOnExit(() => [...placed.values()]);
|
|
188
|
+
placed.set(key, placeTestFiles(workdir, needed));
|
|
189
|
+
}
|
|
190
|
+
const command = localizeCommand(testCase.automatedTestRef, workdir);
|
|
191
|
+
io.out(` $ ${command} (in ${cwd})`);
|
|
192
|
+
run = await runAutomated(command, workdir, { echo: deps.echo });
|
|
193
|
+
if (run.exitCode !== undefined && run.exitCode !== null && COULD_NOT_RUN.has(run.exitCode)) {
|
|
194
|
+
io.out(` ${missingProgram(command, run.exitCode, workdir, run.output ?? '')}`);
|
|
195
|
+
const answer = await askEngineer(io, testCase, "The test couldn't run here, so it isn't recorded as failed");
|
|
196
|
+
run = { status: answer.status, note: answer.note };
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
run = { ...run, note: redact(run.note, secrets), output: redact(run.output ?? '', secrets) };
|
|
200
|
+
}
|
|
133
201
|
}
|
|
134
202
|
else {
|
|
135
203
|
const why = !testCase.automatedTestRef
|
|
@@ -159,6 +227,12 @@ export async function runCases(acceptance, options, deps) {
|
|
|
159
227
|
}
|
|
160
228
|
finally {
|
|
161
229
|
await browser?.close().catch(() => { });
|
|
230
|
+
for (const files of placed.values()) {
|
|
231
|
+
files.remove();
|
|
232
|
+
const count = `${files.count} test file${files.count === 1 ? '' : 's'}`;
|
|
233
|
+
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.`);
|
|
234
|
+
}
|
|
235
|
+
stopRemovingOnExit?.();
|
|
162
236
|
}
|
|
163
237
|
return { baseUrl: options.baseUrl, ranAt: new Date().toISOString(), results };
|
|
164
238
|
}
|
|
@@ -223,11 +297,19 @@ export function ticketLink(consoleOrigin, ticketUuid) {
|
|
|
223
297
|
/** The whole `accept` command; returns the exit code (0 all passed, 1 any not passed). Errors throw, for exit 2. */
|
|
224
298
|
export async function accept(api, args, deps) {
|
|
225
299
|
const { io } = deps;
|
|
300
|
+
if (args.apply) {
|
|
301
|
+
if (args.mode === 'run' || args.mode === 'upload')
|
|
302
|
+
throw new CliError(`--apply needs Sifur, so it can't go with --${args.mode}.`);
|
|
303
|
+
if (!api)
|
|
304
|
+
throw new CliError('Run `sifur login` first.');
|
|
305
|
+
await applyChanges(api, args.ref, { workdirs: args.workdirs, cwd: args.cwd, store: args.store }, io);
|
|
306
|
+
io.out();
|
|
307
|
+
}
|
|
226
308
|
if (args.mode === 'download') {
|
|
227
309
|
if (!api)
|
|
228
310
|
throw new CliError('Run `sifur login` first.');
|
|
229
311
|
const saved = await download(api, args.ref);
|
|
230
|
-
const dir = await saveCases(args.
|
|
312
|
+
const dir = await saveCases(args.store, saved);
|
|
231
313
|
describeDeploy(saved.acceptance, io);
|
|
232
314
|
io.out(`Saved to ${dir}. Run \`sifur accept ${saved.acceptance.ticket.key} --run --url <test url>\` next.`);
|
|
233
315
|
return 0;
|
|
@@ -235,22 +317,26 @@ export async function accept(api, args, deps) {
|
|
|
235
317
|
if (args.mode === 'upload') {
|
|
236
318
|
if (!api)
|
|
237
319
|
throw new CliError('Run `sifur login` first.');
|
|
238
|
-
const dir = await findTicketDir(args.
|
|
320
|
+
const dir = await findTicketDir(args.store, args.ref);
|
|
239
321
|
if (!dir)
|
|
240
|
-
throw new CliError(`Nothing downloaded for ${args.ref} in ${
|
|
322
|
+
throw new CliError(`Nothing downloaded for ${args.ref} in ${args.store}.`);
|
|
241
323
|
const saved = await loadCases(dir);
|
|
242
324
|
if (saved.api !== api.base)
|
|
243
325
|
throw new CliError(`${args.ref} was downloaded from ${saved.api}, but you're signed in to ${api.base}.`);
|
|
244
326
|
const results = await loadResults(dir);
|
|
245
|
-
return finish(api, saved, results, args, io);
|
|
327
|
+
return finish(api, saved, results, dir, args, io);
|
|
246
328
|
}
|
|
247
329
|
if (!args.url)
|
|
248
330
|
throw new CliError('--url <test url> is required: the test server the branch is deployed to.');
|
|
249
331
|
const baseUrl = parseBaseUrl(args.url);
|
|
332
|
+
const parsedUrl = new URL(baseUrl);
|
|
333
|
+
if (parsedUrl.pathname !== '/' || parsedUrl.search || parsedUrl.hash) {
|
|
334
|
+
io.out(`Note: the cases' paths are opened under ${baseUrl}, so the root of the site (${parsedUrl.origin}/) is usually what's wanted.`);
|
|
335
|
+
}
|
|
250
336
|
let saved;
|
|
251
337
|
let dir;
|
|
252
338
|
if (args.mode === 'run') {
|
|
253
|
-
const found = await findTicketDir(args.
|
|
339
|
+
const found = await findTicketDir(args.store, args.ref);
|
|
254
340
|
if (!found)
|
|
255
341
|
throw new CliError(`Nothing downloaded for ${args.ref}. Run with --download first.`);
|
|
256
342
|
dir = found;
|
|
@@ -260,26 +346,34 @@ export async function accept(api, args, deps) {
|
|
|
260
346
|
if (!api)
|
|
261
347
|
throw new CliError('Run `sifur login` first.');
|
|
262
348
|
saved = await download(api, args.ref);
|
|
263
|
-
dir = await saveCases(args.
|
|
349
|
+
dir = await saveCases(args.store, saved);
|
|
264
350
|
}
|
|
265
351
|
describeDeploy(saved.acceptance, io);
|
|
266
|
-
const
|
|
352
|
+
const { workdirs, chosen } = defaultWorkdirs(saved.acceptance.cases, args.workdirs ?? {}, gitTopLevel(args.cwd));
|
|
353
|
+
if (chosen)
|
|
354
|
+
io.out(`Running ${chosen.repository}'s automated cases in ${chosen.dir} (the checkout you're in). Use --workdir ${chosen.repository}=<dir> for another.`);
|
|
355
|
+
io.out(`Results and screenshots are kept in ${dir} until they're uploaded.`);
|
|
356
|
+
io.out();
|
|
357
|
+
const results = await runCases(saved.acceptance, { baseUrl, outDir: join(dir, 'screenshots'), headed: args.headed, workdirs }, deps);
|
|
267
358
|
const resultsPath = await saveResults(dir, results);
|
|
268
359
|
if (args.mode === 'run') {
|
|
269
360
|
const overall = printSummary(io, results.results);
|
|
270
361
|
io.out(`Saved to ${resultsPath}. Run \`sifur accept ${saved.acceptance.ticket.key} --upload\` to send them.`);
|
|
271
362
|
return overall === 'passed' ? 0 : 1;
|
|
272
363
|
}
|
|
364
|
+
return finish(api, saved, results, dir, args, io);
|
|
365
|
+
}
|
|
366
|
+
/** Uploads the results; once Sifur has them, nothing kept for the ticket is needed any more. */
|
|
367
|
+
async function finish(api, saved, results, dir, args, io) {
|
|
368
|
+
let response;
|
|
273
369
|
try {
|
|
274
|
-
|
|
370
|
+
response = await upload(api, saved, results, io);
|
|
275
371
|
}
|
|
276
372
|
catch (error) {
|
|
277
|
-
io.out(`The results are
|
|
373
|
+
io.out(`The results are kept in ${dir}; send them later with \`sifur accept ${saved.acceptance.ticket.key} --upload\`.`);
|
|
278
374
|
throw error;
|
|
279
375
|
}
|
|
280
|
-
|
|
281
|
-
async function finish(api, saved, results, args, io) {
|
|
282
|
-
const response = await upload(api, saved, results, io);
|
|
376
|
+
await removeTicketFiles(args.store, dir);
|
|
283
377
|
io.out();
|
|
284
378
|
const overall = printSummary(io, results.results);
|
|
285
379
|
if (response?.run)
|
|
@@ -289,6 +383,7 @@ async function finish(api, saved, results, args, io) {
|
|
|
289
383
|
const link = ticketLink(args.consoleOrigin, saved.acceptance.ticket.uuid);
|
|
290
384
|
if (link)
|
|
291
385
|
io.out(link);
|
|
386
|
+
io.out(`Cleaned up: removed the cases, results and screenshots kept in ${dir}.`);
|
|
292
387
|
return overall === 'passed' ? 0 : 1;
|
|
293
388
|
}
|
|
294
389
|
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
|
}
|
package/dist/apply.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, statSync } from 'node:fs';
|
|
3
|
+
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { ApiError, CliError } from './api.js';
|
|
6
|
+
import { parseTicketRef, resolveTicket } from './resolve.js';
|
|
7
|
+
import { APPLIED, pruneTicketDir, ticketDir } from './store.js';
|
|
8
|
+
import { defaultWorkdirFor, gitTopLevel } from './workspace.js';
|
|
9
|
+
const INDEX = 'checkouts.json';
|
|
10
|
+
function git(dir, args, input) {
|
|
11
|
+
const result = spawnSync('git', args, { cwd: dir, input, encoding: 'utf8', maxBuffer: 1024 * 1024 * 1024 });
|
|
12
|
+
if (result.error)
|
|
13
|
+
throw new CliError(`Couldn't run git in ${dir}: ${result.error.message}`);
|
|
14
|
+
return { ok: result.status === 0, stdout: result.stdout ?? '', stderr: (result.stderr ?? '').trim() };
|
|
15
|
+
}
|
|
16
|
+
const patchFile = (repository) => `${repository.replace(/[^A-Za-z0-9_.-]/g, '_')}.patch`;
|
|
17
|
+
function filesIn(dir, patch) {
|
|
18
|
+
const numstat = git(dir, ['apply', '--numstat'], patch);
|
|
19
|
+
return numstat.ok ? numstat.stdout.split('\n').filter(Boolean).length : 0;
|
|
20
|
+
}
|
|
21
|
+
async function readIndex(dir) {
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(await readFile(join(dir, INDEX), 'utf8'));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** A key names its folder; a uuid is looked for among the tickets with applied changes. */
|
|
30
|
+
async function findAppliedDir(store, ref) {
|
|
31
|
+
const direct = join(ticketDir(store, ref.toUpperCase()), APPLIED);
|
|
32
|
+
if (await readIndex(direct))
|
|
33
|
+
return direct;
|
|
34
|
+
let entries;
|
|
35
|
+
try {
|
|
36
|
+
entries = await readdir(store);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
const dir = join(store, entry, APPLIED);
|
|
43
|
+
if ((await readIndex(dir))?.ticketUuid === ref.toLowerCase())
|
|
44
|
+
return dir;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Puts Sifur's unpushed work for a ticket in the engineer's checkouts, uncommitted, so they can deploy it.
|
|
50
|
+
* Every checkout is checked before any patch is applied.
|
|
51
|
+
*/
|
|
52
|
+
export async function applyChanges(api, ref, options, io) {
|
|
53
|
+
const { ticketUuid, key, organization } = await resolveTicket(api, ref);
|
|
54
|
+
let patches;
|
|
55
|
+
try {
|
|
56
|
+
patches = (await api.request(`tickets/${ticketUuid}/changes/patch`, { organization: organization.uuid })) ?? [];
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
if (error instanceof ApiError && [404, 409].includes(error.status))
|
|
60
|
+
throw new CliError(`Sifur has no changes for ${ref} to apply: ${error.message}`);
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
if (patches.length === 0)
|
|
64
|
+
io.out(`${ref}: nothing to apply.`);
|
|
65
|
+
const withChanges = patches.filter((p) => p.patch.trim() !== '');
|
|
66
|
+
for (const p of patches)
|
|
67
|
+
if (!withChanges.includes(p))
|
|
68
|
+
io.out(`${p.repository}: nothing to apply.`);
|
|
69
|
+
if (withChanges.length === 0)
|
|
70
|
+
return;
|
|
71
|
+
const appliedDir = join(ticketDir(options.store, key ?? ('uuid' in parseTicketRef(ref) ? ref : ref.toUpperCase())), APPLIED);
|
|
72
|
+
const index = (await readIndex(appliedDir)) ?? { ticketUuid, checkouts: {} };
|
|
73
|
+
let workdirs = options.workdirs ?? {};
|
|
74
|
+
const named = new Set(patches.map((p) => p.repository));
|
|
75
|
+
const top = gitTopLevel(options.cwd);
|
|
76
|
+
for (const p of withChanges) {
|
|
77
|
+
const picked = defaultWorkdirFor(named, [p.repository], workdirs, top);
|
|
78
|
+
if (picked.chosen)
|
|
79
|
+
io.out(`Applying ${picked.chosen.repository}'s changes in ${picked.chosen.dir} (the checkout you're in).`);
|
|
80
|
+
workdirs = picked.workdirs;
|
|
81
|
+
}
|
|
82
|
+
const plan = [];
|
|
83
|
+
for (const p of withChanges) {
|
|
84
|
+
const given = workdirs[p.repository];
|
|
85
|
+
if (!given)
|
|
86
|
+
throw new CliError(`No checkout of ${p.repository}: run this from inside it, or pass --workdir ${p.repository}=<dir>.`);
|
|
87
|
+
const dir = resolve(given);
|
|
88
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory())
|
|
89
|
+
throw new CliError(`--workdir ${p.repository}=${given}: no such directory.`);
|
|
90
|
+
const saved = join(appliedDir, patchFile(p.repository));
|
|
91
|
+
if (existsSync(saved)) {
|
|
92
|
+
throw new CliError(`Sifur's changes to ${p.repository} are already applied. Run \`sifur apply ${key ?? ref} --revert\` first, or delete ${saved} if you've taken them out yourself.`);
|
|
93
|
+
}
|
|
94
|
+
const status = git(dir, ['status', '--porcelain', '--untracked-files=no']);
|
|
95
|
+
if (!status.ok)
|
|
96
|
+
throw new CliError(`${dir} isn't a git checkout: ${status.stderr}`);
|
|
97
|
+
if (status.stdout.trim())
|
|
98
|
+
throw new CliError(`${dir} has uncommitted changes. Commit or stash them, then run this again.`);
|
|
99
|
+
if (!git(dir, ['cat-file', '-e', `${p.base}^{commit}`]).ok) {
|
|
100
|
+
throw new CliError(`${dir} doesn't have ${p.base.slice(0, 12)}, the commit Sifur's changes to ${p.repository} start from. Run \`git fetch\` and check out ${p.branch} first.`);
|
|
101
|
+
}
|
|
102
|
+
plan.push({ patch: p, dir });
|
|
103
|
+
}
|
|
104
|
+
await mkdir(appliedDir, { recursive: true });
|
|
105
|
+
for (const { patch, dir } of plan) {
|
|
106
|
+
const applied = git(dir, ['apply', '--3way', '--whitespace=nowarn'], patch.patch);
|
|
107
|
+
if (!applied.ok) {
|
|
108
|
+
throw new CliError(`Couldn't apply Sifur's changes to ${patch.repository} in ${dir} cleanly:\n${applied.stderr}\nSee \`git status\` there: resolve the conflicts, or put the checkout back as it was with \`git reset --hard\`.`);
|
|
109
|
+
}
|
|
110
|
+
await writeFile(join(appliedDir, patchFile(patch.repository)), patch.patch);
|
|
111
|
+
index.checkouts[patch.repository] = dir;
|
|
112
|
+
await writeFile(join(appliedDir, INDEX), `${JSON.stringify(index, null, 2)}\n`);
|
|
113
|
+
const count = filesIn(dir, patch.patch);
|
|
114
|
+
io.out(`${patch.repository}: applied Sifur's changes to ${count} file${count === 1 ? '' : 's'} in ${dir} (${patch.head.slice(0, 7)} on ${patch.branch}). Nothing was committed.`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** Takes back out what `applyChanges` put in, from the patches it saved. Needs no connection to Sifur. */
|
|
118
|
+
export async function revertChanges(ref, options, io) {
|
|
119
|
+
const appliedDir = await findAppliedDir(options.store, ref);
|
|
120
|
+
const index = appliedDir ? await readIndex(appliedDir) : null;
|
|
121
|
+
const repositories = Object.keys(index?.checkouts ?? {}).filter((r) => existsSync(join(appliedDir, patchFile(r))));
|
|
122
|
+
if (!appliedDir || !index || repositories.length === 0) {
|
|
123
|
+
io.out(`Nothing from ${ref} is applied.`);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
for (const repository of repositories) {
|
|
127
|
+
const dir = resolve(options.workdirs?.[repository] ?? index.checkouts[repository]);
|
|
128
|
+
const saved = join(appliedDir, patchFile(repository));
|
|
129
|
+
const patch = await readFile(saved, 'utf8');
|
|
130
|
+
const reverted = git(dir, ['apply', '-R', '--3way', '--whitespace=nowarn'], patch);
|
|
131
|
+
if (!reverted.ok) {
|
|
132
|
+
throw new CliError(`Couldn't take Sifur's changes to ${repository} back out of ${dir}:\n${reverted.stderr}\nThe patch is kept in ${saved}.`);
|
|
133
|
+
}
|
|
134
|
+
await rm(saved);
|
|
135
|
+
delete index.checkouts[repository];
|
|
136
|
+
await writeFile(join(appliedDir, INDEX), `${JSON.stringify(index, null, 2)}\n`);
|
|
137
|
+
io.out(`${repository}: took Sifur's changes back out of ${dir}.`);
|
|
138
|
+
}
|
|
139
|
+
if (Object.keys(index.checkouts).length === 0) {
|
|
140
|
+
await rm(appliedDir, { recursive: true, force: true });
|
|
141
|
+
await pruneTicketDir(options.store, dirname(appliedDir));
|
|
142
|
+
}
|
|
143
|
+
}
|