@xylentis/testgen 1.1.0 → 1.2.0
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/CHANGELOG.md +25 -0
- package/README.md +189 -14
- package/dist/checks.js +100 -0
- package/dist/cli.js +93 -22
- package/dist/editing.js +167 -0
- package/dist/generate.js +25 -6
- package/dist/generators/index.js +2 -2
- package/dist/generators/javascript-body.js +91 -13
- package/dist/generators/playwright-go.js +85 -21
- package/dist/generators/playwright-ts.js +44 -7
- package/dist/generators/selenium-java.js +201 -109
- package/dist/generators/selenium-js.js +39 -10
- package/dist/generators/test-data.js +171 -0
- package/dist/locator.js +17 -0
- package/dist/recording.js +60 -2
- package/dist/replay.js +268 -0
- package/dist/secrets.js +169 -0
- package/dist/text.js +28 -0
- package/dist/ui/server.js +220 -21
- package/dist/vendor/playwright/locatorParser.js +235 -0
- package/dist/vendor/playwright/locatorUtils.js +67 -0
- package/docs/testgen-ui.png +0 -0
- package/package.json +4 -2
- package/runtime/playwright-go/testgen_data_test.go +64 -0
- package/runtime/playwright-ts/testgen-data.ts +70 -0
- package/runtime/selenium-java/TestgenSupport.java +213 -29
- package/runtime/selenium-js/testgen-support.js +162 -22
- package/ui/app.js +392 -25
- package/ui/index.html +21 -4
- package/ui/style.css +81 -9
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { isRegex, parseLocator } from '../locator.js';
|
|
2
|
+
import { camelCase, pascalCase } from '../naming.js';
|
|
3
|
+
import { collectSecrets, secretPlaceholder } from '../secrets.js';
|
|
4
|
+
import { isWholeWords } from '../text.js';
|
|
5
|
+
// Shorter typed values only follow the data into texts equal to them: "1" may be part of any number.
|
|
6
|
+
const minPartialLength = 3;
|
|
7
|
+
export function collectTestValues(recording, options) {
|
|
8
|
+
const secrets = collectSecrets(recording);
|
|
9
|
+
const columns = [];
|
|
10
|
+
// Typed and selected values, by value: typing the same text twice is one column.
|
|
11
|
+
const inputs = new Map();
|
|
12
|
+
const byStep = new Map();
|
|
13
|
+
const assertions = new Set();
|
|
14
|
+
const variables = new Map();
|
|
15
|
+
const unique = (base) => {
|
|
16
|
+
const taken = (name) => options.reserved(name) || columns.some(c => c.name === name) || [...variables.values()].includes(name);
|
|
17
|
+
// A reserved name often stays reserved with a number ("page2" is a page too): add a word instead.
|
|
18
|
+
const stem = options.reserved(base) ? `${base}Value` : base;
|
|
19
|
+
let name = stem;
|
|
20
|
+
for (let i = 2; taken(name); i++)
|
|
21
|
+
name = stem + i;
|
|
22
|
+
return name;
|
|
23
|
+
};
|
|
24
|
+
const add = (base, value) => {
|
|
25
|
+
const column = { name: unique(base), value };
|
|
26
|
+
columns.push(column);
|
|
27
|
+
return column;
|
|
28
|
+
};
|
|
29
|
+
// Secrets inside other texts, as typed or as the placeholders of a saved recording.
|
|
30
|
+
const secretTexts = secrets.list.flatMap(secret => [
|
|
31
|
+
{ text: secretPlaceholder(secret.name), piece: { secret } },
|
|
32
|
+
...(secret.value ? [{ text: secret.value, piece: { secret } }] : []),
|
|
33
|
+
]);
|
|
34
|
+
const hasSecret = (text) => splitText(text, secretTexts).some(piece => 'secret' in piece);
|
|
35
|
+
recording.actions.forEach(({ action }, index) => {
|
|
36
|
+
// Secrets come from environment variables, never from the data file.
|
|
37
|
+
if (!options.dataDriven || secrets.of(index))
|
|
38
|
+
return;
|
|
39
|
+
const steps = 'selector' in action ? parseLocator(action.selector) : [];
|
|
40
|
+
let input;
|
|
41
|
+
let expected;
|
|
42
|
+
if (action.name === 'fill')
|
|
43
|
+
input = action.text;
|
|
44
|
+
else if (action.name === 'select' && action.options.length === 1)
|
|
45
|
+
input = action.options[0];
|
|
46
|
+
else if (action.name === 'assertText')
|
|
47
|
+
expected = action.text;
|
|
48
|
+
else if (action.name === 'assertValue')
|
|
49
|
+
expected = action.value;
|
|
50
|
+
if ((input && hasSecret(input)) || (expected && hasSecret(expected)))
|
|
51
|
+
return;
|
|
52
|
+
if (input) {
|
|
53
|
+
const known = inputs.get(input);
|
|
54
|
+
const column = known?.column ?? add(camelCase(subjectOf(steps, input) ?? 'value'), input);
|
|
55
|
+
if (!known)
|
|
56
|
+
inputs.set(input, { column, index });
|
|
57
|
+
byStep.set(index, column);
|
|
58
|
+
}
|
|
59
|
+
else if (expected) {
|
|
60
|
+
const known = inputs.get(expected);
|
|
61
|
+
byStep.set(index, known?.column ?? add('expected' + pascalCase(subjectOf(steps, expected) ?? 'text'), expected));
|
|
62
|
+
assertions.add(index);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
for (const secret of secrets.list)
|
|
66
|
+
variables.set(secret, unique(camelCase(secret.name)));
|
|
67
|
+
// A locator text in a data-driven test: a column when it is a typed value, or contains one.
|
|
68
|
+
const columnPieces = (index, text) => {
|
|
69
|
+
const input = inputs.get(text);
|
|
70
|
+
if (input && input.index < index)
|
|
71
|
+
return [{ column: input.column }];
|
|
72
|
+
const own = assertions.has(index) ? byStep.get(index) : undefined;
|
|
73
|
+
if (own?.value === text)
|
|
74
|
+
return [{ column: own }];
|
|
75
|
+
const contained = [...inputs.values()].filter(i => i.index < index).map(i => i.column);
|
|
76
|
+
if (own)
|
|
77
|
+
contained.push(own);
|
|
78
|
+
return splitText(text, contained.filter(c => c.value.length >= minPartialLength).map(column => ({ text: column.value, piece: { column } })));
|
|
79
|
+
};
|
|
80
|
+
return {
|
|
81
|
+
columns,
|
|
82
|
+
secrets: secrets.list,
|
|
83
|
+
value: (index, text) => {
|
|
84
|
+
const secret = secrets.of(index);
|
|
85
|
+
if (secret)
|
|
86
|
+
return [{ secret }];
|
|
87
|
+
const column = byStep.get(index);
|
|
88
|
+
return column ? [{ column }] : splitText(text, secretTexts);
|
|
89
|
+
},
|
|
90
|
+
locator: (index, text) => {
|
|
91
|
+
const pieces = splitText(text, secretTexts);
|
|
92
|
+
if (!options.dataDriven)
|
|
93
|
+
return pieces;
|
|
94
|
+
return pieces.flatMap(piece => 'text' in piece ? columnPieces(index, piece.text) : [piece]);
|
|
95
|
+
},
|
|
96
|
+
variable: secret => variables.get(secret) ?? camelCase(secret.name),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
// Whether an argument is just the empty string.
|
|
100
|
+
export function isEmpty(pieces) {
|
|
101
|
+
return pieces.length === 1 && 'text' in pieces[0] && !pieces[0].text;
|
|
102
|
+
}
|
|
103
|
+
// One argument in a target language: its parts joined with +.
|
|
104
|
+
export function joinPieces(pieces, render) {
|
|
105
|
+
return pieces.map(piece => 'text' in piece ? render.text(piece.text) : 'column' in piece ? render.column(piece.column) : render.secret(piece.secret)).join(' + ');
|
|
106
|
+
}
|
|
107
|
+
// `text` as literal parts and the known texts it contains as whole words, longest first.
|
|
108
|
+
function splitText(text, known) {
|
|
109
|
+
const found = [];
|
|
110
|
+
for (const { text: part, piece } of [...known].sort((a, b) => b.text.length - a.text.length)) {
|
|
111
|
+
for (let start = part ? text.indexOf(part) : -1; start !== -1; start = text.indexOf(part, start + 1)) {
|
|
112
|
+
const end = start + part.length;
|
|
113
|
+
if (isWholeWords(text, start, end) && !found.some(f => start < f.end && end > f.start))
|
|
114
|
+
found.push({ start, end, piece });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (!found.length)
|
|
118
|
+
return [{ text }];
|
|
119
|
+
const pieces = [];
|
|
120
|
+
let last = 0;
|
|
121
|
+
for (const { start, end, piece } of found.sort((a, b) => a.start - b.start)) {
|
|
122
|
+
if (start > last)
|
|
123
|
+
pieces.push({ text: text.slice(last, start) });
|
|
124
|
+
pieces.push(piece);
|
|
125
|
+
last = end;
|
|
126
|
+
}
|
|
127
|
+
if (last < text.length)
|
|
128
|
+
pieces.push({ text: text.slice(last) });
|
|
129
|
+
return pieces;
|
|
130
|
+
}
|
|
131
|
+
// What a step's locator points at, in words for a column name: "Email" for getByRole('textbox',
|
|
132
|
+
// { name: 'Email' }), "welcome" for getByTestId('welcome'). Texts equal to the value itself
|
|
133
|
+
// (getByText('Dune') asserting "Dune") say nothing about the field, so the role is used instead.
|
|
134
|
+
function subjectOf(steps, value) {
|
|
135
|
+
const text = (matcher) => typeof matcher === 'string' && matcher !== value ? identifierWords(matcher) : undefined;
|
|
136
|
+
for (const step of [...steps].reverse()) {
|
|
137
|
+
switch (step.kind) {
|
|
138
|
+
case 'role':
|
|
139
|
+
return text(step.name) ?? identifierWords(step.body);
|
|
140
|
+
case 'test-id':
|
|
141
|
+
case 'label':
|
|
142
|
+
case 'placeholder':
|
|
143
|
+
case 'alt':
|
|
144
|
+
case 'title':
|
|
145
|
+
case 'text':
|
|
146
|
+
if (!isRegex(step.body) && text(step.body))
|
|
147
|
+
return text(step.body);
|
|
148
|
+
break;
|
|
149
|
+
case 'default': {
|
|
150
|
+
// Last #id or [name=...] of a CSS selector.
|
|
151
|
+
const css = [...String(step.body).matchAll(/#([\w-]+)|\[name=["']?([\w-]+)/g)].pop();
|
|
152
|
+
if (css)
|
|
153
|
+
return identifierWords(css[1] ?? css[2]);
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
// ASCII words for an identifier, at most five: "Tìm kiếm sản phẩm..." → "Tim kiem san pham".
|
|
161
|
+
function identifierWords(text) {
|
|
162
|
+
const ascii = text.normalize('NFD').replace(/\p{M}/gu, '').replace(/đ/g, 'd').replace(/Đ/g, 'D');
|
|
163
|
+
const result = ascii.split(/[^A-Za-z0-9]+/).filter(Boolean).slice(0, 5).join(' ');
|
|
164
|
+
return result || undefined;
|
|
165
|
+
}
|
|
166
|
+
// CSV with a BOM, so that Excel opens UTF-8 text (Vietnamese and other accents) correctly.
|
|
167
|
+
export function toCsv(rows) {
|
|
168
|
+
const cell = (value) => /[",\r\n;]|^\s|\s$/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
|
|
169
|
+
return '\uFEFF' + rows.map(row => row.map(cell).join(',')).join('\n') + '\n';
|
|
170
|
+
}
|
|
171
|
+
//# sourceMappingURL=test-data.js.map
|
package/dist/locator.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { asLocator, asLocatorWithFactory } from './vendor/playwright/locatorGenerators.js';
|
|
2
|
+
import { parseAttributeSelector, parseSelector, visitAllSelectorParts } from './vendor/playwright/selectorParser.js';
|
|
2
3
|
const nestedKinds = ['has', 'hasNot', 'and', 'or', 'chain'];
|
|
3
4
|
export function isRegex(matcher) {
|
|
4
5
|
return typeof matcher === 'object' && matcher !== null;
|
|
@@ -46,4 +47,20 @@ export function parseLocator(selector) {
|
|
|
46
47
|
export function describeSelector(selector) {
|
|
47
48
|
return asLocator('javascript', selector);
|
|
48
49
|
}
|
|
50
|
+
// Attributes behind the selector's getByTestId() calls: internal:testid=[data-testid="welcome"s]
|
|
51
|
+
// gives ['data-testid']. The locator itself only keeps the test id ("welcome").
|
|
52
|
+
export function testIdAttributesOf(selector) {
|
|
53
|
+
const names = [];
|
|
54
|
+
try {
|
|
55
|
+
visitAllSelectorParts(parseSelector(selector), part => {
|
|
56
|
+
// Several attributes are one comma-separated name, as in Playwright's testIdAttribute option.
|
|
57
|
+
if (part.name === 'internal:testid')
|
|
58
|
+
names.push(...parseAttributeSelector(part.body, true).attributes[0].name.split(','));
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// Not a selector Playwright can parse; it has no getByTestId() call.
|
|
63
|
+
}
|
|
64
|
+
return names;
|
|
65
|
+
}
|
|
49
66
|
//# sourceMappingURL=locator.js.map
|
package/dist/recording.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { testIdAttributesOf } from './locator.js';
|
|
1
2
|
const defaultHeader = { browserName: 'chromium', launchOptions: {}, contextOptions: {} };
|
|
2
3
|
export function parseRecording(text, source = 'recording') {
|
|
3
4
|
let header;
|
|
@@ -20,11 +21,52 @@ export function parseRecording(text, source = 'recording') {
|
|
|
20
21
|
if (typeof entry.name !== 'string')
|
|
21
22
|
throw new Error(`${source}:${i + 1}: expected a recorded action with a "name"`);
|
|
22
23
|
// `locator` is Playwright's own rendering of `selector`; generators re-parse the selector.
|
|
23
|
-
const { signals = [], pageGuid = 'page', locator, ...action } = entry;
|
|
24
|
-
|
|
24
|
+
const { signals = [], pageGuid = 'page', locator, secret, ...action } = entry;
|
|
25
|
+
if (secret !== undefined && secret !== false && !(typeof secret === 'string' && isEnvironmentVariable(secret)))
|
|
26
|
+
throw new Error(`${source}:${i + 1}: "secret" must be an environment variable name or false`);
|
|
27
|
+
result.push({ pageGuid, signals, action, ...(secret === undefined ? {} : { secret }) });
|
|
25
28
|
}
|
|
26
29
|
return { header: header ?? defaultHeader, actions: collapseActions(result) };
|
|
27
30
|
}
|
|
31
|
+
export function isEnvironmentVariable(name) {
|
|
32
|
+
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
|
|
33
|
+
}
|
|
34
|
+
// JSONL in the recorder's own format, for a recording changed by testgen.
|
|
35
|
+
export function serializeRecording(recording) {
|
|
36
|
+
const lines = [recording.header, ...recording.actions.map(({ action, signals, pageGuid, secret }) => ({ ...action, ...(secret === undefined ? {} : { secret }), signals, pageGuid }))];
|
|
37
|
+
return lines.map(line => JSON.stringify(line)).join('\n') + '\n';
|
|
38
|
+
}
|
|
39
|
+
// Why the steps cannot run in their order: a page used before the step that opens it, after the step
|
|
40
|
+
// that closes it, or never opened (only the first page may be). Steps are numbered from 1 and pages
|
|
41
|
+
// named as in the generated tests.
|
|
42
|
+
export function pageOrderProblem(recording) {
|
|
43
|
+
// Index of the step that opens each page; a popup opens during the step that has its signal.
|
|
44
|
+
const openedAt = new Map();
|
|
45
|
+
recording.actions.forEach(({ action, pageGuid, signals }, index) => {
|
|
46
|
+
if (action.name === 'openPage' && !openedAt.has(pageGuid))
|
|
47
|
+
openedAt.set(pageGuid, index);
|
|
48
|
+
for (const signal of signals) {
|
|
49
|
+
if (signal.name === 'popup' && !openedAt.has(signal.popupPageGuid))
|
|
50
|
+
openedAt.set(signal.popupPageGuid, index + 0.5);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
const aliases = new PageAliases();
|
|
54
|
+
const closedAt = new Map();
|
|
55
|
+
for (const [index, { action, pageGuid }] of recording.actions.entries()) {
|
|
56
|
+
const alias = aliases.alias(pageGuid);
|
|
57
|
+
const opened = openedAt.get(pageGuid);
|
|
58
|
+
if (opened === undefined && alias !== 'page')
|
|
59
|
+
return `Step ${index + 1} uses ${alias}, which no step opens.`;
|
|
60
|
+
if (opened !== undefined && index < opened)
|
|
61
|
+
return `Step ${index + 1} uses ${alias} before step ${Math.floor(opened) + 1} opens it.`;
|
|
62
|
+
const closed = closedAt.get(pageGuid);
|
|
63
|
+
if (closed !== undefined)
|
|
64
|
+
return `Step ${index + 1} uses ${alias} after step ${closed + 1} closes it.`;
|
|
65
|
+
if (action.name === 'closePage')
|
|
66
|
+
closedAt.set(pageGuid, index);
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
28
70
|
// Same rule as Playwright's recorder: consecutive fills of one field become a single fill.
|
|
29
71
|
function collapseActions(list) {
|
|
30
72
|
const result = [];
|
|
@@ -48,6 +90,22 @@ export function signalsOf(actionInContext) {
|
|
|
48
90
|
expect: find('expect'),
|
|
49
91
|
};
|
|
50
92
|
}
|
|
93
|
+
// The test id attribute the recording was made with (`--test-id-attribute`), read back from its
|
|
94
|
+
// getByTestId() selectors; undefined when there are none. Generated getByTestId() calls only keep
|
|
95
|
+
// the test id, so the generated code has to configure this same attribute.
|
|
96
|
+
export function recordedTestIdAttribute(recording) {
|
|
97
|
+
const names = new Set();
|
|
98
|
+
for (const { action, signals } of recording.actions) {
|
|
99
|
+
const selectors = signals.flatMap(signal => signal.name === 'expect' ? [signal.selector] : []);
|
|
100
|
+
if ('selector' in action)
|
|
101
|
+
selectors.push(action.selector);
|
|
102
|
+
for (const selector of selectors) {
|
|
103
|
+
for (const name of testIdAttributesOf(selector))
|
|
104
|
+
names.add(name);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return names.size ? [...names].join(',') : undefined;
|
|
108
|
+
}
|
|
51
109
|
// Mirrors Playwright's codegen: the first page and every page opened later get stable names
|
|
52
110
|
// ("page", "page1", ...), assigned in the order the recording mentions them.
|
|
53
111
|
export class PageAliases {
|
package/dist/replay.js
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { chromium, devices, firefox, webkit } from 'playwright-core';
|
|
4
|
+
import { isBlankUrl, shortcutOf, signalsOf, toKeyboardModifiers } from './recording.js';
|
|
5
|
+
import { collectSecrets, secretPlaceholder } from './secrets.js';
|
|
6
|
+
// Secrets the recording does not keep and that are neither in `values` nor in the environment.
|
|
7
|
+
export function missingSecrets(recording, values = {}) {
|
|
8
|
+
return collectSecrets(recording).list.filter(s => !s.value && values[s.name] === undefined && process.env[s.name] === undefined).map(s => s.name);
|
|
9
|
+
}
|
|
10
|
+
export async function replayRecording(recording, options, onEvent) {
|
|
11
|
+
// Errors quote expected and received values, and selectors: they show secrets as ***.
|
|
12
|
+
const secretValues = collectSecrets(recording).list
|
|
13
|
+
.map(s => s.value || options.secrets?.[s.name] || process.env[s.name] || '')
|
|
14
|
+
.filter(Boolean)
|
|
15
|
+
.flatMap(value => [value, JSON.stringify(value).slice(1, -1)])
|
|
16
|
+
.sort((a, b) => b.length - a.length);
|
|
17
|
+
const mask = (text) => secretValues.reduce((result, value) => result.split(value).join('***'), text);
|
|
18
|
+
const finish = (status, error) => {
|
|
19
|
+
onEvent({ type: 'done', status, ...(error ? { error: mask(error) } : {}) });
|
|
20
|
+
return status;
|
|
21
|
+
};
|
|
22
|
+
const missing = missingSecrets(recording, options.secrets);
|
|
23
|
+
if (missing.length)
|
|
24
|
+
return finish('failed', `Set ${missing.join(', ')}: the recording does not keep the values typed into password fields.`);
|
|
25
|
+
const { header } = recording;
|
|
26
|
+
const browserType = { chromium, firefox, webkit }[header.browserName] ?? chromium;
|
|
27
|
+
const timeout = options.timeout ?? Number(process.env.TESTGEN_TIMEOUT || 10_000);
|
|
28
|
+
let browser;
|
|
29
|
+
try {
|
|
30
|
+
browser = await browserType.launch({ headless: options.headless ?? false, channel: header.launchOptions.channel });
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
return finish('failed', `Cannot start ${header.launchOptions.channel ?? header.browserName}: ${errorMessage(error)}`);
|
|
34
|
+
}
|
|
35
|
+
const stop = () => void browser.close().catch(() => { });
|
|
36
|
+
options.signal?.addEventListener('abort', stop);
|
|
37
|
+
try {
|
|
38
|
+
const context = await browser.newContext(contextOptions(recording));
|
|
39
|
+
context.setDefaultTimeout(timeout);
|
|
40
|
+
context.setDefaultNavigationTimeout(Math.max(timeout, 30_000));
|
|
41
|
+
const runner = new StepRunner(recording, options, timeout, () => context.newPage());
|
|
42
|
+
for (const [index, step] of recording.actions.entries()) {
|
|
43
|
+
if (options.signal?.aborted)
|
|
44
|
+
return finish('stopped');
|
|
45
|
+
onEvent({ type: 'step', index, status: 'running' });
|
|
46
|
+
const started = Date.now();
|
|
47
|
+
try {
|
|
48
|
+
const note = await runner.run(step, index);
|
|
49
|
+
onEvent({ type: 'step', index, status: note ? 'skipped' : 'passed', duration: Date.now() - started, ...(note ? { note: mask(note) } : {}) });
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (options.signal?.aborted)
|
|
53
|
+
return finish('stopped');
|
|
54
|
+
const screenshot = await runner.pageOf(step)?.screenshot({ timeout: 5_000 }).catch(() => undefined);
|
|
55
|
+
onEvent({ type: 'step', index, status: 'failed', duration: Date.now() - started, error: mask(errorMessage(error)), ...(screenshot ? { screenshot } : {}) });
|
|
56
|
+
return finish('failed');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return finish('passed');
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
return finish(options.signal?.aborted ? 'stopped' : 'failed', errorMessage(error));
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
options.signal?.removeEventListener('abort', stop);
|
|
66
|
+
await browser.close().catch(() => { });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Context options of the recording, with its device.
|
|
70
|
+
function contextOptions(recording) {
|
|
71
|
+
const { recordHar, recordVideo, ...options } = recording.header.contextOptions;
|
|
72
|
+
const device = recording.header.deviceName ? devices[recording.header.deviceName] : undefined;
|
|
73
|
+
return { ...device, ...options };
|
|
74
|
+
}
|
|
75
|
+
class StepRunner {
|
|
76
|
+
_recording;
|
|
77
|
+
_options;
|
|
78
|
+
_timeout;
|
|
79
|
+
_newPage;
|
|
80
|
+
_pages = new Map();
|
|
81
|
+
_secrets;
|
|
82
|
+
constructor(_recording, _options, _timeout, _newPage) {
|
|
83
|
+
this._recording = _recording;
|
|
84
|
+
this._options = _options;
|
|
85
|
+
this._timeout = _timeout;
|
|
86
|
+
this._newPage = _newPage;
|
|
87
|
+
this._secrets = collectSecrets(_recording);
|
|
88
|
+
}
|
|
89
|
+
pageOf(step) {
|
|
90
|
+
return this._pages.get(step.pageGuid) ?? [...this._pages.values()].pop();
|
|
91
|
+
}
|
|
92
|
+
// Runs one step; returns why it was skipped, if it was.
|
|
93
|
+
async run(step, index) {
|
|
94
|
+
const { action, pageGuid } = step;
|
|
95
|
+
if (action.name === 'openPage') {
|
|
96
|
+
const page = await this._newPage();
|
|
97
|
+
this._pages.set(pageGuid, page);
|
|
98
|
+
if (!isBlankUrl(action.url))
|
|
99
|
+
await page.goto(this._reveal(action.url));
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
let page = this._pages.get(pageGuid);
|
|
103
|
+
if (!page && !this._pages.size) {
|
|
104
|
+
// Hand-written recordings may start without opening their first page.
|
|
105
|
+
page = await this._newPage();
|
|
106
|
+
this._pages.set(pageGuid, page);
|
|
107
|
+
}
|
|
108
|
+
if (!page)
|
|
109
|
+
throw new Error('This step uses a page that is not open.');
|
|
110
|
+
if (action.name === 'closePage') {
|
|
111
|
+
await page.close();
|
|
112
|
+
this._pages.delete(pageGuid);
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
const signals = signalsOf(step);
|
|
116
|
+
let note;
|
|
117
|
+
const perform = async () => {
|
|
118
|
+
note = await this._perform(page, action, index);
|
|
119
|
+
};
|
|
120
|
+
if (signals.popup) {
|
|
121
|
+
const [popup] = await Promise.all([page.waitForEvent('popup'), perform()]);
|
|
122
|
+
this._pages.set(signals.popup.popupPageGuid, popup);
|
|
123
|
+
}
|
|
124
|
+
else if (signals.download) {
|
|
125
|
+
await Promise.all([page.waitForEvent('download'), perform()]);
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
await perform();
|
|
129
|
+
}
|
|
130
|
+
if (this._recording.header.generateExpectSignal && signals.expect)
|
|
131
|
+
await this._expect(page.locator(this._reveal(signals.expect.selector, true)), 'to be visible', async (locator) => ({ pass: await locator.isVisible(), received: 'hidden' }));
|
|
132
|
+
return note;
|
|
133
|
+
}
|
|
134
|
+
async _perform(page, action, index) {
|
|
135
|
+
const locator = 'selector' in action ? page.locator(this._reveal(action.selector, true)) : undefined;
|
|
136
|
+
switch (action.name) {
|
|
137
|
+
case 'navigate':
|
|
138
|
+
await page.goto(this._reveal(action.url));
|
|
139
|
+
return undefined;
|
|
140
|
+
case 'click': {
|
|
141
|
+
const options = { button: action.button, modifiers: toKeyboardModifiers(action.modifiers), position: action.position };
|
|
142
|
+
if (action.clickCount === 2)
|
|
143
|
+
await locator.dblclick(options);
|
|
144
|
+
else
|
|
145
|
+
await locator.click({ ...options, clickCount: action.clickCount });
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
case 'hover':
|
|
149
|
+
await locator.hover({ position: action.position });
|
|
150
|
+
return undefined;
|
|
151
|
+
case 'check':
|
|
152
|
+
await locator.check();
|
|
153
|
+
return undefined;
|
|
154
|
+
case 'uncheck':
|
|
155
|
+
await locator.uncheck();
|
|
156
|
+
return undefined;
|
|
157
|
+
case 'fill':
|
|
158
|
+
await locator.fill(this._value(index, action.text));
|
|
159
|
+
return undefined;
|
|
160
|
+
case 'press':
|
|
161
|
+
await locator.press(shortcutOf(action));
|
|
162
|
+
return undefined;
|
|
163
|
+
case 'select':
|
|
164
|
+
await locator.selectOption(action.options);
|
|
165
|
+
return undefined;
|
|
166
|
+
case 'setInputFiles': {
|
|
167
|
+
const files = action.files.map(file => path.resolve(this._options.cwd ?? process.cwd(), file));
|
|
168
|
+
const missing = files.filter(file => !fs.existsSync(file));
|
|
169
|
+
if (missing.length)
|
|
170
|
+
return `Not uploaded: ${missing.map(file => path.basename(file)).join(', ')} not found. Recordings keep only file names: edit the step with full paths.`;
|
|
171
|
+
await locator.setInputFiles(files);
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
case 'assertText': {
|
|
175
|
+
const expected = this._value(index, action.text);
|
|
176
|
+
const verb = action.substring ? 'to contain text' : 'to have text';
|
|
177
|
+
await this._expect(locator, `${verb} ${JSON.stringify(expected)}`, async (l) => {
|
|
178
|
+
const text = normalize(await l.textContent() ?? '');
|
|
179
|
+
return { pass: action.substring ? text.includes(normalize(expected)) : text === normalize(expected), received: JSON.stringify(text) };
|
|
180
|
+
});
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
case 'assertValue': {
|
|
184
|
+
const expected = this._value(index, action.value);
|
|
185
|
+
await this._expect(locator, expected ? `to have value ${JSON.stringify(expected)}` : 'to be empty', async (l) => {
|
|
186
|
+
const value = await l.inputValue();
|
|
187
|
+
return { pass: value === expected, received: JSON.stringify(value) };
|
|
188
|
+
});
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
case 'assertChecked':
|
|
192
|
+
await this._expect(locator, action.checked ? 'to be checked' : 'not to be checked', async (l) => {
|
|
193
|
+
const checked = await l.isChecked();
|
|
194
|
+
return { pass: checked === action.checked, received: checked ? 'checked' : 'unchecked' };
|
|
195
|
+
});
|
|
196
|
+
return undefined;
|
|
197
|
+
case 'assertVisible':
|
|
198
|
+
case 'assertSnapshot':
|
|
199
|
+
await this._expect(locator, 'to be visible', async (l) => ({ pass: await l.isVisible(), received: 'hidden' }));
|
|
200
|
+
return undefined;
|
|
201
|
+
case 'openPage':
|
|
202
|
+
case 'closePage':
|
|
203
|
+
throw new Error(`${action.name} is handled by the caller`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// The value a step types or expects; secrets come from the replay options or the environment.
|
|
207
|
+
_value(index, recorded) {
|
|
208
|
+
const secret = this._secrets.of(index);
|
|
209
|
+
if (!secret)
|
|
210
|
+
return this._reveal(recorded);
|
|
211
|
+
if (recorded)
|
|
212
|
+
return recorded;
|
|
213
|
+
return this._secretValue(secret);
|
|
214
|
+
}
|
|
215
|
+
_secretValue(secret) {
|
|
216
|
+
const value = secret.value || (this._options.secrets?.[secret.name] ?? process.env[secret.name]);
|
|
217
|
+
if (value === undefined)
|
|
218
|
+
throw new Error(`Set ${secret.name}: the recording does not keep the value typed into this password field.`);
|
|
219
|
+
return value;
|
|
220
|
+
}
|
|
221
|
+
// `text` with the secret placeholders of a saved recording replaced by the values, as JSON string
|
|
222
|
+
// content inside a selector.
|
|
223
|
+
_reveal(text, selector = false) {
|
|
224
|
+
let result = text;
|
|
225
|
+
for (const secret of this._secrets.list) {
|
|
226
|
+
const placeholder = secretPlaceholder(secret.name);
|
|
227
|
+
if (result.includes(placeholder)) {
|
|
228
|
+
const value = this._secretValue(secret);
|
|
229
|
+
result = result.split(placeholder).join(selector ? JSON.stringify(value).slice(1, -1) : value);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return result;
|
|
233
|
+
}
|
|
234
|
+
// Polls `check` on the single element `locator` matches until it passes or the step times out.
|
|
235
|
+
async _expect(locator, expectation, check) {
|
|
236
|
+
const deadline = Date.now() + this._timeout;
|
|
237
|
+
let received = 'no element matches';
|
|
238
|
+
for (;;) {
|
|
239
|
+
try {
|
|
240
|
+
const count = await locator.count();
|
|
241
|
+
if (count === 1) {
|
|
242
|
+
const result = await check(locator);
|
|
243
|
+
if (result.pass)
|
|
244
|
+
return;
|
|
245
|
+
received = result.received;
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
received = count ? `${count} elements match` : 'no element matches';
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
received = errorMessage(error);
|
|
253
|
+
}
|
|
254
|
+
if (Date.now() > deadline)
|
|
255
|
+
throw new Error(`Expected ${expectation} after ${this._timeout}ms. Received: ${received}`);
|
|
256
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
function normalize(text) {
|
|
261
|
+
return text.replace(//g, '').replace(/\s+/g, ' ').trim();
|
|
262
|
+
}
|
|
263
|
+
// First lines of a Playwright error, without its call log and colors.
|
|
264
|
+
function errorMessage(error) {
|
|
265
|
+
const message = String(error?.message ?? error).replace(/\u001b\[[0-9;]*m/g, '');
|
|
266
|
+
return message.split(/\n\s*Call log:/)[0].split('\n').filter(line => line.trim()).slice(0, 4).join('\n');
|
|
267
|
+
}
|
|
268
|
+
//# sourceMappingURL=replay.js.map
|