Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add JUnit5 implementation of CompilationRule #155

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
19 changes: 19 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

<properties>
<truth.version>0.42</truth.version>
<junit5.version>5.3.1</junit5.version>
</properties>

<url>http://github.com/google/compile-testing</url>
Expand Down Expand Up @@ -52,6 +53,24 @@
<artifactId>junit</artifactId>
<version>4.12</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>${junit5.version}</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-runner</artifactId>
<version>1.3.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>${junit5.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.google.truth</groupId>
<artifactId>truth</artifactId>
Expand Down
353 changes: 353 additions & 0 deletions src/main/java/com/google/testing/compile/CompilationExtension.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,353 @@
/*
* Copyright (C) 2018 Google, Inc.
*
* 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
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 com.google.testing.compile;

import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState;
import static com.google.testing.compile.Compilation.Status.SUCCESS;

import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
import org.junit.jupiter.api.TestInstance;
import org.junit.jupiter.api.extension.AfterAllCallback;
import org.junit.jupiter.api.extension.AfterEachCallback;
import org.junit.jupiter.api.extension.BeforeAllCallback;
import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.extension.Extension;
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;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.concurrent.Phaser;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;
import java.util.function.Supplier;
import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.ProcessingEnvironment;
import javax.annotation.processing.RoundEnvironment;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.TypeElement;
import javax.lang.model.util.Elements;
import javax.lang.model.util.Types;
import javax.tools.JavaFileObject;

/**
* A Junit 5 {@link Extension} that extends a test suite such that an instance of {@link Elements}
* and {@link Types} are available through parameter injection during execution.
*
* <p>To use this extension, request it with {@link ExtendWith} and add the required parameters:
*
* <pre>
* {@code @ExtendWith}(CompilationExtension.class)
* class CompilerTest {
* {@code @Test} void testElements({@link Elements} elements, {@link Types} types) {
* // Any methods of the supplied utility classes can now be accessed.
* }
* }
* </pre>
*
* @author David van Leusen
*/
public class CompilationExtension implements BeforeAllCallback, BeforeEachCallback,
AfterAllCallback, AfterEachCallback, ParameterResolver {
private static final JavaFileObject DUMMY =
JavaFileObjects.forSourceLines("Dummy", "final class Dummy {}");
private static final ExtensionContext.Namespace NAMESPACE =
ExtensionContext.Namespace.create(CompilationExtension.class);

private static final Executor DEFAULT_COMPILER_EXECUTOR = Executors.newCachedThreadPool(
new ThreadFactoryBuilder().setDaemon(true).setNameFormat("async-compiler-%d").build()
);

private static final Map<Class<?>, Function<ProcessingEnvironment, ?>> SUPPORTED_PARAMETERS;

static {
SUPPORTED_PARAMETERS = ImmutableMap.<Class<?>, Function<ProcessingEnvironment, ?>>builder()
.put(Elements.class, ProcessingEnvironment::getElementUtils)
.put(Types.class, ProcessingEnvironment::getTypeUtils)
.build();
}

private final Executor compilerExecutor;

public CompilationExtension(Executor compilerExecutor) {
this.compilerExecutor = compilerExecutor;
}

public CompilationExtension() {
this(DEFAULT_COMPILER_EXECUTOR);
}

@Override
public void beforeAll(ExtensionContext context) throws InterruptedException {
final CompilerState state = context.getStore(NAMESPACE).getOrComputeIfAbsent(
CompilerState.class,
ignored -> new CompilerState(this.compilerExecutor, TestInstance.Lifecycle.PER_CLASS),
CompilerState.class
);

checkState(state.prepareForTests(), state);
}

@Override
public void beforeEach(ExtensionContext context) throws InterruptedException {
final CompilerState state = context.getStore(NAMESPACE).getOrComputeIfAbsent(
CompilerState.class,
ignored -> new CompilerState(this.compilerExecutor, TestInstance.Lifecycle.PER_METHOD),
CompilerState.class
);

checkState(state.prepareForTests(), state);
}

@Override
public void afterEach(ExtensionContext context) throws Exception {
final CompilerState state = checkNotNull(context.getStore(NAMESPACE).get(
CompilerState.class,
CompilerState.class
));

if (state.getLifecycle() == TestInstance.Lifecycle.PER_METHOD) {
// Created on a per-method basis, must clean up as a mirror action
final Compilation compilation = state.allowTermination();
checkState(compilation.status().equals(SUCCESS), compilation);
}
}

@Override
public void afterAll(ExtensionContext context) throws ExecutionException, InterruptedException {
final CompilerState state = checkNotNull(context.getStore(NAMESPACE).get(
CompilerState.class,
CompilerState.class
));

checkState(state.getLifecycle() == TestInstance.Lifecycle.PER_CLASS);

final Compilation compilation = state.allowTermination();
checkState(compilation.status().equals(SUCCESS), compilation);
}

@Override
public boolean supportsParameter(
ParameterContext parameterContext,
ExtensionContext extensionContext
) throws ParameterResolutionException {
final Class<?> parameterType = parameterContext.getParameter().getType();
return SUPPORTED_PARAMETERS.containsKey(parameterType);
}

@Override
public Object resolveParameter(
ParameterContext parameterContext,
ExtensionContext extensionContext
) throws ParameterResolutionException {
final CompilerState state = extensionContext.getStore(NAMESPACE).get(
CompilerState.class,
CompilerState.class
);

checkState(state != null, "CompilerState not initialized");

return SUPPORTED_PARAMETERS.getOrDefault(
parameterContext.getParameter().getType(),
ignored -> {
throw new ParameterResolutionException("Unknown parameter type");
}
).apply(state.getProcessingEnvironment());
}

static final class CompilerState implements ExtensionContext.Store.CloseableResource {
private final AtomicReference<ProcessingEnvironment> sharedState;
private final Phaser syncBarrier;
private final CompletableFuture<Compilation> result;
private final TestInstance.Lifecycle lifecycle;

CompilerState(Executor compilerExecutor, TestInstance.Lifecycle lifecycle) {
this.lifecycle = lifecycle;
this.sharedState = new AtomicReference<>(null);
this.syncBarrier = new Phaser(2) {
@Override
protected boolean onAdvance(int phase, int parties) {
// Terminate the phaser once all parties have deregistered
return parties == 0;
}
};
this.result = CompletableFuture.supplyAsync(
new EvaluatingProcessor(syncBarrier, sharedState),
compilerExecutor
);
}

ProcessingEnvironment getProcessingEnvironment() throws ParameterResolutionException {
// Only while the phaser is in phase 1 should the ProcessingEnvironment be valid.
if (this.syncBarrier.getPhase() != 1) {
throw new ParameterResolutionException(this.toString());
}

final ProcessingEnvironment processingEnvironment = this.sharedState.get();
if (processingEnvironment != null) {
return processingEnvironment;
} else {
throw new ParameterResolutionException(
String.format("ProcessingEnvironment was not initialized: %s", this)
);
}
}

TestInstance.Lifecycle getLifecycle() {
return this.lifecycle;
}

boolean prepareForTests() throws InterruptedException {
switch (this.syncBarrier.getPhase()) {
case 0: // Compiler has been started, but might not yet be initialized
return checkNotTerminated(this.syncBarrier.arriveAndAwaitAdvance());
case 1: // Compiler has been initialized, ready for tests
return true;
default:
throw new IllegalStateException(this.toString());
}
}

Compilation allowTermination() throws InterruptedException, ExecutionException {
if (this.syncBarrier.getPhase() == 1) {
checkState(this.syncBarrier.arriveAndDeregister() == 1, this);
} else if (!this.syncBarrier.isTerminated()) {
throw new IllegalStateException(this.toString());
}

try {
final Compilation result = this.result.get(1, TimeUnit.SECONDS);
checkState(this.syncBarrier.isTerminated(), this);
return result;
} catch (TimeoutException e) {
// This really should never happen, since the 'syncBarrier' is the only thing the
// processor blocks on, deregistering at this point should allow the processor
// to run until it finishes.
throw new AssertionError("Timed out waiting for the compiler to finish");
}
}

private boolean checkNotTerminated(int phaseNumber) throws InterruptedException {
if (phaseNumber < 0) {
// Phaser has terminated unexpectedly, throw exception based on result.

try {
// 'Successful' result
final Compilation result = this.result.get(5, TimeUnit.SECONDS);
throw new IllegalStateException(
String.format("Anomalous compilation result: %s", result)
);
} catch (ExecutionException e) {
// Exception in the compiler
throw new IllegalStateException("Exception during annotation processing", e.getCause());
} catch (TimeoutException e) {
// This really should never happen, since the 'syncBarrier' is the only thing the
// processor blocks on, termination should mean it runs until it finished,
// resolving 'result'
throw new AssertionError("Timed out waiting for the cause of termination");
}
}

return true;
}

@Override
public void close() {
// If the owning ExtensionContext.Store is closed, ensure the compilation terminates as well
this.syncBarrier.forceTermination();
}

@Override
public String toString() {
return "CompilerState{" +
"sharedState=" + sharedState +
", syncBarrier=" + syncBarrier +
", result=" + result +
", lifecycle=" + lifecycle +
'}';
}
}

static final class EvaluatingProcessor extends AbstractProcessor
implements Supplier<Compilation> {
private final Phaser syncBarrier;
private final AtomicReference<ProcessingEnvironment> sharedState;

EvaluatingProcessor(
Phaser syncBarrier,
AtomicReference<ProcessingEnvironment> sharedState
) {
this.syncBarrier = syncBarrier;
this.sharedState = sharedState;
}

@Override
public SourceVersion getSupportedSourceVersion() {
return SourceVersion.latest();
}

@Override
public Set<String> getSupportedAnnotationTypes() {
return ImmutableSet.of("*");
}

@Override
public synchronized void init(ProcessingEnvironment processingEnvironment) {
super.init(processingEnvironment);

// Share the processing environment
checkState(
sharedState.compareAndSet(null, processingEnvironment),
"Shared ProcessingEnvironment was already initialized"
);
}

@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
if (roundEnv.processingOver()) {
// Synchronize on the beginning of the test run
syncBarrier.arriveAndAwaitAdvance();

// Now wait until testing is over
syncBarrier.awaitAdvance(syncBarrier.arriveAndDeregister());

// Clean up the shared state
sharedState.lazySet(null);
}
return false;
}

@Override
public Compilation get() {
try {
return Compiler.javac().withProcessors(this).compile(DUMMY);
} finally {
syncBarrier.forceTermination();
}
}
}
}
Loading