@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
package/dist/cli.js
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import { parseArgs } from 'node:util';
|
|
7
|
+
import { checkFiles, checkRecording } from './checks.js';
|
|
6
8
|
import { generateTargets, writeTargets } from './generate.js';
|
|
7
|
-
import { generators } from './generators/index.js';
|
|
9
|
+
import { findGenerator, generators } from './generators/index.js';
|
|
8
10
|
import { startCodegen, waitForExit } from './record.js';
|
|
9
|
-
import { parseRecording } from './recording.js';
|
|
11
|
+
import { parseRecording, recordedTestIdAttribute, serializeRecording } from './recording.js';
|
|
12
|
+
import { collectSecrets, redactRecording } from './secrets.js';
|
|
10
13
|
import { startUiServer } from './ui/server.js';
|
|
11
14
|
const usage = `testgen: record a UI test once with the Playwright recorder, then generate it for several
|
|
12
15
|
frameworks and languages.
|
|
13
16
|
|
|
14
17
|
Usage:
|
|
15
|
-
testgen ui [--port <port>] [--no-open] Web UI: record and watch the code
|
|
18
|
+
testgen ui [--port <port>] [--no-open] Web UI: record, edit and replay steps, watch the code
|
|
19
|
+
of every target live
|
|
16
20
|
testgen record [url] [options] Record in a browser, generate when it is closed
|
|
17
21
|
testgen generate <recording.jsonl> [options] Generate tests from a saved recording
|
|
18
22
|
testgen targets List the available targets
|
|
@@ -24,7 +28,12 @@ Output options:
|
|
|
24
28
|
(default for record: ./generated)
|
|
25
29
|
-n, --name <name> Test name (default: taken from --output, else "recorded")
|
|
26
30
|
--package <name> Java or Go package (default: derived from the output path)
|
|
27
|
-
--test-id-attribute <a> Attribute
|
|
31
|
+
--test-id-attribute <a> Attribute for getByTestId when recording (default: data-testid);
|
|
32
|
+
generated code keeps the one the recording was made with
|
|
33
|
+
--log-steps Selenium: log every step and assertion result (console, TestNG report)
|
|
34
|
+
--data-driven Typed, selected and asserted values become columns of a CSV file next
|
|
35
|
+
to the test (Java: src/test/resources/<package>/, Go: testdata/); the
|
|
36
|
+
test runs once per row. An existing CSV file is never overwritten
|
|
28
37
|
--save <file.jsonl> record: where to keep the recording (default: <out-dir>/recording.jsonl)
|
|
29
38
|
|
|
30
39
|
Recorder options (record), same as playwright codegen:
|
|
@@ -39,7 +48,9 @@ const options = {
|
|
|
39
48
|
'out-dir': { type: 'string', short: 'd' },
|
|
40
49
|
'name': { type: 'string', short: 'n' },
|
|
41
50
|
'package': { type: 'string' },
|
|
42
|
-
'test-id-attribute': { type: 'string'
|
|
51
|
+
'test-id-attribute': { type: 'string' },
|
|
52
|
+
'log-steps': { type: 'boolean' },
|
|
53
|
+
'data-driven': { type: 'boolean' },
|
|
43
54
|
'save': { type: 'string' },
|
|
44
55
|
'browser': { type: 'string', short: 'b' },
|
|
45
56
|
'channel': { type: 'string' },
|
|
@@ -66,40 +77,69 @@ async function main(argv) {
|
|
|
66
77
|
return;
|
|
67
78
|
}
|
|
68
79
|
const targets = values.target ? values.target.split(',').map(t => t.trim()).filter(Boolean) : generators.map(g => g.id);
|
|
80
|
+
const testIdAttribute = values['test-id-attribute'] ?? 'data-testid';
|
|
69
81
|
const output = {
|
|
70
82
|
targets,
|
|
71
83
|
output: values.output,
|
|
72
84
|
name: values.name,
|
|
73
85
|
packageName: values.package,
|
|
74
|
-
testIdAttribute
|
|
86
|
+
testIdAttribute,
|
|
87
|
+
logSteps: values['log-steps'],
|
|
88
|
+
dataDriven: values['data-driven'],
|
|
75
89
|
};
|
|
90
|
+
if (command === 'generate' || command === 'record') {
|
|
91
|
+
const requested = [
|
|
92
|
+
['logSteps', '--log-steps', values['log-steps']],
|
|
93
|
+
['dataDriven', '--data-driven', values['data-driven']],
|
|
94
|
+
];
|
|
95
|
+
for (const [feature, flag, on] of requested) {
|
|
96
|
+
const ignoredBy = on ? targets.filter(id => !findGenerator(id).features?.includes(feature)) : [];
|
|
97
|
+
if (ignoredBy.length)
|
|
98
|
+
console.error(`note: ${flag} only applies to ${generators.filter(g => g.features?.includes(feature)).map(g => g.id).join(', ')}; ignored for ${ignoredBy.join(', ')}.`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
76
101
|
switch (command) {
|
|
77
|
-
case 'targets':
|
|
102
|
+
case 'targets': {
|
|
103
|
+
const width = Math.max(...generators.map(g => g.id.length)) + 2;
|
|
78
104
|
for (const generator of generators)
|
|
79
|
-
console.log(`${generator.id.padEnd(
|
|
105
|
+
console.log(`${generator.id.padEnd(width)}${generator.framework} (${generator.language})`);
|
|
80
106
|
return;
|
|
107
|
+
}
|
|
81
108
|
case 'generate': {
|
|
82
109
|
const file = rest[0];
|
|
83
110
|
if (!file)
|
|
84
111
|
throw new Error('Usage: testgen generate <recording.jsonl> [options]');
|
|
85
112
|
const recording = parseRecording(fs.readFileSync(file, 'utf8'), file);
|
|
113
|
+
const recorded = recordedTestIdAttribute(recording);
|
|
114
|
+
if (values['test-id-attribute'] && recorded && recorded !== values['test-id-attribute'])
|
|
115
|
+
console.error(`note: ignoring --test-id-attribute ${values['test-id-attribute']}: the recording's getByTestId() steps use ${recorded}.`);
|
|
116
|
+
const typed = collectSecrets(recording).list.filter(secret => secret.value);
|
|
117
|
+
if (typed.length) {
|
|
118
|
+
console.error(`note: ${file} holds the values typed into password fields; the tests read them from ${typed.map(s => s.name).join(', ')}. `
|
|
119
|
+
+ 'To drop them from the file, open it in testgen ui and download it again.');
|
|
120
|
+
}
|
|
86
121
|
if (!values.output && !values['out-dir']) {
|
|
87
122
|
if (targets.length !== 1)
|
|
88
123
|
throw new Error('Pass --out-dir (or a single --target to print the test to stdout).');
|
|
89
|
-
const
|
|
90
|
-
process.stdout.write(files[0].content);
|
|
91
|
-
for (const
|
|
92
|
-
console.error(`note: the test also needs ${
|
|
124
|
+
const outputs = generateTargets(recording, output);
|
|
125
|
+
process.stdout.write(outputs[0].files[0].content);
|
|
126
|
+
for (const other of outputs[0].files.slice(1))
|
|
127
|
+
console.error(`note: the test also needs ${other.path}; use --out-dir or --output to write it.`);
|
|
128
|
+
reportChecks(recording, outputs);
|
|
93
129
|
return;
|
|
94
130
|
}
|
|
95
|
-
|
|
131
|
+
const outputs = generateTargets(recording, { ...output, outDir: values['out-dir'] });
|
|
132
|
+
report(writeTargets(outputs));
|
|
133
|
+
reportChecks(recording, outputs);
|
|
96
134
|
return;
|
|
97
135
|
}
|
|
98
136
|
case 'record': {
|
|
99
137
|
const outDir = values['out-dir'] ?? (values.output ? undefined : 'generated');
|
|
100
138
|
const save = path.resolve(values.save ?? path.join(outDir ?? path.dirname(values.output), 'recording.jsonl'));
|
|
101
139
|
fs.mkdirSync(path.dirname(save), { recursive: true });
|
|
102
|
-
|
|
140
|
+
// The recorder writes typed passwords too; the saved recording leaves them out.
|
|
141
|
+
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'testgen-record-'));
|
|
142
|
+
const recordingFile = path.join(workDir, 'recording.jsonl');
|
|
103
143
|
const codegen = {
|
|
104
144
|
browser: values.browser,
|
|
105
145
|
channel: values.channel,
|
|
@@ -112,20 +152,32 @@ async function main(argv) {
|
|
|
112
152
|
ignoreHttpsErrors: values['ignore-https-errors'],
|
|
113
153
|
loadStorage: values['load-storage'],
|
|
114
154
|
saveStorage: values['save-storage'],
|
|
115
|
-
testIdAttribute
|
|
155
|
+
testIdAttribute,
|
|
116
156
|
inspector: values.inspector,
|
|
117
157
|
};
|
|
118
158
|
console.log('Recording... interact with the page, then close the browser window (or press Ctrl+C) to generate the tests.');
|
|
119
|
-
const recorder = startCodegen(rest[0],
|
|
159
|
+
const recorder = startCodegen(rest[0], recordingFile, codegen);
|
|
120
160
|
// Ctrl+C ends the recording, not testgen: the tests are still generated afterwards.
|
|
121
161
|
const stopRecorder = () => recorder.kill('SIGINT');
|
|
122
162
|
process.on('SIGINT', stopRecorder);
|
|
123
163
|
const code = await waitForExit(recorder);
|
|
124
164
|
process.off('SIGINT', stopRecorder);
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
165
|
+
let recording;
|
|
166
|
+
try {
|
|
167
|
+
if (!fs.existsSync(recordingFile))
|
|
168
|
+
throw new Error(`Nothing was recorded (the recorder exited with code ${code}).`);
|
|
169
|
+
recording = parseRecording(fs.readFileSync(recordingFile, 'utf8'), save);
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
fs.rmSync(workDir, { recursive: true, force: true });
|
|
173
|
+
}
|
|
174
|
+
fs.writeFileSync(save, serializeRecording(redactRecording(recording)));
|
|
175
|
+
const secrets = collectSecrets(recording).list;
|
|
176
|
+
console.log(`Recording saved to ${path.relative(process.cwd(), save)}`
|
|
177
|
+
+ (secrets.length ? ` without the values typed into password fields (the tests read ${secrets.map(s => s.name).join(', ')})` : ''));
|
|
178
|
+
const outputs = generateTargets(recording, { ...output, outDir });
|
|
179
|
+
report(writeTargets(outputs));
|
|
180
|
+
reportChecks(recording, outputs);
|
|
129
181
|
return;
|
|
130
182
|
}
|
|
131
183
|
case 'ui': {
|
|
@@ -142,9 +194,28 @@ async function main(argv) {
|
|
|
142
194
|
throw new Error(`Unknown command "${command}".\n\n${usage}`);
|
|
143
195
|
}
|
|
144
196
|
}
|
|
145
|
-
function report(
|
|
146
|
-
for (const file of
|
|
197
|
+
function report({ written, kept }) {
|
|
198
|
+
for (const file of written)
|
|
147
199
|
console.log(` wrote ${path.relative(process.cwd(), file)}`);
|
|
200
|
+
for (const file of kept)
|
|
201
|
+
console.log(` kept ${path.relative(process.cwd(), file)} (test data is never overwritten; delete it to start over)`);
|
|
202
|
+
}
|
|
203
|
+
// Checks of the recording, then what each target cannot do; targets with the same TODO are listed once.
|
|
204
|
+
function reportChecks(recording, outputs) {
|
|
205
|
+
const byMessage = new Map();
|
|
206
|
+
for (const { generator, files } of outputs) {
|
|
207
|
+
for (const check of checkFiles(files)) {
|
|
208
|
+
const entry = byMessage.get(check.message) ?? { level: check.level, targets: [] };
|
|
209
|
+
entry.targets.push(generator.id);
|
|
210
|
+
byMessage.set(check.message, entry);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const lines = [
|
|
214
|
+
...checkRecording(recording).map(check => `${check.level === 'warning' ? 'warning' : 'note'}: ${check.message}`),
|
|
215
|
+
...[...byMessage].map(([message, { level, targets }]) => `${level === 'warning' ? 'warning' : 'note'}: ${targets.join(', ')}: ${message}`),
|
|
216
|
+
];
|
|
217
|
+
for (const line of lines)
|
|
218
|
+
console.error(line);
|
|
148
219
|
}
|
|
149
220
|
function openInBrowser(url) {
|
|
150
221
|
const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
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
|
@@ -2,11 +2,13 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { findGenerator } from './generators/index.js';
|
|
4
4
|
import { nameFromFileName } from './naming.js';
|
|
5
|
+
import { recordedTestIdAttribute } from './recording.js';
|
|
5
6
|
export function generateTargets(recording, options) {
|
|
6
7
|
if (!options.targets.length)
|
|
7
8
|
throw new Error('Pick at least one target with --target.');
|
|
8
9
|
if (options.output && options.targets.length > 1)
|
|
9
10
|
throw new Error('--output takes a single target; use --out-dir for several targets.');
|
|
11
|
+
const testIdAttribute = recordedTestIdAttribute(recording) ?? options.testIdAttribute;
|
|
10
12
|
return options.targets.map(id => {
|
|
11
13
|
const generator = findGenerator(id);
|
|
12
14
|
const directory = options.output
|
|
@@ -14,12 +16,25 @@ export function generateTargets(recording, options) {
|
|
|
14
16
|
: path.join(options.outDir ?? '.', options.targets.length > 1 ? generator.id : '');
|
|
15
17
|
const name = options.name ?? (options.output ? nameFromFileName(options.output) : 'recorded');
|
|
16
18
|
const packageName = options.packageName ?? defaultPackage(generator, directory);
|
|
17
|
-
const files = generator.generate(recording, { name, packageName, testIdAttribute: options.
|
|
19
|
+
const files = generator.generate(recording, { name, packageName, testIdAttribute, logSteps: options.logSteps, dataDriven: options.dataDriven });
|
|
18
20
|
if (options.output)
|
|
19
21
|
files[0] = { ...files[0], path: path.basename(options.output) };
|
|
20
|
-
|
|
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
|
+
};
|
|
21
29
|
});
|
|
22
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
|
+
}
|
|
23
38
|
// Java: package from the path below src/test/java (or src/main/java). Go: the directory name when it
|
|
24
39
|
// is a valid package name (the generator falls back to "e2e").
|
|
25
40
|
function defaultPackage(generator, directory) {
|
|
@@ -33,15 +48,19 @@ function defaultPackage(generator, directory) {
|
|
|
33
48
|
return undefined;
|
|
34
49
|
}
|
|
35
50
|
export function writeTargets(outputs) {
|
|
36
|
-
const
|
|
51
|
+
const result = { written: [], kept: [] };
|
|
37
52
|
for (const { directory, files } of outputs) {
|
|
38
|
-
fs.mkdirSync(directory, { recursive: true });
|
|
39
53
|
for (const file of files) {
|
|
40
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 });
|
|
41
60
|
fs.writeFileSync(filePath, file.content);
|
|
42
|
-
written.push(filePath);
|
|
61
|
+
result.written.push(filePath);
|
|
43
62
|
}
|
|
44
63
|
}
|
|
45
|
-
return
|
|
64
|
+
return result;
|
|
46
65
|
}
|
|
47
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
|
}
|