@sifurai/cli 0.1.2 → 0.1.5
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 +83 -6
- package/dist/accept.js +38 -7
- package/dist/browser.js +5 -1
- package/dist/cli.js +31 -6
- 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/codegen.js
ADDED
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
import { CliError } from './api.js';
|
|
2
|
+
import { MAX_SELECTOR, redactUrl } from './evidence.js';
|
|
3
|
+
import { appFolder } from './runner.js';
|
|
4
|
+
// The API's limits for a step's fields.
|
|
5
|
+
const MAX_STEP_SELECTOR = Math.min(500, MAX_SELECTOR);
|
|
6
|
+
const MAX_VALUE = 2000;
|
|
7
|
+
export const SUPPORTED = 'Sifur can replay opening a page, click (double-click, check and uncheck count as a click), fill, press and select, ' +
|
|
8
|
+
'and the recorder\'s "Assert text" and "Assert visibility" checks, on elements found by role, text, label, placeholder, ' +
|
|
9
|
+
'test id or CSS, in the page or a frame of it.';
|
|
10
|
+
const PUNCT = ['=>', '...', '?.', '(', ')', '{', '}', '[', ']', ',', ';', '.', ':', '=', '-', '!', '?', '+', '*', '<', '>', '&', '|'];
|
|
11
|
+
// After these, a `/` starts a regular expression rather than dividing.
|
|
12
|
+
const BEFORE_REGEX = new Set(['(', ',', '=', ':', '[', '!', '&', '|', '?', '{', '}', ';', '=>']);
|
|
13
|
+
function tokenize(source) {
|
|
14
|
+
const tokens = [];
|
|
15
|
+
let i = 0;
|
|
16
|
+
let line = 1;
|
|
17
|
+
const fail = (what) => {
|
|
18
|
+
throw new CliError(`Couldn't read the recording at line ${line}: ${what}.`);
|
|
19
|
+
};
|
|
20
|
+
while (i < source.length) {
|
|
21
|
+
const ch = source[i];
|
|
22
|
+
if (ch === '\n') {
|
|
23
|
+
line++;
|
|
24
|
+
i++;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (/\s/.test(ch)) {
|
|
28
|
+
i++;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (source.startsWith('//', i)) {
|
|
32
|
+
while (i < source.length && source[i] !== '\n')
|
|
33
|
+
i++;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (source.startsWith('/*', i)) {
|
|
37
|
+
const close = source.indexOf('*/', i + 2);
|
|
38
|
+
if (close < 0)
|
|
39
|
+
fail('a comment never ends');
|
|
40
|
+
line += (source.slice(i, close).match(/\n/g) ?? []).length;
|
|
41
|
+
i = close + 2;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const start = i;
|
|
45
|
+
const startLine = line;
|
|
46
|
+
if (ch === "'" || ch === '"' || ch === '`') {
|
|
47
|
+
let value = '';
|
|
48
|
+
let interpolated = false;
|
|
49
|
+
i++;
|
|
50
|
+
for (;;) {
|
|
51
|
+
if (i >= source.length)
|
|
52
|
+
fail('a string never ends');
|
|
53
|
+
const c = source[i];
|
|
54
|
+
if (c === ch) {
|
|
55
|
+
i++;
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
if (c === '\n') {
|
|
59
|
+
if (ch !== '`')
|
|
60
|
+
fail('a string runs past the end of its line');
|
|
61
|
+
line++;
|
|
62
|
+
}
|
|
63
|
+
if (ch === '`' && c === '$' && source[i + 1] === '{')
|
|
64
|
+
interpolated = true;
|
|
65
|
+
if (c === '\\') {
|
|
66
|
+
const next = source[i + 1] ?? '';
|
|
67
|
+
i += 2;
|
|
68
|
+
const simple = { n: '\n', t: '\t', r: '\r', b: '\b', f: '\f', v: '\v', 0: '\0' };
|
|
69
|
+
if (next in simple)
|
|
70
|
+
value += simple[next];
|
|
71
|
+
else if (next === 'x') {
|
|
72
|
+
value += String.fromCharCode(parseInt(source.slice(i, i + 2), 16));
|
|
73
|
+
i += 2;
|
|
74
|
+
}
|
|
75
|
+
else if (next === 'u' && source[i] === '{') {
|
|
76
|
+
const close = source.indexOf('}', i);
|
|
77
|
+
value += String.fromCodePoint(parseInt(source.slice(i + 1, close), 16));
|
|
78
|
+
i = close + 1;
|
|
79
|
+
}
|
|
80
|
+
else if (next === 'u') {
|
|
81
|
+
value += String.fromCharCode(parseInt(source.slice(i, i + 4), 16));
|
|
82
|
+
i += 4;
|
|
83
|
+
}
|
|
84
|
+
else if (next === '\n')
|
|
85
|
+
line++;
|
|
86
|
+
else
|
|
87
|
+
value += next;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
value += c;
|
|
91
|
+
i++;
|
|
92
|
+
}
|
|
93
|
+
tokens.push({ kind: interpolated ? 'template' : 'string', value, start, end: i, line: startLine });
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (ch === '/' && (tokens.length === 0 || BEFORE_REGEX.has(tokens.at(-1).value) || tokens.at(-1).value === 'await')) {
|
|
97
|
+
let inClass = false;
|
|
98
|
+
i++;
|
|
99
|
+
let body = '';
|
|
100
|
+
for (;;) {
|
|
101
|
+
const c = source[i];
|
|
102
|
+
if (c === undefined || c === '\n')
|
|
103
|
+
fail('a regular expression never ends');
|
|
104
|
+
if (c === '\\') {
|
|
105
|
+
body += c + (source[i + 1] ?? '');
|
|
106
|
+
i += 2;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (c === '[')
|
|
110
|
+
inClass = true;
|
|
111
|
+
if (c === ']')
|
|
112
|
+
inClass = false;
|
|
113
|
+
i++;
|
|
114
|
+
if (c === '/' && !inClass)
|
|
115
|
+
break;
|
|
116
|
+
body += c;
|
|
117
|
+
}
|
|
118
|
+
const flags = /^[a-z]*/.exec(source.slice(i))[0];
|
|
119
|
+
i += flags.length;
|
|
120
|
+
tokens.push({ kind: 'regex', value: body, flags, start, end: i, line: startLine });
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const number = /^\d+(\.\d+)?/.exec(source.slice(i));
|
|
124
|
+
if (number) {
|
|
125
|
+
i += number[0].length;
|
|
126
|
+
tokens.push({ kind: 'number', value: number[0], start, end: i, line: startLine });
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const ident = /^[A-Za-z_$][\w$]*/.exec(source.slice(i));
|
|
130
|
+
if (ident) {
|
|
131
|
+
i += ident[0].length;
|
|
132
|
+
tokens.push({ kind: 'ident', value: ident[0], start, end: i, line: startLine });
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const punct = PUNCT.find((p) => source.startsWith(p, i));
|
|
136
|
+
if (!punct)
|
|
137
|
+
fail(`unexpected "${ch}"`);
|
|
138
|
+
i += punct.length;
|
|
139
|
+
tokens.push({ kind: 'punct', value: punct, start, end: i, line: startLine });
|
|
140
|
+
}
|
|
141
|
+
return tokens;
|
|
142
|
+
}
|
|
143
|
+
class Unreadable extends Error {
|
|
144
|
+
}
|
|
145
|
+
const OPEN = { '(': ')', '{': '}', '[': ']' };
|
|
146
|
+
class Parser {
|
|
147
|
+
tokens;
|
|
148
|
+
at = 0;
|
|
149
|
+
constructor(tokens) {
|
|
150
|
+
this.tokens = tokens;
|
|
151
|
+
}
|
|
152
|
+
get done() {
|
|
153
|
+
return this.at >= this.tokens.length;
|
|
154
|
+
}
|
|
155
|
+
peek(offset = 0) {
|
|
156
|
+
return this.tokens[this.at + offset];
|
|
157
|
+
}
|
|
158
|
+
is(value, offset = 0) {
|
|
159
|
+
const token = this.peek(offset);
|
|
160
|
+
return token !== undefined && token.kind !== 'string' && token.kind !== 'template' && token.value === value;
|
|
161
|
+
}
|
|
162
|
+
take(value) {
|
|
163
|
+
const token = this.tokens[this.at];
|
|
164
|
+
if (!token || (value !== undefined && !this.is(value)))
|
|
165
|
+
throw new Unreadable();
|
|
166
|
+
this.at++;
|
|
167
|
+
return token;
|
|
168
|
+
}
|
|
169
|
+
/** Past one bracketed group, whatever is in it. */
|
|
170
|
+
skipGroup() {
|
|
171
|
+
const close = OPEN[this.take().value];
|
|
172
|
+
if (!close)
|
|
173
|
+
throw new Unreadable();
|
|
174
|
+
while (!this.is(close)) {
|
|
175
|
+
if (this.done)
|
|
176
|
+
throw new Unreadable();
|
|
177
|
+
if (OPEN[this.peek().value] && this.peek().kind === 'punct')
|
|
178
|
+
this.skipGroup();
|
|
179
|
+
else
|
|
180
|
+
this.at++;
|
|
181
|
+
}
|
|
182
|
+
this.take(close);
|
|
183
|
+
}
|
|
184
|
+
/** An arrow function's body, up to the comma or bracket that ends the argument. */
|
|
185
|
+
skipFunctionBody() {
|
|
186
|
+
if (this.is('{'))
|
|
187
|
+
return this.skipGroup();
|
|
188
|
+
while (!this.done && !this.is(',') && !this.is(')')) {
|
|
189
|
+
if (this.peek().kind === 'punct' && OPEN[this.peek().value])
|
|
190
|
+
this.skipGroup();
|
|
191
|
+
else
|
|
192
|
+
this.at++;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
chain() {
|
|
196
|
+
const head = this.take();
|
|
197
|
+
if (head.kind !== 'ident')
|
|
198
|
+
throw new Unreadable();
|
|
199
|
+
const links = [{ name: head.value }];
|
|
200
|
+
for (;;) {
|
|
201
|
+
if (this.is('.') || this.is('?.')) {
|
|
202
|
+
this.at++;
|
|
203
|
+
const name = this.take();
|
|
204
|
+
if (name.kind !== 'ident')
|
|
205
|
+
throw new Unreadable();
|
|
206
|
+
links.push({ name: name.value });
|
|
207
|
+
}
|
|
208
|
+
else if (this.is('(')) {
|
|
209
|
+
const last = links.at(-1);
|
|
210
|
+
if (last.args)
|
|
211
|
+
throw new Unreadable();
|
|
212
|
+
last.args = this.args();
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
return links;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
args() {
|
|
220
|
+
this.take('(');
|
|
221
|
+
const values = [];
|
|
222
|
+
while (!this.is(')')) {
|
|
223
|
+
values.push(this.value());
|
|
224
|
+
if (!this.is(')'))
|
|
225
|
+
this.take(',');
|
|
226
|
+
}
|
|
227
|
+
this.take(')');
|
|
228
|
+
return values;
|
|
229
|
+
}
|
|
230
|
+
value() {
|
|
231
|
+
const token = this.peek();
|
|
232
|
+
if (!token)
|
|
233
|
+
throw new Unreadable();
|
|
234
|
+
if (token.kind === 'string') {
|
|
235
|
+
this.at++;
|
|
236
|
+
return { kind: 'string', value: token.value };
|
|
237
|
+
}
|
|
238
|
+
if (token.kind === 'regex') {
|
|
239
|
+
this.at++;
|
|
240
|
+
return { kind: 'regex', source: token.value, flags: token.flags ?? '' };
|
|
241
|
+
}
|
|
242
|
+
if (token.kind === 'number') {
|
|
243
|
+
this.at++;
|
|
244
|
+
return { kind: 'number', value: Number(token.value) };
|
|
245
|
+
}
|
|
246
|
+
if (this.is('-') && this.peek(1)?.kind === 'number') {
|
|
247
|
+
this.at++;
|
|
248
|
+
return { kind: 'number', value: -Number(this.take().value) };
|
|
249
|
+
}
|
|
250
|
+
if (this.is('true') || this.is('false'))
|
|
251
|
+
return { kind: 'boolean', value: this.take().value === 'true' };
|
|
252
|
+
if (this.is('{'))
|
|
253
|
+
return this.object();
|
|
254
|
+
if (this.is('[')) {
|
|
255
|
+
this.take('[');
|
|
256
|
+
const items = [];
|
|
257
|
+
while (!this.is(']')) {
|
|
258
|
+
items.push(this.value());
|
|
259
|
+
if (!this.is(']'))
|
|
260
|
+
this.take(',');
|
|
261
|
+
}
|
|
262
|
+
this.take(']');
|
|
263
|
+
return { kind: 'array', items };
|
|
264
|
+
}
|
|
265
|
+
if (this.is('(')) {
|
|
266
|
+
this.skipGroup();
|
|
267
|
+
this.take('=>');
|
|
268
|
+
this.skipFunctionBody();
|
|
269
|
+
return { kind: 'other' };
|
|
270
|
+
}
|
|
271
|
+
if (token.kind === 'ident' && this.is('=>', 1)) {
|
|
272
|
+
this.at += 2;
|
|
273
|
+
this.skipFunctionBody();
|
|
274
|
+
return { kind: 'other' };
|
|
275
|
+
}
|
|
276
|
+
if (token.kind === 'ident')
|
|
277
|
+
return { kind: 'chain', links: this.chain() };
|
|
278
|
+
throw new Unreadable();
|
|
279
|
+
}
|
|
280
|
+
object() {
|
|
281
|
+
this.take('{');
|
|
282
|
+
const entries = {};
|
|
283
|
+
while (!this.is('}')) {
|
|
284
|
+
const key = this.take();
|
|
285
|
+
if (key.kind !== 'ident' && key.kind !== 'string')
|
|
286
|
+
throw new Unreadable();
|
|
287
|
+
this.take(':');
|
|
288
|
+
entries[key.value] = this.value();
|
|
289
|
+
if (!this.is('}'))
|
|
290
|
+
this.take(',');
|
|
291
|
+
}
|
|
292
|
+
this.take('}');
|
|
293
|
+
return { kind: 'object', entries };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/** The statements inside `test('…', async ({ page }) => { … })`, each with the tokens it's made of. */
|
|
297
|
+
function testBody(tokens) {
|
|
298
|
+
let at = tokens.findIndex((t, i) => t.kind === 'ident' && t.value === 'test' && tokens[i + 1]?.value === '(' && tokens[i + 2]?.kind === 'string');
|
|
299
|
+
if (at < 0)
|
|
300
|
+
throw new CliError('The recording has no test in it: it isn\'t what Playwright\'s recorder writes.');
|
|
301
|
+
while (at < tokens.length && !(tokens[at].value === '=>' && tokens[at + 1]?.value === '{'))
|
|
302
|
+
at++;
|
|
303
|
+
if (at >= tokens.length)
|
|
304
|
+
throw new CliError('The recording\'s test has no body.');
|
|
305
|
+
at += 2;
|
|
306
|
+
const statements = [];
|
|
307
|
+
let current = [];
|
|
308
|
+
let depth = 0;
|
|
309
|
+
for (; at < tokens.length; at++) {
|
|
310
|
+
const token = tokens[at];
|
|
311
|
+
if (token.kind === 'punct' && OPEN[token.value])
|
|
312
|
+
depth++;
|
|
313
|
+
if (token.kind === 'punct' && Object.values(OPEN).includes(token.value)) {
|
|
314
|
+
if (depth === 0)
|
|
315
|
+
break;
|
|
316
|
+
depth--;
|
|
317
|
+
}
|
|
318
|
+
if (depth === 0 && token.kind === 'punct' && token.value === ';') {
|
|
319
|
+
if (current.length > 0)
|
|
320
|
+
statements.push(current);
|
|
321
|
+
current = [];
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
current.push(token);
|
|
325
|
+
}
|
|
326
|
+
if (current.length > 0)
|
|
327
|
+
statements.push(current);
|
|
328
|
+
return statements;
|
|
329
|
+
}
|
|
330
|
+
function quoted(value) {
|
|
331
|
+
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
332
|
+
}
|
|
333
|
+
function regexText(value) {
|
|
334
|
+
return `/${value.source}/${value.flags}`;
|
|
335
|
+
}
|
|
336
|
+
const str = (value) => (value?.kind === 'string' ? value.value : undefined);
|
|
337
|
+
const REASONS = {
|
|
338
|
+
hover: "hovering isn't replayed: click what the hover shows instead",
|
|
339
|
+
setInputFiles: "uploading a file isn't supported",
|
|
340
|
+
dragTo: "dragging isn't supported",
|
|
341
|
+
pressSequentially: 'typing key by key isn\'t supported: type into the field normally, so the recorder writes a fill',
|
|
342
|
+
type: 'typing key by key isn\'t supported: type into the field normally, so the recorder writes a fill',
|
|
343
|
+
tap: "tapping isn't supported: click instead",
|
|
344
|
+
};
|
|
345
|
+
/** A Playwright locator chain as one selector string that Sifur's runner (and `locate()`) understands. */
|
|
346
|
+
function selectorOf(links) {
|
|
347
|
+
if (links[0]?.name !== 'page' || links[0].args)
|
|
348
|
+
return { reason: 'it acts on something other than the page' };
|
|
349
|
+
const segments = [];
|
|
350
|
+
// CSS (from `locator('…')`) can take `:has-text()`; the other engines can't.
|
|
351
|
+
let lastIsCss = false;
|
|
352
|
+
for (const link of links.slice(1)) {
|
|
353
|
+
const args = link.args;
|
|
354
|
+
if (!args)
|
|
355
|
+
return { reason: `.${link.name} isn't a locator Sifur knows` };
|
|
356
|
+
const [first, second] = args;
|
|
357
|
+
const options = second?.kind === 'object' ? second.entries : {};
|
|
358
|
+
const exact = options.exact?.kind === 'boolean' && options.exact.value;
|
|
359
|
+
let css = false;
|
|
360
|
+
switch (link.name) {
|
|
361
|
+
case 'getByRole': {
|
|
362
|
+
const role = str(first);
|
|
363
|
+
if (!role)
|
|
364
|
+
return { reason: 'a role that isn\'t plain text' };
|
|
365
|
+
let attributes = '';
|
|
366
|
+
for (const [key, value] of Object.entries(options)) {
|
|
367
|
+
if (key === 'exact')
|
|
368
|
+
continue;
|
|
369
|
+
if (key === 'name') {
|
|
370
|
+
if (value.kind === 'regex')
|
|
371
|
+
attributes += `[name=${regexText(value)}]`;
|
|
372
|
+
else if (value.kind === 'string')
|
|
373
|
+
attributes += exact ? `[name=${quoted(value.value)}]` : `[name*=${quoted(value.value)}i]`;
|
|
374
|
+
else
|
|
375
|
+
return { reason: 'a role\'s name that isn\'t plain text' };
|
|
376
|
+
}
|
|
377
|
+
else if (['checked', 'pressed', 'expanded', 'selected', 'disabled'].includes(key) && value.kind === 'boolean') {
|
|
378
|
+
attributes += `[${key}=${value.value}]`;
|
|
379
|
+
}
|
|
380
|
+
else if (key === 'level' && value.kind === 'number') {
|
|
381
|
+
attributes += `[level=${value.value}]`;
|
|
382
|
+
}
|
|
383
|
+
else if (key === 'includeHidden' && value.kind === 'boolean') {
|
|
384
|
+
if (value.value)
|
|
385
|
+
attributes += '[include-hidden]';
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
388
|
+
return { reason: `getByRole's ${key} option isn't supported` };
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
segments.push(`role=${role}${attributes}`);
|
|
392
|
+
break;
|
|
393
|
+
}
|
|
394
|
+
case 'getByText': {
|
|
395
|
+
if (first?.kind === 'regex') {
|
|
396
|
+
segments.push(`text=${regexText(first)}`);
|
|
397
|
+
break;
|
|
398
|
+
}
|
|
399
|
+
const text = str(first);
|
|
400
|
+
if (text === undefined)
|
|
401
|
+
return { reason: 'text that isn\'t plain text' };
|
|
402
|
+
if (exact)
|
|
403
|
+
segments.push(`text=${quoted(text)}`);
|
|
404
|
+
else if (/^["'/]/.test(text) || text.includes('>>') || text !== text.trim() || text === '') {
|
|
405
|
+
return { reason: `the text ${quoted(text)} can't be written as a Sifur selector` };
|
|
406
|
+
}
|
|
407
|
+
else
|
|
408
|
+
segments.push(`text=${text}`);
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
411
|
+
case 'getByLabel': {
|
|
412
|
+
const label = str(first);
|
|
413
|
+
if (label === undefined)
|
|
414
|
+
return { reason: 'a label that isn\'t plain text' };
|
|
415
|
+
segments.push(`label=${label}`);
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
case 'getByPlaceholder':
|
|
419
|
+
case 'getByAltText':
|
|
420
|
+
case 'getByTitle': {
|
|
421
|
+
const value = str(first);
|
|
422
|
+
if (value === undefined)
|
|
423
|
+
return { reason: `${link.name} with a pattern isn't supported` };
|
|
424
|
+
const attribute = { getByPlaceholder: 'placeholder', getByAltText: 'alt', getByTitle: 'title' }[link.name];
|
|
425
|
+
segments.push(exact ? `[${attribute}=${quoted(value)}]` : `[${attribute}*=${quoted(value)} i]`);
|
|
426
|
+
css = true;
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
case 'getByTestId': {
|
|
430
|
+
const id = str(first);
|
|
431
|
+
if (id === undefined)
|
|
432
|
+
return { reason: 'a test id that isn\'t plain text' };
|
|
433
|
+
segments.push(`[data-testid=${quoted(id)}]`);
|
|
434
|
+
css = true;
|
|
435
|
+
break;
|
|
436
|
+
}
|
|
437
|
+
case 'locator': {
|
|
438
|
+
const selector = str(first);
|
|
439
|
+
if (selector === undefined)
|
|
440
|
+
return { reason: 'a locator inside a locator isn\'t supported' };
|
|
441
|
+
if (selector.startsWith('internal:'))
|
|
442
|
+
return { reason: 'it uses one of Playwright\'s internal selectors' };
|
|
443
|
+
if (second)
|
|
444
|
+
return { reason: 'locator options aren\'t supported' };
|
|
445
|
+
segments.push(selector);
|
|
446
|
+
css = !/^[a-z-]+=/i.test(selector) && !selector.includes('>>') && !selector.startsWith('//');
|
|
447
|
+
break;
|
|
448
|
+
}
|
|
449
|
+
// The runner looks for every selector in the page and each of its frames.
|
|
450
|
+
case 'frameLocator':
|
|
451
|
+
case 'contentFrame':
|
|
452
|
+
segments.length = 0;
|
|
453
|
+
break;
|
|
454
|
+
case 'first':
|
|
455
|
+
segments.push('nth=0');
|
|
456
|
+
break;
|
|
457
|
+
case 'last':
|
|
458
|
+
segments.push('nth=-1');
|
|
459
|
+
break;
|
|
460
|
+
case 'nth':
|
|
461
|
+
if (first?.kind !== 'number')
|
|
462
|
+
return { reason: 'nth() needs a number' };
|
|
463
|
+
segments.push(`nth=${first.value}`);
|
|
464
|
+
break;
|
|
465
|
+
case 'filter': {
|
|
466
|
+
if (first?.kind !== 'object')
|
|
467
|
+
return { reason: 'a filter that isn\'t plain options' };
|
|
468
|
+
const entries = Object.entries(first.entries);
|
|
469
|
+
if (entries.length !== 1)
|
|
470
|
+
return { reason: 'a filter with more than one condition isn\'t supported' };
|
|
471
|
+
const [key, value] = entries[0];
|
|
472
|
+
if (key === 'visible' && value.kind === 'boolean') {
|
|
473
|
+
segments.push(`visible=${value.value}`);
|
|
474
|
+
}
|
|
475
|
+
else if (key === 'hasText' && value.kind === 'string' && lastIsCss) {
|
|
476
|
+
segments[segments.length - 1] += `:has-text(${quoted(value.value)})`;
|
|
477
|
+
css = true;
|
|
478
|
+
}
|
|
479
|
+
else if (key === 'hasText' && value.kind === 'regex') {
|
|
480
|
+
return { reason: 'filtering by a pattern of the element\'s whole text has no exact equivalent Sifur can replay' };
|
|
481
|
+
}
|
|
482
|
+
else {
|
|
483
|
+
return { reason: `filtering by ${key} isn't supported here` };
|
|
484
|
+
}
|
|
485
|
+
break;
|
|
486
|
+
}
|
|
487
|
+
default:
|
|
488
|
+
return { reason: `.${link.name}() isn't a locator Sifur knows` };
|
|
489
|
+
}
|
|
490
|
+
lastIsCss = css;
|
|
491
|
+
}
|
|
492
|
+
if (segments.length === 0)
|
|
493
|
+
return { reason: 'it names no element' };
|
|
494
|
+
if (segments.length > 1 && segments.some((s) => s.startsWith('label='))) {
|
|
495
|
+
return { reason: 'a label inside a chain can\'t be replayed: Sifur finds a field by its label only on its own' };
|
|
496
|
+
}
|
|
497
|
+
const selector = segments.join(' >> ');
|
|
498
|
+
if (selector.length > MAX_STEP_SELECTOR)
|
|
499
|
+
return { reason: `the selector is longer than ${MAX_STEP_SELECTOR} characters` };
|
|
500
|
+
return { selector };
|
|
501
|
+
}
|
|
502
|
+
/** Under the app's folder: the path from it, as the cases write it. Elsewhere: the whole address. */
|
|
503
|
+
export function relativeUrl(url, baseUrl) {
|
|
504
|
+
let parsed;
|
|
505
|
+
try {
|
|
506
|
+
parsed = new URL(url);
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
return url;
|
|
510
|
+
}
|
|
511
|
+
const folder = appFolder(baseUrl).toString();
|
|
512
|
+
const full = parsed.toString();
|
|
513
|
+
if (!full.startsWith(folder))
|
|
514
|
+
return full;
|
|
515
|
+
return full.slice(folder.length) || './';
|
|
516
|
+
}
|
|
517
|
+
function sameUrl(a, b) {
|
|
518
|
+
try {
|
|
519
|
+
return new URL(a).toString() === new URL(b).toString();
|
|
520
|
+
}
|
|
521
|
+
catch {
|
|
522
|
+
return a === b;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
function mapStatement(links, options, first) {
|
|
526
|
+
const head = links[0];
|
|
527
|
+
if (head.name === 'expect') {
|
|
528
|
+
const subject = head.args?.[0];
|
|
529
|
+
if (head.args?.length !== 1 || subject?.kind !== 'chain')
|
|
530
|
+
return { reason: 'a check Sifur can\'t read' };
|
|
531
|
+
const matchers = links.slice(1);
|
|
532
|
+
if (matchers.some((m) => m.name === 'not'))
|
|
533
|
+
return { reason: 'a negative check (.not) isn\'t supported' };
|
|
534
|
+
const matcher = matchers[0];
|
|
535
|
+
if (matchers.length !== 1 || !matcher?.args)
|
|
536
|
+
return { reason: 'a check Sifur can\'t read' };
|
|
537
|
+
const expected = str(matcher.args[0]);
|
|
538
|
+
if (subject.links.length === 1 && subject.links[0].name === 'page') {
|
|
539
|
+
if (matcher.name === 'toHaveURL' && expected !== undefined)
|
|
540
|
+
return { step: { action: 'expectUrl', text: relativeUrl(expected, options.baseUrl) } };
|
|
541
|
+
return { reason: `${matcher.name} on the page isn't supported; only toHaveURL with an address is` };
|
|
542
|
+
}
|
|
543
|
+
const located = selectorOf(subject.links);
|
|
544
|
+
if ('reason' in located)
|
|
545
|
+
return located;
|
|
546
|
+
switch (matcher.name) {
|
|
547
|
+
case 'toContainText':
|
|
548
|
+
case 'toHaveText':
|
|
549
|
+
if (expected === undefined || matcher.args.length !== 1)
|
|
550
|
+
return { reason: 'a text check needs plain text, not a pattern or a list' };
|
|
551
|
+
if (expected.length > MAX_VALUE)
|
|
552
|
+
return { reason: `the text is longer than ${MAX_VALUE} characters` };
|
|
553
|
+
return { step: { action: 'expectText', selector: located.selector, text: expected } };
|
|
554
|
+
case 'toBeVisible':
|
|
555
|
+
if (matcher.args.length > 0)
|
|
556
|
+
return { reason: 'toBeVisible with options isn\'t supported' };
|
|
557
|
+
return { step: { action: 'expectVisible', selector: located.selector } };
|
|
558
|
+
case 'toHaveValue':
|
|
559
|
+
case 'toBeEmpty':
|
|
560
|
+
return { reason: 'Sifur has no check for what\'s in a field: use "Assert text" or "Assert visibility" on what the change shows' };
|
|
561
|
+
case 'toBeChecked':
|
|
562
|
+
return { reason: 'Sifur has no check for a checkbox: use "Assert text" or "Assert visibility" on what the change shows' };
|
|
563
|
+
case 'toMatchAriaSnapshot':
|
|
564
|
+
return { reason: 'snapshot checks aren\'t supported: use "Assert text" or "Assert visibility"' };
|
|
565
|
+
default:
|
|
566
|
+
return { reason: `${matcher.name} isn't a check Sifur knows` };
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
if (head.name !== 'page')
|
|
570
|
+
return { reason: 'it acts on another page, a popup or a download, which Sifur can\'t follow' };
|
|
571
|
+
if (head.args)
|
|
572
|
+
return { reason: 'a step Sifur can\'t read' };
|
|
573
|
+
const second = links[1];
|
|
574
|
+
if (!second)
|
|
575
|
+
return { reason: 'a step Sifur can\'t read' };
|
|
576
|
+
if (['keyboard', 'mouse', 'touchscreen'].includes(second.name)) {
|
|
577
|
+
return { reason: `the ${second.name} isn't supported on its own: act on an element instead` };
|
|
578
|
+
}
|
|
579
|
+
if (links.length === 2) {
|
|
580
|
+
const arg = str(second.args?.[0]);
|
|
581
|
+
switch (second.name) {
|
|
582
|
+
case 'goto': {
|
|
583
|
+
if (arg === undefined || second.args.length !== 1)
|
|
584
|
+
return { reason: 'a goto Sifur can\'t read' };
|
|
585
|
+
if (first && options.startUrl && sameUrl(arg, options.startUrl))
|
|
586
|
+
return { skip: true };
|
|
587
|
+
if (redactUrl(arg, {}) !== arg)
|
|
588
|
+
return { reason: 'the address carries a token, password or session: open the page by clicking to it instead' };
|
|
589
|
+
const url = relativeUrl(arg, options.baseUrl);
|
|
590
|
+
if (url.length > MAX_VALUE)
|
|
591
|
+
return { reason: `the address is longer than ${MAX_VALUE} characters` };
|
|
592
|
+
return { step: { action: 'goto', url } };
|
|
593
|
+
}
|
|
594
|
+
case 'close':
|
|
595
|
+
return { skip: true };
|
|
596
|
+
case 'waitForTimeout':
|
|
597
|
+
if (second.args?.[0]?.kind !== 'number')
|
|
598
|
+
return { reason: 'a wait Sifur can\'t read' };
|
|
599
|
+
return { step: { action: 'wait', value: String(second.args[0].value) } };
|
|
600
|
+
case 'once':
|
|
601
|
+
case 'on':
|
|
602
|
+
return { reason: arg === 'dialog' ? 'answering a dialog isn\'t supported' : `listening for ${arg ?? 'an event'} isn't supported` };
|
|
603
|
+
default:
|
|
604
|
+
return { reason: `page.${second.name}() isn't something Sifur can replay` };
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
const action = links.at(-1);
|
|
608
|
+
if (!action.args)
|
|
609
|
+
return { reason: `.${action.name} isn't something Sifur can replay` };
|
|
610
|
+
const known = ['click', 'dblclick', 'check', 'uncheck', 'fill', 'press', 'selectOption'];
|
|
611
|
+
if (!known.includes(action.name))
|
|
612
|
+
return { reason: REASONS[action.name] ?? `.${action.name}() isn't something Sifur can replay` };
|
|
613
|
+
const located = selectorOf(links.slice(0, -1));
|
|
614
|
+
if ('reason' in located)
|
|
615
|
+
return located;
|
|
616
|
+
const selector = located.selector;
|
|
617
|
+
const value = str(action.args[0]);
|
|
618
|
+
switch (action.name) {
|
|
619
|
+
case 'click':
|
|
620
|
+
case 'dblclick':
|
|
621
|
+
case 'check':
|
|
622
|
+
case 'uncheck':
|
|
623
|
+
if (action.args.length > 0) {
|
|
624
|
+
const keys = action.args[0]?.kind === 'object' ? Object.keys(action.args[0].entries).join(', ') : 'options';
|
|
625
|
+
return { reason: `a click with ${keys} (a right-click, a click with a key held, or at a point) isn't supported` };
|
|
626
|
+
}
|
|
627
|
+
return { step: { action: 'click', selector } };
|
|
628
|
+
case 'fill':
|
|
629
|
+
case 'press':
|
|
630
|
+
case 'selectOption':
|
|
631
|
+
if (value === undefined || action.args.length !== 1) {
|
|
632
|
+
return { reason: action.name === 'selectOption' ? 'choosing more than one option, or by label or index, isn\'t supported' : `a ${action.name} Sifur can't read` };
|
|
633
|
+
}
|
|
634
|
+
if (value.length > MAX_VALUE)
|
|
635
|
+
return { reason: `the value is longer than ${MAX_VALUE} characters` };
|
|
636
|
+
return { step: { action: action.name === 'selectOption' ? 'select' : action.name, selector, value } };
|
|
637
|
+
}
|
|
638
|
+
return { reason: 'a step Sifur can\'t read' };
|
|
639
|
+
}
|
|
640
|
+
/** A statement as shown back to the engineer, with anything typed hidden: it may be a password. */
|
|
641
|
+
function shown(source, tokens) {
|
|
642
|
+
const text = source.slice(tokens[0].start, tokens.at(-1).end);
|
|
643
|
+
return text
|
|
644
|
+
.replace(/\.(fill|pressSequentially|type)\((['"`])(?:\\.|(?!\2).)*\2/g, '.$1(\'…\'')
|
|
645
|
+
.replace(/\s*\n\s*/g, ' ');
|
|
646
|
+
}
|
|
647
|
+
/** The steps of a test that Playwright's recorder wrote (`--target playwright-test`), and every line that can't be replayed. */
|
|
648
|
+
export function parseRecording(source, options) {
|
|
649
|
+
const statements = testBody(tokenize(source));
|
|
650
|
+
const steps = [];
|
|
651
|
+
const refused = [];
|
|
652
|
+
for (const tokens of statements) {
|
|
653
|
+
const line = tokens[0].line;
|
|
654
|
+
const code = shown(source, tokens);
|
|
655
|
+
let mapped;
|
|
656
|
+
if (['const', 'let', 'var'].includes(tokens[0].value)) {
|
|
657
|
+
const popup = tokens.some((t) => (t.kind === 'string' && (t.value === 'popup' || t.value === 'download')) || (t.kind === 'ident' && /^(page\d+|download\d*)(Promise)?$/.test(t.value)));
|
|
658
|
+
mapped = { reason: popup ? 'it opens a new page, a popup or a download, which Sifur can\'t follow' : 'a variable Sifur can\'t follow' };
|
|
659
|
+
}
|
|
660
|
+
else {
|
|
661
|
+
try {
|
|
662
|
+
const parser = new Parser(tokens[0].value === 'await' ? tokens.slice(1) : tokens);
|
|
663
|
+
const links = parser.chain();
|
|
664
|
+
if (!parser.done)
|
|
665
|
+
throw new Unreadable();
|
|
666
|
+
mapped = mapStatement(links, options, steps.length === 0 && refused.length === 0);
|
|
667
|
+
}
|
|
668
|
+
catch (error) {
|
|
669
|
+
if (!(error instanceof Unreadable))
|
|
670
|
+
throw error;
|
|
671
|
+
mapped = { reason: 'a step Sifur can\'t read' };
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
if ('step' in mapped)
|
|
675
|
+
steps.push(mapped.step);
|
|
676
|
+
else if ('reason' in mapped)
|
|
677
|
+
refused.push({ line, code, reason: mapped.reason });
|
|
678
|
+
}
|
|
679
|
+
return { steps, refused };
|
|
680
|
+
}
|