diff --git a/junit-source-launcher/.gitignore b/junit-source-launcher/.gitignore new file mode 100644 index 00000000..dc6bf831 --- /dev/null +++ b/junit-source-launcher/.gitignore @@ -0,0 +1,3 @@ +out/ + +*.jar diff --git a/junit-source-launcher/README.md b/junit-source-launcher/README.md new file mode 100644 index 00000000..ebec5028 --- /dev/null +++ b/junit-source-launcher/README.md @@ -0,0 +1,38 @@ +# junit-source-launcher + +Starting with Java 25 it is possible to write minimal source code test programs using the `org.junit.start` module. +For example, take a look at the [HelloTests.java](src/HelloTests.java) file reading: + +```java +import module org.junit.start; + +void main() { + JUnit.run(); +} + +@Test +void stringLength() { + Assertions.assertEquals(11, "Hello JUnit".length()); +} +``` + +Download `org.junit.start` module and its transitively required modules into a local `lib/` directory by running in a shell: + +```shell +java lib/DownloadRequiredModules.java +``` + +With all required modular JAR files available in a local `lib/` directory, the following Java command will discover and execute tests using the JUnit Platform. + +```shell +java --module-path lib --add-modules org.junit.start src/HelloTests.java +``` + +It will also print the result tree to the console. + +```text +╷ +└─ JUnit Jupiter ✔ + └─ HelloTests ✔ + └─ stringLength() ✔ +``` diff --git a/junit-source-launcher/lib/DownloadRequiredModules.java b/junit-source-launcher/lib/DownloadRequiredModules.java new file mode 100644 index 00000000..e97f58d3 --- /dev/null +++ b/junit-source-launcher/lib/DownloadRequiredModules.java @@ -0,0 +1,118 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +final Path lib = Path.of("lib"); // local directory to be used in module path +final Set roots = Set.of("org.junit.start"); // single root module to lookup +final String version = "6.1.0-M1"; // of JUnit Framework +final String repository = "https://repo.maven.apache.org/maven2"; // of JUnit Framework +final String lookup = + //language=Properties + """ + org.apiguardian.api=https://repo.maven.apache.org/maven2/org/apiguardian/apiguardian-api/1.1.2/apiguardian-api-1.1.2.jar + org.jspecify=https://repo.maven.apache.org/maven2/org/jspecify/jspecify/1.0.0/jspecify-1.0.0.jar + org.junit.jupiter.api={{repository}}/org/junit/jupiter/junit-jupiter-api/{{version}}/junit-jupiter-api-{{version}}.jar + org.junit.jupiter.engine={{repository}}/org/junit/jupiter/junit-jupiter-engine/{{version}}/junit-jupiter-engine-{{version}}.jar + org.junit.jupiter.params={{repository}}/org/junit/jupiter/junit-jupiter-params/{{version}}/junit-jupiter-params-{{version}}.jar + org.junit.jupiter={{repository}}/org/junit/jupiter/junit-jupiter/{{version}}/junit-jupiter-{{version}}.jar + org.junit.platform.commons={{repository}}/org/junit/platform/junit-platform-commons/{{version}}/junit-platform-commons-{{version}}.jar + org.junit.platform.console={{repository}}/org/junit/platform/junit-platform-console/{{version}}/junit-platform-console-{{version}}.jar + org.junit.platform.engine={{repository}}/org/junit/platform/junit-platform-engine/{{version}}/junit-platform-engine-{{version}}.jar + org.junit.platform.launcher={{repository}}/org/junit/platform/junit-platform-launcher/{{version}}/junit-platform-launcher-{{version}}.jar + org.junit.platform.reporting={{repository}}/org/junit/platform/junit-platform-reporting/{{version}}/junit-platform-reporting-{{version}}.jar + org.junit.platform.suite.api={{repository}}/org/junit/platform/junit-platform-suite-api/{{version}}/junit-platform-suite-api-{{version}}.jar + org.junit.platform.suite.engine={{repository}}/org/junit/platform/junit-platform-suite-engine/{{version}}/junit-platform-suite-engine-{{version}}.jar + org.junit.platform.suite={{repository}}/org/junit/platform/junit-platform-suite/{{version}}/junit-platform-suite-{{version}}.jar + org.junit.start={{repository}}/org/junit/junit-start/{{version}}/junit-start-{{version}}.jar + org.opentest4j.reporting.tooling.spi=https://repo.maven.apache.org/maven2/org/opentest4j/reporting/open-test-reporting-tooling-spi/0.2.5/open-test-reporting-tooling-spi-0.2.5.jar + org.opentest4j=https://repo.maven.apache.org/maven2/org/opentest4j/opentest4j/1.3.0/opentest4j-1.3.0.jar + """ + .replace("{{repository}}", repository) + .replace("{{version}}", version); + +void main() throws Exception { + // Ensure being launched inside expected working directory + var program = Path.of("src", "HelloTests.java"); + if (!Files.exists(program)) { + throw new AssertionError("Expected %s in current working directory".formatted(program)); + } + + // Read mapping file to locate remote modules + var properties = new Properties(); + properties.load(new StringReader(lookup)); + + // Create and initialize lib directory with root module(s) + Files.createDirectories(lib); + downloadModules(roots, properties); + + // Compute missing modules and download them transitively + var missing = computeMissingModuleNames(); + while (!missing.isEmpty()) { + downloadModules(missing, properties); + missing = computeMissingModuleNames(); + } + + IO.println("%nList modules of %s directory".formatted(lib)); + listModules(); +} + +void downloadModules(Set names, Properties properties) { + IO.println("Downloading %d module%s".formatted(names.size(), names.size() == 1 ? "" : "s")); + names.stream().parallel().forEach(name -> { + var target = lib.resolve(name + ".jar"); + if (Files.exists(target)) return; // Don't overwrite existing JAR file + var source = URI.create(properties.getProperty(name)); + try (var stream = source.toURL().openStream()) { + IO.println(name + " <- " + source + "..."); + Files.copy(stream, target); + } catch (IOException cause) { + throw new UncheckedIOException(cause); + } + }); + // Ensure that every name can be found to avoid eternal loops + var finder = ModuleFinder.of(lib); + var remainder = new TreeSet<>(names); + remainder.removeIf(name -> finder.find(name).isPresent()); + if (remainder.isEmpty()) return; + throw new AssertionError("Modules not downloaded: " + remainder); +} + +Set computeMissingModuleNames() { + var system = ModuleFinder.ofSystem(); + var finder = ModuleFinder.of(lib); + var names = + finder.findAll().stream() + .parallel() + .map(ModuleReference::descriptor) + .map(ModuleDescriptor::requires) + .flatMap(Collection::stream) + .filter(this::mustBePresentAtCompileTime) + .map(ModuleDescriptor.Requires::name) + .filter(name -> finder.find(name).isEmpty()) + .filter(name -> system.find(name).isEmpty()) + .toList(); + return new TreeSet<>(names); +} + +boolean mustBePresentAtCompileTime(ModuleDescriptor.Requires requires) { + var isStatic = requires.modifiers().contains(ModuleDescriptor.Requires.Modifier.STATIC); + var isTransitive = requires.modifiers().contains(ModuleDescriptor.Requires.Modifier.TRANSITIVE); + return !isStatic || isTransitive; +} + +void listModules() { + var finder = ModuleFinder.of(lib); + var modules = finder.findAll(); + modules.stream() + .map(ModuleReference::descriptor) + .map(ModuleDescriptor::toNameAndVersion) + .sorted() + .forEach(IO::println); + IO.println(" %d modules".formatted(modules.size())); +} diff --git a/junit-source-launcher/src/HelloTests.java b/junit-source-launcher/src/HelloTests.java new file mode 100644 index 00000000..3eaef954 --- /dev/null +++ b/junit-source-launcher/src/HelloTests.java @@ -0,0 +1,20 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +import module org.junit.start; + +void main() { + JUnit.run(); +} + +@Test +void stringLength() { + Assertions.assertEquals(11, "Hello JUnit".length()); +} diff --git a/junit-source-launcher/src/junit-platform.properties b/junit-source-launcher/src/junit-platform.properties new file mode 100644 index 00000000..731f4d5c --- /dev/null +++ b/junit-source-launcher/src/junit-platform.properties @@ -0,0 +1,6 @@ +## +## Enable JUnit Platform Reporting +## -> https://docs.junit.org/current/user-guide/#junit-platform-reporting +## +# junit.platform.reporting.output.dir=out/junit-{uniqueNumber} +# junit.platform.reporting.open.xml.enabled=true diff --git a/src/Builder.java b/src/Builder.java index d05c55a0..14f0b7cc 100644 --- a/src/Builder.java +++ b/src/Builder.java @@ -77,6 +77,13 @@ int build(Target target, Set excludedProjects) { // modular runProject(excludedProjects, "junit-modular-world", "java", modularAction); + // source launcher + runProject(excludedProjects, "junit-source-launcher", "java", "lib/DownloadRequiredModules.java"); + runProject(excludedProjects, "junit-source-launcher", + "java", + "--module-path", "lib", + "--add-modules", "org.junit.start", + "src/HelloTests.java"); System.out.printf("%n%n%n|%n| Done. Build exits with status = %d.%n|%n", status); return status; } @@ -141,7 +148,7 @@ void run(String directory, String executable, String... args) { } boolean isWindows() { - return System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("win"); + return System.getProperty("os.name").toLowerCase(Locale.ROOT).startsWith("win"); } void checkLicense(String blueprint, String... extensions) { diff --git a/src/StagingRepoInjector.java b/src/StagingRepoInjector.java index 3044f5f1..d65739e0 100644 --- a/src/StagingRepoInjector.java +++ b/src/StagingRepoInjector.java @@ -82,6 +82,9 @@ private void inject() throws Exception { appendAfter("junit-multiple-engines/build.gradle.kts", "mavenCentral()", gradleKotlinDslSnippet); + + replace("junit-source-launcher/lib/DownloadRequiredModules.java", "String repository = \"https://repo.maven.apache.org/maven\"", + "String repository = \"%s\"".formatted(stagingRepoUrl)); } void appendAfter(String path, String token, String addedContent) throws IOException { diff --git a/src/Updater.java b/src/Updater.java index 5c5caf0f..66b0f22f 100644 --- a/src/Updater.java +++ b/src/Updater.java @@ -17,7 +17,7 @@ import java.util.regex.Pattern; /** - * Updates the versions of JUnit Platform artifacts in all example projects. + * Updates the versions of JUnit Framework artifacts in all example projects. */ @SuppressWarnings({"WeakerAccess", "SameParameterValue"}) class Updater { @@ -72,6 +72,9 @@ void update() throws IOException { update(Path.of("junit-multiple-engines/build.gradle.kts"), List.of( Pattern.compile("junitBomVersion = \"" + VERSION_REGEX + '"') )); + update(Path.of("junit-source-launcher/lib/DownloadRequiredModules.java"), List.of( + Pattern.compile("final String version = \"" + VERSION_REGEX + '\"') + )); } void update(Path path, List patterns) throws IOException {