Merge main into test-221-cold-open-encrypted-cache
This commit is contained in:
@@ -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
@@ -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
|
||||
+111
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user