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
+298 -3354

No files matched your search

+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"]
}