@goliapkg/sentori-react-native 6.1.0 → 6.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/SentoriReactNative.podspec +11 -4
  2. package/android/build.gradle +15 -1
  3. package/android/src/main/java-core/com/sentori/Sentori.kt +244 -0
  4. package/android/src/main/{java → java-core}/com/sentori/SentoriAnrWatchdog.kt +3 -0
  5. package/android/src/main/java-core/com/sentori/SentoriConfig.kt +61 -0
  6. package/android/src/main/{java → java-core}/com/sentori/SentoriCrashHandler.kt +3 -0
  7. package/android/src/main/java-core/com/sentori/SentoriDevice.kt +61 -0
  8. package/android/src/main/{java → java-core}/com/sentori/SentoriFirebaseMessagingService.kt +3 -0
  9. package/android/src/main/{java → java-core}/com/sentori/SentoriForegroundActivity.kt +3 -0
  10. package/android/src/main/java-core/com/sentori/SentoriIdentity.kt +101 -0
  11. package/android/src/main/{java → java-core}/com/sentori/SentoriMobileVitals.kt +3 -0
  12. package/android/src/main/{java → java-core}/com/sentori/SentoriNativeExceptionBridge.kt +3 -0
  13. package/android/src/main/{java → java-core}/com/sentori/SentoriNativeSignals.kt +3 -0
  14. package/android/src/main/java-core/com/sentori/SentoriPush.kt +359 -0
  15. package/android/src/main/{java → java-core}/com/sentori/SentoriPushNotifications.kt +3 -0
  16. package/android/src/main/{java → java-core}/com/sentori/SentoriReplayCapture.kt +3 -0
  17. package/android/src/main/java-core/com/sentori/SentoriScope.kt +60 -0
  18. package/android/src/main/{java → java-core}/com/sentori/SentoriScreenshotCapture.kt +3 -0
  19. package/android/src/main/java-core/com/sentori/SentoriSignalRing.kt +80 -0
  20. package/android/src/main/java-core/com/sentori/SentoriTransport.kt +383 -0
  21. package/android/src/test/{java → java-core}/com/sentori/SentoriCrashHandlerTest.kt +3 -0
  22. package/android/src/test/java-core/com/sentori/SentoriIdentityTest.kt +107 -0
  23. package/android/src/test/java-core/com/sentori/SentoriLiveServerTest.kt +106 -0
  24. package/android/src/test/java-core/com/sentori/SentoriPushTest.kt +151 -0
  25. package/android/src/test/{java → java-core}/com/sentori/SentoriScreenshotCaptureTest.kt +3 -0
  26. package/android/src/test/java-core/com/sentori/SentoriVerbsTest.kt +185 -0
  27. package/ios/{SentoriCrashHandler.swift → core/SentoriCrashHandler.swift} +16 -0
  28. package/ios/{SentoriHangWatchdog.swift → core/SentoriHangWatchdog.swift} +3 -0
  29. package/ios/{SentoriMobileVitals.swift → core/SentoriMobileVitals.swift} +3 -0
  30. package/ios/{SentoriNativeExceptionBridge.swift → core/SentoriNativeExceptionBridge.swift} +3 -0
  31. package/ios/core/SentoriPush.swift +340 -0
  32. package/ios/{SentoriPushNotifications.swift → core/SentoriPushNotifications.swift} +42 -0
  33. package/ios/{SentoriReplayCapture.swift → core/SentoriReplayCapture.swift} +3 -0
  34. package/ios/{SentoriScreenshotCapture.swift → core/SentoriScreenshotCapture.swift} +14 -0
  35. package/ios/{SentoriThreadSampler.swift → core/SentoriThreadSampler.swift} +3 -0
  36. package/lib/push.d.ts +7 -6
  37. package/lib/push.d.ts.map +1 -1
  38. package/lib/push.js +8 -0
  39. package/lib/push.js.map +1 -1
  40. package/lib/transport.js +1 -1
  41. package/package.json +1 -1
  42. package/src/push.ts +14 -6
  43. package/src/transport.ts +1 -1
  44. package/ios/Tests/SentoriCrashHandlerTests.swift +0 -59
  45. package/ios/Tests/SentoriScreenshotCaptureTests.swift +0 -59
  46. package/ios/Tests/SentoriThreadSamplerTests.swift +0 -96
@@ -0,0 +1,383 @@
1
+ // GENERATED MIRROR — do not edit.
2
+ // Source of truth: sdk/native/android/src/main/java/com/sentori/SentoriTransport.kt
3
+ // Run `node scripts/sync-native-core.mjs` after editing it.
4
+ package com.sentori
5
+
6
+ import java.io.File
7
+ import java.net.HttpURLConnection
8
+ import java.net.URL
9
+ import java.util.concurrent.Executors
10
+ import java.util.concurrent.ScheduledExecutorService
11
+ import java.util.concurrent.ScheduledFuture
12
+ import java.util.concurrent.TimeUnit
13
+ import org.json.JSONArray
14
+ import org.json.JSONObject
15
+
16
+ /**
17
+ * The only place the SDK talks to the network.
18
+ *
19
+ * Ported from `sdk/react-native/src/transport.ts` and matching the
20
+ * Swift one: batch on a 5 s timer or a 10-deep queue, whichever comes
21
+ * first; three attempts with a doubling delay; a 429 waits out
22
+ * `retryAfterMs`; anything left after that spills to disk and drains
23
+ * on the next launch.
24
+ *
25
+ * The iron rule is harder here than in JavaScript. A JS verb returns
26
+ * because everything after it is a microtask; the JVM has no such
27
+ * floor, so "fire and forget" is a real queue on a real background
28
+ * thread. [enqueue] does one bounded append under a lock and returns —
29
+ * no encoding, no I/O.
30
+ *
31
+ * `HttpURLConnection` rather than OkHttp: a monitoring SDK that drags
32
+ * a networking library into every host app has already broken the
33
+ * footprint half of its own contract, and OkHttp would also mean this
34
+ * SDK's traffic goes through the same interceptors the host installed
35
+ * for its own — which is a surprising place for a crash report to be
36
+ * logged, retried or blocked.
37
+ */
38
+ object SentoriTransport {
39
+
40
+ private const val FLUSH_INTERVAL_MS = 5_000L
41
+ private const val BATCH_SIZE = 10
42
+ private const val MAX_RETRY = 3
43
+ private const val MAX_PERSISTED = 1000
44
+
45
+ /**
46
+ * Bounded, because an unbounded in-memory queue is a leak with a
47
+ * nicer name. Past it the oldest go: a crash from ten minutes ago
48
+ * matters less than the one happening now.
49
+ */
50
+ private const val MAX_QUEUED = 500
51
+
52
+ private val lock = Any()
53
+ private val queue = ArrayDeque<Map<String, Any?>>()
54
+ private val assertStats = mutableMapOf<String, MutableMap<String, Any?>>()
55
+ private var started = false
56
+ private var dropped = 0
57
+ private var delivered = 0
58
+ private var pending: ScheduledFuture<*>? = null
59
+
60
+ /** Where the spill lives. Set by [start]; null keeps it in memory only. */
61
+ private var spillDir: File? = null
62
+
63
+ private val worker: ScheduledExecutorService =
64
+ Executors.newSingleThreadScheduledExecutor { r ->
65
+ Thread(r, "sentori-transport").apply { isDaemon = true }
66
+ }
67
+
68
+ /** O(1) on the calling thread: append, maybe schedule. */
69
+ @JvmStatic
70
+ fun enqueue(event: Map<String, Any?>) {
71
+ val due: Boolean
72
+ synchronized(lock) {
73
+ queue.addLast(event)
74
+ while (queue.size > MAX_QUEUED) {
75
+ queue.removeFirst()
76
+ dropped += 1
77
+ }
78
+ due = queue.size >= BATCH_SIZE
79
+ }
80
+ if (due) worker.execute { flush() } else scheduleFlush(FLUSH_INTERVAL_MS)
81
+ }
82
+
83
+ /**
84
+ * Assert outcomes aggregate rather than becoming events, and ride
85
+ * whatever batch goes out next — the liveness ledger without a
86
+ * heartbeat flood.
87
+ */
88
+ @JvmStatic
89
+ fun countAssert(name: String, ok: Boolean, release: String) {
90
+ val idle: Boolean
91
+ synchronized(lock) {
92
+ val key = "$name$release"
93
+ val stat =
94
+ assertStats.getOrPut(key) {
95
+ mutableMapOf(
96
+ "name" to name,
97
+ "release" to release,
98
+ "passDelta" to 0,
99
+ "failDelta" to 0,
100
+ )
101
+ }
102
+ val field = if (ok) "passDelta" else "failDelta"
103
+ stat[field] = (stat[field] as Int) + 1
104
+ idle = queue.isEmpty()
105
+ }
106
+ if (idle) scheduleFlush(FLUSH_INTERVAL_MS * 6)
107
+ }
108
+
109
+ @JvmStatic
110
+ @JvmOverloads
111
+ fun start(spill: File? = null) {
112
+ synchronized(lock) {
113
+ started = true
114
+ spillDir = spill
115
+ }
116
+ worker.execute { drainPersisted() }
117
+ }
118
+
119
+ @JvmStatic
120
+ fun flush() {
121
+ // Before touching the queue, not after: draining first and
122
+ // checking second destroys anything enqueued before init.
123
+ val config = SentoriConfig.current ?: return
124
+
125
+ val events: List<Map<String, Any?>>
126
+ val stats: List<Map<String, Any?>>
127
+ val lost: Int
128
+ synchronized(lock) {
129
+ if (!started || (queue.isEmpty() && assertStats.isEmpty())) return
130
+ events = queue.toList()
131
+ stats = assertStats.values.map { it.toMap() }
132
+ lost = dropped
133
+ queue.clear()
134
+ assertStats.clear()
135
+ dropped = 0
136
+ pending?.cancel(false)
137
+ pending = null
138
+ }
139
+
140
+ val envelope = mutableMapOf<String, Any?>("events" to events)
141
+ if (stats.isNotEmpty()) envelope["assertStats"] = stats
142
+ config.backendHealthUrl?.let { envelope["backendHealthUrl"] = it }
143
+ // Say so rather than let the gap look like quiet: a hole in a
144
+ // timeline otherwise reads as a calm period.
145
+ if (lost > 0) envelope["droppedEvents"] = lost
146
+
147
+ worker.execute {
148
+ when (sendWithRetry(envelope, config)) {
149
+ Outcome.DELIVERED -> synchronized(lock) { delivered += events.size }
150
+ // Handled, but not accepted. Nothing to retry and
151
+ // nothing to count.
152
+ Outcome.DROPPED -> Unit
153
+ else -> persist(events)
154
+ }
155
+ }
156
+ }
157
+
158
+ // ── the network ───────────────────────────────────────────────
159
+
160
+ private enum class Outcome {
161
+ DELIVERED,
162
+ DROPPED,
163
+ RETRY_AFTER,
164
+ FAILED,
165
+ }
166
+
167
+ private var lastRetryAfterMs = 5_000L
168
+
169
+ /**
170
+ * Returns the terminal outcome rather than a bool. "Stop trying"
171
+ * would conflate a 2xx with a 4xx — not worth retrying is not the
172
+ * same as delivered, and a counter built on the bool counts a
173
+ * rejected batch as a success.
174
+ */
175
+ private fun sendWithRetry(envelope: Map<String, Any?>, config: SentoriConfig): Outcome {
176
+ var delay = 1_000L
177
+ repeat(MAX_RETRY) { attempt ->
178
+ when (val outcome = sendOnce(envelope, config)) {
179
+ Outcome.DELIVERED,
180
+ Outcome.DROPPED -> return outcome
181
+ Outcome.RETRY_AFTER -> {
182
+ if (attempt == MAX_RETRY - 1) return outcome
183
+ sleep(lastRetryAfterMs)
184
+ }
185
+ Outcome.FAILED -> {
186
+ if (attempt == MAX_RETRY - 1) return outcome
187
+ sleep(delay)
188
+ delay *= 2
189
+ }
190
+ }
191
+ }
192
+ return Outcome.FAILED
193
+ }
194
+
195
+ private fun sendOnce(envelope: Map<String, Any?>, config: SentoriConfig): Outcome {
196
+ val body =
197
+ try {
198
+ toJson(envelope).toString()
199
+ } catch (_: Throwable) {
200
+ // Unencodable content is our problem, not the host's,
201
+ // and it will not become encodable on a retry.
202
+ return Outcome.DROPPED
203
+ }
204
+
205
+ var conn: HttpURLConnection? = null
206
+ return try {
207
+ conn = URL("${config.ingestUrl}/v1/events:batch").openConnection() as HttpURLConnection
208
+ conn.requestMethod = "POST"
209
+ conn.setRequestProperty("Content-Type", "application/json")
210
+ conn.setRequestProperty("Authorization", "Bearer ${config.token}")
211
+ conn.setRequestProperty("Sentori-Sdk", "kotlin/${SentoriVersion.CURRENT}")
212
+ conn.connectTimeout = 15_000
213
+ conn.readTimeout = 15_000
214
+ conn.doOutput = true
215
+ conn.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
216
+
217
+ when (val code = conn.responseCode) {
218
+ in 200..299 -> Outcome.DELIVERED
219
+ 429 -> {
220
+ lastRetryAfterMs =
221
+ try {
222
+ val text = conn.errorStream?.bufferedReader()?.readText().orEmpty()
223
+ JSONObject(text).optLong("retryAfterMs", 5_000L)
224
+ } catch (_: Throwable) {
225
+ 5_000L
226
+ }
227
+ Outcome.RETRY_AFTER
228
+ }
229
+ in 500..599 -> Outcome.FAILED
230
+ else -> {
231
+ // 4xx other than 429: our request is wrong and
232
+ // will be wrong again. Per-item outcomes are the
233
+ // server's business.
234
+ if (code >= 400) Outcome.DROPPED else Outcome.FAILED
235
+ }
236
+ }
237
+ } catch (_: Throwable) {
238
+ Outcome.FAILED
239
+ } finally {
240
+ conn?.disconnect()
241
+ }
242
+ }
243
+
244
+ private fun sleep(ms: Long) {
245
+ try {
246
+ TimeUnit.MILLISECONDS.sleep(ms)
247
+ } catch (_: InterruptedException) {
248
+ Thread.currentThread().interrupt()
249
+ }
250
+ }
251
+
252
+ // ── json ──────────────────────────────────────────────────────
253
+
254
+ /**
255
+ * Hand-rolled rather than a serialization library, for the same
256
+ * reason as `HttpURLConnection`: `org.json` is in the platform, so
257
+ * it costs the host nothing.
258
+ */
259
+ internal fun toJson(value: Any?): Any {
260
+ return when (value) {
261
+ null -> JSONObject.NULL
262
+ is Map<*, *> -> {
263
+ val o = JSONObject()
264
+ for ((k, v) in value) o.put(k.toString(), toJson(v))
265
+ o
266
+ }
267
+ is List<*> -> {
268
+ val a = JSONArray()
269
+ for (v in value) a.put(toJson(v))
270
+ a
271
+ }
272
+ is Number, is Boolean, is String -> value
273
+ else -> value.toString()
274
+ }
275
+ }
276
+
277
+ // ── the offline queue ─────────────────────────────────────────
278
+
279
+ private fun spillFile(): File? {
280
+ val dir = synchronized(lock) { spillDir } ?: return null
281
+ if (!dir.exists()) dir.mkdirs()
282
+ return File(dir, "pending-events.json")
283
+ }
284
+
285
+ private fun persist(events: List<Map<String, Any?>>) {
286
+ val file = spillFile() ?: return
287
+ try {
288
+ val all = readPersisted().toMutableList()
289
+ all.addAll(events)
290
+ // Newest wins: the file has to stop growing on a device
291
+ // that is offline for a week.
292
+ while (all.size > MAX_PERSISTED) all.removeAt(0)
293
+ file.writeText(toJson(all).toString())
294
+ } catch (_: Throwable) {
295
+ // A full disk must not become the host's problem.
296
+ }
297
+ }
298
+
299
+ private fun readPersisted(): List<Map<String, Any?>> {
300
+ val file = spillFile() ?: return emptyList()
301
+ if (!file.exists()) return emptyList()
302
+ return try {
303
+ val arr = JSONArray(file.readText())
304
+ (0 until arr.length()).map { fromJson(arr.getJSONObject(it)) }
305
+ } catch (_: Throwable) {
306
+ emptyList()
307
+ }
308
+ }
309
+
310
+ private fun fromJson(o: JSONObject): Map<String, Any?> {
311
+ val out = mutableMapOf<String, Any?>()
312
+ for (k in o.keys()) {
313
+ out[k] =
314
+ when (val v = o.get(k)) {
315
+ is JSONObject -> fromJson(v)
316
+ JSONObject.NULL -> null
317
+ else -> v
318
+ }
319
+ }
320
+ return out
321
+ }
322
+
323
+ /**
324
+ * Read the spill, clear it, put the events back through the normal
325
+ * path — clearing first, so a batch that fails again spills once
326
+ * rather than doubling.
327
+ */
328
+ private fun drainPersisted() {
329
+ val pendingEvents = readPersisted()
330
+ if (pendingEvents.isEmpty()) return
331
+ spillFile()?.delete()
332
+ pendingEvents.forEach { enqueue(it) }
333
+ flush()
334
+ }
335
+
336
+ // ── timer ─────────────────────────────────────────────────────
337
+
338
+ private fun scheduleFlush(afterMs: Long) {
339
+ synchronized(lock) {
340
+ if (pending != null) return
341
+ pending =
342
+ worker.schedule(
343
+ {
344
+ synchronized(lock) { pending = null }
345
+ flush()
346
+ },
347
+ afterMs,
348
+ TimeUnit.MILLISECONDS,
349
+ )
350
+ }
351
+ }
352
+
353
+ // ── test seams ────────────────────────────────────────────────
354
+
355
+ internal fun resetForTests() {
356
+ synchronized(lock) {
357
+ spillFile()?.delete()
358
+ queue.clear()
359
+ assertStats.clear()
360
+ dropped = 0
361
+ delivered = 0
362
+ started = false
363
+ pending?.cancel(false)
364
+ pending = null
365
+ spillDir = null
366
+ }
367
+ }
368
+
369
+ internal fun peekQueue(): List<Map<String, Any?>> = synchronized(lock) { queue.toList() }
370
+
371
+ internal fun peekAssertStats(): List<Map<String, Any?>> =
372
+ synchronized(lock) { assertStats.values.map { it.toMap() } }
373
+
374
+ internal fun peekPersisted(): List<Map<String, Any?>> = readPersisted()
375
+
376
+ /**
377
+ * Events the server actually accepted. A test that checks only for
378
+ * the absence of a spill passes while retries are still in flight
379
+ * — which is how the iOS live suite went green against a server
380
+ * that had stored nothing.
381
+ */
382
+ internal fun peekDelivered(): Int = synchronized(lock) { delivered }
383
+ }
@@ -1,3 +1,6 @@
1
+ // GENERATED MIRROR — do not edit.
2
+ // Source of truth: sdk/native/android/src/test/java/com/sentori/SentoriCrashHandlerTest.kt
3
+ // Run `node scripts/sync-native-core.mjs` after editing it.
1
4
  // Phase 16 sub-E: Robolectric coverage for SentoriCrashHandler (回填 Phase 7).
2
5
  //
3
6
  // Run via Gradle on the Android host:
@@ -0,0 +1,107 @@
1
+ // GENERATED MIRROR — do not edit.
2
+ // Source of truth: sdk/native/android/src/test/java/com/sentori/SentoriIdentityTest.kt
3
+ // Run `node scripts/sync-native-core.mjs` after editing it.
4
+ package com.sentori
5
+
6
+ import java.io.File
7
+ import org.json.JSONObject
8
+ import org.junit.Assert.assertEquals
9
+ import org.junit.Assert.assertNull
10
+ import org.junit.Assert.assertTrue
11
+ import org.junit.Test
12
+ import org.junit.runner.RunWith
13
+ import org.robolectric.RobolectricTestRunner
14
+
15
+ /**
16
+ * The Kotlin hash must equal the TypeScript one for every input, or a
17
+ * device stops matching the events from the same person and the push
18
+ * meant for them reaches nobody — silently, on the one path where
19
+ * nobody is watching.
20
+ *
21
+ * The vectors are generated by `sdk/core/src/identity.ts` itself
22
+ * (`scripts/gen-identity-vectors.mjs`), so this asserts against what
23
+ * the other implementations actually do rather than against what this
24
+ * file's author believed they do. It has already earned its keep:
25
+ * Kotlin's default `trim()` leaves U+00A0 and U+FEFF in place where
26
+ * JavaScript and Swift strip them, and the vector caught it before it
27
+ * shipped.
28
+ */
29
+ @RunWith(RobolectricTestRunner::class)
30
+ class SentoriIdentityTest {
31
+
32
+ private fun vectors(): List<Triple<String, String, String>> {
33
+ // Walk up out of the module to the fixtures shared with iOS.
34
+ // Robolectric runs with the module directory as CWD; both the
35
+ // native module and the React Native mirror of it sit three
36
+ // levels below `sdk`.
37
+ var dir = File(System.getProperty("user.dir") ?: ".").absoluteFile
38
+ var fixture: File? = null
39
+ repeat(8) {
40
+ val candidate = File(dir, "sdk/native/fixtures/identity-vectors.json")
41
+ if (candidate.exists()) {
42
+ fixture = candidate
43
+ return@repeat
44
+ }
45
+ dir = dir.parentFile ?: dir
46
+ }
47
+ val f = requireNotNull(fixture) {
48
+ "identity-vectors.json not found above ${System.getProperty("user.dir")}"
49
+ }
50
+ val root = JSONObject(f.readText()).getJSONArray("vectors")
51
+ return (0 until root.length()).map { i ->
52
+ val v = root.getJSONObject(i)
53
+ Triple(v.getString("keyType"), v.getString("raw"), v.getString("sha256Hex"))
54
+ }
55
+ }
56
+
57
+ @Test
58
+ fun matchesTheTypeScriptImplementation() {
59
+ val vectors = vectors()
60
+ // A fixture that read as empty would pass every assertion
61
+ // below without running one.
62
+ assertTrue("fixture looks truncated: ${vectors.size}", vectors.size >= 12)
63
+ for ((keyType, raw, expected) in vectors) {
64
+ assertEquals(
65
+ "$keyType / ${raw.take(40)}",
66
+ expected,
67
+ SentoriIdentity.hash(keyType, raw),
68
+ )
69
+ }
70
+ }
71
+
72
+ @Test
73
+ fun emptyAndBlankProduceNoKey() {
74
+ assertNull(SentoriIdentity.hash("id", ""))
75
+ assertNull(SentoriIdentity.hash("id", " "))
76
+ assertNull(SentoriIdentity.hash("id", null))
77
+ assertNull(SentoriIdentity.hash("email", " \n "))
78
+ // A phone with no digits normalises away entirely.
79
+ assertNull(SentoriIdentity.hash("phone", "(--) --"))
80
+ }
81
+
82
+ @Test
83
+ fun userKeyPrefersIdAndFallsBackToEmail() {
84
+ assertEquals(
85
+ SentoriIdentity.hash("id", "usr_123"),
86
+ SentoriIdentity.userKey("usr_123", "a@b.com"),
87
+ )
88
+ assertEquals(
89
+ SentoriIdentity.hash("email", "A@B.com"),
90
+ SentoriIdentity.userKey(null, "A@B.com"),
91
+ )
92
+ // A blank id must fall through rather than win and yield null:
93
+ // `user(id = "")` is a wiring mistake, not a decision to become
94
+ // unaddressable.
95
+ assertEquals(
96
+ SentoriIdentity.hash("email", "a@b.com"),
97
+ SentoriIdentity.userKey(" ", "a@b.com"),
98
+ )
99
+ assertNull(SentoriIdentity.userKey(null, null))
100
+ }
101
+
102
+ @Test
103
+ fun oauthSubjectsAreLeftExactlyAsGiven() {
104
+ assertEquals(" Ab ", SentoriIdentity.normalise("googleSub", " Ab "))
105
+ assertEquals("X.Y", SentoriIdentity.normalise("appleSub", "X.Y"))
106
+ }
107
+ }
@@ -0,0 +1,106 @@
1
+ // GENERATED MIRROR — do not edit.
2
+ // Source of truth: sdk/native/android/src/test/java/com/sentori/SentoriLiveServerTest.kt
3
+ // Run `node scripts/sync-native-core.mjs` after editing it.
4
+ package com.sentori
5
+
6
+ import java.io.File
7
+ import org.json.JSONObject
8
+ import org.junit.After
9
+ import org.junit.Assert.assertEquals
10
+ import org.junit.Assert.assertTrue
11
+ import org.junit.Assume.assumeTrue
12
+ import org.junit.Before
13
+ import org.junit.Test
14
+ import org.junit.runner.RunWith
15
+ import org.robolectric.RobolectricTestRunner
16
+
17
+ /**
18
+ * Against a real Sentori server, not a mock.
19
+ *
20
+ * Everything else in this suite asserts the shape this SDK *builds*.
21
+ * That is worth nothing if the server rejects it, and a mock would
22
+ * agree with whatever mistake the SDK is making — which is how a wire
23
+ * format quietly diverges. The one thing that cannot agree with a
24
+ * mistake is the server.
25
+ *
26
+ * Configuration comes from a file rather than the environment, the
27
+ * same way the iOS suite reads it, and for the same reason: it was
28
+ * written once by whoever brought a server up.
29
+ *
30
+ * `assumeTrue` skips when there is none. A skip in Gradle's report is
31
+ * visible; `scripts/android-live-ingest.sh` still fails if this test
32
+ * did not actually run, because a suite that quietly tested nothing
33
+ * reports exactly like one that passed.
34
+ */
35
+ @RunWith(RobolectricTestRunner::class)
36
+ class SentoriLiveServerTest {
37
+
38
+ private var base: String? = null
39
+ private var token: String? = null
40
+
41
+ private fun fixture(): File? {
42
+ var dir = File(System.getProperty("user.dir") ?: ".").absoluteFile
43
+ repeat(8) {
44
+ val f = File(dir, "sdk/native/fixtures/live-server.json")
45
+ if (f.exists()) return f
46
+ dir = dir.parentFile ?: dir
47
+ }
48
+ return null
49
+ }
50
+
51
+ @Before
52
+ fun setUp() {
53
+ val f = fixture()
54
+ assumeTrue("no sdk/native/fixtures/live-server.json — run scripts/android-live-ingest.sh", f != null)
55
+ val j = JSONObject(f!!.readText())
56
+ base = j.getString("base")
57
+ token = j.getString("token")
58
+ SentoriTransport.resetForTests()
59
+ SentoriConfig.resetForTests()
60
+ SentoriScope.clear()
61
+ SentoriSignalRing.clear()
62
+ }
63
+
64
+ @After
65
+ fun tearDown() {
66
+ SentoriTransport.resetForTests()
67
+ SentoriConfig.resetForTests()
68
+ }
69
+
70
+ @Test
71
+ fun theServerAcceptsWhatThisSdkSends() {
72
+ Sentori.start(
73
+ SentoriConfig(
74
+ token = token!!,
75
+ ingestUrl = base!!,
76
+ release = "kotlin-e2e@1.0.0",
77
+ environment = "test",
78
+ )
79
+ )
80
+ Sentori.user("usr_kotlin_1", null)
81
+ Sentori.context(mapOf("tenant" to "acme"))
82
+ Sentori.pushSignal("nav", mapOf("to" to "/checkout"))
83
+
84
+ val id = Sentori.error(IllegalStateException("boom"), mapOf("cartId" to "c_1"))
85
+ Sentori.warn("checkout.slow", mapOf("ms" to 3200))
86
+ Sentori.assert("total.positive", false)
87
+ Sentori.probe("SEN-482")
88
+ SentoriTransport.flush()
89
+
90
+ // Poll for delivery rather than sleeping and checking that
91
+ // nothing has failed *yet*: both "no spill" and "queue empty"
92
+ // are true while retries are in flight, and both are true when
93
+ // the server answers 4xx.
94
+ val deadline = System.currentTimeMillis() + 30_000
95
+ while (SentoriTransport.peekDelivered() == 0 && System.currentTimeMillis() < deadline) {
96
+ Thread.sleep(250)
97
+ }
98
+
99
+ assertTrue(
100
+ "the server never accepted a batch — spilled: " +
101
+ "${SentoriTransport.peekPersisted().size}, queued: ${SentoriTransport.peekQueue().size}",
102
+ SentoriTransport.peekDelivered() > 0,
103
+ )
104
+ assertEquals(36, id.length)
105
+ }
106
+ }