From 8754803e35fca7abe0b154c440585a173d37a2ad Mon Sep 17 00:00:00 2001 From: Martin Grosche Date: Mon, 27 Nov 2023 16:40:38 +0100 Subject: [PATCH] initial commit --- .dockerignore | 12 + .github/ISSUE_TEMPLATE/1-feature-request.yml | 31 + .github/ISSUE_TEMPLATE/2-bug-report.yml | 50 ++ .github/ISSUE_TEMPLATE/config.yml | 9 + .github/automation_scripts/scripts.py | 81 ++ .github/labels.yml | 43 ++ .github/release-drafter.yml | 41 + .../workflows/auto-merge-actions-updates.yml | 21 + .github/workflows/close-fork-prs.yml | 22 + .github/workflows/dependency-check.yml | 43 ++ .github/workflows/lint.yml | 33 + .github/workflows/md-link-checker.yml | 19 + .github/workflows/release-drafter.yml | 19 + .github/workflows/release.yml | 47 ++ .github/workflows/reuse.yml | 20 + .github/workflows/sync-labels.yml | 26 + .github/workflows/test.yml | 60 ++ .github/workflows/update-gh-pages.yml | 51 ++ .gitignore | 37 + .reuse/dep5 | 8 + LICENSE | 21 + LICENSES/MIT.txt | 9 + README.md | 175 +++++ config/license/allowlist.json | 76 ++ config/license/allowlist_schema.json | 49 ++ config/license/check_dependencies.py | 151 ++++ conftest.py | 249 ++++++ default.pylintrc | 414 ++++++++++ docs/images/Logo_TEST-GUIDE_rgb_SCREEN.png | Bin 0 -> 11208 bytes docs/images/platform_logo.png | Bin 0 -> 17098 bytes docs/source/conf.py | 49 ++ docs/source/index.rst | 28 + example_TestSuite.py | 50 ++ pyproject.toml | 33 + testguide_report_generator/ReportGenerator.py | 99 +++ testguide_report_generator/__init__.py | 3 + testguide_report_generator/model/TestCase.py | 716 ++++++++++++++++++ .../model/TestCaseFolder.py | 68 ++ testguide_report_generator/model/TestSuite.py | 73 ++ testguide_report_generator/model/__init__.py | 3 + testguide_report_generator/schema/schema.json | 471 ++++++++++++ testguide_report_generator/util/File.py | 50 ++ .../util/Json2AtxRepr.py | 25 + .../util/JsonValidator.py | 73 ++ .../util/ValidityChecks.py | 99 +++ testguide_report_generator/util/__init__.py | 3 + tests/e2e/Dockerfile | 11 + tests/e2e/Jenkinsfile | 63 ++ tests/e2e/e2e_testsuite.py | 67 ++ tests/e2e/test_e2e.py | 110 +++ tests/model/test_TestCase.py | 234 ++++++ tests/model/test_TestCaseFolder.py | 49 ++ tests/model/test_TestSuite.py | 64 ++ tests/resources/artifact.txt | 0 tests/resources/artifact2.txt | 1 + tests/resources/invalid.json | 28 + tests/resources/testcase.json | 153 ++++ tests/resources/testsuite.json | 205 +++++ tests/resources/valid.json | 156 ++++ tests/test_ReportGenerator.py | 73 ++ tests/util/test_File.py | 37 + tests/util/test_JsonValidator.py | 37 + tests/util/test_ValidityChecks.py | 68 ++ 63 files changed, 5016 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/ISSUE_TEMPLATE/1-feature-request.yml create mode 100644 .github/ISSUE_TEMPLATE/2-bug-report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/automation_scripts/scripts.py create mode 100644 .github/labels.yml create mode 100644 .github/release-drafter.yml create mode 100644 .github/workflows/auto-merge-actions-updates.yml create mode 100644 .github/workflows/close-fork-prs.yml create mode 100644 .github/workflows/dependency-check.yml create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/md-link-checker.yml create mode 100644 .github/workflows/release-drafter.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/reuse.yml create mode 100644 .github/workflows/sync-labels.yml create mode 100644 .github/workflows/test.yml create mode 100644 .github/workflows/update-gh-pages.yml create mode 100644 .gitignore create mode 100644 .reuse/dep5 create mode 100644 LICENSE create mode 100644 LICENSES/MIT.txt create mode 100644 README.md create mode 100644 config/license/allowlist.json create mode 100644 config/license/allowlist_schema.json create mode 100644 config/license/check_dependencies.py create mode 100644 conftest.py create mode 100644 default.pylintrc create mode 100644 docs/images/Logo_TEST-GUIDE_rgb_SCREEN.png create mode 100644 docs/images/platform_logo.png create mode 100644 docs/source/conf.py create mode 100644 docs/source/index.rst create mode 100644 example_TestSuite.py create mode 100644 pyproject.toml create mode 100644 testguide_report_generator/ReportGenerator.py create mode 100644 testguide_report_generator/__init__.py create mode 100644 testguide_report_generator/model/TestCase.py create mode 100644 testguide_report_generator/model/TestCaseFolder.py create mode 100644 testguide_report_generator/model/TestSuite.py create mode 100644 testguide_report_generator/model/__init__.py create mode 100644 testguide_report_generator/schema/schema.json create mode 100644 testguide_report_generator/util/File.py create mode 100644 testguide_report_generator/util/Json2AtxRepr.py create mode 100644 testguide_report_generator/util/JsonValidator.py create mode 100644 testguide_report_generator/util/ValidityChecks.py create mode 100644 testguide_report_generator/util/__init__.py create mode 100644 tests/e2e/Dockerfile create mode 100644 tests/e2e/Jenkinsfile create mode 100644 tests/e2e/e2e_testsuite.py create mode 100644 tests/e2e/test_e2e.py create mode 100644 tests/model/test_TestCase.py create mode 100644 tests/model/test_TestCaseFolder.py create mode 100644 tests/model/test_TestSuite.py create mode 100644 tests/resources/artifact.txt create mode 100644 tests/resources/artifact2.txt create mode 100644 tests/resources/invalid.json create mode 100644 tests/resources/testcase.json create mode 100644 tests/resources/testsuite.json create mode 100644 tests/resources/valid.json create mode 100644 tests/test_ReportGenerator.py create mode 100644 tests/util/test_File.py create mode 100644 tests/util/test_JsonValidator.py create mode 100644 tests/util/test_ValidityChecks.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f24959d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +# Copyright (c) 2022-2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT + +.github +.reuse +config +LICENSES +LICENSE +.gitignore +default.pylintrc +example_TestSuite.py diff --git a/.github/ISSUE_TEMPLATE/1-feature-request.yml b/.github/ISSUE_TEMPLATE/1-feature-request.yml new file mode 100644 index 0000000..0bbd83a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1-feature-request.yml @@ -0,0 +1,31 @@ +# Copyright (C) 2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT + +name: '💡 Feature Request' +description: 'Feature request template for TEST-GUIDE Json Generator development' +labels: ['feature'] + +body: + - type: textarea + id: description + attributes: + label: Describe the feature request + description: Describe the main value of the feature request. + validations: + required: true + - type: textarea + id: upstream + attributes: + label: Upstream changes + description: Add upstream changes as checkboxes (if needed). + placeholder: Type "no changes" if no changes are needed. + validations: + required: true + - type: textarea + id: criteria + attributes: + label: Acceptance criteria + description: Add the expected results after task completion as checkboxes. + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/2-bug-report.yml b/.github/ISSUE_TEMPLATE/2-bug-report.yml new file mode 100644 index 0000000..81ebcf3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2-bug-report.yml @@ -0,0 +1,50 @@ +# Copyright (C) 2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT + +name: '🐛 Bug Report' +description: 'Create a report to help us improve' +labels: ['bug'] + +body: + - type: textarea + id: description + attributes: + label: Describe the bug + description: A clear and concise description of what the bug is about. + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Steps for reproduction + description: List of steps to reproduce the behavior. + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + description: A clear and concise description of what you expected to happen. + validations: + required: true + - type: textarea + id: versions + attributes: + label: Versions + description: Which software versions are affected by the bug? + validations: + required: true + - type: textarea + id: context + attributes: + label: Additional context + description: Add any other context about the problem here. + validations: + required: false + - type: markdown + attributes: + value: | + **Never report security issues on GitHub or other public channels (Gitter/Twitter/etc.).** + Instead, use our [TraceTronic Support Center](https://support.tracetronic.com). For reporting issues + containing NDA relevant information please also use our [TraceTronic Support Center](https://support.tracetronic.com). diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3490192 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,9 @@ +# Copyright (C) 2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT + +blank_issues_enabled: false +contact_links: + - name: TraceTronic Support Center + url: https://support.tracetronic.com + about: Please report security vulnerabilities and non-public issues (i.e. NDA relevant information) here. diff --git a/.github/automation_scripts/scripts.py b/.github/automation_scripts/scripts.py new file mode 100644 index 0000000..62857cf --- /dev/null +++ b/.github/automation_scripts/scripts.py @@ -0,0 +1,81 @@ +# Copyright (c) 2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT + +import argparse +import toml +import requests +import json + +REPO_USER = "tracetronic" +REPO_NAME = "testguide_report-generator" + + +def _bump_up_version(): + try: + with open("pyproject.toml", "r") as file: + toml_obj = toml.load(file) + + current_version = toml_obj.get("tool").get("poetry").get("version") + versions = current_version.split(".") + + next_version = versions[0] + "." + str(int(versions[1]) + 1) + "-beta" + print(next_version) # echo + + toml_obj["tool"]["poetry"]["version"] = next_version + with open("pyproject.toml", "w") as file: + toml.dump(toml_obj, file) + + return 0 + + except OSError: + print("Something went wrong during .toml processing. Aborting...") + return 1 + + +def _publish_latest_release_draft(token): + if token is None: + raise TypeError("Argument 'token' must not be None! Aborting...") + + headers = {"Accept": "application/vnd.github+json", "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28"} + + # first, get the latest Release (which should be set to "draft" - otherwise, something is wrong) + url = f"https://api.github.com/repos/{REPO_USER}/{REPO_NAME}/releases" + + response = requests.get(url + "?per_page=1", allow_redirects=True, headers=headers).json() + + release_id = response[0].get("id") + is_draft = response[0].get("draft") + + if not is_draft: + raise ValueError("Release is not a draft. Cannot publish. Aborting...") + + ######################################## + + # set this Release not to be a draft + data_dict = {"draft": "false", "prerelease": "false"} + + response = requests.patch(url=url + f"/{release_id}", headers=headers, data=json.dumps(data_dict), + allow_redirects=True) + + return 0 if response.status_code == 200 else 1 + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + prog='Automation Scripts for GitHub repositories', + description='Helps doing some GitHub workflows automatically.' + ) + parser.add_argument('-n', '--name', required=True) + parser.add_argument('-t', '--token', required=False) + args = parser.parse_args() + + if args.name == "bump_up_version": + _bump_up_version() + + elif args.name == "publish_latest_release_draft": + _publish_latest_release_draft(args.token) + + else: + raise ValueError("Unknown script name. Aborting...") diff --git a/.github/labels.yml b/.github/labels.yml new file mode 100644 index 0000000..c4df651 --- /dev/null +++ b/.github/labels.yml @@ -0,0 +1,43 @@ +# Copyright (C) 2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT + +- name: bug + description: Something isn't working + color: b60205 +- name: chore + color: f2cb4b + description: Maintenance changes and refactoring +- name: dependencies + description: Dependabot + color: 5b8eff +- name: deprecated + description: Deprecated code + color: cfd3d7 +- name: documentation + description: Improvements or additions to documentation + color: 0075ca +- name: duplicate + description: This issue or pull request already exists + color: cfd3d7 +- name: feature + description: New feature request + color: 92d050 +- name: removed + description: Remove code + color: aa0f1c +- name: security + description: Security related issue + color: 7f0e6f +- name: test-guide + description: TEST-GUIDE related issue + color: 208ca3 +- name: test + description: Testing + color: 63666a +- name: wontfix + description: No further work + color: 0b3972 +- name: major + description: Major version update + color: 000000 diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..aae0618 --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,41 @@ +# Copyright (C) 2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT + +# Configuration for Release Drafter: https://github.com/toolmantim/release-drafter +name-template: $NEXT_MINOR_VERSION +tag-template: $NEXT_MINOR_VERSION + +version-template: $MAJOR.$MINOR + +# Emoji reference: https://gitmoji.carloscuesta.me/ +categories: + - title: 💡 New features and improvements + labels: + - feature + - title: 🐛 Bug fixes + labels: + - bug + - title: 🏠 Maintenance + labels: + - chore + - title: ✍ Other changes + # Default label used by Dependabot + - title: 📦 Dependency updates + labels: + - dependencies + collapse-after: 15 + +template: | + + $CHANGES + +replacers: + - search: '@dependabot-preview' + replace: '@dependabot' + +version-resolver: + major: + labels: + - 'major' + default: minor diff --git a/.github/workflows/auto-merge-actions-updates.yml b/.github/workflows/auto-merge-actions-updates.yml new file mode 100644 index 0000000..8be0d2e --- /dev/null +++ b/.github/workflows/auto-merge-actions-updates.yml @@ -0,0 +1,21 @@ +# Copyright (C) 2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT + +name: Auto merge for GH Action Updates +on: pull_request + +permissions: + contents: write + pull-requests: write + +jobs: + dependabot: + runs-on: ubuntu-latest + if: ${{ github.actor == 'dependabot[bot]' && contains( github.event.pull_request.labels.*.name, 'github_actions') }} + steps: + - name: Enable auto-merge for Dependabot GitHub Actions PRs + run: gh pr merge --auto --squash "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/.github/workflows/close-fork-prs.yml b/.github/workflows/close-fork-prs.yml new file mode 100644 index 0000000..3838ac2 --- /dev/null +++ b/.github/workflows/close-fork-prs.yml @@ -0,0 +1,22 @@ +# Copyright (C) 2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT + +name: Auto-close Pull Requests from Forks + +on: + pull_request: + types: [opened, reopened] + schedule: + - cron: '0 0 * * *' + +jobs: + close-prs: + runs-on: ubuntu-latest + steps: + - name: Close Pull Requests + uses: peter-evans/close-fork-pulls@v2 + with: + comment: | + We do not accept any external pull requests. Auto-closing this pull request. + If you have any questions, please contact us at [support@tracetronic.com](mailto:support@tracetronic.com). diff --git a/.github/workflows/dependency-check.yml b/.github/workflows/dependency-check.yml new file mode 100644 index 0000000..16f74e5 --- /dev/null +++ b/.github/workflows/dependency-check.yml @@ -0,0 +1,43 @@ +# Copyright (C) 2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT + +name: Ensure 3rd Party License Compliance + +on: + push: + branches: + - main + pull_request: + +jobs: + + validate-sbom: + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.7.9' # latest binary release of v3.7 + - name: Install poetry + run: pip3 install poetry + - name: Active Python version + run: | + echo "Active Python version is..." + python --version + - name: Export dependencies to requirements file + run: poetry export --only main -f requirements.txt -o requirements.txt --without-hashes --without-urls + - name: Install pipenv environment + run: pip3 install pipenv + - name: Create Pipfile.lock + run: pipenv install -r requirements.txt + - name: Install dev dependencies + run: poetry install --only dev --no-root + - name: check license compliance against allowlist + run: | + poetry run python config/license/check_dependencies.py ` + --allowlist="config/license/allowlist.json" ` + --sbom="cyclonedx.json" ` + --schema="config/license/allowlist_schema.json" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..d41e95e --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,33 @@ +# Copyright (C) 2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT + +name: Lint +run-name: Linting code + +on: + push: + branches: + - main + pull_request: + +jobs: + lint: + runs-on: ubuntu-22.04 + env: + PY_VERSION: '3.9' + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ env.PY_VERSION }} + - name: Install virtual environment + run: pip3 install poetry + - name: Ensure Python version + run: poetry env use ${{ env.PY_VERSION }} + - name: Install dependencies + run: poetry install --without workflow --no-root + - name: Execute Linting + run: poetry run pylint --rcfile default.pylintrc testguide_report_generator diff --git a/.github/workflows/md-link-checker.yml b/.github/workflows/md-link-checker.yml new file mode 100644 index 0000000..55e23cd --- /dev/null +++ b/.github/workflows/md-link-checker.yml @@ -0,0 +1,19 @@ +# Copyright (C) 2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT + +name: Check Markdown Links + +on: + push: + schedule: + - cron: "0 8 * * 1" + +jobs: + markdown-link-check: + name: check md links + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - uses: gaurav-nelson/github-action-markdown-link-check@1.0.15 diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml new file mode 100644 index 0000000..b5eb823 --- /dev/null +++ b/.github/workflows/release-drafter.yml @@ -0,0 +1,19 @@ +# Copyright (C) 2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT + +name: Release Drafter + +on: + push: + branches: + - main + +jobs: + update_release_draft: + name: Update Release Draft + runs-on: ubuntu-latest + steps: + - uses: release-drafter/release-drafter@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..969e607 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,47 @@ +# Copyright (C) 2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT + +name: Release +run-name: Release next product version + +on: + push: + tags: + - '[0-9].[0-9]+' + +jobs: + publish-release-draft: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install poetry + run: pip3 install poetry + - name: Set up Python environment + run: poetry install --only workflow --no-root + - name: Publish Release + run: poetry run python ".github/automation_scripts/scripts.py" --name "publish_latest_release_draft --token "${token}" + env: + token: ${{ secrets.GH_RELEASE }} + + bump-up-version: + runs-on: ubuntu-latest + needs: publish-release-draft + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: 'main' # otherwise, checks out to tag, which is in detached HEAD state + - name: Install poetry + run: pip3 install poetry + - name: Set up Python environment + run: poetry install --only workflow --no-root + - name: Bump up version + run: poetry run python ".github/automation_scripts/scripts.py" --name "bump_up_version" + - name: Push changes + run: | + git config user.name "$GITHUB_ACTOR" + git config user.email "$GITHUB_ACTOR@users.noreply.github.com" + git commit -m "Bump up version" + git push diff --git a/.github/workflows/reuse.yml b/.github/workflows/reuse.yml new file mode 100644 index 0000000..e64fbc2 --- /dev/null +++ b/.github/workflows/reuse.yml @@ -0,0 +1,20 @@ +# Copyright (C) 2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT + +name: REUSE Compliance Check + +on: + push: + branches: + - main + pull_request: + +jobs: + reuse: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + - name: REUSE Compliance Check + uses: fsfe/reuse-action@v2 diff --git a/.github/workflows/sync-labels.yml b/.github/workflows/sync-labels.yml new file mode 100644 index 0000000..3c250ab --- /dev/null +++ b/.github/workflows/sync-labels.yml @@ -0,0 +1,26 @@ +# Copyright (C) 2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT + +name: Sync Labels + +on: + push: + branches: + - main + paths: + - .github/labels.yml + +jobs: + sync-repo-labels: + name: Sync Labels + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Sync Labels + uses: micnncim/action-label-syncer@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + manifest: .github/labels.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..2c1c5a7 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,60 @@ +# Copyright (C) 2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT + +name: Test +run-name: Run unit tests with coverage analysis + +on: + push: + branches: + - main + pull_request: + +jobs: + test: + strategy: + matrix: + py_version: ["3.7", "3.9"] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.py_version }} + - name: Install virtual environment + run: pip3 install poetry + - name: Ensure Python version + run: poetry env use ${{ matrix.py_version }} + - name: Install dependencies + run: poetry install --without workflow --no-root + - name: Execute Tests and create test coverage report + if: ${{ matrix.py_version == '3.9' }} + run: poetry run pytest tests --cov=testguide_report_generator --cov-report=xml:coverage.xml + - name: Execute Test + if: ${{ matrix.py_version != '3.9' }} + run: poetry run pytest tests + - name: Upload test coverage report + if: ${{ matrix.py_version == '3.9' }} + uses: actions/upload-artifact@v3 + with: + name: test-coverage-report + path: coverage.xml + + publish-coverage: + runs-on: ubuntu-latest + if: ${{ github.event_name == 'pull_request' }} + needs: test + steps: + - name: Download test coverage report + uses: actions/download-artifact@v3 + with: + name: test-coverage-report + - name: Get Cover + uses: orgoro/coverage@v3 + with: + coverageFile: coverage.xml + token: ${{ secrets.GITHUB_TOKEN }} + thresholdAll: 0.99 diff --git a/.github/workflows/update-gh-pages.yml b/.github/workflows/update-gh-pages.yml new file mode 100644 index 0000000..e9afc71 --- /dev/null +++ b/.github/workflows/update-gh-pages.yml @@ -0,0 +1,51 @@ +# Copyright (C) 2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT + +name: Update GitHub Pages + +on: + push: + branches: + - main + +jobs: + update_gh_pages: + runs-on: ubuntu-latest + steps: + - name: Checkout main + uses: actions/checkout@v4 + - name: Install poetry + run: pip3 install poetry + - name: Install dependencies + run: poetry install --only dev,docs + - name: Create Sphinx documentation + run: | + mkdir docs/docs + poetry run sphinx-apidoc -f -o ./docs/source ./testguide_report_generator + poetry run sphinx-build -b html ./docs/source ./docs/docs + rm -r docs/source + - name: Copy additional documentation resources into docs folder + run: | + cp -f README.md docs/README.md + cp -f LICENSE docs/LICENSE + cp -f -R docs/images docs/docs/images + - name: Publish GitHub Pages + uses: JamesIves/github-pages-deploy-action@v4 + with: + folder: docs + clean: true + - name: Create .nojekyll file to avoid Jekyll processing + run: | + git checkout gh-pages + git pull + if [ -f "docs/.nojekyll" ]; then + echo ".nojekyll already exists. Exiting." + exit 0 + fi + git config user.name "$GITHUB_ACTOR" + git config user.email "$GITHUB_ACTOR@users.noreply.github.com" + touch docs/.nojekyll + git add docs/.nojekyll + git commit --signoff -m "Add .nojekyll file" + git push diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8039083 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# python +__pycache__ + +# Unit test / coverage reports +.pytest_cache/ +__pytest_cache__ +.coverage +htmlcov/ + +# generated files during tests +/*.json +/*.zip + +# Sphinx documentation +docs/_build/ +docs/source/* +!docs/source/conf.py +!docs/source/index.rst +docs/make.bat +docs/Makefile +_build + + +# Pipenv files +poetry.lock + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Pycharm +.idea/ diff --git a/.reuse/dep5 b/.reuse/dep5 new file mode 100644 index 0000000..3bd54fd --- /dev/null +++ b/.reuse/dep5 @@ -0,0 +1,8 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: TraceTronic TEST-GUIDE JSON Generator for Python +Upstream-Contact: TraceTronic GmbH +Source: https://github.com/tracetronic/testguide_report-generator + +Files: **/*.json Pipfile* .gitignore **/artifact* **/*.png +Copyright: 2023 TraceTronic GmbH +License: MIT diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4b00039 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022-2023 TraceTronic GmbH + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/LICENSES/MIT.txt b/LICENSES/MIT.txt new file mode 100644 index 0000000..697ecda --- /dev/null +++ b/LICENSES/MIT.txt @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2023 TraceTronic GmbH + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1c0e6a9 --- /dev/null +++ b/README.md @@ -0,0 +1,175 @@ + + +# TEST-GUIDE Report Generator + + + +[![Testing](https://github.com/tracetronic/testguide_json_generator_python/actions/workflows/test.yml/badge.svg)](https://github.com/tracetronic/testguide_json_generator_python/actions/workflows/test.yml) [![Releases](https://img.shields.io/badge/Releases-Changelog-blue)](https://github.com/tracetronic/testguide_json_generator_python/releases) [![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](https://github.com/tracetronic/testguide_json_generator_python/blob/main/LICENSE) + + +As a modern automotive test engineer, reliance on automated solutions for the execution, reporting and evaluation of my test suites is essential. +The complexity of the systems under test, and thus the amount of necessary tests is ever growing tremendously. One of the tools which can help +with these tasks is [TraceTronic TEST-GUIDE](https://www.tracetronic.com/products/test-guide/). As a user of TEST-GUIDE, it is desirable to have +a means to customize and structure my test reports in a simple manner. + +This generator acts as a helper to create a [TEST-GUIDE](https://www.tracetronic.com/products/test-guide/) compatible +test report. Specific Python classes reflecting the different elements of a test report (*TestSuite*, *TestCase* and so on) +were designed in such a way that you can create your own testsuite from these objects. This facilitates the conversion from arbitrary test report +formats into a *.json* which TEST-GUIDE can handle. With this generator, it is no more necessary to convert non-ATX formats directly +into a *.json* for TEST-GUIDE. Instead, the delivered Python classes are prefilled in a simple manner, and the *.json* is +generated for you. On top of this, early format checks are conducted such that you will be notified right away if something is not +compliant to the *json* schema. + + +TEST-GUIDE + + +TEST-GUIDE is a database application for the overview, analysis and follow-up processing of test procedures, which has been specially +developed for use in the automotive sector. It significantly facilitates the management of test resources. At the same time, it encourages +cross-role cooperation, thereby closing the gap between test execution and test management. +
+ + +Automotive DevOps Platform + + +
+ +**TraceTronic TEST-GUIDE Report Generator** project is part of +the [Automotive DevOps Platform](https://www.tracetronic.com/products/automotive-devops-platform/) by TraceTronic. With +the **Automotive DevOps Platform**, we go from the big picture to the details and unite all phases of vehicle software +testing – from planning the test scopes to summarizing the test results. At the same time, continuous monitoring across +all test phases always provides an overview of all activities – even with several thousand test executions per day and +in different test environments.

+ + +## Table of Contents + +- [Installation](#installation) +- [Getting Started](#getting-started) +- [Usage](#usage) +- [Contribution](#contribution) +- [Documentation](#documentation) +- [Support](#support) +- [License](#license) + +## Installation + +Installation can be done with your favourite Python package manager CLI, like [pip](https://pypi.org/project/pip/) +```bash +pip install testguide-report-generator +``` + +or by adding the _testguide-report-generator_ dependency to your dependency management file, such as [requirements.txt](https://pip.pypa.io/en/stable/reference/requirements-file-format/) or [pyproject.toml](https://python-poetry.org/docs/pyproject/). + +## Getting Started + + +The commands which are necessary to generate [TEST-GUIDE](https://www.tracetronic.com/products/test-guide/) reports are collected exemplarily in the [*example_TestSuite.py*](/example_TestSuite.py). Run the example script to generate *json* and *zip* file: + + +```bash +python example_TestSuite.py +``` + +## Usage + +### Assembling a TestSuite Object + +The elements follow the hierarchy `TestSuite --> TestCaseFolder --> TestCase --> TestStepFolder --> TestStep`. So, instances of *TestCase(Folder)* are added to *TestSuite*, and instances of *TestStep(Folder)* are added to *TestCase*. At least one *TestCase* or *TestStep* has to be added to the respective folder (see [Restrictions](#restrictions)). + +In the end, the report generator will take the assembled *TestSuite* and generate the report. The generator output is a *.json* report and a *.zip* file containing the generated test report along with possible testcase artifacts. The *.zip* file can be uploaded to TEST-GUIDE via the appropriate option in TEST-GUIDE. The schema of the *.json* which [TEST-GUIDE](https://www.tracetronic.com/products/test-guide/) expects can be found [here](testguide_report_generator/schema/schema.json). + +A small example may look like this: + +``` +# import necessary classes for the TestSuite creation +from testguide_report_generator.model.TestSuite import TestSuite +from testguide_report_generator.model.TestCase import TestCase, Verdict + +# import the .json generator +from testguide_report_generator.ReportGenerator import Generator + + +def create_testsuite(): + + # create the TestSuite object + testsuite = TestSuite("All Tests", 1666698047000) + + # create the TestCase object + testcase = TestCase("Test Brakes", 1666698047001, Verdict.FAILED) + + # add the TestCase to the TestSuite + testsuite.add_testcase(testcase) + + # initialize the generator + generator = Generator(testsuite) + + # execute the generator and export the result + generator.export("output.json") + +if __name__ == "__main__": + create_testsuite() + +``` + +A more extensive example is given in [example_TestSuite.py](/example_TestSuite.py). + + +### Available classes and their purpose + +| Class | Arguments | Description | +|----------------------------------------------------------------------|----------------------------------|--------------------------------------------------------------------------------------------------------------------------------------| +| [TestStep](testguide_report_generator/model/TestCase.py) | name, verdict, (expected result) | a fundamental teststep, is added to TestCase or TestStepFolder | +| [TestStepArtifact](testguide_report_generator/model/TestCase.py) | filepath, type | artifact which gets attached directly to a teststep (such as plots) | +| [TestStepArtifactType](testguide_report_generator/model/TestCase.py) | | the type of a teststep artifact (only used with TestStepArtifact) | +| [TestStepFolder](testguide_report_generator/model/TestCase.py) | name | contains teststeps or teststep folders, is added to TestCase | +| [TestCase](testguide_report_generator/model/TestCase.py) | name, timestamp, verdict | a testcase, may contain teststeps or teststep folders, as well as further specific elements; is added to TestCaseFolder or TestSuite | +| [TestCaseFolder](testguide_report_generator/model/TestCaseFolder.py) | name | contains testcases or testcase folders, is added to TestSuite or TestCaseFolder | +| [TestSuite](testguide_report_generator/model/TestSuite.py) | name, timestamp | the testsuite, may contain TestCases or TestCaseFolder | +| [Verdict](testguide_report_generator/model/TestCase.py) | | the verdict of the test object | +| [Artifact](testguide_report_generator/model/TestCase.py) | filepath | an optional artifact to an existing filepath, can be added to TestCase | +| [Parameter](testguide_report_generator/model/TestCase.py) | name, value, direction | a testcase parameter, can be added to TestCase | +| [Direction](testguide_report_generator/model/TestCase.py) | | direction of a Parameter (only used with Parameter) | +| [Constant](testguide_report_generator/model/TestCase.py) | key, value | a test constant, can be added to TestCase | +| [Attribute](testguide_report_generator/model/TestCase.py) | key, value | a test attribute, can be added to TestCase | +| [Review](testguide_report_generator/model/TestCase.py) | comment, author, timestamp | review, can be added to TestCase | + +* (): arguments in parentheses are _optional_ + +### Restrictions + +Please note that certain requirements for the creation of the test components need to be met in order to generate a valid *.json*. These include: + +* at least **one** [TestCase](testguide_report_generator/model/TestCase.py) or [TestCaseFolder](testguide_report_generator/model/TestCaseFolder.py) within a [TestSuite](testguide_report_generator/model/TestSuite.py) +* at least **one** [TestCase](testguide_report_generator/model/TestCase.py) within a [TestCaseFolder](testguide_report_generator/model/TestCaseFolder.py) +* at least **one** [TestStep](testguide_report_generator/model/TestCase.py) within a [TestStepFolder](testguide_report_generator/model/TestCase.py) +* names for [TestSuite](testguide_report_generator/model/TestSuite.py), [TestCaseFolder](testguide_report_generator/model/TestCaseFolder.py), [TestCase](testguide_report_generator/model/TestCase.py), [TestStepFolder](testguide_report_generator/model/TestCase.py) and +[TestStep](testguide_report_generator/model/TestCase.py) between **1 - 120** characters +* [Review](testguide_report_generator/model/TestCase.py) comments between **10 - 10000** characters +* timestamps in **milliseconds** (epoch Unix time) for [TestSuite](testguide_report_generator/model/TestSuite.py) and [TestCase](testguide_report_generator/model/TestCase.py) + +A complete specification can be found in the [schema](testguide_report_generator/schema/schema.json). + +## Contribution + +At the moment, no external contributions are intended and merge requests from forks will automatically be **rejected**! However, +we do encourage you to file bugs and request features via the [issue tracker](https://github.com/tracetronic/testguide_json_generator_python/issues). + +## Documentation + +The documentation of the project is formatted as [reStructuredText](https://www.sphinx-doc.org/en/master/usage/restructuredtext/index.html). You can generate documentation pages from this with the help of tools such as [Sphinx](https://www.sphinx-doc.org/en/master/index.html). All necessary files are located under `docs/source`. `sphinx-apidoc` is used to generate the referenced modules'.rst files. Use `sphinx-build` to generate the documentation in the desired format, +e.g. HTML. + +## Support + +If you have any questions, please contact us at [support@tracetronic.com](mailto:support@tracetronic.com) and mind our [support page](https://www.tracetronic.com/infohub/support/). + +## License + +This plugin is licensed under MIT license. More information can be found inside the [LICENSE](LICENSE) file or within the +[LICENSES](LICENSES) folder. Using the [REUSE helper tool](https://github.com/fsfe/reuse-tool), you can run reuse spdx to get a bill of materials. diff --git a/config/license/allowlist.json b/config/license/allowlist.json new file mode 100644 index 0000000..a16b9d1 --- /dev/null +++ b/config/license/allowlist.json @@ -0,0 +1,76 @@ +{ + "allowedLicenses": [ + { + "moduleLicense": "MIT", + "moduleVersion": "23.1.0", + "moduleName": "attrs", + "licenseUrl": "https://github.com/python-attrs/attrs/blob/23.1.0/LICENSE", + "moduleCopyright": "Copyright (c) 2015 Hynek Schlawack and the attrs contributors", + "actualLicense": "MIT", + "violationAllowance": "" + }, + { + "moduleLicense": "MIT", + "moduleVersion": "4.16.0", + "moduleName": "jsonschema", + "licenseUrl": "https://github.com/python-jsonschema/jsonschema/blob/v4.16.0/COPYING", + "moduleCopyright": "Copyright (c) 2013 Julian Berman", + "actualLicense": "MIT", + "violationAllowance": "" + }, + { + "moduleLicense": "MIT", + "moduleVersion": "0.19.3", + "moduleName": "pyrsistent", + "licenseUrl": "https://github.com/tobgu/pyrsistent/blob/v0.19.2/LICENSE.mit", + "moduleCopyright": "Copyright (c) 2022 Tobias Gustafsson", + "actualLicense": "MIT", + "violationAllowance": "" + }, + { + "moduleLicense": "Apache-2.0", + "moduleVersion": "6.7.0", + "moduleName": "importlib-metadata", + "licenseUrl": "https://github.com/python/importlib_metadata/blob/v6.7.0/LICENSE", + "moduleCopyright": "Jason R. Coombs and the contributors to the project", + "actualLicense": "Apache-2.0", + "violationAllowance": "" + }, + { + "moduleLicense": "Apache-2.0", + "moduleVersion": "5.12.0", + "moduleName": "importlib-resources", + "licenseUrl": "https://github.com/python/importlib_resources/blob/v5.12.0/LICENSE", + "moduleCopyright": "Barry Warsaw and the contributors to the project", + "actualLicense": "Apache-2.0", + "violationAllowance": "" + }, + { + "moduleLicense": "MIT", + "moduleVersion": "1.3.10", + "moduleName": "pkgutil-resolve-name", + "licenseUrl": "https://github.com/graingert/pkgutil-resolve-name/blob/default/LICENSE", + "moduleCopyright": "Copyright (c) 2020 Thomas Grainger", + "actualLicense": "MIT", + "violationAllowance": "" + }, + { + "moduleLicense": "PSF-2.0", + "moduleVersion": "4.7.1", + "moduleName": "typing-extensions", + "licenseUrl": "https://github.com/python/typing_extensions/blob/4.7.1/LICENSE", + "moduleCopyright": "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022 Python Software Foundation; All Rights Reserved", + "actualLicense": "PSF-2.0", + "violationAllowance": "" + }, + { + "moduleLicense": "MIT", + "moduleVersion": "3.15.0", + "moduleName": "zipp", + "licenseUrl": "https://github.com/jaraco/zipp/blob/v3.15.0/LICENSE", + "moduleCopyright": "Copyright Jason R. Coombs", + "actualLicense": "MIT", + "violationAllowance": "" + } + ] +} diff --git a/config/license/allowlist_schema.json b/config/license/allowlist_schema.json new file mode 100644 index 0000000..2ba595a --- /dev/null +++ b/config/license/allowlist_schema.json @@ -0,0 +1,49 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "properties": { + "allowedLicenses": { + "type": "array", + "items": [ + { + "type": "object", + "properties": { + "moduleLicense": { + "type": ["string", "null"] + }, + "moduleVersion": { + "type": "string" + }, + "moduleName": { + "type": "string" + }, + "licenseUrl": { + "type": ["string", "null"], + "format": "uri" + }, + "moduleCopyright": { + "type": ["string", "null"] + }, + "actualLicense": { + "type": ["string", "null"] + }, + "violationAllowance": { + "type": "string" + } + }, + "required": [ + "moduleLicense", + "moduleVersion", + "moduleName", + "licenseUrl", + "moduleCopyright", + "actualLicense" + ] + } + ] + } + }, + "required": [ + "allowedLicenses" + ] +} diff --git a/config/license/check_dependencies.py b/config/license/check_dependencies.py new file mode 100644 index 0000000..9a1f29f --- /dev/null +++ b/config/license/check_dependencies.py @@ -0,0 +1,151 @@ +# Copyright (c) 2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT + +import getopt +import json +import jsonschema +import sys +import os + +from os.path import exists + +filename = "check_dependencies.py" + +COMPATIBLE_LICENSES = [ + "MIT", + "Apache-2.0", + "PSF-2.0" +] + + +class ComponentValidator: + def __init__(self, allow_component, sbom_component): + self.allow = allow_component + self.sbom = sbom_component + + self.errors = [] + + def validate(self, product=False): + self.__name() + self.__licenses() + if not product: + self.__version() + return self.errors + + def __name(self): + if self.allow["moduleName"] != self.sbom["name"]: + self.append_error("name", self.allow["moduleName"], self.sbom["name"]) + + def __version(self): + if self.allow["moduleVersion"] != self.sbom["version"]: + self.append_error("version", self.allow["moduleVersion"], self.sbom["version"]) + + def __licenses(self): + allow_license = self.allow["actualLicense"].lower() + prepared_license_list = [lic.lower() for lic in COMPATIBLE_LICENSES] + if not (any([allow_license == lic for lic in prepared_license_list])): + self.append_error("license", COMPATIBLE_LICENSES, allow_license) + + def append_error(self, key, expect, current): + self.errors.append("Component '{}':'{}' expects (one of) '{}' but was '{}'." + .format(self.allow["moduleName"], key, expect, current)) + + +def read_json(file_path): + if not exists(file_path): + print("{} does not exist.".format(file_path)) + sys.exit(2) + + with open(file_path) as f: + return json.load(f) + + +def compare_license_files(allowlist_path, sbom_path, allowschema_path): + allow_json = read_json(allowlist_path) + sbom_json = read_json(sbom_path) + schema_json = read_json(allowschema_path) + found_errors = [] + found_warnings = [] + + # check for correct schema first + try: + jsonschema.validate(allow_json, schema_json) + except jsonschema.exceptions.ValidationError as error: + found_errors.append(f"allowlist does not comply to the schema...\n{error.message}") + + # dependencies + allowed_packages = [item["moduleName"] for item in allow_json["allowedLicenses"]] + sbom_packages = [item["name"] for item in sbom_json["components"]] + + if len(allowed_packages) != len(sbom_packages): + found_errors.append("Number of components expects {} but was {}.".format(len(allow_json["allowedLicenses"]), + len(sbom_json["components"]))) + missing_allowed = set(sbom_packages).difference(set(allowed_packages)) + missing_sbom = set(allowed_packages).difference(set(sbom_packages)) + + if len(missing_sbom): + found_errors.append("Dependency {} not found in sbom_path but in allow list.".format(missing_sbom)) + elif len(missing_allowed): + found_warnings.append("Dependency {} not found in allow list but in sbom_path. Check if still necessary." + .format(missing_allowed)) + + for component in sbom_json["components"]: + # component["license"] = metadata(component["name"])["license"] + for allowed_component in allow_json["allowedLicenses"]: + + name = allowed_component["moduleName"] == component["name"] + if name: + validator = ComponentValidator(allowed_component, component) + dependency_err = validator.validate(False) + if dependency_err: + found_errors.extend(dependency_err) + + return found_errors, found_warnings + + +def main(argv): + allow_filepath = '' + sbom_filepath = '' + schema_filepath = "config/license/allowlist_schema.json" + try: + opts, args = getopt.getopt(argv, "h", ["allowlist=", "sbom=", "schema="]) + except getopt.GetoptError: + print("{} -a -s ".format(filename)) + sys.exit(2) + for opt, arg in opts: + if opt == "-h": + print("{} -a -s ".format(filename)) + sys.exit() + elif opt == "--allowlist": + allow_filepath = arg + elif opt == "--sbom": + sbom_filepath = arg + elif opt == "--schema": + schema_filepath = arg + + for file in [allow_filepath, sbom_filepath]: + if "" == file: + print(f"Allow list and sbom_path file path have to be set. For more information run '{filename} -h'.") + sys.exit(2) + if not file.endswith(".json"): + print(f"File '{filename}' does not end with '.json'.") + sys.exit(2) + + err, warn = compare_license_files(allow_filepath, sbom_filepath, schema_filepath) + if warn: + print(*warn, sep="\n") + + if err: + print(*err, sep="\n") + sys.exit(2) + + print("Dependency validation finished successfully.") + + +if __name__ == "__main__": + # generate sbom_path + os.system("cyclonedx-py -pip --format json") + + # check against allowlist + main(sys.argv[1:]) diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..1b312c6 --- /dev/null +++ b/conftest.py @@ -0,0 +1,249 @@ +# Copyright (c) 2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT + +import json +import os +from unittest.mock import patch + +import pytest + +from testguide_report_generator.model.TestCase import ( + TestStep, + Verdict, + TestStepFolder, + Parameter, + Direction, + Artifact, + Constant, + Attribute, + Review, + TestCase, + TestStepArtifact, + TestStepArtifactType +) +from testguide_report_generator.model.TestCaseFolder import TestCaseFolder +from testguide_report_generator.model.TestSuite import TestSuite + +ARTIFACT_PATH = "tests/resources/artifact.txt" +ARTIFACT_PATH_2 = "tests/resources/artifact2.txt" +TESTCASE_JSON_PATH = "tests/resources/testcase.json" +TESTSUITE_JSON_PATH = "tests/resources/testsuite.json" +JSON_SCHEMA_PATH = "testguide_report_generator/schema/schema.json" + +PATH_TO_VALID_JSON = "tests/resources/valid.json" +PATH_TO_INVALID_JSON = "tests/resources/invalid.json" + + +@pytest.fixture +def artifact_path(): + return ARTIFACT_PATH + + +@pytest.fixture +def artifact_path2(): + return ARTIFACT_PATH_2 + + +@pytest.fixture +def testcase_json_path(): + return TESTCASE_JSON_PATH + + +@pytest.fixture +def testsuite_json_path(): + return TESTSUITE_JSON_PATH + + +@pytest.fixture +def json_schema_path(): + return JSON_SCHEMA_PATH + + +@pytest.fixture +def path_to_valid_json(): + return PATH_TO_VALID_JSON + + +@pytest.fixture +def path_to_invalid_json(): + return PATH_TO_INVALID_JSON + + +@pytest.fixture() +def artifact(): + return Artifact(ARTIFACT_PATH) + + +@pytest.fixture +@patch("testguide_report_generator.model.TestCase.get_md5_hash_from_file") +def artifact_mock_hash(mock_hash): + mock_hash.return_value = "hash" + return Artifact(ARTIFACT_PATH) + + +@pytest.fixture +@patch("testguide_report_generator.model.TestCase.get_md5_hash_from_file") +def teststep_artifact_mock_hash(mock_hash): + mock_hash.return_value = "hash" + return TestStepArtifact(ARTIFACT_PATH, TestStepArtifactType.IMAGE) + + +@pytest.fixture +def teststep(): + return TestStep("ts", Verdict.NONE, "undefined") + + +@pytest.fixture +def teststep_2(): + teststep_with_desc = TestStep("ts2", Verdict.ERROR, "err").set_description("teststep2") + return teststep_with_desc + + +@pytest.fixture +def teststep_folder_empty(): + return TestStepFolder("tsf") + + +@pytest.fixture +def teststep_folder(teststep, teststep_2): + tsf = TestStepFolder("tsf") + tsf.add_teststep(teststep) + tsf.add_teststep(teststep_2) + return tsf + + +@pytest.fixture +def parameter(): + return Parameter("param", 10, Direction.OUT) + + +@pytest.fixture +def parameter_2(): + return Parameter("param2", 15, Direction.INOUT) + + +@pytest.fixture +def constant(): + return Constant("const", "one") + + +@pytest.fixture +def constant_2(): + return Constant("another", "const") + + +@pytest.fixture +def attribute(): + return Attribute("an", "attribute") + + +@pytest.fixture +def review(): + return Review("comment", "chucknorris", 1670254005) + + +@pytest.fixture +def testcase( + artifact_mock_hash, + teststep, + teststep_2, + teststep_folder, + parameter, + parameter_2, + constant, + constant_2, + attribute, + review, +): + testcase = TestCase("testcase_one", 1670248341000, Verdict.PASSED) + + testcase.set_description("First testcase.") + testcase.set_execution_time_in_sec(5) + testcase.add_parameter_set("myset", [parameter, parameter_2]) + testcase.add_constants([constant, constant_2]) + testcase.add_constant(Constant("", "")) + testcase.add_constant_pair("const_key", "const_val") + testcase.add_attribute_pair("an", "attribute") + testcase.add_setup_teststep(teststep) + testcase.add_setup_teststep(teststep_folder) + testcase.add_teardown_teststep(teststep_folder) + testcase.add_teardown_teststep(teststep_2) + testcase.add_execution_teststep(teststep_2) + testcase.add_execution_teststep(teststep_folder) + testcase.add_artifact(ARTIFACT_PATH, ignore_on_error=False) + testcase.set_review(review) + + with open("local.json", "w") as file: + file.write(json.dumps(testcase.create_json_repr())) + + return testcase + + +@pytest.fixture +def testcase_folder_empty(): + return TestCaseFolder("mytcf") + + +@pytest.fixture +def testcase_folder(testcase): + tcf = TestCaseFolder("mytcf2") + tcf.add_testcase(testcase) + + return tcf + + +@pytest.fixture +def testsuite(): + return TestSuite("MyTestSuite", 1666698047000) + + +@pytest.fixture +def testsuite_json_obj(): + testsuite = TestSuite("MyTestSuite", 1666698047000) + + testcase = TestCase("TestCase_1", 1666698047001, Verdict.PASSED) + + testcase.add_parameter_set( + "MyParameterSet", [Parameter("Input", 7, Direction.IN), Parameter("Output", 42, Direction.OUT)] + ) + + testcase.add_constant_pair("SOP", "2042") + + testcase.add_attribute_pair("ReqId", "007") + testcase.add_attribute_pair("Designer", "Philipp") + + testcase.add_execution_teststep(TestStep("Check Picture1", Verdict.PASSED, "Shows traffic light")) + testcase.add_execution_teststep( + TestStepFolder("Action").add_teststep(TestStep("Check car speed", Verdict.PASSED, "ego >= 120")) + ) + testcase.add_execution_teststep(TestStep("Check Picture2", Verdict.PASSED, "Shows Ego Vehicle")) + + testcase.add_artifact("testguide_report_generator/schema/schema.json", False) + + testcase.set_review(Review("Review-Comment", "Reviewer", 1423576765001)) + + # testcase no. 1: TestCase + testsuite.add_testcase(testcase) + + testcase_folder = TestCaseFolder("SubFolder") + testcase_folder.add_testcase(TestCase("TestCase_FAILED", 1423536765000, Verdict.FAILED)) + + # testcase no. 2: TestCaseFolder + testsuite.add_testcase(testcase_folder) + + return testsuite.create_json_repr() + + +@pytest.fixture(scope="session", autouse=True) +def value_storage(): + return ValueStorage() + + +class ValueStorage: + def __init__(self) -> None: + self.e2e_atxid = None + self.remote_testcases_json = None + self.BASE_URL = os.getenv("TEST_GUIDE_URL") + self.AUTHKEY = os.getenv("TEST_GUIDE_AUTHKEY") + self.PROJECT_ID = os.getenv("TEST_GUIDE_PROJECT_ID") diff --git a/default.pylintrc b/default.pylintrc new file mode 100644 index 0000000..cafffca --- /dev/null +++ b/default.pylintrc @@ -0,0 +1,414 @@ +# Copyright (c) 2022-2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT + +[MAIN] + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +init-hook='import sys; sys.path.append(".")' + + +# Specify a score threshold under which the program will exit with error. +fail-under=10 + +# Files or directories to be skipped. They should be base names, not paths. +ignore=CVS + +# Files or directories matching the regular expression patterns are skipped. +# The regex matches against base names, not paths. The default value ignores +# Emacs file locks +ignore-patterns=^\.# + +# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the +# number of processors available to use, and will cap the count on Windows to +# avoid hangs. +jobs=1 + +# Control the amount of potential inferred values when inferring a single +# object. This can help the performance when dealing with large functions or +# complex, nested conditions. +limit-inference-results=100 + +# Pickle collected data for later comparisons. +persistent=yes + +# Minimum Python version to use for version dependent checks. Will default to +# the version used to run pylint. +py-version=3.9 + +# Discover python modules and packages in the file system subtree. +recursive=no + +# When enabled, pylint would attempt to guess common misconfiguration and emit +# user-friendly hints instead of false-positive error messages. +suggestion-mode=yes + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + +[BASIC] + +# Naming style matching correct argument names. +argument-naming-style=snake_case + +# Naming style matching correct attribute names. +attr-naming-style=snake_case + +# Bad variable names which should always be refused, separated by a comma. +bad-names=foo, + bar, + baz, + toto, + tutu, + tata + +# Naming style matching correct class attribute names. +class-attribute-naming-style=any + +# Naming style matching correct class constant names. +class-const-naming-style=UPPER_CASE + +# Naming style matching correct class names. +class-naming-style=PascalCase + +# Naming style matching correct constant names. +const-naming-style=UPPER_CASE + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + +# Naming style matching correct function names. +function-naming-style=snake_case + +# Good variable names which should always be accepted, separated by a comma. +good-names=i, + j, + k, + ex, + Run, + _ + +# Include a hint for the correct naming format with invalid-name. +include-naming-hint=no + +# Naming style matching correct inline iteration names. +inlinevar-naming-style=any + +# Naming style matching correct method names. +method-naming-style=snake_case + +# Naming style matching correct module names. +module-naming-style=any + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_ + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +# These decorators are taken in consideration only for invalid-name. +property-classes=abc.abstractproperty + +# Naming style matching correct variable names. +variable-naming-style=snake_case + +[CLASSES] + +# Warn about protected attribute access inside special methods +check-protected-access-in-special-methods=no + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp, + __post_init__ + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict, + _fields, + _replace, + _source, + _make + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=cls + + +[DESIGN] + +# Maximum number of arguments for function / method. +#max-args=5 + +# Maximum number of attributes for a class (see R0902). +#max-attributes=0 + +# Maximum number of boolean expressions in an if statement (see R0916). +max-bool-expr=5 + +# Maximum number of branch for function / method body. +max-branches=12 + +# Maximum number of locals for function / method body. +max-locals=15 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of return / yield for function / method body. +max-returns=6 + +# Maximum number of statements in function / method body. +max-statements=50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=0 + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when caught. +overgeneral-exceptions=BaseException, + Exception + +[FORMAT] + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. +max-line-length=120 + +# Maximum number of lines in a module. +max-module-lines=1000 + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + +[IMPORTS] + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + +[LOGGING] + +# The type of string formatting that logging methods do. `old` means using % +# formatting, `new` is for `{}` formatting. +logging-format-style=old + +# Logging modules to check that the string format arguments are in logging +# function parameter format. +logging-modules=logging + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE, +# UNDEFINED. +confidence=HIGH, + CONTROL_FLOW, + INFERENCE, + INFERENCE_FAILURE, + UNDEFINED + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once). You can also use "--disable=all" to +# disable everything first and then re-enable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use "--disable=all --enable=classes +# --disable=W". +disable=raw-checker-failed, + bad-inline-option, + locally-disabled, + file-ignored, + suppressed-message, + useless-suppression, + deprecated-pragma, + use-symbolic-message-instead, + R0902, # too many class instance attributes + W1203 # Use lazy % formatting in logging functions (logging-fstring-interpolation) + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable=c-extension-no-member + +[METHOD_ARGS] + +# List of qualified names (i.e., library.method) which require a timeout +# parameter e.g. 'requests.api.get,requests.api.post' +timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + XXX, + TODO + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + +# Complete name of functions that never returns. When checking for +# inconsistent-return-statements if a never returning function is called then +# it will be considered as an explicit return statement and no message will be +# printed. +never-returning-functions=sys.exit,argparse.parse_error + +[REPORTS] + +# Python expression which should return a score less than or equal to 10. You +# have access to the variables 'fatal', 'error', 'warning', 'refactor', +# 'convention', and 'info' which contain the number of messages in each +# category, as well as 'statement' which is the total number of statements +# analyzed. This score is used by the global evaluation report (RP0004). +evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)) + +# Tells whether to display a full report or only the messages. +reports=no + +# Activate the evaluation score. +score=yes + +[SIMILARITIES] + +# Comments are removed from the similarity computation +ignore-comments=yes + +# Docstrings are removed from the similarity computation +ignore-docstrings=yes + +# Imports are removed from the similarity computation +ignore-imports=yes + +# Signatures are removed from the similarity computation +ignore-signatures=yes + +# Minimum lines number of a similarity. +min-similarity-lines=4 + +[SPELLING] + +# Limits count of emitted suggestions for spelling mistakes. +max-spelling-suggestions=4 + +# List of comma separated words that should be considered directives if they +# appear at the beginning of a comment and should not be checked. +spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: + +# Tells whether to store unknown words to the private dictionary (see the +# --spelling-private-dict-file option) instead of raising a message. +spelling-store-unknown-words=no + +[STRING] + +# This flag controls whether inconsistent-quotes generates a warning when the +# character used as a quote delimiter is used inconsistently within a module. +check-quote-consistency=no + +# This flag controls whether the implicit-str-concat should generate a warning +# on implicit string concatenation in sequences defined over several lines. +check-str-concat-over-line-jumps=no + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + +# Tells whether to warn about missing members when the owner of the attribute +# is inferred to be None. +ignore-none=yes + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes + +# List of symbolic message names to ignore for Mixin members. +ignored-checks-for-mixins=no-member, + not-async-context-manager, + not-context-manager, + attribute-defined-outside-init + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace + +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes + +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 + +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 + +# Regex pattern to define which classes are considered mixins. +mixin-class-rgx=.*[Mm]ixin + +[VARIABLES] + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_, + _cb + +# A regular expression matching the name of dummy variables (i.e. expected to +# not be used). +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ + +# Argument names that match this expression will be ignored. +ignored-argument-names=_.*|^ignored_|^unused_ + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io diff --git a/docs/images/Logo_TEST-GUIDE_rgb_SCREEN.png b/docs/images/Logo_TEST-GUIDE_rgb_SCREEN.png new file mode 100644 index 0000000000000000000000000000000000000000..ff1a85dfefb213fad79ef975b86e271b86a4d2d5 GIT binary patch literal 11208 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 literal 0 HcmV?d00001 diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..741b1b3 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,49 @@ +# Copyright (c) 2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT + +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information +import os +import sys + +sys.path.insert(0, os.path.abspath("../..")) + +project = 'testguide_report_generator' +copyright = '2023 TraceTronic GmbH' +author = 'TraceTronic GmbH' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'sphinx.ext.autodoc' +] + +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + +language = 'en' + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = 'alabaster' +html_theme_options = { + 'description': 'For further information see GitHub', + 'github_user': 'tracetronic', + 'github_repo': 'testguide_report-generator/tree/gh-pages', + 'github_button': True, + 'github_count': True, + # https://ghbtns.com/ + 'github_type': 'watch', +} + +# -- Options for todo extension ---------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/extensions/todo.html#configuration + +todo_include_todos = True diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..1dff4c2 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,28 @@ +.. + Copyright (c) 2022 TraceTronic GmbH + + SPDX-License-Identifier: MIT + +.. src documentation master file, created by + sphinx-quickstart on Tue Mar 21 08:47:19 2023. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to TEST-GUIDE JSON Generator documentation +================================================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + testguide_report_generator + testguide_report_generator.model + testguide_report_generator.util + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/example_TestSuite.py b/example_TestSuite.py new file mode 100644 index 0000000..c9dc1de --- /dev/null +++ b/example_TestSuite.py @@ -0,0 +1,50 @@ +# Copyright (c) 2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT + +""" +Example Script. +""" + +from testguide_report_generator.model.TestSuite import TestSuite +from testguide_report_generator.model.TestCase import TestCase, TestStep, TestStepFolder, Verdict, Parameter, \ + Direction, Review, TestStepArtifactType +from testguide_report_generator.model.TestCaseFolder import TestCaseFolder +from testguide_report_generator.ReportGenerator import Generator + + +def create_testsuite(): + """ + Creates an example TestSuite and outputs an example .json report file. + """ + + testsuite = TestSuite("MyTestSuite", 1666698047000) + + testcase = TestCase("TestCase_1", 1666698047001, Verdict.PASSED) + testcase.add_parameter_set("MyParameterSet", [Parameter("Input", 7, Direction.IN), + Parameter("Output", 42, Direction.OUT)]) + testcase.add_constant_pair("SOP", "2042") + testcase.add_attribute_pair("ReqId", "007") + testcase.add_attribute_pair("Designer", "Philipp") + testcase.add_execution_teststep(TestStep("Check Picture1", Verdict.PASSED, + "Shows traffic light")) + testcase.add_execution_teststep(TestStepFolder("Action") + .add_teststep(TestStep("Check car speed", Verdict.PASSED, + "ego >= 120"))) + teststep = TestStep("Check Picture2", Verdict.PASSED, "Shows Ego Vehicle") + teststep.add_artifact("docs/images/Logo_TEST-GUIDE_rgb_SCREEN.png", TestStepArtifactType.IMAGE) + testcase.add_execution_teststep(teststep) + testcase.add_artifact("testguide_report_generator/schema/schema.json", False) + testcase.set_review(Review("Review-Comment", "Reviewer", 1423576765001)) + testsuite.add_testcase(testcase) + + testcase_folder = TestCaseFolder("SubFolder") + testcase_folder.add_testcase(TestCase("TestCase_FAILED", 1423536765000, Verdict.FAILED)) + testsuite.add_testcase(testcase_folder) + + json_generator = Generator(testsuite) + json_generator.export("example.json") + + +if __name__ == "__main__": + create_testsuite() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f67ad80 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,33 @@ +# Copyright (c) 2022-2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT + +[build-system] +requires = [ "poetry-core",] +build-backend = "poetry.core.masonry.api" + +[tool.poetry] +name = "testguide-report-generator" +version = "1.0-beta" +description = "This generator acts as a helper to create a TEST-GUIDE compatible test report." +authors = [ "TraceTronic GmbH",] +readme = "README.md" + +[tool.poetry.dependencies] +python = ">=3.7.2 <3.10" +jsonschema = "==4.16.0" + +[tool.poetry.group.workflow.dependencies] +toml = "^0.10.2" +requests = "^2.30.0" + +[tool.poetry.group.dev.dependencies] +pytest = "==7.2.0" +pytest-cov = "==4.0.0" +pylint = "==2.15.5" +cyclonedx-bom = "==3.8.0" +jsonschema = "==4.16.0" + +[tool.poetry.group.docs.dependencies] +sphinx = "^5.3.0" +python_docs_theme = "^2022.1" diff --git a/testguide_report_generator/ReportGenerator.py b/testguide_report_generator/ReportGenerator.py new file mode 100644 index 0000000..eed3ce1 --- /dev/null +++ b/testguide_report_generator/ReportGenerator.py @@ -0,0 +1,99 @@ +# Copyright (c) 2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT + +""" +This module contains the JsonGenerator class. +""" + +import json +import os + +from zipfile import ZipFile, ZIP_DEFLATED +from testguide_report_generator.model.TestSuite import TestSuite +from testguide_report_generator.model.TestCase import TestCase +from testguide_report_generator.model.TestCaseFolder import TestCaseFolder +from testguide_report_generator.util.JsonValidator import JsonValidator + +DEFAULT_JSON_SCHEMA_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "schema", + "schema.json") + + +class Generator: + """ + This class is responsible for the creation of the `.zip` file containing the test report and + possible artifacts, which can be uploaded to TEST-GUIDE. An object of type + :class:`TestSuite` is necessary, containing the + information about all testcases in a format compliant with the `TEST-GUIDE schema.json`. It + is possible that the `.json` generated from the TestSuite object is not compliant with the + schema, for instance, if the suite does not contain any testcases. For further information, + please conduct the README. + """ + + __testsuite = None + + def __init__(self, testsuite: TestSuite, json_schema_path: str = DEFAULT_JSON_SCHEMA_PATH): + """ + Constructor + + :param testsuite: the TestSuite object + :type testsuite: TestSuite + :param json_schema_path: path to the json schema against which the generated `.json` + report is checked + :type json_schema_path: str + """ + self.__testsuite = testsuite + self.__validator = JsonValidator(json_schema_path) + + def export(self, json_file_path: str): + """ + This method generates both a test report in `.json` format from the testsuite and a + `.zip` file containing that report, as well as possible further artifacts added to the + :class:`TestCase` objects. + + :param json_file_path: the path for the output `.json` file + :type json_file_path: str + :return: path to the exported `.zip` file + :rtype: str + """ + + json_repr = self.__testsuite.create_json_repr() + + if self.__validator.validate_json(json_repr): + with open(json_file_path, 'w', encoding='utf-8') as file: + file.write(json.dumps(json_repr, indent=4)) + + filename = os.path.splitext(json_file_path)[0] if (json_file_path.endswith(".json")) \ + else json_file_path + zip_file_path = f"{filename}.zip" + with ZipFile(zip_file_path, 'w') as zip_obj: + zip_obj.write(json_file_path, os.path.basename(json_file_path), ZIP_DEFLATED) + + for each_testcase in self.__testsuite.get_testcases(): + self.__add_artifact_to_zip(zip_obj, each_testcase) + + return zip_file_path + + return None + + def __add_artifact_to_zip(self, zip_obj, node): + """ + Adds the already captured artifact to the upload zip. + + :param zip_obj: Open zipfile object + :type zip_obj: ZipFile + :param node: TestCase object or TestCaseFolder object + :type node: TestCase or TestCaseFolder + """ + + if not isinstance(node, (TestCase, TestCaseFolder)): + raise TypeError("Argument 'node' must be of type 'TestCase' or 'TestCaseFolder'.") + + if isinstance(node, TestCase): + for artifact in node.get_artifacts(): + if artifact.get_path_in_upload_zip() not in zip_obj.namelist(): + zip_obj.write(artifact.get_file_path(), artifact.get_path_in_upload_zip(), ZIP_DEFLATED) + else: + # TestCaseFolder + for testcase in node.get_testcases(): + self.__add_artifact_to_zip(zip_obj, testcase) diff --git a/testguide_report_generator/__init__.py b/testguide_report_generator/__init__.py new file mode 100644 index 0000000..f865d4f --- /dev/null +++ b/testguide_report_generator/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT diff --git a/testguide_report_generator/model/TestCase.py b/testguide_report_generator/model/TestCase.py new file mode 100644 index 0000000..9359fae --- /dev/null +++ b/testguide_report_generator/model/TestCase.py @@ -0,0 +1,716 @@ +# Copyright (c) 2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +This module contains the TestCase class and all other classes for the creation of a testcase, +including: + Artifact + TestStep + TestStepArtifact + TestStepArtifactType + TestStepFolder + Parameter + Direction + Constant + Attribute + Review + Verdict +""" +import errno +from enum import Enum +import logging +import os +from typing import List, Union + +from testguide_report_generator.util.Json2AtxRepr import Json2AtxRepr +from testguide_report_generator.util.File import get_md5_hash_from_file +from testguide_report_generator.util.ValidityChecks import check_name_length, gen_error_msg, \ + validate_new_teststep + + +class Verdict(Enum): + """ + ATX-Verdicts. + """ + NONE = 1 + PASSED = 2 + INCONCLUSIVE = 3 + FAILED = 4 + ERROR = 5 + + +class TestStepArtifactType(Enum): + """ + Possible types of artifacts attached to test steps + """ + __test__ = False # pytest ignore + + IMAGE = 1 + + +class Artifact(Json2AtxRepr): + """ + TestCase artifact. + """ + + def __init__(self, file_path: str): + """ + Constructor + + :param file_path: file path to artifact + :type file_path: str + :raises OSError: file_path is not a valid path to a file + """ + if not os.path.isfile(file_path): + raise OSError(errno.ENOENT, "File does not exist or path does not point to a file", file_path) + + self.__file_path = file_path + self.__zip_file_path = self.__create_zip_file_path() + + def __create_zip_file_path(self): + """ + Determines the path to be created in the upload zip. + + :return: path in upload zip + :rtype: str + """ + md5_hash = get_md5_hash_from_file(self.__file_path) + basename = os.path.basename(self.__file_path) + return f"{md5_hash}/{basename}" + + def get_file_path(self): + """ + :return: path to file + :rtype: str + """ + return self.__file_path + + def get_path_in_upload_zip(self): + """ + :return: hash-encoded path in the `.zip` file + :rtype: str + """ + return self.__zip_file_path + + def create_json_repr(self): + """ + :see: :class:`Json2AtxRepr` + """ + result = self.__zip_file_path + return result + + +class Direction(Enum): + """ + Parameter directions. + """ + IN = 1 + OUT = 2 + INOUT = 3 + + +class Parameter(Json2AtxRepr): + """ + ATX-Parameter. + """ + + def __init__(self, name: str, value, direction: Direction): + """ + Constructor + + :param name: parameter name + :type name: str + :param value: parameter value + :type value: str or int + :param direction: parameter direction + :type direction: Direction + """ + self.__name = name + self.__value = value + self.__direction = direction + + def create_json_repr(self): + """ + :see: :class:`Json2AtxRepr` + """ + result = { + "name": self.__name, + "value": self.__value, + "direction": self.__direction.name + } + return result + + +class Constant(Json2AtxRepr): + """ + ATX-Constant + """ + + def __init__(self, key: str, value: str): + """ + Constructor + + :param key: Constant key + :type key: str + :param value: Constant value + :type value: str + """ + self.__key = key + self.__value = value + + def create_json_repr(self): + """ + :see: :class:`Json2AtxRepr` + """ + result = { + "key": self.__key, + "value": self.__value + } + return result + + +class Attribute(Json2AtxRepr): + """ + ATX-Attribute. + """ + + def __init__(self, key: str, value: str): + """ + Constructor + + :param key: Attribute key + :type key: str + :param value: Attribute value + :type value: str + """ + self.__key = key + self.__value = value + + def create_json_repr(self): + """ + :see: :class:`Json2AtxRepr` + """ + result = { + "key": self.__key, + "value": self.__value + } + return result + + +class Review(Json2AtxRepr): + """ + TestCase review. + """ + + def __init__(self, comment: str, author: str, timestamp: int): + """Constructor + + :param comment: Review comment (10-10000 characters) + :type comment: str + :param author: Review author + :type author: str + :param timestamp: UTC timestamp in seconds + :rtype timestamp: int + """ + self.__comment = comment + self.__author = author + self.__timestamp = timestamp + + def create_json_repr(self): + """ + :see: :class:`Json2AtxRepr` + """ + result = { + "comment": self.__comment, + "timestamp": self.__timestamp, + "author": self.__author + } + return result + + +class TestStepArtifact(Artifact): + """ + Artifact attached to an ATX-TestStep + """ + + __test__ = False # pytest ignore + + def __init__(self, file_path: str, artifact_type: TestStepArtifactType): + """ + Constructor + + :param file_path: file path to the artifact + :type file_path: str + :param artifact_type: Type of the artifact (currently only images are supported) + :type artifact_type: TestStepArtifactType + :raises TypeError: artifact_type is not of type TestStepArtifactType + :raises OSError: file_path is not a valid path to a file + """ + super().__init__(file_path) + + if not isinstance(artifact_type, TestStepArtifactType): + raise TypeError("Argument 'artifact_type' must be of type 'TestStepArtifactType'.") + + self.__artifact_type = artifact_type + + def get_artifact_type(self): + """ + returns the artifacts type + + :rtype: TestStepArtifactType + """ + return self.__artifact_type + + def create_json_repr(self): + """ + :see: :class:`Json2AtxRepr` + """ + return {"path": self.get_path_in_upload_zip(), "artifactType": self.__artifact_type.name} + + +class TestStep(Json2AtxRepr): + """ + ATX-TestStep. + """ + + __test__ = False # pytest ignore + + def __init__(self, name: str, verdict: Verdict, expected_result: str = ''): + """ + Constructor + + :param name: label of the teststep + :type name: str + :param verdict: teststep verdict + :type verdict: Verdict + :param expected_result: expected result of the teststep + :type expected_result: str + :raises TypeError: argument 'verdict' is not of type Verdict + """ + self.__name = check_name_length(name, gen_error_msg("TestStep", name)) + + if not isinstance(verdict, Verdict): + raise TypeError("Argument 'verdict' must be of type 'Verdict'.") + + self.__description = None + self.__verdict = verdict + self.__expected_result = expected_result + self.__artifacts = [] + + def set_description(self, desc: str): + """ + Set the test case description. + + :param desc: teststep description + :type desc: str + :return: this object + :rtype: teststep + """ + self.__description = desc + return self + + def add_artifact(self, file_path: str, artifact_type: TestStepArtifactType, ignore_on_error: bool = False): + """ + Add an artifact to the TestStep. Allows to ignore the artifact, if it does not exist. + + :param file_path: path to artifact + :type file_path: str + :param artifact_type: type of the artifact + :type artifact_type: TestStepArtifactType + :param ignore_on_error: set to True, to skip this artifact if it does not exist (will not raise an error) + :type ignore_on_error: bool + :raises OSError: file_path is invalid, only when ignore_on_error = False + :raises TypeError: artifact_type is not of type TestStepArtifactType + :return: this object + :rtype: TestStep + """ + try: + artifact = TestStepArtifact(file_path, artifact_type) + self.__artifacts.append(artifact) + except OSError as error: + if not ignore_on_error: + raise error + logging.warning(f"Artifact path '{file_path}' for teststep '{self.__name}' is invalid, " + f"will be ignored!") + return self + + def get_artifacts(self): + """ + Get the TestSteps artifacts + + :return: list of TestStepArtifact + :rtype: list + """ + return self.__artifacts + + def create_json_repr(self): + """ + :see: :class:`Json2AtxRepr` + """ + result = { + "@type": "teststep", + "name": self.__name, + "description": self.__description, + "verdict": self.__verdict.name, + "expected_result": self.__expected_result, + "testStepArtifacts": [each.create_json_repr() for each in self.__artifacts] + } + return result + + +class TestStepFolder(Json2AtxRepr): + """ + ATX-TestStepFolder. Each teststep folder must contain at least one TestStep to be TEST-GUIDE + compliant. + """ + + __test__ = False # pytest ignore + + def __init__(self, name: str): + """ + Constructor + + :param name: TestStepFolder name + :type name: str + """ + self.__name = check_name_length(name, gen_error_msg("TestStepFolder", name)) + self.__description = None + self.__teststeps = [] + + def set_description(self, desc: str): + """ + Set the test case description. + + :param desc: teststep description + :type desc: str + :return: this object + :rtype: teststep + """ + self.__description = desc + return self + + def add_teststep(self, teststep): + """ + Adds a TestStep or TestStepFolder to the teststep folder. + + :param teststep: TestStep to be added + :type teststep: TestStep or TestStepFolder + :raises TypeError: the argument is not a TestStep or TestStepFolder + :return: this object + :rtype: TestStepFolder + """ + if not isinstance(teststep, (TestStep, TestStepFolder)): + raise TypeError("Argument teststep must be of type TestStep or TestStepFolder.") + + self.__teststeps.append(teststep) + return self + + def get_teststeps(self): + """ + :return: all teststeps of the TestStepFolder + :rtype: list + """ + return self.__teststeps + + def create_json_repr(self): + """ + :see: :class:`Json2AtxRepr` + """ + result = { + "@type": "teststepfolder", + "name": self.__name, + "description": self.__description, + "teststeps": [each.create_json_repr() for each in self.__teststeps], + } + return result + + +class TestCase(Json2AtxRepr): + """ + ATX-TestCase to be added to a :class:`TestSuite`. Each + TestSuite must contain at least one testcase to be TEST-GUIDE compliant (or, alternatively, + at least one :class:`TestCaseFolder`). + """ + + __test__ = False # pytest ignore + + def __init__(self, name: str, timestamp: int, verdict: Verdict): + """ + Constructor + + :param name: Name of the testcase + :type name: str + :param timestamp: timestamp in milliseconds + :type timestamp: int + :param verdict: testcase verdict + :type verdict: Verdict + :raises: TypeError, if the argument 'verdict' is not of type Verdict + """ + + self.__name = check_name_length(name, gen_error_msg("TestCase", name)) + self.__timestamp = timestamp + + if not isinstance(verdict, Verdict): + raise TypeError("Argument 'verdict' must be of type 'Verdict'.") + self.__verdict = verdict + + self.__execution_time = 0 + self.__description = None + + self.__setup_teststeps = [] + self.__execution_teststeps = [] + self.__teardown_teststeps = [] + + self.__param_set = None + self.__parameters = [] + + self.__attributes = [] + self.__constants = [] + + self.__artifacts = [] + + self.__review = None + + def set_description(self, desc: str): + """ + Set the test case description. + + :param desc: testcase description + :type desc: str + :return: this object + :rtype: TestCase + """ + self.__description = desc + return self + + def set_execution_time_in_sec(self, exec_time: int): + """ + Set the execution time of the testcase. + + :param exec_time: execution time in seconds > 0 + :type exec_time: int + :return: this object + :rtype: TestCase + """ + self.__execution_time = exec_time + return self + + def add_parameter_set(self, name: str, params: List[Parameter]): + """ + Set the parameter set. + + :param name: name of the parameter set + :type name: str or None + :param params: list of Parameter + :type params: list + :raises TypeError: the 'params' parameter has the wrong type + :return: this object + :rtype: TestCase + """ + + self.__param_set = name + + if not all(isinstance(param, Parameter) for param in params): + raise TypeError("Argument params must be of type list from Parameter.") + + self.__parameters = params + return self + + def add_constants(self, constants: List[Constant]): + """ + Add a list of constants to the test case. + + :param constants: list of Constant + :type constants: list + :raises TypeError: constants contains at least one element which has the wrong type + :return: this object + :rtype: TestCase + """ + + if not all(isinstance(constant, Constant) for constant in constants): + raise TypeError("Argument constants must be of type list from Constant.") + + for each in constants: + self.add_constant(each) + + return self + + def add_constant(self, constant: Constant): + """ + Add a constant to the test case. + + :param constant: ATX-Constant + :type constant: Constant + :raises TypeError: the argument is of the wrong type + :return: this object + :rtype: TestCase + """ + + if not isinstance(constant, Constant): + raise TypeError("Argument constant must be of type Constant.") + self.__constants.append(constant) + return self + + def add_constant_pair(self, key: str, value: str): + """ + Add a constant to the testcase. + + :param key: Constant key + :type key: str + :param value: Constant value + :type value: str + :return: this object + :rtype: TestCase + """ + self.add_constant(Constant(key, value)) + return self + + def add_attribute_pair(self, key: str, value: str): + """ + Add an attribute to the testcase. + + :param key: Attribute key + :type key: str + :param value: Attribute value + :type value: str + :return: this object + :rtype: TestCase + """ + self.__attributes.append(Attribute(key, value)) + return self + + def add_setup_teststep(self, teststep: Union[TestStep, TestStepFolder]): + """ + Adds a TestStep or TestStepFolder to the setup/precondition teststeps. + + :param teststep: TestStep to be added + :type teststep: TestStep or TestStepFolder + :raises: ValueError, if the argument is not a TestStep or TestStepFolder, or if an empty + TestStepFolder was added + :return: this object + :rtype: TestCase + """ + if validate_new_teststep(teststep, TestStep, TestStepFolder): + self.__setup_teststeps.append(teststep) + return self + + def add_execution_teststep(self, teststep: Union[TestStep, TestStepFolder]): + """ + Adds a TestStep or TestStepFolder to the execution test steps. + + :param teststep: TestStep to be added + :type teststep: TestStep or TestStepFolder + :raises: ValueError, if the argument is not a TestStep or TestStepFolder, or if an empty + TestStepFolder was added + :return: this object + :rtype: TestCase + """ + if validate_new_teststep(teststep, TestStep, TestStepFolder): + self.__execution_teststeps.append(teststep) + return self + + def add_teardown_teststep(self, teststep: Union[TestStep, TestStepFolder]): + """ + Adds a TestStep or TestStepFolder to the teardown/postcondition test steps. + + :param teststep: TestStep to be added + :type teststep: TestStep or TestStepFolder + :raises: ValueError, if the argument is not a TestStep or TestStepFolder, or if an empty + TestStepFolder was added + :return: this object + :rtype: TestCase + """ + if validate_new_teststep(teststep, TestStep, TestStepFolder): + self.__teardown_teststeps.append(teststep) + return self + + def add_artifact(self, artifact_file_path: str, ignore_on_error: bool = False): + """ + Adds an arbitrary artifact to the testcase execution. + + :param artifact_file_path: artifact file path + :type artifact_file_path: str + :param ignore_on_error: True, if this file should simply be ignored if the file path is + accessed incorrectly, otherwise False. + :type ignore_on_error: bool + :raises OSError: file_path is invalid, only when ignore_on_error = False + :return: this object + :rtype: TestStep + """ + try: + artifact = Artifact(artifact_file_path) + self.__artifacts.append(artifact) + except OSError as error: + if not ignore_on_error: + raise error + logging.warning(f"Artifact path '{artifact_file_path}' for testcase" + f" '{self.__name}' is invalid, will be ignored!") + return self + + def get_artifacts(self): + """ + :return: Attached files to the testcase and its test steps. + :rtype: list + """ + result = [] + result.extend(self.__artifacts) + for step in self.__setup_teststeps: + result.extend(self.__collect_teststep_artifacts(step)) + for step in self.__execution_teststeps: + result.extend(self.__collect_teststep_artifacts(step)) + for step in self.__teardown_teststeps: + result.extend(self.__collect_teststep_artifacts(step)) + return result + + def set_review(self, review: Review): + """ + Set a review for the testcase. + + :param review: Review for testcase + :type review: Review + :raises TypeError: the argument is of the wrong type + """ + if not isinstance(review, Review): + raise TypeError("Argument review must be of type Review.") + self.__review = review + return self + + def create_json_repr(self): + """ + :see: :class:`Json2AtxRepr` + """ + result = { + '@type': "testcase", + 'name': self.__name, + 'verdict': self.__verdict.name, + 'description': self.__description, + 'timestamp': self.__timestamp, + "executionTime": self.__execution_time, + "parameters": [each.create_json_repr() for each in self.__parameters], + "paramSet": self.__param_set, + "setupTestSteps": [each.create_json_repr() for each in self.__setup_teststeps], + "executionTestSteps": [each.create_json_repr() for each in self.__execution_teststeps], + "teardownTestSteps": [each.create_json_repr() for each in self.__teardown_teststeps], + "attributes": [each.create_json_repr() for each in self.__attributes], + "constants": [each.create_json_repr() for each in self.__constants], + "environments": [], + "artifacts": [each.create_json_repr() for each in self.__artifacts], + } + if self.__review: + result["review"] = self.__review.create_json_repr() + return result + + def __collect_teststep_artifacts(self, teststep) -> list: + """ + Helper method to recursively collect all artifacts in a TestStep/TestStepFolder hierarchy + """ + result = [] + if isinstance(teststep, TestStep): + result.extend(teststep.get_artifacts()) + elif isinstance(teststep, TestStepFolder): + for item in teststep.get_teststeps(): + result.extend(self.__collect_teststep_artifacts(item)) + return result diff --git a/testguide_report_generator/model/TestCaseFolder.py b/testguide_report_generator/model/TestCaseFolder.py new file mode 100644 index 0000000..8e10598 --- /dev/null +++ b/testguide_report_generator/model/TestCaseFolder.py @@ -0,0 +1,68 @@ +# Copyright (c) 2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +This module contains the TestCaseFolder class. +""" + +from testguide_report_generator.model.TestCase import TestCase +from testguide_report_generator.util.Json2AtxRepr import Json2AtxRepr +from testguide_report_generator.util.ValidityChecks import check_name_length, gen_error_msg, \ + validate_testcase + + +class TestCaseFolder(Json2AtxRepr): + """ + ATX-TestCaseFolder to be added to a :class:`TestSuite`. + Each TestSuite must contain at least one TestCase or TestCaseFolder to be TEST-GUIDE + compliant. Each TestCaseFolder must contain at least one + :class:`TestCase` to be TEST-GUIDE compliant. + """ + + __test__ = False # pytest ignore + + def __init__(self, name: str): + """ + Constructor + + :param name: name of the testcase folder + :type name: str + """ + self.__name = check_name_length(name, gen_error_msg("TestCaseFolder", name)) + self.__testcases = [] + + def add_testcase(self, testcase): + # pylint: disable=R0801 + """ + Adds a TestCase or TestCaseFolder to the testcase folder. + + :param testcase: testcase to be added + :type testcase: TestCase or TestCaseFolder + :raises: ValueError, if the argument is not a TestCase or TestCaseFolder + :return: this object + :rtype: TestCaseFolder + """ + if validate_testcase(testcase, TestCase, TestCaseFolder): + self.__testcases.append(testcase) + return self + + def get_testcases(self): + """ + :return: Testcases or TestCaseFolders + :rtype: list + """ + return self.__testcases + + def create_json_repr(self): + """ + :see: :class:`Json2AtxRepr` + """ + result = { + '@type': "testcasefolder", + 'name': self.__name, + "testcases": [each.create_json_repr() for each in self.__testcases], + } + return result diff --git a/testguide_report_generator/model/TestSuite.py b/testguide_report_generator/model/TestSuite.py new file mode 100644 index 0000000..b03f509 --- /dev/null +++ b/testguide_report_generator/model/TestSuite.py @@ -0,0 +1,73 @@ +# Copyright (c) 2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +This module contains the TestSuite class. +""" + +from typing import Union + +from testguide_report_generator.model.TestCase import TestCase +from testguide_report_generator.model.TestCaseFolder import TestCaseFolder +from testguide_report_generator.util.Json2AtxRepr import Json2AtxRepr +from testguide_report_generator.util.ValidityChecks import check_name_length, validate_testcase + + +class TestSuite(Json2AtxRepr): + """ + ATX-TestSuite. This is the top-level element from which the `.json` report will be generated. A + testsuite must contain at least one :class:`TestCase` or + :class:`TestCaseFolder` to be TEST-GUIDE compliant + """ + + __test__ = False # pytest ignore + NAME_ERROR_MSG = "The name of the TestSuite must have a length between 1-120 characters." + + def __init__(self, name: str, timestamp: int): + """ + Constructor + + :param name: name of the TestSuite + :type name: str + :param timestamp: timestamp in milliseconds + :type timestamp: int + """ + self.__name = check_name_length(name, self.NAME_ERROR_MSG) + self.__timestamp = timestamp + self.__testcases = [] + + def add_testcase(self, testcase: Union[TestCase, TestCaseFolder]): + """ + Adds a TestCase or TestCaseFolder to the TestSuite. + + :param testcase: testcase to be added + :type testcase: TestCase or TestCaseFolder + :raises: ValueError, if the argument is not a TestCase or TestCaseFolder, or if an empty + TestCaseFolder was added + :return: this object + :rtype: TestSuite + """ + if validate_testcase(testcase, TestCase, TestCaseFolder): + self.__testcases.append(testcase) + return self + + def get_testcases(self): + """ + :return: Testcases or TestCaseFolders + :rtype: list + """ + return self.__testcases + + def create_json_repr(self): + """ + @see: :class:`Json2AtxRepr` + """ + result = { + 'name': self.__name, + 'timestamp': self.__timestamp, + 'testcases': [each.create_json_repr() for each in self.__testcases] + } + return result diff --git a/testguide_report_generator/model/__init__.py b/testguide_report_generator/model/__init__.py new file mode 100644 index 0000000..f865d4f --- /dev/null +++ b/testguide_report_generator/model/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT diff --git a/testguide_report_generator/schema/schema.json b/testguide_report_generator/schema/schema.json new file mode 100644 index 0000000..f93f770 --- /dev/null +++ b/testguide_report_generator/schema/schema.json @@ -0,0 +1,471 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "type": "object", + "properties": { + "timestamp": { + "$ref": "#/definitions/TimeStamp" + }, + "name": { + "$ref": "#/definitions/ShortNameString" + }, + "testcases": { + "$ref": "#/definitions/TestCases" + }, + "optionalReportIdentifier": { + "type": "string", + "maxLength": 64 + } + }, + "required": [ + "name", + "timestamp", + "testcases" + ], + "definitions": { + "TimeStamp": { + "type": "number", + "minimum": 0 + }, + "OptionalString": { + "type": [ + "string", + "null" + ] + }, + "OptionalDescription": { + "type": [ + "string", + "null" + ], + "maxLength": 6144 + }, + "ShortNameString": { + "type": "string", + "minLength": 1, + "maxLength": 120, + "$comment": "ATX standard provides a maximum of 128 characters for the shortnames, we allow 120 characters to buffer the ATX path reference assignment, like e.g. for Testcase_1 or Testcase_42." + }, + "TestStepNameString": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "$comment": "The length of the test step names are limited to 255 and are exceptionally not based on the ATX short name." + }, + "TestCases": { + "type": "array", + "minItems": 1, + "items": { + "anyOf": [ + { + "$ref": "#/definitions/TestCaseFolder" + }, + { + "$ref": "#/definitions/TestCase" + } + ] + } + }, + "TestCase": { + "type": "object", + "properties": { + "@type": { + "const": "testcase" + }, + "name": { + "$ref": "#/definitions/ShortNameString" + }, + "verdict": { + "$ref": "#/definitions/Verdict" + }, + "description": { + "$ref": "#/definitions/OptionalDescription" + }, + "timestamp": { + "$ref": "#/definitions/TimeStamp" + }, + "executionTime": { + "type": "number", + "minimum": 0 + }, + "constants": { + "type": "array", + "items": { + "$ref": "#/definitions/Constant" + } + }, + "attributes": { + "type": "array", + "items": { + "$ref": "#/definitions/Attribute" + } + }, + "setupTestSteps": { + "$ref": "#/definitions/OptionalTestSteps" + }, + "executionTestSteps": { + "$ref": "#/definitions/OptionalTestSteps" + }, + "teardownTestSteps": { + "$ref": "#/definitions/OptionalTestSteps" + }, + "parameters": { + "type": "array", + "items": { + "$ref": "#/definitions/Parameter" + } + }, + "artifacts": { + "type": "array", + "items": { + "type": "string" + } + }, + "artifactRefs": { + "type": "array", + "items": { + "$ref": "#/definitions/ArtifactRef" + } + }, + "review": { + "$ref": "#/definitions/Review" + }, + "paramSet": { + "$ref": "#/definitions/OptionalString" + }, + "environments": { + "type": "array", + "items": { + "$ref": "#/definitions/Environment" + } + }, + "recordings": { + "type": "array", + "items": { + "$ref": "#/definitions/Recording" + } + } + }, + "required": [ + "@type", + "name", + "verdict", + "timestamp" + ] + }, + "TestCaseFolder": { + "type": "object", + "properties": { + "@type": { + "const": "testcasefolder" + }, + "name": { + "$ref": "#/definitions/ShortNameString" + }, + "testcases": { + "$ref": "#/definitions/TestCases" + } + }, + "required": [ + "@type", + "name", + "testcases" + ] + }, + "Attribute": { + "type": "object", + "properties": { + "key": { + "$ref": "#/definitions/ShortNameString" + }, + "value": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "required": [ + "key", + "value" + ] + }, + "Constant": { + "type": "object", + "properties": { + "key": { + "$ref": "#/definitions/ShortNameString" + }, + "value": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "required": [ + "key", + "value" + ] + }, + "OptionalTestSteps": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/TestStepFolder" + }, + { + "$ref": "#/definitions/TestStep" + } + ] + } + }, + "TestStep": { + "type": "object", + "properties": { + "@type": { + "const": "teststep" + }, + "name": { + "$ref": "#/definitions/TestStepNameString" + }, + "description": { + "$ref": "#/definitions/OptionalDescription" + }, + "verdict": { + "$ref": "#/definitions/Verdict" + }, + "expectedResult": { + "$ref": "#/definitions/OptionalString" + }, + "testStepArtifacts": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/TestStepArtifact" + } + ] + } + } + }, + "required": [ + "@type", + "name", + "verdict" + ] + }, + "TestStepFolder": { + "type": "object", + "properties": { + "@type": { + "const": "teststepfolder" + }, + "name": { + "$ref": "#/definitions/TestStepNameString" + }, + "description": { + "$ref": "#/definitions/OptionalDescription" + }, + "verdict": { + "$ref": "#/definitions/Verdict" + }, + "expectedResult": { + "$ref": "#/definitions/OptionalString" + }, + "teststeps": { + "type": "array", + "minItems": 1, + "items": { + "anyOf": [ + { + "$ref": "#/definitions/TestStepFolder" + }, + { + "$ref": "#/definitions/TestStep" + } + ] + } + } + }, + "required": [ + "@type", + "name", + "teststeps" + ] + }, + "Verdict": { + "type": "string", + "enum": [ + "NONE", + "PASSED", + "INCONCLUSIVE", + "FAILED", + "ERROR" + ] + }, + "Environment": { + "type": "object", + "properties": { + "name": { + "$ref": "#/definitions/ShortNameString" + }, + "value": { + "type": "string" + }, + "desc": { + "type": "string" + } + }, + "required": [ + "name", + "value", + "desc" + ] + }, + "Parameter": { + "type": "object", + "properties": { + "name": { + "$ref": "#/definitions/ShortNameString" + }, + "direction": { + "type": "string", + "enum": [ + "IN", + "OUT", + "INOUT" + ] + }, + "value": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "boolean" + }, + { + "type": "number" + } + ] + } + }, + "required": [ + "name", + "direction", + "value" + ] + }, + "Review": { + "type": "object", + "properties": { + "summary": { + "type": [ + "string", + "null" + ], + "maxLength": 512 + }, + "comment": { + "type": "string", + "minLength": 1, + "maxLength": 10000 + }, + "timestamp": { + "$ref": "#/definitions/TimeStamp" + }, + "verdict": { + "$ref": "#/definitions/Verdict" + }, + "author": { + "type": "string", + "maxLength": 512 + }, + "defect": { + "$ref": "#/definitions/OptionalString" + }, + "defectPriority": { + "$ref": "#/definitions/OptionalString" + }, + "tickets": { + "type": "array", + "items": { + "type": "string", + "maxLength": 512 + } + }, + "invalidRun": { + "type": "boolean" + } + }, + "required": [ + "author", + "timestamp", + "comment" + ] + }, + "Recording": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "direction": { + "type": "string", + "enum": [ + "IN", + "OUT", + "INOUT" + ] + } + }, + "required": [ + "name", + "direction" + ] + }, + "ArtifactRef": { + "type": "object", + "properties": { + "ref": { + "type": "string" + }, + "md5": { + "type": "string" + }, + "fileSize": { + "type": "integer" + } + } + }, + "TestStepArtifact": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "artifactType": { + "type": "string", + "enum": [ + "IMAGE" + ] + } + } + } + } +} diff --git a/testguide_report_generator/util/File.py b/testguide_report_generator/util/File.py new file mode 100644 index 0000000..1a0c50b --- /dev/null +++ b/testguide_report_generator/util/File.py @@ -0,0 +1,50 @@ +# Copyright (c) 2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +This module contains some utility methods. +""" + +import hashlib +import os + + +def get_extended_windows_path(source_path: str): + """ + Appends the extension to the transferred path so that Windows can also handle path lengths of + more than 255 characters. UNC paths are explicitly considered separately. + + :param source_path: Path to be extended. + :type source_path: str + :return: Windows path with the extension for more than 255 characters path length. + :rtype: str + """ + if len(source_path) >= 2: + # if path is local non-UNC, extend to local UNC + if source_path[1] == ":": + real_path = os.path.normpath(source_path) + source_path = "\\\\?\\" + real_path + # if path is UNC network resource + elif source_path.startswith("\\\\") and not source_path.startswith("\\\\?\\"): + source_path = "\\\\?\\UNC\\" + source_path.lstrip("\\") + source_path = os.path.realpath(source_path) + return source_path + + +def get_md5_hash_from_file(file_path): + """ + Calculates the MD5 hash of the file. + + :param file_path: file path + :type file_path: str + :return: MD5 hash + :rtype: str + """ + hasher = hashlib.md5() + with open(get_extended_windows_path(file_path), 'rb') as afile: + buf = afile.read() + hasher.update(buf) + return hasher.hexdigest() diff --git a/testguide_report_generator/util/Json2AtxRepr.py b/testguide_report_generator/util/Json2AtxRepr.py new file mode 100644 index 0000000..e6637f3 --- /dev/null +++ b/testguide_report_generator/util/Json2AtxRepr.py @@ -0,0 +1,25 @@ +# Copyright (c) 2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +This module contains the abstract class Json2AtxRepr. +""" + +from abc import ABC, abstractmethod + + +class Json2AtxRepr(ABC): + """ + Interface for all data classes, which should be translated into Json. + """ + + @abstractmethod + def create_json_repr(self): + """ + :return: the JSON ATX representation. + :rtype: dict + """ + raise NotImplementedError("To be implemented") # pragma: no cover diff --git a/testguide_report_generator/util/JsonValidator.py b/testguide_report_generator/util/JsonValidator.py new file mode 100644 index 0000000..a4403f9 --- /dev/null +++ b/testguide_report_generator/util/JsonValidator.py @@ -0,0 +1,73 @@ +# Copyright (c) 2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +This module contains the JsonValidator class. +""" + +import json +import os + +import jsonschema + +DEFAULT_JSON_SCHEMA_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", + "schema", "schema.json") + + +class JsonValidator: + """ + Validator for the Json2Atx file. + """ + + def __init__(self, json_schema_file_path: str = DEFAULT_JSON_SCHEMA_PATH): + """ + Constructor. + + :param json_schema_file_path: path to Json schema file + :type json_schema_file_path: str + """ + + with open(json_schema_file_path, 'r', encoding='utf-8') as file: + self.__schema = json.loads(file.read()) + + def validate_file(self, json_file_path: str): + """ + Validates the given json file against the schema. + + :param json_file_path: path to generated json schema + :type json_file_path: str + :return: true if the validation was successful, otherwise false + :rtype: boolean + """ + + with open(json_file_path, 'r', encoding='utf-8') as file: + json_content = json.loads(file.read()) + + validator = jsonschema.Draft7Validator(self.__schema) + errors = sorted(validator.iter_errors(json_content), key=lambda e: e.path) + for error in errors: + for sub_error in sorted(error.context, key=lambda e: e.schema_path): + print(list(sub_error.schema_path), sub_error.message, sep=", ") + + return len(errors) == 0 + + def validate_json(self, json_object: dict): + """ + Validates the given json object against the schema. + + :param json_object: dictionary which represents json formatted data + :type json_object: dict + :return: true if the validation was successful, otherwise false + :rtype: boolean + """ + + validator = jsonschema.Draft7Validator(self.__schema) + errors = sorted(validator.iter_errors(json_object), key=lambda e: e.path) + for error in errors: + for sub_error in sorted(error.context, key=lambda e: e.schema_path): + print(list(sub_error.schema_path), sub_error.message, sep=", ") + + return len(errors) == 0 diff --git a/testguide_report_generator/util/ValidityChecks.py b/testguide_report_generator/util/ValidityChecks.py new file mode 100644 index 0000000..4cd9eb5 --- /dev/null +++ b/testguide_report_generator/util/ValidityChecks.py @@ -0,0 +1,99 @@ +# Copyright (c) 2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Module containing checking methods to ensure that the objects contained in the testsuite, and the +testsuite, are constructed in a valid manner. This guarantees that errors are found early during +setup of the testsuite. +""" + + +def check_name_length(name, error_msg): + """ + Checks whether the given name complies to length restrictions according to the schema. + + :param name: name + :type name: str + :param error_msg: the error message pertaining to the error thrown, if 'name' is invalid + :type error_msg: str + :raises: ValueError, if 'name' length is invalid + :return: name, if check was successful + :rtype: str + """ + + if len(name) not in range(1, 121): + raise ValueError(error_msg) + + return name + + +def gen_error_msg(obj_type, name): + """ + Dynamic error message. + + :param obj_type: type of object to which this error message belongs to. + :type obj_type: str + :param name: name parameter of the object + :type name: str + :return: error message + :rtype: str + """ + return f"The name of the {obj_type} must have a length between 1-120 characters. Name was: {name}" + + +def validate_new_teststep(teststep, stepclass, folderclass): + """ + Checks whether the TestStep(Folder) object may be added to the TestCase. + + :param teststep: the teststep to be checked + :type teststep: :class:`TestStep` or + :class:`TestStepFolder` + :param stepclass: TestStep class + :type stepclass: :class:`TestStep` + :param folderclass: TestStepFolder class + :type folderclass: :class:`TestStepFolder` + :raises: ValueError, if teststep is not a TestStep or TestStepFolder, or if an empty + TestStepFolder was added + :return: True, if checks are successful + :rtype: bool + """ + + if not isinstance(teststep, (stepclass, folderclass)): + raise ValueError("Argument teststep must be of type TestStep or TestStepFolder.") + + if isinstance(teststep, folderclass): + if not teststep.get_teststeps(): + raise ValueError("TestStepFolder may not be empty.") + + return True + + +def validate_testcase(testcase, test_case_class, test_case_folder_class): + """ + Checks whether the TestCase(Folder) object may be added to the TestSuite. + + :param testcase: the testcase to be checked + :type testcase: :class:`TestCase` or + :class:`TestCaseFolder` + :param test_case_class: TestCase class + :type test_case_class: :class:`TestCase` + :param test_case_folder_class: TestCaseFolder class + :type test_case_folder_class: + :class:`TestCaseFolder` + :raises: ValueError, if the argument is not a TestCase or TestCaseFolder, or if an empty + TestCaseFolder was added + :return: True, if checks are successful + :rtype: bool + """ + + if not isinstance(testcase, (test_case_class, test_case_folder_class)): + raise ValueError("Argument testcase must be of type TestCase or TestCaseFolder.") + + if isinstance(testcase, test_case_folder_class): + if not testcase.get_testcases(): + raise ValueError("TestCaseFolder may not be empty.") + + return True diff --git a/testguide_report_generator/util/__init__.py b/testguide_report_generator/util/__init__.py new file mode 100644 index 0000000..f865d4f --- /dev/null +++ b/testguide_report_generator/util/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT diff --git a/tests/e2e/Dockerfile b/tests/e2e/Dockerfile new file mode 100644 index 0000000..8f0e2b4 --- /dev/null +++ b/tests/e2e/Dockerfile @@ -0,0 +1,11 @@ +# Copyright (c) 2022-2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT +FROM python:3.9 +COPY . . +#Install poetry +RUN pip install poetry +#Install dependencies +RUN poetry install +#Run Test +CMD ["poetry","run", "python","-m","pytest","tests/e2e/test_e2e.py"] diff --git a/tests/e2e/Jenkinsfile b/tests/e2e/Jenkinsfile new file mode 100644 index 0000000..4adf1d4 --- /dev/null +++ b/tests/e2e/Jenkinsfile @@ -0,0 +1,63 @@ +/* +* Copyright (c) 2022-2023 TraceTronic GmbH +* +* SPDX-License-Identifier: MIT +*/ + +@Library(['tracetronic-jenkins-lib', 'internal-cxs-jenkins-lib']) _ + +pipeline { + agent { + node { + label 'windows && docker' + } + } + environment { + pipeline_report_dir = "pipeline_report2TG" + tgAuthKey = credentials('TG_authkey_test_report_upload') + } + + stages { + stage ('Docker Build') { + steps { + bat """ + @echo off + docker build -t temp/json_gen_e2e:${env.GIT_COMMIT} -f tests/e2e/Dockerfile . + """ + } + } + stage('Docker run E2E Test') { + steps { + bat """ + docker run --rm -e TEST_GUIDE_URL=${TESTGUIDE_url} -e TEST_GUIDE_AUTHKEY=${tgAuthKey} -e TEST_GUIDE_PROJECT_ID=${TESTGUIDE_projectID} temp/json_gen_e2e:${env.GIT_COMMIT} + """ + } + } + } + post { + always { + script { + catchError(buildResult: 'FAILURE', stageResult: 'FAILURE') { + stage ('Remove Docker Image') { + bat """ + docker rmi temp/json_gen_e2e:${env.GIT_COMMIT} + """ + } + } + } + } + cleanup { + script { + stage ('TEST-GUIDE Upload') { + dir("${pipeline_report_dir}") { + pipeline2ATX(true) + } + uploadJson2TG("${TESTGUIDE_url}", "${tgAuthKey}", "${TESTGUIDE_projectID}", "${pipeline_report_dir}/**", '') + } + stage ('Clean Workspace') { + cleanWs() + } + } + } + } +} diff --git a/tests/e2e/e2e_testsuite.py b/tests/e2e/e2e_testsuite.py new file mode 100644 index 0000000..9dbb9a7 --- /dev/null +++ b/tests/e2e/e2e_testsuite.py @@ -0,0 +1,67 @@ +# Copyright (c) 2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT +from datetime import datetime + +from testguide_report_generator.model.TestSuite import TestSuite +from testguide_report_generator.model.TestCase import ( + TestCase, + TestStep, + TestStepFolder, + Verdict, + Parameter, + Direction, + Review, +) +from testguide_report_generator.model.TestCaseFolder import TestCaseFolder +from testguide_report_generator.ReportGenerator import Generator + + +def create_testsuite(): + timestamp = round(datetime.timestamp(datetime.now())) * 1000 + testsuite = TestSuite("E2E_TG_JSON_GENERATOR", timestamp) + + testcase_passed = TestCase("testcase_passed", timestamp + 1, Verdict.PASSED) + testcase_passed.set_description("TestCaseDesc") + testcase_passed.add_parameter_set( + "MyParameterSet", [Parameter("Input", 7, Direction.IN), Parameter("Output", 42, Direction.OUT)] + ) + testcase_passed.add_constant_pair("SOP", "2042") + testcase_passed.add_attribute_pair("ReqId", "007") + testcase_passed.add_attribute_pair("Designer", "Philipp") + testcase_passed.add_setup_teststep(TestStep("Check Picture0", Verdict.PASSED, "Shows nothing")) + + teststep_with_desc = TestStep("Check Pic1", Verdict.PASSED, "Shows traffic light").set_description("TestStepDesc") + testcase_passed.add_execution_teststep(teststep_with_desc) + + testcase_passed.add_execution_teststep( + TestStepFolder("Action").add_teststep(TestStep("Check car speed", Verdict.PASSED, "ego >= 120")) + ) + testcase_passed.add_execution_teststep(TestStep("Check Pic2", Verdict.PASSED, "Shows Ego Vehicle")) + testcase_passed.add_artifact("testguide_report_generator/schema/schema.json", False) + testcase_passed.set_review(Review("Review-Comment", "Reviewer", timestamp + 2)) + testsuite.add_testcase(testcase_passed) + + testcase_failed = TestCase("testcase_failed", timestamp + 3, Verdict.FAILED) + testcase_folder = TestCaseFolder("Testcase Folder") + testcase_folder.add_testcase(testcase_passed) + testcase_folder.add_testcase(testcase_failed) + testsuite.add_testcase(testcase_folder) + + testcase_error = TestCase("testcase_error", timestamp, Verdict.ERROR) + testcase_error.set_description("This testcase is supposed to error") + testcase_inconclusive = TestCase("testcase_inconclusive", timestamp, Verdict.INCONCLUSIVE) + testcase_verdict_none = TestCase("testcase_verdict_none", timestamp, Verdict.NONE) + testcase_folder2 = TestCaseFolder("Testcase Folder 2") + testcase_folder2.add_testcase(testcase_error) + testcase_folder2.add_testcase(testcase_inconclusive) + testcase_folder2.add_testcase(testcase_verdict_none) + testsuite.add_testcase(testcase_folder2) + + testcase_passed2 = TestCase("testcase_passed2", timestamp + 10, Verdict.PASSED) + testcase_folder3 = TestCaseFolder("Testcase Folder 3") + testcase_folder3.add_testcase(testcase_passed2) + testsuite.add_testcase(testcase_folder3) + + json_generator = Generator(testsuite) + json_generator.export("e2e.json") diff --git a/tests/e2e/test_e2e.py b/tests/e2e/test_e2e.py new file mode 100644 index 0000000..74a3e1b --- /dev/null +++ b/tests/e2e/test_e2e.py @@ -0,0 +1,110 @@ +# Copyright (c) 2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT + +import json +import os +import pytest +import requests +from urllib.parse import urlparse, parse_qs + +from conftest import ValueStorage +from tests.e2e.e2e_testsuite import create_testsuite + + +@pytest.mark.skipif(os.environ.get("TEST_GUIDE_URL") is None, reason="Env variables are not set") +def test_upload(value_storage: ValueStorage): + """ + Uploads a test suite with all classes to TEST-GUIDE + """ + create_testsuite() + assert os.path.exists("e2e.json") + assert os.path.exists("e2e.zip") + + upload_url = value_storage.BASE_URL + "api/upload-file" + params = { + "projectId": value_storage.PROJECT_ID, + "authKey": value_storage.AUTHKEY, + "apiVersion": "up-to-date", + "converter": "json2atx", + } + + filename = os.path.basename("./e2e.zip") + uploadFile = {"file-upload": (filename, open("./e2e.zip", "rb"), "application/zip")} + r = requests.post( + upload_url, + params=params, + files=uploadFile, + verify=False, + headers={ + "accept": "application/json", + }, + ) + assert r.ok + for elem in r.json()["ENTRIES"]: + if "atxId" in elem["TEXT"]: + url = elem["TEXT"] + parsed_url = urlparse(url) + value_storage.e2e_atxid = parse_qs(parsed_url.query)["atxId"][0] + + assert value_storage.e2e_atxid is not None + + +@pytest.mark.skipif(os.environ.get("TEST_GUIDE_URL") is None, reason="Env variables are not set") +def test_download(value_storage: ValueStorage): + """ + Test downloads data from TEST-GUIDE + """ + query_url = value_storage.BASE_URL + "api/report/testCaseExecutions/filter" + params = {"projectId": value_storage.PROJECT_ID, "offset": 0, "limit": 100, "authKey": value_storage.AUTHKEY} + filter = { + "atxIds": [value_storage.e2e_atxid], + } + r = requests.post( + query_url, + headers={ + "Content-Type": "application/json", + "accept": "application/json", + }, + params=params, + data=json.dumps(filter), + verify=False, + ) + assert r.status_code == 200 + value_storage.remote_testcases_json = r.json() + + +@pytest.mark.skipif(os.environ.get("TEST_GUIDE_URL") is None, reason="Env variables are not set") +def test_compare(value_storage): + """ + Test compares data generated with data downloaded from TEST-GUIDE + """ + assert value_storage.remote_testcases_json is not None + f = open("e2e.json") + data = json.load(f) + local_testcases = [] + testcases = [data["testcases"]] + while testcases: + current_folder = testcases.pop() + for item in current_folder: + if item["@type"] == "testcasefolder": + testcases.append(item["testcases"]) + else: + local_testcases.append(item) + + assert len(value_storage.remote_testcases_json) == len(local_testcases) + for remote_testcase in value_storage.remote_testcases_json: + for local_testcase in local_testcases: + if local_testcase["name"] == remote_testcase["testCaseName"]: + assert local_testcase["verdict"] == remote_testcase["verdict"] + assert local_testcase["paramSet"] == remote_testcase["parameterSet"] + assert len(local_testcase["parameters"]) == len(remote_testcase["parameters"]) + assert len(local_testcase["setupTestSteps"]) == len(remote_testcase["testSteps"]["setup"]) + assert len(local_testcase["executionTestSteps"]) == len(remote_testcase["testSteps"]["execution"]) + assert len(local_testcase["teardownTestSteps"]) == len(remote_testcase["testSteps"]["teardown"]) + assert len(local_testcase["attributes"]) == len(remote_testcase["attributes"]) + assert len(local_testcase["constants"]) == len(remote_testcase["constants"]) + if "review" in local_testcase: + assert local_testcase["review"]["comment"] == remote_testcase["lastReview"]["comment"] + assert remote_testcase["testSuiteName"] == data["name"] + break diff --git a/tests/model/test_TestCase.py b/tests/model/test_TestCase.py new file mode 100644 index 0000000..c6ab840 --- /dev/null +++ b/tests/model/test_TestCase.py @@ -0,0 +1,234 @@ +# Copyright (c) 2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT + +import pytest +import json +from unittest.mock import patch + +from testguide_report_generator.model.TestCase import (TestCase, TestStep, Verdict, Artifact, TestStepArtifact, + TestStepArtifactType) +from testguide_report_generator.util.ValidityChecks import gen_error_msg + + +class TestArtifact: + def test_new_artifact(self, artifact_path): + artifact = Artifact(artifact_path) + assert artifact.get_file_path() == artifact_path + + def test_new_artifact_error(self): + with pytest.raises(OSError, match="File does not exist or path does not point to a file"): + Artifact("does/not/exist.txt") + + def test_correct_json_repr(self, artifact_mock_hash): + json_str = json.dumps(artifact_mock_hash.create_json_repr()) + assert '"hash/artifact.txt"' == json_str + + +class TestTestStepArtifact: + def test_new(self, artifact_path): + artifact = TestStepArtifact(artifact_path, TestStepArtifactType.IMAGE) + assert artifact.get_file_path() == artifact_path + assert artifact.get_artifact_type() == TestStepArtifactType.IMAGE + + def test_new_error(self, artifact_path): + with pytest.raises(TypeError, match="TestStepArtifactType"): + TestStepArtifact(artifact_path, "IMAGE") + + def test_correct_json_repr(self, teststep_artifact_mock_hash): + json_str = json.dumps(teststep_artifact_mock_hash.create_json_repr()) + assert json_str == '{"path": "hash/artifact.txt", "artifactType": "IMAGE"}' + + +class TestTestStep: + + @patch("testguide_report_generator.model.TestCase.get_md5_hash_from_file") + def test_correct_json_repr(self, mock, teststep, artifact_path): + mock.return_value = "hash" + teststep.add_artifact(artifact_path, TestStepArtifactType.IMAGE, False) + assert len(teststep.get_artifacts()) == 1 + json_str = json.dumps(teststep.create_json_repr()) + + assert ( + '{"@type": "teststep", "name": "ts", "description": null, ' + '"verdict": "NONE", "expected_result": "undefined", "testStepArtifacts": ' + '[{"path": "hash/artifact.txt", "artifactType": "IMAGE"}]}' == json_str + ) + + def test_invalid_verdict(self): + with pytest.raises(TypeError) as e: + TestStep("a", "verdict") + + assert str(e.value) == "Argument 'verdict' must be of type 'Verdict'." + + def test_add_artifact_oserror(self, teststep, caplog): + with pytest.raises(OSError, match="File does not exist or path does not point to a file"): + teststep.add_artifact("invalid/path.obj", TestStepArtifactType.IMAGE, False) + + teststep.add_artifact("invalid/path.obj", TestStepArtifactType.IMAGE, True) + assert "Artifact path 'invalid/path.obj' for teststep 'ts' is invalid, will be ignored!" in caplog.text + + def test_add_artifact_typeerror(self, teststep, artifact_path): + with pytest.raises(TypeError, match="TestStepArtifactType"): + teststep.add_artifact(artifact_path, "invalid", True) + + +class TestTestStepFolder: + def test_correct_json_repr(self, teststep_folder): + tsf = teststep_folder.set_description("abc") + json_str = json.dumps(tsf.create_json_repr()) + + assert ( + '{"@type": "teststepfolder", "name": "tsf", "description": "abc", ' + '"teststeps": [' + '{"@type": "teststep", "name": "ts", "description": null, "verdict": "NONE", "expected_result": ' + '"undefined", "testStepArtifacts": []},' + ' {"@type": "teststep", "name": "ts2", "description": "teststep2", "verdict": "ERROR",' + ' "expected_result": "err", "testStepArtifacts": []}]}' == json_str + ) + + def test_add_teststep_error(self, teststep_folder): + with pytest.raises(TypeError) as error: + teststep_folder.add_teststep("") + + assert str(error.value) == "Argument teststep must be of type TestStep or TestStepFolder." + + +class TestParameter: + def test_correct_json_repr(self, parameter): + json_str = json.dumps(parameter.create_json_repr()) + assert '{"name": "param", "value": 10, "direction": "OUT"}' == json_str + + +class TestTestCase: + def test_add_parameter_error(self, testcase): + with pytest.raises(TypeError) as error: + testcase.add_parameter_set("", [""]) + + assert str(error.value) == "Argument params must be of type list from Parameter." + + def test_add_constants_error(self, testcase): + with pytest.raises(TypeError) as error: + testcase.add_constants([""]) + + assert str(error.value) == "Argument constants must be of type list from Constant." + + def test_add_constant_error(self, testcase): + with pytest.raises(TypeError) as error: + testcase.add_constant("") + + assert str(error.value) == "Argument constant must be of type Constant." + + def test_set_review_error(self, testcase): + with pytest.raises(TypeError) as error: + testcase.set_review("") + + assert str(error.value) == "Argument review must be of type Review." + + @pytest.mark.parametrize("input_name", ["a", "x" * 120]) + def test_default(self, input_name): + verdict = Verdict.FAILED + tc = TestCase(input_name, 0, verdict) + json_repr = tc.create_json_repr() + + assert json_repr["name"] == input_name + assert json_repr["timestamp"] == 0 + assert json_repr["verdict"] == "FAILED" + + @pytest.mark.parametrize("input_name", ["", "x" * 121]) + def test_value_error(self, input_name): + verdict = Verdict.FAILED + with pytest.raises(ValueError) as e: + TestCase(input_name, 0, verdict) + + assert str(e.value) == gen_error_msg("TestCase", input_name) + + def test_invalid_verdict(self): + with pytest.raises(TypeError) as e: + TestCase("a", 0, "verdict") + + assert str(e.value) == "Argument 'verdict' must be of type 'Verdict'." + + def test_add_artifact(self, artifact_path): + tc = TestCase("name", "", Verdict.PASSED) + + with pytest.raises(OSError) as error: + tc.add_artifact("", ignore_on_error=False) + + assert str(error.value) == "[Errno 2] File does not exist or path does not point to a file: ''" + + tc.add_artifact(artifact_path, ignore_on_error=False) + tc.add_artifact("", ignore_on_error=True) + + assert len(tc.get_artifacts()) == 1 + + def test_collect_artifacts(self, teststep_folder, artifact_path): + ts = TestStep("ts", Verdict.FAILED).add_artifact(artifact_path, TestStepArtifactType.IMAGE) + tc = TestCase("dummy", 0, Verdict.PASSED) + tc.add_artifact(artifact_path) + tc.add_execution_teststep(teststep_folder) + tc.add_setup_teststep(ts) + tc.add_teardown_teststep(ts) + assert len(tc.get_artifacts()) == 3 + + def test_correct_json_repr(self, testcase, testcase_json_path): + json_str = json.dumps(testcase.create_json_repr()) + + with open(testcase_json_path, "r") as file: + expected_json_repr = json.load(file) + + assert json.dumps(expected_json_repr) == json_str + + def test_add_teststeps_error(self, testcase): + ERROR_MSG = "Argument teststep must be of type TestStep or TestStepFolder." + + with pytest.raises(ValueError) as error: + testcase.add_setup_teststep("") + + assert str(error.value) == ERROR_MSG + + with pytest.raises(ValueError) as error: + testcase.add_execution_teststep("") + + assert str(error.value) == ERROR_MSG + + with pytest.raises(ValueError) as error: + testcase.add_teardown_teststep("") + + assert str(error.value) == ERROR_MSG + + def test_add_empty_teststep_folder_error(self, teststep_folder_empty, testcase): + ERROR_MSG = "TestStepFolder may not be empty." + + with pytest.raises(ValueError) as error: + testcase.add_teardown_teststep(teststep_folder_empty) + + assert str(error.value) == ERROR_MSG + + with pytest.raises(ValueError) as error: + testcase.add_execution_teststep(teststep_folder_empty) + + assert str(error.value) == ERROR_MSG + + with pytest.raises(ValueError) as error: + testcase.add_setup_teststep(teststep_folder_empty) + + assert str(error.value) == ERROR_MSG + + +class TestConstant: + def test_correct_json_repr(self, constant): + json_str = json.dumps(constant.create_json_repr()) + assert '{"key": "const", "value": "one"}' == json_str + + +class TestAttribute: + def test_correct_json_repr(self, attribute): + json_str = json.dumps(attribute.create_json_repr()) + assert '{"key": "an", "value": "attribute"}' == json_str + + +class TestReview: + def test_correct_json_repr(self, review): + json_str = json.dumps(review.create_json_repr()) + assert '{"comment": "comment", "timestamp": 1670254005, "author": "chucknorris"}' == json_str diff --git a/tests/model/test_TestCaseFolder.py b/tests/model/test_TestCaseFolder.py new file mode 100644 index 0000000..b2c55bc --- /dev/null +++ b/tests/model/test_TestCaseFolder.py @@ -0,0 +1,49 @@ +# Copyright (c) 2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT + +import json + +import pytest + +from testguide_report_generator.model.TestCase import TestCase, Verdict + + +def test_empty(testcase_folder_empty): + tcf = testcase_folder_empty + json_str = json.dumps(tcf.create_json_repr()) + + assert '{"@type": "testcasefolder", "name": "mytcf", "testcases": []}' == json_str + + +def test_add_testcase(testcase_folder_empty, testcase_folder): + tcf = testcase_folder_empty + tcf2 = testcase_folder + + tcf.add_testcase(TestCase("name", 0, Verdict.NONE)) + tcf.add_testcase(tcf2) + + assert len(tcf.get_testcases()) == 2 + assert len(tcf.get_testcases()[1].get_testcases()) == 1 + + +def test_add_testcase_error(testcase_folder_empty): + with pytest.raises(ValueError) as error: + testcase_folder_empty.add_testcase("") + + assert str(error.value) == "Argument testcase must be of type TestCase or TestCaseFolder." + + +def test_correct_json_repr(testcase_folder, testcase_json_path): + tcf = testcase_folder + tcf.add_testcase(TestCase("name", 0, Verdict.ERROR)) + + json_repr = tcf.create_json_repr() + tc_json_str = json.dumps(json_repr["testcases"][0]) + + with open(testcase_json_path, "r") as file: + expected_json_repr = json.load(file) + + assert len(json_repr["testcases"]) == 2 + assert json.dumps(expected_json_repr) == tc_json_str + assert json_repr["testcases"][1]["verdict"] == "ERROR" diff --git a/tests/model/test_TestSuite.py b/tests/model/test_TestSuite.py new file mode 100644 index 0000000..080d32a --- /dev/null +++ b/tests/model/test_TestSuite.py @@ -0,0 +1,64 @@ +# Copyright (c) 2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT +import pytest + +from testguide_report_generator.model.TestCase import TestCase, Verdict +from testguide_report_generator.model.TestSuite import TestSuite +import json + +NAME_ERROR_MSG = "The name of the TestSuite must have a length between 1-120 characters." + + +def test_empty(testsuite): + ts = testsuite + json_str = json.dumps(ts.create_json_repr()) + + assert '{"name": "MyTestSuite", "timestamp": 1666698047000, "testcases": []}' == json_str + + +def test_add_testcases(testsuite, testcase_folder, testcase): + ts = testsuite + ts.add_testcase(testcase) + ts.add_testcase(testcase_folder) + + assert len(ts.get_testcases()) == 2 + assert len(ts.get_testcases()[1].get_testcases()) == 1 + + +def test_add_testcase_error(testsuite): + with pytest.raises(ValueError) as error: + testsuite.add_testcase("") + + assert str(error.value) == "Argument testcase must be of type TestCase or TestCaseFolder." + + +@pytest.mark.parametrize("input_name", ["", "x" * 121]) +def test_value_error(input_name): + with pytest.raises(ValueError) as e: + TestSuite(input_name, 0) + + assert str(e.value) == NAME_ERROR_MSG + + +@pytest.mark.parametrize("input_name", ["a", "x" * 120]) +def test_name_valid(input_name): + json_repr = TestSuite(input_name, 0).create_json_repr() + + assert json_repr["name"] == input_name + + +def test_correct_json_representation_it(testcase_folder_empty, testcase_folder, testsuite): + testcase = TestCase("TestCase_1", 1666698047001, Verdict.PASSED) + + testcase_folder_empty.add_testcase(TestCase("name", 0, Verdict.PASSED)) + testsuite.add_testcase(testcase) + testsuite.add_testcase(testcase_folder_empty) + testsuite.add_testcase(testcase_folder) + + json_str = json.dumps(testsuite.create_json_repr()) + + with open("tests/resources/testsuite.json", "r") as file: + expected_json_repr = json.load(file) + + assert json.dumps(expected_json_repr) == json_str diff --git a/tests/resources/artifact.txt b/tests/resources/artifact.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/resources/artifact2.txt b/tests/resources/artifact2.txt new file mode 100644 index 0000000..0025c3b --- /dev/null +++ b/tests/resources/artifact2.txt @@ -0,0 +1 @@ +artifact \ No newline at end of file diff --git a/tests/resources/invalid.json b/tests/resources/invalid.json new file mode 100644 index 0000000..a59bc48 --- /dev/null +++ b/tests/resources/invalid.json @@ -0,0 +1,28 @@ +{ + "name": "MyTestSuite", + "timestamp": 1666698047000, + "testcases": [ + { + "@type": "testcase", + "name": "TestCase_1", + "verdict": "PASSED", + "description": null, + "timestamp": 1666698047001, + "executionTime": 0, + "parameters": [], + "paramSet": null, + "setupTestSteps": [], + "executionTestSteps": [], + "teardownTestSteps": [], + "attributes": [], + "constants": [], + "environments": [], + "artifacts": [] + }, + { + "@type": "testcasefolder", + "name": "mytcf", + "testcases": [] + } + ] +} diff --git a/tests/resources/testcase.json b/tests/resources/testcase.json new file mode 100644 index 0000000..315b300 --- /dev/null +++ b/tests/resources/testcase.json @@ -0,0 +1,153 @@ +{ + "@type": "testcase", + "name": "testcase_one", + "verdict": "PASSED", + "description": "First testcase.", + "timestamp": 1670248341000, + "executionTime": 5, + "parameters": [ + { + "name": "param", + "value": 10, + "direction": "OUT" + }, + { + "name": "param2", + "value": 15, + "direction": "INOUT" + } + ], + "paramSet": "myset", + "setupTestSteps": [ + { + "@type": "teststep", + "name": "ts", + "description": null, + "verdict": "NONE", + "expected_result": "undefined", + "testStepArtifacts": [] + }, + { + "@type": "teststepfolder", + "name": "tsf", + "description": null, + "teststeps": [ + { + "@type": "teststep", + "name": "ts", + "description": null, + "verdict": "NONE", + "expected_result": "undefined", + "testStepArtifacts": [] + }, + { + "@type": "teststep", + "name": "ts2", + "description": "teststep2", + "verdict": "ERROR", + "expected_result": "err", + "testStepArtifacts": [] + } + ] + } + ], + "executionTestSteps": [ + { + "@type": "teststep", + "name": "ts2", + "description": "teststep2", + "verdict": "ERROR", + "expected_result": "err", + "testStepArtifacts": [] + }, + { + "@type": "teststepfolder", + "name": "tsf", + "description": null, + "teststeps": [ + { + "@type": "teststep", + "name": "ts", + "description": null, + "verdict": "NONE", + "expected_result": "undefined", + "testStepArtifacts": [] + }, + { + "@type": "teststep", + "name": "ts2", + "description": "teststep2", + "verdict": "ERROR", + "expected_result": "err", + "testStepArtifacts": [] + } + ] + } + ], + "teardownTestSteps": [ + { + "@type": "teststepfolder", + "name": "tsf", + "description": null, + "teststeps": [ + { + "@type": "teststep", + "name": "ts", + "description": null, + "verdict": "NONE", + "expected_result": "undefined", + "testStepArtifacts": [] + }, + { + "@type": "teststep", + "name": "ts2", + "description": "teststep2", + "verdict": "ERROR", + "expected_result": "err", + "testStepArtifacts": [] + } + ] + }, + { + "@type": "teststep", + "name": "ts2", + "description": "teststep2", + "verdict": "ERROR", + "expected_result": "err", + "testStepArtifacts": [] + } + ], + "attributes": [ + { + "key": "an", + "value": "attribute" + } + ], + "constants": [ + { + "key": "const", + "value": "one" + }, + { + "key": "another", + "value": "const" + }, + { + "key": "", + "value": "" + }, + { + "key": "const_key", + "value": "const_val" + } + ], + "environments": [], + "artifacts": [ + "d41d8cd98f00b204e9800998ecf8427e/artifact.txt" + ], + "review": { + "comment": "comment", + "timestamp": 1670254005, + "author": "chucknorris" + } +} diff --git a/tests/resources/testsuite.json b/tests/resources/testsuite.json new file mode 100644 index 0000000..0e25b48 --- /dev/null +++ b/tests/resources/testsuite.json @@ -0,0 +1,205 @@ +{ + "name": "MyTestSuite", + "timestamp": 1666698047000, + "testcases": [ + { + "@type": "testcase", + "name": "TestCase_1", + "verdict": "PASSED", + "description": null, + "timestamp": 1666698047001, + "executionTime": 0, + "parameters": [], + "paramSet": null, + "setupTestSteps": [], + "executionTestSteps": [], + "teardownTestSteps": [], + "attributes": [], + "constants": [], + "environments": [], + "artifacts": [] + }, + { + "@type": "testcasefolder", + "name": "mytcf", + "testcases": [ + { + "@type": "testcase", + "name": "name", + "verdict": "PASSED", + "description": null, + "timestamp": 0, + "executionTime": 0, + "parameters": [], + "paramSet": null, + "setupTestSteps": [], + "executionTestSteps": [], + "teardownTestSteps": [], + "attributes": [], + "constants": [], + "environments": [], + "artifacts": [] + } + ] + }, + { + "@type": "testcasefolder", + "name": "mytcf2", + "testcases": [ + { + "@type": "testcase", + "name": "testcase_one", + "verdict": "PASSED", + "description": "First testcase.", + "timestamp": 1670248341000, + "executionTime": 5, + "parameters": [ + { + "name": "param", + "value": 10, + "direction": "OUT" + }, + { + "name": "param2", + "value": 15, + "direction": "INOUT" + } + ], + "paramSet": "myset", + "setupTestSteps": [ + { + "@type": "teststep", + "name": "ts", + "description": null, + "verdict": "NONE", + "expected_result": "undefined", + "testStepArtifacts": [] + }, + { + "@type": "teststepfolder", + "name": "tsf", + "description": null, + "teststeps": [ + { + "@type": "teststep", + "name": "ts", + "description": null, + "verdict": "NONE", + "expected_result": "undefined", + "testStepArtifacts": [] + }, + { + "@type": "teststep", + "name": "ts2", + "description": "teststep2", + "verdict": "ERROR", + "expected_result": "err", + "testStepArtifacts": [] + } + ] + } + ], + "executionTestSteps": [ + { + "@type": "teststep", + "name": "ts2", + "description": "teststep2", + "verdict": "ERROR", + "expected_result": "err", + "testStepArtifacts": [] + }, + { + "@type": "teststepfolder", + "name": "tsf", + "description": null, + "teststeps": [ + { + "@type": "teststep", + "name": "ts", + "description": null, + "verdict": "NONE", + "expected_result": "undefined", + "testStepArtifacts": [] + }, + { + "@type": "teststep", + "name": "ts2", + "description": "teststep2", + "verdict": "ERROR", + "expected_result": "err", + "testStepArtifacts": [] + } + ] + } + ], + "teardownTestSteps": [ + { + "@type": "teststepfolder", + "name": "tsf", + "description": null, + "teststeps": [ + { + "@type": "teststep", + "name": "ts", + "description": null, + "verdict": "NONE", + "expected_result": "undefined", + "testStepArtifacts": [] + }, + { + "@type": "teststep", + "name": "ts2", + "description": "teststep2", + "verdict": "ERROR", + "expected_result": "err", + "testStepArtifacts": [] + } + ] + }, + { + "@type": "teststep", + "name": "ts2", + "description": "teststep2", + "verdict": "ERROR", + "expected_result": "err", + "testStepArtifacts": [] + } + ], + "attributes": [ + { + "key": "an", + "value": "attribute" + } + ], + "constants": [ + { + "key": "const", + "value": "one" + }, + { + "key": "another", + "value": "const" + }, + { + "key": "", + "value": "" + }, + { + "key": "const_key", + "value": "const_val" + } + ], + "environments": [], + "artifacts": [ + "d41d8cd98f00b204e9800998ecf8427e/artifact.txt" + ], + "review": { + "comment": "comment", + "timestamp": 1670254005, + "author": "chucknorris" + } + } + ] + } + ] +} diff --git a/tests/resources/valid.json b/tests/resources/valid.json new file mode 100644 index 0000000..a120120 --- /dev/null +++ b/tests/resources/valid.json @@ -0,0 +1,156 @@ +{ + "name": "MyTestSuite", + "timestamp": 1666698047000, + "testcases": [ + { + "@type": "testcase", + "name": "TestCase_1", + "verdict": "PASSED", + "description": null, + "timestamp": 1666698047001, + "executionTime": 0, + "parameters": [], + "paramSet": null, + "setupTestSteps": [], + "executionTestSteps": [], + "teardownTestSteps": [], + "attributes": [], + "constants": [], + "environments": [], + "artifacts": [] + }, + { + "@type": "testcasefolder", + "name": "mytcf2", + "testcases": [ + { + "@type": "testcase", + "name": "testcase_one", + "verdict": "PASSED", + "description": "First testcase.", + "timestamp": 1670248341000, + "executionTime": 5, + "parameters": [ + { + "name": "param2", + "value": 15, + "direction": "INOUT" + } + ], + "paramSet": "myset", + "setupTestSteps": [ + { + "@type": "teststep", + "name": "ts", + "verdict": "NONE", + "expected_result": "undefined" + }, + { + "@type": "teststepfolder", + "name": "tsf", + "teststeps": [ + { + "@type": "teststep", + "name": "ts", + "verdict": "NONE", + "expected_result": "undefined" + }, + { + "@type": "teststep", + "name": "ts2", + "verdict": "ERROR", + "expected_result": "err" + } + ] + } + ], + "executionTestSteps": [ + { + "@type": "teststep", + "name": "ts2", + "verdict": "ERROR", + "expected_result": "err" + }, + { + "@type": "teststepfolder", + "name": "tsf", + "teststeps": [ + { + "@type": "teststep", + "name": "ts", + "verdict": "NONE", + "expected_result": "undefined" + }, + { + "@type": "teststep", + "name": "ts2", + "verdict": "ERROR", + "expected_result": "err" + } + ] + } + ], + "teardownTestSteps": [ + { + "@type": "teststepfolder", + "name": "tsf", + "teststeps": [ + { + "@type": "teststep", + "name": "ts", + "verdict": "NONE", + "expected_result": "undefined" + }, + { + "@type": "teststep", + "name": "ts2", + "verdict": "ERROR", + "expected_result": "err" + } + ] + }, + { + "@type": "teststep", + "name": "ts2", + "verdict": "ERROR", + "expected_result": "err" + } + ], + "attributes": [ + { + "key": "an", + "value": "attribute" + } + ], + "constants": [ + { + "key": "const", + "value": "one" + }, + { + "key": "another", + "value": "const" + }, + { + "key": "a", + "value": "a" + }, + { + "key": "const_key", + "value": "const_val" + } + ], + "environments": [], + "artifacts": [ + "d41d8cd98f00b204e9800998ecf8427e/artifact.txt" + ], + "review": { + "comment": "commentAtLeast10Characters", + "timestamp": 1670254005, + "author": "chucknorris" + } + } + ] + } + ] +} diff --git a/tests/test_ReportGenerator.py b/tests/test_ReportGenerator.py new file mode 100644 index 0000000..bda98da --- /dev/null +++ b/tests/test_ReportGenerator.py @@ -0,0 +1,73 @@ +# Copyright (c) 2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT + +from zipfile import ZipFile +from unittest.mock import patch + +import pytest + +from testguide_report_generator.util.File import get_md5_hash_from_file +from testguide_report_generator.util.JsonValidator import JsonValidator +from testguide_report_generator.ReportGenerator import Generator +import os + +from testguide_report_generator.model.TestCase import TestCase, Verdict +from testguide_report_generator.model.TestCaseFolder import TestCaseFolder +from testguide_report_generator.model.TestSuite import TestSuite + + +def test_ReportGenerator_export(testsuite, json_schema_path, artifact_path, artifact_path2): + pwd = os.getcwd() + + outfile_json_path = pwd + "/export.json" + + testcase = TestCase("name", 123, Verdict.PASSED) + testcase.add_artifact(artifact_path, ignore_on_error=False) + + testcase_2 = TestCase("name", 123, Verdict.PASSED) + testcase_2.add_artifact(artifact_path2, ignore_on_error=False) + testcase_folder = TestCaseFolder("folder") + testcase_folder.add_testcase(testcase_2) + + testsuite.add_testcase(testcase) + testsuite.add_testcase(testcase_folder) + + generator = Generator(testsuite, json_schema_path) + outfile_path = generator.export(outfile_json_path) + + # assert existence of files + assert pwd + "/export.zip" == outfile_path + assert os.path.exists(pwd + "/export.json") + assert os.path.exists(pwd + "/export.zip") + + zip = ZipFile(pwd + "/export.zip") + zip_contents = zip.namelist() + artifact_hash = get_md5_hash_from_file(artifact_path) + artifact_2_hash = get_md5_hash_from_file(artifact_path2) + + # assert content of .zip + expected_zip_contents = ["export.json", f"{artifact_hash}/artifact.txt", f"{artifact_2_hash}/artifact2.txt"] + assert all([file in zip_contents for file in expected_zip_contents]) + + # assert validity of generated json + validator = JsonValidator() + assert validator.validate_file(expected_zip_contents[0]) + + +def test_ReportGenerator_export_invalid_json(testsuite, json_schema_path): + generator = Generator(testsuite, json_schema_path) + assert None is generator.export("out.json") + + +def test_ReportGenerator_export_wrong_testcase_format(json_schema_path): + with patch.object(TestSuite, "get_testcases") as mock_get_testcases: + mock_get_testcases.return_value = [""] + + testsuite = TestSuite("test", 1666698047000) + testcase = TestCase("name", 123, Verdict.PASSED) + testsuite.add_testcase(testcase) + + generator = Generator(testsuite, json_schema_path) + with pytest.raises(TypeError): + generator.export("out.json") diff --git a/tests/util/test_File.py b/tests/util/test_File.py new file mode 100644 index 0000000..9a0c57b --- /dev/null +++ b/tests/util/test_File.py @@ -0,0 +1,37 @@ +# Copyright (c) 2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT + +from unittest.mock import patch + +from testguide_report_generator.util.File import get_extended_windows_path +from testguide_report_generator.util.File import get_md5_hash_from_file + + +def test_get_extended_windows_path_no_windows_path(): + path = "/non/windows/path" + assert path == get_extended_windows_path(path) + + +def test_get_extended_windows_path_local(): + path = "C:\\this\\is\\my\\windows\\path" + assert "\\\\?\\C:\\this\\is\\my\\windows\\path" == get_extended_windows_path(path) + + +@patch("os.path.realpath") +def test_get_extended_windows_path_network(os_mock): + os_mock.side_effect = __os_mock_side_effect + path = "\\\\my\\network\\path" + + result = get_extended_windows_path(path) + + os_mock.assert_called_once() + assert result == "\\\\?\\UNC\\my\\network\\path" + + +def __os_mock_side_effect(mock_input): + return mock_input + + +def test_get_md5_hash_from_file(artifact_path): + assert "d41d8cd98f00b204e9800998ecf8427e" == get_md5_hash_from_file(artifact_path) diff --git a/tests/util/test_JsonValidator.py b/tests/util/test_JsonValidator.py new file mode 100644 index 0000000..9f091e0 --- /dev/null +++ b/tests/util/test_JsonValidator.py @@ -0,0 +1,37 @@ +# Copyright (c) 2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT + +from testguide_report_generator.util.JsonValidator import JsonValidator + + +def test_json_file_valid(json_schema_path, path_to_valid_json): + validator = JsonValidator(json_schema_path) + assert validator.validate_file(path_to_valid_json) + + +def test_json_file_invalid(json_schema_path, path_to_invalid_json): + validator = JsonValidator(json_schema_path) + assert not validator.validate_file(path_to_invalid_json) + + +def test_default_json_file_valid(path_to_valid_json): + validator = JsonValidator() + assert validator.validate_file(path_to_valid_json) + + +def test_testsuite_valid(json_schema_path, testsuite_json_obj): + validator = JsonValidator(json_schema_path) + assert validator.validate_json(testsuite_json_obj) + + +def test_testsuite_invalid(json_schema_path, testsuite_json_obj): + testsuite_json_obj["testcases"][1]["testcases"] = [] # empty TestCaseFolder + + validator = JsonValidator(json_schema_path) + assert not validator.validate_json(testsuite_json_obj) + + +def test_default_valid(testsuite_json_obj): + validator = JsonValidator() + assert validator.validate_json(testsuite_json_obj) diff --git a/tests/util/test_ValidityChecks.py b/tests/util/test_ValidityChecks.py new file mode 100644 index 0000000..e92924d --- /dev/null +++ b/tests/util/test_ValidityChecks.py @@ -0,0 +1,68 @@ +# Copyright (c) 2023 TraceTronic GmbH +# +# SPDX-License-Identifier: MIT + +import pytest + +from testguide_report_generator.util.ValidityChecks import gen_error_msg, check_name_length, validate_new_teststep, \ + validate_testcase +from testguide_report_generator.model.TestCase import TestCase, TestStep, TestStepFolder +from testguide_report_generator.model.TestCaseFolder import TestCaseFolder + + +@pytest.mark.parametrize("name", ["a", "x" * 120]) +def test_check_name_length(name): + assert name == check_name_length(name, "") + + +@pytest.mark.parametrize("name", ["", "x" * 121]) +def test_check_name_length_error(name): + error_msg = "bad name" + with pytest.raises(ValueError) as e: + check_name_length(name, error_msg) + + assert str(e.value) == error_msg + + +def test_error_msg(): + obj_type = "Type" + name = "Fred" + + assert "The name of the Type must have a length between 1-120 characters. Name was: Fred" == \ + gen_error_msg(obj_type, name) + + +def test_teststep_checks(teststep_folder): + assert validate_new_teststep(teststep_folder, TestStep, TestStepFolder) + + +def test_teststep_checks_wrong_type(): + with pytest.raises(ValueError) as e: + validate_new_teststep("", TestStep, TestStepFolder) + + assert str(e.value) == "Argument teststep must be of type TestStep or TestStepFolder." + + +def test_teststep_checks_empty_error(teststep_folder_empty): + with pytest.raises(ValueError) as e: + validate_new_teststep(teststep_folder_empty, TestStep, TestStepFolder) + + assert str(e.value) == "TestStepFolder may not be empty." + + +def test_testcase_checks(testcase_folder): + assert validate_testcase(testcase_folder, TestCase, TestCaseFolder) + + +def test_testcase_checks_wrong_type(): + with pytest.raises(ValueError) as e: + validate_testcase("", TestCase, TestCaseFolder) + + assert str(e.value) == "Argument testcase must be of type TestCase or TestCaseFolder." + + +def test_testcase_checks_empty_error(testcase_folder_empty): + with pytest.raises(ValueError) as e: + validate_testcase(testcase_folder_empty, TestCase, TestCaseFolder) + + assert str(e.value) == "TestCaseFolder may not be empty."