Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/android-device.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
99 changes: 99 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)$/'
55 changes: 55 additions & 0 deletions .github/workflows/test-reports.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 21 additions & 0 deletions docs/en/fastlane.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions docs/ru/fastlane.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions fastlane/Fastfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
black==26.5.1
isort==9.0.1
unittest-xml-reporting==4.0.0
12 changes: 11 additions & 1 deletion scripts/android_device_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
)
Expand Down
Loading