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>? = null, - val extractor: String? = null, - val result: Any? = null, - val error: String? = null, - val reason: String? = null, -) { - companion object { - fun hello(version: String, platform: String, appPackage: String): Message = - Message( - MessageType.HELLO, - protocolVersion = Protocol.PROTOCOL_VERSION, - version = version, - platform = platform, - appPackage = appPackage, - ) - - fun pause(id: Long): Message = Message(MessageType.PAUSE, id = id) - - fun resume(id: Long): Message = Message(MessageType.RESUME, id = id) - - fun state( - id: Long, - snapshots: Map, - exceptions: List>? = null, - ): Message = Message( - MessageType.STATE, - id = id, - snapshots = snapshots, - exceptions = exceptions, - ) - - fun extractResult(id: Long, extractor: String, result: Any?, error: String? = null): Message = - Message(MessageType.EXTRACT_RESULT, id = id, extractor = extractor, result = result, error = error) - - fun goodbye(reason: String): Message = Message(MessageType.GOODBYE, reason = reason) - } -} - -object Protocol { - const val MAX_FRAME_SIZE: Int = 16 * 1024 * 1024 - - // Wire-format version. Must match agent.ProtocolVersion on the Go side. - const val PROTOCOL_VERSION: Int = 1 - - @Throws(IOException::class) - fun write(output: OutputStream, message: Message) { - val bytes = toJson(message).toString().toByteArray(Charsets.UTF_8) - if (bytes.size > MAX_FRAME_SIZE) { - throw IOException("frame of ${bytes.size} bytes exceeds maximum $MAX_FRAME_SIZE") - } - DataOutputStream(output).apply { - writeInt(bytes.size) - write(bytes) - flush() - } - } - - @Throws(IOException::class) - fun read(input: InputStream): Message { - val dataInput = DataInputStream(input) - val length = dataInput.readInt() - if (length < 0 || length > MAX_FRAME_SIZE) { - throw IOException("frame of $length bytes exceeds maximum $MAX_FRAME_SIZE") - } - val bytes = ByteArray(length) - dataInput.readFully(bytes) - return fromJson(JSONObject(String(bytes, Charsets.UTF_8))) - } - - private fun toJson(message: Message): JSONObject { - val json = JSONObject() - json.put("type", message.type.wire) - if (message.id != 0L) json.put("id", message.id) - if (message.protocolVersion != 0) json.put("protocol_version", message.protocolVersion) - message.version?.let { json.put("version", it) } - message.platform?.let { json.put("platform", it) } - message.appPackage?.let { json.put("app_package", it) } - message.snapshots?.let { snapshots -> - val snapshotsJson = JSONObject() - for ((key, value) in snapshots) { - snapshotsJson.put(key, wrap(value)) - } - json.put("snapshots", snapshotsJson) - } - message.exceptions?.let { exceptions -> - val array = JSONArray() - for (entry in exceptions) { - val entryJson = JSONObject() - for ((key, value) in entry) entryJson.put(key, wrap(value)) - array.put(entryJson) - } - json.put("exceptions", array) - } - message.extractor?.let { json.put("extractor", it) } - message.result?.let { json.put("result", wrap(it)) } - message.error?.let { json.put("error", it) } - message.reason?.let { json.put("reason", it) } - return json - } - - private fun fromJson(json: JSONObject): Message { - val typeString = json.optString("type", "") - if (typeString.isEmpty()) throw IOException("missing type") - return Message( - type = MessageType.fromWire(typeString), - id = json.optLong("id", 0L), - protocolVersion = json.optInt("protocol_version", 0), - version = json.optStringOrNull("version"), - platform = json.optStringOrNull("platform"), - appPackage = json.optStringOrNull("app_package"), - snapshots = json.optJSONObject("snapshots")?.let { snapshotsJson -> - snapshotsJson.keys().asSequence().associateWith { unwrap(snapshotsJson.get(it)) } - }, - exceptions = json.optJSONArray("exceptions")?.let { array -> - buildList { - for (index in 0 until array.length()) { - val item = array.optJSONObject(index) ?: continue - add(item.keys().asSequence().associateWith { unwrap(item.get(it)) }) - } - } - }, - extractor = json.optStringOrNull("extractor"), - result = if (json.has("result") && !json.isNull("result")) unwrap(json.get("result")) else null, - error = json.optStringOrNull("error"), - reason = json.optStringOrNull("reason"), - ) - } - - private fun wrap(value: Any?): Any = when (value) { - null -> JSONObject.NULL - is Number, is Boolean, is String -> value - is Map<*, *> -> JSONObject().also { json -> - for ((key, nested) in value) json.put(key.toString(), wrap(nested)) - } - is List<*> -> JSONArray().also { array -> - for (item in value) array.put(wrap(item)) - } - else -> value.toString() - } - - private fun unwrap(value: Any?): Any? = when (value) { - JSONObject.NULL, null -> null - is JSONObject -> value.keys().asSequence().associateWith { unwrap(value.get(it)) } - is JSONArray -> buildList { for (index in 0 until value.length()) add(unwrap(value.get(index))) } - else -> value - } - - private fun JSONObject.optStringOrNull(key: String): String? = - if (has(key) && !isNull(key)) getString(key) else null -} diff --git a/sdk/android/src/main/kotlin/dev/sanderling/sdk/Sanderling.kt b/sdk/android/src/main/kotlin/dev/sanderling/sdk/Sanderling.kt deleted file mode 100644 index 849ce4b..0000000 --- a/sdk/android/src/main/kotlin/dev/sanderling/sdk/Sanderling.kt +++ /dev/null @@ -1,72 +0,0 @@ -package dev.sanderling.sdk - -import android.app.Application -import android.util.Log -import kotlin.properties.ReadOnlyProperty -import kotlin.reflect.KProperty - -internal fun String.camelToSnakeCase(): String = buildString { - for ((i, c) in this@camelToSnakeCase.withIndex()) { - if (c.isUpperCase() && i > 0) append('_') - append(c.lowercaseChar()) - } -} - -data class Configuration( - val socketName: String = "sanderling-agent", - val pauseTimeoutMillis: Long = 5_000L, -) - -object Sanderling { - const val VERSION: String = "0.0.1" - private const val LOG_TAG = "Sanderling" - - @Volatile private var runtime: SanderlingRuntime? = null - - @JvmOverloads - @Synchronized - fun start(application: Application, configuration: Configuration = Configuration()) { - if (runtime != null) return - val newRuntime = SanderlingRuntime( - transport = LocalAbstractTransport(configuration.socketName), - pauser = Pauser(ChoreographerPoster(), configuration.pauseTimeoutMillis), - version = VERSION, - platform = "android", - appPackage = application.packageName, - ) - newRuntime.start() - runtime = newRuntime - Log.i(LOG_TAG, "SDK started (package=${application.packageName} socket=${configuration.socketName})") - } - - fun extract(name: String, function: () -> Any?) { - val activeRuntime = runtime - ?: throw IllegalStateException("Sanderling.start must be called before registering extractors") - activeRuntime.register(name, function) - } - - fun snapshot(function: () -> T): SnapshotDelegate = SnapshotDelegate(function) - - /** - * Records a caught [Throwable] so it surfaces in the next STATE message's - * exceptions field. Useful for coroutine CoroutineExceptionHandler, - * OkHttp interceptors, or anywhere else the host app catches errors it - * still wants verified against properties like noUncaughtExceptions. - */ - fun reportError(throwable: Throwable) { - runtime?.reportError(throwable) - } - - @Synchronized - internal fun stopForTest() { - runtime?.stop() - runtime = null - } -} - -class SnapshotDelegate(private val function: () -> T) { - operator fun provideDelegate(thisRef: Any?, prop: KProperty<*>): ReadOnlyProperty { - Sanderling.extract(prop.name.camelToSnakeCase(), function as () -> Any?) - return ReadOnlyProperty { _, _ -> function() } - } -} diff --git a/sdk/android/src/main/kotlin/dev/sanderling/sdk/SanderlingRuntime.kt b/sdk/android/src/main/kotlin/dev/sanderling/sdk/SanderlingRuntime.kt deleted file mode 100644 index 52e54b4..0000000 --- a/sdk/android/src/main/kotlin/dev/sanderling/sdk/SanderlingRuntime.kt +++ /dev/null @@ -1,97 +0,0 @@ -package dev.sanderling.sdk - -import android.util.Log - -internal class SanderlingRuntime( - transport: AgentTransport, - private val pauser: Pauser, - private val version: String, - private val platform: String, - private val appPackage: String, - private val exceptionRecorder: ExceptionRecorder = ExceptionRecorder(), -) { - private val extractors = LinkedHashMap Any?>() - @Volatile private var sender: SocketClient.MessageSender? = null - private val socketClient = SocketClient(transport, AgentHandler()) - - fun start() { - exceptionRecorder.install() - socketClient.start() - } - - fun stop() { - socketClient.stop() - exceptionRecorder.uninstall() - } - - fun register(name: String, extractor: () -> Any?) { - synchronized(extractors) { extractors[name] = extractor } - } - - fun reportError(throwable: Throwable) { - exceptionRecorder.record(throwable) - } - - internal fun snapshot(): Map { - val drained = synchronized(extractors) { LinkedHashMap(extractors) } - val result = LinkedHashMap(drained.size) - for ((name, extractor) in drained) { - result[name] = runCatching { extractor() } - .onFailure { cause -> Log.w(LOG_TAG, "extractor $name threw: $cause") } - .getOrNull() - } - return result - } - - private inner class AgentHandler : SocketClient.Handler { - override fun onConnected(sender: SocketClient.MessageSender) { - this@SanderlingRuntime.sender = sender - try { - sender.send(Message.hello(version, platform, appPackage)) - } catch (cause: Exception) { - Log.w(LOG_TAG, "failed to send HELLO: $cause") - } - } - - override fun onMessage(message: Message) { - when (message.type) { - MessageType.PAUSE -> handlePause(message.id) - MessageType.RESUME -> pauser.release() - MessageType.GOODBYE -> socketClient.stop() - else -> Log.w(LOG_TAG, "unexpected message type ${message.type} from host") - } - } - - override fun onDisconnected(cause: Throwable?) { - sender = null - pauser.release() - } - - private fun handlePause(id: Long) { - val snapshots = try { - pauser.pauseAndSnapshot { snapshot() } - } catch (cause: Exception) { - Log.w(LOG_TAG, "snapshot failed: $cause") - emptyMap() - } - val exceptions = exceptionRecorder.drain().map { entry -> - mapOf( - "class" to entry.className, - "message" to entry.message, - "stack_trace" to entry.stackTrace, - "unix_millis" to entry.unixMillis, - ) - }.takeIf { it.isNotEmpty() } - val activeSender = sender ?: return - try { - activeSender.send(Message.state(id, snapshots, exceptions)) - } catch (cause: Exception) { - Log.w(LOG_TAG, "failed to send STATE: $cause") - } - } - } - - companion object { - private const val LOG_TAG = "Sanderling" - } -} diff --git a/sdk/android/src/main/kotlin/dev/sanderling/sdk/SocketClient.kt b/sdk/android/src/main/kotlin/dev/sanderling/sdk/SocketClient.kt deleted file mode 100644 index fd8baf8..0000000 --- a/sdk/android/src/main/kotlin/dev/sanderling/sdk/SocketClient.kt +++ /dev/null @@ -1,108 +0,0 @@ -package dev.sanderling.sdk - -import java.io.IOException -import java.io.InputStream -import java.io.OutputStream -import java.util.concurrent.atomic.AtomicBoolean - -interface AgentTransport { - @Throws(IOException::class) - fun connect(): AgentConnection -} - -interface AgentConnection { - val input: InputStream - val output: OutputStream - fun close() -} - -data class Backoff( - val initialDelayMillis: Long = 500L, - val maxDelayMillis: Long = 10_000L, - val multiplier: Double = 2.0, -) { - fun next(previousDelayMillis: Long): Long = - if (previousDelayMillis <= 0L) initialDelayMillis - else minOf((previousDelayMillis * multiplier).toLong(), maxDelayMillis) -} - -class SocketClient( - private val transport: AgentTransport, - private val handler: Handler, - private val backoff: Backoff = Backoff(), - private val threadFactory: (Runnable) -> Thread = { runnable -> Thread(runnable, "sanderling-agent-reader") }, - private val sleeper: (Long) -> Unit = { millis -> if (millis > 0L) Thread.sleep(millis) }, -) { - interface Handler { - fun onConnected(sender: MessageSender) - fun onMessage(message: Message) - fun onDisconnected(cause: Throwable?) - } - - fun interface MessageSender { - @Throws(IOException::class) - fun send(message: Message) - } - - private val running = AtomicBoolean(false) - @Volatile private var workerThread: Thread? = null - @Volatile private var connection: AgentConnection? = null - - fun start() { - if (!running.compareAndSet(false, true)) return - val thread = threadFactory { runLoop() } - thread.isDaemon = true - workerThread = thread - thread.start() - } - - fun stop() { - if (!running.compareAndSet(true, false)) return - try { connection?.close() } catch (_: IOException) {} - workerThread?.interrupt() - try { workerThread?.join(1_000L) } catch (_: InterruptedException) { Thread.currentThread().interrupt() } - } - - private fun runLoop() { - var delayMillis = 0L - while (running.get()) { - val connection = try { - transport.connect() - } catch (e: IOException) { - handler.onDisconnected(e) - if (!running.get()) return - delayMillis = backoff.next(delayMillis) - try { sleeper(delayMillis) } catch (_: InterruptedException) { return } - continue - } - this.connection = connection - delayMillis = 0L - serve(connection) - if (!running.get()) return - delayMillis = backoff.next(delayMillis) - try { sleeper(delayMillis) } catch (_: InterruptedException) { return } - } - } - - private fun serve(connection: AgentConnection) { - val sender = MessageSender { message -> - synchronized(connection.output) { - Protocol.write(connection.output, message) - } - } - handler.onConnected(sender) - var disconnectCause: Throwable? = null - try { - while (running.get()) { - val message = Protocol.read(connection.input) - handler.onMessage(message) - } - } catch (e: IOException) { - disconnectCause = e - } finally { - try { connection.close() } catch (_: IOException) {} - this.connection = null - handler.onDisconnected(disconnectCause) - } - } -} diff --git a/sdk/android/src/test/kotlin/dev/sanderling/sdk/ExceptionRecorderTest.kt b/sdk/android/src/test/kotlin/dev/sanderling/sdk/ExceptionRecorderTest.kt deleted file mode 100644 index 78f54fe..0000000 --- a/sdk/android/src/test/kotlin/dev/sanderling/sdk/ExceptionRecorderTest.kt +++ /dev/null @@ -1,64 +0,0 @@ -package dev.sanderling.sdk - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -class ExceptionRecorderTest { - - @Test fun recordsClassMessageAndStackTrace() { - val recorder = ExceptionRecorder() - recorder.record(RuntimeException("boom")) - - val drained = recorder.drain() - assertEquals(1, drained.size) - val entry = drained[0] - assertEquals("java.lang.RuntimeException", entry.className) - assertEquals("boom", entry.message) - assertTrue( - "stackTrace should include the class name, got: ${entry.stackTrace}", - entry.stackTrace.contains("RuntimeException"), - ) - } - - @Test fun drainClearsBuffer() { - val recorder = ExceptionRecorder() - recorder.record(RuntimeException("first")) - recorder.record(RuntimeException("second")) - assertEquals(2, recorder.drain().size) - assertEquals(0, recorder.drain().size) - } - - @Test fun dropsOldestWhenOverCapacity() { - val recorder = ExceptionRecorder(capacity = 2) - recorder.record(RuntimeException("a")) - recorder.record(RuntimeException("b")) - recorder.record(RuntimeException("c")) - - val drained = recorder.drain() - assertEquals(2, drained.size) - assertEquals("b", drained[0].message) - assertEquals("c", drained[1].message) - } - - @Test fun installChainsExistingHandler() { - val recorder = ExceptionRecorder() - val original = Thread.getDefaultUncaughtExceptionHandler() - var chainedInvoked = false - Thread.setDefaultUncaughtExceptionHandler { _, _ -> chainedInvoked = true } - try { - recorder.install() - // Simulate an uncaught exception by invoking the installed handler - // directly — we don't need to actually terminate a thread. - Thread.getDefaultUncaughtExceptionHandler()!!.uncaughtException( - Thread.currentThread(), - IllegalStateException("chain me"), - ) - assertTrue("chained handler should have fired", chainedInvoked) - assertEquals(1, recorder.drain().size) - } finally { - recorder.uninstall() - Thread.setDefaultUncaughtExceptionHandler(original) - } - } -} diff --git a/sdk/android/src/test/kotlin/dev/sanderling/sdk/PauserTest.kt b/sdk/android/src/test/kotlin/dev/sanderling/sdk/PauserTest.kt deleted file mode 100644 index 281cfb0..0000000 --- a/sdk/android/src/test/kotlin/dev/sanderling/sdk/PauserTest.kt +++ /dev/null @@ -1,133 +0,0 @@ -package dev.sanderling.sdk - -import java.util.concurrent.CountDownLatch -import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit -import java.util.concurrent.TimeoutException -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicReference -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertTrue -import org.junit.Assert.fail -import org.junit.Test - -class PauserTest { - - // Runs posted callbacks on a dedicated "main thread" executor, modelling - // Choreographer's contract that callbacks fire off-thread from the caller. - class FakeFrameThread : FrameCallbackPoster { - val executor = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "fake-frame-thread") } - val threadRef = AtomicReference() - - override fun postFrameCallback(callback: () -> Unit) { - executor.submit { - threadRef.compareAndSet(null, Thread.currentThread()) - callback() - } - } - - fun shutdown() { executor.shutdownNow() } - } - - private lateinit var frameThread: FakeFrameThread - - @After fun tearDown() { - if (::frameThread.isInitialized) frameThread.shutdown() - } - - @Test fun extractorsRunOnFrameThreadAndSnapshotReturns() { - frameThread = FakeFrameThread() - val pauser = Pauser(frameThread, pauseTimeoutMillis = 2_000L) - val extractorThread = AtomicReference() - - // Run on a worker thread so we can observe the separation. - val snapshot = runOnWorker { - pauser.pauseAndSnapshot { - extractorThread.set(Thread.currentThread()) - mapOf("screen" to "home", "count" to 3) - }.also { snapshot -> - // Immediately release so the frame thread can exit the callback. - pauser.release() - snapshot - } - } - - assertEquals("home", snapshot["screen"]) - assertEquals(3, snapshot["count"]) - assertNotNull("extractor must have run", extractorThread.get()) - assertEquals("fake-frame-thread", extractorThread.get().name) - } - - @Test fun frameThreadStaysBlockedUntilRelease() { - frameThread = FakeFrameThread() - val pauser = Pauser(frameThread, pauseTimeoutMillis = 2_000L) - val releasedMarker = AtomicBoolean(false) - - val latch = CountDownLatch(1) - val worker = Thread { - pauser.pauseAndSnapshot { emptyMap() } - // Now the frame thread is blocked inside the callback. Verify by - // posting another callback and checking it does NOT run until we release. - val secondCallbackRan = CountDownLatch(1) - frameThread.postFrameCallback { secondCallbackRan.countDown() } - assertFalse("second callback should be queued, not run", - secondCallbackRan.await(200, TimeUnit.MILLISECONDS)) - - pauser.release() - assertTrue("second callback should run after release", - secondCallbackRan.await(2, TimeUnit.SECONDS)) - releasedMarker.set(true) - latch.countDown() - } - worker.start() - - assertTrue("worker must finish", latch.await(5, TimeUnit.SECONDS)) - assertTrue("release must have happened", releasedMarker.get()) - } - - @Test fun timeoutPropagatesWhenFrameThreadNeverRuns() { - val stuck = FrameCallbackPoster { /* never invokes callback */ } - val pauser = Pauser(stuck, pauseTimeoutMillis = 150L) - - try { - pauser.pauseAndSnapshot { emptyMap() } - fail("expected TimeoutException") - } catch (_: TimeoutException) { - // pass - } - } - - @Test fun extractorExceptionBubbles() { - frameThread = FakeFrameThread() - val pauser = Pauser(frameThread, pauseTimeoutMillis = 2_000L) - - try { - runOnWorker { - pauser.pauseAndSnapshot { - pauser.release() // drop the lock before throwing so main can unwind - throw IllegalStateException("extractor boom") - } - } - fail("expected IllegalStateException") - } catch (e: IllegalStateException) { - assertEquals("extractor boom", e.message) - } - } - - @Test fun releaseWithoutActivePauseIsNoOp() { - frameThread = FakeFrameThread() - val pauser = Pauser(frameThread, pauseTimeoutMillis = 2_000L) - pauser.release() // should not throw - } - - private fun runOnWorker(block: () -> T): T { - val result = AtomicReference>() - val thread = Thread { result.set(runCatching { block() }) } - thread.start() - thread.join(5_000L) - return result.get().getOrThrow() - } -} diff --git a/sdk/android/src/test/kotlin/dev/sanderling/sdk/ProtocolTest.kt b/sdk/android/src/test/kotlin/dev/sanderling/sdk/ProtocolTest.kt deleted file mode 100644 index dc900db..0000000 --- a/sdk/android/src/test/kotlin/dev/sanderling/sdk/ProtocolTest.kt +++ /dev/null @@ -1,181 +0,0 @@ -package dev.sanderling.sdk - -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import java.io.DataInputStream -import java.io.EOFException -import java.io.IOException -import org.junit.Assert.assertArrayEquals -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertThrows -import org.junit.Assert.assertTrue -import org.junit.Test - -class ProtocolTest { - - private fun roundTrip(message: Message): Message { - val output = ByteArrayOutputStream() - Protocol.write(output, message) - return Protocol.read(ByteArrayInputStream(output.toByteArray())) - } - - @Test fun roundTripHello() { - val got = roundTrip(Message.hello("0.0.1", "android", "in.okcredit.merchant")) - assertEquals(MessageType.HELLO, got.type) - assertEquals(Protocol.PROTOCOL_VERSION, got.protocolVersion) - assertEquals("0.0.1", got.version) - assertEquals("android", got.platform) - assertEquals("in.okcredit.merchant", got.appPackage) - } - - @Test fun roundTripPauseResume() { - val pause = roundTrip(Message.pause(42)) - assertEquals(MessageType.PAUSE, pause.type) - assertEquals(42L, pause.id) - - val resume = roundTrip(Message.resume(43)) - assertEquals(MessageType.RESUME, resume.type) - assertEquals(43L, resume.id) - } - - @Test fun roundTripState() { - val snapshots = mapOf( - "screen" to "customer_ledger", - "ledger.balance" to 1500, - "is_signed_in" to true, - ) - val got = roundTrip(Message.state(7, snapshots)) - assertEquals(MessageType.STATE, got.type) - assertEquals(7L, got.id) - assertNotNull(got.snapshots) - assertEquals("customer_ledger", got.snapshots!!["screen"]) - assertEquals(1500, got.snapshots["ledger.balance"]) - assertEquals(true, got.snapshots["is_signed_in"]) - } - - @Test fun roundTripExtractResult() { - val ok = roundTrip(Message.extractResult(1, "ledger.balance", 2500)) - assertEquals("ledger.balance", ok.extractor) - assertEquals(2500, ok.result) - assertNull(ok.error) - - val failed = roundTrip(Message.extractResult(2, "ledger.balance", null, "no active customer")) - assertEquals("no active customer", failed.error) - assertNull(failed.result) - } - - @Test fun roundTripGoodbye() { - val got = roundTrip(Message.goodbye("app terminated")) - assertEquals(MessageType.GOODBYE, got.type) - assertEquals("app terminated", got.reason) - } - - @Test fun frameFormatIsBigEndianLengthPlusJson() { - val output = ByteArrayOutputStream() - Protocol.write(output, Message.pause(99)) - val raw = output.toByteArray() - assertTrue("frame must have 4-byte header plus body", raw.size > 4) - val length = DataInputStream(ByteArrayInputStream(raw.copyOfRange(0, 4))).readInt() - assertEquals(raw.size - 4, length) - val payload = String(raw.copyOfRange(4, raw.size), Charsets.UTF_8) - assertTrue("payload should contain PAUSE type, got $payload", payload.contains("\"type\":\"PAUSE\"")) - } - - @Test fun emptyReaderThrowsEof() { - assertThrows(EOFException::class.java) { - Protocol.read(ByteArrayInputStream(ByteArray(0))) - } - } - - @Test fun oversizedFrameRejected() { - val header = ByteArray(4) - val tooBig = Protocol.MAX_FRAME_SIZE + 1 - header[0] = (tooBig ushr 24).toByte() - header[1] = (tooBig ushr 16).toByte() - header[2] = (tooBig ushr 8).toByte() - header[3] = tooBig.toByte() - val error = assertThrows(IOException::class.java) { - Protocol.read(ByteArrayInputStream(header)) - } - assertTrue("expected size error, got: ${error.message}", error.message!!.contains("exceeds maximum")) - } - - @Test fun missingTypeRejected() { - val payload = "{\"id\":1}".toByteArray(Charsets.UTF_8) - val header = ByteArray(4) - header[0] = (payload.size ushr 24).toByte() - header[1] = (payload.size ushr 16).toByte() - header[2] = (payload.size ushr 8).toByte() - header[3] = payload.size.toByte() - val frame = header + payload - val error = assertThrows(IOException::class.java) { - Protocol.read(ByteArrayInputStream(frame)) - } - assertTrue("expected missing-type error, got: ${error.message}", error.message!!.contains("missing type")) - } - - @Test fun roundTripStateWithExceptions() { - val exceptions = listOf( - mapOf( - "class" to "java.lang.RuntimeException", - "message" to "boom", - "stack_trace" to "at Foo.bar(Foo.kt:42)", - "unix_millis" to 1_700_000_000_000L, - ), - ) - val got = roundTrip(Message.state(3, mapOf("screen" to "home"), exceptions)) - assertNotNull(got.exceptions) - assertEquals(1, got.exceptions!!.size) - assertEquals("java.lang.RuntimeException", got.exceptions[0]["class"]) - assertEquals("boom", got.exceptions[0]["message"]) - } - - @Test fun streamsMultipleFrames() { - val messages = listOf( - Message.hello("v", "android", "com.x"), - Message.pause(1), - Message.state(1, mapOf("x" to 42)), - Message.resume(1), - Message.goodbye("done"), - ) - val output = ByteArrayOutputStream() - for (message in messages) Protocol.write(output, message) - - val input = ByteArrayInputStream(output.toByteArray()) - for (want in messages) { - val got = Protocol.read(input) - assertEquals(want.type, got.type) - } - } - - @Test fun sharedWireFormatMatchesGoEncoder() { - // Fixture encoded by the Go side (see internal/agent/protocol.go). - // Ensures both encoders agree on field names and ordering conventions. - val output = ByteArrayOutputStream() - Protocol.write(output, Message.hello("0.0.1", "android", "com.x")) - val payload = String(output.toByteArray().copyOfRange(4, output.size()), Charsets.UTF_8) - assertTrue(payload.contains("\"type\":\"HELLO\"")) - assertTrue(payload.contains("\"protocol_version\":1")) - assertTrue(payload.contains("\"version\":\"0.0.1\"")) - assertTrue(payload.contains("\"platform\":\"android\"")) - assertTrue(payload.contains("\"app_package\":\"com.x\"")) - } - - @Test fun bytesAreConsumedInOrder() { - // Regression guard: a second read shouldn't see stale bytes. - val output = ByteArrayOutputStream() - Protocol.write(output, Message.pause(1)) - Protocol.write(output, Message.pause(2)) - val bytes = output.toByteArray() - val input = ByteArrayInputStream(bytes) - assertEquals(1L, Protocol.read(input).id) - assertEquals(2L, Protocol.read(input).id) - // ByteArrayInputStream should be drained. - val leftover = ByteArray(bytes.size) - val remaining = input.read(leftover) - assertEquals(-1, remaining) - assertArrayEquals(ByteArray(bytes.size), leftover) - } -} diff --git a/sdk/android/src/test/kotlin/dev/sanderling/sdk/SanderlingRuntimeTest.kt b/sdk/android/src/test/kotlin/dev/sanderling/sdk/SanderlingRuntimeTest.kt deleted file mode 100644 index 438425e..0000000 --- a/sdk/android/src/test/kotlin/dev/sanderling/sdk/SanderlingRuntimeTest.kt +++ /dev/null @@ -1,146 +0,0 @@ -package dev.sanderling.sdk - -import java.util.concurrent.CopyOnWriteArrayList -import java.util.concurrent.CountDownLatch -import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicInteger -import org.junit.After -import org.junit.Assert.assertArrayEquals -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertTrue -import org.junit.Test - -class SanderlingRuntimeTest { - - private lateinit var transport: SocketClientTest.FakeTransport - private lateinit var frameThread: PauserTest.FakeFrameThread - private lateinit var runtime: SanderlingRuntime - - private fun newRuntime(): SanderlingRuntime { - transport = SocketClientTest.FakeTransport() - frameThread = PauserTest.FakeFrameThread() - val pauser = Pauser(frameThread, pauseTimeoutMillis = 2_000L) - return SanderlingRuntime( - transport = transport, - pauser = pauser, - version = "0.0.1", - platform = "android", - appPackage = "com.example.sanderling_test", - ).also { runtime = it } - } - - @After fun tearDown() { - if (::runtime.isInitialized) runtime.stop() - if (::frameThread.isInitialized) frameThread.shutdown() - } - - @Test fun startSendsHelloWithSdkMetadata() { - newRuntime().start() - val server = transport.nextServerEndpoint() - val hello = Protocol.read(server.input) - assertEquals(MessageType.HELLO, hello.type) - assertEquals("0.0.1", hello.version) - assertEquals("android", hello.platform) - assertEquals("com.example.sanderling_test", hello.appPackage) - } - - @Test fun pauseTriggersExtractorsAndReturnsState() { - newRuntime().start() - val server = transport.nextServerEndpoint() - Protocol.read(server.input) // drain HELLO - - runtime.register("screen") { "customer_ledger" } - runtime.register("ledger.balance") { 1500 } - - Protocol.write(server.output, Message.pause(7)) - val state = Protocol.read(server.input) - assertEquals(MessageType.STATE, state.type) - assertEquals(7L, state.id) - val snapshots = state.snapshots ?: error("state.snapshots must not be null") - assertEquals("customer_ledger", snapshots["screen"]) - assertEquals(1500, snapshots["ledger.balance"]) - - Protocol.write(server.output, Message.resume(7)) - // After resume, the frame thread can accept subsequent callbacks. - Protocol.write(server.output, Message.pause(8)) - val nextState = Protocol.read(server.input) - assertEquals(MessageType.STATE, nextState.type) - assertEquals(8L, nextState.id) - } - - @Test fun extractorInvocationOrderMatchesRegistration() { - val runtime = newRuntime() - val order = CopyOnWriteArrayList() - runtime.register("first") { order += "first"; 1 } - runtime.register("second") { order += "second"; 2 } - runtime.register("third") { order += "third"; 3 } - - val snapshot = runtime.snapshot() - assertEquals(listOf("first", "second", "third"), order) - assertEquals(listOf("first", "second", "third"), snapshot.keys.toList()) - assertArrayEquals(arrayOf(1, 2, 3), snapshot.values.toList().toTypedArray()) - } - - @Test fun extractorThrowIsIsolatedAndReportsNull() { - val runtime = newRuntime() - runtime.register("ok") { "value" } - runtime.register("boom") { throw IllegalStateException("oops") } - runtime.register("later") { 42 } - - val snapshot = runtime.snapshot() - assertEquals("value", snapshot["ok"]) - assertEquals(null, snapshot["boom"]) - assertEquals(42, snapshot["later"]) - } - - @Test fun concurrentExtractorRegistrationIsSafe() { - val runtime = newRuntime() - val registrations = 500 - val pool = Executors.newFixedThreadPool(8) - val latch = CountDownLatch(registrations) - val index = AtomicInteger(0) - repeat(registrations) { - pool.submit { - val id = index.getAndIncrement() - runtime.register("ext-$id") { id } - latch.countDown() - } - } - assertTrue(latch.await(5, TimeUnit.SECONDS)) - pool.shutdown() - - val snapshot = runtime.snapshot() - assertEquals(registrations, snapshot.size) - } - - @Test fun helloIsSentOnReconnect() { - val runtime = newRuntime() - val shortBackoff = Backoff(initialDelayMillis = 10L, maxDelayMillis = 10L, multiplier = 1.0) - // Swap in a client with short backoff by re-creating the runtime's client indirectly. - // We'll re-use the existing runtime instead and just close the first connection. - runtime.start() - val firstServer = transport.nextServerEndpoint() - Protocol.read(firstServer.input) // drain first HELLO - firstServer.close() - - val secondServer = transport.nextServerEndpoint(timeoutMillis = 3_000L) - val secondHello = Protocol.read(secondServer.input) - assertEquals(MessageType.HELLO, secondHello.type) - } - - @Test fun registerBeforeStartQueuesCorrectlyOnceStarted() { - val runtime = newRuntime() - runtime.register("early") { 1 } - runtime.register("middle") { 2 } - runtime.start() - runtime.register("late") { 3 } - - val snapshot = runtime.snapshot() - assertEquals(3, snapshot.size) - assertEquals(1, snapshot["early"]) - assertEquals(2, snapshot["middle"]) - assertEquals(3, snapshot["late"]) - } -} diff --git a/sdk/android/src/test/kotlin/dev/sanderling/sdk/SnapshotDelegateTest.kt b/sdk/android/src/test/kotlin/dev/sanderling/sdk/SnapshotDelegateTest.kt deleted file mode 100644 index d16c762..0000000 --- a/sdk/android/src/test/kotlin/dev/sanderling/sdk/SnapshotDelegateTest.kt +++ /dev/null @@ -1,95 +0,0 @@ -package dev.sanderling.sdk - -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Test - -class SnapshotDelegateTest { - - @After fun tearDown() = Sanderling.stopForTest() - - @Test fun camelToSnakeCaseConvertsKnownNames() { - assertEquals("logged_in", "loggedIn".camelToSnakeCase()) - assertEquals("total_balance", "totalBalance".camelToSnakeCase()) - assertEquals("txn_form_type", "txnFormType".camelToSnakeCase()) - assertEquals("active_account_id", "activeAccountId".camelToSnakeCase()) - assertEquals("add_account_error", "addAccountError".camelToSnakeCase()) - assertEquals("ledger_balance", "ledgerBalance".camelToSnakeCase()) - assertEquals("ledger_rows", "ledgerRows".camelToSnakeCase()) - assertEquals("auth_status", "authStatus".camelToSnakeCase()) - assertEquals("login_error", "loginError".camelToSnakeCase()) - assertEquals("txn_error", "txnError".camelToSnakeCase()) - assertEquals("account_count", "accountCount".camelToSnakeCase()) - assertEquals("focused_input", "focusedInput".camelToSnakeCase()) - assertEquals("txn_form_account_id", "txnFormAccountId".camelToSnakeCase()) - } - - @Test fun camelToSnakeCaseLeavesAlreadyLowercase() { - assertEquals("screen", "screen".camelToSnakeCase()) - assertEquals("accounts", "accounts".camelToSnakeCase()) - } - - @Test fun snapshotDelegateRegistersWithDerivedKey() { - val transport = SocketClientTest.FakeTransport() - val frameThread = PauserTest.FakeFrameThread() - val runtime = SanderlingRuntime( - transport = transport, - pauser = Pauser(frameThread, pauseTimeoutMillis = 2_000L), - version = "0.0.1", - platform = "android", - appPackage = "com.example.test", - ) - runtime.start() - // Inject runtime into Sanderling via reflection so snapshot() can register - val runtimeField = Sanderling::class.java.getDeclaredField("runtime") - runtimeField.isAccessible = true - runtimeField.set(Sanderling, runtime) - - var callCount = 0 - val obj = object { - val loggedIn by Sanderling.snapshot { callCount++; true } - } - - val snapshot = runtime.snapshot() - assertEquals(true, snapshot["logged_in"]) - assertEquals(1, callCount) - - // getValue delegates back to lambda - callCount = 0 - val value = obj.loggedIn - assertEquals(true, value) - assertEquals(1, callCount) - - runtime.stop() - frameThread.shutdown() - } - - @Test fun snapshotDelegateGetValueReturnsFreshResult() { - val transport = SocketClientTest.FakeTransport() - val frameThread = PauserTest.FakeFrameThread() - val runtime = SanderlingRuntime( - transport = transport, - pauser = Pauser(frameThread, pauseTimeoutMillis = 2_000L), - version = "0.0.1", - platform = "android", - appPackage = "com.example.test", - ) - runtime.start() - val runtimeField = Sanderling::class.java.getDeclaredField("runtime") - runtimeField.isAccessible = true - runtimeField.set(Sanderling, runtime) - - var counter = 0 - val obj = object { - val accountCount by Sanderling.snapshot { counter } - } - - counter = 5 - assertEquals(5, obj.accountCount) - counter = 10 - assertEquals(10, obj.accountCount) - - runtime.stop() - frameThread.shutdown() - } -} diff --git a/sdk/android/src/test/kotlin/dev/sanderling/sdk/SocketClientTest.kt b/sdk/android/src/test/kotlin/dev/sanderling/sdk/SocketClientTest.kt deleted file mode 100644 index fdc5035..0000000 --- a/sdk/android/src/test/kotlin/dev/sanderling/sdk/SocketClientTest.kt +++ /dev/null @@ -1,190 +0,0 @@ -package dev.sanderling.sdk - -import java.io.IOException -import java.io.PipedInputStream -import java.io.PipedOutputStream -import java.util.concurrent.CopyOnWriteArrayList -import java.util.concurrent.CountDownLatch -import java.util.concurrent.LinkedBlockingQueue -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicInteger -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertSame -import org.junit.Assert.assertTrue -import org.junit.Test - -class SocketClientTest { - - /** In-memory transport that pairs each connect() call with a matching - * server endpoint a test can read/write on. */ - class FakeTransport : AgentTransport { - data class Endpoints(val client: AgentConnection, val server: AgentConnection) - - private val pending = LinkedBlockingQueue() - val connectCount = AtomicInteger(0) - @Volatile var failNextConnect: Throwable? = null - - override fun connect(): AgentConnection { - connectCount.incrementAndGet() - failNextConnect?.let { cause -> - failNextConnect = null - throw IOException("connect failed", cause) - } - val toClient = PipedOutputStream() - val fromServer = PipedInputStream(toClient, 64 * 1024) - val toServer = PipedOutputStream() - val fromClient = PipedInputStream(toServer, 64 * 1024) - val client = pipedConnection(fromServer, toServer) - val server = pipedConnection(fromClient, toClient) - pending.offer(Endpoints(client, server)) - return client - } - - fun nextServerEndpoint(timeoutMillis: Long = 2_000L): AgentConnection { - val endpoints = pending.poll(timeoutMillis, TimeUnit.MILLISECONDS) - ?: error("no server endpoint emerged within $timeoutMillis ms") - return endpoints.server - } - - private fun pipedConnection(input: PipedInputStream, output: PipedOutputStream): AgentConnection { - return object : AgentConnection { - override val input = input - override val output = output - override fun close() { - try { input.close() } catch (_: IOException) {} - try { output.close() } catch (_: IOException) {} - } - } - } - } - - class RecordingHandler : SocketClient.Handler { - val connected = CountDownLatch(1) - val disconnected = LinkedBlockingQueue() - val messages = CopyOnWriteArrayList() - @Volatile var sender: SocketClient.MessageSender? = null - - override fun onConnected(sender: SocketClient.MessageSender) { - this.sender = sender - connected.countDown() - } - - override fun onMessage(message: Message) { - messages += message - } - - override fun onDisconnected(cause: Throwable?) { - disconnected.offer(cause ?: SentinelDisconnect) - } - - fun waitForMessages(expected: Int, timeoutMillis: Long = 2_000L) { - val deadline = System.currentTimeMillis() + timeoutMillis - while (messages.size < expected && System.currentTimeMillis() < deadline) Thread.sleep(5L) - assertEquals("expected $expected messages, got ${messages.size}", expected, messages.size) - } - } - - private object SentinelDisconnect : Throwable() - - private lateinit var client: SocketClient - - @After fun tearDown() { - if (::client.isInitialized) client.stop() - } - - @Test fun connectsAndDeliversIncomingMessages() { - val transport = FakeTransport() - val handler = RecordingHandler() - client = SocketClient(transport, handler).also { it.start() } - - val server = transport.nextServerEndpoint() - Protocol.write(server.output, Message.pause(1)) - Protocol.write(server.output, Message.resume(1)) - handler.waitForMessages(2) - - assertEquals(MessageType.PAUSE, handler.messages[0].type) - assertEquals(1L, handler.messages[0].id) - assertEquals(MessageType.RESUME, handler.messages[1].type) - } - - @Test fun sendGoesThroughOutputStream() { - val transport = FakeTransport() - val handler = RecordingHandler() - client = SocketClient(transport, handler).also { it.start() } - - assertTrue("connected latch must fire", handler.connected.await(2, TimeUnit.SECONDS)) - val sender = handler.sender ?: error("sender not set after onConnected") - - sender.send(Message.hello("0.0.1", "android", "com.x")) - - val server = transport.nextServerEndpoint() - val received = Protocol.read(server.input) - assertEquals(MessageType.HELLO, received.type) - assertEquals("com.x", received.appPackage) - } - - @Test fun reconnectsWithBackoffAfterConnectFailure() { - val transport = FakeTransport() - transport.failNextConnect = RuntimeException("no socket yet") - val handler = RecordingHandler() - - val observedSleeps = CopyOnWriteArrayList() - val shortBackoff = Backoff(initialDelayMillis = 25L, maxDelayMillis = 25L, multiplier = 1.0) - client = SocketClient( - transport, - handler, - backoff = shortBackoff, - sleeper = { millis -> observedSleeps += millis }, - ).also { it.start() } - - assertTrue("connected latch must fire after retry", handler.connected.await(2, TimeUnit.SECONDS)) - assertTrue("should have retried at least once", transport.connectCount.get() >= 2) - assertTrue("sleeper should have been called with backoff delay, got $observedSleeps", - observedSleeps.any { it > 0L }) - } - - @Test fun reconnectsAfterServerClosesConnection() { - val transport = FakeTransport() - val handler = RecordingHandler() - val shortBackoff = Backoff(initialDelayMillis = 10L, maxDelayMillis = 10L, multiplier = 1.0) - client = SocketClient(transport, handler, backoff = shortBackoff).also { it.start() } - - val firstServer = transport.nextServerEndpoint() - assertTrue(handler.connected.await(2, TimeUnit.SECONDS)) - firstServer.close() - - // Handler.onDisconnected should fire. - val cause = handler.disconnected.poll(2, TimeUnit.SECONDS) - assertNotNull("expected disconnect notification", cause) - - // A second connect() should happen — fetch the new server endpoint to prove it. - val secondServer = transport.nextServerEndpoint(timeoutMillis = 2_000L) - assertSame("second endpoint must exist", secondServer, secondServer) - assertTrue("connect count should be >= 2, got ${transport.connectCount.get()}", - transport.connectCount.get() >= 2) - } - - @Test fun stopClosesConnection() { - val transport = FakeTransport() - val handler = RecordingHandler() - client = SocketClient(transport, handler).also { it.start() } - - transport.nextServerEndpoint() - assertTrue(handler.connected.await(2, TimeUnit.SECONDS)) - client.stop() - - val cause = handler.disconnected.poll(2, TimeUnit.SECONDS) - assertNotNull("stop() should trigger onDisconnected", cause) - } - - @Test fun backoffGrowsExponentially() { - val backoff = Backoff(initialDelayMillis = 100L, maxDelayMillis = 800L, multiplier = 2.0) - assertEquals(100L, backoff.next(0L)) - assertEquals(200L, backoff.next(100L)) - assertEquals(400L, backoff.next(200L)) - assertEquals(800L, backoff.next(400L)) - assertEquals(800L, backoff.next(800L)) - } -} diff --git a/settings.gradle.kts b/settings.gradle.kts index 36dd1b3..572e45d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -17,5 +17,3 @@ dependencyResolutionManagement { rootProject.name = "sanderling" include(":sidecar") -include(":sdk-android") -project(":sdk-android").projectDir = file("sdk/android")