1 /*
   2  * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.
   3  * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
   4  *
   5  * This code is free software; you can redistribute it and/or modify it
   6  * under the terms of the GNU General Public License version 2 only, as
   7  * published by the Free Software Foundation.
   8  *
   9  * This code is distributed in the hope that it will be useful, but WITHOUT
  10  * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
  11  * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
  12  * version 2 for more details (a copy is included in the LICENSE file that
  13  * accompanied this code).
  14  *
  15  * You should have received a copy of the GNU General Public License version
  16  * 2 along with this work; if not, write to the Free Software Foundation,
  17  * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
  18  *
  19  * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
  20  * or visit www.oracle.com if you need additional information or have any
  21  * questions.
  22  */
  23 package jdk.jpackage.test;
  24 
  25 import java.io.FileOutputStream;
  26 import java.io.IOException;
  27 import java.io.PrintStream;
  28 import java.lang.reflect.InvocationTargetException;
  29 import java.nio.file.*;
  30 import static java.nio.file.StandardWatchEventKinds.ENTRY_CREATE;
  31 import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY;
  32 import java.util.*;
  33 import java.util.concurrent.TimeUnit;
  34 import java.util.concurrent.atomic.AtomicInteger;
  35 import java.util.function.BiPredicate;
  36 import java.util.function.Consumer;
  37 import java.util.function.Predicate;
  38 import java.util.function.Supplier;
  39 import java.util.stream.Collectors;
  40 import java.util.stream.Stream;
  41 import jdk.jpackage.test.Functional.ExceptionBox;
  42 import jdk.jpackage.test.Functional.ThrowingConsumer;
  43 import jdk.jpackage.test.Functional.ThrowingRunnable;
  44 import jdk.jpackage.test.Functional.ThrowingSupplier;
  45 
  46 final public class TKit {
  47 
  48     private static final String OS = System.getProperty("os.name").toLowerCase();
  49 
  50     public static final Path TEST_SRC_ROOT = Functional.identity(() -> {
  51         Path root = Path.of(System.getProperty("test.src"));
  52 
  53         for (int i = 0; i != 10; ++i) {
  54             if (root.resolve("apps").toFile().isDirectory()) {
  55                 return root.toAbsolutePath();
  56             }
  57             root = root.resolve("..");
  58         }
  59 
  60         throw new RuntimeException("Failed to locate apps directory");
  61     }).get();
  62 
  63     public final static String ICON_SUFFIX = Functional.identity(() -> {
  64         if (isOSX()) {
  65             return ".icns";
  66         }
  67 
  68         if (isLinux()) {
  69             return ".png";
  70         }
  71 
  72         if (isWindows()) {
  73             return ".ico";
  74         }
  75 
  76         throw throwUnknownPlatformError();
  77     }).get();
  78 
  79     public static void run(String args[], ThrowingRunnable testBody) {
  80         if (currentTest != null) {
  81             throw new IllegalStateException(
  82                     "Unexpeced nested or concurrent Test.run() call");
  83         }
  84 
  85         TestInstance test = new TestInstance(testBody);
  86         ThrowingRunnable.toRunnable(() -> runTests(List.of(test))).run();
  87         test.rethrowIfSkipped();
  88         if (!test.passed()) {
  89             throw new RuntimeException();
  90         }
  91     }
  92 
  93     static void withExtraLogStream(ThrowingRunnable action) {
  94         if (extraLogStream != null) {
  95             ThrowingRunnable.toRunnable(action).run();
  96         } else {
  97             try (PrintStream logStream = openLogStream()) {
  98                 extraLogStream = logStream;
  99                 ThrowingRunnable.toRunnable(action).run();
 100             } finally {
 101                 extraLogStream = null;
 102             }
 103         }
 104     }
 105 
 106     static void runTests(List<TestInstance> tests) {
 107         if (currentTest != null) {
 108             throw new IllegalStateException(
 109                     "Unexpeced nested or concurrent Test.run() call");
 110         }
 111 
 112         withExtraLogStream(() -> {
 113             tests.stream().forEach(test -> {
 114                 currentTest = test;
 115                 try {
 116                     ignoreExceptions(test).run();
 117                 } finally {
 118                     currentTest = null;
 119                     if (extraLogStream != null) {
 120                         extraLogStream.flush();
 121                     }
 122                 }
 123             });
 124         });
 125     }
 126 
 127     static Runnable ignoreExceptions(ThrowingRunnable action) {
 128         return () -> {
 129             try {
 130                 try {
 131                     action.run();
 132                 } catch (Throwable ex) {
 133                     unbox(ex);
 134                 }
 135             } catch (Throwable throwable) {
 136                 printStackTrace(throwable);
 137             }
 138         };
 139     }
 140 
 141     static void unbox(Throwable throwable) throws Throwable {
 142         try {
 143             throw throwable;
 144         } catch (ExceptionBox | InvocationTargetException ex) {
 145             unbox(ex.getCause());
 146         }
 147     }
 148 
 149     public static Path workDir() {
 150         return currentTest.workDir();
 151     }
 152 
 153     static Path defaultInputDir() {
 154         return workDir().resolve("input");
 155     }
 156 
 157     static Path defaultOutputDir() {
 158         return workDir().resolve("output");
 159     }
 160 
 161     static String getCurrentDefaultAppName() {
 162         // Construct app name from swapping and joining test base name
 163         // and test function name.
 164         // Say the test name is `FooTest.testBasic`. Then app name would be `BasicFooTest`.
 165         String appNamePrefix = currentTest.functionName();
 166         if (appNamePrefix != null && appNamePrefix.startsWith("test")) {
 167             appNamePrefix = appNamePrefix.substring("test".length());
 168         }
 169         return Stream.of(appNamePrefix, currentTest.baseName()).filter(
 170                 v -> v != null && !v.isEmpty()).collect(Collectors.joining());
 171     }
 172 
 173     public static boolean isWindows() {
 174         return (OS.contains("win"));
 175     }
 176 
 177     public static boolean isOSX() {
 178         return (OS.contains("mac"));
 179     }
 180 
 181     public static boolean isLinux() {
 182         return ((OS.contains("nix") || OS.contains("nux")));
 183     }
 184 
 185     static void log(String v) {
 186         System.out.println(v);
 187         if (extraLogStream != null) {
 188             extraLogStream.println(v);
 189         }
 190     }
 191 
 192     public static void createTextFile(Path propsFilename, Collection<String> lines) {
 193         createTextFile(propsFilename, lines.stream());
 194     }
 195 
 196     public static void createTextFile(Path propsFilename, Stream<String> lines) {
 197         trace(String.format("Create [%s] text file...",
 198                 propsFilename.toAbsolutePath().normalize()));
 199         ThrowingRunnable.toRunnable(() -> Files.write(propsFilename,
 200                 lines.peek(TKit::trace).collect(Collectors.toList()))).run();
 201         trace("Done");
 202     }
 203 
 204     public static void createPropertiesFile(Path propsFilename,
 205             Collection<Map.Entry<String, String>> props) {
 206         trace(String.format("Create [%s] properties file...",
 207                 propsFilename.toAbsolutePath().normalize()));
 208         ThrowingRunnable.toRunnable(() -> Files.write(propsFilename,
 209                 props.stream().map(e -> String.join("=", e.getKey(),
 210                 e.getValue())).peek(TKit::trace).collect(Collectors.toList()))).run();
 211         trace("Done");
 212     }
 213 
 214     public static void createPropertiesFile(Path propsFilename,
 215             Map.Entry<String, String>... props) {
 216         createPropertiesFile(propsFilename, List.of(props));
 217     }
 218 
 219     public static void createPropertiesFile(Path propsFilename,
 220             Map<String, String> props) {
 221         createPropertiesFile(propsFilename, props.entrySet());
 222     }
 223 
 224     public static void trace(String v) {
 225         if (TRACE) {
 226             log("TRACE: " + v);
 227         }
 228     }
 229 
 230     private static void traceAssert(String v) {
 231         if (TRACE_ASSERTS) {
 232             log("TRACE: " + v);
 233         }
 234     }
 235 
 236     public static void error(String v) {
 237         log("ERROR: " + v);
 238         throw new AssertionError(v);
 239     }
 240 
 241     private final static String TEMP_FILE_PREFIX = null;
 242 
 243     private static Path createUniqueFileName(String defaultName) {
 244         final String[] nameComponents;
 245 
 246         int separatorIdx = defaultName.lastIndexOf('.');
 247         final String baseName;
 248         if (separatorIdx == -1) {
 249             baseName = defaultName;
 250             nameComponents = new String[]{baseName};
 251         } else {
 252             baseName = defaultName.substring(0, separatorIdx);
 253             nameComponents = new String[]{baseName, defaultName.substring(
 254                 separatorIdx + 1)};
 255         }
 256 
 257         final Path basedir = workDir();
 258         int i = 0;
 259         for (; i < 100; ++i) {
 260             Path path = basedir.resolve(String.join(".", nameComponents));
 261             if (!path.toFile().exists()) {
 262                 return path;
 263             }
 264             nameComponents[0] = String.format("%s.%d", baseName, i);
 265         }
 266         throw new IllegalStateException(String.format(
 267                 "Failed to create unique file name from [%s] basename after %d attempts",
 268                 baseName, i));
 269     }
 270 
 271     public static Path createTempDirectory(String role) throws IOException {
 272         if (role == null) {
 273             return Files.createTempDirectory(workDir(), TEMP_FILE_PREFIX);
 274         }
 275         return Files.createDirectory(createUniqueFileName(role));
 276     }
 277 
 278     public static Path createTempFile(String role, String suffix) throws
 279             IOException {
 280         if (role == null) {
 281             return Files.createTempFile(workDir(), TEMP_FILE_PREFIX, suffix);
 282         }
 283         return Files.createFile(createUniqueFileName(role));
 284     }
 285 
 286     public static Path withTempFile(String role, String suffix,
 287             ThrowingConsumer<Path> action) {
 288         final Path tempFile = ThrowingSupplier.toSupplier(() -> createTempFile(
 289                 role, suffix)).get();
 290         boolean keepIt = true;
 291         try {
 292             ThrowingConsumer.toConsumer(action).accept(tempFile);
 293             keepIt = false;
 294             return tempFile;
 295         } finally {
 296             if (tempFile != null && !keepIt) {
 297                 ThrowingRunnable.toRunnable(() -> Files.deleteIfExists(tempFile)).run();
 298             }
 299         }
 300     }
 301 
 302     public static Path withTempDirectory(String role,
 303             ThrowingConsumer<Path> action) {
 304         final Path tempDir = ThrowingSupplier.toSupplier(
 305                 () -> createTempDirectory(role)).get();
 306         boolean keepIt = true;
 307         try {
 308             ThrowingConsumer.toConsumer(action).accept(tempDir);
 309             keepIt = false;
 310             return tempDir;
 311         } finally {
 312             if (tempDir != null && tempDir.toFile().isDirectory() && !keepIt) {
 313                 deleteDirectoryRecursive(tempDir, "");
 314             }
 315         }
 316     }
 317 
 318     private static class DirectoryCleaner implements Consumer<Path> {
 319         DirectoryCleaner traceMessage(String v) {
 320             msg = v;
 321             return this;
 322         }
 323 
 324         DirectoryCleaner contentsOnly(boolean v) {
 325             contentsOnly = v;
 326             return this;
 327         }
 328 
 329         @Override
 330         public void accept(Path root) {
 331             if (msg == null) {
 332                 if (contentsOnly) {
 333                     msg = String.format("Cleaning [%s] directory recursively",
 334                             root);
 335                 } else {
 336                     msg = String.format("Deleting [%s] directory recursively",
 337                             root);
 338                 }
 339             }
 340 
 341             if (!msg.isEmpty()) {
 342                 trace(msg);
 343             }
 344 
 345             List<Throwable> errors = new ArrayList<>();
 346             try {
 347                 final List<Path> paths;
 348                 if (contentsOnly) {
 349                     try (var pathStream = Files.walk(root, 0)) {
 350                         paths = pathStream.collect(Collectors.toList());
 351                     }
 352                 } else {
 353                     paths = List.of(root);
 354                 }
 355 
 356                 for (var path : paths) {
 357                     try (var pathStream = Files.walk(path)) {
 358                         pathStream
 359                         .sorted(Comparator.reverseOrder())
 360                         .sequential()
 361                         .forEachOrdered(file -> {
 362                             try {
 363                                 if (isWindows()) {
 364                                     Files.setAttribute(file, "dos:readonly", false);
 365                                 }
 366                                 Files.delete(file);
 367                             } catch (IOException ex) {
 368                                 errors.add(ex);
 369                             }
 370                         });
 371                     }
 372                 }
 373 
 374             } catch (IOException ex) {
 375                 errors.add(ex);
 376             }
 377             errors.forEach(error -> trace(error.toString()));
 378         }
 379 
 380         private String msg;
 381         private boolean contentsOnly;
 382     }
 383 
 384     /**
 385      * Deletes contents of the given directory recursively. Shortcut for
 386      * <code>deleteDirectoryContentsRecursive(path, null)</code>
 387      *
 388      * @param path path to directory to clean
 389      */
 390     public static void deleteDirectoryContentsRecursive(Path path) {
 391         deleteDirectoryContentsRecursive(path, null);
 392     }
 393 
 394     /**
 395      * Deletes contents of the given directory recursively. If <code>path<code> is not a
 396      * directory, request is silently ignored.
 397      *
 398      * @param path path to directory to clean
 399      * @param msg log message. If null, the default log message is used. If
 400      * empty string, no log message will be saved.
 401      */
 402     public static void deleteDirectoryContentsRecursive(Path path, String msg) {
 403         if (path.toFile().isDirectory()) {
 404             new DirectoryCleaner().contentsOnly(true).traceMessage(msg).accept(
 405                     path);
 406         }
 407     }
 408 
 409     /**
 410      * Deletes the given directory recursively. Shortcut for
 411      * <code>deleteDirectoryRecursive(path, null)</code>
 412      *
 413      * @param path path to directory to delete
 414      */
 415     public static void deleteDirectoryRecursive(Path path) {
 416         deleteDirectoryRecursive(path, null);
 417     }
 418 
 419     /**
 420      * Deletes the given directory recursively. If <code>path<code> is not a
 421      * directory, request is silently ignored.
 422      *
 423      * @param path path to directory to delete
 424      * @param msg log message. If null, the default log message is used. If
 425      * empty string, no log message will be saved.
 426      */
 427     public static void deleteDirectoryRecursive(Path path, String msg) {
 428         if (path.toFile().isDirectory()) {
 429             new DirectoryCleaner().traceMessage(msg).accept(path);
 430         }
 431     }
 432 
 433     public static RuntimeException throwUnknownPlatformError() {
 434         if (isWindows() || isLinux() || isOSX()) {
 435             throw new IllegalStateException(
 436                     "Platform is known. throwUnknownPlatformError() called by mistake");
 437         }
 438         throw new IllegalStateException("Unknown platform");
 439     }
 440 
 441     public static RuntimeException throwSkippedException(String reason) {
 442         trace("Skip the test: " + reason);
 443         RuntimeException ex = ThrowingSupplier.toSupplier(
 444                 () -> (RuntimeException) Class.forName("jtreg.SkippedException").getConstructor(
 445                         String.class).newInstance(reason)).get();
 446 
 447         currentTest.notifySkipped(ex);
 448         throw ex;
 449     }
 450 
 451     public static Path createRelativePathCopy(final Path file) {
 452         Path fileCopy = workDir().resolve(file.getFileName()).toAbsolutePath().normalize();
 453 
 454         ThrowingRunnable.toRunnable(() -> Files.copy(file, fileCopy,
 455                 StandardCopyOption.REPLACE_EXISTING)).run();
 456 
 457         final Path basePath = Path.of(".").toAbsolutePath().normalize();
 458         try {
 459             return basePath.relativize(fileCopy);
 460         } catch (IllegalArgumentException ex) {
 461             // May happen on Windows: java.lang.IllegalArgumentException: 'other' has different root
 462             trace(String.format("Failed to relativize [%s] at [%s]", fileCopy,
 463                     basePath));
 464             printStackTrace(ex);
 465         }
 466         return file;
 467     }
 468 
 469     static void waitForFileCreated(Path fileToWaitFor,
 470             long timeoutSeconds) throws IOException {
 471 
 472         trace(String.format("Wait for file [%s] to be available",
 473                                                 fileToWaitFor.toAbsolutePath()));
 474 
 475         WatchService ws = FileSystems.getDefault().newWatchService();
 476 
 477         Path watchDirectory = fileToWaitFor.toAbsolutePath().getParent();
 478         watchDirectory.register(ws, ENTRY_CREATE, ENTRY_MODIFY);
 479 
 480         long waitUntil = System.currentTimeMillis() + timeoutSeconds * 1000;
 481         for (;;) {
 482             long timeout = waitUntil - System.currentTimeMillis();
 483             assertTrue(timeout > 0, String.format(
 484                     "Check timeout value %d is positive", timeout));
 485 
 486             WatchKey key = ThrowingSupplier.toSupplier(() -> ws.poll(timeout,
 487                     TimeUnit.MILLISECONDS)).get();
 488             if (key == null) {
 489                 if (fileToWaitFor.toFile().exists()) {
 490                     trace(String.format(
 491                             "File [%s] is available after poll timeout expired",
 492                             fileToWaitFor));
 493                     return;
 494                 }
 495                 assertUnexpected(String.format("Timeout expired", timeout));
 496             }
 497 
 498             for (WatchEvent<?> event : key.pollEvents()) {
 499                 if (event.kind() == StandardWatchEventKinds.OVERFLOW) {
 500                     continue;
 501                 }
 502                 Path contextPath = (Path) event.context();
 503                 if (Files.isSameFile(watchDirectory.resolve(contextPath),
 504                         fileToWaitFor)) {
 505                     trace(String.format("File [%s] is available", fileToWaitFor));
 506                     return;
 507                 }
 508             }
 509 
 510             if (!key.reset()) {
 511                 assertUnexpected("Watch key invalidated");
 512             }
 513         }
 514     }
 515 
 516     static void printStackTrace(Throwable throwable) {
 517         if (extraLogStream != null) {
 518             throwable.printStackTrace(extraLogStream);
 519         }
 520         throwable.printStackTrace();
 521     }
 522 
 523     private static String concatMessages(String msg, String msg2) {
 524         if (msg2 != null && !msg2.isBlank()) {
 525             return msg + ": " + msg2;
 526         }
 527         return msg;
 528     }
 529 
 530     public static void assertEquals(long expected, long actual, String msg) {
 531         currentTest.notifyAssert();
 532         if (expected != actual) {
 533             error(concatMessages(String.format(
 534                     "Expected [%d]. Actual [%d]", expected, actual),
 535                     msg));
 536         }
 537 
 538         traceAssert(String.format("assertEquals(%d): %s", expected, msg));
 539     }
 540 
 541     public static void assertNotEquals(long expected, long actual, String msg) {
 542         currentTest.notifyAssert();
 543         if (expected == actual) {
 544             error(concatMessages(String.format("Unexpected [%d] value", actual),
 545                     msg));
 546         }
 547 
 548         traceAssert(String.format("assertNotEquals(%d, %d): %s", expected,
 549                 actual, msg));
 550     }
 551 
 552     public static void assertEquals(String expected, String actual, String msg) {
 553         currentTest.notifyAssert();
 554         if ((actual != null && !actual.equals(expected))
 555                 || (expected != null && !expected.equals(actual))) {
 556             error(concatMessages(String.format(
 557                     "Expected [%s]. Actual [%s]", expected, actual),
 558                     msg));
 559         }
 560 
 561         traceAssert(String.format("assertEquals(%s): %s", expected, msg));
 562     }
 563 
 564     public static void assertNotEquals(String expected, String actual, String msg) {
 565         currentTest.notifyAssert();
 566         if ((actual != null && !actual.equals(expected))
 567                 || (expected != null && !expected.equals(actual))) {
 568 
 569             traceAssert(String.format("assertNotEquals(%s, %s): %s", expected,
 570                 actual, msg));
 571             return;
 572         }
 573 
 574         error(concatMessages(String.format("Unexpected [%s] value", actual), msg));
 575     }
 576 
 577     public static void assertNull(Object value, String msg) {
 578         currentTest.notifyAssert();
 579         if (value != null) {
 580             error(concatMessages(String.format("Unexpected not null value [%s]",
 581                     value), msg));
 582         }
 583 
 584         traceAssert(String.format("assertNull(): %s", msg));
 585     }
 586 
 587     public static void assertNotNull(Object value, String msg) {
 588         currentTest.notifyAssert();
 589         if (value == null) {
 590             error(concatMessages("Unexpected null value", msg));
 591         }
 592 
 593         traceAssert(String.format("assertNotNull(%s): %s", value, msg));
 594     }
 595 
 596     public static void assertTrue(boolean actual, String msg) {
 597         assertTrue(actual, msg, null);
 598     }
 599 
 600     public static void assertFalse(boolean actual, String msg) {
 601         assertFalse(actual, msg, null);
 602     }
 603 
 604     public static void assertTrue(boolean actual, String msg, Runnable onFail) {
 605         currentTest.notifyAssert();
 606         if (!actual) {
 607             if (onFail != null) {
 608                 onFail.run();
 609             }
 610             error(concatMessages("Failed", msg));
 611         }
 612 
 613         traceAssert(String.format("assertTrue(): %s", msg));
 614     }
 615 
 616     public static void assertFalse(boolean actual, String msg, Runnable onFail) {
 617         currentTest.notifyAssert();
 618         if (actual) {
 619             if (onFail != null) {
 620                 onFail.run();
 621             }
 622             error(concatMessages("Failed", msg));
 623         }
 624 
 625         traceAssert(String.format("assertFalse(): %s", msg));
 626     }
 627 
 628     public static void assertPathExists(Path path, boolean exists) {
 629         if (exists) {
 630             assertTrue(path.toFile().exists(), String.format(
 631                     "Check [%s] path exists", path));
 632         } else {
 633             assertFalse(path.toFile().exists(), String.format(
 634                     "Check [%s] path doesn't exist", path));
 635         }
 636     }
 637 
 638      public static void assertDirectoryExists(Path path) {
 639         assertPathExists(path, true);
 640         assertTrue(path.toFile().isDirectory(), String.format(
 641                 "Check [%s] is a directory", path));
 642     }
 643 
 644     public static void assertFileExists(Path path) {
 645         assertPathExists(path, true);
 646         assertTrue(path.toFile().isFile(), String.format("Check [%s] is a file",
 647                 path));
 648     }
 649 
 650     public static void assertExecutableFileExists(Path path) {
 651         assertFileExists(path);
 652         assertTrue(path.toFile().canExecute(), String.format(
 653                 "Check [%s] file is executable", path));
 654     }
 655 
 656     public static void assertReadableFileExists(Path path) {
 657         assertFileExists(path);
 658         assertTrue(path.toFile().canRead(), String.format(
 659                 "Check [%s] file is readable", path));
 660     }
 661 
 662     public static void assertUnexpected(String msg) {
 663         currentTest.notifyAssert();
 664         error(concatMessages("Unexpected", msg));
 665     }
 666 
 667     public static void assertStringListEquals(List<String> expected,
 668             List<String> actual, String msg) {
 669         currentTest.notifyAssert();
 670 
 671         traceAssert(String.format("assertStringListEquals(): %s", msg));
 672 
 673         String idxFieldFormat = Functional.identity(() -> {
 674             int listSize = expected.size();
 675             int width = 0;
 676             while (listSize != 0) {
 677                 listSize = listSize / 10;
 678                 width++;
 679             }
 680             return "%" + width + "d";
 681         }).get();
 682 
 683         AtomicInteger counter = new AtomicInteger(0);
 684         Iterator<String> actualIt = actual.iterator();
 685         expected.stream().sequential().filter(expectedStr -> actualIt.hasNext()).forEach(expectedStr -> {
 686             int idx = counter.incrementAndGet();
 687             String actualStr = actualIt.next();
 688 
 689             if ((actualStr != null && !actualStr.equals(expectedStr))
 690                     || (expectedStr != null && !expectedStr.equals(actualStr))) {
 691                 error(concatMessages(String.format(
 692                         "(" + idxFieldFormat + ") Expected [%s]. Actual [%s]",
 693                         idx, expectedStr, actualStr), msg));
 694             }
 695 
 696             traceAssert(String.format(
 697                     "assertStringListEquals(" + idxFieldFormat + ", %s)", idx,
 698                     expectedStr));
 699         });
 700 
 701         if (expected.size() < actual.size()) {
 702             // Actual string list is longer than expected
 703             error(concatMessages(String.format(
 704                     "Actual list is longer than expected by %d elements",
 705                     actual.size() - expected.size()), msg));
 706         }
 707 
 708         if (actual.size() < expected.size()) {
 709             // Actual string list is shorter than expected
 710             error(concatMessages(String.format(
 711                     "Actual list is longer than expected by %d elements",
 712                     expected.size() - actual.size()), msg));
 713         }
 714     }
 715 
 716     public final static class TextStreamAsserter {
 717         TextStreamAsserter(String value) {
 718             this.value = value;
 719             predicate(String::contains);
 720         }
 721 
 722         public TextStreamAsserter label(String v) {
 723             label = v;
 724             return this;
 725         }
 726 
 727         public TextStreamAsserter predicate(BiPredicate<String, String> v) {
 728             predicate = v;
 729             return this;
 730         }
 731 
 732         public TextStreamAsserter negate() {
 733             negate = true;
 734             return this;
 735         }
 736 
 737         public TextStreamAsserter orElseThrow(RuntimeException v) {
 738             return orElseThrow(() -> v);
 739         }
 740 
 741         public TextStreamAsserter orElseThrow(Supplier<RuntimeException> v) {
 742             createException = v;
 743             return this;
 744         }
 745 
 746         public void apply(Stream<String> lines) {
 747             String matchedStr = lines.filter(line -> predicate.test(line, value)).findFirst().orElse(
 748                     null);
 749             String labelStr = Optional.ofNullable(label).orElse("output");
 750             if (negate) {
 751                 String msg = String.format(
 752                         "Check %s doesn't contain [%s] string", labelStr, value);
 753                 if (createException == null) {
 754                     assertNull(matchedStr, msg);
 755                 } else {
 756                     trace(msg);
 757                     if (matchedStr != null) {
 758                         throw createException.get();
 759                     }
 760                 }
 761             } else {
 762                 String msg = String.format("Check %s contains [%s] string",
 763                         labelStr, value);
 764                 if (createException == null) {
 765                     assertNotNull(matchedStr, msg);
 766                 } else {
 767                     trace(msg);
 768                     if (matchedStr == null) {
 769                         throw createException.get();
 770                     }
 771                 }
 772             }
 773         }
 774 
 775         private BiPredicate<String, String> predicate;
 776         private String label;
 777         private boolean negate;
 778         private Supplier<RuntimeException> createException;
 779         final private String value;
 780     }
 781 
 782     public static TextStreamAsserter assertTextStream(String what) {
 783         return new TextStreamAsserter(what);
 784     }
 785 
 786     private static PrintStream openLogStream() {
 787         if (LOG_FILE == null) {
 788             return null;
 789         }
 790 
 791         return ThrowingSupplier.toSupplier(() -> new PrintStream(
 792                 new FileOutputStream(LOG_FILE.toFile(), true))).get();
 793     }
 794 
 795     private static TestInstance currentTest;
 796     private static PrintStream extraLogStream;
 797 
 798     private static final boolean TRACE;
 799     private static final boolean TRACE_ASSERTS;
 800 
 801     static final boolean VERBOSE_JPACKAGE;
 802     static final boolean VERBOSE_TEST_SETUP;
 803 
 804     static String getConfigProperty(String propertyName) {
 805         return System.getProperty(getConfigPropertyName(propertyName));
 806     }
 807 
 808     static String getConfigPropertyName(String propertyName) {
 809         return "jpackage.test." + propertyName;
 810     }
 811 
 812     static Set<String> tokenizeConfigProperty(String propertyName) {
 813         final String val = TKit.getConfigProperty(propertyName);
 814         if (val == null) {
 815             return null;
 816         }
 817         return Stream.of(val.toLowerCase().split(",")).map(String::strip).filter(
 818                 Predicate.not(String::isEmpty)).collect(Collectors.toSet());
 819     }
 820 
 821     static final Path LOG_FILE = Functional.identity(() -> {
 822         String val = getConfigProperty("logfile");
 823         if (val == null) {
 824             return null;
 825         }
 826         return Path.of(val);
 827     }).get();
 828 
 829     static {
 830         Set<String> logOptions = tokenizeConfigProperty("suppress-logging");
 831         if (logOptions == null) {
 832             TRACE = true;
 833             TRACE_ASSERTS = true;
 834             VERBOSE_JPACKAGE = true;
 835             VERBOSE_TEST_SETUP = true;
 836         } else if (logOptions.contains("all")) {
 837             TRACE = false;
 838             TRACE_ASSERTS = false;
 839             VERBOSE_JPACKAGE = false;
 840             VERBOSE_TEST_SETUP = false;
 841         } else {
 842             Predicate<Set<String>> isNonOf = options -> {
 843                 return Collections.disjoint(logOptions, options);
 844             };
 845 
 846             TRACE = isNonOf.test(Set.of("trace", "t"));
 847             TRACE_ASSERTS = isNonOf.test(Set.of("assert", "a"));
 848             VERBOSE_JPACKAGE = isNonOf.test(Set.of("jpackage", "jp"));
 849             VERBOSE_TEST_SETUP = isNonOf.test(Set.of("init", "i"));
 850         }
 851     }
 852 }