@unrulysystems/rn-playwright-driver-instrumentation-companion 0.1.1
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 +10 -0
- package/README.md +141 -0
- package/android/build.gradle +95 -0
- package/android/src/main/AndroidManifest.xml +10 -0
- package/android/src/main/java/com/rndriver/touchcompanion/RNDriverTouchCompanion.kt +341 -0
- package/app.plugin.js +3 -0
- package/package.json +50 -0
- package/plugin/withRNDriverTouchCompanion.js +146 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# @unrulysystems/rn-playwright-driver-instrumentation-companion
|
|
2
|
+
|
|
3
|
+
## 0.1.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Publish the Android instrumentation companion package.
|
|
8
|
+
- Add Expo config plugin packaging that copies the companion runner and writes the androidTest manifest/dependencies.
|
|
9
|
+
- Add auth-token handling for the HTTP companion protocol.
|
|
10
|
+
- Document manual Android instrumentation setup for consumers that cannot use the config plugin.
|
package/README.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# RN Driver Touch Instrumentation Companion
|
|
2
|
+
|
|
3
|
+
Android instrumentation sidecar for `@unrulysystems/rn-playwright-driver`.
|
|
4
|
+
It starts a small HTTP server in a separate instrumentation process and injects
|
|
5
|
+
OS-level touch events through `UiAutomation.injectInputEvent`.
|
|
6
|
+
|
|
7
|
+
## Consumption
|
|
8
|
+
|
|
9
|
+
Install the package in the app under test and add the config plugin:
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
{
|
|
13
|
+
"expo": {
|
|
14
|
+
"plugins": ["@unrulysystems/rn-playwright-driver-instrumentation-companion"]
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
During `expo prebuild`, the plugin:
|
|
20
|
+
|
|
21
|
+
- copies `RNDriverTouchCompanion.kt` into `android/app/src/androidTest/java`;
|
|
22
|
+
- writes `android/app/src/androidTest/AndroidManifest.xml` with the
|
|
23
|
+
instrumentation registration;
|
|
24
|
+
- adds `androidx.test:runner` and `androidx.test:core` as `androidTestImplementation`
|
|
25
|
+
dependencies.
|
|
26
|
+
|
|
27
|
+
The plugin requires `expo.android.package` so the androidTest manifest can target
|
|
28
|
+
the app package. The packaged Android manifest uses `${applicationId}` as the
|
|
29
|
+
target package placeholder; if a consuming build cannot resolve that placeholder,
|
|
30
|
+
copy the manifest below and replace it with the app id.
|
|
31
|
+
|
|
32
|
+
## Manual Device Flow
|
|
33
|
+
|
|
34
|
+
Regenerate the native project:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npx expo prebuild --platform android
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Build the app APK and the androidTest instrumentation APK:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
cd android
|
|
44
|
+
./gradlew :app:assembleDebug :app:assembleDebugAndroidTest
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Install both APKs:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
|
51
|
+
adb install -r app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Forward the companion port and launch the instrumentation:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
RN_TOUCH_INSTRUMENTATION_TOKEN="$(openssl rand -hex 16)"
|
|
58
|
+
export RN_TOUCH_INSTRUMENTATION_TOKEN_FILE="$(mktemp -t rn-driver-touch-token.XXXXXX)"
|
|
59
|
+
chmod 600 "$RN_TOUCH_INSTRUMENTATION_TOKEN_FILE"
|
|
60
|
+
printf '%s' "$RN_TOUCH_INSTRUMENTATION_TOKEN" >"$RN_TOUCH_INSTRUMENTATION_TOKEN_FILE"
|
|
61
|
+
adb forward tcp:9999 tcp:9999
|
|
62
|
+
adb shell am instrument -e rnDriverAuthToken "$RN_TOUCH_INSTRUMENTATION_TOKEN" -w <app>.test/com.rndriver.touchcompanion.RNDriverTouchCompanion
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Configure the driver to force the instrumentation backend:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
import fs from 'node:fs'
|
|
69
|
+
|
|
70
|
+
const authToken = fs.readFileSync(process.env.RN_TOUCH_INSTRUMENTATION_TOKEN_FILE!, 'utf8').trim()
|
|
71
|
+
const device = createDevice({
|
|
72
|
+
touch: {
|
|
73
|
+
mode: 'force',
|
|
74
|
+
backend: 'instrumentation',
|
|
75
|
+
instrumentation: { port: 9999, authToken },
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The test fixture also accepts `RN_TOUCH_INSTRUMENTATION_TOKEN_FILE` directly
|
|
81
|
+
when `RN_TOUCH_BACKEND=instrumentation`. Prefer the file form for local scripts
|
|
82
|
+
so the Playwright process environment carries only a path. The value is still a
|
|
83
|
+
local capability token rather than a durable secret: Android instrumentation
|
|
84
|
+
requires passing it as the `rnDriverAuthToken` argument to `adb shell am
|
|
85
|
+
instrument`.
|
|
86
|
+
|
|
87
|
+
## Raw Assets
|
|
88
|
+
|
|
89
|
+
If the config plugin cannot be used, copy these assets after `expo prebuild`:
|
|
90
|
+
|
|
91
|
+
- `android/src/main/java/com/rndriver/touchcompanion/RNDriverTouchCompanion.kt`
|
|
92
|
+
to `android/app/src/androidTest/java/com/rndriver/touchcompanion/RNDriverTouchCompanion.kt`;
|
|
93
|
+
- the manifest below to `android/app/src/androidTest/AndroidManifest.xml`;
|
|
94
|
+
- the Gradle dependency snippet below into `android/app/build.gradle`.
|
|
95
|
+
|
|
96
|
+
```xml
|
|
97
|
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
|
98
|
+
package="<app>.test">
|
|
99
|
+
<uses-permission android:name="android.permission.INTERNET" />
|
|
100
|
+
<instrumentation
|
|
101
|
+
android:name="com.rndriver.touchcompanion.RNDriverTouchCompanion"
|
|
102
|
+
android:targetPackage="<app>"
|
|
103
|
+
android:functionalTest="false"
|
|
104
|
+
android:handleProfiling="false"
|
|
105
|
+
android:label="RN Driver Touch Companion" />
|
|
106
|
+
</manifest>
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
```gradle
|
|
110
|
+
dependencies {
|
|
111
|
+
androidTestImplementation "androidx.test:runner:1.6.2"
|
|
112
|
+
androidTestImplementation "androidx.test:core:1.6.1"
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Protocol
|
|
117
|
+
|
|
118
|
+
POST `/command` with a JSON body and the `x-rn-driver-auth` header matching
|
|
119
|
+
the `rnDriverAuthToken` instrumentation argument.
|
|
120
|
+
|
|
121
|
+
| Command | Body |
|
|
122
|
+
| ----------- | ---------------------------------------------------------------------------------------------------- |
|
|
123
|
+
| `hello` | `{ "type": "hello" }` |
|
|
124
|
+
| `tap` | `{ "type": "tap", "x": 10, "y": 20 }` |
|
|
125
|
+
| `down` | `{ "type": "down", "x": 10, "y": 20 }` |
|
|
126
|
+
| `move` | `{ "type": "move", "x": 15, "y": 25 }` |
|
|
127
|
+
| `up` | `{ "type": "up" }` |
|
|
128
|
+
| `swipe` | `{ "type": "swipe", "from": { "x": 10, "y": 20 }, "to": { "x": 100, "y": 200 }, "durationMs": 300 }` |
|
|
129
|
+
| `longPress` | `{ "type": "longPress", "x": 10, "y": 20, "durationMs": 500 }` |
|
|
130
|
+
| `typeText` | `{ "type": "typeText", "text": "hello" }` |
|
|
131
|
+
|
|
132
|
+
Responses are JSON envelopes:
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
type TouchCompanionResponse =
|
|
136
|
+
| { ok: true; result?: unknown }
|
|
137
|
+
| { ok: false; error: { message: string; code?: string } }
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Coordinates are logical points (dp). The companion converts them to pixels using
|
|
141
|
+
the target display density before injecting motion events.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
apply plugin: 'com.android.library'
|
|
2
|
+
apply plugin: 'kotlin-android'
|
|
3
|
+
apply plugin: 'maven-publish'
|
|
4
|
+
|
|
5
|
+
group = 'com.rndriver.touchcompanion'
|
|
6
|
+
version = '0.1.1'
|
|
7
|
+
|
|
8
|
+
buildscript {
|
|
9
|
+
repositories {
|
|
10
|
+
google()
|
|
11
|
+
mavenCentral()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
dependencies {
|
|
15
|
+
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.24"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
afterEvaluate {
|
|
20
|
+
publishing {
|
|
21
|
+
publications {
|
|
22
|
+
release(MavenPublication) {
|
|
23
|
+
from components.release
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
repositories {
|
|
27
|
+
maven {
|
|
28
|
+
url = mavenLocal().url
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
android {
|
|
35
|
+
namespace "com.rndriver.touchcompanion"
|
|
36
|
+
compileSdkVersion safeExtGet("compileSdkVersion", 34)
|
|
37
|
+
|
|
38
|
+
defaultConfig {
|
|
39
|
+
minSdkVersion safeExtGet("minSdkVersion", 23)
|
|
40
|
+
targetSdkVersion safeExtGet("targetSdkVersion", 34)
|
|
41
|
+
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
|
42
|
+
manifestPlaceholders = [
|
|
43
|
+
applicationId: safeExtGet("applicationId", "com.rndriver.touchcompanion.target")
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
publishing {
|
|
48
|
+
singleVariant("release") {
|
|
49
|
+
withSourcesJar()
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
lintOptions {
|
|
54
|
+
abortOnError false
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
compileOptions {
|
|
58
|
+
sourceCompatibility JavaVersion.VERSION_17
|
|
59
|
+
targetCompatibility JavaVersion.VERSION_17
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
kotlinOptions {
|
|
63
|
+
jvmTarget = JavaVersion.VERSION_17.majorVersion
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
repositories {
|
|
68
|
+
google()
|
|
69
|
+
mavenCentral()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
dependencies {
|
|
73
|
+
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}"
|
|
74
|
+
implementation "androidx.test:runner:${getAndroidXTestRunnerVersion()}"
|
|
75
|
+
implementation "androidx.test:core:${getAndroidXTestCoreVersion()}"
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
def getKotlinVersion() {
|
|
79
|
+
def kotlin_version = rootProject.ext.has("kotlinVersion") ? rootProject.ext.get("kotlinVersion") : project.properties["kotlinVersion"]
|
|
80
|
+
return kotlin_version ?: "1.9.24"
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
def getAndroidXTestRunnerVersion() {
|
|
84
|
+
def version = rootProject.ext.has("androidXTestRunnerVersion") ? rootProject.ext.get("androidXTestRunnerVersion") : project.properties["androidXTestRunnerVersion"]
|
|
85
|
+
return version ?: "1.6.2"
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
def getAndroidXTestCoreVersion() {
|
|
89
|
+
def version = rootProject.ext.has("androidXTestCoreVersion") ? rootProject.ext.get("androidXTestCoreVersion") : project.properties["androidXTestCoreVersion"]
|
|
90
|
+
return version ?: "1.6.1"
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
def safeExtGet(prop, fallback) {
|
|
94
|
+
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
|
|
95
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
|
2
|
+
package="com.rndriver.touchcompanion">
|
|
3
|
+
<uses-permission android:name="android.permission.INTERNET" />
|
|
4
|
+
<instrumentation
|
|
5
|
+
android:name="com.rndriver.touchcompanion.RNDriverTouchCompanion"
|
|
6
|
+
android:targetPackage="${applicationId}"
|
|
7
|
+
android:functionalTest="false"
|
|
8
|
+
android:handleProfiling="false"
|
|
9
|
+
android:label="RN Driver Touch Companion" />
|
|
10
|
+
</manifest>
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
package com.rndriver.touchcompanion
|
|
2
|
+
|
|
3
|
+
import android.app.Instrumentation
|
|
4
|
+
import android.os.Bundle
|
|
5
|
+
import android.os.SystemClock
|
|
6
|
+
import android.util.Log
|
|
7
|
+
import android.view.MotionEvent
|
|
8
|
+
import org.json.JSONObject
|
|
9
|
+
import java.io.BufferedInputStream
|
|
10
|
+
import java.io.BufferedOutputStream
|
|
11
|
+
import java.io.ByteArrayOutputStream
|
|
12
|
+
import java.io.InputStream
|
|
13
|
+
import java.net.InetAddress
|
|
14
|
+
import java.net.InetSocketAddress
|
|
15
|
+
import java.net.ServerSocket
|
|
16
|
+
import java.net.Socket
|
|
17
|
+
import java.nio.charset.StandardCharsets
|
|
18
|
+
import java.util.concurrent.CountDownLatch
|
|
19
|
+
import kotlin.concurrent.thread
|
|
20
|
+
|
|
21
|
+
private const val DEFAULT_PORT = 9999
|
|
22
|
+
private const val TAG = "RNDriverTouchCompanion"
|
|
23
|
+
private const val ARG_AUTH_TOKEN = "rnDriverAuthToken"
|
|
24
|
+
private const val AUTH_HEADER = "x-rn-driver-auth"
|
|
25
|
+
private const val SOCKET_TIMEOUT_MS = 2_000
|
|
26
|
+
private const val MAX_HEADER_BYTES = 16 * 1024
|
|
27
|
+
private const val MAX_BODY_BYTES = 1024 * 1024
|
|
28
|
+
|
|
29
|
+
class RNDriverTouchCompanion : Instrumentation() {
|
|
30
|
+
private var server: TouchCompanionServer? = null
|
|
31
|
+
private var authToken: String? = null
|
|
32
|
+
private val keepAlive = CountDownLatch(1)
|
|
33
|
+
|
|
34
|
+
override fun onCreate(arguments: Bundle?) {
|
|
35
|
+
super.onCreate(arguments)
|
|
36
|
+
authToken = arguments?.getString(ARG_AUTH_TOKEN)?.takeIf { it.isNotBlank() }
|
|
37
|
+
start()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
override fun onStart() {
|
|
41
|
+
super.onStart()
|
|
42
|
+
val token = checkNotNull(authToken) {
|
|
43
|
+
"RNDriverTouchCompanion requires -e $ARG_AUTH_TOKEN <token>"
|
|
44
|
+
}
|
|
45
|
+
server = TouchCompanionServer(this, authToken = token)
|
|
46
|
+
server?.start()
|
|
47
|
+
keepAlive.await()
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private class TouchCompanionServer(
|
|
52
|
+
private val instrumentation: Instrumentation,
|
|
53
|
+
private val port: Int = DEFAULT_PORT,
|
|
54
|
+
private val authToken: String,
|
|
55
|
+
) {
|
|
56
|
+
private var serverThread: Thread? = null
|
|
57
|
+
private val density: Float = instrumentation.targetContext.resources.displayMetrics.density
|
|
58
|
+
|
|
59
|
+
fun start() {
|
|
60
|
+
if (serverThread != null) return
|
|
61
|
+
serverThread = thread(name = "rn-driver-touch-server", isDaemon = true) {
|
|
62
|
+
ServerSocket().use { serverSocket ->
|
|
63
|
+
serverSocket.bind(InetSocketAddress(InetAddress.getLoopbackAddress(), port))
|
|
64
|
+
Log.i(TAG, "Touch companion listening on 127.0.0.1:$port")
|
|
65
|
+
while (!Thread.currentThread().isInterrupted) {
|
|
66
|
+
val socket = serverSocket.accept()
|
|
67
|
+
try {
|
|
68
|
+
handleClient(socket)
|
|
69
|
+
} catch (error: Exception) {
|
|
70
|
+
Log.w(TAG, "Touch companion request failed", error)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private fun handleClient(socket: Socket) {
|
|
78
|
+
socket.use { client ->
|
|
79
|
+
val input = BufferedInputStream(client.getInputStream())
|
|
80
|
+
val output = BufferedOutputStream(client.getOutputStream())
|
|
81
|
+
client.soTimeout = SOCKET_TIMEOUT_MS
|
|
82
|
+
|
|
83
|
+
val request = readHttpRequest(input) ?: return
|
|
84
|
+
val response = handleCommand(request)
|
|
85
|
+
|
|
86
|
+
output.write(response.toByteArray(StandardCharsets.UTF_8))
|
|
87
|
+
output.flush()
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private data class HttpRequest(
|
|
92
|
+
val headers: Map<String, String>,
|
|
93
|
+
val body: String,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
private fun readHttpRequest(input: InputStream): HttpRequest? {
|
|
97
|
+
val headerBuffer = ByteArrayOutputStream()
|
|
98
|
+
val delimiter = "\r\n\r\n".toByteArray(StandardCharsets.UTF_8)
|
|
99
|
+
val temp = ByteArray(1024)
|
|
100
|
+
|
|
101
|
+
while (true) {
|
|
102
|
+
val read = input.read(temp)
|
|
103
|
+
if (read <= 0) return null
|
|
104
|
+
headerBuffer.write(temp, 0, read)
|
|
105
|
+
if (headerBuffer.size() > MAX_HEADER_BYTES) {
|
|
106
|
+
throw IllegalArgumentException("HTTP headers exceed ${MAX_HEADER_BYTES} bytes")
|
|
107
|
+
}
|
|
108
|
+
val bytes = headerBuffer.toByteArray()
|
|
109
|
+
val index = indexOf(bytes, delimiter)
|
|
110
|
+
if (index >= 0) {
|
|
111
|
+
val headerText = String(bytes, 0, index + delimiter.size, StandardCharsets.UTF_8)
|
|
112
|
+
val headers = parseHeaders(headerText)
|
|
113
|
+
val contentLength = parseContentLength(headers)
|
|
114
|
+
if (contentLength > MAX_BODY_BYTES) {
|
|
115
|
+
throw IllegalArgumentException("HTTP body exceeds ${MAX_BODY_BYTES} bytes")
|
|
116
|
+
}
|
|
117
|
+
val remainingStart = index + delimiter.size
|
|
118
|
+
val remaining = bytes.size - remainingStart
|
|
119
|
+
|
|
120
|
+
val body = ByteArrayOutputStream()
|
|
121
|
+
if (remaining > 0) {
|
|
122
|
+
body.write(bytes, remainingStart, remaining)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
while (body.size() < contentLength) {
|
|
126
|
+
val count = input.read(temp)
|
|
127
|
+
if (count <= 0) break
|
|
128
|
+
body.write(temp, 0, count)
|
|
129
|
+
}
|
|
130
|
+
return HttpRequest(headers, String(body.toByteArray(), StandardCharsets.UTF_8))
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private fun handleCommand(request: HttpRequest): String {
|
|
136
|
+
if (request.headers[AUTH_HEADER] != authToken) {
|
|
137
|
+
return errorResponse("Unauthorized instrumentation companion request", "UNAUTHORIZED", 401)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return try {
|
|
141
|
+
val payload = JSONObject(request.body)
|
|
142
|
+
val type = payload.optString("type", "")
|
|
143
|
+
|
|
144
|
+
when (type) {
|
|
145
|
+
"hello" -> okResponse(JSONObject().apply {
|
|
146
|
+
put("platform", "android")
|
|
147
|
+
put("density", density)
|
|
148
|
+
})
|
|
149
|
+
"tap" -> {
|
|
150
|
+
val (x, y) = parsePoint(payload)
|
|
151
|
+
injectTap(x, y)
|
|
152
|
+
okResponse()
|
|
153
|
+
}
|
|
154
|
+
"down" -> {
|
|
155
|
+
val (x, y) = parsePoint(payload)
|
|
156
|
+
injectDown(x, y)
|
|
157
|
+
okResponse()
|
|
158
|
+
}
|
|
159
|
+
"move" -> {
|
|
160
|
+
val (x, y) = parsePoint(payload)
|
|
161
|
+
injectMove(x, y)
|
|
162
|
+
okResponse()
|
|
163
|
+
}
|
|
164
|
+
"up" -> {
|
|
165
|
+
injectUp()
|
|
166
|
+
okResponse()
|
|
167
|
+
}
|
|
168
|
+
"swipe" -> {
|
|
169
|
+
val from = payload.getJSONObject("from")
|
|
170
|
+
val to = payload.getJSONObject("to")
|
|
171
|
+
val durationMs = payload.optLong("durationMs", 300)
|
|
172
|
+
injectSwipe(
|
|
173
|
+
from.getDouble("x"),
|
|
174
|
+
from.getDouble("y"),
|
|
175
|
+
to.getDouble("x"),
|
|
176
|
+
to.getDouble("y"),
|
|
177
|
+
durationMs,
|
|
178
|
+
)
|
|
179
|
+
okResponse()
|
|
180
|
+
}
|
|
181
|
+
"longPress" -> {
|
|
182
|
+
val (x, y) = parsePoint(payload)
|
|
183
|
+
val durationMs = payload.optLong("durationMs", 500)
|
|
184
|
+
injectLongPress(x, y, durationMs)
|
|
185
|
+
okResponse()
|
|
186
|
+
}
|
|
187
|
+
"typeText" -> {
|
|
188
|
+
val text = payload.optString("text", "")
|
|
189
|
+
instrumentation.sendStringSync(text)
|
|
190
|
+
okResponse()
|
|
191
|
+
}
|
|
192
|
+
else -> errorResponse("Unsupported command: $type", "UNSUPPORTED_COMMAND")
|
|
193
|
+
}
|
|
194
|
+
} catch (error: Exception) {
|
|
195
|
+
errorResponse(error.message ?: "Command failed")
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private fun parsePoint(payload: JSONObject): Pair<Double, Double> {
|
|
200
|
+
val x = payload.getDouble("x")
|
|
201
|
+
val y = payload.getDouble("y")
|
|
202
|
+
return Pair(x, y)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private fun injectTap(x: Double, y: Double) {
|
|
206
|
+
val downTime = SystemClock.uptimeMillis()
|
|
207
|
+
val xPx = (x * density).toFloat()
|
|
208
|
+
val yPx = (y * density).toFloat()
|
|
209
|
+
injectEvent(MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, xPx, yPx, 0))
|
|
210
|
+
injectEvent(MotionEvent.obtain(downTime, downTime + 50, MotionEvent.ACTION_UP, xPx, yPx, 0))
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private var activeDownTime: Long? = null
|
|
214
|
+
private var lastX: Float = 0f
|
|
215
|
+
private var lastY: Float = 0f
|
|
216
|
+
|
|
217
|
+
private fun injectDown(x: Double, y: Double) {
|
|
218
|
+
val downTime = SystemClock.uptimeMillis()
|
|
219
|
+
val xPx = (x * density).toFloat()
|
|
220
|
+
val yPx = (y * density).toFloat()
|
|
221
|
+
activeDownTime = downTime
|
|
222
|
+
lastX = xPx
|
|
223
|
+
lastY = yPx
|
|
224
|
+
injectEvent(MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, xPx, yPx, 0))
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private fun injectMove(x: Double, y: Double) {
|
|
228
|
+
val downTime = activeDownTime ?: return
|
|
229
|
+
val eventTime = SystemClock.uptimeMillis()
|
|
230
|
+
val xPx = (x * density).toFloat()
|
|
231
|
+
val yPx = (y * density).toFloat()
|
|
232
|
+
lastX = xPx
|
|
233
|
+
lastY = yPx
|
|
234
|
+
injectEvent(MotionEvent.obtain(downTime, eventTime, MotionEvent.ACTION_MOVE, xPx, yPx, 0))
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private fun injectUp() {
|
|
238
|
+
val downTime = activeDownTime ?: return
|
|
239
|
+
val eventTime = SystemClock.uptimeMillis()
|
|
240
|
+
injectEvent(MotionEvent.obtain(downTime, eventTime, MotionEvent.ACTION_UP, lastX, lastY, 0))
|
|
241
|
+
activeDownTime = null
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private fun injectSwipe(
|
|
245
|
+
fromX: Double,
|
|
246
|
+
fromY: Double,
|
|
247
|
+
toX: Double,
|
|
248
|
+
toY: Double,
|
|
249
|
+
durationMs: Long,
|
|
250
|
+
) {
|
|
251
|
+
val steps = maxOf(10, (durationMs / 16).toInt())
|
|
252
|
+
val downTime = SystemClock.uptimeMillis()
|
|
253
|
+
val startX = (fromX * density).toFloat()
|
|
254
|
+
val startY = (fromY * density).toFloat()
|
|
255
|
+
val endX = (toX * density).toFloat()
|
|
256
|
+
val endY = (toY * density).toFloat()
|
|
257
|
+
|
|
258
|
+
injectEvent(MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, startX, startY, 0))
|
|
259
|
+
|
|
260
|
+
for (i in 1..steps) {
|
|
261
|
+
val t = i.toFloat() / steps
|
|
262
|
+
val x = startX + (endX - startX) * t
|
|
263
|
+
val y = startY + (endY - startY) * t
|
|
264
|
+
val eventTime = downTime + (durationMs * t).toLong()
|
|
265
|
+
injectEvent(MotionEvent.obtain(downTime, eventTime, MotionEvent.ACTION_MOVE, x, y, 0))
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
val endTime = downTime + durationMs
|
|
269
|
+
injectEvent(MotionEvent.obtain(downTime, endTime, MotionEvent.ACTION_UP, endX, endY, 0))
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private fun injectLongPress(x: Double, y: Double, durationMs: Long) {
|
|
273
|
+
val downTime = SystemClock.uptimeMillis()
|
|
274
|
+
val xPx = (x * density).toFloat()
|
|
275
|
+
val yPx = (y * density).toFloat()
|
|
276
|
+
injectEvent(MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, xPx, yPx, 0))
|
|
277
|
+
SystemClock.sleep(durationMs)
|
|
278
|
+
val upTime = SystemClock.uptimeMillis()
|
|
279
|
+
injectEvent(MotionEvent.obtain(downTime, upTime, MotionEvent.ACTION_UP, xPx, yPx, 0))
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private fun injectEvent(event: MotionEvent) {
|
|
283
|
+
val uiAutomation = instrumentation.uiAutomation
|
|
284
|
+
uiAutomation.injectInputEvent(event, true)
|
|
285
|
+
event.recycle()
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private fun okResponse(result: JSONObject? = null): String {
|
|
289
|
+
val payload = JSONObject()
|
|
290
|
+
payload.put("ok", true)
|
|
291
|
+
if (result != null) {
|
|
292
|
+
payload.put("result", result)
|
|
293
|
+
}
|
|
294
|
+
return httpResponse(payload.toString())
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private fun errorResponse(message: String, code: String = "INTERNAL", status: Int = 500): String {
|
|
298
|
+
val payload = JSONObject()
|
|
299
|
+
payload.put("ok", false)
|
|
300
|
+
payload.put("error", JSONObject().apply {
|
|
301
|
+
put("message", message)
|
|
302
|
+
put("code", code)
|
|
303
|
+
})
|
|
304
|
+
return httpResponse(payload.toString(), status)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private fun httpResponse(body: String, status: Int = 200): String {
|
|
308
|
+
return "HTTP/1.1 $status OK\r\n" +
|
|
309
|
+
"Content-Type: application/json\r\n" +
|
|
310
|
+
"Content-Length: ${body.toByteArray(StandardCharsets.UTF_8).size}\r\n" +
|
|
311
|
+
"Connection: close\r\n\r\n" +
|
|
312
|
+
body
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private fun parseHeaders(headerText: String): Map<String, String> {
|
|
316
|
+
return headerText.split("\r\n")
|
|
317
|
+
.drop(1)
|
|
318
|
+
.mapNotNull { line ->
|
|
319
|
+
val separator = line.indexOf(':')
|
|
320
|
+
if (separator <= 0) return@mapNotNull null
|
|
321
|
+
val name = line.substring(0, separator).trim().lowercase()
|
|
322
|
+
val value = line.substring(separator + 1).trim()
|
|
323
|
+
name to value
|
|
324
|
+
}
|
|
325
|
+
.toMap()
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private fun parseContentLength(headers: Map<String, String>): Int {
|
|
329
|
+
return headers["content-length"]?.toIntOrNull() ?: 0
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private fun indexOf(haystack: ByteArray, needle: ByteArray): Int {
|
|
333
|
+
outer@ for (i in 0..haystack.size - needle.size) {
|
|
334
|
+
for (j in needle.indices) {
|
|
335
|
+
if (haystack[i + j] != needle[j]) continue@outer
|
|
336
|
+
}
|
|
337
|
+
return i
|
|
338
|
+
}
|
|
339
|
+
return -1
|
|
340
|
+
}
|
|
341
|
+
}
|
package/app.plugin.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@unrulysystems/rn-playwright-driver-instrumentation-companion",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Android Instrumentation HTTP companion for RN Playwright driver touch injection",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"android",
|
|
7
|
+
"config-plugin",
|
|
8
|
+
"expo",
|
|
9
|
+
"instrumentation",
|
|
10
|
+
"react-native"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://github.com/unrulysystems/rn-playwright-driver#readme",
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": "0xbigboss",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/unrulysystems/rn-playwright-driver.git",
|
|
18
|
+
"directory": "packages/instrumentation-companion"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"CHANGELOG.md",
|
|
22
|
+
"README.md",
|
|
23
|
+
"android/build.gradle",
|
|
24
|
+
"android/src",
|
|
25
|
+
"app.plugin.js",
|
|
26
|
+
"plugin/withRNDriverTouchCompanion.js"
|
|
27
|
+
],
|
|
28
|
+
"main": "app.plugin.js",
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "echo 'Android instrumentation companion - no JS build needed'",
|
|
34
|
+
"clean": "echo 'Android instrumentation companion - no JS build artifacts'",
|
|
35
|
+
"test": "bun test plugin/withRNDriverTouchCompanion.test.js"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@expo/config-plugins": "~54.0.4"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"expo": "*",
|
|
42
|
+
"react-native": "*"
|
|
43
|
+
},
|
|
44
|
+
"expo": {
|
|
45
|
+
"plugins": [
|
|
46
|
+
"./app.plugin.js"
|
|
47
|
+
]
|
|
48
|
+
},
|
|
49
|
+
"plugin": "app.plugin.js"
|
|
50
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
const {
|
|
2
|
+
withAndroidManifest,
|
|
3
|
+
withAppBuildGradle,
|
|
4
|
+
withDangerousMod,
|
|
5
|
+
} = require('@expo/config-plugins')
|
|
6
|
+
const fs = require('fs')
|
|
7
|
+
const path = require('path')
|
|
8
|
+
|
|
9
|
+
const COMPANION_CLASS = 'com.rndriver.touchcompanion.RNDriverTouchCompanion'
|
|
10
|
+
const GENERATED_TAG = 'rn-driver-touch-companion'
|
|
11
|
+
const TEST_RUNNER_DEP = 'androidTestImplementation "androidx.test:runner:1.6.2"'
|
|
12
|
+
const TEST_CORE_DEP = 'androidTestImplementation "androidx.test:core:1.6.1"'
|
|
13
|
+
|
|
14
|
+
function withRNDriverTouchCompanion(config) {
|
|
15
|
+
config = withAndroidManifest(config, (androidConfig) => {
|
|
16
|
+
const manifest = androidConfig.modResults.manifest
|
|
17
|
+
const applicationId = getAndroidPackage(androidConfig, manifest)
|
|
18
|
+
if (!applicationId) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
'RN Driver Touch Companion requires expo.android.package so the androidTest instrumentation can target the app.',
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
return androidConfig
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
config = withAppBuildGradle(config, (gradleConfig) => {
|
|
27
|
+
gradleConfig.modResults.contents = addAndroidTestGradleConfig(gradleConfig.modResults.contents)
|
|
28
|
+
return gradleConfig
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
config = withDangerousMod(config, [
|
|
32
|
+
'android',
|
|
33
|
+
async (dangerousConfig) => {
|
|
34
|
+
const applicationId = getAndroidPackage(dangerousConfig)
|
|
35
|
+
if (!applicationId) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
'RN Driver Touch Companion requires expo.android.package so the androidTest instrumentation can target the app.',
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const projectRoot = dangerousConfig.modRequest.platformProjectRoot
|
|
42
|
+
const javaDest = path.join(
|
|
43
|
+
projectRoot,
|
|
44
|
+
'app/src/androidTest/java/com/rndriver/touchcompanion/RNDriverTouchCompanion.kt',
|
|
45
|
+
)
|
|
46
|
+
const manifestDest = path.join(projectRoot, 'app/src/androidTest/AndroidManifest.xml')
|
|
47
|
+
const source = path.join(
|
|
48
|
+
__dirname,
|
|
49
|
+
'../android/src/main/java/com/rndriver/touchcompanion/RNDriverTouchCompanion.kt',
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
await fs.promises.mkdir(path.dirname(javaDest), { recursive: true })
|
|
53
|
+
await fs.promises.mkdir(path.dirname(manifestDest), { recursive: true })
|
|
54
|
+
await fs.promises.copyFile(source, javaDest)
|
|
55
|
+
await fs.promises.writeFile(manifestDest, androidTestManifest(applicationId))
|
|
56
|
+
|
|
57
|
+
return dangerousConfig
|
|
58
|
+
},
|
|
59
|
+
])
|
|
60
|
+
|
|
61
|
+
return config
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getAndroidPackage(config, manifest) {
|
|
65
|
+
return config.android?.package || manifest?.$?.package || null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function addAndroidTestGradleConfig(contents) {
|
|
69
|
+
let next = contents
|
|
70
|
+
|
|
71
|
+
if (!next.includes(TEST_RUNNER_DEP)) {
|
|
72
|
+
next = addToDependenciesBlock(next, TEST_RUNNER_DEP)
|
|
73
|
+
}
|
|
74
|
+
if (!next.includes(TEST_CORE_DEP)) {
|
|
75
|
+
next = addToDependenciesBlock(next, TEST_CORE_DEP)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return next
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function addToDependenciesBlock(contents, line) {
|
|
82
|
+
return addToNamedBlock(contents, 'dependencies', generatedBlock(line, ' '))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function addToNamedBlock(contents, blockName, block) {
|
|
86
|
+
const blockRegex = new RegExp(`(^|\\n)(\\s*)${blockName}\\s*\\{`)
|
|
87
|
+
const match = blockRegex.exec(contents)
|
|
88
|
+
|
|
89
|
+
if (!match) {
|
|
90
|
+
return `${contents.trimEnd()}\n\n${blockName} {\n${block}\n}\n`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const openBraceIndex = match.index + match[0].lastIndexOf('{')
|
|
94
|
+
const closeBraceIndex = findMatchingBrace(contents, openBraceIndex)
|
|
95
|
+
if (closeBraceIndex < 0) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`Could not find closing brace for ${blockName} block in android/app/build.gradle`,
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return `${contents.slice(0, closeBraceIndex).trimEnd()}\n${block}\n${contents.slice(closeBraceIndex)}`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function generatedBlock(line, indent) {
|
|
105
|
+
return [
|
|
106
|
+
`${indent}// @generated begin ${GENERATED_TAG}`,
|
|
107
|
+
`${indent}${line}`,
|
|
108
|
+
`${indent}// @generated end ${GENERATED_TAG}`,
|
|
109
|
+
].join('\n')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function findMatchingBrace(contents, openBraceIndex) {
|
|
113
|
+
let depth = 0
|
|
114
|
+
|
|
115
|
+
for (let index = openBraceIndex; index < contents.length; index += 1) {
|
|
116
|
+
const char = contents[index]
|
|
117
|
+
if (char === '{') {
|
|
118
|
+
depth += 1
|
|
119
|
+
} else if (char === '}') {
|
|
120
|
+
depth -= 1
|
|
121
|
+
if (depth === 0) {
|
|
122
|
+
return index
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return -1
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function androidTestManifest(applicationId) {
|
|
131
|
+
return `<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
|
132
|
+
package="${applicationId}.test">
|
|
133
|
+
<uses-permission android:name="android.permission.INTERNET" />
|
|
134
|
+
<instrumentation
|
|
135
|
+
android:name="${COMPANION_CLASS}"
|
|
136
|
+
android:targetPackage="${applicationId}"
|
|
137
|
+
android:functionalTest="false"
|
|
138
|
+
android:handleProfiling="false"
|
|
139
|
+
android:label="RN Driver Touch Companion" />
|
|
140
|
+
</manifest>
|
|
141
|
+
`
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = withRNDriverTouchCompanion
|
|
145
|
+
module.exports.addAndroidTestGradleConfig = addAndroidTestGradleConfig
|
|
146
|
+
module.exports.androidTestManifest = androidTestManifest
|