@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/README.md +114 -19
- package/dist/accept.js +126 -22
- 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/results.js +5 -1
- package/dist/runner.js +83 -18
- package/dist/secrets.js +66 -12
- package/dist/store.js +25 -6
- package/dist/workspace.js +142 -0
- package/package.json +1 -1
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 {
|
|
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/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
|
-
|
|
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,
|
|
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;
|