diff --git a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverService.kt b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverService.kt index cfe11d0..647655b 100644 --- a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverService.kt +++ b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverService.kt @@ -31,7 +31,10 @@ class DriverService( private val launchedBundleId = AtomicReference(null) private val snapshotLock = Any() - override fun launch(request: LaunchRequest, responseObserver: StreamObserver) { + override fun launch( + request: LaunchRequest, + responseObserver: StreamObserver, + ) { runRpc(responseObserver) { backend.launch(request.bundleId, request.clearState, request.envMap) launchedBundleId.set(request.bundleId) @@ -39,7 +42,10 @@ class DriverService( } } - override fun terminate(request: Empty, responseObserver: StreamObserver) { + override fun terminate( + request: Empty, + responseObserver: StreamObserver, + ) { runRpc(responseObserver) { launchedBundleId.get()?.let { backend.terminate(it) } launchedBundleId.set(null) @@ -54,42 +60,60 @@ class DriverService( } } - override fun doubleTap(request: Point, responseObserver: StreamObserver) { + override fun doubleTap( + request: Point, + responseObserver: StreamObserver, + ) { runRpc(responseObserver) { backend.doubleTap(request.x, request.y) Empty.getDefaultInstance() } } - override fun longPress(request: Point, responseObserver: StreamObserver) { + override fun longPress( + request: Point, + responseObserver: StreamObserver, + ) { runRpc(responseObserver) { backend.longPress(request.x, request.y) Empty.getDefaultInstance() } } - override fun tapSelector(request: Selector, responseObserver: StreamObserver) { + override fun tapSelector( + request: Selector, + responseObserver: StreamObserver, + ) { runRpc(responseObserver) { backend.tapSelector(request.value) Empty.getDefaultInstance() } } - override fun inputText(request: Text, responseObserver: StreamObserver) { + override fun inputText( + request: Text, + responseObserver: StreamObserver, + ) { runRpc(responseObserver) { backend.inputText(request.value) Empty.getDefaultInstance() } } - override fun eraseText(request: EraseTextRequest, responseObserver: StreamObserver) { + override fun eraseText( + request: EraseTextRequest, + responseObserver: StreamObserver, + ) { runRpc(responseObserver) { backend.eraseText(request.characterCount) Empty.getDefaultInstance() } } - override fun swipe(request: SwipeRequest, responseObserver: StreamObserver) { + override fun swipe( + request: SwipeRequest, + responseObserver: StreamObserver, + ) { runRpc(responseObserver) { val from = request.from val to = request.to @@ -98,16 +122,25 @@ class DriverService( } } - override fun pressKey(request: PressKeyRequest, responseObserver: StreamObserver) { + override fun pressKey( + request: PressKeyRequest, + responseObserver: StreamObserver, + ) { runRpc(responseObserver) { backend.pressKey(request.key) Empty.getDefaultInstance() } } - override fun recentLogs(request: RecentLogsRequest, responseObserver: StreamObserver) { + override fun recentLogs( + request: RecentLogsRequest, + responseObserver: StreamObserver, + ) { runRpc(responseObserver) { - val entries = backend.recentLogs(request.sinceUnixMillis, request.levelAtLeast) + val entries = backend.recentLogs( + request.sinceUnixMillis, + request.levelAtLeast, + ) val builder = LogEntries.newBuilder() for (entry in entries) { builder.addEntries( @@ -123,7 +156,10 @@ class DriverService( } } - override fun screenshot(request: Empty, responseObserver: StreamObserver) { + override fun screenshot( + request: Empty, + responseObserver: StreamObserver, + ) { runRpc(responseObserver) { val (png, width, height) = backend.screenshot() Image.newBuilder() @@ -134,18 +170,28 @@ class DriverService( } } - override fun hierarchy(request: Empty, responseObserver: StreamObserver) { + override fun hierarchy( + request: Empty, + responseObserver: StreamObserver, + ) { runRpc(responseObserver) { HierarchyJSON.newBuilder().setJson(backend.hierarchy()).build() } } - override fun snapshot(request: Empty, responseObserver: StreamObserver) { + override fun snapshot( + request: Empty, + responseObserver: StreamObserver, + ) { runRpc(responseObserver) { val sample = synchronized(snapshotLock) { backend.snapshot() } val (png, width, height) = sample.screenshot SnapshotResponse.newBuilder() - .setHierarchy(HierarchyJSON.newBuilder().setJson(sample.hierarchyJson).build()) + .setHierarchy( + HierarchyJSON.newBuilder() + .setJson(sample.hierarchyJson) + .build(), + ) .setScreenshot( Image.newBuilder() .setPng(ByteString.copyFrom(png)) @@ -157,14 +203,20 @@ class DriverService( } } - override fun waitForIdle(request: Duration, responseObserver: StreamObserver) { + override fun waitForIdle( + request: Duration, + responseObserver: StreamObserver, + ) { runRpc(responseObserver) { backend.waitForIdle(request.millis) Empty.getDefaultInstance() } } - override fun health(request: Empty, responseObserver: StreamObserver) { + override fun health( + request: Empty, + responseObserver: StreamObserver, + ) { runRpc(responseObserver) { HealthStatus.newBuilder() .setReady(backend.healthy()) @@ -174,9 +226,16 @@ class DriverService( } } - override fun metrics(request: MetricsRequest, responseObserver: StreamObserver) { + override fun metrics( + request: MetricsRequest, + responseObserver: StreamObserver, + ) { runRpc(responseObserver) { - val bundleId = if (request.bundleId.isNotEmpty()) request.bundleId else launchedBundleId.get().orEmpty() + val bundleId = if (request.bundleId.isNotEmpty()) { + request.bundleId + } else { + launchedBundleId.get().orEmpty() + } val sample = backend.metrics(bundleId) MetricsResponse.newBuilder() .setCpuPercent(sample.cpuPercent) @@ -191,7 +250,9 @@ class DriverService( // stale session, then closes the backend so the iOS XCTest runner process // dies with us instead of being orphaned. fun shutdown() { - runCatching { launchedBundleId.getAndSet(null)?.let { backend.terminate(it) } } + runCatching { + launchedBundleId.getAndSet(null)?.let { backend.terminate(it) } + } runCatching { backend.close() } } @@ -209,8 +270,10 @@ class DriverService( // failures that do not extend Exception, and an uncaught one // kills the RPC as a channel-level Unknown instead of a status // the runner can classify. - observer.onError(io.grpc.Status.INTERNAL.withDescription(cause.toString()) - .withCause(cause).asRuntimeException()) + observer.onError( + io.grpc.Status.INTERNAL.withDescription(cause.toString()) + .withCause(cause).asRuntimeException(), + ) } } diff --git a/sidecar/src/main/kotlin/dev/sanderling/sidecar/Main.kt b/sidecar/src/main/kotlin/dev/sanderling/sidecar/Main.kt index 39f9a5e..5692d29 100644 --- a/sidecar/src/main/kotlin/dev/sanderling/sidecar/Main.kt +++ b/sidecar/src/main/kotlin/dev/sanderling/sidecar/Main.kt @@ -13,14 +13,17 @@ class SidecarServer( private val shutdownLatch = CountDownLatch(1) fun start(): Int { - val server = NettyServerBuilder.forAddress(InetSocketAddress("127.0.0.1", port)) + val server = NettyServerBuilder + .forAddress(InetSocketAddress("127.0.0.1", port)) .addService(service) .build() server.start() grpcServer = server - Runtime.getRuntime().addShutdownHook(Thread { - stop() - }) + Runtime.getRuntime().addShutdownHook( + Thread { + stop() + }, + ) return server.port } @@ -48,23 +51,41 @@ class SidecarServer( // lost from run output. private fun quietExpectedDriverNoise() { org.apache.logging.log4j.core.config.Configurator.setLevel( - "util.CommandLineUtils", org.apache.logging.log4j.Level.OFF) + "util.CommandLineUtils", + org.apache.logging.log4j.Level.OFF, + ) org.apache.logging.log4j.core.config.Configurator.setLevel( - "xcuitest.XCTestDriverClient", org.apache.logging.log4j.Level.OFF) + "xcuitest.XCTestDriverClient", + org.apache.logging.log4j.Level.OFF, + ) org.apache.logging.log4j.core.config.Configurator.setLevel( - "maestro.drivers.AndroidDriver", org.apache.logging.log4j.Level.OFF) + "maestro.drivers.AndroidDriver", + org.apache.logging.log4j.Level.OFF, + ) } fun main(arguments: Array) { quietExpectedDriverNoise() val port = arguments.indexOf("--port").let { index -> - if (index >= 0 && index + 1 < arguments.size) arguments[index + 1].toInt() else 0 + if (index >= 0 && index + 1 < arguments.size) { + arguments[index + 1].toInt() + } else { + 0 + } } val platform = arguments.indexOf("--platform").let { index -> - if (index >= 0 && index + 1 < arguments.size) arguments[index + 1] else "android" + if (index >= 0 && index + 1 < arguments.size) { + arguments[index + 1] + } else { + "android" + } } val serial = arguments.indexOf("--serial").let { index -> - if (index >= 0 && index + 1 < arguments.size) arguments[index + 1] else null + if (index >= 0 && index + 1 < arguments.size) { + arguments[index + 1] + } else { + null + } } val backend: DriverBackend = when (platform) { @@ -74,7 +95,9 @@ fun main(arguments: Array) { val service = DriverService(platform = platform, backend = backend) val server = SidecarServer(port, service) val boundPort = server.start() - println("sanderling-sidecar listening on 127.0.0.1:$boundPort platform=$platform") + println( + "sanderling-sidecar listening on 127.0.0.1:$boundPort platform=$platform", + ) System.out.flush() server.awaitTermination() } diff --git a/sidecar/src/test/kotlin/dev/sanderling/sidecar/DadbTargetTest.kt b/sidecar/src/test/kotlin/dev/sanderling/sidecar/DadbTargetTest.kt index 7db71f8..d3ccbbc 100644 --- a/sidecar/src/test/kotlin/dev/sanderling/sidecar/DadbTargetTest.kt +++ b/sidecar/src/test/kotlin/dev/sanderling/sidecar/DadbTargetTest.kt @@ -10,7 +10,10 @@ class DadbTargetTest { } @Test fun hostPortSerialConnectsDirectly() { - assertEquals(DadbTarget.Tcp("192.168.1.243", 5555), dadbTargetFor("192.168.1.243:5555")) + assertEquals( + DadbTarget.Tcp("192.168.1.243", 5555), + dadbTargetFor("192.168.1.243:5555"), + ) } @Test fun usbSerialRoutesThroughAdbServer() { @@ -20,6 +23,9 @@ class DadbTargetTest { // A colon with a non-numeric port is a USB serial that merely contains a // colon, not a host:port, so it must route through the adb server. @Test fun colonWithNonNumericPortIsAServerSerial() { - assertEquals(DadbTarget.Server("emulator:5554x"), dadbTargetFor("emulator:5554x")) + assertEquals( + DadbTarget.Server("emulator:5554x"), + dadbTargetFor("emulator:5554x"), + ) } } diff --git a/sidecar/src/test/kotlin/dev/sanderling/sidecar/DeviceOutputParserTest.kt b/sidecar/src/test/kotlin/dev/sanderling/sidecar/DeviceOutputParserTest.kt index afbccbd..37bc806 100644 --- a/sidecar/src/test/kotlin/dev/sanderling/sidecar/DeviceOutputParserTest.kt +++ b/sidecar/src/test/kotlin/dev/sanderling/sidecar/DeviceOutputParserTest.kt @@ -24,7 +24,8 @@ class DeviceOutputParserTest { assertEquals("FATAL EXCEPTION: main", lines[0].message) val year = java.util.Calendar.getInstance().get(java.util.Calendar.YEAR) - val cal = java.util.Calendar.getInstance().apply { timeInMillis = lines[0].unixMillis } + val cal = java.util.Calendar.getInstance() + .apply { timeInMillis = lines[0].unixMillis } assertEquals(year, cal.get(java.util.Calendar.YEAR)) assertEquals(56, cal.get(java.util.Calendar.SECOND)) assertEquals(789, cal.get(java.util.Calendar.MILLISECOND)) @@ -51,7 +52,9 @@ class DeviceOutputParserTest { @Test fun parseCpuTicksReturnsNullOnTruncatedOrNonNumericStat() { assertNull(parseCpuTicks("1234 (app) S 1 2 3")) - assertNull(parseCpuTicks("1234 (app) S " + (1..12).joinToString(" ") { "x" })) + assertNull( + parseCpuTicks("1234 (app) S " + (1..12).joinToString(" ") { "x" }), + ) assertNull(parseCpuTicks("")) } @@ -79,8 +82,14 @@ class DeviceOutputParserTest { } @Test fun parseBoundsAcceptsWellFormedAndRejectsMalformed() { - assertEquals(listOf(0, 0, 1080, 2340), parseBounds("[0,0,1080,2340]")?.toList()) - assertEquals(listOf(-5, -10, 20, 30), parseBounds("[-5,-10,20,30]")?.toList()) + assertEquals( + listOf(0, 0, 1080, 2340), + parseBounds("[0,0,1080,2340]")?.toList(), + ) + assertEquals( + listOf(-5, -10, 20, 30), + parseBounds("[-5,-10,20,30]")?.toList(), + ) assertNull(parseBounds("[0,0,1080]")) assertNull(parseBounds("0,0,1,1")) assertNull(parseBounds("[0, 0, 1, 1]")) @@ -92,10 +101,14 @@ class DeviceOutputParserTest { "resource-id" to "com.example:id/loginButton", "bounds" to "[10,20,110,80]", ) - assertEquals(listOf(10, 20, 110, 80), findBoundsBySelector(tree, "id:loginButton")?.toList()) assertEquals( listOf(10, 20, 110, 80), - findBoundsBySelector(tree, "id:com.example:id/loginButton")?.toList(), + findBoundsBySelector(tree, "id:loginButton")?.toList(), + ) + assertEquals( + listOf(10, 20, 110, 80), + findBoundsBySelector(tree, "id:com.example:id/loginButton") + ?.toList(), ) } @@ -104,21 +117,36 @@ class DeviceOutputParserTest { "resource-id" to "root", children = listOf( node("text" to "Sign in", "bounds" to "[1,2,3,4]"), - node("content-desc" to "AccountCardRow-7", "bounds" to "[5,6,7,8]"), + node( + "content-desc" to "AccountCardRow-7", + "bounds" to "[5,6,7,8]", + ), ), ) - assertEquals(listOf(1, 2, 3, 4), findBoundsBySelector(tree, "text:Sign in")?.toList()) - assertEquals(listOf(5, 6, 7, 8), findBoundsBySelector(tree, "descPrefix:AccountCard")?.toList()) + assertEquals( + listOf(1, 2, 3, 4), + findBoundsBySelector(tree, "text:Sign in")?.toList(), + ) + assertEquals( + listOf(5, 6, 7, 8), + findBoundsBySelector(tree, "descPrefix:AccountCard")?.toList(), + ) } @Test fun findBoundsBySelectorReturnsNullForBadSelectorOrNoMatch() { - val tree = node("resource-id" to "com.example:id/x", "bounds" to "[0,0,1,1]") + val tree = node( + "resource-id" to "com.example:id/x", + "bounds" to "[0,0,1,1]", + ) assertNull(findBoundsBySelector(tree, "id")) assertNull(findBoundsBySelector(tree, "id:missing")) } @Test fun findBoundsBySelectorReturnsNullWhenMatchHasMalformedBounds() { - val tree = node("resource-id" to "com.example:id/x", "bounds" to "not-bounds") + val tree = node( + "resource-id" to "com.example:id/x", + "bounds" to "not-bounds", + ) assertNull(findBoundsBySelector(tree, "id:x")) } @@ -132,17 +160,26 @@ class DeviceOutputParserTest { private fun ihdr(width: Int, height: Int): ByteArray { val b = ByteArray(33) for (i in 0 until 8) b[8 + i] = 0 - b[12] = 'I'.code.toByte(); b[13] = 'H'.code.toByte() - b[14] = 'D'.code.toByte(); b[15] = 'R'.code.toByte() - b[16] = (width ushr 24).toByte(); b[17] = (width ushr 16).toByte() - b[18] = (width ushr 8).toByte(); b[19] = width.toByte() - b[20] = (height ushr 24).toByte(); b[21] = (height ushr 16).toByte() - b[22] = (height ushr 8).toByte(); b[23] = height.toByte() + b[12] = 'I'.code.toByte() + b[13] = 'H'.code.toByte() + b[14] = 'D'.code.toByte() + b[15] = 'R'.code.toByte() + b[16] = (width ushr 24).toByte() + b[17] = (width ushr 16).toByte() + b[18] = (width ushr 8).toByte() + b[19] = width.toByte() + b[20] = (height ushr 24).toByte() + b[21] = (height ushr 16).toByte() + b[22] = (height ushr 8).toByte() + b[23] = height.toByte() return b } private fun node( vararg attrs: Pair, children: List = emptyList(), - ): maestro.TreeNode = maestro.TreeNode(attributes = attrs.toMap().toMutableMap(), children = children) + ): maestro.TreeNode = maestro.TreeNode( + attributes = attrs.toMap().toMutableMap(), + children = children, + ) } diff --git a/sidecar/src/test/kotlin/dev/sanderling/sidecar/DriverServiceTest.kt b/sidecar/src/test/kotlin/dev/sanderling/sidecar/DriverServiceTest.kt index 65d046b..0187cca 100644 --- a/sidecar/src/test/kotlin/dev/sanderling/sidecar/DriverServiceTest.kt +++ b/sidecar/src/test/kotlin/dev/sanderling/sidecar/DriverServiceTest.kt @@ -20,20 +20,34 @@ import org.junit.Test import kotlin.test.assertEquals import kotlin.test.assertTrue -private data class Quintuple(val a: A, val b: B, val c: C, val d: D, val e: E) +private data class Quintuple( + val a: A, + val b: B, + val c: C, + val d: D, + val e: E, +) class DriverServiceTest { @get:Rule val grpcCleanup: GrpcCleanupRule = GrpcCleanupRule() - private fun newClient(backend: DriverBackend): DriverGrpc.DriverBlockingStub { + private fun newClient( + backend: DriverBackend, + ): DriverGrpc.DriverBlockingStub { val serverName = InProcessServerBuilder.generateName() val service = DriverService(platform = "android", backend = backend) grpcCleanup.register( - InProcessServerBuilder.forName(serverName).directExecutor().addService(service).build().start() + InProcessServerBuilder.forName(serverName) + .directExecutor() + .addService(service) + .build() + .start(), ) val channel: ManagedChannel = grpcCleanup.register( - InProcessChannelBuilder.forName(serverName).directExecutor().build() + InProcessChannelBuilder.forName(serverName) + .directExecutor() + .build(), ) return DriverGrpc.newBlockingStub(channel) } @@ -59,20 +73,32 @@ class DriverServiceTest { var terminated: String? = null var closed = false val backend = object : DriverBackend by StubDriverBackend("android") { - override fun terminate(bundleId: String) { terminated = bundleId } - override fun close() { closed = true } + override fun terminate(bundleId: String) { + terminated = bundleId + } + override fun close() { + closed = true + } } val serverName = InProcessServerBuilder.generateName() val service = DriverService(platform = "android", backend = backend) grpcCleanup.register( - InProcessServerBuilder.forName(serverName).directExecutor().addService(service).build().start() + InProcessServerBuilder.forName(serverName) + .directExecutor() + .addService(service) + .build() + .start(), ) val channel: ManagedChannel = grpcCleanup.register( - InProcessChannelBuilder.forName(serverName).directExecutor().build() + InProcessChannelBuilder.forName(serverName) + .directExecutor() + .build(), ) val client = DriverGrpc.newBlockingStub(channel) - client.launch(LaunchRequest.newBuilder().setBundleId("com.example").build()) + client.launch( + LaunchRequest.newBuilder().setBundleId("com.example").build(), + ) service.shutdown() assertEquals("com.example", terminated) @@ -83,8 +109,12 @@ class DriverServiceTest { var terminated: String? = null var closed = false val backend = object : DriverBackend by StubDriverBackend("android") { - override fun terminate(bundleId: String) { terminated = bundleId } - override fun close() { closed = true } + override fun terminate(bundleId: String) { + terminated = bundleId + } + override fun close() { + closed = true + } } val service = DriverService(platform = "android", backend = backend) @@ -128,17 +158,17 @@ class DriverServiceTest { // the runner can tell transient failures from fatal ones. @Test fun backendStatusCodePassesThrough() { val backend = object : DriverBackend by StubDriverBackend("android") { - override fun inputText(text: String) { + override fun inputText(text: String): Unit = throw io.grpc.Status.UNAVAILABLE .withDescription("connection dropped mid-action") .asRuntimeException() - } } val client = newClient(backend) - val thrown = kotlin.test.assertFailsWith { - client.inputText(Text.newBuilder().setValue("hello").build()) - } + val thrown = + kotlin.test.assertFailsWith { + client.inputText(Text.newBuilder().setValue("hello").build()) + } assertEquals(io.grpc.Status.Code.UNAVAILABLE, thrown.status.code) } @@ -147,17 +177,19 @@ class DriverServiceTest { // channel-level Unknown the runner cannot classify. @Test fun nonExceptionThrowableMapsToInternal() { val backend = object : DriverBackend by StubDriverBackend("android") { - override fun inputText(text: String) { + override fun inputText(text: String): Unit = throw Throwable("only one gesture can be performed at a time") - } } val client = newClient(backend) - val thrown = kotlin.test.assertFailsWith { - client.inputText(Text.newBuilder().setValue("hello").build()) - } + val thrown = + kotlin.test.assertFailsWith { + client.inputText(Text.newBuilder().setValue("hello").build()) + } assertEquals(io.grpc.Status.Code.INTERNAL, thrown.status.code) - assertTrue(thrown.status.description.orEmpty().contains("only one gesture")) + assertTrue( + thrown.status.description.orEmpty().contains("only one gesture"), + ) } @Test fun reapOrphanIosRunnersKillsStrayXcodebuildAndRunnerApp() { @@ -171,7 +203,16 @@ class DriverServiceTest { assertEquals("pkill", commands[0][0]) assertTrue(commands[0][2].contains("test-without-building")) assertTrue(commands[0][2].contains("UDID-1234")) - assertEquals(listOf("xcrun", "simctl", "terminate", "UDID-1234", IOS_XCTEST_RUNNER_BUNDLE_ID), commands[1]) + assertEquals( + listOf( + "xcrun", + "simctl", + "terminate", + "UDID-1234", + IOS_XCTEST_RUNNER_BUNDLE_ID, + ), + commands[1], + ) } @Test fun reapOrphanIosRunnersReportsNothingFound() { @@ -192,7 +233,9 @@ class DriverServiceTest { // still executing fails instead of queuing. val tapAction = { if (!inFlight.compareAndSet(false, true)) { - throw IllegalStateException("only one gesture can be performed at a time") + throw IllegalStateException( + "only one gesture can be performed at a time", + ) } invocations.incrementAndGet() Thread.sleep(150) @@ -211,7 +254,9 @@ class DriverServiceTest { val tapAction = { if (failedFirst.compareAndSet(false, true)) { Thread.sleep(60) - throw IllegalStateException("only one gesture can be performed at a time") + throw IllegalStateException( + "only one gesture can be performed at a time", + ) } landed.incrementAndGet() Unit @@ -226,21 +271,38 @@ class DriverServiceTest { // directly. val taps = mutableListOf>() val backend = object : DriverBackend { - override fun launch(bundleId: String, clearState: Boolean, env: Map) {} + override fun launch( + bundleId: String, + clearState: Boolean, + env: Map, + ) {} override fun terminate(bundleId: String) {} - override fun tap(x: Int, y: Int) { taps.add(x to y) } + override fun tap(x: Int, y: Int) { + taps.add(x to y) + } override fun tapSelector(selector: String) {} override fun inputText(text: String) {} override fun eraseText(characterCount: Int) {} - override fun swipe(fromX: Int, fromY: Int, toX: Int, toY: Int, durationMillis: Long) {} + override fun swipe( + fromX: Int, + fromY: Int, + toX: Int, + toY: Int, + durationMillis: Long, + ) {} override fun pressKey(key: String) {} override fun longPress(x: Int, y: Int) {} - override fun screenshot(): Triple = Triple(byteArrayOf(), 0, 0) + override fun screenshot(): Triple = + Triple(byteArrayOf(), 0, 0) override fun hierarchy(): String = "{}" - override fun recentLogs(sinceUnixMillis: Long, minLevel: String): List = emptyList() + override fun recentLogs( + sinceUnixMillis: Long, + minLevel: String, + ): List = emptyList() override fun waitForIdle(durationMillis: Long) {} override fun healthy(): Boolean = true - override fun metrics(bundleId: String): MetricsSample = MetricsSample(0.0, 0L, 0L) + override fun metrics(bundleId: String): MetricsSample = + MetricsSample(0.0, 0L, 0L) } val client = newClient(backend) @@ -252,13 +314,16 @@ class DriverServiceTest { val backend = StubDriverBackend("android") val client = newClient(backend) - client.eraseText(EraseTextRequest.newBuilder().setCharacterCount(11).build()) + client.eraseText( + EraseTextRequest.newBuilder().setCharacterCount(11).build(), + ) assertEquals(11, backend.lastEraseCharacterCount) } @Test fun screenshotReturnsBackendBytes() { val backend = object : DriverBackend by StubDriverBackend("android") { - override fun screenshot(): Triple = Triple(byteArrayOf(1, 2, 3), 1080, 2340) + override fun screenshot(): Triple = + Triple(byteArrayOf(1, 2, 3), 1080, 2340) } val client = newClient(backend) @@ -294,7 +359,13 @@ class DriverServiceTest { @Test fun swipeForwardsEndpointsAndDuration() { var observed: Quintuple? = null val backend = object : DriverBackend by StubDriverBackend("android") { - override fun swipe(fromX: Int, fromY: Int, toX: Int, toY: Int, durationMillis: Long) { + override fun swipe( + fromX: Int, + fromY: Int, + toX: Int, + toY: Int, + durationMillis: Long, + ) { observed = Quintuple(fromX, fromY, toX, toY, durationMillis) } } @@ -325,14 +396,18 @@ class DriverServiceTest { @Test fun recentLogsReturnsBackendEntries() { val backend = object : DriverBackend by StubDriverBackend("android") { - override fun recentLogs(sinceUnixMillis: Long, minLevel: String): List { - return listOf(LogLine(1, "E", "AndroidRuntime", "boom")) - } + override fun recentLogs( + sinceUnixMillis: Long, + minLevel: String, + ): List = listOf(LogLine(1, "E", "AndroidRuntime", "boom")) } val client = newClient(backend) val response = client.recentLogs( - RecentLogsRequest.newBuilder().setSinceUnixMillis(0).setLevelAtLeast("E").build(), + RecentLogsRequest.newBuilder() + .setSinceUnixMillis(0) + .setLevelAtLeast("E") + .build(), ) assertEquals(1, response.entriesCount) assertEquals("AndroidRuntime", response.getEntries(0).tag) @@ -351,7 +426,9 @@ class DriverServiceTest { } val client = newClient(backend) - client.launch(LaunchRequest.newBuilder().setBundleId("com.launched").build()) + client.launch( + LaunchRequest.newBuilder().setBundleId("com.launched").build(), + ) client.metrics(MetricsRequest.getDefaultInstance()) assertEquals("com.launched", sampled) @@ -367,8 +444,12 @@ class DriverServiceTest { } val client = newClient(backend) - client.launch(LaunchRequest.newBuilder().setBundleId("com.launched").build()) - client.metrics(MetricsRequest.newBuilder().setBundleId("com.other").build()) + client.launch( + LaunchRequest.newBuilder().setBundleId("com.launched").build(), + ) + client.metrics( + MetricsRequest.newBuilder().setBundleId("com.other").build(), + ) assertEquals("com.other", sampled) } diff --git a/sidecar/src/test/kotlin/dev/sanderling/sidecar/InputTextTest.kt b/sidecar/src/test/kotlin/dev/sanderling/sidecar/InputTextTest.kt index c466d00..52112fe 100644 --- a/sidecar/src/test/kotlin/dev/sanderling/sidecar/InputTextTest.kt +++ b/sidecar/src/test/kotlin/dev/sanderling/sidecar/InputTextTest.kt @@ -32,7 +32,8 @@ class InputTextTest { val fallback = listOf( "Emergency Fund", "🙂🔥💸", " ", "\t\n", "'; DROP TABLE--", "", "../../etc/passwd", "%s%n", "", - "-1", "-rf", // a leading dash could be read as an option by `input text` + // a leading dash could be read as an option by `input text` + "-1", "-rf", ) for (text in fallback) { assertTrue( @@ -190,12 +191,12 @@ class InputTextTest { @Test fun parseResumedPackageReadsEachResumedActivityWording() { val cases = mapOf( - " topResumedActivity=ActivityRecord{8b u0 app.folio/.MainActivity t42}" to - "app.folio", - " mResumedActivity: ActivityRecord{1c u0 com.example.app/.Home t9}" to - "com.example.app", - " ResumedActivity: ActivityRecord{2d u0 app.folio/com.folio.Detail t9}" to - "app.folio", + " topResumedActivity=ActivityRecord{8b u0 " + + "app.folio/.MainActivity t42}" to "app.folio", + " mResumedActivity: ActivityRecord{1c u0 " + + "com.example.app/.Home t9}" to "com.example.app", + " ResumedActivity: ActivityRecord{2d u0 " + + "app.folio/com.folio.Detail t9}" to "app.folio", ) for ((line, want) in cases) { assertEquals(want, parseResumedPackage(line), line) diff --git a/sidecar/src/test/kotlin/dev/sanderling/sidecar/ResolveActivityTest.kt b/sidecar/src/test/kotlin/dev/sanderling/sidecar/ResolveActivityTest.kt index 5ffaab8..77b25ab 100644 --- a/sidecar/src/test/kotlin/dev/sanderling/sidecar/ResolveActivityTest.kt +++ b/sidecar/src/test/kotlin/dev/sanderling/sidecar/ResolveActivityTest.kt @@ -12,28 +12,40 @@ class ResolveActivityTest { com.example.app/.MainActivity """.trimIndent() - val activity = StubDriverBackend.parseResolvedActivity("com.example.app", output) + val activity = StubDriverBackend.parseResolvedActivity( + "com.example.app", + output, + ) assertEquals(".MainActivity", activity) } @Test fun extractsFullyQualifiedActivity() { val output = "com.example.app/com.example.app.ui.LaunchActivity" - val activity = StubDriverBackend.parseResolvedActivity("com.example.app", output) + val activity = StubDriverBackend.parseResolvedActivity( + "com.example.app", + output, + ) assertEquals("com.example.app.ui.LaunchActivity", activity) } @Test fun returnsNullWhenPackageNotFound() { val output = "No activity found" - val activity = StubDriverBackend.parseResolvedActivity("com.example.app", output) + val activity = StubDriverBackend.parseResolvedActivity( + "com.example.app", + output, + ) assertNull(activity) } @Test fun doesNotMatchDifferentPackagePrefix() { val output = "other.pkg/.MainActivity" - val activity = StubDriverBackend.parseResolvedActivity("com.example.app", output) + val activity = StubDriverBackend.parseResolvedActivity( + "com.example.app", + output, + ) assertNull(activity) } } diff --git a/sidecar/src/test/kotlin/dev/sanderling/sidecar/RouteTransitionTest.kt b/sidecar/src/test/kotlin/dev/sanderling/sidecar/RouteTransitionTest.kt index 3c7c776..7836f66 100644 --- a/sidecar/src/test/kotlin/dev/sanderling/sidecar/RouteTransitionTest.kt +++ b/sidecar/src/test/kotlin/dev/sanderling/sidecar/RouteTransitionTest.kt @@ -13,10 +13,13 @@ class RouteTransitionTest { private fun screen(id: String, child: String = "") = """{"attributes":{"resource-id":"$id"},"children":[$child]}""" - private fun tree(vararg children: String) = - """{"attributes":{"resource-id":"root"},"children":[${children.joinToString(",")}]}""" + private fun tree(vararg children: String): String { + val joined = children.joinToString(",") + return """{"attributes":{"resource-id":"root"},"children":[$joined]}""" + } - private val crossFade = tree(screen("LedgerScreen"), screen("AddTransactionScreen")) + private val crossFade = + tree(screen("LedgerScreen"), screen("AddTransactionScreen")) private val landed = tree(screen("AddTransactionScreen")) @Test fun waitsForTheCrossFadeToLandAndReturnsTheLandedTree() { @@ -29,8 +32,15 @@ class RouteTransitionTest { reads++ if (reads <= 3) crossFade else landed } - assertTrue(reads > 3, "must keep reading until the fade lands, reads=$reads") - assertEquals(1, countRouteScreens(settled), "must return a tree with one route") + assertTrue( + reads > 3, + "must keep reading until the fade lands, reads=$reads", + ) + assertEquals( + 1, + countRouteScreens(settled), + "must return a tree with one route", + ) } @Test fun settledFrameCostsExactlyOneRead() { @@ -54,13 +64,21 @@ class RouteTransitionTest { // would burn the whole poll budget and still hand over a frame the // runner refuses to act on. val nested = tree(screen("HomeScreen", screen("HomeScreen"))) - assertEquals(1, countRouteScreens(nested), "the same id twice is one route") + assertEquals( + 1, + countRouteScreens(nested), + "the same id twice is one route", + ) var reads = 0 awaitSettledTree { reads++ nested } - assertEquals(1, reads, "a repeated route id must not be treated as a transition") + assertEquals( + 1, + reads, + "a repeated route id must not be treated as a transition", + ) } @Test fun aLayoutThatKeepsTwoRoutesIsBoundedByTheCap() { @@ -78,7 +96,11 @@ class RouteTransitionTest { elapsed < TRANSITION_POLL_CAP_MILLIS + 1000L, "must stop at the cap, elapsed=${elapsed}ms", ) - assertEquals(crossFade, settled, "the caller still gets a tree to record") + assertEquals( + crossFade, + settled, + "the caller still gets a tree to record", + ) } @Test fun capCoversTheNavHostFadePlusTheStreak() { @@ -89,20 +111,29 @@ class RouteTransitionTest { val fadeMillis = 700L val start = System.currentTimeMillis() val settled = awaitSettledTree { - if (System.currentTimeMillis() - start < fadeMillis) crossFade else landed + if (System.currentTimeMillis() - start < fadeMillis) { + crossFade + } else { + landed + } } val elapsed = System.currentTimeMillis() - start - assertEquals(landed, settled, "must hand back the landed tree, not the fade") + assertEquals( + landed, + settled, + "must hand back the landed tree, not the fade", + ) assertTrue( elapsed >= fadeMillis, "cannot have settled before the fade ended, elapsed=${elapsed}ms", ) assertTrue( elapsed < TRANSITION_POLL_CAP_MILLIS, - "the ${TRANSITION_POLL_CAP_MILLIS}ms cap has to leave room for a ${fadeMillis}ms " + - "fade and the ${TRANSITION_STABLE_STREAK_MILLIS}ms streak after it, but the " + - "wait ran to the cap instead, elapsed=${elapsed}ms", + "the ${TRANSITION_POLL_CAP_MILLIS}ms cap has to leave room for " + + "a ${fadeMillis}ms fade and the " + + "${TRANSITION_STABLE_STREAK_MILLIS}ms streak after it, but " + + "the wait ran to the cap instead, elapsed=${elapsed}ms", ) } } diff --git a/sidecar/src/test/kotlin/dev/sanderling/sidecar/SidecarServerTest.kt b/sidecar/src/test/kotlin/dev/sanderling/sidecar/SidecarServerTest.kt index f449c42..05e24a8 100644 --- a/sidecar/src/test/kotlin/dev/sanderling/sidecar/SidecarServerTest.kt +++ b/sidecar/src/test/kotlin/dev/sanderling/sidecar/SidecarServerTest.kt @@ -6,7 +6,10 @@ import kotlin.test.assertTrue class SidecarServerTest { @Test fun startBindsEphemeralPortAndStopReleasesIt() { - val server = SidecarServer(port = 0, service = DriverService(backend = StubDriverBackend("android"))) + val server = SidecarServer( + port = 0, + service = DriverService(backend = StubDriverBackend("android")), + ) val boundPort = server.start() try { assertTrue(boundPort > 0, "expected ephemeral port, got $boundPort") diff --git a/sidecar/src/test/kotlin/dev/sanderling/sidecar/SnapshotHandlerTest.kt b/sidecar/src/test/kotlin/dev/sanderling/sidecar/SnapshotHandlerTest.kt index 13c6e45..2252e4c 100644 --- a/sidecar/src/test/kotlin/dev/sanderling/sidecar/SnapshotHandlerTest.kt +++ b/sidecar/src/test/kotlin/dev/sanderling/sidecar/SnapshotHandlerTest.kt @@ -19,14 +19,22 @@ class SnapshotHandlerTest { @get:Rule val grpcCleanup: GrpcCleanupRule = GrpcCleanupRule() - private fun newClient(backend: DriverBackend): DriverGrpc.DriverBlockingStub { + private fun newClient( + backend: DriverBackend, + ): DriverGrpc.DriverBlockingStub { val serverName = InProcessServerBuilder.generateName() val service = DriverService(platform = "android", backend = backend) grpcCleanup.register( - InProcessServerBuilder.forName(serverName).directExecutor().addService(service).build().start(), + InProcessServerBuilder.forName(serverName) + .directExecutor() + .addService(service) + .build() + .start(), ) val channel: ManagedChannel = grpcCleanup.register( - InProcessChannelBuilder.forName(serverName).directExecutor().build(), + InProcessChannelBuilder.forName(serverName) + .directExecutor() + .build(), ) return DriverGrpc.newBlockingStub(channel) } @@ -37,8 +45,10 @@ class SnapshotHandlerTest { // forward those calls to the delegate, not these overrides. Override // snapshot() directly so the test exercises the wire path end-to-end. val backend = object : DriverBackend by StubDriverBackend("android") { - override fun snapshot(): SnapshotSample = - SnapshotSample("{\"x\":1}", Triple(byteArrayOf(7, 8, 9), 1080, 2340)) + override fun snapshot(): SnapshotSample = SnapshotSample( + "{\"x\":1}", + Triple(byteArrayOf(7, 8, 9), 1080, 2340), + ) } val client = newClient(backend) @@ -55,13 +65,23 @@ class SnapshotHandlerTest { // aligned with the final hierarchy snapshot the runner accepts. val callOrder = mutableListOf() val backend = object : DriverBackend { - override fun launch(bundleId: String, clearState: Boolean, env: Map) {} + override fun launch( + bundleId: String, + clearState: Boolean, + env: Map, + ) {} override fun terminate(bundleId: String) {} override fun tap(x: Int, y: Int) {} override fun tapSelector(selector: String) {} override fun inputText(text: String) {} override fun eraseText(characterCount: Int) {} - override fun swipe(fromX: Int, fromY: Int, toX: Int, toY: Int, durationMillis: Long) {} + override fun swipe( + fromX: Int, + fromY: Int, + toX: Int, + toY: Int, + durationMillis: Long, + ) {} override fun pressKey(key: String) {} override fun longPress(x: Int, y: Int) {} override fun screenshot(): Triple { @@ -72,10 +92,14 @@ class SnapshotHandlerTest { callOrder.add("hierarchy") return "{}" } - override fun recentLogs(sinceUnixMillis: Long, minLevel: String): List = emptyList() + override fun recentLogs( + sinceUnixMillis: Long, + minLevel: String, + ): List = emptyList() override fun waitForIdle(durationMillis: Long) {} override fun healthy(): Boolean = true - override fun metrics(bundleId: String): MetricsSample = MetricsSample(0.0, 0L, 0L) + override fun metrics(bundleId: String): MetricsSample = + MetricsSample(0.0, 0L, 0L) } backend.snapshot() assertEquals(listOf("hierarchy", "screenshot"), callOrder) @@ -88,7 +112,8 @@ class SnapshotHandlerTest { val maxObserved = AtomicInteger(0) val callCount = AtomicInteger(0) val lock = ReentrantLock() - val recordingBackend = object : DriverBackend by StubDriverBackend("android") { + val delegate = StubDriverBackend("android") + val recordingBackend = object : DriverBackend by delegate { override fun snapshot(): SnapshotSample { val now = inFlight.incrementAndGet() try { @@ -109,9 +134,13 @@ class SnapshotHandlerTest { // Use a real (multi-threaded) executor on the server side so the service // is not artificially serialized by directExecutor. val serverName = InProcessServerBuilder.generateName() - val service = DriverService(platform = "android", backend = recordingBackend) + val service = + DriverService(platform = "android", backend = recordingBackend) grpcCleanup.register( - InProcessServerBuilder.forName(serverName).addService(service).build().start(), + InProcessServerBuilder.forName(serverName) + .addService(service) + .build() + .start(), ) val channel: ManagedChannel = grpcCleanup.register( InProcessChannelBuilder.forName(serverName).build(), diff --git a/sidecar/src/test/kotlin/dev/sanderling/sidecar/StabilityPollTest.kt b/sidecar/src/test/kotlin/dev/sanderling/sidecar/StabilityPollTest.kt index c8da826..29d8c79 100644 --- a/sidecar/src/test/kotlin/dev/sanderling/sidecar/StabilityPollTest.kt +++ b/sidecar/src/test/kotlin/dev/sanderling/sidecar/StabilityPollTest.kt @@ -13,7 +13,10 @@ class StabilityPollTest { elapsed >= MIN_STABLE_STREAK_MILLIS, "must observe a stable streak of at least ${MIN_STABLE_STREAK_MILLIS}ms, elapsed=${elapsed}ms", ) - assertTrue(elapsed < 3000L, "should not run to cap when stable, elapsed=${elapsed}ms") + assertTrue( + elapsed < 3000L, + "should not run to cap when stable, elapsed=${elapsed}ms", + ) } @Test fun slowSnapshotReadsDoNotEatTheStreak() { @@ -38,8 +41,9 @@ class StabilityPollTest { val observedQuiet = sampleStarts.last() - sampleEnds.first() assertTrue( observedQuiet >= MIN_STABLE_STREAK_MILLIS, - "the poll returned having observed only ${observedQuiet}ms of quiet, not " + - "${MIN_STABLE_STREAK_MILLIS}ms; starts=$sampleStarts ends=$sampleEnds", + "the poll returned having observed only ${observedQuiet}ms of " + + "quiet, not ${MIN_STABLE_STREAK_MILLIS}ms; " + + "starts=$sampleStarts ends=$sampleEnds", ) assertTrue( sampleStarts.size >= 3, @@ -60,23 +64,27 @@ class StabilityPollTest { calls++ when { calls <= 2 -> "calm" + calls == 3 -> { transientAt = System.currentTimeMillis() "transient" } + else -> "stable" } } val sinceTransition = System.currentTimeMillis() - transientAt assertTrue( calls >= 8, - "after the transition the poll needs a fresh matching pair and then a full " + - "${MIN_STABLE_STREAK_MILLIS}ms of quiet, which is 8 samples, got $calls", + "after the transition the poll needs a fresh matching pair and " + + "then a full ${MIN_STABLE_STREAK_MILLIS}ms of quiet, which " + + "is 8 samples, got $calls", ) assertTrue( sinceTransition >= MIN_STABLE_STREAK_MILLIS, - "the calm prefix must not count: a full ${MIN_STABLE_STREAK_MILLIS}ms streak has to " + - "start over after the transition, returned ${sinceTransition}ms after it", + "the calm prefix must not count: a full " + + "${MIN_STABLE_STREAK_MILLIS}ms streak has to start over " + + "after the transition, returned ${sinceTransition}ms after it", ) } @@ -105,7 +113,10 @@ class StabilityPollTest { "frame-$calls" } val elapsed = System.currentTimeMillis() - start - assertTrue(elapsed in budget..(budget + 1000L), "expected to hit cap, elapsed=$elapsed") + assertTrue( + elapsed in budget..(budget + 1000L), + "expected to hit cap, elapsed=$elapsed", + ) } @Test fun zeroBudgetReturnsImmediately() { @@ -117,7 +128,8 @@ class StabilityPollTest { assertEquals(0, calls) } - @Test fun structuralHashIgnoresBoundsAndIdenticalForSemanticallyEqualTrees() { + @Test + fun structuralHashIgnoresBoundsAndIdenticalForSemanticallyEqualTrees() { val a = """ {"attributes":{"resource-id":"LoginScreen","bounds":"[0,0,1080,2340]"}, "children":[ @@ -130,13 +142,24 @@ class StabilityPollTest { {"attributes":{"resource-id":"LoginEmail","bounds":"[10,11,1070,101]","text":"a@b"},"children":[]} ]} """.trimIndent() - assertEquals(structuralHash(a), structuralHash(b), "bounds-only flicker must not change hash") + assertEquals( + structuralHash(a), + structuralHash(b), + "bounds-only flicker must not change hash", + ) } @Test fun structuralHashDiffersWhenContentChanges() { - val a = """{"attributes":{"resource-id":"LoginEmail","text":"a@b"},"children":[]}""" - val b = """{"attributes":{"resource-id":"LoginEmail","text":"c@d"},"children":[]}""" - assertTrue(structuralHash(a) != structuralHash(b), "text change must alter hash") + val a = """ + {"attributes":{"resource-id":"LoginEmail","text":"a@b"},"children":[]} + """.trimIndent() + val b = """ + {"attributes":{"resource-id":"LoginEmail","text":"c@d"},"children":[]} + """.trimIndent() + assertTrue( + structuralHash(a) != structuralHash(b), + "text change must alter hash", + ) } @Test fun stabilitySnapshotReturnsNullDuringNavHostCrossFade() { @@ -160,7 +183,10 @@ class StabilityPollTest { ]} """.trimIndent() val hash = stabilitySnapshot(singleScreen) - assertTrue(hash != null && hash.isNotBlank(), "single-screen tree must yield a hash, got $hash") + assertTrue( + hash != null && hash.isNotBlank(), + "single-screen tree must yield a hash, got $hash", + ) } @Test fun stabilitySnapshotIgnoresNonRouteAttributeValues() { @@ -173,7 +199,10 @@ class StabilityPollTest { {"attributes":{"text":"Welcome to MyScreen"},"children":[]} ]} """.trimIndent() - assertTrue(stabilitySnapshot(tree) != null, "non-route attribute must not be counted as a screen") + assertTrue( + stabilitySnapshot(tree) != null, + "non-route attribute must not be counted as a screen", + ) } @Test fun countRouteScreensCountsTestTagAndIdentifier() { diff --git a/sidecar/src/test/kotlin/dev/sanderling/sidecar/WdaRecoveryTest.kt b/sidecar/src/test/kotlin/dev/sanderling/sidecar/WdaRecoveryTest.kt index e3eb3d8..6062e41 100644 --- a/sidecar/src/test/kotlin/dev/sanderling/sidecar/WdaRecoveryTest.kt +++ b/sidecar/src/test/kotlin/dev/sanderling/sidecar/WdaRecoveryTest.kt @@ -12,14 +12,15 @@ import kotlin.test.assertTrue class WdaRecoveryTest { - private fun recovery( - isAlive: () -> Boolean, - restart: () -> Unit, - ) = WdaRecovery(isAlive = isAlive, restart = restart, log = {}) + private fun recovery(isAlive: () -> Boolean, restart: () -> Unit) = + WdaRecovery(isAlive = isAlive, restart = restart, log = {}) @Test fun aliveChannelSkipsRestartAndRetriesReads() { val restarts = AtomicInteger(0) - val recovery = recovery(isAlive = { true }, restart = { restarts.incrementAndGet() }) + val recovery = recovery( + isAlive = { true }, + restart = { restarts.incrementAndGet() }, + ) var calls = 0 val result = recovery.run(replay = true) { @@ -35,10 +36,15 @@ class WdaRecoveryTest { @Test fun aliveChannelSurfacesUnavailableForActions() { val restarts = AtomicInteger(0) - val recovery = recovery(isAlive = { true }, restart = { restarts.incrementAndGet() }) + val recovery = recovery( + isAlive = { true }, + restart = { restarts.incrementAndGet() }, + ) val thrown = assertFailsWith { - recovery.run(replay = false) { throw IOException("connection reset") } + recovery.run(replay = false) { + throw IOException("connection reset") + } } assertEquals(io.grpc.Status.Code.UNAVAILABLE, thrown.status.code) @@ -101,7 +107,9 @@ class WdaRecoveryTest { ) val thrown = assertFailsWith { - recovery.run(replay = true) { throw IOException("connection refused") } + recovery.run(replay = true) { + throw IOException("connection refused") + } } assertTrue(thrown.message.orEmpty().contains("WDA reconnect failed")) @@ -116,7 +124,9 @@ class WdaRecoveryTest { ) assertFailsWith { - recovery.run(replay = true) { throw IllegalArgumentException("bad selector") } + recovery.run(replay = true) { + throw IllegalArgumentException("bad selector") + } } assertEquals(0, restarts.get()) @@ -127,7 +137,9 @@ class WdaRecoveryTest { val recovery = recovery(isAlive = { true }, restart = {}) val thrown = assertFailsWith { - recovery.run(replay = true) { throw IOException("connection reset") } + recovery.run(replay = true) { + throw IOException("connection reset") + } } assertEquals(io.grpc.Status.Code.UNAVAILABLE, thrown.status.code)