diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..1c79717 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,75 @@ +name: Tests +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] +jobs: + tests: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ ubuntu-latest, macos-latest, windows-latest ] + python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"] + include: + - os: ubuntu-latest + path: ~/.cache/pip + - os: macos-latest + path: ~/Library/Caches/pip + - os: windows-latest + path: ~\AppData\Local\pip\Cache + env: + OS: ${{ matrix.os }} + PYTHON: ${{ matrix.python-version }} + + defaults: + run: + shell: bash + + name: Python ${{ matrix.python-version }} on OS ${{ matrix.os }} + steps: + + - name: Acquire sources + uses: actions/checkout@v2 + + - name: Setup Python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + architecture: x64 + + - uses: actions/cache@v2 + with: + path: ${{ matrix.path }} + key: ${{ runner.os }}-py${{ matrix.python-version }}-${{ hashFiles('requirements-test.txt') }} + + - name: Run tests + run: | + pip install --requirement=requirements-test.txt + pytest + + - name: Upload test results to wacklig + if: always() + continue-on-error: true + env: + WACKLIG_TOKEN: ${{ secrets.WACKLIG_TOKEN }} + shell: bash + run: | + curl -s https://raw.githubusercontent.com/pipifein/wacklig-uploader/master/wacklig.py | python - --token $WACKLIG_TOKEN \ + && echo "Upload to wacklig succeeded" \ + || errcode=$?; echo "Upload to wacklig failed"; exit $errcode + +# - name: Generate coverage report +# if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.9' +# run: | +# coverage xml +# +# - name: Upload coverage to Codecov +# if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.9' +# uses: codecov/codecov-action@v2 +# with: +# files: ./coverage.xml +# flags: unittests +# env_vars: OS,PYTHON +# name: codecov-umbrella +# fail_ci_if_error: false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..913e981 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/.idea +/.venv* +/test-results +/.coverage +__pycache__ diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000..48b62f6 --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,36 @@ +# wacklig-uploader changelog + + +## in progress + +- Tests: Add test harness +- Fix finding test report files recursively +- Tests: Add test for `main` function +- Tests: Run test harness on GHA +- Tests: Add Windows to test matrix on CI/GHA +- Tests: Fix tests on Windows +- Add compatibility with Windows regarding exclusive write-lock + at `NamedTemporaryFile` vs. `tarfile.open` + + +## 0.3.0 (2021-07-01) + +- Make glob path to pick up test results less restrictive +- Detect GitHub Action environment + + +## 0.2.0 (2021-02-19) + +- Don't print args/ci_info but server result instead + + +## 0.1.0 (2021-01-28) + +- Adapt upload URI +- Add token and local/git ci info +- Add license and minimal readme + + +## 0.0.0 (2021-01-07) + +- Initial commit diff --git a/README.md b/README.md index 61f07ea..cf6284b 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,22 @@ # wacklig-uploader -A python script to upload test results to [wacklig][1]. +## About +A Python script to upload test results to [wacklig]. -[1]: https://wacklig.pipifein.dev/ +## Tests + +```shell +python3 -m venv .venv +source .venv/bin/activate +pip install --requirement=requirements-test.txt + +# Run all tests. +pytest + +# Run specific tests. +pytest -k test_upload_files +``` + +[wacklig]: https://wacklig.pipifein.dev/ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..40917b5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,24 @@ +[tool.isort] +profile = "black" +extend_skip = "wacklig.py" + + +[tool.black] +line_length = 120 +extend_exclude = "wacklig.py" + + +[tool.pytest.ini_options] +addopts = """ + -vvv + --cov --cov-report=term-missing + --junit-xml=test-results/test/junit.xml +""" + +junit_suite_name = "wacklig_uploader" + +# TODO: Write captured log messages to JUnit report: one of no|log|system-out|system-err|out-err|all +# junit_logging = + +# TODO: Emit XML for schema: one of legacy|xunit1|xunit2 +# junit_family = diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 0000000..53f140b --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1,5 @@ +pytest>=6,<7 +pytest-mock>=3,<4 +pytest-cov>=3,<4 +pyfakefs>=4,<5 +mocket>=3,<4 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5e59792 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,7 @@ +import os +import sys + +# Make sure that the application source directory (this directory's parent) is +# on sys.path. +here = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, here) diff --git a/tests/junit-example.xml b/tests/junit-example.xml new file mode 100644 index 0000000..11b3150 --- /dev/null +++ b/tests/junit-example.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + tmp_path = PosixPath('/private/var/folders/06/w9pzygdj7vx53n_0l9q_lhph0000gn/T/pytest-of-amo/pytest-5/test_upload_files_foo0') + + def test_upload_files_foo(tmp_path): +> upload_files(token=None, server=None, ci_info=None, files=None) + +tests/test_uploader.py:95: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +token = None, server = None, ci_info = None, files = None + + def upload_files(token, server, ci_info, files): + if not files: +> raise SystemExit('No test files found') +E SystemExit: No test files found + +wacklig.py:76: SystemExit + + + diff --git a/tests/test_environment.py b/tests/test_environment.py new file mode 100644 index 0000000..60804b4 --- /dev/null +++ b/tests/test_environment.py @@ -0,0 +1,78 @@ +import os +from unittest import mock +from unittest.mock import Mock + +from wacklig import get_ci_info, github_action_env, jenkins_env, search_env + + +@mock.patch.dict(os.environ, {"CI_FOOBAR": "bazqux"}) +def test_search_env_found(): + value = search_env("CI_FOOBAR") + assert value == "bazqux" + + +def test_search_env_notfound(): + value = search_env("CI_FOOBAR") + assert value is None + + +@mock.patch.dict(os.environ, {"JENKINS_URL": "https://jenkins.example.org/job/3f786850e3"}) +@mock.patch.dict(os.environ, {"ghprbSourceBranch": "testdrive"}) +@mock.patch.dict(os.environ, {"GIT_COMMIT": "3f786850e387550fdab836ed7e6dc881de23001b"}) +@mock.patch.dict(os.environ, {"ghprbPullId": "111"}) +@mock.patch.dict(os.environ, {"BUILD_NUMBER": "42"}) +def test_get_ci_info_jenkins(): + data = get_ci_info() + assert data == { + "service": "jenkins", + "branch": "testdrive", + "commit": "3f786850e387550fdab836ed7e6dc881de23001b", + "pr": "111", + "build": "42", + } + + +@mock.patch.dict(os.environ, {"GITHUB_ACTION": "true"}) +@mock.patch.dict(os.environ, {"GITHUB_SHA": "3f786850e387550fdab836ed7e6dc881de23001b"}) +@mock.patch.dict(os.environ, {"GITHUB_REF": "refs/pull/111/merge"}) +@mock.patch.dict(os.environ, {"GITHUB_HEAD_REF": "feature-branch-1"}) +@mock.patch.dict(os.environ, {"GITHUB_RUN_ID": "42"}) +def test_get_ci_info_github_with_pr(): + data = get_ci_info() + assert data == { + "service": "github-actions", + "commit": "3f786850e387550fdab836ed7e6dc881de23001b", + "build": "42", + "branch": "feature-branch-1", + "pr": "111", + } + + +@mock.patch.dict(os.environ, {"GITHUB_ACTION": "true"}) +@mock.patch.dict(os.environ, {"GITHUB_SHA": "3f786850e387550fdab836ed7e6dc881de23001b"}) +@mock.patch.dict(os.environ, {"GITHUB_REF": "refs/heads/feature-branch-1"}) +@mock.patch.dict(os.environ, {"GITHUB_HEAD_REF": ""}) +@mock.patch.dict(os.environ, {"GITHUB_RUN_ID": "42"}) +def test_get_ci_info_github_with_branch(): + data = get_ci_info() + assert data == { + "service": "github-actions", + "commit": "3f786850e387550fdab836ed7e6dc881de23001b", + "build": "42", + "branch": "feature-branch-1", + } + + +@mock.patch.dict(os.environ, {"JENKINS_URL": ""}) +@mock.patch.dict(os.environ, {"GITHUB_ACTION": ""}) +@mock.patch( + "wacklig.check_output", + Mock(side_effect=["testdrive", "3f786850e387550fdab836ed7e6dc881de23001b"]), +) +def test_get_ci_info_local(): + data = get_ci_info() + assert data == { + "service": "local", + "branch": "testdrive", + "commit": "3f786850e387550fdab836ed7e6dc881de23001b", + } diff --git a/tests/test_uploader.py b/tests/test_uploader.py new file mode 100644 index 0000000..e8a5aa3 --- /dev/null +++ b/tests/test_uploader.py @@ -0,0 +1,141 @@ +import io +import os +import re +import tempfile +from pathlib import Path +from unittest import mock +from unittest.mock import Mock + +import pytest +from mocket import Mocket, MocketEntry, Mocketizer + +import wacklig +from tests.conftest import here +from wacklig import find_test_files, upload_files + + +def test_find_test_files(fs): + fs.create_file(file_path=Path("test-results/test/report1.xml")) + fs.create_file(file_path=Path("test-results/test/nested/report2.xml")) + fs.create_file(file_path=Path("test-results/test/nested/more/report3.xml")) + assert find_test_files() == [ + str(Path("test-results/test/report1.xml")), + str(Path("test-results/test/nested/report2.xml")), + str(Path("test-results/test/nested/more/report3.xml")), + ] + + +def test_upload_files_empty(): + with pytest.raises(SystemExit) as ex: + upload_files(token=None, server=None, ci_info=None, files=None) + assert ex.match(re.escape("No test files found")) + + +NamedTemporaryFile = tempfile.NamedTemporaryFile + + +def tempfile_factory(): + return NamedTemporaryFile(delete=False) + + +# Augment `NamedTemporaryFile` to not being deleted. We need it for verifying. +@mock.patch.object(wacklig.tempfile, "NamedTemporaryFile", tempfile_factory) +def test_upload_files_success_synthetic(capsys): + + # Prepare fixture data. + wacklig_server = "https://wacklig.example.org" + wacklig_token = "WACKLIG_TOKEN" + ci_info = { + "service": "local", + "branch": "testdrive", + "commit": "3f786850e387550fdab836ed7e6dc881de23001b", + } + test_report_files = [os.path.join(here, "tests/junit-example.xml")] + + # Invoke `upload_files` with mocked `urllib.urlopen()`. + # TODO: Use a more realistic response payload here. Probably JSON? + urlopen_mock = Mock(return_value=io.BytesIO(b"HTTP response body from wacklig service")) + with mock.patch.object(wacklig, "urlopen", urlopen_mock): + upload_files( + token=wacklig_token, + server=wacklig_server, + ci_info=ci_info, + files=test_report_files, + ) + + # Proof that the request URL has correct information. + urlopen_mock.assert_called_once_with( + "https://wacklig.example.org/api/v1/upload?service=local&branch=testdrive&commit=3f786850e387550fdab836ed7e6dc881de23001b&token=WACKLIG_TOKEN", + data=mock.ANY, + ) + + # Proof that the HTTP request body content is a gzip payload. + filepath = urlopen_mock.call_args[1]["data"].name + assert open(filepath, "rb").read().startswith(b"\x1f\x8b\x08") + + # TODO: Proof that it is actually a .tar.gz payload, having the correct content. + + # Proof that the HTTP response body has been printed to stdout. + stdout = capsys.readouterr().out.strip() + assert stdout == "HTTP response body from wacklig service" + + # Gracefully clean up temporary files. + try: + os.unlink(filepath) + except: # pragma:nocover + pass + + +def test_upload_files_success_real(capsys): + + # Prepare fixture data. + wacklig_server = "https://wacklig.example.org" + wacklig_token = "WACKLIG_TOKEN" + ci_info = { + "service": "local", + "branch": "testdrive", + "commit": "3f786850e387550fdab836ed7e6dc881de23001b", + } + test_report_files = [os.path.join(here, "tests/junit-example.xml")] + + # Invoke `upload_files` with mocked `socket` library. + with Mocketizer(): + addr = ("wacklig.example.org", 443) + # TODO: Use a more realistic response payload here. Probably JSON? + Mocket.register( + MocketEntry(location=addr, responses=["HTTP/1.1 200 OK\r\n\r\nHTTP response body from wacklig service"]) + ) + upload_files( + token=wacklig_token, + server=wacklig_server, + ci_info=ci_info, + files=test_report_files, + ) + + # Proof that the HTTP response body has been printed to stdout. + stdout = capsys.readouterr().out.strip() + assert stdout == "HTTP response body from wacklig service" + + +@mock.patch.dict(os.environ, {"GITHUB_ACTION": "true"}) +@mock.patch.dict(os.environ, {"GITHUB_SHA": "3f786850e387550fdab836ed7e6dc881de23001b"}) +@mock.patch.dict(os.environ, {"GITHUB_RUN_ID": "42"}) +def test_main(mocker, fs, capsys): + + # Prepare fixture data. + wacklig_server = "https://wacklig.example.org" + wacklig_token = "WACKLIG_TOKEN" + report_files = ["test-results/test/report1.xml", "test-results/test/nested/report2.xml"] + + # Create files in fake filesystem. + list(map(fs.create_file, report_files)) + + # Pretend to invoke main program. + mocker.patch("sys.argv", ["wacklig-upload", "--server", wacklig_server, "--token", wacklig_token]) + mocker.patch.object(wacklig, "find_test_files", Mock(return_value=report_files)) + mocker.patch.object(wacklig, "urlopen", Mock(return_value=io.BytesIO(b"HTTP response body from wacklig service"))) + wacklig.main() + + # Proof that the expected message has been written to stdout. + stdout = capsys.readouterr().out.strip() + assert "Uploaded 2 files" in stdout diff --git a/wacklig.py b/wacklig.py index d697259..27daa78 100755 --- a/wacklig.py +++ b/wacklig.py @@ -37,6 +37,7 @@ def github_action_env(): 'commit': os.environ.get('GITHUB_SHA'), 'build': os.environ.get('GITHUB_RUN_ID'), } + # Examples: refs/heads/feature-branch-1, refs/pull/42/merge gh_ref = os.getenv("GITHUB_REF") gh_head_ref = os.getenv('GITHUB_HEAD_REF') if gh_head_ref: @@ -68,7 +69,7 @@ def get_ci_info(): def find_test_files(): - return glob('**/test-results/test/*.xml', recursive=True) + return glob('**/test-results/test/**/*.xml', recursive=True) def upload_files(token, server, ci_info, files): @@ -76,9 +77,10 @@ def upload_files(token, server, ci_info, files): raise SystemExit('No test files found') ci_info['token'] = token with tempfile.NamedTemporaryFile() as fd: - with tarfile.open(fd.name, 'w:gz') as tar: + with tarfile.open(fileobj=fd, mode='w:gz') as tar: for filename in files: tar.add(filename) + fd.seek(0) ci_info = {k: v for (k, v) in ci_info.items() if v} params = ci_info and '?' + urlencode(ci_info) or '' result = urlopen(server + '/api/v1/upload' + params, data=fd) @@ -96,5 +98,5 @@ def main(): print(f'Uploaded {len(files)} files') -if __name__ == "__main__": +if __name__ == "__main__": # pragma:nocover main()