Merge branch 'ci-hygiene-and-fmt' into trial-merge

This commit is contained in:
pj committed 2026-08-15 20:49:20 +05:30
commit 4caafafb1d
20 files changed
+541 -204

No files matched your search

+6 -1
View File
@@ -189,7 +189,12 @@ if [ "$platform" = "android" ]; then
*) echo "folio/android: the harness failed with exit $code" >&2; exit "$code" ;;
esac
if ! grep -q '"AddTransactionScreen"' "$trace"; then
echo "folio/android: the run never reached AddTransactionScreen, so it never got past login" >&2
# Where it stopped is a much longer question than this gate answers, so
# name the routes the trace holds and leave the diagnosis to the reader.
reached=$(grep -oE '"[A-Za-z0-9]+Screen"' "$trace" | tr -d '"' |
awk '!seen[$0]++' | paste -sd, -) || reached=""
echo "folio/android: the run never reached AddTransactionScreen over $steps steps" >&2
echo "folio/android: routes the trace does record: ${reached:-none}" >&2
exit 1
fi
echo "folio/android: healthy run over $steps steps, reached the transaction screen"
+12 -13
View File
@@ -19,25 +19,25 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v5
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: Set up JDK 17
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "17"
- name: Set up Android SDK
uses: android-actions/setup-android@v3
uses: android-actions/setup-android@v4
- name: Set up Node 22
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: "22"
cache: npm
@@ -49,7 +49,7 @@ jobs:
bun-version: "1.3.13"
- name: Cache bun store
uses: actions/cache@v4
uses: actions/cache@v6
with:
path: ~/.bun/install/cache
key: bun-${{ runner.os }}-${{ hashFiles('replay-ui/bun.lock') }}
@@ -74,7 +74,7 @@ jobs:
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
- name: Cache Gradle
uses: actions/cache@v4
uses: actions/cache@v6
with:
path: |
~/.gradle/caches
@@ -98,19 +98,18 @@ jobs:
browser:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v5
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
# Pin stable: the action's default (latest) pulls a dev Chromium whose
# remote-debugging socket is flaky under the driver, even though the
# browser otherwise launches headless.
# Pin stable: a dev Chromium's remote-debugging socket is flaky under
# the driver, even though the browser otherwise launches headless.
- name: Set up Chrome
uses: browser-actions/setup-chrome@v1
uses: browser-actions/setup-chrome@v2
with:
chrome-version: stable
+3 -3
View File
@@ -22,7 +22,7 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Install pandoc
run: sudo apt-get update && sudo apt-get install -y pandoc
@@ -30,7 +30,7 @@ jobs:
- name: Build site
run: make docs
- uses: actions/upload-pages-artifact@v3
- uses: actions/upload-pages-artifact@v5
with:
path: build/site
@@ -41,5 +41,5 @@ jobs:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/deploy-pages@v4
- uses: actions/deploy-pages@v5
id: deployment
+18 -18
View File
@@ -41,16 +41,16 @@ jobs:
if: ${{ inputs.platforms == 'all' || inputs.platforms == 'android' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v5
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: Set up the JDKs
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: temurin
# The metro gradle plugin folio builds with needs a 21 runtime; the
@@ -60,7 +60,7 @@ jobs:
21
- name: Set up Android SDK
uses: android-actions/setup-android@v3
uses: android-actions/setup-android@v4
- name: Set up bun
uses: oven-sh/setup-bun@v2
@@ -68,7 +68,7 @@ jobs:
bun-version: "1.3.13"
- name: Cache Gradle
uses: actions/cache@v4
uses: actions/cache@v6
with:
path: |
~/.gradle/caches
@@ -109,7 +109,7 @@ jobs:
- name: Upload the run
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: folio-android
path: runs/
@@ -120,10 +120,10 @@ jobs:
if: ${{ inputs.platforms == 'all' || inputs.platforms == 'ios' }}
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v5
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
@@ -139,7 +139,7 @@ jobs:
run: brew install facebook/fb/idb-companion xcodegen just
- name: Set up the JDKs
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: temurin
# The metro gradle plugin folio builds with needs a 21 runtime; the
@@ -152,13 +152,13 @@ jobs:
# project, which configures :app:androidApp and so needs an Android SDK
# even on this leg.
- name: Set up Android SDK
uses: android-actions/setup-android@v3
uses: android-actions/setup-android@v4
# Both asset tarballs are built by the prepare scripts, and the runner
# bundle is an xcodebuild of companion/Sources. Keyed on the scripts and
# the versions the Makefile embeds, so a later run reuses them.
- name: Cache the companion and runner bundles
uses: actions/cache@v4
uses: actions/cache@v6
with:
path: |
internal/driver/ioscompanion/companionassets/assets
@@ -196,7 +196,7 @@ jobs:
- name: Upload the run
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: folio-ios
path: runs/
@@ -207,16 +207,16 @@ jobs:
if: ${{ inputs.platforms == 'all' || inputs.platforms == 'web' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v5
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: Set up the JDKs
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: temurin
# The metro gradle plugin folio builds with needs a 21 runtime; the
@@ -231,7 +231,7 @@ jobs:
bun-version: "1.3.13"
- name: Cache Gradle
uses: actions/cache@v4
uses: actions/cache@v6
with:
path: |
~/.gradle/caches
@@ -241,7 +241,7 @@ jobs:
folio-gradle-${{ runner.os }}-
- name: Set up Chrome
uses: browser-actions/setup-chrome@v1
uses: browser-actions/setup-chrome@v2
with:
chrome-version: stable
@@ -270,7 +270,7 @@ jobs:
- name: Upload the run
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: folio-web
path: runs/
+8 -8
View File
@@ -22,7 +22,7 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: ${{ inputs.tag || github.ref }}
@@ -33,7 +33,7 @@ jobs:
echo "version=${raw#v}" >> "$GITHUB_OUTPUT"
- name: Set up Node 22
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: "22"
registry-url: "https://registry.npmjs.org"
@@ -65,28 +65,28 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: ${{ inputs.tag || github.ref }}
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: Set up JDK 17
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "17"
- name: Set up Android SDK
uses: android-actions/setup-android@v3
uses: android-actions/setup-android@v4
- name: Cache Gradle
uses: actions/cache@v4
uses: actions/cache@v6
with:
path: |
~/.gradle/caches
@@ -99,7 +99,7 @@ jobs:
run: make sidecar
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
uses: goreleaser/goreleaser-action@v7
with:
version: "~> v2"
args: release --clean
+4 -4
View File
@@ -27,10 +27,10 @@ jobs:
timeout-minutes: 45
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v5
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
@@ -43,7 +43,7 @@ jobs:
# Pinned stable plus the AppArmor sysctl: the same setup ci.yml's browser
# job needs to get headless Chrome up on ubuntu-latest.
- name: Set up Chrome
uses: browser-actions/setup-chrome@v1
uses: browser-actions/setup-chrome@v2
with:
chrome-version: stable
@@ -135,7 +135,7 @@ jobs:
- name: Upload runs
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: replay-ui-runs
path: runs/
+3 -1
View File
@@ -37,7 +37,9 @@ IOS_DEVICE="iPhone 15" just ios # pick a different simulator
`just ios` regenerates `app/iosApp/iosApp.xcodeproj` from `app/iosApp/project.yml`,
builds the KMP framework (`Shared.framework` from `:app:shared`), links it
into the SwiftUI host, installs, and launches.
into the SwiftUI host, uninstalls any previous copy, installs, and launches.
The uninstall matters: folio's signed-in session survives an install over the
top, so without it a run opens on the last run's Home screen.
## Web
+4
View File
@@ -130,6 +130,10 @@ ios:
-destination 'platform=iOS Simulator,name={{ios_device}}' \
-derivedDataPath app/iosApp/build \
build | tail -5
# Installing over the top keeps the data container, and folio's signed-in
# session with it, so a run started straight after would open on the last
# run's Home screen instead of Login and diverge at step 1.
xcrun simctl uninstall booted app.folio || true
xcrun simctl install booted "{{ios_app}}"
xcrun simctl launch booted app.folio
@@ -31,7 +31,10 @@ class DriverService(
private val launchedBundleId = AtomicReference<String?>(null)
private val snapshotLock = Any()
override fun launch(request: LaunchRequest, responseObserver: StreamObserver<Empty>) {
override fun launch(
request: LaunchRequest,
responseObserver: StreamObserver<Empty>,
) {
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<Empty>) {
override fun terminate(
request: Empty,
responseObserver: StreamObserver<Empty>,
) {
runRpc(responseObserver) {
launchedBundleId.get()?.let { backend.terminate(it) }
launchedBundleId.set(null)
@@ -54,42 +60,60 @@ class DriverService(
}
}
override fun doubleTap(request: Point, responseObserver: StreamObserver<Empty>) {
override fun doubleTap(
request: Point,
responseObserver: StreamObserver<Empty>,
) {
runRpc(responseObserver) {
backend.doubleTap(request.x, request.y)
Empty.getDefaultInstance()
}
}
override fun longPress(request: Point, responseObserver: StreamObserver<Empty>) {
override fun longPress(
request: Point,
responseObserver: StreamObserver<Empty>,
) {
runRpc(responseObserver) {
backend.longPress(request.x, request.y)
Empty.getDefaultInstance()
}
}
override fun tapSelector(request: Selector, responseObserver: StreamObserver<Empty>) {
override fun tapSelector(
request: Selector,
responseObserver: StreamObserver<Empty>,
) {
runRpc(responseObserver) {
backend.tapSelector(request.value)
Empty.getDefaultInstance()
}
}
override fun inputText(request: Text, responseObserver: StreamObserver<Empty>) {
override fun inputText(
request: Text,
responseObserver: StreamObserver<Empty>,
) {
runRpc(responseObserver) {
backend.inputText(request.value)
Empty.getDefaultInstance()
}
}
override fun eraseText(request: EraseTextRequest, responseObserver: StreamObserver<Empty>) {
override fun eraseText(
request: EraseTextRequest,
responseObserver: StreamObserver<Empty>,
) {
runRpc(responseObserver) {
backend.eraseText(request.characterCount)
Empty.getDefaultInstance()
}
}
override fun swipe(request: SwipeRequest, responseObserver: StreamObserver<Empty>) {
override fun swipe(
request: SwipeRequest,
responseObserver: StreamObserver<Empty>,
) {
runRpc(responseObserver) {
val from = request.from
val to = request.to
@@ -98,16 +122,25 @@ class DriverService(
}
}
override fun pressKey(request: PressKeyRequest, responseObserver: StreamObserver<Empty>) {
override fun pressKey(
request: PressKeyRequest,
responseObserver: StreamObserver<Empty>,
) {
runRpc(responseObserver) {
backend.pressKey(request.key)
Empty.getDefaultInstance()
}
}
override fun recentLogs(request: RecentLogsRequest, responseObserver: StreamObserver<LogEntries>) {
override fun recentLogs(
request: RecentLogsRequest,
responseObserver: StreamObserver<LogEntries>,
) {
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<Image>) {
override fun screenshot(
request: Empty,
responseObserver: StreamObserver<Image>,
) {
runRpc(responseObserver) {
val (png, width, height) = backend.screenshot()
Image.newBuilder()
@@ -134,18 +170,28 @@ class DriverService(
}
}
override fun hierarchy(request: Empty, responseObserver: StreamObserver<HierarchyJSON>) {
override fun hierarchy(
request: Empty,
responseObserver: StreamObserver<HierarchyJSON>,
) {
runRpc(responseObserver) {
HierarchyJSON.newBuilder().setJson(backend.hierarchy()).build()
}
}
override fun snapshot(request: Empty, responseObserver: StreamObserver<SnapshotResponse>) {
override fun snapshot(
request: Empty,
responseObserver: StreamObserver<SnapshotResponse>,
) {
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<Empty>) {
override fun waitForIdle(
request: Duration,
responseObserver: StreamObserver<Empty>,
) {
runRpc(responseObserver) {
backend.waitForIdle(request.millis)
Empty.getDefaultInstance()
}
}
override fun health(request: Empty, responseObserver: StreamObserver<HealthStatus>) {
override fun health(
request: Empty,
responseObserver: StreamObserver<HealthStatus>,
) {
runRpc(responseObserver) {
HealthStatus.newBuilder()
.setReady(backend.healthy())
@@ -174,9 +226,16 @@ class DriverService(
}
}
override fun metrics(request: MetricsRequest, responseObserver: StreamObserver<MetricsResponse>) {
override fun metrics(
request: MetricsRequest,
responseObserver: StreamObserver<MetricsResponse>,
) {
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(),
)
}
}
@@ -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<String>) {
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<String>) {
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()
}
@@ -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"),
)
}
}
@@ -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<String, String>,
children: List<maestro.TreeNode> = emptyList(),
): maestro.TreeNode = maestro.TreeNode(attributes = attrs.toMap().toMutableMap(), children = children)
): maestro.TreeNode = maestro.TreeNode(
attributes = attrs.toMap().toMutableMap(),
children = children,
)
}
@@ -20,20 +20,34 @@ import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
private data class Quintuple<A, B, C, D, E>(val a: A, val b: B, val c: C, val d: D, val e: E)
private data class Quintuple<A, B, C, D, E>(
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<io.grpc.StatusRuntimeException> {
client.inputText(Text.newBuilder().setValue("hello").build())
}
val thrown =
kotlin.test.assertFailsWith<io.grpc.StatusRuntimeException> {
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<io.grpc.StatusRuntimeException> {
client.inputText(Text.newBuilder().setValue("hello").build())
}
val thrown =
kotlin.test.assertFailsWith<io.grpc.StatusRuntimeException> {
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<Pair<Int, Int>>()
val backend = object : DriverBackend {
override fun launch(bundleId: String, clearState: Boolean, env: Map<String, String>) {}
override fun launch(
bundleId: String,
clearState: Boolean,
env: Map<String, String>,
) {}
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<ByteArray, Int, Int> = Triple(byteArrayOf(), 0, 0)
override fun screenshot(): Triple<ByteArray, Int, Int> =
Triple(byteArrayOf(), 0, 0)
override fun hierarchy(): String = "{}"
override fun recentLogs(sinceUnixMillis: Long, minLevel: String): List<LogLine> = emptyList()
override fun recentLogs(
sinceUnixMillis: Long,
minLevel: String,
): List<LogLine> = 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<ByteArray, Int, Int> = Triple(byteArrayOf(1, 2, 3), 1080, 2340)
override fun screenshot(): Triple<ByteArray, Int, Int> =
Triple(byteArrayOf(1, 2, 3), 1080, 2340)
}
val client = newClient(backend)
@@ -294,7 +359,13 @@ class DriverServiceTest {
@Test fun swipeForwardsEndpointsAndDuration() {
var observed: Quintuple<Int, Int, Int, Int, Long>? = 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<LogLine> {
return listOf(LogLine(1, "E", "AndroidRuntime", "boom"))
}
override fun recentLogs(
sinceUnixMillis: Long,
minLevel: String,
): List<LogLine> = 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)
}
@@ -32,7 +32,8 @@ class InputTextTest {
val fallback = listOf(
"Emergency Fund", "🙂🔥💸", " ", "\t\n", "'; DROP TABLE--",
"<script>alert(1)</script>", "../../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)
@@ -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)
}
}
@@ -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",
)
}
}
@@ -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")
@@ -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<String>()
val backend = object : DriverBackend {
override fun launch(bundleId: String, clearState: Boolean, env: Map<String, String>) {}
override fun launch(
bundleId: String,
clearState: Boolean,
env: Map<String, String>,
) {}
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<ByteArray, Int, Int> {
@@ -72,10 +92,14 @@ class SnapshotHandlerTest {
callOrder.add("hierarchy")
return "{}"
}
override fun recentLogs(sinceUnixMillis: Long, minLevel: String): List<LogLine> = emptyList()
override fun recentLogs(
sinceUnixMillis: Long,
minLevel: String,
): List<LogLine> = 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(),
@@ -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() {
@@ -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<io.grpc.StatusRuntimeException> {
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<IllegalStateException> {
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<IllegalArgumentException> {
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<io.grpc.StatusRuntimeException> {
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)