Files
sanderling/examples/folio/README.md
T
pj 7623ca7a21 fix(folio): uninstall before installing in just ios
folio's signed-in session lives in the data container, which an install
over the top keeps, so a local run started right after just ios opened on
the previous run's Home screen and diverged at step 1. On CI's fresh
simulator the uninstall is a no-op, so the ios leg is unchanged.
2026-08-15 20:43:28 +05:30

4.1 KiB

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.