You are viewing a plain text version of this content. The canonical link for it is here.
Posted to geb-notifications@groovy.apache.org by cm...@apache.org on 2026/02/10 22:46:28 UTC
(groovy-geb) 01/06: initial import of testcontainers code
This is an automated email from the ASF dual-hosted git repository.
cmarcum pushed a commit to branch task/geb-testcontainers
in repository https://gitbox.apache.org/repos/asf/groovy-geb.git
commit 54966f8ec9939e48fa77926b5c391da9a764f1da
Author: Carl Marcum <ca...@codebuilders.net>
AuthorDate: Sun Feb 1 12:02:30 2026 -0500
initial import of testcontainers code
---
.../geb-testcontainers/geb-testcontainers.gradle | 143 ++++++
.../demo/spock/BaseUrlConfigFromClassSpec.groovy | 47 ++
.../ContainerFileDetectorAnnotationSpec.groovy | 48 ++
.../spock/ContainerFileDetectorDefaultSpec.groovy | 46 ++
.../spock/ContainerFileDetectorSpockSpec.groovy | 57 +++
.../demo/spock/ContainerGebSpecWithServer.groovy | 43 ++
.../org/demo/spock/DownloadSupportSpec.groovy | 49 ++
.../groovy/org/demo/spock/GebConfigSpec.groovy | 71 +++
.../demo/spock/HostNameConfigurationSpec.groovy | 48 ++
.../org/demo/spock/InheritedConfigSpec.groovy | 121 +++++
.../groovy/org/demo/spock/PageDelegateSpec.groovy | 37 ++
.../org/demo/spock/PerTestRecordingSpec.groovy | 103 ++++
.../org/demo/spock/PortConfigFromClassSpec.groovy | 61 +++
.../groovy/org/demo/spock/RootPageSpec.groovy | 50 ++
.../groovy/org/demo/spock/TestFileServer.groovy | 70 +++
.../org/demo/spock/TestFileServerSpec.groovy | 62 +++
.../groovy/org/demo/spock/UploadSpec.groovy | 115 +++++
.../groovy/org/demo/spock/pages/HomePage.groovy | 29 ++
.../groovy/org/demo/spock/pages/UploadPage.groovy | 38 ++
.../org/demo/spock/pages/UploadSuccessPage.groovy | 29 ++
.../integration-test/resources/GebConfig.groovy | 108 ++++
.../resources/assets/upload-test.txt | 1 +
.../integration-test/resources/static/index.html | 12 +
.../resources/static/upload-success.html | 12 +
.../integration-test/resources/static/upload.html | 21 +
.../grails/plugin/geb/ContainerFileDetector.groovy | 35 ++
.../plugin/geb/ContainerGebConfiguration.groovy | 95 ++++
.../grails/plugin/geb/ContainerGebSpec.groovy | 67 +++
.../plugin/geb/ContainerGebTestDescription.groovy | 49 ++
.../plugin/geb/DefaultContainerFileDetector.groovy | 46 ++
.../grails/plugin/geb/GebOnFailureReporter.groovy | 47 ++
.../plugin/geb/GebRecordingTestListener.groovy | 77 +++
.../plugin/geb/GrailsContainerGebExtension.groovy | 145 ++++++
.../grails/plugin/geb/GrailsGebSettings.groovy | 158 ++++++
.../plugin/geb/WebDriverContainerHolder.groovy | 561 +++++++++++++++++++++
.../geb/serviceloader/ServiceRegistry.groovy | 96 ++++
.../grails/plugin/geb/support/BrowserType.groovy | 30 ++
.../geb/support/ContainerGebFileInputSource.groovy | 53 ++
.../plugin/geb/support/ContainerSupport.groovy | 82 +++
.../geb/support/LocalhostDownloadSupport.groovy | 55 ++
.../plugin/geb/support/ReportingSupport.groovy | 50 ++
.../geb/support/delegate/BrowserDelegate.groovy | 429 ++++++++++++++++
.../delegate/DownloadSupportDelegate.groovy | 173 +++++++
.../geb/support/delegate/DriverDelegate.groovy | 53 ++
.../geb/support/delegate/PageDelegate.groovy | 415 +++++++++++++++
...ockframework.runtime.extension.IGlobalExtension | 1 +
.../groovy/org/demo/spock/BaseUrlConfigSpec.groovy | 42 ++
.../src/test/resources/GebConfig.groovy | 36 ++
48 files changed, 4216 insertions(+)
diff --git a/integration/geb-testcontainers/geb-testcontainers.gradle b/integration/geb-testcontainers/geb-testcontainers.gradle
new file mode 100644
index 00000000..6ca65cd1
--- /dev/null
+++ b/integration/geb-testcontainers/geb-testcontainers.gradle
@@ -0,0 +1,143 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+plugins {
+ id 'geb.test-framework-integration-module'
+ id 'geb.dockerised-test'
+}
+
+// tests require java 18+ for SimpleFileServer
+java {
+ toolchain {
+ languageVersion = JavaLanguageVersion.of(21)
+ }
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+}
+
+ext {
+ // The drivers we want to use in containers
+ // see https://github.com/SeleniumHQ/docker-selenium
+ // for browsers per platform
+ // drivers = ["firefox", "chrome", "edge"] // no safari container
+ drivers = ["firefox"]
+}
+
+// add integration tests
+sourceSets {
+ integrationTest {
+ groovy {
+ srcDirs = ['src/integration-test/groovy']
+ }
+ resources {
+ srcDirs = ['src/integration-test/resources']
+ }
+ compileClasspath += sourceSets.main.output
+ runtimeClasspath += sourceSets.main.output
+ }
+}
+
+configurations {
+ integrationTestImplementation.extendsFrom implementation
+ integrationTestRuntimeOnly.extendsFrom runtimeOnly
+}
+
+dependencies {
+ api project(":module:geb-spock")
+ api libs.spock.core
+ api libs.selenium.chrome.driver
+ api libs.selenium.firefox.driver
+ api libs.selenium.edge.driver
+ api libs.selenium.remote.driver
+ api libs.testcontainers.selenium
+
+ implementation libs.selenium.support
+
+
+ testImplementation libs.junit.platform.testkit
+ testRuntimeOnly libs.junit.platform.launcher
+
+ integrationTestRuntimeOnly libs.junit.platform.launcher
+}
+
+
+tasks.withType(Test).configureEach {
+ outputs.upToDateWhen { false } // always run tests
+ useJUnitPlatform()
+}
+
+tasks.named('test') {
+ testLogging {
+ events 'passed', 'skipped', 'failed'
+ }
+
+ systemProperty('grails.geb.recording.mode', 'RECORD_ALL') // also RECORD_FAILING
+}
+
+tasks.register('integrationTest', Test) {
+ description = 'Runs integration tests.'
+ group = 'verification'
+
+ testClassesDirs = sourceSets.integrationTest.output.classesDirs
+ classpath = sourceSets.integrationTest.runtimeClasspath
+ shouldRunAfter test
+
+ testLogging {
+ events 'passed', 'skipped', 'failed'
+ }
+
+ systemProperty('geb.env', System.getProperty('geb.env'))
+ systemProperty('grails.geb.recording.mode', 'RECORD_ALL') // also RECORD_FAILING
+ // systemProperty('grails.geb.recording.restartPerTest', 'false') // makes all recording the same
+
+ dependsOn drivers.collect { tasks["${it}Test"] }
+ enabled = false
+}
+
+drivers.eachWithIndex { driver, index ->
+ tasks.register("${driver}Test", Test) {
+ group JavaBasePlugin.VERIFICATION_GROUP
+
+ testClassesDirs = sourceSets.integrationTest.output.classesDirs
+ classpath = sourceSets.integrationTest.runtimeClasspath
+
+ testLogging {
+ events 'passed', 'skipped', 'failed'
+ }
+
+ systemProperty('grails.geb.recording.mode', 'RECORD_ALL') // also RECORD_FAILING
+ systemProperty "geb.build.reportsDir", reporting.file("geb/$name")
+ systemProperty "geb.env", driver
+
+ // Make Geb tests more resilient in slow CI environments
+ if (project.hasProperty('gebAtCheckWaiting')) {
+ systemProperty('grails.geb.atCheckWaiting.enabled', 'true')
+ }
+
+ if (index > 0) {
+ mustRunAfter("${drivers[index-1]}Test")
+ }
+ }
+}
+
+check.dependsOn integrationTest
+
+publishing.publications.main.pom {
+ name = "Geb for Testcontainers"
+ description = "Geb (pronounced \"jeb\") integration with Testcontainers."
+}
diff --git a/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/BaseUrlConfigFromClassSpec.groovy b/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/BaseUrlConfigFromClassSpec.groovy
new file mode 100644
index 00000000..fbaf4483
--- /dev/null
+++ b/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/BaseUrlConfigFromClassSpec.groovy
@@ -0,0 +1,47 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.demo.spock
+
+import grails.plugin.geb.ContainerGebSpec
+import spock.lang.Narrative
+
+/**
+ * Test spec to verify that the correct baseUrl configuration is being used.
+ */
+@Narrative("To verify that class field overrides config file.")
+class BaseUrlConfigFromClassSpec extends ContainerGebSpec {
+
+
+ String baseUrl = "http://groovy.apache.org"
+
+ def "should use the class field value"() {
+
+ when: "go to geb sub-page of groovy"
+ go "/geb"
+
+ then: "the page title should be correct"
+ title == "Geb - Very Groovy Browser Automation"
+ }
+
+ def cleanup() {
+ sleep(1000) // give the last video time to copy
+ }
+
+}
diff --git a/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/ContainerFileDetectorAnnotationSpec.groovy b/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/ContainerFileDetectorAnnotationSpec.groovy
new file mode 100644
index 00000000..50f713b8
--- /dev/null
+++ b/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/ContainerFileDetectorAnnotationSpec.groovy
@@ -0,0 +1,48 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.demo.spock
+
+import grails.plugin.geb.ContainerGebConfiguration
+import grails.plugin.geb.UselessContainerFileDetector
+import org.demo.spock.pages.UploadPage
+import org.openqa.selenium.WebDriverException
+
+/**
+ * Altered copy of {@link ContainerFileDetectorDefaultSpec}
+ * that throws {@link org.openqa.selenium.InvalidArgumentException}
+ */
+@ContainerGebConfiguration(fileDetector = UselessContainerFileDetector)
+class ContainerFileDetectorAnnotationSpec extends ContainerGebSpecWithServer {
+
+ def "should fail to find file with fileDetector changed to UselessContainerFileDetector via annotation"() {
+ given:
+ def uploadPage = to UploadPage
+
+ when:
+ uploadPage.fileInput.file = new File('src/integration-test/resources/assets/upload-test.txt')
+
+ and:
+ uploadPage.submitBtn.click()
+
+ then:
+ def e = thrown(WebDriverException)
+ e.message.contains('File not found')
+ }
+}
diff --git a/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/ContainerFileDetectorDefaultSpec.groovy b/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/ContainerFileDetectorDefaultSpec.groovy
new file mode 100644
index 00000000..d07001eb
--- /dev/null
+++ b/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/ContainerFileDetectorDefaultSpec.groovy
@@ -0,0 +1,46 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.demo.spock
+
+import org.demo.spock.pages.UploadSuccessPage
+import org.demo.spock.pages.UploadPage
+
+/**
+ * Altered copy of {@link UploadSpec} that depends on default of
+ * {@link grails.plugin.geb.ContainerGebConfiguration#fileDetector} to be
+ * {@link org.openqa.selenium.remote.LocalFileDetector}
+ */
+class ContainerFileDetectorDefaultSpec extends ContainerGebSpecWithServer {
+
+ def "should be able to find and upload local files"() {
+ given:
+ def uploadPage = to(UploadPage)
+
+ when:
+ uploadPage.fileInput.file = new File('src/integration-test/resources/assets/upload-test.txt')
+
+ and:
+ uploadPage.submitBtn.click()
+
+ then:
+ at(UploadSuccessPage)
+ currentUrl.contains(page.pageUrl + "?uploadedFile=upload-test.txt")
+ }
+}
diff --git a/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/ContainerFileDetectorSpockSpec.groovy b/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/ContainerFileDetectorSpockSpec.groovy
new file mode 100644
index 00000000..d9975fa9
--- /dev/null
+++ b/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/ContainerFileDetectorSpockSpec.groovy
@@ -0,0 +1,57 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.demo.spock
+
+import grails.plugin.geb.ContainerFileDetector
+import grails.plugin.geb.UselessContainerFileDetector
+import grails.plugin.geb.serviceloader.ServiceRegistry
+import org.demo.spock.pages.UploadPage
+import org.openqa.selenium.WebDriverException
+import spock.lang.PendingFeature
+
+/**
+ * Altered copy of {@link ContainerFileDetectorAnnotationSpec}
+ */
+class ContainerFileDetectorSpockSpec extends ContainerGebSpecWithServer {
+
+ def setupSpec(){
+ ServiceRegistry.setInstance(ContainerFileDetector, new UselessContainerFileDetector())
+ }
+
+ def cleanupSpec(){
+ ServiceRegistry.setInstance(ContainerFileDetector, null)
+ }
+
+ @PendingFeature(reason = 'https://github.com/apache/grails-geb/pull/146#issuecomment-2691433277')
+ def "should fail to find file with fileDetector changed to UselessContainerFileDetector in setupSpec"() {
+ given:
+ def uploadPage = to(UploadPage)
+
+ when:
+ uploadPage.fileInput.file = new File('src/integration-test/resources/assets/upload-test.txt')
+
+ and:
+ uploadPage.submitBtn.click()
+
+ then:
+ def e = thrown(WebDriverException)
+ e.message.contains('File not found')
+ }
+}
diff --git a/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/ContainerGebSpecWithServer.groovy b/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/ContainerGebSpecWithServer.groovy
new file mode 100644
index 00000000..5dfeefaa
--- /dev/null
+++ b/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/ContainerGebSpecWithServer.groovy
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.demo.spock
+
+
+import grails.plugin.geb.ContainerGebSpec
+
+/**
+ * ContainerGebSpec with a Simple File Server
+ */
+class ContainerGebSpecWithServer extends ContainerGebSpec {
+
+ static TestFileServer server
+
+ def setupSpec() {
+ server = new TestFileServer()
+ server.start(8090)
+ }
+
+ def cleanupSpec() {
+ server.stop(0)
+ }
+
+
+
+}
diff --git a/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/DownloadSupportSpec.groovy b/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/DownloadSupportSpec.groovy
new file mode 100644
index 00000000..3ede798e
--- /dev/null
+++ b/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/DownloadSupportSpec.groovy
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.demo.spock
+
+import org.demo.spock.pages.HomePage
+import spock.lang.Title
+
+
+@Title("should be able to use download methods")
+class DownloadSupportSpec extends ContainerGebSpecWithServer {
+
+ def "download text from the home page"() {
+ when:
+ to(HomePage)
+
+ then:
+ downloadText().contains('Welcome to the Geb/Spock Test')
+ }
+
+ def "download the home page file in bytes"() {
+ when:
+ to(HomePage)
+
+ then:
+ downloadBytes().length == 247
+ }
+
+ def cleanup() {
+ sleep(1000) // give the last video time to copy
+ }
+
+}
\ No newline at end of file
diff --git a/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/GebConfigSpec.groovy b/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/GebConfigSpec.groovy
new file mode 100644
index 00000000..5009c589
--- /dev/null
+++ b/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/GebConfigSpec.groovy
@@ -0,0 +1,71 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.demo.spock
+
+import org.demo.spock.pages.HomePage
+import org.openqa.selenium.remote.RemoteWebDriver
+
+/**
+ * Test spec to verify that our custom GebConfig.groovy configurations are
+ * being used instead of the default WebDriverContainerHolder configuration.
+ */
+class GebConfigSpec extends ContainerGebSpecWithServer {
+
+ def "should use custom RemoteWebDriver from GebConfig.groovy"() {
+ // reportInfo "the config file should use environment blocks with browser names"
+
+ expect: 'the driver to be a RemoteWebDriver'
+ driver instanceof RemoteWebDriver
+
+ when: 'getting the capabilities of the driver'
+ def capabilities = ((RemoteWebDriver) driver).capabilities
+
+ then: 'our custom capability set in GebConfig is available'
+ capabilities.getCapability('grails:gebConfigUsed') == true
+
+ and: 'the driver should use the browser from GebConfig.groovy'
+ capabilities.browserName == System.getProperty('geb.env')
+
+ when: 'navigating to a page'
+ to(HomePage)
+
+ then: 'the session should be active'
+ driver.sessionId != null
+ }
+
+ def "should use the hostPort in GebConfig.groovy"() {
+ // reportInfo "the config file should contain a 'hostPort = 8090' setting"
+
+ when: "go to localhost"
+ to(HomePage)
+
+ then: "the page title should be correct"
+ title == "Hello Geb"
+
+ and: "the welcome header should be displayed"
+ $("h1").text() == "Welcome to the Geb/Spock Test"
+ }
+
+
+ def cleanup() {
+ sleep(1000) // give the last video time to copy
+ }
+
+}
diff --git a/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/HostNameConfigurationSpec.groovy b/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/HostNameConfigurationSpec.groovy
new file mode 100644
index 00000000..7530dccc
--- /dev/null
+++ b/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/HostNameConfigurationSpec.groovy
@@ -0,0 +1,48 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.demo.spock
+
+import org.demo.spock.pages.HomePage
+import grails.plugin.geb.ContainerGebConfiguration
+import spock.lang.Title
+
+/**
+ * See https://grails.apache.org/docs/latest/guide/testing.html#functionalTesting and https://groovy.apache.org/geb/manual/current/
+ * for more instructions on how to write functional tests with Grails and Geb.
+ */
+@Title("host name configuration test")
+@ContainerGebConfiguration(hostName = 'testing.example.com')
+class HostNameConfigurationSpec extends ContainerGebSpecWithServer {
+
+ def "should show the right server name when visiting home page"() {
+ when: "visiting the hpme page with a configured host name"
+ to(HomePage)
+
+ then: "the page text is correct"
+ $('p').text() == 'This page is served by a local JWebServer.'
+ and: "the emitted hostname is correct"
+ currentUrl == "http://testing.example.com:8090/"
+ }
+
+ def cleanup() {
+ sleep(1000) // give the last video time to copy
+ }
+
+}
diff --git a/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/InheritedConfigSpec.groovy b/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/InheritedConfigSpec.groovy
new file mode 100644
index 00000000..5edbb392
--- /dev/null
+++ b/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/InheritedConfigSpec.groovy
@@ -0,0 +1,121 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.demo.spock
+
+import org.demo.spock.pages.HomePage
+
+import grails.plugin.geb.ContainerGebConfiguration
+import grails.plugin.geb.IContainerGebConfiguration
+
+/**
+ * Adaptation of {@link HostNameConfigurationSpec}
+ */
+class SuperSpec extends ContainerGebSpecWithServer implements IContainerGebConfiguration {
+ @Override
+ String hostName() {
+ 'super.example.com'
+ }
+}
+
+@ContainerGebConfiguration(hostName = 'not.example.com')
+class NotSuperSpec extends ContainerGebSpecWithServer {}
+
+class InheritedConfigSpec extends SuperSpec {
+ void 'should show the right server name when visiting /serverName'() {
+ when: 'visiting the server name controller'
+ to(HomePage)
+
+ then: 'the emitted hostname is correct'
+ // pageSource.contains('Server name: super.example.com')
+ currentUrl == "http://super.example.com:8090/"
+ }
+
+ def cleanup() {
+ sleep(1000) // give the last video time to copy
+ }
+
+}
+
+class NotInheritedConfigSpec extends NotSuperSpec {
+ void 'should show the right server name when visiting /serverName'() {
+ when: 'visiting the server name controller'
+ to(HomePage)
+
+ then: 'the emitted hostname is correct'
+ // !pageSource.contains('Server name: not.example.com')
+ currentUrl != "http://not.example.com:8090/"
+ }
+
+ def cleanup() {
+ sleep(1000) // give the last video time to copy
+ }
+}
+
+class ChildPreferenceInheritedConfigSpec extends SuperSpec {
+ @Override
+ String hostName() {
+ 'child.example.com'
+ }
+
+ void 'should show the right server name when visiting /serverName'() {
+ when: 'visiting the server name controller'
+ to(HomePage)
+
+ then: 'the emitted hostname is correct'
+ // pageSource.contains('Server name: child.example.com')
+ currentUrl == "http://child.example.com:8090/"
+
+ when:
+ report('whatever')
+
+ then:
+ // geb.test.GebTestManager: "Reporting has not been enabled on this GebTestManager yet report() was called"
+ Throwable t = thrown(Exception)
+ t.message.contains('not been enabled')
+ }
+
+ def cleanup() {
+ sleep(1000) // give the last video time to copy
+ }
+}
+
+// No sane person would do this, but lets test anyway
+class SuperSuperInheritedConfigSpec extends SuperSpec {
+ @Override
+ boolean reporting() {
+ true
+ }
+}
+
+class MultipleInheritanceSpec extends SuperSuperInheritedConfigSpec {
+ void 'should show the right server name when visiting /serverName'() {
+ when: 'visiting the server name controller'
+ to(HomePage)
+
+ then: 'the emitted hostname is correct'
+ // pageSource.contains('Server name: super.example.com')
+ currentUrl == "http://super.example.com:8090/"
+ report('multi inheritance report')
+ }
+
+ def cleanup() {
+ sleep(1000) // give the last video time to copy
+ }
+}
diff --git a/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/PageDelegateSpec.groovy b/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/PageDelegateSpec.groovy
new file mode 100644
index 00000000..6bc2c67f
--- /dev/null
+++ b/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/PageDelegateSpec.groovy
@@ -0,0 +1,37 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.demo.spock
+
+import org.demo.spock.pages.UploadPage
+
+class PageDelegateSpec extends ContainerGebSpecWithServer {
+
+ def "should delegate to page object"() {
+ given:
+ to(UploadPage)
+
+ when:
+ nop()
+
+ then:
+ title == 'Upload Test'
+ }
+
+}
diff --git a/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/PerTestRecordingSpec.groovy b/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/PerTestRecordingSpec.groovy
new file mode 100644
index 00000000..7995a3b1
--- /dev/null
+++ b/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/PerTestRecordingSpec.groovy
@@ -0,0 +1,103 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.demo.spock
+
+import grails.plugin.geb.ContainerGebSpec
+import spock.lang.Shared
+import spock.lang.Stepwise
+
+@Stepwise // execute feature methods in declared order
+class PerTestRecordingSpec extends ContainerGebSpec {
+
+ // @Shared
+ // serverPort = 8080 // TODO: this needs to be a configuration
+
+ void '(setup) running a test to create a recording'() {
+ when: 'visiting the grails home page'
+ go('https://grails.apache.org/')
+
+ then: 'the page loads correctly'
+ title.contains('Apache Grails')
+ }
+
+ void '(setup) running a second test to create another recording'() {
+ when: 'visiting the geb home page'
+ go('https://groovy.apache.org/geb/')
+
+ then: 'the page loads correctly'
+ title.contains('Geb - Very Groovy Browser Automation')
+ }
+
+ void 'the recordings of the previous two tests are different'() {
+ when: 'getting the configured base recording directory'
+ // Logic from GrailsGebSettings
+ def recordingDirectoryName = System.getProperty(
+ 'grails.geb.recording.directory',
+ 'build/gebContainer/recordings'
+ )
+ def baseRecordingDir = new File(recordingDirectoryName)
+
+ then: 'base recording directory should exist'
+ baseRecordingDir.exists()
+
+ when: 'getting the most recent recording directory'
+ // Find the timestamped recording directory (should be the most recent one)
+ File recordingDir = null
+ def timestampedDirs = baseRecordingDir.listFiles({ File dir ->
+ dir.isDirectory() && dir.name ==~ /^\d{8}_\d{6}$/
+ } as FileFilter)
+
+ if (timestampedDirs) {
+ // Get the most recent directory
+ recordingDir = timestampedDirs.sort { it.name }.last()
+ }
+
+ then: 'the recording directory should be found'
+ recordingDir != null
+
+ when: 'getting all video recording files (mp4 or flv) from the recording directory'
+ def recordingFiles = recordingDir?.listFiles({ File file ->
+ isVideoFile(file) && file.name.contains(this.class.simpleName)
+ } as FileFilter)
+
+ then: 'recording files should exist for each test method'
+ recordingFiles != null
+ recordingFiles.length >= 2 // At least 2 files for the first two test methods
+
+ and: 'the recording files should have different content (different sizes)'
+ // Sort by last modified time to get the most recent files
+ def sortedFiles = recordingFiles.sort { it.lastModified() }
+ def secondLastFile = sortedFiles[sortedFiles.length - 2]
+ def lastFile = sortedFiles[sortedFiles.length - 1]
+
+ // Files should have different sizes (allowing for small variations due to timing)
+ long sizeDifference = Math.abs(lastFile.length() - secondLastFile.length())
+ sizeDifference > 2 // in KB
+ }
+
+ def cleanup() {
+ sleep(1000) // give the last video time to copy
+ }
+
+ private static boolean isVideoFile(File file) {
+ return file.isFile() && (file.name.endsWith('.mp4') || file.name.endsWith('.flv'))
+ }
+
+}
\ No newline at end of file
diff --git a/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/PortConfigFromClassSpec.groovy b/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/PortConfigFromClassSpec.groovy
new file mode 100644
index 00000000..25168b95
--- /dev/null
+++ b/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/PortConfigFromClassSpec.groovy
@@ -0,0 +1,61 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.demo.spock
+
+import grails.plugin.geb.ContainerGebSpec
+import spock.lang.Narrative
+
+/**
+ * Test spec to verify that the correct port configuration is being used.
+ * Some reference taken from:
+ * groovy-geb/module/geb-core/src/test/groovy/geb/conf/ConfigurationLoaderSpec.groovy
+ */
+@Narrative("To verify that class field overrides config file.")
+class PortConfigFromClassSpec extends ContainerGebSpec {
+
+ static TestFileServer server
+
+ int hostPort = 8000
+
+ def setupSpec() {
+ server = new TestFileServer()
+ }
+
+ def "should use the class field value"() {
+ given: "a server listening on port 8000"
+ server.start(8000)
+
+ when: "go to localhost"
+ go "/" // browser is going to 8080
+
+ then: "the page title should be correct"
+ title == "Hello Geb"
+
+ and: "the welcome header should be displayed"
+ $("h1").text() == "Welcome to the Geb/Spock Test"
+
+ }
+
+ def cleanup() {
+ sleep(1000) // give the last video time to copy
+ server.stop()
+ }
+
+}
diff --git a/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/RootPageSpec.groovy b/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/RootPageSpec.groovy
new file mode 100644
index 00000000..aadc5a1c
--- /dev/null
+++ b/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/RootPageSpec.groovy
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.demo.spock
+
+import geb.report.CompositeReporter
+import geb.report.PageSourceReporter
+import geb.report.Reporter
+import org.demo.spock.pages.HomePage
+
+import grails.plugin.geb.ContainerGebConfiguration
+
+/**
+ * See https://grails.apache.org/docs/latest/guide/testing.html#functionalTesting and https://groovy.apache.org/geb/manual/current/
+ * for more instructions on how to write functional tests with Grails and Geb.
+ */
+@ContainerGebConfiguration(reporting = true)
+class RootPageSpec extends ContainerGebSpecWithServer {
+
+ @Override
+ Reporter createReporter() {
+ // Override the default reporter to demonstrate how this can be customized
+ new CompositeReporter(new PageSourceReporter())
+ }
+
+ void 'should display the correct title on the home page'() {
+ when: 'visiting the home page'
+ to(HomePage)
+
+ then: 'the page title is correct'
+ report('root page report')
+ title == 'Hello Geb'
+ }
+}
diff --git a/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/TestFileServer.groovy b/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/TestFileServer.groovy
new file mode 100644
index 00000000..211c9be7
--- /dev/null
+++ b/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/TestFileServer.groovy
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.demo.spock
+
+import com.sun.net.httpserver.HttpServer
+import com.sun.net.httpserver.SimpleFileServer
+import com.sun.net.httpserver.SimpleFileServer.OutputLevel
+
+import java.nio.file.Path
+import java.nio.file.Paths
+
+class TestFileServer {
+
+ static HttpServer server
+
+ TestFileServer() {}
+
+ void start() {
+ start(8080)
+ }
+
+ void start(int port) {
+ println "Starting TestFileServer on port $port..."
+ // def staticDir = new File('src/integration-test/resources/static').toPath()
+ URL staticDirUrl = getClass().getResource("/static")
+
+ // Convert the URL to a URI and then to a Path
+ Path staticDirPath = Paths.get(staticDirUrl.toURI())
+ def addr = new InetSocketAddress(port)
+
+ // Use JDK's built-in SimpleFileServer to serve the static content
+ server = SimpleFileServer.createFileServer(addr, staticDirPath, OutputLevel.INFO)
+ server.start()
+ println "TestFileServer started on port 8080"
+ }
+
+ void stop() {
+ stop(0)
+ }
+
+
+
+ void stop(int delay) {
+ println "Stopping TestFileServer..."
+ if (server) {
+ server.stop(delay)
+ println "TestFileServer stopped"
+ } else {
+ println "TestFileServer not found!"
+ }
+ }
+
+}
diff --git a/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/TestFileServerSpec.groovy b/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/TestFileServerSpec.groovy
new file mode 100644
index 00000000..a0df3cf4
--- /dev/null
+++ b/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/TestFileServerSpec.groovy
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.demo.spock
+
+import grails.plugin.geb.ContainerGebSpec
+import spock.lang.Narrative
+
+/**
+ * Test spec to verify that the test server works.
+ */
+@Narrative("To verify the server works like it should")
+class TestFileServerSpec extends ContainerGebSpec {
+
+ static TestFileServer server
+
+ def setupSpec() {
+ server = new TestFileServer()
+ }
+
+
+ // the config file should contain a 'hostPort = 8090' setting
+ def "should use the hostPort con GebConfig.groovy"() {
+ given: "a server listening on port 8090"
+
+ server.start(8090)
+
+ when: "go to localhost"
+ go "/"
+
+ then: "the page title should be correct"
+ title == "Hello Geb"
+
+ and: "the welcome header should be displayed"
+ $("h1").text() == "Welcome to the Geb/Spock Test"
+
+ }
+
+ def cleanup() {
+ sleep(1000) // give the last video time to copy
+ server.stop(0)
+ }
+
+
+
+}
diff --git a/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/UploadSpec.groovy b/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/UploadSpec.groovy
new file mode 100644
index 00000000..674c9263
--- /dev/null
+++ b/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/UploadSpec.groovy
@@ -0,0 +1,115 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.demo.spock
+
+import geb.module.FileInput
+import org.demo.spock.pages.UploadSuccessPage
+
+import grails.plugin.geb.ContainerGebSpec
+import org.demo.spock.pages.UploadPage
+import spock.lang.IgnoreIf
+import spock.lang.Requires
+import spock.lang.TempDir
+import spock.lang.Title
+
+import java.nio.file.Path
+
+@Title("should be able to upload files")
+class UploadSpec extends ContainerGebSpecWithServer {
+
+ @TempDir
+ Path tempDir
+
+ @Requires({ os.windows })
+ def "upload files on a Windows host"() {
+ given:
+ File fileInContainer = createFileInputSource(
+ 'src/integration-test/resources/assets/upload-test.txt',
+ '/tmp/upload-test.txt'
+ )
+ and:
+ def uploadPage = to(UploadPage)
+
+ when:
+ container.copyFileFromContainer("/tmp/upload-test.txt", tempDir.toString() + "/upload-test.txt")
+ and:
+ File fileFromContainer = new File(tempDir.toString(), "upload-test.txt")
+
+ then:
+ fileFromContainer.bytes.length == 26
+
+ when:
+ uploadPage.fileInput.file = fileInContainer
+ and:
+ uploadPage.submitBtn.click()
+
+ then:
+ at(UploadSuccessPage)
+ }
+
+ @IgnoreIf({ os.windows })
+ def "upload files on a non-Windows host"() {
+ given:
+ File fileInContainer = createFileInputSource(
+ 'src/integration-test/resources/assets/upload-test.txt',
+ '/tmp/upload-test.txt'
+ )
+ and:
+ def uploadPage = to(UploadPage)
+
+ when:
+ container.copyFileFromContainer("/tmp/upload-test.txt", tempDir.toString() + "/upload-test.txt")
+ and:
+ File fileFromContainer = new File(tempDir.toString(), "upload-test.txt")
+
+ then:
+ fileFromContainer.bytes.length == 26
+
+ when:
+ uploadPage.fileInput.file = fileInContainer
+ and:
+ uploadPage.submitBtn.click()
+
+ then:
+ at(UploadSuccessPage)
+ }
+
+ def "upload file to external site"() {
+ given:
+ go("https://the-internet.herokuapp.com/upload")
+ File uploadFile = new File('src/integration-test/resources/assets/upload-test.txt')
+ $('input', id: 'file-upload').module(FileInput).file = uploadFile
+
+ when:
+ $("input", id: "file-submit").click()
+
+ then:
+ $("div.example").$("h3").text() == "File Uploaded!"
+ $("div.example").$("div", id: "uploaded-files").text() == "upload-test.txt"
+
+ }
+
+ def cleanup() {
+ sleep(1000) // give the last video time to copy
+ container.execInContainer("rm /tmp/upload-test.txt")
+ }
+
+
+}
\ No newline at end of file
diff --git a/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/pages/HomePage.groovy b/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/pages/HomePage.groovy
new file mode 100644
index 00000000..7fd3d999
--- /dev/null
+++ b/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/pages/HomePage.groovy
@@ -0,0 +1,29 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.demo.spock.pages
+
+import geb.Page
+
+class HomePage extends Page {
+
+ static url = '/'
+ static at = { title == 'Hello Geb' }
+
+}
\ No newline at end of file
diff --git a/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/pages/UploadPage.groovy b/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/pages/UploadPage.groovy
new file mode 100644
index 00000000..610e9a6d
--- /dev/null
+++ b/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/pages/UploadPage.groovy
@@ -0,0 +1,38 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.demo.spock.pages
+
+import geb.Page
+import geb.module.FileInput
+
+class UploadPage extends Page {
+
+ static url = '/upload.html'
+ static at = { title == 'Upload Test' }
+
+ static content = {
+ fileInput { $('input', id: 'myFile').module(FileInput) }
+ submitBtn { $('button', type: 'submit') }
+ }
+
+ void nop() {
+ // no-op
+ }
+}
\ No newline at end of file
diff --git a/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/pages/UploadSuccessPage.groovy b/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/pages/UploadSuccessPage.groovy
new file mode 100644
index 00000000..3f53dd5a
--- /dev/null
+++ b/integration/geb-testcontainers/src/integration-test/groovy/org/demo/spock/pages/UploadSuccessPage.groovy
@@ -0,0 +1,29 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.demo.spock.pages
+
+import geb.Page
+
+class UploadSuccessPage extends Page {
+
+ static url = '/upload-success.html'
+ static at = { title == 'File Uploaded' }
+
+}
\ No newline at end of file
diff --git a/integration/geb-testcontainers/src/integration-test/resources/GebConfig.groovy b/integration/geb-testcontainers/src/integration-test/resources/GebConfig.groovy
new file mode 100644
index 00000000..c57519d7
--- /dev/null
+++ b/integration/geb-testcontainers/src/integration-test/resources/GebConfig.groovy
@@ -0,0 +1,108 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+
+import geb.report.ReportState
+import geb.report.Reporter
+import org.openqa.selenium.chrome.ChromeOptions
+import org.openqa.selenium.chromium.ChromiumOptions
+import org.openqa.selenium.edge.EdgeOptions
+import org.openqa.selenium.firefox.FirefoxOptions
+import org.openqa.selenium.remote.RemoteWebDriver
+import geb.report.ReportingListener
+
+// Configuration for container-based Geb testing
+// This driver configuration will be used by WebDriverContainerHolder
+
+
+environments {
+
+ // run via “./gradlew firefoxTest”
+ // See: https://github.com/SeleniumHQ/selenium/blob/trunk/java/src/org/openqa/selenium/firefox/FirefoxDriver.java
+ firefox {
+ driver = {
+ def firefoxOptions = new FirefoxOptions()
+
+ // Add a custom capability that we can test for to verify our configuration is being used
+ firefoxOptions.setCapability('grails:gebConfigUsed', true)
+
+ // The remote address will be set by WebDriverContainerHolder via system property
+ // webdriver.remote.server before this closure is called
+ new RemoteWebDriver(firefoxOptions)
+ }
+ containerBrowser = 'firefox'
+ }
+
+ // run via “./gradlew chromeTest”
+ // See: https://github.com/SeleniumHQ/selenium/blob/trunk/java/src/org/openqa/selenium/chrome/ChromeDriver.java
+ chrome {
+ driver = {
+ // Chrome preferences to disable password manager and credentials service
+ def prefs = [
+ 'credentials_enable_service' : false,
+ 'profile.password_manager_enabled' : false,
+ 'profile.password_manager_leak_detection': false
+ ]
+
+ def chromeOptions = new ChromeOptions()
+ // TO DO: guest would be preferred, but this causes issues with downloads
+ // see https://issues.chromium.org/issues/42323769
+ // chromeOptions.addArguments('--guest') // do not enable
+ chromeOptions.setExperimentalOption('prefs', prefs)
+
+ // Add a custom capability that we can test for to verify our configuration is being used
+ chromeOptions.setCapability('grails:gebConfigUsed', true)
+
+ // The remote address will be set by WebDriverContainerHolder via system property
+ // webdriver.remote.server before this closure is called
+ new RemoteWebDriver(chromeOptions)
+ }
+ containerBrowser = 'chrome'
+ }
+
+ // run via “./gradlew edgeTest”
+ // See: https://github.com/SeleniumHQ/selenium/blob/trunk/java/src/org/openqa/selenium/edge/EdgeDriver.java
+ edge {
+ driver = {
+ def edgeOptions = new EdgeOptions()
+
+ // Add a custom capability that we can test for to verify our configuration is being used
+ edgeOptions.setCapability('grails:gebConfigUsed', true)
+
+ // The remote address will be set by WebDriverContainerHolder via system property
+ // webdriver.remote.server before this closure is called
+ new RemoteWebDriver(edgeOptions)
+ }
+ containerBrowser = 'edge'
+ }
+}
+
+// The `containerBrowser` property must match the configured
+// driver in order to start up a matching Selenium browser container.
+// containerBrowser = 'firefox' // see environments
+
+hostPort = 8090
+
+// Another proof that GebConfig.groovy is being utilized, next to GebConfigSpec.
+reportingListener = new ReportingListener() {
+ void onReport(Reporter reporter, ReportState reportState, List<File> reportFiles) {
+ reportFiles.each {
+ println "[[ATTACHMENT|$it.absolutePath]]"
+ }
+ }
+}
\ No newline at end of file
diff --git a/integration/geb-testcontainers/src/integration-test/resources/assets/upload-test.txt b/integration/geb-testcontainers/src/integration-test/resources/assets/upload-test.txt
new file mode 100644
index 00000000..0c96d3ba
--- /dev/null
+++ b/integration/geb-testcontainers/src/integration-test/resources/assets/upload-test.txt
@@ -0,0 +1 @@
+File uploaded successfully
\ No newline at end of file
diff --git a/integration/geb-testcontainers/src/integration-test/resources/static/index.html b/integration/geb-testcontainers/src/integration-test/resources/static/index.html
new file mode 100644
index 00000000..4a8ebbd8
--- /dev/null
+++ b/integration/geb-testcontainers/src/integration-test/resources/static/index.html
@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <title>Hello Geb</title>
+</head>
+<body>
+<h1>Welcome to the Geb/Spock Test</h1>
+<p>This page is served by a local JWebServer.</p>
+<a href="about.html">About</a>
+</body>
+</html>
diff --git a/integration/geb-testcontainers/src/integration-test/resources/static/upload-success.html b/integration/geb-testcontainers/src/integration-test/resources/static/upload-success.html
new file mode 100644
index 00000000..3014deb6
--- /dev/null
+++ b/integration/geb-testcontainers/src/integration-test/resources/static/upload-success.html
@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <title>File Uploaded</title>
+</head>
+<body>
+<h1>File Uploaded!</h1>
+<p>This page is served by a local JWebServer.</p>
+<a href="about.html">About</a>
+</body>
+</html>
diff --git a/integration/geb-testcontainers/src/integration-test/resources/static/upload.html b/integration/geb-testcontainers/src/integration-test/resources/static/upload.html
new file mode 100644
index 00000000..3c387652
--- /dev/null
+++ b/integration/geb-testcontainers/src/integration-test/resources/static/upload.html
@@ -0,0 +1,21 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Upload Test</title>
+</head>
+<body>
+<h1>Upload Your File</h1>
+
+<form action="upload-success.html" method="get" enctype="multipart/form-data">
+ <div>
+ <label for="myFile">Choose file to upload:</label>
+ <input type="file" id="myFile" name="uploadedFile">
+ </div>
+ <div>
+ <button type="submit">Upload File</button>
+ </div>
+</form>
+</body>
+</html>
\ No newline at end of file
diff --git a/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/ContainerFileDetector.groovy b/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/ContainerFileDetector.groovy
new file mode 100644
index 00000000..0b824738
--- /dev/null
+++ b/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/ContainerFileDetector.groovy
@@ -0,0 +1,35 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.openqa.selenium.remote.FileDetector
+
+/**
+ * An extension of {@link org.openqa.selenium.remote.FileDetector}
+ * that will get passed additional parameters from the webdriver container holder.
+ * <p>
+ * Implementations must provide a zero-argument constructor to ensure compatibility
+ * with {@link java.util.ServiceLoader}.
+ *
+ * @see GebRecordingTestListener
+ */
+@CompileStatic
+interface ContainerFileDetector extends FileDetector {
+}
diff --git a/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/ContainerGebConfiguration.groovy b/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/ContainerGebConfiguration.groovy
new file mode 100644
index 00000000..0dc4f808
--- /dev/null
+++ b/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/ContainerGebConfiguration.groovy
@@ -0,0 +1,95 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.testcontainers.containers.GenericContainer
+
+import java.lang.annotation.ElementType
+import java.lang.annotation.Retention
+import java.lang.annotation.RetentionPolicy
+import java.lang.annotation.Target
+
+/**
+ * Can be used to configure the protocol and hostname that the container's browser will use.
+ *
+ * @author James Daugherty
+ * @since 4.1
+ */
+@Target(ElementType.TYPE)
+@Retention(RetentionPolicy.RUNTIME)
+@interface ContainerGebConfiguration {
+
+ static final String DEFAULT_HOSTNAME_FROM_CONTAINER = GenericContainer.INTERNAL_HOST_HOSTNAME
+ static final String DEFAULT_PROTOCOL = 'http'
+ static final Class<? extends ContainerFileDetector> DEFAULT_FILE_DETECTOR = DefaultContainerFileDetector
+
+ /**
+ * The protocol that the container's browser will use to access the server under test.
+ * <p>Defaults to {@code http}.
+ */
+ String protocol() default DEFAULT_PROTOCOL
+
+ /**
+ * The hostname that the container's browser will use to access the server under test.
+ * <p>Defaults to {@code host.testcontainers.internal}.
+ * <p>This is useful when the server under test needs to be accessed with a certain hostname.
+ */
+ String hostName() default DEFAULT_HOSTNAME_FROM_CONTAINER
+
+ /**
+ * Whether reporting should be enabled for this test.
+ * Add a `GebConfig.groovy` to customize the reporter configuration.
+ */
+ boolean reporting() default false
+
+ /**
+ * The {@link org.openqa.selenium.remote.FileDetector} implementation to use for this class.
+ * <p> {@link NullContainerFileDetector} results in the
+ * {@link grails.plugin.geb.serviceloader.ServiceRegistry last set} instance being used.
+ *
+ * @since 4.2
+ * @see grails.plugin.geb.DefaultContainerFileDetector DefaultContainerFileDetector
+ * @see grails.plugin.geb.UselessContainerFileDetector UselessContainerFileDetector
+ */
+ Class<? extends ContainerFileDetector> fileDetector() default DefaultContainerFileDetector
+}
+
+/**
+ * Inheritable version of {@link ContainerGebConfiguration}.
+ *
+ * @since 4.2
+ */
+interface IContainerGebConfiguration {
+
+ default String protocol() {
+ ContainerGebConfiguration.DEFAULT_PROTOCOL
+ }
+
+ default String hostName() {
+ ContainerGebConfiguration.DEFAULT_HOSTNAME_FROM_CONTAINER
+ }
+
+ default boolean reporting() {
+ false
+ }
+
+ default Class<? extends ContainerFileDetector> fileDetector() {
+ ContainerGebConfiguration.DEFAULT_FILE_DETECTOR
+ }
+}
diff --git a/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/ContainerGebSpec.groovy b/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/ContainerGebSpec.groovy
new file mode 100644
index 00000000..4d08d1e3
--- /dev/null
+++ b/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/ContainerGebSpec.groovy
@@ -0,0 +1,67 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.Page
+import geb.test.GebTestManager
+import grails.plugin.geb.support.ContainerSupport
+import grails.plugin.geb.support.ReportingSupport
+import grails.plugin.geb.support.delegate.BrowserDelegate
+import grails.plugin.geb.support.delegate.DownloadSupportDelegate
+import grails.plugin.geb.support.delegate.DriverDelegate
+import grails.plugin.geb.support.delegate.PageDelegate
+import groovy.transform.CompileStatic
+import spock.lang.Shared
+import spock.lang.Specification
+
+/**
+ * A {@link geb.spock.GebSpec GebSpec} that leverages Testcontainers
+ * to run the browser inside a container.
+ *
+ * <p>Prerequisites:
+ * <ul>
+ * <li>
+ * A <a href="https://java.testcontainers.org/supported_docker_environment/">compatible container runtime</a>
+ * (e.g., Docker) must be available for Testcontainers to utilize.
+ * </li>
+ * </ul>
+ *
+ * @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 4.1
+ */
+@CompileStatic
+abstract class ContainerGebSpec extends Specification implements ContainerSupport, ReportingSupport, BrowserDelegate, PageDelegate, DriverDelegate, DownloadSupportDelegate {
+
+ @Shared
+ static GebTestManager testManager
+
+ static void setTestManager(GebTestManager testManager) {
+ this.testManager = testManager
+ }
+
+ @Override
+ Page getPage() {
+ // Be explicit which trait to use (PageDelegate vs BrowserDelegate)
+ PageDelegate.super.page
+ }
+}
\ No newline at end of file
diff --git a/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/ContainerGebTestDescription.groovy b/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/ContainerGebTestDescription.groovy
new file mode 100644
index 00000000..65bb5f15
--- /dev/null
+++ b/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/ContainerGebTestDescription.groovy
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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
+
+/**
+ * Implements {@link org.testcontainers.lifecycle.TestDescription}
+ * to customize recording names.
+ *
+ * @author James Daugherty
+ * @since 4.1
+ */
+@CompileStatic
+class ContainerGebTestDescription implements TestDescription {
+
+ String testId
+ String filesystemFriendlyName
+
+ ContainerGebTestDescription(IterationInfo testInfo) {
+ testId = [
+ testInfo.feature.spec.displayName,
+ testInfo.feature.displayName,
+ testInfo.displayName != testInfo.feature.displayName ? testInfo.displayName : null,
+ testInfo.displayName != testInfo.feature.displayName ? testInfo.iterationIndex : null
+ ].findAll(/* Remove nulls */).join(' ')
+
+ def safeName = testId.replaceAll('\\W+', '_')
+ filesystemFriendlyName = safeName
+ }
+}
diff --git a/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/DefaultContainerFileDetector.groovy b/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/DefaultContainerFileDetector.groovy
new file mode 100644
index 00000000..a15a7e4c
--- /dev/null
+++ b/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/DefaultContainerFileDetector.groovy
@@ -0,0 +1,46 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.openqa.selenium.remote.LocalFileDetector
+import org.openqa.selenium.remote.UselessFileDetector
+
+/**
+ * Detects files on the local disk.
+ *
+ * @since 4.2
+ */
+class DefaultContainerFileDetector extends LocalFileDetector implements ContainerFileDetector {
+}
+
+/**
+ * A file detector that never finds anything.
+ *
+ * @since 4.2
+ */
+class UselessContainerFileDetector extends UselessFileDetector implements ContainerFileDetector {
+}
+
+/**
+ * Used by {@link ContainerGebConfiguration#fileDetector()} interface to represent a null value.
+ *
+ * @since 4.2
+ */
+class NullContainerFileDetector extends DefaultContainerFileDetector {
+}
\ No newline at end of file
diff --git a/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/GebOnFailureReporter.groovy b/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/GebOnFailureReporter.groovy
new file mode 100644
index 00000000..d80ed8aa
--- /dev/null
+++ b/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/GebOnFailureReporter.groovy
@@ -0,0 +1,47 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.opentest4j.IncompleteExecutionException
+import org.spockframework.runtime.extension.IMethodInterceptor
+import org.spockframework.runtime.extension.IMethodInvocation
+
+/**
+ * Adapts {@link geb.spock.OnFailureReporter} for use with
+ * {@link grails.plugin.geb.ContainerGebSpec}.
+ */
+@CompileStatic
+class GebOnFailureReporter implements IMethodInterceptor {
+ void intercept(IMethodInvocation invocation) throws Throwable {
+ try {
+ invocation.proceed()
+ } catch (IncompleteExecutionException notACauseForReporting) {
+ throw notACauseForReporting
+ } catch (Throwable throwable) {
+ def spec = invocation.instance as ContainerGebSpec
+ if (spec.testManager.reportingEnabled) {
+ try {
+ spec.testManager.reportFailure()
+ } catch (ignored) {}
+ }
+ throw throwable
+ }
+ }
+}
diff --git a/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/GebRecordingTestListener.groovy b/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/GebRecordingTestListener.groovy
new file mode 100644
index 00000000..83e040ee
--- /dev/null
+++ b/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/GebRecordingTestListener.groovy
@@ -0,0 +1,77 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 com.github.dockerjava.api.exception.NotFoundException
+import groovy.transform.CompileStatic
+import groovy.util.logging.Slf4j
+import org.spockframework.runtime.AbstractRunListener
+import org.spockframework.runtime.model.ErrorInfo
+import org.spockframework.runtime.model.IterationInfo
+
+/**
+ * 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 4.1
+ */
+@Slf4j
+@CompileStatic
+class GebRecordingTestListener extends AbstractRunListener {
+
+ WebDriverContainerHolder containerHolder
+ ErrorInfo errorInfo
+
+ GebRecordingTestListener(WebDriverContainerHolder containerHolder) {
+ this.containerHolder = containerHolder
+ }
+
+ @Override
+ void afterIteration(IterationInfo iteration) {
+ try {
+ containerHolder.container.afterTest(
+ new ContainerGebTestDescription(iteration),
+ Optional.ofNullable(errorInfo?.exception)
+ )
+ } catch (NotFoundException e) {
+ // Handle the case where VNC recording container doesn't have a recording file.
+ // This can happen when per-test recording is enabled and a test doesn't use the browser.
+ if (containerHolder.settings.restartRecordingContainerPerTest &&
+ e.message?.contains('/newScreen.mp4')) {
+ log.debug(
+ 'No VNC recording found for test [{}] - this is expected for tests that do not use a browser',
+ iteration.displayName
+ )
+ } else {
+ // Re-throw if it's a different type of NotFoundException
+ throw e
+ }
+ }
+ errorInfo = null
+ }
+
+ @Override
+ void error(ErrorInfo error) {
+ errorInfo = error
+ }
+}
\ No newline at end of file
diff --git a/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/GrailsContainerGebExtension.groovy b/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/GrailsContainerGebExtension.groovy
new file mode 100644
index 00000000..b976aebd
--- /dev/null
+++ b/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/GrailsContainerGebExtension.groovy
@@ -0,0 +1,145 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package grails.plugin.geb
+
+import grails.plugin.geb.support.LocalhostDownloadSupport
+import groovy.transform.CompileStatic
+import groovy.transform.TailRecursive
+import groovy.util.logging.Slf4j
+import org.spockframework.runtime.extension.IGlobalExtension
+import org.spockframework.runtime.model.MethodInfo
+import org.spockframework.runtime.model.SpecInfo
+import org.spockframework.runtime.model.parallel.ExclusiveResource
+import org.spockframework.runtime.model.parallel.ResourceAccessMode
+
+import java.time.LocalDateTime
+
+/**
+ * A Spock Extension that manages the Testcontainers
+ * lifecycle for a {@link grails.plugin.geb.ContainerGebSpec}.
+ *
+ * <p> ContainerGebSpec cannot be a
+ * {@link geb.test.ManagedGebTest ManagedGebTest} because it would cause the test
+ * manager to be initialized out of sequence of the container management.
+ * Instead, we initialize the same interceptors as the {@link geb.spock.GebExtension GebExtension} does.
+ *
+ * @author James Daugherty
+ * @since 4.1
+ */
+@Slf4j
+@CompileStatic
+class GrailsContainerGebExtension implements IGlobalExtension {
+
+ ExclusiveResource exclusiveResource
+ WebDriverContainerHolder holder
+
+ @Override
+ void start() {
+ exclusiveResource = new ExclusiveResource(
+ ContainerGebSpec.name,
+ ResourceAccessMode.READ_WRITE
+ )
+ holder = new WebDriverContainerHolder(
+ new GrailsGebSettings(LocalDateTime.now())
+ )
+ addShutdownHook {
+ holder.stop()
+ }
+ }
+
+ @Override
+ void stop() {
+ holder.stop()
+ }
+
+ @Override
+ void visitSpec(SpecInfo spec) {
+ if (isContainerGebSpec(spec) ) {
+ // Do not allow parallel execution since there's only 1 set of containers in testcontainers
+ spec.addExclusiveResource(exclusiveResource)
+
+ // Always initialize the container requirements first so the GebTestManager can properly configure the browser
+ spec.addSharedInitializerInterceptor { invocation ->
+ holder.reinitialize(invocation)
+
+ ContainerGebSpec gebSpec = invocation.sharedInstance as ContainerGebSpec
+ gebSpec.container = holder.container
+ gebSpec.testManager = holder.testManager
+ gebSpec.downloadSupport = new LocalhostDownloadSupport(
+ holder.browser,
+ holder.hostNameFromHost
+ )
+
+ // code below here is from the geb.spock.GebExtension since there can only be 1 shared initializer per extension
+ holder.testManager.beforeTestClass(invocation.spec.reflection)
+ invocation.proceed()
+ }
+
+ spec.addSetupInterceptor { invocation ->
+ // Grails will be initialized by this point, so setup the browser url correctly
+ holder.setupBrowserUrl(invocation)
+ invocation.proceed()
+ }
+
+ spec.addInterceptor { invocation ->
+ try {
+ invocation.proceed()
+ } finally {
+ holder.testManager.afterTestClass()
+ }
+ }
+
+ spec.allFeatures*.addIterationInterceptor { invocation ->
+ holder.restartVncRecordingContainer()
+
+ holder.testManager.beforeTest(invocation.instance.getClass(), invocation.iteration.displayName)
+ try {
+ invocation.proceed()
+ } finally {
+ holder.testManager.afterTest()
+ }
+ }
+
+ addGebExtensionOnFailureReporter(spec)
+
+ spec.addListener(new GebRecordingTestListener(holder))
+ }
+ }
+
+ @TailRecursive
+ private boolean isContainerGebSpec(SpecInfo spec) {
+ if (spec) {
+ if (spec.filename.startsWith("${ContainerGebSpec.simpleName}.")) {
+ return true
+ }
+ return isContainerGebSpec(spec.superSpec)
+ }
+ return false
+ }
+
+
+
+ private static void addGebExtensionOnFailureReporter(SpecInfo spec) {
+ List<MethodInfo> methods = spec.allFeatures*.featureMethod + spec.allFixtureMethods.toList()
+ methods.each {
+ it.addInterceptor(new GebOnFailureReporter())
+ }
+ }
+}
+
diff --git a/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/GrailsGebSettings.groovy b/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/GrailsGebSettings.groovy
new file mode 100644
index 00000000..55b6bd2e
--- /dev/null
+++ b/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/GrailsGebSettings.groovy
@@ -0,0 +1,158 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+
+import geb.waiting.Wait
+
+import static org.testcontainers.containers.BrowserWebDriverContainer.VncRecordingMode
+import static org.testcontainers.containers.VncRecordingContainer.VncRecordingFormat
+
+/**
+ * Handles parsing various recording configuration
+ * used by {@link GrailsContainerGebExtension}.
+ *
+ * @author James Daugherty
+ * @since 4.1
+ */
+@Slf4j
+@CompileStatic
+class GrailsGebSettings {
+
+ public static final boolean DEFAULT_AT_CHECK_WAITING = false
+ private static final VncRecordingMode DEFAULT_RECORDING_MODE = VncRecordingMode.SKIP
+ private static final VncRecordingFormat DEFAULT_RECORDING_FORMAT = VncRecordingFormat.MP4
+ public static final int DEFAULT_TIMEOUT_IMPLICITLY_WAIT = 0
+ public static final int DEFAULT_TIMEOUT_PAGE_LOAD = 300
+ public static final int DEFAULT_TIMEOUT_SCRIPT = 30
+
+ boolean tracingEnabled
+ String recordingDirectoryName
+ String reportingDirectoryName
+ String browserType
+ boolean restartRecordingContainerPerTest
+ VncRecordingMode recordingMode
+ VncRecordingFormat recordingFormat
+ LocalDateTime startTime
+ int implicitlyWait
+ int pageLoadTimeout
+ int scriptTimeout
+
+ boolean atCheckWaiting
+ Number timeout
+ Number retryInterval
+
+ GrailsGebSettings(LocalDateTime startTime) {
+ tracingEnabled = getBooleanProperty('grails.geb.tracing.enabled', false)
+ recordingDirectoryName = System.getProperty('grails.geb.recording.directory', 'build/gebContainer/recordings')
+ reportingDirectoryName = System.getProperty('grails.geb.reporting.directory', 'build/gebContainer/reports')
+ // browserType = System.getProperty('grails.geb.browser.type', DEFAULT_BROWSER_TYPE)
+ // browserType = System.getProperty('geb.env', DEFAULT_BROWSER_TYPE)
+ 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())
+ )
+ restartRecordingContainerPerTest = getBooleanProperty(
+ 'grails.geb.recording.restartRecordingContainerPerTest',
+ true
+ )
+ implicitlyWait = getIntProperty('grails.geb.timeouts.implicitlyWait', DEFAULT_TIMEOUT_IMPLICITLY_WAIT)
+ pageLoadTimeout = getIntProperty('grails.geb.timeouts.pageLoad', DEFAULT_TIMEOUT_PAGE_LOAD)
+ scriptTimeout = getIntProperty('grails.geb.timeouts.script', DEFAULT_TIMEOUT_SCRIPT)
+ atCheckWaiting = getBooleanProperty('grails.geb.atCheckWaiting.enabled', DEFAULT_AT_CHECK_WAITING)
+ timeout = getNumberProperty('grails.geb.timeouts.timeout', Wait.DEFAULT_TIMEOUT)
+ retryInterval = getNumberProperty('grails.geb.timeouts.retryInterval', Wait.DEFAULT_RETRY_INTERVAL)
+ this.startTime = startTime
+ }
+
+ private static boolean getBooleanProperty(String propertyName, boolean defaultValue) {
+ Boolean.parseBoolean(System.getProperty(propertyName, defaultValue.toString()))
+ }
+
+ private static int getIntProperty(String propertyName, int defaultValue) {
+ Integer.getInteger(propertyName, defaultValue) ?: defaultValue
+ }
+
+ private static Number getNumberProperty(String propertyName, Number defaultValue) {
+ def propValue = System.getProperty(propertyName)
+ if (propValue) {
+ try {
+ if (propValue.contains('.')) {
+ return new BigDecimal(propValue)
+ } else {
+ return Integer.parseInt(propValue)
+ }
+ } catch (NumberFormatException ignored) {
+ log.warn(
+ 'Could not parse property [{}] with value [{}] as a Number. Using default value [{}] instead.',
+ propertyName,
+ propValue,
+ defaultValue
+ )
+ }
+ }
+ return defaultValue
+ }
+
+ boolean isRecordingEnabled() {
+ recordingMode != VncRecordingMode.SKIP
+ }
+
+ @Memoized
+ File getRecordingDirectory() {
+ if (!recordingEnabled) {
+ return null
+ }
+ createDirectory(recordingDirectoryName, 'recording')
+ }
+
+ @Memoized
+ File getReportingDirectory() {
+ if (!reportingDirectoryName) {
+ return null
+ }
+ createDirectory(reportingDirectoryName, 'reporting')
+ }
+
+ private File createDirectory(String directoryName, String useCase) {
+ def dir = new File(
+ "$directoryName$File.separator${DateTimeFormatter.ofPattern('yyyyMMdd_HHmmss').format(startTime)}"
+ )
+ if (!dir.exists()) {
+ if (!dir.parentFile.exists()) {
+ log.info('Could not find [{}] directory for {}. Creating...', directoryName, useCase)
+ }
+ dir.mkdirs()
+ } else if (!dir.isDirectory()) {
+ throw new IllegalStateException(
+ "Configured $useCase directory [$dir] is expected to be a directory, but found file instead."
+ )
+ }
+
+ return dir
+ }
+}
diff --git a/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy b/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy
new file mode 100644
index 00000000..9beaa695
--- /dev/null
+++ b/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy
@@ -0,0 +1,561 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.openqa.selenium.firefox.FirefoxOptions
+
+import java.time.Duration
+import java.time.temporal.ChronoUnit
+import java.util.function.Supplier
+
+import groovy.transform.CompileStatic
+import groovy.transform.EqualsAndHashCode
+import groovy.transform.PackageScope
+import groovy.util.logging.Slf4j
+import org.codehaus.groovy.runtime.InvokerHelper
+
+import com.github.dockerjava.api.model.ContainerNetwork
+import geb.Browser
+import geb.Configuration
+import geb.ConfigurationLoader
+import geb.spock.SpockGebTestManagerBuilder
+import geb.test.GebTestManager
+import geb.waiting.Wait
+import org.openqa.selenium.SessionNotCreatedException
+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 org.testcontainers.containers.ContainerFetchException
+import org.testcontainers.containers.PortForwardingContainer
+import org.testcontainers.containers.SeleniumUtils
+import org.testcontainers.containers.VncRecordingContainer
+import org.testcontainers.images.PullPolicy
+import org.testcontainers.utility.DockerImageName
+
+import grails.plugin.geb.serviceloader.ServiceRegistry
+
+import static GrailsGebSettings.DEFAULT_AT_CHECK_WAITING
+import static GrailsGebSettings.DEFAULT_TIMEOUT_IMPLICITLY_WAIT
+import static GrailsGebSettings.DEFAULT_TIMEOUT_PAGE_LOAD
+import static GrailsGebSettings.DEFAULT_TIMEOUT_SCRIPT
+
+/**
+ * Responsible for initializing a {@link org.testcontainers.containers.BrowserWebDriverContainer}
+ * per the Spec's {@link grails.plugin.geb.ContainerGebConfiguration}. This class will try to
+ * reuse the same container if the configuration matches the current container.
+ *
+ * @author James Daugherty
+ * @since 4.1
+ */
+@Slf4j
+@CompileStatic
+class WebDriverContainerHolder {
+
+ /* Driver defaults to Firefox due to availability on AMD64 and ARM64 platforms.
+ * {@link https://github.com/SeleniumHQ/docker-selenium?tab=readme-ov-file#experimental-multi-arch-amd64aarch64armhf-images Selenium Browser Archetectures}
+ */
+
+ private static final String DEFAULT_HOSTNAME_FROM_HOST = 'localhost'
+ private static final String REMOTE_ADDRESS_PROPERTY = 'webdriver.remote.server'
+ private static final String DEFAULT_BROWSER = 'firefox'
+
+ GrailsGebSettings settings
+ GebTestManager testManager
+ Browser browser
+ BrowserWebDriverContainer container
+ WebDriverContainerConfiguration containerConf
+
+ WebDriverContainerHolder(GrailsGebSettings settings) {
+ this.settings = settings
+ }
+
+ boolean isInitialized() {
+ container != null
+ }
+
+ void stop() {
+ container?.stop()
+ container = null
+ browser = null
+ testManager = null
+ containerConf = null
+ }
+
+ boolean matchesCurrentContainerConfiguration(WebDriverContainerConfiguration specConf) {
+ specConf == containerConf &&
+ settings.recordingMode == BrowserWebDriverContainer.VncRecordingMode.SKIP
+ }
+
+ /**
+ * Returns a host port to use for the container in this order:
+ * Class field 'hostPort' if found in the invocation (Spec).
+ * A 'hostPort' property in the GebConfig configuration.
+ * @param methodInvocation
+ * @return
+ */
+ private static int findServerPort(IMethodInvocation methodInvocation) {
+ // use the test class hostPort if specified
+ try {
+ return (int) methodInvocation.instance.metaClass.getProperty(
+ methodInvocation.instance,
+ 'hostPort'
+ )
+ } catch (ignored) {
+ // Grails throws an IllegalStateException about their annotation here. We do not.
+ log.info("no hostPort from methodInvocation")
+ }
+
+ try {
+ // use geb config setting or default 8080
+ Configuration gebConfig = new ConfigurationLoader().conf
+ return (int) gebConfig.rawConfig.getProperty('hostPort')
+ } catch (ignored) {
+ log.info("no hostPort in config")
+ }
+
+ log.info("using default hostPort 8080")
+ return 8080
+ }
+
+ /**
+ * Returns a baseUrl from a setting if found in this order:
+ * A class field 'baseUrl' if found in the invocation (Spec).
+ * A 'baseUrl' property in the GebConfig configuration.
+ * Or an empty String if not found.
+ * @param methodInvocation
+ * @return
+ */
+ private static String findBaseUrl(IMethodInvocation methodInvocation) {
+ // use the test class baseUrl if specified
+ try {
+ String baseUrl = methodInvocation.instance.metaClass.getProperty(
+ methodInvocation.instance,
+ 'baseUrl'
+ )
+ if (baseUrl) {
+ log.info("using baseUrl: ${baseUrl} from method invocation.")
+ return baseUrl
+ }
+ } catch (ignored) {
+ log.info("no baseUrl from methodInvocation")
+ }
+
+ try {
+ // use geb config setting or default
+ Configuration gebConfig = new ConfigurationLoader().conf
+ String baseUrl = gebConfig.getProperty('baseUrl') ?: ""
+ if (baseUrl != "") {
+ log.info("using baseUrl: ${baseUrl} from configuration.")
+ return baseUrl
+ }
+ } catch (ignored) {
+ log.info("no baseUrl in config.")
+ }
+
+ log.info("no configured baseUrl found.")
+ return ""
+
+ }
+
+
+ @PackageScope
+ boolean reinitialize(IMethodInvocation methodInvocation) {
+ def specConf = new WebDriverContainerConfiguration(
+ methodInvocation.spec
+ )
+ if (matchesCurrentContainerConfiguration(specConf)) {
+ return false
+ }
+
+ if (initialized) {
+ stop()
+ }
+
+ def gebConf = new ConfigurationLoader().conf
+ def gebConfigExists = gebConf.rawConfig.size() != 0
+ def dockerImageName = createDockerImageName(DEFAULT_BROWSER)
+ def customBrowser = gebConf.rawConfig.containerBrowser as String
+
+ if (gebConfigExists) {
+ log.info("A Geb configuration exists...")
+ validateDriverConf(gebConf)
+ if (customBrowser) {
+ log.info(
+ 'A \'containerBrowser\' property was found in GebConfig. ' +
+ "Using [$customBrowser] container image."
+ )
+ // Prepare for creating a container matching
+ // the GebConfig `containerBrowser` property.
+ dockerImageName = createDockerImageName(customBrowser)
+ } else {
+ log.info(
+ 'No \'containerBrowser\' property found in GebConfig. ' +
+ "Using default [$DEFAULT_BROWSER] container image."
+ )
+ }
+ }
+
+ containerConf = specConf
+ container = new BrowserWebDriverContainer(dockerImageName).withRecordingMode(
+ settings.recordingMode,
+ settings.recordingDirectory,
+ settings.recordingFormat
+ )
+
+ container.with {
+ withEnv('SE_ENABLE_TRACING', settings.tracingEnabled.toString())
+ withAccessToHost(true)
+ withImagePullPolicy(PullPolicy.ageBased(Duration.of(1, ChronoUnit.DAYS)))
+ // start() // without Capabilities this is starting chrome
+ }
+
+ startContainer(container, dockerImageName, customBrowser)
+
+
+ if (hostnameChanged) {
+ container.execInContainer(
+ '/bin/sh', '-c',
+ "echo '$hostIp\t$containerConf.hostName' | sudo tee -a /etc/hosts"
+ )
+ }
+
+ // Ensure that the driver points to the re-initialized container with the correct host.
+ // The driver is explicitly quit by us in stop() method, to fulfill our resulting responsibility.
+ gebConf.cacheDriver = false
+
+ // As we don't cache, this will have been defaulted to true. We override to false.
+ gebConf.quitDriverOnBrowserReset = false
+
+ gebConf.baseUrl = container.seleniumAddress
+ if (containerConf.reporting) {
+ gebConf.reportsDir = settings.reportingDirectory
+ gebConf.reporter = (methodInvocation.sharedInstance as ContainerGebSpec).createReporter()
+ }
+
+ if (gebConf.driverConf) {
+ // As a custom `GebConfig` cannot know the `remoteAddress` of the container beforehand,
+ // the `RemoteWebDriver` will be instantiated using the `webdriver.remote.server`
+ // system property. We set that property to inform the driver of the container address.
+ gebConf.driverConf = ClosureDecorators.withSystemProperty(
+ gebConf.driverConf as Closure,
+ REMOTE_ADDRESS_PROPERTY,
+ container.seleniumAddress
+ )
+ } else {
+ // If no driver was set in GebConfig, create a Firefox driver
+ gebConf.driverConf = { ->
+ log.info('Using default Firefox RemoteWebDriver for {}', container.seleniumAddress)
+ new RemoteWebDriver(container.seleniumAddress, new FirefoxOptions())
+ }
+ }
+
+ browser = createBrowser(gebConf)
+ applyFileDetector(browser, containerConf)
+ applyTimeouts(browser, settings)
+
+ // There's a bit of a chicken and egg problem here: the container and browser are initialized
+ // when the static/shared fields are initialized, which is before the grails server has started
+ // so the real url cannot be set (it will be checked as part of the geb test manager startup in
+ // reporting mode). We set the url to localhost, which the selenium server should respond to
+ // (albeit with an error that will be ignored).
+ browser.baseUrl = 'http://localhost'
+
+ testManager = createTestManager()
+
+ return true
+ }
+
+ private static Browser createBrowser(Configuration gebConf) {
+ def browser = new Browser(gebConf)
+ try {
+ browser.driver
+ }
+ catch (SessionNotCreatedException e) {
+ throw new IllegalStateException(
+ 'Failed to create a remote browser session. ' +
+ 'Did you set a \'containerBrowser\' property ' +
+ 'corresponding to the \'driver\' in GebConfig?',
+ e
+ )
+ }
+ browser
+ }
+
+ private static void applyFileDetector(Browser browser, WebDriverContainerConfiguration conf) {
+ if (conf.fileDetector != NullContainerFileDetector) {
+ ServiceRegistry.setInstance(ContainerFileDetector, conf.fileDetector)
+ }
+ ((RemoteWebDriver) browser.driver).fileDetector = ServiceRegistry.getInstance(
+ ContainerFileDetector,
+ DefaultContainerFileDetector
+ )
+ }
+
+ private static void applyTimeouts(Browser browser, GrailsGebSettings settings) {
+ // Overwrite `GebConfig` timeouts with values explicitly set in
+ // `GrailsGebSettings` (via system properties)
+ if (settings.implicitlyWait != DEFAULT_TIMEOUT_IMPLICITLY_WAIT)
+ browser.driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(settings.implicitlyWait))
+ if (settings.pageLoadTimeout != DEFAULT_TIMEOUT_PAGE_LOAD)
+ browser.driver.manage().timeouts().pageLoadTimeout(Duration.ofSeconds(settings.pageLoadTimeout))
+ if (settings.scriptTimeout != DEFAULT_TIMEOUT_SCRIPT)
+ browser.driver.manage().timeouts().scriptTimeout(Duration.ofSeconds(settings.scriptTimeout))
+ if (settings.atCheckWaiting != DEFAULT_AT_CHECK_WAITING)
+ browser.config.atCheckWaiting = settings.atCheckWaiting
+ if (settings.timeout != Wait.DEFAULT_TIMEOUT)
+ (browser.config.rawConfig.waiting as ConfigObject).timeout = settings.timeout
+ if (settings.retryInterval != Wait.DEFAULT_RETRY_INTERVAL)
+ (browser.config.rawConfig.waiting as ConfigObject).retryInterval = settings.retryInterval
+ }
+
+ private static void startContainer(BrowserWebDriverContainer container, DockerImageName dockerImageName, String customBrowser) {
+ try {
+ container.start()
+ } catch (ContainerFetchException e) {
+ if (customBrowser) {
+ throw new IllegalStateException(
+ "Could not find the Docker image [$dockerImageName] " +
+ "with the browser name from the 'containerBrowser' [$customBrowser] " +
+ 'property specified in GebConfig. ' +
+ 'See https://hub.docker.com/u/selenium for a list of available images.',
+ e
+ )
+ }
+ throw e
+ }
+ }
+
+ void setupBrowserUrl(IMethodInvocation methodInvocation) {
+ if (!browser) return
+
+ // use a configured baseUrl if found, otherwise use localhost and hostPort
+ def baseUrl = findBaseUrl(methodInvocation)
+ if (baseUrl != "") {
+ browser.baseUrl = baseUrl
+ } else {
+ int hostPort = findServerPort(methodInvocation)
+ Testcontainers.exposeHostPorts(hostPort)
+ browser.baseUrl = "$containerConf.protocol://$containerConf.hostName:$hostPort"
+ }
+
+ }
+
+ private GebTestManager createTestManager() {
+ new SpockGebTestManagerBuilder()
+ .withReportingEnabled(containerConf.reporting)
+ .withBrowserCreator(
+ new Supplier<Browser>() {
+ @Override
+ Browser get() {
+ browser
+ }
+ }
+ )
+ .build()
+ }
+
+ private boolean isHostnameChanged() {
+ containerConf.hostName != ContainerGebConfiguration.DEFAULT_HOSTNAME_FROM_CONTAINER
+ }
+
+ private static String getHostIp() {
+ try {
+ PortForwardingContainer.getDeclaredMethod('getNetwork').with {
+ accessible = true
+ (invoke(PortForwardingContainer.INSTANCE) as Optional<ContainerNetwork>)
+ .get()
+ .ipAddress
+ }
+ } catch (Exception e) {
+ throw new RuntimeException(
+ 'Could not access network from PortForwardingContainer',
+ e
+ )
+ }
+ }
+
+ /**
+ * Returns the hostname that the server under test is available on from the host.
+ * <p>This is useful when using any of the {@code download*()} methods as they will
+ * connect from the host, and not from within the container.
+ *
+ * <p>Defaults to {@code localhost}. If the value returned by {@code webDriverContainer.getHost()}
+ * is different from the default, this method will return the same value same as
+ * {@code webDriverContainer.getHost()}.
+ *
+ * @return the hostname for accessing the server under test from the host
+ */
+ String getHostNameFromHost() {
+ hostNameChanged ? container.host : DEFAULT_HOSTNAME_FROM_HOST
+ }
+
+ private boolean isHostNameChanged() {
+ container.host != ContainerGebConfiguration.DEFAULT_HOSTNAME_FROM_CONTAINER
+ }
+
+ private static DockerImageName createDockerImageName(Object browserName) {
+ DockerImageName.parse(
+ "selenium/standalone-$browserName:$seleniumVersion"
+ )
+ }
+
+ private static void validateDriverConf(Configuration gebConf) {
+ if (gebConf.driverConf && !(gebConf.driverConf instanceof Closure)) {
+ throw new IllegalStateException(
+ 'The \'driver\' property of GebConfig must be a ' +
+ 'Closure that returns an instance of RemoteWebDriver.'
+ )
+ }
+ }
+
+ private static String getSeleniumVersion() {
+ SeleniumUtils.determineClasspathSeleniumVersion()
+ }
+
+ @CompileStatic
+ @EqualsAndHashCode
+ private static class WebDriverContainerConfiguration {
+
+ String protocol
+ String hostName
+ boolean reporting
+ Class<? extends ContainerFileDetector> fileDetector
+
+ WebDriverContainerConfiguration(SpecInfo spec) {
+ ContainerGebConfiguration conf
+
+ // Check if the class implements the interface
+ if (IContainerGebConfiguration.isAssignableFrom(spec.reflection)) {
+ conf = spec.reflection.getConstructor().newInstance() as ContainerGebConfiguration
+ } else {
+ // Check for the annotation
+ conf = spec.annotations.find {
+ it.annotationType() == ContainerGebConfiguration
+ } as ContainerGebConfiguration
+ }
+
+ protocol = conf?.protocol() ?: ContainerGebConfiguration.DEFAULT_PROTOCOL
+ hostName = conf?.hostName() ?: ContainerGebConfiguration.DEFAULT_HOSTNAME_FROM_CONTAINER
+ reporting = conf?.reporting() ?: false
+ fileDetector = conf?.fileDetector() ?: ContainerGebConfiguration.DEFAULT_FILE_DETECTOR
+ }
+ }
+
+ /**
+ * Workaround for https://github.com/testcontainers/testcontainers-java/issues/3998
+ * <p>
+ * Restarts the VNC recording container to enable separate recording files for each
+ * test method. This method uses reflection to access the VNC recording container
+ * field in BrowserWebDriverContainer. Should be called BEFORE each test starts.
+ */
+ void restartVncRecordingContainer() {
+ if (!settings.recordingEnabled || !settings.restartRecordingContainerPerTest || !container) {
+ return
+ }
+ try {
+ // Use reflection to access the VNC recording container field
+ def field = BrowserWebDriverContainer.getDeclaredField('vncRecordingContainer').tap {
+ accessible = true
+ }
+
+ def vncContainer = field.get(container) as VncRecordingContainer
+ if (vncContainer) {
+ // Stop the current VNC recording container
+ vncContainer.stop()
+ // Create and start a new VNC recording container for the next test
+ def newVncContainer = new VncRecordingContainer(container)
+ .withVncPassword('secret')
+ .withVncPort(5900)
+ .withVideoFormat(settings.recordingFormat)
+ field.set(container, newVncContainer)
+ newVncContainer.start()
+
+ log.debug('Successfully restarted VNC recording container')
+ }
+ } catch (Exception e) {
+ log.warn("Failed to restart VNC recording container: $e.message", e)
+ // Don't throw the exception to avoid breaking the test execution
+ }
+ }
+
+ @CompileStatic
+ private static class ClosureDecorators {
+
+ /**
+ * Wraps a closure so that during its execution, System.getProperty(key)
+ * returns a custom value instead of what is actually in the system properties.
+ */
+ static Closure withSystemProperty(Closure target, String key, Object value) {
+ Closure wrapped = { Object... args ->
+ SysPropScope.withProperty(key, value.toString()) {
+ InvokerHelper.invokeClosure(target, args)
+ }
+ }
+
+ // keep original closure semantics
+ wrapped.rehydrate(target.delegate, target.owner, target.thisObject).tap {
+ resolveStrategy = target.resolveStrategy
+ }
+ }
+
+ @CompileStatic
+ private static class SysPropScope {
+
+ private static final ThreadLocal<Map<String,String>> OVERRIDDEN_SYSTEM_PROPERTIES =
+ ThreadLocal.withInitial { [:] as Map<String,String> }
+
+ @Lazy // Thread-safe wrapping of system properties
+ private static Properties propertiesWrappedOnFirstAccess = {
+ new InterceptingProperties().tap {
+ putAll(System.getProperties())
+ System.setProperties(it)
+ }
+ }()
+
+ static <T> T withProperty(String key, String value, Closure<T> body) {
+ propertiesWrappedOnFirstAccess // Access property to trigger property wrapping
+ def map = OVERRIDDEN_SYSTEM_PROPERTIES.get()
+ def prev = map.put(key, value)
+ try {
+ return body.call()
+ } finally {
+ if (prev == null) map.remove(key) else map[key] = prev
+ if (map.isEmpty()) OVERRIDDEN_SYSTEM_PROPERTIES.remove()
+ }
+ }
+
+ @CompileStatic
+ private static class InterceptingProperties extends Properties {
+ @Override
+ String getProperty(String key) {
+ def v = OVERRIDDEN_SYSTEM_PROPERTIES.get().get(key)
+ v != null ? v : super.getProperty(key)
+ }
+ }
+ }
+ }
+
+
+
+
+
+}
diff --git a/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/serviceloader/ServiceRegistry.groovy b/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/serviceloader/ServiceRegistry.groovy
new file mode 100644
index 00000000..7b18c118
--- /dev/null
+++ b/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/serviceloader/ServiceRegistry.groovy
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.serviceloader
+
+import groovy.transform.CompileStatic
+
+/**
+ * A service registry that loads and caches different service types using the Java
+ * {@link java.util.ServiceLoader}, while allowing overriding which instance to return.
+ * <p>
+ * This class provides thread-safe service loading and caching, supporting parallel test execution.
+ * It provides both automatic service discovery through {@link java.util.ServiceLoader}
+ * and explicit replacement of the returned service instance for customization.
+ * </p>
+ * <p>
+ * Usage example:
+ * <pre><code>
+ * MyService service = ServiceRegistry.getInstance(MyService, DefaultMyService)
+ * </code></pre>
+ * </p>
+ *
+ * @since 4.2
+ * @author Mattias Reichel
+ */
+@CompileStatic
+class ServiceRegistry {
+
+ private static final ThreadLocal<HashMap<Class<?>, Object>> INSTANCES = ThreadLocal.withInitial {
+ new HashMap<Class<?>, Object>()
+ }
+
+ /**
+ * Returns the service instance of the given service type, loading it using
+ * {@link java.util.ServiceLoader} if not already loaded or an instance
+ * of the default implementation type if no service implementation is found
+ * by the {@link java.util.ServiceLoader}.
+ *
+ * If an instance has been set using {@link #setInstance(Class, Object)} or
+ * {@link #setInstance(Class, Class)}, that instance will be returned instead.
+ *
+ * @param serviceType The service type
+ * @param defaultType The service implementation type to use if no service
+ * implementation is found (Must have a zero-argument constructor)
+ * @return An instance of the service type
+ */
+ static <T> T getInstance(Class<T> serviceType, Class<? extends T> defaultType) {
+ (T) INSTANCES.get().computeIfAbsent(serviceType) {
+ ServiceLoader.load(serviceType)
+ .findFirst()
+ .orElseGet { defaultType.getDeclaredConstructor().newInstance() }
+ }
+ }
+
+ /**
+ * Sets the instance to be returned for the given service type, bypassing instance
+ * loading from {@link java.util.ServiceLoader}.
+ * <p>
+ * Setting the instance to {@code null} will revert to loading the the service instance
+ * via the {@link java.util.ServiceLoader}.
+ * </p>
+ * @param serviceType The service type for which the instance should be set
+ * @param instance The instance to return for the given service type, or
+ * {@code null} for default service loading
+ */
+ static <T> void setInstance(Class<T> serviceType, T instance) {
+ INSTANCES.get().put(serviceType, instance)
+ }
+
+ /**
+ * Sets the implementation type to return for the given service type, bypassing instance loading
+ * from {@link java.util.ServiceLoader}.
+ *
+ * @param serviceType The service type for which the instance should be set
+ * @param instanceType The type of the instance to return for the given service type.
+ * (Must have a zero-argument constructor).
+ */
+ static <T> void setInstance(Class<T> serviceType, Class<? extends T> instanceType) {
+ setInstance(serviceType, instanceType.getDeclaredConstructor().newInstance())
+ }
+}
diff --git a/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/support/BrowserType.groovy b/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/support/BrowserType.groovy
new file mode 100644
index 00000000..4ebefd32
--- /dev/null
+++ b/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/support/BrowserType.groovy
@@ -0,0 +1,30 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.support
+
+/**
+ *
+ */
+enum BrowserType {
+
+ CHROME,
+ FIREFOX,
+ EDGE
+
+}
\ No newline at end of file
diff --git a/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/support/ContainerGebFileInputSource.groovy b/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/support/ContainerGebFileInputSource.groovy
new file mode 100644
index 00000000..5ec39d57
--- /dev/null
+++ b/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/support/ContainerGebFileInputSource.groovy
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.support
+
+/**
+ * A File implementation specifically for assigning to the Geb FileInput module when using ContainerGebSpec.
+ * This will normalize the path for cross-platform compatibility (Windows paths with in Linux containers).
+ *
+ * @author Mattias Reichel
+ * @since 4.2
+ */
+class ContainerGebFileInputSource extends File {
+
+ String normalizedPath
+
+ ContainerGebFileInputSource(String path) {
+ super(path)
+ normalizedPath = normalizePath(path)
+ }
+
+ @Override
+ String getAbsolutePath() {
+ return normalizedPath
+ }
+
+ /**
+ * Normalizes the path for cross-platform compatibility (Windows paths used in Linux containers).
+ *
+ * @param path the original file path
+ * @return the normalized path with forward slashes
+ */
+ protected String normalizePath(String path) {
+ // Normalize separator to forward slash
+ path.replace(separatorChar, (char) '/')
+ }
+}
diff --git a/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/support/ContainerSupport.groovy b/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/support/ContainerSupport.groovy
new file mode 100644
index 00000000..1af66662
--- /dev/null
+++ b/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/support/ContainerSupport.groovy
@@ -0,0 +1,82 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.support
+
+import geb.download.DownloadSupport
+import grails.plugin.geb.ContainerGebSpec
+import groovy.transform.CompileStatic
+import groovy.transform.SelfType
+import org.testcontainers.containers.BrowserWebDriverContainer
+import org.testcontainers.images.builder.Transferable
+import spock.lang.Shared
+
+/**
+ * Features for supporting Geb tests running in a container.
+ *
+ * @author Mattias Reichel
+ * @since 4.2
+ */
+@CompileStatic
+@SelfType(ContainerGebSpec)
+trait ContainerSupport implements DownloadSupport {
+
+ /**
+ * Get access to container running the web-driver, for convenience to execInContainer, copyFileToContainer etc.
+ *
+ * @see org.testcontainers.containers.ContainerState#execInContainer(java.lang.String ...)
+ * @see org.testcontainers.containers.ContainerState#copyFileToContainer(org.testcontainers.utility.MountableFile, java.lang.String)
+ * @see org.testcontainers.containers.ContainerState#copyFileFromContainer(java.lang.String, java.lang.String)
+ * @see org.testcontainers.containers.ContainerState
+ */
+ @Shared
+ static BrowserWebDriverContainer container
+
+ static void setContainer(BrowserWebDriverContainer container) {
+ this.container = container
+ }
+
+ @Shared
+ static DownloadSupport downloadSupport
+
+ /**
+ * Sets the {@link DownloadSupport} instance to use for file downloads.
+ * This allows for setting a custom implementation of {@code DownloadSupport}
+ * when downloading from a container.
+ *
+ * @param downloadSupport the {@code DownloadSupport} instance to use
+ * @since 4.1
+ */
+ static void setDownloadSupport(DownloadSupport downloadSupport) {
+ this.downloadSupport = downloadSupport
+ }
+
+ /**
+ * Copies a file from the host to the container for assignment to a Geb FileInput module.
+ * This method is useful when you need to upload a file to a form in a Geb test and will work cross-platform.
+ *
+ * @param hostPath relative path to the file on the host
+ * @param containerPath absolute path to where to put the file in the container
+ * @return the file object to assign to the FileInput module
+ * @since 4.2
+ */
+ File createFileInputSource(String hostPath, String containerPath) {
+ container.copyFileToContainer(Transferable.of(new File(hostPath).bytes), containerPath)
+ return new ContainerGebFileInputSource(containerPath)
+ }
+}
\ No newline at end of file
diff --git a/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/support/LocalhostDownloadSupport.groovy b/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/support/LocalhostDownloadSupport.groovy
new file mode 100644
index 00000000..8f782c97
--- /dev/null
+++ b/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/support/LocalhostDownloadSupport.groovy
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.support
+
+import geb.Browser
+import geb.download.DefaultDownloadSupport
+import groovy.transform.CompileStatic
+
+import java.util.regex.Pattern
+
+/**
+ * @author Mattias Reichel
+ * @since 4.1
+ */
+@CompileStatic
+class LocalhostDownloadSupport extends DefaultDownloadSupport {
+
+ private final static Pattern urlPattern = ~/(https?:\/\/)([^\/:]+)(:\d+\/.*)/
+
+ private final String hostNameFromHost
+ private final Browser browser
+
+ LocalhostDownloadSupport(Browser browser, String hostNameFromHost) {
+ super(browser)
+ this.browser = browser
+ this.hostNameFromHost = hostNameFromHost
+ }
+
+ @Override
+ HttpURLConnection download(Map options) {
+ return super.download([*: options, base: resolveBase(options)])
+ }
+
+ private String resolveBase(Map options) {
+ return options.base ?: browser.driver.currentUrl.replaceAll(urlPattern) { match, proto, host, rest ->
+ "${proto}${hostNameFromHost}${rest}"
+ }
+ }
+}
diff --git a/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/support/ReportingSupport.groovy b/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/support/ReportingSupport.groovy
new file mode 100644
index 00000000..4821d490
--- /dev/null
+++ b/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/support/ReportingSupport.groovy
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.support
+
+import geb.report.CompositeReporter
+import geb.report.PageSourceReporter
+import geb.report.Reporter
+import geb.report.ScreenshotReporter
+import grails.plugin.geb.ContainerGebSpec
+import groovy.transform.CompileStatic
+import groovy.transform.SelfType
+
+/**
+ * Enables reporting support for ContainerGebSpec.
+ *
+ * @author James Daugherty
+ * @author Mattias Reichel
+ * @since 4.2
+ */
+@CompileStatic
+@SelfType(ContainerGebSpec)
+trait ReportingSupport {
+
+ void report(String message) {
+ ContainerGebSpec.testManager.report(message)
+ }
+
+ /**
+ * The reporter that Geb should use when reporting is enabled.
+ */
+ Reporter createReporter() {
+ return new CompositeReporter(new PageSourceReporter(), new ScreenshotReporter())
+ }
+}
diff --git a/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/support/delegate/BrowserDelegate.groovy b/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/support/delegate/BrowserDelegate.groovy
new file mode 100644
index 00000000..0ad9763a
--- /dev/null
+++ b/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/support/delegate/BrowserDelegate.groovy
@@ -0,0 +1,429 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.support.delegate
+
+import geb.Browser
+import geb.Page
+import geb.js.JavascriptInterface
+import geb.url.UrlFragment
+import geb.webstorage.WebStorage
+import grails.plugin.geb.ContainerGebSpec
+import groovy.transform.CompileDynamic
+import groovy.transform.CompileStatic
+import groovy.transform.SelfType
+import org.openqa.selenium.WebDriver
+
+import java.time.Duration
+
+import static groovy.lang.Closure.DELEGATE_FIRST
+
+/**
+ * Handles delegation to the browser instance so that the Geb API can be used directly in the test.
+ * <p>
+ * As method parameter names are not available in the Geb artifacts we are delegating manually,
+ * instead of using @Delegate AST transform, to get the best possible end user IDE support and user experience.
+ *
+ * @author Mattias Reichel
+ * @since 4.2
+ */
+@CompileStatic
+@SelfType(ContainerGebSpec)
+trait BrowserDelegate {
+
+ /**
+ * Accessor to the Geb {@link geb.Browser} instance.
+ */
+ Browser getBrowser() {
+ ContainerGebSpec.testManager.browser
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#getDriver()}.
+ */
+ WebDriver getDriver() {
+ browser.driver
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#getCurrentUrl()}.
+ */
+ String getCurrentUrl() {
+ browser.currentUrl
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#getPage()}.
+ */
+ Page getPage() {
+ browser.page
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#at(Class)}.
+ */
+ <T extends Page> T at(Class<T> pageType) {
+ browser.at(pageType)
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#at(Class, Closure)}.
+ */
+ <T extends Page, R> R at(Class<T> pageType, @DelegatesTo(strategy = DELEGATE_FIRST, genericTypeIndex = 0) Closure<R> assertions) {
+ browser.at(at(pageType), assertions)
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#at(Page)}.
+ */
+ <T extends Page> T at(T page) {
+ browser.at(page)
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#isAt(Class, boolean)}.
+ */
+ boolean isAt(Class<? extends Page> pageType, boolean honourGlobalAtCheckWaiting = true) {
+ browser.isAt(pageType, honourGlobalAtCheckWaiting)
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#isAt(Page, boolean)}.
+ */
+ boolean isAt(Page page, boolean honourGlobalAtCheckWaiting = true) {
+ browser.isAt(page, honourGlobalAtCheckWaiting)
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#checkIfAtAnUnexpectedPage(Class[])}.
+ */
+ void checkIfAtAnUnexpectedPage(Class<? extends Page>[] expectedPages) {
+ browser.checkIfAtAnUnexpectedPage(expectedPages)
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#checkIfAtAnUnexpectedPage(Page[])}.
+ */
+ void checkIfAtAnUnexpectedPage(Page[] expectedPages) {
+ browser.checkIfAtAnUnexpectedPage(expectedPages)
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#go(String)}.
+ */
+ void go(String url) {
+ browser.go(url)
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#go(String, UrlFragment)}.
+ */
+ void go(String url, UrlFragment fragment) {
+ browser.go(url, fragment)
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#go(Map, UrlFragment)}.
+ */
+ void go(Map params = [:], UrlFragment fragment) {
+ browser.go(params, fragment)
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#go(Map, String, UrlFragment)}.
+ */
+ void go(Map params = [:], String url = null, UrlFragment fragment = null) {
+ browser.go(params, url, fragment)
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#to(Map, Class, Object[])}.
+ */
+ <T extends Page> T to(Map params = [:], Class<T> pageType, Object[] args) {
+ browser.to(params, pageType, args)
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#to(Map, Class, UrlFragment, Object[])}.
+ */
+ <T extends Page> T to(Map params = [:], Class<T> pageType, UrlFragment fragment, Object[] args) {
+ browser.to(params, pageType, fragment, args)
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#to(Map, Page, Object[])}.
+ */
+ <T extends Page> T to(Map params = [:], T page, Object[] args) {
+ browser.to(params, page, args)
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#to(Map, Page, UrlFragment, Object[])}.
+ */
+ <T extends Page> T to(Map params = [:], T page, UrlFragment fragment, Object[] args) {
+ browser.to(params, page, fragment, args)
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#via(Map, Class, Object[])}.
+ */
+ <T extends Page> T via(Map params = [:], Class<T> pageType, Object[] args) {
+ browser.via(params, pageType, args)
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#via(Map, Class, UrlFragment, Object[])}.
+ */
+ <T extends Page> T via(Map params = [:], Class<T> pageType, UrlFragment fragment, Object[] args) {
+ browser.via(params, pageType, fragment, args)
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#via(Map, Page, Object[])}.
+ */
+ <T extends Page> T via(Map params = [:], T page, Object[] args) {
+ browser.via(params, page, args)
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#via(Map, Page, UrlFragment, Object[])}.
+ */
+ <T extends Page> T via(Map params = [:], T page, UrlFragment fragment, Object[] args) {
+ browser.via(params, page, fragment, args)
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#clearCookies(String[])}.
+ */
+ void clearCookies(String... additionalUrls) {
+ browser.clearCookies(additionalUrls)
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#clearCookies()}.
+ */
+ void clearCookies() {
+ browser.clearCookies()
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#clearCookiesQuietly()}.
+ */
+ void clearCookiesQuietly() {
+ browser.clearCookiesQuietly()
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#clearWebStorage()}.
+ */
+ void clearWebStorage() {
+ browser.clearWebStorage()
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#clearWebStorageQuietly()}.
+ */
+ void clearWebStorageQuietly() {
+ browser.clearWebStorageQuietly()
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#quit()}.
+ */
+ void quit() {
+ browser.quit()
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#close()}.
+ */
+ void close() {
+ browser.close()
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#getAvailableWindows()}.
+ */
+ Set<String> getAvailableWindows() {
+ browser.getAvailableWindows()
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#withWindow(String, Closure)}.
+ */
+ <T> T withWindow(String window, @DelegatesTo(value = Browser, strategy = DELEGATE_FIRST) Closure<T> block) {
+ browser.withWindow(window, block)
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#withWindow(Closure, Closure)}.
+ */
+ <T> List<T> withWindow(Closure specification, @DelegatesTo(value = Browser, strategy = DELEGATE_FIRST) Closure<T> block) {
+ browser.withWindow(specification, block)
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#withWindow(Map, Closure, Closure)}.
+ */
+ <T> List<T> withWindow(Map options, Closure specification, @DelegatesTo(value = Browser, strategy = DELEGATE_FIRST) Closure<T> block) {
+ browser.withWindow(options, specification, block)
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#withWindow(Map, String, Closure)}.
+ */
+ <T> T withWindow(Map options, String window, @DelegatesTo(value = Browser, strategy = DELEGATE_FIRST) Closure<T> block) {
+ browser.withWindow(options, window, block)
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#withWindow(Map, Closure, Closure)}.
+ */
+ <T> T withNewWindow(Map options, Closure windowOpeningBlock, @DelegatesTo(value = Browser, strategy = DELEGATE_FIRST) Closure<T> block) {
+ browser.withNewWindow(options, windowOpeningBlock, block)
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#withNewWindow(Closure, Closure)}.
+ */
+ <T> T withNewWindow(Closure windowOpeningBlock, @DelegatesTo(value = Browser, strategy = DELEGATE_FIRST) Closure<T> block) {
+ browser.withNewWindow(windowOpeningBlock, block)
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#createPage(Class)}.
+ */
+ <T extends Page> T createPage(Class<T> pageType) {
+ browser.createPage(pageType)
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#getJs()}.
+ */
+ JavascriptInterface getJs() {
+ browser.js
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#getReportGroupDir()}.
+ */
+ File getReportGroupDir() {
+ browser.reportGroupDir
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#reportGroup(String)}.
+ */
+ void reportGroup(String path) {
+ browser.reportGroup(path)
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#reportGroup(Class)}.
+ */
+ void reportGroup(Class clazz) {
+ browser.reportGroup(clazz)
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#cleanReportGroupDir()}.
+ */
+ void cleanReportGroupDir() {
+ browser.cleanReportGroupDir()
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#pause()}.
+ */
+ void pause() {
+ browser.pause()
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#getLocalStorage()}.
+ */
+ WebStorage getLocalStorage() {
+ browser.localStorage
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#getSessionStorage()}.
+ */
+ WebStorage getSessionStorage() {
+ browser.sessionStorage
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#verifyAtImplicitly(Class)}.
+ */
+ void verifyAtImplicitly(Class<? extends Page> targetPage) {
+ browser.verifyAtImplicitly(targetPage)
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#verifyAtImplicitly(Page)}.
+ */
+ void verifyAtImplicitly(Page targetPage) {
+ browser.verifyAtImplicitly(targetPage)
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#setNetworkLatency(Duration)}.
+ */
+ void setNetworkLatency(Duration duration) {
+ browser.setNetworkLatency(duration)
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#resetNetworkLatency()}.
+ */
+ void resetNetworkLatency() {
+ browser.resetNetworkLatency()
+ }
+
+ /**
+ * Delegates to {@link geb.Browser#driverAs(Class)}.
+ */
+ <T> Optional<T> driverAs(Class<T> castType) {
+ browser.driverAs(castType)
+ }
+
+ /**
+ * Delegates missing method calls to the current {@link geb.Page} instance.
+ */
+ @CompileDynamic
+ def methodMissing(String name, args) {
+ browser.page."$name"(*args)
+ }
+
+ /**
+ * Delegates missing property accesses to the current {@link geb.Page} instance.
+ */
+ @CompileDynamic
+ def propertyMissing(String name) {
+ browser.page."$name"
+ }
+
+ /**
+ * Delegates missing property mutations to the current {@link geb.Page} instance.
+ */
+ @CompileDynamic
+ def propertyMissing(String name, value) {
+ browser.page."$name" = value
+ }
+}
\ No newline at end of file
diff --git a/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/support/delegate/DownloadSupportDelegate.groovy b/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/support/delegate/DownloadSupportDelegate.groovy
new file mode 100644
index 00000000..22f55213
--- /dev/null
+++ b/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/support/delegate/DownloadSupportDelegate.groovy
@@ -0,0 +1,173 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.support.delegate
+
+import geb.download.DownloadSupport
+import grails.plugin.geb.ContainerGebSpec
+import groovy.transform.CompileStatic
+import groovy.transform.SelfType
+
+/**
+ * Handles delegation to the DownloadSupport instance so that the Geb API can be used directly in the test.
+ * <p>
+ * As method parameter names are not available in the Geb artifacts we are delegating manually to
+ * get the best possible IDE support and user experience.
+ *
+ * @author Mattias Reichel
+ * @since 4.2
+ */
+@CompileStatic
+@SelfType(ContainerGebSpec)
+trait DownloadSupportDelegate implements DownloadSupport {
+
+ @Override
+ HttpURLConnection download() {
+ downloadSupport.download()
+ }
+
+ @Override
+ HttpURLConnection download(Map options) {
+ downloadSupport.download(options)
+ }
+
+ @Override
+ HttpURLConnection download(String uri) {
+ downloadSupport.download(uri)
+ }
+
+ @Override
+ InputStream downloadStream() {
+ downloadSupport.downloadStream()
+ }
+
+ @Override
+ InputStream downloadStream(Map options) {
+ downloadSupport.downloadStream(options)
+ }
+
+ @Override
+ InputStream downloadStream(Map options, Closure connectionConfig) {
+ downloadSupport.downloadStream(options, connectionConfig)
+ }
+
+ @Override
+ InputStream downloadStream(String uri) {
+ downloadSupport.downloadStream(uri)
+ }
+
+ @Override
+ InputStream downloadStream(String uri, Closure connectionConfig) {
+ downloadSupport.downloadStream(uri, connectionConfig)
+ }
+
+ @Override
+ InputStream downloadStream(Closure connectionConfig) {
+ downloadSupport.downloadStream(connectionConfig)
+ }
+
+ @Override
+ String downloadText() {
+ downloadSupport.downloadText()
+ }
+
+ @Override
+ String downloadText(Map options) {
+ downloadSupport.downloadText(options)
+ }
+
+ @Override
+ String downloadText(Map options, Closure connectionConfig) {
+ downloadSupport.downloadText(options, connectionConfig)
+ }
+
+ @Override
+ String downloadText(String uri) {
+ downloadSupport.downloadText(uri)
+ }
+
+ @Override
+ String downloadText(String uri, Closure connectionConfig) {
+ downloadSupport.downloadText(uri, connectionConfig)
+ }
+
+ @Override
+ String downloadText(Closure connectionConfig) {
+ downloadSupport.downloadText(connectionConfig)
+ }
+
+ @Override
+ byte[] downloadBytes() {
+ downloadSupport.downloadBytes()
+ }
+
+ @Override
+ byte[] downloadBytes(Map options) {
+ downloadSupport.downloadBytes(options)
+ }
+
+ @Override
+ byte[] downloadBytes(Map options, Closure connectionConfig) {
+ downloadSupport.downloadBytes(options, connectionConfig)
+ }
+
+ @Override
+ byte[] downloadBytes(Closure connectionConfig) {
+ downloadSupport.downloadBytes(connectionConfig)
+ }
+
+ @Override
+ byte[] downloadBytes(String uri) {
+ downloadSupport.downloadBytes(uri)
+ }
+
+ @Override
+ byte[] downloadBytes(String uri, Closure connectionConfig) {
+ downloadSupport.downloadBytes(uri, connectionConfig)
+ }
+
+ @Override
+ Object downloadContent() {
+ downloadSupport.downloadContent()
+ }
+
+ @Override
+ Object downloadContent(Map options) {
+ downloadSupport.downloadContent(options)
+ }
+
+ @Override
+ Object downloadContent(Map options, Closure connectionConfig) {
+ downloadSupport.downloadContent(options, connectionConfig)
+ }
+
+ @Override
+ Object downloadContent(String uri) {
+ downloadSupport.downloadContent(uri)
+ }
+
+ @Override
+ Object downloadContent(String uri, Closure connectionConfig) {
+ downloadSupport.downloadContent(uri, connectionConfig)
+ }
+
+ @Override
+ Object downloadContent(Closure connectionConfig) {
+ downloadSupport.downloadContent(connectionConfig)
+ }
+}
\ No newline at end of file
diff --git a/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/support/delegate/DriverDelegate.groovy b/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/support/delegate/DriverDelegate.groovy
new file mode 100644
index 00000000..61e2104d
--- /dev/null
+++ b/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/support/delegate/DriverDelegate.groovy
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.support.delegate
+
+import grails.plugin.geb.ContainerGebSpec
+import groovy.transform.CompileStatic
+import groovy.transform.SelfType
+
+/**
+ * Handles delegation to select methods of the driver instance for end user convenience.
+ *
+ * @author Mattias Reichel
+ * @since 4.2
+ */
+@CompileStatic
+@SelfType(ContainerGebSpec)
+trait DriverDelegate {
+
+ /**
+ * Get the source of the last loaded page. If the page has been modified after loading (for
+ * example, by Javascript) there is no guarantee that the returned text is that of the modified
+ * page. Please consult the documentation of the particular driver being used to determine whether
+ * the returned text reflects the current state of the page or the text last sent by the web
+ * server. The page source returned is a representation of the underlying DOM: do not expect it to
+ * be formatted or escaped in the same way as the response sent from the web server. Think of it
+ * as an artist's impression.
+ *
+ * <p>See <a href="https://w3c.github.io/webdriver/#get-page-source">W3C WebDriver
+ * specification</a> for more details.
+ *
+ * @return The source of the current page
+ */
+ String getPageSource() {
+ ContainerGebSpec.testManager.browser.driver.pageSource
+ }
+
+}
\ No newline at end of file
diff --git a/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/support/delegate/PageDelegate.groovy b/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/support/delegate/PageDelegate.groovy
new file mode 100644
index 00000000..1706abe5
--- /dev/null
+++ b/integration/geb-testcontainers/src/main/groovy/grails/plugin/geb/support/delegate/PageDelegate.groovy
@@ -0,0 +1,415 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.support.delegate
+
+import geb.Module
+import geb.Page
+import geb.content.Navigable
+import geb.content.TemplateDerivedPageContent
+import geb.frame.FrameSupport
+import geb.interaction.InteractDelegate
+import geb.interaction.InteractionsSupport
+import geb.js.AlertAndConfirmSupport
+import geb.navigator.Navigator
+import geb.textmatching.TextMatchingSupport
+import geb.url.UrlFragment
+import geb.waiting.WaitingSupport
+import grails.plugin.geb.ContainerGebSpec
+import groovy.transform.CompileStatic
+import groovy.transform.SelfType
+import org.openqa.selenium.By
+import org.openqa.selenium.WebElement
+
+/**
+ * Handles delegation to the page instance so that the Geb API can be used directly in the test.
+ * <p>
+ * As method parameter names are not available in the Geb artifacts we are delegating manually to
+ * get the best possible IDE support and user experience.
+ *
+ * @author Mattias Reichel
+ * @since 4.2
+ */
+@CompileStatic
+@SelfType(ContainerGebSpec)
+trait PageDelegate implements Navigable, AlertAndConfirmSupport, WaitingSupport, FrameSupport, InteractionsSupport {
+
+ @Delegate
+ private final TextMatchingSupport textMatchingSupport = new TextMatchingSupport()
+
+ Page getPage() {
+ ContainerGebSpec.testManager.browser.page
+ }
+
+ void to(Map params, UrlFragment fragment = null, Object[] args) {
+ page.to(params, fragment, args)
+ }
+
+ UrlFragment getPageFragment() {
+ page.pageFragment
+ }
+
+ String getPageUrl(String path) {
+ page.getPageUrl(path)
+ }
+
+ String getTitle() {
+ page.title
+ }
+
+ <T> T refreshWaitFor(Map params = [:], Closure<T> block) {
+ page.refreshWaitFor(params, block)
+ }
+
+ <T> T refreshWaitFor(Map params = [:], String waitPreset, Closure<T> block) {
+ page.refreshWaitFor(params, waitPreset, block)
+ }
+
+ <T> T refreshWaitFor(Map params = [:], Number timeoutSeconds, Closure<T> block) {
+ page.refreshWaitFor(params, timeoutSeconds, block)
+ }
+
+ <T> T refreshWaitFor(Map params = [:], Number timeoutSeconds, Number intervalSeconds, Closure<T> block) {
+ page.refreshWaitFor(params, timeoutSeconds, intervalSeconds, block)
+ }
+
+ @Override
+ Navigator find() {
+ page.find()
+ }
+
+ @Override
+ Navigator $() {
+ page.$()
+ }
+
+ @Override
+ Navigator find(int index) {
+ page.find(index)
+ }
+
+ @Override
+ Navigator find(Range<Integer> range) {
+ page.find(range)
+ }
+
+ @Override
+ Navigator $(int index) {
+ page.$(index)
+ }
+
+ @Override
+ Navigator $(Range<Integer> range) {
+ page.$(range)
+ }
+
+ @Override
+ Navigator $(Navigator... navigators) {
+ page.$(navigators)
+ }
+
+ @Override
+ Navigator $(WebElement... elements) {
+ page.$(elements)
+ }
+
+ @Override
+ Navigator focused() {
+ page.focused()
+ }
+
+ @Override
+ <T extends Module> T module(Class<T> moduleClass) {
+ page.module(moduleClass)
+ }
+
+ @Override
+ <T extends Module> T module(T module) {
+ page.module(module)
+ }
+
+ @Override
+ Navigator find(String selector) {
+ page.find(selector)
+ }
+
+ @Override
+ Navigator $(String selector) {
+ page.$(selector)
+ }
+
+ @Override
+ Navigator find(Map<String, Object> attributes) {
+ page.find(attributes)
+ }
+
+ @Override
+ Navigator find(String selector, int index) {
+ page.find(selector, index)
+ }
+
+ @Override
+ Navigator find(String selector, Range<Integer> range) {
+ page.find(selector, range)
+ }
+
+ @Override
+ Navigator $(String selector, int index) {
+ page.$(selector, index)
+ }
+
+ @Override
+ Navigator $(String selector, Range<Integer> range) {
+ page.$(selector, range)
+ }
+
+ @Override
+ Navigator $(Map<String, Object> attributes, By bySelector) {
+ page.$(attributes, bySelector)
+ }
+
+ @Override
+ Navigator $(Map<String, Object> attributes, By bySelector, int index) {
+ page.$(attributes, bySelector, index)
+ }
+
+ @Override
+ Navigator $(Map<String, Object> attributes, By bySelector, Range<Integer> range) {
+ page.$(attributes, bySelector, range)
+ }
+
+ @Override
+ Navigator $(By bySelector) {
+ page.$(bySelector)
+ }
+
+ @Override
+ Navigator $(By bySelector, int index) {
+ page.$(bySelector, index)
+ }
+
+ @Override
+ Navigator $(By bySelector, Range<Integer> range) {
+ page.$(bySelector, range)
+ }
+
+ @Override
+ Navigator find(By bySelector, Range<Integer> range) {
+ page.find(bySelector, range)
+ }
+
+ @Override
+ Navigator $(Map<String, Object> attributes) {
+ page.$(attributes)
+ }
+
+ @Override
+ Navigator $(Map<String, Object> attributes, int index) {
+ page.$(attributes, index)
+ }
+
+ @Override
+ Navigator $(Map<String, Object> attributes, Range<Integer> range) {
+ page.$(attributes, range)
+ }
+
+ @Override
+ Navigator $(Map<String, Object> attributes, String selector) {
+ page.$(attributes, selector)
+ }
+
+ @Override
+ Navigator $(Map<String, Object> attributes, String selector, int index) {
+ page.$(attributes, selector, index)
+ }
+
+ @Override
+ Navigator $(Map<String, Object> attributes, String selector, Range<Integer> range) {
+ page.$(attributes, selector, range)
+ }
+
+ @Override
+ Navigator find(By bySelector) {
+ page.find(bySelector)
+ }
+
+ @Override
+ Navigator find(By bySelector, int index) {
+ page.find(bySelector, index)
+ }
+
+ @Override
+ Navigator find(Map<String, Object> attributes, By bySelector) {
+ page.find(attributes, bySelector)
+ }
+
+ @Override
+ Navigator find(Map<String, Object> attributes, int index) {
+ page.find(attributes, index)
+ }
+
+ @Override
+ Navigator find(Map<String, Object> attributes, Range<Integer> range) {
+ page.find(attributes, range)
+ }
+
+ @Override
+ Navigator find(Map<String, Object> attributes, By bySelector, int index) {
+ page.find(attributes, bySelector, index)
+ }
+
+ @Override
+ Navigator find(Map<String, Object> attributes, By bySelector, Range<Integer> range) {
+ page.find(attributes, bySelector, range)
+ }
+
+ @Override
+ Navigator find(Map<String, Object> attributes, String selector) {
+ page.find(attributes, selector)
+ }
+
+ @Override
+ Navigator find(Map<String, Object> attributes, String selector, int index) {
+ page.find(attributes, selector, index)
+ }
+
+ @Override
+ Navigator find(Map<String, Object> attributes, String selector, Range<Integer> range) {
+ page.find(attributes, selector, range)
+ }
+
+ @Override
+ Object withAlert(Closure actions) {
+ page.withAlert(actions)
+ }
+
+ @Override
+ Object withAlert(Map params, Closure actions) {
+ page.withAlert(params, actions)
+ }
+
+ @Override
+ void withNoAlert(Closure actions) {
+ page.withNoAlert(actions)
+ }
+
+ @Override
+ Object withConfirm(boolean ok, Closure actions) {
+ page.withConfirm(ok, actions)
+ }
+
+ @Override
+ Object withConfirm(Closure actions) {
+ page.withConfirm(actions)
+ }
+
+ @Override
+ Object withConfirm(Map attributes, Closure actions) {
+ page.withConfirm(attributes, actions)
+ }
+
+ @Override
+ Object withConfirm(Map attributes, boolean ok, Closure actions) {
+ page.withConfirm(attributes, ok, actions)
+ }
+
+ @Override
+ void withNoConfirm(Closure actions) {
+ page.withNoConfirm(actions)
+ }
+
+ @Override
+ <T> T waitFor(String waitPreset, Closure<T> block) {
+ page.waitFor(waitPreset, block)
+ }
+
+ @Override
+ <T> T waitFor(Map params, String waitPreset, Closure<T> block) {
+ page.waitFor(params, waitPreset, block)
+ }
+
+ @Override
+ <T> T waitFor(Closure<T> block) {
+ page.waitFor(block)
+ }
+
+ @Override
+ <T> T waitFor(Map params, Closure<T> block) {
+ page.waitFor(params, block)
+ }
+
+ @Override
+ <T> T waitFor(Number timeoutSeconds, Closure<T> block) {
+ page.waitFor(timeoutSeconds, block)
+ }
+
+ @Override
+ <T> T waitFor(Map params, Number timeoutSeconds, Closure<T> block) {
+ page.waitFor(params, timeoutSeconds, block)
+ }
+
+ @Override
+ <T> T waitFor(Number timeoutSeconds, Number intervalSeconds, Closure<T> block) {
+ page.waitFor(timeoutSeconds, intervalSeconds, block)
+ }
+
+ @Override
+ <T> T waitFor(Map params, Number timeoutSeconds, Number intervalSeconds, Closure<T> block) {
+ page.waitFor(params, timeoutSeconds, intervalSeconds, block)
+ }
+
+ @Override
+ <T> T withFrame(Object frame, Closure<T> block) {
+ page.withFrame(frame, block)
+ }
+
+ @Override
+ <P extends Page, T> T withFrame(Object frame, @DelegatesTo.Target Class<P> page, @DelegatesTo(strategy = 1, genericTypeIndex = 0) Closure<T> block) {
+ this.page.withFrame(frame, page, block)
+ }
+
+ @Override
+ <P extends Page, T> T withFrame(Object frame, @DelegatesTo.Target P page, @DelegatesTo(strategy = 1) Closure<T> block) {
+ this.page.withFrame(frame, page, block)
+ }
+
+ @Override
+ <P extends Page, T> T withFrame(Navigator frame, @DelegatesTo.Target Class<P> page, @DelegatesTo(strategy = 1, genericTypeIndex = 0) Closure<T> block) {
+ this.page.withFrame(frame, page, block)
+ }
+
+ @Override
+ <P extends Page, T> T withFrame(Navigator frame, @DelegatesTo.Target P page, @DelegatesTo(strategy = 1) Closure<T> block) {
+ this.page.withFrame(frame, page, block)
+ }
+
+ @Override
+ <T> T withFrame(Navigator frame, Closure<T> block) {
+ page.withFrame(frame, block)
+ }
+
+ @Override
+ <T> T withFrame(TemplateDerivedPageContent frame, Closure<T> block) {
+ page.withFrame(frame, block)
+ }
+
+ @Override
+ void interact(@DelegatesTo(strategy = 1, value = InteractDelegate.class) Closure interactionClosure) {
+ page.interact(interactionClosure)
+ }
+}
\ No newline at end of file
diff --git a/integration/geb-testcontainers/src/main/resources/META-INF/services/org.spockframework.runtime.extension.IGlobalExtension b/integration/geb-testcontainers/src/main/resources/META-INF/services/org.spockframework.runtime.extension.IGlobalExtension
new file mode 100644
index 00000000..cd6ea72f
--- /dev/null
+++ b/integration/geb-testcontainers/src/main/resources/META-INF/services/org.spockframework.runtime.extension.IGlobalExtension
@@ -0,0 +1 @@
+grails.plugin.geb.GrailsContainerGebExtension
\ No newline at end of file
diff --git a/integration/geb-testcontainers/src/test/groovy/org/demo/spock/BaseUrlConfigSpec.groovy b/integration/geb-testcontainers/src/test/groovy/org/demo/spock/BaseUrlConfigSpec.groovy
new file mode 100644
index 00000000..c6b7e5a2
--- /dev/null
+++ b/integration/geb-testcontainers/src/test/groovy/org/demo/spock/BaseUrlConfigSpec.groovy
@@ -0,0 +1,42 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.demo.spock
+
+import grails.plugin.geb.ContainerGebSpec
+import spock.lang.Narrative
+
+/**
+ * Test spec to verify that the correct baseUrl configuration is being used.
+ */
+@Narrative("To verify that the config file is used.")
+class BaseUrlConfigSpec extends ContainerGebSpec {
+
+ def "should use the baseUrl value"() {
+ when: "go to geb sub-page of groovy"
+ go "/geb"
+
+ then: "the page title should be correct"
+ title == "Geb - Very Groovy Browser Automation"
+ }
+
+ def cleanup() {
+ sleep(1000) // give the last video time to copy
+ }
+
+}
diff --git a/integration/geb-testcontainers/src/test/resources/GebConfig.groovy b/integration/geb-testcontainers/src/test/resources/GebConfig.groovy
new file mode 100644
index 00000000..fcecd90c
--- /dev/null
+++ b/integration/geb-testcontainers/src/test/resources/GebConfig.groovy
@@ -0,0 +1,36 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+
+import geb.report.ReportState
+import geb.report.Reporter
+import org.openqa.selenium.chrome.ChromeOptions
+import org.openqa.selenium.chromium.ChromiumOptions
+import org.openqa.selenium.edge.EdgeOptions
+import org.openqa.selenium.firefox.FirefoxOptions
+import org.openqa.selenium.remote.RemoteWebDriver
+import geb.report.ReportingListener
+
+// Configuration for container-based Geb testing
+// This driver configuration will be used by WebDriverContainerHolder
+
+// The `containerBrowser` property must match the configured
+// driver in order to start up a matching Selenium browser container.
+// containerBrowser = 'firefox' // see environments
+
+baseUrl = 'http://groovy.apache.org'