@xylentis/testgen 1.1.1 → 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 -2
- package/README.md +159 -12
- package/dist/checks.js +100 -0
- package/dist/cli.js +84 -18
- package/dist/editing.js +167 -0
- package/dist/generate.js +23 -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/recording.js +43 -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 +5 -3
- 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 +386 -25
- package/ui/index.html +19 -3
- package/ui/style.css +80 -9
package/dist/editing.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { describeSelector } from './locator.js';
|
|
2
|
+
import { isEnvironmentVariable, pageOrderProblem, shortcutOf } from './recording.js';
|
|
3
|
+
import { collectSecrets } from './secrets.js';
|
|
4
|
+
import { locatorOrSelectorAsSelector } from './vendor/playwright/locatorParser.js';
|
|
5
|
+
// `secrets` saves collecting them again for each step.
|
|
6
|
+
export function stepFields(recording, index, secrets = collectSecrets(recording)) {
|
|
7
|
+
const step = stepAt(recording, index);
|
|
8
|
+
const { action } = step;
|
|
9
|
+
const fields = {};
|
|
10
|
+
if ('selector' in action)
|
|
11
|
+
fields.locator = describeSelector(action.selector);
|
|
12
|
+
const label = valueLabels[action.name];
|
|
13
|
+
if (label) {
|
|
14
|
+
fields.valueLabel = label;
|
|
15
|
+
fields.value = valueOf(action);
|
|
16
|
+
}
|
|
17
|
+
if (action.name === 'assertText')
|
|
18
|
+
fields.substring = action.substring;
|
|
19
|
+
if (action.name === 'assertChecked')
|
|
20
|
+
fields.checked = action.checked;
|
|
21
|
+
if (action.name === 'fill') {
|
|
22
|
+
const secret = secrets.of(index);
|
|
23
|
+
if (secret)
|
|
24
|
+
fields.secret = secret.name;
|
|
25
|
+
fields.secretChosen = step.secret !== undefined;
|
|
26
|
+
}
|
|
27
|
+
return fields;
|
|
28
|
+
}
|
|
29
|
+
export function deleteStep(recording, index) {
|
|
30
|
+
stepAt(recording, index);
|
|
31
|
+
return checked({ ...recording, actions: recording.actions.filter((_, i) => i !== index) });
|
|
32
|
+
}
|
|
33
|
+
// Moves step `index` so that it becomes step `to`.
|
|
34
|
+
export function moveStep(recording, index, to) {
|
|
35
|
+
const step = stepAt(recording, index);
|
|
36
|
+
stepAt(recording, to);
|
|
37
|
+
const actions = recording.actions.filter((_, i) => i !== index);
|
|
38
|
+
actions.splice(to, 0, step);
|
|
39
|
+
return checked({ ...recording, actions });
|
|
40
|
+
}
|
|
41
|
+
// `testIdAttribute` reads getByTestId() in a typed locator.
|
|
42
|
+
export function editStep(recording, index, change, testIdAttribute) {
|
|
43
|
+
const step = stepAt(recording, index);
|
|
44
|
+
let action = step.action;
|
|
45
|
+
const edited = { ...step };
|
|
46
|
+
if (change.locator !== undefined) {
|
|
47
|
+
if (!('selector' in action))
|
|
48
|
+
throw new Error(`Step ${index + 1} has no locator.`);
|
|
49
|
+
action = { ...action, selector: parseLocatorText(change.locator, testIdAttribute) };
|
|
50
|
+
}
|
|
51
|
+
if (change.value !== undefined)
|
|
52
|
+
action = withValue(action, change.value, index);
|
|
53
|
+
if (change.substring !== undefined) {
|
|
54
|
+
if (action.name !== 'assertText')
|
|
55
|
+
throw new Error(`Step ${index + 1} does not check a text.`);
|
|
56
|
+
action = { ...action, substring: change.substring };
|
|
57
|
+
}
|
|
58
|
+
if (change.checked !== undefined) {
|
|
59
|
+
if (action.name !== 'assertChecked')
|
|
60
|
+
throw new Error(`Step ${index + 1} does not check a checkbox.`);
|
|
61
|
+
action = { ...action, checked: change.checked };
|
|
62
|
+
}
|
|
63
|
+
if (change.secret !== undefined) {
|
|
64
|
+
if (action.name !== 'fill')
|
|
65
|
+
throw new Error(`Only steps that type a value can keep it in an environment variable.`);
|
|
66
|
+
if (typeof change.secret === 'string' && !isEnvironmentVariable(change.secret))
|
|
67
|
+
throw new Error(`"${change.secret}" is not an environment variable name: use letters, digits and _, e.g. TEST_PASSWORD.`);
|
|
68
|
+
if (change.secret === null)
|
|
69
|
+
delete edited.secret;
|
|
70
|
+
else
|
|
71
|
+
edited.secret = change.secret;
|
|
72
|
+
}
|
|
73
|
+
edited.action = action;
|
|
74
|
+
return checked({ ...recording, actions: recording.actions.map((s, i) => i === index ? edited : s) });
|
|
75
|
+
}
|
|
76
|
+
const valueLabels = {
|
|
77
|
+
fill: 'Text',
|
|
78
|
+
select: 'Options',
|
|
79
|
+
assertText: 'Expected text',
|
|
80
|
+
assertValue: 'Expected value',
|
|
81
|
+
navigate: 'URL',
|
|
82
|
+
openPage: 'URL',
|
|
83
|
+
press: 'Key',
|
|
84
|
+
setInputFiles: 'Files',
|
|
85
|
+
};
|
|
86
|
+
function valueOf(action) {
|
|
87
|
+
switch (action.name) {
|
|
88
|
+
case 'fill':
|
|
89
|
+
case 'assertText':
|
|
90
|
+
return action.text;
|
|
91
|
+
case 'select':
|
|
92
|
+
return action.options.join('\n');
|
|
93
|
+
case 'assertValue':
|
|
94
|
+
return action.value;
|
|
95
|
+
case 'navigate':
|
|
96
|
+
case 'openPage':
|
|
97
|
+
return action.url;
|
|
98
|
+
case 'press':
|
|
99
|
+
return shortcutOf(action);
|
|
100
|
+
case 'setInputFiles':
|
|
101
|
+
return action.files.join('\n');
|
|
102
|
+
default:
|
|
103
|
+
return '';
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function withValue(action, value, index) {
|
|
107
|
+
const lines = value.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
|
|
108
|
+
switch (action.name) {
|
|
109
|
+
case 'fill':
|
|
110
|
+
return { ...action, text: value };
|
|
111
|
+
case 'assertText':
|
|
112
|
+
return { ...action, text: value };
|
|
113
|
+
case 'assertValue':
|
|
114
|
+
return { ...action, value };
|
|
115
|
+
case 'select':
|
|
116
|
+
if (!lines.length)
|
|
117
|
+
throw new Error(`Step ${index + 1} needs at least one option to select.`);
|
|
118
|
+
return { ...action, options: lines };
|
|
119
|
+
case 'setInputFiles':
|
|
120
|
+
return { ...action, files: lines };
|
|
121
|
+
case 'navigate':
|
|
122
|
+
case 'openPage':
|
|
123
|
+
if (!value.trim() && action.name === 'navigate')
|
|
124
|
+
throw new Error(`Step ${index + 1} needs a URL.`);
|
|
125
|
+
return { ...action, url: value.trim() };
|
|
126
|
+
case 'press':
|
|
127
|
+
return { ...action, ...parseShortcut(value, index) };
|
|
128
|
+
default:
|
|
129
|
+
throw new Error(`Step ${index + 1} has no value to change.`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// "Shift+Enter" → key and the recorder's modifier bits (Alt 1, Control 2, Meta 4, Shift 8).
|
|
133
|
+
function parseShortcut(shortcut, index) {
|
|
134
|
+
const parts = shortcut.trim().split(/\+(?=.)/);
|
|
135
|
+
const key = parts.pop();
|
|
136
|
+
if (!key)
|
|
137
|
+
throw new Error(`Step ${index + 1} needs a key, e.g. Enter or Control+A.`);
|
|
138
|
+
const bits = { Alt: 1, Control: 2, ControlOrMeta: 2, Meta: 4, Shift: 8 };
|
|
139
|
+
let modifiers = 0;
|
|
140
|
+
for (const part of parts) {
|
|
141
|
+
if (!bits[part])
|
|
142
|
+
throw new Error(`Unknown modifier "${part}" in step ${index + 1}: use Alt, Control, ControlOrMeta, Meta or Shift.`);
|
|
143
|
+
modifiers |= bits[part];
|
|
144
|
+
}
|
|
145
|
+
return { key, modifiers };
|
|
146
|
+
}
|
|
147
|
+
// A locator as written in a Playwright test (with or without `page.`) or a selector → selector.
|
|
148
|
+
function parseLocatorText(text, testIdAttribute) {
|
|
149
|
+
const locator = text.trim().replace(/^(await\s+)?page\d*\./, '').replace(/;$/, '');
|
|
150
|
+
const selector = locator ? locatorOrSelectorAsSelector('javascript', locator, testIdAttribute) : '';
|
|
151
|
+
if (!selector)
|
|
152
|
+
throw new Error(`Cannot read the locator "${text.trim()}". Write it as in a Playwright test, e.g. getByRole('button', { name: 'Sign in' }), getByLabel('Email') or locator('#email').`);
|
|
153
|
+
return selector;
|
|
154
|
+
}
|
|
155
|
+
function stepAt(recording, index) {
|
|
156
|
+
const step = Number.isInteger(index) ? recording.actions[index] : undefined;
|
|
157
|
+
if (!step)
|
|
158
|
+
throw new Error(`There is no step ${index + 1}.`);
|
|
159
|
+
return step;
|
|
160
|
+
}
|
|
161
|
+
function checked(recording) {
|
|
162
|
+
const problem = pageOrderProblem(recording);
|
|
163
|
+
if (problem)
|
|
164
|
+
throw new Error(problem);
|
|
165
|
+
return recording;
|
|
166
|
+
}
|
|
167
|
+
//# sourceMappingURL=editing.js.map
|
package/dist/generate.js
CHANGED
|
@@ -16,12 +16,25 @@ export function generateTargets(recording, options) {
|
|
|
16
16
|
: path.join(options.outDir ?? '.', options.targets.length > 1 ? generator.id : '');
|
|
17
17
|
const name = options.name ?? (options.output ? nameFromFileName(options.output) : 'recorded');
|
|
18
18
|
const packageName = options.packageName ?? defaultPackage(generator, directory);
|
|
19
|
-
const files = generator.generate(recording, { name, packageName, testIdAttribute });
|
|
19
|
+
const files = generator.generate(recording, { name, packageName, testIdAttribute, logSteps: options.logSteps, dataDriven: options.dataDriven });
|
|
20
20
|
if (options.output)
|
|
21
21
|
files[0] = { ...files[0], path: path.basename(options.output) };
|
|
22
|
-
|
|
22
|
+
// Java data files are classpath resources: src/test/java/<package> → src/test/resources/<package>.
|
|
23
|
+
const resources = generator.language === 'Java' ? resourceDirectory(directory) : undefined;
|
|
24
|
+
return {
|
|
25
|
+
generator,
|
|
26
|
+
directory,
|
|
27
|
+
files: files.map(file => file.data && resources ? { ...file, path: path.relative(directory, path.join(resources, file.path)) } : file),
|
|
28
|
+
};
|
|
23
29
|
});
|
|
24
30
|
}
|
|
31
|
+
function resourceDirectory(directory) {
|
|
32
|
+
const absolute = path.resolve(directory);
|
|
33
|
+
const match = /([\\/]src[\\/](?:test|main|it)[\\/])java(?=[\\/]|$)/.exec(absolute);
|
|
34
|
+
if (!match)
|
|
35
|
+
return undefined;
|
|
36
|
+
return path.join(absolute.slice(0, match.index) + match[1] + 'resources', absolute.slice(match.index + match[0].length));
|
|
37
|
+
}
|
|
25
38
|
// Java: package from the path below src/test/java (or src/main/java). Go: the directory name when it
|
|
26
39
|
// is a valid package name (the generator falls back to "e2e").
|
|
27
40
|
function defaultPackage(generator, directory) {
|
|
@@ -35,15 +48,19 @@ function defaultPackage(generator, directory) {
|
|
|
35
48
|
return undefined;
|
|
36
49
|
}
|
|
37
50
|
export function writeTargets(outputs) {
|
|
38
|
-
const
|
|
51
|
+
const result = { written: [], kept: [] };
|
|
39
52
|
for (const { directory, files } of outputs) {
|
|
40
|
-
fs.mkdirSync(directory, { recursive: true });
|
|
41
53
|
for (const file of files) {
|
|
42
54
|
const filePath = path.join(directory, file.path);
|
|
55
|
+
if (file.data && fs.existsSync(filePath)) {
|
|
56
|
+
result.kept.push(filePath);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
43
60
|
fs.writeFileSync(filePath, file.content);
|
|
44
|
-
written.push(filePath);
|
|
61
|
+
result.written.push(filePath);
|
|
45
62
|
}
|
|
46
63
|
}
|
|
47
|
-
return
|
|
64
|
+
return result;
|
|
48
65
|
}
|
|
49
66
|
//# sourceMappingURL=generate.js.map
|
package/dist/generators/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { playwrightGo } from './playwright-go.js';
|
|
2
2
|
import { playwrightTs } from './playwright-ts.js';
|
|
3
|
-
import { seleniumJava } from './selenium-java.js';
|
|
3
|
+
import { seleniumJava, seleniumJavaTestng } from './selenium-java.js';
|
|
4
4
|
import { seleniumJs } from './selenium-js.js';
|
|
5
|
-
export const generators = [playwrightTs, playwrightGo, seleniumJava, seleniumJs];
|
|
5
|
+
export const generators = [playwrightTs, playwrightGo, seleniumJava, seleniumJavaTestng, seleniumJs];
|
|
6
6
|
export function findGenerator(id) {
|
|
7
7
|
const generator = generators.find(g => g.id === id);
|
|
8
8
|
if (!generator)
|
|
@@ -1,13 +1,40 @@
|
|
|
1
|
-
import { asLocator } from '../vendor/playwright/locatorGenerators.js';
|
|
1
|
+
import { JavaScriptLocatorFactory, asLocator, asLocatorWithFactory } from '../vendor/playwright/locatorGenerators.js';
|
|
2
2
|
import { formatObject } from '../vendor/playwright/stringUtils.js';
|
|
3
3
|
import { PageAliases, isBlankUrl, shortcutOf, signalsOf, toKeyboardModifiers } from '../recording.js';
|
|
4
|
+
import { isEmpty, joinPieces } from './test-data.js';
|
|
4
5
|
import { singleQuote } from './writer.js';
|
|
6
|
+
const javaScriptPieces = {
|
|
7
|
+
text: singleQuote,
|
|
8
|
+
column: (column) => `row.${column.name}`,
|
|
9
|
+
secret: (secret) => `secret(${singleQuote(secret.name)})`,
|
|
10
|
+
};
|
|
11
|
+
// `const rows = readCsv(...)` for a data-driven test, its column list wrapped at 120 characters.
|
|
12
|
+
export function writeReadCsv(w, dataFile, columns) {
|
|
13
|
+
w.line(`// Data-driven: the test runs once per row of ${dataFile}, next to this file. Its first row holds the recorded values.`);
|
|
14
|
+
const head = `const rows = readCsv(path.join(__dirname, ${singleQuote(dataFile)}), [`;
|
|
15
|
+
const names = columns.map(c => singleQuote(c.name));
|
|
16
|
+
if (head.length + names.join(', ').length + 3 <= 120) {
|
|
17
|
+
w.line(`${head}${names.join(', ')}]);`);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
w.line(head);
|
|
21
|
+
const lines = [''];
|
|
22
|
+
for (const name of names) {
|
|
23
|
+
const last = lines[lines.length - 1];
|
|
24
|
+
if (last && 2 + last.length + name.length + 2 > 120)
|
|
25
|
+
lines.push(name + ',');
|
|
26
|
+
else
|
|
27
|
+
lines[lines.length - 1] = (last ? last + ' ' : '') + name + ',';
|
|
28
|
+
}
|
|
29
|
+
w.indent(() => lines.forEach(line => w.line(line)));
|
|
30
|
+
w.line(']);');
|
|
31
|
+
}
|
|
5
32
|
// Test body written against the Playwright JavaScript API. Used by the Playwright target and by
|
|
6
33
|
// the Selenium JavaScript target, whose support file implements the same API on WebDriver.
|
|
7
34
|
// Follows Playwright's own `playwright-test` code generator.
|
|
8
35
|
export function writeJavaScriptBody(w, recording, options) {
|
|
9
36
|
const aliases = new PageAliases();
|
|
10
|
-
|
|
37
|
+
recording.actions.forEach((actionInContext, index) => {
|
|
11
38
|
const action = actionInContext.action;
|
|
12
39
|
// Resolve before skipping, so pages are numbered in the order they are opened.
|
|
13
40
|
const pageAlias = aliases.alias(actionInContext.pageGuid);
|
|
@@ -16,11 +43,11 @@ export function writeJavaScriptBody(w, recording, options) {
|
|
|
16
43
|
w.line(`const ${pageAlias} = await page.context().newPage();`);
|
|
17
44
|
if (!isBlankUrl(action.url))
|
|
18
45
|
w.line(`await ${pageAlias}.goto(${singleQuote(action.url)});`);
|
|
19
|
-
|
|
46
|
+
return;
|
|
20
47
|
}
|
|
21
48
|
// The test runner closes pages itself.
|
|
22
49
|
if (action.name === 'closePage')
|
|
23
|
-
|
|
50
|
+
return;
|
|
24
51
|
const signals = signalsOf(actionInContext);
|
|
25
52
|
if (signals.dialog) {
|
|
26
53
|
w.line(`${pageAlias}.once('dialog', dialog => {`);
|
|
@@ -38,17 +65,22 @@ export function writeJavaScriptBody(w, recording, options) {
|
|
|
38
65
|
w.line(`const ${download}Promise = ${pageAlias}.waitForEvent('download');`);
|
|
39
66
|
if (signals.download && !download)
|
|
40
67
|
w.line(options.todo('the next step starts a download, which is not checked here.'));
|
|
41
|
-
|
|
68
|
+
const literals = {
|
|
69
|
+
value: (text) => options.values.value(index, text),
|
|
70
|
+
locator: (selector) => javaScriptLocator(selector, text => expression(options.values.locator(index, text))),
|
|
71
|
+
};
|
|
72
|
+
w.line(actionCall(pageAlias, actionInContext, options, literals));
|
|
42
73
|
if (popupAlias)
|
|
43
74
|
w.line(`const ${popupAlias} = await ${popupAlias}Promise;`);
|
|
44
75
|
if (download)
|
|
45
76
|
w.line(`const ${download} = await ${download}Promise;`);
|
|
46
77
|
if (recording.header.generateExpectSignal && signals.expect)
|
|
47
|
-
w.line(`await expect(${pageAlias}.${
|
|
48
|
-
}
|
|
78
|
+
w.line(`await expect(${pageAlias}.${literals.locator(signals.expect.selector)}).toBeVisible();`);
|
|
79
|
+
});
|
|
49
80
|
}
|
|
50
|
-
function actionCall(subject, { action }, options) {
|
|
51
|
-
const locator = 'selector' in action ? `${subject}.${
|
|
81
|
+
function actionCall(subject, { action }, options, literals) {
|
|
82
|
+
const locator = 'selector' in action ? `${subject}.${literals.locator(action.selector)}` : subject;
|
|
83
|
+
const value = (text) => joinPieces(literals.value(text), javaScriptPieces);
|
|
52
84
|
switch (action.name) {
|
|
53
85
|
case 'click': {
|
|
54
86
|
const method = action.clickCount === 2 ? 'dblclick' : 'click';
|
|
@@ -71,7 +103,7 @@ function actionCall(subject, { action }, options) {
|
|
|
71
103
|
case 'uncheck':
|
|
72
104
|
return `await ${locator}.uncheck();`;
|
|
73
105
|
case 'fill':
|
|
74
|
-
return `await ${locator}.fill(${
|
|
106
|
+
return `await ${locator}.fill(${value(action.text)});`;
|
|
75
107
|
case 'setInputFiles':
|
|
76
108
|
return `await ${locator}.setInputFiles(${formatObject(action.files.length === 1 ? action.files[0] : action.files)});`;
|
|
77
109
|
case 'press':
|
|
@@ -79,15 +111,15 @@ function actionCall(subject, { action }, options) {
|
|
|
79
111
|
case 'navigate':
|
|
80
112
|
return `await ${subject}.goto(${singleQuote(action.url)});`;
|
|
81
113
|
case 'select':
|
|
82
|
-
return `await ${locator}.selectOption(${
|
|
114
|
+
return `await ${locator}.selectOption(${action.options.length === 1 ? value(action.options[0]) : formatObject(action.options)});`;
|
|
83
115
|
case 'assertText':
|
|
84
|
-
return `await expect(${locator}).${action.substring ? 'toContainText' : 'toHaveText'}(${
|
|
116
|
+
return `await expect(${locator}).${action.substring ? 'toContainText' : 'toHaveText'}(${value(action.text)});`;
|
|
85
117
|
case 'assertChecked':
|
|
86
118
|
return `await expect(${locator})${action.checked ? '' : '.not'}.toBeChecked();`;
|
|
87
119
|
case 'assertVisible':
|
|
88
120
|
return `await expect(${locator}).toBeVisible();`;
|
|
89
121
|
case 'assertValue':
|
|
90
|
-
return `await expect(${locator}).${action.value ? `toHaveValue(${
|
|
122
|
+
return `await expect(${locator}).${isEmpty(literals.value(action.value)) ? 'toBeEmpty()' : `toHaveValue(${value(action.value)})`};`;
|
|
91
123
|
case 'assertSnapshot':
|
|
92
124
|
if (action.ariaSnapshot)
|
|
93
125
|
return `await expect(${locator}).toMatchAriaSnapshot(${singleQuote(action.ariaSnapshot)});`;
|
|
@@ -96,6 +128,52 @@ function actionCall(subject, { action }, options) {
|
|
|
96
128
|
return options.todo(`unsupported recorded action ${JSON.stringify(action)}`);
|
|
97
129
|
}
|
|
98
130
|
}
|
|
131
|
+
// An expression for a locator text, or undefined to keep the text as a literal.
|
|
132
|
+
function expression(pieces) {
|
|
133
|
+
return pieces.length === 1 && 'text' in pieces[0] ? undefined : joinPieces(pieces, javaScriptPieces);
|
|
134
|
+
}
|
|
135
|
+
// Playwright's own JavaScript locator code, with some texts written as expressions. Those texts are
|
|
136
|
+
// rendered as placeholders first, so that everything else keeps Playwright's formatting.
|
|
137
|
+
function javaScriptLocator(selector, textExpression) {
|
|
138
|
+
const expressions = [];
|
|
139
|
+
const placeholder = (text) => {
|
|
140
|
+
const replacement = typeof text === 'string' ? textExpression(text) : undefined;
|
|
141
|
+
if (replacement === undefined)
|
|
142
|
+
return text;
|
|
143
|
+
expressions.push(replacement);
|
|
144
|
+
return `__testgen_${expressions.length - 1}__`;
|
|
145
|
+
};
|
|
146
|
+
const inner = new JavaScriptLocatorFactory();
|
|
147
|
+
const factory = {
|
|
148
|
+
generateLocator(base, kind, body, options = {}) {
|
|
149
|
+
switch (kind) {
|
|
150
|
+
case 'role':
|
|
151
|
+
return inner.generateLocator(base, kind, body, { ...options, name: placeholder(options.name) });
|
|
152
|
+
case 'default':
|
|
153
|
+
return inner.generateLocator(base, kind, body, { ...options, hasText: placeholder(options.hasText), hasNotText: placeholder(options.hasNotText) });
|
|
154
|
+
case 'has-text':
|
|
155
|
+
case 'has-not-text':
|
|
156
|
+
case 'test-id':
|
|
157
|
+
case 'text':
|
|
158
|
+
case 'alt':
|
|
159
|
+
case 'placeholder':
|
|
160
|
+
case 'label':
|
|
161
|
+
case 'title':
|
|
162
|
+
return inner.generateLocator(base, kind, placeholder(body), options);
|
|
163
|
+
default:
|
|
164
|
+
return inner.generateLocator(base, kind, body, options);
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
chainLocators: locators => inner.chainLocators(locators),
|
|
168
|
+
};
|
|
169
|
+
try {
|
|
170
|
+
return asLocatorWithFactory(factory, selector).replace(/'__testgen_(\d+)__'/g, (_, i) => expressions[Number(i)]);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
// Not a selector Playwright can turn into locators: asLocator() keeps it as it is.
|
|
174
|
+
return asLocator('javascript', selector);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
99
177
|
function formatOptions(value) {
|
|
100
178
|
return Object.values(value).some(v => v !== undefined) ? formatObject(value) : '';
|
|
101
179
|
}
|
|
@@ -2,6 +2,8 @@ import { devices } from 'playwright-core';
|
|
|
2
2
|
import { isRegex, parseLocator } from '../locator.js';
|
|
3
3
|
import { pascalCase, snakeCase } from '../naming.js';
|
|
4
4
|
import { PageAliases, isBlankUrl, shortcutOf, signalsOf, toKeyboardModifiers } from '../recording.js';
|
|
5
|
+
import { readRuntimeFile } from './runtime.js';
|
|
6
|
+
import { collectTestValues, isEmpty, joinPieces, toCsv } from './test-data.js';
|
|
5
7
|
import { CodeWriter, doubleQuote } from './writer.js';
|
|
6
8
|
const playwrightGoModule = 'github.com/mxschmitt/playwright-go';
|
|
7
9
|
// Roles with a playwright.AriaRole<Name> constant in playwright-go.
|
|
@@ -12,15 +14,29 @@ const ariaRoles = new Set(('alert alertdialog application article banner blockqu
|
|
|
12
14
|
'radiogroup region row rowgroup rowheader scrollbar search searchbox separator slider spinbutton status strong ' +
|
|
13
15
|
'subscript superscript switch tab table tablist tabpanel term textbox time timer toolbar tooltip tree treegrid treeitem').split(' '));
|
|
14
16
|
const capitalize = (text) => text.charAt(0).toUpperCase() + text.slice(1);
|
|
17
|
+
// Names a secret's variable cannot take: Go keywords, imported packages and the test's own variables.
|
|
18
|
+
const goNames = new Set(('break case chan const continue default defer else fallthrough for func go goto if import interface ' +
|
|
19
|
+
'map package range return select struct switch type var fmt os playwright regexp require testing ' +
|
|
20
|
+
'pw err browser context page expect device dialog t i row ok').split(' '));
|
|
21
|
+
const reservedName = (name) => goNames.has(name) || /^page\d+$/.test(name);
|
|
15
22
|
export const playwrightGo = {
|
|
16
23
|
id: 'playwright-go',
|
|
17
24
|
framework: 'Playwright for Go (playwright-go)',
|
|
18
25
|
language: 'Go',
|
|
19
26
|
highlight: 'go',
|
|
27
|
+
features: ['dataDriven'],
|
|
20
28
|
testFileName: name => `${snakeCase(name)}_test.go`,
|
|
21
29
|
generate(recording, options) {
|
|
22
30
|
const imports = new Set(['os', 'testing']);
|
|
23
31
|
const locators = new GoLocators(() => imports.add('regexp'));
|
|
32
|
+
const values = collectTestValues(recording, { dataDriven: options.dataDriven, reserved: reservedName });
|
|
33
|
+
const { columns } = values;
|
|
34
|
+
const dataFile = `testdata/${snakeCase(options.name)}.csv`;
|
|
35
|
+
const goPieces = {
|
|
36
|
+
text: doubleQuote,
|
|
37
|
+
column: (column) => `row[${doubleQuote(column.name)}]`,
|
|
38
|
+
secret: (secret) => values.variable(secret),
|
|
39
|
+
};
|
|
24
40
|
const body = new CodeWriter('\t');
|
|
25
41
|
const aliases = new PageAliases();
|
|
26
42
|
const declared = new Set();
|
|
@@ -60,7 +76,12 @@ export const playwrightGo = {
|
|
|
60
76
|
body.line('_ = dialog.Dismiss()');
|
|
61
77
|
}, '})');
|
|
62
78
|
}
|
|
63
|
-
const
|
|
79
|
+
const literals = {
|
|
80
|
+
value: text => joinPieces(values.value(index, text), goPieces),
|
|
81
|
+
isEmpty: text => isEmpty(values.value(index, text)),
|
|
82
|
+
locator: text => joinPieces(values.locator(index, text), goPieces),
|
|
83
|
+
};
|
|
84
|
+
const call = actionCall(pageAlias, action, locators, literals);
|
|
64
85
|
if (action.name.startsWith('assert'))
|
|
65
86
|
usesExpect = true;
|
|
66
87
|
if (call.todo)
|
|
@@ -81,15 +102,18 @@ export const playwrightGo = {
|
|
|
81
102
|
}
|
|
82
103
|
if (recording.header.generateExpectSignal && signals.expect) {
|
|
83
104
|
usesExpect = true;
|
|
84
|
-
emit(body, { expr: `expect.Locator(${locators.render(pageAlias, parseLocator(signals.expect.selector))}).ToBeVisible()`, returnsValue: false });
|
|
105
|
+
emit(body, { expr: `expect.Locator(${locators.render(pageAlias, parseLocator(signals.expect.selector), literals.locator)}).ToBeVisible()`, returnsValue: false });
|
|
85
106
|
}
|
|
86
107
|
});
|
|
87
108
|
if (!declared.size && !recording.header.saveStorage)
|
|
88
109
|
body.line('_ = context');
|
|
89
110
|
if (recording.header.saveStorage)
|
|
90
111
|
emit(body, { expr: `context.StorageState(playwright.BrowserContextStorageStateOptions{Path: playwright.String(${doubleQuote(recording.header.saveStorage)})})`, returnsValue: true });
|
|
112
|
+
if (columns.length)
|
|
113
|
+
imports.add('fmt');
|
|
114
|
+
const packageName = goPackageName(options.packageName);
|
|
91
115
|
const w = new CodeWriter('\t');
|
|
92
|
-
w.line(`package ${
|
|
116
|
+
w.line(`package ${packageName}`);
|
|
93
117
|
w.line();
|
|
94
118
|
w.line('import (');
|
|
95
119
|
w.indent(() => {
|
|
@@ -103,6 +127,14 @@ export const playwrightGo = {
|
|
|
103
127
|
w.line();
|
|
104
128
|
w.line('// Generated by testgen from a Playwright recording. Set HEADED=1 to watch the browser.');
|
|
105
129
|
w.block(`func Test${pascalCase(options.name)}(t *testing.T) {`, () => {
|
|
130
|
+
if (values.secrets.length) {
|
|
131
|
+
w.line('// Values typed into password fields are not kept in the test: set them as environment variables.');
|
|
132
|
+
for (const secret of values.secrets) {
|
|
133
|
+
w.line(`${values.variable(secret)}, ok := os.LookupEnv(${doubleQuote(secret.name)})`);
|
|
134
|
+
w.line(`require.True(t, ok, ${doubleQuote(`set the environment variable ${secret.name}, the value to type into a password field`)})`);
|
|
135
|
+
}
|
|
136
|
+
w.line();
|
|
137
|
+
}
|
|
106
138
|
w.line('pw, err := playwright.Run()');
|
|
107
139
|
w.line('require.NoError(t, err)');
|
|
108
140
|
w.line('t.Cleanup(func() { _ = pw.Stop() })');
|
|
@@ -118,16 +150,42 @@ export const playwrightGo = {
|
|
|
118
150
|
const context = contextOptions(recording.header, options.testIdAttribute);
|
|
119
151
|
for (const line of context.before)
|
|
120
152
|
w.line(line);
|
|
121
|
-
|
|
122
|
-
|
|
153
|
+
const newContext = `context, err := browser.NewContext(${context.fields.length ? structLiteral('playwright.BrowserNewContextOptions', context.fields) : ''})`;
|
|
154
|
+
const setTestIdAttribute = `pw.Selectors.SetTestIdAttribute(${doubleQuote(options.testIdAttribute)})`;
|
|
155
|
+
if (!columns.length) {
|
|
156
|
+
w.line(newContext);
|
|
157
|
+
w.line('require.NoError(t, err)');
|
|
158
|
+
if (options.testIdAttribute !== 'data-testid')
|
|
159
|
+
w.line(setTestIdAttribute);
|
|
160
|
+
if (usesExpect)
|
|
161
|
+
w.line('expect := playwright.NewPlaywrightAssertions()');
|
|
162
|
+
w.line();
|
|
163
|
+
if (options.dataDriven)
|
|
164
|
+
w.line('// Data-driven: nothing in the recording is typed, selected or asserted with a value, so there is no data file.');
|
|
165
|
+
w.line(body.toString().trimEnd());
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
123
168
|
if (options.testIdAttribute !== 'data-testid')
|
|
124
|
-
w.line(
|
|
169
|
+
w.line(setTestIdAttribute);
|
|
125
170
|
if (usesExpect)
|
|
126
171
|
w.line('expect := playwright.NewPlaywrightAssertions()');
|
|
127
172
|
w.line();
|
|
128
|
-
w.line(
|
|
173
|
+
w.line(`// Data-driven: a subtest runs for each row of ${dataFile}. Its first row holds the recorded values.`);
|
|
174
|
+
w.block(`for i, row := range readTestgenCSV(t, ${[dataFile, ...columns.map(c => c.name)].map(doubleQuote).join(', ')}) {`, () => {
|
|
175
|
+
w.block('t.Run(fmt.Sprintf("row %d", i+1), func(t *testing.T) {', () => {
|
|
176
|
+
w.line(newContext);
|
|
177
|
+
w.line('require.NoError(t, err)');
|
|
178
|
+
w.line('t.Cleanup(func() { _ = context.Close() })');
|
|
179
|
+
w.line();
|
|
180
|
+
w.line(body.toString().trimEnd());
|
|
181
|
+
}, '})');
|
|
182
|
+
});
|
|
129
183
|
});
|
|
130
|
-
|
|
184
|
+
const files = [{ path: this.testFileName(options.name), content: w.toString() }];
|
|
185
|
+
if (columns.length) {
|
|
186
|
+
files.push({ path: 'testgen_data_test.go', content: readRuntimeFile('playwright-go/testgen_data_test.go').replace(/^package \w+$/m, `package ${packageName}`), support: true }, { path: dataFile, content: toCsv([columns.map(c => c.name), columns.map(c => c.value)]), data: true });
|
|
187
|
+
}
|
|
188
|
+
return files;
|
|
131
189
|
},
|
|
132
190
|
};
|
|
133
191
|
function goPackageName(name) {
|
|
@@ -230,8 +288,8 @@ function contextOptions(header, testIdAttribute) {
|
|
|
230
288
|
before.unshift(`// TODO(testgen): context options not converted: ${unsupported.join(', ')}.`);
|
|
231
289
|
return { before, fields };
|
|
232
290
|
}
|
|
233
|
-
function actionCall(subject, action, locators) {
|
|
234
|
-
const locator = 'selector' in action ? locators.render(subject, parseLocator(action.selector)) : subject;
|
|
291
|
+
function actionCall(subject, action, locators, literals) {
|
|
292
|
+
const locator = 'selector' in action ? locators.render(subject, parseLocator(action.selector), literals.locator) : subject;
|
|
235
293
|
const expect = (assertion) => ({ expr: `expect.Locator(${locator}).${assertion}`, returnsValue: false });
|
|
236
294
|
switch (action.name) {
|
|
237
295
|
case 'click': {
|
|
@@ -258,7 +316,7 @@ function actionCall(subject, action, locators) {
|
|
|
258
316
|
case 'uncheck':
|
|
259
317
|
return { expr: `${locator}.Uncheck()`, returnsValue: false };
|
|
260
318
|
case 'fill':
|
|
261
|
-
return { expr: `${locator}.Fill(${
|
|
319
|
+
return { expr: `${locator}.Fill(${literals.value(action.text)})`, returnsValue: false };
|
|
262
320
|
case 'setInputFiles': {
|
|
263
321
|
const files = action.files.length === 1 ? doubleQuote(action.files[0]) : `[]string{${action.files.map(doubleQuote).join(', ')}}`;
|
|
264
322
|
return { expr: `${locator}.SetInputFiles(${files})`, returnsValue: false };
|
|
@@ -268,15 +326,15 @@ function actionCall(subject, action, locators) {
|
|
|
268
326
|
case 'navigate':
|
|
269
327
|
return { expr: `${subject}.Goto(${doubleQuote(action.url)})`, returnsValue: true };
|
|
270
328
|
case 'select':
|
|
271
|
-
return { expr: `${locator}.SelectOption(playwright.SelectOptionValues{ValuesOrLabels: playwright.StringSlice(${action.options.map(doubleQuote).join(', ')})})`, returnsValue: true };
|
|
329
|
+
return { expr: `${locator}.SelectOption(playwright.SelectOptionValues{ValuesOrLabels: playwright.StringSlice(${action.options.length === 1 ? literals.value(action.options[0]) : action.options.map(doubleQuote).join(', ')})})`, returnsValue: true };
|
|
272
330
|
case 'assertText':
|
|
273
|
-
return expect(`${action.substring ? 'ToContainText' : 'ToHaveText'}(${
|
|
331
|
+
return expect(`${action.substring ? 'ToContainText' : 'ToHaveText'}(${literals.value(action.text)})`);
|
|
274
332
|
case 'assertChecked':
|
|
275
333
|
return expect(action.checked ? 'ToBeChecked()' : 'Not().ToBeChecked()');
|
|
276
334
|
case 'assertVisible':
|
|
277
335
|
return expect('ToBeVisible()');
|
|
278
336
|
case 'assertValue':
|
|
279
|
-
return expect(action.value ? `ToHaveValue(${
|
|
337
|
+
return expect(literals.isEmpty(action.value) ? 'ToBeEmpty()' : `ToHaveValue(${literals.value(action.value)})`);
|
|
280
338
|
case 'assertSnapshot':
|
|
281
339
|
if (action.ariaSnapshot)
|
|
282
340
|
return expect(`ToMatchAriaSnapshot(${doubleQuote(action.ariaSnapshot)})`);
|
|
@@ -289,15 +347,21 @@ function actionCall(subject, action, locators) {
|
|
|
289
347
|
// Renders Playwright locator steps as playwright-go calls.
|
|
290
348
|
class GoLocators {
|
|
291
349
|
_onRegexp;
|
|
350
|
+
_literal = doubleQuote;
|
|
292
351
|
constructor(_onRegexp) {
|
|
293
352
|
this._onRegexp = _onRegexp;
|
|
294
353
|
}
|
|
295
|
-
|
|
354
|
+
// `literal` writes the text arguments of GetBy* and Filter calls.
|
|
355
|
+
render(subject, steps, literal = doubleQuote) {
|
|
356
|
+
this._literal = literal;
|
|
357
|
+
return this._render(subject, steps);
|
|
358
|
+
}
|
|
359
|
+
_render(subject, steps) {
|
|
296
360
|
return subject + steps.map(step => '.' + this._step(subject, step)).join('');
|
|
297
361
|
}
|
|
298
362
|
_value(matcher) {
|
|
299
363
|
if (!isRegex(matcher))
|
|
300
|
-
return
|
|
364
|
+
return this._literal(matcher);
|
|
301
365
|
this._onRegexp();
|
|
302
366
|
const flags = matcher.flags.replace(/[^ims]/g, '');
|
|
303
367
|
const source = (flags ? `(?${flags})` : '') + matcher.source;
|
|
@@ -335,15 +399,15 @@ class GoLocators {
|
|
|
335
399
|
case 'has-not-text':
|
|
336
400
|
return filter('HasNotText', this._value(step.body));
|
|
337
401
|
case 'has':
|
|
338
|
-
return filter('Has', this.
|
|
402
|
+
return filter('Has', this._render(subject, step.inner));
|
|
339
403
|
case 'hasNot':
|
|
340
|
-
return filter('HasNot', this.
|
|
404
|
+
return filter('HasNot', this._render(subject, step.inner));
|
|
341
405
|
case 'and':
|
|
342
|
-
return `And(${this.
|
|
406
|
+
return `And(${this._render(subject, step.inner)})`;
|
|
343
407
|
case 'or':
|
|
344
|
-
return `Or(${this.
|
|
408
|
+
return `Or(${this._render(subject, step.inner)})`;
|
|
345
409
|
case 'chain':
|
|
346
|
-
return `Locator(${this.
|
|
410
|
+
return `Locator(${this._render(subject, step.inner)})`;
|
|
347
411
|
case 'test-id':
|
|
348
412
|
return `GetByTestId(${this._value(step.body)})`;
|
|
349
413
|
case 'role':
|