Files
sanderling/examples/folio
pj 40c39e93b9 fix(folio): the bound carries no unconfirmed-submit guard
deleting confirmedApplied here broke 0 of 355 tests: under a bound a submit
that may not have landed moves the balance by 0, which the bound already
permits, so the guard could only ever drop the double commit it exists to
catch. the relaunch guard stays for a reason the bound does not cover, and
both tests now assert a verdict that changes when their guard does.
2026-08-16 01:09:56 +05:30
..
2026-04-20 16:04:20 +07:00

Folio

A minimal Kotlin Multiplatform personal-ledger app: login with demo credentials, create accounts, add credits and debits. Shared UI across Android, iOS, and web (wasmJs via Compose for Web). Doubles as the example sanderling runs its property-based specs against.

Stack

  • Kotlin Multiplatform + Compose Multiplatform (shared UI)
  • SQLDelight for the data layer (unified across platforms)
  • kotlinx.coroutines for state flows
  • kotlinx.serialization for @Serializable route types

Prerequisites

  • just
  • JDK 17
  • Android SDK (auto-discovered under $ANDROID_HOME, ~/Library/Android/sdk, or the Homebrew cask)
  • Xcode 16+ and xcodegen (brew install xcodegen) for iOS

Android

just install      # build + install on a booted emulator / device
just uninstall
just clean

iOS

just ios                          # default device: iPhone 17 Pro
IOS_DEVICE="iPhone 15" just ios   # pick a different simulator

just ios regenerates app/iosApp/iosApp.xcodeproj from app/iosApp/project.yml, builds the KMP framework (Shared.framework from :app:shared), links it into the SwiftUI host, uninstalls any previous copy, installs, and launches. The uninstall matters: folio's signed-in session survives an install over the top, so without it a run opens on the last run's Home screen.

Web

just web         # webpack dev server with COOP/COEP headers
just web-build   # produce a webpack distributable bundle

just web runs :app:webApp:wasmJsBrowserDevelopmentRun --continuous, so edits to shared code reload in the browser.

Demo credentials

email:    [email protected]
password: ledger123

Run a sanderling test (Android)

just test

If no device is connected, sanderling boots the single AVD it finds. With multiple AVDs, pick one:

AVD=Pixel_7 just test

Persistent settings can live in .env alongside the justfile:

AVD=Pixel_7
DURATION=5m

Traces land in ./sanderling/runs/<timestamp>/.

Run with the LLM action generator

The same sanderling/spec.ts runs under either generator: --generator seeded (the default weighted fuzzer) or --generator llm, where a vision model picks from the SAME weighted candidate set (reading the screenshot plus a numbered, weight-annotated list of concrete actions) and returns one number. The spec's generator = llm({ model, instructions }) export configures it.

export OPENROUTER_API_KEY=sk-or-...   # or OPENAI_API_KEY=sk-... for OpenAI direct
just test-llm                         # or: sanderling test --generator llm --spec sanderling/spec.ts --bundle-id app.folio

OpenRouter wins when both keys are set. With a plain OpenAI key, drop the vendor prefix from the model id in spec.ts (gpt-5.4-nano, not openai/gpt-5.4-nano). The model must support image input and strict json_schema structured outputs. Each step is one multimodal call, so keep the duration / step budget modest. The trace records the model's reasoning, the chosen number, and source: "llm" on each action, so the replay UI shows why each pick was made.

Run a sanderling test (iOS)

just test-ios                          # default simulator: iPhone 17 Pro
IOS_DEVICE="iPhone 15" just test-ios   # pick a different simulator

just test-ios boots the simulator if needed, runs just ios to install and launch the app, then invokes sanderling test --platform ios. Same DURATION, SEED, and OUTPUT env vars as the Android target.

How it connects to sanderling

  • Each screen sets a stable Compose testTag (HomeScreen, AccountCard, LedgerRow, TxnAmount, ...). The Sanderling SDK resolves testTag to resource-id on Android and accessibilityIdentifier on iOS.
  • Identity for list items is the visible text content (account name; txn note + amount). No synthetic IDs encoded in semantics.
  • contentDescription is reserved for real accessibility labels, never as a data carrier.
  • 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.