diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 7fb7222..b5616e8 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -1,4 +1,4 @@ -name: Java CI +name: "Java CI" on: push: branches: @@ -9,64 +9,55 @@ on: workflow_dispatch: jobs: build: + name: "Build Project" runs-on: ubuntu-latest - env: - WORKSPACE: ${{ github.workspace }} steps: - - uses: actions/checkout@v4 - - name: Set up JDK + - name: "๐Ÿ“ฅ Checkout repository" + uses: actions/checkout@v4 + - name: "โ˜•๏ธ Setup JDK" uses: actions/setup-java@v4 with: - distribution: 'adopt' - java-version: '8' - - name: Run Build - id: build - uses: gradle/gradle-build-action@v2 - env: - GRADLE_ENTERPRISE_ACCESS_KEY: ${{ secrets.GRADLE_ENTERPRISE_ACCESS_KEY }} - GRADLE_ENTERPRISE_BUILD_CACHE_NODE_USER: ${{ secrets.GRADLE_ENTERPRISE_BUILD_CACHE_NODE_USER }} - GRADLE_ENTERPRISE_BUILD_CACHE_NODE_KEY: ${{ secrets.GRADLE_ENTERPRISE_BUILD_CACHE_NODE_KEY }} + distribution: liberica + java-version: 8 + - name: "๐Ÿ˜ Setup Gradle" + uses: gradle/actions/setup-gradle@v4 with: - arguments: build + develocity-access-key: ${{ secrets.GRADLE_ENTERPRISE_ACCESS_KEY }} + - name: "๐Ÿ”จ Build project" + id: build + run: ./gradlew build publish: if: github.event_name == 'push' runs-on: ubuntu-latest - needs: ["build"] + needs: 'build' steps: - - uses: actions/checkout@v4 - - name: Set up JDK + - name: "๐Ÿ“ฅ Checkout repository" + uses: actions/checkout@v4 + - name: "โ˜•๏ธ Setup JDK" uses: actions/setup-java@v4 with: - distribution: 'adopt' - java-version: '8' - - name: Publish to repo.grails.org - uses: gradle/gradle-build-action@v2 + java-version: 11 + distribution: liberica + - name: "๐Ÿ˜ Setup Gradle" + uses: gradle/actions/setup-gradle@v4 + with: + develocity-access-key: ${{ secrets.GRADLE_ENTERPRISE_ACCESS_KEY }} + - name: "๐Ÿ“ค Publish Snapshot version to Artifactory (repo.grails.org)" env: ARTIFACTORY_USERNAME: ${{ secrets.ARTIFACTORY_USERNAME }} ARTIFACTORY_PASSWORD: ${{ secrets.ARTIFACTORY_PASSWORD }} - GRADLE_ENTERPRISE_ACCESS_KEY: ${{ secrets.GRADLE_ENTERPRISE_ACCESS_KEY }} - GRADLE_ENTERPRISE_BUILD_CACHE_NODE_USER: ${{ secrets.GRADLE_ENTERPRISE_BUILD_CACHE_NODE_USER }} - GRADLE_ENTERPRISE_BUILD_CACHE_NODE_KEY: ${{ secrets.GRADLE_ENTERPRISE_BUILD_CACHE_NODE_KEY }} - with: - arguments: publish - - name: Generate Documentation - id: docs - uses: gradle/gradle-build-action@v2 + run: ./gradlew publish + - name: "๐Ÿ“– Generate Snapshot Documentation" if: success() - with: - arguments: groovydoc - env: - GRADLE_ENTERPRISE_ACCESS_KEY: ${{ secrets.GRADLE_ENTERPRISE_ACCESS_KEY }} - GRADLE_ENTERPRISE_BUILD_CACHE_NODE_USER: ${{ secrets.GRADLE_ENTERPRISE_BUILD_CACHE_NODE_USER }} - GRADLE_ENTERPRISE_BUILD_CACHE_NODE_KEY: ${{ secrets.GRADLE_ENTERPRISE_BUILD_CACHE_NODE_KEY }} - - name: Publish to Github Pages + id: docs + run: ./gradlew groovydoc + - name: "๐Ÿ“ค Publish Snapshot Documentation to Github Pages" if: steps.docs.outcome == 'success' uses: micronaut-projects/github-pages-deploy-action@grails env: - TARGET_REPOSITORY: ${{ github.repository }} - GH_TOKEN: ${{ secrets.GH_TOKEN }} BRANCH: gh-pages - FOLDER: build/docs + COMMIT_EMAIL: 'grails-build@users.noreply.github.com' + COMMIT_NAME: 'grails-build' DOC_FOLDER: gh-pages - COMMIT_EMAIL: behlp@objectcomputing.com - COMMIT_NAME: Puneet Behl + FOLDER: build/docs + GH_TOKEN: ${{ secrets.GH_TOKEN }} diff --git a/.github/workflows/release-notes.yml b/.github/workflows/release-notes.yml index 7a780c6..1cb1f53 100644 --- a/.github/workflows/release-notes.yml +++ b/.github/workflows/release-notes.yml @@ -1,47 +1,22 @@ -name: Changelog +name: "Release Drafter" on: issues: types: [closed,reopened] push: branches: - '[3-9]+.[0-9]+.x' + pull_request: + types: [opened, reopened, synchronize] + pull_request_target: + types: [opened, reopened, synchronize] workflow_dispatch: jobs: - release_notes: + update_release_draft: + permissions: + contents: read # limit to read access runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Check if it has release drafter config file - id: check_release_drafter - run: | - has_release_drafter=$([ -f .github/release-drafter.yml ] && echo "true" || echo "false") - echo ::set-output name=has_release_drafter::${has_release_drafter} - - name: Extract branch name - id: extract_branch - run: echo ::set-output name=value::${GITHUB_REF:11} - # If it has release drafter: - - uses: release-drafter/release-drafter@v5 - if: steps.check_release_drafter.outputs.has_release_drafter == 'true' + - name: "๐Ÿ“ Update Release Draft" + uses: release-drafter/release-drafter@v6 env: - GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} - with: - commitish: ${{ steps.extract_branch.outputs.value }} - # Otherwise: - - name: Export Gradle Properties - if: steps.check_release_drafter.outputs.has_release_drafter == 'false' - uses: micronaut-projects/github-actions/export-gradle-properties@master - - uses: micronaut-projects/github-actions/release-notes@master - if: steps.check_release_drafter.outputs.has_release_drafter == 'false' - id: release_notes - with: - token: ${{ secrets.GH_TOKEN }} - - uses: ncipollo/release-action@v1 - if: steps.check_release_drafter.outputs.has_release_drafter == 'false' && steps.release_notes.outputs.generated_changelog == 'true' - with: - allowUpdates: true - commit: ${{ steps.release_notes.outputs.current_branch }} - draft: true - name: ${{ env.title }} ${{ steps.release_notes.outputs.next_version }} - tag: v${{ steps.release_notes.outputs.next_version }} - bodyFile: CHANGELOG.md - token: ${{ secrets.GH_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 48e7a8e..2932b8a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,41 +1,36 @@ -name: Release +name: "Release" on: release: types: [published] jobs: release: runs-on: ubuntu-latest - strategy: - matrix: - java: ['8'] env: - GIT_USER_NAME: puneetbehl - GIT_USER_EMAIL: behlp@objectcomputing.com + GIT_USER_NAME: 'grails-build' + GIT_USER_EMAIL: 'grails-build@users.noreply.github.com' steps: - - name: Checkout repository + - name: "๐Ÿ“ฅ Checkout repository" uses: actions/checkout@v4 - with: - token: ${{ secrets.GH_TOKEN }} - - uses: gradle/wrapper-validation-action@v1 - - name: Set up JDK + - name: "โ˜•๏ธ Setup JDK" uses: actions/setup-java@v4 with: - distribution: 'adopt' - java-version: ${{ matrix.java }} - - name: Set the current release version + distribution: liberica + java-version: '8' + - name: "๐Ÿ˜ Setup Gradle" + uses: gradle/actions/setup-gradle@v4 + with: + develocity-access-key: ${{ secrets.GRADLE_ENTERPRISE_ACCESS_KEY }} + - name: "๐Ÿ“ Store the current release version" id: release_version - run: echo ::set-output name=release_version::${GITHUB_REF:11} - - name: Run pre-release + run: echo "release_version=${GITHUB_REF:11}" >> $GITHUB_OUTPUT + - name: "โš™ Run pre-release" uses: micronaut-projects/github-actions/pre-release@master - with: - token: ${{ secrets.GITHUB_TOKEN }} - - name: Generate secring file + - name: "๐Ÿ” Generate key file for artifact signing" env: SECRING_FILE: ${{ secrets.SECRING_FILE }} run: echo $SECRING_FILE | base64 -d > ${{ github.workspace }}/secring.gpg - - name: Publish to Sonatype OSSRH + - name: "๐Ÿ“ค Publish artifacts to Sonatype" id: publish - uses: gradle/gradle-build-action@v2 env: SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} @@ -44,33 +39,31 @@ jobs: SIGNING_KEY: ${{ secrets.SIGNING_KEY }} SIGNING_PASSPHRASE: ${{ secrets.SIGNING_PASSPHRASE }} SECRING_FILE: ${{ secrets.SECRING_FILE }} - with: - arguments: -Psigning.secretKeyRingFile=${{ github.workspace }}/secring.gpg publishToSonatype closeAndReleaseSonatypeStagingRepository - - name: Publish Documentation + run: > + ./gradlew + -Psigning.secretKeyRingFile=${{ github.workspace }}/secring.gpg + publishToSonatype + closeAndReleaseSonatypeStagingRepository + - name: "๐Ÿ“– Generate Documentation" id: docs if: steps.publish.outcome == 'success' - uses: gradle/gradle-build-action@v2 - with: - arguments: groovydoc - - name: Export Gradle Properties + run: ./gradlew groovydoc + - name: "๐Ÿ“ Export Gradle Properties" uses: micronaut-projects/github-actions/export-gradle-properties@master - - name: Publish to Github Pages + - name: "๐Ÿ“ค Publish to Github Pages" if: steps.docs.outcome == 'success' uses: micronaut-projects/github-pages-deploy-action@master env: BETA: ${{ contains(steps.release_version.outputs.release_version, 'M') || contains(steps.release_version.outputs.release_version, 'RC') }} - TARGET_REPOSITORY: ${{ github.repository }} - GH_TOKEN: ${{ secrets.GH_TOKEN }} BRANCH: gh-pages - FOLDER: build/docs + COMMIT_EMAIL: ${{ env.GIT_USER_EMAIL }} + COMMIT_NAME: ${{ env.GIT_USER_NAME }} DOC_FOLDER: gh-pages - COMMIT_EMAIL: behlp@objectcomputing.com - COMMIT_NAME: Puneet Behl + FOLDER: build/docs + GH_TOKEN: ${{ secrets.GH_TOKEN }} VERSION: ${{ steps.release_version.outputs.release_version }} - - name: Run post-release + - name: "โš™๏ธ Run post-release" if: success() uses: micronaut-projects/github-actions/post-release@master with: - token: ${{ secrets.GITHUB_TOKEN }} - env: - SNAPSHOT_SUFFIX: -SNAPSHOT + token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/README.md b/README.md index 9c92fb6..5c3a0e6 100644 --- a/README.md +++ b/README.md @@ -3,27 +3,93 @@ [![Maven Central](https://img.shields.io/maven-central/v/org.grails.plugins/geb.svg?label=Maven%20Central)](https://central.sonatype.com/artifact/org.grails.plugins/geb) [![Java CI](https://github.com/grails/geb/actions/workflows/gradle.yml/badge.svg?event=push)](https://github.com/grails/geb/actions/workflows/gradle.yml) -Geb Functional Testing for the Grailsยฎ framework. +## Geb Functional Testing for the Grailsยฎ framework -This plugin provides the Geb dependencies and a `create-functional-test` command for generating Geb tests in a Grails app. - -For further reference please see the [Geb documentation](https://www.gebish.org). +This plugin integrates [Geb](https://www.gebish.org) with [Grails](https://www.grails.org) to make it easy to write functional tests for your Grails applications. ## Examples If you are looking for examples on how to write Geb tests, check: [Geb/Grails example project](https://github.com/grails-samples/geb-example-grails) or [Grails functional test suite](https://github.com/grails/grails-functional-tests) where Geb tests are used extensively. +For further reference please see the [Geb documentation](https://www.gebish.org). + +## Usage + +To use the plugin, add the following dependencies to your `build.gradle` file: +```groovy +dependencies { + + // This is only needed to if you want to use the + // create-functional-test command (see below) + implementation 'org.grails.plugins:geb' + + // This is needed to compile and run the tests + integrationTestImplementation testFixtures('org.grails.plugins:geb') +} +``` + +To get started, you can use the `create-functional-test` command to generate a new functional test using Geb: + +```console +./grailsw create-functional-test com.example.MyFunctionalSpec +``` + +This will create a new Geb test named `MyFunctionalSpec` in the `src/integration-test/groovy/com/example` directory. + +There are two ways to use this plugin. Either extend your test classes with the `ContainerGebSpec` class or with the `GebSpec` class. + +### ContainerGebSpec (recommended) + +By extending your test classes with `ContainerGebSpec`, your tests will automatically use a containerized browser using [Testcontainers](https://java.testcontainers.org/). +This requires a [compatible container runtime](https://java.testcontainers.org/supported_docker_environment/) to be installed, such as: -## Additional Drivers +- [Docker Desktop](https://www.docker.com/products/docker-desktop/) +- [OrbStack](https://orbstack.dev/) - macOS only +- [Rancher Desktop](https://rancherdesktop.io/) +- [podman desktop](https://podman-desktop.io/) +- [Colima](https://github.com/abiosoft/colima) - macOS and Linux -Grails comes with a set of pre-installed browser drivers. -To set up additional drivers you need to add the driver to your `build.gradle` for example: +If you choose to use the `ContainerGebSpec` class, as long as you have a compatible container runtime installed, you don't need to do anything else. +Just run `./gradlew integrationTest` and a container will be started and configured to start a browser that can access your application under test. + +#### Custom Host Configuration + +The annotation `ContainerGebConfiguration` exists to customize the connection the container will use to access the application under test. The annotation is not required and `ContainerGebSpec` will use the default values in this annotation if it's not present. + +#### Recording +By default, no test recording will be performed. Here are the system properties available to change the recording behavior: + +* `grails.geb.recording.mode` + * purpose: which tests to record + * possible values: `SKIP`, `RECORD_ALL`, or `RECORD_FAILING` + * defaults to `SKIP` + + +* `grails.geb.recording.directory` + * purpose: the directory to save the recordings relative to the project directory + * defaults to `build/recordings` + + +* `grails.geb.recording.format` + * purpose: sets the format of the recording + * possible values are `FLV` or `MP4` + * defaults to `MP4` + +### GebSpec + +If you choose to extend `GebSpec`, you will need to have a [Selenium WebDriver](https://www.selenium.dev/documentation/webdriver/browsers/) installed that matches a browser you have installed on your system. +This plugin comes with the `selenium-chrome-driver` java bindings pre-installed, but you can also add additional browser bindings. + +To set up additional bindings, you need to add them to your `build.gradle` for example: ```groovy -integrationTestRuntimeOnly "org.seleniumhq.selenium:selenium-edge-driver:$seleniumVersion" +dependencies { + integrationTestImplementation 'org.seleniumhq.selenium:selenium-firefox-driver' + integrationTestImplementation 'org.seleniumhq.selenium:selenium-edge-driver' +} ``` -You also need to add it to the `GebConfig.groovy` file in the `src/test/resources/` directory. For example: +You also need to add a `GebConfig.groovy` file in the `src/integration-test/resources/` directory. For example: ```groovy /* This is the Geb configuration file. @@ -33,18 +99,30 @@ You also need to add it to the `GebConfig.groovy` file in the `src/test/resource /* ... */ import org.openqa.selenium.edge.EdgeDriver +import org.openqa.selenium.firefox.FirefoxDriver environments { /* ... */ - edge { driver = { new EdgeDriver() } } + firefox { + driver = { new FirefoxDriver() } + } +} +``` + +And pass on the `geb.env` system property if running your tests via Gradle: +```groovy +// build.gradle +tasks.withType(Test) { + useJUnitPlatform() + systemProperty 'geb.env', System.getProperty('geb.env') } ``` -Now you can run your tests with the new driver by specifying the Geb environment: +Now you can run your tests with the browsers installed on your system by specifying the Geb environment you have set up in your `GebConfig.groovy` file. For example: ```console ./gradlew integrationTest -Dgeb.env=edge ``` diff --git a/build.gradle b/build.gradle index 8e8c0d4..1ac2ee9 100644 --- a/build.gradle +++ b/build.gradle @@ -1,19 +1,24 @@ buildscript { repositories { - maven { url "https://repo.grails.org/grails/core" } + maven { url = 'https://repo.grails.org/grails/core' } } dependencies { classpath "org.grails:grails-gradle-plugin:$grailsGradlePluginVersion" } } -version projectVersion -group "org.grails.plugins" +plugins { + id 'com.github.ben-manes.versions' version '0.39.0' +} + +version = projectVersion +group = 'org.grails.plugins' apply plugin: 'java-library' -apply plugin: 'idea' +apply plugin: 'java-test-fixtures' apply plugin: 'org.grails.grails-plugin' apply plugin: 'org.grails.internal.grails-plugin-publish' +apply plugin: 'maven-publish' sourceCompatibility = 1.8 targetCompatibility = 1.8 @@ -28,29 +33,23 @@ configurations { dependencies { api "org.grails:grails-core:$grailsVersion" - api "org.gebish:geb-spock:$gebSpock", { + + testFixturesApi "org.gebish:geb-spock:$gebVersion", { exclude group: 'org.codehaus.groovy', module: 'groovy-all' + exclude group: 'org.seleniumhq.selenium', module: 'selenium-api' } + testFixturesApi 'org.grails:grails-testing-support:2.6.1' + testFixturesApi 'org.grails:grails-datastore-gorm:7.3.4' + testFixturesApi "org.testcontainers:selenium:$testcontainersVersion" + testFixturesApi "org.seleniumhq.selenium:selenium-chrome-driver:$seleniumVersion" + testFixturesApi "org.seleniumhq.selenium:selenium-remote-driver:$seleniumVersion" + documentation "org.codehaus.groovy:groovy:$groovyVersion" documentation "org.codehaus.groovy:groovy-ant:$groovyVersion" documentation "org.codehaus.groovy:groovy-templates:$groovyVersion" documentation "com.github.javaparser:javaparser-core:3.15.14" } -findMainClass.enabled = false - -bootRun { - ignoreExitValue true - jvmArgs( - '-Dspring.output.ansi.enabled=always', - '-noverify', - '-XX:TieredStopAtLevel=1', - '-Xmx1024m') - sourceResources sourceSets.main - String springProfilesActive = 'spring.profiles.active' - systemProperty springProfilesActive, System.getProperty(springProfilesActive) -} - grailsPublish { userOrg = 'grails' @@ -58,36 +57,34 @@ grailsPublish { license { name = 'Apache-2.0' } - title = "Grails Geb Plugin" - desc = "Provides Integration with Geb for Functional Testing" - developers = [graemerocher: "Graeme Rocher", puneetbehl: "Puneet Behl"] + title = 'Grails Geb Plugin' + desc = 'Provides Integration with Geb for Functional Testing' + developers = [ + graemerocher: 'Graeme Rocher', + puneetbehl: 'Puneet Behl', + sbglasius: 'Sรธren Berg Glasius', + matrei: 'Mattias Reichel', + jdaugherty: 'James Daugherty' + ] } -tasks.withType(Groovydoc) { - destinationDir = new File(buildDir, 'docs/api') +tasks.withType(Groovydoc).configureEach { + destinationDir = layout.buildDirectory.dir('docs/api').get().asFile docTitle = "Grails Geb Plugin ${version}" classpath = configurations.documentation } -tasks.withType(GroovyCompile) { - configure(groovyOptions) { - forkOptions.jvmArgs = ['-Xmx1024m'] - } -} - -tasks.withType(Test) { +tasks.withType(Test).configureEach { useJUnitPlatform() -} - -test { testLogging { - events "passed", "skipped", "failed" - + events 'passed', 'skipped', 'failed' showExceptions true - exceptionFormat "full" + exceptionFormat 'full' showCauses true showStackTraces true } } -bootJar.enabled = false +tasks.named('bootJar') { enabled = false } +tasks.named('bootRun') { enabled = false } +tasks.named('findMainClass') { enabled = false } \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 63a1fa2..2290378 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,10 +1,11 @@ -title=Grails Geb Plugin projectVersion=4.0.1-SNAPSHOT -developers=Puneet Behl grailsVersion=5.3.5 grailsGradlePluginVersion=5.3.1 groovyVersion=3.0.11 -gebSpock=5.1 +gebVersion=5.1 +seleniumVersion=4.1.4 +testcontainersVersion=1.20.2 + org.gradle.parallel=true org.gradle.caching=true org.gradle.daemon=true diff --git a/settings.gradle b/settings.gradle index 27ae624..5e7b294 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,30 +1,27 @@ plugins { - id "com.gradle.enterprise" version "3.16" - id 'com.gradle.common-custom-user-data-gradle-plugin' version '1.12.1' + id 'com.gradle.develocity' version '3.18.1' + id 'com.gradle.common-custom-user-data-gradle-plugin' version '2.0.2' + id 'org.gradle.toolchains.foojay-resolver-convention' version '0.8.0' } -rootProject.name="geb" +def isCI = System.getenv('CI') != null +def isLocal = !isCI +def isAuthenticated = System.getenv('DEVELOCITY_ACCESS_KEY') != null -gradleEnterprise { +develocity { server = 'https://ge.grails.org' buildScan { - publishAlways() - publishIfAuthenticated() - uploadInBackground = System.getenv("CI") == null - capture { - taskInputFiles = true - } + publishing.onlyIf { isAuthenticated } + uploadInBackground = isLocal } } buildCache { - local { enabled = System.getenv('CI') != 'true' } - remote(HttpBuildCache) { - push = System.getenv('CI') == 'true' + local { enabled = isLocal } + remote(develocity.buildCache) { + push = isCI && isAuthenticated enabled = true - url = 'https://ge.grails.org/cache/' - credentials { - username = System.getenv('GRADLE_ENTERPRISE_BUILD_CACHE_NODE_USER') - password = System.getenv('GRADLE_ENTERPRISE_BUILD_CACHE_NODE_KEY') - } - }} + } +} + +rootProject.name = 'geb' diff --git a/src/main/groovy/geb/GebGrailsPlugin.groovy b/src/main/groovy/grails/plugin/geb/GebGrailsPlugin.groovy similarity index 92% rename from src/main/groovy/geb/GebGrailsPlugin.groovy rename to src/main/groovy/grails/plugin/geb/GebGrailsPlugin.groovy index 14a275b..e186222 100644 --- a/src/main/groovy/geb/GebGrailsPlugin.groovy +++ b/src/main/groovy/grails/plugin/geb/GebGrailsPlugin.groovy @@ -1,4 +1,4 @@ -package geb +package grails.plugin.geb import grails.plugins.Plugin import grails.plugins.metadata.PluginSource @@ -13,7 +13,7 @@ class GebGrailsPlugin extends Plugin { ] def title = "Grails Geb Plugin" def author = "Graeme Rocher" - def authorEmail = "grocher@pivotal.io" + def authorEmail = "" def description = '''\ Plugin that adds Geb functional testing code generation features. ''' diff --git a/src/testFixtures/groovy/grails/plugin/geb/ContainerAwareDownloadSupport.groovy b/src/testFixtures/groovy/grails/plugin/geb/ContainerAwareDownloadSupport.groovy new file mode 100644 index 0000000..8bf8e8e --- /dev/null +++ b/src/testFixtures/groovy/grails/plugin/geb/ContainerAwareDownloadSupport.groovy @@ -0,0 +1,44 @@ +/* + * Copyright 2024 original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package grails.plugin.geb + +import geb.download.DownloadSupport +import groovy.transform.CompileStatic +import groovy.transform.SelfType +import spock.lang.Shared + +/** + * A custom implementation of {@link geb.download.DownloadSupport} for enabling the use of its {@code download*()} methods + * within {@code ContainerGebSpec} environments. + * + *

This implementation is based on {@code DefaultDownloadSupport} from Geb, with modifications to support + * containerized environments. Specifically, it enables file downloads by resolving URLs relative to the host + * rather than the internal hostname used by the browser within the container.

+ * + *

These adaptations allow the download functionality to operate correctly when tests are executed in containerized + * setups, ensuring the host network context is used for download requests.

+ * + * @author Mattias Reichel + * @since 4.0 + */ +@CompileStatic +@SelfType(ContainerGebSpec) +trait ContainerAwareDownloadSupport implements DownloadSupport { + + @Delegate + @Shared + DownloadSupport downloadSupport +} \ No newline at end of file diff --git a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebConfiguration.groovy b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebConfiguration.groovy new file mode 100644 index 0000000..44f9bcd --- /dev/null +++ b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebConfiguration.groovy @@ -0,0 +1,55 @@ +/* + * Copyright 2024 original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package grails.plugin.geb + +import 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.0 + */ +@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' + + /** + * The protocol that the container's browser will use to access the server under test. + *

Defaults to {@code http}. + */ + String protocol() default DEFAULT_PROTOCOL + + /** + * The hostname that the container's browser will use to access the server under test. + *

Defaults to {@code host.testcontainers.internal}. + *

This is useful when the server under test needs to be accessed with a certain hostname. + */ + String hostName() default DEFAULT_HOSTNAME_FROM_CONTAINER + + /** + * Whether reporting should be enabled for this test. Add a `GebConfig.groovy` to customize the reporter configuration. + */ + boolean reporting() default false +} \ No newline at end of file diff --git a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebSpec.groovy b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebSpec.groovy new file mode 100644 index 0000000..5a47486 --- /dev/null +++ b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebSpec.groovy @@ -0,0 +1,73 @@ +/* + * Copyright 2024 original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package grails.plugin.geb + +import geb.report.CompositeReporter +import geb.report.PageSourceReporter +import geb.report.Reporter +import geb.test.GebTestManager +import geb.transform.DynamicallyDispatchesToBrowser +import org.testcontainers.containers.BrowserWebDriverContainer +import spock.lang.Shared +import spock.lang.Specification + +/** + * A {@link geb.spock.GebSpec GebSpec} that leverages Testcontainers to run the browser inside a container. + * + *

Prerequisites: + *

+ * + * @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.0 + */ +@DynamicallyDispatchesToBrowser +abstract class ContainerGebSpec extends Specification implements ContainerAwareDownloadSupport { + + @Shared + @Delegate(includes = ['getBrowser', 'report']) + @SuppressWarnings('unused') + static GebTestManager testManager + + /** + * 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 + + /** + * The reporter that GebShould use when reporting is enabled. + */ + Reporter createReporter() { + new CompositeReporter(new PageSourceReporter()) + } +} \ No newline at end of file diff --git a/src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestDescription.groovy b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestDescription.groovy new file mode 100644 index 0000000..d0de993 --- /dev/null +++ b/src/testFixtures/groovy/grails/plugin/geb/ContainerGebTestDescription.groovy @@ -0,0 +1,39 @@ +/* + * Copyright 2024 original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package grails.plugin.geb + +import groovy.transform.CompileStatic +import org.spockframework.runtime.model.IterationInfo +import org.testcontainers.lifecycle.TestDescription + +/** + * Implements {@link org.testcontainers.lifecycle.TestDescription} to customize recording names. + * + * @author James Daugherty + * @since 4.0 + */ +@CompileStatic +class ContainerGebTestDescription implements TestDescription { + + String testId + String filesystemFriendlyName + + ContainerGebTestDescription(IterationInfo testInfo) { + testId = testInfo.displayName + String safeName = testId.replaceAll('\\W+', '_') + filesystemFriendlyName = safeName + } +} diff --git a/src/testFixtures/groovy/grails/plugin/geb/GebOnFailureReporter.groovy b/src/testFixtures/groovy/grails/plugin/geb/GebOnFailureReporter.groovy new file mode 100644 index 0000000..9a18947 --- /dev/null +++ b/src/testFixtures/groovy/grails/plugin/geb/GebOnFailureReporter.groovy @@ -0,0 +1,46 @@ +/* + * Copyright 2016 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package grails.plugin.geb + +import groovy.transform.CompileStatic +import org.opentest4j.IncompleteExecutionException +import org.spockframework.runtime.extension.IMethodInterceptor +import org.spockframework.runtime.extension.IMethodInvocation + +/** + * This class is a direct clone of {@link geb.spock.OnFailureReporter OnFailureReporter}, except it works for the + * {@link grails.plugin.geb.ContainerGebSpec ContainerGebSpec}. + */ +@CompileStatic +class GebOnFailureReporter implements IMethodInterceptor { + void intercept(IMethodInvocation invocation) throws Throwable { + try { + invocation.proceed() + } catch (IncompleteExecutionException notACauseForReporting) { + throw notACauseForReporting + } catch (Throwable throwable) { + ContainerGebSpec spec = invocation.instance as ContainerGebSpec + if (spec.testManager.reportingEnabled) { + try { + spec.testManager.reportFailure() + } catch (ignored) { + //ignore + } + } + throw throwable + } + } +} diff --git a/src/testFixtures/groovy/grails/plugin/geb/GebRecordingTestListener.groovy b/src/testFixtures/groovy/grails/plugin/geb/GebRecordingTestListener.groovy new file mode 100644 index 0000000..c8bdc29 --- /dev/null +++ b/src/testFixtures/groovy/grails/plugin/geb/GebRecordingTestListener.groovy @@ -0,0 +1,62 @@ +/* + * Copyright 2024 original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package grails.plugin.geb + +import groovy.transform.CompileStatic +import org.spockframework.runtime.AbstractRunListener +import org.spockframework.runtime.model.ErrorInfo +import org.spockframework.runtime.model.IterationInfo +import org.spockframework.runtime.model.SpecInfo + +import java.time.LocalDateTime + +/** + * A test listener that reports the test result to {@link org.testcontainers.containers.BrowserWebDriverContainer} so + * that recordings may be saved. + * + * @see org.testcontainers.containers.BrowserWebDriverContainer#afterTest + * + * @author James Daugherty + * @since 4.0 + */ +@CompileStatic +class GebRecordingTestListener extends AbstractRunListener { + + WebDriverContainerHolder containerHolder + ErrorInfo errorInfo + SpecInfo spec + LocalDateTime runDate + + GebRecordingTestListener(WebDriverContainerHolder containerHolder, SpecInfo spec) { + this.spec = spec + this.runDate = runDate + this.containerHolder = containerHolder + } + + @Override + void afterIteration(IterationInfo iteration) { + containerHolder.currentContainer.afterTest( + new ContainerGebTestDescription(iteration), + Optional.ofNullable(errorInfo?.exception) + ) + errorInfo = null + } + + @Override + void error(ErrorInfo error) { + errorInfo = error + } +} \ No newline at end of file diff --git a/src/testFixtures/groovy/grails/plugin/geb/GrailsContainerGebExtension.groovy b/src/testFixtures/groovy/grails/plugin/geb/GrailsContainerGebExtension.groovy new file mode 100644 index 0000000..686bdf5 --- /dev/null +++ b/src/testFixtures/groovy/grails/plugin/geb/GrailsContainerGebExtension.groovy @@ -0,0 +1,147 @@ +/* + * Copyright 2024 original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package grails.plugin.geb + +import grails.testing.mixin.integration.Integration +import groovy.transform.CompileStatic +import groovy.transform.TailRecursive +import groovy.util.logging.Slf4j +import org.spockframework.runtime.extension.IGlobalExtension +import org.spockframework.runtime.model.MethodInfo +import org.spockframework.runtime.model.SpecInfo +import org.spockframework.runtime.model.parallel.ExclusiveResource +import org.spockframework.runtime.model.parallel.ResourceAccessMode + +import java.time.LocalDateTime + +/** + * A Spock Extension that manages the Testcontainers lifecycle for a {@link grails.plugin.geb.ContainerGebSpec} + * + *

ContainerGebSpec cannot be a {@link geb.test.ManagedGebTest ManagedGebTest} because it would cause the test manager + * to be initialized out of sequence of the container management. Instead, we initialize the same interceptors + * as the {@link geb.spock.GebExtension GebExtension} does. + * + * @author James Daugherty + * @since 4.0 + */ +@Slf4j +@CompileStatic +class GrailsContainerGebExtension implements IGlobalExtension { + + ExclusiveResource exclusiveResource + WebDriverContainerHolder holder + + @Override + void start() { + exclusiveResource = new ExclusiveResource( + ContainerGebSpec.name, + ResourceAccessMode.READ_WRITE + ) + holder = new WebDriverContainerHolder( + new GrailsGebSettings(LocalDateTime.now()) + ) + addShutdownHook { + holder.stop() + } + } + + @Override + void stop() { + holder.stop() + } + + @Override + void visitSpec(SpecInfo spec) { + if (isContainerGebSpec(spec) && validateContainerGebSpec(spec)) { + // Do not allow parallel execution since there's only 1 set of containers in testcontainers + spec.addExclusiveResource(exclusiveResource) + + // Always initialize the container requirements first so the GebTestManager can properly configure the browser + spec.addSharedInitializerInterceptor { invocation -> + holder.reinitialize(invocation) + + ContainerGebSpec gebSpec = invocation.sharedInstance as ContainerGebSpec + gebSpec.container = holder.currentContainer + gebSpec.testManager = holder.testManager + gebSpec.downloadSupport = new LocalhostDownloadSupport( + holder.currentBrowser, + holder.hostNameFromHost + ) + + // code below here is from the geb.spock.GebExtension since there can only be 1 shared initializer per extension + holder.testManager.beforeTestClass(invocation.spec.reflection) + invocation.proceed() + } + + spec.addSetupInterceptor { + // Grails will be initialized by this point, so setup the browser url correctly + holder.setupBrowserUrl(it) + } + + spec.addInterceptor { invocation -> + try { + invocation.proceed() + } finally { + holder.testManager.afterTestClass() + } + } + + spec.allFeatures*.addIterationInterceptor { invocation -> + holder.testManager.beforeTest(invocation.instance.getClass(), invocation.iteration.displayName) + try { + invocation.proceed() + } finally { + holder.testManager.afterTest() + } + } + + addGebExtensionOnFailureReporter(spec) + + GebRecordingTestListener recordingListener = new GebRecordingTestListener( + holder, + spec + ) + spec.addListener(recordingListener) + } + } + + @TailRecursive + private boolean isContainerGebSpec(SpecInfo spec) { + if (spec != null) { + if (spec.filename.startsWith("${ContainerGebSpec.simpleName}." as String)) { + return true + } + return isContainerGebSpec(spec.superSpec) + } + return false + } + + private static boolean validateContainerGebSpec(SpecInfo specInfo) { + if (!specInfo.annotations.find { it.annotationType() == Integration }) { + throw new IllegalArgumentException('ContainerGebSpec classes must be annotated with @Integration') + } + + return true + } + + private static void addGebExtensionOnFailureReporter(SpecInfo spec) { + List methods = spec.allFeatures*.featureMethod + spec.allFixtureMethods.toList() + methods.each { MethodInfo method -> + method.addInterceptor(new GebOnFailureReporter()) + } + } +} + diff --git a/src/testFixtures/groovy/grails/plugin/geb/GrailsGebSettings.groovy b/src/testFixtures/groovy/grails/plugin/geb/GrailsGebSettings.groovy new file mode 100644 index 0000000..a279571 --- /dev/null +++ b/src/testFixtures/groovy/grails/plugin/geb/GrailsGebSettings.groovy @@ -0,0 +1,96 @@ +/* + * Copyright 2024 original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package grails.plugin.geb + +import groovy.transform.CompileStatic +import groovy.transform.Memoized +import groovy.util.logging.Slf4j + +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +import static org.testcontainers.containers.BrowserWebDriverContainer.VncRecordingMode +import static org.testcontainers.containers.VncRecordingContainer.VncRecordingFormat + +/** + * Handles parsing various recording configuration used by {@link GrailsContainerGebExtension} + * + * @author James Daugherty + * @since 4.0 + */ +@Slf4j +@CompileStatic +class GrailsGebSettings { + + private static VncRecordingMode DEFAULT_RECORDING_MODE = VncRecordingMode.SKIP + private static VncRecordingFormat DEFAULT_RECORDING_FORMAT = VncRecordingFormat.MP4 + + String recordingDirectoryName + String reportingDirectoryName + VncRecordingMode recordingMode + VncRecordingFormat recordingFormat + LocalDateTime startTime + + GrailsGebSettings(LocalDateTime startTime) { + recordingDirectoryName = System.getProperty('grails.geb.recording.directory', 'build/gebContainer/recordings') + reportingDirectoryName = System.getProperty('grails.geb.reporting.directory', 'build/gebContainer/reports') + recordingMode = VncRecordingMode.valueOf( + System.getProperty('grails.geb.recording.mode', DEFAULT_RECORDING_MODE.name()) + ) + recordingFormat = VncRecordingFormat.valueOf( + System.getProperty('grails.geb.recording.format', DEFAULT_RECORDING_FORMAT.name()) + ) + this.startTime = startTime + } + + boolean isRecordingEnabled() { + recordingMode != VncRecordingMode.SKIP + } + + @Memoized + File getRecordingDirectory() { + if (!recordingEnabled) { + return null + } + + File recordingDirectory = new File("${recordingDirectoryName}${File.separator}${DateTimeFormatter.ofPattern('yyyyMMdd_HHmmss').format(startTime)}") + if (!recordingDirectory.exists()) { + log.info('Could not find `{}` Directory for recording. Creating...', recordingDirectoryName) + recordingDirectory.mkdirs() + } else if (!recordingDirectory.isDirectory()) { + throw new IllegalStateException("Configured recording directory '${recordingDirectory}' is expected to be a directory, but found file instead.") + } + + return recordingDirectory + } + + @Memoized + File getReportingDirectory() { + if (!reportingDirectoryName) { + return null + } + + File reportingDirectory = new File("${reportingDirectoryName}${File.separator}${DateTimeFormatter.ofPattern('yyyyMMdd_HHmmss').format(startTime)}") + if (!reportingDirectory.exists()) { + log.info('Could not find `{}` Directory for reporting. Creating...', reportingDirectoryName) + reportingDirectory.mkdirs() + } else if (!reportingDirectory.isDirectory()) { + throw new IllegalStateException("Configured reporting directory '${reportingDirectory}' is expected to be a directory, but found file instead.") + } + + return reportingDirectory + } +} diff --git a/src/testFixtures/groovy/grails/plugin/geb/LocalhostDownloadSupport.groovy b/src/testFixtures/groovy/grails/plugin/geb/LocalhostDownloadSupport.groovy new file mode 100644 index 0000000..30431f5 --- /dev/null +++ b/src/testFixtures/groovy/grails/plugin/geb/LocalhostDownloadSupport.groovy @@ -0,0 +1,54 @@ +/* + * Copyright 2024 original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package grails.plugin.geb + +import geb.Browser +import geb.download.DefaultDownloadSupport +import groovy.transform.CompileStatic +import groovy.transform.PackageScope + +import java.util.regex.Pattern + +/** + * @author Mattias Reichel + * @since 4.0 + */ +@PackageScope +@CompileStatic +class LocalhostDownloadSupport extends DefaultDownloadSupport { + + private final static Pattern urlPattern = ~/(https?:\/\/)([^\/:]+)(:\d+\/.*)/ + + private final String hostNameFromHost + private final Browser browser + + LocalhostDownloadSupport(Browser browser, String hostNameFromHost) { + super(browser) + this.browser = browser + this.hostNameFromHost = hostNameFromHost + } + + @Override + HttpURLConnection download(Map options) { + return super.download([*: options, base: resolveBase(options)]) + } + + private String resolveBase(Map options) { + return options.base ?: browser.driver.currentUrl.replaceAll(urlPattern) { match, proto, host, rest -> + "${proto}${hostNameFromHost}${rest}" + } + } +} diff --git a/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy b/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy new file mode 100644 index 0000000..228ed93 --- /dev/null +++ b/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy @@ -0,0 +1,216 @@ +/* + * Copyright 2024 original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package grails.plugin.geb + +import com.github.dockerjava.api.model.ContainerNetwork +import geb.Browser +import geb.Configuration +import geb.spock.SpockGebTestManagerBuilder +import geb.test.GebTestManager +import groovy.transform.CompileStatic +import groovy.transform.EqualsAndHashCode +import groovy.transform.PackageScope +import groovy.util.logging.Slf4j +import org.openqa.selenium.WebDriver +import org.openqa.selenium.chrome.ChromeOptions +import org.openqa.selenium.remote.RemoteWebDriver +import org.spockframework.runtime.extension.IMethodInvocation +import org.spockframework.runtime.model.SpecInfo +import org.testcontainers.Testcontainers +import org.testcontainers.containers.BrowserWebDriverContainer +import org.testcontainers.containers.PortForwardingContainer + +import java.time.Duration +import java.util.function.Supplier + +/** + * Responsible for initializing a {@link org.testcontainers.containers.BrowserWebDriverContainer BrowserWebDriverContainer} + * per the Spec's {@link grails.plugin.geb.ContainerGebConfiguration ContainerGebConfiguration}. This class will try to + * reuse the same container if the configuration matches the current container. + * + * @author James Daugherty + * @since 4.0 + */ +@Slf4j +@CompileStatic +class WebDriverContainerHolder { + + private static final String DEFAULT_HOSTNAME_FROM_HOST = 'localhost' + + GrailsGebSettings grailsGebSettings + GebTestManager testManager + Browser currentBrowser + BrowserWebDriverContainer currentContainer + WebDriverContainerConfiguration currentConfiguration + + WebDriverContainerHolder(GrailsGebSettings grailsGebSettings) { + this.grailsGebSettings = grailsGebSettings + } + + boolean isInitialized() { + currentContainer != null + } + + void stop() { + currentContainer?.stop() + currentContainer = null + currentBrowser = null + testManager = null + currentConfiguration = null + } + + boolean matchesCurrentContainerConfiguration(WebDriverContainerConfiguration specConfiguration) { + specConfiguration == currentConfiguration + } + + private static int getPort(IMethodInvocation invocation) { + try { + return (int) invocation.instance.metaClass.getProperty(invocation.instance, 'serverPort') + } catch (ignored) { + throw new IllegalStateException('Test class must be annotated with @Integration for serverPort to be injected') + } + } + + @PackageScope + boolean reinitialize(IMethodInvocation invocation) { + WebDriverContainerConfiguration specConfiguration = new WebDriverContainerConfiguration( + invocation.getSpec() + ) + if (matchesCurrentContainerConfiguration(specConfiguration)) { + return false + } + + if (initialized) { + stop() + } + + currentConfiguration = specConfiguration + currentContainer = new BrowserWebDriverContainer() + if (grailsGebSettings.recordingEnabled) { + currentContainer = currentContainer.withRecordingMode( + grailsGebSettings.recordingMode, + grailsGebSettings.recordingDirectory, + grailsGebSettings.recordingFormat + ) + } + currentContainer.tap { + withAccessToHost(true) + start() + } + if (hostnameChanged) { + currentContainer.execInContainer('/bin/sh', '-c', "echo '$hostIp\t${currentConfiguration.hostName}' | sudo tee -a /etc/hosts") + } + + ConfigObject configObject = new ConfigObject() + if (currentConfiguration.reporting) { + configObject.reportsDir = grailsGebSettings.getReportingDirectory() + configObject.reporter = (invocation.sharedInstance as ContainerGebSpec).createReporter() + } + + currentBrowser = new Browser(new Configuration(configObject, new Properties(), null, null)) + + WebDriver driver = new RemoteWebDriver(currentContainer.seleniumAddress, new ChromeOptions()) + driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(30)) + + currentBrowser.driver = driver + + // There's a bit of a chicken and egg problem here: the container & browser are initialized when + // the static/shared fields are initialized, which is before the grails server has started so the + // real url cannot be set (it will be checked as part of the geb test manager startup in reporting mode) + // set the url to localhost, which the selenium server should respond to (albeit with an error that will be ignored) + + currentBrowser.baseUrl = "http://localhost" + + testManager = createTestManager() + + return true + } + + void setupBrowserUrl(IMethodInvocation invocation) { + if (!currentBrowser) { + return + } + int port = getPort(invocation) + Testcontainers.exposeHostPorts(port) + + currentBrowser.baseUrl = "${currentConfiguration.protocol}://${currentConfiguration.hostName}:${port}" + } + + private GebTestManager createTestManager() { + new SpockGebTestManagerBuilder() + .withReportingEnabled(currentConfiguration.reporting) + .withBrowserCreator(new Supplier() { + @Override + Browser get() { + currentBrowser + } + }) + .build() + } + + private boolean getHostnameChanged() { + currentConfiguration.hostName != ContainerGebConfiguration.DEFAULT_HOSTNAME_FROM_CONTAINER + } + + private static String getHostIp() { + try { + PortForwardingContainer.getDeclaredMethod("getNetwork").with { + accessible = true + Optional network = invoke(PortForwardingContainer.INSTANCE) as Optional + return network.get().ipAddress + } + } catch (Exception e) { + throw new RuntimeException("Could not access network from PortForwardingContainer", e) + } + } + + /** + * Returns the hostname that the server under test is available on from the host. + *

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

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