@sifurai/cli 0.1.2 → 0.1.5
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 +83 -6
- package/dist/accept.js +38 -7
- package/dist/browser.js +5 -1
- package/dist/cli.js +31 -6
- package/dist/codegen.js +680 -0
- package/dist/evidence.js +156 -0
- package/dist/record.js +333 -0
- package/dist/results.js +2 -0
- package/dist/runner.js +14 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ send the results back to Sifur.
|
|
|
6
6
|
Use it when your app only runs on your team's servers, behind a firewall or VPN that Sifur can't
|
|
7
7
|
reach. Sifur never deploys to your server, never holds VPN or server credentials, and never connects
|
|
8
8
|
into your network: you deploy the ticket's branch, this tool runs the cases on your machine, and only
|
|
9
|
-
the results and
|
|
9
|
+
the results, screenshots and a failed step's evidence (below) go to Sifur.
|
|
10
10
|
|
|
11
11
|
## Requirements
|
|
12
12
|
|
|
@@ -96,17 +96,45 @@ sifur accept AZA-12 --url https://test.example.internal
|
|
|
96
96
|
|
|
97
97
|
1. Finds the ticket in your organizations and downloads its cases.
|
|
98
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 `--
|
|
99
|
+
it), taking a screenshot after every step, in a browser window you can watch. Add `--headless`
|
|
100
|
+
to run it without one.
|
|
100
101
|
3. For a step that needs a person's eye, shows you the screenshot's path and asks whether it looks
|
|
101
102
|
right.
|
|
102
103
|
4. Runs automated cases in your checkout of their repository (see below); without one, asks you
|
|
103
104
|
for the result.
|
|
104
105
|
5. For a case with no script, shows its steps and expected result and asks you for the result:
|
|
105
106
|
passed, failed or blocked, with a note.
|
|
106
|
-
6.
|
|
107
|
+
6. After a case that failed or was blocked, asks whether the app or the test is wrong (below).
|
|
108
|
+
7. Uploads the screenshots and results, prints a summary, and links to the ticket.
|
|
107
109
|
|
|
108
110
|
Every case passed: the ticket moves on to approval. Any failed or blocked: it goes back to be fixed.
|
|
109
111
|
|
|
112
|
+
### When a case fails
|
|
113
|
+
|
|
114
|
+
When a UI step fails, the CLI also records what the screen showed at that moment, so Sifur can
|
|
115
|
+
tell a wrong app from a wrong test:
|
|
116
|
+
|
|
117
|
+
- the page's address, with secrets and the values of query keys like `token`, `password`, `key`,
|
|
118
|
+
`secret` or `session` replaced;
|
|
119
|
+
- the visible text of the page and every frame it can read, at most 20,000 characters;
|
|
120
|
+
- up to 40 buttons, links and fields on the screen, nearest the step's target first, each with its
|
|
121
|
+
role, its label and a selector for it;
|
|
122
|
+
- how many frames couldn't be read, if any.
|
|
123
|
+
|
|
124
|
+
Secrets are replaced with `[secret]` in all of it. It is kept with the results until they're
|
|
125
|
+
uploaded, and nothing else from the page leaves your machine. If the screen can't be read, the
|
|
126
|
+
result goes without it.
|
|
127
|
+
|
|
128
|
+
After a case that failed or was blocked, UI or automated, the CLI asks:
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
Is the app wrong, or the test? [a]pp / [t]est / [s]kip:
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`a` or `t` asks for an optional reason; `s` or Enter gives no answer. The answer goes to Sifur
|
|
135
|
+
with the result, and Sifur takes it as given: `test` means the case is wrong and is rewritten,
|
|
136
|
+
and it's never sent to the coding agent to change the code; `app` sends it to be fixed.
|
|
137
|
+
|
|
110
138
|
`--url` is the root of your test server: the cases' paths are opened under it. The CLI warns when
|
|
111
139
|
it has a path or a query string, since that's rarely what's wanted.
|
|
112
140
|
|
|
@@ -153,6 +181,53 @@ afterwards it removes them again (also on Ctrl-C), along with any folder it made
|
|
|
153
181
|
`git status` is just as it was. A file of yours at the same path is set aside and put back; one
|
|
154
182
|
that's already identical is left alone. A path outside the checkout is refused.
|
|
155
183
|
|
|
184
|
+
## Record a case
|
|
185
|
+
|
|
186
|
+
For a screen Sifur's agents can't see, record the case yourself in a browser:
|
|
187
|
+
|
|
188
|
+
```sh
|
|
189
|
+
sifur record AZA-12 TC-3 # record TC-3's steps again
|
|
190
|
+
sifur record AZA-12 --new "The filter keeps the choice" # add a case
|
|
191
|
+
sifur record AZA-12 --new "…" --repository web # when the project has more than one repository
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Like `accept`, it uses the project's test server unless `--url` names another. The CLI:
|
|
195
|
+
|
|
196
|
+
1. Downloads the ticket's cases and signs in to your test server with Sifur's sign-in (the steps
|
|
197
|
+
every UI case starts with), in a browser you can watch (`--headless` hides it). The sign-in's
|
|
198
|
+
secrets come from `SIFUR_SECRET_<NAME>`, or are asked for, as for `accept`.
|
|
199
|
+
2. Opens Playwright's recorder in Chrome (or Edge), already signed in, on the page the sign-in
|
|
200
|
+
left it at. Do the case's steps, then check the result with the toolbar's **Assert text** or
|
|
201
|
+
**Assert visibility** on what the change shows. Close the window when you're done.
|
|
202
|
+
3. Turns the recording into Sifur's steps and shows them. The sign-in isn't part of them: Sifur
|
|
203
|
+
puts it in front of every UI case.
|
|
204
|
+
4. Asks `Upload these N steps? [y/N]`, and uploads them as the case's new version (or as a new
|
|
205
|
+
case). Sifur refuses a recording made against an older version of the case, or after the
|
|
206
|
+
ticket has moved on; record it again.
|
|
207
|
+
|
|
208
|
+
A recording needs at least one check: what proves the case is yours to choose, so the CLI never
|
|
209
|
+
adds one. Sifur replays opening a page, clicks (a double-click, check or uncheck counts as a
|
|
210
|
+
click), typing into a field, key presses and choosing an option, on elements found by role, text,
|
|
211
|
+
label, placeholder, test id or CSS, in the page or one of its frames. Anything else (hovering,
|
|
212
|
+
uploading a file, dragging, the keyboard or mouse on their own, dialogs, popups and new tabs, a
|
|
213
|
+
value or snapshot check) is refused, with each line and the reason, and nothing is uploaded.
|
|
214
|
+
|
|
215
|
+
**Credentials.** What you type is never uploaded as typed where it's a credential. A value that
|
|
216
|
+
matches a secret the CLI knows (the sign-in's, or any `SIFUR_SECRET_<NAME>`) becomes its
|
|
217
|
+
placeholder, `{{NAME}}`. Anything else typed into a field whose selector or label looks like a
|
|
218
|
+
password, email or username field, the CLI asks you to name a placeholder for (like
|
|
219
|
+
`APP_PASSWORD`); when the case runs, it comes from `SIFUR_SECRET_<NAME>` or is asked for.
|
|
220
|
+
The recorder only sees the field's selector, not its type, so check the steps it shows you before
|
|
221
|
+
you upload them.
|
|
222
|
+
|
|
223
|
+
The signed-in session and the recorder's raw output (which holds everything you typed) are kept in
|
|
224
|
+
a private temporary folder (the session file with owner-only permissions, 0600) and deleted as
|
|
225
|
+
soon as the recorder closes, whatever happens, Ctrl-C included.
|
|
226
|
+
|
|
227
|
+
The session carries into the recorder through its cookies and storage (`localStorage` and
|
|
228
|
+
IndexedDB). An app that keeps it only in `sessionStorage` opens signed out there, and can't be
|
|
229
|
+
recorded this way yet.
|
|
230
|
+
|
|
156
231
|
## VPN: split tunnel or full tunnel
|
|
157
232
|
|
|
158
233
|
With a **split-tunnel** VPN (only company traffic goes through it), one command does everything:
|
|
@@ -201,12 +276,14 @@ SIFUR_SECRET_ADMIN_PASSWORD='…' sifur accept AZA-12 --url https://test.example
|
|
|
201
276
|
| Option | |
|
|
202
277
|
|---|---|
|
|
203
278
|
| `--url <url>` | Your test server, where the ticket's branch is deployed |
|
|
204
|
-
| `--
|
|
279
|
+
| `--headless` | Run the browser without showing it; it shows by default |
|
|
205
280
|
| `--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
281
|
| `--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
282
|
| `--download` / `--run` / `--upload` | The three steps for a full-tunnel VPN |
|
|
208
283
|
| `--apply` | With `accept`: put Sifur's changes in your checkouts first, as `sifur apply` does |
|
|
209
284
|
| `--revert` | With `apply`: take the changes back out |
|
|
285
|
+
| `--new <title>` | With `record`: record a new case with this title |
|
|
286
|
+
| `--repository <name>` | With `record --new`: the case's repository, when the project has more than one |
|
|
210
287
|
| `--api <url>` | Sifur's API, for `login` (default `https://api.sifur.ai`, or `SIFUR_API_URL`). Not your test server: that's `--url` |
|
|
211
288
|
|
|
212
289
|
## Troubleshooting
|
|
@@ -224,6 +301,6 @@ certificate with `NODE_EXTRA_CA_CERTS=<root cert>`.
|
|
|
224
301
|
|
|
225
302
|
| Code | Meaning |
|
|
226
303
|
|---|---|
|
|
227
|
-
| 0 | Every case passed (or `--download` finished) |
|
|
228
|
-
| 1 | At least one case failed or was blocked |
|
|
304
|
+
| 0 | Every case passed (or `--download` finished, or `record` uploaded the steps) |
|
|
305
|
+
| 1 | At least one case failed or was blocked (or you chose not to upload a recording) |
|
|
229
306
|
| 2 | An error: not signed in, the ticket isn't in acceptance testing, Sifur unreachable, no Chrome or Edge, … |
|
package/dist/accept.js
CHANGED
|
@@ -70,6 +70,20 @@ function judgeWith(io) {
|
|
|
70
70
|
return { ok: false, note: note || undefined };
|
|
71
71
|
};
|
|
72
72
|
}
|
|
73
|
+
export const VERDICT_EXPLAINED = "Your answer goes to Sifur with the result: 'test' means the case is wrong and is rewritten, never sent to the coding agent.";
|
|
74
|
+
/** Whose fault a failed or blocked case is, in the engineer's view; nothing when they skip it. */
|
|
75
|
+
async function askVerdict(io) {
|
|
76
|
+
const choices = { a: 'app', app: 'app', t: 'test', test: 'test', s: null, skip: null, '': null };
|
|
77
|
+
for (;;) {
|
|
78
|
+
const verdict = choices[(await io.ask(' Is the app wrong, or the test? [a]pp / [t]est / [s]kip: ')).trim().toLowerCase()];
|
|
79
|
+
if (verdict === null)
|
|
80
|
+
return undefined;
|
|
81
|
+
if (verdict) {
|
|
82
|
+
const note = await askNote(io, ' Why? (optional): ');
|
|
83
|
+
return note ? { verdict, note } : { verdict };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
73
87
|
function filesFor(testCase, files, workdirs) {
|
|
74
88
|
return files.filter((f) => f.repository === testCase.repository || (!testCase.repository && !workdirs[f.repository]));
|
|
75
89
|
}
|
|
@@ -144,6 +158,7 @@ export async function runCases(acceptance, options, deps) {
|
|
|
144
158
|
const runToken = newRunToken();
|
|
145
159
|
let browser = null;
|
|
146
160
|
const results = [];
|
|
161
|
+
let verdictExplained = false;
|
|
147
162
|
try {
|
|
148
163
|
for (const testCase of acceptance.cases) {
|
|
149
164
|
io.out(`${testCase.id} ${testCase.title}`);
|
|
@@ -151,6 +166,7 @@ export async function runCases(acceptance, options, deps) {
|
|
|
151
166
|
const notes = [];
|
|
152
167
|
let steps = [];
|
|
153
168
|
let output;
|
|
169
|
+
let evidence;
|
|
154
170
|
const withoutLogin = secretUses([testCase])
|
|
155
171
|
.map((use) => use.name)
|
|
156
172
|
.filter((name) => secrets[name] === undefined);
|
|
@@ -177,6 +193,7 @@ export async function runCases(acceptance, options, deps) {
|
|
|
177
193
|
statuses.push(ui.status);
|
|
178
194
|
notes.push(...ui.notes);
|
|
179
195
|
steps = ui.steps;
|
|
196
|
+
evidence = ui.evidence;
|
|
180
197
|
}
|
|
181
198
|
else {
|
|
182
199
|
const answer = await askEngineer(io, testCase, 'No UI script');
|
|
@@ -223,6 +240,13 @@ export async function runCases(acceptance, options, deps) {
|
|
|
223
240
|
}
|
|
224
241
|
const status = combine(statuses);
|
|
225
242
|
io.out(` → ${status}`);
|
|
243
|
+
let engineerVerdict;
|
|
244
|
+
if (status === 'failed' || status === 'blocked') {
|
|
245
|
+
if (!verdictExplained)
|
|
246
|
+
io.out(` ${VERDICT_EXPLAINED}`);
|
|
247
|
+
verdictExplained = true;
|
|
248
|
+
engineerVerdict = await askVerdict(io);
|
|
249
|
+
}
|
|
226
250
|
io.out();
|
|
227
251
|
results.push({
|
|
228
252
|
caseUuid: testCase.uuid,
|
|
@@ -231,6 +255,8 @@ export async function runCases(acceptance, options, deps) {
|
|
|
231
255
|
notes: summarize(status, steps, notes),
|
|
232
256
|
steps,
|
|
233
257
|
...(output !== undefined ? { output } : {}),
|
|
258
|
+
...(evidence ? { evidence } : {}),
|
|
259
|
+
...(engineerVerdict ? { engineerVerdict } : {}),
|
|
234
260
|
});
|
|
235
261
|
}
|
|
236
262
|
}
|
|
@@ -335,13 +361,6 @@ export async function accept(api, args, deps) {
|
|
|
335
361
|
const results = await loadResults(dir);
|
|
336
362
|
return finish(api, saved, results, dir, args, io);
|
|
337
363
|
}
|
|
338
|
-
if (!args.url)
|
|
339
|
-
throw new CliError('--url <test url> is required: the test server the branch is deployed to.');
|
|
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
|
-
}
|
|
345
364
|
let saved;
|
|
346
365
|
let dir;
|
|
347
366
|
if (args.mode === 'run') {
|
|
@@ -357,6 +376,18 @@ export async function accept(api, args, deps) {
|
|
|
357
376
|
saved = await download(api, args.ref);
|
|
358
377
|
dir = await saveCases(args.store, saved);
|
|
359
378
|
}
|
|
379
|
+
// The project's test server unless --url names another, as for a server of the engineer's own.
|
|
380
|
+
const url = args.url ?? saved.acceptance.appUrl ?? null;
|
|
381
|
+
if (!url) {
|
|
382
|
+
throw new CliError('--url <test url> is required: the test server the branch is deployed to. Or set it once in the project\'s test environment settings.');
|
|
383
|
+
}
|
|
384
|
+
const baseUrl = parseBaseUrl(url);
|
|
385
|
+
if (!args.url)
|
|
386
|
+
io.out(`Testing ${baseUrl} (the project's test server). Use --url for another.`);
|
|
387
|
+
const parsedUrl = new URL(baseUrl);
|
|
388
|
+
if (parsedUrl.search || parsedUrl.hash) {
|
|
389
|
+
io.out(`Note: the cases' paths are opened under ${baseUrl}, without its query or fragment.`);
|
|
390
|
+
}
|
|
360
391
|
describeDeploy(saved.acceptance, io);
|
|
361
392
|
const { workdirs, chosen } = defaultWorkdirs(saved.acceptance.cases, args.workdirs ?? {}, gitTopLevel(args.cwd));
|
|
362
393
|
if (chosen)
|
package/dist/browser.js
CHANGED
|
@@ -3,10 +3,14 @@ import { CliError } from './api.js';
|
|
|
3
3
|
const CHANNELS = ['chrome', 'msedge'];
|
|
4
4
|
/** The engineer's own Chrome, else Edge: playwright-core downloads no browser of its own. */
|
|
5
5
|
export async function launchBrowser(options = {}) {
|
|
6
|
+
return (await launchChannel(options)).browser;
|
|
7
|
+
}
|
|
8
|
+
/** The browser, and which one it is, for a later process (the recorder) to open the same one. */
|
|
9
|
+
export async function launchChannel(options = {}) {
|
|
6
10
|
const failures = [];
|
|
7
11
|
for (const channel of CHANNELS) {
|
|
8
12
|
try {
|
|
9
|
-
return await chromium.launch({ channel, headless: !options.headed });
|
|
13
|
+
return { browser: await chromium.launch({ channel, headless: !options.headed }), channel };
|
|
10
14
|
}
|
|
11
15
|
catch (error) {
|
|
12
16
|
failures.push(`${channel}: ${String(error.message ?? error).split('\n')[0]}`);
|
package/dist/cli.js
CHANGED
|
@@ -6,21 +6,24 @@ import { Api, ApiError, CliError, DEFAULT_API, normalizeApi } from './api.js';
|
|
|
6
6
|
import { applyChanges, revertChanges } from './apply.js';
|
|
7
7
|
import { cacheDir, loadCredentials } from './config.js';
|
|
8
8
|
import { login, logout } from './login.js';
|
|
9
|
+
import { recordCase } from './record.js';
|
|
9
10
|
import { LEGACY_STORE } from './store.js';
|
|
10
11
|
const USAGE = `Usage:
|
|
11
12
|
sifur login [--api <url>]
|
|
12
13
|
sifur logout
|
|
13
14
|
sifur apply <KEY|uuid> [--workdir <repo>=<dir> …]
|
|
14
15
|
sifur apply <KEY|uuid> --revert
|
|
15
|
-
sifur accept <KEY|uuid> --url <test url> [--apply] [--
|
|
16
|
+
sifur accept <KEY|uuid> [--url <test url>] [--apply] [--headless] [--workdir <repo>=<dir> …]
|
|
16
17
|
sifur accept <KEY|uuid> --download [--apply]
|
|
17
|
-
sifur accept <KEY|uuid> --run --url <test url> [--
|
|
18
|
+
sifur accept <KEY|uuid> --run [--url <test url>] [--headless] [--workdir <repo>=<dir> …]
|
|
18
19
|
sifur accept <KEY|uuid> --upload
|
|
20
|
+
sifur record <KEY|uuid> <TC-n> [--url <test url>] [--headless]
|
|
21
|
+
sifur record <KEY|uuid> --new "<title>" [--repository <name>] [--url <test url>] [--headless]
|
|
19
22
|
|
|
20
23
|
Options:
|
|
21
24
|
--api <url> Sifur's API (default ${DEFAULT_API}, or SIFUR_API_URL)
|
|
22
|
-
--url <url> Your test server
|
|
23
|
-
--
|
|
25
|
+
--url <url> Your test server (default: the project's saved address)
|
|
26
|
+
--headless Run the browser without showing it (it shows by default)
|
|
24
27
|
--timeout <seconds> How long each browser step may take (default 60; raise it for a slow VPN)
|
|
25
28
|
--apply Put Sifur's changes in your checkouts first, uncommitted, as \`sifur apply\` does
|
|
26
29
|
--revert Take the changes \`sifur apply\` put in back out
|
|
@@ -29,9 +32,11 @@ Options:
|
|
|
29
32
|
--download Save the cases to ${cacheDir()}/<KEY>/ (before a full-tunnel VPN is up)
|
|
30
33
|
--run Run the saved cases and save the results (no connection to Sifur)
|
|
31
34
|
--upload Send the saved results (after the VPN is down)
|
|
35
|
+
--new <title> With record: record a new case with this title
|
|
36
|
+
--repository <name> With record --new: the case's repository, when the project has more than one
|
|
32
37
|
|
|
33
38
|
Secrets: {{NAME}} in a case comes from SIFUR_SECRET_NAME, or is asked for. It never leaves this machine.
|
|
34
|
-
Exit codes: 0 every case passed, 1 any case failed or was blocked, 2 an error.`;
|
|
39
|
+
Exit codes: 0 every case passed (record: uploaded), 1 any case failed or was blocked (record: not uploaded), 2 an error.`;
|
|
35
40
|
function version() {
|
|
36
41
|
try {
|
|
37
42
|
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
@@ -67,7 +72,9 @@ export async function main(argv, io, env = process.env, cwd = process.cwd()) {
|
|
|
67
72
|
options: {
|
|
68
73
|
api: { type: 'string' },
|
|
69
74
|
url: { type: 'string' },
|
|
75
|
+
// Kept so older instructions still work: the browser shows by default now.
|
|
70
76
|
headed: { type: 'boolean', default: false },
|
|
77
|
+
headless: { type: 'boolean', default: false },
|
|
71
78
|
timeout: { type: 'string' },
|
|
72
79
|
workdir: { type: 'string', multiple: true, default: [] },
|
|
73
80
|
download: { type: 'boolean', default: false },
|
|
@@ -75,6 +82,8 @@ export async function main(argv, io, env = process.env, cwd = process.cwd()) {
|
|
|
75
82
|
upload: { type: 'boolean', default: false },
|
|
76
83
|
apply: { type: 'boolean', default: false },
|
|
77
84
|
revert: { type: 'boolean', default: false },
|
|
85
|
+
new: { type: 'string' },
|
|
86
|
+
repository: { type: 'string' },
|
|
78
87
|
help: { type: 'boolean', short: 'h', default: false },
|
|
79
88
|
version: { type: 'boolean', short: 'v', default: false },
|
|
80
89
|
},
|
|
@@ -128,7 +137,7 @@ export async function main(argv, io, env = process.env, cwd = process.cwd()) {
|
|
|
128
137
|
ref,
|
|
129
138
|
mode,
|
|
130
139
|
url: values.url,
|
|
131
|
-
headed: values.
|
|
140
|
+
headed: !values.headless,
|
|
132
141
|
workdirs: parseWorkdirs(values.workdir ?? []),
|
|
133
142
|
cwd,
|
|
134
143
|
store: cacheDir(env),
|
|
@@ -136,6 +145,22 @@ export async function main(argv, io, env = process.env, cwd = process.cwd()) {
|
|
|
136
145
|
consoleOrigin: env.SIFUR_CONSOLE_URL ?? credentials?.console ?? null,
|
|
137
146
|
}, { io, env, timeoutMs: timeoutSeconds(values.timeout) * 1000 });
|
|
138
147
|
}
|
|
148
|
+
case 'record': {
|
|
149
|
+
const [ref, caseId, ...extra] = rest;
|
|
150
|
+
if (!ref || extra.length > 0 || (caseId === undefined) === (values.new === undefined)) {
|
|
151
|
+
throw new CliError('Usage: sifur record <KEY|uuid> <TC-n>, or sifur record <KEY|uuid> --new "<title>"');
|
|
152
|
+
}
|
|
153
|
+
const credentials = await loadCredentials(env);
|
|
154
|
+
return recordCase(signedIn(credentials, explicitApi, userAgent), {
|
|
155
|
+
ref,
|
|
156
|
+
caseId,
|
|
157
|
+
title: values.new,
|
|
158
|
+
repository: values.repository,
|
|
159
|
+
url: values.url,
|
|
160
|
+
headed: !values.headless,
|
|
161
|
+
consoleOrigin: env.SIFUR_CONSOLE_URL ?? credentials?.console ?? null,
|
|
162
|
+
}, { io, env, timeoutMs: timeoutSeconds(values.timeout) * 1000 });
|
|
163
|
+
}
|
|
139
164
|
default:
|
|
140
165
|
throw new CliError(`Unknown command "${command}".\n\n${USAGE}`);
|
|
141
166
|
}
|