@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.
@@ -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 _act(name, action, { visible = true, enabled = false } = {}) {
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
- constructor(driver, handle, options = {}) {
373
- super(null, [], 'page');
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
- return new Page(this._driver, await this._driver.getWindowHandle(), this._options);
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
- return new Page(this._driver, handle, this._options);
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
- throw new assert.AssertionError({
510
- message: `expect(${this._locator})${this._negated ? '.not' : ''}.${name}(${expected === undefined ? '' : format(expected)}) failed after ${page._timeout}ms\nReceived: ${actual}`,
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 };