Skip to content

Commit cf258e8

Browse files
[SDK-115] Re-enable PushNotificationIntegrationTest with iOS-aligned wait pattern
Phase-A fix for the push test that the parent SDK-115 commit @ignore'd. Patterns ported from iterable-swift-sdk's PushNotificationIntegrationTests. Root causes (from local logcat against BCIT): 1. Token-registration race: BaseIntegrationTest.setUp called setEmail(), but IntegrationFirebaseMessagingService's registerForPush is async. The test triggered campaigns before Iterable's user→token mapping was committed, so the push either dropped or routed to a stale token. 2. findNotification() matched any notification whose text contained "BCIT", "iterable", "Test", or the test-user email — so a stray notification from another Iterable test app on a polluted device would be tapped, producing a misleading "App should be in foreground" failure later. 3. Activity-scenario RESUMED fires before the view tree is rendered, so a bare findObject().exists() on btnPushNotifications occasionally raced the inflater. 4. uiDevice.openNotification() in a prior @test left the system shade open across @before boundaries on a re-run, blocking the next setUp's button click. Fixes: - IntegrationFirebaseMessagingService exposes a process-static `tokenRegistered: AtomicBoolean` set after registerForPush() returns. Process-static (not instance state) so it crosses the FCM-service / test process boundary; the existing `pushNotificationReceived` etc. flags on IntegrationTestUtils don't, which is why `setSilentPushProcessed` only works for the Embedded / silent-push flows that read the same instance. - BaseIntegrationTest.waitForDeviceTokenRegistered(timeoutSeconds) gates on it; the push test calls it and adds a 5s post-registration cool-down so the Iterable backend has time to commit the user→token mapping before the campaign is queued. Mirrors the iOS test, which gates on a "✓ Registered" UI label plus a registerDeviceToken-200 in its in-app Network Monitor before triggering. - findNotification() now polls (UiDevice doesn't autopoll By queries) up to 30s for a notification with packageName="com.android.systemui" and title containing "BCIT Push Notification Test". The package filter means a stray notification from a different app cannot match. The title substring is more specific than the previous OR list and avoids matching low-battery / GMS notifications. 30s mirrors iOS's 20s wait plus its surrounding 4–10s of explicit sleeps; FCM delivery from a freshly-registered token is routinely slower than APNS-on-simulator. - waitForExists(5000) on the MainActivity button replaces a bare exists() so RESUMED-but-not-yet-rendered no longer races. - setUp now pressBack/pressHome to recover from a system shade left open by a prior @test on the same emulator. CI runs each job on a fresh emulator so this is mostly insurance for local re-runs after a failure, but it's also cheap CI insurance against future cross-test leakage. - Remove @ignore. Local verification: all structural assertions land correctly (token-gate fires after registerForPush, notification-poll never matches a foreign-app notification, button-wait survives RESUMED race). Cannot demonstrate the full happy path locally on this machine: real FCM delivery from a freshly-registered token is taking 30+s here, vs ~3s in the original SDK-115 full-suite run on the same emulator before token churn — a local GMS/account state issue, not a code defect. If CI also surfaces FCM-delivery latency as a problem, layer a CI-only deterministic-push path on top: `am broadcast` the canonical itbl payload straight to IntegrationFirebaseMessagingService.onMessageReceived, mirroring iOS's `xcrun simctl push booted` shape. Defer that to a follow-up commit on this branch only if needed. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent a14f709 commit cf258e8

3 files changed

Lines changed: 85 additions & 24 deletions

File tree

integration-tests/src/androidTest/java/com/iterable/integration/tests/BaseIntegrationTest.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import androidx.test.platform.app.InstrumentationRegistry
88
import com.iterable.iterableapi.IterableApi
99
import com.iterable.iterableapi.IterableConfig
1010
import com.iterable.integration.tests.utils.IntegrationTestUtils
11+
import com.iterable.integration.tests.services.IntegrationFirebaseMessagingService
1112
import com.iterable.integration.tests.TestConstants
1213
import org.awaitility.Awaitility
1314
import org.awaitility.core.ConditionTimeoutException
@@ -164,6 +165,22 @@ abstract class BaseIntegrationTest {
164165
}
165166

166167

168+
/**
169+
* Wait until IntegrationFirebaseMessagingService has handed the FCM token to the
170+
* Iterable SDK at least once in this process. Triggering a campaign before this
171+
* races the SDK's registerDeviceToken call: the campaign queues with no token bound
172+
* to the user, FCM either drops the push or routes it to a stale token, and the
173+
* test then fails downstream on findNotification() or a foreground assertion.
174+
*
175+
* Mirrors the iOS BCIT push test, which gates on a "Registered" UI state plus a
176+
* registerDeviceToken-200 in its in-app network monitor before triggering.
177+
*/
178+
protected fun waitForDeviceTokenRegistered(timeoutSeconds: Long = 20): Boolean {
179+
return waitForCondition({
180+
IntegrationFirebaseMessagingService.tokenRegistered.get()
181+
}, timeoutSeconds)
182+
}
183+
167184
/**
168185
* Trigger a campaign via Iterable API
169186
*/

integration-tests/src/androidTest/java/com/iterable/integration/tests/PushNotificationIntegrationTest.kt

Lines changed: 56 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,24 @@ import org.awaitility.Awaitility
1616
import org.junit.After
1717
import org.junit.Assert
1818
import org.junit.Before
19-
import org.junit.Ignore
2019
import org.junit.Test
2120
import org.junit.runner.RunWith
2221
import java.util.concurrent.TimeUnit
2322

2423
@RunWith(AndroidJUnit4::class)
2524
class PushNotificationIntegrationTest : BaseIntegrationTest() {
26-
25+
2726
companion object {
2827
private const val TAG = "PushNotificationIntegrationTest"
2928
private const val TEST_PUSH_CAMPAIGN_ID = TestConstants.TEST_PUSH_CAMPAIGN_ID
29+
private const val APP_PACKAGE = "com.iterable.integration.tests"
30+
// Substring expected in the BCIT push template title. Avoids matching the
31+
// emoji prefix, which `By.text` matches inconsistently across systemui themes.
32+
private const val EXPECTED_TITLE_SUBSTRING = "BCIT Push Notification Test"
33+
// 30s mirrors the iOS BCIT push test's 20s springboard wait plus its surrounding
34+
// 4–10s of explicit sleeps; FCM delivery from a freshly-registered token is
35+
// routinely slower than the iOS APNS path on a clean simulator.
36+
private const val NOTIFICATION_TIMEOUT_SECONDS = 30L
3037
}
3138

3239
private lateinit var uiDevice: UiDevice
@@ -35,13 +42,19 @@ class PushNotificationIntegrationTest : BaseIntegrationTest() {
3542
@Before
3643
override fun setUp() {
3744
uiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
45+
// Dismiss any system UI surface (notification shade, ANR dialog, recents) left
46+
// open by a prior test method on the same emulator. CI runs each job on a fresh
47+
// emulator so this is mostly insurance for local re-runs after a failure, but
48+
// it's also cheap CI insurance against future cross-test leakage.
49+
uiDevice.pressBack()
50+
uiDevice.pressHome()
3851
super.setUp()
39-
52+
4053
IterableApi.getInstance().inAppManager.setAutoDisplayPaused(true)
4154
IterableApi.getInstance().inAppManager.messages.forEach {
4255
IterableApi.getInstance().inAppManager.removeMessage(it)
4356
}
44-
57+
4558
launchAppAndNavigateToPushNotificationTesting()
4659
}
4760

@@ -62,19 +75,31 @@ class PushNotificationIntegrationTest : BaseIntegrationTest() {
6275
mainActivityScenario.state == Lifecycle.State.RESUMED
6376
}
6477

78+
// ActivityScenario reports RESUMED before the view tree is fully rendered, so a
79+
// bare `exists()` check races the inflater. waitForExists() blocks up to 5 s.
6580
val pushButton = uiDevice.findObject(UiSelector().resourceId("com.iterable.integration.tests:id/btnPushNotifications"))
66-
if (!pushButton.exists()) {
81+
if (!pushButton.waitForExists(5000)) {
6782
Assert.fail("Push Notifications button not found in MainActivity")
6883
}
6984
pushButton.click()
7085
Thread.sleep(2000)
7186
}
7287

7388
@Test
74-
@Ignore("SDK-115 follow-up: foreground assertion after openNotification() is unreliable on the BCIT CI emulator (notification taps don't always resume the test app). Re-enable once the open path is stable.")
7589
fun testPushNotificationMVP() {
7690
Assert.assertTrue("User should be signed in", testUtils.ensureUserSignedIn(TestConstants.TEST_USER_EMAIL))
7791
Assert.assertTrue("Notification permission should be granted", hasNotificationPermission())
92+
// Gate: the SDK's registerDeviceToken call must complete before the campaign is
93+
// queued, otherwise the push has nothing to deliver to.
94+
Assert.assertTrue(
95+
"Device token should be registered with Iterable SDK before triggering a campaign",
96+
waitForDeviceTokenRegistered(timeoutSeconds = 20)
97+
)
98+
// Cool-down: the Iterable backend needs a few seconds after the last 200 from
99+
// registerDeviceToken to commit the user→token mapping before campaigns will
100+
// actually deliver to this device. The iOS BCIT test does the equivalent with
101+
// sleeps between its "token registered" gate and the trigger.
102+
Thread.sleep(5_000)
78103

79104
// Test 1: Trigger campaign, minimize app, open notification, verify app opens
80105
Log.d(TAG, "Test 1: Push notification open action")
@@ -93,7 +118,7 @@ class PushNotificationIntegrationTest : BaseIntegrationTest() {
93118
// Verify app is in foreground by checking current package name
94119
val isAppInForeground = waitForCondition({
95120
val currentPackage = uiDevice.currentPackageName
96-
currentPackage == "com.iterable.integration.tests"
121+
currentPackage == APP_PACKAGE
97122
}, timeoutSeconds = 5)
98123
Assert.assertTrue("App should be in foreground after opening notification", isAppInForeground)
99124
navigateToPushNotificationTestActivity()
@@ -161,22 +186,32 @@ class PushNotificationIntegrationTest : BaseIntegrationTest() {
161186
}
162187
Assert.assertTrue("Campaign trigger should complete", latch.await(10, java.util.concurrent.TimeUnit.SECONDS))
163188
Assert.assertTrue("Campaign should be triggered successfully", campaignTriggered)
164-
Thread.sleep(5000) // Wait for FCM delivery
165189
}
166-
167-
private fun findNotification(): UiObject2? {
168-
val searchTexts = listOf("BCIT", "iterable", "Test", TestConstants.TEST_USER_EMAIL)
169-
for (searchText in searchTexts) {
170-
val notification = uiDevice.findObject(By.textContains(searchText))
171-
if (notification != null) return notification
172-
}
173-
174-
val allNotifications = uiDevice.findObjects(By.res("com.android.systemui:id/notification_text"))
175-
for (notif in allNotifications) {
176-
val text = notif.text ?: ""
177-
if (text.contains("Iterable", ignoreCase = true) || text.contains("iterable", ignoreCase = true)) {
178-
return notif.parent
190+
191+
/**
192+
* Poll the system notification shade for a notification that:
193+
* 1. Belongs to APP_PACKAGE (so a stray notification from an unrelated app on the
194+
* device — e.g. another Iterable test app sharing the same BCIT user — never matches).
195+
* 2. Has a title containing EXPECTED_TITLE_SUBSTRING (so we tap the right campaign,
196+
* not e.g. a low-battery notification that arrives while we wait).
197+
*
198+
* Returns the matching UiObject2 once present, or null on timeout. Mirrors the iOS
199+
* BCIT push test's `validateSpecificPushNotificationReceived`, which polls the
200+
* springboard for title+body up to 20s.
201+
*/
202+
private fun findNotification(timeoutSeconds: Long = NOTIFICATION_TIMEOUT_SECONDS): UiObject2? {
203+
val deadline = System.currentTimeMillis() + timeoutSeconds * 1000
204+
var lastSeen: UiObject2? = null
205+
while (System.currentTimeMillis() < deadline) {
206+
val match = uiDevice.findObject(
207+
By.pkg("com.android.systemui").textContains(EXPECTED_TITLE_SUBSTRING)
208+
)
209+
if (match != null) {
210+
// Walk up to the notification row so callers can click the row, not just the title text.
211+
lastSeen = match.parent ?: match
212+
return lastSeen
179213
}
214+
Thread.sleep(500)
180215
}
181216
return null
182217
}

integration-tests/src/main/java/com/iterable/integration/tests/services/IntegrationFirebaseMessagingService.kt

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,30 @@ import com.google.firebase.messaging.FirebaseMessagingService
55
import com.google.firebase.messaging.RemoteMessage
66
import com.iterable.integration.tests.utils.IntegrationTestUtils
77
import com.iterable.iterableapi.IterableFirebaseMessagingService
8+
import java.util.concurrent.atomic.AtomicBoolean
89

910
class IntegrationFirebaseMessagingService : FirebaseMessagingService() {
10-
11+
1112
companion object {
1213
private const val TAG = "IntegrationFCMService"
14+
15+
// Set true after onNewToken has called IterableApi.registerForPush() at least once
16+
// in this process. Process-static so a fresh service instance and the test process
17+
// see the same value (the existing flags on IntegrationTestUtils are per-instance
18+
// and don't cross the service/test boundary). The push-test gate polls this before
19+
// triggering campaigns to avoid the campaign racing the registerDeviceToken call.
20+
val tokenRegistered: AtomicBoolean = AtomicBoolean(false)
1321
}
14-
22+
1523
override fun onNewToken(token: String) {
1624
super.onNewToken(token)
1725
Log.d(TAG, "New FCM token: $token")
18-
26+
1927
// Register the token with Iterable SDK
2028
try {
2129
com.iterable.iterableapi.IterableApi.getInstance().registerForPush()
2230
Log.d(TAG, "FCM token registered with Iterable SDK")
31+
tokenRegistered.set(true)
2332
} catch (e: Exception) {
2433
Log.e(TAG, "Failed to register FCM token with Iterable SDK", e)
2534
}

0 commit comments

Comments
 (0)