| /** |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * <p> |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * <p> |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| package org.apache.winegrower.test; |
| |
| import static java.lang.annotation.ElementType.METHOD; |
| import static java.lang.annotation.ElementType.PARAMETER; |
| import static java.lang.annotation.RetentionPolicy.RUNTIME; |
| import static java.util.Arrays.asList; |
| import static java.util.Collections.singletonList; |
| import static java.util.Optional.of; |
| import static java.util.Optional.ofNullable; |
| import static java.util.stream.Collectors.toList; |
| |
| import java.io.File; |
| import java.io.FileOutputStream; |
| import java.io.IOException; |
| import java.lang.annotation.Retention; |
| import java.lang.annotation.Target; |
| import java.lang.reflect.InvocationTargetException; |
| import java.net.MalformedURLException; |
| import java.net.URL; |
| import java.net.URLClassLoader; |
| import java.nio.file.FileVisitResult; |
| import java.nio.file.Files; |
| import java.nio.file.Path; |
| import java.nio.file.SimpleFileVisitor; |
| import java.nio.file.attribute.BasicFileAttributes; |
| import java.util.ArrayList; |
| import java.util.Collection; |
| import java.util.HashSet; |
| import java.util.Optional; |
| import java.util.Set; |
| import java.util.UUID; |
| import java.util.jar.JarEntry; |
| import java.util.jar.JarOutputStream; |
| import java.util.stream.Stream; |
| |
| import org.apache.winegrower.Ripener; |
| import org.apache.winegrower.api.LifecycleCallbacks; |
| import org.junit.jupiter.api.extension.AfterEachCallback; |
| import org.junit.jupiter.api.extension.BeforeEachCallback; |
| import org.junit.jupiter.api.extension.ExtendWith; |
| import org.junit.jupiter.api.extension.ExtensionContext; |
| import org.junit.jupiter.api.extension.ParameterContext; |
| import org.junit.jupiter.api.extension.ParameterResolutionException; |
| import org.junit.jupiter.api.extension.ParameterResolver; |
| |
| @Target(METHOD) |
| @Retention(RUNTIME) |
| @ExtendWith(WithRipener.Extension.class) |
| public @interface WithRipener { |
| |
| @Target(PARAMETER) |
| @Retention(RUNTIME) |
| @interface Service { |
| } |
| |
| @Retention(RUNTIME) |
| @interface Entry { |
| |
| String jarName() default ""; |
| |
| String path(); |
| |
| String prefix() default ""; |
| } |
| |
| String workDir() default ""; |
| |
| String[] includes() default {}; |
| |
| String[] dependencies() default "target/${test}/*.jar"; |
| |
| Entry[] includeResources() default {}; |
| |
| boolean addLifecycleCallbackSpy() default false; |
| |
| class Extension implements BeforeEachCallback, AfterEachCallback, ParameterResolver { |
| |
| private static final String CLASSES_BASE = System.getProperty(Extension.class.getName() + ".classesBase", |
| "target/test-classes/"); |
| |
| private static final String WORK_DIR = System.getProperty(Extension.class.getName() + ".workdir", "target/winegrower"); |
| |
| private static final ExtensionContext.Namespace NAMESPACE = ExtensionContext.Namespace.create(Extension.class.getName()); |
| |
| @Override |
| public void beforeEach(final ExtensionContext extensionContext) { |
| final WithRipener config = getConfig(extensionContext).orElseThrow(IllegalArgumentException::new); |
| |
| final Thread thread = Thread.currentThread(); |
| final URL[] urls = createUrls(config, extensionContext); |
| final URLClassLoader loader = new URLClassLoader(urls, thread.getContextClassLoader()); |
| final ExtensionContext.Store store = extensionContext.getStore(NAMESPACE); |
| store.put(Context.class, new Context(thread, thread.getContextClassLoader(), loader)); |
| thread.setContextClassLoader(loader); |
| |
| final Ripener.Configuration configuration = new Ripener.Configuration(); |
| configuration.setScanningExcludes(asList("common-java5-" /* surefire, yes... */, "test-classes")); |
| setConfiguration(configuration, config, extensionContext.getTestClass().orElseThrow(IllegalStateException::new)); |
| |
| final Ripener ripener = new Ripener.Impl(configuration).start(); |
| store.put(Ripener.class, ripener); |
| |
| ripener.getServices().inject(extensionContext.getRequiredTestInstance()); |
| } |
| |
| @Override |
| public void afterEach(final ExtensionContext extensionContext) { |
| final ExtensionContext.Store store = extensionContext.getStore(NAMESPACE); |
| if (store == null) { |
| return; |
| } |
| ofNullable(store.get(Context.class, Context.class)).ifPresent(Context::close); |
| ofNullable(store.get(FilesToDelete.class, FilesToDelete.class)).ifPresent(it -> it.files.forEach(f -> { |
| if (f.exists() && !f.delete()) { |
| f.deleteOnExit(); |
| } |
| })); |
| ofNullable(store.get(Ripener.class, Ripener.class)).ifPresent(Ripener::stop); |
| } |
| |
| private void setConfiguration(final Ripener.Configuration configuration, final WithRipener config, |
| final Class<?> test) { |
| final Collection<String> includes = asList(config.includes()); |
| if (!includes.isEmpty()) { |
| configuration.setJarFilter(it -> includes.stream().anyMatch(e -> e.startsWith(it))); |
| } |
| |
| final String workDir = config.workDir(); |
| if (!workDir.isEmpty()) { |
| configuration.setWorkDir(new File(workDir)); |
| } |
| |
| if (config.addLifecycleCallbackSpy()) { |
| configuration.setLifecycleCallbacks(Stream.of(test.getClasses()) |
| .filter(LifecycleCallbacks.class::isAssignableFrom) |
| .map(it -> { |
| try { |
| return it.asSubclass(LifecycleCallbacks.class).getConstructor().newInstance(); |
| } catch (final InstantiationException | IllegalAccessException | InvocationTargetException | NoSuchMethodException e) { |
| throw new IllegalStateException(e); |
| } |
| }) |
| .collect(toList())); |
| } |
| } |
| |
| private URL[] createUrls(final WithRipener config, final ExtensionContext context) { |
| return Stream.concat(Stream.of(config.dependencies()) |
| .flatMap(it -> Stream.of(variabilize(it, context.getTestClass().map(Class::getName).orElse("default")), |
| variabilize(it, "default"))) |
| .flatMap(this::listFiles).filter(File::exists).map(f -> { |
| try { |
| return f.toURI().toURL(); |
| } catch (final MalformedURLException e) { |
| throw new IllegalArgumentException(e); |
| } |
| }), Stream.of(config.includeResources()).map(resources -> { |
| try { |
| final File jar = createJar(resources); |
| context.getStore(NAMESPACE).getOrComputeIfAbsent(FilesToDelete.class, ignored -> new FilesToDelete(), |
| FilesToDelete.class).files.add(jar); |
| return jar.toURI().toURL(); |
| } catch (final MalformedURLException e) { |
| throw new IllegalArgumentException(e); |
| } |
| })).toArray(URL[]::new); |
| } |
| |
| private Optional<WithRipener> getConfig(ExtensionContext context) { |
| return context |
| .getElement().map(e -> ofNullable(e.getAnnotation(WithRipener.class)) |
| .orElseGet(()-> context.getParent() |
| .flatMap(ExtensionContext::getElement) |
| .map(it -> it.getAnnotation(WithRipener.class)) |
| .orElse(null))); |
| } |
| |
| private Stream<File> listFiles(final String it) { |
| return it.endsWith("*.jar") |
| ? Stream.of(ofNullable(new File(it.substring(0, it.length() - "*.jar".length())) |
| .listFiles((dir, name) -> name.endsWith(".jar"))).orElseGet(() -> new File[0])) |
| : Stream.of(new File(it)); |
| } |
| |
| private File createJar(final Entry entry) { |
| final File out = new File( |
| WORK_DIR, |
| of(entry.jarName()).filter(it -> !it.isEmpty()).orElseGet(() -> UUID.randomUUID().toString()) + ".jar"); |
| out.getParentFile().mkdirs(); |
| try (final JarOutputStream jarOutputStream = new JarOutputStream(new FileOutputStream(out))) { |
| final Set<String> createdFolders = new HashSet<>(); |
| try { |
| final File classesRoot = new File(CLASSES_BASE); |
| final Path classesPath = classesRoot.toPath(); |
| final Path root = new File(classesRoot, entry.path().replace(".", "/")).toPath(); |
| Files.walkFileTree(root, new SimpleFileVisitor<Path>() { |
| |
| @Override |
| public FileVisitResult visitFile(final Path file, final BasicFileAttributes attrs) throws IOException { |
| String relative = classesPath.relativize(file).toString().substring(entry.prefix().length()); |
| if (relative.endsWith("META-INF/MANIFEST.MF")) { // simpler config |
| relative = "META-INF/MANIFEST.MF"; |
| } |
| final String[] segments = relative.split("/"); |
| final StringBuilder builder = new StringBuilder(relative.length()); |
| for (int i = 0; i < segments.length - 1; i++) { |
| builder.append(segments[i]).append('/'); |
| final String folder = builder.toString(); |
| if (createdFolders.add(folder)) { |
| jarOutputStream.putNextEntry(new JarEntry(folder)); |
| jarOutputStream.closeEntry(); |
| } |
| } |
| jarOutputStream.putNextEntry(new JarEntry(relative)); |
| Files.copy(file, jarOutputStream); |
| jarOutputStream.closeEntry(); |
| return super.visitFile(file, attrs); |
| } |
| }); |
| } catch (final IOException e) { |
| throw new IllegalStateException(e); |
| } |
| } catch (final IOException e) { |
| throw new IllegalStateException(e); |
| } |
| return out; |
| } |
| |
| private String variabilize(final String name, final String testName) { |
| return name.replace("${test}", testName); |
| } |
| |
| @Override |
| public boolean supportsParameter(final ParameterContext parameterContext, final ExtensionContext extensionContext) |
| throws ParameterResolutionException { |
| return supports(parameterContext.getParameter().getType()) || parameterContext.getParameter().isAnnotationPresent(Service.class); |
| } |
| |
| @Override |
| public Object resolveParameter(final ParameterContext parameterContext, final ExtensionContext extensionContext) |
| throws ParameterResolutionException { |
| return findInjection(extensionContext, parameterContext.getParameter().getType()); |
| } |
| |
| private boolean supports(final Class<?> type) { |
| return type == Ripener.class; |
| } |
| |
| private <T> T findInjection(final ExtensionContext extensionContext, final Class<T> type) { |
| return ofNullable(extensionContext.getStore(NAMESPACE).get(type, type)) |
| .orElseGet(() -> findInjection(extensionContext, Ripener.class).getServices().findService(type).orElse(null)); |
| } |
| |
| private static class Context implements AutoCloseable { |
| |
| private final Thread thread; |
| |
| private final ClassLoader previousLoader; |
| |
| private final URLClassLoader currentLoader; |
| |
| private Context(final Thread thread, final ClassLoader previousLoader, final URLClassLoader currentLoader) { |
| this.thread = thread; |
| this.previousLoader = previousLoader; |
| this.currentLoader = currentLoader; |
| } |
| |
| @Override |
| public void close() { |
| thread.setContextClassLoader(previousLoader); |
| try { |
| currentLoader.close(); |
| } catch (final IOException e) { |
| throw new IllegalStateException(e); |
| } |
| } |
| } |
| |
| private static class FilesToDelete { |
| private final Collection<File> files = new ArrayList<>(); |
| } |
| } |
| } |