@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
|
@@ -2,6 +2,8 @@ import { devices } from 'playwright-core';
|
|
|
2
2
|
import { isRegex, parseLocator } from '../locator.js';
|
|
3
3
|
import { pascalCase, snakeCase } from '../naming.js';
|
|
4
4
|
import { PageAliases, isBlankUrl, shortcutOf, signalsOf, toKeyboardModifiers } from '../recording.js';
|
|
5
|
+
import { readRuntimeFile } from './runtime.js';
|
|
6
|
+
import { collectTestValues, isEmpty, joinPieces, toCsv } from './test-data.js';
|
|
5
7
|
import { CodeWriter, doubleQuote } from './writer.js';
|
|
6
8
|
const playwrightGoModule = 'github.com/mxschmitt/playwright-go';
|
|
7
9
|
// Roles with a playwright.AriaRole<Name> constant in playwright-go.
|
|
@@ -12,15 +14,29 @@ const ariaRoles = new Set(('alert alertdialog application article banner blockqu
|
|
|
12
14
|
'radiogroup region row rowgroup rowheader scrollbar search searchbox separator slider spinbutton status strong ' +
|
|
13
15
|
'subscript superscript switch tab table tablist tabpanel term textbox time timer toolbar tooltip tree treegrid treeitem').split(' '));
|
|
14
16
|
const capitalize = (text) => text.charAt(0).toUpperCase() + text.slice(1);
|
|
17
|
+
// Names a secret's variable cannot take: Go keywords, imported packages and the test's own variables.
|
|
18
|
+
const goNames = new Set(('break case chan const continue default defer else fallthrough for func go goto if import interface ' +
|
|
19
|
+
'map package range return select struct switch type var fmt os playwright regexp require testing ' +
|
|
20
|
+
'pw err browser context page expect device dialog t i row ok').split(' '));
|
|
21
|
+
const reservedName = (name) => goNames.has(name) || /^page\d+$/.test(name);
|
|
15
22
|
export const playwrightGo = {
|
|
16
23
|
id: 'playwright-go',
|
|
17
24
|
framework: 'Playwright for Go (playwright-go)',
|
|
18
25
|
language: 'Go',
|
|
19
26
|
highlight: 'go',
|
|
27
|
+
features: ['dataDriven'],
|
|
20
28
|
testFileName: name => `${snakeCase(name)}_test.go`,
|
|
21
29
|
generate(recording, options) {
|
|
22
30
|
const imports = new Set(['os', 'testing']);
|
|
23
31
|
const locators = new GoLocators(() => imports.add('regexp'));
|
|
32
|
+
const values = collectTestValues(recording, { dataDriven: options.dataDriven, reserved: reservedName });
|
|
33
|
+
const { columns } = values;
|
|
34
|
+
const dataFile = `testdata/${snakeCase(options.name)}.csv`;
|
|
35
|
+
const goPieces = {
|
|
36
|
+
text: doubleQuote,
|
|
37
|
+
column: (column) => `row[${doubleQuote(column.name)}]`,
|
|
38
|
+
secret: (secret) => values.variable(secret),
|
|
39
|
+
};
|
|
24
40
|
const body = new CodeWriter('\t');
|
|
25
41
|
const aliases = new PageAliases();
|
|
26
42
|
const declared = new Set();
|
|
@@ -60,7 +76,12 @@ export const playwrightGo = {
|
|
|
60
76
|
body.line('_ = dialog.Dismiss()');
|
|
61
77
|
}, '})');
|
|
62
78
|
}
|
|
63
|
-
const
|
|
79
|
+
const literals = {
|
|
80
|
+
value: text => joinPieces(values.value(index, text), goPieces),
|
|
81
|
+
isEmpty: text => isEmpty(values.value(index, text)),
|
|
82
|
+
locator: text => joinPieces(values.locator(index, text), goPieces),
|
|
83
|
+
};
|
|
84
|
+
const call = actionCall(pageAlias, action, locators, literals);
|
|
64
85
|
if (action.name.startsWith('assert'))
|
|
65
86
|
usesExpect = true;
|
|
66
87
|
if (call.todo)
|
|
@@ -81,15 +102,18 @@ export const playwrightGo = {
|
|
|
81
102
|
}
|
|
82
103
|
if (recording.header.generateExpectSignal && signals.expect) {
|
|
83
104
|
usesExpect = true;
|
|
84
|
-
emit(body, { expr: `expect.Locator(${locators.render(pageAlias, parseLocator(signals.expect.selector))}).ToBeVisible()`, returnsValue: false });
|
|
105
|
+
emit(body, { expr: `expect.Locator(${locators.render(pageAlias, parseLocator(signals.expect.selector), literals.locator)}).ToBeVisible()`, returnsValue: false });
|
|
85
106
|
}
|
|
86
107
|
});
|
|
87
108
|
if (!declared.size && !recording.header.saveStorage)
|
|
88
109
|
body.line('_ = context');
|
|
89
110
|
if (recording.header.saveStorage)
|
|
90
111
|
emit(body, { expr: `context.StorageState(playwright.BrowserContextStorageStateOptions{Path: playwright.String(${doubleQuote(recording.header.saveStorage)})})`, returnsValue: true });
|
|
112
|
+
if (columns.length)
|
|
113
|
+
imports.add('fmt');
|
|
114
|
+
const packageName = goPackageName(options.packageName);
|
|
91
115
|
const w = new CodeWriter('\t');
|
|
92
|
-
w.line(`package ${
|
|
116
|
+
w.line(`package ${packageName}`);
|
|
93
117
|
w.line();
|
|
94
118
|
w.line('import (');
|
|
95
119
|
w.indent(() => {
|
|
@@ -103,6 +127,14 @@ export const playwrightGo = {
|
|
|
103
127
|
w.line();
|
|
104
128
|
w.line('// Generated by testgen from a Playwright recording. Set HEADED=1 to watch the browser.');
|
|
105
129
|
w.block(`func Test${pascalCase(options.name)}(t *testing.T) {`, () => {
|
|
130
|
+
if (values.secrets.length) {
|
|
131
|
+
w.line('// Values typed into password fields are not kept in the test: set them as environment variables.');
|
|
132
|
+
for (const secret of values.secrets) {
|
|
133
|
+
w.line(`${values.variable(secret)}, ok := os.LookupEnv(${doubleQuote(secret.name)})`);
|
|
134
|
+
w.line(`require.True(t, ok, ${doubleQuote(`set the environment variable ${secret.name}, the value to type into a password field`)})`);
|
|
135
|
+
}
|
|
136
|
+
w.line();
|
|
137
|
+
}
|
|
106
138
|
w.line('pw, err := playwright.Run()');
|
|
107
139
|
w.line('require.NoError(t, err)');
|
|
108
140
|
w.line('t.Cleanup(func() { _ = pw.Stop() })');
|
|
@@ -118,16 +150,42 @@ export const playwrightGo = {
|
|
|
118
150
|
const context = contextOptions(recording.header, options.testIdAttribute);
|
|
119
151
|
for (const line of context.before)
|
|
120
152
|
w.line(line);
|
|
121
|
-
|
|
122
|
-
|
|
153
|
+
const newContext = `context, err := browser.NewContext(${context.fields.length ? structLiteral('playwright.BrowserNewContextOptions', context.fields) : ''})`;
|
|
154
|
+
const setTestIdAttribute = `pw.Selectors.SetTestIdAttribute(${doubleQuote(options.testIdAttribute)})`;
|
|
155
|
+
if (!columns.length) {
|
|
156
|
+
w.line(newContext);
|
|
157
|
+
w.line('require.NoError(t, err)');
|
|
158
|
+
if (options.testIdAttribute !== 'data-testid')
|
|
159
|
+
w.line(setTestIdAttribute);
|
|
160
|
+
if (usesExpect)
|
|
161
|
+
w.line('expect := playwright.NewPlaywrightAssertions()');
|
|
162
|
+
w.line();
|
|
163
|
+
if (options.dataDriven)
|
|
164
|
+
w.line('// Data-driven: nothing in the recording is typed, selected or asserted with a value, so there is no data file.');
|
|
165
|
+
w.line(body.toString().trimEnd());
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
123
168
|
if (options.testIdAttribute !== 'data-testid')
|
|
124
|
-
w.line(
|
|
169
|
+
w.line(setTestIdAttribute);
|
|
125
170
|
if (usesExpect)
|
|
126
171
|
w.line('expect := playwright.NewPlaywrightAssertions()');
|
|
127
172
|
w.line();
|
|
128
|
-
w.line(
|
|
173
|
+
w.line(`// Data-driven: a subtest runs for each row of ${dataFile}. Its first row holds the recorded values.`);
|
|
174
|
+
w.block(`for i, row := range readTestgenCSV(t, ${[dataFile, ...columns.map(c => c.name)].map(doubleQuote).join(', ')}) {`, () => {
|
|
175
|
+
w.block('t.Run(fmt.Sprintf("row %d", i+1), func(t *testing.T) {', () => {
|
|
176
|
+
w.line(newContext);
|
|
177
|
+
w.line('require.NoError(t, err)');
|
|
178
|
+
w.line('t.Cleanup(func() { _ = context.Close() })');
|
|
179
|
+
w.line();
|
|
180
|
+
w.line(body.toString().trimEnd());
|
|
181
|
+
}, '})');
|
|
182
|
+
});
|
|
129
183
|
});
|
|
130
|
-
|
|
184
|
+
const files = [{ path: this.testFileName(options.name), content: w.toString() }];
|
|
185
|
+
if (columns.length) {
|
|
186
|
+
files.push({ path: 'testgen_data_test.go', content: readRuntimeFile('playwright-go/testgen_data_test.go').replace(/^package \w+$/m, `package ${packageName}`), support: true }, { path: dataFile, content: toCsv([columns.map(c => c.name), columns.map(c => c.value)]), data: true });
|
|
187
|
+
}
|
|
188
|
+
return files;
|
|
131
189
|
},
|
|
132
190
|
};
|
|
133
191
|
function goPackageName(name) {
|
|
@@ -230,8 +288,8 @@ function contextOptions(header, testIdAttribute) {
|
|
|
230
288
|
before.unshift(`// TODO(testgen): context options not converted: ${unsupported.join(', ')}.`);
|
|
231
289
|
return { before, fields };
|
|
232
290
|
}
|
|
233
|
-
function actionCall(subject, action, locators) {
|
|
234
|
-
const locator = 'selector' in action ? locators.render(subject, parseLocator(action.selector)) : subject;
|
|
291
|
+
function actionCall(subject, action, locators, literals) {
|
|
292
|
+
const locator = 'selector' in action ? locators.render(subject, parseLocator(action.selector), literals.locator) : subject;
|
|
235
293
|
const expect = (assertion) => ({ expr: `expect.Locator(${locator}).${assertion}`, returnsValue: false });
|
|
236
294
|
switch (action.name) {
|
|
237
295
|
case 'click': {
|
|
@@ -258,7 +316,7 @@ function actionCall(subject, action, locators) {
|
|
|
258
316
|
case 'uncheck':
|
|
259
317
|
return { expr: `${locator}.Uncheck()`, returnsValue: false };
|
|
260
318
|
case 'fill':
|
|
261
|
-
return { expr: `${locator}.Fill(${
|
|
319
|
+
return { expr: `${locator}.Fill(${literals.value(action.text)})`, returnsValue: false };
|
|
262
320
|
case 'setInputFiles': {
|
|
263
321
|
const files = action.files.length === 1 ? doubleQuote(action.files[0]) : `[]string{${action.files.map(doubleQuote).join(', ')}}`;
|
|
264
322
|
return { expr: `${locator}.SetInputFiles(${files})`, returnsValue: false };
|
|
@@ -268,15 +326,15 @@ function actionCall(subject, action, locators) {
|
|
|
268
326
|
case 'navigate':
|
|
269
327
|
return { expr: `${subject}.Goto(${doubleQuote(action.url)})`, returnsValue: true };
|
|
270
328
|
case 'select':
|
|
271
|
-
return { expr: `${locator}.SelectOption(playwright.SelectOptionValues{ValuesOrLabels: playwright.StringSlice(${action.options.map(doubleQuote).join(', ')})})`, returnsValue: true };
|
|
329
|
+
return { expr: `${locator}.SelectOption(playwright.SelectOptionValues{ValuesOrLabels: playwright.StringSlice(${action.options.length === 1 ? literals.value(action.options[0]) : action.options.map(doubleQuote).join(', ')})})`, returnsValue: true };
|
|
272
330
|
case 'assertText':
|
|
273
|
-
return expect(`${action.substring ? 'ToContainText' : 'ToHaveText'}(${
|
|
331
|
+
return expect(`${action.substring ? 'ToContainText' : 'ToHaveText'}(${literals.value(action.text)})`);
|
|
274
332
|
case 'assertChecked':
|
|
275
333
|
return expect(action.checked ? 'ToBeChecked()' : 'Not().ToBeChecked()');
|
|
276
334
|
case 'assertVisible':
|
|
277
335
|
return expect('ToBeVisible()');
|
|
278
336
|
case 'assertValue':
|
|
279
|
-
return expect(action.value ? `ToHaveValue(${
|
|
337
|
+
return expect(literals.isEmpty(action.value) ? 'ToBeEmpty()' : `ToHaveValue(${literals.value(action.value)})`);
|
|
280
338
|
case 'assertSnapshot':
|
|
281
339
|
if (action.ariaSnapshot)
|
|
282
340
|
return expect(`ToMatchAriaSnapshot(${doubleQuote(action.ariaSnapshot)})`);
|
|
@@ -289,15 +347,21 @@ function actionCall(subject, action, locators) {
|
|
|
289
347
|
// Renders Playwright locator steps as playwright-go calls.
|
|
290
348
|
class GoLocators {
|
|
291
349
|
_onRegexp;
|
|
350
|
+
_literal = doubleQuote;
|
|
292
351
|
constructor(_onRegexp) {
|
|
293
352
|
this._onRegexp = _onRegexp;
|
|
294
353
|
}
|
|
295
|
-
|
|
354
|
+
// `literal` writes the text arguments of GetBy* and Filter calls.
|
|
355
|
+
render(subject, steps, literal = doubleQuote) {
|
|
356
|
+
this._literal = literal;
|
|
357
|
+
return this._render(subject, steps);
|
|
358
|
+
}
|
|
359
|
+
_render(subject, steps) {
|
|
296
360
|
return subject + steps.map(step => '.' + this._step(subject, step)).join('');
|
|
297
361
|
}
|
|
298
362
|
_value(matcher) {
|
|
299
363
|
if (!isRegex(matcher))
|
|
300
|
-
return
|
|
364
|
+
return this._literal(matcher);
|
|
301
365
|
this._onRegexp();
|
|
302
366
|
const flags = matcher.flags.replace(/[^ims]/g, '');
|
|
303
367
|
const source = (flags ? `(?${flags})` : '') + matcher.source;
|
|
@@ -335,15 +399,15 @@ class GoLocators {
|
|
|
335
399
|
case 'has-not-text':
|
|
336
400
|
return filter('HasNotText', this._value(step.body));
|
|
337
401
|
case 'has':
|
|
338
|
-
return filter('Has', this.
|
|
402
|
+
return filter('Has', this._render(subject, step.inner));
|
|
339
403
|
case 'hasNot':
|
|
340
|
-
return filter('HasNot', this.
|
|
404
|
+
return filter('HasNot', this._render(subject, step.inner));
|
|
341
405
|
case 'and':
|
|
342
|
-
return `And(${this.
|
|
406
|
+
return `And(${this._render(subject, step.inner)})`;
|
|
343
407
|
case 'or':
|
|
344
|
-
return `Or(${this.
|
|
408
|
+
return `Or(${this._render(subject, step.inner)})`;
|
|
345
409
|
case 'chain':
|
|
346
|
-
return `Locator(${this.
|
|
410
|
+
return `Locator(${this._render(subject, step.inner)})`;
|
|
347
411
|
case 'test-id':
|
|
348
412
|
return `GetByTestId(${this._value(step.body)})`;
|
|
349
413
|
case 'role':
|
|
@@ -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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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.
|