From 8023b15846a92347cc10baa9f179fa4d10f632ed Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Mon, 2 Mar 2026 18:41:52 +0100 Subject: [PATCH 01/15] Introduce `TempDirDeletionStrategy` Issue: #4567 --- .../org/junit/jupiter/api/io/TempDir.java | 9 +- .../api/io/TempDirDeletionStrategy.java | 238 +++++++++++ .../io/FailingTempDirDeletionStrategy.java | 40 ++ .../config/CachingJupiterConfiguration.java | 9 + .../config/DefaultJupiterConfiguration.java | 12 + .../engine/config/JupiterConfiguration.java | 3 + .../engine/extension/TempDirectory.java | 379 +++++------------- .../engine/extension/CloseablePathTests.java | 49 ++- .../engine/extension/TempDirectoryTests.java | 20 +- 9 files changed, 443 insertions(+), 316 deletions(-) create mode 100644 junit-jupiter-api/src/main/java/org/junit/jupiter/api/io/TempDirDeletionStrategy.java create mode 100644 junit-jupiter-api/src/testFixtures/java/org/junit/jupiter/api/io/FailingTempDirDeletionStrategy.java 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..5658fe58c33f 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; @@ -138,12 +139,18 @@ @API(status = MAINTAINED, since = "5.13.3") String DEFAULT_CLEANUP_MODE_PROPERTY_NAME = "junit.jupiter.tempdir.cleanup.mode.default"; + @API(status = EXPERIMENTAL, since = "6.1") + String DEFAULT_DELETION_STRATEGY_PROPERTY_NAME = "junit.jupiter.tempdir.deletion.strategy.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; + @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..a50d648cadaf --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/io/TempDirDeletionStrategy.java @@ -0,0 +1,238 @@ +/* + * 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 java.io.File; +import java.io.IOException; +import java.nio.file.DirectoryNotEmptyException; +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.Map; +import java.util.Set; +import java.util.SortedMap; +import java.util.TreeMap; + +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.logging.Logger; +import org.junit.platform.commons.logging.LoggerFactory; + +@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 Map} with an entry for every {@link Path} that could not + * be deleted and the corresponding {@link Exception}; empty, if deletion + * was successful; never {@code null} + * @throws IOException in case of general failures + */ + Map delete(Path tempDir, AnnotatedElementContext elementContext, ExtensionContext extensionContext) + throws IOException; + + class Standard implements TempDirDeletionStrategy { + + public static final TempDirDeletionStrategy INSTANCE = new Standard(); + + private static final Logger LOGGER = LoggerFactory.getLogger(Standard.class); + + public Standard() { + } + + @Override + public Map delete(Path tempDir, AnnotatedElementContext elementContext, + ExtensionContext extensionContext) throws IOException { + + return delete(tempDir, Files::delete); + } + + // package-private for testing + SortedMap delete(Path tempDir, FileOperations fileOperations) throws IOException { + SortedMap failures = new TreeMap<>(); + 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) { + 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 { + deleteWithLogging(path, fileOperations); + } + } + catch (Exception suppressed) { + exception.addSuppressed(suppressed); + failures.put(path, exception); + } + } + else { + failures.put(path, exception); + } + } + }); + + return failures; + } + + 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 + } + } + } + + // For testing only + interface FileOperations { + + void delete(Path path) throws IOException; + + } + } +} 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..cbebc109e2f0 --- /dev/null +++ b/junit-jupiter-api/src/testFixtures/java/org/junit/jupiter/api/io/FailingTempDirDeletionStrategy.java @@ -0,0 +1,40 @@ +/* + * 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 java.util.Map; + +import org.jspecify.annotations.NullMarked; +import org.junit.jupiter.api.extension.AnnotatedElementContext; +import org.junit.jupiter.api.extension.ExtensionContext; + +@NullMarked +public class FailingTempDirDeletionStrategy extends TempDirDeletionStrategy.Standard { + + public static final Path UNDELETABLE_PATH = Path.of("undeletable"); + + @Override + public Map delete(Path tempDir, AnnotatedElementContext elementContext, + ExtensionContext extensionContext) throws IOException { + + return super.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..9c1aee47de19 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 @@ -23,6 +23,7 @@ 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_DELETION_STRATEGY_PROPERTY_NAME; import static org.junit.jupiter.api.io.TempDir.DEFAULT_FACTORY_PROPERTY_NAME; import java.util.Optional; @@ -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; @@ -157,6 +159,13 @@ public Supplier getDefaultTempDirFactorySupplier() { __ -> delegate.getDefaultTempDirFactorySupplier()); } + @SuppressWarnings("unchecked") + @Override + public Supplier getDefaultTempDirDeletionStrategySupplier() { + return (Supplier) cache.computeIfAbsent(DEFAULT_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..8b01777fdfad 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 @@ -29,6 +29,7 @@ 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_DELETION_STRATEGY_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; @@ -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"); @@ -231,6 +236,13 @@ public Supplier getDefaultTempDirFactorySupplier() { return () -> supplier.get().orElse(TempDirFactory.Standard.INSTANCE); } + @Override + public Supplier getDefaultTempDirDeletionStrategySupplier() { + Supplier> supplier = tempDirDeletionStrategyConverter.supply( + configurationParameters, DEFAULT_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..b18857b9cd49 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,8 +10,7 @@ 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.Objects.requireNonNull; import static java.util.stream.Collectors.joining; import static org.junit.jupiter.api.extension.TestInstantiationAwareExtension.ExtensionContextScope.TEST_METHOD; import static org.junit.jupiter.api.io.CleanupMode.DEFAULT; @@ -29,22 +28,12 @@ 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.Map; import java.util.function.Predicate; +import java.util.function.Supplier; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.extension.AnnotatedElementContext; @@ -58,6 +47,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; @@ -85,10 +75,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 +142,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 +169,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 +216,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 +258,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,47 +286,77 @@ 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(); } } + } + + private record FieldContext(Field field) implements AnnotatedElementContext { + + private FieldContext(Field field) { + this.field = Preconditions.notNull(field, "field must not be null"); + } + + @Override + public AnnotatedElement getAnnotatedElement() { + return this.field; + } + + @Override + public String toString() { + // @formatter:off + return new ToStringBuilder(this) + .append("field", this.field) + .toString(); + // @formatter:on + } + + } + + @SuppressWarnings("deprecation") + private record FailureTracker(ExtensionContext context, ExtensionContext parentContext) + implements Store.CloseableResource, AutoCloseable { + + @Override + public void close() { + if (selfOrChildFailed(context)) { + getContextSpecificStore(parentContext).put(CHILD_FAILED, true); + } + } + } + + 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)) { + Map failures = deletionStrategy.get().delete(dir, elementContext, extensionContext); + if (!failures.isEmpty()) { + throw createIOExceptionWithAttachedFailures(dir, failures); + } + } + } - /** - * @since 5.12 - */ private static String descriptionFor(AnnotatedElement annotatedElement) { if (annotatedElement instanceof Field field) { return "field " + field.getDeclaringClass().getSimpleName() + "." + field.getName(); @@ -355,9 +368,6 @@ private static String descriptionFor(AnnotatedElement annotatedElement) { 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"; @@ -366,159 +376,14 @@ private static String descriptionFor(Executable executable) { 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) { + private IOException createIOExceptionWithAttachedFailures(Path rootDir, Map failures) { Path emptyPath = Path.of(""); String joinedPaths = failures.keySet().stream() // .map(this::tryToDeleteOnExit) // - .map(this::relativizeSafely) // + .map(path -> relativizeSafely(rootDir, path)) // .map(path -> emptyPath.equals(path) ? "" : path.toString()) // .collect(joining(", ")); - IOException exception = new IOException("Failed to delete temp directory " + this.dir.toAbsolutePath() + IOException exception = new IOException("Failed to delete temp directory " + rootDir.toAbsolutePath() + ". The following paths could not be deleted (see suppressed exceptions for details): " + joinedPaths); failures.values().forEach(exception::addSuppressed); @@ -535,9 +400,9 @@ private Path tryToDeleteOnExit(Path path) { return path; } - private Path relativizeSafely(Path path) { + private Path relativizeSafely(Path rootDir, Path path) { try { - return this.dir.relativize(path); + return rootDir.relativize(path); } catch (IllegalArgumentException e) { return path; @@ -545,46 +410,4 @@ private Path relativizeSafely(Path path) { } } - interface FileOperations { - - FileOperations DEFAULT = Files::delete; - - void delete(Path path) throws IOException; - - } - - private record FieldContext(Field field) implements AnnotatedElementContext { - - private FieldContext(Field field) { - this.field = Preconditions.notNull(field, "field must not be null"); - } - - @Override - public AnnotatedElement getAnnotatedElement() { - return this.field; - } - - @Override - public String toString() { - // @formatter:off - return new ToStringBuilder(this) - .append("field", this.field) - .toString(); - // @formatter:on - } - - } - - @SuppressWarnings("deprecation") - private record FailureTracker(ExtensionContext context, ExtensionContext parentContext) - implements Store.CloseableResource, AutoCloseable { - - @Override - public void close() { - if (selfOrChildFailed(context)) { - getContextSpecificStore(parentContext).put(CHILD_FAILED, true); - } - } - } - } 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/TempDirectoryTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/TempDirectoryTests.java index 90b3a87107e4..4a0a86c52705 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 @@ -24,6 +24,7 @@ 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.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; @@ -72,16 +73,14 @@ 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.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; @@ -1235,22 +1234,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 From d457eef8ea25fd3c498f40d9c57acde2112bf102 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Mon, 2 Mar 2026 18:45:54 +0100 Subject: [PATCH 02/15] Make `Standard` final --- .../org/junit/jupiter/api/io/TempDirDeletionStrategy.java | 6 +++--- .../jupiter/api/io/FailingTempDirDeletionStrategy.java | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) 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 index a50d648cadaf..95ca636baebb 100644 --- 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 @@ -60,13 +60,13 @@ public interface TempDirDeletionStrategy { Map delete(Path tempDir, AnnotatedElementContext elementContext, ExtensionContext extensionContext) throws IOException; - class Standard implements TempDirDeletionStrategy { + final class Standard implements TempDirDeletionStrategy { - public static final TempDirDeletionStrategy INSTANCE = new Standard(); + public static final Standard INSTANCE = new Standard(); private static final Logger LOGGER = LoggerFactory.getLogger(Standard.class); - public Standard() { + private Standard() { } @Override 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 index cbebc109e2f0..bef4ebbec6dd 100644 --- 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 @@ -20,7 +20,7 @@ import org.junit.jupiter.api.extension.ExtensionContext; @NullMarked -public class FailingTempDirDeletionStrategy extends TempDirDeletionStrategy.Standard { +public class FailingTempDirDeletionStrategy implements TempDirDeletionStrategy { public static final Path UNDELETABLE_PATH = Path.of("undeletable"); @@ -28,7 +28,7 @@ public class FailingTempDirDeletionStrategy extends TempDirDeletionStrategy.Stan public Map delete(Path tempDir, AnnotatedElementContext elementContext, ExtensionContext extensionContext) throws IOException { - return super.delete(tempDir, path -> { + return Standard.INSTANCE.delete(tempDir, path -> { if (path.endsWith(UNDELETABLE_PATH)) { throw new IOException("Simulated failure"); } From d6f3af5e0ae4882b3dcb0e34989c739be9239050 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Tue, 17 Mar 2026 12:54:52 +0100 Subject: [PATCH 03/15] Introduce `DeletionResult` and `DeletionFailure` result objects --- .../jupiter/api/io/DefaultDeletionResult.java | 45 +++++++++++++++ .../api/io/TempDirDeletionStrategy.java | 55 ++++++++++++++----- .../io/FailingTempDirDeletionStrategy.java | 3 +- .../engine/extension/TempDirectory.java | 21 ++++--- .../engine/extension/TempDirectoryTests.java | 13 ++++- 5 files changed, 112 insertions(+), 25 deletions(-) create mode 100644 junit-jupiter-api/src/main/java/org/junit/jupiter/api/io/DefaultDeletionResult.java 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..ed101abc91db --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/io/DefaultDeletionResult.java @@ -0,0 +1,45 @@ +/* + * 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.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.io.TempDirDeletionStrategy.DeletionFailure; +import org.junit.jupiter.api.io.TempDirDeletionStrategy.DeletionResult; + +record DefaultDeletionResult(List failures) implements DeletionResult { + + DefaultDeletionResult(List failures) { + this.failures = List.copyOf(failures); + } + + static final class Builder implements DeletionResult.Builder { + + private final List failures = new ArrayList<>(); + + @Override + public Builder addFailure(Path path, Exception cause) { + failures.add(new DefaultDeletionFailure(path, cause)); + return this; + } + + @Override + public DefaultDeletionResult build() { + return new DefaultDeletionResult(failures); + } + + } + + record DefaultDeletionFailure(Path path, Exception cause) implements DeletionFailure { + } +} 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 index 95ca636baebb..22bb997879c6 100644 --- 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 @@ -26,10 +26,8 @@ import java.nio.file.attribute.BasicFileAttributes; import java.nio.file.attribute.DosFileAttributeView; import java.util.HashSet; -import java.util.Map; +import java.util.List; import java.util.Set; -import java.util.SortedMap; -import java.util.TreeMap; import org.apiguardian.api.API; import org.jspecify.annotations.Nullable; @@ -52,12 +50,12 @@ public interface TempDirDeletionStrategy { * @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 Map} with an entry for every {@link Path} that could not - * be deleted and the corresponding {@link Exception}; empty, if deletion + * @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 */ - Map delete(Path tempDir, AnnotatedElementContext elementContext, ExtensionContext extensionContext) + DeletionResult delete(Path tempDir, AnnotatedElementContext elementContext, ExtensionContext extensionContext) throws IOException; final class Standard implements TempDirDeletionStrategy { @@ -70,15 +68,15 @@ private Standard() { } @Override - public Map delete(Path tempDir, AnnotatedElementContext elementContext, + public DeletionResult delete(Path tempDir, AnnotatedElementContext elementContext, ExtensionContext extensionContext) throws IOException { return delete(tempDir, Files::delete); } // package-private for testing - SortedMap delete(Path tempDir, FileOperations fileOperations) throws IOException { - SortedMap failures = new TreeMap<>(); + DeletionResult delete(Path tempDir, FileOperations fileOperations) throws IOException { + var result = DeletionResult.builder(); Set retriedPaths = new HashSet<>(); Path rootRealPath = tempDir.toRealPath(); @@ -156,7 +154,7 @@ private void delete(Path path, FileOperations fileOperations) { // ignore } catch (DirectoryNotEmptyException exception) { - failures.put(path, exception); + result.addFailure(path, exception); } catch (IOException exception) { // IOException includes `AccessDeniedException` thrown by non-readable or non-executable flags @@ -178,16 +176,16 @@ private void resetPermissionsAndTryToDeleteAgain(Path path, IOException exceptio } catch (Exception suppressed) { exception.addSuppressed(suppressed); - failures.put(path, exception); + result.addFailure(path, exception); } } else { - failures.put(path, exception); + result.addFailure(path, exception); } } }); - return failures; + return result.build(); } private void deleteWithLogging(Path file, FileOperations fileOperations) throws IOException { @@ -235,4 +233,35 @@ interface FileOperations { } } + + sealed interface DeletionResult permits DefaultDeletionResult { + + static Builder builder() { + return new DefaultDeletionResult.Builder(); + } + + default boolean isSuccessful() { + return failures().isEmpty(); + } + + List failures(); + + sealed interface Builder permits DefaultDeletionResult.Builder { + + Builder addFailure(Path path, Exception cause); + + DeletionResult build(); + + } + + } + + sealed interface DeletionFailure permits DefaultDeletionResult.DefaultDeletionFailure { + + Path path(); + + Exception cause(); + + } + } 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 index bef4ebbec6dd..8d38a59f1151 100644 --- 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 @@ -13,7 +13,6 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.util.Map; import org.jspecify.annotations.NullMarked; import org.junit.jupiter.api.extension.AnnotatedElementContext; @@ -25,7 +24,7 @@ public class FailingTempDirDeletionStrategy implements TempDirDeletionStrategy { public static final Path UNDELETABLE_PATH = Path.of("undeletable"); @Override - public Map delete(Path tempDir, AnnotatedElementContext elementContext, + public DeletionResult delete(Path tempDir, AnnotatedElementContext elementContext, ExtensionContext extensionContext) throws IOException { return Standard.INSTANCE.delete(tempDir, path -> { 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 b18857b9cd49..bf6f8d632220 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,6 +10,7 @@ package org.junit.jupiter.engine.extension; +import static java.util.Comparator.comparing; import static java.util.Objects.requireNonNull; import static java.util.stream.Collectors.joining; import static org.junit.jupiter.api.extension.TestInstantiationAwareExtension.ExtensionContextScope.TEST_METHOD; @@ -31,7 +32,7 @@ import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.Path; -import java.util.Map; +import java.util.List; import java.util.function.Predicate; import java.util.function.Supplier; @@ -48,6 +49,7 @@ 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.TempDirDeletionStrategy.DeletionFailure; import org.junit.jupiter.api.io.TempDirFactory; import org.junit.jupiter.engine.config.JupiterConfiguration; import org.junit.platform.commons.JUnitException; @@ -350,9 +352,9 @@ void run(Path dir, AnnotatedElementContext elementContext, ExtensionContext exte LOGGER.trace(() -> "Cleaning up temp dir " + dir); if (Files.exists(dir)) { - Map failures = deletionStrategy.get().delete(dir, elementContext, extensionContext); - if (!failures.isEmpty()) { - throw createIOExceptionWithAttachedFailures(dir, failures); + var result = deletionStrategy.get().delete(dir, elementContext, extensionContext); + if (!result.isSuccessful()) { + throw createIOExceptionWithAttachedFailures(dir, result.failures()); } } } @@ -376,17 +378,20 @@ private static String descriptionFor(Executable executable) { ClassUtils.nullSafeToString(Class::getSimpleName, executable.getParameterTypes())); } - private IOException createIOExceptionWithAttachedFailures(Path rootDir, Map failures) { + private IOException createIOExceptionWithAttachedFailures(Path rootDir, List failures) { Path emptyPath = Path.of(""); - String joinedPaths = failures.keySet().stream() // - .map(this::tryToDeleteOnExit) // + String joinedPaths = failures.stream() // + .map(DeletionFailure::path) // + .sorted().distinct().map(this::tryToDeleteOnExit) // .map(path -> relativizeSafely(rootDir, path)) // .map(path -> emptyPath.equals(path) ? "" : path.toString()) // .collect(joining(", ")); IOException exception = new IOException("Failed to delete temp directory " + rootDir.toAbsolutePath() + ". The following paths could not be deleted (see suppressed exceptions for details): " + joinedPaths); - failures.values().forEach(exception::addSuppressed); + failures.stream() // + .sorted(comparing(DeletionFailure::path)).map(DeletionFailure::cause) // + .forEach(exception::addSuppressed); return exception; } 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 4a0a86c52705..8add4f4c7218 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; @@ -47,6 +48,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; @@ -87,6 +89,7 @@ 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; @@ -96,7 +99,7 @@ * * @since 5.8 */ -@DisplayName("TempDirectory extension (per declaration)") +@DisplayName("TempDirectory extension") class TempDirectoryTests extends AbstractJupiterTestEngineTests { private EngineExecutionResults executeTestsForClassWithDefaultFactory(Class testClass, @@ -540,7 +543,13 @@ 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); + failures.getFirst().printStackTrace(); + assertThat(failures.getFirst()).has(allOf(conditions)); } // ------------------------------------------------------------------------- From 27e2e47a45b97f2bda6790d753ea2610459d2037 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Tue, 17 Mar 2026 14:58:09 +0100 Subject: [PATCH 04/15] Add `TempDirDeletionStrategy.IgnoreFailures` --- .../jupiter/api/io/DefaultDeletionResult.java | 47 ++++++++++- .../api/io/TempDirDeletionStrategy.java | 78 ++++++++++++++++--- .../engine/extension/TempDirectory.java | 42 +--------- .../api/io/TempDirDeletionStrategyTests.java | 64 +++++++++++++++ .../engine/extension/TempDirectoryTests.java | 54 +++++++++++-- 5 files changed, 226 insertions(+), 59 deletions(-) create mode 100644 jupiter-tests/src/test/java/org/junit/jupiter/api/io/TempDirDeletionStrategyTests.java 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 index ed101abc91db..bc73dbb1187b 100644 --- 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 @@ -10,23 +10,64 @@ 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 org.junit.jupiter.api.io.TempDirDeletionStrategy.DeletionException; import org.junit.jupiter.api.io.TempDirDeletionStrategy.DeletionFailure; import org.junit.jupiter.api.io.TempDirDeletionStrategy.DeletionResult; -record DefaultDeletionResult(List failures) implements DeletionResult { +record DefaultDeletionResult(Path rootDir, List failures) implements DeletionResult { - DefaultDeletionResult(List failures) { + DefaultDeletionResult(Path rootDir, List failures) { + this.rootDir = rootDir; this.failures = List.copyOf(failures); } + @Override + public DeletionException toException() { + if (isSuccessful()) { + throw new IllegalStateException("Cannot create an exception for a successful deletion result"); + } + var emptyPath = Path.of(""); + var joinedPaths = failures().stream() // + .map(DeletionFailure::path) // + .sorted() // + .distinct() // + .map(path -> relativizeSafely(rootDir(), path)) // + .map(path -> emptyPath.equals(path) ? "" : path.toString()) // + .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 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) { failures.add(new DefaultDeletionFailure(path, cause)); @@ -35,7 +76,7 @@ public Builder addFailure(Path path, Exception cause) { @Override public DefaultDeletionResult build() { - return new DefaultDeletionResult(failures); + return new DefaultDeletionResult(rootDir, failures); } } 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 index 22bb997879c6..988472cdb7e3 100644 --- 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 @@ -16,6 +16,7 @@ import java.io.File; import java.io.IOException; +import java.io.Serial; import java.nio.file.DirectoryNotEmptyException; import java.nio.file.FileVisitResult; import java.nio.file.Files; @@ -28,6 +29,7 @@ import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.function.BiConsumer; import org.apiguardian.api.API; import org.jspecify.annotations.Nullable; @@ -58,6 +60,34 @@ public interface TempDirDeletionStrategy { DeletionResult delete(Path tempDir, AnnotatedElementContext elementContext, ExtensionContext extensionContext) throws IOException; + final class IgnoreFailures implements TempDirDeletionStrategy { + + private static final Logger LOGGER = LoggerFactory.getLogger(IgnoreFailures.class); + private final TempDirDeletionStrategy delegate; + + 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); + + if (!result.isSuccessful()) { + var exception = result.toException(); + LOGGER.warn(exception, exception::getMessage); + } + + return DeletionResult.builder(tempDir).build(); + } + } + final class Standard implements TempDirDeletionStrategy { public static final Standard INSTANCE = new Standard(); @@ -76,7 +106,16 @@ public DeletionResult delete(Path tempDir, AnnotatedElementContext elementContex // package-private for testing DeletionResult delete(Path tempDir, FileOperations fileOperations) throws IOException { - var result = DeletionResult.builder(); + 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(); @@ -154,7 +193,7 @@ private void delete(Path path, FileOperations fileOperations) { // ignore } catch (DirectoryNotEmptyException exception) { - result.addFailure(path, exception); + failureHandler.accept(path, exception); } catch (IOException exception) { // IOException includes `AccessDeniedException` thrown by non-readable or non-executable flags @@ -176,16 +215,14 @@ private void resetPermissionsAndTryToDeleteAgain(Path path, IOException exceptio } catch (Exception suppressed) { exception.addSuppressed(suppressed); - result.addFailure(path, exception); + failureHandler.accept(path, exception); } } else { - result.addFailure(path, exception); + failureHandler.accept(path, exception); } } }); - - return result.build(); } private void deleteWithLogging(Path file, FileOperations fileOperations) throws IOException { @@ -226,6 +263,15 @@ private static void tryToResetPermissions(Path path) { } } + @SuppressWarnings("EmptyCatch") + private static void tryToDeleteOnExit(Path path) { + try { + path.toFile().deleteOnExit(); + } + catch (UnsupportedOperationException ignore) { + } + } + // For testing only interface FileOperations { @@ -236,15 +282,19 @@ interface FileOperations { sealed interface DeletionResult permits DefaultDeletionResult { - static Builder builder() { - return new DefaultDeletionResult.Builder(); + static Builder builder(Path rootDir) { + return new DefaultDeletionResult.Builder(rootDir); } + Path rootDir(); + + List failures(); + default boolean isSuccessful() { return failures().isEmpty(); } - List failures(); + DeletionException toException(); sealed interface Builder permits DefaultDeletionResult.Builder { @@ -264,4 +314,14 @@ sealed interface DeletionFailure permits DefaultDeletionResult.DefaultDeletionFa } + final class DeletionException extends RuntimeException { + + @Serial + private static final long serialVersionUID = 1L; + + DeletionException(String message) { + super(message, null, true, false); + } + } + } 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 bf6f8d632220..3b1e9512441a 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,9 +10,7 @@ package org.junit.jupiter.engine.extension; -import static java.util.Comparator.comparing; import static java.util.Objects.requireNonNull; -import static java.util.stream.Collectors.joining; 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; @@ -32,7 +30,6 @@ import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.Path; -import java.util.List; import java.util.function.Predicate; import java.util.function.Supplier; @@ -49,7 +46,6 @@ 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.TempDirDeletionStrategy.DeletionFailure; import org.junit.jupiter.api.io.TempDirFactory; import org.junit.jupiter.engine.config.JupiterConfiguration; import org.junit.platform.commons.JUnitException; @@ -354,7 +350,7 @@ void run(Path dir, AnnotatedElementContext elementContext, ExtensionContext exte if (Files.exists(dir)) { var result = deletionStrategy.get().delete(dir, elementContext, extensionContext); if (!result.isSuccessful()) { - throw createIOExceptionWithAttachedFailures(dir, result.failures()); + throw result.toException(); } } } @@ -377,42 +373,6 @@ private static String descriptionFor(Executable executable) { return "%s %s(%s)".formatted(type, name, ClassUtils.nullSafeToString(Class::getSimpleName, executable.getParameterTypes())); } - - private IOException createIOExceptionWithAttachedFailures(Path rootDir, List failures) { - Path emptyPath = Path.of(""); - String joinedPaths = failures.stream() // - .map(DeletionFailure::path) // - .sorted().distinct().map(this::tryToDeleteOnExit) // - .map(path -> relativizeSafely(rootDir, path)) // - .map(path -> emptyPath.equals(path) ? "" : path.toString()) // - .collect(joining(", ")); - IOException exception = new IOException("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 exception; - } - - @SuppressWarnings("EmptyCatch") - private Path tryToDeleteOnExit(Path path) { - try { - path.toFile().deleteOnExit(); - } - catch (UnsupportedOperationException ignore) { - } - return path; - } - - private Path relativizeSafely(Path rootDir, Path path) { - try { - return rootDir.relativize(path); - } - catch (IllegalArgumentException e) { - return path; - } - } } } 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..7d924e7bafa8 --- /dev/null +++ b/jupiter-tests/src/test/java/org/junit/jupiter/api/io/TempDirDeletionStrategyTests.java @@ -0,0 +1,64 @@ +/* + * 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 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.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()); + + var result = strategy.delete(undeletableDir, mock(), mock()); + + assertThat(result.isSuccessful()); + + var loggedWarnings = log.stream(IgnoreFailures.class, Level.WARNING).toList(); + + assertThat(loggedWarnings) // + .extracting(LogRecord::getMessage) // + .containsExactly( + "Failed to delete temp directory %s. The following paths could not be deleted (see suppressed exceptions for details): ".formatted( + undeletableDir.toAbsolutePath())); + + var exception = loggedWarnings.getFirst().getThrown(); + assertThat(exception).isInstanceOf(DeletionException.class); + 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/TempDirectoryTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/TempDirectoryTests.java index 8add4f4c7218..f0f0859bce9f 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 @@ -80,6 +80,7 @@ import org.junit.jupiter.api.extension.ParameterResolutionException; 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; @@ -250,13 +251,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(TempDir.DEFAULT_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)), // @@ -265,6 +282,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() // @@ -548,7 +573,6 @@ private static void assertSingleFailedTest(EngineExecutionResults results, Condi null)) // .filter(Objects::nonNull).toList(); assertThat(failures).hasSize(1); - failures.getFirst().printStackTrace(); assertThat(failures.getFirst()).has(allOf(conditions)); } @@ -1233,7 +1257,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) { @@ -1268,6 +1292,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 From 96f450589ca924120651a2fdff613d129b782ef2 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Tue, 17 Mar 2026 15:05:01 +0100 Subject: [PATCH 05/15] Use proper super class --- .../java/org/junit/jupiter/api/io/TempDirDeletionStrategy.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index 988472cdb7e3..99f5e00d6d6f 100644 --- 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 @@ -35,6 +35,7 @@ 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; @@ -314,7 +315,7 @@ sealed interface DeletionFailure permits DefaultDeletionResult.DefaultDeletionFa } - final class DeletionException extends RuntimeException { + final class DeletionException extends JUnitException { @Serial private static final long serialVersionUID = 1L; From 89ccae2fe0530c3ce48c10553cd1c607ab9acef0 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Fri, 20 Mar 2026 19:43:13 +0100 Subject: [PATCH 06/15] Document --- documentation/antora.yml | 4 + .../writing-tests/built-in-extensions.adoc | 53 +++++++++- .../test/java/example/TempDirectoryDemo.java | 13 +++ .../org/junit/jupiter/api/io/TempDir.java | 30 ++++++ .../api/io/TempDirDeletionStrategy.java | 98 +++++++++++++++++++ .../io/FailingTempDirDeletionStrategy.java | 8 ++ 6 files changed, 202 insertions(+), 4 deletions(-) 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..043d765ed374 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,13 @@ xref:running-tests/configuration-parameters.adoc[configuration parameter] to ove include::example$java/example/TempDirectoryDemo.java[tags=user_guide_cleanup_mode] ---- +=== 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 +120,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 +130,53 @@ 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. +=== 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, and 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/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/io/TempDir.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/io/TempDir.java index 5658fe58c33f..04bcbda9b368 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 @@ -139,6 +139,19 @@ @API(status = MAINTAINED, since = "5.13.3") String DEFAULT_CLEANUP_MODE_PROPERTY_NAME = "junit.jupiter.tempdir.cleanup.mode.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"; @@ -150,6 +163,23 @@ @API(status = STABLE, since = "5.11") CleanupMode cleanup() default CleanupMode.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 index 99f5e00d6d6f..782f25a8b0d2 100644 --- 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 @@ -38,7 +38,26 @@ 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; +/** + * {@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 { @@ -61,11 +80,20 @@ public interface TempDirDeletionStrategy { 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); } @@ -91,6 +119,9 @@ public DeletionResult delete(Path tempDir, AnnotatedElementContext elementContex 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); @@ -281,40 +312,107 @@ interface FileOperations { } } + /** + * 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(rootDir); } + /** + * 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 a {@link DeletionException}; never {@code null} + * @throws IllegalStateException if this result is successful + */ DeletionException 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 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 index 8d38a59f1151..d1c0ec368753 100644 --- 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 @@ -18,9 +18,17 @@ 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 From b6d4430cc6094ac1d5f429865bcaf907a6fa5175 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Fri, 20 Mar 2026 19:43:46 +0100 Subject: [PATCH 07/15] Improve logging --- .../api/io/TempDirDeletionStrategy.java | 39 ++++++++++++++++++- .../engine/extension/TempDirectory.java | 23 +---------- .../api/io/TempDirDeletionStrategyTests.java | 16 ++++++-- 3 files changed, 52 insertions(+), 26 deletions(-) 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 index 782f25a8b0d2..92dcda8d61b9 100644 --- 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 @@ -13,10 +13,16 @@ 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.FileVisitResult; import java.nio.file.Files; @@ -110,13 +116,44 @@ public DeletionResult delete(Path tempDir, AnnotatedElementContext elementContex if (!result.isSuccessful()) { var exception = result.toException(); - LOGGER.warn(exception, exception::getMessage); + LOGGER.warn(exception, () -> "Failed to delete all temporary files for %s".formatted( + descriptionFor(elementContext.getAnnotatedElement()))); } return DeletionResult.builder(tempDir).build(); } + + @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 { /** 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 3b1e9512441a..c0fd5c747bc3 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 @@ -15,6 +15,7 @@ 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; @@ -23,8 +24,6 @@ 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.FileSystems; @@ -54,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; @@ -354,25 +352,6 @@ void run(Path dir, AnnotatedElementContext elementContext, ExtensionContext exte } } } - - 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); - } - - 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())); - } } } 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 index 7d924e7bafa8..10a403d32c9c 100644 --- 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 @@ -12,6 +12,7 @@ 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; @@ -21,6 +22,7 @@ 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; @@ -39,7 +41,12 @@ void logsAndIgnoresFailures(@TempDir Path tempDir, @TrackLogRecords LogRecordLis var undeletableDir = Files.createDirectory(tempDir.resolve("undeletable")); var strategy = new IgnoreFailures(new FailingTempDirDeletionStrategy()); - var result = strategy.delete(undeletableDir, mock(), mock()); + 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()); @@ -48,11 +55,14 @@ void logsAndIgnoresFailures(@TempDir Path tempDir, @TrackLogRecords LogRecordLis assertThat(loggedWarnings) // .extracting(LogRecord::getMessage) // .containsExactly( - "Failed to delete temp directory %s. The following paths could not be deleted (see suppressed exceptions for details): ".formatted( - undeletableDir.toAbsolutePath())); + "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) // From 357e2d235c27872e85606ad094acfb96759a87a2 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Fri, 20 Mar 2026 19:44:04 +0100 Subject: [PATCH 08/15] Avoid exception for non-default file systems --- .../org/junit/jupiter/api/io/TempDirDeletionStrategy.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 index 92dcda8d61b9..3c0a69456140 100644 --- 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 @@ -24,6 +24,7 @@ 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; @@ -335,7 +336,9 @@ private static void tryToResetPermissions(Path path) { @SuppressWarnings("EmptyCatch") private static void tryToDeleteOnExit(Path path) { try { - path.toFile().deleteOnExit(); + if (FileSystems.getDefault().equals(path.getFileSystem())) { + path.toFile().deleteOnExit(); + } } catch (UnsupportedOperationException ignore) { } From 8c55968928b5711cbdb4f710e8c2fa7abd23064a Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Sat, 28 Mar 2026 17:57:56 +0100 Subject: [PATCH 09/15] Return an `Optional` from `toException()` --- .../jupiter/api/io/DefaultDeletionResult.java | 7 ++++--- .../api/io/TempDirDeletionStrategy.java | 19 +++++++++++-------- .../engine/extension/TempDirectory.java | 6 +++--- 3 files changed, 18 insertions(+), 14 deletions(-) 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 index bc73dbb1187b..346a012df859 100644 --- 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 @@ -16,6 +16,7 @@ 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; @@ -29,9 +30,9 @@ record DefaultDeletionResult(Path rootDir, List failures) imple } @Override - public DeletionException toException() { + public Optional toException() { if (isSuccessful()) { - throw new IllegalStateException("Cannot create an exception for a successful deletion result"); + return Optional.empty(); } var emptyPath = Path.of(""); var joinedPaths = failures().stream() // @@ -47,7 +48,7 @@ public DeletionException toException() { .sorted(comparing(DeletionFailure::path)) // .map(DeletionFailure::cause) // .forEach(exception::addSuppressed); - return exception; + return Optional.of(exception); } private static Path relativizeSafely(Path rootDir, Path path) { 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 index 3c0a69456140..7fad8f22e0e9 100644 --- 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 @@ -35,6 +35,7 @@ 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; @@ -115,15 +116,16 @@ public DeletionResult delete(Path tempDir, AnnotatedElementContext elementContex var result = delegate.delete(tempDir, elementContext, extensionContext); - if (!result.isSuccessful()) { - var exception = result.toException(); - LOGGER.warn(exception, () -> "Failed to delete all temporary files for %s".formatted( - descriptionFor(elementContext.getAnnotatedElement()))); - } + 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) { @@ -397,10 +399,11 @@ default boolean isSuccessful() { *

Must only be called if {@link #isSuccessful()} returns * {@code false}. * - * @return a {@link DeletionException}; never {@code null} - * @throws IllegalStateException if this result is successful + * @return an {@link DeletionException}, if the deletion + * {@linkplain #isSuccessful() was successful; otherwise, empty}; never + * {@code null} */ - DeletionException toException(); + Optional toException(); /** * Builder for {@link DeletionResult}. 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 c0fd5c747bc3..9aa8d386fd25 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 @@ -346,9 +346,9 @@ void run(Path dir, AnnotatedElementContext elementContext, ExtensionContext exte LOGGER.trace(() -> "Cleaning up temp dir " + dir); if (Files.exists(dir)) { - var result = deletionStrategy.get().delete(dir, elementContext, extensionContext); - if (!result.isSuccessful()) { - throw result.toException(); + var exception = deletionStrategy.get().delete(dir, elementContext, extensionContext).toException(); + if (exception.isPresent()) { + throw exception.get(); } } } From ad967e102594247e2cdfe8f4ef1044e7ea702aee Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Sat, 28 Mar 2026 18:02:17 +0100 Subject: [PATCH 10/15] Avoid checking for default file system --- .../java/org/junit/jupiter/api/io/DefaultDeletionResult.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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 index 346a012df859..7e52ab443f0c 100644 --- 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 @@ -34,13 +34,12 @@ public Optional toException() { if (isSuccessful()) { return Optional.empty(); } - var emptyPath = Path.of(""); var joinedPaths = failures().stream() // .map(DeletionFailure::path) // .sorted() // .distinct() // - .map(path -> relativizeSafely(rootDir(), path)) // - .map(path -> emptyPath.equals(path) ? "" : path.toString()) // + .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); From 421e9a878db243f137cabaf094ffd1988cc978cd Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Sat, 28 Mar 2026 18:16:53 +0100 Subject: [PATCH 11/15] Introduce `Constants` for `TempDir` config params --- .../java/org/junit/jupiter/api/Constants.java | 25 +++++++++++++++++-- .../org/junit/jupiter/api/io/TempDir.java | 22 ++++++++-------- .../config/CachingJupiterConfiguration.java | 13 +++++----- .../config/DefaultJupiterConfiguration.java | 13 +++++----- .../extension/TempDirectoryCleanupTests.java | 5 ++-- .../engine/extension/TempDirectoryTests.java | 11 ++++---- 6 files changed, 58 insertions(+), 31 deletions(-) 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/TempDir.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/io/TempDir.java index 04bcbda9b368..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 @@ -128,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,6 +141,14 @@ @API(status = MAINTAINED, since = "5.13.3") String DEFAULT_CLEANUP_MODE_PROPERTY_NAME = "junit.jupiter.tempdir.cleanup.mode.default"; + /** + * 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} @@ -155,14 +165,6 @@ @API(status = EXPERIMENTAL, since = "6.1") String DEFAULT_DELETION_STRATEGY_PROPERTY_NAME = "junit.jupiter.tempdir.deletion.strategy.default"; - /** - * 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; - /** * Deletion strategy for the temporary directory. * 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 9c1aee47de19..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,9 +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_DELETION_STRATEGY_PROPERTY_NAME; -import static org.junit.jupiter.api.io.TempDir.DEFAULT_FACTORY_PROPERTY_NAME; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; @@ -148,21 +148,22 @@ 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_DELETION_STRATEGY_PROPERTY_NAME, + return (Supplier) cache.computeIfAbsent( + DEFAULT_TEMP_DIR_DELETION_STRATEGY_PROPERTY_NAME, __ -> delegate.getDefaultTempDirDeletionStrategySupplier()); } 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 8b01777fdfad..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,9 +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_DELETION_STRATEGY_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; @@ -226,20 +226,21 @@ 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_DELETION_STRATEGY_PROPERTY_NAME); + configurationParameters, DEFAULT_TEMP_DIR_DELETION_STRATEGY_PROPERTY_NAME); return () -> supplier.get().orElse(TempDirDeletionStrategy.Standard.INSTANCE); } 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 f0f0859bce9f..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 @@ -25,6 +25,9 @@ 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; @@ -62,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; @@ -107,7 +109,7 @@ private EngineExecutionResults executeTestsForClassWithDefaultFactory(Class t 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()); } @@ -124,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)); @@ -261,7 +262,7 @@ void onlyAttemptsToDeleteUndeletablePathsOnce(Class testClass) { void appliesGloballyConfiguredDeletionStrategy() { var results = executeTests(builder -> builder // .selectors(selectClass(UndeletableWithDefaultDeletionStrategyTestCase.class)) // - .configurationParameter(TempDir.DEFAULT_DELETION_STRATEGY_PROPERTY_NAME, + .configurationParameter(DEFAULT_TEMP_DIR_DELETION_STRATEGY_PROPERTY_NAME, FailingTempDirDeletionStrategy.class.getName())); var tempDir = determineTempDirFromReportEntries(results, From 72652a3b2b0ec22056204dff5f32027ec7405cd3 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Sat, 28 Mar 2026 18:22:08 +0100 Subject: [PATCH 12/15] Check preconditions --- .../java/org/junit/jupiter/api/io/DefaultDeletionResult.java | 3 +++ .../java/org/junit/jupiter/api/io/TempDirDeletionStrategy.java | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) 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 index 7e52ab443f0c..16e67172e81e 100644 --- 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 @@ -21,6 +21,7 @@ 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 { @@ -70,6 +71,8 @@ static final class Builder implements DeletionResult.Builder { @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; } 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 index 7fad8f22e0e9..c98ef6ec0f91 100644 --- 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 @@ -47,6 +47,7 @@ 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 @@ -367,7 +368,7 @@ sealed interface DeletionResult permits DefaultDeletionResult { * @return a new {@code Builder}; never {@code null} */ static Builder builder(Path rootDir) { - return new DefaultDeletionResult.Builder(rootDir); + return new DefaultDeletionResult.Builder(Preconditions.notNull(rootDir, "rootDir must not be null")); } /** From 59bf2963bf4265f3efb3c63c09baef540cd11020 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Sat, 28 Mar 2026 18:27:33 +0100 Subject: [PATCH 13/15] Add to release notes --- .../ROOT/pages/writing-tests/built-in-extensions.adoc | 2 ++ .../ROOT/partials/release-notes/release-notes-6.1.0-M2.adoc | 5 +++++ 2 files changed, 7 insertions(+) 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 043d765ed374..590944dacaa4 100644 --- a/documentation/modules/ROOT/pages/writing-tests/built-in-extensions.adoc +++ b/documentation/modules/ROOT/pages/writing-tests/built-in-extensions.adoc @@ -59,6 +59,7 @@ 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 @@ -134,6 +135,7 @@ precedence rules: 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 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 b0e68a6a18f3..628287d42561 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 @@ -73,6 +73,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`. From 2161634450a998e26ce03d715c881a539bc23ba1 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Wed, 1 Apr 2026 14:02:25 +0200 Subject: [PATCH 14/15] Replace isPresent with ifPresent --- .../junit/jupiter/engine/extension/TempDirectory.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 9aa8d386fd25..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 @@ -346,10 +346,12 @@ void run(Path dir, AnnotatedElementContext elementContext, ExtensionContext exte LOGGER.trace(() -> "Cleaning up temp dir " + dir); if (Files.exists(dir)) { - var exception = deletionStrategy.get().delete(dir, elementContext, extensionContext).toException(); - if (exception.isPresent()) { - throw exception.get(); - } + deletionStrategy.get().delete(dir, elementContext, extensionContext) // + .toException() // + .ifPresent(exception -> { + throw exception; + }); + } } } From d9f4867d669d4643ea4fde94e2a6af4758c075a7 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Wed, 1 Apr 2026 14:21:27 +0200 Subject: [PATCH 15/15] Break up long sentence for clarity --- .../modules/ROOT/pages/writing-tests/built-in-extensions.adoc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 590944dacaa4..f266bc33b70f 100644 --- a/documentation/modules/ROOT/pages/writing-tests/built-in-extensions.adoc +++ b/documentation/modules/ROOT/pages/writing-tests/built-in-extensions.adoc @@ -149,8 +149,8 @@ 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, and the test is - failed. + 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.