diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6564c7..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,7 +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: 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 @@ -37,7 +93,37 @@ jobs: - name: Run native tests with the race detector 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 + 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" + 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 + 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-cleanup.yml b/.github/workflows/selectel-cleanup.yml new file mode 100644 index 0000000..1847746 --- /dev/null +++ b/.github/workflows/selectel-cleanup.yml @@ -0,0 +1,83 @@ +name: Selectel lease recovery + +on: + workflow_run: + workflows: [Selectel UI] + types: [completed] + +permissions: + contents: read + actions: read + statuses: write + +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: 15 + 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 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: + artifact-ids: ${{ steps.journal.outputs.ids }} + 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 + - 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 new file mode 100644 index 0000000..ca13fe6 --- /dev/null +++ b/.github/workflows/selectel-ui.yml @@ -0,0 +1,228 @@ +name: Selectel UI +run-name: Selectel UI · ${{ inputs.devices }} · ${{ github.ref_name }} + +on: + workflow_dispatch: + inputs: + devices: + description: Required smoke test or additional Android versions + type: choice + options: [required, additional] + default: required + required: true + +permissions: + contents: read + +# Manual runs on the same branch wait for cleanup instead of cancelling a paid session. +concurrency: + group: selectel-ui-${{ github.ref }} + 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}` + }); + + 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" + DEVICE_PROFILE: ${{ inputs.devices }} + outputs: + devices: ${{ steps.matrix.outputs.devices }} + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "21" + cache: gradle + - 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: 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 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 + 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: 40 + 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 }} + 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 }}-${{ matrix.id }} + 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 }}-${{ matrix.id }} + 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 + + result: + # Commit statuses are the PR-facing contexts; avoid an identically named check run. + name: UI result · ${{ inputs.devices }} + if: always() + needs: [changes, build, ui-tests] + runs-on: ubuntu-latest + permissions: + statuses: write + steps: + - name: Publish the UI result on the tested commit + 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 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: 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/.gitignore b/.gitignore index b8ad376..c05ad8e 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,8 @@ native/*.aar app/libs/*.aar app/libs/megaproxy-sources.jar *.idsig + +.selectel/ +__pycache__/ + +.venv/ diff --git a/AGENTS.md b/AGENTS.md index cb6deaa..d17ca31 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,11 +16,19 @@ 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 the separate required + `Python tests and style` CI job. + ## CI and artifacts - 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 @@ -28,9 +36,23 @@ 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 + 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. + 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. +- 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 + configuration for device tests. ## Releases and distribution diff --git a/README.md b/README.md index d0b339d..2d53042 100644 --- a/README.md +++ b/README.md @@ -277,6 +277,15 @@ 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 manually on Selectel before merging, with a required check for the current PR commit. +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. + ### 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/config/selectel-devices.json b/config/selectel-devices.json new file mode 100644 index 0000000..4510e8c --- /dev/null +++ b/config/selectel-devices.json @@ -0,0 +1,41 @@ +{ + "captured_on": "2026-09-07", + "devices": [ + { + "id": "samsung-galaxy-a03-30-arm64-v8a", + "manufacturer": "SAMSUNG", + "model": "Galaxy A03", + "api": "30", + "abi": "arm64-v8a" + }, + { + "id": "samsung-galaxy-a14-33-arm64-v8a", + "manufacturer": "SAMSUNG", + "model": "Galaxy A14", + "api": "33", + "abi": "arm64-v8a" + }, + { + "id": "honor-x8c-35-arm64-v8a", + "manufacturer": "HONOR", + "model": "X8c", + "api": "35", + "abi": "arm64-v8a" + }, + { + "id": "samsung-galaxy-a35-36-arm64-v8a", + "manufacturer": "SAMSUNG", + "model": "Galaxy A35", + "api": "36", + "abi": "arm64-v8a" + }, + { + "id": "google-pixel-8-37-arm64-v8a", + "manufacturer": "GOOGLE", + "model": "Pixel 8", + "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 8eb1645..0821544 100644 --- a/docs/en/fastlane.md +++ b/docs/en/fastlane.md @@ -35,11 +35,22 @@ 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. | | `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 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: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 @@ -73,3 +84,23 @@ 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). + +## 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`. The separate required +`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 +. .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 new file mode 100644 index 0000000..edcd13c --- /dev/null +++ b/docs/en/selectel-ui-tests.md @@ -0,0 +1,172 @@ +# Selectel UI tests + +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`: 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. + +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 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 +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. +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. + +The UI results are published as commit statuses, not only manual-workflow check runs. On each PR +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 +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 `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. + +`--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 +`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 + +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: +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 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). +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. + +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 +bundle exec fastlane android ui_test_artifacts profile:additional +bundle exec fastlane android selectel_ui_tests profile:additional +``` + +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. +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 +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 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. + +```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`. + + +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 +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..6e06c3b 100644 --- a/docs/ru/fastlane.md +++ b/docs/ru/fastlane.md @@ -36,11 +36,22 @@ 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`; основная команда перед коммитом. | | `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 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:additional` | Последовательно проверяет четыре дополнительные конфигурации, исключая обязательный Android 15. | Для release lane нужна конфигурация подписи из раздела [Signed release builds](../../README.md#signed-release-builds). Lane только собирает артефакты: он @@ -75,3 +86,23 @@ bundle exec fastlane android test хранить lock-файл в репозитории и использовать `bundle exec fastlane` локально и в CI. [English version](../en/fastlane.md) + +[Настройка UI-тестов Selectel](selectel-ui-tests.md). + +## Инструменты разработки Python + +Сами скрипты используют стандартную библиотеку Python. Зафиксированные форматтеры нужны только для +разработки; настройки Black и isort находятся в `pyproject.toml`. Отдельная обязательная проверка +`Python tests and style` запускает проверку форматирования и Python-тесты при изменениях Python или общих файлов CI/сборки в полном diff PR. +`Change scope` выбирает нужные проверки: изменения только Python/документации пропускают Android +и UI. Изменения только документации пропускают тестовые задания. При пропуске Android-сборки APK не публикуются. + +```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 new file mode 100644 index 0000000..22b8a73 --- /dev/null +++ b/docs/ru/selectel-ui-tests.md @@ -0,0 +1,176 @@ +# UI-тесты Selectel + +Workflow `Selectel UI` запускается **только вручную**: Actions → Selectel UI → Run workflow, +выбрать ветку 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 и не заменяет его обязательный статус. + +Список зафиксирован в `config/selectel-devices.json` по каталогу на 2026-09-07. Оба профиля используют +ARM64. Дополнительные устройства запускаются максимум по два одновременно в GitHub и последовательно +локально. Автоматического обнаружения и подмены недоступных конфигураций нет; список меняется через PR. +Ошибка сборки, любого выбранного устройства или освобождения аренды приводит к ошибке профиля. +Native/Python/Android проверки запускаются автоматически, если нужны по полному diff PR. Профили UI запускаются отдельно; +для слияния достаточно успешного required. + +```sh +gh workflow run selectel-ui.yml --ref YOUR_PR_BRANCH -f devices=required +# Или: -f devices=additional +``` + +Можно выбрать только ветку этого репозитория; код форков автоматически не получает секреты. +Эмулятор и release-подпись не используются. Ruleset для `main` требует `Selectel UI required` от +GitHub Actions вместе с существующими native/Android проверками. Кнопка ручного запуска появится +в Actions после попадания workflow в основную ветку. + +Результаты UI публикуются как статусы коммита, а не только проверки ручного workflow. Для каждого +коммита 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 +python3 scripts/github_actions.py +# Проверить выбор без запуска заданий и аренды устройств: +python3 scripts/github_actions.py --dry-run +``` + +Выберите открытый PR, затем UI `required`, UI `additional` или повтор обычного CI. Скрипт показывает параметры +ветки и текущий коммит, спрашивает перед отправкой и прекращает запуск, если PR изменился за время +выбора. При UI-запуске GitHub берёт коммит ветки в момент принятия запроса; не делайте пуш одновременно +с запуском. Повтор 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` +и `gh run list/rerun` массивами аргументов, без shell. Локальные секреты Selectel и Android toolchain +не нужны. Другой репозиторий задаётся через `--repo OWNER/REPO`. В списке до 1 000 открытых PR. +Ошибка или таймаут запуска не приводят к повторной отправке: сначала проверьте Actions. +Для нашего зарегистрированного workflow скрипт работает и до появления кнопки Run workflow. + +## Настройка + +Создайте сервисного пользователя 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 +``` + +Первая команда только читает данные. Последняя арендует **одно устройство с поминутной оплатой**. +По умолчанию выбирается HONOR X8c, Android API 35, arm64. Можно задать +`SELECTEL_ANDROID_API` и `SELECTEL_DEVICE_MODEL` (точное имя модели из каталога). Автоматической смены +модели или API нет. Доступность и фактическая цена зависят от провайдера. APK собираются до начала аренды и содержат только ARM64, чтобы сократить время загрузки. +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 — одна проверка сразу). +Ожидание не создаёт аренду и не оплачивается как использование устройства. Ошибки API завершают +прогон сразу; запрос аренды с неопределённым результатом не повторяется. Между проверкой наличия +и арендой устройство всё ещё может занять другой пользователь. Лимит задания увеличен до 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 +bundle exec fastlane android ui_test_artifacts profile:additional +bundle exec fastlane android selectel_ui_tests profile:additional +``` + +Для обеих команд нужен `profile:additional`. Выбранный каталог использует ARM64; если позднее явно добавить +в список ARM32, сборка переключится на универсальный APK. Локально устройства +запускаются последовательно; журналы аренды и отчёты разделены по ID из каталога. Ошибки тестов +не останавливают остальные конфигурации; ошибка освобождения аренды прекращает новые аренды. +Сводка — `app/build/reports/selectel/matrix.json`. `selectel_release` повторяет освобождение для всех +журналов в `.selectel/`, включая скачанные в отдельные подкаталоги артефактов. + +## Покрытие и результаты + +Тесты работают с настоящей 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`. + + +При отмене работают шаг освобождения с `always()` и отдельный recovery-прогон по +`workflow_run: completed`, включая результат `cancelled`. Recovery также закрывает статус ошибки +или отмены, не перезаписывая более новый прогон. Время локального cleanup ограничено: GitHub может +принудительно остановить отменяемые процессы через пять минут. Force-cancel и потеря runner могут +обойти локальное освобождение. Recovery нужен загруженный журнал; он начнёт автоматически работать +только после попадания workflow в основную ветку. + +Остаётся окно риска: 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..69ef411 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,10 +32,15 @@ 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( @@ -70,6 +74,76 @@ 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 "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] || "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 == "additional" && devices.any? { |device| device.fetch("abi") == "armeabi-v7a" } + variant = needs_arm32 ? "universal" : "arm64-v8a" + ensure_no_release_signing + selectel_contract_tests + native_library + gradle(tasks: %w[assembleDebug assembleDebugAndroidTest], + flags: "--no-daemon -PmegaproxyVersionVariant=#{variant}", 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 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-all") + end + + desc "Rent, run prebuilt UI tests, and release a Selectel Android device" + lane :selectel_ui_tests do |options| + 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") + end + 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..82ffb32 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 | @@ -14,3 +16,8 @@ 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`. +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/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/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 new file mode 100755 index 0000000..6957aef --- /dev/null +++ b/scripts/github_actions.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +"""Choose a PR and launch its tests through the authenticated GitHub CLI.""" + +import argparse +import json +import os +import re +import shlex +import subprocess +import sys + +REPOSITORY = "andre487/AndroidMegaProxy" +MODES = [ + ("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)"), +] +PR_FIELDS = "number,title,state,headRefName,headRefOid,isCrossRepository" + + +class GitHub: + 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 + + 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: + result = subprocess.run( + self.argv(*args), capture_output=True, text=True, timeout=60, env=env + ) + except FileNotFoundError: + raise RuntimeError( + "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 + + def open_prs(self): + return self.read_json( + "pr", "list", "--state", "open", "--limit", "1000", "--json", PR_FIELDS + ) + + 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): + 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 pr["isCrossRepository"]: + raise RuntimeError( + "Select an open PR from this repository; forks are unsupported." + ) + if mode in ("required", "additional"): + return ( + [ + "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.") + 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", + ) + 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 = runs[0] + if run["status"] != "completed": + 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, 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 not yes and input("Start this run? [y/N]: ").strip().lower() != "y": + print("Cancelled.") + return + current = client.pr(pr["number"]) + if ( + 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." + ) + # 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(): + 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" + ) + 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) + prs = [pr for pr in client.open_prs() if not pr["isCrossRepository"]] + 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.pr(number) + mode = choose("Tests to run", MODES) + launch(client, pr, mode, args.dry_run, args.yes) + return 0 + except (KeyboardInterrupt, EOFError): + print("\nCancelled.") + return 130 + except RuntimeError as error: + print(str(error), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) 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..05d014f --- /dev/null +++ b/scripts/selectel_ui.py @@ -0,0 +1,698 @@ +#!/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 +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 +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" + + +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): + # 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() + 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( + [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 + (result.stderr if include_stderr else "") + + +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: + devices = adb( + path, "devices", "-l", timeout=10, check=False, include_stderr=True + ) + state = next( + ( + line.split()[1] + for line in devices.splitlines() + if len(line.split()) >= 2 and line.split()[0] == endpoint + ), + "absent", + ) + if state == "device": + return + if state != diagnostic: + print(f"Remote ADB state: {state}", flush=True) + diagnostic = state + except subprocess.TimeoutExpired: + 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") + 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() + 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"]) + 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" + ) + if profile == "required": + return required + return [device for device in devices if device["id"] != required[0]["id"]] + + +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") + + +class DeviceUnavailable(RuntimeError): + pass + + +def choose_device(available): + 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 + 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 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( + "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") + public_key = adb_public_key(path) + model = wait_for_device(client) + existing = client.request("GET", "/v3/devices") + 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="") + wait_until_ready(client, serial) + 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} + ) + 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") + 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 ["/remote-connect", ""]: + try: + 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"]} + ) + 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 released; any ADB key registration created by this run removed", + 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" + 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. + 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", + "release-all", + "test", + "matrix", + "matrix-test", + ], + ) + parser.add_argument("--state", type=Path, default=ROOT / ".selectel/lease.json") + parser.add_argument( + "--profile", choices=["required", "additional"], default="required" + ) + 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": + run_tests(args.state) + else: + client = Client() + 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']}" + ) + 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_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 new file mode 100644 index 0000000..2157181 --- /dev/null +++ b/scripts/tests/test_github_actions.py @@ -0,0 +1,195 @@ +import copy +import importlib.util +import os +import subprocess +import unittest +from pathlib import Path +from unittest.mock import 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 = m.GitHub(m.REPOSITORY) + self.pr = { + "number": 31, + "state": "OPEN", + "headRefOid": "abc", + "headRefName": "feature/test", + "isCrossRepository": False, + } + + 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_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["headRefOid"] = "new" + elif change == "closed": + current["state"] = "CLOSED" + else: + 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, "required") + + 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_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_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"): + m.plan_run(self.client, self.pr, "additional") + + def test_ci_filters_current_commit_and_branch(self): + run = { + "databaseId": 20, + "headSha": "abc", + "headBranch": "feature/test", + "status": "completed", + "url": "https://github.com/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( + [ + "gh", + "pr", + "view", + "branch/$(not-a-command)", + "--json", + "number", + "--repo", + m.REPOSITORY, + ], + args[0], + ) + 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.object(m.subprocess, "run", side_effect=FileNotFoundError), + self.assertRaisesRegex(RuntimeError, "gh auth login"), + ): + self.client.open_prs() + + 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 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__": + unittest.main() diff --git a/scripts/tests/test_selectel_ui.py b/scripts/tests/test_selectel_ui.py new file mode 100644 index 0000000..24e2adf --- /dev/null +++ b/scripts/tests/test_selectel_ui.py @@ -0,0 +1,485 @@ +import importlib.util +import json +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import Mock, 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 "/v3/users/devices/" 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") + ) + + +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 ( + patch.object( + m, + "adb", + side_effect=[ + "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"), + ): + m.connect_device(Path("/tmp/test-lease.json"), "example.test:1234") + self.assertEqual( + [("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): + 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 [ + "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": + 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"} + return None # Assignment succeeds with an empty HTTP 200 response. + + client.request.side_effect = request + + 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, "adb_public_key", return_value="primary-public-key"), + 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.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, + ) + 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): + 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 ( + patch.dict(os.environ, {}, clear=True), + patch.object(m, "Client", side_effect=AssertionError), + ): + devices = m.device_matrix("additional") + required = m.device_matrix("required") + self.assertEqual(1, len(required)) + self.assertEqual("X8c", 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", "36", "37"], sorted(d["api"] for d in devices)) + + def test_selection_does_not_substitute_another_configuration(self): + device = m.device_matrix("required")[0] + with patch.dict(os.environ): + m.select_configuration(device) + candidate = dict( + platform="Android", + sdk="35", + marketName="X8c", + manufacturer="HONOR", + 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("additional")[: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", "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() + ) + 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()