@sifurai/cli 0.1.0 → 0.1.2

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/dist/apply.js ADDED
@@ -0,0 +1,143 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync, statSync } from 'node:fs';
3
+ import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { ApiError, CliError } from './api.js';
6
+ import { parseTicketRef, resolveTicket } from './resolve.js';
7
+ import { APPLIED, pruneTicketDir, ticketDir } from './store.js';
8
+ import { defaultWorkdirFor, gitTopLevel } from './workspace.js';
9
+ const INDEX = 'checkouts.json';
10
+ function git(dir, args, input) {
11
+ const result = spawnSync('git', args, { cwd: dir, input, encoding: 'utf8', maxBuffer: 1024 * 1024 * 1024 });
12
+ if (result.error)
13
+ throw new CliError(`Couldn't run git in ${dir}: ${result.error.message}`);
14
+ return { ok: result.status === 0, stdout: result.stdout ?? '', stderr: (result.stderr ?? '').trim() };
15
+ }
16
+ const patchFile = (repository) => `${repository.replace(/[^A-Za-z0-9_.-]/g, '_')}.patch`;
17
+ function filesIn(dir, patch) {
18
+ const numstat = git(dir, ['apply', '--numstat'], patch);
19
+ return numstat.ok ? numstat.stdout.split('\n').filter(Boolean).length : 0;
20
+ }
21
+ async function readIndex(dir) {
22
+ try {
23
+ return JSON.parse(await readFile(join(dir, INDEX), 'utf8'));
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ /** A key names its folder; a uuid is looked for among the tickets with applied changes. */
30
+ async function findAppliedDir(store, ref) {
31
+ const direct = join(ticketDir(store, ref.toUpperCase()), APPLIED);
32
+ if (await readIndex(direct))
33
+ return direct;
34
+ let entries;
35
+ try {
36
+ entries = await readdir(store);
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ for (const entry of entries) {
42
+ const dir = join(store, entry, APPLIED);
43
+ if ((await readIndex(dir))?.ticketUuid === ref.toLowerCase())
44
+ return dir;
45
+ }
46
+ return null;
47
+ }
48
+ /**
49
+ * Puts Sifur's unpushed work for a ticket in the engineer's checkouts, uncommitted, so they can deploy it.
50
+ * Every checkout is checked before any patch is applied.
51
+ */
52
+ export async function applyChanges(api, ref, options, io) {
53
+ const { ticketUuid, key, organization } = await resolveTicket(api, ref);
54
+ let patches;
55
+ try {
56
+ patches = (await api.request(`tickets/${ticketUuid}/changes/patch`, { organization: organization.uuid })) ?? [];
57
+ }
58
+ catch (error) {
59
+ if (error instanceof ApiError && [404, 409].includes(error.status))
60
+ throw new CliError(`Sifur has no changes for ${ref} to apply: ${error.message}`);
61
+ throw error;
62
+ }
63
+ if (patches.length === 0)
64
+ io.out(`${ref}: nothing to apply.`);
65
+ const withChanges = patches.filter((p) => p.patch.trim() !== '');
66
+ for (const p of patches)
67
+ if (!withChanges.includes(p))
68
+ io.out(`${p.repository}: nothing to apply.`);
69
+ if (withChanges.length === 0)
70
+ return;
71
+ const appliedDir = join(ticketDir(options.store, key ?? ('uuid' in parseTicketRef(ref) ? ref : ref.toUpperCase())), APPLIED);
72
+ const index = (await readIndex(appliedDir)) ?? { ticketUuid, checkouts: {} };
73
+ let workdirs = options.workdirs ?? {};
74
+ const named = new Set(patches.map((p) => p.repository));
75
+ const top = gitTopLevel(options.cwd);
76
+ for (const p of withChanges) {
77
+ const picked = defaultWorkdirFor(named, [p.repository], workdirs, top);
78
+ if (picked.chosen)
79
+ io.out(`Applying ${picked.chosen.repository}'s changes in ${picked.chosen.dir} (the checkout you're in).`);
80
+ workdirs = picked.workdirs;
81
+ }
82
+ const plan = [];
83
+ for (const p of withChanges) {
84
+ const given = workdirs[p.repository];
85
+ if (!given)
86
+ throw new CliError(`No checkout of ${p.repository}: run this from inside it, or pass --workdir ${p.repository}=<dir>.`);
87
+ const dir = resolve(given);
88
+ if (!existsSync(dir) || !statSync(dir).isDirectory())
89
+ throw new CliError(`--workdir ${p.repository}=${given}: no such directory.`);
90
+ const saved = join(appliedDir, patchFile(p.repository));
91
+ if (existsSync(saved)) {
92
+ throw new CliError(`Sifur's changes to ${p.repository} are already applied. Run \`sifur apply ${key ?? ref} --revert\` first, or delete ${saved} if you've taken them out yourself.`);
93
+ }
94
+ const status = git(dir, ['status', '--porcelain', '--untracked-files=no']);
95
+ if (!status.ok)
96
+ throw new CliError(`${dir} isn't a git checkout: ${status.stderr}`);
97
+ if (status.stdout.trim())
98
+ throw new CliError(`${dir} has uncommitted changes. Commit or stash them, then run this again.`);
99
+ if (!git(dir, ['cat-file', '-e', `${p.base}^{commit}`]).ok) {
100
+ throw new CliError(`${dir} doesn't have ${p.base.slice(0, 12)}, the commit Sifur's changes to ${p.repository} start from. Run \`git fetch\` and check out ${p.branch} first.`);
101
+ }
102
+ plan.push({ patch: p, dir });
103
+ }
104
+ await mkdir(appliedDir, { recursive: true });
105
+ for (const { patch, dir } of plan) {
106
+ const applied = git(dir, ['apply', '--3way', '--whitespace=nowarn'], patch.patch);
107
+ if (!applied.ok) {
108
+ throw new CliError(`Couldn't apply Sifur's changes to ${patch.repository} in ${dir} cleanly:\n${applied.stderr}\nSee \`git status\` there: resolve the conflicts, or put the checkout back as it was with \`git reset --hard\`.`);
109
+ }
110
+ await writeFile(join(appliedDir, patchFile(patch.repository)), patch.patch);
111
+ index.checkouts[patch.repository] = dir;
112
+ await writeFile(join(appliedDir, INDEX), `${JSON.stringify(index, null, 2)}\n`);
113
+ const count = filesIn(dir, patch.patch);
114
+ io.out(`${patch.repository}: applied Sifur's changes to ${count} file${count === 1 ? '' : 's'} in ${dir} (${patch.head.slice(0, 7)} on ${patch.branch}). Nothing was committed.`);
115
+ }
116
+ }
117
+ /** Takes back out what `applyChanges` put in, from the patches it saved. Needs no connection to Sifur. */
118
+ export async function revertChanges(ref, options, io) {
119
+ const appliedDir = await findAppliedDir(options.store, ref);
120
+ const index = appliedDir ? await readIndex(appliedDir) : null;
121
+ const repositories = Object.keys(index?.checkouts ?? {}).filter((r) => existsSync(join(appliedDir, patchFile(r))));
122
+ if (!appliedDir || !index || repositories.length === 0) {
123
+ io.out(`Nothing from ${ref} is applied.`);
124
+ return;
125
+ }
126
+ for (const repository of repositories) {
127
+ const dir = resolve(options.workdirs?.[repository] ?? index.checkouts[repository]);
128
+ const saved = join(appliedDir, patchFile(repository));
129
+ const patch = await readFile(saved, 'utf8');
130
+ const reverted = git(dir, ['apply', '-R', '--3way', '--whitespace=nowarn'], patch);
131
+ if (!reverted.ok) {
132
+ throw new CliError(`Couldn't take Sifur's changes to ${repository} back out of ${dir}:\n${reverted.stderr}\nThe patch is kept in ${saved}.`);
133
+ }
134
+ await rm(saved);
135
+ delete index.checkouts[repository];
136
+ await writeFile(join(appliedDir, INDEX), `${JSON.stringify(index, null, 2)}\n`);
137
+ io.out(`${repository}: took Sifur's changes back out of ${dir}.`);
138
+ }
139
+ if (Object.keys(index.checkouts).length === 0) {
140
+ await rm(appliedDir, { recursive: true, force: true });
141
+ await pruneTicketDir(options.store, dirname(appliedDir));
142
+ }
143
+ }
package/dist/automated.js CHANGED
@@ -22,7 +22,42 @@ export function runAutomated(command, cwd, options = {}) {
22
22
  status: code === 0 ? 'passed' : 'failed',
23
23
  note: `\`${command}\` ${ended}.${tail ? `\n${tail}` : ''}`,
24
24
  output,
25
+ exitCode: code,
25
26
  });
26
27
  });
27
28
  });
28
29
  }
30
+ /** The shell's "command not found" and "not executable": the checkout can't run the test, which says nothing about the ticket. */
31
+ export const COULD_NOT_RUN = new Set([126, 127]);
32
+ /** The program a command runs: its first word, past any `cd … &&` and VAR=value prefixes. */
33
+ export function programOf(command) {
34
+ const segment = command.split(/&&|\|\||;/).map((s) => s.trim()).find((s) => s && !/^cd(\s|$)/.test(s)) ?? command.trim();
35
+ const words = segment.split(/\s+/).filter((w) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(w));
36
+ return words[0] ?? segment;
37
+ }
38
+ /** The program the shell said it couldn't find, as in `sh: mocha: command not found`. */
39
+ export function notFoundIn(output) {
40
+ const match = /(?:^|\n)[^\n:]*?:\s*(?:line \d+:\s*)?([^\s:]+):\s*(?:command )?not found/.exec(output);
41
+ return match?.[1] ?? null;
42
+ }
43
+ const SCRIPT_RUNNERS = new Set(['npm', 'yarn', 'pnpm']);
44
+ export function missingProgram(command, exitCode, workdir, output = '') {
45
+ const program = programOf(command);
46
+ const words = command.trim().split(/\s+/);
47
+ // `npm run x` ran: what's missing is the tool its script calls, installed by `npm install`.
48
+ const reported = notFoundIn(output);
49
+ if (reported && reported !== program && SCRIPT_RUNNERS.has(program)) {
50
+ return `\`${reported}\` isn't installed in ${workdir}: run \`${program} install\`?`;
51
+ }
52
+ if (program === 'npx') {
53
+ const runs = words.slice(words.indexOf('npx') + 1).find((w) => !w.startsWith('-')) ?? 'its package';
54
+ return `\`npx\` couldn't run ${runs} in ${workdir}: run \`npm install\`?`;
55
+ }
56
+ const where = program.includes('/') ? `in ${workdir}` : 'on your PATH';
57
+ let message = exitCode === 126 ? `\`${program}\` isn't executable ${where}` : `\`${program}\` isn't ${where}`;
58
+ if (/(^|\/)vendor\/bin\//.test(program))
59
+ message += ': run `composer install`?';
60
+ else if (/(^|\/)node_modules\/\.bin\//.test(program))
61
+ message += ': run `npm install`?';
62
+ return message;
63
+ }
package/dist/bin.js CHANGED
File without changes
package/dist/cli.js CHANGED
@@ -1,14 +1,19 @@
1
- import { readFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
2
3
  import { parseArgs } from 'node:util';
3
4
  import { accept } from './accept.js';
4
5
  import { Api, ApiError, CliError, DEFAULT_API, normalizeApi } from './api.js';
5
- import { loadCredentials } from './config.js';
6
+ import { applyChanges, revertChanges } from './apply.js';
7
+ import { cacheDir, loadCredentials } from './config.js';
6
8
  import { login, logout } from './login.js';
9
+ import { LEGACY_STORE } from './store.js';
7
10
  const USAGE = `Usage:
8
11
  sifur login [--api <url>]
9
12
  sifur logout
10
- sifur accept <KEY|uuid> --url <test url> [--headed] [--workdir <repo>=<dir> …]
11
- sifur accept <KEY|uuid> --download
13
+ sifur apply <KEY|uuid> [--workdir <repo>=<dir> …]
14
+ sifur apply <KEY|uuid> --revert
15
+ sifur accept <KEY|uuid> --url <test url> [--apply] [--headed] [--workdir <repo>=<dir> …]
16
+ sifur accept <KEY|uuid> --download [--apply]
12
17
  sifur accept <KEY|uuid> --run --url <test url> [--headed] [--workdir <repo>=<dir> …]
13
18
  sifur accept <KEY|uuid> --upload
14
19
 
@@ -16,8 +21,12 @@ Options:
16
21
  --api <url> Sifur's API (default ${DEFAULT_API}, or SIFUR_API_URL)
17
22
  --url <url> Your test server, where the ticket's branch is deployed
18
23
  --headed Show the browser while the cases run
19
- --workdir <repo>=<dir> Run that repository's automated cases in <dir>; repeatable
20
- --download Save the cases to ./.sifur/<KEY>/ (before a full-tunnel VPN is up)
24
+ --timeout <seconds> How long each browser step may take (default 60; raise it for a slow VPN)
25
+ --apply Put Sifur's changes in your checkouts first, uncommitted, as \`sifur apply\` does
26
+ --revert Take the changes \`sifur apply\` put in back out
27
+ --workdir <repo>=<dir> Run that repository's automated cases in <dir>; repeatable. Without it,
28
+ the git checkout you're in, when it's that repository's
29
+ --download Save the cases to ${cacheDir()}/<KEY>/ (before a full-tunnel VPN is up)
21
30
  --run Run the saved cases and save the results (no connection to Sifur)
22
31
  --upload Send the saved results (after the VPN is down)
23
32
 
@@ -42,6 +51,15 @@ export function parseWorkdirs(values) {
42
51
  }
43
52
  return workdirs;
44
53
  }
54
+ const DEFAULT_TIMEOUT_SECONDS = 60;
55
+ export function timeoutSeconds(value) {
56
+ if (value === undefined)
57
+ return DEFAULT_TIMEOUT_SECONDS;
58
+ const seconds = Number(value);
59
+ if (!Number.isFinite(seconds) || seconds <= 0)
60
+ throw new CliError(`--timeout ${value}: expected a number of seconds.`);
61
+ return seconds;
62
+ }
45
63
  export async function main(argv, io, env = process.env, cwd = process.cwd()) {
46
64
  const { values, positionals } = parseArgs({
47
65
  args: argv,
@@ -50,10 +68,13 @@ export async function main(argv, io, env = process.env, cwd = process.cwd()) {
50
68
  api: { type: 'string' },
51
69
  url: { type: 'string' },
52
70
  headed: { type: 'boolean', default: false },
71
+ timeout: { type: 'string' },
53
72
  workdir: { type: 'string', multiple: true, default: [] },
54
73
  download: { type: 'boolean', default: false },
55
74
  run: { type: 'boolean', default: false },
56
75
  upload: { type: 'boolean', default: false },
76
+ apply: { type: 'boolean', default: false },
77
+ revert: { type: 'boolean', default: false },
57
78
  help: { type: 'boolean', short: 'h', default: false },
58
79
  version: { type: 'boolean', short: 'v', default: false },
59
80
  },
@@ -69,6 +90,9 @@ export async function main(argv, io, env = process.env, cwd = process.cwd()) {
69
90
  }
70
91
  const explicitApi = values.api ?? env.SIFUR_API_URL;
71
92
  const userAgent = `sifur-cli/${version()} node/${process.versions.node}`;
93
+ if ((command === 'accept' || command === 'apply') && existsSync(join(cwd, LEGACY_STORE))) {
94
+ io.out(`${join(cwd, LEGACY_STORE)} is left over from sifur 0.1.0, which kept its files there; nothing uses it now, so it can be deleted.`);
95
+ }
72
96
  switch (command) {
73
97
  case 'login': {
74
98
  const api = new Api(explicitApi ?? DEFAULT_API, null, userAgent);
@@ -78,6 +102,18 @@ export async function main(argv, io, env = process.env, cwd = process.cwd()) {
78
102
  case 'logout':
79
103
  await logout(io, env);
80
104
  return 0;
105
+ case 'apply': {
106
+ const ref = rest[0];
107
+ if (!ref || rest.length > 1)
108
+ throw new CliError('Usage: sifur apply <KEY|uuid> [--workdir <repo>=<dir> …]');
109
+ const options = { workdirs: parseWorkdirs(values.workdir ?? []), cwd, store: cacheDir(env) };
110
+ if (values.revert) {
111
+ await revertChanges(ref, options, io);
112
+ return 0;
113
+ }
114
+ await applyChanges(signedIn(await loadCredentials(env), explicitApi, userAgent), ref, options, io);
115
+ return 0;
116
+ }
81
117
  case 'accept': {
82
118
  const ref = rest[0];
83
119
  if (!ref || rest.length > 1)
@@ -87,15 +123,7 @@ export async function main(argv, io, env = process.env, cwd = process.cwd()) {
87
123
  throw new CliError('Use one of --download, --run and --upload at a time.');
88
124
  const mode = modes[0] ?? 'full';
89
125
  const credentials = await loadCredentials(env);
90
- let api = null;
91
- if (mode !== 'run') {
92
- if (!credentials)
93
- throw new CliError('Not signed in. Run `sifur login` first.');
94
- if (explicitApi && normalizeApi(explicitApi) !== normalizeApi(credentials.api)) {
95
- throw new CliError(`You're signed in to ${credentials.api}, not ${explicitApi}. Run \`sifur login --api ${explicitApi}\`.`);
96
- }
97
- api = new Api(credentials.api, credentials.token, userAgent);
98
- }
126
+ const api = mode !== 'run' || values.apply ? signedIn(credentials, explicitApi, userAgent) : null;
99
127
  return accept(api, {
100
128
  ref,
101
129
  mode,
@@ -103,18 +131,28 @@ export async function main(argv, io, env = process.env, cwd = process.cwd()) {
103
131
  headed: values.headed,
104
132
  workdirs: parseWorkdirs(values.workdir ?? []),
105
133
  cwd,
134
+ store: cacheDir(env),
135
+ apply: values.apply,
106
136
  consoleOrigin: env.SIFUR_CONSOLE_URL ?? credentials?.console ?? null,
107
- }, { io, env });
137
+ }, { io, env, timeoutMs: timeoutSeconds(values.timeout) * 1000 });
108
138
  }
109
139
  default:
110
140
  throw new CliError(`Unknown command "${command}".\n\n${USAGE}`);
111
141
  }
112
142
  }
143
+ function signedIn(credentials, explicitApi, userAgent) {
144
+ if (!credentials)
145
+ throw new CliError('Not signed in. Run `sifur login` first.');
146
+ if (explicitApi && normalizeApi(explicitApi) !== normalizeApi(credentials.api)) {
147
+ throw new CliError(`You're signed in to ${credentials.api}, not ${explicitApi}. Run \`sifur login --api ${explicitApi}\`.`);
148
+ }
149
+ return new Api(credentials.api, credentials.token, userAgent);
150
+ }
113
151
  export function describeError(error) {
114
152
  if (error instanceof ApiError && error.status === 401)
115
153
  return 'Your sign-in has expired or was revoked. Run `sifur login` again.';
116
154
  if (error instanceof ApiError)
117
- return `Sifur answered ${error.status}: ${error.message}`;
155
+ return error.fromSifur ? `Sifur answered ${error.status}: ${error.message}` : error.message;
118
156
  if (error instanceof CliError)
119
157
  return error.message;
120
158
  if (error instanceof TypeError && error.code?.startsWith('ERR_PARSE_ARGS'))
package/dist/config.js CHANGED
@@ -8,6 +8,12 @@ export function configDir(env = process.env, platform = process.platform) {
8
8
  return join(env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'), 'sifur');
9
9
  return join(env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'sifur');
10
10
  }
11
+ /** Where downloaded cases, results and screenshots are kept: outside the engineer's checkout. */
12
+ export function cacheDir(env = process.env, platform = process.platform) {
13
+ if (platform === 'win32')
14
+ return join(env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local'), 'sifur');
15
+ return join(env.XDG_CACHE_HOME || join(homedir(), '.cache'), 'sifur');
16
+ }
11
17
  export function credentialsPath(env = process.env) {
12
18
  return join(configDir(env), 'credentials.json');
13
19
  }
package/dist/login.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { hostname, userInfo } from 'node:os';
3
- import { Api, ApiError, CliError } from './api.js';
3
+ import { Api, ApiError, CliError, DEFAULT_API } from './api.js';
4
4
  import { deleteCredentials, loadCredentials, saveCredentials } from './config.js';
5
5
  const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
6
6
  export function openInBrowser(url) {
@@ -43,10 +43,21 @@ export async function login(api, deps) {
43
43
  const { io } = deps;
44
44
  const sleep = deps.sleep ?? wait;
45
45
  const now = deps.now ?? Date.now;
46
- const start = await api.request('auth/device', {
47
- method: 'POST',
48
- body: { clientName: (deps.clientName ?? defaultClientName()).slice(0, 100) },
49
- });
46
+ const notSifur = new CliError(`${api.base} isn't Sifur's API. --api is Sifur's address (for example ${DEFAULT_API}); your test server goes in \`accept --url\`.`);
47
+ let start;
48
+ try {
49
+ start = await api.request('auth/device', {
50
+ method: 'POST',
51
+ body: { clientName: (deps.clientName ?? defaultClientName()).slice(0, 100) },
52
+ });
53
+ }
54
+ catch (error) {
55
+ if (error instanceof ApiError && (!error.json || [404, 405, 419].includes(error.status)))
56
+ throw notSifur;
57
+ throw error;
58
+ }
59
+ if (!start?.deviceCode || !start.userCode || !start.verificationUrl)
60
+ throw notSifur;
50
61
  const url = withCode(start.verificationUrl, start.userCode);
51
62
  io.out(`Your code: ${start.userCode}`);
52
63
  io.out(`Approve it at ${url}`);
package/dist/resolve.js CHANGED
@@ -23,7 +23,8 @@ export async function resolveTicket(api, ref) {
23
23
  const ticket = await api.request(path, { organization: organization.uuid });
24
24
  if (!ticket?.uuid)
25
25
  throw new CliError(`Sifur found ${ref} but didn't say which ticket it is.`);
26
- return { ticketUuid: ticket.uuid, organization };
26
+ const key = ticket.key ?? ('projectKey' in parsed ? `${parsed.projectKey}-${parsed.number}` : null);
27
+ return { ticketUuid: ticket.uuid, key, organization };
27
28
  }
28
29
  catch (error) {
29
30
  if (error instanceof ApiError && [403, 404].includes(error.status))
package/dist/results.js CHANGED
@@ -25,8 +25,12 @@ export function combine(statuses) {
25
25
  return 'failed';
26
26
  if (statuses.includes('blocked'))
27
27
  return 'blocked';
28
+ if (statuses.length > 0 && statuses.every((status) => status === 'skipped'))
29
+ return 'skipped';
28
30
  return 'passed';
29
31
  }
32
+ /** Judged on the cases that ran, as Sifur judges it: a run where nothing ran proves nothing. */
30
33
  export function outcome(results) {
31
- return combine(results.map((result) => result.status));
34
+ const ran = results.filter((result) => result.status !== 'skipped');
35
+ return ran.length === 0 ? 'skipped' : combine(ran.map((result) => result.status));
32
36
  }
package/dist/runner.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { mkdir } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
- import { redact, withSecrets } from './secrets.js';
3
+ import { newRunToken, redact, resolveStep } from './secrets.js';
4
4
  const ASSERTIONS = new Set(['expectText', 'expectVisible', 'expectUrl']);
5
- const TIMEOUT = 15_000;
5
+ const TIMEOUT = 60_000;
6
6
  // An assertion after an action waits for the page to settle, as Playwright's own do: a
7
7
  // response that lands half a second later still counts.
8
8
  export async function eventually(check, timeoutMs = 10_000) {
@@ -18,46 +18,81 @@ export async function eventually(check, timeoutMs = 10_000) {
18
18
  }
19
19
  }
20
20
  }
21
- function first(page, selector) {
21
+ /**
22
+ * The first visible match in the page or any of its frames: older apps put whole screens in
23
+ * iframes, where the top page's locator never looks.
24
+ */
25
+ async function find(page, selector, timeoutMs) {
22
26
  if (!selector)
23
27
  throw new Error('the step has no selector');
24
- return page.locator(selector).first();
28
+ return eventually(async () => {
29
+ for (const frame of page.frames()) {
30
+ const match = frame.locator(selector).first();
31
+ if ((await match.count()) > 0 && (await match.isVisible().catch(() => false)))
32
+ return match;
33
+ }
34
+ throw new Error(`nothing visible matches ${selector}, in the page or its frames`);
35
+ }, timeoutMs);
36
+ }
37
+ /** The text of every frame's body: what a person reading the screen sees. */
38
+ async function screenText(page) {
39
+ const texts = await Promise.all(page.frames().map((frame) => frame.locator('body').innerText().catch(() => '')));
40
+ return texts.join('\n');
25
41
  }
26
- async function perform(page, step, baseUrl, secrets, settleMs) {
27
- const url = step.url ? new URL(step.url, baseUrl).toString() : null;
42
+ /**
43
+ * A case's path under `--url`, which may be the app's folder rather than the site's root
44
+ * (`https://test.example/team/app/`): `/login.php`, `login.php` and `#list` all open under it.
45
+ * A full URL is used as is.
46
+ */
47
+ export function caseUrl(path, baseUrl) {
48
+ if (/^[a-z][a-z0-9+.-]*:/i.test(path))
49
+ return path;
50
+ const base = new URL(baseUrl);
51
+ base.search = '';
52
+ base.hash = '';
53
+ // `…/app/login.php` is a page in the app's folder; `…/app` is the folder itself.
54
+ if (!base.pathname.endsWith('/')) {
55
+ base.pathname = /\.[a-z0-9]+$/i.test(base.pathname)
56
+ ? base.pathname.replace(/[^/]*$/, '')
57
+ : `${base.pathname}/`;
58
+ }
59
+ return new URL(path.replace(/^\/+/, ''), base).toString();
60
+ }
61
+ async function perform(page, step, baseUrl, settleMs) {
62
+ const url = step.url ? caseUrl(step.url, baseUrl) : null;
28
63
  switch (step.action) {
29
64
  case 'goto':
30
65
  if (!url)
31
66
  throw new Error('the step has no url');
32
- await page.goto(url, { waitUntil: 'networkidle' });
67
+ // `load`, not `networkidle`: an app that polls never goes quiet, least of all over a VPN.
68
+ await page.goto(url, { waitUntil: 'load' });
33
69
  break;
34
70
  case 'click':
35
- await first(page, step.selector).click();
71
+ await (await find(page, step.selector, settleMs)).click();
36
72
  break;
37
73
  case 'fill':
38
- await first(page, step.selector).fill(withSecrets(String(step.value ?? ''), secrets));
74
+ await (await find(page, step.selector, settleMs)).fill(String(step.value ?? ''));
39
75
  break;
40
76
  case 'press':
41
- await first(page, step.selector).press(String(step.value));
77
+ await (await find(page, step.selector, settleMs)).press(String(step.value));
42
78
  break;
43
79
  case 'select':
44
- await first(page, step.selector).selectOption(String(step.value));
80
+ await (await find(page, step.selector, settleMs)).selectOption(String(step.value));
45
81
  break;
46
82
  case 'wait':
47
83
  await page.waitForTimeout(Math.min(Number(step.value ?? 500), 10_000));
48
84
  break;
49
85
  case 'expectText': {
50
- const target = step.selector ? page.locator(step.selector).first() : page.locator('body');
51
- await target.waitFor();
86
+ const selector = step.selector;
52
87
  await eventually(async () => {
53
- const text = await target.innerText();
88
+ const text = selector ? await (await find(page, selector, 0)).innerText() : await screenText(page);
54
89
  if (!text.includes(String(step.text)))
55
- throw new Error(`expected "${step.text}" in ${step.selector ?? 'the page'}`);
90
+ throw new Error(`expected "${step.text}" in ${selector ?? 'the page'}`);
56
91
  }, settleMs);
57
92
  break;
58
93
  }
59
94
  case 'expectVisible':
60
- await first(page, step.selector).waitFor({ state: 'visible' });
95
+ await find(page, step.selector, settleMs);
61
96
  break;
62
97
  case 'expectUrl':
63
98
  await eventually(async () => {
@@ -72,9 +107,38 @@ async function perform(page, step, baseUrl, secrets, settleMs) {
72
107
  throw new Error(`unknown action ${step.action}`);
73
108
  }
74
109
  }
110
+ /** What a step does, for one with no caption: a screenshot labeled "expectText" says nothing. */
111
+ export function describe(step) {
112
+ const target = step.selector ?? '';
113
+ switch (step.action) {
114
+ case 'goto':
115
+ return `Open ${step.url ?? ''}`.trim();
116
+ case 'click':
117
+ return `Click ${target}`.trim();
118
+ case 'fill':
119
+ return `Type into ${target}`.trim();
120
+ case 'press':
121
+ return `Press ${step.value ?? ''} in ${target}`.trim();
122
+ case 'select':
123
+ return `Choose ${step.value ?? ''} in ${target}`.trim();
124
+ case 'wait':
125
+ return `Wait ${Number(step.value ?? 500) / 1000}s`;
126
+ case 'expectText':
127
+ return `Expect "${step.text ?? ''}"${target ? ` in ${target}` : ''}`;
128
+ case 'expectVisible':
129
+ return `Expect ${target} to show`;
130
+ case 'expectUrl':
131
+ return `Expect the address to contain "${step.text ?? ''}"`;
132
+ case 'judge':
133
+ return 'Check the screen';
134
+ default:
135
+ return step.action;
136
+ }
137
+ }
75
138
  /** One case's UI script, as the sandbox's runner plays it: a screenshot after every step, stopping at the first failure. */
76
139
  export async function runUiCase(browser, testCase, options) {
77
140
  const secrets = options.secrets ?? {};
141
+ const runToken = options.runToken ?? newRunToken();
78
142
  const dir = join(options.outDir, testCase.id);
79
143
  await mkdir(dir, { recursive: true });
80
144
  // Light, always: an app that follows the system theme is judged in one, the same every run.
@@ -87,11 +151,12 @@ export async function runUiCase(browser, testCase, options) {
87
151
  try {
88
152
  for (const [index, step] of (testCase.uiScript ?? []).entries()) {
89
153
  const n = index + 1;
90
- const caption = step.caption ?? step.action;
154
+ const caption = redact(step.caption ?? describe(step), secrets);
91
155
  let ok = true;
92
156
  let error = null;
157
+ options.onStep?.(n, caption);
93
158
  try {
94
- await perform(page, step, options.baseUrl, secrets, Math.min(10_000, options.timeoutMs ?? 10_000));
159
+ await perform(page, resolveStep(step, secrets, runToken), options.baseUrl, options.timeoutMs ?? TIMEOUT);
95
160
  }
96
161
  catch (caught) {
97
162
  ok = false;