diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 10ff80f..442acae 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -16,47 +16,6 @@ concurrency:
cancel-in-progress: false
jobs:
- release-android:
- name: Publish sdk-android to Maven Central
- runs-on: ubuntu-latest
- env:
- ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }}
- ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }}
- ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_IN_MEMORY_KEY }}
- ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_IN_MEMORY_KEY_PASSWORD }}
- steps:
- - uses: actions/checkout@v4
- with:
- ref: ${{ inputs.tag || github.ref }}
-
- - name: Resolve version
- id: ver
- run: |
- raw="${{ inputs.tag || github.ref_name }}"
- echo "version=${raw#v}" >> "$GITHUB_OUTPUT"
-
- - name: Set up JDK 17
- uses: actions/setup-java@v4
- with:
- distribution: temurin
- java-version: "17"
-
- - name: Set up Android SDK
- uses: android-actions/setup-android@v3
-
- - name: Cache Gradle
- uses: actions/cache@v4
- with:
- path: |
- ~/.gradle/caches
- ~/.gradle/wrapper
- key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
- restore-keys: |
- gradle-${{ runner.os }}-
-
- - name: Publish to Maven Central
- run: ./gradlew :sdk-android:publishAndReleaseToMavenCentral -Psanderling.version=${{ steps.ver.outputs.version }}
-
release-npm:
name: Publish @sanderling/spec to npm
runs-on: ubuntu-latest
diff --git a/Makefile b/Makefile
index 57d1920..60388b9 100644
--- a/Makefile
+++ b/Makefile
@@ -8,7 +8,6 @@ BUF := buf
GO_PACKAGES := ./...
SIDECAR_JAR := sidecar/build/libs/sidecar-all.jar
SIDECAR_EMBED := internal/sidecar/assets/sidecar-all.jar
-SDK_AAR := sdk/android/build/outputs/aar/sdk-android-release.aar
SANDERLING_BIN := bin/sanderling
DOCS_SRC := $(shell find docs -type f -name '*.md' -not -path 'docs/_*')
@@ -22,7 +21,7 @@ DOCS_TEMPLATE := docs/_template/page.html
INSPECT_DIST := internal/inspect/dist
WEB_DIST := inspect-ui/dist
-.PHONY: bootstrap proto sidecar sdk-android sdk-android-publish sanderling install test test-go test-kotlin test-spec-api web-typecheck web-build web-dev inspect-dev docs clean release-cli release-android-local release-npm-dry
+.PHONY: bootstrap proto sidecar sanderling install test test-go test-kotlin test-spec-api web-typecheck web-build web-dev inspect-dev docs clean release-cli release-npm-dry
bootstrap:
$(GO) mod download
@@ -35,12 +34,6 @@ proto:
sidecar: $(SIDECAR_JAR)
-sdk-android:
- ANDROID_HOME=$(ANDROID_HOME) $(GRADLE) :sdk-android:assembleRelease
-
-sdk-android-publish:
- ANDROID_HOME=$(ANDROID_HOME) $(GRADLE) :sdk-android:publishToMavenLocal
-
sanderling: $(SANDERLING_BIN)
$(SANDERLING_BIN): $(SIDECAR_EMBED) web-build
@@ -75,13 +68,13 @@ $(SIDECAR_EMBED): $(SIDECAR_JAR)
mkdir -p $(dir $@)
cp $< $@
-test: test-go test-kotlin test-spec-api web-typecheck
+test: test-go test-spec-api web-typecheck
test-go:
$(GO) test $(GO_PACKAGES)
test-kotlin:
- ANDROID_HOME=$(ANDROID_HOME) $(GRADLE) :sidecar:test :sdk-android:testDebugUnitTest
+ ANDROID_HOME=$(ANDROID_HOME) $(GRADLE) :sidecar:test
test-spec-api:
cd pkg/spec && npm test --silent
@@ -118,9 +111,5 @@ clean:
release-cli: $(SIDECAR_JAR)
goreleaser release --snapshot --clean
-release-android-local:
- @if [ -f .env.local ]; then set -a; . ./.env.local; set +a; fi; \
- ANDROID_HOME=$(ANDROID_HOME) $(GRADLE) :sdk-android:publishToMavenLocal -Psanderling.version=0.0.0-local
-
release-npm-dry:
cd pkg/spec && npm ci && npm run build && npm pack --dry-run
diff --git a/README.md b/README.md
index de60d00..443bcf1 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# sanderling
-Autonomous property-based testing for mobile apps. Specs in TypeScript. Core in Go. Drives the app under test through Maestro and an in-app SDK.
+Autonomous property-based testing for mobile apps. Specs in TypeScript. Core in Go. Drives the app under test through Maestro.
> Alpha. Android emulator only. Full scope in the [v0.1.0 roadmap](https://github.com/priyanshujain/sanderling/issues/4).
diff --git a/docs/development/architecture.md b/docs/development/architecture.md
index a07c6d8..f0eb863 100644
--- a/docs/development/architecture.md
+++ b/docs/development/architecture.md
@@ -15,15 +15,12 @@ flowchart TB
end
SC["Maestro sidecar (JVM)"]
- SDK["in-app SDK\n(Device / Emulator)"]
CH["Chrome (CDP)"]
RD[("runs/")]
IN["sanderling inspect\nHTTP + SSE"]
UI["Web UI (React)"]
D -->|gRPC| SC
- SC -->|UIAutomator / XCTest| SDK
- R -->|"Unix socket
(pause / state / logs)"| SDK
D -->|CDP| CH
T --> RD --> IN --> UI
@@ -35,16 +32,13 @@ flowchart TB
**Maestro sidecar (JVM).** A Kotlin process that wraps `maestro-client` and exposes a gRPC surface matching the `DeviceDriver` interface. Handles UI input, screenshots, the system accessibility tree, and OS-level alerts. Native platforms only.
-**In-app SDK.** A Kotlin (or Swift for iOS) library linked into the app under test. Exposes a Unix socket to the runner. Provides pause and resume, view-hierarchy dumps, coverage reads, log capture, and user-registered state extractors. Native platforms only.
-
-**Chrome (CDP).** For web targets, the Go binary drives Chrome directly over the Chrome DevTools Protocol. No sidecar or in-app SDK is involved.
+**Chrome (CDP).** For web targets, the Go binary drives Chrome directly over the Chrome DevTools Protocol. No sidecar is involved.
## Transports
| Channel | Platform | Transport | Purpose |
|---|---|---|---|
| Go to Maestro sidecar | Native | gRPC (localhost TCP) | UI input, screenshots, system alerts |
-| Go to in-app SDK | Native | Unix domain socket | Pause / resume, hierarchy, coverage, logs, extractors |
| Go to Chrome | Web | Chrome DevTools Protocol | UI input, screenshots, DOM hierarchy, console logs |
On native, the transport split exists because only real UI events need to cross process and OS-API boundaries. Introspection is cheap, frequent, and lives on a fast local socket directly to the app. On web, CDP handles both.
@@ -58,23 +52,21 @@ On native, the transport split exists because only real UI events need to cross
The heart of the system is:
```
-pause ─► capture state ─► evaluate properties ─► pick action ─► resume ─► dispatch
+fetch state ─► evaluate properties ─► pick action ─► dispatch
```
**Native (Android / iOS):**
1. The runner asks the driver to wait until the UI is idle.
-2. The runner sends `PAUSE` to the SDK over the Unix socket. The SDK freezes the main runloop at a safe point.
-3. The SDK sends back a `STATE` message: view hierarchy, coverage delta, logs since last step, exception list, snapshot values.
-4. The runner feeds state into goja. Extractors re-read; properties re-evaluate; the action generator returns a weighted tree.
-5. The runner writes the trace entry for this step.
-6. The runner picks an action by weight.
-7. The runner sends `RESUME` to the SDK, then dispatches the action through the driver (gRPC to sidecar → Maestro → UIAutomator or XCTest).
-8. Loop.
+2. The runner fetches the UI hierarchy and logs from the sidecar.
+3. The runner feeds state into goja. Extractors re-read; properties re-evaluate; the action generator returns a weighted tree.
+4. The runner writes the trace entry for this step.
+5. The runner picks an action by weight and dispatches it through the driver (gRPC to sidecar -> Maestro -> UIAutomator or XCTest).
+6. Loop.
**Web (Chrome):**
-Steps 2-3 use CDP to capture the DOM hierarchy and console logs directly; there is no SDK pause/resume. The rest of the cycle is identical.
+CDP captures the DOM hierarchy and console logs directly. The rest of the cycle is identical.
The cycle runs hundreds of times per minute. Every step produces one row in `trace.jsonl` and one screenshot.
diff --git a/docs/index.md b/docs/index.md
index e8268fe..555c749 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -4,7 +4,7 @@ title: Sanderling Manual
# Sanderling Manual
-Autonomous property-based testing for mobile/web apps. Specs in TypeScript. Core in Go. Drives the app under test through UIAutomation/XCTest and an in-app SDK on Android/iOS and CDP on web.
+Autonomous property-based testing for mobile/web apps. Specs in TypeScript. Core in Go. Drives the app under test through UIAutomation/XCTest on Android/iOS and CDP on web.
Alpha: Scope of v0.1.0 is tracked in [issue #4](https://github.com/priyanshujain/sanderling/issues/4).
diff --git a/docs/manual/getting-started.md b/docs/manual/getting-started.md
index c9f1cae..9762120 100644
--- a/docs/manual/getting-started.md
+++ b/docs/manual/getting-started.md
@@ -4,20 +4,18 @@ title: Getting started
# Getting started
-Install the CLI, link the SDK into your debug build, run a spec.
+Install the CLI, run a spec.
## Prerequisites
**Android / iOS:**
- An Android emulator with API level 30 or newer (or a connected device).
-- The app under test built as a debug variant with the sanderling Android SDK linked in.
- `adb` on your PATH.
**Web:**
- Chrome installed. sanderling drives it via CDP; no other setup required.
-- No in-app SDK needed.
Run `sanderling doctor` to check the host environment.
@@ -35,14 +33,6 @@ curl -fsSL https://raw.githubusercontent.com/priyanshujain/sanderling/master/ins
npm install --save-dev @sanderling/spec
```
-### Android SDK ([Maven Central](https://central.sonatype.com/artifact/io.github.priyanshujain.sanderling/sdk-android))
-
-```kotlin
-dependencies {
- implementation("io.github.priyanshujain.sanderling:sdk-android:")
-}
-```
-
## Your first run
### Android
diff --git a/docs/manual/writing-specs.md b/docs/manual/writing-specs.md
index a6dae99..b32d517 100644
--- a/docs/manual/writing-specs.md
+++ b/docs/manual/writing-specs.md
@@ -11,7 +11,6 @@ import { extract, always, now, actions, weighted, Tap, taps, swipes } from "@san
// 1. Extractors pull values from each observed state.
const loggedIn = extract((s) => !!s.ax.find("id:home-tab-bar"));
-const cartCount = extract((s) => (s.snapshots.cart_count as number) ?? 0);
// 2. Properties are LTL formulas evaluated every step.
export const properties = {
@@ -34,7 +33,6 @@ What extractors see:
```ts
interface State {
ax: AccessibilityTree; // view hierarchy
- snapshots: Record; // values registered by the in-app SDK
screen: { id: string; hash: string };
lastAction: Action | null;
logs: LogEntry[]; // since previous state
@@ -45,8 +43,6 @@ interface State {
`ax.find("text:Click me")`, `ax.find("id:login-form")`, `ax.findAll("role:todo-row")` are the common accessors. Prefer stable testID-style identifiers over positional selectors, for the same reason you would in Espresso or XCUITest.
-`snapshots` is populated by the in-app SDK via `Sanderling.extract("name") { value }`. Use it when the UI does not expose a value you need, such as business-logic state or hidden fields.
-
## Pattern: preconditions (login, onboarding)
sanderling has no setup phase and no fixtures. Preconditions are action generators with two properties:
@@ -129,29 +125,6 @@ loginSucceedsWithin30s: eventually(() => loggedIn.current).within(30, "seconds")
`within` takes `"milliseconds"`, `"seconds"`, or `"steps"`. Useful for liveness checks: the loading spinner eventually goes away, the deep link eventually lands on `/home`.
-## Pattern: snapshot-backed properties
-
-When the UI does not expose a value but the app knows it, use the SDK's extractor registry:
-
-```kotlin
-// in the app (Android)
-Sanderling.extract("cart_count") { store.cart.size }
-```
-
-```ts
-// in the spec
-const cartCount = extract((s) => (s.snapshots.cart_count as number) ?? 0);
-
-export const properties = {
- cartMonotonicAfterAdd: always(() => {
- const previous = cartCount.previous;
- return previous === undefined || cartCount.current >= previous;
- }),
-};
-```
-
-This pattern lets you write properties against business logic that no UI element exposes.
-
## Pattern: weighted exploration sub-trees
Nest `weighted` to group related actions and tune their collective rate:
diff --git a/examples/folio/README.md b/examples/folio/README.md
index 22bdce3..968899c 100644
--- a/examples/folio/README.md
+++ b/examples/folio/README.md
@@ -10,7 +10,6 @@ sanderling runs its property-based specs against.
- Kotlin Multiplatform + Compose Multiplatform (shared UI)
- kotlinx.serialization for file-backed persistence
- kotlinx.coroutines for state flows
-- sanderling `sdk-android` for harness integration on Android
## Prerequisites
@@ -70,9 +69,9 @@ Traces land in `./sanderling/runs//`.
## How it connects to sanderling
-- `composeApp/src/androidMain/.../FolioApplication.kt` calls `Sanderling.start(this)`
- and registers snapshot extractors (`logged_in`, `account_count`,
- `total_balance`, `route`)
-- `sanderling/spec.ts` imports `@sanderling/spec`, reads those snapshots, asserts
- properties, and weights the actions the fuzzer picks from
+- UI elements expose state via content descriptions (`account:$id:$balance`,
+ `ledger_row:$id:$signed`, `ledger_balance:$cents`, `active_account:$id`,
+ `focused_input:$field`)
+- `sanderling/spec.ts` imports `@sanderling/spec`, reads state via `s.ax.*`,
+ asserts properties, and weights the actions the fuzzer picks from
- `just test` invokes `sanderling test` against the installed APK
diff --git a/examples/folio/composeApp/build.gradle.kts b/examples/folio/composeApp/build.gradle.kts
index 10ff24b..286730d 100644
--- a/examples/folio/composeApp/build.gradle.kts
+++ b/examples/folio/composeApp/build.gradle.kts
@@ -83,7 +83,6 @@ kotlin {
androidMain.dependencies {
implementation("androidx.activity:activity-compose:1.13.0")
implementation("app.cash.sqldelight:android-driver:$sqldelightVersion")
- implementation("io.github.priyanshujain.sanderling:sdk-android:$sanderlingVersion")
}
iosMain.dependencies {
diff --git a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/FolioApplication.kt b/examples/folio/composeApp/src/androidMain/kotlin/app/folio/FolioApplication.kt
index ccb9629..f5bf413 100644
--- a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/FolioApplication.kt
+++ b/examples/folio/composeApp/src/androidMain/kotlin/app/folio/FolioApplication.kt
@@ -3,21 +3,11 @@ package app.folio
import android.app.Application
import app.folio.data.AndroidLedgerContext
import app.folio.data.Repository
-import app.folio.sanderling.AccountSnapshots
-import app.folio.sanderling.AuthSnapshots
-import app.folio.sanderling.LedgerSnapshots
-import app.folio.sanderling.NavigationSnapshots
-import dev.sanderling.sdk.Sanderling
class FolioApplication : Application() {
override fun onCreate() {
super.onCreate()
AndroidLedgerContext.context = applicationContext
Repository.init()
- Sanderling.start(this)
- AuthSnapshots
- AccountSnapshots
- LedgerSnapshots
- NavigationSnapshots
}
}
diff --git a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/sanderling/AccountSnapshots.kt b/examples/folio/composeApp/src/androidMain/kotlin/app/folio/sanderling/AccountSnapshots.kt
deleted file mode 100644
index 1f4be1c..0000000
--- a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/sanderling/AccountSnapshots.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-package app.folio.sanderling
-
-import app.folio.data.Repository
-import app.folio.feature.account.AddAccountUiState
-import app.folio.platform.balanceOf
-import dev.sanderling.sdk.Sanderling
-
-object AccountSnapshots {
- val accountCount by Sanderling.snapshot { Repository.accounts.value.size }
- val totalBalance by Sanderling.snapshot { balanceOf(Repository.transactions.value) }
- val accounts by Sanderling.snapshot {
- val txns = Repository.transactions.value
- Repository.accounts.value.map { a ->
- val rows = txns.filter { it.accountId == a.id }
- mapOf("id" to a.id, "name" to a.name, "balance" to balanceOf(rows), "txnCount" to rows.size)
- }
- }
- val addAccountError by Sanderling.snapshot { AddAccountUiState.addAccountError.value }
-}
diff --git a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/sanderling/AuthSnapshots.kt b/examples/folio/composeApp/src/androidMain/kotlin/app/folio/sanderling/AuthSnapshots.kt
deleted file mode 100644
index d8be6ab..0000000
--- a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/sanderling/AuthSnapshots.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-package app.folio.sanderling
-
-import app.folio.data.Repository
-import app.folio.feature.auth.LoginUiState
-import dev.sanderling.sdk.Sanderling
-
-object AuthSnapshots {
- val loggedIn by Sanderling.snapshot { Repository.session.value != null }
- val authStatus by Sanderling.snapshot { if (Repository.session.value != null) "logged-in" else "logged-out" }
- val loginError by Sanderling.snapshot { LoginUiState.loginError.value }
-}
diff --git a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/sanderling/LedgerSnapshots.kt b/examples/folio/composeApp/src/androidMain/kotlin/app/folio/sanderling/LedgerSnapshots.kt
deleted file mode 100644
index 778eaf0..0000000
--- a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/sanderling/LedgerSnapshots.kt
+++ /dev/null
@@ -1,42 +0,0 @@
-package app.folio.sanderling
-
-import app.folio.FocusTracker
-import app.folio.data.Repository
-import app.folio.data.TxnType
-import app.folio.feature.ledger.AddTransactionUiState
-import app.folio.navigation.Navigator
-import app.folio.navigation.Route
-import app.folio.platform.balanceOf
-import app.folio.platform.signedAmount
-import dev.sanderling.sdk.Sanderling
-
-object LedgerSnapshots {
- private val activeId
- get() = when (val r = Navigator.current.value) {
- is Route.Ledger -> r.accountId
- is Route.AddTransaction -> r.accountId
- else -> null
- }
-
- val activeAccountId by Sanderling.snapshot { activeId }
- val ledgerRows by Sanderling.snapshot {
- val active = activeId ?: return@snapshot emptyList()
- Repository.transactions.value.filter { it.accountId == active }.map {
- mapOf(
- "id" to it.id,
- "accountId" to it.accountId,
- "type" to if (it.type == TxnType.credit) "credit" else "debit",
- "amount" to it.amount,
- "signed" to signedAmount(it),
- )
- }
- }
- val ledgerBalance by Sanderling.snapshot {
- val active = activeId ?: return@snapshot 0L
- balanceOf(Repository.transactions.value.filter { it.accountId == active })
- }
- val focusedInput by Sanderling.snapshot { FocusTracker.current.value }
- val txnFormType by Sanderling.snapshot { AddTransactionUiState.txnFormType.value }
- val txnFormAccountId by Sanderling.snapshot { (Navigator.current.value as? Route.AddTransaction)?.accountId }
- val txnError by Sanderling.snapshot { AddTransactionUiState.txnError.value }
-}
diff --git a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/sanderling/NavigationSnapshots.kt b/examples/folio/composeApp/src/androidMain/kotlin/app/folio/sanderling/NavigationSnapshots.kt
deleted file mode 100644
index 6da97ac..0000000
--- a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/sanderling/NavigationSnapshots.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package app.folio.sanderling
-
-import app.folio.navigation.Navigator
-import app.folio.navigation.Route
-import dev.sanderling.sdk.Sanderling
-
-object NavigationSnapshots {
- val screen by Sanderling.snapshot {
- when (Navigator.current.value) {
- Route.Login -> "login"
- Route.Home -> "home"
- Route.AddAccount -> "add-account"
- is Route.Ledger -> "ledger"
- is Route.AddTransaction -> "add-transaction"
- }
- }
-}
diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/App.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/App.kt
index 052ccd0..19b8270 100644
--- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/App.kt
+++ b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/App.kt
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.safeDrawing
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -13,6 +14,9 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
import app.folio.data.Repository
import app.folio.feature.account.AddAccountScreen
import app.folio.feature.auth.LoginScreen
@@ -21,6 +25,7 @@ import app.folio.feature.ledger.AddTransactionScreen
import app.folio.feature.ledger.LedgerScreen
import app.folio.navigation.Navigator
import app.folio.navigation.Route
+import app.folio.FocusTracker
import app.folio.ui.theme.LedgerTheme
import app.folio.ui.theme.LocalTokens
import app.folio.ui.theme.Tokens
@@ -29,6 +34,7 @@ import app.folio.ui.theme.Tokens
fun App() {
val session by Repository.session.collectAsState()
val route by Navigator.current.collectAsState()
+ val focusedInput by FocusTracker.current.collectAsState()
LaunchedEffect(session, route) {
if (session == null && route !is Route.Login) {
@@ -47,6 +53,9 @@ fun App() {
.background(t.bg)
.windowInsetsPadding(WindowInsets.safeDrawing),
) {
+ if (focusedInput != null) {
+ Box(Modifier.size(1.dp).semantics { contentDescription = "focused_input:$focusedInput" })
+ }
Column(Modifier.fillMaxSize()) {
when (val r = route) {
Route.Login -> LoginScreen(onLoggedIn = { Navigator.replace(Route.Home) })
diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/account/AddAccountScreen.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/account/AddAccountScreen.kt
index b3f15d4..f4d017b 100644
--- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/account/AddAccountScreen.kt
+++ b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/account/AddAccountScreen.kt
@@ -14,6 +14,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import app.folio.data.Repository
import app.folio.navigation.Navigator
@@ -60,7 +62,7 @@ fun AddAccountScreen() {
}
Screen(
- modifier = Modifier.testTag("AddAccountScreen"),
+ modifier = Modifier.testTag("AddAccountScreen").semantics { contentDescription = "AddAccountScreen" },
header = {
Header(title = "New account", left = { BackButton(onClick = { Navigator.back(Route.Home) }) })
},
diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/auth/LoginScreen.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/auth/LoginScreen.kt
index bde7a33..be1149c 100644
--- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/auth/LoginScreen.kt
+++ b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/auth/LoginScreen.kt
@@ -16,6 +16,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import app.folio.data.Repository
@@ -60,7 +62,7 @@ fun LoginScreen(onLoggedIn: (String) -> Unit) {
onLoggedIn(user)
}
- Screen(modifier = Modifier.testTag("LoginScreen")) {
+ Screen(modifier = Modifier.testTag("LoginScreen").semantics { contentDescription = "LoginScreen" }) {
Spacer(Modifier.height(16.dp))
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/home/HomeScreen.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/home/HomeScreen.kt
index a5aa165..f6345c9 100644
--- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/home/HomeScreen.kt
+++ b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/home/HomeScreen.kt
@@ -50,7 +50,7 @@ fun HomeScreen(user: String, onLogout: () -> Unit) {
val total = balanceOf(txns)
Screen(
- modifier = Modifier.testTag("HomeScreen"),
+ modifier = Modifier.testTag("HomeScreen").semantics { contentDescription = "HomeScreen" },
header = {
Header(
title = "Accounts",
@@ -123,7 +123,7 @@ private fun AccountCard(
.clip(RoundedCornerShape(RadiusLg))
.background(t.surface)
.border(1.dp, t.border, RoundedCornerShape(RadiusLg))
- .semantics(mergeDescendants = true) { contentDescription = "account_card:$id" }
+ .semantics(mergeDescendants = true) { contentDescription = "account:$id:$balance" }
.clickable(role = Role.Button, onClick = onClick)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionScreen.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionScreen.kt
index 83ee08f..714abfb 100644
--- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionScreen.kt
+++ b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionScreen.kt
@@ -13,6 +13,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -91,7 +93,7 @@ fun AddTransactionScreen(accountId: String) {
}
Screen(
- modifier = Modifier.testTag("AddTransactionScreen"),
+ modifier = Modifier.testTag("AddTransactionScreen").semantics { contentDescription = "AddTransactionScreen" },
header = {
Header(
title = "Add transaction",
diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/LedgerScreen.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/LedgerScreen.kt
index 633ce95..6412e21 100644
--- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/LedgerScreen.kt
+++ b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/LedgerScreen.kt
@@ -75,7 +75,7 @@ fun LedgerScreen(accountId: String) {
val balance = balanceOf(txns)
Screen(
- modifier = Modifier.testTag("LedgerScreen"),
+ modifier = Modifier.testTag("LedgerScreen").semantics { contentDescription = "LedgerScreen" },
header = {
Header(
title = account.name,
@@ -92,6 +92,8 @@ fun LedgerScreen(accountId: String) {
)
},
) {
+ Box(Modifier.size(1.dp).semantics { contentDescription = "active_account:$accountId" })
+ Box(Modifier.size(1.dp).semantics { contentDescription = "ledger_balance:$balance" })
Card {
Text("BALANCE", style = Type.label, color = t.textMuted)
Text(formatCents(balance), style = Type.balance, color = t.text)
@@ -136,7 +138,7 @@ private fun TxnRow(id: String, type: TxnType, amount: Long, note: String, date:
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 14.dp)
- .semantics(mergeDescendants = true) { contentDescription = "txn_row:$id" },
+ .semantics(mergeDescendants = true) { contentDescription = "ledger_row:$id:$signed" },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/AccountSnapshots.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/AccountSnapshots.kt
deleted file mode 100644
index 87ca22b..0000000
--- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/AccountSnapshots.kt
+++ /dev/null
@@ -1,18 +0,0 @@
-package app.folio.sanderling
-
-import app.folio.data.Repository
-import app.folio.feature.account.AddAccountUiState
-import app.folio.platform.balanceOf
-
-object AccountSnapshots {
- val accountCount by SanderlingIos.snapshot { Repository.accounts.value.size }
- val totalBalance by SanderlingIos.snapshot { balanceOf(Repository.transactions.value) }
- val accounts by SanderlingIos.snapshot {
- val txns = Repository.transactions.value
- Repository.accounts.value.map { a ->
- val rows = txns.filter { it.accountId == a.id }
- mapOf("id" to a.id, "name" to a.name, "balance" to balanceOf(rows), "txnCount" to rows.size)
- }
- }
- val addAccountError by SanderlingIos.snapshot { AddAccountUiState.addAccountError.value }
-}
diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/AuthSnapshots.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/AuthSnapshots.kt
deleted file mode 100644
index a59b3a2..0000000
--- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/AuthSnapshots.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-package app.folio.sanderling
-
-import app.folio.data.Repository
-import app.folio.feature.auth.LoginUiState
-
-object AuthSnapshots {
- val loggedIn by SanderlingIos.snapshot { Repository.session.value != null }
- val authStatus by SanderlingIos.snapshot { if (Repository.session.value != null) "logged-in" else "logged-out" }
- val loginError by SanderlingIos.snapshot { LoginUiState.loginError.value }
-}
diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/IosAgent.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/IosAgent.kt
deleted file mode 100644
index 6b271cc..0000000
--- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/IosAgent.kt
+++ /dev/null
@@ -1,90 +0,0 @@
-package app.folio.sanderling
-
-import kotlinx.cinterop.ExperimentalForeignApi
-import platform.Foundation.NSBundle
-import platform.Foundation.NSThread
-
-@OptIn(ExperimentalForeignApi::class)
-internal object IosAgent {
- private const val VERSION = "0.0.1"
- private const val PROTOCOL_VERSION = 1
-
- fun start(host: String, port: Int) {
- val thread = NSThread { runLoop(host, port) }
- thread.name = "sanderling-agent"
- thread.start()
- }
-
- private fun runLoop(host: String, port: Int) {
- var delayMs = 0L
- while (true) {
- if (delayMs > 0) NSThread.sleepForTimeInterval(delayMs / 1000.0)
- try {
- val conn = TcpConnection.connect(host, port)
- delayMs = 0L
- try { serve(conn) } finally { conn.close() }
- } catch (_: Exception) {
- delayMs = if (delayMs <= 0L) 500L else minOf(delayMs * 2, 10_000L)
- }
- }
- }
-
- private fun serve(conn: TcpConnection) {
- val appPackage = (NSBundle.mainBundle.infoDictionary?.get("CFBundleIdentifier") as? String) ?: "unknown"
- conn.writeFrame(
- """{"type":"HELLO","protocol_version":$PROTOCOL_VERSION,"version":${jsonString(VERSION)},"platform":"ios","app_package":${jsonString(appPackage)}}""".encodeToByteArray()
- )
- while (true) {
- val frame = conn.readFrame()
- val text = frame.decodeToString()
- val type = extractJsonField(text, "type") ?: break
- val id = extractJsonLong(text, "id") ?: 0L
- when (type) {
- "PAUSE" -> handlePause(conn, id)
- "RESUME" -> IosPauser.release()
- "GOODBYE" -> return
- }
- }
- }
-
- private fun handlePause(conn: TcpConnection, id: Long) {
- val snapshots = IosPauser.pauseAndSnapshot {
- val snap = SanderlingIos.extractors.toMap()
- buildMap { for ((name, extractor) in snap) put(name, runCatching { extractor() }.getOrNull()) }
- }
- val snapshotsJson = snapshots.entries.joinToString(",") { (k, v) -> "${jsonString(k)}:${jsonValue(v)}" }
- conn.writeFrame("""{"type":"STATE","id":$id,"snapshots":{$snapshotsJson}}""".encodeToByteArray())
- }
-}
-
-internal fun extractJsonField(json: String, key: String): String? =
- Regex("\"${Regex.escape(key)}\"\\s*:\\s*\"([^\"\\\\]*(?:\\\\.[^\"\\\\]*)*)\"").find(json)?.groupValues?.get(1)
-
-internal fun extractJsonLong(json: String, key: String): Long? =
- Regex("\"${Regex.escape(key)}\"\\s*:\\s*(-?\\d+)").find(json)?.groupValues?.get(1)?.toLongOrNull()
-
-internal fun jsonString(s: String): String = buildString {
- append('"')
- for (c in s) when (c) {
- '"' -> append("\\\"")
- '\\' -> append("\\\\")
- '\n' -> append("\\n")
- '\r' -> append("\\r")
- '\t' -> append("\\t")
- else -> if (c.code < 0x20) append("\\u${c.code.toString(16).padStart(4, '0')}") else append(c)
- }
- append('"')
-}
-
-internal fun jsonValue(value: Any?): String = when (value) {
- null -> "null"
- is Boolean -> if (value) "true" else "false"
- is Int -> value.toString()
- is Long -> value.toString()
- is Float -> value.toString()
- is Double -> value.toString()
- is String -> jsonString(value)
- is Map<*, *> -> "{${value.entries.joinToString(",") { (k, v) -> "${jsonString(k.toString())}:${jsonValue(v)}" }}}"
- is List<*> -> "[${value.joinToString(",") { jsonValue(it) }}]"
- else -> jsonString(value.toString())
-}
diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/IosPauser.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/IosPauser.kt
deleted file mode 100644
index e9220cd..0000000
--- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/IosPauser.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-package app.folio.sanderling
-
-import kotlinx.cinterop.ExperimentalForeignApi
-import platform.darwin.*
-
-@OptIn(ExperimentalForeignApi::class)
-internal object IosPauser {
- private val snapshotReady = dispatch_semaphore_create(0)
- private val resumeGate = dispatch_semaphore_create(0)
- private var capturedSnapshot: Map = emptyMap()
-
- fun pauseAndSnapshot(extractors: () -> Map): Map {
- dispatch_async(dispatch_get_main_queue()) {
- capturedSnapshot = runCatching { extractors() }.getOrElse { emptyMap() }
- dispatch_semaphore_signal(snapshotReady)
- dispatch_semaphore_wait(resumeGate, dispatch_time(DISPATCH_TIME_NOW, 5_000_000_000L))
- }
- val waited = dispatch_semaphore_wait(snapshotReady, dispatch_time(DISPATCH_TIME_NOW, 5_000_000_000L))
- check(waited == 0L) { "snapshot timed out: main thread did not signal within 5s" }
- return capturedSnapshot
- }
-
- fun release() {
- dispatch_semaphore_signal(resumeGate)
- }
-}
diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/LedgerSnapshots.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/LedgerSnapshots.kt
deleted file mode 100644
index 551fd31..0000000
--- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/LedgerSnapshots.kt
+++ /dev/null
@@ -1,41 +0,0 @@
-package app.folio.sanderling
-
-import app.folio.FocusTracker
-import app.folio.data.Repository
-import app.folio.data.TxnType
-import app.folio.feature.ledger.AddTransactionUiState
-import app.folio.navigation.Navigator
-import app.folio.navigation.Route
-import app.folio.platform.balanceOf
-import app.folio.platform.signedAmount
-
-object LedgerSnapshots {
- private val activeId
- get() = when (val r = Navigator.current.value) {
- is Route.Ledger -> r.accountId
- is Route.AddTransaction -> r.accountId
- else -> null
- }
-
- val activeAccountId by SanderlingIos.snapshot { activeId }
- val ledgerRows by SanderlingIos.snapshot {
- val active = activeId ?: return@snapshot emptyList()
- Repository.transactions.value.filter { it.accountId == active }.map {
- mapOf(
- "id" to it.id,
- "accountId" to it.accountId,
- "type" to if (it.type == TxnType.credit) "credit" else "debit",
- "amount" to it.amount,
- "signed" to signedAmount(it),
- )
- }
- }
- val ledgerBalance by SanderlingIos.snapshot {
- val active = activeId ?: return@snapshot 0L
- balanceOf(Repository.transactions.value.filter { it.accountId == active })
- }
- val focusedInput by SanderlingIos.snapshot { FocusTracker.current.value }
- val txnFormType by SanderlingIos.snapshot { AddTransactionUiState.txnFormType.value }
- val txnFormAccountId by SanderlingIos.snapshot { (Navigator.current.value as? Route.AddTransaction)?.accountId }
- val txnError by SanderlingIos.snapshot { AddTransactionUiState.txnError.value }
-}
diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/NavigationSnapshots.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/NavigationSnapshots.kt
deleted file mode 100644
index 41c570e..0000000
--- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/NavigationSnapshots.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-package app.folio.sanderling
-
-import app.folio.navigation.Navigator
-import app.folio.navigation.Route
-
-object NavigationSnapshots {
- val screen by SanderlingIos.snapshot {
- when (Navigator.current.value) {
- Route.Login -> "login"
- Route.Home -> "home"
- Route.AddAccount -> "add-account"
- is Route.Ledger -> "ledger"
- is Route.AddTransaction -> "add-transaction"
- }
- }
-}
diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/SanderlingIos.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/SanderlingIos.kt
deleted file mode 100644
index f14dd0f..0000000
--- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/SanderlingIos.kt
+++ /dev/null
@@ -1,48 +0,0 @@
-package app.folio.sanderling
-
-import kotlin.properties.ReadOnlyProperty
-import kotlin.reflect.KProperty
-import platform.Foundation.NSProcessInfo
-
-private fun String.camelToSnakeCase(): String = buildString {
- for ((i, c) in this@camelToSnakeCase.withIndex()) {
- if (c.isUpperCase() && i > 0) append('_')
- append(c.lowercaseChar())
- }
-}
-
-object SanderlingIos {
- internal val extractors = mutableMapOf Any?>()
-
- fun start() {
- val port = resolvePort() ?: return
- IosAgent.start("127.0.0.1", port)
- }
-
- private fun resolvePort(): Int? {
- // Env var set via SIMCTL_CHILD_SANDERLING_PORT (simctl direct launch).
- (NSProcessInfo.processInfo.environment["SANDERLING_PORT"] as? String)
- ?.toIntOrNull()?.let { return it }
- // Launch argument -SANDERLING_PORT (Maestro simctl launch).
- @Suppress("UNCHECKED_CAST")
- val args = NSProcessInfo.processInfo.arguments as? List ?: return null
- val idx = args.indexOfFirst { it == "-SANDERLING_PORT" }
- if (idx >= 0 && idx + 1 < args.size) {
- return args[idx + 1].toIntOrNull()
- }
- return null
- }
-
- fun extract(name: String, block: () -> Any?) {
- extractors[name] = block
- }
-
- fun snapshot(block: () -> T): SnapshotDelegate = SnapshotDelegate(block)
-}
-
-class SnapshotDelegate(private val block: () -> T) {
- operator fun provideDelegate(thisRef: Any?, prop: KProperty<*>): ReadOnlyProperty {
- SanderlingIos.extract(prop.name.camelToSnakeCase(), block as () -> Any?)
- return ReadOnlyProperty { _, _ -> block() }
- }
-}
diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/TcpConnection.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/TcpConnection.kt
deleted file mode 100644
index d821388..0000000
--- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/TcpConnection.kt
+++ /dev/null
@@ -1,81 +0,0 @@
-package app.folio.sanderling
-
-import kotlinx.cinterop.*
-import platform.posix.*
-
-@OptIn(ExperimentalForeignApi::class)
-internal class TcpConnection private constructor(private val fd: Int) {
- companion object {
- fun connect(host: String, port: Int): TcpConnection {
- val sock = socket(AF_INET, SOCK_STREAM, 0)
- check(sock >= 0) { "socket() failed" }
- memScoped {
- val addr = alloc()
- addr.sin_family = AF_INET.convert()
- addr.sin_port = networkShort(port)
- addr.sin_addr.s_addr = networkAddress(host)
- val result = platform.posix.connect(sock, addr.ptr.reinterpret(), sizeOf().convert())
- if (result < 0) {
- close(sock)
- error("connect() to $host:$port failed: errno=$errno")
- }
- }
- return TcpConnection(sock)
- }
-
- // Convert port to network byte order (big-endian bytes in memory on little-endian iOS).
- private fun networkShort(value: Int): UShort =
- (((value ushr 8) and 0xFF) or ((value and 0xFF) shl 8)).toUShort()
-
- // Parse "a.b.c.d" → UInt with bytes [a,b,c,d] in memory (little-endian storage).
- private fun networkAddress(host: String): UInt {
- val parts = host.split(".").map { it.toInt() }
- check(parts.size == 4) { "expected IPv4 address, got: $host" }
- return (parts[0] or (parts[1] shl 8) or (parts[2] shl 16) or (parts[3] shl 24)).toUInt()
- }
- }
-
- fun writeFrame(data: ByteArray) {
- val len = data.size
- writeAll(byteArrayOf((len ushr 24).toByte(), (len ushr 16).toByte(), (len ushr 8).toByte(), len.toByte()))
- writeAll(data)
- }
-
- fun readFrame(): ByteArray {
- val header = readAll(4)
- val len = ((header[0].toInt() and 0xFF) shl 24) or
- ((header[1].toInt() and 0xFF) shl 16) or
- ((header[2].toInt() and 0xFF) shl 8) or
- (header[3].toInt() and 0xFF)
- check(len in 0..16_777_216) { "bad frame length: $len" }
- return readAll(len)
- }
-
- private fun writeAll(data: ByteArray) {
- data.usePinned { pinned ->
- var offset = 0
- while (offset < data.size) {
- val n = send(fd, pinned.addressOf(offset), (data.size - offset).convert(), 0).toInt()
- check(n > 0) { "send() failed: errno=$errno" }
- offset += n
- }
- }
- }
-
- private fun readAll(count: Int): ByteArray {
- val buf = ByteArray(count)
- buf.usePinned { pinned ->
- var offset = 0
- while (offset < count) {
- val n = recv(fd, pinned.addressOf(offset), (count - offset).convert(), 0).toInt()
- check(n > 0) { "recv() returned $n" }
- offset += n
- }
- }
- return buf
- }
-
- fun close() {
- platform.posix.close(fd)
- }
-}
diff --git a/examples/folio/sanderling/bun.lock b/examples/folio/sanderling/bun.lock
new file mode 100644
index 0000000..f5b6b46
--- /dev/null
+++ b/examples/folio/sanderling/bun.lock
@@ -0,0 +1,83 @@
+{
+ "lockfileVersion": 1,
+ "configVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "folio",
+ "devDependencies": {
+ "@sanderling/spec": "file:../../../pkg/spec",
+ },
+ },
+ },
+ "packages": {
+ "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
+
+ "@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="],
+
+ "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="],
+
+ "@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="],
+
+ "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="],
+
+ "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="],
+
+ "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="],
+
+ "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="],
+
+ "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="],
+
+ "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="],
+
+ "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="],
+
+ "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="],
+
+ "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="],
+
+ "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="],
+
+ "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="],
+
+ "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="],
+
+ "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="],
+
+ "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="],
+
+ "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="],
+
+ "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="],
+
+ "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="],
+
+ "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="],
+
+ "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="],
+
+ "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="],
+
+ "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="],
+
+ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="],
+
+ "@sanderling/spec": ["@sanderling/spec@file:../../../pkg/spec", { "devDependencies": { "@types/node": "^22.7.5", "tsx": "^4.21.0", "typescript": "^5.9.3" } }],
+
+ "@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="],
+
+ "esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="],
+
+ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
+ "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
+
+ "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
+
+ "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
+
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
+ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
+ }
+}
diff --git a/examples/folio/sanderling/package-lock.json b/examples/folio/sanderling/package-lock.json
deleted file mode 100644
index 5c02caa..0000000
--- a/examples/folio/sanderling/package-lock.json
+++ /dev/null
@@ -1,20 +0,0 @@
-{
- "name": "folio",
- "lockfileVersion": 3,
- "requires": true,
- "packages": {
- "": {
- "name": "folio",
- "devDependencies": {
- "@sanderling/spec": "^0.0.1-rc4"
- }
- },
- "node_modules/@sanderling/spec": {
- "version": "0.0.1-rc4",
- "resolved": "https://registry.npmjs.org/@sanderling/spec/-/spec-0.0.1-rc4.tgz",
- "integrity": "sha512-iv08d2duxQMowqEnDtOeWRGLmiv7188D68g/5ei+cU1BL33cVXwGscLYznfsUv4PgK7XI5Vihwbnp7ME4+Lh1w==",
- "dev": true,
- "license": "Apache-2.0"
- }
- }
-}
diff --git a/examples/folio/sanderling/package.json b/examples/folio/sanderling/package.json
index 5325120..73c9a4a 100644
--- a/examples/folio/sanderling/package.json
+++ b/examples/folio/sanderling/package.json
@@ -3,6 +3,6 @@
"private": true,
"description": "Minimal sanderling integration example.",
"devDependencies": {
- "@sanderling/spec": "^0.0.1-rc4"
+ "@sanderling/spec": "file:../../../pkg/spec"
}
}
diff --git a/examples/folio/sanderling/spec.ts b/examples/folio/sanderling/spec.ts
index 2244795..8b4c5e2 100644
--- a/examples/folio/sanderling/spec.ts
+++ b/examples/folio/sanderling/spec.ts
@@ -12,7 +12,6 @@ import {
interface Account {
id: string;
- name: string;
balance: number;
}
@@ -21,31 +20,70 @@ interface LedgerRow {
signed: number;
}
-const loggedIn = extract(s => (s.snapshots.logged_in as boolean) ?? false);
-const route = extract(s => (s.snapshots.screen as string) ?? "");
-const accounts = extract(s => (s.snapshots.accounts as Account[]) ?? []);
-const ledgerRows = extract(s => (s.snapshots.ledger_rows as LedgerRow[]) ?? []);
-const ledgerBalance = extract(s => (s.snapshots.ledger_balance as number) ?? 0);
-const activeAccountId = extract(s => (s.snapshots.active_account_id as string | null) ?? null);
-const focusedInput = extract(s => (s.snapshots.focused_input as string | null) ?? null);
+function parseAccount(desc: string | undefined): Account {
+ const parts = (desc ?? "").split(":");
+ return { id: parts[1] ?? "", balance: Number(parts[2]) };
+}
-const loginEmailField = extract(s => s.ax.find("id:LoginScreen > desc:login_email"));
-const loginPasswordField = extract(s => s.ax.find("id:LoginScreen > desc:login_password"));
-const loginSubmit = extract(s => s.ax.find("id:LoginScreen > desc:login_submit"));
-const addAccountButton = extract(s => s.ax.find("id:HomeScreen > desc:add_account_button"));
-const accountNameField = extract(s => s.ax.find("id:AddAccountScreen > desc:account_name_field"));
-const addAccountSubmit = extract(s => s.ax.find("id:AddAccountScreen > desc:add_account_submit"));
-const addTxnButton = extract(s => s.ax.find("id:LedgerScreen > desc:add_txn_button"));
-const txnAmountField = extract(s => s.ax.find("id:AddTransactionScreen > desc:txn_amount"));
-const txnSubmit = extract(s => s.ax.find("id:AddTransactionScreen > desc:txn_submit"));
-const accountCards = extract(s => s.ax.findAll("descPrefix:account_card:"));
+function parseLedgerRow(desc: string | undefined): LedgerRow {
+ const parts = (desc ?? "").split(":");
+ return { id: parts[1] ?? "", signed: Number(parts[2]) };
+}
+
+function parseCents(desc: string | null | undefined): number {
+ if (!desc) return 0;
+ const parts = desc.split(":");
+ return Number(parts[1]) || 0;
+}
+
+// Route and auth state derived from screen root nodes
+const loggedIn = extract(s => s.ax.find("desc:LoginScreen") == null);
+const route = extract(s => {
+ if (s.ax.find("desc:LoginScreen")) return "login";
+ if (s.ax.find("desc:HomeScreen")) return "home";
+ if (s.ax.find("desc:AddAccountScreen")) return "add-account";
+ if (s.ax.find("desc:LedgerScreen")) return "ledger";
+ if (s.ax.find("desc:AddTransactionScreen")) return "add-transaction";
+ return null;
+});
+
+// All element lookups scoped through their screen root
+const accounts = extract(s => s.ax.findAll("desc:HomeScreen > descPrefix:account:")
+ .map(el => parseAccount(el.desc)));
+const ledgerRows = extract(s => s.ax.findAll("desc:LedgerScreen > descPrefix:ledger_row:")
+ .map(el => parseLedgerRow(el.desc)));
+const ledgerBalance = extract(s =>
+ parseCents(s.ax.find("desc:LedgerScreen > descPrefix:ledger_balance:")?.desc));
+const activeAccountId = extract(s =>
+ s.ax.find("desc:LedgerScreen > descPrefix:active_account:")?.desc?.split(":")[1] ?? null);
+
+// focusedInput lives in the app root (not inside any screen), so unscoped
+const focusedInput = extract(s =>
+ s.ax.find("descPrefix:focused_input:")?.desc?.split(":")[1] ?? null);
+
+const loginEmailField = extract(s => s.ax.find("desc:LoginScreen > desc:login_email"));
+const loginPasswordField = extract(s => s.ax.find("desc:LoginScreen > desc:login_password"));
+const loginSubmit = extract(s => s.ax.find("desc:LoginScreen > desc:login_submit"));
+const addAccountButton = extract(s => s.ax.find("desc:HomeScreen > desc:add_account_button"));
+const accountNameField = extract(s => s.ax.find("desc:AddAccountScreen > desc:account_name_field"));
+const addAccountSubmit = extract(s => s.ax.find("desc:AddAccountScreen > desc:add_account_submit"));
+const addTxnButton = extract(s => s.ax.find("desc:LedgerScreen > desc:add_txn_button"));
+const txnAmountField = extract(s => s.ax.find("desc:AddTransactionScreen > desc:txn_amount"));
+const txnSubmit = extract(s => s.ax.find("desc:AddTransactionScreen > desc:txn_submit"));
+const accountCards = extract(s => s.ax.findAll("desc:HomeScreen > descPrefix:account:"));
const backButton = extract(s => s.ax.find("desc:Back"));
// Property 1: every new account starts with balance === 0
+// Guard: only check when accounts were visible in the previous step too.
+// Without this, navigating away from HomeScreen (accounts=[]) then back
+// makes every account look "new", causing false positives on pre-existing balances.
const newAccountBalanceIsZero = always(
next(() => {
- const prevIds = new Set((accounts.previous ?? []).map(a => a.id));
- const newAccounts = accounts.current.filter(a => !prevIds.has(a.id));
+ const prev = accounts.previous ?? [];
+ const curr = accounts.current;
+ if (prev.length === 0 || curr.length === 0) return true;
+ const prevIds = new Set(prev.map(a => a.id));
+ const newAccounts = curr.filter(a => !prevIds.has(a.id));
return newAccounts.every(a => a.balance === 0);
})
);
@@ -69,7 +107,7 @@ const newTxnChangesBalance = always(
const DEMO_EMAIL = "demo@folio.app";
const DEMO_PASSWORD = "ledger123";
-// Login if not already in — step by step based on which field has focus
+// Login if not already in - step by step based on which field has focus
const login = actions(() => {
if (loggedIn.current) return [];
const focus = focusedInput.current;
diff --git a/examples/folio/sanderling/tsconfig.json b/examples/folio/sanderling/tsconfig.json
new file mode 100644
index 0000000..0c6b7e0
--- /dev/null
+++ b/examples/folio/sanderling/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "noEmit": true
+ },
+ "include": ["spec.ts"]
+}
diff --git a/internal/agent/protocol.go b/internal/agent/protocol.go
deleted file mode 100644
index fe61fe7..0000000
--- a/internal/agent/protocol.go
+++ /dev/null
@@ -1,127 +0,0 @@
-package agent
-
-import (
- "encoding/binary"
- "encoding/json"
- "errors"
- "fmt"
- "io"
-)
-
-type MessageType string
-
-const (
- MessageTypeHello MessageType = "HELLO"
- MessageTypePause MessageType = "PAUSE"
- MessageTypeResume MessageType = "RESUME"
- MessageTypeState MessageType = "STATE"
- MessageTypeExtractResult MessageType = "EXTRACT_RESULT"
- MessageTypeGoodbye MessageType = "GOODBYE"
-)
-
-const MaxFrameSize = 16 * 1024 * 1024
-
-// ProtocolVersion is the wire-format version. Bump on any breaking change
-// to the message schema or framing. Independent of the SDK release version.
-const ProtocolVersion = 1
-
-type Message struct {
- Type MessageType `json:"type"`
- ID uint64 `json:"id,omitempty"`
-
- ProtocolVersion int `json:"protocol_version,omitempty"`
- Version string `json:"version,omitempty"`
- Platform string `json:"platform,omitempty"`
- AppPackage string `json:"app_package,omitempty"`
-
- Snapshots map[string]json.RawMessage `json:"snapshots,omitempty"`
- Exceptions []Exception `json:"exceptions,omitempty"`
-
- Extractor string `json:"extractor,omitempty"`
- Result json.RawMessage `json:"result,omitempty"`
- Error string `json:"error,omitempty"`
-
- Reason string `json:"reason,omitempty"`
-}
-
-// Exception mirrors an uncaught throwable captured by the SDK.
-type Exception struct {
- Class string `json:"class"`
- Message string `json:"message,omitempty"`
- StackTrace string `json:"stack_trace,omitempty"`
- UnixMillis int64 `json:"unix_millis,omitempty"`
-}
-
-func Hello(version, platform, appPackage string) Message {
- return Message{
- Type: MessageTypeHello,
- ProtocolVersion: ProtocolVersion,
- Version: version,
- Platform: platform,
- AppPackage: appPackage,
- }
-}
-
-func Pause(id uint64) Message { return Message{Type: MessageTypePause, ID: id} }
-
-func Resume(id uint64) Message { return Message{Type: MessageTypeResume, ID: id} }
-
-func State(id uint64, snapshots map[string]json.RawMessage) Message {
- return Message{Type: MessageTypeState, ID: id, Snapshots: snapshots}
-}
-
-func ExtractResult(id uint64, extractor string, result json.RawMessage, extractorError string) Message {
- return Message{
- Type: MessageTypeExtractResult,
- ID: id,
- Extractor: extractor,
- Result: result,
- Error: extractorError,
- }
-}
-
-func Goodbye(reason string) Message {
- return Message{Type: MessageTypeGoodbye, Reason: reason}
-}
-
-func WriteMessage(writer io.Writer, message Message) error {
- payload, err := json.Marshal(message)
- if err != nil {
- return fmt.Errorf("marshal: %w", err)
- }
- if len(payload) > MaxFrameSize {
- return fmt.Errorf("frame of %d bytes exceeds maximum %d", len(payload), MaxFrameSize)
- }
- var header [4]byte
- binary.BigEndian.PutUint32(header[:], uint32(len(payload)))
- if _, err := writer.Write(header[:]); err != nil {
- return fmt.Errorf("write header: %w", err)
- }
- if _, err := writer.Write(payload); err != nil {
- return fmt.Errorf("write payload: %w", err)
- }
- return nil
-}
-
-func ReadMessage(reader io.Reader) (Message, error) {
- var header [4]byte
- if _, err := io.ReadFull(reader, header[:]); err != nil {
- return Message{}, err
- }
- length := binary.BigEndian.Uint32(header[:])
- if length > MaxFrameSize {
- return Message{}, fmt.Errorf("frame of %d bytes exceeds maximum %d", length, MaxFrameSize)
- }
- payload := make([]byte, length)
- if _, err := io.ReadFull(reader, payload); err != nil {
- return Message{}, fmt.Errorf("read payload: %w", err)
- }
- var message Message
- if err := json.Unmarshal(payload, &message); err != nil {
- return Message{}, fmt.Errorf("unmarshal: %w", err)
- }
- if message.Type == "" {
- return Message{}, errors.New("missing type")
- }
- return message, nil
-}
diff --git a/internal/agent/protocol_test.go b/internal/agent/protocol_test.go
deleted file mode 100644
index 19509ad..0000000
--- a/internal/agent/protocol_test.go
+++ /dev/null
@@ -1,153 +0,0 @@
-package agent
-
-import (
- "bytes"
- "encoding/binary"
- "encoding/json"
- "errors"
- "io"
- "strings"
- "testing"
-)
-
-func roundTrip(t *testing.T, message Message) Message {
- t.Helper()
- var buffer bytes.Buffer
- if err := WriteMessage(&buffer, message); err != nil {
- t.Fatalf("WriteMessage: %v", err)
- }
- got, err := ReadMessage(&buffer)
- if err != nil {
- t.Fatalf("ReadMessage: %v", err)
- }
- return got
-}
-
-func TestRoundTrip_Hello(t *testing.T) {
- got := roundTrip(t, Hello("0.0.1", "android", "in.okcredit.merchant"))
- if got.Type != MessageTypeHello || got.Version != "0.0.1" || got.Platform != "android" || got.AppPackage != "in.okcredit.merchant" {
- t.Fatalf("hello round-trip failed: %+v", got)
- }
- if got.ProtocolVersion != ProtocolVersion {
- t.Errorf("protocol_version: got %d, want %d", got.ProtocolVersion, ProtocolVersion)
- }
-}
-
-func TestRoundTrip_PauseResume(t *testing.T) {
- for _, builder := range []func(uint64) Message{Pause, Resume} {
- got := roundTrip(t, builder(42))
- if got.ID != 42 {
- t.Errorf("id round-trip failed: %+v", got)
- }
- }
-}
-
-func TestRoundTrip_State(t *testing.T) {
- snapshots := map[string]json.RawMessage{
- "screen": json.RawMessage(`"customer_ledger"`),
- "ledger.balance": json.RawMessage(`1500`),
- "is_signed_in": json.RawMessage(`true`),
- }
- got := roundTrip(t, State(7, snapshots))
- if got.Type != MessageTypeState || got.ID != 7 {
- t.Fatalf("state envelope wrong: %+v", got)
- }
- if string(got.Snapshots["screen"]) != `"customer_ledger"` {
- t.Errorf("screen snapshot wrong: %s", got.Snapshots["screen"])
- }
- if string(got.Snapshots["ledger.balance"]) != `1500` {
- t.Errorf("balance snapshot wrong: %s", got.Snapshots["ledger.balance"])
- }
-}
-
-func TestRoundTrip_ExtractResult(t *testing.T) {
- got := roundTrip(t, ExtractResult(1, "ledger.balance", json.RawMessage(`2500`), ""))
- if got.Extractor != "ledger.balance" || string(got.Result) != `2500` {
- t.Fatalf("extract result round-trip failed: %+v", got)
- }
-
- failed := roundTrip(t, ExtractResult(2, "ledger.balance", nil, "no active customer"))
- if failed.Error != "no active customer" {
- t.Errorf("extract error round-trip failed: %+v", failed)
- }
-}
-
-func TestRoundTrip_Goodbye(t *testing.T) {
- got := roundTrip(t, Goodbye("app terminated"))
- if got.Type != MessageTypeGoodbye || got.Reason != "app terminated" {
- t.Fatalf("goodbye round-trip failed: %+v", got)
- }
-}
-
-func TestWriteMessage_FrameFormat(t *testing.T) {
- var buffer bytes.Buffer
- if err := WriteMessage(&buffer, Pause(99)); err != nil {
- t.Fatal(err)
- }
- raw := buffer.Bytes()
- if len(raw) < 4 {
- t.Fatalf("frame too short: %d bytes", len(raw))
- }
- length := binary.BigEndian.Uint32(raw[:4])
- if int(length) != len(raw)-4 {
- t.Errorf("header length %d mismatches payload length %d", length, len(raw)-4)
- }
- if !strings.Contains(string(raw[4:]), `"type":"PAUSE"`) {
- t.Errorf("payload does not contain PAUSE type: %s", raw[4:])
- }
-}
-
-func TestReadMessage_ShortReaderReturnsEOF(t *testing.T) {
- _, err := ReadMessage(bytes.NewReader(nil))
- if !errors.Is(err, io.EOF) {
- t.Errorf("expected EOF on empty reader, got %v", err)
- }
-}
-
-func TestReadMessage_OversizedFrameRejected(t *testing.T) {
- var header [4]byte
- binary.BigEndian.PutUint32(header[:], uint32(MaxFrameSize+1))
- _, err := ReadMessage(bytes.NewReader(header[:]))
- if err == nil || !strings.Contains(err.Error(), "exceeds maximum") {
- t.Errorf("expected oversized-frame error, got %v", err)
- }
-}
-
-func TestReadMessage_MissingTypeRejected(t *testing.T) {
- var buffer bytes.Buffer
- payload := []byte(`{"id":1}`)
- var header [4]byte
- binary.BigEndian.PutUint32(header[:], uint32(len(payload)))
- buffer.Write(header[:])
- buffer.Write(payload)
-
- _, err := ReadMessage(&buffer)
- if err == nil || !strings.Contains(err.Error(), "missing type") {
- t.Errorf("expected missing-type error, got %v", err)
- }
-}
-
-func TestWriteMessage_StreamsMultipleFrames(t *testing.T) {
- var buffer bytes.Buffer
- messages := []Message{
- Hello("v", "android", "com.x"),
- Pause(1),
- State(1, map[string]json.RawMessage{"x": json.RawMessage(`42`)}),
- Resume(1),
- Goodbye("done"),
- }
- for _, message := range messages {
- if err := WriteMessage(&buffer, message); err != nil {
- t.Fatal(err)
- }
- }
- for index, want := range messages {
- got, err := ReadMessage(&buffer)
- if err != nil {
- t.Fatalf("frame %d: %v", index, err)
- }
- if got.Type != want.Type {
- t.Errorf("frame %d: got type %q, want %q", index, got.Type, want.Type)
- }
- }
-}
diff --git a/internal/agent/server.go b/internal/agent/server.go
deleted file mode 100644
index 93ea20c..0000000
--- a/internal/agent/server.go
+++ /dev/null
@@ -1,145 +0,0 @@
-package agent
-
-import (
- "context"
- "errors"
- "fmt"
- "net"
- "time"
-)
-
-type Server struct {
- listener net.Listener
-}
-
-func NewServer(listener net.Listener) *Server {
- return &Server{listener: listener}
-}
-
-func (s *Server) Addr() net.Addr { return s.listener.Addr() }
-
-// Accept waits for the next SDK client and performs the HELLO handshake.
-// Only one Conn may be active at a time; subsequent Accepts block until the
-// current connection closes.
-func (s *Server) Accept(ctx context.Context) (*Conn, error) {
- cancelCloser := closeListenerOnCancel(ctx, s.listener)
- defer cancelCloser()
-
- rawConn, err := s.listener.Accept()
- if err != nil {
- if ctx.Err() != nil {
- return nil, ctx.Err()
- }
- return nil, fmt.Errorf("accept: %w", err)
- }
- hello, err := readWithDeadline(ctx, rawConn)
- if err != nil {
- rawConn.Close()
- return nil, fmt.Errorf("read hello: %w", err)
- }
- if hello.Type != MessageTypeHello {
- rawConn.Close()
- return nil, fmt.Errorf("expected HELLO, got %q", hello.Type)
- }
- if hello.ProtocolVersion != ProtocolVersion {
- rawConn.Close()
- return nil, fmt.Errorf("protocol version mismatch: host=%d sdk=%d", ProtocolVersion, hello.ProtocolVersion)
- }
- return &Conn{rawConn: rawConn, hello: hello}, nil
-}
-
-func (s *Server) Close() error { return s.listener.Close() }
-
-type Conn struct {
- rawConn net.Conn
- hello Message
- nextID uint64
-}
-
-func (c *Conn) Hello() Message { return c.hello }
-
-func (c *Conn) RemoteAddr() net.Addr { return c.rawConn.RemoteAddr() }
-
-// Snapshot sends PAUSE with a fresh id and blocks until the SDK returns the
-// matching STATE. The SDK's main thread stays paused until Release is called.
-func (c *Conn) Snapshot(ctx context.Context) (Message, error) {
- c.nextID++
- id := c.nextID
-
- if err := writeWithDeadline(ctx, c.rawConn, Pause(id)); err != nil {
- return Message{}, fmt.Errorf("send pause: %w", err)
- }
- message, err := readWithDeadline(ctx, c.rawConn)
- if err != nil {
- return Message{}, fmt.Errorf("read state: %w", err)
- }
- if message.Type != MessageTypeState {
- return Message{}, fmt.Errorf("expected STATE, got %q", message.Type)
- }
- if message.ID != id {
- return Message{}, fmt.Errorf("state id mismatch: sent %d, got %d", id, message.ID)
- }
- return message, nil
-}
-
-// Release sends RESUME, freeing the SDK's paused main thread.
-func (c *Conn) Release(ctx context.Context) error {
- return writeWithDeadline(ctx, c.rawConn, Resume(c.nextID))
-}
-
-// Close sends GOODBYE (best effort) and closes the underlying connection.
-func (c *Conn) Close() error {
- _ = writeWithDeadline(context.Background(), c.rawConn, Goodbye("shutdown"))
- return c.rawConn.Close()
-}
-
-func readWithDeadline(ctx context.Context, conn net.Conn) (Message, error) {
- if deadline, ok := ctx.Deadline(); ok {
- _ = conn.SetReadDeadline(deadline)
- }
- done := make(chan struct{})
- exited := make(chan struct{})
- go func() {
- defer close(exited)
- select {
- case <-ctx.Done():
- _ = conn.SetReadDeadline(time.Unix(1, 0))
- case <-done:
- }
- }()
- message, err := ReadMessage(conn)
- close(done)
- <-exited
- _ = conn.SetReadDeadline(time.Time{})
- if err != nil && ctx.Err() != nil {
- return Message{}, ctx.Err()
- }
- return message, err
-}
-
-func writeWithDeadline(ctx context.Context, conn net.Conn, message Message) error {
- if deadline, ok := ctx.Deadline(); ok {
- _ = conn.SetWriteDeadline(deadline)
- defer conn.SetWriteDeadline(time.Time{})
- }
- err := WriteMessage(conn, message)
- if err != nil && ctx.Err() != nil {
- return ctx.Err()
- }
- return err
-}
-
-func closeListenerOnCancel(ctx context.Context, listener net.Listener) (cancel func()) {
- done := make(chan struct{})
- go func() {
- select {
- case <-ctx.Done():
- _ = listener.Close()
- case <-done:
- }
- }()
- return func() { close(done) }
-}
-
-// ErrClosed is returned when a Conn method is called after Close.
-var ErrClosed = errors.New("agent: connection closed")
diff --git a/internal/agent/server_test.go b/internal/agent/server_test.go
deleted file mode 100644
index b71aa70..0000000
--- a/internal/agent/server_test.go
+++ /dev/null
@@ -1,347 +0,0 @@
-package agent
-
-import (
- "context"
- "encoding/json"
- "net"
- "strings"
- "sync"
- "testing"
- "time"
-)
-
-// fakeSDK drives the client side of an agent connection the way the real SDK
-// would: HELLO on connect, then respond to PAUSE with STATE, honor RESUME,
-// and close on GOODBYE.
-type fakeSDK struct {
- conn net.Conn
- snapshotFunc func(id uint64) map[string]json.RawMessage
-}
-
-func (f *fakeSDK) sendHello(version, platform, appPackage string) error {
- return WriteMessage(f.conn, Hello(version, platform, appPackage))
-}
-
-func (f *fakeSDK) serveOne() error {
- message, err := ReadMessage(f.conn)
- if err != nil {
- return err
- }
- switch message.Type {
- case MessageTypePause:
- snapshots := f.snapshotFunc(message.ID)
- return WriteMessage(f.conn, State(message.ID, snapshots))
- case MessageTypeResume:
- return nil
- case MessageTypeGoodbye:
- return nil
- default:
- return nil
- }
-}
-
-func newLoopbackServer(t *testing.T) *Server {
- t.Helper()
- listener, err := net.Listen("tcp", "127.0.0.1:0")
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() { listener.Close() })
- return NewServer(listener)
-}
-
-func TestServer_AcceptHandshake(t *testing.T) {
- server := newLoopbackServer(t)
-
- connectErr := make(chan error, 1)
- go func() {
- client, err := net.Dial("tcp", server.Addr().String())
- if err != nil {
- connectErr <- err
- return
- }
- sdk := &fakeSDK{conn: client}
- connectErr <- sdk.sendHello("0.0.1", "android", "com.example")
- }()
-
- ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
- defer cancel()
- conn, err := server.Accept(ctx)
- if err != nil {
- t.Fatalf("Accept: %v", err)
- }
- defer conn.Close()
-
- if got := conn.Hello(); got.Type != MessageTypeHello || got.Version != "0.0.1" || got.AppPackage != "com.example" {
- t.Errorf("unexpected hello: %+v", got)
- }
- if err := <-connectErr; err != nil {
- t.Fatalf("client side: %v", err)
- }
-}
-
-func TestServer_SnapshotAndRelease(t *testing.T) {
- server := newLoopbackServer(t)
-
- var wg sync.WaitGroup
- wg.Go(func() {
- client, err := net.Dial("tcp", server.Addr().String())
- if err != nil {
- t.Errorf("dial: %v", err)
- return
- }
- sdk := &fakeSDK{
- conn: client,
- snapshotFunc: func(id uint64) map[string]json.RawMessage {
- return map[string]json.RawMessage{
- "screen": json.RawMessage(`"home"`),
- "ledger.balance": json.RawMessage(`1500`),
- }
- },
- }
- if err := sdk.sendHello("0.0.1", "android", "com.x"); err != nil {
- t.Errorf("hello: %v", err)
- return
- }
- for range 2 {
- if err := sdk.serveOne(); err != nil {
- t.Errorf("pause: %v", err)
- return
- }
- if err := sdk.serveOne(); err != nil {
- t.Errorf("resume: %v", err)
- return
- }
- }
- })
-
- ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
- defer cancel()
- conn, err := server.Accept(ctx)
- if err != nil {
- t.Fatalf("Accept: %v", err)
- }
- defer conn.Close()
-
- for expected := uint64(1); expected <= 2; expected++ {
- state, err := conn.Snapshot(ctx)
- if err != nil {
- t.Fatalf("Snapshot #%d: %v", expected, err)
- }
- if state.ID != expected {
- t.Errorf("snapshot #%d: id=%d", expected, state.ID)
- }
- if string(state.Snapshots["screen"]) != `"home"` {
- t.Errorf("snapshot #%d: screen=%s", expected, state.Snapshots["screen"])
- }
- if err := conn.Release(ctx); err != nil {
- t.Fatalf("Release #%d: %v", expected, err)
- }
- }
- wg.Wait()
-}
-
-func TestServer_AcceptRejectsProtocolVersionMismatch(t *testing.T) {
- server := newLoopbackServer(t)
-
- go func() {
- client, err := net.Dial("tcp", server.Addr().String())
- if err != nil {
- return
- }
- defer client.Close()
- mismatched := Hello("0.0.1", "android", "com.x")
- mismatched.ProtocolVersion = ProtocolVersion + 99
- _ = WriteMessage(client, mismatched)
- }()
-
- ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
- defer cancel()
- _, err := server.Accept(ctx)
- if err == nil || !strings.Contains(err.Error(), "protocol version mismatch") {
- t.Fatalf("expected protocol-version-mismatch error, got %v", err)
- }
-}
-
-func TestServer_AcceptRequiresHello(t *testing.T) {
- server := newLoopbackServer(t)
-
- go func() {
- client, err := net.Dial("tcp", server.Addr().String())
- if err != nil {
- return
- }
- defer client.Close()
- // Send a PAUSE instead of HELLO — server should reject.
- _ = WriteMessage(client, Pause(1))
- }()
-
- ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
- defer cancel()
- _, err := server.Accept(ctx)
- if err == nil || !strings.Contains(err.Error(), "expected HELLO") {
- t.Fatalf("expected HELLO-required error, got %v", err)
- }
-}
-
-func TestServer_AcceptCancelsOnContext(t *testing.T) {
- server := newLoopbackServer(t)
-
- ctx, cancel := context.WithCancel(context.Background())
- acceptErr := make(chan error, 1)
- go func() { _, err := server.Accept(ctx); acceptErr <- err }()
-
- cancel()
-
- select {
- case err := <-acceptErr:
- if err == nil {
- t.Errorf("expected error after cancel, got nil")
- }
- case <-time.After(2 * time.Second):
- t.Errorf("accept did not return after cancel")
- }
-}
-
-func TestConn_SnapshotRejectsIDMismatch(t *testing.T) {
- server := newLoopbackServer(t)
-
- go func() {
- client, _ := net.Dial("tcp", server.Addr().String())
- defer client.Close()
- _ = WriteMessage(client, Hello("0.0.1", "android", "com.x"))
- // Read the PAUSE but respond with a wrong id.
- msg, _ := ReadMessage(client)
- _ = WriteMessage(client, State(msg.ID+99, map[string]json.RawMessage{}))
- }()
-
- ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
- defer cancel()
- conn, err := server.Accept(ctx)
- if err != nil {
- t.Fatalf("Accept: %v", err)
- }
- defer conn.Close()
-
- _, err = conn.Snapshot(ctx)
- if err == nil || !strings.Contains(err.Error(), "id mismatch") {
- t.Errorf("expected id-mismatch error, got %v", err)
- }
-}
-
-func TestConn_CloseSendsGoodbye(t *testing.T) {
- server := newLoopbackServer(t)
-
- received := make(chan Message, 1)
- go func() {
- client, _ := net.Dial("tcp", server.Addr().String())
- defer client.Close()
- _ = WriteMessage(client, Hello("0.0.1", "android", "com.x"))
- // Drain until GOODBYE.
- for {
- msg, err := ReadMessage(client)
- if err != nil {
- return
- }
- if msg.Type == MessageTypeGoodbye {
- received <- msg
- return
- }
- }
- }()
-
- ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
- defer cancel()
- conn, err := server.Accept(ctx)
- if err != nil {
- t.Fatal(err)
- }
- if err := conn.Close(); err != nil {
- t.Fatal(err)
- }
-
- select {
- case msg := <-received:
- if msg.Reason != "shutdown" {
- t.Errorf("expected reason=shutdown, got %q", msg.Reason)
- }
- case <-time.After(time.Second):
- t.Error("client did not receive GOODBYE")
- }
-}
-
-// TestConn_SnapshotAfterAcceptContextCancel guards against a race in
-// readWithDeadline where the watcher goroutine from Accept could clobber the
-// conn's read deadline with a past time after Accept returned, causing the
-// next read on the same conn (Snapshot) to time out instantly.
-func TestConn_SnapshotAfterAcceptContextCancel(t *testing.T) {
- for iteration := range 50 {
- server := newLoopbackServer(t)
-
- clientDone := make(chan struct{})
- go func() {
- defer close(clientDone)
- client, err := net.Dial("tcp", server.Addr().String())
- if err != nil {
- return
- }
- defer client.Close()
- if err := WriteMessage(client, Hello("0.0.1", "android", "com.x")); err != nil {
- return
- }
- msg, err := ReadMessage(client)
- if err != nil {
- return
- }
- _ = WriteMessage(client, State(msg.ID, map[string]json.RawMessage{"ok": json.RawMessage(`true`)}))
- }()
-
- acceptCtx, acceptCancel := context.WithTimeout(context.Background(), time.Second)
- conn, err := server.Accept(acceptCtx)
- acceptCancel()
- if err != nil {
- t.Fatalf("iteration %d: Accept: %v", iteration, err)
- }
-
- snapCtx, snapCancel := context.WithTimeout(context.Background(), 2*time.Second)
- state, err := conn.Snapshot(snapCtx)
- snapCancel()
- if err != nil {
- t.Fatalf("iteration %d: Snapshot: %v", iteration, err)
- }
- if string(state.Snapshots["ok"]) != `true` {
- t.Errorf("iteration %d: unexpected snapshots: %v", iteration, state.Snapshots)
- }
- conn.Close()
- <-clientDone
- }
-}
-
-func TestConn_SnapshotTimesOutIfSDKSilent(t *testing.T) {
- server := newLoopbackServer(t)
-
- done := make(chan struct{})
- t.Cleanup(func() { close(done) })
- go func() {
- client, _ := net.Dial("tcp", server.Addr().String())
- defer client.Close()
- _ = WriteMessage(client, Hello("0.0.1", "android", "com.x"))
- // Never respond to PAUSE; stay alive until the test ends.
- <-done
- }()
-
- ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
- defer cancel()
- conn, err := server.Accept(ctx)
- if err != nil {
- t.Fatal(err)
- }
- defer conn.Close()
-
- fastCtx, fastCancel := context.WithTimeout(ctx, 200*time.Millisecond)
- defer fastCancel()
- _, err = conn.Snapshot(fastCtx)
- if err == nil {
- t.Errorf("expected timeout error, got nil")
- }
-}
diff --git a/internal/runner/runner.go b/internal/runner/runner.go
index 32087f0..c71b379 100644
--- a/internal/runner/runner.go
+++ b/internal/runner/runner.go
@@ -11,7 +11,6 @@ import (
"golang.org/x/sync/errgroup"
- "github.com/priyanshujain/sanderling/internal/agent"
"github.com/priyanshujain/sanderling/internal/driver"
"github.com/priyanshujain/sanderling/internal/hierarchy"
"github.com/priyanshujain/sanderling/internal/ltl"
@@ -20,12 +19,10 @@ import (
)
type Options struct {
- Duration time.Duration
- SnapshotTimeout time.Duration
- IdleTimeout time.Duration
+ Duration time.Duration
+ IdleTimeout time.Duration
BundleID string
- Connection *agent.Conn
Driver driver.DeviceDriver
Verifier *verifier.Verifier
TraceWriter *trace.Writer
@@ -44,10 +41,9 @@ type ViolationRecord struct {
Properties []string
}
-// Run drives the snapshot/evaluate/release/act loop until the duration
-// elapses or the context is canceled. The caller is responsible for
-// launching the app and connecting the SDK before Run is called, and for
-// terminating the app afterwards.
+// Run drives the evaluate/act loop until the duration elapses or the context
+// is canceled. The caller is responsible for launching the app before Run is
+// called and for terminating it afterwards.
func Run(ctx context.Context, options Options) (Summary, error) {
if err := validate(options); err != nil {
return Summary{}, err
@@ -71,10 +67,8 @@ func Run(ctx context.Context, options Options) (Summary, error) {
stepIndex++
stepStart := time.Now()
- // Hierarchy, metrics, and logs are independent device reads. Run
- // them concurrently so metrics+logs hide behind the hierarchy
- // fetch (~2s). All three must finish before snapshotStep pauses
- // the SDK.
+ // Hierarchy, metrics, and logs are independent device reads — run
+ // them concurrently so metrics+logs hide behind the hierarchy fetch.
var tree *hierarchy.Tree
var hierarchyErr error
var metrics *trace.Metrics
@@ -115,31 +109,20 @@ func Run(ctx context.Context, options Options) (Summary, error) {
if tree != nil {
treeSize = len(tree.Elements)
}
-
- snapshot, err := snapshotStep(ctx, options)
- if err != nil {
- return summary, fmt.Errorf("step %d snapshot: %w", stepIndex, err)
- }
lastLogTime = stepStart
- exceptions := decodeExceptions(snapshot)
-
if err := options.Verifier.PushSnapshot(verifier.SnapshotInput{
- Snapshots: verifier.Snapshots(snapshot.Snapshots),
Tree: tree,
LastAction: lastAction,
StepTime: stepStart,
RunStart: summary.StartTime,
Logs: logs,
- Exceptions: exceptions,
}); err != nil {
return summary, fmt.Errorf("step %d push: %w", stepIndex, err)
}
- screen, screenErr := screenFromSnapshot(snapshot.Snapshots)
- if screenErr != nil {
- logger.Warn("screen snapshot decode failed", "step", stepIndex, "err", screenErr)
- }
- if screen == "" && tree != nil && len(tree.Elements) > 0 {
+
+ screen := ""
+ if tree != nil && len(tree.Elements) > 0 {
screen = tree.Elements[0].Screen
}
logger.Info("step", "index", stepIndex, "screen", screen, "nodes", treeSize)
@@ -165,16 +148,14 @@ func Run(ctx context.Context, options Options) (Summary, error) {
}
step := trace.Step{
- Index: stepIndex,
- Timestamp: stepStart,
- Screen: screen,
- Snapshots: snapshot.Snapshots,
- Action: traceAction,
- Exceptions: traceExceptions(exceptions),
+ Index: stepIndex,
+ Timestamp: stepStart,
+ Screen: screen,
+ Action: traceAction,
Violations: violations,
- Hierarchy: tree,
- Residuals: residuals,
- Metrics: metrics,
+ Hierarchy: tree,
+ Residuals: residuals,
+ Metrics: metrics,
}
if err := options.TraceWriter.WriteStep(step); err != nil {
return summary, fmt.Errorf("step %d trace: %w", stepIndex, err)
@@ -188,12 +169,6 @@ func Run(ctx context.Context, options Options) (Summary, error) {
})
}
- if options.Connection != nil {
- if err := options.Connection.Release(ctx); err != nil {
- return summary, fmt.Errorf("step %d release: %w", stepIndex, err)
- }
- }
-
if nextErr == nil {
if err := applyAction(ctx, options.Driver, nextAction, tree); err != nil {
if isWDADrop(err) {
@@ -240,28 +215,12 @@ func validate(options Options) error {
if options.Duration <= 0 {
return errors.New("runner: Duration must be positive")
}
- if options.SnapshotTimeout <= 0 {
- options.SnapshotTimeout = 5 * time.Second
- }
if options.IdleTimeout <= 0 {
options.IdleTimeout = 2 * time.Second
}
return nil
}
-func snapshotStep(ctx context.Context, options Options) (agent.Message, error) {
- if options.Connection == nil {
- return agent.Message{}, nil
- }
- snapshotTimeout := options.SnapshotTimeout
- if snapshotTimeout <= 0 {
- snapshotTimeout = 5 * time.Second
- }
- snapshotCtx, snapshotCancel := context.WithTimeout(ctx, snapshotTimeout)
- defer snapshotCancel()
- return options.Connection.Snapshot(snapshotCtx)
-}
-
func violationNames(verdicts map[string]ltl.Verdict) []string {
var names []string
for name, verdict := range verdicts {
@@ -272,18 +231,6 @@ func violationNames(verdicts map[string]ltl.Verdict) []string {
return names
}
-func screenFromSnapshot(snapshots map[string]json.RawMessage) (string, error) {
- raw, ok := snapshots["screen"]
- if !ok {
- return "", nil
- }
- var screen string
- if err := json.Unmarshal(raw, &screen); err != nil {
- return "", err
- }
- return screen, nil
-}
-
func applyAction(ctx context.Context, drv driver.DeviceDriver, action verifier.Action, tree *hierarchy.Tree) error {
switch action.Kind {
case verifier.ActionKindTap:
@@ -355,22 +302,6 @@ func collectLogs(ctx context.Context, drv driver.DeviceDriver, since time.Time)
return result
}
-func decodeExceptions(snapshot agent.Message) []verifier.Exception {
- if len(snapshot.Exceptions) == 0 {
- return nil
- }
- result := make([]verifier.Exception, 0, len(snapshot.Exceptions))
- for _, e := range snapshot.Exceptions {
- result = append(result, verifier.Exception{
- Class: e.Class,
- Message: e.Message,
- StackTrace: e.StackTrace,
- UnixMillis: e.UnixMillis,
- })
- }
- return result
-}
-
func resolveCoordinates(action verifier.Action, tree *hierarchy.Tree) (int, int, bool) {
if action.X > 0 && action.Y > 0 {
return action.X, action.Y, true
@@ -421,9 +352,7 @@ func traceActionFor(action verifier.Action, tree *hierarchy.Tree) *trace.Action
}
// stampSelectorTarget mirrors applyAction's coordinate-resolution rule so the
-// trace records the same point the runner taps. When the spec passed an ax
-// element directly, action.X/Y are already populated and we use them; when the
-// spec passed a string selector, we resolve it against the captured hierarchy.
+// trace records the same point the runner taps.
func stampSelectorTarget(traceAction *trace.Action, action verifier.Action, tree *hierarchy.Tree) {
if action.X > 0 && action.Y > 0 {
traceAction.TapPoint = &trace.PointRecord{X: action.X, Y: action.Y}
@@ -512,19 +441,3 @@ func isWDADrop(err error) bool {
return strings.Contains(msg, "ConnectException") ||
(strings.Contains(msg, "code = Internal") && strings.Contains(msg, "SocketException"))
}
-
-func traceExceptions(exceptions []verifier.Exception) []trace.Exception {
- if len(exceptions) == 0 {
- return nil
- }
- result := make([]trace.Exception, 0, len(exceptions))
- for _, e := range exceptions {
- result = append(result, trace.Exception{
- Class: e.Class,
- Message: e.Message,
- StackTrace: e.StackTrace,
- UnixMillis: e.UnixMillis,
- })
- }
- return result
-}
diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go
index b11b7e8..e962ecb 100644
--- a/internal/runner/runner_test.go
+++ b/internal/runner/runner_test.go
@@ -3,20 +3,16 @@ package runner
import (
"bytes"
"context"
- "encoding/json"
"errors"
"fmt"
"log/slog"
- "net"
"os"
"path/filepath"
"slices"
"strings"
- "sync"
"testing"
"time"
- "github.com/priyanshujain/sanderling/internal/agent"
"github.com/priyanshujain/sanderling/internal/driver"
mockdriver "github.com/priyanshujain/sanderling/internal/driver/mock"
"github.com/priyanshujain/sanderling/internal/trace"
@@ -24,7 +20,6 @@ import (
)
const fixtureSpec = `
-const screen = __sanderling__.extract(state => state.snapshots.screen ?? "");
const balance = __sanderling__.extract(state => state.snapshots.balance ?? 0);
globalThis.properties = {
balanceNonNegative: __sanderling__.always(() => balance.current >= 0),
@@ -32,28 +27,25 @@ globalThis.properties = {
globalThis.actions = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:next" })]);
`
+const violationSpec = `
+globalThis.properties = {
+ balanceNonNegative: __sanderling__.always(() => false),
+};
+globalThis.actions = __sanderling__.actions(() => []);
+`
+
type harness struct {
- server *agent.Server
- listener net.Listener
- clientWG sync.WaitGroup
- conn *agent.Conn
mock *mockdriver.Driver
verifier *verifier.Verifier
writer *trace.Writer
- snapshot []map[string]json.RawMessage
}
-func newHarness(t *testing.T, snapshots []map[string]json.RawMessage) *harness {
- return newHarnessWithSpec(t, snapshots, fixtureSpec)
+func newHarness(t *testing.T) *harness {
+ return newHarnessWithSpec(t, fixtureSpec)
}
-func newHarnessWithSpec(t *testing.T, snapshots []map[string]json.RawMessage, spec string) *harness {
+func newHarnessWithSpec(t *testing.T, spec string) *harness {
t.Helper()
- listener, err := net.Listen("tcp", "127.0.0.1:0")
- if err != nil {
- t.Fatal(err)
- }
- server := agent.NewServer(listener)
directory := t.TempDir()
writer, err := trace.NewWriter(directory)
if err != nil {
@@ -67,84 +59,25 @@ func newHarnessWithSpec(t *testing.T, snapshots []map[string]json.RawMessage, sp
t.Fatal(err)
}
state := &harness{
- server: server,
- listener: listener,
mock: mockdriver.New(),
verifier: verifierInstance,
writer: writer,
- snapshot: snapshots,
}
- t.Cleanup(func() {
- _ = listener.Close()
- _ = writer.Close()
- })
+ t.Cleanup(func() { _ = writer.Close() })
return state
}
-func (h *harness) startSDK(t *testing.T) {
- t.Helper()
- h.clientWG.Go(func() {
- conn, err := net.Dial("tcp", h.listener.Addr().String())
- if err != nil {
- t.Errorf("dial: %v", err)
- return
- }
- defer conn.Close()
- if err := agent.WriteMessage(conn, agent.Hello("0.0.1", "android", "com.fixture")); err != nil {
- t.Errorf("hello: %v", err)
- return
- }
- index := 0
- for {
- message, err := agent.ReadMessage(conn)
- if err != nil {
- return
- }
- if message.Type == agent.MessageTypePause {
- snapshots := map[string]json.RawMessage{}
- if index < len(h.snapshot) {
- snapshots = h.snapshot[index]
- }
- if err := agent.WriteMessage(conn, agent.State(message.ID, snapshots)); err != nil {
- return
- }
- index++
- }
- }
- })
-}
-
-func (h *harness) acceptConnection(t *testing.T) {
- t.Helper()
- ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
- defer cancel()
- connection, err := h.server.Accept(ctx)
- if err != nil {
- t.Fatalf("Accept: %v", err)
- }
- h.conn = connection
-}
-
func TestRunner_HappyPathStepsAndTraces(t *testing.T) {
- snapshots := []map[string]json.RawMessage{
- {"screen": json.RawMessage(`"home"`), "balance": json.RawMessage(`100`)},
- {"screen": json.RawMessage(`"home"`), "balance": json.RawMessage(`200`)},
- {"screen": json.RawMessage(`"home"`), "balance": json.RawMessage(`300`)},
- }
- state := newHarness(t, snapshots)
- state.startSDK(t)
- state.acceptConnection(t)
+ state := newHarness(t)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
summary, err := Run(ctx, Options{
- Duration: 100 * time.Millisecond,
- SnapshotTimeout: 2 * time.Second,
- IdleTimeout: 50 * time.Millisecond,
- Connection: state.conn,
- Driver: state.mock,
- Verifier: state.verifier,
- TraceWriter: state.writer,
+ Duration: 100 * time.Millisecond,
+ IdleTimeout: 50 * time.Millisecond,
+ Driver: state.mock,
+ Verifier: state.verifier,
+ TraceWriter: state.writer,
})
if err != nil {
t.Fatalf("Run: %v", err)
@@ -163,25 +96,16 @@ func TestRunner_HappyPathStepsAndTraces(t *testing.T) {
}
func TestRunner_ViolationSurfacesInSummary(t *testing.T) {
- snapshots := []map[string]json.RawMessage{
- {"balance": json.RawMessage(`100`)},
- {"balance": json.RawMessage(`-1`)},
- {"balance": json.RawMessage(`50`)},
- }
- state := newHarness(t, snapshots)
- state.startSDK(t)
- state.acceptConnection(t)
+ state := newHarnessWithSpec(t, violationSpec)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
summary, err := Run(ctx, Options{
- Duration: 100 * time.Millisecond,
- SnapshotTimeout: 2 * time.Second,
- IdleTimeout: 50 * time.Millisecond,
- Connection: state.conn,
- Driver: state.mock,
- Verifier: state.verifier,
- TraceWriter: state.writer,
+ Duration: 100 * time.Millisecond,
+ IdleTimeout: 50 * time.Millisecond,
+ Driver: state.mock,
+ Verifier: state.verifier,
+ TraceWriter: state.writer,
})
if err != nil {
t.Fatalf("Run: %v", err)
@@ -201,9 +125,7 @@ globalThis.properties = {
};
globalThis.actions = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:next" })]);
`
- state := newHarnessWithSpec(t, []map[string]json.RawMessage{{}, {}}, throwingSpec)
- state.startSDK(t)
- state.acceptConnection(t)
+ state := newHarnessWithSpec(t, throwingSpec)
var buffer bytes.Buffer
logger := slog.New(slog.NewTextHandler(&buffer, &slog.HandlerOptions{Level: slog.LevelWarn}))
@@ -211,14 +133,12 @@ globalThis.actions = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
summary, err := Run(ctx, Options{
- Duration: 100 * time.Millisecond,
- SnapshotTimeout: 2 * time.Second,
- IdleTimeout: 50 * time.Millisecond,
- Connection: state.conn,
- Driver: state.mock,
- Verifier: state.verifier,
- TraceWriter: state.writer,
- Logger: logger,
+ Duration: 100 * time.Millisecond,
+ IdleTimeout: 50 * time.Millisecond,
+ Driver: state.mock,
+ Verifier: state.verifier,
+ TraceWriter: state.writer,
+ Logger: logger,
})
if err != nil {
t.Fatalf("Run: %v", err)
@@ -240,7 +160,6 @@ func TestRunner_RejectsMissingFields(t *testing.T) {
func TestRunner_RejectsZeroDuration(t *testing.T) {
_, err := Run(context.Background(), Options{
- Connection: &agent.Conn{},
Driver: mockdriver.New(),
Verifier: mustNewVerifier(t),
TraceWriter: mustNewTraceWriter(t),
@@ -250,89 +169,18 @@ func TestRunner_RejectsZeroDuration(t *testing.T) {
}
}
-func TestRunner_RecordsScreenFieldFromSnapshot(t *testing.T) {
- snapshots := []map[string]json.RawMessage{
- {"screen": json.RawMessage(`"customer_ledger"`), "balance": json.RawMessage(`1`)},
- }
- state := newHarness(t, snapshots)
- state.startSDK(t)
- state.acceptConnection(t)
-
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
- if _, err := Run(ctx, Options{
- Duration: 100 * time.Millisecond,
- SnapshotTimeout: 2 * time.Second,
- IdleTimeout: 50 * time.Millisecond,
- Connection: state.conn,
- Driver: state.mock,
- Verifier: state.verifier,
- TraceWriter: state.writer,
- }); err != nil {
- t.Fatal(err)
- }
- body, err := os.ReadFile(filepath.Join(state.writer.Directory(), "trace.jsonl"))
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(string(body), `"screen":"customer_ledger"`) {
- t.Errorf("screen field not in trace: %s", body)
- }
-}
-
-func TestScreenFromSnapshot(t *testing.T) {
- t.Run("string value returns screen", func(t *testing.T) {
- snapshots := map[string]json.RawMessage{"screen": json.RawMessage(`"home"`)}
- screen, err := screenFromSnapshot(snapshots)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if screen != "home" {
- t.Errorf("screen = %q, want %q", screen, "home")
- }
- })
- t.Run("missing key returns empty with no error", func(t *testing.T) {
- screen, err := screenFromSnapshot(map[string]json.RawMessage{})
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if screen != "" {
- t.Errorf("screen = %q, want empty", screen)
- }
- })
- t.Run("non-string value returns error", func(t *testing.T) {
- snapshots := map[string]json.RawMessage{"screen": json.RawMessage(`{"nested":1}`)}
- screen, err := screenFromSnapshot(snapshots)
- if err == nil {
- t.Fatalf("expected error for non-string screen, got nil")
- }
- if screen != "" {
- t.Errorf("screen = %q, want empty on error", screen)
- }
- })
-}
-
func TestRunner_StampsHierarchyResolvedBoundsAndResiduals(t *testing.T) {
- snapshots := []map[string]json.RawMessage{
- {"balance": json.RawMessage(`100`)},
- {"balance": json.RawMessage(`200`)},
- }
- state := newHarness(t, snapshots)
- state.startSDK(t)
- state.acceptConnection(t)
-
+ state := newHarness(t)
state.mock.HierarchyJSON = `{"attributes":{"resource-id":"com.fixture:id/next","bounds":"[40,80,240,160]"},"children":[],"clickable":true,"enabled":true}`
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, err := Run(ctx, Options{
- Duration: 100 * time.Millisecond,
- SnapshotTimeout: 2 * time.Second,
- IdleTimeout: 50 * time.Millisecond,
- Connection: state.conn,
- Driver: state.mock,
- Verifier: state.verifier,
- TraceWriter: state.writer,
+ Duration: 100 * time.Millisecond,
+ IdleTimeout: 50 * time.Millisecond,
+ Driver: state.mock,
+ Verifier: state.verifier,
+ TraceWriter: state.writer,
}); err != nil {
t.Fatalf("Run: %v", err)
}
@@ -359,12 +207,7 @@ func TestRunner_StampsHierarchyResolvedBoundsAndResiduals(t *testing.T) {
}
func TestRunner_LogsWaitForIdleDriverErrors(t *testing.T) {
- snapshots := []map[string]json.RawMessage{
- {"balance": json.RawMessage(`100`)},
- }
- state := newHarness(t, snapshots)
- state.startSDK(t)
- state.acceptConnection(t)
+ state := newHarness(t)
state.mock.Failures[mockdriver.ActionWaitForIdle] = errors.New("sidecar lost gRPC stream")
var logBuf bytes.Buffer
@@ -373,14 +216,12 @@ func TestRunner_LogsWaitForIdleDriverErrors(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, err := Run(ctx, Options{
- Duration: 100 * time.Millisecond,
- SnapshotTimeout: 2 * time.Second,
- IdleTimeout: 50 * time.Millisecond,
- Connection: state.conn,
- Driver: state.mock,
- Verifier: state.verifier,
- TraceWriter: state.writer,
- Logger: logger,
+ Duration: 100 * time.Millisecond,
+ IdleTimeout: 50 * time.Millisecond,
+ Driver: state.mock,
+ Verifier: state.verifier,
+ TraceWriter: state.writer,
+ Logger: logger,
}); err != nil {
t.Fatalf("Run: %v", err)
}
@@ -423,28 +264,21 @@ func TestApplyAction_InputTextSurfacesFocusTapError(t *testing.T) {
}
func TestRunner_ParallelFetchCallsAllDriverMethods(t *testing.T) {
- snapshots := []map[string]json.RawMessage{
- {"screen": json.RawMessage(`"home"`), "balance": json.RawMessage(`100`)},
- }
- state := newHarness(t, snapshots)
+ state := newHarness(t)
state.mock.MetricsData = driver.Metrics{CPUPercent: 5.0, HeapBytes: 1024, TotalMemoryBytes: 4096}
state.mock.LogEntries = []driver.LogEntry{
{UnixMillis: 1000, Level: "E", Tag: "test", Message: "boom"},
}
- state.startSDK(t)
- state.acceptConnection(t)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := Run(ctx, Options{
- Duration: 100 * time.Millisecond,
- SnapshotTimeout: 2 * time.Second,
- IdleTimeout: 50 * time.Millisecond,
- BundleID: "com.fixture",
- Connection: state.conn,
- Driver: state.mock,
- Verifier: state.verifier,
- TraceWriter: state.writer,
+ Duration: 100 * time.Millisecond,
+ IdleTimeout: 50 * time.Millisecond,
+ BundleID: "com.fixture",
+ Driver: state.mock,
+ Verifier: state.verifier,
+ TraceWriter: state.writer,
})
if err != nil {
t.Fatalf("Run: %v", err)
@@ -474,26 +308,17 @@ func TestRunner_ParallelFetchCallsAllDriverMethods(t *testing.T) {
}
func TestRunner_PipelinedPostScreenshotWritten(t *testing.T) {
- snapshots := []map[string]json.RawMessage{
- {"screen": json.RawMessage(`"home"`), "balance": json.RawMessage(`100`)},
- {"screen": json.RawMessage(`"home"`), "balance": json.RawMessage(`200`)},
- {"screen": json.RawMessage(`"home"`), "balance": json.RawMessage(`300`)},
- }
- state := newHarness(t, snapshots)
+ state := newHarness(t)
state.mock.ImageData = driver.Image{PNG: []byte("fakepng"), Width: 100, Height: 200}
- state.startSDK(t)
- state.acceptConnection(t)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
summary, err := Run(ctx, Options{
- Duration: 200 * time.Millisecond,
- SnapshotTimeout: 2 * time.Second,
- IdleTimeout: 50 * time.Millisecond,
- Connection: state.conn,
- Driver: state.mock,
- Verifier: state.verifier,
- TraceWriter: state.writer,
+ Duration: 200 * time.Millisecond,
+ IdleTimeout: 50 * time.Millisecond,
+ Driver: state.mock,
+ Verifier: state.verifier,
+ TraceWriter: state.writer,
})
if err != nil {
t.Fatalf("Run: %v", err)
@@ -509,13 +334,11 @@ func TestRunner_PipelinedPostScreenshotWritten(t *testing.T) {
t.Errorf("expected pre-screenshot for step 1: %s", preFile)
}
- // Step 1's post-screenshot is pipelined into step 2's errgroup
postFile := filepath.Join(screenshotDir, "step-00001-after.png")
if _, err := os.Stat(postFile); os.IsNotExist(err) {
t.Errorf("expected pipelined post-screenshot for step 1: %s", postFile)
}
- // Last step's post-screenshot is flushed after the loop
lastAfter := filepath.Join(screenshotDir, fmt.Sprintf("step-%05d-after.png", summary.Steps))
if _, err := os.Stat(lastAfter); os.IsNotExist(err) {
t.Errorf("expected flushed post-screenshot for last step %d: %s", summary.Steps, lastAfter)
diff --git a/internal/testrun/driver.go b/internal/testrun/driver.go
index be3a215..ed49219 100644
--- a/internal/testrun/driver.go
+++ b/internal/testrun/driver.go
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"io"
+ "net"
"os"
"os/exec"
"strconv"
@@ -80,3 +81,12 @@ func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver
}
return driverClient, cleanup, nil
}
+
+func pickFreePort() (int, error) {
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ return 0, err
+ }
+ defer listener.Close()
+ return listener.Addr().(*net.TCPAddr).Port, nil
+}
diff --git a/internal/testrun/testrun.go b/internal/testrun/testrun.go
index 7e6a16a..a960fe5 100644
--- a/internal/testrun/testrun.go
+++ b/internal/testrun/testrun.go
@@ -5,13 +5,10 @@ import (
"fmt"
"io"
"math/rand/v2"
- "net"
"os"
"path/filepath"
- "strconv"
"time"
- "github.com/priyanshujain/sanderling/internal/agent"
"github.com/priyanshujain/sanderling/internal/android"
"github.com/priyanshujain/sanderling/internal/bundler"
"github.com/priyanshujain/sanderling/internal/ios"
@@ -20,11 +17,7 @@ import (
"github.com/priyanshujain/sanderling/internal/verifier"
)
-const (
- socketName = "sanderling-agent"
- sidecarStartupTimeout = 30 * time.Second
- sdkAcceptTimeout = 60 * time.Second
-)
+const sidecarStartupTimeout = 30 * time.Second
// Options are the parameters for a single test pipeline run.
type Options struct {
@@ -38,7 +31,7 @@ type Options struct {
Output string
}
-// Execute runs the full test pipeline: bundle, connect SDK, verify properties.
+// Execute runs the full test pipeline: bundle, launch app, verify properties.
func Execute(ctx context.Context, options Options, stdout io.Writer) error {
switch options.Platform {
case "android":
@@ -75,100 +68,8 @@ func Execute(ctx context.Context, options Options, stdout io.Writer) error {
}
defer cleanup()
- var connection *agent.Conn
-
- switch options.Platform {
- case "android":
- listener, err := net.Listen("tcp", "127.0.0.1:0")
- if err != nil {
- return fmt.Errorf("agent listener: %w", err)
- }
- defer listener.Close()
- agentPort := listener.Addr().(*net.TCPAddr).Port
-
- if err := android.AdbReverse(socketName, agentPort); err != nil {
- return fmt.Errorf("adb reverse: %w", err)
- }
- defer func() {
- if err := android.AdbReverseRemove(socketName); err != nil {
- fmt.Fprintf(stdout, "warning: adb reverse cleanup: %v\n", err)
- }
- }()
- fmt.Fprintf(stdout, "forwarded localabstract:%s -> tcp:%d\n", socketName, agentPort)
-
- agentServer := agent.NewServer(listener)
-
- type acceptResult struct {
- conn *agent.Conn
- err error
- }
- acceptChannel := make(chan acceptResult, 1)
- go func() {
- acceptCtx, cancel := context.WithTimeout(ctx, sdkAcceptTimeout)
- defer cancel()
- conn, acceptErr := agentServer.Accept(acceptCtx)
- acceptChannel <- acceptResult{conn: conn, err: acceptErr}
- }()
-
- if err := activeDriver.Launch(ctx, options.BundleID, false, nil); err != nil {
- return fmt.Errorf("launch app: %w", err)
- }
- fmt.Fprintf(stdout, "launched %s; waiting for SDK to connect (%.0fs timeout)\n", options.BundleID, sdkAcceptTimeout.Seconds())
-
- result := <-acceptChannel
- if result.err != nil {
- return fmt.Errorf("accept SDK: %w", result.err)
- }
- connection = result.conn
- defer connection.Close()
- hello := connection.Hello()
- fmt.Fprintf(stdout, "SDK connected: platform=%s app=%s sdk=%s\n", hello.Platform, hello.AppPackage, hello.Version)
-
- case "ios":
- // iOS simulator shares the Mac's loopback — no port forwarding needed.
- listener, err := net.Listen("tcp", "127.0.0.1:0")
- if err != nil {
- return fmt.Errorf("agent listener: %w", err)
- }
- defer listener.Close()
- agentPort := listener.Addr().(*net.TCPAddr).Port
-
- agentServer := agent.NewServer(listener)
-
- type acceptResult struct {
- conn *agent.Conn
- err error
- }
- acceptChannel := make(chan acceptResult, 1)
- go func() {
- acceptCtx, cancel := context.WithTimeout(ctx, sdkAcceptTimeout)
- defer cancel()
- conn, acceptErr := agentServer.Accept(acceptCtx)
- acceptChannel <- acceptResult{conn: conn, err: acceptErr}
- }()
-
- // Launch app via XCTest with SANDERLING_PORT so the SDK can connect.
- if err := activeDriver.Launch(ctx, options.BundleID, false, map[string]string{
- "SANDERLING_PORT": strconv.Itoa(agentPort),
- }); err != nil {
- return fmt.Errorf("launch app: %w", err)
- }
- fmt.Fprintf(stdout, "iOS app launched with SANDERLING_PORT=%d; waiting for SDK (%.0fs timeout)\n", agentPort, sdkAcceptTimeout.Seconds())
-
- result := <-acceptChannel
- if result.err != nil {
- return fmt.Errorf("accept SDK: %w", result.err)
- }
- connection = result.conn
- defer connection.Close()
- hello := connection.Hello()
- fmt.Fprintf(stdout, "SDK connected: platform=%s app=%s sdk=%s\n", hello.Platform, hello.AppPackage, hello.Version)
-
- case "web":
- fmt.Fprintln(stdout, "web mode: skipping SDK")
- if err := activeDriver.Launch(ctx, options.BundleID, false, nil); err != nil {
- return fmt.Errorf("launch app: %w", err)
- }
+ if err := activeDriver.Launch(ctx, options.BundleID, false, nil); err != nil {
+ return fmt.Errorf("launch app: %w", err)
}
seed := options.Seed
@@ -211,15 +112,13 @@ func Execute(ctx context.Context, options Options, stdout io.Writer) error {
fmt.Fprintf(stdout, "running for %s (seed=%d)\n", options.Duration, seed)
summary, err := runner.Run(ctx, runner.Options{
- Duration: options.Duration,
- SnapshotTimeout: 5 * time.Second,
- IdleTimeout: 1 * time.Second,
- BundleID: options.BundleID,
- Connection: connection,
- Driver: activeDriver,
- Verifier: verifierInstance,
- TraceWriter: traceWriter,
- Logger: newProgressLogger(stdout),
+ Duration: options.Duration,
+ IdleTimeout: 1 * time.Second,
+ BundleID: options.BundleID,
+ Driver: activeDriver,
+ Verifier: verifierInstance,
+ TraceWriter: traceWriter,
+ Logger: newProgressLogger(stdout),
})
terminateCtx, terminateCancel := context.WithTimeout(context.Background(), 5*time.Second)
@@ -269,12 +168,3 @@ func resolveSpecAPIPath(specPath string) string {
}
return ""
}
-
-func pickFreePort() (int, error) {
- listener, err := net.Listen("tcp", "127.0.0.1:0")
- if err != nil {
- return 0, err
- }
- defer listener.Close()
- return listener.Addr().(*net.TCPAddr).Port, nil
-}
diff --git a/pkg/spec/src/types.ts b/pkg/spec/src/types.ts
index a33a612..5019187 100644
--- a/pkg/spec/src/types.ts
+++ b/pkg/spec/src/types.ts
@@ -3,6 +3,13 @@ export type Snapshots = Record;
export interface AccessibilityElement {
id?: string;
text?: string;
+ desc?: string;
+ class?: string;
+ clickable?: boolean;
+ enabled?: boolean;
+ checked?: boolean;
+ focused?: boolean;
+ selected?: boolean;
bounds?: { left: number; top: number; right: number; bottom: number };
x?: number;
y?: number;
diff --git a/sdk/android/build.gradle.kts b/sdk/android/build.gradle.kts
deleted file mode 100644
index 147ebef..0000000
--- a/sdk/android/build.gradle.kts
+++ /dev/null
@@ -1,98 +0,0 @@
-import com.vanniktech.maven.publish.AndroidSingleVariantLibrary
-import com.vanniktech.maven.publish.JavadocJar
-import com.vanniktech.maven.publish.SourcesJar
-
-plugins {
- id("com.android.library") version "8.13.0"
- kotlin("android") version "2.1.21"
- id("com.vanniktech.maven.publish") version "0.36.0"
- id("org.jetbrains.dokka") version "2.2.0"
- id("org.jetbrains.dokka-javadoc") version "2.2.0"
-}
-
-version = findProperty("sanderling.version") as String? ?: "0.0.0-dev"
-group = "io.github.priyanshujain.sanderling"
-
-android {
- namespace = "dev.sanderling.sdk"
- compileSdk = 35
-
- defaultConfig {
- minSdk = 24
- consumerProguardFiles("consumer-rules.pro")
- }
-
- compileOptions {
- sourceCompatibility = JavaVersion.VERSION_17
- targetCompatibility = JavaVersion.VERSION_17
- }
-
- kotlinOptions {
- jvmTarget = "17"
- }
-
- testOptions {
- unitTests.isReturnDefaultValues = true
- }
-}
-
-mavenPublishing {
- publishToMavenCentral(automaticRelease = true)
-
- // Sign only when a release-signing key is provided (env or Gradle
- // property). Unsigned runs are useful for `publishToMavenLocal` dry-runs;
- // CI always has the key set so the actual Central push is always signed.
- if (findProperty("signingInMemoryKey") != null) {
- signAllPublications()
- }
-
- configure(
- AndroidSingleVariantLibrary(
- javadocJar = JavadocJar.Dokka("dokkaGeneratePublicationJavadoc"),
- sourcesJar = SourcesJar.Sources(),
- variant = "release",
- ),
- )
-
- coordinates(
- groupId = "io.github.priyanshujain.sanderling",
- artifactId = "sdk-android",
- version = version.toString(),
- )
-
- pom {
- name.set("sanderling sdk-android")
- description.set(
- "Android runtime SDK for sanderling, a property-based UI fuzzer for mobile apps. " +
- "Exposes a content-provider accessibility bridge consumed by the sanderling CLI at test time.",
- )
- url.set("https://github.com/priyanshujain/sanderling")
-
- licenses {
- license {
- name.set("Apache License, Version 2.0")
- url.set("https://www.apache.org/licenses/LICENSE-2.0.txt")
- distribution.set("repo")
- }
- }
-
- developers {
- developer {
- id.set("priyanshujain")
- name.set("Priyanshu Jain")
- url.set("https://github.com/priyanshujain")
- }
- }
-
- scm {
- url.set("https://github.com/priyanshujain/sanderling")
- connection.set("scm:git:git://github.com/priyanshujain/sanderling.git")
- developerConnection.set("scm:git:ssh://git@github.com/priyanshujain/sanderling.git")
- }
- }
-}
-
-dependencies {
- testImplementation("junit:junit:4.13.2")
- testImplementation("org.json:json:20240303")
-}
diff --git a/sdk/android/consumer-rules.pro b/sdk/android/consumer-rules.pro
deleted file mode 100644
index 0b2914b..0000000
--- a/sdk/android/consumer-rules.pro
+++ /dev/null
@@ -1,2 +0,0 @@
-# ProGuard rules shipped to consumers of dev.sanderling:sdk-android.
-# None needed for v0.1. Socket and semaphore APIs are all reflection-free.
diff --git a/sdk/android/src/main/AndroidManifest.xml b/sdk/android/src/main/AndroidManifest.xml
deleted file mode 100644
index b2d3ea1..0000000
--- a/sdk/android/src/main/AndroidManifest.xml
+++ /dev/null
@@ -1,2 +0,0 @@
-
-
diff --git a/sdk/android/src/main/kotlin/dev/sanderling/sdk/ChoreographerPoster.kt b/sdk/android/src/main/kotlin/dev/sanderling/sdk/ChoreographerPoster.kt
deleted file mode 100644
index 6a2a939..0000000
--- a/sdk/android/src/main/kotlin/dev/sanderling/sdk/ChoreographerPoster.kt
+++ /dev/null
@@ -1,15 +0,0 @@
-package dev.sanderling.sdk
-
-import android.os.Handler
-import android.os.Looper
-import android.view.Choreographer
-
-class ChoreographerPoster : FrameCallbackPoster {
- private val mainHandler = Handler(Looper.getMainLooper())
-
- override fun postFrameCallback(callback: () -> Unit) {
- mainHandler.post {
- Choreographer.getInstance().postFrameCallback { callback() }
- }
- }
-}
diff --git a/sdk/android/src/main/kotlin/dev/sanderling/sdk/ExceptionRecorder.kt b/sdk/android/src/main/kotlin/dev/sanderling/sdk/ExceptionRecorder.kt
deleted file mode 100644
index a7e2ee7..0000000
--- a/sdk/android/src/main/kotlin/dev/sanderling/sdk/ExceptionRecorder.kt
+++ /dev/null
@@ -1,62 +0,0 @@
-package dev.sanderling.sdk
-
-import java.io.PrintWriter
-import java.io.StringWriter
-
-internal class ExceptionRecorder(private val capacity: Int = DEFAULT_CAPACITY) {
- data class Entry(
- val className: String,
- val message: String,
- val stackTrace: String,
- val unixMillis: Long,
- )
-
- private val buffer: ArrayDeque = ArrayDeque()
- private var chainedHandler: Thread.UncaughtExceptionHandler? = null
- @Volatile private var installed: Boolean = false
-
- @Synchronized
- fun install() {
- if (installed) return
- chainedHandler = Thread.getDefaultUncaughtExceptionHandler()
- Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
- record(throwable)
- chainedHandler?.uncaughtException(thread, throwable)
- }
- installed = true
- }
-
- @Synchronized
- fun uninstall() {
- if (!installed) return
- Thread.setDefaultUncaughtExceptionHandler(chainedHandler)
- chainedHandler = null
- installed = false
- }
-
- @Synchronized
- fun record(throwable: Throwable, now: Long = System.currentTimeMillis()) {
- val stackTrace = StringWriter().also { throwable.printStackTrace(PrintWriter(it)) }.toString()
- val entry = Entry(
- className = throwable.javaClass.name,
- message = throwable.message ?: "",
- stackTrace = stackTrace,
- unixMillis = now,
- )
- if (buffer.size >= capacity) {
- buffer.removeFirst()
- }
- buffer.addLast(entry)
- }
-
- @Synchronized
- fun drain(): List {
- val snapshot = buffer.toList()
- buffer.clear()
- return snapshot
- }
-
- companion object {
- const val DEFAULT_CAPACITY: Int = 50
- }
-}
diff --git a/sdk/android/src/main/kotlin/dev/sanderling/sdk/LocalAbstractTransport.kt b/sdk/android/src/main/kotlin/dev/sanderling/sdk/LocalAbstractTransport.kt
deleted file mode 100644
index 36ff117..0000000
--- a/sdk/android/src/main/kotlin/dev/sanderling/sdk/LocalAbstractTransport.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-package dev.sanderling.sdk
-
-import android.net.LocalSocket
-import android.net.LocalSocketAddress
-import java.io.IOException
-import java.io.InputStream
-import java.io.OutputStream
-
-class LocalAbstractTransport(private val socketName: String) : AgentTransport {
- @Throws(IOException::class)
- override fun connect(): AgentConnection {
- val socket = LocalSocket()
- socket.connect(LocalSocketAddress(socketName, LocalSocketAddress.Namespace.ABSTRACT))
- return LocalSocketConnection(socket)
- }
-
- private class LocalSocketConnection(private val socket: LocalSocket) : AgentConnection {
- override val input: InputStream = socket.inputStream
- override val output: OutputStream = socket.outputStream
- override fun close() {
- try { socket.shutdownInput() } catch (_: IOException) {}
- try { socket.shutdownOutput() } catch (_: IOException) {}
- socket.close()
- }
- }
-}
diff --git a/sdk/android/src/main/kotlin/dev/sanderling/sdk/Pauser.kt b/sdk/android/src/main/kotlin/dev/sanderling/sdk/Pauser.kt
deleted file mode 100644
index bcfaec3..0000000
--- a/sdk/android/src/main/kotlin/dev/sanderling/sdk/Pauser.kt
+++ /dev/null
@@ -1,51 +0,0 @@
-package dev.sanderling.sdk
-
-import java.util.concurrent.CountDownLatch
-import java.util.concurrent.Semaphore
-import java.util.concurrent.TimeUnit
-import java.util.concurrent.TimeoutException
-import java.util.concurrent.atomic.AtomicReference
-
-fun interface FrameCallbackPoster {
- fun postFrameCallback(callback: () -> Unit)
-}
-
-class Pauser(
- private val poster: FrameCallbackPoster,
- private val pauseTimeoutMillis: Long = 5_000L,
-) {
- @Volatile private var currentGate: Semaphore? = null
-
- /**
- * Schedules extractors to run on the frame-callback thread (the SDK's
- * "main thread" analogue) and blocks that thread after they complete
- * until release() is called or pauseTimeoutMillis elapses.
- * Returns the extractor output. Must be called from a worker thread.
- */
- @Throws(TimeoutException::class)
- fun pauseAndSnapshot(extractors: () -> Map): Map {
- val gate = Semaphore(0)
- val ready = CountDownLatch(1)
- val captured = AtomicReference>>()
-
- poster.postFrameCallback {
- captured.set(runCatching { extractors() })
- ready.countDown()
- try {
- gate.tryAcquire(pauseTimeoutMillis, TimeUnit.MILLISECONDS)
- } catch (_: InterruptedException) {
- Thread.currentThread().interrupt()
- }
- }
- currentGate = gate
-
- if (!ready.await(pauseTimeoutMillis, TimeUnit.MILLISECONDS)) {
- throw TimeoutException("extractors did not run within ${pauseTimeoutMillis}ms")
- }
- return captured.get().getOrThrow()
- }
-
- fun release() {
- currentGate?.release()
- }
-}
diff --git a/sdk/android/src/main/kotlin/dev/sanderling/sdk/Protocol.kt b/sdk/android/src/main/kotlin/dev/sanderling/sdk/Protocol.kt
deleted file mode 100644
index f7160b4..0000000
--- a/sdk/android/src/main/kotlin/dev/sanderling/sdk/Protocol.kt
+++ /dev/null
@@ -1,183 +0,0 @@
-package dev.sanderling.sdk
-
-import java.io.DataInputStream
-import java.io.DataOutputStream
-import java.io.IOException
-import java.io.InputStream
-import java.io.OutputStream
-import org.json.JSONArray
-import org.json.JSONObject
-
-enum class MessageType(val wire: String) {
- HELLO("HELLO"),
- PAUSE("PAUSE"),
- RESUME("RESUME"),
- STATE("STATE"),
- EXTRACT_RESULT("EXTRACT_RESULT"),
- GOODBYE("GOODBYE");
-
- companion object {
- fun fromWire(wire: String): MessageType =
- values().firstOrNull { it.wire == wire }
- ?: throw IOException("unknown message type: $wire")
- }
-}
-
-data class Message(
- val type: MessageType,
- val id: Long = 0L,
- val protocolVersion: Int = 0,
- val version: String? = null,
- val platform: String? = null,
- val appPackage: String? = null,
- val snapshots: Map? = null,
- val exceptions: List