@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.
@@ -4,16 +4,25 @@
4
4
  // permission: as part of tests generated by testgen, this file may be used, modified and distributed under
5
5
  // terms of your choice (see NOTICE in @xylentis/testgen).
6
6
 
7
+ import java.io.IOException;
8
+ import java.io.InputStream;
9
+ import java.io.UncheckedIOException;
7
10
  import java.net.MalformedURLException;
8
11
  import java.net.URI;
12
+ import java.nio.charset.StandardCharsets;
9
13
  import java.nio.file.Path;
10
14
  import java.time.Duration;
15
+ import java.time.LocalTime;
16
+ import java.time.format.DateTimeFormatter;
11
17
  import java.util.ArrayList;
18
+ import java.util.Arrays;
12
19
  import java.util.LinkedHashMap;
13
20
  import java.util.List;
14
21
  import java.util.Map;
15
22
  import java.util.Set;
16
23
  import java.util.TreeMap;
24
+ import java.util.concurrent.ConcurrentHashMap;
25
+ import java.util.concurrent.atomic.AtomicInteger;
17
26
  import java.util.function.Consumer;
18
27
  import java.util.function.Function;
19
28
  import java.util.function.Predicate;
@@ -58,7 +67,8 @@ import org.openqa.selenium.support.ui.WebDriverWait;
58
67
  * ({@link #ENGINE}), so getByRole/getByText/getByLabel find the elements the Playwright recorder pointed at.
59
68
  *
60
69
  * <p>Environment variables: HEADED=1 shows the browser, SELENIUM_REMOTE_URL runs on a Selenium Grid,
61
- * TESTGEN_TIMEOUT sets the auto-wait timeout in milliseconds (default 10000).
70
+ * TESTGEN_TIMEOUT sets the auto-wait timeout in milliseconds (default 10000). {@link #secret(String)} reads
71
+ * a value typed into a password field, e.g. TEST_PASSWORD.
62
72
  */
63
73
  public final class TestgenSupport {
64
74
  private TestgenSupport() {}
@@ -71,6 +81,32 @@ public final class TestgenSupport {
71
81
  return value == null || value.isEmpty() ? fallback : value;
72
82
  }
73
83
 
84
+ // Values returned by secret(), as typed and as written in logs: logs and errors show them as ***.
85
+ private static final Set<String> SECRETS = ConcurrentHashMap.newKeySet();
86
+
87
+ /**
88
+ * Value of the environment variable (or system property) {@code name}, for a value typed into a password
89
+ * field: the value typed while recording is not kept in the test. Logs and assertion messages show it as ***.
90
+ */
91
+ public static String secret(String name) {
92
+ String value = System.getProperty(name, System.getenv(name));
93
+ if (value == null)
94
+ throw new IllegalStateException("testgen: set the environment variable " + name + ", the value to type into a password field");
95
+ if (!value.isEmpty()) {
96
+ SECRETS.add(value);
97
+ String quoted = quote(value);
98
+ SECRETS.add(quoted.substring(1, quoted.length() - 1));
99
+ }
100
+ return value;
101
+ }
102
+
103
+ static String mask(String text) {
104
+ String result = text;
105
+ for (String secret : SECRETS.stream().sorted((a, b) -> b.length() - a.length()).toList())
106
+ result = result.replace(secret, "***");
107
+ return result;
108
+ }
109
+
74
110
  /** Starts "chrome", "edge", "firefox" or "safari", locally or on SELENIUM_REMOTE_URL; headless unless HEADED is set. */
75
111
  public static WebDriver createDriver(String browser, String... arguments) {
76
112
  boolean headed = !env("HEADED", "").isEmpty();
@@ -412,40 +448,48 @@ public final class TestgenSupport {
412
448
  reason = firstLine(e.getMessage());
413
449
  }
414
450
  if (System.nanoTime() > deadline)
415
- throw new TimeoutException(name + ": timeout " + page.timeout.toMillis() + "ms exceeded waiting for " + this + " (" + reason + ")");
451
+ throw new TimeoutException(mask(name + ": timeout " + page.timeout.toMillis() + "ms exceeded waiting for " + this + " (" + reason + ")"));
416
452
  sleep(100);
417
453
  }
418
454
  }
419
455
 
420
- private void act(String name, Consumer<WebElement> action, boolean visible, boolean enabled) {
421
- query(name, element -> {
422
- action.accept(element);
423
- return null;
424
- }, visible, enabled);
456
+ // Runs `action` on the first matching element once it is ready; `arguments` describe the call in the log.
457
+ private void act(String name, String arguments, Consumer<WebElement> action, boolean visible, boolean enabled) {
458
+ String call = this + "." + name + "(" + arguments + ")";
459
+ page().log(call);
460
+ try {
461
+ query(name, element -> {
462
+ action.accept(element);
463
+ return null;
464
+ }, visible, enabled);
465
+ } catch (RuntimeException e) {
466
+ page().log(call + " FAILED: " + firstLine(e.getMessage()));
467
+ throw e;
468
+ }
425
469
  }
426
470
 
427
471
  public void click() {
428
- act("click", WebElement::click, true, true);
472
+ act("click", "", WebElement::click, true, true);
429
473
  }
430
474
 
431
475
  public void click(ClickOptions options) {
432
- act("click", element -> options.perform(page().driver, element, true), true, true);
476
+ act("click", options.toString(), element -> options.perform(page().driver, element, true), true, true);
433
477
  }
434
478
 
435
479
  public void dblclick() {
436
- act("dblclick", element -> new Actions(page().driver).doubleClick(element).perform(), true, true);
480
+ act("dblclick", "", element -> new Actions(page().driver).doubleClick(element).perform(), true, true);
437
481
  }
438
482
 
439
483
  public void hover() {
440
- act("hover", element -> new Actions(page().driver).moveToElement(element).perform(), true, false);
484
+ act("hover", "", element -> new Actions(page().driver).moveToElement(element).perform(), true, false);
441
485
  }
442
486
 
443
487
  public void hover(ClickOptions options) {
444
- act("hover", element -> options.perform(page().driver, element, false), true, false);
488
+ act("hover", options.toString(), element -> options.perform(page().driver, element, false), true, false);
445
489
  }
446
490
 
447
491
  public void fill(String value) {
448
- act("fill", element -> {
492
+ act("fill", quote(value), element -> {
449
493
  element.clear();
450
494
  if (!value.isEmpty())
451
495
  element.sendKeys(value);
@@ -453,7 +497,7 @@ public final class TestgenSupport {
453
497
  }
454
498
 
455
499
  public void press(String key) {
456
- act("press", element -> element.sendKeys(keys(key)), true, false);
500
+ act("press", quote(key), element -> element.sendKeys(keys(key)), true, false);
457
501
  }
458
502
 
459
503
  public void check() {
@@ -465,14 +509,14 @@ public final class TestgenSupport {
465
509
  }
466
510
 
467
511
  private void setChecked(boolean checked) {
468
- act(checked ? "check" : "uncheck", element -> {
512
+ act(checked ? "check" : "uncheck", "", element -> {
469
513
  if (isChecked(element) != checked)
470
514
  element.click();
471
515
  }, true, true);
472
516
  }
473
517
 
474
518
  public void selectOption(String... values) {
475
- act("selectOption", element -> {
519
+ act("selectOption", String.join(", ", Arrays.stream(values).map(TestgenSupport::quote).toList()), element -> {
476
520
  Select select = new Select(element);
477
521
  if (select.isMultiple())
478
522
  select.deselectAll();
@@ -490,7 +534,8 @@ public final class TestgenSupport {
490
534
  List<String> paths = new ArrayList<>();
491
535
  for (String file : files)
492
536
  paths.add(Path.of(file).toAbsolutePath().toString());
493
- act("setInputFiles", element -> element.sendKeys(String.join("\n", paths)), false, false);
537
+ act("setInputFiles", String.join(", ", Arrays.stream(files).map(TestgenSupport::quote).toList()),
538
+ element -> element.sendKeys(String.join("\n", paths)), false, false);
494
539
  }
495
540
 
496
541
  public void shouldBeVisible() {
@@ -524,23 +569,30 @@ public final class TestgenSupport {
524
569
  // Polls until `check` passes for the first matching element.
525
570
  private void expect(String name, String expected, Predicate<WebElement> check, Function<WebElement, String> actual) {
526
571
  Page page = page();
572
+ String call = this + "." + name + "(" + (expected == null ? "" : quote(expected)) + ")";
527
573
  long deadline = System.nanoTime() + page.timeout.toNanos();
528
574
  String received = "<element not found>";
529
575
  while (true) {
530
576
  try {
531
577
  List<WebElement> found = elements();
532
578
  if (!found.isEmpty()) {
533
- if (check.test(found.get(0)))
579
+ if (check.test(found.get(0))) {
580
+ page.log(call + " passed");
534
581
  return;
582
+ }
535
583
  received = actual.apply(found.get(0));
536
584
  }
537
585
  } catch (WebDriverException e) {
538
- if (!isRetryable(e))
586
+ if (!isRetryable(e)) {
587
+ page.log(call + " FAILED: " + firstLine(e.getMessage()));
539
588
  throw e;
589
+ }
540
590
  received = firstLine(e.getMessage());
541
591
  }
542
- if (System.nanoTime() > deadline)
543
- throw new AssertionError(this + "." + name + "(" + (expected == null ? "" : quote(expected)) + ") failed after " + page.timeout.toMillis() + "ms\nReceived: " + received);
592
+ if (System.nanoTime() > deadline) {
593
+ page.log(call + " FAILED, received: " + received);
594
+ throw new AssertionError(mask(call + " failed after " + page.timeout.toMillis() + "ms\nReceived: " + received));
595
+ }
544
596
  sleep(100);
545
597
  }
546
598
  }
@@ -548,20 +600,26 @@ public final class TestgenSupport {
548
600
 
549
601
  /** One browser window or tab, like Playwright's Page; actions switch WebDriver to it automatically. */
550
602
  public static final class Page extends Locator {
603
+ private static final DateTimeFormatter LOG_TIME = DateTimeFormatter.ofPattern("HH:mm:ss.SSS");
604
+
551
605
  final WebDriver driver;
552
606
  final String handle;
553
607
  String testIdAttribute = "data-testid";
554
608
  Duration timeout = DEFAULT_TIMEOUT;
609
+ private Consumer<String> logger;
610
+ // Pages opened later are named page1, page2, ... like the variables of the generated test.
611
+ private final AtomicInteger opened;
555
612
  private final List<Consumer<Dialog>> dialogHandlers = new ArrayList<>();
556
613
 
557
614
  public Page(WebDriver driver) {
558
- this(driver, driver.getWindowHandle());
615
+ this(driver, driver.getWindowHandle(), "page", new AtomicInteger());
559
616
  }
560
617
 
561
- private Page(WebDriver driver, String handle) {
562
- super(null, List.of(), "page");
618
+ private Page(WebDriver driver, String handle, String name, AtomicInteger opened) {
619
+ super(null, List.of(), name);
563
620
  this.driver = driver;
564
621
  this.handle = handle;
622
+ this.opened = opened;
565
623
  }
566
624
 
567
625
  public Page setTestIdAttribute(String attribute) {
@@ -569,17 +627,34 @@ public final class TestgenSupport {
569
627
  return this;
570
628
  }
571
629
 
630
+ /**
631
+ * Receives a line for every action, with its arguments, and for the result of every assertion, e.g.
632
+ * {@code page.setLogger(System.out::println)} or {@code page.setLogger(message -> Reporter.log(message, true))}
633
+ * with TestNG. Pages opened later log to it as well.
634
+ */
635
+ public Page setLogger(Consumer<String> logger) {
636
+ this.logger = logger;
637
+ return this;
638
+ }
639
+
640
+ void log(String message) {
641
+ if (logger != null)
642
+ logger.accept(LocalTime.now().format(LOG_TIME) + " " + mask(message));
643
+ }
644
+
572
645
  public Page setDefaultTimeout(Duration timeout) {
573
646
  this.timeout = timeout;
574
647
  return this;
575
648
  }
576
649
 
577
650
  public void navigate(String url) {
651
+ log(this + ".navigate(" + quote(url) + ")");
578
652
  activate();
579
653
  driver.get(url);
580
654
  }
581
655
 
582
656
  public void close() {
657
+ log(this + ".close()");
583
658
  activate();
584
659
  driver.close();
585
660
  }
@@ -587,7 +662,9 @@ public final class TestgenSupport {
587
662
  /** Opens a new tab. */
588
663
  public Page newPage() {
589
664
  driver.switchTo().newWindow(WindowType.TAB);
590
- return sibling(driver.getWindowHandle());
665
+ Page page = sibling(driver.getWindowHandle());
666
+ log(page + " = " + this + ".newPage()");
667
+ return page;
591
668
  }
592
669
 
593
670
  /** Runs `action` and returns the window it opens. */
@@ -598,8 +675,11 @@ public final class TestgenSupport {
598
675
  long deadline = System.nanoTime() + timeout.toNanos();
599
676
  while (true) {
600
677
  for (String candidate : driver.getWindowHandles()) {
601
- if (!before.contains(candidate))
602
- return sibling(candidate);
678
+ if (!before.contains(candidate)) {
679
+ Page popup = sibling(candidate);
680
+ log(popup + " opened as a popup of " + this);
681
+ return popup;
682
+ }
603
683
  }
604
684
  if (System.nanoTime() > deadline)
605
685
  throw new TimeoutException("waitForPopup: timeout " + timeout.toMillis() + "ms exceeded");
@@ -621,7 +701,9 @@ public final class TestgenSupport {
621
701
  } catch (TimeoutException e) {
622
702
  return;
623
703
  }
624
- dialogHandlers.remove(0).accept(new Dialog(alert));
704
+ Dialog dialog = new Dialog(alert);
705
+ log(this + " dialog " + quote(dialog.message()));
706
+ dialogHandlers.remove(0).accept(dialog);
625
707
  }
626
708
 
627
709
  void activate() {
@@ -646,9 +728,10 @@ public final class TestgenSupport {
646
728
  }
647
729
 
648
730
  private Page sibling(String handle) {
649
- Page page = new Page(driver, handle);
731
+ Page page = new Page(driver, handle, "page" + opened.incrementAndGet(), opened);
650
732
  page.testIdAttribute = testIdAttribute;
651
733
  page.timeout = timeout;
734
+ page.logger = logger;
652
735
  return page;
653
736
  }
654
737
  }
@@ -709,6 +792,20 @@ public final class TestgenSupport {
709
792
  return this;
710
793
  }
711
794
 
795
+ @Override
796
+ public String toString() {
797
+ StringBuilder result = new StringBuilder("new ClickOptions()");
798
+ if (!"left".equals(button))
799
+ result.append(".setButton(").append(quote(button)).append(")");
800
+ if (modifiers.length > 0)
801
+ result.append(".setModifiers(").append(String.join(", ", Arrays.stream(modifiers).map(TestgenSupport::quote).toList())).append(")");
802
+ if (clickCount != 1)
803
+ result.append(".setClickCount(").append(clickCount).append(")");
804
+ if (x != null)
805
+ result.append(".setPosition(").append(x).append(", ").append(y).append(")");
806
+ return result.toString();
807
+ }
808
+
712
809
  void perform(WebDriver driver, WebElement element, boolean click) {
713
810
  if ("middle".equals(button))
714
811
  throw new UnsupportedOperationException("testgen: middle click is not supported on Selenium");
@@ -735,6 +832,93 @@ public final class TestgenSupport {
735
832
  }
736
833
  }
737
834
 
835
+ /**
836
+ * Rows of the CSV data file of a data-driven test, as test arguments in the order of {@code columns}.
837
+ * The file is a classpath resource next to {@code testClass} (Maven: src/test/resources/<package>/).
838
+ * It is UTF-8, with or without a BOM, separated by commas or semicolons (as Excel saves it in some
839
+ * languages), and its first line names the columns; other columns are ignored, blank lines skipped.
840
+ */
841
+ public static Object[][] readCsv(Class<?> testClass, String resource, String... columns) {
842
+ String text;
843
+ try (InputStream input = testClass.getResourceAsStream(resource)) {
844
+ if (input == null) {
845
+ String folder = testClass.getPackageName().replace('.', '/');
846
+ throw new IllegalStateException("testgen: data file " + resource + " not found next to " + testClass.getName()
847
+ + " on the classpath (Maven: src/test/resources/" + (folder.isEmpty() ? "" : folder + "/") + resource + ")");
848
+ }
849
+ text = new String(input.readAllBytes(), StandardCharsets.UTF_8);
850
+ } catch (IOException e) {
851
+ throw new UncheckedIOException(e);
852
+ }
853
+ if (text.startsWith("\uFEFF"))
854
+ text = text.substring(1);
855
+ String firstLine = text.split("\r?\n", 2)[0];
856
+ char separator = firstLine.indexOf(',') < 0 && firstLine.indexOf(';') >= 0 ? ';' : ',';
857
+ List<List<String>> rows = parseCsv(text, separator);
858
+ if (rows.isEmpty())
859
+ throw new IllegalStateException("testgen: data file " + resource + " is empty");
860
+
861
+ List<String> header = rows.get(0).stream().map(String::trim).toList();
862
+ int[] indexes = new int[columns.length];
863
+ for (int i = 0; i < columns.length; i++) {
864
+ indexes[i] = header.indexOf(columns[i]);
865
+ if (indexes[i] < 0)
866
+ throw new IllegalStateException("testgen: data file " + resource + " has no column \"" + columns[i] + "\" (it has " + String.join(", ", header) + ")");
867
+ }
868
+ List<Object[]> result = new ArrayList<>();
869
+ for (List<String> row : rows.subList(1, rows.size())) {
870
+ if (row.stream().allMatch(String::isBlank))
871
+ continue;
872
+ Object[] arguments = new Object[columns.length];
873
+ for (int i = 0; i < columns.length; i++)
874
+ arguments[i] = indexes[i] < row.size() ? row.get(indexes[i]) : "";
875
+ result.add(arguments);
876
+ }
877
+ if (result.isEmpty())
878
+ throw new IllegalStateException("testgen: data file " + resource + " has no data rows");
879
+ return result.toArray(new Object[0][]);
880
+ }
881
+
882
+ // RFC 4180: quoted fields may contain separators, line breaks and doubled quotes.
883
+ static List<List<String>> parseCsv(String text, char separator) {
884
+ List<List<String>> rows = new ArrayList<>();
885
+ List<String> row = new ArrayList<>();
886
+ StringBuilder field = new StringBuilder();
887
+ boolean quoted = false;
888
+ for (int i = 0; i < text.length(); i++) {
889
+ char c = text.charAt(i);
890
+ if (quoted) {
891
+ if (c == '"' && i + 1 < text.length() && text.charAt(i + 1) == '"') {
892
+ field.append('"');
893
+ i++;
894
+ } else if (c == '"') {
895
+ quoted = false;
896
+ } else {
897
+ field.append(c);
898
+ }
899
+ } else if (c == '"') {
900
+ quoted = true;
901
+ } else if (c == separator) {
902
+ row.add(field.toString());
903
+ field.setLength(0);
904
+ } else if (c == '\n' || c == '\r') {
905
+ if (c == '\r' && i + 1 < text.length() && text.charAt(i + 1) == '\n')
906
+ i++;
907
+ row.add(field.toString());
908
+ field.setLength(0);
909
+ rows.add(row);
910
+ row = new ArrayList<>();
911
+ } else {
912
+ field.append(c);
913
+ }
914
+ }
915
+ if (field.length() > 0 || !row.isEmpty()) {
916
+ row.add(field.toString());
917
+ rows.add(row);
918
+ }
919
+ return rows;
920
+ }
921
+
738
922
  static boolean isChecked(WebElement element) {
739
923
  String type = String.valueOf(element.getDomAttribute("type")).toLowerCase();
740
924
  if ("input".equalsIgnoreCase(element.getTagName()) && (type.equals("checkbox") || type.equals("radio")))