@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 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 screenshots go to Sifur.
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 `--headed` to watch.
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. Uploads the screenshots and results, prints a summary, and links to the ticket.
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
- | `--headed` | Show the browser while the cases run |
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] [--headed] [--workdir <repo>=<dir> …]
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> [--headed] [--workdir <repo>=<dir> …]
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, where the ticket's branch is deployed
23
- --headed Show the browser while the cases run
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.headed,
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
  }