@sifurai/cli 0.1.0 → 0.1.1
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 +114 -19
- package/dist/accept.js +115 -20
- package/dist/api.js +34 -5
- package/dist/apply.js +143 -0
- package/dist/automated.js +35 -0
- package/dist/bin.js +0 -0
- package/dist/cli.js +55 -17
- package/dist/config.js +6 -0
- package/dist/login.js +16 -5
- package/dist/resolve.js +2 -1
- package/dist/runner.js +83 -18
- package/dist/secrets.js +63 -12
- package/dist/store.js +25 -6
- package/dist/workspace.js +142 -0
- package/package.json +1 -1
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 {
|
|
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
|
|
11
|
-
sifur
|
|
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
|
-
--
|
|
20
|
-
--
|
|
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
|
-
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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/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,
|
|
3
|
+
import { newRunToken, redact, resolveStep } from './secrets.js';
|
|
4
4
|
const ASSERTIONS = new Set(['expectText', 'expectVisible', 'expectUrl']);
|
|
5
|
-
const TIMEOUT =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
|
71
|
+
await (await find(page, step.selector, settleMs)).click();
|
|
36
72
|
break;
|
|
37
73
|
case 'fill':
|
|
38
|
-
await
|
|
74
|
+
await (await find(page, step.selector, settleMs)).fill(String(step.value ?? ''));
|
|
39
75
|
break;
|
|
40
76
|
case 'press':
|
|
41
|
-
await
|
|
77
|
+
await (await find(page, step.selector, settleMs)).press(String(step.value));
|
|
42
78
|
break;
|
|
43
79
|
case 'select':
|
|
44
|
-
await
|
|
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
|
|
51
|
-
await target.waitFor();
|
|
86
|
+
const selector = step.selector;
|
|
52
87
|
await eventually(async () => {
|
|
53
|
-
const text = await
|
|
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 ${
|
|
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
|
|
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
|
|
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,
|
|
159
|
+
await perform(page, resolveStep(step, secrets, runToken), options.baseUrl, options.timeoutMs ?? TIMEOUT);
|
|
95
160
|
}
|
|
96
161
|
catch (caught) {
|
|
97
162
|
ok = false;
|
package/dist/secrets.js
CHANGED
|
@@ -1,28 +1,79 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
1
2
|
const PLACEHOLDER = /\{\{([A-Z][A-Z0-9_]*)\}\}/g;
|
|
2
|
-
|
|
3
|
-
|
|
3
|
+
/** Not a secret: each run's own token, for data a case creates and can create again next run. */
|
|
4
|
+
export const RUN = 'RUN';
|
|
5
|
+
const ECHOED = /USER|EMAIL|LOGIN/;
|
|
6
|
+
const HIDDEN = /PASSWORD|TOKEN|SECRET/;
|
|
7
|
+
/** Where a step's secrets go: what it types, the page it opens, and the text it checks for. */
|
|
8
|
+
function secretFields(step) {
|
|
9
|
+
switch (step.action) {
|
|
10
|
+
case 'fill':
|
|
11
|
+
return ['value'];
|
|
12
|
+
case 'goto':
|
|
13
|
+
return ['url'];
|
|
14
|
+
case 'expectText':
|
|
15
|
+
case 'expectUrl':
|
|
16
|
+
return ['text'];
|
|
17
|
+
default:
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Each secret the scripts use, with the first step that needs it. */
|
|
22
|
+
export function secretUses(cases) {
|
|
23
|
+
const uses = new Map();
|
|
4
24
|
for (const testCase of cases) {
|
|
5
|
-
for (const step of testCase.uiScript ?? []) {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
25
|
+
for (const [index, step] of (testCase.uiScript ?? []).entries()) {
|
|
26
|
+
for (const field of secretFields(step)) {
|
|
27
|
+
for (const match of String(step[field] ?? '').matchAll(PLACEHOLDER)) {
|
|
28
|
+
const name = match[1];
|
|
29
|
+
if (name !== RUN && !uses.has(name))
|
|
30
|
+
uses.set(name, { name, caseId: testCase.id, step: index + 1, caption: step.caption });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
10
33
|
}
|
|
11
34
|
}
|
|
12
|
-
return [...
|
|
35
|
+
return [...uses.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
36
|
+
}
|
|
37
|
+
export function secretNames(cases) {
|
|
38
|
+
return secretUses(cases).map((use) => use.name);
|
|
13
39
|
}
|
|
14
|
-
/** From `SIFUR_SECRET_<NAME>`, or typed
|
|
15
|
-
export async function collectSecrets(
|
|
40
|
+
/** From `SIFUR_SECRET_<NAME>`, or typed. Kept in memory only: never written or sent to Sifur. */
|
|
41
|
+
export async function collectSecrets(uses, io, env = process.env) {
|
|
16
42
|
const secrets = {};
|
|
17
|
-
for (const name of
|
|
43
|
+
for (const { name, caseId, step, caption } of uses) {
|
|
18
44
|
const fromEnv = env[`SIFUR_SECRET_${name}`];
|
|
19
|
-
|
|
45
|
+
if (fromEnv !== undefined) {
|
|
46
|
+
secrets[name] = fromEnv;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
io.out(`${caseId} uses {{${name}}} in step ${step}${caption ? ` (${caption})` : ''}.`);
|
|
50
|
+
const prompt = `${name} (or set SIFUR_SECRET_${name}): `;
|
|
51
|
+
secrets[name] = ECHOED.test(name) && !HIDDEN.test(name) ? await io.ask(prompt) : await io.askHidden(prompt);
|
|
20
52
|
}
|
|
21
53
|
return secrets;
|
|
22
54
|
}
|
|
23
55
|
export function withSecrets(value, secrets) {
|
|
24
56
|
return value.replace(PLACEHOLDER, (match, name) => secrets[name] ?? match);
|
|
25
57
|
}
|
|
58
|
+
export function newRunToken() {
|
|
59
|
+
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
|
60
|
+
return [...randomBytes(8)].map((byte) => alphabet[byte % alphabet.length]).join('');
|
|
61
|
+
}
|
|
62
|
+
/** The step as it's played: this run's token in every field, secrets where they belong. */
|
|
63
|
+
export function resolveStep(step, secrets, runToken) {
|
|
64
|
+
const resolved = { ...step };
|
|
65
|
+
for (const field of ['url', 'selector', 'value', 'text']) {
|
|
66
|
+
const value = resolved[field];
|
|
67
|
+
if (typeof value === 'string')
|
|
68
|
+
resolved[field] = value.replaceAll(`{{${RUN}}}`, runToken);
|
|
69
|
+
}
|
|
70
|
+
for (const field of secretFields(step)) {
|
|
71
|
+
const value = resolved[field];
|
|
72
|
+
if (value !== undefined)
|
|
73
|
+
resolved[field] = withSecrets(String(value), secrets);
|
|
74
|
+
}
|
|
75
|
+
return resolved;
|
|
76
|
+
}
|
|
26
77
|
export function redact(text, secrets) {
|
|
27
78
|
let redacted = text;
|
|
28
79
|
for (const value of Object.values(secrets))
|
package/dist/store.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { mkdir, readdir, readFile, rm, rmdir, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
3
3
|
import { CliError } from './api.js';
|
|
4
|
-
|
|
4
|
+
/** Where 0.1.0 kept a ticket's files, in the directory it ran from. Never read or written now. */
|
|
5
|
+
export const LEGACY_STORE = '.sifur';
|
|
6
|
+
/** Folder of a patch `sifur apply` put in a checkout, kept until `--revert` needs it. */
|
|
7
|
+
export const APPLIED = 'applied';
|
|
5
8
|
export function ticketDir(root, key) {
|
|
6
|
-
return join(root,
|
|
9
|
+
return join(root, key.replace(/[^A-Za-z0-9_-]/g, '_'));
|
|
7
10
|
}
|
|
8
11
|
export async function saveCases(root, saved) {
|
|
9
12
|
const dir = ticketDir(root, saved.acceptance.ticket.key);
|
|
@@ -44,15 +47,15 @@ export async function findTicketDir(root, ref) {
|
|
|
44
47
|
return direct;
|
|
45
48
|
let entries;
|
|
46
49
|
try {
|
|
47
|
-
entries = await readdir(
|
|
50
|
+
entries = await readdir(root);
|
|
48
51
|
}
|
|
49
52
|
catch {
|
|
50
53
|
return null;
|
|
51
54
|
}
|
|
52
55
|
for (const entry of entries) {
|
|
53
|
-
const saved = await readJson(join(root,
|
|
56
|
+
const saved = await readJson(join(root, entry, 'cases.json'));
|
|
54
57
|
if (saved?.acceptance.ticket.uuid === ref.toLowerCase())
|
|
55
|
-
return join(root,
|
|
58
|
+
return join(root, entry);
|
|
56
59
|
}
|
|
57
60
|
return null;
|
|
58
61
|
}
|
|
@@ -68,3 +71,19 @@ export async function loadResults(dir) {
|
|
|
68
71
|
throw new CliError(`No results in ${dir}. Run with --run first.`);
|
|
69
72
|
return mapScreenshots(saved, (screenshot) => resolve(dir, screenshot));
|
|
70
73
|
}
|
|
74
|
+
async function removeIfEmpty(dir) {
|
|
75
|
+
await rmdir(dir).catch(() => { });
|
|
76
|
+
}
|
|
77
|
+
/** Removes a ticket's folder, and the store's own folder when nothing else is left in it. */
|
|
78
|
+
export async function pruneTicketDir(store, dir) {
|
|
79
|
+
await removeIfEmpty(dir);
|
|
80
|
+
await removeIfEmpty(store);
|
|
81
|
+
}
|
|
82
|
+
/** Everything kept for a ticket once its results are in, except a patch that's still applied. */
|
|
83
|
+
export async function removeTicketFiles(store, dir) {
|
|
84
|
+
const entries = await readdir(dir).catch(() => []);
|
|
85
|
+
for (const entry of entries)
|
|
86
|
+
if (entry !== APPLIED)
|
|
87
|
+
await rm(join(dir, entry), { recursive: true, force: true });
|
|
88
|
+
await pruneTicketDir(store, dir);
|
|
89
|
+
}
|