diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index 5fc5ee427..000000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,47 +0,0 @@ -[//]: # ( -. Note: for support questions, please use Stackoverflow or Gitter**. -. This repository's issues are reserved for feature requests and bug reports. -. -. In case of any problems with Allure Jenkins plugin** please use the following repository -. to create an issue: https://github.com/jenkinsci/allure-plugin/issues -. -. Make sure you have a clear name for your issue. The name should start with a capital -. letter and no dot is required in the end of the sentence. An example of good issue names: -. -. - The report is broken in IE11 -. - Add an ability to disable default plugins -. - Support emoji in test descriptions -) - -#### I'm submitting a ... - - [ ] bug report - - [ ] feature request - - [ ] support request => Please do not submit support request here, see note at the top of this template. - -#### What is the current behavior? - - -#### If the current behavior is a bug, please provide the steps to reproduce and if possible a minimal demo of the problem - - -#### What is the expected behavior? - - -#### What is the motivation / use case for changing the behavior? - - -#### Please tell us about your environment: - - -| Allure version | 2.2.0 | -| --- | --- | -| Test framework | testng@6.8 | -| Allure integration | allure-testng@2.0-BETA11 | -| Generate report using | allure-maven@2.18 | - -#### Other information - -[//]: # ( -. e.g. detailed explanation, stacktraces, related issues, suggestions -. how to fix, links for us to have more context, eg. Stackoverflow, Gitter etc -) 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..3d29f6ce9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,82 @@ +name: Bug report +description: Report broken or incorrect behavior in an Allure Java integration. +type: bug +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-awaitility + - allure-bom + - allure-citrus + - allure-cucumber7-jvm + - allure-descriptions-javadoc + - allure-grpc + - allure-hamcrest + - allure-httpclient + - allure-httpclient5 + - allure-java-commons + - allure-java-commons-test + - allure-jax-rs + - allure-jbehave5 + - allure-jooq + - allure-jsonunit + - allure-junit-platform + - allure-junit4 + - allure-junit4-aspect + - allure-jupiter + - allure-jupiter-assert + - allure-karate + - allure-model + - allure-okhttp3 + - allure-playwright + - allure-rest-assured + - allure-scalatest + - allure-selenium-bidi + - allure-selenide + - allure-servlet-api + - allure-spock2 + - allure-spring-web + - 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/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 000000000..eb189d888 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,32 @@ +name: Feature request +description: Suggest a user-facing feature or improvement. +type: feature +body: + - type: markdown + attributes: + value: | + Thanks for suggesting an improvement for Allure Java. Please describe the outcome you need, not only a possible implementation. + - type: textarea + id: problem + attributes: + label: What problem should this solve? + description: Describe the workflow, limitation, or user need behind the request. + validations: + required: true + - type: textarea + id: proposal + attributes: + label: What would you like to happen? + description: Describe the behavior or capability you want Allure Java to provide. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Share any current workaround or alternative design you have considered. + - type: textarea + id: additional-context + attributes: + label: Additional context + description: Add examples, links, screenshots, or related issues. diff --git a/.github/ISSUE_TEMPLATE/task.yml b/.github/ISSUE_TEMPLATE/task.yml new file mode 100644 index 000000000..7cd649f04 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/task.yml @@ -0,0 +1,16 @@ +name: Task +description: Track internal maintenance or repository work. +type: task +body: + - type: textarea + id: goal + attributes: + label: Goal + description: Describe the maintenance, cleanup, or repository task. + validations: + required: true + - type: textarea + id: details + attributes: + label: Details + description: Add relevant context, constraints, links, or acceptance criteria. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 240d09840..70c93749e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,11 +5,11 @@ updates: schedule: interval: "daily" labels: - - "type:dependencies" + - "pr:dependencies" - package-ecosystem: "gradle" directory: "/" schedule: interval: "daily" labels: - - "type:dependencies" + - "pr:dependencies" diff --git a/.github/labeler.yml b/.github/labeler.yml deleted file mode 100644 index ca8c814d0..000000000 --- a/.github/labeler.yml +++ /dev/null @@ -1,82 +0,0 @@ -"theme:workflow": - - ".github/**" - -"theme:build": - - "gradle/**" - - "build.gradle.kts" - - "**/build.gradle.kts" - - "gradle.properties" - - ".gitignore" - -"theme:assertj": - - "allure-assertj/**" - -"theme:attachments": - - "allure-attachments/**" - -"theme:citrus": - - "allure-citrus/**" - -"theme:cucumber-jvm": - - "allure-cucumber*-jvm/**" - -"theme:descriptions-javadoc": - - "allure-descriptions-javadoc/**" - -"theme:httpclient": - - "allure-httpclient/**" - -"theme:model": - - "allure-model/**" - -"theme:core": - - "allure-java-commons/**" - - "allure-java-commons-test/**" - - "allure-test-filter/**" - -"theme:jax-rs": - - "allure-jax-rs/**" - -"theme:jbehave": - - "allure-jbehave/**" - -"theme:jsonunit": - - "allure-jsonunit/**" - -"theme:junit4": - - "allure-junit4/**" - - "allure-junit4-aspect/**" - -"theme:junit-platform": - - "allure-junit5/**" - - "allure-junit5-assert/**" - - "allure-junit-platform/**" - -"theme:karate": - - "allure-karate/**" - -"theme:okhttp": - - "allure-okhttp/**" - - "allure-okhttp3/**" - -"theme:rest-assured": - - "allure-rest-assured/**" - -"theme:scalatest": - - "allure-scalatest/**" - -"theme:selenide": - - "allure-selenide/**" - -"theme:servlet-api": - - "allure-servlet-api/**" - -"theme:spock": - - "allure-spock/**" - -"theme:spring": - - "allure-spring-web/**" - -"theme:testng": - - "allure-testng/**" - diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/pull_request_template.md similarity index 62% rename from .github/PULL_REQUEST_TEMPLATE.md rename to .github/pull_request_template.md index 0dec83312..4ccdd31e5 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/pull_request_template.md @@ -1,9 +1,9 @@ ### Context + #### Checklist + - [ ] [Sign Allure CLA][cla] - [ ] Provide unit tests -[cla]: https://cla-assistant.io/accept/allure-framework/allure2 +[cla]: https://cla-assistant.io/accept/allure-framework/allure-java diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml deleted file mode 100644 index c17d77dbe..000000000 --- a/.github/release-drafter.yml +++ /dev/null @@ -1,25 +0,0 @@ -name-template: '$NEXT_MINOR_VERSION' -tag-template: '$NEXT_MINOR_VERSION' -categories: - - title: '🚀 New Features' - labels: - - 'type:new feature' - - title: '🔬 Improvements' - labels: - - 'type:improvement' - - title: '🐞 Bug Fixes' - labels: - - 'type:bug' - - title: '⬆️ Dependency Updates' - labels: - - 'type:dependencies' - -change-template: '* $TITLE (via #$NUMBER) - @$AUTHOR' -exclude-labels: - - 'type:internal' -template: | - $CHANGES - - ## 👀 Links - - [Commits since $PREVIOUS_TAG](https://github.com/allure-framework/allure-java/compare/$PREVIOUS_TAG...master) diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 000000000..7670c3878 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,28 @@ +# release.yml + +changelog: + exclude: + labels: + - 'pr:invalid' + categories: + - title: '🚀 New Features' + labels: + - 'pr:new feature' + - title: '🔬 Improvements' + labels: + - 'pr:improvement' + - title: '🐞 Bug Fixes' + labels: + - 'pr:bug' + - title: '⬆️ Dependency Updates' + labels: + - 'pr:dependencies' + - title: '📖 Documentation improvements' + labels: + - 'pr:documentation' + - title: '⛔️ Security' + labels: + - 'pr:security' + - title: '👻 Internal changes' + labels: + - 'pr:internal' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6009acaf8..7ba750a1a 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,28 +10,108 @@ 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" + name: "Build (JDK ${{ matrix.java }})" runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - java-version: [ '8.0.x', '11.0.x' ] + java: [17, 25] + env: + ALLURE_MATRIX_ENV: ubuntu-jdk-${{ matrix.java }} + ALLURE_TEST_DUMP_NAME: allure-results-test-jdk-${{ matrix.java }} steps: - - uses: actions/checkout@v2.3.4 + - uses: actions/checkout@v7 - - name: Set up JDK ${{ matrix.java-version }} - uses: actions/setup-java@v2 + - uses: actions/setup-node@v7 + with: + node-version: '20.x' + + - name: "Set up JDK" + uses: actions/setup-java@v5 with: distribution: 'zulu' - java-version: ${{ matrix.java-version }} + java-version: ${{ matrix.java }} + + - name: "Setup Gradle" + uses: gradle/actions/setup-gradle@v6 + with: + gradle-version: 'wrapper' + + - name: "Select Gradle task exclusions" + shell: bash + run: | + if [[ "${{ matrix.java }}" == "17" ]]; then + higher_java_modules=( + ":allure-jooq" + ":allure-karate" + ) + excludes=() + for module in "${higher_java_modules[@]}"; do + excludes+=("-x" "$module:build" "-x" "$module:test") + done + echo "GRADLE_EXCLUDES=${excludes[*]}" >> "$GITHUB_ENV" + fi - - name: Build with Gradle - run: ./gradlew build -x test --scan + - name: "Build with Gradle" + run: ./gradlew build -x test $GRADLE_EXCLUDES --scan - - name: Run tests + - name: "Run tests with Allure" if: always() - run: ./gradlew --no-build-cache cleanTest test + 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 $GRADLE_EXCLUDES + + - name: "Upload Allure test dump" + if: always() + 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 }} + ALLURE_TOKEN: ${{ secrets.ALLURE_TOKEN }} + ALLURE_ENDPOINT: ${{ secrets.ALLURE_ENDPOINT }} + ALLURE_PROJECT_ID: ${{ secrets.ALLURE_PROJECT_ID }} + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + 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..31faaec6e --- /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@v7 + - 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 deleted file mode 100644 index 956bf2f95..000000000 --- a/.github/workflows/labeler.yml +++ /dev/null @@ -1,12 +0,0 @@ -name: "Set theme labels" - -on: - - pull_request_target - -jobs: - triage: - runs-on: ubuntu-latest - steps: - - uses: actions/labeler@v3 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/labels-verify.yml b/.github/workflows/labels-verify.yml index 077315bba..c94a1e441 100644 --- a/.github/workflows/labels-verify.yml +++ b/.github/workflows/labels-verify.yml @@ -1,13 +1,61 @@ -name: "Verify type labels" +name: "Verify PR labels" on: pull_request: - types: [ opened, labeled, unlabeled, synchronize ] + types: + - opened + - reopened + - synchronize + - ready_for_review + - labeled + - unlabeled + - edited + +permissions: + contents: read + pull-requests: read + issues: read jobs: triage: + name: Require exactly one pr label runs-on: ubuntu-latest steps: - - uses: baev/match-label-action@master - with: - allowed: 'type:bug,type:new feature,type:improvement,type:dependencies,type:internal,type:invalid' + - name: Validate release notes label + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + shell: bash + run: | + labels="$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/labels" --jq '.[].name')" + pr_labels="$(printf '%s\n' "${labels}" | grep '^pr:' || true)" + + if [ -z "${pr_labels}" ]; then + count=0 + else + count="$(printf '%s\n' "${pr_labels}" | wc -l | tr -d ' ')" + fi + + if [ "${count}" -eq 1 ]; then + echo "Found PR release-note label: ${pr_labels}" + exit 0 + fi + + { + echo "Pull requests must have exactly one label with the pr: prefix." + echo "Found ${count} pr: labels." + if [ "${count}" -gt 0 ]; then + printf 'Matching labels:\n' + printf '%s\n' "${pr_labels}" | sed 's/^/- /' + fi + echo + echo "Available labels on this PR:" + if [ -n "${labels}" ]; then + printf '%s\n' "${labels}" | sed 's/^/- /' + else + echo "- " + fi + } >> "${GITHUB_STEP_SUMMARY}" + + echo "::error title=Invalid pr: label count::Pull requests must have exactly one label with the pr: prefix. Found ${count}." + exit 1 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 02bf97606..9f77206b8 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@v2.3.4 + - uses: actions/checkout@v7 - - name: Set up JDK 1.8 - uses: actions/setup-java@v2 + - name: "Set up JDK" + uses: actions/setup-java@v5 with: distribution: 'zulu' - java-version: 8.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-draft.yml b/.github/workflows/release-draft.yml deleted file mode 100644 index ea9ee55ca..000000000 --- a/.github/workflows/release-draft.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: Release Draft - -on: - push: - branches: - - master - -jobs: - update_draft_release: - runs-on: ubuntu-latest - steps: - - uses: toolmantim/release-drafter@v5.15.0 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5afc8430b..11d33c09f 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,19 +11,22 @@ 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: | expr "${{ github.event.inputs.releaseVersion }}" : '[[:digit:]][[:digit:]]*\.[[:digit:]][[:digit:]]*\.[[:digit:]][[:digit:]]*$' - - name: "Check next version" run: | expr "${{ github.event.inputs.nextVersion }}" : '[[:digit:]][[:digit:]]*\.[[:digit:]][[:digit:]]*$' - - - uses: actions/checkout@v2.3.4 + - uses: actions/checkout@v7 with: token: ${{ secrets.QAMETA_CI }} @@ -30,34 +34,29 @@ jobs: run: | git config --global user.name qameta-ci git config --global user.email qameta-ci@qameta.io - - - name: "Set release version in gradle.properties file" + - name: "Set release version" run: | sed -i -e '/version=/s/.*/version=${{ github.event.inputs.releaseVersion }}/g' gradle.properties cat gradle.properties - - name: "Commit release version and create tag" run: | git commit -am "release ${{ github.event.inputs.releaseVersion }}" git tag ${{ github.event.inputs.releaseVersion }} git push origin ${{ github.event.inputs.releaseVersion }} - - - name: "Set next development version in gradle.properties file" + - name: "Set next development version" run: | sed -i -e '/version=/s/.*/version=${{ github.event.inputs.nextVersion }}-SNAPSHOT/g' gradle.properties cat gradle.properties - - name: "Commit next development version and push it" run: | git commit -am "set next development version ${{ github.event.inputs.nextVersion }}" - git push origin ${GITHUB_REF} - + git push origin ${{ github.ref }} - name: "Publish Github Release" - uses: toolmantim/release-drafter@v5.15.0 + uses: octokit/request-action@v2.x with: - tag: ${{ github.event.inputs.releaseVersion }} - publish: "true" - version: ${{ github.event.inputs.releaseVersion }} + route: POST /repos/${{ github.repository }}/releases + tag_name: ${{ github.event.inputs.releaseVersion }} + generate_release_notes: true + target_commitish: ${{ github.ref }} env: GITHUB_TOKEN: ${{ secrets.QAMETA_CI }} - diff --git a/.idea/icon.png b/.idea/icon.png index 621c0930b..252994657 100644 Binary files a/.idea/icon.png and b/.idea/icon.png differ diff --git a/.idea/vcs.xml b/.idea/vcs.xml index aeaa9e459..d154cb226 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -11,6 +11,6 @@ - + - + \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..732e8f7b1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,24 @@ +# Project Guide + +Never create pull requests or push git branches without explicit confirmation from the user. + +## Test Work + +Use [Allure Agent Mode](docs/allure-agent-mode.md) for test-related work in this repository. + +- Read `docs/allure-agent-mode.md` before designing, writing, reviewing, validating, debugging, or enriching tests. +- Use the `$allure-agent-mode` skill as the durable behavior guide when it is installed; this project file contains local commands and conventions. +- If a command executes tests and its result will be used for smoke checking, reasoning, review, coverage analysis, debugging, or a user-facing conclusion, run it through `allure agent`. +- Use agent-mode execution for smoke checks too, even when the change is small or mechanical. +- If agent output is missing or incomplete, debug that first and treat console-only conclusions as provisional. + +## Validation + +After making changes, run the applicable validation checks before reporting done. + +- For Java, Groovy, or Scala production/test changes, run the relevant module-scoped quality checks when practical: `::spotlessCheck`, `::checkstyleMain`, `::pmdMain`, and `::spotbugsMain`. +- For shared test-support, build logic, root configuration, or broad cross-module changes, run the aggregate quality command: `./gradlew --no-daemon spotlessCheck checkstyleMain pmdMain spotbugsMain`. +- If Spotless reports formatting issues, run the matching `spotlessApply` task, such as `::spotlessApply` or `./gradlew --no-daemon spotlessApply`, then rerun `spotlessCheck`. +- If a quality check fails, fix the smallest relevant issue and rerun the failed check or the original quality command until it passes. +- For docs-only changes, run `git diff --check` and any configured documentation lint before reporting completion. +- If validation cannot be run, state exactly which checks were skipped and why. 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 fa8419abf..90c5da4eb 100644 --- a/README.md +++ b/README.md @@ -1,173 +1,122 @@ -[license]: http://www.apache.org/licenses/LICENSE-2.0 "Apache License 2.0" -[blog]: https://qameta.io/blog -[gitter]: https://gitter.im/allure-framework/allure-core -[gitter-ru]: https://gitter.im/allure-framework/allure-ru -[twitter]: https://twitter.com/QametaSoftware "Qameta Software" -[twitter-team]: https://twitter.com/QametaSoftware/lists/team/members "Team" +# Allure Java -[bintray]: https://bintray.com/qameta/maven/allure-java "Bintray" -[bintray-badge]: https://img.shields.io/bintray/v/qameta/maven/allure-java.svg?style=flat +Allure Java is the JVM integration family for [Allure Report](https://allurereport.org/). It helps Java, Groovy, Scala, Kotlin, and other JVM test suites write Allure result files that can be viewed in Allure Report. -[CONTRIBUTING.md]: .github/CONTRIBUTING.md -[docs]: https://docs.qameta.io/allure/2.0/ +## Requirements -# Allure Java Integrations +- Allure Java 3.x targets Java 17 and newer. +- Use one framework adapter per test runtime, for example `allure-jupiter`, `allure-testng`, or `allure-cucumber7-jvm`. +- Prefer `allure-bom` to keep Allure module versions aligned. -[![Build](https://github.com/allure-framework/allure-java/actions/workflows/build.yaml/badge.svg)](https://github.com/allure-framework/allure-java/actions/workflows/build.yaml) [![bintray-badge][]][bintray] +## Quick Start -The repository contains new versions of adaptors for JVM-based test frameworks. +Gradle: -All the artifacts are deployed to `https://dl.bintray.com/qameta/maven`. +```kotlin +dependencies { + testImplementation(platform("io.qameta.allure:allure-bom:")) + testImplementation("io.qameta.allure:allure-jupiter") +} -## TestNG - -The new TestNG adaptors is pretty much ready. To use the adaptor you should add the following dependency: - -```xml - - io.qameta.allure - allure-testng - $LATEST_VERSION - -``` - -also you need to configure AspectJ weaver to support steps. - -## 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 - -``` - -## Selenide - -Listener for Selenide, that logging steps for Allure: - -```xml - - io.qameta.allure - allure-selenide - $LATEST_VERSION - -``` - -Usage example: -``` -SelenideLogger.addListener("AllureSelenide", new AllureSelenide().screenshots(true).savePageSource(false)); - -Capture selenium logs: -SelenideLogger.addListener("AllureSelenide", new AllureSelenide().enableLogs(LogType.BROWSER, Level.ALL)); -https://github.com/SeleniumHQ/selenium/wiki/Logging -``` - - -## Rest Assured - -Filter for rest-assured http client, that generates attachment for allure. - -```xml - - io.qameta.allure - allure-rest-assured - $LATEST_VERSION - -``` - -Usage example: -``` -.filter(new AllureRestAssured()) -``` -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. - -```xml - - io.qameta.allure - allure-okhttp3 - $LATEST_VERSION - +tasks.test { + useJUnitPlatform() +} ``` -Usage example: -``` -.addInterceptor(new AllureOkHttp3()) -``` -You can specify custom templates, which should be placed in src/main/resources/tpl folder: -``` -.addInterceptor(new AllureOkHttp3() - .withRequestTemplate("custom-http-request.ftl") - .withResponseTemplate("custom-http-response.ftl")) - -``` - -## Http client - -Interceptors for Apache HTTP client, that generates attachment for allure. +Maven: ```xml - - io.qameta.allure - allure-httpclient - $LATEST_VERSION - -``` - -Usage example: -``` -.addInterceptorFirst(new AllureHttpClientRequest()) -.addInterceptorLast(new AllureHttpClientResponse()); -``` - -## JAX-RS Filter - -Filter that can be used with JAX-RS compliant clients such as RESTeasy and Jersey - -```xml - - io.qameta.allure - allure-jax-rs - $LATEST_VERSION - -``` - -Usage example: -``` -.register(AllureJaxRs.class) -``` - -## JsonUnit -JsonPatchMatcher is extension of JsonUnit matcher, that generates pretty html attachment for differences based on [json diff patch](https://github.com/benjamine/jsondiffpatch/blob/master/docs/deltas.md). - -```xml - - io.qameta.allure - allure-jsonunit - $LATEST_VERSION - -``` + + + + io.qameta.allure + allure-bom + ${allure.version} + pom + import + + + + + + + io.qameta.allure + allure-jupiter + test + + +``` + +Run your tests, then generate or serve the report from the produced Allure results directory, usually `build/allure-results` for Gradle or `target/allure-results` for Maven. + +## Module Catalog + +### Test Frameworks + +| Module | Use When | Registration | +| --- | --- | --- | +| [`allure-jupiter`](allure-jupiter/README.md) | JUnit Jupiter on JUnit 5 or 6 | JUnit Platform service loader | +| [`allure-junit-platform`](allure-junit-platform/README.md) | Building a custom JUnit Platform integration | JUnit Platform listener and filter services | +| [`allure-junit4`](allure-junit4/README.md) | JUnit 4 runner where listeners can be configured | Register `io.qameta.allure.junit4.AllureJunit4` | +| [`allure-junit4-aspect`](allure-junit4-aspect/README.md) | Gradle built-in JUnit 4 execution | Enable AspectJ weaver | +| [`allure-testng`](allure-testng/README.md) | TestNG 7 suites | TestNG service loader or listener registration | +| [`allure-spock2`](allure-spock2/README.md) | Spock 2 specifications | Spock global extension service | +| [`allure-scalatest`](allure-scalatest/README.md) | ScalaTest suites | ScalaTest reporter | +| [`allure-cucumber7-jvm`](allure-cucumber7-jvm/README.md) | Cucumber JVM 7 | Cucumber plugin | +| [`allure-jbehave5`](allure-jbehave5/README.md) | JBehave 5 stories | JBehave story reporter | +| [`allure-karate`](allure-karate/README.md) | Karate runtime listeners | Karate runtime listener | +| [`allure-citrus`](allure-citrus/README.md) | Citrus tests | Citrus listeners | + +### HTTP, Browser, And Client Integrations + +| Module | Use When | Captured Data | +| --- | --- | --- | +| [`allure-rest-assured`](allure-rest-assured/README.md) | REST Assured filters | HTTP requests and responses | +| [`allure-httpclient5`](allure-httpclient5/README.md) | Apache HttpClient 5 interceptors | HTTP requests and responses | +| [`allure-httpclient`](allure-httpclient/README.md) | Apache HttpClient 4 interceptors | HTTP requests and responses | +| [`allure-okhttp3`](allure-okhttp3/README.md) | OkHttp interceptors | HTTP requests and responses | +| [`allure-spring-web`](allure-spring-web/README.md) | Spring `RestTemplate` interceptors | HTTP requests and responses | +| [`allure-jax-rs`](allure-jax-rs/README.md) | Jakarta RESTful Web Services / JAX-RS client filters | HTTP requests and responses | +| [`allure-servlet-api`](allure-servlet-api/README.md) | Jakarta Servlet request/response conversion | Servlet request and response data | +| [`allure-grpc`](allure-grpc/README.md) | gRPC client interceptors | gRPC calls and metadata | +| [`allure-selenide`](allure-selenide/README.md) | Selenide UI tests | UI steps, screenshots, page source, logs | +| [`allure-selenium-bidi`](allure-selenium-bidi/README.md) | Selenium WebDriver BiDi sessions | Browser logs and network attachments | +| [`allure-playwright`](allure-playwright/README.md) | Playwright Java actions | AspectJ action steps and screenshots | +| [`allure-jooq`](allure-jooq/README.md) | jOOQ execution listener | SQL execution steps | + +### Assertions And Utilities + +| Module | Use When | Notes | +| --- | --- | --- | +| [`allure-assertj`](allure-assertj/README.md) | AssertJ assertions should appear as Allure steps | Requires AspectJ | +| [`allure-hamcrest`](allure-hamcrest/README.md) | Hamcrest assertions should appear as Allure steps | Requires AspectJ | +| [`allure-jupiter-assert`](allure-jupiter-assert/README.md) | JUnit Jupiter assertions should appear as Allure steps | Requires AspectJ | +| [`allure-awaitility`](allure-awaitility/README.md) | Awaitility polling should appear as Allure steps | Register condition listener | +| [`allure-jsonunit`](allure-jsonunit/README.md) | JsonUnit diffs should be attached to Allure | Provides JSON matcher/listener helpers | + +### Core And Support Modules + +| Module | Purpose | +| --- | --- | +| [`allure-bom`](allure-bom/README.md) | Maven/Gradle dependency alignment | +| [`allure-java-commons`](allure-java-commons/README.md) | Runtime API, lifecycle, annotations, aspects, and test-plan filtering | +| [`allure-model`](allure-model/README.md) | Serializable Allure result model | +| [`allure-descriptions-javadoc`](allure-descriptions-javadoc/README.md) | Annotation processor for JavaDoc-based test descriptions | + +## Common Runtime APIs + +Most adapters depend on `allure-java-commons`, which provides the high-level API: + +```java +import io.qameta.allure.Allure; + +Allure.step("Create order", () -> { + Allure.attachment("request-id", "42"); +}); +``` + +For HTTP clients, browser tools, assertion libraries, and other integrations, add the matching module from the catalog above. Each module README shows the registration snippet for that tool. + +## Resources + +- [Allure Report documentation](https://allurereport.org/docs/) diff --git a/allure-assertj/README.md b/allure-assertj/README.md new file mode 100644 index 000000000..1c8ca7a1a --- /dev/null +++ b/allure-assertj/README.md @@ -0,0 +1,46 @@ +# allure-assertj + +AssertJ assertion step integration for Allure Java. + +Use this module when you want AssertJ assertion chains to appear as nested steps in Allure Report. + +## Supported Versions + +- Allure Java 3.x requires Java 17 or newer. +- This module targets AssertJ Core. +- The current build validates against AssertJ Core 3.27.7 and AspectJ 1.9.25.1. + +## Installation + +Gradle: + +```kotlin +val aspectjAgent by configurations.creating + +dependencies { + testImplementation(platform("io.qameta.allure:allure-bom:")) + testImplementation("io.qameta.allure:allure-assertj") + testRuntimeOnly("org.aspectj:aspectjrt:") + aspectjAgent("org.aspectj:aspectjweaver:") +} + +tasks.test { + doFirst { + jvmArgs("-javaagent:${aspectjAgent.singleFile}") + } +} +``` + +Maven, with `allure-bom` imported in dependency management, can use the same artifact together with an AspectJ javaagent configured for the test JVM. + +## Setup + +Enable the AspectJ weaver for the test JVM. The module weaves AssertJ calls and reports assertion chains as Allure steps. + +## Report Output + +- AssertJ `Assertions.assertThat(...)` and BDD `then(...)` entry points as assertion steps. +- Assertion chains grouped under the corresponding test or user step. +- Failed assertions update the corresponding Allure step status and status details. + +For large classpaths, consider an AspectJ `META-INF/aop.xml` that limits weaving to Allure, AssertJ, and your test packages. diff --git a/allure-assertj/build.gradle.kts b/allure-assertj/build.gradle.kts index e8e064ed9..10005f9eb 100644 --- a/allure-assertj/build.gradle.kts +++ b/allure-assertj/build.gradle.kts @@ -1,9 +1,6 @@ description = "Allure AssertJ Integration" -val agent: Configuration by configurations.creating - dependencies { - agent("org.aspectj:aspectjweaver") api(project(":allure-java-commons")) compileOnly("org.aspectj:aspectjrt") compileOnly("org.assertj:assertj-core") @@ -13,6 +10,7 @@ dependencies { testImplementation(project(":allure-java-commons-test")) testImplementation(project(":allure-junit-platform")) testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") } tasks.jar { @@ -25,7 +23,4 @@ tasks.jar { tasks.test { useJUnitPlatform() - doFirst { - jvmArgs("-javaagent:${agent.singleFile}") - } } 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 4ecea1dd8..351b6c205 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,125 +17,180 @@ 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; /** - * @author charlie (Dmitry Baev). - * @author sskorol (Sergey Korol). + * Captures user-side AssertJ factories and fluent calls, then delegates assertion-chain state + * to {@link AssertJRecorder}. + * */ @SuppressWarnings("all") @Aspect public class AllureAspectJ { - private static final Logger LOGGER = LoggerFactory.getLogger(AllureAspectJ.class); + private static final ThreadLocal RECORDER = ThreadLocal.withInitial(AssertJRecorder::new); - private static InheritableThreadLocal lifecycle = new InheritableThreadLocal() { - @Override - protected AllureLifecycle initialValue() { - return Allure.getLifecycle(); - } - }; + private static final ThreadLocal RECORDING_MUTED = ThreadLocal.withInitial(() -> false); - @Pointcut("execution(public org.assertj.core.api.AbstractAssert.new(..))") - public void anyAssertCreation() { - //pointcut body, should be empty - } + @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*(..))" + + ")" + ) - @Pointcut("execution(public * org.assertj.core.api.AssertionsForClassTypes.*(..))") - public void anyAssertCreation2() { + /** + * Handles the assert factory call callback. + */ + 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)" + ) + + /** + * Handles the assert operation call callback. + * + * @param assertion the assertion + */ + public void assertOperationCall(final AbstractAssert assertion) { //pointcut body, should be empty } - @Pointcut("execution(public * org.assertj.core.api.AbstractAssert+.*(..)) && !proxyMethod()") - public void anyAssert() { + /** + * Handles the user code call callback. + */ + @Pointcut("!within(org.assertj..*) && !within(io.qameta.allure.assertj.AllureAspectJ)") + public void userCodeCall() { //pointcut body, should be empty } - @After("anyAssertCreation() || anyAssertCreation2()") - 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); + @AfterReturning( + pointcut = "assertFactoryCall() && userCodeCall()", + returning = "result" + ) - final StepResult result = new StepResult() - .setName(name) - .setStatus(Status.PASSED); + /** + * Handles the log assert creation callback. + * + * @param joinPoint the join point + * @param result the model object or framework result to process + */ + 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); + /** + * Returns the log assert operation. + * + * @param joinPoint the join point + * @param assertion the assertion + * @return the log assert operation + * @throws Throwable if the underlying framework operation fails + */ + @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(); + } - getLifecycle().startStep(uuid, result); + 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; + } } - @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(); - } + @After( + "execution(public void org.assertj.core.api.DefaultAssertionErrorCollector.collectAssertionError(" + + "java.lang.AssertionError)) && args(error)" + ) - @AfterReturning(pointcut = "anyAssert()") - public void stepStop() { - getLifecycle().updateStep(s -> s.setStatus(Status.PASSED)); - getLifecycle().stopStep(); + /** + * Handles the soft assertion failed callback. + * + * @param error the error reported by the framework + */ + public void softAssertionFailed(final AssertionError error) { + getRecorder().softAssertionFailed(error); } /** - * For tests only. + * Returns the lifecycle. * - * @param allure allure lifecycle to set. + * @return the Allure lifecycle used by this integration */ - public static void setLifecycle(final AllureLifecycle allure) { - lifecycle.set(allure); + public static AllureLifecycle getLifecycle() { + return Allure.getLifecycle(); } - public static AllureLifecycle getLifecycle() { - return lifecycle.get(); + /** + * Drops the calling thread's recorder state. Invoked by {@link AssertJLifecycleListener} at test boundaries so + * chain bookkeeping never accumulates across tests. + */ + 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 arrayToString(final Object... array) { - return Stream.of(array) - .map(ObjectUtils::toString) - .collect(Collectors.joining(" ")); + 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..21b850902 --- /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.AllureExternalKey; +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; + +/** + * 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 AllureExternalKey key; + + private final AbstractAssert assertion; + + private final StepResult step; + + AssertJChain(final AbstractAssert assertion, final String subject) { + this.key = AllureExternalKey.random(AllureAspectJ.class); + this.assertion = assertion; + this.step = new StepResult() + .setName(chainName(subject)) + .setStatus(Status.PASSED) + .setStage(Stage.FINISHED) + .setStart(System.currentTimeMillis()) + .setStop(System.currentTimeMillis()); + } + + AllureExternalKey getKey() { + return key; + } + + 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..d85316e2f --- /dev/null +++ b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJLifecycleListener.java @@ -0,0 +1,50 @@ +/* + * 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 { + + /** + * {@inheritDoc} + */ + @Override + public void afterTestWrite(final TestResult result) { + AllureAspectJ.clearContext(); + } + + /** + * {@inheritDoc} + */ + @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..18794e577 --- /dev/null +++ b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJRecorder.java @@ -0,0 +1,261 @@ +/* + * 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.getCurrentExecutableKey().ifPresent(parent -> { + lifecycle.startStep(parent, chain.getKey(), chain.getStep()); + lifecycle.stopStep(chain.getKey()); + }); + 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..49f9ba6ac --- /dev/null +++ b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJValueRenderer.java @@ -0,0 +1,561 @@ +/* + * 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) { + if (value == null) { + return false; + } + 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 a4c311e29..2f1410be0 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,92 +15,538 @@ */ 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 io.qameta.allure.test.IsolatedLifecycle; +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; - -/** - * @author charlie (Dmitry Baev). - */ +import static org.assertj.core.api.Assertions.tuple; +@IsolatedLifecycle 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); + }); + + 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)); + }); + + 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"); + }); - assertThat(results.getTestResults()) - .flatExtracting(TestResult::getSteps) + 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"); + }); + + 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) + ); + }); + + 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 shouldRenderNullValuesInContainsExactlyInAnyOrder() { + final AllureResults results = runWithinTestContext(() -> { + assertThat(Arrays.asList(null, "a", "b")) + .containsExactlyInAnyOrder(null, "a", "b"); + }); + + final TestResult result = assertOnlyOneResult(results); + assertThat(result.getSteps()) + .extracting(StepResult::getName) + .containsExactly("assert [null, \"a\", \"b\"]"); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .extracting(StepResult::getName) + .containsExactly("contains exactly in any order [null, \"a\", \"b\"]"); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .flatExtracting(StepResult::getParameters) + .isEmpty(); + } + + @AllureFeatures.Steps + @Test + void shouldRenderNullValuesAfterExtractingAndKeepLambdaVarargs() { + final TestResult model = new TestResult(); + + final AllureResults results = runWithinTestContext(() -> { + assertThat(model) + .extracting(TestResult::getDescription, TestResult::getDescriptionHtml) + .containsExactly(null, null); + }); + + final TestResult result = assertOnlyOneResult(results); + assertThat(result.getSteps()) + .extracting(StepResult::getName) + .containsExactly("assert TestResult"); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) .extracting(StepResult::getName) .containsExactly( - "assertThat 'null'", - "as 'Nullable object []'", - "isNull" + "extracts [, ] -> [null, null]", + "contains exactly [null, null]" ); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .flatExtracting(StepResult::getParameters) + .isEmpty(); } @AllureFeatures.Steps @Test - void shouldHandleByteArrayObject() { - final String s = "some string"; + void shouldRenderFieldOrPropertyValueAssertions() { + final StatusDetails details = new StatusDetails() + .setMessage("Make the test failed"); + final AllureResults results = runWithinTestContext(() -> { - assertThat(s.getBytes(StandardCharsets.UTF_8)) - .as("Byte array object") - .isEqualTo(s.getBytes(StandardCharsets.UTF_8)); - }, AllureAspectJ::setLifecycle); + assertThat(details) + .hasFieldOrPropertyWithValue("message", "Make the test failed"); + }); + + 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")); - assertThat(results.getTestResults()) - .flatExtracting(TestResult::getSteps) + final AllureResults results = runWithinTestContext(() -> { + assertThat(value) + .isEqualTo(value); + }); + + 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"); + }); + + 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( + "has size 4", + "is positive", + "is equal to 42", + "has size 2", + "contains \"a\"" + ); + } + + @AllureFeatures.Steps + @Test + void shouldAttachOperationsToStoredAssertionInstances() { + final String targetA = "alpha"; + final String targetB = "bravo"; + + final AllureResults results = runWithinTestContext(() -> { + final AbstractStringAssert a = assertThat(targetA); + final AbstractStringAssert b = assertThat(targetB); + + a.isEqualTo("alpha"); + b.isEqualTo("bravo"); + }); + + 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); + }); + + 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"); + }); + + 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 softAssertions() { + void shouldRenderSerializedLambdaMethodReferences() { + final TestResult model = new TestResult() + .setFullName("my.company.Test.testOne"); + + final AllureResults results = runWithinTestContext(() -> { + assertThat(Collections.singletonList(model)) + .extracting((Function & Serializable) TestResult::getFullName) + .containsExactly("my.company.Test.testOne"); + }); + + 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); + }); + + 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 shouldMarkTheFailedSoftAssertionOperationBeforeAssertAll() { final AllureResults results = runWithinTestContext(() -> { final SoftAssertions soft = new SoftAssertions(); soft.assertThat(25) - .as("Test description") + .as("Age") .isEqualTo(26); soft.assertAll(); - }, AllureAspectJ::setLifecycle); + }); + + 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"); + } - assertThat(results.getTestResults()) - .flatExtracting(TestResult::getSteps) + @AllureFeatures.Steps + @Test + void shouldAttachNestedAssertionsUnderCallbackOperations() { + final AllureResults results = runWithinTestContext(() -> { + assertThat("alpha") + .satisfies( + value -> assertThat(value) + .startsWith("al") + .endsWith("ha") + ); + }); + + 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/DefaultAttachmentContent.java b/allure-attachments/src/main/java/io/qameta/allure/attachment/DefaultAttachmentContent.java deleted file mode 100644 index ab26abbb6..000000000 --- a/allure-attachments/src/main/java/io/qameta/allure/attachment/DefaultAttachmentContent.java +++ /dev/null @@ -1,51 +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.attachment; - -/** - * @author charlie (Dmitry Baev). - */ -public class DefaultAttachmentContent implements AttachmentContent { - - private final String content; - - private final String contentType; - - private final String fileExtension; - - public DefaultAttachmentContent(final String content, - final String contentType, - final String fileExtension) { - this.content = content; - this.contentType = contentType; - this.fileExtension = fileExtension; - } - - @Override - public String getContent() { - return content; - } - - @Override - public String getContentType() { - return contentType; - } - - @Override - public String getFileExtension() { - return fileExtension; - } -} 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 deleted file mode 100644 index dc57e9a01..000000000 --- a/allure-attachments/src/main/java/io/qameta/allure/attachment/DefaultAttachmentProcessor.java +++ /dev/null @@ -1,49 +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.attachment; - -import io.qameta.allure.Allure; -import io.qameta.allure.AllureLifecycle; - -import java.nio.charset.StandardCharsets; - -/** - * @author charlie (Dmitry Baev). - */ -public class DefaultAttachmentProcessor implements AttachmentProcessor { - - private final AllureLifecycle lifecycle; - - public DefaultAttachmentProcessor() { - this(Allure.getLifecycle()); - } - - public DefaultAttachmentProcessor(final AllureLifecycle lifecycle) { - this.lifecycle = lifecycle; - } - - @Override - public void addAttachment(final AttachmentData attachmentData, - final AttachmentRenderer renderer) { - final AttachmentContent content = renderer.render(attachmentData); - lifecycle.addAttachment( - attachmentData.getName(), - content.getContentType(), - content.getFileExtension(), - content.getContent().getBytes(StandardCharsets.UTF_8) - ); - } -} 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 deleted file mode 100644 index 9179f3ed7..000000000 --- a/allure-attachments/src/main/java/io/qameta/allure/attachment/FreemarkerAttachmentRenderer.java +++ /dev/null @@ -1,53 +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.attachment; - -import freemarker.template.Configuration; -import freemarker.template.Template; - -import java.io.StringWriter; -import java.io.Writer; -import java.util.Collections; - -/** - * @author charlie (Dmitry Baev). - */ -public class FreemarkerAttachmentRenderer implements AttachmentRenderer { - - private final Configuration configuration; - - private final String templateName; - - public FreemarkerAttachmentRenderer(final String templateName) { - this.templateName = templateName; - this.configuration = new Configuration(Configuration.VERSION_2_3_23); - this.configuration.setLocalizedLookup(false); - this.configuration.setTemplateUpdateDelayMilliseconds(0); - this.configuration.setClassLoaderForTemplateLoading(getClass().getClassLoader(), "tpl"); - } - - @Override - public DefaultAttachmentContent render(final AttachmentData data) { - try (Writer writer = new StringWriter()) { - final Template template = configuration.getTemplate(templateName); - template.process(Collections.singletonMap("data", data), writer); - return new DefaultAttachmentContent(writer.toString(), "text/html", ".html"); - } catch (Exception e) { - 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 deleted file mode 100644 index 831b2a5da..000000000 --- a/allure-attachments/src/main/java/io/qameta/allure/attachment/http/HttpRequestAttachment.java +++ /dev/null @@ -1,240 +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.attachment.http; - -import io.qameta.allure.attachment.AttachmentData; - -import java.util.HashMap; -import java.util.Map; -import java.util.Objects; - -/** - * @author charlie (Dmitry Baev). - */ -public class HttpRequestAttachment implements AttachmentData { - - private final String name; - - private final String url; - - private final String method; - - private final String body; - - private final String curl; - - private final Map headers; - - private final Map cookies; - - 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 = name; - this.url = url; - this.method = method; - this.body = body; - this.curl = curl; - this.headers = headers; - this.cookies = cookies; - } - - public String getUrl() { - return url; - } - - public String getMethod() { - return method; - } - - public String getBody() { - return body; - } - - public Map getHeaders() { - return headers; - } - - public Map getCookies() { - return cookies; - } - - public String getCurl() { - return curl; - } - - @Override - public String getName() { - return name; - } - - /** - * Builder for HttpRequestAttachment. - */ - @SuppressWarnings("PMD.AvoidFieldNameMatchingMethodName") - public static final class Builder { - - private final String name; - - private final String url; - - private String method; - - private String body; - - private final Map headers = new HashMap<>(); - - private final Map cookies = 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"); - this.name = name; - this.url = url; - } - - public static Builder create(final String attachmentName, final String url) { - return new Builder(attachmentName, url); - } - - public Builder setMethod(final String method) { - Objects.requireNonNull(method, "Method must not be null value"); - this.method = method; - return this; - } - - public Builder setHeader(final String name, final String value) { - Objects.requireNonNull(name, "Header name must not be null value"); - Objects.requireNonNull(value, "Header value must not be null value"); - this.headers.put(name, value); - return this; - } - - public Builder setHeaders(final Map headers) { - Objects.requireNonNull(headers, "Headers must not be null value"); - this.headers.putAll(headers); - return this; - } - - public Builder setCookie(final String name, final String value) { - Objects.requireNonNull(name, "Cookie name must not be null value"); - Objects.requireNonNull(value, "Cookie value must not be null value"); - this.cookies.put(name, value); - return this; - } - - public Builder setCookies(final Map cookies) { - Objects.requireNonNull(cookies, "Cookies must not be null value"); - this.cookies.putAll(cookies); - return this; - } - - public Builder setBody(final String body) { - Objects.requireNonNull(body, "Body should not be null value"); - this.body = body; - return this; - } - - /** - * Use setter method instead. - * @deprecated scheduled for removal in 3.0 release - */ - @Deprecated - public Builder withMethod(final String method) { - return setMethod(method); - } - - /** - * Use setter method instead. - * @deprecated scheduled for removal in 3.0 release - */ - @Deprecated - public Builder withHeader(final String name, final String value) { - return setHeader(name, value); - } - - /** - * Use setter method instead. - * @deprecated scheduled for removal in 3.0 release - */ - @Deprecated - public Builder withHeaders(final Map headers) { - return setHeaders(headers); - } - - /** - * Use setter method instead. - * @deprecated scheduled for removal in 3.0 release - */ - @Deprecated - public Builder withCookie(final String name, final String value) { - return setCookie(name, value); - } - - /** - * Use setter method instead. - * @deprecated scheduled for removal in 3.0 release - */ - @Deprecated - public Builder withCookies(final Map cookies) { - return setCookies(cookies); - } - - /** - * Use setter method instead. - * @deprecated scheduled for removal in 3.0 release - */ - @Deprecated - public Builder withBody(final String body) { - return setBody(body); - } - - public HttpRequestAttachment build() { - return new HttpRequestAttachment(name, url, method, body, getCurl(), headers, cookies); - } - - private String getCurl() { - final StringBuilder builder = new StringBuilder("curl -v"); - if (Objects.nonNull(method)) { - builder.append(" -X ").append(method); - } - builder.append(" '").append(url).append('\''); - headers.forEach((key, value) -> appendHeader(builder, key, value)); - cookies.forEach((key, value) -> appendCookie(builder, key, value)); - - if (Objects.nonNull(body)) { - builder.append(" -d '").append(body).append('\''); - } - return builder.toString(); - } - - private static void appendHeader(final StringBuilder builder, final String key, final String value) { - builder.append(" -H '") - .append(key) - .append(": ") - .append(value) - .append('\''); - } - - private static void appendCookie(final StringBuilder builder, final String key, final String value) { - builder.append(" -b '") - .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 deleted file mode 100644 index 0b5ef9841..000000000 --- a/allure-attachments/src/main/java/io/qameta/allure/attachment/http/HttpResponseAttachment.java +++ /dev/null @@ -1,214 +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.attachment.http; - -import io.qameta.allure.attachment.AttachmentData; - -import java.util.HashMap; -import java.util.Map; -import java.util.Objects; - -/** - * @author charlie (Dmitry Baev). - */ -public class HttpResponseAttachment implements AttachmentData { - - private final String name; - - private final String url; - - private final String body; - - private final int responseCode; - - private final Map headers; - - private final Map cookies; - - public HttpResponseAttachment(final String name, final String url, - final String body, final int responseCode, - final Map headers, final Map cookies) { - this.name = name; - this.url = url; - this.body = body; - this.responseCode = responseCode; - this.headers = headers; - this.cookies = cookies; - } - - @Override - public String getName() { - return name; - } - - public String getUrl() { - return url; - } - - public String getBody() { - return body; - } - - public int getResponseCode() { - return responseCode; - } - - public Map getHeaders() { - return headers; - } - - public Map getCookies() { - return cookies; - } - - /** - * Builder for HttpRequestAttachment. - */ - @SuppressWarnings("PMD.AvoidFieldNameMatchingMethodName") - public static final class Builder { - - private final String name; - - private String url; - - private int responseCode; - - private String body; - - private final Map headers = new HashMap<>(); - - private final Map cookies = new HashMap<>(); - - private Builder(final String name) { - Objects.requireNonNull(name, "Name must not be null value"); - this.name = name; - } - - public static Builder create(final String attachmentName) { - return new Builder(attachmentName); - } - - public Builder setUrl(final String url) { - Objects.requireNonNull(url, "Url must not be null value"); - this.url = url; - return this; - } - - public Builder setResponseCode(final int responseCode) { - this.responseCode = responseCode; - return this; - } - - public Builder setHeader(final String name, final String value) { - Objects.requireNonNull(name, "Header name must not be null value"); - Objects.requireNonNull(value, "Header value must not be null value"); - this.headers.put(name, value); - return this; - } - - public Builder setHeaders(final Map headers) { - Objects.requireNonNull(headers, "Headers must not be null value"); - this.headers.putAll(headers); - return this; - } - - public Builder setCookie(final String name, final String value) { - Objects.requireNonNull(name, "Cookie name must not be null value"); - Objects.requireNonNull(value, "Cookie value must not be null value"); - this.cookies.put(name, value); - return this; - } - - public Builder setCookies(final Map cookies) { - Objects.requireNonNull(cookies, "Cookies must not be null value"); - this.cookies.putAll(cookies); - return this; - } - - public Builder setBody(final String body) { - Objects.requireNonNull(body, "Body should not be null value"); - this.body = body; - return this; - } - - /** - * Use setter method instead. - * @deprecated scheduled for removal in 3.0 release - */ - @Deprecated - public Builder withUrl(final String url) { - return setUrl(url); - } - - /** - * Use setter method instead. - * @deprecated scheduled for removal in 3.0 release - */ - @Deprecated - public Builder withResponseCode(final int responseCode) { - return setResponseCode(responseCode); - } - - /** - * Use setter method instead. - * @deprecated scheduled for removal in 3.0 release - */ - @Deprecated - public Builder withHeader(final String name, final String value) { - return setHeader(name, value); - } - - /** - * Use setter method instead. - * @deprecated scheduled for removal in 3.0 release - */ - @Deprecated - public Builder withHeaders(final Map headers) { - return setHeaders(headers); - } - - /** - * Use setter method instead. - * @deprecated scheduled for removal in 3.0 release - */ - @Deprecated - public Builder withCookie(final String name, final String value) { - return setCookie(name, value); - } - - /** - * Use setter method instead. - * @deprecated scheduled for removal in 3.0 release - */ - @Deprecated - public Builder withCookies(final Map cookies) { - return setCookies(cookies); - } - - /** - * Use setter method instead. - * @deprecated scheduled for removal in 3.0 release - */ - @Deprecated - public Builder withBody(final String body) { - return setBody(body); - } - - public HttpResponseAttachment build() { - return new HttpResponseAttachment(name, url, body, responseCode, headers, cookies); - } - } -} diff --git a/allure-attachments/src/main/resources/tpl/http-request.ftl b/allure-attachments/src/main/resources/tpl/http-request.ftl deleted file mode 100644 index 5ef76b521..000000000 --- a/allure-attachments/src/main/resources/tpl/http-request.ftl +++ /dev/null @@ -1,38 +0,0 @@ -<#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
- -<#if data.body??> -

Body

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

Headers

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

Cookies

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

Curl

-
-${data.curl} -
- diff --git a/allure-attachments/src/main/resources/tpl/http-response.ftl b/allure-attachments/src/main/resources/tpl/http-response.ftl deleted file mode 100644 index 2bc55de9f..000000000 --- a/allure-attachments/src/main/resources/tpl/http-response.ftl +++ /dev/null @@ -1,32 +0,0 @@ -<#ftl output_format="HTML"> -<#-- @ftlvariable name="data" type="io.qameta.allure.attachment.http.HttpResponseAttachment" --> -
Status code <#if data.responseCode??>${data.responseCode} <#else>Unknown
-<#if data.url??>
${data.url}
- -<#if data.body??> -

Body

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

Headers

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

Cookies

-
- <#list data.cookies as name, value> -
${name}: ${value}
- -
- 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 deleted file mode 100644 index 1283d6116..000000000 --- a/allure-attachments/src/test/java/io/qameta/allure/attachment/DefaultAttachmentProcessorTest.java +++ /dev/null @@ -1,62 +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.attachment; - -import io.qameta.allure.AllureLifecycle; -import io.qameta.allure.attachment.http.HttpRequestAttachment; -import io.qameta.allure.test.AllureFeatures; -import org.junit.jupiter.api.Test; - -import java.nio.charset.StandardCharsets; - -import static io.qameta.allure.attachment.testdata.TestData.randomAttachmentContent; -import static io.qameta.allure.attachment.testdata.TestData.randomHttpRequestAttachment; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; - -/** - * @author charlie (Dmitry Baev). - */ -class DefaultAttachmentProcessorTest { - - @SuppressWarnings("unchecked") - @AllureFeatures.Attachments - @Test - void shouldProcessAttachments() { - final HttpRequestAttachment attachment = randomHttpRequestAttachment(); - final AllureLifecycle lifecycle = mock(AllureLifecycle.class); - final AttachmentRenderer renderer = mock(AttachmentRenderer.class); - final AttachmentContent content = randomAttachmentContent(); - doReturn(content) - .when(renderer) - .render(attachment); - - new DefaultAttachmentProcessor(lifecycle) - .addAttachment(attachment, renderer); - - verify(renderer, times(1)).render(attachment); - verify(lifecycle, times(1)) - .addAttachment( - eq(attachment.getName()), - eq(content.getContentType()), - eq(content.getFileExtension()), - eq(content.getContent().getBytes(StandardCharsets.UTF_8)) - ); - } -} 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 deleted file mode 100644 index 75ddac32d..000000000 --- a/allure-attachments/src/test/java/io/qameta/allure/attachment/FreemarkerAttachmentRendererTest.java +++ /dev/null @@ -1,55 +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.attachment; - -import io.qameta.allure.attachment.http.HttpRequestAttachment; -import io.qameta.allure.test.AllureFeatures; -import org.junit.jupiter.api.Test; - -import static io.qameta.allure.attachment.testdata.TestData.randomHttpRequestAttachment; -import static org.assertj.core.api.Assertions.assertThat; - -/** - * @author charlie (Dmitry Baev). - */ -class FreemarkerAttachmentRendererTest { - - @AllureFeatures.Attachments - @Test - void shouldRenderRequestAttachment() { - final HttpRequestAttachment data = randomHttpRequestAttachment(); - final DefaultAttachmentContent content = new FreemarkerAttachmentRenderer("http-request.ftl") - .render(data); - - assertThat(content) - .hasFieldOrPropertyWithValue("contentType", "text/html") - .hasFieldOrPropertyWithValue("fileExtension", ".html") - .hasFieldOrProperty("content"); - } - - @AllureFeatures.Attachments - @Test - void shouldRenderResponseAttachment() { - final HttpRequestAttachment data = randomHttpRequestAttachment(); - final DefaultAttachmentContent content = new FreemarkerAttachmentRenderer("http-response.ftl") - .render(data); - - assertThat(content) - .hasFieldOrPropertyWithValue("contentType", "text/html") - .hasFieldOrPropertyWithValue("fileExtension", ".html") - .hasFieldOrProperty("content"); - } -} 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 deleted file mode 100644 index 26eba1c6f..000000000 --- a/allure-attachments/src/test/java/io/qameta/allure/attachment/testdata/TestData.java +++ /dev/null @@ -1,74 +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.attachment.testdata; - -import io.qameta.allure.attachment.AttachmentContent; -import io.qameta.allure.attachment.DefaultAttachmentContent; -import io.qameta.allure.attachment.http.HttpRequestAttachment; -import io.qameta.allure.attachment.http.HttpResponseAttachment; -import org.apache.commons.lang3.RandomStringUtils; - -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.ThreadLocalRandom; - -/** - * @author charlie (Dmitry Baev). - */ -public final class TestData { - - private TestData() { - throw new IllegalStateException("Do not instance"); - } - - public static String randomString() { - return RandomStringUtils.randomAlphabetic(10); - } - - public static HttpRequestAttachment randomHttpRequestAttachment() { - return new HttpRequestAttachment( - randomString(), - randomString(), - randomString(), - randomString(), - randomString(), - randomMap(), - randomMap() - ); - } - - public static HttpResponseAttachment randomHttpResponseAttachment() { - return new HttpResponseAttachment( - randomString(), - randomString(), - randomString(), - ThreadLocalRandom.current().nextInt(), - randomMap(), - randomMap() - ); - } - - public static AttachmentContent randomAttachmentContent() { - return new DefaultAttachmentContent(randomString(), randomString(), randomString()); - } - - public static Map randomMap() { - final Map map = new HashMap<>(); - map.put(randomString(), randomString()); - map.put(randomString(), randomString()); - return map; - } -} diff --git a/allure-awaitility/README.md b/allure-awaitility/README.md new file mode 100644 index 000000000..51c1f72f8 --- /dev/null +++ b/allure-awaitility/README.md @@ -0,0 +1,54 @@ +# allure-awaitility + +Awaitility condition listener integration for Allure Java. + +Use this module when your tests wait with Awaitility and you want polling attempts, timeouts, ignored exceptions, and successful waits to be visible in Allure Report. + +## Supported Versions + +- Allure Java 3.x requires Java 17 or newer. +- This module targets Awaitility 4.x. +- The current build validates against Awaitility 4.3.0. + +## Installation + +Gradle: + +```kotlin +dependencies { + testImplementation(platform("io.qameta.allure:allure-bom:")) + testImplementation("io.qameta.allure:allure-awaitility") +} +``` + +Maven, with `allure-bom` imported in dependency management: + +```xml + + io.qameta.allure + allure-awaitility + test + +``` + +## Setup + +Register `io.qameta.allure.awaitility.AllureAwaitilityListener` as an Awaitility condition listener. + +```java +await() + .conditionEvaluationListener(new AllureAwaitilityListener()) + .untilAsserted(() -> assertThat(service.isReady()).isTrue()); +``` + +You can also set it as the default listener for your test suite: + +```java +Awaitility.setDefaultConditionEvaluationListener(new AllureAwaitilityListener()); +``` + +## Report Output + +- Await start, poll, satisfaction, timeout, and exception events. +- Poll attempts as nested Allure steps. +- Timing information rendered through `TemporalDuration`. diff --git a/allure-cucumber3-jvm/build.gradle.kts b/allure-awaitility/build.gradle.kts similarity index 56% rename from allure-cucumber3-jvm/build.gradle.kts rename to allure-awaitility/build.gradle.kts index 681f1ab7c..b48443834 100644 --- a/allure-cucumber3-jvm/build.gradle.kts +++ b/allure-awaitility/build.gradle.kts @@ -1,35 +1,33 @@ -description = "Allure CucumberJVM 3.0 Integration" +description = "Allure Awaitlity Integration" val agent: Configuration by configurations.creating -val cucumberVersion = "3.0.2" +val awaitilityVersion = "4.3.0" dependencies { agent("org.aspectj:aspectjweaver") api(project(":allure-java-commons")) - implementation("io.cucumber:cucumber-core:$cucumberVersion") - implementation("io.cucumber:cucumber-java:$cucumberVersion") - testImplementation("commons-io:commons-io") - testImplementation("io.github.glytching:junit-extensions") + compileOnly("org.awaitility:awaitility:$awaitilityVersion") + testImplementation("jakarta.annotation:jakarta.annotation-api") testImplementation("org.assertj:assertj-core") + testImplementation(project(":allure-assertj")) + testImplementation("org.awaitility:awaitility:$awaitilityVersion") testImplementation("org.junit.jupiter:junit-jupiter-api") testImplementation("org.slf4j:slf4j-simple") testImplementation(project(":allure-java-commons-test")) testImplementation(project(":allure-junit-platform")) testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") } tasks.jar { manifest { attributes(mapOf( - "Automatic-Module-Name" to "io.qameta.allure.cucumber3jvm" + "Automatic-Module-Name" to "io.qameta.allure.awaitility" )) } } tasks.test { useJUnitPlatform() - doFirst { - jvmArgs("-javaagent:${agent.singleFile}") - } } 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 new file mode 100644 index 000000000..d3afd8afb --- /dev/null +++ b/allure-awaitility/src/main/java/io/qameta/allure/awaitility/AllureAwaitilityListener.java @@ -0,0 +1,325 @@ +/* + * 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.Allure; +import io.qameta.allure.AllureExternalKey; +import io.qameta.allure.AllureLifecycle; +import io.qameta.allure.AllureThreadBinding; +import io.qameta.allure.AttachmentOptions; +import io.qameta.allure.model.Status; +import io.qameta.allure.model.StepResult; +import org.awaitility.Awaitility; +import org.awaitility.core.ConditionEvaluationListener; +import org.awaitility.core.ConditionFactory; +import org.awaitility.core.EvaluatedCondition; +import org.awaitility.core.IgnoredException; +import org.awaitility.core.StartEvaluationEvent; +import org.awaitility.core.TimeoutEvent; + +import java.io.ByteArrayInputStream; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; + +/** + *

+ * Class implements Awaitility condition listener to log all awaiting and polls as {@link io.qameta.allure.Step}. + *

+ *

+ * Usage with single condition + *

+ * 
+ * Awaitility.await()
+ *     .conditionEvaluationListener(new AllureAwaitilityListener())
+ *     .until(() -> somethingHappen());
+ * 
+ * 
+ *

+ *

+ * Usage globally for all conditions + *

+ * 
+ * Awaitility.setDefaultConditionEvaluationListener(new AllureAwaitilityListener());
+ * 
+ * 
+ *

+ *

+ * A listener instance tracks a single condition at a time, so prefer one instance per condition when tests run in + * parallel. Awaitility fires no callback when a non-ignored exception escapes a condition; such an aborted wait + * stays the reporting parent until the same listener starts its next condition, which finalizes the aborted one, + * or until the test ends. + *

+ * + * @see org.awaitility.core.ConditionEvaluationListener + * @see Awaitility#setDefaultConditionEvaluationListener(ConditionEvaluationListener) + * @see ConditionFactory#conditionEvaluationListener(ConditionEvaluationListener) + * @see awaitlity wiki + */ +@SuppressWarnings("unused") +public class AllureAwaitilityListener implements ConditionEvaluationListener { + + private final AtomicReference unit = new AtomicReference<>(MILLISECONDS); + private final AtomicBoolean logIgnoredExceptions = new AtomicBoolean(true); + private final String onStartStepTextPattern; + private final String onSatisfiedStepTextPattern; + private final String onAwaitStepTextPattern; + private final String onTimeoutStepTextPattern; + private final String onExceptionStepTextPattern; + + private final AtomicReference currentCondition = new AtomicReference<>(); + + /** + * Returns the lifecycle. + * + * @return the Allure lifecycle used by this integration + */ + public static AllureLifecycle getLifecycle() { + return Allure.getLifecycle(); + } + + /** + * Default all args constructor with default params. + */ + public AllureAwaitilityListener() { + this.onStartStepTextPattern = "Awaitility: %s"; + this.onSatisfiedStepTextPattern = "%s after %d %s (remaining time %d %s, last poll interval was %s)"; + this.onAwaitStepTextPattern = "%s (elapsed time %d %s, remaining time %d %s (last poll interval was %s))"; + this.onTimeoutStepTextPattern = "Condition timeout. %s"; + this.onExceptionStepTextPattern = "Exception ignored. %s"; + } + + /** + * Set default timeunit used in awaitility in project for properly printing. + * + * @param unit default timeunit in project + * @return this factory + */ + public AllureAwaitilityListener setUnit(final TimeUnit unit) { + this.unit.set(unit); + return this; + } + + /** + * Set logging ignored exceptions. True by default. + * + * @param logging to log or not + * @return this factory + */ + public AllureAwaitilityListener setLogIgnoredExceptions(final boolean logging) { + this.logIgnoredExceptions.set(logging); + return this; + } + + /** + * Method creates top-level step with short description about condition. + * + * @param startEvaluationEvent condition evaluation started + */ + @Override + public void beforeEvaluation(final StartEvaluationEvent startEvaluationEvent) { + finishCurrentCondition(); + final AllureLifecycle lifecycle = getLifecycle(); + lifecycle.getCurrentExecutableKey().ifPresent(parent -> { + final String nameWoAlias = String.format(onStartStepTextPattern, startEvaluationEvent.getDescription()); + final String nameWithAlias = String.format(onStartStepTextPattern, startEvaluationEvent.getAlias()); + final String stepName = startEvaluationEvent.getAlias() != null ? nameWithAlias : nameWoAlias; + final AllureExternalKey conditionStepKey = AllureExternalKey.random(AllureAwaitilityListener.class); + lifecycle.startStep( + parent, + conditionStepKey, + new StepResult() + .setName(stepName) + .setDescription("Awaitility condition started") + .setStatus(Status.FAILED) + ); + final ConditionState condition = new ConditionState(lifecycle, conditionStepKey); + currentCondition.set(condition); + // Keep condition-body steps under the wait. The binding remembers the caller thread's stack, so + // Awaitility may close it from a polling callback without mutating a reused worker's unrelated context. + condition.setBinding(lifecycle.bindDetached(conditionStepKey)); + }); + } + + /** + * Logging timeout evaluation result. Method should create second-level step with useful info about timeout. + * + * @param timeoutEvent poling ended with timeout + */ + @Override + public void onTimeout(final TimeoutEvent timeoutEvent) { + final ConditionState condition = currentCondition.get(); + if (condition == null) { + return; + } + condition.getLifecycle().logStep( + condition.getStepKey(), + new StepResult() + .setName(String.format(onTimeoutStepTextPattern, timeoutEvent.getDescription())) + .setDescription("Awaitility condition timeout") + .setStatus(Status.BROKEN) + ); + finishCondition(condition); + } + + /** + * Logging any evaluation result. Method should create second-level step with evaluation result and useful info. + * + * @param condition evaluation result for poling iteration + */ + @Override + public void conditionEvaluated(final EvaluatedCondition condition) { + final TimeUnit currentUnit = unit.get(); + final String description = condition.getDescription(); + final long elapsedTime = currentUnit.convert(condition.getElapsedTimeInMS(), MILLISECONDS); + final long remainingTime = currentUnit.convert(condition.getRemainingTimeInMS(), MILLISECONDS); + final String unitAsString = currentUnit.toString().toLowerCase(); + + final String message = String.format( + condition.isSatisfied() ? onSatisfiedStepTextPattern : onAwaitStepTextPattern, + description, + elapsedTime, + unitAsString, + remainingTime, + unitAsString, + new TemporalDuration(condition.getPollInterval()) + ); + + final ConditionState current = currentCondition.get(); + if (current == null) { + return; + } + current.getLifecycle().logStep( + current.getStepKey(), + new StepResult() + .setName(message) + .setDescription("Awaitility condition satisfied or not, but awaiting still in progress") + .setStatus(Status.PASSED) + ); + if (condition.isSatisfied()) { + current.getLifecycle().updateStep( + current.getStepKey(), awaitilityCondition -> awaitilityCondition.setStatus(Status.PASSED) + ); + finishCondition(current); + } + } + + private void finishCurrentCondition() { + final ConditionState condition = currentCondition.getAndSet(null); + if (condition != null) { + condition.finish(); + } + } + + private void finishCondition(final ConditionState condition) { + currentCondition.compareAndSet(condition, null); + condition.finish(); + } + + private static final class ConditionState { + + private final AllureLifecycle lifecycle; + private final AllureExternalKey stepKey; + private final AtomicReference binding = new AtomicReference<>(); + private final AtomicBoolean finished = new AtomicBoolean(); + + private ConditionState(final AllureLifecycle lifecycle, final AllureExternalKey stepKey) { + this.lifecycle = lifecycle; + this.stepKey = stepKey; + } + + private AllureLifecycle getLifecycle() { + return lifecycle; + } + + private AllureExternalKey getStepKey() { + return stepKey; + } + + private void setBinding(final AllureThreadBinding value) { + binding.set(value); + if (finished.get()) { + closeBinding(); + } + } + + private void finish() { + if (finished.compareAndSet(false, true)) { + try { + closeBinding(); + } finally { + lifecycle.stopStep(stepKey); + } + } + } + + private void closeBinding() { + final AllureThreadBinding current = binding.getAndSet(null); + if (current != null) { + current.close(); + } + } + } + + /** + * Logging ignored exceptions while poling conditions. Active only with + *
await().with().ignoreExceptions()
+ * or + *
Awaitility.ignoreExceptionsByDefault()
+ * + * @param ignoredException ignored exception + * @see Awaitility#ignoreExceptionsByDefault() + * @see Awaitility#ignoreExceptionByDefault(Class) + * @see ConditionFactory#ignoreExceptions() + * @see ConditionFactory#ignoreException(Class) + * @see awaitlity wiki + */ + @Override + public void exceptionIgnored(final IgnoredException ignoredException) { + final ConditionState condition = currentCondition.get(); + if (logIgnoredExceptions.get() && condition != null) { + final AllureExternalKey exceptionIgnoredStepKey = AllureExternalKey.random(AllureAwaitilityListener.class); + final String message = String.format( + onExceptionStepTextPattern, ignoredException.getThrowable().getMessage() + ); + final StringWriter stringWriter = new StringWriter(); + ignoredException.getThrowable().printStackTrace(new PrintWriter(stringWriter)); + final String stackTrace = stringWriter.toString(); + condition.getLifecycle().startStep( + condition.getStepKey(), + exceptionIgnoredStepKey, + new StepResult() + .setName(message) + .setDescription("Exception occurred and ignored, but awaiting still in progress") + .setStatus(Status.SKIPPED) + ); + condition.getLifecycle().addAttachment( + exceptionIgnoredStepKey, + ignoredException.getThrowable().getMessage(), + "text/plain", + new ByteArrayInputStream(stackTrace.getBytes(StandardCharsets.UTF_8)), + AttachmentOptions.empty() + ); + condition.getLifecycle().stopStep(exceptionIgnoredStepKey); + } + } + +} 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 new file mode 100644 index 000000000..d6243f7f9 --- /dev/null +++ b/allure-awaitility/src/main/java/io/qameta/allure/awaitility/TemporalDuration.java @@ -0,0 +1,92 @@ +/* + * 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 java.time.Duration; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.time.temporal.Temporal; +import java.time.temporal.TemporalAccessor; +import java.time.temporal.TemporalField; +import java.time.temporal.UnsupportedTemporalTypeException; + +import static java.time.temporal.ChronoField.DAY_OF_MONTH; +import static java.time.temporal.ChronoField.HOUR_OF_DAY; +import static java.time.temporal.ChronoField.MILLI_OF_SECOND; +import static java.time.temporal.ChronoField.MINUTE_OF_HOUR; +import static java.time.temporal.ChronoField.MONTH_OF_YEAR; +import static java.time.temporal.ChronoField.SECOND_OF_MINUTE; +import static java.time.temporal.ChronoField.YEAR; + +/** + * Class helper for Duration printing purposes. + */ +public class TemporalDuration implements TemporalAccessor { + private static final Temporal BASE = LocalDateTime.of(0, 1, 1, 0, 0, 0, 0); + + private static final DateTimeFormatter DTF = new DateTimeFormatterBuilder().optionalStart() + .appendValue(YEAR) + .appendLiteral(" years ").optionalEnd() + .optionalStart().appendLiteral(' ').appendValue(MONTH_OF_YEAR).appendLiteral(" months ").optionalEnd() + .optionalStart().appendLiteral(' ').appendValue(DAY_OF_MONTH).appendLiteral(" days ").optionalEnd() + .optionalStart().appendLiteral(' ').appendValue(HOUR_OF_DAY).appendLiteral(" hours ").optionalEnd() + .optionalStart().appendLiteral(' ').appendValue(MINUTE_OF_HOUR).appendLiteral(" minutes ").optionalEnd() + .optionalStart().appendLiteral(' ').appendValue(SECOND_OF_MINUTE).appendLiteral(" seconds").optionalEnd() + .optionalStart().appendLiteral(' ').appendValue(MILLI_OF_SECOND).appendLiteral(" milliseconds") + .optionalEnd() + .toFormatter(); + + private final Duration duration; + private final Temporal temporal; + + TemporalDuration(final Duration duration) { + this.duration = duration; + this.temporal = duration.addTo(BASE); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean isSupported(final TemporalField field) { + return temporal.isSupported(field) && temporal.getLong(field) - BASE.getLong(field) != 0L; + } + + /** + * {@inheritDoc} + */ + @Override + public long getLong(final TemporalField temporalField) { + if (!isSupported(temporalField)) { + throw new UnsupportedTemporalTypeException(temporalField.toString()); + } + return temporal.getLong(temporalField) - BASE.getLong(temporalField); + } + + /** + * {@inheritDoc} + */ + @Override + public String toString() { + if (duration.compareTo(Duration.ofMillis(1)) < 0) { + return duration.toNanos() + " nanoseconds"; + } else { + return DTF.format(this).trim(); + } + } + +} 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 new file mode 100644 index 000000000..50f367968 --- /dev/null +++ b/allure-awaitility/src/test/java/io/qameta/allure/awaitility/ConditionListenersPositiveTest.java @@ -0,0 +1,319 @@ +/* + * 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.Description; +import io.qameta.allure.model.Status; +import io.qameta.allure.model.StepResult; +import io.qameta.allure.model.TestResult; +import io.qameta.allure.test.IsolatedLifecycle; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static io.qameta.allure.test.RunUtils.runWithinTestContext; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +@IsolatedLifecycle +class ConditionListenersPositiveTest { + + private static final String AWAITILITY_EVALUATION_DESCRIPTION = "Awaitility condition satisfied or not, but awaiting still in progress"; + + @BeforeAll + static void setup() { + Awaitility.pollInSameThread(); + } + + /** + * Verifies that a successful Awaitility condition with an inline Allure listener is reported as one top-level step + * when no await alias is provided. + */ + @Description + @Test + void awaitWithoutAliasShouldCreateSingleTopLevelStep() { + final List steps = runAwaitWithoutAliasTopLevelSteps(); + + assertThat(steps) + .as("Exactly 1 top level step for 1 awaitility condition") + .hasSize(1); + } + + /** + * Verifies that the top-level report step for a successful Awaitility condition with an inline listener is marked + * passed. + */ + @Description + @Test + void awaitWithoutAliasShouldMarkTopLevelStepPassed() { + final StepResult step = awaitWithoutAliasTopLevelStep(); + + assertThat(step.getStatus()) + .as("Top level step has passed status") + .isEqualTo(Status.PASSED); + } + + /** + * Verifies that a successful Awaitility condition without an alias keeps the default top-level step name in the + * Allure report. + */ + @Description + @Test + void awaitWithoutAliasShouldUseDefaultTopLevelStepName() { + final StepResult step = awaitWithoutAliasTopLevelStep(); + + assertThat(step.getName()) + .as("Top level step has default name because await() wo alias") + .isEqualTo("Awaitility: Starting evaluation"); + } + + /** + * Verifies that an Awaitility alias supplied on a condition with an inline Allure listener is visible in the + * top-level report step name. + */ + @Description + @Test + void globalSettingsAwaitWithAliasCheckTopLevelPassedStep() { + final StepResult step = awaitWithAliasTopLevelStep(); + + assertThat(step.getName()) + .as("Top level step has name with alias") + .isEqualTo("Awaitility: Counter should be at least 3"); + } + + /** + * Verifies that report consumers can see one condition-evaluation child step for each poll performed before a + * successful Awaitility condition completes. + */ + @Description + @Test + void awaitWithoutAliasShouldCreateSecondLevelStepForEachPoll() { + final List steps = awaitWithoutAliasPollSteps(); + + assertThat(steps) + .as("Exactly 4 second level steps for 4 polling iterations") + .hasSize(4); + } + + /** + * Verifies that all poll-level report steps for a successfully completed Awaitility condition are marked passed. + */ + @Description + @Test + void awaitWithoutAliasShouldMarkAllSecondLevelStepsPassed() { + final List steps = awaitWithoutAliasPollSteps(); + + assertThat(steps) + .as("All second level steps has passed statuses") + .allMatch(x -> x.getStatus().equals(Status.PASSED)); + } + + /** + * Verifies that the first failed poll records the evaluated condition, expected value, actual value, and timing + * context in the report. + */ + @Description + @Test + void awaitWithoutAliasShouldDescribeFirstFailedPoll() { + final StepResult step = firstFailedPollStep(); + + assertThat(step.getName()) + .contains("io.qameta.allure.awaitility.ConditionListenersPositiveTest") + .contains("expected: 3") + .contains("but was: 0") + .contains("elapsed time") + .contains("remaining time") + .contains("last poll interval was"); + } + + /** + * Verifies that the second failed poll records the next observed value together with the evaluated condition and + * timing context. + */ + @Description + @Test + void awaitWithoutAliasShouldDescribeSecondFailedPoll() { + final StepResult step = secondFailedPollStep(); + + assertThat(step.getName()) + .contains("io.qameta.allure.awaitility.ConditionListenersPositiveTest") + .contains("expected: 3") + .contains("but was: 1") + .contains("elapsed time") + .contains("remaining time") + .contains("last poll interval was"); + } + + /** + * Verifies that the third failed poll records the final unsuccessful value before the Awaitility condition succeeds. + */ + @Description + @Test + void awaitWithoutAliasShouldDescribeThirdFailedPoll() { + final StepResult step = thirdFailedPollStep(); + + assertThat(step.getName()) + .contains("io.qameta.allure.awaitility.ConditionListenersPositiveTest") + .contains("expected: 3") + .contains("but was: 2") + .contains("elapsed time") + .contains("remaining time") + .contains("last poll interval was"); + } + + /** + * Verifies that the successful poll reports the reached value and timing context so consumers can identify why the + * wait completed. + */ + @Description + @Test + void awaitWithoutAliasShouldDescribeSuccessfulPoll() { + final StepResult step = successfulPollStep(); + + assertThat(step.getName()) + .contains("io.qameta.allure.awaitility.ConditionListenersPositiveTest") + .contains("reached its end value after") + .contains("remaining time") + .contains("last poll interval was"); + } + + /** + * Verifies that Awaitility 4.3's supplier-and-consumer {@code untilAsserted} overload reports every supplied value + * under one successful Awaitility step. + */ + @Description + @Test + void supplierConsumerUntilAssertedShouldReportPollsUnderSingleAwaitilityStep() { + final List testResults = runWithinTestContext(() -> { + final AtomicInteger counter = new AtomicInteger(); + await("supplied counter reaches 2").with() + .conditionEvaluationListener(new AllureAwaitilityListener()) + .atMost(Duration.of(1000, ChronoUnit.MILLIS)) + .pollInterval(Duration.of(10, ChronoUnit.MILLIS)) + .untilAsserted(counter::getAndIncrement, value -> assertThat(value).isEqualTo(2)); + }).getTestResults(); + + final TestResult testResult = testResults.get(0); + assertThat(testResult.getSteps()) + .singleElement() + .satisfies(step -> { + assertThat(step.getName()).isEqualTo("Awaitility: supplied counter reaches 2"); + assertThat(step.getStatus()).isEqualTo(Status.PASSED); + final List evaluationSteps = step.getSteps().stream() + .filter(ConditionListenersPositiveTest::isAwaitilityEvaluationStep) + .toList(); + assertThat(evaluationSteps) + .hasSize(3); + }); + } + + /** + * Verifies that a nested wait with its own listener keeps the outer condition active and reports the inner + * condition beneath it. + */ + @Description + @Test + void nestedAwaitShouldKeepOuterConditionScope() { + final List testResults = runWithinTestContext( + () -> await("outer condition").with() + .pollInSameThread() + .conditionEvaluationListener(new AllureAwaitilityListener()) + .atMost(Duration.ofSeconds(1)) + .until(() -> { + await("inner condition").with() + .pollInSameThread() + .conditionEvaluationListener(new AllureAwaitilityListener()) + .atMost(Duration.ofSeconds(1)) + .until(() -> true); + return true; + }) + ).getTestResults(); + + final List topLevelSteps = testResults.get(0).getSteps(); + assertThat(topLevelSteps) + .extracting(StepResult::getName) + .containsExactly("Awaitility: outer condition"); + + final List nestedStepNames = topLevelSteps.get(0).getSteps().stream() + .map(StepResult::getName) + .toList(); + assertThat(nestedStepNames) + .as("nested condition step names") + .contains("Awaitility: inner condition"); + } + + private List runAwaitWithoutAliasTopLevelSteps() { + 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)) + .untilAsserted(() -> assertThat(atomicInteger.getAndIncrement()).isEqualTo(3)); + }).getTestResults(); + + return testResult.get(0).getSteps(); + } + + private StepResult awaitWithoutAliasTopLevelStep() { + return runAwaitWithoutAliasTopLevelSteps().get(0); + } + + private StepResult awaitWithAliasTopLevelStep() { + 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)) + .untilAsserted(() -> assertThat(atomicInteger.getAndIncrement()).isEqualTo(3)); + }).getTestResults(); + + return testResult.get(0).getSteps().get(0); + } + + private List awaitWithoutAliasPollSteps() { + return awaitWithoutAliasTopLevelStep().getSteps().stream() + .filter(ConditionListenersPositiveTest::isAwaitilityEvaluationStep) + .toList(); + } + + private static boolean isAwaitilityEvaluationStep(final StepResult step) { + return AWAITILITY_EVALUATION_DESCRIPTION.equals(step.getDescription()); + } + + private StepResult firstFailedPollStep() { + return awaitWithoutAliasPollSteps().get(0); + } + + private StepResult secondFailedPollStep() { + return awaitWithoutAliasPollSteps().get(1); + } + + private StepResult thirdFailedPollStep() { + return awaitWithoutAliasPollSteps().get(2); + } + + private StepResult successfulPollStep() { + return awaitWithoutAliasPollSteps().get(3); + } + +} diff --git a/allure-awaitility/src/test/java/io/qameta/allure/awaitility/ExceptionReportingTest.java b/allure-awaitility/src/test/java/io/qameta/allure/awaitility/ExceptionReportingTest.java new file mode 100644 index 000000000..210e09503 --- /dev/null +++ b/allure-awaitility/src/test/java/io/qameta/allure/awaitility/ExceptionReportingTest.java @@ -0,0 +1,92 @@ +/* + * 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.Allure; +import io.qameta.allure.Description; +import io.qameta.allure.model.Stage; +import io.qameta.allure.model.StepResult; +import io.qameta.allure.model.TestResult; +import io.qameta.allure.test.IsolatedLifecycle; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static io.qameta.allure.test.RunUtils.runWithinTestContext; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +@IsolatedLifecycle +class ExceptionReportingTest { + + /** + * Verifies that reusing a listener after a propagated condition exception cannot retain the failed wait as the + * parent of the next condition or later test steps, and that the aborted wait's step is finalized when the next + * condition starts. Awaitility fires no callback for a propagated exception, so a wait aborted this way is + * cleaned up when the same listener starts its next condition. + */ + @Description + @Test + void shouldDiscardAbortedConditionBindingBeforeListenerReuse() { + final IllegalStateException failure = new IllegalStateException("first condition aborted"); + final AtomicReference caught = new AtomicReference<>(); + final AllureAwaitilityListener listener = new AllureAwaitilityListener(); + + final List testResults = runWithinTestContext(() -> { + try { + await("aborting condition").with() + .pollInSameThread() + .conditionEvaluationListener(listener) + .atMost(Duration.ofSeconds(1)) + .until(() -> { + throw failure; + }); + } catch (IllegalStateException exception) { + caught.set(exception); + } + + await("following condition").with() + .pollInSameThread() + .conditionEvaluationListener(listener) + .atMost(Duration.ofSeconds(1)) + .until(() -> true); + Allure.step("step after following condition"); + }).getTestResults(); + + assertThat(caught.get()) + .isSameAs(failure); + + final List topLevelSteps = testResults.get(0).getSteps(); + assertThat(topLevelSteps) + .extracting(StepResult::getName) + .containsExactly( + "Awaitility: aborting condition", + "Awaitility: following condition", + "step after following condition" + ); + + final StepResult abortedCondition = topLevelSteps.get(0); + assertThat(abortedCondition.getStage()) + .as("aborted condition stage") + .isEqualTo(Stage.FINISHED); + assertThat(abortedCondition.getStop()) + .as("aborted condition stop time") + .isGreaterThanOrEqualTo(abortedCondition.getStart()); + } + +} diff --git a/allure-awaitility/src/test/java/io/qameta/allure/awaitility/ExecutorServiceReportingTest.java b/allure-awaitility/src/test/java/io/qameta/allure/awaitility/ExecutorServiceReportingTest.java new file mode 100644 index 000000000..e22e2352f --- /dev/null +++ b/allure-awaitility/src/test/java/io/qameta/allure/awaitility/ExecutorServiceReportingTest.java @@ -0,0 +1,86 @@ +/* + * 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.Allure; +import io.qameta.allure.Description; +import io.qameta.allure.Issue; +import io.qameta.allure.model.StepResult; +import io.qameta.allure.model.TestResult; +import io.qameta.allure.test.IsolatedLifecycle; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static io.qameta.allure.test.RunUtils.runWithinTestContext; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +@IsolatedLifecycle +class ExecutorServiceReportingTest { + + /** + * Verifies that reusing an Awaitility polling thread cannot retain a finished wait as the parent of steps produced + * later by the test, protecting the report hierarchy regression described in issue 891. + */ + @Description + @Issue("891") + @Test + void shouldKeepFollowingStepsAtTestLevelWhenExecutorIsReused() throws Exception { + final ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + executor.submit(() -> { + // Start the reusable worker before an Allure test context exists. + }).get(5, SECONDS); + + final AllureAwaitilityListener listener = new AllureAwaitilityListener(); + final List testResults = runWithinTestContext(() -> { + await("first condition").with() + .pollExecutorService(executor) + .conditionEvaluationListener(listener) + .atMost(Duration.ofSeconds(1)) + .until(() -> true); + Allure.step("step after first condition"); + + await("second condition").with() + .pollExecutorService(executor) + .conditionEvaluationListener(listener) + .atMost(Duration.ofSeconds(1)) + .until(() -> true); + Allure.step("step after second condition"); + }).getTestResults(); + + final TestResult testResult = testResults.get(0); + final List topLevelSteps = testResult.getSteps(); + + assertThat(topLevelSteps) + .extracting(StepResult::getName) + .containsExactly( + "Awaitility: first condition", + "step after first condition", + "Awaitility: second condition", + "step after second condition" + ); + } finally { + executor.shutdownNow(); + } + } + +} 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 new file mode 100644 index 000000000..61d5f3ff8 --- /dev/null +++ b/allure-awaitility/src/test/java/io/qameta/allure/awaitility/GlobalSettingsNegativeTest.java @@ -0,0 +1,177 @@ +/* + * 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.Description; +import io.qameta.allure.model.Status; +import io.qameta.allure.model.StepResult; +import io.qameta.allure.model.TestResult; +import io.qameta.allure.test.IsolatedLifecycle; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static io.qameta.allure.test.RunUtils.runWithinTestContext; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +@TestInstance(TestInstance.Lifecycle.PER_METHOD) +@IsolatedLifecycle +class GlobalSettingsNegativeTest { + + private static final String AWAITILITY_EVALUATION_DESCRIPTION = "Awaitility condition satisfied or not, but awaiting still in progress"; + + private static final String AWAITILITY_TIMEOUT_DESCRIPTION = "Awaitility condition timeout"; + + @AfterEach + void reset() { + Awaitility.reset(); + } + + @BeforeEach + void setup() { + Awaitility.pollInSameThread(); + Awaitility.setDefaultConditionEvaluationListener(new AllureAwaitilityListener()); + } + + /** + * Verifies that a timed-out Awaitility condition using the global Allure listener marks the top-level wait step as + * failed. + */ + @Description + @Test + void globalSettingsAwaitWoAliasCheckTopLevelFailedStep() { + final StepResult step = timedOutAwaitTopLevelStep(); + + assertThat(step.getStatus()) + .as("Top level step has failed status") + .isEqualTo(Status.FAILED); + } + + /** + * Verifies that a timed-out Awaitility condition exposes both the failed condition-evaluation step and the timeout + * event as child report steps. + */ + @Description + @Test + void timedOutAwaitShouldCreateSecondLevelStepForFailedPollAndTimeout() { + final List steps = timedOutAwaitPollSteps(); + + assertThat(steps) + .as("Exactly 2 second level steps for 2 polling iterations") + .hasSize(2); + } + + /** + * Verifies that the failed poll before timeout records the evaluated condition, expected and actual values, and + * timing context. + */ + @Description + @Test + void timedOutAwaitShouldDescribeFailedPoll() { + final StepResult step = failedPollStepBeforeTimeout(); + + assertThat(step.getName()) + .contains("io.qameta.allure.awaitility.GlobalSettingsNegativeTest") + .contains("expected: 3") + .contains("but was: 0") + .contains("elapsed time") + .contains("remaining time") + .contains("last poll interval was"); + } + + /** + * Verifies that a failed poll that has not yet exhausted the wait timeout remains passed in the report. + */ + @Description + @Test + void timedOutAwaitShouldMarkFailedPollPassed() { + final StepResult step = failedPollStepBeforeTimeout(); + + assertThat(step.getStatus()) + .isEqualTo(Status.PASSED); + } + + /** + * Verifies that the terminal poll for a timed-out wait clearly reports the timeout condition to report consumers. + */ + @Description + @Test + void timedOutAwaitShouldDescribeTimeoutPoll() { + final StepResult step = timeoutPollStep(); + + assertThat(step.getName()) + .contains("Condition timeout.") + .contains("io.qameta.allure.awaitility.GlobalSettingsNegativeTest"); + } + + /** + * Verifies that the terminal timeout poll is marked broken so the report distinguishes timeout failure from normal + * polling attempts. + */ + @Description + @Test + void timedOutAwaitShouldMarkTimeoutPollBroken() { + final StepResult step = timeoutPollStep(); + + assertThat(step.getStatus()) + .isEqualTo(Status.BROKEN); + } + + private List runTimedOutAwaitWithoutAliasTopLevelSteps() { + final List testResult = runWithinTestContext(() -> { + final AtomicInteger atomicInteger = new AtomicInteger(0); + await().with() + .atMost(Duration.of(1000, ChronoUnit.MILLIS)) + .pollInterval(Duration.of(500, ChronoUnit.MILLIS)) + .untilAsserted(() -> assertThat(atomicInteger.getAndIncrement()).isEqualTo(3)); + } + ).getTestResults(); + + return testResult.get(0).getSteps(); + } + + private StepResult timedOutAwaitTopLevelStep() { + return runTimedOutAwaitWithoutAliasTopLevelSteps().get(0); + } + + private List timedOutAwaitPollSteps() { + return timedOutAwaitTopLevelStep().getSteps().stream() + .filter(GlobalSettingsNegativeTest::isAwaitilityEvaluationOrTimeoutStep) + .toList(); + } + + private static boolean isAwaitilityEvaluationOrTimeoutStep(final StepResult step) { + return AWAITILITY_EVALUATION_DESCRIPTION.equals(step.getDescription()) + || AWAITILITY_TIMEOUT_DESCRIPTION.equals(step.getDescription()); + } + + private StepResult failedPollStepBeforeTimeout() { + return timedOutAwaitPollSteps().get(0); + } + + private StepResult timeoutPollStep() { + return timedOutAwaitPollSteps().get(1); + } + +} 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 new file mode 100644 index 000000000..0a89f8ea5 --- /dev/null +++ b/allure-awaitility/src/test/java/io/qameta/allure/awaitility/GlobalSettingsPositiveTest.java @@ -0,0 +1,264 @@ +/* + * 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.Description; +import io.qameta.allure.model.Status; +import io.qameta.allure.model.StepResult; +import io.qameta.allure.model.TestResult; +import io.qameta.allure.test.IsolatedLifecycle; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static io.qameta.allure.test.RunUtils.runWithinTestContext; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +@TestInstance(TestInstance.Lifecycle.PER_METHOD) +@IsolatedLifecycle +class GlobalSettingsPositiveTest { + + private static final String AWAITILITY_EVALUATION_DESCRIPTION = "Awaitility condition satisfied or not, but awaiting still in progress"; + + @AfterEach + void reset() { + Awaitility.reset(); + } + + @BeforeEach + void setup() { + Awaitility.pollInSameThread(); + Awaitility.setDefaultConditionEvaluationListener(new AllureAwaitilityListener()); + } + + /** + * Verifies that a successful Awaitility condition using the global Allure listener is reported as one top-level step + * when no await alias is provided. + */ + @Description + @Test + void awaitWithoutAliasShouldCreateSingleTopLevelStep() { + final List steps = runAwaitWithoutAliasTopLevelSteps(); + + assertThat(steps) + .as("Exactly 1 top level step for 1 awaitility condition") + .hasSize(1); + } + + /** + * Verifies that the top-level report step created by the global Awaitility listener is marked passed for a + * successful condition. + */ + @Description + @Test + void awaitWithoutAliasShouldMarkTopLevelStepPassed() { + final StepResult step = awaitWithoutAliasTopLevelStep(); + + assertThat(step.getStatus()) + .as("Top level step has passed status") + .isEqualTo(Status.PASSED); + } + + /** + * Verifies that a successful globally-listened Awaitility condition without an alias keeps the default top-level + * step name in the report. + */ + @Description + @Test + void awaitWithoutAliasShouldUseDefaultTopLevelStepName() { + final StepResult step = awaitWithoutAliasTopLevelStep(); + + assertThat(step.getName()) + .as("Top level step has default name because await() wo alias") + .isEqualTo("Awaitility: Starting evaluation"); + } + + /** + * Verifies that an Awaitility alias is preserved in the top-level report step when the listener is configured + * globally. + */ + @Description + @Test + void globalSettingsAwaitWithAliasCheckTopLevelPassedStep() { + final StepResult step = awaitWithAliasTopLevelStep(); + + assertThat(step.getName()) + .as("Top level step has name with alias") + .isEqualTo("Awaitility: Counter should be at least 3"); + } + + /** + * Verifies that the global Awaitility listener creates one condition-evaluation child report step for each poll + * before a successful condition completes. + */ + @Description + @Test + void awaitWithoutAliasShouldCreateSecondLevelStepForEachPoll() { + final List steps = awaitWithoutAliasPollSteps(); + + assertThat(steps) + .as("Exactly 4 second level steps for 4 polling iterations") + .hasSize(4); + } + + /** + * Verifies that every poll-level report step generated by the global listener is passed for a successful condition. + */ + @Description + @Test + void awaitWithoutAliasShouldMarkAllSecondLevelStepsPassed() { + final List steps = awaitWithoutAliasPollSteps(); + + assertThat(steps) + .extracting(StepResult::getStatus) + .containsExactlyInAnyOrder( + Status.PASSED, + Status.PASSED, + Status.PASSED, + Status.PASSED + ); + } + + /** + * Verifies that the first failed poll from the global listener reports the evaluated condition, expected and actual + * values, and timing context. + */ + @Description + @Test + void awaitWithoutAliasShouldDescribeFirstFailedPoll() { + final StepResult step = firstFailedPollStep(); + + assertThat(step.getName()) + .contains("io.qameta.allure.awaitility.GlobalSettingsPositiveTest") + .contains("expected: 3") + .contains("but was: 0") + .contains("elapsed time") + .contains("remaining time") + .contains("last poll interval was"); + } + + /** + * Verifies that the second failed poll from the global listener reports the next observed value with timing context. + */ + @Description + @Test + void awaitWithoutAliasShouldDescribeSecondFailedPoll() { + final StepResult step = secondFailedPollStep(); + + assertThat(step.getName()) + .contains("io.qameta.allure.awaitility.GlobalSettingsPositiveTest") + .contains("expected: 3") + .contains("but was: 1") + .contains("elapsed time") + .contains("remaining time") + .contains("last poll interval was"); + } + + /** + * Verifies that the third failed poll from the global listener reports the final unsuccessful value before success. + */ + @Description + @Test + void awaitWithoutAliasShouldDescribeThirdFailedPoll() { + final StepResult step = thirdFailedPollStep(); + + assertThat(step.getName()) + .contains("io.qameta.allure.awaitility.GlobalSettingsPositiveTest") + .contains("expected: 3") + .contains("but was: 2") + .contains("elapsed time") + .contains("remaining time") + .contains("last poll interval was"); + } + + /** + * Verifies that the successful poll from the global listener reports the reached value and timing context. + */ + @Description + @Test + void awaitWithoutAliasShouldDescribeSuccessfulPoll() { + final StepResult step = successfulPollStep(); + + assertThat(step.getName()) + .contains("io.qameta.allure.awaitility.GlobalSettingsPositiveTest") + .contains("reached its end value after") + .contains("remaining time") + .contains("last poll interval was"); + } + + private List runAwaitWithoutAliasTopLevelSteps() { + final List testResult = runWithinTestContext(() -> { + final AtomicInteger atomicInteger = new AtomicInteger(0); + await().with() + .atMost(Duration.of(1000, ChronoUnit.MILLIS)) + .pollInterval(Duration.of(50, ChronoUnit.MILLIS)) + .untilAsserted(() -> assertThat(atomicInteger.getAndIncrement()).isEqualTo(3)); + }).getTestResults(); + + return testResult.get(0).getSteps(); + } + + private StepResult awaitWithoutAliasTopLevelStep() { + return runAwaitWithoutAliasTopLevelSteps().get(0); + } + + private StepResult awaitWithAliasTopLevelStep() { + 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)) + .untilAsserted(() -> assertThat(atomicInteger.getAndIncrement()).isEqualTo(3)); + }).getTestResults(); + + return testResult.get(0).getSteps().get(0); + } + + private List awaitWithoutAliasPollSteps() { + return awaitWithoutAliasTopLevelStep().getSteps().stream() + .filter(GlobalSettingsPositiveTest::isAwaitilityEvaluationStep) + .toList(); + } + + private static boolean isAwaitilityEvaluationStep(final StepResult step) { + return AWAITILITY_EVALUATION_DESCRIPTION.equals(step.getDescription()); + } + + private StepResult firstFailedPollStep() { + return awaitWithoutAliasPollSteps().get(0); + } + + private StepResult secondFailedPollStep() { + return awaitWithoutAliasPollSteps().get(1); + } + + private StepResult thirdFailedPollStep() { + return awaitWithoutAliasPollSteps().get(2); + } + + private StepResult successfulPollStep() { + return awaitWithoutAliasPollSteps().get(3); + } + +} 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..dbf24053e --- /dev/null +++ b/allure-awaitility/src/test/java/io/qameta/allure/awaitility/MultipleConditionsTest.java @@ -0,0 +1,88 @@ +/* + * 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.Description; +import io.qameta.allure.model.Status; +import io.qameta.allure.model.StepResult; +import io.qameta.allure.model.TestResult; +import io.qameta.allure.test.IsolatedLifecycle; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static io.qameta.allure.test.RunUtils.runWithinTestContext; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +@IsolatedLifecycle +public class MultipleConditionsTest { + + @AfterEach + void reset() { + Awaitility.reset(); + } + + @BeforeEach + void setup() { + Awaitility.pollInSameThread(); + Awaitility.setDefaultConditionEvaluationListener(new AllureAwaitilityListener()); + } + + /** + * Verifies that a test containing two Awaitility conditions reports a separate top-level step for each wait. + */ + @Description + @Test + void shouldRecordTopLevelStepForEachAwaitilityCondition() { + final List steps = runMultipleAwaitilityTopLevelSteps(); + + assertThat(steps) + .describedAs("Allure TestResult contains exactly 2 top level step for 2 awaitility condition") + .hasSize(2); + } + + /** + * Verifies that every top-level step created for multiple successful Awaitility conditions is marked passed. + */ + @Description + @Test + void shouldMarkEveryAwaitilityConditionStepPassed() { + final List steps = runMultipleAwaitilityTopLevelSteps(); + + assertThat(steps) + .describedAs("Allure TestResult contains all top level step for all awaitility with PASSED condition") + .allMatch(step -> Status.PASSED.equals(step.getStatus())); + } + + private List runMultipleAwaitilityTopLevelSteps() { + final List testResult = runWithinTestContext(() -> { + await().with() + .alias("First waiting") + .until(() -> true); + await().with() + .alias("Second waiting") + .until(() -> true); + } + ).getTestResults(); + + return testResult.get(0).getSteps(); + } + +} diff --git a/allure-awaitility/src/test/resources/allure.properties b/allure-awaitility/src/test/resources/allure.properties new file mode 100644 index 000000000..94bba0eee --- /dev/null +++ b/allure-awaitility/src/test/resources/allure.properties @@ -0,0 +1,4 @@ +allure.results.directory=build/allure-results +allure.link.issue.pattern=https://github.com/allure-framework/allure-java/issues/{} +allure.label.epic=#project.description# +allure.label.module=allure-awaitility diff --git a/allure-bom/README.md b/allure-bom/README.md new file mode 100644 index 000000000..59f71f014 --- /dev/null +++ b/allure-bom/README.md @@ -0,0 +1,52 @@ +# allure-bom + +Bill of materials for Allure Java artifacts. + +Use this module to keep all Allure Java dependencies on the same release line. It is the recommended way to declare Allure modules in Gradle and Maven projects. + +## Supported Versions + +- Allure Java 3.x requires Java 17 or newer for runtime modules. +- The BOM aligns all artifacts published from the same Allure Java release. + +## Installation + +Gradle: + +```kotlin +dependencies { + testImplementation(platform("io.qameta.allure:allure-bom:")) + testImplementation("io.qameta.allure:allure-jupiter") + testImplementation("io.qameta.allure:allure-rest-assured") +} +``` + +Maven: + +```xml + + + + io.qameta.allure + allure-bom + ${allure.version} + pom + import + + + +``` + +After importing the BOM, omit versions from individual Allure Java dependencies. + +## What It Aligns + +- Test framework adapters such as `allure-jupiter`, `allure-testng`, and `allure-cucumber7-jvm`. +- Runtime and support APIs such as `allure-java-commons` and `allure-model`. +- HTTP, browser, assertion, and utility integrations. + +Use one Allure version for all modules in a test suite. Mixing versions can produce missing metadata, duplicate lifecycle listeners, or unreadable result files. + +## What To Expect + +The BOM does not write Allure results by itself. It only controls dependency versions. Add at least one adapter, such as `allure-jupiter`, `allure-testng`, or `allure-cucumber7-jvm`, to produce report data. diff --git a/allure-citrus/README.md b/allure-citrus/README.md new file mode 100644 index 000000000..5764240f6 --- /dev/null +++ b/allure-citrus/README.md @@ -0,0 +1,42 @@ +# allure-citrus + +Citrus listener integration for Allure Java. + +Use this module when your integration tests run on Citrus and you want Citrus suites, test cases, actions, parameters, and failures to appear in Allure Report. + +## Supported Versions + +- Allure Java 3.x requires Java 17 or newer. +- This module targets the classic `com.consol.citrus` API. +- The current build validates against Citrus 3.4.1. + +## Installation + +Gradle: + +```kotlin +dependencies { + testImplementation(platform("io.qameta.allure:allure-bom:")) + testImplementation("io.qameta.allure:allure-citrus") +} +``` + +Maven, with `allure-bom` imported in dependency management: + +```xml + + io.qameta.allure + allure-citrus + test + +``` + +## Setup + +Register `io.qameta.allure.citrus.AllureCitrus` with Citrus as a test, test suite, and test action listener. The listener translates Citrus suite, test case, and action events into Allure tests and steps. + +## Report Output + +- Citrus test cases and suite lifecycle. +- Test actions as Allure steps. +- Labels, links, parameters, status, and status details. diff --git a/allure-citrus/build.gradle.kts b/allure-citrus/build.gradle.kts index bd740f9a7..7184fd620 100644 --- a/allure-citrus/build.gradle.kts +++ b/allure-citrus/build.gradle.kts @@ -1,11 +1,8 @@ description = "Allure Citrus Integration" -val agent: Configuration by configurations.creating - -val citrusVersion = "2.8.0" +val citrusVersion = "3.4.1" dependencies { - agent("org.aspectj:aspectjweaver") api(project(":allure-java-commons")) compileOnly("com.consol.citrus:citrus-core:$citrusVersion") testImplementation("com.consol.citrus:citrus-http:$citrusVersion") @@ -19,6 +16,7 @@ dependencies { testImplementation(project(":allure-java-commons-test")) testImplementation(project(":allure-junit-platform")) testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") } tasks.jar { @@ -32,7 +30,4 @@ tasks.jar { tasks.test { useJUnitPlatform() exclude("**/samples/*") - doFirst { - jvmArgs("-javaagent:${agent.singleFile}") - } } 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..a9ed3a86c 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. @@ -21,6 +21,7 @@ import com.consol.citrus.report.TestListener; import com.consol.citrus.report.TestSuiteListener; import io.qameta.allure.Allure; +import io.qameta.allure.AllureExternalKey; import io.qameta.allure.AllureLifecycle; import io.qameta.allure.Description; import io.qameta.allure.Epic; @@ -29,7 +30,6 @@ import io.qameta.allure.model.Label; import io.qameta.allure.model.Link; 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; @@ -40,13 +40,13 @@ import java.lang.annotation.Repeatable; import java.lang.reflect.AnnotatedElement; import java.lang.reflect.Method; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; -import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; @@ -58,132 +58,208 @@ import static io.qameta.allure.util.ResultsUtils.createLanguageLabel; import static io.qameta.allure.util.ResultsUtils.createParameter; import static io.qameta.allure.util.ResultsUtils.createSuiteLabel; +import static io.qameta.allure.util.ResultsUtils.createTestClassLabel; import static io.qameta.allure.util.ResultsUtils.createThreadLabel; +import static io.qameta.allure.util.ResultsUtils.createTitlePath; import static io.qameta.allure.util.ResultsUtils.getProvidedLabels; +import static io.qameta.allure.util.ResultsUtils.md5; /** - * @author charlie (Dmitry Baev). + * Reports Citrus test execution to Allure. + * + *

Register this listener with Citrus so suite, test case, and test action events are reflected as Allure containers, fixtures, tests, and steps. The listener can use the global lifecycle or an explicitly provided lifecycle.

*/ public class AllureCitrus implements TestListener, TestSuiteListener, TestActionListener { - private final Map testUuids = new ConcurrentHashMap<>(); + private final Map testKeys = new ConcurrentHashMap<>(); private final ReadWriteLock lock = new ReentrantReadWriteLock(); private final AllureLifecycle lifecycle; + /** + * Creates an Allure citrus with the supplied values. + * + * @param lifecycle the Allure lifecycle to use + */ public AllureCitrus(final AllureLifecycle lifecycle) { this.lifecycle = lifecycle; } + /** + * Creates an Allure citrus with default configuration. + */ @SuppressWarnings("unused") public AllureCitrus() { this.lifecycle = Allure.getLifecycle(); } + /** + * Returns the lifecycle. + * + * @return the Allure lifecycle used by this integration + */ public AllureLifecycle getLifecycle() { return lifecycle; } + /** + * {@inheritDoc} + */ @Override public void onStart() { //do nothing } + /** + * {@inheritDoc} + */ @Override public void onStartSuccess() { //do nothing } + /** + * {@inheritDoc} + */ @Override public void onStartFailure(final Throwable cause) { //do nothing } + /** + * {@inheritDoc} + */ @Override public void onFinish() { //do nothing } + /** + * {@inheritDoc} + */ @Override public void onFinishSuccess() { //do nothing } + /** + * {@inheritDoc} + */ @Override public void onFinishFailure(final Throwable cause) { //do nothing } + /** + * {@inheritDoc} + */ @Override public void onTestStart(final TestCase test) { - startTestCase(test); + startTest(test); } + /** + * {@inheritDoc} + */ @Override public void onTestFinish(final TestCase test) { //do nothing } + /** + * {@inheritDoc} + */ @Override public void onTestSuccess(final TestCase test) { - stopTestCase(test, Status.PASSED, null); + stopTest(test, Status.PASSED, null); } + /** + * {@inheritDoc} + */ @Override public void onTestFailure(final TestCase test, final Throwable cause) { final Status status = ResultsUtils.getStatus(cause).orElse(Status.BROKEN); final StatusDetails details = ResultsUtils.getStatusDetails(cause).orElse(null); - stopTestCase(test, status, details); + stopTest(test, status, details); } + /** + * {@inheritDoc} + */ @Override public void onTestSkipped(final TestCase test) { - //do nothing + if (!isTestStarted(test)) { + startTest(test); + } + stopTest(test, Status.SKIPPED, null); } + /** + * {@inheritDoc} + */ @Override public void onTestActionStart(final TestCase testCase, final TestAction testAction) { - final String parentUuid = getUuid(testCase); - final String uuid = UUID.randomUUID().toString(); - getLifecycle().startStep(parentUuid, uuid, new StepResult().setName(testAction.getName())); + getLifecycle().startStep(new StepResult().setName(testAction.getName())); } + /** + * {@inheritDoc} + */ @Override public void onTestActionFinish(final TestCase testCase, final TestAction testAction) { getLifecycle().stopStep(); } + /** + * {@inheritDoc} + */ @Override public void onTestActionSkipped(final TestCase testCase, final TestAction testAction) { //do nothing } - private void startTestCase(final TestCase testCase) { - final String uuid = createUuid(testCase); + private void startTest(final TestCase testCase) { + final AllureExternalKey testKey = createTestKey(testCase); + final Optional> testClass = Optional.ofNullable(testCase.getTestClass()); + final String fullName = testClass + .map(Class::getName) + .map(className -> className + "." + testCase.getName()) + .orElseGet(testCase::getName); final TestResult result = new TestResult() - .setUuid(uuid) .setName(testCase.getName()) - .setStage(Stage.RUNNING); + .setFullName(fullName) + .setTestCaseId(md5(fullName)) + .setTitlePath( + testClass + .map(ResultsUtils::createTitlePathFromJavaClass) + .orElseGet(() -> createTitlePath(testCase.getName())) + ); 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") - )); + // the test is represented by a class only when the test case is class-backed (java dsl); + // the test method stays unknown — the test case name may be customized by the user + testClass.ifPresent(aClass -> result.getLabels().add(createTestClassLabel(aClass.getName()))); + + result.getLabels().addAll( + Arrays.asList( + createHostLabel(), + createThreadLabel(), + createFrameworkLabel("citrus"), + createLanguageLabel("java") + ) + ); + final List