Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25f162923c | ||
|
|
ad28293b72 | ||
|
|
b9abe85580 | ||
|
|
4375a377bc | ||
|
|
3925f1aa9f | ||
|
|
a3c835b7c9 | ||
|
|
650ca8fca3 | ||
|
|
b18f45def7 | ||
|
|
d674bc4848 | ||
|
|
02555ceb91 | ||
|
|
1b1d5c6d04 | ||
|
|
2f3f461cc1 | ||
|
|
6166763f24 | ||
|
|
46ad95350b | ||
|
|
e968deb5a2 | ||
|
|
3d55004286 | ||
|
|
e06b0826a0 | ||
|
|
7b578c1ccf | ||
|
|
9e7f80feaa | ||
|
|
3d51fefeff |
@@ -240,17 +240,33 @@ jobs:
|
||||
# CAVEAT, read this before trusting a green here: this leg runs with
|
||||
# SystemUI disabled and the framework restarted under it. No other leg
|
||||
# and no Pixel run uses that configuration. It is defensible only because
|
||||
# nothing in this suite touches system UI -- these are Media3, FFmpeg and
|
||||
# nothing THIS LEG RUNS touches system UI -- Media3, FFmpeg and
|
||||
# WorkManager tests -- and because the alternative is no CI coverage of
|
||||
# the level this app targets. **Anything that ever does depend on system
|
||||
# UI must not trust this row.** E2E_DISABLE_SYSTEM_UI is what does it;
|
||||
# .github/scripts/e2e-run.sh explains the mechanism and why every step of
|
||||
# it is verified rather than assumed.
|
||||
#
|
||||
# "this leg" and not "this suite", since 2026-08-24, and the difference is
|
||||
# now load-bearing: SafPickerRoundTripTest DOES touch system UI. It drives
|
||||
# DocumentsUI and rotates the display, and both reach the gralloc mapper
|
||||
# this image aborts in -- disabling SystemUI removes the IDLE trigger, not
|
||||
# those. Measured per method on android-37.0: the ROTATION test takes the
|
||||
# framework down (INSTRUMENTATION_ABORTED) and carries
|
||||
# @FailsOnEmulatorApi37, so notAnnotation below keeps it off this row; the
|
||||
# PICKER test passes and runs here like anything else. A rotation rebuilds
|
||||
# every surface at once, and starting another app's activity does not.
|
||||
#
|
||||
# So this row does now run one test that depends on system UI, and the
|
||||
# caveat above still applies to it: a green here is not evidence the picker
|
||||
# works on a device with SystemUI running -- the Pixel release check is.
|
||||
# docs/api-37-emulator-crash.md has the per-method measurements, and the
|
||||
# correction that produced them.
|
||||
#
|
||||
# api-level must be "37.0". A bare 37 is not an SDK package and fails
|
||||
# during setup, which cost a run to discover.
|
||||
#
|
||||
# notAnnotation removes the two tests that do not pass on this image; they
|
||||
# notAnnotation removes the three tests that do not pass on this image; they
|
||||
# run in the advisory job below, off the same marker so they cannot end up
|
||||
# in both or neither. docs/api-37-emulator-crash.md has the measurements.
|
||||
- label: "37"
|
||||
@@ -351,7 +367,7 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The two API 37 tests the gating row above excludes, run on their own so they
|
||||
# The three API 37 tests the gating row above excludes, run on their own so they
|
||||
# stay visible instead of disappearing behind a notAnnotation.
|
||||
#
|
||||
# continue-on-error: it reports, it never blocks. That is the whole reason it is
|
||||
@@ -359,17 +375,29 @@ jobs:
|
||||
# job's `E2E API <label>` name, and a check cannot be both required and advisory
|
||||
# under one name.
|
||||
#
|
||||
# It is named for WHAT IT RUNS, deliberately. Both tests drive a full H.264 ->
|
||||
# H.265 hardware transcode through Media3Engine -- which is exactly what
|
||||
# separates them from the two Media3EngineTest cases that pass here, since those
|
||||
# two never decode video. The current theory about why they fail is in the next
|
||||
# paragraph, where it can be corrected without renaming a check that people have
|
||||
# already learned to look for.
|
||||
# It was named for WHAT IT RUNS, and that is now APPROXIMATE rather than exact.
|
||||
# When this job was created it held two tests, both driving a full H.264 -> H.265
|
||||
# hardware transcode through Media3Engine -- which is exactly what separated them
|
||||
# from the two Media3EngineTest cases that pass here, since those two never decode
|
||||
# video. Since 2026-08-25 it also holds SafPickerRoundTripTest's rotation case,
|
||||
# which drives no transcode at all: a real rotation rebuilds every surface at once
|
||||
# and takes the framework down on this image (INSTRUMENTATION_ABORTED), which is a
|
||||
# different failure from the decoder one below.
|
||||
#
|
||||
# The name is kept anyway, and that is a decision rather than an oversight. This is
|
||||
# not a required context, it is red on every PR by design, and it is one people
|
||||
# have learned to look for -- renaming a check costs more than the imprecision
|
||||
# does. **The marker is the definition, not the name:** what this job holds is the
|
||||
# tests that cannot pass on the API 37 emulator image, whatever their subject. The
|
||||
# theory about the Media3 pair is in the next paragraph, where it can be corrected
|
||||
# without touching the name.
|
||||
#
|
||||
# THEORY, NOT SETTLED: the exception surfaces at `dequeueOutputBuffer` on
|
||||
# `c2.goldfish.h264.decoder`, the emulator's own codec, which gets its frames out
|
||||
# of a host-side colour buffer -- the same readback machinery that aborts
|
||||
# surfaceflinger on this image. What is MEASURED is narrower: these two fail on
|
||||
# surfaceflinger on this image. It is about the MEDIA3 PAIR only; the rotation
|
||||
# case above fails for its own reason. What is MEASURED is narrower: those two
|
||||
# fail on
|
||||
# the API 37 emulator image; pass at API 36 on this runner under the same renderer
|
||||
# AND the same SystemUI-disable path; pass at API 33-36 without that path at all,
|
||||
# since nothing below 37 needs it; and pass on a physical Pixel 10 Pro XL at 37. That the decoder is the culprit rather than something else
|
||||
|
||||
@@ -64,16 +64,34 @@ of the first one that fails.
|
||||
on this machine (below), so without it an androidTest compile error is not discovered until CI.
|
||||
ktlint and detekt also cover the `test`/`androidTest` source sets that `lintDebug` skips.
|
||||
|
||||
## Instrumented tests do not run locally
|
||||
## Instrumented tests: where they actually run
|
||||
|
||||
Two independent reasons, so do not spend time on either:
|
||||
This section said the opposite until 2026-08-24, and both of its claims had been false for two
|
||||
days. Read it as the current answer, and see the git history if you need the old one.
|
||||
|
||||
- **Emulators segfault on this host.** qemu dies on every AVD. Instrumented tests run on CI or on
|
||||
the physical Pixel, never in a local emulator.
|
||||
- **The API 37 image is broken.** `android-37.0` crash-loops surfaceflinger inside its own gralloc
|
||||
mapper, so every test fails there regardless of this app. `docs/api-37-emulator-crash.md` records
|
||||
the evidence and the ruled-out fixes; CI's matrix therefore stops at API 36 even though targetSdk
|
||||
is 37. **API 37 needs a manual check on the Pixel 10 Pro XL before each release.**
|
||||
- **Local emulators work, for API 33-36.** `tools/local-emulator/run-e2e.sh` runs them on this
|
||||
host. The segfault that made this look impossible was not a broken machine: SwiftShader's Reactor
|
||||
JIT writes generated shader code onto the heap and executes it, Fedora's SELinux policy denies
|
||||
`execheap`, and qemu dies. Choosing a different renderer avoids it entirely — `-gpu host`,
|
||||
`angle_indirect` and `swangle_indirect` all boot, while `auto`, `off`, `guest` and
|
||||
`swiftshader_indirect` do not. `docs/local-emulator.md` has the evidence and the per-API renderer
|
||||
table.
|
||||
- **CI runs API 37, and it gates.** The matrix is 33/34/35/36/37. **Three** of the 59 instrumented
|
||||
tests cannot pass on that image, for two unrelated reasons: two Media3 hardware transcodes fail
|
||||
inside the emulator's own `c2.goldfish.h264.decoder`, and one SAF test takes the framework down
|
||||
when it rotates the display. All three carry `@FailsOnEmulatorApi37` and run in a separate
|
||||
`continue-on-error` job; the gating leg runs the other 56.
|
||||
|
||||
That job is still called `E2E API 37 Media3 hardware transcode (advisory)`, which no longer
|
||||
describes everything in it. The name is kept deliberately — it is not a required context and
|
||||
people have learned to look for it — so **read the marker, not the name**, for what it holds.
|
||||
**It is red on every PR, by design**: do not read it as your change breaking something, and do
|
||||
not read a green run as evidence those three tests pass.
|
||||
`docs/api-37-emulator-crash.md` has the measurements.
|
||||
|
||||
Still true, and the reason the advisory job is not simply deleted: **API 37 needs a manual check on
|
||||
the Pixel 10 Pro XL before each release.** Those three tests are the one thing CI cannot answer
|
||||
for.
|
||||
|
||||
On a device or emulator, build only the ABI it can execute:
|
||||
|
||||
@@ -96,12 +114,26 @@ install for code that can never run — and on API 37 the full APK does not fit
|
||||
- The `model` package is excluded from `ReturnCount` and `CyclomaticComplexMethod` only. It is the
|
||||
decision layer, where one branch is one documented user-visible outcome and the metric counts
|
||||
answers rather than complexity. Every other rule still applies there.
|
||||
- **Coverage is reported, not gated** — **29.8% of lines (629/2113), 28.7% of branches**, measured
|
||||
on `main` 2026-08-23 with `./gradlew :app:jacocoTestReport`. A floor needs a baseline that has
|
||||
settled first, and this one has not: the figure **fell** from the ~31% recorded earlier even
|
||||
though the JVM suite went from 11 test files to 43. Main source grew 4,114 -> 5,715 lines over
|
||||
the same period, so the denominator outran the numerator. Re-measure before quoting it; do not
|
||||
assume more tests means a higher percentage here.
|
||||
- **Coverage is reported, not gated** — **69.2% of lines (1519/2194), 53.2% of branches**,
|
||||
measured 2026-08-24 with `./gradlew :app:jacocoTestReport`.
|
||||
|
||||
**Every figure this file carried before that date was an artifact, roughly half the real one.**
|
||||
Robolectric loads classes through its own sandbox classloader with no source location, JaCoCo
|
||||
skips no-location classes by default, and nothing told it otherwise — so **not one Robolectric
|
||||
test counted**, and Robolectric is what exercises the framework edge here. The
|
||||
`isIncludeNoLocationClasses` block in `app/build.gradle.kts` is what fixes it; **do not delete
|
||||
it as stray config**, and re-run the numbers if you ever touch it. Same commit, same 335 tests:
|
||||
29.7% -> 69.2% with that block alone.
|
||||
|
||||
The old entry also explained the wrong thing. It said coverage **fell** as the suite grew from 11
|
||||
test files to 43 because "the denominator outran the numerator" on framework-edge code "the JVM
|
||||
cannot reach". The JVM reaches that code fine. What actually happened is that the new tests were
|
||||
disproportionately Robolectric, so each one added denominator and no numerator — the measurement
|
||||
was punishing exactly the tests that were hardest to write.
|
||||
|
||||
Two things still hold. A floor needs a baseline that has settled, and this one has now moved by
|
||||
39 points in a single build change, so it has not. And **re-measure before quoting** — that
|
||||
instruction is the only reason this was caught.
|
||||
- **Testable code is not done until it is tested.** If a piece is unit testable, it gets unit
|
||||
tests before it counts as done. If it is e2e testable, it gets e2e tests. Both clauses apply —
|
||||
a change that is both needs both.
|
||||
@@ -112,9 +144,9 @@ install for code that can never run — and on API 37 the full APK does not fit
|
||||
documents the reasoning — turns "needs a device" into "a pure function plus a thin edge".
|
||||
Robolectric is in the JVM source set, `compose-ui-test-junit4` with it, so Compose screens are
|
||||
unit testable too. Reach for the seam before concluding something cannot be unit tested.
|
||||
- **E2E is runnable locally now.** `tools/local-emulator/run-e2e.sh` runs API 33-36 on this
|
||||
machine; see `docs/local-emulator.md`. That was believed impossible until the SELinux/renderer
|
||||
cause was found, and it is what makes the e2e half of this norm enforceable.
|
||||
- **E2E is runnable locally**, API 33-36, via `tools/local-emulator/run-e2e.sh` — see
|
||||
"Instrumented tests: where they actually run" above. That was believed impossible until the
|
||||
SELinux/renderer cause was found, and it is what makes the e2e half of this norm enforceable.
|
||||
- **A test has to bite.** Revert the line it covers, confirm it goes red, restore. A review of
|
||||
this codebase ran 46 mutations against a 257-test suite and **9 were vacuous** — five of them
|
||||
passing the whole suite over a completely unguarded code path. Green is not evidence.
|
||||
|
||||
+35
-2
@@ -188,6 +188,30 @@ detekt {
|
||||
}
|
||||
|
||||
// Pin the coverage agent rather than inheriting whatever Gradle bundles.
|
||||
// Robolectric loads every class it touches through its own sandbox classloader, and those
|
||||
// classes arrive with no source location. JaCoCo skips no-location classes by default, so
|
||||
// without this block **not one Robolectric test counts** -- and Robolectric is what exercises
|
||||
// the framework edge here: the workers, the publisher, both ViewModels, every Compose screen.
|
||||
//
|
||||
// Measured on e06b082, same 335 tests, same 0 failures, only this block added:
|
||||
//
|
||||
// LINE 29.7% -> 69.2% OutputPublisher 0.0% -> 97.5%
|
||||
// BRANCH 29.8% -> 53.2% ConversionViewModel 0.0% -> 85.4%
|
||||
//
|
||||
// The discriminator, if this ever looks like superstition: inside ConverterScreenKt, `describe`
|
||||
// is the one non-Composable and is exercised by a plain JVM test -- it reported 8/8 covered while
|
||||
// every @Composable in the same class reported 0, including ones whose mutations demonstrably
|
||||
// failed the build when reverted.
|
||||
//
|
||||
// `excludes` is not optional. Without it JaCoCo walks JDK-internal classes that Robolectric has
|
||||
// no location for either, and the test JVM dies rather than reporting a number.
|
||||
tasks.withType<Test>().configureEach {
|
||||
extensions.configure<JacocoTaskExtension> {
|
||||
isIncludeNoLocationClasses = true
|
||||
excludes = listOf("jdk.internal.*")
|
||||
}
|
||||
}
|
||||
|
||||
jacoco {
|
||||
toolVersion = libs.versions.jacoco.get()
|
||||
}
|
||||
@@ -211,8 +235,11 @@ val jacocoGeneratedExcludes = listOf(
|
||||
)
|
||||
|
||||
// AGP 9 compiles Kotlin through its built-in compiler, which writes here rather than to the
|
||||
// classic `tmp/kotlin-classes/debug`. All hand-written code in this module is Kotlin, so the
|
||||
// javac output (BuildConfig and R only) is not read at all.
|
||||
// classic `tmp/kotlin-classes/debug`. All hand-written code in the MAIN source set is Kotlin, so
|
||||
// the javac output (BuildConfig and R only) is not read at all. There is now one hand-written
|
||||
// Java file in the module -- androidTest's FixtureDocumentsProvider, which cannot be Kotlin
|
||||
// because the process it runs in has no Kotlin stdlib; its own header explains why. It is in
|
||||
// androidTest, so it is not in this task's classDirectories and this stays accurate.
|
||||
val jacocoDebugKotlinClasses = layout.buildDirectory.dir(
|
||||
"intermediates/built_in_kotlinc/debug/compileDebugKotlin/classes",
|
||||
)
|
||||
@@ -316,5 +343,11 @@ dependencies {
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(libs.compose.ui.test.junit4)
|
||||
androidTestImplementation(libs.androidx.work.testing)
|
||||
// androidTest only, and it has to be: UiAutomator drives the whole device, including
|
||||
// windows belonging to other packages. The system file picker is one -- DocumentsUI runs
|
||||
// in its own process, so Compose's matchers cannot see it and Espresso's cannot either
|
||||
// (both are scoped to this process's view hierarchy). Nothing on the JVM has a device to
|
||||
// drive, so there is no unit-test counterpart to add it to.
|
||||
androidTestImplementation(libs.androidx.uiautomator)
|
||||
debugImplementation(libs.compose.ui.test.manifest)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
The first manifest this source set has ever had, and it exists for one component.
|
||||
|
||||
SafPickerRoundTripTest drives the real system file picker. DocumentsUI only shows what a
|
||||
DocumentsProvider offers it, so a test that picks a file needs a provider to pick from, and
|
||||
that provider has to be declared: a ContentProvider is instantiated by the system from a
|
||||
manifest entry and cannot be registered from test code.
|
||||
|
||||
It is declared HERE rather than in src/debug on purpose. src/debug would put a fake storage
|
||||
root inside the shipped debug APK, where it would show up in every developer's own file
|
||||
picker and in every other app's; this way it is installed only by the instrumentation APK,
|
||||
alongside the test that needs it, and is gone the moment that APK is uninstalled.
|
||||
|
||||
The four attributes are not decoration. Each one is required for the picker to see it:
|
||||
|
||||
exported DocumentsUI is another app; an unexported provider is invisible to it.
|
||||
permission MANAGE_DOCUMENTS is held by DocumentsUI and essentially nothing else,
|
||||
so this is what stops any installed app from reading the fixture. The
|
||||
provider is exported to the *picker*, not to the world.
|
||||
grantUriPermissions How the app under test ends up able to read the URI it was handed. The
|
||||
picker returns the document URI with FLAG_GRANT_READ_URI_PERMISSION,
|
||||
and that flag does nothing unless the provider allows grants. Without
|
||||
it the pick "succeeds" and every read of the result fails.
|
||||
DOCUMENTS_PROVIDER The action DocumentsUI queries the package manager for. No filter, no
|
||||
root in the drawer.
|
||||
|
||||
The authority carries the .test suffix because this component belongs to the instrumentation
|
||||
package (org.libremediaconverter.test), not to the app. Authorities are global to the device:
|
||||
reusing the app's would collide with the app on any device where both are installed.
|
||||
-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application>
|
||||
<provider
|
||||
android:name="org.libremediaconverter.saf.FixtureDocumentsProvider"
|
||||
android:authorities="org.libremediaconverter.test.fixtures"
|
||||
android:exported="true"
|
||||
android:grantUriPermissions="true"
|
||||
android:permission="android.permission.MANAGE_DOCUMENTS">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
|
||||
</intent-filter>
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,234 @@
|
||||
package org.libremediaconverter.saf;
|
||||
|
||||
import android.database.Cursor;
|
||||
import android.database.MatrixCursor;
|
||||
import android.os.CancellationSignal;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
import android.provider.DocumentsContract.Document;
|
||||
import android.provider.DocumentsContract.Root;
|
||||
import android.provider.DocumentsProvider;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
|
||||
/**
|
||||
* One file, offered to the system file picker, so that picking one can be tested at all.
|
||||
*
|
||||
* <p>DocumentsUI does not browse a filesystem: it lists what {@link DocumentsProvider}s hand it.
|
||||
* So a test that drives the real picker has to supply the thing being picked, and it has to
|
||||
* supply it as a manifest-declared component, because a {@code ContentProvider} is instantiated
|
||||
* by the system and cannot be registered from test code. {@code
|
||||
* app/src/androidTest/AndroidManifest.xml} is that declaration and says why each of its
|
||||
* attributes is load-bearing.
|
||||
*
|
||||
* <h2>The only Java file in this module, and it has to be</h2>
|
||||
*
|
||||
* <p>Everything else here is Kotlin. This cannot be: <b>the Kotlin standard library is not on
|
||||
* this class's classpath at runtime.</b>
|
||||
*
|
||||
* <p>Instrumentation code normally never notices. The test APK's dex is loaded into the app's
|
||||
* process, where the app APK supplies {@code kotlin.jvm.internal.Intrinsics} — so the test APK is
|
||||
* built without it, deliberately, since packaging a second copy is what {@code
|
||||
* checkDebugAndroidTestDuplicateClasses} exists to prevent. A provider is different. It is a
|
||||
* component of the instrumentation <i>package</i>, so when DocumentsUI queries it the system
|
||||
* starts a plain {@code org.libremediaconverter.test} process with only the test APK on its dex
|
||||
* path, and no app APK anywhere. The Kotlin version of this file crashed there on its first
|
||||
* query, before returning a single row:
|
||||
*
|
||||
* <pre>
|
||||
* FATAL EXCEPTION: binder:6369_2
|
||||
* Process: org.libremediaconverter.test
|
||||
* java.lang.NoClassDefFoundError: Failed resolution of: Lkotlin/jvm/internal/Intrinsics;
|
||||
* at org.libremediaconverter.saf.FixtureDocumentsProvider.queryDocument
|
||||
* </pre>
|
||||
*
|
||||
* <p>The compiler emits that reference for the null checks on almost every function, so there is
|
||||
* no Kotlin dialect that avoids it. For the same reason nothing here imports {@code androidx.*}:
|
||||
* those classes are absent from this process for exactly the same reason. Framework and JDK only.
|
||||
*
|
||||
* <h2>Why a provider rather than a file in Downloads</h2>
|
||||
*
|
||||
* <p>That would have worked, and it would have tested less. Two properties are what {@code
|
||||
* SafPickerRoundTripTest} actually needs:
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>The root declares {@link Root#COLUMN_MIME_TYPES}, and DocumentsUI filters by it.</b>
|
||||
* That is what gives the screen's MIME filter a mutation with a shape: ask for a type this
|
||||
* root does not offer and the root itself is not in the picker, so the failure reads as
|
||||
* "the fixture root is not there" rather than "one file among the hundreds in Downloads was
|
||||
* not listed".
|
||||
* <li><b>The contents are exactly this and nothing else.</b> A shared directory accumulates
|
||||
* whatever earlier runs and other tests left in it, and a picker test that finds the wrong
|
||||
* file passes.
|
||||
* </ul>
|
||||
*
|
||||
* <p>The descriptor is opened on a real file rather than served through a pipe, deliberately.
|
||||
* {@code InputQuery.sizeOf} falls back to {@code ParcelFileDescriptor.statSize} when a provider
|
||||
* omits {@code OpenableColumns.SIZE}, and a pipe's {@code statSize} is {@code -1} — an unknown
|
||||
* size, which is a different case with a screen of its own. This fixture is meant to be an
|
||||
* ordinary, fully described file, so that the one thing under test is the round trip.
|
||||
*/
|
||||
public final class FixtureDocumentsProvider extends DocumentsProvider {
|
||||
|
||||
/**
|
||||
* What the picker calls this root.
|
||||
*
|
||||
* <p>Deliberately not a word any other root uses. The picker's own landing screen already
|
||||
* offers "Images", "Audio", "Videos" and "Documents", and a UiAutomator selector that could
|
||||
* match two things is not a selector.
|
||||
*/
|
||||
public static final String ROOT_TITLE = "LMC R38 fixtures";
|
||||
|
||||
/**
|
||||
* What the file card has to end up showing.
|
||||
*
|
||||
* <p>The same string reaches the assertion two ways — as the picker row UiAutomator taps, and
|
||||
* as {@code OpenableColumns.DISPLAY_NAME} on the URI the app is handed — which is exactly the
|
||||
* round trip under test.
|
||||
*/
|
||||
public static final String FIXTURE_DISPLAY_NAME = "lmc-r38-fixture.mp4";
|
||||
|
||||
/**
|
||||
* The type the root advertises, and the one the MIME mutation has to stop matching.
|
||||
*
|
||||
* <p>A real type rather than something invented, so the wildcard filter the screen passes
|
||||
* today is not the only filter under which this test could pass.
|
||||
*/
|
||||
public static final String FIXTURE_MIME_TYPE = "video/mp4";
|
||||
|
||||
private static final String ROOT_ID = "lmc-r38-root";
|
||||
private static final String ROOT_DOCUMENT_ID = "root";
|
||||
private static final String FIXTURE_DOCUMENT_ID = "root/" + FIXTURE_DISPLAY_NAME;
|
||||
|
||||
/** Already in this source set, and already a real H.264 MP4 the engines can open. */
|
||||
private static final String FIXTURE_ASSET = "sample_h264.mp4";
|
||||
|
||||
private static final String[] DEFAULT_ROOT_PROJECTION = {
|
||||
Root.COLUMN_ROOT_ID,
|
||||
Root.COLUMN_DOCUMENT_ID,
|
||||
Root.COLUMN_TITLE,
|
||||
Root.COLUMN_SUMMARY,
|
||||
Root.COLUMN_MIME_TYPES,
|
||||
Root.COLUMN_FLAGS,
|
||||
Root.COLUMN_ICON,
|
||||
};
|
||||
|
||||
private static final String[] DEFAULT_DOCUMENT_PROJECTION = {
|
||||
Document.COLUMN_DOCUMENT_ID,
|
||||
Document.COLUMN_DISPLAY_NAME,
|
||||
Document.COLUMN_MIME_TYPE,
|
||||
Document.COLUMN_FLAGS,
|
||||
Document.COLUMN_SIZE,
|
||||
Document.COLUMN_LAST_MODIFIED,
|
||||
};
|
||||
|
||||
@Override
|
||||
public boolean onCreate() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single root.
|
||||
*
|
||||
* <p>{@link Root#COLUMN_MIME_TYPES} is the important column. Left null it would mean "this
|
||||
* root supports everything", the picker would list it whatever was asked for, and the MIME
|
||||
* mutation would have nothing to bite on.
|
||||
*/
|
||||
@Override
|
||||
public Cursor queryRoots(String[] projection) {
|
||||
MatrixCursor cursor = new MatrixCursor(projection != null ? projection : DEFAULT_ROOT_PROJECTION);
|
||||
cursor.newRow()
|
||||
.add(Root.COLUMN_ROOT_ID, ROOT_ID)
|
||||
.add(Root.COLUMN_DOCUMENT_ID, ROOT_DOCUMENT_ID)
|
||||
.add(Root.COLUMN_TITLE, ROOT_TITLE)
|
||||
.add(Root.COLUMN_SUMMARY, "Instrumentation fixture")
|
||||
.add(Root.COLUMN_MIME_TYPES, FIXTURE_MIME_TYPE)
|
||||
.add(Root.COLUMN_FLAGS, Root.FLAG_LOCAL_ONLY)
|
||||
.add(Root.COLUMN_ICON, android.R.drawable.ic_menu_gallery);
|
||||
return cursor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Cursor queryDocument(String documentId, String[] projection) throws FileNotFoundException {
|
||||
MatrixCursor cursor = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION);
|
||||
if (ROOT_DOCUMENT_ID.equals(documentId)) {
|
||||
addDirectoryRow(cursor);
|
||||
} else if (FIXTURE_DOCUMENT_ID.equals(documentId)) {
|
||||
addFixtureRow(cursor);
|
||||
} else {
|
||||
throw new FileNotFoundException("no such document: " + documentId);
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Cursor queryChildDocuments(String parentDocumentId, String[] projection, String sortOrder)
|
||||
throws FileNotFoundException {
|
||||
MatrixCursor cursor = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION);
|
||||
if (ROOT_DOCUMENT_ID.equals(parentDocumentId)) {
|
||||
addFixtureRow(cursor);
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ParcelFileDescriptor openDocument(String documentId, String mode, CancellationSignal signal)
|
||||
throws FileNotFoundException {
|
||||
if (!FIXTURE_DOCUMENT_ID.equals(documentId)) {
|
||||
throw new FileNotFoundException("no such document: " + documentId);
|
||||
}
|
||||
return ParcelFileDescriptor.open(fixtureFile(), ParcelFileDescriptor.MODE_READ_ONLY);
|
||||
}
|
||||
|
||||
private void addDirectoryRow(MatrixCursor cursor) {
|
||||
cursor.newRow()
|
||||
.add(Document.COLUMN_DOCUMENT_ID, ROOT_DOCUMENT_ID)
|
||||
.add(Document.COLUMN_DISPLAY_NAME, ROOT_TITLE)
|
||||
.add(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR)
|
||||
.add(Document.COLUMN_FLAGS, 0)
|
||||
.add(Document.COLUMN_SIZE, null);
|
||||
}
|
||||
|
||||
private void addFixtureRow(MatrixCursor cursor) throws FileNotFoundException {
|
||||
File file = fixtureFile();
|
||||
cursor.newRow()
|
||||
.add(Document.COLUMN_DOCUMENT_ID, FIXTURE_DOCUMENT_ID)
|
||||
.add(Document.COLUMN_DISPLAY_NAME, FIXTURE_DISPLAY_NAME)
|
||||
.add(Document.COLUMN_MIME_TYPE, FIXTURE_MIME_TYPE)
|
||||
.add(Document.COLUMN_FLAGS, 0)
|
||||
.add(Document.COLUMN_SIZE, file.length())
|
||||
.add(Document.COLUMN_LAST_MODIFIED, file.lastModified());
|
||||
}
|
||||
|
||||
/**
|
||||
* The fixture on disk, unpacked from this APK's own assets the first time anything asks.
|
||||
*
|
||||
* <p>On demand rather than seeded once in {@link #onCreate()}, because this process is started
|
||||
* by whoever queries the provider and can be killed between two queries of the same test.
|
||||
*
|
||||
* <p>A failure here is reported as {@link FileNotFoundException} rather than swallowed. A
|
||||
* provider that answers with a zero-byte file would put the test on the "Size unknown" screen
|
||||
* with nothing saying why.
|
||||
*/
|
||||
private File fixtureFile() throws FileNotFoundException {
|
||||
File file = new File(getContext().getFilesDir(), FIXTURE_DISPLAY_NAME);
|
||||
if (file.length() > 0L) {
|
||||
return file;
|
||||
}
|
||||
try (InputStream source = getContext().getAssets().open(FIXTURE_ASSET);
|
||||
OutputStream sink = new FileOutputStream(file)) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int read;
|
||||
while ((read = source.read(buffer)) != -1) {
|
||||
sink.write(buffer, 0, read);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new FileNotFoundException("could not unpack " + FIXTURE_ASSET + ": " + e);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,786 @@
|
||||
package org.libremediaconverter.saf
|
||||
|
||||
import android.app.UiAutomation
|
||||
import androidx.compose.ui.test.ComposeTimeoutException
|
||||
import androidx.compose.ui.test.assertTextEquals
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.uiautomator.By
|
||||
import androidx.test.uiautomator.BySelector
|
||||
import androidx.test.uiautomator.Configurator
|
||||
import androidx.test.uiautomator.StaleObjectException
|
||||
import androidx.test.uiautomator.UiDevice
|
||||
import androidx.test.uiautomator.Until
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.FailsOnEmulatorApi37
|
||||
import org.libremediaconverter.MainActivity
|
||||
import org.libremediaconverter.ui.TestTags
|
||||
|
||||
/**
|
||||
* Choosing a file, through the real system picker, and still having it after a rotation.
|
||||
*
|
||||
* Two defects, and neither is reachable from anywhere else in this repo.
|
||||
*
|
||||
* **The picker is opened with a filter, and a filter can hide the user's file.** `ConverterScreen`
|
||||
* launches `ActivityResultContracts.OpenDocument` with a MIME array; DocumentsUI hides every root
|
||||
* and every document that array does not match. Narrow it and the app still compiles, still
|
||||
* renders, still passes every JVM test — and the user taps "Choose file" and is shown an empty
|
||||
* picker. Nothing in either source set drove SAF **as a picker** before this: the only SAF coverage
|
||||
* is the publish side, in `OutputPublisherPublishTest`, against hand-written `ContentProvider`
|
||||
* fakes. The launcher wiring, the filter, and the read grant that comes back had never been
|
||||
* executed by a test.
|
||||
*
|
||||
* **The picked file has to survive a rotation.** `MainActivity` declares no `configChanges`, so
|
||||
* every rotation destroys and recreates it, and `ConversionViewModel` holds the picked file in a
|
||||
* plain `MutableStateFlow` with no `SavedStateHandle` behind it. The only thing that carries it
|
||||
* across is the retained `ViewModelStore` the Activity gets from resolving the ViewModel through
|
||||
* `LocalViewModelStoreOwner`. Scope it to the composition instead and the file is gone.
|
||||
*
|
||||
* ### Why these two are one test class
|
||||
*
|
||||
* A rotation test alone has no bite of its own. `AppRootRestorationTest` already catches
|
||||
* `rememberSaveable` -> `remember` on the JVM, and a second test whose only mutation is one an
|
||||
* existing test catches is the vacuous test this whole decomposition exists to prevent. So the
|
||||
* rotation here runs **from a real picked input**, which is a state no JVM test can produce:
|
||||
* `AppRootRestorationTest` injects a stub `content` lambda specifically to avoid standing up
|
||||
* either ViewModel, and `StateRestorationTester` saves into an in-memory map rather than a
|
||||
* `Bundle`.
|
||||
*
|
||||
* ### #93: what actually failed was reading the screen, not the picker
|
||||
*
|
||||
* Ninety minutes after this class landed it started failing on gating legs at API 33, 34, 35 and
|
||||
* 37 — on diffs that were two KDoc comments, a MIME lookup table and a README paragraph (#93).
|
||||
* Every failure named the fixture root, so it read as a root-discovery race, and the ticket was
|
||||
* filed on that reading. It was not one, and it was not the `StaleObjectException` #80 had fixed
|
||||
* an hour earlier either.
|
||||
*
|
||||
* **DocumentsUI was fine.** On the API 34 leg of run 32806342548 its own
|
||||
* `ProvidersAccess: Matched roots` names
|
||||
* `content://org.libremediaconverter.test.fixtures/root/lmc-r38-root` five times inside the sixty
|
||||
* seconds the test spent failing, `ActivityTaskManager` logged the `PickActivity` as `Displayed`,
|
||||
* and the provider process started on cue.
|
||||
*
|
||||
* **This process could not read any window at all.** Two counts settle it. Across that whole leg
|
||||
* UiAutomator logged `Retrieving node with selector` 1095 times and `Node not found with selector`
|
||||
* 1095 times — not one selector ever matched, from the first query of the run. The green leg of
|
||||
* the same job asked 7 times and found 5. `UiDevice.getWindowRoots` builds its search set from
|
||||
* `UiAutomation.getWindows()` and, on API 21 and up, from nothing else; an empty list there makes
|
||||
* every selector unfindable and says nothing whatever about SAF. The corroborating detail is that
|
||||
* `By.desc("Show roots")` — the toolbar button, present on that screen whether the roots list is
|
||||
* stale or not — was also not found, 28 s after the picker was displayed.
|
||||
*
|
||||
* **A fresh picker is not the repair, and this was measured rather than assumed.** The same leg
|
||||
* opened a *second* `PickActivity` for the second test, in the same DocumentsUI process
|
||||
* (pid 3299), and read exactly as little from it. So whatever was broken outlived one window.
|
||||
* [requireAReadableScreen] is the part aimed at that: it asks whether this process can see the
|
||||
* app's own window *before* the picker is opened, and [rebuildUiAutomation] tears the connection
|
||||
* down and builds another if it cannot.
|
||||
*
|
||||
* **The check has since caught the real thing, in CI, and the connection rebuild did not repair
|
||||
* it.** Run 32811493607, API 35 and API 37 legs, both tests, 12 s each instead of 60:
|
||||
*
|
||||
* ```
|
||||
* java.lang.AssertionError: UiAutomator cannot see this app's own window, so it could not have
|
||||
* seen the picker's either. This is not a SAF failure.
|
||||
* at SafPickerRoundTripTest.requireAReadableScreen
|
||||
* ```
|
||||
*
|
||||
* That is the diagnosis this class could not previously give, and it moves the question off SAF
|
||||
* for good.
|
||||
*
|
||||
* ### What the window list said, and why nothing here can fix it
|
||||
*
|
||||
* [describeWindows] was added to that failure so the next occurrence would close the question
|
||||
* rather than reopen it. It did — on the API 34 leg of run 32812248131 and again, character for
|
||||
* character, on the API 33 leg of run 32812892103:
|
||||
*
|
||||
* ```
|
||||
* ... Waking the device, dismissing the keyguard and rebuilding the UiAutomation connection all
|
||||
* failed to make it readable. What it could see: com.android.systemui[type=3], android[type=3]
|
||||
* ```
|
||||
*
|
||||
* `type=3` is `AccessibilityWindowInfo.TYPE_SYSTEM`. The list is **not** empty — it holds the
|
||||
* system windows and **not one `TYPE_APPLICATION` window**, on a device where the framework had
|
||||
* already logged `Displayed org.libremediaconverter/.MainActivity`. So the application layer
|
||||
* never reaches accessibility on those boots, and every selector in this class, the picker's and
|
||||
* the app's alike, is unfindable for the whole instrumentation run.
|
||||
*
|
||||
* Three CI runs on this branch caught the fault, at API 33, 34, 35 and 37, and every one of them
|
||||
* printed that same list. It is not one level's quirk.
|
||||
*
|
||||
* ### And that list is what identified the occluder
|
||||
*
|
||||
* `android[type=3]` is `system_server`, and what it was holding is in the same logcat, minutes
|
||||
* before this class ever ran:
|
||||
*
|
||||
* ```
|
||||
* ANR in com.google.android.apps.nexuslauncher (com.google.android.apps.nexuslauncher/.NexusLauncherActivity)
|
||||
* Reason: Input dispatching timed out (Application does not have a focused window)
|
||||
* Window{4ed8414 u0 Application Not Responding: com.google.android.apps.nexuslauncher}
|
||||
* ```
|
||||
*
|
||||
* **The launcher ANRs on a loaded runner emulator, and the dialog it leaves behind never goes
|
||||
* away.** It is opaque and fullscreen, so `AccessibilityWindowManager` drops every application
|
||||
* window beneath it — which is how the app can be `Displayed` and unreadable at once, the
|
||||
* contradiction that made #93 look like a SAF bug for six PRs. It is present on both legs
|
||||
* examined, at API 33 and 34, at the failure timestamp.
|
||||
*
|
||||
* So [dismissASystemErrorDialog] is tried first, and it is the remedy with a mechanism behind it.
|
||||
* The other two are kept behind it and are **measured as not the cause**: [unlockTheDevice] (the
|
||||
* keyguard theory, from `KeyguardViewMediator` reporting an unprovisioned device — dismissing it
|
||||
* changed nothing) and [rebuildUiAutomation]. A second `PickActivity` is not a remedy for this
|
||||
* either, and that was measured too: the first failing leg opened one and read as little from it.
|
||||
*
|
||||
* **What is honest about the dialog remedy: it has been shown to do no harm, not to work.** It
|
||||
* was forced on with no dialog present and the suite stayed green, which is the way a blind
|
||||
* `click()` could have broken a healthy run. Dismissing a real ANR dialog has not been observed,
|
||||
* because the fault has never been reproduced locally — not on six warm runs, not on cold
|
||||
* full-suite runs at API 34 and 35 on freshly created AVDs under `swangle_indirect` at two cores,
|
||||
* not under host load. If it recurs, the message now names the dialog and the window list, so the
|
||||
* next step is a measurement rather than another theory.
|
||||
*
|
||||
* ### The whole pick is retried, which is a separate and smaller claim
|
||||
*
|
||||
* [pickTheFixture] also backs out and asks for another picker when the walk comes up short. That
|
||||
* is not the answer to the paragraph above; it is the answer to a picker whose *lists* were built
|
||||
* before their data arrived, which is a real thing DocumentsUI does and which
|
||||
* [tapPickerNode]'s re-find cannot reach either — it re-acquires a handle inside the one picker.
|
||||
*
|
||||
* One API 37 run failed a step deeper than the rest: the root appeared and
|
||||
* `[TEXT='\Qlmc-r38-fixture.mp4\E']` did not. **That shape has not been reproduced or
|
||||
* diagnosed.** It is covered here only because a fresh pick re-walks from Recent, and that is
|
||||
* worth writing down rather than letting the retry read as a fix for something nobody measured.
|
||||
*
|
||||
* ### The mutations, and what they printed
|
||||
*
|
||||
* Both were run, not asserted. Narrowing the wildcard array `ConverterScreen.kt` passes to
|
||||
* `pickInput.launch` — to `arrayOf("application/x-lmc-no-such-type")` — empties the picker of the
|
||||
* fixture root entirely, and both tests fail on the assertion that names it. **Re-run after the
|
||||
* #93 retry landed**, because a retry that tolerated an absent root would have made this mutation
|
||||
* vacuous, which is the one thing that must not happen here:
|
||||
*
|
||||
* ```
|
||||
* java.lang.AssertionError: the system picker never showed BySelector [TEXT='\QLMC R38 fixtures\E'],
|
||||
* in 3 separate pickers (the last one left org.libremediaconverter in front)
|
||||
* at org.libremediaconverter.saf.SafPickerRoundTripTest.pickTheFixture(SafPickerRoundTripTest.kt:268)
|
||||
* ```
|
||||
*
|
||||
* The root is absent from all three pickers, so all three report it, and the cost of saying so is
|
||||
* bounded: 126 s and 127 s for the two tests, against the 1200 s wrapper timeout in
|
||||
* `.github/scripts/e2e-run.sh`. The clause about what was left in front is not decoration either
|
||||
* — it is what says the retry really did get back to the app between attempts rather than tapping
|
||||
* behind a picker that never closed.
|
||||
*
|
||||
* **That mutation only shows the retry failing correctly.** Showing it *recovering* needs a
|
||||
* failure that goes away, so one was injected: a field making the first
|
||||
* [walkThePickerToTheFixture] of each test return a selector nothing matches. Both tests then
|
||||
* passed, with `ActivityTaskManager` logging four `OPEN_DOCUMENT` starts for the two of them —
|
||||
* two pickers each. That is the run which says the reopened pick completes: that
|
||||
* `pickInput.launch` is not refused from the re-resumed Activity, and that the second test's
|
||||
* reopen, which lands in the last-accessed stack rather than on Recent, still walks to the file.
|
||||
* Making the ViewModel composition-scoped leaves the picker test alone and fails
|
||||
* [thePickedInputSurvivesARealRotation], with `:app:testDebugUnitTest` still BUILD SUCCESSFUL —
|
||||
* which is the divergence this ticket was filed to establish, and which was doubted on it. It is
|
||||
* `viewModel()` -> `viewModel(viewModelStoreOwner = remember { <a plain ViewModelStoreOwner> })`,
|
||||
* **plus** `factory = ViewModelProvider.AndroidViewModelFactory()` and a `MutableCreationExtras`
|
||||
* carrying `APPLICATION_KEY`. The factory half is not decoration: an owner that is not a
|
||||
* `HasDefaultViewModelProviderFactory` contributes no creation extras, and the default factory
|
||||
* cannot construct an `AndroidViewModel` without them — so the owner swap alone crashes on
|
||||
* construction instead of demonstrating the scope. The PR body quotes both failures verbatim.
|
||||
*
|
||||
* ### It has to be an unlocked emulator
|
||||
*
|
||||
* The Pixel 10 Pro XL is secure-locked and cannot be unlocked from a shell, so the picker cannot be
|
||||
* driven there at all. That is why this gap survived as long as it did.
|
||||
* `tools/local-emulator/run-e2e.sh` runs API 33-36 on the development host, and both tests pass
|
||||
* there: **59 / 0 / 0 / 2 at API 33 and again at API 36**, whole suite, 2026-08-24.
|
||||
*
|
||||
* ### Why only the rotation test carries [FailsOnEmulatorApi37]
|
||||
*
|
||||
* This class is the first thing in the suite that touches system UI, and the android-37.x images
|
||||
* are where that stops being free: surfaceflinger aborts inside the guest's Gralloc5 mapper, init
|
||||
* SIGKILLs zygote with it, and the framework restarts underneath the run. Disabling SystemUI --
|
||||
* the deviation the API 37 leg already makes -- removes the *idle* trigger, not this one.
|
||||
*
|
||||
* The marker is on one method and not on the class, because that is what was measured, one method
|
||||
* per fresh emulator, on `android-37.0` under `swangle_indirect`:
|
||||
*
|
||||
* ```
|
||||
* thePickedInputSurvivesARealRotation INSTRUMENTATION_ABORTED: System has crashed.
|
||||
* Expected 1 tests, received 0
|
||||
* pickingAFileThroughTheSystemPickerFillsInTheFileCard PASSED
|
||||
* ```
|
||||
*
|
||||
* A rotation rebuilds every surface on screen at once, which the mapper does not survive; merely
|
||||
* starting DocumentsUI does not.
|
||||
*
|
||||
* **The first version of this said the class, and it was wrong.** The picker test had failed at
|
||||
* API 37 too -- with a `StaleObjectException` that turned out to be this file's own bug rather
|
||||
* than the image's, and which CI then reproduced deterministically at API 33, 34 and 35. Fixing
|
||||
* it ([tapPickerNode]) and re-measuring is what separated the two. An annotation is a claim about
|
||||
* an image, and a broken test makes every image look broken; **re-measure after fixing a test
|
||||
* before deciding what the platform did.**
|
||||
*
|
||||
* The annotation says only that, and CI reads it twice, so the rotation test runs on the advisory
|
||||
* API 37 leg and not the gating one. **Do not read it as "a rotation is allowed to lose the
|
||||
* file".** That is what API 33 through 36 are for, and they answer it.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SafPickerRoundTripTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<MainActivity>()
|
||||
|
||||
private val device: UiDevice =
|
||||
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
|
||||
|
||||
/** The app under test, whose own window is what [requireAReadableScreen] asks for. */
|
||||
private val appPackage: String =
|
||||
InstrumentationRegistry.getInstrumentation().targetContext.packageName
|
||||
|
||||
/** Set by the one test that rotates, read by [restoreOrientation]. See its KDoc. */
|
||||
private var rotated = false
|
||||
|
||||
/**
|
||||
* Leave the device the way it was found — and only if this test moved it.
|
||||
*
|
||||
* Two things are deliberate here, and both are about the *other* tests on the device rather
|
||||
* than about these two.
|
||||
*
|
||||
* The flag, because this runs after every test in the class, not only the one that rotated. An
|
||||
* unconditional restore issues a WindowManager rotation request after the picker test as well,
|
||||
* which has nothing to undo; JUnit does not promise method order, so that is an interaction
|
||||
* between two tests that no single-class run would ever show. Tracked as a flag rather than
|
||||
* read back off `isNaturalOrientation`, because a device whose *natural* orientation is
|
||||
* landscape would answer that question the wrong way round.
|
||||
*
|
||||
* And `unfreezeRotation`, because `setOrientationNatural` does not merely rotate: it freezes
|
||||
* the rotation there. A run that stopped after it would hand the next test a device that
|
||||
* cannot rotate at all.
|
||||
*/
|
||||
@After
|
||||
fun restoreOrientation() {
|
||||
if (!rotated) return
|
||||
device.setOrientationNatural()
|
||||
device.unfreezeRotation()
|
||||
device.waitForIdle()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pickingAFileThroughTheSystemPickerFillsInTheFileCard() {
|
||||
pickTheFixture()
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD_NAME)
|
||||
.assertTextEquals(FixtureDocumentsProvider.FIXTURE_DISPLAY_NAME)
|
||||
|
||||
// Not the same assertion twice. The name above comes from a metadata query, which a URI
|
||||
// with no read grant answers just as well; this line only appears once something has
|
||||
// opened the file and read its header. It is what says the picker handed back a URI the
|
||||
// app can actually USE -- delete grantUriPermissions from the fixture's manifest entry and
|
||||
// the name still arrives while this goes red.
|
||||
//
|
||||
// The whole "Container: MP4" and not "MP4": DetailRow renders the label and the value as
|
||||
// one semantics node.
|
||||
awaitNode(TestTags.Converter.detailRow(CONTAINER_LABEL))
|
||||
composeRule.onNodeWithTag(TestTags.Converter.detailRow(CONTAINER_LABEL))
|
||||
.assertTextEquals("$CONTAINER_LABEL: MP4")
|
||||
}
|
||||
|
||||
@Test
|
||||
@FailsOnEmulatorApi37
|
||||
fun thePickedInputSurvivesARealRotation() {
|
||||
pickTheFixture()
|
||||
// The identity hash rather than the Activity itself, so nothing here keeps a destroyed
|
||||
// Activity reachable across the recreation it is being used to detect.
|
||||
val before = System.identityHashCode(composeRule.activity)
|
||||
|
||||
device.setOrientationLandscape()
|
||||
rotated = true
|
||||
composeRule.waitForIdle()
|
||||
|
||||
// Two guards before the assertion that matters, because both of the ways this test could
|
||||
// pass while proving nothing are silent ones.
|
||||
//
|
||||
// A device that ignored the rotation request would leave the app exactly as it was, and
|
||||
// "the file is still there" would then be a statement about a screen nothing happened to.
|
||||
assertNotEquals(
|
||||
"the device did not actually rotate, so nothing below is about a rotation",
|
||||
NATURAL_ROTATION,
|
||||
device.displayRotation,
|
||||
)
|
||||
// And a rotation that did NOT recreate the Activity -- a configChanges attribute added to
|
||||
// the manifest, an aspect-ratio or orientation lock -- would make this a recomposition
|
||||
// test. The retained ViewModelStore is only interesting because the Activity around it
|
||||
// really was destroyed and rebuilt.
|
||||
assertNotEquals(
|
||||
"the rotation did not recreate MainActivity, so the retained ViewModelStore was never used",
|
||||
before,
|
||||
System.identityHashCode(composeRule.activity),
|
||||
)
|
||||
|
||||
awaitNode(TestTags.Converter.FILE_CARD_NAME)
|
||||
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD_NAME)
|
||||
.assertTextEquals(FixtureDocumentsProvider.FIXTURE_DISPLAY_NAME)
|
||||
}
|
||||
|
||||
// --- driving the picker ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Taps "Choose file", walks the system picker to the fixture, and returns once the app has it.
|
||||
*
|
||||
* Everything between the first tap and the last belongs to `com.google.android.documentsui`,
|
||||
* which is why UiAutomator is here at all: Compose's matchers stop at this process's
|
||||
* composition and Espresso's at its view hierarchy, and the picker is neither.
|
||||
*
|
||||
* **What is retried here is the whole pick.** [tapPickerNode]'s re-find re-acquires a handle
|
||||
* to a node inside the picker that is already open, so it cannot reach a list that was built
|
||||
* before its data arrived. Backing out and tapping "Choose file" again gets a *second*
|
||||
* `PickActivity`, which rebuilds every list in it — and is what a user does when a picker
|
||||
* comes up wrong. It is **not** the answer to the unreadable-screen failure in the class
|
||||
* KDoc; [requireAReadableScreen], one line above, is the part aimed at that.
|
||||
*
|
||||
* The first attempt keeps the full [PICKER_TIMEOUT_MS]; the later ones use
|
||||
* [REOPENED_TIMEOUT_MS], because by then the picker's process, its provider and its root cache
|
||||
* are all warm and the only thing being waited on is one screen. That is what keeps the cost
|
||||
* of a genuinely absent root bounded — see the class KDoc.
|
||||
*/
|
||||
private fun pickTheFixture() {
|
||||
var missing: BySelector? = null
|
||||
repeat(PICK_ATTEMPTS) { attempt ->
|
||||
requireAReadableScreen()
|
||||
openThePicker()
|
||||
missing = walkThePickerToTheFixture(
|
||||
if (attempt == 0) PICKER_TIMEOUT_MS else REOPENED_TIMEOUT_MS,
|
||||
)
|
||||
if (missing == null) {
|
||||
awaitNode(TestTags.Converter.FILE_CARD_NAME)
|
||||
return
|
||||
}
|
||||
dismissThePicker()
|
||||
}
|
||||
throw AssertionError(
|
||||
"the system picker never showed $missing, in $PICK_ATTEMPTS separate pickers " +
|
||||
"(the last one left ${device.currentPackageName} in front)",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses to go near the picker until this process can read a window it already knows is there.
|
||||
*
|
||||
* **This is the check that would have answered #93 outright**, instead of leaving six PRs to
|
||||
* infer a SAF fault from a picker that was never the problem. It is here because of what the
|
||||
* failing logcat counts. Across the whole API 34 leg UiAutomator
|
||||
* asked for a node 1095 times and logged `Node not found` 1095 times — it never read anything,
|
||||
* from the first query of the run onwards. The green leg of the same job asked 7 times and
|
||||
* found 5. So the window list `UiDevice` searches, `UiAutomation.getWindows()`, was empty for
|
||||
* that entire instrumentation run; on API 21 and up that list is the *only* place
|
||||
* `getWindowRoots` looks, so an empty one makes every selector unfindable and says nothing
|
||||
* about the app, the picker or the fixture.
|
||||
*
|
||||
* The probe is deliberately the app's **own** window, asked while the app is in front and
|
||||
* before anything is tapped. It is the one window that must be readable for any of the rest to
|
||||
* mean anything, so a failure here is unambiguous — where "the picker never showed the root"
|
||||
* was not, and is what sent #93 looking at package installation and root caches.
|
||||
*
|
||||
* The repair is [rebuildUiAutomation]. It has been forced on and measured — a rebuilt
|
||||
* connection still reads windows, which is the way it could have been worse than nothing —
|
||||
* but it has **never been run against the real fault**, because the fault has never been
|
||||
* reproduced on demand. See the class KDoc. What is certain is that a fresh picker is *not*
|
||||
* the repair: the failing leg opened a second `PickActivity` for the second test, in the
|
||||
* same DocumentsUI process, and read exactly as little from it.
|
||||
*/
|
||||
private fun requireAReadableScreen() {
|
||||
val app = By.pkg(appPackage)
|
||||
if (device.wait(Until.hasObject(app), READABLE_TIMEOUT_MS) == true) return
|
||||
dismissASystemErrorDialog()
|
||||
if (device.wait(Until.hasObject(app), READABLE_TIMEOUT_MS) == true) return
|
||||
unlockTheDevice()
|
||||
if (device.wait(Until.hasObject(app), READABLE_TIMEOUT_MS) == true) return
|
||||
rebuildUiAutomation()
|
||||
if (device.wait(Until.hasObject(app), READABLE_TIMEOUT_MS) != true) {
|
||||
throw AssertionError(
|
||||
"UiAutomator cannot see this app's own window, so it could not have seen the " +
|
||||
"picker's either. This is not a SAF failure. Closing a system error dialog, " +
|
||||
"waking the device, dismissing the keyguard and rebuilding the UiAutomation " +
|
||||
"connection all failed to make it readable. What it could see: " +
|
||||
describeWindows(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes a system "isn't responding" dialog, if that is what is on top of the app.
|
||||
*
|
||||
* **This is the occluder #93 turned out to have**, and it took the window list in the failure
|
||||
* message to find it. `AppNotRespondingDialog` belongs to `system_server`, so it is the
|
||||
* `android[type=3]` in `com.android.systemui[type=3], android[type=3]` — and it is opaque and
|
||||
* fullscreen, so `AccessibilityWindowManager` drops every application window beneath it. The
|
||||
* app is `Displayed` and unreadable at the same time, which is exactly the contradiction this
|
||||
* class spent #93 failing to explain. It is not even this app's dialog:
|
||||
*
|
||||
* ```
|
||||
* ANR in com.google.android.apps.nexuslauncher (com.google.android.apps.nexuslauncher/.NexusLauncherActivity)
|
||||
* Reason: Input dispatching timed out (Application does not have a focused window)
|
||||
* Window{4ed8414 u0 Application Not Responding: com.google.android.apps.nexuslauncher}
|
||||
* ```
|
||||
*
|
||||
* The launcher ANRs on a loaded runner emulator minutes before this class runs, and the dialog
|
||||
* it leaves behind never goes away on its own.
|
||||
*
|
||||
* Dismissed by resource id rather than by button text, because the text is localised and the
|
||||
* ids are not, and by id rather than by "the first button in the system window", because that
|
||||
* would click whatever system window happened to be there. `aerr_wait` first: it dismisses the
|
||||
* dialog and leaves the offending app alone, which is the polite answer when the app is not
|
||||
* ours. Back is not tried — `BaseErrorDialog` swallows key events.
|
||||
*/
|
||||
private fun dismissASystemErrorDialog() {
|
||||
for (id in ERROR_DIALOG_BUTTONS) {
|
||||
val button = device.findObject(By.res(id)) ?: continue
|
||||
button.click()
|
||||
device.waitForIdle()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wakes the display and asks the keyguard to go away.
|
||||
*
|
||||
* The cheapest explanation for "this process cannot see the app's own window" is that
|
||||
* something is in front of it, and on a runner emulator that something is the lock screen:
|
||||
* these images come up unprovisioned, and `KeyguardViewMediator` says so in as many words --
|
||||
* `we need to show the keyguard since the device isn't provisioned yet`. An occluded window is
|
||||
* not in the accessibility window list, which is the same symptom as a broken connection and
|
||||
* has a far more ordinary cause.
|
||||
*
|
||||
* `wm dismiss-keyguard` rather than a swipe, because it is a request to the window manager
|
||||
* rather than a gesture that has to land somewhere this process cannot see. It is only
|
||||
* attempted on the failure path -- a device that was readable never reaches here -- so a run
|
||||
* where the keyguard was never up pays nothing and is not altered.
|
||||
*/
|
||||
private fun unlockTheDevice() {
|
||||
device.wakeUp()
|
||||
device.executeShellCommand("wm dismiss-keyguard")
|
||||
device.waitForIdle()
|
||||
}
|
||||
|
||||
/** The accessibility window list, for a failure message that says what was actually there. */
|
||||
private fun describeWindows(): String {
|
||||
val windows = InstrumentationRegistry.getInstrumentation().uiAutomation.windows
|
||||
if (windows.isEmpty()) return "no windows at all (UiAutomation.getWindows() is empty)"
|
||||
return windows.joinToString(", ") { "${it.root?.packageName ?: "?"}[type=${it.type}]" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Tears down this run's `UiAutomation` connection and establishes a new one.
|
||||
*
|
||||
* `Instrumentation.getUiAutomation` hands back the existing connection unless the flags differ
|
||||
* from the ones it was created with, in which case it destroys it and builds another — so
|
||||
* asking for different flags and then for the original ones back is how a test reaches the
|
||||
* connection at all. `UiDevice` re-reads the flags from `Configurator` on every call rather
|
||||
* than caching an instance, so the next selector goes through the new connection.
|
||||
*
|
||||
* `FLAG_DONT_SUPPRESS_ACCESSIBILITY_SERVICES` is toggled rather than chosen: it is only being
|
||||
* used as a value that differs from whatever is configured, and it is put back.
|
||||
*
|
||||
* **Forced on and measured, because the obvious way for this to be worse than nothing is
|
||||
* silent.** `UiDevice` puts `FLAG_RETRIEVE_INTERACTIVE_WINDOWS` on the service info during its
|
||||
* own initialisation, and `getWindows()` is empty without it — so a rebuilt connection that
|
||||
* did not get the flag back would cause exactly the emptiness this is meant to cure, on the
|
||||
* one path where it is the last hope. Run unconditionally on every attempt, on a cold API 34
|
||||
* emulator, both tests passed, and logcat shows the connection really being replaced rather
|
||||
* than handed back: `Init UiAutomation[id=2, flags=0]`, then `id=4, flags=1`, then
|
||||
* `id=6, flags=0`, with `Registering UiTestAutomationService` between each.
|
||||
*/
|
||||
private fun rebuildUiAutomation() {
|
||||
val configurator = Configurator.getInstance()
|
||||
val flags = configurator.uiAutomationFlags
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
configurator.uiAutomationFlags = flags xor UiAutomation.FLAG_DONT_SUPPRESS_ACCESSIBILITY_SERVICES
|
||||
instrumentation.getUiAutomation(configurator.uiAutomationFlags)
|
||||
configurator.uiAutomationFlags = flags
|
||||
instrumentation.getUiAutomation(flags)
|
||||
}
|
||||
|
||||
/** Waits for the app to be showing its own screen again, then asks for a picker. */
|
||||
private fun openThePicker() {
|
||||
awaitNode(TestTags.Converter.CHOOSE_FILE)
|
||||
composeRule.onNodeWithTag(TestTags.Converter.CHOOSE_FILE).performClick()
|
||||
}
|
||||
|
||||
/**
|
||||
* Null once the fixture URI is with the app, or the selector whose list never carried it.
|
||||
*
|
||||
* Three things have to be there, in order, and the `when` names them in that order so that a
|
||||
* failure says which one was missing rather than "the picker did not work".
|
||||
*
|
||||
* **The first branch is what tells an unreadable picker from an absent root.** In #93 neither
|
||||
* the root *nor the toolbar's "Show roots" button* could be found for sixty seconds, and a
|
||||
* stale roots list would have left the toolbar findable. Both arrived as one message. Asking
|
||||
* for the picker's package on its own separates them: `never showed BySelector [PKG=...]`
|
||||
* means the picker was not readable, and the root selector means the root was not offered.
|
||||
*
|
||||
* The second is the line the MIME filter mutation fails on: DocumentsUI matches the requested
|
||||
* types against `Root.COLUMN_MIME_TYPES` and drops the roots that cannot answer, so a filter
|
||||
* the fixture root does not satisfy takes the root out of the picker altogether — along with
|
||||
* "Images", "Audio", "Videos" and "Documents", measured on API 34.
|
||||
*
|
||||
* **The third takes no recovery action of its own, and that is deliberate rather than an
|
||||
* oversight.** [openTheRootsDrawer] exists because a root has a *second* place it can be
|
||||
* shown; a document in a directory listing has no second place, so there is nothing an
|
||||
* in-picker action could do. Its recovery is the outer loop: a fresh picker re-walks from
|
||||
* Recent into the root, which rebuilds the directory listing as well as the roots strip.
|
||||
*/
|
||||
private fun walkThePickerToTheFixture(timeoutMs: Long): BySelector? {
|
||||
val picker = By.pkg(DOCUMENTS_UI_PACKAGE)
|
||||
val root = By.text(FixtureDocumentsProvider.ROOT_TITLE)
|
||||
val fixture = By.text(FixtureDocumentsProvider.FIXTURE_DISPLAY_NAME)
|
||||
return when {
|
||||
device.wait(Until.hasObject(picker), timeoutMs) != true -> picker
|
||||
!tapPickerNode(root, timeoutMs, ifAbsent = ::openTheRootsDrawer) -> root
|
||||
!tapPickerNode(fixture, timeoutMs) -> fixture
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The picker's own drawer, opened only when the root was not on the screen it landed on.
|
||||
*
|
||||
* **In practice it never runs, and #80 was right to say so.** A hierarchy dump taken on a
|
||||
* cold API 34 emulator while this test was passing has the fixture root on the landing
|
||||
* screen — `text="LMC R38 fixtures"` at `android:id/title`, under a `BROWSE FILES IN OTHER
|
||||
* APPS` header — with the drawer shut (`Show roots` present, `Hide roots` absent). So the
|
||||
* roots strip is the normal path and the drawer is a widening, kept because a device with a
|
||||
* populated Recent may push the strip off screen. Looking in a second place widens where the
|
||||
* root is searched for; it does not weaken what has to be found, which is still this root.
|
||||
*/
|
||||
private fun openTheRootsDrawer() {
|
||||
device.findObject(By.desc(SHOW_ROOTS_DESCRIPTION))?.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Backs out of the picker until the app has the window focus again.
|
||||
*
|
||||
* **The focus is asked of the Activity, not of UiAutomator, and that is not a stylistic
|
||||
* choice.** The failure this retry exists for is a picker window UiAutomator cannot see, so a
|
||||
* probe that went through the same accessibility window list would cheerfully report "the
|
||||
* picker is gone" about the window that is still in front — and the reopened pick would then
|
||||
* tap "Choose file" behind it. `Activity.hasWindowFocus` comes from the framework instead, and
|
||||
* answers about the app rather than about the picker.
|
||||
*
|
||||
* It is also why this counts backs rather than pressing a fixed number of them. One back is
|
||||
* enough from Recent and two are needed from inside the root, but a third from Recent would
|
||||
* finish `MainActivity` and take the rest of the test with it.
|
||||
*/
|
||||
private fun dismissThePicker() {
|
||||
repeat(BACK_PRESSES) {
|
||||
if (awaitAppFocus()) return
|
||||
// Before the back press, not instead of it: an app-error dialog swallows key events,
|
||||
// so a back aimed at the picker lands on the dialog and nothing moves. Measured --
|
||||
// API 34 of run 32813885120 exhausted all four presses with `android` in front, which
|
||||
// is that dialog, while the launcher it belonged to went on ANRing behind everything.
|
||||
dismissASystemErrorDialog()
|
||||
device.pressBack()
|
||||
}
|
||||
// The check after the last press, and not a spare one: `repeat` presses on its final
|
||||
// iteration too, so without this a dismissal that worked on the last press would still be
|
||||
// reported as a failure to close.
|
||||
if (!awaitAppFocus()) {
|
||||
throw AssertionError(
|
||||
"the system picker would not close: after $BACK_PRESSES back presses the app " +
|
||||
"still does not have the window focus, and ${device.currentPackageName} is " +
|
||||
"in front. What could be seen: " + describeWindows(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** True once [MainActivity] has the window focus, false if it does not take it in time. */
|
||||
private fun awaitAppFocus(): Boolean = try {
|
||||
composeRule.waitUntil("the app has the window focus back", FOCUS_TIMEOUT_MS) {
|
||||
composeRule.activity.hasWindowFocus()
|
||||
}
|
||||
true
|
||||
} catch (_: ComposeTimeoutException) {
|
||||
false
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the picker node [selector] names and taps it, re-finding it if it goes stale.
|
||||
*
|
||||
* **The re-finding is not padding, and this is not a retry of the assertion.** A `UiObject2`
|
||||
* holds an `AccessibilityNodeInfo` captured when it was found, and DocumentsUI is still
|
||||
* settling when the node first appears — its list rebinds, the roots strip lays out, a window
|
||||
* animates. If the node is replaced in that gap, `click()` throws `StaleObjectException`
|
||||
* against the handle rather than missing the target. Measured on a cold API 34 emulator:
|
||||
*
|
||||
* ```
|
||||
* androidx.test.uiautomator.StaleObjectException
|
||||
* at androidx.test.uiautomator.UiObject2.getAccessibilityNodeInfo(UiObject2.java:1042)
|
||||
* at androidx.test.uiautomator.UiObject2.click(UiObject2.java:526)
|
||||
* ```
|
||||
*
|
||||
* So what is retried is *acquiring a handle to a node that has to be there anyway*. **A node
|
||||
* that is simply not in this picker is reported rather than retried here** — it comes back as
|
||||
* `false`, and [pickTheFixture] answers it with a whole new picker, which is the only thing
|
||||
* that rebuilds a list or a window. The MIME mutation's bite is untouched either way: a root
|
||||
* that is not in the picker is not found on any attempt or in any picker, and the failure is
|
||||
* still "the system picker never showed" rather than a stale one.
|
||||
*/
|
||||
private fun tapPickerNode(selector: BySelector, timeoutMs: Long, ifAbsent: () -> Unit = {}): Boolean {
|
||||
var stale: StaleObjectException? = null
|
||||
repeat(TAP_ATTEMPTS) { attempt ->
|
||||
// ifAbsent only on the first attempt: it navigates, and re-navigating from a screen it
|
||||
// already reached would walk away from the node.
|
||||
val node = awaitPickerNode(selector, timeoutMs, if (attempt == 0) ifAbsent else ({}))
|
||||
?: return false
|
||||
device.waitForIdle()
|
||||
try {
|
||||
node.click()
|
||||
return true
|
||||
} catch (e: StaleObjectException) {
|
||||
stale = e
|
||||
}
|
||||
}
|
||||
throw AssertionError("$selector kept going stale between finding it and tapping it", stale)
|
||||
}
|
||||
|
||||
/**
|
||||
* The picker node [selector] names, or null if this picker never showed it.
|
||||
*
|
||||
* [ifAbsent] runs once, after the first wait comes up empty, and then the wait is repeated. A
|
||||
* null return from `findObject` is deliberately not an error there: it is the "already on the
|
||||
* right screen" case.
|
||||
*/
|
||||
private fun awaitPickerNode(selector: BySelector, timeoutMs: Long, ifAbsent: () -> Unit) =
|
||||
device.wait(Until.findObject(selector), timeoutMs)
|
||||
?: run {
|
||||
ifAbsent()
|
||||
device.wait(Until.findObject(selector), timeoutMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocks until [tag] is in the composition, so an assertion cannot race the picker's result.
|
||||
*
|
||||
* The described overload of `waitUntil`, not the bare one. A timeout is how both of this
|
||||
* class's mutations report themselves, and the bare overload's message is
|
||||
* `Condition still not satisfied after 30000 ms` — which names neither the node nor the test.
|
||||
* With the description it says which affordance never arrived, which is the whole finding.
|
||||
*/
|
||||
private fun awaitNode(tag: String) {
|
||||
composeRule.waitUntil("a node tagged $tag exists", APP_TIMEOUT_MS) {
|
||||
composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
/**
|
||||
* Generous on purpose. This waits on another app being started, and on FFprobe spawning a
|
||||
* native process over a `content://` URI; a timeout that merely usually passes is a flaky
|
||||
* gating leg on five API levels, which costs far more than the seconds it saves.
|
||||
*/
|
||||
const val PICKER_TIMEOUT_MS = 30_000L
|
||||
const val APP_TIMEOUT_MS = 30_000L
|
||||
|
||||
/**
|
||||
* The same wait once a picker has already come and gone, and shorter for a reason.
|
||||
*
|
||||
* What [PICKER_TIMEOUT_MS] is generous about is a cold start: DocumentsUI's process, the
|
||||
* fixture's provider process, the root cache. By the second attempt all three are warm and
|
||||
* the only thing left to wait on is one screen being laid out — measured at 2.7 to 3.4 s
|
||||
* from the picker starting, on cold CI emulators at API 33, 34 and 35. Ten seconds is
|
||||
* three times the worst of those, and it is what keeps a genuinely absent root — the MIME
|
||||
* mutation — from costing three full-length attempts.
|
||||
*/
|
||||
const val REOPENED_TIMEOUT_MS = 10_000L
|
||||
|
||||
/**
|
||||
* How long the app is given to take the window focus back after a back press.
|
||||
*
|
||||
* Short, because this is asked once per back press and the first one is always asked while
|
||||
* the picker is still in front, where it is *expected* to time out.
|
||||
*/
|
||||
const val FOCUS_TIMEOUT_MS = 3_000L
|
||||
|
||||
/**
|
||||
* How long this process is given to be able to read the screen at all.
|
||||
*
|
||||
* Short, and it is not waiting on anything being drawn: the app is already in front
|
||||
* when this is asked. It is waiting only on the accessibility window list existing,
|
||||
* which either does within a poll or two or -- as in #93 -- not at all.
|
||||
*/
|
||||
const val READABLE_TIMEOUT_MS = 5_000L
|
||||
|
||||
/** `Surface.ROTATION_0`, named rather than `0` so the comparison reads. */
|
||||
const val NATURAL_ROTATION = 0
|
||||
|
||||
/**
|
||||
* How many pickers the fixture may fail to appear in before that is the finding.
|
||||
*
|
||||
* Three. Each one is a fresh `PickActivity` -- a fresh window, a fresh accessibility
|
||||
* registration, a fresh roots query and a fresh directory load -- so this bounds the thing
|
||||
* #93 measured, which is a picker that came up unreadable *once*. A root that is genuinely
|
||||
* not offered is absent from all three, which is what keeps #64's MIME mutation red.
|
||||
*/
|
||||
const val PICK_ATTEMPTS = 3
|
||||
|
||||
/**
|
||||
* How many back presses may be spent getting out of a picker.
|
||||
*
|
||||
* One is enough from Recent, two from inside the fixture's own directory. Four leaves room
|
||||
* for a picker that has been navigated deeper than this test ever navigates it, and stops
|
||||
* well short of the count that would start finishing `MainActivity` instead.
|
||||
*/
|
||||
const val BACK_PRESSES = 4
|
||||
|
||||
/**
|
||||
* The package the system picker runs in.
|
||||
*
|
||||
* Named rather than resolved: `PackageManager.resolveActivity` is deprecated from API 33
|
||||
* and its replacement is a lint argument this test does not need to have. A wrong value
|
||||
* here cannot pass silently -- it is the first thing [walkThePickerToTheFixture] looks
|
||||
* for, so the failure would read `never showed BySelector [PKG='...']` on every device.
|
||||
* It is `com.google.android.documentsui` on every `google_apis` emulator image the CI
|
||||
* matrix uses and on the Pixel 10 Pro XL.
|
||||
*/
|
||||
const val DOCUMENTS_UI_PACKAGE = "com.google.android.documentsui"
|
||||
|
||||
/**
|
||||
* How many times a picker node may be re-found before its staleness is the finding.
|
||||
*
|
||||
* Three, not "until the timeout". Each attempt already waits up to [PICKER_TIMEOUT_MS] for
|
||||
* the node to exist, so this bounds only the settling window after it does; a node that is
|
||||
* still being replaced after three of those is telling you something about the device, and
|
||||
* a loop that hid it would be the flake rather than the fix.
|
||||
*/
|
||||
const val TAP_ATTEMPTS = 3
|
||||
|
||||
/**
|
||||
* The buttons on the framework's app-error dialogs, by resource id.
|
||||
*
|
||||
* `aerr_wait` is first because it dismisses the dialog without killing the app under it,
|
||||
* and the app under it is usually the launcher rather than anything this suite owns.
|
||||
* `button1` catches the plainer `BaseErrorDialog` shapes that have no `aerr_` ids.
|
||||
*/
|
||||
val ERROR_DIALOG_BUTTONS = listOf(
|
||||
"android:id/aerr_wait",
|
||||
"android:id/aerr_close",
|
||||
"android:id/button1",
|
||||
)
|
||||
|
||||
/** DocumentsUI's drawer button. It carries no text, only this description. */
|
||||
const val SHOW_ROOTS_DESCRIPTION = "Show roots"
|
||||
|
||||
/** The detail row `MediaProbe` fills in for anything it could open and identify. */
|
||||
const val CONTAINER_LABEL = "Container"
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,89 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { viewModel.convert() }
|
||||
|
||||
ConverterScreenContent(
|
||||
state = state,
|
||||
settings = settings,
|
||||
validation = validation,
|
||||
actions = ConverterActions(
|
||||
onPickInput = { pickInput.launch(arrayOf("*/*")) },
|
||||
onPreset = viewModel::setPreset,
|
||||
onContainer = viewModel::setContainer,
|
||||
onVideoCodec = viewModel::setVideoCodec,
|
||||
onAudioCodec = viewModel::setAudioCodec,
|
||||
onSuggestion = viewModel::applySuggestion,
|
||||
onQuality = viewModel::setQuality,
|
||||
onEnginePreference = viewModel::setEnginePreference,
|
||||
onConvert = { requestNotifications.launch(Manifest.permission.POST_NOTIFICATIONS) },
|
||||
onCancel = viewModel::cancel,
|
||||
onSave = { suggestedName -> chooseDestination.launch(suggestedName) },
|
||||
onReset = viewModel::reset,
|
||||
),
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything [ConverterScreenContent] can ask for, in one value.
|
||||
*
|
||||
* A holder rather than twelve parameters because detekt's `LongParameterList` sits at its default
|
||||
* threshold of six and `config/detekt/detekt.yml` does not relax it for `@Composable` the way it
|
||||
* relaxes `LongMethod` and `CyclomaticComplexMethod` -- `AdvancedPicker` already sits exactly on
|
||||
* that threshold. The rule exempts data classes, so the callbacks travel together.
|
||||
*
|
||||
* In production every one of these is a launcher or a `ConversionViewModel` call. Naming them here
|
||||
* instead of handing the content a ViewModel is the whole point of the seam: a test can render a
|
||||
* [ConversionState] no ViewModel can be driven into, since `Waiting` needs a denied foreground
|
||||
* start and `Converted` needs a worker run that has already succeeded.
|
||||
*/
|
||||
internal data class ConverterActions(
|
||||
/** Open the document picker. The `Idle` and `Ready` branches both offer it. */
|
||||
val onPickInput: () -> Unit,
|
||||
val onPreset: (OutputFormat) -> Unit,
|
||||
val onContainer: (Container) -> Unit,
|
||||
val onVideoCodec: (VideoCodec) -> Unit,
|
||||
val onAudioCodec: (AudioCodec) -> Unit,
|
||||
val onSuggestion: (OutputSpec) -> Unit,
|
||||
val onQuality: (QualityTier) -> Unit,
|
||||
val onEnginePreference: (EnginePreference) -> Unit,
|
||||
/**
|
||||
* Start the job. It asks for the notification permission first, which is why the screen never
|
||||
* calls `convert` directly -- the launcher's result callback does, whichever way it went.
|
||||
*/
|
||||
val onConvert: () -> Unit,
|
||||
val onCancel: () -> Unit,
|
||||
/**
|
||||
* Open the save dialog for the finished output.
|
||||
*
|
||||
* Takes the suggested name rather than reading it back off the state, because the name comes
|
||||
* from the job -- see `ConversionWorker.KEY_SUGGESTED_NAME` -- and the branch that renders the
|
||||
* button is the only place that has it.
|
||||
*/
|
||||
val onSave: (suggestedName: String) -> Unit,
|
||||
val onReset: () -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* The converter screen, with its state handed in.
|
||||
*
|
||||
* Split from [ConverterScreen] so that state has somewhere to come from other than a live
|
||||
* `ConversionViewModel`. Driving the screen through a real one needs a `WorkManager` and a media
|
||||
* probe in the constructor, and even then two of the six states are unreachable: `Waiting` follows
|
||||
* a denied foreground start and `Converted` follows a completed worker.
|
||||
*
|
||||
* `internal` rather than private, because `src/test` is a friend of `main` and this is what the
|
||||
* state tests compose. The leaves below stay exactly where they were -- this function is a move,
|
||||
* not a redesign, and the tests that already pin those leaves are what says so.
|
||||
*/
|
||||
@UnstableApi
|
||||
@Composable
|
||||
internal fun ConverterScreenContent(
|
||||
state: ConversionState,
|
||||
settings: ConversionSettings,
|
||||
validation: Validation,
|
||||
actions: ConverterActions,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
@@ -115,7 +198,7 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
)
|
||||
Button(
|
||||
onClick = { pickInput.launch(arrayOf("*/*")) },
|
||||
onClick = actions.onPickInput,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PrimaryButtonHeight)
|
||||
@@ -132,21 +215,19 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
|
||||
|
||||
is ConversionState.Ready -> {
|
||||
FileCard(s.input)
|
||||
FormatPicker(settings.matchingPreset, viewModel::setPreset)
|
||||
FormatPicker(settings.matchingPreset, actions.onPreset)
|
||||
AdvancedPicker(
|
||||
spec = settings.spec,
|
||||
validation = validation,
|
||||
onContainer = viewModel::setContainer,
|
||||
onVideoCodec = viewModel::setVideoCodec,
|
||||
onAudioCodec = viewModel::setAudioCodec,
|
||||
onSuggestion = viewModel::applySuggestion,
|
||||
onContainer = actions.onContainer,
|
||||
onVideoCodec = actions.onVideoCodec,
|
||||
onAudioCodec = actions.onAudioCodec,
|
||||
onSuggestion = actions.onSuggestion,
|
||||
)
|
||||
QualityPicker(settings.quality, viewModel::setQuality)
|
||||
EnginePicker(settings.enginePreference, viewModel::setEnginePreference)
|
||||
QualityPicker(settings.quality, actions.onQuality)
|
||||
EnginePicker(settings.enginePreference, actions.onEnginePreference)
|
||||
Button(
|
||||
onClick = {
|
||||
requestNotifications.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
},
|
||||
onClick = actions.onConvert,
|
||||
// The Advanced picker lets an impossible combination be selected on
|
||||
// purpose, so this is what stops it from being run.
|
||||
enabled = validation.isValid,
|
||||
@@ -156,7 +237,7 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
|
||||
.testTag(TestTags.Converter.CONVERT),
|
||||
) { Text("Convert") }
|
||||
OutlinedButton(
|
||||
onClick = { pickInput.launch(arrayOf("*/*")) },
|
||||
onClick = actions.onPickInput,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.Converter.CHOOSE_DIFFERENT_FILE),
|
||||
@@ -173,7 +254,7 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
|
||||
.testTag(TestTags.Converter.PROGRESS),
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = viewModel::cancel,
|
||||
onClick = actions.onCancel,
|
||||
modifier = Modifier.fillMaxWidth().testTag(TestTags.CANCEL),
|
||||
) { Text("Cancel") }
|
||||
}
|
||||
@@ -192,7 +273,7 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = viewModel::cancel,
|
||||
onClick = actions.onCancel,
|
||||
modifier = Modifier.fillMaxWidth().testTag(TestTags.CANCEL),
|
||||
) { Text("Cancel") }
|
||||
}
|
||||
@@ -208,17 +289,21 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
|
||||
// explains why a job was slow, makes the software fallback
|
||||
// visible, and is how the user learns a remux happened rather
|
||||
// than a re-encode.
|
||||
AssistChip(onClick = {}, label = { Text(s.routeReason) })
|
||||
AssistChip(
|
||||
onClick = {},
|
||||
label = { Text(s.routeReason) },
|
||||
modifier = Modifier.testTag(TestTags.Converter.ROUTE_REASON),
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = { chooseDestination.launch(s.suggestedName) },
|
||||
onClick = { actions.onSave(s.suggestedName) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PrimaryButtonHeight)
|
||||
.testTag(TestTags.SAVE_FILE),
|
||||
) { Text("Save file") }
|
||||
OutlinedButton(
|
||||
onClick = viewModel::reset,
|
||||
onClick = actions.onReset,
|
||||
modifier = Modifier.fillMaxWidth().testTag(TestTags.START_OVER),
|
||||
) { Text("Start over") }
|
||||
}
|
||||
@@ -226,7 +311,7 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
|
||||
is ConversionState.Saved -> {
|
||||
Text("Saved ${s.displayName}.", style = MaterialTheme.typography.bodyLarge)
|
||||
Button(
|
||||
onClick = viewModel::reset,
|
||||
onClick = actions.onReset,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PrimaryButtonHeight)
|
||||
@@ -241,7 +326,7 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Button(
|
||||
onClick = viewModel::reset,
|
||||
onClick = actions.onReset,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PrimaryButtonHeight)
|
||||
|
||||
@@ -53,6 +53,46 @@ fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewMod
|
||||
remember(destinationMime) { ActivityResultContracts.CreateDocument(destinationMime) },
|
||||
) { uri -> uri?.let(viewModel::save) }
|
||||
|
||||
JoinScreenContent(
|
||||
state = state,
|
||||
actions = JoinActions(
|
||||
onPickInputs = { pickInputs.launch(arrayOf("video/*")) },
|
||||
onJoin = viewModel::join,
|
||||
onCancel = viewModel::cancel,
|
||||
onSave = { suggestedName -> chooseDestination.launch(suggestedName) },
|
||||
onReset = viewModel::reset,
|
||||
),
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything [JoinScreenContent] can ask for, in one value.
|
||||
*
|
||||
* Five callbacks would fit under detekt's `LongParameterList` threshold, unlike the converter's
|
||||
* twelve. It is a holder anyway, so both screens present the same shape to the state tests and
|
||||
* neither one has to be reworked the first time a branch grows a button.
|
||||
*/
|
||||
internal data class JoinActions(
|
||||
/** Open the multi-document picker. The `Idle` and `Ready` branches both offer it. */
|
||||
val onPickInputs: () -> Unit,
|
||||
val onJoin: () -> Unit,
|
||||
val onCancel: () -> Unit,
|
||||
/** Open the save dialog. Takes the name the job chose -- see `ConcatWorker.KEY_SUGGESTED_NAME`. */
|
||||
val onSave: (suggestedName: String) -> Unit,
|
||||
val onReset: () -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* The join screen, with its state handed in.
|
||||
*
|
||||
* The same split as [org.libremediaconverter.convert.ConverterScreenContent], for the same reason:
|
||||
* `JoinState.Waiting` follows a denied foreground start and `JoinState.Joined` follows a completed
|
||||
* concatenation, so neither is reachable by driving a real `JoinViewModel`.
|
||||
*/
|
||||
@UnstableApi
|
||||
@Composable
|
||||
internal fun JoinScreenContent(state: JoinState, actions: JoinActions, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
@@ -81,7 +121,7 @@ fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewMod
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
)
|
||||
Button(
|
||||
onClick = { pickInputs.launch(arrayOf("video/*")) },
|
||||
onClick = actions.onPickInputs,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PrimaryButtonHeight)
|
||||
@@ -99,14 +139,14 @@ fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewMod
|
||||
is JoinState.Ready -> {
|
||||
s.inputs.forEach { FileRow(it) }
|
||||
Button(
|
||||
onClick = viewModel::join,
|
||||
onClick = actions.onJoin,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PrimaryButtonHeight)
|
||||
.testTag(TestTags.Join.JOIN),
|
||||
) { Text("Join ${s.inputs.size} files") }
|
||||
OutlinedButton(
|
||||
onClick = { pickInputs.launch(arrayOf("video/*")) },
|
||||
onClick = actions.onPickInputs,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.Join.CHOOSE_DIFFERENT_FILES),
|
||||
@@ -124,7 +164,7 @@ fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewMod
|
||||
.testTag(TestTags.Join.PROGRESS),
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = viewModel::cancel,
|
||||
onClick = actions.onCancel,
|
||||
modifier = Modifier.fillMaxWidth().testTag(TestTags.CANCEL),
|
||||
) { Text("Cancel") }
|
||||
}
|
||||
@@ -138,7 +178,7 @@ fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewMod
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = viewModel::cancel,
|
||||
onClick = actions.onCancel,
|
||||
modifier = Modifier.fillMaxWidth().testTag(TestTags.CANCEL),
|
||||
) { Text("Cancel") }
|
||||
}
|
||||
@@ -157,14 +197,14 @@ fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewMod
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Button(
|
||||
onClick = { chooseDestination.launch(s.suggestedName) },
|
||||
onClick = { actions.onSave(s.suggestedName) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PrimaryButtonHeight)
|
||||
.testTag(TestTags.SAVE_FILE),
|
||||
) { Text("Save file") }
|
||||
OutlinedButton(
|
||||
onClick = viewModel::reset,
|
||||
onClick = actions.onReset,
|
||||
modifier = Modifier.fillMaxWidth().testTag(TestTags.START_OVER),
|
||||
) { Text("Start over") }
|
||||
}
|
||||
@@ -172,7 +212,7 @@ fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewMod
|
||||
is JoinState.Saved -> {
|
||||
Text("Saved ${s.displayName}.", style = MaterialTheme.typography.bodyLarge)
|
||||
Button(
|
||||
onClick = viewModel::reset,
|
||||
onClick = actions.onReset,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PrimaryButtonHeight)
|
||||
@@ -187,7 +227,7 @@ fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewMod
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Button(
|
||||
onClick = viewModel::reset,
|
||||
onClick = actions.onReset,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PrimaryButtonHeight)
|
||||
|
||||
@@ -55,6 +55,15 @@ object TestTags {
|
||||
/** The determinate bar in `Converting`. It carries no text, so nothing else can find it. */
|
||||
const val PROGRESS: String = "converter.progress"
|
||||
|
||||
/**
|
||||
* The chip on `Converted` that says which engine ran the job and why.
|
||||
*
|
||||
* Conditional on `routeReason` being non-blank, and that condition is what the tag is for:
|
||||
* its text comes from the finished job, so a text matcher looking for it would have to
|
||||
* name a routing explanation the screen does not own.
|
||||
*/
|
||||
const val ROUTE_REASON: String = "converter.routeReason"
|
||||
|
||||
const val FILE_CARD: String = "converter.fileCard"
|
||||
const val FILE_CARD_NAME: String = "converter.fileCard.name"
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.createDrainedComposeRule
|
||||
import org.libremediaconverter.model.Validation
|
||||
import org.libremediaconverter.ui.TestTags
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The seam carries a `ConversionState` in and an action back out.
|
||||
*
|
||||
* The defect this bites on is the extraction having quietly stopped being an extraction: a
|
||||
* `ConverterScreenContent` that ignores the `state` it was handed, or renders the finished job's
|
||||
* affordances without wiring them to the callbacks the entry point supplies. Neither shows up at
|
||||
* compile time -- an unread parameter compiles, and a `Button` whose `onClick` does nothing is a
|
||||
* valid `Button` -- and neither is visible from the leaf tests, which compose `FileCard`,
|
||||
* `AdvancedPicker` and the pickers directly and never see a state at all.
|
||||
*
|
||||
* **Both assertions were unreachable before R38.5**, which is the point of the ticket rather than
|
||||
* a remark about it. `ConversionState.Converted` is produced only by a `ConversionWorker` run that
|
||||
* has already succeeded, so no test can drive a real `ConversionViewModel` into it: it would need
|
||||
* a `WorkManager`, a media probe, a staged output file and a completed job. Handing the state in
|
||||
* is the only way to ask what the screen does with it.
|
||||
*
|
||||
* Deliberately not the state matrix. Which affordances each of the six `ConversionState`s renders
|
||||
* is R38.6 (#62); this file asserts only that the injection point exists and works in both
|
||||
* directions, so the two PRs cannot collide over the same cases.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class ConverterScreenContentTest {
|
||||
|
||||
// Not `createComposeRule()` directly: see [org.libremediaconverter.drainEscapedCoroutineErrors].
|
||||
@get:Rule
|
||||
val composeRule = createDrainedComposeRule()
|
||||
|
||||
/** What the screen asked to save, in the order it asked. Empty until Save is tapped. */
|
||||
private val savedAs = mutableListOf<String>()
|
||||
|
||||
@Test
|
||||
fun `a converted job renders the save button`() {
|
||||
setContent(converted())
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.SAVE_FILE).assertExists()
|
||||
}
|
||||
|
||||
/**
|
||||
* The direction that did not exist before this change.
|
||||
*
|
||||
* Asserting the *name* rather than just that something was called: the suggested name comes
|
||||
* from the job -- `ConversionWorker.KEY_SUGGESTED_NAME` -- and is what the save dialog opens
|
||||
* with, so a Save button wired to the wrong branch's state would hand over the wrong one and
|
||||
* a bare "was called" check would stay green.
|
||||
*/
|
||||
@Test
|
||||
fun `tapping save hands back the name the finished job chose`() {
|
||||
setContent(converted())
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.SAVE_FILE).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("holiday.mp4"), savedAs)
|
||||
}
|
||||
|
||||
/**
|
||||
* `staged` names a file that does not exist, on purpose.
|
||||
*
|
||||
* The branch renders `formatBytes(s.staged.length())`, and `length()` answers `0L` for a
|
||||
* missing path rather than throwing, so the size line reads `0 B` and no temporary folder is
|
||||
* needed. `routeReason` stays blank, which is what keeps the routing chip out of the tree --
|
||||
* that chip is R38.6's case, not this file's.
|
||||
*/
|
||||
private fun converted() = ConversionState.Converted(
|
||||
input = InputFile(
|
||||
uri = Uri.parse("content://test/holiday.mkv"),
|
||||
displayName = "holiday.mkv",
|
||||
sizeBytes = 12_345_678L,
|
||||
),
|
||||
staged = File("no-such-staged-output.mp4"),
|
||||
suggestedName = "holiday.mp4",
|
||||
mimeType = "video/mp4",
|
||||
)
|
||||
|
||||
private fun setContent(state: ConversionState) {
|
||||
composeRule.setContent {
|
||||
ConverterScreenContent(
|
||||
state = state,
|
||||
settings = ConversionSettings(),
|
||||
validation = Validation.Valid,
|
||||
actions = ConverterActions(
|
||||
onPickInput = {},
|
||||
onPreset = {},
|
||||
onContainer = {},
|
||||
onVideoCodec = {},
|
||||
onAudioCodec = {},
|
||||
onSuggestion = {},
|
||||
onQuality = {},
|
||||
onEnginePreference = {},
|
||||
onConvert = {},
|
||||
onCancel = {},
|
||||
onSave = { suggestedName -> savedAs += suggestedName },
|
||||
onReset = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.ui.semantics.ProgressBarRangeInfo
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.assertRangeInfoEquals
|
||||
import androidx.compose.ui.test.assertTextEquals
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.createDrainedComposeRule
|
||||
import org.libremediaconverter.model.AudioCodec
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.libremediaconverter.model.OutputSpec
|
||||
import org.libremediaconverter.model.Validation
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
import org.libremediaconverter.ui.TestTags
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Every `ConversionState` renders its own affordances, and only its own.
|
||||
*
|
||||
* The defect this bites on is a `when` arm that has drifted from the state it names: a button
|
||||
* offered in a state where it cannot work, a state's own data never reaching the node that is
|
||||
* supposed to display it, or an affordance wired to the wrong callback. None of that is a compile
|
||||
* error -- every arm of the `when` returns `Unit`, so an arm can render anything at all -- and none
|
||||
* of it is visible from the leaf tests, which compose `FileCard`, `AdvancedPicker` and the three
|
||||
* pickers directly and never see a `ConversionState`.
|
||||
*
|
||||
* The arm most worth guarding is `Ready`'s `enabled = validation.isValid`. The Advanced picker
|
||||
* deliberately lets an impossible container / codec combination be selected -- `AdvancedPicker`'s
|
||||
* KDoc says teaching the constraint beats hiding it -- so that single expression is the only thing
|
||||
* standing between an invalid spec and a job that cannot succeed. `enabled = true` compiles, renders
|
||||
* an identical screen apart from one colour, and passes every other test in this suite.
|
||||
*
|
||||
* Callbacks are asserted by **identity, over the whole log**: [fired] records all twelve of them and
|
||||
* each assertion compares the complete list against one expected entry. A bare "the callback ran"
|
||||
* check stays green when an arm fires the right callback for the wrong reason, and a check on one
|
||||
* callback alone stays green when an arm fires two.
|
||||
*
|
||||
* ### Not asserted here, so that each is a decision rather than an omission
|
||||
*
|
||||
* - **`Failed`'s error colour.** #62's table asks for the message "in the error colour". Compose
|
||||
* publishes no text colour to the semantics tree -- there is no `SemanticsProperties` entry for
|
||||
* it -- so it is unobservable from a JVM test, the same limit `FileCardTest` records for
|
||||
* `HorizontalDivider`. The message text itself is asserted; the colour would need a screenshot.
|
||||
* - **The three `assertDoesNotExist` checks on [TestTags.Converter.FILE_CARD] are compile-guarded,
|
||||
* not guarded by this file.** `Idle` is a `data object`, and `Saved` and `Failed` carry only a
|
||||
* `displayName` and a `message`; none of the three has an `input`, so `FileCard(s.input)` does not
|
||||
* compile in those arms. The lines stay because they state the intent cheaply, but they are not
|
||||
* what stops a `FileCard` appearing there and this file does not claim they are.
|
||||
* - **Which constant each chip hands back** belongs to `ConverterPickerSelectionTest`, and **what
|
||||
* the file card says about an unknown size** to `FileCardTest`. This file asserts that `Ready`
|
||||
* puts those leaves on screen at all, not what they then do.
|
||||
* - **The suggested name `Converted` hands to the save dialog** is pinned by
|
||||
* `ConverterScreenContentTest`; repeating it here would be a second copy of one assertion.
|
||||
* - **`ConverterScreen`'s permission dance.** `requestNotifications` calls `convert()` on both grant
|
||||
* and deny, deliberately -- the KDoc explains that the foreground service runs either way -- and
|
||||
* it lives in the entry point, above the seam this file composes.
|
||||
* - **`is ConversionState.Idle -> Unit` in the nested `when`.** The outer `when` peels `Idle` off
|
||||
* first, so that arm is permanently unreachable and no test can reach it.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class ConverterStateAffordancesTest {
|
||||
|
||||
// Not `createComposeRule()` directly: see [org.libremediaconverter.drainEscapedCoroutineErrors].
|
||||
@get:Rule
|
||||
val composeRule = createDrainedComposeRule()
|
||||
|
||||
/**
|
||||
* Every callback the screen fired, in order, tagged with the value it carried.
|
||||
*
|
||||
* All twelve are recorded rather than only the one under test, so an assertion can be
|
||||
* `assertEquals(listOf("cancel"), fired)` -- which says "this one and nothing else".
|
||||
*/
|
||||
private val fired = mutableListOf<String>()
|
||||
|
||||
// -------------------------------------------------------------------- Idle
|
||||
|
||||
@Test
|
||||
fun `an idle screen offers the prompt and the picker, and nothing to act on yet`() {
|
||||
setContent(ConversionState.Idle)
|
||||
|
||||
composeRule.onNodeWithText("Pick a file to convert.").assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Converter.CHOOSE_FILE).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Converter.CONVERT).assertDoesNotExist()
|
||||
composeRule.onNodeWithTag(TestTags.CANCEL).assertDoesNotExist()
|
||||
// Compile-guarded rather than guarded here -- `Idle` has no `input`. See the class KDoc.
|
||||
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD).assertDoesNotExist()
|
||||
}
|
||||
|
||||
/**
|
||||
* No [performScrollTo] on this one, unlike every other click below. `Idle` is the centred
|
||||
* branch outside the `verticalScroll` column, so it has no scrollable ancestor to scroll in.
|
||||
*/
|
||||
@Test
|
||||
fun `tapping choose file on an idle screen asks for a file and does nothing else`() {
|
||||
setContent(ConversionState.Idle)
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.CHOOSE_FILE).performClick()
|
||||
|
||||
assertEquals(listOf("pickInput"), fired)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- Ready
|
||||
|
||||
/**
|
||||
* All four pickers, the card above them and both buttons below, in one assertion each.
|
||||
*
|
||||
* A superset of #62's "all five pickers": which four or five of these count as a picker is not
|
||||
* worth arguing about, so the case names everything the arm emits.
|
||||
*/
|
||||
@Test
|
||||
fun `a picked file offers its card, all four pickers and both buttons`() {
|
||||
setContent(ConversionState.Ready(input()))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Converter.FORMAT_CHIPS).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_TOGGLE).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Converter.QUALITY_CHIPS).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Converter.ENGINE_CHIPS).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Converter.CONVERT).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Converter.CHOOSE_DIFFERENT_FILE).assertExists()
|
||||
}
|
||||
|
||||
/** The card is handed `s.input`, so the name on it is how the state is shown to have arrived. */
|
||||
@Test
|
||||
fun `the file card on a picked file names the file that was picked`() {
|
||||
setContent(ConversionState.Ready(input()))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD_NAME).assertTextEquals("holiday.mkv")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `convert is offered for a spec that can be produced`() {
|
||||
setContent(ConversionState.Ready(input()), validation = Validation.Valid)
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.CONVERT).assertIsEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tapping convert starts the job and does nothing else`() {
|
||||
setContent(ConversionState.Ready(input()), validation = Validation.Valid)
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.CONVERT).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("convert"), fired)
|
||||
}
|
||||
|
||||
/**
|
||||
* The bite named in #62. Reverting `enabled = validation.isValid` to `enabled = true` reddens
|
||||
* exactly this case, and nothing else in the repository.
|
||||
*/
|
||||
@Test
|
||||
fun `convert is withheld for a spec that cannot be produced`() {
|
||||
setContent(ConversionState.Ready(input()), validation = INVALID)
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.CONVERT).assertIsNotEnabled()
|
||||
}
|
||||
|
||||
/** The other button on the arm goes back to the picker rather than starting anything. */
|
||||
@Test
|
||||
fun `tapping choose a different file asks for a file rather than converting`() {
|
||||
setContent(ConversionState.Ready(input()))
|
||||
|
||||
composeRule
|
||||
.onNodeWithTag(TestTags.Converter.CHOOSE_DIFFERENT_FILE)
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
|
||||
assertEquals(listOf("pickInput"), fired)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- Converting
|
||||
|
||||
/**
|
||||
* Two independent readings of the same `percent`, on purpose.
|
||||
*
|
||||
* The heading is a string and the bar is a float, and the arm computes them from the state
|
||||
* separately -- `"${s.percent}%"` against `s.percent / 100f`. A hardcoded bar and a hardcoded
|
||||
* heading are different mistakes, so neither assertion covers the other.
|
||||
*/
|
||||
@Test
|
||||
fun `a running job reports how far it has got, in words and on the bar`() {
|
||||
setContent(ConversionState.Converting(input(), percent = 42))
|
||||
|
||||
composeRule.onNodeWithText("Converting… 42%").assertExists()
|
||||
composeRule
|
||||
.onNodeWithTag(TestTags.Converter.PROGRESS)
|
||||
.assertRangeInfoEquals(ProgressBarRangeInfo(0.42f, 0f..1f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a running job offers cancel and not start over`() {
|
||||
setContent(ConversionState.Converting(input(), percent = 42))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.CANCEL).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.START_OVER).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tapping cancel on a running job cancels it and does nothing else`() {
|
||||
setContent(ConversionState.Converting(input(), percent = 42))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.CANCEL).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("cancel"), fired)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- Waiting
|
||||
|
||||
/**
|
||||
* The second bite named in #62. Deleting the `Cancel` button from the `Waiting` arm reddens
|
||||
* this case and the one below it.
|
||||
*
|
||||
* The paragraph is asserted in full rather than by a fragment because it is the only thing the
|
||||
* arm renders besides the card and the button, and because its wording is the arm's whole
|
||||
* job -- `FailureOutcome` records that two different causes land here and the state cannot tell
|
||||
* them apart, so the text has to cover both. A reword should redden one test, and this is it.
|
||||
*/
|
||||
@Test
|
||||
fun `a paused job explains why and still offers cancel`() {
|
||||
setContent(ConversionState.Waiting(input()))
|
||||
|
||||
composeRule.onNodeWithText(PAUSED_PARAGRAPH).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.CANCEL).assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tapping cancel on a paused job cancels it and does nothing else`() {
|
||||
setContent(ConversionState.Waiting(input()))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.CANCEL).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("cancel"), fired)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- Converted
|
||||
|
||||
@Test
|
||||
fun `a finished job offers save and start over, and no longer offers cancel`() {
|
||||
setContent(converted())
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.SAVE_FILE).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.START_OVER).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.CANCEL).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tapping start over on a finished job resets and does not save`() {
|
||||
setContent(converted())
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.START_OVER).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("reset"), fired)
|
||||
}
|
||||
|
||||
/**
|
||||
* The chip carries the job's own explanation, so its text is the assertion rather than its
|
||||
* presence: a chip showing the engine name, or the previous job's reason, would still exist.
|
||||
*/
|
||||
@Test
|
||||
fun `a finished job shows the routing decision the job reported`() {
|
||||
setContent(converted(routeReason = "Software — the MKV input needed a re-encode"))
|
||||
|
||||
composeRule
|
||||
.onNodeWithTag(TestTags.Converter.ROUTE_REASON)
|
||||
.assertTextEquals("Software — the MKV input needed a re-encode")
|
||||
}
|
||||
|
||||
/** The other side of the `isNotBlank` guard, which is unguarded without a case of its own. */
|
||||
@Test
|
||||
fun `a finished job that reported no routing decision shows no chip`() {
|
||||
setContent(converted(routeReason = ""))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.ROUTE_REASON).assertDoesNotExist()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- Saved
|
||||
|
||||
@Test
|
||||
fun `a saved file names itself and offers another conversion`() {
|
||||
setContent(ConversionState.Saved(displayName = "holiday.mp4"))
|
||||
|
||||
composeRule.onNodeWithText("Saved holiday.mp4.").assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Converter.CONVERT_ANOTHER).assertExists()
|
||||
// Compile-guarded rather than guarded here -- `Saved` has no `input`. See the class KDoc.
|
||||
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tapping convert another after a save resets and does nothing else`() {
|
||||
setContent(ConversionState.Saved(displayName = "holiday.mp4"))
|
||||
|
||||
composeRule
|
||||
.onNodeWithTag(TestTags.Converter.CONVERT_ANOTHER)
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
|
||||
assertEquals(listOf("reset"), fired)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Failed
|
||||
|
||||
/**
|
||||
* The message is the arm's only output that carries information, and it comes from the state.
|
||||
* An arm rendering a fixed apology would look right and say nothing, which is why the assertion
|
||||
* is on the text handed in rather than on a node existing.
|
||||
*/
|
||||
@Test
|
||||
fun `a failed job renders the reason it was given and offers a restart`() {
|
||||
setContent(ConversionState.Failed(message = "Ran out of space while writing the output."))
|
||||
|
||||
composeRule.onNodeWithText("Ran out of space while writing the output.").assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.START_OVER).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.SAVE_FILE).assertDoesNotExist()
|
||||
// Compile-guarded rather than guarded here -- `Failed` has no `input`. See the class KDoc.
|
||||
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tapping start over after a failure resets and does nothing else`() {
|
||||
setContent(ConversionState.Failed(message = "Ran out of space while writing the output."))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.START_OVER).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("reset"), fired)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Harness
|
||||
|
||||
private fun input() = InputFile(
|
||||
uri = Uri.parse("content://test/holiday.mkv"),
|
||||
displayName = "holiday.mkv",
|
||||
sizeBytes = 12_345_678L,
|
||||
)
|
||||
|
||||
/**
|
||||
* `staged` names a path that does not exist, deliberately: `File.length()` answers `0L` for a
|
||||
* missing file rather than throwing, so the size line reads `0 B` and no temporary folder is
|
||||
* needed to render the arm.
|
||||
*/
|
||||
private fun converted(routeReason: String = "") = ConversionState.Converted(
|
||||
input = input(),
|
||||
staged = File("no-such-staged-output.mp4"),
|
||||
routeReason = routeReason,
|
||||
suggestedName = "holiday.mp4",
|
||||
mimeType = "video/mp4",
|
||||
)
|
||||
|
||||
private fun setContent(state: ConversionState, validation: Validation = Validation.Valid) {
|
||||
composeRule.setContent {
|
||||
ConverterScreenContent(
|
||||
state = state,
|
||||
settings = ConversionSettings(),
|
||||
validation = validation,
|
||||
actions = ConverterActions(
|
||||
onPickInput = { fired += "pickInput" },
|
||||
onPreset = { fired += "preset:$it" },
|
||||
onContainer = { fired += "container:$it" },
|
||||
onVideoCodec = { fired += "videoCodec:$it" },
|
||||
onAudioCodec = { fired += "audioCodec:$it" },
|
||||
onSuggestion = { fired += "suggestion:$it" },
|
||||
onQuality = { fired += "quality:$it" },
|
||||
onEnginePreference = { fired += "engine:$it" },
|
||||
onConvert = { fired += "convert" },
|
||||
onCancel = { fired += "cancel" },
|
||||
onSave = { fired += "save:$it" },
|
||||
onReset = { fired += "reset" },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
/**
|
||||
* A spec no container can hold, with somewhere to go instead.
|
||||
*
|
||||
* Built here rather than run through `ContainerCapabilities` because what makes a spec
|
||||
* invalid is that class's subject; all this arm needs is a `Validation` that answers
|
||||
* `isValid == false`.
|
||||
*/
|
||||
val INVALID = Validation.Invalid(
|
||||
message = "WebM cannot hold H.264 video.",
|
||||
suggestions = listOf(OutputSpec(Container.MKV, VideoCodec.H264, AudioCodec.AAC)),
|
||||
)
|
||||
|
||||
/** Copied from the `Waiting` arm, where it is written as two concatenated fragments. */
|
||||
const val PAUSED_PARAGRAPH =
|
||||
"Paused. Android limits background media processing, so this will " +
|
||||
"resume automatically — keeping the app open helps it along."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package org.libremediaconverter.join
|
||||
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.createDrainedComposeRule
|
||||
import org.libremediaconverter.model.ConcatStrategy
|
||||
import org.libremediaconverter.ui.TestTags
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The join screen's half of the same seam, and the same two directions.
|
||||
*
|
||||
* The defect is the one `ConverterScreenContentTest` describes -- a content composable that
|
||||
* ignores the state handed to it, or renders the finished job's affordances unwired -- and it has
|
||||
* to be asked separately here because the two screens share no code. `JoinScreen` and
|
||||
* `ConverterScreen` were extracted in the same commit by the same hand, which is exactly the
|
||||
* circumstance in which one of them gets the wiring right and the other does not.
|
||||
*
|
||||
* `JoinState.Joined` is unreachable through a real `JoinViewModel` for the same reason
|
||||
* `ConversionState.Converted` is: only a `ConcatWorker` run that has already succeeded produces
|
||||
* one, carrying the strategy it chose and the name it picked.
|
||||
*
|
||||
* `JoinScreenKt` is the honest remaining coverage gap on this repo, and closing it is R38.7 (#63),
|
||||
* not this file. Which affordances each `JoinState` renders belongs there; this asserts only that
|
||||
* the injection point exists.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class JoinScreenContentTest {
|
||||
|
||||
// Not `createComposeRule()` directly: see [org.libremediaconverter.drainEscapedCoroutineErrors].
|
||||
@get:Rule
|
||||
val composeRule = createDrainedComposeRule()
|
||||
|
||||
/** What the screen asked to save, in the order it asked. Empty until Save is tapped. */
|
||||
private val savedAs = mutableListOf<String>()
|
||||
|
||||
@Test
|
||||
fun `a finished join renders the save button`() {
|
||||
setContent(joined())
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.SAVE_FILE).assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tapping save hands back the name the finished join chose`() {
|
||||
setContent(joined())
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.SAVE_FILE).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("joined.mp4"), savedAs)
|
||||
}
|
||||
|
||||
/** `staged` names a missing file deliberately -- see the same helper on the converter side. */
|
||||
private fun joined() = JoinState.Joined(
|
||||
staged = File("no-such-staged-output.mp4"),
|
||||
strategy = ConcatStrategy.STREAM_COPY,
|
||||
suggestedName = "joined.mp4",
|
||||
mimeType = "video/mp4",
|
||||
)
|
||||
|
||||
private fun setContent(state: JoinState) {
|
||||
composeRule.setContent {
|
||||
JoinScreenContent(
|
||||
state = state,
|
||||
actions = JoinActions(
|
||||
onPickInputs = {},
|
||||
onJoin = {},
|
||||
onCancel = {},
|
||||
onSave = { suggestedName -> savedAs += suggestedName },
|
||||
onReset = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package org.libremediaconverter.join
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.ui.semantics.ProgressBarRangeInfo
|
||||
import androidx.compose.ui.semantics.SemanticsProperties
|
||||
import androidx.compose.ui.semantics.getOrNull
|
||||
import androidx.compose.ui.test.SemanticsMatcher
|
||||
import androidx.compose.ui.test.assertRangeInfoEquals
|
||||
import androidx.compose.ui.test.assertTextEquals
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.convert.InputFile
|
||||
import org.libremediaconverter.createDrainedComposeRule
|
||||
import org.libremediaconverter.model.ConcatStrategy
|
||||
import org.libremediaconverter.ui.TestTags
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Every `JoinState` renders its own affordances, wired to its own callback.
|
||||
*
|
||||
* The defect is a branch of `JoinScreenContent`'s `when` that reads the wrong thing: a count taken
|
||||
* from a literal rather than from `inputs`, a strategy line that describes the other strategy, a
|
||||
* button wired to the neighbouring branch's callback, a `Failed` that drops the message it carries.
|
||||
* None of that is visible at compile time -- every branch of the `when` type-checks against the
|
||||
* same `JoinScreenContent` signature -- and none of it is visible from the leaf tests either, which
|
||||
* compose `FileRow` on its own and never see a state.
|
||||
*
|
||||
* `JoinScreenContentTest` deliberately asks only whether the seam exists, using `Joined`. This is
|
||||
* the matrix behind it: seven states, each pinned to what it lets the user do next.
|
||||
*
|
||||
* ### Two assertions here that nothing else in the suite makes
|
||||
*
|
||||
* **Order.** A join is the one flow where the order of the inputs is the content of the output --
|
||||
* the empty state promises "in the order you want them" -- so the rows are read back sorted by
|
||||
* their position on screen and compared as a list, not as a set. `JoinLeafTagsTest` proves a row
|
||||
* tags itself with the file it shows; nothing proved the rows come out in the order they went in.
|
||||
*
|
||||
* **Indeterminate.** The join progress bar carries no percentage, on purpose: FFmpeg reports
|
||||
* progress against one input's duration, which means nothing across a concatenation. The converter
|
||||
* screen's bar is determinate, so "it has a progress bar" is the assertion that would not notice a
|
||||
* fabricated percentage arriving here.
|
||||
*
|
||||
* ### Not asserted here, deliberately
|
||||
*
|
||||
* `JoinState.Joined.mimeType` is not rendered by this composable at all -- it is read by the entry
|
||||
* point, to open the save dialog with a type that matches the finished job. The colour of the
|
||||
* `Failed` message is `MaterialTheme.colorScheme.error`, which is theme lookup rather than state
|
||||
* logic, so it is left to the eye. The `is JoinState.Idle -> Unit` arm inside the scrolling branch
|
||||
* is unreachable by construction: the outer `when` peels `Idle` off first.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class JoinStateAffordancesTest {
|
||||
|
||||
// Not `createComposeRule()` directly: see [org.libremediaconverter.drainEscapedCoroutineErrors].
|
||||
@get:Rule
|
||||
val composeRule = createDrainedComposeRule()
|
||||
|
||||
/** Which callback the screen invoked, in order, with what it passed. Empty until one fires. */
|
||||
private val events = mutableListOf<String>()
|
||||
|
||||
@Test
|
||||
fun `the empty state asks for files in order and offers the picker`() {
|
||||
setContent(JoinState.Idle)
|
||||
|
||||
composeRule.onNodeWithText("Pick two or more files to join, in the order you want them.").assertExists()
|
||||
// No `performScrollTo` on this one: `Idle` is the centred branch, outside the scrolling
|
||||
// column every other state renders into, so there is nothing to scroll.
|
||||
composeRule.onNodeWithTag(TestTags.Join.CHOOSE_FILES).performClick()
|
||||
|
||||
assertEquals(listOf("pickInputs"), events)
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows come out in the order the inputs went in.
|
||||
*
|
||||
* Sorted by position rather than trusting the order `fetchSemanticsNodes` happens to return, so
|
||||
* the assertion is about what the user sees down the screen. Three inputs, with names whose
|
||||
* alphabetical order is not their picked order, so a list that had been sorted anywhere on the
|
||||
* way through would not be able to pass this.
|
||||
*/
|
||||
@Test
|
||||
fun `the picked inputs are listed in the order they were picked`() {
|
||||
val picked = listOf("intro.mp4", "middle.mp4", "outro.mp4")
|
||||
setContent(JoinState.Ready(inputs = picked.map(::input)))
|
||||
|
||||
val topToBottom = composeRule.onAllNodes(isFileRow)
|
||||
.fetchSemanticsNodes()
|
||||
.sortedBy { it.positionInRoot.y }
|
||||
.map { it.config[SemanticsProperties.TestTag] }
|
||||
|
||||
assertEquals(picked.map(TestTags.Join::fileRow), topToBottom)
|
||||
}
|
||||
|
||||
/**
|
||||
* Three inputs, not two: two is the minimum a join accepts, so a button that had been
|
||||
* hardcoded to the smallest legal join would still read correctly with two on screen.
|
||||
*/
|
||||
@Test
|
||||
fun `the join button counts the files it will join`() {
|
||||
setContent(JoinState.Ready(inputs = listOf(input("intro.mp4"), input("middle.mp4"), input("outro.mp4"))))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Join.JOIN).assertTextEquals("Join 3 files")
|
||||
composeRule.onNodeWithTag(TestTags.Join.JOIN).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("join"), events)
|
||||
}
|
||||
|
||||
/** `Ready` is the one working state that still offers the picker, to replace the selection. */
|
||||
@Test
|
||||
fun `a ready join can be repicked`() {
|
||||
setContent(JoinState.Ready(inputs = listOf(input("intro.mp4"), input("outro.mp4"))))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Join.CHOOSE_DIFFERENT_FILES).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("pickInputs"), events)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a running join names the count and shows a bar with no percentage`() {
|
||||
setContent(JoinState.Joining(inputs = listOf(input("intro.mp4"), input("outro.mp4"))))
|
||||
|
||||
composeRule.onNodeWithText("Joining 2 files…").assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Join.PROGRESS).assertRangeInfoEquals(ProgressBarRangeInfo.Indeterminate)
|
||||
composeRule.onNodeWithTag(TestTags.CANCEL).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("cancel"), events)
|
||||
}
|
||||
|
||||
/**
|
||||
* The paragraph is byte-identical to the converter screen's, which is the point of asserting
|
||||
* the whole of it rather than a fragment: the two branches were worded together, and a reword
|
||||
* that lands on one screen only is the failure this notices.
|
||||
*/
|
||||
@Test
|
||||
fun `a paused join explains itself and still offers cancel`() {
|
||||
setContent(JoinState.Waiting(inputs = listOf(input("intro.mp4"), input("outro.mp4"))))
|
||||
|
||||
composeRule.onNodeWithText(PAUSED_PARAGRAPH).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.CANCEL).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("cancel"), events)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stream copied join says nothing was re-encoded`() {
|
||||
setContent(joined(ConcatStrategy.STREAM_COPY))
|
||||
|
||||
composeRule.onNodeWithText(STREAM_COPY_EXPLANATION).assertExists()
|
||||
composeRule.onNodeWithText(REENCODE_EXPLANATION).assertDoesNotExist()
|
||||
}
|
||||
|
||||
/**
|
||||
* The other half of the pair. Asserting the absence of the stream-copy line as well, because a
|
||||
* branch that had collapsed to one answer would still render *an* explanation.
|
||||
*/
|
||||
@Test
|
||||
fun `a re-encoded join says the files differed`() {
|
||||
setContent(joined(ConcatStrategy.REENCODE))
|
||||
|
||||
composeRule.onNodeWithText(REENCODE_EXPLANATION).assertExists()
|
||||
composeRule.onNodeWithText(STREAM_COPY_EXPLANATION).assertDoesNotExist()
|
||||
}
|
||||
|
||||
/** The size comes from the staged file, which is missing here, so `length()` answers `0L`. */
|
||||
@Test
|
||||
fun `a finished join reports the size of what it produced`() {
|
||||
setContent(joined(ConcatStrategy.STREAM_COPY))
|
||||
|
||||
composeRule.onNodeWithText("Joined — 0 MB.").assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a finished join offers save and start over, and they are not the same button`() {
|
||||
setContent(joined(ConcatStrategy.STREAM_COPY))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.SAVE_FILE).performScrollTo().performClick()
|
||||
composeRule.onNodeWithTag(TestTags.START_OVER).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("save:joined.mp4", "reset"), events)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a saved join names the file and offers to join more`() {
|
||||
setContent(JoinState.Saved(displayName = "holiday-joined.mp4"))
|
||||
|
||||
composeRule.onNodeWithText("Saved holiday-joined.mp4.").assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Join.JOIN_MORE).assertTextEquals("Join more")
|
||||
composeRule.onNodeWithTag(TestTags.Join.JOIN_MORE).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("reset"), events)
|
||||
}
|
||||
|
||||
/**
|
||||
* The message is the whole content of this state -- it is the only thing that says why the job
|
||||
* stopped -- and it arrives as a string the failure produced, so a branch that rendered a fixed
|
||||
* apology instead would look correct on screen.
|
||||
*/
|
||||
@Test
|
||||
fun `a failed join renders the message it carries`() {
|
||||
setContent(JoinState.Failed(message = "The second file has no audio track, so joining stopped."))
|
||||
|
||||
composeRule.onNodeWithText("The second file has no audio track, so joining stopped.").assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed join offers start over`() {
|
||||
setContent(JoinState.Failed(message = "The second file has no audio track, so joining stopped."))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.START_OVER).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("reset"), events)
|
||||
}
|
||||
|
||||
/** Anything `FileRow` tagged, whichever file it is showing. The prefix comes from the table. */
|
||||
private val isFileRow = SemanticsMatcher("is a join file row") { node ->
|
||||
node.config.getOrNull(SemanticsProperties.TestTag)?.startsWith(TestTags.Join.fileRow("")) == true
|
||||
}
|
||||
|
||||
private fun input(displayName: String) = InputFile(
|
||||
uri = Uri.parse("content://test/$displayName"),
|
||||
displayName = displayName,
|
||||
sizeBytes = 4_000_000L,
|
||||
)
|
||||
|
||||
/** `staged` names a missing file deliberately -- see the same helper in `JoinScreenContentTest`. */
|
||||
private fun joined(strategy: ConcatStrategy) = JoinState.Joined(
|
||||
staged = File("no-such-staged-output.mp4"),
|
||||
strategy = strategy,
|
||||
suggestedName = "joined.mp4",
|
||||
mimeType = "video/mp4",
|
||||
)
|
||||
|
||||
private fun setContent(state: JoinState) {
|
||||
composeRule.setContent {
|
||||
JoinScreenContent(
|
||||
state = state,
|
||||
actions = JoinActions(
|
||||
onPickInputs = { events += "pickInputs" },
|
||||
onJoin = { events += "join" },
|
||||
onCancel = { events += "cancel" },
|
||||
onSave = { suggestedName -> events += "save:$suggestedName" },
|
||||
onReset = { events += "reset" },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Byte-identical to the converter screen's, and split the same way `main` splits it. */
|
||||
const val PAUSED_PARAGRAPH =
|
||||
"Paused. Android limits background media processing, so this will " +
|
||||
"resume automatically — keeping the app open helps it along."
|
||||
|
||||
const val STREAM_COPY_EXPLANATION =
|
||||
"Files matched, so they were joined without " +
|
||||
"re-encoding — no quality loss."
|
||||
|
||||
const val REENCODE_EXPLANATION =
|
||||
"Files differed in format, so they were re-encoded " +
|
||||
"to match."
|
||||
}
|
||||
}
|
||||
@@ -230,11 +230,17 @@ booted (`emulator_alive=yes`). The host emulator is fine; the guest is not.
|
||||
|
||||
## Can the suite run on it?
|
||||
|
||||
**Almost.** `tools/local-emulator/run-e2e.sh 37` now runs the whole suite locally, and all of it
|
||||
passes except two tests. Measured at `22c7914`: **49 tests, 2 failures, 0 errors, 2 skipped** — 45
|
||||
passed, the two `Media3EngineTest` failures dissected below, and the two `assumeTrue` skips every
|
||||
level has. It costs two deviations from how every other level is run, and both are worth
|
||||
understanding before trusting the leg.
|
||||
**Almost, and less so than it was.** `tools/local-emulator/run-e2e.sh 37` runs the whole suite
|
||||
locally. Measured at `22c7914`: **49 tests, 2 failures, 0 errors, 2 skipped** — 45 passed, the two
|
||||
`Media3EngineTest` failures dissected below, and the two `assumeTrue` skips every level has. It
|
||||
costs two deviations from how every other level is run, and both are worth understanding before
|
||||
trusting the leg.
|
||||
|
||||
**That was the high-water mark.** On 2026-08-24 a test that touches system UI joined the suite,
|
||||
and the level stopped *finishing* rather than merely failing two —
|
||||
[see below](#something-does-depend-on-system-ui-now-and-it-is-excluded-rather-than-trusted).
|
||||
Two `Media3EngineTest` failures is what **CI's gating leg** expects, because it filters on
|
||||
`notAnnotation`; a local `run-e2e.sh 37` does not filter and sees more.
|
||||
|
||||
Two things about that total before it is compared with anything. It is the size of the suite on
|
||||
the checkout that ran, not a property of API 37 — `app/src/androidTest` held 49 `@Test` methods at
|
||||
@@ -320,10 +326,60 @@ and proceeding straight to the tests fails exactly as before. The harness theref
|
||||
1. **The renderer is ANGLE, not the host GPU.** Shared with nothing else in the matrix — API
|
||||
33–36 run `-gpu host` locally, and CI runs `swiftshader_indirect`.
|
||||
2. **SystemUI is disabled.** The API 37 leg does not run the same device configuration as any
|
||||
other leg or as the Pixel. It is defensible here only because nothing in this suite touches
|
||||
system UI — these are Media3, FFmpeg and WorkManager tests — and because the alternative is no
|
||||
local API 37 coverage at all. **Anything that ever does depend on system UI must not trust
|
||||
this leg.**
|
||||
other leg or as the Pixel. It was defensible here because nothing in this suite touched
|
||||
system UI — Media3, FFmpeg and WorkManager tests — and because the alternative is no local
|
||||
API 37 coverage at all. **Anything that ever does depend on system UI must not trust this
|
||||
leg.** Something now does; see the section below.
|
||||
|
||||
### Something does depend on system UI now, and half of it is excluded
|
||||
|
||||
Added 2026-08-24, and the first entry on this page that is not a codec.
|
||||
|
||||
`SafPickerRoundTripTest` drives the real system file picker and rotates the display. Both reach
|
||||
the gralloc mapper — DocumentsUI is another app's windows, and a rotation rebuilds every surface
|
||||
on screen — and **disabling SystemUI does not help**, because it removes the *idle* trigger
|
||||
(RegionSamplingThread's nav-bar luma sampling) and not this one.
|
||||
|
||||
Measured one method per fresh emulator, `android-37.0`, `swangle_indirect`, SystemUI disabled and
|
||||
verified quiet — separately, because inferring the second from the first is the mistake this
|
||||
page's opening correction is about:
|
||||
|
||||
| test | result on android-37.0 | `hasReadColorBufferDma` aborts in the window |
|
||||
|---|---|---|
|
||||
| `thePickedInputSurvivesARealRotation` | **fails**: `INSTRUMENTATION_ABORTED: System has crashed.`, `Expected 1 tests, received 0`. The framework dies **during** it, so the JUnit XML carries a failure with no text at all. | 3 |
|
||||
| `pickingAFileThroughTheSystemPickerFillsInTheFileCard` | **passes** | 4 |
|
||||
|
||||
So a rotation, which rebuilds every surface at once, is what the mapper does not survive. Merely
|
||||
starting DocumentsUI is not. Only the rotation test carries `@FailsOnEmulatorApi37`; the picker
|
||||
test runs on the gating leg like anything else.
|
||||
|
||||
#### The correction that produced that table
|
||||
|
||||
**The first version of this section said both tests failed, and put the marker on the class.** The
|
||||
picker test had indeed failed at API 37 — with `androidx.test.uiautomator.StaleObjectException`,
|
||||
which looked like a framework restart invalidating an accessibility node, because that is exactly
|
||||
what it looks like.
|
||||
|
||||
It was the test's own bug. `UiObject2` caches the `AccessibilityNodeInfo` it was found with, and
|
||||
DocumentsUI is still settling when a node first appears; the handle went stale before `click()`.
|
||||
CI then reproduced it **deterministically** at API 33, 34 and 35 — every cold runner emulator, not
|
||||
intermittently — which is what made it obviously not an API 37 property. It had passed locally
|
||||
only because the emulator was warm.
|
||||
|
||||
The lesson is worth more than the measurement: **an annotation is a claim about an image, and a
|
||||
broken test makes every image look broken.** Re-measure after fixing a test before deciding what
|
||||
the platform did. Both the abort and the stale node produce "the run fell over", and only one of
|
||||
them was the image.
|
||||
|
||||
#### Two consequences worth stating rather than discovering
|
||||
|
||||
- **`run-e2e.sh 37` applies no annotation filter**, unlike CI, so a local API 37 run includes the
|
||||
rotation test and therefore **does not finish**: its totals come back short and which later
|
||||
tests ran is arbitrary. The summary row says so.
|
||||
- **The advisory job is still named `E2E API 37 Media3 hardware transcode (advisory)`** and now
|
||||
carries a test that is neither Media3 nor a transcode. Renaming a check is a branch-protection
|
||||
change and was deliberately not made in the same PR; the name is stale, the behaviour is
|
||||
correct.
|
||||
|
||||
### The two remaining failures are the same bug, one layer down
|
||||
|
||||
@@ -650,9 +706,15 @@ though a new API level shipped. Watch for these instead:
|
||||
- **`E2E API 37 Media3 hardware transcode (advisory)` going green.** Nothing announces this: the
|
||||
job is `continue-on-error`, so it fixing itself looks exactly like a check nobody reads
|
||||
quietly ceasing to be red. It is listed here because that makes it the *least* likely of these
|
||||
triggers to be noticed, not the most. When it happens, delete `@FailsOnEmulatorApi37` from the
|
||||
two tests rather than the job — the gating leg picks them back up on its own, and the advisory
|
||||
job then runs nothing and can go.
|
||||
triggers to be noticed, not the most. When it happens, delete `@FailsOnEmulatorApi37` from
|
||||
everything carrying it rather than deleting the job — the gating leg picks them back up on its
|
||||
own, and the advisory job then runs nothing and can go.
|
||||
|
||||
**It is not two tests any more.** As of 2026-08-24 the marker is on `Media3EngineTest`'s two
|
||||
methods *and* on `SafPickerRoundTripTest` as a class, and the two groups fail for unrelated
|
||||
reasons — a codec and the gralloc mapper. They can go green independently, so check both before
|
||||
concluding the marker is done; and the job's name still says "Media3 hardware transcode", which
|
||||
half of what it runs is not.
|
||||
|
||||
## Correction owed to `CLAUDE.md`
|
||||
|
||||
|
||||
@@ -42,6 +42,18 @@ annotation = "1.+"
|
||||
junit = "4.+"
|
||||
androidxJunit = "1.+"
|
||||
espressoCore = "3.+"
|
||||
# UiAutomator. FLOATING, and the argument for it is the one the guard already makes:
|
||||
# androidx.test.uiautomator is inside `floatedGroupPrefixes` ("androidx."), so `2.+` reads
|
||||
# as "the newest RELEASED 2.x" exactly the way `work = "2.+"` does -- and this library does
|
||||
# publish alphas above its stable, so without the guard it would be a pin.
|
||||
#
|
||||
# Not pinned like ktlint/detekt/robolectric, because it is not that kind of dependency. Those
|
||||
# are pinned because a new *rule* or a new *runtime* makes untouched files fail -- the tool
|
||||
# changes its verdict on code nobody edited. UiAutomator has no verdict: it clicks what a
|
||||
# selector names, and a selector that stops matching is this repo's test to fix, in a diff
|
||||
# that says so. `2.` and not bare `+` because 3.x does not exist yet and a major is where the
|
||||
# selector API would be free to change under exactly that assumption.
|
||||
uiautomator = "2.+"
|
||||
# PINNED, unlike its neighbours. Under semver a 0.x minor is allowed to break, and
|
||||
# this library is load-bearing exactly where breakage is hardest to see: the wrapper
|
||||
# reaches for smartexception.java.Exceptions only when an FFmpeg call FAILS, so a
|
||||
@@ -139,6 +151,11 @@ junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
|
||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||
|
||||
# The only way to touch UI this app does not own. Compose's own matchers stop at this
|
||||
# process's composition, and the system file picker is a DocumentsUI activity in another
|
||||
# process -- so a SAF round trip is unreachable without it.
|
||||
androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" }
|
||||
|
||||
# Robolectric — an Android runtime for the JVM test source set, so file-lifecycle behaviour
|
||||
# that needs a real Context can be verified without a device. The instrumented suite cannot
|
||||
# run on the development host at all (see CLAUDE.md), so an androidTest-only red test is not
|
||||
|
||||
@@ -15,11 +15,13 @@
|
||||
# EXIT CODE: 0 only if every level was green; 1 if any level failed, wedged or could not be
|
||||
# set up; 2 if it refused to start at all. **A bare `run-e2e.sh` therefore exits 1 by design.**
|
||||
# API 37 is in the default list on purpose -- leaving it out is what left the level unlooked-at
|
||||
# for as long as it was -- and it is permanently two failures short of green, on the emulator's
|
||||
# own c2.goldfish.h264.decoder rather than on anything this app does. The summary names the two,
|
||||
# so a third is visibly new, and the last line printed says the same thing. Anything that reads a
|
||||
# non-zero exit as breakage should name the levels it wants: `run-e2e.sh 33 34 35 36` is the
|
||||
# sweep that can be green. docs/api-37-emulator-crash.md has the measurements.
|
||||
# for as long as it was -- and it is permanently short of green, on the emulator image rather
|
||||
# than on anything this app does. Since 2026-08-24 it does not even FINISH: one of its expected
|
||||
# failures kills the framework, so the totals come back short with an arbitrary tail. The summary
|
||||
# names every failure it expects, so an unnamed one is visibly new, and the last line printed
|
||||
# says the same thing. Anything that reads a non-zero exit as breakage should name the levels it
|
||||
# wants: `run-e2e.sh 33 34 35 36` is the sweep that can be green.
|
||||
# docs/api-37-emulator-crash.md has the measurements.
|
||||
#
|
||||
# WHY THIS EXISTS, AND WHAT IT DELIBERATELY DOES NOT DO
|
||||
#
|
||||
@@ -320,9 +322,20 @@ boot_emulator() {
|
||||
#
|
||||
# THIS IS A DEVIATION, and it is deliberately loud rather than silent. The API 37 leg does not
|
||||
# run the same device configuration as API 33-36 or as the Pixel. It is defensible only
|
||||
# because nothing in this suite touches SystemUI -- these are Media3, FFmpeg and WorkManager
|
||||
# tests -- and because the alternative is no API 37 coverage at all. Anything that ever does
|
||||
# depend on system UI must not trust this leg. docs/api-37-emulator-crash.md explains why.
|
||||
# because nothing in this suite touched SystemUI -- Media3, FFmpeg and WorkManager tests --
|
||||
# and because the alternative is no API 37 coverage at all. Anything that ever does depend on
|
||||
# system UI must not trust this leg. docs/api-37-emulator-crash.md explains why.
|
||||
#
|
||||
# "Touched", past tense, since 2026-08-24. SafPickerRoundTripTest drives DocumentsUI and rotates
|
||||
# the display, and both reach the gralloc mapper these images abort in -- disabling SystemUI
|
||||
# removes the IDLE trigger, not those. Measured per method on android-37.0: the ROTATION test
|
||||
# takes the framework down (INSTRUMENTATION_ABORTED) and carries @FailsOnEmulatorApi37; the
|
||||
# picker test passes.
|
||||
#
|
||||
# THIS SCRIPT APPLIES NO ANNOTATION FILTER, unlike CI, so a local `run-e2e.sh 37` runs the
|
||||
# rotation test anyway -- and because that test kills the framework rather than merely failing,
|
||||
# THE LEVEL DOES NOT FINISH. Its totals come back short and which later tests ran is arbitrary.
|
||||
# CI's gating leg never sees it.
|
||||
#
|
||||
# The retry loop is not defensive padding: at the moment boot_completed flips, the framework
|
||||
# may be in one of its restarts and `pm` is simply not published yet. The first attempt at this
|
||||
@@ -564,13 +577,25 @@ for api in "${APIS[@]}"; do
|
||||
guest_forensics "$api"
|
||||
# API 37 is in the default list on purpose, and it is expected to be red. Leaving it out would
|
||||
# put the level back where this whole exercise found it -- untested and unlooked-at -- but a
|
||||
# summary that just says "2 failures" with no explanation trains people to ignore the exit
|
||||
# code. So the row says which two, and a THIRD failure is then obviously new.
|
||||
# summary that just says "N failures" with no explanation trains people to ignore the exit
|
||||
# code. So the row NAMES the expected ones, and anything else is then obviously new.
|
||||
#
|
||||
# The list grew on 2026-08-24 and the shape of the row changed with it. The two
|
||||
# Media3EngineTest failures are a codec; the third is the gralloc bug reached through system
|
||||
# UI, and it takes the framework DOWN rather than merely failing -- so the level does not
|
||||
# finish, and the totals come back SHORT (50 of 59 when this was written) with the later
|
||||
# tests never run. A run whose totals do not add up is expected here now, which it never
|
||||
# was before.
|
||||
case "$api" in
|
||||
37 | 37.*)
|
||||
line="$line
|
||||
expected here: 2 failures, both Media3EngineTest, on c2.goldfish.h264.decoder.
|
||||
A third is new -- docs/api-37-emulator-crash.md"
|
||||
expected here: 2 Media3EngineTest failures on c2.goldfish.h264.decoder, plus
|
||||
SafPickerRoundTripTest.thePickedInputSurvivesARealRotation -- which kills the framework
|
||||
rather than merely failing, so the run ABORTS partway and the total comes back SHORT with
|
||||
an arbitrary tail. That is expected here too, and never was before. Anything else is new.
|
||||
CI's gating leg sees only the first two: the rotation test carries @FailsOnEmulatorApi37
|
||||
and this script, unlike CI, applies no annotation filter.
|
||||
docs/api-37-emulator-crash.md"
|
||||
;;
|
||||
esac
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
@@ -596,8 +621,9 @@ echo "=============================================================="
|
||||
# worse than no note at all.
|
||||
if [ "$overall" -ne 0 ] && [ "$NON37_RED" -eq 0 ]; then
|
||||
echo "note: the only level that went red is API 37, which exits non-zero by design -- it is"
|
||||
echo " permanently 2 failures short of green. Confirm its row above shows exactly those"
|
||||
echo " two and nothing else; docs/api-37-emulator-crash.md says why they are the image."
|
||||
echo " permanently short of green, and since 2026-08-24 it does not even finish. Confirm"
|
||||
echo " its row above names every failure it shows; docs/api-37-emulator-crash.md says why"
|
||||
echo " each of them is the image rather than this app."
|
||||
fi
|
||||
|
||||
exit "$overall"
|
||||
|
||||
Reference in New Issue
Block a user