@sifurai/cli 0.0.0-stage → 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 CHANGED
@@ -1,3 +1,229 @@
1
- # Temporary Holding Version
1
+ # Sifur CLI
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Run a Sifur ticket's acceptance cases against your own test server, from your own machine, and
4
+ send the results back to Sifur.
5
+
6
+ Use it when your app only runs on your team's servers, behind a firewall or VPN that Sifur can't
7
+ reach. Sifur never deploys to your server, never holds VPN or server credentials, and never connects
8
+ into your network: you deploy the ticket's branch, this tool runs the cases on your machine, and only
9
+ the results and screenshots go to Sifur.
10
+
11
+ ## Requirements
12
+
13
+ - Node.js 20 or newer
14
+ - Google Chrome or Microsoft Edge (the tool drives the browser you already have; it downloads none)
15
+ - A machine that can reach your test server (usually: on your company VPN)
16
+
17
+ ## Install
18
+
19
+ Install it once to get the `sifur` command:
20
+
21
+ ```sh
22
+ npm install -g @sifurai/cli
23
+ sifur --version
24
+ ```
25
+
26
+ Update it with:
27
+
28
+ ```sh
29
+ npm install -g @sifurai/cli@latest
30
+ ```
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
+
35
+ ## Sign in
36
+
37
+ ```sh
38
+ sifur login
39
+ ```
40
+
41
+ This prints a code and opens Sifur in your browser. Check that the code matches, approve it, and
42
+ the CLI is signed in. The sign-in is listed with your other sessions in Sifur, where you can revoke it.
43
+ It follows your organization's single sign-on and MFA rules, like the console.
44
+
45
+ The token is saved with owner-only permissions (0600) in:
46
+
47
+ - macOS and Linux: `~/.config/sifur/credentials.json` (or `$XDG_CONFIG_HOME/sifur`)
48
+ - Windows: `%APPDATA%\sifur\credentials.json`
49
+
50
+ To sign out (and revoke the session):
51
+
52
+ ```sh
53
+ sifur logout
54
+ ```
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
+
86
+ ## Run a ticket's acceptance cases
87
+
88
+ When a ticket is in **Acceptance testing**, its Overview shows the branch and the commit of each
89
+ repository to deploy. Deploy them to your test server, then run:
90
+
91
+ ```sh
92
+ sifur accept AZA-12 --url https://test.example.internal
93
+ ```
94
+
95
+ `AZA-12` is the ticket's key; its uuid works too. The CLI:
96
+
97
+ 1. Finds the ticket in your organizations and downloads its cases.
98
+ 2. Runs each UI case in a headless browser against `--url` (paths in the cases are relative to
99
+ it), taking a screenshot after every step. Add `--headed` to watch.
100
+ 3. For a step that needs a person's eye, shows you the screenshot's path and asks whether it looks
101
+ right.
102
+ 4. Runs automated cases in your checkout of their repository (see below); without one, asks you
103
+ for the result.
104
+ 5. For a case with no script, shows its steps and expected result and asks you for the result:
105
+ passed, failed or blocked, with a note.
106
+ 6. Uploads the screenshots and results, prints a summary, and links to the ticket.
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
+
135
+ ```sh
136
+ sifur accept AZA-12 --url https://test.example.internal \
137
+ --workdir api=~/src/azalea-api --workdir web=~/src/azalea-web
138
+ ```
139
+
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.
144
+
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.
155
+
156
+ ## VPN: split tunnel or full tunnel
157
+
158
+ With a **split-tunnel** VPN (only company traffic goes through it), one command does everything:
159
+ your machine reaches the test server and Sifur at the same time.
160
+
161
+ With a **full-tunnel** VPN that blocks the internet while it's up, work in three steps:
162
+
163
+ ```sh
164
+ # VPN down
165
+ sifur accept AZA-12 --download
166
+
167
+ # VPN up: runs the cases, talks only to your test server
168
+ sifur accept AZA-12 --run --url https://test.example.internal
169
+
170
+ # VPN down
171
+ sifur accept AZA-12 --upload
172
+ ```
173
+
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.
179
+
180
+ ## Secrets
181
+
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.
194
+
195
+ ```sh
196
+ SIFUR_SECRET_ADMIN_PASSWORD='…' sifur accept AZA-12 --url https://test.example.internal
197
+ ```
198
+
199
+ ## Options
200
+
201
+ | Option | |
202
+ |---|---|
203
+ | `--url <url>` | Your test server, where the ticket's branch is deployed |
204
+ | `--headed` | Show the browser while the cases run |
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 |
207
+ | `--download` / `--run` / `--upload` | The three steps for a full-tunnel VPN |
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>`.
222
+
223
+ ## Exit codes
224
+
225
+ | Code | Meaning |
226
+ |---|---|
227
+ | 0 | Every case passed (or `--download` finished) |
228
+ | 1 | At least one case failed or was blocked |
229
+ | 2 | An error: not signed in, the ticket isn't in acceptance testing, Sifur unreachable, no Chrome or Edge, … |
package/dist/accept.js ADDED
@@ -0,0 +1,399 @@
1
+ import { existsSync, statSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { basename, extname, join, resolve } from 'node:path';
4
+ import { ApiError, CliError } from './api.js';
5
+ import { applyChanges } from './apply.js';
6
+ import { COULD_NOT_RUN, missingProgram, runAutomated } from './automated.js';
7
+ import { launchBrowser } from './browser.js';
8
+ import { askStatus, confirm } from './io.js';
9
+ import { resolveTicket } from './resolve.js';
10
+ import { buildResultsPayload, combine, outcome } from './results.js';
11
+ import { runUiCase } from './runner.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';
15
+ export const MAX_SCREENSHOT_BYTES = 5 * 1024 * 1024;
16
+ export async function download(api, ref) {
17
+ const { ticketUuid, organization } = await resolveTicket(api, ref);
18
+ try {
19
+ const acceptance = await api.request(`tickets/${ticketUuid}/acceptance`, { organization: organization.uuid });
20
+ return { api: api.base, organizationUuid: organization.uuid, acceptance: { ...acceptance, files: acceptance.files ?? [] } };
21
+ }
22
+ catch (error) {
23
+ if (error instanceof ApiError && error.status === 409) {
24
+ throw new CliError(`${ref} isn't waiting for acceptance testing: ${error.message}`);
25
+ }
26
+ throw error;
27
+ }
28
+ }
29
+ export function describeDeploy(acceptance, io) {
30
+ const { ticket, heads } = acceptance;
31
+ io.out(`${ticket.key}: ${ticket.title}`);
32
+ if (ticket.branch)
33
+ io.out(`Branch: ${ticket.branch}`);
34
+ const repositories = Object.entries(heads ?? {});
35
+ if (repositories.length > 0) {
36
+ io.out('The test server should be running:');
37
+ for (const [repository, sha] of repositories)
38
+ io.out(` ${repository} ${sha}`);
39
+ }
40
+ io.out(`${acceptance.cases.length} case${acceptance.cases.length === 1 ? '' : 's'}`);
41
+ io.out();
42
+ }
43
+ function printCase(io, testCase) {
44
+ for (const [index, step] of (testCase.steps ?? []).entries())
45
+ io.out(` ${index + 1}. ${step}`);
46
+ if (testCase.expected)
47
+ io.out(` Expected: ${testCase.expected}`);
48
+ }
49
+ async function askNote(io, prompt) {
50
+ return (await io.ask(prompt)).trim();
51
+ }
52
+ async function askEngineer(io, testCase, why) {
53
+ io.out(` ${why}. Check it by hand:`);
54
+ printCase(io, testCase);
55
+ if (testCase.automatedTestRef)
56
+ io.out(` Test: ${testCase.automatedTestRef}`);
57
+ const status = await askStatus(io, ' Result');
58
+ const note = await askNote(io, status === 'passed' ? ' Note (optional): ' : ' What happened? ');
59
+ return { status, note };
60
+ }
61
+ function judgeWith(io) {
62
+ return async (step, screenshot, testCase) => {
63
+ io.out(` Judge: ${step.caption ?? 'Does the page look right?'}`);
64
+ if (testCase.expected)
65
+ io.out(` Expected: ${testCase.expected}`);
66
+ io.out(screenshot ? ` Screenshot: ${screenshot}` : ' (No screenshot could be taken.)');
67
+ if (await confirm(io, ' Does it look right?'))
68
+ return { ok: true };
69
+ const note = await askNote(io, " What's wrong? ");
70
+ return { ok: false, note: note || undefined };
71
+ };
72
+ }
73
+ function filesFor(testCase, files, workdirs) {
74
+ return files.filter((f) => f.repository === testCase.repository || (!testCase.repository && !workdirs[f.repository]));
75
+ }
76
+ function workdirFor(testCase, workdirs) {
77
+ if (testCase.repository && workdirs[testCase.repository])
78
+ return workdirs[testCase.repository];
79
+ const all = Object.values(workdirs);
80
+ return !testCase.repository && all.length === 1 ? all[0] : null;
81
+ }
82
+ function summarize(status, steps, notes) {
83
+ if (notes.length > 0)
84
+ return notes.join('\n');
85
+ if (status === 'passed' && steps.length > 0)
86
+ return `All ${steps.length} step${steps.length === 1 ? '' : 's'} passed.`;
87
+ return status === 'passed' ? 'Passed.' : `The case ${status}.`;
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
+ }
129
+ /** Runs every case on this machine against the engineer's test server. Nothing here talks to Sifur. */
130
+ export async function runCases(acceptance, options, deps) {
131
+ const { io } = deps;
132
+ const workdirs = options.workdirs ?? {};
133
+ for (const [repository, dir] of Object.entries(workdirs)) {
134
+ if (!existsSync(dir) || !statSync(dir).isDirectory())
135
+ throw new CliError(`--workdir ${repository}=${dir}: no such directory.`);
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);
142
+ const scripted = acceptance.cases.filter((c) => c.check !== 'automated' && (c.uiScript?.length ?? 0) > 0);
143
+ const secrets = await collectSecrets(secretUses(scripted), io, deps.env);
144
+ const runToken = newRunToken();
145
+ let browser = null;
146
+ const results = [];
147
+ try {
148
+ for (const testCase of acceptance.cases) {
149
+ io.out(`${testCase.id} ${testCase.title}`);
150
+ const statuses = [];
151
+ const notes = [];
152
+ let steps = [];
153
+ let output;
154
+ if (testCase.check === 'ui' || testCase.check === 'both') {
155
+ if (scripted.includes(testCase)) {
156
+ browser ??= await (deps.launch ?? launchBrowser)({ headed: options.headed });
157
+ const ui = await runUiCase(browser, testCase, {
158
+ baseUrl: options.baseUrl,
159
+ outDir: options.outDir,
160
+ secrets,
161
+ runToken,
162
+ judge: judgeWith(io),
163
+ timeoutMs: deps.timeoutMs,
164
+ onStep: (n, caption) => io.out(` … ${n}. ${caption}`),
165
+ });
166
+ for (const step of ui.steps)
167
+ io.out(` ${step.ok ? 'ok ' : 'FAIL'} ${step.n}. ${step.caption}${step.error ? `: ${step.error}` : ''}`);
168
+ statuses.push(ui.status);
169
+ notes.push(...ui.notes);
170
+ steps = ui.steps;
171
+ }
172
+ else {
173
+ const answer = await askEngineer(io, testCase, 'No UI script');
174
+ statuses.push(answer.status);
175
+ if (answer.note)
176
+ notes.push(answer.note);
177
+ }
178
+ }
179
+ if (testCase.check === 'automated' || testCase.check === 'both') {
180
+ const cwd = workdirFor(testCase, workdirs);
181
+ let run;
182
+ if (testCase.automatedTestRef && cwd) {
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
+ }
201
+ }
202
+ else {
203
+ const why = !testCase.automatedTestRef
204
+ ? 'No test command'
205
+ : `No --workdir ${testCase.repository ?? '<repository>'}=<dir> to run \`${testCase.automatedTestRef}\` in`;
206
+ const answer = await askEngineer(io, testCase, why);
207
+ run = { status: answer.status, note: answer.note };
208
+ }
209
+ statuses.push(run.status);
210
+ if (run.note)
211
+ notes.push(run.note);
212
+ if (run.output !== undefined)
213
+ output = run.output;
214
+ }
215
+ const status = combine(statuses);
216
+ io.out(` → ${status}`);
217
+ io.out();
218
+ results.push({
219
+ caseUuid: testCase.uuid,
220
+ id: testCase.id,
221
+ status,
222
+ notes: summarize(status, steps, notes),
223
+ steps,
224
+ ...(output !== undefined ? { output } : {}),
225
+ });
226
+ }
227
+ }
228
+ finally {
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?.();
236
+ }
237
+ return { baseUrl: options.baseUrl, ranAt: new Date().toISOString(), results };
238
+ }
239
+ async function uploadScreenshot(api, saved, path) {
240
+ const bytes = await readFile(path);
241
+ const type = extname(path).toLowerCase() === '.png' ? 'image/png' : 'image/jpeg';
242
+ const form = new FormData();
243
+ form.append('file', new Blob([bytes], { type }), basename(path));
244
+ const uploaded = await api.request(`tickets/${saved.acceptance.ticket.uuid}/acceptance/screenshots`, {
245
+ method: 'POST',
246
+ form,
247
+ organization: saved.organizationUuid,
248
+ });
249
+ return uploaded.uuid;
250
+ }
251
+ export async function upload(api, saved, results, io) {
252
+ const screenshots = new Map();
253
+ const paths = [...new Set(results.results.flatMap((r) => r.steps.map((s) => s.screenshot)).filter((p) => Boolean(p)))];
254
+ if (paths.length > 0)
255
+ io.out(`Uploading ${paths.length} screenshot${paths.length === 1 ? '' : 's'}…`);
256
+ for (const path of paths) {
257
+ if (!existsSync(path)) {
258
+ io.out(` Skipped ${path}: the file is gone.`);
259
+ continue;
260
+ }
261
+ if (statSync(path).size > MAX_SCREENSHOT_BYTES) {
262
+ io.out(` Skipped ${path}: larger than 5 MB.`);
263
+ continue;
264
+ }
265
+ screenshots.set(path, await uploadScreenshot(api, saved, path));
266
+ }
267
+ const payload = buildResultsPayload(saved.acceptance.ticket.stateVersion, results.baseUrl, results.results, screenshots);
268
+ try {
269
+ return await api.request(`tickets/${saved.acceptance.ticket.uuid}/acceptance/results`, {
270
+ method: 'POST',
271
+ body: payload,
272
+ organization: saved.organizationUuid,
273
+ });
274
+ }
275
+ catch (error) {
276
+ if (error instanceof ApiError && error.status === 409) {
277
+ throw new CliError(`Sifur refused the results: ${error.message} The ticket has moved on since the cases were downloaded; run accept again.`);
278
+ }
279
+ throw error;
280
+ }
281
+ }
282
+ export function printSummary(io, results) {
283
+ io.out('Summary');
284
+ for (const result of results)
285
+ io.out(` ${result.status.padEnd(7)} ${result.id}`);
286
+ const overall = outcome(results);
287
+ const counts = ['passed', 'failed', 'blocked']
288
+ .map((status) => [status, results.filter((r) => r.status === status).length])
289
+ .filter(([, count]) => count > 0)
290
+ .map(([status, count]) => `${count} ${status}`);
291
+ io.out(`${overall === 'passed' ? 'Passed' : 'Not passed'}: ${counts.join(', ') || 'no cases'}`);
292
+ return overall;
293
+ }
294
+ export function ticketLink(consoleOrigin, ticketUuid) {
295
+ return consoleOrigin ? `${consoleOrigin.replace(/\/+$/, '')}/tickets/${ticketUuid}` : null;
296
+ }
297
+ /** The whole `accept` command; returns the exit code (0 all passed, 1 any not passed). Errors throw, for exit 2. */
298
+ export async function accept(api, args, deps) {
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
+ }
308
+ if (args.mode === 'download') {
309
+ if (!api)
310
+ throw new CliError('Run `sifur login` first.');
311
+ const saved = await download(api, args.ref);
312
+ const dir = await saveCases(args.store, saved);
313
+ describeDeploy(saved.acceptance, io);
314
+ io.out(`Saved to ${dir}. Run \`sifur accept ${saved.acceptance.ticket.key} --run --url <test url>\` next.`);
315
+ return 0;
316
+ }
317
+ if (args.mode === 'upload') {
318
+ if (!api)
319
+ throw new CliError('Run `sifur login` first.');
320
+ const dir = await findTicketDir(args.store, args.ref);
321
+ if (!dir)
322
+ throw new CliError(`Nothing downloaded for ${args.ref} in ${args.store}.`);
323
+ const saved = await loadCases(dir);
324
+ if (saved.api !== api.base)
325
+ throw new CliError(`${args.ref} was downloaded from ${saved.api}, but you're signed in to ${api.base}.`);
326
+ const results = await loadResults(dir);
327
+ return finish(api, saved, results, dir, args, io);
328
+ }
329
+ if (!args.url)
330
+ throw new CliError('--url <test url> is required: the test server the branch is deployed to.');
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
+ }
336
+ let saved;
337
+ let dir;
338
+ if (args.mode === 'run') {
339
+ const found = await findTicketDir(args.store, args.ref);
340
+ if (!found)
341
+ throw new CliError(`Nothing downloaded for ${args.ref}. Run with --download first.`);
342
+ dir = found;
343
+ saved = await loadCases(dir);
344
+ }
345
+ else {
346
+ if (!api)
347
+ throw new CliError('Run `sifur login` first.');
348
+ saved = await download(api, args.ref);
349
+ dir = await saveCases(args.store, saved);
350
+ }
351
+ describeDeploy(saved.acceptance, io);
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);
358
+ const resultsPath = await saveResults(dir, results);
359
+ if (args.mode === 'run') {
360
+ const overall = printSummary(io, results.results);
361
+ io.out(`Saved to ${resultsPath}. Run \`sifur accept ${saved.acceptance.ticket.key} --upload\` to send them.`);
362
+ return overall === 'passed' ? 0 : 1;
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;
369
+ try {
370
+ response = await upload(api, saved, results, io);
371
+ }
372
+ catch (error) {
373
+ io.out(`The results are kept in ${dir}; send them later with \`sifur accept ${saved.acceptance.ticket.key} --upload\`.`);
374
+ throw error;
375
+ }
376
+ await removeTicketFiles(args.store, dir);
377
+ io.out();
378
+ const overall = printSummary(io, results.results);
379
+ if (response?.run)
380
+ io.out(`Recorded as run ${response.run.runNumber}: ${response.run.result}.`);
381
+ if (response?.state)
382
+ io.out(`The ticket is now in ${response.state.replaceAll('_', ' ')}.`);
383
+ const link = ticketLink(args.consoleOrigin, saved.acceptance.ticket.uuid);
384
+ if (link)
385
+ io.out(link);
386
+ io.out(`Cleaned up: removed the cases, results and screenshots kept in ${dir}.`);
387
+ return overall === 'passed' ? 0 : 1;
388
+ }
389
+ export function parseBaseUrl(url) {
390
+ try {
391
+ const parsed = new URL(url);
392
+ if (!['http:', 'https:'].includes(parsed.protocol))
393
+ throw new Error();
394
+ return parsed.toString();
395
+ }
396
+ catch {
397
+ throw new CliError(`--url ${url} isn't an http(s) URL.`);
398
+ }
399
+ }