1 package expo.modules.test.core 2 3 import com.facebook.react.bridge.ReadableArray 4 import expo.modules.kotlin.ModuleHolder 5 import expo.modules.kotlin.Promise 6 import expo.modules.kotlin.types.JSTypeConverter 7 import java.lang.reflect.InvocationHandler 8 import java.lang.reflect.Method 9 import kotlin.reflect.KClass 10 11 /** 12 * The promise rejection will be converted into this exception. 13 */ 14 class TestCodedException( 15 code: String, 16 message: String?, 17 cause: Throwable? 18 ) : Exception("[$code] $message", cause) 19 20 /** 21 * Mocked module invocation handler which dispatches a call on test interface to the corresponding 22 * exported function or to the module controller if the method doesn't exist in the module definition. 23 * 24 * Methods mapping: 25 * AsyncFunction("name") { args: ArgsType -> return ReturnType } can be invoked using one of the following methods mapping rules: 26 * - [non-promise mapping] fun ModuleTestInterface.name(args: ArgsType): ReturnType 27 * - [promise mapping] fun ModuleTestInterface.name(args: ArgsType, promise: Promise): Unit 28 * 29 * AsyncFunction("name") { args: ArgsType, promise: Promise -> promise.resolve(ReturnType) } can be invoked using one of the following methods mapping rules: 30 * - [non-promise mapping] fun ModuleTestInterface.name(args: ArgsType): ReturnType 31 * - [promise mapping] fun ModuleTestInterface.name(args: ArgsType, promise: Promise): Unit 32 * 33 * Function("name") { args: ArgsType -> return ReturnType } can be invoked using non-promise mapping only: 34 * - fun ModuleTestInterface.name(args: ArgsType): ReturnType 35 * 36 * In tests, the non-promise mapping should be preferred if possible. 37 * The promise mapping should be only used when dealing with native async code. 38 * 39 * In the non-promise mapping, rejection will be converted into exceptions. 40 * If you want to test if the method rejects, add the `@Throws` annotation to the `ModuleTestInterface` method you are testing. 41 * Otherwise, the exception will be wrapped in `UndeclaredThrowableException`. 42 */ 43 class ModuleMockInvocationHandler<T : Any>( 44 private val moduleTestInterface: KClass<T>, 45 private val moduleController: ModuleController, 46 private val holder: ModuleHolder 47 ) : InvocationHandler { invokenull48 override fun invoke(proxy: Any, method: Method, args: Array<out Any>?): Any? { 49 if (!holder.definition.asyncFunctions.containsKey(method.name) && 50 !holder.definition.syncFunctions.containsKey(method.name) 51 ) { 52 return method.invoke(moduleController, *(args ?: emptyArray())) 53 } 54 55 return callExportedFunction(method.name, args) 56 } 57 callExportedFunctionnull58 private fun callExportedFunction(methodName: String, args: Array<out Any>?): Any? { 59 if (holder.definition.syncFunctions.containsKey(methodName)) { 60 // Call as a sync function 61 return holder.callSync(methodName, convertArgs(args?.asList() ?: emptyList())) 62 } 63 64 if (holder.definition.asyncFunctions.containsKey(methodName)) { 65 // We know it's a async function, but we don't know which mapping we're using 66 val lastArg = args?.lastOrNull() 67 if (Promise::class.java.isInstance(lastArg)) { 68 promiseMappingCall(methodName, args!!.dropLast(1), lastArg as Promise) 69 return Unit 70 } 71 72 return nonPromiseMappingCall(methodName, args) 73 } 74 75 throw IllegalStateException("Module class method '$methodName' not found") 76 } 77 nonPromiseMappingCallnull78 private fun nonPromiseMappingCall(methodName: String, args: Array<out Any>?): Any? { 79 val mockedPromise = PromiseMock() 80 holder.call(methodName, convertArgs(args?.asList() ?: emptyList()), mockedPromise) 81 82 when (mockedPromise.state) { 83 PromiseState.RESOLVED -> { 84 val moduleClassMethod = moduleTestInterface.members.firstOrNull { it.name == methodName } 85 ?: throw IllegalStateException("Module class method '$methodName' not found") 86 87 if (mockedPromise.resolveValue == null) { 88 if (moduleClassMethod.returnType.isMarkedNullable) { 89 return null 90 } 91 92 throw IllegalStateException("Method returns 'null' but the non-nullable type was expected") 93 } 94 95 if (!(moduleClassMethod.returnType.classifier as KClass<*>).isInstance(mockedPromise.resolveValue)) { 96 throw IllegalStateException("Illegal return type ${mockedPromise.resolveValue?.javaClass}, expected ${moduleClassMethod.returnType.classifier}.") 97 } 98 99 return mockedPromise.resolveValue 100 } 101 PromiseState.REJECTED -> 102 throw TestCodedException( 103 mockedPromise.rejectCode!!, 104 mockedPromise.rejectMessage, 105 mockedPromise.rejectThrowable 106 ) 107 PromiseState.NONE, PromiseState.ILLEGAL -> 108 throw IllegalStateException("Illegal promise state '${mockedPromise.state}'") 109 } 110 } 111 promiseMappingCallnull112 private fun promiseMappingCall(methodName: String, args: List<Any>, promise: Promise) { 113 holder.call(methodName, convertArgs(args), promise) 114 } 115 syncCallnull116 private fun syncCall(methodName: String, args: Iterable<Any?>): Any? { 117 return holder.callSync(methodName, convertArgs(args)) 118 } 119 convertArgsnull120 private fun convertArgs(args: Iterable<Any?>): ReadableArray { 121 return JSTypeConverter.convertToJSValue(args, TestJSContainerProvider) as ReadableArray 122 } 123 } 124