@sifurai/cli 0.1.2 → 0.1.4
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 +80 -4
- package/dist/accept.js +38 -7
- package/dist/browser.js +5 -1
- package/dist/cli.js +24 -1
- 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
|
|
|
@@ -103,10 +103,37 @@ sifur accept AZA-12 --url https://test.example.internal
|
|
|
103
103
|
for the result.
|
|
104
104
|
5. For a case with no script, shows its steps and expected result and asks you for the result:
|
|
105
105
|
passed, failed or blocked, with a note.
|
|
106
|
-
6.
|
|
106
|
+
6. After a case that failed or was blocked, asks whether the app or the test is wrong (below).
|
|
107
|
+
7. Uploads the screenshots and results, prints a summary, and links to the ticket.
|
|
107
108
|
|
|
108
109
|
Every case passed: the ticket moves on to approval. Any failed or blocked: it goes back to be fixed.
|
|
109
110
|
|
|
111
|
+
### When a case fails
|
|
112
|
+
|
|
113
|
+
When a UI step fails, the CLI also records what the screen showed at that moment, so Sifur can
|
|
114
|
+
tell a wrong app from a wrong test:
|
|
115
|
+
|
|
116
|
+
- the page's address, with secrets and the values of query keys like `token`, `password`, `key`,
|
|
117
|
+
`secret` or `session` replaced;
|
|
118
|
+
- the visible text of the page and every frame it can read, at most 20,000 characters;
|
|
119
|
+
- up to 40 buttons, links and fields on the screen, nearest the step's target first, each with its
|
|
120
|
+
role, its label and a selector for it;
|
|
121
|
+
- how many frames couldn't be read, if any.
|
|
122
|
+
|
|
123
|
+
Secrets are replaced with `[secret]` in all of it. It is kept with the results until they're
|
|
124
|
+
uploaded, and nothing else from the page leaves your machine. If the screen can't be read, the
|
|
125
|
+
result goes without it.
|
|
126
|
+
|
|
127
|
+
After a case that failed or was blocked, UI or automated, the CLI asks:
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
Is the app wrong, or the test? [a]pp / [t]est / [s]kip:
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`a` or `t` asks for an optional reason; `s` or Enter gives no answer. The answer goes to Sifur
|
|
134
|
+
with the result, and Sifur takes it as given: `test` means the case is wrong and is rewritten,
|
|
135
|
+
and it's never sent to the coding agent to change the code; `app` sends it to be fixed.
|
|
136
|
+
|
|
110
137
|
`--url` is the root of your test server: the cases' paths are opened under it. The CLI warns when
|
|
111
138
|
it has a path or a query string, since that's rarely what's wanted.
|
|
112
139
|
|
|
@@ -153,6 +180,53 @@ afterwards it removes them again (also on Ctrl-C), along with any folder it made
|
|
|
153
180
|
`git status` is just as it was. A file of yours at the same path is set aside and put back; one
|
|
154
181
|
that's already identical is left alone. A path outside the checkout is refused.
|
|
155
182
|
|
|
183
|
+
## Record a case
|
|
184
|
+
|
|
185
|
+
For a screen Sifur's agents can't see, record the case yourself in a browser:
|
|
186
|
+
|
|
187
|
+
```sh
|
|
188
|
+
sifur record AZA-12 TC-3 # record TC-3's steps again
|
|
189
|
+
sifur record AZA-12 --new "The filter keeps the choice" # add a case
|
|
190
|
+
sifur record AZA-12 --new "…" --repository web # when the project has more than one repository
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Like `accept`, it uses the project's test server unless `--url` names another. The CLI:
|
|
194
|
+
|
|
195
|
+
1. Downloads the ticket's cases and signs in to your test server with Sifur's sign-in (the steps
|
|
196
|
+
every UI case starts with), in a browser you don't see (`--headed` shows it). The sign-in's
|
|
197
|
+
secrets come from `SIFUR_SECRET_<NAME>`, or are asked for, as for `accept`.
|
|
198
|
+
2. Opens Playwright's recorder in Chrome (or Edge), already signed in, on the page the sign-in
|
|
199
|
+
left it at. Do the case's steps, then check the result with the toolbar's **Assert text** or
|
|
200
|
+
**Assert visibility** on what the change shows. Close the window when you're done.
|
|
201
|
+
3. Turns the recording into Sifur's steps and shows them. The sign-in isn't part of them: Sifur
|
|
202
|
+
puts it in front of every UI case.
|
|
203
|
+
4. Asks `Upload these N steps? [y/N]`, and uploads them as the case's new version (or as a new
|
|
204
|
+
case). Sifur refuses a recording made against an older version of the case, or after the
|
|
205
|
+
ticket has moved on; record it again.
|
|
206
|
+
|
|
207
|
+
A recording needs at least one check: what proves the case is yours to choose, so the CLI never
|
|
208
|
+
adds one. Sifur replays opening a page, clicks (a double-click, check or uncheck counts as a
|
|
209
|
+
click), typing into a field, key presses and choosing an option, on elements found by role, text,
|
|
210
|
+
label, placeholder, test id or CSS, in the page or one of its frames. Anything else (hovering,
|
|
211
|
+
uploading a file, dragging, the keyboard or mouse on their own, dialogs, popups and new tabs, a
|
|
212
|
+
value or snapshot check) is refused, with each line and the reason, and nothing is uploaded.
|
|
213
|
+
|
|
214
|
+
**Credentials.** What you type is never uploaded as typed where it's a credential. A value that
|
|
215
|
+
matches a secret the CLI knows (the sign-in's, or any `SIFUR_SECRET_<NAME>`) becomes its
|
|
216
|
+
placeholder, `{{NAME}}`. Anything else typed into a field whose selector or label looks like a
|
|
217
|
+
password, email or username field, the CLI asks you to name a placeholder for (like
|
|
218
|
+
`APP_PASSWORD`); when the case runs, it comes from `SIFUR_SECRET_<NAME>` or is asked for.
|
|
219
|
+
The recorder only sees the field's selector, not its type, so check the steps it shows you before
|
|
220
|
+
you upload them.
|
|
221
|
+
|
|
222
|
+
The signed-in session and the recorder's raw output (which holds everything you typed) are kept in
|
|
223
|
+
a private temporary folder (the session file with owner-only permissions, 0600) and deleted as
|
|
224
|
+
soon as the recorder closes, whatever happens, Ctrl-C included.
|
|
225
|
+
|
|
226
|
+
The session carries into the recorder through its cookies and storage (`localStorage` and
|
|
227
|
+
IndexedDB). An app that keeps it only in `sessionStorage` opens signed out there, and can't be
|
|
228
|
+
recorded this way yet.
|
|
229
|
+
|
|
156
230
|
## VPN: split tunnel or full tunnel
|
|
157
231
|
|
|
158
232
|
With a **split-tunnel** VPN (only company traffic goes through it), one command does everything:
|
|
@@ -207,6 +281,8 @@ SIFUR_SECRET_ADMIN_PASSWORD='…' sifur accept AZA-12 --url https://test.example
|
|
|
207
281
|
| `--download` / `--run` / `--upload` | The three steps for a full-tunnel VPN |
|
|
208
282
|
| `--apply` | With `accept`: put Sifur's changes in your checkouts first, as `sifur apply` does |
|
|
209
283
|
| `--revert` | With `apply`: take the changes back out |
|
|
284
|
+
| `--new <title>` | With `record`: record a new case with this title |
|
|
285
|
+
| `--repository <name>` | With `record --new`: the case's repository, when the project has more than one |
|
|
210
286
|
| `--api <url>` | Sifur's API, for `login` (default `https://api.sifur.ai`, or `SIFUR_API_URL`). Not your test server: that's `--url` |
|
|
211
287
|
|
|
212
288
|
## Troubleshooting
|
|
@@ -224,6 +300,6 @@ certificate with `NODE_EXTRA_CA_CERTS=<root cert>`.
|
|
|
224
300
|
|
|
225
301
|
| Code | Meaning |
|
|
226
302
|
|---|---|
|
|
227
|
-
| 0 | Every case passed (or `--download` finished) |
|
|
228
|
-
| 1 | At least one case failed or was blocked |
|
|
303
|
+
| 0 | Every case passed (or `--download` finished, or `record` uploaded the steps) |
|
|
304
|
+
| 1 | At least one case failed or was blocked (or you chose not to upload a recording) |
|
|
229
305
|
| 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,6 +6,7 @@ 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>]
|
|
@@ -16,6 +17,8 @@ const USAGE = `Usage:
|
|
|
16
17
|
sifur accept <KEY|uuid> --download [--apply]
|
|
17
18
|
sifur accept <KEY|uuid> --run --url <test url> [--headed] [--workdir <repo>=<dir> …]
|
|
18
19
|
sifur accept <KEY|uuid> --upload
|
|
20
|
+
sifur record <KEY|uuid> <TC-n> [--url <test url>] [--headed]
|
|
21
|
+
sifur record <KEY|uuid> --new "<title>" [--repository <name>] [--url <test url>] [--headed]
|
|
19
22
|
|
|
20
23
|
Options:
|
|
21
24
|
--api <url> Sifur's API (default ${DEFAULT_API}, or SIFUR_API_URL)
|
|
@@ -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'));
|
|
@@ -75,6 +80,8 @@ export async function main(argv, io, env = process.env, cwd = process.cwd()) {
|
|
|
75
80
|
upload: { type: 'boolean', default: false },
|
|
76
81
|
apply: { type: 'boolean', default: false },
|
|
77
82
|
revert: { type: 'boolean', default: false },
|
|
83
|
+
new: { type: 'string' },
|
|
84
|
+
repository: { type: 'string' },
|
|
78
85
|
help: { type: 'boolean', short: 'h', default: false },
|
|
79
86
|
version: { type: 'boolean', short: 'v', default: false },
|
|
80
87
|
},
|
|
@@ -136,6 +143,22 @@ export async function main(argv, io, env = process.env, cwd = process.cwd()) {
|
|
|
136
143
|
consoleOrigin: env.SIFUR_CONSOLE_URL ?? credentials?.console ?? null,
|
|
137
144
|
}, { io, env, timeoutMs: timeoutSeconds(values.timeout) * 1000 });
|
|
138
145
|
}
|
|
146
|
+
case 'record': {
|
|
147
|
+
const [ref, caseId, ...extra] = rest;
|
|
148
|
+
if (!ref || extra.length > 0 || (caseId === undefined) === (values.new === undefined)) {
|
|
149
|
+
throw new CliError('Usage: sifur record <KEY|uuid> <TC-n>, or sifur record <KEY|uuid> --new "<title>"');
|
|
150
|
+
}
|
|
151
|
+
const credentials = await loadCredentials(env);
|
|
152
|
+
return recordCase(signedIn(credentials, explicitApi, userAgent), {
|
|
153
|
+
ref,
|
|
154
|
+
caseId,
|
|
155
|
+
title: values.new,
|
|
156
|
+
repository: values.repository,
|
|
157
|
+
url: values.url,
|
|
158
|
+
headed: values.headed,
|
|
159
|
+
consoleOrigin: env.SIFUR_CONSOLE_URL ?? credentials?.console ?? null,
|
|
160
|
+
}, { io, env, timeoutMs: timeoutSeconds(values.timeout) * 1000 });
|
|
161
|
+
}
|
|
139
162
|
default:
|
|
140
163
|
throw new CliError(`Unknown command "${command}".\n\n${USAGE}`);
|
|
141
164
|
}
|