Skip to content

Commit ece9581

Browse files
runningcodeclaude
andauthored
test: Add an in-process MockSentryServer to sentry-test-support (#6266)
First step of moving the sample system tests into the modules they cover. The server stands in for the Python mock server: it binds a free port, accepts envelopes through the SDK's real HTTP transport, and lets tests wait for a matching one. The release-minified UI test APK pulls in sentry-test-support, so R8 needs to be told the JDK-only com.sun.net.httpserver classes are absent. Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 37d61d8 commit ece9581

4 files changed

Lines changed: 124 additions & 0 deletions

File tree

‎sentry-android-integration-tests/sentry-uitest-android/proguard-rules.pro‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
-dontwarn com.google.errorprone.**
2626
-dontwarn com.sun.jna.**
27+
-dontwarn com.sun.net.httpserver.**
2728
-dontwarn edu.umd.cs.findbugs.**
2829
-dontwarn java.lang.instrument.**
2930
-dontwarn java.lang.management.**

‎sentry-test-support/build.gradle.kts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,6 @@ dependencies {
3030
implementation(kotlin(Config.kotlinStdLib))
3131
implementation(libs.kotlin.test.junit)
3232
implementation(libs.mockito.kotlin)
33+
34+
testImplementation(libs.google.truth)
3335
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package io.sentry.test
2+
3+
import com.sun.net.httpserver.HttpExchange
4+
import com.sun.net.httpserver.HttpServer
5+
import io.sentry.JsonSerializer
6+
import io.sentry.SentryEnvelope
7+
import io.sentry.SentryOptions
8+
import io.sentry.time.Deadline
9+
import io.sentry.time.JavaMonotonicTicker
10+
import java.io.Closeable
11+
import java.net.InetAddress
12+
import java.net.InetSocketAddress
13+
import java.util.concurrent.CopyOnWriteArrayList
14+
import java.util.concurrent.TimeUnit
15+
import java.util.zip.GZIPInputStream
16+
17+
/**
18+
* A stand-in for Sentry's ingestion endpoint that integration tests point the SDK at through [dsn].
19+
*
20+
* Envelopes travel through the real HTTP transport, so serialization, compression and the DSN are
21+
* covered too. It binds a free port so that test tasks running in parallel don't clash, and it
22+
* works for an app in a child process as well as in the test's own JVM.
23+
*/
24+
class MockSentryServer : Closeable {
25+
private val serializer = JsonSerializer(SentryOptions.empty())
26+
private val received = CopyOnWriteArrayList<SentryEnvelope>()
27+
private val server =
28+
HttpServer.create(InetSocketAddress(InetAddress.getByName(HOST), 0), 0).apply {
29+
createContext("/api/") { exchange -> receive(exchange) }
30+
start()
31+
}
32+
33+
val dsn: String = "http://key@$HOST:${server.address.port}/0"
34+
35+
val envelopes: List<SentryEnvelope>
36+
get() = received.toList()
37+
38+
/** Returns the first received envelope matching [predicate], waiting up to [timeoutMillis]. */
39+
fun awaitEnvelope(
40+
timeoutMillis: Long = DEFAULT_TIMEOUT_MILLIS,
41+
predicate: (SentryEnvelope) -> Boolean,
42+
): SentryEnvelope {
43+
val deadline =
44+
Deadline.after(JavaMonotonicTicker.getInstance(), timeoutMillis, TimeUnit.MILLISECONDS)
45+
while (true) {
46+
received.firstOrNull(predicate)?.let {
47+
return it
48+
}
49+
if (deadline.hasPassed()) {
50+
throw AssertionError(
51+
"No matching envelope within ${timeoutMillis}ms, received ${received.size} envelopes"
52+
)
53+
}
54+
Thread.sleep(POLL_INTERVAL_MILLIS)
55+
}
56+
}
57+
58+
fun reset() {
59+
received.clear()
60+
}
61+
62+
override fun close() {
63+
server.stop(0)
64+
}
65+
66+
private fun receive(exchange: HttpExchange) {
67+
try {
68+
val body =
69+
if (exchange.requestHeaders.getFirst("Content-Encoding") == "gzip") {
70+
GZIPInputStream(exchange.requestBody)
71+
} else {
72+
exchange.requestBody
73+
}
74+
serializer.deserializeEnvelope(body)?.let { received.add(it) }
75+
exchange.sendResponseHeaders(200, -1)
76+
} finally {
77+
exchange.close()
78+
}
79+
}
80+
81+
private companion object {
82+
// An IP rather than "localhost", which can resolve to IPv4 for the server and IPv6 for the SDK.
83+
const val HOST = "127.0.0.1"
84+
const val DEFAULT_TIMEOUT_MILLIS = 10_000L
85+
const val POLL_INTERVAL_MILLIS = 50L
86+
}
87+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package io.sentry.test
2+
3+
import com.google.common.truth.Truth.assertThat
4+
import io.sentry.Sentry
5+
import io.sentry.SentryItemType
6+
import kotlin.test.AfterTest
7+
import kotlin.test.Test
8+
import kotlin.test.assertFailsWith
9+
10+
class MockSentryServerTest {
11+
private val server = MockSentryServer()
12+
13+
@AfterTest
14+
fun teardown() {
15+
Sentry.close()
16+
server.close()
17+
}
18+
19+
@Test
20+
fun `receives an event sent through the HTTP transport`() {
21+
initForTest { it.dsn = server.dsn }
22+
23+
val eventId = Sentry.captureMessage("hello")
24+
Sentry.flush(5000)
25+
26+
val envelope = server.awaitEnvelope { it.header.eventId == eventId }
27+
assertThat(envelope.items.single().header.type).isEqualTo(SentryItemType.Event)
28+
}
29+
30+
@Test
31+
fun `awaitEnvelope fails once the timeout passes without a match`() {
32+
assertFailsWith<AssertionError> { server.awaitEnvelope(timeoutMillis = 100) { true } }
33+
}
34+
}

0 commit comments

Comments
 (0)