From df8915cf5cec4c5ff61b4505f0444d540bf5e9ba Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 14 Dec 2025 02:13:53 +0000 Subject: [PATCH] test(android): add bridge unit tests --- apps/android/app/build.gradle.kts | 3 + .../node/bridge/BridgePairingClientTest.kt | 101 ++++++++ .../clawdis/node/bridge/BridgeSessionTest.kt | 224 ++++++++++++++++++ 3 files changed, 328 insertions(+) create mode 100644 apps/android/app/src/test/java/com/steipete/clawdis/node/bridge/BridgePairingClientTest.kt create mode 100644 apps/android/app/src/test/java/com/steipete/clawdis/node/bridge/BridgeSessionTest.kt diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 5ace327a8..2126a55b6 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -76,4 +76,7 @@ dependencies { implementation("androidx.camera:camera-lifecycle:1.3.4") implementation("androidx.camera:camera-video:1.3.4") implementation("androidx.camera:camera-view:1.3.4") + + testImplementation("junit:junit:4.13.2") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1") } diff --git a/apps/android/app/src/test/java/com/steipete/clawdis/node/bridge/BridgePairingClientTest.kt b/apps/android/app/src/test/java/com/steipete/clawdis/node/bridge/BridgePairingClientTest.kt new file mode 100644 index 000000000..9644bb7c9 --- /dev/null +++ b/apps/android/app/src/test/java/com/steipete/clawdis/node/bridge/BridgePairingClientTest.kt @@ -0,0 +1,101 @@ +package com.steipete.clawdis.node.bridge + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.BufferedReader +import java.io.BufferedWriter +import java.io.InputStreamReader +import java.io.OutputStreamWriter +import java.net.ServerSocket + +class BridgePairingClientTest { + @Test + fun helloOkReturnsExistingToken() = runBlocking { + val serverSocket = ServerSocket(0) + val port = serverSocket.localPort + + val server = + async(Dispatchers.IO) { + serverSocket.use { ss -> + val sock = ss.accept() + sock.use { s -> + val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8)) + val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8)) + + val hello = reader.readLine() + assertTrue(hello.contains("\"type\":\"hello\"")) + writer.write("""{"type":"hello-ok","serverName":"Test Bridge"}""") + writer.write("\n") + writer.flush() + } + } + } + + val client = BridgePairingClient() + val res = + client.pairAndHello( + endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port), + hello = + BridgePairingClient.Hello( + nodeId = "node-1", + displayName = "Android Node", + token = "token-123", + platform = "Android", + version = "test", + ), + ) + assertTrue(res.ok) + assertEquals("token-123", res.token) + server.await() + } + + @Test + fun notPairedTriggersPairRequestAndReturnsToken() = runBlocking { + val serverSocket = ServerSocket(0) + val port = serverSocket.localPort + + val server = + async(Dispatchers.IO) { + serverSocket.use { ss -> + val sock = ss.accept() + sock.use { s -> + val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8)) + val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8)) + + reader.readLine() // hello + writer.write("""{"type":"error","code":"NOT_PAIRED","message":"not paired"}""") + writer.write("\n") + writer.flush() + + val pairReq = reader.readLine() + assertTrue(pairReq.contains("\"type\":\"pair-request\"")) + writer.write("""{"type":"pair-ok","token":"new-token"}""") + writer.write("\n") + writer.flush() + } + } + } + + val client = BridgePairingClient() + val res = + client.pairAndHello( + endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port), + hello = + BridgePairingClient.Hello( + nodeId = "node-1", + displayName = "Android Node", + token = null, + platform = "Android", + version = "test", + ), + ) + assertTrue(res.ok) + assertEquals("new-token", res.token) + server.await() + } +} + diff --git a/apps/android/app/src/test/java/com/steipete/clawdis/node/bridge/BridgeSessionTest.kt b/apps/android/app/src/test/java/com/steipete/clawdis/node/bridge/BridgeSessionTest.kt new file mode 100644 index 000000000..b7d535f1a --- /dev/null +++ b/apps/android/app/src/test/java/com/steipete/clawdis/node/bridge/BridgeSessionTest.kt @@ -0,0 +1,224 @@ +package com.steipete.clawdis.node.bridge + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.BufferedReader +import java.io.BufferedWriter +import java.io.InputStreamReader +import java.io.OutputStreamWriter +import java.net.ServerSocket + +class BridgeSessionTest { + @Test + fun requestReturnsPayloadJson() = runBlocking { + val serverSocket = ServerSocket(0) + val port = serverSocket.localPort + + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val connected = CompletableDeferred() + + val session = + BridgeSession( + scope = scope, + onConnected = { _, _ -> connected.complete(Unit) }, + onDisconnected = { /* ignore */ }, + onEvent = { _, _ -> /* ignore */ }, + onInvoke = { BridgeSession.InvokeResult.ok(null) }, + ) + + val server = + async(Dispatchers.IO) { + serverSocket.use { ss -> + val sock = ss.accept() + sock.use { s -> + val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8)) + val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8)) + + val hello = reader.readLine() + assertTrue(hello.contains("\"type\":\"hello\"")) + writer.write("""{"type":"hello-ok","serverName":"Test Bridge"}""") + writer.write("\n") + writer.flush() + + val req = reader.readLine() + assertTrue(req.contains("\"type\":\"req\"")) + val id = extractJsonString(req, "id") + writer.write("""{"type":"res","id":"$id","ok":true,"payloadJSON":"{\"value\":123}"}""") + writer.write("\n") + writer.flush() + } + } + } + + session.connect( + endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port), + hello = + BridgeSession.Hello( + nodeId = "node-1", + displayName = "Android Node", + token = null, + platform = "Android", + version = "test", + ), + ) + + connected.await() + val payload = session.request(method = "health", paramsJson = null) + assertEquals("""{"value":123}""", payload) + server.await() + + session.disconnect() + scope.cancel() + } + + @Test + fun requestThrowsOnErrorResponse() = runBlocking { + val serverSocket = ServerSocket(0) + val port = serverSocket.localPort + + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val connected = CompletableDeferred() + + val session = + BridgeSession( + scope = scope, + onConnected = { _, _ -> connected.complete(Unit) }, + onDisconnected = { /* ignore */ }, + onEvent = { _, _ -> /* ignore */ }, + onInvoke = { BridgeSession.InvokeResult.ok(null) }, + ) + + val server = + async(Dispatchers.IO) { + serverSocket.use { ss -> + val sock = ss.accept() + sock.use { s -> + val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8)) + val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8)) + + reader.readLine() // hello + writer.write("""{"type":"hello-ok","serverName":"Test Bridge"}""") + writer.write("\n") + writer.flush() + + val req = reader.readLine() + val id = extractJsonString(req, "id") + writer.write( + """{"type":"res","id":"$id","ok":false,"error":{"code":"FORBIDDEN","message":"nope"}}""", + ) + writer.write("\n") + writer.flush() + } + } + } + + session.connect( + endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port), + hello = + BridgeSession.Hello( + nodeId = "node-1", + displayName = "Android Node", + token = null, + platform = "Android", + version = "test", + ), + ) + connected.await() + + try { + session.request(method = "chat.history", paramsJson = """{"sessionKey":"main"}""") + throw AssertionError("expected request() to throw") + } catch (e: IllegalStateException) { + assertTrue(e.message?.contains("FORBIDDEN: nope") == true) + } + server.await() + + session.disconnect() + scope.cancel() + } + + @Test + fun invokeResReturnsErrorWhenHandlerThrows() = runBlocking { + val serverSocket = ServerSocket(0) + val port = serverSocket.localPort + + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val connected = CompletableDeferred() + + val session = + BridgeSession( + scope = scope, + onConnected = { _, _ -> connected.complete(Unit) }, + onDisconnected = { /* ignore */ }, + onEvent = { _, _ -> /* ignore */ }, + onInvoke = { throw IllegalStateException("FOO_BAR: boom") }, + ) + + val invokeResLine = CompletableDeferred() + val server = + async(Dispatchers.IO) { + serverSocket.use { ss -> + val sock = ss.accept() + sock.use { s -> + val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8)) + val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8)) + + reader.readLine() // hello + writer.write("""{"type":"hello-ok","serverName":"Test Bridge"}""") + writer.write("\n") + writer.flush() + + // Ask the node to invoke something; handler will throw. + writer.write("""{"type":"invoke","id":"i1","command":"screen.snapshot","paramsJSON":null}""") + writer.write("\n") + writer.flush() + + val res = reader.readLine() + invokeResLine.complete(res) + } + } + } + + session.connect( + endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port), + hello = + BridgeSession.Hello( + nodeId = "node-1", + displayName = "Android Node", + token = null, + platform = "Android", + version = "test", + ), + ) + connected.await() + + // Give the reader loop time to process. + val line = invokeResLine.await() + assertTrue(line.contains("\"type\":\"invoke-res\"")) + assertTrue(line.contains("\"ok\":false")) + assertTrue(line.contains("\"code\":\"FOO_BAR\"")) + assertTrue(line.contains("\"message\":\"boom\"")) + server.await() + + session.disconnect() + scope.cancel() + } +} + +private fun extractJsonString(raw: String, key: String): String { + val needle = "\"$key\":\"" + val start = raw.indexOf(needle) + if (start < 0) throw IllegalArgumentException("missing key $key in $raw") + val from = start + needle.length + val end = raw.indexOf('"', from) + if (end < 0) throw IllegalArgumentException("unterminated string for $key in $raw") + return raw.substring(from, end) +}