1 import ExpoModulesTestCore
2 
3 @testable import ExpoModulesCore
4 
5 class FunctionSpec: ExpoSpec {
6   override func spec() {
7     let appContext = AppContext.create()
8     let functionName = "test function name"
9 
10     context("native") {
11       func testFunctionReturning<T: Equatable>(value returnValue: T) {
12         waitUntil { done in
13           mockModuleHolder(appContext) {
14             AsyncFunction(functionName) {
15               return returnValue
16             }
17           }
18           .call(function: functionName, args: []) { result in
19             let value = try! result.get()
20 
21             expect(value).notTo(beNil())
22             expect(value).to(beAKindOf(T.self))
23             expect(value as? T).to(equal(returnValue))
24             done()
25           }
26         }
27       }
28 
29       it("is called") {
30         waitUntil { done in
31           mockModuleHolder(appContext) {
32             AsyncFunction(functionName) {
33               done()
34             }
35           }
36           .call(function: functionName, args: [])
37         }
38       }
39 
40       it("returns bool values") {
41         testFunctionReturning(value: true)
42         testFunctionReturning(value: false)
43         testFunctionReturning(value: [true, false])
44       }
45 
46       it("returns int values") {
47         testFunctionReturning(value: 1_234)
48         testFunctionReturning(value: [2, 1, 3, 7])
49       }
50 
51       it("returns double values") {
52         testFunctionReturning(value: 3.14)
53         testFunctionReturning(value: [0, 1.1, 2.2])
54       }
55 
56       it("returns string values") {
57         testFunctionReturning(value: "a string")
58         testFunctionReturning(value: ["expo", "modules", "core"])
59       }
60 
61       it("is called with nil value") {
62         let str: String? = nil
63 
64         mockModuleHolder(appContext) {
65           AsyncFunction(functionName) { (a: String?) in
66             expect(a == nil) == true
67           }
68         }
69         .callSync(function: functionName, args: [str as Any])
70       }
71 
72       it("is called with an array of arrays") {
73         let array: [[String]] = [["expo"]]
74 
75         mockModuleHolder(appContext) {
76           AsyncFunction(functionName) { (a: [[String]]) in
77             expect(a.first!.first) == array.first!.first
78           }
79         }
80         .callSync(function: functionName, args: [array])
81       }
82 
83       describe("converting records") {
84         struct TestRecord: Record {
85           @Field var property: String = "expo"
86           @Field var optionalProperty: Int?
87           @Field("propertyWithCustomKey") var customKeyProperty: String = "expo"
88         }
89         let dict = [
90           "property": "Hello",
91           "propertyWithCustomKey": "Expo!"
92         ]
93 
94         it("converts to simple record when passed as an argument") {
95           waitUntil { done in
96             mockModuleHolder(appContext) {
97               AsyncFunction(functionName) { (a: TestRecord) in
98                 return a.property
99               }
100             }
101             .call(function: functionName, args: [dict]) { result in
102               let value = try! result.get()
103 
104               expect(value).notTo(beNil())
105               expect(value).to(beAKindOf(String.self))
106               expect(value).to(be(dict["property"]))
107               done()
108             }
109           }
110         }
111 
112         it("converts to record with custom key") {
113           waitUntil { done in
114             mockModuleHolder(appContext) {
115               AsyncFunction(functionName) { (a: TestRecord) in
116                 return a.customKeyProperty
117               }
118             }
119             .call(function: functionName, args: [dict]) { result in
120               let value = try! result.get()
121               expect(value).notTo(beNil())
122               expect(value).to(beAKindOf(String.self))
123               expect(value).to(be(dict["propertyWithCustomKey"]))
124               done()
125             }
126           }
127         }
128 
129         it("returns the record back (sync)") {
130           let result = try Function(functionName) { (record: TestRecord) in record }
131             .call(by: nil, withArguments: [dict], appContext: appContext) as? TestRecord
132 
133           guard let result = Conversions.convertFunctionResult(result, appContext: appContext) as? TestRecord.Dict else {
134             return fail()
135           }
136 
137           expect(result).notTo(beNil())
138           expect(result["property"] as? String).to(equal(dict["property"]))
139           expect(result["propertyWithCustomKey"] as? String).to(equal(dict["propertyWithCustomKey"]))
140         }
141 
142         it("returns the record back (async)") {
143           waitUntil { done in
144             mockModuleHolder(appContext) {
145               AsyncFunction(functionName) { (a: TestRecord) in
146                 return a
147               }
148             }
149             .call(function: functionName, args: [dict]) { result in
150               let value = try! result.get()
151 
152               expect(value).notTo(beNil())
153               expect(value).to(beAKindOf(Record.Dict.self))
154 
155               let valueAsDict = value as! Record.Dict
156 
157               expect(valueAsDict["property"] as? String).to(equal(dict["property"]))
158               expect(valueAsDict["propertyWithCustomKey"] as? String).to(equal(dict["propertyWithCustomKey"]))
159               done()
160             }
161           }
162         }
163       }
164 
165       it("throws when called with more arguments than expected") {
166         waitUntil { done in
167           mockModuleHolder(appContext) {
168             AsyncFunction(functionName) { (_: Int) in
169               return "something"
170             }
171           }
172           // Function expects one argument, let's give it more.
173           .call(function: functionName, args: [1, 2]) { result in
174             switch result {
175             case .failure(let error):
176               expect(error).notTo(beNil())
177               expect(error).to(beAKindOf(InvalidArgsNumberException.self))
178             case .success(_):
179               fail()
180             }
181             done()
182           }
183         }
184       }
185 
186       it("allows to skip trailing optional arguments") {
187         let returnedValue = "something"
188         let fn = Function(functionName) { (a: String, b: Int?, c: Bool?) in
189           expect(c).to(beNil())
190           return returnedValue
191         }
192 
193         expect({ try fn.call(by: nil, withArguments: ["test"], appContext: appContext) })
194           .notTo(throwError())
195           .to(be(returnedValue))
196 
197         expect({ try fn.call(by: nil, withArguments: ["test", 3], appContext: appContext) })
198           .notTo(throwError())
199           .to(be(returnedValue))
200       }
201 
202       it("throws when called without required arguments") {
203         let fn = Function(functionName) { (requiredArgument: String, optionalArgument: Int?) in
204           return "something"
205         }
206 
207         expect({ try fn.call(by: nil, withArguments: [], appContext: appContext) })
208           .to(throwError(errorType: FunctionCallException.self) { error in
209             expect(error.rootCause).to(beAKindOf(InvalidArgsNumberException.self))
210             let exception = error.rootCause as! InvalidArgsNumberException
211             expect(exception.param.received) == 0
212             expect(exception.param.required) == 1
213             expect(exception.param.expected) == 2
214           })
215       }
216 
217       it("throws when called with arguments of incompatible types") {
218         waitUntil { done in
219           mockModuleHolder(appContext) {
220             AsyncFunction(functionName) { (_: String) in
221               return "something"
222             }
223           }
224           // Function expects a string, let's give it a number.
225           .call(function: functionName, args: [1]) { result in
226             switch result {
227             case .failure(let error):
228               expect(error).notTo(beNil())
229               expect(error).to(beAKindOf(FunctionCallException.self))
230               expect(error.isCausedBy(ArgumentCastException.self)) == true
231               expect(error.isCausedBy(Conversions.CastingException<String>.self)) == true
232             case .success(_):
233               fail()
234             }
235             done()
236           }
237         }
238       }
239     }
240 
241     context("JavaScript") {
242       let runtime = try! appContext.runtime
243 
244       beforeSuite {
245         appContext.moduleRegistry.register(holder: mockModuleHolder(appContext) {
246           Name("TestModule")
247 
248           Function("returnPi") { Double.pi }
249 
250           Function("returnNull") { () -> Double? in
251             return nil
252           }
253 
254           Function("isArgNull") { (arg: Double?) -> Bool in
255             return arg == nil
256           }
257 
258           Function("returnObjectDefinition") { (initial: Int) -> ObjectDefinition in
259             var foo = initial
260 
261             return Object {
262               Function("increment") { () -> Int in
263                 foo += 1
264                 return foo
265               }
266             }
267           }
268 
269           Function("withFunction") { (fn: JavaScriptFunction<String>) -> String in
270             return try fn.call("foo", "bar")
271           }
272         })
273       }
274 
275       it("returns values") {
276         expect(try runtime.eval("expo.modules.TestModule.returnPi()").asDouble()) == Double.pi
277         expect(try runtime.eval("expo.modules.TestModule.returnNull()").isNull()) == true
278       }
279 
280       it("accepts optional arguments") {
281         expect(try runtime.eval("expo.modules.TestModule.isArgNull(3.14)").asBool()) == false
282         expect(try runtime.eval("expo.modules.TestModule.isArgNull(null)").asBool()) == true
283       }
284 
285       it("returns object made from definition") {
286         let initialValue = Int.random(in: 1..<100)
287         let object = try runtime.eval("object = expo.modules.TestModule.returnObjectDefinition(\(initialValue))")
288 
289         expect(object.kind) == .object
290         expect(object.getObject().hasProperty("increment")) == true
291 
292         let result = try runtime.eval("object.increment()")
293 
294         expect(result.kind) == .number
295         expect(result.getInt()) == initialValue + 1
296       }
297 
298       it("takes JavaScriptFunction argument") {
299         let value = try runtime.eval("expo.modules.TestModule.withFunction((a, b) => a + b)")
300 
301         expect(value.kind) == .string
302         expect(value.getString()) == "foobar"
303       }
304     }
305   }
306 }
307