@xylentis/testgen 0.0.0-stage → 1.1.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 +11 -0
- package/LICENSE +661 -0
- package/NOTICE +29 -0
- package/README.md +195 -2
- package/dist/cli.js +158 -0
- package/dist/generate.js +47 -0
- package/dist/generators/index.js +12 -0
- package/dist/generators/javascript-body.js +102 -0
- package/dist/generators/playwright-go.js +388 -0
- package/dist/generators/playwright-ts.js +50 -0
- package/dist/generators/runtime.js +16 -0
- package/dist/generators/selenium-common.js +38 -0
- package/dist/generators/selenium-java.js +257 -0
- package/dist/generators/selenium-js.js +57 -0
- package/dist/generators/types.js +2 -0
- package/dist/generators/writer.js +38 -0
- package/dist/locator.js +49 -0
- package/dist/naming.js +31 -0
- package/dist/record.js +23 -0
- package/dist/recorder-process.js +102 -0
- package/dist/recording.js +82 -0
- package/dist/ui/server.js +255 -0
- package/dist/vendor/playwright/LICENSE +202 -0
- package/dist/vendor/playwright/cssParser.js +241 -0
- package/dist/vendor/playwright/cssTokenizer.js +950 -0
- package/dist/vendor/playwright/locatorGenerators.js +727 -0
- package/dist/vendor/playwright/selectorParser.js +427 -0
- package/dist/vendor/playwright/stringUtils.js +195 -0
- package/docs/testgen-ui.png +0 -0
- package/package.json +50 -4
- package/runtime/selenium/engine.js +426 -0
- package/runtime/selenium-java/TestgenSupport.java +941 -0
- package/runtime/selenium-js/testgen-support.js +618 -0
- package/ui/app.js +259 -0
- package/ui/index.html +65 -0
- package/ui/style.css +189 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { isRegex, parseLocator } from '../locator.js';
|
|
2
|
+
import { camelCase, pascalCase } from '../naming.js';
|
|
3
|
+
import { PageAliases, isBlankUrl, shortcutOf, signalsOf, toKeyboardModifiers } from '../recording.js';
|
|
4
|
+
import { readRuntime } from './runtime.js';
|
|
5
|
+
import { seleniumBrowser, seleniumDriverOptions } from './selenium-common.js';
|
|
6
|
+
import { CodeWriter, doubleQuote } from './writer.js';
|
|
7
|
+
const javaKeywords = new Set(('abstract assert boolean break byte case catch char class const continue default do double else enum ' +
|
|
8
|
+
'extends final finally float for goto if implements import instanceof int interface long native new package private protected ' +
|
|
9
|
+
'public return short static strictfp super switch synchronized this throw throws transient try void volatile while var record yield').split(' '));
|
|
10
|
+
export const seleniumJava = {
|
|
11
|
+
id: 'selenium-java',
|
|
12
|
+
framework: 'Selenium WebDriver + JUnit 5',
|
|
13
|
+
language: 'Java',
|
|
14
|
+
highlight: 'java',
|
|
15
|
+
testFileName: name => `${pascalCase(name)}Test.java`,
|
|
16
|
+
generate(recording, options) {
|
|
17
|
+
const { header } = recording;
|
|
18
|
+
const locators = new JavaLocators();
|
|
19
|
+
const body = new CodeWriter(' ');
|
|
20
|
+
const aliases = new PageAliases();
|
|
21
|
+
const todo = (message) => body.line(`// TODO(testgen): ${message}`);
|
|
22
|
+
recording.actions.forEach(actionInContext => {
|
|
23
|
+
const { action } = actionInContext;
|
|
24
|
+
const pageAlias = aliases.alias(actionInContext.pageGuid);
|
|
25
|
+
if (action.name === 'openPage') {
|
|
26
|
+
if (pageAlias !== 'page')
|
|
27
|
+
body.line(`var ${pageAlias} = page.newPage();`);
|
|
28
|
+
if (!isBlankUrl(action.url))
|
|
29
|
+
body.line(`${pageAlias}.navigate(${doubleQuote(action.url)});`);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (action.name === 'closePage') {
|
|
33
|
+
// The driver quits after the test; only pages opened later are closed explicitly.
|
|
34
|
+
if (pageAlias !== 'page')
|
|
35
|
+
body.line(`${pageAlias}.close();`);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const signals = signalsOf(actionInContext);
|
|
39
|
+
if (signals.dialog) {
|
|
40
|
+
body.block(`${pageAlias}.onceDialog(dialog -> {`, () => {
|
|
41
|
+
body.line('System.out.println("Dialog message: " + dialog.message());');
|
|
42
|
+
body.line('dialog.dismiss();');
|
|
43
|
+
}, '});');
|
|
44
|
+
}
|
|
45
|
+
if (signals.download)
|
|
46
|
+
todo('the next step starts a download, which Selenium cannot wait for.');
|
|
47
|
+
if (action.name === 'assertSnapshot')
|
|
48
|
+
todo('aria snapshot assertions are not available on Selenium; checking visibility instead.');
|
|
49
|
+
const call = actionCall(pageAlias, action, locators);
|
|
50
|
+
if (signals.popup)
|
|
51
|
+
body.line(`var ${aliases.alias(signals.popup.popupPageGuid)} = ${pageAlias}.waitForPopup(() -> ${call});`);
|
|
52
|
+
else
|
|
53
|
+
body.line(`${call};`);
|
|
54
|
+
if (header.generateExpectSignal && signals.expect)
|
|
55
|
+
body.line(`${locators.render(pageAlias, parseLocator(signals.expect.selector))}.shouldBeVisible();`);
|
|
56
|
+
});
|
|
57
|
+
const driverOptions = seleniumDriverOptions(header);
|
|
58
|
+
const className = `${pascalCase(options.name)}Test`;
|
|
59
|
+
const methodName = javaKeywords.has(camelCase(options.name)) ? `test${pascalCase(options.name)}` : camelCase(options.name);
|
|
60
|
+
const packageName = options.packageName;
|
|
61
|
+
const w = new CodeWriter(' ');
|
|
62
|
+
if (packageName) {
|
|
63
|
+
w.line(`package ${packageName};`);
|
|
64
|
+
w.line();
|
|
65
|
+
}
|
|
66
|
+
if (locators.usesMap)
|
|
67
|
+
w.line('import java.util.Map;');
|
|
68
|
+
if (locators.usesPattern)
|
|
69
|
+
w.line('import java.util.regex.Pattern;');
|
|
70
|
+
w.line('import org.junit.jupiter.api.AfterEach;');
|
|
71
|
+
w.line('import org.junit.jupiter.api.BeforeEach;');
|
|
72
|
+
w.line('import org.junit.jupiter.api.Test;');
|
|
73
|
+
w.line('import org.openqa.selenium.WebDriver;');
|
|
74
|
+
w.line();
|
|
75
|
+
w.line('// Generated by testgen from a Playwright recording; runs on Selenium WebDriver.');
|
|
76
|
+
w.line('// TestgenSupport.java (same package) provides Playwright-style locators with auto-waiting.');
|
|
77
|
+
w.block(`class ${className} {`, () => {
|
|
78
|
+
w.line('private WebDriver driver;');
|
|
79
|
+
w.line('private TestgenSupport.Page page;');
|
|
80
|
+
w.line();
|
|
81
|
+
w.line('@BeforeEach');
|
|
82
|
+
w.block('void setUp() {', () => {
|
|
83
|
+
if (driverOptions.unsupported.length)
|
|
84
|
+
w.line(`// TODO(testgen): not applied on Selenium: ${driverOptions.unsupported.join(', ')}.`);
|
|
85
|
+
const args = [doubleQuote(seleniumBrowser(header)), ...driverOptions.args.map(doubleQuote)];
|
|
86
|
+
w.line(`driver = TestgenSupport.createDriver(${args.join(', ')});`);
|
|
87
|
+
if (driverOptions.viewport)
|
|
88
|
+
w.line(`TestgenSupport.setViewportSize(driver, ${driverOptions.viewport.width}, ${driverOptions.viewport.height});`);
|
|
89
|
+
w.line('page = new TestgenSupport.Page(driver);');
|
|
90
|
+
if (options.testIdAttribute !== 'data-testid')
|
|
91
|
+
w.line(`page.setTestIdAttribute(${doubleQuote(options.testIdAttribute)});`);
|
|
92
|
+
});
|
|
93
|
+
w.line();
|
|
94
|
+
w.line('@AfterEach');
|
|
95
|
+
w.block('void tearDown() {', () => {
|
|
96
|
+
w.line('if (driver != null)');
|
|
97
|
+
w.line(' driver.quit();');
|
|
98
|
+
});
|
|
99
|
+
w.line();
|
|
100
|
+
w.line('@Test');
|
|
101
|
+
w.block(`void ${methodName}() {`, () => w.line(body.toString().trimEnd()));
|
|
102
|
+
});
|
|
103
|
+
const support = readRuntime('selenium-java/TestgenSupport.java', engine => engine.replace(/\\/g, '\\\\'))
|
|
104
|
+
.replace('/* @testgen-package */', packageName ? `package ${packageName};` : '');
|
|
105
|
+
return [
|
|
106
|
+
{ path: `${className}.java`, content: w.toString() },
|
|
107
|
+
{ path: 'TestgenSupport.java', content: support.replace(/^\n+/, ''), support: true },
|
|
108
|
+
];
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
function actionCall(subject, action, locators) {
|
|
112
|
+
const locator = 'selector' in action ? locators.render(subject, parseLocator(action.selector)) : subject;
|
|
113
|
+
switch (action.name) {
|
|
114
|
+
case 'click': {
|
|
115
|
+
if (action.clickCount === 2 && action.button === 'left' && !action.modifiers && !action.position)
|
|
116
|
+
return `${locator}.dblclick()`;
|
|
117
|
+
const options = [];
|
|
118
|
+
if (action.button !== 'left')
|
|
119
|
+
options.push(`.setButton(${doubleQuote(action.button)})`);
|
|
120
|
+
const modifiers = toKeyboardModifiers(action.modifiers);
|
|
121
|
+
if (modifiers.length)
|
|
122
|
+
options.push(`.setModifiers(${modifiers.map(doubleQuote).join(', ')})`);
|
|
123
|
+
if (action.clickCount > 1)
|
|
124
|
+
options.push(`.setClickCount(${action.clickCount})`);
|
|
125
|
+
if (action.position)
|
|
126
|
+
options.push(`.setPosition(${action.position.x}, ${action.position.y})`);
|
|
127
|
+
return options.length ? `${locator}.click(new TestgenSupport.ClickOptions()${options.join('')})` : `${locator}.click()`;
|
|
128
|
+
}
|
|
129
|
+
case 'hover':
|
|
130
|
+
return action.position
|
|
131
|
+
? `${locator}.hover(new TestgenSupport.ClickOptions().setPosition(${action.position.x}, ${action.position.y}))`
|
|
132
|
+
: `${locator}.hover()`;
|
|
133
|
+
case 'check':
|
|
134
|
+
return `${locator}.check()`;
|
|
135
|
+
case 'uncheck':
|
|
136
|
+
return `${locator}.uncheck()`;
|
|
137
|
+
case 'fill':
|
|
138
|
+
return `${locator}.fill(${doubleQuote(action.text)})`;
|
|
139
|
+
case 'setInputFiles':
|
|
140
|
+
return `${locator}.setInputFiles(${action.files.map(doubleQuote).join(', ')})`;
|
|
141
|
+
case 'press':
|
|
142
|
+
return `${locator}.press(${doubleQuote(shortcutOf(action))})`;
|
|
143
|
+
case 'navigate':
|
|
144
|
+
return `${subject}.navigate(${doubleQuote(action.url)})`;
|
|
145
|
+
case 'select':
|
|
146
|
+
return `${locator}.selectOption(${action.options.map(doubleQuote).join(', ')})`;
|
|
147
|
+
case 'assertText':
|
|
148
|
+
return `${locator}.${action.substring ? 'shouldContainText' : 'shouldHaveText'}(${doubleQuote(action.text)})`;
|
|
149
|
+
case 'assertChecked':
|
|
150
|
+
return `${locator}.${action.checked ? 'shouldBeChecked' : 'shouldNotBeChecked'}()`;
|
|
151
|
+
case 'assertVisible':
|
|
152
|
+
case 'assertSnapshot':
|
|
153
|
+
return `${locator}.shouldBeVisible()`;
|
|
154
|
+
case 'assertValue':
|
|
155
|
+
return action.value ? `${locator}.shouldHaveValue(${doubleQuote(action.value)})` : `${locator}.shouldBeEmpty()`;
|
|
156
|
+
case 'openPage':
|
|
157
|
+
case 'closePage':
|
|
158
|
+
throw new Error(`${action.name} is handled by the caller`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// Renders Playwright locator steps as calls on TestgenSupport.Locator.
|
|
162
|
+
class JavaLocators {
|
|
163
|
+
usesMap = false;
|
|
164
|
+
usesPattern = false;
|
|
165
|
+
render(subject, steps) {
|
|
166
|
+
return subject + steps.map(step => '.' + this._step(subject, step)).join('');
|
|
167
|
+
}
|
|
168
|
+
_value(matcher) {
|
|
169
|
+
if (!isRegex(matcher))
|
|
170
|
+
return doubleQuote(matcher);
|
|
171
|
+
this.usesPattern = true;
|
|
172
|
+
const flags = [['i', 'CASE_INSENSITIVE'], ['m', 'MULTILINE'], ['s', 'DOTALL']]
|
|
173
|
+
.filter(([flag]) => matcher.flags.includes(flag))
|
|
174
|
+
.map(([, name]) => `Pattern.${name}`);
|
|
175
|
+
return `Pattern.compile(${doubleQuote(matcher.source)}${flags.length ? ', ' + flags.join(' | ') : ''})`;
|
|
176
|
+
}
|
|
177
|
+
_step(subject, step) {
|
|
178
|
+
const body = step.body;
|
|
179
|
+
switch (step.kind) {
|
|
180
|
+
case 'default':
|
|
181
|
+
if (/^visible=(true|false)$/.test(body))
|
|
182
|
+
return `filterVisible(${body.endsWith('true')})`;
|
|
183
|
+
if (step.hasText !== undefined)
|
|
184
|
+
return `locator(${doubleQuote(body)}).filterHasText(${this._value(step.hasText)})`;
|
|
185
|
+
if (step.hasNotText !== undefined)
|
|
186
|
+
return `locator(${doubleQuote(body)}).filterHasNotText(${this._value(step.hasNotText)})`;
|
|
187
|
+
return `locator(${doubleQuote(body)})`;
|
|
188
|
+
case 'frame-locator':
|
|
189
|
+
return `frameLocator(${doubleQuote(body)})`;
|
|
190
|
+
case 'frame':
|
|
191
|
+
return 'contentFrame()';
|
|
192
|
+
case 'any-frame':
|
|
193
|
+
return `locator(${doubleQuote('internal:control=any-frame')})`;
|
|
194
|
+
case 'nth':
|
|
195
|
+
return `nth(${body})`;
|
|
196
|
+
case 'first':
|
|
197
|
+
return 'first()';
|
|
198
|
+
case 'last':
|
|
199
|
+
return 'last()';
|
|
200
|
+
case 'visible':
|
|
201
|
+
return 'filterVisible(true)';
|
|
202
|
+
case 'filter-visible':
|
|
203
|
+
return `filterVisible(${body === 'true'})`;
|
|
204
|
+
case 'has-text':
|
|
205
|
+
return `filterHasText(${this._value(step.body)})`;
|
|
206
|
+
case 'has-not-text':
|
|
207
|
+
return `filterHasNotText(${this._value(step.body)})`;
|
|
208
|
+
case 'has':
|
|
209
|
+
return `filterHas(${this.render(subject, step.inner)})`;
|
|
210
|
+
case 'hasNot':
|
|
211
|
+
return `filterHasNot(${this.render(subject, step.inner)})`;
|
|
212
|
+
case 'and':
|
|
213
|
+
return `and(${this.render(subject, step.inner)})`;
|
|
214
|
+
case 'or':
|
|
215
|
+
return `or(${this.render(subject, step.inner)})`;
|
|
216
|
+
case 'chain':
|
|
217
|
+
return `locator(${this.render(subject, step.inner)})`;
|
|
218
|
+
case 'test-id':
|
|
219
|
+
return `getByTestId(${this._value(step.body)})`;
|
|
220
|
+
case 'role':
|
|
221
|
+
return this._role(step);
|
|
222
|
+
case 'text':
|
|
223
|
+
return this._byText('getByText', step);
|
|
224
|
+
case 'alt':
|
|
225
|
+
return this._byText('getByAltText', step);
|
|
226
|
+
case 'placeholder':
|
|
227
|
+
return this._byText('getByPlaceholder', step);
|
|
228
|
+
case 'label':
|
|
229
|
+
return this._byText('getByLabel', step);
|
|
230
|
+
case 'title':
|
|
231
|
+
return this._byText('getByTitle', step);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
_byText(method, step) {
|
|
235
|
+
if (step.exact && !isRegex(step.body))
|
|
236
|
+
return `${method}(${this._value(step.body)}, true)`;
|
|
237
|
+
return `${method}(${this._value(step.body)})`;
|
|
238
|
+
}
|
|
239
|
+
_role(step) {
|
|
240
|
+
const role = doubleQuote(step.body);
|
|
241
|
+
const simpleName = typeof step.name === 'string' && !step.exact && !step.attrs?.length && step.description === undefined;
|
|
242
|
+
if (step.name === undefined && !step.attrs?.length)
|
|
243
|
+
return `getByRole(${role})`;
|
|
244
|
+
if (simpleName)
|
|
245
|
+
return `getByRole(${role}, ${doubleQuote(step.name)})`;
|
|
246
|
+
const options = [];
|
|
247
|
+
if (step.name !== undefined)
|
|
248
|
+
options.push(`"name", ${this._value(step.name)}`);
|
|
249
|
+
if (step.exact && typeof step.name === 'string')
|
|
250
|
+
options.push(`"exact", true`);
|
|
251
|
+
for (const { name, value } of step.attrs ?? [])
|
|
252
|
+
options.push(`${doubleQuote(name)}, ${typeof value === 'string' ? doubleQuote(value) : value}`);
|
|
253
|
+
this.usesMap = true;
|
|
254
|
+
return `getByRole(${role}, Map.of(${options.join(', ')}))`;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
//# sourceMappingURL=selenium-java.js.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { kebabCase, words } from '../naming.js';
|
|
2
|
+
import { writeJavaScriptBody } from './javascript-body.js';
|
|
3
|
+
import { readRuntime } from './runtime.js';
|
|
4
|
+
import { seleniumBrowser, seleniumDriverOptions } from './selenium-common.js';
|
|
5
|
+
import { CodeWriter, singleQuote } from './writer.js';
|
|
6
|
+
export const seleniumJs = {
|
|
7
|
+
id: 'selenium-js',
|
|
8
|
+
framework: 'Selenium WebDriver + Mocha',
|
|
9
|
+
language: 'JavaScript',
|
|
10
|
+
highlight: 'javascript',
|
|
11
|
+
testFileName: name => `${kebabCase(name)}.spec.js`,
|
|
12
|
+
generate(recording, options) {
|
|
13
|
+
const { header } = recording;
|
|
14
|
+
const title = singleQuote(words(options.name).join(' '));
|
|
15
|
+
const driverOptions = seleniumDriverOptions(header);
|
|
16
|
+
const createOptions = [];
|
|
17
|
+
if (driverOptions.viewport)
|
|
18
|
+
createOptions.push(`viewport: { width: ${driverOptions.viewport.width}, height: ${driverOptions.viewport.height} }`);
|
|
19
|
+
if (driverOptions.args.length)
|
|
20
|
+
createOptions.push(`args: [${driverOptions.args.map(singleQuote).join(', ')}]`);
|
|
21
|
+
const createArgs = [singleQuote(seleniumBrowser(header)), ...(createOptions.length ? [`{ ${createOptions.join(', ')} }`] : [])];
|
|
22
|
+
const attachArgs = ['driver', ...(options.testIdAttribute !== 'data-testid' ? [`{ testIdAttribute: ${singleQuote(options.testIdAttribute)} }`] : [])];
|
|
23
|
+
const w = new CodeWriter(' ');
|
|
24
|
+
w.line('// Generated by testgen from a Playwright recording; runs on Selenium WebDriver.');
|
|
25
|
+
w.line('// testgen-support.js implements the Playwright locator API (getByRole, expect, ...) on top of WebDriver.');
|
|
26
|
+
w.line(`const { createDriver, Page, expect } = require('./testgen-support');`);
|
|
27
|
+
w.line();
|
|
28
|
+
w.block(`describe(${title}, function () {`, () => {
|
|
29
|
+
w.line('this.timeout(120_000);');
|
|
30
|
+
w.line('let driver;');
|
|
31
|
+
w.line('let page;');
|
|
32
|
+
w.line();
|
|
33
|
+
w.block('beforeEach(async function () {', () => {
|
|
34
|
+
if (driverOptions.unsupported.length)
|
|
35
|
+
w.line(`// TODO(testgen): not applied on Selenium: ${driverOptions.unsupported.join(', ')}.`);
|
|
36
|
+
w.line(`driver = await createDriver(${createArgs.join(', ')});`);
|
|
37
|
+
w.line(`page = await Page.attach(${attachArgs.join(', ')});`);
|
|
38
|
+
}, '});');
|
|
39
|
+
w.line();
|
|
40
|
+
w.block('afterEach(async function () {', () => {
|
|
41
|
+
w.line('await driver?.quit();');
|
|
42
|
+
}, '});');
|
|
43
|
+
w.line();
|
|
44
|
+
w.block(`it(${title}, async function () {`, () => {
|
|
45
|
+
writeJavaScriptBody(w, recording, {
|
|
46
|
+
supportsDownloads: false,
|
|
47
|
+
todo: message => `// TODO(testgen): ${message}`,
|
|
48
|
+
});
|
|
49
|
+
}, '});');
|
|
50
|
+
}, '});');
|
|
51
|
+
return [
|
|
52
|
+
{ path: this.testFileName(options.name), content: w.toString() },
|
|
53
|
+
{ path: 'testgen-support.js', content: readRuntime('selenium-js/testgen-support.js'), support: true },
|
|
54
|
+
];
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
//# sourceMappingURL=selenium-js.js.map
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { escapeWithQuotes } from '../vendor/playwright/stringUtils.js';
|
|
2
|
+
// Accumulates indented source lines.
|
|
3
|
+
export class CodeWriter {
|
|
4
|
+
_unit;
|
|
5
|
+
_lines = [];
|
|
6
|
+
_depth = 0;
|
|
7
|
+
constructor(_unit) {
|
|
8
|
+
this._unit = _unit;
|
|
9
|
+
}
|
|
10
|
+
line(text = '') {
|
|
11
|
+
for (const line of text.split('\n'))
|
|
12
|
+
this._lines.push(line ? this._unit.repeat(this._depth) + line : '');
|
|
13
|
+
return this;
|
|
14
|
+
}
|
|
15
|
+
// Adds `open`, the body one level deeper, then `close`.
|
|
16
|
+
block(open, body, close = '}') {
|
|
17
|
+
this.line(open);
|
|
18
|
+
this.indent(body);
|
|
19
|
+
return this.line(close);
|
|
20
|
+
}
|
|
21
|
+
indent(body) {
|
|
22
|
+
this._depth++;
|
|
23
|
+
body();
|
|
24
|
+
this._depth--;
|
|
25
|
+
return this;
|
|
26
|
+
}
|
|
27
|
+
toString() {
|
|
28
|
+
return this._lines.join('\n') + '\n';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// Double-quoted literal valid in Go, Java and JavaScript (JSON escapes are a common subset).
|
|
32
|
+
export function doubleQuote(text) {
|
|
33
|
+
return JSON.stringify(text);
|
|
34
|
+
}
|
|
35
|
+
export function singleQuote(text) {
|
|
36
|
+
return escapeWithQuotes(text, '\'');
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=writer.js.map
|
package/dist/locator.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { asLocator, asLocatorWithFactory } from './vendor/playwright/locatorGenerators.js';
|
|
2
|
+
const nestedKinds = ['has', 'hasNot', 'and', 'or', 'chain'];
|
|
3
|
+
export function isRegex(matcher) {
|
|
4
|
+
return typeof matcher === 'object' && matcher !== null;
|
|
5
|
+
}
|
|
6
|
+
function toMatcher(value) {
|
|
7
|
+
return value instanceof RegExp ? { source: value.source, flags: value.flags } : value;
|
|
8
|
+
}
|
|
9
|
+
// Playwright decomposes a selector by calling a LocatorFactory per step; this factory
|
|
10
|
+
// records the steps as JSON instead of rendering source code.
|
|
11
|
+
class StepsFactory {
|
|
12
|
+
generateLocator(base, kind, body, options = {}) {
|
|
13
|
+
const step = { kind, base, body: '' };
|
|
14
|
+
if (nestedKinds.includes(kind))
|
|
15
|
+
step.inner = JSON.parse(body);
|
|
16
|
+
else
|
|
17
|
+
step.body = toMatcher(body);
|
|
18
|
+
if (options.exact !== undefined)
|
|
19
|
+
step.exact = options.exact;
|
|
20
|
+
if (options.name !== undefined)
|
|
21
|
+
step.name = toMatcher(options.name);
|
|
22
|
+
if (options.description !== undefined)
|
|
23
|
+
step.description = toMatcher(options.description);
|
|
24
|
+
if (options.attrs?.length)
|
|
25
|
+
step.attrs = options.attrs;
|
|
26
|
+
if (options.hasText !== undefined)
|
|
27
|
+
step.hasText = toMatcher(options.hasText);
|
|
28
|
+
if (options.hasNotText !== undefined)
|
|
29
|
+
step.hasNotText = toMatcher(options.hasNotText);
|
|
30
|
+
return JSON.stringify([step]);
|
|
31
|
+
}
|
|
32
|
+
chainLocators(locators) {
|
|
33
|
+
return JSON.stringify(locators.flatMap(locator => JSON.parse(locator)));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function parseLocator(selector) {
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(asLocatorWithFactory(new StepsFactory(), selector));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// Not a selector Playwright can turn into locators; keep it as a raw selector.
|
|
42
|
+
return [{ kind: 'default', base: 'page', body: selector }];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// Human-readable Playwright form, e.g. getByRole('button', { name: 'Sign in' }).
|
|
46
|
+
export function describeSelector(selector) {
|
|
47
|
+
return asLocator('javascript', selector);
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=locator.js.map
|
package/dist/naming.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Splits "loginFlow", "login-flow", "Login flow" or "LoginFlowTest" into lowercase words.
|
|
2
|
+
export function words(name) {
|
|
3
|
+
return name
|
|
4
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
5
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
|
6
|
+
.split(/[^A-Za-z0-9]+/)
|
|
7
|
+
.filter(Boolean)
|
|
8
|
+
.map(word => word.toLowerCase());
|
|
9
|
+
}
|
|
10
|
+
function safeWords(name) {
|
|
11
|
+
const result = words(name);
|
|
12
|
+
if (!result.length)
|
|
13
|
+
return ['recorded'];
|
|
14
|
+
if (/^[0-9]/.test(result[0]))
|
|
15
|
+
result.unshift('test');
|
|
16
|
+
return result;
|
|
17
|
+
}
|
|
18
|
+
const capitalize = (word) => word.charAt(0).toUpperCase() + word.slice(1);
|
|
19
|
+
export const pascalCase = (name) => safeWords(name).map(capitalize).join('');
|
|
20
|
+
export const camelCase = (name) => safeWords(name).map((word, i) => i ? capitalize(word) : word).join('');
|
|
21
|
+
export const snakeCase = (name) => safeWords(name).join('_');
|
|
22
|
+
export const kebabCase = (name) => safeWords(name).join('-');
|
|
23
|
+
// Recovers the test name from a file name: login_test.go, LoginTest.java, login.spec.js → "login".
|
|
24
|
+
export function nameFromFileName(fileName) {
|
|
25
|
+
const base = fileName.replace(/^.*[\\/]/, '').replace(/\.[^.]+$/, '');
|
|
26
|
+
return base
|
|
27
|
+
.replace(/\.(spec|test)$/i, '')
|
|
28
|
+
.replace(/(_test|Test|Tests|IT)$/, '')
|
|
29
|
+
|| 'recorded';
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=naming.js.map
|
package/dist/record.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
// Compiled: dist/recorder-process.js. From sources (tests), Node runs the .ts file directly.
|
|
4
|
+
const recorderProcess = fileURLToPath(new URL(import.meta.url.endsWith('.ts') ? './recorder-process.ts' : './recorder-process.js', import.meta.url));
|
|
5
|
+
// Starts the Playwright recorder in a child process. It rewrites `outputFile` (JSONL) after every
|
|
6
|
+
// recorded action and exits when the recorded browser is closed.
|
|
7
|
+
// TESTGEN_RECORDER_HEADLESS=1 records in a headless browser (for tests).
|
|
8
|
+
export function startCodegen(url, outputFile, options, stdio = 'inherit') {
|
|
9
|
+
const processOptions = {
|
|
10
|
+
...options,
|
|
11
|
+
url,
|
|
12
|
+
outputFile,
|
|
13
|
+
headless: !!process.env.TESTGEN_RECORDER_HEADLESS,
|
|
14
|
+
};
|
|
15
|
+
return spawn(process.execPath, [recorderProcess, JSON.stringify(processOptions)], { stdio });
|
|
16
|
+
}
|
|
17
|
+
export function waitForExit(child) {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
child.on('error', reject);
|
|
20
|
+
child.on('exit', code => resolve(code ?? 0));
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=record.js.map
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Runs Playwright's recorder (the engine behind `playwright codegen`) in its own process, with the
|
|
2
|
+
// Playwright Inspector window hidden: the user only sees the browser being recorded, and the code
|
|
3
|
+
// is shown by testgen. Mirrors codegen's own browser setup (packages/playwright-core/src/cli).
|
|
4
|
+
//
|
|
5
|
+
// Started by record.ts: node recorder-process.js '<RecorderProcessOptions as JSON>'
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import * as playwright from 'playwright-core';
|
|
10
|
+
async function main(options) {
|
|
11
|
+
// Playwright's own hook for running the recorder's Inspector window headless.
|
|
12
|
+
if (!options.inspector)
|
|
13
|
+
process.env.PWTEST_CLI_HEADLESS = '1';
|
|
14
|
+
const browserType = { chromium: playwright.chromium, firefox: playwright.firefox, webkit: playwright.webkit }[options.browser ?? 'chromium'];
|
|
15
|
+
if (!browserType)
|
|
16
|
+
throw new Error(`Unknown browser "${options.browser}": use chromium, firefox or webkit.`);
|
|
17
|
+
const launchOptions = { headless: !!options.headless, handleSIGINT: false };
|
|
18
|
+
if (options.channel)
|
|
19
|
+
launchOptions.channel = options.channel;
|
|
20
|
+
let contextOptions = {};
|
|
21
|
+
if (options.device) {
|
|
22
|
+
const device = playwright.devices[options.device];
|
|
23
|
+
if (!device)
|
|
24
|
+
throw new Error(`Unknown device "${options.device}".`);
|
|
25
|
+
contextOptions = { ...device };
|
|
26
|
+
}
|
|
27
|
+
// Like codegen: a crisp headed browser, and no touch emulation on WebKit for Linux.
|
|
28
|
+
const headedScaleFactor = os.platform() === 'darwin' ? 2 : 1;
|
|
29
|
+
if (browserType.name() === 'webkit' && process.platform === 'linux') {
|
|
30
|
+
delete contextOptions.hasTouch;
|
|
31
|
+
delete contextOptions.isMobile;
|
|
32
|
+
}
|
|
33
|
+
if (options.viewportSize) {
|
|
34
|
+
const [width, height] = options.viewportSize.split(',').map(Number);
|
|
35
|
+
if (!width || !height)
|
|
36
|
+
throw new Error('Invalid viewport size: use "width,height", for example 1280,720.');
|
|
37
|
+
contextOptions.viewport = { width, height };
|
|
38
|
+
}
|
|
39
|
+
if (options.userAgent)
|
|
40
|
+
contextOptions.userAgent = options.userAgent;
|
|
41
|
+
if (options.lang)
|
|
42
|
+
contextOptions.locale = options.lang;
|
|
43
|
+
if (options.colorScheme)
|
|
44
|
+
contextOptions.colorScheme = options.colorScheme;
|
|
45
|
+
if (options.timezone)
|
|
46
|
+
contextOptions.timezoneId = options.timezone;
|
|
47
|
+
if (options.loadStorage)
|
|
48
|
+
contextOptions.storageState = options.loadStorage;
|
|
49
|
+
if (options.ignoreHttpsErrors)
|
|
50
|
+
contextOptions.ignoreHTTPSErrors = true;
|
|
51
|
+
const browser = await browserType.launch(launchOptions);
|
|
52
|
+
const context = await browser.newContext({ ...contextOptions, ...(options.headless ? {} : { deviceScaleFactor: headedScaleFactor }) });
|
|
53
|
+
let closing = false;
|
|
54
|
+
const close = async (code) => {
|
|
55
|
+
if (closing)
|
|
56
|
+
return;
|
|
57
|
+
closing = true;
|
|
58
|
+
if (options.saveStorage)
|
|
59
|
+
await context.storageState({ path: options.saveStorage }).catch(() => { });
|
|
60
|
+
// The recorder rewrites its output file on a 250ms throttle; let the last write land.
|
|
61
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
62
|
+
await browser.close().catch(() => { });
|
|
63
|
+
process.exit(code);
|
|
64
|
+
};
|
|
65
|
+
// Closing the last tab ends the recording, as with codegen.
|
|
66
|
+
context.on('page', page => page.on('close', () => {
|
|
67
|
+
if (!context.pages().length)
|
|
68
|
+
void close(0);
|
|
69
|
+
}));
|
|
70
|
+
browser.on('disconnected', () => void close(0));
|
|
71
|
+
for (const signal of ['SIGINT', 'SIGTERM'])
|
|
72
|
+
process.on(signal, () => void close(0));
|
|
73
|
+
context.setDefaultTimeout(0);
|
|
74
|
+
context.setDefaultNavigationTimeout(0);
|
|
75
|
+
await context._enableRecorder({
|
|
76
|
+
language: 'jsonl',
|
|
77
|
+
mode: 'recording',
|
|
78
|
+
outputFile: path.resolve(options.outputFile),
|
|
79
|
+
launchOptions: options.channel ? { channel: options.channel } : {},
|
|
80
|
+
contextOptions,
|
|
81
|
+
device: options.device,
|
|
82
|
+
saveStorage: options.saveStorage,
|
|
83
|
+
testIdAttributeName: options.testIdAttribute,
|
|
84
|
+
handleSIGINT: false,
|
|
85
|
+
});
|
|
86
|
+
const page = await context.newPage();
|
|
87
|
+
if (options.url)
|
|
88
|
+
await page.goto(normalizeUrl(options.url)).catch(error => console.error(`testgen: could not open ${options.url}: ${error.message}`));
|
|
89
|
+
}
|
|
90
|
+
// Same rules as codegen: local files become file:// URLs, bare hosts get http://.
|
|
91
|
+
function normalizeUrl(url) {
|
|
92
|
+
if (fs.existsSync(url))
|
|
93
|
+
return 'file://' + path.resolve(url);
|
|
94
|
+
if (!/^(https?:|file:|about:|data:)/.test(url))
|
|
95
|
+
return 'http://' + url;
|
|
96
|
+
return url;
|
|
97
|
+
}
|
|
98
|
+
main(JSON.parse(process.argv[2] ?? '{}')).catch(error => {
|
|
99
|
+
console.error(`testgen: ${error.message}`);
|
|
100
|
+
process.exit(1);
|
|
101
|
+
});
|
|
102
|
+
//# sourceMappingURL=recorder-process.js.map
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const defaultHeader = { browserName: 'chromium', launchOptions: {}, contextOptions: {} };
|
|
2
|
+
export function parseRecording(text, source = 'recording') {
|
|
3
|
+
let header;
|
|
4
|
+
const result = [];
|
|
5
|
+
const lines = text.split(/\r?\n/);
|
|
6
|
+
for (let i = 0; i < lines.length; i++) {
|
|
7
|
+
if (!lines[i].trim())
|
|
8
|
+
continue;
|
|
9
|
+
let entry;
|
|
10
|
+
try {
|
|
11
|
+
entry = JSON.parse(lines[i]);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
throw new Error(`${source}:${i + 1}: not valid JSON`);
|
|
15
|
+
}
|
|
16
|
+
if (!header && !result.length && typeof entry.browserName === 'string') {
|
|
17
|
+
header = { ...defaultHeader, ...entry };
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (typeof entry.name !== 'string')
|
|
21
|
+
throw new Error(`${source}:${i + 1}: expected a recorded action with a "name"`);
|
|
22
|
+
// `locator` is Playwright's own rendering of `selector`; generators re-parse the selector.
|
|
23
|
+
const { signals = [], pageGuid = 'page', locator, ...action } = entry;
|
|
24
|
+
result.push({ pageGuid, signals, action });
|
|
25
|
+
}
|
|
26
|
+
return { header: header ?? defaultHeader, actions: collapseActions(result) };
|
|
27
|
+
}
|
|
28
|
+
// Same rule as Playwright's recorder: consecutive fills of one field become a single fill.
|
|
29
|
+
function collapseActions(list) {
|
|
30
|
+
const result = [];
|
|
31
|
+
for (const current of list) {
|
|
32
|
+
const last = result[result.length - 1];
|
|
33
|
+
const merge = last && current.action.name === 'fill' && last.action.name === 'fill'
|
|
34
|
+
&& current.pageGuid === last.pageGuid && current.action.selector === last.action.selector;
|
|
35
|
+
if (merge)
|
|
36
|
+
result[result.length - 1] = { ...current, signals: [...last.signals, ...current.signals] };
|
|
37
|
+
else
|
|
38
|
+
result.push(current);
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
export function signalsOf(actionInContext) {
|
|
43
|
+
const find = (name) => actionInContext.signals.find(signal => signal.name === name);
|
|
44
|
+
return {
|
|
45
|
+
popup: find('popup'),
|
|
46
|
+
download: find('download'),
|
|
47
|
+
dialog: find('dialog'),
|
|
48
|
+
expect: find('expect'),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
// Mirrors Playwright's codegen: the first page and every page opened later get stable names
|
|
52
|
+
// ("page", "page1", ...), assigned in the order the recording mentions them.
|
|
53
|
+
export class PageAliases {
|
|
54
|
+
_aliases = new Map();
|
|
55
|
+
alias(pageGuid) {
|
|
56
|
+
let alias = this._aliases.get(pageGuid);
|
|
57
|
+
if (!alias) {
|
|
58
|
+
alias = 'page' + (this._aliases.size || '');
|
|
59
|
+
this._aliases.set(pageGuid, alias);
|
|
60
|
+
}
|
|
61
|
+
return alias;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// Recorded modifier bit mask → Playwright modifier names (same mapping as Playwright's codegen).
|
|
65
|
+
export function toKeyboardModifiers(modifiers) {
|
|
66
|
+
const result = [];
|
|
67
|
+
if (modifiers & 1)
|
|
68
|
+
result.push('Alt');
|
|
69
|
+
if (modifiers & 2 || modifiers & 4)
|
|
70
|
+
result.push('ControlOrMeta');
|
|
71
|
+
if (modifiers & 8)
|
|
72
|
+
result.push('Shift');
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
// "Shift+Enter" style shortcut for a recorded `press` action.
|
|
76
|
+
export function shortcutOf(action) {
|
|
77
|
+
return [...toKeyboardModifiers(action.modifiers), action.key].join('+');
|
|
78
|
+
}
|
|
79
|
+
export function isBlankUrl(url) {
|
|
80
|
+
return !url || url === 'about:blank' || url === 'chrome://newtab/';
|
|
81
|
+
}
|
|
82
|
+
//# sourceMappingURL=recording.js.map
|