Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 26 additions & 31 deletions androidTests/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Android Tests

UI-driven tests for the React Native bridge using UIAutomator.
Headless instrumentation tests for the React Native bridge.

## Test Credentials

Expand All @@ -10,28 +10,24 @@ Tests require Salesforce org credentials. Copy `shared/test/test_credentials.jso

### From Android Studio
1. Set up the test app: `./prepareandroid.js`
2. Start the Metro bundler: `npm start`
3. Open the project in Android Studio: `androidTests/android/`
4. Right-click on a test class or method → Run
2. Open the project in Android Studio: `androidTests/android/`
3. Right-click on a test class or method → Run

### From Command Line
```bash
cd androidTests
./gradlew connectedAndroidTest
cd androidTests/android
./gradlew connectedDebugAndroidTest
```

## Test Structure

Each test class extends `BaseReactNativeTest` and provides:

- `testTimeoutMs`: Timeout for each test (optional, default: 15s)
Each test class extends `BaseReactNativeTest` and maps its JUnit methods to the
shared JavaScript test names.

Example:

```kotlin
class ReactNetTest : BaseReactNativeTest() {
override val testTimeoutMs: Long = 30_000

@Test fun testGetApiVersion() = runTest("testGetApiVersion")
@Test fun testVersions() = runTest("testVersions")
// ...
Expand All @@ -40,36 +36,35 @@ class ReactNetTest : BaseReactNativeTest() {

## Timeouts

Default timeouts:
- **OAuth, Harness, SmartStore**: 15 seconds (default)
- **Net**: 30 seconds
- **MobileSync**: 60 seconds

Override `testTimeoutMs` to customize per test class.
Every headless test has a 30-second JavaScript timeout. The Android collector
independently fails after 35 seconds without a new result, including a final
logcat snapshot at the timeout boundary. This provides polling and scheduling
slack while still catching a blocking native call that suppresses the JavaScript
timer. Its 45-minute overall ceiling remains as a final guard for a suite that
continues to make progress. The collector values can be overridden with the
`progressTimeoutMs` and `maxRunMs` instrumentation arguments.

## How It Works

1. Each test method triggers the activity to launch via `@Rule`
2. `runTest()` waits for the React Native app to load
3. Taps the individual test button in the UI (testID: `run_{testName}`)
4. Waits for the test result element to appear (testID: `result_{testName}_pass` or `result_{testName}_fail`)
5. Asserts the result
1. The first JUnit test launches the app and authenticates from `test_credentials.json`.
2. `HeadlessTestApp` runs the shared JavaScript tests sequentially.
3. Each JavaScript result and the final summary are written to logcat sentinels.
4. The Android collector reads finite logcat snapshots and caches all results.
5. Each JUnit method asserts its corresponding cached result.

## Authentication

Tests use instant authentication via `TestAuthenticationActivity`:
- Credentials are loaded from `assets/test_credentials.json`
- `ActivityScenarioRule` launches `TestAuthenticationActivity` with credentials as an intent extra
- The headless collector launches `TestAuthenticationActivity` with credentials as an intent extra
- Activity authenticates and launches the main React Native activity
- Tests interact with the running React Native app via UIAutomator
- The JavaScript suite reports results through logcat; no UI interaction is required

## Test IDs
## Result Protocol

The JavaScript test app must expose these accessibility identifiers:
- `testList`: The ScrollView containing all test buttons
- `run_{testName}`: Button to run individual test
- `result_{testName}_pass`: Success indicator
- `result_{testName}_fail`: Failure indicator
- `error_{testName}`: Error message text (if test fails)
The JavaScript test app emits `SFTESTBEGIN::`, one `SFTESTRESULT::` JSON line per
test, and `SFTESTDONE::` when the suite completes. The instrumentation collector
uses finite `logcat -d` snapshots so Android 12L cannot retain the final buffered
line in a long-lived logcat pipe.

See `docs/android-tests/README.md` for detailed documentation.
Comment thread
brandonpage marked this conversation as resolved.
1 change: 0 additions & 1 deletion androidTests/android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,6 @@ dependencies {
implementation("com.facebook.react:hermes-android:0.86.0")

androidTestImplementation("androidx.test:runner:1.6.2")
androidTestImplementation("androidx.test:rules:1.6.1")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0")
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,22 +26,14 @@
*/
package com.salesforce.androidsdk.reactnative

import android.Manifest
import android.content.Intent
import android.os.Build
import android.os.ParcelFileDescriptor
import android.os.SystemClock
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import androidx.test.uiautomator.UiDevice
import com.salesforce.androidsdk.util.test.TestAuthenticationActivity
import org.json.JSONObject
import org.junit.Assert.assertTrue
import org.junit.Rule
import java.io.BufferedReader
import java.io.InputStreamReader
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit

data class TestResult(val success: Boolean, val message: String?)

Expand All @@ -56,27 +48,14 @@ data class TestResult(val success: Boolean, val message: String?)
*
* This version drives NO UI. It launches the app ONCE; the app mounts
* HeadlessTestApp (see androidTests/index.js), which runs the whole shared suite
* and emits one logcat line per result. [HeadlessResults] streams logcat, parses
* those lines, and each @Test simply asserts on its parsed result. Because the run
* happens once for the whole process, the ~70min (35 cold starts) runtime collapses
* to a single launch while every @Test still reports independently in the JUnit XML.
* and emits one logcat line per result. [HeadlessResults] reads finite logcat
* snapshots, parses those lines, and each @Test simply asserts on its parsed result.
* Because the run happens once for the whole process, the ~70min (35 cold starts)
* runtime collapses to a single launch while every @Test still reports independently
* in the JUnit XML.
*/
abstract class BaseReactNativeTest {

// Pre-grant POST_NOTIFICATIONS so no permission dialog can interrupt the run on
// API 33+. (The app manifest removes the permission; granting is a no-op if absent.)
@get:Rule
val permissionRule: GrantPermissionRule = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS)
} else {
GrantPermissionRule.grant()
}

// Vestigial: kept only so the two subclass overrides (ReactNetTest, ReactMobileSyncTest)
// still compile. Real per-test timeouts now live in HeadlessTestApp.js (SUITE_TIMEOUTS).
open val testTimeoutMs: Long
get() = 60_000

fun runTest(name: String) {
val result = HeadlessResults.resultFor(name)
assertTrue(result.message ?: "Test '$name' failed", result.success)
Expand All @@ -91,13 +70,18 @@ abstract class BaseReactNativeTest {
*/
object HeadlessResults {

private const val BEGIN_PREFIX = "SFTESTBEGIN::"
private const val RESULT_PREFIX = "SFTESTRESULT::"
private const val DONE_PREFIX = "SFTESTDONE::"
private const val DEFAULT_MAX_RUN_MS = 45L * 60 * 1000 // < Firebase --timeout 60m
// App must launch and mount HeadlessTestApp (emit BEGIN) within this, else fail
// fast with a real cause instead of blocking the whole run on a silent no-mount.
private const val DEFAULT_BEGIN_TIMEOUT_MS = 3L * 60 * 1000
// HeadlessTestApp caps each test at 30 seconds. Enforce a slightly longer
// watchdog outside the JS event loop so polling and scheduling at the timeout
// boundary cannot discard the JavaScript timeout result.
private const val DEFAULT_PROGRESS_TIMEOUT_MS = 35_000L
private const val LOGCAT_POLL_INTERVAL_MS = 1_000L
private const val LOGCAT_SNAPSHOT_COMMAND =
"logcat -d -v raw -s ReactNativeJS:I AndroidRuntime:E"

private val results = ConcurrentHashMap<String, TestResult>()
private val lock = Object()
Expand Down Expand Up @@ -135,56 +119,16 @@ object HeadlessResults {
val maxRunMs = InstrumentationRegistry.getArguments()
.getString("maxRunMs")?.toLongOrNull() ?: DEFAULT_MAX_RUN_MS

// Clear logcat so we only read this run's output.
UiDevice.getInstance(instrumentation).executeShellCommand("logcat -c")
val device = UiDevice.getInstance(instrumentation)
// Clear logcat so every finite snapshot only contains this run's output.
device.executeShellCommand("logcat -c")

val beginTimeoutMs = InstrumentationRegistry.getArguments()
.getString("beginTimeoutMs")?.toLongOrNull() ?: DEFAULT_BEGIN_TIMEOUT_MS
val progressTimeoutMs = InstrumentationRegistry.getArguments()
.getString("progressTimeoutMs")?.toLongOrNull() ?: DEFAULT_PROGRESS_TIMEOUT_MS
val targetPackage = context.packageName

val begun = CountDownLatch(1)
val done = CountDownLatch(1)
// Stream logcat from the shell uid (which holds READ_LOGS). Start reading
// BEFORE launching so no early sentinel is missed. AndroidRuntime:E is
// included so a FATAL crash in the app can be captured as the real cause.
val pfd = instrumentation.uiAutomation
.executeShellCommand("logcat -v raw -s ReactNativeJS:I AndroidRuntime:E")
val reader = BufferedReader(InputStreamReader(ParcelFileDescriptor.AutoCloseInputStream(pfd)))
val readerThread = Thread {
// Capture the FATAL EXCEPTION block only when it belongs to our process.
val fatalBuf = StringBuilder()
var fatalLinesLeft = 0
try {
reader.forEachLine { line ->
when {
line.contains(BEGIN_PREFIX) -> begun.countDown()
// One malformed line must not kill the reader (the sole DONE
// consumer) — guard the parse.
line.contains(RESULT_PREFIX) -> runCatching { parseResult(line) }
line.contains(DONE_PREFIX) -> done.countDown()
line.contains("FATAL EXCEPTION") -> {
fatalBuf.setLength(0)
fatalBuf.append(line).append('\n')
fatalLinesLeft = 25
}
fatalLinesLeft > 0 -> {
fatalBuf.append(line).append('\n')
fatalLinesLeft--
// The "Process:" line tells us whose crash this is.
if (line.contains("Process:") && line.contains(targetPackage)) {
crashHint = fatalBuf.toString().take(1500)
fatalLinesLeft = 0
}
}
}
}
} catch (_: Throwable) {
// Stream closed after the run finished — expected.
}
}
readerThread.isDaemon = true
readerThread.start()

// Launch once: TestAuthenticationActivity authenticates from the creds asset,
// then starts MainActivity, which mounts HeadlessTestApp and runs the suite.
context.startActivity(
Expand All @@ -198,22 +142,130 @@ object HeadlessResults {
// the whole maxRunMs and then report a misleading "no DONE". (A crash that
// kills the shared instrumentation process is already reported quickly by
// `am instrument` as "Process crashed"; this covers the alive-but-silent case.)
if (!begun.await(beginTimeoutMs, TimeUnit.MILLISECONDS)) {
var events = awaitEvents(device, targetPackage, beginTimeoutMs) { it.began || it.done }
if (!events.began) {
runError = "Headless run did not emit BEGIN within ${beginTimeoutMs}ms — " +
"app launched but HeadlessTestApp never mounted " +
"(check test_credentials.json and the JS bundle)." +
(crashHint?.let { "\nApp FATAL:\n$it" } ?: "")
runCatching { pfd.close() }
return
}

// Condition-wait on the DONE sentinel — no Thread.sleep / polling.
val finished = done.await(maxRunMs, TimeUnit.MILLISECONDS)
if (!finished) {
runError = "Headless run did not emit DONE within ${maxRunMs}ms" +
(crashHint?.let { " — app FATAL:\n$it" } ?: "")
// Android 12L's long-running `logcat` pipe can retain the final buffered
// lines indefinitely. Read finite `logcat -d` snapshots instead: the
// command exits and flushes, so a tail-position DONE is observable.
if (!events.done) {
val completion = awaitCompletion(
device,
targetPackage,
events,
maxRunMs,
progressTimeoutMs
)
events = completion.events
if (!events.done) {
runError = completion.timeoutMessage +
(crashHint?.let { " — app FATAL:\n$it" } ?: "")
}
}
}

private data class CompletionWait(
val events: HeadlessLogcatEvents,
val timeoutMessage: String
)

private fun awaitCompletion(
device: UiDevice,
targetPackage: String,
initialEvents: HeadlessLogcatEvents,
maxRunMs: Long,
progressTimeoutMs: Long
): CompletionWait {
val startedAt = SystemClock.elapsedRealtime()
val overallDeadline = startedAt + maxRunMs
var lastProgressAt = startedAt
var lastResultCount = initialEvents.resultLines.size
var events = initialEvents

while (true) {
if (events.done) return CompletionWait(events, "")

val now = SystemClock.elapsedRealtime()
if (events.resultLines.size > lastResultCount) {
lastResultCount = events.resultLines.size
lastProgressAt = now
}

val idleMs = now - lastProgressAt
if (idleMs >= progressTimeoutMs) {
Comment thread
brandonpage marked this conversation as resolved.
// Close the boundary race where the JavaScript timeout/result is
// emitted just after the snapshot used to calculate idleMs.
val finalEvents = readEvents(device, targetPackage)
if (finalEvents.done) return CompletionWait(finalEvents, "")
if (finalEvents.resultLines.size > lastResultCount) {
events = finalEvents
lastResultCount = finalEvents.resultLines.size
lastProgressAt = SystemClock.elapsedRealtime()
continue
}
return CompletionWait(
finalEvents,
"Headless run made no progress for ${progressTimeoutMs}ms " +
"after ${lastResultCount} result(s)"
)
}
if (now >= overallDeadline) {
return CompletionWait(
events,
"Headless run did not emit DONE within ${maxRunMs}ms"
)
}

SystemClock.sleep(
minOf(
LOGCAT_POLL_INTERVAL_MS,
overallDeadline - now,
progressTimeoutMs - idleMs
)
)
events = readEvents(device, targetPackage)
}
runCatching { pfd.close() }
}

private fun awaitEvents(
device: UiDevice,
targetPackage: String,
timeoutMs: Long,
finished: (HeadlessLogcatEvents) -> Boolean
): HeadlessLogcatEvents {
val deadline = SystemClock.elapsedRealtime() + timeoutMs
var events: HeadlessLogcatEvents
do {
events = readEvents(device, targetPackage)
if (finished(events)) return events

val remainingMs = deadline - SystemClock.elapsedRealtime()
if (remainingMs > 0) {
SystemClock.sleep(minOf(LOGCAT_POLL_INTERVAL_MS, remainingMs))
}
} while (SystemClock.elapsedRealtime() < deadline)

// One last finite dump closes the race where DONE arrives at the deadline.
return readEvents(device, targetPackage)
}

private fun readEvents(
device: UiDevice,
targetPackage: String
): HeadlessLogcatEvents {
val events = HeadlessLogcatParser.parse(
device.executeShellCommand(LOGCAT_SNAPSHOT_COMMAND),
targetPackage
)
events.resultLines.forEach { line -> runCatching { parseResult(line) } }
events.crashHint?.let { crashHint = it }
return events
}

private fun parseResult(line: String) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,6 @@ package com.salesforce.androidsdk.reactnative
import org.junit.Test

class ReactMobileSyncTest : BaseReactNativeTest() {
override val testTimeoutMs: Long = 180_000

@Test fun testSyncDown() = runTest("testSyncDown")
@Test fun testSyncUp() = runTest("testSyncUp")
@Test fun testReSync() = runTest("testReSync")
Expand Down
Loading
Loading