@goliapkg/sentori-react-native 6.2.0 → 6.3.0

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 (56) hide show
  1. package/SentoriReactNative.podspec +11 -4
  2. package/android/build.gradle +21 -1
  3. package/android/src/main/java-core/com/sentori/Sentori.kt +273 -0
  4. package/android/src/main/{java → java-core}/com/sentori/SentoriAnrWatchdog.kt +3 -0
  5. package/android/src/main/java-core/com/sentori/SentoriAttachment.kt +153 -0
  6. package/android/src/main/java-core/com/sentori/SentoriConfig.kt +61 -0
  7. package/android/src/main/{java → java-core}/com/sentori/SentoriCrashHandler.kt +22 -0
  8. package/android/src/main/java-core/com/sentori/SentoriDevice.kt +61 -0
  9. package/android/src/main/{java → java-core}/com/sentori/SentoriFirebaseMessagingService.kt +18 -0
  10. package/android/src/main/{java → java-core}/com/sentori/SentoriForegroundActivity.kt +3 -0
  11. package/android/src/main/java-core/com/sentori/SentoriIdentity.kt +101 -0
  12. package/android/src/main/{java → java-core}/com/sentori/SentoriMobileVitals.kt +3 -0
  13. package/android/src/main/{java → java-core}/com/sentori/SentoriNativeExceptionBridge.kt +3 -0
  14. package/android/src/main/{java → java-core}/com/sentori/SentoriNativeSignals.kt +3 -0
  15. package/android/src/main/java-core/com/sentori/SentoriNotificationTap.kt +148 -0
  16. package/android/src/main/java-core/com/sentori/SentoriPendingCrash.kt +154 -0
  17. package/android/src/main/java-core/com/sentori/SentoriPush.kt +588 -0
  18. package/android/src/main/{java → java-core}/com/sentori/SentoriPushNotifications.kt +171 -17
  19. package/android/src/main/{java → java-core}/com/sentori/SentoriReplayCapture.kt +3 -0
  20. package/android/src/main/java-core/com/sentori/SentoriScope.kt +107 -0
  21. package/android/src/main/{java → java-core}/com/sentori/SentoriScreenshotCapture.kt +3 -0
  22. package/android/src/main/java-core/com/sentori/SentoriSignalRing.kt +80 -0
  23. package/android/src/main/java-core/com/sentori/SentoriTransport.kt +476 -0
  24. package/android/src/test/java-core/com/sentori/SentoriAttachmentTest.kt +413 -0
  25. package/android/src/test/{java → java-core}/com/sentori/SentoriCrashHandlerTest.kt +3 -0
  26. package/android/src/test/java-core/com/sentori/SentoriIdentityTest.kt +107 -0
  27. package/android/src/test/java-core/com/sentori/SentoriLiveServerTest.kt +149 -0
  28. package/android/src/test/java-core/com/sentori/SentoriPendingCrashTest.kt +194 -0
  29. package/android/src/test/java-core/com/sentori/SentoriPushTest.kt +707 -0
  30. package/android/src/test/{java → java-core}/com/sentori/SentoriScreenshotCaptureTest.kt +3 -0
  31. package/android/src/test/java-core/com/sentori/SentoriVerbsTest.kt +185 -0
  32. package/ios/core/SentoriAttachment.swift +129 -0
  33. package/ios/{SentoriCrashHandler.swift → core/SentoriCrashHandler.swift} +31 -0
  34. package/ios/{SentoriHangWatchdog.swift → core/SentoriHangWatchdog.swift} +3 -0
  35. package/ios/{SentoriMobileVitals.swift → core/SentoriMobileVitals.swift} +3 -0
  36. package/ios/{SentoriNativeExceptionBridge.swift → core/SentoriNativeExceptionBridge.swift} +3 -0
  37. package/ios/{SentoriPushNotifications.swift → core/SentoriPushNotifications.swift} +50 -0
  38. package/ios/{SentoriReplayCapture.swift → core/SentoriReplayCapture.swift} +3 -0
  39. package/ios/{SentoriScreenshotCapture.swift → core/SentoriScreenshotCapture.swift} +14 -0
  40. package/ios/{SentoriThreadSampler.swift → core/SentoriThreadSampler.swift} +3 -0
  41. package/lib/push.d.ts.map +1 -1
  42. package/lib/push.js +51 -1
  43. package/lib/push.js.map +1 -1
  44. package/lib/scope.d.ts +6 -0
  45. package/lib/scope.d.ts.map +1 -1
  46. package/lib/scope.js +45 -1
  47. package/lib/scope.js.map +1 -1
  48. package/lib/transport.js +1 -1
  49. package/package.json +2 -2
  50. package/src/__tests__/push.test.ts +99 -1
  51. package/src/push.ts +49 -1
  52. package/src/scope.ts +50 -1
  53. package/src/transport.ts +1 -1
  54. package/ios/Tests/SentoriCrashHandlerTests.swift +0 -59
  55. package/ios/Tests/SentoriScreenshotCaptureTests.swift +0 -59
  56. package/ios/Tests/SentoriThreadSamplerTests.swift +0 -96
@@ -0,0 +1,413 @@
1
+ // GENERATED MIRROR — do not edit.
2
+ // Source of truth: sdk/native/android/src/test/java/com/sentori/SentoriAttachmentTest.kt
3
+ // Run `node scripts/sync-native-core.mjs` after editing it.
4
+ package com.sentori
5
+
6
+ import androidx.test.core.app.ApplicationProvider
7
+ import java.io.File
8
+ import org.json.JSONArray
9
+ import org.json.JSONObject
10
+ import org.junit.After
11
+ import org.junit.Assert.assertEquals
12
+ import org.junit.Assert.assertFalse
13
+ import org.junit.Assert.assertTrue
14
+ import org.junit.Before
15
+ import org.junit.Test
16
+ import org.junit.runner.RunWith
17
+ import org.robolectric.RobolectricTestRunner
18
+
19
+ /**
20
+ * The screenshot taken as the app died, and the view tree behind it.
21
+ *
22
+ * Both were already being captured and written into the crash file on
23
+ * both platforms — and then nothing uploaded them. The event arrived,
24
+ * the evidence did not, and the dashboard showed a crash with an empty
25
+ * viewport that looked like a capture bug.
26
+ *
27
+ * Two things can go wrong here and neither shows up as an error:
28
+ * uploading before the server has the event (a 404 nobody reads), and
29
+ * refusing a kind the server would have accepted.
30
+ *
31
+ * Mirrors `SentoriAttachmentTests.swift`.
32
+ */
33
+ @RunWith(RobolectricTestRunner::class)
34
+ class SentoriAttachmentTest {
35
+
36
+ private val uploads = mutableListOf<Triple<String, String, String>>()
37
+
38
+ @Before
39
+ fun setUp() {
40
+ SentoriTransport.resetForTests()
41
+ SentoriConfig.resetForTests()
42
+ SentoriCrashHandler.consumePending()
43
+ uploads.clear()
44
+ SentoriAttachment.recorderForTests = { id, kind, body ->
45
+ synchronized(uploads) { uploads.add(Triple(id, kind, body)) }
46
+ }
47
+ }
48
+
49
+ @After
50
+ fun tearDown() {
51
+ SentoriAttachment.recorderForTests = null
52
+ SentoriTransport.resetForTests()
53
+ SentoriConfig.resetForTests()
54
+ }
55
+
56
+ /**
57
+ * `start()` with no directory leaves the spill a no-op, which made
58
+ * the "nothing uploads when the batch never lands" test wait five
59
+ * seconds for a file that could never be written — a test failing
60
+ * on its own setup while the code under it was correct.
61
+ */
62
+ private fun spillDir(): File =
63
+ File(
64
+ ApplicationProvider.getApplicationContext<android.content.Context>().cacheDir,
65
+ "sentori-spill",
66
+ )
67
+ .apply {
68
+ deleteRecursively()
69
+ mkdirs()
70
+ }
71
+
72
+ private fun configure() {
73
+ SentoriConfig.set(
74
+ SentoriConfig(
75
+ token = "st_test",
76
+ ingestUrl = "http://127.0.0.1:9",
77
+ release = "app@1.0.0",
78
+ environment = "test",
79
+ ),
80
+ )
81
+ }
82
+
83
+ /**
84
+ * Poll rather than sleep: a fixed wait encodes a guess about how
85
+ * fast the machine is, and CI is slower than this one.
86
+ *
87
+ * The default is generous because a passing wait returns
88
+ * immediately and only a failing one spends the budget. Five
89
+ * seconds bought nothing and cost two red builds on the iOS side.
90
+ */
91
+ private fun waitUntil(what: String, timeoutMs: Long = 30_000, cond: () -> Boolean) {
92
+ val deadline = System.currentTimeMillis() + timeoutMs
93
+ while (System.currentTimeMillis() < deadline) {
94
+ if (cond()) return
95
+ Thread.sleep(20)
96
+ }
97
+ throw AssertionError("$what — not after ${timeoutMs}ms")
98
+ }
99
+
100
+ /** A crash file with a screenshot in it, as the handler writes it. */
101
+ private fun crashFile(id: String): JSONObject =
102
+ JSONObject().apply {
103
+ put("id", id)
104
+ put("timestamp", "2026-08-11T09:00:00.000Z")
105
+ put("kind", "error")
106
+ put("platform", "android")
107
+ put("release", "app@1.0.0")
108
+ put("environment", "test")
109
+ put(
110
+ "device",
111
+ JSONObject().apply {
112
+ put("os", "android")
113
+ put("osVersion", "15")
114
+ put("model", "Pixel 10 Pro")
115
+ },
116
+ )
117
+ put("app", JSONObject().apply { put("version", "1.0.0") })
118
+ put(
119
+ "error",
120
+ JSONObject().apply {
121
+ put("type", "IllegalStateException")
122
+ put("message", "boom")
123
+ },
124
+ )
125
+ put(
126
+ "_pendingAttachments",
127
+ JSONArray().apply {
128
+ put(
129
+ JSONObject().apply {
130
+ put("kind", "screenshot")
131
+ put("base64", "aGVsbG8=")
132
+ put("mediaType", "image/webp")
133
+ put("source", "android")
134
+ },
135
+ )
136
+ put(
137
+ JSONObject().apply {
138
+ put("kind", "viewTree")
139
+ put("base64", "eyJhIjoxfQ==")
140
+ put("mediaType", "application/json")
141
+ put("source", "android")
142
+ },
143
+ )
144
+ },
145
+ )
146
+ }
147
+
148
+ // ── the allowlist ─────────────────────────────────────────────
149
+
150
+ /**
151
+ * The first version of this list had three entries — `replay`,
152
+ * `screens`, `screenshot` — and the crash handler writes
153
+ * `viewTree`. The delivery path would have dropped, on the way
154
+ * out, the very evidence it exists to deliver.
155
+ */
156
+ @Test
157
+ fun everyKindTheCrashHandlerWritesIsAccepted() {
158
+ for (kind in listOf("screenshot", "viewTree")) {
159
+ assertTrue(
160
+ "the crash handler writes '$kind' and this list would refuse it",
161
+ kind in SentoriAttachment.KNOWN,
162
+ )
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Kept in step with `KINDS` in
168
+ * `self-hosted/server/src/handlers/sdk/events_attachments.rs`,
169
+ * where anything else is a 400 and the CHECK constraint behind it
170
+ * would refuse the row anyway.
171
+ */
172
+ @Test
173
+ fun theAllowlistIsTheServersList() {
174
+ assertEquals(
175
+ setOf(
176
+ "logTail",
177
+ "replay",
178
+ "screens",
179
+ "screenshot",
180
+ "sessionTrail",
181
+ "stateSnapshot",
182
+ "viewTree",
183
+ ),
184
+ SentoriAttachment.KNOWN,
185
+ )
186
+ assertEquals(setOf("android", "ios", "js"), SentoriAttachment.KNOWN_SOURCES)
187
+ }
188
+
189
+ @Test
190
+ fun anUnknownKindIsDroppedRatherThanPosted() {
191
+ configure()
192
+ SentoriAttachment.upload("e1", "heapDump", "aGk=", "application/octet-stream")
193
+ assertTrue(uploads.isEmpty())
194
+ }
195
+
196
+ @Test
197
+ fun nothingIsPostedWithoutAConfig() {
198
+ SentoriAttachment.upload("e1", "screenshot", "aGk=", "image/webp")
199
+ assertTrue(uploads.isEmpty())
200
+ }
201
+
202
+ // ── the body ──────────────────────────────────────────────────
203
+
204
+ /**
205
+ * Built by hand, so it is worth reading back. The failure mode is
206
+ * a 2xx that stored the wrong bytes: without the transfer encoding
207
+ * the server keeps the base64 *text* as the image, which renders
208
+ * as nothing and reads as a capture problem.
209
+ */
210
+ @Test
211
+ fun multipartBodyIsWhatTheServerParses() {
212
+ val body =
213
+ SentoriAttachment.multipartBody(
214
+ "BOUND",
215
+ "screenshot",
216
+ "image/webp",
217
+ "aGVsbG8=",
218
+ "android",
219
+ )
220
+
221
+ assertTrue(body.startsWith("--BOUND\r\n"))
222
+ assertTrue(body.endsWith("--BOUND--\r\n"))
223
+ assertTrue(
224
+ body.contains(
225
+ "Content-Disposition: form-data; name=\"file\"; filename=\"screenshot.bin\"\r\n",
226
+ ),
227
+ )
228
+ assertTrue(body.contains("Content-Type: image/webp\r\n"))
229
+ assertTrue(body.contains("Content-Transfer-Encoding: base64\r\n"))
230
+ assertTrue(body.contains("\r\n\r\naGVsbG8=\r\n"))
231
+ assertTrue(
232
+ body.contains("Content-Disposition: form-data; name=\"source\"\r\n\r\nandroid\r\n"),
233
+ )
234
+
235
+ // Two opening delimiters, no more and no fewer.
236
+ assertEquals(2, body.split("--BOUND\r\n").size - 1)
237
+ }
238
+
239
+ /** The bytes are identical to what the Swift package would send. */
240
+ @Test
241
+ fun theBodyMatchesTheOneSwiftBuilds() {
242
+ val body =
243
+ SentoriAttachment.multipartBody("B", "viewTree", "application/json", "eyJhIjoxfQ==", "ios")
244
+ assertEquals(
245
+ "--B\r\n" +
246
+ "Content-Disposition: form-data; name=\"file\"; filename=\"viewTree.bin\"\r\n" +
247
+ "Content-Type: application/json\r\n" +
248
+ "Content-Transfer-Encoding: base64\r\n" +
249
+ "\r\neyJhIjoxfQ==\r\n" +
250
+ "--B\r\n" +
251
+ "Content-Disposition: form-data; name=\"source\"\r\n" +
252
+ "\r\nios\r\n" +
253
+ "--B--\r\n",
254
+ body,
255
+ )
256
+ }
257
+
258
+ // ── the ordering ──────────────────────────────────────────────
259
+
260
+ /**
261
+ * The one that matters. An upload keyed on an event the server has
262
+ * not seen is a 404, and it wins that race every time: the batch
263
+ * waits for a flush and the upload does not.
264
+ */
265
+ @Test
266
+ fun attachmentsUploadOnlyAfterTheBatchLands() {
267
+ configure()
268
+ SentoriCrashHandler.installForTesting(ApplicationProvider.getApplicationContext())
269
+ SentoriTransport.start(spillDir())
270
+ SentoriTransport.forcedOutcomeForTests = 0 // DELIVERED
271
+
272
+ val id = "019ff080-2aeb-7e30-aba1-4431b296d120"
273
+ SentoriCrashHandler.persistRawForTesting(crashFile(id))
274
+ SentoriPendingCrash.ship()
275
+
276
+ waitUntil("both blobs upload after the batch lands") {
277
+ synchronized(uploads) { uploads.size == 2 }
278
+ }
279
+
280
+ assertEquals(setOf("screenshot", "viewTree"), uploads.map { it.second }.toSet())
281
+ for (upload in uploads) {
282
+ assertEquals("an attachment keyed on anything but the event id is a 404", id, upload.first)
283
+ }
284
+ assertTrue(uploads.any { it.third.contains("aGVsbG8=") })
285
+ assertTrue(uploads.any { it.third.contains("Content-Type: application/json") })
286
+ }
287
+
288
+ /**
289
+ * If the batch was refused or spilled to disk, the events are not
290
+ * on the server and there is nothing to attach to. Uploading
291
+ * anyway spends the user's bandwidth on a guaranteed 404.
292
+ */
293
+ @Test
294
+ fun nothingUploadsWhenTheBatchNeverLands() {
295
+ configure()
296
+ SentoriCrashHandler.installForTesting(ApplicationProvider.getApplicationContext())
297
+ SentoriTransport.start(spillDir())
298
+ SentoriTransport.forcedOutcomeForTests = 2 // FAILED
299
+
300
+ val spilledId = "019ff080-2aeb-7e30-aba1-4431b296d121"
301
+ SentoriCrashHandler.persistRawForTesting(crashFile(spilledId))
302
+ SentoriPendingCrash.ship()
303
+
304
+ // The event has to reach the spill before the absence of an
305
+ // upload means anything — otherwise this passes by being early.
306
+ //
307
+ // Ask for this crash, not for a count: the spill is one file
308
+ // the whole process shares, so a size check is an assertion
309
+ // about what every other test left behind.
310
+ waitUntil("the failed batch spills", 60_000) {
311
+ SentoriTransport.peekPersisted().any { it["id"] as? String == spilledId }
312
+ }
313
+
314
+ assertTrue(
315
+ "a spilled batch has no event on the server to attach to",
316
+ synchronized(uploads) { uploads.isEmpty() },
317
+ )
318
+ }
319
+
320
+ // ── the hook itself ───────────────────────────────────────────
321
+
322
+ /** Registered for events that are still queued, it waits. */
323
+ @Test
324
+ fun aHookWaitsUntilItsOwnEventsLand() {
325
+ configure()
326
+ SentoriTransport.start(spillDir())
327
+ SentoriTransport.forcedOutcomeForTests = 0 // DELIVERED
328
+
329
+ var fired = 0
330
+ SentoriTransport.afterDelivery(setOf("a", "b")) { fired += 1 }
331
+
332
+ SentoriTransport.enqueue(mapOf("id" to "a", "kind" to "error"))
333
+ SentoriTransport.flush()
334
+ waitUntil("the first batch lands") { SentoriTransport.peekDelivered() >= 1 }
335
+ assertEquals("half its events have landed — it is not due yet", 0, fired)
336
+
337
+ SentoriTransport.enqueue(mapOf("id" to "b", "kind" to "error"))
338
+ SentoriTransport.flush()
339
+ waitUntil("the hook fires once both have landed") { fired == 1 }
340
+ }
341
+
342
+ /**
343
+ * Registered for events the server refuses outright, it is
344
+ * dropped: there is nothing on the server to attach to, and
345
+ * uploading anyway spends a device's bandwidth on a 404.
346
+ */
347
+ @Test
348
+ fun aHookIsDiscardedWhenTheServerRefusesItsEvents() {
349
+ configure()
350
+ SentoriTransport.start(spillDir())
351
+ SentoriTransport.forcedOutcomeForTests = 1 // DROPPED
352
+
353
+ var fired = false
354
+ SentoriTransport.afterDelivery(setOf("a")) { fired = true }
355
+ assertEquals(1, SentoriTransport.peekWaiting())
356
+
357
+ SentoriTransport.enqueue(mapOf("id" to "a", "kind" to "error"))
358
+ SentoriTransport.flush()
359
+
360
+ waitUntil("the refusal discards the waiter") { SentoriTransport.peekWaiting() == 0 }
361
+ assertFalse("the events it waited on were refused", fired)
362
+ }
363
+
364
+ /**
365
+ * The shape of the bug CI caught. A hook registered after its
366
+ * batch has already been accepted never fires — which is why
367
+ * `ship` registers before it enqueues, not after it flushes.
368
+ */
369
+ @Test
370
+ fun aHookRegisteredTooLateNeverFires() {
371
+ configure()
372
+ SentoriTransport.start(spillDir())
373
+ SentoriTransport.forcedOutcomeForTests = 0
374
+
375
+ SentoriTransport.enqueue(mapOf("id" to "a", "kind" to "error"))
376
+ SentoriTransport.flush()
377
+ waitUntil("the batch lands") { SentoriTransport.peekDelivered() >= 1 }
378
+
379
+ var fired = false
380
+ SentoriTransport.afterDelivery(setOf("a")) { fired = true }
381
+ SentoriTransport.enqueue(mapOf("id" to "z", "kind" to "error"))
382
+ SentoriTransport.flush()
383
+ waitUntil("a later batch lands") { SentoriTransport.peekDelivered() >= 2 }
384
+
385
+ assertFalse(
386
+ "it fired on someone else's batch — an upload keyed on an event that " +
387
+ "batch never carried is a 404",
388
+ fired,
389
+ )
390
+ }
391
+
392
+ /**
393
+ * The blobs travel in the file and never on the wire. An event
394
+ * carrying a base64 screenshot inside its JSON is a megabyte in a
395
+ * batch that is meant to be a few kilobytes.
396
+ */
397
+ @Test
398
+ fun pendingAttachmentsNeverGoOnTheWire() {
399
+ configure()
400
+ SentoriCrashHandler.installForTesting(ApplicationProvider.getApplicationContext())
401
+ SentoriTransport.forcedOutcomeForTests = 0
402
+ SentoriCrashHandler.persistRawForTesting(crashFile("019ff080-2aeb-7e30-aba1-4431b296d122"))
403
+ SentoriPendingCrash.ship()
404
+
405
+ val queued = SentoriTransport.peekQueue() + SentoriTransport.peekPersisted()
406
+ for (event in queued) {
407
+ assertFalse(
408
+ "the screenshot bytes must not ride inside the event",
409
+ event.toString().contains("aGVsbG8="),
410
+ )
411
+ }
412
+ }
413
+ }
@@ -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,149 @@
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 androidx.test.core.app.ApplicationProvider
7
+ import java.io.File
8
+ import org.json.JSONObject
9
+ import org.junit.After
10
+ import org.junit.Assert.assertEquals
11
+ import org.junit.Assert.assertNotNull
12
+ import org.junit.Assert.assertTrue
13
+ import org.junit.Assume.assumeTrue
14
+ import org.junit.Before
15
+ import org.junit.Test
16
+ import org.junit.runner.RunWith
17
+ import org.robolectric.RobolectricTestRunner
18
+
19
+ /**
20
+ * Against a real Sentori server, not a mock.
21
+ *
22
+ * Everything else in this suite asserts the shape this SDK *builds*.
23
+ * That is worth nothing if the server rejects it, and a mock would
24
+ * agree with whatever mistake the SDK is making — which is how a wire
25
+ * format quietly diverges. The one thing that cannot agree with a
26
+ * mistake is the server.
27
+ *
28
+ * Configuration comes from a file rather than the environment, the
29
+ * same way the iOS suite reads it, and for the same reason: it was
30
+ * written once by whoever brought a server up.
31
+ *
32
+ * `assumeTrue` skips when there is none. A skip in Gradle's report is
33
+ * visible; `scripts/android-live-ingest.sh` still fails if this test
34
+ * did not actually run, because a suite that quietly tested nothing
35
+ * reports exactly like one that passed.
36
+ */
37
+ @RunWith(RobolectricTestRunner::class)
38
+ class SentoriLiveServerTest {
39
+
40
+ private var base: String? = null
41
+ private var token: String? = null
42
+
43
+ private fun fixture(): File? {
44
+ var dir = File(System.getProperty("user.dir") ?: ".").absoluteFile
45
+ repeat(8) {
46
+ val f = File(dir, "sdk/native/fixtures/live-server.json")
47
+ if (f.exists()) return f
48
+ dir = dir.parentFile ?: dir
49
+ }
50
+ return null
51
+ }
52
+
53
+ @Before
54
+ fun setUp() {
55
+ val f = fixture()
56
+ assumeTrue("no sdk/native/fixtures/live-server.json — run scripts/android-live-ingest.sh", f != null)
57
+ val j = JSONObject(f!!.readText())
58
+ base = j.getString("base")
59
+ token = j.getString("token")
60
+ SentoriTransport.resetForTests()
61
+ SentoriConfig.resetForTests()
62
+ SentoriScope.clear()
63
+ SentoriSignalRing.clear()
64
+ }
65
+
66
+ @After
67
+ fun tearDown() {
68
+ SentoriTransport.resetForTests()
69
+ SentoriConfig.resetForTests()
70
+ }
71
+
72
+ @Test
73
+ fun theServerAcceptsWhatThisSdkSends() {
74
+ Sentori.start(
75
+ SentoriConfig(
76
+ token = token!!,
77
+ ingestUrl = base!!,
78
+ release = "kotlin-e2e@1.0.0",
79
+ environment = "test",
80
+ )
81
+ )
82
+ Sentori.user("usr_kotlin_1", null)
83
+ Sentori.context(mapOf("tenant" to "acme"))
84
+ Sentori.pushSignal("nav", mapOf("to" to "/checkout"))
85
+
86
+ val id = Sentori.error(IllegalStateException("boom"), mapOf("cartId" to "c_1"))
87
+ Sentori.warn("checkout.slow", mapOf("ms" to 3200))
88
+ Sentori.assert("total.positive", false)
89
+ Sentori.probe("SEN-482")
90
+ SentoriTransport.flush()
91
+
92
+ // Poll for delivery rather than sleeping and checking that
93
+ // nothing has failed *yet*: both "no spill" and "queue empty"
94
+ // are true while retries are in flight, and both are true when
95
+ // the server answers 4xx.
96
+ val deadline = System.currentTimeMillis() + 30_000
97
+ while (SentoriTransport.peekDelivered() == 0 && System.currentTimeMillis() < deadline) {
98
+ Thread.sleep(250)
99
+ }
100
+
101
+ assertTrue(
102
+ "the server never accepted a batch — spilled: " +
103
+ "${SentoriTransport.peekPersisted().size}, queued: ${SentoriTransport.peekQueue().size}",
104
+ SentoriTransport.peekDelivered() > 0,
105
+ )
106
+ assertEquals(36, id.length)
107
+
108
+ // The multipart body against a real parser.
109
+ //
110
+ // Everything else about attachments is asserted as a string,
111
+ // and a string that looks right is exactly what React Native
112
+ // shipped for a release while every upload failed on the
113
+ // device. The body is hand-built; the only proof it is
114
+ // well-formed is a server that read it.
115
+ val uploaded = java.util.concurrent.CountDownLatch(1)
116
+ var ok = false
117
+ SentoriAttachment.upload(
118
+ eventId = id,
119
+ kind = "screenshot",
120
+ base64 = ONE_PIXEL_JPEG_BASE64,
121
+ mediaType = "image/jpeg",
122
+ ) { result ->
123
+ ok = result
124
+ uploaded.countDown()
125
+ }
126
+ assertTrue(
127
+ "the attachment upload never came back",
128
+ uploaded.await(30, java.util.concurrent.TimeUnit.SECONDS),
129
+ )
130
+ assertTrue(
131
+ "the server refused the multipart body — it parses this, or attachments " +
132
+ "die silently on every device",
133
+ ok,
134
+ )
135
+ }
136
+
137
+ companion object {
138
+ /**
139
+ * The smallest valid JPEG, base64. Content matters: the server
140
+ * stores what it decodes, and a payload that is not an image
141
+ * would pass the upload and fail the viewer.
142
+ */
143
+ private const val ONE_PIXEL_JPEG_BASE64 =
144
+ "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0a" +
145
+ "HBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAFAABAAAAAAAA" +
146
+ "AAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q=="
147
+ }
148
+
149
+ }