@goliapkg/sentori-react-native 6.2.1 → 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/android/build.gradle +6 -0
- package/android/src/main/java-core/com/sentori/Sentori.kt +33 -4
- package/android/src/main/java-core/com/sentori/SentoriAttachment.kt +153 -0
- package/android/src/main/java-core/com/sentori/SentoriConfig.kt +1 -1
- package/android/src/main/java-core/com/sentori/SentoriCrashHandler.kt +19 -0
- package/android/src/main/java-core/com/sentori/SentoriFirebaseMessagingService.kt +15 -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 +242 -13
- package/android/src/main/java-core/com/sentori/SentoriPushNotifications.kt +168 -17
- package/android/src/main/java-core/com/sentori/SentoriScope.kt +49 -2
- package/android/src/main/java-core/com/sentori/SentoriTransport.kt +95 -2
- package/android/src/test/java-core/com/sentori/SentoriAttachmentTest.kt +413 -0
- package/android/src/test/java-core/com/sentori/SentoriLiveServerTest.kt +43 -0
- package/android/src/test/java-core/com/sentori/SentoriPendingCrashTest.kt +194 -0
- package/android/src/test/java-core/com/sentori/SentoriPushTest.kt +556 -0
- package/ios/core/SentoriAttachment.swift +129 -0
- package/ios/core/SentoriCrashHandler.swift +15 -0
- package/ios/core/SentoriPushNotifications.swift +8 -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/core/SentoriPush.swift +0 -340
|
@@ -50,6 +50,61 @@ object SentoriTransport {
|
|
|
50
50
|
private const val MAX_QUEUED = 500
|
|
51
51
|
|
|
52
52
|
private val lock = Any()
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Work that may only run once the server has taken particular
|
|
56
|
+
* events.
|
|
57
|
+
*
|
|
58
|
+
* Attachments are the reason: the server keys one on an event id
|
|
59
|
+
* it must already know, so uploading before that event lands is a
|
|
60
|
+
* guaranteed 404.
|
|
61
|
+
*
|
|
62
|
+
* Keyed on ids rather than "the next delivery", which was the
|
|
63
|
+
* first version and had a race at each end. `flush` hands the send
|
|
64
|
+
* to a worker and returns, so registering afterwards can miss a
|
|
65
|
+
* batch that has already come back — on a fast network the uploads
|
|
66
|
+
* then never happen at all, silently, which is the bug this exists
|
|
67
|
+
* to prevent. Registering beforehand instead lets an unrelated
|
|
68
|
+
* batch already in flight fire the block early, into a 404.
|
|
69
|
+
* Matching on ids has neither end.
|
|
70
|
+
*/
|
|
71
|
+
private val afterDelivery = mutableListOf<Pair<Set<String>, () -> Unit>>()
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Run [block] once the server has accepted every event in [ids].
|
|
75
|
+
* If the server refuses them outright the block is discarded — they
|
|
76
|
+
* are not there to attach to. A batch that merely spilled keeps its
|
|
77
|
+
* waiters, since the drain retries it under the same ids.
|
|
78
|
+
*/
|
|
79
|
+
internal fun afterDelivery(ids: Set<String>, block: () -> Unit) {
|
|
80
|
+
if (ids.isEmpty()) return
|
|
81
|
+
synchronized(lock) { afterDelivery.add(ids to block) }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private fun settle(events: List<Map<String, Any?>>, accepted: Boolean) {
|
|
85
|
+
val ids = events.mapNotNull { it["id"] as? String }.toSet()
|
|
86
|
+
if (ids.isEmpty()) return
|
|
87
|
+
val ready = mutableListOf<() -> Unit>()
|
|
88
|
+
synchronized(lock) {
|
|
89
|
+
val kept = mutableListOf<Pair<Set<String>, () -> Unit>>()
|
|
90
|
+
for ((waiting, block) in afterDelivery) {
|
|
91
|
+
if (waiting.none { id -> id in ids }) {
|
|
92
|
+
kept.add(waiting to block)
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
if (!accepted) continue // refused: drop the waiter
|
|
96
|
+
// Subtract what landed rather than asking whether this
|
|
97
|
+
// one batch carried everything. A waiter on two events
|
|
98
|
+
// whose events go out in two batches is otherwise never
|
|
99
|
+
// due — it is a subset of neither.
|
|
100
|
+
val left = waiting - ids
|
|
101
|
+
if (left.isEmpty()) ready.add(block) else kept.add(left to block)
|
|
102
|
+
}
|
|
103
|
+
afterDelivery.clear()
|
|
104
|
+
afterDelivery.addAll(kept)
|
|
105
|
+
}
|
|
106
|
+
ready.forEach { it() }
|
|
107
|
+
}
|
|
53
108
|
private val queue = ArrayDeque<Map<String, Any?>>()
|
|
54
109
|
private val assertStats = mutableMapOf<String, MutableMap<String, Any?>>()
|
|
55
110
|
private var started = false
|
|
@@ -146,10 +201,16 @@ object SentoriTransport {
|
|
|
146
201
|
|
|
147
202
|
worker.execute {
|
|
148
203
|
when (sendWithRetry(envelope, config)) {
|
|
149
|
-
Outcome.DELIVERED ->
|
|
204
|
+
Outcome.DELIVERED -> {
|
|
205
|
+
synchronized(lock) { delivered += events.size }
|
|
206
|
+
settle(events, accepted = true)
|
|
207
|
+
}
|
|
150
208
|
// Handled, but not accepted. Nothing to retry and
|
|
151
209
|
// nothing to count.
|
|
152
|
-
Outcome.DROPPED ->
|
|
210
|
+
Outcome.DROPPED -> settle(events, accepted = false)
|
|
211
|
+
// Spilled, not lost: `drainPersisted` puts these back
|
|
212
|
+
// through this path on the next start with the same
|
|
213
|
+
// ids, so anything waiting on them keeps waiting.
|
|
153
214
|
else -> persist(events)
|
|
154
215
|
}
|
|
155
216
|
}
|
|
@@ -192,7 +253,28 @@ object SentoriTransport {
|
|
|
192
253
|
return Outcome.FAILED
|
|
193
254
|
}
|
|
194
255
|
|
|
256
|
+
/**
|
|
257
|
+
* Test seam: skip the network and return this outcome.
|
|
258
|
+
* `0` delivered, `1` dropped, anything else failed.
|
|
259
|
+
*
|
|
260
|
+
* The iOS suite arrived here after two attempts to provoke a real
|
|
261
|
+
* failure by sending to a closed port: one CI runner dropped the
|
|
262
|
+
* packet and waited out the timeout, another answered in a way
|
|
263
|
+
* that read as a 4xx — and both times a working transport was
|
|
264
|
+
* reported broken. What these tests are about is the spill, the
|
|
265
|
+
* drain and the ordering, not TCP; `android-live-ingest` is what
|
|
266
|
+
* exercises the wire.
|
|
267
|
+
*/
|
|
268
|
+
internal var forcedOutcomeForTests: Int? = null
|
|
269
|
+
|
|
195
270
|
private fun sendOnce(envelope: Map<String, Any?>, config: SentoriConfig): Outcome {
|
|
271
|
+
forcedOutcomeForTests?.let {
|
|
272
|
+
return when (it) {
|
|
273
|
+
0 -> Outcome.DELIVERED
|
|
274
|
+
1 -> Outcome.DROPPED
|
|
275
|
+
else -> Outcome.FAILED
|
|
276
|
+
}
|
|
277
|
+
}
|
|
196
278
|
val body =
|
|
197
279
|
try {
|
|
198
280
|
toJson(envelope).toString()
|
|
@@ -359,6 +441,8 @@ object SentoriTransport {
|
|
|
359
441
|
assertStats.clear()
|
|
360
442
|
dropped = 0
|
|
361
443
|
delivered = 0
|
|
444
|
+
afterDelivery.clear()
|
|
445
|
+
forcedOutcomeForTests = null
|
|
362
446
|
started = false
|
|
363
447
|
pending?.cancel(false)
|
|
364
448
|
pending = null
|
|
@@ -380,4 +464,13 @@ object SentoriTransport {
|
|
|
380
464
|
* that had stored nothing.
|
|
381
465
|
*/
|
|
382
466
|
internal fun peekDelivered(): Int = synchronized(lock) { delivered }
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* How many blocks are still waiting on events. Without this a test
|
|
470
|
+
* cannot tell "discarded because the server refused it" from "has
|
|
471
|
+
* not been reached yet", and the version that tried instead
|
|
472
|
+
* flipped the forced outcome mid-send — measuring the race rather
|
|
473
|
+
* than the rule.
|
|
474
|
+
*/
|
|
475
|
+
internal fun peekWaiting(): Int = synchronized(lock) { afterDelivery.size }
|
|
383
476
|
}
|
|
@@ -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
|
+
}
|
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
// Run `node scripts/sync-native-core.mjs` after editing it.
|
|
4
4
|
package com.sentori
|
|
5
5
|
|
|
6
|
+
import androidx.test.core.app.ApplicationProvider
|
|
6
7
|
import java.io.File
|
|
7
8
|
import org.json.JSONObject
|
|
8
9
|
import org.junit.After
|
|
9
10
|
import org.junit.Assert.assertEquals
|
|
11
|
+
import org.junit.Assert.assertNotNull
|
|
10
12
|
import org.junit.Assert.assertTrue
|
|
11
13
|
import org.junit.Assume.assumeTrue
|
|
12
14
|
import org.junit.Before
|
|
@@ -102,5 +104,46 @@ class SentoriLiveServerTest {
|
|
|
102
104
|
SentoriTransport.peekDelivered() > 0,
|
|
103
105
|
)
|
|
104
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=="
|
|
105
147
|
}
|
|
148
|
+
|
|
106
149
|
}
|