expo-modules-jsi 57.0.0 → 57.0.2

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.
@@ -0,0 +1,270 @@
1
+ // Copyright 2025-present 650 Industries. All rights reserved.
2
+
3
+ import ExpoModulesJSI
4
+ import Foundation
5
+ import Testing
6
+
7
+ @Suite("JavaScriptCodable+Date")
8
+ @JavaScriptActor
9
+ struct JavaScriptCodableDateTests {
10
+ let runtime = JavaScriptRuntime()
11
+
12
+ // MARK: - Decode
13
+
14
+ @Test
15
+ func `decodes a JS Date to the same instant`() throws {
16
+ // 2021-01-01T00:00:00.000Z expressed as an absolute UTC millisecond instant. `Date.UTC` returns
17
+ // that instant directly, so no local timezone enters the JS side.
18
+ let value = try runtime.eval("new Date(Date.UTC(2021, 0, 1, 0, 0, 0, 0))")
19
+ let decoded = try Date.decode(value, in: runtime)
20
+ #expect(decoded.timeIntervalSince1970 == 1_609_459_200.0)
21
+ }
22
+
23
+ @Test
24
+ func `decodes the Unix epoch`() throws {
25
+ let decoded = try Date.decode(runtime.eval("new Date(0)"), in: runtime)
26
+ #expect(decoded.timeIntervalSince1970 == 0.0)
27
+ }
28
+
29
+ @Test
30
+ func `decodes a pre-epoch Date`() throws {
31
+ // A negative millisecond instant (1960-01-01T00:00:00Z) must decode to a negative interval.
32
+ let value = try runtime.eval("new Date(Date.UTC(1960, 0, 1))")
33
+ let decoded = try Date.decode(value, in: runtime)
34
+ #expect(decoded.timeIntervalSince1970 == -315_619_200.0)
35
+ }
36
+
37
+ @Test
38
+ func `decode preserves sub-second milliseconds`() throws {
39
+ // Assert on the whole-millisecond instant (an integer-valued Double, always exact) rather than the
40
+ // reconstructed fractional seconds, whose last bit can differ by an ULP across the JS boundary.
41
+ let decoded = try Date.decode(runtime.eval("new Date(1234)"), in: runtime)
42
+ #expect((decoded.timeIntervalSince1970 * 1000.0).rounded() == 1234.0)
43
+ }
44
+
45
+ @Test
46
+ func `decodes a JS Date through the borrowed-value overload`() throws {
47
+ // `Date` does not override the zero-copy overload (it needs a `getTime()` call), so this goes
48
+ // through the default that copies the borrowed value into an owning one and forwards.
49
+ let value = try runtime.eval("new Date(5000)")
50
+ let buffer = JavaScriptValuesBuffer.copying(in: runtime, values: [value])
51
+ let decoded = try Date.decode(buffer.unownedValue(at: 0), in: runtime)
52
+ #expect(decoded.timeIntervalSince1970 == 5.0)
53
+ }
54
+
55
+ // MARK: - Encode
56
+
57
+ @Test
58
+ func `encodes a Date to a JS Date`() throws {
59
+ let encoded = try Date.encode(Date(timeIntervalSince1970: 1_609_459_200.0), in: runtime)
60
+ #expect(encoded.is("Date") == true)
61
+ }
62
+
63
+ @Test
64
+ func `encoded Date carries the same instant into JS`() throws {
65
+ // Cross-check the encoded value against JS's own `getTime()`, so the assertion is that JS and
66
+ // Swift agree on the absolute millisecond instant — not merely that a Date was produced.
67
+ let date = Date(timeIntervalSince1970: 1_609_459_200.0)
68
+ let encoded = try Date.encode(date, in: runtime)
69
+ let millisecondsInJS = try encoded.asObject().callFunction("getTime").asDouble()
70
+ #expect(millisecondsInJS == 1_609_459_200_000.0)
71
+ }
72
+
73
+ // MARK: - Timezone / calendar independence
74
+
75
+ @Test
76
+ func `instant is independent of the JS Date's local-time getters`() throws {
77
+ // Build the same absolute instant two ways: from a UTC millisecond value, and from local-time
78
+ // components. `Date.UTC` and the `new Date(y, m, d, …)` local constructor produce different
79
+ // instants unless the runtime is at UTC, so we decode the explicitly-UTC one and assert the
80
+ // instant matches its `getTime()` exactly. The point: decode reads `getTime()` (absolute UTC ms),
81
+ // never a local-time getter, so no timezone offset is folded in.
82
+ let value = try runtime.eval("new Date(Date.UTC(2021, 5, 15, 12, 30, 45, 123))")
83
+ let expectedMilliseconds = try value.asObject().callFunction("getTime").asDouble()
84
+ let decoded = try Date.decode(value, in: runtime)
85
+ #expect((decoded.timeIntervalSince1970 * 1000.0).rounded() == expectedMilliseconds)
86
+ }
87
+
88
+ @Test
89
+ func `round-trip is calendar and timezone agnostic`() throws {
90
+ // A `Date` is an absolute instant with no stored calendar or timezone; both JS `Date` and Swift
91
+ // `Date` are epoch-relative. This encodes to JS and decodes back, asserting the instant is
92
+ // unchanged at millisecond granularity regardless of the ambient calendar/timezone.
93
+ let original = Date(timeIntervalSince1970: 1_655_296_245.123)
94
+ let encoded = try Date.encode(original, in: runtime)
95
+ let roundTripped = try Date.decode(encoded, in: runtime)
96
+ // Compare at whole-millisecond granularity (JS `Date`'s resolution); the original is already an
97
+ // exact number of milliseconds, so nothing is lost, but the comparison avoids float-equality ULP noise.
98
+ #expect(
99
+ (roundTripped.timeIntervalSince1970 * 1000.0).rounded() == (original.timeIntervalSince1970 * 1000.0).rounded())
100
+ }
101
+
102
+ @Test
103
+ func `round-trip drops sub-millisecond precision`() throws {
104
+ // JS `Date` is integer-millisecond resolution, so the microsecond tail of a Swift `Date` is lost
105
+ // on the way through JS. The round-trip lands on the truncated whole millisecond (JS's `Date`
106
+ // constructor truncates the fractional millisecond toward zero), and no longer equals the original.
107
+ let original = Date(timeIntervalSince1970: 1.2345678)
108
+ let roundTripped = try Date.decode(Date.encode(original, in: runtime), in: runtime)
109
+ #expect((roundTripped.timeIntervalSince1970 * 1000.0).rounded() == 1234.0)
110
+ #expect(roundTripped.timeIntervalSince1970 != original.timeIntervalSince1970)
111
+ }
112
+
113
+ @Test
114
+ func `Date current time round-trips to the millisecond`() throws {
115
+ let now = Date()
116
+ let roundTripped = try Date.decode(Date.encode(now, in: runtime), in: runtime)
117
+ // Truncate the original to whole milliseconds (JS's resolution) and compare on the millisecond
118
+ // instant, which is an exact integer-valued Double on both sides.
119
+ let nowMilliseconds = (now.timeIntervalSince1970 * 1000.0).rounded(.towardZero)
120
+ #expect((roundTripped.timeIntervalSince1970 * 1000.0).rounded() == nowMilliseconds)
121
+ }
122
+
123
+ // MARK: - Decode from a JS number (milliseconds since the epoch)
124
+
125
+ @Test
126
+ func `decodes a number as milliseconds since the epoch`() throws {
127
+ // A JS number is interpreted the same way `new Date(ms)` interprets it, so `getTime()` round-trips.
128
+ let decoded = try Date.decode(runtime.eval("1609459200000"), in: runtime)
129
+ #expect(decoded.timeIntervalSince1970 == 1_609_459_200.0)
130
+ }
131
+
132
+ @Test
133
+ func `decodes zero as the Unix epoch`() throws {
134
+ let decoded = try Date.decode(runtime.eval("0"), in: runtime)
135
+ #expect(decoded.timeIntervalSince1970 == 0.0)
136
+ }
137
+
138
+ @Test
139
+ func `decodes a negative number as a pre-epoch instant`() throws {
140
+ let decoded = try Date.decode(runtime.eval("-315619200000"), in: runtime)
141
+ #expect(decoded.timeIntervalSince1970 == -315_619_200.0)
142
+ }
143
+
144
+ @Test
145
+ func `a JS Date's getTime decodes to the same instant as the Date`() throws {
146
+ // Passing `someDate` and passing `someDate.getTime()` must decode to the same Swift `Date`.
147
+ let fromDate = try Date.decode(runtime.eval("new Date(Date.UTC(2021, 0, 1))"), in: runtime)
148
+ let fromMilliseconds = try Date.decode(runtime.eval("new Date(Date.UTC(2021, 0, 1)).getTime()"), in: runtime)
149
+ #expect(fromDate.timeIntervalSince1970 == fromMilliseconds.timeIntervalSince1970)
150
+ }
151
+
152
+ @Test
153
+ func `decode truncates a fractional number toward zero like the Date constructor`() throws {
154
+ // `new Date(1.9).getTime()` is `1` and `new Date(-1.9).getTime()` is `-1`: the constructor applies
155
+ // TimeClip (truncate toward zero), so the number branch must too rather than keeping 1.9 ms.
156
+ let positive = try Date.decode(runtime.eval("1.9"), in: runtime)
157
+ #expect((positive.timeIntervalSince1970 * 1000.0).rounded() == 1.0)
158
+ let negative = try Date.decode(runtime.eval("-1.9"), in: runtime)
159
+ #expect((negative.timeIntervalSince1970 * 1000.0).rounded() == -1.0)
160
+ }
161
+
162
+ @Test
163
+ func `decode accepts the maximum representable JS date`() throws {
164
+ // ±8.64e15 ms (100,000,000 days from the epoch) is the inclusive bound of a valid JS `Date`.
165
+ let maximum = try Date.decode(runtime.eval("8640000000000000"), in: runtime)
166
+ #expect((maximum.timeIntervalSince1970 * 1000.0) == 8_640_000_000_000_000.0)
167
+ let minimum = try Date.decode(runtime.eval("-8640000000000000"), in: runtime)
168
+ #expect((minimum.timeIntervalSince1970 * 1000.0) == -8_640_000_000_000_000.0)
169
+ }
170
+
171
+ @Test
172
+ func `decode rejects a number beyond the JS date range`() throws {
173
+ // One millisecond past the bound is an `Invalid Date` in JS (`new Date(8640000000000001).getTime()`
174
+ // is `NaN`), so decode must reject it rather than accept an instant JS `Date` can't hold.
175
+ #expect(throws: InvalidDateException.self) {
176
+ _ = try Date.decode(runtime.eval("8640000000000001"), in: runtime)
177
+ }
178
+ #expect(throws: InvalidDateException.self) {
179
+ _ = try Date.decode(runtime.eval("-8640000000000001"), in: runtime)
180
+ }
181
+ }
182
+
183
+ @Test
184
+ func `decode matches new Date for the same number`() throws {
185
+ // Cross-check the number branch against the constructor for values that exercise TimeClip.
186
+ for source in ["1.9", "-1.9", "0.4", "1234.5678", "8640000000000000"] {
187
+ let expected = try runtime.eval("new Date(\(source)).getTime()").asDouble()
188
+ let decoded = try Date.decode(runtime.eval(source), in: runtime)
189
+ #expect((decoded.timeIntervalSince1970 * 1000.0).rounded() == expected)
190
+ }
191
+ }
192
+
193
+ // MARK: - Decode from a string (parsed by the JS `Date` constructor)
194
+
195
+ @Test
196
+ func `decodes an ISO 8601 string with fractional seconds`() throws {
197
+ let decoded = try Date.decode(runtime.eval("\"2021-01-01T00:00:00.123Z\""), in: runtime)
198
+ #expect((decoded.timeIntervalSince1970 * 1000.0).rounded() == 1_609_459_200_123.0)
199
+ }
200
+
201
+ @Test
202
+ func `decodes an ISO 8601 string without fractional seconds`() throws {
203
+ let decoded = try Date.decode(runtime.eval("\"2021-01-01T00:00:00Z\""), in: runtime)
204
+ #expect(decoded.timeIntervalSince1970 == 1_609_459_200.0)
205
+ }
206
+
207
+ @Test
208
+ func `decodes an ISO 8601 string with a timezone offset to the correct UTC instant`() throws {
209
+ // 01:00 at +01:00 is the same absolute instant as 00:00 UTC; the offset must be applied, not ignored.
210
+ let decoded = try Date.decode(runtime.eval("\"2021-01-01T01:00:00+01:00\""), in: runtime)
211
+ #expect(decoded.timeIntervalSince1970 == 1_609_459_200.0)
212
+ }
213
+
214
+ @Test
215
+ func `decodes a date-only string as UTC midnight`() throws {
216
+ // The JS `Date` constructor reads a date-only ISO string as UTC midnight (unlike a zone-less
217
+ // date-time, which it reads as local). This form is accepted here because `new Date(str)` accepts it.
218
+ let decoded = try Date.decode(runtime.eval("\"2021-01-01\""), in: runtime)
219
+ #expect(decoded.timeIntervalSince1970 == 1_609_459_200.0)
220
+ }
221
+
222
+ @Test
223
+ func `decodes a string the same way new Date parses it`() throws {
224
+ // The decode contract for a string is "exactly what `new Date(str)` produces". Cross-check the
225
+ // decoded instant against the engine's own parse of the same string, so this holds for whatever
226
+ // date grammar the runtime supports (ISO, RFC-2822-ish, etc.), identically on every platform.
227
+ let source = "\"2021-06-15T12:30:45.500Z\""
228
+ let expectedMilliseconds = try runtime.eval("new Date(\(source)).getTime()").asDouble()
229
+ let decoded = try Date.decode(runtime.eval(source), in: runtime)
230
+ #expect((decoded.timeIntervalSince1970 * 1000.0).rounded() == expectedMilliseconds)
231
+ }
232
+
233
+ // MARK: - Error paths
234
+
235
+ @Test
236
+ func `Date decode throws on an unparseable string`() throws {
237
+ // `new Date(...)` yields an `Invalid Date` (NaN time) rather than throwing; decode turns that NaN
238
+ // into a thrown error.
239
+ #expect(throws: InvalidDateException.self) {
240
+ _ = try Date.decode(runtime.eval("\"not a date\""), in: runtime)
241
+ }
242
+ #expect(throws: InvalidDateException.self) {
243
+ _ = try Date.decode(runtime.eval("\"\""), in: runtime)
244
+ }
245
+ }
246
+
247
+ @Test
248
+ func `Date decode throws on NaN`() throws {
249
+ // A NaN number produces an `Invalid Date` in JS; decode must reject it rather than yield a bogus instant.
250
+ #expect(throws: InvalidDateException.self) {
251
+ _ = try Date.decode(runtime.eval("NaN"), in: runtime)
252
+ }
253
+ }
254
+
255
+ @Test
256
+ func `Date decode throws on an unsupported type`() throws {
257
+ #expect(throws: InvalidDateException.self) {
258
+ _ = try Date.decode(runtime.eval("({})"), in: runtime)
259
+ }
260
+ #expect(throws: InvalidDateException.self) {
261
+ _ = try Date.decode(runtime.eval("null"), in: runtime)
262
+ }
263
+ #expect(throws: InvalidDateException.self) {
264
+ _ = try Date.decode(runtime.eval("true"), in: runtime)
265
+ }
266
+ #expect(throws: InvalidDateException.self) {
267
+ _ = try Date.decode(runtime.eval("[1, 2, 3]"), in: runtime)
268
+ }
269
+ }
270
+ }
@@ -179,6 +179,39 @@ struct JavaScriptErrorTests {
179
179
  #expect(caught.getObject().getProperty("message").getString() == "native failure")
180
180
  }
181
181
 
182
+ // MARK: - JavaScriptError.from(_:in:) helper
183
+
184
+ @Test
185
+ func `from returns an existing JavaScriptError unchanged`() {
186
+ // An existing `JavaScriptError` must be returned as the very same instance so its wrapped
187
+ // value (which may be an arbitrary JS value) reaches JS unchanged.
188
+ let original = JavaScriptError(runtime, value: JavaScriptValue(runtime, "just a string"))
189
+ let result = JavaScriptError.from(original, in: runtime)
190
+
191
+ #expect(result === original)
192
+ #expect(result.toValue().getString() == "just a string")
193
+ }
194
+
195
+ @Test
196
+ func `from routes a JavaScriptThrowable through the code-preserving initializer`() {
197
+ let throwable = CodedError(message: "Not found", code: "ERR_NOT_FOUND")
198
+ let object = JavaScriptError.from(throwable, in: runtime).toValue().getObject()
199
+
200
+ #expect(object.getProperty("message").getString() == "Not found")
201
+ #expect(object.getProperty("code").getString() == "ERR_NOT_FOUND")
202
+ }
203
+
204
+ @Test
205
+ func `from stringifies any other native error into a generic Error`() {
206
+ struct TestError: Error, CustomStringConvertible {
207
+ var description: String { "native failure" }
208
+ }
209
+ let object = JavaScriptError.from(TestError(), in: runtime).toValue().getObject()
210
+
211
+ #expect(object.getProperty("message").getString() == "native failure")
212
+ #expect(object.getProperty("code").isUndefined() == true)
213
+ }
214
+
182
215
  @Test
183
216
  func `nested host function errors are independent`() throws {
184
217
  let outer = runtime.createFunction("outer") { [self] _, _ in
@@ -106,6 +106,9 @@ struct JavaScriptPromiseTests {
106
106
  #expect(throws: Error.self) {
107
107
  _ = try promiseValue.getPromise()
108
108
  }
109
+ // A failed initializer must not leave its state registered, or it would pin the promise object
110
+ // in the collection until teardown even though no `JavaScriptPromise` escaped.
111
+ #expect(runtime.longLivedObjects.count == 0)
109
112
  }
110
113
 
111
114
  @Test
@@ -370,4 +373,126 @@ struct JavaScriptPromiseTests {
370
373
 
371
374
  #expect(promise.isDeferred == false)
372
375
  }
376
+
377
+ // MARK: - Long-lived object registration
378
+
379
+ @Test
380
+ func `deferred promise registers as a long-lived object`() throws {
381
+ let runtime = JavaScriptRuntime()
382
+ #expect(runtime.longLivedObjects.count == 0)
383
+
384
+ let promise = try JavaScriptPromise(runtime)
385
+ _ = promise.isDeferred
386
+
387
+ #expect(runtime.longLivedObjects.count == 1)
388
+ }
389
+
390
+ @Test
391
+ func `wrapping an existing promise registers to own its object`() throws {
392
+ let runtime = JavaScriptRuntime()
393
+ let promise = try runtime.eval("Promise.resolve(42)").getPromise()
394
+ #expect(promise.isDeferred == false)
395
+
396
+ // Even a wrapped promise owns a JSI object that must not be released against a freed runtime,
397
+ // so it registers to have that object swept at teardown.
398
+ #expect(runtime.longLivedObjects.count == 1)
399
+ }
400
+
401
+ @Test
402
+ func `settling keeps the promise registered while the wrapper is alive`() async throws {
403
+ let runtime = JavaScriptRuntime()
404
+ let promise = try JavaScriptPromise(runtime)
405
+ #expect(runtime.longLivedObjects.count == 1)
406
+
407
+ promise.resolve(JavaScriptValue(runtime, 42))
408
+ _ = try await promise.await()
409
+
410
+ // Settling releases the resolve/reject functions, but the state stays registered so it continues
411
+ // to own the promise object for as long as the wrapper is alive (it is only deregistered when the
412
+ // wrapper is dropped, see the tests below, or by the teardown sweep).
413
+ #expect(promise.isDeferred == false)
414
+ #expect(runtime.longLivedObjects.count == 1)
415
+ }
416
+
417
+ @Test
418
+ func `dropping a settled promise deregisters its state`() async throws {
419
+ let runtime = JavaScriptRuntime()
420
+
421
+ do {
422
+ let promise = try JavaScriptPromise(runtime)
423
+ promise.resolve(JavaScriptValue(runtime, 42))
424
+ _ = try await promise.await()
425
+ #expect(runtime.longLivedObjects.count == 1)
426
+ // Leaving the scope drops the last owner of the wrapper.
427
+ }
428
+
429
+ // Dropping the wrapper deregisters its state and releases the promise object, so a stream of
430
+ // short-lived promises doesn't pin their objects (and resolution values) until teardown.
431
+ #expect(runtime.longLivedObjects.count == 0)
432
+ }
433
+
434
+ @Test
435
+ func `dropping an unsettled promise deregisters its state`() throws {
436
+ let runtime = JavaScriptRuntime()
437
+
438
+ do {
439
+ let promise = try JavaScriptPromise(runtime)
440
+ #expect(promise.isDeferred == true)
441
+ #expect(runtime.longLivedObjects.count == 1)
442
+ // The promise is never settled; dropping the wrapper here is the only owner going away.
443
+ }
444
+
445
+ // Even an unsettled promise releases its state when its wrapper is dropped: nothing outside can
446
+ // settle it anymore, so keeping it registered would only pin the object until teardown.
447
+ #expect(runtime.longLivedObjects.count == 0)
448
+ }
449
+
450
+ @Test
451
+ func `dropping a wrapped promise deregisters its state`() throws {
452
+ let runtime = JavaScriptRuntime()
453
+
454
+ do {
455
+ let promise = try runtime.eval("Promise.resolve(42)").getPromise()
456
+ #expect(promise.isDeferred == false)
457
+ #expect(runtime.longLivedObjects.count == 1)
458
+ }
459
+
460
+ #expect(runtime.longLivedObjects.count == 0)
461
+ }
462
+
463
+ @Test
464
+ func `an unsettled deferred promise is released by the teardown sweep`() throws {
465
+ let runtime = JavaScriptRuntime()
466
+ let promise = try JavaScriptPromise(runtime)
467
+ #expect(runtime.longLivedObjects.count == 1)
468
+
469
+ // The promise is never settled; the runtime's teardown sweep must release its long-lived state.
470
+ runtime.longLivedObjects.clear()
471
+
472
+ #expect(runtime.longLivedObjects.count == 0)
473
+ // After the sweep the settle functions are released, so it can no longer be settled.
474
+ #expect(promise.isDeferred == false)
475
+ }
476
+
477
+ @Test
478
+ func `an unsettled deferred promise outliving its runtime does not crash on teardown`() throws {
479
+ // Reproduces the shape of the promise-teardown crash (#47454): a deferred promise is still in
480
+ // flight when its runtime is torn down. Before the state was owned by the runtime's
481
+ // `LongLivedObjectCollection`, its JSI values were released after the Hermes runtime was already
482
+ // destroyed, a use-after-free. Now the runtime's teardown sweep releases the state on the JS
483
+ // thread while the runtime is still valid.
484
+ var promise: JavaScriptPromise? = nil
485
+
486
+ do {
487
+ let runtime = JavaScriptRuntime()
488
+ promise = try JavaScriptPromise(runtime)
489
+ #expect(promise?.isDeferred == true)
490
+ // Leaving the scope releases the runtime while the promise is still unsettled. Its teardown
491
+ // sweep must release the promise's state before Hermes is destroyed.
492
+ }
493
+
494
+ // Dropping the promise wrapper here must not touch a freed runtime. This is the crash point in
495
+ // #47454; reaching the end of the test without a crash is the assertion.
496
+ promise = nil
497
+ }
373
498
  }
@@ -790,6 +790,73 @@ struct JavaScriptRuntimeTests {
790
790
  let otherRuntime = JavaScriptRuntime()
791
791
  #expect(otherRuntime.id != runtime.id)
792
792
  }
793
+
794
+ @Test
795
+ func `creating and releasing standalone runtimes repeatedly does not crash`() throws {
796
+ // Each standalone runtime owns its Hermes runtime and destroys it on `deinit`. Cycling through
797
+ // many create/use/release rounds exercises that teardown and would surface a use-after-free or
798
+ // double-free (destroying a runtime must not corrupt a subsequently created one). Calling `is`
799
+ // caches a `PropNameID` on the runtime, so this also covers releasing cached JSI objects before
800
+ // the runtime is freed.
801
+ for index in 0..<20 {
802
+ let localRuntime = JavaScriptRuntime()
803
+ let value = try localRuntime.eval("({ index: \(index) })")
804
+ #expect(value.is("Object") == true)
805
+ #expect(value.getObject().getProperty("index").getInt() == index)
806
+ }
807
+ }
808
+
809
+ // MARK: - Long-lived objects teardown
810
+
811
+ /// Records whether `allowRelease()` was called.
812
+ final class TrackedObject: LongLivedObject {
813
+ private(set) var released = false
814
+
815
+ func allowRelease() {
816
+ released = true
817
+ }
818
+ }
819
+
820
+ @Test
821
+ func `tearing down the runtime clears its long-lived objects`() {
822
+ let tracked = TrackedObject()
823
+
824
+ do {
825
+ let localRuntime = JavaScriptRuntime()
826
+ localRuntime.longLivedObjects.add(tracked)
827
+ #expect(localRuntime.longLivedObjects.count == 1)
828
+ // Leaving the scope releases the runtime. Its `deinit` destroys the owned Hermes runtime,
829
+ // tearing down the JS heap, which drops the teardown object's native state and fires its
830
+ // deallocator, sweeping the collection.
831
+ }
832
+
833
+ #expect(tracked.released == true)
834
+ }
835
+
836
+ @Test
837
+ func `an object removed before teardown is not released by the sweep`() {
838
+ let tracked = TrackedObject()
839
+
840
+ do {
841
+ let localRuntime = JavaScriptRuntime()
842
+ localRuntime.longLivedObjects.add(tracked)
843
+ localRuntime.longLivedObjects.remove(tracked)
844
+ }
845
+
846
+ #expect(tracked.released == false)
847
+ }
848
+
849
+ @Test
850
+ func `wrapping the same runtime again does not sweep the first wrapper's objects`() {
851
+ // Each wrapper pins its own teardown object under a per-wrapper property name. A second wrapper
852
+ // of the same underlying runtime must not overwrite the first's pinned object (which would let
853
+ // it be collected early and sweep the first wrapper's collection while the runtime is alive).
854
+ let tracked = TrackedObject()
855
+ runtime.longLivedObjects.add(tracked)
856
+ _ = runtime.withUnsafePointee { JavaScriptRuntime(unsafePointer: $0) }
857
+ #expect(tracked.released == false)
858
+ #expect(runtime.longLivedObjects.count == 1)
859
+ }
793
860
  }
794
861
 
795
862
  /// Runs `body` on a freshly spawned synchronous thread and bridges the result back into the