@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/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 of every target live
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 used by getByTestId (default: data-testid)
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', default: 'data-testid' },
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: values['test-id-attribute'],
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(16)}${generator.framework} (${generator.language})`);
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 [{ files }] = generateTargets(recording, output);
90
- process.stdout.write(files[0].content);
91
- for (const support of files.slice(1))
92
- console.error(`note: the test also needs ${support.path}; use --out-dir or --output to write it.`);
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
- report(writeTargets(generateTargets(recording, { ...output, outDir: values['out-dir'] })));
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
- fs.rmSync(save, { force: true });
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: values['test-id-attribute'],
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], save, codegen);
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
- if (!fs.existsSync(save))
126
- throw new Error(`Nothing was recorded (the recorder exited with code ${code}).`);
127
- console.log(`Recording saved to ${path.relative(process.cwd(), save)}`);
128
- report(writeTargets(generateTargets(parseRecording(fs.readFileSync(save, 'utf8'), save), { ...output, outDir })));
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(files) {
146
- for (const file of files)
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';
@@ -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.testIdAttribute });
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
- return { generator, directory, files };
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 written = [];
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 written;
64
+ return result;
46
65
  }
47
66
  //# sourceMappingURL=generate.js.map
@@ -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
- for (const actionInContext of recording.actions) {
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
- continue;
46
+ return;
20
47
  }
21
48
  // The test runner closes pages itself.
22
49
  if (action.name === 'closePage')
23
- continue;
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
- w.line(actionCall(pageAlias, actionInContext, options));
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}.${asLocator('javascript', signals.expect.selector)}).toBeVisible();`);
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}.${asLocator('javascript', action.selector)}` : 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(${singleQuote(action.text)});`;
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(${formatObject(action.options.length === 1 ? action.options[0] : action.options)});`;
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'}(${singleQuote(action.text)});`;
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(${singleQuote(action.value)})` : 'toBeEmpty()'};`;
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
  }