diff --git a/documentation/src/docs/asciidoc/release-notes/release-notes-5.11.0-RC1.adoc b/documentation/src/docs/asciidoc/release-notes/release-notes-5.11.0-RC1.adoc index 852cb29531bd..0ae91bdda350 100644 --- a/documentation/src/docs/asciidoc/release-notes/release-notes-5.11.0-RC1.adoc +++ b/documentation/src/docs/asciidoc/release-notes/release-notes-5.11.0-RC1.adoc @@ -29,6 +29,7 @@ repository on GitHub. * New `rootCause()` condition in `TestExecutionResultConditions` that matches if an exception's _root_ cause matches all supplied conditions, for use with the `EngineTestKit`. +* `ReflectionSupport` now supports scanning for classpath resources. [[release-notes-5.11.0-RC1-junit-jupiter]] diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/support/ReflectionSupport.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/ReflectionSupport.java index ab7c194fe35a..6d63e015fccc 100644 --- a/junit-platform-commons/src/main/java/org/junit/platform/commons/support/ReflectionSupport.java +++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/ReflectionSupport.java @@ -67,7 +67,6 @@ private ReflectionSupport() { */ @API(status = DEPRECATED, since = "1.4") @Deprecated - @SuppressWarnings("deprecation") public static Optional> loadClass(String name) { return ReflectionUtils.loadClass(name); } @@ -137,6 +136,28 @@ public static List> findAllClassesInClasspathRoot(URI root, PredicateThe classpath scanning algorithm searches recursively in subpackages + * beginning with the root of the classpath. + * + * @param root the URI for the classpath root in which to scan; never + * {@code null} + * @param resourceFilter the resource type filter; never {@code null} + * @return an immutable list of all such resources found; never {@code null} + * but potentially empty + * @since 1.11 + * @see #findAllResourcesInPackage(String, Predicate) + * @see #findAllResourcesInModule(String, Predicate) + */ + @API(status = EXPERIMENTAL, since = "1.11") + public static List findAllResourcesInClasspathRoot(URI root, Predicate resourceFilter) { + + return ReflectionUtils.findAllResourcesInClasspathRoot(root, resourceFilter); + } + /** * Find all {@linkplain Class classes} in the supplied classpath {@code root} * that match the specified {@code classFilter} and {@code classNameFilter} @@ -162,6 +183,28 @@ public static Stream> streamAllClassesInClasspathRoot(URI root, Predica return ReflectionUtils.streamAllClassesInClasspathRoot(root, classFilter, classNameFilter); } + /** + * Find all {@linkplain Resource resources} in the supplied classpath {@code root} + * that match the specified {@code resourceFilter} predicate. + * + *

The classpath scanning algorithm searches recursively in subpackages + * beginning with the root of the classpath. + * + * @param root the URI for the classpath root in which to scan; never + * {@code null} + * @param resourceFilter the resource type filter; never {@code null} + * @return a stream of all such classes found; never {@code null} + * but potentially empty + * @since 1.11 + * @see #streamAllResourcesInPackage(String, Predicate) + * @see #streamAllResourcesInModule(String, Predicate) + */ + @API(status = EXPERIMENTAL, since = "1.11") + public static Stream streamAllResourcesInClasspathRoot(URI root, Predicate resourceFilter) { + + return ReflectionUtils.streamAllResourcesInClasspathRoot(root, resourceFilter); + } + /** * Find all {@linkplain Class classes} in the supplied {@code basePackageName} * that match the specified {@code classFilter} and {@code classNameFilter} @@ -186,6 +229,29 @@ public static List> findAllClassesInPackage(String basePackageName, Pre return ReflectionUtils.findAllClassesInPackage(basePackageName, classFilter, classNameFilter); } + /** + * Find all {@linkplain Resource resources} in the supplied {@code basePackageName} + * that match the specified {@code resourceFilter} predicate. + * + *

The classpath scanning algorithm searches recursively in subpackages + * beginning within the supplied base package. + * + * @param basePackageName the name of the base package in which to start + * scanning; must not be {@code null} and must be valid in terms of Java + * syntax + * @param resourceFilter the resource type filter; never {@code null} + * @return an immutable list of all such classes found; never {@code null} + * but potentially empty + * @since 1.11 + * @see #findAllResourcesInClasspathRoot(URI, Predicate) + * @see #findAllResourcesInModule(String, Predicate) + */ + @API(status = EXPERIMENTAL, since = "1.11") + public static List findAllResourcesInPackage(String basePackageName, Predicate resourceFilter) { + + return ReflectionUtils.findAllResourcesInPackage(basePackageName, resourceFilter); + } + /** * Find all {@linkplain Class classes} in the supplied {@code basePackageName} * that match the specified {@code classFilter} and {@code classNameFilter} @@ -212,6 +278,30 @@ public static Stream> streamAllClassesInPackage(String basePackageName, return ReflectionUtils.streamAllClassesInPackage(basePackageName, classFilter, classNameFilter); } + /** + * Find all {@linkplain Resource resources} in the supplied {@code basePackageName} + * that match the specified {@code resourceFilter} predicate. + * + *

The classpath scanning algorithm searches recursively in subpackages + * beginning within the supplied base package. + * + * @param basePackageName the name of the base package in which to start + * scanning; must not be {@code null} and must be valid in terms of Java + * syntax + * @param resourceFilter the resource type filter; never {@code null} + * @return a stream of all such resources found; never {@code null} + * but potentially empty + * @since 1.11 + * @see #streamAllResourcesInClasspathRoot(URI, Predicate) + * @see #streamAllResourcesInModule(String, Predicate) + */ + @API(status = EXPERIMENTAL, since = "1.11") + public static Stream streamAllResourcesInPackage(String basePackageName, + Predicate resourceFilter) { + + return ReflectionUtils.streamAllResourcesInPackage(basePackageName, resourceFilter); + } + /** * Find all {@linkplain Class classes} in the supplied {@code moduleName} * that match the specified {@code classFilter} and {@code classNameFilter} @@ -236,6 +326,28 @@ public static List> findAllClassesInModule(String moduleName, Predicate return ReflectionUtils.findAllClassesInModule(moduleName, classFilter, classNameFilter); } + /** + * Find all {@linkplain Resource resources} in the supplied {@code moduleName} + * that match the specified {@code resourceFilter} predicate. + * + *

The module-path scanning algorithm searches recursively in all + * packages contained in the module. + * + * @param moduleName the name of the module to scan; never {@code null} or + * empty + * @param resourceFilter the resource type filter; never {@code null} + * @return an immutable list of all such resources found; never {@code null} + * but potentially empty + * @since 1.11 + * @see #findAllResourcesInClasspathRoot(URI, Predicate) + * @see #findAllResourcesInPackage(String, Predicate) + */ + @API(status = EXPERIMENTAL, since = "1.11") + public static List findAllResourcesInModule(String moduleName, Predicate resourceFilter) { + + return ReflectionUtils.findAllResourcesInModule(moduleName, resourceFilter); + } + /** * Find all {@linkplain Class classes} in the supplied {@code moduleName} * that match the specified {@code classFilter} and {@code classNameFilter} @@ -261,6 +373,28 @@ public static Stream> streamAllClassesInModule(String moduleName, Predi return ReflectionUtils.streamAllClassesInModule(moduleName, classFilter, classNameFilter); } + /** + * Find all {@linkplain Resource resources} in the supplied {@code moduleName} + * that match the specified {@code resourceFilter} predicate. + * + *

The module-path scanning algorithm searches recursively in all + * packages contained in the module. + * + * @param moduleName the name of the module to scan; never {@code null} or + * empty + * @param resourceFilter the resource type filter; never {@code null} + * @return a stream of all such resources found; never {@code null} + * but potentially empty + * @since 1.11 + * @see #streamAllResourcesInClasspathRoot(URI, Predicate) + * @see #streamAllResourcesInPackage(String, Predicate) + */ + @API(status = EXPERIMENTAL, since = "1.11") + public static Stream streamAllResourcesInModule(String moduleName, Predicate resourceFilter) { + + return ReflectionUtils.streamAllResourcesInModule(moduleName, resourceFilter); + } + /** * Create a new instance of the specified {@link Class} by invoking * the constructor whose argument list matches the types of the supplied diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/support/Resource.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/Resource.java new file mode 100644 index 000000000000..c9587c3a5ca6 --- /dev/null +++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/Resource.java @@ -0,0 +1,61 @@ +/* + * Copyright 2015-2024 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 + */ + +package org.junit.platform.commons.support; + +import static org.apiguardian.api.API.Status.EXPERIMENTAL; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.util.function.Predicate; + +import org.apiguardian.api.API; + +/** + * Represents a resource on the classpath. + * @since 1.11 + * @see ReflectionSupport#findAllResourcesInClasspathRoot(URI, Predicate) + * @see ReflectionSupport#findAllResourcesInPackage(String, Predicate) + * @see ReflectionSupport#findAllResourcesInModule(String, Predicate) + * @see ReflectionSupport#streamAllResourcesInClasspathRoot(URI, Predicate) + * @see ReflectionSupport#streamAllResourcesInPackage(String, Predicate) + * @see ReflectionSupport#streamAllResourcesInModule(String, Predicate) + */ +@API(status = EXPERIMENTAL, since = "1.11") +public interface Resource { + + /** + * Get the resource name. + *

+ * The resource name is a {@code /}-separated path. The path is relative to + * the classpath root in which the resource is located. + * + * @return the resource name; never {@code null} + */ + String getName(); + + /** + * Get URI to a resource. + * + * @return the uri of the resource; never {@code null} + */ + URI getUri(); + + /** + * Returns an input stream for reading this resource. + * + * @return an input stream for this resource; never {@code null} + * @throws IOException if an I/O exception occurs + */ + default InputStream getInputStream() throws IOException { + return getUri().toURL().openStream(); + } +} diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ClassFileVisitor.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ClasspathFileVisitor.java similarity index 56% rename from junit-platform-commons/src/main/java/org/junit/platform/commons/util/ClassFileVisitor.java rename to junit-platform-commons/src/main/java/org/junit/platform/commons/util/ClasspathFileVisitor.java index 7a49471c16b1..7f2ad8195085 100644 --- a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ClassFileVisitor.java +++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ClasspathFileVisitor.java @@ -17,32 +17,33 @@ import java.nio.file.Path; import java.nio.file.SimpleFileVisitor; import java.nio.file.attribute.BasicFileAttributes; -import java.util.function.Consumer; +import java.util.function.BiConsumer; +import java.util.function.Predicate; import org.junit.platform.commons.logging.Logger; import org.junit.platform.commons.logging.LoggerFactory; /** - * @since 1.0 + * @since 1.11 */ -class ClassFileVisitor extends SimpleFileVisitor { +class ClasspathFileVisitor extends SimpleFileVisitor { - private static final Logger logger = LoggerFactory.getLogger(ClassFileVisitor.class); + private static final Logger logger = LoggerFactory.getLogger(ClasspathFileVisitor.class); - static final String CLASS_FILE_SUFFIX = ".class"; - private static final String PACKAGE_INFO_FILE_NAME = "package-info" + CLASS_FILE_SUFFIX; - private static final String MODULE_INFO_FILE_NAME = "module-info" + CLASS_FILE_SUFFIX; + private final Path basePath; + private final BiConsumer consumer; + private final Predicate filter; - private final Consumer classFileConsumer; - - ClassFileVisitor(Consumer classFileConsumer) { - this.classFileConsumer = classFileConsumer; + ClasspathFileVisitor(Path basePath, Predicate filter, BiConsumer consumer) { + this.basePath = basePath; + this.filter = filter; + this.consumer = consumer; } @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) { - if (isNotPackageInfo(file) && isNotModuleInfo(file) && isClassFile(file)) { - classFileConsumer.accept(file); + if (filter.test(file)) { + consumer.accept(basePath, file); } return CONTINUE; } @@ -61,16 +62,4 @@ public FileVisitResult postVisitDirectory(Path dir, IOException ex) { return CONTINUE; } - private static boolean isNotPackageInfo(Path path) { - return !path.endsWith(PACKAGE_INFO_FILE_NAME); - } - - private static boolean isNotModuleInfo(Path path) { - return !path.endsWith(MODULE_INFO_FILE_NAME); - } - - private static boolean isClassFile(Path file) { - return file.getFileName().toString().endsWith(CLASS_FILE_SUFFIX); - } - } diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ClasspathFilters.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ClasspathFilters.java new file mode 100644 index 000000000000..7ad6cd3b0682 --- /dev/null +++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ClasspathFilters.java @@ -0,0 +1,45 @@ +/* + * Copyright 2015-2024 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 + */ + +package org.junit.platform.commons.util; + +import java.nio.file.Path; +import java.util.function.Predicate; + +/** + * @since 1.11 + */ +class ClasspathFilters { + + static final String CLASS_FILE_SUFFIX = ".class"; + private static final String PACKAGE_INFO_FILE_NAME = "package-info" + CLASS_FILE_SUFFIX; + private static final String MODULE_INFO_FILE_NAME = "module-info" + CLASS_FILE_SUFFIX; + + static Predicate classFiles() { + return file -> isNotPackageInfo(file) && isNotModuleInfo(file) && isClassFile(file); + } + + static Predicate resourceFiles() { + return file -> !isClassFile(file); + } + + private static boolean isNotPackageInfo(Path path) { + return !path.endsWith(PACKAGE_INFO_FILE_NAME); + } + + private static boolean isNotModuleInfo(Path path) { + return !path.endsWith(MODULE_INFO_FILE_NAME); + } + + private static boolean isClassFile(Path file) { + return file.getFileName().toString().endsWith(CLASS_FILE_SUFFIX); + } + +} diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ClasspathResource.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ClasspathResource.java new file mode 100644 index 000000000000..720c5166c297 --- /dev/null +++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ClasspathResource.java @@ -0,0 +1,55 @@ +/* + * Copyright 2015-2024 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 + */ + +package org.junit.platform.commons.util; + +import java.net.URI; +import java.util.Objects; + +import org.junit.platform.commons.support.Resource; + +/** + * @since 1.11 + */ +class ClasspathResource implements Resource { + + private final String name; + private final URI uri; + + ClasspathResource(String name, URI uri) { + this.name = Preconditions.notNull(name, "name must not be null"); + this.uri = Preconditions.notNull(uri, "uri must not be null"); + } + + @Override + public String getName() { + return name; + } + + @Override + public URI getUri() { + return uri; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + ClasspathResource that = (ClasspathResource) o; + return name.equals(that.name) && uri.equals(that.uri); + } + + @Override + public int hashCode() { + return Objects.hash(name, uri); + } +} diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ClasspathScanner.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ClasspathScanner.java index 3a040c577405..19bec125b93c 100644 --- a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ClasspathScanner.java +++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ClasspathScanner.java @@ -14,7 +14,7 @@ import static java.util.Collections.emptyList; import static java.util.stream.Collectors.joining; import static java.util.stream.Collectors.toList; -import static org.junit.platform.commons.util.ClassFileVisitor.CLASS_FILE_SUFFIX; +import static org.junit.platform.commons.util.ClasspathFilters.CLASS_FILE_SUFFIX; import static org.junit.platform.commons.util.StringUtils.isNotBlank; import java.io.IOException; @@ -28,8 +28,10 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Set; +import java.util.function.BiConsumer; import java.util.function.BiFunction; import java.util.function.Consumer; +import java.util.function.Predicate; import java.util.function.Supplier; import java.util.stream.Stream; @@ -37,6 +39,7 @@ import org.junit.platform.commons.function.Try; import org.junit.platform.commons.logging.Logger; import org.junit.platform.commons.logging.LoggerFactory; +import org.junit.platform.commons.support.Resource; /** *

DISCLAIMER

@@ -80,8 +83,8 @@ List> scanForClassesInPackage(String basePackageName, ClassFilter class Preconditions.notNull(classFilter, "classFilter must not be null"); basePackageName = basePackageName.trim(); - return findClassesForUris(getRootUrisForPackageNameOnClassPathAndModulePath(basePackageName), basePackageName, - classFilter); + List roots = getRootUrisForPackageNameOnClassPathAndModulePath(basePackageName); + return findClassesForUris(roots, basePackageName, classFilter); } List> scanForClassesInClasspathRoot(URI root, ClassFilter classFilter) { @@ -91,8 +94,26 @@ List> scanForClassesInClasspathRoot(URI root, ClassFilter classFilter) return findClassesForUri(root, PackageUtils.DEFAULT_PACKAGE_NAME, classFilter); } + List scanForResourcesInPackage(String basePackageName, Predicate resourceFilter) { + Preconditions.condition( + PackageUtils.DEFAULT_PACKAGE_NAME.equals(basePackageName) || isNotBlank(basePackageName), + "basePackageName must not be null or blank"); + Preconditions.notNull(resourceFilter, "resourceFilter must not be null"); + basePackageName = basePackageName.trim(); + + List roots = getRootUrisForPackageNameOnClassPathAndModulePath(basePackageName); + return findResourcesForUris(roots, basePackageName, resourceFilter); + } + + List scanForResourcesInClasspathRoot(URI root, Predicate resourceFilter) { + Preconditions.notNull(root, "root must not be null"); + Preconditions.notNull(resourceFilter, "resourceFilter must not be null"); + + return findResourcesForUri(root, PackageUtils.DEFAULT_PACKAGE_NAME, resourceFilter); + } + /** - * Recursively scan for classes in all of the supplied source directories. + * Recursively scan for classes in all the supplied source directories. */ private List> findClassesForUris(List baseUris, String basePackageName, ClassFilter classFilter) { // @formatter:off @@ -105,32 +126,59 @@ private List> findClassesForUris(List baseUris, String basePackage } private List> findClassesForUri(URI baseUri, String basePackageName, ClassFilter classFilter) { + List> classes = new ArrayList<>(); + // @formatter:off + walkFilesForUri(baseUri, ClasspathFilters.classFiles(), + (baseDir, file) -> + processClassFileSafely(baseDir, basePackageName, classFilter, file, classes::add)); + // @formatter:on + return classes; + } + + /** + * Recursively scan for resources in all the supplied source directories. + */ + private List findResourcesForUris(List baseUris, String basePackageName, + Predicate resourceFilter) { + // @formatter:off + return baseUris.stream() + .map(baseUri -> findResourcesForUri(baseUri, basePackageName, resourceFilter)) + .flatMap(Collection::stream) + .distinct() + .collect(toList()); + // @formatter:on + } + + private List findResourcesForUri(URI baseUri, String basePackageName, + Predicate resourceFilter) { + List resources = new ArrayList<>(); + // @formatter:off + walkFilesForUri(baseUri, ClasspathFilters.resourceFiles(), + (baseDir, file) -> + processResourceFileSafely(baseDir, basePackageName, resourceFilter, file, resources::add)); + // @formatter:on + return resources; + } + + private static void walkFilesForUri(URI baseUri, Predicate filter, BiConsumer consumer) { try (CloseablePath closeablePath = CloseablePath.create(baseUri)) { Path baseDir = closeablePath.getPath(); - return findClassesForPath(baseDir, basePackageName, classFilter); + Preconditions.condition(Files.exists(baseDir), () -> "baseDir must exist: " + baseDir); + try { + Files.walkFileTree(baseDir, new ClasspathFileVisitor(baseDir, filter, consumer)); + } + catch (IOException ex) { + logger.warn(ex, () -> "I/O error scanning files in " + baseDir); + } } catch (PreconditionViolationException ex) { throw ex; } catch (Exception ex) { logger.warn(ex, () -> "Error scanning files for URI " + baseUri); - return emptyList(); } } - private List> findClassesForPath(Path baseDir, String basePackageName, ClassFilter classFilter) { - Preconditions.condition(Files.exists(baseDir), () -> "baseDir must exist: " + baseDir); - List> classes = new ArrayList<>(); - try { - Files.walkFileTree(baseDir, new ClassFileVisitor( - classFile -> processClassFileSafely(baseDir, basePackageName, classFilter, classFile, classes::add))); - } - catch (IOException ex) { - logger.warn(ex, () -> "I/O error scanning files in " + baseDir); - } - return classes; - } - private void processClassFileSafely(Path baseDir, String basePackageName, ClassFilter classFilter, Path classFile, Consumer> classConsumer) { try { @@ -140,7 +188,8 @@ private void processClassFileSafely(Path baseDir, String basePackageName, ClassF // @formatter:off loadClass.apply(fullyQualifiedClassName, getClassLoader()) .toOptional() - .filter(classFilter) // Always use ".filter(classFilter)" to include future predicates. + // Always use ".filter(classFilter)" to include future predicates. + .filter(classFilter) .ifPresent(classConsumer); // @formatter:on } @@ -154,6 +203,22 @@ private void processClassFileSafely(Path baseDir, String basePackageName, ClassF } } + private void processResourceFileSafely(Path baseDir, String basePackageName, Predicate resourceFilter, + Path resourceFile, Consumer resourceConsumer) { + try { + String fullyQualifiedResourceName = determineFullyQualifiedResourceName(baseDir, basePackageName, + resourceFile); + Resource resource = new ClasspathResource(fullyQualifiedResourceName, resourceFile.toUri()); + if (resourceFilter.test(resource)) { + resourceConsumer.accept(resource); + } + // @formatter:on + } + catch (Throwable throwable) { + handleThrowable(resourceFile, throwable); + } + } + private String determineFullyQualifiedClassName(Path baseDir, String basePackageName, Path classFile) { // @formatter:off return Stream.of( @@ -166,11 +231,34 @@ private String determineFullyQualifiedClassName(Path baseDir, String basePackage // @formatter:on } + /** + * The fully qualified resource name is a {@code /}-separated path. + *

+ * The path is relative to the classpath root in which the resource is located. + + * @return the resource name; never {@code null} + */ + private String determineFullyQualifiedResourceName(Path baseDir, String basePackageName, Path resourceFile) { + // @formatter:off + return Stream.of( + packagePath(basePackageName), + packagePath(determineSubpackageName(baseDir, resourceFile)), + determineSimpleResourceName(resourceFile) + ) + .filter(value -> !value.isEmpty()) // Handle default package appropriately. + .collect(joining(CLASSPATH_RESOURCE_PATH_SEPARATOR_STRING)); + // @formatter:on + } + private String determineSimpleClassName(Path classFile) { String fileName = classFile.getFileName().toString(); return fileName.substring(0, fileName.length() - CLASS_FILE_SUFFIX.length()); } + private String determineSimpleResourceName(Path resourceFile) { + return resourceFile.getFileName().toString(); + } + private String determineSubpackageName(Path baseDir, Path classFile) { Path relativePath = baseDir.relativize(classFile.getParent()); String pathSeparator = baseDir.getFileSystem().getSeparator(); @@ -208,9 +296,9 @@ private void logMalformedClassName(Path classFile, String fullyQualifiedClassNam } } - private void logGenericFileProcessingException(Path classFile, Throwable throwable) { - logger.debug(throwable, () -> format("Failed to load java.lang.Class for path [%s] during classpath scanning.", - classFile.toAbsolutePath())); + private void logGenericFileProcessingException(Path classpathFile, Throwable throwable) { + logger.debug(throwable, + () -> format("Failed to load [%s] during classpath scanning.", classpathFile.toAbsolutePath())); } private ClassLoader getClassLoader() { diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ModuleUtils.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ModuleUtils.java index 8f6b45ce6f56..d24b977d71eb 100644 --- a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ModuleUtils.java +++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ModuleUtils.java @@ -17,10 +17,12 @@ import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.function.Predicate; import org.apiguardian.api.API; import org.junit.platform.commons.logging.Logger; import org.junit.platform.commons.logging.LoggerFactory; +import org.junit.platform.commons.support.Resource; /** * Collection of utilities for working with {@code java.lang.Module} @@ -97,4 +99,23 @@ public static List> findAllClassesInModule(String moduleName, ClassFilt return emptyList(); } + /** + * Find all resources for the given module name. + * + * @param moduleName the name of the module to scan; never {@code null} or + * empty + * @param filter the class filter to apply; never {@code null} + * @return an immutable list of all such resources found; never {@code null} + * but potentially empty + * @since 1.11 + */ + @API(status = INTERNAL, since = "1.11") + public static List findAllResourcesInModule(String moduleName, Predicate filter) { + Preconditions.notBlank(moduleName, "Module name must not be null or empty"); + Preconditions.notNull(filter, "Resource filter must not be null"); + + logger.config(() -> "Basic version of findAllResourcesInModule() always returns an empty list!"); + return emptyList(); + } + } diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ReflectionUtils.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ReflectionUtils.java index 1e8b3fdd2efb..517ee073f273 100644 --- a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ReflectionUtils.java +++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ReflectionUtils.java @@ -59,6 +59,7 @@ import org.junit.platform.commons.function.Try; import org.junit.platform.commons.logging.Logger; import org.junit.platform.commons.logging.LoggerFactory; +import org.junit.platform.commons.support.Resource; /** * Collection of utilities for working with the Java reflection APIs. @@ -1067,6 +1068,13 @@ public static List> findAllClassesInClasspathRoot(URI root, ClassFilter return Collections.unmodifiableList(classpathScanner.scanForClassesInClasspathRoot(root, classFilter)); } + /** + * @since 1.11 + */ + public static List findAllResourcesInClasspathRoot(URI root, Predicate resourceFilter) { + return Collections.unmodifiableList(classpathScanner.scanForResourcesInClasspathRoot(root, resourceFilter)); + } + /** * @since 1.10 */ @@ -1074,6 +1082,13 @@ public static Stream> streamAllClassesInClasspathRoot(URI root, ClassFi return findAllClassesInClasspathRoot(root, classFilter).stream(); } + /** + * @since 1.11 + */ + public static Stream streamAllResourcesInClasspathRoot(URI root, Predicate resourceFilter) { + return findAllResourcesInClasspathRoot(root, resourceFilter).stream(); + } + /** * @see org.junit.platform.commons.support.ReflectionSupport#findAllClassesInPackage(String, Predicate, Predicate) */ @@ -1099,6 +1114,14 @@ public static List> findAllClassesInPackage(String basePackageName, Cla return Collections.unmodifiableList(classpathScanner.scanForClassesInPackage(basePackageName, classFilter)); } + /** + * @since 1.11 + */ + public static List findAllResourcesInPackage(String basePackageName, Predicate resourceFilter) { + return Collections.unmodifiableList( + classpathScanner.scanForResourcesInPackage(basePackageName, resourceFilter)); + } + /** * @since 1.10 */ @@ -1106,6 +1129,14 @@ public static Stream> streamAllClassesInPackage(String basePackageName, return findAllClassesInPackage(basePackageName, classFilter).stream(); } + /** + * @since 1.11 + */ + public static Stream streamAllResourcesInPackage(String basePackageName, + Predicate resourceFilter) { + return findAllResourcesInPackage(basePackageName, resourceFilter).stream(); + } + /** * @since 1.1.1 * @see org.junit.platform.commons.support.ReflectionSupport#findAllClassesInModule(String, Predicate, Predicate) @@ -1132,6 +1163,13 @@ public static List> findAllClassesInModule(String moduleName, ClassFilt return Collections.unmodifiableList(ModuleUtils.findAllClassesInModule(moduleName, classFilter)); } + /** + * @since 1.11 + */ + public static List findAllResourcesInModule(String moduleName, Predicate resourceFilter) { + return Collections.unmodifiableList(ModuleUtils.findAllResourcesInModule(moduleName, resourceFilter)); + } + /** * @since 1.10 */ @@ -1139,6 +1177,13 @@ public static Stream> streamAllClassesInModule(String moduleName, Class return findAllClassesInModule(moduleName, classFilter).stream(); } + /** + * @since 1.11 + */ + public static Stream streamAllResourcesInModule(String moduleName, Predicate resourceFilter) { + return findAllResourcesInModule(moduleName, resourceFilter).stream(); + } + /** * @see org.junit.platform.commons.support.ReflectionSupport#findNestedClasses(Class, Predicate) */ diff --git a/junit-platform-commons/src/main/java9/org/junit/platform/commons/util/ModuleUtils.java b/junit-platform-commons/src/main/java9/org/junit/platform/commons/util/ModuleUtils.java index 100b1034c936..f3a6bd1a5f33 100644 --- a/junit-platform-commons/src/main/java9/org/junit/platform/commons/util/ModuleUtils.java +++ b/junit-platform-commons/src/main/java9/org/junit/platform/commons/util/ModuleUtils.java @@ -21,6 +21,8 @@ import java.lang.module.ModuleReader; import java.lang.module.ModuleReference; import java.lang.module.ResolvedModule; +import java.net.URI; +import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashSet; @@ -35,6 +37,7 @@ import org.junit.platform.commons.JUnitException; import org.junit.platform.commons.logging.Logger; import org.junit.platform.commons.logging.LoggerFactory; +import org.junit.platform.commons.support.Resource; /** * Collection of utilities for working with {@code java.lang.Module} @@ -93,7 +96,7 @@ public static Optional getModuleVersion(Class type) { } /** - * Find all classes for the given module name. + * Find all {@linkplain Class classes} for the given module name. * * @param moduleName the name of the module to scan; never {@code null} or * empty @@ -114,6 +117,30 @@ public static List> findAllClassesInModule(String moduleName, ClassFilt return scan(moduleReferences, filter, ModuleUtils.class.getClassLoader()); } + /** + * Find all {@linkplain Resource resources} for the given module name. + * + * @param moduleName the name of the module to scan; never {@code null} or + * empty + * @param filter the class filter to apply; never {@code null} + * @return an immutable list of all such resources found; never {@code null} + * but potentially empty + * @since 1.11 + */ + @API(status = INTERNAL, since = "1.11") + public static List findAllResourcesInModule(String moduleName, Predicate filter) { + Preconditions.notBlank(moduleName, "Module name must not be null or empty"); + Preconditions.notNull(filter, "Resource filter must not be null"); + + logger.debug(() -> "Looking for classes in module: " + moduleName); + // @formatter:off + Set moduleReferences = streamResolvedModules(isEqual(moduleName)) + .map(ResolvedModule::reference) + .collect(toSet()); + // @formatter:on + return scan(moduleReferences, filter, ModuleUtils.class.getClassLoader()); + } + /** * Stream resolved modules from current (or boot) module layer. */ @@ -146,7 +173,7 @@ private static Stream streamResolvedModules(Predicate mo */ private static List> scan(Set references, ClassFilter filter, ClassLoader loader) { logger.debug(() -> "Scanning " + references.size() + " module references: " + references); - ModuleReferenceScanner scanner = new ModuleReferenceScanner(filter, loader); + ModuleReferenceClassScanner scanner = new ModuleReferenceClassScanner(filter, loader); List> classes = new ArrayList<>(); for (ModuleReference reference : references) { classes.addAll(scanner.scan(reference)); @@ -156,14 +183,32 @@ private static List> scan(Set references, ClassFilter } /** - * {@link ModuleReference} scanner. + * Scan for classes using the supplied set of module references, class + * filter, and loader. + */ + private static List scan(Set references, Predicate filter, + ClassLoader loader) { + logger.debug(() -> "Scanning " + references.size() + " module references: " + references); + ModuleReferenceResourceScanner scanner = new ModuleReferenceResourceScanner(filter, loader); + List classes = new ArrayList<>(); + for (ModuleReference reference : references) { + classes.addAll(scanner.scan(reference)); + } + logger.debug(() -> "Found " + classes.size() + " classes: " + classes); + return Collections.unmodifiableList(classes); + } + + /** + * {@link ModuleReference} class scanner. + * + * @since 1.1 */ - static class ModuleReferenceScanner { + static class ModuleReferenceClassScanner { private final ClassFilter classFilter; private final ClassLoader classLoader; - ModuleReferenceScanner(ClassFilter classFilter, ClassLoader classLoader) { + ModuleReferenceClassScanner(ClassFilter classFilter, ClassLoader classLoader) { this.classFilter = classFilter; this.classLoader = classLoader; } @@ -180,7 +225,8 @@ List> scan(ModuleReference reference) { .filter(name -> !name.equals("module-info")) .filter(classFilter::match) .map(this::loadClassUnchecked) - .filter(classFilter::match) + // Always use ".filter(classFilter)" to include future predicates. + .filter(classFilter) .collect(Collectors.toList()); // @formatter:on } @@ -215,4 +261,50 @@ private Class loadClassUnchecked(String binaryName) { } + /** + * {@link ModuleReference} resource class scanner. + * + * @since 1.11 + */ + static class ModuleReferenceResourceScanner { + + private final Predicate resourceFilter; + private final ClassLoader classLoader; + + ModuleReferenceResourceScanner(Predicate resourceFilter, ClassLoader classLoader) { + this.resourceFilter = resourceFilter; + this.classLoader = classLoader; + } + + /** + * Scan module reference for resources that potentially contain testable resources. + */ + List scan(ModuleReference reference) { + try (ModuleReader reader = reference.open()) { + try (Stream names = reader.list()) { + // @formatter:off + return names.filter(name -> !name.endsWith(".class")) + .map(this::loadResourceUnchecked) + .filter(resourceFilter) + .collect(Collectors.toList()); + // @formatter:on + } + } + catch (IOException e) { + throw new JUnitException("Failed to read contents of " + reference + ".", e); + } + } + + private Resource loadResourceUnchecked(String binaryName) { + try { + URI uri = classLoader.getResource(binaryName).toURI(); + return new ClasspathResource(binaryName, uri); + } + catch (URISyntaxException e) { + throw new JUnitException("Failed to load resource with name '" + binaryName + "'.", e); + } + } + + } + } diff --git a/platform-tests/src/test/java/org/junit/platform/commons/support/ReflectionSupportTests.java b/platform-tests/src/test/java/org/junit/platform/commons/support/ReflectionSupportTests.java index bcaac336fa45..adb986154b79 100644 --- a/platform-tests/src/test/java/org/junit/platform/commons/support/ReflectionSupportTests.java +++ b/platform-tests/src/test/java/org/junit/platform/commons/support/ReflectionSupportTests.java @@ -19,6 +19,7 @@ import java.lang.reflect.Field; import java.lang.reflect.Method; +import java.net.URI; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; @@ -36,6 +37,7 @@ class ReflectionSupportTests { private static final Predicate> allTypes = type -> true; + private static final Predicate allResources = type -> true; private static final Predicate allNames = name -> true; private static final Predicate allMethods = name -> true; private static final Predicate allFields = name -> true; @@ -94,7 +96,7 @@ void tryToLoadClassWithExplicitClassLoaderDelegates() { */ @Test void tryToLoadClassWithExplicitClassLoaderPreconditions() { - ClassLoader cl = getClass().getClassLoader(); + var cl = getClass().getClassLoader(); assertPreconditionViolationExceptionForString("Class name", () -> ReflectionSupport.tryToLoadClass(null, cl)); assertPreconditionViolationExceptionForString("Class name", () -> ReflectionSupport.tryToLoadClass("", cl)); @@ -110,11 +112,7 @@ List findAllClassesInClasspathRootDelegates() throws Throwable { paths.addAll(ReflectionUtils.getAllClasspathRootDirectories()); for (var path : paths) { var root = path.toUri(); - var displayName = root.getPath(); - if (displayName.length() > 42) { - displayName = "..." + displayName.substring(displayName.length() - 42); - } - tests.add(DynamicTest.dynamicTest(displayName, + tests.add(DynamicTest.dynamicTest(createDisplayName(root), () -> assertEquals(ReflectionUtils.findAllClassesInClasspathRoot(root, allTypes, allNames), ReflectionSupport.findAllClassesInClasspathRoot(root, allTypes, allNames)))); } @@ -132,6 +130,66 @@ void findAllClassesInClasspathRootPreconditions() { () -> ReflectionSupport.findAllClassesInClasspathRoot(path, allTypes, null)); } + /** + * @since 1.11 + */ + @TestFactory + List findAllResourcesInClasspathRootDelegates() throws Throwable { + List tests = new ArrayList<>(); + List paths = new ArrayList<>(); + paths.add(Path.of(".").toRealPath()); + paths.addAll(ReflectionUtils.getAllClasspathRootDirectories()); + for (var path : paths) { + var root = path.toUri(); + tests.add(DynamicTest.dynamicTest(createDisplayName(root), + () -> assertEquals(ReflectionUtils.findAllResourcesInClasspathRoot(root, allResources), + ReflectionSupport.findAllResourcesInClasspathRoot(root, allResources)))); + } + return tests; + } + + /** + * @since 1.11 + */ + @Test + void findAllResourcesInClasspathRootPreconditions() { + var path = Path.of(".").toUri(); + assertPreconditionViolationException("root", + () -> ReflectionSupport.findAllResourcesInClasspathRoot(null, allResources)); + assertPreconditionViolationException("resourceFilter", + () -> ReflectionSupport.findAllResourcesInClasspathRoot(path, null)); + } + + /** + * @since 1.11 + */ + @TestFactory + List streamAllResourcesInClasspathRootDelegates() throws Throwable { + List tests = new ArrayList<>(); + List paths = new ArrayList<>(); + paths.add(Path.of(".").toRealPath()); + paths.addAll(ReflectionUtils.getAllClasspathRootDirectories()); + for (var path : paths) { + var root = path.toUri(); + tests.add(DynamicTest.dynamicTest(createDisplayName(root), + () -> assertEquals(ReflectionUtils.streamAllResourcesInClasspathRoot(root, allResources).toList(), + ReflectionSupport.streamAllResourcesInClasspathRoot(root, allResources).toList()))); + } + return tests; + } + + /** + * @since 1.11 + */ + @Test + void streamAllResourcesInClasspathRootPreconditions() { + var path = Path.of(".").toUri(); + assertPreconditionViolationException("root", + () -> ReflectionSupport.streamAllResourcesInClasspathRoot(null, allResources)); + assertPreconditionViolationException("resourceFilter", + () -> ReflectionSupport.streamAllResourcesInClasspathRoot(path, null)); + } + @Test void findAllClassesInPackageDelegates() { assertNotEquals(0, ReflectionSupport.findAllClassesInPackage("org.junit", allTypes, allNames).size()); @@ -149,6 +207,50 @@ void findAllClassesInPackagePreconditions() { () -> ReflectionSupport.findAllClassesInPackage("org.junit", allTypes, null)); } + /** + * @since 1.11 + */ + @Test + void findAllResourcesInPackageDelegates() { + assertNotEquals(0, ReflectionSupport.findAllResourcesInPackage("org.junit", allResources).size()); + + assertEquals(ReflectionUtils.findAllResourcesInPackage("org.junit", allResources), + ReflectionSupport.findAllResourcesInPackage("org.junit", allResources)); + } + + /** + * @since 1.11 + */ + @Test + void findAllResourcesInPackagePreconditions() { + assertPreconditionViolationExceptionForString("basePackageName", + () -> ReflectionSupport.findAllResourcesInPackage(null, allResources)); + assertPreconditionViolationException("resourceFilter", + () -> ReflectionSupport.findAllResourcesInPackage("org.junit", null)); + } + + /** + * @since 1.11 + */ + @Test + void streamAllResourcesInPackageDelegates() { + assertNotEquals(0, ReflectionSupport.streamAllResourcesInPackage("org.junit", allResources).count()); + + assertEquals(ReflectionUtils.streamAllResourcesInPackage("org.junit", allResources).toList(), + ReflectionSupport.streamAllResourcesInPackage("org.junit", allResources).toList()); + } + + /** + * @since 1.11 + */ + @Test + void streamAllResourcesInPackagePreconditions() { + assertPreconditionViolationExceptionForString("basePackageName", + () -> ReflectionSupport.streamAllResourcesInPackage(null, allResources)); + assertPreconditionViolationException("resourceFilter", + () -> ReflectionSupport.streamAllResourcesInPackage("org.junit", null)); + } + @Test void findAllClassesInModuleDelegates() { assertEquals(ReflectionUtils.findAllClassesInModule("org.junit.platform.commons", allTypes, allNames), @@ -166,6 +268,48 @@ void findAllClassesInModulePreconditions() { () -> ReflectionSupport.findAllClassesInModule("org.junit.platform.commons", allTypes, null)); } + /** + * @since 1.11 + */ + @Test + void findAllResourcesInModuleDelegates() { + assertEquals(ReflectionUtils.findAllResourcesInModule("org.junit.platform.commons", allResources), + ReflectionSupport.findAllResourcesInModule("org.junit.platform.commons", allResources)); + } + + /** + * @since 1.11 + */ + @Test + void findAllResourcesInModulePreconditions() { + var exception = assertThrows(PreconditionViolationException.class, + () -> ReflectionSupport.findAllResourcesInModule(null, allResources)); + assertEquals("Module name must not be null or empty", exception.getMessage()); + assertPreconditionViolationException("Resource filter", + () -> ReflectionSupport.findAllResourcesInModule("org.junit.platform.commons", null)); + } + + /** + * @since 1.11 + */ + @Test + void streamAllResourcesInModuleDelegates() { + assertEquals(ReflectionUtils.streamAllResourcesInModule("org.junit.platform.commons", allResources).toList(), + ReflectionSupport.streamAllResourcesInModule("org.junit.platform.commons", allResources).toList()); + } + + /** + * @since 1.11 + */ + @Test + void streamAllResourcesInModulePreconditions() { + var exception = assertThrows(PreconditionViolationException.class, + () -> ReflectionSupport.streamAllResourcesInModule(null, allResources)); + assertEquals("Module name must not be null or empty", exception.getMessage()); + assertPreconditionViolationException("Resource filter", + () -> ReflectionSupport.streamAllResourcesInModule("org.junit.platform.commons", null)); + } + @Test void newInstanceDelegates() { assertEquals(ReflectionUtils.newInstance(String.class, "foo"), @@ -318,11 +462,21 @@ void findNestedClassesPreconditions() { () -> ReflectionSupport.findNestedClasses(ClassWithNestedClasses.class, null)); } + private static String createDisplayName(URI root) { + var displayName = root.getPath(); + if (displayName.length() > 42) { + displayName = "..." + displayName.substring(displayName.length() - 42); + } + return displayName; + } + static class ClassWithNestedClasses { + @SuppressWarnings({ "InnerClassMayBeStatic", "unused" }) class Nested1 { } + @SuppressWarnings("unused") static class Nested2 { } diff --git a/platform-tests/src/test/java/org/junit/platform/commons/util/ClasspathScannerTests.java b/platform-tests/src/test/java/org/junit/platform/commons/util/ClasspathScannerTests.java index 3835a2713cd5..7cd2f31456b4 100644 --- a/platform-tests/src/test/java/org/junit/platform/commons/util/ClasspathScannerTests.java +++ b/platform-tests/src/test/java/org/junit/platform/commons/util/ClasspathScannerTests.java @@ -10,6 +10,7 @@ package org.junit.platform.commons.util; +import static java.util.Objects.requireNonNull; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertSame; @@ -19,10 +20,13 @@ import static org.junit.platform.commons.test.ConcurrencyTestingUtils.executeConcurrently; import java.io.IOException; +import java.io.InputStream; import java.lang.module.ModuleFinder; import java.net.URI; +import java.net.URISyntaxException; import java.net.URL; import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; import java.nio.file.FileSystemNotFoundException; import java.nio.file.FileSystems; import java.nio.file.Files; @@ -45,6 +49,7 @@ import org.junit.platform.commons.PreconditionViolationException; import org.junit.platform.commons.function.Try; import org.junit.platform.commons.logging.LogRecordListener; +import org.junit.platform.commons.support.Resource; /** * Unit tests for {@link ClasspathScanner}. @@ -55,6 +60,7 @@ class ClasspathScannerTests { private static final ClassFilter allClasses = ClassFilter.of(type -> true); + private static final Predicate allResources = type -> true; private final List> loadedClasses = new ArrayList<>(); @@ -76,7 +82,7 @@ void scanForClassesInClasspathRootWhenMalformedClassnameInternalErrorOccursWithN }; assertClassesScannedWhenExceptionIsThrown(malformedClassNameSimulationFilter); - assertDebugMessageLogged(listener, "Failed to load java.lang.Class for path .+ during classpath scanning."); + assertDebugMessageLogged(listener, "Failed to load .+ during classpath scanning."); } @Test @@ -104,7 +110,7 @@ void scanForClassesInClasspathRootWhenOtherInternalErrorOccurs(LogRecordListener }; assertClassesScannedWhenExceptionIsThrown(otherInternalErrorSimulationFilter); - assertDebugMessageLogged(listener, "Failed to load java.lang.Class for path .+ during classpath scanning."); + assertDebugMessageLogged(listener, "Failed to load .+ during classpath scanning."); } @Test @@ -117,7 +123,7 @@ void scanForClassesInClasspathRootWhenGenericRuntimeExceptionOccurs(LogRecordLis }; assertClassesScannedWhenExceptionIsThrown(runtimeExceptionSimulationFilter); - assertDebugMessageLogged(listener, "Failed to load java.lang.Class for path .+ during classpath scanning."); + assertDebugMessageLogged(listener, "Failed to load .+ during classpath scanning."); } private void assertClassesScannedWhenExceptionIsThrown(Predicate> filter) throws Exception { @@ -126,6 +132,24 @@ private void assertClassesScannedWhenExceptionIsThrown(Predicate> filte assertThat(classes).hasSizeGreaterThanOrEqualTo(150); } + @Test + void scanForResourcesInClasspathRootWhenGenericRuntimeExceptionOccurs(LogRecordListener listener) throws Exception { + Predicate runtimeExceptionSimulationFilter = resource -> { + if (resource.getName().equals("org/junit/platform/commons/other-example.resource")) { + throw new RuntimeException("a generic exception"); + } + return true; + }; + + assertResourcesScannedWhenExceptionIsThrown(runtimeExceptionSimulationFilter); + assertDebugMessageLogged(listener, "Failed to load .+ during classpath scanning."); + } + + private void assertResourcesScannedWhenExceptionIsThrown(Predicate filter) { + var resources = this.classpathScanner.scanForResourcesInClasspathRoot(getTestClasspathResourceRoot(), filter); + assertThat(resources).hasSizeGreaterThanOrEqualTo(150); + } + private void assertDebugMessageLogged(LogRecordListener listener, String regex) { // @formatter:off assertThat(listener.stream(ClasspathScanner.class, Level.FINE) @@ -162,18 +186,103 @@ void scanForClassesInClasspathRootWithinJarWithSpacesInPath() throws Exception { private void scanForClassesInClasspathRootWithinJarFile(String resourceName) throws Exception { var jarfile = getClass().getResource(resourceName); - try (var classLoader = new URLClassLoader(new URL[] { jarfile })) { + try (var classLoader = new URLClassLoader(new URL[] { jarfile }, null)) { var classpathScanner = new ClasspathScanner(() -> classLoader, ReflectionUtils::tryToLoadClass); var classes = classpathScanner.scanForClassesInClasspathRoot(jarfile.toURI(), allClasses); - var classNames = classes.stream().map(Class::getName).collect(Collectors.toList()); - assertThat(classNames).hasSize(3) // - .contains("org.junit.platform.jartest.notincluded.NotIncluded", + assertThat(classes).extracting(Class::getName) // + .containsExactlyInAnyOrder("org.junit.platform.jartest.notincluded.NotIncluded", "org.junit.platform.jartest.included.recursive.RecursivelyIncluded", "org.junit.platform.jartest.included.Included"); } } + @Test + void scanForResourcesInClasspathRootWithinJarFile() throws Exception { + scanForResourcesInClasspathRootWithinJarFile("/jartest.jar"); + } + + @Test + void scanForResourcesInClasspathRootWithinJarWithSpacesInPath() throws Exception { + scanForResourcesInClasspathRootWithinJarFile("/folder with spaces/jar test with spaces.jar"); + } + + private void scanForResourcesInClasspathRootWithinJarFile(String resourceName) throws Exception { + var jarfile = getClass().getResource(resourceName); + + try (var classLoader = new URLClassLoader(new URL[] { jarfile }, null)) { + var classpathScanner = new ClasspathScanner(() -> classLoader, ReflectionUtils::tryToLoadClass); + + var resources = classpathScanner.scanForResourcesInClasspathRoot(jarfile.toURI(), allResources); + assertThat(resources).extracting(Resource::getName) // + .containsExactlyInAnyOrder("org/junit/platform/jartest/notincluded/not-included.resource", + "org/junit/platform/jartest/included/included.resource", + "org/junit/platform/jartest/included/recursive/recursively-included.resource", + "META-INF/MANIFEST.MF"); + } + } + + @Test + void scanForResourcesInShadowedClassPathRoot() throws Exception { + var jarFile = getClass().getResource("/jartest.jar"); + var shadowedJarFile = getClass().getResource("/jartest-shadowed.jar"); + + try (var classLoader = new URLClassLoader(new URL[] { jarFile, shadowedJarFile }, null)) { + var classpathScanner = new ClasspathScanner(() -> classLoader, ReflectionUtils::tryToLoadClass); + + var resources = classpathScanner.scanForResourcesInClasspathRoot(shadowedJarFile.toURI(), allResources); + assertThat(resources).extracting(Resource::getName).containsExactlyInAnyOrder( + "org/junit/platform/jartest/included/unique.resource", // + "org/junit/platform/jartest/included/included.resource", // + "org/junit/platform/jartest/included/recursive/recursively-included.resource", // + "META-INF/MANIFEST.MF"); + + assertThat(resources).extracting(Resource::getUri) // + .map(ClasspathScannerTests::jarFileAndEntry) // + .containsExactlyInAnyOrder( + // This resource only exists in the shadowed jar file + "jartest-shadowed.jar!/org/junit/platform/jartest/included/unique.resource", + // These resources exist in both the jar and shadowed jar file. + // They must be discovered in the shadowed jar as we're searching in that classpath root. + "jartest-shadowed.jar!/org/junit/platform/jartest/included/included.resource", + "jartest-shadowed.jar!/org/junit/platform/jartest/included/recursive/recursively-included.resource", + "jartest-shadowed.jar!/META-INF/MANIFEST.MF"); + } + } + + @Test + void scanForResourcesInPackageWithDuplicateResources() throws Exception { + var jarFile = getClass().getResource("/jartest.jar"); + var shadowedJarFile = getClass().getResource("/jartest-shadowed.jar"); + + try (var classLoader = new URLClassLoader(new URL[] { jarFile, shadowedJarFile }, null)) { + var classpathScanner = new ClasspathScanner(() -> classLoader, ReflectionUtils::tryToLoadClass); + + var resources = classpathScanner.scanForResourcesInPackage("org.junit.platform.jartest.included", + allResources); + + assertThat(resources).extracting(Resource::getUri) // + .map(ClasspathScannerTests::jarFileAndEntry) // + .containsExactlyInAnyOrder( + // This resource only exists in the shadowed jar file + "jartest-shadowed.jar!/org/junit/platform/jartest/included/unique.resource", + // These resources exist in both the jar and shadowed jar file. + "jartest.jar!/org/junit/platform/jartest/included/included.resource", + "jartest-shadowed.jar!/org/junit/platform/jartest/included/included.resource", + "jartest.jar!/org/junit/platform/jartest/included/recursive/recursively-included.resource", + "jartest-shadowed.jar!/org/junit/platform/jartest/included/recursive/recursively-included.resource"); + } + } + + private static String jarFileAndEntry(URI uri) { + var uriString = uri.toString(); + int lastJarUriSeparator = uriString.lastIndexOf("!/"); + var jarUri = uriString.substring(0, lastJarUriSeparator); + var jarEntry = uriString.substring(lastJarUriSeparator + 1); + var fileName = jarUri.substring(jarUri.lastIndexOf("/") + 1); + return fileName + "!" + jarEntry; + } + @Test void scanForClassesInPackage() { var classes = classpathScanner.scanForClassesInPackage("org.junit.platform.commons", allClasses); @@ -182,6 +291,14 @@ void scanForClassesInPackage() { assertTrue(classes.contains(MemberClassToBeFound.class)); } + @Test + void scanForResourcesInPackage() { + var resources = classpathScanner.scanForResourcesInPackage("org.junit.platform.commons", allResources); + assertThat(resources).extracting(Resource::getUri).containsExactlyInAnyOrder( + uriOf("/org/junit/platform/commons/example.resource"), + uriOf("/org/junit/platform/commons/other-example.resource")); + } + @Test // #2500 void scanForClassesInPackageWithinModulesSharingNamePrefix(@TempDir Path temp) throws Exception { @@ -239,12 +356,35 @@ void findAllClassesInPackageWithinJarFileConcurrently() throws Exception { assertThrows(FileSystemNotFoundException.class, () -> FileSystems.getFileSystem(jarUri), "FileSystem should be closed"); - results.forEach(classes -> { - assertThat(classes).hasSize(2); - var classNames = classes.stream().map(Class::getSimpleName).toList(); - assertTrue(classNames.contains("Included")); - assertTrue(classNames.contains("RecursivelyIncluded")); - }); + results.forEach(classes -> assertThat(classes) // + .hasSize(2) // + .extracting(Class::getSimpleName) // + .containsExactlyInAnyOrder("Included", "RecursivelyIncluded")); + } + } + + @Test + void findAllResourcesInPackageWithinJarFileConcurrently() throws Exception { + var jarFile = getClass().getResource("/jartest.jar"); + var jarUri = URI.create("jar:" + jarFile); + + try (var classLoader = new URLClassLoader(new URL[] { jarFile })) { + var classpathScanner = new ClasspathScanner(() -> classLoader, ReflectionUtils::tryToLoadClass); + + var results = executeConcurrently(10, + () -> classpathScanner.scanForResourcesInPackage("org.junit.platform.jartest.included", allResources)); + + assertThrows(FileSystemNotFoundException.class, () -> FileSystems.getFileSystem(jarUri), + "FileSystem should be closed"); + + // @formatter:off + results.forEach(resources -> assertThat(resources) + .hasSize(2) + .extracting(Resource::getName).containsExactlyInAnyOrder( + "org/junit/platform/jartest/included/included.resource", + "org/junit/platform/jartest/included/recursive/recursively-included.resource" + )); + // @formatter:on } } @@ -258,6 +398,16 @@ void scanForClassesInDefaultPackage() { assertTrue(classes.stream().anyMatch(clazz -> "DefaultPackageTestCase".equals(clazz.getName()))); } + @Test + void scanForResourcesInDefaultPackage() { + Predicate resourceFilter = this::inDefaultPackage; + var resources = classpathScanner.scanForResourcesInPackage("", resourceFilter); + + assertThat(resources).as("number of resources found in default package").isNotEmpty(); + assertTrue(resources.stream().allMatch(this::inDefaultPackage)); + assertTrue(resources.stream().anyMatch(resource -> "default-package.resource".equals(resource.getName()))); + } + @Test void scanForClassesInPackageWithFilter() { var thisClassOnly = ClassFilter.of(clazz -> clazz == ClasspathScannerTests.class); @@ -265,18 +415,54 @@ void scanForClassesInPackageWithFilter() { assertSame(ClasspathScannerTests.class, classes.get(0)); } + @Test + void scanForResourcesInPackageWithFilter() { + Predicate thisResourceOnly = resource -> "org/junit/platform/commons/example.resource".equals( + resource.getName()); + var resources = classpathScanner.scanForResourcesInPackage("org.junit.platform.commons", thisResourceOnly); + assertThat(resources).extracting(Resource::getName).containsExactly( + "org/junit/platform/commons/example.resource"); + } + + @Test + void resourcesCanBeRead() throws IOException { + Predicate thisResourceOnly = resource -> "org/junit/platform/commons/example.resource".equals( + resource.getName()); + var resources = classpathScanner.scanForResourcesInPackage("org.junit.platform.commons", thisResourceOnly); + Resource resource = resources.get(0); + + assertThat(resource.getName()).isEqualTo("org/junit/platform/commons/example.resource"); + assertThat(resource.getUri()).isEqualTo(uriOf("/org/junit/platform/commons/example.resource")); + try (InputStream is = resource.getInputStream()) { + String contents = new String(is.readAllBytes(), StandardCharsets.UTF_8); + assertThat(contents).isEqualTo("This file was unintentionally left blank.\n"); + } + } + @Test void scanForClassesInPackageForNullBasePackage() { assertThrows(PreconditionViolationException.class, () -> classpathScanner.scanForClassesInPackage(null, allClasses)); } + @Test + void scanForResourcesInPackageForNullBasePackage() { + assertThrows(PreconditionViolationException.class, + () -> classpathScanner.scanForResourcesInPackage(null, allResources)); + } + @Test void scanForClassesInPackageForWhitespaceBasePackage() { assertThrows(PreconditionViolationException.class, () -> classpathScanner.scanForClassesInPackage(" ", allClasses)); } + @Test + void scanForResourcesInPackageForWhitespaceBasePackage() { + assertThrows(PreconditionViolationException.class, + () -> classpathScanner.scanForResourcesInPackage(" ", allResources)); + } + @Test void scanForClassesInPackageForNullClassFilter() { assertThrows(PreconditionViolationException.class, @@ -290,6 +476,13 @@ void scanForClassesInPackageWhenIOExceptionOccurs() { assertThat(classes).isEmpty(); } + @Test + void scanForResourcesInPackageWhenIOExceptionOccurs() { + var scanner = new ClasspathScanner(ThrowingClassLoader::new, ReflectionUtils::tryToLoadClass); + var classes = scanner.scanForResourcesInPackage("org.junit.platform.commons", allResources); + assertThat(classes).isEmpty(); + } + @Test void scanForClassesInPackageOnlyLoadsClassesThatAreIncludedByTheClassNameFilter() { Predicate classNameFilter = name -> ClasspathScannerTests.class.getName().equals(name); @@ -340,6 +533,10 @@ private boolean inDefaultPackage(Class clazz) { return pkg == null || "".equals(clazz.getPackage().getName()); } + private boolean inDefaultPackage(Resource resource) { + return !resource.getName().contains("/"); + } + @Test void findAllClassesInClasspathRootWithFilter() throws Exception { var root = getTestClasspathRoot(); @@ -377,11 +574,28 @@ void onlyLoadsClassesInClasspathRootThatAreIncludedByTheClassNameFilter() throws assertThat(loadedClasses).containsExactly(ClasspathScannerTests.class); } + private static URI uriOf(String name) { + var resource = ClasspathScannerTests.class.getResource(name); + try { + return requireNonNull(resource).toURI(); + } + catch (URISyntaxException e) { + throw new RuntimeException(e); + } + } + private URI getTestClasspathRoot() throws Exception { var location = getClass().getProtectionDomain().getCodeSource().getLocation(); return location.toURI(); } + private URI getTestClasspathResourceRoot() { + // Gradle puts classes and resources in different roots. + var defaultPackageResource = "/default-package.resource"; + var resourceUri = getClass().getResource(defaultPackageResource).toString(); + return URI.create(resourceUri.substring(0, resourceUri.length() - defaultPackageResource.length())); + } + class MemberClassToBeFound { } diff --git a/platform-tests/src/test/resources/default-package.resource b/platform-tests/src/test/resources/default-package.resource new file mode 100644 index 000000000000..3f8177ae5d6c --- /dev/null +++ b/platform-tests/src/test/resources/default-package.resource @@ -0,0 +1 @@ +This file was unintentionally left blank. diff --git a/platform-tests/src/test/resources/folder with spaces/jar test with spaces.jar b/platform-tests/src/test/resources/folder with spaces/jar test with spaces.jar index d470846c8745..02f12e1b9d45 100644 Binary files a/platform-tests/src/test/resources/folder with spaces/jar test with spaces.jar and b/platform-tests/src/test/resources/folder with spaces/jar test with spaces.jar differ diff --git a/platform-tests/src/test/resources/jartest-shadowed.jar b/platform-tests/src/test/resources/jartest-shadowed.jar new file mode 100644 index 000000000000..8a13258b5759 Binary files /dev/null and b/platform-tests/src/test/resources/jartest-shadowed.jar differ diff --git a/platform-tests/src/test/resources/jartest.jar b/platform-tests/src/test/resources/jartest.jar index d470846c8745..02f12e1b9d45 100644 Binary files a/platform-tests/src/test/resources/jartest.jar and b/platform-tests/src/test/resources/jartest.jar differ diff --git a/platform-tests/src/test/resources/org/junit/platform/commons/example.resource b/platform-tests/src/test/resources/org/junit/platform/commons/example.resource new file mode 100644 index 000000000000..3f8177ae5d6c --- /dev/null +++ b/platform-tests/src/test/resources/org/junit/platform/commons/example.resource @@ -0,0 +1 @@ +This file was unintentionally left blank. diff --git a/platform-tests/src/test/resources/org/junit/platform/commons/other-example.resource b/platform-tests/src/test/resources/org/junit/platform/commons/other-example.resource new file mode 100644 index 000000000000..3f8177ae5d6c --- /dev/null +++ b/platform-tests/src/test/resources/org/junit/platform/commons/other-example.resource @@ -0,0 +1 @@ +This file was unintentionally left blank.