@smileid/usesmileid_testing 0.0.0-stage → 12.3.1-SNAPSHOT.71
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 +5 -0
- package/LICENSE +21 -0
- package/README.md +90 -2
- package/android/build.gradle +51 -0
- package/android/consumer-rules.pro +4 -0
- package/android/src/debug/java/com/usesmileid/testing/rn/UseSmileIDTestingModule.kt +127 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/release/java/com/usesmileid/testing/rn/UseSmileIDTestingModule.kt +24 -0
- package/app.plugin.js +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1 -0
- package/dist/use_smile_id_test.d.ts +23 -0
- package/dist/use_smile_id_test.js +1 -0
- package/dist/use_smile_id_test_scenario.d.ts +30 -0
- package/dist/use_smile_id_test_scenario.js +1 -0
- package/dist/use_smile_id_test_server.d.ts +86 -0
- package/dist/use_smile_id_test_server.js +1 -0
- package/expo-module.config.json +9 -0
- package/ios/UseSmileIDTestingExpo.podspec +58 -0
- package/ios/UseSmileIDTestingModule/UseSmileIDTestingModule.swift +191 -0
- package/package.json +74 -3
- package/plugin/build/android_loopback.d.ts +31 -0
- package/plugin/build/android_loopback.js +14 -0
- package/plugin/build/index.d.ts +7 -0
- package/plugin/build/index.js +1 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
Full release notes: [Smile ID docs](https://docs.usesmileid.com/developer-resources/sdks/mobile/release-notes/react-native-expo).
|
|
2
|
+
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
First release: the native SDKs' test server, for your own tests, and `UseSmileIDTest`, which scripts the selfie on Android and iOS. See the repository CHANGELOG.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Smile ID
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,91 @@
|
|
|
1
|
-
#
|
|
1
|
+
# `@smileid/usesmileid_testing`
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Test doubles for the UseSmileID React Native (Expo) SDK: the Android and iOS SDKs' in-process
|
|
4
|
+
test server (`com.usesmileid:testing`, `UseSmileIDTesting`), so your E2E tests run a whole flow
|
|
5
|
+
with no Smile ID backend.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pnpm add -D @smileid/usesmileid_testing
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Apply its config plugin only in the app config you test with. On iOS it links the
|
|
14
|
+
`UseSmileIDTesting` product in every build configuration (the server throws outside a Debug
|
|
15
|
+
build), so it must not reach the config you ship. For example, in `app.config.js`:
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
module.exports = ({ config }) => ({
|
|
19
|
+
...config,
|
|
20
|
+
plugins: [
|
|
21
|
+
...(config.plugins ?? []),
|
|
22
|
+
// Your E2E build only.
|
|
23
|
+
...(process.env.EXPO_PUBLIC_E2E === "1"
|
|
24
|
+
? ["@smileid/usesmileid_testing"]
|
|
25
|
+
: []),
|
|
26
|
+
],
|
|
27
|
+
});
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The server runs only in a debug build. On Android a release build gets a stub that refuses to
|
|
31
|
+
start, so the test doubles never reach a shipped app, and the plugin lets the debug build's native
|
|
32
|
+
network client reach the server on loopback: it adds `127.0.0.1` and `localhost` to your network
|
|
33
|
+
security config in the debug source set only, or writes a debug-only config when your app names
|
|
34
|
+
none. On iOS, an app built without the plugin gets a module that refuses to start a server.
|
|
35
|
+
|
|
36
|
+
## Use
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import {
|
|
40
|
+
UseSmileIDTestServer,
|
|
41
|
+
UseSmileIDTestResponse,
|
|
42
|
+
testToken,
|
|
43
|
+
} from "@smileid/usesmileid_testing";
|
|
44
|
+
|
|
45
|
+
const server = await UseSmileIDTestServer.start();
|
|
46
|
+
|
|
47
|
+
// In the flow's network block:
|
|
48
|
+
// n.config((c) => { c.token = testToken(); ... })
|
|
49
|
+
// n.interceptors((i) => i.testRoute(server))
|
|
50
|
+
|
|
51
|
+
// After the flow:
|
|
52
|
+
expect(server.submissions().map((request) => request.path)).toEqual([
|
|
53
|
+
"/v3/registration",
|
|
54
|
+
]);
|
|
55
|
+
server.close();
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Every request the flow makes goes to the server, whatever host the configuration names, after the
|
|
59
|
+
native network client has signed it. `respond(path, response)`
|
|
60
|
+
scripts `UseSmileIDTestResponse.status(code, body)`, `tokenExpired` (a `401`, so your
|
|
61
|
+
`onTokenExpired` runs) or `connectionDropped`. Once closed, the flow's requests fail rather than
|
|
62
|
+
reach Smile ID.
|
|
63
|
+
|
|
64
|
+
## A whole flow, with no one in front of the camera
|
|
65
|
+
|
|
66
|
+
`UseSmileIDTest` adds a scripted person to the server: one of the shared scenarios every Smile ID
|
|
67
|
+
SDK runs (`UseSmileIDTestScenario.selfieHappyPath`, `enhancedSelfieHeadTurns`, `selfieTimeout`,
|
|
68
|
+
`skipSubmission` and the document ones) or your own, `UseSmileIDTestScenario.fromJson(text)`.
|
|
69
|
+
`UseSmileIDTestScenario.named("selfie-happy-path")` finds one of them by the name its JSON gives it,
|
|
70
|
+
or returns null.
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
const test = await UseSmileIDTest.create(
|
|
74
|
+
UseSmileIDTestScenario.selfieHappyPath,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// In the flow's blocks:
|
|
78
|
+
// n.config((c) => { c.token = test.token; ... })
|
|
79
|
+
// n.interceptors((i) => test.install(i))
|
|
80
|
+
// m.analyzers((a) => {
|
|
81
|
+
// a.forCaptureType(CaptureType.selfie, (face) => face.add(useSmileIDMlkitFace));
|
|
82
|
+
// test.install(a);
|
|
83
|
+
// })
|
|
84
|
+
|
|
85
|
+
test.close();
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The scenario answers in place of your selfie analyzer, inside that analyzer's own native session,
|
|
89
|
+
so the capture, the files it writes and the submission are the real ones; any camera frames will
|
|
90
|
+
do, an emulator's included. On iOS that means a phone, since a simulator has no camera. A document
|
|
91
|
+
scenario's `install` throws.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Expo Module — the native SDK's test server (`com.usesmileid:testing`) for React Native tests.
|
|
2
|
+
// It is a debug-only dependency: a release build gets the stub in src/release, which refuses to
|
|
3
|
+
// start a server, so the test doubles and their dependencies never reach a shipped app. An E2E
|
|
4
|
+
// release build (an app's minified test build, never one it ships) opts in with
|
|
5
|
+
// `-PsmileidTestingInRelease=true` and gets the real server instead.
|
|
6
|
+
|
|
7
|
+
plugins {
|
|
8
|
+
id 'com.android.library'
|
|
9
|
+
id 'expo-module-gradle-plugin'
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
def testServerInRelease = (findProperty('smileidTestingInRelease') ?: 'false').toBoolean()
|
|
13
|
+
|
|
14
|
+
group = 'com.usesmileid.testing.rn'
|
|
15
|
+
version = '12.3.0'
|
|
16
|
+
|
|
17
|
+
android {
|
|
18
|
+
namespace 'com.usesmileid.testing.rn'
|
|
19
|
+
|
|
20
|
+
defaultConfig {
|
|
21
|
+
versionCode 1
|
|
22
|
+
versionName '12.3.0'
|
|
23
|
+
consumerProguardFiles 'consumer-rules.pro'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
sourceSets {
|
|
27
|
+
release {
|
|
28
|
+
java.srcDirs = testServerInRelease ? ['src/debug/java'] : ['src/release/java']
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
dependencies {
|
|
34
|
+
def testServer = [
|
|
35
|
+
'com.usesmileid:testing:12.3.0-20261026.002955-236',
|
|
36
|
+
// The bridge build the testing artifact runs against, from the same publish: a snapshot names
|
|
37
|
+
// the moving x.y.z-SNAPSHOT bridge, so the build is pinned here as each provider pins it.
|
|
38
|
+
'com.usesmileid:bridge:12.3.0-20261026.002955-290',
|
|
39
|
+
]
|
|
40
|
+
testServer.each { debugImplementation it }
|
|
41
|
+
if (testServerInRelease) testServer.each { releaseImplementation it }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
def smileIdPackageVersion = { File packageDir -> new groovy.json.JsonSlurper().parse(new File(packageDir, 'package.json')).version }
|
|
45
|
+
def bridgeVersion = smileIdPackageVersion(project(':smileid-usesmileid_bridge').projectDir.parentFile)
|
|
46
|
+
def testingVersion = smileIdPackageVersion(projectDir.parentFile)
|
|
47
|
+
if (bridgeVersion != testingVersion) {
|
|
48
|
+
throw new GradleException(
|
|
49
|
+
"@smileid/usesmileid_bridge is $bridgeVersion but ${project.name} is $testingVersion: install every @smileid package at one version."
|
|
50
|
+
)
|
|
51
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
# The Expo module entry point is found by name through autolinking.
|
|
2
|
+
-keep class com.usesmileid.testing.rn.UseSmileIDTestingModule { *; }
|
|
3
|
+
# An E2E release build (-PsmileidTestingInRelease=true) minifies the test server it links.
|
|
4
|
+
-keep class com.usesmileid.testing.** { *; }
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
package com.usesmileid.testing.rn
|
|
2
|
+
|
|
3
|
+
import com.usesmileid.bridge.model.CaptureType
|
|
4
|
+
import com.usesmileid.bridge.registry.FaceAnalyzerFactoryRegistry
|
|
5
|
+
import com.usesmileid.core.models.UseSmileIDResult
|
|
6
|
+
import com.usesmileid.presentation.flow.dsl.UseSmileIDFlowBuilder
|
|
7
|
+
import com.usesmileid.testing.UseSmileIDTest
|
|
8
|
+
import com.usesmileid.testing.UseSmileIDTestPart
|
|
9
|
+
import com.usesmileid.testing.UseSmileIDTestResponse
|
|
10
|
+
import com.usesmileid.testing.UseSmileIDTestScenario
|
|
11
|
+
import com.usesmileid.testing.UseSmileIDTestServer
|
|
12
|
+
import expo.modules.kotlin.modules.Module
|
|
13
|
+
import expo.modules.kotlin.modules.ModuleDefinition
|
|
14
|
+
import java.util.concurrent.ConcurrentHashMap
|
|
15
|
+
import java.util.concurrent.atomic.AtomicInteger
|
|
16
|
+
import kotlinx.coroutines.runBlocking
|
|
17
|
+
import org.json.JSONArray
|
|
18
|
+
import org.json.JSONObject
|
|
19
|
+
|
|
20
|
+
/** The native SDK's test servers by port, and its scripted selfie analyzers by key, for `@smileid/usesmileid_testing`. Debug builds only. */
|
|
21
|
+
class UseSmileIDTestingModule : Module() {
|
|
22
|
+
private val servers = ConcurrentHashMap<Int, UseSmileIDTestServer>()
|
|
23
|
+
private val scriptedFaces = ConcurrentHashMap.newKeySet<String>()
|
|
24
|
+
private val scriptedFaceCount = AtomicInteger()
|
|
25
|
+
|
|
26
|
+
override fun definition() = ModuleDefinition {
|
|
27
|
+
Name("UseSmileIDTesting")
|
|
28
|
+
|
|
29
|
+
AsyncFunction("start") {
|
|
30
|
+
val server = UseSmileIDTestServer()
|
|
31
|
+
servers[server.port] = server
|
|
32
|
+
server.port
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
Function("requests") { port: Int -> requestsJson(server(port)) }
|
|
36
|
+
|
|
37
|
+
Function("respond") { port: Int, path: String, response: String -> server(port).respond(path, parseResponse(response)) }
|
|
38
|
+
|
|
39
|
+
Function("close") { port: Int -> servers.remove(port)?.close() }
|
|
40
|
+
|
|
41
|
+
AsyncFunction("startTest") { name: String, json: String? ->
|
|
42
|
+
val test = UseSmileIDTest(json?.let(UseSmileIDTestScenario::fromJson) ?: bundledScenario(name))
|
|
43
|
+
val selfie = try {
|
|
44
|
+
selfieAnalyzer(test)
|
|
45
|
+
} catch (failure: Exception) {
|
|
46
|
+
test.close()
|
|
47
|
+
throw failure
|
|
48
|
+
}
|
|
49
|
+
val key = "$SCRIPTED_FACE_KEY-${scriptedFaceCount.incrementAndGet()}"
|
|
50
|
+
FaceAnalyzerFactoryRegistry.register(key) { checkNotNull(runBlocking { selfie.newInstance() }) }
|
|
51
|
+
scriptedFaces += key
|
|
52
|
+
servers[test.server.port] = test.server
|
|
53
|
+
mapOf("port" to test.server.port, "faceKey" to key)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
Function("unscriptFace") { key: String ->
|
|
57
|
+
if (scriptedFaces.remove(key)) FaceAnalyzerFactoryRegistry.unregister(key)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A reloaded runtime leaves no server listening and no scripted analyzer registered.
|
|
61
|
+
OnDestroy {
|
|
62
|
+
servers.values.forEach(UseSmileIDTestServer::close)
|
|
63
|
+
servers.clear()
|
|
64
|
+
scriptedFaces.forEach(FaceAnalyzerFactoryRegistry::unregister)
|
|
65
|
+
scriptedFaces.clear()
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private fun server(port: Int): UseSmileIDTestServer = servers[port] ?: throw IllegalStateException("No test server on port $port: it was closed or never started")
|
|
70
|
+
|
|
71
|
+
internal companion object {
|
|
72
|
+
const val SCRIPTED_FACE_KEY = "usesmileid-testing-face"
|
|
73
|
+
|
|
74
|
+
private val bundledScenarios = listOf(
|
|
75
|
+
UseSmileIDTestScenario.selfieHappyPath,
|
|
76
|
+
UseSmileIDTestScenario.enhancedSelfieHeadTurns,
|
|
77
|
+
UseSmileIDTestScenario.documentFront,
|
|
78
|
+
UseSmileIDTestScenario.documentFrontAndBack,
|
|
79
|
+
UseSmileIDTestScenario.documentVisa,
|
|
80
|
+
UseSmileIDTestScenario.selfieTimeout,
|
|
81
|
+
UseSmileIDTestScenario.skipSubmission,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
fun bundledScenario(name: String): UseSmileIDTestScenario =
|
|
85
|
+
bundledScenarios.firstOrNull { it.name == name } ?: throw IllegalArgumentException("No bundled test scenario named $name")
|
|
86
|
+
|
|
87
|
+
/** The selfie analyzer [test] installs, read back through the public `ml { }` block it installs into. */
|
|
88
|
+
fun selfieAnalyzer(test: UseSmileIDTest) =
|
|
89
|
+
when (val registry = UseSmileIDFlowBuilder().ml { analyzers { test.install(into = this) } }) {
|
|
90
|
+
is UseSmileIDResult.Success -> registry.value.getAnalyzersFor(CaptureType.SELFIE).first()
|
|
91
|
+
is UseSmileIDResult.Failure -> throw registry.error
|
|
92
|
+
else -> throw IllegalStateException("The scenario's analyzers didn't install: $registry")
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
fun requestsJson(server: UseSmileIDTestServer): String = JSONArray(
|
|
96
|
+
server.requests.map { request ->
|
|
97
|
+
JSONObject()
|
|
98
|
+
.put("path", request.path)
|
|
99
|
+
.put("headers", JSONObject(request.headers))
|
|
100
|
+
.put(
|
|
101
|
+
"parts",
|
|
102
|
+
JSONObject().apply {
|
|
103
|
+
request.parts.forEach { (name, part) ->
|
|
104
|
+
put(
|
|
105
|
+
name,
|
|
106
|
+
when (part) {
|
|
107
|
+
is UseSmileIDTestPart.Text -> JSONObject().put("text", part.value)
|
|
108
|
+
is UseSmileIDTestPart.File -> JSONObject().put("fileName", part.fileName).put("size", part.size)
|
|
109
|
+
},
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
)
|
|
114
|
+
},
|
|
115
|
+
).toString()
|
|
116
|
+
|
|
117
|
+
fun parseResponse(json: String): UseSmileIDTestResponse {
|
|
118
|
+
val response = JSONObject(json)
|
|
119
|
+
return when (val kind = response.getString("kind")) {
|
|
120
|
+
"status" -> UseSmileIDTestResponse.status(response.getInt("code"), response.optString("body", "{}"))
|
|
121
|
+
"tokenExpired" -> UseSmileIDTestResponse.tokenExpired
|
|
122
|
+
"connectionDropped" -> UseSmileIDTestResponse.connectionDropped
|
|
123
|
+
else -> throw IllegalArgumentException("Unknown test response: $kind")
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
package com.usesmileid.testing.rn
|
|
2
|
+
|
|
3
|
+
import expo.modules.kotlin.modules.Module
|
|
4
|
+
import expo.modules.kotlin.modules.ModuleDefinition
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A release build's stand-in: the test server is a debug-only dependency, so a shipped app can't
|
|
8
|
+
* start one, and the test doubles never reach it.
|
|
9
|
+
*/
|
|
10
|
+
class UseSmileIDTestingModule : Module() {
|
|
11
|
+
override fun definition() = ModuleDefinition {
|
|
12
|
+
Name("UseSmileIDTesting")
|
|
13
|
+
|
|
14
|
+
AsyncFunction("start") { -> refuse<Int>() }
|
|
15
|
+
Function("requests") { _: Int -> refuse<String>() }
|
|
16
|
+
Function("respond") { _: Int, _: String, _: String -> refuse<Unit>() }
|
|
17
|
+
Function("close") { _: Int -> }
|
|
18
|
+
AsyncFunction("startTest") { _: String, _: String? -> refuse<Map<String, Any>>() }
|
|
19
|
+
Function("unscriptFace") { _: String -> }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Typed, because Expo can't register a function whose body only throws. */
|
|
23
|
+
private fun <T> refuse(): T = throw UnsupportedOperationException("UseSmileIDTestServer runs only in a debug build")
|
|
24
|
+
}
|
package/app.plugin.js
ADDED
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { UseSmileIDTestResponse, UseSmileIDTestServer, testToken, type UseSmileIDTestPart, type UseSmileIDTestRequest, type UseSmileIDTestingNativeModule, } from "./use_smile_id_test_server";
|
|
2
|
+
export { UseSmileIDTest } from "./use_smile_id_test";
|
|
3
|
+
export { UseSmileIDTestScenario } from "./use_smile_id_test_scenario";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";export{UseSmileIDTestResponse,UseSmileIDTestServer,testToken}from"./use_smile_id_test_server";export{UseSmileIDTest}from"./use_smile_id_test";export{UseSmileIDTestScenario}from"./use_smile_id_test_scenario";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type AnalyzerRegistryBuilder, type InterceptorConfigBuilder } from "@smileid/usesmileid";
|
|
2
|
+
import { UseSmileIDTestServer, type UseSmileIDTestingNativeModule } from "./use_smile_id_test_server";
|
|
3
|
+
import { UseSmileIDTestScenario } from "./use_smile_id_test_scenario";
|
|
4
|
+
/** A flow under test with no Smile ID backend and no person in front of the camera; debug builds only. */
|
|
5
|
+
export declare class UseSmileIDTest {
|
|
6
|
+
/** The person in front of the camera. */
|
|
7
|
+
readonly scenario: UseSmileIDTestScenario;
|
|
8
|
+
/** The backend, in process. */
|
|
9
|
+
readonly server: UseSmileIDTestServer;
|
|
10
|
+
private readonly native;
|
|
11
|
+
private readonly faceKey;
|
|
12
|
+
/** A structurally valid token for the flow's `token`; the test server is the only one that sees it. */
|
|
13
|
+
readonly token: string;
|
|
14
|
+
private constructor();
|
|
15
|
+
/** Starts the native test, its server and its scripted selfie (`selfieHappyPath` for a document scenario). */
|
|
16
|
+
static create(scenario: UseSmileIDTestScenario, native?: UseSmileIDTestingNativeModule): Promise<UseSmileIDTest>;
|
|
17
|
+
/** Routes every request the flow makes to `server`, inside `n.interceptors((i) => …)`. */
|
|
18
|
+
install(into: InterceptorConfigBuilder): void;
|
|
19
|
+
/** Runs the scenario in the selfie analyzer you registered, inside `m.analyzers((a) => …)` after your own; a document scenario throws. */
|
|
20
|
+
install(into: AnalyzerRegistryBuilder): void;
|
|
21
|
+
/** Stops the server, so requests after this are refused, and drops the scripted analyzer. */
|
|
22
|
+
close(): void;
|
|
23
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";import{requireNativeModule as n}from"expo-modules-core";import{AnalyzerFactoryKey as i,faceConfigSchema as o}from"@smileid/usesmileid_bridge";import{CaptureType as c}from"@smileid/usesmileid";import{UseSmileIDTestServer as l,testToken as f}from"./use_smile_id_test_server";const u={[i.MLKitFace]:"UseSmileIDMlkitFace",[i.HuaweiFace]:"UseSmileIDHuaweiFace",[i.VisionFace]:"UseSmileIDVisionFace"};export class UseSmileIDTest{scenario;server;native;faceKey;token=f();constructor(s,e,r,a){this.scenario=s,this.server=e,this.native=r,this.faceKey=a}static async create(s,e=n("UseSmileIDTesting")){const{port:r,faceKey:a}=await e.startTest(s.name,s.script??null);return new UseSmileIDTest(s,l.attach(e,r),e,a)}install(s){if("testRoute"in s){s.testRoute(this.server);return}if(this.scenario.isDocument)throw new Error(`${this.scenario.name}: React Native can't script a document capture yet, only a selfie`);const e=s.build().selfie;if(!e)throw new Error("Register your selfie analyzer before installing the test: the scenario runs in its native session");s.forCaptureType(c.selfie,r=>r.add(m(e,this.faceKey)))}close(){this.server.close(),this.native.unscriptFace(this.faceKey)}}function m(t,s){const e=n(u[t.key]);return{kind:"face",key:t.key,frameProcessorPluginName:t.frameProcessorPluginName,configure:r=>e.configure({...o.parse(r),analyzerFactoryKey:s}),analyzeKameraFrame:r=>e.analyzeKameraFrame(r),reset:()=>e.reset(),flush:()=>e.flush(),close:()=>e.close()}}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** The person in front of the camera, scripted from the shared scenarios every Smile ID SDK runs; any camera frames will do. */
|
|
2
|
+
export declare class UseSmileIDTestScenario {
|
|
3
|
+
/** The scenario's name, as its JSON gives it. */
|
|
4
|
+
readonly name: string;
|
|
5
|
+
private readonly captureType;
|
|
6
|
+
/** @internal A scenario of your own's JSON; undefined for a bundled one, which the native test doubles carry. */
|
|
7
|
+
readonly script?: string | undefined;
|
|
8
|
+
private constructor();
|
|
9
|
+
/** A selfie: hold still, then smile, then the capture completes. */
|
|
10
|
+
static readonly selfieHappyPath: UseSmileIDTestScenario;
|
|
11
|
+
/** An Enhanced SmartSelfie: the head turns, each held, then the capture completes. */
|
|
12
|
+
static readonly enhancedSelfieHeadTurns: UseSmileIDTestScenario;
|
|
13
|
+
/** One side of a document, found and held steady. */
|
|
14
|
+
static readonly documentFront: UseSmileIDTestScenario;
|
|
15
|
+
/** Both sides of a document. */
|
|
16
|
+
static readonly documentFrontAndBack: UseSmileIDTestScenario;
|
|
17
|
+
/** A passport, then the visa page a residency job reads. */
|
|
18
|
+
static readonly documentVisa: UseSmileIDTestScenario;
|
|
19
|
+
/** A selfie that never satisfies the capture, so the flow times out. */
|
|
20
|
+
static readonly selfieTimeout: UseSmileIDTestScenario;
|
|
21
|
+
/** A selfie for a flow with `skipApiSubmission`, which hands the job back instead of submitting it. */
|
|
22
|
+
static readonly skipSubmission: UseSmileIDTestScenario;
|
|
23
|
+
/** The one of the scenarios above whose JSON gives it `name`, such as `selfie-happy-path`, or null. */
|
|
24
|
+
static named(name: string): UseSmileIDTestScenario | null;
|
|
25
|
+
/** A scenario of your own, in the shared schema; throws when `text` isn't one. */
|
|
26
|
+
static fromJson(text: string): UseSmileIDTestScenario;
|
|
27
|
+
/** @internal */
|
|
28
|
+
get isDocument(): boolean;
|
|
29
|
+
toString(): string;
|
|
30
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";import{z as e}from"zod";const a=e.object({name:e.string().min(1),captureType:e.enum(["selfie","document"]),steps:e.array(e.object({emit:e.string().min(1),holdFrames:e.number().int().positive().optional()})).min(1)});export class UseSmileIDTestScenario{name;captureType;script;constructor(n,t,s){this.name=n,this.captureType=t,this.script=s}static selfieHappyPath=new UseSmileIDTestScenario("selfie-happy-path","selfie");static enhancedSelfieHeadTurns=new UseSmileIDTestScenario("ess-head-turn-happy-path","selfie");static documentFront=new UseSmileIDTestScenario("document-happy-path","document");static documentFrontAndBack=new UseSmileIDTestScenario("document-back-side","document");static documentVisa=new UseSmileIDTestScenario("document-visa","document");static selfieTimeout=new UseSmileIDTestScenario("selfie-timeout","selfie");static skipSubmission=new UseSmileIDTestScenario("selfie-skip-submission","selfie");static named(n){return Object.values(UseSmileIDTestScenario).filter(t=>t instanceof UseSmileIDTestScenario).find(t=>t.name===n)??null}static fromJson(n){const t=a.safeParse(JSON.parse(n));if(!t.success)throw new Error(`Not a test scenario: ${t.error.message}`);return new UseSmileIDTestScenario(t.data.name,t.data.captureType,n)}get isDocument(){return this.captureType==="document"}toString(){return`UseSmileIDTestScenario(${this.name})`}}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { UseSmileIDTestRoute } from "@smileid/usesmileid_bridge/internal";
|
|
3
|
+
declare const testPart: z.ZodUnion<readonly [z.ZodObject<{
|
|
4
|
+
text: z.ZodString;
|
|
5
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
6
|
+
fileName: z.ZodString;
|
|
7
|
+
size: z.ZodNumber;
|
|
8
|
+
}, z.core.$strict>]>;
|
|
9
|
+
declare const testRequests: z.ZodArray<z.ZodObject<{
|
|
10
|
+
path: z.ZodString;
|
|
11
|
+
headers: z.ZodRecord<z.ZodString, z.ZodString>;
|
|
12
|
+
parts: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodObject<{
|
|
13
|
+
text: z.ZodString;
|
|
14
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
15
|
+
fileName: z.ZodString;
|
|
16
|
+
size: z.ZodNumber;
|
|
17
|
+
}, z.core.$strict>]>>;
|
|
18
|
+
}, z.core.$strip>>;
|
|
19
|
+
/** One part of a request: text, or a file by its name and size. A file's bytes are never kept. */
|
|
20
|
+
export type UseSmileIDTestPart = Readonly<z.infer<typeof testPart>>;
|
|
21
|
+
/** One request the flow made, with a multipart body's parts or a JSON body's top-level fields decoded. */
|
|
22
|
+
export type UseSmileIDTestRequest = Readonly<z.infer<typeof testRequests>[number]>;
|
|
23
|
+
/** What `UseSmileIDTestServer.respond` makes a path answer. */
|
|
24
|
+
export type UseSmileIDTestResponse = {
|
|
25
|
+
readonly kind: "status";
|
|
26
|
+
readonly code: number;
|
|
27
|
+
readonly body: string;
|
|
28
|
+
} | {
|
|
29
|
+
readonly kind: "tokenExpired";
|
|
30
|
+
} | {
|
|
31
|
+
readonly kind: "connectionDropped";
|
|
32
|
+
};
|
|
33
|
+
export declare const UseSmileIDTestResponse: {
|
|
34
|
+
/** An HTTP `code` with `body`, in the API's error shape when it's an error. */
|
|
35
|
+
readonly status: (code: number, body?: string) => UseSmileIDTestResponse;
|
|
36
|
+
/** A `401`, which makes the SDK ask your `onTokenExpired` for a new token. */
|
|
37
|
+
readonly tokenExpired: UseSmileIDTestResponse;
|
|
38
|
+
/** The connection drops before an answer. */
|
|
39
|
+
readonly connectionDropped: UseSmileIDTestResponse;
|
|
40
|
+
};
|
|
41
|
+
/** The native module: Android's `com.usesmileid:testing` and iOS's `UseSmileIDTesting` servers. */
|
|
42
|
+
export type UseSmileIDTestingNativeModule = {
|
|
43
|
+
start(): Promise<number>;
|
|
44
|
+
requests(port: number): string;
|
|
45
|
+
respond(port: number, path: string, response: string): void;
|
|
46
|
+
close(port: number): void;
|
|
47
|
+
/** Starts the native test of the named bundled scenario, or `json`'s: its server's port, and the key its selfie analyzer is under. */
|
|
48
|
+
startTest(name: string, json: string | null): Promise<{
|
|
49
|
+
port: number;
|
|
50
|
+
faceKey: string;
|
|
51
|
+
}>;
|
|
52
|
+
unscriptFace(key: string): void;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* The Smile ID backend, in process, for a flow under test. Every `/v3` job endpoint answers the
|
|
56
|
+
* canonical accepted-job payload unless a test scripts something else with `respond`, and every
|
|
57
|
+
* request is recorded, decoded, in `requests()`. It listens on `127.0.0.1` only, in a debug build.
|
|
58
|
+
* Install it with `n.interceptors((i) => i.testRoute(server))` and close it when the test ends.
|
|
59
|
+
*/
|
|
60
|
+
export declare class UseSmileIDTestServer implements UseSmileIDTestRoute {
|
|
61
|
+
private readonly native;
|
|
62
|
+
/** The port the server listens on, on `127.0.0.1`. */
|
|
63
|
+
readonly port: number;
|
|
64
|
+
private closed;
|
|
65
|
+
private constructor();
|
|
66
|
+
/** Starts a server. Fails outside a debug build. */
|
|
67
|
+
static start(native?: UseSmileIDTestingNativeModule): Promise<UseSmileIDTestServer>;
|
|
68
|
+
/** @internal The server a native test already started on `port`. */
|
|
69
|
+
static attach(native: UseSmileIDTestingNativeModule, port: number): UseSmileIDTestServer;
|
|
70
|
+
/** The route the SDK reads: this server's port, or undefined once it is closed. */
|
|
71
|
+
testRoutePort(): number | undefined;
|
|
72
|
+
/** Every request the flow made, in order. */
|
|
73
|
+
requests(): readonly UseSmileIDTestRequest[];
|
|
74
|
+
/** The requests to the `/v3` job endpoints: the job submissions. */
|
|
75
|
+
submissions(): readonly UseSmileIDTestRequest[];
|
|
76
|
+
/** Makes `path` answer `response` from now on, instead of what it answers by default. */
|
|
77
|
+
respond(path: string, response: UseSmileIDTestResponse): void;
|
|
78
|
+
/** Stops listening. Requests after this are refused before they leave the process. */
|
|
79
|
+
close(): void;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* A structurally valid, far-future token for the flow's `token`; the SDK never checks its signature.
|
|
83
|
+
* A `payload` is the claim binding user details and consent, which the server then injects.
|
|
84
|
+
*/
|
|
85
|
+
export declare function testToken(payload?: Readonly<Record<string, unknown>>): string;
|
|
86
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";import{requireNativeModule as u}from"expo-modules-core";import{z as e}from"zod";const h=e.union([e.object({text:e.string()}).strict(),e.object({fileName:e.string(),size:e.number().int().nonnegative()}).strict()]),l=e.array(e.object({path:e.string(),headers:e.record(e.string(),e.string()),parts:e.record(e.string(),h)}));export const UseSmileIDTestResponse={status:(r,t="{}")=>({kind:"status",code:r,body:t}),tokenExpired:{kind:"tokenExpired"},connectionDropped:{kind:"connectionDropped"}};const v=["/v3/enhanced_kyc","/v3/registration","/v3/authentication","/v3/biometric_kyc","/v3/document_verification","/v3/enhanced_document_verification","/v3/residency_document_verification"];export class UseSmileIDTestServer{native;port;closed=!1;constructor(t,s){this.native=t,this.port=s}static async start(t=u("UseSmileIDTesting")){return new UseSmileIDTestServer(t,await t.start())}static attach(t,s){return new UseSmileIDTestServer(t,s)}testRoutePort(){return this.closed?void 0:this.port}requests(){const t=l.safeParse(JSON.parse(this.native.requests(this.port)));if(!t.success)throw new Error(`The test server answered requests in a shape it never sends: ${t.error.message}`);return t.data}submissions(){return this.requests().filter(t=>v.some(s=>t.path.startsWith(s)))}respond(t,s){this.native.respond(this.port,t,JSON.stringify(s))}close(){this.closed||(this.closed=!0,this.native.close(this.port))}}export function testToken(r){return[i('{"alg":"HS256","typ":"JWT"}'),i(JSON.stringify({partner_id:"test-partner",exp:4102444800,...r&&{payload:r}})),i("test-signature")].join(".")}const g="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";function i(r){let t="";for(let s=0;s<r.length;s+=3){const[o,a,c]=[0,1,2].map(n=>r.charCodeAt(s+n)),d=o<<16|(a||0)<<8|(c||0),p=s+2<r.length?4:s+1<r.length?3:2;for(let n=0;n<p;n+=1)t+=g[d>>18-6*n&63]}return t}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
# Reads the package's package.json so version stays in sync with the
|
|
4
|
+
# JS-side package version. Mirrors the convention used by every Expo
|
|
5
|
+
# Module in this workspace.
|
|
6
|
+
package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
|
|
7
|
+
|
|
8
|
+
Pod::Spec.new do |s|
|
|
9
|
+
# Pod name is intentionally distinct from the SPM product
|
|
10
|
+
# `UseSmileIDTesting` (declared in `expo-module.config.json`)
|
|
11
|
+
# to avoid a name collision between the CocoaPods bridge target
|
|
12
|
+
# and the Swift Package product it imports.
|
|
13
|
+
s.name = 'UseSmileIDTestingExpo'
|
|
14
|
+
s.version = package['version']
|
|
15
|
+
s.summary = 'The native test server for UseSmileID v12 tests (Expo Module bridge).'
|
|
16
|
+
s.description = package['description']
|
|
17
|
+
s.license = package['license']
|
|
18
|
+
s.author = package['author']
|
|
19
|
+
s.homepage = package['homepage']
|
|
20
|
+
s.platforms = { :ios => '15.0' }
|
|
21
|
+
s.swift_version = '5.9'
|
|
22
|
+
s.source = { git: 'https://github.com/usesmileid/react-native-expo.git', tag: "v#{s.version}" }
|
|
23
|
+
s.static_framework = true
|
|
24
|
+
|
|
25
|
+
s.dependency 'ExpoModulesCore'
|
|
26
|
+
|
|
27
|
+
# The Smile ID native deps (`UseSmileIDTesting` / `UseSmileIDBridge`)
|
|
28
|
+
# are SPM-only — declared in `expo-module.config.json` under
|
|
29
|
+
# `apple.dependencies`. Expo Modules autolinking surfaces the SPM
|
|
30
|
+
# products to this target at compile time. Do NOT add a
|
|
31
|
+
# `s.dependency` for them here; that would require shipping a
|
|
32
|
+
# CocoaPods podspec for the binary, which we don't.
|
|
33
|
+
|
|
34
|
+
# Swift flags consistent with Expo Modules' Kotlin/Swift compiler-flag
|
|
35
|
+
# plumbing — toggled by the consuming app's Podfile when it runs in
|
|
36
|
+
# debug mode.
|
|
37
|
+
s.pod_target_xcconfig = {
|
|
38
|
+
# UseSmileIDTesting is an SPM source target whose module no pod searches for; without this, `canImport` is false.
|
|
39
|
+
'SWIFT_INCLUDE_PATHS' => '$(inherited) "${PODS_CONFIGURATION_BUILD_DIR}"',
|
|
40
|
+
'DEFINES_MODULE' => 'YES',
|
|
41
|
+
'SWIFT_COMPILATION_MODE' => 'wholemodule',
|
|
42
|
+
# Hardening — mirrors the native iOS frameworks' Release-config
|
|
43
|
+
# build settings. Dead code is stripped, install-path metadata is
|
|
44
|
+
# removed at link time, debug symbols (DWARF / line tables) are
|
|
45
|
+
# stripped via STRIP_STYLE = 'debugging', and Swift's field-name
|
|
46
|
+
# reflection metadata is dropped so class-dump / Hopper cannot
|
|
47
|
+
# recover Swift property names. STRIP_STYLE must stay 'debugging'
|
|
48
|
+
# (not 'all') — dynamic frameworks need their dyld-visible symbol
|
|
49
|
+
# table intact for consumers to link against. 'all' is for apps.
|
|
50
|
+
'DEAD_CODE_STRIPPING' => 'YES',
|
|
51
|
+
'DEPLOYMENT_POSTPROCESSING' => 'YES',
|
|
52
|
+
'STRIP_INSTALLED_PRODUCT' => 'YES',
|
|
53
|
+
'STRIP_STYLE' => 'debugging',
|
|
54
|
+
'SWIFT_REFLECTION_METADATA_LEVEL' => 'without-names'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
s.source_files = 'UseSmileIDTestingModule/**/*.{h,m,mm,swift,hpp,cpp}'
|
|
58
|
+
end
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
import Foundation
|
|
3
|
+
#if canImport(UseSmileIDTesting)
|
|
4
|
+
import UseSmileID
|
|
5
|
+
import UseSmileIDBridge
|
|
6
|
+
import UseSmileIDTesting
|
|
7
|
+
#endif
|
|
8
|
+
|
|
9
|
+
#if canImport(UseSmileIDTesting)
|
|
10
|
+
/// The native SDK's test servers by port and scripted selfie analyzers by key, for `@smileid/usesmileid_testing`; Debug builds only.
|
|
11
|
+
public final class UseSmileIDTestingModule: Module {
|
|
12
|
+
static let scriptedFaceKey = "usesmileid-testing-face"
|
|
13
|
+
|
|
14
|
+
private let lock = NSLock()
|
|
15
|
+
private var servers: [Int: UseSmileIDTestServer] = [:]
|
|
16
|
+
private var scriptedFaces: Set<String> = []
|
|
17
|
+
private var scriptedFaceCount = 0
|
|
18
|
+
|
|
19
|
+
public func definition() -> ModuleDefinition {
|
|
20
|
+
Name("UseSmileIDTesting")
|
|
21
|
+
|
|
22
|
+
AsyncFunction("start") { () async throws -> Int in
|
|
23
|
+
let server = try UseSmileIDTestServer()
|
|
24
|
+
guard let port = await server.port() else {
|
|
25
|
+
server.close()
|
|
26
|
+
throw TestServerError.didNotStart
|
|
27
|
+
}
|
|
28
|
+
self.lock.withLock { self.servers[Int(port)] = server }
|
|
29
|
+
return Int(port)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
Function("requests") { (port: Int) throws -> String in
|
|
33
|
+
try Self.requestsJSON(self.server(port).requests)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
Function("respond") { (port: Int, path: String, response: String) throws in
|
|
37
|
+
try self.server(port).respond(path, with: Self.response(from: response))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
Function("close") { (port: Int) in
|
|
41
|
+
self.lock.withLock { self.servers.removeValue(forKey: port) }?.close()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
AsyncFunction("startTest") { (name: String, json: String?) async throws -> [String: Any] in
|
|
45
|
+
let test = try UseSmileIDTest(scenario: json.map(UseSmileIDTestScenario.fromJSON) ?? Self.bundledScenario(name))
|
|
46
|
+
guard let port = await test.server.port() else {
|
|
47
|
+
test.close()
|
|
48
|
+
throw TestServerError.didNotStart
|
|
49
|
+
}
|
|
50
|
+
guard let selfie = test.analyzers.first(where: { $0.captureType == .selfie })?.factories.first else {
|
|
51
|
+
test.close()
|
|
52
|
+
throw TestServerError.noScriptedFace
|
|
53
|
+
}
|
|
54
|
+
let key = self.lock.withLock { () -> String in
|
|
55
|
+
self.scriptedFaceCount += 1
|
|
56
|
+
let key = "\(Self.scriptedFaceKey)-\(self.scriptedFaceCount)"
|
|
57
|
+
self.scriptedFaces.insert(key)
|
|
58
|
+
self.servers[Int(port)] = test.server
|
|
59
|
+
return key
|
|
60
|
+
}
|
|
61
|
+
FaceAnalyzerFactoryRegistry.shared.register(key: key) { ScriptedFace(factory: selfie).analyzeFrame }
|
|
62
|
+
return ["port": Int(port), "faceKey": key]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
Function("unscriptFace") { (key: String) in
|
|
66
|
+
if self.lock.withLock({ self.scriptedFaces.remove(key) }) != nil {
|
|
67
|
+
FaceAnalyzerFactoryRegistry.shared.unregister(key: key)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// A reloaded runtime leaves no server listening and no scripted analyzer registered.
|
|
72
|
+
OnDestroy {
|
|
73
|
+
let (all, keys) = self.lock.withLock { () -> ([UseSmileIDTestServer], Set<String>) in
|
|
74
|
+
defer {
|
|
75
|
+
self.servers.removeAll()
|
|
76
|
+
self.scriptedFaces.removeAll()
|
|
77
|
+
}
|
|
78
|
+
return (Array(self.servers.values), self.scriptedFaces)
|
|
79
|
+
}
|
|
80
|
+
all.forEach { $0.close() }
|
|
81
|
+
keys.forEach { FaceAnalyzerFactoryRegistry.shared.unregister(key: $0) }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
static func bundledScenario(_ name: String) throws -> UseSmileIDTestScenario {
|
|
86
|
+
let bundled: [UseSmileIDTestScenario] = [
|
|
87
|
+
.selfieHappyPath, .enhancedSelfieHeadTurns, .documentFront, .documentFrontAndBack,
|
|
88
|
+
.documentVisa, .selfieTimeout, .skipSubmission,
|
|
89
|
+
]
|
|
90
|
+
guard let scenario = bundled.first(where: { $0.name == name }) else { throw TestServerError.noScenario(name) }
|
|
91
|
+
return scenario
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private func server(_ port: Int) throws -> UseSmileIDTestServer {
|
|
95
|
+
guard let server = lock.withLock({ servers[port] }) else { throw TestServerError.noServer(port) }
|
|
96
|
+
return server
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
static func requestsJSON(_ requests: [UseSmileIDTestRequest]) throws -> String {
|
|
100
|
+
let json: [[String: Any]] = requests.map { request in
|
|
101
|
+
[
|
|
102
|
+
"path": request.path,
|
|
103
|
+
"headers": request.headers,
|
|
104
|
+
"parts": request.parts.mapValues { part -> [String: Any] in
|
|
105
|
+
switch part {
|
|
106
|
+
case let .text(text): ["text": text]
|
|
107
|
+
case let .file(fileName, size): ["fileName": fileName, "size": size]
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
]
|
|
111
|
+
}
|
|
112
|
+
return try String(decoding: JSONSerialization.data(withJSONObject: json), as: UTF8.self)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
static func response(from json: String) throws -> UseSmileIDTestResponse {
|
|
116
|
+
guard let object = try JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any] else {
|
|
117
|
+
throw TestServerError.unknownResponse(json)
|
|
118
|
+
}
|
|
119
|
+
switch object["kind"] as? String {
|
|
120
|
+
case "status":
|
|
121
|
+
guard let code = object["code"] as? Int else { throw TestServerError.unknownResponse(json) }
|
|
122
|
+
return .status(code: code, body: object["body"] as? String ?? "{}")
|
|
123
|
+
case "tokenExpired": return .tokenExpired
|
|
124
|
+
case "connectionDropped": return .connectionDropped
|
|
125
|
+
default: throw TestServerError.unknownResponse(json)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/// One capture session's scripted selfie analyzer, made on its first frame: the registry's provider is synchronous.
|
|
131
|
+
private actor ScriptedFace {
|
|
132
|
+
private let factory: BaseAnalyzerFactory
|
|
133
|
+
private var analyzer: BaseAnalyzer?
|
|
134
|
+
|
|
135
|
+
init(factory: BaseAnalyzerFactory) {
|
|
136
|
+
self.factory = factory
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
nonisolated var analyzeFrame: FaceAnalyzerFactoryRegistry.FaceAnalyzeFrame {
|
|
140
|
+
{ input, state in await self.analyze(input, state) }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private func analyze(_ input: AnalyzerInput, _ state: IdentityScanState) async -> AnalyzerOutput {
|
|
144
|
+
if analyzer == nil {
|
|
145
|
+
do {
|
|
146
|
+
analyzer = try await factory.newInstance()
|
|
147
|
+
} catch {
|
|
148
|
+
preconditionFailure("The scenario's scripted selfie analyzer didn't start: \(error)")
|
|
149
|
+
}
|
|
150
|
+
guard analyzer != nil else { preconditionFailure("The scenario's scripted selfie analyzer didn't start") }
|
|
151
|
+
}
|
|
152
|
+
return (try? await analyzer?.analyze(data: input, state: state)) ?? FaceDetectorOutput(faces: [])
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
#else
|
|
157
|
+
/// An app built without this package's config plugin has no test server to start: the plugin is
|
|
158
|
+
/// applied only in the app config you test with.
|
|
159
|
+
public final class UseSmileIDTestingModule: Module {
|
|
160
|
+
public func definition() -> ModuleDefinition {
|
|
161
|
+
Name("UseSmileIDTesting")
|
|
162
|
+
|
|
163
|
+
AsyncFunction("start") { () async throws -> Int in throw TestServerError.notLinked }
|
|
164
|
+
Function("requests") { (_: Int) throws -> String in throw TestServerError.notLinked }
|
|
165
|
+
Function("respond") { (_: Int, _: String, _: String) throws in throw TestServerError.notLinked }
|
|
166
|
+
Function("close") { (_: Int) in }
|
|
167
|
+
AsyncFunction("startTest") { (_: String, _: String?) async throws -> [String: Any] in throw TestServerError.notLinked }
|
|
168
|
+
Function("unscriptFace") { (_: String) in }
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
#endif
|
|
172
|
+
|
|
173
|
+
enum TestServerError: Error, CustomStringConvertible {
|
|
174
|
+
case didNotStart
|
|
175
|
+
case noServer(Int)
|
|
176
|
+
case unknownResponse(String)
|
|
177
|
+
case notLinked
|
|
178
|
+
case noScriptedFace
|
|
179
|
+
case noScenario(String)
|
|
180
|
+
|
|
181
|
+
var description: String {
|
|
182
|
+
switch self {
|
|
183
|
+
case .notLinked: "This app is built without @smileid/usesmileid_testing's config plugin, so it has no test server"
|
|
184
|
+
case .noScriptedFace: "The scenario's scripted selfie analyzer didn't install"
|
|
185
|
+
case let .noScenario(name): "No bundled test scenario named \(name)"
|
|
186
|
+
case .didNotStart: "The test server didn't start"
|
|
187
|
+
case let .noServer(port): "No test server on port \(port): it was closed or never started"
|
|
188
|
+
case let .unknownResponse(json): "Unknown test response: \(json)"
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,77 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smileid/usesmileid_testing",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "12.3.1-SNAPSHOT.71",
|
|
4
|
+
"description": "Test doubles for the UseSmileID React Native (Expo) SDK: the native SDKs' in-process test server, for your own E2E tests.",
|
|
5
|
+
"homepage": "https://usesmileid.com",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/smileidentity/react-native-expo.git",
|
|
9
|
+
"directory": "usesmileid_testing"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/smileidentity/react-native-expo/issues"
|
|
13
|
+
},
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"author": "Smile Identity <hello@usesmileid.com>",
|
|
16
|
+
"keywords": [
|
|
17
|
+
"react-native",
|
|
18
|
+
"expo",
|
|
19
|
+
"expo-module",
|
|
20
|
+
"testing",
|
|
21
|
+
"smile-id"
|
|
22
|
+
],
|
|
23
|
+
"main": "./dist/index.js",
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"import": "./dist/index.js",
|
|
29
|
+
"require": "./dist/index.js"
|
|
30
|
+
},
|
|
31
|
+
"./app.plugin.js": "./app.plugin.js"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"dist",
|
|
35
|
+
"android/build.gradle",
|
|
36
|
+
"android/consumer-rules.pro",
|
|
37
|
+
"android/src",
|
|
38
|
+
"ios/UseSmileIDTestingExpo.podspec",
|
|
39
|
+
"ios/UseSmileIDTestingModule",
|
|
40
|
+
"plugin/build",
|
|
41
|
+
"app.plugin.js",
|
|
42
|
+
"expo-module.config.json",
|
|
43
|
+
"README.md",
|
|
44
|
+
"LICENSE",
|
|
45
|
+
"CHANGELOG.md"
|
|
46
|
+
],
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"zod": "^4.6.7",
|
|
49
|
+
"@smileid/usesmileid_bridge": "12.3.1-SNAPSHOT.71"
|
|
50
|
+
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"expo": ">=56.0.0",
|
|
53
|
+
"expo-modules-core": ">=56.0.0",
|
|
54
|
+
"react-native": ">=0.83.0",
|
|
55
|
+
"@smileid/usesmileid": "12.3.1-SNAPSHOT.71"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@types/node": "^26.6.4",
|
|
59
|
+
"expo": "~57.0.26",
|
|
60
|
+
"expo-modules-core": "^57.0.23",
|
|
61
|
+
"react-native": "^0.86.3",
|
|
62
|
+
"typescript": "^6.0.3",
|
|
63
|
+
"@smileid/usesmileid": "12.3.1-SNAPSHOT.71"
|
|
64
|
+
},
|
|
65
|
+
"publishConfig": {
|
|
66
|
+
"access": "public"
|
|
67
|
+
},
|
|
68
|
+
"scripts": {
|
|
69
|
+
"build": "pnpm run build:lib && pnpm run build:plugin && node ../Scripts/copy-assets.mjs",
|
|
70
|
+
"build:lib": "tsc -p tsconfig.json",
|
|
71
|
+
"build:plugin": "tsc -p plugin/tsconfig.json",
|
|
72
|
+
"typecheck": "tsc -b && tsc -p plugin/tsconfig.json --noEmit",
|
|
73
|
+
"test": "vitest run",
|
|
74
|
+
"test:watch": "vitest",
|
|
75
|
+
"clean": "rm -rf dist plugin/build .turbo coverage tsconfig.tsbuildinfo"
|
|
76
|
+
}
|
|
6
77
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type ConfigPlugin } from "expo/config-plugins";
|
|
2
|
+
/** The debug-only config this plugin writes when the app names none of its own. */
|
|
3
|
+
export declare const DEBUG_CONFIG_NAME = "smileid_test_network_security";
|
|
4
|
+
/**
|
|
5
|
+
* `config` (a network security config's XML) with cleartext allowed to loopback. A config that
|
|
6
|
+
* already allows both names is returned as it is.
|
|
7
|
+
*/
|
|
8
|
+
export declare function allowLoopback(config: string): string;
|
|
9
|
+
/** The XML resource a `@xml/name` reference names, or undefined for anything else. */
|
|
10
|
+
export declare function xmlResourceName(reference: string | undefined): string | undefined;
|
|
11
|
+
/**
|
|
12
|
+
* Writes the debug build's network security config, on every prebuild, so it follows the app's own: the app's own (from `src/main`) plus
|
|
13
|
+
* loopback, under the same name so the debug source set overrides it, or this plugin's own when
|
|
14
|
+
* the app names none, which the debug manifest then points at. Answers the name written.
|
|
15
|
+
*/
|
|
16
|
+
export declare function writeDebugConfig(appDirectory: string, ownName: string | undefined, buildType?: string): string;
|
|
17
|
+
/**
|
|
18
|
+
* Points the debug manifest at `name` when the app's own manifest names no network security
|
|
19
|
+
* config, creating the debug manifest if the app has none.
|
|
20
|
+
*/
|
|
21
|
+
export declare function writeDebugManifest(appDirectory: string, name: string, buildType?: string): void;
|
|
22
|
+
/**
|
|
23
|
+
* Removes the debug manifest's pointer to this plugin's own config, left by a prebuild from before
|
|
24
|
+
* the app named a config of its own; the debug override of the app's config replaces it.
|
|
25
|
+
*/
|
|
26
|
+
export declare function forgetDebugManifest(appDirectory: string, buildType?: string): void;
|
|
27
|
+
/** The Android build types that reach the test server: `debug` unless the app names others. */
|
|
28
|
+
export type AndroidLoopbackOptions = {
|
|
29
|
+
readonly androidBuildTypes?: readonly string[];
|
|
30
|
+
};
|
|
31
|
+
export declare const withAndroidLoopback: ConfigPlugin<AndroidLoopbackOptions | void>;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use strict";var __createBinding=this&&this.__createBinding||(Object.create?(function(n,t,i,e){e===void 0&&(e=i);var r=Object.getOwnPropertyDescriptor(t,i);(!r||("get"in r?!t.__esModule:r.writable||r.configurable))&&(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(n,e,r)}):(function(n,t,i,e){e===void 0&&(e=i),n[e]=t[i]})),__setModuleDefault=this&&this.__setModuleDefault||(Object.create?(function(n,t){Object.defineProperty(n,"default",{enumerable:!0,value:t})}):function(n,t){n.default=t}),__importStar=this&&this.__importStar||(function(){var n=function(t){return n=Object.getOwnPropertyNames||function(i){var e=[];for(var r in i)Object.prototype.hasOwnProperty.call(i,r)&&(e[e.length]=r);return e},n(t)};return function(t){if(t&&t.__esModule)return t;var i={};if(t!=null)for(var e=n(t),r=0;r<e.length;r++)e[r]!=="default"&&__createBinding(i,t,e[r]);return __setModuleDefault(i,t),i}})();Object.defineProperty(exports,"__esModule",{value:!0}),exports.withAndroidLoopback=exports.DEBUG_CONFIG_NAME=void 0,exports.allowLoopback=allowLoopback,exports.xmlResourceName=xmlResourceName,exports.writeDebugConfig=writeDebugConfig,exports.writeDebugManifest=writeDebugManifest,exports.forgetDebugManifest=forgetDebugManifest;const config_plugins_1=require("expo/config-plugins"),fs=__importStar(require("fs")),path=__importStar(require("path"));exports.DEBUG_CONFIG_NAME="smileid_test_network_security";const LOOPBACK=["127.0.0.1","localhost"],DEBUG_ONLY_CONFIG=`<?xml version="1.0" encoding="utf-8"?>
|
|
2
|
+
<network-security-config>
|
|
3
|
+
<base-config cleartextTrafficPermitted="true" />
|
|
4
|
+
</network-security-config>
|
|
5
|
+
`,LOOPBACK_ONLY_CONFIG=`<?xml version="1.0" encoding="utf-8"?>
|
|
6
|
+
<network-security-config>
|
|
7
|
+
</network-security-config>
|
|
8
|
+
`;function allowLoopback(n){const t=LOOPBACK.filter(e=>!cleartextHosts(n).includes(e));if(t.length===0)return n;const i=[' <domain-config cleartextTrafficPermitted="true">',...t.map(e=>` <domain includeSubdomains="false">${e}</domain>`)," </domain-config>"].join(`
|
|
9
|
+
`);if(!n.includes("</network-security-config>"))throw new Error("[usesmileid_testing] The app's network security config has no closing tag.");return n.replace("</network-security-config>",`${i}
|
|
10
|
+
</network-security-config>`)}function cleartextHosts(n){const t=[];for(const i of n.matchAll(/<domain-config\b([^>]*)>([\s\S]*?)<\/domain-config>/g))if(/cleartextTrafficPermitted="true"/.test(i[1]??""))for(const e of(i[2]??"").matchAll(/<domain\b[^>]*>\s*([^<\s]+)\s*<\/domain>/g))t.push(e[1]);return t}function xmlResourceName(n){return n?.match(/^@xml\/([A-Za-z0-9_]+)$/)?.[1]}function writeDebugConfig(n,t,i="debug"){const e=t??exports.DEBUG_CONFIG_NAME,r=path.join(n,"src","main","res","xml",`${e}.xml`);if(t!==void 0&&!fs.existsSync(r))throw new Error(`[usesmileid_testing] The app names @xml/${e}, but ${r} isn't there to extend. List @smileid/usesmileid_testing after the plugin that writes it, or keep the file in src/main/res/xml.`);const o=t!==void 0?fs.readFileSync(r,"utf-8"):i==="debug"?DEBUG_ONLY_CONFIG:LOOPBACK_ONLY_CONFIG,s=path.join(n,"src",i,"res","xml");return fs.mkdirSync(s,{recursive:!0}),fs.writeFileSync(path.join(s,`${e}.xml`),allowLoopback(o)),e}function writeDebugManifest(n,t,i="debug"){const e=path.join(n,"src",i,"AndroidManifest.xml"),r=`android:networkSecurityConfig="@xml/${t}"`;if(!fs.existsSync(e)){fs.mkdirSync(path.dirname(e),{recursive:!0}),fs.writeFileSync(e,`<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
11
|
+
<application ${r} />
|
|
12
|
+
</manifest>
|
|
13
|
+
`);return}const o=fs.readFileSync(e,"utf-8"),s=o.match(/android:networkSecurityConfig="([^"]*)"/)?.[1];if(s===`@xml/${t}`)return;if(s!==void 0)throw new Error(`[usesmileid_testing] ${e} already names ${s}; add 127.0.0.1 and localhost to it rather than have this plugin replace it.`);const c=/<application\b/.test(o)?o.replace(/<application\b/,`<application ${r}`):o.replace("</manifest>",` <application ${r} />
|
|
14
|
+
</manifest>`);if(c===o)throw new Error(`[usesmileid_testing] ${e} has neither an <application> element nor a closing </manifest> tag to add one before.`);fs.writeFileSync(e,c)}function forgetDebugManifest(n,t="debug"){const i=path.join(n,"src",t,"AndroidManifest.xml");if(!fs.existsSync(i))return;const e=fs.readFileSync(i,"utf-8"),r=e.replace(` android:networkSecurityConfig="@xml/${exports.DEBUG_CONFIG_NAME}"`,"");r!==e&&fs.writeFileSync(i,r)}const withAndroidLoopback=(n,t)=>(0,config_plugins_1.withAndroidManifest)(n,i=>{if(i.modRequest.introspect)return i;const e=config_plugins_1.AndroidConfig.Manifest.getMainApplicationOrThrow(i.modResults),r=xmlResourceName(e.$["android:networkSecurityConfig"]),o=path.join(i.modRequest.platformProjectRoot,"app");for(const s of t?.androidBuildTypes??["debug"]){const c=writeDebugConfig(o,r,s);r===void 0?writeDebugManifest(o,c,s):forgetDebugManifest(o,s)}return i});exports.withAndroidLoopback=withAndroidLoopback;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ConfigPlugin } from "expo/config-plugins";
|
|
2
|
+
import { type SmileIDSpmOptions } from "@smileid/usesmileid_bridge/plugin";
|
|
3
|
+
import { type AndroidLoopbackOptions } from "./android_loopback";
|
|
4
|
+
export type TestingOptions = SmileIDSpmOptions & AndroidLoopbackOptions;
|
|
5
|
+
export declare const SPM_PRODUCTS: readonly ["UseSmileIDTesting"];
|
|
6
|
+
declare const withTesting: ConfigPlugin<TestingOptions | void>;
|
|
7
|
+
export default withTesting;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.SPM_PRODUCTS=void 0;const plugin_1=require("@smileid/usesmileid_bridge/plugin"),android_loopback_1=require("./android_loopback");exports.SPM_PRODUCTS=["UseSmileIDTesting"];const withIosTesting=(0,plugin_1.withSmileIDSpm)({packageName:"usesmileid_testing",podTarget:"UseSmileIDTestingExpo",products:exports.SPM_PRODUCTS}),withTesting=(i,e)=>(0,android_loopback_1.withAndroidLoopback)(withIosTesting(i,e),e);exports.default=withTesting;
|