Merge main into test-221-cold-open-encrypted-cache

This commit is contained in:
Jason Ross
2026-07-03 23:31:47 -05:00
committed by GitHub
8 changed files with 895 additions and 0 deletions
@@ -0,0 +1,43 @@
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
# `local_instrumented.sh` — reliable local instrumented/E2E runs
A helper for running LibreMail's instrumented / E2E tests **locally** without Gradle
Managed Devices (GMD). Companion to `api37_e2e.py`; born from issue #269.
## Usage
```bash
# from the repo root, in Git Bash:
.claude/skills/preflight/local_instrumented.sh org.libremail.ui.compose.ComposeScreenE2ETest
# multiple classes (comma-separated, no spaces):
.claude/skills/preflight/local_instrumented.sh org.libremail.a.FooTest,org.libremail.b.BarTest
```
It cold-boots **one** emulator (`-no-snapshot`, no GMD), waits for `sys.boot_completed`,
runs `:app:connectedDebugAndroidTest` filtered to the class(es) you pass, then tears the
emulator down and verifies no orphaned `qemu` process is left behind (exit **3** if one is).
## Why (short version)
- **GMD is broken locally on this box.** `apiXXDebugAndroidTest` fails in GMD's snapshot
step — `AvdSnapshotHandler$EmulatorSnapshotCannotCreatedException: Snapshot creation
timed out` (AEHD 2.2 can't save/load the snapshot). The emulator itself boots fine; only
GMD's snapshot machinery is broken. CI is unaffected (it uses `connectedDebugAndroidTest`,
not GMD). See issue #269.
- **Keep runs targeted.** The full ~114-test suite tends to wedge mid-run on this machine;
small, targeted class sets do not. That's why the script requires an explicit class list —
run only what you changed. The full matrix is CI's job.
- **Emulator hygiene is mandatory.** A hung `adb emu kill` leaves a detached
`qemu-system-x86_64-headless.exe`; accumulated orphans have frozen this machine. The
script force-kills stragglers before booting and after tearing down, and fails loudly if
a zombie survives.
See the header comment of `local_instrumented.sh` for the full rationale, requirements, and
the `LOCAL_INSTRUMENTED_*` environment overrides (AVD name, JDK home, boot timeout, …).
## Requirements
Git Bash; Android SDK `emulator` + `adb` on `PATH`; a JDK **17–21** (AGP 9.2 fails on 25+ —
the script pins `JAVA_HOME` to a known JDK 21, overridable via `LOCAL_INSTRUMENTED_JDK`); and
a free hardware hypervisor (shut down VirtualBox / other VMs first).
+245
View File
@@ -0,0 +1,245 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-3.0-or-later
#
# local_instrumented.sh — reliable LOCAL instrumented / E2E test runner for LibreMail.
#
# Usage: local_instrumented.sh <fully.qualified.TestClass>[,<Class2>,...]
# Example:
# .claude/skills/preflight/local_instrumented.sh \
# org.libremail.ui.compose.ComposeScreenE2ETest
# .claude/skills/preflight/local_instrumented.sh \
# org.libremail.ui.compose.ComposeScreenE2ETest,org.libremail.ui.compose.RecipientChipTest
#
# =============================================================================
# WHY THIS SCRIPT EXISTS (issue #269)
# -----------------------------------------------------------------------------
# On this machine (Windows + the AEHD 2.2 hypervisor) the Gradle Managed Device
# (GMD) instrumented tasks — `apiXXDebugAndroidTest` — FAIL during setup. GMD tries
# to save/load an AVD *snapshot* and AEHD 2.2 cannot complete it:
#
# AvdSnapshotHandler$EmulatorSnapshotCannotCreatedException: Snapshot creation timed out
#
# GMD retries the snapshot ~5x, rebooting the AVD each time — that endless reboot is
# the "cycling" that eats hours. The emulator ITSELF is healthy (8 GB RAM,
# `sys.boot_completed=1`, shell-responsive); only GMD's snapshot step is broken. So
# every LOCAL GMD task is affected: coverage lanes 3/5 (#248/#250) and the /preflight
# api35/api36 steps (#266). CI is unaffected — it uses reactivecircus/android-emulator-runner
# + `connectedDebugAndroidTest`, never GMD.
#
# THE RELIABLE LOCAL PATH (this script):
# Cold-boot ONE emulator by hand with `-no-snapshot` (no GMD, no snapshot machinery),
# then run `:app:connectedDebugAndroidTest` — the exact technique CI and
# `api37_e2e.py` already use. We reuse a GMD-provisioned AVD by name so we don't have
# to re-download a system image; GMD re-provisions its own copy on its next run, so
# the `-wipe-data` cold boot here does not disturb it.
#
# =============================================================================
# KEEP RUNS TARGETED — THE ~114-TEST MID-SUITE WEDGE
# -----------------------------------------------------------------------------
# Running the WHOLE instrumented suite (~114 tests) via `connectedDebugAndroidTest`
# on this box tends to wedge partway through — the emulator stops making progress
# mid-run. Small, targeted class sets do NOT hit that wedge. That is why this helper
# takes an explicit `<fully.qualified.TestClass>[,...]` argument and filters the run
# with `-Pandroid.testInstrumentationRunnerArguments.class=...` instead of running
# everything. Run the class(es) you actually changed; do not use this to run the full
# suite (that is CI's / preflight's job across the API matrix).
#
# =============================================================================
# FREEZE / HYGIENE RATIONALE — WHY THE ORPHAN-KILL + TEARDOWN VERIFY ARE MANDATORY
# -----------------------------------------------------------------------------
# A hung `adb emu kill` (or an interrupted run) leaves a detached
# `qemu-system-x86_64-headless.exe` behind. These orphans do not show up in
# `adb devices`, they keep holding the hypervisor + RAM, and accumulated orphans have
# FROZEN this machine outright. So this script:
# * PREAMBLE — force-kills any pre-existing qemu/emulator processes and resets the
# adb server BEFORE booting, so we always start from a clean slate.
# * TEARDOWN — `adb emu kill`, then re-checks `tasklist` for qemu and force-kills any
# survivor. The teardown runs even on Ctrl-C / error (EXIT/INT/TERM trap).
# * VERIFY — if a qemu process is STILL alive after the force-kill, the script exits
# non-zero (code 3) so the leak is never silently ignored.
# Never leave an emulator running after this script; if it exits 3, hunt the zombie
# down by hand (`tasklist | grep -i qemu`; `taskkill //F //IM qemu-system-x86_64-headless.exe`).
#
# =============================================================================
# REQUIREMENTS
# * Git Bash (this is a bash script; it shells out to Windows `tasklist`/`taskkill`).
# * Android SDK `emulator` + `adb` on PATH (SDK at C:\Android here).
# * A JDK 17–21 for the Gradle daemon — AGP 9.2 fails on JDK 25+. This script pins
# JAVA_HOME to a known JDK 21 (override with LOCAL_INSTRUMENTED_JDK) because the
# ambient JAVA_HOME on this box points at JDK 25.
# * A free hardware hypervisor (VT-x/WHPX/AEHD). Shut down VirtualBox / other VMs first
# or the AVD hangs at 0% CPU and never reaches sys.boot_completed.
#
# Overridable via environment (defaults target THIS machine):
# LOCAL_INSTRUMENTED_AVD AVD name to boot (dev36_google_apis_x86_64_Pixel_2)
# ANDROID_AVD_HOME AVD home dir (C:/Users/jasonross/.android/avd/gradle-managed)
# LOCAL_INSTRUMENTED_JDK JDK 17–21 home (Eclipse Adoptium jdk-21.0.11.10-hotspot)
# LOCAL_INSTRUMENTED_SERIAL adb serial (emulator-5554)
# LOCAL_INSTRUMENTED_BOOT_TIMEOUT boot wait seconds (300)
# =============================================================================
set -uo pipefail
# ---- configuration (env-overridable; defaults are correct for this machine) -----------
AVD_NAME="${LOCAL_INSTRUMENTED_AVD:-dev36_google_apis_x86_64_Pixel_2}"
AVD_HOME="${ANDROID_AVD_HOME:-C:/Users/jasonross/.android/avd/gradle-managed}"
JDK_HOME="${LOCAL_INSTRUMENTED_JDK:-C:/Program Files/Eclipse Adoptium/jdk-21.0.11.10-hotspot}"
SERIAL="${LOCAL_INSTRUMENTED_SERIAL:-emulator-5554}"
BOOT_TIMEOUT="${LOCAL_INSTRUMENTED_BOOT_TIMEOUT:-300}"
QEMU_IMAGE="qemu-system-x86_64-headless.exe"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" # .claude/skills/preflight -> repo root
GRADLEW="${REPO_ROOT}/gradlew"
EMU_LOG="${TMPDIR:-/tmp}/libremail-local-instrumented-emulator.log"
EMU_PID=""
TEST_EXIT=1
ZOMBIE=0
TEARDOWN_DONE=0
log() { printf '\n=== %s ===\n' "$*"; }
warn() { printf 'WARNING: %s\n' "$*" >&2; }
die() { printf 'ERROR: %s\n' "$*" >&2; exit 2; }
# All emulator/qemu processes Windows currently sees (empty string if none).
list_emu_procs() { tasklist 2>/dev/null | grep -iE 'qemu|emulator' || true; }
list_qemu() { tasklist 2>/dev/null | grep -i 'qemu' || true; }
# ---- argument parsing -----------------------------------------------------------------
TEST_CLASSES="${1:-}"
if [[ -z "${TEST_CLASSES}" ]]; then
cat >&2 <<'USAGE'
usage: local_instrumented.sh <fully.qualified.TestClass>[,<Class2>,...]
Cold-boots ONE emulator (no GMD, no snapshot) and runs :app:connectedDebugAndroidTest
filtered to the given instrumented test class(es). Keep the set small and targeted —
see the header for the ~114-test mid-suite wedge.
USAGE
exit 2
fi
# ---- preconditions --------------------------------------------------------------------
command -v emulator >/dev/null 2>&1 || die "emulator not on PATH (install Android SDK emulator)."
command -v adb >/dev/null 2>&1 || die "adb not on PATH (install Android SDK platform-tools)."
command -v tasklist >/dev/null 2>&1 || die "tasklist not found — this helper targets Windows/Git Bash."
[[ -f "${GRADLEW}" ]] || die "gradlew not found at ${GRADLEW}."
[[ -d "${JDK_HOME}" ]] || die "JDK 17-21 not found at '${JDK_HOME}'. Set LOCAL_INSTRUMENTED_JDK."
[[ -f "${AVD_HOME}/${AVD_NAME}.ini" ]] || \
die "AVD '${AVD_NAME}' not found under '${AVD_HOME}'. Set LOCAL_INSTRUMENTED_AVD / ANDROID_AVD_HOME.
(GMD AVDs are created by any local apiXXDebugAndroidTest run.)"
export JAVA_HOME="${JDK_HOME}"
export ANDROID_AVD_HOME="${AVD_HOME}"
# ---- teardown: always runs (normal exit, error, or Ctrl-C) ----------------------------
teardown() {
[[ "${TEARDOWN_DONE}" == "1" ]] && return 0
TEARDOWN_DONE=1
log "Teardown: killing emulator and verifying no orphaned qemu remains"
adb -s "${SERIAL}" emu kill >/dev/null 2>&1 || true
sleep 2
# Belt-and-suspenders: kill the emulator launcher process we started, if still alive.
if [[ -n "${EMU_PID}" ]] && kill -0 "${EMU_PID}" 2>/dev/null; then
kill "${EMU_PID}" 2>/dev/null || true
sleep 1
kill -9 "${EMU_PID}" 2>/dev/null || true
fi
# Verify: any surviving qemu is a machine-freezing zombie — force-kill and re-check.
local remaining
remaining="$(list_qemu)"
if [[ -n "${remaining}" ]]; then
warn "qemu still present after 'adb emu kill'; force-killing:"
printf '%s\n' "${remaining}" >&2
taskkill //F //IM "${QEMU_IMAGE}" >/dev/null 2>&1 || true
# Sweep any other stray qemu-system image name, too.
taskkill //F //IM "qemu-system-x86_64.exe" >/dev/null 2>&1 || true
sleep 2
remaining="$(list_qemu)"
if [[ -n "${remaining}" ]]; then
warn "qemu ZOMBIE survived teardown — kill it by hand or the machine may freeze:"
printf '%s\n' "${remaining}" >&2
ZOMBIE=1
fi
fi
adb kill-server >/dev/null 2>&1 || true
}
trap teardown EXIT INT TERM
# ---- 1. orphan-kill preamble ----------------------------------------------------------
log "Orphan-kill preamble: ensuring a clean slate before boot"
existing="$(list_emu_procs)"
if [[ -n "${existing}" ]]; then
warn "Pre-existing emulator/qemu processes found — force-killing them first:"
printf '%s\n' "${existing}" >&2
taskkill //F //IM "${QEMU_IMAGE}" //IM "emulator.exe" >/dev/null 2>&1 || true
sleep 2
else
echo "No pre-existing qemu/emulator processes."
fi
adb kill-server >/dev/null 2>&1 || true
adb start-server >/dev/null 2>&1 || true
# ---- 2. cold-boot ONE emulator (no snapshot) ------------------------------------------
log "Cold-booting @${AVD_NAME} (no GMD, no snapshot); log -> ${EMU_LOG}"
emulator "@${AVD_NAME}" \
-no-window -no-snapshot -no-boot-anim -no-audio \
-gpu auto-no-window -cores 8 -wipe-data \
>"${EMU_LOG}" 2>&1 &
EMU_PID=$!
echo "emulator launcher pid=${EMU_PID}"
echo "Waiting up to ${BOOT_TIMEOUT}s for sys.boot_completed on ${SERIAL}..."
deadline=$(( $(date +%s) + BOOT_TIMEOUT ))
booted=0
while (( $(date +%s) < deadline )); do
if ! kill -0 "${EMU_PID}" 2>/dev/null; then
warn "emulator process exited during boot; last log lines:"
tail -n 40 "${EMU_LOG}" >&2 || true
break
fi
state="$(adb -s "${SERIAL}" get-state 2>/dev/null | tr -d '\r')"
if [[ "${state}" == "device" ]]; then
bc="$(adb -s "${SERIAL}" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r\n ')"
if [[ "${bc}" == "1" ]]; then booted=1; break; fi
fi
sleep 3
done
if [[ "${booted}" != "1" ]]; then
warn "Emulator did not reach sys.boot_completed within ${BOOT_TIMEOUT}s."
tail -n 40 "${EMU_LOG}" >&2 || true
# teardown runs via the EXIT trap; surface a boot failure distinctly.
exit 4
fi
echo "Emulator booted."
# Dismiss the keyguard (mirrors CI + api37_e2e.py). Best-effort: a cold -wipe-data boot
# rarely needs it, and the input service can lose a race right after boot.
adb -s "${SERIAL}" shell input keyevent 82 >/dev/null 2>&1 || true
# ---- 3. run the targeted instrumented tests -------------------------------------------
log "Running :app:connectedDebugAndroidTest for: ${TEST_CLASSES}"
echo "JAVA_HOME=${JAVA_HOME}"
"${GRADLEW}" :app:connectedDebugAndroidTest \
"-Pandroid.testInstrumentationRunnerArguments.class=${TEST_CLASSES}" \
--stacktrace
TEST_EXIT=$?
# ---- 4. teardown + verify, then exit --------------------------------------------------
teardown
if (( ZOMBIE != 0 )); then
warn "Exiting 3: a qemu zombie was left behind (see above) — clean it up before the next run."
exit 3
fi
if (( TEST_EXIT != 0 )); then
warn "connectedDebugAndroidTest failed (exit ${TEST_EXIT}). Report: app/build/reports/androidTests/connected/"
exit "${TEST_EXIT}"
fi
log "PASS — instrumented tests green for: ${TEST_CLASSES}"
exit 0
@@ -0,0 +1,111 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.accountsetup
import android.app.Activity
import android.app.Instrumentation
import android.content.Intent
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import androidx.lifecycle.SavedStateHandle
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction
import androidx.test.espresso.intent.matcher.IntentMatchers.hasData
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.hamcrest.CoreMatchers.allOf
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.domain.model.MailProvider
import org.libremail.ui.FakeAccountRepository
import org.libremail.ui.navigation.Routes
import org.libremail.ui.theme.LibreMailTheme
/**
* End-to-end UI test for the guided app-password setup screen (#29). Drives the real
* [AppPasswordSetupScreen] + [AppPasswordViewModel] over a [FakeAccountRepository] for the preset
* Gmail vendor: the provider-specific chrome renders, entering an email + app password and tapping
* "Test & add" persists through the repository and reports the new account id, and tapping the
* "create an app password" help link fires the browser intent. That launch is asserted with
* Espresso-Intents (mirroring `AccountPickerScreenTest`'s Outlook test), so no real browser opens.
*/
@RunWith(AndroidJUnit4::class)
class AppPasswordSetupScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private val provider = MailProvider.GMAIL
private fun string(resId: Int, vararg args: Any) = composeTestRule.activity.getString(resId, *args)
private fun setContent(
repository: FakeAccountRepository = FakeAccountRepository(),
onAccountAdded: (String) -> Unit = {},
) {
val viewModel = AppPasswordViewModel(
SavedStateHandle(mapOf(Routes.APP_PASSWORD_ARG_PROVIDER to provider.key)),
repository,
)
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
AppPasswordSetupScreen(onBack = {}, onAccountAdded = onAccountAdded, viewModel = viewModel)
}
}
}
@Test
fun rendersProviderTitle_andCredentialFields() {
setContent()
composeTestRule.onNodeWithText(string(R.string.app_password_title, provider.displayName)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.app_password_email)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.app_password_field)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.app_password_test_and_add)).performScrollTo().assertIsDisplayed()
}
@Test
fun enteringCredentials_andTappingTestAndAdd_addsAccountAndReportsId() {
val repository = FakeAccountRepository()
var addedId: String? = null
setContent(repository = repository, onAccountAdded = { addedId = it })
composeTestRule.onNodeWithText(string(R.string.app_password_email)).performTextInput("me@gmail.com")
composeTestRule.onNodeWithText(string(R.string.app_password_field)).performTextInput("app-pw-1234")
composeTestRule.onNodeWithText(string(R.string.app_password_test_and_add)).performScrollTo().performClick()
// A successful add persists via the repository and hands the new account id to the caller.
composeTestRule.waitUntil(5_000) {
repository.addedAccount?.email == "me@gmail.com" && addedId == "imap:me@gmail.com"
}
}
/**
* Tapping the "create an app password" link opens the provider's help page via
* [androidx.compose.ui.platform.UriHandler], which starts an `ACTION_VIEW` intent. Stubbing that
* intent both proves the tap launched it and stops a real browser from opening on the device.
*/
@Test
fun tappingCreateAppPasswordPage_launchesBrowserIntentToHelpUrl() {
setContent()
Intents.init()
try {
Intents.intending(hasAction(Intent.ACTION_VIEW))
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_CANCELED, null))
composeTestRule.onNodeWithText(string(R.string.app_password_open_page, provider.displayName))
.performScrollTo()
.performClick()
Intents.intended(allOf(hasAction(Intent.ACTION_VIEW), hasData(provider.appPasswordHelpUrl)))
} finally {
Intents.release()
}
}
}
@@ -0,0 +1,90 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.compose.format
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotSelected
import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.ui.theme.LibreMailTheme
/**
* UI tests for the shared font-color / highlight swatch row (#78). [ColorSwatchRow] is
* presentational, so it is driven in isolation - independent of the compose formatting toolbar that
* hosts it - mirroring how `ParagraphAlignmentControlTest` exercises its control. Every swatch is a
* TalkBack-labeled, selectable button, so nodes are addressed by their content description.
*/
@RunWith(AndroidJUnit4::class)
class ColorSwatchRowTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private val red = ColorSwatch(argb = 0xFFD32F2F.toInt(), label = "Red")
private val blue = ColorSwatch(argb = 0xFF1976D2.toInt(), label = "Blue")
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
private fun setContent(selectedArgb: Int?, onSelect: (Int?) -> Unit = {}) {
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
ColorSwatchRow(swatches = listOf(red, blue), selectedArgb = selectedArgb, onSelect = onSelect)
}
}
}
@Test
fun showsNoneEntry_andEverySwatch() {
setContent(selectedArgb = null)
composeTestRule.onNodeWithContentDescription(string(R.string.format_color_none)).assertIsDisplayed()
composeTestRule.onNodeWithContentDescription(red.label).assertIsDisplayed()
composeTestRule.onNodeWithContentDescription(blue.label).assertIsDisplayed()
}
@Test
fun tappingASwatch_reportsItsArgb() {
var picked: Int? = -1
setContent(selectedArgb = null) { picked = it }
composeTestRule.onNodeWithContentDescription(blue.label).performClick()
assertEquals(blue.argb, picked)
}
@Test
fun tappingNoneEntry_reportsNull() {
var picked: Int? = red.argb
setContent(selectedArgb = red.argb) { picked = it }
composeTestRule.onNodeWithContentDescription(string(R.string.format_color_none)).performClick()
assertEquals(null, picked)
}
@Test
fun selectedSwatch_isMarkedSelected_andOthersAreNot() {
setContent(selectedArgb = red.argb)
composeTestRule.onNodeWithContentDescription(red.label).assertIsSelected()
composeTestRule.onNodeWithContentDescription(blue.label).assertIsNotSelected()
// With a color selected, the leading "no color" entry is not the selected one.
composeTestRule.onNodeWithContentDescription(string(R.string.format_color_none)).assertIsNotSelected()
}
@Test
fun noSelection_marksTheNoneEntrySelected() {
setContent(selectedArgb = null)
composeTestRule.onNodeWithContentDescription(string(R.string.format_color_none)).assertIsSelected()
composeTestRule.onNodeWithContentDescription(red.label).assertIsNotSelected()
}
}
@@ -0,0 +1,69 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.lock
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.ui.theme.LibreMailTheme
/**
* UI tests for the app-lock gate. [LockScreen] is presentational (its biometric prompt is driven by
* the caller via [onUnlock]), so it is exercised in isolation: the locked title/body always show, an
* optional error string appears only when non-null, and the unlock button reports back.
*/
@RunWith(AndroidJUnit4::class)
class LockScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
private fun setContent(error: String? = null, onUnlock: () -> Unit = {}) {
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
LockScreen(error = error, onUnlock = onUnlock)
}
}
}
@Test
fun showsLockedTitleBody_andUnlockButton() {
setContent()
composeTestRule.onNodeWithText(string(R.string.app_lock_title)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.app_lock_locked_body)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.app_lock_unlock)).assertIsDisplayed()
}
@Test
fun noError_hidesErrorText() {
setContent(error = null)
composeTestRule.onNodeWithText(string(R.string.app_lock_unlock_failed)).assertDoesNotExist()
}
@Test
fun error_isDisplayed() {
setContent(error = string(R.string.app_lock_unlock_failed))
composeTestRule.onNodeWithText(string(R.string.app_lock_unlock_failed)).assertIsDisplayed()
}
@Test
fun tappingUnlock_invokesOnUnlock() {
var unlocked = false
setContent(onUnlock = { unlocked = true })
composeTestRule.onNodeWithText(string(R.string.app_lock_unlock)).performClick()
composeTestRule.waitUntil(5_000) { unlocked }
}
}
@@ -0,0 +1,66 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.onboarding
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.ui.theme.LibreMailTheme
/**
* UI tests for the post-add onboarding prompt. [AddAnotherAccountScreen] is presentational — it just
* confirms the add and routes the two choices back to the caller — so it is driven in isolation:
* "Add another" returns to the vendor picker and "No" finishes onboarding.
*/
@RunWith(AndroidJUnit4::class)
class AddAnotherAccountScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
private fun setContent(onAddAnother: () -> Unit = {}, onFinish: () -> Unit = {}) {
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
AddAnotherAccountScreen(onAddAnother = onAddAnother, onFinish = onFinish)
}
}
}
@Test
fun showsConfirmation_andBothChoices() {
setContent()
composeTestRule.onNodeWithText(string(R.string.onboarding_account_added_title)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_prompt)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_yes)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_no)).assertIsDisplayed()
}
@Test
fun tappingAddAnother_invokesOnAddAnother() {
var addAnother = false
setContent(onAddAnother = { addAnother = true })
composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_yes)).performClick()
composeTestRule.waitUntil(5_000) { addAnother }
}
@Test
fun tappingNo_invokesOnFinish() {
var finished = false
setContent(onFinish = { finished = true })
composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_no)).performClick()
composeTestRule.waitUntil(5_000) { finished }
}
}
@@ -0,0 +1,136 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.reporting
import android.content.Context
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import androidx.lifecycle.SavedStateHandle
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import io.mockk.every
import io.mockk.mockk
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.reporting.DebugReport
import org.libremail.reporting.ReportKind
import org.libremail.reporting.ReportStore
import org.libremail.reporting.ReportSubmitter
import org.libremail.ui.navigation.Routes
import org.libremail.ui.theme.LibreMailTheme
import java.io.File
/**
* End-to-end UI test for [ReportReviewScreen] + [ReportReviewViewModel] over a real file-backed
* [ReportStore] (temp dir) and a [ReportSubmitter] stubbed as disabled (no ingest endpoint, this
* repo's default): the disclaimer + comment/email fields render, Submit stays disabled until the
* comment reaches the minimum length and the email is valid, and Discard deletes the report and
* leaves the screen. The submit-enqueue/upload path (WorkManager) is deliberately out of scope.
*/
@RunWith(AndroidJUnit4::class)
class ReportReviewScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private val context: Context =
InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
private val storeDir = File(context.cacheDir, "report-review-test")
private val reportId = "r-1"
@Before
fun setUp() {
storeDir.deleteRecursively()
}
@After
fun tearDown() {
storeDir.deleteRecursively()
}
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
private fun report(id: String) = DebugReport(
id = id,
createdAtMillis = 1_000L,
kind = ReportKind.MANUAL,
appVersionName = "1.0",
appVersionCode = 1L,
androidRelease = "14",
androidSdkInt = 34,
deviceManufacturer = "Test",
deviceModel = "Model",
stackTrace = null,
settings = emptyMap(),
logs = emptyList(),
)
private fun setContent(onDone: () -> Unit = {}): ReportStore {
val store = ReportStore(storeDir)
store.save(report(reportId))
val submitter = mockk<ReportSubmitter> { every { isEnabled } returns false }
val viewModel = ReportReviewViewModel(
SavedStateHandle(mapOf(Routes.REPORT_REVIEW_ARG_ID to reportId)),
store,
submitter,
)
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
ReportReviewScreen(onDone = onDone, viewModel = viewModel)
}
}
return store
}
@Test
fun rendersDisclaimer_fieldsAndSubmit() {
setContent()
composeTestRule.onNodeWithText(string(R.string.report_pii_disclaimer_title)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.report_comment_label)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.report_email_label)).performScrollTo().assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.report_submit)).performScrollTo().assertIsDisplayed()
}
@Test
fun submit_isDisabled_whenCommentTooShort_orEmailInvalid() {
setContent()
composeTestRule.onNodeWithText(string(R.string.report_submit)).performScrollTo().assertIsNotEnabled()
}
@Test
fun submit_isEnabled_afterValidCommentAndEmail() {
setContent()
composeTestRule.onNodeWithText(string(R.string.report_comment_label))
.performScrollTo()
.performTextInput("x".repeat(ReportSubmissionRules.MIN_COMMENT_LENGTH))
composeTestRule.onNodeWithText(string(R.string.report_email_label))
.performScrollTo()
.performTextInput("me@example.com")
composeTestRule.onNodeWithText(string(R.string.report_submit)).performScrollTo().assertIsEnabled()
}
@Test
fun tappingDiscard_deletesReport_andLeavesTheScreen() {
var done = false
val store = setContent(onDone = { done = true })
composeTestRule.onNodeWithText(string(R.string.report_discard)).performScrollTo().performClick()
// Discard removes the row; the screen then auto-navigates away once it observes it is gone.
composeTestRule.waitUntil(5_000) { store.find(reportId) == null && done }
}
}
@@ -0,0 +1,135 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.settings
import android.content.Context
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.performTextReplacement
import androidx.lifecycle.SavedStateHandle
import androidx.room.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.data.local.AccountDatabase
import org.libremail.data.local.entity.AccountEntity
import org.libremail.data.local.entity.ServerConfigEmbedded
import org.libremail.data.settings.SignatureRepository
import org.libremail.ui.navigation.Routes
import org.libremail.ui.theme.LibreMailTheme
/**
* End-to-end UI test for [SignatureEditScreen] + [SignatureEditViewModel] over a real in-memory Room
* DB and [SignatureRepository]: the new-vs-edit title, saving a freshly entered name (round-tripping
* through the repository, which makes the account's first signature its default), loading an existing
* signature's name into the field, and saving an edit back to the same row.
*/
@RunWith(AndroidJUnit4::class)
class SignatureEditScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private val context: Context =
InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
private val accountId = "imap:a"
private lateinit var db: AccountDatabase
private lateinit var repository: SignatureRepository
@Before
fun setUp() {
db = Room.inMemoryDatabaseBuilder(context, AccountDatabase::class.java).build()
repository = SignatureRepository(db.signatureDao())
// Signatures foreign-key to an account row, so the parent account must exist first.
runBlocking {
db.accountDao().upsert(
AccountEntity(
id = accountId,
email = "a@example.org",
displayName = "A",
authType = "PASSWORD_IMAP",
imap = ServerConfigEmbedded("imap.example.org", 993, "SSL_TLS"),
smtp = ServerConfigEmbedded("smtp.example.org", 465, "SSL_TLS"),
),
)
}
}
@After
fun tearDown() = db.close()
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
private fun setContent(signatureId: String? = null, onBack: () -> Unit = {}) {
val args = buildMap {
put(Routes.SIGNATURE_EDIT_ARG_ACCOUNT, accountId)
if (signatureId != null) put(Routes.SIGNATURE_EDIT_ARG_ID, signatureId)
}
val viewModel = SignatureEditViewModel(SavedStateHandle(args), repository)
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
SignatureEditScreen(onBack = onBack, viewModel = viewModel)
}
}
}
private fun waitForText(text: String) = composeTestRule.waitUntil(5_000) {
composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty()
}
@Test
fun newSignature_showsNewTitle() {
setContent(signatureId = null)
composeTestRule.onNodeWithText(string(R.string.signature_new_title)).assertIsDisplayed()
}
@Test
fun newSignature_savingEnteredName_persistsViaRepository() {
var backInvoked = false
setContent(signatureId = null, onBack = { backInvoked = true })
composeTestRule.onNodeWithText(string(R.string.signature_name)).performTextInput("Work")
composeTestRule.onNodeWithText(string(R.string.signature_save)).performClick()
// The save round-trips to the DB: the first signature created becomes the account's default.
composeTestRule.waitUntil(5_000) {
runBlocking { repository.getDefault(accountId)?.name } == "Work" && backInvoked
}
}
@Test
fun existingSignature_showsEditTitle_andPrefillsName() {
val id = runBlocking { repository.create(accountId, "Personal", "<p>hi</p>") }
setContent(signatureId = id)
waitForText("Personal")
composeTestRule.onNodeWithText(string(R.string.signature_edit_title)).assertIsDisplayed()
composeTestRule.onNodeWithText("Personal").assertIsDisplayed()
}
@Test
fun existingSignature_savingRenamedName_updatesTheSameRow() {
val id = runBlocking { repository.create(accountId, "Personal", "<p>hi</p>") }
setContent(signatureId = id)
waitForText("Personal")
composeTestRule.onNodeWithText("Personal").performTextReplacement("Renamed")
composeTestRule.onNodeWithText(string(R.string.signature_save)).performClick()
composeTestRule.waitUntil(5_000) {
runBlocking { repository.get(id)?.name } == "Renamed"
}
}
}