From a8926e1febe422ab339f8656ddf15203d9266f28 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Tue, 6 Oct 2026 21:02:42 +0300 Subject: [PATCH 1/5] Publish JUnit test reports as GitHub Checks --- .github/workflows/android-device.yml | 7 ++++++ .github/workflows/ci.yml | 37 ++++++++++++++++++++++++++++ .github/workflows/test-reports.yml | 36 +++++++++++++++++++++++++++ AGENTS.md | 3 +++ docs/en/fastlane.md | 20 +++++++++++++++ docs/ru/fastlane.md | 20 +++++++++++++++ fastlane/Fastfile | 12 ++++++--- requirements-dev.txt | 1 + scripts/android_device_tests.py | 8 +++++- 9 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/test-reports.yml 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..dd7df09 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,16 @@ jobs: uses: ./.github/workflows/android-device.yml with: api: 35 + + test-reports: + name: Publish JUnit reports + needs: [native-tests, python-tests, android-tests, emulator26, emulator35] + if: >- + !cancelled() && + (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 diff --git a/.github/workflows/test-reports.yml b/.github/workflows/test-reports.yml new file mode 100644 index 0000000..c5c2eec --- /dev/null +++ b/.github/workflows/test-reports.yml @@ -0,0 +1,36 @@ +name: JUnit reports + +on: + workflow_call: + 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: '/^junit-(.+)$/' + name: 'Test results · $1' + path: '**/*.xml' + reporter: java-junit + list-tests: failed + max-annotations: 0 + fail-on-empty: false diff --git a/AGENTS.md b/AGENTS.md index 3d87fb7..ec37130 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 GitHub Checks from a separate reporting job; 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..2dcecfc 100644 --- a/docs/en/fastlane.md +++ b/docs/en/fastlane.md @@ -158,6 +158,26 @@ 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. A separate publisher creates `Test results · …` GitHub Checks with test counts and +failure details, plus an Actions summary. 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 after CI jobs finish. 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..f35adab 100644 --- a/docs/ru/fastlane.md +++ b/docs/ru/fastlane.md @@ -161,6 +161,26 @@ 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-*`, в том числе при падении тестов, +со сроком хранения семь дней. Отдельное задание создаёт GitHub Checks `Test results · …` с числом +тестов и подробностями ошибок, а также summary Actions. Для пропущенных наборов отчёт не создаётся: +они не показываются как заново пройденные тесты. Существующие required checks продолжают блокировать merge. + +Право `checks: write` есть у publisher; тестовые задания сохраняют доступ к репозиторию только на +чтение. Внутренние PR и push публикуют отчёты после завершения заданий CI. 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..375cfdd 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: From 760bb332f23f8cae69ea889898450612491ff52e Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Tue, 6 Oct 2026 21:06:15 +0300 Subject: [PATCH 2/5] Publish each suite report immediately after its test job finishes --- .github/workflows/ci.yml | 70 ++++++++++++++++++++++++++++-- .github/workflows/test-reports.yml | 7 ++- AGENTS.md | 2 +- docs/en/fastlane.md | 7 +-- docs/ru/fastlane.md | 7 +-- 5 files changed, 81 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd7df09..b732df2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -231,11 +231,11 @@ jobs: with: api: 35 - test-reports: - name: Publish JUnit reports - needs: [native-tests, python-tests, android-tests, emulator26, emulator35] + report-native-tests: + name: Publish Native Go results + needs: native-tests if: >- - !cancelled() && + !cancelled() && needs.native-tests.result != 'skipped' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) permissions: @@ -243,3 +243,65 @@ jobs: 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 index c5c2eec..a30c8af 100644 --- a/.github/workflows/test-reports.yml +++ b/.github/workflows/test-reports.yml @@ -2,6 +2,11 @@ name: JUnit reports on: workflow_call: + inputs: + artifact: + type: string + required: false + default: "/^junit-(.+)$/" workflow_run: workflows: [CI] types: [completed] @@ -27,7 +32,7 @@ jobs: - name: Publish JUnit results in GitHub Checks uses: dorny/test-reporter@d1b5dff021aa968b288b02285a8bf47471c5928a # v3 with: - artifact: '/^junit-(.+)$/' + artifact: ${{ inputs.artifact || '/^junit-(.+)$/' }} name: 'Test results · $1' path: '**/*.xml' reporter: java-junit diff --git a/AGENTS.md b/AGENTS.md index ec37130..688f491 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ branch names, credentials, signing material, or other secrets. - 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 GitHub Checks from a separate reporting job; fork reports use trusted `workflow_run` + 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 diff --git a/docs/en/fastlane.md b/docs/en/fastlane.md index 2dcecfc..f72f6e0 100644 --- a/docs/en/fastlane.md +++ b/docs/en/fastlane.md @@ -168,12 +168,13 @@ JUnit XML under `app/build/test-results/testDebugUnitTest/`; device tests keep s 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. A separate publisher creates `Test results · …` GitHub Checks with test counts and -failure details, plus an Actions summary. Skipped suites have no report and are not presented as +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, plus an Actions summary. 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 after CI jobs finish. Fork PRs publish from the trusted `workflow_run` workflow, +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. diff --git a/docs/ru/fastlane.md b/docs/ru/fastlane.md index f35adab..bfdc178 100644 --- a/docs/ru/fastlane.md +++ b/docs/ru/fastlane.md @@ -171,12 +171,13 @@ JUnit XML в `app/build/test-results/testDebugUnitTest/`; device-тесты со API 26/35. Ошибка тестов по-прежнему возвращает ненулевой код завершения. CI загружает XML каждого набора отдельным артефактом `junit-*`, в том числе при падении тестов, -со сроком хранения семь дней. Отдельное задание создаёт GitHub Checks `Test results · …` с числом -тестов и подробностями ошибок, а также summary Actions. Для пропущенных наборов отчёт не создаётся: +со сроком хранения семь дней. У каждого набора своё задание публикации: оно запускается сразу +после завершения этого набора, не дожидаясь остального CI. Оно создаёт GitHub Checks +`Test results · …` с числом тестов и подробностями ошибок, а также summary Actions. Для пропущенных наборов отчёт не создаётся: они не показываются как заново пройденные тесты. Существующие required checks продолжают блокировать merge. Право `checks: write` есть у publisher; тестовые задания сохраняют доступ к репозиторию только на -чтение. Внутренние PR и push публикуют отчёты после завершения заданий CI. PR из форков используют +чтение. Внутренние PR и push публикуют отчёты независимо после завершения соответствующего тестового задания. PR из форков используют доверенный workflow `workflow_run`, который становится доступен после merge этого workflow в `main`. Он читает артефакты без checkout и исполнения кода PR. Разбираются только JUnit-артефакты; XML иерархии интерфейса эмулятора остаётся в отдельном артефакте диагностических данных. From 456ecce35ee8a8daef05a8f38ca0da87fcd57cbc Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Tue, 6 Oct 2026 21:11:26 +0300 Subject: [PATCH 3/5] Create actual GitHub Checks and collapsible full test summaries --- .github/workflows/test-reports.yml | 16 +++++++++++++++- docs/en/fastlane.md | 2 +- docs/ru/fastlane.md | 3 ++- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test-reports.yml b/.github/workflows/test-reports.yml index a30c8af..5df5ddf 100644 --- a/.github/workflows/test-reports.yml +++ b/.github/workflows/test-reports.yml @@ -36,6 +36,20 @@ jobs: name: 'Test results · $1' path: '**/*.xml' reporter: java-junit - list-tests: failed + list-tests: all max-annotations: 0 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: 0 + fail-on-empty: false + use-actions-summary: true + collapsed: always diff --git a/docs/en/fastlane.md b/docs/en/fastlane.md index f72f6e0..75dace2 100644 --- a/docs/en/fastlane.md +++ b/docs/en/fastlane.md @@ -170,7 +170,7 @@ 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, plus an Actions summary. Skipped suites have no report and are not presented as +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 diff --git a/docs/ru/fastlane.md b/docs/ru/fastlane.md index bfdc178..c415a9f 100644 --- a/docs/ru/fastlane.md +++ b/docs/ru/fastlane.md @@ -173,7 +173,8 @@ API 26/35. Ошибка тестов по-прежнему возвращает CI загружает XML каждого набора отдельным артефактом `junit-*`, в том числе при падении тестов, со сроком хранения семь дней. У каждого набора своё задание публикации: оно запускается сразу после завершения этого набора, не дожидаясь остального CI. Оно создаёт GitHub Checks -`Test results · …` с числом тестов и подробностями ошибок, а также summary Actions. Для пропущенных наборов отчёт не создаётся: +`Test results · …` с числом тестов и подробностями ошибок. Summary Actions также содержит +сворачиваемые группы с именами и статусами всех тестов. Для пропущенных наборов отчёт не создаётся: они не показываются как заново пройденные тесты. Существующие required checks продолжают блокировать merge. Право `checks: write` есть у publisher; тестовые задания сохраняют доступ к репозиторию только на From d78457483f9b340228fdb966396608497e96b47f Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Tue, 6 Oct 2026 21:12:35 +0300 Subject: [PATCH 4/5] Include failure details and source annotations in test reports --- .github/workflows/test-reports.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-reports.yml b/.github/workflows/test-reports.yml index 5df5ddf..d931f96 100644 --- a/.github/workflows/test-reports.yml +++ b/.github/workflows/test-reports.yml @@ -37,7 +37,7 @@ jobs: path: '**/*.xml' reporter: java-junit list-tests: all - max-annotations: 0 + max-annotations: 10 fail-on-empty: false use-actions-summary: false - name: Add collapsible test groups to Actions Summary @@ -49,7 +49,7 @@ jobs: path: '**/*.xml' reporter: java-junit list-tests: all - max-annotations: 0 + max-annotations: 10 fail-on-empty: false use-actions-summary: true collapsed: always From cf029623639c1b527de04338507df12cc91bb53e Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Tue, 6 Oct 2026 21:16:18 +0300 Subject: [PATCH 5/5] Include total emulator suite duration in JUnit XML --- scripts/android_device_tests.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/android_device_tests.py b/scripts/android_device_tests.py index 375cfdd..52fb394 100644 --- a/scripts/android_device_tests.py +++ b/scripts/android_device_tests.py @@ -330,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 )