@sifurai/cli 0.1.1 → 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 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
 
@@ -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. Uploads the screenshots and results, prints a summary, and links to the ticket.
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,7 +166,17 @@ export async function runCases(acceptance, options, deps) {
151
166
  const notes = [];
152
167
  let steps = [];
153
168
  let output;
154
- if (testCase.check === 'ui' || testCase.check === 'both') {
169
+ let evidence;
170
+ const withoutLogin = secretUses([testCase])
171
+ .map((use) => use.name)
172
+ .filter((name) => secrets[name] === undefined);
173
+ if ((testCase.check === 'ui' || testCase.check === 'both') && withoutLogin.length > 0) {
174
+ const note = `Not run: no ${withoutLogin.join(' or ')} was given.`;
175
+ io.out(` skipped: ${note}`);
176
+ statuses.push('skipped');
177
+ notes.push(note);
178
+ }
179
+ else if (testCase.check === 'ui' || testCase.check === 'both') {
155
180
  if (scripted.includes(testCase)) {
156
181
  browser ??= await (deps.launch ?? launchBrowser)({ headed: options.headed });
157
182
  const ui = await runUiCase(browser, testCase, {
@@ -168,6 +193,7 @@ export async function runCases(acceptance, options, deps) {
168
193
  statuses.push(ui.status);
169
194
  notes.push(...ui.notes);
170
195
  steps = ui.steps;
196
+ evidence = ui.evidence;
171
197
  }
172
198
  else {
173
199
  const answer = await askEngineer(io, testCase, 'No UI script');
@@ -214,6 +240,13 @@ export async function runCases(acceptance, options, deps) {
214
240
  }
215
241
  const status = combine(statuses);
216
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
+ }
217
250
  io.out();
218
251
  results.push({
219
252
  caseUuid: testCase.uuid,
@@ -222,6 +255,8 @@ export async function runCases(acceptance, options, deps) {
222
255
  notes: summarize(status, steps, notes),
223
256
  steps,
224
257
  ...(output !== undefined ? { output } : {}),
258
+ ...(evidence ? { evidence } : {}),
259
+ ...(engineerVerdict ? { engineerVerdict } : {}),
225
260
  });
226
261
  }
227
262
  }
@@ -284,7 +319,7 @@ export function printSummary(io, results) {
284
319
  for (const result of results)
285
320
  io.out(` ${result.status.padEnd(7)} ${result.id}`);
286
321
  const overall = outcome(results);
287
- const counts = ['passed', 'failed', 'blocked']
322
+ const counts = ['passed', 'failed', 'blocked', 'skipped']
288
323
  .map((status) => [status, results.filter((r) => r.status === status).length])
289
324
  .filter(([, count]) => count > 0)
290
325
  .map(([status, count]) => `${count} ${status}`);
@@ -326,13 +361,6 @@ export async function accept(api, args, deps) {
326
361
  const results = await loadResults(dir);
327
362
  return finish(api, saved, results, dir, args, io);
328
363
  }
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
364
  let saved;
337
365
  let dir;
338
366
  if (args.mode === 'run') {
@@ -348,6 +376,18 @@ export async function accept(api, args, deps) {
348
376
  saved = await download(api, args.ref);
349
377
  dir = await saveCases(args.store, saved);
350
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
+ }
351
391
  describeDeploy(saved.acceptance, io);
352
392
  const { workdirs, chosen } = defaultWorkdirs(saved.acceptance.cases, args.workdirs ?? {}, gitTopLevel(args.cwd));
353
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
  }