@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.
@@ -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':
@@ -1,31 +1,68 @@
1
1
  import { devices } from 'playwright-core';
2
2
  import { formatObject } from '../vendor/playwright/stringUtils.js';
3
3
  import { kebabCase, words } from '../naming.js';
4
- import { writeJavaScriptBody } from './javascript-body.js';
4
+ import { writeJavaScriptBody, writeReadCsv } from './javascript-body.js';
5
+ import { readRuntimeFile } from './runtime.js';
6
+ import { collectTestValues, toCsv } from './test-data.js';
5
7
  import { CodeWriter, singleQuote } from './writer.js';
6
8
  export const playwrightTs = {
7
9
  id: 'playwright-ts',
8
10
  framework: 'Playwright Test',
9
11
  language: 'TypeScript',
10
12
  highlight: 'typescript',
13
+ features: ['dataDriven'],
11
14
  testFileName: name => `${kebabCase(name)}.spec.ts`,
12
15
  generate(recording, options) {
13
16
  const { header } = recording;
17
+ // Data columns are read as row.<name>, so they cannot clash with the test's own names.
18
+ const values = collectTestValues(recording, { dataDriven: options.dataDriven, reserved: () => false });
19
+ const { columns } = values;
20
+ const dataFile = `${kebabCase(options.name)}.csv`;
21
+ const title = words(options.name).join(' ');
14
22
  const w = new CodeWriter(' ');
23
+ const body = () => writeJavaScriptBody(w, recording, {
24
+ supportsDownloads: true,
25
+ todo: message => `// TODO(testgen): ${message}`,
26
+ values,
27
+ });
15
28
  w.line(`import { test, expect${header.deviceName ? ', devices' : ''} } from '@playwright/test';`);
29
+ if (columns.length) {
30
+ w.line(`import path from 'node:path';`);
31
+ w.line(`import { readCsv } from './testgen-data';`);
32
+ }
16
33
  const use = useOptions(header, options.testIdAttribute);
17
34
  if (use) {
18
35
  w.line();
19
36
  w.line(`test.use(${use});`);
20
37
  }
21
38
  w.line();
22
- w.block(`test(${singleQuote(words(options.name).join(' '))}, async ({ page }) => {`, () => {
23
- writeJavaScriptBody(w, recording, {
24
- supportsDownloads: true,
25
- todo: message => `// TODO(testgen): ${message}`,
39
+ if (columns.length) {
40
+ writeReadCsv(w, dataFile, columns);
41
+ w.line();
42
+ w.block('for (const [index, row] of rows.entries()) {', () => {
43
+ w.block(`test(\`${title} (row \${index + 1})\`, async ({ page }) => {`, body, '});');
44
+ });
45
+ }
46
+ else {
47
+ if (options.dataDriven)
48
+ w.line('// Data-driven: nothing in the recording is typed, selected or asserted with a value, so there is no data file.');
49
+ w.block(`test(${singleQuote(title)}, async ({ page }) => {`, body, '});');
50
+ }
51
+ if (values.secrets.length) {
52
+ w.line();
53
+ w.line('// Values typed into password fields are not kept in the test: set them as environment variables.');
54
+ w.block('function secret(name: string): string {', () => {
55
+ w.line('const value = process.env[name];');
56
+ w.line('if (value === undefined)');
57
+ w.line(' throw new Error(`testgen: set the environment variable ${name}, the value to type into a password field`);');
58
+ w.line('return value;');
26
59
  });
27
- }, '});');
28
- return [{ path: this.testFileName(options.name), content: w.toString() }];
60
+ }
61
+ const files = [{ path: this.testFileName(options.name), content: w.toString() }];
62
+ if (columns.length) {
63
+ files.push({ path: 'testgen-data.ts', content: readRuntimeFile('playwright-ts/testgen-data.ts'), support: true }, { path: dataFile, content: toCsv([columns.map(c => c.name), columns.map(c => c.value)]), data: true });
64
+ }
65
+ return files;
29
66
  },
30
67
  };
31
68
  // Context options for test.use(), spreading the device descriptor like Playwright's codegen.