import ExpoModulesTestCore @testable import ExpoModulesCore class FunctionSpec: ExpoSpec { override class func spec() { let appContext = AppContext.create() let functionName = "test function name" context("native") { func testFunctionReturning(value returnValue: T) { waitUntil { done in mockModuleHolder(appContext) { AsyncFunction(functionName) { return returnValue } } .call(function: functionName, args: []) { result in let value = try! result.get() expect(value).notTo(beNil()) expect(value).to(beAKindOf(T.self)) expect(value as? T).to(equal(returnValue)) done() } } } it("is called") { waitUntil { done in mockModuleHolder(appContext) { AsyncFunction(functionName) { done() } } .call(function: functionName, args: []) } } it("returns bool values") { testFunctionReturning(value: true) testFunctionReturning(value: false) testFunctionReturning(value: [true, false]) } it("returns int values") { testFunctionReturning(value: 1_234) testFunctionReturning(value: [2, 1, 3, 7]) } it("returns double values") { testFunctionReturning(value: 3.14) testFunctionReturning(value: [0, 1.1, 2.2]) } it("returns string values") { testFunctionReturning(value: "a string") testFunctionReturning(value: ["expo", "modules", "core"]) } it("is called with nil value") { let str: String? = nil mockModuleHolder(appContext) { AsyncFunction(functionName) { (a: String?) in expect(a == nil) == true } } .callSync(function: functionName, args: [str as Any]) } it("is called with an array of arrays") { let array: [[String]] = [["expo"]] mockModuleHolder(appContext) { AsyncFunction(functionName) { (a: [[String]]) in expect(a.first!.first) == array.first!.first } } .callSync(function: functionName, args: [array]) } describe("converting records") { struct TestRecord: Record { @Field var property: String = "expo" @Field var optionalProperty: Int? @Field("propertyWithCustomKey") var customKeyProperty: String = "expo" } let dict = [ "property": "Hello", "propertyWithCustomKey": "Expo!" ] it("converts to simple record when passed as an argument") { waitUntil { done in mockModuleHolder(appContext) { AsyncFunction(functionName) { (a: TestRecord) in return a.property } } .call(function: functionName, args: [dict]) { result in let value = try! result.get() expect(value).notTo(beNil()) expect(value).to(beAKindOf(String.self)) expect(value as? String).to(equal(dict["property"]!)) done() } } } it("converts to record with custom key") { waitUntil { done in mockModuleHolder(appContext) { AsyncFunction(functionName) { (a: TestRecord) in return a.customKeyProperty } } .call(function: functionName, args: [dict]) { result in let value = try! result.get() expect(value).notTo(beNil()) expect(value).to(beAKindOf(String.self)) expect(value as? String).to(equal(dict["propertyWithCustomKey"])) done() } } } it("returns the record back (sync)") { let result = try Function(functionName) { (record: TestRecord) in record } .call(by: nil, withArguments: [dict], appContext: appContext) as? TestRecord guard let result = Conversions.convertFunctionResult(result, appContext: appContext) as? TestRecord.Dict else { return fail() } expect(result).notTo(beNil()) expect(result["property"] as? String).to(equal(dict["property"])) expect(result["propertyWithCustomKey"] as? String).to(equal(dict["propertyWithCustomKey"])) } it("returns the record back (async)") { waitUntil { done in mockModuleHolder(appContext) { AsyncFunction(functionName) { (a: TestRecord) in return a } } .call(function: functionName, args: [dict]) { result in let value = try! result.get() expect(value).notTo(beNil()) expect(value).to(beAKindOf(Record.Dict.self)) let valueAsDict = value as! Record.Dict expect(valueAsDict["property"] as? String).to(equal(dict["property"])) expect(valueAsDict["propertyWithCustomKey"] as? String).to(equal(dict["propertyWithCustomKey"])) done() } } } } it("throws when called with more arguments than expected") { waitUntil { done in mockModuleHolder(appContext) { AsyncFunction(functionName) { (_: Int) in return "something" } } // Function expects one argument, let's give it more. .call(function: functionName, args: [1, 2]) { result in switch result { case .failure(let error): expect(error).notTo(beNil()) expect(error).to(beAKindOf(InvalidArgsNumberException.self)) case .success(_): fail() } done() } } } it("allows to skip trailing optional arguments") { let returnedValue = "something" let fn = Function(functionName) { (a: String, b: Int?, c: Bool?) in expect(c).to(beNil()) return returnedValue } expect({ (try fn.call(by: nil, withArguments: ["test"], appContext: appContext)) as? String }) .notTo(throwError()) .to(equal(returnedValue)) expect({ (try fn.call(by: nil, withArguments: ["test", 3], appContext: appContext)) as? String }) .notTo(throwError()) .to(equal(returnedValue)) } it("throws when called without required arguments") { let fn = Function(functionName) { (requiredArgument: String, optionalArgument: Int?) in return "something" } expect({ try fn.call(by: nil, withArguments: [], appContext: appContext) }) .to(throwError(errorType: FunctionCallException.self) { error in expect(error.rootCause).to(beAKindOf(InvalidArgsNumberException.self)) let exception = error.rootCause as! InvalidArgsNumberException expect(exception.param.received) == 0 expect(exception.param.required) == 1 expect(exception.param.expected) == 2 }) } it("throws when called with arguments of incompatible types") { waitUntil { done in mockModuleHolder(appContext) { AsyncFunction(functionName) { (_: String) in return "something" } } // Function expects a string, let's give it a number. .call(function: functionName, args: [1]) { result in switch result { case .failure(let error): expect(error).notTo(beNil()) expect(error).to(beAKindOf(FunctionCallException.self)) expect(error.isCausedBy(ArgumentCastException.self)) == true expect(error.isCausedBy(Conversions.CastingException.self)) == true case .success(_): fail() } done() } } } } context("JavaScript") { let runtime = try! appContext.runtime struct TestRecord: Record { @Field var property: String = "expo" } struct TestURLRecord: Record { static let defaultURLString = "https://expo.dev" static let defaultURL = URL(string: defaultURLString)! @Field var url: URL = defaultURL } struct TestEncodable: Encodable { let name: String let version: Int } afterEach { try runtime.eval("globalThis.result = undefined") } beforeSuite { appContext.moduleRegistry.register(holder: mockModuleHolder(appContext) { Name("TestModule") Function("returnPi") { Double.pi } Function("returnNull") { () -> Double? in return nil } Function("returnUndefined") { () -> JavaScriptValue in return .undefined } Function("isArgNull") { (arg: Double?) -> Bool in return arg == nil } Function("returnObjectDefinition") { (initial: Int) -> ObjectDefinition in var foo = initial return Object { Function("increment") { () -> Int in foo += 1 return foo } } } Function("withFunction") { (fn: JavaScriptFunction) -> String in return try fn.call("foo", "bar") } Function("withCGFloat") { (f: CGFloat) in return "\(f)" } Function("withRecord") { (f: TestRecord) in return "\(f.property)" } Function("withURL") { return TestURLRecord.defaultURL } Function("withNestedURL") { return TestURLRecord() } Function("withOptionalRecord") { (f: TestRecord?) in return "\(f?.property ?? "no value")" } Function("returnEncodable") { return TestEncodable(name: "Expo SDK", version: 55) } Function("withSharedObject") { return SharedString("Test") } AsyncFunction("withSharedObjectAsync") { return SharedString("Test") } AsyncFunction("withArrayOfSharedObjectsAsync") { return [SharedString("Test1"), SharedString("Test2"), SharedString("Test3")] } AsyncFunction("withSharedObjectPromise") { (p: Promise) in p.resolve(SharedString("Test with Promise")) } Function("withEither") { (either: Either) in return either } AsyncFunction("withURLAsync") { return TestURLRecord.defaultURL } AsyncFunction("withNestedURLAsync") { return TestURLRecord() } Class("Shared", SharedString.self) { Property("value") { shared in return shared.ref } } }) } it("returns values") { expect(try runtime.eval("expo.modules.TestModule.returnPi()").asDouble()) == Double.pi expect(try runtime.eval("expo.modules.TestModule.returnNull()").isNull()) == true expect(try runtime.eval("expo.modules.TestModule.returnUndefined()").isUndefined()) == true } it("accepts optional arguments") { expect(try runtime.eval("expo.modules.TestModule.isArgNull(3.14)").asBool()) == false expect(try runtime.eval("expo.modules.TestModule.isArgNull(null)").asBool()) == true } it("returns object made from definition") { let initialValue = Int.random(in: 1..<100) let object = try runtime.eval("object = expo.modules.TestModule.returnObjectDefinition(\(initialValue))") expect(object.kind) == .object expect(object.getObject().hasProperty("increment")) == true let result = try runtime.eval("object.increment()") expect(result.kind) == .number expect(result.getInt()) == initialValue + 1 } it("takes JavaScriptFunction argument") { let value = try runtime.eval("expo.modules.TestModule.withFunction((a, b) => a + b)") expect(value.kind) == .string expect(value.getString()) == "foobar" } it("accepts CGFloat argument") { expect(try runtime.eval("expo.modules.TestModule.withCGFloat(20.23)").asString()) == "20.23" } it("accepts record") { expect(try runtime.eval("expo.modules.TestModule.withRecord({property: \"123\"})").asString()) == "123" } it("accepts no optional record") { expect(try runtime.eval("expo.modules.TestModule.withOptionalRecord()").asString()) == "no value" } it("accepts optional record") { expect(try runtime.eval("expo.modules.TestModule.withOptionalRecord({property: \"123\"})").asString()) == "123" } it("returns encodable struct") { let result = try runtime.eval("expo.modules.TestModule.returnEncodable()") expect(result.kind) == .object expect(result.getObject().getPropertyNames()).to(contain(["name", "version"])) expect(try result.getObject().getProperty("name").asString()) == "Expo SDK" expect(try result.getObject().getProperty("version").asInt()) == 55 } it("returns URL (sync)") { let result = try runtime.eval("globalThis.result = expo.modules.TestModule.withURL()") expect(result.kind) == .string expect(result.getString()) == TestURLRecord.defaultURLString } it("returns URL (async)") { try runtime.eval("expo.modules.TestModule.withURLAsync().then((result) => { globalThis.result = result; })") expect(safeBoolEval("!!globalThis.result")).toEventually(beTrue(), timeout: .milliseconds(2000)) let urlValue = try runtime.eval("url = globalThis.result") expect(urlValue.kind) == .string expect(urlValue.getString()) == TestURLRecord.defaultURLString } it("returns a record wit url (sync)") { let object = try runtime.eval("globalThis.result = expo.modules.TestModule.withNestedURL()") expect(object.kind) == .object expect(object.getObject().hasProperty("url")) == true expect(object.getObject().getProperty("url").getString()) == TestURLRecord.defaultURLString } it("returns a record with url (async)") { try runtime.eval("expo.modules.TestModule.withNestedURLAsync().then((result) => { globalThis.result = result; })") expect(safeBoolEval("!!globalThis.result.url")).toEventually(beTrue(), timeout: .milliseconds(2000)) let object = try runtime.eval("object = globalThis.result") expect(object.kind) == .object expect(object.getObject().hasProperty("url")) == true let urlValue = try runtime.eval("object.url") expect(urlValue.kind) == .string expect(urlValue.getString()) == TestURLRecord.defaultURLString } it("returns a SharedObject (sync)") { let object = try runtime.eval("expo.modules.TestModule.withSharedObject()") expect(object.kind) == .object expect(object.getObject().hasProperty("value")) == true expect(object.getObject().getProperty("value").getString()) == "Test" } it("returns a SharedObject (async)") { try runtime .eval( "expo.modules.TestModule.withSharedObjectAsync().then((result) => { globalThis.result = result; })" ) expect(safeBoolEval("globalThis.result != null")).toEventually(beTrue(), timeout: .milliseconds(4000)) let object = try runtime.eval("object = globalThis.result") expect(object.kind) == .object expect(object.getObject().hasProperty("value")) == true let result = try runtime.eval("object.value") expect(result.kind) == .string expect(result.getString()) == "Test" } it("returns an Array of SharedObjects (async)") { try runtime .eval( "expo.modules.TestModule.withArrayOfSharedObjectsAsync().then((result) => { globalThis.resultArray = result; })" ) expect(safeBoolEval("globalThis.resultArray != null")).toEventually(beTrue(), timeout: .milliseconds(2000)) let object = try runtime.eval("object = globalThis.resultArray") expect(object.kind) == .object expect(object.getObject().hasProperty("length")) == true let result = object.getArray() try result.enumerated().forEach { index, element in expect(element.kind) == .object expect(element.getObject().hasProperty("value")) == true let value = try runtime.eval("object[\(index)].value") expect(value.kind) == .string expect(value.getString()) == "Test\(index + 1)" } } it("returns a SharedObject with Promise") { try runtime .eval( "expo.modules.TestModule.withSharedObjectPromise().then((result) => { globalThis.promiseResult = result; })" ) expect(safeBoolEval("globalThis.promiseResult != null")).toEventually(beTrue(), timeout: .milliseconds(2000)) let object = try runtime.eval("object = globalThis.promiseResult") expect(object.kind) == .object expect(object.getObject().hasProperty("value")) == true let result = try runtime.eval("object.value") expect(result.kind) == .string expect(result.getString()) == "Test with Promise" } it("accepts and returns Either value") { let stringResult = try runtime.eval("expo.modules.TestModule.withEither('test string')") expect(stringResult.kind) == .string expect(stringResult.getString()) == "test string" let boolResult = try runtime.eval("expo.modules.TestModule.withEither(true)") expect(boolResult.kind) == .bool expect(boolResult.getBool()) == true } // For async tests, this is a safe way to repeatedly evaluate JS // and catch both Swift and ObjC exceptions func safeBoolEval(_ js: String) -> Bool { var result = false do { try EXUtilities.catchException { guard let jsResult = try? runtime.eval(js) else { return } result = jsResult.getBool() } } catch { return false } return result } } } } private class SharedString: SharedRef { override var nativeRefType: String { "string" } }