diff --git a/documentation/antora.yml b/documentation/antora.yml index 63ce0bcd0ab8..f9c151b5602e 100644 --- a/documentation/antora.yml +++ b/documentation/antora.yml @@ -232,6 +232,10 @@ asciidoc: JRE: '{javadoc-root}/org.junit.jupiter.api/org/junit/jupiter/api/condition/JRE.html[JRE]' # Jupiter I/O TempDir: '{javadoc-root}/org.junit.jupiter.api/org/junit/jupiter/api/io/TempDir.html[@TempDir]' + TempDirDeletionStrategy: '{javadoc-root}/org.junit.jupiter.api/org/junit/jupiter/api/io/TempDirDeletionStrategy.html[TempDirDeletionStrategy]' + TempDirDeletionStrategyIgnoreFailures: '{javadoc-root}/org.junit.jupiter.api/org/junit/jupiter/api/io/TempDirDeletionStrategy.IgnoreFailures.html[IgnoreFailures]' + TempDirDeletionStrategyStandard: '{javadoc-root}/org.junit.jupiter.api/org/junit/jupiter/api/io/TempDirDeletionStrategy.Standard.html[Standard]' + TempDirFactory: '{javadoc-root}/org.junit.jupiter.api/org/junit/jupiter/api/io/TempDirFactory.html[TempDirFactory]' # Jupiter Params params-provider-package: '{javadoc-root}/org.junit.jupiter.params/org/junit/jupiter/params/provider/package-summary.html[org.junit.jupiter.params.provider]' AfterParameterizedClassInvocation: '{javadoc-root}/org.junit.jupiter.params/org/junit/jupiter/params/AfterParameterizedClassInvocation.html[@AfterParameterizedClassInvocation]' diff --git a/documentation/modules/ROOT/pages/writing-tests/built-in-extensions.adoc b/documentation/modules/ROOT/pages/writing-tests/built-in-extensions.adoc index b478bb7cb2c4..f266bc33b70f 100644 --- a/documentation/modules/ROOT/pages/writing-tests/built-in-extensions.adoc +++ b/documentation/modules/ROOT/pages/writing-tests/built-in-extensions.adoc @@ -59,11 +59,14 @@ xref:running-tests/configuration-parameters.adoc[configuration parameter] to ove include::example$java/example/TempDirectoryDemo.java[tags=user_guide_cleanup_mode] ---- +[[TempDirFactory]] +=== Factories + `@TempDir` supports the programmatic creation of temporary directories via the optional `factory` attribute. This is typically used to gain control over the temporary directory creation, like defining the parent directory or the file system that should be used. -Factories can be created by implementing `TempDirFactory`. Implementations must provide a +Factories can be created by implementing `{TempDirFactory}`. Implementations must provide a no-args constructor and should not make any assumptions regarding when and how many times they are instantiated, but they can assume that their `createTempDirectory(...)` and `close()` methods will both be called once per instance, in this order, and from the same @@ -118,9 +121,9 @@ parameter of the `createTempDirectory(...)` method. You can use the `junit.jupiter.tempdir.factory.default` xref:running-tests/configuration-parameters.adoc[configuration parameter] to specify the -fully qualified class name of the `TempDirFactory` you would like to use by default. Just +fully qualified class name of the `{TempDirFactory}` you would like to use by default. Just like for factories configured via the `factory` attribute of the `@TempDir` annotation, -the supplied class has to implement the `TempDirFactory` interface. The default factory +the supplied class has to implement the `{TempDirFactory}` interface. The default factory will be used for all `@TempDir` annotations unless the `factory` attribute of the annotation specifies a different factory. @@ -128,10 +131,54 @@ In summary, the factory for a temporary directory is determined according to the precedence rules: 1. The `factory` attribute of the `@TempDir` annotation, if present -2. The default `TempDirFactory` configured via the configuration +2. The default `{TempDirFactory}` configured via the configuration parameter, if present 3. Otherwise, `org.junit.jupiter.api.io.TempDirFactory$Standard` will be used. +[[TempDirDeletionStrategy]] +=== Deletion + +`@TempDir` supports the programmatic deletion of temporary directories via the optional +`deletionStrategy` attribute. This is typically used to gain control over what happens +when deletion of a file or directory fails. + +Deletion strategies can be created by implementing `{TempDirDeletionStrategy}`. +Implementations must provide a no-args constructor. + +Jupiter ships with two built-in deletion strategies: + +* `{TempDirDeletionStrategyStandard}` (the default): attempts to delete all files and + directories recursively, retrying with permission resets on failure. Paths that still + cannot be deleted are scheduled for deletion on JVM exit, if possible. Additionally, + the test is failed. +* `{TempDirDeletionStrategyIgnoreFailures}`: delegates to `{TempDirDeletionStrategyStandard}` + but suppresses deletion failures by logging a warning instead of failing the test. + +The following example uses `{TempDirDeletionStrategyIgnoreFailures}` so that any deletion +failures are only logged. + +[source,java,indent=0] +.A test class with a temporary directory that ignores deletion failures +---- +include::example$java/example/TempDirectoryDemo.java[tags=user_guide_deletion_strategy] +---- + +You can use the `junit.jupiter.tempdir.deletion.strategy.default` +xref:running-tests/configuration-parameters.adoc[configuration parameter] to specify the +fully qualified class name of the `{TempDirDeletionStrategy}` you would like to use by +default. Just like for strategies configured via the `deletionStrategy` attribute of the +`@TempDir` annotation, the supplied class has to implement the `{TempDirDeletionStrategy}` +interface. The default strategy will be used for all `@TempDir` annotations unless the +`deletionStrategy` attribute of the annotation specifies a different strategy. + +In summary, the deletion strategy for a temporary directory is determined according to the +following precedence rules: + +1. The `deletionStrategy` attribute of the `@TempDir` annotation, if present +2. The default `{TempDirDeletionStrategy}` configured via the configuration parameter, +if present +3. Otherwise, `org.junit.jupiter.api.io.TempDirDeletionStrategy$Standard` will be used. + [[AutoClose]] == The @AutoClose Extension diff --git a/documentation/modules/ROOT/partials/release-notes/release-notes-6.1.0-M2.adoc b/documentation/modules/ROOT/partials/release-notes/release-notes-6.1.0-M2.adoc index c38852832522..0ee7c0629dce 100644 --- a/documentation/modules/ROOT/partials/release-notes/release-notes-6.1.0-M2.adoc +++ b/documentation/modules/ROOT/partials/release-notes/release-notes-6.1.0-M2.adoc @@ -75,6 +75,11 @@ repository on GitHub. the xref:writing-tests/built-in-extensions.adoc#system-properties[User Guide]. For details regarding implementation differences between JUnit Pioneer and Jupiter, see the corresponding https://github.com/junit-team/junit-framework/pull/5258[pull request]. +* `@TempDir` now allows configuring a deletion strategy for temporary directories. This is + typically used to gain control over what happens when deletion of a file or directory + fails (see + xref:writing-tests/built-in-extensions.adoc#TempDirDeletionStrategy[User Guide] for + details). * `Arguments` may now be created from instances of `Iterable` via the following new methods which make it easier to dynamically build arguments from collections when using `@ParameterizedTest`. diff --git a/documentation/src/test/java/example/TempDirectoryDemo.java b/documentation/src/test/java/example/TempDirectoryDemo.java index 8ddfec5e4932..cf18d8d7a4d6 100644 --- a/documentation/src/test/java/example/TempDirectoryDemo.java +++ b/documentation/src/test/java/example/TempDirectoryDemo.java @@ -35,6 +35,7 @@ import org.junit.jupiter.api.extension.AnnotatedElementContext; import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.io.TempDirDeletionStrategy; import org.junit.jupiter.api.io.TempDirFactory; @SuppressWarnings("NewClassNamingConvention") @@ -151,6 +152,18 @@ public void close() throws IOException { } // end::user_guide_factory_jimfs[] + static + // tag::user_guide_deletion_strategy[] + class DeletionStrategyDemo { + + @Test + void test(@TempDir(deletionStrategy = TempDirDeletionStrategy.IgnoreFailures.class) Path tempDir) { + // perform test + } + + } + // end::user_guide_deletion_strategy[] + // tag::user_guide_composed_annotation[] @Target({ ElementType.ANNOTATION_TYPE, ElementType.FIELD, ElementType.PARAMETER }) @Retention(RetentionPolicy.RUNTIME) diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/Constants.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/Constants.java index a40690109d07..f1c38975ed51 100644 --- a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/Constants.java +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/Constants.java @@ -10,12 +10,14 @@ package org.junit.jupiter.api; +import static org.apiguardian.api.API.Status.EXPERIMENTAL; import static org.apiguardian.api.API.Status.INTERNAL; import static org.apiguardian.api.API.Status.STABLE; import org.apiguardian.api.API; import org.junit.jupiter.api.extension.PreInterruptCallback; import org.junit.jupiter.api.extension.TestInstantiationAwareExtension.ExtensionContextScope; +import org.junit.jupiter.api.io.CleanupMode; import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.parallel.Execution; @@ -393,13 +395,32 @@ public final class Constants { public static final String DEFAULT_TIMEOUT_THREAD_MODE_PROPERTY_NAME = Timeout.DEFAULT_TIMEOUT_THREAD_MODE_PROPERTY_NAME; /** - * Property name used to set the default factory for temporary directories created via - * the {@link TempDir @TempDir} annotation: {@value} + * Property name used to set the default factory for temporary directories + * created via the {@link TempDir @TempDir} annotation: {@value} * * @see TempDir#DEFAULT_FACTORY_PROPERTY_NAME */ public static final String DEFAULT_TEMP_DIR_FACTORY_PROPERTY_NAME = TempDir.DEFAULT_FACTORY_PROPERTY_NAME; + /** + * Property name used to configure the default {@link CleanupMode} for + * temporary directories created via the {@link TempDir @TempDir} + * annotation: {@value} + * + * @see TempDir#DEFAULT_CLEANUP_MODE_PROPERTY_NAME + */ + public static final String DEFAULT_TEMP_DIR_CLEANUP_MODE_PROPERTY_NAME = TempDir.DEFAULT_CLEANUP_MODE_PROPERTY_NAME; + + /** + * Property name used to set the default deletion strategy class name for + * temporary directories created via the {@link TempDir @TempDir} + * annotation: {@value} + * + * @see TempDir#DEFAULT_DELETION_STRATEGY_PROPERTY_NAME + */ + @API(status = EXPERIMENTAL, since = "6.1") + public static final String DEFAULT_TEMP_DIR_DELETION_STRATEGY_PROPERTY_NAME = TempDir.DEFAULT_DELETION_STRATEGY_PROPERTY_NAME; + /** * Property name used to set the default extension context scope for * extensions that participate in test instantiation: {@value} diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/io/DefaultDeletionResult.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/io/DefaultDeletionResult.java new file mode 100644 index 000000000000..16e67172e81e --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/io/DefaultDeletionResult.java @@ -0,0 +1,89 @@ +/* + * Copyright 2026 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.jupiter.api.io; + +import static java.util.Comparator.comparing; +import static java.util.stream.Collectors.joining; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.io.TempDirDeletionStrategy.DeletionException; +import org.junit.jupiter.api.io.TempDirDeletionStrategy.DeletionFailure; +import org.junit.jupiter.api.io.TempDirDeletionStrategy.DeletionResult; +import org.junit.platform.commons.util.Preconditions; + +record DefaultDeletionResult(Path rootDir, List failures) implements DeletionResult { + + DefaultDeletionResult(Path rootDir, List failures) { + this.rootDir = rootDir; + this.failures = List.copyOf(failures); + } + + @Override + public Optional toException() { + if (isSuccessful()) { + return Optional.empty(); + } + var joinedPaths = failures().stream() // + .map(DeletionFailure::path) // + .sorted() // + .distinct() // + .map(path -> relativizeSafely(rootDir(), path).toString()) // + .map(path -> path.isEmpty() ? "" : path) // + .collect(joining(", ")); + var exception = new DeletionException("Failed to delete temp directory " + rootDir().toAbsolutePath() + + ". The following paths could not be deleted (see suppressed exceptions for details): " + joinedPaths); + failures().stream() // + .sorted(comparing(DeletionFailure::path)) // + .map(DeletionFailure::cause) // + .forEach(exception::addSuppressed); + return Optional.of(exception); + } + + private static Path relativizeSafely(Path rootDir, Path path) { + try { + return rootDir.relativize(path); + } + catch (IllegalArgumentException e) { + return path; + } + } + + static final class Builder implements DeletionResult.Builder { + + private final Path rootDir; + private final List failures = new ArrayList<>(); + + Builder(Path rootDir) { + this.rootDir = rootDir; + } + + @Override + public Builder addFailure(Path path, Exception cause) { + Preconditions.notNull(path, "path must not be null"); + Preconditions.notNull(cause, "cause must not be null"); + failures.add(new DefaultDeletionFailure(path, cause)); + return this; + } + + @Override + public DefaultDeletionResult build() { + return new DefaultDeletionResult(rootDir, failures); + } + + } + + record DefaultDeletionFailure(Path path, Exception cause) implements DeletionFailure { + } +} diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/io/TempDir.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/io/TempDir.java index eba5b72201e0..5ba3f799215c 100644 --- a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/io/TempDir.java +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/io/TempDir.java @@ -10,6 +10,7 @@ package org.junit.jupiter.api.io; +import static org.apiguardian.api.API.Status.EXPERIMENTAL; import static org.apiguardian.api.API.Status.MAINTAINED; import static org.apiguardian.api.API.Status.STABLE; @@ -127,8 +128,10 @@ Class factory() default TempDirFactory.class; /** - * The name of the configuration parameter that is used to configure the - * default {@link CleanupMode}. + * Property name used to configure the default {@link CleanupMode}: {@value} + * + *

Supported values include names of enum constants defined in + * {@link CleanupMode}, ignoring case. * *

If this configuration parameter is not set, {@link CleanupMode#ALWAYS} * will be used as the default. @@ -139,11 +142,47 @@ String DEFAULT_CLEANUP_MODE_PROPERTY_NAME = "junit.jupiter.tempdir.cleanup.mode.default"; /** - * How the temporary directory gets cleaned up after the test completes. + * In which cases the temporary directory gets cleaned up after the test completes. * * @since 5.9 */ @API(status = STABLE, since = "5.11") CleanupMode cleanup() default CleanupMode.DEFAULT; + /** + * Property name used to set the default deletion strategy class name: + * {@value} + * + *

Supported Values

+ * + *

Supported values include fully qualified class names for types that + * implement {@link TempDirDeletionStrategy}. + * + *

If not specified, the default is {@link TempDirDeletionStrategy.Standard}. + * + * @since 6.1 + */ + @API(status = EXPERIMENTAL, since = "6.1") + String DEFAULT_DELETION_STRATEGY_PROPERTY_NAME = "junit.jupiter.tempdir.deletion.strategy.default"; + + /** + * Deletion strategy for the temporary directory. + * + *

Defaults to {@link TempDirDeletionStrategy.Standard}. + * + *

As an alternative to setting this attribute, a global + * {@link TempDirDeletionStrategy} can be configured for the entire test + * suite via the {@value #DEFAULT_DELETION_STRATEGY_PROPERTY_NAME} + * configuration parameter. See the User Guide for details. Note, however, + * that a {@code @TempDir} declaration with a custom + * {@code deletionStrategy} always overrides a global + * {@code TempDirDeletionStrategy}. + * + * @return the type of {@code TempDirDeletionStrategy} to use + * @since 6.1 + * @see TempDirDeletionStrategy + */ + @API(status = EXPERIMENTAL, since = "6.1") + Class deletionStrategy() default TempDirDeletionStrategy.class; + } diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/io/TempDirDeletionStrategy.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/io/TempDirDeletionStrategy.java new file mode 100644 index 000000000000..c98ef6ec0f91 --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/io/TempDirDeletionStrategy.java @@ -0,0 +1,470 @@ +/* + * Copyright 2026 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.jupiter.api.io; + +import static java.nio.file.FileVisitResult.CONTINUE; +import static java.nio.file.FileVisitResult.SKIP_SUBTREE; +import static org.apiguardian.api.API.Status.EXPERIMENTAL; +import static org.apiguardian.api.API.Status.INTERNAL; + +import java.io.File; +import java.io.IOException; +import java.io.Serial; +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Constructor; +import java.lang.reflect.Executable; +import java.lang.reflect.Field; +import java.lang.reflect.Parameter; +import java.nio.file.DirectoryNotEmptyException; +import java.nio.file.FileSystems; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.DosFileAttributeView; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.function.BiConsumer; + +import org.apiguardian.api.API; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.extension.AnnotatedElementContext; +import org.junit.jupiter.api.extension.ExtensionContext; +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.util.ClassUtils; +import org.junit.platform.commons.util.Preconditions; + +/** + * {@code TempDirDeletionStrategy} defines the SPI for deleting temporary + * directories programmatically. + * + *

A deletion strategy controls how a temporary directory is cleaned up + * when the end of its scope is reached. + * + *

Implementations must provide a no-args constructor. + * + *

A {@link TempDirDeletionStrategy} can be configured globally + * for the entire test suite via the + * {@value TempDir#DEFAULT_DELETION_STRATEGY_PROPERTY_NAME} configuration + * parameter (see the User Guide for details) or locally for a test + * class field or method parameter via the {@link TempDir @TempDir} annotation. + * + * @since 6.1 + * @see TempDir @TempDir + */ +@API(status = EXPERIMENTAL, since = "6.1") +public interface TempDirDeletionStrategy { + + /** + * Delete the supplied temporary directory and all of its contents. + * + *

Depending on the used {@link TempDirFactory}, the supplied + * {@link Path} may or may not be associated with the + * {@linkplain java.nio.file.FileSystems#getDefault() default FileSystem}. + * + * @param tempDir the temporary directory to delete; never {@code null} + * @param elementContext the context of the field or parameter where + * {@code @TempDir} is declared; never {@code null} + * @param extensionContext the current extension context; never {@code null} + * @return a {@link DeletionResult}, potentially containing failures for + * {@link Path Paths} that could not be deleted or no failures if deletion + * was successful; never {@code null} + * @throws IOException in case of general failures + */ + DeletionResult delete(Path tempDir, AnnotatedElementContext elementContext, ExtensionContext extensionContext) + throws IOException; + + /** + * A {@link TempDirDeletionStrategy} that delegates to {@link Standard} but + * suppresses deletion failures by logging a warning instead of propagating + * them. + */ + final class IgnoreFailures implements TempDirDeletionStrategy { + + private static final Logger LOGGER = LoggerFactory.getLogger(IgnoreFailures.class); + private final TempDirDeletionStrategy delegate; + + /** + * Create a new {@code IgnoreFailures} strategy that delegates to + * {@link Standard}. + */ + public IgnoreFailures() { + this(Standard.INSTANCE); + } + + IgnoreFailures(TempDirDeletionStrategy delegate) { + this.delegate = delegate; + } + + @Override + public DeletionResult delete(Path tempDir, AnnotatedElementContext elementContext, + ExtensionContext extensionContext) throws IOException { + + var result = delegate.delete(tempDir, elementContext, extensionContext); + + result.toException().ifPresent(ex -> logWarning(elementContext, ex)); + + return DeletionResult.builder(tempDir).build(); + } + + private void logWarning(AnnotatedElementContext elementContext, DeletionException exception) { + LOGGER.warn(exception, () -> "Failed to delete all temporary files for %s".formatted( + descriptionFor(elementContext.getAnnotatedElement()))); + } + + @API(status = INTERNAL, since = "6.1") + public static String descriptionFor(AnnotatedElement annotatedElement) { + if (annotatedElement instanceof Field field) { + return "field " + field.getDeclaringClass().getSimpleName() + "." + field.getName(); + } + if (annotatedElement instanceof Parameter parameter) { + Executable executable = parameter.getDeclaringExecutable(); + return "parameter '" + parameter.getName() + "' in " + descriptionFor(executable); + } + throw new IllegalStateException("Unsupported AnnotatedElement type for @TempDir: " + annotatedElement); + } + + private static String descriptionFor(Executable executable) { + boolean isConstructor = executable instanceof Constructor; + String type = isConstructor ? "constructor" : "method"; + String name = isConstructor ? executable.getDeclaringClass().getSimpleName() : executable.getName(); + return "%s %s(%s)".formatted(type, name, + ClassUtils.nullSafeToString(Class::getSimpleName, executable.getParameterTypes())); + } + } + + /** + * Standard {@link TempDirDeletionStrategy} implementation that recursively + * deletes all files and directories within the temporary directory. + * + *

If a file or directory cannot be deleted, its permissions are reset + * and deletion is attempted again. If deletion still fails, the path is + * scheduled for deletion on JVM exit via + * {@link java.io.File#deleteOnExit()}, if it belongs to the default file + * system. + */ + final class Standard implements TempDirDeletionStrategy { + + /** + * The singleton instance of {@code Standard}. + */ + public static final Standard INSTANCE = new Standard(); + + private static final Logger LOGGER = LoggerFactory.getLogger(Standard.class); + + private Standard() { + } + + @Override + public DeletionResult delete(Path tempDir, AnnotatedElementContext elementContext, + ExtensionContext extensionContext) throws IOException { + + return delete(tempDir, Files::delete); + } + + // package-private for testing + DeletionResult delete(Path tempDir, FileOperations fileOperations) throws IOException { + var result = DeletionResult.builder(tempDir); + delete(tempDir, fileOperations, (path, cause) -> { + result.addFailure(path, cause); + tryToDeleteOnExit(path); + }); + return result.build(); + } + + private void delete(Path tempDir, FileOperations fileOperations, BiConsumer failureHandler) + throws IOException { + Set retriedPaths = new HashSet<>(); + Path rootRealPath = tempDir.toRealPath(); + + tryToResetPermissions(tempDir); + Files.walkFileTree(tempDir, new SimpleFileVisitor<>() { + + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { + LOGGER.trace(() -> "preVisitDirectory: " + dir); + if (isLinkWithTargetOutsideTempDir(dir)) { + warnAboutLinkWithTargetOutsideTempDir("link", dir); + delete(dir, fileOperations); + return SKIP_SUBTREE; + } + if (!dir.equals(tempDir)) { + tryToResetPermissions(dir); + } + return CONTINUE; + } + + @Override + public FileVisitResult visitFileFailed(Path file, IOException exc) { + LOGGER.trace(exc, () -> "visitFileFailed: " + file); + if (exc instanceof NoSuchFileException && !Files.exists(file, LinkOption.NOFOLLOW_LINKS)) { + return CONTINUE; + } + // IOException includes `AccessDeniedException` thrown by non-readable or non-executable flags + resetPermissionsAndTryToDeleteAgain(file, exc); + return CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException { + LOGGER.trace(() -> "visitFile: " + file); + if (Files.isSymbolicLink(file) && isLinkWithTargetOutsideTempDir(file)) { + warnAboutLinkWithTargetOutsideTempDir("symbolic link", file); + } + delete(file, fileOperations); + return CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path dir, @Nullable IOException exc) { + LOGGER.trace(exc, () -> "postVisitDirectory: " + dir); + delete(dir, fileOperations); + return CONTINUE; + } + + private boolean isLinkWithTargetOutsideTempDir(Path path) { + // While `Files.walkFileTree` does not follow symbolic links, it may follow other links + // such as "junctions" on Windows + try { + return !path.toRealPath().startsWith(rootRealPath); + } + catch (IOException e) { + LOGGER.trace(e, + () -> "Failed to determine real path for " + path + "; assuming it is not a link"); + return false; + } + } + + private void warnAboutLinkWithTargetOutsideTempDir(String linkType, Path file) throws IOException { + Path realPath = file.toRealPath(); + LOGGER.warn(() -> """ + Deleting %s from location inside of temp dir (%s) \ + to location outside of temp dir (%s) but not the target file/directory""".formatted( + linkType, file, realPath)); + } + + private void delete(Path path, FileOperations fileOperations) { + try { + deleteWithLogging(path, fileOperations); + } + catch (NoSuchFileException ignore) { + // ignore + } + catch (DirectoryNotEmptyException exception) { + failureHandler.accept(path, exception); + } + catch (IOException exception) { + // IOException includes `AccessDeniedException` thrown by non-readable or non-executable flags + resetPermissionsAndTryToDeleteAgain(path, exception); + } + } + + private void resetPermissionsAndTryToDeleteAgain(Path path, IOException exception) { + boolean notYetRetried = retriedPaths.add(path); + if (notYetRetried) { + try { + tryToResetPermissions(path); + if (Files.isDirectory(path)) { + Files.walkFileTree(path, this); + } + else { + deleteWithLogging(path, fileOperations); + } + } + catch (Exception suppressed) { + exception.addSuppressed(suppressed); + failureHandler.accept(path, exception); + } + } + else { + failureHandler.accept(path, exception); + } + } + }); + } + + private void deleteWithLogging(Path file, FileOperations fileOperations) throws IOException { + LOGGER.trace(() -> "Attempting to delete " + file); + try { + fileOperations.delete(file); + LOGGER.trace(() -> "Successfully deleted " + file); + } + catch (IOException e) { + LOGGER.trace(e, () -> "Failed to delete " + file); + throw e; + } + } + + @SuppressWarnings("ResultOfMethodCallIgnored") + private static void tryToResetPermissions(Path path) { + File file; + try { + file = path.toFile(); + } + catch (UnsupportedOperationException ignore) { + // Might happen when the `TempDirFactory` uses a custom `FileSystem` + return; + } + file.setReadable(true); + file.setWritable(true); + if (Files.isDirectory(path)) { + file.setExecutable(true); + } + DosFileAttributeView dos = Files.getFileAttributeView(path, DosFileAttributeView.class); + if (dos != null) { + try { + dos.setReadOnly(false); + } + catch (IOException ignore) { + // nothing we can do + } + } + } + + @SuppressWarnings("EmptyCatch") + private static void tryToDeleteOnExit(Path path) { + try { + if (FileSystems.getDefault().equals(path.getFileSystem())) { + path.toFile().deleteOnExit(); + } + } + catch (UnsupportedOperationException ignore) { + } + } + + // For testing only + interface FileOperations { + + void delete(Path path) throws IOException; + + } + } + + /** + * Represents the result of a {@link TempDirDeletionStrategy#delete} operation, + * including any paths that could not be deleted. + */ + sealed interface DeletionResult permits DefaultDeletionResult { + + /** + * Create a new {@link Builder} for the supplied root directory. + * + * @param rootDir the root temporary directory; never {@code null} + * @return a new {@code Builder}; never {@code null} + */ + static Builder builder(Path rootDir) { + return new DefaultDeletionResult.Builder(Preconditions.notNull(rootDir, "rootDir must not be null")); + } + + /** + * Return the root temporary directory of this deletion operation. + * + * @return the root directory; never {@code null} + */ + Path rootDir(); + + /** + * Return the list of failures that occurred during deletion. + * + * @return the list of failures; never {@code null} + */ + List failures(); + + /** + * Return {@code true} if the deletion was successful, i.e., no + * {@linkplain #failures() failures} were recorded. + */ + default boolean isSuccessful() { + return failures().isEmpty(); + } + + /** + * Convert this result to a {@link DeletionException} summarizing all + * failures. + * + *

Must only be called if {@link #isSuccessful()} returns + * {@code false}. + * + * @return an {@link DeletionException}, if the deletion + * {@linkplain #isSuccessful() was successful; otherwise, empty}; never + * {@code null} + */ + Optional toException(); + + /** + * Builder for {@link DeletionResult}. + */ + sealed interface Builder permits DefaultDeletionResult.Builder { + + /** + * Record a failure for the supplied path. + * + * @param path the path that could not be deleted; never {@code null} + * @param cause the exception that caused the failure; never {@code null} + * @return this builder; never {@code null} + */ + Builder addFailure(Path path, Exception cause); + + /** + * Build the {@link DeletionResult}. + * + * @return a new {@link DeletionResult}; never {@code null} + */ + DeletionResult build(); + + } + + } + + /** + * Represents a single failure that occurred while attempting to delete a + * path during a {@link TempDirDeletionStrategy#delete} operation. + */ + sealed interface DeletionFailure permits DefaultDeletionResult.DefaultDeletionFailure { + + /** + * Return the path that could not be deleted. + * + * @return the path; never {@code null} + */ + Path path(); + + /** + * Return the exception that caused the failure. + * + * @return the cause; never {@code null} + */ + Exception cause(); + + } + + /** + * Exception thrown when one or more paths in a temporary directory could + * not be deleted by a {@link TempDirDeletionStrategy}. + */ + final class DeletionException extends JUnitException { + + @Serial + private static final long serialVersionUID = 1L; + + DeletionException(String message) { + super(message, null, true, false); + } + } + +} diff --git a/junit-jupiter-api/src/testFixtures/java/org/junit/jupiter/api/io/FailingTempDirDeletionStrategy.java b/junit-jupiter-api/src/testFixtures/java/org/junit/jupiter/api/io/FailingTempDirDeletionStrategy.java new file mode 100644 index 000000000000..d1c0ec368753 --- /dev/null +++ b/junit-jupiter-api/src/testFixtures/java/org/junit/jupiter/api/io/FailingTempDirDeletionStrategy.java @@ -0,0 +1,47 @@ +/* + * Copyright 2026 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.jupiter.api.io; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.jspecify.annotations.NullMarked; +import org.junit.jupiter.api.extension.AnnotatedElementContext; +import org.junit.jupiter.api.extension.ExtensionContext; + +/** + * A {@link TempDirDeletionStrategy} for testing that simulates a deletion + * failure for any path ending in {@link #UNDELETABLE_PATH}. + */ +@NullMarked +public class FailingTempDirDeletionStrategy implements TempDirDeletionStrategy { + + /** + * A path segment that, when present at the end of a path, causes deletion + * to fail with a simulated {@link java.io.IOException}. + */ + public static final Path UNDELETABLE_PATH = Path.of("undeletable"); + + @Override + public DeletionResult delete(Path tempDir, AnnotatedElementContext elementContext, + ExtensionContext extensionContext) throws IOException { + + return Standard.INSTANCE.delete(tempDir, path -> { + if (path.endsWith(UNDELETABLE_PATH)) { + throw new IOException("Simulated failure"); + } + else { + Files.delete(path); + } + }); + } +} diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/CachingJupiterConfiguration.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/CachingJupiterConfiguration.java index 46aaa4c87e8b..1905380f9b2c 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/CachingJupiterConfiguration.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/CachingJupiterConfiguration.java @@ -15,6 +15,9 @@ import static org.junit.jupiter.api.Constants.DEFAULT_CLASSES_EXECUTION_MODE_PROPERTY_NAME; import static org.junit.jupiter.api.Constants.DEFAULT_DISPLAY_NAME_GENERATOR_PROPERTY_NAME; import static org.junit.jupiter.api.Constants.DEFAULT_EXECUTION_MODE_PROPERTY_NAME; +import static org.junit.jupiter.api.Constants.DEFAULT_TEMP_DIR_CLEANUP_MODE_PROPERTY_NAME; +import static org.junit.jupiter.api.Constants.DEFAULT_TEMP_DIR_DELETION_STRATEGY_PROPERTY_NAME; +import static org.junit.jupiter.api.Constants.DEFAULT_TEMP_DIR_FACTORY_PROPERTY_NAME; import static org.junit.jupiter.api.Constants.DEFAULT_TEST_CLASS_INSTANCE_CONSTRUCTION_EXTENSION_CONTEXT_SCOPE_PROPERTY_NAME; import static org.junit.jupiter.api.Constants.DEFAULT_TEST_CLASS_ORDER_PROPERTY_NAME; import static org.junit.jupiter.api.Constants.DEFAULT_TEST_INSTANCE_LIFECYCLE_PROPERTY_NAME; @@ -22,8 +25,6 @@ import static org.junit.jupiter.api.Constants.EXTENSIONS_AUTODETECTION_ENABLED_PROPERTY_NAME; import static org.junit.jupiter.api.Constants.EXTENSIONS_TIMEOUT_THREAD_DUMP_ENABLED_PROPERTY_NAME; import static org.junit.jupiter.api.Constants.PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME; -import static org.junit.jupiter.api.io.TempDir.DEFAULT_CLEANUP_MODE_PROPERTY_NAME; -import static org.junit.jupiter.api.io.TempDir.DEFAULT_FACTORY_PROPERTY_NAME; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; @@ -41,6 +42,7 @@ import org.junit.jupiter.api.extension.Extension; import org.junit.jupiter.api.extension.TestInstantiationAwareExtension.ExtensionContextScope; import org.junit.jupiter.api.io.CleanupMode; +import org.junit.jupiter.api.io.TempDirDeletionStrategy; import org.junit.jupiter.api.io.TempDirFactory; import org.junit.jupiter.api.parallel.ExecutionMode; import org.junit.platform.engine.OutputDirectoryCreator; @@ -146,17 +148,25 @@ public Optional getDefaultTestClassOrderer() { @Override public CleanupMode getDefaultTempDirCleanupMode() { - return (CleanupMode) cache.computeIfAbsent(DEFAULT_CLEANUP_MODE_PROPERTY_NAME, + return (CleanupMode) cache.computeIfAbsent(DEFAULT_TEMP_DIR_CLEANUP_MODE_PROPERTY_NAME, __ -> delegate.getDefaultTempDirCleanupMode()); } @SuppressWarnings("unchecked") @Override public Supplier getDefaultTempDirFactorySupplier() { - return (Supplier) cache.computeIfAbsent(DEFAULT_FACTORY_PROPERTY_NAME, + return (Supplier) cache.computeIfAbsent(DEFAULT_TEMP_DIR_FACTORY_PROPERTY_NAME, __ -> delegate.getDefaultTempDirFactorySupplier()); } + @SuppressWarnings("unchecked") + @Override + public Supplier getDefaultTempDirDeletionStrategySupplier() { + return (Supplier) cache.computeIfAbsent( + DEFAULT_TEMP_DIR_DELETION_STRATEGY_PROPERTY_NAME, + __ -> delegate.getDefaultTempDirDeletionStrategySupplier()); + } + @Override public ExtensionContextScope getDefaultTestInstantiationExtensionContextScope() { return (ExtensionContextScope) cache.computeIfAbsent( diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/DefaultJupiterConfiguration.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/DefaultJupiterConfiguration.java index 41ed916a968a..4a7690fda87d 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/DefaultJupiterConfiguration.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/DefaultJupiterConfiguration.java @@ -17,6 +17,9 @@ import static org.junit.jupiter.api.Constants.DEFAULT_CLASSES_EXECUTION_MODE_PROPERTY_NAME; import static org.junit.jupiter.api.Constants.DEFAULT_DISPLAY_NAME_GENERATOR_PROPERTY_NAME; import static org.junit.jupiter.api.Constants.DEFAULT_EXECUTION_MODE_PROPERTY_NAME; +import static org.junit.jupiter.api.Constants.DEFAULT_TEMP_DIR_CLEANUP_MODE_PROPERTY_NAME; +import static org.junit.jupiter.api.Constants.DEFAULT_TEMP_DIR_DELETION_STRATEGY_PROPERTY_NAME; +import static org.junit.jupiter.api.Constants.DEFAULT_TEMP_DIR_FACTORY_PROPERTY_NAME; import static org.junit.jupiter.api.Constants.DEFAULT_TEST_CLASS_INSTANCE_CONSTRUCTION_EXTENSION_CONTEXT_SCOPE_PROPERTY_NAME; import static org.junit.jupiter.api.Constants.DEFAULT_TEST_CLASS_ORDER_PROPERTY_NAME; import static org.junit.jupiter.api.Constants.DEFAULT_TEST_INSTANCE_LIFECYCLE_PROPERTY_NAME; @@ -28,8 +31,6 @@ import static org.junit.jupiter.api.Constants.PARALLEL_CONFIG_EXECUTOR_SERVICE_PROPERTY_NAME; import static org.junit.jupiter.api.Constants.PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME; import static org.junit.jupiter.api.io.CleanupMode.ALWAYS; -import static org.junit.jupiter.api.io.TempDir.DEFAULT_CLEANUP_MODE_PROPERTY_NAME; -import static org.junit.jupiter.api.io.TempDir.DEFAULT_FACTORY_PROPERTY_NAME; import static org.junit.jupiter.engine.config.FilteringConfigurationParameterConverter.exclude; import static org.junit.platform.engine.support.hierarchical.ParallelHierarchicalTestExecutorServiceFactory.ParallelExecutorServiceType.FORK_JOIN_POOL; import static org.junit.platform.engine.support.hierarchical.ParallelHierarchicalTestExecutorServiceFactory.ParallelExecutorServiceType.WORKER_THREAD_POOL; @@ -49,6 +50,7 @@ import org.junit.jupiter.api.extension.Extension; import org.junit.jupiter.api.extension.TestInstantiationAwareExtension.ExtensionContextScope; import org.junit.jupiter.api.io.CleanupMode; +import org.junit.jupiter.api.io.TempDirDeletionStrategy; import org.junit.jupiter.api.io.TempDirFactory; import org.junit.jupiter.api.parallel.ExecutionMode; import org.junit.platform.commons.util.ClassNamePatternFilterUtils; @@ -95,6 +97,9 @@ public class DefaultJupiterConfiguration implements JupiterConfiguration { private static final InstantiatingConfigurationParameterConverter tempDirFactoryConverter = // new InstantiatingConfigurationParameterConverter<>(TempDirFactory.class, "temp dir factory"); + private static final InstantiatingConfigurationParameterConverter tempDirDeletionStrategyConverter = // + new InstantiatingConfigurationParameterConverter<>(TempDirDeletionStrategy.class, "temp dir deletion strategy"); + private static final ConfigurationParameterConverter extensionContextScopeConverter = // new EnumConfigurationParameterConverter<>(ExtensionContextScope.class, "extension context scope"); @@ -221,16 +226,24 @@ public Optional getDefaultTestClassOrderer() { @Override public CleanupMode getDefaultTempDirCleanupMode() { - return cleanupModeConverter.getOrDefault(configurationParameters, DEFAULT_CLEANUP_MODE_PROPERTY_NAME, ALWAYS); + return cleanupModeConverter.getOrDefault(configurationParameters, DEFAULT_TEMP_DIR_CLEANUP_MODE_PROPERTY_NAME, + ALWAYS); } @Override public Supplier getDefaultTempDirFactorySupplier() { Supplier> supplier = tempDirFactoryConverter.supply(configurationParameters, - DEFAULT_FACTORY_PROPERTY_NAME); + DEFAULT_TEMP_DIR_FACTORY_PROPERTY_NAME); return () -> supplier.get().orElse(TempDirFactory.Standard.INSTANCE); } + @Override + public Supplier getDefaultTempDirDeletionStrategySupplier() { + Supplier> supplier = tempDirDeletionStrategyConverter.supply( + configurationParameters, DEFAULT_TEMP_DIR_DELETION_STRATEGY_PROPERTY_NAME); + return () -> supplier.get().orElse(TempDirDeletionStrategy.Standard.INSTANCE); + } + @SuppressWarnings("deprecation") @Override public ExtensionContextScope getDefaultTestInstantiationExtensionContextScope() { diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/JupiterConfiguration.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/JupiterConfiguration.java index 276021e5067c..62c35c5f40b7 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/JupiterConfiguration.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/JupiterConfiguration.java @@ -26,6 +26,7 @@ import org.junit.jupiter.api.extension.Extension; import org.junit.jupiter.api.extension.TestInstantiationAwareExtension.ExtensionContextScope; import org.junit.jupiter.api.io.CleanupMode; +import org.junit.jupiter.api.io.TempDirDeletionStrategy; import org.junit.jupiter.api.io.TempDirFactory; import org.junit.jupiter.api.parallel.ExecutionMode; import org.junit.platform.engine.OutputDirectoryCreator; @@ -68,6 +69,8 @@ public interface JupiterConfiguration { Supplier getDefaultTempDirFactorySupplier(); + Supplier getDefaultTempDirDeletionStrategySupplier(); + ExtensionContextScope getDefaultTestInstantiationExtensionContextScope(); OutputDirectoryCreator getOutputDirectoryCreator(); diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/TempDirectory.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/TempDirectory.java index 94f9565b5a4c..2889b0d9d53a 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/TempDirectory.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/TempDirectory.java @@ -10,13 +10,12 @@ package org.junit.jupiter.engine.extension; -import static java.nio.file.FileVisitResult.CONTINUE; -import static java.nio.file.FileVisitResult.SKIP_SUBTREE; -import static java.util.stream.Collectors.joining; +import static java.util.Objects.requireNonNull; import static org.junit.jupiter.api.extension.TestInstantiationAwareExtension.ExtensionContextScope.TEST_METHOD; import static org.junit.jupiter.api.io.CleanupMode.DEFAULT; import static org.junit.jupiter.api.io.CleanupMode.NEVER; import static org.junit.jupiter.api.io.CleanupMode.ON_SUCCESS; +import static org.junit.jupiter.api.io.TempDirDeletionStrategy.IgnoreFailures.descriptionFor; import static org.junit.platform.commons.support.AnnotationSupport.findAnnotatedFields; import static org.junit.platform.commons.support.AnnotationSupport.findAnnotation; import static org.junit.platform.commons.support.ReflectionSupport.makeAccessible; @@ -25,26 +24,13 @@ import java.io.File; import java.io.IOException; import java.lang.reflect.AnnotatedElement; -import java.lang.reflect.Constructor; -import java.lang.reflect.Executable; import java.lang.reflect.Field; import java.lang.reflect.Parameter; -import java.nio.file.DirectoryNotEmptyException; import java.nio.file.FileSystems; -import java.nio.file.FileVisitResult; import java.nio.file.Files; -import java.nio.file.LinkOption; -import java.nio.file.NoSuchFileException; import java.nio.file.Path; -import java.nio.file.SimpleFileVisitor; -import java.nio.file.attribute.BasicFileAttributes; -import java.nio.file.attribute.DosFileAttributeView; -import java.util.Collections; -import java.util.HashSet; -import java.util.Set; -import java.util.SortedMap; -import java.util.TreeMap; import java.util.function.Predicate; +import java.util.function.Supplier; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.extension.AnnotatedElementContext; @@ -58,6 +44,7 @@ import org.junit.jupiter.api.extension.ParameterResolver; import org.junit.jupiter.api.io.CleanupMode; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.io.TempDirDeletionStrategy; import org.junit.jupiter.api.io.TempDirFactory; import org.junit.jupiter.engine.config.JupiterConfiguration; import org.junit.platform.commons.JUnitException; @@ -66,7 +53,6 @@ import org.junit.platform.commons.logging.LoggerFactory; import org.junit.platform.commons.support.ModifierSupport; import org.junit.platform.commons.support.ReflectionSupport; -import org.junit.platform.commons.util.ClassUtils; import org.junit.platform.commons.util.ExceptionUtils; import org.junit.platform.commons.util.Preconditions; import org.junit.platform.commons.util.ToStringBuilder; @@ -85,10 +71,7 @@ */ class TempDirectory implements BeforeAllCallback, BeforeEachCallback, ParameterResolver { - // package-private for testing purposes - static final Namespace NAMESPACE = Namespace.create(TempDirectory.class); - static final String FILE_OPERATIONS_KEY = "file.operations"; - + private static final Namespace NAMESPACE = Namespace.create(TempDirectory.class); private static final String KEY = "temp.dir"; private static final String FAILURE_TRACKER = "failure.tracker"; private static final String CHILD_FAILED = "child.failed"; @@ -155,10 +138,9 @@ private void injectFields(ExtensionContext context, @Nullable Object testInstanc assertSupportedType("field", field.getType()); try { - CleanupMode cleanupMode = determineCleanupModeForField(field); - TempDirFactory factory = determineTempDirFactoryForField(field); + TempDir tempDir = findAnnotationOnField(field); makeAccessible(field).set(testInstance, - getPathOrFile(field.getType(), new FieldContext(field), factory, cleanupMode, context)); + getPathOrFile(field.getType(), new FieldContext(field), context, tempDir)); } catch (Throwable t) { throw ExceptionUtils.throwAsUncheckedException(t); @@ -183,38 +165,30 @@ public boolean supportsParameter(ParameterContext parameterContext, ExtensionCon public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) { Class parameterType = parameterContext.getParameter().getType(); assertSupportedType("parameter", parameterType); - CleanupMode cleanupMode = determineCleanupModeForParameter(parameterContext); - TempDirFactory factory = determineTempDirFactoryForParameter(parameterContext); - return getPathOrFile(parameterType, parameterContext, factory, cleanupMode, extensionContext); + TempDir tempDir = findAnnotationOnParameter(parameterContext); + return getPathOrFile(parameterType, parameterContext, extensionContext, tempDir); } - private CleanupMode determineCleanupModeForField(Field field) { - TempDir tempDir = findAnnotation(field, TempDir.class).orElseThrow( + private static TempDir findAnnotationOnField(Field field) { + return findAnnotation(field, TempDir.class).orElseThrow( () -> new JUnitException("Field " + field + " must be annotated with @TempDir")); - return determineCleanupMode(tempDir); } - private CleanupMode determineCleanupModeForParameter(ParameterContext parameterContext) { - TempDir tempDir = parameterContext.findAnnotation(TempDir.class).orElseThrow(() -> new JUnitException( + private static TempDir findAnnotationOnParameter(ParameterContext parameterContext) { + return parameterContext.findAnnotation(TempDir.class).orElseThrow(() -> new JUnitException( "Parameter " + parameterContext.getParameter() + " must be annotated with @TempDir")); - return determineCleanupMode(tempDir); } - private CleanupMode determineCleanupMode(TempDir tempDir) { - CleanupMode cleanupMode = tempDir.cleanup(); + private CleanupMode determineCleanupMode(TempDir annotation) { + var cleanupMode = annotation.cleanup(); return cleanupMode == DEFAULT ? this.configuration.getDefaultTempDirCleanupMode() : cleanupMode; } - private TempDirFactory determineTempDirFactoryForField(Field field) { - TempDir tempDir = findAnnotation(field, TempDir.class).orElseThrow( - () -> new JUnitException("Field " + field + " must be annotated with @TempDir")); - return determineTempDirFactory(tempDir); - } - - private TempDirFactory determineTempDirFactoryForParameter(ParameterContext parameterContext) { - TempDir tempDir = parameterContext.findAnnotation(TempDir.class).orElseThrow(() -> new JUnitException( - "Parameter " + parameterContext.getParameter() + " must be annotated with @TempDir")); - return determineTempDirFactory(tempDir); + private Supplier determineDeletionStrategy(TempDir annotation) { + var strategyClass = annotation.deletionStrategy(); + return strategyClass == TempDirDeletionStrategy.class // + ? this.configuration.getDefaultTempDirDeletionStrategySupplier() // + : () -> ReflectionSupport.newInstance(strategyClass); } private TempDirFactory determineTempDirFactory(TempDir tempDir) { @@ -238,23 +212,30 @@ private static void assertSupportedType(String target, Class type) { } } + private Object getPathOrFile(Class elementType, AnnotatedElementContext elementContext, + ExtensionContext extensionContext, TempDir tempDir) { + TempDirFactory factory = determineTempDirFactory(tempDir); + Cleanup cleanup = new Cleanup(determineCleanupMode(tempDir), determineDeletionStrategy(tempDir)); + return getPathOrFile(elementType, elementContext, factory, cleanup, extensionContext); + } + private static Object getPathOrFile(Class elementType, AnnotatedElementContext elementContext, - TempDirFactory factory, CleanupMode cleanupMode, ExtensionContext extensionContext) { + TempDirFactory factory, Cleanup cleanup, ExtensionContext extensionContext) { Path path = extensionContext.getStore(NAMESPACE.append(elementContext)) // .computeIfAbsent(KEY, - __ -> createTempDir(factory, cleanupMode, elementType, elementContext, extensionContext), + __ -> createTempDir(factory, cleanup, elementType, elementContext, extensionContext), CloseablePath.class) // .get(); return (elementType == Path.class) ? path : path.toFile(); } - static CloseablePath createTempDir(TempDirFactory factory, CleanupMode cleanupMode, Class elementType, + static CloseablePath createTempDir(TempDirFactory factory, Cleanup cleanup, Class elementType, AnnotatedElementContext elementContext, ExtensionContext extensionContext) { try { - return new CloseablePath(factory, cleanupMode, elementType, elementContext, extensionContext); + return new CloseablePath(factory, cleanup, elementType, elementContext, extensionContext); } catch (Exception ex) { throw new ExtensionConfigurationException("Failed to create default temp directory", ex); @@ -273,20 +254,18 @@ private static ExtensionContext.Store getContextSpecificStore(ExtensionContext c @SuppressWarnings("deprecation") static class CloseablePath implements Store.CloseableResource, AutoCloseable { - private static final Logger LOGGER = LoggerFactory.getLogger(CloseablePath.class); - - private final Path dir; + private final @Nullable Path dir; private final TempDirFactory factory; - private final CleanupMode cleanupMode; - private final AnnotatedElement annotatedElement; + private final Cleanup cleanup; + private final AnnotatedElementContext elementContext; private final ExtensionContext extensionContext; - private CloseablePath(TempDirFactory factory, CleanupMode cleanupMode, Class elementType, + private CloseablePath(TempDirFactory factory, Cleanup cleanup, Class elementType, AnnotatedElementContext elementContext, ExtensionContext extensionContext) throws Exception { this.dir = factory.createTempDirectory(elementContext, extensionContext); this.factory = factory; - this.cleanupMode = cleanupMode; - this.annotatedElement = elementContext.getAnnotatedElement(); + this.cleanup = cleanup; + this.elementContext = elementContext; this.extensionContext = extensionContext; if (this.dir == null || !Files.isDirectory(this.dir)) { @@ -303,254 +282,20 @@ private CloseablePath(TempDirFactory factory, CleanupMode cleanupMode, Class } Path get() { - return this.dir; + return requireNonNull(this.dir); } @Override public void close() throws IOException { try { - if (this.cleanupMode == NEVER - || (this.cleanupMode == ON_SUCCESS && selfOrChildFailed(this.extensionContext))) { - LOGGER.info(() -> "Skipping cleanup of temp dir %s for %s due to CleanupMode.%s.".formatted( - this.dir, descriptionFor(this.annotatedElement), this.cleanupMode.name())); - return; - } - - FileOperations fileOperations = this.extensionContext.getStore(NAMESPACE) // - .getOrDefault(FILE_OPERATIONS_KEY, FileOperations.class, FileOperations.DEFAULT); - FileOperations loggingFileOperations = file -> { - LOGGER.trace(() -> "Attempting to delete " + file); - try { - fileOperations.delete(file); - LOGGER.trace(() -> "Successfully deleted " + file); - } - catch (IOException e) { - LOGGER.trace(e, () -> "Failed to delete " + file); - throw e; - } - }; - - LOGGER.trace(() -> "Cleaning up temp dir " + this.dir); - SortedMap failures = deleteAllFilesAndDirectories(loggingFileOperations); - if (!failures.isEmpty()) { - throw createIOExceptionWithAttachedFailures(failures); + if (this.dir != null) { + this.cleanup.run(this.dir, this.elementContext, this.extensionContext); } } finally { this.factory.close(); } } - - /** - * @since 5.12 - */ - private static String descriptionFor(AnnotatedElement annotatedElement) { - if (annotatedElement instanceof Field field) { - return "field " + field.getDeclaringClass().getSimpleName() + "." + field.getName(); - } - if (annotatedElement instanceof Parameter parameter) { - Executable executable = parameter.getDeclaringExecutable(); - return "parameter '" + parameter.getName() + "' in " + descriptionFor(executable); - } - throw new IllegalStateException("Unsupported AnnotatedElement type for @TempDir: " + annotatedElement); - } - - /** - * @since 5.12 - */ - private static String descriptionFor(Executable executable) { - boolean isConstructor = executable instanceof Constructor; - String type = isConstructor ? "constructor" : "method"; - String name = isConstructor ? executable.getDeclaringClass().getSimpleName() : executable.getName(); - return "%s %s(%s)".formatted(type, name, - ClassUtils.nullSafeToString(Class::getSimpleName, executable.getParameterTypes())); - } - - private SortedMap deleteAllFilesAndDirectories(FileOperations fileOperations) - throws IOException { - - Path rootDir = this.dir; - if (rootDir == null || Files.notExists(rootDir)) { - return Collections.emptySortedMap(); - } - - SortedMap failures = new TreeMap<>(); - Set retriedPaths = new HashSet<>(); - Path rootRealPath = rootDir.toRealPath(); - - tryToResetPermissions(rootDir); - Files.walkFileTree(rootDir, new SimpleFileVisitor() { - - @Override - public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { - LOGGER.trace(() -> "preVisitDirectory: " + dir); - if (isLinkWithTargetOutsideTempDir(dir)) { - warnAboutLinkWithTargetOutsideTempDir("link", dir); - delete(dir); - return SKIP_SUBTREE; - } - if (!dir.equals(rootDir)) { - tryToResetPermissions(dir); - } - return CONTINUE; - } - - @Override - public FileVisitResult visitFileFailed(Path file, IOException exc) { - LOGGER.trace(exc, () -> "visitFileFailed: " + file); - if (exc instanceof NoSuchFileException && !Files.exists(file, LinkOption.NOFOLLOW_LINKS)) { - return CONTINUE; - } - // IOException includes `AccessDeniedException` thrown by non-readable or non-executable flags - resetPermissionsAndTryToDeleteAgain(file, exc); - return CONTINUE; - } - - @Override - public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException { - LOGGER.trace(() -> "visitFile: " + file); - if (Files.isSymbolicLink(file) && isLinkWithTargetOutsideTempDir(file)) { - warnAboutLinkWithTargetOutsideTempDir("symbolic link", file); - } - delete(file); - return CONTINUE; - } - - @Override - public FileVisitResult postVisitDirectory(Path dir, IOException exc) { - LOGGER.trace(exc, () -> "postVisitDirectory: " + dir); - delete(dir); - return CONTINUE; - } - - private boolean isLinkWithTargetOutsideTempDir(Path path) { - // While `Files.walkFileTree` does not follow symbolic links, it may follow other links - // such as "junctions" on Windows - try { - return !path.toRealPath().startsWith(rootRealPath); - } - catch (IOException e) { - LOGGER.trace(e, - () -> "Failed to determine real path for " + path + "; assuming it is not a link"); - return false; - } - } - - private void warnAboutLinkWithTargetOutsideTempDir(String linkType, Path file) throws IOException { - Path realPath = file.toRealPath(); - LOGGER.warn(() -> """ - Deleting %s from location inside of temp dir (%s) \ - to location outside of temp dir (%s) but not the target file/directory""".formatted( - linkType, file, realPath)); - } - - private void delete(Path path) { - try { - fileOperations.delete(path); - } - catch (NoSuchFileException ignore) { - // ignore - } - catch (DirectoryNotEmptyException exception) { - failures.put(path, exception); - } - catch (IOException exception) { - // IOException includes `AccessDeniedException` thrown by non-readable or non-executable flags - resetPermissionsAndTryToDeleteAgain(path, exception); - } - } - - private void resetPermissionsAndTryToDeleteAgain(Path path, IOException exception) { - boolean notYetRetried = retriedPaths.add(path); - if (notYetRetried) { - try { - tryToResetPermissions(path); - if (Files.isDirectory(path)) { - Files.walkFileTree(path, this); - } - else { - fileOperations.delete(path); - } - } - catch (Exception suppressed) { - exception.addSuppressed(suppressed); - failures.put(path, exception); - } - } - else { - failures.put(path, exception); - } - } - }); - return failures; - } - - @SuppressWarnings("ResultOfMethodCallIgnored") - private static void tryToResetPermissions(Path path) { - File file; - try { - file = path.toFile(); - } - catch (UnsupportedOperationException ignore) { - // Might happen when the `TempDirFactory` uses a custom `FileSystem` - return; - } - file.setReadable(true); - file.setWritable(true); - if (Files.isDirectory(path)) { - file.setExecutable(true); - } - DosFileAttributeView dos = Files.getFileAttributeView(path, DosFileAttributeView.class); - if (dos != null) { - try { - dos.setReadOnly(false); - } - catch (IOException ignore) { - // nothing we can do - } - } - } - - private IOException createIOExceptionWithAttachedFailures(SortedMap failures) { - Path emptyPath = Path.of(""); - String joinedPaths = failures.keySet().stream() // - .map(this::tryToDeleteOnExit) // - .map(this::relativizeSafely) // - .map(path -> emptyPath.equals(path) ? "" : path.toString()) // - .collect(joining(", ")); - IOException exception = new IOException("Failed to delete temp directory " + this.dir.toAbsolutePath() - + ". The following paths could not be deleted (see suppressed exceptions for details): " - + joinedPaths); - failures.values().forEach(exception::addSuppressed); - return exception; - } - - @SuppressWarnings("EmptyCatch") - private Path tryToDeleteOnExit(Path path) { - try { - path.toFile().deleteOnExit(); - } - catch (UnsupportedOperationException ignore) { - } - return path; - } - - private Path relativizeSafely(Path path) { - try { - return this.dir.relativize(path); - } - catch (IllegalArgumentException e) { - return path; - } - } - } - - interface FileOperations { - - FileOperations DEFAULT = Files::delete; - - void delete(Path path) throws IOException; - } private record FieldContext(Field field) implements AnnotatedElementContext { @@ -587,4 +332,28 @@ public void close() { } } + record Cleanup(CleanupMode cleanupMode, Supplier deletionStrategy) { + + private static final Logger LOGGER = LoggerFactory.getLogger(Cleanup.class); + + void run(Path dir, AnnotatedElementContext elementContext, ExtensionContext extensionContext) + throws IOException { + if (cleanupMode == NEVER || (cleanupMode == ON_SUCCESS && selfOrChildFailed(extensionContext))) { + LOGGER.info(() -> "Skipping cleanup of temp dir %s for %s due to CleanupMode.%s.".formatted(dir, + descriptionFor(elementContext.getAnnotatedElement()), cleanupMode.name())); + return; + } + + LOGGER.trace(() -> "Cleaning up temp dir " + dir); + if (Files.exists(dir)) { + deletionStrategy.get().delete(dir, elementContext, extensionContext) // + .toException() // + .ifPresent(exception -> { + throw exception; + }); + + } + } + } + } diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/api/io/TempDirDeletionStrategyTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/api/io/TempDirDeletionStrategyTests.java new file mode 100644 index 000000000000..10a403d32c9c --- /dev/null +++ b/jupiter-tests/src/test/java/org/junit/jupiter/api/io/TempDirDeletionStrategyTests.java @@ -0,0 +1,74 @@ +/* + * Copyright 2026 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.jupiter.api.io; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.logging.Level; +import java.util.logging.LogRecord; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.AnnotatedElementContext; +import org.junit.jupiter.api.fixtures.TrackLogRecords; +import org.junit.jupiter.api.io.TempDirDeletionStrategy.DeletionException; +import org.junit.jupiter.api.io.TempDirDeletionStrategy.IgnoreFailures; +import org.junit.platform.commons.logging.LogRecordListener; + +/** + * @since 6.1 + */ +class TempDirDeletionStrategyTests { + + @Nested + class IgnoreFailuresTests { + + @Test + void logsAndIgnoresFailures(@TempDir Path tempDir, @TrackLogRecords LogRecordListener log) throws Exception { + var undeletableDir = Files.createDirectory(tempDir.resolve("undeletable")); + var strategy = new IgnoreFailures(new FailingTempDirDeletionStrategy()); + + AnnotatedElementContext annotatedElementContext = mock(); + var testMethod = IgnoreFailuresTests.class.getDeclaredMethod("logsAndIgnoresFailures", Path.class, + LogRecordListener.class); + when(annotatedElementContext.getAnnotatedElement()).thenReturn(testMethod.getParameters()[0]); + + var result = strategy.delete(undeletableDir, annotatedElementContext, mock()); + + assertThat(result.isSuccessful()); + + var loggedWarnings = log.stream(IgnoreFailures.class, Level.WARNING).toList(); + + assertThat(loggedWarnings) // + .extracting(LogRecord::getMessage) // + .containsExactly( + "Failed to delete all temporary files for parameter 'tempDir' in method logsAndIgnoresFailures(Path, LogRecordListener)"); + + var exception = loggedWarnings.getFirst().getThrown(); + assertThat(exception).isInstanceOf(DeletionException.class); + assertThat(exception).hasMessage( + "Failed to delete temp directory %s. The following paths could not be deleted (see suppressed exceptions for details): ".formatted( + undeletableDir.toAbsolutePath())); + + assertThat(exception.getSuppressed()).hasSize(1); + assertThat(exception.getSuppressed()[0]) // + .isInstanceOf(IOException.class) // + .hasMessage("Simulated failure"); + } // + + } + +} diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/CloseablePathTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/CloseablePathTests.java index 492874a22682..dcae2ca3a96b 100644 --- a/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/CloseablePathTests.java +++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/CloseablePathTests.java @@ -23,7 +23,6 @@ import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.junit.jupiter.api.condition.OS.WINDOWS; import static org.junit.jupiter.api.io.CleanupMode.ALWAYS; -import static org.junit.jupiter.api.io.CleanupMode.DEFAULT; import static org.junit.jupiter.api.io.CleanupMode.NEVER; import static org.junit.jupiter.api.io.CleanupMode.ON_SUCCESS; import static org.mockito.ArgumentMatchers.any; @@ -52,6 +51,7 @@ import com.google.common.jimfs.Jimfs; import org.assertj.core.api.ThrowableAssert.ThrowingCallable; +import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.NullUnmarked; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.AfterEach; @@ -67,6 +67,7 @@ import org.junit.jupiter.api.fixtures.TrackLogRecords; import org.junit.jupiter.api.io.CleanupMode; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.io.TempDirDeletionStrategy; import org.junit.jupiter.api.io.TempDirFactory; import org.junit.jupiter.engine.AbstractJupiterTestEngineTests; import org.junit.jupiter.engine.execution.NamespaceAwareStore; @@ -130,9 +131,9 @@ void cleanupRoot() throws IOException { @ParameterizedTest @ElementTypeSource void factoryReturnsDirectoryDynamic(Class elementType) throws IOException { - TempDirFactory factory = (elementContext, extensionContext) -> createDirectory(root.resolve("directory")); + TempDirFactory factory = (_, _) -> createDirectory(root.resolve("directory")); - closeablePath = TempDirectory.createTempDir(factory, DEFAULT, elementType, elementContext, + closeablePath = TempDirectory.createTempDir(factory, cleanup(ALWAYS), elementType, elementContext, extensionContext); assertThat(closeablePath.get()).isDirectory(); @@ -145,10 +146,9 @@ void factoryReturnsDirectoryDynamic(Class elementType) throws IOException { @DisabledOnOs(WINDOWS) void factoryReturnsSymbolicLinkToDirectory(Class elementType) throws IOException { Path directory = createDirectory(root.resolve("directory")); - TempDirFactory factory = (elementContext, - extensionContext) -> createSymbolicLink(root.resolve("symbolicLink"), directory); + TempDirFactory factory = (_, _) -> createSymbolicLink(root.resolve("symbolicLink"), directory); - closeablePath = TempDirectory.createTempDir(factory, DEFAULT, elementType, elementContext, + closeablePath = TempDirectory.createTempDir(factory, cleanup(ALWAYS), elementType, elementContext, extensionContext); assertThat(closeablePath.get()).isDirectory(); @@ -161,7 +161,8 @@ void factoryReturnsSymbolicLinkToDirectory(Class elementType) throws IOExcept void factoryReturnsDirectoryOnNonDefaultFileSystemWithPath() throws IOException { TempDirFactory factory = new JimfsFactory(); - closeablePath = TempDirectory.createTempDir(factory, DEFAULT, Path.class, elementContext, extensionContext); + closeablePath = TempDirectory.createTempDir(factory, cleanup(ALWAYS), Path.class, elementContext, + extensionContext); assertThat(closeablePath.get()).isDirectory(); delete(closeablePath.get()); @@ -174,8 +175,8 @@ void factoryReturnsDirectoryOnNonDefaultFileSystemWithPath() throws IOException void factoryReturnsNull(Class elementType) throws IOException { TempDirFactory factory = spy(new Factory(null)); - assertThatExtensionConfigurationExceptionIsThrownBy( - () -> TempDirectory.createTempDir(factory, DEFAULT, elementType, elementContext, extensionContext)); + assertThatExtensionConfigurationExceptionIsThrownBy(() -> TempDirectory.createTempDir(factory, + cleanup(ALWAYS), elementType, elementContext, extensionContext)); verify(factory).close(); } @@ -187,8 +188,8 @@ void factoryReturnsFile(Class elementType) throws IOException { Path file = createFile(root.resolve("file")); TempDirFactory factory = spy(new Factory(file)); - assertThatExtensionConfigurationExceptionIsThrownBy( - () -> TempDirectory.createTempDir(factory, DEFAULT, elementType, elementContext, extensionContext)); + assertThatExtensionConfigurationExceptionIsThrownBy(() -> TempDirectory.createTempDir(factory, + cleanup(ALWAYS), elementType, elementContext, extensionContext)); verify(factory).close(); assertThat(file).doesNotExist(); @@ -203,8 +204,8 @@ void factoryReturnsSymbolicLinkToFile(Class elementType) throws IOException { Path symbolicLink = createSymbolicLink(root.resolve("symbolicLink"), file); TempDirFactory factory = spy(new Factory(symbolicLink)); - assertThatExtensionConfigurationExceptionIsThrownBy( - () -> TempDirectory.createTempDir(factory, DEFAULT, elementType, elementContext, extensionContext)); + assertThatExtensionConfigurationExceptionIsThrownBy(() -> TempDirectory.createTempDir(factory, + cleanup(ALWAYS), elementType, elementContext, extensionContext)); verify(factory).close(); assertThat(symbolicLink).doesNotExist(); @@ -218,7 +219,7 @@ void factoryReturnsDirectoryOnNonDefaultFileSystemWithFile() throws IOException TempDirFactory factory = spy(new JimfsFactory()); assertThatExceptionOfType(ExtensionConfigurationException.class)// - .isThrownBy(() -> TempDirectory.createTempDir(factory, DEFAULT, File.class, elementContext, + .isThrownBy(() -> TempDirectory.createTempDir(factory, cleanup(ALWAYS), File.class, elementContext, extensionContext))// .withMessage("Failed to create default temp directory")// .withCauseInstanceOf(PreconditionViolationException.class)// @@ -229,6 +230,7 @@ void factoryReturnsDirectoryOnNonDefaultFileSystemWithFile() throws IOException } // Mockito spying a lambda fails with: VM does not support modification of given type + @NullMarked private record Factory(Path path) implements TempDirFactory { @Override @@ -238,6 +240,7 @@ public Path createTempDirectory(AnnotatedElementContext elementContext, Extensio } + @NullMarked private static class JimfsFactory implements TempDirFactory { private final FileSystem fileSystem = Jimfs.newFileSystem(unix()); @@ -291,7 +294,8 @@ void cleanupTempDirectory() throws IOException { void always(Class elementType, @TrackLogRecords LogRecordListener listener) throws IOException { reset(factory); - closeablePath = TempDirectory.createTempDir(factory, ALWAYS, elementType, elementContext, extensionContext); + closeablePath = TempDirectory.createTempDir(factory, cleanup(ALWAYS), elementType, elementContext, + extensionContext); assertThat(closeablePath.get()).isDirectory(); closeablePath.close(); @@ -310,7 +314,8 @@ void never(Class elementType, @TrackLogRecords LogRecordListener listener) th when(elementContext.getAnnotatedElement()).thenReturn(TestCase.class.getDeclaredField("tempDir")); - closeablePath = TempDirectory.createTempDir(factory, NEVER, elementType, elementContext, extensionContext); + closeablePath = TempDirectory.createTempDir(factory, cleanup(NEVER), elementType, elementContext, + extensionContext); assertThat(closeablePath.get()).isDirectory(); closeablePath.close(); @@ -368,7 +373,7 @@ private void onSuccessWithException(Class elementType, @TrackLogRecords LogRe when(extensionContext.getExecutionException()).thenReturn(Optional.of(new Exception())); when(elementContext.getAnnotatedElement()).thenReturn(annotatedElement); - closeablePath = TempDirectory.createTempDir(factory, ON_SUCCESS, elementType, elementContext, + closeablePath = TempDirectory.createTempDir(factory, cleanup(ON_SUCCESS), elementType, elementContext, extensionContext); assertThat(closeablePath.get()).isDirectory(); @@ -390,7 +395,7 @@ void onSuccessWithNoException(Class elementType, @TrackLogRecords LogRecordLi when(extensionContext.getExecutionException()).thenReturn(Optional.empty()); - closeablePath = TempDirectory.createTempDir(factory, ON_SUCCESS, elementType, elementContext, + closeablePath = TempDirectory.createTempDir(factory, cleanup(ON_SUCCESS), elementType, elementContext, extensionContext); assertThat(closeablePath.get()).isDirectory(); @@ -411,7 +416,7 @@ void deletesSymbolicLinksTargetingDirInsideTempDir(Class elementType, reset(factory); - closeablePath = TempDirectory.createTempDir(factory, ON_SUCCESS, elementType, elementContext, + closeablePath = TempDirectory.createTempDir(factory, cleanup(ON_SUCCESS), elementType, elementContext, extensionContext); var rootDir = closeablePath.get(); assertThat(rootDir).isDirectory(); @@ -437,7 +442,7 @@ void deletesSymbolicLinksTargetingDirOutsideTempDir(Class elementType, reset(factory); - closeablePath = TempDirectory.createTempDir(factory, ON_SUCCESS, elementType, elementContext, + closeablePath = TempDirectory.createTempDir(factory, cleanup(ON_SUCCESS), elementType, elementContext, extensionContext); var rootDir = closeablePath.get(); assertThat(rootDir).isDirectory(); @@ -463,6 +468,10 @@ void deletesSymbolicLinksTargetingDirOutsideTempDir(Class elementType, } } + private static TempDirectory.Cleanup cleanup(CleanupMode cleanupMode) { + return new TempDirectory.Cleanup(cleanupMode, () -> TempDirDeletionStrategy.Standard.INSTANCE); + } + @NullUnmarked static class TestCase { diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/TempDirectoryCleanupTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/TempDirectoryCleanupTests.java index 176f22cb4872..02d69812a83f 100644 --- a/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/TempDirectoryCleanupTests.java +++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/TempDirectoryCleanupTests.java @@ -13,6 +13,7 @@ import static java.nio.file.Files.deleteIfExists; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.jupiter.api.Constants.DEFAULT_TEMP_DIR_CLEANUP_MODE_PROPERTY_NAME; import static org.junit.jupiter.api.condition.OS.WINDOWS; import static org.junit.jupiter.api.io.CleanupMode.ALWAYS; import static org.junit.jupiter.api.io.CleanupMode.NEVER; @@ -88,7 +89,7 @@ void cleanupModeDefaultField() { @Test void cleanupModeCustomDefaultField() { LauncherDiscoveryRequest request = request()// - .configurationParameter(TempDir.DEFAULT_CLEANUP_MODE_PROPERTY_NAME, "never")// + .configurationParameter(DEFAULT_TEMP_DIR_CLEANUP_MODE_PROPERTY_NAME, "never")// .selectors(selectMethod(DefaultFieldCase.class, "testDefaultField"))// .build(); executeTests(request); @@ -351,7 +352,7 @@ void cleanupModeDefaultParameter() { @Test void cleanupModeCustomDefaultParameter() { LauncherDiscoveryRequest request = request()// - .configurationParameter(TempDir.DEFAULT_CLEANUP_MODE_PROPERTY_NAME, "never")// + .configurationParameter(DEFAULT_TEMP_DIR_CLEANUP_MODE_PROPERTY_NAME, "never")// .selectors(selectMethod(DefaultParameterCase.class, "testDefaultParameter", "java.nio.file.Path"))// .build(); executeTests(request); diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/TempDirectoryTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/TempDirectoryTests.java index 90b3a87107e4..70eb33ebaafd 100644 --- a/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/TempDirectoryTests.java +++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/TempDirectoryTests.java @@ -15,6 +15,7 @@ import static java.lang.annotation.ElementType.PARAMETER; import static java.lang.annotation.RetentionPolicy.RUNTIME; import static java.util.Objects.requireNonNull; +import static org.assertj.core.api.Assertions.allOf; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertNotEquals; @@ -24,6 +25,10 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; import static org.junit.jupiter.api.Assumptions.assumeTrue; +import static org.junit.jupiter.api.Constants.DEFAULT_TEMP_DIR_DELETION_STRATEGY_PROPERTY_NAME; +import static org.junit.jupiter.api.Constants.DEFAULT_TEMP_DIR_FACTORY_PROPERTY_NAME; +import static org.junit.jupiter.api.Constants.DEFAULT_TEST_INSTANCE_LIFECYCLE_PROPERTY_NAME; +import static org.junit.jupiter.api.io.FailingTempDirDeletionStrategy.UNDELETABLE_PATH; import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass; import static org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder.request; import static org.junit.platform.testkit.engine.EventConditions.finishedWithFailure; @@ -46,6 +51,7 @@ import java.util.HashMap; import java.util.LinkedHashMap; import java.util.Map; +import java.util.Objects; import com.github.marschall.memoryfilesystem.MemoryFileSystemBuilder; import com.google.common.jimfs.Configuration; @@ -59,7 +65,6 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Constants; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.MethodOrderer.OrderAnnotation; import org.junit.jupiter.api.Nested; @@ -72,22 +77,22 @@ import org.junit.jupiter.api.condition.DisabledOnOs; import org.junit.jupiter.api.condition.OS; import org.junit.jupiter.api.extension.AnnotatedElementContext; -import org.junit.jupiter.api.extension.BeforeEachCallback; import org.junit.jupiter.api.extension.ExtensionConfigurationException; import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.jupiter.api.extension.ParameterResolutionException; -import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.api.io.FailingTempDirDeletionStrategy; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.io.TempDirDeletionStrategy.DeletionException; import org.junit.jupiter.api.io.TempDirFactory; import org.junit.jupiter.api.io.TempDirFactory.Standard; import org.junit.jupiter.engine.AbstractJupiterTestEngineTests; -import org.junit.jupiter.engine.extension.TempDirectory.FileOperations; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.ValueSource; import org.junit.platform.commons.PreconditionViolationException; import org.junit.platform.engine.DiscoveryIssue; import org.junit.platform.engine.DiscoveryIssue.Severity; +import org.junit.platform.engine.TestExecutionResult; import org.junit.platform.engine.reporting.ReportEntry; import org.junit.platform.testkit.engine.EngineExecutionResults; @@ -97,14 +102,14 @@ * * @since 5.8 */ -@DisplayName("TempDirectory extension (per declaration)") +@DisplayName("TempDirectory extension") class TempDirectoryTests extends AbstractJupiterTestEngineTests { private EngineExecutionResults executeTestsForClassWithDefaultFactory(Class testClass, Class factoryClass) { return executeTests(request() // .selectors(selectClass(testClass)) // - .configurationParameter(TempDir.DEFAULT_FACTORY_PROPERTY_NAME, factoryClass.getName()) // + .configurationParameter(DEFAULT_TEMP_DIR_FACTORY_PROPERTY_NAME, factoryClass.getName()) // .build()); } @@ -121,8 +126,7 @@ void resetStaticVariables() { void resolvesSeparateTempDirsForEachAnnotationDeclaration(TestInstance.Lifecycle lifecycle) { var results = executeTests(request() // .selectors(selectClass(AllPossibleDeclarationLocationsTestCase.class)) // - .configurationParameter(Constants.DEFAULT_TEST_INSTANCE_LIFECYCLE_PROPERTY_NAME, - lifecycle.name()).build()); + .configurationParameter(DEFAULT_TEST_INSTANCE_LIFECYCLE_PROPERTY_NAME, lifecycle.name()).build()); results.containerEvents().assertStatistics(stats -> stats.started(2).succeeded(2)); results.testEvents().assertStatistics(stats -> stats.started(2).succeeded(2)); @@ -248,13 +252,29 @@ void canBeUsedViaStaticFieldInsideNestedTestClasses() { void onlyAttemptsToDeleteUndeletablePathsOnce(Class testClass) { var results = executeTestsForClass(testClass); - var tempDir = results.testEvents().reportingEntryPublished().stream().map( - it -> it.getPayload(ReportEntry.class).orElseThrow()).map( - it -> Path.of(it.getKeyValuePairs().get(UndeletableTestCase.TEMP_DIR))).findAny().orElseThrow(); + var tempDir = determineTempDirFromReportEntries(results, UndeletableTestCase.TEMP_DIR); + assertFailedDueToDeletionException(results, tempDir); + } + + @Test + @DisplayName("applies globally configured deletion strategy") + void appliesGloballyConfiguredDeletionStrategy() { + var results = executeTests(builder -> builder // + .selectors(selectClass(UndeletableWithDefaultDeletionStrategyTestCase.class)) // + .configurationParameter(DEFAULT_TEMP_DIR_DELETION_STRATEGY_PROPERTY_NAME, + FailingTempDirDeletionStrategy.class.getName())); + + var tempDir = determineTempDirFromReportEntries(results, + UndeletableWithDefaultDeletionStrategyTestCase.TEMP_DIR); + + assertFailedDueToDeletionException(results, tempDir); + } + + private static void assertFailedDueToDeletionException(EngineExecutionResults results, Path tempDir) { assertSingleFailedTest(results, // cause( // - instanceOf(IOException.class), // + instanceOf(DeletionException.class), // message("Failed to delete temp directory " + tempDir.toAbsolutePath() + ". " + // "The following paths could not be deleted (see suppressed exceptions for details): , undeletable"), // suppressed(0, instanceOf(DirectoryNotEmptyException.class)), // @@ -263,6 +283,14 @@ void onlyAttemptsToDeleteUndeletablePathsOnce(Class testClass) { ); } + private static Path determineTempDirFromReportEntries(EngineExecutionResults results, String key) { + return results.testEvents().reportingEntryPublished().stream() // + .map(it -> it.getPayload(ReportEntry.class).orElseThrow()) // + .map(it -> Path.of(it.getKeyValuePairs().get(key))) // + .findAny() // + .orElseThrow(); + } + @Test void usingTheRemovedScopeConfigurationParameterProducesWarning() { var results = discoverTests(request() // @@ -541,7 +569,12 @@ private static void assertSingleFailedTest(EngineExecutionResults results, Class @SuppressWarnings("varargs") private static void assertSingleFailedTest(EngineExecutionResults results, Condition... conditions) { results.testEvents().assertStatistics(stats -> stats.started(1).failed(1).succeeded(0)); - results.testEvents().assertThatEvents().haveExactly(1, finishedWithFailure(conditions)); + var failures = results.testEvents().stream().filter(finishedWithFailure()::matches) // + .map(e -> e.getPayload(TestExecutionResult.class).flatMap(TestExecutionResult::getThrowable).orElse( + null)) // + .filter(Objects::nonNull).toList(); + assertThat(failures).hasSize(1); + assertThat(failures.getFirst()).has(allOf(conditions)); } // ------------------------------------------------------------------------- @@ -1225,7 +1258,7 @@ static void afterAll(@TempDir Path param1, @TempDir Path param2, TestInfo testIn } private static Map getTempDirs(TestInfo testInfo) { - return tempDirs.computeIfAbsent(testInfo.getDisplayName(), __ -> new LinkedHashMap<>()); + return tempDirs.computeIfAbsent(testInfo.getDisplayName(), _ -> new LinkedHashMap<>()); } private static void assertAllTempDirsExist(TestInfo testInfo) { @@ -1235,22 +1268,9 @@ private static void assertAllTempDirsExist(TestInfo testInfo) { static class UndeletableTestCase { - static final Path UNDELETABLE_PATH = Path.of("undeletable"); static final String TEMP_DIR = "TEMP_DIR"; - @RegisterExtension - BeforeEachCallback injector = context -> context // - .getStore(TempDirectory.NAMESPACE) // - .put(TempDirectory.FILE_OPERATIONS_KEY, (FileOperations) path -> { - if (path.endsWith(UNDELETABLE_PATH)) { - throw new IOException("Simulated failure"); - } - else { - Files.delete(path); - } - }); - - @TempDir + @TempDir(deletionStrategy = FailingTempDirDeletionStrategy.class) Path tempDir; @BeforeEach @@ -1273,6 +1293,24 @@ void test() throws Exception { } } + static class UndeletableWithDefaultDeletionStrategyTestCase extends UndeletableTestCase { + + static final String TEMP_DIR = "TEMP_DIR"; + + @TempDir + Path tempDir; + + @BeforeEach + void reportTempDir(TestReporter reporter) { + reporter.publishEntry(TEMP_DIR, tempDir.toString()); + } + + @Test + void test() throws Exception { + Files.createFile(tempDir.resolve(UNDELETABLE_PATH)); + } + } + static class FactoryWithTestMethodNameAsPrefixTestCase { @Test