diff --git a/.github/workflows/android-device.yml b/.github/workflows/android-device.yml index b52ea4f..6edadac 100644 --- a/.github/workflows/android-device.yml +++ b/.github/workflows/android-device.yml @@ -105,6 +105,13 @@ jobs: path: test-results/android-api${{ inputs.api }}/ if-no-files-found: warn retention-days: 7 + - name: Upload emulator JUnit report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: junit-Android API ${{ inputs.api }} + path: test-results/android-api${{ inputs.api }}/junit.xml + retention-days: 7 - name: Link evidence in job summary if: always() && steps.evidence.outputs.artifact-url != '' env: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8a51be..b732df2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,6 +80,14 @@ jobs: - name: Test real GOST and OpenSSH servers run: bundle exec fastlane android native_integration + - name: Upload native JUnit reports + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: junit-Native Go + path: test-results/native/*.xml + retention-days: 7 + python-tests: needs: changes if: needs.changes.outputs.python == 'true' @@ -108,6 +116,14 @@ jobs: - name: Check Python formatting and Python tests run: bundle exec fastlane android python_checks + - name: Upload Python JUnit reports + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: junit-Python + path: test-results/python/*.xml + retention-days: 7 + android-tests: needs: changes if: needs.changes.outputs.android == 'true' @@ -154,6 +170,14 @@ jobs: - name: Run Android checks and verify the unsigned APK run: bundle exec fastlane android android_checks + - name: Upload Android JVM JUnit reports + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: junit-Android JVM + path: app/build/test-results/testDebugUnitTest/*.xml + retention-days: 7 + - name: Publish PR debug APK if: github.event_name == 'pull_request' id: debug_apk_upload @@ -206,3 +230,78 @@ jobs: uses: ./.github/workflows/android-device.yml with: api: 35 + + report-native-tests: + name: Publish Native Go results + needs: native-tests + if: >- + !cancelled() && needs.native-tests.result != 'skipped' && + (github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository) + permissions: + contents: read + actions: read + checks: write + uses: ./.github/workflows/test-reports.yml + with: + artifact: '/^junit-(Native Go)$/' + + report-python-tests: + name: Publish Python results + needs: python-tests + if: >- + !cancelled() && needs.python-tests.result != 'skipped' && + (github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository) + permissions: + contents: read + actions: read + checks: write + uses: ./.github/workflows/test-reports.yml + with: + artifact: '/^junit-(Python)$/' + + report-android-tests: + name: Publish Android JVM results + needs: android-tests + if: >- + !cancelled() && needs.android-tests.result != 'skipped' && + (github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository) + permissions: + contents: read + actions: read + checks: write + uses: ./.github/workflows/test-reports.yml + with: + artifact: '/^junit-(Android JVM)$/' + + report-emulator26: + name: Publish Android API 26 results + needs: emulator26 + if: >- + !cancelled() && needs.emulator26.result != 'skipped' && + (github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository) + permissions: + contents: read + actions: read + checks: write + uses: ./.github/workflows/test-reports.yml + with: + artifact: '/^junit-(Android API 26)$/' + + report-emulator35: + name: Publish Android API 35 results + needs: emulator35 + if: >- + !cancelled() && needs.emulator35.result != 'skipped' && + (github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository) + permissions: + contents: read + actions: read + checks: write + uses: ./.github/workflows/test-reports.yml + with: + artifact: '/^junit-(Android API 35)$/' diff --git a/.github/workflows/test-reports.yml b/.github/workflows/test-reports.yml new file mode 100644 index 0000000..d931f96 --- /dev/null +++ b/.github/workflows/test-reports.yml @@ -0,0 +1,55 @@ +name: JUnit reports + +on: + workflow_call: + inputs: + artifact: + type: string + required: false + default: "/^junit-(.+)$/" + workflow_run: + workflows: [CI] + types: [completed] + +permissions: + contents: read + actions: read + checks: write + +jobs: + reports: + name: Test results + # Internal PRs and pushes call this workflow from CI. Fork PRs need a trusted + # workflow_run context because their CI token cannot write GitHub Checks. + if: >- + github.event_name != 'workflow_run' || + (github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.head_repository.full_name != github.repository) + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + # Parse uploaded data only; never check out or run code from the tested PR. + - name: Publish JUnit results in GitHub Checks + uses: dorny/test-reporter@d1b5dff021aa968b288b02285a8bf47471c5928a # v3 + with: + artifact: ${{ inputs.artifact || '/^junit-(.+)$/' }} + name: 'Test results · $1' + path: '**/*.xml' + reporter: java-junit + list-tests: all + max-annotations: 10 + fail-on-empty: false + use-actions-summary: false + - name: Add collapsible test groups to Actions Summary + if: ${{ !cancelled() }} + uses: dorny/test-reporter@d1b5dff021aa968b288b02285a8bf47471c5928a # v3 + with: + artifact: ${{ inputs.artifact || '/^junit-(.+)$/' }} + name: 'Test results · $1' + path: '**/*.xml' + reporter: java-junit + list-tests: all + max-annotations: 10 + fail-on-empty: false + use-actions-summary: true + collapsed: always diff --git a/AGENTS.md b/AGENTS.md index 3d87fb7..688f491 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,9 @@ branch names, credentials, signing material, or other secrets. signing material and must never produce or publish a signed release APK. - Surface downloadable APK artifacts in the GitHub Actions job summary in addition to uploading them through `actions/upload-artifact`. +- Test lanes produce JUnit XML. CI uploads separate `junit-*` artifacts on success or failure and + publishes each suite's GitHub Check independently as soon as its job finishes; fork reports use trusted `workflow_run` + without executing PR code. Keep original suite checks as merge requirements. - Prefer extracting UI-facing decisions into small production contracts and testing those with deterministic JVM unit tests. Resource parity, navigation destination wiring, preference serialization/defaults, formatting, and state transitions should not require a device. diff --git a/docs/en/fastlane.md b/docs/en/fastlane.md index 6eae15c..75dace2 100644 --- a/docs/en/fastlane.md +++ b/docs/en/fastlane.md @@ -158,6 +158,27 @@ bundle exec fastlane android python_checks (default: `python3`). Runtime scripts use only Python's standard library. The CI job `Python tests and style` runs independently of Android builds. +## JUnit reports in GitHub Checks + +`native_tests` and `native_integration` use pinned gotestsum v1.13.0 and write JUnit XML to +`test-results/native/unit.xml` and `test-results/native/integration.xml`. The first run downloads +the runner through Go without changing the app module. `python_tests` uses pinned xmlrunner from +`requirements-dev.txt` and writes XML to `test-results/python/`. Android JVM tests already write +JUnit XML under `app/build/test-results/testDebugUnitTest/`; device tests keep separate API 26/35 +reports. Test failures still return a nonzero exit status. + +CI uploads each suite's XML as a separate `junit-*` artifact even when tests fail, with seven-day +retention. Each suite has a separate publisher that starts as soon as that suite finishes, without waiting +for the rest of CI. It creates `Test results · …` GitHub Checks with test counts and failure +details. Actions Summary also provides collapsible groups with all test names and statuses. Skipped suites have no report and are not presented as +newly passed tests. Existing required checks continue to gate merges. + +The publisher has `checks: write`; test jobs retain read-only repository access. Internal PRs and +pushes publish independently after each corresponding test job finishes. Fork PRs publish from the trusted `workflow_run` workflow, +which becomes available after this workflow is merged into `main`. It reads artifacts without +checking out or executing PR code. Only JUnit artifacts are parsed; device UI hierarchy XML stays +in the separate evidence artifact. + ## Interactive GitHub Actions launcher ```sh diff --git a/docs/ru/fastlane.md b/docs/ru/fastlane.md index b172459..c415a9f 100644 --- a/docs/ru/fastlane.md +++ b/docs/ru/fastlane.md @@ -161,6 +161,28 @@ bundle exec fastlane android python_checks (по умолчанию `python3`). Скрипты используют только стандартную библиотеку Python. Задание CI `Python tests and style` выполняется независимо от Android-сборки. +## JUnit-отчёты в GitHub Checks + +`native_tests` и `native_integration` используют закреплённый gotestsum v1.13.0 и сохраняют JUnit XML +в `test-results/native/unit.xml` и `test-results/native/integration.xml`. При первом запуске Go +скачивает runner без изменения модуля приложения. `python_tests` использует закреплённый xmlrunner +из `requirements-dev.txt` и сохраняет XML в `test-results/python/`. Android JVM-тесты уже создают +JUnit XML в `app/build/test-results/testDebugUnitTest/`; device-тесты сохраняют отдельные отчёты +API 26/35. Ошибка тестов по-прежнему возвращает ненулевой код завершения. + +CI загружает XML каждого набора отдельным артефактом `junit-*`, в том числе при падении тестов, +со сроком хранения семь дней. У каждого набора своё задание публикации: оно запускается сразу +после завершения этого набора, не дожидаясь остального CI. Оно создаёт GitHub Checks +`Test results · …` с числом тестов и подробностями ошибок. Summary Actions также содержит +сворачиваемые группы с именами и статусами всех тестов. Для пропущенных наборов отчёт не создаётся: +они не показываются как заново пройденные тесты. Существующие required checks продолжают блокировать merge. + +Право `checks: write` есть у publisher; тестовые задания сохраняют доступ к репозиторию только на +чтение. Внутренние PR и push публикуют отчёты независимо после завершения соответствующего тестового задания. PR из форков используют +доверенный workflow `workflow_run`, который становится доступен после merge этого workflow в +`main`. Он читает артефакты без checkout и исполнения кода PR. Разбираются только JUnit-артефакты; +XML иерархии интерфейса эмулятора остаётся в отдельном артефакте диагностических данных. + ## Интерактивный запуск GitHub Actions ```sh diff --git a/fastlane/Fastfile b/fastlane/Fastfile index ee1d436..eb7ad6a 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -9,15 +9,19 @@ project_root = File.expand_path("..", __dir__) platform :android do desc "Run the native Go tests with the race detector" lane :native_tests do + report = File.join(project_root, "test-results/native/unit.xml") + FileUtils.mkdir_p(File.dirname(report)) Dir.chdir(File.join(project_root, "native")) do - sh("go test -race ./...") + sh("go", "run", "gotest.tools/gotestsum@v1.13.0", "--junitfile", report, "--", "-race", "./...") end end desc "Test production dialers against disposable GOST and OpenSSH servers (requires Docker)" lane :native_integration do + report = File.join(project_root, "test-results/native/integration.xml") + FileUtils.mkdir_p(File.dirname(report)) Dir.chdir(File.join(project_root, "native")) do - sh("go", "test", "-race", "-tags=integration", "./mobile", "-run=^TestRealProxyServers$", "-count=1", "-timeout=15m", "-v") + sh("go", "run", "gotest.tools/gotestsum@v1.13.0", "--junitfile", report, "--", "-race", "-tags=integration", "./mobile", "-run=^TestRealProxyServers$", "-count=1", "-timeout=15m", "-v") end end @@ -100,7 +104,9 @@ platform :android do desc "Run Python unit tests" lane :python_tests do python = ENV.fetch("PYTHON", "python3") - sh(python, "-m", "unittest", "discover", "-s", File.join(project_root, "scripts", "tests")) + reports = File.join(project_root, "test-results/python") + FileUtils.rm_rf(reports) + sh(python, "-m", "xmlrunner", "discover", "-s", File.join(project_root, "scripts", "tests"), "-o", reports) end desc "Format Python scripts with isort and Black" diff --git a/requirements-dev.txt b/requirements-dev.txt index 80e7482..5147c6f 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,2 +1,3 @@ black==26.5.1 isort==9.0.1 +unittest-xml-reporting==4.0.0 diff --git a/scripts/android_device_tests.py b/scripts/android_device_tests.py index 98f0805..52fb394 100644 --- a/scripts/android_device_tests.py +++ b/scripts/android_device_tests.py @@ -251,7 +251,13 @@ def shell(*words): ] for test, phase in tests: label = test.replace("#", ".") + ("." + phase if phase else "") - case = ET.SubElement(suite, "testcase", name=label) + class_name, method = test.split("#", 1) + case = ET.SubElement( + suite, + "testcase", + classname=PACKAGE + "." + class_name, + name=method + ("." + phase if phase else ""), + ) started = time.monotonic() log_start = shell("date", "+%m-%d %H:%M:%S.000") try: @@ -324,6 +330,10 @@ def shell(*words): shell("am", "force-stop", PACKAGE) suite.set("tests", str(len(suite))) suite.set("failures", str(failures)) + suite.set( + "time", + f"{sum(float(item.get('time', '0')) for item in suite):.3f}", + ) ET.ElementTree(suite).write( results / "junit.xml", encoding="utf-8", xml_declaration=True )