diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index dd84ea782..000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: '' -labels: '' -assignees: '' - ---- - -**Describe the bug** -A clear and concise description of what the bug is. - -**To Reproduce** -Steps to reproduce the behavior: -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Screenshots** -If applicable, add screenshots to help explain your problem. - -**Desktop (please complete the following information):** - - OS: [e.g. iOS] - - Browser [e.g. chrome, safari] - - Version [e.g. 22] - -**Smartphone (please complete the following information):** - - Device: [e.g. iPhone6] - - OS: [e.g. iOS8.1] - - Browser [e.g. stock browser, safari] - - Version [e.g. 22] - -**Additional context** -Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..319290ead --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,88 @@ +name: 🐞 Bug Report +description: File a Bug report in Java Integration +title: "🐞: " +labels: [ "type:bug", "triage" ] +assignees: [] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! + - type: textarea + id: what-happened + attributes: + label: What happened? + description: Also tell us, what did you expect to happen? + placeholder: Tell us what you see! + value: "A bug happened!" + validations: + required: true + - type: dropdown + id: integration + attributes: + label: What Allure Integration are you using? + multiple: true + description: Please select the Allure integration you + options: + - allure-assertj + - allure-attachments + - allure-awaitility + - allure-citrus + - allure-cucumber2-jvm + - allure-cucumber3-jvm + - allure-cucumber4-jvm + - allure-cucumber5-jvm + - allure-cucumber6-jvm + - allure-cucumber7-jvm + - allure-descriptions-javadoc + - allure-grpc + - allure-hamcrest + - allure-httpclient + - allure-java-commons + - allure-jax-rs + - allure-jbehave + - allure-jbehave5 + - allure-jsonunit + - allure-junit-platform + - allure-junit4 + - allure-jupiter + - allure-jupiter-assert + - allure-junit5 + - allure-junit5-assert + - allure-karate + - allure-okhttp + - allure-okhttp3 + - allure-reader + - allure-rest-assured + - allure-scalatest + - allure-selenide + - allure-servlet-api + - allure-spock + - allure-spock2 + - allure-spring-web + - allure-test-filter + - allure-testng + validations: + required: true + - type: input + id: integration_version + attributes: + label: What version of Allure Integration you are using? + placeholder: 2.22.3 + validations: + required: true + - type: input + id: allure_report_version + attributes: + label: What version of Allure Report you are using? + placeholder: 2.22.3 + validations: + required: true + - type: checkboxes + id: terms + attributes: + label: Code of Conduct + description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/allure-framework/allure-java/blob/main/CODE_OF_CONDUCT.md) + options: + - label: I agree to follow this project's Code of Conduct + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..fb81bedae --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: ⭐️ Allure Report | main repository | give us a ⭐️ + url: https://github.com/allure-framework/allure2 + about: For Allure Report related issues. + - name: 💬 Allure Report Community - for bugs, issues, dedicated support and more! + url: https://github.com/orgs/allure-framework/discussions + about: Please ask and answer questions here. + - name: 💚 Allure TestOps Support + url: https://help.qameta.io/support/home + about: Please report Allure TestOps issues here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index bbcbbe7d6..000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project -title: '' -labels: '' -assignees: '' - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. diff --git a/.github/labeler.yml b/.github/labeler.yml index c22e48e72..367271a00 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -38,7 +38,7 @@ - "allure-jax-rs/**" "theme:jbehave": - - "allure-jbehave/**" + - "allure-jbehave*/**" "theme:jsonunit": - "allure-jsonunit/**" @@ -48,8 +48,8 @@ - "allure-junit4-aspect/**" "theme:junit-platform": - - "allure-junit5/**" - - "allure-junit5-assert/**" + - "allure-jupiter/**" + - "allure-jupiter-assert/**" - "allure-junit-platform/**" "theme:karate": diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 984646994..341788f5e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,5 +1,8 @@ name: Build +permissions: + contents: read + on: workflow_dispatch: pull_request: @@ -7,29 +10,86 @@ on: - '*' push: branches: - - 'master' + - 'main' - 'hotfix-*' +concurrency: + # On main, we don't want any jobs cancelled. + # On PR branches, we cancel the job if new commits are pushed. + group: ${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + jobs: build: name: "Build" runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - java-version: [ '17.0.x' ] + env: + ALLURE_MATRIX_ENV: ubuntu-jdk-21 + ALLURE_TEST_DUMP_NAME: allure-results-test-jdk-21 steps: - - uses: actions/checkout@v3.3.0 + - uses: actions/checkout@v6 - - name: Set up JDK ${{ matrix.java-version }} - uses: actions/setup-java@v3 + - uses: actions/setup-node@v6 + with: + node-version: '20.x' + + - name: "Set up JDK" + uses: actions/setup-java@v5 with: distribution: 'zulu' - java-version: ${{ matrix.java-version }} + java-version: 21 + + - name: "Setup Gradle" + uses: gradle/actions/setup-gradle@v6 + with: + gradle-version: 'wrapper' - - name: Build with Gradle + - name: "Build with Gradle" run: ./gradlew build -x test --scan - - name: Run tests + - name: "Run tests with Allure" + if: always() + run: npx -y allure@3 run --config ./allurerc.mjs --rerun 2 --environment="${{ env.ALLURE_MATRIX_ENV }}" --dump="${{ env.ALLURE_TEST_DUMP_NAME }}" -- ./gradlew --no-build-cache cleanTest test + + - name: "Upload Allure test dump" if: always() - run: ./gradlew --no-build-cache cleanTest test + uses: actions/upload-artifact@v7 + with: + name: ${{ env.ALLURE_TEST_DUMP_NAME }} + path: ./${{ env.ALLURE_TEST_DUMP_NAME }}.zip + + report: + needs: [build] + name: "Build report" + runs-on: ubuntu-latest + if: always() + permissions: + contents: read + pull-requests: write + checks: write + env: + ALLURE_SERVICE_TOKEN: ${{ secrets.ALLURE_SERVICE_TOKEN }} + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: '20.x' + + - name: "Download Allure dumps" + uses: actions/download-artifact@v8 + continue-on-error: true + with: + pattern: allure-results-* + path: ./ + merge-multiple: true + + - name: "Generate Allure report" + run: npx -y allure@3 generate --config ./allurerc.mjs --dump="allure-results-*.zip" --output=./build/allure-report + + - name: "Post Allure summary" + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false + uses: allure-framework/allure-action@v0 + with: + report-directory: ./build/allure-report + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/dependency-submission.yml b/.github/workflows/dependency-submission.yml new file mode 100644 index 000000000..d02a1dbee --- /dev/null +++ b/.github/workflows/dependency-submission.yml @@ -0,0 +1,22 @@ +name: Dependency Submission + +on: + push: + branches: + - main + +permissions: + contents: write + +jobs: + dependency-submission: + name: Dependency Submission + runs-on: ubuntu-latest + steps: + - name: Checkout sources + uses: actions/checkout@v6 + - name: Generate and submit dependency graph + uses: gradle/actions/dependency-submission@v6 + env: + DEPENDENCY_GRAPH_EXCLUDE_PROJECTS: ':allure-java-commons-test' + DEPENDENCY_GRAPH_INCLUDE_CONFIGURATIONS: 'runtimeClasspath' diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index e0d99fc81..f49976b59 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -3,9 +3,14 @@ name: "Set theme labels" on: - pull_request_target +permissions: + contents: read + jobs: triage: runs-on: ubuntu-latest + permissions: + pull-requests: write steps: - uses: actions/labeler@v4 with: diff --git a/.github/workflows/labels-verify.yml b/.github/workflows/labels-verify.yml index 0c18ecb77..7315a905a 100644 --- a/.github/workflows/labels-verify.yml +++ b/.github/workflows/labels-verify.yml @@ -4,9 +4,14 @@ on: pull_request_target: types: [opened, labeled, unlabeled, synchronize] +permissions: + contents: none + jobs: triage: runs-on: ubuntu-latest + permissions: + pull-requests: read steps: - uses: baev/action-label-verify@main with: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3ccdaa8ea..fd0a661c4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,17 +4,20 @@ on: release: types: [ published ] +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3.3.0 + - uses: actions/checkout@v6 - - name: "Set up JDK 17.0.x" - uses: actions/setup-java@v3 + - name: "Set up JDK" + uses: actions/setup-java@v5 with: distribution: 'zulu' - java-version: '17.0.x' + java-version: '21' - name: Set up GPG run: echo -n "${GPG_PRIVATE_KEY}" | base64 --decode > ${GITHUB_WORKSPACE}/${GPG_KEY_ID}.gpg @@ -27,7 +30,7 @@ jobs: - name: "Gradle Publish" run: | - ./gradlew publishToSonatype -Pversion=${GITHUB_REF:10} \ + ./gradlew publishToSonatype closeAndReleaseSonatypeStagingRepository -Pversion=${GITHUB_REF:10} \ -Psigning.keyId=${GPG_KEY_ID} \ -Psigning.password=${GPG_PASSPHRASE} \ -Psigning.secretKeyRingFile=${GITHUB_WORKSPACE}/${GPG_KEY_ID}.gpg diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c76e6c1c3..7d5ace556 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,5 @@ name: Release +run-name: Release ${{ inputs.releaseVersion }} (next ${{ inputs.nextVersion }}) by ${{ github.actor }} on: workflow_dispatch: @@ -10,9 +11,14 @@ on: description: "The next version in . format WITHOUT SNAPSHOT SUFFIX" required: true +permissions: + contents: read + jobs: triage: runs-on: ubuntu-latest + permissions: + contents: write steps: - name: "Check release version" run: | @@ -20,7 +26,7 @@ jobs: - name: "Check next version" run: | expr "${{ github.event.inputs.nextVersion }}" : '[[:digit:]][[:digit:]]*\.[[:digit:]][[:digit:]]*$' - - uses: actions/checkout@v3.3.0 + - uses: actions/checkout@v6 with: token: ${{ secrets.QAMETA_CI }} diff --git a/.idea/vcs.xml b/.idea/vcs.xml index aeaa9e459..95443a122 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -10,7 +10,4 @@ - - - - + \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..8b7a6aa4d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,8 @@ +# Project Guide + +Use [Allure Agent Mode](docs/allure-agent-mode.md) for all test-related work in this repository. + +- Read `docs/allure-agent-mode.md` before designing, writing, reviewing, validating, debugging, or enriching tests. +- Run test-executing commands through `allure run`, including smoke checks after small edits. +- Use `./gradlew` for repo-local test commands and scope runs to the smallest relevant module or task. +- If agent-mode output is missing or incomplete, debug that first rather than relying on console-only conclusions. diff --git a/AUTHORS b/AUTHORS index 0edbd3fb9..8263d9471 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1,4 +1,4 @@ The following authors have created the source code of "Allure Java" -published and distributed by Qameta Software OÜ as the owner: +published and distributed by Qameta Software Inc as the owner: * Dmitry Baev diff --git a/Jenkinsfile b/Jenkinsfile deleted file mode 100644 index 64bac5a9e..000000000 --- a/Jenkinsfile +++ /dev/null @@ -1,40 +0,0 @@ -pipeline { - agent { label 'java' } - parameters { - booleanParam(name: 'RELEASE', defaultValue: false, description: 'Perform release?') - string(name: 'RELEASE_VERSION', defaultValue: '', description: 'Release version') - string(name: 'NEXT_VERSION', defaultValue: '', description: 'Next version (without SNAPSHOT)') - } - stages { - stage('Build') { - steps { - sh './gradlew build' - } - } - stage('Release') { - when { expression { return params.RELEASE } } - steps { - withCredentials([usernamePassword(credentialsId: 'qameta-ci_bintray', - usernameVariable: 'BINTRAY_USER', passwordVariable: 'BINTRAY_API_KEY')]) { - sshagent(['qameta-ci_ssh']) { - sh 'git checkout master && git pull origin master' - sh "./gradlew release -Prelease.useAutomaticVersion=true " + - "-Prelease.releaseVersion=${RELEASE_VERSION} " + - "-Prelease.newVersion=${NEXT_VERSION}-SNAPSHOT" - } - } - } - } - } - post { - always { - allure results: [[path: '**/build/allure-results']] - deleteDir() - } - - failure { - slackSend message: "${env.JOB_NAME} - #${env.BUILD_NUMBER} failed (<${env.BUILD_URL}|Open>)", - color: 'danger', teamDomain: 'qameta', channel: 'allure', tokenCredentialId: 'allure-channel' - } - } -} diff --git a/LICENSE b/LICENSE index e078585bf..961d751b7 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2021 Qameta Software OÜ + Copyright 2016-2026 Qameta Software Inc Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index 567df89e6..fd415585f 100644 --- a/README.md +++ b/README.md @@ -6,55 +6,56 @@ [twitter-team]: https://twitter.com/QametaSoftware/lists/team/members "Team" [CONTRIBUTING.md]: .github/CONTRIBUTING.md -[docs]: https://docs.qameta.io/allure/2.0/ +[docs]: https://allurereport.org/docs/ # Allure Java Integrations [![Build](https://github.com/allure-framework/allure-java/actions/workflows/build.yml/badge.svg)](https://github.com/allure-framework/allure-java/actions/workflows/build.yml) [![Allure Java](https://img.shields.io/github/release/allure-framework/allure-java.svg)](https://github.com/allure-framework/allure-java/releases/latest) -The repository contains new versions of adaptors for JVM-based test frameworks. +> The repository contains new versions of adaptors for JVM-based test frameworks. -All the artifacts are deployed to `https://dl.bintray.com/qameta/maven`. +[Allure Report logo](https://allurereport.org "Allure Report") -## TestNG - -The new TestNG adaptors is pretty much ready. To use the adaptor you should add the following dependency: +- Learn more about Allure Report at [https://allurereport.org](https://allurereport.org) +- 📚 [Documentation](https://allurereport.org/docs/) – discover official documentation for Allure Report +- ❓ [Questions and Support](https://github.com/orgs/allure-framework/discussions/categories/questions-support) – get help from the team and community +- 📢 [Official announcements](https://github.com/orgs/allure-framework/discussions/categories/announcements) – stay updated with our latest news and updates +- 💬 [General Discussion](https://github.com/orgs/allure-framework/discussions/categories/general-discussion) – engage in casual conversations, share insights and ideas with the community +- 🖥️ [Live Demo](https://demo.allurereport.org/) — explore a live example of Allure Report in action -```xml - - io.qameta.allure - allure-testng - $LATEST_VERSION - -``` +--- +## TestNG -also you need to configure AspectJ weaver to support steps. +- 🚀 Documentation — https://allurereport.org/docs/testng/ +- 📚 Example project — https://github.com/allure-examples?q=topic%3Atestng +- ✅ Generate a project in 10 seconds via Allure Start - https://allurereport.org/start/ ## JUnit 4 -The first draft of a new JUnit 4 adaptor is ready. To use the adaptor you should add the following dependency: - -```xml - - io.qameta.allure - allure-junit4 - $LATEST_VERSION - -``` - -## JUnit 5 - -To use JUnit 5 simply add the following dependency to your project: - -```xml - - io.qameta.allure - allure-junit5 - $LATEST_VERSION - -``` - +- 🚀 Documentation — work in progress +- 📚 Example project — https://github.com/allure-examples?q=topic%3Ajunit4 +- ✅ Generate a project in 10 seconds via Allure Start - https://allurereport.org/start/ +- +## JUnit Jupiter (JUnit 5 and 6) + +- 🚀 Documentation — https://allurereport.org/docs/junit5/ +- 📚 Example project — https://github.com/allure-examples?q=topic%3Ajunit5 +- ✅ Generate a project in 10 seconds via Allure Start - https://allurereport.org/start/ +- 🧩 Use `io.qameta.allure:allure-jupiter` for new setups. `allure-junit5` remains available as a deprecated compatibility alias during migration. + +## Cucumber JVM + +- 🚀 Documentation — https://allurereport.org/docs/cucumberjvm/ +- 📚 Example project — https://github.com/allure-examples?q=cucumber&type=all&language=java +- ✅ Generate a project in 10 seconds via Allure Start - https://allurereport.org/start/ + +## Spock + +- 🚀 Documentation — https://allurereport.org/docs/spock/ +- 📚 Example project — https://github.com/allure-examples?q=topic%3Aspock +- ✅ Generate a project in 10 seconds via Allure Start - https://allurereport.org/start/ + ## Selenide Listener for Selenide, that logging steps for Allure: @@ -76,10 +77,72 @@ SelenideLogger.addListener("AllureSelenide", new AllureSelenide().enableLogs(Log https://github.com/SeleniumHQ/selenium/wiki/Logging ``` - -## Rest Assured - -Filter for rest-assured http client, that generates attachment for allure. +## Playwright Java + +AspectJ-based integration for Playwright Java that reports browser actions as Allure steps and attaches +Playwright screenshots automatically: + +```xml + + io.qameta.allure + allure-playwright + $LATEST_VERSION + +``` + +Enable the AspectJ weaver for automatic action steps: +``` +-javaagent:/path/to/aspectjweaver.jar +``` + +Usage example with Playwright Java JUnit fixtures: +```java +@UsePlaywright +class UiTest { + + @Test + void shouldOpenPage(Page page) { + page.navigate("https://playwright.dev"); + page.screenshot(); + } +} +``` + +The module registers an Allure test lifecycle listener automatically, so per-test cleanup, failure diagnostics, +and final trace/log flush work with any test framework that reports through Allure. Playwright pages and +contexts are tracked by the AspectJ integration when they are created or used. Use +`AllurePlaywright.register(...)` only for pages or contexts the aspect cannot observe. + +Frameworks or custom runners that do not use the Allure lifecycle can call the reporting hooks directly: +```java +AllurePlaywright.beforeTest(); +try { + testBody(); +} catch (Throwable e) { + AllurePlaywright.afterTestFailure(e); + throw e; +} finally { + AllurePlaywright.afterTest(); +} +``` + +The following defaults can be overridden in `allure.properties`: +``` +allure.playwright.steps.enabled=true +allure.playwright.steps.mode=actions +allure.playwright.parameters=redacted +allure.playwright.screenshots.attach=true +allure.playwright.failure.screenshot=true +allure.playwright.failure.page-source=true +allure.playwright.close.trace=true +allure.playwright.close.video=true +allure.playwright.close.page-logs=true +``` + + +## Rest Assured + +Filter for rest-assured http client, that generates attachment for allure. ```xml @@ -95,14 +158,50 @@ Usage example: ``` You can specify custom templates, which should be placed in src/main/resources/tpl folder: ``` -.filter(new AllureRestAssured() - .withRequestTemplate("custom-http-request.ftl") - .withResponseTemplate("custom-http-response.ftl")) -``` - -## OkHttp - -Interceptor for OkHttp client, that generates attachment for allure. +.filter(new AllureRestAssured() + .withRequestTemplate("custom-http-request.ftl") + .withResponseTemplate("custom-http-response.ftl")) +``` + +## Spring Web + +Interceptor for Spring synchronous HTTP clients, that generates attachments for allure. + +```xml + + io.qameta.allure + allure-spring-web + $LATEST_VERSION + +``` + +Usage example with `RestClient`: +``` +RestClient restClient = RestClient.builder() + .requestFactory(new BufferingClientHttpRequestFactory(new SimpleClientHttpRequestFactory())) + .requestInterceptor(new AllureRestTemplate()) + .build(); +``` +Use a buffering request factory when the client should still be able to read the response body after Allure captures it. + +`RestTemplate` remains supported: +``` +RestTemplate restTemplate = new RestTemplate( + new BufferingClientHttpRequestFactory(new SimpleClientHttpRequestFactory()) +); +restTemplate.setInterceptors(Collections.singletonList(new AllureRestTemplate())); +``` + +You can specify custom templates, which should be placed in src/main/resources/tpl folder: +``` +new AllureRestTemplate() + .setRequestTemplate("custom-http-request.ftl") + .setResponseTemplate("custom-http-response.ftl") +``` + +## OkHttp + +Interceptor for OkHttp client, that generates attachment for allure. ```xml @@ -176,6 +275,25 @@ Usage example: .addInterceptorLast(new AllureHttpClientResponse()); ``` +## Http client 5 +Interceptors for Apache [httpclient5](https://hc.apache.org/httpcomponents-client-5.2.x/index.html). +Additional info can be found in module `allure-httpclient5` + +```xml + + io.qameta.allure + allure-httpclient5 + $LATEST_VERSION + +``` + +Usage example: +```java +final HttpClientBuilder builder = HttpClientBuilder.create() + .addRequestInterceptorFirst(new AllureHttpClient5Request("your-request-template-attachment.ftl")) + .addResponseInterceptorLast(new AllureHttpClient5Response("your-response-template-attachment.ftl")); +``` + ## JAX-RS Filter Filter that can be used with JAX-RS compliant clients such as RESTeasy and Jersey @@ -219,4 +337,5 @@ more usage example look into module `allure-awaitility` Usage example: ``` Awaitility.setDefaultConditionEvaluationListener(new AllureAwaitilityListener()); -``` \ No newline at end of file +``` + diff --git a/allure-assertj/src/main/java/io/qameta/allure/assertj/AllureAspectJ.java b/allure-assertj/src/main/java/io/qameta/allure/assertj/AllureAspectJ.java index fbf10f7f0..7ba05d2f8 100644 --- a/allure-assertj/src/main/java/io/qameta/allure/assertj/AllureAspectJ.java +++ b/allure-assertj/src/main/java/io/qameta/allure/assertj/AllureAspectJ.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,28 +17,22 @@ import io.qameta.allure.Allure; import io.qameta.allure.AllureLifecycle; -import io.qameta.allure.model.Status; -import io.qameta.allure.model.StepResult; -import io.qameta.allure.util.ObjectUtils; import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.After; import org.aspectj.lang.annotation.AfterReturning; -import org.aspectj.lang.annotation.AfterThrowing; +import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; -import org.aspectj.lang.annotation.Before; import org.aspectj.lang.annotation.Pointcut; import org.aspectj.lang.reflect.MethodSignature; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import org.assertj.core.api.AbstractAssert; -import java.util.UUID; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import static io.qameta.allure.util.ResultsUtils.getStatus; -import static io.qameta.allure.util.ResultsUtils.getStatusDetails; +import java.util.function.Supplier; /** + * Captures user-side AssertJ factories and fluent calls, then delegates assertion-chain state + * to {@link AssertJRecorder}. + * * @author charlie (Dmitry Baev). * @author sskorol (Sergey Korol). */ @@ -46,8 +40,6 @@ @Aspect public class AllureAspectJ { - private static final Logger LOGGER = LoggerFactory.getLogger(AllureAspectJ.class); - private static InheritableThreadLocal lifecycle = new InheritableThreadLocal() { @Override protected AllureLifecycle initialValue() { @@ -55,64 +47,83 @@ protected AllureLifecycle initialValue() { } }; - @Pointcut("execution(!private org.assertj.core.api.AbstractAssert.new(..))") - public void anyAssertCreation() { + private static final ThreadLocal RECORDER = ThreadLocal.withInitial(AssertJRecorder::new); + + private static final ThreadLocal RECORDING_MUTED = ThreadLocal.withInitial(() -> false); + + @Pointcut( + "(" + + "call(public static * org.assertj.core.api.Assertions*.assertThat*(..))" + + " || call(public static * org.assertj.core.api.BDDAssertions*.then*(..))" + + " || call(public * org.assertj.core.api.*SoftAssertionsProvider+.assertThat*(..))" + + " || call(public * org.assertj.core.api.*SoftAssertionsProvider+.then*(..))" + + ")" + ) + public void assertFactoryCall() { //pointcut body, should be empty } - @Pointcut("execution(* org.assertj.core.api.AssertJProxySetup.*(..))") - public void proxyMethod() { + @Pointcut( + "(" + + "call(public * org.assertj.core.api.AbstractAssert+.*(..))" + + " || call(public * org.assertj.core.api.Assert+.*(..))" + + " || call(public * org.assertj.core.api.Descriptable+.*(..))" + + ")" + + " && target(assertion)" + ) + public void assertOperationCall(final AbstractAssert assertion) { //pointcut body, should be empty } - @Pointcut("execution(public * org.assertj.core.api.AbstractAssert+.*(..)) && !proxyMethod()") - public void anyAssert() { + @Pointcut("!within(org.assertj..*) && !within(io.qameta.allure.assertj.AllureAspectJ)") + public void userCodeCall() { //pointcut body, should be empty } - @After("anyAssertCreation()") - public void logAssertCreation(final JoinPoint joinPoint) { - final String actual = joinPoint.getArgs().length > 0 - ? ObjectUtils.toString(joinPoint.getArgs()[0]) - : ""; - final String uuid = UUID.randomUUID().toString(); - final String name = String.format("assertThat \'%s\'", actual); - - final StepResult result = new StepResult() - .setName(name) - .setStatus(Status.PASSED); + @AfterReturning( + pointcut = "assertFactoryCall() && userCodeCall()", + returning = "result" + ) + public void logAssertCreation(final JoinPoint joinPoint, final Object result) { + if (isRecordingMuted() || !(result instanceof AbstractAssert)) { + return; + } - getLifecycle().startStep(uuid, result); - getLifecycle().stopStep(uuid); + final AbstractAssert assertion = (AbstractAssert) result; + getRecorder().assertionCreated(getLifecycle(), assertion, firstArgumentOf(joinPoint)); } - @Before("anyAssert()") - public void stepStart(final JoinPoint joinPoint) { - final MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature(); - - final String uuid = UUID.randomUUID().toString(); - final String name = joinPoint.getArgs().length > 0 - ? String.format("%s \'%s\'", methodSignature.getName(), arrayToString(joinPoint.getArgs())) - : methodSignature.getName(); - - final StepResult result = new StepResult() - .setName(name); - - getLifecycle().startStep(uuid, result); - } + @Around("assertOperationCall(assertion) && userCodeCall()") + public Object logAssertOperation(final ProceedingJoinPoint joinPoint, + final AbstractAssert assertion) + throws Throwable { + final String methodName = getMethodName(joinPoint); + if (isRecordingMuted() || getRecorder().isIgnored(methodName)) { + return joinPoint.proceed(); + } - @AfterThrowing(pointcut = "anyAssert()", throwing = "e") - public void stepFailed(final Throwable e) { - getLifecycle().updateStep(s -> s - .setStatus(getStatus(e).orElse(Status.BROKEN)) - .setStatusDetails(getStatusDetails(e).orElse(null))); - getLifecycle().stopStep(); + final AssertJOperation operation = getRecorder().startOperation( + getLifecycle(), + assertion, + methodName, + joinPoint.getArgs() + ); + try { + final Object result = joinPoint.proceed(); + getRecorder().operationPassed(operation, result); + return result; + } catch (Throwable throwable) { + getRecorder().operationFailed(operation, throwable); + throw throwable; + } } - @AfterReturning(pointcut = "anyAssert()") - public void stepStop() { - getLifecycle().updateStep(s -> s.setStatus(Status.PASSED)); - getLifecycle().stopStep(); + @After( + "execution(public void org.assertj.core.api.DefaultAssertionErrorCollector.collectAssertionError(" + + "java.lang.AssertionError)) && args(error)" + ) + public void softAssertionFailed(final AssertionError error) { + getRecorder().softAssertionFailed(error); } /** @@ -122,15 +133,40 @@ public void stepStop() { */ public static void setLifecycle(final AllureLifecycle allure) { lifecycle.set(allure); + clearContext(); } public static AllureLifecycle getLifecycle() { return lifecycle.get(); } - private static String arrayToString(final Object... array) { - return Stream.of(array) - .map(ObjectUtils::toString) - .collect(Collectors.joining(" ")); + public static void clearContext() { + RECORDER.remove(); + } + + static T withoutRecording(final Supplier supplier) { + final boolean previous = RECORDING_MUTED.get(); + RECORDING_MUTED.set(true); + try { + return supplier.get(); + } finally { + RECORDING_MUTED.set(previous); + } + } + + private static AssertJRecorder getRecorder() { + return RECORDER.get(); + } + + private static boolean isRecordingMuted() { + return RECORDING_MUTED.get(); + } + + private static Object firstArgumentOf(final JoinPoint joinPoint) { + return joinPoint.getArgs().length == 0 ? null : joinPoint.getArgs()[0]; + } + + private static String getMethodName(final ProceedingJoinPoint joinPoint) { + return ((MethodSignature) joinPoint.getSignature()).getMethod().getName(); } } diff --git a/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJChain.java b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJChain.java new file mode 100644 index 000000000..c4d30c752 --- /dev/null +++ b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJChain.java @@ -0,0 +1,123 @@ +/* + * Copyright 2016-2026 Qameta Software Inc + * + * 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 io.qameta.allure.assertj; + +import io.qameta.allure.model.Stage; +import io.qameta.allure.model.Status; +import io.qameta.allure.model.StatusDetails; +import io.qameta.allure.model.StepResult; +import org.assertj.core.api.AbstractAssert; + +import java.util.Optional; +import java.util.UUID; + +/** + * Parent Allure step for one AssertJ assertion chain. + * + *

A chain is the stable container for all meaningful fluent operations produced by one AssertJ assertion object. + * {@link AssertJRecorder} creates it when user code calls an AssertJ factory such as {@code assertThat(actual)}, + * stores it by assertion object identity, and appends one {@link AssertJOperation} child for every reported fluent + * call. Methods such as {@code extracting}, {@code first}, or {@code asInstanceOf} can return another assertion + * object, but they should still read as the same assertion story, so the returned assertion is associated with this + * chain instead of creating an unrelated top-level step.

+ * + *

For a scalar assertion:

+ *
{@code
+ * assertThat("Data").hasSize(4)
+ *
+ * assert "Data"
+ *   has size 4
+ * }
+ * + *

For an assertion with a description, the parent step is renamed while the operation history stays visible:

+ *
{@code
+ * assertThat(user).as("user profile").isNotNull()
+ *
+ * assert user profile
+ *   described as "user profile"
+ *   is not null
+ * }
+ * + *

For navigation or extraction, later checks remain under the same parent:

+ *
{@code
+ * assertThat(results).extracting(Result::getName).containsExactly("passed")
+ *
+ * assert 1 Result item
+ *   extracts Result::getName -> 1 string
+ *   contains exactly ["passed"]
+ * }
+ * + *

This class is intentionally only a small mutable model around the retained {@link StepResult}. It owns the + * parent step name, status, timing, and child operation list. It does not decide which AssertJ methods are meaningful + * or how subjects and arguments are rendered; those decisions belong to {@link AssertJRecorder}, + * {@link AssertJMethodSupport}, and {@link AssertJValueRenderer}.

+ */ +final class AssertJChain { + + private static final String ASSERTJ_STEP_PREFIX = "assert "; + + private final String uuid; + + private final AbstractAssert assertion; + + private final StepResult step; + + AssertJChain(final AbstractAssert assertion, final String subject) { + this.uuid = UUID.randomUUID().toString(); + this.assertion = assertion; + this.step = new StepResult() + .setName(chainName(subject)) + .setStatus(Status.PASSED) + .setStage(Stage.FINISHED) + .setStart(System.currentTimeMillis()) + .setStop(System.currentTimeMillis()); + } + + String getUuid() { + return uuid; + } + + AbstractAssert getAssertion() { + return assertion; + } + + StepResult getStep() { + return step; + } + + void addOperation(final AssertJOperation operation) { + step.getSteps().add(operation.getStep()); + } + + void rename(final Optional description) { + description.ifPresent(value -> step.setName(chainName(value))); + } + + void updateStatus(final Status status, final StatusDetails details) { + step + .setStatus(status) + .setStatusDetails(details); + finish(); + } + + void finish() { + step.setStop(System.currentTimeMillis()); + } + + private String chainName(final String subject) { + return AssertJValueRenderer.truncateStepName(ASSERTJ_STEP_PREFIX + subject); + } +} diff --git a/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJLifecycleListener.java b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJLifecycleListener.java new file mode 100644 index 000000000..c9ea7a1ea --- /dev/null +++ b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJLifecycleListener.java @@ -0,0 +1,44 @@ +/* + * Copyright 2016-2026 Qameta Software Inc + * + * 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 io.qameta.allure.assertj; + +import io.qameta.allure.listener.FixtureLifecycleListener; +import io.qameta.allure.listener.TestLifecycleListener; +import io.qameta.allure.model.FixtureResult; +import io.qameta.allure.model.TestResult; + +/** + * Clears per-thread AssertJ recorder state after Allure has finished owning the current result. + * + *

{@link AllureAspectJ} keeps an {@link AssertJRecorder} in a {@link ThreadLocal} so assertion objects can + * be matched by identity across later fluent calls. Test engines commonly reuse worker threads, so that + * thread-local map would otherwise keep old assertion objects, rendered steps, and operation stack state after + * the test or fixture result has already been written. The retained {@code StepResult}s are already attached to + * the Allure model by reference, so removing the recorder here does not remove any reported steps; it only + * releases per-thread bookkeeping before the next test or fixture starts on the same thread.

+ */ +public class AssertJLifecycleListener implements TestLifecycleListener, FixtureLifecycleListener { + + @Override + public void afterTestWrite(final TestResult result) { + AllureAspectJ.clearContext(); + } + + @Override + public void afterFixtureStop(final FixtureResult result) { + AllureAspectJ.clearContext(); + } +} diff --git a/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJMethodSupport.java b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJMethodSupport.java new file mode 100644 index 000000000..455b28039 --- /dev/null +++ b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJMethodSupport.java @@ -0,0 +1,98 @@ +/* + * Copyright 2016-2026 Qameta Software Inc + * + * 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 io.qameta.allure.assertj; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Keeps method-name decisions out of the aspect and recorder flow. + */ +final class AssertJMethodSupport { + + private static final String AS = "as"; + private static final String DESCRIBED_AS = "describedAs"; + + private static final List IGNORED_METHODS = Arrays.asList( + "actual", + "descriptionText", + "equals", + "getWritableAssertionInfo", + "hashCode", + "toString" + ); + + private static final Set NAVIGATION_METHODS = new HashSet<>( + Arrays.asList( + "asBase64Decoded", + "asBoolean", + "asByte", + "asDouble", + "asFloat", + "asInstanceOf", + "asInt", + "asList", + "asLong", + "asShort", + "asString", + "bytes", + "decodedAsBase64", + "element", + "elements", + "extracting", + "extractingResultOf", + "first", + "flatExtracting", + "flatMap", + "last", + "map", + "rootCause", + "singleElement", + "size", + "usingRecursiveAssertion", + "usingRecursiveComparison" + ) + ); + + private AssertJMethodSupport() { + throw new IllegalStateException("do not instantiate"); + } + + static boolean isIgnored(final String methodName) { + return IGNORED_METHODS.contains(methodName); + } + + static String normalize(final String methodName) { + final int accessorIndex = methodName.indexOf("$accessor$"); + if (accessorIndex > 0) { + return methodName.substring(0, accessorIndex); + } + if (DESCRIBED_AS.equals(methodName)) { + return AS; + } + return methodName; + } + + static boolean isDescription(final String methodName) { + return AS.equals(methodName) || DESCRIBED_AS.equals(methodName); + } + + static boolean isNavigation(final String methodName) { + return NAVIGATION_METHODS.contains(methodName); + } +} diff --git a/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJOperation.java b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJOperation.java new file mode 100644 index 000000000..f06b8be49 --- /dev/null +++ b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJOperation.java @@ -0,0 +1,160 @@ +/* + * Copyright 2016-2026 Qameta Software Inc + * + * 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 io.qameta.allure.assertj; + +import io.qameta.allure.model.Parameter; +import io.qameta.allure.model.Stage; +import io.qameta.allure.model.Status; +import io.qameta.allure.model.StatusDetails; +import io.qameta.allure.model.StepResult; + +import java.util.List; + +import static io.qameta.allure.util.ResultsUtils.getStatus; +import static io.qameta.allure.util.ResultsUtils.getStatusDetails; + +/** + * Child Allure step for one meaningful AssertJ fluent operation. + * + *

An operation is the report entry for one fluent method call inside an {@link AssertJChain}. The recorder creates + * it before proceeding with the intercepted AssertJ call, marks it passed or failed after the call returns, and keeps + * it attached to the chain that owns the assertion object. Earlier operations remain passed when a later operation + * fails, so the report shows the exact point where the assertion chain stopped matching the expectation.

+ * + *

For a simple assertion, each checked method becomes one operation:

+ *
{@code
+ * assertThat("Data").startsWith("Da").endsWith("ta")
+ *
+ * assert "Data"
+ *   starts with "Da"
+ *   ends with "ta"
+ * }
+ * + *

For navigation methods, the operation name is enriched with the returned subject. The returned AssertJ object + * still belongs to the same chain, so the report stays readable as one story:

+ *
{@code
+ * assertThat(users).first(InstanceOfAssertFactories.STRING).startsWith("alice")
+ *
+ * assert 1 string
+ *   first element as InstanceOfAssertFactory -> "alice@example.org"
+ *   starts with "alice"
+ * }
+ * + *

For failures, this operation receives the failure status and status details, and the parent chain receives the + * same status. This makes the failed operation visible without losing the successful context before it:

+ *
{@code
+ * assertThat("Data").startsWith("Da").hasSize(5)
+ *
+ * assert "Data"                 FAILED
+ *   starts with "Da"             PASSED
+ *   has size 5                   FAILED
+ * }
+ * + *

Some AssertJ methods call other assertion methods internally. Those calls should not become extra child steps + * because they would duplicate implementation details instead of user intent. The {@code nestedLevel} counter lets the + * recorder reuse the active operation while those internal calls run, then finish only the user-visible operation.

+ */ +final class AssertJOperation { + + private final AssertJChain chain; + + private final String methodName; + + private final StepResult step; + + private final boolean navigation; + + private String returnedSubject; + + private int nestedLevel; + + AssertJOperation(final AssertJChain chain, + final String methodName, + final String name, + final List parameters, + final boolean navigation) { + this.chain = chain; + this.methodName = methodName; + this.navigation = navigation; + this.step = new StepResult() + .setName(name) + .setParameters(parameters) + .setStage(Stage.RUNNING) + .setStart(System.currentTimeMillis()); + } + + AssertJChain getChain() { + return chain; + } + + StepResult getStep() { + return step; + } + + boolean isNavigation() { + return navigation; + } + + boolean isDescription() { + return AssertJMethodSupport.isDescription(methodName); + } + + boolean isNested() { + return nestedLevel > 0; + } + + AssertJOperation nested() { + nestedLevel++; + return this; + } + + void leaveNested() { + nestedLevel--; + } + + void setReturnedSubject(final String subject) { + if (returnedSubject != null) { + return; + } + + returnedSubject = subject; + step.setName(AssertJValueRenderer.truncateStepName(step.getName() + " -> " + subject)); + } + + void passed() { + if (step.getStatus() == null) { + step.setStatus(Status.PASSED); + } + finish(); + } + + void failed(final Throwable throwable) { + final Status status = getStatus(throwable).orElse(Status.BROKEN); + final StatusDetails details = getStatusDetails(throwable).orElse(null); + step + .setStatus(status) + .setStatusDetails(details); + chain.updateStatus(status, details); + finish(); + } + + private void finish() { + step + .setStage(Stage.FINISHED) + .setStop(System.currentTimeMillis()); + chain.finish(); + } +} diff --git a/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJRecorder.java b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJRecorder.java new file mode 100644 index 000000000..50bc77531 --- /dev/null +++ b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJRecorder.java @@ -0,0 +1,259 @@ +/* + * Copyright 2016-2026 Qameta Software Inc + * + * 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 io.qameta.allure.assertj; + +import io.qameta.allure.AllureLifecycle; +import org.assertj.core.api.AbstractAssert; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.Optional; + +/** + * Records AssertJ objects by identity and builds one Allure step tree per assertion chain. + * + *

The recorder is the stateful part behind {@link AllureAspectJ}. The aspect only detects user-side AssertJ + * factory calls and fluent operation calls; this class decides which {@link AssertJChain} owns the assertion object, + * where a new {@link AssertJOperation} should be attached, and how pass/fail state should be reflected in the retained + * Allure {@code StepResult} tree.

+ * + *

Each aspect thread gets its own recorder instance. Assertion objects are tracked in an {@link IdentityHashMap} + * because AssertJ assertion classes can override {@code equals} and {@code hashCode}; object identity is the only safe + * way to know that a later fluent call belongs to the same assertion object that was created earlier.

+ * + *

The normal hard-assertion flow is:

+ *
{@code
+ * assertThat("Data").startsWith("Da").endsWith("ta")
+ *
+ * assertionCreated(assertThat result, "Data")
+ * startOperation(startsWith, ["Da"])
+ * operationPassed(startsWith)
+ * startOperation(endsWith, ["ta"])
+ * operationPassed(endsWith)
+ *
+ * assert "Data"
+ *   starts with "Da"
+ *   ends with "ta"
+ * }
+ * + *

Stored assertion instances keep separate chains because the map key is the assertion instance itself:

+ *
{@code
+ * final AbstractStringAssert a = assertThat("alpha");
+ * final AbstractStringAssert b = assertThat("bravo");
+ *
+ * a.isEqualTo("alpha");
+ * b.isEqualTo("bravo");
+ *
+ * assert "alpha"
+ *   is equal to "alpha"
+ * assert "bravo"
+ *   is equal to "bravo"
+ * }
+ * + *

Navigation operations such as {@code extracting}, {@code first}, and {@code asInstanceOf} may return new AssertJ + * assertion objects. Those returned objects are registered against the existing chain, so later checks stay under the + * same parent step:

+ *
{@code
+ * assertThat(results).extracting(Result::getName).containsExactly("passed")
+ *
+ * assert 1 Result item
+ *   extracts Result::getName -> 1 string
+ *   contains exactly ["passed"]
+ * }
+ * + *

The {@code operations} stack tracks the currently executing user-visible operation. It has two jobs: assertions + * created inside callbacks such as {@code satisfies} are attached beneath the active operation, and AssertJ internal + * calls on the same chain are counted as nested work instead of being reported as extra steps.

+ * + *
{@code
+ * assertThat("alpha").satisfies(value -> assertThat(value).startsWith("al"))
+ *
+ * assert "alpha"
+ *   satisfies 
+ *     assert "alpha"
+ *       starts with "al"
+ * }
+ * + *

Soft assertion failures are reported before {@code assertAll()} throws. The AssertJ error collector callback calls + * {@link #softAssertionFailed(AssertionError)}, which marks the active operation and its chain as failed while + * preserving the earlier passed operations.

+ */ +final class AssertJRecorder { + + private final Map, AssertJChain> chains = new IdentityHashMap<>(); + + private final Deque operations = new ArrayDeque<>(); + + private final AssertJValueRenderer renderer = new AssertJValueRenderer(); + + void assertionCreated(final AllureLifecycle lifecycle, + final AbstractAssert assertion, + final Object actual) { + if (chains.containsKey(assertion)) { + return; + } + + final AssertJOperation activeOperation = activeOperation(); + if (isNavigationResult(activeOperation)) { + chains.put(assertion, activeOperation.getChain()); + return; + } + + final AssertJChain chain = new AssertJChain(assertion, renderer.renderSubject(actual)); + chains.put(assertion, chain); + attachChain(lifecycle, chain, activeOperation); + } + + AssertJOperation startOperation(final AllureLifecycle lifecycle, + final AbstractAssert assertion, + final String methodName, + final Object... args) { + final AssertJChain chain = chainFor(lifecycle, assertion); + final String normalizedName = AssertJMethodSupport.normalize(methodName); + + final AssertJOperation activeOperation = activeOperation(); + if (isInternalCallOnSameChain(activeOperation, chain)) { + return activeOperation.nested(); + } + + final AssertJOperation operation = new AssertJOperation( + chain, + normalizedName, + renderer.renderOperation(normalizedName, args), + renderer.renderParameters(normalizedName, args), + AssertJMethodSupport.isNavigation(normalizedName) + ); + chain.addOperation(operation); + operations.push(operation); + return operation; + } + + void operationPassed(final AssertJOperation operation, final Object result) { + if (operation.isNested()) { + pop(operation); + return; + } + + registerReturnedAssertion(operation, result); + renameChainFromDescription(operation); + operation.passed(); + pop(operation); + } + + void operationFailed(final AssertJOperation operation, final Throwable throwable) { + operation.failed(throwable); + pop(operation); + } + + void softAssertionFailed(final AssertionError error) { + final AssertJOperation current = activeOperation(); + if (current != null) { + current.failed(error); + } + } + + boolean isIgnored(final String methodName) { + return AssertJMethodSupport.isIgnored(methodName); + } + + private AssertJChain chainFor(final AllureLifecycle lifecycle, final AbstractAssert assertion) { + final AssertJChain chain = chains.get(assertion); + if (chain != null) { + return chain; + } + + final AssertJChain created = new AssertJChain(assertion, renderer.renderSubject(actualOf(assertion))); + chains.put(assertion, created); + attachChain(lifecycle, created, activeOperation()); + return created; + } + + private void attachChain(final AllureLifecycle lifecycle, + final AssertJChain chain, + final AssertJOperation parentOperation) { + if (parentOperation == null) { + lifecycle.startStep(chain.getUuid(), chain.getStep()); + lifecycle.stopStep(chain.getUuid()); + return; + } + + parentOperation.getStep().getSteps().add(chain.getStep()); + } + + private void registerReturnedAssertion(final AssertJOperation operation, final Object result) { + if (!(result instanceof AbstractAssert)) { + return; + } + + final AbstractAssert returned = (AbstractAssert) result; + chains.put(returned, operation.getChain()); + if (operation.isNavigation()) { + operation.setReturnedSubject(renderer.renderSubject(actualOf(returned))); + } + } + + private void renameChainFromDescription(final AssertJOperation operation) { + if (operation.isDescription()) { + operation.getChain().rename(descriptionOf(operation.getChain().getAssertion())); + } + } + + private AssertJOperation activeOperation() { + return operations.peek(); + } + + private boolean isNavigationResult(final AssertJOperation activeOperation) { + return activeOperation != null && activeOperation.isNavigation(); + } + + private boolean isInternalCallOnSameChain(final AssertJOperation activeOperation, final AssertJChain chain) { + return activeOperation != null && activeOperation.getChain() == chain; + } + + private void pop(final AssertJOperation operation) { + if (operation.isNested()) { + operation.leaveNested(); + return; + } + if (!operations.isEmpty() && operations.peek() == operation) { + operations.pop(); + } + } + + private Object actualOf(final AbstractAssert assertion) { + return AllureAspectJ.withoutRecording(() -> { + try { + return assertion.actual(); + } catch (RuntimeException e) { + return null; + } + }); + } + + private Optional descriptionOf(final AbstractAssert assertion) { + return AllureAspectJ.withoutRecording(() -> { + try { + return Optional.ofNullable(assertion.descriptionText()) + .map(String::trim) + .filter(value -> !value.isEmpty()); + } catch (RuntimeException e) { + return Optional.empty(); + } + }); + } +} diff --git a/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJValueRenderer.java b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJValueRenderer.java new file mode 100644 index 000000000..3e33ae78c --- /dev/null +++ b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJValueRenderer.java @@ -0,0 +1,558 @@ +/* + * Copyright 2016-2026 Qameta Software Inc + * + * 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 io.qameta.allure.assertj; + +import io.qameta.allure.model.Parameter; +import io.qameta.allure.util.ObjectUtils; +import org.assertj.core.description.Description; +import org.assertj.core.groups.Tuple; + +import java.lang.reflect.Array; +import java.net.URI; +import java.net.URL; +import java.nio.file.Path; +import java.time.temporal.TemporalAccessor; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import static io.qameta.allure.util.ResultsUtils.getLambdaName; + +/** + * Renders AssertJ subjects and arguments into semantic step names. + */ +@SuppressWarnings("all") +final class AssertJValueRenderer { + + private static final int STEP_NAME_LIMIT = 1000; + + private static final int INLINE_VALUE_LIMIT = 3; + + private static final String LAMBDA = ""; + + private static final String TRUNCATED = "..."; + + String renderSubject(final Object value) { + return truncateStepName(renderSubjectValue(value)); + } + + String renderOperation(final String methodName, final Object[] args) { + return truncateStepName(renderOperationName(methodName, args)); + } + + List renderParameters(final String methodName, final Object[] args) { + final Object[] values = parameterArguments(methodName, args); + if (values.length == 0) { + return Collections.emptyList(); + } + + final String renderedOperation = renderOperation(methodName, args); + final List parameters = new ArrayList<>(); + for (int index = 0; index < values.length; index++) { + final String value = renderParameterValue(values[index]); + if (renderedOperation.contains(value)) { + continue; + } + parameters.add( + new Parameter() + .setName(parameterName(methodName, index)) + .setValue(value) + .setMode(Parameter.Mode.DEFAULT) + ); + } + return parameters; + } + + static String truncateStepName(final String value) { + if (value == null || value.length() <= STEP_NAME_LIMIT) { + return value; + } + return value.substring(0, STEP_NAME_LIMIT - TRUNCATED.length()) + TRUNCATED; + } + + private String renderSubjectValue(final Object value) { + if (value == null) { + return "null"; + } + if (value instanceof CharSequence || isSimple(value)) { + return renderSimple(value); + } + if (value instanceof Collection) { + if (isInlineCollection((Collection) value)) { + return renderCollectionValue((Collection) value); + } + return renderCollectionSubject((Collection) value); + } + if (value instanceof Map) { + return "map with " + renderEntryCount(((Map) value).size()); + } + if (value.getClass().isArray()) { + return renderArraySubject(value); + } + if (value instanceof Iterable) { + return "iterable"; + } + return simpleClassName(value); + } + + private Object[] parameterArguments(final String methodName, final Object[] args) { + if (isDescriptionWithEmptyValues(args)) { + return new Object[]{args[0]}; + } + if (isSingleVarargToUnwrap(methodName, args)) { + return new Object[]{Array.get(args[0], 0)}; + } + return args; + } + + private String parameterName(final String methodName, final int index) { + if ("hasFieldOrPropertyWithValue".equals(methodName)) { + return index == 0 ? "field or property" : "expected value"; + } + + if (index > 0) { + return "argument " + (index + 1); + } + + switch (methodName) { + case "as": + return "description"; + case "asInstanceOf": + case "first": + case "singleElement": + return "factory"; + case "extracting": + case "flatExtracting": + return "extractor"; + case "hasSize": + return "expected size"; + case "satisfies": + return "condition"; + case "contains": + case "containsExactly": + case "containsExactlyInAnyOrder": + case "endsWith": + case "isEqualTo": + case "startsWith": + return "expected"; + default: + return "argument 1"; + } + } + + private String renderParameterValue(final Object value) { + return renderArgument(value); + } + + private String renderOperationName(final String methodName, final Object[] args) { + if (args.length == 0) { + return readableMethodName(methodName); + } + + final String arguments = renderArguments(methodName, args); + switch (methodName) { + case "as": + return "described as " + arguments; + case "asInstanceOf": + return "as instance of " + arguments; + case "contains": + return "contains " + arguments; + case "containsExactly": + return "contains exactly " + arguments; + case "containsExactlyInAnyOrder": + return "contains exactly in any order " + arguments; + case "endsWith": + return "ends with " + arguments; + case "extracting": + return "extracts " + arguments; + case "flatExtracting": + return "flat extracts " + arguments; + case "first": + return "first element as " + arguments; + case "hasFieldOrPropertyWithValue": + return renderHasFieldOrPropertyWithValue(args); + case "hasSize": + return "has size " + arguments; + case "isEqualTo": + return "is equal to " + arguments; + case "singleElement": + return "single element as " + arguments; + case "startsWith": + return "starts with " + arguments; + case "satisfies": + return "satisfies " + arguments; + default: + return readableMethodName(methodName) + " " + arguments; + } + } + + private String renderHasFieldOrPropertyWithValue(final Object[] args) { + if (args.length != 2) { + return "has field or property with value " + renderEach(args); + } + return "has field or property " + renderArgument(args[0]) + " with value " + renderArgument(args[1]); + } + + private String readableMethodName(final String methodName) { + if (methodName.startsWith("is") && methodName.length() > 2 && Character.isUpperCase(methodName.charAt(2))) { + return "is " + splitCamelCase(methodName.substring(2)); + } + if (methodName.startsWith("has") && methodName.length() > 3 && Character.isUpperCase(methodName.charAt(3))) { + return "has " + splitCamelCase(methodName.substring(3)); + } + return splitCamelCase(methodName); + } + + private String splitCamelCase(final String value) { + return value + .replaceAll("([a-z0-9])([A-Z])", "$1 $2") + .toLowerCase(); + } + + private String renderArguments(final String methodName, final Object[] args) { + if (isDescriptionWithEmptyValues(args)) { + return renderArgument(args[0]); + } + if (isSingleVarargToUnwrap(methodName, args)) { + return renderArgument(Array.get(args[0], 0)); + } + if (isSingleArrayArgument(args)) { + return renderArray(args[0]); + } + return renderEach(args); + } + + private boolean isDescriptionWithEmptyValues(final Object[] args) { + return args.length == 2 + && args[1] != null + && args[1].getClass().isArray() + && Array.getLength(args[1]) == 0; + } + + private boolean isSingleVarargToUnwrap(final String methodName, final Object[] args) { + return args.length == 1 + && args[0] != null + && args[0].getClass().isArray() + && Array.getLength(args[0]) == 1 + && shouldUnwrapSingleVararg(methodName); + } + + private boolean isSingleArrayArgument(final Object[] args) { + return args.length == 1 + && args[0] != null + && args[0].getClass().isArray(); + } + + private boolean shouldUnwrapSingleVararg(final String methodName) { + return !methodName.contains("Any") + && !methodName.contains("Exactly") + && !methodName.contains("Only") + && !methodName.contains("Sequence") + && !methodName.contains("Subsequence") + && !methodName.endsWith("In"); + } + + private String renderEach(final Object[] args) { + final List values = new ArrayList<>(); + for (Object arg : args) { + values.add(renderArgument(arg)); + } + return values.stream().collect(Collectors.joining(", ")); + } + + private String renderArgument(final Object value) { + if (value == null) { + return "null"; + } + if (isLambda(value)) { + return renderLambda(value); + } + if (value instanceof Description) { + return renderSimple(value.toString()); + } + if (value instanceof Tuple) { + return renderTuple((Tuple) value); + } + if (value instanceof CharSequence || isSimple(value)) { + return renderSimple(value); + } + if (value instanceof Collection) { + if (isInlineCollection((Collection) value)) { + return renderCollectionValue((Collection) value); + } + return renderCollectionSubject((Collection) value); + } + if (value instanceof Map) { + return "map with " + renderEntryCount(((Map) value).size()); + } + if (value.getClass().isArray()) { + return renderArray(value); + } + return simpleClassName(value); + } + + private String renderArray(final Object array) { + if (array instanceof byte[]) { + return ObjectUtils.toString(array); + } + + final int length = Array.getLength(array); + if (array.getClass().getComponentType().isPrimitive()) { + return ObjectUtils.toString(array); + } + if (allLambdas(array, length)) { + return length == 1 ? renderLambda(Array.get(array, 0)) : lambdaList(array, length); + } + if (allSimple(array, length) || !array.getClass().getComponentType().isPrimitive()) { + return renderObjectArray(array, length); + } + return array.getClass().getComponentType().getSimpleName() + "[](length=" + length + ")"; + } + + private String renderCollectionSubject(final Collection value) { + final int size = value.size(); + if (size == 0) { + return "empty collection"; + } + return commonElementType(value) + .map(type -> renderElementCount(size, type)) + .orElseGet(() -> renderItemCount(size)); + } + + private String renderArraySubject(final Object array) { + final int length = Array.getLength(array); + if (isInlineArray(array, length)) { + return renderArrayValue(array, length); + } + if (array instanceof byte[]) { + return "byte array with " + renderByteCount(length); + } + return renderElementCount(length, array.getClass().getComponentType()); + } + + private boolean isInlineCollection(final Collection value) { + return value.size() <= INLINE_VALUE_LIMIT && allInlineValues(value); + } + + private boolean allInlineValues(final Collection value) { + for (Object item : value) { + if (!isInlineValue(item)) { + return false; + } + } + return true; + } + + private boolean isInlineValue(final Object value) { + return value == null + || isLambda(value) + || value instanceof Description + || isInlineTuple(value) + || value instanceof CharSequence + || isSimple(value); + } + + private boolean isInlineTuple(final Object value) { + if (!(value instanceof Tuple)) { + return false; + } + final Object[] values = ((Tuple) value).toArray(); + return isInlineArray(values, values.length); + } + + private String renderTuple(final Tuple tuple) { + final Object[] values = tuple.toArray(); + return renderObjectArray(values, values.length) + .replaceFirst("^\\[", "(") + .replaceFirst("]$", ")"); + } + + private String renderCollectionValue(final Collection value) { + final List values = new ArrayList<>(); + for (Object item : value) { + values.add(renderArgument(item)); + } + return values.stream().collect(Collectors.joining(", ", "[", "]")); + } + + private boolean isInlineArray(final Object array, final int length) { + if (length > INLINE_VALUE_LIMIT || array instanceof byte[]) { + return false; + } + if (array.getClass().getComponentType().isPrimitive()) { + return true; + } + for (int i = 0; i < length; i++) { + if (!isInlineValue(Array.get(array, i))) { + return false; + } + } + return true; + } + + private String renderArrayValue(final Object array, final int length) { + if (array.getClass().getComponentType().isPrimitive()) { + return ObjectUtils.toString(array); + } + return renderObjectArray(array, length); + } + + private Optional> commonElementType(final Collection value) { + Class result = null; + for (Object item : value) { + if (item == null) { + continue; + } + final Class itemType = elementTypeOf(item); + if (result == null) { + result = itemType; + } else if (!result.equals(itemType)) { + return java.util.Optional.empty(); + } + } + return java.util.Optional.ofNullable(result); + } + + private Class elementTypeOf(final Object item) { + if (item instanceof Collection) { + return Collection.class; + } + if (item instanceof Map) { + return Map.class; + } + return item.getClass(); + } + + private String renderElementCount(final int size, final Class type) { + if (String.class.equals(type)) { + return size + " " + pluralize("string", size); + } + if (Boolean.class.equals(type) || Boolean.TYPE.equals(type)) { + return size + " " + pluralize("boolean", size); + } + if (Character.class.equals(type) || Character.TYPE.equals(type)) { + return size + " " + pluralize("character", size); + } + if (Number.class.isAssignableFrom(type) || type.isPrimitive() && !Boolean.TYPE.equals(type) + && !Character.TYPE.equals(type)) { + return size + " " + pluralize("number", size); + } + if (Collection.class.equals(type)) { + return size + " " + pluralize("collection", size); + } + if (Map.class.equals(type)) { + return size + " " + pluralize("map", size); + } + return size + " " + type.getSimpleName() + " " + pluralize("item", size); + } + + private String renderItemCount(final int size) { + return size + " " + pluralize("item", size); + } + + private String renderEntryCount(final int size) { + return size + " " + pluralize("entry", size); + } + + private String renderByteCount(final int size) { + return size + " " + pluralize("byte", size); + } + + private String pluralize(final String word, final int count) { + return count == 1 ? word : word + "s"; + } + + private String renderObjectArray(final Object array, final int length) { + final List values = new ArrayList<>(); + for (int i = 0; i < length; i++) { + values.add(renderArgument(Array.get(array, i))); + } + return values.stream().collect(Collectors.joining(", ", "[", "]")); + } + + private boolean allLambdas(final Object array, final int length) { + if (length == 0) { + return false; + } + for (int i = 0; i < length; i++) { + if (!isLambda(Array.get(array, i))) { + return false; + } + } + return true; + } + + private String lambdaList(final Object array, final int length) { + final List values = new ArrayList<>(); + for (int i = 0; i < length; i++) { + values.add(renderLambda(Array.get(array, i))); + } + return values.stream().collect(Collectors.joining(", ", "[", "]")); + } + + private boolean allSimple(final Object array, final int length) { + for (int i = 0; i < length; i++) { + final Object item = Array.get(array, i); + if (item != null && !isSimple(item) && !(item instanceof CharSequence)) { + return false; + } + } + return true; + } + + private boolean isSimple(final Object value) { + return value instanceof Number + || value instanceof Boolean + || value instanceof Character + || value instanceof Enum + || value instanceof Path + || value instanceof URI + || value instanceof URL + || value instanceof TemporalAccessor; + } + + private boolean isLambda(final Object value) { + final Class type = value.getClass(); + return type.isSynthetic() || type.getName().contains("$$Lambda$"); + } + + private String renderLambda(final Object value) { + return getLambdaName(value) + .orElse(LAMBDA); + } + + private String renderSimple(final Object value) { + if (value instanceof CharSequence || value instanceof Character) { + return "\"" + ObjectUtils.toString(value) + "\""; + } + if (value instanceof Enum) { + return ((Enum) value).name(); + } + return ObjectUtils.toString(value); + } + + private String simpleClassName(final Object value) { + final Class type = value.getClass(); + if (type.isAnonymousClass()) { + return type.getSuperclass().getSimpleName(); + } + return type.getSimpleName(); + } +} diff --git a/allure-assertj/src/main/resources/META-INF/services/io.qameta.allure.listener.FixtureLifecycleListener b/allure-assertj/src/main/resources/META-INF/services/io.qameta.allure.listener.FixtureLifecycleListener new file mode 100644 index 000000000..65aaf3eca --- /dev/null +++ b/allure-assertj/src/main/resources/META-INF/services/io.qameta.allure.listener.FixtureLifecycleListener @@ -0,0 +1 @@ +io.qameta.allure.assertj.AssertJLifecycleListener diff --git a/allure-assertj/src/main/resources/META-INF/services/io.qameta.allure.listener.TestLifecycleListener b/allure-assertj/src/main/resources/META-INF/services/io.qameta.allure.listener.TestLifecycleListener new file mode 100644 index 000000000..65aaf3eca --- /dev/null +++ b/allure-assertj/src/main/resources/META-INF/services/io.qameta.allure.listener.TestLifecycleListener @@ -0,0 +1 @@ +io.qameta.allure.assertj.AssertJLifecycleListener diff --git a/allure-assertj/src/test/java/io/qameta/allure/assertj/AllureAspectJTest.java b/allure-assertj/src/test/java/io/qameta/allure/assertj/AllureAspectJTest.java index 12437b900..71e14e7ca 100644 --- a/allure-assertj/src/test/java/io/qameta/allure/assertj/AllureAspectJTest.java +++ b/allure-assertj/src/test/java/io/qameta/allure/assertj/AllureAspectJTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,18 +15,27 @@ */ package io.qameta.allure.assertj; +import io.qameta.allure.model.Parameter; +import io.qameta.allure.model.Status; +import io.qameta.allure.model.StatusDetails; import io.qameta.allure.model.StepResult; import io.qameta.allure.model.TestResult; import io.qameta.allure.test.AllureFeatures; import io.qameta.allure.test.AllureResults; +import org.assertj.core.api.AbstractStringAssert; +import org.assertj.core.api.InstanceOfAssertFactories; import org.assertj.core.api.SoftAssertions; import org.junit.jupiter.api.Test; +import java.io.Serializable; import java.nio.charset.StandardCharsets; import java.util.Arrays; +import java.util.Collections; +import java.util.function.Function; import static io.qameta.allure.test.RunUtils.runWithinTestContext; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; /** * @author charlie (Dmitry Baev). @@ -35,90 +44,461 @@ class AllureAspectJTest { @AllureFeatures.Steps @Test - void shouldCreateStepsForAsserts() { + void shouldCreateSemanticChainForScalarAssert() { final AllureResults results = runWithinTestContext(() -> { assertThat("Data") .hasSize(4); }, AllureAspectJ::setLifecycle); - assertThat(results.getTestResults()) - .flatExtracting(TestResult::getSteps) + + final TestResult result = assertOnlyOneResult(results); + assertThat(result.getSteps()) + .extracting(StepResult::getName, StepResult::getStatus) + .containsExactly(tuple("assert \"Data\"", Status.PASSED)); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) .extracting(StepResult::getName) - .containsExactly( - "assertThat 'Data'", - "hasSize '4'" - ); + .containsExactly("has size 4"); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .flatExtracting(StepResult::getParameters) + .isEmpty(); } @AllureFeatures.Steps @Test - void shouldHandleNullableObject() { + void shouldUseAssertDescriptionAsChainName() { final AllureResults results = runWithinTestContext(() -> { assertThat((Object) null) .as("Nullable object") .isNull(); }, AllureAspectJ::setLifecycle); - assertThat(results.getTestResults()) - .flatExtracting(TestResult::getSteps) + final TestResult result = assertOnlyOneResult(results); + assertThat(result.getSteps()) + .extracting(StepResult::getName) + .containsExactly("assert Nullable object"); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .extracting(StepResult::getName) + .containsExactly("described as \"Nullable object\"", "is null"); + } + + @AllureFeatures.Steps + @Test + void shouldRenderByteArraysWithoutPayload() { + final String value = "some string"; + final AllureResults results = runWithinTestContext(() -> { + assertThat(value.getBytes(StandardCharsets.UTF_8)) + .as("Byte array object") + .isEqualTo(value.getBytes(StandardCharsets.UTF_8)); + }, AllureAspectJ::setLifecycle); + + final TestResult result = assertOnlyOneResult(results); + assertThat(result.getSteps()) + .extracting(StepResult::getName) + .containsExactly("assert Byte array object"); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .extracting(StepResult::getName) + .containsExactly("described as \"Byte array object\"", "is equal to "); + } + + @AllureFeatures.Steps + @Test + void shouldRenderCollectionsAsSubjectsAndExpectedValuesAsValues() { + final AllureResults results = runWithinTestContext(() -> { + assertThat(Arrays.asList("a", "b")) + .containsExactly("a", "b"); + }, AllureAspectJ::setLifecycle); + + final TestResult result = assertOnlyOneResult(results); + assertThat(result.getSteps()) + .extracting(StepResult::getName) + .containsExactly("assert [\"a\", \"b\"]"); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .extracting(StepResult::getName) + .containsExactly("contains exactly [\"a\", \"b\"]"); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .flatExtracting(StepResult::getParameters) + .isEmpty(); + } + + @AllureFeatures.Steps + @Test + void shouldRenderSmallArraysAsValues() { + final AllureResults results = runWithinTestContext(() -> { + assertThat(new int[]{1, 2}) + .containsExactly(1, 2); + + assertThat(new String[]{"alpha", "bravo"}) + .containsExactly("alpha", "bravo"); + }, AllureAspectJ::setLifecycle); + + final TestResult result = assertOnlyOneResult(results); + assertThat(result.getSteps()) + .extracting(StepResult::getName) + .containsExactly("assert [1, 2]", "assert [\"alpha\", \"bravo\"]"); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .extracting(StepResult::getName) + .containsExactly("contains exactly [1, 2]", "contains exactly [\"alpha\", \"bravo\"]"); + } + + @AllureFeatures.Steps + @Test + void shouldRenderTuplesAsValues() { + final AllureResults results = runWithinTestContext(() -> { + assertThat( + Arrays.asList( + tuple("first", Status.PASSED), + tuple("second", Status.FAILED) + ) + ) + .containsExactly( + tuple("first", Status.PASSED), + tuple("second", Status.FAILED) + ); + }, AllureAspectJ::setLifecycle); + + final TestResult result = assertOnlyOneResult(results); + assertThat(result.getSteps()) + .extracting(StepResult::getName) + .containsExactly("assert [(\"first\", PASSED), (\"second\", FAILED)]"); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .extracting(StepResult::getName) + .containsExactly("contains exactly [(\"first\", PASSED), (\"second\", FAILED)]"); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .flatExtracting(StepResult::getParameters) + .isEmpty(); + } + + @AllureFeatures.Steps + @Test + void shouldRenderFieldOrPropertyValueAssertions() { + final StatusDetails details = new StatusDetails() + .setMessage("Make the test failed"); + + final AllureResults results = runWithinTestContext(() -> { + assertThat(details) + .hasFieldOrPropertyWithValue("message", "Make the test failed"); + }, AllureAspectJ::setLifecycle); + + final TestResult result = assertOnlyOneResult(results); + assertThat(result.getSteps()) + .extracting(StepResult::getName) + .containsExactly("assert StatusDetails"); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .extracting(StepResult::getName) + .containsExactly("has field or property \"message\" with value \"Make the test failed\""); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .flatExtracting(StepResult::getParameters) + .isEmpty(); + } + + @AllureFeatures.Steps + @Test + void shouldTruncateLongStepNamesAndAddOnlyTruncatedValuesAsParameters() { + final String value = String.join("", Collections.nCopies(1200, "a")); + + final AllureResults results = runWithinTestContext(() -> { + assertThat(value) + .isEqualTo(value); + }, AllureAspectJ::setLifecycle); + + final TestResult result = assertOnlyOneResult(results); + assertThat(result.getSteps()) + .extracting(StepResult::getName) + .singleElement() + .asString() + .hasSize(1000) + .startsWith("assert \"") + .endsWith("..."); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .extracting(StepResult::getName) + .singleElement() + .asString() + .hasSize(1000) + .startsWith("is equal to \"") + .endsWith("..."); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .flatExtracting(StepResult::getParameters) + .extracting(Parameter::getName, Parameter::getValue) + .containsExactly(tuple("expected", "\"" + value + "\"")); + } + + @AllureFeatures.Steps + @Test + void shouldCreateSeparateChainsForMultipleAssertThatCalls() { + final AllureResults results = runWithinTestContext(() -> { + assertThat("Data") + .hasSize(4); + + assertThat(42) + .isPositive() + .isEqualTo(42); + + assertThat(Arrays.asList("a", "b")) + .hasSize(2) + .contains("a"); + }, AllureAspectJ::setLifecycle); + + final TestResult result = assertOnlyOneResult(results); + assertThat(result.getSteps()) + .extracting(StepResult::getName, StepResult::getStatus) + .containsExactly( + tuple("assert \"Data\"", Status.PASSED), + tuple("assert 42", Status.PASSED), + tuple("assert [\"a\", \"b\"]", Status.PASSED) + ); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) .extracting(StepResult::getName) .containsExactly( - "assertThat 'null'", - "as 'Nullable object []'", - "isNull" + "has size 4", + "is positive", + "is equal to 42", + "has size 2", + "contains \"a\"" ); } @AllureFeatures.Steps @Test - void shouldHandleByteArrayObject() { - final String s = "some string"; + void shouldAttachOperationsToStoredAssertionInstances() { + final String targetA = "alpha"; + final String targetB = "bravo"; + final AllureResults results = runWithinTestContext(() -> { - assertThat(s.getBytes(StandardCharsets.UTF_8)) - .as("Byte array object") - .isEqualTo(s.getBytes(StandardCharsets.UTF_8)); + final AbstractStringAssert a = assertThat(targetA); + final AbstractStringAssert b = assertThat(targetB); + + a.isEqualTo("alpha"); + b.isEqualTo("bravo"); + }, AllureAspectJ::setLifecycle); + + final TestResult result = assertOnlyOneResult(results); + assertThat(result.getSteps()) + .extracting(StepResult::getName, StepResult::getStatus) + .containsExactly( + tuple("assert \"alpha\"", Status.PASSED), + tuple("assert \"bravo\"", Status.PASSED) + ); + assertThat(result.getSteps()) + .filteredOn("name", "assert \"alpha\"") + .flatExtracting(StepResult::getSteps) + .extracting(StepResult::getName) + .containsExactly("is equal to \"alpha\""); + assertThat(result.getSteps()) + .filteredOn("name", "assert \"bravo\"") + .flatExtracting(StepResult::getSteps) + .extracting(StepResult::getName) + .containsExactly("is equal to \"bravo\""); + } + + @AllureFeatures.Steps + @Test + void shouldAvoidVerboseModelToStringPayloads() { + final TestResult model = new TestResult() + .setUuid("uid") + .setName("testPassed") + .setFullName("other.PassingTest.testPassed"); + + final AllureResults results = runWithinTestContext(() -> { + assertThat(Collections.singletonList(model)) + .hasSize(1) + .containsExactly(model); + }, AllureAspectJ::setLifecycle); + + final TestResult result = assertOnlyOneResult(results); + assertThat(result.getSteps()) + .extracting(StepResult::getName) + .containsExactly("assert 1 TestResult item"); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .extracting(StepResult::getName) + .containsExactly("has size 1", "contains exactly [TestResult]"); + assertThat(result.getSteps()) + .extracting(StepResult::getName) + .noneMatch(name -> name.contains("fullName=")) + .noneMatch(name -> name.contains("other.PassingTest.testPassed")); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .extracting(StepResult::getName) + .noneMatch(name -> name.contains("fullName=")) + .noneMatch(name -> name.contains("other.PassingTest.testPassed")); + } + + @AllureFeatures.Steps + @Test + void shouldKeepNavigationInsideTheSameChain() { + final TestResult model = new TestResult() + .setFullName("my.company.Test.testOne"); + + final AllureResults results = runWithinTestContext(() -> { + assertThat(Collections.singletonList(model)) + .extracting(TestResult::getFullName) + .containsExactly("my.company.Test.testOne"); + + assertThat(Collections.singletonList("alpha")) + .first(InstanceOfAssertFactories.STRING) + .startsWith("al"); + + assertThat(Collections.singletonList("bravo")) + .singleElement(InstanceOfAssertFactories.STRING) + .endsWith("vo"); + + assertThat((Object) "charlie") + .asInstanceOf(InstanceOfAssertFactories.STRING) + .contains("har"); + + assertThat(Collections.singletonList(Collections.singletonList("delta"))) + .flatExtracting(value -> value) + .containsExactly("delta"); }, AllureAspectJ::setLifecycle); - assertThat(results.getTestResults()) - .flatExtracting(TestResult::getSteps) + final TestResult result = assertOnlyOneResult(results); + assertThat(result.getSteps()) + .hasSize(5); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) .extracting(StepResult::getName) .containsExactly( - "assertThat ''", - "describedAs 'Byte array object'", - "isEqualTo ''" + "extracts -> [\"my.company.Test.testOne\"]", + "contains exactly [\"my.company.Test.testOne\"]", + "first element as InstanceOfAssertFactory -> \"alpha\"", + "starts with \"al\"", + "single element as InstanceOfAssertFactory -> \"bravo\"", + "ends with \"vo\"", + "as instance of InstanceOfAssertFactory -> \"charlie\"", + "contains \"har\"", + "flat extracts -> [\"delta\"]", + "contains exactly [\"delta\"]" ); } @AllureFeatures.Steps @Test - void shouldHandleCollections() { + void shouldRenderSerializedLambdaMethodReferences() { + final TestResult model = new TestResult() + .setFullName("my.company.Test.testOne"); + final AllureResults results = runWithinTestContext(() -> { - assertThat(Arrays.asList("a", "b")) - .containsExactly("a", "b"); + assertThat(Collections.singletonList(model)) + .extracting((Function & Serializable) TestResult::getFullName) + .containsExactly("my.company.Test.testOne"); + }, AllureAspectJ::setLifecycle); + + final TestResult result = assertOnlyOneResult(results); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .extracting(StepResult::getName) + .containsExactly( + "extracts TestResult::getFullName -> [\"my.company.Test.testOne\"]", + "contains exactly [\"my.company.Test.testOne\"]" + ); + } + + @AllureFeatures.Steps + @Test + void shouldMarkTheFailedHardAssertionOperation() { + final AllureResults results = runWithinTestContext(() -> { + assertThat("Data") + .hasSize(5); }, AllureAspectJ::setLifecycle); - assertThat(results.getTestResults()) - .flatExtracting(TestResult::getSteps) - .extracting(StepResult::getName) - .containsExactly( - "assertThatList '[a, b]'", - "containsExactly '[a, b]'" - ); + final TestResult result = assertOnlyOneResult(results); + assertThat(result.getSteps()) + .extracting(StepResult::getName, StepResult::getStatus) + .containsExactly(tuple("assert \"Data\"", Status.FAILED)); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .extracting(StepResult::getName, StepResult::getStatus) + .containsExactly(tuple("has size 5", Status.FAILED)); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .filteredOn("name", "has size 5") + .extracting(step -> step.getStatusDetails().getMessage()) + .singleElement() + .asString() + .contains("size"); } @AllureFeatures.Steps @Test - void softAssertions() { + void shouldMarkTheFailedSoftAssertionOperationBeforeAssertAll() { final AllureResults results = runWithinTestContext(() -> { final SoftAssertions soft = new SoftAssertions(); soft.assertThat(25) - .as("Test description") + .as("Age") .isEqualTo(26); soft.assertAll(); }, AllureAspectJ::setLifecycle); - assertThat(results.getTestResults()) - .flatExtracting(TestResult::getSteps) + final TestResult result = assertOnlyOneResult(results); + assertThat(result.getSteps()) + .extracting(StepResult::getName, StepResult::getStatus) + .containsExactly(tuple("assert Age", Status.FAILED)); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .extracting(StepResult::getName, StepResult::getStatus) + .containsExactly( + tuple("described as \"Age\"", Status.PASSED), + tuple("is equal to 26", Status.FAILED) + ); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .filteredOn("name", "is equal to 26") + .extracting(step -> step.getStatusDetails().getMessage()) + .singleElement() + .asString() + .contains("expected: 26"); + } + + @AllureFeatures.Steps + @Test + void shouldAttachNestedAssertionsUnderCallbackOperations() { + final AllureResults results = runWithinTestContext(() -> { + assertThat("alpha") + .satisfies( + value -> assertThat(value) + .startsWith("al") + .endsWith("ha") + ); + }, AllureAspectJ::setLifecycle); + + final TestResult result = assertOnlyOneResult(results); + assertThat(result.getSteps()) + .extracting(StepResult::getName) + .containsExactly("assert \"alpha\""); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) .extracting(StepResult::getName) - .contains("as 'Test description []'", "isEqualTo '26'"); + .containsExactly("satisfies "); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .filteredOn("name", "satisfies ") + .flatExtracting(StepResult::getSteps) + .extracting(StepResult::getName) + .containsExactly("assert \"alpha\""); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .filteredOn("name", "satisfies ") + .flatExtracting(StepResult::getSteps) + .flatExtracting(StepResult::getSteps) + .extracting(StepResult::getName) + .containsExactly("starts with \"al\"", "ends with \"ha\""); + } + + private TestResult assertOnlyOneResult(final AllureResults results) { + assertThat(results.getTestResults()).hasSize(1); + return results.getTestResults().get(0); } } diff --git a/allure-assertj/src/test/resources/allure.properties b/allure-assertj/src/test/resources/allure.properties index 9c0b0a2d7..c881472e6 100644 --- a/allure-assertj/src/test/resources/allure.properties +++ b/allure-assertj/src/test/resources/allure.properties @@ -1,2 +1,3 @@ allure.results.directory=build/allure-results allure.label.epic=#project.description# +allure.label.module=allure-assertj diff --git a/allure-attachments/src/main/java/io/qameta/allure/attachment/AttachmentContent.java b/allure-attachments/src/main/java/io/qameta/allure/attachment/AttachmentContent.java index c8fe7c111..070f2b5c6 100644 --- a/allure-attachments/src/main/java/io/qameta/allure/attachment/AttachmentContent.java +++ b/allure-attachments/src/main/java/io/qameta/allure/attachment/AttachmentContent.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-attachments/src/main/java/io/qameta/allure/attachment/AttachmentData.java b/allure-attachments/src/main/java/io/qameta/allure/attachment/AttachmentData.java index 86b318a05..f7f6af5ff 100644 --- a/allure-attachments/src/main/java/io/qameta/allure/attachment/AttachmentData.java +++ b/allure-attachments/src/main/java/io/qameta/allure/attachment/AttachmentData.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,6 +20,7 @@ * * @author charlie (Dmitry Baev). */ +@FunctionalInterface public interface AttachmentData { String getName(); diff --git a/allure-attachments/src/main/java/io/qameta/allure/attachment/AttachmentProcessor.java b/allure-attachments/src/main/java/io/qameta/allure/attachment/AttachmentProcessor.java index cfc93c716..06ef35fc1 100644 --- a/allure-attachments/src/main/java/io/qameta/allure/attachment/AttachmentProcessor.java +++ b/allure-attachments/src/main/java/io/qameta/allure/attachment/AttachmentProcessor.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,6 +19,7 @@ * @param the type of attachment data. * @author charlie (Dmitry Baev). */ +@FunctionalInterface public interface AttachmentProcessor { void addAttachment(T attachmentData, AttachmentRenderer renderer); diff --git a/allure-attachments/src/main/java/io/qameta/allure/attachment/AttachmentRenderException.java b/allure-attachments/src/main/java/io/qameta/allure/attachment/AttachmentRenderException.java index 5d702bffa..3a6303e7a 100644 --- a/allure-attachments/src/main/java/io/qameta/allure/attachment/AttachmentRenderException.java +++ b/allure-attachments/src/main/java/io/qameta/allure/attachment/AttachmentRenderException.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-attachments/src/main/java/io/qameta/allure/attachment/AttachmentRenderer.java b/allure-attachments/src/main/java/io/qameta/allure/attachment/AttachmentRenderer.java index 1d239331e..44029cc85 100644 --- a/allure-attachments/src/main/java/io/qameta/allure/attachment/AttachmentRenderer.java +++ b/allure-attachments/src/main/java/io/qameta/allure/attachment/AttachmentRenderer.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,7 +19,7 @@ * @param the type of attachment data * @author charlie (Dmitry Baev). */ -@SuppressWarnings("PMD.AvoidUncheckedExceptionsInSignatures") +@FunctionalInterface public interface AttachmentRenderer { AttachmentContent render(T attachmentData) throws AttachmentRenderException; diff --git a/allure-attachments/src/main/java/io/qameta/allure/attachment/DefaultAttachmentContent.java b/allure-attachments/src/main/java/io/qameta/allure/attachment/DefaultAttachmentContent.java index ab26abbb6..2e3f200d9 100644 --- a/allure-attachments/src/main/java/io/qameta/allure/attachment/DefaultAttachmentContent.java +++ b/allure-attachments/src/main/java/io/qameta/allure/attachment/DefaultAttachmentContent.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-attachments/src/main/java/io/qameta/allure/attachment/DefaultAttachmentProcessor.java b/allure-attachments/src/main/java/io/qameta/allure/attachment/DefaultAttachmentProcessor.java index dc57e9a01..017d1f610 100644 --- a/allure-attachments/src/main/java/io/qameta/allure/attachment/DefaultAttachmentProcessor.java +++ b/allure-attachments/src/main/java/io/qameta/allure/attachment/DefaultAttachmentProcessor.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-attachments/src/main/java/io/qameta/allure/attachment/FreemarkerAttachmentRenderer.java b/allure-attachments/src/main/java/io/qameta/allure/attachment/FreemarkerAttachmentRenderer.java index 9179f3ed7..354956d89 100644 --- a/allure-attachments/src/main/java/io/qameta/allure/attachment/FreemarkerAttachmentRenderer.java +++ b/allure-attachments/src/main/java/io/qameta/allure/attachment/FreemarkerAttachmentRenderer.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,6 +17,9 @@ import freemarker.template.Configuration; import freemarker.template.Template; +import freemarker.template.TemplateExceptionHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.io.StringWriter; import java.io.Writer; @@ -27,6 +30,8 @@ */ public class FreemarkerAttachmentRenderer implements AttachmentRenderer { + private static final Logger LOGGER = LoggerFactory.getLogger(FreemarkerAttachmentRenderer.class); + private final Configuration configuration; private final String templateName; @@ -36,6 +41,7 @@ public FreemarkerAttachmentRenderer(final String templateName) { this.configuration = new Configuration(Configuration.VERSION_2_3_23); this.configuration.setLocalizedLookup(false); this.configuration.setTemplateUpdateDelayMilliseconds(0); + this.configuration.setTemplateExceptionHandler(TemplateExceptionHandler.HTML_DEBUG_HANDLER); this.configuration.setClassLoaderForTemplateLoading(getClass().getClassLoader(), "tpl"); } @@ -46,6 +52,7 @@ public DefaultAttachmentContent render(final AttachmentData data) { template.process(Collections.singletonMap("data", data), writer); return new DefaultAttachmentContent(writer.toString(), "text/html", ".html"); } catch (Exception e) { + LOGGER.debug(data.toString()); throw new AttachmentRenderException("Could't render http attachment file", e); } } diff --git a/allure-attachments/src/main/java/io/qameta/allure/attachment/http/HttpRequestAttachment.java b/allure-attachments/src/main/java/io/qameta/allure/attachment/http/HttpRequestAttachment.java index 831b2a5da..8f35b75b3 100644 --- a/allure-attachments/src/main/java/io/qameta/allure/attachment/http/HttpRequestAttachment.java +++ b/allure-attachments/src/main/java/io/qameta/allure/attachment/http/HttpRequestAttachment.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,7 +16,9 @@ package io.qameta.allure.attachment.http; import io.qameta.allure.attachment.AttachmentData; +import io.qameta.allure.util.ObjectUtils; +import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.Objects; @@ -40,9 +42,18 @@ public class HttpRequestAttachment implements AttachmentData { private final Map cookies; + private final Map formParams; + public HttpRequestAttachment(final String name, final String url, final String method, final String body, final String curl, final Map headers, final Map cookies) { + this(name, url, method, body, curl, headers, cookies, Collections.emptyMap()); + } + + @SuppressWarnings("checkstyle:parameternumber") + public HttpRequestAttachment(final String name, final String url, final String method, + final String body, final String curl, final Map headers, + final Map cookies, final Map formParams) { this.name = name; this.url = url; this.method = method; @@ -50,6 +61,7 @@ public HttpRequestAttachment(final String name, final String url, final String m this.curl = curl; this.headers = headers; this.cookies = cookies; + this.formParams = formParams; } public String getUrl() { @@ -72,6 +84,10 @@ public Map getCookies() { return cookies; } + public Map getFormParams() { + return formParams; + } + public String getCurl() { return curl; } @@ -81,10 +97,21 @@ public String getName() { return name; } + @Override + public String toString() { + return "HttpRequestAttachment(" + + "\n\tname=" + this.name + + ",\n\turl=" + this.url + + ",\n\tbody=" + this.body + + ",\n\theaders=" + ObjectUtils.mapToString(this.headers) + + ",\n\tcookies=" + ObjectUtils.mapToString(this.cookies) + + ",\n\tformParams=" + ObjectUtils.mapToString(this.formParams) + + "\n)"; + } + /** * Builder for HttpRequestAttachment. */ - @SuppressWarnings("PMD.AvoidFieldNameMatchingMethodName") public static final class Builder { private final String name; @@ -99,6 +126,8 @@ public static final class Builder { private final Map cookies = new HashMap<>(); + private final Map formParams = new HashMap<>(); + private Builder(final String name, final String url) { Objects.requireNonNull(name, "Name must not be null value"); Objects.requireNonNull(url, "Url must not be null value"); @@ -148,6 +177,12 @@ public Builder setBody(final String body) { return this; } + public Builder setFormParams(final Map formParams) { + Objects.requireNonNull(formParams, "Form params must not be null value"); + this.formParams.putAll(formParams); + return this; + } + /** * Use setter method instead. * @deprecated scheduled for removal in 3.0 release @@ -203,7 +238,7 @@ public Builder withBody(final String body) { } public HttpRequestAttachment build() { - return new HttpRequestAttachment(name, url, method, body, getCurl(), headers, cookies); + return new HttpRequestAttachment(name, url, method, body, getCurl(), headers, cookies, formParams); } private String getCurl() { @@ -214,6 +249,7 @@ private String getCurl() { builder.append(" '").append(url).append('\''); headers.forEach((key, value) -> appendHeader(builder, key, value)); cookies.forEach((key, value) -> appendCookie(builder, key, value)); + formParams.forEach((key, value) -> appendFormParams(builder, key, value)); if (Objects.nonNull(body)) { builder.append(" -d '").append(body).append('\''); @@ -236,5 +272,13 @@ private static void appendCookie(final StringBuilder builder, final String key, .append(value) .append('\''); } + + private static void appendFormParams(final StringBuilder builder, final String key, final String value) { + builder.append(" --form '") + .append(key) + .append('=') + .append(value) + .append('\''); + } } } diff --git a/allure-attachments/src/main/java/io/qameta/allure/attachment/http/HttpResponseAttachment.java b/allure-attachments/src/main/java/io/qameta/allure/attachment/http/HttpResponseAttachment.java index 0b5ef9841..20187c7cb 100644 --- a/allure-attachments/src/main/java/io/qameta/allure/attachment/http/HttpResponseAttachment.java +++ b/allure-attachments/src/main/java/io/qameta/allure/attachment/http/HttpResponseAttachment.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,7 @@ package io.qameta.allure.attachment.http; import io.qameta.allure.attachment.AttachmentData; +import io.qameta.allure.util.ObjectUtils; import java.util.HashMap; import java.util.Map; @@ -74,10 +75,21 @@ public Map getCookies() { return cookies; } + @Override + public String toString() { + return "HttpResponseAttachment(" + + "\n\tname=" + this.name + + ",\n\turl=" + this.url + + ",\n\tbody=" + this.body + + ",\n\tresponseCode=" + this.responseCode + + ",\n\theaders=" + ObjectUtils.mapToString(this.headers) + + ",\n\tcookies=" + ObjectUtils.mapToString(this.cookies) + + "\n)"; + } + /** * Builder for HttpRequestAttachment. */ - @SuppressWarnings("PMD.AvoidFieldNameMatchingMethodName") public static final class Builder { private final String name; @@ -146,6 +158,7 @@ public Builder setBody(final String body) { /** * Use setter method instead. + * * @deprecated scheduled for removal in 3.0 release */ @Deprecated @@ -155,6 +168,7 @@ public Builder withUrl(final String url) { /** * Use setter method instead. + * * @deprecated scheduled for removal in 3.0 release */ @Deprecated @@ -164,6 +178,7 @@ public Builder withResponseCode(final int responseCode) { /** * Use setter method instead. + * * @deprecated scheduled for removal in 3.0 release */ @Deprecated @@ -173,6 +188,7 @@ public Builder withHeader(final String name, final String value) { /** * Use setter method instead. + * * @deprecated scheduled for removal in 3.0 release */ @Deprecated @@ -182,6 +198,7 @@ public Builder withHeaders(final Map headers) { /** * Use setter method instead. + * * @deprecated scheduled for removal in 3.0 release */ @Deprecated @@ -191,6 +208,7 @@ public Builder withCookie(final String name, final String value) { /** * Use setter method instead. + * * @deprecated scheduled for removal in 3.0 release */ @Deprecated @@ -200,6 +218,7 @@ public Builder withCookies(final Map cookies) { /** * Use setter method instead. + * * @deprecated scheduled for removal in 3.0 release */ @Deprecated diff --git a/allure-attachments/src/main/resources/tpl/http-request.ftl b/allure-attachments/src/main/resources/tpl/http-request.ftl index b4e31d632..935c1c91a 100644 --- a/allure-attachments/src/main/resources/tpl/http-request.ftl +++ b/allure-attachments/src/main/resources/tpl/http-request.ftl @@ -3,36 +3,45 @@
<#if data.method??>${data.method}<#else>GET to <#if data.url??>${data.url}<#else>Unknown
<#if data.body??> -

Body

-
+

Body

+
     <#t>${data.body}
     
-
+
<#if (data.headers)?has_content> -

Headers

-
- <#list data.headers as name, value> -
${name}: ${value!"null"}
- -
+

Headers

+
+ <#list data.headers as name, value> +
${name}: ${value!"null"}
+ +
<#if (data.cookies)?has_content> -

Cookies

-
- <#list data.cookies as name, value> -
${name}: ${value!"null"}
- -
+

Cookies

+
+ <#list data.cookies as name, value> +
${name}: ${value!"null"}
+ +
<#if data.curl??> -

Curl

-
-${data.curl} -
+

Curl

+
+ ${data.curl} +
+ + +<#if (data.formParams)?has_content> +

FormParams

+
+ <#list data.formParams as name, value> +
${name}: ${value!"null"}
+ +
diff --git a/allure-attachments/src/test/java/io/qameta/allure/attachment/DefaultAttachmentProcessorTest.java b/allure-attachments/src/test/java/io/qameta/allure/attachment/DefaultAttachmentProcessorTest.java index 1283d6116..fe84d9cf6 100644 --- a/allure-attachments/src/test/java/io/qameta/allure/attachment/DefaultAttachmentProcessorTest.java +++ b/allure-attachments/src/test/java/io/qameta/allure/attachment/DefaultAttachmentProcessorTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-attachments/src/test/java/io/qameta/allure/attachment/FreemarkerAttachmentRendererTest.java b/allure-attachments/src/test/java/io/qameta/allure/attachment/FreemarkerAttachmentRendererTest.java index 75ddac32d..3c37c53ab 100644 --- a/allure-attachments/src/test/java/io/qameta/allure/attachment/FreemarkerAttachmentRendererTest.java +++ b/allure-attachments/src/test/java/io/qameta/allure/attachment/FreemarkerAttachmentRendererTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,10 +16,12 @@ package io.qameta.allure.attachment; import io.qameta.allure.attachment.http.HttpRequestAttachment; +import io.qameta.allure.attachment.http.HttpResponseAttachment; import io.qameta.allure.test.AllureFeatures; import org.junit.jupiter.api.Test; import static io.qameta.allure.attachment.testdata.TestData.randomHttpRequestAttachment; +import static io.qameta.allure.attachment.testdata.TestData.randomHttpResponseAttachment; import static org.assertj.core.api.Assertions.assertThat; /** @@ -27,6 +29,12 @@ */ class FreemarkerAttachmentRendererTest { + private static final String CONTENT = "content"; + private static final String CONTENT_TYPE = "contentType"; + private static final String TEXT_HTML = "text/html"; + private static final String FILE_EXTENSION = "fileExtension"; + private static final String HTML = ".html"; + @AllureFeatures.Attachments @Test void shouldRenderRequestAttachment() { @@ -35,21 +43,21 @@ void shouldRenderRequestAttachment() { .render(data); assertThat(content) - .hasFieldOrPropertyWithValue("contentType", "text/html") - .hasFieldOrPropertyWithValue("fileExtension", ".html") - .hasFieldOrProperty("content"); + .hasFieldOrPropertyWithValue(CONTENT_TYPE, TEXT_HTML) + .hasFieldOrPropertyWithValue(FILE_EXTENSION, HTML) + .hasFieldOrProperty(CONTENT); } @AllureFeatures.Attachments @Test void shouldRenderResponseAttachment() { - final HttpRequestAttachment data = randomHttpRequestAttachment(); + final HttpResponseAttachment data = randomHttpResponseAttachment(); final DefaultAttachmentContent content = new FreemarkerAttachmentRenderer("http-response.ftl") .render(data); assertThat(content) - .hasFieldOrPropertyWithValue("contentType", "text/html") - .hasFieldOrPropertyWithValue("fileExtension", ".html") - .hasFieldOrProperty("content"); + .hasFieldOrPropertyWithValue(CONTENT_TYPE, TEXT_HTML) + .hasFieldOrPropertyWithValue(FILE_EXTENSION, HTML) + .hasFieldOrProperty(CONTENT); } } diff --git a/allure-attachments/src/test/java/io/qameta/allure/attachment/NegativeFreemarkerAttachmentRendererTest.java b/allure-attachments/src/test/java/io/qameta/allure/attachment/NegativeFreemarkerAttachmentRendererTest.java new file mode 100644 index 000000000..444e5c61e --- /dev/null +++ b/allure-attachments/src/test/java/io/qameta/allure/attachment/NegativeFreemarkerAttachmentRendererTest.java @@ -0,0 +1,87 @@ +/* + * Copyright 2016-2026 Qameta Software Inc + * + * 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 io.qameta.allure.attachment; + +import io.qameta.allure.attachment.http.HttpRequestAttachment; +import io.qameta.allure.test.AllureFeatures; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.io.UnsupportedEncodingException; +import java.nio.charset.StandardCharsets; + +import static io.qameta.allure.attachment.testdata.TestData.negativeHttpRequestAttachment; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * @author a-simeshin (Simeshin Artem). + */ +class NegativeFreemarkerAttachmentRendererTest { + + private static final String TEMPLATE_FOR_EXCEPTION = "body-npe-non-safe-attachment.ftl"; + + private PrintStream realSysOut; + private ByteArrayOutputStream sysOutBuffer; + + @BeforeEach + void setUpSysOut() throws UnsupportedEncodingException { + realSysOut = System.err; + sysOutBuffer = new ByteArrayOutputStream(); + System.setErr(new PrintStream(sysOutBuffer, false, StandardCharsets.UTF_8.toString())); + } + + @AfterEach + void rollBackSysOut() { + System.setErr(realSysOut); + } + + @AllureFeatures.Attachments + @Test + void shouldThrowExceptionalSituationsForFreeMarketRendererWithIncorrectAttachmentData() { + assertThrows(AttachmentRenderException.class, () -> { + final HttpRequestAttachment data = negativeHttpRequestAttachment(); + new FreemarkerAttachmentRenderer(TEMPLATE_FOR_EXCEPTION).render(data); + }); + } + + @AllureFeatures.Attachments + @Test + void shouldExplainExceptionalSituationsForFreeMarketRenderer() throws UnsupportedEncodingException { + try { + final HttpRequestAttachment data = negativeHttpRequestAttachment(); + new FreemarkerAttachmentRenderer(TEMPLATE_FOR_EXCEPTION).render(data); + } catch (Exception ignored) { + // for test purposes + } + assertThat(sysOutBuffer.toString(StandardCharsets.UTF_8.toString())) + .contains("SEVERE: Error executing FreeMarker template") + .contains("FreeMarker template error:") + .contains("The following has evaluated to null or missing:") + .contains("==> data.body") + .contains("[in template \"body-npe-non-safe-attachment.ftl\" at line 8, column 11]") + .contains("\t- Failed at: ${data.body.size}") + .contains("io.qameta.allure.attachment.FreemarkerAttachmentRenderer - HttpRequestAttachment") + .contains("\tname=null,") + .contains("\turl=null,") + .contains("\tbody=null,") + .contains("\theaders={},") + .contains("\tcookies={}"); + } +} diff --git a/allure-attachments/src/test/java/io/qameta/allure/attachment/testdata/TestData.java b/allure-attachments/src/test/java/io/qameta/allure/attachment/testdata/TestData.java index 306f27b63..d30634dae 100644 --- a/allure-attachments/src/test/java/io/qameta/allure/attachment/testdata/TestData.java +++ b/allure-attachments/src/test/java/io/qameta/allure/attachment/testdata/TestData.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -72,4 +72,17 @@ public static Map randomMap() { map.put(randomString(), null); return map; } + + public static HttpRequestAttachment negativeHttpRequestAttachment() { + return new HttpRequestAttachment( + null, + null, + null, + null, + null, + null, + null + ); + } + } diff --git a/allure-attachments/src/test/resources/allure.properties b/allure-attachments/src/test/resources/allure.properties index 9c0b0a2d7..b47a01f60 100644 --- a/allure-attachments/src/test/resources/allure.properties +++ b/allure-attachments/src/test/resources/allure.properties @@ -1,2 +1,3 @@ allure.results.directory=build/allure-results allure.label.epic=#project.description# +allure.label.module=allure-attachments diff --git a/allure-attachments/src/test/resources/tpl/body-npe-non-safe-attachment.ftl b/allure-attachments/src/test/resources/tpl/body-npe-non-safe-attachment.ftl new file mode 100644 index 000000000..813425567 --- /dev/null +++ b/allure-attachments/src/test/resources/tpl/body-npe-non-safe-attachment.ftl @@ -0,0 +1,36 @@ +<#ftl output_format="HTML"> +<#-- @ftlvariable name="data" type="io.qameta.allure.attachment.http.HttpRequestAttachment" --> +
<#if data.method??>${data.method}<#else>GET to <#if data.url??>${data.url}<#else>Unknown
+ +

Body

+
+
+    <#t>${data.body.size}
+    
+
+ +<#if (data.headers)?has_content> +

Headers

+
+ <#list data.headers as name, value> +
${name}: ${value!"null"}
+ +
+ + + +<#if (data.cookies)?has_content> +

Cookies

+
+ <#list data.cookies as name, value> +
${name}: ${value!"null"}
+ +
+ + +<#if data.curl??> +

Curl

+
+ ${data.curl} +
+ diff --git a/allure-awaitility/build.gradle.kts b/allure-awaitility/build.gradle.kts index 2426a93bc..3c9ed6c79 100644 --- a/allure-awaitility/build.gradle.kts +++ b/allure-awaitility/build.gradle.kts @@ -2,14 +2,15 @@ description = "Allure Awaitlity Integration" val agent: Configuration by configurations.creating -val awaitilityVersion = "4.2.0" +val awaitilityVersion = "4.3.0" dependencies { agent("org.aspectj:aspectjweaver") api(project(":allure-java-commons")) - implementation("org.awaitility:awaitility:$awaitilityVersion") + compileOnly("org.awaitility:awaitility:$awaitilityVersion") testImplementation("javax.annotation:javax.annotation-api") testImplementation("org.assertj:assertj-core") + testImplementation("org.awaitility:awaitility:$awaitilityVersion") testImplementation("org.junit.jupiter:junit-jupiter-api") testImplementation("org.slf4j:slf4j-simple") testImplementation(project(":allure-java-commons-test")) @@ -27,4 +28,4 @@ tasks.jar { tasks.test { useJUnitPlatform() -} \ No newline at end of file +} diff --git a/allure-awaitility/readme.md b/allure-awaitility/readme.md index 5a93b6a42..1fcb34691 100644 --- a/allure-awaitility/readme.md +++ b/allure-awaitility/readme.md @@ -7,11 +7,16 @@ For more information about awaitility highly recommended look into [awaitility u ### Configuration examples -Single line for all awaitility conditions in project +Single line for all awaitility conditions in project ```java Awaitility.setDefaultConditionEvaluationListener(new AllureAwaitilityListener()); ``` +And another line to prevent breaking allure lifecycle for Steps inside Awaitility evaluations +```java +Awaitility.pollInSameThread(); +``` + Moreover, it's possible logging only few unstable conditions with method `.conditionEvaluationListener()` ```java final AtomicInteger atomicInteger = new AtomicInteger(0); diff --git a/allure-awaitility/src/main/java/io/qameta/allure/awaitility/AllureAwaitilityListener.java b/allure-awaitility/src/main/java/io/qameta/allure/awaitility/AllureAwaitilityListener.java index 8a3f468b2..a19e5e31e 100644 --- a/allure-awaitility/src/main/java/io/qameta/allure/awaitility/AllureAwaitilityListener.java +++ b/allure-awaitility/src/main/java/io/qameta/allure/awaitility/AllureAwaitilityListener.java @@ -1,5 +1,5 @@ /* - * Copyright 2022 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -77,7 +77,7 @@ public class AllureAwaitilityListener implements ConditionEvaluationListener lifecycle = new InheritableThreadLocal() { + private static final InheritableThreadLocal LIFECYCLE = new InheritableThreadLocal() { @Override protected AllureLifecycle initialValue() { return Allure.getLifecycle(); @@ -85,7 +85,7 @@ protected AllureLifecycle initialValue() { }; public static AllureLifecycle getLifecycle() { - return lifecycle.get(); + return LIFECYCLE.get(); } /** @@ -224,7 +224,8 @@ public void exceptionIgnored(final IgnoredException ignoredException) { getLifecycle().updateStep(awaitilityCondition -> { final String currentExceptionIgnoredStepUUID = UUID.randomUUID().toString(); final String message = String.format( - onExceptionStepTextPattern, ignoredException.getThrowable().getMessage()); + onExceptionStepTextPattern, ignoredException.getThrowable().getMessage() + ); final StringWriter stringWriter = new StringWriter(); ignoredException.getThrowable().printStackTrace(new PrintWriter(stringWriter)); final String stackTrace = stringWriter.toString(); @@ -238,7 +239,8 @@ public void exceptionIgnored(final IgnoredException ignoredException) { ); getLifecycle().addAttachment( ignoredException.getThrowable().getMessage(), "text/plain", ".txt", - stackTrace.getBytes(StandardCharsets.UTF_8)); + stackTrace.getBytes(StandardCharsets.UTF_8) + ); getLifecycle().stopStep(currentExceptionIgnoredStepUUID); }); } @@ -250,7 +252,7 @@ public void exceptionIgnored(final IgnoredException ignoredException) { * @param allure allure lifecycle to set */ public static void setLifecycle(final AllureLifecycle allure) { - lifecycle.set(allure); + LIFECYCLE.set(allure); } } diff --git a/allure-awaitility/src/main/java/io/qameta/allure/awaitility/TemporalDuration.java b/allure-awaitility/src/main/java/io/qameta/allure/awaitility/TemporalDuration.java index 17a489e27..5632b1bed 100644 --- a/allure-awaitility/src/main/java/io/qameta/allure/awaitility/TemporalDuration.java +++ b/allure-awaitility/src/main/java/io/qameta/allure/awaitility/TemporalDuration.java @@ -1,5 +1,5 @@ /* - * Copyright 2022 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -60,10 +60,7 @@ public class TemporalDuration implements TemporalAccessor { @Override public boolean isSupported(final TemporalField field) { - if (!temporal.isSupported(field)) { - return false; - } - return temporal.getLong(field) - BASE.getLong(field) != 0L; + return temporal.isSupported(field) && temporal.getLong(field) - BASE.getLong(field) != 0L; } @Override diff --git a/allure-awaitility/src/test/java/io/qameta/allure/awaitility/ConditionListenersPositiveTest.java b/allure-awaitility/src/test/java/io/qameta/allure/awaitility/ConditionListenersPositiveTest.java index e62e0314f..9eb6d7d6f 100644 --- a/allure-awaitility/src/test/java/io/qameta/allure/awaitility/ConditionListenersPositiveTest.java +++ b/allure-awaitility/src/test/java/io/qameta/allure/awaitility/ConditionListenersPositiveTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2022 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,6 +18,8 @@ import io.qameta.allure.model.Status; import io.qameta.allure.model.StepResult; import io.qameta.allure.model.TestResult; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.DynamicNode; import org.junit.jupiter.api.DynamicTest; import org.junit.jupiter.api.Test; @@ -38,6 +40,11 @@ class ConditionListenersPositiveTest { + @BeforeAll + static void setup() { + Awaitility.pollInSameThread(); + } + /** * Positive test to check proper allure steps generation. *

@@ -51,27 +58,27 @@ class ConditionListenersPositiveTest { @TestFactory Stream globalSettingsAwaitWoAliasCheckTopLevelPassedStep() { final List testResult = runWithinTestContext(() -> { - final AtomicInteger atomicInteger = new AtomicInteger(0); - await().with() - .conditionEvaluationListener(new AllureAwaitilityListener()) - .atMost(Duration.of(1000, ChronoUnit.MILLIS)) - .pollInterval(Duration.of(50, ChronoUnit.MILLIS)) - .until(atomicInteger::getAndIncrement, is(3)); - }, + final AtomicInteger atomicInteger = new AtomicInteger(0); + await().with() + .conditionEvaluationListener(new AllureAwaitilityListener()) + .atMost(Duration.of(1000, ChronoUnit.MILLIS)) + .pollInterval(Duration.of(50, ChronoUnit.MILLIS)) + .until(atomicInteger::getAndIncrement, is(3)); + }, AllureAwaitilityListener::setLifecycle ).getTestResults(); return Stream.of( - DynamicTest.dynamicTest("Exactly 1 top level step for 1 awaitility condition", () -> - assertThat(testResult.get(0).getSteps()) + DynamicTest.dynamicTest( + "Exactly 1 top level step for 1 awaitility condition", () -> assertThat(testResult.get(0).getSteps()) .hasSize(1) ), - DynamicTest.dynamicTest("Top level step has passed status", () -> - assertThat(testResult.get(0).getSteps()) + DynamicTest.dynamicTest( + "Top level step has passed status", () -> assertThat(testResult.get(0).getSteps()) .allMatch(step -> Status.PASSED.equals(step.getStatus())) ), - DynamicTest.dynamicTest("Top level step has default name because await() wo alias", () -> - assertThat(testResult.get(0).getSteps()) + DynamicTest.dynamicTest( + "Top level step has default name because await() wo alias", () -> assertThat(testResult.get(0).getSteps()) .extracting(StepResult::getName) .containsExactly("Awaitility: Starting evaluation") ) @@ -89,13 +96,13 @@ Stream globalSettingsAwaitWoAliasCheckTopLevelPassedStep() { @Test void globalSettingsAwaitWithAliasCheckTopLevelPassedStep() { final List testResult = runWithinTestContext(() -> { - final AtomicInteger atomicInteger = new AtomicInteger(0); - await("Counter should be at least 3").with() - .conditionEvaluationListener(new AllureAwaitilityListener()) - .atMost(Duration.of(1000, ChronoUnit.MILLIS)) - .pollInterval(Duration.of(50, ChronoUnit.MILLIS)) - .until(atomicInteger::getAndIncrement, is(3)); - }, + final AtomicInteger atomicInteger = new AtomicInteger(0); + await("Counter should be at least 3").with() + .conditionEvaluationListener(new AllureAwaitilityListener()) + .atMost(Duration.of(1000, ChronoUnit.MILLIS)) + .pollInterval(Duration.of(50, ChronoUnit.MILLIS)) + .until(atomicInteger::getAndIncrement, is(3)); + }, AllureAwaitilityListener::setLifecycle ).getTestResults(); assertEquals( @@ -118,51 +125,51 @@ void globalSettingsAwaitWithAliasCheckTopLevelPassedStep() { @TestFactory Stream globalSettingsCheckAwaitWoAliasSecondLevelPassedSteps() { final List testResult = runWithinTestContext(() -> { - final AtomicInteger atomicInteger = new AtomicInteger(0); - await().with() - .conditionEvaluationListener(new AllureAwaitilityListener()) - .atMost(Duration.of(1000, ChronoUnit.MILLIS)) - .pollInterval(Duration.of(50, ChronoUnit.MILLIS)) - .until(atomicInteger::getAndIncrement, is(3)); - }, + final AtomicInteger atomicInteger = new AtomicInteger(0); + await().with() + .conditionEvaluationListener(new AllureAwaitilityListener()) + .atMost(Duration.of(1000, ChronoUnit.MILLIS)) + .pollInterval(Duration.of(50, ChronoUnit.MILLIS)) + .until(atomicInteger::getAndIncrement, is(3)); + }, AllureAwaitilityListener::setLifecycle ).getTestResults(); return Stream.of( - dynamicTest("Exactly 4 second level steps for 4 polling iterations", () -> - assertThat(testResult.get(0).getSteps().get(0).getSteps()) + dynamicTest( + "Exactly 4 second level steps for 4 polling iterations", () -> assertThat(testResult.get(0).getSteps().get(0).getSteps()) .hasSize(4) ), - dynamicTest("All second level steps has passed statuses", () -> - assertThat(testResult.get(0).getSteps().get(0).getSteps()) + dynamicTest( + "All second level steps has passed statuses", () -> assertThat(testResult.get(0).getSteps().get(0).getSteps()) .allMatch(x -> x.getStatus().equals(Status.PASSED)) ), - dynamicTest("Second level step 1 name", () -> - assertThat(testResult.get(0).getSteps().get(0).getSteps().get(0).getName()) + dynamicTest( + "Second level step 1 name", () -> assertThat(testResult.get(0).getSteps().get(0).getSteps().get(0).getName()) .contains("io.qameta.allure.awaitility.ConditionListenersPositiveTest") .contains("expected <3> but was <0>") .contains("elapsed time") .contains("remaining time") .contains("last poll interval was") ), - dynamicTest("Second level step 2 name", () -> - assertThat(testResult.get(0).getSteps().get(0).getSteps().get(1).getName()) + dynamicTest( + "Second level step 2 name", () -> assertThat(testResult.get(0).getSteps().get(0).getSteps().get(1).getName()) .contains("io.qameta.allure.awaitility.ConditionListenersPositiveTest") .contains("expected <3> but was <1>") .contains("elapsed time") .contains("remaining time") .contains("last poll interval was") ), - dynamicTest("Second level step 3 name", () -> - assertThat(testResult.get(0).getSteps().get(0).getSteps().get(2).getName()) + dynamicTest( + "Second level step 3 name", () -> assertThat(testResult.get(0).getSteps().get(0).getSteps().get(2).getName()) .contains("io.qameta.allure.awaitility.ConditionListenersPositiveTest") .contains("expected <3> but was <2>") .contains("elapsed time") .contains("remaining time") .contains("last poll interval was") ), - dynamicTest("Second level step 4 name", () -> - assertThat(testResult.get(0).getSteps().get(0).getSteps().get(3).getName()) + dynamicTest( + "Second level step 4 name", () -> assertThat(testResult.get(0).getSteps().get(0).getSteps().get(3).getName()) .contains("io.qameta.allure.awaitility.ConditionListenersPositiveTest") .contains("reached its end value of <3> after") .contains("remaining time") diff --git a/allure-awaitility/src/test/java/io/qameta/allure/awaitility/GlobalSettingsNegativeTest.java b/allure-awaitility/src/test/java/io/qameta/allure/awaitility/GlobalSettingsNegativeTest.java index 4b27b17e6..c00fcf774 100644 --- a/allure-awaitility/src/test/java/io/qameta/allure/awaitility/GlobalSettingsNegativeTest.java +++ b/allure-awaitility/src/test/java/io/qameta/allure/awaitility/GlobalSettingsNegativeTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2022 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -48,6 +48,7 @@ void reset() { @BeforeEach void setup() { + Awaitility.pollInSameThread(); Awaitility.setDefaultConditionEvaluationListener(new AllureAwaitilityListener()); } @@ -62,12 +63,12 @@ void setup() { @Test void globalSettingsAwaitWoAliasCheckTopLevelBrokenStep() { final List testResult = runWithinTestContext(() -> { - final AtomicInteger atomicInteger = new AtomicInteger(0); - await().with() - .atMost(Duration.of(1000, ChronoUnit.MILLIS)) - .pollInterval(Duration.of(500, ChronoUnit.MILLIS)) - .until(atomicInteger::getAndIncrement, is(3)); - }, + final AtomicInteger atomicInteger = new AtomicInteger(0); + await().with() + .atMost(Duration.of(1000, ChronoUnit.MILLIS)) + .pollInterval(Duration.of(500, ChronoUnit.MILLIS)) + .until(atomicInteger::getAndIncrement, is(3)); + }, AllureAwaitilityListener::setLifecycle ).getTestResults(); assertEquals( @@ -89,37 +90,42 @@ void globalSettingsAwaitWoAliasCheckTopLevelBrokenStep() { @TestFactory Stream globalSettingsCheckAwaitWoAliasSecondLevelTimeoutStep() { final List testResult = runWithinTestContext(() -> { - final AtomicInteger atomicInteger = new AtomicInteger(0); - await().with() - .atMost(Duration.of(1000, ChronoUnit.MILLIS)) - .pollInterval(Duration.of(500, ChronoUnit.MILLIS)) - .until(atomicInteger::getAndIncrement, is(3)); - }, + final AtomicInteger atomicInteger = new AtomicInteger(0); + await().with() + .atMost(Duration.of(1000, ChronoUnit.MILLIS)) + .pollInterval(Duration.of(500, ChronoUnit.MILLIS)) + .until(atomicInteger::getAndIncrement, is(3)); + }, AllureAwaitilityListener::setLifecycle ).getTestResults(); return Stream.of( - dynamicTest("Second level steps count", () -> - assertThat(testResult.get(0).getSteps().get(0).getSteps()) + dynamicTest( + "Second level steps count", () -> assertThat(testResult.get(0).getSteps().get(0).getSteps()) .as("Exactly 2 second level steps for 2 polling iterations") - .hasSize(2)), - dynamicTest("Second level step 1 name", () -> - assertThat(testResult.get(0).getSteps().get(0).getSteps().get(0).getName()) + .hasSize(2) + ), + dynamicTest( + "Second level step 1 name", () -> assertThat(testResult.get(0).getSteps().get(0).getSteps().get(0).getName()) .contains("io.qameta.allure.awaitility.GlobalSettingsNegativeTest") .contains("expected <3> but was <0>") .contains("elapsed time") .contains("remaining time") - .contains("last poll interval was")), - dynamicTest("Second level step 1 status", () -> - assertThat(testResult.get(0).getSteps().get(0).getSteps().get(0).getStatus()) - .isEqualTo(Status.PASSED)), - dynamicTest("Second level step 2 name", () -> - assertThat(testResult.get(0).getSteps().get(0).getSteps().get(1).getName()) + .contains("last poll interval was") + ), + dynamicTest( + "Second level step 1 status", () -> assertThat(testResult.get(0).getSteps().get(0).getSteps().get(0).getStatus()) + .isEqualTo(Status.PASSED) + ), + dynamicTest( + "Second level step 2 name", () -> assertThat(testResult.get(0).getSteps().get(0).getSteps().get(1).getName()) .contains("Condition timeout.") - .contains("io.qameta.allure.awaitility.GlobalSettingsNegativeTest")), - dynamicTest("Second level step 2 status", () -> - assertThat(testResult.get(0).getSteps().get(0).getSteps().get(1).getStatus()) - .isEqualTo(Status.BROKEN)) + .contains("io.qameta.allure.awaitility.GlobalSettingsNegativeTest") + ), + dynamicTest( + "Second level step 2 status", () -> assertThat(testResult.get(0).getSteps().get(0).getSteps().get(1).getStatus()) + .isEqualTo(Status.BROKEN) + ) ); } diff --git a/allure-awaitility/src/test/java/io/qameta/allure/awaitility/GlobalSettingsPositiveTest.java b/allure-awaitility/src/test/java/io/qameta/allure/awaitility/GlobalSettingsPositiveTest.java index da5f84137..dfb3ff3b7 100644 --- a/allure-awaitility/src/test/java/io/qameta/allure/awaitility/GlobalSettingsPositiveTest.java +++ b/allure-awaitility/src/test/java/io/qameta/allure/awaitility/GlobalSettingsPositiveTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2022 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -48,6 +48,7 @@ void reset() { @BeforeEach void setup() { + Awaitility.pollInSameThread(); Awaitility.setDefaultConditionEvaluationListener(new AllureAwaitilityListener()); } @@ -64,29 +65,29 @@ void setup() { @Test Stream globalSettingsAwaitWoAliasCheckTopLevelPassedStep() { final List testResult = runWithinTestContext(() -> { - final AtomicInteger atomicInteger = new AtomicInteger(0); - await().with() - .atMost(Duration.of(1000, ChronoUnit.MILLIS)) - .pollInterval(Duration.of(50, ChronoUnit.MILLIS)) - .until(atomicInteger::getAndIncrement, is(3)); - }, + final AtomicInteger atomicInteger = new AtomicInteger(0); + await().with() + .atMost(Duration.of(1000, ChronoUnit.MILLIS)) + .pollInterval(Duration.of(50, ChronoUnit.MILLIS)) + .until(atomicInteger::getAndIncrement, is(3)); + }, AllureAwaitilityListener::setLifecycle ).getTestResults(); return Stream.of( - dynamicTest("Steps count", () -> - assertThat(testResult.get(0).getSteps()) + dynamicTest( + "Steps count", () -> assertThat(testResult.get(0).getSteps()) .as("Exactly 1 top level step for 1 awaitility condition") .hasSize(1) ), - dynamicTest("Top level step status", () -> - assertEquals( + dynamicTest( + "Top level step status", () -> assertEquals( Status.PASSED, testResult.get(0).getSteps().get(0).getStatus(), "Top level step has passed status" ) ), - dynamicTest("Top level step name", () -> - assertEquals( + dynamicTest( + "Top level step name", () -> assertEquals( "Awaitility: Starting evaluation", testResult.get(0).getSteps().get(0).getName(), "Top level step has default name because await() wo alias" @@ -106,12 +107,12 @@ Stream globalSettingsAwaitWoAliasCheckTopLevelPassedStep() { @Test void globalSettingsAwaitWithAliasCheckTopLevelPassedStep() { final List testResult = runWithinTestContext(() -> { - final AtomicInteger atomicInteger = new AtomicInteger(0); - await("Counter should be at least 3").with() - .atMost(Duration.of(1000, ChronoUnit.MILLIS)) - .pollInterval(Duration.of(50, ChronoUnit.MILLIS)) - .until(atomicInteger::getAndIncrement, is(3)); - }, + final AtomicInteger atomicInteger = new AtomicInteger(0); + await("Counter should be at least 3").with() + .atMost(Duration.of(1000, ChronoUnit.MILLIS)) + .pollInterval(Duration.of(50, ChronoUnit.MILLIS)) + .until(atomicInteger::getAndIncrement, is(3)); + }, AllureAwaitilityListener::setLifecycle ).getTestResults(); assertEquals( @@ -134,24 +135,24 @@ void globalSettingsAwaitWithAliasCheckTopLevelPassedStep() { @Test Stream globalSettingsCheckAwaitWoAliasSecondLevelPassedSteps() { final List testResult = runWithinTestContext(() -> { - final AtomicInteger atomicInteger = new AtomicInteger(0); - await().with() - .atMost(Duration.of(1000, ChronoUnit.MILLIS)) - .pollInterval(Duration.of(50, ChronoUnit.MILLIS)) - .until(atomicInteger::getAndIncrement, is(3)); - }, + final AtomicInteger atomicInteger = new AtomicInteger(0); + await().with() + .atMost(Duration.of(1000, ChronoUnit.MILLIS)) + .pollInterval(Duration.of(50, ChronoUnit.MILLIS)) + .until(atomicInteger::getAndIncrement, is(3)); + }, AllureAwaitilityListener::setLifecycle ).getTestResults(); return Stream.of( - dynamicTest("Second level steps count", () -> - assertEquals( + dynamicTest( + "Second level steps count", () -> assertEquals( 4, testResult.get(0).getSteps().get(0).getSteps().size(), "Exactly 4 second level steps for 4 polling iterations" ) ), - dynamicTest("Second level steps all passed", () -> - assertThat(testResult.get(0).getSteps().get(0).getSteps()) + dynamicTest( + "Second level steps all passed", () -> assertThat(testResult.get(0).getSteps().get(0).getSteps()) .extracting(StepResult::getStatus) .containsExactlyInAnyOrder( Status.PASSED, @@ -160,32 +161,32 @@ Stream globalSettingsCheckAwaitWoAliasSecondLevelPassedSteps() { Status.PASSED ) ), - dynamicTest("Second level step 1 name", () -> - assertThat(testResult.get(0).getSteps().get(0).getSteps().get(0).getName()) + dynamicTest( + "Second level step 1 name", () -> assertThat(testResult.get(0).getSteps().get(0).getSteps().get(0).getName()) .contains("io.qameta.allure.awaitility.GlobalSettingsPositiveTest") .contains("expected <3> but was <0>") .contains("elapsed time") .contains("remaining time") .contains("last poll interval was") ), - dynamicTest("Second level step 2 name", () -> - assertThat(testResult.get(0).getSteps().get(0).getSteps().get(1).getName()) + dynamicTest( + "Second level step 2 name", () -> assertThat(testResult.get(0).getSteps().get(0).getSteps().get(1).getName()) .contains("io.qameta.allure.awaitility.GlobalSettingsPositiveTest") .contains("expected <3> but was <1>") .contains("elapsed time") .contains("remaining time") .contains("last poll interval was") ), - dynamicTest("Second level step 3 name", () -> - assertThat(testResult.get(0).getSteps().get(0).getSteps().get(2).getName()) + dynamicTest( + "Second level step 3 name", () -> assertThat(testResult.get(0).getSteps().get(0).getSteps().get(2).getName()) .contains("io.qameta.allure.awaitility.GlobalSettingsPositiveTest") .contains("expected <3> but was <2>") .contains("elapsed time") .contains("remaining time") .contains("last poll interval was") ), - dynamicTest("Second level step 4 name", () -> - assertThat(testResult.get(0).getSteps().get(0).getSteps().get(3).getName()) + dynamicTest( + "Second level step 4 name", () -> assertThat(testResult.get(0).getSteps().get(0).getSteps().get(3).getName()) .contains("io.qameta.allure.awaitility.GlobalSettingsPositiveTest") .contains("java.util.concurrent.atomic.AtomicInteger:") .contains("reached its end value of <3> after") diff --git a/allure-awaitility/src/test/java/io/qameta/allure/awaitility/MultipleConditionsTest.java b/allure-awaitility/src/test/java/io/qameta/allure/awaitility/MultipleConditionsTest.java new file mode 100644 index 000000000..6b2fff5f9 --- /dev/null +++ b/allure-awaitility/src/test/java/io/qameta/allure/awaitility/MultipleConditionsTest.java @@ -0,0 +1,70 @@ +/* + * Copyright 2016-2026 Qameta Software Inc + * + * 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 io.qameta.allure.awaitility; + +import io.qameta.allure.model.Status; +import io.qameta.allure.model.TestResult; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.*; + +import java.util.List; +import java.util.stream.Stream; + +import static io.qameta.allure.test.RunUtils.runWithinTestContext; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public class MultipleConditionsTest { + + @AfterEach + void reset() { + Awaitility.reset(); + } + + @BeforeEach + void setup() { + Awaitility.pollInSameThread(); + Awaitility.setDefaultConditionEvaluationListener(new AllureAwaitilityListener()); + } + + @TestFactory + Stream bothAwaitilityStepsShouldAppearTest() { + final List testResult = runWithinTestContext(() -> { + await().with() + .alias("First waiting") + .until(() -> true); + await().with() + .alias("Second waiting") + .until(() -> true); + }, + AllureAwaitilityListener::setLifecycle + ).getTestResults(); + + return Stream.of( + DynamicTest.dynamicTest( + "Exactly 2 top level step for 2 awaitility condition", () -> assertThat(testResult.get(0).getSteps()) + .describedAs("Allure TestResult contains exactly 2 top level step for 2 awaitility condition") + .hasSize(2) + ), + DynamicTest.dynamicTest( + "All top level step for all awaitility condition has PASSED", () -> assertThat(testResult.get(0).getSteps()) + .describedAs("Allure TestResult contains all top level step for all awaitility with PASSED condition") + .allMatch(step -> Status.PASSED.equals(step.getStatus())) + ) + ); + } + +} diff --git a/allure-awaitility/src/test/resources/allure.properties b/allure-awaitility/src/test/resources/allure.properties new file mode 100644 index 000000000..0486d8a71 --- /dev/null +++ b/allure-awaitility/src/test/resources/allure.properties @@ -0,0 +1,3 @@ +allure.results.directory=build/allure-results +allure.label.epic=#project.description# +allure.label.module=allure-awaitility diff --git a/allure-bom/build.gradle.kts b/allure-bom/build.gradle.kts index 9593300aa..0d83d95e9 100644 --- a/allure-bom/build.gradle.kts +++ b/allure-bom/build.gradle.kts @@ -8,6 +8,8 @@ dependencies { constraints { rootProject.subprojects.sorted() .forEach { api("${it.group}:${it.name}:${it.version}") } + api("io.qameta.allure:allure-junit5:${project.version}") + api("io.qameta.allure:allure-junit5-assert:${project.version}") } } diff --git a/allure-citrus/src/main/java/io/qameta/allure/citrus/AllureCitrus.java b/allure-citrus/src/main/java/io/qameta/allure/citrus/AllureCitrus.java index 3db26c236..b50ec45df 100644 --- a/allure-citrus/src/main/java/io/qameta/allure/citrus/AllureCitrus.java +++ b/allure-citrus/src/main/java/io/qameta/allure/citrus/AllureCitrus.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -59,6 +59,7 @@ import static io.qameta.allure.util.ResultsUtils.createParameter; import static io.qameta.allure.util.ResultsUtils.createSuiteLabel; import static io.qameta.allure.util.ResultsUtils.createThreadLabel; +import static io.qameta.allure.util.ResultsUtils.createTitlePath; import static io.qameta.allure.util.ResultsUtils.getProvidedLabels; /** @@ -161,25 +162,31 @@ public void onTestActionSkipped(final TestCase testCase, final TestAction testAc private void startTestCase(final TestCase testCase) { final String uuid = createUuid(testCase); + final Optional> testClass = Optional.ofNullable(testCase.getTestClass()); final TestResult result = new TestResult() .setUuid(uuid) .setName(testCase.getName()) + .setTitlePath( + testClass + .map(ResultsUtils::createTitlePathFromJavaClass) + .orElseGet(() -> createTitlePath(testCase.getName())) + ) .setStage(Stage.RUNNING); result.getLabels().addAll(getProvidedLabels()); - - final Optional> testClass = Optional.ofNullable(testCase.getTestClass()); testClass.map(this::getLabels).ifPresent(result.getLabels()::addAll); testClass.map(this::getLinks).ifPresent(result.getLinks()::addAll); - result.getLabels().addAll(Arrays.asList( - createHostLabel(), - createThreadLabel(), - createFrameworkLabel("citrus"), - createLanguageLabel("java") - )); + result.getLabels().addAll( + Arrays.asList( + createHostLabel(), + createThreadLabel(), + createFrameworkLabel("citrus"), + createLanguageLabel("java") + ) + ); testClass.ifPresent(aClass -> { final String suiteName = aClass.getCanonicalName(); @@ -217,7 +224,6 @@ private void stopTestCase(final TestCase testCase, getLifecycle().writeTestCase(uuid); } - private String createUuid(final TestCase testCase) { final String uuid = UUID.randomUUID().toString(); try { @@ -265,7 +271,8 @@ private List getLinks(final AnnotatedElement annotatedElement) { return Stream.of( getAnnotations(annotatedElement, io.qameta.allure.Link.class).map(ResultsUtils::createLink), getAnnotations(annotatedElement, io.qameta.allure.Issue.class).map(ResultsUtils::createLink), - getAnnotations(annotatedElement, io.qameta.allure.TmsLink.class).map(ResultsUtils::createLink)) + getAnnotations(annotatedElement, io.qameta.allure.TmsLink.class).map(ResultsUtils::createLink) + ) .reduce(Stream::concat).orElseGet(Stream::empty).collect(Collectors.toList()); } diff --git a/allure-citrus/src/test/java/io/qameta/allure/citrus/AllureCitrusTest.java b/allure-citrus/src/test/java/io/qameta/allure/citrus/AllureCitrusTest.java index a31fd6f00..eb4b05d29 100644 --- a/allure-citrus/src/test/java/io/qameta/allure/citrus/AllureCitrusTest.java +++ b/allure-citrus/src/test/java/io/qameta/allure/citrus/AllureCitrusTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -61,6 +61,8 @@ void shouldSetName() { assertThat(results.getTestResults()) .extracting(TestResult::getName) .containsExactly("Simple test"); + assertThat(results.getTestResults().get(0).getTitlePath()) + .containsExactly("com", "consol", "citrus", "dsl", "design", "DefaultTestDesigner"); } @AllureFeatures.PassedTests diff --git a/allure-citrus/src/test/resources/allure.properties b/allure-citrus/src/test/resources/allure.properties index 9c0b0a2d7..0833b8e02 100644 --- a/allure-citrus/src/test/resources/allure.properties +++ b/allure-citrus/src/test/resources/allure.properties @@ -1,2 +1,3 @@ allure.results.directory=build/allure-results allure.label.epic=#project.description# +allure.label.module=allure-citrus diff --git a/allure-cucumber2-jvm/src/main/java/io/qameta/allure/cucumber2jvm/AllureCucumber2Jvm.java b/allure-cucumber2-jvm/src/main/java/io/qameta/allure/cucumber2jvm/AllureCucumber2Jvm.java deleted file mode 100644 index 0dd1a38bf..000000000 --- a/allure-cucumber2-jvm/src/main/java/io/qameta/allure/cucumber2jvm/AllureCucumber2Jvm.java +++ /dev/null @@ -1,408 +0,0 @@ -/* - * Copyright 2019 Qameta Software OÜ - * - * 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 io.qameta.allure.cucumber2jvm; - -import cucumber.api.HookType; -import cucumber.api.PendingException; -import cucumber.api.Result; -import cucumber.api.TestCase; -import cucumber.api.TestStep; -import cucumber.api.event.EmbedEvent; -import cucumber.api.event.EventHandler; -import cucumber.api.event.EventPublisher; -import cucumber.api.event.TestCaseFinished; -import cucumber.api.event.TestCaseStarted; -import cucumber.api.event.TestSourceRead; -import cucumber.api.event.TestStepFinished; -import cucumber.api.event.TestStepStarted; -import cucumber.api.event.WriteEvent; -import cucumber.api.formatter.Formatter; -import cucumber.runner.UnskipableStep; -import gherkin.ast.Examples; -import gherkin.ast.Feature; -import gherkin.ast.ScenarioDefinition; -import gherkin.ast.ScenarioOutline; -import gherkin.ast.TableCell; -import gherkin.pickles.PickleCell; -import gherkin.pickles.PickleRow; -import gherkin.pickles.PickleTable; -import gherkin.pickles.PickleTag; -import io.qameta.allure.Allure; -import io.qameta.allure.AllureLifecycle; -import io.qameta.allure.model.FixtureResult; -import io.qameta.allure.model.Parameter; -import io.qameta.allure.model.Status; -import io.qameta.allure.model.StatusDetails; -import io.qameta.allure.model.StepResult; -import io.qameta.allure.model.TestResult; -import io.qameta.allure.model.TestResultContainer; - -import java.io.ByteArrayInputStream; -import java.nio.charset.StandardCharsets; -import java.util.Collections; -import java.util.Deque; -import java.util.HashMap; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.UUID; -import java.util.stream.Collectors; -import java.util.stream.IntStream; - -import static io.qameta.allure.util.ResultsUtils.getStatus; -import static io.qameta.allure.util.ResultsUtils.getStatusDetails; -import static io.qameta.allure.util.ResultsUtils.md5; - -/** - * Allure plugin for Cucumber JVM 2.0. - */ -@SuppressWarnings({ - "PMD.ExcessiveImports", - "ClassFanOutComplexity", "ClassDataAbstractionCoupling" -}) -public class AllureCucumber2Jvm implements Formatter { - - private final AllureLifecycle lifecycle; - - private final Map scenarioUuids = new HashMap<>(); - - private final CucumberSourceUtils cucumberSourceUtils = new CucumberSourceUtils(); - private Feature currentFeature; - private String currentFeatureFile; - private TestCase currentTestCase; - private String currentContainer; - private boolean forbidTestCaseStatusChange; - - private final EventHandler featureStartedHandler = this::handleFeatureStartedHandler; - private final EventHandler caseStartedHandler = this::handleTestCaseStarted; - private final EventHandler caseFinishedHandler = this::handleTestCaseFinished; - private final EventHandler stepStartedHandler = this::handleTestStepStarted; - private final EventHandler stepFinishedHandler = this::handleTestStepFinished; - private final EventHandler writeEventHandler = this::handleWriteEvent; - private final EventHandler embedEventHandler = this::handleEmbedEvent; - - private static final String TXT_EXTENSION = ".txt"; - private static final String TEXT_PLAIN = "text/plain"; - - @SuppressWarnings("unused") - public AllureCucumber2Jvm() { - this(Allure.getLifecycle()); - } - - public AllureCucumber2Jvm(final AllureLifecycle lifecycle) { - this.lifecycle = lifecycle; - } - - @Override - public void setEventPublisher(final EventPublisher publisher) { - publisher.registerHandlerFor(TestSourceRead.class, featureStartedHandler); - - publisher.registerHandlerFor(TestCaseStarted.class, caseStartedHandler); - publisher.registerHandlerFor(TestCaseFinished.class, caseFinishedHandler); - - publisher.registerHandlerFor(TestStepStarted.class, stepStartedHandler); - publisher.registerHandlerFor(TestStepFinished.class, stepFinishedHandler); - - publisher.registerHandlerFor(WriteEvent.class, writeEventHandler); - publisher.registerHandlerFor(EmbedEvent.class, embedEventHandler); - } - - /* - Event Handlers - */ - - private void handleFeatureStartedHandler(final TestSourceRead event) { - cucumberSourceUtils.addTestSourceReadEvent(event.uri, event); - } - - private void handleTestCaseStarted(final TestCaseStarted event) { - currentTestCase = event.testCase; - currentFeatureFile = currentTestCase.getUri(); - currentFeature = cucumberSourceUtils.getFeature(currentFeatureFile); - currentContainer = UUID.randomUUID().toString(); - forbidTestCaseStatusChange = false; - - - final Deque tags = new LinkedList<>(currentTestCase.getTags()); - - final LabelBuilder labelBuilder = new LabelBuilder(currentFeature, currentTestCase, tags); - - final String name = currentTestCase.getName(); - final String featureName = currentFeature.getName(); - - final TestResult result = new TestResult() - .setUuid(getTestCaseUuid(currentTestCase)) - .setHistoryId(getHistoryId(currentTestCase)) - .setFullName(featureName + ": " + name) - .setName(name) - .setLabels(labelBuilder.getScenarioLabels()) - .setLinks(labelBuilder.getScenarioLinks()); - - final ScenarioDefinition scenarioDefinition = - cucumberSourceUtils.getScenarioDefinition(currentFeatureFile, currentTestCase.getLine()); - if (scenarioDefinition instanceof ScenarioOutline) { - result.setParameters( - getExamplesAsParameters((ScenarioOutline) scenarioDefinition) - ); - } - - if (currentFeature.getDescription() != null && !currentFeature.getDescription().isEmpty()) { - result.setDescription(currentFeature.getDescription()); - } - - final TestResultContainer resultContainer = new TestResultContainer() - .setName(String.format("%s: %s", scenarioDefinition.getKeyword(), scenarioDefinition.getName())) - .setUuid(getTestContainerUuid()) - .setChildren(Collections.singletonList(getTestCaseUuid(currentTestCase))); - - lifecycle.scheduleTestCase(result); - lifecycle.startTestContainer(getTestContainerUuid(), resultContainer); - lifecycle.startTestCase(getTestCaseUuid(currentTestCase)); - } - - private void handleTestCaseFinished(final TestCaseFinished event) { - - final String uuid = getTestCaseUuid(event.testCase); - final Optional details = getStatusDetails(event.result.getError()); - details.ifPresent(statusDetails -> lifecycle.updateTestCase( - uuid, - testResult -> testResult.setStatusDetails(statusDetails) - )); - lifecycle.stopTestCase(uuid); - lifecycle.stopTestContainer(getTestContainerUuid()); - lifecycle.writeTestCase(uuid); - lifecycle.writeTestContainer(getTestContainerUuid()); - } - - private void handleTestStepStarted(final TestStepStarted event) { - if (!event.testStep.isHook()) { - final String stepKeyword = Optional.ofNullable( - cucumberSourceUtils.getKeywordFromSource(currentFeatureFile, event.testStep.getStepLine()) - ).orElse("UNDEFINED"); - - final StepResult stepResult = new StepResult() - .setName(String.format("%s %s", stepKeyword, event.testStep.getPickleStep().getText())) - .setStart(System.currentTimeMillis()); - - lifecycle.startStep(getTestCaseUuid(currentTestCase), getStepUuid(event.testStep), stepResult); - - event.testStep.getStepArgument().stream() - .filter(PickleTable.class::isInstance) - .findFirst() - .ifPresent(table -> createDataTableAttachment((PickleTable) table)); - } else if (event.testStep instanceof UnskipableStep) { - initHook((UnskipableStep) event.testStep); - } - } - - private void initHook(final UnskipableStep hook) { - - final FixtureResult hookResult = new FixtureResult() - .setName(hook.getCodeLocation()) - .setStart(System.currentTimeMillis()); - - if (hook.getHookType() == HookType.Before) { - lifecycle.startPrepareFixture(getTestContainerUuid(), getHookStepUuid(hook), hookResult); - } else { - lifecycle.startTearDownFixture(getTestContainerUuid(), getHookStepUuid(hook), hookResult); - } - - } - - private void handleTestStepFinished(final TestStepFinished event) { - if (event.testStep.isHook() && event.testStep instanceof UnskipableStep) { - handleHookStep(event); - } else { - handlePickleStep(event); - } - } - - private void handleWriteEvent(final WriteEvent event) { - lifecycle.addAttachment( - "Text output", - TEXT_PLAIN, - TXT_EXTENSION, - Objects.toString(event.text).getBytes(StandardCharsets.UTF_8) - ); - } - - private void handleEmbedEvent(final EmbedEvent event) { - lifecycle.addAttachment("Screenshot", null, null, new ByteArrayInputStream(event.data)); - } - - /* - Utility Methods - */ - - private String getTestContainerUuid() { - return currentContainer; - } - - private String getTestCaseUuid(final TestCase testCase) { - return scenarioUuids.computeIfAbsent(getHistoryId(testCase), it -> UUID.randomUUID().toString()); - } - - private String getStepUuid(final TestStep step) { - return currentFeature.getName() + getTestCaseUuid(currentTestCase) - + step.getPickleStep().getText() + step.getStepLine(); - } - - private String getHookStepUuid(final TestStep step) { - return currentFeature.getName() + getTestCaseUuid(currentTestCase) - + step.getHookType().toString() + step.getCodeLocation(); - } - - private String getHistoryId(final TestCase testCase) { - final String testCaseLocation = testCase.getUri() + ":" + testCase.getLine(); - return md5(testCaseLocation); - } - - private Status translateTestCaseStatus(final Result testCaseResult) { - switch (testCaseResult.getStatus()) { - case FAILED: - return getStatus(testCaseResult.getError()) - .orElse(Status.FAILED); - case PASSED: - return Status.PASSED; - case SKIPPED: - case PENDING: - return Status.SKIPPED; - case AMBIGUOUS: - case UNDEFINED: - default: - return null; - } - } - - private List getExamplesAsParameters(final ScenarioOutline scenarioOutline) { - final int gap = 2; - final Optional examplesBlock = scenarioOutline.getExamples().stream() - .filter(e -> currentTestCase.getLine() >= e.getLocation().getLine() + gap) - .filter(e -> currentTestCase.getLine() < e.getLocation().getLine() + e.getTableBody().size() + gap) - .findFirst(); - - if (examplesBlock.isPresent()) { - final Examples examples = examplesBlock.get(); - final int rowIndex = currentTestCase.getLine() - examples.getLocation().getLine() - gap; - final List names = examples.getTableHeader().getCells(); - final List values = examples.getTableBody().get(rowIndex).getCells(); - return IntStream.range(0, examplesBlock.get().getTableHeader().getCells().size()).mapToObj(index -> { - final String name = names.get(index).getValue(); - final String value = values.get(index).getValue(); - return new Parameter().setName(name).setValue(value); - }).collect(Collectors.toList()); - } - return Collections.emptyList(); - } - - private void createDataTableAttachment(final PickleTable pickleTable) { - final List rows = pickleTable.getRows(); - - final StringBuilder dataTableCsv = new StringBuilder(); - if (!rows.isEmpty()) { - rows.forEach(dataTableRow -> { - dataTableCsv.append( - dataTableRow.getCells().stream() - .map(PickleCell::getValue) - .collect(Collectors.joining("\t")) - ); - dataTableCsv.append('\n'); - }); - - final String attachmentSource = lifecycle - .prepareAttachment("Data table", "text/tab-separated-values", "csv"); - lifecycle.writeAttachment(attachmentSource, - new ByteArrayInputStream(dataTableCsv.toString().getBytes(StandardCharsets.UTF_8))); - } - } - - private void handleHookStep(final TestStepFinished event) { - final String uuid = getHookStepUuid(event.testStep); - final FixtureResult fixtureResult = new FixtureResult().setStatus(translateTestCaseStatus(event.result)); - - if (!Status.PASSED.equals(fixtureResult.getStatus())) { - final TestResult testResult = new TestResult().setStatus(translateTestCaseStatus(event.result)); - final StatusDetails statusDetails = getStatusDetails(event.result.getError()).get(); - - statusDetails.setMessage(event.testStep.getHookType() - .name() + " is failed: " + event.result.getError().getLocalizedMessage()); - - if (event.testStep.getHookType() == HookType.Before) { - final TagParser tagParser = new TagParser(currentFeature, currentTestCase); - statusDetails - .setFlaky(tagParser.isFlaky()) - .setMuted(tagParser.isMuted()) - .setKnown(tagParser.isKnown()); - testResult.setStatus(Status.SKIPPED); - updateTestCaseStatus(testResult.getStatus()); - forbidTestCaseStatusChange = true; - } else { - testResult.setStatus(Status.BROKEN); - updateTestCaseStatus(testResult.getStatus()); - } - fixtureResult.setStatusDetails(statusDetails); - } - - lifecycle.updateFixture(uuid, result -> result.setStatus(fixtureResult.getStatus()) - .setStatusDetails(fixtureResult.getStatusDetails())); - lifecycle.stopFixture(uuid); - } - - private void handlePickleStep(final TestStepFinished event) { - - final Status stepStatus = translateTestCaseStatus(event.result); - final StatusDetails statusDetails; - if (event.result.getStatus() == Result.Type.UNDEFINED) { - updateTestCaseStatus(Status.PASSED); - - statusDetails = - getStatusDetails(new PendingException("TODO: implement me")) - .orElse(new StatusDetails()); - lifecycle.updateTestCase(getTestCaseUuid(currentTestCase), scenarioResult -> - scenarioResult - .setStatusDetails(statusDetails)); - } else { - statusDetails = - getStatusDetails(event.result.getError()) - .orElse(new StatusDetails()); - updateTestCaseStatus(stepStatus); - } - - - if (!Status.PASSED.equals(stepStatus) && stepStatus != null) { - forbidTestCaseStatusChange = true; - } - - final TagParser tagParser = new TagParser(currentFeature, currentTestCase); - statusDetails - .setFlaky(tagParser.isFlaky()) - .setMuted(tagParser.isMuted()) - .setKnown(tagParser.isKnown()); - - lifecycle.updateStep(getStepUuid(event.testStep), - stepResult -> stepResult.setStatus(stepStatus).setStatusDetails(statusDetails)); - lifecycle.stopStep(getStepUuid(event.testStep)); - } - - private void updateTestCaseStatus(final Status status) { - if (!forbidTestCaseStatusChange) { - lifecycle.updateTestCase(getTestCaseUuid(currentTestCase), - result -> result.setStatus(status)); - } - } -} diff --git a/allure-cucumber2-jvm/src/main/java/io/qameta/allure/cucumber2jvm/CucumberSourceUtils.java b/allure-cucumber2-jvm/src/main/java/io/qameta/allure/cucumber2jvm/CucumberSourceUtils.java deleted file mode 100644 index 519db53f6..000000000 --- a/allure-cucumber2-jvm/src/main/java/io/qameta/allure/cucumber2jvm/CucumberSourceUtils.java +++ /dev/null @@ -1,196 +0,0 @@ -/* - * Copyright 2019 Qameta Software OÜ - * - * 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 io.qameta.allure.cucumber2jvm; - -import cucumber.api.event.TestSourceRead; - -import gherkin.Parser; -import gherkin.AstBuilder; -import gherkin.TokenMatcher; -import gherkin.ParserException; -import gherkin.GherkinDialect; -import gherkin.GherkinDialectProvider; - -import gherkin.ast.GherkinDocument; -import gherkin.ast.Feature; -import gherkin.ast.ScenarioDefinition; -import gherkin.ast.Node; -import gherkin.ast.ScenarioOutline; -import gherkin.ast.TableRow; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.HashMap; -import java.util.Map; -import java.util.stream.IntStream; - -/** - * Parts of package-private cucumber.runtime.formatter.TestSourcesModel needed for Allure 2 adapter. - */ -public final class CucumberSourceUtils { - - private static final Logger LOGGER = LoggerFactory.getLogger(CucumberSourceUtils.class); - - private final Map pathToReadEventMap = new HashMap<>(); - private final Map pathToAstMap = new HashMap<>(); - private final Map> pathToNodeMap = new HashMap<>(); - - public void addTestSourceReadEvent(final String path, final TestSourceRead event) { - pathToReadEventMap.put(path, event); - } - - public Feature getFeature(final String path) { - if (!pathToAstMap.containsKey(path)) { - parseGherkinSource(path); - } - if (pathToAstMap.containsKey(path)) { - return pathToAstMap.get(path).getFeature(); - } - return null; - } - - private void parseGherkinSource(final String path) { - if (!pathToReadEventMap.containsKey(path)) { - return; - } - final Parser parser = new Parser<>(new AstBuilder()); - final TokenMatcher matcher = new TokenMatcher(); - try { - final GherkinDocument gherkinDocument = parser.parse(pathToReadEventMap.get(path).source, matcher); - pathToAstMap.put(path, gherkinDocument); - final Map nodeMap = new HashMap<>(); - final AstNode currentParent = new AstNode(gherkinDocument.getFeature(), null); - for (ScenarioDefinition child : gherkinDocument.getFeature().getChildren()) { - processScenarioDefinition(nodeMap, child, currentParent); - } - pathToNodeMap.put(path, nodeMap); - } catch (ParserException e) { - LOGGER.trace(e.getMessage(), e); - } - } - - private void processScenarioDefinition( - final Map nodeMap, final ScenarioDefinition child, final AstNode currentParent - ) { - final AstNode childNode = new AstNode(child, currentParent); - nodeMap.put(child.getLocation().getLine(), childNode); - - child.getSteps().forEach( - step -> nodeMap.put(step.getLocation().getLine(), new AstNode(step, childNode)) - ); - - if (child instanceof ScenarioOutline) { - processScenarioOutlineExamples(nodeMap, (ScenarioOutline) child, childNode); - } - } - - private void processScenarioOutlineExamples( - final Map nodeMap, final ScenarioOutline scenarioOutline, final AstNode childNode - ) { - scenarioOutline.getExamples().forEach(examples -> { - final AstNode examplesNode = new AstNode(examples, childNode); - final TableRow headerRow = examples.getTableHeader(); - final AstNode headerNode = new AstNode(headerRow, examplesNode); - nodeMap.put(headerRow.getLocation().getLine(), headerNode); - IntStream.range(0, examples.getTableBody().size()).forEach(i -> { - final TableRow examplesRow = examples.getTableBody().get(i); - final Node rowNode = new CucumberSourceUtils.ExamplesRowWrapperNode(examplesRow, i); - final AstNode expandedScenarioNode = new AstNode(rowNode, examplesNode); - nodeMap.put(examplesRow.getLocation().getLine(), expandedScenarioNode); - }); - }); - } - - private AstNode getAstNode(final String path, final int line) { - if (!pathToNodeMap.containsKey(path)) { - parseGherkinSource(path); - } - if (pathToNodeMap.containsKey(path)) { - return pathToNodeMap.get(path).get(line); - } - return null; - } - - public ScenarioDefinition getScenarioDefinition(final String path, final int line) { - return getScenarioDefinition(getAstNode(path, line)); - } - - private ScenarioDefinition getScenarioDefinition(final AstNode astNode) { - return astNode.getNode() instanceof ScenarioDefinition - ? (ScenarioDefinition) astNode.getNode() - : (ScenarioDefinition) astNode.getParent().getParent().getNode(); - } - - public String getKeywordFromSource(final String uri, final int stepLine) { - final Feature feature = getFeature(uri); - if (feature != null) { - final TestSourceRead event = getTestSourceReadEvent(uri); - final String trimmedSourceLine = event.source.split("\n")[stepLine - 1].trim(); - final GherkinDialect dialect = new GherkinDialectProvider(feature.getLanguage()).getDefaultDialect(); - for (String keyword : dialect.getStepKeywords()) { - if (trimmedSourceLine.startsWith(keyword)) { - return keyword; - } - } - } - return ""; - } - - private TestSourceRead getTestSourceReadEvent(final String uri) { - if (pathToReadEventMap.containsKey(uri)) { - return pathToReadEventMap.get(uri); - } - return null; - } - - /** - * Representation of Examples row. - */ - private static class ExamplesRowWrapperNode extends Node { - private final int bodyRowIndex; - - ExamplesRowWrapperNode(final Node examplesRow, final int bodyRowIndex) { - super(examplesRow.getLocation()); - this.bodyRowIndex = bodyRowIndex; - } - - public int getBodyRowIndex() { - return bodyRowIndex; - } - } - - /** - * Representation of leaf node. - */ - private static class AstNode { - private final Node node; - private final AstNode parent; - - AstNode(final Node node, final AstNode parent) { - this.node = node; - this.parent = parent; - } - - public Node getNode() { - return node; - } - - public AstNode getParent() { - return parent; - } - } -} diff --git a/allure-cucumber2-jvm/src/main/java/io/qameta/allure/cucumber2jvm/LabelBuilder.java b/allure-cucumber2-jvm/src/main/java/io/qameta/allure/cucumber2jvm/LabelBuilder.java deleted file mode 100644 index 18b36ae0a..000000000 --- a/allure-cucumber2-jvm/src/main/java/io/qameta/allure/cucumber2jvm/LabelBuilder.java +++ /dev/null @@ -1,161 +0,0 @@ -/* - * Copyright 2019 Qameta Software OÜ - * - * 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 io.qameta.allure.cucumber2jvm; - -import cucumber.api.TestCase; -import gherkin.ast.Feature; -import gherkin.pickles.PickleTag; -import io.qameta.allure.model.Label; -import io.qameta.allure.model.Link; -import io.qameta.allure.util.ResultsUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Deque; -import java.util.List; -import java.util.Objects; -import java.util.regex.Pattern; - -import static io.qameta.allure.util.ResultsUtils.createFeatureLabel; -import static io.qameta.allure.util.ResultsUtils.createFrameworkLabel; -import static io.qameta.allure.util.ResultsUtils.createHostLabel; -import static io.qameta.allure.util.ResultsUtils.createIssueLink; -import static io.qameta.allure.util.ResultsUtils.createLanguageLabel; -import static io.qameta.allure.util.ResultsUtils.createLink; -import static io.qameta.allure.util.ResultsUtils.createPackageLabel; -import static io.qameta.allure.util.ResultsUtils.createSeverityLabel; -import static io.qameta.allure.util.ResultsUtils.createStoryLabel; -import static io.qameta.allure.util.ResultsUtils.createSuiteLabel; -import static io.qameta.allure.util.ResultsUtils.createTagLabel; -import static io.qameta.allure.util.ResultsUtils.createTestClassLabel; -import static io.qameta.allure.util.ResultsUtils.createThreadLabel; -import static io.qameta.allure.util.ResultsUtils.createTmsLink; - -/** - * Scenario labels and links builder. - */ -@SuppressWarnings({"CyclomaticComplexity", "PMD.CyclomaticComplexity", "PMD.NcssCount"}) -class LabelBuilder { - private static final Logger LOGGER = LoggerFactory.getLogger(LabelBuilder.class); - private static final String COMPOSITE_TAG_DELIMITER = "="; - - private static final String SEVERITY = "@SEVERITY"; - private static final String ISSUE_LINK = "@ISSUE"; - private static final String TMS_LINK = "@TMSLINK"; - private static final String PLAIN_LINK = "@LINK"; - - private final List

This renderer intentionally implements a small, conservative subset of the JavaDoc comment + * specification instead of attempting to preserve the full doclet output. The goal is to keep + * JavaDoc-backed descriptions readable in reports while ensuring that untrusted comment content is + * never treated as executable HTML.

+ * + *

The rendering algorithm is intentionally simple:

+ *
    + *
  1. Take only the main description, stopping at the first block tag such as {@code @param} + * or {@code @throws}.
  2. + *
  3. Render the remaining content with a small parser that recognizes a limited set of inline + * JavaDoc tags and structural HTML tags.
  4. + *
  5. Escape or drop everything else so the output remains plain text or safe markdown.
  6. + *
+ * + *

Currently supported JavaDoc constructs include inline tags such as {@code {@code ...}}, + * {@code {@literal ...}}, {@code {@link ...}}, and {@code {@linkplain ...}}.

+ * + *

The renderer also understands a small set of structural HTML tags: {@code p}, {@code br}, + * {@code ul}, {@code ol}, {@code li}, and {@code code}. Common entity references such as + * {@code &lt;}, {@code &gt;}, {@code &amp;}, {@code &#064;}, + * {@code &lbrace;}, and numeric entities are decoded before the output is escaped again.

+ * + *

Unsupported tags are degraded to escaped text instead of being interpreted. Unknown HTML tags + * are ignored as markup while their text content remains visible. This keeps the JavaDoc + * description path suitable for open source projects where comments may evolve over time and where + * security is more important than pixel-perfect parity with the standard doclet.

+ */ +@SuppressWarnings({"PMD.GodClass", "PMD.TooManyMethods"}) +final class JavaDocDescriptionRenderer { + + private static final String PARAGRAPH_BREAK = "\n\n"; + private static final String HTML_LT = "<"; + private static final String HTML_GT = ">"; + private static final String HTML_AMP = "&"; + private static final String INLINE_CODE_MARKER = "`"; + private static final String ESCAPED_INLINE_CODE_MARKER = "``"; + private static final String CODE_TAG = "code"; + private static final String HTML_TAG_END = ">"; + private static final String CLOSING_TAG_PREFIX = "The method extracts the JavaDoc main description, renders the supported inline and HTML + * constructs into plain text or markdown, normalizes whitespace, and escapes unsafe content. + * The returned value is intended for Allure's plain {@code description} field, not for + * {@code descriptionHtml}.

+ * + * @param rawDocComment the comment text returned by {@link javax.lang.model.util.Elements#getDocComment} + * @return a safe markdown/plain-text description, or an empty string if the comment has no main + * description + */ + String render(final String rawDocComment) { + final String descriptionBody = extractDescriptionBody(rawDocComment); + if (descriptionBody.isEmpty()) { + return ""; + } + + final StringBuilder rendered = new StringBuilder(); + renderFragment(descriptionBody, rendered); + return cleanup(rendered.toString()); + } + + private String extractDescriptionBody(final String rawDocComment) { + final String[] lines = normalize(rawDocComment).split(NEW_LINE, -1); + final StringBuilder body = new StringBuilder(); + int inlineTagDepth = 0; + + for (String line : lines) { + if (inlineTagDepth == 0 && startsBlockTag(line)) { + return trimBlankLines(body.toString()); + } + if (body.length() > 0) { + body.append('\n'); + } + body.append(trimTrailingWhitespace(line)); + inlineTagDepth = updateInlineTagDepth(line, inlineTagDepth); + } + + return trimBlankLines(body.toString()); + } + + private boolean startsBlockTag(final String line) { + final String trimmed = line.trim(); + return trimmed.length() > 1 && trimmed.charAt(0) == '@' && Character.isJavaIdentifierStart(trimmed.charAt(1)); + } + + @SuppressWarnings({"checkstyle:CyclomaticComplexity", "PMD.CognitiveComplexity"}) + private void renderFragment(final String fragment, final StringBuilder rendered) { + int index = 0; + while (index < fragment.length()) { + final char current = fragment.charAt(index); + if (current == '{' && index + 1 < fragment.length() && fragment.charAt(index + 1) == '@') { + final int nextIndex = renderInlineTag(fragment, index, rendered); + if (nextIndex > index) { + index = nextIndex; + continue; + } + } + if (current == '<') { + final int nextIndex = renderHtmlTag(fragment, index, rendered); + if (nextIndex > index) { + index = nextIndex; + continue; + } + rendered.append(HTML_LT); + index++; + continue; + } + if (current == '&') { + final int nextIndex = renderEntityReference(fragment, index, rendered); + if (nextIndex > index) { + index = nextIndex; + continue; + } + rendered.append(HTML_AMP); + index++; + continue; + } + if (current == '>') { + rendered.append(HTML_GT); + index++; + continue; + } + rendered.append(current); + index++; + } + } + + @SuppressWarnings("checkstyle:ReturnCount") + private int renderInlineTag(final String fragment, final int start, final StringBuilder rendered) { + final int end = findInlineTagEnd(fragment, start); + if (end < 0) { + return start; + } + + final String content = fragment.substring(start + 2, end).trim(); + if (content.isEmpty()) { + return end + 1; + } + + final int separator = findWhitespace(content); + final String tag = separator < 0 ? content : content.substring(0, separator); + final String payload = separator < 0 ? "" : content.substring(separator + 1).trim(); + + if (CODE_TAG.equals(tag)) { + appendCode(rendered, payload); + return end + 1; + } + if ("literal".equals(tag)) { + rendered.append(escapeText(payload)); + return end + 1; + } + if ("link".equals(tag) || "linkplain".equals(tag)) { + appendLink(rendered, payload); + return end + 1; + } + + rendered.append(escapeText(content)); + return end + 1; + } + + @SuppressWarnings( + { + "checkstyle:CyclomaticComplexity", + "checkstyle:NPathComplexity", + "PMD.CognitiveComplexity", + "checkstyle:ReturnCount"} + ) + private int renderHtmlTag(final String fragment, final int start, final StringBuilder rendered) { + if (start + 1 >= fragment.length() || Character.isWhitespace(fragment.charAt(start + 1))) { + return start; + } + + final int end = fragment.indexOf('>', start + 1); + if (end < 0) { + return start; + } + + final String rawTag = fragment.substring(start + 1, end).trim(); + if (rawTag.isEmpty()) { + return start; + } + + boolean closing = false; + String tag = rawTag; + if (tag.charAt(0) == '/') { + closing = true; + tag = tag.substring(1).trim(); + } + + if (tag.endsWith("/")) { + tag = tag.substring(0, tag.length() - 1).trim(); + } + + final int separator = findTagNameEnd(tag); + if (separator <= 0) { + return end + 1; + } + + final String name = tag.substring(0, separator).toLowerCase(Locale.ROOT); + if ("br".equals(name)) { + appendLineBreak(rendered); + return end + 1; + } + if ("p".equals(name) || "ul".equals(name) || "ol".equals(name)) { + appendParagraphBreak(rendered); + return end + 1; + } + if ("li".equals(name)) { + if (!closing) { + startListItem(rendered); + } + return end + 1; + } + if (CODE_TAG.equals(name)) { + if (closing) { + return end + 1; + } + final int closingStart = findClosingTag(fragment, end + 1, CODE_TAG); + if (closingStart <= end) { + return end + 1; + } + appendCode(rendered, fragment.substring(end + 1, closingStart)); + return closingStart + (CLOSING_TAG_PREFIX + CODE_TAG + HTML_TAG_END).length(); + } + + return end + 1; + } + + private void appendLink(final StringBuilder rendered, final String payload) { + if (payload.isEmpty()) { + return; + } + + final int separator = findWhitespace(payload); + final String label = separator < 0 ? "" : payload.substring(separator + 1).trim(); + if (label.isEmpty()) { + final String reference = separator < 0 ? payload : payload.substring(0, separator); + rendered.append(escapeText(shortenReference(reference))); + return; + } + + renderFragment(label, rendered); + } + + private String shortenReference(final String reference) { + final String trimmed = reference.trim(); + final int hashIndex = trimmed.lastIndexOf('#'); + if (hashIndex >= 0 && hashIndex + 1 < trimmed.length()) { + return trimmed.substring(hashIndex + 1); + } + + final int dotIndex = trimmed.lastIndexOf('.'); + if (dotIndex >= 0 && dotIndex + 1 < trimmed.length()) { + return trimmed.substring(dotIndex + 1); + } + + return trimmed; + } + + private void appendCode(final StringBuilder rendered, final String payload) { + final String escaped = escapeText(payload); + final String marker = escaped.contains(INLINE_CODE_MARKER) + ? ESCAPED_INLINE_CODE_MARKER + : INLINE_CODE_MARKER; + rendered.append(marker) + .append(escaped) + .append(marker); + } + + private void startListItem(final StringBuilder rendered) { + trimTrailingSpaces(rendered); + if (rendered.length() > 0 && rendered.charAt(rendered.length() - 1) != '\n') { + rendered.append('\n'); + } + rendered.append("- "); + } + + private void appendParagraphBreak(final StringBuilder rendered) { + trimTrailingSpaces(rendered); + if (rendered.length() == 0 || endsWith(rendered, PARAGRAPH_BREAK)) { + return; + } + if (rendered.charAt(rendered.length() - 1) == '\n') { + rendered.append('\n'); + return; + } + rendered.append(PARAGRAPH_BREAK); + } + + private void appendLineBreak(final StringBuilder rendered) { + trimTrailingSpaces(rendered); + if (rendered.length() == 0 || rendered.charAt(rendered.length() - 1) == '\n') { + return; + } + rendered.append('\n'); + } + + private String cleanup(final String rendered) { + final String[] lines = normalize(rendered).split(NEW_LINE, -1); + final StringBuilder cleaned = new StringBuilder(); + boolean blankLinePending = false; + + for (String line : lines) { + final String trimmed = line.trim(); + if (trimmed.isEmpty()) { + if (cleaned.length() > 0) { + blankLinePending = true; + } + continue; + } + + if (cleaned.length() > 0) { + cleaned.append(blankLinePending ? PARAGRAPH_BREAK : NEW_LINE); + } + cleaned.append(trimmed); + blankLinePending = false; + } + + return cleaned.toString(); + } + + private String trimBlankLines(final String value) { + final String[] lines = normalize(value).split(NEW_LINE, -1); + int start = 0; + int end = lines.length; + + while (start < end && isBlank(lines[start])) { + start++; + } + while (end > start && isBlank(lines[end - 1])) { + end--; + } + + final StringBuilder result = new StringBuilder(); + for (int index = start; index < end; index++) { + if (result.length() > 0) { + result.append('\n'); + } + result.append(lines[index]); + } + return result.toString(); + } + + private int updateInlineTagDepth(final String line, final int initialDepth) { + int depth = initialDepth; + int index = 0; + while (index < line.length()) { + final char current = line.charAt(index); + if (depth == 0) { + if (current == '{' && index + 1 < line.length() && line.charAt(index + 1) == '@') { + depth = 1; + index += 2; + continue; + } + } else if (current == '{') { + depth++; + } else if (current == '}') { + depth--; + } + index++; + } + return depth; + } + + private void trimTrailingSpaces(final StringBuilder builder) { + while (builder.length() > 0) { + final char current = builder.charAt(builder.length() - 1); + if (current != ' ' && current != '\t') { + break; + } + builder.deleteCharAt(builder.length() - 1); + } + } + + private boolean endsWith(final StringBuilder builder, final String suffix) { + return builder.length() >= suffix.length() + && builder.substring(builder.length() - suffix.length()).equals(suffix); + } + + private int findWhitespace(final String value) { + for (int index = 0; index < value.length(); index++) { + if (Character.isWhitespace(value.charAt(index))) { + return index; + } + } + return -1; + } + + private int findTagNameEnd(final String tag) { + for (int index = 0; index < tag.length(); index++) { + final char current = tag.charAt(index); + if (!(Character.isLetterOrDigit(current) || current == '-' || current == '_')) { + return index; + } + } + return tag.length(); + } + + private int findClosingTag(final String fragment, final int fromIndex, final String tagName) { + return fragment.toLowerCase(Locale.ROOT).indexOf(CLOSING_TAG_PREFIX + tagName + HTML_TAG_END, fromIndex); + } + + private int findInlineTagEnd(final String fragment, final int start) { + int depth = 1; + for (int index = start + 2; index < fragment.length(); index++) { + final char current = fragment.charAt(index); + if (current == '{') { + depth++; + continue; + } + if (current == '}') { + depth--; + if (depth == 0) { + return index; + } + } + } + return -1; + } + + private String trimTrailingWhitespace(final String line) { + int end = line.length(); + while (end > 0) { + final char current = line.charAt(end - 1); + if (current != ' ' && current != '\t') { + break; + } + end--; + } + return line.substring(0, end); + } + + private String normalize(final String value) { + return value.replace("\r\n", NEW_LINE).replace('\r', '\n'); + } + + private boolean isBlank(final String value) { + for (int index = 0; index < value.length(); index++) { + if (!Character.isWhitespace(value.charAt(index))) { + return false; + } + } + return true; + } + + private int renderEntityReference(final String fragment, final int start, final StringBuilder rendered) { + final int end = fragment.indexOf(';', start + 1); + if (end < 0) { + return start; + } + + final String decoded = decodeEntity(fragment.substring(start + 1, end)); + if (decoded == null) { + return start; + } + + rendered.append(escapeText(decoded)); + return end + 1; + } + + @SuppressWarnings( + { + "checkstyle:CyclomaticComplexity", + "checkstyle:NPathComplexity", + "checkstyle:ReturnCount"} + ) + private String decodeEntity(final String entity) { + if (entity.isEmpty()) { + return null; + } + + if (entity.charAt(0) == '#') { + return decodeNumericEntity(entity); + } + + if ("amp".equals(entity)) { + return Character.toString('&'); + } + if ("lt".equals(entity)) { + return Character.toString('<'); + } + if ("gt".equals(entity)) { + return Character.toString('>'); + } + if ("quot".equals(entity)) { + return "\""; + } + if ("apos".equals(entity)) { + return "'"; + } + if ("nbsp".equals(entity)) { + return " "; + } + if ("lbrace".equals(entity)) { + return "{"; + } + if ("rbrace".equals(entity)) { + return "}"; + } + if ("commat".equals(entity)) { + return Character.toString('@'); + } + return null; + } + + private String decodeNumericEntity(final String entity) { + try { + final int codePoint; + if (entity.startsWith("#x") || entity.startsWith("#X")) { + codePoint = Integer.parseInt(entity.substring(2), 16); + } else { + codePoint = Integer.parseInt(entity.substring(1), 10); + } + return new String(Character.toChars(codePoint)); + } catch (IllegalArgumentException e) { + return null; + } + } + + private String escapeText(final String value) { + final StringBuilder escaped = new StringBuilder(); + int index = 0; + while (index < value.length()) { + final char current = value.charAt(index); + if (current == '&') { + final int nextIndex = renderEntityReference(value, index, escaped); + if (nextIndex > index) { + index = nextIndex; + continue; + } + escaped.append(HTML_AMP); + } else if (current == '<') { + escaped.append(HTML_LT); + } else if (current == '>') { + escaped.append(HTML_GT); + } else { + escaped.append(current); + } + index++; + } + return escaped.toString(); + } +} diff --git a/allure-descriptions-javadoc/src/main/java/io/qameta/allure/description/JavaDocDescriptionsProcessor.java b/allure-descriptions-javadoc/src/main/java/io/qameta/allure/description/JavaDocDescriptionsProcessor.java index 6efbe2fe9..289c5f43a 100644 --- a/allure-descriptions-javadoc/src/main/java/io/qameta/allure/description/JavaDocDescriptionsProcessor.java +++ b/allure-descriptions-javadoc/src/main/java/io/qameta/allure/description/JavaDocDescriptionsProcessor.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,42 +15,47 @@ */ package io.qameta.allure.description; -import io.qameta.allure.Description; - import javax.annotation.processing.AbstractProcessor; import javax.annotation.processing.Filer; import javax.annotation.processing.Messager; import javax.annotation.processing.ProcessingEnvironment; import javax.annotation.processing.RoundEnvironment; import javax.annotation.processing.SupportedAnnotationTypes; -import javax.annotation.processing.SupportedSourceVersion; import javax.lang.model.SourceVersion; import javax.lang.model.element.Element; import javax.lang.model.element.ExecutableElement; import javax.lang.model.element.TypeElement; import javax.lang.model.element.VariableElement; +import javax.lang.model.util.ElementFilter; import javax.lang.model.util.Elements; import javax.tools.Diagnostic; import javax.tools.FileObject; import javax.tools.StandardLocation; + import java.io.IOException; import java.io.Writer; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.List; import java.util.Set; import java.util.stream.Collectors; -import static io.qameta.allure.util.ResultsUtils.generateMethodSignatureHash; +import static io.qameta.allure.description.ClassNames.DESCRIPTION_ANNOTATION; /** * @author Egor Borisov ehborisov@gmail.com */ -@SupportedAnnotationTypes("io.qameta.allure.Description") -@SupportedSourceVersion(SourceVersion.RELEASE_8) +@SupportedAnnotationTypes(DESCRIPTION_ANNOTATION) public class JavaDocDescriptionsProcessor extends AbstractProcessor { + private static final String ALLURE_DESCRIPTIONS_FOLDER = "META-INF/allureDescriptions/"; + private Filer filer; private Elements elementUtils; private Messager messager; + private JavaDocDescriptionRenderer renderer; @Override @SuppressWarnings("PMD.AvoidSynchronizedAtMethodLevel") @@ -59,37 +64,51 @@ public synchronized void init(final ProcessingEnvironment env) { filer = env.getFiler(); elementUtils = env.getElementUtils(); messager = env.getMessager(); + renderer = new JavaDocDescriptionRenderer(); + } + + @Override + public SourceVersion getSupportedSourceVersion() { + return SourceVersion.latest(); } @Override public boolean process(final Set annotations, final RoundEnvironment env) { - final Set elements = env.getElementsAnnotatedWith(Description.class); - elements.forEach(el -> { - if (!el.getAnnotation(Description.class).useJavaDoc()) { + final TypeElement typeElement = elementUtils.getTypeElement(DESCRIPTION_ANNOTATION); + final Set elements = env.getElementsAnnotatedWith(typeElement); + final Set methods = ElementFilter.methodsIn(elements); + methods.forEach(method -> { + final String rawDocs = elementUtils.getDocComment(method); + if (rawDocs == null) { return; } - final String docs = elementUtils.getDocComment(el); - final List typeParams = ((ExecutableElement) el).getParameters().stream() - .map(this::methodParameterTypeMapper) - .collect(Collectors.toList()); - final String name = el.getSimpleName().toString(); - if (docs == null) { - messager.printMessage(Diagnostic.Kind.WARNING, - "Unable to create resource for method " + name + typeParams - + " as it does not have a docs comment"); + + final String docs = renderer.render(rawDocs); + if (docs.isEmpty()) { return; } - final String hash = generateMethodSignatureHash(el.getEnclosingElement().toString(), name, typeParams); + final String name = method.getSimpleName().toString(); + final List typeParams = method.getParameters().stream() + .map(this::methodParameterTypeMapper) + .collect(Collectors.toList()); + + final String hash = generateMethodSignatureHash( + method.getEnclosingElement().toString(), name, typeParams + ); try { - final FileObject file = filer.createResource(StandardLocation.CLASS_OUTPUT, - "allureDescriptions", hash); + final FileObject file = filer.createResource( + StandardLocation.CLASS_OUTPUT, "", + ALLURE_DESCRIPTIONS_FOLDER + hash + ); try (Writer writer = file.openWriter()) { writer.write(docs); } } catch (IOException e) { - messager.printMessage(Diagnostic.Kind.WARNING, - "Unable to create resource from docs comment of method " + name + typeParams); + messager.printMessage( + Diagnostic.Kind.WARNING, + "Unable to create resource from docs comment of method " + name + typeParams + ); } }); @@ -100,4 +119,29 @@ private String methodParameterTypeMapper(final VariableElement parameter) { final Element typeElement = processingEnv.getTypeUtils().asElement(parameter.asType()); return typeElement != null ? typeElement.toString() : parameter.asType().toString(); } + + private static String generateMethodSignatureHash(final String className, + final String methodName, + final List parameterTypes) { + final MessageDigest md = getMd5Digest(); + md.update(className.getBytes(StandardCharsets.UTF_8)); + md.update(methodName.getBytes(StandardCharsets.UTF_8)); + parameterTypes.stream() + .map(string -> string.getBytes(StandardCharsets.UTF_8)) + .forEach(md::update); + final byte[] bytes = md.digest(); + return bytesToHex(bytes); + } + + private static MessageDigest getMd5Digest() { + try { + return MessageDigest.getInstance("MD5"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("Can not find hashing algorithm", e); + } + } + + private static String bytesToHex(final byte[] bytes) { + return new BigInteger(1, bytes).toString(16); + } } diff --git a/allure-descriptions-javadoc/src/test/java/io/qameta/allure/description/JavaDocDescriptionRendererTest.java b/allure-descriptions-javadoc/src/test/java/io/qameta/allure/description/JavaDocDescriptionRendererTest.java new file mode 100644 index 000000000..b82f4c476 --- /dev/null +++ b/allure-descriptions-javadoc/src/test/java/io/qameta/allure/description/JavaDocDescriptionRendererTest.java @@ -0,0 +1,328 @@ +/* + * Copyright 2016-2026 Qameta Software Inc + * + * 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 io.qameta.allure.description; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class JavaDocDescriptionRendererTest { + + private final JavaDocDescriptionRenderer renderer = new JavaDocDescriptionRenderer(); + + @Test + void shouldRenderPlainTextAndTrimBlankLines() { + final String rendered = renderer.render( + "\r\n" + + " First line \r\n" + + "\r\n" + + " Second line\t\r\n" + + "\r\n" + ); + + assertThat(rendered) + .isEqualTo("First line\n\nSecond line"); + } + + @Test + void shouldReturnEmptyStringWhenBodyContainsOnlyBlockTags() { + final String rendered = renderer.render( + "@param value description\n" + + "@throws Exception description" + ); + + assertThat(rendered) + .isEmpty(); + } + + @Test + void shouldIgnoreBlockTagsAndEverythingAfterThem() { + final String rendered = renderer.render( + "Summary paragraph.\n" + + "\n" + + "@param value Description of the value.\n" + + "Continuation that should also be ignored." + ); + + assertThat(rendered) + .isEqualTo("Summary paragraph."); + } + + @Test + void shouldIgnoreStandardBlockTagsAfterMainDescription() { + final List blockTags = List.of( + "author", + "deprecated", + "exception", + "hidden", + "param", + "provides", + "return", + "see", + "serial", + "serialData", + "serialField", + "since", + "spec", + "throws", + "uses", + "version" + ); + + for (String blockTag : blockTags) { + assertThat(renderer.render("Summary paragraph.\n@" + blockTag + " metadata")) + .as(blockTag) + .isEqualTo("Summary paragraph."); + } + } + + @Test + void shouldNotTreatAtSignsInsideTextAsBlockTags() { + final String rendered = renderer.render( + "Email support@example.com\n" + + "Use @smoke in prose." + ); + + assertThat(rendered) + .isEqualTo("Email support@example.com\nUse @smoke in prose."); + } + + @Test + void shouldDecodeEscapedAtEntityBeforeBlockTags() { + final String rendered = renderer.render( + "@version stays in prose.\n" + + "@version 2.4.0" + ); + + assertThat(rendered) + .isEqualTo("@version stays in prose."); + } + + @Test + void shouldPreserveUnicodeCharactersInDescriptions() { + final String rendered = renderer.render("Release notes: cafe, café, Привет, 東京, λ."); + + assertThat(rendered) + .isEqualTo("Release notes: cafe, café, Привет, 東京, λ."); + } + + @Test + void shouldDecodeSupportedNamedAndNumericEntities() { + final String rendered = renderer.render( + "Use <tag>, &, {x}, @, λ, and λ." + ); + + assertThat(rendered) + .isEqualTo("Use <tag>, &, {x}, @, λ, and λ."); + } + + @Test + void shouldRenderSupportedInlineTags() { + final String rendered = renderer.render( + "Use {@code a < b}, {@literal }, " + + "{@link java.lang.String}, " + + "{@linkplain java.lang.String#valueOf(Object)}, " + + "{@link java.util.List list docs}." + ); + + assertThat(rendered) + .isEqualTo("Use `a < b`, <safe>, String, valueOf(Object), list docs."); + } + + @Test + void shouldSupportBalancedBracesInsideInlineTags() { + final String rendered = renderer.render( + "Payload {@code {\"outer\": {\"inner\": true}}}." + ); + + assertThat(rendered) + .isEqualTo("Payload `{\"outer\": {\"inner\": true}}`."); + } + + @Test + void shouldNotTreatAtLinesInsideBalancedInlineTagsAsBlockTags() { + final String rendered = renderer.render( + "Summary {@literal first line\n" + + "@notATag\n" + + "last line}\n" + + "@param ignored" + ); + + assertThat(rendered) + .isEqualTo("Summary first line\n@notATag\nlast line"); + } + + @Test + void shouldRenderNestedInlineTagsInsideLinkLabels() { + final String rendered = renderer.render( + "See {@linkplain java.util.List docs with {@code List}}." + ); + + assertThat(rendered) + .isEqualTo("See docs with `List`."); + } + + @Test + void shouldSafelyDegradeUnsupportedStandardInlineTags() { + final String rendered = renderer.render( + "Fallbacks: {@docRoot}, {@inheritDoc}, {@index release}, " + + "{@summary quick summary}, {@systemProperty user.home}, " + + "{@value java.lang.Integer#MAX_VALUE}." + ); + + assertThat(rendered) + .isEqualTo( + "Fallbacks: docRoot, inheritDoc, index release, summary quick summary, " + + "systemProperty user.home, value java.lang.Integer#MAX_VALUE." + ); + } + + @Test + void shouldSafelyDegradeSnippetTags() { + final String rendered = renderer.render( + "Snippet {@snippet :\n" + + "int answer = 42;\n" + + "@highlight substring=\"answer\"\n" + + "}." + ); + + assertThat(rendered) + .isEqualTo("Snippet snippet :\nint answer = 42;\n@highlight substring=\"answer\"."); + } + + @Test + void shouldEscapeUnknownInlineTags() { + final String rendered = renderer.render("Unsupported {@unknown } clause."); + + assertThat(rendered) + .isEqualTo("Unsupported unknown <tag> clause."); + } + + @Test + void shouldPreserveMalformedInlineTagsAsText() { + final String rendered = renderer.render("Broken {@code tag"); + + assertThat(rendered) + .isEqualTo("Broken {@code tag"); + } + + @Test + void shouldRenderSupportedHtmlStructure() { + final String rendered = renderer.render( + "First

Second
Third

  • one
  • two
  1. three
" + ); + + assertThat(rendered) + .isEqualTo("First\n\nSecond\nThird\n\n- one\n- two\n\n- three"); + } + + @Test + void shouldIgnoreUnclosedHtmlTagsSafely() { + final String rendered = renderer.render("Broken bold text"); + + assertThat(rendered) + .isEqualTo("Broken bold text"); + } + + @Test + void shouldPreserveAngleBracketComparisonsAsText() { + final String rendered = renderer.render("Math says a < b > c."); + + assertThat(rendered) + .isEqualTo("Math says a < b > c."); + } + + @Test + void shouldIgnoreUnmatchedCodeHtmlTagsSafely() { + final String rendered = renderer.render("Broken value < limit and stray tag"); + + assertThat(rendered) + .isEqualTo("Broken `value < limit and stray `tag"); + } + + @Test + void shouldIgnoreOpeningCodeTagWithoutClosingTag() { + final String rendered = renderer.render("Broken value < limit"); + + assertThat(rendered) + .isEqualTo("Broken value < limit"); + } + + @Test + void shouldIgnoreClosingCodeTagWithoutOpeningTag() { + final String rendered = renderer.render("Broken tag"); + + assertThat(rendered) + .isEqualTo("Broken tag"); + } + + @Test + void shouldRenderHtmlCodeTagAsCodeSpan() { + final String rendered = renderer.render("name < value & more"); + + assertThat(rendered) + .isEqualTo("`name < value & more`"); + } + + @Test + void shouldLeaveUnknownEntitiesEscaped() { + final String rendered = renderer.render("Keep ¬AnEntity; literal."); + + assertThat(rendered) + .isEqualTo("Keep &notAnEntity; literal."); + } + + @Test + void shouldDropUnknownHtmlTagsButKeepTheirTextContentEscaped() { + final String rendered = renderer.render( + "prefix
safe & sound
" + ); + + assertThat(rendered) + .isEqualTo("prefix alert(\"x\") safe & sound"); + } + + @Test + void shouldRenderComplexModernJavadocExampleSafely() { + final String rendered = renderer.render( + "Fetches release metadata for the current build.\n" + + "\n" + + "

Use {@link java.net.URI URIs} for endpoint configuration.

\n" + + "
\n" + + "Example: client.fetch(\"v2\")\n" + + "@beta remains prose.\n" + + "@author Jane Doe\n" + + "@version 2.3.0\n" + + "@since 2.0" + ); + + assertThat(rendered) + .isEqualTo( + "Fetches release metadata for the current build.\n\n" + + "Use URIs for endpoint configuration.\n\n" + + "- Supports café, Привет, 東京, and λ.\n" + + "- See the Javadoc specification and formatted examples.\n\n" + + "Example: `client.fetch(\"v2\")`\n" + + "@beta remains prose." + ); + } +} diff --git a/allure-descriptions-javadoc/src/test/java/io/qameta/allure/description/ProcessDescriptionsTest.java b/allure-descriptions-javadoc/src/test/java/io/qameta/allure/description/ProcessDescriptionsTest.java index 868004f6f..49ab0437a 100644 --- a/allure-descriptions-javadoc/src/test/java/io/qameta/allure/description/ProcessDescriptionsTest.java +++ b/allure-descriptions-javadoc/src/test/java/io/qameta/allure/description/ProcessDescriptionsTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -31,13 +31,13 @@ */ class ProcessDescriptionsTest { - private static final String ALLURE_PACKAGE_NAME = "allureDescriptions"; + private static final String ALLURE_DESCRIPTIONS_FOLDER = "META-INF/allureDescriptions/"; @Test void captureDescriptionTest() { final String expectedMethodSignatureHash = "4e7f896021ef2fce7c1deb7f5b9e38fb"; - JavaFileObject source = JavaFileObjects.forSourceLines( + final JavaFileObject source = JavaFileObjects.forSourceLines( "io.qameta.allure.description.test.DescriptionSample", "package io.qameta.allure.description.test;", "import io.qameta.allure.Description;", @@ -53,18 +53,55 @@ void captureDescriptionTest() { "}" ); - Compiler compiler = javac().withProcessors(new JavaDocDescriptionsProcessor()); - Compilation compilation = compiler.compile(source); - assertThat(compilation).generatedFile( - StandardLocation.CLASS_OUTPUT, - ALLURE_PACKAGE_NAME, - expectedMethodSignatureHash + final Compiler compiler = javac().withProcessors(new JavaDocDescriptionsProcessor()) + .withOptions("-Werror"); + final Compilation compilation = compiler.compile(source); + assertThat(compilation) + .generatedFile( + StandardLocation.CLASS_OUTPUT, + "", + ALLURE_DESCRIPTIONS_FOLDER + expectedMethodSignatureHash + ) + .contentsAsUtf8String() + .isEqualTo("Captured javadoc description"); + } + + @Test + void captureDescriptionTestIfNoUseJavadocIsSpecified() { + final String expectedMethodSignatureHash = "4e7f896021ef2fce7c1deb7f5b9e38fb"; + + final JavaFileObject source = JavaFileObjects.forSourceLines( + "io.qameta.allure.description.test.DescriptionSample", + "package io.qameta.allure.description.test;", + "import io.qameta.allure.Description;", + "", + "public class DescriptionSample {", + "", + "/**", + "* Captured javadoc description", + "*/", + "@Description", + "public void sampleTest() {", + "}", + "}" ); + + final Compiler compiler = javac().withProcessors(new JavaDocDescriptionsProcessor()) + .withOptions("-Werror"); + final Compilation compilation = compiler.compile(source); + assertThat(compilation) + .generatedFile( + StandardLocation.CLASS_OUTPUT, + "", + ALLURE_DESCRIPTIONS_FOLDER + expectedMethodSignatureHash + ) + .contentsAsUtf8String() + .contains("Captured javadoc description"); } @Test void skipUncommentedMethodTest() { - JavaFileObject source = JavaFileObjects.forSourceLines( + final JavaFileObject source = JavaFileObjects.forSourceLines( "io.qameta.allure.description.test.DescriptionSample", "package io.qameta.allure.description.test;", "import io.qameta.allure.Description;", @@ -77,19 +114,16 @@ void skipUncommentedMethodTest() { "}" ); - Compiler compiler = javac().withProcessors(new JavaDocDescriptionsProcessor()); - Compilation compilation = compiler.compile(source); + final Compiler compiler = javac().withProcessors(new JavaDocDescriptionsProcessor()); + final Compilation compilation = compiler.compile(source); assertThat(compilation).succeeded(); - assertThat(compilation) - .hadWarningContaining("Unable to create resource for method " - + "sampleTestWithoutJavadocComment[] as it does not have a docs comment"); } @Test void captureDescriptionParametrizedTestWithGenericParameterTest() { final String expectedMethodSignatureHash = "e90e26691bf14511db819d78624ba716"; - JavaFileObject source = JavaFileObjects.forSourceLines( + final JavaFileObject source = JavaFileObjects.forSourceLines( "io.qameta.allure.description.test.DescriptionSample", "package io.qameta.allure.description.test;", "import io.qameta.allure.Description;", @@ -116,12 +150,12 @@ void captureDescriptionParametrizedTestWithGenericParameterTest() { "}" ); - Compiler compiler = javac().withProcessors(new JavaDocDescriptionsProcessor()); - Compilation compilation = compiler.compile(source); + final Compiler compiler = javac().withProcessors(new JavaDocDescriptionsProcessor()); + final Compilation compilation = compiler.compile(source); assertThat(compilation).generatedFile( StandardLocation.CLASS_OUTPUT, - ALLURE_PACKAGE_NAME, - expectedMethodSignatureHash + "", + ALLURE_DESCRIPTIONS_FOLDER + expectedMethodSignatureHash ); } @@ -129,7 +163,7 @@ void captureDescriptionParametrizedTestWithGenericParameterTest() { void captureDescriptionParametrizedTestWithPrimitivesParameterTest() { final String expectedMethodSignatureHash = "edeeeaa02f01218cc206e0c6ff024c7a"; - JavaFileObject source = JavaFileObjects.forSourceLines( + final JavaFileObject source = JavaFileObjects.forSourceLines( "io.qameta.allure.description.test.DescriptionSample", "package io.qameta.allure.description.test;", "import io.qameta.allure.Description;", @@ -149,12 +183,119 @@ void captureDescriptionParametrizedTestWithPrimitivesParameterTest() { "}" ); - Compiler compiler = javac().withProcessors(new JavaDocDescriptionsProcessor()); - Compilation compilation = compiler.compile(source); - assertThat(compilation).generatedFile( - StandardLocation.CLASS_OUTPUT, - ALLURE_PACKAGE_NAME, - expectedMethodSignatureHash + final Compiler compiler = javac().withProcessors(new JavaDocDescriptionsProcessor()); + final Compilation compilation = compiler.compile(source); + assertThat(compilation) + .generatedFile( + StandardLocation.CLASS_OUTPUT, + "", + ALLURE_DESCRIPTIONS_FOLDER + expectedMethodSignatureHash + ) + .contentsAsUtf8String() + .isEqualTo("Captured javadoc description"); + } + + @Test + void shouldIgnoreBlockTagsAndRenderSafeMarkdown() { + final String expectedMethodSignatureHash = "4e7f896021ef2fce7c1deb7f5b9e38fb"; + + final JavaFileObject source = JavaFileObjects.forSourceLines( + "io.qameta.allure.description.test.DescriptionSample", + "package io.qameta.allure.description.test;", + "import io.qameta.allure.Description;", + "", + "public class DescriptionSample {", + "", + "/**", + "* This is my test description with {@code sample} and {@literal }.", + "*", + "*

Use {@link java.lang.String String} for values.

", + "*
    ", + "*
  • first item
  • ", + "*
  • second item
  • ", + "*
", + "* ", + "*", + "* @throws Exception", + "* Thrown when the test unexpectedly fails.", + "*/", + "@Description", + "public void sampleTest() throws Exception {", + "}", + "}" ); + + final Compiler compiler = javac().withProcessors(new JavaDocDescriptionsProcessor()) + .withOptions("-Werror"); + final Compilation compilation = compiler.compile(source); + assertThat(compilation) + .generatedFile( + StandardLocation.CLASS_OUTPUT, + "", + ALLURE_DESCRIPTIONS_FOLDER + expectedMethodSignatureHash + ) + .contentsAsUtf8String() + .isEqualTo( + "This is my test description with `sample` and <safe>.\n\n" + + "Use String for values.\n\n" + + "- first item\n" + + "- second item\n\n" + + "alert(\"xss\")" + ); + } + + @Test + void shouldCaptureComplexModernJavadocDescriptionSafely() { + final String expectedMethodSignatureHash = "4e7f896021ef2fce7c1deb7f5b9e38fb"; + + final JavaFileObject source = JavaFileObjects.forSourceLines( + "io.qameta.allure.description.test.DescriptionSample", + "package io.qameta.allure.description.test;", + "import io.qameta.allure.Description;", + "", + "public class DescriptionSample {", + "", + "/**", + "* Fetches release metadata for the current build.", + "*", + "*

Use {@link java.net.URI URIs} for endpoint configuration.

", + "*
    ", + "*
  • Supports café, Привет, 東京, and λ.
  • ", + "*
  • See the Javadoc specification", + "* and {@linkplain java.lang.String#formatted(Object...) formatted examples}.
  • ", + "*
", + "* Example: client.fetch(\"v2\")", + "* @beta remains prose.", + "*", + "* @author Jane Doe", + "* @version 2.3.0", + "* @since 2.0", + "* @see Javadoc spec", + "*/", + "@Description", + "public void sampleTest() {", + "}", + "}" + ); + + final Compiler compiler = javac().withProcessors(new JavaDocDescriptionsProcessor()) + .withOptions("-Werror"); + final Compilation compilation = compiler.compile(source); + assertThat(compilation) + .generatedFile( + StandardLocation.CLASS_OUTPUT, + "", + ALLURE_DESCRIPTIONS_FOLDER + expectedMethodSignatureHash + ) + .contentsAsUtf8String() + .isEqualTo( + "Fetches release metadata for the current build.\n\n" + + "Use URIs for endpoint configuration.\n\n" + + "- Supports café, Привет, 東京, and λ.\n" + + "- See the Javadoc specification\n" + + "and formatted examples.\n\n" + + "Example: `client.fetch(\"v2\")`\n" + + "@beta remains prose." + ); } } diff --git a/allure-descriptions-javadoc/src/test/resources/allure.properties b/allure-descriptions-javadoc/src/test/resources/allure.properties index 9c0b0a2d7..384d9cd50 100644 --- a/allure-descriptions-javadoc/src/test/resources/allure.properties +++ b/allure-descriptions-javadoc/src/test/resources/allure.properties @@ -1,2 +1,3 @@ allure.results.directory=build/allure-results allure.label.epic=#project.description# +allure.label.module=allure-descriptions-javadoc diff --git a/allure-grpc/build.gradle.kts b/allure-grpc/build.gradle.kts index 909425d92..104a8e43f 100644 --- a/allure-grpc/build.gradle.kts +++ b/allure-grpc/build.gradle.kts @@ -8,22 +8,25 @@ description = "Allure gRPC Integration" val agent: Configuration by configurations.creating -val grpcVersion = "1.51.0" -val protobufVersion = "3.21.11" +val grpcVersion = "1.79.0" +val protobufVersion = "4.33.5" +val jacksonVersion = "2.17.2" dependencies { agent("org.aspectj:aspectjweaver") api(project(":allure-attachments")) - implementation("io.grpc:grpc-core:$grpcVersion") - implementation("com.google.protobuf:protobuf-java-util:$protobufVersion") - - testImplementation("io.grpc:grpc-stub:$grpcVersion") - testImplementation("io.grpc:grpc-protobuf:$grpcVersion") - testImplementation("io.grpc:grpc-netty-shaded:$grpcVersion") + compileOnly("com.fasterxml.jackson.core:jackson-annotations:$jacksonVersion") + compileOnly("com.google.protobuf:protobuf-java-util:$protobufVersion") + compileOnly("io.grpc:grpc-api:$grpcVersion") + testImplementation("com.google.protobuf:protobuf-java-util:$protobufVersion") testImplementation("com.google.protobuf:protobuf-java:$protobufVersion") - testImplementation("org.grpcmock:grpcmock-junit5") + testImplementation("io.grpc:grpc-core:$grpcVersion") + testImplementation("io.grpc:grpc-netty-shaded:$grpcVersion") + testImplementation("io.grpc:grpc-protobuf:$grpcVersion") + testImplementation("io.grpc:grpc-stub:$grpcVersion") testImplementation("javax.annotation:javax.annotation-api") testImplementation("org.assertj:assertj-core") + testImplementation("org.grpcmock:grpcmock-junit5") testImplementation("org.junit.jupiter:junit-jupiter-api") testImplementation("org.slf4j:slf4j-simple") testImplementation(project(":allure-java-commons-test")) diff --git a/allure-grpc/src/main/java/io/qameta/allure/grpc/AllureGrpc.java b/allure-grpc/src/main/java/io/qameta/allure/grpc/AllureGrpc.java index d394a16aa..73c0fa527 100644 --- a/allure-grpc/src/main/java/io/qameta/allure/grpc/AllureGrpc.java +++ b/allure-grpc/src/main/java/io/qameta/allure/grpc/AllureGrpc.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -27,186 +27,453 @@ import io.grpc.Metadata; import io.grpc.MethodDescriptor; import io.qameta.allure.Allure; +import io.qameta.allure.AllureLifecycle; import io.qameta.allure.attachment.AttachmentData; -import io.qameta.allure.attachment.AttachmentProcessor; -import io.qameta.allure.attachment.DefaultAttachmentProcessor; +import io.qameta.allure.attachment.AttachmentRenderer; import io.qameta.allure.attachment.FreemarkerAttachmentRenderer; +import io.qameta.allure.model.Attachment; import io.qameta.allure.model.Status; import io.qameta.allure.model.StepResult; -import io.qameta.allure.util.ResultsUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; +import java.util.Map; import java.util.UUID; -import static java.util.Objects.requireNonNull; - /** * Allure interceptor logger for gRPC. * * @author dtuchs (Dmitry Tuchs). */ -@SuppressWarnings({ - "PMD.AvoidFieldNameMatchingMethodName", - "checkstyle:ClassFanOutComplexity", - "checkstyle:AnonInnerLength", - "checkstyle:JavaNCSS" -}) +@SuppressWarnings( + { + "checkstyle:ClassFanOutComplexity", + "checkstyle:AnonInnerLength", + "checkstyle:JavaNCSS", + "PMD.GodClass" + } +) public class AllureGrpc implements ClientInterceptor { private static final Logger LOGGER = LoggerFactory.getLogger(AllureGrpc.class); - private static final JsonFormat.Printer JSON_PRINTER = JsonFormat.printer(); - - private String requestTemplatePath = "grpc-request.ftl"; - private String responseTemplatePath = "grpc-response.ftl"; - - private boolean markStepFailedOnNonZeroCode = true; - private boolean interceptResponseMetadata; + private static final String UNKNOWN = "unknown"; + private static final String JSON_SUFFIX = " (json)"; + private static final JsonFormat.Printer GRPC_TO_JSON_PRINTER = JsonFormat.printer(); - public AllureGrpc setRequestTemplate(final String templatePath) { - this.requestTemplatePath = templatePath; - return this; - } - - public AllureGrpc setResponseTemplate(final String templatePath) { - this.responseTemplatePath = templatePath; - return this; - } + private final AllureLifecycle lifecycle; + private final boolean markStepFailedOnNonZeroCode; + private final boolean interceptResponseMetadata; + private final String requestTemplatePath; + private final String responseTemplatePath; - public AllureGrpc markStepFailedOnNonZeroCode(final boolean value) { - this.markStepFailedOnNonZeroCode = value; - return this; + public AllureGrpc() { + this( + Allure.getLifecycle(), true, false, + "grpc-request.ftl", "grpc-response.ftl" + ); } - public AllureGrpc interceptResponseMetadata(final boolean value) { - this.interceptResponseMetadata = value; - return this; + public AllureGrpc( + final AllureLifecycle lifecycle, + final boolean markStepFailedOnNonZeroCode, + final boolean interceptResponseMetadata, + final String requestTemplatePath, + final String responseTemplatePath) { + this.lifecycle = lifecycle; + this.markStepFailedOnNonZeroCode = markStepFailedOnNonZeroCode; + this.interceptResponseMetadata = interceptResponseMetadata; + this.requestTemplatePath = requestTemplatePath; + this.responseTemplatePath = responseTemplatePath; } - @SuppressWarnings({"PMD.MethodArgumentCouldBeFinal", "PMD.NPathComplexity"}) @Override - public ClientCall interceptCall(MethodDescriptor method, - CallOptions callOptions, - Channel next) { - final AttachmentProcessor processor = new DefaultAttachmentProcessor(); - - return new ForwardingClientCall.SimpleForwardingClientCall( - next.newCall(method, callOptions.withoutWaitForReady())) { + public ClientCall interceptCall( + final MethodDescriptor methodDescriptor, + final CallOptions callOptions, + final Channel nextChannel) { + final AllureLifecycle current = lifecycle; + final String parent = current.getCurrentTestCaseOrStep().orElse(null); + final String stepUuid = UUID.randomUUID().toString(); + final List clientMessages = new ArrayList<>(); + final List serverMessages = new ArrayList<>(); + final Map initialHeaders = new LinkedHashMap<>(); + final Map trailers = new LinkedHashMap<>(); - private String stepUuid; - private List parsedResponses = new ArrayList<>(); + final String stepName = buildStepName(nextChannel, methodDescriptor); + if (parent != null) { + current.startStep(parent, stepUuid, new StepResult().setName(stepName)); + } else { + current.startStep(stepUuid, new StepResult().setName(stepName)); + } - @SuppressWarnings("PMD.MethodArgumentCouldBeFinal") - @Override - public void sendMessage(T message) { - stepUuid = UUID.randomUUID().toString(); - Allure.getLifecycle().startStep(stepUuid, (new StepResult()).setName( - "Send gRPC request to " - + next.authority() - + trimGrpcMethodName(method.getFullMethodName()) - )); - try { - final GrpcRequestAttachment rpcRequestAttach = GrpcRequestAttachment.Builder - .create("gRPC request", method.getFullMethodName()) - .setBody(JSON_PRINTER.print((MessageOrBuilder) message)) - .build(); - processor.addAttachment(rpcRequestAttach, new FreemarkerAttachmentRenderer(requestTemplatePath)); - super.sendMessage(message); - } catch (InvalidProtocolBufferException e) { - LOGGER.warn("Can`t parse gRPC request", e); - } catch (Throwable e) { - Allure.getLifecycle().updateStep(stepResult -> - stepResult.setStatus(ResultsUtils.getStatus(e).orElse(Status.BROKEN)) - .setStatusDetails(ResultsUtils.getStatusDetails(e).orElse(null)) - ); - Allure.getLifecycle().stopStep(stepUuid); - stepUuid = null; - } - } + final StepContext stepContext = new StepContext<>( + stepUuid, methodDescriptor, current, clientMessages, + serverMessages, initialHeaders, trailers + ); - @SuppressWarnings("PMD.MethodArgumentCouldBeFinal") + return new ForwardingClientCall.SimpleForwardingClientCall( + nextChannel.newCall(methodDescriptor, callOptions) + ) { @Override - public void start(Listener responseListener, Metadata headers) { - final ClientCall.Listener listener = new ForwardingClientCallListener() { + public void start(final Listener responseListener, final Metadata requestHeaders) { + final Listener forwardingListener = new ForwardingClientCallListener() { @Override - protected Listener delegate() { + protected Listener delegate() { return responseListener; } - @SuppressWarnings({"PMD.MethodArgumentCouldBeFinal", "PMD.AvoidLiteralsInIfCondition"}) @Override - public void onClose(io.grpc.Status status, Metadata trailers) { - GrpcResponseAttachment.Builder responseAttachmentBuilder = null; - - if (parsedResponses.size() == 1) { - responseAttachmentBuilder = GrpcResponseAttachment.Builder - .create("gRPC response") - .setBody(parsedResponses.iterator().next()); - } else if (parsedResponses.size() > 1) { - responseAttachmentBuilder = GrpcResponseAttachment.Builder - .create("gRPC response (collection of elements from Server stream)") - .setBody("[" + String.join(",\n", parsedResponses) + "]"); - } - if (!status.isOk()) { - String description = status.getDescription(); - if (description == null) { - description = "No description provided"; - } - responseAttachmentBuilder = GrpcResponseAttachment.Builder - .create(status.getCode().name()) - .setStatus(description); - } - - requireNonNull(responseAttachmentBuilder).setStatus(status.toString()); - if (interceptResponseMetadata) { - for (String key : headers.keys()) { - requireNonNull(responseAttachmentBuilder).setMetadata( - key, - headers.get(Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER)) - ); - } - } - processor.addAttachment( - requireNonNull(responseAttachmentBuilder).build(), - new FreemarkerAttachmentRenderer(responseTemplatePath) - ); - - if (status.isOk() || !markStepFailedOnNonZeroCode) { - Allure.getLifecycle().updateStep(stepUuid, step -> step.setStatus(Status.PASSED)); - } else { - Allure.getLifecycle().updateStep(stepUuid, step -> step.setStatus(Status.FAILED)); - } - Allure.getLifecycle().stopStep(stepUuid); - stepUuid = null; - super.onClose(status, trailers); + public void onHeaders(final Metadata headers) { + handleHeaders(headers, stepContext.getInitialHeaders()); + super.onHeaders(headers); + } + + @Override + public void onMessage(final R message) { + handleServerMessage(message, stepContext.getServerMessages()); + super.onMessage(message); } - @SuppressWarnings("PMD.MethodArgumentCouldBeFinal") @Override - public void onMessage(A message) { - try { - parsedResponses.add(JSON_PRINTER.print((MessageOrBuilder) message)); - super.onMessage(message); - } catch (InvalidProtocolBufferException e) { - LOGGER.warn("Can`t parse gRPC response", e); - } catch (Throwable e) { - Allure.getLifecycle().updateStep(step -> - step.setStatus(ResultsUtils.getStatus(e).orElse(Status.BROKEN)) - .setStatusDetails(ResultsUtils.getStatusDetails(e).orElse(null)) - ); - Allure.getLifecycle().stopStep(stepUuid); - stepUuid = null; - } + public void onClose(final io.grpc.Status status, final Metadata responseTrailers) { + handleClose(status, responseTrailers, stepContext); + super.onClose(status, responseTrailers); } }; - super.start(listener, headers); + super.start(forwardingListener, requestHeaders); } - private String trimGrpcMethodName(final String source) { - return source.substring(source.lastIndexOf('/')); + @Override + public void sendMessage(final T message) { + handleClientMessage(message, stepContext.getClientMessages()); + super.sendMessage(message); } }; } + + private void addRawJsonAttachment( + final String stepUuid, + final String attachmentName, + final String jsonBody, + final AllureLifecycle lifecycle) { + if (jsonBody == null || jsonBody.isEmpty()) { + return; + } + final String source = UUID.randomUUID() + ".json"; + lifecycle.updateStep( + stepUuid, step -> step.getAttachments().add( + new Attachment() + .setName(attachmentName) + .setSource(source) + .setType("application/json") + ) + ); + lifecycle.writeAttachment( + source, + new ByteArrayInputStream(jsonBody.getBytes(StandardCharsets.UTF_8)) + ); + } + + private void handleClose( + final io.grpc.Status status, + final Metadata responseTrailers, + final StepContext stepContext) { + try { + if (interceptResponseMetadata && responseTrailers != null) { + copyAsciiResponseMetadata(responseTrailers, stepContext.getTrailers()); + } + attachRequestIfPresent( + stepContext.getStepUuid(), + stepContext.getMethodDescriptor(), + stepContext.getClientMessages(), + stepContext.getLifecycle() + ); + attachResponse( + stepContext.getStepUuid(), + stepContext.getServerMessages(), + status, + stepContext.getInitialHeaders(), + stepContext.getTrailers(), + stepContext.getLifecycle() + ); + stepContext.getLifecycle().updateStep( + stepContext.getStepUuid(), + step -> step.setStatus(convertStatus(status)) + ); + } catch (Throwable throwable) { + LOGGER.error("Failed to finalize Allure step for gRPC call", throwable); + stepContext.getLifecycle().updateStep( + stepContext.getStepUuid(), + step -> step.setStatus(Status.BROKEN) + ); + } finally { + stopStepSafely(stepContext.getLifecycle(), stepContext.getStepUuid()); + } + } + + private void handleHeaders(final Metadata headers, final Map destination) { + try { + if (interceptResponseMetadata && headers != null) { + copyAsciiResponseMetadata(headers, destination); + } + } catch (Throwable throwable) { + LOGGER.warn("Failed to capture response headers", throwable); + } + } + + private void handleClientMessage(final T message, final List destination) { + try { + destination.add(GRPC_TO_JSON_PRINTER.print((MessageOrBuilder) message)); + } catch (InvalidProtocolBufferException e) { + LOGGER.error("Could not serialize gRPC request message to JSON", e); + } catch (Throwable throwable) { + LOGGER.error("Unexpected error while serializing gRPC request message", throwable); + } + } + + private void handleServerMessage(final R message, final List destination) { + try { + destination.add(GRPC_TO_JSON_PRINTER.print((MessageOrBuilder) message)); + } catch (InvalidProtocolBufferException e) { + LOGGER.error("Could not serialize gRPC response message to JSON", e); + } catch (Throwable throwable) { + LOGGER.error("Unexpected error while serializing gRPC response message", throwable); + } + } + + private void attachRequestIfPresent( + final String stepUuid, + final MethodDescriptor methodDescriptor, + final List clientMessages, + final AllureLifecycle lifecycle) { + final String body = toJsonBody(clientMessages); + if (body == null) { + return; + } + final String name = clientMessages.size() > 1 + ? "gRPC request (collection of elements from Client stream)" + : "gRPC request"; + final GrpcRequestAttachment requestAttachment = GrpcRequestAttachment.Builder + .create(name, methodDescriptor.getFullMethodName()) + .setBody(body) + .build(); + + addRenderedAttachmentToStep( + stepUuid, + requestAttachment.getName(), + requestAttachment, + requestTemplatePath, + lifecycle + ); + addRawJsonAttachment(stepUuid, name + JSON_SUFFIX, body, lifecycle); + } + + private void attachResponse( + final String stepUuid, + final List serverMessages, + final io.grpc.Status status, + final Map initialHeaders, + final Map trailers, + final AllureLifecycle lifecycle) { + final String body = toJsonBody(serverMessages); + final String name = serverMessages.size() > 1 + ? "gRPC response (collection of elements from Server stream)" + : "gRPC response"; + + final Map metadata = new LinkedHashMap<>(); + if (interceptResponseMetadata) { + metadata.putAll(initialHeaders); + metadata.putAll(trailers); + } + + final GrpcResponseAttachment.Builder builder = GrpcResponseAttachment.Builder + .create(name) + .setStatus(status.toString()); + + if (body != null) { + builder.setBody(body); + } + if (!metadata.isEmpty()) { + builder.addMetadata(metadata); + } + + final GrpcResponseAttachment responseAttachment = builder.build(); + addRenderedAttachmentToStep( + stepUuid, + responseAttachment.getName(), + responseAttachment, + responseTemplatePath, + lifecycle + ); + if (body != null) { + addRawJsonAttachment(stepUuid, name + JSON_SUFFIX, body, lifecycle); + } + } + + private void stopStepSafely(final AllureLifecycle lifecycle, final String stepUuid) { + try { + lifecycle.stopStep(stepUuid); + } catch (Throwable throwable) { + LOGGER.warn("Failed to stop Allure step {}", stepUuid, throwable); + } + } + + private Status convertStatus(final io.grpc.Status grpcStatus) { + if (grpcStatus.isOk() || !markStepFailedOnNonZeroCode) { + return Status.PASSED; + } + return Status.FAILED; + } + + private static String buildStepName( + final Channel channel, + final MethodDescriptor methodDescriptor) { + final String authority = channel != null ? channel.authority() : null; + final String safeAuthority = authority != null ? authority : UNKNOWN; + final String type = toSnakeCase(methodDescriptor.getType()); + return "Send " + type + " gRPC request to " + + safeAuthority + "/" + methodDescriptor.getFullMethodName(); + } + + private static String toSnakeCase(final MethodDescriptor.MethodType methodType) { + if (methodType == null) { + return UNKNOWN; + } + return methodType.name().toLowerCase(Locale.ROOT); + } + + private void addRenderedAttachmentToStep( + final String stepUuid, + final String attachmentName, + final AttachmentData data, + final String templatePath, + final AllureLifecycle lifecycle) { + final AttachmentRenderer renderer = new FreemarkerAttachmentRenderer(templatePath); + final io.qameta.allure.attachment.AttachmentContent content; + try { + content = renderer.render(data); + } catch (Throwable throwable) { + LOGGER.warn( + "Could not render attachment '{}' using template '{}'", + attachmentName, templatePath, throwable + ); + return; + } + if (content == null || content.getContent() == null) { + LOGGER.warn("Rendered attachment '{}' is empty; skipping", attachmentName); + return; + } + String fileExtension = content.getFileExtension(); + if (fileExtension == null || fileExtension.isEmpty()) { + fileExtension = ".html"; + } + final String source = UUID.randomUUID() + fileExtension; + lifecycle.updateStep( + stepUuid, + step -> step.getAttachments().add( + new Attachment() + .setName(attachmentName) + .setSource(source) + .setType( + content.getContentType() != null + ? content.getContentType() + : "text/html" + ) + ) + ); + lifecycle.writeAttachment( + source, + new ByteArrayInputStream(content.getContent().getBytes(StandardCharsets.UTF_8)) + ); + } + + private static String toJsonBody(final List items) { + if (items == null || items.isEmpty()) { + return null; + } + if (items.size() == 1) { + return items.get(0); + } + final String joined = String.join(",\n", items); + return "[" + joined + "]"; + } + + private static void copyAsciiResponseMetadata( + final Metadata source, + final Map target) { + for (String key : source.keys()) { + if (key == null) { + continue; + } + if (key.endsWith(Metadata.BINARY_HEADER_SUFFIX)) { + continue; + } + final Metadata.Key keyAscii = Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER); + final String value = source.get(keyAscii); + if (value != null) { + target.put(key, value); + } + } + } + + private static final class StepContext { + private final String stepUuid; + private final MethodDescriptor methodDescriptor; + private final AllureLifecycle lifecycle; + private final List clientMessages; + private final List serverMessages; + private final Map initialHeaders; + private final Map trailers; + + StepContext( + final String stepUuid, + final MethodDescriptor methodDescriptor, + final AllureLifecycle lifecycle, + final List clientMessages, + final List serverMessages, + final Map initialHeaders, + final Map trailers) { + this.stepUuid = stepUuid; + this.methodDescriptor = methodDescriptor; + this.lifecycle = lifecycle; + this.clientMessages = clientMessages; + this.serverMessages = serverMessages; + this.initialHeaders = initialHeaders; + this.trailers = trailers; + } + + String getStepUuid() { + return stepUuid; + } + + MethodDescriptor getMethodDescriptor() { + return methodDescriptor; + } + + AllureLifecycle getLifecycle() { + return lifecycle; + } + + List getClientMessages() { + return clientMessages; + } + + List getServerMessages() { + return serverMessages; + } + + Map getInitialHeaders() { + return initialHeaders; + } + + Map getTrailers() { + return trailers; + } + } } diff --git a/allure-grpc/src/main/java/io/qameta/allure/grpc/GrpcRequestAttachment.java b/allure-grpc/src/main/java/io/qameta/allure/grpc/GrpcRequestAttachment.java index ef32e4dde..1f2276728 100644 --- a/allure-grpc/src/main/java/io/qameta/allure/grpc/GrpcRequestAttachment.java +++ b/allure-grpc/src/main/java/io/qameta/allure/grpc/GrpcRequestAttachment.java @@ -1,5 +1,5 @@ /* - * Copyright 2021 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -47,7 +47,6 @@ public String getName() { /** * Builder for GrpcRequestAttachment. */ - @SuppressWarnings("PMD.AvoidFieldNameMatchingMethodName") static final class Builder { private final String name; diff --git a/allure-grpc/src/main/java/io/qameta/allure/grpc/GrpcResponseAttachment.java b/allure-grpc/src/main/java/io/qameta/allure/grpc/GrpcResponseAttachment.java index 7824d9ee2..414aa7257 100644 --- a/allure-grpc/src/main/java/io/qameta/allure/grpc/GrpcResponseAttachment.java +++ b/allure-grpc/src/main/java/io/qameta/allure/grpc/GrpcResponseAttachment.java @@ -1,5 +1,5 @@ /* - * Copyright 2021 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -58,7 +58,6 @@ public String getName() { /** * Builder for GrpcResponseAttachment. */ - @SuppressWarnings("PMD.AvoidFieldNameMatchingMethodName") static final class Builder { private final String name; diff --git a/allure-grpc/src/test/java/io/qameta/allure/grpc/AllureGrpcTest.java b/allure-grpc/src/test/java/io/qameta/allure/grpc/AllureGrpcTest.java index f75df916b..ec5d55ca1 100644 --- a/allure-grpc/src/test/java/io/qameta/allure/grpc/AllureGrpcTest.java +++ b/allure-grpc/src/test/java/io/qameta/allure/grpc/AllureGrpcTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,12 +15,17 @@ */ package io.qameta.allure.grpc; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import io.grpc.ManagedChannel; import io.grpc.ManagedChannelBuilder; import io.grpc.Status; import io.grpc.StatusRuntimeException; +import io.grpc.stub.StreamObserver; +import io.qameta.allure.Allure; import io.qameta.allure.model.Attachment; import io.qameta.allure.model.StepResult; +import io.qameta.allure.model.TestResult; import io.qameta.allure.test.AllureResults; import org.grpcmock.GrpcMock; import org.grpcmock.junit5.GrpcMockExtension; @@ -29,72 +34,110 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.Iterator; +import java.util.List; +import java.util.Map; import java.util.Optional; import static io.qameta.allure.test.RunUtils.runWithinTestContext; import static java.util.Arrays.asList; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.grpcmock.GrpcMock.bidiStreamingMethod; +import static org.grpcmock.GrpcMock.clientStreamingMethod; import static org.grpcmock.GrpcMock.serverStreamingMethod; import static org.grpcmock.GrpcMock.unaryMethod; -/** - * @author dtuchs (Dmitry Tuchs). - */ @ExtendWith(GrpcMockExtension.class) class AllureGrpcTest { private static final String RESPONSE_MESSAGE = "Hello world!"; + private static final ObjectMapper JSON = new ObjectMapper(); - private ManagedChannel channel; - private TestServiceGrpc.TestServiceBlockingStub blockingStub; + private ManagedChannel managedChannel; @BeforeEach - void configureMock() { - channel = ManagedChannelBuilder.forAddress("localhost", GrpcMock.getGlobalPort()) + void configureMockServer() { + managedChannel = ManagedChannelBuilder + .forAddress("localhost", GrpcMock.getGlobalPort()) .usePlaintext() + .directExecutor() .build(); - blockingStub = TestServiceGrpc.newBlockingStub(channel) - .withInterceptors(new AllureGrpc()); - GrpcMock.stubFor(unaryMethod(TestServiceGrpc.getCalculateMethod()) - .willReturn(Response.newBuilder().setMessage(RESPONSE_MESSAGE).build())); - GrpcMock.stubFor(serverStreamingMethod(TestServiceGrpc.getCalculateServerStreamMethod()) - .willReturn(asList( - Response.newBuilder().setMessage(RESPONSE_MESSAGE).build(), - Response.newBuilder().setMessage(RESPONSE_MESSAGE).build() - ))); + GrpcMock.stubFor( + unaryMethod(TestServiceGrpc.getCalculateMethod()) + .willReturn(Response.newBuilder().setMessage(RESPONSE_MESSAGE).build()) + ); + + GrpcMock.stubFor( + serverStreamingMethod(TestServiceGrpc.getCalculateServerStreamMethod()) + .willReturn( + asList( + Response.newBuilder().setMessage(RESPONSE_MESSAGE).build(), + Response.newBuilder().setMessage(RESPONSE_MESSAGE).build() + ) + ) + ); + + GrpcMock.stubFor( + clientStreamingMethod(TestServiceGrpc.getCalculateClientStreamMethod()) + .willReturn(Response.newBuilder().setMessage(RESPONSE_MESSAGE).build()) + ); + + GrpcMock.stubFor( + bidiStreamingMethod(TestServiceGrpc.getCalculateBidiStreamMethod()) + .willProxyTo(responseObserver -> new StreamObserver() { + @Override + public void onNext(Request request) { + responseObserver.onNext(Response.newBuilder().setMessage(RESPONSE_MESSAGE).build()); + } + @Override + public void onError(Throwable throwable) { + } + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }) + ); } @AfterEach void shutdownChannel() { - Optional.ofNullable(channel).ifPresent(ManagedChannel::shutdownNow); + Optional.ofNullable(managedChannel).ifPresent(ManagedChannel::shutdown); } @Test void shouldCreateRequestAttachment() { - final Request request = Request.newBuilder() + Request request = Request.newBuilder() .setTopic("1") .build(); - final AllureResults results = execute(request); + Status errorStatus = Status.NOT_FOUND; + GrpcMock.stubFor(unaryMethod(TestServiceGrpc.getCalculateMethod()).willReturn(errorStatus)); - assertThat(results.getTestResults().get(0).getSteps()) + AllureResults allureResults = executeUnaryExpectingException(request); + + assertThat(allureResults.getTestResults().get(0).getSteps().get(0).getStatus()) + .isEqualTo(io.qameta.allure.model.Status.FAILED); + + assertThat(allureResults.getTestResults().get(0).getSteps()) .flatExtracting(StepResult::getAttachments) .extracting(Attachment::getName) - .contains("gRPC request"); + .contains("gRPC request", "gRPC response"); } @Test void shouldCreateResponseAttachment() { - final Request request = Request.newBuilder() + Request request = Request.newBuilder() .setTopic("1") .build(); - final AllureResults results = execute(request); + AllureResults allureResults = executeUnary(request); - assertThat(results.getTestResults().get(0).getSteps()) + assertThat(allureResults.getTestResults().get(0).getSteps()) .flatExtracting(StepResult::getAttachments) .extracting(Attachment::getName) .contains("gRPC response"); @@ -102,13 +145,13 @@ void shouldCreateResponseAttachment() { @Test void shouldCreateResponseAttachmentForServerStreamingResponse() { - final Request request = Request.newBuilder() + Request request = Request.newBuilder() .setTopic("1") .build(); - final AllureResults results = executeStreaming(request); + AllureResults allureResults = executeServerStreaming(request); - assertThat(results.getTestResults().get(0).getSteps()) + assertThat(allureResults.getTestResults().get(0).getSteps()) .flatExtracting(StepResult::getAttachments) .extracting(Attachment::getName) .contains("gRPC response (collection of elements from Server stream)"); @@ -116,48 +159,240 @@ void shouldCreateResponseAttachmentForServerStreamingResponse() { @Test void shouldCreateResponseAttachmentOnStatusException() { - final Status status = Status.NOT_FOUND; - GrpcMock.stubFor(unaryMethod(TestServiceGrpc.getCalculateMethod()) - .willReturn(status)); + Status notFoundStatus = Status.NOT_FOUND; + GrpcMock.stubFor(unaryMethod(TestServiceGrpc.getCalculateMethod()).willReturn(notFoundStatus)); - final Request request = Request.newBuilder() + Request request = Request.newBuilder() .setTopic("2") .build(); - final AllureResults results = executeException(request); + AllureResults allureResults = executeUnaryExpectingException(request); - assertThat(results.getTestResults().get(0).getSteps()) + assertThat(allureResults.getTestResults().get(0).getSteps().get(0).getStatus()) + .isEqualTo(io.qameta.allure.model.Status.FAILED); + + assertThat(allureResults.getTestResults().get(0).getSteps()) .flatExtracting(StepResult::getAttachments) .extracting(Attachment::getName) - .contains(status.getCode().name()); + .contains("gRPC response"); + } + + @Test + void shouldCreateAttachmentsForClientStreamingWithAsynchronousStub() { + Request firstClientRequest = Request.newBuilder().setTopic("A").build(); + Request secondClientRequest = Request.newBuilder().setTopic("B").build(); + + runWithinTestContext(() -> { + TestServiceGrpc.TestServiceStub asynchronousStub = TestServiceGrpc.newStub(managedChannel).withInterceptors(new AllureGrpc()); + + final List receivedResponses = new ArrayList(); + + Allure.step("async-root-client-stream", () -> { + StreamObserver responseObserver = new StreamObserver() { + @Override + public void onNext(Response value) { + receivedResponses.add(value); + } + @Override + public void onError(Throwable throwable) { + } + @Override + public void onCompleted() { + } + }; + + StreamObserver requestObserver = asynchronousStub.calculateClientStream(responseObserver); + requestObserver.onNext(firstClientRequest); + requestObserver.onNext(secondClientRequest); + requestObserver.onCompleted(); + }); + + assertThat(receivedResponses).hasSize(1); + assertThat(receivedResponses.get(0).getMessage()).isEqualTo(RESPONSE_MESSAGE); + }); + } + + @Test + void shouldCreateAttachmentsForBidirectionalStreamingWithAsynchronousStub() { + Request firstBidirectionalRequest = Request.newBuilder().setTopic("C").build(); + Request secondBidirectionalRequest = Request.newBuilder().setTopic("D").build(); + + runWithinTestContext(() -> { + TestServiceGrpc.TestServiceStub asynchronousStub = TestServiceGrpc.newStub(managedChannel).withInterceptors(new AllureGrpc()); + + List receivedResponses = new ArrayList<>(); + + Allure.step("async-root-bidi-stream", () -> { + StreamObserver responseObserver = new StreamObserver() { + @Override + public void onNext(Response value) { + receivedResponses.add(value); + } + @Override + public void onError(Throwable throwable) { + } + @Override + public void onCompleted() { + } + }; + + StreamObserver requestObserver = asynchronousStub.calculateBidiStream(responseObserver); + requestObserver.onNext(firstBidirectionalRequest); + requestObserver.onNext(secondBidirectionalRequest); + requestObserver.onCompleted(); + }); + + assertThat(receivedResponses).hasSize(2); + assertThat(receivedResponses.get(0).getMessage()).isEqualTo(RESPONSE_MESSAGE); + assertThat(receivedResponses.get(1).getMessage()).isEqualTo(RESPONSE_MESSAGE); + }); + } + + @Test + void unaryRequestBodyIsCapturedAsJsonObject() throws Exception { + GrpcMock.stubFor( + unaryMethod(TestServiceGrpc.getCalculateMethod()) + .willReturn(Response.newBuilder().setMessage("ok").build()) + ); + + Request request = Request.newBuilder().setTopic("topic-1").build(); + + AllureResults allureResults = runWithinTestContext(() -> { + TestServiceGrpc.TestServiceBlockingStub stub = TestServiceGrpc.newBlockingStub(managedChannel).withInterceptors(new AllureGrpc()); + Response response = stub.calculate(request); + assertThat(response.getMessage()).isEqualTo("ok"); + }); + + String jsonPayload = readJsonAttachmentByName(allureResults, "gRPC request (json)"); + JsonNode actualJsonNode = JSON.readTree(jsonPayload); + JsonNode expectedJsonNode = JSON.createObjectNode().put("topic", "topic-1"); + + assertThat(actualJsonNode).isEqualTo(expectedJsonNode); } - protected final AllureResults execute(final Request request) { + @Test + void unaryResponseBodyIsCapturedAsJsonObject() throws Exception { + GrpcMock.stubFor( + unaryMethod(TestServiceGrpc.getCalculateMethod()) + .willReturn(Response.newBuilder().setMessage("hello-world").build()) + ); + + Request request = Request.newBuilder().setTopic("x").build(); + + AllureResults allureResults = runWithinTestContext(() -> { + TestServiceGrpc.TestServiceBlockingStub stub = TestServiceGrpc.newBlockingStub(managedChannel).withInterceptors(new AllureGrpc()); + Response response = stub.calculate(request); + assertThat(response.getMessage()).isEqualTo("hello-world"); + }); + + String jsonPayload = readJsonAttachmentByName(allureResults, "gRPC response (json)"); + JsonNode actualJsonNode = JSON.readTree(jsonPayload); + JsonNode expectedJsonNode = JSON.createObjectNode().put("message", "hello-world"); + + assertThat(actualJsonNode).isEqualTo(expectedJsonNode); + } + + @Test + void serverStreamingResponseBodyIsJsonArrayInOrder() throws Exception { + GrpcMock.stubFor( + serverStreamingMethod(TestServiceGrpc.getCalculateServerStreamMethod()) + .willReturn( + asList( + Response.newBuilder().setMessage("first").build(), + Response.newBuilder().setMessage("second").build() + ) + ) + ); + + Request request = Request.newBuilder().setTopic("stream-topic").build(); + + AllureResults allureResults = runWithinTestContext(() -> { + TestServiceGrpc.TestServiceBlockingStub stub = TestServiceGrpc.newBlockingStub(managedChannel).withInterceptors(new AllureGrpc()); + Iterator responseIterator = stub.calculateServerStream(request); + assertThat(responseIterator.hasNext()).isTrue(); + assertThat(responseIterator.next().getMessage()).isEqualTo("first"); + assertThat(responseIterator.hasNext()).isTrue(); + assertThat(responseIterator.next().getMessage()).isEqualTo("second"); + assertThat(responseIterator.hasNext()).isFalse(); + }); + + String jsonPayload = readJsonAttachmentByName( + allureResults, "gRPC response (collection of elements from Server stream) (json)" + ); + JsonNode actualJsonArray = JSON.readTree(jsonPayload); + + assertThat(actualJsonArray.isArray()).isTrue(); + assertThat(actualJsonArray.size()).isEqualTo(2); + assertThat(actualJsonArray.get(0)).isEqualTo(JSON.createObjectNode().put("message", "first")); + assertThat(actualJsonArray.get(1)).isEqualTo(JSON.createObjectNode().put("message", "second")); + } + private static String readJsonAttachmentByName(AllureResults allureResults, String jsonAttachmentName) { + TestResult test = allureResults.getTestResults().get(0); + + Attachment matchedAttachment = flattenSteps(test.getSteps()).stream() + .flatMap(step -> step.getAttachments().stream()) + .filter(attachment -> jsonAttachmentName.equals(attachment.getName())) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Attachment not found: " + jsonAttachmentName)); + + String attachmentSourceKey = matchedAttachment.getSource(); + Map attachmentsContent = allureResults.getAttachments(); + byte[] rawAttachmentContent = attachmentsContent.get(attachmentSourceKey); + if (rawAttachmentContent == null) { + throw new IllegalStateException("Attachment content not found by source: " + attachmentSourceKey); + } + return new String(rawAttachmentContent, StandardCharsets.UTF_8); + } + + protected final AllureResults executeUnary(Request request) { return runWithinTestContext(() -> { try { - final Response response = blockingStub.calculate(request); + TestServiceGrpc.TestServiceBlockingStub stub = TestServiceGrpc.newBlockingStub(managedChannel).withInterceptors(new AllureGrpc()); + Response response = stub.calculate(request); assertThat(response.getMessage()).isEqualTo(RESPONSE_MESSAGE); - } catch (Exception e) { - throw new RuntimeException("Could not execute request " + request, e); + } catch (Exception exception) { + throw new RuntimeException("Could not execute request " + request, exception); } }); } - protected final AllureResults executeStreaming(final Request request) { + + protected final AllureResults executeServerStreaming(Request request) { return runWithinTestContext(() -> { try { - Iterator responseIterator = blockingStub.calculateServerStream(request); + TestServiceGrpc.TestServiceBlockingStub stub = TestServiceGrpc.newBlockingStub(managedChannel).withInterceptors(new AllureGrpc()); + Iterator responseIterator = stub.calculateServerStream(request); + int responseCount = 0; while (responseIterator.hasNext()) { assertThat(responseIterator.next().getMessage()).isEqualTo(RESPONSE_MESSAGE); + responseCount++; } - } catch (Exception e) { - throw new RuntimeException("Could not execute request " + request, e); + assertThat(responseCount).isEqualTo(2); + } catch (Exception exception) { + throw new RuntimeException("Could not execute request " + request, exception); } }); } - protected final AllureResults executeException(final Request request) { - return runWithinTestContext(() -> { - assertThatExceptionOfType(StatusRuntimeException.class).isThrownBy(() -> blockingStub.calculate(request)); - }); + protected final AllureResults executeUnaryExpectingException(Request request) { + return runWithinTestContext( + () -> assertThatExceptionOfType(StatusRuntimeException.class) + .isThrownBy(() -> { + TestServiceGrpc.TestServiceBlockingStub stub = TestServiceGrpc.newBlockingStub(managedChannel).withInterceptors(new AllureGrpc()); + Response response = stub.calculate(request); + assertThat(response.getMessage()).isEqualTo("ok"); + }) + ); + } + + private static List flattenSteps(List rootSteps) { + List allSteps = new ArrayList<>(); + if (rootSteps == null) { + return allSteps; + } + for (StepResult step : rootSteps) { + allSteps.add(step); + allSteps.addAll(flattenSteps(step.getSteps())); + } + return allSteps; } } diff --git a/allure-grpc/src/test/proto/api.proto b/allure-grpc/src/test/proto/api.proto index 552e76f6c..378bb0872 100644 --- a/allure-grpc/src/test/proto/api.proto +++ b/allure-grpc/src/test/proto/api.proto @@ -6,6 +6,8 @@ option java_package = "io.qameta.allure.grpc"; service TestService { rpc Calculate (Request) returns (Response); rpc CalculateServerStream (Request) returns (stream Response); + rpc CalculateClientStream (stream Request) returns (Response); + rpc CalculateBidiStream (stream Request) returns (stream Response); } message Request { diff --git a/allure-grpc/src/test/resources/allure.properties b/allure-grpc/src/test/resources/allure.properties index 9c0b0a2d7..556029437 100644 --- a/allure-grpc/src/test/resources/allure.properties +++ b/allure-grpc/src/test/resources/allure.properties @@ -1,2 +1,3 @@ allure.results.directory=build/allure-results allure.label.epic=#project.description# +allure.label.module=allure-grpc diff --git a/allure-hamcrest/build.gradle.kts b/allure-hamcrest/build.gradle.kts index 8ccb0ee47..3817ab48c 100644 --- a/allure-hamcrest/build.gradle.kts +++ b/allure-hamcrest/build.gradle.kts @@ -3,14 +3,16 @@ description = "Allure Hamcrest Assertions Integration" dependencies { api(project(":allure-java-commons")) compileOnly("org.aspectj:aspectjrt") - implementation("org.hamcrest:hamcrest") + compileOnly("org.hamcrest:hamcrest") testAnnotationProcessor(project(":allure-descriptions-javadoc")) + testImplementation("org.assertj:assertj-core") + testImplementation("org.hamcrest:hamcrest") testImplementation("org.junit.jupiter:junit-jupiter-api") testImplementation("org.junit.jupiter:junit-jupiter-params") - testImplementation("org.assertj:assertj-core") testImplementation("org.slf4j:slf4j-simple") testImplementation(project(":allure-java-commons-test")) testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") } tasks.jar { diff --git a/allure-hamcrest/src/main/java/io/qameta/allure/hamcrest/AllureHamcrestAssert.java b/allure-hamcrest/src/main/java/io/qameta/allure/hamcrest/AllureHamcrestAssert.java index cf836d8f1..4a1782edc 100644 --- a/allure-hamcrest/src/main/java/io/qameta/allure/hamcrest/AllureHamcrestAssert.java +++ b/allure-hamcrest/src/main/java/io/qameta/allure/hamcrest/AllureHamcrestAssert.java @@ -1,5 +1,5 @@ /* - * Copyright 2021 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,17 +15,17 @@ */ package io.qameta.allure.hamcrest; -import org.aspectj.lang.JoinPoint; -import org.aspectj.lang.annotation.Aspect; -import org.aspectj.lang.annotation.Pointcut; -import org.aspectj.lang.annotation.Before; -import org.aspectj.lang.annotation.AfterThrowing; -import org.aspectj.lang.annotation.AfterReturning; import io.qameta.allure.Allure; import io.qameta.allure.AllureLifecycle; import io.qameta.allure.model.Status; import io.qameta.allure.model.StepResult; import io.qameta.allure.util.ObjectUtils; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.AfterReturning; +import org.aspectj.lang.annotation.AfterThrowing; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.aspectj.lang.annotation.Pointcut; import org.hamcrest.Matcher; import org.hamcrest.StringDescription; @@ -106,7 +106,10 @@ public void catchAndStartStep(final JoinPoint joinPoint) { } } - @AfterThrowing(pointcut = "initAssertThat()", throwing = "e") + @AfterThrowing( + pointcut = "initAssertThat()", + throwing = "e" + ) public void stepFailed(final Throwable e) { getLifecycle().updateStep(s -> s.setStatus(getStatus(e).orElse(Status.BROKEN))); getLifecycle().stopStep(); diff --git a/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestAssertionNameContainsReasonTest.java b/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestAssertionNameContainsReasonTest.java index 6086258f9..bc945a66e 100644 --- a/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestAssertionNameContainsReasonTest.java +++ b/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestAssertionNameContainsReasonTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2021 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestCollectionsMatchersTest.java b/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestCollectionsMatchersTest.java index 805b593c4..acdd8900c 100644 --- a/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestCollectionsMatchersTest.java +++ b/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestCollectionsMatchersTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2021 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -54,7 +54,7 @@ public class AllureHamcrestCollectionsMatchersTest { @Test void hamcrestAssertNameForArrayMatchers() { final TestResult testResult = runWithinTestContext( - () -> assertThat(new Integer[]{1,2,3}, is(array(equalTo(1), equalTo(2), equalTo(3)))), + () -> assertThat(new Integer[]{1, 2, 3}, is(array(equalTo(1), equalTo(2), equalTo(3)))), AllureHamcrestAssert::setLifecycle ).getTestResults().get(0); diff --git a/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestLogicalMatchersTest.java b/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestLogicalMatchersTest.java index f2c810425..6e986f47f 100644 --- a/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestLogicalMatchersTest.java +++ b/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestLogicalMatchersTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2021 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestNumberMatchersTest.java b/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestNumberMatchersTest.java index c6d43e44c..86d72532f 100644 --- a/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestNumberMatchersTest.java +++ b/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestNumberMatchersTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2021 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestObjectMatchersTest.java b/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestObjectMatchersTest.java index 3b0170d24..beeb7fd4f 100644 --- a/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestObjectMatchersTest.java +++ b/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestObjectMatchersTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2021 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestTextMatchersTest.java b/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestTextMatchersTest.java index ec401a53b..515f6ecbb 100644 --- a/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestTextMatchersTest.java +++ b/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestTextMatchersTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2021 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,7 +20,6 @@ import org.assertj.core.api.Assertions; import org.hamcrest.Matcher; import org.junit.jupiter.api.TestInstance; - import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; diff --git a/allure-junit5-assert/src/test/resources/allure.properties b/allure-hamcrest/src/test/resources/allure.properties similarity index 70% rename from allure-junit5-assert/src/test/resources/allure.properties rename to allure-hamcrest/src/test/resources/allure.properties index 9c0b0a2d7..ee8c853e2 100644 --- a/allure-junit5-assert/src/test/resources/allure.properties +++ b/allure-hamcrest/src/test/resources/allure.properties @@ -1,2 +1,3 @@ allure.results.directory=build/allure-results allure.label.epic=#project.description# +allure.label.module=allure-hamcrest diff --git a/allure-httpclient/build.gradle.kts b/allure-httpclient/build.gradle.kts index 087e00bfb..9f3b4c554 100644 --- a/allure-httpclient/build.gradle.kts +++ b/allure-httpclient/build.gradle.kts @@ -1,10 +1,13 @@ description = "Allure Apache HttpClient Integration" +val httpClient4Version = "4.5.14"; + dependencies { api(project(":allure-attachments")) - implementation("org.apache.httpcomponents:httpclient") + compileOnly("org.apache.httpcomponents:httpclient:$httpClient4Version") testImplementation("com.github.tomakehurst:wiremock") testImplementation("io.github.glytching:junit-extensions") + testImplementation("org.apache.httpcomponents:httpclient:$httpClient4Version") testImplementation("org.assertj:assertj-core") testImplementation("org.junit.jupiter:junit-jupiter-api") testImplementation("org.mockito:mockito-core") diff --git a/allure-httpclient/src/main/java/io/qameta/allure/httpclient/AllureHttpClientRequest.java b/allure-httpclient/src/main/java/io/qameta/allure/httpclient/AllureHttpClientRequest.java index 5dc0fdd9e..833f45bca 100644 --- a/allure-httpclient/src/main/java/io/qameta/allure/httpclient/AllureHttpClientRequest.java +++ b/allure-httpclient/src/main/java/io/qameta/allure/httpclient/AllureHttpClientRequest.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -43,8 +43,9 @@ public class AllureHttpClientRequest implements HttpRequestInterceptor { private final AttachmentProcessor processor; public AllureHttpClientRequest() { - this(new FreemarkerAttachmentRenderer("http-request.ftl"), - new DefaultAttachmentProcessor() + this( + new FreemarkerAttachmentRenderer("http-request.ftl"), + new DefaultAttachmentProcessor() ); } @@ -55,20 +56,25 @@ public AllureHttpClientRequest(final AttachmentRenderer renderer } private static String getAttachmentName(final HttpRequest request) { - return String.format("Request_%s_%s", request.getRequestLine().getMethod(), - request.getRequestLine().getUri()); + return String.format( + "Request_%s_%s", request.getRequestLine().getMethod(), + request.getRequestLine().getUri() + ); } @Override public void process(final HttpRequest request, - final HttpContext context) throws IOException { + final HttpContext context) + throws IOException { - final HttpRequestAttachment.Builder builder = create(getAttachmentName(request), - request.getRequestLine().getUri()) + final HttpRequestAttachment.Builder builder = create( + getAttachmentName(request), + request.getRequestLine().getUri() + ) .setMethod(request.getRequestLine().getMethod()); Stream.of(request.getAllHeaders()) - .forEach(header -> builder.setHeader(header.getName(), header.getValue())); + .forEach(header -> builder.setHeader(header.getName(), header.getValue())); if (request instanceof HttpEntityEnclosingRequest) { final HttpEntity entity = ((HttpEntityEnclosingRequest) request).getEntity(); diff --git a/allure-httpclient/src/main/java/io/qameta/allure/httpclient/AllureHttpClientResponse.java b/allure-httpclient/src/main/java/io/qameta/allure/httpclient/AllureHttpClientResponse.java index f75891181..5e8daec13 100644 --- a/allure-httpclient/src/main/java/io/qameta/allure/httpclient/AllureHttpClientResponse.java +++ b/allure-httpclient/src/main/java/io/qameta/allure/httpclient/AllureHttpClientResponse.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -41,7 +41,8 @@ public class AllureHttpClientResponse implements HttpResponseInterceptor { private final AttachmentProcessor processor; public AllureHttpClientResponse() { - this(new FreemarkerAttachmentRenderer("http-response.ftl"), + this( + new FreemarkerAttachmentRenderer("http-response.ftl"), new DefaultAttachmentProcessor() ); } @@ -54,7 +55,8 @@ public AllureHttpClientResponse(final AttachmentRenderer rendere @Override public void process(final HttpResponse response, - final HttpContext context) throws IOException { + final HttpContext context) + throws IOException { final HttpResponseAttachment.Builder builder = create("Response") .setResponseCode(response.getStatusLine().getStatusCode()); diff --git a/allure-httpclient/src/test/java/io/qameta/allure/httpclient/AllureHttpClientTest.java b/allure-httpclient/src/test/java/io/qameta/allure/httpclient/AllureHttpClientTest.java index d7e56ea95..8ed24f1f4 100644 --- a/allure-httpclient/src/test/java/io/qameta/allure/httpclient/AllureHttpClientTest.java +++ b/allure-httpclient/src/test/java/io/qameta/allure/httpclient/AllureHttpClientTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -62,16 +62,26 @@ void setUp() { server.start(); configureFor(server.port()); - stubFor(get(urlEqualTo("/hello")) - .willReturn(aResponse() - .withBody(BODY_STRING))); - - stubFor(get(urlEqualTo("/empty")) - .willReturn(aResponse() - .withStatus(304))); - - stubFor(delete(urlEqualTo("/hello")) - .willReturn(noContent())); + stubFor( + get(urlEqualTo("/hello")) + .willReturn( + aResponse() + .withBody(BODY_STRING) + ) + ); + + stubFor( + get(urlEqualTo("/empty")) + .willReturn( + aResponse() + .withStatus(304) + ) + ); + + stubFor( + delete(urlEqualTo("/hello")) + .willReturn(noContent()) + ); } @AfterEach @@ -169,7 +179,7 @@ void shouldCreateRequestAttachmentWithEmptyBodyWhenNoContentIsReturned() throws final AttachmentProcessor processor = mock(AttachmentProcessor.class); final HttpClientBuilder builder = HttpClientBuilder.create() - .addInterceptorLast(new AllureHttpClientRequest(renderer, processor)); + .addInterceptorLast(new AllureHttpClientRequest(renderer, processor)); try (CloseableHttpClient httpClient = builder.build()) { final HttpDelete httpDelete = new HttpDelete(String.format("http://localhost:%d/hello", server.port())); @@ -195,16 +205,16 @@ void shouldNotConsumeBody() throws Exception { final AttachmentProcessor processor = mock(AttachmentProcessor.class); final HttpClientBuilder builder = HttpClientBuilder.create() - .addInterceptorLast(new AllureHttpClientResponse(renderer, processor)); + .addInterceptorLast(new AllureHttpClientResponse(renderer, processor)); try (CloseableHttpClient httpClient = builder.build()) { - final HttpGet httpGet = new HttpGet(String.format("http://localhost:%d/hello", server.port())); - try (CloseableHttpResponse response = httpClient.execute(httpGet)) { - response.getStatusLine().getStatusCode(); - BufferedHttpEntity ent = new BufferedHttpEntity(response.getEntity()); - assertThat(EntityUtils.toString(ent)) - .isEqualTo(BODY_STRING); - } + final HttpGet httpGet = new HttpGet(String.format("http://localhost:%d/hello", server.port())); + try (CloseableHttpResponse response = httpClient.execute(httpGet)) { + response.getStatusLine().getStatusCode(); + BufferedHttpEntity ent = new BufferedHttpEntity(response.getEntity()); + assertThat(EntityUtils.toString(ent)) + .isEqualTo(BODY_STRING); + } } } } diff --git a/allure-httpclient/src/test/resources/allure.properties b/allure-httpclient/src/test/resources/allure.properties index 9c0b0a2d7..0b9f016cb 100644 --- a/allure-httpclient/src/test/resources/allure.properties +++ b/allure-httpclient/src/test/resources/allure.properties @@ -1,2 +1,3 @@ allure.results.directory=build/allure-results allure.label.epic=#project.description# +allure.label.module=allure-httpclient diff --git a/allure-cucumber2-jvm/build.gradle.kts b/allure-httpclient5/build.gradle.kts similarity index 52% rename from allure-cucumber2-jvm/build.gradle.kts rename to allure-httpclient5/build.gradle.kts index 1ce0094c1..aa6d53179 100644 --- a/allure-cucumber2-jvm/build.gradle.kts +++ b/allure-httpclient5/build.gradle.kts @@ -1,15 +1,16 @@ -description = "Allure CucumberJVM 2.0 Integration" +description = "Allure Apache HttpClient5 Integration" -val cucumberVersion = "2.4.0" +val httpClient5Version = "5.3.1"; dependencies { - api(project(":allure-java-commons")) - implementation("io.cucumber:cucumber-core:$cucumberVersion") - implementation("io.cucumber:cucumber-java:$cucumberVersion") - testImplementation("commons-io:commons-io") + api(project(":allure-attachments")) + compileOnly("org.apache.httpcomponents.client5:httpclient5:$httpClient5Version") + testImplementation("com.github.tomakehurst:wiremock") testImplementation("io.github.glytching:junit-extensions") + testImplementation("org.apache.httpcomponents.client5:httpclient5:$httpClient5Version") testImplementation("org.assertj:assertj-core") testImplementation("org.junit.jupiter:junit-jupiter-api") + testImplementation("org.mockito:mockito-core") testImplementation("org.slf4j:slf4j-simple") testImplementation(project(":allure-java-commons-test")) testImplementation(project(":allure-junit-platform")) @@ -19,7 +20,7 @@ dependencies { tasks.jar { manifest { attributes(mapOf( - "Automatic-Module-Name" to "io.qameta.allure.cucumber2jvm" + "Automatic-Module-Name" to "io.qameta.allure.httpclient5" )) } } diff --git a/allure-httpclient5/readme.md b/allure-httpclient5/readme.md new file mode 100644 index 000000000..b307e4e4f --- /dev/null +++ b/allure-httpclient5/readme.md @@ -0,0 +1,56 @@ +## Allure-httpclient5 +Extended logging for requests and responses with [httpclient5](https://mvnrepository.com/artifact/org.apache.httpcomponents.client5/httpclient5) +This library does not support `httpclient` due to package and API changes between `httpclient` and `httpclient5`. +To work with `httpclient`, it is recommended to use the `allure-httpclient` library. + +## Wiki +https://hc.apache.org/httpcomponents-client-5.2.x/ +https://hc.apache.org/httpcomponents-client-5.2.x/quickstart.html +https://hc.apache.org/httpcomponents-client-5.2.x/migration-guide/index.html +https://hc.apache.org/httpcomponents-client-5.2.x/examples.html + +## Additional features +Implemented: +- The `httpclient5` library uses `gzip` compression by default. Interceptors attach message bodies in decompressed form +- `HttpEntityEnclosingRequest` is removed from `httpclient5`. Request interceptor works wo `HttpEntityEnclosingRequest` + +Not tested: +- The httpclient5 library support Async interactions (Not tested) + +## Examples + +```java +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import io.qameta.allure.httpclient5.AllureHttpClient5Request; +import io.qameta.allure.httpclient5.AllureHttpClient5Response; + +class Test { + + @Test + void smokeGetShouldNotThrowThenReturnCorrectResponseMessage() throws IOException { + final HttpClientBuilder builder = HttpClientBuilder.create() + .addRequestInterceptorFirst(new AllureHttpClient5Request()) + .addResponseInterceptorLast(new AllureHttpClient5Response()); + + try (CloseableHttpClient httpClient = builder.build()) { + final HttpGet httpGet = new HttpGet("/hello"); + httpClient.execute(httpGet, response -> { + assertThat(EntityUtils.toString(response.getEntity())).isEqualTo(BODY_STRING); + return response; + }); + } + } +} +``` + +In addition to using standard templates for formatting, you can use your custom `ftl` templates along the path +`/resources/tpl/...`. For examples, you can use templates from the `allure-attachments` module. + +```java + final HttpClientBuilder builder = HttpClientBuilder.create() + .addRequestInterceptorFirst(new AllureHttpClient5Request("your-request-template-attachment.ftl")) + .addResponseInterceptorLast(new AllureHttpClient5Response("your-response-template-attachment.ftl")); +``` \ No newline at end of file diff --git a/allure-httpclient5/src/main/java/io/qameta/allure/httpclient5/AllureHttpClient5Request.java b/allure-httpclient5/src/main/java/io/qameta/allure/httpclient5/AllureHttpClient5Request.java new file mode 100644 index 000000000..60aff3996 --- /dev/null +++ b/allure-httpclient5/src/main/java/io/qameta/allure/httpclient5/AllureHttpClient5Request.java @@ -0,0 +1,84 @@ +/* + * Copyright 2016-2026 Qameta Software Inc + * + * 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 io.qameta.allure.httpclient5; + +import io.qameta.allure.attachment.AttachmentData; +import io.qameta.allure.attachment.AttachmentProcessor; +import io.qameta.allure.attachment.AttachmentRenderer; +import io.qameta.allure.attachment.DefaultAttachmentProcessor; +import io.qameta.allure.attachment.FreemarkerAttachmentRenderer; +import io.qameta.allure.attachment.http.HttpRequestAttachment; +import org.apache.hc.core5.http.EntityDetails; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.HttpRequestInterceptor; +import org.apache.hc.core5.http.protocol.HttpContext; + +import java.util.stream.Stream; + +import static io.qameta.allure.attachment.http.HttpRequestAttachment.Builder.create; + +/** + * @author a-simeshin (Simeshin Artem) + */ +public class AllureHttpClient5Request implements HttpRequestInterceptor { + + private final AttachmentRenderer renderer; + private final AttachmentProcessor processor; + + public AllureHttpClient5Request() { + this("http-request.ftl"); + } + + public AllureHttpClient5Request(final String templateName) { + this(new FreemarkerAttachmentRenderer(templateName), new DefaultAttachmentProcessor()); + } + + public AllureHttpClient5Request(final AttachmentRenderer renderer, + final AttachmentProcessor processor) { + this.renderer = renderer; + this.processor = processor; + } + + /** + * Processes the HTTP request and adds an attachment to the Allure Attachment processor. + * + * @param request the HTTP request + * @param entity the entity details + * @param context the HTTP context + */ + @Override + public void process(final HttpRequest request, + final EntityDetails entity, + final HttpContext context) { + final String attachmentName = getAttachmentName(request); + final HttpRequestAttachment.Builder builder = create(attachmentName, request.getRequestUri()); + builder.setMethod(request.getMethod()); + + Stream.of(request.getHeaders()).forEach(header -> builder.setHeader(header.getName(), header.getValue())); + + if (entity instanceof HttpEntity && ((HttpEntity) entity).isRepeatable() && entity.getContentLength() != 0) { + builder.setBody(AllureHttpEntityUtils.getBody((HttpEntity) entity)); + } + + processor.addAttachment(builder.build(), renderer); + } + + private String getAttachmentName(final HttpRequest request) { + return String.format("Request_%s_%s", request.getMethod(), request.getRequestUri()); + } + +} diff --git a/allure-httpclient5/src/main/java/io/qameta/allure/httpclient5/AllureHttpClient5Response.java b/allure-httpclient5/src/main/java/io/qameta/allure/httpclient5/AllureHttpClient5Response.java new file mode 100644 index 000000000..90cdd03ac --- /dev/null +++ b/allure-httpclient5/src/main/java/io/qameta/allure/httpclient5/AllureHttpClient5Response.java @@ -0,0 +1,104 @@ +/* + * Copyright 2016-2026 Qameta Software Inc + * + * 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 io.qameta.allure.httpclient5; + +import io.qameta.allure.attachment.AttachmentData; +import io.qameta.allure.attachment.AttachmentProcessor; +import io.qameta.allure.attachment.AttachmentRenderer; +import io.qameta.allure.attachment.DefaultAttachmentProcessor; +import io.qameta.allure.attachment.FreemarkerAttachmentRenderer; +import io.qameta.allure.attachment.http.HttpResponseAttachment; +import org.apache.hc.core5.http.EntityDetails; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.HttpResponseInterceptor; +import org.apache.hc.core5.http.io.entity.BufferedHttpEntity; +import org.apache.hc.core5.http.message.BasicClassicHttpResponse; +import org.apache.hc.core5.http.protocol.HttpContext; + +import java.io.IOException; +import java.util.stream.Stream; + +import static io.qameta.allure.attachment.http.HttpResponseAttachment.Builder.create; + +/** + * @author a-simeshin (Simeshin Artem) + */ +@SuppressWarnings( + { + "checkstyle:ParameterAssignment", + "PMD.AvoidReassigningParameters"} +) +public class AllureHttpClient5Response implements HttpResponseInterceptor { + private final AttachmentRenderer renderer; + private final AttachmentProcessor processor; + private static final String NO_BODY = "No body present"; + + public AllureHttpClient5Response() { + this("http-response.ftl"); + } + + public AllureHttpClient5Response(final String templateName) { + this(new FreemarkerAttachmentRenderer(templateName), new DefaultAttachmentProcessor()); + } + + public AllureHttpClient5Response(final AttachmentRenderer renderer, + final AttachmentProcessor processor) { + this.renderer = renderer; + this.processor = processor; + } + + /** + * Processes the HTTP response and adds an attachment to the Allure Attachment processor. + * + * @param response the HTTP response + * @param entity the entity details, may be null for no response body responses + * @param context the HTTP context + * @throws IOException if an I/O error occurs + */ + @SuppressWarnings("PMD.CloseResource") + @Override + public void process(final HttpResponse response, + EntityDetails entity, + final HttpContext context) + throws IOException { + final HttpResponseAttachment.Builder builder = create("Response"); + builder.setResponseCode(response.getCode()); + + Stream.of(response.getHeaders()).forEach(header -> builder.setHeader(header.getName(), header.getValue())); + + final HttpEntity originalHttpEntity = (HttpEntity) entity; + if (originalHttpEntity != null && !originalHttpEntity.isRepeatable()) { + // Looks like a bug or completely new logic. It's not enough to replace chaining EntityDetails entity. + // To read the response body twice, It needs to put in the context also + entity = new BufferedHttpEntity(originalHttpEntity); + final BasicClassicHttpResponse responseEntity = (BasicClassicHttpResponse) context.getAttribute("http.response"); + responseEntity.setEntity((HttpEntity) entity); + + final String responseBody = AllureHttpEntityUtils.getBody((HttpEntity) entity); + if (responseBody == null || responseBody.isEmpty()) { + builder.setBody(NO_BODY); + } else { + builder.setBody(responseBody); + } + } else { + builder.setBody(NO_BODY); + } + + processor.addAttachment(builder.build(), renderer); + } + +} diff --git a/allure-httpclient5/src/main/java/io/qameta/allure/httpclient5/AllureHttpEntityUtils.java b/allure-httpclient5/src/main/java/io/qameta/allure/httpclient5/AllureHttpEntityUtils.java new file mode 100644 index 000000000..47a4d80a3 --- /dev/null +++ b/allure-httpclient5/src/main/java/io/qameta/allure/httpclient5/AllureHttpEntityUtils.java @@ -0,0 +1,96 @@ +/* + * Copyright 2016-2026 Qameta Software Inc + * + * 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 io.qameta.allure.httpclient5; + +import io.qameta.allure.AllureResultsWriteException; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.ParseException; +import org.apache.hc.core5.http.io.entity.EntityUtils; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.zip.GZIPInputStream; + +/** + * Utility class for working with HTTP entity in Allure framework. + */ +@SuppressWarnings({"checkstyle:ParameterAssignment"}) +public final class AllureHttpEntityUtils { + + private AllureHttpEntityUtils() { + throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); + } + + /** + * Retrieves the body of the HTTP entity as a string. + * + * @param httpEntity the HTTP entity + * @return the body of the HTTP entity as a string + * @throws AllureResultsWriteException if an error occurs while reading the entity body + */ + static String getBody(final HttpEntity httpEntity) { + try { + final String contentEncoding = httpEntity.getContentEncoding(); + if (contentEncoding != null && contentEncoding.contains("gzip")) { + return unpackGzipEntityString(httpEntity); + } else { + return EntityUtils.toString(httpEntity, getContentEncoding(httpEntity.getContentEncoding())); + } + } catch (IOException | ParseException e) { + throw new AllureResultsWriteException("Can't read request message body to String", e); + } + } + + /** + * Retrieves the content encoding of the HTTP entity. + * + * @param contentEncoding the content encoding value + * @return the charset corresponding to the content encoding, or UTF-8 if the encoding is invalid + */ + static Charset getContentEncoding(final String contentEncoding) { + try { + return Charset.forName(contentEncoding); + } catch (IllegalArgumentException ignored) { + return StandardCharsets.UTF_8; + } + } + + /** + * Unpacks the GZIP-encoded entity string. + * + * @param entity the GZIP-encoded HTTP entity + * @return the unpacked entity string + * @throws IOException if an error occurs while unpacking the entity + */ + static String unpackGzipEntityString(final HttpEntity entity) throws IOException { + final GZIPInputStream gis = new GZIPInputStream(entity.getContent()); + final Charset contentEncoding = getContentEncoding(entity.getContentEncoding()); + try (InputStreamReader inputStreamReader = new InputStreamReader(gis, contentEncoding)) { + try (BufferedReader bufferedReader = new BufferedReader(inputStreamReader)) { + final StringBuilder outStr = new StringBuilder(); + String line; + while ((line = bufferedReader.readLine()) != null) { + outStr.append(line); + } + return outStr.toString(); + } + } + } + +} diff --git a/allure-httpclient5/src/test/java/io/qameta/allure/httpclient5/AllureHttpClient5DeleteTest.java b/allure-httpclient5/src/test/java/io/qameta/allure/httpclient5/AllureHttpClient5DeleteTest.java new file mode 100644 index 000000000..89ad99f75 --- /dev/null +++ b/allure-httpclient5/src/test/java/io/qameta/allure/httpclient5/AllureHttpClient5DeleteTest.java @@ -0,0 +1,142 @@ +/* + * Copyright 2016-2026 Qameta Software Inc + * + * 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 io.qameta.allure.httpclient5; + +import com.github.tomakehurst.wiremock.WireMockServer; +import io.qameta.allure.attachment.AttachmentData; +import io.qameta.allure.attachment.AttachmentProcessor; +import io.qameta.allure.attachment.AttachmentRenderer; +import org.apache.hc.client5.http.classic.methods.HttpDelete; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.Objects; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.configureFor; +import static com.github.tomakehurst.wiremock.client.WireMock.delete; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * @author a-simeshin (Simeshin Artem). + */ +@SuppressWarnings({"unchecked", "PMD.JUnitTestContainsTooManyAsserts"}) +class AllureHttpClient5DeleteTest { + + private static final String DELETE_URL = "http://localhost:%d/delete"; + private static final String HELLO_RESOURCE_PATH = "/delete"; + + private WireMockServer server; + + @BeforeEach + void setUp() { + server = new WireMockServer(options().dynamicPort()); + server.start(); + configureFor(server.port()); + + stubFor( + delete(HELLO_RESOURCE_PATH).willReturn( + aResponse() + .withStatus(204) + ) + ); + } + + @AfterEach + void tearDown() { + if (Objects.nonNull(server)) { + server.stop(); + } + } + + @Test + void smokeDeleteShouldNotThrowThenReturnCorrectCode() { + final HttpClientBuilder builder = HttpClientBuilder.create() + .addRequestInterceptorFirst(new AllureHttpClient5Request()) + .addResponseInterceptorLast(new AllureHttpClient5Response()); + + assertDoesNotThrow(() -> { + try (CloseableHttpClient httpClient = builder.build()) { + final HttpDelete httpDelete = new HttpDelete(String.format(DELETE_URL, server.port())); + httpClient.execute(httpDelete, response -> { + assertThat(response.getCode()).isEqualTo(204); + return response; + }); + } + }); + } + + @Test + void shouldCreateDeleteRequestAttachment() throws Exception { + final AttachmentRenderer renderer = mock(AttachmentRenderer.class); + final AttachmentProcessor processor = mock(AttachmentProcessor.class); + + final HttpClientBuilder builder = HttpClientBuilder.create() + .addRequestInterceptorFirst(new AllureHttpClient5Request(renderer, processor)); + + try (CloseableHttpClient httpClient = builder.build()) { + final HttpDelete httpDelete = new HttpDelete(String.format(DELETE_URL, server.port())); + httpClient.execute(httpDelete, response -> { + assertThat(response.getCode()).isEqualTo(204); + return response; + }); + } + + final ArgumentCaptor captor = ArgumentCaptor.forClass(AttachmentData.class); + + verify(processor, times(1)).addAttachment(captor.capture(), eq(renderer)); + assertThat(captor.getAllValues()) + .hasSize(1) + .extracting("url") + .containsExactly(HELLO_RESOURCE_PATH); + } + @Test + void shouldCreateDeleteResponseAttachmentWithEmptyBody() throws Exception { + final AttachmentRenderer renderer = mock(AttachmentRenderer.class); + final AttachmentProcessor processor = mock(AttachmentProcessor.class); + + final HttpClientBuilder builder = HttpClientBuilder.create() + .addResponseInterceptorLast(new AllureHttpClient5Response(renderer, processor)); + + try (CloseableHttpClient httpClient = builder.build()) { + final HttpDelete httpDelete = new HttpDelete(String.format(DELETE_URL, server.port())); + httpClient.execute(httpDelete, response -> { + assertThat(response.getCode()).isEqualTo(204); + return response; + }); + } + + final ArgumentCaptor captor = ArgumentCaptor.forClass(AttachmentData.class); + verify(processor, times(1)) + .addAttachment(captor.capture(), eq(renderer)); + + assertThat(captor.getAllValues()) + .hasSize(1) + .extracting("body") + .containsExactly("No body present"); + } +} diff --git a/allure-httpclient5/src/test/java/io/qameta/allure/httpclient5/AllureHttpClient5GetTest.java b/allure-httpclient5/src/test/java/io/qameta/allure/httpclient5/AllureHttpClient5GetTest.java new file mode 100644 index 000000000..468f25803 --- /dev/null +++ b/allure-httpclient5/src/test/java/io/qameta/allure/httpclient5/AllureHttpClient5GetTest.java @@ -0,0 +1,179 @@ +/* + * Copyright 2016-2026 Qameta Software Inc + * + * 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 io.qameta.allure.httpclient5; + +import com.github.tomakehurst.wiremock.WireMockServer; +import io.qameta.allure.attachment.AttachmentData; +import io.qameta.allure.attachment.AttachmentProcessor; +import io.qameta.allure.attachment.AttachmentRenderer; +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.Objects; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.configureFor; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * @author a-simeshin (Simeshin Artem). + */ +@SuppressWarnings({"unchecked", "PMD.JUnitTestContainsTooManyAsserts"}) +class AllureHttpClient5GetTest { + + private static final String BODY_STRING = "Hello world!"; + private static final String HELLO_RESOURCE_PATH = "/hello"; + private static final String HELLO_GET_RETURN_BODY = "http://localhost:%d/hello"; + private static final String HELLO_GET_201_NO_BODY = "http://localhost:%d/empty"; + + private WireMockServer server; + + @BeforeEach + void setUp() { + server = new WireMockServer(options().dynamicPort()); + server.start(); + configureFor(server.port()); + + stubFor( + get(HELLO_RESOURCE_PATH).willReturn( + aResponse() + .withHeader("Content-Type", "application/json") + .withBody(BODY_STRING) + ) + ); + stubFor( + get("/empty").willReturn( + aResponse() + .withStatus(200) + ) + ); + } + + @AfterEach + void tearDown() { + if (Objects.nonNull(server)) { + server.stop(); + } + } + + @Test + void smokeGetShouldNotThrowThenReturnCorrectResponseMessage() { + final HttpClientBuilder builder = HttpClientBuilder.create() + .addRequestInterceptorFirst(new AllureHttpClient5Request()) + .addResponseInterceptorLast(new AllureHttpClient5Response()); + + assertDoesNotThrow(() -> { + try (CloseableHttpClient httpClient = builder.build()) { + final HttpGet httpGet = new HttpGet(String.format(HELLO_GET_RETURN_BODY, server.port())); + httpClient.execute(httpGet, response -> { + assertThat(EntityUtils.toString(response.getEntity())).isEqualTo(BODY_STRING); + return response; + }); + } + }); + } + + @Test + void shouldCreateGetRequestAttachment() throws Exception { + final AttachmentRenderer renderer = mock(AttachmentRenderer.class); + final AttachmentProcessor processor = mock(AttachmentProcessor.class); + + final HttpClientBuilder builder = HttpClientBuilder.create() + .addRequestInterceptorLast(new AllureHttpClient5Request(renderer, processor)); + + try (CloseableHttpClient httpClient = builder.build()) { + final HttpGet httpGet = new HttpGet(String.format(HELLO_GET_RETURN_BODY, server.port())); + httpClient.execute(httpGet, response -> { + assertThat(EntityUtils.toString(response.getEntity())).isEqualTo(BODY_STRING); + return response; + }); + } + + final ArgumentCaptor captor = ArgumentCaptor.forClass(AttachmentData.class); + + verify(processor, times(1)).addAttachment(captor.capture(), eq(renderer)); + assertThat(captor.getAllValues()) + .hasSize(1) + .extracting("url") + .containsExactly(HELLO_RESOURCE_PATH); + } + + @Test + void shouldCreateGetResponseAttachment() throws Exception { + final AttachmentRenderer renderer = mock(AttachmentRenderer.class); + final AttachmentProcessor processor = mock(AttachmentProcessor.class); + + final HttpClientBuilder builder = HttpClientBuilder.create() + .addResponseInterceptorLast(new AllureHttpClient5Response(renderer, processor)); + + try (CloseableHttpClient httpClient = builder.build()) { + final HttpGet httpGet = new HttpGet(String.format(HELLO_GET_RETURN_BODY, server.port())); + httpClient.execute(httpGet, response -> { + assertThat(EntityUtils.toString(response.getEntity())).isEqualTo(BODY_STRING); + return response; + }); + } + + final ArgumentCaptor captor = ArgumentCaptor.forClass(AttachmentData.class); + verify(processor, times(1)) + .addAttachment(captor.capture(), eq(renderer)); + + assertThat(captor.getAllValues()) + .hasSize(1) + .extracting("responseCode") + .containsExactly(200); + } + + @Test + void shouldCreateGetResponseAttachmentWithEmptyBody() throws Exception { + final AttachmentRenderer renderer = mock(AttachmentRenderer.class); + final AttachmentProcessor processor = mock(AttachmentProcessor.class); + + final HttpClientBuilder builder = HttpClientBuilder.create() + .addResponseInterceptorLast(new AllureHttpClient5Response(renderer, processor)); + + try (CloseableHttpClient httpClient = builder.build()) { + final HttpGet httpGet = new HttpGet(String.format(HELLO_GET_201_NO_BODY, server.port())); + httpClient.execute(httpGet, response -> { + assertThat(EntityUtils.toString(response.getEntity())).isEqualTo(""); + return response; + }); + } + + final ArgumentCaptor captor = ArgumentCaptor.forClass(AttachmentData.class); + verify(processor, times(1)) + .addAttachment(captor.capture(), eq(renderer)); + + assertThat(captor.getAllValues()) + .hasSize(1) + .extracting("body") + .containsExactly("No body present"); + } +} diff --git a/allure-httpclient5/src/test/java/io/qameta/allure/httpclient5/AllureHttpClient5PostTest.java b/allure-httpclient5/src/test/java/io/qameta/allure/httpclient5/AllureHttpClient5PostTest.java new file mode 100644 index 000000000..e2a375df8 --- /dev/null +++ b/allure-httpclient5/src/test/java/io/qameta/allure/httpclient5/AllureHttpClient5PostTest.java @@ -0,0 +1,190 @@ +/* + * Copyright 2016-2026 Qameta Software Inc + * + * 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 io.qameta.allure.httpclient5; + +import com.github.tomakehurst.wiremock.WireMockServer; +import io.qameta.allure.attachment.AttachmentData; +import io.qameta.allure.attachment.AttachmentProcessor; +import io.qameta.allure.attachment.AttachmentRenderer; +import org.apache.hc.client5.http.classic.methods.HttpPost; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.apache.hc.core5.http.io.entity.StringEntity; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.Objects; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.configureFor; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * @author a-simeshin (Simeshin Artem). + */ +@SuppressWarnings({"unchecked", "PMD.JUnitTestContainsTooManyAsserts"}) +class AllureHttpClient5PostTest { + + private static final String BODY_STRING = "Hello world!"; + private static final String POST_REQUEST_BODY = "hello post request body"; + private static final String HELLO_RESOURCE_PATH = "/hello"; + private static final String HELLO_POST_RETURN_BODY = "http://localhost:%d/hello"; + private static final String HELLO_POST_201_NO_BODY = "http://localhost:%d/empty"; + + private WireMockServer server; + + @BeforeEach + void setUp() { + server = new WireMockServer(options().dynamicPort()); + server.start(); + configureFor(server.port()); + + stubFor( + post(HELLO_RESOURCE_PATH).willReturn( + aResponse() + .withHeader("Content-Type", "application/json") + .withBody(BODY_STRING) + ) + ); + stubFor( + post("/empty").willReturn( + aResponse() + .withStatus(201) + ) + ); + } + + @AfterEach + void tearDown() { + if (Objects.nonNull(server)) { + server.stop(); + } + } + + @Test + void smokePostShouldNotThrowThenReturnCorrectResponseMessage() { + final HttpClientBuilder builder = HttpClientBuilder.create() + .addRequestInterceptorFirst(new AllureHttpClient5Request()) + .addResponseInterceptorLast(new AllureHttpClient5Response()); + + assertDoesNotThrow(() -> { + try (CloseableHttpClient httpClient = builder.build()) { + final HttpPost httpPost = new HttpPost(String.format(HELLO_POST_RETURN_BODY, server.port())); + httpPost.setEntity(new StringEntity(POST_REQUEST_BODY, ContentType.APPLICATION_JSON)); + httpClient.execute(httpPost, response -> { + response.getCode(); + assertThat(EntityUtils.toString(response.getEntity())).isEqualTo(BODY_STRING); + return response; + }); + } + }); + } + + @Test + void shouldCreatePostRequestAttachment() throws Exception { + final AttachmentRenderer renderer = mock(AttachmentRenderer.class); + final AttachmentProcessor processor = mock(AttachmentProcessor.class); + + final HttpClientBuilder builder = HttpClientBuilder.create() + .addRequestInterceptorFirst(new AllureHttpClient5Request(renderer, processor)); + + try (CloseableHttpClient httpClient = builder.build()) { + final HttpPost httpPost = new HttpPost(String.format(HELLO_POST_RETURN_BODY, server.port())); + httpPost.setEntity(new StringEntity(POST_REQUEST_BODY, ContentType.APPLICATION_JSON)); + httpClient.execute(httpPost, response -> { + response.getCode(); + assertThat(EntityUtils.toString(response.getEntity())).isEqualTo(BODY_STRING); + return response; + }); + } + + final ArgumentCaptor captor = ArgumentCaptor.forClass(AttachmentData.class); + + verify(processor, times(1)).addAttachment(captor.capture(), eq(renderer)); + assertThat(captor.getAllValues()) + .hasSize(1) + .extracting("url") + .containsExactly(HELLO_RESOURCE_PATH); + } + + @Test + void shouldCreatePostResponseAttachment() throws Exception { + final AttachmentRenderer renderer = mock(AttachmentRenderer.class); + final AttachmentProcessor processor = mock(AttachmentProcessor.class); + + final HttpClientBuilder builder = HttpClientBuilder.create() + .addResponseInterceptorLast(new AllureHttpClient5Response(renderer, processor)); + + try (CloseableHttpClient httpClient = builder.build()) { + final HttpPost httpPost = new HttpPost(String.format(HELLO_POST_RETURN_BODY, server.port())); + httpPost.setEntity(new StringEntity(POST_REQUEST_BODY, ContentType.APPLICATION_JSON)); + httpClient.execute(httpPost, response -> { + response.getCode(); + assertThat(EntityUtils.toString(response.getEntity())).isEqualTo(BODY_STRING); + return response; + }); + } + + final ArgumentCaptor captor = ArgumentCaptor.forClass(AttachmentData.class); + verify(processor, times(1)) + .addAttachment(captor.capture(), eq(renderer)); + + assertThat(captor.getAllValues()) + .hasSize(1) + .extracting("responseCode") + .containsExactly(200); + } + + @Test + void shouldCreatePostResponseAttachmentWithEmptyBody() throws Exception { + final AttachmentRenderer renderer = mock(AttachmentRenderer.class); + final AttachmentProcessor processor = mock(AttachmentProcessor.class); + + final HttpClientBuilder builder = HttpClientBuilder.create() + .addResponseInterceptorLast(new AllureHttpClient5Response(renderer, processor)); + + try (CloseableHttpClient httpClient = builder.build()) { + final HttpPost httpPost = new HttpPost(String.format(HELLO_POST_201_NO_BODY, server.port())); + httpPost.setEntity(new StringEntity(POST_REQUEST_BODY, ContentType.APPLICATION_JSON)); + httpClient.execute(httpPost, response -> { + response.getCode(); + assertThat(EntityUtils.toString(response.getEntity())).isEqualTo(""); + return response; + }); + } + + final ArgumentCaptor captor = ArgumentCaptor.forClass(AttachmentData.class); + verify(processor, times(1)) + .addAttachment(captor.capture(), eq(renderer)); + + assertThat(captor.getAllValues()) + .hasSize(1) + .extracting("body") + .containsExactly("No body present"); + } +} diff --git a/allure-httpclient5/src/test/resources/allure.properties b/allure-httpclient5/src/test/resources/allure.properties new file mode 100644 index 000000000..a6feadd77 --- /dev/null +++ b/allure-httpclient5/src/test/resources/allure.properties @@ -0,0 +1,3 @@ +allure.results.directory=build/allure-results +allure.label.epic=#project.description# +allure.label.module=allure-httpclient5 diff --git a/allure-java-commons-test/build.gradle.kts b/allure-java-commons-test/build.gradle.kts index ea06623dc..92c4e2e00 100644 --- a/allure-java-commons-test/build.gradle.kts +++ b/allure-java-commons-test/build.gradle.kts @@ -5,6 +5,10 @@ dependencies { api("io.github.benas:random-beans") api("org.apache.commons:commons-lang3") api(project(":allure-java-commons")) + implementation("com.fasterxml.jackson.core:jackson-databind") + testImplementation("org.junit.jupiter:junit-jupiter-api") + testImplementation(project(":allure-junit-platform")) + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") } tasks.jar { @@ -14,3 +18,7 @@ tasks.jar { )) } } + +tasks.test { + useJUnitPlatform() +} diff --git a/allure-java-commons-test/src/main/java/io/qameta/allure/test/AllureFeatures.java b/allure-java-commons-test/src/main/java/io/qameta/allure/test/AllureFeatures.java index d4641dcc8..e3706d29a 100644 --- a/allure-java-commons-test/src/main/java/io/qameta/allure/test/AllureFeatures.java +++ b/allure-java-commons-test/src/main/java/io/qameta/allure/test/AllureFeatures.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -27,7 +27,7 @@ /** * @author charlie (Dmitry Baev). */ -@SuppressWarnings({"JavadocType", "PMD.MissingStaticMethodInNonInstantiatableClass"}) +@SuppressWarnings({"JavadocType"}) @Target({}) public @interface AllureFeatures { diff --git a/allure-java-commons-test/src/main/java/io/qameta/allure/test/AllurePredicates.java b/allure-java-commons-test/src/main/java/io/qameta/allure/test/AllurePredicates.java index f59a9581d..cfb24b3da 100644 --- a/allure-java-commons-test/src/main/java/io/qameta/allure/test/AllurePredicates.java +++ b/allure-java-commons-test/src/main/java/io/qameta/allure/test/AllurePredicates.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,7 +25,6 @@ /** * @author charlie (Dmitry Baev). */ -@SuppressWarnings({"PMD.ClassNamingConventions", "PMD.LinguisticNaming"}) public final class AllurePredicates { private AllurePredicates() { diff --git a/allure-java-commons-test/src/main/java/io/qameta/allure/test/AllureResults.java b/allure-java-commons-test/src/main/java/io/qameta/allure/test/AllureResults.java index d0a22173f..d41aacf98 100644 --- a/allure-java-commons-test/src/main/java/io/qameta/allure/test/AllureResults.java +++ b/allure-java-commons-test/src/main/java/io/qameta/allure/test/AllureResults.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,6 +20,9 @@ import java.util.List; import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.stream.Collectors; /** * @author charlie (Dmitry Baev). @@ -32,4 +35,22 @@ public interface AllureResults { Map getAttachments(); + default TestResult getTestResultByName(final String name) { + return getTestResults().stream() + .filter(tr -> Objects.equals(name, tr.getName())) + .findFirst() + .orElseThrow( + () -> new NoSuchElementException( + "test result with name " + name + " is not found" + ) + ); + } + + default List getTestResultContainersForTestResult(final TestResult testResult) { + return getTestResultContainers().stream() + .filter(c -> Objects.nonNull(c.getChildren())) + .filter(c -> c.getChildren().contains(testResult.getUuid())) + .collect(Collectors.toList()); + } + } diff --git a/allure-java-commons-test/src/main/java/io/qameta/allure/test/AllureResultsWriterStub.java b/allure-java-commons-test/src/main/java/io/qameta/allure/test/AllureResultsWriterStub.java index 0e560c932..10501feef 100644 --- a/allure-java-commons-test/src/main/java/io/qameta/allure/test/AllureResultsWriterStub.java +++ b/allure-java-commons-test/src/main/java/io/qameta/allure/test/AllureResultsWriterStub.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-java-commons-test/src/main/java/io/qameta/allure/test/AllureTestCommonsUtils.java b/allure-java-commons-test/src/main/java/io/qameta/allure/test/AllureTestCommonsUtils.java new file mode 100644 index 000000000..1065f8119 --- /dev/null +++ b/allure-java-commons-test/src/main/java/io/qameta/allure/test/AllureTestCommonsUtils.java @@ -0,0 +1,176 @@ +/* + * Copyright 2016-2026 Qameta Software Inc + * + * 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 io.qameta.allure.test; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectWriter; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import io.qameta.allure.Allure; +import io.qameta.allure.AllureConstants; +import io.qameta.allure.model.Parameter; +import io.qameta.allure.model.Stage; +import io.qameta.allure.model.Status; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Locale; + +import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_DEFAULT; +import static com.fasterxml.jackson.databind.MapperFeature.USE_WRAPPER_NAME_AS_PROPERTY_NAME; + +/** + * @author charlie (Dmitry Baev). + */ +public final class AllureTestCommonsUtils { + + private static final String DOT = "."; + private static final String JSON_EXTENSION = "json"; + private static final String JSON_TYPE = "application/json"; + private static final String TEXT_EXTENSION = "txt"; + private static final String TEXT_TYPE = "text/plain"; + private static final ObjectWriter WRITER = JsonMapper + .builder() + .configure(USE_WRAPPER_NAME_AS_PROPERTY_NAME, true) + .serializationInclusion(NON_DEFAULT) + .build() + .registerModule( + new SimpleModule() + .addSerializer(Status.class, new StatusSerializer()) + .addSerializer(Stage.class, new StageSerializer()) + .addSerializer(Parameter.Mode.class, new ParameterModeSerializer()) + ) + .writerWithDefaultPrettyPrinter(); + + private AllureTestCommonsUtils() { + throw new IllegalStateException("do not instance"); + } + + /** + * Attach {@link AllureResults} to the report. + */ + public static void attach(final AllureResults allureResults) { + allureResults.getTestResults().forEach(testResult -> { + try { + Allure.addAttachment( + testResult.getUuid() + AllureConstants.TEST_RESULT_FILE_SUFFIX, + JSON_TYPE, + WRITER.writeValueAsString(testResult), + JSON_EXTENSION + ); + } catch (JsonProcessingException e) { + throw new UncheckedIOException(e); + } + }); + + allureResults.getTestResultContainers().forEach(container -> { + try { + Allure.addAttachment( + container.getUuid() + AllureConstants.TEST_RESULT_CONTAINER_FILE_SUFFIX, + JSON_TYPE, + WRITER.writeValueAsString(container), + JSON_EXTENSION + ); + } catch (JsonProcessingException e) { + throw new UncheckedIOException(e); + } + }); + + allureResults.getAttachments().forEach( + (fileName, body) -> Allure + .addAttachment( + fileName, + type(fileName), + new ByteArrayInputStream(body), + extension(fileName) + ) + ); + } + + private static String type(final String fileName) { + if (fileName.endsWith(DOT + JSON_EXTENSION)) { + return JSON_TYPE; + } + if (fileName.endsWith(DOT + TEXT_EXTENSION)) { + return TEXT_TYPE; + } + return null; + } + + private static String extension(final String fileName) { + final int index = fileName.lastIndexOf('.'); + if (index < 0 || index == fileName.length() - 1) { + return null; + } + return fileName.substring(index + 1); + } + + /** + * Parameter mode serializer. + */ + private static class ParameterModeSerializer extends StdSerializer { + protected ParameterModeSerializer() { + super(Parameter.Mode.class); + } + + @Override + public void serialize(final Parameter.Mode value, + final JsonGenerator gen, + final SerializerProvider provider) + throws IOException { + gen.writeString(value.name().toLowerCase(Locale.ENGLISH)); + } + } + + /** + * Stage serializer. + */ + private static class StageSerializer extends StdSerializer { + protected StageSerializer() { + super(Stage.class); + } + + @Override + public void serialize(final Stage value, + final JsonGenerator gen, + final SerializerProvider provider) + throws IOException { + gen.writeString(value.name().toLowerCase(Locale.ENGLISH)); + } + } + + /** + * Status serializer. + */ + private static class StatusSerializer extends StdSerializer { + protected StatusSerializer() { + super(Status.class); + } + + @Override + public void serialize(final Status value, + final JsonGenerator gen, + final SerializerProvider provider) + throws IOException { + gen.writeString(value.name().toLowerCase(Locale.ENGLISH)); + } + } + +} diff --git a/allure-java-commons-test/src/main/java/io/qameta/allure/test/RunUtils.java b/allure-java-commons-test/src/main/java/io/qameta/allure/test/RunUtils.java index 27eae0ccf..6faf95660 100644 --- a/allure-java-commons-test/src/main/java/io/qameta/allure/test/RunUtils.java +++ b/allure-java-commons-test/src/main/java/io/qameta/allure/test/RunUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,12 +17,15 @@ import io.qameta.allure.Allure; import io.qameta.allure.AllureLifecycle; +import io.qameta.allure.AllureResultsWriter; import io.qameta.allure.aspects.AttachmentsAspects; import io.qameta.allure.aspects.StepsAspects; import io.qameta.allure.model.TestResult; +import io.qameta.allure.util.ExceptionUtils; import java.util.UUID; import java.util.function.Consumer; +import java.util.function.Function; import java.util.stream.Stream; import static io.qameta.allure.util.ResultsUtils.getStatus; @@ -37,8 +40,21 @@ private RunUtils() { throw new IllegalStateException("do not instance"); } - public static AllureResults runWithinTestContext(final Runnable runnable) { - return runWithinTestContext( + public static AllureResults runTests( + final Allure.ThrowableContextRunnableVoid runnable) { + return runTests( + runnable, + Allure::setLifecycle, + StepsAspects::setLifecycle, + AttachmentsAspects::setLifecycle + ); + } + + public static AllureResults runTests( + final Function lifecycleFactory, + final Allure.ThrowableContextRunnableVoid runnable) { + return runTests( + lifecycleFactory, runnable, Allure::setLifecycle, StepsAspects::setLifecycle, @@ -47,18 +63,67 @@ public static AllureResults runWithinTestContext(final Runnable runnable) { } @SafeVarargs - public static AllureResults runWithinTestContext(final Runnable runnable, - final Consumer... configurers) { + public static AllureResults runTests( + final Allure.ThrowableContextRunnableVoid runnable, + final Consumer... configurers) { + return runTests(AllureLifecycle::new, runnable, configurers); + } + + @SafeVarargs + public static AllureResults runTests( + final Function lifecycleFactory, + final Allure.ThrowableContextRunnableVoid runnable, + final Consumer... configurers) { final AllureResultsWriterStub writer = new AllureResultsWriterStub(); - final AllureLifecycle lifecycle = new AllureLifecycle(writer); + final AllureLifecycle lifecycle = lifecycleFactory.apply(writer); + + final AllureLifecycle defaultLifecycle = Allure.getLifecycle(); + try { + Stream.of(configurers).forEach(configurer -> configurer.accept(lifecycle)); + + runnable.run(lifecycle); + return writer; + } catch (Throwable e) { + throw ExceptionUtils.sneakyThrow(e); + } finally { + Stream.of(configurers).forEach(configurer -> configurer.accept(defaultLifecycle)); + + AllureTestCommonsUtils.attach(writer); + } + } + + public static AllureResults runWithinTestContext( + final Runnable runnable) { + return runTests(lifecycle -> withTestContext(runnable, lifecycle)); + } + + public static AllureResults runWithinTestContext( + final Function lifecycleFactory, + final Runnable runnable) { + return runTests(lifecycleFactory, lifecycle -> withTestContext(runnable, lifecycle)); + } + + @SafeVarargs + public static AllureResults runWithinTestContext( + final Runnable runnable, + final Consumer... configurers) { + return runTests(lifecycle -> withTestContext(runnable, lifecycle), configurers); + } + + @SafeVarargs + public static AllureResults runWithinTestContext( + final Function lifecycleFactory, + final Runnable runnable, + final Consumer... configurers) { + return runTests(lifecycleFactory, lifecycle -> withTestContext(runnable, lifecycle), configurers); + } + + private static void withTestContext(final Runnable runnable, final AllureLifecycle lifecycle) { final String uuid = UUID.randomUUID().toString(); final TestResult result = new TestResult().setUuid(uuid); - final AllureLifecycle cached = Allure.getLifecycle(); try { - Stream.of(configurers).forEach(configurer -> configurer.accept(lifecycle)); - lifecycle.scheduleTestCase(result); lifecycle.startTestCase(uuid); @@ -72,11 +137,7 @@ public static AllureResults runWithinTestContext(final Runnable runnable, } finally { lifecycle.stopTestCase(uuid); lifecycle.writeTestCase(uuid); - - Stream.of(configurers).forEach(configurer -> configurer.accept(cached)); } - - return writer; } } diff --git a/allure-java-commons-test/src/main/java/io/qameta/allure/test/TestData.java b/allure-java-commons-test/src/main/java/io/qameta/allure/test/TestData.java index 3748458f8..13b035c8f 100644 --- a/allure-java-commons-test/src/main/java/io/qameta/allure/test/TestData.java +++ b/allure-java-commons-test/src/main/java/io/qameta/allure/test/TestData.java @@ -1,5 +1,5 @@ /* - * Copyright 2020 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-java-commons-test/src/main/java/io/qameta/allure/test/ThreadLocalEnhancedRandom.java b/allure-java-commons-test/src/main/java/io/qameta/allure/test/ThreadLocalEnhancedRandom.java index d0be47326..1c9b27184 100644 --- a/allure-java-commons-test/src/main/java/io/qameta/allure/test/ThreadLocalEnhancedRandom.java +++ b/allure-java-commons-test/src/main/java/io/qameta/allure/test/ThreadLocalEnhancedRandom.java @@ -1,5 +1,5 @@ /* - * Copyright 2020 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-java-commons-test/src/test/java/io/qameta/allure/test/AllurePredicatesTest.java b/allure-java-commons-test/src/test/java/io/qameta/allure/test/AllurePredicatesTest.java new file mode 100644 index 000000000..4f5048a55 --- /dev/null +++ b/allure-java-commons-test/src/test/java/io/qameta/allure/test/AllurePredicatesTest.java @@ -0,0 +1,41 @@ +/* + * Copyright 2016-2026 Qameta Software Inc + * + * 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 io.qameta.allure.test; + +import io.qameta.allure.model.Label; +import io.qameta.allure.model.Status; +import io.qameta.allure.model.TestResult; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AllurePredicatesTest { + + @Test + void shouldMatchStatusAndLabels() { + final TestResult result = new TestResult() + .setStatus(Status.PASSED) + .setLabels(List.of(new Label().setName("feature").setValue("attachments"))); + + assertTrue(AllurePredicates.hasStatus(Status.PASSED).test(result)); + assertTrue(AllurePredicates.hasLabel("feature", "attachments").test(result)); + assertFalse(AllurePredicates.hasStatus(Status.FAILED).test(result)); + assertFalse(AllurePredicates.hasLabel("feature", "steps").test(result)); + } +} diff --git a/allure-java-commons-test/src/test/java/io/qameta/allure/test/AllureResultsWriterStubTest.java b/allure-java-commons-test/src/test/java/io/qameta/allure/test/AllureResultsWriterStubTest.java new file mode 100644 index 000000000..607d7013f --- /dev/null +++ b/allure-java-commons-test/src/test/java/io/qameta/allure/test/AllureResultsWriterStubTest.java @@ -0,0 +1,55 @@ +/* + * Copyright 2016-2026 Qameta Software Inc + * + * 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 io.qameta.allure.test; + +import io.qameta.allure.Allure; +import io.qameta.allure.model.TestResult; +import io.qameta.allure.model.TestResultContainer; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +class AllureResultsWriterStubTest { + + @Test + void shouldStoreResultsContainersAndAttachments() { + final AllureResultsWriterStub writer = new AllureResultsWriterStub(); + final TestResult testResult = new TestResult() + .setUuid("test-uuid") + .setName("demo"); + final TestResultContainer container = new TestResultContainer() + .setUuid("container-uuid") + .setChildren(List.of("test-uuid")); + + Allure.step("Store a test result, its container, and an attachment", () -> { + writer.write(testResult); + writer.write(container); + writer.write("payload.txt", new ByteArrayInputStream("payload".getBytes(StandardCharsets.UTF_8))); + }); + + Allure.step("Verify the stub exposes the written runtime artifacts", () -> { + assertSame(testResult, writer.getTestResultByName("demo")); + assertEquals(List.of(container), writer.getTestResultContainersForTestResult(testResult)); + assertArrayEquals("payload".getBytes(StandardCharsets.UTF_8), writer.getAttachments().get("payload.txt")); + }); + } +} diff --git a/allure-java-commons-test/src/test/java/io/qameta/allure/test/RunUtilsTest.java b/allure-java-commons-test/src/test/java/io/qameta/allure/test/RunUtilsTest.java new file mode 100644 index 000000000..265ae9f9f --- /dev/null +++ b/allure-java-commons-test/src/test/java/io/qameta/allure/test/RunUtilsTest.java @@ -0,0 +1,60 @@ +/* + * Copyright 2016-2026 Qameta Software Inc + * + * 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 io.qameta.allure.test; + +import io.qameta.allure.Allure; +import io.qameta.allure.model.Status; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RunUtilsTest { + + @Test + void shouldCaptureFailureStatusWithinSyntheticTestContext() { + final AllureResults results = Allure.step("Execute a synthetic test context that raises an assertion error", () -> RunUtils.runWithinTestContext(() -> { + throw new AssertionError("boom"); + }) + ); + + Allure.step("Verify the captured synthetic test result is marked as failed", () -> { + assertEquals(1, results.getTestResults().size()); + assertEquals(Status.FAILED, results.getTestResults().get(0).getStatus()); + assertTrue(results.getTestResults().get(0).getStatusDetails().getMessage().contains("boom")); + }); + } + + @Test + void shouldAttachNestedRunArtifactsToOuterLifecycle() { + final AllureResults results = Allure + .step("Execute a nested synthetic run and capture its emitted attachments", () -> RunUtils.runWithinTestContext(() -> RunUtils.runWithinTestContext(() -> { + }) + ) + ); + + Allure.addAttachment("nested-attachment-keys", String.join("\n", results.getAttachments().keySet())); + Allure.step("Verify the outer lifecycle receives serialized artifacts from the nested run", () -> { + assertFalse(results.getAttachments().isEmpty()); + assertTrue( + results.getAttachments().values().stream() + .map(bytes -> new String(bytes, java.nio.charset.StandardCharsets.UTF_8)) + .anyMatch(body -> body.contains("\"uuid\"")) + ); + }); + } +} diff --git a/allure-java-commons-test/src/test/java/io/qameta/allure/test/TestUtilitiesTest.java b/allure-java-commons-test/src/test/java/io/qameta/allure/test/TestUtilitiesTest.java new file mode 100644 index 000000000..2ecb62921 --- /dev/null +++ b/allure-java-commons-test/src/test/java/io/qameta/allure/test/TestUtilitiesTest.java @@ -0,0 +1,65 @@ +/* + * Copyright 2016-2026 Qameta Software Inc + * + * 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 io.qameta.allure.test; + +import io.github.benas.randombeans.api.EnhancedRandom; +import io.qameta.allure.Allure; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TestUtilitiesTest { + + @Test + void shouldGenerateStableThreadLocalRandomPerThread() throws Exception { + final EnhancedRandom mainThread = ThreadLocalEnhancedRandom.current(); + final AtomicReference workerThread = new AtomicReference<>(); + final Thread thread = new Thread( + () -> workerThread.set(ThreadLocalEnhancedRandom.current()) + ); + + Allure.step("Resolve thread-local random generators on two threads and compare their identities", () -> { + thread.start(); + thread.join(); + Allure.addAttachment( + "thread-local-random-identities", + "main=" + System.identityHashCode(mainThread) + + "\nworker=" + System.identityHashCode(workerThread.get()) + ); + assertSame(mainThread, ThreadLocalEnhancedRandom.current()); + assertNotSame(mainThread, workerThread.get()); + }); + } + + @Test + void shouldGenerateExpectedRandomTestDataShapes() { + final String name = TestData.randomName(); + final String id = TestData.randomId(); + final String value = TestData.randomString(16); + + assertEquals(10, name.length()); + assertEquals(10, id.length()); + assertEquals(16, value.length()); + assertTrue(name.matches("[A-Za-z]+")); + assertTrue(id.matches("[A-Za-z0-9]+")); + assertTrue(value.matches("[A-Za-z0-9]+")); + } +} diff --git a/allure-java-commons-test/src/test/resources/allure.properties b/allure-java-commons-test/src/test/resources/allure.properties new file mode 100644 index 000000000..c1b2f8a0e --- /dev/null +++ b/allure-java-commons-test/src/test/resources/allure.properties @@ -0,0 +1,3 @@ +allure.results.directory=build/allure-results +allure.label.epic=#project.description# +allure.label.module=allure-java-commons-test diff --git a/allure-java-commons/build.gradle.kts b/allure-java-commons/build.gradle.kts index 65c5ae82d..34a9a2ed3 100644 --- a/allure-java-commons/build.gradle.kts +++ b/allure-java-commons/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - id("com.github.johnrengelman.shadow") + id("io.github.goooler.shadow") } description = "Allure Java Commons" diff --git a/allure-java-commons/src/main/java/io/qameta/allure/Allure.java b/allure-java-commons/src/main/java/io/qameta/allure/Allure.java index 21c90d2fa..6b9d32a82 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/Allure.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/Allure.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -45,7 +45,7 @@ /** * The class contains some useful methods to work with {@link AllureLifecycle}. */ -@SuppressWarnings({"PMD.ClassNamingConventions", "PMD.ExcessivePublicCount", "PMD.TooManyMethods"}) +@SuppressWarnings("PMD.TooManyMethods") public final class Allure { private static final String TXT_EXTENSION = ".txt"; @@ -182,11 +182,12 @@ public static T step(final ThrowableContextRunnable runnable getLifecycle().updateStep(uuid, step -> step.setStatus(Status.PASSED)); return result; } catch (Throwable throwable) { - getLifecycle().updateStep(s -> s - .setStatus(getStatus(throwable).orElse(Status.BROKEN)) - .setStatusDetails(getStatusDetails(throwable).orElse(null))); - ExceptionUtils.sneakyThrow(throwable); - return null; + getLifecycle().updateStep( + s -> s + .setStatus(getStatus(throwable).orElse(Status.BROKEN)) + .setStatusDetails(getStatusDetails(throwable).orElse(null)) + ); + throw ExceptionUtils.sneakyThrow(throwable); } finally { getLifecycle().stopStep(uuid); } @@ -454,31 +455,29 @@ public static void addAttachment(final String name, final InputStream content) { getLifecycle().addAttachment(name, null, null, content); } - @SuppressWarnings("PMD.UseObjectForClearerAPI") public static void addAttachment(final String name, final String type, final InputStream content, final String fileExtension) { getLifecycle().addAttachment(name, type, fileExtension, content); } public static CompletableFuture addByteAttachmentAsync( - final String name, final String type, final Supplier body) { + final String name, final String type, final Supplier body) { return addByteAttachmentAsync(name, type, "", body); } public static CompletableFuture addByteAttachmentAsync( - final String name, final String type, final String fileExtension, final Supplier body) { + final String name, final String type, final String fileExtension, final Supplier body) { final String source = getLifecycle().prepareAttachment(name, type, fileExtension); - return supplyAsync(body).whenComplete((result, ex) -> - getLifecycle().writeAttachment(source, new ByteArrayInputStream(result))); + return supplyAsync(body).whenComplete((result, ex) -> getLifecycle().writeAttachment(source, new ByteArrayInputStream(result))); } public static CompletableFuture addStreamAttachmentAsync( - final String name, final String type, final Supplier body) { + final String name, final String type, final Supplier body) { return addStreamAttachmentAsync(name, type, "", body); } public static CompletableFuture addStreamAttachmentAsync( - final String name, final String type, final String fileExtension, final Supplier body) { + final String name, final String type, final String fileExtension, final Supplier body) { final String source = lifecycle.prepareAttachment(name, type, fileExtension); return supplyAsync(body).whenComplete((result, ex) -> lifecycle.writeAttachment(source, result)); } diff --git a/allure-java-commons/src/main/java/io/qameta/allure/AllureConstants.java b/allure-java-commons/src/main/java/io/qameta/allure/AllureConstants.java index 7e432af99..7d932178e 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/AllureConstants.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/AllureConstants.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,7 +19,7 @@ * @author @author charlie (Dmitry Baev baev@qameta.io) * @since 1.0-BETA1 */ -@SuppressWarnings({"unused", "PMD.ClassNamingConventions"}) +@SuppressWarnings({"unused"}) public final class AllureConstants { public static final String TEST_RESULT_FILE_SUFFIX = "-result.json"; diff --git a/allure-java-commons/src/main/java/io/qameta/allure/AllureId.java b/allure-java-commons/src/main/java/io/qameta/allure/AllureId.java index cd5786aac..6e84162c5 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/AllureId.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/AllureId.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-java-commons/src/main/java/io/qameta/allure/AllureLifecycle.java b/allure-java-commons/src/main/java/io/qameta/allure/AllureLifecycle.java index 5b9e0065f..46e7d4479 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/AllureLifecycle.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/AllureLifecycle.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -49,7 +49,7 @@ /** * The class contains Allure context and methods to change it. */ -@SuppressWarnings({"PMD.TooManyMethods", "unused"}) +@SuppressWarnings({"PMD.AvoidSynchronizedStatement", "PMD.TooManyMethods"}) public class AllureLifecycle { private static final Logger LOGGER = LoggerFactory.getLogger(AllureLifecycle.class); @@ -596,7 +596,6 @@ public void addAttachment(final String name, final String type, * @param fileExtension the attachment file extension * @return the source of added attachment */ - @SuppressWarnings({"PMD.NullAssignment", "PMD.UseObjectForClearerAPI"}) public String prepareAttachment(final String name, final String type, final String fileExtension) { final String extension = Optional.ofNullable(fileExtension) .filter(ext -> !ext.isEmpty()) @@ -641,7 +640,13 @@ private boolean isEmpty(final String s) { private static FileSystemResultsWriter getDefaultWriter() { final Properties properties = PropertiesUtils.loadAllureProperties(); final String path = properties.getProperty("allure.results.directory", "allure-results"); - return new FileSystemResultsWriter(Paths.get(path)); + final boolean cleanBeforeRun = Boolean.parseBoolean( + properties.getProperty("allure.results.clean.before.run", "false") + ); + final boolean cleanOnlyOnce = Boolean.parseBoolean( + properties.getProperty("allure.results.clean.only.once", "true") + ); + return new FileSystemResultsWriter(Paths.get(path), cleanBeforeRun, cleanOnlyOnce); } private static LifecycleNotifier getDefaultNotifier() { diff --git a/allure-java-commons/src/main/java/io/qameta/allure/AllureResultsWriteException.java b/allure-java-commons/src/main/java/io/qameta/allure/AllureResultsWriteException.java index ca3b92180..1b1299a49 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/AllureResultsWriteException.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/AllureResultsWriteException.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-java-commons/src/main/java/io/qameta/allure/AllureResultsWriter.java b/allure-java-commons/src/main/java/io/qameta/allure/AllureResultsWriter.java index e997fd492..5d191ba1e 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/AllureResultsWriter.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/AllureResultsWriter.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-java-commons/src/main/java/io/qameta/allure/Attachment.java b/allure-java-commons/src/main/java/io/qameta/allure/Attachment.java index 848ba4f4c..9ca7c51d8 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/Attachment.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/Attachment.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-java-commons/src/main/java/io/qameta/allure/Description.java b/allure-java-commons/src/main/java/io/qameta/allure/Description.java index c02c4405e..7c879eade 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/Description.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/Description.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,7 +21,7 @@ import java.lang.annotation.Target; /** - * Annotation that allows to attach a description for a test. + * Annotation that allows to attach a description for a test. */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) @@ -35,11 +35,12 @@ String value() default ""; /** - * Use annotated method's javadoc to extract description that - * supports html markdown. + * Use annotated method's javadoc to extract a safe markdown/plain-text description. * * @return boolean flag to enable description extraction from javadoc. + * @deprecated use {@link Description} without value specified instead. */ + @Deprecated boolean useJavaDoc() default false; } diff --git a/allure-java-commons/src/main/java/io/qameta/allure/Epic.java b/allure-java-commons/src/main/java/io/qameta/allure/Epic.java index c6515b41e..5d9edb8a0 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/Epic.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/Epic.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-java-commons/src/main/java/io/qameta/allure/Epics.java b/allure-java-commons/src/main/java/io/qameta/allure/Epics.java index 8e462ca77..9ea7c0801 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/Epics.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/Epics.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-java-commons/src/main/java/io/qameta/allure/Feature.java b/allure-java-commons/src/main/java/io/qameta/allure/Feature.java index b340558de..5161a2f6b 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/Feature.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/Feature.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-java-commons/src/main/java/io/qameta/allure/Features.java b/allure-java-commons/src/main/java/io/qameta/allure/Features.java index 4c6795e79..093b0a747 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/Features.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/Features.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-java-commons/src/main/java/io/qameta/allure/FileSystemResultsWriter.java b/allure-java-commons/src/main/java/io/qameta/allure/FileSystemResultsWriter.java index 3e361b07b..14216c0a7 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/FileSystemResultsWriter.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/FileSystemResultsWriter.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,25 +19,46 @@ import io.qameta.allure.internal.Allure2ModelJackson; import io.qameta.allure.model.TestResult; import io.qameta.allure.model.TestResultContainer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Comparator; import java.util.Objects; import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Stream; /** * @author charlie (Dmitry Baev). */ public class FileSystemResultsWriter implements AllureResultsWriter { + private static final Logger LOGGER = LoggerFactory.getLogger(FileSystemResultsWriter.class); + private final Path outputDirectory; private final ObjectMapper mapper; + private final boolean cleanBeforeRun; + + private final boolean cleanOnlyOnce; + + private final AtomicBoolean cleaned = new AtomicBoolean(false); + public FileSystemResultsWriter(final Path outputDirectory) { + this(outputDirectory, false, true); + } + + public FileSystemResultsWriter(final Path outputDirectory, + final boolean cleanBeforeRun, + final boolean cleanOnlyOnce) { this.outputDirectory = outputDirectory; + this.cleanBeforeRun = cleanBeforeRun; + this.cleanOnlyOnce = cleanOnlyOnce; this.mapper = Allure2ModelJackson.createMapper(); } @@ -46,7 +67,7 @@ public void write(final TestResult testResult) { final String testResultName = Objects.isNull(testResult.getUuid()) ? generateTestResultName() : generateTestResultName(testResult.getUuid()); - createDirectories(outputDirectory); + ensureInitialized(); final Path file = outputDirectory.resolve(testResultName); try { mapper.writeValue(file.toFile(), testResult); @@ -60,7 +81,7 @@ public void write(final TestResultContainer testResultContainer) { final String testResultContainerName = Objects.isNull(testResultContainer.getUuid()) ? generateTestResultContainerName() : generateTestResultContainerName(testResultContainer.getUuid()); - createDirectories(outputDirectory); + ensureInitialized(); final Path file = outputDirectory.resolve(testResultContainerName); try { mapper.writeValue(file.toFile(), testResultContainer); @@ -71,7 +92,7 @@ public void write(final TestResultContainer testResultContainer) { @Override public void write(final String source, final InputStream attachment) { - createDirectories(outputDirectory); + ensureInitialized(); final Path file = outputDirectory.resolve(source); try (InputStream is = attachment) { Files.copy(is, file); @@ -88,6 +109,35 @@ private void createDirectories(final Path directory) { } } + private void ensureInitialized() { + createDirectories(outputDirectory); + if (cleanBeforeRun) { + final boolean shouldClean = !cleanOnlyOnce || cleaned.compareAndSet(false, true); + if (shouldClean) { + cleanDirectoryContents(outputDirectory); + } + } + } + + private void cleanDirectoryContents(final Path directory) { + if (!Files.exists(directory)) { + return; + } + try (Stream stream = Files.walk(directory)) { + stream.sorted(Comparator.reverseOrder()) + .filter(path -> !path.equals(directory)) + .forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + LOGGER.warn("Failed to delete {} during directory cleanup", path, e); + } + }); + } catch (IOException e) { + LOGGER.warn("Failed to clean directory contents: {}", directory, e); + } + } + protected static String generateTestResultName() { return generateTestResultName(UUID.randomUUID().toString()); } diff --git a/allure-java-commons/src/main/java/io/qameta/allure/Flaky.java b/allure-java-commons/src/main/java/io/qameta/allure/Flaky.java index 3d6748826..3914c435b 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/Flaky.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/Flaky.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-java-commons/src/main/java/io/qameta/allure/Issue.java b/allure-java-commons/src/main/java/io/qameta/allure/Issue.java index f795897c7..f909aad46 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/Issue.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/Issue.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-java-commons/src/main/java/io/qameta/allure/Issues.java b/allure-java-commons/src/main/java/io/qameta/allure/Issues.java index 2ab62c75b..1f2c29f56 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/Issues.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/Issues.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-java-commons/src/main/java/io/qameta/allure/LabelAnnotation.java b/allure-java-commons/src/main/java/io/qameta/allure/LabelAnnotation.java index 294108077..682ba2d24 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/LabelAnnotation.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/LabelAnnotation.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-java-commons/src/main/java/io/qameta/allure/LabelAnnotations.java b/allure-java-commons/src/main/java/io/qameta/allure/LabelAnnotations.java index 8afd77f89..75f75b1d1 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/LabelAnnotations.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/LabelAnnotations.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-java-commons/src/main/java/io/qameta/allure/Lead.java b/allure-java-commons/src/main/java/io/qameta/allure/Lead.java index 760f61bc2..27f53c05a 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/Lead.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/Lead.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-java-commons/src/main/java/io/qameta/allure/Link.java b/allure-java-commons/src/main/java/io/qameta/allure/Link.java index b0b4dacb6..8be0fcf1f 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/Link.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/Link.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-java-commons/src/main/java/io/qameta/allure/LinkAnnotation.java b/allure-java-commons/src/main/java/io/qameta/allure/LinkAnnotation.java index e646de636..0120765fe 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/LinkAnnotation.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/LinkAnnotation.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-java-commons/src/main/java/io/qameta/allure/LinkAnnotations.java b/allure-java-commons/src/main/java/io/qameta/allure/LinkAnnotations.java index 50428d1c8..a1a2ccf63 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/LinkAnnotations.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/LinkAnnotations.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-java-commons/src/main/java/io/qameta/allure/Links.java b/allure-java-commons/src/main/java/io/qameta/allure/Links.java index 4e0c977d9..76f1f7ded 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/Links.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/Links.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-java-commons/src/main/java/io/qameta/allure/Muted.java b/allure-java-commons/src/main/java/io/qameta/allure/Muted.java index 16ac6f222..90c1ff856 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/Muted.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/Muted.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-java-commons/src/main/java/io/qameta/allure/Owner.java b/allure-java-commons/src/main/java/io/qameta/allure/Owner.java index d0eea5eb4..7101a5b85 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/Owner.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/Owner.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-java-commons/src/main/java/io/qameta/allure/Param.java b/allure-java-commons/src/main/java/io/qameta/allure/Param.java index 33e34f5bc..14a374de6 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/Param.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/Param.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-java-commons/src/main/java/io/qameta/allure/Severity.java b/allure-java-commons/src/main/java/io/qameta/allure/Severity.java index 767bc0b90..ccfb284dc 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/Severity.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/Severity.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-java-commons/src/main/java/io/qameta/allure/SeverityLevel.java b/allure-java-commons/src/main/java/io/qameta/allure/SeverityLevel.java index eb8e3a0b6..1abd896a6 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/SeverityLevel.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/SeverityLevel.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-java-commons/src/main/java/io/qameta/allure/Step.java b/allure-java-commons/src/main/java/io/qameta/allure/Step.java index 1fec2ab71..83e62af92 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/Step.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/Step.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-java-commons/src/main/java/io/qameta/allure/Stories.java b/allure-java-commons/src/main/java/io/qameta/allure/Stories.java index e9f376d94..5d3528149 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/Stories.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/Stories.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-java-commons/src/main/java/io/qameta/allure/Story.java b/allure-java-commons/src/main/java/io/qameta/allure/Story.java index bf74c5ceb..8d19605d9 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/Story.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/Story.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-java-commons/src/main/java/io/qameta/allure/TmsLink.java b/allure-java-commons/src/main/java/io/qameta/allure/TmsLink.java index d5e839017..273354d04 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/TmsLink.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/TmsLink.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-java-commons/src/main/java/io/qameta/allure/TmsLinks.java b/allure-java-commons/src/main/java/io/qameta/allure/TmsLinks.java index 1253436b4..f862c3aa7 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/TmsLinks.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/TmsLinks.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-java-commons/src/main/java/io/qameta/allure/aspects/AttachmentsAspects.java b/allure-java-commons/src/main/java/io/qameta/allure/aspects/AttachmentsAspects.java index 617eae770..6db4164a7 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/aspects/AttachmentsAspects.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/aspects/AttachmentsAspects.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -39,8 +39,7 @@ @Aspect public class AttachmentsAspects { - private static final InheritableThreadLocal LIFECYCLE = - new InheritableThreadLocal() { + private static final InheritableThreadLocal LIFECYCLE = new InheritableThreadLocal() { @Override protected AllureLifecycle initialValue() { return Allure.getLifecycle(); @@ -70,13 +69,18 @@ public void anyMethod() { * @param joinPoint the join point to process. * @param result the returned value. */ - @AfterReturning(pointcut = "anyMethod() && withAttachmentAnnotation()", returning = "result") + @AfterReturning( + pointcut = "anyMethod() && withAttachmentAnnotation()", + returning = "result" + ) public void attachment(final JoinPoint joinPoint, final Object result) { final MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature(); final Attachment attachment = methodSignature.getMethod() .getAnnotation(Attachment.class); - final byte[] bytes = (result instanceof byte[]) ? (byte[]) result : Objects.toString(result) - .getBytes(StandardCharsets.UTF_8); + final byte[] bytes = (result instanceof byte[]) + ? (byte[]) result + : Objects.toString(result) + .getBytes(StandardCharsets.UTF_8); final String name = attachment.value().isEmpty() ? methodSignature.getName() diff --git a/allure-java-commons/src/main/java/io/qameta/allure/aspects/StepsAspects.java b/allure-java-commons/src/main/java/io/qameta/allure/aspects/StepsAspects.java index 407c82d40..77e29082a 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/aspects/StepsAspects.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/aspects/StepsAspects.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -45,8 +45,7 @@ @Aspect public class StepsAspects { - private static final InheritableThreadLocal LIFECYCLE - = new InheritableThreadLocal() { + private static final InheritableThreadLocal LIFECYCLE = new InheritableThreadLocal() { @Override protected AllureLifecycle initialValue() { return Allure.getLifecycle(); @@ -79,11 +78,16 @@ public void stepStart(final JoinPoint joinPoint) { getLifecycle().startStep(uuid, result); } - @AfterThrowing(pointcut = "anyMethod() && withStepAnnotation()", throwing = "e") + @AfterThrowing( + pointcut = "anyMethod() && withStepAnnotation()", + throwing = "e" + ) public void stepFailed(final Throwable e) { - getLifecycle().updateStep(s -> s - .setStatus(getStatus(e).orElse(Status.BROKEN)) - .setStatusDetails(getStatusDetails(e).orElse(null))); + getLifecycle().updateStep( + s -> s + .setStatus(getStatus(e).orElse(Status.BROKEN)) + .setStatusDetails(getStatusDetails(e).orElse(null)) + ); getLifecycle().stopStep(); } diff --git a/allure-java-commons/src/main/java/io/qameta/allure/internal/Allure2ModelJackson.java b/allure-java-commons/src/main/java/io/qameta/allure/internal/Allure2ModelJackson.java index 6613230ca..93d46e354 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/internal/Allure2ModelJackson.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/internal/Allure2ModelJackson.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -37,7 +37,6 @@ * * @author baev (Dmitry Baev). */ -@SuppressWarnings("PMD.ClassNamingConventions") public final class Allure2ModelJackson { public static final String INDENT_OUTPUT_PROPERTY_NAME = "allure.results.indentOutput"; @@ -53,10 +52,11 @@ public static ObjectMapper createMapper() { .serializationInclusion(NON_NULL) .configure(INDENT_OUTPUT, Boolean.getBoolean(INDENT_OUTPUT_PROPERTY_NAME)) .build() - .registerModule(new SimpleModule() - .addSerializer(Status.class, new StatusSerializer()) - .addSerializer(Stage.class, new StageSerializer()) - .addSerializer(Parameter.Mode.class, new ParameterModeSerializer()) + .registerModule( + new SimpleModule() + .addSerializer(Status.class, new StatusSerializer()) + .addSerializer(Stage.class, new StageSerializer()) + .addSerializer(Parameter.Mode.class, new ParameterModeSerializer()) ); } @@ -71,7 +71,8 @@ protected ParameterModeSerializer() { @Override public void serialize(final Parameter.Mode value, final JsonGenerator gen, - final SerializerProvider provider) throws IOException { + final SerializerProvider provider) + throws IOException { gen.writeString(value.name().toLowerCase(Locale.ENGLISH)); } } @@ -87,7 +88,8 @@ protected StageSerializer() { @Override public void serialize(final Stage value, final JsonGenerator gen, - final SerializerProvider provider) throws IOException { + final SerializerProvider provider) + throws IOException { gen.writeString(value.name().toLowerCase(Locale.ENGLISH)); } } @@ -103,7 +105,8 @@ protected StatusSerializer() { @Override public void serialize(final Status value, final JsonGenerator gen, - final SerializerProvider provider) throws IOException { + final SerializerProvider provider) + throws IOException { gen.writeString(value.name().toLowerCase(Locale.ENGLISH)); } } diff --git a/allure-java-commons/src/main/java/io/qameta/allure/internal/AllureStorage.java b/allure-java-commons/src/main/java/io/qameta/allure/internal/AllureStorage.java index a72614684..458d06941 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/internal/AllureStorage.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/internal/AllureStorage.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -55,9 +55,9 @@ public Optional getStep(final String uuid) { } public Optional get(final String uuid, final Class clazz) { + Objects.requireNonNull(uuid, "Can't get item from storage: uuid can't be null"); lock.readLock().lock(); try { - Objects.requireNonNull(uuid, "Can't get item from storage: uuid can't be null"); return Optional.ofNullable(storage.get(uuid)) .filter(clazz::isInstance) .map(clazz::cast); @@ -67,9 +67,9 @@ public Optional get(final String uuid, final Class clazz) { } public T put(final String uuid, final T item) { + Objects.requireNonNull(uuid, "Can't put item to storage: uuid can't be null"); lock.writeLock().lock(); try { - Objects.requireNonNull(uuid, "Can't put item to storage: uuid can't be null"); storage.put(uuid, item); return item; } finally { @@ -78,9 +78,9 @@ public T put(final String uuid, final T item) { } public void remove(final String uuid) { + Objects.requireNonNull(uuid, "Can't remove item from storage: uuid can't be null"); lock.writeLock().lock(); try { - Objects.requireNonNull(uuid, "Can't remove item from storage: uuid can't be null"); storage.remove(uuid); } finally { lock.writeLock().unlock(); diff --git a/allure-java-commons/src/main/java/io/qameta/allure/internal/AllureThreadContext.java b/allure-java-commons/src/main/java/io/qameta/allure/internal/AllureThreadContext.java index cc7236a79..51dd87416 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/internal/AllureThreadContext.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/internal/AllureThreadContext.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,6 +15,7 @@ */ package io.qameta.allure.internal; +import java.util.Deque; import java.util.LinkedList; import java.util.Objects; import java.util.Optional; @@ -32,7 +33,7 @@ public class AllureThreadContext { * Returns last (most recent) uuid. */ public Optional getCurrent() { - final LinkedList uuids = context.get(); + final Deque uuids = context.get(); return uuids.isEmpty() ? Optional.empty() : Optional.of(uuids.getFirst()); @@ -42,7 +43,7 @@ public Optional getCurrent() { * Returns first (oldest) uuid. */ public Optional getRoot() { - final LinkedList uuids = context.get(); + final Deque uuids = context.get(); return uuids.isEmpty() ? Optional.empty() : Optional.of(uuids.getLast()); @@ -62,7 +63,7 @@ public void start(final String uuid) { * @return removed uuid. */ public Optional stop() { - final LinkedList uuids = context.get(); + final Deque uuids = context.get(); if (!uuids.isEmpty()) { return Optional.of(uuids.pop()); } @@ -79,15 +80,15 @@ public void clear() { /** * Thread local context that stores information about not finished tests and steps. */ - private static class Context extends InheritableThreadLocal> { + private static final class Context extends InheritableThreadLocal> { @Override - public LinkedList initialValue() { + public Deque initialValue() { return new LinkedList<>(); } @Override - protected LinkedList childValue(final LinkedList parentStepContext) { + protected Deque childValue(final Deque parentStepContext) { return new LinkedList<>(parentStepContext); } diff --git a/allure-java-commons/src/main/java/io/qameta/allure/listener/ContainerLifecycleListener.java b/allure-java-commons/src/main/java/io/qameta/allure/listener/ContainerLifecycleListener.java index 73db041ad..96bb63385 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/listener/ContainerLifecycleListener.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/listener/ContainerLifecycleListener.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-java-commons/src/main/java/io/qameta/allure/listener/FixtureLifecycleListener.java b/allure-java-commons/src/main/java/io/qameta/allure/listener/FixtureLifecycleListener.java index cb2e70558..035eb845b 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/listener/FixtureLifecycleListener.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/listener/FixtureLifecycleListener.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-java-commons/src/main/java/io/qameta/allure/listener/LifecycleListener.java b/allure-java-commons/src/main/java/io/qameta/allure/listener/LifecycleListener.java index b6930873b..3029dad9d 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/listener/LifecycleListener.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/listener/LifecycleListener.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-java-commons/src/main/java/io/qameta/allure/listener/LifecycleNotifier.java b/allure-java-commons/src/main/java/io/qameta/allure/listener/LifecycleNotifier.java index 92f56097a..7684b66d8 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/listener/LifecycleNotifier.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/listener/LifecycleNotifier.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -29,8 +29,12 @@ * @since 2.0 */ @SuppressWarnings("PMD.TooManyMethods") -public class LifecycleNotifier implements ContainerLifecycleListener, - TestLifecycleListener, FixtureLifecycleListener, StepLifecycleListener { +public class LifecycleNotifier + implements + ContainerLifecycleListener, + TestLifecycleListener, + FixtureLifecycleListener, + StepLifecycleListener { private static final Logger LOGGER = LoggerFactory.getLogger(LifecycleNotifier.class); @@ -52,7 +56,6 @@ public LifecycleNotifier(final List containerListene this.stepListeners = stepListeners; } - @Override public void beforeTestSchedule(final TestResult result) { runSafely(testListeners, TestLifecycleListener::beforeTestSchedule, result); diff --git a/allure-java-commons/src/main/java/io/qameta/allure/listener/StepLifecycleListener.java b/allure-java-commons/src/main/java/io/qameta/allure/listener/StepLifecycleListener.java index f59b5b812..277a4b0fb 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/listener/StepLifecycleListener.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/listener/StepLifecycleListener.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-java-commons/src/main/java/io/qameta/allure/listener/TestLifecycleListener.java b/allure-java-commons/src/main/java/io/qameta/allure/listener/TestLifecycleListener.java index 12a0b1ea6..c4d8e9c8a 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/listener/TestLifecycleListener.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/listener/TestLifecycleListener.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-java-commons/src/main/java/io/qameta/allure/util/AnnotationUtils.java b/allure-java-commons/src/main/java/io/qameta/allure/util/AnnotationUtils.java index b575111e0..1e6ad1246 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/util/AnnotationUtils.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/util/AnnotationUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -141,9 +141,9 @@ public static Set +And b is +When I add a to b +Then result is + +Examples: +| a | b | result | +| 1 | 3 | 4 | +| 2 | 4 | 6 | diff --git a/allure-jbehave5/src/test/resources/stories/failed.story b/allure-jbehave5/src/test/resources/stories/failed.story new file mode 100644 index 000000000..5b33b75d7 --- /dev/null +++ b/allure-jbehave5/src/test/resources/stories/failed.story @@ -0,0 +1,6 @@ +Scenario: Add a to b + +Given a is 5 +And b is 10 +When I add a to b +Then result is 123 diff --git a/allure-jbehave5/src/test/resources/stories/given.story b/allure-jbehave5/src/test/resources/stories/given.story new file mode 100644 index 000000000..2fc2feb0c --- /dev/null +++ b/allure-jbehave5/src/test/resources/stories/given.story @@ -0,0 +1,5 @@ +Scenario: Add a to b +GivenStories:stories/precondition-a.story,stories/precondition-b.story + +When I add a to b +Then result is 15 diff --git a/allure-jbehave5/src/test/resources/stories/long.story b/allure-jbehave5/src/test/resources/stories/long.story new file mode 100644 index 000000000..88f529253 --- /dev/null +++ b/allure-jbehave5/src/test/resources/stories/long.story @@ -0,0 +1,14 @@ +Scenario: Add a to b + +Given a is 5 +And b is 10 +When I add a to b +Then result is 15 +Then result is 15 +When I add a to b +Then result is 20 +Then result is 21 +Then result is 22 +Then result is 23 +When I add a to b +Then result is 25 diff --git a/allure-jbehave5/src/test/resources/stories/multiply.story b/allure-jbehave5/src/test/resources/stories/multiply.story new file mode 100644 index 000000000..4aecb435b --- /dev/null +++ b/allure-jbehave5/src/test/resources/stories/multiply.story @@ -0,0 +1,20 @@ +Scenario: First + +Given a is 2 +And b is 2 +When I add a to b +Then result is 4 + +Scenario: Second + +Given a is 3 +And b is 7 +When I add a to b +Then result is 10 + +Scenario: Third + +Given a is 5 +And b is -5 +When I add a to b +Then result is 0 diff --git a/allure-jbehave5/src/test/resources/stories/precondition-a.story b/allure-jbehave5/src/test/resources/stories/precondition-a.story new file mode 100644 index 000000000..9defc653a --- /dev/null +++ b/allure-jbehave5/src/test/resources/stories/precondition-a.story @@ -0,0 +1,3 @@ +Scenario: Init a + +Given a is 5 diff --git a/allure-jbehave5/src/test/resources/stories/precondition-b.story b/allure-jbehave5/src/test/resources/stories/precondition-b.story new file mode 100644 index 000000000..30af31190 --- /dev/null +++ b/allure-jbehave5/src/test/resources/stories/precondition-b.story @@ -0,0 +1,3 @@ +Scenario: Init b + +Given b is 10 diff --git a/allure-jbehave5/src/test/resources/stories/runtimeapi.story b/allure-jbehave5/src/test/resources/stories/runtimeapi.story new file mode 100644 index 000000000..305c5ae1c --- /dev/null +++ b/allure-jbehave5/src/test/resources/stories/runtimeapi.story @@ -0,0 +1,3 @@ +Scenario: Runtime API + +Given runtime api diff --git a/allure-jbehave5/src/test/resources/stories/simple.story b/allure-jbehave5/src/test/resources/stories/simple.story new file mode 100644 index 000000000..b76d831aa --- /dev/null +++ b/allure-jbehave5/src/test/resources/stories/simple.story @@ -0,0 +1,6 @@ +Scenario: Add a to b + +Given a is 5 +And b is 10 +When I add a to b +Then result is 15 diff --git a/allure-jbehave5/src/test/resources/stories/undefined.story b/allure-jbehave5/src/test/resources/stories/undefined.story new file mode 100644 index 000000000..5a193a746 --- /dev/null +++ b/allure-jbehave5/src/test/resources/stories/undefined.story @@ -0,0 +1,2 @@ +Scenario: Step is not implemented +Given hello my friend diff --git a/allure-jooq/build.gradle.kts b/allure-jooq/build.gradle.kts new file mode 100644 index 000000000..5f1709107 --- /dev/null +++ b/allure-jooq/build.gradle.kts @@ -0,0 +1,36 @@ +description = "Allure JOOQ Integration" + +val jooqVersion = "3.20.11" + +dependencies { + api(project(":allure-java-commons")) + compileOnly("org.jooq:jooq:${jooqVersion}") + testImplementation("io.zonky.test:embedded-postgres:2.2.0") + testImplementation("org.assertj:assertj-core") + testImplementation("org.jooq:jooq:${jooqVersion}") + testImplementation("org.junit.jupiter:junit-jupiter-api") + testImplementation("org.mockito:mockito-core") + testImplementation("org.slf4j:slf4j-simple") + testImplementation(platform("io.zonky.test.postgres:embedded-postgres-binaries-bom:18.1.0")) + testImplementation(project(":allure-assertj")) + testImplementation(project(":allure-java-commons-test")) + testImplementation(project(":allure-junit-platform")) + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") + testRuntimeOnly("org.postgresql:postgresql:42.7.7") +} + +tasks.compileJava { + options.release.set(17) +} + +tasks.jar { + manifest { + attributes(mapOf( + "Automatic-Module-Name" to "io.qameta.allure.jooq" + )) + } +} + +tasks.test { + useJUnitPlatform() +} diff --git a/allure-jooq/src/main/java/io/qameta/allure/jooq/AllureJooq.java b/allure-jooq/src/main/java/io/qameta/allure/jooq/AllureJooq.java new file mode 100644 index 000000000..ae7538cc8 --- /dev/null +++ b/allure-jooq/src/main/java/io/qameta/allure/jooq/AllureJooq.java @@ -0,0 +1,141 @@ +/* + * Copyright 2016-2026 Qameta Software Inc + * + * 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 io.qameta.allure.jooq; + +import io.qameta.allure.Allure; +import io.qameta.allure.AllureLifecycle; +import io.qameta.allure.model.Status; +import io.qameta.allure.model.StepResult; +import org.jooq.ExecuteContext; +import org.jooq.ExecuteListener; +import org.jooq.Formattable; +import org.jooq.Query; +import org.jooq.Record; +import org.jooq.Routine; + +import java.util.Objects; +import java.util.UUID; + +import static java.lang.Boolean.FALSE; + +/** + * @author charlie (Dmitry Baev). + */ +public class AllureJooq implements ExecuteListener { + + private static final String STEP_UUID = "io.qameta.allure.jooq.AllureJooq.STEP_UUID"; + private static final String DO_BUFFER = "io.qameta.allure.jooq.AllureJooq.DO_BUFFER"; + + private final AllureLifecycle lifecycle; + + public AllureJooq() { + this(Allure.getLifecycle()); + } + + public AllureJooq(final AllureLifecycle lifecycle) { + this.lifecycle = lifecycle; + } + + @Override + public void renderEnd(final ExecuteContext ctx) { + if (!lifecycle.getCurrentTestCaseOrStep().isPresent()) { + return; + } + + final String stepName = stepName(ctx); + final String uuid = UUID.randomUUID().toString(); + ctx.data(STEP_UUID, uuid); + lifecycle.startStep( + uuid, new StepResult() + .setName(stepName) + ); + } + + private String stepName(final ExecuteContext ctx) { + final Query query = ctx.query(); + if (query != null) { + return ctx.dsl().renderInlined(query); + } + + final Routine routine = ctx.routine(); + if (ctx.routine() != null) { + return ctx.dsl().renderInlined(routine); + } + + final String sql = ctx.sql(); + if (Objects.nonNull(sql) && !sql.isEmpty()) { + return sql; + } + + final String[] batchSQL = ctx.batchSQL(); + if (batchSQL.length > 0 && batchSQL[batchSQL.length - 1] != null) { + return String.join("\n", batchSQL); + } + return "UNKNOWN"; + } + + @Override + public void recordEnd(final ExecuteContext ctx) { + if (ctx.recordLevel() > 0) { + return; + } + + if (!lifecycle.getCurrentTestCaseOrStep().isPresent()) { + return; + } + + final Record record = ctx.record(); + if (record != null && !FALSE.equals(ctx.data(DO_BUFFER))) { + attachResultSet(record); + } + } + + @Override + public void resultStart(final ExecuteContext ctx) { + ctx.data(DO_BUFFER, false); + } + + @Override + public void resultEnd(final ExecuteContext ctx) { + if (!lifecycle.getCurrentTestCaseOrStep().isPresent()) { + return; + } + + attachResultSet(ctx.result()); + } + + @Override + public void end(final ExecuteContext ctx) { + if (!lifecycle.getCurrentTestCaseOrStep().isPresent()) { + return; + } + + final String stepUuid = (String) ctx.data(STEP_UUID); + if (Objects.isNull(stepUuid)) { + return; + } + + lifecycle.updateStep(stepUuid, sr -> sr.setStatus(Status.PASSED)); + lifecycle.stopStep(stepUuid); + } + + private void attachResultSet(final Formattable formattable) { + if (Objects.nonNull(formattable)) { + Allure.addAttachment("ResultSet", "text/csv", formattable.formatCSV()); + } + } + +} diff --git a/allure-jooq/src/test/java/io/qameta/allure/jooq/AllureJooqTest.java b/allure-jooq/src/test/java/io/qameta/allure/jooq/AllureJooqTest.java new file mode 100644 index 000000000..799a44c41 --- /dev/null +++ b/allure-jooq/src/test/java/io/qameta/allure/jooq/AllureJooqTest.java @@ -0,0 +1,137 @@ +/* + * Copyright 2016-2026 Qameta Software Inc + * + * 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 io.qameta.allure.jooq; + +import io.qameta.allure.Allure; +import io.qameta.allure.model.Attachment; +import io.qameta.allure.model.Status; +import io.qameta.allure.model.StepResult; +import io.qameta.allure.model.TestResult; +import io.qameta.allure.test.AllureResults; +import io.zonky.test.db.postgres.embedded.EmbeddedPostgres; +import org.jooq.DSLContext; +import org.jooq.Field; +import org.jooq.Name; +import org.jooq.Record; +import org.jooq.SQLDialect; +import org.jooq.Table; +import org.jooq.impl.DSL; +import org.jooq.impl.DataSourceConnectionProvider; +import org.jooq.impl.DefaultConfiguration; +import org.jooq.impl.DefaultDSLContext; +import org.jooq.impl.SQLDataType; +import org.junit.jupiter.api.Test; + +import javax.sql.DataSource; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.function.Consumer; + +import static io.qameta.allure.test.RunUtils.runWithinTestContext; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; + +/** + * @author charlie (Dmitry Baev). + */ +class AllureJooqTest { + + @Test + void shouldSupportFetchSqlStatements() { + final AllureResults results = execute(dsl -> dsl.fetchSingle("select 1")); + + final TestResult result = results.getTestResults().get(0); + assertThat(result.getSteps()) + .extracting(StepResult::getName, StepResult::getStatus) + .containsExactly( + tuple("select 1", Status.PASSED) + ); + } + + @Test + void shouldAddResultSetsAsAttachments() { + final AllureResults results = execute(dsl -> dsl.fetchSingle("select 1 as one, 2 as two")); + final TestResult result = results.getTestResults().get(0); + final StepResult step = result.getSteps().get(0); + assertThat(step.getAttachments()) + .extracting(Attachment::getName, Attachment::getType) + .containsExactly( + tuple("ResultSet", "text/csv") + ); + + final Attachment attachment = step.getAttachments().get(0); + + final byte[] content = results.getAttachments().get(attachment.getSource()); + + assertThat(new String(content, StandardCharsets.UTF_8)) + .contains("one,two\n1,2\n"); + + } + + @Test + void shouldSupportCreateTableStatements() { + final AllureResults results = execute(dsl -> { + final Name tableName = DSL.name("first_table"); + final Field id = DSL.field("id", SQLDataType.BIGINT); + final Field name = DSL.field("name", SQLDataType.VARCHAR); + dsl.createTable(tableName) + .column(id) + .column(name) + .primaryKey(id) + .execute(); + + final Table table = DSL.table(tableName); + + dsl.insertInto(table, id, name) + .values(1L, "first") + .values(2L, "second") + .execute(); + }); + + final TestResult result = results.getTestResults().get(0); + assertThat(result.getSteps()) + .extracting(StepResult::getName, StepResult::getStatus) + .containsExactly( + tuple("create table \"first_table\" (id bigint, name varchar, primary key (id))", Status.PASSED), + tuple("insert into \"first_table\" (id, name) values (1, 'first'), (2, 'second')", Status.PASSED) + ); + } + + private static AllureResults execute(final Consumer dslContextConsumer) { + final EmbeddedPostgres.Builder builder = EmbeddedPostgres.builder(); + try (EmbeddedPostgres postgres = builder.start()) { + final DataSource dataSource = postgres.getPostgresDatabase(); + + final DataSourceConnectionProvider connectionProvider = new DataSourceConnectionProvider(dataSource); + final DefaultConfiguration configuration = new DefaultConfiguration(); + configuration.set(SQLDialect.POSTGRES); + configuration.set(connectionProvider); + + return runWithinTestContext( + () -> { + final DefaultDSLContext dsl = new DefaultDSLContext(configuration); + dslContextConsumer.accept(dsl); + }, + Allure::setLifecycle, + allureLifecycle -> configuration.setExecuteListener(new AllureJooq(allureLifecycle)) + ); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/allure-cucumber2-jvm/src/test/resources/allure.properties b/allure-jooq/src/test/resources/allure.properties similarity index 72% rename from allure-cucumber2-jvm/src/test/resources/allure.properties rename to allure-jooq/src/test/resources/allure.properties index 9c0b0a2d7..d86adbae1 100644 --- a/allure-cucumber2-jvm/src/test/resources/allure.properties +++ b/allure-jooq/src/test/resources/allure.properties @@ -1,2 +1,3 @@ allure.results.directory=build/allure-results allure.label.epic=#project.description# +allure.label.module=allure-jooq diff --git a/allure-jooq/src/test/resources/simplelogger.properties b/allure-jooq/src/test/resources/simplelogger.properties new file mode 100644 index 000000000..3ae9a5745 --- /dev/null +++ b/allure-jooq/src/test/resources/simplelogger.properties @@ -0,0 +1,2 @@ +org.slf4j.simpleLogger.log.org.jooq.Constants=warn +org.slf4j.simpleLogger.log.org.jooq.tools.LoggerListener=info diff --git a/allure-jsonunit/build.gradle.kts b/allure-jsonunit/build.gradle.kts index b449271cd..ab22c0f6e 100644 --- a/allure-jsonunit/build.gradle.kts +++ b/allure-jsonunit/build.gradle.kts @@ -4,9 +4,9 @@ val jsonUnitVersion = "2.35.0" dependencies { api(project(":allure-attachments")) + compileOnly("net.javacrumbs.json-unit:json-unit:$jsonUnitVersion") implementation("com.fasterxml.jackson.core:jackson-databind") - implementation("net.javacrumbs.json-unit:json-unit:$jsonUnitVersion") - implementation("org.apache.commons:commons-lang3") + testImplementation("net.javacrumbs.json-unit:json-unit:$jsonUnitVersion") testImplementation("org.assertj:assertj-core") testImplementation("org.junit.jupiter:junit-jupiter-api") testImplementation("org.mockito:mockito-core") diff --git a/allure-jsonunit/src/main/java/io/qameta/allure/jsonunit/AbstractJsonPatchMatcher.java b/allure-jsonunit/src/main/java/io/qameta/allure/jsonunit/AbstractJsonPatchMatcher.java index 875adf470..ba99c1b41 100644 --- a/allure-jsonunit/src/main/java/io/qameta/allure/jsonunit/AbstractJsonPatchMatcher.java +++ b/allure-jsonunit/src/main/java/io/qameta/allure/jsonunit/AbstractJsonPatchMatcher.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,18 +15,19 @@ */ package io.qameta.allure.jsonunit; -import java.math.BigDecimal; - -import org.hamcrest.Matcher; - import net.javacrumbs.jsonunit.core.Configuration; +import net.javacrumbs.jsonunit.core.ConfigurationWhen.ApplicableForPath; +import net.javacrumbs.jsonunit.core.ConfigurationWhen.PathsParam; import net.javacrumbs.jsonunit.core.Option; import net.javacrumbs.jsonunit.core.internal.Diff; import net.javacrumbs.jsonunit.core.internal.Options; import net.javacrumbs.jsonunit.core.listener.DifferenceListener; +import org.hamcrest.Matcher; + +import java.math.BigDecimal; /** - * Сontains basic matcher functionality and implementation of methods for matching configuration. + * Contains basic matcher functionality and implementation of methods for matching configuration. * * @param the type */ @@ -54,6 +55,11 @@ public T when(final Option first, final Option... next) { return (T) this; } + public T when(final PathsParam pathsParam, final ApplicableForPath... applicableForPaths) { + this.configuration = this.configuration.when(pathsParam, applicableForPaths); + return (T) this; + } + public T withOptions(final Options options) { this.configuration = configuration.withOptions(options); return (T) this; diff --git a/allure-jsonunit/src/main/java/io/qameta/allure/jsonunit/AllureConfigurableJsonMatcher.java b/allure-jsonunit/src/main/java/io/qameta/allure/jsonunit/AllureConfigurableJsonMatcher.java index 05871c70d..fa45c7030 100644 --- a/allure-jsonunit/src/main/java/io/qameta/allure/jsonunit/AllureConfigurableJsonMatcher.java +++ b/allure-jsonunit/src/main/java/io/qameta/allure/jsonunit/AllureConfigurableJsonMatcher.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,7 +25,9 @@ /** * @param the type of matcher * @see net.javacrumbs.jsonunit.ConfigurableJsonMatcher + * @deprecated Use {@link net.javacrumbs.jsonunit.ConfigurableJsonMatcher} */ +@Deprecated public interface AllureConfigurableJsonMatcher extends Matcher { AllureConfigurableJsonMatcher withTolerance(BigDecimal tolerance); diff --git a/allure-jsonunit/src/main/java/io/qameta/allure/jsonunit/DiffAttachment.java b/allure-jsonunit/src/main/java/io/qameta/allure/jsonunit/DiffAttachment.java index bd7e7b60c..055f0ce22 100644 --- a/allure-jsonunit/src/main/java/io/qameta/allure/jsonunit/DiffAttachment.java +++ b/allure-jsonunit/src/main/java/io/qameta/allure/jsonunit/DiffAttachment.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-jsonunit/src/main/java/io/qameta/allure/jsonunit/DiffModel.java b/allure-jsonunit/src/main/java/io/qameta/allure/jsonunit/DiffModel.java index 514a0215e..68772ee1e 100644 --- a/allure-jsonunit/src/main/java/io/qameta/allure/jsonunit/DiffModel.java +++ b/allure-jsonunit/src/main/java/io/qameta/allure/jsonunit/DiffModel.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/allure-jsonunit/src/main/java/io/qameta/allure/jsonunit/JsonPatchListener.java b/allure-jsonunit/src/main/java/io/qameta/allure/jsonunit/JsonPatchListener.java index df7a7dc49..2ab2941e7 100644 --- a/allure-jsonunit/src/main/java/io/qameta/allure/jsonunit/JsonPatchListener.java +++ b/allure-jsonunit/src/main/java/io/qameta/allure/jsonunit/JsonPatchListener.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,7 +20,6 @@ import net.javacrumbs.jsonunit.core.listener.Difference; import net.javacrumbs.jsonunit.core.listener.DifferenceContext; import net.javacrumbs.jsonunit.core.listener.DifferenceListener; -import org.apache.commons.lang3.StringUtils; import java.io.UncheckedIOException; import java.util.ArrayList; @@ -100,7 +99,8 @@ public DiffModel getDiffModel() { return new DiffModel( writeAsString(context.getActualSource(), "actual"), writeAsString(context.getExpectedSource(), "expected"), - getJsonPatch()); + getJsonPatch() + ); } @SuppressWarnings({"all", "unchecked"}) @@ -124,7 +124,7 @@ public String getJsonPatch() { final String field = getPath(difference); Map currentMap = jsonDiffPatch; - final String fieldWithDots = StringUtils.replace(field, "[", "."); + final String fieldWithDots = field.replace('[', '.'); final int len = fieldWithDots.length(); int left = 0; int right = 0; @@ -134,7 +134,7 @@ public String getJsonPatch() { right = len; } String fieldName = fieldWithDots.substring(left, right); - fieldName = StringUtils.remove(fieldName, "]"); + fieldName = fieldName.replaceAll("]", ""); if (right != len) { if (!fieldName.isEmpty()) { diff --git a/allure-jsonunit/src/main/java/io/qameta/allure/jsonunit/JsonPatchMatcher.java b/allure-jsonunit/src/main/java/io/qameta/allure/jsonunit/JsonPatchMatcher.java index 34d154682..a832b3d4c 100644 --- a/allure-jsonunit/src/main/java/io/qameta/allure/jsonunit/JsonPatchMatcher.java +++ b/allure-jsonunit/src/main/java/io/qameta/allure/jsonunit/JsonPatchMatcher.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,8 +17,8 @@ import io.qameta.allure.attachment.DefaultAttachmentProcessor; import io.qameta.allure.attachment.FreemarkerAttachmentRenderer; +import net.javacrumbs.jsonunit.ConfigurableJsonMatcher; import net.javacrumbs.jsonunit.core.listener.DifferenceListener; - import org.hamcrest.Description; /** @@ -28,8 +28,9 @@ * @param the type */ @SuppressWarnings("unused") -public final class JsonPatchMatcher extends AbstractJsonPatchMatcher> - implements AllureConfigurableJsonMatcher { +public final class JsonPatchMatcher extends AbstractJsonPatchMatcher> + implements + ConfigurableJsonMatcher { private final Object expected; @@ -37,7 +38,7 @@ private JsonPatchMatcher(final Object expected) { this.expected = expected; } - public static AllureConfigurableJsonMatcher jsonEquals(final Object expected) { + public static ConfigurableJsonMatcher jsonEquals(final Object expected) { return new JsonPatchMatcher(expected); } @@ -67,7 +68,9 @@ public void _dont_implement_Matcher___instead_extend_BaseMatcher_() { protected void render(final DifferenceListener listener) { final JsonPatchListener jsonDiffListener = (JsonPatchListener) listener; final DiffAttachment attachment = new DiffAttachment(jsonDiffListener.getDiffModel()); - new DefaultAttachmentProcessor().addAttachment(attachment, - new FreemarkerAttachmentRenderer("diff.ftl")); + new DefaultAttachmentProcessor().addAttachment( + attachment, + new FreemarkerAttachmentRenderer("diff.ftl") + ); } } diff --git a/allure-jsonunit/src/test/java/io/qameta/allure/jsonunit/JsonPatchListenerTest.java b/allure-jsonunit/src/test/java/io/qameta/allure/jsonunit/JsonPatchListenerTest.java index 28f3cb80e..d09adab78 100644 --- a/allure-jsonunit/src/test/java/io/qameta/allure/jsonunit/JsonPatchListenerTest.java +++ b/allure-jsonunit/src/test/java/io/qameta/allure/jsonunit/JsonPatchListenerTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -80,7 +80,6 @@ void shouldSeeEmptyForCheckAnyStringNode() { assertThat(listener.getJsonPatch()).isEqualTo("{}"); } - @Test void shouldSeeChangedStringNode() { Diff diff = Diff.create("{\"test\": \"1\"}", "{\"test\": \"2\"}", "", "", commonConfig()); @@ -149,8 +148,7 @@ void shouldSeeNullNode() { void shouldWorkWhenIgnoringArrayOrder() { Diff diff = Diff.create("{\"test\": [[1,2],[2,3]]}", "{\"test\":[[4,2],[1,2]]}", "", "", commonConfig().when(Option.IGNORING_ARRAY_ORDER)); diff.similar(); - assertThat(listener.getJsonPatch()). - isEqualTo("{\"test\":{\"0\":{\"0\":[3,4],\"_t\":\"a\"},\"_t\":\"a\"}}"); + assertThat(listener.getJsonPatch()).isEqualTo("{\"test\":{\"0\":{\"0\":[3,4],\"_t\":\"a\"},\"_t\":\"a\"}}"); } @Test diff --git a/allure-jsonunit/src/test/java/io/qameta/allure/jsonunit/JsonPatchMatcherTests.java b/allure-jsonunit/src/test/java/io/qameta/allure/jsonunit/JsonPatchMatcherTests.java index 7959ddcf9..3bfa9cf36 100644 --- a/allure-jsonunit/src/test/java/io/qameta/allure/jsonunit/JsonPatchMatcherTests.java +++ b/allure-jsonunit/src/test/java/io/qameta/allure/jsonunit/JsonPatchMatcherTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,30 +15,28 @@ */ package io.qameta.allure.jsonunit; -import static org.assertj.core.api.Assertions.assertThat; +import net.javacrumbs.jsonunit.core.Configuration; +import net.javacrumbs.jsonunit.core.Option; +import net.javacrumbs.jsonunit.core.internal.Options; +import net.javacrumbs.jsonunit.core.listener.DifferenceListener; +import org.hamcrest.Description; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import java.lang.reflect.Field; +import java.math.BigDecimal; +import java.util.function.BiConsumer; + +import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.CoreMatchers.equalTo; +import static org.mockito.ArgumentCaptor.forClass; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; -import static org.mockito.ArgumentCaptor.forClass; - -import java.lang.reflect.Field; -import java.math.BigDecimal; -import java.util.function.BiConsumer; - -import org.hamcrest.Description; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; - -import net.javacrumbs.jsonunit.core.Configuration; -import net.javacrumbs.jsonunit.core.Option; -import net.javacrumbs.jsonunit.core.internal.Options; -import net.javacrumbs.jsonunit.core.listener.DifferenceListener; class JsonPatchMatcherTests { diff --git a/allure-jsonunit/src/test/resources/allure.properties b/allure-jsonunit/src/test/resources/allure.properties index 9c0b0a2d7..5f86fe638 100644 --- a/allure-jsonunit/src/test/resources/allure.properties +++ b/allure-jsonunit/src/test/resources/allure.properties @@ -1,2 +1,3 @@ allure.results.directory=build/allure-results allure.label.epic=#project.description# +allure.label.module=allure-jsonunit diff --git a/allure-junit-platform/build.gradle.kts b/allure-junit-platform/build.gradle.kts index 4cf244bf2..b090e9ec6 100644 --- a/allure-junit-platform/build.gradle.kts +++ b/allure-junit-platform/build.gradle.kts @@ -29,11 +29,21 @@ tasks.jar { } tasks.test { + // The Allure Gradle adapter adds this module's published artifact to the + // test runtime classpath, so make the jar/task relationship explicit when + // jar and test are scheduled in the same build. + dependsOn(tasks.jar) systemProperty("junit.jupiter.execution.parallel.enabled", "false") useJUnitPlatform() exclude("**/features/*") } +tasks.named("pmdMain") { + // PMD type resolution reads the main compile classpath, which also + // contains this module's published artifact via the Allure adapter setup. + dependsOn(tasks.jar) +} + val spiOffJar: Jar by tasks.creating(Jar::class) { from(sourceSets.getByName("main").output) archiveClassifier.set("spi-off") diff --git a/allure-junit-platform/src/main/java/io/qameta/allure/junitplatform/AllureJunitPlatform.java b/allure-junit-platform/src/main/java/io/qameta/allure/junitplatform/AllureJunitPlatform.java index 5ff3384df..9e583fbef 100644 --- a/allure-junit-platform/src/main/java/io/qameta/allure/junitplatform/AllureJunitPlatform.java +++ b/allure-junit-platform/src/main/java/io/qameta/allure/junitplatform/AllureJunitPlatform.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Qameta Software OÜ + * Copyright 2016-2026 Qameta Software Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -34,6 +34,7 @@ import org.junit.platform.engine.TestExecutionResult; import org.junit.platform.engine.TestSource; import org.junit.platform.engine.TestTag; +import org.junit.platform.engine.UniqueId; import org.junit.platform.engine.reporting.ReportEntry; import org.junit.platform.engine.support.descriptor.ClassSource; import org.junit.platform.engine.support.descriptor.MethodSource; @@ -83,17 +84,18 @@ /** * @author ehborisov */ -@SuppressWarnings({ - "ClassFanOutComplexity", - "MultipleStringLiterals", - "ClassDataAbstractionCoupling", - "PMD.GodClass", - "PMD.TooManyMethods" -}) +@SuppressWarnings( + { + "ClassDataAbstractionCoupling", + "ClassFanOutComplexity", + "MultipleStringLiterals", + "PMD.GodClass", + "PMD.TooManyMethods", + } +) public class AllureJunitPlatform implements TestExecutionListener { - public static final String ALLURE_REPORT_ENTRY_BLANK_PREFIX - = "ALLURE_REPORT_ENTRY_BLANK_PREFIX__"; + public static final String ALLURE_REPORT_ENTRY_BLANK_PREFIX = "ALLURE_REPORT_ENTRY_BLANK_PREFIX__"; public static final String ALLURE_PARAMETER = "allure.parameter"; public static final String ALLURE_PARAMETER_VALUE_KEY = "value"; @@ -114,10 +116,18 @@ public class AllureJunitPlatform implements TestExecutionListener { private static final String TEXT_PLAIN = "text/plain"; private static final String TXT_EXTENSION = ".txt"; - private static final boolean HAS_SPOCK2_IN_CLASSPATH - = isClassAvailableOnClasspath("io.qameta.allure.spock2.AllureSpock2"); + private static final boolean HAS_SPOCK2_IN_CLASSPATH = isClassAvailableOnClasspath("io.qameta.allure.spock2.AllureSpock2"); + + private static final boolean HAS_CUCUMBERJVM7_IN_CLASSPATH = isClassAvailableOnClasspath("io.qameta.allure.cucumber7jvm.AllureCucumber7Jvm"); + + private static final boolean HAS_CUCUMBERJVM6_IN_CLASSPATH = isClassAvailableOnClasspath("io.qameta.allure.cucumber6jvm.AllureCucumber6Jvm"); + + private static final boolean HAS_CUCUMBERJVM5_IN_CLASSPATH = isClassAvailableOnClasspath("io.qameta.allure.cucumber5jvm.AllureCucumber5Jvm"); + + private static final boolean HAS_CUCUMBERJVM4_IN_CLASSPATH = isClassAvailableOnClasspath("io.qameta.allure.cucumber4jvm.AllureCucumber4Jvm"); private static final String ENGINE_SPOCK2 = "spock"; + private static final String ENGINE_CUCUMBER = "cucumber"; private final ThreadLocal testPlanStorage = new InheritableThreadLocal<>(); @@ -148,15 +158,43 @@ public AllureLifecycle getLifecycle() { return lifecycle; } + @SuppressWarnings({"CyclomaticComplexity", "BooleanExpressionComplexity"}) private boolean shouldSkipReportingFor(final TestIdentifier testIdentifier) { - return !testIdentifier.getParentId().isPresent() - || HAS_SPOCK2_IN_CLASSPATH && engineIs(testIdentifier, ENGINE_SPOCK2); + // Always skip root + if (!testIdentifier.getParentId().isPresent()) { + return true; + } + + final Optional maybeEngine = getEngine(testIdentifier); + // can't find the engine, don't know if it's possible but just in case + // keep reporting such nodes. + if (!maybeEngine.isPresent()) { + return false; + } + + final String engine = maybeEngine.get(); + + return HAS_SPOCK2_IN_CLASSPATH && ENGINE_SPOCK2.equals(engine) + || (HAS_CUCUMBERJVM7_IN_CLASSPATH + || HAS_CUCUMBERJVM6_IN_CLASSPATH + || HAS_CUCUMBERJVM5_IN_CLASSPATH + || HAS_CUCUMBERJVM4_IN_CLASSPATH) && ENGINE_CUCUMBER.equals(engine); } - private boolean engineIs(final TestIdentifier testIdentifier, final String engineId) { - return testIdentifier.getUniqueIdObject().getEngineId() - .filter(v -> Objects.equals(engineId, v)) - .isPresent(); + private Optional getEngine(final TestIdentifier testIdentifier) { + final UniqueId uniqueId = testIdentifier.getUniqueIdObject(); + final List segments = uniqueId.getSegments(); + // since junit-platform-suite engine creates nested engine segments + // we need to lookup for the last one with type engine + // to determinate the actual used engine: + // [engine:junit-platform-suite]/[suite:org.example.JUnitRunnerTest]/[engine:cucumber]/... + for (int i = segments.size() - 1; i >= 0; i--) { + final UniqueId.Segment segment = segments.get(i); + if ("engine".equals(segment.getType())) { + return Optional.of(segment.getValue()); + } + } + return Optional.empty(); } private static boolean isClassAvailableOnClasspath(final String clazz) { @@ -236,7 +274,7 @@ public void executionSkipped(final TestIdentifier testIdentifier, ); } - @SuppressWarnings({"ReturnCount", "PMD.NcssCount", "CyclomaticComplexity"}) + @SuppressWarnings({"ReturnCount", "CyclomaticComplexity"}) @Override public void reportingEntryPublished(final TestIdentifier testIdentifier, final ReportEntry entry) { @@ -265,17 +303,18 @@ public void reportingEntryPublished(final TestIdentifier testIdentifier, } + @SuppressWarnings("PMD.InefficientEmptyStringCheck") private Map unwrap(final Map data) { final Map res = new HashMap<>(); data.forEach((key, value) -> { - if (Objects.nonNull(value) - && value.trim().isEmpty() - && value.startsWith(ALLURE_REPORT_ENTRY_BLANK_PREFIX)) { - res.put(key, value.substring(ALLURE_REPORT_ENTRY_BLANK_PREFIX.length())); - } else { - res.put(key, value); - } - } + if (Objects.nonNull(value) + && value.trim().isEmpty() + && value.startsWith(ALLURE_REPORT_ENTRY_BLANK_PREFIX)) { + res.put(key, value.substring(ALLURE_REPORT_ENTRY_BLANK_PREFIX.length())); + } else { + res.put(key, value); + } + } ); return res; } @@ -306,8 +345,9 @@ private void processParameterEvent(final Map keyValuePairs) { .ifPresent(parameter::setExcluded); } - getLifecycle().updateTestCase(tr -> tr.getParameters() - .add(parameter) + getLifecycle().updateTestCase( + tr -> tr.getParameters() + .add(parameter) ); } @@ -445,6 +485,10 @@ private void failFixture(final Map keyValue) { .ifPresent(fixtureResult.getStatusDetails()::setMessage); Optional.of(keyValue.get("trace")) .ifPresent(fixtureResult.getStatusDetails()::setTrace); + Optional.of(keyValue.get("actual")) + .ifPresent(fixtureResult.getStatusDetails()::setActual); + Optional.of(keyValue.get("expected")) + .ifPresent(fixtureResult.getStatusDetails()::setExpected); }); getLifecycle().stopFixture(uuid); } @@ -458,7 +502,6 @@ private void stopFixture(final Map keyValue) { getLifecycle().stopFixture(uuid); } - @SuppressWarnings("PMD.NcssCount") private void startTestCase(final TestIdentifier testIdentifier) { final String uuid = getOrCreateTest(testIdentifier); @@ -477,30 +520,36 @@ private void startTestCase(final TestIdentifier testIdentifier) { final TestResult result = new TestResult() .setUuid(uuid) - .setName(testTemplate && maybeParent.isPresent() - ? maybeParent.get().getDisplayName() + " " + testIdentifier.getDisplayName() - : testIdentifier.getDisplayName() + .setName( + testTemplate && maybeParent.isPresent() + ? maybeParent.get().getDisplayName() + " " + testIdentifier.getDisplayName() + : testIdentifier.getDisplayName() ) + .setTitlePath(getTitlePath(testIdentifier, testClass)) .setLabels(getTags(testIdentifier)) - .setTestCaseId(testTemplate - ? maybeParent.map(TestIdentifier::getUniqueId) - .orElseGet(testIdentifier::getUniqueId) - : testIdentifier.getUniqueId() + .setTestCaseId( + testTemplate + ? maybeParent.map(TestIdentifier::getUniqueId) + .orElseGet(testIdentifier::getUniqueId) + : testIdentifier.getUniqueId() + ) + .setTestCaseName( + testTemplate + ? maybeParent.map(TestIdentifier::getDisplayName) + .orElseGet(testIdentifier::getDisplayName) + : testIdentifier.getDisplayName() ) - .setTestCaseName(testTemplate - ? maybeParent.map(TestIdentifier::getDisplayName) - .orElseGet(testIdentifier::getDisplayName) - : testIdentifier.getDisplayName()) .setHistoryId(getHistoryId(testIdentifier)) .setStage(Stage.RUNNING); if (testTemplate) { // history id is ignored in Allure TestOps, so we add a hidden parameter // to make sure different results are not considered as retries - result.getParameters().add(new Parameter() - .setMode(Parameter.Mode.HIDDEN) - .setName("UniqueId") - .setValue(testIdentifier.getUniqueId()) + result.getParameters().add( + new Parameter() + .setMode(Parameter.Mode.HIDDEN) + .setName("UniqueId") + .setValue(testIdentifier.getUniqueId()) ); } @@ -508,18 +557,29 @@ private void startTestCase(final TestIdentifier testIdentifier) { result.getLabels().add(getJUnitPlatformUniqueId(testIdentifier)); - testClass.map(AnnotationUtils::getLabels).ifPresent(result.getLabels()::addAll); + // add annotations from outer classes (support for @Nested tests in JUnit 5) + testClass.ifPresent(clazz -> { + Class clazz1 = clazz; + do { + final Set