@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.
@@ -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
- 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
+ };
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 written = [];
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 written;
64
+ return result;
48
65
  }
49
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
  }
@@ -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 call = actionCall(pageAlias, action, locators);
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 ${goPackageName(options.packageName)}`);
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
- w.line(`context, err := browser.NewContext(${context.fields.length ? structLiteral('playwright.BrowserNewContextOptions', context.fields) : ''})`);
122
- w.line('require.NoError(t, err)');
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(`pw.Selectors.SetTestIdAttribute(${doubleQuote(options.testIdAttribute)})`);
169
+ w.line(setTestIdAttribute);
125
170
  if (usesExpect)
126
171
  w.line('expect := playwright.NewPlaywrightAssertions()');
127
172
  w.line();
128
- w.line(body.toString().trimEnd());
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
- return [{ path: this.testFileName(options.name), content: w.toString() }];
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(${doubleQuote(action.text)})`, returnsValue: false };
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'}(${doubleQuote(action.text)})`);
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(${doubleQuote(action.value)})` : 'ToBeEmpty()');
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
- render(subject, steps) {
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 doubleQuote(matcher);
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.render(subject, step.inner));
402
+ return filter('Has', this._render(subject, step.inner));
339
403
  case 'hasNot':
340
- return filter('HasNot', this.render(subject, step.inner));
404
+ return filter('HasNot', this._render(subject, step.inner));
341
405
  case 'and':
342
- return `And(${this.render(subject, step.inner)})`;
406
+ return `And(${this._render(subject, step.inner)})`;
343
407
  case 'or':
344
- return `Or(${this.render(subject, step.inner)})`;
408
+ return `Or(${this._render(subject, step.inner)})`;
345
409
  case 'chain':
346
- return `Locator(${this.render(subject, step.inner)})`;
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':