Skip to content

Commit 605d0bf

Browse files
committed
feat: improve testing to detect this bug in the future
1 parent 061ced7 commit 605d0bf

3 files changed

Lines changed: 209 additions & 7 deletions

File tree

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package com.telemetrydeck.sdk
2+
3+
import androidx.activity.ComponentActivity
4+
import androidx.lifecycle.Lifecycle
5+
import androidx.lifecycle.ProcessLifecycleOwner
6+
import androidx.test.core.app.ActivityScenario
7+
import androidx.test.ext.junit.runners.AndroidJUnit4
8+
import androidx.test.platform.app.InstrumentationRegistry
9+
import com.telemetrydeck.sdk.params.Calendar
10+
import com.telemetrydeck.sdk.params.SDK
11+
import com.telemetrydeck.sdk.signals.Session
12+
import org.junit.After
13+
import org.junit.Assert.assertEquals
14+
import org.junit.Assert.assertNotNull
15+
import org.junit.Assert.assertTrue
16+
import org.junit.Test
17+
import org.junit.runner.RunWith
18+
import java.io.File
19+
20+
/**
21+
* Regression test for the launch-enrichment bug where TelemetryDeck.Session.started was emitted
22+
* before enrichment providers (EnvironmentParameterProvider, CalendarParameterProvider, etc.) had
23+
* registered, resulting in a launch signal with missing device/SDK/calendar parameters and
24+
* sometimes a duplicate signal.
25+
*
26+
* The fix registers sessionManager LAST inside installProviders(), and removes the explicit
27+
* handleOnForeground() call from SessionTrackingSignalProvider.register() — the lifecycle
28+
* observer's onStart is the sole trigger.
29+
*
30+
* This test runs on a real device so ProcessLifecycleOwner transitions naturally when an Activity
31+
* is resumed, without any reflection hacks.
32+
*/
33+
@RunWith(AndroidJUnit4::class)
34+
class SessionTrackingLaunchEnrichmentTest {
35+
36+
private var scenario: ActivityScenario<ComponentActivity>? = null
37+
private var sdk: TelemetryDeck? = null
38+
39+
@After
40+
fun cleanup() {
41+
InstrumentationRegistry.getInstrumentation().runOnMainSync {
42+
TelemetryDeck.stop()
43+
sdk?.let { instance ->
44+
instance.broadcastTimer?.stop()
45+
for (provider in instance.providers) {
46+
provider.stop()
47+
}
48+
instance.identityProvider.stop()
49+
instance.sessionManager?.stop()
50+
}
51+
sdk = null
52+
}
53+
scenario?.close()
54+
scenario = null
55+
deleteTrackingFile()
56+
}
57+
58+
@Test
59+
fun launch_session_signal_contains_enrichment_params_and_is_not_duplicated() {
60+
deleteTrackingFile()
61+
62+
scenario = ActivityScenario.launch(ComponentActivity::class.java)
63+
scenario!!.moveToState(Lifecycle.State.RESUMED)
64+
65+
InstrumentationRegistry.getInstrumentation().runOnMainSync {
66+
assertTrue(
67+
"ProcessLifecycleOwner must be at least STARTED before building SDK",
68+
ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
69+
)
70+
}
71+
72+
val cache = MemorySignalCache()
73+
74+
InstrumentationRegistry.getInstrumentation().runOnMainSync {
75+
val appContext = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
76+
sdk = TelemetryDeck.Builder()
77+
.appID("32CB6574-6732-4238-879F-582FEBEB6536")
78+
.sendNewSessionBeganSignal(true)
79+
.signalCache(cache)
80+
.build(appContext)
81+
}
82+
83+
val allSignals = cache.empty()
84+
val sessionStartedSignals = allSignals.filter { it.type == Session.Started.signalName }
85+
86+
assertEquals(
87+
"Expected exactly one ${Session.Started.signalName} signal, but got ${sessionStartedSignals.size}. All signals: ${allSignals.map { it.type }}",
88+
1,
89+
sessionStartedSignals.size
90+
)
91+
92+
val launchSignal = sessionStartedSignals.first()
93+
94+
val sdkVersionEntry = launchSignal.payload.find { it.startsWith("${SDK.Version.paramName}:") }
95+
assertNotNull(
96+
"Launch session signal must contain '${SDK.Version.paramName}:...' — enrichment providers were not registered before sessionManager",
97+
sdkVersionEntry
98+
)
99+
100+
val calendarEntry = launchSignal.payload.find { it.startsWith("${Calendar.DayOfWeek.paramName}:") }
101+
assertNotNull(
102+
"Launch session signal must contain '${Calendar.DayOfWeek.paramName}:...' — CalendarParameterProvider was not registered before sessionManager",
103+
calendarEntry
104+
)
105+
}
106+
107+
private fun deleteTrackingFile() {
108+
val appContext = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
109+
val file = File(appContext.filesDir, "telemetrydeckstracking")
110+
if (file.exists()) {
111+
file.delete()
112+
}
113+
}
114+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package com.telemetrydeck.sdk.providers
2+
3+
import androidx.lifecycle.Lifecycle
4+
import androidx.lifecycle.ProcessLifecycleOwner
5+
import com.telemetrydeck.sdk.TelemetryDeck
6+
import com.telemetrydeck.sdk.signals.Session
7+
import org.junit.After
8+
import org.junit.Assert
9+
import org.junit.Before
10+
import org.junit.Test
11+
import org.junit.runner.RunWith
12+
import org.robolectric.RobolectricTestRunner
13+
import org.robolectric.RuntimeEnvironment
14+
import org.robolectric.Shadows
15+
import org.robolectric.annotation.Config
16+
17+
/**
18+
* Regression guard for the launch-session ordering bug:
19+
*
20+
* When the process lifecycle is already at STARTED when TelemetryDeck.build() runs,
21+
* SessionTrackingSignalProvider.register() -> ProcessLifecycleOwner.addObserver() dispatches
22+
* a synchronous catch-up onStart() -> Session.started is emitted at that point. The fix ensures
23+
* all enrichment providers (EnvironmentParameterProvider, CalendarParameterProvider, etc.) are
24+
* registered before sessionManager.register() runs, so the signal is fully enriched and emitted
25+
* exactly once.
26+
*/
27+
@RunWith(RobolectricTestRunner::class)
28+
@Config(sdk = [28])
29+
class SessionTrackingRegressionTest {
30+
31+
@Before
32+
fun setUp() {
33+
TelemetryDeck.instance = null
34+
}
35+
36+
@After
37+
fun tearDown() {
38+
TelemetryDeck.stop()
39+
TelemetryDeck.instance = null
40+
// Reset ProcessLifecycleOwner to below STARTED so tests sharing this sandbox are not
41+
// affected. activityStopped$lifecycle_process() mirrors what happens when the last
42+
// activity stops, transitioning the registry back toward CREATED.
43+
val processOwner = ProcessLifecycleOwner.get()
44+
processOwner.javaClass
45+
.getMethod("activityStopped\$lifecycle_process")
46+
.invoke(processOwner)
47+
Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
48+
}
49+
50+
@Test
51+
fun launch_sessionStarted_is_enriched_and_single_when_process_already_started() {
52+
// Advance ProcessLifecycleOwner to STARTED before build() is called, reproducing the
53+
// scenario where an app's process is already foregrounded when TelemetryDeck initializes.
54+
// activityStarted$lifecycle_process() is the exact method that ReportFragment.onStart()
55+
// and the ActivityLifecycleCallbacks in ProcessLifecycleOwner.attach() invoke in
56+
// production. It is public at the JVM level (Kotlin-module visibility only prevents
57+
// cross-module calls at the Kotlin compiler level).
58+
val processOwner = ProcessLifecycleOwner.get()
59+
processOwner.javaClass
60+
.getMethod("activityStarted\$lifecycle_process")
61+
.invoke(processOwner)
62+
Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
63+
64+
Assert.assertTrue(
65+
"ProcessLifecycleOwner must reach STARTED before build() for this test to be meaningful",
66+
processOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
67+
)
68+
69+
val manager = TelemetryDeck.Builder()
70+
.appID("32CB6574-6732-4238-879F-582FEBEB6536")
71+
.sendNewSessionBeganSignal(true)
72+
.build(RuntimeEnvironment.getApplication())
73+
74+
Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
75+
76+
val signals = manager.cache?.empty() ?: emptyList()
77+
val sessionStartedSignals = signals.filter { it.type == Session.Started.signalName }
78+
79+
Assert.assertEquals(
80+
"Expected exactly one Session.started signal, got ${sessionStartedSignals.size}",
81+
1,
82+
sessionStartedSignals.size
83+
)
84+
85+
val signal = sessionStartedSignals.first()
86+
Assert.assertTrue(
87+
"Session.started must carry TelemetryDeck.SDK.version from EnvironmentParameterProvider",
88+
signal.payload.any { it.startsWith("TelemetryDeck.SDK.version:") }
89+
)
90+
Assert.assertTrue(
91+
"Session.started must carry TelemetryDeck.Calendar.dayOfWeek from CalendarParameterProvider",
92+
signal.payload.any { it.startsWith("TelemetryDeck.Calendar.dayOfWeek:") }
93+
)
94+
}
95+
}

lib/src/test/java/com/telemetrydeck/sdk/providers/SessionTrackingSignalProviderTest.kt

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,6 @@ import org.robolectric.annotation.Config
1313
@Config(sdk = [28])
1414
class SessionTrackingSignalProviderTest {
1515

16-
// Robolectric unit tests leave ProcessLifecycleOwner at INITIALIZED (no activity running),
17-
// so addObserver catch-up does not fire onStart() automatically. Tests drive foreground
18-
// transitions by calling handleOnForeground() directly, which is the same code path invoked
19-
// by onStart(). The regression guard for the double-fire (point 4 in the spec) relies on the
20-
// process already being STARTED when register() runs — that path requires an instrumented
21-
// (device/emulator) test and is not covered here.
22-
2316
@Test
2417
fun sessionStarted_emits_exactly_one_signal_when_foreground_triggered_after_registration() {
2518
val context = RuntimeEnvironment.getApplication()

0 commit comments

Comments
 (0)