From 8897d2467152a2487694e3f6f3b36c8a4b1dcc8d Mon Sep 17 00:00:00 2001 From: Altay Date: Sun, 20 Sep 2026 15:59:17 +0300 Subject: [PATCH] fix(ci): scope emulator smoke and await OAuth callback jobs --- .github/workflows/emulator-smoke.yml | 28 ++++++++++++++++--- AGENTS.md | 8 ++++-- .../android/auth/StaleOAuthCallbackTest.kt | 9 +++--- .../android/auth/MobileOAuthRuntime.kt | 4 +-- 4 files changed, 35 insertions(+), 14 deletions(-) diff --git a/.github/workflows/emulator-smoke.yml b/.github/workflows/emulator-smoke.yml index 44d9139a..8eb28376 100644 --- a/.github/workflows/emulator-smoke.yml +++ b/.github/workflows/emulator-smoke.yml @@ -1,7 +1,8 @@ name: Emulator smoke # Opt-in / scheduled lane: boots the app on a Gradle Managed Device and runs -# the instrumented LaunchSmokeTest (RESUMED + stability + composited pixels). +# LaunchSmokeTest (RESUMED + stability + composited pixels) and the credential-free +# stale OAuth callback regression. Other device suites require explicit opt-in. # Not a PR gate — emulator boots on shared runners are too slow and flaky to # block merges; the PR gate is the CI workflow. Local day-to-day proof stays # on scripts/prove.sh. @@ -20,7 +21,7 @@ concurrency: jobs: smoke: - name: Launch smoke (mobile, managed device) + name: Launch and OAuth smoke (mobile, managed device) runs-on: ubuntu-24.04 timeout-minutes: 60 @@ -82,12 +83,31 @@ jobs: working-directory: putio-android run: >- ./gradlew :app:ciPhoneMobileProductionDebugAndroidTest + -Pandroid.testInstrumentationRunnerArguments.class=io.putdotio.android.LaunchSmokeTest -Pandroid.testoptions.manageddevices.emulator.gpu=swiftshader - - name: Upload device test results + - name: Upload launch test results if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: managed-device-results + name: launch-smoke-results + if-no-files-found: warn + path: putio-android/app/build/outputs/androidTest-results/** + + # AGP's instrumentation-args map drops comma-separated class selectors. + # Keep the OAuth regression explicit and retain each run's result files. + - name: Run stale OAuth callback regression + id: oauth-smoke + working-directory: putio-android + run: >- + ./gradlew :app:ciPhoneMobileProductionDebugAndroidTest + -Pandroid.testInstrumentationRunnerArguments.class=io.putdotio.android.auth.StaleOAuthCallbackTest + -Pandroid.testoptions.manageddevices.emulator.gpu=swiftshader + + - name: Upload OAuth test results + if: ${{ !cancelled() && steps.oauth-smoke.outcome != 'skipped' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: oauth-smoke-results if-no-files-found: warn path: putio-android/app/build/outputs/androidTest-results/** diff --git a/AGENTS.md b/AGENTS.md index 627ee643..66593bf2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -135,9 +135,11 @@ worktree's ignored `local.properties` before running Gradle. Include local modifications with a failure report; SHA pairs describe only committed source. Emulator-on-CI: `.github/workflows/emulator-smoke.yml` (weekly schedule + -`workflow_dispatch`) runs `LaunchSmokeTest` on the `ciPhone` Gradle Managed -Device (`app/build.gradle.kts`, API 36, swiftshader) with KVM enabled on the -runner. It is deliberately not a PR gate: shared-runner emulator boots are too +`workflow_dispatch`) explicitly selects `LaunchSmokeTest` and the credential-free +`StaleOAuthCallbackTest` in separate invocations with separate result artifacts; +opt-in feature suites remain separate. Both run on the +`ciPhone` Gradle Managed Device (`app/build.gradle.kts`, API 36, swiftshader) with +KVM enabled on the runner. It is deliberately not a PR gate: shared-runner emulator boots are too slow and flaky to block merges, so `scripts/prove.sh` stays the local proof. ## Harness diff --git a/app/src/androidTestMobile/kotlin/io/putdotio/android/auth/StaleOAuthCallbackTest.kt b/app/src/androidTestMobile/kotlin/io/putdotio/android/auth/StaleOAuthCallbackTest.kt index fa3c8664..c0e02a3b 100644 --- a/app/src/androidTestMobile/kotlin/io/putdotio/android/auth/StaleOAuthCallbackTest.kt +++ b/app/src/androidTestMobile/kotlin/io/putdotio/android/auth/StaleOAuthCallbackTest.kt @@ -11,7 +11,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.joinAll import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import org.junit.Assert.assertEquals @@ -57,22 +56,22 @@ class StaleOAuthCallbackTest { val newerAttempt = requireNotNull(pendingStore.read()) assertNotEquals(olderAttempt.state, newerAttempt.state) - runtime.dispatchAuthTabResult( + val staleCallback = runtime.dispatchAuthTabResult( AuthTabIntent.RESULT_OK, "putio://auth#state=${olderAttempt.state}&access_token=synthetic-stale-token", ) - withTimeout(CALLBACK_TIMEOUT_MILLIS) { runtimeJob.children.toList().joinAll() } + withTimeout(CALLBACK_TIMEOUT_MILLIS) { staleCallback.join() } assertNull(runtimeFailure.get()) assertEquals(newerAttempt, pendingStore.read()) assertEquals(MobileAuthState.AwaitingOAuthCallback, controller.state.value) assertNull(tokenStore.read()) - runtime.dispatchAuthTabResult( + val matchingCallback = runtime.dispatchAuthTabResult( AuthTabIntent.RESULT_OK, "putio://auth#state=${newerAttempt.state}&access_token=", ) - withTimeout(CALLBACK_TIMEOUT_MILLIS) { runtimeJob.children.toList().joinAll() } + withTimeout(CALLBACK_TIMEOUT_MILLIS) { matchingCallback.join() } assertNull(runtimeFailure.get()) assertNull(pendingStore.read()) diff --git a/app/src/mobile/kotlin/io/putdotio/android/auth/MobileOAuthRuntime.kt b/app/src/mobile/kotlin/io/putdotio/android/auth/MobileOAuthRuntime.kt index 5bf8a534..52c5c61a 100644 --- a/app/src/mobile/kotlin/io/putdotio/android/auth/MobileOAuthRuntime.kt +++ b/app/src/mobile/kotlin/io/putdotio/android/auth/MobileOAuthRuntime.kt @@ -11,6 +11,7 @@ import io.putdotio.sdk.PutioConfig import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch @@ -49,7 +50,7 @@ class MobileOAuthRuntime internal constructor( fun dispatchAuthTabResult( resultCode: Int, rawResultUri: String?, - ) { + ): Job = // Activity results outlive individual UI owners; keep a boundary failure inside this application scope. @Suppress("TooGenericExceptionCaught") applicationScope.launch { @@ -65,7 +66,6 @@ class MobileOAuthRuntime internal constructor( failureReporter.report(error) } } - } companion object { @Volatile