expo-modules-jsi 58.0.6 → 58.0.8
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/CHANGELOG.md +19 -0
- package/apple/Benchmarks/FreeFormBenchmarks.swift +61 -0
- package/apple/Benchmarks/RuntimeCacheBenchmarks.swift +71 -0
- package/apple/Benchmarks/UnownedDecodeBenchmarks.swift +121 -0
- package/apple/Sources/ExpoModulesJSI/Coding/JavaScriptCodable+Any.swift +500 -0
- package/apple/Sources/ExpoModulesJSI/Coding/JavaScriptCodable+Containers.swift +52 -10
- package/apple/Sources/ExpoModulesJSI/Coding/JavaScriptCodable+Data.swift +5 -0
- package/apple/Sources/ExpoModulesJSI/Coding/JavaScriptCodable+Date.swift +27 -5
- package/apple/Sources/ExpoModulesJSI/Coding/JavaScriptCodable+Primitives.swift +75 -0
- package/apple/Sources/ExpoModulesJSI/Coding/JavaScriptDecodable.swift +34 -5
- package/apple/Sources/ExpoModulesJSI/Coding/JavaScriptValueKinds.swift +73 -0
- package/apple/Sources/ExpoModulesJSI/Protocols/JSIRepresentable.swift +4 -2
- package/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptRuntime.swift +18 -13
- package/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptRuntimeCache.swift +74 -0
- package/apple/Sources/ExpoModulesJSI/Runtime/Values/JavaScriptArray.swift +26 -0
- package/apple/Sources/ExpoModulesJSI/Runtime/Values/JavaScriptObject.swift +30 -0
- package/apple/Sources/ExpoModulesJSI/Runtime/Values/JavaScriptPromise.swift +14 -14
- package/apple/Sources/ExpoModulesJSI/Runtime/Values/JavaScriptUnownedValue.swift +17 -0
- package/apple/Sources/ExpoModulesJSI/Runtime/Values/JavaScriptValue.swift +64 -2
- package/apple/Sources/ExpoModulesJSI/Utilities/String+JSI.swift +4 -3
- package/apple/Sources/ExpoModulesJSI-Cxx/include/JSIUtils.h +27 -0
- package/apple/Tests/JavaScriptCodableAnyTests.swift +531 -0
- package/apple/Tests/JavaScriptDecodableKindsTests.swift +132 -0
- package/apple/Tests/JavaScriptRuntimeCacheTests.swift +117 -0
- package/apple/Tests/JavaScriptRuntimeTests.swift +59 -0
- package/apple/Tests/JavaScriptUnownedDecodeTests.swift +99 -0
- package/apple/Tests/JavaScriptUnownedElementsTests.swift +76 -0
- package/apple/scripts/build-xcframework.sh +15 -0
- package/package.json +1 -1
|
@@ -10,7 +10,10 @@ public final class JavaScriptValue: JavaScriptType, Equatable, Escapable {
|
|
|
10
10
|
/// Handle to the runtime the value belongs to. `nil` only for runtime-free values (undefined, null,
|
|
11
11
|
/// booleans and numbers).
|
|
12
12
|
internal let runtimeHandle: JavaScriptRuntimeHandle?
|
|
13
|
-
|
|
13
|
+
/// Mutable only so that ``write(_:to:)`` can move the engine value out of a uniquely referenced
|
|
14
|
+
/// instance on the JS thread, right before it is deallocated. Unchecked exclusivity keeps reads of
|
|
15
|
+
/// a mutable class property free of the dynamic exclusivity check.
|
|
16
|
+
@exclusivity(unchecked) nonisolated(unsafe) internal var pointee: facebook.jsi.Value
|
|
14
17
|
|
|
15
18
|
/// The runtime the value belongs to, or `nil` if it has been deallocated or the value is runtime-free.
|
|
16
19
|
/// Prefer ``jsiRuntime`` on hot paths: it costs no reference counting.
|
|
@@ -119,6 +122,45 @@ public final class JavaScriptValue: JavaScriptType, Equatable, Escapable {
|
|
|
119
122
|
}
|
|
120
123
|
}
|
|
121
124
|
|
|
125
|
+
/// Calls `body` with a `JavaScriptUnownedValue` that borrows this value's `jsi::Value`, without
|
|
126
|
+
/// copying it. The unowned value is valid only for the duration of the closure and must not be
|
|
127
|
+
/// stored or escaped.
|
|
128
|
+
///
|
|
129
|
+
/// `runtime` must be the runtime the value belongs to. It is passed in because a runtime-free value
|
|
130
|
+
/// (undefined, null, a boolean or a number) doesn't hold one.
|
|
131
|
+
///
|
|
132
|
+
/// Inlinable, so the closure and `R` specialize in the caller; only `borrowUnownedValue(in:)` is a
|
|
133
|
+
/// call into this module.
|
|
134
|
+
@inlinable
|
|
135
|
+
public func withUnownedValue<R>(
|
|
136
|
+
in runtime: borrowing JavaScriptRuntime,
|
|
137
|
+
_ body: (borrowing JavaScriptUnownedValue) throws -> R
|
|
138
|
+
) rethrows -> R {
|
|
139
|
+
let unownedValue = borrowUnownedValue(in: runtime)
|
|
140
|
+
// The unowned value points into `self`, so `self` must outlive `body`.
|
|
141
|
+
defer { withExtendedLifetime(self) {} }
|
|
142
|
+
return try body(unownedValue)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/// A `JavaScriptUnownedValue` pointing at the stored `jsi::Value`. It stays valid while `self` is
|
|
146
|
+
/// alive: the value is a stored property of this instance, so its address doesn't change, and
|
|
147
|
+
/// `withUnsafeBytes(of:)` yields that address because a `jsi::Value` can't be copied. Not inlinable,
|
|
148
|
+
/// since it touches the JSI types; `withUnownedValue(in:_:)` is the only caller.
|
|
149
|
+
@usableFromInline
|
|
150
|
+
internal func borrowUnownedValue(in runtime: borrowing JavaScriptRuntime) -> JavaScriptUnownedValue {
|
|
151
|
+
let pointer = withUnsafeBytes(of: pointee) { bytes in
|
|
152
|
+
// `withUnsafeBytes(of:)` rather than `withUnsafePointer(to:)`, for the same SIL optimizer crash
|
|
153
|
+
// `withUnsafePointee(_:)` avoids.
|
|
154
|
+
guard let baseAddress = bytes.baseAddress else {
|
|
155
|
+
preconditionFailure(
|
|
156
|
+
"withUnsafeBytes(of:) gave an empty buffer for a jsi::Value, which can't happen for a non-zero-sized type"
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
return baseAddress.assumingMemoryBound(to: facebook.jsi.Value.self)
|
|
160
|
+
}
|
|
161
|
+
return JavaScriptUnownedValue(runtime.pointee, pointer)
|
|
162
|
+
}
|
|
163
|
+
|
|
122
164
|
// MARK: - Type checks
|
|
123
165
|
|
|
124
166
|
public func isUndefined() -> Bool {
|
|
@@ -534,6 +576,17 @@ public final class JavaScriptValue: JavaScriptType, Equatable, Escapable {
|
|
|
534
576
|
return copy()
|
|
535
577
|
}
|
|
536
578
|
|
|
579
|
+
/// Writes `value` into a host callback's result slot. A uniquely referenced instance, the normal
|
|
580
|
+
/// case for a value the callback just created, has its engine value moved out instead of cloned,
|
|
581
|
+
/// since the instance is deallocated right after. Shared instances go through ``writeJSIValue(to:)``.
|
|
582
|
+
internal static func write(_ value: inout JavaScriptValue, to slot: UnsafeMutablePointer<facebook.jsi.Value>) {
|
|
583
|
+
if value.runtimeHandle != nil, isKnownUniquelyReferenced(&value) {
|
|
584
|
+
expo.emplaceMovedValue(slot, &value.pointee)
|
|
585
|
+
} else {
|
|
586
|
+
value.writeJSIValue(to: slot)
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
537
590
|
/// Writes this value into a host callback's result slot. Undefined, null, booleans and numbers are
|
|
538
591
|
/// emplaced with the engine's inline constructors, so the common results skip the out-of-line
|
|
539
592
|
/// `jsi::Value` move and destroy that assigning to the slot would cost; everything else is copied
|
|
@@ -732,9 +785,18 @@ extension JavaScriptValue: JSIRepresentable {
|
|
|
732
785
|
extension JavaScriptValue {
|
|
733
786
|
public struct TypeError: Error, CustomStringConvertible {
|
|
734
787
|
let type: any ~Copyable.Type
|
|
788
|
+
/// A specific explanation that replaces the generic message, when the failure needs one.
|
|
789
|
+
var reason: String? = nil
|
|
790
|
+
/// Where inside the converted value the failure happened, such as `.user.tags[1]`. Empty when it's
|
|
791
|
+
/// the value itself.
|
|
792
|
+
var path: String = ""
|
|
735
793
|
|
|
736
794
|
public var description: String {
|
|
737
|
-
|
|
795
|
+
let message = "TypeError: \(reason ?? "Value cannot be represented as \(type)")"
|
|
796
|
+
guard !path.isEmpty else {
|
|
797
|
+
return message
|
|
798
|
+
}
|
|
799
|
+
return "\(message) (path: \(path.hasPrefix(".") ? path.dropFirst() : Substring(path)))"
|
|
738
800
|
}
|
|
739
801
|
}
|
|
740
802
|
}
|
|
@@ -69,9 +69,10 @@ private func appendEngineStringChunk(ctx: UnsafeMutableRawPointer?, ascii: Bool,
|
|
|
69
69
|
let chunk: String
|
|
70
70
|
if ascii {
|
|
71
71
|
let bytes = UnsafeBufferPointer(start: data.assumingMemoryBound(to: UInt8.self), count: count)
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
// `String(decoding:as:)` re-validates bytes the engine already guarantees to be ASCII, but it
|
|
73
|
+
// builds short strings inline. `String(unsafeUninitializedCapacity:)` always allocates heap
|
|
74
|
+
// storage first, which measured 5 to 10 ns slower for 6 and 22 byte strings.
|
|
75
|
+
chunk = String(decoding: bytes, as: UTF8.self)
|
|
75
76
|
} else {
|
|
76
77
|
let units = UnsafeBufferPointer(start: data.assumingMemoryBound(to: UInt16.self), count: count)
|
|
77
78
|
if count < 512 {
|
|
@@ -165,6 +165,8 @@ inline void collectGarbage(jsi::IRuntime &runtime, const std::string &cause) {
|
|
|
165
165
|
// owns nothing; an object or string there would leak its engine handle. Both callers,
|
|
166
166
|
// `createHostFunction` and `HostObject::get`, pass a default-constructed slot. The Swift side picks
|
|
167
167
|
// the helper by kind in `JavaScriptValue.writeJSIValue(to:)` and moves everything else.
|
|
168
|
+
// `JavaScriptValue.write(_:to:)` moves a uniquely referenced value's handle into the slot the same
|
|
169
|
+
// way, through `emplaceMovedValue`.
|
|
168
170
|
inline void emplaceUndefined(jsi::Value *_Nonnull slot) noexcept {
|
|
169
171
|
::new (slot) jsi::Value();
|
|
170
172
|
}
|
|
@@ -181,6 +183,31 @@ inline void emplaceNumber(jsi::Value *_Nonnull slot, double value) noexcept {
|
|
|
181
183
|
::new (slot) jsi::Value(value);
|
|
182
184
|
}
|
|
183
185
|
|
|
186
|
+
/**
|
|
187
|
+
Moves the value at `source` into the engine's result slot. Placement new, like the `emplace*`
|
|
188
|
+
helpers above: the slot must hold a value that owns nothing. `source` is left holding a null
|
|
189
|
+
pointer, so destroying it afterwards releases nothing.
|
|
190
|
+
*/
|
|
191
|
+
inline void emplaceMovedValue(jsi::Value *_Nonnull slot, jsi::Value *_Nonnull source) noexcept {
|
|
192
|
+
::new (slot) jsi::Value(std::move(*source));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
Creates a string value straight from UTF-8 bytes. The `jsi::String` returned by `createFromUtf8`
|
|
197
|
+
is moved into the value, so the engine allocates one handle. Going through `jsi::Value(runtime,
|
|
198
|
+
const jsi::String &)` from Swift instead clones the handle and then releases the original.
|
|
199
|
+
*/
|
|
200
|
+
inline jsi::Value createStringValueFromUtf8(jsi::IRuntime &runtime, const uint8_t *_Nonnull utf8, size_t length) {
|
|
201
|
+
return jsi::Value(jsi::String::createFromUtf8(runtime, utf8, length));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
Same as `createStringValueFromUtf8`, for bytes the caller knows to be ASCII.
|
|
206
|
+
*/
|
|
207
|
+
inline jsi::Value createStringValueFromAscii(jsi::IRuntime &runtime, const char *_Nonnull ascii, size_t length) {
|
|
208
|
+
return jsi::Value(jsi::String::createFromAscii(runtime, ascii, length));
|
|
209
|
+
}
|
|
210
|
+
|
|
184
211
|
inline jsi::Value callFunction(jsi::IRuntime &runtime, const jsi::Function &function, const jsi::Value *_Nullable args, size_t count) {
|
|
185
212
|
return expo::CppError::tryCatch(runtime, [&] {
|
|
186
213
|
return function.call(runtime, args, count);
|
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
// Copyright 2026-present 650 Industries. All rights reserved.
|
|
2
|
+
|
|
3
|
+
import ExpoModulesJSI
|
|
4
|
+
import Foundation
|
|
5
|
+
import Testing
|
|
6
|
+
|
|
7
|
+
@Suite("JavaScriptCodable+Any")
|
|
8
|
+
@JavaScriptActor
|
|
9
|
+
struct JavaScriptCodableAnyTests {
|
|
10
|
+
let runtime = JavaScriptRuntime()
|
|
11
|
+
|
|
12
|
+
// MARK: - decodeAny
|
|
13
|
+
|
|
14
|
+
@Test
|
|
15
|
+
func `decodes primitives into their native types`() throws {
|
|
16
|
+
#expect(try JavaScriptValue.decodeAny(runtime.eval("true"), in: runtime) as? Bool == true)
|
|
17
|
+
#expect(try JavaScriptValue.decodeAny(runtime.eval("1.5"), in: runtime) as? Double == 1.5)
|
|
18
|
+
#expect(try JavaScriptValue.decodeAny(runtime.eval("'abc'"), in: runtime) as? String == "abc")
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@Test
|
|
22
|
+
func `decodes numbers as doubles`() throws {
|
|
23
|
+
let decoded = try JavaScriptValue.decodeAny(runtime.eval("3"), in: runtime)
|
|
24
|
+
#expect(decoded is Double)
|
|
25
|
+
#expect(decoded as? Double == 3)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@Test
|
|
29
|
+
func `decodes null and undefined as nil`() throws {
|
|
30
|
+
#expect(try isNil(JavaScriptValue.decodeAny(runtime.eval("null"), in: runtime)))
|
|
31
|
+
#expect(try isNil(JavaScriptValue.decodeAny(runtime.eval("undefined"), in: runtime)))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@Test
|
|
35
|
+
func `decodes nested containers`() throws {
|
|
36
|
+
let decoded = try JavaScriptValue.decodeAny(
|
|
37
|
+
runtime.eval("({ list: [1, 'a', null], inner: { flag: false } })"),
|
|
38
|
+
in: runtime
|
|
39
|
+
)
|
|
40
|
+
let dictionary = try #require(decoded as? [String: Any])
|
|
41
|
+
let list = try #require(dictionary["list"] as? [Any])
|
|
42
|
+
let inner = try #require(dictionary["inner"] as? [String: Any])
|
|
43
|
+
#expect(list.count == 3)
|
|
44
|
+
#expect(list[0] as? Double == 1)
|
|
45
|
+
#expect(list[1] as? String == "a")
|
|
46
|
+
#expect(isNil(list[2]))
|
|
47
|
+
#expect(inner["flag"] as? Bool == false)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@Test
|
|
51
|
+
func `decodes from a borrowed value`() throws {
|
|
52
|
+
let buffer = try JavaScriptValuesBuffer.allocate(in: runtime, with: runtime.eval("({ a: 'x' })"))
|
|
53
|
+
let decoded = try JavaScriptValue.decodeAny(buffer.unownedValue(at: 0), in: runtime)
|
|
54
|
+
#expect((decoded as? [String: Any])?["a"] as? String == "x")
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@Test
|
|
58
|
+
func `decodes a bigint within the Int64 range as Int64`() throws {
|
|
59
|
+
#expect(try JavaScriptValue.decodeAny(runtime.eval("42n"), in: runtime) as? Int64 == 42)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@Test
|
|
63
|
+
func `throws for a bigint outside the Int64 range`() throws {
|
|
64
|
+
#expect(throws: BigIntConversionError.self) {
|
|
65
|
+
try JavaScriptValue.decodeAny(runtime.eval("2n ** 64n"), in: runtime)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
@Test
|
|
70
|
+
func `throws TypeError for a function`() throws {
|
|
71
|
+
#expect(throws: JavaScriptValue.TypeError.self) {
|
|
72
|
+
try JavaScriptValue.decodeAny(runtime.eval("(() => {})"), in: runtime)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@Test
|
|
77
|
+
func `throws TypeError for a symbol`() throws {
|
|
78
|
+
#expect(throws: JavaScriptValue.TypeError.self) {
|
|
79
|
+
try JavaScriptValue.decodeAny(runtime.eval("Symbol('s')"), in: runtime)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@Test
|
|
84
|
+
func `throws TypeError for a function nested in a container`() throws {
|
|
85
|
+
#expect(throws: JavaScriptValue.TypeError.self) {
|
|
86
|
+
try JavaScriptValue.decodeAnyDictionary(runtime.eval("({ callback() {} })"), in: runtime)
|
|
87
|
+
}
|
|
88
|
+
#expect(throws: JavaScriptValue.TypeError.self) {
|
|
89
|
+
try JavaScriptValue.decodeAnyArray(runtime.eval("[1, () => {}]"), in: runtime)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
@Test
|
|
94
|
+
func `throws TypeError for a borrowed function`() throws {
|
|
95
|
+
let buffer = try JavaScriptValuesBuffer.allocate(in: runtime, with: runtime.eval("(() => {})"))
|
|
96
|
+
#expect(throws: JavaScriptValue.TypeError.self) {
|
|
97
|
+
try JavaScriptValue.decodeAny(buffer.unownedValue(at: 0), in: runtime)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
@Test
|
|
102
|
+
func `decodes a borrowed bigint as Int64`() throws {
|
|
103
|
+
let buffer = try JavaScriptValuesBuffer.allocate(in: runtime, with: runtime.eval("7n"))
|
|
104
|
+
let decoded = try JavaScriptValue.decodeAny(buffer.unownedValue(at: 0), in: runtime)
|
|
105
|
+
#expect(decoded as? Int64 == 7)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
@Test
|
|
109
|
+
func `decodes a Date as Date`() throws {
|
|
110
|
+
let decoded = try JavaScriptValue.decodeAny(runtime.eval("({ at: new Date(1500) })"), in: runtime)
|
|
111
|
+
#expect((decoded as? [String: Any])?["at"] as? Date == Date(timeIntervalSince1970: 1.5))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
@Test
|
|
115
|
+
func `decodes a borrowed Date as Date`() throws {
|
|
116
|
+
let buffer = try JavaScriptValuesBuffer.allocate(in: runtime, with: runtime.eval("new Date(1500)"))
|
|
117
|
+
let decoded = try JavaScriptValue.decodeAny(buffer.unownedValue(at: 0), in: runtime)
|
|
118
|
+
#expect(decoded as? Date == Date(timeIntervalSince1970: 1.5))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
@Test
|
|
122
|
+
func `round-trips a Date through encode and decode`() throws {
|
|
123
|
+
let date = Date(timeIntervalSince1970: 2)
|
|
124
|
+
let decoded = try JavaScriptValue.decodeAny(JavaScriptValue.encodeAny(date, in: runtime), in: runtime)
|
|
125
|
+
#expect(decoded as? Date == date)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
@Test
|
|
129
|
+
func `decodes a Map with string keys as a dictionary and a Set as an array of its elements`() throws {
|
|
130
|
+
let decoded = try JavaScriptValue.decodeAnyDictionary(
|
|
131
|
+
runtime.eval("({ map: new Map([['k', 1], ['n', { x: true }]]), set: new Set([1, 'two', [3]]) })"),
|
|
132
|
+
in: runtime
|
|
133
|
+
)
|
|
134
|
+
let map = try #require(decoded["map"] as? [String: Any])
|
|
135
|
+
#expect(map.count == 2)
|
|
136
|
+
#expect(map["k"] as? Double == 1)
|
|
137
|
+
#expect((map["n"] as? [String: Any])?["x"] as? Bool == true)
|
|
138
|
+
let set = try #require(decoded["set"] as? [Any])
|
|
139
|
+
try #require(set.count == 3)
|
|
140
|
+
#expect(set[0] as? Double == 1)
|
|
141
|
+
#expect(set[1] as? String == "two")
|
|
142
|
+
#expect((set[2] as? [Any])?.first as? Double == 3)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// An `undefined` value means "absent" in a `Map` too, the same as for an object property.
|
|
146
|
+
@Test
|
|
147
|
+
func `drops Map entries whose value is undefined`() throws {
|
|
148
|
+
let decoded = try JavaScriptValue.decodeAnyDictionary(
|
|
149
|
+
runtime.eval("new Map([['gone', undefined], ['kept', 1]])"),
|
|
150
|
+
in: runtime
|
|
151
|
+
)
|
|
152
|
+
#expect(Array(decoded.keys) == ["kept"])
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
@Test
|
|
156
|
+
func `throws TypeError for a Map with a key that isn't a string`() throws {
|
|
157
|
+
let error = #expect(throws: JavaScriptValue.TypeError.self) {
|
|
158
|
+
try JavaScriptValue.decodeAny(runtime.eval("({ lookup: new Map([[1, 'one']]) })"), in: runtime)
|
|
159
|
+
}
|
|
160
|
+
#expect(error?.description.contains("path: lookup") == true)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
@Test
|
|
164
|
+
func `names the path to an unrepresentable value inside a Map`() throws {
|
|
165
|
+
let error = #expect(throws: JavaScriptValue.TypeError.self) {
|
|
166
|
+
try JavaScriptValue.decodeAny(runtime.eval("({ lookup: new Map([['k', () => {}]]) })"), in: runtime)
|
|
167
|
+
}
|
|
168
|
+
#expect(error?.description.contains("path: lookup.k") == true)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
@Test
|
|
172
|
+
func `throws TypeError for a WeakMap, a WeakSet or a DataView`() throws {
|
|
173
|
+
#expect(throws: JavaScriptValue.TypeError.self) {
|
|
174
|
+
try JavaScriptValue.decodeAny(runtime.eval("({ cache: new WeakMap() })"), in: runtime)
|
|
175
|
+
}
|
|
176
|
+
#expect(throws: JavaScriptValue.TypeError.self) {
|
|
177
|
+
try JavaScriptValue.decodeAny(runtime.eval("[new WeakSet()]"), in: runtime)
|
|
178
|
+
}
|
|
179
|
+
#expect(throws: JavaScriptValue.TypeError.self) {
|
|
180
|
+
try JavaScriptValue.decodeAny(runtime.eval("({ view: new DataView(new ArrayBuffer(4)) })"), in: runtime)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// An `Error` keeps `message` and `stack` in non-enumerable properties, so only what the caller
|
|
185
|
+
// added is decoded. This pins the documented limit of decoding from enumerable own properties.
|
|
186
|
+
@Test
|
|
187
|
+
func `decodes an Error from its enumerable properties only`() throws {
|
|
188
|
+
let decoded = try JavaScriptValue.decodeAnyDictionary(
|
|
189
|
+
runtime.eval("const error = new Error('failed'); error.code = 'E_FAILED'; error"),
|
|
190
|
+
in: runtime
|
|
191
|
+
)
|
|
192
|
+
#expect(Array(decoded.keys) == ["code"])
|
|
193
|
+
#expect(decoded["code"] as? String == "E_FAILED")
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
@Test
|
|
197
|
+
func `throws TypeError for a typed array or an ArrayBuffer`() throws {
|
|
198
|
+
#expect(throws: JavaScriptValue.TypeError.self) {
|
|
199
|
+
try JavaScriptValue.decodeAny(runtime.eval("({ bytes: new Uint8Array(4) })"), in: runtime)
|
|
200
|
+
}
|
|
201
|
+
#expect(throws: JavaScriptValue.TypeError.self) {
|
|
202
|
+
try JavaScriptValue.decodeAny(runtime.eval("[new ArrayBuffer(4)]"), in: runtime)
|
|
203
|
+
}
|
|
204
|
+
#expect(throws: JavaScriptValue.TypeError.self) {
|
|
205
|
+
try JavaScriptValue.decodeAnyDictionary(runtime.eval("new Float64Array(4)"), in: runtime)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
@Test
|
|
210
|
+
func `throws TypeError for a cyclic value`() throws {
|
|
211
|
+
#expect(throws: JavaScriptValue.TypeError.self) {
|
|
212
|
+
try JavaScriptValue.decodeAnyDictionary(
|
|
213
|
+
runtime.eval("const object = {}; object.self = object; object"),
|
|
214
|
+
in: runtime
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
#expect(throws: JavaScriptValue.TypeError.self) {
|
|
218
|
+
try JavaScriptValue.decodeAnyArray(runtime.eval("const array = []; array.push(array); array"), in: runtime)
|
|
219
|
+
}
|
|
220
|
+
#expect(throws: JavaScriptValue.TypeError.self) {
|
|
221
|
+
try JavaScriptValue.decodeAny(runtime.eval("const set = new Set(); set.add(set); set"), in: runtime)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
@Test
|
|
226
|
+
func `names the path to an unrepresentable value in the error`() throws {
|
|
227
|
+
let error = #expect(throws: JavaScriptValue.TypeError.self) {
|
|
228
|
+
try JavaScriptValue.decodeAny(runtime.eval("({ user: { tags: ['a', () => {}] } })"), in: runtime)
|
|
229
|
+
}
|
|
230
|
+
#expect(error?.description.contains("user.tags[1]") == true)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// MARK: - decodeAnyArray
|
|
234
|
+
|
|
235
|
+
@Test
|
|
236
|
+
func `decodes an array of mixed values`() throws {
|
|
237
|
+
let decoded = try JavaScriptValue.decodeAnyArray(runtime.eval("[true, 2, 'three', [4]]"), in: runtime)
|
|
238
|
+
#expect(decoded.count == 4)
|
|
239
|
+
#expect(decoded[0] as? Bool == true)
|
|
240
|
+
#expect(decoded[1] as? Double == 2)
|
|
241
|
+
#expect(decoded[2] as? String == "three")
|
|
242
|
+
#expect((decoded[3] as? [Any])?.first as? Double == 4)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
@Test
|
|
246
|
+
func `decodes undefined array elements as nil`() throws {
|
|
247
|
+
let decoded = try JavaScriptValue.decodeAnyArray(runtime.eval("[undefined, 1]"), in: runtime)
|
|
248
|
+
#expect(decoded.count == 2)
|
|
249
|
+
#expect(isNil(decoded[0]))
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// A `Set` already decodes as an array of its elements, so it isn't wrapped like other non-array
|
|
253
|
+
// values. This matches `decodeAny(_:as: [Any].self)` and a `Set` nested inside a value.
|
|
254
|
+
@Test
|
|
255
|
+
func `decodes a top-level Set as its elements`() throws {
|
|
256
|
+
let decoded = try JavaScriptValue.decodeAnyArray(runtime.eval("new Set([1, 'two'])"), in: runtime)
|
|
257
|
+
try #require(decoded.count == 2)
|
|
258
|
+
#expect(decoded[0] as? Double == 1)
|
|
259
|
+
#expect(decoded[1] as? String == "two")
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
@Test
|
|
263
|
+
func `decodes a borrowed top-level Set as its elements`() throws {
|
|
264
|
+
let buffer = try JavaScriptValuesBuffer.allocate(in: runtime, with: runtime.eval("new Set([1, 'two'])"))
|
|
265
|
+
let decoded = try JavaScriptValue.decodeAnyArray(buffer.unownedValue(at: 0), in: runtime)
|
|
266
|
+
try #require(decoded.count == 2)
|
|
267
|
+
#expect(decoded[1] as? String == "two")
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
@Test
|
|
271
|
+
func `arrayizes a non-array value`() throws {
|
|
272
|
+
let decoded = try JavaScriptValue.decodeAnyArray(runtime.eval("'solo'"), in: runtime)
|
|
273
|
+
#expect(decoded.count == 1)
|
|
274
|
+
#expect(decoded[0] as? String == "solo")
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
@Test
|
|
278
|
+
func `decodes an array from a borrowed value`() throws {
|
|
279
|
+
let buffer = try JavaScriptValuesBuffer.allocate(in: runtime, with: runtime.eval("[1, 2]"))
|
|
280
|
+
let decoded = try JavaScriptValue.decodeAnyArray(buffer.unownedValue(at: 0), in: runtime)
|
|
281
|
+
#expect(decoded.compactMap { $0 as? Double } == [1, 2])
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// MARK: - decodeAnyDictionary
|
|
285
|
+
|
|
286
|
+
@Test
|
|
287
|
+
func `decodes an object of mixed values`() throws {
|
|
288
|
+
let decoded = try JavaScriptValue.decodeAnyDictionary(runtime.eval("({ a: 1, b: 'two', c: null })"), in: runtime)
|
|
289
|
+
#expect(decoded.count == 3)
|
|
290
|
+
#expect(decoded["a"] as? Double == 1)
|
|
291
|
+
#expect(decoded["b"] as? String == "two")
|
|
292
|
+
#expect(decoded.keys.contains("c"))
|
|
293
|
+
#expect(isNil(decoded["c"]!))
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
@Test
|
|
297
|
+
func `drops undefined properties`() throws {
|
|
298
|
+
let decoded = try JavaScriptValue.decodeAnyDictionary(runtime.eval("({ a: undefined, b: 1 })"), in: runtime)
|
|
299
|
+
#expect(Array(decoded.keys) == ["b"])
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
@Test
|
|
303
|
+
func `throws TypeError for a non-object value`() throws {
|
|
304
|
+
#expect(throws: JavaScriptValue.TypeError.self) {
|
|
305
|
+
try JavaScriptValue.decodeAnyDictionary(runtime.eval("42"), in: runtime)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// The dedicated dictionary path must treat a top-level value the same way as a nested one, and the
|
|
310
|
+
// same way as the generic `decodeAny(_:as: [String: Any].self)`.
|
|
311
|
+
@Test
|
|
312
|
+
func `decodes a top-level Map with string keys as a dictionary`() throws {
|
|
313
|
+
let decoded = try JavaScriptValue.decodeAnyDictionary(runtime.eval("new Map([['a', 1]])"), in: runtime)
|
|
314
|
+
#expect(decoded.count == 1)
|
|
315
|
+
#expect(decoded["a"] as? Double == 1)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
@Test
|
|
319
|
+
func `decodes a borrowed top-level Map with string keys as a dictionary`() throws {
|
|
320
|
+
let buffer = try JavaScriptValuesBuffer.allocate(in: runtime, with: runtime.eval("new Map([['a', 1]])"))
|
|
321
|
+
let decoded = try JavaScriptValue.decodeAnyDictionary(buffer.unownedValue(at: 0), in: runtime)
|
|
322
|
+
#expect(decoded["a"] as? Double == 1)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
@Test(arguments: [
|
|
326
|
+
"[1, 2]",
|
|
327
|
+
"new Date(0)",
|
|
328
|
+
"new Set([1])",
|
|
329
|
+
"(() => {})",
|
|
330
|
+
"new WeakMap()",
|
|
331
|
+
"new WeakSet()",
|
|
332
|
+
"new DataView(new ArrayBuffer(4))",
|
|
333
|
+
"new Map([[1, 'one']])",
|
|
334
|
+
])
|
|
335
|
+
func `throws TypeError for a top-level value that isn't a dictionary`(source: String) throws {
|
|
336
|
+
#expect(throws: JavaScriptValue.TypeError.self) {
|
|
337
|
+
try JavaScriptValue.decodeAnyDictionary(runtime.eval(source), in: runtime)
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
@Test
|
|
342
|
+
func `decodes an object from a borrowed value`() throws {
|
|
343
|
+
let buffer = try JavaScriptValuesBuffer.allocate(in: runtime, with: runtime.eval("({ k: true })"))
|
|
344
|
+
let decoded = try JavaScriptValue.decodeAnyDictionary(buffer.unownedValue(at: 0), in: runtime)
|
|
345
|
+
#expect(decoded["k"] as? Bool == true)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// MARK: - decodeAny(_:as:in:)
|
|
349
|
+
|
|
350
|
+
@Test
|
|
351
|
+
func `decodes a nested free-form type`() throws {
|
|
352
|
+
let decoded = try JavaScriptValue.decodeAny(runtime.eval("[[1], ['a', 'b']]"), as: [[Any]].self, in: runtime)
|
|
353
|
+
#expect(decoded.count == 2)
|
|
354
|
+
#expect(decoded[1].count == 2)
|
|
355
|
+
#expect(decoded[1][0] as? String == "a")
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
@Test
|
|
359
|
+
func `decodes null and undefined as nil for an optional type`() throws {
|
|
360
|
+
#expect(try JavaScriptValue.decodeAny(runtime.eval("null"), as: [String: [Any]]?.self, in: runtime) == nil)
|
|
361
|
+
#expect(try JavaScriptValue.decodeAny(runtime.eval("undefined"), as: [String: [Any]]?.self, in: runtime) == nil)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
@Test
|
|
365
|
+
func `decodes a present value for an optional type`() throws {
|
|
366
|
+
let decoded = try JavaScriptValue.decodeAny(runtime.eval("({ a: [1] })"), as: [String: [Any]]?.self, in: runtime)
|
|
367
|
+
#expect(decoded?["a"]?.first as? Double == 1)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
@Test
|
|
371
|
+
func `throws TypeError when the value doesn't match the type`() throws {
|
|
372
|
+
#expect(throws: JavaScriptValue.TypeError.self) {
|
|
373
|
+
try JavaScriptValue.decodeAny(runtime.eval("'text'"), as: [String: [Any]].self, in: runtime)
|
|
374
|
+
}
|
|
375
|
+
#expect(throws: JavaScriptValue.TypeError.self) {
|
|
376
|
+
try JavaScriptValue.decodeAny(runtime.eval("null"), as: [[Any]].self, in: runtime)
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
@Test
|
|
381
|
+
func `explains how free-form values decode when the cast fails`() throws {
|
|
382
|
+
let error = #expect(throws: JavaScriptValue.TypeError.self) {
|
|
383
|
+
try JavaScriptValue.decodeAny(runtime.eval("({ 1: 'one' })"), as: [Int: Any].self, in: runtime)
|
|
384
|
+
}
|
|
385
|
+
#expect(error?.description.contains("Double") == true)
|
|
386
|
+
#expect(error?.description.contains("String") == true)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
@Test
|
|
390
|
+
func `decodes a nested null as nil for a nested optional`() throws {
|
|
391
|
+
let dictionary = try JavaScriptValue.decodeAny(
|
|
392
|
+
runtime.eval("({ a: null, b: [1] })"),
|
|
393
|
+
as: [String: [Any]?].self,
|
|
394
|
+
in: runtime
|
|
395
|
+
)
|
|
396
|
+
#expect(dictionary.keys.contains("a"))
|
|
397
|
+
#expect(dictionary["a"]! == nil)
|
|
398
|
+
#expect(dictionary["b"]??.first as? Double == 1)
|
|
399
|
+
let list = try JavaScriptValue.decodeAny(runtime.eval("[null, 1]"), as: [Any?].self, in: runtime)
|
|
400
|
+
#expect(list.count == 2)
|
|
401
|
+
#expect(list[0] == nil)
|
|
402
|
+
#expect(list[1] as? Double == 1)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// MARK: - encodeAny
|
|
406
|
+
|
|
407
|
+
@Test
|
|
408
|
+
func `encodes native primitives`() throws {
|
|
409
|
+
#expect(try JavaScriptValue.encodeAny("abc", in: runtime).getString() == "abc")
|
|
410
|
+
#expect(try JavaScriptValue.encodeAny(1.5, in: runtime).getDouble() == 1.5)
|
|
411
|
+
#expect(try JavaScriptValue.encodeAny(true, in: runtime).getBool() == true)
|
|
412
|
+
#expect(try JavaScriptValue.encodeAny(42, in: runtime).getInt() == 42)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
@Test
|
|
416
|
+
func `throws for an Int outside the safe-integer range, like Int.encode`() throws {
|
|
417
|
+
#expect(throws: (any Error).self) {
|
|
418
|
+
try JavaScriptValue.encodeAny(9_007_199_254_740_993, in: runtime)
|
|
419
|
+
}
|
|
420
|
+
#expect(throws: (any Error).self) {
|
|
421
|
+
try JavaScriptValue.encodeAnyDictionary(["big": 9_007_199_254_740_993], in: runtime)
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
@Test
|
|
426
|
+
func `encodes boolean NSNumbers as booleans and other NSNumbers as numbers`() throws {
|
|
427
|
+
let yes = try JavaScriptValue.encodeAny(NSNumber(value: true), in: runtime)
|
|
428
|
+
let one = try JavaScriptValue.encodeAny(NSNumber(value: 1), in: runtime)
|
|
429
|
+
#expect(yes.isBool())
|
|
430
|
+
#expect(yes.getBool() == true)
|
|
431
|
+
#expect(one.isNumber())
|
|
432
|
+
#expect(one.getDouble() == 1)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
@Test
|
|
436
|
+
func `encodes nil and NSNull as null`() throws {
|
|
437
|
+
let none: String? = nil
|
|
438
|
+
#expect(try JavaScriptValue.encodeAny(none as Any, in: runtime).isNull())
|
|
439
|
+
#expect(try JavaScriptValue.encodeAny(NSNull(), in: runtime).isNull())
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
@Test
|
|
443
|
+
func `encodes a present optional as its wrapped value`() throws {
|
|
444
|
+
let some: Any? = "wrapped"
|
|
445
|
+
#expect(try JavaScriptValue.encodeAny(some as Any, in: runtime).getString() == "wrapped")
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
@Test
|
|
449
|
+
func `encodes nested free-form containers`() throws {
|
|
450
|
+
let value: [[Any]] = [[1, "a"], [["k": true] as [String: Any]]]
|
|
451
|
+
let encoded = try JavaScriptValue.encodeAny(value, in: runtime)
|
|
452
|
+
runtime.global().setProperty("encoded", value: encoded)
|
|
453
|
+
#expect(try runtime.eval("JSON.stringify(encoded)").getString() == #"[[1,"a"],[{"k":true}]]"#)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
@Test
|
|
457
|
+
func `encodes JavaScriptEncodable values`() throws {
|
|
458
|
+
let date = Date(timeIntervalSince1970: 1)
|
|
459
|
+
let encoded = try JavaScriptValue.encodeAny(date, in: runtime)
|
|
460
|
+
#expect(encoded.isObject())
|
|
461
|
+
#expect(try JavaScriptValue.encodeAny([1, 2] as [Int], in: runtime).getArray().length == 2)
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
@Test
|
|
465
|
+
func `round-trips a bigint through Int64`() throws {
|
|
466
|
+
let decoded = try JavaScriptValue.decodeAny(runtime.eval("9007199254740993n"), in: runtime)
|
|
467
|
+
let encoded = try JavaScriptValue.encodeAny(decoded, in: runtime)
|
|
468
|
+
try #require(encoded.isBigInt())
|
|
469
|
+
#expect(try encoded.getBigInt().asInt64() == 9_007_199_254_740_993)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
@Test
|
|
473
|
+
func `passes a JavaScriptValue through`() throws {
|
|
474
|
+
let object = try runtime.eval("({ marker: 7 })")
|
|
475
|
+
let encoded = try JavaScriptValue.encodeAny(object, in: runtime)
|
|
476
|
+
#expect(encoded.getObject().getProperty("marker").getInt() == 7)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
@Test
|
|
480
|
+
func `throws for a value with no JavaScript representation`() throws {
|
|
481
|
+
struct Opaque {}
|
|
482
|
+
#expect(throws: JavaScriptValue.EncodingError.self) {
|
|
483
|
+
try JavaScriptValue.encodeAny(Opaque(), in: runtime)
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// MARK: - encodeAnyArray / encodeAnyDictionary
|
|
488
|
+
|
|
489
|
+
@Test
|
|
490
|
+
func `encodes an array of mixed values`() throws {
|
|
491
|
+
let encoded = try JavaScriptValue.encodeAnyArray([true, 2, "three", NSNull()], in: runtime)
|
|
492
|
+
runtime.global().setProperty("encoded", value: encoded)
|
|
493
|
+
#expect(try runtime.eval("JSON.stringify(encoded)").getString() == #"[true,2,"three",null]"#)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
@Test
|
|
497
|
+
func `encodes a dictionary of mixed values`() throws {
|
|
498
|
+
let encoded = try JavaScriptValue.encodeAnyDictionary(["a": 1, "b": ["x", false] as [Any]], in: runtime)
|
|
499
|
+
let object = encoded.getObject()
|
|
500
|
+
#expect(object.getProperty("a").getInt() == 1)
|
|
501
|
+
runtime.global().setProperty("encoded", value: object.getProperty("b"))
|
|
502
|
+
#expect(try runtime.eval("JSON.stringify(encoded)").getString() == #"["x",false]"#)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
@Test
|
|
506
|
+
func `round-trips a dictionary through decode and encode`() throws {
|
|
507
|
+
let decoded = try JavaScriptValue.decodeAnyDictionary(
|
|
508
|
+
runtime.eval("({ n: 1.5, s: 's', l: [true, null], o: { z: 0 } })"),
|
|
509
|
+
in: runtime
|
|
510
|
+
)
|
|
511
|
+
let encoded = try JavaScriptValue.encodeAnyDictionary(decoded, in: runtime)
|
|
512
|
+
runtime.global().setProperty("encoded", value: encoded)
|
|
513
|
+
#expect(
|
|
514
|
+
try runtime.eval("JSON.stringify(encoded, ['l', 'n', 'o', 's', 'z'])").getString()
|
|
515
|
+
== #"{"l":[true,null],"n":1.5,"o":{"z":0},"s":"s"}"#
|
|
516
|
+
)
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/// True when a free-form value is `nil`, looking through any optionals it's boxed in (a decoded
|
|
521
|
+
/// `null` is a `nil` boxed in `Any`, which `== nil` doesn't see).
|
|
522
|
+
private func isNil(_ value: Any) -> Bool {
|
|
523
|
+
let mirror = Mirror(reflecting: value)
|
|
524
|
+
guard mirror.displayStyle == .optional else {
|
|
525
|
+
return false
|
|
526
|
+
}
|
|
527
|
+
guard let wrapped = mirror.children.first else {
|
|
528
|
+
return true
|
|
529
|
+
}
|
|
530
|
+
return isNil(wrapped.value)
|
|
531
|
+
}
|