@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.
- package/SentoriReactNative.podspec +11 -4
- package/android/build.gradle +21 -1
- package/android/src/main/java-core/com/sentori/Sentori.kt +273 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriAnrWatchdog.kt +3 -0
- package/android/src/main/java-core/com/sentori/SentoriAttachment.kt +153 -0
- package/android/src/main/java-core/com/sentori/SentoriConfig.kt +61 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriCrashHandler.kt +22 -0
- package/android/src/main/java-core/com/sentori/SentoriDevice.kt +61 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriFirebaseMessagingService.kt +18 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriForegroundActivity.kt +3 -0
- package/android/src/main/java-core/com/sentori/SentoriIdentity.kt +101 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriMobileVitals.kt +3 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriNativeExceptionBridge.kt +3 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriNativeSignals.kt +3 -0
- package/android/src/main/java-core/com/sentori/SentoriNotificationTap.kt +148 -0
- package/android/src/main/java-core/com/sentori/SentoriPendingCrash.kt +154 -0
- package/android/src/main/java-core/com/sentori/SentoriPush.kt +588 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriPushNotifications.kt +171 -17
- package/android/src/main/{java → java-core}/com/sentori/SentoriReplayCapture.kt +3 -0
- package/android/src/main/java-core/com/sentori/SentoriScope.kt +107 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriScreenshotCapture.kt +3 -0
- package/android/src/main/java-core/com/sentori/SentoriSignalRing.kt +80 -0
- package/android/src/main/java-core/com/sentori/SentoriTransport.kt +476 -0
- package/android/src/test/java-core/com/sentori/SentoriAttachmentTest.kt +413 -0
- package/android/src/test/{java → java-core}/com/sentori/SentoriCrashHandlerTest.kt +3 -0
- package/android/src/test/java-core/com/sentori/SentoriIdentityTest.kt +107 -0
- package/android/src/test/java-core/com/sentori/SentoriLiveServerTest.kt +149 -0
- package/android/src/test/java-core/com/sentori/SentoriPendingCrashTest.kt +194 -0
- package/android/src/test/java-core/com/sentori/SentoriPushTest.kt +707 -0
- package/android/src/test/{java → java-core}/com/sentori/SentoriScreenshotCaptureTest.kt +3 -0
- package/android/src/test/java-core/com/sentori/SentoriVerbsTest.kt +185 -0
- package/ios/core/SentoriAttachment.swift +129 -0
- package/ios/{SentoriCrashHandler.swift → core/SentoriCrashHandler.swift} +31 -0
- package/ios/{SentoriHangWatchdog.swift → core/SentoriHangWatchdog.swift} +3 -0
- package/ios/{SentoriMobileVitals.swift → core/SentoriMobileVitals.swift} +3 -0
- package/ios/{SentoriNativeExceptionBridge.swift → core/SentoriNativeExceptionBridge.swift} +3 -0
- package/ios/{SentoriPushNotifications.swift → core/SentoriPushNotifications.swift} +50 -0
- package/ios/{SentoriReplayCapture.swift → core/SentoriReplayCapture.swift} +3 -0
- package/ios/{SentoriScreenshotCapture.swift → core/SentoriScreenshotCapture.swift} +14 -0
- package/ios/{SentoriThreadSampler.swift → core/SentoriThreadSampler.swift} +3 -0
- package/lib/push.d.ts.map +1 -1
- package/lib/push.js +51 -1
- package/lib/push.js.map +1 -1
- package/lib/scope.d.ts +6 -0
- package/lib/scope.d.ts.map +1 -1
- package/lib/scope.js +45 -1
- package/lib/scope.js.map +1 -1
- package/lib/transport.js +1 -1
- package/package.json +2 -2
- package/src/__tests__/push.test.ts +99 -1
- package/src/push.ts +49 -1
- package/src/scope.ts +50 -1
- package/src/transport.ts +1 -1
- package/ios/Tests/SentoriCrashHandlerTests.swift +0 -59
- package/ios/Tests/SentoriScreenshotCaptureTests.swift +0 -59
- 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
|
+
}
|