From f97da65b0c6ab5e7969dae075c477b54fd9dcb4c Mon Sep 17 00:00:00 2001 From: Ashley Scopes <73482956+ascopes@users.noreply.github.com> Date: Sat, 18 Mar 2023 12:51:29 +0000 Subject: [PATCH 1/2] Implement JctExtension JUnit extension. This enables declaring workspaces on a class level as fields and annotating them with the 'Managed' annotation to enable automatic creation and closure of workspaces as part of the JUnit workflow, leading to cleaner declarative test code. --- README.md | 128 +++--- .../acceptance-tests-dogfood/pom.xml | 24 ++ .../src/test/java/module-info.java | 5 +- java-compiler-testing/pom.xml | 7 + .../ascopes/jct/junit/JctExtension.java | 149 +++++++ .../io/github/ascopes/jct/junit/Managed.java | 70 +++ .../ascopes/jct/workspaces/Workspace.java | 9 + .../jct/workspaces/impl/WorkspaceImpl.java | 43 +- .../src/main/java/module-info.java | 4 + .../JctExtensionIntegrationTest.java | 305 +++++++++++++ .../tests/unit/junit/JctExtensionTest.java | 406 ++++++++++++++++++ .../src/test/java/module-info.java | 5 +- 12 files changed, 1073 insertions(+), 82 deletions(-) create mode 100644 java-compiler-testing/src/main/java/io/github/ascopes/jct/junit/JctExtension.java create mode 100644 java-compiler-testing/src/main/java/io/github/ascopes/jct/junit/Managed.java create mode 100644 java-compiler-testing/src/test/java/io/github/ascopes/jct/tests/integration/JctExtensionIntegrationTest.java create mode 100644 java-compiler-testing/src/test/java/io/github/ascopes/jct/tests/unit/junit/JctExtensionTest.java diff --git a/README.md b/README.md index 74820a149..de479cbad 100644 --- a/README.md +++ b/README.md @@ -138,45 +138,47 @@ open module my.tests { ```java @DisplayName("Example tests") +@ExtendWith(JctExtension.class) class ExampleTest { + + @Managed + Workspace workspace; @DisplayName("I can compile a Hello World application") @JavacCompilerTest void canCompileHelloWorld(JctCompiler compiler) { - try (var workspace = Workspaces.newWorkspace()) { - // Given - workspace - .createSourcePathPackage() - .createFile("org/example/Message.java").withContents(""" - package org.example; - - import lombok.Data; - import lombok.NonNull; - - @Data - public class Message { - private String content; - - public static void main(String[] args) { - Message message = new Message("Hello, World!"); - System.out.println(message); - } + // Given + workspace + .createSourcePathPackage() + .createFile("org/example/Message.java").withContents(""" + package org.example; + + import lombok.Data; + import lombok.NonNull; + + @Data + public class Message { + private String content; + + public static void main(String[] args) { + Message message = new Message("Hello, World!"); + System.out.println(message); } - """ - ); + } + """ + ); - // When - var compilation = compiler.compile(workspace); + // When + var compilation = compiler.compile(workspace); - // Then - assertThatCompilation(compilation) - .isSuccessfulWithoutWarnings(); + // Then + assertThatCompilation(compilation) + .isSuccessfulWithoutWarnings(); - assertThatCompilation(compilation) - .classOutput().packages() - .fileExists("com/example/Message.class") - .isNotEmptyFile(); - } + assertThatCompilation(compilation) + .classOutput().packages() + .fileExists("com/example/Message.class") + .isNotEmptyFile(); } } ``` @@ -192,42 +194,44 @@ import io.github.ascopes.jct.workspaces.Workspaces; import org.example.processor.JsonSchemaAnnotationProcessor; import org.skyscreamer.jsonassert.JSONAssert; +@ExtendWith(JctExtension.class) class JsonSchemaAnnotationProcessorTest { + + @Managed + Workspace workspace; @JavacCompilerTest(minVersion = 11, maxVersion = 19) void theJsonSchemaIsCreatedFromTheInputCode(JctCompiler compiler) { - try (var workspace = Workspaces.newWorkspace()) { - // Given - workspace - .createSourcePathPackage() - .createDirectory("org", "example", "tests") - .copyContentsFrom("src", "test", "resources", "code", "schematest"); - - // When - var compilation = compiler - .addAnnotationProcessors(new JsonSchemaAnnotationProcessor()) - .addAnnotationProcessorOptions("jsonschema.verbose=true") - .failOnWarnings(true) - .showDeprecationWarnings(true) - .compile(workspace); - - // Then - assertThatCompilation(compilation) - .isSuccessfulWithoutWarnings(); - - assertThatCompilation(compilation) - .diagnostics().notes().singleElement() - .message().isEqualTo( - "Creating JSON schema in Java %s for package org.example.tests", - compiler.getRelease() - ); - - assertThatCompilation(compilation) - .classOutputs().packages() - .fileExists("json-schemas", "UserSchema.json").contents() - .isNotEmpty() - .satisfies(contents -> JSONAssert.assertEquals(...)); - } + // Given + workspace + .createSourcePathPackage() + .createDirectory("org", "example", "tests") + .copyContentsFrom("src", "test", "resources", "code", "schematest"); + + // When + var compilation = compiler + .addAnnotationProcessors(new JsonSchemaAnnotationProcessor()) + .addAnnotationProcessorOptions("jsonschema.verbose=true") + .failOnWarnings(true) + .showDeprecationWarnings(true) + .compile(workspace); + + // Then + assertThatCompilation(compilation) + .isSuccessfulWithoutWarnings(); + + assertThatCompilation(compilation) + .diagnostics().notes().singleElement() + .message().isEqualTo( + "Creating JSON schema in Java %s for package org.example.tests", + compiler.getRelease() + ); + + assertThatCompilation(compilation) + .classOutputs().packages() + .fileExists("json-schemas", "UserSchema.json").contents() + .isNotEmpty() + .satisfies(contents -> JSONAssert.assertEquals(...)); } } ``` diff --git a/acceptance-tests/acceptance-tests-dogfood/pom.xml b/acceptance-tests/acceptance-tests-dogfood/pom.xml index 2a32d072e..26b504224 100644 --- a/acceptance-tests/acceptance-tests-dogfood/pom.xml +++ b/acceptance-tests/acceptance-tests-dogfood/pom.xml @@ -43,6 +43,30 @@ test + + org.junit.platform + junit-platform-commons + test + + + + org.junit.platform + junit-platform-engine + test + + + + org.junit.platform + junit-platform-launcher + test + + + + org.junit.platform + junit-platform-testkit + test + + org.slf4j slf4j-simple diff --git a/acceptance-tests/acceptance-tests-dogfood/src/test/java/module-info.java b/acceptance-tests/acceptance-tests-dogfood/src/test/java/module-info.java index 0ac245dbf..b0c1baf47 100644 --- a/acceptance-tests/acceptance-tests-dogfood/src/test/java/module-info.java +++ b/acceptance-tests/acceptance-tests-dogfood/src/test/java/module-info.java @@ -20,6 +20,7 @@ requires transitive org.junit.jupiter.api; requires transitive org.junit.jupiter.engine; requires transitive org.junit.jupiter.params; - requires transitive org.junit.platform.commons; // required to make IntelliJ happy. - requires transitive org.junit.platform.engine; // required to make IntelliJ happy. + requires transitive org.junit.platform.commons; // required to make IntelliJ happy. + requires transitive org.junit.platform.engine; // required to make IntelliJ happy. + requires transitive org.junit.platform.launcher; // required to make IntelliJ happy. } diff --git a/java-compiler-testing/pom.xml b/java-compiler-testing/pom.xml index 33c72337f..4423dbbb7 100644 --- a/java-compiler-testing/pom.xml +++ b/java-compiler-testing/pom.xml @@ -90,6 +90,12 @@ test + + org.junit.platform + junit-platform-testkit + test + + org.junit.jupiter junit-jupiter @@ -138,6 +144,7 @@ io.github.ascopes.jct.compilers.javac; io.github.ascopes.jct.**.impl; + io.github.ascopes.jct.junit.ext; io.github.ascopes.jct.utils; diff --git a/java-compiler-testing/src/main/java/io/github/ascopes/jct/junit/JctExtension.java b/java-compiler-testing/src/main/java/io/github/ascopes/jct/junit/JctExtension.java new file mode 100644 index 000000000..170b67105 --- /dev/null +++ b/java-compiler-testing/src/main/java/io/github/ascopes/jct/junit/JctExtension.java @@ -0,0 +1,149 @@ +/* + * Copyright (C) 2022 - 2023, the original author or authors. + * + * 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 io.github.ascopes.jct.junit; + +import io.github.ascopes.jct.workspaces.Workspace; +import io.github.ascopes.jct.workspaces.Workspaces; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.List; +import org.apiguardian.api.API; +import org.apiguardian.api.API.Status; +import org.jspecify.annotations.Nullable; +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.Extension; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Implicit extension that will manage the lifecycle of {@link Managed}-annotated {@link Workspace} + * fields within JUnit5 test classes. + * + * @author Ashley Scopes + * @since 0.4.0 + */ +@API(since = "0.4.0", status = Status.STABLE) +public final class JctExtension implements + Extension, BeforeEachCallback, BeforeAllCallback, AfterEachCallback, AfterAllCallback { + + private static final Logger LOGGER = LoggerFactory.getLogger(JctExtension.class); + + @Override + public void beforeAll(ExtensionContext context) throws Exception { + for (var field : getManagedStaticWorkspaceFields(context.getRequiredTestClass())) { + initWorkspaceForField(field, null); + } + } + + @Override + public void beforeEach(ExtensionContext context) throws Exception { + for (var instance : context.getRequiredTestInstances().getAllInstances()) { + for (var field : getManagedInstanceWorkspaceFields(instance.getClass())) { + initWorkspaceForField(field, instance); + } + } + } + + @Override + public void afterAll(ExtensionContext context) throws Exception { + for (var field : getManagedStaticWorkspaceFields(context.getRequiredTestClass())) { + closeWorkspaceForField(field, null); + } + } + + @Override + public void afterEach(ExtensionContext context) throws Exception { + for (var instance : context.getRequiredTestInstances().getAllInstances()) { + for (var field : getManagedInstanceWorkspaceFields(instance.getClass())) { + closeWorkspaceForField(field, instance); + } + } + } + + private List getManagedStaticWorkspaceFields(Class clazz) { + // Do not recurse for static fields, as the state of any parent classes may be shared + // with other classes running in parallel. Need to look up how JUnit expects us to handle that + // case, if at all. + + var fields = new ArrayList(); + + for (var field : clazz.getDeclaredFields()) { + if (isWorkspaceField(field) && Modifier.isStatic(field.getModifiers())) { + fields.add(field); + } + } + + return fields; + } + + private List getManagedInstanceWorkspaceFields(Class clazz) { + // For instances, discover all the fields recursively in superclasses as well that are + // non-static. + + var fields = new ArrayList(); + + while (clazz != null) { + for (var field : clazz.getDeclaredFields()) { + if (isWorkspaceField(field) && !Modifier.isStatic(field.getModifiers())) { + fields.add(field); + } + } + clazz = clazz.getSuperclass(); + } + + return fields; + } + + private boolean isWorkspaceField(Field field) { + return field.getType().equals(Workspace.class) + && field.isAnnotationPresent(Managed.class); + } + + private void initWorkspaceForField(Field field, @Nullable Object instance) throws Exception { + LOGGER + .atTrace() + .setMessage("Initialising workspace for field in {}: {} {} on instance {}") + .addArgument(() -> field.getDeclaringClass().getSimpleName()) + .addArgument(() -> field.getType().getSimpleName()) + .addArgument(field::getName) + .addArgument(instance) + .log(); + + field.setAccessible(true); + var managedWorkspace = field.getAnnotation(Managed.class); + var workspace = Workspaces.newWorkspace(managedWorkspace.pathStrategy()); + field.set(instance, workspace); + } + + private void closeWorkspaceForField(Field field, @Nullable Object instance) throws Exception { + LOGGER + .atTrace() + .setMessage("Closing workspace for field in {}: {} {} on instance {}") + .addArgument(() -> field.getDeclaringClass().getSimpleName()) + .addArgument(() -> field.getType().getSimpleName()) + .addArgument(field::getName) + .addArgument(instance) + .log(); + + field.setAccessible(true); + ((Workspace) field.get(instance)).close(); + } +} diff --git a/java-compiler-testing/src/main/java/io/github/ascopes/jct/junit/Managed.java b/java-compiler-testing/src/main/java/io/github/ascopes/jct/junit/Managed.java new file mode 100644 index 000000000..c47428805 --- /dev/null +++ b/java-compiler-testing/src/main/java/io/github/ascopes/jct/junit/Managed.java @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2022 - 2023, the original author or authors. + * + * 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 io.github.ascopes.jct.junit; + +import io.github.ascopes.jct.workspaces.PathStrategy; +import io.github.ascopes.jct.workspaces.Workspace; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.apiguardian.api.API; +import org.apiguardian.api.API.Status; + +/** + * Annotation for a {@link Workspace} field in a test class. This will ensure it gets + * initialised and closed correctly between tests. + * + *

Use static-fields to keep a workspace object alive for the duration of all the tests + * (providing the same semantics as initialising and closing resources using the + * {@link org.junit.jupiter.api.BeforeAll} and {@link org.junit.jupiter.api.AfterAll} + * annotations). + * + * You must extend your test class with the {@link JctExtension} extension for this annotation + * to be detected and handled. + * + *

Example usage: + * + *


+ * {@literal @ExtendWith(JctExtension.class)}
+ * class MyTest {
+ *   {@literal @Managed}
+ *   Workspace workspace;
+ *
+ *   {@literal @JavacCompilerTest}
+ *   void myTest(JctCompiler<?, ?> compiler) {
+ *     ...
+ *     var compilation = compiler.compile(workspace);
+ *     ...
+ *   }
+ * }
+ * 
+ * + * @author Ashley Scopes + * @since 0.4.0 + */ +@API(since = "0.4.0", status = Status.STABLE) +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface Managed { + + /** + * Get the path strategy to use for the workspace. + * + * @return the path strategy to use. + */ + PathStrategy pathStrategy() default PathStrategy.RAM_DIRECTORIES; +} diff --git a/java-compiler-testing/src/main/java/io/github/ascopes/jct/workspaces/Workspace.java b/java-compiler-testing/src/main/java/io/github/ascopes/jct/workspaces/Workspace.java index b23967fa8..7b2f8a619 100644 --- a/java-compiler-testing/src/main/java/io/github/ascopes/jct/workspaces/Workspace.java +++ b/java-compiler-testing/src/main/java/io/github/ascopes/jct/workspaces/Workspace.java @@ -103,6 +103,15 @@ public interface Workspace extends AutoCloseable { @Override void close(); + /** + * Determine if the workspace is closed or not. + * + * @return {@code true} if closed, {@code false} if open. + * @since 0.4.0 + */ + @API(since = "0.4.0", status = Status.STABLE) + boolean isClosed(); + /// /// Accessor operations /// diff --git a/java-compiler-testing/src/main/java/io/github/ascopes/jct/workspaces/impl/WorkspaceImpl.java b/java-compiler-testing/src/main/java/io/github/ascopes/jct/workspaces/impl/WorkspaceImpl.java index b2e7d04f1..952bd8336 100644 --- a/java-compiler-testing/src/main/java/io/github/ascopes/jct/workspaces/impl/WorkspaceImpl.java +++ b/java-compiler-testing/src/main/java/io/github/ascopes/jct/workspaces/impl/WorkspaceImpl.java @@ -47,39 +47,50 @@ @API(since = "0.0.1", status = Status.INTERNAL) public final class WorkspaceImpl implements Workspace { + private volatile boolean closed; private final PathStrategy pathStrategy; private final Map> paths; public WorkspaceImpl(PathStrategy pathStrategy) { + closed = false; this.pathStrategy = requireNonNull(pathStrategy, "pathStrategy"); paths = new HashMap<>(); } @Override public void close() { - // Close everything in a best-effort fashion. - var exceptions = new ArrayList(); - - for (var list : paths.values()) { - for (var path : list) { - if (path instanceof AbstractManagedDirectory) { - try { - ((AbstractManagedDirectory) path).close(); - - } catch (Exception ex) { - exceptions.add(ex); + try { + // Close everything in a best-effort fashion. + var exceptions = new ArrayList(); + + for (var list : paths.values()) { + for (var path : list) { + if (path instanceof AbstractManagedDirectory) { + try { + ((AbstractManagedDirectory) path).close(); + + } catch (Exception ex) { + exceptions.add(ex); + } } } } - } - if (exceptions.size() > 0) { - var newEx = new IllegalStateException("One or more components failed to close"); - exceptions.forEach(newEx::addSuppressed); - throw newEx; + if (exceptions.size() > 0) { + var newEx = new IllegalStateException("One or more components failed to close"); + exceptions.forEach(newEx::addSuppressed); + throw newEx; + } + } finally { + closed = true; } } + @Override + public boolean isClosed() { + return closed; + } + @Override public void addPackage(Location location, Path path) { requireNonNull(location, "location"); diff --git a/java-compiler-testing/src/main/java/module-info.java b/java-compiler-testing/src/main/java/module-info.java index b70cdd32e..6fa9566c4 100644 --- a/java-compiler-testing/src/main/java/module-info.java +++ b/java-compiler-testing/src/main/java/module-info.java @@ -14,7 +14,9 @@ * limitations under the License. */ +import io.github.ascopes.jct.junit.JctExtension; import io.github.ascopes.jct.workspaces.RamFileSystemProvider; +import org.junit.jupiter.api.extension.Extension; /** * A framework for performing exhaustive integration testing against Java compilers in modern Java @@ -101,6 +103,7 @@ requires static transitive org.apiguardian.api; requires org.assertj.core; requires static org.jspecify; + requires static transitive org.junit.jupiter.api; requires static transitive org.junit.jupiter.params; requires org.slf4j; @@ -123,6 +126,7 @@ /// SERVICE PROVIDER INTERFACES /// /////////////////////////////////// + provides Extension with JctExtension; uses RamFileSystemProvider; ////////////////////////////////////////////////////// diff --git a/java-compiler-testing/src/test/java/io/github/ascopes/jct/tests/integration/JctExtensionIntegrationTest.java b/java-compiler-testing/src/test/java/io/github/ascopes/jct/tests/integration/JctExtensionIntegrationTest.java new file mode 100644 index 000000000..c1baf698f --- /dev/null +++ b/java-compiler-testing/src/test/java/io/github/ascopes/jct/tests/integration/JctExtensionIntegrationTest.java @@ -0,0 +1,305 @@ +/* + * Copyright (C) 2022 - 2023, the original author or authors. + * + * 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 io.github.ascopes.jct.tests.integration; + + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.DynamicTest.dynamicTest; +import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +import io.github.ascopes.jct.junit.JctExtension; +import io.github.ascopes.jct.junit.Managed; +import io.github.ascopes.jct.workspaces.PathStrategy; +import io.github.ascopes.jct.workspaces.Workspace; +import io.github.ascopes.jct.workspaces.Workspaces; +import java.util.ArrayList; +import java.util.stream.IntStream; +import java.util.stream.Stream; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.junit.platform.testkit.engine.EngineTestKit; + +/** + * Integration tests for {@link JctExtension}. + * + * @author Ashley Scopes + */ +@DisplayName("JctExtension integration tests") +class JctExtensionIntegrationTest { + + @DisplayName("Static workspaces are initialized and closed for all tests") + @Test + void staticWorkspacesAreInitializedAndClosedOnceForAllTests() { + var workspace = mock(Workspace.class); + + try (var workspacesMock = mockStatic(Workspaces.class)) { + workspacesMock.when(() -> Workspaces.newWorkspace(any())).thenReturn(workspace); + + var results = testKit() + .selectors(selectClass(StaticLifecycleTestCase.class)) + .execute(); + + assertThat(results.testEvents().succeeded().count()).isEqualTo(7); + workspacesMock.verify(() -> Workspaces.newWorkspace(PathStrategy.RAM_DIRECTORIES)); + verify(workspace).close(); + workspacesMock.verifyNoMoreInteractions(); + verifyNoMoreInteractions(workspace); + } + } + + @Disabled("This is just test data") + @ExtendWith(JctExtension.class) + static class StaticLifecycleTestCase { + + @Managed + static Workspace workspace; + + @Test + void testWorkspaceIsInitialised() { + assertThat(workspace).isNotNull(); + } + + @Test + void testWorkspaceIsInitialisedAgain() { + assertThat(workspace).isNotNull(); + } + + @RepeatedTest(5) + void testWorkspaceIsInitialisedRepeatedly() { + assertThat(workspace).isNotNull(); + } + } + + @DisplayName("Instance workspaces are initialized and closed for each test case") + @Test + void instanceWorkspacesAreInitializedAndClosedForEachTest() { + var workspaces = new ArrayList(); + + try (var workspacesMock = mockStatic(Workspaces.class)) { + workspacesMock.when(() -> Workspaces.newWorkspace(any())) + .then(ctx -> { + var workspace = mock(Workspace.class); + workspaces.add(workspace); + return workspace; + }); + + var results = testKit() + .selectors(selectClass(InstanceLifecycleTestCase.class)) + .execute(); + + assertThat(results.testEvents().succeeded().count()).isEqualTo(7); + workspacesMock.verify(() -> Workspaces.newWorkspace(PathStrategy.RAM_DIRECTORIES), times(21)); + workspacesMock.verifyNoMoreInteractions(); + + assertThat(workspaces) + .hasSize(21) + .allSatisfy(ws -> verify(ws).close()) + .allSatisfy(ws -> verifyNoMoreInteractions(ws)); + } + } + + @Disabled("This is just test data") + @ExtendWith(JctExtension.class) + static class InstanceLifecycleTestCase { + + @Managed + Workspace workspace1; + + @Managed + Workspace workspace2; + + @Managed + Workspace workspace3; + + @Test + void testWorkspaceIsInitialised() { + assertThat(workspace1).isNotNull(); + assertThat(workspace2).isNotNull(); + assertThat(workspace3).isNotNull(); + } + + @Test + void testWorkspaceIsInitialisedAgain() { + assertThat(workspace1).isNotNull(); + assertThat(workspace2).isNotNull(); + assertThat(workspace3).isNotNull(); + } + + @RepeatedTest(5) + void testWorkspaceIsInitialisedRepeatedly() { + assertThat(workspace1).isNotNull(); + assertThat(workspace2).isNotNull(); + assertThat(workspace3).isNotNull(); + } + } + + @DisplayName("Instance workspaces are initialized and closed for each parameterized test case") + @Test + void instanceWorkspacesAreInitializedAndClosedForEachParameterizedTest() { + var workspaces = new ArrayList(); + + try (var workspacesMock = mockStatic(Workspaces.class)) { + workspacesMock.when(() -> Workspaces.newWorkspace(any())) + .then(ctx -> { + var workspace = mock(Workspace.class); + workspaces.add(workspace); + return workspace; + }); + + var results = testKit() + .selectors(selectClass(ParameterizedLifecycleTestCase.class)) + .execute(); + + assertThat(results.testEvents().succeeded().count()).isEqualTo(10); + workspacesMock.verify(() -> Workspaces.newWorkspace(PathStrategy.RAM_DIRECTORIES), times(10)); + workspacesMock.verifyNoMoreInteractions(); + + assertThat(workspaces) + .hasSize(10) + .allSatisfy(ws -> verify(ws).close()) + .allSatisfy(ws -> verifyNoMoreInteractions(ws)); + } + } + + @Disabled("This is just test data") + @ExtendWith(JctExtension.class) + static class ParameterizedLifecycleTestCase { + + @Managed + Workspace workspace; + + @ValueSource(ints = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}) + @ParameterizedTest + void testWorkspaceIsInitialised(int iteration) { + assertThat(workspace) + .as("workspace " + workspace + " for iteration " + iteration) + .isNotNull(); + } + } + + @DisplayName("Instance workspaces are initialized and closed once for test factories") + @Test + void instanceWorkspacesAreInitializedAndClosedOnceForTestFactories() { + var workspaces = new ArrayList(); + + try (var workspacesMock = mockStatic(Workspaces.class)) { + workspacesMock.when(() -> Workspaces.newWorkspace(any())) + .then(ctx -> { + var workspace = mock(Workspace.class); + workspaces.add(workspace); + return workspace; + }); + + var results = testKit() + .selectors(selectClass(DynamicLifecycleTestCase.class)) + .execute(); + + assertThat(results.testEvents().succeeded().count()).isEqualTo(10); + workspacesMock.verify(() -> Workspaces.newWorkspace(PathStrategy.RAM_DIRECTORIES), times(1)); + workspacesMock.verifyNoMoreInteractions(); + + assertThat(workspaces) + .hasSize(1) + .allSatisfy(ws -> verify(ws).close()) + .allSatisfy(ws -> verifyNoMoreInteractions(ws)); + } + } + + @Disabled("This is just test data") + @ExtendWith(JctExtension.class) + static class DynamicLifecycleTestCase { + + @Managed + Workspace workspace; + + @TestFactory + Stream testWorkspaceIsInitialised() { + return IntStream + .rangeClosed(1, 10) + .mapToObj(iteration -> dynamicTest("for iteration " + iteration, () -> { + assertThat(workspace) + .as("workspace " + workspace + " for iteration " + iteration) + .isNotNull(); + })); + } + } + + @DisplayName("Explicit path strategies for workspaces are handled") + @Test + void explicitPathStrategiesAreHandled() { + try (var workspacesMock = mockStatic(Workspaces.class)) { + workspacesMock.when(() -> Workspaces.newWorkspace(any())).thenCallRealMethod(); + + var results = testKit() + .selectors(selectClass(CustomPathStrategyTestCase.class)) + .execute(); + + assertThat(results.testEvents().succeeded().count()).isEqualTo(1); + workspacesMock + .verify(() -> Workspaces.newWorkspace(PathStrategy.RAM_DIRECTORIES), times(2)); + workspacesMock + .verify(() -> Workspaces.newWorkspace(PathStrategy.TEMP_DIRECTORIES), times(2)); + workspacesMock.verifyNoMoreInteractions(); + } + } + + @Disabled("This is just test data") + @ExtendWith(JctExtension.class) + static class CustomPathStrategyTestCase { + + @Managed(pathStrategy = PathStrategy.RAM_DIRECTORIES) + static Workspace staticRamDirectoriesWorkspace; + + @Managed(pathStrategy = PathStrategy.TEMP_DIRECTORIES) + static Workspace staticTempDirectoriesWorkspace; + + @Managed(pathStrategy = PathStrategy.RAM_DIRECTORIES) + Workspace ramDirectoriesWorkspace; + + @Managed(pathStrategy = PathStrategy.TEMP_DIRECTORIES) + Workspace tempDirectoriesWorkspace; + + @Test + void testWorkspaceIsInitialisedWithCorrectPathStrategy() { + assertThat(staticRamDirectoriesWorkspace.getPathStrategy()) + .isSameAs(PathStrategy.RAM_DIRECTORIES); + assertThat(staticTempDirectoriesWorkspace.getPathStrategy()) + .isSameAs(PathStrategy.TEMP_DIRECTORIES); + assertThat(ramDirectoriesWorkspace.getPathStrategy()) + .isSameAs(PathStrategy.RAM_DIRECTORIES); + assertThat(tempDirectoriesWorkspace.getPathStrategy()) + .isSameAs(PathStrategy.TEMP_DIRECTORIES); + } + } + + private static EngineTestKit.Builder testKit() { + return EngineTestKit.engine("junit-jupiter") + .configurationParameter("junit.jupiter.conditions.deactivate", "*DisabledCondition"); + } +} diff --git a/java-compiler-testing/src/test/java/io/github/ascopes/jct/tests/unit/junit/JctExtensionTest.java b/java-compiler-testing/src/test/java/io/github/ascopes/jct/tests/unit/junit/JctExtensionTest.java new file mode 100644 index 000000000..bd3dc6ff2 --- /dev/null +++ b/java-compiler-testing/src/test/java/io/github/ascopes/jct/tests/unit/junit/JctExtensionTest.java @@ -0,0 +1,406 @@ +/* + * Copyright (C) 2022 - 2023, the original author or authors. + * + * 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 io.github.ascopes.jct.tests.unit.junit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import io.github.ascopes.jct.junit.JctExtension; +import io.github.ascopes.jct.junit.Managed; +import io.github.ascopes.jct.workspaces.PathStrategy; +import io.github.ascopes.jct.workspaces.Workspace; +import io.github.ascopes.jct.workspaces.Workspaces; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.TestInstances; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.junit.jupiter.api.parallel.Isolated; +import org.mockito.Answers; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * {@link JctExtension} tests. + * + * @author Ashley Scopes + */ +@DisplayName("JctExtension tests") +@Execution(ExecutionMode.SAME_THREAD) +@ExtendWith(MockitoExtension.class) +@Isolated("This modifies global state in some test cases") +class JctExtensionTest { + @Mock + ExtensionContext extensionContext; + + @Mock(answer = Answers.RETURNS_MOCKS) + MockedStatic workspacesMock; + + JctExtension extension; + + @BeforeEach + void setUp() { + extension = new JctExtension(); + } + + @DisplayName("The beforeAll hook initialises annotated static workspace fields") + @Test + void beforeAllHookInitialisesAnnotatedStaticWorkspaceFields() { + // Given + StaticWorkspaceTestCase.staticWorkspace1 = null; + StaticWorkspaceTestCase.staticWorkspace2 = null; + StaticWorkspaceTestCase.staticWorkspace3 = null; + StaticWorkspaceTestCase.someInvalidStaticWorkspace = null; + StaticWorkspaceTestCase.someIgnoredStaticWorkspace = null; + + var expectedWorkspace1 = mock(Workspace.class); + var expectedWorkspace2 = mock(Workspace.class); + var expectedWorkspace3 = mock(Workspace.class); + workspacesMock.when(() -> Workspaces.newWorkspace(any())) + .thenReturn(expectedWorkspace1, expectedWorkspace2, expectedWorkspace3); + when(extensionContext.getRequiredTestClass()).thenAnswer(ctx -> StaticWorkspaceTestCase.class); + + // When + assertThatCode(() -> extension.beforeAll(extensionContext)) + .doesNotThrowAnyException(); + + // Then + workspacesMock.verify(() -> Workspaces.newWorkspace(PathStrategy.RAM_DIRECTORIES), times(2)); + workspacesMock.verify(() -> Workspaces.newWorkspace(PathStrategy.TEMP_DIRECTORIES)); + workspacesMock.verifyNoMoreInteractions(); + + assertThat(List.of( + StaticWorkspaceTestCase.staticWorkspace1, + StaticWorkspaceTestCase.staticWorkspace2, + StaticWorkspaceTestCase.staticWorkspace3 + )).containsExactlyInAnyOrder(expectedWorkspace1, expectedWorkspace2, expectedWorkspace3); + + assertThat(StaticWorkspaceTestCase.someInvalidStaticWorkspace).isNull(); + assertThat(StaticWorkspaceTestCase.someIgnoredStaticWorkspace).isNull(); + } + + @DisplayName("The afterAll hook closes annotated static workspace fields") + @Test + void afterAllHookClosesAnnotatedStaticWorkspaceFields() { + // Given + StaticWorkspaceTestCase.staticWorkspace1 = mock(Workspace.class); + StaticWorkspaceTestCase.staticWorkspace2 = mock(Workspace.class); + StaticWorkspaceTestCase.staticWorkspace3 = mock(Workspace.class); + StaticWorkspaceTestCase.someInvalidStaticWorkspace = mock(Workspace.class); + StaticWorkspaceTestCase.someIgnoredStaticWorkspace = mock(); + + when(extensionContext.getRequiredTestClass()).thenAnswer(ctx -> StaticWorkspaceTestCase.class); + + // When + assertThatCode(() -> extension.afterAll(extensionContext)) + .doesNotThrowAnyException(); + + // Then + verify(StaticWorkspaceTestCase.staticWorkspace1).close(); + verifyNoMoreInteractions(StaticWorkspaceTestCase.staticWorkspace1); + verify(StaticWorkspaceTestCase.staticWorkspace2).close(); + verifyNoMoreInteractions(StaticWorkspaceTestCase.staticWorkspace2); + verify(StaticWorkspaceTestCase.staticWorkspace3).close(); + verifyNoMoreInteractions(StaticWorkspaceTestCase.staticWorkspace3); + verifyNoInteractions(StaticWorkspaceTestCase.someInvalidStaticWorkspace); + verifyNoInteractions(StaticWorkspaceTestCase.someIgnoredStaticWorkspace); + } + + @Disabled("This is just test data") + static class StaticWorkspaceTestCase { + @Managed + static Workspace staticWorkspace1; + + @Managed(pathStrategy = PathStrategy.RAM_DIRECTORIES) + static Workspace staticWorkspace2; + + @Managed(pathStrategy = PathStrategy.TEMP_DIRECTORIES) + static Workspace staticWorkspace3; + + // These all get ignored because they don't match the typing/annotation criteria. + static Workspace someIgnoredStaticWorkspace; + + @Managed + static Object someInvalidStaticWorkspace; + + // These should be ignored because they are not static, so no instance exists to apply them on. + @Managed + Object someInvalidInstanceWorkspace; + + @Managed + Workspace someIgnoredInstanceWorkspace; + + @Test + void testSomething() { + // ... + } + + @Test + void testSomethingAgain() { + // ... + } + } + + @DisplayName("The beforeEach hook initialises annotated instance workspace fields") + @Test + void beforeEachHookInitialisesAnnotatedInstanceWorkspaceFields() { + // Given + var instance1 = new InstanceWorkspaceTestCase(); + var instance2 = new InstanceWorkspaceTestCase(); + var instance3 = new InstanceWorkspaceTestCase(); + var testInstances = mock(TestInstances.class); + when(testInstances.getAllInstances()).thenReturn(List.of(instance1, instance2, instance3)); + + var expectedWorkspace1 = mock(Workspace.class); + var expectedWorkspace2 = mock(Workspace.class); + var expectedWorkspace3 = mock(Workspace.class); + var expectedWorkspace4 = mock(Workspace.class); + var expectedWorkspace5 = mock(Workspace.class); + var expectedWorkspace6 = mock(Workspace.class); + var expectedWorkspace7 = mock(Workspace.class); + var expectedWorkspace8 = mock(Workspace.class); + var expectedWorkspace9 = mock(Workspace.class); + workspacesMock.when(() -> Workspaces.newWorkspace(any())) + .thenReturn( + expectedWorkspace1, expectedWorkspace2, expectedWorkspace3, expectedWorkspace4, + expectedWorkspace5, expectedWorkspace6, expectedWorkspace7, expectedWorkspace8, + expectedWorkspace9 + ); + when(extensionContext.getRequiredTestInstances()) + .thenReturn(testInstances); + + // When + assertThatCode(() -> extension.beforeEach(extensionContext)) + .doesNotThrowAnyException(); + + // Then + workspacesMock.verify(() -> Workspaces.newWorkspace(PathStrategy.RAM_DIRECTORIES), times(6)); + workspacesMock.verify(() -> Workspaces.newWorkspace(PathStrategy.TEMP_DIRECTORIES), times(3)); + workspacesMock.verifyNoMoreInteractions(); + + assertThat(List.of( + instance1.workspace1, + instance1.workspace2, + instance1.workspace3 + )).containsExactlyInAnyOrder(expectedWorkspace1, expectedWorkspace2, expectedWorkspace3); + + assertThat(List.of( + instance2.workspace1, + instance2.workspace2, + instance2.workspace3 + )).containsExactlyInAnyOrder(expectedWorkspace4, expectedWorkspace5, expectedWorkspace6); + + assertThat(List.of( + instance3.workspace1, + instance3.workspace2, + instance3.workspace3 + )).containsExactlyInAnyOrder(expectedWorkspace7, expectedWorkspace8, expectedWorkspace9); + + assertThat(InstanceWorkspaceTestCase.someIgnoredStaticWorkspace).isNull(); + assertThat(InstanceWorkspaceTestCase.someInvalidStaticWorkspace).isNull(); + } + + @DisplayName("The afterEach hook closes annotated instance workspace fields") + @Test + void afterEachHookClosesAnnotatedInstanceWorkspaceFields() { + // Given + InstanceWorkspaceTestCase.someIgnoredStaticWorkspace = mock(); + InstanceWorkspaceTestCase.someInvalidStaticWorkspace = mock(); + var instance1 = new InstanceWorkspaceTestCase(); + var instance2 = new InstanceWorkspaceTestCase(); + var instance3 = new InstanceWorkspaceTestCase(); + var testInstances = mock(TestInstances.class); + when(testInstances.getAllInstances()).thenReturn(List.of(instance1, instance2, instance3)); + + instance1.workspace1 = mock(); + instance1.workspace2 = mock(); + instance1.workspace3 = mock(); + instance1.someIgnoredInstanceWorkspace = mock(); + instance1.someInvalidInstanceWorkspace = mock(); + instance2.workspace1 = mock(); + instance2.workspace2 = mock(); + instance2.workspace3 = mock(); + instance2.someIgnoredInstanceWorkspace = mock(); + instance2.someInvalidInstanceWorkspace = mock(); + instance3.workspace1 = mock(); + instance3.workspace2 = mock(); + instance3.workspace3 = mock(); + instance3.someIgnoredInstanceWorkspace = mock(); + instance3.someInvalidInstanceWorkspace = mock(); + + when(extensionContext.getRequiredTestInstances()) + .thenReturn(testInstances); + + // When + assertThatCode(() -> extension.afterEach(extensionContext)) + .doesNotThrowAnyException(); + + // Then + verify(instance1.workspace1).close(); + verifyNoMoreInteractions(instance1.workspace1); + verify(instance1.workspace2).close(); + verifyNoMoreInteractions(instance1.workspace2); + verify(instance1.workspace3).close(); + verifyNoMoreInteractions(instance1.workspace3); + verify(instance2.workspace1).close(); + verifyNoMoreInteractions(instance2.workspace1); + verify(instance2.workspace2).close(); + verifyNoMoreInteractions(instance2.workspace2); + verify(instance2.workspace3).close(); + verifyNoMoreInteractions(instance2.workspace3); + verify(instance3.workspace1).close(); + verifyNoMoreInteractions(instance3.workspace1); + verify(instance3.workspace2).close(); + verifyNoMoreInteractions(instance3.workspace2); + verify(instance3.workspace3).close(); + verifyNoMoreInteractions(instance3.workspace3); + + verifyNoInteractions(instance1.someIgnoredInstanceWorkspace); + verifyNoInteractions(instance1.someInvalidInstanceWorkspace); + verifyNoInteractions(instance2.someIgnoredInstanceWorkspace); + verifyNoInteractions(instance2.someInvalidInstanceWorkspace); + verifyNoInteractions(instance3.someIgnoredInstanceWorkspace); + verifyNoInteractions(instance3.someInvalidInstanceWorkspace); + verifyNoInteractions(InstanceWorkspaceTestCase.someIgnoredStaticWorkspace); + verifyNoInteractions(InstanceWorkspaceTestCase.someInvalidStaticWorkspace); + verifyNoInteractions(InstanceWorkspaceTestCase.someIgnoredStaticWorkspace); + } + + @Disabled(value = "This is just test data") + static class InstanceWorkspaceTestCase { + @Managed + Workspace workspace1; + + @Managed(pathStrategy = PathStrategy.RAM_DIRECTORIES) + Workspace workspace2; + + @Managed(pathStrategy = PathStrategy.TEMP_DIRECTORIES) + Workspace workspace3; + + // These all get ignored because they don't match the typing/annotation criteria. + Workspace someIgnoredInstanceWorkspace; + + @Managed + Object someInvalidInstanceWorkspace; + + // These should be ignored because they are static. + @Managed + static Object someInvalidStaticWorkspace; + + @Managed + static Workspace someIgnoredStaticWorkspace; + + @Test + void testSomething() { + // ... + } + + @Test + void testSomethingAgain() { + // ... + } + } + + @DisplayName("The beforeEach hook will initialise workspaces in any superclasses") + @Test + void beforeEachHookWillInitialiseWorkspacesInAnySuperClasses() { + // Given + var instance = new TestCaseImpl(); + var testInstances = mock(TestInstances.class); + when(testInstances.getAllInstances()).thenReturn(List.of(instance)); + when(extensionContext.getRequiredTestInstances()).thenReturn(testInstances); + + var expectedWorkspace1 = mock(Workspace.class); + var expectedWorkspace2 = mock(Workspace.class); + var expectedWorkspace3 = mock(Workspace.class); + var expectedWorkspace4 = mock(Workspace.class); + + workspacesMock.when(() -> Workspaces.newWorkspace(any())) + .thenReturn(expectedWorkspace1, expectedWorkspace2, expectedWorkspace3, expectedWorkspace4); + + // When + assertThatCode(() -> extension.beforeEach(extensionContext)) + .doesNotThrowAnyException(); + + // Then + assertThat(instance.testCaseImplWorkspace).isSameAs(expectedWorkspace1); + assertThat(instance.testCaseBase3Workspace).isSameAs(expectedWorkspace2); + assertThat(instance.testCaseBase2Workspace).isSameAs(expectedWorkspace3); + assertThat(instance.testCaseBase1Workspace).isSameAs(expectedWorkspace4); + } + + @DisplayName("The afterEach hook will close workspaces in any superclasses") + @Test + void afterEachHookWillCloseWorkspacesInAnySuperClasses() { + // Given + var instance = new TestCaseImpl(); + var testInstances = mock(TestInstances.class); + when(testInstances.getAllInstances()).thenReturn(List.of(instance)); + when(extensionContext.getRequiredTestInstances()).thenReturn(testInstances); + + instance.testCaseImplWorkspace = mock(); + instance.testCaseBase3Workspace = mock(); + instance.testCaseBase2Workspace = mock(); + instance.testCaseBase1Workspace = mock(); + + // When + assertThatCode(() -> extension.afterEach(extensionContext)) + .doesNotThrowAnyException(); + + // Then + verify(instance.testCaseImplWorkspace).close(); + verifyNoMoreInteractions(instance.testCaseImplWorkspace); + verify(instance.testCaseBase1Workspace).close(); + verifyNoMoreInteractions(instance.testCaseBase1Workspace); + verify(instance.testCaseBase2Workspace).close(); + verifyNoMoreInteractions(instance.testCaseBase2Workspace); + verify(instance.testCaseBase3Workspace).close(); + verifyNoMoreInteractions(instance.testCaseBase3Workspace); + } + + static class TestCaseBase1 { + @Managed + Workspace testCaseBase1Workspace; + } + + static class TestCaseBase2 extends TestCaseBase1 { + @Managed + Workspace testCaseBase2Workspace; + } + + static class TestCaseBase3 extends TestCaseBase2 { + @Managed + Workspace testCaseBase3Workspace; + } + + static class TestCaseImpl extends TestCaseBase3 { + @Managed + Workspace testCaseImplWorkspace; + } +} diff --git a/java-compiler-testing/src/test/java/module-info.java b/java-compiler-testing/src/test/java/module-info.java index 3dc1f89d3..93cb7fae7 100644 --- a/java-compiler-testing/src/test/java/module-info.java +++ b/java-compiler-testing/src/test/java/module-info.java @@ -27,8 +27,9 @@ requires transitive org.junit.jupiter.api; requires transitive org.junit.jupiter.engine; requires transitive org.junit.jupiter.params; - requires transitive org.junit.platform.commons; // required to make IntelliJ happy. - requires transitive org.junit.platform.engine; // required to make IntelliJ happy. + requires transitive org.junit.platform.commons; + requires transitive org.junit.platform.engine; + requires transitive org.junit.platform.testkit; requires org.mockito; requires org.mockito.junit.jupiter; requires org.slf4j; From 1957b7a48981dc4ab971c7311141f40c098eded3 Mon Sep 17 00:00:00 2001 From: Ash <73482956+ascopes@users.noreply.github.com> Date: Sun, 19 Mar 2023 08:23:01 +0000 Subject: [PATCH 2/2] Fix doc typo in Managed.java Signed-off-by: Ash <73482956+ascopes@users.noreply.github.com> --- .../src/main/java/io/github/ascopes/jct/junit/Managed.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/java-compiler-testing/src/main/java/io/github/ascopes/jct/junit/Managed.java b/java-compiler-testing/src/main/java/io/github/ascopes/jct/junit/Managed.java index c47428805..095501d36 100644 --- a/java-compiler-testing/src/main/java/io/github/ascopes/jct/junit/Managed.java +++ b/java-compiler-testing/src/main/java/io/github/ascopes/jct/junit/Managed.java @@ -45,7 +45,7 @@ * Workspace workspace; * * {@literal @JavacCompilerTest} - * void myTest(JctCompiler<?, ?> compiler) { + * void myTest(JctCompiler<?, ?> compiler) { * ... * var compilation = compiler.compile(workspace); * ...