diff --git a/playwright/src/main/java/com/microsoft/playwright/impl/junit/OptionsExtension.java b/playwright/src/main/java/com/microsoft/playwright/impl/junit/OptionsExtension.java index d36eb700e..7e14e1873 100644 --- a/playwright/src/main/java/com/microsoft/playwright/impl/junit/OptionsExtension.java +++ b/playwright/src/main/java/com/microsoft/playwright/impl/junit/OptionsExtension.java @@ -18,33 +18,70 @@ import com.microsoft.playwright.PlaywrightException; import com.microsoft.playwright.junit.Options; -import com.microsoft.playwright.junit.UsePlaywright; +import com.microsoft.playwright.junit.OptionsFactory; import org.junit.jupiter.api.extension.AfterAllCallback; import org.junit.jupiter.api.extension.ExtensionContext; +import java.lang.reflect.Constructor; +import java.lang.reflect.Modifier; + import static com.microsoft.playwright.impl.junit.ExtensionUtils.*; public class OptionsExtension implements AfterAllCallback { private static final ThreadLocal threadLocalOptions = new ThreadLocal<>(); + // Test instance that the options were created for, when the factory is an inner class of the test class. + private static final ThreadLocal threadLocalEnclosingInstance = new ThreadLocal<>(); @Override public void afterAll(ExtensionContext extensionContext) { threadLocalOptions.remove(); + threadLocalEnclosingInstance.remove(); } static Options getOptions(ExtensionContext extensionContext) { + Class factoryClass = getUsePlaywrightAnnotation(extensionContext).value(); + // Inner class factories can access fields of the test instance, so the options are + // re-created for each test instance. + Object enclosingInstance = findEnclosingInstance(factoryClass, extensionContext); Options options = threadLocalOptions.get(); - if (options != null) { + if (options != null && (enclosingInstance == null || enclosingInstance == threadLocalEnclosingInstance.get())) { return options; } + options = createFactory(factoryClass, enclosingInstance).getOptions(); + threadLocalOptions.set(options); + threadLocalEnclosingInstance.set(enclosingInstance); + return options; + } + + private static boolean isInnerClass(Class clazz) { + return clazz.isMemberClass() && !Modifier.isStatic(clazz.getModifiers()); + } + + private static Object findEnclosingInstance(Class factoryClass, ExtensionContext extensionContext) { + if (!isInnerClass(factoryClass)) { + return null; + } + return extensionContext.getTestInstances() + .flatMap(instances -> instances.findInstance(factoryClass.getDeclaringClass())) + .orElse(null); + } - UsePlaywright usePlaywrightAnnotation = getUsePlaywrightAnnotation(extensionContext); + private static OptionsFactory createFactory(Class factoryClass, Object enclosingInstance) { + if (enclosingInstance == null && isInnerClass(factoryClass)) { + throw new PlaywrightException("Cannot create " + factoryClass.getName() + " without an instance of " + + factoryClass.getDeclaringClass().getName() + ". Make the options factory a static class or use " + + "@TestInstance(TestInstance.Lifecycle.PER_CLASS) to request Playwright fixtures in @BeforeAll methods."); + } try { - options = usePlaywrightAnnotation.value().newInstance().getOptions(); - threadLocalOptions.set(options); - } catch (InstantiationException | IllegalAccessException e) { + if (enclosingInstance == null) { + return factoryClass.newInstance(); + } + // Use the declaring class rather than enclosingInstance.getClass(), which may be a subclass. + Constructor constructor = factoryClass.getDeclaredConstructor(factoryClass.getDeclaringClass()); + constructor.setAccessible(true); + return constructor.newInstance(enclosingInstance); + } catch (ReflectiveOperationException e) { throw new PlaywrightException("Failed to create options", e); } - return options; } } diff --git a/playwright/src/main/java/com/microsoft/playwright/junit/OptionsFactory.java b/playwright/src/main/java/com/microsoft/playwright/junit/OptionsFactory.java index 3a6256a1a..c2b994254 100644 --- a/playwright/src/main/java/com/microsoft/playwright/junit/OptionsFactory.java +++ b/playwright/src/main/java/com/microsoft/playwright/junit/OptionsFactory.java @@ -54,6 +54,29 @@ * } * } * + *

If the options depend on the state of the test instance (e.g. a server injected by + * a dependency injection framework), declare the factory as a non-static inner class of + * the test class. It will be created for each test instance and can access its fields: + *

{@code
+ * @UsePlaywright(MyTest.CustomOptions.class)
+ * public class MyTest {
+ *   @Inject
+ *   EmbeddedServer server;
+ *
+ *   class CustomOptions implements OptionsFactory {
+ *     @Override
+ *     public Options getOptions() {
+ *       return new Options().setBaseUrl(server.getURL().toString());
+ *     }
+ *   }
+ * }
+ * }
+ * Only context level options (e.g. base URL, context options, trace) are re-evaluated for each + * test instance; {@code Playwright} and {@code Browser} are created with the options of the first + * test instance and reused. Unless the test class uses {@code @TestInstance(Lifecycle.PER_CLASS)}, + * Playwright fixtures cannot be requested in {@code @BeforeAll} methods because there is no + * test instance yet. + * *

For more details and usage examples see our * JUnit guide. */ diff --git a/playwright/src/test/java/com/microsoft/playwright/junit/TestFixtureInnerOptionsFactory.java b/playwright/src/test/java/com/microsoft/playwright/junit/TestFixtureInnerOptionsFactory.java new file mode 100644 index 000000000..3411fd2b1 --- /dev/null +++ b/playwright/src/test/java/com/microsoft/playwright/junit/TestFixtureInnerOptionsFactory.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) Microsoft Corporation. + * + * 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.microsoft.playwright.junit; + +import com.microsoft.playwright.Browser; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.Server; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@FixtureTest +@UsePlaywright(TestFixtureInnerOptionsFactory.CustomOptions.class) +public class TestFixtureInnerOptionsFactory { + // Set per instance, similar to fields injected by Spring or Micronaut. + private final Server server; + private boolean optionsCreated; + + TestFixtureInnerOptionsFactory(Server server) { + this.server = server; + } + + class CustomOptions implements OptionsFactory { + @Override + public Options getOptions() { + optionsCreated = true; + return new Options().setBaseUrl(server.PREFIX); + } + } + + // Each repetition gets a new test instance. + @RepeatedTest(2) + void shouldCreateOptionsForEachTestInstance(Page page) { + assertTrue(optionsCreated); + page.navigate("/empty.html"); + assertThat(page).hasURL(server.EMPTY_PAGE); + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class PerClassLifecycle { + @BeforeAll + void beforeAll(Browser.NewContextOptions contextOptions) { + assertEquals(server.PREFIX, contextOptions.baseURL); + } + + @Test + void shouldUseOuterInstance(Page page) { + page.navigate("/empty.html"); + assertThat(page).hasURL(server.EMPTY_PAGE); + } + } +}