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'