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: + *
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 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