@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 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
@@ -0,0 +1,3 @@
1
+ const withRNDriverTouchCompanion = require('./plugin/withRNDriverTouchCompanion')
2
+
3
+ module.exports = withRNDriverTouchCompanion
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