diff --git a/android/docs/internal-documentation.md b/android/docs/internal-documentation.md index 6c6337ee5..e051ea5d3 100644 --- a/android/docs/internal-documentation.md +++ b/android/docs/internal-documentation.md @@ -6,6 +6,7 @@ * [Exceptions and ANRs export](#exceptions-and-anrs-export) * [Thread management](#thread-management) * [Configuration](#configuration) +* [Testing](#testing) # Storage @@ -118,6 +119,9 @@ sensitive information from being sent or modifying the behavior of the SDK. Any configuration change made to `MeasureConfig` is a public API change and must also result in updating the documentation. +See [README](../../docs/android/configuration-options.md) for more details about the +available configurations. + ## Applying configs Configs which modify events, like removing fields or decision to drop events are all centralized in @@ -129,10 +133,32 @@ changes the color of the mask applied to the screenshot. These configs are applied at the time of collection itself. -## Remote config +# Testing + +The SDK is tested using both unit tests and integration tests. Certain unit tests which require +Android framework classes are run using Robolectric. The integration tests are run using Espresso +and UI Automator. + +To run unit tests, use the following command: +```shell +./gradlew :measure:test +``` + +To run integration tests (requires a device), use the following command: +```shell +./gradlew :measure:connectedAndroidTest +``` + +The _Measure gradle plugin_ also contains both unit tests and functional tests. The functional tests +are run using the [testkit by autonomous apps](https://github.com/autonomousapps/dependency-analysis-gradle-plugin/tree/main/testkit) +and use JUnit5 for testing as it provides an easy way to run parameterized tests. -Although not implemented yet, the config is expected to be modified from the dashboard, and the -changes should be reflected in the SDK. Config received from the server is also expected to be -persisted and used for subsequent initializations. Once implemented, the config passed in during -initialization will be overridden by the config received from the server. During initialization, the -the persisted config will be used over the config passed in during initialization. +TO run the unit tests, use the following command: +```shell +./gradlew :measure-gradle-plugin:test +``` + +To run the functional tests, use the following command: +```shell +./gradlew :measure-gradle-plugin:functionalTest +``` diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 9c46a885f..0220eb342 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -1,5 +1,6 @@ [versions] agp = "8.5.1" +benchmarkJunit4 = "1.2.4" bundletool = "1.17.0" # android-tools is used for calculating app size and aab size by the gradle plugin. # The version should remain compatible with lower versions of android gradle plugin. New versions @@ -36,6 +37,7 @@ kotlinx-serialization-json = "1.6.2" google-material = "1.11.0" mockito-kotlin = "5.1.0" nhaarman-mockito-kotlin = "2.2.0" +orchestrator = "1.5.0" semver = "1.1.2" squareup-curtains = "1.2.4" squareup-okhttp = "4.12.0" @@ -53,6 +55,7 @@ mavenPublish = "0.29.0" androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" } androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "androidx-annotation" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } +androidx-benchmark-junit4 = { module = "androidx.benchmark:benchmark-junit4", version.ref = "benchmarkJunit4" } androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "androidx-benchmark-macro-junit4" } androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "androidx-constraintlayout" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core-ktx" } @@ -77,6 +80,7 @@ androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", versi androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "androidx-compose-ui" } androidx-compose-runtime-android = { module = "androidx.compose.runtime:runtime-android", version.ref = "androidx-runtime-android" } androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "androidx-uiautomator" } +androidx-orchestrator = { module = "androidx.test:orchestrator", version.ref = "orchestrator" } agp = { module = "com.android.tools.build:gradle", version.ref = "agp" } asm-commons = { module = "org.ow2.asm:asm-commons", version.ref = "asm-util" } asm-util = { module = "org.ow2.asm:asm-util", version.ref = "asm-util" } diff --git a/android/measure/build.gradle.kts b/android/measure/build.gradle.kts index 0c8a865ee..93ae73277 100644 --- a/android/measure/build.gradle.kts +++ b/android/measure/build.gradle.kts @@ -62,6 +62,7 @@ android { defaultConfig { minSdk = 21 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunnerArguments["clearPackageData"] = "true" consumerProguardFiles("consumer-rules.pro") } @@ -88,6 +89,7 @@ android { unitTests { isIncludeAndroidResources = true isReturnDefaultValues = true + execution = "ANDROIDX_TEST_ORCHESTRATOR" } } buildFeatures { @@ -138,7 +140,10 @@ fun configureSpotlessKotlin(spotlessExtension: SpotlessExtension) { spotlessExtension.kotlin { ktlint().apply { editorConfigOverride( - mapOf("max_line_length" to 2147483647), + mapOf( + "max_line_length" to 2147483647, + "ktlint_function_naming_ignore_when_annotated_with" to "Composable", + ), ) } target("src/**/*.kt") @@ -183,4 +188,8 @@ dependencies { androidTestImplementation(libs.androidx.activity.compose) androidTestImplementation(libs.androidx.navigation.compose) androidTestImplementation(libs.androidx.rules) + androidTestImplementation(libs.androidx.uiautomator) + androidTestImplementation(libs.squareup.okhttp.mockwebserver) + androidTestUtil(libs.androidx.orchestrator) + androidTestImplementation(libs.androidx.benchmark.junit4) } diff --git a/android/measure/src/androidTest/AndroidManifest.xml b/android/measure/src/androidTest/AndroidManifest.xml index 67a63a697..2cbd45f79 100644 --- a/android/measure/src/androidTest/AndroidManifest.xml +++ b/android/measure/src/androidTest/AndroidManifest.xml @@ -1,5 +1,4 @@ - - - - - - - - + + + + + + + + diff --git a/android/measure/src/androidTest/java/sh/measure/android/MeasureEventsTest.kt b/android/measure/src/androidTest/java/sh/measure/android/MeasureEventsTest.kt new file mode 100644 index 000000000..6e4d51157 --- /dev/null +++ b/android/measure/src/androidTest/java/sh/measure/android/MeasureEventsTest.kt @@ -0,0 +1,683 @@ +package sh.measure.android + +import android.Manifest +import androidx.benchmark.junit4.PerfettoTraceRule +import androidx.benchmark.perfetto.ExperimentalPerfettoCaptureApi +import androidx.lifecycle.Lifecycle +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.IdlingRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 +import okhttp3.Headers +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Ignore +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import sh.measure.android.config.MeasureConfig +import sh.measure.android.events.EventType +import sh.measure.android.exceptions.ExceptionData +import sh.measure.android.lifecycle.ActivityLifecycleType +import sh.measure.android.lifecycle.AppLifecycleType +import java.util.concurrent.TimeUnit + +/** + * Functional tests for the Measure SDK. + * + * The tests use the Measure SDK as it would be used in a real app. The only difference is that + * periodic export is disabled. The tests help verify that the SDK is able to track events + * and send them to the server. + * + * The tests use a MockWebServer to intercept the requests sent by the SDK and verify that the + * expected events are being sent. + * + * These tests also depend on `ANDROIDX_TEST_ORCHESTRATOR`, which is setup in + * the build.gradle file. Certain tests depend on the application being launched from scratch, + * which is made possible by adding `testInstrumentationRunnerArguments["clearPackageData"] = "true"` + * + * However, the tests do not verify the correctness of the data sent in the events. This can + * be improved in the future by adding a way to parse the multi-part requests made by the SDK. + * + * Also note that certain tests are ignored if the test harness cannot trigger the events + * in a reliable way, these must be verified manually. + * + * See [TestRobot] for helper methods to interact with the test app. + */ +@RunWith(AndroidJUnit4::class) +class MeasureEventsTest { + private lateinit var robot: TestRobot + private val mockWebServer: MockWebServer = MockWebServer() + + @OptIn(ExperimentalPerfettoCaptureApi::class) + @get:Rule + val perfettoRule = PerfettoTraceRule(enableUserspaceTracing = true) + + @Before + fun setup() { + mockWebServer.start(port = 8080) + // mockWebServer.enqueue(MockResponse().setResponseCode(200)) + robot = TestRobot() + } + + @After + fun teardown() { + mockWebServer.shutdown() + } + + @Test + fun tracksExceptionEvent() { + // Given + robot.disableDefaultExceptionHandler() + robot.initializeMeasure(MeasureConfig(enableLogging = true)) + ActivityScenario.launch(TestActivity::class.java).use { + it.moveToState(Lifecycle.State.RESUMED) + it.onActivity { + // When + robot.crashApp() + } + // Then + assertEventTracked(EventType.EXCEPTION) + } + } + + @Test + fun givenScreenshotOnCrashEnabledThenTracksExceptionEventWithScreenshot() { + // Given + robot.disableDefaultExceptionHandler() + robot.initializeMeasure(MeasureConfig(enableLogging = true, trackScreenshotOnCrash = true)) + ActivityScenario.launch(TestActivity::class.java).use { + it.moveToState(Lifecycle.State.RESUMED) + it.onActivity { + // When + robot.crashApp() + } + val requestBody = getLastRequestBody() + + // Then + assertEventTracked(requestBody, EventType.EXCEPTION) + assertScreenshot(requestBody, true) + } + } + + @Test + fun givenScreenshotOnCrashDisabledThenTracksExceptionEventWithoutScreenshot() { + // Given + robot.disableDefaultExceptionHandler() + robot.initializeMeasure(MeasureConfig(enableLogging = true, trackScreenshotOnCrash = false)) + ActivityScenario.launch(TestActivity::class.java).use { + it.moveToState(Lifecycle.State.RESUMED) + it.onActivity { + // When + robot.crashApp() + } + val requestBody = getLastRequestBody() + + // Then + assertEventTracked(requestBody, EventType.EXCEPTION) + assertScreenshot(requestBody, false) + } + } + + @Test + fun tracksAnrEvent() { + // Given + robot.initializeMeasure(MeasureConfig(enableLogging = true)) + ActivityScenario.launch(TestActivity::class.java).use { + it.moveToState(Lifecycle.State.RESUMED) + + // When + triggerAnr() + + // Then + assertEventTracked(EventType.ANR) + } + } + + @Test + fun givenScreenshotOnCrashEnabledThenTracksAnrEventWithScreenshot() { + // Given + robot.initializeMeasure(MeasureConfig(enableLogging = true, trackScreenshotOnCrash = true)) + ActivityScenario.launch(TestActivity::class.java).use { + it.moveToState(Lifecycle.State.RESUMED) + + // When + triggerAnr() + val requestBody = getLastRequestBody() + + // Then + assertEventTracked(requestBody, EventType.ANR) + assertScreenshot(requestBody, true) + } + } + + @Test + fun givenScreenshotOnCrashDisabledThenTracksAnrEventWithoutScreenshot() { + // Given + robot.initializeMeasure(MeasureConfig(enableLogging = true, trackScreenshotOnCrash = false)) + ActivityScenario.launch(TestActivity::class.java).use { + it.moveToState(Lifecycle.State.RESUMED) + + // When + triggerAnr() + val requestBody = getLastRequestBody() + + // Then + assertEventTracked(requestBody, EventType.ANR) + assertScreenshot(requestBody, false) + } + } + + @Test + fun tracksGestureViewClickEvent() { + // Given + robot.initializeMeasure(MeasureConfig(enableLogging = true)) + ActivityScenario.launch(TestActivity::class.java).use { + it.moveToState(Lifecycle.State.RESUMED) + + // When + robot.clickButton() + triggerExport() + + // Then + assertEventTracked(EventType.CLICK) + } + } + + @Test + fun tracksGestureComposeClickEvent() { + // Given + robot.initializeMeasure(MeasureConfig(enableLogging = true)) + ActivityScenario.launch(TestActivity::class.java).use { + it.moveToState(Lifecycle.State.RESUMED) + + // When + robot.clickComposeButton() + triggerExport() + val requestBody = getLastRequestBody() + + // Then + assertEventTracked(requestBody, EventType.CLICK) + // simply checking for click might introduce a false positive + // so we also check the expected body of the event + Assert.assertTrue(requestBody.contains("compose_button")) + Assert.assertTrue(requestBody.contains("androidx.compose.ui.platform.AndroidComposeView")) + } + } + + @Test + fun tracksGestureLongClickEvent() { + // Given + robot.initializeMeasure(MeasureConfig(enableLogging = true)) + ActivityScenario.launch(TestActivity::class.java).use { + it.moveToState(Lifecycle.State.RESUMED) + + // When + robot.longClickButton() + triggerExport() + + // Then + assertEventTracked(EventType.LONG_CLICK) + } + } + + @Test + fun tracksGestureScrollEvent() { + // Given + robot.initializeMeasure(MeasureConfig(enableLogging = true)) + ActivityScenario.launch(TestActivity::class.java).use { + it.moveToState(Lifecycle.State.RESUMED) + + // When + robot.scrollDown() + triggerExport() + + // Then + assertEventTracked(EventType.SCROLL) + } + } + + @Test + fun tracksGestureComposeScrollEvent() { + // Given + robot.initializeMeasure(MeasureConfig(enableLogging = true)) + ActivityScenario.launch(TestActivity::class.java).use { + it.moveToState(Lifecycle.State.RESUMED) + + // When + robot.composeScrollDown() + triggerExport() + + // Then + assertEventTracked(EventType.SCROLL) + } + } + + @Test + fun tracksLifecycleActivityEvents() { + // Given + robot.initializeMeasure(MeasureConfig(enableLogging = true)) + ActivityScenario.launch(TestActivity::class.java).use { + it.moveToState(Lifecycle.State.RESUMED) + + // When + it.moveToState(Lifecycle.State.DESTROYED) + triggerExport() + val body = getLastRequestBody() + + // Then + Assert.assertTrue(body.containsEvent(EventType.LIFECYCLE_ACTIVITY)) + Assert.assertTrue(body.contains(ActivityLifecycleType.CREATED)) + Assert.assertTrue(body.contains(ActivityLifecycleType.RESUMED)) + Assert.assertTrue(body.contains(ActivityLifecycleType.PAUSED)) + Assert.assertTrue(body.contains(ActivityLifecycleType.DESTROYED)) + } + } + + @Test + fun tracksLifecycleApplicationEvents() { + // Given + robot.initializeMeasure(MeasureConfig(enableLogging = true)) + ActivityScenario.launch(TestActivity::class.java).use { + it.moveToState(Lifecycle.State.RESUMED) + + // When + robot.pressHomeButton() + triggerExport() + val body = getLastRequestBody() + + // Then + Assert.assertTrue(body.containsEvent(EventType.LIFECYCLE_APP)) + Assert.assertTrue(body.contains(AppLifecycleType.FOREGROUND)) + Assert.assertTrue(body.contains(AppLifecycleType.BACKGROUND)) + } + } + + @Test + fun tracksColdLaunchEvent() { + // Given + robot.initializeMeasure(MeasureConfig(enableLogging = true)) + ActivityScenario.launch(TestActivity::class.java).use { + // WHen + it.moveToState(Lifecycle.State.RESUMED) + triggerExport() + + // Then + assertEventTracked(EventType.COLD_LAUNCH) + } + } + + @Test + fun tracksWarmLaunchEvent() { + robot.initializeMeasure(MeasureConfig(enableLogging = true)) + ActivityScenario.launch(TestActivity::class.java).use { + it.recreate() + triggerExport() + assertEventTracked(EventType.WARM_LAUNCH) + } + } + + @Test + @Ignore("Unable to trigger hot launch in tests") + fun tracksHotLaunchEvent() { + // Implementation would go here if we could reliably trigger a hot launch in tests + } + + @Test + @Ignore("Changing network requires a real internet connection to be available") + fun tracksNetworkChangeEvent() { + robot.grantPermissions(Manifest.permission.ACCESS_NETWORK_STATE) + robot.initializeMeasure(MeasureConfig(enableLogging = true)) + ActivityScenario.launch(TestActivity::class.java).use { + val networkEnabled = robot.isInternetAvailable() + robot.enableWiFi(!networkEnabled) + robot.enableMobileData(!networkEnabled) + // Network state change takes some time to reflect, so wait for a bit + // an idle resource would be better. + Thread.sleep(3000) + triggerExport() + + // reset network state + robot.enableWiFi(networkEnabled) + robot.enableMobileData(networkEnabled) + assertEventTracked(EventType.NETWORK_CHANGE) + } + } + + @Test + fun givenDefaultConfigThenTracksHttpEvent() { + // Given + robot.initializeMeasure(MeasureConfig(enableLogging = true)) + ActivityScenario.launch(TestActivity::class.java).use { + it.moveToState(Lifecycle.State.RESUMED) + it.onActivity { activity -> + IdlingRegistry.getInstance().register(activity.httpIdlingResource) + + // When + robot.makeNetworkRequest(activity, "http://example:8080") + activity.httpIdlingResource.registerIdleTransitionCallback { + triggerExport() + + // Then + assertEventTracked(EventType.HTTP) + } + IdlingRegistry.getInstance().unregister(activity.httpIdlingResource) + } + } + } + + @Test + fun givenUrlAllowlistContainsUrlThenTracksHttpEvent() { + // Given + robot.initializeMeasure( + MeasureConfig( + enableLogging = true, + httpUrlAllowlist = listOf("allowed"), + ), + ) + ActivityScenario.launch(TestActivity::class.java).use { + it.moveToState(Lifecycle.State.RESUMED) + it.onActivity { activity -> + IdlingRegistry.getInstance().register(activity.httpIdlingResource) + + // When + robot.makeNetworkRequest(activity, "http://allowed.com") + activity.httpIdlingResource.registerIdleTransitionCallback { + triggerExport() + + // Then + assertEventTracked(EventType.HTTP) + } + IdlingRegistry.getInstance().unregister(activity.httpIdlingResource) + } + } + } + + @Test + fun givenUrlAllowlistDoesNotContainUrlThenDoesNotTrackHttpEvent() { + // GIven + robot.initializeMeasure( + MeasureConfig( + enableLogging = true, + httpUrlAllowlist = listOf("allowed.com"), + ), + ) + ActivityScenario.launch(TestActivity::class.java).use { + it.moveToState(Lifecycle.State.RESUMED) + it.onActivity { activity -> + IdlingRegistry.getInstance().register(activity.httpIdlingResource) + + // When + robot.makeNetworkRequest(activity, "http://notallowed.com") + activity.httpIdlingResource.registerIdleTransitionCallback { + triggerExport() + + // Then + assertEventNotTracked(EventType.HTTP) + } + IdlingRegistry.getInstance().unregister(activity.httpIdlingResource) + } + } + } + + @Test + fun givenUrlBlocklistContainsUrlThenDoesNotTrackHttpEvent() { + // Given + robot.initializeMeasure( + MeasureConfig( + enableLogging = true, + httpUrlBlocklist = listOf("disallowed"), + ), + ) + ActivityScenario.launch(TestActivity::class.java).use { + it.moveToState(Lifecycle.State.RESUMED) + it.onActivity { activity -> + IdlingRegistry.getInstance().register(activity.httpIdlingResource) + + // When + robot.makeNetworkRequest(activity, "http://disallowed.com") + activity.httpIdlingResource.registerIdleTransitionCallback { + triggerExport() + + // Then + assertEventNotTracked(EventType.HTTP) + } + IdlingRegistry.getInstance().unregister(activity.httpIdlingResource) + } + } + } + + @Test + fun givenTrackBodyAndTrackHeadersAreEnabledThenTracksHeaders() { + // Given + robot.initializeMeasure( + MeasureConfig( + enableLogging = true, + trackHttpBody = true, + trackHttpHeaders = true, + ), + ) + ActivityScenario.launch(TestActivity::class.java).use { + it.moveToState(Lifecycle.State.RESUMED) + it.onActivity { activity -> + IdlingRegistry.getInstance().register(activity.httpIdlingResource) + + // When + robot.makeNetworkRequest( + activity, + "http://example.com", + headers = Headers.Builder().add("x-header-key", "x-header-value").build(), + ) + activity.httpIdlingResource.registerIdleTransitionCallback { + triggerExport() + + // Then + val body = getLastRequestBody() + assertEventTracked(body, EventType.HTTP) + Assert.assertTrue(body.contains("x-header-key")) + Assert.assertTrue(body.contains("x-header-value")) + } + IdlingRegistry.getInstance().unregister(activity.httpIdlingResource) + } + } + } + + @Test + fun givenTrackHeadersIsDisabledThenDoesNotTrackHeaders() { + // Given + robot.initializeMeasure( + MeasureConfig( + enableLogging = true, + trackHttpBody = true, + trackHttpHeaders = false, + ), + ) + ActivityScenario.launch(TestActivity::class.java).use { + it.moveToState(Lifecycle.State.RESUMED) + it.onActivity { activity -> + IdlingRegistry.getInstance().register(activity.httpIdlingResource) + + // When + robot.makeNetworkRequest( + activity, + "http://example.com", + headers = Headers.Builder().add("x-header-key", "x-header-value").build(), + ) + activity.httpIdlingResource.registerIdleTransitionCallback { + triggerExport() + + // Then + val body = getLastRequestBody() + assertEventTracked(body, EventType.HTTP) + Assert.assertFalse(body.contains("x-header-key")) + Assert.assertFalse(body.contains("x-header-value")) + } + IdlingRegistry.getInstance().unregister(activity.httpIdlingResource) + } + } + } + + @Test + fun givenTrackBodyIsDisabledThenDoesNotTrackBody() { + // Given + robot.initializeMeasure( + MeasureConfig( + enableLogging = true, + trackHttpBody = false, + ), + ) + ActivityScenario.launch(TestActivity::class.java).use { + it.moveToState(Lifecycle.State.RESUMED) + it.onActivity { activity -> + IdlingRegistry.getInstance().register(activity.httpIdlingResource) + + // When + robot.makeNetworkRequest( + activity, + "http://example.com", + headers = Headers.Builder().add("x-header-key", "x-header-value").build(), + requestBody = "request_body", + ) + activity.httpIdlingResource.registerIdleTransitionCallback { + triggerExport() + val body = getLastRequestBody() + + // Then + assertEventTracked(body, EventType.HTTP) + Assert.assertFalse(body.contains("request_body")) + } + IdlingRegistry.getInstance().unregister(activity.httpIdlingResource) + } + } + } + + @Test + fun givenTrackBodyIsEnabledThenTracksBody() { + // Given + robot.initializeMeasure( + MeasureConfig( + enableLogging = true, + trackHttpBody = true, + ), + ) + ActivityScenario.launch(TestActivity::class.java).use { + it.moveToState(Lifecycle.State.RESUMED) + it.onActivity { activity -> + IdlingRegistry.getInstance().register(activity.httpIdlingResource) + + // When + robot.makeNetworkRequest( + activity, + "http://example.com", + headers = Headers.Builder().add("x-header-key", "x-header-value").build(), + requestBody = "request_body", + ) + activity.httpIdlingResource.registerIdleTransitionCallback { + triggerExport() + + // Then + val body = getLastRequestBody() + assertEventTracked(body, EventType.HTTP) + Assert.assertTrue(body.contains("request_body")) + } + IdlingRegistry.getInstance().unregister(activity.httpIdlingResource) + } + } + } + + @Test + fun tracksMemoryUsageEvent() { + // Given + robot.initializeMeasure(MeasureConfig(enableLogging = true)) + ActivityScenario.launch(TestActivity::class.java).use { + // When + it.moveToState(Lifecycle.State.RESUMED) + triggerExport() + + // Then + assertEventTracked(EventType.MEMORY_USAGE) + } + } + + @Test + fun tracksCpuUsageEvent() { + // Given + robot.initializeMeasure(MeasureConfig(enableLogging = true)) + ActivityScenario.launch(TestActivity::class.java).use { + // When + it.moveToState(Lifecycle.State.RESUMED) + triggerExport() + + // Then + assertEventTracked(EventType.CPU_USAGE) + } + } + + @Test + @Ignore("Unable to trigger trim memory callbacks in tests") + fun tracksTrimMemoryEvent() { + // Implementation would go here if we could reliably trigger trim memory in tests + } + + private fun String.containsEvent(eventType: String): Boolean { + return contains("\"type\":\"$eventType\"") + } + + private fun triggerExport() { + Measure.simulateAppCrash( + type = EventType.EXCEPTION, + data = ExceptionData( + exceptions = emptyList(), + threads = emptyList(), + handled = false, + foreground = true, + ), + timestamp = 987654321L, + attributes = mutableMapOf(), + attachments = mutableListOf(), + ) + } + + private fun triggerAnr() { + Measure.simulateAnr( + data = ExceptionData( + exceptions = emptyList(), + threads = emptyList(), + handled = false, + foreground = true, + ), + timestamp = 987654321L, + attributes = mutableMapOf(), + attachments = mutableListOf(), + ) + } + + private fun assertEventTracked(body: String, eventType: String) { + Assert.assertTrue(body.containsEvent(eventType)) + } + + private fun assertScreenshot(requestBody: String, expected: Boolean) { + if (expected) { + Assert.assertTrue(requestBody.contains("\"type\":\"screenshot\"")) + } else { + Assert.assertFalse(requestBody.contains("\"type\":\"screenshot\"")) + } + } + + private fun assertEventTracked(eventType: String) { + val body = getLastRequestBody() + Assert.assertTrue(body.containsEvent(eventType)) + } + + private fun assertEventNotTracked(eventType: String) { + val body = getLastRequestBody() + Assert.assertFalse(body.containsEvent(eventType)) + } + + private fun getLastRequestBody(): String { + val request = mockWebServer.takeRequest(timeout = 500, unit = TimeUnit.MILLISECONDS) + Assert.assertNotNull(request) + return request!!.body.readUtf8() + } +} diff --git a/android/measure/src/androidTest/java/sh/measure/android/NoopPeriodicEventExporter.kt b/android/measure/src/androidTest/java/sh/measure/android/NoopPeriodicEventExporter.kt new file mode 100644 index 000000000..537f26226 --- /dev/null +++ b/android/measure/src/androidTest/java/sh/measure/android/NoopPeriodicEventExporter.kt @@ -0,0 +1,17 @@ +package sh.measure.android + +import sh.measure.android.exporter.PeriodicEventExporter + +internal class NoopPeriodicEventExporter : PeriodicEventExporter { + override fun onAppForeground() { + // No-op + } + + override fun onAppBackground() { + // No-op + } + + override fun onColdLaunch() { + // No-op + } +} diff --git a/android/measure/src/androidTest/java/sh/measure/android/TestActivity.kt b/android/measure/src/androidTest/java/sh/measure/android/TestActivity.kt index 85778a869..b423b97fd 100644 --- a/android/measure/src/androidTest/java/sh/measure/android/TestActivity.kt +++ b/android/measure/src/androidTest/java/sh/measure/android/TestActivity.kt @@ -1,12 +1,122 @@ package sh.measure.android -import android.app.Activity import android.os.Bundle +import android.widget.Button +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.compose.ui.unit.dp +import androidx.test.espresso.idling.CountingIdlingResource +import okhttp3.Call +import okhttp3.Callback +import okhttp3.Headers +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import sh.measure.android.okhttp.MeasureEventListenerFactory import sh.measure.android.test.R +import java.io.IOException -class TestActivity : Activity() { +class TestActivity : ComponentActivity() { + companion object { + const val IDLING_RES_HTTP_REQUEST = "http_request" + } + + val httpIdlingResource = CountingIdlingResource(IDLING_RES_HTTP_REQUEST) + + @OptIn(ExperimentalComposeUiApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_test) + findViewById