From 5cf44146ec9e493c81831437973fe9b7caae1d65 Mon Sep 17 00:00:00 2001
From: James Daugherty
Date: Sat, 23 Nov 2024 10:12:18 -0500
Subject: [PATCH 01/18] feature #79 - add recording support
---
README.md | 25 ++++++++++
.../geb/ContainerGebConfiguration.groovy | 44 +++++++++++++++++
.../geb/ContainerGebRecordingExtension.groovy | 47 +++++++++++++++++++
.../grails/plugin/geb/ContainerGebSpec.groovy | 14 ++----
.../geb/ContainerGebTestDescription.groovy | 19 ++++++++
.../geb/ContainerGebTestListener.groovy | 32 +++++++++++++
...amework.runtime.extension.IGlobalExtension | 1 +
7 files changed, 172 insertions(+), 10 deletions(-)
create mode 100644 src/testFixtures/groovy/grails/plugin/geb/ContainerGebConfiguration.groovy
create mode 100644 src/testFixtures/groovy/grails/plugin/geb/ContainerGebRecordingExtension.groovy
create mode 100644 src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestDescription.groovy
create mode 100644 src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestListener.groovy
create mode 100644 src/testFixtures/resources/META-INF/services/org.spockframework.runtime.extension.IGlobalExtension
diff --git a/README.md b/README.md
index 5db4161..df53bad 100644
--- a/README.md
+++ b/README.md
@@ -53,6 +53,31 @@ 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.
+#### Recording
+By default, all failed tests will generate a video recording of the test execution. These recordings are saved to a `recordings` directory under the project's `build` directory.
+
+The following system properties can be change to configure the recording behavior:
+* `grails.geb.recording.enabled`
+ * purpose: toggle for recording
+ * possible values: `true` or `false`
+
+
+* `grails.geb.recording.directory`
+ * purpose: the directory to save the recordings relative to the project directory
+ * defaults to `build/recordings`
+
+
+* `grails.geb.recording.mode`
+ * purpose: which tests to record via the enum `VncRecordingMode`
+ * possible values: `RECORD_ALL` or `RECORD_FAILING`
+ * defaults to `RECORD_FAILING`
+
+
+* `grails.geb.recording.format`
+ * purpose: sets the format of the recording
+ * possible values are `FLV` or `MP4`
+ * defaults to `MP4`
+
### GebSpec
If you choose to extend `GebSpec`, you will need to have a [Selenium WebDriver](https://www.selenium.dev/documentation/webdriver/browsers/) installed that matches a browser you have installed on your system.
diff --git a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebConfiguration.groovy b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebConfiguration.groovy
new file mode 100644
index 0000000..5cc1581
--- /dev/null
+++ b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebConfiguration.groovy
@@ -0,0 +1,44 @@
+package grails.plugin.geb
+
+import groovy.transform.CompileStatic
+import groovy.transform.Memoized
+import groovy.util.logging.Slf4j
+import org.testcontainers.containers.BrowserWebDriverContainer
+import org.testcontainers.containers.VncRecordingContainer
+
+@Slf4j
+@CompileStatic
+class ContainerGebConfiguration {
+ String recordingDirectoryName
+
+ boolean recording
+
+ BrowserWebDriverContainer.VncRecordingMode recordingMode
+
+ VncRecordingContainer.VncRecordingFormat recordingFormat
+
+ ContainerGebConfiguration() {
+ recording = Boolean.parseBoolean(System.getProperty('grails.geb.recording.enabled', true as String))
+ recordingDirectoryName = System.getProperty('grails.geb.recording.directory', 'build/recordings')
+ recordingMode = BrowserWebDriverContainer.VncRecordingMode.valueOf(System.getProperty('grails.geb.recording.mode', BrowserWebDriverContainer.VncRecordingMode.RECORD_FAILING.name()))
+ recordingFormat = VncRecordingContainer.VncRecordingFormat.valueOf(System.getProperty('grails.geb.recording.format', VncRecordingContainer.VncRecordingFormat.MP4.name()))
+ }
+
+ @Memoized
+ File getRecordingDirectory() {
+ if(!recording) {
+ return null
+ }
+
+ File recordingDirectory = new File(recordingDirectoryName)
+ if(!recordingDirectory.exists()) {
+ log.info("Could not find `${recordingDirectoryName}` directory for recording. Creating...")
+ recordingDirectory.mkdir()
+ }
+ else if(!recordingDirectory.isDirectory()) {
+ throw new IllegalStateException("Recording Directory name expected to be `${recordingDirectoryName}, but found file instead.")
+ }
+
+ recordingDirectory
+ }
+}
diff --git a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebRecordingExtension.groovy b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebRecordingExtension.groovy
new file mode 100644
index 0000000..2c60b9d
--- /dev/null
+++ b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebRecordingExtension.groovy
@@ -0,0 +1,47 @@
+package grails.plugin.geb
+
+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
+
+@Slf4j
+class ContainerGebRecordingExtension implements IGlobalExtension {
+ ContainerGebConfiguration configuration
+
+ @Override
+ void start() {
+ configuration = new ContainerGebConfiguration()
+ }
+
+ @Override
+ void visitSpec(SpecInfo spec) {
+ if (isContainerizedGebSpec(spec)) {
+ ContainerGebTestListener listener = new ContainerGebTestListener(spec, LocalDateTime.now())
+ // TODO: We should initialize the web driver container once for all geb tests so we don't have to spin it up & down.
+ spec.addSetupInterceptor {
+ ContainerGebSpec gebSpec = it.instance as ContainerGebSpec
+ gebSpec.initialize()
+ if(configuration.recording) {
+ listener.webDriverContainer = gebSpec.webDriverContainer.withRecordingMode(configuration.recordingMode, configuration.recordingDirectory, configuration.recordingFormat)
+ }
+ }
+
+ spec.addListener(listener)
+ }
+ }
+
+ @TailRecursive
+ boolean isContainerizedGebSpec(SpecInfo spec) {
+ if(spec != null) {
+ if(spec.filename.startsWith('ContainerGebSpec.')) {
+ return true
+ }
+
+ return isContainerizedGebSpec(spec.superSpec)
+ }
+ return false
+ }
+}
diff --git a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebSpec.groovy b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebSpec.groovy
index 82a927d..b0ddd54 100644
--- a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebSpec.groovy
+++ b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebSpec.groovy
@@ -72,6 +72,10 @@ abstract class ContainerGebSpec extends Specification implements ManagedGebTest,
@PackageScope
void initialize() {
+ if(webDriverContainer) {
+ return
+ }
+
webDriverContainer = new BrowserWebDriverContainer()
Testcontainers.exposeHostPorts(port)
webDriverContainer.tap {
@@ -85,12 +89,6 @@ abstract class ContainerGebSpec extends Specification implements ManagedGebTest,
WebDriver driver = new RemoteWebDriver(webDriverContainer.seleniumAddress, new ChromeOptions())
driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(30))
browser.driver = driver
- }
-
- void setup() {
- if (notInitialized) {
- initialize()
- }
browser.baseUrl = "$protocol://$hostName:$port"
}
@@ -164,8 +162,4 @@ abstract class ContainerGebSpec extends Specification implements ManagedGebTest,
private boolean isHostNameChanged() {
return hostNameFromContainer != DEFAULT_HOSTNAME_FROM_CONTAINER
}
-
- private boolean isNotInitialized() {
- webDriverContainer == null
- }
}
\ 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
new file mode 100644
index 0000000..214d9a8
--- /dev/null
+++ b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestDescription.groovy
@@ -0,0 +1,19 @@
+package grails.plugin.geb
+
+import org.spockframework.runtime.model.IterationInfo
+import org.testcontainers.lifecycle.TestDescription
+
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+
+class ContainerGebTestDescription implements TestDescription {
+ String testId
+ String filesystemFriendlyName
+
+ ContainerGebTestDescription(IterationInfo testInfo, LocalDateTime runDate) {
+ testId = testInfo.displayName
+
+ String safeName = testId.replaceAll("\\W+", "")
+ filesystemFriendlyName = "${DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(runDate)}_${safeName}"
+ }
+}
diff --git a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestListener.groovy b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestListener.groovy
new file mode 100644
index 0000000..9cb01db
--- /dev/null
+++ b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestListener.groovy
@@ -0,0 +1,32 @@
+package grails.plugin.geb
+
+import org.spockframework.runtime.AbstractRunListener
+import org.spockframework.runtime.model.ErrorInfo
+import org.spockframework.runtime.model.IterationInfo
+import org.spockframework.runtime.model.SpecInfo
+import org.testcontainers.containers.BrowserWebDriverContainer
+
+import java.time.LocalDateTime
+
+class ContainerGebTestListener extends AbstractRunListener {
+ BrowserWebDriverContainer webDriverContainer
+ ErrorInfo errorInfo
+ SpecInfo spec
+ LocalDateTime runDate
+
+ ContainerGebTestListener(SpecInfo spec, LocalDateTime runDate) {
+ this.spec = spec
+ this.runDate = runDate
+ }
+
+ @Override
+ void afterIteration(IterationInfo iteration) {
+ webDriverContainer.afterTest(new ContainerGebTestDescription(iteration, runDate), Optional.of(errorInfo?.exception))
+ errorInfo = null
+ }
+
+ @Override
+ void error(ErrorInfo error) {
+ errorInfo = error
+ }
+}
\ No newline at end of file
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
new file mode 100644
index 0000000..893fab6
--- /dev/null
+++ b/src/testFixtures/resources/META-INF/services/org.spockframework.runtime.extension.IGlobalExtension
@@ -0,0 +1 @@
+grails.plugin.geb.ContainerGebRecordingExtension
\ No newline at end of file
From 7f5593333aceea2f0f174f46b9f3a3aee4fb04c1 Mon Sep 17 00:00:00 2001
From: James Daugherty
Date: Sat, 23 Nov 2024 10:23:20 -0500
Subject: [PATCH 02/18] cleanup - compilestatic, license, and documentation
---
.../geb/ContainerGebConfiguration.groovy | 22 ++++++++++++++++
.../geb/ContainerGebRecordingExtension.groovy | 23 ++++++++++++++++
.../geb/ContainerGebTestDescription.groovy | 25 ++++++++++++++++++
.../geb/ContainerGebTestListener.groovy | 26 +++++++++++++++++++
4 files changed, 96 insertions(+)
diff --git a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebConfiguration.groovy b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebConfiguration.groovy
index 5cc1581..7f1a33b 100644
--- a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebConfiguration.groovy
+++ b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebConfiguration.groovy
@@ -1,11 +1,33 @@
+/*
+ * 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 groovy.transform.CompileStatic
import groovy.transform.Memoized
+import groovy.transform.PackageScope
import groovy.util.logging.Slf4j
import org.testcontainers.containers.BrowserWebDriverContainer
import org.testcontainers.containers.VncRecordingContainer
+/**
+ * A container class to parse the configuration used by {@link grails.plugin.geb.ContainerGebRecordingExtension}
+ *
+ * @author James Daugherty
+ * @since 5.0.0
+ */
@Slf4j
@CompileStatic
class ContainerGebConfiguration {
diff --git a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebRecordingExtension.groovy b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebRecordingExtension.groovy
index 2c60b9d..7c29ce7 100644
--- a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebRecordingExtension.groovy
+++ b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebRecordingExtension.groovy
@@ -1,5 +1,21 @@
+/*
+ * 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 groovy.transform.CompileStatic
import groovy.transform.TailRecursive
import groovy.util.logging.Slf4j
import org.spockframework.runtime.extension.IGlobalExtension
@@ -7,7 +23,14 @@ 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.0
+ */
@Slf4j
+@CompileStatic
class ContainerGebRecordingExtension implements IGlobalExtension {
ContainerGebConfiguration configuration
diff --git a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestDescription.groovy b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestDescription.groovy
index 214d9a8..ea1b8d4 100644
--- a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestDescription.groovy
+++ b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestDescription.groovy
@@ -1,11 +1,36 @@
+/*
+ * 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 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
+ */
+@CompileStatic
class ContainerGebTestDescription implements TestDescription {
String testId
String filesystemFriendlyName
diff --git a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestListener.groovy b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestListener.groovy
index 9cb01db..b9fed69 100644
--- a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestListener.groovy
+++ b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestListener.groovy
@@ -1,5 +1,21 @@
+/*
+ * 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 groovy.transform.CompileStatic
import org.spockframework.runtime.AbstractRunListener
import org.spockframework.runtime.model.ErrorInfo
import org.spockframework.runtime.model.IterationInfo
@@ -8,6 +24,16 @@ import org.testcontainers.containers.BrowserWebDriverContainer
import java.time.LocalDateTime
+/**
+ * A test listener that reports the test result to {@link org.testcontainers.containers.BrowserWebDriverContainer} so
+ * that recordings may be saved.
+ *
+ * @see org.testcontainers.containers.BrowserWebDriverContainer#afterTest
+ *
+ * @author James Daugherty
+ * @since 5.0
+ */
+@CompileStatic
class ContainerGebTestListener extends AbstractRunListener {
BrowserWebDriverContainer webDriverContainer
ErrorInfo errorInfo
From 5d77412e18162da41a92258022cba78fd9884e56 Mon Sep 17 00:00:00 2001
From: James Daugherty
Date: Sat, 23 Nov 2024 10:23:53 -0500
Subject: [PATCH 03/18] Move webDriverContainer stop to extension
---
.../grails/plugin/geb/ContainerGebRecordingExtension.groovy | 6 +++++-
.../groovy/grails/plugin/geb/ContainerGebSpec.groovy | 4 ----
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebRecordingExtension.groovy b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebRecordingExtension.groovy
index 7c29ce7..83c937c 100644
--- a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebRecordingExtension.groovy
+++ b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebRecordingExtension.groovy
@@ -51,13 +51,17 @@ class ContainerGebRecordingExtension implements IGlobalExtension {
listener.webDriverContainer = gebSpec.webDriverContainer.withRecordingMode(configuration.recordingMode, configuration.recordingDirectory, configuration.recordingFormat)
}
}
+ spec.addCleanupInterceptor {
+ ContainerGebSpec gebSpec = it.instance as ContainerGebSpec
+ gebSpec.container?.stop()
+ }
spec.addListener(listener)
}
}
@TailRecursive
- boolean isContainerizedGebSpec(SpecInfo spec) {
+ private boolean isContainerizedGebSpec(SpecInfo spec) {
if(spec != null) {
if(spec.filename.startsWith('ContainerGebSpec.')) {
return true
diff --git a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebSpec.groovy b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebSpec.groovy
index b0ddd54..208a719 100644
--- a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebSpec.groovy
+++ b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebSpec.groovy
@@ -92,10 +92,6 @@ abstract class ContainerGebSpec extends Specification implements ManagedGebTest,
browser.baseUrl = "$protocol://$hostName:$port"
}
- def cleanupSpec() {
- webDriverContainer?.stop()
- }
-
/**
* Get access to container running the web-driver, for convenience to execInContainer, copyFileToContainer etc.
*
From fdc7f20e17369d6b644764059ce308d9c4820a9b Mon Sep 17 00:00:00 2001
From: James Daugherty
Date: Sun, 24 Nov 2024 11:08:54 -0500
Subject: [PATCH 04/18] feedback - formating, use existing enum instead of
recording flag, and clean-up
---
README.md | 14 ++++-------
.../geb/ContainerAwareDownloadSupport.groovy | 3 ++-
.../geb/ContainerGebConfiguration.groovy | 24 +++++++------------
.../geb/ContainerGebRecordingExtension.groovy | 23 +++++++++++-------
.../grails/plugin/geb/ContainerGebSpec.groovy | 4 ++--
.../geb/ContainerGebTestDescription.groovy | 5 ++--
.../geb/ContainerGebTestListener.groovy | 12 ++++++----
7 files changed, 42 insertions(+), 43 deletions(-)
diff --git a/README.md b/README.md
index df53bad..3997dcf 100644
--- a/README.md
+++ b/README.md
@@ -57,9 +57,11 @@ Just run `./gradlew integrationTest` and a container will be started and configu
By default, all failed tests will generate a video recording of the test execution. These recordings are saved to a `recordings` directory under the project's `build` directory.
The following system properties can be change to configure the recording behavior:
-* `grails.geb.recording.enabled`
- * purpose: toggle for recording
- * possible values: `true` or `false`
+
+* `grails.geb.recording.mode`
+ * purpose: which tests to record
+ * possible values: `SKIP`, `RECORD_ALL`, or `RECORD_FAILING`
+ * defaults to `RECORD_FAILING`
* `grails.geb.recording.directory`
@@ -67,12 +69,6 @@ The following system properties can be change to configure the recording behavio
* defaults to `build/recordings`
-* `grails.geb.recording.mode`
- * purpose: which tests to record via the enum `VncRecordingMode`
- * possible values: `RECORD_ALL` or `RECORD_FAILING`
- * defaults to `RECORD_FAILING`
-
-
* `grails.geb.recording.format`
* purpose: sets the format of the recording
* possible values are `FLV` or `MP4`
diff --git a/src/testFixtures/groovy/grails/plugin/geb/ContainerAwareDownloadSupport.groovy b/src/testFixtures/groovy/grails/plugin/geb/ContainerAwareDownloadSupport.groovy
index 3663d3d..407ddbe 100644
--- a/src/testFixtures/groovy/grails/plugin/geb/ContainerAwareDownloadSupport.groovy
+++ b/src/testFixtures/groovy/grails/plugin/geb/ContainerAwareDownloadSupport.groovy
@@ -35,7 +35,7 @@ import java.util.regex.Pattern
* setups, ensuring the host network context is used for download requests.
*
* @author Mattias Reichel
- * @since 5.0.0
+ * @since 5.0
*/
@CompileStatic
@SelfType(ContainerGebSpec)
@@ -45,6 +45,7 @@ trait ContainerAwareDownloadSupport implements DownloadSupport {
private final DownloadSupport downloadSupport = new LocalhostDownloadSupport(browser, this)
abstract Browser getBrowser()
+
abstract String getHostNameFromHost()
private static class LocalhostDownloadSupport extends DefaultDownloadSupport {
diff --git a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebConfiguration.groovy b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebConfiguration.groovy
index 7f1a33b..a3afa2b 100644
--- a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebConfiguration.groovy
+++ b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebConfiguration.groovy
@@ -17,30 +17,25 @@ package grails.plugin.geb
import groovy.transform.CompileStatic
import groovy.transform.Memoized
-import groovy.transform.PackageScope
import groovy.util.logging.Slf4j
import org.testcontainers.containers.BrowserWebDriverContainer
import org.testcontainers.containers.VncRecordingContainer
/**
- * A container class to parse the configuration used by {@link grails.plugin.geb.ContainerGebRecordingExtension}
+ * Handles parsing various configuration used by {@link grails.plugin.geb.ContainerGebRecordingExtension}
*
* @author James Daugherty
- * @since 5.0.0
+ * @since 5.0
*/
@Slf4j
@CompileStatic
class ContainerGebConfiguration {
- String recordingDirectoryName
-
- boolean recording
+ String recordingDirectoryName
BrowserWebDriverContainer.VncRecordingMode recordingMode
-
VncRecordingContainer.VncRecordingFormat recordingFormat
ContainerGebConfiguration() {
- recording = Boolean.parseBoolean(System.getProperty('grails.geb.recording.enabled', true as String))
recordingDirectoryName = System.getProperty('grails.geb.recording.directory', 'build/recordings')
recordingMode = BrowserWebDriverContainer.VncRecordingMode.valueOf(System.getProperty('grails.geb.recording.mode', BrowserWebDriverContainer.VncRecordingMode.RECORD_FAILING.name()))
recordingFormat = VncRecordingContainer.VncRecordingFormat.valueOf(System.getProperty('grails.geb.recording.format', VncRecordingContainer.VncRecordingFormat.MP4.name()))
@@ -48,19 +43,18 @@ class ContainerGebConfiguration {
@Memoized
File getRecordingDirectory() {
- if(!recording) {
+ if (recordingMode == BrowserWebDriverContainer.VncRecordingMode.SKIP) {
return null
}
File recordingDirectory = new File(recordingDirectoryName)
- if(!recordingDirectory.exists()) {
- log.info("Could not find `${recordingDirectoryName}` directory for recording. Creating...")
- recordingDirectory.mkdir()
- }
- else if(!recordingDirectory.isDirectory()) {
+ if (!recordingDirectory.exists()) {
+ log.info("Could not find `{}` directory for recording. Creating...", recordingDirectoryName)
+ recordingDirectory.mkdirs()
+ } else if (!recordingDirectory.isDirectory()) {
throw new IllegalStateException("Recording Directory name expected to be `${recordingDirectoryName}, but found file instead.")
}
- recordingDirectory
+ return recordingDirectory
}
}
diff --git a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebRecordingExtension.groovy b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebRecordingExtension.groovy
index 83c937c..936c4fb 100644
--- a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebRecordingExtension.groovy
+++ b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebRecordingExtension.groovy
@@ -20,6 +20,7 @@ import groovy.transform.TailRecursive
import groovy.util.logging.Slf4j
import org.spockframework.runtime.extension.IGlobalExtension
import org.spockframework.runtime.model.SpecInfo
+import org.testcontainers.containers.BrowserWebDriverContainer
import java.time.LocalDateTime
@@ -27,7 +28,7 @@ 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.0
+ * @since 5.0
*/
@Slf4j
@CompileStatic
@@ -41,14 +42,19 @@ class ContainerGebRecordingExtension implements IGlobalExtension {
@Override
void visitSpec(SpecInfo spec) {
- if (isContainerizedGebSpec(spec)) {
+ if (isContainerGebSpec(spec)) {
ContainerGebTestListener listener = new ContainerGebTestListener(spec, LocalDateTime.now())
// TODO: We should initialize the web driver container once for all geb tests so we don't have to spin it up & down.
spec.addSetupInterceptor {
ContainerGebSpec gebSpec = it.instance as ContainerGebSpec
gebSpec.initialize()
- if(configuration.recording) {
- listener.webDriverContainer = gebSpec.webDriverContainer.withRecordingMode(configuration.recordingMode, configuration.recordingDirectory, configuration.recordingFormat)
+ if (configuration.recordingMode != BrowserWebDriverContainer.VncRecordingMode.SKIP) {
+ listener.webDriverContainer = gebSpec.webDriverContainer
+ .withRecordingMode(
+ configuration.recordingMode,
+ configuration.recordingDirectory,
+ configuration.recordingFormat
+ )
}
}
spec.addCleanupInterceptor {
@@ -61,13 +67,12 @@ class ContainerGebRecordingExtension implements IGlobalExtension {
}
@TailRecursive
- private boolean isContainerizedGebSpec(SpecInfo spec) {
- if(spec != null) {
- if(spec.filename.startsWith('ContainerGebSpec.')) {
+ private boolean isContainerGebSpec(SpecInfo spec) {
+ if (spec != null) {
+ if (spec.filename.startsWith('ContainerGebSpec.')) {
return true
}
-
- return isContainerizedGebSpec(spec.superSpec)
+ return isContainerGebSpec(spec.superSpec)
}
return false
}
diff --git a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebSpec.groovy b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebSpec.groovy
index 208a719..9eb0259 100644
--- a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebSpec.groovy
+++ b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebSpec.groovy
@@ -46,7 +46,7 @@ import java.time.Duration
*
* @author Søren Berg Glasius
* @author Mattias Reichel
- * @since 5.0.0
+ * @since 5.0
*/
@DynamicallyDispatchesToBrowser
abstract class ContainerGebSpec extends Specification implements ManagedGebTest, ContainerAwareDownloadSupport {
@@ -72,7 +72,7 @@ abstract class ContainerGebSpec extends Specification implements ManagedGebTest,
@PackageScope
void initialize() {
- if(webDriverContainer) {
+ if (webDriverContainer) {
return
}
diff --git a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestDescription.groovy b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestDescription.groovy
index ea1b8d4..0002123 100644
--- a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestDescription.groovy
+++ b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestDescription.groovy
@@ -26,19 +26,18 @@ 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
*/
@CompileStatic
class ContainerGebTestDescription implements TestDescription {
+
String testId
String filesystemFriendlyName
ContainerGebTestDescription(IterationInfo testInfo, LocalDateTime runDate) {
testId = testInfo.displayName
-
- String safeName = testId.replaceAll("\\W+", "")
+ String safeName = testId.replaceAll('\\W+', '_')
filesystemFriendlyName = "${DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(runDate)}_${safeName}"
}
}
diff --git a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestListener.groovy b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestListener.groovy
index b9fed69..059b38b 100644
--- a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestListener.groovy
+++ b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestListener.groovy
@@ -35,10 +35,11 @@ import java.time.LocalDateTime
*/
@CompileStatic
class ContainerGebTestListener extends AbstractRunListener {
+
BrowserWebDriverContainer webDriverContainer
- ErrorInfo errorInfo
- SpecInfo spec
- LocalDateTime runDate
+ ErrorInfo errorInfo
+ SpecInfo spec
+ LocalDateTime runDate
ContainerGebTestListener(SpecInfo spec, LocalDateTime runDate) {
this.spec = spec
@@ -47,7 +48,10 @@ class ContainerGebTestListener extends AbstractRunListener {
@Override
void afterIteration(IterationInfo iteration) {
- webDriverContainer.afterTest(new ContainerGebTestDescription(iteration, runDate), Optional.of(errorInfo?.exception))
+ webDriverContainer.afterTest(
+ new ContainerGebTestDescription(iteration, runDate),
+ Optional.ofNullable(errorInfo?.exception)
+ )
errorInfo = null
}
From 268b341fd06166a5f92150ea41454e5337142844 Mon Sep 17 00:00:00 2001
From: James Daugherty
Date: Sun, 24 Nov 2024 11:13:51 -0500
Subject: [PATCH 05/18] feedback - add back isInitialized helper
---
.../groovy/grails/plugin/geb/ContainerGebSpec.groovy | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebSpec.groovy b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebSpec.groovy
index 9eb0259..98dc535 100644
--- a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebSpec.groovy
+++ b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebSpec.groovy
@@ -72,7 +72,7 @@ abstract class ContainerGebSpec extends Specification implements ManagedGebTest,
@PackageScope
void initialize() {
- if (webDriverContainer) {
+ if (initialized) {
return
}
@@ -158,4 +158,8 @@ abstract class ContainerGebSpec extends Specification implements ManagedGebTest,
private boolean isHostNameChanged() {
return hostNameFromContainer != DEFAULT_HOSTNAME_FROM_CONTAINER
}
+
+ private boolean isInitialized() {
+ webDriverContainer == null
+ }
}
\ No newline at end of file
From d7761da88e88d868b3918362e054a28724d3da43 Mon Sep 17 00:00:00 2001
From: James Daugherty
Date: Sun, 24 Nov 2024 13:04:22 -0500
Subject: [PATCH 06/18] Reduce the number of times the container is started
across tests
---
README.md | 4 +
.../geb/ContainerGebConfiguration.groovy | 56 +++----
.../geb/ContainerGebRecordingExtension.groovy | 37 +++--
.../grails/plugin/geb/ContainerGebSpec.groovy | 79 +---------
.../geb/ContainerGebTestListener.groovy | 7 +-
.../plugin/geb/RecordingSettings.groovy | 60 ++++++++
.../geb/WebDriverContainerHolder.groovy | 137 ++++++++++++++++++
7 files changed, 252 insertions(+), 128 deletions(-)
create mode 100644 src/testFixtures/groovy/grails/plugin/geb/RecordingSettings.groovy
create mode 100644 src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy
diff --git a/README.md b/README.md
index 3997dcf..1d96537 100644
--- a/README.md
+++ b/README.md
@@ -53,6 +53,10 @@ 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.
+#### 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.
+
#### Recording
By default, all failed tests will generate a video recording of the test execution. These recordings are saved to a `recordings` directory under the project's `build` directory.
diff --git a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebConfiguration.groovy b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebConfiguration.groovy
index a3afa2b..83577e6 100644
--- a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebConfiguration.groovy
+++ b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebConfiguration.groovy
@@ -15,46 +15,34 @@
*/
package grails.plugin.geb
-import groovy.transform.CompileStatic
-import groovy.transform.Memoized
-import groovy.util.logging.Slf4j
-import org.testcontainers.containers.BrowserWebDriverContainer
-import org.testcontainers.containers.VncRecordingContainer
+import java.lang.annotation.ElementType
+import java.lang.annotation.Retention
+import java.lang.annotation.RetentionPolicy
+import java.lang.annotation.Target
/**
- * Handles parsing various configuration used by {@link grails.plugin.geb.ContainerGebRecordingExtension}
+ * Can be used to configure the protocol and hostname that the container's browser will use
*
* @author James Daugherty
* @since 5.0
*/
-@Slf4j
-@CompileStatic
-class ContainerGebConfiguration {
+@Target(ElementType.TYPE)
+@Retention(RetentionPolicy.RUNTIME)
+@interface ContainerGebConfiguration {
- String recordingDirectoryName
- BrowserWebDriverContainer.VncRecordingMode recordingMode
- VncRecordingContainer.VncRecordingFormat recordingFormat
+ static final String DEFAULT_HOSTNAME_FROM_CONTAINER = 'host.testcontainers.internal'
+ static final String DEFAULT_PROTOCOL = 'http'
- ContainerGebConfiguration() {
- recordingDirectoryName = System.getProperty('grails.geb.recording.directory', 'build/recordings')
- recordingMode = BrowserWebDriverContainer.VncRecordingMode.valueOf(System.getProperty('grails.geb.recording.mode', BrowserWebDriverContainer.VncRecordingMode.RECORD_FAILING.name()))
- recordingFormat = VncRecordingContainer.VncRecordingFormat.valueOf(System.getProperty('grails.geb.recording.format', VncRecordingContainer.VncRecordingFormat.MP4.name()))
- }
+ /**
+ * The protocol that the container's browser will use to access the server under test.
+ *
Defaults to {@code http}.
+ */
+ String protocol() default DEFAULT_PROTOCOL
- @Memoized
- File getRecordingDirectory() {
- if (recordingMode == BrowserWebDriverContainer.VncRecordingMode.SKIP) {
- return null
- }
-
- File recordingDirectory = new File(recordingDirectoryName)
- if (!recordingDirectory.exists()) {
- log.info("Could not find `{}` directory for recording. Creating...", recordingDirectoryName)
- recordingDirectory.mkdirs()
- } else if (!recordingDirectory.isDirectory()) {
- throw new IllegalStateException("Recording Directory name expected to be `${recordingDirectoryName}, but found file instead.")
- }
-
- return recordingDirectory
- }
-}
+ /**
+ * The hostname that the container's browser will use to access the server under test.
+ *
Defaults to {@code host.testcontainers.internal}.
+ *
This is useful when the server under test needs to be accessed with a certain hostname.
+ */
+ String hostName() default DEFAULT_HOSTNAME_FROM_CONTAINER
+}
\ 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
index 936c4fb..61fc1cd 100644
--- a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebRecordingExtension.groovy
+++ b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebRecordingExtension.groovy
@@ -15,12 +15,12 @@
*/
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 org.testcontainers.containers.BrowserWebDriverContainer
import java.time.LocalDateTime
@@ -33,33 +33,25 @@ import java.time.LocalDateTime
@Slf4j
@CompileStatic
class ContainerGebRecordingExtension implements IGlobalExtension {
- ContainerGebConfiguration configuration
+ WebDriverContainerHolder holder
@Override
void start() {
- configuration = new ContainerGebConfiguration()
+ holder = new WebDriverContainerHolder(new RecordingSettings())
+ addShutdownHook {
+ holder.stop()
+ }
}
@Override
void visitSpec(SpecInfo spec) {
if (isContainerGebSpec(spec)) {
- ContainerGebTestListener listener = new ContainerGebTestListener(spec, LocalDateTime.now())
- // TODO: We should initialize the web driver container once for all geb tests so we don't have to spin it up & down.
+ validateContainerGebSpec(spec)
+
+ ContainerGebTestListener listener = new ContainerGebTestListener(holder, spec, LocalDateTime.now())
spec.addSetupInterceptor {
- ContainerGebSpec gebSpec = it.instance as ContainerGebSpec
- gebSpec.initialize()
- if (configuration.recordingMode != BrowserWebDriverContainer.VncRecordingMode.SKIP) {
- listener.webDriverContainer = gebSpec.webDriverContainer
- .withRecordingMode(
- configuration.recordingMode,
- configuration.recordingDirectory,
- configuration.recordingFormat
- )
- }
- }
- spec.addCleanupInterceptor {
- ContainerGebSpec gebSpec = it.instance as ContainerGebSpec
- gebSpec.container?.stop()
+ holder.reinitialize(it)
+ (it.sharedInstance as ContainerGebSpec).webDriverContainer = holder.current
}
spec.addListener(listener)
@@ -76,4 +68,11 @@ class ContainerGebRecordingExtension implements IGlobalExtension {
}
return false
}
+
+ private static void validateContainerGebSpec(SpecInfo specInfo) {
+ if (!specInfo.annotations.find { it.annotationType() == Integration }) {
+ throw new IllegalArgumentException("ContainerGebSpec classes must be annotated with @Integration")
+ }
+ }
}
+
diff --git a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebSpec.groovy b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebSpec.groovy
index 98dc535..4962393 100644
--- a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebSpec.groovy
+++ b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebSpec.groovy
@@ -44,19 +44,17 @@ import java.time.Duration
*
*
*
+ * @see grails.plugin.geb.ContainerGebConfiguration for how to customize the container's connection information
+ *
* @author Søren Berg Glasius
* @author Mattias Reichel
+ * @author James Daugherty
* @since 5.0
*/
@DynamicallyDispatchesToBrowser
abstract class ContainerGebSpec extends Specification implements ManagedGebTest, ContainerAwareDownloadSupport {
- private static final String DEFAULT_HOSTNAME_FROM_CONTAINER = 'host.testcontainers.internal'
private static final String DEFAULT_HOSTNAME_FROM_HOST = 'localhost'
- private static final String DEFAULT_PROTOCOL = 'http'
-
- private String hostNameFromContainer = DEFAULT_HOSTNAME_FROM_CONTAINER
-
boolean reportingEnabled = false
@Override
@@ -70,28 +68,6 @@ abstract class ContainerGebSpec extends Specification implements ManagedGebTest,
@Shared
BrowserWebDriverContainer webDriverContainer
- @PackageScope
- void initialize() {
- if (initialized) {
- return
- }
-
- webDriverContainer = new BrowserWebDriverContainer()
- Testcontainers.exposeHostPorts(port)
- webDriverContainer.tap {
- addExposedPort(this.port)
- withAccessToHost(true)
- start()
- }
- if (hostNameChanged) {
- webDriverContainer.execInContainer('/bin/sh', '-c', "echo '$hostIp\t$hostName' | sudo tee -a /etc/hosts")
- }
- WebDriver driver = new RemoteWebDriver(webDriverContainer.seleniumAddress, new ChromeOptions())
- driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(30))
- browser.driver = driver
- browser.baseUrl = "$protocol://$hostName:$port"
- }
-
/**
* Get access to container running the web-driver, for convenience to execInContainer, copyFileToContainer etc.
*
@@ -104,62 +80,21 @@ abstract class ContainerGebSpec extends Specification implements ManagedGebTest,
return webDriverContainer
}
- /**
- * Returns the protocol that the browser will use to access the server under test.
- *
Defaults to {@code http}.
- *
- * @return the protocol for accessing the server under test
- */
- String getProtocol() {
- return DEFAULT_PROTOCOL
- }
-
- /**
- * Returns the hostname that the browser will use to access the server under test.
- *
Defaults to {@code host.testcontainers.internal}.
- *
This is useful when the server under test needs to be accessed with a certain hostname.
- *
- * @return the hostname for accessing the server under test
- */
- String getHostName() {
- return hostNameFromContainer
- }
-
- void setHostName(String hostName) {
- hostNameFromContainer = hostName
- }
-
/**
* 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 getHostName()}
- * is different from the default, this method will return the same value same as {@code getHostName()}.
+ *
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
*/
@Override
String getHostNameFromHost() {
- return hostNameChanged ? hostName : DEFAULT_HOSTNAME_FROM_HOST
- }
-
- int getPort() {
- try {
- return (int) getProperty('serverPort')
- } catch (Exception ignore) {
- throw new IllegalStateException('Test class must be annotated with @Integration for serverPort to be injected')
- }
- }
-
- private static String getHostIp() {
- PortForwardingContainer.INSTANCE.network.get().ipAddress
+ return hostNameChanged ? webDriverContainer.host : DEFAULT_HOSTNAME_FROM_HOST
}
private boolean isHostNameChanged() {
- return hostNameFromContainer != DEFAULT_HOSTNAME_FROM_CONTAINER
- }
-
- private boolean isInitialized() {
- webDriverContainer == null
+ return webDriverContainer.host != ContainerGebConfiguration.DEFAULT_HOSTNAME_FROM_CONTAINER
}
}
\ No newline at end of file
diff --git a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestListener.groovy b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestListener.groovy
index 059b38b..eb0334a 100644
--- a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestListener.groovy
+++ b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestListener.groovy
@@ -36,19 +36,20 @@ import java.time.LocalDateTime
@CompileStatic
class ContainerGebTestListener extends AbstractRunListener {
- BrowserWebDriverContainer webDriverContainer
+ WebDriverContainerHolder containerHolder
ErrorInfo errorInfo
SpecInfo spec
LocalDateTime runDate
- ContainerGebTestListener(SpecInfo spec, LocalDateTime runDate) {
+ ContainerGebTestListener(WebDriverContainerHolder containerHolder, SpecInfo spec, LocalDateTime runDate) {
this.spec = spec
this.runDate = runDate
+ this.containerHolder = containerHolder
}
@Override
void afterIteration(IterationInfo iteration) {
- webDriverContainer.afterTest(
+ containerHolder.current.afterTest(
new ContainerGebTestDescription(iteration, runDate),
Optional.ofNullable(errorInfo?.exception)
)
diff --git a/src/testFixtures/groovy/grails/plugin/geb/RecordingSettings.groovy b/src/testFixtures/groovy/grails/plugin/geb/RecordingSettings.groovy
new file mode 100644
index 0000000..97f0feb
--- /dev/null
+++ b/src/testFixtures/groovy/grails/plugin/geb/RecordingSettings.groovy
@@ -0,0 +1,60 @@
+/*
+ * 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 groovy.transform.CompileStatic
+import groovy.transform.Memoized
+import groovy.util.logging.Slf4j
+import org.testcontainers.containers.BrowserWebDriverContainer
+import org.testcontainers.containers.VncRecordingContainer
+
+/**
+ * Handles parsing various recording configuration used by {@link grails.plugin.geb.ContainerGebRecordingExtension}
+ *
+ * @author James Daugherty
+ * @since 5.0
+ */
+@Slf4j
+@CompileStatic
+class RecordingSettings {
+
+ String recordingDirectoryName
+ BrowserWebDriverContainer.VncRecordingMode recordingMode
+ VncRecordingContainer.VncRecordingFormat recordingFormat
+
+ RecordingSettings() {
+ recordingDirectoryName = System.getProperty('grails.geb.recording.directory', 'build/recordings')
+ recordingMode = BrowserWebDriverContainer.VncRecordingMode.valueOf(System.getProperty('grails.geb.recording.mode', BrowserWebDriverContainer.VncRecordingMode.RECORD_FAILING.name()))
+ recordingFormat = VncRecordingContainer.VncRecordingFormat.valueOf(System.getProperty('grails.geb.recording.format', VncRecordingContainer.VncRecordingFormat.MP4.name()))
+ }
+
+ @Memoized
+ File getRecordingDirectory() {
+ if (recordingMode == BrowserWebDriverContainer.VncRecordingMode.SKIP) {
+ return null
+ }
+
+ File recordingDirectory = new File(recordingDirectoryName)
+ if (!recordingDirectory.exists()) {
+ log.info("Could not find `{}` directory for recording. Creating...", recordingDirectoryName)
+ recordingDirectory.mkdirs()
+ } else if (!recordingDirectory.isDirectory()) {
+ throw new IllegalStateException("Recording Directory name expected to be `${recordingDirectoryName}, but found file instead.")
+ }
+
+ return recordingDirectory
+ }
+}
diff --git a/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy b/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy
new file mode 100644
index 0000000..a0ad90c
--- /dev/null
+++ b/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy
@@ -0,0 +1,137 @@
+/*
+ * 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 groovy.transform.EqualsAndHashCode
+import org.openqa.selenium.WebDriver
+import org.openqa.selenium.chrome.ChromeOptions
+import org.openqa.selenium.remote.RemoteWebDriver
+import org.spockframework.runtime.extension.IMethodInvocation
+import org.spockframework.runtime.model.SpecInfo
+import org.testcontainers.Testcontainers
+import org.testcontainers.containers.BrowserWebDriverContainer
+
+import java.time.Duration
+
+/**
+ * Responsible for initializing a {@link org.testcontainers.containers.BrowserWebDriverContainer BrowserWebDriverContainer}
+ * per the Spec's {@link grails.plugin.geb.ContainerGebConfiguration ContainerGebConfiguration}. This class will try to
+ * reuse the same container if the configuration matches the current container.
+ *
+ * @author James Daugherty
+ * @since 5.0
+ */
+class WebDriverContainerHolder {
+ RecordingSettings recordingSettings
+ WebDriverContainerConfiguration configuration
+ BrowserWebDriverContainer current
+
+ WebDriverContainerHolder(RecordingSettings recordingSettings) {
+ this.recordingSettings = recordingSettings
+ }
+
+ boolean isInitialized() {
+ current != null
+ }
+
+ boolean stop() {
+ if (!current) {
+ return false
+ }
+ current.stop()
+ current = null
+ configuration = null
+ return true
+ }
+
+ boolean matchesCurrentContainerConfiguration(WebDriverContainerConfiguration specConfiguration) {
+ specConfiguration == configuration
+ }
+
+ private static int getPort(IMethodInvocation invocation) {
+ try {
+ return (int) invocation.instance.metaClass.getProperty(invocation.instance, 'serverPort')
+ } catch (Exception ignore) {
+ throw new IllegalStateException('Test class must be annotated with @Integration for serverPort to be injected')
+ }
+ }
+
+ boolean reinitialize(IMethodInvocation invocation) {
+ WebDriverContainerConfiguration specConfiguration = new WebDriverContainerConfiguration(getPort(invocation), invocation.getSpec())
+ if (matchesCurrentContainerConfiguration(specConfiguration)) {
+ return false
+ }
+
+ if (isInitialized()) {
+ stop()
+ }
+
+ configuration = specConfiguration
+ current = new BrowserWebDriverContainer()
+ Testcontainers.exposeHostPorts(configuration.port)
+ current.tap {
+ addExposedPort(configuration.port)
+ withAccessToHost(true)
+ start()
+ }
+ if (!isDefaultHostname()) {
+ current.execInContainer('/bin/sh', '-c', "echo '$hostIp\t${configuration.hostName}' | sudo tee -a /etc/hosts")
+ }
+
+ if (recordingSettings.recordingMode != BrowserWebDriverContainer.VncRecordingMode.SKIP) {
+ current = current.withRecordingMode(
+ recordingSettings.recordingMode,
+ recordingSettings.recordingDirectory,
+ recordingSettings.recordingFormat
+ )
+ }
+
+ WebDriver driver = new RemoteWebDriver(current.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}"
+
+ true
+ }
+
+ private boolean isDefaultHostname() {
+ return configuration.hostName == ContainerGebConfiguration.DEFAULT_HOSTNAME_FROM_CONTAINER
+ }
+
+ private String getHostIp() {
+ current.getContainerInfo().getNetworkSettings().getNetworks().entrySet().first().value.ipAddress
+ }
+
+ @EqualsAndHashCode
+ private static class WebDriverContainerConfiguration {
+ String protocol
+ String hostName
+ int port
+
+ WebDriverContainerConfiguration(int port, 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
+ }
+ }
+}
+
From 606d31ce327dca711d20d68905e84cc4204eeee6 Mon Sep 17 00:00:00 2001
From: James Daugherty
Date: Sun, 24 Nov 2024 13:07:57 -0500
Subject: [PATCH 07/18] Add explicit return for clarity
---
.../groovy/grails/plugin/geb/WebDriverContainerHolder.groovy | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy b/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy
index a0ad90c..c4ef7f2 100644
--- a/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy
+++ b/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy
@@ -108,7 +108,7 @@ class WebDriverContainerHolder {
browser.driver = driver
browser.baseUrl = "${configuration.protocol}://${configuration.hostName}:${configuration.port}"
- true
+ return true
}
private boolean isDefaultHostname() {
From 9c1050509d896bb1b31f83b0e6eba29833d9c3d5 Mon Sep 17 00:00:00 2001
From: James Daugherty
Date: Sun, 24 Nov 2024 13:08:22 -0500
Subject: [PATCH 08/18] Remove return
---
.../groovy/grails/plugin/geb/WebDriverContainerHolder.groovy | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy b/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy
index c4ef7f2..a224a1d 100644
--- a/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy
+++ b/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy
@@ -112,7 +112,7 @@ class WebDriverContainerHolder {
}
private boolean isDefaultHostname() {
- return configuration.hostName == ContainerGebConfiguration.DEFAULT_HOSTNAME_FROM_CONTAINER
+ configuration.hostName == ContainerGebConfiguration.DEFAULT_HOSTNAME_FROM_CONTAINER
}
private String getHostIp() {
From e47e1f30c3d26ccd094b4d41350a75d6346638c0 Mon Sep 17 00:00:00 2001
From: James Daugherty
Date: Sun, 24 Nov 2024 14:39:09 -0500
Subject: [PATCH 09/18] Remove unused imports
---
.../groovy/grails/plugin/geb/ContainerGebSpec.groovy | 8 --------
1 file changed, 8 deletions(-)
diff --git a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebSpec.groovy b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebSpec.groovy
index 4962393..0b3e8a0 100644
--- a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebSpec.groovy
+++ b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebSpec.groovy
@@ -18,18 +18,10 @@ package grails.plugin.geb
import geb.test.GebTestManager
import geb.test.ManagedGebTest
import geb.transform.DynamicallyDispatchesToBrowser
-import groovy.transform.PackageScope
-import org.openqa.selenium.WebDriver
-import org.openqa.selenium.chrome.ChromeOptions
-import org.openqa.selenium.remote.RemoteWebDriver
-import org.testcontainers.Testcontainers
import org.testcontainers.containers.BrowserWebDriverContainer
-import org.testcontainers.containers.PortForwardingContainer
import spock.lang.Shared
import spock.lang.Specification
-import java.time.Duration
-
/**
* A {@link geb.spock.GebSpec GebSpec} that leverages Testcontainers to run the browser inside a container.
*
From 55abfd710e64a7f1204a10c1d639bbda5de80d95 Mon Sep 17 00:00:00 2001
From: James Daugherty
Date: Sun, 24 Nov 2024 14:39:29 -0500
Subject: [PATCH 10/18] Switch to single quotes
---
.../groovy/grails/plugin/geb/ContainerGebTestDescription.groovy | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestDescription.groovy b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestDescription.groovy
index 0002123..d1a5cc7 100644
--- a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestDescription.groovy
+++ b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestDescription.groovy
@@ -38,6 +38,6 @@ class ContainerGebTestDescription implements TestDescription {
ContainerGebTestDescription(IterationInfo testInfo, LocalDateTime runDate) {
testId = testInfo.displayName
String safeName = testId.replaceAll('\\W+', '_')
- filesystemFriendlyName = "${DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(runDate)}_${safeName}"
+ filesystemFriendlyName = "${DateTimeFormatter.ofPattern('yyyyMMdd_HHmmss').format(runDate)}_${safeName}"
}
}
From 8a8a85114a82ba43f268e362858993b05e20f584 Mon Sep 17 00:00:00 2001
From: James Daugherty
Date: Sun, 24 Nov 2024 14:51:29 -0500
Subject: [PATCH 11/18] Code Review Feedback
---
.../geb/ContainerGebRecordingExtension.groovy | 5 ++-
.../geb/ContainerGebTestListener.groovy | 3 +-
.../plugin/geb/RecordingSettings.groovy | 24 ++++++++---
.../geb/WebDriverContainerHolder.groovy | 43 +++++++++++--------
4 files changed, 46 insertions(+), 29 deletions(-)
diff --git a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebRecordingExtension.groovy b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebRecordingExtension.groovy
index 61fc1cd..275b762 100644
--- a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebRecordingExtension.groovy
+++ b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebRecordingExtension.groovy
@@ -33,6 +33,7 @@ import java.time.LocalDateTime
@Slf4j
@CompileStatic
class ContainerGebRecordingExtension implements IGlobalExtension {
+
WebDriverContainerHolder holder
@Override
@@ -51,7 +52,7 @@ class ContainerGebRecordingExtension implements IGlobalExtension {
ContainerGebTestListener listener = new ContainerGebTestListener(holder, spec, LocalDateTime.now())
spec.addSetupInterceptor {
holder.reinitialize(it)
- (it.sharedInstance as ContainerGebSpec).webDriverContainer = holder.current
+ (it.sharedInstance as ContainerGebSpec).webDriverContainer = holder.currentContainer
}
spec.addListener(listener)
@@ -71,7 +72,7 @@ class ContainerGebRecordingExtension implements IGlobalExtension {
private static void validateContainerGebSpec(SpecInfo specInfo) {
if (!specInfo.annotations.find { it.annotationType() == Integration }) {
- throw new IllegalArgumentException("ContainerGebSpec classes must be annotated with @Integration")
+ throw new IllegalArgumentException('ContainerGebSpec classes must be annotated with @Integration')
}
}
}
diff --git a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestListener.groovy b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestListener.groovy
index eb0334a..5da846a 100644
--- a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestListener.groovy
+++ b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestListener.groovy
@@ -20,7 +20,6 @@ import org.spockframework.runtime.AbstractRunListener
import org.spockframework.runtime.model.ErrorInfo
import org.spockframework.runtime.model.IterationInfo
import org.spockframework.runtime.model.SpecInfo
-import org.testcontainers.containers.BrowserWebDriverContainer
import java.time.LocalDateTime
@@ -49,7 +48,7 @@ class ContainerGebTestListener extends AbstractRunListener {
@Override
void afterIteration(IterationInfo iteration) {
- containerHolder.current.afterTest(
+ containerHolder.currentContainer.afterTest(
new ContainerGebTestDescription(iteration, runDate),
Optional.ofNullable(errorInfo?.exception)
)
diff --git a/src/testFixtures/groovy/grails/plugin/geb/RecordingSettings.groovy b/src/testFixtures/groovy/grails/plugin/geb/RecordingSettings.groovy
index 97f0feb..42b78f1 100644
--- a/src/testFixtures/groovy/grails/plugin/geb/RecordingSettings.groovy
+++ b/src/testFixtures/groovy/grails/plugin/geb/RecordingSettings.groovy
@@ -21,6 +21,9 @@ import groovy.util.logging.Slf4j
import org.testcontainers.containers.BrowserWebDriverContainer
import org.testcontainers.containers.VncRecordingContainer
+import static org.testcontainers.containers.BrowserWebDriverContainer.*
+import static org.testcontainers.containers.VncRecordingContainer.*
+
/**
* Handles parsing various recording configuration used by {@link grails.plugin.geb.ContainerGebRecordingExtension}
*
@@ -31,28 +34,35 @@ import org.testcontainers.containers.VncRecordingContainer
@CompileStatic
class RecordingSettings {
+ private static VncRecordingMode DEFAULT_RECORDING_MODE = VncRecordingMode.SKIP
+ private static VncRecordingFormat DEFAULT_RECORDING_FORMAT = VncRecordingFormat.MP4
+
String recordingDirectoryName
- BrowserWebDriverContainer.VncRecordingMode recordingMode
- VncRecordingContainer.VncRecordingFormat recordingFormat
+ VncRecordingMode recordingMode
+ VncRecordingFormat recordingFormat
RecordingSettings() {
recordingDirectoryName = System.getProperty('grails.geb.recording.directory', 'build/recordings')
- recordingMode = BrowserWebDriverContainer.VncRecordingMode.valueOf(System.getProperty('grails.geb.recording.mode', BrowserWebDriverContainer.VncRecordingMode.RECORD_FAILING.name()))
- recordingFormat = VncRecordingContainer.VncRecordingFormat.valueOf(System.getProperty('grails.geb.recording.format', VncRecordingContainer.VncRecordingFormat.MP4.name()))
+ 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()))
+ }
+
+ boolean isRecordingEnabled() {
+ recordingMode != VncRecordingMode.SKIP
}
@Memoized
File getRecordingDirectory() {
- if (recordingMode == BrowserWebDriverContainer.VncRecordingMode.SKIP) {
+ if (!recordingEnabled) {
return null
}
File recordingDirectory = new File(recordingDirectoryName)
if (!recordingDirectory.exists()) {
- log.info("Could not find `{}` directory for recording. Creating...", recordingDirectoryName)
+ log.info('Could not find `{}` Directory for recording. Creating...', recordingDirectoryName)
recordingDirectory.mkdirs()
} else if (!recordingDirectory.isDirectory()) {
- throw new IllegalStateException("Recording Directory name expected to be `${recordingDirectoryName}, but found file instead.")
+ throw new IllegalStateException("Configured recording directory '${recordingDirectory}' is expected to be a directory, but found file instead.")
}
return recordingDirectory
diff --git a/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy b/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy
index a224a1d..a9e93f2 100644
--- a/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy
+++ b/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy
@@ -36,24 +36,25 @@ import java.time.Duration
* @since 5.0
*/
class WebDriverContainerHolder {
+
RecordingSettings recordingSettings
WebDriverContainerConfiguration configuration
- BrowserWebDriverContainer current
+ BrowserWebDriverContainer currentContainer
WebDriverContainerHolder(RecordingSettings recordingSettings) {
this.recordingSettings = recordingSettings
}
boolean isInitialized() {
- current != null
+ currentContainer != null
}
boolean stop() {
- if (!current) {
+ if (!currentContainer) {
return false
}
- current.stop()
- current = null
+ currentContainer.stop()
+ currentContainer = null
configuration = null
return true
}
@@ -71,36 +72,39 @@ class WebDriverContainerHolder {
}
boolean reinitialize(IMethodInvocation invocation) {
- WebDriverContainerConfiguration specConfiguration = new WebDriverContainerConfiguration(getPort(invocation), invocation.getSpec())
+ WebDriverContainerConfiguration specConfiguration = new WebDriverContainerConfiguration(
+ getPort(invocation),
+ invocation.getSpec()
+ )
if (matchesCurrentContainerConfiguration(specConfiguration)) {
return false
}
- if (isInitialized()) {
+ if (initialized) {
stop()
}
configuration = specConfiguration
- current = new BrowserWebDriverContainer()
+ currentContainer = new BrowserWebDriverContainer()
Testcontainers.exposeHostPorts(configuration.port)
- current.tap {
+ currentContainer.tap {
addExposedPort(configuration.port)
withAccessToHost(true)
start()
}
- if (!isDefaultHostname()) {
- current.execInContainer('/bin/sh', '-c', "echo '$hostIp\t${configuration.hostName}' | sudo tee -a /etc/hosts")
+ if (hostnameChanged) {
+ currentContainer.execInContainer('/bin/sh', '-c', "echo '$hostIp\t${configuration.hostName}' | sudo tee -a /etc/hosts")
}
- if (recordingSettings.recordingMode != BrowserWebDriverContainer.VncRecordingMode.SKIP) {
- current = current.withRecordingMode(
+ if (recordingSettings.recordingEnabled) {
+ currentContainer = currentContainer.withRecordingMode(
recordingSettings.recordingMode,
recordingSettings.recordingDirectory,
recordingSettings.recordingFormat
)
}
- WebDriver driver = new RemoteWebDriver(current.seleniumAddress, new ChromeOptions())
+ WebDriver driver = new RemoteWebDriver(currentContainer.seleniumAddress, new ChromeOptions())
driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(30))
// Update the browser to use this container
@@ -111,22 +115,25 @@ class WebDriverContainerHolder {
return true
}
- private boolean isDefaultHostname() {
- configuration.hostName == ContainerGebConfiguration.DEFAULT_HOSTNAME_FROM_CONTAINER
+ private boolean getHostnameChanged() {
+ configuration.hostName != ContainerGebConfiguration.DEFAULT_HOSTNAME_FROM_CONTAINER
}
private String getHostIp() {
- current.getContainerInfo().getNetworkSettings().getNetworks().entrySet().first().value.ipAddress
+ currentContainer.getContainerInfo().getNetworkSettings().getNetworks().entrySet().first().value.ipAddress
}
@EqualsAndHashCode
private static class WebDriverContainerConfiguration {
+
String protocol
String hostName
int port
WebDriverContainerConfiguration(int port, SpecInfo spec) {
- ContainerGebConfiguration configuration = spec.annotations.find { it.annotationType() == ContainerGebConfiguration } as ContainerGebConfiguration
+ ContainerGebConfiguration configuration = spec.annotations.find {
+ it.annotationType() == ContainerGebConfiguration
+ } as ContainerGebConfiguration
protocol = configuration?.protocol() ?: ContainerGebConfiguration.DEFAULT_PROTOCOL
hostName = configuration?.hostName() ?: ContainerGebConfiguration.DEFAULT_HOSTNAME_FROM_CONTAINER
From b0a5e7d6d050b7d0267963aedadbd4a15e0e8256 Mon Sep 17 00:00:00 2001
From: James Daugherty
Date: Sun, 24 Nov 2024 14:55:10 -0500
Subject: [PATCH 12/18] Update readme for default change
---
README.md | 6 ++----
1 file changed, 2 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index 1d96537..5c3a0e6 100644
--- a/README.md
+++ b/README.md
@@ -58,14 +58,12 @@ Just run `./gradlew integrationTest` and a container will be started and configu
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.
#### Recording
-By default, all failed tests will generate a video recording of the test execution. These recordings are saved to a `recordings` directory under the project's `build` directory.
-
-The following system properties can be change to configure the recording behavior:
+By default, no test recording will be performed. Here are the system properties available to change the recording behavior:
* `grails.geb.recording.mode`
* purpose: which tests to record
* possible values: `SKIP`, `RECORD_ALL`, or `RECORD_FAILING`
- * defaults to `RECORD_FAILING`
+ * defaults to `SKIP`
* `grails.geb.recording.directory`
From 06db4c70897a47c6ec0eb54b11edfc38898a55a1 Mon Sep 17 00:00:00 2001
From: James Daugherty
Date: Sun, 24 Nov 2024 15:45:01 -0500
Subject: [PATCH 13/18] Feedback
---
.../geb/ContainerGebRecordingExtension.groovy | 8 ++++----
.../grails/plugin/geb/RecordingSettings.groovy | 14 ++++++++------
.../plugin/geb/WebDriverContainerHolder.groovy | 8 +++++---
3 files changed, 17 insertions(+), 13 deletions(-)
diff --git a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebRecordingExtension.groovy b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebRecordingExtension.groovy
index 275b762..80dfde2 100644
--- a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebRecordingExtension.groovy
+++ b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebRecordingExtension.groovy
@@ -46,9 +46,7 @@ class ContainerGebRecordingExtension implements IGlobalExtension {
@Override
void visitSpec(SpecInfo spec) {
- if (isContainerGebSpec(spec)) {
- validateContainerGebSpec(spec)
-
+ if (isContainerGebSpec(spec) && validateContainerGebSpec(spec)) {
ContainerGebTestListener listener = new ContainerGebTestListener(holder, spec, LocalDateTime.now())
spec.addSetupInterceptor {
holder.reinitialize(it)
@@ -70,10 +68,12 @@ class ContainerGebRecordingExtension implements IGlobalExtension {
return false
}
- private static void validateContainerGebSpec(SpecInfo specInfo) {
+ 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/RecordingSettings.groovy b/src/testFixtures/groovy/grails/plugin/geb/RecordingSettings.groovy
index 42b78f1..da46322 100644
--- a/src/testFixtures/groovy/grails/plugin/geb/RecordingSettings.groovy
+++ b/src/testFixtures/groovy/grails/plugin/geb/RecordingSettings.groovy
@@ -18,11 +18,9 @@ package grails.plugin.geb
import groovy.transform.CompileStatic
import groovy.transform.Memoized
import groovy.util.logging.Slf4j
-import org.testcontainers.containers.BrowserWebDriverContainer
-import org.testcontainers.containers.VncRecordingContainer
-import static org.testcontainers.containers.BrowserWebDriverContainer.*
-import static org.testcontainers.containers.VncRecordingContainer.*
+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}
@@ -43,8 +41,12 @@ class RecordingSettings {
RecordingSettings() {
recordingDirectoryName = System.getProperty('grails.geb.recording.directory', 'build/recordings')
- 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()))
+ 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())
+ )
}
boolean isRecordingEnabled() {
diff --git a/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy b/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy
index a9e93f2..1c2f8af 100644
--- a/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy
+++ b/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy
@@ -16,6 +16,7 @@
package grails.plugin.geb
import geb.Browser
+import groovy.transform.CompileStatic
import groovy.transform.EqualsAndHashCode
import org.openqa.selenium.WebDriver
import org.openqa.selenium.chrome.ChromeOptions
@@ -35,6 +36,7 @@ import java.time.Duration
* @author James Daugherty
* @since 5.0
*/
+@CompileStatic
class WebDriverContainerHolder {
RecordingSettings recordingSettings
@@ -49,14 +51,13 @@ class WebDriverContainerHolder {
currentContainer != null
}
- boolean stop() {
+ void stop() {
if (!currentContainer) {
- return false
+ return
}
currentContainer.stop()
currentContainer = null
configuration = null
- return true
}
boolean matchesCurrentContainerConfiguration(WebDriverContainerConfiguration specConfiguration) {
@@ -123,6 +124,7 @@ class WebDriverContainerHolder {
currentContainer.getContainerInfo().getNetworkSettings().getNetworks().entrySet().first().value.ipAddress
}
+ @CompileStatic
@EqualsAndHashCode
private static class WebDriverContainerConfiguration {
From b159b219fbd2dbbd0a60cf54e68336d8444782af Mon Sep 17 00:00:00 2001
From: James Daugherty
Date: Sun, 24 Nov 2024 21:58:01 +0100
Subject: [PATCH 14/18] Feedback
---
.../groovy/grails/plugin/geb/WebDriverContainerHolder.groovy | 5 +----
1 file changed, 1 insertion(+), 4 deletions(-)
diff --git a/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy b/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy
index 1c2f8af..035f155 100644
--- a/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy
+++ b/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy
@@ -52,10 +52,7 @@ class WebDriverContainerHolder {
}
void stop() {
- if (!currentContainer) {
- return
- }
- currentContainer.stop()
+ currentContainer?.stop()
currentContainer = null
configuration = null
}
From a36bd7ec2771ede94ad364974a13ef4090a4d394 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?S=C3=B8ren=20Berg=20Glasius?=
Date: Mon, 25 Nov 2024 10:06:08 +0100
Subject: [PATCH 15/18] WebDriverContainerHolder getHostIp did not get Host ip,
but container IP
---
.../plugin/geb/WebDriverContainerHolder.groovy | 14 ++++++++++++--
1 file changed, 12 insertions(+), 2 deletions(-)
diff --git a/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy b/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy
index 035f155..aaf9be0 100644
--- a/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy
+++ b/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy
@@ -15,6 +15,7 @@
*/
package grails.plugin.geb
+import com.github.dockerjava.api.model.ContainerNetwork
import geb.Browser
import groovy.transform.CompileStatic
import groovy.transform.EqualsAndHashCode
@@ -25,6 +26,7 @@ import org.spockframework.runtime.extension.IMethodInvocation
import org.spockframework.runtime.model.SpecInfo
import org.testcontainers.Testcontainers
import org.testcontainers.containers.BrowserWebDriverContainer
+import org.testcontainers.containers.PortForwardingContainer
import java.time.Duration
@@ -117,8 +119,16 @@ class WebDriverContainerHolder {
configuration.hostName != ContainerGebConfiguration.DEFAULT_HOSTNAME_FROM_CONTAINER
}
- private String getHostIp() {
- currentContainer.getContainerInfo().getNetworkSettings().getNetworks().entrySet().first().value.ipAddress
+ private static String getHostIp() {
+ try {
+ 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)
+ }
}
@CompileStatic
From 895466bb085ac286ad103b80165adaa0b541c47c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?S=C3=B8ren=20Berg=20Glasius?=
Date: Mon, 25 Nov 2024 10:34:25 +0100
Subject: [PATCH 16/18] Suggesting a test-app for the basic tests.
* Not auto-imported
* Stand-alone project
* Includes 'geb' as subproject (not the other way around)
* Project has grails.geb.recording.mode = RECORD_ALL in build.gradle
---
spock-container-test-app/.gitignore | 42 +
spock-container-test-app/build.gradle | 60 +
.../buildSrc/build.gradle | 10 +
spock-container-test-app/gradle.properties | 7 +
.../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43583 bytes
.../gradle/wrapper/gradle-wrapper.properties | 7 +
spock-container-test-app/gradlew | 252 +
spock-container-test-app/gradlew.bat | 94 +
.../assets/images/advancedgrails.svg | 27 +
.../assets/images/apple-touch-icon-retina.png | Bin 0 -> 7038 bytes
.../assets/images/apple-touch-icon.png | Bin 0 -> 3077 bytes
.../assets/images/documentation.svg | 19 +
.../grails-app/assets/images/favicon.ico | Bin 0 -> 5558 bytes
.../images/grails-cupsonly-logo-white.svg | 26 +
.../grails-app/assets/images/grails.svg | 13 +
.../assets/images/skin/database_add.png | Bin 0 -> 658 bytes
.../assets/images/skin/database_delete.png | Bin 0 -> 659 bytes
.../assets/images/skin/database_edit.png | Bin 0 -> 767 bytes
.../assets/images/skin/database_save.png | Bin 0 -> 755 bytes
.../assets/images/skin/database_table.png | Bin 0 -> 726 bytes
.../assets/images/skin/exclamation.png | Bin 0 -> 701 bytes
.../grails-app/assets/images/skin/house.png | Bin 0 -> 806 bytes
.../assets/images/skin/information.png | Bin 0 -> 778 bytes
.../assets/images/skin/sorted_asc.gif | Bin 0 -> 835 bytes
.../assets/images/skin/sorted_desc.gif | Bin 0 -> 834 bytes
.../grails-app/assets/images/slack.svg | 18 +
.../grails-app/assets/images/spinner.gif | Bin 0 -> 2037 bytes
.../assets/javascripts/application.js | 11 +
.../assets/javascripts/bootstrap.bundle.js | 6972 ++++++++++
.../javascripts/bootstrap.bundle.js.map | 1 +
.../javascripts/bootstrap.bundle.min.js | 7 +
.../javascripts/bootstrap.bundle.min.js.map | 1 +
.../assets/javascripts/bootstrap.js | 4357 +++++++
.../assets/javascripts/bootstrap.js.map | 1 +
.../assets/javascripts/bootstrap.min.js | 7 +
.../assets/javascripts/bootstrap.min.js.map | 1 +
.../assets/javascripts/jquery-3.5.1.js | 10872 ++++++++++++++++
.../assets/javascripts/jquery-3.5.1.min.js | 2 +
.../javascripts/jquery-3.5.1.min.js.map | 1 +
.../grails-app/assets/javascripts/popper.js | 2624 ++++
.../assets/javascripts/popper.min.js | 5 +
.../assets/javascripts/popper.min.js.map | 1 +
.../assets/stylesheets/application.css | 15 +
.../assets/stylesheets/bootstrap-grid.css | 3872 ++++++
.../assets/stylesheets/bootstrap-grid.css.map | 1 +
.../assets/stylesheets/bootstrap-grid.min.css | 7 +
.../stylesheets/bootstrap-grid.min.css.map | 1 +
.../assets/stylesheets/bootstrap-reboot.css | 331 +
.../assets/stylesheets/bootstrap.css | 10315 +++++++++++++++
.../assets/stylesheets/bootstrap.css.map | 1 +
.../assets/stylesheets/bootstrap.min.css | 7 +
.../assets/stylesheets/bootstrap.min.css.map | 1 +
.../grails-app/assets/stylesheets/errors.css | 98 +
.../grails-app/assets/stylesheets/grails.css | 1052 ++
.../grails-app/assets/stylesheets/main.css | 589 +
.../grails-app/assets/stylesheets/mobile.css | 82 +
.../grails-app/conf/application.yml | 94 +
.../grails-app/conf/logback-spring.xml | 39 +
.../grails-app/conf/spring/resources.groovy | 3 +
.../demo/spock/ServerNameController.groovy | 9 +
.../org/demo/spock/UrlMappings.groovy | 16 +
.../grails-app/i18n/messages.properties | 56 +
.../init/org/demo/spock/Application.groovy | 13 +
.../init/org/demo/spock/BootStrap.groovy | 9 +
.../grails-app/views/error.gsp | 31 +
.../grails-app/views/index.gsp | 78 +
.../grails-app/views/layouts/main.gsp | 73 +
.../grails-app/views/notFound.gsp | 14 +
.../grails-app/views/serverName/index.gsp | 17 +
spock-container-test-app/grails-cli.yml | 8 +
spock-container-test-app/grails-wrapper.jar | Bin 0 -> 5743 bytes
spock-container-test-app/grailsw | 152 +
spock-container-test-app/grailsw.bat | 89 +
spock-container-test-app/settings.gradle | 17 +
.../groovy/org/demo/spock/RootPageSpec.groovy | 20 +
.../spock/ServerNameControllerSpec.groovy | 22 +
76 files changed, 42570 insertions(+)
create mode 100644 spock-container-test-app/.gitignore
create mode 100644 spock-container-test-app/build.gradle
create mode 100644 spock-container-test-app/buildSrc/build.gradle
create mode 100644 spock-container-test-app/gradle.properties
create mode 100644 spock-container-test-app/gradle/wrapper/gradle-wrapper.jar
create mode 100644 spock-container-test-app/gradle/wrapper/gradle-wrapper.properties
create mode 100755 spock-container-test-app/gradlew
create mode 100644 spock-container-test-app/gradlew.bat
create mode 100644 spock-container-test-app/grails-app/assets/images/advancedgrails.svg
create mode 100644 spock-container-test-app/grails-app/assets/images/apple-touch-icon-retina.png
create mode 100644 spock-container-test-app/grails-app/assets/images/apple-touch-icon.png
create mode 100644 spock-container-test-app/grails-app/assets/images/documentation.svg
create mode 100644 spock-container-test-app/grails-app/assets/images/favicon.ico
create mode 100644 spock-container-test-app/grails-app/assets/images/grails-cupsonly-logo-white.svg
create mode 100644 spock-container-test-app/grails-app/assets/images/grails.svg
create mode 100644 spock-container-test-app/grails-app/assets/images/skin/database_add.png
create mode 100644 spock-container-test-app/grails-app/assets/images/skin/database_delete.png
create mode 100644 spock-container-test-app/grails-app/assets/images/skin/database_edit.png
create mode 100644 spock-container-test-app/grails-app/assets/images/skin/database_save.png
create mode 100644 spock-container-test-app/grails-app/assets/images/skin/database_table.png
create mode 100644 spock-container-test-app/grails-app/assets/images/skin/exclamation.png
create mode 100644 spock-container-test-app/grails-app/assets/images/skin/house.png
create mode 100644 spock-container-test-app/grails-app/assets/images/skin/information.png
create mode 100644 spock-container-test-app/grails-app/assets/images/skin/sorted_asc.gif
create mode 100644 spock-container-test-app/grails-app/assets/images/skin/sorted_desc.gif
create mode 100644 spock-container-test-app/grails-app/assets/images/slack.svg
create mode 100644 spock-container-test-app/grails-app/assets/images/spinner.gif
create mode 100644 spock-container-test-app/grails-app/assets/javascripts/application.js
create mode 100644 spock-container-test-app/grails-app/assets/javascripts/bootstrap.bundle.js
create mode 100644 spock-container-test-app/grails-app/assets/javascripts/bootstrap.bundle.js.map
create mode 100644 spock-container-test-app/grails-app/assets/javascripts/bootstrap.bundle.min.js
create mode 100644 spock-container-test-app/grails-app/assets/javascripts/bootstrap.bundle.min.js.map
create mode 100644 spock-container-test-app/grails-app/assets/javascripts/bootstrap.js
create mode 100644 spock-container-test-app/grails-app/assets/javascripts/bootstrap.js.map
create mode 100644 spock-container-test-app/grails-app/assets/javascripts/bootstrap.min.js
create mode 100644 spock-container-test-app/grails-app/assets/javascripts/bootstrap.min.js.map
create mode 100644 spock-container-test-app/grails-app/assets/javascripts/jquery-3.5.1.js
create mode 100644 spock-container-test-app/grails-app/assets/javascripts/jquery-3.5.1.min.js
create mode 100644 spock-container-test-app/grails-app/assets/javascripts/jquery-3.5.1.min.js.map
create mode 100644 spock-container-test-app/grails-app/assets/javascripts/popper.js
create mode 100644 spock-container-test-app/grails-app/assets/javascripts/popper.min.js
create mode 100644 spock-container-test-app/grails-app/assets/javascripts/popper.min.js.map
create mode 100644 spock-container-test-app/grails-app/assets/stylesheets/application.css
create mode 100644 spock-container-test-app/grails-app/assets/stylesheets/bootstrap-grid.css
create mode 100644 spock-container-test-app/grails-app/assets/stylesheets/bootstrap-grid.css.map
create mode 100644 spock-container-test-app/grails-app/assets/stylesheets/bootstrap-grid.min.css
create mode 100644 spock-container-test-app/grails-app/assets/stylesheets/bootstrap-grid.min.css.map
create mode 100644 spock-container-test-app/grails-app/assets/stylesheets/bootstrap-reboot.css
create mode 100644 spock-container-test-app/grails-app/assets/stylesheets/bootstrap.css
create mode 100644 spock-container-test-app/grails-app/assets/stylesheets/bootstrap.css.map
create mode 100644 spock-container-test-app/grails-app/assets/stylesheets/bootstrap.min.css
create mode 100644 spock-container-test-app/grails-app/assets/stylesheets/bootstrap.min.css.map
create mode 100644 spock-container-test-app/grails-app/assets/stylesheets/errors.css
create mode 100644 spock-container-test-app/grails-app/assets/stylesheets/grails.css
create mode 100644 spock-container-test-app/grails-app/assets/stylesheets/main.css
create mode 100644 spock-container-test-app/grails-app/assets/stylesheets/mobile.css
create mode 100644 spock-container-test-app/grails-app/conf/application.yml
create mode 100644 spock-container-test-app/grails-app/conf/logback-spring.xml
create mode 100644 spock-container-test-app/grails-app/conf/spring/resources.groovy
create mode 100644 spock-container-test-app/grails-app/controllers/org/demo/spock/ServerNameController.groovy
create mode 100644 spock-container-test-app/grails-app/controllers/org/demo/spock/UrlMappings.groovy
create mode 100644 spock-container-test-app/grails-app/i18n/messages.properties
create mode 100644 spock-container-test-app/grails-app/init/org/demo/spock/Application.groovy
create mode 100644 spock-container-test-app/grails-app/init/org/demo/spock/BootStrap.groovy
create mode 100644 spock-container-test-app/grails-app/views/error.gsp
create mode 100644 spock-container-test-app/grails-app/views/index.gsp
create mode 100644 spock-container-test-app/grails-app/views/layouts/main.gsp
create mode 100644 spock-container-test-app/grails-app/views/notFound.gsp
create mode 100644 spock-container-test-app/grails-app/views/serverName/index.gsp
create mode 100644 spock-container-test-app/grails-cli.yml
create mode 100644 spock-container-test-app/grails-wrapper.jar
create mode 100755 spock-container-test-app/grailsw
create mode 100644 spock-container-test-app/grailsw.bat
create mode 100644 spock-container-test-app/settings.gradle
create mode 100644 spock-container-test-app/src/integration-test/groovy/org/demo/spock/RootPageSpec.groovy
create mode 100644 spock-container-test-app/src/integration-test/groovy/org/demo/spock/ServerNameControllerSpec.groovy
diff --git a/spock-container-test-app/.gitignore b/spock-container-test-app/.gitignore
new file mode 100644
index 0000000..cc02599
--- /dev/null
+++ b/spock-container-test-app/.gitignore
@@ -0,0 +1,42 @@
+### Gradle ###
+.gradle
+build/
+!gradle/wrapper/gradle-wrapper.jar
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+bin/
+!**/src/main/**/bin/
+!**/src/test/**/bin/
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+out/
+!**/src/main/**/out/
+!**/src/test/**/out/
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+
+### VS Code ###
+.vscode/
+
+### Other ###
+Thumbs.db
+.DS_Store
+target/
diff --git a/spock-container-test-app/build.gradle b/spock-container-test-app/build.gradle
new file mode 100644
index 0000000..dc7d0c0
--- /dev/null
+++ b/spock-container-test-app/build.gradle
@@ -0,0 +1,60 @@
+plugins {
+ id "groovy"
+ id "org.grails.grails-web"
+ id "org.grails.grails-gsp"
+ id "war"
+ id "idea"
+ id "com.bertramlabs.asset-pipeline" version "5.0.1"
+ id "eclipse"
+}
+
+group = "org.demo.spock"
+
+repositories {
+ mavenCentral()
+ maven { url "https://repo.grails.org/grails/core/" }
+ maven { url "https://repository.apache.org/content/repositories/snapshots"}
+}
+
+dependencies {
+ profile("org.grails.profiles:web")
+ implementation("org.grails:grails-core")
+ implementation("org.grails:grails-logging")
+ implementation("org.grails:grails-plugin-databinding")
+ implementation("org.grails:grails-plugin-i18n")
+ implementation("org.grails:grails-plugin-interceptors")
+ implementation("org.grails:grails-plugin-rest")
+ implementation("org.grails:grails-plugin-services")
+ implementation("org.grails:grails-plugin-url-mappings")
+ implementation("org.grails:grails-web-boot")
+ implementation("org.grails.plugins:gsp")
+ implementation("org.grails.plugins:hibernate5")
+ implementation("org.grails.plugins:scaffolding")
+ implementation("org.sitemesh:grails-plugin-sitemesh3:7.0.0-SNAPSHOT")
+ implementation("org.springframework.boot:spring-boot-autoconfigure")
+ implementation("org.springframework.boot:spring-boot-starter")
+ implementation("org.springframework.boot:spring-boot-starter-actuator")
+ implementation("org.springframework.boot:spring-boot-starter-logging")
+ implementation("org.springframework.boot:spring-boot-starter-tomcat")
+ implementation("org.springframework.boot:spring-boot-starter-validation")
+ console("org.grails:grails-console")
+ runtimeOnly("com.bertramlabs.plugins:asset-pipeline-grails")
+ runtimeOnly("com.h2database:h2")
+ runtimeOnly("org.apache.tomcat:tomcat-jdbc")
+ runtimeOnly("org.fusesource.jansi:jansi")
+ integrationTestImplementation testFixtures(project(':geb'))
+ testImplementation("org.grails:grails-gorm-testing-support")
+ testImplementation("org.grails:grails-web-testing-support")
+ testImplementation("org.spockframework:spock-core")
+}
+
+compileJava.options.release = 17
+
+tasks.withType(Test) {
+ useJUnitPlatform()
+ systemProperty 'grails.geb.recording.mode', 'RECORD_ALL'
+}
+assets {
+ minifyJs = true
+ minifyCss = true
+}
diff --git a/spock-container-test-app/buildSrc/build.gradle b/spock-container-test-app/buildSrc/build.gradle
new file mode 100644
index 0000000..538b219
--- /dev/null
+++ b/spock-container-test-app/buildSrc/build.gradle
@@ -0,0 +1,10 @@
+repositories {
+ mavenCentral()
+ maven { url "https://repo.grails.org/grails/core/" }
+ maven { url "https://repository.apache.org/content/repositories/snapshots"}
+}
+dependencies {
+ implementation platform("org.grails:grails-bom:7.0.0-SNAPSHOT")
+ implementation("org.grails:grails-gradle-plugin:7.0.0-SNAPSHOT")
+ implementation("org.grails.plugins:hibernate5:9.0.0-SNAPSHOT")
+}
diff --git a/spock-container-test-app/gradle.properties b/spock-container-test-app/gradle.properties
new file mode 100644
index 0000000..3f27b29
--- /dev/null
+++ b/spock-container-test-app/gradle.properties
@@ -0,0 +1,7 @@
+grailsVersion=7.0.0-SNAPSHOT
+grailsGradlePluginVersion=7.0.0-SNAPSHOT
+version=0.1
+org.gradle.caching=true
+org.gradle.daemon=true
+org.gradle.parallel=true
+org.gradle.jvmargs=-Dfile.encoding=UTF-8 -Xmx1024M
diff --git a/spock-container-test-app/gradle/wrapper/gradle-wrapper.jar b/spock-container-test-app/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000000000000000000000000000000000000..a4b76b9530d66f5e68d973ea569d8e19de379189
GIT binary patch
literal 43583
zcma&N1CXTcmMvW9vTb(Rwr$&4wr$(C?dmSu>@vG-+vuvg^_??!{yS%8zW-#zn-LkA
z5&1^$^{lnmUON?}LBF8_K|(?T0Ra(xUH{($5eN!MR#ZihR#HxkUPe+_R8Cn`RRs(P
z_^*#_XlXmGv7!4;*Y%p4nw?{bNp@UZHv1?Um8r6)Fei3p@ClJn0ECfg1hkeuUU@Or
zDaPa;U3fE=3L}DooL;8f;P0ipPt0Z~9P0)lbStMS)ag54=uL9ia-Lm3nh|@(Y?B`;
zx_#arJIpXH!U{fbCbI^17}6Ri*H<>OLR%c|^mh8+)*h~K8Z!9)DPf
zR2h?lbDZQ`p9P;&DQ4F0sur@TMa!Y}S8irn(%d-gi0*WxxCSk*A?3lGh=gcYN?FGl
z7D=Js!i~0=u3rox^eO3i@$0=n{K1lPNU
zwmfjRVmLOCRfe=seV&P*1Iq=^i`502keY8Uy-WNPwVNNtJFx?IwAyRPZo2Wo1+S(xF37LJZ~%i)kpFQ3Fw=mXfd@>%+)RpYQLnr}B~~zoof(JVm^^&f
zxKV^+3D3$A1G;qh4gPVjhrC8e(VYUHv#dy^)(RoUFM?o%W-EHxufuWf(l*@-l+7vt
z=l`qmR56K~F|v<^Pd*p~1_y^P0P^aPC##d8+HqX4IR1gu+7w#~TBFphJxF)T$2WEa
zxa?H&6=Qe7d(#tha?_1uQys2KtHQ{)Qco)qwGjrdNL7thd^G5i8Os)CHqc>iOidS}
z%nFEDdm=GXBw=yXe1W-ShHHFb?Cc70+$W~z_+}nAoHFYI1MV1wZegw*0y^tC*s%3h
zhD3tN8b=Gv&rj}!SUM6|ajSPp*58KR7MPpI{oAJCtY~JECm)*m_x>AZEu>DFgUcby
z1Qaw8lU4jZpQ_$;*7RME+gq1KySGG#Wql>aL~k9tLrSO()LWn*q&YxHEuzmwd1?aAtI
zBJ>P=&$=l1efe1CDU;`Fd+_;&wI07?V0aAIgc(!{a
z0Jg6Y=inXc3^n!U0Atk`iCFIQooHqcWhO(qrieUOW8X(x?(RD}iYDLMjSwffH2~tB
z)oDgNBLB^AJBM1M^c5HdRx6fBfka`(LD-qrlh5jqH~);#nw|iyp)()xVYak3;Ybik
z0j`(+69aK*B>)e_p%=wu8XC&9e{AO4c~O1U`5X9}?0mrd*m$_EUek{R?DNSh(=br#
z#Q61gBzEpmy`$pA*6!87
zSDD+=@fTY7<4A?GLqpA?Pb2z$pbCc4B4zL{BeZ?F-8`s$?>*lXXtn*NC61>|*w7J*
z$?!iB{6R-0=KFmyp1nnEmLsA-H0a6l+1uaH^g%c(p{iT&YFrbQ$&PRb8Up#X3@Zsk
zD^^&LK~111%cqlP%!_gFNa^dTYT?rhkGl}5=fL{a`UViaXWI$k-UcHJwmaH1s=S$4
z%4)PdWJX;hh5UoK?6aWoyLxX&NhNRqKam7tcOkLh{%j3K^4Mgx1@i|Pi&}<^5>hs5
zm8?uOS>%)NzT(%PjVPGa?X%`N2TQCKbeH2l;cTnHiHppPSJ<7y-yEIiC!P*ikl&!B
z%+?>VttCOQM@ShFguHVjxX^?mHX^hSaO_;pnyh^v9EumqSZTi+#f&_Vaija0Q-e*|
z7ulQj6Fs*bbmsWp{`auM04gGwsYYdNNZcg|ph0OgD>7O}Asn7^Z=eI>`$2*v78;sj-}oMoEj&@)9+ycEOo92xSyY344^
z11Hb8^kdOvbf^GNAK++bYioknrpdN>+u8R?JxG=!2Kd9r=YWCOJYXYuM0cOq^FhEd
zBg2puKy__7VT3-r*dG4c62Wgxi52EMCQ`bKgf*#*ou(D4-ZN$+mg&7$u!!
z-^+Z%;-3IDwqZ|K=ah85OLwkO
zKxNBh+4QHh)u9D?MFtpbl)us}9+V!D%w9jfAMYEb>%$A;u)rrI
zuBudh;5PN}_6J_}l55P3l_)&RMlH{m!)ai-i$g)&*M`eN$XQMw{v^r@-125^RRCF0
z^2>|DxhQw(mtNEI2Kj(;KblC7x=JlK$@78`O~>V!`|1Lm-^JR$-5pUANAnb(5}B}JGjBsliK4&
zk6y(;$e&h)lh2)L=bvZKbvh@>vLlreBdH8No2>$#%_Wp1U0N7Ank!6$dFSi#xzh|(
zRi{Uw%-4W!{IXZ)fWx@XX6;&(m_F%c6~X8hx=BN1&q}*(
zoaNjWabE{oUPb!Bt$eyd#$5j9rItB-h*5JiNi(v^e|XKAj*8(k<5-2$&ZBR5fF|JA
z9&m4fbzNQnAU}r8ab>fFV%J0z5awe#UZ|bz?Ur)U9bCIKWEzi2%A+5CLqh?}K4JHi
z4vtM;+uPsVz{Lfr;78W78gC;z*yTch~4YkLr&m-7%-xc
ztw6Mh2d>_iO*$Rd8(-Cr1_V8EO1f*^@wRoSozS)
zy1UoC@pruAaC8Z_7~_w4Q6n*&B0AjOmMWa;sIav&gu
z|J5&|{=a@vR!~k-OjKEgPFCzcJ>#A1uL&7xTDn;{XBdeM}V=l3B8fE1--DHjSaxoSjNKEM9|U9#m2<3>n{Iuo`r3UZp;>GkT2YBNAh|b
z^jTq-hJp(ebZh#Lk8hVBP%qXwv-@vbvoREX$TqRGTgEi$%_F9tZES@z8Bx}$#5eeG
zk^UsLBH{bc2VBW)*EdS({yw=?qmevwi?BL6*=12k9zM5gJv1>y#ML4!)iiPzVaH9%
zgSImetD@dam~e>{LvVh!phhzpW+iFvWpGT#CVE5TQ40n%F|p(sP5mXxna+Ev7PDwA
zamaV4m*^~*xV+&p;W749xhb_X=$|LD;FHuB&JL5?*Y2-oIT(wYY2;73<^#46S~Gx|
z^cez%V7x$81}UWqS13Gz80379Rj;6~WdiXWOSsdmzY39L;Hg3MH43o*y8ibNBBH`(av4|u;YPq%{R;IuYow<+GEsf@R?=@tT@!}?#>zIIn0CoyV!hq3mw
zHj>OOjfJM3F{RG#6ujzo?y32m^tgSXf@v=J$ELdJ+=5j|=F-~hP$G&}tDZsZE?5rX
ztGj`!S>)CFmdkccxM9eGIcGnS2AfK#gXwj%esuIBNJQP1WV~b~+D7PJTmWGTSDrR`
zEAu4B8l>NPuhsk5a`rReSya2nfV1EK01+G!x8aBdTs3Io$u5!6n6KX%uv@DxAp3F@{4UYg4SWJtQ-W~0MDb|j-$lwVn
znAm*Pl!?Ps&3wO=R115RWKb*JKoexo*)uhhHBncEDMSVa_PyA>k{Zm2(wMQ(5NM3#
z)jkza|GoWEQo4^s*wE(gHz?Xsg4`}HUAcs42cM1-qq_=+=!Gk^y710j=66(cSWqUe
zklbm8+zB_syQv5A2rj!Vbw8;|$@C!vfNmNV!yJIWDQ>{+2x
zKjuFX`~~HKG~^6h5FntRpnnHt=D&rq0>IJ9#F0eM)Y-)GpRjiN7gkA8wvnG#K=q{q
z9dBn8_~wm4J<3J_vl|9H{7q6u2A!cW{bp#r*-f{gOV^e=8S{nc1DxMHFwuM$;aVI^
zz6A*}m8N-&x8;aunp1w7_vtB*pa+OYBw=TMc6QK=mbA-|Cf*
zvyh8D4LRJImooUaSb7t*fVfih<97Gf@VE0|z>NcBwBQze);Rh!k3K_sfunToZY;f2
z^HmC4KjHRVg+eKYj;PRN^|E0>Gj_zagfRbrki68I^#~6-HaHg3BUW%+clM1xQEdPYt_g<2K+z!$>*$9nQ>;
zf9Bei{?zY^-e{q_*|W#2rJG`2fy@{%6u0i_VEWTq$*(ZN37|8lFFFt)nCG({r!q#9
z5VK_kkSJ3?zOH)OezMT{!YkCuSSn!K#-Rhl$uUM(bq*jY?
zi1xbMVthJ`E>d>(f3)~fozjg^@eheMF6<)I`oeJYx4*+M&%c9VArn(OM-wp%M<-`x
z7sLP1&3^%Nld9Dhm@$3f2}87!quhI@nwd@3~fZl_3LYW-B?Ia>ui`ELg
z&Qfe!7m6ze=mZ`Ia9$z|ARSw|IdMpooY4YiPN8K
z4B(ts3p%2i(Td=tgEHX
z0UQ_>URBtG+-?0E;E7Ld^dyZ;jjw0}XZ(}-QzC6+NN=40oDb2^v!L1g9xRvE#@IBR
zO!b-2N7wVfLV;mhEaXQ9XAU+>=XVA6f&T4Z-@AX!leJ8obP^P^wP0aICND?~w&NykJ#54x3_@r7IDMdRNy4Hh;h*!u(Ol(#0bJdwEo$5437-UBjQ+j=Ic>Q2z`
zJNDf0yO6@mr6y1#n3)s(W|$iE_i8r@Gd@!DWDqZ7J&~gAm1#~maIGJ1sls^gxL9LLG_NhU!pTGty!TbhzQnu)I*S^54U6Yu%ZeCg`R>Q
zhBv$n5j~h)Y%y=zErI?{tl!(JWSDXxco7X8WI-6K;9Z-h&~kIv?$!60v%O_j{QYWG!R9W?5_b&67KB$t}&e2LdMvd(PxN6Ir!H4>PNlerpBL>Zvyy!yw
z-SOo8caEpDt(}|gKPBd$qND5#a5nju^O>V&;f890?yEOfkSG^HQVmEbM3Ugzu+UtH
zC(INPDdraBN?P%kE;*Ae%Wto&sgw(crfZ#Qy(<4nk;S|hD3j{IQRI6Yq|f^basLY;
z-HB&Je%Gg}Jt@={_C{L$!RM;$$|iD6vu#3w?v?*;&()uB|I-XqEKqZPS!reW9JkLewLb!70T7n`i!gNtb1%vN-
zySZj{8-1>6E%H&=V}LM#xmt`J3XQoaD|@XygXjdZ1+P77-=;=eYpoEQ01B@L*a(uW
zrZeZz?HJsw_4g0vhUgkg@VF8<-X$B8pOqCuWAl28uB|@r`19DTUQQsb^pfqB6QtiT
z*`_UZ`fT}vtUY#%sq2{rchyfu*pCg;uec2$-$N_xgjZcoumE5vSI{+s@iLWoz^Mf;
zuI8kDP{!XY6OP~q5}%1&L}CtfH^N<3o4L@J@zg1-mt{9L`s^z$Vgb|mr{@WiwAqKg
zp#t-lhrU>F8o0s1q_9y`gQNf~Vb!F%70f}$>i7o4ho$`uciNf=xgJ>&!gSt0g;M>*x4-`U)ysFW&Vs^Vk6m%?iuWU+o&m(2Jm26Y(3%TL;
zA7T)BP{WS!&xmxNw%J=$MPfn(9*^*TV;$JwRy8Zl*yUZi8jWYF>==j~&S|Xinsb%c
z2?B+kpet*muEW7@AzjBA^wAJBY8i|#C{WtO_or&Nj2{=6JTTX05}|H>N2B|Wf!*3_
z7hW*j6p3TvpghEc6-wufFiY!%-GvOx*bZrhZu+7?iSrZL5q9}igiF^*R3%DE4aCHZ
zqu>xS8LkW+Auv%z-<1Xs92u23R$nk@Pk}MU5!gT|c7vGlEA%G^2th&Q*zfg%-D^=f
z&J_}jskj|Q;73NP4<4k*Y%pXPU2Thoqr+5uH1yEYM|VtBPW6lXaetokD0u
z9qVek6Q&wk)tFbQ8(^HGf3Wp16gKmr>G;#G(HRBx?F`9AIRboK+;OfHaLJ(P>IP0w
zyTbTkx_THEOs%Q&aPrxbZrJlio+hCC_HK<4%f3ZoSAyG7Dn`=X=&h@m*|UYO-4Hq0
z-Bq&+Ie!S##4A6OGoC~>ZW`Y5J)*ouaFl_e9GA*VSL!O_@xGiBw!AF}1{tB)z(w%c
zS1Hmrb9OC8>0a_$BzeiN?rkPLc9%&;1CZW*4}CDDNr2gcl_3z+WC15&H1Zc2{o~i)
z)LLW=WQ{?ricmC`G1GfJ0Yp4Dy~Ba;j6ZV4r{8xRs`13{dD!xXmr^Aga|C=iSmor%
z8hi|pTXH)5Yf&v~exp3o+sY4B^^b*eYkkCYl*T{*=-0HniSA_1F53eCb{x~1k3*`W
zr~};p1A`k{1DV9=UPnLDgz{aJH=-LQo<5%+Em!DNN252xwIf*wF_zS^!(XSm(9eoj
z=*dXG&n0>)_)N5oc6v!>-bd(2ragD8O=M|wGW
z!xJQS<)u70m&6OmrF0WSsr@I%T*c#Qo#Ha4d3COcX+9}hM5!7JIGF>7<~C(Ear^Sn
zm^ZFkV6~Ula6+8S?oOROOA6$C&q&dp`>oR-2Ym3(HT@O7Sd5c~+kjrmM)YmgPH*tL
zX+znN>`tv;5eOfX?h{AuX^LK~V#gPCu=)Tigtq9&?7Xh$qN|%A$?V*v=&-2F$zTUv
z`C#WyIrChS5|Kgm_GeudCFf;)!WH7FI60j^0o#65o6`w*S7R@)88n$1nrgU(oU0M9
zx+EuMkC>(4j1;m6NoGqEkpJYJ?vc|B
zOlwT3t&UgL!pX_P*6g36`ZXQ;
z9~Cv}ANFnJGp(;ZhS(@FT;3e)0)Kp;h^x;$*xZn*k0U6-&FwI=uOGaODdrsp-!K$Ac32^c{+FhI-HkYd5v=`PGsg%6I`4d9Jy)uW0y%)
zm&j^9WBAp*P8#kGJUhB!L?a%h$hJgQrx!6KCB_TRo%9{t0J7KW8!o1B!NC)VGLM5!
zpZy5Jc{`r{1e(jd%jsG7k%I+m#CGS*BPA65ZVW~fLYw0dA-H_}O
zrkGFL&P1PG9p2(%QiEWm6x;U-U&I#;Em$nx-_I^wtgw3xUPVVu
zqSuKnx&dIT-XT+T10p;yjo1Y)z(x1fb8Dzfn8e yu?e%!_ptzGB|8GrCfu%p?(_
zQccdaaVK$5bz;*rnyK{_SQYM>;aES6Qs^lj9lEs6_J+%nIiuQC*fN;z8md>r_~Mfl
zU%p5Dt_YT>gQqfr@`cR!$NWr~+`CZb%dn;WtzrAOI>P_JtsB76PYe*<%H(y>qx-`Kq!X_;
z<{RpAqYhE=L1r*M)gNF3B8r(<%8mo*SR2hu
zccLRZwGARt)Hlo1euqTyM>^!HK*!Q2P;4UYrysje@;(<|$&%vQekbn|0Ruu_Io(w4#%p6ld2Yp7tlA`Y$cciThP
zKzNGIMPXX%&Ud0uQh!uQZz|FB`4KGD?3!ND?wQt6!n*f4EmCoJUh&b?;B{|lxs#F-
z31~HQ`SF4x$&v00@(P+j1pAaj5!s`)b2RDBp*PB=2IB>oBF!*6vwr7Dp%zpAx*dPr
zb@Zjq^XjN?O4QcZ*O+8>)|HlrR>oD*?WQl5ri3R#2?*W6iJ>>kH%KnnME&TT@ZzrHS$Q%LC?n|e>V+D+8D
zYc4)QddFz7I8#}y#Wj6>4P%34dZH~OUDb?uP%-E
zwjXM(?Sg~1!|wI(RVuxbu)-rH+O=igSho_pDCw(c6b=P
zKk4ATlB?bj9+HHlh<_!&z0rx13K3ZrAR8W)!@Y}o`?a*JJsD+twZIv`W)@Y?Amu_u
zz``@-e2X}27$i(2=9rvIu5uTUOVhzwu%mNazS|lZb&PT;XE2|B&W1>=B58#*!~D&)
zfVmJGg8UdP*fx(>Cj^?yS^zH#o-$Q-*$SnK(ZVFkw+er=>N^7!)FtP3y~Xxnu^nzY
zikgB>Nj0%;WOltWIob|}%lo?_C7<``a5hEkx&1ku$|)i>Rh6@3h*`slY=9U}(Ql_<
zaNG*J8vb&@zpdhAvv`?{=zDedJ23TD&Zg__snRAH4eh~^oawdYi6A3w8<Ozh@Kw)#bdktM^GVb
zrG08?0bG?|NG+w^&JvD*7LAbjED{_Zkc`3H!My>0u5Q}m!+6VokMLXxl`Mkd=g&Xx
z-a>m*#G3SLlhbKB!)tnzfWOBV;u;ftU}S!NdD5+YtOjLg?X}dl>7m^gOpihrf1;PY
zvll&>dIuUGs{Qnd-
zwIR3oIrct8Va^Tm0t#(bJD7c$Z7DO9*7NnRZorrSm`b`cxz>OIC;jSE3DO8`hX955ui`s%||YQtt2
z5DNA&pG-V+4oI2s*x^>-$6J?p=I>C|9wZF8z;VjR??Icg?1w2v5Me+FgAeGGa8(3S
z4vg*$>zC-WIVZtJ7}o9{D-7d>zCe|z#<9>CFve-OPAYsneTb^JH!Enaza#j}^mXy1
z+ULn^10+rWLF6j2>Ya@@Kq?26>AqK{A_|
zQKb*~F1>sE*=d?A?W7N2j?L09_7n+HGi{VY;MoTGr_)G9)ot$p!-UY5zZ2Xtbm=t
z@dpPSGwgH=QtIcEulQNI>S-#ifbnO5EWkI;$A|pxJd885oM+
zGZ0_0gDvG8q2xebj+fbCHYfAXuZStH2j~|d^sBAzo46(K8n59+T6rzBwK)^rfPT+B
zyIFw)9YC-V^rhtK`!3jrhmW-sTmM+tPH+;nwjL#-SjQPUZ53L@A>y*rt(#M(qsiB2
zx6B)dI}6Wlsw%bJ8h|(lhkJVogQZA&n{?Vgs6gNSXzuZpEyu*xySy8ro07QZ7Vk1!3tJphN_5V7qOiyK8p
z#@jcDD8nmtYi1^l8ml;AF<#IPK?!pqf9D4moYk>d99Im}Jtwj6c#+A;f)CQ*f-hZ<
z=p_T86jog%!p)D&5g9taSwYi&eP
z#JuEK%+NULWus;0w32-SYFku#i}d~+{Pkho&^{;RxzP&0!RCm3-9K6`>KZpnzS6?L
z^H^V*s!8<>x8bomvD%rh>Zp3>Db%kyin;qtl+jAv8Oo~1g~mqGAC&Qi_wy|xEt2iz
zWAJEfTV%cl2Cs<1L&DLRVVH05EDq`pH7Oh7sR`NNkL%wi}8n>IXcO40hp+J+sC!W?!krJf!GJNE8uj
zg-y~Ns-<~D?yqbzVRB}G>0A^f0!^N7l=$m0OdZuqAOQqLc
zX?AEGr1Ht+inZ-Qiwnl@Z0qukd__a!C*CKuGdy5#nD7VUBM^6OCpxCa2A(X;e0&V4
zM&WR8+wErQ7UIc6LY~Q9x%Sn*Tn>>P`^t&idaOEnOd(Ufw#>NoR^1QdhJ8s`h^|R_
zXX`c5*O~Xdvh%q;7L!_!ohf$NfEBmCde|#uVZvEo>OfEq%+Ns7&_f$OR9xsihRpBb
z+cjk8LyDm@U{YN>+r46?nn{7Gh(;WhFw6GAxtcKD+YWV?uge>;+q#Xx4!GpRkVZYu
zzsF}1)7$?%s9g9CH=Zs+B%M_)+~*j3L0&Q9u7!|+T`^O{xE6qvAP?XWv9_MrZKdo&
z%IyU)$Q95AB4!#hT!_dA>4e@zjOBD*Y=XjtMm)V|+IXzjuM;(l+8aA5#Kaz_$rR6!
zj>#&^DidYD$nUY(D$mH`9eb|dtV0b{S>H6FBfq>t5`;OxA4Nn{J(+XihF(stSche7$es&~N$epi&PDM_N`As;*9D^L==2Q7Z2zD+CiU(|+-kL*VG+&9!Yb3LgPy?A
zm7Z&^qRG_JIxK7-FBzZI3Q<;{`DIxtc48k>
zc|0dmX;Z=W$+)qE)~`yn6MdoJ4co;%!`ddy+FV538Y)j(vg}5*k(WK)KWZ3WaOG!8
z!syGn=s{H$odtpqFrT#JGM*utN7B((abXnpDM6w56nhw}OY}0TiTG1#f*VFZr+^-g
zbP10`$LPq_;PvrA1XXlyx2uM^mrjTzX}w{yuLo-cOClE8MMk47T25G8M!9Z5ypOSV
zAJUBGEg5L2fY)ZGJb^E34R2zJ?}Vf>{~gB!8=5Z)
z9y$>5c)=;o0HeHHSuE4U)#vG&KF|I%-cF6f$~pdYJWk_dD}iOA>iA$O$+4%@>JU08
zS`ep)$XLPJ+n0_i@PkF#ri6T8?ZeAot$6JIYHm&P6EB=BiaNY|aA$W0I+nz*zkz_z
zkEru!tj!QUffq%)8y0y`T&`fuus-1p>=^hnBiBqD^hXrPs`PY9tU3m0np~rISY09>
z`P3s=-kt_cYcxWd{de@}TwSqg*xVhp;E9zCsnXo6z
z?f&Sv^U7n4`xr=mXle94HzOdN!2kB~4=%)u&N!+2;z6UYKUDqi-s6AZ!haB;@&B`?
z_TRX0%@suz^TRdCb?!vNJYPY8L_}&07uySH9%W^Tc&1pia6y1q#?*Drf}GjGbPjBS
zbOPcUY#*$3sL2x4v_i*Y=N7E$mR}J%|GUI(>WEr+28+V
z%v5{#e!UF*6~G&%;l*q*$V?&r$Pp^sE^i-0$+RH3ERUUdQ0>rAq2(2QAbG}$y{de(
z>{qD~GGuOk559Y@%$?N^1ApVL_a704>8OD%8Y%8B;FCt%AoPu8*D1
zLB5X>b}Syz81pn;xnB}%0FnwazlWfUV)Z-~rZg6~b
z6!9J$EcE&sEbzcy?CI~=boWA&eeIa%z(7SE^qgVLz??1Vbc1*aRvc%Mri)AJaAG!p
z$X!_9Ds;Zz)f+;%s&dRcJt2==P{^j3bf0M=nJd&xwUGlUFn?H=2W(*2I2Gdu
zv!gYCwM10aeus)`RIZSrCK=&oKaO_Ry~D1B5!y0R=%!i2*KfXGYX&gNv_u+n9wiR5
z*e$Zjju&ODRW3phN925%S(jL+bCHv6rZtc?!*`1TyYXT6%Ju=|X;6D@lq$8T
zW{Y|e39ioPez(pBH%k)HzFITXHvnD6hw^lIoUMA;qAJ^CU?top1fo@s7xT13Fvn1H
z6JWa-6+FJF#x>~+A;D~;VDs26>^oH0EI`IYT2iagy23?nyJ==i{g4%HrAf1-*v
zK1)~@&(KkwR7TL}L(A@C_S0G;-GMDy=MJn2$FP5s<%wC)4jC5PXoxrQBFZ_k0P{{s@sz+gX`-!=T8rcB(=7vW}^K6oLWMmp(rwDh}b
zwaGGd>yEy6fHv%jM$yJXo5oMAQ>c9j`**}F?MCry;T@47@r?&sKHgVe$MCqk#Z_3S
z1GZI~nOEN*P~+UaFGnj{{Jo@16`(qVNtbU>O0Hf57-P>x8Jikp=`s8xWs^dAJ9lCQ
z)GFm+=OV%AMVqVATtN@|vp61VVAHRn87}%PC^RAzJ%JngmZTasWBAWsoAqBU+8L8u
z4A&Pe?fmTm0?mK-BL9t+{y7o(7jm+RpOhL9KnY#E&qu^}B6=K_dB}*VlSEiC9fn)+V=J;OnN)Ta5v66ic1rG+dGAJ1
z1%Zb_+!$=tQ~lxQrzv3x#CPb?CekEkA}0MYSgx$Jdd}q8+R=ma$|&1a#)TQ=l$1tQ
z=tL9&_^vJ)Pk}EDO-va`UCT1m#Uty1{v^A3P~83_#v^ozH}6*9mIjIr;t3Uv%@VeW
zGL6(CwCUp)Jq%G0bIG%?{_*Y#5IHf*5M@wPo6A{$Um++Co$wLC=J1aoG93&T7Ho}P
z=mGEPP7GbvoG!uD$k(H3A$Z))+i{Hy?QHdk>3xSBXR0j!11O^mEe9RHmw!pvzv?Ua~2_l2Yh~_!s1qS`|0~0)YsbHSz8!mG)WiJE|
z2f($6TQtt6L_f~ApQYQKSb=`053LgrQq7G@98#igV>y#i==-nEjQ!XNu9
z~;mE+gtj4IDDNQJ~JVk5Ux6&LCSFL!y=>79kE9=V}J7tD==Ga+IW
zX)r7>VZ9dY=V&}DR))xUoV!u(Z|%3ciQi_2jl}3=$Agc(`RPb
z8kEBpvY>1FGQ9W$n>Cq=DIpski};nE)`p3IUw1Oz0|wxll^)4dq3;CCY@RyJgFgc#
zKouFh!`?Xuo{IMz^xi-h=StCis_M7y