@sifurai/cli 0.1.2 → 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 +80 -4
- package/dist/accept.js +38 -7
- package/dist/browser.js +5 -1
- package/dist/cli.js +24 -1
- package/dist/codegen.js +680 -0
- package/dist/evidence.js +156 -0
- package/dist/record.js +333 -0
- package/dist/results.js +2 -0
- package/dist/runner.js +14 -5
- package/package.json +1 -1
package/dist/evidence.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { redact } from './secrets.js';
|
|
2
|
+
export const MAX_TEXT = 20_000;
|
|
3
|
+
export const MAX_ELEMENTS = 40;
|
|
4
|
+
export const MAX_NAME = 120;
|
|
5
|
+
// The API's limits: one value over them and the whole upload is refused.
|
|
6
|
+
export const MAX_SELECTOR = 512;
|
|
7
|
+
export const MAX_URL = 2048;
|
|
8
|
+
const CAPTURE_TIMEOUT = 10_000;
|
|
9
|
+
const SENSITIVE_KEY = /token|passw|pwd|secret|session|key|auth|signature|jwt|otp|^sid$|^code$|^sig$/i;
|
|
10
|
+
/** The page's address with secrets taken out, and the values of query (or fragment) keys that usually carry one. */
|
|
11
|
+
export function redactUrl(url, secrets) {
|
|
12
|
+
let redacted = url;
|
|
13
|
+
for (const value of Object.values(secrets)) {
|
|
14
|
+
if (!value)
|
|
15
|
+
continue;
|
|
16
|
+
redacted = redacted.replaceAll(value, '[secret]');
|
|
17
|
+
const encoded = encodeURIComponent(value);
|
|
18
|
+
if (encoded !== value)
|
|
19
|
+
redacted = redacted.replaceAll(encoded, '[secret]');
|
|
20
|
+
}
|
|
21
|
+
return redacted.replace(/([?&#;])([^=&#;?]+)=([^&#;]*)/g, (match, separator, key) => {
|
|
22
|
+
let name = key;
|
|
23
|
+
try {
|
|
24
|
+
name = decodeURIComponent(key);
|
|
25
|
+
}
|
|
26
|
+
catch { }
|
|
27
|
+
return SENSITIVE_KEY.test(name) ? `${separator}${key}=[redacted]` : match;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
/** The label `role=button[name="Save"]` wants: quotes and backslashes escaped. */
|
|
31
|
+
function quoted(value) {
|
|
32
|
+
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
33
|
+
}
|
|
34
|
+
// Plain JavaScript, run in each frame: the project compiles without the DOM's types.
|
|
35
|
+
const SCAN = `(limit) => {
|
|
36
|
+
const roles = { A: 'link', BUTTON: 'button', SELECT: 'combobox', TEXTAREA: 'textbox', SUMMARY: 'button' }
|
|
37
|
+
const inputRoles = { button: 'button', submit: 'button', reset: 'button', image: 'button', checkbox: 'checkbox', radio: 'radio', range: 'slider', search: 'searchbox' }
|
|
38
|
+
const clean = (s) => (s || '').replace(/\\s+/g, ' ').trim()
|
|
39
|
+
const body = document.body
|
|
40
|
+
const text = body ? body.innerText || '' : ''
|
|
41
|
+
const found = []
|
|
42
|
+
if (!body) return { text, elements: found }
|
|
43
|
+
const query = 'a[href], button, input:not([type=hidden]), select, textarea, summary, [role=button], [role=link], [role=checkbox], [role=radio], [role=tab], [role=menuitem], [role=option], [role=switch], [role=combobox], [role=textbox], [contenteditable=""], [contenteditable=true]'
|
|
44
|
+
for (const el of document.querySelectorAll(query)) {
|
|
45
|
+
if (found.length >= limit) break
|
|
46
|
+
const rect = el.getBoundingClientRect()
|
|
47
|
+
if (rect.width === 0 || rect.height === 0) continue
|
|
48
|
+
if (typeof el.checkVisibility === 'function' && !el.checkVisibility({ visibilityProperty: true, opacityProperty: true })) continue
|
|
49
|
+
const tag = el.tagName
|
|
50
|
+
const type = (el.getAttribute('type') || 'text').toLowerCase()
|
|
51
|
+
const role = el.getAttribute('role') || (tag === 'INPUT' ? inputRoles[type] || 'textbox' : roles[tag]) || (el.isContentEditable ? 'textbox' : tag.toLowerCase())
|
|
52
|
+
let name = clean(el.getAttribute('aria-label'))
|
|
53
|
+
const labelledBy = el.getAttribute('aria-labelledby')
|
|
54
|
+
if (!name && labelledBy) name = clean(labelledBy.split(/\\s+/).map((id) => document.getElementById(id)?.innerText || '').join(' '))
|
|
55
|
+
if (!name && el.labels && el.labels.length) name = clean([...el.labels].map((l) => l.innerText).join(' '))
|
|
56
|
+
if (!name && tag !== 'INPUT' && tag !== 'TEXTAREA' && tag !== 'SELECT') name = clean(el.innerText)
|
|
57
|
+
if (!name && tag === 'INPUT' && ['button', 'submit', 'reset'].includes(type)) name = clean(el.value)
|
|
58
|
+
if (!name) name = clean(el.getAttribute('placeholder') || el.getAttribute('title') || el.getAttribute('alt'))
|
|
59
|
+
found.push({ role, name, id: el.id || null, nameAttr: el.getAttribute('name'), tag: tag.toLowerCase(), x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 })
|
|
60
|
+
}
|
|
61
|
+
return { text, elements: found }
|
|
62
|
+
}`;
|
|
63
|
+
function selectorFor(element) {
|
|
64
|
+
if (element.id && /^[A-Za-z][\w-]*$/.test(element.id))
|
|
65
|
+
return `#${element.id}`;
|
|
66
|
+
if (element.nameAttr)
|
|
67
|
+
return `${element.tag}[name=${quoted(element.nameAttr)}]`;
|
|
68
|
+
if (element.name)
|
|
69
|
+
return `role=${element.role}[name=${quoted(element.name)}]`;
|
|
70
|
+
return element.tag;
|
|
71
|
+
}
|
|
72
|
+
/** `label=Email` is the field a label names, as Sifur writes the sign-in for a project. */
|
|
73
|
+
export function locate(frame, selector) {
|
|
74
|
+
const label = /^label=(.+)$/.exec(selector)?.[1];
|
|
75
|
+
return (label ? frame.getByLabel(label, { exact: true }) : frame.locator(selector)).first();
|
|
76
|
+
}
|
|
77
|
+
/** The center of what the step names, in the top page's coordinates, when it's on the screen. */
|
|
78
|
+
async function targetCenter(page, step) {
|
|
79
|
+
if (!step.selector)
|
|
80
|
+
return null;
|
|
81
|
+
for (const frame of page.frames()) {
|
|
82
|
+
try {
|
|
83
|
+
const box = await locate(frame, step.selector).boundingBox({ timeout: 500 });
|
|
84
|
+
if (box)
|
|
85
|
+
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
|
86
|
+
}
|
|
87
|
+
catch { }
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
/** Where a frame's own coordinates start on the top page. */
|
|
92
|
+
async function frameOffset(frame, page) {
|
|
93
|
+
if (frame === page.mainFrame())
|
|
94
|
+
return { x: 0, y: 0 };
|
|
95
|
+
const box = await (await frame.frameElement()).boundingBox();
|
|
96
|
+
return box ? { x: box.x, y: box.y } : { x: 0, y: 0 };
|
|
97
|
+
}
|
|
98
|
+
async function capture(page, step, secrets) {
|
|
99
|
+
const texts = [];
|
|
100
|
+
const elements = [];
|
|
101
|
+
let framesUnavailable = 0;
|
|
102
|
+
for (const frame of page.frames()) {
|
|
103
|
+
try {
|
|
104
|
+
const scan = await frame.evaluate(`(${SCAN})(${MAX_ELEMENTS * 10})`);
|
|
105
|
+
const offset = await frameOffset(frame, page).catch(() => ({ x: 0, y: 0 }));
|
|
106
|
+
texts.push(scan.text);
|
|
107
|
+
for (const element of scan.elements) {
|
|
108
|
+
elements.push({
|
|
109
|
+
role: element.role,
|
|
110
|
+
name: redact(element.name, secrets).slice(0, MAX_NAME),
|
|
111
|
+
selector: redact(selectorFor(element), secrets).slice(0, MAX_SELECTOR),
|
|
112
|
+
x: element.x + offset.x,
|
|
113
|
+
y: element.y + offset.y,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// The top page unread is no evidence at all; a frame unread is said, not guessed at.
|
|
119
|
+
if (frame === page.mainFrame())
|
|
120
|
+
return null;
|
|
121
|
+
framesUnavailable++;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const target = await targetCenter(page, step);
|
|
125
|
+
if (target) {
|
|
126
|
+
const distance = (e) => Math.hypot(e.x - target.x, e.y - target.y);
|
|
127
|
+
elements.sort((a, b) => distance(a) - distance(b));
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
url: redactUrl(page.url(), secrets).slice(0, MAX_URL),
|
|
131
|
+
text: redact(texts.join('\n').replace(/\s+/g, ' ').trim(), secrets).slice(0, MAX_TEXT),
|
|
132
|
+
elements: elements.slice(0, MAX_ELEMENTS).map(({ role, name, selector }) => ({ role, name, selector })),
|
|
133
|
+
...(framesUnavailable > 0 ? { framesUnavailable } : {}),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* What the screen showed when a step failed, for telling a wrong app from a wrong test. Null rather
|
|
138
|
+
* than an error when it can't be read: the result matters more than the evidence.
|
|
139
|
+
*/
|
|
140
|
+
export async function captureEvidence(page, step, secrets) {
|
|
141
|
+
let timer;
|
|
142
|
+
try {
|
|
143
|
+
return await Promise.race([
|
|
144
|
+
capture(page, step, secrets),
|
|
145
|
+
new Promise((resolve) => {
|
|
146
|
+
timer = setTimeout(() => resolve(null), CAPTURE_TIMEOUT);
|
|
147
|
+
}),
|
|
148
|
+
]);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
clearTimeout(timer);
|
|
155
|
+
}
|
|
156
|
+
}
|
package/dist/record.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { readFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { mkdtemp, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { download, parseBaseUrl, ticketLink } from './accept.js';
|
|
8
|
+
import { ApiError, CliError } from './api.js';
|
|
9
|
+
import { launchChannel } from './browser.js';
|
|
10
|
+
import { parseRecording, SUPPORTED } from './codegen.js';
|
|
11
|
+
import { describe, perform } from './runner.js';
|
|
12
|
+
import { collectSecrets, newRunToken, redact, resolveStep, RUN, secretUses } from './secrets.js';
|
|
13
|
+
const ASSERTIONS = new Set(['expectText', 'expectVisible', 'expectUrl']);
|
|
14
|
+
const PLACEHOLDER_ONLY = /^\{\{[A-Z][A-Z0-9_]*\}\}$/;
|
|
15
|
+
const PLACEHOLDER_NAME = /^[A-Z][A-Z0-9_]*$/;
|
|
16
|
+
// A field a sign-in typically has, by its selector or label: what's typed there is a credential.
|
|
17
|
+
const CREDENTIAL_FIELD = /pass(word|code)?\b|passwd|pwd|secret|token|e-?mail|user[\s_-]?name|login|type=["']?password/i;
|
|
18
|
+
const MAX_STEPS = 200;
|
|
19
|
+
// As the runner plays the case, so the recording sees the same layout.
|
|
20
|
+
const VIEWPORT = { width: 1440, height: 900 };
|
|
21
|
+
const TIMEOUT = 60_000;
|
|
22
|
+
/**
|
|
23
|
+
* The sign-in Sifur writes in front of a UI case: a goto, fills that each type one placeholder,
|
|
24
|
+
* then the click on the submit button. Matched by that shape rather than as the steps every case
|
|
25
|
+
* shares, which with one case, or cases that all open the same screen, would take in more.
|
|
26
|
+
*/
|
|
27
|
+
export function signInPrefix(script) {
|
|
28
|
+
const [open, ...rest] = script;
|
|
29
|
+
if (open?.action !== 'goto' || !open.url)
|
|
30
|
+
return [];
|
|
31
|
+
let fills = 0;
|
|
32
|
+
while (rest[fills]?.action === 'fill' && PLACEHOLDER_ONLY.test(String(rest[fills].value ?? '')))
|
|
33
|
+
fills++;
|
|
34
|
+
const submit = rest[fills];
|
|
35
|
+
if (fills === 0 || submit?.action !== 'click' || !/^role=button\[name=".*"\]$/.test(submit.selector ?? ''))
|
|
36
|
+
return [];
|
|
37
|
+
return script.slice(0, fills + 2);
|
|
38
|
+
}
|
|
39
|
+
/** The sign-in of the case being recorded, else of any UI case: it's the same for every one. */
|
|
40
|
+
export function findSignIn(cases, target) {
|
|
41
|
+
const candidates = [target, ...cases.filter((c) => c.check !== 'automated')];
|
|
42
|
+
for (const testCase of candidates) {
|
|
43
|
+
const prefix = signInPrefix(testCase?.uiScript ?? []);
|
|
44
|
+
if (prefix.length > 0)
|
|
45
|
+
return prefix;
|
|
46
|
+
}
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
async function playSignIn(prefix, options) {
|
|
50
|
+
const { browser, channel } = await launchChannel({ headed: options.headed });
|
|
51
|
+
try {
|
|
52
|
+
const context = await browser.newContext({ viewport: VIEWPORT, colorScheme: 'light' });
|
|
53
|
+
const page = await context.newPage();
|
|
54
|
+
page.setDefaultTimeout(options.timeoutMs);
|
|
55
|
+
const runToken = newRunToken();
|
|
56
|
+
let signInUrl = '';
|
|
57
|
+
for (const [index, step] of prefix.entries()) {
|
|
58
|
+
try {
|
|
59
|
+
await perform(page, resolveStep(step, options.secrets, runToken), options.baseUrl, options.timeoutMs);
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
const message = redact(String(error?.message ?? error).split('\n')[0] ?? '', options.secrets);
|
|
63
|
+
throw new CliError(`Couldn't sign in: step ${index + 1} (${step.caption ?? describe(step)}) failed: ${message}`);
|
|
64
|
+
}
|
|
65
|
+
if (index === 0)
|
|
66
|
+
signInUrl = page.url();
|
|
67
|
+
}
|
|
68
|
+
let stayed = false;
|
|
69
|
+
if (prefix.length > 0) {
|
|
70
|
+
// The session is there once the browser has left the sign-in page, not when the button is clicked.
|
|
71
|
+
await page.waitForURL((url) => url.toString() !== signInUrl, { timeout: Math.min(options.timeoutMs, 15_000) }).catch(() => {
|
|
72
|
+
stayed = true;
|
|
73
|
+
});
|
|
74
|
+
await page.waitForLoadState('load').catch(() => { });
|
|
75
|
+
}
|
|
76
|
+
const url = prefix.length > 0 ? page.url() : options.baseUrl;
|
|
77
|
+
return { storageState: await context.storageState({ indexedDB: true }), url, channel, stayed };
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
await browser.close().catch(() => { });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** Playwright's documented recorder, from the playwright-core this CLI ships with (not whatever npx finds). */
|
|
84
|
+
export function codegenCommand(options) {
|
|
85
|
+
const require = createRequire(import.meta.url);
|
|
86
|
+
const manifest = require.resolve('playwright-core/package.json');
|
|
87
|
+
const bin = JSON.parse(readFileSync(manifest, 'utf8')).bin['playwright-core'];
|
|
88
|
+
return {
|
|
89
|
+
command: process.execPath,
|
|
90
|
+
args: [
|
|
91
|
+
join(dirname(manifest), bin),
|
|
92
|
+
'codegen',
|
|
93
|
+
'--target',
|
|
94
|
+
'playwright-test',
|
|
95
|
+
'--channel',
|
|
96
|
+
options.channel,
|
|
97
|
+
'--load-storage',
|
|
98
|
+
options.storageState,
|
|
99
|
+
'--viewport-size',
|
|
100
|
+
`${VIEWPORT.width},${VIEWPORT.height}`,
|
|
101
|
+
'--color-scheme',
|
|
102
|
+
'light',
|
|
103
|
+
'-o',
|
|
104
|
+
options.output,
|
|
105
|
+
options.url,
|
|
106
|
+
],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
async function runCodegen(options) {
|
|
110
|
+
const { command, args } = codegenCommand(options);
|
|
111
|
+
await new Promise((resolve, reject) => {
|
|
112
|
+
// No stdin: the CLI's own questions keep the terminal.
|
|
113
|
+
const child = spawn(command, args, { stdio: ['ignore', 'inherit', 'inherit'] });
|
|
114
|
+
child.on('error', (error) => reject(new CliError(`Couldn't start Playwright's recorder: ${error.message}`)));
|
|
115
|
+
child.on('exit', () => resolve());
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
/** Removes the folder on Ctrl-C too: it holds the signed-in session, and may hold what was typed. */
|
|
119
|
+
function removeOnExit(dir) {
|
|
120
|
+
const remove = () => rmSync(dir, { recursive: true, force: true });
|
|
121
|
+
const onSignal = (signal) => {
|
|
122
|
+
remove();
|
|
123
|
+
process.exit(signal === 'SIGINT' ? 130 : 143);
|
|
124
|
+
};
|
|
125
|
+
process.on('exit', remove);
|
|
126
|
+
process.on('SIGINT', onSignal);
|
|
127
|
+
process.on('SIGTERM', onSignal);
|
|
128
|
+
return () => {
|
|
129
|
+
process.off('exit', remove);
|
|
130
|
+
process.off('SIGINT', onSignal);
|
|
131
|
+
process.off('SIGTERM', onSignal);
|
|
132
|
+
remove();
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Signs in, records, and reads what the recorder wrote. The signed-in session and the raw recording
|
|
137
|
+
* (which can hold anything typed, passwords included) live in a private temporary folder that is
|
|
138
|
+
* removed before this returns, whatever happens.
|
|
139
|
+
*/
|
|
140
|
+
export async function record(prefix, options, deps) {
|
|
141
|
+
const { io } = deps;
|
|
142
|
+
const dir = await mkdtemp(join(tmpdir(), 'sifur-record-'));
|
|
143
|
+
const cleanup = removeOnExit(dir);
|
|
144
|
+
try {
|
|
145
|
+
if (prefix.length > 0)
|
|
146
|
+
io.out('Signing in…');
|
|
147
|
+
const signedIn = await (deps.signIn ?? playSignIn)(prefix, options);
|
|
148
|
+
if (signedIn.stayed)
|
|
149
|
+
io.out("Note: the browser is still on the sign-in page after signing in. If the recorder opens there, the sign-in didn't work.");
|
|
150
|
+
const storageState = join(dir, 'state.json');
|
|
151
|
+
await writeFile(storageState, JSON.stringify(signedIn.storageState), { mode: 0o600 });
|
|
152
|
+
const output = join(dir, 'recording.spec.ts');
|
|
153
|
+
io.out(`Recording in a new ${signedIn.channel === 'msedge' ? 'Edge' : 'Chrome'} window, starting at ${signedIn.url}${prefix.length > 0 ? ', signed in' : ''}.`);
|
|
154
|
+
io.out('Do the case\'s steps, then check the result with the recorder toolbar\'s "Assert text" or "Assert visibility".');
|
|
155
|
+
io.out('Close the window when you\'re done.');
|
|
156
|
+
await (deps.codegen ?? runCodegen)({ url: signedIn.url, storageState, output, channel: signedIn.channel });
|
|
157
|
+
let source;
|
|
158
|
+
try {
|
|
159
|
+
source = await readFile(output, 'utf8');
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
throw new CliError('Nothing was recorded: the recorder closed without writing a test.');
|
|
163
|
+
}
|
|
164
|
+
return { source, startUrl: signedIn.url };
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
cleanup();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** The secrets the engineer has given this CLI: the sign-in's, and every SIFUR_SECRET_<NAME>. */
|
|
171
|
+
export function knownSecrets(signIn, env) {
|
|
172
|
+
const known = {};
|
|
173
|
+
for (const [key, value] of Object.entries(env)) {
|
|
174
|
+
const name = /^SIFUR_SECRET_([A-Z][A-Z0-9_]*)$/.exec(key)?.[1];
|
|
175
|
+
if (name && value)
|
|
176
|
+
known[name] = value;
|
|
177
|
+
}
|
|
178
|
+
return { ...known, ...signIn };
|
|
179
|
+
}
|
|
180
|
+
async function askPlaceholder(io, step, selector) {
|
|
181
|
+
io.out(`Step ${step} types into ${selector}, which looks like a sign-in field. What's typed there is never uploaded:`);
|
|
182
|
+
for (;;) {
|
|
183
|
+
const name = (await io.ask(' Name a placeholder for it, like APP_EMAIL or APP_PASSWORD: ')).trim().replace(/^\{\{|\}\}$/g, '').toUpperCase();
|
|
184
|
+
if (PLACEHOLDER_NAME.test(name) && name !== RUN)
|
|
185
|
+
return name;
|
|
186
|
+
io.out(' Use capital letters, digits and underscores, starting with a letter (and not RUN).');
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Credentials out of the steps: a known secret's value becomes its `{{NAME}}`, and anything else
|
|
191
|
+
* typed into a password, email or username field becomes a placeholder the engineer names.
|
|
192
|
+
*/
|
|
193
|
+
export async function withPlaceholders(steps, known, io) {
|
|
194
|
+
const secrets = Object.entries(known)
|
|
195
|
+
.filter(([, value]) => value !== '')
|
|
196
|
+
.sort(([, a], [, b]) => b.length - a.length);
|
|
197
|
+
// A short value inside a longer one is more likely a coincidence than the secret.
|
|
198
|
+
const replaceIn = (text) => {
|
|
199
|
+
let replaced = text;
|
|
200
|
+
for (const [name, value] of secrets)
|
|
201
|
+
if (value.length >= 4)
|
|
202
|
+
replaced = replaced.replaceAll(value, `{{${name}}}`);
|
|
203
|
+
return replaced;
|
|
204
|
+
};
|
|
205
|
+
const asked = new Map();
|
|
206
|
+
const named = [];
|
|
207
|
+
const result = [];
|
|
208
|
+
for (const [index, original] of steps.entries()) {
|
|
209
|
+
const step = { ...original };
|
|
210
|
+
if (step.action === 'fill' && typeof step.value === 'string' && step.value !== '') {
|
|
211
|
+
const exact = secrets.find(([, value]) => value === step.value);
|
|
212
|
+
step.value = exact ? `{{${exact[0]}}}` : replaceIn(step.value);
|
|
213
|
+
if (!/\{\{[A-Z][A-Z0-9_]*\}\}/.test(step.value) && CREDENTIAL_FIELD.test(step.selector ?? '')) {
|
|
214
|
+
let name = asked.get(step.value);
|
|
215
|
+
if (!name) {
|
|
216
|
+
name = await askPlaceholder(io, index + 1, step.selector ?? 'a field');
|
|
217
|
+
asked.set(step.value, name);
|
|
218
|
+
named.push(name);
|
|
219
|
+
}
|
|
220
|
+
step.value = `{{${name}}}`;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (step.action === 'goto' && step.url)
|
|
224
|
+
step.url = replaceIn(step.url);
|
|
225
|
+
if ((step.action === 'expectText' || step.action === 'expectUrl') && step.text)
|
|
226
|
+
step.text = replaceIn(step.text);
|
|
227
|
+
result.push(step);
|
|
228
|
+
}
|
|
229
|
+
return { steps: result, named };
|
|
230
|
+
}
|
|
231
|
+
export function formatStep(step) {
|
|
232
|
+
const target = step.selector ?? step.url ?? '';
|
|
233
|
+
const detail = step.value !== undefined ? ` ${step.value}` : step.text !== undefined ? ` "${step.text}"` : '';
|
|
234
|
+
return `${step.action} ${target}${detail}`.trim();
|
|
235
|
+
}
|
|
236
|
+
async function confirmUpload(io, count) {
|
|
237
|
+
const answer = (await io.ask(`Upload these ${count} step${count === 1 ? '' : 's'}? [y/N] `)).trim().toLowerCase();
|
|
238
|
+
return answer === 'y' || answer === 'yes';
|
|
239
|
+
}
|
|
240
|
+
function findCase(cases, id) {
|
|
241
|
+
const found = cases.find((c) => c.id.toLowerCase() === id.toLowerCase() || c.uuid === id);
|
|
242
|
+
if (!found)
|
|
243
|
+
throw new CliError(`There's no ${id} in this ticket's cases: ${cases.map((c) => c.id).join(', ') || 'it has none'}.`);
|
|
244
|
+
return found;
|
|
245
|
+
}
|
|
246
|
+
/** The whole `record` command: 0 when the steps were uploaded, 1 when the engineer chose not to. Errors throw. */
|
|
247
|
+
export async function recordCase(api, args, deps) {
|
|
248
|
+
const { io } = deps;
|
|
249
|
+
const env = deps.env ?? process.env;
|
|
250
|
+
if (!args.caseId === !args.title)
|
|
251
|
+
throw new CliError('Name the case to record again (TC-3), or use --new "<title>" for a new one.');
|
|
252
|
+
if (args.repository && !args.title)
|
|
253
|
+
throw new CliError('--repository goes with --new: a case keeps its repository.');
|
|
254
|
+
const saved = await download(api, args.ref);
|
|
255
|
+
const { acceptance } = saved;
|
|
256
|
+
const target = args.caseId ? findCase(acceptance.cases, args.caseId) : undefined;
|
|
257
|
+
if (target && typeof target.version !== 'number') {
|
|
258
|
+
throw new CliError(`This Sifur doesn't say which version ${target.id} is on, so a recording can't be checked against it. Sifur needs updating first.`);
|
|
259
|
+
}
|
|
260
|
+
const url = args.url ?? acceptance.appUrl ?? null;
|
|
261
|
+
if (!url) {
|
|
262
|
+
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.");
|
|
263
|
+
}
|
|
264
|
+
const baseUrl = parseBaseUrl(url);
|
|
265
|
+
io.out(target ? `${acceptance.ticket.key} ${target.id}: ${target.title}` : `${acceptance.ticket.key}: a new case, "${args.title}"`);
|
|
266
|
+
if (!args.url)
|
|
267
|
+
io.out(`Recording on ${baseUrl} (the project's test server). Use --url for another.`);
|
|
268
|
+
const prefix = findSignIn(acceptance.cases, target);
|
|
269
|
+
const uses = secretUses([{ uuid: '', id: target?.id ?? 'The sign-in', title: '', check: 'ui', uiScript: prefix }]);
|
|
270
|
+
const signInSecrets = await collectSecrets(uses, io, env);
|
|
271
|
+
const missing = uses.filter((use) => signInSecrets[use.name] === undefined);
|
|
272
|
+
if (missing.length > 0)
|
|
273
|
+
throw new CliError(`Recording starts signed in, which needs ${missing.map((use) => use.name).join(' and ')}.`);
|
|
274
|
+
const timeoutMs = deps.timeoutMs ?? TIMEOUT;
|
|
275
|
+
const { source, startUrl } = await record(prefix, { baseUrl, secrets: signInSecrets, headed: args.headed, timeoutMs }, deps);
|
|
276
|
+
// Only a sign-in leaves the browser at the start page; without one, the case has to open it itself.
|
|
277
|
+
const parsed = parseRecording(source, { baseUrl, startUrl: prefix.length > 0 ? startUrl : undefined });
|
|
278
|
+
if (parsed.refused.length > 0) {
|
|
279
|
+
const lines = parsed.refused.map((r) => ` line ${r.line}: ${r.code}\n ${r.reason}`);
|
|
280
|
+
throw new CliError(`Sifur can't replay ${parsed.refused.length === 1 ? 'one line' : `${parsed.refused.length} lines`} of the recording, so it wasn't uploaded:\n${lines.join('\n')}\n${SUPPORTED}`);
|
|
281
|
+
}
|
|
282
|
+
if (parsed.steps.length === 0)
|
|
283
|
+
throw new CliError('Nothing was recorded.');
|
|
284
|
+
if (!parsed.steps.some((step) => ASSERTIONS.has(step.action))) {
|
|
285
|
+
throw new CliError('The recording has no check, so nothing in it proves the case. Record it again, and after your steps use the recorder toolbar\'s "Assert text" or "Assert visibility" on what the change shows.');
|
|
286
|
+
}
|
|
287
|
+
if (parsed.steps.length > MAX_STEPS)
|
|
288
|
+
throw new CliError(`The recording has ${parsed.steps.length} steps; a case can have at most ${MAX_STEPS}.`);
|
|
289
|
+
const { steps, named } = await withPlaceholders(parsed.steps, knownSecrets(signInSecrets, env), io);
|
|
290
|
+
io.out();
|
|
291
|
+
io.out(`${steps.length} step${steps.length === 1 ? '' : 's'}${prefix.length > 0 ? ", after Sifur's sign-in" : ''}:`);
|
|
292
|
+
for (const [index, step] of steps.entries())
|
|
293
|
+
io.out(` ${index + 1}. ${formatStep(step)}`);
|
|
294
|
+
if (named.length > 0)
|
|
295
|
+
io.out(`When the case runs, ${named.map((name) => `{{${name}}}`).join(' and ')} come${named.length === 1 ? 's' : ''} from SIFUR_SECRET_<NAME>, or are asked for.`);
|
|
296
|
+
if (!(await confirmUpload(io, steps.length))) {
|
|
297
|
+
io.out('Nothing was uploaded.');
|
|
298
|
+
return 1;
|
|
299
|
+
}
|
|
300
|
+
let result;
|
|
301
|
+
try {
|
|
302
|
+
result = target
|
|
303
|
+
? await api.request(`tickets/${acceptance.ticket.uuid}/test-cases/${target.uuid}/steps`, {
|
|
304
|
+
method: 'PUT',
|
|
305
|
+
body: { stateVersion: acceptance.ticket.stateVersion, caseVersion: target.version, uiSteps: steps },
|
|
306
|
+
organization: saved.organizationUuid,
|
|
307
|
+
})
|
|
308
|
+
: await api.request(`tickets/${acceptance.ticket.uuid}/acceptance/cases`, {
|
|
309
|
+
method: 'POST',
|
|
310
|
+
body: {
|
|
311
|
+
stateVersion: acceptance.ticket.stateVersion,
|
|
312
|
+
title: args.title,
|
|
313
|
+
...(args.repository ? { repository: args.repository } : {}),
|
|
314
|
+
uiSteps: steps,
|
|
315
|
+
},
|
|
316
|
+
organization: saved.organizationUuid,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
catch (error) {
|
|
320
|
+
if (error instanceof ApiError && error.status === 409) {
|
|
321
|
+
throw new CliError(`Sifur refused the recording: ${error.message} Nothing was saved; run \`sifur record\` again.`);
|
|
322
|
+
}
|
|
323
|
+
if (error instanceof ApiError && error.status === 422)
|
|
324
|
+
throw new CliError(`Sifur refused the recording: ${error.message}`);
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
327
|
+
io.out(target ? `Saved as version ${result.version} of ${result.id}.` : `Added ${result.id}.`);
|
|
328
|
+
io.out(`The acceptance run starts again with the cases as they are now: run \`sifur accept ${acceptance.ticket.key}\` when you're ready.`);
|
|
329
|
+
const link = ticketLink(args.consoleOrigin, acceptance.ticket.uuid);
|
|
330
|
+
if (link)
|
|
331
|
+
io.out(link);
|
|
332
|
+
return 0;
|
|
333
|
+
}
|
package/dist/results.js
CHANGED
|
@@ -16,6 +16,8 @@ export function buildResultsPayload(stateVersion, baseUrl, results, screenshots)
|
|
|
16
16
|
screenshotUuid: step.screenshot ? (screenshots.get(step.screenshot) ?? null) : null,
|
|
17
17
|
})),
|
|
18
18
|
...(result.output !== undefined ? { output: result.output } : {}),
|
|
19
|
+
...(result.evidence ? { evidence: result.evidence } : {}),
|
|
20
|
+
...(result.engineerVerdict ? { engineerVerdict: result.engineerVerdict } : {}),
|
|
19
21
|
})),
|
|
20
22
|
};
|
|
21
23
|
}
|
package/dist/runner.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { mkdir } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { captureEvidence, locate } from './evidence.js';
|
|
3
4
|
import { newRunToken, redact, resolveStep } from './secrets.js';
|
|
4
5
|
const ASSERTIONS = new Set(['expectText', 'expectVisible', 'expectUrl']);
|
|
5
6
|
const TIMEOUT = 60_000;
|
|
@@ -27,7 +28,7 @@ async function find(page, selector, timeoutMs) {
|
|
|
27
28
|
throw new Error('the step has no selector');
|
|
28
29
|
return eventually(async () => {
|
|
29
30
|
for (const frame of page.frames()) {
|
|
30
|
-
const match = frame
|
|
31
|
+
const match = locate(frame, selector);
|
|
31
32
|
if ((await match.count()) > 0 && (await match.isVisible().catch(() => false)))
|
|
32
33
|
return match;
|
|
33
34
|
}
|
|
@@ -47,6 +48,10 @@ async function screenText(page) {
|
|
|
47
48
|
export function caseUrl(path, baseUrl) {
|
|
48
49
|
if (/^[a-z][a-z0-9+.-]*:/i.test(path))
|
|
49
50
|
return path;
|
|
51
|
+
return new URL(path.replace(/^\/+/, ''), appFolder(baseUrl)).toString();
|
|
52
|
+
}
|
|
53
|
+
/** The folder a case's paths open under. */
|
|
54
|
+
export function appFolder(baseUrl) {
|
|
50
55
|
const base = new URL(baseUrl);
|
|
51
56
|
base.search = '';
|
|
52
57
|
base.hash = '';
|
|
@@ -56,9 +61,9 @@ export function caseUrl(path, baseUrl) {
|
|
|
56
61
|
? base.pathname.replace(/[^/]*$/, '')
|
|
57
62
|
: `${base.pathname}/`;
|
|
58
63
|
}
|
|
59
|
-
return
|
|
64
|
+
return base;
|
|
60
65
|
}
|
|
61
|
-
async function perform(page, step, baseUrl, settleMs) {
|
|
66
|
+
export async function perform(page, step, baseUrl, settleMs) {
|
|
62
67
|
const url = step.url ? caseUrl(step.url, baseUrl) : null;
|
|
63
68
|
switch (step.action) {
|
|
64
69
|
case 'goto':
|
|
@@ -148,6 +153,7 @@ export async function runUiCase(browser, testCase, options) {
|
|
|
148
153
|
const steps = [];
|
|
149
154
|
const notes = [];
|
|
150
155
|
let status = 'passed';
|
|
156
|
+
let evidence = null;
|
|
151
157
|
try {
|
|
152
158
|
for (const [index, step] of (testCase.uiScript ?? []).entries()) {
|
|
153
159
|
const n = index + 1;
|
|
@@ -155,12 +161,14 @@ export async function runUiCase(browser, testCase, options) {
|
|
|
155
161
|
let ok = true;
|
|
156
162
|
let error = null;
|
|
157
163
|
options.onStep?.(n, caption);
|
|
164
|
+
const resolved = resolveStep(step, secrets, runToken);
|
|
158
165
|
try {
|
|
159
|
-
await perform(page,
|
|
166
|
+
await perform(page, resolved, options.baseUrl, options.timeoutMs ?? TIMEOUT);
|
|
160
167
|
}
|
|
161
168
|
catch (caught) {
|
|
162
169
|
ok = false;
|
|
163
170
|
error = redact(String(caught?.message ?? caught).split('\n')[0] ?? '', secrets);
|
|
171
|
+
evidence = await captureEvidence(page, resolved, secrets);
|
|
164
172
|
}
|
|
165
173
|
let screenshot = join(dir, `${String(n).padStart(2, '0')}.png`);
|
|
166
174
|
await page.screenshot({ path: screenshot, fullPage: true }).catch(() => {
|
|
@@ -174,6 +182,7 @@ export async function runUiCase(browser, testCase, options) {
|
|
|
174
182
|
notes.push(`Step ${n} (${caption}): ${answer.note}`);
|
|
175
183
|
if (!answer.ok) {
|
|
176
184
|
ok = false;
|
|
185
|
+
evidence = await captureEvidence(page, resolved, secrets);
|
|
177
186
|
error = answer.note ? `The engineer judged it wrong: ${answer.note}` : 'The engineer judged it wrong.';
|
|
178
187
|
}
|
|
179
188
|
}
|
|
@@ -191,5 +200,5 @@ export async function runUiCase(browser, testCase, options) {
|
|
|
191
200
|
finally {
|
|
192
201
|
await context.close().catch(() => { });
|
|
193
202
|
}
|
|
194
|
-
return { status, steps, notes };
|
|
203
|
+
return { status, steps, notes, ...(evidence ? { evidence } : {}) };
|
|
195
204
|
}
|
package/package.json
CHANGED