diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index b3aa99d..c6a4dd0 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -27,6 +27,9 @@ jobs: - name: Install pandoc run: sudo apt-get update && sudo apt-get install -y pandoc + - name: Install d2 + run: curl -fsSL https://d2lang.com/install.sh | sh -s -- --tag v0.7.1 + - name: Build site run: make docs diff --git a/.gitignore b/.gitignore index daca44a..495ccf9 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,10 @@ web/node_modules/ web/dist/ web/.vite/ +# d2 diagrams render into build/site/_assets/diagrams; keep sources only +/docs/_diagrams/*.svg +/docs/_diagrams/*.png + # coding agent files .claude/ .claude/* diff --git a/Makefile b/Makefile index 7277e25..9a24bd9 100644 --- a/Makefile +++ b/Makefile @@ -14,6 +14,8 @@ SANDERLING_BIN := bin/sanderling DOCS_SRC := $(shell find docs -type f -name '*.md' -not -path 'docs/_*') DOCS_OUT := $(patsubst docs/%.md,build/site/%.html,$(DOCS_SRC)) DOCS_TEMPLATE := docs/_template/page.html +DIAGRAM_SRC := $(shell find docs/_diagrams -type f -name '*.d2' 2>/dev/null) +DIAGRAM_OUT := $(patsubst docs/_diagrams/%.d2,build/site/_assets/diagrams/%.svg,$(DIAGRAM_SRC)) INSPECT_DIST := internal/inspect/dist WEB_DIST := web/dist @@ -82,14 +84,18 @@ test-kotlin: test-spec-api: cd pkg/spec-api && npm test --silent -docs: $(DOCS_OUT) build/site/_assets - @echo "built $(words $(DOCS_OUT)) pages to build/site" +docs: $(DOCS_OUT) build/site/_assets $(DIAGRAM_OUT) + @echo "built $(words $(DOCS_OUT)) pages, $(words $(DIAGRAM_OUT)) diagrams to build/site" build/site/_assets: docs/_assets @mkdir -p build/site @rm -rf $@ @cp -R $< $@ +build/site/_assets/diagrams/%.svg: docs/_diagrams/%.d2 + @mkdir -p $(dir $@) + @d2 --theme 301 --pad 20 $< $@ + build/site/%.html: docs/%.md $(DOCS_TEMPLATE) @mkdir -p $(dir $@) @pandoc $< --from=gfm --to=html5 --standalone \ diff --git a/README.md b/README.md index abbedaf..de60d00 100644 --- a/README.md +++ b/README.md @@ -13,3 +13,9 @@ Autonomous property-based testing for mobile apps. Specs in TypeScript. Core in - [Architecture](https://priyanshujain.github.io/sanderling/development/architecture.html) After a `sanderling test` run, browse traces locally with `sanderling inspect`. It opens a web UI for stepping through actions, screenshots, snapshots, residual formulas, and exceptions. + +--- + +sanderling + +> sanderling, a wading bird that probes the shoreline for bugs that lie beneath. \ No newline at end of file diff --git a/docs/_assets/inspect-ui.png b/docs/_assets/inspect-ui.png new file mode 100644 index 0000000..c62afd8 Binary files /dev/null and b/docs/_assets/inspect-ui.png differ diff --git a/docs/_assets/sanderling.jpeg b/docs/_assets/sanderling.jpeg new file mode 100644 index 0000000..9bf0673 Binary files /dev/null and b/docs/_assets/sanderling.jpeg differ diff --git a/docs/_assets/style.css b/docs/_assets/style.css index 51fb6ce..46531e6 100644 --- a/docs/_assets/style.css +++ b/docs/_assets/style.css @@ -6,6 +6,8 @@ --accent: #0a66c2; --code-bg: #f6f6f6; --sidebar-width: 240px; + --font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } @media (prefers-color-scheme: dark) { @@ -25,9 +27,9 @@ html { background: var(--bg); color: var(--fg); } body { margin: 0; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; - font-size: 16px; - line-height: 1.55; + font-family: var(--font-mono); + font-size: 14px; + line-height: 1.65; } .layout { @@ -49,20 +51,24 @@ body { .sidebar .brand { display: block; + font-family: var(--font-mono); font-weight: 700; - font-size: 1.25rem; + font-size: 1.1rem; + text-transform: uppercase; + letter-spacing: 0.08em; text-decoration: none; color: var(--fg); margin-bottom: 1.5rem; } .sidebar h3 { - font-size: 0.75rem; + font-family: var(--font-mono); + font-size: 0.7rem; text-transform: uppercase; - letter-spacing: 0.05em; + letter-spacing: 0.08em; color: var(--muted); margin: 1.25rem 0 0.25rem; - font-weight: 600; + font-weight: 500; } .sidebar nav ul { @@ -89,15 +95,32 @@ main { width: 100%; } -main article h1 { font-size: 2rem; margin: 0 0 1.5rem; line-height: 1.2; } -main article h2 { margin-top: 2.5rem; font-size: 1.4rem; } -main article h3 { margin-top: 2rem; font-size: 1.1rem; } +main article h1, +main article h2, +main article h3, +main article h4 { + font-family: var(--font-mono); + font-weight: 700; + letter-spacing: -0.01em; +} + +main article h1 { font-size: 1.9rem; margin: 0 0 1.5rem; line-height: 1.2; } +main article h2 { margin-top: 2.5rem; font-size: 1.3rem; } +main article h3 { margin-top: 2rem; font-size: 1.05rem; } + +main article img { + max-width: 100%; + height: auto; + display: block; + margin: 1.25rem 0; + border-radius: 4px; +} a { color: var(--accent); } code { - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 0.9em; + font-family: var(--font-mono); + font-size: 0.88em; background: var(--code-bg); padding: 0.1rem 0.3rem; border-radius: 3px; @@ -108,7 +131,8 @@ pre { padding: 1rem; border-radius: 4px; overflow-x: auto; - font-size: 0.875rem; + font-family: var(--font-mono); + font-size: 0.85rem; line-height: 1.5; } diff --git a/docs/_diagrams/architecture.d2 b/docs/_diagrams/architecture.d2 new file mode 100644 index 0000000..1abff15 --- /dev/null +++ b/docs/_diagrams/architecture.d2 @@ -0,0 +1,65 @@ +grid-rows: 2 +grid-gap: 40 + +test_run: { + label: "Test run" + grid-rows: 2 + grid-gap: 100 + + go_binary: sanderling (Go) { + direction: right + + bundler: Bundler\nesbuild + verifier: Verifier\ngoja + LTL + runner: Runner + driver: Driver + trace: Trace writer\nJSONL + PNG + + bundler -> verifier + verifier <-> runner + runner -> driver + runner -> trace + } + + platform: { + label: "" + direction: right + style.stroke-width: 0 + style.fill: transparent + + device: Emulator / device { + sdk: sanderling-sdk\npause / state\ncoverage / logs + } + + sidecar: Maestro sidecar (JVM) { + maestro: maestro-client + } + + sidecar.maestro -> device.sdk: UIAutomator + } + + go_binary.driver -> platform.sidecar.maestro: gRPC + go_binary.runner -> platform.device.sdk: Unix socket +} + +viewer: { + label: "Inspect" + grid-columns: 4 + grid-gap: 40 + + left_pad: "" { + style.stroke-width: 0 + style.fill: transparent + style.opacity: 0 + } + web: Web UI (React) + inspect: sanderling inspect\nHTTP + SSE + runs: runs/ { + shape: cylinder + } + + runs -> inspect + inspect -> web +} + +test_run.go_binary.trace -> viewer.runs diff --git a/docs/_template/page.html b/docs/_template/page.html index 9c7a44a..111c13c 100644 --- a/docs/_template/page.html +++ b/docs/_template/page.html @@ -4,6 +4,9 @@ $if(title)$$title$ · $endif$sanderling + + + @@ -17,6 +20,7 @@
  • Getting started
  • Writing specs
  • Runs
  • +
  • Inspect
  • CLI reference
  • Development

    diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 6264517..76b0a1c 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -6,30 +6,7 @@ title: Architecture Three processes, two transports. -```mermaid -flowchart TB - subgraph Go["sanderling (Go)"] - Bundler[Bundler
    esbuild] --> Verifier[Verifier
    goja + LTL] - Verifier <--> Runner[Runner] - Runner --> Trace[Trace writer
    JSONL + PNG] - Runner <--> Driver[Driver iface] - end - - subgraph Sidecar["Maestro Sidecar (JVM)"] - Maestro[maestro-client] - end - - subgraph Device["Emulator"] - subgraph App["Android app (debug)"] - SDK[sanderling-sdk
    pause / hierarchy
    logs / coverage] - end - end - - Driver -- gRPC --> Maestro - Maestro -- UIAutomator --> App - Runner -- Unix socket --> SDK - Trace --> Runs[(runs/)] -``` +sanderling architecture ## Processes @@ -48,6 +25,10 @@ flowchart TB The split exists for one reason: only real UI events need the cost of crossing process and OS-API boundaries. Introspection is cheap, frequent, and lives on a fast local socket directly to the app. +## Inspect UI + +`sanderling inspect` is a separate mode of the same Go binary. It serves an embedded React bundle and reads `runs/` from disk, streaming file-watcher events over SSE so the UI updates as new steps land. It has no connection to the sidecar or the SDK; it only consumes the trace artifacts. + ## Per-step cycle The heart of the system is: diff --git a/docs/index.md b/docs/index.md index b280ead..a4d626d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,21 +1,27 @@ --- -title: sanderling +title: Sanderling Manual --- -# sanderling +# Sanderling Manual 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. Alpha: Android emulator only. Scope of v0.1.0 is tracked in [issue #4](https://github.com/priyanshujain/sanderling/issues/4). -## Manual - [Getting started](./manual/getting-started.html) - [Writing specs](./manual/writing-specs.html) - [Runs](./manual/runs.html) +- [Inspect](./manual/inspect.html) - [CLI reference](./manual/cli.html) ## Development - [Design principles](./development/design-principles.html) - [Architecture](./development/architecture.html) + +--- + +sanderling + +> sanderling, a wading bird that probes the shoreline for bugs that lie beneath. diff --git a/docs/manual/getting-started.md b/docs/manual/getting-started.md index 2fd82f9..14c7b43 100644 --- a/docs/manual/getting-started.md +++ b/docs/manual/getting-started.md @@ -18,22 +18,10 @@ Run `sanderling doctor` to check the host environment. ### CLI -macOS arm64: - ```sh -curl -L https://github.com/priyanshujain/sanderling/releases/latest/download/sanderling__darwin_arm64.tar.gz | tar xz -./sanderling version +curl -fsSL https://raw.githubusercontent.com/priyanshujain/sanderling/master/install.sh | bash ``` -Linux amd64: - -```sh -curl -L https://github.com/priyanshujain/sanderling/releases/latest/download/sanderling__linux_amd64.tar.gz | tar xz -./sanderling version -``` - -Pre-built for `darwin/arm64`, `darwin/amd64`, `linux/amd64`, `linux/arm64`. - ### Spec package (npm) ```sh @@ -50,21 +38,22 @@ dependencies { ## Your first run -The repo ships a working sample at `examples/folio`. From that directory: +The repo ships a working sample at `examples/folio`, a Kotlin Multiplatform app with a TypeScript spec under `sanderling/spec.ts`. Install `just`, then from `examples/folio`: ```sh -npm install -(cd android && ./gradlew installDebug) -sanderling test \ - --spec spec.ts \ - --bundle-id app.folio \ - --platform android \ - --duration 2m +just install # build and install the folio APK on a booted emulator or device +just test # run the spec ``` -Pass `--avd ` only when no device is connected and you have multiple AVDs; otherwise sanderling uses the connected device or boots the single AVD it finds. +With no device connected and multiple AVDs, pick one: -When the run ends, the trace lands in `runs//`: +```sh +AVD=Pixel_7 just test +``` + +Persistent settings can live in a `.env` alongside the justfile (`AVD=Pixel_7`, `DURATION=5m`, and so on). + +When the run ends, the trace lands in `sanderling/runs//`: ``` runs/2026-04-18T12-34-56/ @@ -73,6 +62,6 @@ runs/2026-04-18T12-34-56/ └── meta.json ``` -Open the screenshots directory to scrub visually, or read `trace.jsonl` step by step. +Browse it with `sanderling inspect` (see [inspect](./inspect.html)), or read `trace.jsonl` step by step. Next: [writing specs](./writing-specs.html). diff --git a/docs/manual/inspect.md b/docs/manual/inspect.md index 788fdea..0990471 100644 --- a/docs/manual/inspect.md +++ b/docs/manual/inspect.md @@ -4,24 +4,15 @@ title: sanderling inspect # sanderling inspect -`sanderling inspect` is a local web UI for exploring runs produced by `sanderling test`. It reads `runs//meta.json` and `runs//trace.jsonl` and renders each step with its action, screenshot, snapshots, residual formulas, and exceptions. +Local web UI for exploring runs produced by `sanderling test`. Reads `runs//meta.json` and `runs//trace.jsonl` from disk. ``` sanderling inspect [run-or-runs-dir] [--port N] [--no-open] [--dev] ``` -The positional argument can be either a runs directory or a single run directory (auto-detected by the presence of `meta.json`). When omitted, it defaults to `./runs`. +The positional argument can be a runs directory or a single run directory (auto-detected by `meta.json`). Defaults to `./runs`. -## Layout - -The detail page uses a phone-dominant grid: - -- **Actions** (left): vertical step list. Steps with violations are marked with a red dot; steps with exceptions have a dashed-outline marker. -- **Screenshot** (center): the device screenshot for the current step. The runner's resolved tap target is overlaid as a red rectangle, the tap point as an outlined circle. Swipes show an arrow from start to end. -- **Snapshots** (top right): the current step's snapshots flattened into dotted-path rows. Values that changed since the previous step are highlighted; hover to see the previous value. -- **Properties** (middle right): one row per property with status (violated / pending / holds) and an expandable residual formula. -- **Exceptions** (bottom right): SDK-captured uncaught throwables. Stack traces expand inline. -- **Timeline** (bottom): per-property swimlane across all steps; click a cell to seek. +![sanderling inspect](../_assets/inspect-ui.png) ## Keyboard shortcuts @@ -31,31 +22,28 @@ The detail page uses a phone-dominant grid: | `k`, `Left` | Previous step | | `Shift+j`, `Shift+Right` | Jump 10 forward | | `Shift+k`, `Shift+Left` | Jump 10 back | -| `g` | First step | -| `G` | Last step | +| `g` / `G` | First / last step | | `.` | Next step with a violation | -## URLs +Arrow keys inside a tablist or listbox yield to those widgets. Use `j`/`k` when focus is on one. -- `/` — run index (auto-refreshes via SSE when new runs land) -- `/runs/:id` — redirects to step 1 -- `/runs/:id/steps/:n` — direct deep link +## Deep links -## Theme +`/runs/:id/steps/:n` links to a specific step. Use it in issues or PRs when pointing at a violation. -Defaults to the system color scheme via `prefers-color-scheme`. The `light`/`dark` button in the toolbar toggles a manual override stored in `localStorage`. +The run index auto-refreshes over SSE as new runs land, so `sanderling inspect` and `sanderling test` can run side by side. ## Development -Two-process loop: +Two-process loop while iterating on the UI: ``` -make web-dev # bun + vite, http://127.0.0.1:5173 +make web-dev # bun + vite on http://127.0.0.1:5173 make inspect-dev # sanderling inspect --dev, proxies non-API to 5173 ``` -For a single binary with embedded assets: +Single binary with the bundle embedded: ``` -make sanderling # builds web/dist, copies to internal/inspect/dist, then go build +make sanderling ``` diff --git a/docs/manual/writing-specs.md b/docs/manual/writing-specs.md index a6781e6..a6dae99 100644 --- a/docs/manual/writing-specs.md +++ b/docs/manual/writing-specs.md @@ -7,7 +7,7 @@ title: Writing specs A spec has three parts: extractors, properties, and actions. ```ts -import { extract, always, actions, weighted, Tap, taps, swipes } from "@sanderling/spec"; +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")); @@ -105,39 +105,29 @@ Session state (tokens, keychain, prefs) persists through the rest of the run. If ## Pattern: conditional properties -Use gating extractors the same way inside properties. Express "only check X when Y holds": +Use gating extractors the same way inside properties. Express "only check X when Y holds" with `now(...).implies(...)`: ```ts const loggedIn = extract((s) => !!s.ax.find("id:home-tab-bar")); export const properties = { - cartPersistsWhenLoggedIn: always(() => { - if (!loggedIn.current) return true; - return state.snapshots.cart_count !== undefined; - }), + cartPersistsWhenLoggedIn: always( + now(() => loggedIn.current).implies(now(() => cartCount.current !== undefined)), + ), }; ``` -When `implies` ships in v0.1.0, this becomes: +`implies`, `and`, `or`, and `not` are methods on any formula. Combine them freely. -```ts -cartPersistsWhenLoggedIn: always(() => - implies(loggedIn.current, () => cartCount.current !== undefined) -), -``` - -## Pattern: eventually (once the operator lands) +## Pattern: eventually `always` asserts something holds at every step. `eventually` asserts it holds at some step, usually with a time bound: ```ts -// v0.1.0+ -loginSucceedsWithin30s: eventually( - () => loggedIn.current -).within(30, "seconds"), +loginSucceedsWithin30s: eventually(() => loggedIn.current).within(30, "seconds"), ``` -Useful for liveness checks: the loading spinner eventually goes away, the deep link eventually lands on `/home`. +`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 diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..7498575 --- /dev/null +++ b/install.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# +# Installs the sanderling CLI on macOS or Linux. +# +# Usage: +# curl -fsSL https://raw.githubusercontent.com/priyanshujain/sanderling/master/install.sh | bash +# +# Environment: +# SANDERLING_VERSION Tag to install (default: latest release) +# SANDERLING_INSTALL Install prefix (default: $HOME/.sanderling); binary lands in $prefix/bin + +set -euo pipefail + +REPO="priyanshujain/sanderling" +PREFIX="${SANDERLING_INSTALL:-$HOME/.sanderling}" +BIN_DIR="$PREFIX/bin" + +os="$(uname -s)" +arch="$(uname -m)" +case "$os" in + Darwin) os=darwin ;; + Linux) os=linux ;; + *) echo "sanderling: unsupported OS '$os' (need Darwin or Linux)" >&2; exit 1 ;; +esac +case "$arch" in + x86_64|amd64) arch=amd64 ;; + arm64|aarch64) arch=arm64 ;; + *) echo "sanderling: unsupported arch '$arch' (need amd64 or arm64)" >&2; exit 1 ;; +esac + +version="${SANDERLING_VERSION:-}" +if [ -z "$version" ]; then + # /releases/latest skips pre-releases; fall back to /releases for the + # newest tag of any kind so alphas remain installable. + version="$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" 2>/dev/null \ + | awk -F'"' '/"tag_name":/ {print $4; exit}')" +fi +if [ -z "$version" ]; then + version="$(curl -fsSL "https://api.github.com/repos/$REPO/releases?per_page=1" \ + | awk -F'"' '/"tag_name":/ {print $4; exit}')" +fi +if [ -z "$version" ]; then + echo "sanderling: could not resolve a release tag" >&2; exit 1 +fi + +stripped="${version#v}" +tarball="sanderling_${stripped}_${os}_${arch}.tar.gz" +base="https://github.com/$REPO/releases/download/${version}" + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +echo "sanderling: downloading $tarball ($version)" +curl -fsSL -o "$tmp/$tarball" "$base/$tarball" +curl -fsSL -o "$tmp/checksums.txt" "$base/checksums.txt" + +if command -v sha256sum >/dev/null 2>&1; then + ( cd "$tmp" && grep " $tarball\$" checksums.txt | sha256sum -c - >/dev/null ) +elif command -v shasum >/dev/null 2>&1; then + ( cd "$tmp" && grep " $tarball\$" checksums.txt | shasum -a 256 -c - >/dev/null ) +else + echo "sanderling: no sha256 tool available, skipping checksum verification" >&2 +fi + +tar -xzf "$tmp/$tarball" -C "$tmp" +mkdir -p "$BIN_DIR" +mv "$tmp/sanderling" "$BIN_DIR/sanderling" +chmod +x "$BIN_DIR/sanderling" + +echo "sanderling: installed $version to $BIN_DIR/sanderling" + +case ":$PATH:" in + *":$BIN_DIR:"*) ;; + *) echo "sanderling: add $BIN_DIR to PATH, e.g. 'export PATH=\"$BIN_DIR:\$PATH\"'" ;; +esac