Skip to content

Run unit tests in CI and characterize the git auth controller - #75

Merged
AhmadKharfan merged 4 commits into
developfrom
chore/unit-test-safety-net
Jul 29, 2026
Merged

Run unit tests in CI and characterize the git auth controller#75
AhmadKharfan merged 4 commits into
developfrom
chore/unit-test-safety-net

Conversation

@AhmadKharfan

@AhmadKharfan AhmadKharfan commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Why

CI has never run the unit tests. ci_verify_build.yml runs detekt and :app:assembleDebug only, so the 689 existing tests could go red on develop without anyone noticing. This is the safety net for the feature-modularization work that follows, where every later step relies on the suite being trustworthy.

Two smaller findings came out of the same audit:

  • GitAuthController — the git-auth state machine shared between :feature:git and :feature:settings — has no direct test coverage at all. It sits exactly on a boundary that a later change reshapes.
  • :feature:settings declares implementation(projects.feature.projects) but uses nothing from it.

What changed

  • .github/workflows/ci_verify_build.yml — new unit-tests job running ./gradlew test, alongside the existing detekt and assemble jobs. Uploads the HTML test reports when it fails.
  • GitAuthControllerCharacterizationTest — 18 characterization tests in :feature:git pinning the current behaviour of GitAuthController: prompt opening, token submission, the GitHub device flow, cancellation, and dismissal.
  • feature/settings/build.gradle.kts — dropped the unused :feature:projects dependency.
  • DataStorePreferencesRepositoryTest — fixed a pre-existing race the new CI job immediately exposed. See below.

Behavior

No behavior change. Only test and CI additions, plus one dependency removal with no corresponding source usage.

The failure the new job caught on its first run

DataStorePreferencesRepositoryTest > selected variant is remembered per project across a simulated relaunch failed in CI with:

There are multiple DataStores active for the same file: /tmp/junit.../app_preferences.preferences_pb. You should either maintain your DataStore as a singleton or confirm that there is no two DataStore's active on the same file (by confirming that the scope is cancelled).

The test's withRepository helper cancels its CoroutineScope in a finally, then immediately opens a second DataStore on the same file to simulate a relaunch. Cancellation is not synchronous: DataStore only releases its exclusive connection to the file once the scope's job has actually completed. Locally the teardown wins that race, so the test always passed; on a slower runner it loses.

Fixed by joining the job after cancelling. This bug is not introduced here — it has been latent for as long as the test has existed, because the tests have never run in CI. It is included in this PR because a change whose purpose is turning the gate on cannot land with the gate red.

The characterization tests deliberately assert what the code does today rather than what it arguably should do. Two quirks are pinned as-is, with comments:

  • The re-entrancy guard in onStartGitHubSignIn keys off emitted state (isBusy / device / succeeded), not off whether a deviceJob is live. Until the authenticator emits its first step, all three are false, so a second tap starts a second authentication and cancels the first.
  • Submitting a token with a null host saves nothing but still resets the prompt and runs the retry callback.

Both are pinned so a later refactor of this boundary either preserves them or changes them knowingly.

Scope

Left out on purpose:

  • Feature/data dependency cleanup. An audit found 14 more feature-to-data edges with no src/main usage, but two of them (:feature:projects to :data:templates, :feature:editor to :data:local) are used from tests and need converting to testImplementation rather than deleting. That is its own change.
  • Navigation result-delivery tests. Routes.kt builds routes through android.net.Uri.encode, which returns stubbed defaults under testOptions.unitTests.isReturnDefaultValues, and there is no Robolectric here. A JVM test would assert on null and falsely bless a broken route, so it is not worth adding in this shape.
  • :data:local detekt baseline. It is the one module without a baseline file, but detekt reports zero findings for it, so the baseline would be empty.

Tests

  • 18 new tests in GitAuthControllerCharacterizationTest, covering: prompt open in both sign-in and token modes, hasCredentials null-host handling, blank and whitespace token rejection, token trimming and credential save shape, the null-host submission quirk, the unconfigured-GitHub fallback message, device-code surfacing, error mapping, the re-entrancy guard on both sides, mode-switch cancellation, dismissal before and after success, and the 1100 ms deferred auto-close.
  • Timing is pinned on virtual time. advanceTimeBy runs events in [now, now + n), so the deferred close is asserted at exactly 1100 ms rather than "some time later".
  • The cancellation tests use a live MutableSharedFlow and assert that a late emission cannot mutate state. Verified by mutation: deleting deviceJob?.cancel() from onAuthModeChanged makes the test fail.
  • DataStorePreferencesRepositoryTest now deterministically tears its DataStore down between simulated relaunches.
  • All 689 pre-existing tests still pass.

Verification

Verified from a clean worktree of the branch tip, so no uncommitted local work could mask a failure:

  • ./gradlew test detekt — BUILD SUCCESSFUL
  • ./gradlew :app:assembleDebug — BUILD SUCCESSFUL
  • Test totals: 707 passing, 0 failures (689 before this branch, plus the 18 added here).

CI on the branch tip: Unit tests, Detekt and Assemble debug all green. The DataStore race could only be confirmed fixed in CI — it reproduces on runner I/O timings, and five local --rerun-tasks runs passed both before and after the fix, so local runs prove nothing here.

./gradlew test was confirmed to cover every subproject, and to run debug unit tests only — the release-signing guard in app/build.gradle.kts reads gradle.startParameter.taskNames, so a test invocation cannot trip it and the new job needs no signing configuration.

@AhmadKharfan
AhmadKharfan merged commit be08381 into develop Jul 29, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant