From d0259be3532bcd8caf3e2b7b92ddedb5e3c70ffb Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Mon, 7 Sep 2026 18:50:04 +0300 Subject: [PATCH 01/13] Run Android UI tests on Selectel with managed device leases --- .github/workflows/ci.yml | 3 + .github/workflows/selectel-cleanup.yml | 57 +++ .github/workflows/selectel-ui.yml | 88 +++++ .gitignore | 3 + AGENTS.md | 10 +- README.md | 6 + app/build.gradle.kts | 6 + .../java/net/megaproxy487/MainScreenUiTest.kt | 133 +++++++ docs/en/fastlane.md | 9 + docs/en/selectel-ui-tests.md | 72 ++++ docs/ru/fastlane.md | 9 + docs/ru/selectel-ui-tests.md | 74 ++++ fastlane/Fastfile | 49 ++- fastlane/README.md | 4 + scripts/selectel-ui.sh | 12 + scripts/selectel_ui.py | 361 ++++++++++++++++++ scripts/tests/test_selectel_ui.py | 100 +++++ 17 files changed, 990 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/selectel-cleanup.yml create mode 100644 .github/workflows/selectel-ui.yml create mode 100644 app/src/androidTest/java/net/megaproxy487/MainScreenUiTest.kt create mode 100644 docs/en/selectel-ui-tests.md create mode 100644 docs/ru/selectel-ui-tests.md create mode 100755 scripts/selectel-ui.sh create mode 100644 scripts/selectel_ui.py create mode 100644 scripts/tests/test_selectel_ui.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6564c7..35bbaf9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,9 @@ jobs: ruby-version: "3.4.10" bundler-cache: true + - name: Test Selectel lifecycle contracts without a device + run: bundle exec fastlane android selectel_contract_tests + - name: Run native tests with the race detector run: bundle exec fastlane android native_tests diff --git a/.github/workflows/selectel-cleanup.yml b/.github/workflows/selectel-cleanup.yml new file mode 100644 index 0000000..de9f5af --- /dev/null +++ b/.github/workflows/selectel-cleanup.yml @@ -0,0 +1,57 @@ +name: Selectel lease recovery + +on: + workflow_run: + workflows: [Selectel UI] + types: [completed] + +permissions: + contents: read + actions: read + +jobs: + release: + # Only our own branches can create leases. Do not run fork-provided code. + if: github.event.workflow_run.head_repository.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check out trusted default branch cleanup code + uses: actions/checkout@v6 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4.10" + bundler-cache: true + - name: Find lease journal + id: journal + uses: actions/github-script@v8 + with: + script: | + const run = context.payload.workflow_run; + const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { + ...context.repo, run_id: run.id + }); + const expected = `selectel-lease-${run.id}-${run.run_attempt}`; + const journal = artifacts.find(a => a.name === expected && !a.expired); + core.setOutput('found', Boolean(journal)); + core.setOutput('name', expected); + - name: Download only the lease data + if: steps.journal.outputs.found == 'true' + uses: actions/download-artifact@v8 + with: + name: ${{ steps.journal.outputs.name }} + path: .selectel + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + - name: Release any remaining lease + if: steps.journal.outputs.found == 'true' + env: + SELECTEL_USERNAME: ${{ secrets.SELECTEL_USERNAME }} + SELECTEL_PASSWORD: ${{ secrets.SELECTEL_PASSWORD }} + SELECTEL_ACCOUNT_ID: ${{ secrets.SELECTEL_ACCOUNT_ID }} + SELECTEL_PROJECT_ID: ${{ secrets.SELECTEL_PROJECT_ID }} + FASTLANE_SKIP_DOCS: "1" + run: bundle exec fastlane android selectel_release diff --git a/.github/workflows/selectel-ui.yml b/.github/workflows/selectel-ui.yml new file mode 100644 index 0000000..8b27ce6 --- /dev/null +++ b/.github/workflows/selectel-ui.yml @@ -0,0 +1,88 @@ +name: Selectel UI + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +# A newer commit waits for cleanup instead of cancelling a paid device session. +concurrency: + group: selectel-ui-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: false + +jobs: + ui-tests: + # Forks have no device credentials; never use pull_request_target for PR code. + if: github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 35 + env: + FASTLANE_SKIP_DOCS: "1" + SELECTEL_ANDROID_API: "35" + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "21" + - uses: actions/setup-go@v7 + with: + go-version-file: native/go.mod + cache-dependency-path: native/go.sum + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4.10" + bundler-cache: true + - name: Install Android SDK components + run: | + sdkmanager="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" + yes | "$sdkmanager" --licenses >/dev/null || true + "$sdkmanager" "platforms;android-36" "build-tools;36.0.0" "ndk;29.0.14206865" + echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> "$GITHUB_ENV" + - name: Build application and UI test APKs before renting + run: bundle exec fastlane android ui_test_artifacts + - name: Acquire Selectel device + env: + SELECTEL_USERNAME: ${{ secrets.SELECTEL_USERNAME }} + SELECTEL_PASSWORD: ${{ secrets.SELECTEL_PASSWORD }} + SELECTEL_ACCOUNT_ID: ${{ secrets.SELECTEL_ACCOUNT_ID }} + SELECTEL_PROJECT_ID: ${{ secrets.SELECTEL_PROJECT_ID }} + run: bundle exec fastlane android selectel_acquire + - name: Persist lease journal for recovery (no credentials) + if: always() + uses: actions/upload-artifact@v7 + with: + name: selectel-lease-${{ github.run_id }}-${{ github.run_attempt }} + path: .selectel/lease.json + include-hidden-files: true + if-no-files-found: ignore + retention-days: 14 + - name: Run device UI tests + run: bundle exec fastlane android selectel_run + - name: Release paid device lease + if: always() + env: + SELECTEL_USERNAME: ${{ secrets.SELECTEL_USERNAME }} + SELECTEL_PASSWORD: ${{ secrets.SELECTEL_PASSWORD }} + SELECTEL_ACCOUNT_ID: ${{ secrets.SELECTEL_ACCOUNT_ID }} + SELECTEL_PROJECT_ID: ${{ secrets.SELECTEL_PROJECT_ID }} + run: bundle exec fastlane android selectel_release + - name: Upload UI reports + if: always() + id: reports + uses: actions/upload-artifact@v7 + with: + name: selectel-ui-results-${{ github.run_id }}-${{ github.run_attempt }} + path: app/build/reports/selectel/ + if-no-files-found: ignore + retention-days: 14 + - name: Publish report link + if: always() + env: + REPORT_URL: ${{ steps.reports.outputs.artifact-url }} + run: | + if [[ -n "$REPORT_URL" ]]; then + echo "[Selectel UI reports and screenshots]($REPORT_URL)" >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/.gitignore b/.gitignore index b8ad376..c11abca 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ native/*.aar app/libs/*.aar app/libs/megaproxy-sources.jar *.idsig + +.selectel/ +__pycache__/ diff --git a/AGENTS.md b/AGENTS.md index cb6deaa..2a3897c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,9 +28,13 @@ branch names, credentials, signing material, or other secrets. - 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. -- Keep device-only tests out of required GitHub CI unless the project later adopts a dependable - device farm or controlled self-hosted runner. Do not reintroduce a software-emulated Android - fallback. +- Device UI tests run on Selectel real Android devices through Fastlane, separately from the + required JVM/native checks. Keep the farm check optional until its reliability is established; + do not reintroduce a software-emulated Android fallback. +- Selectel credentials live in GitHub Actions secrets or `~/.config/megaproxy/selectel.env`. + Build APKs before renting; release only the recorded device/slot owned by the run, including + after failures. Preserve the lease journal until cleanup succeeds. Never load release signing + configuration for device tests. ## Releases and distribution diff --git a/README.md b/README.md index d0b339d..aaddaae 100644 --- a/README.md +++ b/README.md @@ -277,6 +277,12 @@ installed Gradle version. See [native/README.md](native/README.md) for Go data-p English is the project language for source code, comments, documentation, commit messages, UI copy, logs, and tooling. +### Real-device UI tests + +UI tests run on Selectel through Fastlane without a GitHub-hosted emulator. See the +[English setup](docs/en/selectel-ui-tests.md) or [Russian setup](docs/ru/selectel-ui-tests.md) +for local credentials, PR integration, reports, and paid-device cleanup. + ### Emulator Create the API 35 Google APIs ARM64 emulator: diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 805c27e..83e63b0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -42,6 +42,7 @@ android { applicationId = "net.megaproxy487" minSdk = 26 targetSdk = 36 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" // Each APK has a unique, monotonically ordered code. Keeping the // universal code below the ABI variants lets app stores prefer the // smaller compatible APK when both are available. @@ -116,6 +117,11 @@ dependencies { implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") implementation("androidx.navigation:navigation-compose:2.9.5") + androidTestImplementation(platform("androidx.compose:compose-bom:2025.01.01")) + androidTestImplementation("androidx.compose.ui:ui-test-junit4") + androidTestImplementation("androidx.test:runner:1.6.2") + androidTestImplementation("androidx.test:rules:1.6.1") + androidTestImplementation("androidx.test.ext:junit:1.2.1") testImplementation("junit:junit:4.13.2") testImplementation("org.json:json:20250107") runtimeOnly(files("libs/megaproxy.aar")) diff --git a/app/src/androidTest/java/net/megaproxy487/MainScreenUiTest.kt b/app/src/androidTest/java/net/megaproxy487/MainScreenUiTest.kt new file mode 100644 index 0000000..e3b1655 --- /dev/null +++ b/app/src/androidTest/java/net/megaproxy487/MainScreenUiTest.kt @@ -0,0 +1,133 @@ +package net.megaproxy487 + +import android.content.Context +import android.graphics.Bitmap +import androidx.compose.ui.graphics.asAndroidBitmap +import java.io.File +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.test.core.app.ActivityScenario +import androidx.test.platform.app.InstrumentationRegistry +import net.megaproxy487.data.ConfigStore +import net.megaproxy487.data.ConfigWrites +import net.megaproxy487.model.ProxyType +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +/** Real activity/navigation/storage, synthetic profiles, no dependency on a working proxy. */ +@RunWith(Parameterized::class) +class MainScreenUiTest(private val language: AppLanguage, private val fontScale: String) { + @get:Rule val compose = createEmptyComposeRule() + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val context get() = instrumentation.targetContext + private lateinit var scenario: ActivityScenario + private lateinit var oldFontScale: String + + private fun shell(command: String): String = instrumentation.uiAutomation.executeShellCommand(command) + .use { android.os.ParcelFileDescriptor.AutoCloseInputStream(it).bufferedReader().readText().trim() } + + @Before fun launch() { + oldFontScale = shell("settings get system font_scale") + shell("settings put system font_scale $fontScale") + AppLanguageManager.set(context, language) + context.getSharedPreferences("battery_optimization_reminder", Context.MODE_PRIVATE).edit() + .putLong("last_request_at", System.currentTimeMillis()).commit() + val store = ConfigStore(context) + val profile = store.profiles().first() + store.saveProfile(profile.copy(name = "UI test profile with a deliberately long name", + config = profile.config.copy(type = ProxyType.HTTPS_JUMP, host = "", jumpHost = ""))) + scenario = ActivityScenario.launch(MainActivity::class.java) + screen("main") + } + + @After fun close() { + if (::scenario.isInitialized) scenario.close() + if (::oldFontScale.isInitialized) { + if (oldFontScale == "null") shell("settings delete system font_scale") + else shell("settings put system font_scale $oldFontScale") + } + } + + private fun text(id: Int) = context.uiText(id) + private fun screen(route: String) { + compose.waitUntil(15_000) { compose.onAllNodesWithTag("screen-$route").fetchSemanticsNodes().isNotEmpty() } + compose.onNodeWithTag("screen-$route").assertIsDisplayed() + } + private fun openProfiles() { + compose.onNodeWithContentDescription(text(R.string.main_actions)).performClick() + compose.onNodeWithText(text(R.string.settings)).performClick() + screen("settings") + compose.onNodeWithText(text(R.string.profiles)).performScrollTo().performClick() + screen("profiles") + } + private fun addProfile() { + val button = hasText(text(R.string.add_profile)) and isEnabled() + compose.waitUntil(10_000) { compose.onAllNodes(button).fetchSemanticsNodes().isNotEmpty() } + compose.onNode(button).performClick() + screen("profile-editor") + } + private fun back() { + compose.onNodeWithContentDescription(text(R.string.back)).performClick() + } + + @Test fun mainMenuAndNavigationSurviveRecreation() { + val screenshotDirectory = File(context.getExternalFilesDir(null), "ui-test-screenshots").apply { mkdirs() } + File(screenshotDirectory, "main-${language.tag}-$fontScale.png").outputStream().use { + compose.onRoot().captureToImage().asAndroidBitmap().compress(Bitmap.CompressFormat.PNG, 100, it) + } + compose.onNodeWithText(text(R.string.connect)).assertIsDisplayed().assertIsNotEnabled() + compose.onNodeWithText(text(R.string.test_connection)).assertDoesNotExist() + compose.onNodeWithContentDescription(text(R.string.main_actions)).performClick() + compose.onNodeWithText(text(R.string.test_connection)).assertIsDisplayed() + compose.onNodeWithText(text(R.string.feedback)).assertIsDisplayed() + compose.onNodeWithText(text(R.string.settings)).performClick() + screen("settings") + scenario.recreate() + screen("settings") + back() + screen("main") + compose.onNodeWithContentDescription(text(R.string.main_actions)).assertIsDisplayed() + } + + @Test fun editedProfileSurvivesRecreationAndNavigation() { + openProfiles() + addProfile() + val name = "Saved UI draft ${language.tag} $fontScale" + compose.onNode(hasSetTextAction() and hasText(text(R.string.profile_name_optional))) + .performScrollTo().performTextReplacement(name) + compose.waitUntil(10_000) { ConfigWrites.status.value.pending == 0 } + scenario.recreate() + screen("profile-editor") + compose.onNode(hasSetTextAction() and hasText(name)).assertExists() + back() + screen("profiles") + compose.waitUntil(10_000) { ConfigWrites.status.value.pending == 0 } + compose.onNode(hasScrollToIndexAction()).performScrollToNode(hasText(name)) + compose.onNodeWithText(name).assertIsDisplayed() + assertEquals(1, ConfigStore(context).profiles().count { it.name == name }) + } + + @Test fun closingUntouchedDraftDoesNotCreateProfile() { + val before = ConfigStore(context).profiles().size + openProfiles() + addProfile() + scenario.recreate() + screen("profile-editor") + back() + screen("profiles") + assertEquals(before, ConfigStore(context).profiles().size) + } + + companion object { + @JvmStatic @Parameterized.Parameters(name = "{0}, font={1}") + fun configurations() = listOf( + arrayOf(AppLanguage.ENGLISH, "1.0"), arrayOf(AppLanguage.RUSSIAN, "1.0"), + arrayOf(AppLanguage.ENGLISH, "2.0"), arrayOf(AppLanguage.RUSSIAN, "2.0"), + ) + } +} diff --git a/docs/en/fastlane.md b/docs/en/fastlane.md index 8eb1645..0a23629 100644 --- a/docs/en/fastlane.md +++ b/docs/en/fastlane.md @@ -40,6 +40,13 @@ That command lists the lanes available in the checked-out version of the project | `bundle exec fastlane android test` | Runs `native_tests` and `android_checks`; this is the normal pre-commit command. | | `bundle exec fastlane android debug_artifact` | Builds `app/build/outputs/apk/debug/app-debug.apk`. | | `bundle exec fastlane android release_artifacts` | Builds and verifies the signed release APKs, AAB, native debug symbols, and `SHA256SUMS` in `dist/release`. | +| `bundle exec fastlane android selectel_contract_tests` | Tests lease cleanup and instrumentation parsing without renting a device. | +| `bundle exec fastlane android ui_test_artifacts` | Runs Selectel contract tests and builds debug application and instrumentation APKs. | +| `bundle exec fastlane android selectel_probe` | Checks access and availability without renting. | +| `bundle exec fastlane android selectel_acquire` | Rents one device and records its lease; requires prebuilt APKs. | +| `bundle exec fastlane android selectel_run` | Runs tests on the device from the lease journal. | +| `bundle exec fastlane android selectel_release` | Releases the recorded lease and temporary ADB key; safe to repeat. | +| `bundle exec fastlane android selectel_ui_tests` | Rents, runs prebuilt tests, and releases the device in a finally block. | The release lane requires the signing configuration described in [Signed release builds](../../README.md#signed-release-builds). It builds artifacts but does not @@ -73,3 +80,5 @@ Review changes to both `Gemfile` and `Gemfile.lock`. The official Fastlane docum committing the lock file and using `bundle exec fastlane` locally and in CI. [Русская версия](../ru/fastlane.md) + +[Selectel UI testing setup](selectel-ui-tests.md). diff --git a/docs/en/selectel-ui-tests.md b/docs/en/selectel-ui-tests.md new file mode 100644 index 0000000..c894c9e --- /dev/null +++ b/docs/en/selectel-ui-tests.md @@ -0,0 +1,72 @@ +# Selectel UI tests + +The `Selectel UI` workflow runs on real Android devices for same-repository PRs and manual +`workflow_dispatch` runs. Fork PRs are skipped; neither `pull_request_target` nor fork code receives +credentials. Keep this job optional in branch protection until the farm's reliability is established. +The existing native/JVM/lint checks remain required. No hosted emulator or release signing is used. + +## Setup + +Create a Selectel service user with `mobile_farm.admin` scoped to the chosen project. This role is +needed to add and remove paid devices, not just open an ADB session. Set these GitHub Actions secrets: +`SELECTEL_USERNAME`, `SELECTEL_PASSWORD`, `SELECTEL_ACCOUNT_ID`, `SELECTEL_PROJECT_ID`. +Use the project UUID for `SELECTEL_PROJECT_ID`, not its display name. + +Locally, put the same shell variable assignments in `~/.config/megaproxy/selectel.env` (directory +mode 700, file mode 600). The wrapper loads this file only outside GitHub Actions. Override its +location with `MEGAPROXY_SELECTEL_ENV`. Do not source `release.env` for UI tests. +Python 3 and Android SDK platform-tools (ADB) are required; no Selectel CLI or pip dependencies. +Configure the normal project JDK 21/SDK/NDK/Go/Ruby toolchain before building. + +```sh +bundle exec fastlane android selectel_probe +bundle exec fastlane android ui_test_artifacts +bundle exec fastlane android selectel_ui_tests +``` + +The first command is read-only. The last rents **one device with minute billing**. Default selection: +Android API 35, arm64, preferring Pixel if available. Override with `SELECTEL_ANDROID_API` and +optionally `SELECTEL_DEVICE_MODEL` (exact catalog name). No silent API-level fallback. Device +availability and the actual tariff depend on the provider. Build before renting to avoid paying for +compilation. ADB uses its own key directory and server port 5038 (`SELECTEL_ADB_PORT` overrides it), +so local phones and emulators are not targeted. + +## Coverage and results + +Instrumentation uses the real activity, navigation and profile storage with synthetic input, without +starting VPN connections. Tests cover main-menu navigation, state after activity recreation, saved +profile drafts, and closing untouched drafts, in English/Russian with 100%/200% font scale. The +original font scale is restored after each test. Existing devices in the Selectel project are untouched. + +Results are in `app/build/reports/selectel/`: JUnit XML, raw instrumentation output and main-screen +screenshots. A successful ADB exit is not sufficient: missing tests, runner crashes, assertions and +timeouts fail the job. GitHub uploads reports and links them in the job summary. + +## Cleanup and recovery + +`.selectel/lease.json` records only project/device/slot identifiers and temporary public-key identity, +not credentials. Do not delete it while a lease is outstanding. It survives Gradle clean and is ignored +by Git. `selectel_ui_tests` cleans up in `finally`; GitHub also uses an `always()` release step. +Recovery is idempotent and verifies the slot ID so an old journal cannot delete a newer rental. + +```sh +bundle exec fastlane android selectel_release +``` + +Closing ADB or unassigning a device does **not** end minute billing: removal from the project does. +The `Selectel lease recovery` workflow reads the uploaded lease journal after a run completes and +uses trusted default-branch code to release any remaining rental. This recovery workflow becomes +active after merging it into the default branch. It does not execute code from downloaded artifacts. +To recover manually on another checkout, download that run's `selectel-lease-*` artifact and put its +`lease.json` in `.selectel/` before invoking `selectel_release`. + +There is a residual window if the runner dies before uploading the journal, or the allocation API +accepts the request but its response is lost. Allocation is deliberately not retried automatically. +An `allocation_pending` journal requires checking the Selectel project for the newly added rental +and removing it in the panel. Never remove all project devices: other rentals may belong to someone +else. The journal preserves the pre-existing device list to help investigation. API/network cleanup +errors fail the job and preserve the journal for retry. A hard kill cannot guarantee local cleanup. + +References: [Mobile Farm API](https://docs.selectel.ru/api/mobile-farm/), +[project roles](https://docs.selectel.ru/mobile-farm/manage/manage-access/), +[IAM authentication](https://docs.selectel.ru/api/authorization/). diff --git a/docs/ru/fastlane.md b/docs/ru/fastlane.md index 14ca945..b8d5feb 100644 --- a/docs/ru/fastlane.md +++ b/docs/ru/fastlane.md @@ -41,6 +41,13 @@ bundle exec fastlane lanes | `bundle exec fastlane android test` | Выполняет `native_tests` и `android_checks`; основная команда перед коммитом. | | `bundle exec fastlane android debug_artifact` | Собирает `app/build/outputs/apk/debug/app-debug.apk`. | | `bundle exec fastlane android release_artifacts` | Собирает и проверяет подписанные APK, AAB, native debug symbols и `SHA256SUMS` в `dist/release`. | +| `bundle exec fastlane android selectel_contract_tests` | Проверяет освобождение аренды и разбор результатов без аренды устройства. | +| `bundle exec fastlane android ui_test_artifacts` | Запускает контрактные тесты Selectel и собирает debug APK приложения и UI-тестов. | +| `bundle exec fastlane android selectel_probe` | Проверяет доступ и наличие устройств без аренды. | +| `bundle exec fastlane android selectel_acquire` | Арендует одно устройство и сохраняет журнал; APK должны быть собраны заранее. | +| `bundle exec fastlane android selectel_run` | Запускает тесты на устройстве из журнала аренды. | +| `bundle exec fastlane android selectel_release` | Удаляет записанную аренду и временный ADB-ключ; повторный вызов безопасен. | +| `bundle exec fastlane android selectel_ui_tests` | Арендует устройство, выполняет готовые тесты и освобождает аренду в finally. | Для release lane нужна конфигурация подписи из раздела [Signed release builds](../../README.md#signed-release-builds). Lane только собирает артефакты: он @@ -75,3 +82,5 @@ bundle exec fastlane android test хранить lock-файл в репозитории и использовать `bundle exec fastlane` локально и в CI. [English version](../en/fastlane.md) + +[Настройка UI-тестов Selectel](selectel-ui-tests.md). diff --git a/docs/ru/selectel-ui-tests.md b/docs/ru/selectel-ui-tests.md new file mode 100644 index 0000000..b7bac04 --- /dev/null +++ b/docs/ru/selectel-ui-tests.md @@ -0,0 +1,74 @@ +# UI-тесты Selectel + +Workflow `Selectel UI` запускает тесты на реальном Android для PR из этого репозитория и вручную +через `workflow_dispatch`. PR из форков пропускаются; `pull_request_target` не используется, код +форков не получает секреты. Пока стабильность фермы не подтверждена, проверка необязательна в +branch protection. Обязательные native/JVM/lint проверки сохраняются. Эмулятор и release-подпись +для этих тестов не используются. + +## Настройка + +Создайте сервисного пользователя Selectel с ролью `mobile_farm.admin` в выбранном проекте. Роль +нужна для добавления и удаления платных устройств, а не только открытия ADB-сессий. В GitHub Actions +задайте secrets: `SELECTEL_USERNAME`, `SELECTEL_PASSWORD`, `SELECTEL_ACCOUNT_ID`, `SELECTEL_PROJECT_ID`. +В `SELECTEL_PROJECT_ID` нужен UUID проекта, не его отображаемое имя. + +Локально положите присваивания этих переменных shell в `~/.config/megaproxy/selectel.env`: права +каталога 700, файла 600. Обёртка загружает его только вне GitHub Actions. Другой путь задаётся через +`MEGAPROXY_SELECTEL_ENV`. Не загружайте `release.env` для UI-тестов. +Нужны Python 3 и Android SDK platform-tools (ADB); отдельный Selectel CLI и зависимости pip не нужны. +Перед сборкой настройте обычный toolchain проекта: JDK 21, SDK, NDK, Go и Ruby. + +```sh +bundle exec fastlane android selectel_probe +bundle exec fastlane android ui_test_artifacts +bundle exec fastlane android selectel_ui_tests +``` + +Первая команда только читает данные. Последняя арендует **одно устройство с поминутной оплатой**. +По умолчанию выбирается Android API 35, arm64; при наличии предпочтение отдаётся Pixel. Можно задать +`SELECTEL_ANDROID_API` и `SELECTEL_DEVICE_MODEL` (точное имя модели из каталога). Автоматической смены +API нет. Доступность и фактическая цена зависят от провайдера. APK собираются до начала аренды. +ADB использует отдельные ключи и порт сервера 5038 (переопределяется `SELECTEL_ADB_PORT`), поэтому +локальные телефоны и эмуляторы не затрагиваются. + +## Покрытие и результаты + +Тесты работают с настоящей activity, навигацией и хранилищем профилей, используют синтетические данные +и не включают VPN. Проверяются меню главного экрана, состояние после пересоздания activity, сохранение +отредактированного профиля и закрытие нетронутого черновика. Матрица: русский/английский и шрифт +100%/200%. Исходный масштаб восстанавливается после каждого теста. Существующие устройства проекта +Selectel не изменяются. + +Результаты сохраняются в `app/build/reports/selectel/`: JUnit XML, вывод instrumentation и скриншоты +главного экрана. Успешного кода возврата ADB недостаточно: отсутствие тестов, падение runner, ошибки +проверок и таймауты завершают job ошибкой. GitHub публикует отчёты и ссылку в summary. + +## Освобождение аренды + +`.selectel/lease.json` содержит идентификаторы проекта, устройства, слота и временного публичного +ключа, но не секреты. Не удаляйте его до завершения аренды. Файл переживает Gradle clean и исключён +из Git. `selectel_ui_tests` освобождает аренду в `finally`; в GitHub также есть шаг с `always()`. +Повторное освобождение безопасно: проверяется ID слота, чтобы старый журнал не удалил новую аренду. + +```sh +bundle exec fastlane android selectel_release +``` + +Отключение ADB или освобождение устройства пользователя **не останавливает оплату**: нужно удалить +устройство из проекта. Workflow `Selectel lease recovery` после завершения прогона читает сохранённый +артефакт журнала и освобождает оставшуюся аренду кодом из доверенной основной ветки. Он начинает +работать после слияния в основную ветку и не выполняет код из скачанных артефактов. +Для ручного восстановления в другом checkout скачайте артефакт `selectel-lease-*`, положите его +`lease.json` в `.selectel/` и запустите `selectel_release`. + +Остаётся окно риска: runner может погибнуть до загрузки журнала, а API — создать аренду, но потерять +ответ. Поэтому запрос создания автоматически не повторяется. При `allocation_pending` проверьте +проект в панели Selectel и удалите именно новую аренду. Не удаляйте все устройства: они могут быть +заняты другими задачами. В журнале сохраняется список устройств до аренды для проверки. Ошибка API +или сети при очистке завершает job ошибкой и сохраняет журнал для повторной попытки. При жёстком +завершении процесса гарантировать локальную очистку невозможно. + +Документация: [API мобильной фермы](https://docs.selectel.ru/api/mobile-farm/), +[роли проекта](https://docs.selectel.ru/mobile-farm/manage/manage-access/), +[IAM-аутентификация](https://docs.selectel.ru/api/authorization/). diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 04e016a..21928b0 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -19,8 +19,7 @@ platform :android do sh(File.join(project_root, "scripts", "build-fdroid-native.sh")) end - desc "Run Android checks, build a debug APK, and verify that the CI release APK is unsigned" - lane :android_checks do + private_lane :ensure_no_release_signing do signing_variables = %w[ MEGAPROXY_KEYSTORE_PATH MEGAPROXY_KEYSTORE_PASSWORD @@ -33,11 +32,17 @@ platform :android do end unless configured_signing_variables.empty? UI.user_error!( - "Signing configuration must not be available to android_checks: " \ + "Signing configuration must not be available to unsigned checks or UI tests: " \ "#{configured_signing_variables.join(', ')}" ) end + end + + desc "Run Android checks, build a debug APK, and verify that the CI release APK is unsigned" + lane :android_checks do + ensure_no_release_signing + native_library gradle( tasks: %w[testDebugUnitTest lintDebug assembleDebug assembleRelease], @@ -70,6 +75,44 @@ platform :android do android_checks end + desc "Test the Selectel lease lifecycle and instrumentation result parser without a device" + lane :selectel_contract_tests do + sh("python3", "-m", "unittest", "discover", "-s", File.join(project_root, "scripts", "tests")) + end + + desc "Build debug application and instrumentation APKs before renting a device" + lane :ui_test_artifacts do + ensure_no_release_signing + selectel_contract_tests + native_library + gradle(tasks: %w[assembleDebug assembleDebugAndroidTest], flags: "--no-daemon", project_dir: project_root) + end + + desc "Check Selectel access and device availability without renting" + lane :selectel_probe do + sh("bash", File.join(project_root, "scripts", "selectel-ui.sh"), "probe") + end + + desc "Rent and connect one Selectel Android device for the prebuilt UI test APKs" + lane :selectel_acquire do + sh("bash", File.join(project_root, "scripts", "selectel-ui.sh"), "acquire") + end + + desc "Run prebuilt UI tests on the device recorded in the Selectel lease journal" + lane :selectel_run do + sh("bash", File.join(project_root, "scripts", "selectel-ui.sh"), "run") + end + + desc "Release the Selectel lease recorded by this checkout (safe to repeat)" + lane :selectel_release do + sh("bash", File.join(project_root, "scripts", "selectel-ui.sh"), "release") + end + + desc "Rent, run prebuilt UI tests, and release a Selectel Android device" + lane :selectel_ui_tests do + sh("bash", File.join(project_root, "scripts", "selectel-ui.sh"), "test") + end + desc "Build a debug APK after preparing the native library" lane :debug_artifact do native_library diff --git a/fastlane/README.md b/fastlane/README.md index f691648..8800f52 100644 --- a/fastlane/README.md +++ b/fastlane/README.md @@ -14,3 +14,7 @@ Fastlane is the supported entry point for tests and build artifacts. Install Rub The release lane deliberately delegates signing and artifact verification to the repository's existing release scripts. It requires the signing environment documented in the root README. Publishing to an app store is not performed by any lane. + +Real-device UI tests use `ui_test_artifacts` followed by `selectel_ui_tests`. See the complete +[English command reference](../docs/en/fastlane.md), [Russian command reference](../docs/ru/fastlane.md), +and [Selectel setup](../docs/en/selectel-ui-tests.md) for probe, lease and recovery commands. diff --git a/scripts/selectel-ui.sh b/scripts/selectel-ui.sh new file mode 100755 index 0000000..5b6262d --- /dev/null +++ b/scripts/selectel-ui.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail +# Source only Selectel credentials; release signing must never enter UI test jobs. +if [[ -z "${GITHUB_ACTIONS:-}" ]]; then + config_file="${MEGAPROXY_SELECTEL_ENV:-$HOME/.config/megaproxy/selectel.env}" + if [[ -f "$config_file" ]]; then + set -a + source "$config_file" + set +a + fi +fi +exec python3 "$(dirname "$0")/selectel_ui.py" "$@" diff --git a/scripts/selectel_ui.py b/scripts/selectel_ui.py new file mode 100644 index 0000000..eec02bb --- /dev/null +++ b/scripts/selectel_ui.py @@ -0,0 +1,361 @@ +#!/usr/bin/env python3 +"""Selectel device lease + Android instrumentation, using only Python's standard library. + +State contains device/slot IDs, never credentials. Only devices created by this run +are eligible for release. APKs must be built before acquiring a paid device. +""" +import argparse +import json +import os +from pathlib import Path +import re +import shutil +import signal +import subprocess +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +import xml.etree.ElementTree as ET + +ROOT = Path(__file__).resolve().parents[1] +API = 'https://api.selectel.ru/mobfarm/api' +AUTH = 'https://cloud.api.selcloud.ru/identity/v3/auth/tokens' +APP = 'net.megaproxy487' +RUNNER = APP + '.test/androidx.test.runner.AndroidJUnitRunner' + + +class ApiError(RuntimeError): + def __init__(self, method, path, status): + super().__init__(f'Selectel {method} {path}: HTTP {status}') + self.status = status + + +class Client: + def __init__(self): + names = ['SELECTEL_USERNAME', 'SELECTEL_PASSWORD', 'SELECTEL_ACCOUNT_ID', 'SELECTEL_PROJECT_ID'] + if any(not os.environ.get(n) for n in names): + raise RuntimeError('Required variables: ' + ', '.join(names)) + self.project = os.environ['SELECTEL_PROJECT_ID'] + payload = {'auth': {'identity': {'methods': ['password'], 'password': {'user': { + 'name': os.environ['SELECTEL_USERNAME'], 'password': os.environ['SELECTEL_PASSWORD'], + 'domain': {'name': os.environ['SELECTEL_ACCOUNT_ID']}}}}, + 'scope': {'project': {'id': self.project}}}} + request = urllib.request.Request(AUTH, data=json.dumps(payload).encode(), + headers={'Content-Type': 'application/json'}) + try: + with urllib.request.urlopen(request, timeout=30) as response: + self.token = response.headers['X-Subject-Token'] + except urllib.error.HTTPError as e: + raise ApiError('POST', 'auth', e.code) from None + if not self.token: + raise RuntimeError('Selectel did not return an IAM token') + + def request(self, method, path, body=None): + request = urllib.request.Request(API + path, method=method, + data=None if body is None else json.dumps(body).encode(), + headers={'Content-Type': 'application/json', 'X-Auth-Token': self.token}) + try: + with urllib.request.urlopen(request, timeout=30) as response: + raw = response.read() + return json.loads(raw) if raw else None + except urllib.error.HTTPError as e: + # Never include response bodies: auth failures may reflect request data. + raise ApiError(method, path, e.code) from None + + +def write_state(path, state): + path.parent.mkdir(parents=True, exist_ok=True) + temp = path.with_suffix('.tmp') + temp.write_text(json.dumps(state, indent=2)) + os.chmod(temp, 0o600) + temp.replace(path) + + +def read_state(path): + return json.loads(path.read_text()) + + +def adb_path(): + sdk = os.environ.get('ANDROID_HOME') or os.environ.get('ANDROID_SDK_ROOT') + adb = str(Path(sdk) / 'platform-tools/adb') if sdk else shutil.which('adb') + if not adb or not Path(adb).is_file(): + raise RuntimeError('ADB is required (ANDROID_HOME/platform-tools/adb or PATH)') + return adb + + +def adb_env(state_path): + # Isolate keys and the host server from the developer's own devices. + home = state_path.parent / 'adb-home' + (home / '.android').mkdir(parents=True, exist_ok=True) + env = os.environ.copy() + env['ANDROID_USER_HOME'] = str(home / '.android') + env['ANDROID_PREFS_ROOT'] = str(home) + env['ADB_VENDOR_KEYS'] = str(home / '.android/adbkey') + return env + + +def adb(state_path, *args, timeout=30, check=True): + port = str(os.environ.get('SELECTEL_ADB_PORT', '5038')) + result = subprocess.run([adb_path(), '-P', port, *args], env=adb_env(state_path), + capture_output=True, text=True, timeout=timeout) + if check and result.returncode: + raise RuntimeError(f'ADB command failed: {args[0]} (exit {result.returncode})') + return result.stdout + + +def connect_device(path, endpoint): + print('Waiting for remote ADB (maximum 90 seconds)', flush=True) + deadline = time.monotonic() + 90 + while time.monotonic() < deadline: + try: + adb(path, 'connect', endpoint, timeout=10, check=False) + if adb(path, '-s', endpoint, 'get-state', timeout=10, check=False).strip() == 'device': + return + except subprocess.TimeoutExpired: + pass + time.sleep(3) + raise RuntimeError('Remote ADB did not connect within 90 seconds; lease will be released') + + +def choose_device(available): + sdk = os.environ.get('SELECTEL_ANDROID_API', '35') + model = os.environ.get('SELECTEL_DEVICE_MODEL', '') + choices = [d for d in available if d.get('platform') == 'Android' + and str(d.get('sdk')) == sdk and d.get('abi') == 'arm64-v8a' + and d.get('count', 0) > 0 and (not model or d.get('marketName') == model)] + if not choices: + raise RuntimeError(f'No available arm64 Android API {sdk} device; no lease created') + # Prefer Pixel, then use another available vendor without changing API level. + return sorted(choices, key=lambda d: (not d.get('marketName', '').startswith('Pixel'), d['marketName']))[0] + + +def acquire(client, path): + if path.exists() and not read_state(path).get('released'): + raise RuntimeError('An unreleased lease journal exists; run selectel_release first') + for apk in [ROOT / 'app/build/outputs/apk/debug/app-debug.apk', + ROOT / 'app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk']: + if not apk.is_file(): + raise RuntimeError('Build UI test APKs before renting a device') + adb_path() + existing = client.request('GET', '/v3/devices') + model = choose_device(client.request('GET', '/v3/devices/available')) + state = {'project': client.project, 'created_at': time.time(), 'devices': [], + 'released': False, 'baseline': [d['serial'] for d in existing]} + write_state(path, state) + filters = {k: model[k] for k in ['manufacturer', 'marketName', 'sdk', 'platform', 'abi']} + filters['count'] = 1 + print(f"Renting one {model['marketName']} (API {model['sdk']}), billing=minutes", flush=True) + # Never retry this non-idempotent request: a lost response may already have created a lease. + state['allocation_pending'] = True + write_state(path, state) + try: + result = client.request('POST', '/v3/devices', {'billingType': 'minutes', 'filters': [filters]}) + except ApiError as error: + if 400 <= error.status < 500: + state['allocation_pending'] = False + write_state(path, state) + raise + state['devices'] = [{'serial': d['serial'], 'slot': d['meta']['slot']['id']} for d in result['devices']] + state['allocation_pending'] = False + write_state(path, state) + if len(state['devices']) != 1 or any(d['serial'] in state['baseline'] for d in state['devices']): + raise RuntimeError('Unexpected lease response; inspect journal before proceeding') + device = state['devices'][0] + serial = urllib.parse.quote(device['serial'], safe='') + deadline = time.monotonic() + 180 + while time.monotonic() < deadline: + info = client.request('GET', '/v3/devices/' + serial) + if info.get('ready') and info.get('present'): + break + time.sleep(5) + else: + raise RuntimeError('Device did not become ready within 180 seconds') + env = adb_env(path) + key = Path(env['ADB_VENDOR_KEYS']) + subprocess.run([adb_path(), 'keygen', str(key)], check=True, capture_output=True, env=env) + os.chmod(key, 0o600) + key_info = client.request('POST', '/v3/keys/adb', + {'publicKey': key.with_suffix('.pub').read_text(), 'title': 'megaproxy-ui-' + str(int(state['created_at']))}) + state['fingerprint'] = key_info['fingerprint'] + write_state(path, state) + client.request('POST', '/v1/user/devices', {'serial': device['serial'], 'timeout': 1200000}) + remote = client.request('POST', '/v1/user/devices/' + serial + '/remoteConnect') + endpoint = remote.get('remoteConnectUrl', '') + if not remote.get('success') or not re.fullmatch(r'[A-Za-z0-9.-]+:[0-9]+', endpoint): + raise RuntimeError('Selectel did not return a valid ADB endpoint') + state['endpoint'] = endpoint + write_state(path, state) + connect_device(path, endpoint) + print('Device connected; lease journal saved', flush=True) + + +def release(client, path): + if not path.exists(): + return + state = read_state(path) + if state.get('project') != client.project: + raise RuntimeError('Lease belongs to a different project') + if state.get('released'): + return + errors = [] + if state.get('allocation_pending'): + errors.append('Allocation response was lost: inspect project for a new lease; do not blindly retry') + for device in state.get('devices', []): + if device['serial'] in state.get('baseline', []): + errors.append('Refusing to remove a pre-existing device') + continue + serial = urllib.parse.quote(device['serial'], safe='') + # Slot identity prevents a stale recovery job from deleting a newer lease. + try: + current = client.request('GET', '/v3/devices/' + serial) + if current['meta']['slot']['id'] != device['slot']: + continue + for suffix in ['/remoteConnect', '']: + try: + client.request('DELETE', '/v1/user/devices/' + serial + suffix) + except (ApiError, urllib.error.URLError): + pass # Still remove the paid lease, even if session teardown fails. + client.request('DELETE', '/v3/devices/' + serial, {'slotID': device['slot']}) + except ApiError as e: + if e.status != 404: + errors.append(str(e)) + except (KeyError, urllib.error.URLError) as e: + errors.append(type(e).__name__ + ' while releasing device') + if state.get('fingerprint'): + try: + client.request('DELETE', '/v3/keys/adb/' + urllib.parse.quote(state['fingerprint'], safe='')) + except ApiError as e: + if e.status != 404: + errors.append(str(e)) + try: + adb(path, 'kill-server', check=False) + except (RuntimeError, subprocess.TimeoutExpired): + pass + shutil.rmtree(path.parent / 'adb-home', ignore_errors=True) + if errors: + raise RuntimeError('; '.join(errors) + f'. Recovery journal: {path}') + state['released'] = True + write_state(path, state) + print('Paid device lease and temporary ADB key released', flush=True) + + +def instrumentation_report(output, target): + """Translate Android's instrumentation protocol; adb exit=0 alone is not success.""" + suite = ET.Element('testsuite', name='MegaProxy UI') + fields = {} + last_key = None + completed = 0 + failures = 0 + passed = 0 + for line in output.splitlines(): + if line.startswith('INSTRUMENTATION_STATUS: '): + key, _, value = line[len('INSTRUMENTATION_STATUS: '):].partition('=') + fields[key] = value + last_key = key + elif line.startswith('INSTRUMENTATION_STATUS_CODE: '): + code = int(line.split(': ', 1)[1]) + if code <= 0 and fields.get('test'): + case = ET.SubElement(suite, 'testcase', classname=fields.get('class', ''), name=fields['test']) + completed += 1 + if code in (-3, -4): + ET.SubElement(case, 'skipped') + elif code == 0: + passed += 1 + else: + failures += 1 + ET.SubElement(case, 'failure').text = fields.get('stack', 'Instrumentation failure') + fields = {} + last_key = None + elif last_key == 'stack' and not line.startswith('INSTRUMENTATION_'): + fields['stack'] += '\n' + line + success = passed > 0 and failures == 0 and 'INSTRUMENTATION_CODE: -1' in output and re.search(r'OK \(\d+ tests?\)', output) is not None + if not success and failures == 0: + failures += 1 + completed += 1 + ET.SubElement(ET.SubElement(suite, 'testcase', name='instrumentation_finished'), 'failure').text = 'No successful instrumentation completion; see instrumentation.txt' + suite.set('tests', str(completed)) + suite.set('failures', str(failures)) + ET.ElementTree(suite).write(target, encoding='utf-8', xml_declaration=True) + return success + + +def run_tests(path): + state = read_state(path) + if state.get('released'): + raise RuntimeError('Device lease is already released') + endpoint = state['endpoint'] + reports = ROOT / 'app/build/reports/selectel' + shutil.rmtree(reports, ignore_errors=True) + reports.mkdir(parents=True, exist_ok=True) + # Install only on the newly leased device, never a local emulator or personal phone. + connect_device(path, endpoint) + for apk in ['app/build/outputs/apk/debug/app-debug.apk', + 'app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk']: + print('Installing ' + Path(apk).name, flush=True) + adb(path, '-s', endpoint, 'install', '-r', '-t', str(ROOT / apk), timeout=180) + adb(path, '-s', endpoint, 'shell', 'pm', 'clear', APP) + print('Running instrumentation (maximum 10 minutes)', flush=True) + try: + output = adb(path, '-s', endpoint, 'shell', 'am', 'instrument', '-w', '-r', RUNNER, timeout=600, check=False) + except subprocess.TimeoutExpired as error: + output = error.stdout or '' + if isinstance(output, bytes): + output = output.decode(errors='replace') + (reports / 'instrumentation.txt').write_text(output + '\nInstrumentation timed out') + instrumentation_report(output, reports / 'junit.xml') + raise RuntimeError('Instrumentation exceeded 10 minutes') from None + finally: + try: + adb(path, '-s', endpoint, 'pull', '/sdcard/Android/data/' + APP + '/files/ui-test-screenshots', + str(reports / 'screenshots'), timeout=30, check=False) + except (RuntimeError, subprocess.TimeoutExpired): + print('Screenshot collection unavailable; instrumentation outcome is preserved', flush=True) + (reports / 'instrumentation.txt').write_text(output) + success = instrumentation_report(output, reports / 'junit.xml') + print(f'UI report: {reports / "junit.xml"}', flush=True) + if not success: + raise RuntimeError('UI instrumentation tests failed; see reports') + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('action', choices=['probe', 'acquire', 'run', 'release', 'test']) + parser.add_argument('--state', type=Path, default=ROOT / '.selectel/lease.json') + args = parser.parse_args() + args.state = args.state.resolve() + def interrupted(signum, frame): + raise KeyboardInterrupt + signal.signal(signal.SIGTERM, interrupted) + try: + if args.action == 'run': + run_tests(args.state) + else: + client = Client() + if args.action == 'probe': + existing = client.request('GET', '/v3/devices') + model = choose_device(client.request('GET', '/v3/devices/available')) + print(f"Access OK; {len(existing)} existing devices untouched; candidate {model['marketName']} API {model['sdk']}") + elif args.action == 'release': + release(client, args.state) + elif args.action == 'acquire': + acquire(client, args.state) + else: + try: + acquire(client, args.state) + run_tests(args.state) + finally: + release(client, args.state) + except KeyboardInterrupt: + print('Interrupted; run selectel_release if the lease remains active', file=sys.stderr) + return 130 + except Exception as e: + # Avoid tracebacks containing request data or credentials. + print(str(e) if isinstance(e, (RuntimeError, ApiError)) else type(e).__name__, file=sys.stderr) + return 1 + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/tests/test_selectel_ui.py b/scripts/tests/test_selectel_ui.py new file mode 100644 index 0000000..8e402b0 --- /dev/null +++ b/scripts/tests/test_selectel_ui.py @@ -0,0 +1,100 @@ +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location('selectel_ui', Path(__file__).parents[1] / 'selectel_ui.py') +m = importlib.util.module_from_spec(spec) +spec.loader.exec_module(m) + + +class FakeClient: + project = 'ci' + def __init__(self, slot='owned-slot', fail_session=False): + self.slot = slot + self.fail_session = fail_session + self.calls = [] + def request(self, method, route, body=None): + self.calls.append((method, route, body)) + if method == 'GET': + return {'meta': {'slot': {'id': self.slot}}} + if self.fail_session and '/v1/' in route: + raise m.ApiError(method, route, 500) + + +class LeaseTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.path = Path(self.temp.name) / 'lease.json' + self.state = {'project': 'ci', 'devices': [{'serial': 'test-device', 'slot': 'owned-slot'}], + 'baseline': ['existing-device'], 'released': False} + m.write_state(self.path, self.state) + self.adb = patch.object(m, 'adb', return_value='').start() + self.addCleanup(patch.stopall) + + def test_release_ends_rental_even_when_adb_session_fails(self): + client = FakeClient(fail_session=True) + m.release(client, self.path) + self.assertIn(('DELETE', '/v3/devices/test-device', {'slotID': 'owned-slot'}), client.calls) + self.assertTrue(m.read_state(self.path)['released']) + + def test_stale_journal_cannot_delete_reallocated_device(self): + client = FakeClient(slot='someone-elses-slot') + m.release(client, self.path) + self.assertFalse(any(call[0] == 'DELETE' for call in client.calls)) + + def test_preexisting_device_cannot_be_deleted(self): + self.state['baseline'].append('test-device') + m.write_state(self.path, self.state) + client = FakeClient() + with self.assertRaisesRegex(RuntimeError, 'pre-existing'): + m.release(client, self.path) + self.assertEqual([], client.calls) + + def test_release_is_idempotent(self): + client = FakeClient() + m.release(client, self.path) + count = len(client.calls) + m.release(client, self.path) + self.assertEqual(count, len(client.calls)) + + def test_lost_allocation_response_requires_recovery(self): + self.state.update(devices=[], allocation_pending=True) + m.write_state(self.path, self.state) + with self.assertRaisesRegex(RuntimeError, 'response was lost'): + m.release(FakeClient(), self.path) + self.assertFalse(m.read_state(self.path)['released']) + + def test_wrong_project_is_rejected(self): + client = FakeClient() + client.project = 'other-project' + with self.assertRaisesRegex(RuntimeError, 'different project'): + m.release(client, self.path) + self.assertEqual([], client.calls) + + def test_zero_tests_or_crashed_instrumentation_is_failure(self): + for output in ['', 'INSTRUMENTATION_CODE: -1', 'OK (0 tests)\nINSTRUMENTATION_CODE: -1']: + self.assertFalse(m.instrumentation_report(output, Path(self.temp.name) / 'junit.xml')) + + def test_android_failure_is_not_hidden_by_successful_adb_exit(self): + output = ('INSTRUMENTATION_STATUS: class=Test\nINSTRUMENTATION_STATUS: test=broken\n' + 'INSTRUMENTATION_STATUS: stack=Assertion failed\nINSTRUMENTATION_STATUS_CODE: -2\n' + 'INSTRUMENTATION_CODE: -1') + self.assertFalse(m.instrumentation_report(output, Path(self.temp.name) / 'junit.xml')) + + def test_all_skipped_is_not_a_successful_smoke_run(self): + output = ('INSTRUMENTATION_STATUS: class=Test\nINSTRUMENTATION_STATUS: test=skipped\n' + 'INSTRUMENTATION_STATUS_CODE: -3\nOK (0 tests)\nINSTRUMENTATION_CODE: -1') + self.assertFalse(m.instrumentation_report(output, Path(self.temp.name) / 'junit.xml')) + + def test_successful_instrumentation(self): + output = ('INSTRUMENTATION_STATUS: class=Test\nINSTRUMENTATION_STATUS: test=works\n' + 'INSTRUMENTATION_STATUS_CODE: 0\nOK (1 test)\nINSTRUMENTATION_CODE: -1') + self.assertTrue(m.instrumentation_report(output, Path(self.temp.name) / 'junit.xml')) + + +if __name__ == '__main__': + unittest.main() From f749728662eac89ea5065d6ce9ff9f51ad2920ac Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Mon, 7 Sep 2026 18:55:03 +0300 Subject: [PATCH 02/13] Reduce device upload time with ARM64 UI test APKs --- .github/workflows/selectel-ui.yml | 1 + docs/en/selectel-ui-tests.md | 2 +- docs/ru/selectel-ui-tests.md | 2 +- fastlane/Fastfile | 4 ++-- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/selectel-ui.yml b/.github/workflows/selectel-ui.yml index 8b27ce6..11d8870 100644 --- a/.github/workflows/selectel-ui.yml +++ b/.github/workflows/selectel-ui.yml @@ -27,6 +27,7 @@ jobs: with: distribution: temurin java-version: "21" + cache: gradle - uses: actions/setup-go@v7 with: go-version-file: native/go.mod diff --git a/docs/en/selectel-ui-tests.md b/docs/en/selectel-ui-tests.md index c894c9e..1c3ce63 100644 --- a/docs/en/selectel-ui-tests.md +++ b/docs/en/selectel-ui-tests.md @@ -27,7 +27,7 @@ bundle exec fastlane android selectel_ui_tests The first command is read-only. The last rents **one device with minute billing**. Default selection: Android API 35, arm64, preferring Pixel if available. Override with `SELECTEL_ANDROID_API` and optionally `SELECTEL_DEVICE_MODEL` (exact catalog name). No silent API-level fallback. Device -availability and the actual tariff depend on the provider. Build before renting to avoid paying for +availability and the actual tariff depend on the provider. The UI build includes only ARM64 to reduce upload time. Build before renting to avoid paying for compilation. ADB uses its own key directory and server port 5038 (`SELECTEL_ADB_PORT` overrides it), so local phones and emulators are not targeted. diff --git a/docs/ru/selectel-ui-tests.md b/docs/ru/selectel-ui-tests.md index b7bac04..3fec1a5 100644 --- a/docs/ru/selectel-ui-tests.md +++ b/docs/ru/selectel-ui-tests.md @@ -28,7 +28,7 @@ bundle exec fastlane android selectel_ui_tests Первая команда только читает данные. Последняя арендует **одно устройство с поминутной оплатой**. По умолчанию выбирается Android API 35, arm64; при наличии предпочтение отдаётся Pixel. Можно задать `SELECTEL_ANDROID_API` и `SELECTEL_DEVICE_MODEL` (точное имя модели из каталога). Автоматической смены -API нет. Доступность и фактическая цена зависят от провайдера. APK собираются до начала аренды. +API нет. Доступность и фактическая цена зависят от провайдера. APK собираются до начала аренды и содержат только ARM64, чтобы сократить время загрузки. ADB использует отдельные ключи и порт сервера 5038 (переопределяется `SELECTEL_ADB_PORT`), поэтому локальные телефоны и эмуляторы не затрагиваются. diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 21928b0..e358d9d 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -36,7 +36,6 @@ platform :android do "#{configured_signing_variables.join(', ')}" ) end - end desc "Run Android checks, build a debug APK, and verify that the CI release APK is unsigned" @@ -85,7 +84,8 @@ platform :android do ensure_no_release_signing selectel_contract_tests native_library - gradle(tasks: %w[assembleDebug assembleDebugAndroidTest], flags: "--no-daemon", project_dir: project_root) + gradle(tasks: %w[assembleDebug assembleDebugAndroidTest], + flags: "--no-daemon -PmegaproxyVersionVariant=arm64-v8a", project_dir: project_root) end desc "Check Selectel access and device availability without renting" From f4987bb45590d302d4aa38c0ea2a72f739ea17d3 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Mon, 7 Sep 2026 19:00:13 +0300 Subject: [PATCH 03/13] Pin the verified farm model and report ADB connection failures --- .github/workflows/selectel-ui.yml | 1 + docs/en/selectel-ui-tests.md | 4 ++-- docs/ru/selectel-ui-tests.md | 4 ++-- scripts/selectel_ui.py | 18 ++++++++++-------- 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/.github/workflows/selectel-ui.yml b/.github/workflows/selectel-ui.yml index 11d8870..1cb7204 100644 --- a/.github/workflows/selectel-ui.yml +++ b/.github/workflows/selectel-ui.yml @@ -21,6 +21,7 @@ jobs: env: FASTLANE_SKIP_DOCS: "1" SELECTEL_ANDROID_API: "35" + SELECTEL_DEVICE_MODEL: "Galaxy A14" steps: - uses: actions/checkout@v6 - uses: actions/setup-java@v5 diff --git a/docs/en/selectel-ui-tests.md b/docs/en/selectel-ui-tests.md index 1c3ce63..8e07e72 100644 --- a/docs/en/selectel-ui-tests.md +++ b/docs/en/selectel-ui-tests.md @@ -25,8 +25,8 @@ bundle exec fastlane android selectel_ui_tests ``` The first command is read-only. The last rents **one device with minute billing**. Default selection: -Android API 35, arm64, preferring Pixel if available. Override with `SELECTEL_ANDROID_API` and -optionally `SELECTEL_DEVICE_MODEL` (exact catalog name). No silent API-level fallback. Device +Galaxy A14, Android API 35, arm64. Override with `SELECTEL_ANDROID_API` and +`SELECTEL_DEVICE_MODEL` (exact catalog name). No silent model/API-level fallback. Device availability and the actual tariff depend on the provider. The UI build includes only ARM64 to reduce upload time. Build before renting to avoid paying for compilation. ADB uses its own key directory and server port 5038 (`SELECTEL_ADB_PORT` overrides it), so local phones and emulators are not targeted. diff --git a/docs/ru/selectel-ui-tests.md b/docs/ru/selectel-ui-tests.md index 3fec1a5..7e6f6d4 100644 --- a/docs/ru/selectel-ui-tests.md +++ b/docs/ru/selectel-ui-tests.md @@ -26,9 +26,9 @@ bundle exec fastlane android selectel_ui_tests ``` Первая команда только читает данные. Последняя арендует **одно устройство с поминутной оплатой**. -По умолчанию выбирается Android API 35, arm64; при наличии предпочтение отдаётся Pixel. Можно задать +По умолчанию выбирается Galaxy A14, Android API 35, arm64. Можно задать `SELECTEL_ANDROID_API` и `SELECTEL_DEVICE_MODEL` (точное имя модели из каталога). Автоматической смены -API нет. Доступность и фактическая цена зависят от провайдера. APK собираются до начала аренды и содержат только ARM64, чтобы сократить время загрузки. +модели или API нет. Доступность и фактическая цена зависят от провайдера. APK собираются до начала аренды и содержат только ARM64, чтобы сократить время загрузки. ADB использует отдельные ключи и порт сервера 5038 (переопределяется `SELECTEL_ADB_PORT`), поэтому локальные телефоны и эмуляторы не затрагиваются. diff --git a/scripts/selectel_ui.py b/scripts/selectel_ui.py index eec02bb..f954949 100644 --- a/scripts/selectel_ui.py +++ b/scripts/selectel_ui.py @@ -96,39 +96,41 @@ def adb_env(state_path): return env -def adb(state_path, *args, timeout=30, check=True): +def adb(state_path, *args, timeout=30, check=True, include_stderr=False): port = str(os.environ.get('SELECTEL_ADB_PORT', '5038')) result = subprocess.run([adb_path(), '-P', port, *args], env=adb_env(state_path), capture_output=True, text=True, timeout=timeout) if check and result.returncode: raise RuntimeError(f'ADB command failed: {args[0]} (exit {result.returncode})') - return result.stdout + return result.stdout + (result.stderr if include_stderr else "") def connect_device(path, endpoint): print('Waiting for remote ADB (maximum 90 seconds)', flush=True) deadline = time.monotonic() + 90 + diagnostic = 'no response' while time.monotonic() < deadline: try: - adb(path, 'connect', endpoint, timeout=10, check=False) - if adb(path, '-s', endpoint, 'get-state', timeout=10, check=False).strip() == 'device': + connected = adb(path, 'connect', endpoint, timeout=10, check=False, include_stderr=True) + status = adb(path, '-s', endpoint, 'get-state', timeout=10, check=False, include_stderr=True).strip() + if status == 'device': return + diagnostic = (connected + ' ' + status).replace(endpoint, '').replace('\n', ' ')[:240] except subprocess.TimeoutExpired: pass time.sleep(3) - raise RuntimeError('Remote ADB did not connect within 90 seconds; lease will be released') + raise RuntimeError('Remote ADB did not connect within 90 seconds: ' + diagnostic) def choose_device(available): sdk = os.environ.get('SELECTEL_ANDROID_API', '35') - model = os.environ.get('SELECTEL_DEVICE_MODEL', '') + model = os.environ.get('SELECTEL_DEVICE_MODEL', 'Galaxy A14') choices = [d for d in available if d.get('platform') == 'Android' and str(d.get('sdk')) == sdk and d.get('abi') == 'arm64-v8a' and d.get('count', 0) > 0 and (not model or d.get('marketName') == model)] if not choices: raise RuntimeError(f'No available arm64 Android API {sdk} device; no lease created') - # Prefer Pixel, then use another available vendor without changing API level. - return sorted(choices, key=lambda d: (not d.get('marketName', '').startswith('Pixel'), d['marketName']))[0] + return sorted(choices, key=lambda d: d['marketName'])[0] def acquire(client, path): From 45e0c437f12d35936c267066229e33a865937c88 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Mon, 7 Sep 2026 19:30:02 +0300 Subject: [PATCH 04/13] Run required Selectel UI checks manually with a fixed device matrix --- .github/workflows/selectel-cleanup.yml | 10 +- .github/workflows/selectel-ui.yml | 84 ++++- AGENTS.md | 6 +- README.md | 3 +- config/selectel-devices.json | 474 +++++++++++++++++++++++++ docs/en/fastlane.md | 2 + docs/en/selectel-ui-tests.md | 39 +- docs/ru/fastlane.md | 2 + docs/ru/selectel-ui-tests.md | 40 ++- fastlane/Fastfile | 21 +- fastlane/README.md | 3 +- scripts/selectel_ui.py | 93 ++++- scripts/tests/test_selectel_ui.py | 53 +++ 13 files changed, 790 insertions(+), 40 deletions(-) create mode 100644 config/selectel-devices.json diff --git a/.github/workflows/selectel-cleanup.yml b/.github/workflows/selectel-cleanup.yml index de9f5af..4d3dfed 100644 --- a/.github/workflows/selectel-cleanup.yml +++ b/.github/workflows/selectel-cleanup.yml @@ -14,7 +14,7 @@ jobs: # Only our own branches can create leases. Do not run fork-provided code. if: github.event.workflow_run.head_repository.full_name == github.repository runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 15 steps: - name: Check out trusted default branch cleanup code uses: actions/checkout@v6 @@ -35,14 +35,14 @@ jobs: ...context.repo, run_id: run.id }); const expected = `selectel-lease-${run.id}-${run.run_attempt}`; - const journal = artifacts.find(a => a.name === expected && !a.expired); - core.setOutput('found', Boolean(journal)); - core.setOutput('name', expected); + const journals = artifacts.filter(a => !a.expired && (a.name === expected || a.name.startsWith(expected + '-'))); + core.setOutput('found', journals.length > 0); + core.setOutput('ids', journals.map(a => a.id).join(',')); - name: Download only the lease data if: steps.journal.outputs.found == 'true' uses: actions/download-artifact@v8 with: - name: ${{ steps.journal.outputs.name }} + artifact-ids: ${{ steps.journal.outputs.ids }} path: .selectel run-id: ${{ github.event.workflow_run.id }} github-token: ${{ github.token }} diff --git a/.github/workflows/selectel-ui.yml b/.github/workflows/selectel-ui.yml index 1cb7204..3fe1a5a 100644 --- a/.github/workflows/selectel-ui.yml +++ b/.github/workflows/selectel-ui.yml @@ -1,27 +1,32 @@ name: Selectel UI on: - pull_request: workflow_dispatch: + inputs: + devices: + description: Fixed device set + type: choice + options: [single, all] + default: single + required: true permissions: contents: read -# A newer commit waits for cleanup instead of cancelling a paid device session. +# Manual runs on the same branch wait for cleanup instead of cancelling a paid session. concurrency: - group: selectel-ui-${{ github.event.pull_request.number || github.ref }} + group: selectel-ui-${{ github.ref }} cancel-in-progress: false jobs: - ui-tests: - # Forks have no device credentials; never use pull_request_target for PR code. - if: github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository + build: runs-on: ubuntu-latest timeout-minutes: 35 env: FASTLANE_SKIP_DOCS: "1" - SELECTEL_ANDROID_API: "35" - SELECTEL_DEVICE_MODEL: "Galaxy A14" + DEVICE_PROFILE: ${{ inputs.devices }} + outputs: + devices: ${{ steps.matrix.outputs.devices }} steps: - uses: actions/checkout@v6 - uses: actions/setup-java@v5 @@ -43,8 +48,53 @@ jobs: yes | "$sdkmanager" --licenses >/dev/null || true "$sdkmanager" "platforms;android-36" "build-tools;36.0.0" "ndk;29.0.14206865" echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> "$GITHUB_ENV" + - name: Read fixed device matrix + id: matrix + run: echo "devices=$(python3 scripts/selectel_ui.py matrix --profile "$DEVICE_PROFILE")" >> "$GITHUB_OUTPUT" - name: Build application and UI test APKs before renting - run: bundle exec fastlane android ui_test_artifacts + run: bundle exec fastlane android ui_test_artifacts profile:"$DEVICE_PROFILE" + - uses: actions/upload-artifact@v7 + id: apks + with: + name: selectel-apks-${{ github.run_id }}-${{ github.run_attempt }} + path: | + app/build/outputs/apk/debug/*.apk + app/build/outputs/apk/androidTest/debug/*.apk + if-no-files-found: error + retention-days: 3 + - name: Publish APK link + env: + APK_URL: ${{ steps.apks.outputs.artifact-url }} + run: echo "[Debug application and UI test APKs]($APK_URL)" >> "$GITHUB_STEP_SUMMARY" + + ui-tests: + needs: build + name: UI · ${{ matrix.model }} · API ${{ matrix.api }} · ${{ matrix.abi }} + strategy: + fail-fast: false + max-parallel: 2 + matrix: ${{ fromJSON(needs.build.outputs.devices) }} + runs-on: ubuntu-latest + timeout-minutes: 25 + env: + FASTLANE_SKIP_DOCS: "1" + SELECTEL_DEVICE_MODEL: ${{ matrix.model }} + SELECTEL_DEVICE_MANUFACTURER: ${{ matrix.manufacturer }} + SELECTEL_ANDROID_API: ${{ matrix.api }} + SELECTEL_DEVICE_ABI: ${{ matrix.abi }} + SELECTEL_RESULT_ID: ${{ matrix.id }} + steps: + - uses: actions/checkout@v6 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4.10" + bundler-cache: true + - name: Add ADB to PATH + run: echo "$ANDROID_HOME/platform-tools" >> "$GITHUB_PATH" + - uses: actions/download-artifact@v8 + with: + name: selectel-apks-${{ github.run_id }}-${{ github.run_attempt }} + path: app/build/outputs/apk - name: Acquire Selectel device env: SELECTEL_USERNAME: ${{ secrets.SELECTEL_USERNAME }} @@ -56,7 +106,7 @@ jobs: if: always() uses: actions/upload-artifact@v7 with: - name: selectel-lease-${{ github.run_id }}-${{ github.run_attempt }} + name: selectel-lease-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.id }} path: .selectel/lease.json include-hidden-files: true if-no-files-found: ignore @@ -76,7 +126,7 @@ jobs: id: reports uses: actions/upload-artifact@v7 with: - name: selectel-ui-results-${{ github.run_id }}-${{ github.run_attempt }} + name: selectel-ui-results-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.id }} path: app/build/reports/selectel/ if-no-files-found: ignore retention-days: 14 @@ -88,3 +138,15 @@ jobs: if [[ -n "$REPORT_URL" ]]; then echo "[Selectel UI reports and screenshots]($REPORT_URL)" >> "$GITHUB_STEP_SUMMARY" fi + + required: + name: Selectel UI required + if: always() + needs: [build, ui-tests] + runs-on: ubuntu-latest + steps: + - name: Require successful build and every selected device + env: + BUILD_RESULT: ${{ needs.build.result }} + UI_RESULT: ${{ needs.ui-tests.result }} + run: test "$BUILD_RESULT" = success && test "$UI_RESULT" = success diff --git a/AGENTS.md b/AGENTS.md index 2a3897c..0478aac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,8 +29,10 @@ branch names, credentials, signing material, or other secrets. deterministic JVM unit tests. Resource parity, navigation destination wiring, preference serialization/defaults, formatting, and state transitions should not require a device. - Device UI tests run on Selectel real Android devices through Fastlane, separately from the - required JVM/native checks. Keep the farm check optional until its reliability is established; - do not reintroduce a software-emulated Android fallback. + automatic JVM/native checks. Run UI tests manually on the PR branch before merging; require + `Selectel UI required` for the current commit. Never rent devices on each push. + Use `single` or the fixed `config/selectel-devices.json` catalog (`all`); no runtime discovery. + Do not reintroduce a software-emulated Android fallback. - Selectel credentials live in GitHub Actions secrets or `~/.config/megaproxy/selectel.env`. Build APKs before renting; release only the recorded device/slot owned by the run, including after failures. Preserve the lease journal until cleanup succeeds. Never load release signing diff --git a/README.md b/README.md index aaddaae..4976812 100644 --- a/README.md +++ b/README.md @@ -279,7 +279,8 @@ copy, logs, and tooling. ### Real-device UI tests -UI tests run on Selectel through Fastlane without a GitHub-hosted emulator. See the +UI tests run manually on Selectel before merging, with a required check for the current PR commit. +Choose one device or the fixed full catalog; pushes only trigger native/JVM/lint checks. See the [English setup](docs/en/selectel-ui-tests.md) or [Russian setup](docs/ru/selectel-ui-tests.md) for local credentials, PR integration, reports, and paid-device cleanup. diff --git a/config/selectel-devices.json b/config/selectel-devices.json new file mode 100644 index 0000000..610f45c --- /dev/null +++ b/config/selectel-devices.json @@ -0,0 +1,474 @@ +{ + "captured_on": "2026-09-07", + "devices": [ + { + "id": "google-pixel-7-33-arm64-v8a", + "manufacturer": "GOOGLE", + "model": "Pixel 7", + "api": "33", + "abi": "arm64-v8a" + }, + { + "id": "google-pixel-7-37-arm64-v8a", + "manufacturer": "GOOGLE", + "model": "Pixel 7", + "api": "37", + "abi": "arm64-v8a" + }, + { + "id": "google-pixel-8-36-arm64-v8a", + "manufacturer": "GOOGLE", + "model": "Pixel 8", + "api": "36", + "abi": "arm64-v8a" + }, + { + "id": "google-pixel-8-37-arm64-v8a", + "manufacturer": "GOOGLE", + "model": "Pixel 8", + "api": "37", + "abi": "arm64-v8a" + }, + { + "id": "google-pixel-9-37-arm64-v8a", + "manufacturer": "GOOGLE", + "model": "Pixel 9", + "api": "37", + "abi": "arm64-v8a" + }, + { + "id": "google-pixel-9a-36-arm64-v8a", + "manufacturer": "GOOGLE", + "model": "Pixel 9a", + "api": "36", + "abi": "arm64-v8a" + }, + { + "id": "google-pixel-9a-37-arm64-v8a", + "manufacturer": "GOOGLE", + "model": "Pixel 9a", + "api": "37", + "abi": "arm64-v8a" + }, + { + "id": "honor-90-33-arm64-v8a", + "manufacturer": "HONOR", + "model": "90", + "api": "33", + "abi": "arm64-v8a" + }, + { + "id": "honor-90-34-arm64-v8a", + "manufacturer": "HONOR", + "model": "90", + "api": "34", + "abi": "arm64-v8a" + }, + { + "id": "honor-pad-x8a-34-arm64-v8a", + "manufacturer": "HONOR", + "model": "Pad X8a", + "api": "34", + "abi": "arm64-v8a" + }, + { + "id": "honor-x6c-35-arm64-v8a", + "manufacturer": "HONOR", + "model": "X6c", + "api": "35", + "abi": "arm64-v8a" + }, + { + "id": "honor-x7c-34-arm64-v8a", + "manufacturer": "HONOR", + "model": "X7c", + "api": "34", + "abi": "arm64-v8a" + }, + { + "id": "honor-x8c-35-arm64-v8a", + "manufacturer": "HONOR", + "model": "X8c", + "api": "35", + "abi": "arm64-v8a" + }, + { + "id": "honor-x9c-smart-34-arm64-v8a", + "manufacturer": "HONOR", + "model": "X9c Smart", + "api": "34", + "abi": "arm64-v8a" + }, + { + "id": "huawei-nova-11-31-arm64-v8a", + "manufacturer": "HUAWEI", + "model": "Nova 11", + "api": "31", + "abi": "arm64-v8a" + }, + { + "id": "huawei-nova-13-31-arm64-v8a", + "manufacturer": "HUAWEI", + "model": "Nova 13", + "api": "31", + "abi": "arm64-v8a" + }, + { + "id": "huawei-nova-y63-31-arm64-v8a", + "manufacturer": "HUAWEI", + "model": "Nova Y63", + "api": "31", + "abi": "arm64-v8a" + }, + { + "id": "huawei-pura-80-31-arm64-v8a", + "manufacturer": "HUAWEI", + "model": "Pura 80", + "api": "31", + "abi": "arm64-v8a" + }, + { + "id": "infinix-hot-30-play-33-arm64-v8a", + "manufacturer": "INFINIX", + "model": "Hot 30 Play", + "api": "33", + "abi": "arm64-v8a" + }, + { + "id": "infinix-hot-30i-31-arm64-v8a", + "manufacturer": "INFINIX", + "model": "Hot 30i", + "api": "31", + "abi": "arm64-v8a" + }, + { + "id": "infinix-note-30-33-arm64-v8a", + "manufacturer": "INFINIX", + "model": "Note 30", + "api": "33", + "abi": "arm64-v8a" + }, + { + "id": "infinix-note-30i-33-arm64-v8a", + "manufacturer": "INFINIX", + "model": "Note 30i", + "api": "33", + "abi": "arm64-v8a" + }, + { + "id": "infinix-zero-40-34-arm64-v8a", + "manufacturer": "INFINIX", + "model": "Zero 40", + "api": "34", + "abi": "arm64-v8a" + }, + { + "id": "realme-11-33-arm64-v8a", + "manufacturer": "REALME", + "model": "11", + "api": "33", + "abi": "arm64-v8a" + }, + { + "id": "realme-11-pro-33-arm64-v8a", + "manufacturer": "REALME", + "model": "11 Pro+", + "api": "33", + "abi": "arm64-v8a" + }, + { + "id": "realme-11-pro-35-arm64-v8a", + "manufacturer": "REALME", + "model": "11 Pro+", + "api": "35", + "abi": "arm64-v8a" + }, + { + "id": "realme-c30s-31-armeabi-v7a", + "manufacturer": "REALME", + "model": "C30s", + "api": "31", + "abi": "armeabi-v7a" + }, + { + "id": "realme-c53-33-arm64-v8a", + "manufacturer": "REALME", + "model": "C53", + "api": "33", + "abi": "arm64-v8a" + }, + { + "id": "realme-c53-35-arm64-v8a", + "manufacturer": "REALME", + "model": "C53", + "api": "35", + "abi": "arm64-v8a" + }, + { + "id": "realme-c55-33-arm64-v8a", + "manufacturer": "REALME", + "model": "C55", + "api": "33", + "abi": "arm64-v8a" + }, + { + "id": "realme-c55-35-arm64-v8a", + "manufacturer": "REALME", + "model": "C55", + "api": "35", + "abi": "arm64-v8a" + }, + { + "id": "realme-gt3-34-arm64-v8a", + "manufacturer": "REALME", + "model": "GT3", + "api": "34", + "abi": "arm64-v8a" + }, + { + "id": "realme-pad-10-4-30-arm64-v8a", + "manufacturer": "REALME", + "model": "Pad 10.4", + "api": "30", + "abi": "arm64-v8a" + }, + { + "id": "realme-re58bc-33-arm64-v8a", + "manufacturer": "REALME", + "model": "RE58BC", + "api": "33", + "abi": "arm64-v8a" + }, + { + "id": "samsung-galaxy-a03-30-arm64-v8a", + "manufacturer": "SAMSUNG", + "model": "Galaxy A03", + "api": "30", + "abi": "arm64-v8a" + }, + { + "id": "samsung-galaxy-a03-31-arm64-v8a", + "manufacturer": "SAMSUNG", + "model": "Galaxy A03", + "api": "31", + "abi": "arm64-v8a" + }, + { + "id": "samsung-galaxy-a03-33-arm64-v8a", + "manufacturer": "SAMSUNG", + "model": "Galaxy A03", + "api": "33", + "abi": "arm64-v8a" + }, + { + "id": "samsung-galaxy-a05-34-arm64-v8a", + "manufacturer": "SAMSUNG", + "model": "Galaxy A05", + "api": "34", + "abi": "arm64-v8a" + }, + { + "id": "samsung-galaxy-a13-34-armeabi-v7a", + "manufacturer": "SAMSUNG", + "model": "Galaxy A13", + "api": "34", + "abi": "armeabi-v7a" + }, + { + "id": "samsung-galaxy-a14-33-arm64-v8a", + "manufacturer": "SAMSUNG", + "model": "Galaxy A14", + "api": "33", + "abi": "arm64-v8a" + }, + { + "id": "samsung-galaxy-a14-35-arm64-v8a", + "manufacturer": "SAMSUNG", + "model": "Galaxy A14", + "api": "35", + "abi": "arm64-v8a" + }, + { + "id": "samsung-galaxy-a15-34-arm64-v8a", + "manufacturer": "SAMSUNG", + "model": "Galaxy A15", + "api": "34", + "abi": "arm64-v8a" + }, + { + "id": "samsung-galaxy-a24-33-arm64-v8a", + "manufacturer": "SAMSUNG", + "model": "Galaxy A24", + "api": "33", + "abi": "arm64-v8a" + }, + { + "id": "samsung-galaxy-a24-34-arm64-v8a", + "manufacturer": "SAMSUNG", + "model": "Galaxy A24", + "api": "34", + "abi": "arm64-v8a" + }, + { + "id": "samsung-galaxy-a34-5g-33-arm64-v8a", + "manufacturer": "SAMSUNG", + "model": "Galaxy A34 5G", + "api": "33", + "abi": "arm64-v8a" + }, + { + "id": "samsung-galaxy-a34-5g-36-arm64-v8a", + "manufacturer": "SAMSUNG", + "model": "Galaxy A34 5G", + "api": "36", + "abi": "arm64-v8a" + }, + { + "id": "samsung-galaxy-a35-34-arm64-v8a", + "manufacturer": "SAMSUNG", + "model": "Galaxy A35", + "api": "34", + "abi": "arm64-v8a" + }, + { + "id": "samsung-galaxy-a35-36-arm64-v8a", + "manufacturer": "SAMSUNG", + "model": "Galaxy A35", + "api": "36", + "abi": "arm64-v8a" + }, + { + "id": "samsung-galaxy-a51-33-arm64-v8a", + "manufacturer": "SAMSUNG", + "model": "Galaxy A51", + "api": "33", + "abi": "arm64-v8a" + }, + { + "id": "samsung-galaxy-s23-34-arm64-v8a", + "manufacturer": "SAMSUNG", + "model": "Galaxy S23", + "api": "34", + "abi": "arm64-v8a" + }, + { + "id": "samsung-galaxy-s23-36-arm64-v8a", + "manufacturer": "SAMSUNG", + "model": "Galaxy S23", + "api": "36", + "abi": "arm64-v8a" + }, + { + "id": "samsung-galaxy-s24-34-arm64-v8a", + "manufacturer": "SAMSUNG", + "model": "Galaxy S24", + "api": "34", + "abi": "arm64-v8a" + }, + { + "id": "samsung-galaxy-s24-36-arm64-v8a", + "manufacturer": "SAMSUNG", + "model": "Galaxy S24", + "api": "36", + "abi": "arm64-v8a" + }, + { + "id": "samsung-galaxy-s25-fe-36-arm64-v8a", + "manufacturer": "SAMSUNG", + "model": "Galaxy S25 FE", + "api": "36", + "abi": "arm64-v8a" + }, + { + "id": "samsung-galaxy-tab-a9-34-arm64-v8a", + "manufacturer": "SAMSUNG", + "model": "Galaxy Tab A9", + "api": "34", + "abi": "arm64-v8a" + }, + { + "id": "tecno-camon-20-pro-33-arm64-v8a", + "manufacturer": "TECNO", + "model": "Camon 20 Pro", + "api": "33", + "abi": "arm64-v8a" + }, + { + "id": "tecno-camon-20-pro-34-arm64-v8a", + "manufacturer": "TECNO", + "model": "Camon 20 Pro", + "api": "34", + "abi": "arm64-v8a" + }, + { + "id": "tecno-phantom-v-fold-33-arm64-v8a", + "manufacturer": "TECNO", + "model": "PHANTOM V Fold", + "api": "33", + "abi": "arm64-v8a" + }, + { + "id": "tecno-phantom-x2-5g-31-arm64-v8a", + "manufacturer": "TECNO", + "model": "Phantom X2 5G", + "api": "31", + "abi": "arm64-v8a" + }, + { + "id": "tecno-pova-5-33-arm64-v8a", + "manufacturer": "TECNO", + "model": "Pova 5", + "api": "33", + "abi": "arm64-v8a" + }, + { + "id": "tecno-pova-neo-3-33-arm64-v8a", + "manufacturer": "TECNO", + "model": "Pova Neo 3", + "api": "33", + "abi": "arm64-v8a" + }, + { + "id": "tecno-spark-10c-31-arm64-v8a", + "manufacturer": "TECNO", + "model": "Spark 10C", + "api": "31", + "abi": "arm64-v8a" + }, + { + "id": "tecno-spark-20-pro-33-arm64-v8a", + "manufacturer": "TECNO", + "model": "Spark 20 Pro", + "api": "33", + "abi": "arm64-v8a" + }, + { + "id": "tecno-spark-20c-33-arm64-v8a", + "manufacturer": "TECNO", + "model": "Spark 20c", + "api": "33", + "abi": "arm64-v8a" + }, + { + "id": "tecno-spark-30c-34-arm64-v8a", + "manufacturer": "TECNO", + "model": "Spark 30C", + "api": "34", + "abi": "arm64-v8a" + }, + { + "id": "tecno-spark-go-1-34-arm64-v8a", + "manufacturer": "TECNO", + "model": "Spark Go 1", + "api": "34", + "abi": "arm64-v8a" + }, + { + "id": "tecno-spark-go-2024-33-arm64-v8a", + "manufacturer": "TECNO", + "model": "Spark Go 2024", + "api": "33", + "abi": "arm64-v8a" + } + ] +} diff --git a/docs/en/fastlane.md b/docs/en/fastlane.md index 0a23629..ad69a0a 100644 --- a/docs/en/fastlane.md +++ b/docs/en/fastlane.md @@ -42,11 +42,13 @@ That command lists the lanes available in the checked-out version of the project | `bundle exec fastlane android release_artifacts` | Builds and verifies the signed release APKs, AAB, native debug symbols, and `SHA256SUMS` in `dist/release`. | | `bundle exec fastlane android selectel_contract_tests` | Tests lease cleanup and instrumentation parsing without renting a device. | | `bundle exec fastlane android ui_test_artifacts` | Runs Selectel contract tests and builds debug application and instrumentation APKs. | +| `bundle exec fastlane android ui_test_artifacts profile:all` | Builds a universal APK for every fixed device configuration. | | `bundle exec fastlane android selectel_probe` | Checks access and availability without renting. | | `bundle exec fastlane android selectel_acquire` | Rents one device and records its lease; requires prebuilt APKs. | | `bundle exec fastlane android selectel_run` | Runs tests on the device from the lease journal. | | `bundle exec fastlane android selectel_release` | Releases the recorded lease and temporary ADB key; safe to repeat. | | `bundle exec fastlane android selectel_ui_tests` | Rents, runs prebuilt tests, and releases the device in a finally block. | +| `bundle exec fastlane android selectel_ui_tests profile:all` | Runs the fixed catalog sequentially, releasing each lease and collecting separate reports. | The release lane requires the signing configuration described in [Signed release builds](../../README.md#signed-release-builds). It builds artifacts but does not diff --git a/docs/en/selectel-ui-tests.md b/docs/en/selectel-ui-tests.md index 8e07e72..93f2ef8 100644 --- a/docs/en/selectel-ui-tests.md +++ b/docs/en/selectel-ui-tests.md @@ -1,9 +1,27 @@ # Selectel UI tests -The `Selectel UI` workflow runs on real Android devices for same-repository PRs and manual -`workflow_dispatch` runs. Fork PRs are skipped; neither `pull_request_target` nor fork code receives -credentials. Keep this job optional in branch protection until the farm's reliability is established. -The existing native/JVM/lint checks remain required. No hosted emulator or release signing is used. +The `Selectel UI` workflow runs **manually**, on the PR branch selected in Actions → Selectel UI → +Run workflow. Select `single` or `all`. Pushes do not rent devices. The aggregate +`Selectel UI required` check must succeed for the current PR commit before merging; a new commit +requires a new manual run. Native/JVM/lint checks still run automatically. The required check fails +if the build or any selected device fails, including unavailable configurations. + +The fixed snapshot in `config/selectel-devices.json`, captured on 2026-09-07, contains **67 Android +model/API/ABI configurations** (65 ARM64 and two ARM32). `single` selects Galaxy A14 / API 35 / ARM64; +`all` runs one device per configuration, at most two simultaneously in GitHub. This is not runtime +discovery and does not rent every physical copy of a model. Update the snapshot explicitly in a PR. +The full matrix performs 804 test cases and costs more than a single-device run. Availability and +compatibility of every catalog entry are not guaranteed; there is no substitution on failure. + +```sh +gh workflow run selectel-ui.yml --ref YOUR_PR_BRANCH -f devices=single +# Or: -f devices=all +``` + +Only repository branches can be selected; fork code is not automatically tested with secrets. +No hosted emulator or release signing is used. The ruleset for `main` requires `Selectel UI required` +from GitHub Actions alongside the existing native/Android checks. Workflow dispatch becomes available +in the Actions UI after the workflow is merged into the default branch. ## Setup @@ -31,6 +49,19 @@ availability and the actual tariff depend on the provider. The UI build includes compilation. ADB uses its own key directory and server port 5038 (`SELECTEL_ADB_PORT` overrides it), so local phones and emulators are not targeted. +## Local full matrix + +```sh +bundle exec fastlane android ui_test_artifacts profile:all +bundle exec fastlane android selectel_ui_tests profile:all +``` + +Use `profile:all` for both commands: its universal APK supports ARM32 as well as ARM64. Locally, +devices run sequentially with independent lease journals and report directories named by catalog ID. +Test failures do not stop remaining configurations; cleanup failures stop new rentals immediately. +The summary is `app/build/reports/selectel/matrix.json`. `selectel_release` retries all journals +under `.selectel/`, including journals downloaded into separate artifact subdirectories. + ## Coverage and results Instrumentation uses the real activity, navigation and profile storage with synthetic input, without diff --git a/docs/ru/fastlane.md b/docs/ru/fastlane.md index b8d5feb..0b27bcf 100644 --- a/docs/ru/fastlane.md +++ b/docs/ru/fastlane.md @@ -43,11 +43,13 @@ bundle exec fastlane lanes | `bundle exec fastlane android release_artifacts` | Собирает и проверяет подписанные APK, AAB, native debug symbols и `SHA256SUMS` в `dist/release`. | | `bundle exec fastlane android selectel_contract_tests` | Проверяет освобождение аренды и разбор результатов без аренды устройства. | | `bundle exec fastlane android ui_test_artifacts` | Запускает контрактные тесты Selectel и собирает debug APK приложения и UI-тестов. | +| `bundle exec fastlane android ui_test_artifacts profile:all` | Собирает универсальный APK для всех зафиксированных конфигураций. | | `bundle exec fastlane android selectel_probe` | Проверяет доступ и наличие устройств без аренды. | | `bundle exec fastlane android selectel_acquire` | Арендует одно устройство и сохраняет журнал; APK должны быть собраны заранее. | | `bundle exec fastlane android selectel_run` | Запускает тесты на устройстве из журнала аренды. | | `bundle exec fastlane android selectel_release` | Удаляет записанную аренду и временный ADB-ключ; повторный вызов безопасен. | | `bundle exec fastlane android selectel_ui_tests` | Арендует устройство, выполняет готовые тесты и освобождает аренду в finally. | +| `bundle exec fastlane android selectel_ui_tests profile:all` | Последовательно проверяет фиксированный каталог, освобождая аренды и разделяя отчёты. | Для release lane нужна конфигурация подписи из раздела [Signed release builds](../../README.md#signed-release-builds). Lane только собирает артефакты: он diff --git a/docs/ru/selectel-ui-tests.md b/docs/ru/selectel-ui-tests.md index 7e6f6d4..508d0f2 100644 --- a/docs/ru/selectel-ui-tests.md +++ b/docs/ru/selectel-ui-tests.md @@ -1,10 +1,27 @@ # UI-тесты Selectel -Workflow `Selectel UI` запускает тесты на реальном Android для PR из этого репозитория и вручную -через `workflow_dispatch`. PR из форков пропускаются; `pull_request_target` не используется, код -форков не получает секреты. Пока стабильность фермы не подтверждена, проверка необязательна в -branch protection. Обязательные native/JVM/lint проверки сохраняются. Эмулятор и release-подпись -для этих тестов не используются. +Workflow `Selectel UI` запускается **только вручную**: Actions → Selectel UI → Run workflow, +выбрать ветку PR и `single` либо `all`. Пуши не арендуют устройства. Для слияния нужна успешная +проверка `Selectel UI required` на текущем коммите PR; после нового коммита нужен новый ручной прогон. +Native/JVM/lint продолжают запускаться автоматически. Итоговая проверка падает при ошибке сборки +или любого выбранного устройства, в том числе при недоступности конфигурации. + +В `config/selectel-devices.json` зафиксированы **67 конфигураций модель/API/ABI** из каталога на +2026-09-07: 65 ARM64 и две ARM32. `single` выбирает Galaxy A14 / API 35 / ARM64; `all` запускает +по одному устройству каждой конфигурации, максимум два одновременно в GitHub. Автоматического +обнаружения списка при запуске нет; одинаковые физические экземпляры не дублируются. Список меняется +явно через PR. Полный прогон — 804 тестовых случая и больше расходов, чем одно устройство. +Доступность и совместимость всех записей каталога не гарантированы; подмены при ошибке нет. + +```sh +gh workflow run selectel-ui.yml --ref YOUR_PR_BRANCH -f devices=single +# Или: -f devices=all +``` + +Можно выбрать только ветку этого репозитория; код форков автоматически не получает секреты. +Эмулятор и release-подпись не используются. Ruleset для `main` требует `Selectel UI required` от +GitHub Actions вместе с существующими native/Android проверками. Кнопка ручного запуска появится +в Actions после попадания workflow в основную ветку. ## Настройка @@ -32,6 +49,19 @@ bundle exec fastlane android selectel_ui_tests ADB использует отдельные ключи и порт сервера 5038 (переопределяется `SELECTEL_ADB_PORT`), поэтому локальные телефоны и эмуляторы не затрагиваются. +## Полная матрица локально + +```sh +bundle exec fastlane android ui_test_artifacts profile:all +bundle exec fastlane android selectel_ui_tests profile:all +``` + +Для обеих команд нужен `profile:all`: универсальный APK содержит ARM32 и ARM64. Локально устройства +запускаются последовательно; журналы аренды и отчёты разделены по ID из каталога. Ошибки тестов +не останавливают остальные конфигурации; ошибка освобождения аренды прекращает новые аренды. +Сводка — `app/build/reports/selectel/matrix.json`. `selectel_release` повторяет освобождение для всех +журналов в `.selectel/`, включая скачанные в отдельные подкаталоги артефактов. + ## Покрытие и результаты Тесты работают с настоящей activity, навигацией и хранилищем профилей, используют синтетические данные diff --git a/fastlane/Fastfile b/fastlane/Fastfile index e358d9d..010b3d2 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -80,12 +80,15 @@ platform :android do end desc "Build debug application and instrumentation APKs before renting a device" - lane :ui_test_artifacts do + lane :ui_test_artifacts do |options| + profile = options[:profile] || "single" + UI.user_error!("profile must be single or all") unless %w[single all].include?(profile) + variant = profile == "all" ? "universal" : "arm64-v8a" ensure_no_release_signing selectel_contract_tests native_library gradle(tasks: %w[assembleDebug assembleDebugAndroidTest], - flags: "--no-daemon -PmegaproxyVersionVariant=arm64-v8a", project_dir: project_root) + flags: "--no-daemon -PmegaproxyVersionVariant=#{variant}", project_dir: project_root) end desc "Check Selectel access and device availability without renting" @@ -103,14 +106,20 @@ platform :android do sh("bash", File.join(project_root, "scripts", "selectel-ui.sh"), "run") end - desc "Release the Selectel lease recorded by this checkout (safe to repeat)" + desc "Release all Selectel leases recorded by this checkout (safe to repeat)" lane :selectel_release do - sh("bash", File.join(project_root, "scripts", "selectel-ui.sh"), "release") + sh("bash", File.join(project_root, "scripts", "selectel-ui.sh"), "release-all") end desc "Rent, run prebuilt UI tests, and release a Selectel Android device" - lane :selectel_ui_tests do - sh("bash", File.join(project_root, "scripts", "selectel-ui.sh"), "test") + lane :selectel_ui_tests do |options| + profile = options[:profile] || "single" + UI.user_error!("profile must be single or all") unless %w[single all].include?(profile) + if profile == "all" + sh("bash", File.join(project_root, "scripts", "selectel-ui.sh"), "matrix-test", "--profile", profile) + else + sh("bash", File.join(project_root, "scripts", "selectel-ui.sh"), "test") + end end desc "Build a debug APK after preparing the native library" diff --git a/fastlane/README.md b/fastlane/README.md index 8800f52..7ae71ff 100644 --- a/fastlane/README.md +++ b/fastlane/README.md @@ -15,6 +15,7 @@ The release lane deliberately delegates signing and artifact verification to the existing release scripts. It requires the signing environment documented in the root README. Publishing to an app store is not performed by any lane. -Real-device UI tests use `ui_test_artifacts` followed by `selectel_ui_tests`. See the complete +Real-device UI tests use `ui_test_artifacts` followed by `selectel_ui_tests`. +Pass `profile:all` to both lanes for the fixed full device catalog. See the complete [English command reference](../docs/en/fastlane.md), [Russian command reference](../docs/ru/fastlane.md), and [Selectel setup](../docs/en/selectel-ui-tests.md) for probe, lease and recovery commands. diff --git a/scripts/selectel_ui.py b/scripts/selectel_ui.py index f954949..c219139 100644 --- a/scripts/selectel_ui.py +++ b/scripts/selectel_ui.py @@ -122,14 +122,85 @@ def connect_device(path, endpoint): raise RuntimeError('Remote ADB did not connect within 90 seconds: ' + diagnostic) +def device_matrix(profile): + if profile not in ('single', 'all'): + raise RuntimeError('Device profile must be single or all') + devices = json.loads((ROOT / 'config/selectel-devices.json').read_text())['devices'] + if not devices: + raise RuntimeError('The fixed device catalog is empty') + ids = set() + for device in devices: + if not re.fullmatch(r'[a-z0-9-]+', device['id']) or device['id'] in ids: + raise RuntimeError('Invalid or duplicate device matrix ID') + if device['abi'] not in ('arm64-v8a', 'armeabi-v7a') or not str(device['api']).isdigit(): + raise RuntimeError('Unsupported device matrix entry') + ids.add(device['id']) + if profile == 'single': + devices = [d for d in devices if d['manufacturer'] == 'SAMSUNG' and d['model'] == 'Galaxy A14' + and d['api'] == '35' and d['abi'] == 'arm64-v8a'] + if len(devices) != 1: + raise RuntimeError('The default device must occur exactly once in the fixed catalog') + return devices + + +def select_configuration(device): + for name, key in [('SELECTEL_DEVICE_MODEL', 'model'), ('SELECTEL_ANDROID_API', 'api'), + ('SELECTEL_DEVICE_ABI', 'abi'), ('SELECTEL_DEVICE_MANUFACTURER', 'manufacturer')]: + os.environ[name] = str(device[key]) + + +def release_all(client, root): + errors = [] + for path in sorted(root.rglob('lease.json')): + try: + release(client, path) + except Exception as error: + errors.append(f'{path}: {error}') + if errors: + raise RuntimeError('; '.join(errors)) + + +def run_matrix(client, state_path, profile): + outcomes = [] + for device in device_matrix(profile): + select_configuration(device) + os.environ['SELECTEL_RESULT_ID'] = device['id'] + path = state_path.parent / device['id'] / 'lease.json' + print('Configuration: ' + device['id'], flush=True) + outcome = {'device': device['id'], 'status': 'passed'} + try: + acquire(client, path) + run_tests(path) + except Exception as error: + outcome.update(status='failed', error=str(error) if isinstance(error, RuntimeError) else type(error).__name__) + finally: + cleanup_error = None + try: + release(client, path) + except Exception as error: + cleanup_error = error + outcome.update(status='failed', cleanup_error=type(error).__name__) + outcomes.append(outcome) + report = ROOT / 'app/build/reports/selectel/matrix.json' + report.parent.mkdir(parents=True, exist_ok=True) + report.write_text(json.dumps(outcomes, indent=2)) + if cleanup_error is not None: + raise RuntimeError('Matrix stopped after cleanup failed; run selectel_release') from cleanup_error + if any(result['status'] != 'passed' for result in outcomes): + raise RuntimeError('Some fixed device configurations failed; see matrix.json') + + def choose_device(available): sdk = os.environ.get('SELECTEL_ANDROID_API', '35') model = os.environ.get('SELECTEL_DEVICE_MODEL', 'Galaxy A14') + abi = os.environ.get('SELECTEL_DEVICE_ABI', 'arm64-v8a') + manufacturer = os.environ.get('SELECTEL_DEVICE_MANUFACTURER', '') choices = [d for d in available if d.get('platform') == 'Android' - and str(d.get('sdk')) == sdk and d.get('abi') == 'arm64-v8a' + and str(d.get('sdk')) == sdk and d.get('abi') == abi + and (not manufacturer or d.get('manufacturer') == manufacturer) and d.get('count', 0) > 0 and (not model or d.get('marketName') == model)] if not choices: - raise RuntimeError(f'No available arm64 Android API {sdk} device; no lease created') + raise RuntimeError(f'Unavailable configuration: {manufacturer} {model}, API {sdk}, {abi}; no lease created') return sorted(choices, key=lambda d: d['marketName'])[0] @@ -289,6 +360,11 @@ def run_tests(path): raise RuntimeError('Device lease is already released') endpoint = state['endpoint'] reports = ROOT / 'app/build/reports/selectel' + result_id = os.environ.get('SELECTEL_RESULT_ID', '') + if result_id: + if not re.fullmatch(r'[a-z0-9-]+', result_id): + raise RuntimeError('Invalid SELECTEL_RESULT_ID') + reports = reports / result_id shutil.rmtree(reports, ignore_errors=True) reports.mkdir(parents=True, exist_ok=True) # Install only on the newly leased device, never a local emulator or personal phone. @@ -323,19 +399,26 @@ def run_tests(path): def main(): parser = argparse.ArgumentParser() - parser.add_argument('action', choices=['probe', 'acquire', 'run', 'release', 'test']) + parser.add_argument('action', choices=['probe', 'acquire', 'run', 'release', 'release-all', 'test', 'matrix', 'matrix-test']) parser.add_argument('--state', type=Path, default=ROOT / '.selectel/lease.json') + parser.add_argument('--profile', choices=['single', 'all'], default='single') args = parser.parse_args() args.state = args.state.resolve() def interrupted(signum, frame): raise KeyboardInterrupt signal.signal(signal.SIGTERM, interrupted) try: - if args.action == 'run': + if args.action == 'matrix': + print(json.dumps({'include': device_matrix(args.profile)})) + elif args.action == 'run': run_tests(args.state) else: client = Client() - if args.action == 'probe': + if args.action == 'release-all': + release_all(client, args.state.parent) + elif args.action == 'matrix-test': + run_matrix(client, args.state, args.profile) + elif args.action == 'probe': existing = client.request('GET', '/v3/devices') model = choose_device(client.request('GET', '/v3/devices/available')) print(f"Access OK; {len(existing)} existing devices untouched; candidate {model['marketName']} API {model['sdk']}") diff --git a/scripts/tests/test_selectel_ui.py b/scripts/tests/test_selectel_ui.py index 8e402b0..56dd535 100644 --- a/scripts/tests/test_selectel_ui.py +++ b/scripts/tests/test_selectel_ui.py @@ -1,5 +1,6 @@ import importlib.util import json +import os from pathlib import Path import tempfile import unittest @@ -96,5 +97,57 @@ def test_successful_instrumentation(self): self.assertTrue(m.instrumentation_report(output, Path(self.temp.name) / 'junit.xml')) +class MatrixTest(unittest.TestCase): + def test_fixed_catalog_needs_no_credentials_or_api(self): + with patch.dict(os.environ, {}, clear=True), patch.object(m, 'Client', side_effect=AssertionError): + devices = m.device_matrix('all') + single = m.device_matrix('single') + self.assertEqual(1, len(single)) + self.assertEqual('Galaxy A14', single[0]['model']) + self.assertIn(single[0], devices) + self.assertEqual(len(devices), len({d['id'] for d in devices})) + self.assertEqual({'arm64-v8a', 'armeabi-v7a'}, {d['abi'] for d in devices}) + + def test_selection_does_not_substitute_another_configuration(self): + device = m.device_matrix('single')[0] + with patch.dict(os.environ): + m.select_configuration(device) + candidate = dict(platform='Android', sdk='35', marketName='Galaxy A14', + manufacturer='SAMSUNG', abi='arm64-v8a', count=1) + self.assertEqual(candidate, m.choose_device([candidate])) + for field, value in [('sdk', '33'), ('abi', 'armeabi-v7a'), + ('manufacturer', 'OTHER'), ('count', 0)]: + with self.assertRaisesRegex(RuntimeError, 'Unavailable configuration'): + m.choose_device([dict(candidate, **{field: value})]) + + def test_cleanup_attempts_every_journal_after_failure(self): + with tempfile.TemporaryDirectory() as root: + paths = [Path(root) / name / 'lease.json' for name in ['a', 'b']] + for path in paths: + m.write_state(path, {}) + with patch.object(m, 'release', side_effect=[RuntimeError('offline'), None]) as release: + with self.assertRaisesRegex(RuntimeError, 'offline'): + m.release_all(None, Path(root)) + self.assertEqual(paths, [call.args[1] for call in release.call_args_list]) + + def test_matrix_continues_test_failures_but_stops_cleanup_failures(self): + devices = m.device_matrix('all')[:2] + for cleanup_fails in [False, True]: + with tempfile.TemporaryDirectory() as root, patch.dict(os.environ), \ + patch.object(m, 'ROOT', Path(root)), \ + patch.object(m, 'device_matrix', return_value=devices), \ + patch.object(m, 'acquire') as acquire, \ + patch.object(m, 'run_tests', side_effect=[RuntimeError('test failed'), None]), \ + patch.object(m, 'release', side_effect=RuntimeError('cleanup failed') if cleanup_fails else None): + with self.assertRaises(RuntimeError): + m.run_matrix(None, Path(root) / '.selectel/lease.json', 'all') + self.assertEqual(1 if cleanup_fails else 2, acquire.call_count) + report = json.loads((Path(root) / 'app/build/reports/selectel/matrix.json').read_text()) + self.assertEqual('failed', report[0]['status']) + if not cleanup_fails: + self.assertEqual('passed', report[1]['status']) + self.assertNotEqual(acquire.call_args_list[0].args[1], acquire.call_args_list[1].args[1]) + + if __name__ == '__main__': unittest.main() From 18e6e650c5fdf90d9e811f792a0b39955eb7d92a Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Mon, 7 Sep 2026 19:42:03 +0300 Subject: [PATCH 05/13] Limit all-device runs to five Android versions and reconnect offline ADB --- config/selectel-devices.json | 442 +----------------------------- docs/en/fastlane.md | 2 +- docs/en/selectel-ui-tests.md | 9 +- docs/ru/fastlane.md | 2 +- docs/ru/selectel-ui-tests.md | 9 +- fastlane/Fastfile | 5 +- scripts/selectel_ui.py | 3 + scripts/tests/test_selectel_ui.py | 17 +- 8 files changed, 39 insertions(+), 450 deletions(-) diff --git a/config/selectel-devices.json b/config/selectel-devices.json index 610f45c..650f159 100644 --- a/config/selectel-devices.json +++ b/config/selectel-devices.json @@ -1,244 +1,6 @@ { "captured_on": "2026-09-07", "devices": [ - { - "id": "google-pixel-7-33-arm64-v8a", - "manufacturer": "GOOGLE", - "model": "Pixel 7", - "api": "33", - "abi": "arm64-v8a" - }, - { - "id": "google-pixel-7-37-arm64-v8a", - "manufacturer": "GOOGLE", - "model": "Pixel 7", - "api": "37", - "abi": "arm64-v8a" - }, - { - "id": "google-pixel-8-36-arm64-v8a", - "manufacturer": "GOOGLE", - "model": "Pixel 8", - "api": "36", - "abi": "arm64-v8a" - }, - { - "id": "google-pixel-8-37-arm64-v8a", - "manufacturer": "GOOGLE", - "model": "Pixel 8", - "api": "37", - "abi": "arm64-v8a" - }, - { - "id": "google-pixel-9-37-arm64-v8a", - "manufacturer": "GOOGLE", - "model": "Pixel 9", - "api": "37", - "abi": "arm64-v8a" - }, - { - "id": "google-pixel-9a-36-arm64-v8a", - "manufacturer": "GOOGLE", - "model": "Pixel 9a", - "api": "36", - "abi": "arm64-v8a" - }, - { - "id": "google-pixel-9a-37-arm64-v8a", - "manufacturer": "GOOGLE", - "model": "Pixel 9a", - "api": "37", - "abi": "arm64-v8a" - }, - { - "id": "honor-90-33-arm64-v8a", - "manufacturer": "HONOR", - "model": "90", - "api": "33", - "abi": "arm64-v8a" - }, - { - "id": "honor-90-34-arm64-v8a", - "manufacturer": "HONOR", - "model": "90", - "api": "34", - "abi": "arm64-v8a" - }, - { - "id": "honor-pad-x8a-34-arm64-v8a", - "manufacturer": "HONOR", - "model": "Pad X8a", - "api": "34", - "abi": "arm64-v8a" - }, - { - "id": "honor-x6c-35-arm64-v8a", - "manufacturer": "HONOR", - "model": "X6c", - "api": "35", - "abi": "arm64-v8a" - }, - { - "id": "honor-x7c-34-arm64-v8a", - "manufacturer": "HONOR", - "model": "X7c", - "api": "34", - "abi": "arm64-v8a" - }, - { - "id": "honor-x8c-35-arm64-v8a", - "manufacturer": "HONOR", - "model": "X8c", - "api": "35", - "abi": "arm64-v8a" - }, - { - "id": "honor-x9c-smart-34-arm64-v8a", - "manufacturer": "HONOR", - "model": "X9c Smart", - "api": "34", - "abi": "arm64-v8a" - }, - { - "id": "huawei-nova-11-31-arm64-v8a", - "manufacturer": "HUAWEI", - "model": "Nova 11", - "api": "31", - "abi": "arm64-v8a" - }, - { - "id": "huawei-nova-13-31-arm64-v8a", - "manufacturer": "HUAWEI", - "model": "Nova 13", - "api": "31", - "abi": "arm64-v8a" - }, - { - "id": "huawei-nova-y63-31-arm64-v8a", - "manufacturer": "HUAWEI", - "model": "Nova Y63", - "api": "31", - "abi": "arm64-v8a" - }, - { - "id": "huawei-pura-80-31-arm64-v8a", - "manufacturer": "HUAWEI", - "model": "Pura 80", - "api": "31", - "abi": "arm64-v8a" - }, - { - "id": "infinix-hot-30-play-33-arm64-v8a", - "manufacturer": "INFINIX", - "model": "Hot 30 Play", - "api": "33", - "abi": "arm64-v8a" - }, - { - "id": "infinix-hot-30i-31-arm64-v8a", - "manufacturer": "INFINIX", - "model": "Hot 30i", - "api": "31", - "abi": "arm64-v8a" - }, - { - "id": "infinix-note-30-33-arm64-v8a", - "manufacturer": "INFINIX", - "model": "Note 30", - "api": "33", - "abi": "arm64-v8a" - }, - { - "id": "infinix-note-30i-33-arm64-v8a", - "manufacturer": "INFINIX", - "model": "Note 30i", - "api": "33", - "abi": "arm64-v8a" - }, - { - "id": "infinix-zero-40-34-arm64-v8a", - "manufacturer": "INFINIX", - "model": "Zero 40", - "api": "34", - "abi": "arm64-v8a" - }, - { - "id": "realme-11-33-arm64-v8a", - "manufacturer": "REALME", - "model": "11", - "api": "33", - "abi": "arm64-v8a" - }, - { - "id": "realme-11-pro-33-arm64-v8a", - "manufacturer": "REALME", - "model": "11 Pro+", - "api": "33", - "abi": "arm64-v8a" - }, - { - "id": "realme-11-pro-35-arm64-v8a", - "manufacturer": "REALME", - "model": "11 Pro+", - "api": "35", - "abi": "arm64-v8a" - }, - { - "id": "realme-c30s-31-armeabi-v7a", - "manufacturer": "REALME", - "model": "C30s", - "api": "31", - "abi": "armeabi-v7a" - }, - { - "id": "realme-c53-33-arm64-v8a", - "manufacturer": "REALME", - "model": "C53", - "api": "33", - "abi": "arm64-v8a" - }, - { - "id": "realme-c53-35-arm64-v8a", - "manufacturer": "REALME", - "model": "C53", - "api": "35", - "abi": "arm64-v8a" - }, - { - "id": "realme-c55-33-arm64-v8a", - "manufacturer": "REALME", - "model": "C55", - "api": "33", - "abi": "arm64-v8a" - }, - { - "id": "realme-c55-35-arm64-v8a", - "manufacturer": "REALME", - "model": "C55", - "api": "35", - "abi": "arm64-v8a" - }, - { - "id": "realme-gt3-34-arm64-v8a", - "manufacturer": "REALME", - "model": "GT3", - "api": "34", - "abi": "arm64-v8a" - }, - { - "id": "realme-pad-10-4-30-arm64-v8a", - "manufacturer": "REALME", - "model": "Pad 10.4", - "api": "30", - "abi": "arm64-v8a" - }, - { - "id": "realme-re58bc-33-arm64-v8a", - "manufacturer": "REALME", - "model": "RE58BC", - "api": "33", - "abi": "arm64-v8a" - }, { "id": "samsung-galaxy-a03-30-arm64-v8a", "manufacturer": "SAMSUNG", @@ -246,34 +8,6 @@ "api": "30", "abi": "arm64-v8a" }, - { - "id": "samsung-galaxy-a03-31-arm64-v8a", - "manufacturer": "SAMSUNG", - "model": "Galaxy A03", - "api": "31", - "abi": "arm64-v8a" - }, - { - "id": "samsung-galaxy-a03-33-arm64-v8a", - "manufacturer": "SAMSUNG", - "model": "Galaxy A03", - "api": "33", - "abi": "arm64-v8a" - }, - { - "id": "samsung-galaxy-a05-34-arm64-v8a", - "manufacturer": "SAMSUNG", - "model": "Galaxy A05", - "api": "34", - "abi": "arm64-v8a" - }, - { - "id": "samsung-galaxy-a13-34-armeabi-v7a", - "manufacturer": "SAMSUNG", - "model": "Galaxy A13", - "api": "34", - "abi": "armeabi-v7a" - }, { "id": "samsung-galaxy-a14-33-arm64-v8a", "manufacturer": "SAMSUNG", @@ -288,48 +22,6 @@ "api": "35", "abi": "arm64-v8a" }, - { - "id": "samsung-galaxy-a15-34-arm64-v8a", - "manufacturer": "SAMSUNG", - "model": "Galaxy A15", - "api": "34", - "abi": "arm64-v8a" - }, - { - "id": "samsung-galaxy-a24-33-arm64-v8a", - "manufacturer": "SAMSUNG", - "model": "Galaxy A24", - "api": "33", - "abi": "arm64-v8a" - }, - { - "id": "samsung-galaxy-a24-34-arm64-v8a", - "manufacturer": "SAMSUNG", - "model": "Galaxy A24", - "api": "34", - "abi": "arm64-v8a" - }, - { - "id": "samsung-galaxy-a34-5g-33-arm64-v8a", - "manufacturer": "SAMSUNG", - "model": "Galaxy A34 5G", - "api": "33", - "abi": "arm64-v8a" - }, - { - "id": "samsung-galaxy-a34-5g-36-arm64-v8a", - "manufacturer": "SAMSUNG", - "model": "Galaxy A34 5G", - "api": "36", - "abi": "arm64-v8a" - }, - { - "id": "samsung-galaxy-a35-34-arm64-v8a", - "manufacturer": "SAMSUNG", - "model": "Galaxy A35", - "api": "34", - "abi": "arm64-v8a" - }, { "id": "samsung-galaxy-a35-36-arm64-v8a", "manufacturer": "SAMSUNG", @@ -338,136 +30,10 @@ "abi": "arm64-v8a" }, { - "id": "samsung-galaxy-a51-33-arm64-v8a", - "manufacturer": "SAMSUNG", - "model": "Galaxy A51", - "api": "33", - "abi": "arm64-v8a" - }, - { - "id": "samsung-galaxy-s23-34-arm64-v8a", - "manufacturer": "SAMSUNG", - "model": "Galaxy S23", - "api": "34", - "abi": "arm64-v8a" - }, - { - "id": "samsung-galaxy-s23-36-arm64-v8a", - "manufacturer": "SAMSUNG", - "model": "Galaxy S23", - "api": "36", - "abi": "arm64-v8a" - }, - { - "id": "samsung-galaxy-s24-34-arm64-v8a", - "manufacturer": "SAMSUNG", - "model": "Galaxy S24", - "api": "34", - "abi": "arm64-v8a" - }, - { - "id": "samsung-galaxy-s24-36-arm64-v8a", - "manufacturer": "SAMSUNG", - "model": "Galaxy S24", - "api": "36", - "abi": "arm64-v8a" - }, - { - "id": "samsung-galaxy-s25-fe-36-arm64-v8a", - "manufacturer": "SAMSUNG", - "model": "Galaxy S25 FE", - "api": "36", - "abi": "arm64-v8a" - }, - { - "id": "samsung-galaxy-tab-a9-34-arm64-v8a", - "manufacturer": "SAMSUNG", - "model": "Galaxy Tab A9", - "api": "34", - "abi": "arm64-v8a" - }, - { - "id": "tecno-camon-20-pro-33-arm64-v8a", - "manufacturer": "TECNO", - "model": "Camon 20 Pro", - "api": "33", - "abi": "arm64-v8a" - }, - { - "id": "tecno-camon-20-pro-34-arm64-v8a", - "manufacturer": "TECNO", - "model": "Camon 20 Pro", - "api": "34", - "abi": "arm64-v8a" - }, - { - "id": "tecno-phantom-v-fold-33-arm64-v8a", - "manufacturer": "TECNO", - "model": "PHANTOM V Fold", - "api": "33", - "abi": "arm64-v8a" - }, - { - "id": "tecno-phantom-x2-5g-31-arm64-v8a", - "manufacturer": "TECNO", - "model": "Phantom X2 5G", - "api": "31", - "abi": "arm64-v8a" - }, - { - "id": "tecno-pova-5-33-arm64-v8a", - "manufacturer": "TECNO", - "model": "Pova 5", - "api": "33", - "abi": "arm64-v8a" - }, - { - "id": "tecno-pova-neo-3-33-arm64-v8a", - "manufacturer": "TECNO", - "model": "Pova Neo 3", - "api": "33", - "abi": "arm64-v8a" - }, - { - "id": "tecno-spark-10c-31-arm64-v8a", - "manufacturer": "TECNO", - "model": "Spark 10C", - "api": "31", - "abi": "arm64-v8a" - }, - { - "id": "tecno-spark-20-pro-33-arm64-v8a", - "manufacturer": "TECNO", - "model": "Spark 20 Pro", - "api": "33", - "abi": "arm64-v8a" - }, - { - "id": "tecno-spark-20c-33-arm64-v8a", - "manufacturer": "TECNO", - "model": "Spark 20c", - "api": "33", - "abi": "arm64-v8a" - }, - { - "id": "tecno-spark-30c-34-arm64-v8a", - "manufacturer": "TECNO", - "model": "Spark 30C", - "api": "34", - "abi": "arm64-v8a" - }, - { - "id": "tecno-spark-go-1-34-arm64-v8a", - "manufacturer": "TECNO", - "model": "Spark Go 1", - "api": "34", - "abi": "arm64-v8a" - }, - { - "id": "tecno-spark-go-2024-33-arm64-v8a", - "manufacturer": "TECNO", - "model": "Spark Go 2024", - "api": "33", + "id": "google-pixel-8-37-arm64-v8a", + "manufacturer": "GOOGLE", + "model": "Pixel 8", + "api": "37", "abi": "arm64-v8a" } ] diff --git a/docs/en/fastlane.md b/docs/en/fastlane.md index ad69a0a..58aa26f 100644 --- a/docs/en/fastlane.md +++ b/docs/en/fastlane.md @@ -42,7 +42,7 @@ That command lists the lanes available in the checked-out version of the project | `bundle exec fastlane android release_artifacts` | Builds and verifies the signed release APKs, AAB, native debug symbols, and `SHA256SUMS` in `dist/release`. | | `bundle exec fastlane android selectel_contract_tests` | Tests lease cleanup and instrumentation parsing without renting a device. | | `bundle exec fastlane android ui_test_artifacts` | Runs Selectel contract tests and builds debug application and instrumentation APKs. | -| `bundle exec fastlane android ui_test_artifacts profile:all` | Builds a universal APK for every fixed device configuration. | +| `bundle exec fastlane android ui_test_artifacts profile:all` | Builds an APK supporting every fixed device configuration. | | `bundle exec fastlane android selectel_probe` | Checks access and availability without renting. | | `bundle exec fastlane android selectel_acquire` | Rents one device and records its lease; requires prebuilt APKs. | | `bundle exec fastlane android selectel_run` | Runs tests on the device from the lease journal. | diff --git a/docs/en/selectel-ui-tests.md b/docs/en/selectel-ui-tests.md index 93f2ef8..4ef5697 100644 --- a/docs/en/selectel-ui-tests.md +++ b/docs/en/selectel-ui-tests.md @@ -6,11 +6,11 @@ Run workflow. Select `single` or `all`. Pushes do not rent devices. The aggregat requires a new manual run. Native/JVM/lint checks still run automatically. The required check fails if the build or any selected device fails, including unavailable configurations. -The fixed snapshot in `config/selectel-devices.json`, captured on 2026-09-07, contains **67 Android -model/API/ABI configurations** (65 ARM64 and two ARM32). `single` selects Galaxy A14 / API 35 / ARM64; +The fixed snapshot in `config/selectel-devices.json`, captured on 2026-09-07, contains **five ARM64 configurations**: Android 11 (Galaxy A03), 13 (Galaxy A14), +15 (Galaxy A14), 16 (Galaxy A35) and 17 (Pixel 8). `single` selects Galaxy A14 / API 35 / ARM64; `all` runs one device per configuration, at most two simultaneously in GitHub. This is not runtime discovery and does not rent every physical copy of a model. Update the snapshot explicitly in a PR. -The full matrix performs 804 test cases and costs more than a single-device run. Availability and +The full matrix performs 60 test cases and costs more than a single-device run. Availability and compatibility of every catalog entry are not guaranteed; there is no substitution on failure. ```sh @@ -56,7 +56,8 @@ bundle exec fastlane android ui_test_artifacts profile:all bundle exec fastlane android selectel_ui_tests profile:all ``` -Use `profile:all` for both commands: its universal APK supports ARM32 as well as ARM64. Locally, +Use `profile:all` for both commands. The selected catalog uses ARM64; if ARM32 is explicitly added +to the snapshot later, the build switches to a universal APK. Locally, devices run sequentially with independent lease journals and report directories named by catalog ID. Test failures do not stop remaining configurations; cleanup failures stop new rentals immediately. The summary is `app/build/reports/selectel/matrix.json`. `selectel_release` retries all journals diff --git a/docs/ru/fastlane.md b/docs/ru/fastlane.md index 0b27bcf..adea0b5 100644 --- a/docs/ru/fastlane.md +++ b/docs/ru/fastlane.md @@ -43,7 +43,7 @@ bundle exec fastlane lanes | `bundle exec fastlane android release_artifacts` | Собирает и проверяет подписанные APK, AAB, native debug symbols и `SHA256SUMS` в `dist/release`. | | `bundle exec fastlane android selectel_contract_tests` | Проверяет освобождение аренды и разбор результатов без аренды устройства. | | `bundle exec fastlane android ui_test_artifacts` | Запускает контрактные тесты Selectel и собирает debug APK приложения и UI-тестов. | -| `bundle exec fastlane android ui_test_artifacts profile:all` | Собирает универсальный APK для всех зафиксированных конфигураций. | +| `bundle exec fastlane android ui_test_artifacts profile:all` | Собирает APK для всех зафиксированных конфигураций. | | `bundle exec fastlane android selectel_probe` | Проверяет доступ и наличие устройств без аренды. | | `bundle exec fastlane android selectel_acquire` | Арендует одно устройство и сохраняет журнал; APK должны быть собраны заранее. | | `bundle exec fastlane android selectel_run` | Запускает тесты на устройстве из журнала аренды. | diff --git a/docs/ru/selectel-ui-tests.md b/docs/ru/selectel-ui-tests.md index 508d0f2..2e62732 100644 --- a/docs/ru/selectel-ui-tests.md +++ b/docs/ru/selectel-ui-tests.md @@ -6,11 +6,11 @@ Workflow `Selectel UI` запускается **только вручную**: A Native/JVM/lint продолжают запускаться автоматически. Итоговая проверка падает при ошибке сборки или любого выбранного устройства, в том числе при недоступности конфигурации. -В `config/selectel-devices.json` зафиксированы **67 конфигураций модель/API/ABI** из каталога на -2026-09-07: 65 ARM64 и две ARM32. `single` выбирает Galaxy A14 / API 35 / ARM64; `all` запускает +В `config/selectel-devices.json` зафиксированы **пять ARM64-конфигураций** из каталога на +2026-09-07: Android 11 (Galaxy A03), 13 (Galaxy A14), 15 (Galaxy A14), 16 (Galaxy A35) и 17 (Pixel 8). `single` выбирает Galaxy A14 / API 35 / ARM64; `all` запускает по одному устройству каждой конфигурации, максимум два одновременно в GitHub. Автоматического обнаружения списка при запуске нет; одинаковые физические экземпляры не дублируются. Список меняется -явно через PR. Полный прогон — 804 тестовых случая и больше расходов, чем одно устройство. +явно через PR. Полный прогон — 60 тестовых случаев и больше расходов, чем одно устройство. Доступность и совместимость всех записей каталога не гарантированы; подмены при ошибке нет. ```sh @@ -56,7 +56,8 @@ bundle exec fastlane android ui_test_artifacts profile:all bundle exec fastlane android selectel_ui_tests profile:all ``` -Для обеих команд нужен `profile:all`: универсальный APK содержит ARM32 и ARM64. Локально устройства +Для обеих команд нужен `profile:all`. Выбранный каталог использует ARM64; если позднее явно добавить +в список ARM32, сборка переключится на универсальный APK. Локально устройства запускаются последовательно; журналы аренды и отчёты разделены по ID из каталога. Ошибки тестов не останавливают остальные конфигурации; ошибка освобождения аренды прекращает новые аренды. Сводка — `app/build/reports/selectel/matrix.json`. `selectel_release` повторяет освобождение для всех diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 010b3d2..32765a9 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -83,7 +83,10 @@ platform :android do lane :ui_test_artifacts do |options| profile = options[:profile] || "single" UI.user_error!("profile must be single or all") unless %w[single all].include?(profile) - variant = profile == "all" ? "universal" : "arm64-v8a" + require "json" + devices = JSON.parse(File.read(File.join(project_root, "config", "selectel-devices.json"))).fetch("devices") + needs_arm32 = profile == "all" && devices.any? { |device| device.fetch("abi") == "armeabi-v7a" } + variant = needs_arm32 ? "universal" : "arm64-v8a" ensure_no_release_signing selectel_contract_tests native_library diff --git a/scripts/selectel_ui.py b/scripts/selectel_ui.py index c219139..30af786 100644 --- a/scripts/selectel_ui.py +++ b/scripts/selectel_ui.py @@ -116,6 +116,9 @@ def connect_device(path, endpoint): if status == 'device': return diagnostic = (connected + ' ' + status).replace(endpoint, '').replace('\n', ' ')[:240] + # An offline transport makes repeated connect calls return 'already connected'. + # Drop only this leased endpoint so the next attempt performs a new handshake. + adb(path, 'disconnect', endpoint, timeout=10, check=False) except subprocess.TimeoutExpired: pass time.sleep(3) diff --git a/scripts/tests/test_selectel_ui.py b/scripts/tests/test_selectel_ui.py index 56dd535..0533fa7 100644 --- a/scripts/tests/test_selectel_ui.py +++ b/scripts/tests/test_selectel_ui.py @@ -97,6 +97,20 @@ def test_successful_instrumentation(self): self.assertTrue(m.instrumentation_report(output, Path(self.temp.name) / 'junit.xml')) +class AdbConnectionTest(unittest.TestCase): + def test_offline_transport_is_reconnected_without_renting_again(self): + with patch.object(m, 'adb', side_effect=['connected', 'error: device offline', '', + 'connected', 'device']) as adb, \ + patch.object(m.time, 'sleep'), patch.object(m, 'Client', side_effect=AssertionError): + m.connect_device(Path('/tmp/test-lease.json'), 'example.test:1234') + commands = [call.args[1:] for call in adb.call_args_list] + self.assertEqual([('connect', 'example.test:1234'), + ('-s', 'example.test:1234', 'get-state'), + ('disconnect', 'example.test:1234'), + ('connect', 'example.test:1234'), + ('-s', 'example.test:1234', 'get-state')], commands) + + class MatrixTest(unittest.TestCase): def test_fixed_catalog_needs_no_credentials_or_api(self): with patch.dict(os.environ, {}, clear=True), patch.object(m, 'Client', side_effect=AssertionError): @@ -106,7 +120,8 @@ def test_fixed_catalog_needs_no_credentials_or_api(self): self.assertEqual('Galaxy A14', single[0]['model']) self.assertIn(single[0], devices) self.assertEqual(len(devices), len({d['id'] for d in devices})) - self.assertEqual({'arm64-v8a', 'armeabi-v7a'}, {d['abi'] for d in devices}) + self.assertEqual({'arm64-v8a'}, {d['abi'] for d in devices}) + self.assertEqual(['30', '33', '35', '36', '37'], sorted(d['api'] for d in devices)) def test_selection_does_not_substitute_another_configuration(self): device = m.device_matrix('single')[0] From 7efe76a16e21cff513fd7a2d35cc08ded68af999 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Mon, 7 Sep 2026 20:02:36 +0300 Subject: [PATCH 06/13] Add interactive PR test launcher and enforce Python formatting in CI --- .github/workflows/ci.yml | 13 +- .gitignore | 2 + AGENTS.md | 4 + README.md | 1 + docs/en/fastlane.md | 17 + docs/en/selectel-ui-tests.md | 22 ++ docs/ru/fastlane.md | 17 + docs/ru/selectel-ui-tests.md | 22 ++ fastlane/Fastfile | 19 + fastlane/README.md | 2 + pyproject.toml | 8 + requirements-dev.txt | 2 + scripts/github_actions.py | 224 +++++++++++ scripts/selectel_ui.py | 569 ++++++++++++++++++--------- scripts/tests/test_github_actions.py | 157 ++++++++ scripts/tests/test_selectel_ui.py | 221 +++++++---- 16 files changed, 1032 insertions(+), 268 deletions(-) create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt create mode 100644 scripts/github_actions.py create mode 100644 scripts/tests/test_github_actions.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35bbaf9..cb3b298 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,8 +34,17 @@ jobs: ruby-version: "3.4.10" bundler-cache: true - - name: Test Selectel lifecycle contracts without a device - run: bundle exec fastlane android selectel_contract_tests + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: requirements-dev.txt + + - name: Install Python formatting tools + run: python -m pip install -r requirements-dev.txt + + - name: Check Python formatting and runner contracts without a device + run: bundle exec fastlane android python_checks - name: Run native tests with the race detector run: bundle exec fastlane android native_tests diff --git a/.gitignore b/.gitignore index c11abca..c05ad8e 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,5 @@ app/libs/megaproxy-sources.jar .selectel/ __pycache__/ + +.venv/ diff --git a/AGENTS.md b/AGENTS.md index 0478aac..11c3c2e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,10 @@ branch names, credentials, signing material, or other secrets. - Direct scripts and Gradle tasks may remain implementation details behind Fastlane lanes, but documentation and CI should normally expose the Fastlane commands. +- Python scripts use the standard library at runtime. Format them with pinned Black and isort + (`requirements-dev.txt`, `pyproject.toml`) through the `python_format` Fastlane lane; + `python_checks` validates formatting and runner contracts in CI. + ## CI and artifacts - Pull requests must run native tests and Android JVM unit/lint/build checks. Do not require an diff --git a/README.md b/README.md index 4976812..2084b27 100644 --- a/README.md +++ b/README.md @@ -283,6 +283,7 @@ UI tests run manually on Selectel before merging, with a required check for the Choose one device or the fixed full catalog; pushes only trigger native/JVM/lint checks. See the [English setup](docs/en/selectel-ui-tests.md) or [Russian setup](docs/ru/selectel-ui-tests.md) for local credentials, PR integration, reports, and paid-device cleanup. +Use `python3 scripts/github_actions.py` to choose a PR and launch UI tests or rerun CI interactively. ### Emulator diff --git a/docs/en/fastlane.md b/docs/en/fastlane.md index 58aa26f..82ec9e5 100644 --- a/docs/en/fastlane.md +++ b/docs/en/fastlane.md @@ -35,6 +35,8 @@ That command lists the lanes available in the checked-out version of the project | Command | Result | | --- | --- | +| `bundle exec fastlane android python_format` | Formats Python scripts and sorts imports with Black/isort. | +| `bundle exec fastlane android python_checks` | Checks Python style and runs all runner/launcher contract tests without devices. | | `bundle exec fastlane android native_tests` | Runs all Go tests with the race detector. | | `bundle exec fastlane android android_checks` | Builds the native AAR, runs Android unit tests and lint, builds a debug APK, then builds and verifies an unsigned release APK. It rejects any release-signing environment variables. | | `bundle exec fastlane android test` | Runs `native_tests` and `android_checks`; this is the normal pre-commit command. | @@ -84,3 +86,18 @@ committing the lock file and using `bundle exec fastlane` locally and in CI. [Русская версия](../ru/fastlane.md) [Selectel UI testing setup](selectel-ui-tests.md). + +## Python development tools + +The scripts themselves use Python's standard library. Install the pinned formatting tools only for +development; Black and isort settings live in `pyproject.toml` and CI checks them on every PR. + +```sh +python3 -m venv .venv +. .venv/bin/activate +python -m pip install -r requirements-dev.txt +bundle exec fastlane android python_format +bundle exec fastlane android python_checks +``` + +Set `PYTHON` to override the formatter interpreter. diff --git a/docs/en/selectel-ui-tests.md b/docs/en/selectel-ui-tests.md index 4ef5697..c6501fb 100644 --- a/docs/en/selectel-ui-tests.md +++ b/docs/en/selectel-ui-tests.md @@ -23,6 +23,28 @@ No hosted emulator or release signing is used. The ruleset for `main` requires ` from GitHub Actions alongside the existing native/Android checks. Workflow dispatch becomes available in the Actions UI after the workflow is merged into the default branch. +## Interactive GitHub launcher + +```sh +python3 scripts/github_actions.py +# Preview the selection without starting jobs or renting devices: +python3 scripts/github_actions.py --dry-run +``` + +Choose an open PR and then UI `single`, UI `all`, or a rerun of ordinary CI. The launcher shows the +branch inputs and current commit, asks before sending the request, and refuses to proceed if the PR +changed during selection. UI dispatch resolves the branch head when GitHub accepts it; avoid pushing +while launching. CI reruns target only an existing, completed run for the current PR commit. Fork PRs +are excluded. Release workflows are not offered. `q` or Ctrl+C cancels. + +Only Python's standard library is required. Authentication uses `GH_TOKEN`, then `GITHUB_TOKEN`, then +an existing `gh auth` login if the optional GitHub CLI is installed. Otherwise the token is requested +with hidden input and is never saved. A fine-grained token needs repository **Actions: write** and +**Pull requests: read**. No local Selectel secrets or Android toolchain are needed for this launcher. +Use `--repo OWNER/REPO` to override the default repository. Network failures are not retried: if a +launch response is lost, check Actions before launching again. The output links to the workflow or CI +run. The launcher can be used for our registered workflow before the web Run workflow button appears. + ## Setup Create a Selectel service user with `mobile_farm.admin` scoped to the chosen project. This role is diff --git a/docs/ru/fastlane.md b/docs/ru/fastlane.md index adea0b5..4f662c6 100644 --- a/docs/ru/fastlane.md +++ b/docs/ru/fastlane.md @@ -36,6 +36,8 @@ bundle exec fastlane lanes | Команда | Результат | | --- | --- | +| `bundle exec fastlane android python_format` | Форматирует Python-скрипты и сортирует импорты через Black/isort. | +| `bundle exec fastlane android python_checks` | Проверяет стиль Python и контрактные тесты скриптов без устройств. | | `bundle exec fastlane android native_tests` | Запускает все Go-тесты с race detector. | | `bundle exec fastlane android android_checks` | Собирает native AAR, запускает Android unit-тесты и lint, собирает debug APK, затем собирает и проверяет unsigned release APK. Команда отклоняет переменные release-подписи. | | `bundle exec fastlane android test` | Выполняет `native_tests` и `android_checks`; основная команда перед коммитом. | @@ -86,3 +88,18 @@ bundle exec fastlane android test [English version](../en/fastlane.md) [Настройка UI-тестов Selectel](selectel-ui-tests.md). + +## Инструменты разработки Python + +Сами скрипты используют стандартную библиотеку Python. Зафиксированные форматтеры нужны только для +разработки; настройки Black и isort находятся в `pyproject.toml`, CI проверяет их в каждом PR. + +```sh +python3 -m venv .venv +. .venv/bin/activate +python -m pip install -r requirements-dev.txt +bundle exec fastlane android python_format +bundle exec fastlane android python_checks +``` + +Переменная `PYTHON` переопределяет интерпретатор форматтеров. diff --git a/docs/ru/selectel-ui-tests.md b/docs/ru/selectel-ui-tests.md index 2e62732..fc2920b 100644 --- a/docs/ru/selectel-ui-tests.md +++ b/docs/ru/selectel-ui-tests.md @@ -23,6 +23,28 @@ gh workflow run selectel-ui.yml --ref YOUR_PR_BRANCH -f devices=single GitHub Actions вместе с существующими native/Android проверками. Кнопка ручного запуска появится в Actions после попадания workflow в основную ветку. +## Интерактивный запуск GitHub Actions + +```sh +python3 scripts/github_actions.py +# Проверить выбор без запуска заданий и аренды устройств: +python3 scripts/github_actions.py --dry-run +``` + +Выберите открытый PR, затем UI `single`, UI `all` или повтор обычного CI. Скрипт показывает параметры +ветки и текущий коммит, спрашивает перед отправкой и прекращает запуск, если PR изменился за время +выбора. При UI-запуске GitHub берёт коммит ветки в момент принятия запроса; не делайте пуш одновременно +с запуском. Повтор CI использует только существующий завершённый прогон текущего коммита PR. +PR из форков исключены. Release-workflow в меню нет. `q` или Ctrl+C отменяет операцию. + +Нужна только стандартная библиотека Python. Авторизация: `GH_TOKEN`, затем `GITHUB_TOKEN`, затем +существующий вход `gh auth`, если необязательный GitHub CLI установлен. Иначе скрипт скрыто запросит +токен и не сохранит его. Fine-grained токену нужны права репозитория **Actions: write** и +**Pull requests: read**. Локальные секреты Selectel и Android toolchain для этого скрипта не нужны. +Другой репозиторий задаётся через `--repo OWNER/REPO`. При сетевой ошибке повторной отправки нет: +если ответ о запуске потерялся, сначала проверьте Actions. В конце выводится ссылка на workflow или +прогон CI. Для нашего зарегистрированного workflow скрипт работает и до появления кнопки Run workflow. + ## Настройка Создайте сервисного пользователя Selectel с ролью `mobile_farm.admin` в выбранном проекте. Роль diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 32765a9..7588ef5 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -79,6 +79,25 @@ platform :android do sh("python3", "-m", "unittest", "discover", "-s", File.join(project_root, "scripts", "tests")) end + desc "Format Python scripts with isort and Black" + lane :python_format do + python = ENV.fetch("PYTHON", "python3") + Dir.chdir(project_root) do + sh(python, "-m", "isort", "scripts") + sh(python, "-m", "black", "scripts") + end + end + + desc "Check Python formatting, import order, and runner contracts" + lane :python_checks do + python = ENV.fetch("PYTHON", "python3") + Dir.chdir(project_root) do + sh(python, "-m", "isort", "--check-only", "scripts") + sh(python, "-m", "black", "--check", "scripts") + end + selectel_contract_tests + end + desc "Build debug application and instrumentation APKs before renting a device" lane :ui_test_artifacts do |options| profile = options[:profile] || "single" diff --git a/fastlane/README.md b/fastlane/README.md index 7ae71ff..3ba47a3 100644 --- a/fastlane/README.md +++ b/fastlane/README.md @@ -5,6 +5,8 @@ Fastlane is the supported entry point for tests and build artifacts. Install Rub | Command | Purpose | | --- | --- | +| `bundle exec fastlane android python_format` | Format Python with Black and isort | +| `bundle exec fastlane android python_checks` | Check Python style and runner contracts | | `bundle exec fastlane android native_tests` | Run Go tests with the race detector | | `bundle exec fastlane android android_checks` | Run Android tests and lint, build a debug APK and release APK, and prove that the release APK is unsigned | | `bundle exec fastlane android test` | Run all native and Android checks | diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..df37bfe --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,8 @@ +[tool.black] +line-length = 88 +target-version = ["py310"] + +[tool.isort] +profile = "black" +line_length = 88 +py_version = 310 diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..80e7482 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +black==26.5.1 +isort==9.0.1 diff --git a/scripts/github_actions.py b/scripts/github_actions.py new file mode 100644 index 0000000..74bcb6b --- /dev/null +++ b/scripts/github_actions.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +"""Choose a PR and launch its tests using the GitHub API and Python's stdlib.""" + +import argparse +import getpass +import json +import os +import re +import shutil +import subprocess +import sys +import urllib.error +import urllib.parse +import urllib.request + +REPOSITORY = "andre487/AndroidMegaProxy" +MODES = [ + ("single", "Selectel UI: one Galaxy A14 (Android 15, paid rental)"), + ("all", "Selectel UI: Android 11, 13, 15, 16, 17 (five paid rentals)"), + ("ci", "Re-run CI: native Go + Android/JVM/lint/build (no device rental)"), +] + + +def github_token(): + for name in ("GH_TOKEN", "GITHUB_TOKEN"): + if os.environ.get(name, "").strip(): + return os.environ[name].strip() + if shutil.which("gh"): + result = subprocess.run( + ["gh", "auth", "token", "--hostname", "github.com"], + capture_output=True, + text=True, + timeout=15, + ) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip() + token = getpass.getpass("GitHub token (not saved): ").strip() + if not token: + raise RuntimeError( + "A GitHub token is required (Actions: write, Pull requests: read)." + ) + return token + + +class GitHub: + def __init__(self, repository, token): + if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository): + raise RuntimeError("Repository must be OWNER/REPO.") + self.repository = repository + self.token = token + + def request(self, method, path, body=None): + request = urllib.request.Request( + "https://api.github.com/repos/" + self.repository + path, + method=method, + data=json.dumps(body).encode() if body is not None else None, + headers={ + "Authorization": "Bearer " + self.token, + "Accept": "application/vnd.github+json", + "Content-Type": "application/json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "MegaProxy-Actions", + }, + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + data = response.read() + return json.loads(data) if data else None + except urllib.error.HTTPError as error: + # Do not echo server responses, headers, or the token. + hints = { + 401: "Check GitHub authentication.", + 403: "Check Actions write permission and rate limits.", + 404: "The PR/workflow may be unavailable to this token.", + 422: "Check workflow_dispatch support on the selected branch or rerun eligibility.", + } + suffix = ( + " Launch outcome is unknown; check Actions before retrying." + if method == "POST" and error.code >= 500 + else "" + ) + raise RuntimeError( + f'GitHub HTTP {error.code}. {hints.get(error.code, "Request failed.")}' + + suffix + ) from None + except (urllib.error.URLError, TimeoutError): + suffix = ( + " Launch outcome is unknown; check Actions before retrying." + if method == "POST" + else "" + ) + raise RuntimeError("GitHub network request failed." + suffix) from None + + def open_prs(self): + result = [] + page = 1 + while True: + items = self.request("GET", f"/pulls?state=open&per_page=100&page={page}") + result.extend(items) + if len(items) < 100: + return result + page += 1 + + +def same_repository(pr, repository): + return (pr["head"].get("repo") or {}).get( + "full_name", "" + ).lower() == repository.lower() + + +def choose(title, options): + print("\n" + title) + for index, (_, label) in enumerate(options, 1): + # PR titles and branch names are untrusted terminal text. + print(f" {index}. " + "".join(c if c.isprintable() else " " for c in label)) + while True: + value = input("Choose a number (q to cancel): ").strip() + if value.lower() == "q": + raise KeyboardInterrupt + if value.isdecimal() and 1 <= int(value) <= len(options): + return options[int(value) - 1][0] + print("Enter a number from the list.") + + +def plan_run(client, pr, mode): + if pr["state"] != "open" or not same_repository(pr, client.repository): + raise RuntimeError( + "Select an open PR from a branch in this repository; forks are unsupported." + ) + if mode in ("single", "all"): + return ( + "/actions/workflows/selectel-ui.yml/dispatches", + {"ref": pr["head"]["ref"], "inputs": {"devices": mode}}, + f"https://github.com/{client.repository}/actions/workflows/selectel-ui.yml", + ) + if mode != "ci": + raise RuntimeError("Unknown test selection.") + query = urllib.parse.urlencode( + {"head_sha": pr["head"]["sha"], "event": "pull_request", "per_page": 100} + ) + runs = client.request("GET", "/actions/workflows/ci.yml/runs?" + query)[ + "workflow_runs" + ] + runs = [ + run + for run in runs + if run["head_sha"] == pr["head"]["sha"] + and run["head_branch"] == pr["head"]["ref"] + and (run.get("head_repository") or {}).get("full_name", "").lower() + == client.repository.lower() + ] + if not runs: + raise RuntimeError( + "No CI run exists for this PR commit yet. CI starts automatically on pushes." + ) + run = max(runs, key=lambda item: item["id"]) + if run["status"] != "completed": + raise RuntimeError("CI is already queued or running: " + run["html_url"]) + return f'/actions/runs/{run["id"]}/rerun', {}, run["html_url"] + + +def launch(client, pr, mode, dry_run=False): + path, body, url = plan_run(client, pr, mode) + print(f'\nPR #{pr["number"]}, commit {pr["head"]["sha"][:12]}') + print(json.dumps(body, ensure_ascii=True)) + if dry_run: + print("Dry run: no workflow started.\n" + url) + return + if input("Start this run? [y/N]: ").strip().lower() != "y": + print("Cancelled.") + return + current = client.request("GET", f'/pulls/{pr["number"]}') + if ( + current["state"] != "open" + or not same_repository(current, client.repository) + or current["head"]["sha"] != pr["head"]["sha"] + or current["head"]["ref"] != pr["head"]["ref"] + ): + raise RuntimeError( + "The PR changed while choosing. Start again to review its current commit." + ) + # Dispatch uses a branch, so GitHub resolves its head at acceptance time. Never retry POST. + response = client.request("POST", path, body) + print("Launch accepted.\n" + ((response or {}).get("html_url") or url)) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", default=REPOSITORY, help="GitHub OWNER/REPO") + parser.add_argument( + "--dry-run", action="store_true", help="Preview without starting workflows" + ) + args = parser.parse_args() + try: + client = GitHub(args.repo, github_token()) + prs = [pr for pr in client.open_prs() if same_repository(pr, args.repo)] + if not prs: + print("No open PRs from repository branches.") + return 0 + number = choose( + "Open pull requests", + [(pr["number"], f'#{pr["number"]} {pr["title"]}') for pr in prs], + ) + pr = client.request("GET", f"/pulls/{number}") + mode = choose("Tests to run", MODES) + launch(client, pr, mode, args.dry_run) + return 0 + except (KeyboardInterrupt, EOFError): + print("\nCancelled.") + return 130 + except (RuntimeError, subprocess.TimeoutExpired) as error: + print( + ( + str(error) + if isinstance(error, RuntimeError) + else "GitHub CLI authentication timed out." + ), + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/selectel_ui.py b/scripts/selectel_ui.py index 30af786..8157754 100644 --- a/scripts/selectel_ui.py +++ b/scripts/selectel_ui.py @@ -4,10 +4,10 @@ State contains device/slot IDs, never credentials. Only devices created by this run are eligible for release. APKs must be built before acquiring a paid device. """ + import argparse import json import os -from pathlib import Path import re import shutil import signal @@ -18,44 +18,67 @@ import urllib.parse import urllib.request import xml.etree.ElementTree as ET +from pathlib import Path ROOT = Path(__file__).resolve().parents[1] -API = 'https://api.selectel.ru/mobfarm/api' -AUTH = 'https://cloud.api.selcloud.ru/identity/v3/auth/tokens' -APP = 'net.megaproxy487' -RUNNER = APP + '.test/androidx.test.runner.AndroidJUnitRunner' +API = "https://api.selectel.ru/mobfarm/api" +AUTH = "https://cloud.api.selcloud.ru/identity/v3/auth/tokens" +APP = "net.megaproxy487" +RUNNER = APP + ".test/androidx.test.runner.AndroidJUnitRunner" class ApiError(RuntimeError): def __init__(self, method, path, status): - super().__init__(f'Selectel {method} {path}: HTTP {status}') + super().__init__(f"Selectel {method} {path}: HTTP {status}") self.status = status class Client: def __init__(self): - names = ['SELECTEL_USERNAME', 'SELECTEL_PASSWORD', 'SELECTEL_ACCOUNT_ID', 'SELECTEL_PROJECT_ID'] + names = [ + "SELECTEL_USERNAME", + "SELECTEL_PASSWORD", + "SELECTEL_ACCOUNT_ID", + "SELECTEL_PROJECT_ID", + ] if any(not os.environ.get(n) for n in names): - raise RuntimeError('Required variables: ' + ', '.join(names)) - self.project = os.environ['SELECTEL_PROJECT_ID'] - payload = {'auth': {'identity': {'methods': ['password'], 'password': {'user': { - 'name': os.environ['SELECTEL_USERNAME'], 'password': os.environ['SELECTEL_PASSWORD'], - 'domain': {'name': os.environ['SELECTEL_ACCOUNT_ID']}}}}, - 'scope': {'project': {'id': self.project}}}} - request = urllib.request.Request(AUTH, data=json.dumps(payload).encode(), - headers={'Content-Type': 'application/json'}) + raise RuntimeError("Required variables: " + ", ".join(names)) + self.project = os.environ["SELECTEL_PROJECT_ID"] + payload = { + "auth": { + "identity": { + "methods": ["password"], + "password": { + "user": { + "name": os.environ["SELECTEL_USERNAME"], + "password": os.environ["SELECTEL_PASSWORD"], + "domain": {"name": os.environ["SELECTEL_ACCOUNT_ID"]}, + } + }, + }, + "scope": {"project": {"id": self.project}}, + } + } + request = urllib.request.Request( + AUTH, + data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json"}, + ) try: with urllib.request.urlopen(request, timeout=30) as response: - self.token = response.headers['X-Subject-Token'] + self.token = response.headers["X-Subject-Token"] except urllib.error.HTTPError as e: - raise ApiError('POST', 'auth', e.code) from None + raise ApiError("POST", "auth", e.code) from None if not self.token: - raise RuntimeError('Selectel did not return an IAM token') + raise RuntimeError("Selectel did not return an IAM token") def request(self, method, path, body=None): - request = urllib.request.Request(API + path, method=method, + request = urllib.request.Request( + API + path, + method=method, data=None if body is None else json.dumps(body).encode(), - headers={'Content-Type': 'application/json', 'X-Auth-Token': self.token}) + headers={"Content-Type": "application/json", "X-Auth-Token": self.token}, + ) try: with urllib.request.urlopen(request, timeout=30) as response: raw = response.read() @@ -67,7 +90,7 @@ def request(self, method, path, body=None): def write_state(path, state): path.parent.mkdir(parents=True, exist_ok=True) - temp = path.with_suffix('.tmp') + temp = path.with_suffix(".tmp") temp.write_text(json.dumps(state, indent=2)) os.chmod(temp, 0o600) temp.replace(path) @@ -78,356 +101,506 @@ def read_state(path): def adb_path(): - sdk = os.environ.get('ANDROID_HOME') or os.environ.get('ANDROID_SDK_ROOT') - adb = str(Path(sdk) / 'platform-tools/adb') if sdk else shutil.which('adb') + sdk = os.environ.get("ANDROID_HOME") or os.environ.get("ANDROID_SDK_ROOT") + adb = str(Path(sdk) / "platform-tools/adb") if sdk else shutil.which("adb") if not adb or not Path(adb).is_file(): - raise RuntimeError('ADB is required (ANDROID_HOME/platform-tools/adb or PATH)') + raise RuntimeError("ADB is required (ANDROID_HOME/platform-tools/adb or PATH)") return adb def adb_env(state_path): # Isolate keys and the host server from the developer's own devices. - home = state_path.parent / 'adb-home' - (home / '.android').mkdir(parents=True, exist_ok=True) + home = state_path.parent / "adb-home" + (home / ".android").mkdir(parents=True, exist_ok=True) env = os.environ.copy() - env['ANDROID_USER_HOME'] = str(home / '.android') - env['ANDROID_PREFS_ROOT'] = str(home) - env['ADB_VENDOR_KEYS'] = str(home / '.android/adbkey') + env["ANDROID_USER_HOME"] = str(home / ".android") + env["ANDROID_PREFS_ROOT"] = str(home) + env["ADB_VENDOR_KEYS"] = str(home / ".android/adbkey") return env def adb(state_path, *args, timeout=30, check=True, include_stderr=False): - port = str(os.environ.get('SELECTEL_ADB_PORT', '5038')) - result = subprocess.run([adb_path(), '-P', port, *args], env=adb_env(state_path), - capture_output=True, text=True, timeout=timeout) + port = str(os.environ.get("SELECTEL_ADB_PORT", "5038")) + result = subprocess.run( + [adb_path(), "-P", port, *args], + env=adb_env(state_path), + capture_output=True, + text=True, + timeout=timeout, + ) if check and result.returncode: - raise RuntimeError(f'ADB command failed: {args[0]} (exit {result.returncode})') + raise RuntimeError(f"ADB command failed: {args[0]} (exit {result.returncode})") return result.stdout + (result.stderr if include_stderr else "") def connect_device(path, endpoint): - print('Waiting for remote ADB (maximum 90 seconds)', flush=True) + print("Waiting for remote ADB (maximum 90 seconds)", flush=True) deadline = time.monotonic() + 90 - diagnostic = 'no response' + diagnostic = "no response" while time.monotonic() < deadline: try: - connected = adb(path, 'connect', endpoint, timeout=10, check=False, include_stderr=True) - status = adb(path, '-s', endpoint, 'get-state', timeout=10, check=False, include_stderr=True).strip() - if status == 'device': + connected = adb( + path, "connect", endpoint, timeout=10, check=False, include_stderr=True + ) + status = adb( + path, + "-s", + endpoint, + "get-state", + timeout=10, + check=False, + include_stderr=True, + ).strip() + if status == "device": return - diagnostic = (connected + ' ' + status).replace(endpoint, '').replace('\n', ' ')[:240] + diagnostic = ( + (connected + " " + status) + .replace(endpoint, "") + .replace("\n", " ")[:240] + ) # An offline transport makes repeated connect calls return 'already connected'. # Drop only this leased endpoint so the next attempt performs a new handshake. - adb(path, 'disconnect', endpoint, timeout=10, check=False) + adb(path, "disconnect", endpoint, timeout=10, check=False) except subprocess.TimeoutExpired: pass time.sleep(3) - raise RuntimeError('Remote ADB did not connect within 90 seconds: ' + diagnostic) + raise RuntimeError("Remote ADB did not connect within 90 seconds: " + diagnostic) def device_matrix(profile): - if profile not in ('single', 'all'): - raise RuntimeError('Device profile must be single or all') - devices = json.loads((ROOT / 'config/selectel-devices.json').read_text())['devices'] + if profile not in ("single", "all"): + raise RuntimeError("Device profile must be single or all") + devices = json.loads((ROOT / "config/selectel-devices.json").read_text())["devices"] if not devices: - raise RuntimeError('The fixed device catalog is empty') + raise RuntimeError("The fixed device catalog is empty") ids = set() for device in devices: - if not re.fullmatch(r'[a-z0-9-]+', device['id']) or device['id'] in ids: - raise RuntimeError('Invalid or duplicate device matrix ID') - if device['abi'] not in ('arm64-v8a', 'armeabi-v7a') or not str(device['api']).isdigit(): - raise RuntimeError('Unsupported device matrix entry') - ids.add(device['id']) - if profile == 'single': - devices = [d for d in devices if d['manufacturer'] == 'SAMSUNG' and d['model'] == 'Galaxy A14' - and d['api'] == '35' and d['abi'] == 'arm64-v8a'] + if not re.fullmatch(r"[a-z0-9-]+", device["id"]) or device["id"] in ids: + raise RuntimeError("Invalid or duplicate device matrix ID") + if ( + device["abi"] not in ("arm64-v8a", "armeabi-v7a") + or not str(device["api"]).isdigit() + ): + raise RuntimeError("Unsupported device matrix entry") + ids.add(device["id"]) + if profile == "single": + devices = [ + d + for d in devices + if d["manufacturer"] == "SAMSUNG" + and d["model"] == "Galaxy A14" + and d["api"] == "35" + and d["abi"] == "arm64-v8a" + ] if len(devices) != 1: - raise RuntimeError('The default device must occur exactly once in the fixed catalog') + raise RuntimeError( + "The default device must occur exactly once in the fixed catalog" + ) return devices def select_configuration(device): - for name, key in [('SELECTEL_DEVICE_MODEL', 'model'), ('SELECTEL_ANDROID_API', 'api'), - ('SELECTEL_DEVICE_ABI', 'abi'), ('SELECTEL_DEVICE_MANUFACTURER', 'manufacturer')]: + for name, key in [ + ("SELECTEL_DEVICE_MODEL", "model"), + ("SELECTEL_ANDROID_API", "api"), + ("SELECTEL_DEVICE_ABI", "abi"), + ("SELECTEL_DEVICE_MANUFACTURER", "manufacturer"), + ]: os.environ[name] = str(device[key]) def release_all(client, root): errors = [] - for path in sorted(root.rglob('lease.json')): + for path in sorted(root.rglob("lease.json")): try: release(client, path) except Exception as error: - errors.append(f'{path}: {error}') + errors.append(f"{path}: {error}") if errors: - raise RuntimeError('; '.join(errors)) + raise RuntimeError("; ".join(errors)) def run_matrix(client, state_path, profile): outcomes = [] for device in device_matrix(profile): select_configuration(device) - os.environ['SELECTEL_RESULT_ID'] = device['id'] - path = state_path.parent / device['id'] / 'lease.json' - print('Configuration: ' + device['id'], flush=True) - outcome = {'device': device['id'], 'status': 'passed'} + os.environ["SELECTEL_RESULT_ID"] = device["id"] + path = state_path.parent / device["id"] / "lease.json" + print("Configuration: " + device["id"], flush=True) + outcome = {"device": device["id"], "status": "passed"} try: acquire(client, path) run_tests(path) except Exception as error: - outcome.update(status='failed', error=str(error) if isinstance(error, RuntimeError) else type(error).__name__) + outcome.update( + status="failed", + error=( + str(error) + if isinstance(error, RuntimeError) + else type(error).__name__ + ), + ) finally: cleanup_error = None try: release(client, path) except Exception as error: cleanup_error = error - outcome.update(status='failed', cleanup_error=type(error).__name__) + outcome.update(status="failed", cleanup_error=type(error).__name__) outcomes.append(outcome) - report = ROOT / 'app/build/reports/selectel/matrix.json' + report = ROOT / "app/build/reports/selectel/matrix.json" report.parent.mkdir(parents=True, exist_ok=True) report.write_text(json.dumps(outcomes, indent=2)) if cleanup_error is not None: - raise RuntimeError('Matrix stopped after cleanup failed; run selectel_release') from cleanup_error - if any(result['status'] != 'passed' for result in outcomes): - raise RuntimeError('Some fixed device configurations failed; see matrix.json') + raise RuntimeError( + "Matrix stopped after cleanup failed; run selectel_release" + ) from cleanup_error + if any(result["status"] != "passed" for result in outcomes): + raise RuntimeError("Some fixed device configurations failed; see matrix.json") def choose_device(available): - sdk = os.environ.get('SELECTEL_ANDROID_API', '35') - model = os.environ.get('SELECTEL_DEVICE_MODEL', 'Galaxy A14') - abi = os.environ.get('SELECTEL_DEVICE_ABI', 'arm64-v8a') - manufacturer = os.environ.get('SELECTEL_DEVICE_MANUFACTURER', '') - choices = [d for d in available if d.get('platform') == 'Android' - and str(d.get('sdk')) == sdk and d.get('abi') == abi - and (not manufacturer or d.get('manufacturer') == manufacturer) - and d.get('count', 0) > 0 and (not model or d.get('marketName') == model)] + sdk = os.environ.get("SELECTEL_ANDROID_API", "35") + model = os.environ.get("SELECTEL_DEVICE_MODEL", "Galaxy A14") + abi = os.environ.get("SELECTEL_DEVICE_ABI", "arm64-v8a") + manufacturer = os.environ.get("SELECTEL_DEVICE_MANUFACTURER", "") + choices = [ + d + for d in available + if d.get("platform") == "Android" + and str(d.get("sdk")) == sdk + and d.get("abi") == abi + and (not manufacturer or d.get("manufacturer") == manufacturer) + and d.get("count", 0) > 0 + and (not model or d.get("marketName") == model) + ] if not choices: - raise RuntimeError(f'Unavailable configuration: {manufacturer} {model}, API {sdk}, {abi}; no lease created') - return sorted(choices, key=lambda d: d['marketName'])[0] + raise RuntimeError( + f"Unavailable configuration: {manufacturer} {model}, API {sdk}, {abi}; no lease created" + ) + return sorted(choices, key=lambda d: d["marketName"])[0] def acquire(client, path): - if path.exists() and not read_state(path).get('released'): - raise RuntimeError('An unreleased lease journal exists; run selectel_release first') - for apk in [ROOT / 'app/build/outputs/apk/debug/app-debug.apk', - ROOT / 'app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk']: + if path.exists() and not read_state(path).get("released"): + raise RuntimeError( + "An unreleased lease journal exists; run selectel_release first" + ) + for apk in [ + ROOT / "app/build/outputs/apk/debug/app-debug.apk", + ROOT / "app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk", + ]: if not apk.is_file(): - raise RuntimeError('Build UI test APKs before renting a device') + raise RuntimeError("Build UI test APKs before renting a device") adb_path() - existing = client.request('GET', '/v3/devices') - model = choose_device(client.request('GET', '/v3/devices/available')) - state = {'project': client.project, 'created_at': time.time(), 'devices': [], - 'released': False, 'baseline': [d['serial'] for d in existing]} + existing = client.request("GET", "/v3/devices") + model = choose_device(client.request("GET", "/v3/devices/available")) + state = { + "project": client.project, + "created_at": time.time(), + "devices": [], + "released": False, + "baseline": [d["serial"] for d in existing], + } write_state(path, state) - filters = {k: model[k] for k in ['manufacturer', 'marketName', 'sdk', 'platform', 'abi']} - filters['count'] = 1 - print(f"Renting one {model['marketName']} (API {model['sdk']}), billing=minutes", flush=True) + filters = { + k: model[k] for k in ["manufacturer", "marketName", "sdk", "platform", "abi"] + } + filters["count"] = 1 + print( + f"Renting one {model['marketName']} (API {model['sdk']}), billing=minutes", + flush=True, + ) # Never retry this non-idempotent request: a lost response may already have created a lease. - state['allocation_pending'] = True + state["allocation_pending"] = True write_state(path, state) try: - result = client.request('POST', '/v3/devices', {'billingType': 'minutes', 'filters': [filters]}) + result = client.request( + "POST", "/v3/devices", {"billingType": "minutes", "filters": [filters]} + ) except ApiError as error: if 400 <= error.status < 500: - state['allocation_pending'] = False + state["allocation_pending"] = False write_state(path, state) raise - state['devices'] = [{'serial': d['serial'], 'slot': d['meta']['slot']['id']} for d in result['devices']] - state['allocation_pending'] = False + state["devices"] = [ + {"serial": d["serial"], "slot": d["meta"]["slot"]["id"]} + for d in result["devices"] + ] + state["allocation_pending"] = False write_state(path, state) - if len(state['devices']) != 1 or any(d['serial'] in state['baseline'] for d in state['devices']): - raise RuntimeError('Unexpected lease response; inspect journal before proceeding') - device = state['devices'][0] - serial = urllib.parse.quote(device['serial'], safe='') + if len(state["devices"]) != 1 or any( + d["serial"] in state["baseline"] for d in state["devices"] + ): + raise RuntimeError( + "Unexpected lease response; inspect journal before proceeding" + ) + device = state["devices"][0] + serial = urllib.parse.quote(device["serial"], safe="") deadline = time.monotonic() + 180 while time.monotonic() < deadline: - info = client.request('GET', '/v3/devices/' + serial) - if info.get('ready') and info.get('present'): + info = client.request("GET", "/v3/devices/" + serial) + if info.get("ready") and info.get("present"): break time.sleep(5) else: - raise RuntimeError('Device did not become ready within 180 seconds') + raise RuntimeError("Device did not become ready within 180 seconds") env = adb_env(path) - key = Path(env['ADB_VENDOR_KEYS']) - subprocess.run([adb_path(), 'keygen', str(key)], check=True, capture_output=True, env=env) + key = Path(env["ADB_VENDOR_KEYS"]) + subprocess.run( + [adb_path(), "keygen", str(key)], check=True, capture_output=True, env=env + ) os.chmod(key, 0o600) - key_info = client.request('POST', '/v3/keys/adb', - {'publicKey': key.with_suffix('.pub').read_text(), 'title': 'megaproxy-ui-' + str(int(state['created_at']))}) - state['fingerprint'] = key_info['fingerprint'] + key_info = client.request( + "POST", + "/v3/keys/adb", + { + "publicKey": key.with_suffix(".pub").read_text(), + "title": "megaproxy-ui-" + str(int(state["created_at"])), + }, + ) + state["fingerprint"] = key_info["fingerprint"] write_state(path, state) - client.request('POST', '/v1/user/devices', {'serial': device['serial'], 'timeout': 1200000}) - remote = client.request('POST', '/v1/user/devices/' + serial + '/remoteConnect') - endpoint = remote.get('remoteConnectUrl', '') - if not remote.get('success') or not re.fullmatch(r'[A-Za-z0-9.-]+:[0-9]+', endpoint): - raise RuntimeError('Selectel did not return a valid ADB endpoint') - state['endpoint'] = endpoint + client.request( + "POST", "/v1/user/devices", {"serial": device["serial"], "timeout": 1200000} + ) + remote = client.request("POST", "/v1/user/devices/" + serial + "/remoteConnect") + endpoint = remote.get("remoteConnectUrl", "") + if not remote.get("success") or not re.fullmatch( + r"[A-Za-z0-9.-]+:[0-9]+", endpoint + ): + raise RuntimeError("Selectel did not return a valid ADB endpoint") + state["endpoint"] = endpoint write_state(path, state) connect_device(path, endpoint) - print('Device connected; lease journal saved', flush=True) + print("Device connected; lease journal saved", flush=True) def release(client, path): if not path.exists(): return state = read_state(path) - if state.get('project') != client.project: - raise RuntimeError('Lease belongs to a different project') - if state.get('released'): + if state.get("project") != client.project: + raise RuntimeError("Lease belongs to a different project") + if state.get("released"): return errors = [] - if state.get('allocation_pending'): - errors.append('Allocation response was lost: inspect project for a new lease; do not blindly retry') - for device in state.get('devices', []): - if device['serial'] in state.get('baseline', []): - errors.append('Refusing to remove a pre-existing device') + if state.get("allocation_pending"): + errors.append( + "Allocation response was lost: inspect project for a new lease; do not blindly retry" + ) + for device in state.get("devices", []): + if device["serial"] in state.get("baseline", []): + errors.append("Refusing to remove a pre-existing device") continue - serial = urllib.parse.quote(device['serial'], safe='') + serial = urllib.parse.quote(device["serial"], safe="") # Slot identity prevents a stale recovery job from deleting a newer lease. try: - current = client.request('GET', '/v3/devices/' + serial) - if current['meta']['slot']['id'] != device['slot']: + current = client.request("GET", "/v3/devices/" + serial) + if current["meta"]["slot"]["id"] != device["slot"]: continue - for suffix in ['/remoteConnect', '']: + for suffix in ["/remoteConnect", ""]: try: - client.request('DELETE', '/v1/user/devices/' + serial + suffix) + client.request("DELETE", "/v1/user/devices/" + serial + suffix) except (ApiError, urllib.error.URLError): pass # Still remove the paid lease, even if session teardown fails. - client.request('DELETE', '/v3/devices/' + serial, {'slotID': device['slot']}) + client.request( + "DELETE", "/v3/devices/" + serial, {"slotID": device["slot"]} + ) except ApiError as e: if e.status != 404: errors.append(str(e)) except (KeyError, urllib.error.URLError) as e: - errors.append(type(e).__name__ + ' while releasing device') - if state.get('fingerprint'): + errors.append(type(e).__name__ + " while releasing device") + if state.get("fingerprint"): try: - client.request('DELETE', '/v3/keys/adb/' + urllib.parse.quote(state['fingerprint'], safe='')) + client.request( + "DELETE", + "/v3/keys/adb/" + urllib.parse.quote(state["fingerprint"], safe=""), + ) except ApiError as e: if e.status != 404: errors.append(str(e)) try: - adb(path, 'kill-server', check=False) + adb(path, "kill-server", check=False) except (RuntimeError, subprocess.TimeoutExpired): pass - shutil.rmtree(path.parent / 'adb-home', ignore_errors=True) + shutil.rmtree(path.parent / "adb-home", ignore_errors=True) if errors: - raise RuntimeError('; '.join(errors) + f'. Recovery journal: {path}') - state['released'] = True + raise RuntimeError("; ".join(errors) + f". Recovery journal: {path}") + state["released"] = True write_state(path, state) - print('Paid device lease and temporary ADB key released', flush=True) + print("Paid device lease and temporary ADB key released", flush=True) def instrumentation_report(output, target): """Translate Android's instrumentation protocol; adb exit=0 alone is not success.""" - suite = ET.Element('testsuite', name='MegaProxy UI') + suite = ET.Element("testsuite", name="MegaProxy UI") fields = {} last_key = None completed = 0 failures = 0 passed = 0 for line in output.splitlines(): - if line.startswith('INSTRUMENTATION_STATUS: '): - key, _, value = line[len('INSTRUMENTATION_STATUS: '):].partition('=') + if line.startswith("INSTRUMENTATION_STATUS: "): + key, _, value = line[len("INSTRUMENTATION_STATUS: ") :].partition("=") fields[key] = value last_key = key - elif line.startswith('INSTRUMENTATION_STATUS_CODE: '): - code = int(line.split(': ', 1)[1]) - if code <= 0 and fields.get('test'): - case = ET.SubElement(suite, 'testcase', classname=fields.get('class', ''), name=fields['test']) + elif line.startswith("INSTRUMENTATION_STATUS_CODE: "): + code = int(line.split(": ", 1)[1]) + if code <= 0 and fields.get("test"): + case = ET.SubElement( + suite, + "testcase", + classname=fields.get("class", ""), + name=fields["test"], + ) completed += 1 if code in (-3, -4): - ET.SubElement(case, 'skipped') + ET.SubElement(case, "skipped") elif code == 0: passed += 1 else: failures += 1 - ET.SubElement(case, 'failure').text = fields.get('stack', 'Instrumentation failure') + ET.SubElement(case, "failure").text = fields.get( + "stack", "Instrumentation failure" + ) fields = {} last_key = None - elif last_key == 'stack' and not line.startswith('INSTRUMENTATION_'): - fields['stack'] += '\n' + line - success = passed > 0 and failures == 0 and 'INSTRUMENTATION_CODE: -1' in output and re.search(r'OK \(\d+ tests?\)', output) is not None + elif last_key == "stack" and not line.startswith("INSTRUMENTATION_"): + fields["stack"] += "\n" + line + success = ( + passed > 0 + and failures == 0 + and "INSTRUMENTATION_CODE: -1" in output + and re.search(r"OK \(\d+ tests?\)", output) is not None + ) if not success and failures == 0: failures += 1 completed += 1 - ET.SubElement(ET.SubElement(suite, 'testcase', name='instrumentation_finished'), 'failure').text = 'No successful instrumentation completion; see instrumentation.txt' - suite.set('tests', str(completed)) - suite.set('failures', str(failures)) - ET.ElementTree(suite).write(target, encoding='utf-8', xml_declaration=True) + ET.SubElement( + ET.SubElement(suite, "testcase", name="instrumentation_finished"), "failure" + ).text = "No successful instrumentation completion; see instrumentation.txt" + suite.set("tests", str(completed)) + suite.set("failures", str(failures)) + ET.ElementTree(suite).write(target, encoding="utf-8", xml_declaration=True) return success def run_tests(path): state = read_state(path) - if state.get('released'): - raise RuntimeError('Device lease is already released') - endpoint = state['endpoint'] - reports = ROOT / 'app/build/reports/selectel' - result_id = os.environ.get('SELECTEL_RESULT_ID', '') + if state.get("released"): + raise RuntimeError("Device lease is already released") + endpoint = state["endpoint"] + reports = ROOT / "app/build/reports/selectel" + result_id = os.environ.get("SELECTEL_RESULT_ID", "") if result_id: - if not re.fullmatch(r'[a-z0-9-]+', result_id): - raise RuntimeError('Invalid SELECTEL_RESULT_ID') + if not re.fullmatch(r"[a-z0-9-]+", result_id): + raise RuntimeError("Invalid SELECTEL_RESULT_ID") reports = reports / result_id shutil.rmtree(reports, ignore_errors=True) reports.mkdir(parents=True, exist_ok=True) # Install only on the newly leased device, never a local emulator or personal phone. connect_device(path, endpoint) - for apk in ['app/build/outputs/apk/debug/app-debug.apk', - 'app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk']: - print('Installing ' + Path(apk).name, flush=True) - adb(path, '-s', endpoint, 'install', '-r', '-t', str(ROOT / apk), timeout=180) - adb(path, '-s', endpoint, 'shell', 'pm', 'clear', APP) - print('Running instrumentation (maximum 10 minutes)', flush=True) + for apk in [ + "app/build/outputs/apk/debug/app-debug.apk", + "app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk", + ]: + print("Installing " + Path(apk).name, flush=True) + adb(path, "-s", endpoint, "install", "-r", "-t", str(ROOT / apk), timeout=180) + adb(path, "-s", endpoint, "shell", "pm", "clear", APP) + print("Running instrumentation (maximum 10 minutes)", flush=True) try: - output = adb(path, '-s', endpoint, 'shell', 'am', 'instrument', '-w', '-r', RUNNER, timeout=600, check=False) + output = adb( + path, + "-s", + endpoint, + "shell", + "am", + "instrument", + "-w", + "-r", + RUNNER, + timeout=600, + check=False, + ) except subprocess.TimeoutExpired as error: - output = error.stdout or '' + output = error.stdout or "" if isinstance(output, bytes): - output = output.decode(errors='replace') - (reports / 'instrumentation.txt').write_text(output + '\nInstrumentation timed out') - instrumentation_report(output, reports / 'junit.xml') - raise RuntimeError('Instrumentation exceeded 10 minutes') from None + output = output.decode(errors="replace") + (reports / "instrumentation.txt").write_text( + output + "\nInstrumentation timed out" + ) + instrumentation_report(output, reports / "junit.xml") + raise RuntimeError("Instrumentation exceeded 10 minutes") from None finally: try: - adb(path, '-s', endpoint, 'pull', '/sdcard/Android/data/' + APP + '/files/ui-test-screenshots', - str(reports / 'screenshots'), timeout=30, check=False) + adb( + path, + "-s", + endpoint, + "pull", + "/sdcard/Android/data/" + APP + "/files/ui-test-screenshots", + str(reports / "screenshots"), + timeout=30, + check=False, + ) except (RuntimeError, subprocess.TimeoutExpired): - print('Screenshot collection unavailable; instrumentation outcome is preserved', flush=True) - (reports / 'instrumentation.txt').write_text(output) - success = instrumentation_report(output, reports / 'junit.xml') + print( + "Screenshot collection unavailable; instrumentation outcome is preserved", + flush=True, + ) + (reports / "instrumentation.txt").write_text(output) + success = instrumentation_report(output, reports / "junit.xml") print(f'UI report: {reports / "junit.xml"}', flush=True) if not success: - raise RuntimeError('UI instrumentation tests failed; see reports') + raise RuntimeError("UI instrumentation tests failed; see reports") def main(): parser = argparse.ArgumentParser() - parser.add_argument('action', choices=['probe', 'acquire', 'run', 'release', 'release-all', 'test', 'matrix', 'matrix-test']) - parser.add_argument('--state', type=Path, default=ROOT / '.selectel/lease.json') - parser.add_argument('--profile', choices=['single', 'all'], default='single') + parser.add_argument( + "action", + choices=[ + "probe", + "acquire", + "run", + "release", + "release-all", + "test", + "matrix", + "matrix-test", + ], + ) + parser.add_argument("--state", type=Path, default=ROOT / ".selectel/lease.json") + parser.add_argument("--profile", choices=["single", "all"], default="single") args = parser.parse_args() args.state = args.state.resolve() + def interrupted(signum, frame): raise KeyboardInterrupt + signal.signal(signal.SIGTERM, interrupted) try: - if args.action == 'matrix': - print(json.dumps({'include': device_matrix(args.profile)})) - elif args.action == 'run': + if args.action == "matrix": + print(json.dumps({"include": device_matrix(args.profile)})) + elif args.action == "run": run_tests(args.state) else: client = Client() - if args.action == 'release-all': + if args.action == "release-all": release_all(client, args.state.parent) - elif args.action == 'matrix-test': + elif args.action == "matrix-test": run_matrix(client, args.state, args.profile) - elif args.action == 'probe': - existing = client.request('GET', '/v3/devices') - model = choose_device(client.request('GET', '/v3/devices/available')) - print(f"Access OK; {len(existing)} existing devices untouched; candidate {model['marketName']} API {model['sdk']}") - elif args.action == 'release': + elif args.action == "probe": + existing = client.request("GET", "/v3/devices") + model = choose_device(client.request("GET", "/v3/devices/available")) + print( + f"Access OK; {len(existing)} existing devices untouched; candidate {model['marketName']} API {model['sdk']}" + ) + elif args.action == "release": release(client, args.state) - elif args.action == 'acquire': + elif args.action == "acquire": acquire(client, args.state) else: try: @@ -436,14 +609,20 @@ def interrupted(signum, frame): finally: release(client, args.state) except KeyboardInterrupt: - print('Interrupted; run selectel_release if the lease remains active', file=sys.stderr) + print( + "Interrupted; run selectel_release if the lease remains active", + file=sys.stderr, + ) return 130 except Exception as e: # Avoid tracebacks containing request data or credentials. - print(str(e) if isinstance(e, (RuntimeError, ApiError)) else type(e).__name__, file=sys.stderr) + print( + str(e) if isinstance(e, (RuntimeError, ApiError)) else type(e).__name__, + file=sys.stderr, + ) return 1 return 0 -if __name__ == '__main__': +if __name__ == "__main__": sys.exit(main()) diff --git a/scripts/tests/test_github_actions.py b/scripts/tests/test_github_actions.py new file mode 100644 index 0000000..fc22343 --- /dev/null +++ b/scripts/tests/test_github_actions.py @@ -0,0 +1,157 @@ +import copy +import importlib.util +import json +import os +import unittest +import urllib.error +from pathlib import Path +from unittest.mock import MagicMock, Mock, patch + +spec = importlib.util.spec_from_file_location( + "github_actions", Path(__file__).parents[1] / "github_actions.py" +) +m = importlib.util.module_from_spec(spec) +spec.loader.exec_module(m) + + +class LauncherTest(unittest.TestCase): + def setUp(self): + self.client = Mock(repository=m.REPOSITORY) + self.pr = { + "number": 31, + "state": "open", + "head": { + "sha": "abc", + "ref": "feature/test", + "repo": {"full_name": m.REPOSITORY}, + }, + } + + def test_dispatch_uses_selected_branch_and_profile(self): + for mode in ("single", "all"): + path, body, _ = m.plan_run(self.client, self.pr, mode) + self.assertEqual("/actions/workflows/selectel-ui.yml/dispatches", path) + self.assertEqual({"ref": "feature/test", "inputs": {"devices": mode}}, body) + + def test_dry_run_never_posts_or_requests_confirmation(self): + with patch("builtins.input", side_effect=AssertionError): + m.launch(self.client, self.pr, "all", dry_run=True) + self.client.request.assert_not_called() + + def test_changed_or_closed_pr_prevents_dispatch(self): + for change in ("sha", "closed", "fork"): + current = copy.deepcopy(self.pr) + if change == "sha": + current["head"]["sha"] = "new" + elif change == "closed": + current["state"] = "closed" + else: + current["head"]["repo"]["full_name"] = "other/repo" + self.client.request.reset_mock() + self.client.request.return_value = current + with ( + patch("builtins.input", return_value="y"), + self.assertRaisesRegex(RuntimeError, "PR changed"), + ): + m.launch(self.client, self.pr, "single") + self.assertEqual( + ["GET"], [call.args[0] for call in self.client.request.call_args_list] + ) + + def test_cancel_never_posts(self): + with patch("builtins.input", return_value="n"): + m.launch(self.client, self.pr, "single") + self.client.request.assert_not_called() + + def test_confirmed_dispatch_posts_exactly_once(self): + self.client.request.side_effect = [self.pr, None] + with patch("builtins.input", return_value="y"): + m.launch(self.client, self.pr, "single") + self.assertEqual( + ["GET", "POST"], + [call.args[0] for call in self.client.request.call_args_list], + ) + + def test_fork_is_rejected(self): + self.pr["head"]["repo"] = {"full_name": "other/repo"} + with self.assertRaisesRegex(RuntimeError, "forks"): + m.plan_run(self.client, self.pr, "all") + + def test_ci_does_not_rerun_old_commit_or_duplicate_active_run(self): + run = { + "id": 20, + "head_sha": "abc", + "head_branch": "feature/test", + "head_repository": {"full_name": m.REPOSITORY}, + "status": "completed", + "html_url": "https://github.com/run", + } + self.client.request.return_value = {"workflow_runs": [run]} + self.assertEqual( + "/actions/runs/20/rerun", m.plan_run(self.client, self.pr, "ci")[0] + ) + run["head_sha"] = "old" + with self.assertRaisesRegex(RuntimeError, "No CI run"): + m.plan_run(self.client, self.pr, "ci") + run.update(head_sha="abc", status="in_progress") + with self.assertRaisesRegex(RuntimeError, "already queued or running"): + m.plan_run(self.client, self.pr, "ci") + + def test_environment_token_does_not_need_gh(self): + with ( + patch.dict(os.environ, {"GH_TOKEN": "test-token"}), + patch.object(m.subprocess, "run", side_effect=AssertionError), + ): + self.assertEqual("test-token", m.github_token()) + + def test_pr_list_paginates(self): + client = m.GitHub(m.REPOSITORY, "test-token") + with patch.object( + client, "request", side_effect=[[self.pr] * 100, [self.pr]] + ) as request: + self.assertEqual(101, len(client.open_prs())) + self.assertIn("page=2", request.call_args.args[1]) + + +class GitHubRequestTest(unittest.TestCase): + def test_dispatch_serializes_json_and_accepts_empty_success(self): + client = m.GitHub(m.REPOSITORY, "test-token") + response = MagicMock() + response.__enter__.return_value.read.return_value = b"" + body = {"ref": "feature/test", "inputs": {"devices": "single"}} + with patch.object( + m.urllib.request, "urlopen", return_value=response + ) as urlopen: + self.assertIsNone( + client.request( + "POST", "/actions/workflows/selectel-ui.yml/dispatches", body + ) + ) + request = urlopen.call_args.args[0] + self.assertEqual(body, json.loads(request.data)) + self.assertEqual("Bearer test-token", request.get_header("Authorization")) + self.assertEqual("POST", request.method) + + def test_ambiguous_post_failure_is_not_retried_or_leaked(self): + client = m.GitHub(m.REPOSITORY, "test-token") + for error in ( + TimeoutError("test-token"), + urllib.error.HTTPError( + "https://api.github.com", 503, "test-token", {}, None + ), + ): + with patch.object( + m.urllib.request, "urlopen", side_effect=error + ) as urlopen: + with self.assertRaisesRegex( + RuntimeError, "outcome is unknown" + ) as raised: + client.request( + "POST", "/actions/workflows/selectel-ui.yml/dispatches", {} + ) + self.assertEqual(1, urlopen.call_count) + self.assertNotIn("test-token", str(raised.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_selectel_ui.py b/scripts/tests/test_selectel_ui.py index 0533fa7..7de0561 100644 --- a/scripts/tests/test_selectel_ui.py +++ b/scripts/tests/test_selectel_ui.py @@ -1,27 +1,31 @@ import importlib.util import json import os -from pathlib import Path import tempfile import unittest +from pathlib import Path from unittest.mock import patch -spec = importlib.util.spec_from_file_location('selectel_ui', Path(__file__).parents[1] / 'selectel_ui.py') +spec = importlib.util.spec_from_file_location( + "selectel_ui", Path(__file__).parents[1] / "selectel_ui.py" +) m = importlib.util.module_from_spec(spec) spec.loader.exec_module(m) class FakeClient: - project = 'ci' - def __init__(self, slot='owned-slot', fail_session=False): + project = "ci" + + def __init__(self, slot="owned-slot", fail_session=False): self.slot = slot self.fail_session = fail_session self.calls = [] + def request(self, method, route, body=None): self.calls.append((method, route, body)) - if method == 'GET': - return {'meta': {'slot': {'id': self.slot}}} - if self.fail_session and '/v1/' in route: + if method == "GET": + return {"meta": {"slot": {"id": self.slot}}} + if self.fail_session and "/v1/" in route: raise m.ApiError(method, route, 500) @@ -29,29 +33,36 @@ class LeaseTest(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory() self.addCleanup(self.temp.cleanup) - self.path = Path(self.temp.name) / 'lease.json' - self.state = {'project': 'ci', 'devices': [{'serial': 'test-device', 'slot': 'owned-slot'}], - 'baseline': ['existing-device'], 'released': False} + self.path = Path(self.temp.name) / "lease.json" + self.state = { + "project": "ci", + "devices": [{"serial": "test-device", "slot": "owned-slot"}], + "baseline": ["existing-device"], + "released": False, + } m.write_state(self.path, self.state) - self.adb = patch.object(m, 'adb', return_value='').start() + self.adb = patch.object(m, "adb", return_value="").start() self.addCleanup(patch.stopall) def test_release_ends_rental_even_when_adb_session_fails(self): client = FakeClient(fail_session=True) m.release(client, self.path) - self.assertIn(('DELETE', '/v3/devices/test-device', {'slotID': 'owned-slot'}), client.calls) - self.assertTrue(m.read_state(self.path)['released']) + self.assertIn( + ("DELETE", "/v3/devices/test-device", {"slotID": "owned-slot"}), + client.calls, + ) + self.assertTrue(m.read_state(self.path)["released"]) def test_stale_journal_cannot_delete_reallocated_device(self): - client = FakeClient(slot='someone-elses-slot') + client = FakeClient(slot="someone-elses-slot") m.release(client, self.path) - self.assertFalse(any(call[0] == 'DELETE' for call in client.calls)) + self.assertFalse(any(call[0] == "DELETE" for call in client.calls)) def test_preexisting_device_cannot_be_deleted(self): - self.state['baseline'].append('test-device') + self.state["baseline"].append("test-device") m.write_state(self.path, self.state) client = FakeClient() - with self.assertRaisesRegex(RuntimeError, 'pre-existing'): + with self.assertRaisesRegex(RuntimeError, "pre-existing"): m.release(client, self.path) self.assertEqual([], client.calls) @@ -65,104 +76,172 @@ def test_release_is_idempotent(self): def test_lost_allocation_response_requires_recovery(self): self.state.update(devices=[], allocation_pending=True) m.write_state(self.path, self.state) - with self.assertRaisesRegex(RuntimeError, 'response was lost'): + with self.assertRaisesRegex(RuntimeError, "response was lost"): m.release(FakeClient(), self.path) - self.assertFalse(m.read_state(self.path)['released']) + self.assertFalse(m.read_state(self.path)["released"]) def test_wrong_project_is_rejected(self): client = FakeClient() - client.project = 'other-project' - with self.assertRaisesRegex(RuntimeError, 'different project'): + client.project = "other-project" + with self.assertRaisesRegex(RuntimeError, "different project"): m.release(client, self.path) self.assertEqual([], client.calls) def test_zero_tests_or_crashed_instrumentation_is_failure(self): - for output in ['', 'INSTRUMENTATION_CODE: -1', 'OK (0 tests)\nINSTRUMENTATION_CODE: -1']: - self.assertFalse(m.instrumentation_report(output, Path(self.temp.name) / 'junit.xml')) + for output in [ + "", + "INSTRUMENTATION_CODE: -1", + "OK (0 tests)\nINSTRUMENTATION_CODE: -1", + ]: + self.assertFalse( + m.instrumentation_report(output, Path(self.temp.name) / "junit.xml") + ) def test_android_failure_is_not_hidden_by_successful_adb_exit(self): - output = ('INSTRUMENTATION_STATUS: class=Test\nINSTRUMENTATION_STATUS: test=broken\n' - 'INSTRUMENTATION_STATUS: stack=Assertion failed\nINSTRUMENTATION_STATUS_CODE: -2\n' - 'INSTRUMENTATION_CODE: -1') - self.assertFalse(m.instrumentation_report(output, Path(self.temp.name) / 'junit.xml')) + output = ( + "INSTRUMENTATION_STATUS: class=Test\nINSTRUMENTATION_STATUS: test=broken\n" + "INSTRUMENTATION_STATUS: stack=Assertion failed\nINSTRUMENTATION_STATUS_CODE: -2\n" + "INSTRUMENTATION_CODE: -1" + ) + self.assertFalse( + m.instrumentation_report(output, Path(self.temp.name) / "junit.xml") + ) def test_all_skipped_is_not_a_successful_smoke_run(self): - output = ('INSTRUMENTATION_STATUS: class=Test\nINSTRUMENTATION_STATUS: test=skipped\n' - 'INSTRUMENTATION_STATUS_CODE: -3\nOK (0 tests)\nINSTRUMENTATION_CODE: -1') - self.assertFalse(m.instrumentation_report(output, Path(self.temp.name) / 'junit.xml')) + output = ( + "INSTRUMENTATION_STATUS: class=Test\nINSTRUMENTATION_STATUS: test=skipped\n" + "INSTRUMENTATION_STATUS_CODE: -3\nOK (0 tests)\nINSTRUMENTATION_CODE: -1" + ) + self.assertFalse( + m.instrumentation_report(output, Path(self.temp.name) / "junit.xml") + ) def test_successful_instrumentation(self): - output = ('INSTRUMENTATION_STATUS: class=Test\nINSTRUMENTATION_STATUS: test=works\n' - 'INSTRUMENTATION_STATUS_CODE: 0\nOK (1 test)\nINSTRUMENTATION_CODE: -1') - self.assertTrue(m.instrumentation_report(output, Path(self.temp.name) / 'junit.xml')) + output = ( + "INSTRUMENTATION_STATUS: class=Test\nINSTRUMENTATION_STATUS: test=works\n" + "INSTRUMENTATION_STATUS_CODE: 0\nOK (1 test)\nINSTRUMENTATION_CODE: -1" + ) + self.assertTrue( + m.instrumentation_report(output, Path(self.temp.name) / "junit.xml") + ) class AdbConnectionTest(unittest.TestCase): def test_offline_transport_is_reconnected_without_renting_again(self): - with patch.object(m, 'adb', side_effect=['connected', 'error: device offline', '', - 'connected', 'device']) as adb, \ - patch.object(m.time, 'sleep'), patch.object(m, 'Client', side_effect=AssertionError): - m.connect_device(Path('/tmp/test-lease.json'), 'example.test:1234') + with ( + patch.object( + m, + "adb", + side_effect=[ + "connected", + "error: device offline", + "", + "connected", + "device", + ], + ) as adb, + patch.object(m.time, "sleep"), + patch.object(m, "Client", side_effect=AssertionError), + ): + m.connect_device(Path("/tmp/test-lease.json"), "example.test:1234") commands = [call.args[1:] for call in adb.call_args_list] - self.assertEqual([('connect', 'example.test:1234'), - ('-s', 'example.test:1234', 'get-state'), - ('disconnect', 'example.test:1234'), - ('connect', 'example.test:1234'), - ('-s', 'example.test:1234', 'get-state')], commands) + self.assertEqual( + [ + ("connect", "example.test:1234"), + ("-s", "example.test:1234", "get-state"), + ("disconnect", "example.test:1234"), + ("connect", "example.test:1234"), + ("-s", "example.test:1234", "get-state"), + ], + commands, + ) class MatrixTest(unittest.TestCase): def test_fixed_catalog_needs_no_credentials_or_api(self): - with patch.dict(os.environ, {}, clear=True), patch.object(m, 'Client', side_effect=AssertionError): - devices = m.device_matrix('all') - single = m.device_matrix('single') + with ( + patch.dict(os.environ, {}, clear=True), + patch.object(m, "Client", side_effect=AssertionError), + ): + devices = m.device_matrix("all") + single = m.device_matrix("single") self.assertEqual(1, len(single)) - self.assertEqual('Galaxy A14', single[0]['model']) + self.assertEqual("Galaxy A14", single[0]["model"]) self.assertIn(single[0], devices) - self.assertEqual(len(devices), len({d['id'] for d in devices})) - self.assertEqual({'arm64-v8a'}, {d['abi'] for d in devices}) - self.assertEqual(['30', '33', '35', '36', '37'], sorted(d['api'] for d in devices)) + self.assertEqual(len(devices), len({d["id"] for d in devices})) + self.assertEqual({"arm64-v8a"}, {d["abi"] for d in devices}) + self.assertEqual( + ["30", "33", "35", "36", "37"], sorted(d["api"] for d in devices) + ) def test_selection_does_not_substitute_another_configuration(self): - device = m.device_matrix('single')[0] + device = m.device_matrix("single")[0] with patch.dict(os.environ): m.select_configuration(device) - candidate = dict(platform='Android', sdk='35', marketName='Galaxy A14', - manufacturer='SAMSUNG', abi='arm64-v8a', count=1) + candidate = dict( + platform="Android", + sdk="35", + marketName="Galaxy A14", + manufacturer="SAMSUNG", + abi="arm64-v8a", + count=1, + ) self.assertEqual(candidate, m.choose_device([candidate])) - for field, value in [('sdk', '33'), ('abi', 'armeabi-v7a'), - ('manufacturer', 'OTHER'), ('count', 0)]: - with self.assertRaisesRegex(RuntimeError, 'Unavailable configuration'): + for field, value in [ + ("sdk", "33"), + ("abi", "armeabi-v7a"), + ("manufacturer", "OTHER"), + ("count", 0), + ]: + with self.assertRaisesRegex(RuntimeError, "Unavailable configuration"): m.choose_device([dict(candidate, **{field: value})]) def test_cleanup_attempts_every_journal_after_failure(self): with tempfile.TemporaryDirectory() as root: - paths = [Path(root) / name / 'lease.json' for name in ['a', 'b']] + paths = [Path(root) / name / "lease.json" for name in ["a", "b"]] for path in paths: m.write_state(path, {}) - with patch.object(m, 'release', side_effect=[RuntimeError('offline'), None]) as release: - with self.assertRaisesRegex(RuntimeError, 'offline'): + with patch.object( + m, "release", side_effect=[RuntimeError("offline"), None] + ) as release: + with self.assertRaisesRegex(RuntimeError, "offline"): m.release_all(None, Path(root)) self.assertEqual(paths, [call.args[1] for call in release.call_args_list]) def test_matrix_continues_test_failures_but_stops_cleanup_failures(self): - devices = m.device_matrix('all')[:2] + devices = m.device_matrix("all")[:2] for cleanup_fails in [False, True]: - with tempfile.TemporaryDirectory() as root, patch.dict(os.environ), \ - patch.object(m, 'ROOT', Path(root)), \ - patch.object(m, 'device_matrix', return_value=devices), \ - patch.object(m, 'acquire') as acquire, \ - patch.object(m, 'run_tests', side_effect=[RuntimeError('test failed'), None]), \ - patch.object(m, 'release', side_effect=RuntimeError('cleanup failed') if cleanup_fails else None): + with ( + tempfile.TemporaryDirectory() as root, + patch.dict(os.environ), + patch.object(m, "ROOT", Path(root)), + patch.object(m, "device_matrix", return_value=devices), + patch.object(m, "acquire") as acquire, + patch.object( + m, "run_tests", side_effect=[RuntimeError("test failed"), None] + ), + patch.object( + m, + "release", + side_effect=( + RuntimeError("cleanup failed") if cleanup_fails else None + ), + ), + ): with self.assertRaises(RuntimeError): - m.run_matrix(None, Path(root) / '.selectel/lease.json', 'all') + m.run_matrix(None, Path(root) / ".selectel/lease.json", "all") self.assertEqual(1 if cleanup_fails else 2, acquire.call_count) - report = json.loads((Path(root) / 'app/build/reports/selectel/matrix.json').read_text()) - self.assertEqual('failed', report[0]['status']) + report = json.loads( + (Path(root) / "app/build/reports/selectel/matrix.json").read_text() + ) + self.assertEqual("failed", report[0]["status"]) if not cleanup_fails: - self.assertEqual('passed', report[1]['status']) - self.assertNotEqual(acquire.call_args_list[0].args[1], acquire.call_args_list[1].args[1]) + self.assertEqual("passed", report[1]["status"]) + self.assertNotEqual( + acquire.call_args_list[0].args[1], + acquire.call_args_list[1].args[1], + ) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() From 547098710ea2c55b3d095dc59a7f0e24b1d3835c Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Mon, 7 Sep 2026 20:09:17 +0300 Subject: [PATCH 07/13] Expose Python tests and style as a separate required PR check --- .github/workflows/ci.yml | 20 +++++++++++++++++--- AGENTS.md | 3 ++- docs/en/fastlane.md | 3 ++- docs/ru/fastlane.md | 3 ++- 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb3b298..a0e4fb9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,23 @@ jobs: ruby-version: "3.4.10" bundler-cache: true + - name: Run native tests with the race detector + run: bundle exec fastlane android native_tests + + python-tests: + name: Python tests and style + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Set up Ruby and Fastlane + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4.10" + bundler-cache: true + - uses: actions/setup-python@v6 with: python-version: "3.12" @@ -46,9 +63,6 @@ jobs: - name: Check Python formatting and runner contracts without a device run: bundle exec fastlane android python_checks - - name: Run native tests with the race detector - run: bundle exec fastlane android native_tests - android-tests: name: Android tests and checks runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index 11c3c2e..f904203 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,8 @@ branch names, credentials, signing material, or other secrets. - Python scripts use the standard library at runtime. Format them with pinned Black and isort (`requirements-dev.txt`, `pyproject.toml`) through the `python_format` Fastlane lane; - `python_checks` validates formatting and runner contracts in CI. + `python_checks` validates formatting and runner contracts in the separate required + `Python tests and style` CI job. ## CI and artifacts diff --git a/docs/en/fastlane.md b/docs/en/fastlane.md index 82ec9e5..2eb824a 100644 --- a/docs/en/fastlane.md +++ b/docs/en/fastlane.md @@ -90,7 +90,8 @@ committing the lock file and using `bundle exec fastlane` locally and in CI. ## Python development tools The scripts themselves use Python's standard library. Install the pinned formatting tools only for -development; Black and isort settings live in `pyproject.toml` and CI checks them on every PR. +development; Black and isort settings live in `pyproject.toml`. The separate required +`Python tests and style` job runs formatting checks and Python tests on every PR. ```sh python3 -m venv .venv diff --git a/docs/ru/fastlane.md b/docs/ru/fastlane.md index 4f662c6..bb1f45d 100644 --- a/docs/ru/fastlane.md +++ b/docs/ru/fastlane.md @@ -92,7 +92,8 @@ bundle exec fastlane android test ## Инструменты разработки Python Сами скрипты используют стандартную библиотеку Python. Зафиксированные форматтеры нужны только для -разработки; настройки Black и isort находятся в `pyproject.toml`, CI проверяет их в каждом PR. +разработки; настройки Black и isort находятся в `pyproject.toml`. Отдельная обязательная проверка +`Python tests and style` запускает проверку форматирования и Python-тесты в каждом PR. ```sh python3 -m venv .venv From c9180fc66b3a1f72766cc067bd8aff8e6ea44854 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Mon, 7 Sep 2026 20:17:59 +0300 Subject: [PATCH 08/13] Separate required and additional UI profiles and delegate launches to gh --- .github/workflows/selectel-ui.yml | 12 +- AGENTS.md | 3 +- README.md | 3 +- docs/en/fastlane.md | 4 +- docs/en/selectel-ui-tests.md | 53 ++++--- docs/ru/fastlane.md | 4 +- docs/ru/selectel-ui-tests.md | 53 ++++--- fastlane/Fastfile | 12 +- fastlane/README.md | 2 +- scripts/github_actions.py | 228 ++++++++++++--------------- scripts/selectel_ui.py | 37 +++-- scripts/tests/test_github_actions.py | 225 +++++++++++++------------- scripts/tests/test_selectel_ui.py | 24 +-- 13 files changed, 330 insertions(+), 330 deletions(-) diff --git a/.github/workflows/selectel-ui.yml b/.github/workflows/selectel-ui.yml index 3fe1a5a..f375c23 100644 --- a/.github/workflows/selectel-ui.yml +++ b/.github/workflows/selectel-ui.yml @@ -1,13 +1,14 @@ name: Selectel UI +run-name: Selectel UI · ${{ inputs.devices }} · ${{ github.ref_name }} on: workflow_dispatch: inputs: devices: - description: Fixed device set + description: Required smoke test or additional Android versions type: choice - options: [single, all] - default: single + options: [required, additional] + default: required required: true permissions: @@ -139,8 +140,9 @@ jobs: echo "[Selectel UI reports and screenshots]($REPORT_URL)" >> "$GITHUB_STEP_SUMMARY" fi - required: - name: Selectel UI required + result: + # Use different contexts: additional must never satisfy the required check. + name: ${{ format('Selectel UI {0}', inputs.devices) }} if: always() needs: [build, ui-tests] runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index f904203..01d6191 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,8 @@ branch names, credentials, signing material, or other secrets. - Device UI tests run on Selectel real Android devices through Fastlane, separately from the automatic JVM/native checks. Run UI tests manually on the PR branch before merging; require `Selectel UI required` for the current commit. Never rent devices on each push. - Use `single` or the fixed `config/selectel-devices.json` catalog (`all`); no runtime discovery. + `required` covers Android 15; optional `additional` covers Android 11, 13, 16, 17 without overlap. + Keep their check names distinct. Both use `config/selectel-devices.json`; no runtime discovery. Do not reintroduce a software-emulated Android fallback. - Selectel credentials live in GitHub Actions secrets or `~/.config/megaproxy/selectel.env`. Build APKs before renting; release only the recorded device/slot owned by the run, including diff --git a/README.md b/README.md index 2084b27..2d53042 100644 --- a/README.md +++ b/README.md @@ -280,7 +280,8 @@ copy, logs, and tooling. ### Real-device UI tests UI tests run manually on Selectel before merging, with a required check for the current PR commit. -Choose one device or the fixed full catalog; pushes only trigger native/JVM/lint checks. See the +Choose `required` (Android 15) or optional `additional` (Android 11, 13, 16, 17); +pushes only trigger Python/native/Android checks. See the [English setup](docs/en/selectel-ui-tests.md) or [Russian setup](docs/ru/selectel-ui-tests.md) for local credentials, PR integration, reports, and paid-device cleanup. Use `python3 scripts/github_actions.py` to choose a PR and launch UI tests or rerun CI interactively. diff --git a/docs/en/fastlane.md b/docs/en/fastlane.md index 2eb824a..f5dbf66 100644 --- a/docs/en/fastlane.md +++ b/docs/en/fastlane.md @@ -44,13 +44,13 @@ That command lists the lanes available in the checked-out version of the project | `bundle exec fastlane android release_artifacts` | Builds and verifies the signed release APKs, AAB, native debug symbols, and `SHA256SUMS` in `dist/release`. | | `bundle exec fastlane android selectel_contract_tests` | Tests lease cleanup and instrumentation parsing without renting a device. | | `bundle exec fastlane android ui_test_artifacts` | Runs Selectel contract tests and builds debug application and instrumentation APKs. | -| `bundle exec fastlane android ui_test_artifacts profile:all` | Builds an APK supporting every fixed device configuration. | +| `bundle exec fastlane android ui_test_artifacts profile:additional` | Builds APKs for the optional additional device configurations. | | `bundle exec fastlane android selectel_probe` | Checks access and availability without renting. | | `bundle exec fastlane android selectel_acquire` | Rents one device and records its lease; requires prebuilt APKs. | | `bundle exec fastlane android selectel_run` | Runs tests on the device from the lease journal. | | `bundle exec fastlane android selectel_release` | Releases the recorded lease and temporary ADB key; safe to repeat. | | `bundle exec fastlane android selectel_ui_tests` | Rents, runs prebuilt tests, and releases the device in a finally block. | -| `bundle exec fastlane android selectel_ui_tests profile:all` | Runs the fixed catalog sequentially, releasing each lease and collecting separate reports. | +| `bundle exec fastlane android selectel_ui_tests profile:additional` | Runs four additional configurations sequentially, excluding required Android 15. | The release lane requires the signing configuration described in [Signed release builds](../../README.md#signed-release-builds). It builds artifacts but does not diff --git a/docs/en/selectel-ui-tests.md b/docs/en/selectel-ui-tests.md index c6501fb..9496b08 100644 --- a/docs/en/selectel-ui-tests.md +++ b/docs/en/selectel-ui-tests.md @@ -1,21 +1,24 @@ # Selectel UI tests The `Selectel UI` workflow runs **manually**, on the PR branch selected in Actions → Selectel UI → -Run workflow. Select `single` or `all`. Pushes do not rent devices. The aggregate -`Selectel UI required` check must succeed for the current PR commit before merging; a new commit -requires a new manual run. Native/JVM/lint checks still run automatically. The required check fails -if the build or any selected device fails, including unavailable configurations. - -The fixed snapshot in `config/selectel-devices.json`, captured on 2026-09-07, contains **five ARM64 configurations**: Android 11 (Galaxy A03), 13 (Galaxy A14), -15 (Galaxy A14), 16 (Galaxy A35) and 17 (Pixel 8). `single` selects Galaxy A14 / API 35 / ARM64; -`all` runs one device per configuration, at most two simultaneously in GitHub. This is not runtime -discovery and does not rent every physical copy of a model. Update the snapshot explicitly in a PR. -The full matrix performs 60 test cases and costs more than a single-device run. Availability and -compatibility of every catalog entry are not guaranteed; there is no substitution on failure. +Run workflow. Pushes do not rent devices. Choose one independent profile: + +- `required`: Galaxy A14 / Android 15 (API 35), 12 test cases. Its `Selectel UI required` check must + succeed for the current PR commit before merging; a new commit requires a new manual run. +- `additional`: Galaxy A03 / Android 11, Galaxy A14 / Android 13, Galaxy A35 / Android 16, + Pixel 8 / Android 17 — four devices, 48 test cases. Its `Selectel UI additional` check is optional. + It excludes the required device configuration and cannot satisfy or overwrite the required check. + +The fixed snapshot is `config/selectel-devices.json`, captured on 2026-09-07. Both profiles use ARM64. +Additional devices run at most two simultaneously in GitHub; locally they run sequentially. There is +no runtime discovery or substitution if a configuration is unavailable. Update the snapshot in a PR. +Each profile fails if its build, any selected device or lease cleanup fails. Native/Python/Android +checks still run automatically. The two UI profiles are launched separately; additional is not needed +for the merge gate. ```sh -gh workflow run selectel-ui.yml --ref YOUR_PR_BRANCH -f devices=single -# Or: -f devices=all +gh workflow run selectel-ui.yml --ref YOUR_PR_BRANCH -f devices=required +# Or: -f devices=additional ``` Only repository branches can be selected; fork code is not automatically tested with secrets. @@ -31,19 +34,19 @@ python3 scripts/github_actions.py python3 scripts/github_actions.py --dry-run ``` -Choose an open PR and then UI `single`, UI `all`, or a rerun of ordinary CI. The launcher shows the +Choose an open PR and then UI `required`, UI `additional`, or a rerun of ordinary CI. The launcher shows the branch inputs and current commit, asks before sending the request, and refuses to proceed if the PR changed during selection. UI dispatch resolves the branch head when GitHub accepts it; avoid pushing while launching. CI reruns target only an existing, completed run for the current PR commit. Fork PRs are excluded. Release workflows are not offered. `q` or Ctrl+C cancels. -Only Python's standard library is required. Authentication uses `GH_TOKEN`, then `GITHUB_TOKEN`, then -an existing `gh auth` login if the optional GitHub CLI is installed. Otherwise the token is requested -with hidden input and is never saved. A fine-grained token needs repository **Actions: write** and -**Pull requests: read**. No local Selectel secrets or Android toolchain are needed for this launcher. -Use `--repo OWNER/REPO` to override the default repository. Network failures are not retried: if a -launch response is lost, check Actions before launching again. The output links to the workflow or CI -run. The launcher can be used for our registered workflow before the web Run workflow button appears. +The Python code uses only the standard library and invokes **GitHub CLI (`gh`)**. Install `gh`, then +run `gh auth login` once (or use its existing `GH_TOKEN`/`GITHUB_TOKEN` environment authentication). +The script has no HTTP client or token storage. It calls `gh pr list/view`, `gh workflow run`, and +`gh run list/rerun` with argument arrays, without a shell. No local Selectel secrets or Android +toolchain are needed. Use `--repo OWNER/REPO` to override the repository. The launcher lists up to +1,000 open PRs. Failed or timed-out launches are not retried: check Actions before trying again. +The launcher works for our registered workflow before its web Run workflow button appears. ## Setup @@ -71,14 +74,14 @@ availability and the actual tariff depend on the provider. The UI build includes compilation. ADB uses its own key directory and server port 5038 (`SELECTEL_ADB_PORT` overrides it), so local phones and emulators are not targeted. -## Local full matrix +## Local additional devices ```sh -bundle exec fastlane android ui_test_artifacts profile:all -bundle exec fastlane android selectel_ui_tests profile:all +bundle exec fastlane android ui_test_artifacts profile:additional +bundle exec fastlane android selectel_ui_tests profile:additional ``` -Use `profile:all` for both commands. The selected catalog uses ARM64; if ARM32 is explicitly added +Use `profile:additional` for both commands. The selected profiles use ARM64; if ARM32 is explicitly added to the snapshot later, the build switches to a universal APK. Locally, devices run sequentially with independent lease journals and report directories named by catalog ID. Test failures do not stop remaining configurations; cleanup failures stop new rentals immediately. diff --git a/docs/ru/fastlane.md b/docs/ru/fastlane.md index bb1f45d..5b9498e 100644 --- a/docs/ru/fastlane.md +++ b/docs/ru/fastlane.md @@ -45,13 +45,13 @@ bundle exec fastlane lanes | `bundle exec fastlane android release_artifacts` | Собирает и проверяет подписанные APK, AAB, native debug symbols и `SHA256SUMS` в `dist/release`. | | `bundle exec fastlane android selectel_contract_tests` | Проверяет освобождение аренды и разбор результатов без аренды устройства. | | `bundle exec fastlane android ui_test_artifacts` | Запускает контрактные тесты Selectel и собирает debug APK приложения и UI-тестов. | -| `bundle exec fastlane android ui_test_artifacts profile:all` | Собирает APK для всех зафиксированных конфигураций. | +| `bundle exec fastlane android ui_test_artifacts profile:additional` | Собирает APK для дополнительных необязательных конфигураций. | | `bundle exec fastlane android selectel_probe` | Проверяет доступ и наличие устройств без аренды. | | `bundle exec fastlane android selectel_acquire` | Арендует одно устройство и сохраняет журнал; APK должны быть собраны заранее. | | `bundle exec fastlane android selectel_run` | Запускает тесты на устройстве из журнала аренды. | | `bundle exec fastlane android selectel_release` | Удаляет записанную аренду и временный ADB-ключ; повторный вызов безопасен. | | `bundle exec fastlane android selectel_ui_tests` | Арендует устройство, выполняет готовые тесты и освобождает аренду в finally. | -| `bundle exec fastlane android selectel_ui_tests profile:all` | Последовательно проверяет фиксированный каталог, освобождая аренды и разделяя отчёты. | +| `bundle exec fastlane android selectel_ui_tests profile:additional` | Последовательно проверяет четыре дополнительные конфигурации, исключая обязательный Android 15. | Для release lane нужна конфигурация подписи из раздела [Signed release builds](../../README.md#signed-release-builds). Lane только собирает артефакты: он diff --git a/docs/ru/selectel-ui-tests.md b/docs/ru/selectel-ui-tests.md index fc2920b..a166d5c 100644 --- a/docs/ru/selectel-ui-tests.md +++ b/docs/ru/selectel-ui-tests.md @@ -1,21 +1,24 @@ # UI-тесты Selectel Workflow `Selectel UI` запускается **только вручную**: Actions → Selectel UI → Run workflow, -выбрать ветку PR и `single` либо `all`. Пуши не арендуют устройства. Для слияния нужна успешная -проверка `Selectel UI required` на текущем коммите PR; после нового коммита нужен новый ручной прогон. -Native/JVM/lint продолжают запускаться автоматически. Итоговая проверка падает при ошибке сборки -или любого выбранного устройства, в том числе при недоступности конфигурации. - -В `config/selectel-devices.json` зафиксированы **пять ARM64-конфигураций** из каталога на -2026-09-07: Android 11 (Galaxy A03), 13 (Galaxy A14), 15 (Galaxy A14), 16 (Galaxy A35) и 17 (Pixel 8). `single` выбирает Galaxy A14 / API 35 / ARM64; `all` запускает -по одному устройству каждой конфигурации, максимум два одновременно в GitHub. Автоматического -обнаружения списка при запуске нет; одинаковые физические экземпляры не дублируются. Список меняется -явно через PR. Полный прогон — 60 тестовых случаев и больше расходов, чем одно устройство. -Доступность и совместимость всех записей каталога не гарантированы; подмены при ошибке нет. +выбрать ветку PR и один из независимых профилей. Пуши не арендуют устройства. + +- `required`: Galaxy A14 / Android 15 (API 35), 12 тестовых случаев. Проверка `Selectel UI required` + обязательна для текущего коммита PR; после нового коммита нужен новый ручной прогон. +- `additional`: Galaxy A03 / Android 11, Galaxy A14 / Android 13, Galaxy A35 / Android 16, + Pixel 8 / Android 17 — четыре устройства, 48 тестовых случаев. Проверка `Selectel UI additional` + необязательна. Она не повторяет конфигурацию required и не заменяет его обязательный статус. + +Список зафиксирован в `config/selectel-devices.json` по каталогу на 2026-09-07. Оба профиля используют +ARM64. Дополнительные устройства запускаются максимум по два одновременно в GitHub и последовательно +локально. Автоматического обнаружения и подмены недоступных конфигураций нет; список меняется через PR. +Ошибка сборки, любого выбранного устройства или освобождения аренды приводит к ошибке профиля. +Native/Python/Android проверки продолжают запускаться автоматически. Профили UI запускаются отдельно; +для слияния достаточно успешного required. ```sh -gh workflow run selectel-ui.yml --ref YOUR_PR_BRANCH -f devices=single -# Или: -f devices=all +gh workflow run selectel-ui.yml --ref YOUR_PR_BRANCH -f devices=required +# Или: -f devices=additional ``` Можно выбрать только ветку этого репозитория; код форков автоматически не получает секреты. @@ -31,19 +34,19 @@ python3 scripts/github_actions.py python3 scripts/github_actions.py --dry-run ``` -Выберите открытый PR, затем UI `single`, UI `all` или повтор обычного CI. Скрипт показывает параметры +Выберите открытый PR, затем UI `required`, UI `additional` или повтор обычного CI. Скрипт показывает параметры ветки и текущий коммит, спрашивает перед отправкой и прекращает запуск, если PR изменился за время выбора. При UI-запуске GitHub берёт коммит ветки в момент принятия запроса; не делайте пуш одновременно с запуском. Повтор CI использует только существующий завершённый прогон текущего коммита PR. PR из форков исключены. Release-workflow в меню нет. `q` или Ctrl+C отменяет операцию. -Нужна только стандартная библиотека Python. Авторизация: `GH_TOKEN`, затем `GITHUB_TOKEN`, затем -существующий вход `gh auth`, если необязательный GitHub CLI установлен. Иначе скрипт скрыто запросит -токен и не сохранит его. Fine-grained токену нужны права репозитория **Actions: write** и -**Pull requests: read**. Локальные секреты Selectel и Android toolchain для этого скрипта не нужны. -Другой репозиторий задаётся через `--repo OWNER/REPO`. При сетевой ошибке повторной отправки нет: -если ответ о запуске потерялся, сначала проверьте Actions. В конце выводится ссылка на workflow или -прогон CI. Для нашего зарегистрированного workflow скрипт работает и до появления кнопки Run workflow. +Python-код использует только стандартную библиотеку и вызывает **GitHub CLI (`gh`)**. Установите `gh` +и один раз выполните `gh auth login` либо используйте его авторизацию через `GH_TOKEN`/`GITHUB_TOKEN`. +Своего HTTP-клиента и хранения токенов у скрипта нет. Он вызывает `gh pr list/view`, `gh workflow run` +и `gh run list/rerun` массивами аргументов, без shell. Локальные секреты Selectel и Android toolchain +не нужны. Другой репозиторий задаётся через `--repo OWNER/REPO`. В списке до 1 000 открытых PR. +Ошибка или таймаут запуска не приводят к повторной отправке: сначала проверьте Actions. +Для нашего зарегистрированного workflow скрипт работает и до появления кнопки Run workflow. ## Настройка @@ -71,14 +74,14 @@ bundle exec fastlane android selectel_ui_tests ADB использует отдельные ключи и порт сервера 5038 (переопределяется `SELECTEL_ADB_PORT`), поэтому локальные телефоны и эмуляторы не затрагиваются. -## Полная матрица локально +## Дополнительные устройства локально ```sh -bundle exec fastlane android ui_test_artifacts profile:all -bundle exec fastlane android selectel_ui_tests profile:all +bundle exec fastlane android ui_test_artifacts profile:additional +bundle exec fastlane android selectel_ui_tests profile:additional ``` -Для обеих команд нужен `profile:all`. Выбранный каталог использует ARM64; если позднее явно добавить +Для обеих команд нужен `profile:additional`. Выбранный каталог использует ARM64; если позднее явно добавить в список ARM32, сборка переключится на универсальный APK. Локально устройства запускаются последовательно; журналы аренды и отчёты разделены по ID из каталога. Ошибки тестов не останавливают остальные конфигурации; ошибка освобождения аренды прекращает новые аренды. diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 7588ef5..69ef411 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -100,11 +100,11 @@ platform :android do desc "Build debug application and instrumentation APKs before renting a device" lane :ui_test_artifacts do |options| - profile = options[:profile] || "single" - UI.user_error!("profile must be single or all") unless %w[single all].include?(profile) + profile = options[:profile] || "required" + UI.user_error!("profile must be required or additional") unless %w[required additional].include?(profile) require "json" devices = JSON.parse(File.read(File.join(project_root, "config", "selectel-devices.json"))).fetch("devices") - needs_arm32 = profile == "all" && devices.any? { |device| device.fetch("abi") == "armeabi-v7a" } + needs_arm32 = profile == "additional" && devices.any? { |device| device.fetch("abi") == "armeabi-v7a" } variant = needs_arm32 ? "universal" : "arm64-v8a" ensure_no_release_signing selectel_contract_tests @@ -135,9 +135,9 @@ platform :android do desc "Rent, run prebuilt UI tests, and release a Selectel Android device" lane :selectel_ui_tests do |options| - profile = options[:profile] || "single" - UI.user_error!("profile must be single or all") unless %w[single all].include?(profile) - if profile == "all" + profile = options[:profile] || "required" + UI.user_error!("profile must be required or additional") unless %w[required additional].include?(profile) + if profile == "additional" sh("bash", File.join(project_root, "scripts", "selectel-ui.sh"), "matrix-test", "--profile", profile) else sh("bash", File.join(project_root, "scripts", "selectel-ui.sh"), "test") diff --git a/fastlane/README.md b/fastlane/README.md index 3ba47a3..82ffb32 100644 --- a/fastlane/README.md +++ b/fastlane/README.md @@ -18,6 +18,6 @@ existing release scripts. It requires the signing environment documented in the Publishing to an app store is not performed by any lane. Real-device UI tests use `ui_test_artifacts` followed by `selectel_ui_tests`. -Pass `profile:all` to both lanes for the fixed full device catalog. See the complete +Pass `profile:additional` to both lanes for the four optional configurations, excluding the required Android 15 device. See the complete [English command reference](../docs/en/fastlane.md), [Russian command reference](../docs/ru/fastlane.md), and [Selectel setup](../docs/en/selectel-ui-tests.md) for probe, lease and recovery commands. diff --git a/scripts/github_actions.py b/scripts/github_actions.py index 74bcb6b..9f4852b 100644 --- a/scripts/github_actions.py +++ b/scripts/github_actions.py @@ -1,117 +1,83 @@ #!/usr/bin/env python3 -"""Choose a PR and launch its tests using the GitHub API and Python's stdlib.""" +"""Choose a PR and launch its tests through the authenticated GitHub CLI.""" import argparse -import getpass import json import os import re -import shutil +import shlex import subprocess import sys -import urllib.error -import urllib.parse -import urllib.request REPOSITORY = "andre487/AndroidMegaProxy" MODES = [ - ("single", "Selectel UI: one Galaxy A14 (Android 15, paid rental)"), - ("all", "Selectel UI: Android 11, 13, 15, 16, 17 (five paid rentals)"), - ("ci", "Re-run CI: native Go + Android/JVM/lint/build (no device rental)"), + ("required", "Required UI: Galaxy A14 / Android 15 (one paid rental)"), + ("additional", "Additional UI: Android 11, 13, 16, 17 (four paid rentals)"), + ("ci", "Re-run CI: Python, Go, Android/JVM/lint/build (no device rental)"), ] - - -def github_token(): - for name in ("GH_TOKEN", "GITHUB_TOKEN"): - if os.environ.get(name, "").strip(): - return os.environ[name].strip() - if shutil.which("gh"): - result = subprocess.run( - ["gh", "auth", "token", "--hostname", "github.com"], - capture_output=True, - text=True, - timeout=15, - ) - if result.returncode == 0 and result.stdout.strip(): - return result.stdout.strip() - token = getpass.getpass("GitHub token (not saved): ").strip() - if not token: - raise RuntimeError( - "A GitHub token is required (Actions: write, Pull requests: read)." - ) - return token +PR_FIELDS = "number,title,state,headRefName,headRefOid,isCrossRepository" class GitHub: - def __init__(self, repository, token): + def __init__(self, repository): if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository): raise RuntimeError("Repository must be OWNER/REPO.") self.repository = repository - self.token = token - - def request(self, method, path, body=None): - request = urllib.request.Request( - "https://api.github.com/repos/" + self.repository + path, - method=method, - data=json.dumps(body).encode() if body is not None else None, - headers={ - "Authorization": "Bearer " + self.token, - "Accept": "application/vnd.github+json", - "Content-Type": "application/json", - "X-GitHub-Api-Version": "2022-11-28", - "User-Agent": "MegaProxy-Actions", - }, + + def argv(self, *args): + return ["gh", *args, "--repo", self.repository] + + def run(self, *args): + mutating = args[:2] in (("workflow", "run"), ("run", "rerun")) + uncertain = ( + " Check Actions before retrying; the launch may have been accepted." + if mutating + else "" ) + env = os.environ.copy() + env.pop("GH_DEBUG", None) try: - with urllib.request.urlopen(request, timeout=30) as response: - data = response.read() - return json.loads(data) if data else None - except urllib.error.HTTPError as error: - # Do not echo server responses, headers, or the token. - hints = { - 401: "Check GitHub authentication.", - 403: "Check Actions write permission and rate limits.", - 404: "The PR/workflow may be unavailable to this token.", - 422: "Check workflow_dispatch support on the selected branch or rerun eligibility.", - } - suffix = ( - " Launch outcome is unknown; check Actions before retrying." - if method == "POST" and error.code >= 500 - else "" + result = subprocess.run( + self.argv(*args), capture_output=True, text=True, timeout=60, env=env ) + except FileNotFoundError: raise RuntimeError( - f'GitHub HTTP {error.code}. {hints.get(error.code, "Request failed.")}' - + suffix + "GitHub CLI is required. Install gh and run: gh auth login" + ) from None + except subprocess.TimeoutExpired: + raise RuntimeError("GitHub CLI timed out." + uncertain) from None + if result.returncode: + # Authentication is owned by gh; never echo tokens from its diagnostics. + diagnostic = result.stderr.strip() + for name in ("GH_TOKEN", "GITHUB_TOKEN"): + if env.get(name): + diagnostic = diagnostic.replace(env[name], "") + diagnostic = "".join(c if c.isprintable() else " " for c in diagnostic)[ + :500 + ] + raise RuntimeError(f"GitHub CLI failed: {diagnostic}" + uncertain) + return result.stdout.strip() + + def read_json(self, *args): + try: + return json.loads(self.run(*args)) + except json.JSONDecodeError: + raise RuntimeError( + "GitHub CLI returned invalid JSON. Update gh and retry." ) from None - except (urllib.error.URLError, TimeoutError): - suffix = ( - " Launch outcome is unknown; check Actions before retrying." - if method == "POST" - else "" - ) - raise RuntimeError("GitHub network request failed." + suffix) from None def open_prs(self): - result = [] - page = 1 - while True: - items = self.request("GET", f"/pulls?state=open&per_page=100&page={page}") - result.extend(items) - if len(items) < 100: - return result - page += 1 - + return self.read_json( + "pr", "list", "--state", "open", "--limit", "1000", "--json", PR_FIELDS + ) -def same_repository(pr, repository): - return (pr["head"].get("repo") or {}).get( - "full_name", "" - ).lower() == repository.lower() + def pr(self, number): + return self.read_json("pr", "view", str(number), "--json", PR_FIELDS) def choose(title, options): print("\n" + title) for index, (_, label) in enumerate(options, 1): - # PR titles and branch names are untrusted terminal text. print(f" {index}. " + "".join(c if c.isprintable() else " " for c in label)) while True: value = input("Choose a number (q to cancel): ").strip() @@ -123,65 +89,78 @@ def choose(title, options): def plan_run(client, pr, mode): - if pr["state"] != "open" or not same_repository(pr, client.repository): + if pr["state"] != "OPEN" or pr["isCrossRepository"]: raise RuntimeError( - "Select an open PR from a branch in this repository; forks are unsupported." + "Select an open PR from this repository; forks are unsupported." ) - if mode in ("single", "all"): + if mode in ("required", "additional"): return ( - "/actions/workflows/selectel-ui.yml/dispatches", - {"ref": pr["head"]["ref"], "inputs": {"devices": mode}}, + [ + "workflow", + "run", + "selectel-ui.yml", + "--ref", + pr["headRefName"], + "-f", + f"devices={mode}", + ], f"https://github.com/{client.repository}/actions/workflows/selectel-ui.yml", ) if mode != "ci": raise RuntimeError("Unknown test selection.") - query = urllib.parse.urlencode( - {"head_sha": pr["head"]["sha"], "event": "pull_request", "per_page": 100} + runs = client.read_json( + "run", + "list", + "--workflow", + "ci.yml", + "--commit", + pr["headRefOid"], + "--branch", + pr["headRefName"], + "--event", + "pull_request", + "--limit", + "1", + "--json", + "databaseId,status,url,headSha,headBranch", ) - runs = client.request("GET", "/actions/workflows/ci.yml/runs?" + query)[ - "workflow_runs" - ] - runs = [ - run - for run in runs - if run["head_sha"] == pr["head"]["sha"] - and run["head_branch"] == pr["head"]["ref"] - and (run.get("head_repository") or {}).get("full_name", "").lower() - == client.repository.lower() - ] - if not runs: + if ( + not runs + or runs[0]["headSha"] != pr["headRefOid"] + or runs[0]["headBranch"] != pr["headRefName"] + ): raise RuntimeError( "No CI run exists for this PR commit yet. CI starts automatically on pushes." ) - run = max(runs, key=lambda item: item["id"]) + run = runs[0] if run["status"] != "completed": - raise RuntimeError("CI is already queued or running: " + run["html_url"]) - return f'/actions/runs/{run["id"]}/rerun', {}, run["html_url"] + raise RuntimeError("CI is already queued or running: " + run["url"]) + return ["run", "rerun", str(run["databaseId"])], run["url"] def launch(client, pr, mode, dry_run=False): - path, body, url = plan_run(client, pr, mode) - print(f'\nPR #{pr["number"]}, commit {pr["head"]["sha"][:12]}') - print(json.dumps(body, ensure_ascii=True)) + command, url = plan_run(client, pr, mode) + print(f'\nPR #{pr["number"]}, commit {pr["headRefOid"][:12]}') + print(shlex.join(client.argv(*command))) if dry_run: print("Dry run: no workflow started.\n" + url) return if input("Start this run? [y/N]: ").strip().lower() != "y": print("Cancelled.") return - current = client.request("GET", f'/pulls/{pr["number"]}') + current = client.pr(pr["number"]) if ( - current["state"] != "open" - or not same_repository(current, client.repository) - or current["head"]["sha"] != pr["head"]["sha"] - or current["head"]["ref"] != pr["head"]["ref"] + current["state"] != "OPEN" + or current["isCrossRepository"] + or current["headRefOid"] != pr["headRefOid"] + or current["headRefName"] != pr["headRefName"] ): raise RuntimeError( "The PR changed while choosing. Start again to review its current commit." ) - # Dispatch uses a branch, so GitHub resolves its head at acceptance time. Never retry POST. - response = client.request("POST", path, body) - print("Launch accepted.\n" + ((response or {}).get("html_url") or url)) + # gh dispatches by branch; GitHub resolves its head at acceptance. Never retry launches. + output = client.run(*command) + print("Launch accepted.\n" + (output or url)) def main(): @@ -192,8 +171,8 @@ def main(): ) args = parser.parse_args() try: - client = GitHub(args.repo, github_token()) - prs = [pr for pr in client.open_prs() if same_repository(pr, args.repo)] + client = GitHub(args.repo) + prs = [pr for pr in client.open_prs() if not pr["isCrossRepository"]] if not prs: print("No open PRs from repository branches.") return 0 @@ -201,22 +180,15 @@ def main(): "Open pull requests", [(pr["number"], f'#{pr["number"]} {pr["title"]}') for pr in prs], ) - pr = client.request("GET", f"/pulls/{number}") + pr = client.pr(number) mode = choose("Tests to run", MODES) launch(client, pr, mode, args.dry_run) return 0 except (KeyboardInterrupt, EOFError): print("\nCancelled.") return 130 - except (RuntimeError, subprocess.TimeoutExpired) as error: - print( - ( - str(error) - if isinstance(error, RuntimeError) - else "GitHub CLI authentication timed out." - ), - file=sys.stderr, - ) + except RuntimeError as error: + print(str(error), file=sys.stderr) return 1 diff --git a/scripts/selectel_ui.py b/scripts/selectel_ui.py index 8157754..767c934 100644 --- a/scripts/selectel_ui.py +++ b/scripts/selectel_ui.py @@ -168,8 +168,8 @@ def connect_device(path, endpoint): def device_matrix(profile): - if profile not in ("single", "all"): - raise RuntimeError("Device profile must be single or all") + if profile not in ("required", "additional"): + raise RuntimeError("Device profile must be required or additional") devices = json.loads((ROOT / "config/selectel-devices.json").read_text())["devices"] if not devices: raise RuntimeError("The fixed device catalog is empty") @@ -183,20 +183,21 @@ def device_matrix(profile): ): raise RuntimeError("Unsupported device matrix entry") ids.add(device["id"]) - if profile == "single": - devices = [ - d - for d in devices - if d["manufacturer"] == "SAMSUNG" - and d["model"] == "Galaxy A14" - and d["api"] == "35" - and d["abi"] == "arm64-v8a" - ] - if len(devices) != 1: - raise RuntimeError( - "The default device must occur exactly once in the fixed catalog" - ) - return devices + required = [ + d + for d in devices + if d["manufacturer"] == "SAMSUNG" + and d["model"] == "Galaxy A14" + and d["api"] == "35" + and d["abi"] == "arm64-v8a" + ] + if len(required) != 1: + raise RuntimeError( + "The required device must occur exactly once in the fixed catalog" + ) + if profile == "required": + return required + return [device for device in devices if device["id"] != required[0]["id"]] def select_configuration(device): @@ -573,7 +574,9 @@ def main(): ], ) parser.add_argument("--state", type=Path, default=ROOT / ".selectel/lease.json") - parser.add_argument("--profile", choices=["single", "all"], default="single") + parser.add_argument( + "--profile", choices=["required", "additional"], default="required" + ) args = parser.parse_args() args.state = args.state.resolve() diff --git a/scripts/tests/test_github_actions.py b/scripts/tests/test_github_actions.py index fc22343..9338210 100644 --- a/scripts/tests/test_github_actions.py +++ b/scripts/tests/test_github_actions.py @@ -1,11 +1,10 @@ import copy import importlib.util -import json import os +import subprocess import unittest -import urllib.error from pathlib import Path -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import Mock, patch spec = importlib.util.spec_from_file_location( "github_actions", Path(__file__).parents[1] / "github_actions.py" @@ -16,141 +15,155 @@ class LauncherTest(unittest.TestCase): def setUp(self): - self.client = Mock(repository=m.REPOSITORY) + self.client = m.GitHub(m.REPOSITORY) self.pr = { "number": 31, - "state": "open", - "head": { - "sha": "abc", - "ref": "feature/test", - "repo": {"full_name": m.REPOSITORY}, - }, + "state": "OPEN", + "headRefOid": "abc", + "headRefName": "feature/test", + "isCrossRepository": False, } - def test_dispatch_uses_selected_branch_and_profile(self): - for mode in ("single", "all"): - path, body, _ = m.plan_run(self.client, self.pr, mode) - self.assertEqual("/actions/workflows/selectel-ui.yml/dispatches", path) - self.assertEqual({"ref": "feature/test", "inputs": {"devices": mode}}, body) + def test_required_and_additional_use_distinct_dispatch_inputs(self): + for mode in ("required", "additional"): + command, _ = m.plan_run(self.client, self.pr, mode) + self.assertEqual( + [ + "workflow", + "run", + "selectel-ui.yml", + "--ref", + "feature/test", + "-f", + f"devices={mode}", + ], + command, + ) - def test_dry_run_never_posts_or_requests_confirmation(self): - with patch("builtins.input", side_effect=AssertionError): - m.launch(self.client, self.pr, "all", dry_run=True) - self.client.request.assert_not_called() + def test_dry_run_never_launches_or_requests_confirmation(self): + with ( + patch.object(self.client, "run", side_effect=AssertionError), + patch("builtins.input", side_effect=AssertionError), + ): + m.launch(self.client, self.pr, "additional", dry_run=True) def test_changed_or_closed_pr_prevents_dispatch(self): for change in ("sha", "closed", "fork"): current = copy.deepcopy(self.pr) if change == "sha": - current["head"]["sha"] = "new" + current["headRefOid"] = "new" elif change == "closed": - current["state"] = "closed" + current["state"] = "CLOSED" else: - current["head"]["repo"]["full_name"] = "other/repo" - self.client.request.reset_mock() - self.client.request.return_value = current + current["isCrossRepository"] = True with ( + patch.object(self.client, "pr", return_value=current), + patch.object(self.client, "run", side_effect=AssertionError), patch("builtins.input", return_value="y"), self.assertRaisesRegex(RuntimeError, "PR changed"), ): - m.launch(self.client, self.pr, "single") - self.assertEqual( - ["GET"], [call.args[0] for call in self.client.request.call_args_list] - ) + m.launch(self.client, self.pr, "required") - def test_cancel_never_posts(self): - with patch("builtins.input", return_value="n"): - m.launch(self.client, self.pr, "single") - self.client.request.assert_not_called() + def test_cancel_never_launches(self): + with ( + patch.object(self.client, "run", side_effect=AssertionError), + patch("builtins.input", return_value="n"), + ): + m.launch(self.client, self.pr, "required") - def test_confirmed_dispatch_posts_exactly_once(self): - self.client.request.side_effect = [self.pr, None] - with patch("builtins.input", return_value="y"): - m.launch(self.client, self.pr, "single") - self.assertEqual( - ["GET", "POST"], - [call.args[0] for call in self.client.request.call_args_list], + def test_confirmed_dispatch_launches_exactly_once(self): + with ( + patch.object(self.client, "pr", return_value=self.pr), + patch.object(self.client, "run", return_value="") as run, + patch("builtins.input", return_value="y"), + ): + m.launch(self.client, self.pr, "required") + run.assert_called_once_with( + "workflow", + "run", + "selectel-ui.yml", + "--ref", + "feature/test", + "-f", + "devices=required", ) def test_fork_is_rejected(self): - self.pr["head"]["repo"] = {"full_name": "other/repo"} + self.pr["isCrossRepository"] = True with self.assertRaisesRegex(RuntimeError, "forks"): - m.plan_run(self.client, self.pr, "all") + m.plan_run(self.client, self.pr, "additional") - def test_ci_does_not_rerun_old_commit_or_duplicate_active_run(self): + def test_ci_filters_current_commit_and_branch(self): run = { - "id": 20, - "head_sha": "abc", - "head_branch": "feature/test", - "head_repository": {"full_name": m.REPOSITORY}, + "databaseId": 20, + "headSha": "abc", + "headBranch": "feature/test", "status": "completed", - "html_url": "https://github.com/run", + "url": "https://github.com/run", } - self.client.request.return_value = {"workflow_runs": [run]} + with patch.object(self.client, "read_json", return_value=[run]) as read: + self.assertEqual( + ["run", "rerun", "20"], m.plan_run(self.client, self.pr, "ci")[0] + ) + args = read.call_args.args + self.assertEqual("abc", args[args.index("--commit") + 1]) + self.assertEqual("feature/test", args[args.index("--branch") + 1]) + run["headSha"] = "old" + with self.assertRaisesRegex(RuntimeError, "No CI run"): + m.plan_run(self.client, self.pr, "ci") + run.update(headSha="abc", status="in_progress") + with self.assertRaisesRegex(RuntimeError, "already queued or running"): + m.plan_run(self.client, self.pr, "ci") + + def test_arguments_are_passed_without_a_shell(self): + with patch.object( + m.subprocess, "run", return_value=Mock(returncode=0, stdout="[]", stderr="") + ) as run: + self.client.run("pr", "view", "branch/$(not-a-command)", "--json", "number") + args, kwargs = run.call_args self.assertEqual( - "/actions/runs/20/rerun", m.plan_run(self.client, self.pr, "ci")[0] + [ + "gh", + "pr", + "view", + "branch/$(not-a-command)", + "--json", + "number", + "--repo", + m.REPOSITORY, + ], + args[0], ) - run["head_sha"] = "old" - with self.assertRaisesRegex(RuntimeError, "No CI run"): - m.plan_run(self.client, self.pr, "ci") - run.update(head_sha="abc", status="in_progress") - with self.assertRaisesRegex(RuntimeError, "already queued or running"): - m.plan_run(self.client, self.pr, "ci") - - def test_environment_token_does_not_need_gh(self): + self.assertFalse(kwargs.get("shell", False)) + + def test_timeout_never_retries_a_launch(self): + with patch.object( + m.subprocess, "run", side_effect=subprocess.TimeoutExpired(["gh"], 60) + ) as run: + with self.assertRaisesRegex(RuntimeError, "Check Actions before retrying"): + self.client.run("workflow", "run", "selectel-ui.yml") + self.assertEqual(1, run.call_count) + + def test_missing_cli_is_explained(self): with ( - patch.dict(os.environ, {"GH_TOKEN": "test-token"}), - patch.object(m.subprocess, "run", side_effect=AssertionError), + patch.object(m.subprocess, "run", side_effect=FileNotFoundError), + self.assertRaisesRegex(RuntimeError, "gh auth login"), ): - self.assertEqual("test-token", m.github_token()) + self.client.open_prs() - def test_pr_list_paginates(self): - client = m.GitHub(m.REPOSITORY, "test-token") - with patch.object( - client, "request", side_effect=[[self.pr] * 100, [self.pr]] - ) as request: - self.assertEqual(101, len(client.open_prs())) - self.assertIn("page=2", request.call_args.args[1]) - - -class GitHubRequestTest(unittest.TestCase): - def test_dispatch_serializes_json_and_accepts_empty_success(self): - client = m.GitHub(m.REPOSITORY, "test-token") - response = MagicMock() - response.__enter__.return_value.read.return_value = b"" - body = {"ref": "feature/test", "inputs": {"devices": "single"}} - with patch.object( - m.urllib.request, "urlopen", return_value=response - ) as urlopen: - self.assertIsNone( - client.request( - "POST", "/actions/workflows/selectel-ui.yml/dispatches", body - ) - ) - request = urlopen.call_args.args[0] - self.assertEqual(body, json.loads(request.data)) - self.assertEqual("Bearer test-token", request.get_header("Authorization")) - self.assertEqual("POST", request.method) - - def test_ambiguous_post_failure_is_not_retried_or_leaked(self): - client = m.GitHub(m.REPOSITORY, "test-token") - for error in ( - TimeoutError("test-token"), - urllib.error.HTTPError( - "https://api.github.com", 503, "test-token", {}, None - ), + def test_tokens_are_not_echoed_from_cli_errors(self): + with ( + patch.dict(os.environ, {"GH_TOKEN": "test-token", "GH_DEBUG": "api"}), + patch.object( + m.subprocess, + "run", + return_value=Mock(returncode=1, stderr="failed test-token"), + ) as run, ): - with patch.object( - m.urllib.request, "urlopen", side_effect=error - ) as urlopen: - with self.assertRaisesRegex( - RuntimeError, "outcome is unknown" - ) as raised: - client.request( - "POST", "/actions/workflows/selectel-ui.yml/dispatches", {} - ) - self.assertEqual(1, urlopen.call_count) - self.assertNotIn("test-token", str(raised.exception)) + with self.assertRaises(RuntimeError) as raised: + self.client.open_prs() + self.assertNotIn("test-token", str(raised.exception)) + self.assertNotIn("GH_DEBUG", run.call_args.kwargs["env"]) if __name__ == "__main__": diff --git a/scripts/tests/test_selectel_ui.py b/scripts/tests/test_selectel_ui.py index 7de0561..0d59a97 100644 --- a/scripts/tests/test_selectel_ui.py +++ b/scripts/tests/test_selectel_ui.py @@ -163,19 +163,19 @@ def test_fixed_catalog_needs_no_credentials_or_api(self): patch.dict(os.environ, {}, clear=True), patch.object(m, "Client", side_effect=AssertionError), ): - devices = m.device_matrix("all") - single = m.device_matrix("single") - self.assertEqual(1, len(single)) - self.assertEqual("Galaxy A14", single[0]["model"]) - self.assertIn(single[0], devices) + devices = m.device_matrix("additional") + required = m.device_matrix("required") + self.assertEqual(1, len(required)) + self.assertEqual("Galaxy A14", required[0]["model"]) + self.assertNotIn(required[0], devices) + self.assertEqual("35", required[0]["api"]) + self.assertEqual(4, len(devices)) self.assertEqual(len(devices), len({d["id"] for d in devices})) self.assertEqual({"arm64-v8a"}, {d["abi"] for d in devices}) - self.assertEqual( - ["30", "33", "35", "36", "37"], sorted(d["api"] for d in devices) - ) + self.assertEqual(["30", "33", "36", "37"], sorted(d["api"] for d in devices)) def test_selection_does_not_substitute_another_configuration(self): - device = m.device_matrix("single")[0] + device = m.device_matrix("required")[0] with patch.dict(os.environ): m.select_configuration(device) candidate = dict( @@ -209,7 +209,7 @@ def test_cleanup_attempts_every_journal_after_failure(self): self.assertEqual(paths, [call.args[1] for call in release.call_args_list]) def test_matrix_continues_test_failures_but_stops_cleanup_failures(self): - devices = m.device_matrix("all")[:2] + devices = m.device_matrix("additional")[:2] for cleanup_fails in [False, True]: with ( tempfile.TemporaryDirectory() as root, @@ -229,7 +229,9 @@ def test_matrix_continues_test_failures_but_stops_cleanup_failures(self): ), ): with self.assertRaises(RuntimeError): - m.run_matrix(None, Path(root) / ".selectel/lease.json", "all") + m.run_matrix( + None, Path(root) / ".selectel/lease.json", "additional" + ) self.assertEqual(1 if cleanup_fails else 2, acquire.call_count) report = json.loads( (Path(root) / "app/build/reports/selectel/matrix.json").read_text() From 1b839fc06099f794c4316313da1b47a7abf7e21c Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Mon, 7 Sep 2026 20:30:39 +0300 Subject: [PATCH 09/13] Add scripts/github_actions.py +x --- scripts/github_actions.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 scripts/github_actions.py diff --git a/scripts/github_actions.py b/scripts/github_actions.py old mode 100644 new mode 100755 From d9097d8aadc4b62c7266ef0661bd40c78da3dbfe Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Mon, 7 Sep 2026 20:41:44 +0300 Subject: [PATCH 10/13] Publish manual UI commit statuses and close cancelled runs during recovery --- .github/workflows/ci.yml | 24 ++++++++++++++ .github/workflows/selectel-cleanup.yml | 26 ++++++++++++++++ .github/workflows/selectel-ui.yml | 43 +++++++++++++++++++++++--- AGENTS.md | 3 +- docs/en/selectel-ui-tests.md | 12 +++++++ docs/ru/selectel-ui-tests.md | 13 ++++++++ 6 files changed, 116 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0e4fb9..2ee3664 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,30 @@ concurrency: cancel-in-progress: true jobs: + ui-status: + name: Prepare manual UI status + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + permissions: + statuses: write + steps: + - uses: actions/github-script@v8 + with: + script: | + const sha = context.payload.pull_request.head.sha; + const name = 'Selectel UI required'; + const statuses = await github.paginate(github.rest.repos.listCommitStatusesForRef, { + ...context.repo, ref: sha, per_page: 100 + }); + // A CI rerun must preserve an existing UI result or active manual run. + if (!statuses.some(status => status.context === name)) { + await github.rest.repos.createCommitStatus({ + ...context.repo, sha, context: name, state: 'pending', + description: 'Awaiting manual required UI run for this commit', + target_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/workflows/selectel-ui.yml` + }); + } + native-tests: name: Native Go tests runs-on: ubuntu-latest diff --git a/.github/workflows/selectel-cleanup.yml b/.github/workflows/selectel-cleanup.yml index 4d3dfed..1847746 100644 --- a/.github/workflows/selectel-cleanup.yml +++ b/.github/workflows/selectel-cleanup.yml @@ -8,6 +8,7 @@ on: permissions: contents: read actions: read + statuses: write jobs: release: @@ -55,3 +56,28 @@ jobs: SELECTEL_PROJECT_ID: ${{ secrets.SELECTEL_PROJECT_ID }} FASTLANE_SKIP_DOCS: "1" run: bundle exec fastlane android selectel_release + - name: Close a failed or cancelled UI status + if: always() && github.event.workflow_run.conclusion != 'success' + uses: actions/github-script@v8 + with: + script: | + const run = context.payload.workflow_run; + const match = /^Selectel UI · (required|additional) · /.exec(run.display_title || ''); + if (!match) return; + const current = (await github.rest.actions.getWorkflowRun({ + ...context.repo, run_id: run.id + })).data; + if (current.run_attempt !== run.run_attempt) return; + const name = `Selectel UI ${match[1]}`; + const statuses = await github.paginate(github.rest.repos.listCommitStatusesForRef, { + ...context.repo, ref: run.head_sha, per_page: 100 + }); + const latest = statuses.find(status => status.context === name); + const waitingUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/workflows/selectel-ui.yml`; + // Never overwrite a newer run's result for the same commit/profile. + if (latest && latest.target_url !== run.html_url && latest.target_url !== waitingUrl) return; + await github.rest.repos.createCommitStatus({ + ...context.repo, sha: run.head_sha, context: name, state: 'failure', + description: 'UI run failed or was cancelled; inspect lease recovery logs', + target_url: run.html_url + }); diff --git a/.github/workflows/selectel-ui.yml b/.github/workflows/selectel-ui.yml index f375c23..305ec59 100644 --- a/.github/workflows/selectel-ui.yml +++ b/.github/workflows/selectel-ui.yml @@ -20,7 +20,27 @@ concurrency: cancel-in-progress: false jobs: + status: + name: Publish UI start + runs-on: ubuntu-latest + permissions: + statuses: write + steps: + - uses: actions/github-script@v8 + env: + PROFILE: ${{ inputs.devices }} + with: + script: | + const profile = process.env.PROFILE; + if (!['required', 'additional'].includes(profile)) throw new Error('Invalid UI profile'); + await github.rest.repos.createCommitStatus({ + ...context.repo, sha: context.sha, context: `Selectel UI ${profile}`, + state: 'pending', description: 'Building APKs and running device tests', + target_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}` + }); + build: + needs: status runs-on: ubuntu-latest timeout-minutes: 35 env: @@ -141,14 +161,29 @@ jobs: fi result: - # Use different contexts: additional must never satisfy the required check. - name: ${{ format('Selectel UI {0}', inputs.devices) }} + # Commit statuses are the PR-facing contexts; avoid an identically named check run. + name: UI result · ${{ inputs.devices }} if: always() needs: [build, ui-tests] runs-on: ubuntu-latest + permissions: + statuses: write steps: - - name: Require successful build and every selected device + - name: Publish the UI result on the tested commit + uses: actions/github-script@v8 env: + PROFILE: ${{ inputs.devices }} BUILD_RESULT: ${{ needs.build.result }} UI_RESULT: ${{ needs.ui-tests.result }} - run: test "$BUILD_RESULT" = success && test "$UI_RESULT" = success + with: + script: | + const profile = process.env.PROFILE; + if (!['required', 'additional'].includes(profile)) throw new Error('Invalid UI profile'); + const passed = process.env.BUILD_RESULT === 'success' && process.env.UI_RESULT === 'success'; + await github.rest.repos.createCommitStatus({ + ...context.repo, sha: context.sha, context: `Selectel UI ${profile}`, + state: passed ? 'success' : 'failure', + description: passed ? 'All selected UI tests and cleanup passed' : 'UI run failed, was cancelled, or cleanup failed', + target_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}` + }); + if (!passed) core.setFailed('UI build, device tests, or cleanup did not succeed'); diff --git a/AGENTS.md b/AGENTS.md index 01d6191..b700eca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,8 @@ branch names, credentials, signing material, or other secrets. automatic JVM/native checks. Run UI tests manually on the PR branch before merging; require `Selectel UI required` for the current commit. Never rent devices on each push. `required` covers Android 15; optional `additional` covers Android 11, 13, 16, 17 without overlap. - Keep their check names distinct. Both use `config/selectel-devices.json`; no runtime discovery. + Publish their distinct commit statuses so manual-run progress is visible on the PR; initialize + required as pending for each new PR head, preserving results on CI reruns. Both use `config/selectel-devices.json`; no runtime discovery. Do not reintroduce a software-emulated Android fallback. - Selectel credentials live in GitHub Actions secrets or `~/.config/megaproxy/selectel.env`. Build APKs before renting; release only the recorded device/slot owned by the run, including diff --git a/docs/en/selectel-ui-tests.md b/docs/en/selectel-ui-tests.md index 9496b08..65c1c9d 100644 --- a/docs/en/selectel-ui-tests.md +++ b/docs/en/selectel-ui-tests.md @@ -26,6 +26,11 @@ No hosted emulator or release signing is used. The ruleset for `main` requires ` from GitHub Actions alongside the existing native/Android checks. Workflow dispatch becomes available in the Actions UI after the workflow is merged into the default branch. +The UI results are published as commit statuses, not only manual-workflow check runs. On each PR +commit, CI initializes `Selectel UI required` as pending with a link to the manual workflow. Starting +and finishing a UI profile updates its own status on the tested SHA. Rerunning CI preserves existing +UI results. A new commit never inherits a previous commit's UI success. + ## Interactive GitHub launcher ```sh @@ -117,6 +122,13 @@ active after merging it into the default branch. It does not execute code from d To recover manually on another checkout, download that run's `selectel-lease-*` artifact and put its `lease.json` in `.selectel/` before invoking `selectel_release`. + +Cancellation uses the device job's `always()` release step and a separate `workflow_run: completed` +recovery run, including cancelled conclusions. Recovery also closes a failed/cancelled commit status +without overwriting a newer run. Normal cancellation has a finite cleanup window (GitHub can force +termination after five minutes); force-cancel and a dead runner can bypass local cleanup. Recovery +needs the uploaded journal and only becomes active once this workflow reaches the default branch. + There is a residual window if the runner dies before uploading the journal, or the allocation API accepts the request but its response is lost. Allocation is deliberately not retried automatically. An `allocation_pending` journal requires checking the Selectel project for the newly added rental diff --git a/docs/ru/selectel-ui-tests.md b/docs/ru/selectel-ui-tests.md index a166d5c..3d2a2e9 100644 --- a/docs/ru/selectel-ui-tests.md +++ b/docs/ru/selectel-ui-tests.md @@ -26,6 +26,11 @@ gh workflow run selectel-ui.yml --ref YOUR_PR_BRANCH -f devices=required GitHub Actions вместе с существующими native/Android проверками. Кнопка ручного запуска появится в Actions после попадания workflow в основную ветку. +Результаты UI публикуются как статусы коммита, а не только проверки ручного workflow. Для каждого +коммита PR обычный CI создаёт ожидающий `Selectel UI required` со ссылкой на ручной запуск. Начало +и окончание профиля обновляют его собственный статус на проверяемом SHA. Повтор CI сохраняет +существующие UI-результаты. Новый коммит не наследует успех UI-тестов предыдущего коммита. + ## Интерактивный запуск GitHub Actions ```sh @@ -118,6 +123,14 @@ bundle exec fastlane android selectel_release Для ручного восстановления в другом checkout скачайте артефакт `selectel-lease-*`, положите его `lease.json` в `.selectel/` и запустите `selectel_release`. + +При отмене работают шаг освобождения с `always()` и отдельный recovery-прогон по +`workflow_run: completed`, включая результат `cancelled`. Recovery также закрывает статус ошибки +или отмены, не перезаписывая более новый прогон. Время локального cleanup ограничено: GitHub может +принудительно остановить отменяемые процессы через пять минут. Force-cancel и потеря runner могут +обойти локальное освобождение. Recovery нужен загруженный журнал; он начнёт автоматически работать +только после попадания workflow в основную ветку. + Остаётся окно риска: runner может погибнуть до загрузки журнала, а API — создать аренду, но потерять ответ. Поэтому запрос создания автоматически не повторяется. При `allocation_pending` проверьте проект в панели Selectel и удалите именно новую аренду. Не удаляйте все устройства: они могут быть From 698e35e448bad3fe8d844e185fad85c483ce220f Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Mon, 7 Sep 2026 21:10:43 +0300 Subject: [PATCH 11/13] Filter CI by full PR diff and wait for available Selectel devices --- .github/workflows/ci.yml | 40 ++++++- .github/workflows/pr-artifacts-comment.yml | 8 ++ .github/workflows/selectel-ui.yml | 49 +++++++- AGENTS.md | 5 +- config/selectel-devices.json | 9 +- docs/en/fastlane.md | 4 +- docs/en/selectel-ui-tests.md | 25 ++++- docs/ru/fastlane.md | 4 +- docs/ru/selectel-ui-tests.md | 26 ++++- scripts/ci_changes.py | 123 +++++++++++++++++++++ scripts/github_actions.py | 14 ++- scripts/selectel_ui.py | 56 +++++++--- scripts/tests/test_ci_changes.py | 118 ++++++++++++++++++++ scripts/tests/test_github_actions.py | 25 +++++ scripts/tests/test_selectel_ui.py | 84 +++++++++++++- 15 files changed, 544 insertions(+), 46 deletions(-) create mode 100644 scripts/ci_changes.py create mode 100644 scripts/tests/test_ci_changes.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ee3664..db38646 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,5 @@ name: CI +run-name: CI · ${{ github.event.pull_request.title || github.ref_name }} on: pull_request: @@ -14,31 +15,62 @@ concurrency: cancel-in-progress: true jobs: + changes: + name: Change scope + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + android: ${{ steps.scope.outputs.android }} + native: ${{ steps.scope.outputs.native }} + python: ${{ steps.scope.outputs.python }} + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + persist-credentials: false + - name: Classify the complete change set + id: scope + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + EVENT_NAME: ${{ github.event_name }} + run: | + args=(--base "$BASE_SHA" --head "$HEAD_SHA" --github-output "$GITHUB_OUTPUT" --summary "$GITHUB_STEP_SUMMARY") + if [[ "$EVENT_NAME" == push ]]; then args+=(--push); fi + python3 scripts/ci_changes.py "${args[@]}" + ui-status: name: Prepare manual UI status + needs: changes if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest permissions: statuses: write steps: - uses: actions/github-script@v8 + env: + ANDROID_CHANGED: ${{ needs.changes.outputs.android }} with: script: | const sha = context.payload.pull_request.head.sha; const name = 'Selectel UI required'; + const needed = process.env.ANDROID_CHANGED === 'true'; const statuses = await github.paginate(github.rest.repos.listCommitStatusesForRef, { ...context.repo, ref: sha, per_page: 100 }); // A CI rerun must preserve an existing UI result or active manual run. if (!statuses.some(status => status.context === name)) { await github.rest.repos.createCommitStatus({ - ...context.repo, sha, context: name, state: 'pending', - description: 'Awaiting manual required UI run for this commit', + ...context.repo, sha, context: name, state: needed ? 'pending' : 'success', + description: needed ? 'Awaiting manual required UI run for this commit' : 'UI not required: no Android-impacting changes', target_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/workflows/selectel-ui.yml` }); } native-tests: + needs: changes + if: needs.changes.outputs.native == 'true' name: Native Go tests runs-on: ubuntu-latest timeout-minutes: 20 @@ -62,6 +94,8 @@ jobs: run: bundle exec fastlane android native_tests python-tests: + needs: changes + if: needs.changes.outputs.python == 'true' name: Python tests and style runs-on: ubuntu-latest timeout-minutes: 10 @@ -88,6 +122,8 @@ jobs: run: bundle exec fastlane android python_checks android-tests: + needs: changes + if: needs.changes.outputs.android == 'true' name: Android tests and checks runs-on: ubuntu-latest timeout-minutes: 45 diff --git a/.github/workflows/pr-artifacts-comment.yml b/.github/workflows/pr-artifacts-comment.yml index 7191515..f3c36dc 100644 --- a/.github/workflows/pr-artifacts-comment.yml +++ b/.github/workflows/pr-artifacts-comment.yml @@ -33,6 +33,14 @@ jobs: return; } + const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, { + ...context.repo, run_id: run.id, filter: 'latest', per_page: 100 + }); + if (jobs.some(job => job.name === 'Android tests and checks' && job.conclusion === 'skipped')) { + core.notice('Android checks were skipped by the change filter; no APK artifacts are expected'); + return; + } + const pullNumber = pullRequests[0].number; const expectedArtifacts = { debug: `megaproxy-pr-${pullNumber}-debug-apk`, diff --git a/.github/workflows/selectel-ui.yml b/.github/workflows/selectel-ui.yml index 305ec59..ca13fe6 100644 --- a/.github/workflows/selectel-ui.yml +++ b/.github/workflows/selectel-ui.yml @@ -39,9 +39,44 @@ jobs: target_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}` }); - build: + changes: + name: Check Android change scope needs: status runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + outputs: + android: ${{ steps.scope.outputs.android }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + - name: Find the PR comparison base + id: base + uses: actions/github-script@v8 + with: + script: | + const branch = context.ref.replace(/^refs\/heads\//, ''); + const prs = await github.paginate(github.rest.pulls.list, { + ...context.repo, state: 'open', head: `${context.repo.owner}:${branch}`, per_page: 100 + }); + const pr = prs.find(pr => pr.head.sha === context.sha && pr.head.repo.full_name === `${context.repo.owner}/${context.repo.repo}`); + // Without a matching PR, run conservatively instead of guessing the diff. + core.setOutput('sha', pr ? pr.base.sha : '0'.repeat(40)); + - name: Classify the full PR diff before renting + id: scope + env: + BASE_SHA: ${{ steps.base.outputs.sha }} + HEAD_SHA: ${{ github.sha }} + run: python3 scripts/ci_changes.py --base "$BASE_SHA" --head "$HEAD_SHA" --github-output "$GITHUB_OUTPUT" --summary "$GITHUB_STEP_SUMMARY" + + build: + needs: [status, changes] + if: needs.changes.outputs.android == 'true' + runs-on: ubuntu-latest timeout-minutes: 35 env: FASTLANE_SKIP_DOCS: "1" @@ -90,13 +125,14 @@ jobs: ui-tests: needs: build + if: needs.build.result == 'success' name: UI · ${{ matrix.model }} · API ${{ matrix.api }} · ${{ matrix.abi }} strategy: fail-fast: false max-parallel: 2 matrix: ${{ fromJSON(needs.build.outputs.devices) }} runs-on: ubuntu-latest - timeout-minutes: 25 + timeout-minutes: 40 env: FASTLANE_SKIP_DOCS: "1" SELECTEL_DEVICE_MODEL: ${{ matrix.model }} @@ -164,7 +200,7 @@ jobs: # Commit statuses are the PR-facing contexts; avoid an identically named check run. name: UI result · ${{ inputs.devices }} if: always() - needs: [build, ui-tests] + needs: [changes, build, ui-tests] runs-on: ubuntu-latest permissions: statuses: write @@ -173,17 +209,20 @@ jobs: uses: actions/github-script@v8 env: PROFILE: ${{ inputs.devices }} + SCOPE_RESULT: ${{ needs.changes.result }} + ANDROID_CHANGED: ${{ needs.changes.outputs.android }} BUILD_RESULT: ${{ needs.build.result }} UI_RESULT: ${{ needs.ui-tests.result }} with: script: | const profile = process.env.PROFILE; if (!['required', 'additional'].includes(profile)) throw new Error('Invalid UI profile'); - const passed = process.env.BUILD_RESULT === 'success' && process.env.UI_RESULT === 'success'; + const skipped = process.env.SCOPE_RESULT === 'success' && process.env.ANDROID_CHANGED === 'false'; + const passed = skipped || (process.env.BUILD_RESULT === 'success' && process.env.UI_RESULT === 'success'); await github.rest.repos.createCommitStatus({ ...context.repo, sha: context.sha, context: `Selectel UI ${profile}`, state: passed ? 'success' : 'failure', - description: passed ? 'All selected UI tests and cleanup passed' : 'UI run failed, was cancelled, or cleanup failed', + description: skipped ? 'UI not required: no Android-impacting changes' : passed ? 'All selected UI tests and cleanup passed' : 'UI run failed, was cancelled, or cleanup failed', target_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}` }); if (!passed) core.setFailed('UI build, device tests, or cleanup did not succeed'); diff --git a/AGENTS.md b/AGENTS.md index b700eca..ce3612d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,9 @@ branch names, credentials, signing material, or other secrets. - Pull requests must run native tests and Android JVM unit/lint/build checks. Do not require an Android emulator in GitHub Actions: hosted-runner KVM availability proved too unreliable for a trustworthy required check. +- Classify the full PR diff, not just the latest push. Require `Change scope`; skip unrelated + suites. Without Android-impacting changes, skip Android/UI jobs and publish a successful UI + status explaining the skip. Shared build/CI inputs and unknown paths run all suites. - PR builds may publish debug and unsigned APK artifacts. They must never have access to release 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 @@ -38,7 +41,7 @@ branch names, credentials, signing material, or other secrets. `Selectel UI required` for the current commit. Never rent devices on each push. `required` covers Android 15; optional `additional` covers Android 11, 13, 16, 17 without overlap. Publish their distinct commit statuses so manual-run progress is visible on the PR; initialize - required as pending for each new PR head, preserving results on CI reruns. Both use `config/selectel-devices.json`; no runtime discovery. + required as pending for Android-impacting PR heads, preserving results on CI reruns. Both use `config/selectel-devices.json`; no runtime discovery. Do not reintroduce a software-emulated Android fallback. - Selectel credentials live in GitHub Actions secrets or `~/.config/megaproxy/selectel.env`. Build APKs before renting; release only the recorded device/slot owned by the run, including diff --git a/config/selectel-devices.json b/config/selectel-devices.json index 650f159..4510e8c 100644 --- a/config/selectel-devices.json +++ b/config/selectel-devices.json @@ -16,9 +16,9 @@ "abi": "arm64-v8a" }, { - "id": "samsung-galaxy-a14-35-arm64-v8a", - "manufacturer": "SAMSUNG", - "model": "Galaxy A14", + "id": "honor-x8c-35-arm64-v8a", + "manufacturer": "HONOR", + "model": "X8c", "api": "35", "abi": "arm64-v8a" }, @@ -36,5 +36,6 @@ "api": "37", "abi": "arm64-v8a" } - ] + ], + "required_device": "honor-x8c-35-arm64-v8a" } diff --git a/docs/en/fastlane.md b/docs/en/fastlane.md index f5dbf66..0821544 100644 --- a/docs/en/fastlane.md +++ b/docs/en/fastlane.md @@ -91,7 +91,9 @@ committing the lock file and using `bundle exec fastlane` locally and in CI. The scripts themselves use Python's standard library. Install the pinned formatting tools only for development; Black and isort settings live in `pyproject.toml`. The separate required -`Python tests and style` job runs formatting checks and Python tests on every PR. +`Python tests and style` job runs formatting checks and Python tests when the full PR diff affects Python or shared CI/build inputs. +`Change scope` selects relevant suites; Python/docs-only changes skip Android and UI tests. +Documentation-only changes skip test jobs. Skipped Android builds do not publish APK artifacts. ```sh python3 -m venv .venv diff --git a/docs/en/selectel-ui-tests.md b/docs/en/selectel-ui-tests.md index 65c1c9d..602a469 100644 --- a/docs/en/selectel-ui-tests.md +++ b/docs/en/selectel-ui-tests.md @@ -3,8 +3,8 @@ The `Selectel UI` workflow runs **manually**, on the PR branch selected in Actions → Selectel UI → Run workflow. Pushes do not rent devices. Choose one independent profile: -- `required`: Galaxy A14 / Android 15 (API 35), 12 test cases. Its `Selectel UI required` check must - succeed for the current PR commit before merging; a new commit requires a new manual run. +- `required`: HONOR X8c / Android 15 (API 35), 12 test cases. Its `Selectel UI required` check must + succeed for the current PR commit before merging; a new commit requires a new manual run when the full PR diff affects Android. - `additional`: Galaxy A03 / Android 11, Galaxy A14 / Android 13, Galaxy A35 / Android 16, Pixel 8 / Android 17 — four devices, 48 test cases. Its `Selectel UI additional` check is optional. It excludes the required device configuration and cannot satisfy or overwrite the required check. @@ -13,7 +13,7 @@ The fixed snapshot is `config/selectel-devices.json`, captured on 2026-09-07. Bo Additional devices run at most two simultaneously in GitHub; locally they run sequentially. There is no runtime discovery or substitution if a configuration is unavailable. Update the snapshot in a PR. Each profile fails if its build, any selected device or lease cleanup fails. Native/Python/Android -checks still run automatically. The two UI profiles are launched separately; additional is not needed +checks run automatically when relevant to the full PR diff. The two UI profiles are launched separately; additional is not needed for the merge gate. ```sh @@ -27,10 +27,16 @@ from GitHub Actions alongside the existing native/Android checks. Workflow dispa in the Actions UI after the workflow is merged into the default branch. The UI results are published as commit statuses, not only manual-workflow check runs. On each PR -commit, CI initializes `Selectel UI required` as pending with a link to the manual workflow. Starting +commit affecting Android, CI initializes `Selectel UI required` as pending with a link to the manual workflow. Starting and finishing a UI profile updates its own status on the tested SHA. Rerunning CI preserves existing UI results. A new commit never inherits a previous commit's UI success. +CI classifies the **full PR diff against its base**, not only the latest push. Python-only changes +run Python checks; documentation-only changes skip test jobs. Android and UI tests are skipped when +Android is unaffected, and the required UI status succeeds with an explicit skip reason. Manual UI +dispatch uses the same filter and does not build APKs or rent devices in that case. Shared CI/build +inputs and unknown paths conservatively enable all suites. `Change scope` is a required check. + ## Interactive GitHub launcher ```sh @@ -45,6 +51,9 @@ changed during selection. UI dispatch resolves the branch head when GitHub accep while launching. CI reruns target only an existing, completed run for the current PR commit. Fork PRs are excluded. Release workflows are not offered. `q` or Ctrl+C cancels. +`--yes` / `-y` skips the final confirmation while retaining PR/profile selection and the check +that the PR head has not changed. `--dry-run` still prevents launches when combined with `--yes`. + The Python code uses only the standard library and invokes **GitHub CLI (`gh`)**. Install `gh`, then run `gh auth login` once (or use its existing `GH_TOKEN`/`GITHUB_TOKEN` environment authentication). The script has no HTTP client or token storage. It calls `gh pr list/view`, `gh workflow run`, and @@ -73,12 +82,18 @@ bundle exec fastlane android selectel_ui_tests ``` The first command is read-only. The last rents **one device with minute billing**. Default selection: -Galaxy A14, Android API 35, arm64. Override with `SELECTEL_ANDROID_API` and +HONOR X8c, Android API 35, arm64. Override with `SELECTEL_ANDROID_API` and `SELECTEL_DEVICE_MODEL` (exact catalog name). No silent model/API-level fallback. Device availability and the actual tariff depend on the provider. The UI build includes only ARM64 to reduce upload time. Build before renting to avoid paying for compilation. ADB uses its own key directory and server port 5038 (`SELECTEL_ADB_PORT` overrides it), so local phones and emulators are not targeted. +Before creating a rental, the runner waits up to **600 seconds**, polling free-device availability +every 30 seconds. `SELECTEL_DEVICE_WAIT_SECONDS` accepts 0–900 seconds (0 means one immediate check). +This wait creates no rental and incurs no device billing. API errors fail immediately; an ambiguous +rental POST is never retried. A device may still be taken between the availability check and rental. +The device job has a 40-minute limit to leave time for tests and cleanup after waiting. + ## Local additional devices ```sh diff --git a/docs/ru/fastlane.md b/docs/ru/fastlane.md index 5b9498e..6e06c3b 100644 --- a/docs/ru/fastlane.md +++ b/docs/ru/fastlane.md @@ -93,7 +93,9 @@ bundle exec fastlane android test Сами скрипты используют стандартную библиотеку Python. Зафиксированные форматтеры нужны только для разработки; настройки Black и isort находятся в `pyproject.toml`. Отдельная обязательная проверка -`Python tests and style` запускает проверку форматирования и Python-тесты в каждом PR. +`Python tests and style` запускает проверку форматирования и Python-тесты при изменениях Python или общих файлов CI/сборки в полном diff PR. +`Change scope` выбирает нужные проверки: изменения только Python/документации пропускают Android +и UI. Изменения только документации пропускают тестовые задания. При пропуске Android-сборки APK не публикуются. ```sh python3 -m venv .venv diff --git a/docs/ru/selectel-ui-tests.md b/docs/ru/selectel-ui-tests.md index 3d2a2e9..f874d21 100644 --- a/docs/ru/selectel-ui-tests.md +++ b/docs/ru/selectel-ui-tests.md @@ -3,8 +3,8 @@ Workflow `Selectel UI` запускается **только вручную**: Actions → Selectel UI → Run workflow, выбрать ветку PR и один из независимых профилей. Пуши не арендуют устройства. -- `required`: Galaxy A14 / Android 15 (API 35), 12 тестовых случаев. Проверка `Selectel UI required` - обязательна для текущего коммита PR; после нового коммита нужен новый ручной прогон. +- `required`: HONOR X8c / Android 15 (API 35), 12 тестовых случаев. Проверка `Selectel UI required` + обязательна для текущего коммита PR; после нового коммита нужен новый ручной прогон, если полный diff PR затрагивает Android. - `additional`: Galaxy A03 / Android 11, Galaxy A14 / Android 13, Galaxy A35 / Android 16, Pixel 8 / Android 17 — четыре устройства, 48 тестовых случаев. Проверка `Selectel UI additional` необязательна. Она не повторяет конфигурацию required и не заменяет его обязательный статус. @@ -13,7 +13,7 @@ Workflow `Selectel UI` запускается **только вручную**: A ARM64. Дополнительные устройства запускаются максимум по два одновременно в GitHub и последовательно локально. Автоматического обнаружения и подмены недоступных конфигураций нет; список меняется через PR. Ошибка сборки, любого выбранного устройства или освобождения аренды приводит к ошибке профиля. -Native/Python/Android проверки продолжают запускаться автоматически. Профили UI запускаются отдельно; +Native/Python/Android проверки запускаются автоматически, если нужны по полному diff PR. Профили UI запускаются отдельно; для слияния достаточно успешного required. ```sh @@ -27,10 +27,16 @@ GitHub Actions вместе с существующими native/Android про в Actions после попадания workflow в основную ветку. Результаты UI публикуются как статусы коммита, а не только проверки ручного workflow. Для каждого -коммита PR обычный CI создаёт ожидающий `Selectel UI required` со ссылкой на ручной запуск. Начало +коммита PR, затрагивающего Android, обычный CI создаёт ожидающий `Selectel UI required` со ссылкой на ручной запуск. Начало и окончание профиля обновляют его собственный статус на проверяемом SHA. Повтор CI сохраняет существующие UI-результаты. Новый коммит не наследует успех UI-тестов предыдущего коммита. +CI анализирует **полный diff PR относительно базы**, а не только последний пуш. Изменения только +Python запускают Python-проверки; изменения только документации пропускают тестовые задания. Если +Android не затронут, Android- и UI-тесты пропускаются, обязательный UI-статус становится успешным +с пояснением. Ручной UI-запуск применяет тот же фильтр: без сборки APK и аренды. Общие файлы CI/сборки +и неизвестные пути консервативно включают все проверки. `Change scope` — обязательная проверка. + ## Интерактивный запуск GitHub Actions ```sh @@ -45,6 +51,9 @@ python3 scripts/github_actions.py --dry-run с запуском. Повтор CI использует только существующий завершённый прогон текущего коммита PR. PR из форков исключены. Release-workflow в меню нет. `q` или Ctrl+C отменяет операцию. +`--yes` / `-y` пропускает последнее подтверждение, сохраняя выбор PR/профиля и проверку, +что коммит PR не изменился. При сочетании с `--dry-run` запусков по-прежнему нет. + Python-код использует только стандартную библиотеку и вызывает **GitHub CLI (`gh`)**. Установите `gh` и один раз выполните `gh auth login` либо используйте его авторизацию через `GH_TOKEN`/`GITHUB_TOKEN`. Своего HTTP-клиента и хранения токенов у скрипта нет. Он вызывает `gh pr list/view`, `gh workflow run` @@ -73,12 +82,19 @@ bundle exec fastlane android selectel_ui_tests ``` Первая команда только читает данные. Последняя арендует **одно устройство с поминутной оплатой**. -По умолчанию выбирается Galaxy A14, Android API 35, arm64. Можно задать +По умолчанию выбирается HONOR X8c, Android API 35, arm64. Можно задать `SELECTEL_ANDROID_API` и `SELECTEL_DEVICE_MODEL` (точное имя модели из каталога). Автоматической смены модели или API нет. Доступность и фактическая цена зависят от провайдера. APK собираются до начала аренды и содержат только ARM64, чтобы сократить время загрузки. ADB использует отдельные ключи и порт сервера 5038 (переопределяется `SELECTEL_ADB_PORT`), поэтому локальные телефоны и эмуляторы не затрагиваются. +Перед созданием аренды скрипт ждёт свободное устройство до **600 секунд**, проверяя наличие +каждые 30 секунд. `SELECTEL_DEVICE_WAIT_SECONDS` принимает 0–900 секунд (0 — одна проверка сразу). +Ожидание не создаёт аренду и не оплачивается как использование устройства. Ошибки API завершают +прогон сразу; запрос аренды с неопределённым результатом не повторяется. Между проверкой наличия +и арендой устройство всё ещё может занять другой пользователь. Лимит задания увеличен до 40 минут, +чтобы после ожидания осталось время на тесты и освобождение устройства. + ## Дополнительные устройства локально ```sh diff --git a/scripts/ci_changes.py b/scripts/ci_changes.py new file mode 100644 index 0000000..6b418e3 --- /dev/null +++ b/scripts/ci_changes.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Classify the full PR diff for CI; unknown paths conservatively run every suite.""" + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path, PurePosixPath + +SUITES = ("android", "native", "python") + + +def classify(paths): + selected = set() + for path in paths: + # Markdown packaged in the app is runtime content, unlike repository docs. + if path.startswith(("app/src/main/assets/", "app/src/main/res/")): + selected.add("android") + elif ( + PurePosixPath(path).suffix.lower() == ".md" + or path.startswith(("docs/", "fastlane/metadata/", ".vscode/")) + or path in ("LICENSE", ".gitignore", ".gitattributes") + ): + continue + elif path.startswith((".github/", "fastlane/")) or path in ( + "Gemfile", + "Gemfile.lock", + ".ruby-version", + ): + selected.update(SUITES) + elif path.startswith("native/"): + selected.add("native") + if not path.endswith("_test.go"): + selected.add("android") + elif path.endswith(".py") or path in ( + "pyproject.toml", + "requirements-dev.txt", + "config/selectel-devices.json", + ): + selected.add("python") + elif ( + path.startswith(("app/", "gradle/")) + or path.endswith((".gradle", ".gradle.kts")) + or path in ("gradlew", "gradlew.bat", "gradle.properties") + ): + selected.add("android") + elif path.startswith("scripts/") and path.endswith(".sh"): + selected.add("android") + else: + selected.update(SUITES) + return {suite: suite in selected for suite in SUITES} + + +def changed_files(base, head, pull_request=True): + if not all(re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", sha) for sha in (base, head)): + raise RuntimeError("Expected full base and head commit SHAs") + if not base.strip("0"): + return None # Initial push has no reliable comparison point: run everything. + commits = [f"{base}...{head}"] if pull_request else [base, head] + try: + output = subprocess.run( + ["git", "diff", "--name-only", "--no-renames", "-z", *commits, "--"], + capture_output=True, + check=True, + timeout=60, + ).stdout + except (subprocess.CalledProcessError, subprocess.TimeoutExpired): + raise RuntimeError( + "Could not compute the full diff; CI scope must not be skipped" + ) from None + # --no-renames includes both paths when a code file moves into a docs-only directory. + return [ + name.decode("utf-8", errors="surrogateescape") + for name in output.split(b"\0") + if name + ] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base", required=True) + parser.add_argument("--head", required=True) + parser.add_argument( + "--push", + action="store_true", + help="Compare push endpoints instead of the PR merge base", + ) + parser.add_argument("--github-output", type=Path) + parser.add_argument("--summary", type=Path) + args = parser.parse_args() + try: + paths = changed_files(args.base, args.head, not args.push) + result = classify(paths) if paths is not None else dict.fromkeys(SUITES, True) + print( + json.dumps( + {**result, "changed_files": len(paths) if paths is not None else None} + ) + ) + if args.github_output: + with args.github_output.open("a") as output: + for suite, enabled in result.items(): + output.write(f"{suite}={str(enabled).lower()}\n") + if args.summary: + with args.summary.open("a") as summary: + summary.write( + "## CI change scope\n\nCompared the full PR diff" + if not args.push + else "## CI change scope\n\nCompared the push endpoints" + ) + summary.write(f" (`{args.base[:12]}` → `{args.head[:12]}`).\n\n") + for suite, enabled in result.items(): + summary.write( + f"- {suite}: {'run' if enabled else 'skip — no relevant changes'}\n" + ) + return 0 + except RuntimeError as error: + print(str(error), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/github_actions.py b/scripts/github_actions.py index 9f4852b..6957aef 100755 --- a/scripts/github_actions.py +++ b/scripts/github_actions.py @@ -11,7 +11,7 @@ REPOSITORY = "andre487/AndroidMegaProxy" MODES = [ - ("required", "Required UI: Galaxy A14 / Android 15 (one paid rental)"), + ("required", "Required UI: HONOR X8c / Android 15 (one paid rental)"), ("additional", "Additional UI: Android 11, 13, 16, 17 (four paid rentals)"), ("ci", "Re-run CI: Python, Go, Android/JVM/lint/build (no device rental)"), ] @@ -138,14 +138,14 @@ def plan_run(client, pr, mode): return ["run", "rerun", str(run["databaseId"])], run["url"] -def launch(client, pr, mode, dry_run=False): +def launch(client, pr, mode, dry_run=False, yes=False): command, url = plan_run(client, pr, mode) print(f'\nPR #{pr["number"]}, commit {pr["headRefOid"][:12]}') print(shlex.join(client.argv(*command))) if dry_run: print("Dry run: no workflow started.\n" + url) return - if input("Start this run? [y/N]: ").strip().lower() != "y": + if not yes and input("Start this run? [y/N]: ").strip().lower() != "y": print("Cancelled.") return current = client.pr(pr["number"]) @@ -169,6 +169,12 @@ def main(): parser.add_argument( "--dry-run", action="store_true", help="Preview without starting workflows" ) + parser.add_argument( + "--yes", + "-y", + action="store_true", + help="Skip final launch confirmation; still choose a PR and test profile", + ) args = parser.parse_args() try: client = GitHub(args.repo) @@ -182,7 +188,7 @@ def main(): ) pr = client.pr(number) mode = choose("Tests to run", MODES) - launch(client, pr, mode, args.dry_run) + launch(client, pr, mode, args.dry_run, args.yes) return 0 except (KeyboardInterrupt, EOFError): print("\nCancelled.") diff --git a/scripts/selectel_ui.py b/scripts/selectel_ui.py index 767c934..cd0250e 100644 --- a/scripts/selectel_ui.py +++ b/scripts/selectel_ui.py @@ -170,7 +170,8 @@ def connect_device(path, endpoint): def device_matrix(profile): if profile not in ("required", "additional"): raise RuntimeError("Device profile must be required or additional") - devices = json.loads((ROOT / "config/selectel-devices.json").read_text())["devices"] + catalog = json.loads((ROOT / "config/selectel-devices.json").read_text()) + devices = catalog["devices"] if not devices: raise RuntimeError("The fixed device catalog is empty") ids = set() @@ -183,14 +184,7 @@ def device_matrix(profile): ): raise RuntimeError("Unsupported device matrix entry") ids.add(device["id"]) - required = [ - d - for d in devices - if d["manufacturer"] == "SAMSUNG" - and d["model"] == "Galaxy A14" - and d["api"] == "35" - and d["abi"] == "arm64-v8a" - ] + required = [d for d in devices if d["id"] == catalog["required_device"]] if len(required) != 1: raise RuntimeError( "The required device must occur exactly once in the fixed catalog" @@ -260,10 +254,15 @@ def run_matrix(client, state_path, profile): raise RuntimeError("Some fixed device configurations failed; see matrix.json") +class DeviceUnavailable(RuntimeError): + pass + + def choose_device(available): - sdk = os.environ.get("SELECTEL_ANDROID_API", "35") - model = os.environ.get("SELECTEL_DEVICE_MODEL", "Galaxy A14") - abi = os.environ.get("SELECTEL_DEVICE_ABI", "arm64-v8a") + default = device_matrix("required")[0] + sdk = os.environ.get("SELECTEL_ANDROID_API", default["api"]) + model = os.environ.get("SELECTEL_DEVICE_MODEL", default["model"]) + abi = os.environ.get("SELECTEL_DEVICE_ABI", default["abi"]) manufacturer = os.environ.get("SELECTEL_DEVICE_MANUFACTURER", "") choices = [ d @@ -276,12 +275,41 @@ def choose_device(available): and (not model or d.get("marketName") == model) ] if not choices: - raise RuntimeError( + raise DeviceUnavailable( f"Unavailable configuration: {manufacturer} {model}, API {sdk}, {abi}; no lease created" ) return sorted(choices, key=lambda d: d["marketName"])[0] +def wait_for_device(client): + raw_timeout = os.environ.get("SELECTEL_DEVICE_WAIT_SECONDS", "600") + if ( + not raw_timeout.isascii() + or not raw_timeout.isdecimal() + or not 0 <= int(raw_timeout) <= 900 + ): + raise RuntimeError( + "SELECTEL_DEVICE_WAIT_SECONDS must be an integer from 0 to 900" + ) + timeout = int(raw_timeout) + deadline = time.monotonic() + timeout + while True: + try: + return choose_device(client.request("GET", "/v3/devices/available")) + except DeviceUnavailable as error: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise DeviceUnavailable( + f"{error}; availability wait expired ({timeout}s)" + ) from None + delay = min(30, remaining) + print( + f"{error}; checking again in {delay:.0f}s ({remaining:.0f}s remaining)", + flush=True, + ) + time.sleep(delay) + + def acquire(client, path): if path.exists() and not read_state(path).get("released"): raise RuntimeError( @@ -294,8 +322,8 @@ def acquire(client, path): if not apk.is_file(): raise RuntimeError("Build UI test APKs before renting a device") adb_path() + model = wait_for_device(client) existing = client.request("GET", "/v3/devices") - model = choose_device(client.request("GET", "/v3/devices/available")) state = { "project": client.project, "created_at": time.time(), diff --git a/scripts/tests/test_ci_changes.py b/scripts/tests/test_ci_changes.py new file mode 100644 index 0000000..019fd32 --- /dev/null +++ b/scripts/tests/test_ci_changes.py @@ -0,0 +1,118 @@ +import importlib.util +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location( + "ci_changes", Path(__file__).parents[1] / "ci_changes.py" +) +m = importlib.util.module_from_spec(spec) +spec.loader.exec_module(m) + + +class ChangeScopeTest(unittest.TestCase): + def test_python_and_markdown_do_not_run_android(self): + self.assertEqual( + {"android": False, "native": False, "python": True}, + m.classify(["scripts/github_actions.py", "docs/ru/fastlane.md"]), + ) + + def test_docs_only_skip_all_suites(self): + self.assertFalse( + any( + m.classify( + ["README.md", "native/README.md", "docs/assets/preview.png"] + ).values() + ) + ) + + def test_runtime_markdown_is_android_content(self): + self.assertTrue(m.classify(["app/src/main/assets/help.md"])["android"]) + + def test_native_production_changes_rebuild_android(self): + self.assertEqual( + {"android": True, "native": True, "python": False}, + m.classify(["native/mobile/dialer.go"]), + ) + self.assertEqual( + {"android": False, "native": True, "python": False}, + m.classify(["native/mobile/dialer_test.go"]), + ) + + def test_build_inputs_and_unknown_paths_are_not_silently_skipped(self): + for name in [ + "app/build.gradle.kts", + "gradle.properties", + "gradle/wrapper/gradle-wrapper.jar", + "scripts/build-fdroid-native.sh", + ]: + self.assertTrue(m.classify([name])["android"]) + for name in [ + ".github/workflows/ci.yml", + "fastlane/Fastfile", + "new-build-input.conf", + ]: + self.assertTrue(all(m.classify([name]).values())) + + def test_first_push_runs_everything_and_diff_failure_is_not_empty_diff(self): + self.assertIsNone(m.changed_files("0" * 40, "a" * 40, False)) + with ( + patch.object( + m.subprocess, "run", side_effect=subprocess.CalledProcessError(1, "git") + ), + self.assertRaises(RuntimeError), + ): + m.changed_files("a" * 40, "b" * 40) + + def test_full_pr_diff_includes_earlier_commits_and_deleted_code(self): + with tempfile.TemporaryDirectory() as root: + + def git(*args): + return subprocess.check_output( + ["git", "-C", root, *args], text=True + ).strip() + + git("init", "-q") + git("config", "user.name", "Test") + git("config", "user.email", "test@example.invalid") + git("config", "commit.gpgsign", "false") + path = Path(root) / "README.md" + path.write_text("base") + git("add", ".") + git("commit", "-qm", "base") + base = git("rev-parse", "HEAD") + code = Path(root) / "app/src/main/example.kt" + code.parent.mkdir(parents=True) + code.write_text("code") + git("add", ".") + git("commit", "-qm", "Android change") + path.write_text("docs only in latest commit") + git("add", ".") + git("commit", "-qm", "docs") + head = git("rev-parse", "HEAD") + original = m.subprocess.run + with patch.object( + m.subprocess, + "run", + side_effect=lambda *a, **kw: original(*a, cwd=root, **kw), + ): + self.assertTrue(m.classify(m.changed_files(base, head))["android"]) + previous = head + code.rename(Path(root) / "moved.md") + git("add", "-A") + git("commit", "-qm", "move code to docs") + head = git("rev-parse", "HEAD") + with patch.object( + m.subprocess, + "run", + side_effect=lambda *a, **kw: original(*a, cwd=root, **kw), + ): + files = m.changed_files(previous, head) + self.assertIn("app/src/main/example.kt", files) + self.assertTrue(m.classify(files)["android"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_github_actions.py b/scripts/tests/test_github_actions.py index 9338210..2157181 100644 --- a/scripts/tests/test_github_actions.py +++ b/scripts/tests/test_github_actions.py @@ -88,6 +88,31 @@ def test_confirmed_dispatch_launches_exactly_once(self): "devices=required", ) + def test_yes_skips_confirmation_but_still_rechecks_the_commit(self): + with ( + patch.object(self.client, "pr", return_value=self.pr) as pr, + patch.object(self.client, "run", return_value="") as run, + patch("builtins.input", side_effect=AssertionError), + ): + m.launch(self.client, self.pr, "required", yes=True) + pr.assert_called_once_with(31) + run.assert_called_once() + changed = dict(self.pr, headRefOid="new") + with ( + patch.object(self.client, "pr", return_value=changed), + patch.object(self.client, "run", side_effect=AssertionError), + patch("builtins.input", side_effect=AssertionError), + self.assertRaisesRegex(RuntimeError, "PR changed"), + ): + m.launch(self.client, self.pr, "required", yes=True) + + def test_dry_run_wins_over_yes(self): + with ( + patch.object(self.client, "run", side_effect=AssertionError), + patch("builtins.input", side_effect=AssertionError), + ): + m.launch(self.client, self.pr, "additional", dry_run=True, yes=True) + def test_fork_is_rejected(self): self.pr["isCrossRepository"] = True with self.assertRaisesRegex(RuntimeError, "forks"): diff --git a/scripts/tests/test_selectel_ui.py b/scripts/tests/test_selectel_ui.py index 0d59a97..e9a0035 100644 --- a/scripts/tests/test_selectel_ui.py +++ b/scripts/tests/test_selectel_ui.py @@ -4,7 +4,7 @@ import tempfile import unittest from pathlib import Path -from unittest.mock import patch +from unittest.mock import Mock, patch spec = importlib.util.spec_from_file_location( "selectel_ui", Path(__file__).parents[1] / "selectel_ui.py" @@ -157,6 +157,82 @@ def test_offline_transport_is_reconnected_without_renting_again(self): ) +class AvailabilityTest(unittest.TestCase): + def candidate(self): + device = m.device_matrix("required")[0] + return dict( + platform="Android", + sdk=device["api"], + marketName=device["model"], + manufacturer=device["manufacturer"], + abi=device["abi"], + count=1, + ) + + def test_waits_without_creating_rentals(self): + candidate = self.candidate() + client = Mock() + client.request.side_effect = [[], [candidate]] + with ( + patch.dict(os.environ, {}, clear=True), + patch.object(m.time, "sleep") as sleep, + ): + self.assertEqual(candidate, m.wait_for_device(client)) + sleep.assert_called_once_with(30) + self.assertEqual( + [(("GET", "/v3/devices/available"),)] * 2, + [(call.args,) for call in client.request.call_args_list], + ) + + def test_available_device_does_not_wait(self): + client = Mock() + client.request.return_value = [self.candidate()] + with ( + patch.dict(os.environ, {}, clear=True), + patch.object(m.time, "sleep") as sleep, + ): + m.wait_for_device(client) + sleep.assert_not_called() + + def test_timeout_expires_without_renting(self): + client = Mock() + client.request.return_value = [] + with ( + patch.dict(os.environ, {"SELECTEL_DEVICE_WAIT_SECONDS": "40"}, clear=True), + patch.object(m.time, "monotonic", side_effect=[0, 0, 30, 40]), + patch.object(m.time, "sleep") as sleep, + ): + with self.assertRaisesRegex(m.DeviceUnavailable, "wait expired"): + m.wait_for_device(client) + self.assertEqual([30, 10], [call.args[0] for call in sleep.call_args_list]) + self.assertTrue( + all( + call.args == ("GET", "/v3/devices/available") + for call in client.request.call_args_list + ) + ) + + def test_api_errors_are_not_retried(self): + client = Mock() + client.request.side_effect = m.ApiError("GET", "/v3/devices/available", 403) + with ( + patch.dict(os.environ, {}, clear=True), + patch.object(m.time, "sleep") as sleep, + ): + with self.assertRaises(m.ApiError): + m.wait_for_device(client) + sleep.assert_not_called() + client.request.assert_called_once() + + def test_invalid_timeout_cannot_rent(self): + for value in ["-1", "nan", "1.5", "901", ""]: + client = Mock() + with patch.dict(os.environ, {"SELECTEL_DEVICE_WAIT_SECONDS": value}): + with self.assertRaisesRegex(RuntimeError, "integer from 0 to 900"): + m.wait_for_device(client) + client.request.assert_not_called() + + class MatrixTest(unittest.TestCase): def test_fixed_catalog_needs_no_credentials_or_api(self): with ( @@ -166,7 +242,7 @@ def test_fixed_catalog_needs_no_credentials_or_api(self): devices = m.device_matrix("additional") required = m.device_matrix("required") self.assertEqual(1, len(required)) - self.assertEqual("Galaxy A14", required[0]["model"]) + self.assertEqual("X8c", required[0]["model"]) self.assertNotIn(required[0], devices) self.assertEqual("35", required[0]["api"]) self.assertEqual(4, len(devices)) @@ -181,8 +257,8 @@ def test_selection_does_not_substitute_another_configuration(self): candidate = dict( platform="Android", sdk="35", - marketName="Galaxy A14", - manufacturer="SAMSUNG", + marketName="X8c", + manufacturer="HONOR", abi="arm64-v8a", count=1, ) From 238a0f88522f7c50d5c0748a6e23945907f4b70a Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Mon, 7 Sep 2026 21:30:12 +0300 Subject: [PATCH 12/13] Align Selectel device sessions with documented v3 API --- AGENTS.md | 3 + docs/en/selectel-ui-tests.md | 12 +++ docs/ru/selectel-ui-tests.md | 12 +++ scripts/selectel_ui.py | 93 +++++++++++++--------- scripts/tests/test_selectel_ui.py | 128 ++++++++++++++++++++++++++---- 5 files changed, 194 insertions(+), 54 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ce3612d..b3e59f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,9 @@ branch names, credentials, signing material, or other secrets. Publish their distinct commit statuses so manual-run progress is visible on the PR; initialize required as pending for Android-impacting PR heads, preserving results on CI reruns. Both use `config/selectel-devices.json`; no runtime discovery. Do not reintroduce a software-emulated Android fallback. +- Use Selectel v3 `/users/devices` and `/users/devices/{serial}/remote-connect` for sessions. + The remote-connect response contains `remoteConnectUrl`, not a `success` flag. Check actual + ADB device state after connecting; do not disconnect on each intermediate offline poll. - Selectel credentials live in GitHub Actions secrets or `~/.config/megaproxy/selectel.env`. Build APKs before renting; release only the recorded device/slot owned by the run, including after failures. Preserve the lease journal until cleanup succeeds. Never load release signing diff --git a/docs/en/selectel-ui-tests.md b/docs/en/selectel-ui-tests.md index 602a469..d6dd56a 100644 --- a/docs/en/selectel-ui-tests.md +++ b/docs/en/selectel-ui-tests.md @@ -94,6 +94,18 @@ This wait creates no rental and incurs no device billing. API errors fail immedi rental POST is never retried. A device may still be taken between the availability check and rental. The device job has a 40-minute limit to leave time for tests and cleanup after waiting. +The runner follows the [current Selectel API](https://docs.selectel.ru/api/mobile-farm/): +register a public ADB key, assign the device with `POST /v3/users/devices`, then open +`POST /v3/users/devices/{serial}/remote-connect`. Assignment accepts an empty successful response; +remote connect supplies `remoteConnectUrl` without a `success` field. Check operational status 3, +`ready` and `present` before and after assignment. This numeric status is separate from ownership. +Following the [ADB instructions](https://docs.selectel.ru/mobile-farm/manage/connect-to-device-with-adb/), +connect once, then poll `adb devices -l` for the exact endpoint to reach `device` (90 seconds maximum). +An authentication warning from `connect` alone does not fail the run; intermediate `offline` states +do not trigger repeated disconnects. Close remote connect and assignment through the matching v3 +DELETE methods, then remove the paid device with its slot ID. Session cleanup errors are reported +without preventing rental removal. Existing lease journals remain compatible with recovery. + ## Local additional devices ```sh diff --git a/docs/ru/selectel-ui-tests.md b/docs/ru/selectel-ui-tests.md index f874d21..6485c50 100644 --- a/docs/ru/selectel-ui-tests.md +++ b/docs/ru/selectel-ui-tests.md @@ -95,6 +95,18 @@ ADB использует отдельные ключи и порт сервер и арендой устройство всё ещё может занять другой пользователь. Лимит задания увеличен до 40 минут, чтобы после ожидания осталось время на тесты и освобождение устройства. +Скрипт использует [актуальный API Selectel](https://docs.selectel.ru/api/mobile-farm/): +регистрация публичного ADB-ключа, назначение через `POST /v3/users/devices`, затем открытие +`POST /v3/users/devices/{serial}/remote-connect`. Назначение допускает пустой успешный ответ; +подключение возвращает `remoteConnectUrl` без поля `success`. До и после назначения проверяются +рабочий статус 3, `ready` и `present`. Числовой статус отделён от владения пользовательской сессией. +По [инструкции ADB](https://docs.selectel.ru/mobile-farm/manage/connect-to-device-with-adb/) выполняется +один `connect`, затем опрос `adb devices -l` до состояния `device` у нужного адреса (максимум 90 секунд). +Предупреждение аутентификации от `connect` само по себе не означает сбой; промежуточный `offline` +не вызывает постоянных отключений. Сессия ADB и назначение закрываются соответствующими DELETE +методами v3, затем платное устройство удаляется с его slot ID. Ошибки закрытия сессии выводятся +в журнал и не мешают прекращению аренды. Старые журналы аренды совместимы с восстановлением. + ## Дополнительные устройства локально ```sh diff --git a/scripts/selectel_ui.py b/scripts/selectel_ui.py index cd0250e..a778eb6 100644 --- a/scripts/selectel_ui.py +++ b/scripts/selectel_ui.py @@ -134,39 +134,55 @@ def adb(state_path, *args, timeout=30, check=True, include_stderr=False): def connect_device(path, endpoint): + # Selectel documents that connect may report authentication failure even on + # success. Keep the transport alive and check its actual state afterwards. print("Waiting for remote ADB (maximum 90 seconds)", flush=True) deadline = time.monotonic() + 90 + try: + adb(path, "connect", endpoint, timeout=30, check=False, include_stderr=True) + except subprocess.TimeoutExpired: + pass # The server may still be establishing the transport. diagnostic = "no response" while time.monotonic() < deadline: try: - connected = adb( - path, "connect", endpoint, timeout=10, check=False, include_stderr=True + devices = adb( + path, "devices", "-l", timeout=10, check=False, include_stderr=True ) - status = adb( - path, - "-s", - endpoint, - "get-state", - timeout=10, - check=False, - include_stderr=True, - ).strip() - if status == "device": - return - diagnostic = ( - (connected + " " + status) - .replace(endpoint, "") - .replace("\n", " ")[:240] + state = next( + ( + line.split()[1] + for line in devices.splitlines() + if len(line.split()) >= 2 and line.split()[0] == endpoint + ), + "absent", ) - # An offline transport makes repeated connect calls return 'already connected'. - # Drop only this leased endpoint so the next attempt performs a new handshake. - adb(path, "disconnect", endpoint, timeout=10, check=False) + if state == "device": + return + if state != diagnostic: + print(f"Remote ADB state: {state}", flush=True) + diagnostic = state except subprocess.TimeoutExpired: - pass + diagnostic = "state query timed out" time.sleep(3) raise RuntimeError("Remote ADB did not connect within 90 seconds: " + diagnostic) +def wait_until_ready(client, serial): + deadline = time.monotonic() + 180 + while time.monotonic() < deadline: + info = client.request("GET", "/v3/devices/" + serial) + # API status 3 is the operational device status; ownership is separate. + # See Selectel's device settings preconditions and live GET device format. + if ( + info.get("status") == 3 + and info.get("ready") is True + and info.get("present") is True + ): + return + time.sleep(5) + raise RuntimeError("Device did not become ready within 180 seconds") + + def device_matrix(profile): if profile not in ("required", "additional"): raise RuntimeError("Device profile must be required or additional") @@ -366,14 +382,7 @@ def acquire(client, path): ) device = state["devices"][0] serial = urllib.parse.quote(device["serial"], safe="") - deadline = time.monotonic() + 180 - while time.monotonic() < deadline: - info = client.request("GET", "/v3/devices/" + serial) - if info.get("ready") and info.get("present"): - break - time.sleep(5) - else: - raise RuntimeError("Device did not become ready within 180 seconds") + wait_until_ready(client, serial) env = adb_env(path) key = Path(env["ADB_VENDOR_KEYS"]) subprocess.run( @@ -391,11 +400,12 @@ def acquire(client, path): state["fingerprint"] = key_info["fingerprint"] write_state(path, state) client.request( - "POST", "/v1/user/devices", {"serial": device["serial"], "timeout": 1200000} + "POST", "/v3/users/devices", {"serial": device["serial"], "timeout": 1200000} ) - remote = client.request("POST", "/v1/user/devices/" + serial + "/remoteConnect") - endpoint = remote.get("remoteConnectUrl", "") - if not remote.get("success") or not re.fullmatch( + wait_until_ready(client, serial) + remote = client.request("POST", "/v3/users/devices/" + serial + "/remote-connect") + endpoint = remote.get("remoteConnectUrl", "") if isinstance(remote, dict) else "" + if not isinstance(endpoint, str) or not re.fullmatch( r"[A-Za-z0-9.-]+:[0-9]+", endpoint ): raise RuntimeError("Selectel did not return a valid ADB endpoint") @@ -428,11 +438,20 @@ def release(client, path): current = client.request("GET", "/v3/devices/" + serial) if current["meta"]["slot"]["id"] != device["slot"]: continue - for suffix in ["/remoteConnect", ""]: + for suffix in ["/remote-connect", ""]: try: - client.request("DELETE", "/v1/user/devices/" + serial + suffix) - except (ApiError, urllib.error.URLError): - pass # Still remove the paid lease, even if session teardown fails. + client.request("DELETE", "/v3/users/devices/" + serial + suffix) + except (ApiError, urllib.error.URLError) as error: + # Session failure must not prevent ending the paid rental. + detail = ( + f"HTTP {error.status}" + if isinstance(error, ApiError) + else type(error).__name__ + ) + print( + f"Session cleanup warning ({suffix or 'assignment'}): {detail}; continuing lease removal", + flush=True, + ) client.request( "DELETE", "/v3/devices/" + serial, {"slotID": device["slot"]} ) diff --git a/scripts/tests/test_selectel_ui.py b/scripts/tests/test_selectel_ui.py index e9a0035..39a8b1c 100644 --- a/scripts/tests/test_selectel_ui.py +++ b/scripts/tests/test_selectel_ui.py @@ -25,7 +25,7 @@ def request(self, method, route, body=None): self.calls.append((method, route, body)) if method == "GET": return {"meta": {"slot": {"id": self.slot}}} - if self.fail_session and "/v1/" in route: + if self.fail_session and "/v3/users/devices/" in route: raise m.ApiError(method, route, 500) @@ -127,35 +127,129 @@ def test_successful_instrumentation(self): class AdbConnectionTest(unittest.TestCase): - def test_offline_transport_is_reconnected_without_renting_again(self): + def test_auth_warning_and_offline_can_become_ready_without_disconnect(self): with ( patch.object( m, "adb", side_effect=[ - "connected", - "error: device offline", - "", - "connected", - "device", + "failed to authenticate to example.test:1234", + "List of devices attached\nexample.test:1234 offline\n", + "List of devices attached\nother.test:9999 device\nexample.test:1234 device model:X8c\n", ], ) as adb, patch.object(m.time, "sleep"), - patch.object(m, "Client", side_effect=AssertionError), ): m.connect_device(Path("/tmp/test-lease.json"), "example.test:1234") - commands = [call.args[1:] for call in adb.call_args_list] self.assertEqual( - [ - ("connect", "example.test:1234"), - ("-s", "example.test:1234", "get-state"), - ("disconnect", "example.test:1234"), - ("connect", "example.test:1234"), - ("-s", "example.test:1234", "get-state"), - ], - commands, + [("connect", "example.test:1234"), ("devices", "-l"), ("devices", "-l")], + [call.args[1:] for call in adb.call_args_list], ) + def test_other_connected_device_does_not_satisfy_wait(self): + with ( + patch.object(m, "adb", side_effect=["connected", "other.test:9999 device"]), + patch.object(m.time, "sleep"), + patch.object(m.time, "monotonic", side_effect=[0, 0, 91]), + ): + with self.assertRaisesRegex(RuntimeError, "absent"): + m.connect_device(Path("/tmp/test-lease.json"), "example.test:1234") + + +class AcquisitionTest(unittest.TestCase): + def test_v3_lifecycle_accepts_remote_url_without_success_flag(self): + with tempfile.TemporaryDirectory() as root: + root = Path(root) + for name in [ + "debug/app-debug.apk", + "androidTest/debug/app-debug-androidTest.apk", + ]: + apk = root / "app/build/outputs/apk" / name + apk.parent.mkdir(parents=True, exist_ok=True) + apk.touch() + path = root / ".selectel/lease.json" + client = Mock(project="ci") + requests = [] + info = { + "status": 3, + "ready": True, + "present": True, + "meta": {"slot": {"id": "owned"}}, + "serial": "device", + } + + def request(method, route, body=None): + requests.append((method, route, body)) + if (method, route) == ("GET", "/v3/devices"): + return [] + if (method, route) == ("POST", "/v3/devices"): + return {"devices": [info]} + if method == "GET": + return info + if route == "/v3/keys/adb": + return {"fingerprint": "key"} + if route.endswith("/remote-connect") and method == "POST": + return {"remoteConnectUrl": "example.test:1234"} + return None # Assignment succeeds with an empty HTTP 200 response. + + client.request.side_effect = request + + def keygen(args, **kwargs): + key = Path(args[2]) + key.write_text("test-private") + key.with_suffix(".pub").write_text("test-public") + + with ( + patch.object(m, "ROOT", root), + patch.object( + m, + "wait_for_device", + return_value={ + "manufacturer": "HONOR", + "marketName": "X8c", + "sdk": "35", + "platform": "Android", + "abi": "arm64-v8a", + }, + ), + patch.object(m, "adb_path", return_value="adb"), + patch.object(m.subprocess, "run", side_effect=keygen), + patch.object(m, "connect_device") as connect, + patch.object(m, "adb"), + ): + m.acquire(client, path) + connect.assert_called_once_with(path, "example.test:1234") + m.release(client, path) + self.assertTrue(m.read_state(path)["released"]) + self.assertIn( + ("POST", "/v3/users/devices", {"serial": "device", "timeout": 1200000}), + requests, + ) + self.assertIn( + ("DELETE", "/v3/users/devices/device/remote-connect", None), requests + ) + self.assertIn(("DELETE", "/v3/users/devices/device", None), requests) + self.assertEqual( + 3, + sum( + route == "/v3/devices/device" and method == "GET" + for method, route, _ in requests + ), + ) + self.assertFalse(any("/v1/" in route for _, route, _ in requests)) + + def test_ready_requires_operational_status_and_both_flags(self): + client = Mock() + client.request.side_effect = [ + {"status": 1, "ready": True, "present": True}, + {"status": 3, "ready": False, "present": True}, + {"status": 3, "ready": True, "present": False}, + {"status": 3, "ready": True, "present": True}, + ] + with patch.object(m.time, "sleep") as sleep: + m.wait_until_ready(client, "device") + self.assertEqual(3, sleep.call_count) + class AvailabilityTest(unittest.TestCase): def candidate(self): From 033b12140d84fc30df2cbc610883a9947f349ea4 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Mon, 7 Sep 2026 21:47:07 +0300 Subject: [PATCH 13/13] Register the actual primary ADB key for Selectel authentication --- AGENTS.md | 3 ++ docs/en/selectel-ui-tests.md | 10 ++-- docs/ru/selectel-ui-tests.md | 11 +++-- scripts/selectel_ui.py | 68 +++++++++++++++++---------- scripts/tests/test_selectel_ui.py | 78 ++++++++++++++++++++++++++++--- 5 files changed, 134 insertions(+), 36 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b3e59f8..d17ca31 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,9 @@ branch names, credentials, signing material, or other secrets. - Use Selectel v3 `/users/devices` and `/users/devices/{serial}/remote-connect` for sessions. The remote-connect response contains `remoteConnectUrl`, not a `success` flag. Check actual ADB device state after connecting; do not disconnect on each intermediate offline poll. +- Register the public half of ADB’s actual primary `~/.android/adbkey`; Android preference + variables do not relocate it, and `ADB_VENDOR_KEYS` does not replace it. Preserve existing + private keys and pre-existing Selectel key registrations; isolate the server port. - Selectel credentials live in GitHub Actions secrets or `~/.config/megaproxy/selectel.env`. Build APKs before renting; release only the recorded device/slot owned by the run, including after failures. Preserve the lease journal until cleanup succeeds. Never load release signing diff --git a/docs/en/selectel-ui-tests.md b/docs/en/selectel-ui-tests.md index d6dd56a..edcd13c 100644 --- a/docs/en/selectel-ui-tests.md +++ b/docs/en/selectel-ui-tests.md @@ -85,8 +85,12 @@ The first command is read-only. The last rents **one device with minute billing* HONOR X8c, Android API 35, arm64. Override with `SELECTEL_ANDROID_API` and `SELECTEL_DEVICE_MODEL` (exact catalog name). No silent model/API-level fallback. Device availability and the actual tariff depend on the provider. The UI build includes only ARM64 to reduce upload time. Build before renting to avoid paying for -compilation. ADB uses its own key directory and server port 5038 (`SELECTEL_ADB_PORT` overrides it), -so local phones and emulators are not targeted. +compilation. ADB uses its normal primary key at `~/.android/adbkey` and a separate server port 5038 +(`SELECTEL_ADB_PORT` overrides it). Commands target only the rented endpoint. Existing private +keys are never overwritten or deleted; when missing, ADB creates its standard key. The runner +registers the public key derived with `adb pubkey`, not a separate vendor key. Android preference +variables do not relocate ADB’s primary key. A pre-existing Selectel key registration (HTTP 409) +is reused and preserved; only registrations created by this run are removed during cleanup. Before creating a rental, the runner waits up to **600 seconds**, polling free-device availability every 30 seconds. `SELECTEL_DEVICE_WAIT_SECONDS` accepts 0–900 seconds (0 means one immediate check). @@ -133,7 +137,7 @@ timeouts fail the job. GitHub uploads reports and links them in the job summary. ## Cleanup and recovery -`.selectel/lease.json` records only project/device/slot identifiers and temporary public-key identity, +`.selectel/lease.json` records only project/device/slot identifiers and public-key registration identity, not credentials. Do not delete it while a lease is outstanding. It survives Gradle clean and is ignored by Git. `selectel_ui_tests` cleans up in `finally`; GitHub also uses an `always()` release step. Recovery is idempotent and verifies the slot ID so an old journal cannot delete a newer rental. diff --git a/docs/ru/selectel-ui-tests.md b/docs/ru/selectel-ui-tests.md index 6485c50..22b8a73 100644 --- a/docs/ru/selectel-ui-tests.md +++ b/docs/ru/selectel-ui-tests.md @@ -85,8 +85,13 @@ bundle exec fastlane android selectel_ui_tests По умолчанию выбирается HONOR X8c, Android API 35, arm64. Можно задать `SELECTEL_ANDROID_API` и `SELECTEL_DEVICE_MODEL` (точное имя модели из каталога). Автоматической смены модели или API нет. Доступность и фактическая цена зависят от провайдера. APK собираются до начала аренды и содержат только ARM64, чтобы сократить время загрузки. -ADB использует отдельные ключи и порт сервера 5038 (переопределяется `SELECTEL_ADB_PORT`), поэтому -локальные телефоны и эмуляторы не затрагиваются. +ADB использует основной ключ `~/.android/adbkey` и отдельный порт сервера 5038 +(переопределяется `SELECTEL_ADB_PORT`). Команды адресуются только арендованному устройству. +Существующий приватный ключ не перезаписывается и не удаляется; при отсутствии ADB создаёт +стандартный ключ. В Selectel регистрируется публичная часть из `adb pubkey`, а не отдельный +vendor-ключ. Переменные настроек Android не меняют путь основного ключа ADB. Если ключ уже +зарегистрирован в Selectel (HTTP 409), регистрация используется и сохраняется. При завершении +удаляются только регистрации, созданные текущим прогоном. Перед созданием аренды скрипт ждёт свободное устройство до **600 секунд**, проверяя наличие каждые 30 секунд. `SELECTEL_DEVICE_WAIT_SECONDS` принимает 0–900 секунд (0 — одна проверка сразу). @@ -135,7 +140,7 @@ Selectel не изменяются. ## Освобождение аренды -`.selectel/lease.json` содержит идентификаторы проекта, устройства, слота и временного публичного +`.selectel/lease.json` содержит идентификаторы проекта, устройства, слота и регистрации публичного ключа, но не секреты. Не удаляйте его до завершения аренды. Файл переживает Gradle clean и исключён из Git. `selectel_ui_tests` освобождает аренду в `finally`; в GitHub также есть шаг с `always()`. Повторное освобождение безопасно: проверяется ID слота, чтобы старый журнал не удалил новую аренду. diff --git a/scripts/selectel_ui.py b/scripts/selectel_ui.py index a778eb6..05d014f 100644 --- a/scripts/selectel_ui.py +++ b/scripts/selectel_ui.py @@ -109,16 +109,30 @@ def adb_path(): def adb_env(state_path): - # Isolate keys and the host server from the developer's own devices. - home = state_path.parent / "adb-home" - (home / ".android").mkdir(parents=True, exist_ok=True) + # ADB reads its primary key from ~/.android/adbkey, independently of Android + # SDK preference paths. Use that same identity when registering with Selectel. env = os.environ.copy() - env["ANDROID_USER_HOME"] = str(home / ".android") - env["ANDROID_PREFS_ROOT"] = str(home) - env["ADB_VENDOR_KEYS"] = str(home / ".android/adbkey") + for name in ( + "ANDROID_USER_HOME", + "ANDROID_PREFS_ROOT", + "ADB_VENDOR_KEYS", + "ADB_SERVER_SOCKET", + ): + env.pop(name, None) return env +def adb_public_key(path): + key = Path.home() / ".android/adbkey" + if not key.is_file(): + # Let ADB create its normal identity; never overwrite an existing key. + adb(path, "start-server") + public_key = adb(path, "pubkey", str(key)).strip() + if not public_key: + raise RuntimeError("ADB did not return its primary public key") + return public_key + + def adb(state_path, *args, timeout=30, check=True, include_stderr=False): port = str(os.environ.get("SELECTEL_ADB_PORT", "5038")) result = subprocess.run( @@ -337,7 +351,7 @@ def acquire(client, path): ]: if not apk.is_file(): raise RuntimeError("Build UI test APKs before renting a device") - adb_path() + public_key = adb_public_key(path) model = wait_for_device(client) existing = client.request("GET", "/v3/devices") state = { @@ -383,22 +397,25 @@ def acquire(client, path): device = state["devices"][0] serial = urllib.parse.quote(device["serial"], safe="") wait_until_ready(client, serial) - env = adb_env(path) - key = Path(env["ADB_VENDOR_KEYS"]) - subprocess.run( - [adb_path(), "keygen", str(key)], check=True, capture_output=True, env=env - ) - os.chmod(key, 0o600) - key_info = client.request( - "POST", - "/v3/keys/adb", - { - "publicKey": key.with_suffix(".pub").read_text(), - "title": "megaproxy-ui-" + str(int(state["created_at"])), - }, - ) - state["fingerprint"] = key_info["fingerprint"] - write_state(path, state) + try: + key_info = client.request( + "POST", + "/v3/keys/adb", + { + "publicKey": public_key, + "title": "megaproxy-ui-" + str(int(state["created_at"])), + }, + ) + except ApiError as error: + if error.status != 409: + raise + # Documented 409: this fingerprint already exists. It is not ours to delete. + print( + "Using an existing Selectel registration of the primary ADB key", flush=True + ) + else: + state["fingerprint"] = key_info["fingerprint"] + write_state(path, state) client.request( "POST", "/v3/users/devices", {"serial": device["serial"], "timeout": 1200000} ) @@ -478,7 +495,10 @@ def release(client, path): raise RuntimeError("; ".join(errors) + f". Recovery journal: {path}") state["released"] = True write_state(path, state) - print("Paid device lease and temporary ADB key released", flush=True) + print( + "Paid device lease released; any ADB key registration created by this run removed", + flush=True, + ) def instrumentation_report(output, target): diff --git a/scripts/tests/test_selectel_ui.py b/scripts/tests/test_selectel_ui.py index 39a8b1c..24e2adf 100644 --- a/scripts/tests/test_selectel_ui.py +++ b/scripts/tests/test_selectel_ui.py @@ -126,6 +126,61 @@ def test_successful_instrumentation(self): ) +class AdbIdentityTest(unittest.TestCase): + def test_existing_primary_key_is_used_without_overwriting_it(self): + with tempfile.TemporaryDirectory() as root: + root = Path(root) + key = root / ".android/adbkey" + key.parent.mkdir() + key.write_text("existing-private-key") + with ( + patch.object(m.Path, "home", return_value=root), + patch.object(m, "adb", return_value="primary-public-key\n") as adb, + ): + self.assertEqual( + "primary-public-key", m.adb_public_key(root / "lease.json") + ) + adb.assert_called_once_with(root / "lease.json", "pubkey", str(key)) + self.assertEqual("existing-private-key", key.read_text()) + + def test_missing_primary_key_is_initialized_by_adb(self): + with tempfile.TemporaryDirectory() as root: + root = Path(root) + state = root / "lease.json" + with ( + patch.object(m.Path, "home", return_value=root), + patch.object(m, "adb", side_effect=["", "public-key\n"]) as adb, + ): + self.assertEqual("public-key", m.adb_public_key(state)) + self.assertEqual( + [ + (state, "start-server"), + (state, "pubkey", str(root / ".android/adbkey")), + ], + [call.args for call in adb.call_args_list], + ) + + def test_environment_does_not_redirect_server_or_add_vendor_keys(self): + with patch.dict( + os.environ, + { + "ADB_VENDOR_KEYS": "/other/key", + "ADB_SERVER_SOCKET": "tcp:elsewhere:5037", + "ANDROID_USER_HOME": "/other", + "ANDROID_PREFS_ROOT": "/other", + }, + ): + env = m.adb_env(Path("/tmp/lease.json")) + for name in ( + "ADB_VENDOR_KEYS", + "ADB_SERVER_SOCKET", + "ANDROID_USER_HOME", + "ANDROID_PREFS_ROOT", + ): + self.assertNotIn(name, env) + self.assertEqual(os.environ.get("HOME"), env.get("HOME")) + + class AdbConnectionTest(unittest.TestCase): def test_auth_warning_and_offline_can_become_ready_without_disconnect(self): with ( @@ -158,6 +213,12 @@ def test_other_connected_device_does_not_satisfy_wait(self): class AcquisitionTest(unittest.TestCase): def test_v3_lifecycle_accepts_remote_url_without_success_flag(self): + self.run_lifecycle(False) + + def test_existing_key_registration_is_not_deleted(self): + self.run_lifecycle(True) + + def run_lifecycle(self, existing_key): with tempfile.TemporaryDirectory() as root: root = Path(root) for name in [ @@ -187,6 +248,9 @@ def request(method, route, body=None): if method == "GET": return info if route == "/v3/keys/adb": + self.assertEqual("primary-public-key", body["publicKey"]) + if existing_key: + raise m.ApiError(method, route, 409) return {"fingerprint": "key"} if route.endswith("/remote-connect") and method == "POST": return {"remoteConnectUrl": "example.test:1234"} @@ -194,11 +258,6 @@ def request(method, route, body=None): client.request.side_effect = request - def keygen(args, **kwargs): - key = Path(args[2]) - key.write_text("test-private") - key.with_suffix(".pub").write_text("test-public") - with ( patch.object(m, "ROOT", root), patch.object( @@ -213,7 +272,7 @@ def keygen(args, **kwargs): }, ), patch.object(m, "adb_path", return_value="adb"), - patch.object(m.subprocess, "run", side_effect=keygen), + patch.object(m, "adb_public_key", return_value="primary-public-key"), patch.object(m, "connect_device") as connect, patch.object(m, "adb"), ): @@ -221,6 +280,13 @@ def keygen(args, **kwargs): connect.assert_called_once_with(path, "example.test:1234") m.release(client, path) self.assertTrue(m.read_state(path)["released"]) + self.assertEqual( + not existing_key, + any( + method == "DELETE" and route.startswith("/v3/keys/adb/") + for method, route, _ in requests + ), + ) self.assertIn( ("POST", "/v3/users/devices", {"serial": "device", "timeout": 1200000}), requests,