Remove in-app SDK (#43)

* chore: delete internal/agent package

* chore(build): remove sdk-android from gradle settings

* chore(makefile): remove sdk-android targets

* chore(ci): remove release-android job from release workflow

* chore(folio): remove sdk-android dependency

* chore(folio): remove SDK initialization from FolioApplication

* chore(folio): delete snapshot extractor files

* feat(folio): add balance to account card content description

* feat(folio): add hierarchy content descriptions to LedgerScreen

* refactor(folio): rewrite spec.ts to use ax extractors

* docs: remove in-app SDK from README

* feat(folio): add focused_input indicator to App

* docs: remove in-app SDK from index

* refactor(runner): remove agent SDK connection and snapshot step

* test(runner): update tests for SDK removal

* docs: remove Android SDK section from getting-started

* refactor(testrun): remove agent SDK connection setup

* docs: remove snapshots from writing-specs

* docs: remove in-app SDK from architecture doc

* docs(folio): update README for SDK removal

* docs: update per-step cycle diagram in architecture doc

* fix(folio): detect screens from unique element presence, not id: selectors

testTag() in Compose is not exposed as resource-id without testTagsAsResourceId.
Use desc: selectors for elements unique to each screen instead of id: path queries.

* feat(folio): add screen root contentDescription for scoped ax selection

Each screen root gets semantics { contentDescription = "ScreenName" } so
sanderling specs can scope element lookups through the screen: desc:LoginScreen > desc:login_submit.

* fix(folio): scope all ax selectors through screen root nodes

Use desc:ScreenName > desc:element path queries so every selector is
rooted at the screen level. focusedInput stays unscoped since it lives
in the app root, outside any screen.

* fix(folio): guard newAccountBalanceIsZero against navigation false positives

Scoped selectors return [] when not on HomeScreen so accounts vanish and
reappear as apparently-new on each visit. Skip the check when prev was empty.

* chore(folio): link @sanderling/spec to local pkg/spec for IDE type checking

* feat(spec): add desc, class, clickable, enabled, checked, focused, selected to AccessibilityElement

Runtime fields set by the verifier were missing from the TypeScript type,
causing linting errors on el.desc and related accesses in specs.

* chore(folio): switch to bun, add tsconfig.json for IDE type checking

- Remove package-lock.json, add bun.lock
- Add tsconfig.json so VSCode resolves @sanderling/spec types
- Fix parseAccount/parseLedgerRow to accept string | undefined
This commit is contained in:
pj authored and GitHub committed 2026-04-25 20:04:29 +07:00
1 parent 6c32fb0e1d
commit 776becdf4b
60 files changed
+242 -3298

No files matched your search

-41
View File
@@ -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
+3 -14
View File
@@ -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
+1 -1
View File
@@ -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).
+8 -16
View File
@@ -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<br/>(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.
+1 -1
View File
@@ -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).
+1 -11
View File
@@ -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:<version>")
}
```
## Your first run
### Android
-27
View File
@@ -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<number>((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<string, unknown>; // 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<number>((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:
+5 -6
View File
@@ -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/<timestamp>/`.
## 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
@@ -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 {
@@ -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
}
}
@@ -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 }
}
@@ -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 }
}
@@ -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<Any>()
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 }
}
@@ -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"
}
}
}
@@ -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) })
@@ -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) }) })
},
@@ -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),
@@ -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,
@@ -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",
@@ -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),
) {
@@ -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 }
}
@@ -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 }
}
@@ -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())
}
@@ -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<String, Any?> = emptyMap()
fun pauseAndSnapshot(extractors: () -> Map<String, Any?>): Map<String, Any?> {
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)
}
}
@@ -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<Any>()
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 }
}
@@ -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"
}
}
}
@@ -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<String, () -> 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 <value> (Maestro simctl launch).
@Suppress("UNCHECKED_CAST")
val args = NSProcessInfo.processInfo.arguments as? List<String> ?: 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 <T> snapshot(block: () -> T): SnapshotDelegate<T> = SnapshotDelegate(block)
}
class SnapshotDelegate<T>(private val block: () -> T) {
operator fun provideDelegate(thisRef: Any?, prop: KProperty<*>): ReadOnlyProperty<Any?, T> {
SanderlingIos.extract(prop.name.camelToSnakeCase(), block as () -> Any?)
return ReadOnlyProperty { _, _ -> block() }
}
}
@@ -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<sockaddr_in>()
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<sockaddr_in>().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)
}
}
+83
View File
@@ -0,0 +1,83 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "folio",
"devDependencies": {
"@sanderling/spec": "file:../../../pkg/spec",
},
},
},
"packages": {
"@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
"@esbuild/android-arm": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="],
"@esbuild/android-arm64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="],
"@esbuild/android-x64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="],
"@esbuild/darwin-arm64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="],
"@esbuild/darwin-x64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="],
"@esbuild/freebsd-arm64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="],
"@esbuild/freebsd-x64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="],
"@esbuild/linux-arm": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="],
"@esbuild/linux-arm64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="],
"@esbuild/linux-ia32": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="],
"@esbuild/linux-loong64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="],
"@esbuild/linux-mips64el": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="],
"@esbuild/linux-ppc64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="],
"@esbuild/linux-riscv64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="],
"@esbuild/linux-s390x": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="],
"@esbuild/linux-x64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="],
"@esbuild/netbsd-arm64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="],
"@esbuild/netbsd-x64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="],
"@esbuild/openbsd-arm64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="],
"@esbuild/openbsd-x64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="],
"@esbuild/openharmony-arm64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="],
"@esbuild/sunos-x64": ["@esbuild/[email protected]", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="],
"@esbuild/win32-arm64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="],
"@esbuild/win32-ia32": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="],
"@esbuild/win32-x64": ["@esbuild/[email protected]", "", { "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/[email protected]", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="],
"esbuild": ["[email protected]", "", { "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": ["[email protected]", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"get-tsconfig": ["[email protected]", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
"resolve-pkg-maps": ["[email protected]", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"tsx": ["[email protected]", "", { "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": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["[email protected]", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
}
}
-20
View File
@@ -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"
}
}
}
+1 -1
View File
@@ -3,6 +3,6 @@
"private": true,
"description": "Minimal sanderling integration example.",
"devDependencies": {
"@sanderling/spec": "^0.0.1-rc4"
"@sanderling/spec": "file:../../../pkg/spec"
}
}
+59 -21
View File
@@ -12,7 +12,6 @@ import {
interface Account {
id: string;
name: string;
balance: number;
}
@@ -21,31 +20,70 @@ interface LedgerRow {
signed: number;
}
const loggedIn = extract<boolean>(s => (s.snapshots.logged_in as boolean) ?? false);
const route = extract<string>(s => (s.snapshots.screen as string) ?? "");
const accounts = extract<Account[]>(s => (s.snapshots.accounts as Account[]) ?? []);
const ledgerRows = extract<LedgerRow[]>(s => (s.snapshots.ledger_rows as LedgerRow[]) ?? []);
const ledgerBalance = extract<number>(s => (s.snapshots.ledger_balance as number) ?? 0);
const activeAccountId = extract<string | null>(s => (s.snapshots.active_account_id as string | null) ?? null);
const focusedInput = extract<string | null>(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<string | null>(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 = "[email protected]";
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;
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true
},
"include": ["spec.ts"]
}
-127
View File
@@ -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
}
-153
View File
@@ -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)
}
}
}
-145
View File
@@ -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")
-347
View File
@@ -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")
}
}
+9 -96
View File
@@ -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"
@@ -21,11 +20,9 @@ import (
type Options struct {
Duration time.Duration
SnapshotTimeout 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)
@@ -168,9 +151,7 @@ func Run(ctx context.Context, options Options) (Summary, error) {
Index: stepIndex,
Timestamp: stepStart,
Screen: screen,
Snapshots: snapshot.Snapshots,
Action: traceAction,
Exceptions: traceExceptions(exceptions),
Violations: violations,
Hierarchy: tree,
Residuals: residuals,
@@ -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
}
+18 -195
View File
@@ -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,81 +59,22 @@ 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,
@@ -163,22 +96,13 @@ 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,
@@ -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}))
@@ -212,9 +134,7 @@ globalThis.actions = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:
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,
@@ -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,86 +169,15 @@ 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,
@@ -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
@@ -374,9 +217,7 @@ func TestRunner_LogsWaitForIdleDriverErrors(t *testing.T) {
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,
@@ -423,25 +264,18 @@ 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,
@@ -474,23 +308,14 @@ 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,
@@ -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)
+10
View File
@@ -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
}
+2 -112
View File
@@ -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,101 +68,9 @@ 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)
}
}
seed := options.Seed
if seed == 0 {
@@ -212,10 +113,8 @@ 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,
@@ -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
}
+7
View File
@@ -3,6 +3,13 @@ export type Snapshots = Record<string, unknown>;
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;
-98
View File
@@ -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://[email protected]/priyanshujain/sanderling.git")
}
}
}
dependencies {
testImplementation("junit:junit:4.13.2")
testImplementation("org.json:json:20240303")
}
-2
View File
@@ -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.
-2
View File
@@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -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() }
}
}
}
@@ -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<Entry> = 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<Entry> {
val snapshot = buffer.toList()
buffer.clear()
return snapshot
}
companion object {
const val DEFAULT_CAPACITY: Int = 50
}
}
@@ -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()
}
}
}
@@ -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<String, Any?>): Map<String, Any?> {
val gate = Semaphore(0)
val ready = CountDownLatch(1)
val captured = AtomicReference<Result<Map<String, Any?>>>()
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()
}
}
@@ -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<String, Any?>? = null,
val exceptions: List<Map<String, Any?>>? = 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<String, Any?>,
exceptions: List<Map<String, Any?>>? = 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
}
@@ -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 <T> snapshot(function: () -> T): SnapshotDelegate<T> = 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<T>(private val function: () -> T) {
operator fun provideDelegate(thisRef: Any?, prop: KProperty<*>): ReadOnlyProperty<Any?, T> {
Sanderling.extract(prop.name.camelToSnakeCase(), function as () -> Any?)
return ReadOnlyProperty { _, _ -> function() }
}
}
@@ -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<String, () -> 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<String, Any?> {
val drained = synchronized(extractors) { LinkedHashMap(extractors) }
val result = LinkedHashMap<String, Any?>(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"
}
}
@@ -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)
}
}
}
@@ -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)
}
}
}
@@ -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<Thread>()
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<Thread>()
// 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 <T> runOnWorker(block: () -> T): T {
val result = AtomicReference<Result<T>>()
val thread = Thread { result.set(runCatching { block() }) }
thread.start()
thread.join(5_000L)
return result.get().getOrThrow()
}
}
@@ -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<String, Any?>(
"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<String, Any?>(
"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)
}
}
@@ -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<String>()
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"])
}
}
@@ -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()
}
}
@@ -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<Endpoints>()
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<Throwable?>()
val messages = CopyOnWriteArrayList<Message>()
@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<Long>()
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))
}
}
-2
View File
@@ -17,5 +17,3 @@ dependencyResolutionManagement {
rootProject.name = "sanderling"
include(":sidecar")
include(":sdk-android")
project(":sdk-android").projectDir = file("sdk/android")