@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.
- package/CHANGELOG.md +25 -2
- package/README.md +159 -12
- package/dist/checks.js +100 -0
- package/dist/cli.js +84 -18
- package/dist/editing.js +167 -0
- package/dist/generate.js +23 -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/recording.js +43 -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 +5 -3
- 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 +386 -25
- package/ui/index.html +19 -3
- package/ui/style.css +80 -9
|
@@ -9,10 +9,12 @@
|
|
|
9
9
|
// same elements the Playwright recorder pointed at.
|
|
10
10
|
//
|
|
11
11
|
// Environment variables: HEADED=1 shows the browser, SELENIUM_REMOTE_URL runs on a Selenium Grid,
|
|
12
|
-
// TESTGEN_TIMEOUT sets the auto-wait timeout in milliseconds (default 10000).
|
|
12
|
+
// TESTGEN_TIMEOUT sets the auto-wait timeout in milliseconds (default 10000). secret('TEST_PASSWORD')
|
|
13
|
+
// reads a value typed into a password field from the environment.
|
|
13
14
|
'use strict';
|
|
14
15
|
|
|
15
16
|
const assert = require('node:assert');
|
|
17
|
+
const fs = require('node:fs');
|
|
16
18
|
const path = require('node:path');
|
|
17
19
|
const { Browser, Builder, Button, Key, Select, until } = require('selenium-webdriver');
|
|
18
20
|
const chrome = require('selenium-webdriver/chrome');
|
|
@@ -22,6 +24,93 @@ const remote = require('selenium-webdriver/remote');
|
|
|
22
24
|
|
|
23
25
|
const defaultTimeout = Number(process.env.TESTGEN_TIMEOUT || 10_000);
|
|
24
26
|
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
27
|
+
const firstLine = text => String(text).split('\n')[0];
|
|
28
|
+
|
|
29
|
+
// Values returned by secret(), as typed and as written in logs: logs and errors show them as ***.
|
|
30
|
+
const secrets = new Set();
|
|
31
|
+
|
|
32
|
+
// Value of the environment variable `name`, for a value typed into a password field: the value typed
|
|
33
|
+
// while recording is not kept in the test.
|
|
34
|
+
function secret(name) {
|
|
35
|
+
const value = process.env[name];
|
|
36
|
+
if (value === undefined)
|
|
37
|
+
throw new Error(`testgen: set the environment variable ${name}, the value to type into a password field`);
|
|
38
|
+
if (value) {
|
|
39
|
+
secrets.add(value);
|
|
40
|
+
secrets.add(format(value).slice(1, -1));
|
|
41
|
+
}
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function mask(text) {
|
|
46
|
+
let result = String(text);
|
|
47
|
+
for (const value of [...secrets].sort((a, b) => b.length - a.length))
|
|
48
|
+
result = result.split(value).join('***');
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Rows of the CSV data file of a data-driven test, as objects with the given columns. The file is UTF-8,
|
|
53
|
+
// with or without a BOM, separated by commas or semicolons (as Excel saves it in some languages), and its
|
|
54
|
+
// first line names the columns; other columns are ignored, blank lines skipped.
|
|
55
|
+
function readCsv(file, columns) {
|
|
56
|
+
const text = fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '');
|
|
57
|
+
const firstRow = text.split(/\r?\n/, 1)[0];
|
|
58
|
+
const separator = !firstRow.includes(',') && firstRow.includes(';') ? ';' : ',';
|
|
59
|
+
const [header, ...records] = parseCsv(text, separator);
|
|
60
|
+
if (!header)
|
|
61
|
+
throw new Error(`testgen: data file ${file} is empty`);
|
|
62
|
+
const names = header.map(name => name.trim());
|
|
63
|
+
for (const column of columns) {
|
|
64
|
+
if (!names.includes(column))
|
|
65
|
+
throw new Error(`testgen: data file ${file} has no column "${column}" (it has ${names.join(', ')})`);
|
|
66
|
+
}
|
|
67
|
+
const rows = records
|
|
68
|
+
.filter(record => record.some(cell => cell.trim()))
|
|
69
|
+
.map(record => Object.fromEntries(columns.map(column => [column, record[names.indexOf(column)] ?? ''])));
|
|
70
|
+
if (!rows.length)
|
|
71
|
+
throw new Error(`testgen: data file ${file} has no data rows`);
|
|
72
|
+
return rows;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// RFC 4180: quoted fields may contain separators, line breaks and doubled quotes.
|
|
76
|
+
function parseCsv(text, separator) {
|
|
77
|
+
const rows = [];
|
|
78
|
+
let row = [];
|
|
79
|
+
let field = '';
|
|
80
|
+
let quoted = false;
|
|
81
|
+
for (let i = 0; i < text.length; i++) {
|
|
82
|
+
const c = text[i];
|
|
83
|
+
if (quoted) {
|
|
84
|
+
if (c === '"' && text[i + 1] === '"') {
|
|
85
|
+
field += '"';
|
|
86
|
+
i++;
|
|
87
|
+
} else if (c === '"') {
|
|
88
|
+
quoted = false;
|
|
89
|
+
} else {
|
|
90
|
+
field += c;
|
|
91
|
+
}
|
|
92
|
+
} else if (c === '"') {
|
|
93
|
+
quoted = true;
|
|
94
|
+
} else if (c === separator) {
|
|
95
|
+
row.push(field);
|
|
96
|
+
field = '';
|
|
97
|
+
} else if (c === '\n' || c === '\r') {
|
|
98
|
+
if (c === '\r' && text[i + 1] === '\n')
|
|
99
|
+
i++;
|
|
100
|
+
row.push(field);
|
|
101
|
+
rows.push(row);
|
|
102
|
+
row = [];
|
|
103
|
+
field = '';
|
|
104
|
+
} else {
|
|
105
|
+
field += c;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (field || row.length) {
|
|
109
|
+
row.push(field);
|
|
110
|
+
rows.push(row);
|
|
111
|
+
}
|
|
112
|
+
return rows;
|
|
113
|
+
}
|
|
25
114
|
|
|
26
115
|
async function createDriver(browser = 'chrome', options = {}) {
|
|
27
116
|
const headed = !!process.env.HEADED;
|
|
@@ -73,6 +162,8 @@ function format(value) {
|
|
|
73
162
|
return value.toString();
|
|
74
163
|
if (typeof value === 'string')
|
|
75
164
|
return `'${value.replace(/\\/g, '\\\\').replace(/'/g, '\\\'')}'`;
|
|
165
|
+
if (Array.isArray(value))
|
|
166
|
+
return `[${value.map(format).join(', ')}]`;
|
|
76
167
|
if (value && typeof value === 'object')
|
|
77
168
|
return `{ ${Object.entries(value).map(([key, v]) => `${key}: ${format(v)}`).join(', ')} }`;
|
|
78
169
|
return String(value);
|
|
@@ -246,8 +337,20 @@ class Locator {
|
|
|
246
337
|
return (await this.elements()).length;
|
|
247
338
|
}
|
|
248
339
|
|
|
340
|
+
// Runs `action` on the first matching element once it is ready; `args` describe the call in the log.
|
|
341
|
+
async _act(name, args, action, options) {
|
|
342
|
+
const call = `${this}.${name}(${args})`;
|
|
343
|
+
this._page._log(call);
|
|
344
|
+
try {
|
|
345
|
+
return await this._perform(name, action, options);
|
|
346
|
+
} catch (error) {
|
|
347
|
+
this._page._log(`${call} FAILED: ${firstLine(error.message)}`);
|
|
348
|
+
throw error;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
249
352
|
// Waits for the first matching element to be ready, then runs `action` on it.
|
|
250
|
-
async
|
|
353
|
+
async _perform(name, action, { visible = true, enabled = false } = {}) {
|
|
251
354
|
const deadline = Date.now() + this._page._timeout;
|
|
252
355
|
let reason = '';
|
|
253
356
|
for (;;) {
|
|
@@ -270,7 +373,7 @@ class Locator {
|
|
|
270
373
|
reason = error.message;
|
|
271
374
|
}
|
|
272
375
|
if (Date.now() > deadline)
|
|
273
|
-
throw new Error(`${name}: timeout ${this._page._timeout}ms exceeded waiting for ${this} (${reason})`);
|
|
376
|
+
throw new Error(mask(`${name}: timeout ${this._page._timeout}ms exceeded waiting for ${this} (${reason})`));
|
|
274
377
|
await sleep(100);
|
|
275
378
|
}
|
|
276
379
|
}
|
|
@@ -278,7 +381,7 @@ class Locator {
|
|
|
278
381
|
async click(options = {}) {
|
|
279
382
|
const driver = this._page._driver;
|
|
280
383
|
const simple = !options.button && !options.modifiers && !options.clickCount && !options.position;
|
|
281
|
-
return this._act('click', async element => {
|
|
384
|
+
return this._act('click', simple ? '' : format(options), async element => {
|
|
282
385
|
if (simple)
|
|
283
386
|
return element.click();
|
|
284
387
|
const button = { right: Button.RIGHT, middle: Button.MIDDLE }[options.button] ?? Button.LEFT;
|
|
@@ -295,12 +398,12 @@ class Locator {
|
|
|
295
398
|
|
|
296
399
|
async dblclick() {
|
|
297
400
|
const driver = this._page._driver;
|
|
298
|
-
return this._act('dblclick', element => driver.actions({ async: true }).doubleClick(element).perform(), { enabled: true });
|
|
401
|
+
return this._act('dblclick', '', element => driver.actions({ async: true }).doubleClick(element).perform(), { enabled: true });
|
|
299
402
|
}
|
|
300
403
|
|
|
301
404
|
async hover(options = {}) {
|
|
302
405
|
const driver = this._page._driver;
|
|
303
|
-
return this._act('hover', async element => {
|
|
406
|
+
return this._act('hover', options.position ? format(options) : '', async element => {
|
|
304
407
|
const actions = driver.actions({ async: true });
|
|
305
408
|
await moveTo(actions, element, options.position);
|
|
306
409
|
return actions.perform();
|
|
@@ -308,7 +411,7 @@ class Locator {
|
|
|
308
411
|
}
|
|
309
412
|
|
|
310
413
|
async fill(value) {
|
|
311
|
-
return this._act('fill', async element => {
|
|
414
|
+
return this._act('fill', format(value), async element => {
|
|
312
415
|
await element.clear();
|
|
313
416
|
if (value)
|
|
314
417
|
await element.sendKeys(value);
|
|
@@ -316,7 +419,7 @@ class Locator {
|
|
|
316
419
|
}
|
|
317
420
|
|
|
318
421
|
async press(key) {
|
|
319
|
-
return this._act('press', element => element.sendKeys(toKeys(key)));
|
|
422
|
+
return this._act('press', format(key), element => element.sendKeys(toKeys(key)));
|
|
320
423
|
}
|
|
321
424
|
|
|
322
425
|
async check() {
|
|
@@ -328,7 +431,7 @@ class Locator {
|
|
|
328
431
|
}
|
|
329
432
|
|
|
330
433
|
async _setChecked(checked) {
|
|
331
|
-
return this._act(checked ? 'check' : 'uncheck', async element => {
|
|
434
|
+
return this._act(checked ? 'check' : 'uncheck', '', async element => {
|
|
332
435
|
if (await isChecked(element) !== checked)
|
|
333
436
|
await element.click();
|
|
334
437
|
}, { enabled: true });
|
|
@@ -336,7 +439,7 @@ class Locator {
|
|
|
336
439
|
|
|
337
440
|
async selectOption(values) {
|
|
338
441
|
const list = Array.isArray(values) ? values : [values];
|
|
339
|
-
return this._act('selectOption', async element => {
|
|
442
|
+
return this._act('selectOption', format(values), async element => {
|
|
340
443
|
const select = new Select(element);
|
|
341
444
|
if (await select.isMultiple())
|
|
342
445
|
await select.deselectAll();
|
|
@@ -352,7 +455,7 @@ class Locator {
|
|
|
352
455
|
|
|
353
456
|
async setInputFiles(files) {
|
|
354
457
|
const list = (Array.isArray(files) ? files : [files]).map(file => path.resolve(file));
|
|
355
|
-
return this._act('setInputFiles', element => element.sendKeys(list.join('\n')), { visible: false });
|
|
458
|
+
return this._act('setInputFiles', format(files), element => element.sendKeys(list.join('\n')), { visible: false });
|
|
356
459
|
}
|
|
357
460
|
}
|
|
358
461
|
|
|
@@ -369,8 +472,9 @@ class Page extends Locator {
|
|
|
369
472
|
return new Page(driver, await driver.getWindowHandle(), options);
|
|
370
473
|
}
|
|
371
474
|
|
|
372
|
-
|
|
373
|
-
|
|
475
|
+
// Pages opened later are named page1, page2... like the variables of the generated test.
|
|
476
|
+
constructor(driver, handle, options = {}, name = 'page', opened = { count: 0 }) {
|
|
477
|
+
super(null, [], name);
|
|
374
478
|
this._page = this;
|
|
375
479
|
this._driver = driver;
|
|
376
480
|
this._handle = handle;
|
|
@@ -378,6 +482,30 @@ class Page extends Locator {
|
|
|
378
482
|
this._testIdAttribute = options.testIdAttribute || 'data-testid';
|
|
379
483
|
this._timeout = options.timeout || defaultTimeout;
|
|
380
484
|
this._dialogHandlers = [];
|
|
485
|
+
this._opened = opened;
|
|
486
|
+
this._logger = undefined;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Receives a line for every action, with its arguments, and for the result of every assertion, e.g.
|
|
490
|
+
// page.setLogger(console.log). Pages opened later log to it as well.
|
|
491
|
+
setLogger(logger) {
|
|
492
|
+
this._logger = logger;
|
|
493
|
+
return this;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
_log(message) {
|
|
497
|
+
if (!this._logger)
|
|
498
|
+
return;
|
|
499
|
+
const now = new Date();
|
|
500
|
+
this._logger(`${now.toTimeString().slice(0, 8)}.${String(now.getMilliseconds()).padStart(3, '0')} ${mask(message)}`);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
_sibling(handle) {
|
|
504
|
+
const page = new Page(this._driver, handle, this._options, `page${++this._opened.count}`, this._opened);
|
|
505
|
+
page._testIdAttribute = this._testIdAttribute;
|
|
506
|
+
page._timeout = this._timeout;
|
|
507
|
+
page._logger = this._logger;
|
|
508
|
+
return page;
|
|
381
509
|
}
|
|
382
510
|
|
|
383
511
|
async _activate() {
|
|
@@ -387,11 +515,13 @@ class Page extends Locator {
|
|
|
387
515
|
}
|
|
388
516
|
|
|
389
517
|
async goto(url) {
|
|
518
|
+
this._log(`${this}.goto(${format(url)})`);
|
|
390
519
|
await this._activate();
|
|
391
520
|
await this._driver.get(url);
|
|
392
521
|
}
|
|
393
522
|
|
|
394
523
|
async close() {
|
|
524
|
+
this._log(`${this}.close()`);
|
|
395
525
|
await this._activate();
|
|
396
526
|
await this._driver.close();
|
|
397
527
|
}
|
|
@@ -400,7 +530,9 @@ class Page extends Locator {
|
|
|
400
530
|
return {
|
|
401
531
|
newPage: async () => {
|
|
402
532
|
await this._driver.switchTo().newWindow('tab');
|
|
403
|
-
|
|
533
|
+
const page = this._sibling(await this._driver.getWindowHandle());
|
|
534
|
+
this._log(`${page} = ${this}.context().newPage()`);
|
|
535
|
+
return page;
|
|
404
536
|
},
|
|
405
537
|
};
|
|
406
538
|
}
|
|
@@ -415,8 +547,11 @@ class Page extends Locator {
|
|
|
415
547
|
const deadline = Date.now() + this._timeout;
|
|
416
548
|
for (;;) {
|
|
417
549
|
const handle = (await this._driver.getAllWindowHandles()).find(h => !before.has(h));
|
|
418
|
-
if (handle)
|
|
419
|
-
|
|
550
|
+
if (handle) {
|
|
551
|
+
const page = this._sibling(handle);
|
|
552
|
+
this._log(`${page} opened as a popup of ${this}`);
|
|
553
|
+
return page;
|
|
554
|
+
}
|
|
420
555
|
if (Date.now() > deadline)
|
|
421
556
|
throw new Error(`waitForEvent('popup'): timeout ${this._timeout}ms exceeded`);
|
|
422
557
|
await sleep(100);
|
|
@@ -444,6 +579,7 @@ class Page extends Locator {
|
|
|
444
579
|
return;
|
|
445
580
|
}
|
|
446
581
|
const dialog = new Dialog(alert, await alert.getText());
|
|
582
|
+
this._log(`${this} dialog ${format(dialog.message())}`);
|
|
447
583
|
await this._dialogHandlers.shift()(dialog);
|
|
448
584
|
await dialog._settled;
|
|
449
585
|
}
|
|
@@ -491,24 +627,28 @@ class LocatorAssertions {
|
|
|
491
627
|
// Polls until `check` passes (or fails, when negated) for the first matching element.
|
|
492
628
|
async _poll(name, expected, check) {
|
|
493
629
|
const page = this._locator._page;
|
|
630
|
+
const call = `expect(${this._locator})${this._negated ? '.not' : ''}.${name}(${expected === undefined ? '' : format(expected)})`;
|
|
494
631
|
const deadline = Date.now() + page._timeout;
|
|
495
632
|
let actual = '<element not found>';
|
|
496
633
|
for (;;) {
|
|
497
634
|
try {
|
|
498
635
|
const [element] = await this._locator.elements();
|
|
499
636
|
const result = element ? await check(element) : { pass: false, actual: '<element not found>' };
|
|
500
|
-
if (result.pass !== this._negated)
|
|
637
|
+
if (result.pass !== this._negated) {
|
|
638
|
+
page._log(`${call} passed`);
|
|
501
639
|
return;
|
|
640
|
+
}
|
|
502
641
|
actual = result.actual;
|
|
503
642
|
} catch (error) {
|
|
504
|
-
if (!isRetryable(error))
|
|
643
|
+
if (!isRetryable(error)) {
|
|
644
|
+
page._log(`${call} FAILED: ${firstLine(error.message)}`);
|
|
505
645
|
throw error;
|
|
646
|
+
}
|
|
506
647
|
actual = error.message;
|
|
507
648
|
}
|
|
508
649
|
if (Date.now() > deadline) {
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
});
|
|
650
|
+
page._log(`${call} FAILED, received: ${actual}`);
|
|
651
|
+
throw new assert.AssertionError({ message: mask(`${call} failed after ${page._timeout}ms\nReceived: ${actual}`) });
|
|
512
652
|
}
|
|
513
653
|
await sleep(100);
|
|
514
654
|
}
|
|
@@ -615,4 +755,4 @@ function toKeys(shortcut) {
|
|
|
615
755
|
|
|
616
756
|
/* @testgen-engine */
|
|
617
757
|
|
|
618
|
-
module.exports = { createDriver, setViewportSize, Page, Locator, Dialog, expect };
|
|
758
|
+
module.exports = { createDriver, setViewportSize, Page, Locator, Dialog, expect, readCsv, secret };
|