Files
sanderling/docs/manual/writing-specs.md
T
pj 776becdf4b 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
2026-04-25 20:04:29 +07:00

5.2 KiB

title
title
Writing specs

Writing specs

A spec has three parts: extractors, properties, and actions.

import { extract, always, now, actions, weighted, Tap, taps, swipes } from "@sanderling/spec";

// 1. Extractors pull values from each observed state.
const loggedIn = extract((s) => !!s.ax.find("id:home-tab-bar"));

// 2. Properties are LTL formulas evaluated every step.
export const properties = {
  cartNeverNegative: always(() => cartCount.current >= 0),
};

// 3. Actions are a weighted tree of what sanderling is allowed to do.
export const actions = weighted(
  [10, taps],
  [2, swipes],
);

The Go runner calls into the JS runtime each step. Extractors re-read the current state. Properties re-evaluate with their residual formulas. The action generator returns a tree, and one leaf is sampled by weight and dispatched.

The State object

What extractors see:

interface State {
  ax: AccessibilityTree;               // view hierarchy
  screen: { id: string; hash: string };
  lastAction: Action | null;
  logs: LogEntry[];                    // since previous state
  exceptions: Exception[];
  time: number;                        // ms since run start
}

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.

Pattern: preconditions (login, onboarding)

sanderling has no setup phase and no fixtures. Preconditions are action generators with two properties:

  1. High weight, so they fire whenever applicable.
  2. Gated on a state extractor, so they return an empty tree when not applicable and self-disable once the precondition is met.
const onLoginScreen = extract((s) => !!s.ax.find("id:login-form"));

const doLogin = actions(() => {
  if (!onLoginScreen.current) return [];
  const emailField = state.ax.find("id:email-field");
  const signInButton = state.ax.find("id:sign-in-button");
  if (!emailField || !signInButton) return [];
  return [
    InputText({ into: emailField, text: "[email protected]" }),
    Tap({ on: signInButton }),
  ];
});

Stack these for onboarding, consent dialogs, cold-start flows:

const dismissOnboarding = actions(() => {
  const skip = state.ax.find("text:Skip");
  return skip ? [Tap({ on: skip })] : [];
});

export const actions = weighted(
  [100, dismissOnboarding],  // clear the path first
  [50,  doLogin],            // log in when the login screen appears
  [10,  taps],               // exploration
  [2,   swipes],
);

Lifecycle of a run:

Step 1:   fresh install, onboarding visible
          eligible: dismissOnboarding (weight 100)
          picks: Tap "Skip"

Step 2-3: login screen visible
          eligible: doLogin (weight 50)
          picks: InputText / Tap to sign in

Step 4+:  home screen, onboarding and login generators return []
          eligible: taps, swipes
          picks: autonomous exploration

Session state (tokens, keychain, prefs) persists through the rest of the run. If the app logs the user out mid-run, doLogin re-fires automatically. No retry logic, no special-casing.

Pattern: conditional properties

Use gating extractors the same way inside properties. Express "only check X when Y holds" with now(...).implies(...):

const loggedIn = extract((s) => !!s.ax.find("id:home-tab-bar"));

export const properties = {
  cartPersistsWhenLoggedIn: always(
    now(() => loggedIn.current).implies(now(() => cartCount.current !== undefined)),
  ),
};

implies, and, or, and not are methods on any formula. Combine them freely.

Pattern: eventually

always asserts something holds at every step. eventually asserts it holds at some step, usually with a time bound:

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: weighted exploration sub-trees

Nest weighted to group related actions and tune their collective rate:

export const actions = weighted(
  [100, dismissOnboarding],
  [50,  doLogin],
  [10,  taps],
  [2,   swipes],
  [1, weighted(
    [3, openLink("todos://home")],
    [1, openLink("todos://settings")],
    [1, openLink("todos://item/42/edit")],
  )],
);

Weights are relative within a tree, so nested trees get their own local budget. This is how you keep low-frequency but high-value actions (deep links, background/foreground, rotate) from drowning out normal tapping.

Anti-patterns

Positional taps. Tap({ on: { x: 100, y: 200 } }) works for a demo but breaks on any layout change. Always prefer an ax.find("id:...") reference.

Sleep or wait-for-time. Wait(3000) inside an action generator is a smell. If you need to wait for a condition, use an extractor and gate the next action on it.

Retry logic inside generators. Generators should be pure: given the same state they produce the same actions. Retry is the runner's responsibility.

Unbounded eventually. Without a .within(...), eventually never fails within a finite run. It just stays residual. Almost always you want a bound.