diff --git a/README.md b/README.md index 897c0b6..6e88b85 100644 --- a/README.md +++ b/README.md @@ -53,9 +53,21 @@ This requires a [compatible container runtime](https://java.testcontainers.org/s If you choose to use the `ContainerGebSpec` class, as long as you have a compatible container runtime installed, you don't need to do anything else. Just run `./gradlew integrationTest` and a container will be started and configured to start a browser that can access your application under test. +#### Parallel Execution + +Parallel execution of `ContainerGebSpec` specifications is not currently supported. + #### Custom Host Configuration -The annotation `ContainerGebConfiguration` exists to customize the connection the container will use to access the application under test. The annotation is not required and `ContainerGebSpec` will use the default values in this annotation if it's not present. +The annotation `ContainerGebConfiguration` exists to customize the connection the container will use to access the application under test. The annotation is not required and `ContainerGebSpec` will use the default values in this annotation if it's not present. A traditional `GebConfig.groovy` can be provided to configure non-container specific settings. + +#### Reporting + +To configure reporting, enable it using the `recording` property on the annotation `ContainerGebConfiguration`. The following system properties exist for reporting configuration: + +* `grails.geb.reporting.directory` + * purpose: if the test enables reporting, the directory to save the reports relative to the project directory + * defaults to `build/gebContainer/reports` #### Recording @@ -74,7 +86,7 @@ By default, no test recording will be performed. Various system properties exis * `grails.geb.recording.directory` * purpose: the directory to save the recordings relative to the project directory - * defaults to `build/recordings` + * defaults to `build/gebContainer/recordings` * `grails.geb.recording.format` diff --git a/gradle.properties b/gradle.properties index b82b693..748654e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,7 +2,7 @@ projectVersion=5.0.0-SNAPSHOT grailsVersion=7.0.0-SNAPSHOT grailsGradlePluginVersion=7.0.0-SNAPSHOT seleniumVersion=4.25.0 -testcontainersVersion=1.20.2 +testcontainersVersion=1.20.4 # This prevents the Grails Gradle Plugin from unnecessarily excluding slf4j-simple in the generated POMs # https://github.com/grails/grails-gradle-plugin/issues/222 diff --git a/spock-container-test-app/src/integration-test/groovy/org/demo/spock/RootPageSpec.groovy b/spock-container-test-app/src/integration-test/groovy/org/demo/spock/RootPageSpec.groovy index 2ef4364..3156dfd 100644 --- a/spock-container-test-app/src/integration-test/groovy/org/demo/spock/RootPageSpec.groovy +++ b/spock-container-test-app/src/integration-test/groovy/org/demo/spock/RootPageSpec.groovy @@ -1,5 +1,10 @@ package org.demo.spock +import geb.report.CompositeReporter +import geb.report.PageSourceReporter +import geb.report.Reporter +import geb.report.ScreenshotReporter +import grails.plugin.geb.ContainerGebConfiguration import grails.plugin.geb.ContainerGebSpec import grails.testing.mixin.integration.Integration @@ -8,13 +13,21 @@ import grails.testing.mixin.integration.Integration * for more instructions on how to write functional tests with Grails and Geb. */ @Integration +@ContainerGebConfiguration(reporting = true) class RootPageSpec extends ContainerGebSpec { + @Override + Reporter createReporter() { + // Override the default reporter to demonstrate how this can be customized + new CompositeReporter(new ScreenshotReporter(), new PageSourceReporter()) + } + void 'should display the correct title on the home page'() { when: 'visiting the home page' go '/' then: 'the page title is correct' + report('root page report') title == 'Welcome to Grails' } } diff --git a/src/testFixtures/groovy/grails/plugin/geb/ContainerAwareDownloadSupport.groovy b/src/testFixtures/groovy/grails/plugin/geb/ContainerAwareDownloadSupport.groovy index 407ddbe..26b59c6 100644 --- a/src/testFixtures/groovy/grails/plugin/geb/ContainerAwareDownloadSupport.groovy +++ b/src/testFixtures/groovy/grails/plugin/geb/ContainerAwareDownloadSupport.groovy @@ -15,13 +15,10 @@ */ package grails.plugin.geb -import geb.Browser -import geb.download.DefaultDownloadSupport import geb.download.DownloadSupport import groovy.transform.CompileStatic import groovy.transform.SelfType - -import java.util.regex.Pattern +import spock.lang.Shared /** * A custom implementation of {@link geb.download.DownloadSupport} for enabling the use of its {@code download*()} methods @@ -35,41 +32,13 @@ import java.util.regex.Pattern * setups, ensuring the host network context is used for download requests.

* * @author Mattias Reichel - * @since 5.0 + * @since 4.1 */ @CompileStatic @SelfType(ContainerGebSpec) trait ContainerAwareDownloadSupport implements DownloadSupport { @Delegate - private final DownloadSupport downloadSupport = new LocalhostDownloadSupport(browser, this) - - abstract Browser getBrowser() - - abstract String getHostNameFromHost() - - private static class LocalhostDownloadSupport extends DefaultDownloadSupport { - - private final static Pattern urlPattern = ~/(https?:\/\/)([^\/:]+)(:\d+\/.*)/ - - private final ContainerAwareDownloadSupport parent - private final Browser browser - - LocalhostDownloadSupport(Browser browser, ContainerAwareDownloadSupport parent) { - super(browser) - this.browser = browser - this.parent = parent - } - - @Override - HttpURLConnection download(Map options) { - return super.download([*: options, base: resolveBase(options)]) - } - - private String resolveBase(Map options) { - return options.base ?: browser.driver.currentUrl.replaceAll(urlPattern) { match, proto, host, rest -> - "${proto}${parent.hostNameFromHost}${rest}" - } - } - } + @Shared + static DownloadSupport downloadSupport } \ No newline at end of file diff --git a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebConfiguration.groovy b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebConfiguration.groovy index 83577e6..a24ce27 100644 --- a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebConfiguration.groovy +++ b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebConfiguration.groovy @@ -15,6 +15,8 @@ */ package grails.plugin.geb +import org.testcontainers.containers.GenericContainer + import java.lang.annotation.ElementType import java.lang.annotation.Retention import java.lang.annotation.RetentionPolicy @@ -24,13 +26,13 @@ import java.lang.annotation.Target * Can be used to configure the protocol and hostname that the container's browser will use * * @author James Daugherty - * @since 5.0 + * @since 4.1 */ @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @interface ContainerGebConfiguration { - static final String DEFAULT_HOSTNAME_FROM_CONTAINER = 'host.testcontainers.internal' + static final String DEFAULT_HOSTNAME_FROM_CONTAINER = GenericContainer.INTERNAL_HOST_HOSTNAME static final String DEFAULT_PROTOCOL = 'http' /** @@ -45,4 +47,9 @@ import java.lang.annotation.Target *

This is useful when the server under test needs to be accessed with a certain hostname. */ String hostName() default DEFAULT_HOSTNAME_FROM_CONTAINER + + /** + * Whether reporting should be enabled for this test. Add a `GebConfig.groovy` to customize the reporter configuration. + */ + boolean reporting() default false } \ No newline at end of file diff --git a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebRecordingExtension.groovy b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebRecordingExtension.groovy deleted file mode 100644 index 931c694..0000000 --- a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebRecordingExtension.groovy +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright 2024 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 - * - * https://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 grails.plugin.geb - -import grails.testing.mixin.integration.Integration -import groovy.transform.CompileStatic -import groovy.transform.TailRecursive -import groovy.util.logging.Slf4j -import org.spockframework.runtime.extension.IGlobalExtension -import org.spockframework.runtime.model.SpecInfo - -import java.time.LocalDateTime - -/** - * A Spock Extension that manages the Testcontainers lifecycle for a {@link grails.plugin.geb.ContainerGebSpec} - * - * @author James Daugherty - * @since 5.0 - */ -@Slf4j -@CompileStatic -class ContainerGebRecordingExtension implements IGlobalExtension { - - WebDriverContainerHolder holder - - @Override - void start() { - holder = new WebDriverContainerHolder(new RecordingSettings()) - addShutdownHook { - holder.stop() - } - } - - @Override - void stop() { - holder.stop() - } - - @Override - void visitSpec(SpecInfo spec) { - if (isContainerGebSpec(spec) && validateContainerGebSpec(spec)) { - ContainerGebTestListener listener = new ContainerGebTestListener(holder, spec, LocalDateTime.now()) - spec.addSetupInterceptor { - holder.reinitialize(it) - (it.sharedInstance as ContainerGebSpec).webDriverContainer = holder.currentContainer - } - - spec.addListener(listener) - } - } - - @TailRecursive - private boolean isContainerGebSpec(SpecInfo spec) { - if (spec != null) { - if (spec.filename.startsWith('ContainerGebSpec.')) { - return true - } - return isContainerGebSpec(spec.superSpec) - } - return false - } - - private static boolean validateContainerGebSpec(SpecInfo specInfo) { - if (!specInfo.annotations.find { it.annotationType() == Integration }) { - throw new IllegalArgumentException('ContainerGebSpec classes must be annotated with @Integration') - } - - return true - } -} - diff --git a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebSpec.groovy b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebSpec.groovy index 0b3e8a0..94647f6 100644 --- a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebSpec.groovy +++ b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebSpec.groovy @@ -15,8 +15,10 @@ */ package grails.plugin.geb +import geb.report.CompositeReporter +import geb.report.PageSourceReporter +import geb.report.Reporter import geb.test.GebTestManager -import geb.test.ManagedGebTest import geb.transform.DynamicallyDispatchesToBrowser import org.testcontainers.containers.BrowserWebDriverContainer import spock.lang.Shared @@ -41,24 +43,15 @@ import spock.lang.Specification * @author Søren Berg Glasius * @author Mattias Reichel * @author James Daugherty - * @since 5.0 + * @since 4.1 */ @DynamicallyDispatchesToBrowser -abstract class ContainerGebSpec extends Specification implements ManagedGebTest, ContainerAwareDownloadSupport { - - private static final String DEFAULT_HOSTNAME_FROM_HOST = 'localhost' - boolean reportingEnabled = false - - @Override - @Delegate(includes = ['getBrowser', 'report']) - GebTestManager getTestManager() { - return isReportingEnabled() ? - GebTestManagerProvider.getReportingInstance() : - GebTestManagerProvider.getInstance() - } +abstract class ContainerGebSpec extends Specification implements ContainerAwareDownloadSupport { @Shared - BrowserWebDriverContainer webDriverContainer + @Delegate(includes = ['getBrowser', 'report']) + @SuppressWarnings('unused') + static GebTestManager testManager /** * Get access to container running the web-driver, for convenience to execInContainer, copyFileToContainer etc. @@ -68,25 +61,13 @@ abstract class ContainerGebSpec extends Specification implements ManagedGebTest, * @see org.testcontainers.containers.ContainerState#copyFileFromContainer(java.lang.String, java.lang.String) * @see org.testcontainers.containers.ContainerState */ - BrowserWebDriverContainer getContainer() { - return webDriverContainer - } + @Shared + static BrowserWebDriverContainer container /** - * Returns the hostname that the server under test is available on from the host. - *

This is useful when using any of the {@code download*()} methods as they will connect from the host, - * and not from within the container. - *

Defaults to {@code localhost}. If the value returned by {@code webDriverContainer.getHost()} - * is different from the default, this method will return the same value same as {@code webDriverContainer.getHost()}. - * - * @return the hostname for accessing the server under test from the host + * The reporter that Geb should use when reporting is enabled. */ - @Override - String getHostNameFromHost() { - return hostNameChanged ? webDriverContainer.host : DEFAULT_HOSTNAME_FROM_HOST - } - - private boolean isHostNameChanged() { - return webDriverContainer.host != ContainerGebConfiguration.DEFAULT_HOSTNAME_FROM_CONTAINER + Reporter createReporter() { + new CompositeReporter(new PageSourceReporter()) } } \ No newline at end of file diff --git a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestDescription.groovy b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestDescription.groovy index d1a5cc7..ab72c14 100644 --- a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestDescription.groovy +++ b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestDescription.groovy @@ -19,15 +19,11 @@ import groovy.transform.CompileStatic import org.spockframework.runtime.model.IterationInfo import org.testcontainers.lifecycle.TestDescription -import java.time.LocalDateTime -import java.time.format.DateTimeFormatter - /** * Implements {@link org.testcontainers.lifecycle.TestDescription} to customize recording names. * - * @see org.testcontainers.lifecycle.TestDescription * @author James Daugherty - * @since 5.0 + * @since 4.1 */ @CompileStatic class ContainerGebTestDescription implements TestDescription { @@ -35,9 +31,9 @@ class ContainerGebTestDescription implements TestDescription { String testId String filesystemFriendlyName - ContainerGebTestDescription(IterationInfo testInfo, LocalDateTime runDate) { + ContainerGebTestDescription(IterationInfo testInfo) { testId = testInfo.displayName String safeName = testId.replaceAll('\\W+', '_') - filesystemFriendlyName = "${DateTimeFormatter.ofPattern('yyyyMMdd_HHmmss').format(runDate)}_${safeName}" + filesystemFriendlyName = safeName } } diff --git a/src/testFixtures/groovy/grails/plugin/geb/GebOnFailureReporter.groovy b/src/testFixtures/groovy/grails/plugin/geb/GebOnFailureReporter.groovy new file mode 100644 index 0000000..9a18947 --- /dev/null +++ b/src/testFixtures/groovy/grails/plugin/geb/GebOnFailureReporter.groovy @@ -0,0 +1,46 @@ +/* + * Copyright 2016 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 grails.plugin.geb + +import groovy.transform.CompileStatic +import org.opentest4j.IncompleteExecutionException +import org.spockframework.runtime.extension.IMethodInterceptor +import org.spockframework.runtime.extension.IMethodInvocation + +/** + * This class is a direct clone of {@link geb.spock.OnFailureReporter OnFailureReporter}, except it works for the + * {@link grails.plugin.geb.ContainerGebSpec ContainerGebSpec}. + */ +@CompileStatic +class GebOnFailureReporter implements IMethodInterceptor { + void intercept(IMethodInvocation invocation) throws Throwable { + try { + invocation.proceed() + } catch (IncompleteExecutionException notACauseForReporting) { + throw notACauseForReporting + } catch (Throwable throwable) { + ContainerGebSpec spec = invocation.instance as ContainerGebSpec + if (spec.testManager.reportingEnabled) { + try { + spec.testManager.reportFailure() + } catch (ignored) { + //ignore + } + } + throw throwable + } + } +} diff --git a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestListener.groovy b/src/testFixtures/groovy/grails/plugin/geb/GebRecordingTestListener.groovy similarity index 81% rename from src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestListener.groovy rename to src/testFixtures/groovy/grails/plugin/geb/GebRecordingTestListener.groovy index 5da846a..a4bbf05 100644 --- a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestListener.groovy +++ b/src/testFixtures/groovy/grails/plugin/geb/GebRecordingTestListener.groovy @@ -30,26 +30,22 @@ import java.time.LocalDateTime * @see org.testcontainers.containers.BrowserWebDriverContainer#afterTest * * @author James Daugherty - * @since 5.0 + * @since 4.1 */ @CompileStatic -class ContainerGebTestListener extends AbstractRunListener { +class GebRecordingTestListener extends AbstractRunListener { WebDriverContainerHolder containerHolder ErrorInfo errorInfo - SpecInfo spec - LocalDateTime runDate - ContainerGebTestListener(WebDriverContainerHolder containerHolder, SpecInfo spec, LocalDateTime runDate) { - this.spec = spec - this.runDate = runDate + GebRecordingTestListener(WebDriverContainerHolder containerHolder) { this.containerHolder = containerHolder } @Override void afterIteration(IterationInfo iteration) { containerHolder.currentContainer.afterTest( - new ContainerGebTestDescription(iteration, runDate), + new ContainerGebTestDescription(iteration), Optional.ofNullable(errorInfo?.exception) ) errorInfo = null diff --git a/src/testFixtures/groovy/grails/plugin/geb/GebTestManagerProvider.groovy b/src/testFixtures/groovy/grails/plugin/geb/GebTestManagerProvider.groovy deleted file mode 100644 index 454daa1..0000000 --- a/src/testFixtures/groovy/grails/plugin/geb/GebTestManagerProvider.groovy +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright 2024 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 - * - * https://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 grails.plugin.geb - -import geb.spock.SpockGebTestManagerBuilder -import geb.test.GebTestManager -import groovy.transform.CompileStatic - -/** - * A provider class for managing instances of {@link GebTestManager}. - * This class uses the Initialization-on-Demand Holder Idiom to ensure thread-safe, - * lazy initialization of {@link GebTestManager} instances for use in Geb tests. - * - *

The class provides two static instances:

- * - * - *

This class cannot be instantiated, as it is designed to be used as a - * utility provider.

- * - * @see GebTestManager - * @see SpockGebTestManagerBuilder - * - * @author Mattias Reichel - * @author Søren Berg Glasius - * @since 5.0 - */ -@CompileStatic -class GebTestManagerProvider { - - /** - * A lazily and thread-safe-initialized instance of {@link GebTestManager}. - * Built using {@link SpockGebTestManagerBuilder}. - */ - @Lazy - static volatile GebTestManager instance = { - new SpockGebTestManagerBuilder().build() - }() - - /** - * A lazily and thread-safe-initialized instance of {@link GebTestManager} - * with reporting enabled. Built using {@link SpockGebTestManagerBuilder}. - */ - @Lazy - static volatile GebTestManager reportingInstance = { - new SpockGebTestManagerBuilder() - .withReportingEnabled(true) - .build() - }() - - /** - * Private constructor to prevent instantiation of this utility class. - */ - private GebTestManagerProvider() {} - -} \ No newline at end of file diff --git a/src/testFixtures/groovy/grails/plugin/geb/GrailsContainerGebExtension.groovy b/src/testFixtures/groovy/grails/plugin/geb/GrailsContainerGebExtension.groovy new file mode 100644 index 0000000..3a60fe1 --- /dev/null +++ b/src/testFixtures/groovy/grails/plugin/geb/GrailsContainerGebExtension.groovy @@ -0,0 +1,146 @@ +/* + * Copyright 2024 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 + * + * https://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 grails.plugin.geb + +import grails.testing.mixin.integration.Integration +import groovy.transform.CompileStatic +import groovy.transform.TailRecursive +import groovy.util.logging.Slf4j +import org.spockframework.runtime.extension.IGlobalExtension +import org.spockframework.runtime.model.MethodInfo +import org.spockframework.runtime.model.SpecInfo +import org.spockframework.runtime.model.parallel.ExclusiveResource +import org.spockframework.runtime.model.parallel.ResourceAccessMode + +import java.time.LocalDateTime + +/** + * A Spock Extension that manages the Testcontainers lifecycle for a {@link grails.plugin.geb.ContainerGebSpec} + * + *

ContainerGebSpec cannot be a {@link geb.test.ManagedGebTest ManagedGebTest} because it would cause the test manager + * to be initialized out of sequence of the container management. Instead, we initialize the same interceptors + * as the {@link geb.spock.GebExtension GebExtension} does. + * + * @author James Daugherty + * @since 4.1 + */ +@Slf4j +@CompileStatic +class GrailsContainerGebExtension implements IGlobalExtension { + + ExclusiveResource exclusiveResource + WebDriverContainerHolder holder + + @Override + void start() { + exclusiveResource = new ExclusiveResource( + ContainerGebSpec.name, + ResourceAccessMode.READ_WRITE + ) + holder = new WebDriverContainerHolder( + new GrailsGebSettings(LocalDateTime.now()) + ) + addShutdownHook { + holder.stop() + } + } + + @Override + void stop() { + holder.stop() + } + + @Override + void visitSpec(SpecInfo spec) { + if (isContainerGebSpec(spec) && validateContainerGebSpec(spec)) { + // Do not allow parallel execution since there's only 1 set of containers in testcontainers + spec.addExclusiveResource(exclusiveResource) + + // Always initialize the container requirements first so the GebTestManager can properly configure the browser + spec.addSharedInitializerInterceptor { invocation -> + holder.reinitialize(invocation) + + ContainerGebSpec gebSpec = invocation.sharedInstance as ContainerGebSpec + gebSpec.container = holder.currentContainer + gebSpec.testManager = holder.testManager + gebSpec.downloadSupport = new LocalhostDownloadSupport( + holder.currentBrowser, + holder.hostNameFromHost + ) + + // code below here is from the geb.spock.GebExtension since there can only be 1 shared initializer per extension + holder.testManager.beforeTestClass(invocation.spec.reflection) + invocation.proceed() + } + + spec.addSetupInterceptor { + // Grails will be initialized by this point, so setup the browser url correctly + holder.setupBrowserUrl(it) + } + + spec.addInterceptor { invocation -> + try { + invocation.proceed() + } finally { + holder.testManager.afterTestClass() + } + } + + spec.allFeatures*.addIterationInterceptor { invocation -> + holder.testManager.beforeTest(invocation.instance.getClass(), invocation.iteration.displayName) + try { + invocation.proceed() + } finally { + holder.testManager.afterTest() + } + } + + addGebExtensionOnFailureReporter(spec) + + GebRecordingTestListener recordingListener = new GebRecordingTestListener( + holder + ) + spec.addListener(recordingListener) + } + } + + @TailRecursive + private boolean isContainerGebSpec(SpecInfo spec) { + if (spec != null) { + if (spec.filename.startsWith("${ContainerGebSpec.simpleName}." as String)) { + return true + } + return isContainerGebSpec(spec.superSpec) + } + return false + } + + private static boolean validateContainerGebSpec(SpecInfo specInfo) { + if (!specInfo.annotations.find { it.annotationType() == Integration }) { + throw new IllegalArgumentException('ContainerGebSpec classes must be annotated with @Integration') + } + + return true + } + + private static void addGebExtensionOnFailureReporter(SpecInfo spec) { + List methods = spec.allFeatures*.featureMethod + spec.allFixtureMethods.toList() + methods.each { MethodInfo method -> + method.addInterceptor(new GebOnFailureReporter()) + } + } +} + diff --git a/src/testFixtures/groovy/grails/plugin/geb/RecordingSettings.groovy b/src/testFixtures/groovy/grails/plugin/geb/GrailsGebSettings.groovy similarity index 61% rename from src/testFixtures/groovy/grails/plugin/geb/RecordingSettings.groovy rename to src/testFixtures/groovy/grails/plugin/geb/GrailsGebSettings.groovy index da46322..264126d 100644 --- a/src/testFixtures/groovy/grails/plugin/geb/RecordingSettings.groovy +++ b/src/testFixtures/groovy/grails/plugin/geb/GrailsGebSettings.groovy @@ -19,34 +19,41 @@ import groovy.transform.CompileStatic import groovy.transform.Memoized import groovy.util.logging.Slf4j +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + import static org.testcontainers.containers.BrowserWebDriverContainer.VncRecordingMode import static org.testcontainers.containers.VncRecordingContainer.VncRecordingFormat /** - * Handles parsing various recording configuration used by {@link grails.plugin.geb.ContainerGebRecordingExtension} + * Handles parsing various recording configuration used by {@link GrailsContainerGebExtension} * * @author James Daugherty - * @since 5.0 + * @since 4.1 */ @Slf4j @CompileStatic -class RecordingSettings { +class GrailsGebSettings { private static VncRecordingMode DEFAULT_RECORDING_MODE = VncRecordingMode.SKIP private static VncRecordingFormat DEFAULT_RECORDING_FORMAT = VncRecordingFormat.MP4 String recordingDirectoryName - VncRecordingMode recordingMode - VncRecordingFormat recordingFormat + String reportingDirectoryName + VncRecordingMode recordingMode + VncRecordingFormat recordingFormat + LocalDateTime startTime - RecordingSettings() { - recordingDirectoryName = System.getProperty('grails.geb.recording.directory', 'build/recordings') + GrailsGebSettings(LocalDateTime startTime) { + recordingDirectoryName = System.getProperty('grails.geb.recording.directory', 'build/gebContainer/recordings') + reportingDirectoryName = System.getProperty('grails.geb.reporting.directory', 'build/gebContainer/reports') recordingMode = VncRecordingMode.valueOf( System.getProperty('grails.geb.recording.mode', DEFAULT_RECORDING_MODE.name()) ) recordingFormat = VncRecordingFormat.valueOf( System.getProperty('grails.geb.recording.format', DEFAULT_RECORDING_FORMAT.name()) ) + this.startTime = startTime } boolean isRecordingEnabled() { @@ -59,7 +66,7 @@ class RecordingSettings { return null } - File recordingDirectory = new File(recordingDirectoryName) + File recordingDirectory = new File("${recordingDirectoryName}${File.separator}${DateTimeFormatter.ofPattern('yyyyMMdd_HHmmss').format(startTime)}") if (!recordingDirectory.exists()) { log.info('Could not find `{}` Directory for recording. Creating...', recordingDirectoryName) recordingDirectory.mkdirs() @@ -69,4 +76,21 @@ class RecordingSettings { return recordingDirectory } + + @Memoized + File getReportingDirectory() { + if (!reportingDirectoryName) { + return null + } + + File reportingDirectory = new File("${reportingDirectoryName}${File.separator}${DateTimeFormatter.ofPattern('yyyyMMdd_HHmmss').format(startTime)}") + if (!reportingDirectory.exists()) { + log.info('Could not find `{}` Directory for reporting. Creating...', reportingDirectoryName) + reportingDirectory.mkdirs() + } else if (!reportingDirectory.isDirectory()) { + throw new IllegalStateException("Configured reporting directory '${reportingDirectory}' is expected to be a directory, but found file instead.") + } + + return reportingDirectory + } } diff --git a/src/testFixtures/groovy/grails/plugin/geb/LocalhostDownloadSupport.groovy b/src/testFixtures/groovy/grails/plugin/geb/LocalhostDownloadSupport.groovy new file mode 100644 index 0000000..a1a9ade --- /dev/null +++ b/src/testFixtures/groovy/grails/plugin/geb/LocalhostDownloadSupport.groovy @@ -0,0 +1,54 @@ +/* + * Copyright 2024 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 + * + * https://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 grails.plugin.geb + +import geb.Browser +import geb.download.DefaultDownloadSupport +import groovy.transform.CompileStatic +import groovy.transform.PackageScope + +import java.util.regex.Pattern + +/** + * @author Mattias Reichel + * @since 4.1 + */ +@PackageScope +@CompileStatic +class LocalhostDownloadSupport extends DefaultDownloadSupport { + + private final static Pattern urlPattern = ~/(https?:\/\/)([^\/:]+)(:\d+\/.*)/ + + private final String hostNameFromHost + private final Browser browser + + LocalhostDownloadSupport(Browser browser, String hostNameFromHost) { + super(browser) + this.browser = browser + this.hostNameFromHost = hostNameFromHost + } + + @Override + HttpURLConnection download(Map options) { + return super.download([*: options, base: resolveBase(options)]) + } + + private String resolveBase(Map options) { + return options.base ?: browser.driver.currentUrl.replaceAll(urlPattern) { match, proto, host, rest -> + "${proto}${hostNameFromHost}${rest}" + } + } +} diff --git a/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy b/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy index 60a8f67..6ee8a01 100644 --- a/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy +++ b/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy @@ -17,8 +17,13 @@ package grails.plugin.geb import com.github.dockerjava.api.model.ContainerNetwork import geb.Browser +import geb.Configuration +import geb.spock.SpockGebTestManagerBuilder +import geb.test.GebTestManager import groovy.transform.CompileStatic import groovy.transform.EqualsAndHashCode +import groovy.transform.PackageScope +import groovy.util.logging.Slf4j import org.openqa.selenium.WebDriver import org.openqa.selenium.chrome.ChromeOptions import org.openqa.selenium.remote.RemoteWebDriver @@ -29,6 +34,7 @@ import org.testcontainers.containers.BrowserWebDriverContainer import org.testcontainers.containers.PortForwardingContainer import java.time.Duration +import java.util.function.Supplier /** * Responsible for initializing a {@link org.testcontainers.containers.BrowserWebDriverContainer BrowserWebDriverContainer} @@ -36,17 +42,22 @@ import java.time.Duration * reuse the same container if the configuration matches the current container. * * @author James Daugherty - * @since 5.0 + * @since 4.1 */ +@Slf4j @CompileStatic class WebDriverContainerHolder { - RecordingSettings recordingSettings - WebDriverContainerConfiguration configuration + private static final String DEFAULT_HOSTNAME_FROM_HOST = 'localhost' + + GrailsGebSettings grailsGebSettings + GebTestManager testManager + Browser currentBrowser BrowserWebDriverContainer currentContainer + WebDriverContainerConfiguration currentConfiguration - WebDriverContainerHolder(RecordingSettings recordingSettings) { - this.recordingSettings = recordingSettings + WebDriverContainerHolder(GrailsGebSettings grailsGebSettings) { + this.grailsGebSettings = grailsGebSettings } boolean isInitialized() { @@ -56,24 +67,26 @@ class WebDriverContainerHolder { void stop() { currentContainer?.stop() currentContainer = null - configuration = null + currentBrowser = null + testManager = null + currentConfiguration = null } boolean matchesCurrentContainerConfiguration(WebDriverContainerConfiguration specConfiguration) { - specConfiguration == configuration + specConfiguration == currentConfiguration } private static int getPort(IMethodInvocation invocation) { try { return (int) invocation.instance.metaClass.getProperty(invocation.instance, 'serverPort') - } catch (Exception ignore) { + } catch (ignored) { throw new IllegalStateException('Test class must be annotated with @Integration for serverPort to be injected') } } + @PackageScope boolean reinitialize(IMethodInvocation invocation) { WebDriverContainerConfiguration specConfiguration = new WebDriverContainerConfiguration( - getPort(invocation), invocation.getSpec() ) if (matchesCurrentContainerConfiguration(specConfiguration)) { @@ -84,68 +97,119 @@ class WebDriverContainerHolder { stop() } - configuration = specConfiguration + currentConfiguration = specConfiguration currentContainer = new BrowserWebDriverContainer() - if (recordingSettings.recordingEnabled) { + if (grailsGebSettings.recordingEnabled) { currentContainer = currentContainer.withRecordingMode( - recordingSettings.recordingMode, - recordingSettings.recordingDirectory, - recordingSettings.recordingFormat + grailsGebSettings.recordingMode, + grailsGebSettings.recordingDirectory, + grailsGebSettings.recordingFormat ) } - Testcontainers.exposeHostPorts(configuration.port) currentContainer.tap { - addExposedPort(configuration.port) withAccessToHost(true) start() } if (hostnameChanged) { - currentContainer.execInContainer('/bin/sh', '-c', "echo '$hostIp\t${configuration.hostName}' | sudo tee -a /etc/hosts") + currentContainer.execInContainer('/bin/sh', '-c', "echo '$hostIp\t${currentConfiguration.hostName}' | sudo tee -a /etc/hosts") + } + + ConfigObject configObject = new ConfigObject() + if (currentConfiguration.reporting) { + configObject.reportsDir = grailsGebSettings.reportingDirectory + configObject.reporter = (invocation.sharedInstance as ContainerGebSpec).createReporter() } + currentBrowser = new Browser(new Configuration(configObject, new Properties(), null, null)) + WebDriver driver = new RemoteWebDriver(currentContainer.seleniumAddress, new ChromeOptions()) driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(30)) - // Update the browser to use this container - Browser browser = (invocation.sharedInstance as ContainerGebSpec).browser - browser.driver = driver - browser.baseUrl = "${configuration.protocol}://${configuration.hostName}:${configuration.port}" + currentBrowser.driver = driver + + // There's a bit of a chicken and egg problem here: the container & browser are initialized when + // the static/shared fields are initialized, which is before the grails server has started so the + // real url cannot be set (it will be checked as part of the geb test manager startup in reporting mode) + // set the url to localhost, which the selenium server should respond to (albeit with an error that will be ignored) + + currentBrowser.baseUrl = 'http://localhost' + + testManager = createTestManager() return true } + void setupBrowserUrl(IMethodInvocation invocation) { + if (!currentBrowser) { + return + } + int port = getPort(invocation) + Testcontainers.exposeHostPorts(port) + + currentBrowser.baseUrl = "${currentConfiguration.protocol}://${currentConfiguration.hostName}:${port}" + } + + private GebTestManager createTestManager() { + new SpockGebTestManagerBuilder() + .withReportingEnabled(currentConfiguration.reporting) + .withBrowserCreator(new Supplier() { + @Override + Browser get() { + currentBrowser + } + }) + .build() + } + private boolean getHostnameChanged() { - configuration.hostName != ContainerGebConfiguration.DEFAULT_HOSTNAME_FROM_CONTAINER + currentConfiguration.hostName != ContainerGebConfiguration.DEFAULT_HOSTNAME_FROM_CONTAINER } private static String getHostIp() { try { - PortForwardingContainer.getDeclaredMethod("getNetwork").with { + PortForwardingContainer.getDeclaredMethod('getNetwork').with { accessible = true Optional network = invoke(PortForwardingContainer.INSTANCE) as Optional return network.get().ipAddress } } catch (Exception e) { - throw new RuntimeException("Could not access network from PortForwardingContainer", e) + throw new RuntimeException('Could not access network from PortForwardingContainer', e) } } + /** + * Returns the hostname that the server under test is available on from the host. + *

This is useful when using any of the {@code download*()} methods as they will connect from the host, + * and not from within the container. + *

Defaults to {@code localhost}. If the value returned by {@code webDriverContainer.getHost()} + * is different from the default, this method will return the same value same as {@code webDriverContainer.getHost()}. + * + * @return the hostname for accessing the server under test from the host + */ + String getHostNameFromHost() { + return hostNameChanged ? currentContainer.host : DEFAULT_HOSTNAME_FROM_HOST + } + + private boolean isHostNameChanged() { + return currentContainer.host != ContainerGebConfiguration.DEFAULT_HOSTNAME_FROM_CONTAINER + } + @CompileStatic @EqualsAndHashCode private static class WebDriverContainerConfiguration { String protocol String hostName - int port + boolean reporting - WebDriverContainerConfiguration(int port, SpecInfo spec) { + WebDriverContainerConfiguration(SpecInfo spec) { ContainerGebConfiguration configuration = spec.annotations.find { it.annotationType() == ContainerGebConfiguration } as ContainerGebConfiguration protocol = configuration?.protocol() ?: ContainerGebConfiguration.DEFAULT_PROTOCOL hostName = configuration?.hostName() ?: ContainerGebConfiguration.DEFAULT_HOSTNAME_FROM_CONTAINER - this.port = port + reporting = configuration?.reporting() ?: false } } } diff --git a/src/testFixtures/resources/META-INF/services/org.spockframework.runtime.extension.IGlobalExtension b/src/testFixtures/resources/META-INF/services/org.spockframework.runtime.extension.IGlobalExtension index 893fab6..cd6ea72 100644 --- a/src/testFixtures/resources/META-INF/services/org.spockframework.runtime.extension.IGlobalExtension +++ b/src/testFixtures/resources/META-INF/services/org.spockframework.runtime.extension.IGlobalExtension @@ -1 +1 @@ -grails.plugin.geb.ContainerGebRecordingExtension \ No newline at end of file +grails.plugin.geb.GrailsContainerGebExtension \ No newline at end of file