diff --git a/.gitattributes b/.gitattributes index dfe0770..9787d19 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,7 @@ # Auto detect text files and perform LF normalization * text=auto + +# Gradle wrapper jar is binary; `* text=auto` would otherwise let Git +# normalize line endings inside the ZIP and strip Main-Class from the +# manifest (CI then fails with "no main manifest attribute"). +/gradle/wrapper/gradle-wrapper.jar binary diff --git a/.github/scripts/dismiss-anr.sh b/.github/scripts/dismiss-anr.sh new file mode 100644 index 0000000..1c5bb18 --- /dev/null +++ b/.github/scripts/dismiss-anr.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Dismiss system ANR dialogs (e.g. "Pixel Launcher isn't responding") via ui dump + tap. +# Maestro optional taps often miss these system windows on CI. +set -eu + +DUMP_REMOTE="/sdcard/window_dump.xml" +DUMP_LOCAL="$(mktemp)" +cleanup() { rm -f "${DUMP_LOCAL}"; } +trap cleanup EXIT + +adb shell uiautomator dump "${DUMP_REMOTE}" >/dev/null 2>&1 || true +adb pull "${DUMP_REMOTE}" "${DUMP_LOCAL}" >/dev/null 2>&1 || true + +if [[ ! -s "${DUMP_LOCAL}" ]]; then + echo "ANR dismiss: no ui dump" + exit 0 +fi + +if ! grep -Eqi "isn't responding|aerr_wait|aerr_close|Application Not Responding" "${DUMP_LOCAL}"; then + echo "ANR dismiss: no ANR dialog in hierarchy" + exit 0 +fi + +echo "ANR dismiss: dialog detected, tapping Wait if present" + +# Prefer android:id/aerr_wait, then text="Wait", then aerr_close (launcher only). +tap_bounds() { + local line="$1" + if [[ "${line}" =~ bounds=\"\[([0-9]+),([0-9]+)\]\[([0-9]+),([0-9]+)\]\" ]]; then + local x=$(( (BASH_REMATCH[1] + BASH_REMATCH[3]) / 2 )) + local y=$(( (BASH_REMATCH[2] + BASH_REMATCH[4]) / 2 )) + echo "ANR dismiss: input tap ${x} ${y}" + adb shell input tap "${x}" "${y}" || true + return 0 + fi + return 1 +} + +line="$(grep -E 'resource-id="android:id/aerr_wait"' "${DUMP_LOCAL}" | head -n 1 || true)" +if [[ -n "${line}" ]] && tap_bounds "${line}"; then + exit 0 +fi + +line="$(grep -E 'text="Wait"' "${DUMP_LOCAL}" | head -n 1 || true)" +if [[ -n "${line}" ]] && tap_bounds "${line}"; then + exit 0 +fi + +# Fallback: DPAD + ENTER (often lands on Wait). +echo "ANR dismiss: falling back to DPAD_DOWN + ENTER" +adb shell input keyevent KEYCODE_DPAD_DOWN || true +adb shell input keyevent KEYCODE_ENTER || true diff --git a/.github/scripts/run-android-e2e.sh b/.github/scripts/run-android-e2e.sh new file mode 100644 index 0000000..ee15603 --- /dev/null +++ b/.github/scripts/run-android-e2e.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Install Maestro, install the debug APK, grant mic, run the smoke orchestrator. +set -eu + +APP_ID="${APP_ID:-org.bibletranslationtools.recorder2}" +APK_PATH="${APK_PATH:-}" +if [[ -z "${APK_PATH}" ]]; then + APK_PATH="$(ls -1 app-recorder/build/outputs/apk/debug/*.apk | head -n 1)" +fi +if [[ -z "${APK_PATH}" || ! -f "${APK_PATH}" ]]; then + echo "Debug APK not found under app-recorder/build/outputs/apk/debug/" >&2 + exit 1 +fi + +echo "Waiting for emulator" +bash .github/scripts/wait-for-emulator-ready.sh + +echo "Installing Maestro CLI" +curl -Ls "https://get.maestro.mobile.dev" | bash +export PATH="${HOME}/.maestro/bin:${PATH}" +maestro --version + +echo "Installing ${APK_PATH}" +adb install -r -t "${APK_PATH}" + +echo "Granting RECORD_AUDIO to ${APP_ID}" +adb shell pm grant "${APP_ID}" android.permission.RECORD_AUDIO || true + +# Re-wake after install; headed CI emulators can still lose focus before Maestro starts. +adb shell input keyevent KEYCODE_WAKEUP || true +adb shell wm dismiss-keyguard 2>/dev/null || true +adb shell settings put global hide_error_dialogs 1 || true +adb shell settings put global anr_show_background 0 || true +bash .github/scripts/dismiss-anr.sh || true + +mkdir -p maestro-results +set +e +# Single orchestrator (like BTT-Writer); --flatten-debug-output keeps artifacts shallow. +maestro test \ + --test-output-dir maestro-results \ + --debug-output maestro-results \ + --flatten-debug-output \ + .maestro/flows/smoke.yaml +STATUS=$? +set -e + +if [[ "${STATUS}" -ne 0 ]]; then + echo "Maestro failed — dumping app logcat (InitializeApp / splash)" + adb logcat -d -t 400 \ + '*:S' \ + 'InitializeApp:V' \ + 'InitializeUlb:V' \ + 'InitializeLanguages:V' \ + 'InitializeSources:V' \ + 'AndroidRuntime:E' \ + 'System.err:W' \ + > maestro-results/logcat-splash.txt 2>&1 || true + adb logcat -d -t 800 > maestro-results/logcat-full.txt 2>&1 || true +fi + +exit "$STATUS" diff --git a/.github/scripts/setup-android-emulator.sh b/.github/scripts/setup-android-emulator.sh new file mode 100644 index 0000000..7d71e22 --- /dev/null +++ b/.github/scripts/setup-android-emulator.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +mkdir -p "${HOME}/.android" +cat > "${HOME}/.android/advancedFeatures.ini" <<'EOF' +Vulkan = off +GLDirectMem = on +EOF + +echo "Configured ${HOME}/.android/advancedFeatures.ini:" +cat "${HOME}/.android/advancedFeatures.ini" diff --git a/.github/scripts/wait-for-emulator-ready.sh b/.github/scripts/wait-for-emulator-ready.sh new file mode 100644 index 0000000..cb77686 --- /dev/null +++ b/.github/scripts/wait-for-emulator-ready.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +adb wait-for-device +adb shell 'until [[ "$(getprop sys.boot_completed)" == "1" ]]; do sleep 2; done' + +# Let system services settle; reduces cold-start ANRs on CI. +sleep 20 + +adb shell input keyevent KEYCODE_WAKEUP +adb shell wm dismiss-keyguard 2>/dev/null || true +# Do NOT send KEYCODE_HOME — that foregrounds Pixel Launcher, which often ANRs on CI +# and leaves "isn't responding" on top of the app under test. + +# Suppress ANR / crash dialogs where the platform honors these globals. +adb shell settings put global hide_error_dialogs 1 || true +adb shell settings put global anr_show_background 0 || true + +bash "$(dirname "$0")/dismiss-anr.sh" || true + +echo "Emulator boot completed and keyguard dismissed" diff --git a/.github/workflows/recorder-android-e2e.yml b/.github/workflows/recorder-android-e2e.yml new file mode 100644 index 0000000..32d20a0 --- /dev/null +++ b/.github/workflows/recorder-android-e2e.yml @@ -0,0 +1,84 @@ +name: Recorder Android E2E + +on: + push: + +concurrency: + group: recorder-android-e2e-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + maestro-smoke: + name: Maestro smoke (API 34 google_apis) + runs-on: ubuntu-latest + timeout-minutes: 90 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + ls -l /dev/kvm + + # Headed emulator (Xvfb): -no-window failed to surface main-menu a11y ("Files") on CI + # even though the same flows pass on a local headed emulator. + - name: Start Xvfb and install emulator deps + run: | + sudo apt-get update + sudo apt-get install -y xvfb libpulse0 + Xvfb :99 -screen 0 1280x800x24 -ac +extension GLX +render -noreset & + echo "DISPLAY=:99" >> "$GITHUB_ENV" + sleep 1 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + # No gradle-wrapper.jar in git (*.jar ignored); install Gradle directly. + gradle-version: 9.3.1 + + - name: Cache en_ulb GL source + uses: actions/cache@v4 + with: + path: shared/src/commonMain/composeResources/files/content/en_ulb.zip + key: en-ulb-${{ hashFiles('shared/src/commonMain/composeResources/files/gl_sources.json') }} + + # Assemble before the emulator so Gradle is not contending with the AVD. + - name: Assemble debug APK + run: gradle :app-recorder:assembleDebug -PminimalGlSources=true --stacktrace + + - name: Run Maestro smoke + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 34 + arch: x86_64 + target: google_apis + profile: pixel_6 + disable-animations: true + enable-hw-keyboard: true + disk-size: 6000M + ram-size: 4096M + heap-size: 512M + emulator-boot-timeout: 900 + pre-emulator-launch-script: bash .github/scripts/setup-android-emulator.sh + # Headed (needs DISPLAY/Xvfb). Extra RAM reduces Pixel Launcher ANRs on CI. + emulator-options: -no-snapshot -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -memory 4096 + script: bash .github/scripts/run-android-e2e.sh + + - name: Upload Maestro results + if: failure() + uses: actions/upload-artifact@v4 + with: + name: recorder-maestro-results + path: maestro-results/ + if-no-files-found: ignore + retention-days: 14 diff --git a/.maestro/flows/dismiss-anr-if-visible.yaml b/.maestro/flows/dismiss-anr-if-visible.yaml new file mode 100644 index 0000000..14be0f5 --- /dev/null +++ b/.maestro/flows/dismiss-anr-if-visible.yaml @@ -0,0 +1,13 @@ +appId: org.bibletranslationtools.recorder2 +--- +# Dismiss "… isn't responding" by tapping Wait (system ANR dialog). +- runFlow: + when: + visible: ".*isn't responding.*" + commands: + - tapOn: + id: "android:id/aerr_wait" + optional: true + - tapOn: + text: "Wait" + optional: true diff --git a/.maestro/flows/shared/create_project_genesis.yaml b/.maestro/flows/shared/create_project_genesis.yaml new file mode 100644 index 0000000..54b672d --- /dev/null +++ b/.maestro/flows/shared/create_project_genesis.yaml @@ -0,0 +1,54 @@ +appId: org.bibletranslationtools.recorder2 +--- +# Files → New Project → English → Afar → Genesis → Project Management. +# Paths are relative to this file (shared/), not the workspace root. +- runFlow: open_files.yaml +- tapOn: "New Project" +- extendedWaitUntil: + visible: "New Project|Select Source" + timeout: 3000 + +# Prefer en_ulb, else English, else search "en". +- runFlow: + when: + visible: ".*en_ulb.*" + commands: + - tapOn: ".*en_ulb.*" +- runFlow: + when: + notVisible: "Choose Target Language" + visible: ".*English.*" + commands: + - tapOn: ".*English.*" +- runFlow: + when: + notVisible: "Choose Target Language" + commands: + - runFlow: + file: search_and_tap.yaml + env: + QUERY: "en" + RESULT: ".*English.*" + +- extendedWaitUntil: + visible: "Choose Target Language" + timeout: 3000 +- runFlow: + file: search_and_tap.yaml + env: + QUERY: "aa" + RESULT: ".*Afar.*" + +- extendedWaitUntil: + visible: "Choose a Book" + timeout: 3000 +- runFlow: + file: search_and_tap.yaml + env: + QUERY: "gen" + RESULT: ".*Genesis.*" + +- extendedWaitUntil: + visible: "Project Management" + timeout: 3000 +- assertVisible: ".*Genesis.*" diff --git a/.maestro/flows/shared/open_files.yaml b/.maestro/flows/shared/open_files.yaml new file mode 100644 index 0000000..976bb45 --- /dev/null +++ b/.maestro/flows/shared/open_files.yaml @@ -0,0 +1,10 @@ +appId: org.bibletranslationtools.recorder2 +--- +# Retry tap — Compose clickables can miss on first gesture (API 34). +- repeat: + times: 20 + while: + notVisible: "Project Management" + commands: + - tapOn: "Files" +- assertVisible: "Project Management" diff --git a/.maestro/flows/shared/search_and_tap.yaml b/.maestro/flows/shared/search_and_tap.yaml new file mode 100644 index 0000000..6142524 --- /dev/null +++ b/.maestro/flows/shared/search_and_tap.yaml @@ -0,0 +1,14 @@ +appId: org.bibletranslationtools.recorder2 +--- +# Env: QUERY (search string), RESULT (regex for row text / contentDescription). +- repeat: + times: 10 + while: + notVisible: "Close search" + commands: + - tapOn: "Search" +- inputText: ${QUERY} +- extendedWaitUntil: + visible: ${RESULT} + timeout: 3000 +- tapOn: ${RESULT} diff --git a/.maestro/flows/shared/wait_main_menu.yaml b/.maestro/flows/shared/wait_main_menu.yaml new file mode 100644 index 0000000..88c43bc --- /dev/null +++ b/.maestro/flows/shared/wait_main_menu.yaml @@ -0,0 +1,8 @@ +appId: org.bibletranslationtools.recorder2 +--- +# Used when already past splash; keep in sync with smoke-launch wait. +- extendedWaitUntil: + visible: "Files" + timeout: 60000 +- assertVisible: "Files" +- assertVisible: "Record" diff --git a/.maestro/flows/smoke-create-project.yaml b/.maestro/flows/smoke-create-project.yaml new file mode 100644 index 0000000..7c45218 --- /dev/null +++ b/.maestro/flows/smoke-create-project.yaml @@ -0,0 +1,6 @@ +appId: org.bibletranslationtools.recorder2 +--- +# CreateProjectWizardFlowTest.createNewProjectViaWizard +# Assumes main menu (or Project Management). Leaves Genesis on Project Management. +- runFlow: dismiss-anr-if-visible.yaml +- runFlow: shared/create_project_genesis.yaml diff --git a/.maestro/flows/smoke-files-settings.yaml b/.maestro/flows/smoke-files-settings.yaml new file mode 100644 index 0000000..b13c055 --- /dev/null +++ b/.maestro/flows/smoke-files-settings.yaml @@ -0,0 +1,28 @@ +appId: org.bibletranslationtools.recorder2 +--- +# MainMenuNavigationFlowTest.filesOpensProjectManagementAndSettings +# Assumes main menu. Leaves app on main menu. +- runFlow: dismiss-anr-if-visible.yaml +- runFlow: shared/open_files.yaml + +- tapOn: "More options" +- extendedWaitUntil: + visible: "Settings" + timeout: 3000 +- tapOn: "Settings" +- extendedWaitUntil: + visible: "Audio" + timeout: 3000 +- assertVisible: "Audio" + +- tapOn: "Back" +- extendedWaitUntil: + visible: "Project Management" + timeout: 3000 +- assertVisible: "Project Management" + +# Project Management has no toolbar back; system back returns to main menu. +- pressKey: back +- extendedWaitUntil: + visible: "Files" + timeout: 3000 diff --git a/.maestro/flows/smoke-launch.yaml b/.maestro/flows/smoke-launch.yaml new file mode 100644 index 0000000..22aeaf2 --- /dev/null +++ b/.maestro/flows/smoke-launch.yaml @@ -0,0 +1,25 @@ +appId: org.bibletranslationtools.recorder2 +--- +# Launch → splash → main menu (Files / Record). +# Files/Record are contentDescriptions (icons), matching instrumented By.desc("Files"). +# +# Maestro retry.maxRetries is capped at 3 — do NOT use a high retry count for splash. +# Cold InitializeApp (langnames + en_ulb) needs a long single wait on CI (see +# waitForMainMenuAfterSplash(240_000) in instrumented tests). +- launchApp: + clearState: true + permissions: + microphone: allow +- runFlow: dismiss-anr-if-visible.yaml +# Runtime permission sheet can still appear despite pm grant + launchApp permissions. +- runFlow: + when: + visible: "While using the app|Allow|ONLY THIS TIME" + commands: + - tapOn: "While using the app|Allow|ONLY THIS TIME" +- extendedWaitUntil: + visible: "Files" + timeout: 240000 +- runFlow: dismiss-anr-if-visible.yaml +- assertVisible: "Files" +- assertVisible: "Record" diff --git a/.maestro/flows/smoke-record-playback.yaml b/.maestro/flows/smoke-record-playback.yaml new file mode 100644 index 0000000..081d294 --- /dev/null +++ b/.maestro/flows/smoke-record-playback.yaml @@ -0,0 +1,51 @@ +appId: org.bibletranslationtools.recorder2 +--- +# SeededRecordPlaybackFlowTest.openRecorderAndEngageTransport +# Assumes Project Management with Genesis project from smoke-create-project. +- runFlow: dismiss-anr-if-visible.yaml + +# Project card mic (right of Genesis title) jumps straight into the recorder. +- tapOn: + text: "Record" + rightOf: ".*Genesis.*" + +- extendedWaitUntil: + visible: "Record transport" + timeout: 3000 +# loadTarget is async; wait for header labels before engaging mic. +- extendedWaitUntil: + visible: ".*Genesis.*|.*ULB.*" + timeout: 3000 + +- tapOn: "Record transport" + +# Soft-pass: Stop may not appear on emulator mic; still require transport present. +- runFlow: + when: + visible: "Stop" + commands: + - tapOn: "Stop" + - extendedWaitUntil: + visible: "Play/Pause" + timeout: 3000 + - assertVisible: "Play/Pause" +- runFlow: + when: + notVisible: "Play/Pause" + commands: + - assertVisible: "Record transport" + +# Leave recorder (mirrors @After teardown). +- runFlow: + when: + visible: "Back" + commands: + - tapOn: "Back" +- runFlow: + when: + visible: "Record transport" + commands: + - pressKey: back +- extendedWaitUntil: + visible: "Files|Project Management|.*Genesis.*" + timeout: 3000 diff --git a/.maestro/flows/smoke-record-without-project.yaml b/.maestro/flows/smoke-record-without-project.yaml new file mode 100644 index 0000000..172bd72 --- /dev/null +++ b/.maestro/flows/smoke-record-without-project.yaml @@ -0,0 +1,17 @@ +appId: org.bibletranslationtools.recorder2 +--- +# MainMenuNavigationFlowTest.recordWithoutActiveProjectGoesToProjectManagement +# Assumes main menu, no active project. Leaves app on main menu. +- runFlow: dismiss-anr-if-visible.yaml +- repeat: + times: 20 + while: + notVisible: "Project Management" + commands: + - tapOn: "Record" +- assertVisible: "Project Management" + +- pressKey: back +- extendedWaitUntil: + visible: "Files" + timeout: 3000 diff --git a/.maestro/flows/smoke.yaml b/.maestro/flows/smoke.yaml new file mode 100644 index 0000000..1d9fc5d --- /dev/null +++ b/.maestro/flows/smoke.yaml @@ -0,0 +1,8 @@ +appId: org.bibletranslationtools.recorder2 +--- +# Single entrypoint for CI: maestro test .maestro/flows/smoke.yaml +- runFlow: smoke-launch.yaml +- runFlow: smoke-files-settings.yaml +- runFlow: smoke-record-without-project.yaml +- runFlow: smoke-create-project.yaml +- runFlow: smoke-record-playback.yaml diff --git a/app-recorder/build.gradle.kts b/app-recorder/build.gradle.kts index 3e9108b..2613c6d 100755 --- a/app-recorder/build.gradle.kts +++ b/app-recorder/build.gradle.kts @@ -81,18 +81,6 @@ kotlin { } } - val androidInstrumentedTest by getting { - dependencies { - implementation(libs.kotlin.test.junit) - implementation(libs.junit) - implementation(libs.androidx.test.junit) - implementation(libs.androidx.test.runner) - implementation(libs.androidx.test.rules) - implementation(libs.androidx.uiautomator) - implementation(libs.koin.android) - } - } - val desktopMain by getting { dependencies { implementation(compose.desktop.currentOs) @@ -112,7 +100,6 @@ android { targetSdk = libs.versions.android.targetSdk.get().toInt() versionCode = 1 versionName = "1.0" - testInstrumentationRunner = "org.bibletranslationtools.recorder2.e2e.RecorderE2ERunner" } packaging { resources { diff --git a/app-recorder/src/androidInstrumentedTest/HANDOFF.md b/app-recorder/src/androidInstrumentedTest/HANDOFF.md deleted file mode 100644 index 14f7fd1..0000000 --- a/app-recorder/src/androidInstrumentedTest/HANDOFF.md +++ /dev/null @@ -1,54 +0,0 @@ -# Android e2e (`:app-recorder`) - -Instrumented UI tests on a real device/emulator: mock audio + Koin via `RecorderTestApplication`, UiAutomator + `ActivityScenarioRule` (not ComposeTestRule). Desktop suite is separate (`desktopTest`). - -## Run - -```bat -gradlew.bat :app-recorder:connectedDebugAndroidTest -PminimalGlSources=true -``` - -One class (FQCN under `…e2e.flow`): - -```bat -gradlew.bat :app-recorder:connectedDebugAndroidTest -PminimalGlSources=true -Pandroid.testInstrumentationRunnerArguments.class= -``` - -Desktop suite: - -```bat -gradlew.bat :app-recorder:desktopTest -PminimalGlSources=true -``` - -Logs: tag `RecorderE2E`. Flow suites live in `e2e/flow/` (independent; no fixed order). JDK 17/21 fine. - -## Critical rule - -**Use `ActivityScenarioRule` + UiAutomator only.** `ComposeTestRule` owns the frame clock — taps show ripple but navigation never applies. - -MainMenu Record / project open navigate on the click path (no `scope.launch { navState.first() }`). Reintroducing a deferred prefs await can leave home stuck under instrumented idling. - -## Layout - -``` -e2e/ Runner, TestApplication, AndroidUiTestHelpers, E2eLog -e2e/harness/ mockAudioModule, seedGenesisProject() -e2e/flow/ Scenario / flow test classes -``` - -Runner: `RecorderE2ERunner` → `RecorderTestApplication` (Koin + mock audio). - -## Config - -| Property | Effect | -|----------|--------| -| `leaveApksInstalledAfterRun=true` | Keep app installed after tests (currently in root `gradle.properties`) | -| `-PminimalGlSources=true` | Download/list only `en_ulb` (pass on the Gradle command; not a repo default) | - -Zips under `shared/.../files/content/` are gitignored; extras on disk still pack into the APK — delete them for a truly minimal bundle. Omit `-PminimalGlSources` for a full multi-language build. - -## Open items - -- Android instrumented e2e not in CI yet (desktop suite: `.github/workflows/recorder-desktop-e2e.yml`) -- Record/playback soft-passes if Stop never appears -- Device DB persists across runs diff --git a/app-recorder/src/androidInstrumentedTest/kotlin/org/bibletranslationtools/recorder2/e2e/AndroidUiTestHelpers.kt b/app-recorder/src/androidInstrumentedTest/kotlin/org/bibletranslationtools/recorder2/e2e/AndroidUiTestHelpers.kt deleted file mode 100644 index 67d74f5..0000000 --- a/app-recorder/src/androidInstrumentedTest/kotlin/org/bibletranslationtools/recorder2/e2e/AndroidUiTestHelpers.kt +++ /dev/null @@ -1,215 +0,0 @@ -package org.bibletranslationtools.recorder2.e2e - -import android.os.SystemClock -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.uiautomator.By -import androidx.test.uiautomator.UiDevice -import androidx.test.uiautomator.Until -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking -import org.junit.Assert.assertTrue - -/** - * UiAutomator helpers for recorder Android e2e. - * - * Prefer [ActivityScenarioRule] over ComposeTestRule: the latter replaces the frame clock so - * taps show ripple but NavHost never recomposes (unlike a normal installDebug launch). - * - * App navigation is sync on the click path (collected nav state, navigate before prefs I/O). - * Do not reintroduce `scope.launch { navState.first() }` for MainMenu Record — under an - * instrumented test dispatcher / idling that coroutine often never runs and home stays stuck. - */ - -internal fun waitForMainMenuAfterSplash(timeoutMillis: Long = 120_000) { - E2eLog.step("WAIT splash→main menu (timeout=${timeoutMillis}ms)") - val device = uiDevice() - val deadline = SystemClock.uptimeMillis() + timeoutMillis - while (SystemClock.uptimeMillis() < deadline) { - if (device.hasObject(By.desc("Files")) && device.hasObject(By.desc("Record"))) { - E2eLog.step("FOUND Files+Record (main menu)") - return - } - SystemClock.sleep(50) - } - assertTrue("Timed out waiting for main menu after splash (Files + Record)", false) -} - -internal fun waitForText(text: String, timeoutMillis: Long = 60_000) { - E2eLog.step("WAIT text=\"$text\" (timeout=${timeoutMillis}ms)") - assertTrue( - "Timed out waiting for text: $text", - uiDevice().wait(Until.hasObject(By.text(text)), timeoutMillis) - ) - E2eLog.step("FOUND text=\"$text\"") -} - -internal fun waitForTextContains(substring: String, timeoutMillis: Long = 60_000) { - E2eLog.step("WAIT textContains=\"$substring\" (timeout=${timeoutMillis}ms)") - assertTrue( - "Timed out waiting for text containing: $substring", - uiDevice().wait(Until.hasObject(By.textContains(substring)), timeoutMillis) - ) - E2eLog.step("FOUND textContains=\"$substring\"") -} - -internal fun waitForContentDescription(label: String, timeoutMillis: Long = 120_000) { - E2eLog.step("WAIT contentDescription=\"$label\" (timeout=${timeoutMillis}ms)") - assertTrue( - "Timed out waiting for content description: $label", - uiDevice().wait(Until.hasObject(By.desc(label)), timeoutMillis) - ) - E2eLog.step("FOUND contentDescription=\"$label\"") -} - -internal fun clickContentDescription(label: String) { - E2eLog.step("CLICK contentDescription=\"$label\"") - val device = uiDevice() - assertTrue( - "No node with contentDescription \"$label\" to click", - device.wait(Until.hasObject(By.desc(label)), 5_000) - ) - device.findObject(By.desc(label)).click() -} - -internal fun clickText(text: String) { - E2eLog.step("CLICK text=\"$text\"") - val device = uiDevice() - assertTrue( - "No node with text \"$text\" to click", - device.wait(Until.hasObject(By.text(text)), 5_000) - ) - device.findObject(By.text(text)).click() -} - -internal fun clickTextContains(substring: String) { - E2eLog.step("CLICK textContains=\"$substring\"") - val device = uiDevice() - assertTrue( - "No node with text containing \"$substring\" to click", - device.wait(Until.hasObject(By.textContains(substring)), 5_000) - ) - device.findObject(By.textContains(substring)).click() -} - -internal fun assertDisplayedContentDescription(label: String) { - E2eLog.step("ASSERT contentDescription=\"$label\"") - assertTrue( - "Expected contentDescription \"$label\" on screen", - uiDevice().hasObject(By.desc(label)) - ) -} - -internal fun assertDisplayedText(text: String) { - E2eLog.step("ASSERT text=\"$text\"") - assertTrue( - "Expected text \"$text\" on screen", - uiDevice().hasObject(By.text(text)) - ) -} - -internal fun waitForActiveWorkbook(timeoutMillis: Long = 60_000) { - E2eLog.step("WAIT prefs.hasActiveWorkbook (timeout=${timeoutMillis}ms)") - val prefs = org.koin.core.context.GlobalContext.get() - .get() - val deadline = SystemClock.uptimeMillis() + timeoutMillis - while (SystemClock.uptimeMillis() < deadline) { - val nav = runBlocking { prefs.navState.first() } - if (nav.hasActiveWorkbook) { - E2eLog.step( - "FOUND active workbook source=${nav.workbookSourceId} target=${nav.workbookTargetId} " + - "chapter=${nav.chapterSort}" - ) - return - } - SystemClock.sleep(100) - } - assertTrue("Timed out waiting for active workbook in app preferences", false) -} - -/** MainMenu only shows book/language labels once uiState.hasActiveProject is true. */ -internal fun waitForActiveProjectOnMainMenu(timeoutMillis: Long = 60_000) { - E2eLog.step("WAIT main-menu active project labels (timeout=${timeoutMillis}ms)") - val device = uiDevice() - val deadline = SystemClock.uptimeMillis() + timeoutMillis - while (SystemClock.uptimeMillis() < deadline) { - if (device.hasObject(By.textContains("Genesis")) || device.hasObject(By.textContains("Afar"))) { - E2eLog.step("FOUND active project labels on main menu") - return - } - SystemClock.sleep(100) - } - assertTrue( - "Timed out waiting for Genesis/Afar on main menu (active project UI not ready)", - false - ) -} - -/** - * After tapping home Record: either the recorder opens, or we landed on Project Management - * because nav state had no active workbook (common race right after recreate). - */ -internal fun waitForRecorderTransportOrFail(timeoutMillis: Long = 120_000) { - E2eLog.step("WAIT recorder transport or Project Management (timeout=${timeoutMillis}ms)") - val device = uiDevice() - val deadline = SystemClock.uptimeMillis() + timeoutMillis - while (SystemClock.uptimeMillis() < deadline) { - if (device.hasObject(By.desc("Record transport"))) { - E2eLog.step("FOUND contentDescription=\"Record transport\"") - return - } - if (device.hasObject(By.text("Project Management"))) { - assertTrue( - "Home Record opened Project Management instead of the recorder — " + - "active workbook was not ready in MainMenu nav state", - false - ) - } - SystemClock.sleep(100) - } - assertTrue("Timed out waiting for content description: Record transport", false) -} - -/** - * [RecorderViewModel.loadTarget] is async; [RecorderViewModel.startRecording] no-ops while - * `associatedAudio` is still null. Wait for the seeded project's header labels so the - * transport mic click actually engages recording. - */ -internal fun waitForRecorderTargetLoaded(timeoutMillis: Long = 60_000) { - E2eLog.step("WAIT recorder target labels (timeout=${timeoutMillis}ms)") - val device = uiDevice() - val deadline = SystemClock.uptimeMillis() + timeoutMillis - while (SystemClock.uptimeMillis() < deadline) { - // Header is "ULB Genesis" (source identifier + target book label) once switchToTarget runs. - if (device.hasObject(By.textContains("Genesis")) || - device.hasObject(By.textContains("ULB")) - ) { - E2eLog.step("FOUND recorder target labels") - return - } - SystemClock.sleep(100) - } - assertTrue("Timed out waiting for recorder target labels (Genesis/ULB)", false) -} - -/** - * Opens the wizard search field, types [query], then taps a row matching [resultSubstring]. - */ -internal fun searchAndClickResult( - query: String, - resultSubstring: String, - timeoutMillis: Long = 60_000, -) { - E2eLog.step("SEARCH query=\"$query\" then click textContains=\"$resultSubstring\"") - clickContentDescription("Search") - val device = uiDevice() - assertTrue( - "Wizard search EditText not found", - device.wait(Until.hasObject(By.clazz("android.widget.EditText")), 10_000) - ) - device.findObject(By.clazz("android.widget.EditText")).text = query - waitForTextContains(resultSubstring, timeoutMillis) - clickTextContains(resultSubstring) -} - -internal fun uiDevice(): UiDevice = - UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) diff --git a/app-recorder/src/androidInstrumentedTest/kotlin/org/bibletranslationtools/recorder2/e2e/E2eLog.kt b/app-recorder/src/androidInstrumentedTest/kotlin/org/bibletranslationtools/recorder2/e2e/E2eLog.kt deleted file mode 100644 index d4f9b3f..0000000 --- a/app-recorder/src/androidInstrumentedTest/kotlin/org/bibletranslationtools/recorder2/e2e/E2eLog.kt +++ /dev/null @@ -1,17 +0,0 @@ -package org.bibletranslationtools.recorder2.e2e - -import android.util.Log - -/** - * Step logging for Android instrumented e2e. Goes to logcat ([TAG]) and instrumentation - * stdout so Gradle `connectedDebugAndroidTest` output shows the action trail. - */ -internal object E2eLog { - const val TAG = "RecorderE2E" - - fun step(message: String) { - val line = "[$TAG] $message" - Log.i(TAG, message) - println(line) - } -} diff --git a/app-recorder/src/androidInstrumentedTest/kotlin/org/bibletranslationtools/recorder2/e2e/RecorderE2ERunner.kt b/app-recorder/src/androidInstrumentedTest/kotlin/org/bibletranslationtools/recorder2/e2e/RecorderE2ERunner.kt deleted file mode 100644 index fcdcf03..0000000 --- a/app-recorder/src/androidInstrumentedTest/kotlin/org/bibletranslationtools/recorder2/e2e/RecorderE2ERunner.kt +++ /dev/null @@ -1,19 +0,0 @@ -package org.bibletranslationtools.recorder2.e2e - -import android.app.Application -import android.content.Context -import androidx.test.runner.AndroidJUnitRunner - -/** - * Swaps in [RecorderTestApplication] so instrumented e2e runs with mock audio and primed init - * instead of the production [org.bibletranslationtools.recorder2.Application]. - */ -class RecorderE2ERunner : AndroidJUnitRunner() { - override fun newApplication( - cl: ClassLoader?, - className: String?, - context: Context?, - ): Application { - return super.newApplication(cl, RecorderTestApplication::class.java.name, context) - } -} diff --git a/app-recorder/src/androidInstrumentedTest/kotlin/org/bibletranslationtools/recorder2/e2e/RecorderTestApplication.kt b/app-recorder/src/androidInstrumentedTest/kotlin/org/bibletranslationtools/recorder2/e2e/RecorderTestApplication.kt deleted file mode 100644 index 2f76c4b..0000000 --- a/app-recorder/src/androidInstrumentedTest/kotlin/org/bibletranslationtools/recorder2/e2e/RecorderTestApplication.kt +++ /dev/null @@ -1,48 +0,0 @@ -package org.bibletranslationtools.recorder2.e2e - -import android.app.Application -import org.bibletranslationtools.bttrecorder2.di.koin.recorderViewModelModule -import org.bibletranslationtools.di.koin.androidContextModule -import org.bibletranslationtools.di.koin.directoryProviderModule -import org.bibletranslationtools.otter.common.device.newaudio.AudioDeviceSelector -import org.bibletranslationtools.otter.common.device.newaudio.AudioSpec -import org.bibletranslationtools.otter.common.device.newaudio.AudioSystemConfig -import org.bibletranslationtools.recorder2.e2e.harness.RecorderAndroidUiTestHarness -import org.bibletranslationtools.shared.di.koin.appDatabaseModule -import org.bibletranslationtools.shared.di.koin.sharedCommonModules -import org.koin.android.ext.koin.androidContext -import org.koin.android.ext.koin.androidLogger -import org.koin.core.context.GlobalContext -import org.koin.core.context.startKoin - -/** - * Production-like Koin graph for Android e2e with mock audio (no mic/speakers). App init stays on - * the splash path so [Application.onCreate] does not block the main thread with - * [org.bibletranslationtools.otter.common.initialization.InitializeApp]. - */ -class RecorderTestApplication : Application() { - override fun onCreate() { - super.onCreate() - - startKoin { - androidLogger() - androidContext(this@RecorderTestApplication) - modules( - sharedCommonModules + - listOf(appDatabaseModule) + - androidContextModule + - directoryProviderModule + - recorderViewModelModule + - RecorderAndroidUiTestHarness.mockAudioModule - ) - } - - val koin = GlobalContext.get() - val config = koin.get() - val selector = koin.get() - val spec = AudioSpec() - config.start() - selector.getOutputDevices(spec).firstOrNull()?.let(selector::selectOutputDevice) - selector.getInputDevices(spec).firstOrNull()?.let(selector::selectInputDevice) - } -} diff --git a/app-recorder/src/androidInstrumentedTest/kotlin/org/bibletranslationtools/recorder2/e2e/flow/CreateProjectWizardFlowTest.kt b/app-recorder/src/androidInstrumentedTest/kotlin/org/bibletranslationtools/recorder2/e2e/flow/CreateProjectWizardFlowTest.kt deleted file mode 100644 index 92a220e..0000000 --- a/app-recorder/src/androidInstrumentedTest/kotlin/org/bibletranslationtools/recorder2/e2e/flow/CreateProjectWizardFlowTest.kt +++ /dev/null @@ -1,113 +0,0 @@ -package org.bibletranslationtools.recorder2.e2e.flow - -import android.Manifest -import android.os.SystemClock -import androidx.test.ext.junit.rules.ActivityScenarioRule -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.rule.GrantPermissionRule -import androidx.test.uiautomator.By -import kotlinx.coroutines.runBlocking -import org.bibletranslationtools.recorder2.MainActivity -import org.bibletranslationtools.recorder2.e2e.E2eLog -import org.bibletranslationtools.recorder2.e2e.clickContentDescription -import org.bibletranslationtools.recorder2.e2e.clickTextContains -import org.bibletranslationtools.recorder2.e2e.searchAndClickResult -import org.bibletranslationtools.recorder2.e2e.uiDevice -import org.bibletranslationtools.recorder2.e2e.waitForMainMenuAfterSplash -import org.bibletranslationtools.recorder2.e2e.waitForText -import org.bibletranslationtools.recorder2.e2e.waitForTextContains -import org.bibletranslationtools.shared.preferences.IAppPreferences -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.koin.core.context.GlobalContext - -/** - * Launch → splash → home → Files → New Project → English → Afar → Genesis → Project Management. - * - * Uses [ActivityScenarioRule] (not ComposeTestRule) so navigation matches a normal install. - */ -@RunWith(AndroidJUnit4::class) -class CreateProjectWizardFlowTest { - - @get:Rule(order = 0) - val permissionRule: GrantPermissionRule = - GrantPermissionRule.grant(Manifest.permission.RECORD_AUDIO) - - @get:Rule(order = 1) - val activityRule = ActivityScenarioRule(MainActivity::class.java) - - @Before - fun clearActiveWorkbook() { - E2eLog.step("SETUP clear active workbook + recreate") - waitForMainMenuAfterSplash() - runBlocking { - GlobalContext.get().get().clearActiveWorkbook() - } - activityRule.scenario.recreate() - waitForMainMenuAfterSplash() - E2eLog.step("SETUP done") - } - - @Test - fun createNewProjectViaWizard() { - E2eLog.step("TEST createNewProjectViaWizard start") - waitForMainMenuAfterSplash() - - clickContentDescription("Files") - waitForText("Project Management", timeoutMillis = 30_000) - - clickContentDescription("New Project") - waitForWizardSourceStep() - - selectEnglishSource() - waitForText("Choose Target Language", timeoutMillis = 120_000) - - searchAndClickResult(query = "aa", resultSubstring = "Afar", timeoutMillis = 120_000) - waitForText("Choose a Book", timeoutMillis = 120_000) - - searchAndClickResult(query = "gen", resultSubstring = "Genesis", timeoutMillis = 120_000) - - waitForText("Project Management", timeoutMillis = 180_000) - waitForTextContains("Genesis", timeoutMillis = 60_000) - E2eLog.step("TEST createNewProjectViaWizard done") - } - - private fun waitForWizardSourceStep(timeoutMillis: Long = 60_000) { - E2eLog.step("WAIT wizard source step (timeout=${timeoutMillis}ms)") - val device = uiDevice() - val deadline = SystemClock.uptimeMillis() + timeoutMillis - while (SystemClock.uptimeMillis() < deadline) { - if (device.hasObject(By.text("Select Source")) || - device.hasObject(By.text("New Project")) - ) { - E2eLog.step("FOUND wizard source / New Project title") - return - } - SystemClock.sleep(100) - } - assertTrue("Timed out waiting for New Project wizard", false) - } - - private fun selectEnglishSource() { - E2eLog.step("SELECT English / en_ulb source") - val device = uiDevice() - val deadline = SystemClock.uptimeMillis() + 15_000 - while (SystemClock.uptimeMillis() < deadline) { - when { - device.hasObject(By.textContains("en_ulb")) -> { - clickTextContains("en_ulb") - return - } - device.hasObject(By.textContains("English")) -> { - clickTextContains("English") - return - } - } - SystemClock.sleep(100) - } - searchAndClickResult(query = "en", resultSubstring = "English", timeoutMillis = 120_000) - } -} diff --git a/app-recorder/src/androidInstrumentedTest/kotlin/org/bibletranslationtools/recorder2/e2e/flow/MainMenuNavigationFlowTest.kt b/app-recorder/src/androidInstrumentedTest/kotlin/org/bibletranslationtools/recorder2/e2e/flow/MainMenuNavigationFlowTest.kt deleted file mode 100644 index 58243fc..0000000 --- a/app-recorder/src/androidInstrumentedTest/kotlin/org/bibletranslationtools/recorder2/e2e/flow/MainMenuNavigationFlowTest.kt +++ /dev/null @@ -1,85 +0,0 @@ -package org.bibletranslationtools.recorder2.e2e.flow - -import android.Manifest -import androidx.test.ext.junit.rules.ActivityScenarioRule -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.rule.GrantPermissionRule -import kotlinx.coroutines.runBlocking -import org.bibletranslationtools.recorder2.MainActivity -import org.bibletranslationtools.recorder2.e2e.E2eLog -import org.bibletranslationtools.recorder2.e2e.assertDisplayedContentDescription -import org.bibletranslationtools.recorder2.e2e.assertDisplayedText -import org.bibletranslationtools.recorder2.e2e.clickContentDescription -import org.bibletranslationtools.recorder2.e2e.clickText -import org.bibletranslationtools.recorder2.e2e.waitForMainMenuAfterSplash -import org.bibletranslationtools.recorder2.e2e.waitForText -import org.bibletranslationtools.shared.preferences.IAppPreferences -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.koin.core.context.GlobalContext - -/** - * Splash → home, Files → Settings, and Record-with-no-project → Project Management. - */ -@RunWith(AndroidJUnit4::class) -class MainMenuNavigationFlowTest { - - @get:Rule(order = 0) - val permissionRule: GrantPermissionRule = - GrantPermissionRule.grant(Manifest.permission.RECORD_AUDIO) - - @get:Rule(order = 1) - val activityRule = ActivityScenarioRule(MainActivity::class.java) - - @Before - fun clearActiveWorkbook() { - E2eLog.step("SETUP clear active workbook + recreate") - waitForMainMenuAfterSplash() - runBlocking { - GlobalContext.get().get().clearActiveWorkbook() - } - E2eLog.step("SETUP recreate activity") - activityRule.scenario.recreate() - waitForMainMenuAfterSplash() - E2eLog.step("SETUP done") - } - - @Test - fun mainMenuVisibleAfterSplash() { - E2eLog.step("TEST mainMenuVisibleAfterSplash start") - waitForMainMenuAfterSplash() - assertDisplayedContentDescription("Files") - assertDisplayedContentDescription("Record") - E2eLog.step("TEST mainMenuVisibleAfterSplash done") - } - - @Test - fun filesOpensProjectManagementAndSettings() { - E2eLog.step("TEST filesOpensProjectManagementAndSettings start") - waitForMainMenuAfterSplash() - clickContentDescription("Files") - waitForText("Project Management", timeoutMillis = 30_000) - - clickContentDescription("More options") - waitForText("Settings", timeoutMillis = 15_000) - clickText("Settings") - waitForText("Audio", timeoutMillis = 30_000) - assertDisplayedText("Audio") - - clickContentDescription("Back") - waitForText("Project Management", timeoutMillis = 30_000) - E2eLog.step("TEST filesOpensProjectManagementAndSettings done") - } - - @Test - fun recordWithoutActiveProjectGoesToProjectManagement() { - E2eLog.step("TEST recordWithoutActiveProjectGoesToProjectManagement start") - waitForMainMenuAfterSplash() - clickContentDescription("Record") - waitForText("Project Management", timeoutMillis = 60_000) - assertDisplayedText("Project Management") - E2eLog.step("TEST recordWithoutActiveProjectGoesToProjectManagement done") - } -} diff --git a/app-recorder/src/androidInstrumentedTest/kotlin/org/bibletranslationtools/recorder2/e2e/flow/SeededRecordPlaybackFlowTest.kt b/app-recorder/src/androidInstrumentedTest/kotlin/org/bibletranslationtools/recorder2/e2e/flow/SeededRecordPlaybackFlowTest.kt deleted file mode 100644 index 407f8ff..0000000 --- a/app-recorder/src/androidInstrumentedTest/kotlin/org/bibletranslationtools/recorder2/e2e/flow/SeededRecordPlaybackFlowTest.kt +++ /dev/null @@ -1,112 +0,0 @@ -package org.bibletranslationtools.recorder2.e2e.flow - -import android.Manifest -import android.os.SystemClock -import androidx.test.ext.junit.rules.ActivityScenarioRule -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.rule.GrantPermissionRule -import androidx.test.uiautomator.By -import androidx.test.uiautomator.Until -import org.bibletranslationtools.recorder2.MainActivity -import org.bibletranslationtools.recorder2.e2e.E2eLog -import org.bibletranslationtools.recorder2.e2e.clickContentDescription -import org.bibletranslationtools.recorder2.e2e.clickText -import org.bibletranslationtools.recorder2.e2e.harness.RecorderAndroidUiTestHarness -import org.bibletranslationtools.recorder2.e2e.uiDevice -import org.bibletranslationtools.recorder2.e2e.waitForActiveProjectOnMainMenu -import org.bibletranslationtools.recorder2.e2e.waitForActiveWorkbook -import org.bibletranslationtools.recorder2.e2e.waitForContentDescription -import org.bibletranslationtools.recorder2.e2e.waitForMainMenuAfterSplash -import org.bibletranslationtools.recorder2.e2e.waitForRecorderTargetLoaded -import org.bibletranslationtools.recorder2.e2e.waitForRecorderTransportOrFail -import org.junit.After -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -/** - * Seed Afar Genesis in-process → home with active project → Record → engage transport. - */ -@RunWith(AndroidJUnit4::class) -class SeededRecordPlaybackFlowTest { - - @get:Rule(order = 0) - val permissionRule: GrantPermissionRule = - GrantPermissionRule.grant(Manifest.permission.RECORD_AUDIO) - - @get:Rule(order = 1) - val activityRule = ActivityScenarioRule(MainActivity::class.java) - - @Before - fun seedProject() { - E2eLog.step("SETUP seed Genesis + recreate activity") - waitForMainMenuAfterSplash() - RecorderAndroidUiTestHarness.seedGenesisProject() - waitForActiveWorkbook() - E2eLog.step("SETUP recreate activity") - activityRule.scenario.recreate() - waitForMainMenuAfterSplash() - waitForActiveWorkbook() - // Give MainMenu collectAsState a moment to observe the active project. - val deadline = SystemClock.uptimeMillis() + 5_000 - while (SystemClock.uptimeMillis() < deadline) { - if (uiDevice().hasObject(By.textContains("Genesis")) || - uiDevice().hasObject(By.textContains("Afar")) - ) { - break - } - SystemClock.sleep(50) - } - waitForActiveProjectOnMainMenu() - E2eLog.step("SETUP done") - } - - @After - fun leaveRecorderBeforeTeardown() { - runCatching { - val device = uiDevice() - if (device.hasObject(By.desc("Record transport"))) { - E2eLog.step("TEARDOWN leave recorder") - if (device.hasObject(By.desc("Back"))) { - clickContentDescription("Back") - } else { - E2eLog.step("TEARDOWN pressBack()") - device.pressBack() - } - E2eLog.step("TEARDOWN wait for Files") - device.wait(Until.hasObject(By.desc("Files")), 30_000) - } - } - } - - @Test - fun openRecorderAndEngageTransport() { - E2eLog.step("TEST openRecorderAndEngageTransport start") - waitForMainMenuAfterSplash() - waitForActiveWorkbook() - waitForActiveProjectOnMainMenu() - clickContentDescription("Record") - - waitForRecorderTransportOrFail(timeoutMillis = 120_000) - // Transport is visible before loadTarget finishes; startRecording no-ops until then. - waitForRecorderTargetLoaded() - clickContentDescription("Record transport") - - val device = uiDevice() - E2eLog.step("WAIT text=\"Stop\" (timeout=15000ms)") - val stopAppeared = device.wait(Until.hasObject(By.text("Stop")), 15_000) - if (stopAppeared) { - E2eLog.step("FOUND text=\"Stop\"") - clickText("Stop") - waitForContentDescription("Play/Pause", timeoutMillis = 120_000) - E2eLog.step("ASSERT has Play/Pause") - assertTrue(device.hasObject(By.desc("Play/Pause"))) - } else { - E2eLog.step("MISS text=\"Stop\" — assert Record transport still present") - assertTrue(device.hasObject(By.desc("Record transport"))) - } - E2eLog.step("TEST openRecorderAndEngageTransport done") - } -} diff --git a/app-recorder/src/androidInstrumentedTest/kotlin/org/bibletranslationtools/recorder2/e2e/harness/RecorderAndroidUiTestHarness.kt b/app-recorder/src/androidInstrumentedTest/kotlin/org/bibletranslationtools/recorder2/e2e/harness/RecorderAndroidUiTestHarness.kt deleted file mode 100644 index 48a5d2b..0000000 --- a/app-recorder/src/androidInstrumentedTest/kotlin/org/bibletranslationtools/recorder2/e2e/harness/RecorderAndroidUiTestHarness.kt +++ /dev/null @@ -1,158 +0,0 @@ -package org.bibletranslationtools.recorder2.e2e.harness - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.runBlocking -import org.bibletranslationtools.otter.common.device.newaudio.AudioDevice -import org.bibletranslationtools.otter.common.device.newaudio.AudioDeviceSelector -import org.bibletranslationtools.otter.common.device.newaudio.AudioHardwareProvider -import org.bibletranslationtools.otter.common.device.newaudio.AudioSink -import org.bibletranslationtools.otter.common.device.newaudio.AudioSource -import org.bibletranslationtools.otter.common.device.newaudio.AudioSpec -import org.koin.core.context.GlobalContext -import org.koin.dsl.module - -/** - * Android instrumented e2e helpers: mock audio Koin module (used by [org.bibletranslationtools.recorder2.e2e.RecorderTestApplication]) - * and Genesis project seeding (parity with desktop [org.bibletranslationtools.bttrecorder2.e2e.harness.RecorderUiTestHarness]). - */ -object RecorderAndroidUiTestHarness { - - private val mockSource = MockAudioSource() - private val mockSink = MockAudioSink() - private val mockSelector = MockAudioDeviceSelector() - - val mockAudioModule = module { - single { mockSource } - single { mockSink } - single { mockSelector } - single { - object : AudioHardwareProvider { - override fun createSink(device: AudioDevice): AudioSink = mockSink - override fun createSource(device: AudioDevice): AudioSource = mockSource - } - } - } - - /** - * Creates an Afar Genesis narration project from the seeded English ULB source and marks it - * active so MainMenu Record opens the recorder directly. - */ - fun seedGenesisProject() { - val koin = GlobalContext.get() - val createProject = - koin.get() - val createTranslation = - koin.get() - val collectionRepository = - koin.get() - val languageRepository = - koin.get() - val resourceMetadataRepository = - koin.get() - val appPreferences = koin.get() - - val target = languageRepository.getAll().blockingGet().first { it.slug == "aa" } - val sourceMeta = resourceMetadataRepository.getAllSources().blockingGet() - .first { it.language.slug == "en" } - val sourceLang = sourceMeta.language - val root = collectionRepository.getRootSources().blockingGet() - .first { it.resourceContainer?.id == sourceMeta.id } - val gen = collectionRepository.getChildren(root).blockingGet().first { it.slug == "gen" } - val targetBook = createProject.create( - sourceProject = gen, - targetLanguage = target, - mode = org.bibletranslationtools.otter.common.data.primitives.ProjectMode.NARRATION, - deriveProjectFromVerses = true - ).blockingGet() - // Translation row may already exist from a prior instrumented run on the same device. - runCatching { createTranslation.create(sourceLang, target).blockingGet() } - runBlocking { - appPreferences.setActiveWorkbook(gen.id, targetBook.id) - // Prefer a concrete chapter so MainMenu / recorder resolve a real target quickly. - appPreferences.setActiveChapter(1) - } - } -} - -internal class MockAudioSource : AudioSource { - var isOpen = false - var isStarted = false - - override fun open(spec: AudioSpec) { - isOpen = true - } - - override fun start() { - isStarted = true - } - - override fun stop() { - isStarted = false - } - - override fun close() { - isOpen = false - } - - override fun read(data: ByteArray, offset: Int, size: Int): Int { - if (!isStarted) return 0 - for (i in 0 until size) data[offset + i] = (i % 128).toByte() - return size - } -} - -internal class MockAudioSink : AudioSink { - var isOpen = false - var isStarted = false - var bytesWritten = 0 - override var framePosition: Long = 0 - override var isRunning = false - - override fun open(spec: AudioSpec) { - isOpen = true - } - - override fun write(data: ByteArray, offset: Int, size: Int): Int { - bytesWritten += size - framePosition += (size / 2).toLong() - return size - } - - override fun drain() {} - override fun flush() {} - override fun close() { - isOpen = false - } - - override fun start() { - isStarted = true - isRunning = true - } - - override fun stop() { - isStarted = false - isRunning = false - } -} - -internal class MockAudioDeviceSelector : AudioDeviceSelector { - private val input = AudioDevice(id = "mock-in", name = "Mock Input", type = AudioDevice.DeviceType.INPUT) - private val output = AudioDevice(id = "mock-out", name = "Mock Output", type = AudioDevice.DeviceType.OUTPUT) - private val _activeOut = MutableStateFlow(output) - private val _activeIn = MutableStateFlow(input) - - override val activeOutputDevice: Flow = _activeOut.asStateFlow() - override val activeInputDevice: Flow = _activeIn.asStateFlow() - - override fun getOutputDevices(spec: AudioSpec): List = listOf(output) - override fun getInputDevices(spec: AudioSpec): List = listOf(input) - override fun selectOutputDevice(device: AudioDevice?) { - _activeOut.value = device - } - - override fun selectInputDevice(device: AudioDevice?) { - _activeIn.value = device - } -} diff --git a/gradle.properties b/gradle.properties index 4e06801..8d2834d 100755 --- a/gradle.properties +++ b/gradle.properties @@ -8,8 +8,6 @@ org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8 #Android android.nonTransitiveRClass=true android.useAndroidX=true -# Keep app + androidTest APKs installed after connected*AndroidTest (for post-run inspection). -android.injected.androidTest.leaveApksInstalledAfterRun=true # AGP 9 dropped built-in compatibility with the Kotlin Multiplatform plugin when using the # com.android.application/library plugins; bypass its built-in Kotlin + new DSL so the existing # KMP setup keeps working (https://developer.android.com/build/releases/gradle-plugin). diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 734aa96..6fe42a5 100755 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,17 +7,11 @@ androidx-activityCompose = "1.13.0" androidx-appcompat = "1.7.1" androidx-constraintlayout = "2.2.1" androidx-core-ktx = "1.19.0" -androidx-espresso-core = "3.7.0" androidx-lifecycle = "2.11.0" androidx-navigation = "2.9.2" sentry = "8.50.0" androidx-material = "1.14.0" -androidx-test-junit = "1.3.0" -androidx-test-runner = "1.7.0" -androidx-test-rules = "1.7.0" -androidx-uiautomator = "2.3.0" compose-multiplatform = "1.11.1" -junit = "4.13.2" kotlin = "2.4.10" kotlinx-coroutines = "1.11.0" sqldroid = "1.0.3" @@ -37,13 +31,7 @@ datastore = "1.2.1" sentry = { group = "io.sentry", name = "sentry", version.ref = "sentry" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } -junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" } -androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-junit" } -androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidx-test-runner" } -androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidx-test-rules" } -androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "androidx-uiautomator" } -androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidx-espresso-core" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" } androidx-material = { group = "com.google.android.material", name = "material", version.ref = "androidx-material" } androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "androidx-constraintlayout" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 2c35211..61285a6 100755 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradlew b/gradlew index f5feea6..adff685 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015-2021 the original authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -86,8 +86,7 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s -' "$PWD" ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -115,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -173,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -206,15 +203,14 @@ fi DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. diff --git a/gradlew.bat b/gradlew.bat index 9d21a21..c4bdd3a 100755 --- a/gradlew.bat +++ b/gradlew.bat @@ -70,11 +70,10 @@ goto fail :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell