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" ;; *) echo "folio/android: the harness failed with exit $code" >&2; exit "$code" ;;
esac esac
if ! grep -q '"AddTransactionScreen"' "$trace"; then 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 exit 1
fi fi
echo "folio/android: healthy run over $steps steps, reached the transaction screen" echo "folio/android: healthy run over $steps steps, reached the transaction screen"
+12 -13
View File
@@ -19,25 +19,25 @@ jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v5 uses: actions/setup-go@v7
with: with:
go-version-file: go.mod go-version-file: go.mod
cache: true cache: true
- name: Set up JDK 17 - name: Set up JDK 17
uses: actions/setup-java@v4 uses: actions/setup-java@v5
with: with:
distribution: temurin distribution: temurin
java-version: "17" java-version: "17"
- name: Set up Android SDK - name: Set up Android SDK
uses: android-actions/setup-android@v3 uses: android-actions/setup-android@v4
- name: Set up Node 22 - name: Set up Node 22
uses: actions/setup-node@v4 uses: actions/setup-node@v7
with: with:
node-version: "22" node-version: "22"
cache: npm cache: npm
@@ -49,7 +49,7 @@ jobs:
bun-version: "1.3.13" bun-version: "1.3.13"
- name: Cache bun store - name: Cache bun store
uses: actions/cache@v4 uses: actions/cache@v6
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: bun-${{ runner.os }}-${{ hashFiles('replay-ui/bun.lock') }} key: bun-${{ runner.os }}-${{ hashFiles('replay-ui/bun.lock') }}
@@ -74,7 +74,7 @@ jobs:
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
- name: Cache Gradle - name: Cache Gradle
uses: actions/cache@v4 uses: actions/cache@v6
with: with:
path: | path: |
~/.gradle/caches ~/.gradle/caches
@@ -98,19 +98,18 @@ jobs:
browser: browser:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v5 uses: actions/setup-go@v7
with: with:
go-version-file: go.mod go-version-file: go.mod
cache: true cache: true
# Pin stable: the action's default (latest) pulls a dev Chromium whose # Pin stable: a dev Chromium's remote-debugging socket is flaky under
# remote-debugging socket is flaky under the driver, even though the # the driver, even though the browser otherwise launches headless.
# browser otherwise launches headless.
- name: Set up Chrome - name: Set up Chrome
uses: browser-actions/setup-chrome@v1 uses: browser-actions/setup-chrome@v2
with: with:
chrome-version: stable chrome-version: stable
+3 -3
View File
@@ -22,7 +22,7 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
- name: Install pandoc - name: Install pandoc
run: sudo apt-get update && sudo apt-get install -y pandoc run: sudo apt-get update && sudo apt-get install -y pandoc
@@ -30,7 +30,7 @@ jobs:
- name: Build site - name: Build site
run: make docs run: make docs
- uses: actions/upload-pages-artifact@v3 - uses: actions/upload-pages-artifact@v5
with: with:
path: build/site path: build/site
@@ -41,5 +41,5 @@ jobs:
name: github-pages name: github-pages
url: ${{ steps.deployment.outputs.page_url }} url: ${{ steps.deployment.outputs.page_url }}
steps: steps:
- uses: actions/deploy-pages@v4 - uses: actions/deploy-pages@v5
id: deployment id: deployment
+18 -18
View File
@@ -41,16 +41,16 @@ jobs:
if: ${{ inputs.platforms == 'all' || inputs.platforms == 'android' }} if: ${{ inputs.platforms == 'all' || inputs.platforms == 'android' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v5 uses: actions/setup-go@v7
with: with:
go-version-file: go.mod go-version-file: go.mod
cache: true cache: true
- name: Set up the JDKs - name: Set up the JDKs
uses: actions/setup-java@v4 uses: actions/setup-java@v5
with: with:
distribution: temurin distribution: temurin
# The metro gradle plugin folio builds with needs a 21 runtime; the # The metro gradle plugin folio builds with needs a 21 runtime; the
@@ -60,7 +60,7 @@ jobs:
21 21
- name: Set up Android SDK - name: Set up Android SDK
uses: android-actions/setup-android@v3 uses: android-actions/setup-android@v4
- name: Set up bun - name: Set up bun
uses: oven-sh/setup-bun@v2 uses: oven-sh/setup-bun@v2
@@ -68,7 +68,7 @@ jobs:
bun-version: "1.3.13" bun-version: "1.3.13"
- name: Cache Gradle - name: Cache Gradle
uses: actions/cache@v4 uses: actions/cache@v6
with: with:
path: | path: |
~/.gradle/caches ~/.gradle/caches
@@ -109,7 +109,7 @@ jobs:
- name: Upload the run - name: Upload the run
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: folio-android name: folio-android
path: runs/ path: runs/
@@ -120,10 +120,10 @@ jobs:
if: ${{ inputs.platforms == 'all' || inputs.platforms == 'ios' }} if: ${{ inputs.platforms == 'all' || inputs.platforms == 'ios' }}
runs-on: macos-15 runs-on: macos-15
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v5 uses: actions/setup-go@v7
with: with:
go-version-file: go.mod go-version-file: go.mod
cache: true cache: true
@@ -139,7 +139,7 @@ jobs:
run: brew install facebook/fb/idb-companion xcodegen just run: brew install facebook/fb/idb-companion xcodegen just
- name: Set up the JDKs - name: Set up the JDKs
uses: actions/setup-java@v4 uses: actions/setup-java@v5
with: with:
distribution: temurin distribution: temurin
# The metro gradle plugin folio builds with needs a 21 runtime; the # 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 # project, which configures :app:androidApp and so needs an Android SDK
# even on this leg. # even on this leg.
- name: Set up Android SDK - 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 # Both asset tarballs are built by the prepare scripts, and the runner
# bundle is an xcodebuild of companion/Sources. Keyed on the scripts and # bundle is an xcodebuild of companion/Sources. Keyed on the scripts and
# the versions the Makefile embeds, so a later run reuses them. # the versions the Makefile embeds, so a later run reuses them.
- name: Cache the companion and runner bundles - name: Cache the companion and runner bundles
uses: actions/cache@v4 uses: actions/cache@v6
with: with:
path: | path: |
internal/driver/ioscompanion/companionassets/assets internal/driver/ioscompanion/companionassets/assets
@@ -196,7 +196,7 @@ jobs:
- name: Upload the run - name: Upload the run
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: folio-ios name: folio-ios
path: runs/ path: runs/
@@ -207,16 +207,16 @@ jobs:
if: ${{ inputs.platforms == 'all' || inputs.platforms == 'web' }} if: ${{ inputs.platforms == 'all' || inputs.platforms == 'web' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v5 uses: actions/setup-go@v7
with: with:
go-version-file: go.mod go-version-file: go.mod
cache: true cache: true
- name: Set up the JDKs - name: Set up the JDKs
uses: actions/setup-java@v4 uses: actions/setup-java@v5
with: with:
distribution: temurin distribution: temurin
# The metro gradle plugin folio builds with needs a 21 runtime; the # The metro gradle plugin folio builds with needs a 21 runtime; the
@@ -231,7 +231,7 @@ jobs:
bun-version: "1.3.13" bun-version: "1.3.13"
- name: Cache Gradle - name: Cache Gradle
uses: actions/cache@v4 uses: actions/cache@v6
with: with:
path: | path: |
~/.gradle/caches ~/.gradle/caches
@@ -241,7 +241,7 @@ jobs:
folio-gradle-${{ runner.os }}- folio-gradle-${{ runner.os }}-
- name: Set up Chrome - name: Set up Chrome
uses: browser-actions/setup-chrome@v1 uses: browser-actions/setup-chrome@v2
with: with:
chrome-version: stable chrome-version: stable
@@ -270,7 +270,7 @@ jobs:
- name: Upload the run - name: Upload the run
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: folio-web name: folio-web
path: runs/ path: runs/
+8 -8
View File
@@ -22,7 +22,7 @@ jobs:
env: env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
with: with:
ref: ${{ inputs.tag || github.ref }} ref: ${{ inputs.tag || github.ref }}
@@ -33,7 +33,7 @@ jobs:
echo "version=${raw#v}" >> "$GITHUB_OUTPUT" echo "version=${raw#v}" >> "$GITHUB_OUTPUT"
- name: Set up Node 22 - name: Set up Node 22
uses: actions/setup-node@v4 uses: actions/setup-node@v7
with: with:
node-version: "22" node-version: "22"
registry-url: "https://registry.npmjs.org" registry-url: "https://registry.npmjs.org"
@@ -65,28 +65,28 @@ jobs:
permissions: permissions:
contents: write contents: write
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
with: with:
ref: ${{ inputs.tag || github.ref }} ref: ${{ inputs.tag || github.ref }}
fetch-depth: 0 fetch-depth: 0
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v5 uses: actions/setup-go@v7
with: with:
go-version-file: go.mod go-version-file: go.mod
cache: true cache: true
- name: Set up JDK 17 - name: Set up JDK 17
uses: actions/setup-java@v4 uses: actions/setup-java@v5
with: with:
distribution: temurin distribution: temurin
java-version: "17" java-version: "17"
- name: Set up Android SDK - name: Set up Android SDK
uses: android-actions/setup-android@v3 uses: android-actions/setup-android@v4
- name: Cache Gradle - name: Cache Gradle
uses: actions/cache@v4 uses: actions/cache@v6
with: with:
path: | path: |
~/.gradle/caches ~/.gradle/caches
@@ -99,7 +99,7 @@ jobs:
run: make sidecar run: make sidecar
- name: Run GoReleaser - name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6 uses: goreleaser/goreleaser-action@v7
with: with:
version: "~> v2" version: "~> v2"
args: release --clean args: release --clean
+4 -4
View File
@@ -27,10 +27,10 @@ jobs:
timeout-minutes: 45 timeout-minutes: 45
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v5 uses: actions/setup-go@v7
with: with:
go-version-file: go.mod go-version-file: go.mod
cache: true cache: true
@@ -43,7 +43,7 @@ jobs:
# Pinned stable plus the AppArmor sysctl: the same setup ci.yml's browser # Pinned stable plus the AppArmor sysctl: the same setup ci.yml's browser
# job needs to get headless Chrome up on ubuntu-latest. # job needs to get headless Chrome up on ubuntu-latest.
- name: Set up Chrome - name: Set up Chrome
uses: browser-actions/setup-chrome@v1 uses: browser-actions/setup-chrome@v2
with: with:
chrome-version: stable chrome-version: stable
@@ -135,7 +135,7 @@ jobs:
- name: Upload runs - name: Upload runs
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: replay-ui-runs name: replay-ui-runs
path: 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`, `just ios` regenerates `app/iosApp/iosApp.xcodeproj` from `app/iosApp/project.yml`,
builds the KMP framework (`Shared.framework` from `:app:shared`), links it 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 ## Web
+4
View File
@@ -130,6 +130,10 @@ ios:
-destination 'platform=iOS Simulator,name={{ios_device}}' \ -destination 'platform=iOS Simulator,name={{ios_device}}' \
-derivedDataPath app/iosApp/build \ -derivedDataPath app/iosApp/build \
build | tail -5 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 install booted "{{ios_app}}"
xcrun simctl launch booted app.folio xcrun simctl launch booted app.folio
@@ -31,7 +31,10 @@ class DriverService(
private val launchedBundleId = AtomicReference<String?>(null) private val launchedBundleId = AtomicReference<String?>(null)
private val snapshotLock = Any() private val snapshotLock = Any()
override fun launch(request: LaunchRequest, responseObserver: StreamObserver<Empty>) { override fun launch(
request: LaunchRequest,
responseObserver: StreamObserver<Empty>,
) {
runRpc(responseObserver) { runRpc(responseObserver) {
backend.launch(request.bundleId, request.clearState, request.envMap) backend.launch(request.bundleId, request.clearState, request.envMap)
launchedBundleId.set(request.bundleId) 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) { runRpc(responseObserver) {
launchedBundleId.get()?.let { backend.terminate(it) } launchedBundleId.get()?.let { backend.terminate(it) }
launchedBundleId.set(null) 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) { runRpc(responseObserver) {
backend.doubleTap(request.x, request.y) backend.doubleTap(request.x, request.y)
Empty.getDefaultInstance() Empty.getDefaultInstance()
} }
} }
override fun longPress(request: Point, responseObserver: StreamObserver<Empty>) { override fun longPress(
request: Point,
responseObserver: StreamObserver<Empty>,
) {
runRpc(responseObserver) { runRpc(responseObserver) {
backend.longPress(request.x, request.y) backend.longPress(request.x, request.y)
Empty.getDefaultInstance() Empty.getDefaultInstance()
} }
} }
override fun tapSelector(request: Selector, responseObserver: StreamObserver<Empty>) { override fun tapSelector(
request: Selector,
responseObserver: StreamObserver<Empty>,
) {
runRpc(responseObserver) { runRpc(responseObserver) {
backend.tapSelector(request.value) backend.tapSelector(request.value)
Empty.getDefaultInstance() Empty.getDefaultInstance()
} }
} }
override fun inputText(request: Text, responseObserver: StreamObserver<Empty>) { override fun inputText(
request: Text,
responseObserver: StreamObserver<Empty>,
) {
runRpc(responseObserver) { runRpc(responseObserver) {
backend.inputText(request.value) backend.inputText(request.value)
Empty.getDefaultInstance() Empty.getDefaultInstance()
} }
} }
override fun eraseText(request: EraseTextRequest, responseObserver: StreamObserver<Empty>) { override fun eraseText(
request: EraseTextRequest,
responseObserver: StreamObserver<Empty>,
) {
runRpc(responseObserver) { runRpc(responseObserver) {
backend.eraseText(request.characterCount) backend.eraseText(request.characterCount)
Empty.getDefaultInstance() Empty.getDefaultInstance()
} }
} }
override fun swipe(request: SwipeRequest, responseObserver: StreamObserver<Empty>) { override fun swipe(
request: SwipeRequest,
responseObserver: StreamObserver<Empty>,
) {
runRpc(responseObserver) { runRpc(responseObserver) {
val from = request.from val from = request.from
val to = request.to 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) { runRpc(responseObserver) {
backend.pressKey(request.key) backend.pressKey(request.key)
Empty.getDefaultInstance() Empty.getDefaultInstance()
} }
} }
override fun recentLogs(request: RecentLogsRequest, responseObserver: StreamObserver<LogEntries>) { override fun recentLogs(
request: RecentLogsRequest,
responseObserver: StreamObserver<LogEntries>,
) {
runRpc(responseObserver) { runRpc(responseObserver) {
val entries = backend.recentLogs(request.sinceUnixMillis, request.levelAtLeast) val entries = backend.recentLogs(
request.sinceUnixMillis,
request.levelAtLeast,
)
val builder = LogEntries.newBuilder() val builder = LogEntries.newBuilder()
for (entry in entries) { for (entry in entries) {
builder.addEntries( 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) { runRpc(responseObserver) {
val (png, width, height) = backend.screenshot() val (png, width, height) = backend.screenshot()
Image.newBuilder() 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) { runRpc(responseObserver) {
HierarchyJSON.newBuilder().setJson(backend.hierarchy()).build() HierarchyJSON.newBuilder().setJson(backend.hierarchy()).build()
} }
} }
override fun snapshot(request: Empty, responseObserver: StreamObserver<SnapshotResponse>) { override fun snapshot(
request: Empty,
responseObserver: StreamObserver<SnapshotResponse>,
) {
runRpc(responseObserver) { runRpc(responseObserver) {
val sample = synchronized(snapshotLock) { backend.snapshot() } val sample = synchronized(snapshotLock) { backend.snapshot() }
val (png, width, height) = sample.screenshot val (png, width, height) = sample.screenshot
SnapshotResponse.newBuilder() SnapshotResponse.newBuilder()
.setHierarchy(HierarchyJSON.newBuilder().setJson(sample.hierarchyJson).build()) .setHierarchy(
HierarchyJSON.newBuilder()
.setJson(sample.hierarchyJson)
.build(),
)
.setScreenshot( .setScreenshot(
Image.newBuilder() Image.newBuilder()
.setPng(ByteString.copyFrom(png)) .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) { runRpc(responseObserver) {
backend.waitForIdle(request.millis) backend.waitForIdle(request.millis)
Empty.getDefaultInstance() Empty.getDefaultInstance()
} }
} }
override fun health(request: Empty, responseObserver: StreamObserver<HealthStatus>) { override fun health(
request: Empty,
responseObserver: StreamObserver<HealthStatus>,
) {
runRpc(responseObserver) { runRpc(responseObserver) {
HealthStatus.newBuilder() HealthStatus.newBuilder()
.setReady(backend.healthy()) .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) { 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) val sample = backend.metrics(bundleId)
MetricsResponse.newBuilder() MetricsResponse.newBuilder()
.setCpuPercent(sample.cpuPercent) .setCpuPercent(sample.cpuPercent)
@@ -191,7 +250,9 @@ class DriverService(
// stale session, then closes the backend so the iOS XCTest runner process // stale session, then closes the backend so the iOS XCTest runner process
// dies with us instead of being orphaned. // dies with us instead of being orphaned.
fun shutdown() { fun shutdown() {
runCatching { launchedBundleId.getAndSet(null)?.let { backend.terminate(it) } } runCatching {
launchedBundleId.getAndSet(null)?.let { backend.terminate(it) }
}
runCatching { backend.close() } runCatching { backend.close() }
} }
@@ -209,8 +270,10 @@ class DriverService(
// failures that do not extend Exception, and an uncaught one // failures that do not extend Exception, and an uncaught one
// kills the RPC as a channel-level Unknown instead of a status // kills the RPC as a channel-level Unknown instead of a status
// the runner can classify. // the runner can classify.
observer.onError(io.grpc.Status.INTERNAL.withDescription(cause.toString()) observer.onError(
.withCause(cause).asRuntimeException()) io.grpc.Status.INTERNAL.withDescription(cause.toString())
.withCause(cause).asRuntimeException(),
)
} }
} }
@@ -13,14 +13,17 @@ class SidecarServer(
private val shutdownLatch = CountDownLatch(1) private val shutdownLatch = CountDownLatch(1)
fun start(): Int { 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) .addService(service)
.build() .build()
server.start() server.start()
grpcServer = server grpcServer = server
Runtime.getRuntime().addShutdownHook(Thread { Runtime.getRuntime().addShutdownHook(
stop() Thread {
}) stop()
},
)
return server.port return server.port
} }
@@ -48,23 +51,41 @@ class SidecarServer(
// lost from run output. // lost from run output.
private fun quietExpectedDriverNoise() { private fun quietExpectedDriverNoise() {
org.apache.logging.log4j.core.config.Configurator.setLevel( 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( 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( 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>) { fun main(arguments: Array<String>) {
quietExpectedDriverNoise() quietExpectedDriverNoise()
val port = arguments.indexOf("--port").let { index -> 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 -> 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 -> 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) { val backend: DriverBackend = when (platform) {
@@ -74,7 +95,9 @@ fun main(arguments: Array<String>) {
val service = DriverService(platform = platform, backend = backend) val service = DriverService(platform = platform, backend = backend)
val server = SidecarServer(port, service) val server = SidecarServer(port, service)
val boundPort = server.start() 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() System.out.flush()
server.awaitTermination() server.awaitTermination()
} }
@@ -10,7 +10,10 @@ class DadbTargetTest {
} }
@Test fun hostPortSerialConnectsDirectly() { @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() { @Test fun usbSerialRoutesThroughAdbServer() {
@@ -20,6 +23,9 @@ class DadbTargetTest {
// A colon with a non-numeric port is a USB serial that merely contains a // 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. // colon, not a host:port, so it must route through the adb server.
@Test fun colonWithNonNumericPortIsAServerSerial() { @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) assertEquals("FATAL EXCEPTION: main", lines[0].message)
val year = java.util.Calendar.getInstance().get(java.util.Calendar.YEAR) 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(year, cal.get(java.util.Calendar.YEAR))
assertEquals(56, cal.get(java.util.Calendar.SECOND)) assertEquals(56, cal.get(java.util.Calendar.SECOND))
assertEquals(789, cal.get(java.util.Calendar.MILLISECOND)) assertEquals(789, cal.get(java.util.Calendar.MILLISECOND))
@@ -51,7 +52,9 @@ class DeviceOutputParserTest {
@Test fun parseCpuTicksReturnsNullOnTruncatedOrNonNumericStat() { @Test fun parseCpuTicksReturnsNullOnTruncatedOrNonNumericStat() {
assertNull(parseCpuTicks("1234 (app) S 1 2 3")) 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("")) assertNull(parseCpuTicks(""))
} }
@@ -79,8 +82,14 @@ class DeviceOutputParserTest {
} }
@Test fun parseBoundsAcceptsWellFormedAndRejectsMalformed() { @Test fun parseBoundsAcceptsWellFormedAndRejectsMalformed() {
assertEquals(listOf(0, 0, 1080, 2340), parseBounds("[0,0,1080,2340]")?.toList()) assertEquals(
assertEquals(listOf(-5, -10, 20, 30), parseBounds("[-5,-10,20,30]")?.toList()) 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,1080]"))
assertNull(parseBounds("0,0,1,1")) assertNull(parseBounds("0,0,1,1"))
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", "resource-id" to "com.example:id/loginButton",
"bounds" to "[10,20,110,80]", "bounds" to "[10,20,110,80]",
) )
assertEquals(listOf(10, 20, 110, 80), findBoundsBySelector(tree, "id:loginButton")?.toList())
assertEquals( assertEquals(
listOf(10, 20, 110, 80), 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", "resource-id" to "root",
children = listOf( children = listOf(
node("text" to "Sign in", "bounds" to "[1,2,3,4]"), 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(
assertEquals(listOf(5, 6, 7, 8), findBoundsBySelector(tree, "descPrefix:AccountCard")?.toList()) 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() { @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"))
assertNull(findBoundsBySelector(tree, "id:missing")) assertNull(findBoundsBySelector(tree, "id:missing"))
} }
@Test fun findBoundsBySelectorReturnsNullWhenMatchHasMalformedBounds() { @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")) assertNull(findBoundsBySelector(tree, "id:x"))
} }
@@ -132,17 +160,26 @@ class DeviceOutputParserTest {
private fun ihdr(width: Int, height: Int): ByteArray { private fun ihdr(width: Int, height: Int): ByteArray {
val b = ByteArray(33) val b = ByteArray(33)
for (i in 0 until 8) b[8 + i] = 0 for (i in 0 until 8) b[8 + i] = 0
b[12] = 'I'.code.toByte(); b[13] = 'H'.code.toByte() b[12] = 'I'.code.toByte()
b[14] = 'D'.code.toByte(); b[15] = 'R'.code.toByte() b[13] = 'H'.code.toByte()
b[16] = (width ushr 24).toByte(); b[17] = (width ushr 16).toByte() b[14] = 'D'.code.toByte()
b[18] = (width ushr 8).toByte(); b[19] = width.toByte() b[15] = 'R'.code.toByte()
b[20] = (height ushr 24).toByte(); b[21] = (height ushr 16).toByte() b[16] = (width ushr 24).toByte()
b[22] = (height ushr 8).toByte(); b[23] = height.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 return b
} }
private fun node( private fun node(
vararg attrs: Pair<String, String>, vararg attrs: Pair<String, String>,
children: List<maestro.TreeNode> = emptyList(), 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.assertEquals
import kotlin.test.assertTrue 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 { class DriverServiceTest {
@get:Rule val grpcCleanup: GrpcCleanupRule = GrpcCleanupRule() @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 serverName = InProcessServerBuilder.generateName()
val service = DriverService(platform = "android", backend = backend) val service = DriverService(platform = "android", backend = backend)
grpcCleanup.register( grpcCleanup.register(
InProcessServerBuilder.forName(serverName).directExecutor().addService(service).build().start() InProcessServerBuilder.forName(serverName)
.directExecutor()
.addService(service)
.build()
.start(),
) )
val channel: ManagedChannel = grpcCleanup.register( val channel: ManagedChannel = grpcCleanup.register(
InProcessChannelBuilder.forName(serverName).directExecutor().build() InProcessChannelBuilder.forName(serverName)
.directExecutor()
.build(),
) )
return DriverGrpc.newBlockingStub(channel) return DriverGrpc.newBlockingStub(channel)
} }
@@ -59,20 +73,32 @@ class DriverServiceTest {
var terminated: String? = null var terminated: String? = null
var closed = false var closed = false
val backend = object : DriverBackend by StubDriverBackend("android") { val backend = object : DriverBackend by StubDriverBackend("android") {
override fun terminate(bundleId: String) { terminated = bundleId } override fun terminate(bundleId: String) {
override fun close() { closed = true } terminated = bundleId
}
override fun close() {
closed = true
}
} }
val serverName = InProcessServerBuilder.generateName() val serverName = InProcessServerBuilder.generateName()
val service = DriverService(platform = "android", backend = backend) val service = DriverService(platform = "android", backend = backend)
grpcCleanup.register( grpcCleanup.register(
InProcessServerBuilder.forName(serverName).directExecutor().addService(service).build().start() InProcessServerBuilder.forName(serverName)
.directExecutor()
.addService(service)
.build()
.start(),
) )
val channel: ManagedChannel = grpcCleanup.register( val channel: ManagedChannel = grpcCleanup.register(
InProcessChannelBuilder.forName(serverName).directExecutor().build() InProcessChannelBuilder.forName(serverName)
.directExecutor()
.build(),
) )
val client = DriverGrpc.newBlockingStub(channel) val client = DriverGrpc.newBlockingStub(channel)
client.launch(LaunchRequest.newBuilder().setBundleId("com.example").build()) client.launch(
LaunchRequest.newBuilder().setBundleId("com.example").build(),
)
service.shutdown() service.shutdown()
assertEquals("com.example", terminated) assertEquals("com.example", terminated)
@@ -83,8 +109,12 @@ class DriverServiceTest {
var terminated: String? = null var terminated: String? = null
var closed = false var closed = false
val backend = object : DriverBackend by StubDriverBackend("android") { val backend = object : DriverBackend by StubDriverBackend("android") {
override fun terminate(bundleId: String) { terminated = bundleId } override fun terminate(bundleId: String) {
override fun close() { closed = true } terminated = bundleId
}
override fun close() {
closed = true
}
} }
val service = DriverService(platform = "android", backend = backend) val service = DriverService(platform = "android", backend = backend)
@@ -128,17 +158,17 @@ class DriverServiceTest {
// the runner can tell transient failures from fatal ones. // the runner can tell transient failures from fatal ones.
@Test fun backendStatusCodePassesThrough() { @Test fun backendStatusCodePassesThrough() {
val backend = object : DriverBackend by StubDriverBackend("android") { val backend = object : DriverBackend by StubDriverBackend("android") {
override fun inputText(text: String) { override fun inputText(text: String): Unit =
throw io.grpc.Status.UNAVAILABLE throw io.grpc.Status.UNAVAILABLE
.withDescription("connection dropped mid-action") .withDescription("connection dropped mid-action")
.asRuntimeException() .asRuntimeException()
}
} }
val client = newClient(backend) val client = newClient(backend)
val thrown = kotlin.test.assertFailsWith<io.grpc.StatusRuntimeException> { val thrown =
client.inputText(Text.newBuilder().setValue("hello").build()) kotlin.test.assertFailsWith<io.grpc.StatusRuntimeException> {
} client.inputText(Text.newBuilder().setValue("hello").build())
}
assertEquals(io.grpc.Status.Code.UNAVAILABLE, thrown.status.code) assertEquals(io.grpc.Status.Code.UNAVAILABLE, thrown.status.code)
} }
@@ -147,17 +177,19 @@ class DriverServiceTest {
// channel-level Unknown the runner cannot classify. // channel-level Unknown the runner cannot classify.
@Test fun nonExceptionThrowableMapsToInternal() { @Test fun nonExceptionThrowableMapsToInternal() {
val backend = object : DriverBackend by StubDriverBackend("android") { 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") throw Throwable("only one gesture can be performed at a time")
}
} }
val client = newClient(backend) val client = newClient(backend)
val thrown = kotlin.test.assertFailsWith<io.grpc.StatusRuntimeException> { val thrown =
client.inputText(Text.newBuilder().setValue("hello").build()) kotlin.test.assertFailsWith<io.grpc.StatusRuntimeException> {
} client.inputText(Text.newBuilder().setValue("hello").build())
}
assertEquals(io.grpc.Status.Code.INTERNAL, thrown.status.code) 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() { @Test fun reapOrphanIosRunnersKillsStrayXcodebuildAndRunnerApp() {
@@ -171,7 +203,16 @@ class DriverServiceTest {
assertEquals("pkill", commands[0][0]) assertEquals("pkill", commands[0][0])
assertTrue(commands[0][2].contains("test-without-building")) assertTrue(commands[0][2].contains("test-without-building"))
assertTrue(commands[0][2].contains("UDID-1234")) 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() { @Test fun reapOrphanIosRunnersReportsNothingFound() {
@@ -192,7 +233,9 @@ class DriverServiceTest {
// still executing fails instead of queuing. // still executing fails instead of queuing.
val tapAction = { val tapAction = {
if (!inFlight.compareAndSet(false, true)) { 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() invocations.incrementAndGet()
Thread.sleep(150) Thread.sleep(150)
@@ -211,7 +254,9 @@ class DriverServiceTest {
val tapAction = { val tapAction = {
if (failedFirst.compareAndSet(false, true)) { if (failedFirst.compareAndSet(false, true)) {
Thread.sleep(60) 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() landed.incrementAndGet()
Unit Unit
@@ -226,21 +271,38 @@ class DriverServiceTest {
// directly. // directly.
val taps = mutableListOf<Pair<Int, Int>>() val taps = mutableListOf<Pair<Int, Int>>()
val backend = object : DriverBackend { 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 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 tapSelector(selector: String) {}
override fun inputText(text: String) {} override fun inputText(text: String) {}
override fun eraseText(characterCount: Int) {} 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 pressKey(key: String) {}
override fun longPress(x: Int, y: Int) {} 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 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 waitForIdle(durationMillis: Long) {}
override fun healthy(): Boolean = true 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) val client = newClient(backend)
@@ -252,13 +314,16 @@ class DriverServiceTest {
val backend = StubDriverBackend("android") val backend = StubDriverBackend("android")
val client = newClient(backend) val client = newClient(backend)
client.eraseText(EraseTextRequest.newBuilder().setCharacterCount(11).build()) client.eraseText(
EraseTextRequest.newBuilder().setCharacterCount(11).build(),
)
assertEquals(11, backend.lastEraseCharacterCount) assertEquals(11, backend.lastEraseCharacterCount)
} }
@Test fun screenshotReturnsBackendBytes() { @Test fun screenshotReturnsBackendBytes() {
val backend = object : DriverBackend by StubDriverBackend("android") { 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) val client = newClient(backend)
@@ -294,7 +359,13 @@ class DriverServiceTest {
@Test fun swipeForwardsEndpointsAndDuration() { @Test fun swipeForwardsEndpointsAndDuration() {
var observed: Quintuple<Int, Int, Int, Int, Long>? = null var observed: Quintuple<Int, Int, Int, Int, Long>? = null
val backend = object : DriverBackend by StubDriverBackend("android") { 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) observed = Quintuple(fromX, fromY, toX, toY, durationMillis)
} }
} }
@@ -325,14 +396,18 @@ class DriverServiceTest {
@Test fun recentLogsReturnsBackendEntries() { @Test fun recentLogsReturnsBackendEntries() {
val backend = object : DriverBackend by StubDriverBackend("android") { val backend = object : DriverBackend by StubDriverBackend("android") {
override fun recentLogs(sinceUnixMillis: Long, minLevel: String): List<LogLine> { override fun recentLogs(
return listOf(LogLine(1, "E", "AndroidRuntime", "boom")) sinceUnixMillis: Long,
} minLevel: String,
): List<LogLine> = listOf(LogLine(1, "E", "AndroidRuntime", "boom"))
} }
val client = newClient(backend) val client = newClient(backend)
val response = client.recentLogs( val response = client.recentLogs(
RecentLogsRequest.newBuilder().setSinceUnixMillis(0).setLevelAtLeast("E").build(), RecentLogsRequest.newBuilder()
.setSinceUnixMillis(0)
.setLevelAtLeast("E")
.build(),
) )
assertEquals(1, response.entriesCount) assertEquals(1, response.entriesCount)
assertEquals("AndroidRuntime", response.getEntries(0).tag) assertEquals("AndroidRuntime", response.getEntries(0).tag)
@@ -351,7 +426,9 @@ class DriverServiceTest {
} }
val client = newClient(backend) val client = newClient(backend)
client.launch(LaunchRequest.newBuilder().setBundleId("com.launched").build()) client.launch(
LaunchRequest.newBuilder().setBundleId("com.launched").build(),
)
client.metrics(MetricsRequest.getDefaultInstance()) client.metrics(MetricsRequest.getDefaultInstance())
assertEquals("com.launched", sampled) assertEquals("com.launched", sampled)
@@ -367,8 +444,12 @@ class DriverServiceTest {
} }
val client = newClient(backend) val client = newClient(backend)
client.launch(LaunchRequest.newBuilder().setBundleId("com.launched").build()) client.launch(
client.metrics(MetricsRequest.newBuilder().setBundleId("com.other").build()) LaunchRequest.newBuilder().setBundleId("com.launched").build(),
)
client.metrics(
MetricsRequest.newBuilder().setBundleId("com.other").build(),
)
assertEquals("com.other", sampled) assertEquals("com.other", sampled)
} }
@@ -32,7 +32,8 @@ class InputTextTest {
val fallback = listOf( val fallback = listOf(
"Emergency Fund", "🙂🔥💸", " ", "\t\n", "'; DROP TABLE--", "Emergency Fund", "🙂🔥💸", " ", "\t\n", "'; DROP TABLE--",
"<script>alert(1)</script>", "../../etc/passwd", "%s%n", "", "<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) { for (text in fallback) {
assertTrue( assertTrue(
@@ -190,12 +191,12 @@ class InputTextTest {
@Test fun parseResumedPackageReadsEachResumedActivityWording() { @Test fun parseResumedPackageReadsEachResumedActivityWording() {
val cases = mapOf( val cases = mapOf(
" topResumedActivity=ActivityRecord{8b u0 app.folio/.MainActivity t42}" to " topResumedActivity=ActivityRecord{8b u0 " +
"app.folio", "app.folio/.MainActivity t42}" to "app.folio",
" mResumedActivity: ActivityRecord{1c u0 com.example.app/.Home t9}" to " mResumedActivity: ActivityRecord{1c u0 " +
"com.example.app", "com.example.app/.Home t9}" to "com.example.app",
" ResumedActivity: ActivityRecord{2d u0 app.folio/com.folio.Detail t9}" to " ResumedActivity: ActivityRecord{2d u0 " +
"app.folio", "app.folio/com.folio.Detail t9}" to "app.folio",
) )
for ((line, want) in cases) { for ((line, want) in cases) {
assertEquals(want, parseResumedPackage(line), line) assertEquals(want, parseResumedPackage(line), line)
@@ -12,28 +12,40 @@ class ResolveActivityTest {
com.example.app/.MainActivity com.example.app/.MainActivity
""".trimIndent() """.trimIndent()
val activity = StubDriverBackend.parseResolvedActivity("com.example.app", output) val activity = StubDriverBackend.parseResolvedActivity(
"com.example.app",
output,
)
assertEquals(".MainActivity", activity) assertEquals(".MainActivity", activity)
} }
@Test fun extractsFullyQualifiedActivity() { @Test fun extractsFullyQualifiedActivity() {
val output = "com.example.app/com.example.app.ui.LaunchActivity" 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) assertEquals("com.example.app.ui.LaunchActivity", activity)
} }
@Test fun returnsNullWhenPackageNotFound() { @Test fun returnsNullWhenPackageNotFound() {
val output = "No activity found" val output = "No activity found"
val activity = StubDriverBackend.parseResolvedActivity("com.example.app", output) val activity = StubDriverBackend.parseResolvedActivity(
"com.example.app",
output,
)
assertNull(activity) assertNull(activity)
} }
@Test fun doesNotMatchDifferentPackagePrefix() { @Test fun doesNotMatchDifferentPackagePrefix() {
val output = "other.pkg/.MainActivity" val output = "other.pkg/.MainActivity"
val activity = StubDriverBackend.parseResolvedActivity("com.example.app", output) val activity = StubDriverBackend.parseResolvedActivity(
"com.example.app",
output,
)
assertNull(activity) assertNull(activity)
} }
} }
@@ -13,10 +13,13 @@ class RouteTransitionTest {
private fun screen(id: String, child: String = "") = private fun screen(id: String, child: String = "") =
"""{"attributes":{"resource-id":"$id"},"children":[$child]}""" """{"attributes":{"resource-id":"$id"},"children":[$child]}"""
private fun tree(vararg children: String) = private fun tree(vararg children: String): String {
"""{"attributes":{"resource-id":"root"},"children":[${children.joinToString(",")}]}""" 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")) private val landed = tree(screen("AddTransactionScreen"))
@Test fun waitsForTheCrossFadeToLandAndReturnsTheLandedTree() { @Test fun waitsForTheCrossFadeToLandAndReturnsTheLandedTree() {
@@ -29,8 +32,15 @@ class RouteTransitionTest {
reads++ reads++
if (reads <= 3) crossFade else landed if (reads <= 3) crossFade else landed
} }
assertTrue(reads > 3, "must keep reading until the fade lands, reads=$reads") assertTrue(
assertEquals(1, countRouteScreens(settled), "must return a tree with one route") 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() { @Test fun settledFrameCostsExactlyOneRead() {
@@ -54,13 +64,21 @@ class RouteTransitionTest {
// would burn the whole poll budget and still hand over a frame the // would burn the whole poll budget and still hand over a frame the
// runner refuses to act on. // runner refuses to act on.
val nested = tree(screen("HomeScreen", screen("HomeScreen"))) 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 var reads = 0
awaitSettledTree { awaitSettledTree {
reads++ reads++
nested 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() { @Test fun aLayoutThatKeepsTwoRoutesIsBoundedByTheCap() {
@@ -78,7 +96,11 @@ class RouteTransitionTest {
elapsed < TRANSITION_POLL_CAP_MILLIS + 1000L, elapsed < TRANSITION_POLL_CAP_MILLIS + 1000L,
"must stop at the cap, elapsed=${elapsed}ms", "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() { @Test fun capCoversTheNavHostFadePlusTheStreak() {
@@ -89,20 +111,29 @@ class RouteTransitionTest {
val fadeMillis = 700L val fadeMillis = 700L
val start = System.currentTimeMillis() val start = System.currentTimeMillis()
val settled = awaitSettledTree { val settled = awaitSettledTree {
if (System.currentTimeMillis() - start < fadeMillis) crossFade else landed if (System.currentTimeMillis() - start < fadeMillis) {
crossFade
} else {
landed
}
} }
val elapsed = System.currentTimeMillis() - start 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( assertTrue(
elapsed >= fadeMillis, elapsed >= fadeMillis,
"cannot have settled before the fade ended, elapsed=${elapsed}ms", "cannot have settled before the fade ended, elapsed=${elapsed}ms",
) )
assertTrue( assertTrue(
elapsed < TRANSITION_POLL_CAP_MILLIS, elapsed < TRANSITION_POLL_CAP_MILLIS,
"the ${TRANSITION_POLL_CAP_MILLIS}ms cap has to leave room for a ${fadeMillis}ms " + "the ${TRANSITION_POLL_CAP_MILLIS}ms cap has to leave room for " +
"fade and the ${TRANSITION_STABLE_STREAK_MILLIS}ms streak after it, but the " + "a ${fadeMillis}ms fade and the " +
"wait ran to the cap instead, elapsed=${elapsed}ms", "${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 { class SidecarServerTest {
@Test @Test
fun startBindsEphemeralPortAndStopReleasesIt() { 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() val boundPort = server.start()
try { try {
assertTrue(boundPort > 0, "expected ephemeral port, got $boundPort") assertTrue(boundPort > 0, "expected ephemeral port, got $boundPort")
@@ -19,14 +19,22 @@ class SnapshotHandlerTest {
@get:Rule val grpcCleanup: GrpcCleanupRule = GrpcCleanupRule() @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 serverName = InProcessServerBuilder.generateName()
val service = DriverService(platform = "android", backend = backend) val service = DriverService(platform = "android", backend = backend)
grpcCleanup.register( grpcCleanup.register(
InProcessServerBuilder.forName(serverName).directExecutor().addService(service).build().start(), InProcessServerBuilder.forName(serverName)
.directExecutor()
.addService(service)
.build()
.start(),
) )
val channel: ManagedChannel = grpcCleanup.register( val channel: ManagedChannel = grpcCleanup.register(
InProcessChannelBuilder.forName(serverName).directExecutor().build(), InProcessChannelBuilder.forName(serverName)
.directExecutor()
.build(),
) )
return DriverGrpc.newBlockingStub(channel) return DriverGrpc.newBlockingStub(channel)
} }
@@ -37,8 +45,10 @@ class SnapshotHandlerTest {
// forward those calls to the delegate, not these overrides. Override // forward those calls to the delegate, not these overrides. Override
// snapshot() directly so the test exercises the wire path end-to-end. // snapshot() directly so the test exercises the wire path end-to-end.
val backend = object : DriverBackend by StubDriverBackend("android") { val backend = object : DriverBackend by StubDriverBackend("android") {
override fun snapshot(): SnapshotSample = override fun snapshot(): SnapshotSample = SnapshotSample(
SnapshotSample("{\"x\":1}", Triple(byteArrayOf(7, 8, 9), 1080, 2340)) "{\"x\":1}",
Triple(byteArrayOf(7, 8, 9), 1080, 2340),
)
} }
val client = newClient(backend) val client = newClient(backend)
@@ -55,13 +65,23 @@ class SnapshotHandlerTest {
// aligned with the final hierarchy snapshot the runner accepts. // aligned with the final hierarchy snapshot the runner accepts.
val callOrder = mutableListOf<String>() val callOrder = mutableListOf<String>()
val backend = object : DriverBackend { 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 terminate(bundleId: String) {}
override fun tap(x: Int, y: Int) {} override fun tap(x: Int, y: Int) {}
override fun tapSelector(selector: String) {} override fun tapSelector(selector: String) {}
override fun inputText(text: String) {} override fun inputText(text: String) {}
override fun eraseText(characterCount: Int) {} 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 pressKey(key: String) {}
override fun longPress(x: Int, y: Int) {} override fun longPress(x: Int, y: Int) {}
override fun screenshot(): Triple<ByteArray, Int, Int> { override fun screenshot(): Triple<ByteArray, Int, Int> {
@@ -72,10 +92,14 @@ class SnapshotHandlerTest {
callOrder.add("hierarchy") callOrder.add("hierarchy")
return "{}" 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 waitForIdle(durationMillis: Long) {}
override fun healthy(): Boolean = true 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() backend.snapshot()
assertEquals(listOf("hierarchy", "screenshot"), callOrder) assertEquals(listOf("hierarchy", "screenshot"), callOrder)
@@ -88,7 +112,8 @@ class SnapshotHandlerTest {
val maxObserved = AtomicInteger(0) val maxObserved = AtomicInteger(0)
val callCount = AtomicInteger(0) val callCount = AtomicInteger(0)
val lock = ReentrantLock() val lock = ReentrantLock()
val recordingBackend = object : DriverBackend by StubDriverBackend("android") { val delegate = StubDriverBackend("android")
val recordingBackend = object : DriverBackend by delegate {
override fun snapshot(): SnapshotSample { override fun snapshot(): SnapshotSample {
val now = inFlight.incrementAndGet() val now = inFlight.incrementAndGet()
try { try {
@@ -109,9 +134,13 @@ class SnapshotHandlerTest {
// Use a real (multi-threaded) executor on the server side so the service // Use a real (multi-threaded) executor on the server side so the service
// is not artificially serialized by directExecutor. // is not artificially serialized by directExecutor.
val serverName = InProcessServerBuilder.generateName() val serverName = InProcessServerBuilder.generateName()
val service = DriverService(platform = "android", backend = recordingBackend) val service =
DriverService(platform = "android", backend = recordingBackend)
grpcCleanup.register( grpcCleanup.register(
InProcessServerBuilder.forName(serverName).addService(service).build().start(), InProcessServerBuilder.forName(serverName)
.addService(service)
.build()
.start(),
) )
val channel: ManagedChannel = grpcCleanup.register( val channel: ManagedChannel = grpcCleanup.register(
InProcessChannelBuilder.forName(serverName).build(), InProcessChannelBuilder.forName(serverName).build(),
@@ -13,7 +13,10 @@ class StabilityPollTest {
elapsed >= MIN_STABLE_STREAK_MILLIS, elapsed >= MIN_STABLE_STREAK_MILLIS,
"must observe a stable streak of at least ${MIN_STABLE_STREAK_MILLIS}ms, elapsed=${elapsed}ms", "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() { @Test fun slowSnapshotReadsDoNotEatTheStreak() {
@@ -38,8 +41,9 @@ class StabilityPollTest {
val observedQuiet = sampleStarts.last() - sampleEnds.first() val observedQuiet = sampleStarts.last() - sampleEnds.first()
assertTrue( assertTrue(
observedQuiet >= MIN_STABLE_STREAK_MILLIS, observedQuiet >= MIN_STABLE_STREAK_MILLIS,
"the poll returned having observed only ${observedQuiet}ms of quiet, not " + "the poll returned having observed only ${observedQuiet}ms of " +
"${MIN_STABLE_STREAK_MILLIS}ms; starts=$sampleStarts ends=$sampleEnds", "quiet, not ${MIN_STABLE_STREAK_MILLIS}ms; " +
"starts=$sampleStarts ends=$sampleEnds",
) )
assertTrue( assertTrue(
sampleStarts.size >= 3, sampleStarts.size >= 3,
@@ -60,23 +64,27 @@ class StabilityPollTest {
calls++ calls++
when { when {
calls <= 2 -> "calm" calls <= 2 -> "calm"
calls == 3 -> { calls == 3 -> {
transientAt = System.currentTimeMillis() transientAt = System.currentTimeMillis()
"transient" "transient"
} }
else -> "stable" else -> "stable"
} }
} }
val sinceTransition = System.currentTimeMillis() - transientAt val sinceTransition = System.currentTimeMillis() - transientAt
assertTrue( assertTrue(
calls >= 8, calls >= 8,
"after the transition the poll needs a fresh matching pair and then a full " + "after the transition the poll needs a fresh matching pair and " +
"${MIN_STABLE_STREAK_MILLIS}ms of quiet, which is 8 samples, got $calls", "then a full ${MIN_STABLE_STREAK_MILLIS}ms of quiet, which " +
"is 8 samples, got $calls",
) )
assertTrue( assertTrue(
sinceTransition >= MIN_STABLE_STREAK_MILLIS, sinceTransition >= MIN_STABLE_STREAK_MILLIS,
"the calm prefix must not count: a full ${MIN_STABLE_STREAK_MILLIS}ms streak has to " + "the calm prefix must not count: a full " +
"start over after the transition, returned ${sinceTransition}ms after it", "${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" "frame-$calls"
} }
val elapsed = System.currentTimeMillis() - start 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() { @Test fun zeroBudgetReturnsImmediately() {
@@ -117,7 +128,8 @@ class StabilityPollTest {
assertEquals(0, calls) assertEquals(0, calls)
} }
@Test fun structuralHashIgnoresBoundsAndIdenticalForSemanticallyEqualTrees() { @Test
fun structuralHashIgnoresBoundsAndIdenticalForSemanticallyEqualTrees() {
val a = """ val a = """
{"attributes":{"resource-id":"LoginScreen","bounds":"[0,0,1080,2340]"}, {"attributes":{"resource-id":"LoginScreen","bounds":"[0,0,1080,2340]"},
"children":[ "children":[
@@ -130,13 +142,24 @@ class StabilityPollTest {
{"attributes":{"resource-id":"LoginEmail","bounds":"[10,11,1070,101]","text":"a@b"},"children":[]} {"attributes":{"resource-id":"LoginEmail","bounds":"[10,11,1070,101]","text":"a@b"},"children":[]}
]} ]}
""".trimIndent() """.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() { @Test fun structuralHashDiffersWhenContentChanges() {
val a = """{"attributes":{"resource-id":"LoginEmail","text":"a@b"},"children":[]}""" val a = """
val b = """{"attributes":{"resource-id":"LoginEmail","text":"c@d"},"children":[]}""" {"attributes":{"resource-id":"LoginEmail","text":"a@b"},"children":[]}
assertTrue(structuralHash(a) != structuralHash(b), "text change must alter hash") """.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() { @Test fun stabilitySnapshotReturnsNullDuringNavHostCrossFade() {
@@ -160,7 +183,10 @@ class StabilityPollTest {
]} ]}
""".trimIndent() """.trimIndent()
val hash = stabilitySnapshot(singleScreen) 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() { @Test fun stabilitySnapshotIgnoresNonRouteAttributeValues() {
@@ -173,7 +199,10 @@ class StabilityPollTest {
{"attributes":{"text":"Welcome to MyScreen"},"children":[]} {"attributes":{"text":"Welcome to MyScreen"},"children":[]}
]} ]}
""".trimIndent() """.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() { @Test fun countRouteScreensCountsTestTagAndIdentifier() {
@@ -12,14 +12,15 @@ import kotlin.test.assertTrue
class WdaRecoveryTest { class WdaRecoveryTest {
private fun recovery( private fun recovery(isAlive: () -> Boolean, restart: () -> Unit) =
isAlive: () -> Boolean, WdaRecovery(isAlive = isAlive, restart = restart, log = {})
restart: () -> Unit,
) = WdaRecovery(isAlive = isAlive, restart = restart, log = {})
@Test fun aliveChannelSkipsRestartAndRetriesReads() { @Test fun aliveChannelSkipsRestartAndRetriesReads() {
val restarts = AtomicInteger(0) val restarts = AtomicInteger(0)
val recovery = recovery(isAlive = { true }, restart = { restarts.incrementAndGet() }) val recovery = recovery(
isAlive = { true },
restart = { restarts.incrementAndGet() },
)
var calls = 0 var calls = 0
val result = recovery.run(replay = true) { val result = recovery.run(replay = true) {
@@ -35,10 +36,15 @@ class WdaRecoveryTest {
@Test fun aliveChannelSurfacesUnavailableForActions() { @Test fun aliveChannelSurfacesUnavailableForActions() {
val restarts = AtomicInteger(0) 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> { 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) assertEquals(io.grpc.Status.Code.UNAVAILABLE, thrown.status.code)
@@ -101,7 +107,9 @@ class WdaRecoveryTest {
) )
val thrown = assertFailsWith<IllegalStateException> { 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")) assertTrue(thrown.message.orEmpty().contains("WDA reconnect failed"))
@@ -116,7 +124,9 @@ class WdaRecoveryTest {
) )
assertFailsWith<IllegalArgumentException> { assertFailsWith<IllegalArgumentException> {
recovery.run(replay = true) { throw IllegalArgumentException("bad selector") } recovery.run(replay = true) {
throw IllegalArgumentException("bad selector")
}
} }
assertEquals(0, restarts.get()) assertEquals(0, restarts.get())
@@ -127,7 +137,9 @@ class WdaRecoveryTest {
val recovery = recovery(isAlive = { true }, restart = {}) val recovery = recovery(isAlive = { true }, restart = {})
val thrown = assertFailsWith<io.grpc.StatusRuntimeException> { 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) assertEquals(io.grpc.Status.Code.UNAVAILABLE, thrown.status.code)