From e62319e916a0932b6ac20443601a2d8c22d86f58 Mon Sep 17 00:00:00 2001 From: pjay Date: Sat, 18 Apr 2026 14:00:57 +0700 Subject: [PATCH] docs: pandoc-based site and v0.1.0 groundwork (#5) * chore(prose): remove em-dashes from config files * chore(prose): remove em-dashes from android sdk config * docs(spec-api): remove em-dash from README * fix(doctor): reword sidecar-jar error without em-dash * test(sidecar): reword assertion message without em-dash * docs: add CLAUDE.md with project conventions * build: add docs target for pandoc site * docs(site): add pandoc template and stylesheet * docs(site): add pandoc build script * docs(site): add landing pages * docs(manual): add getting-started * docs(manual): add writing-specs * docs(manual): add runs * docs(manual): add cli reference * docs(dev): add design principles * docs(dev): add architecture * ci: deploy docs site to github pages * docs: rewrite README as entry point to docs site --- .env.local.example | 2 +- .github/workflows/ci.yml | 4 +- .github/workflows/docs.yml | 45 ++++ .gitignore | 6 +- CLAUDE.md | 25 +++ Makefile | 7 +- README.md | 52 +---- cmd/uatu/doctor.go | 2 +- docs/_assets/style.css | 174 ++++++++++++++++ docs/_template/page.html | 52 +++++ docs/development/architecture.md | 69 +++++++ docs/development/design-principles.md | 49 +++++ docs/development/index.md | 21 ++ docs/index.md | 21 ++ docs/manual/cli.md | 41 ++++ docs/manual/getting-started.md | 76 +++++++ docs/manual/index.md | 10 + docs/manual/runs.md | 65 ++++++ docs/manual/writing-specs.md | 193 ++++++++++++++++++ pkg/spec-api/README.md | 2 +- pkg/spec-api/package.json | 2 +- scripts/build-docs.sh | 52 +++++ sdk/android/build.gradle.kts | 2 +- sdk/android/consumer-rules.pro | 2 +- .../test/kotlin/dev/uatu/sidecar/MainTest.kt | 2 +- 25 files changed, 920 insertions(+), 56 deletions(-) create mode 100644 .github/workflows/docs.yml create mode 100644 CLAUDE.md create mode 100644 docs/_assets/style.css create mode 100644 docs/_template/page.html create mode 100644 docs/development/architecture.md create mode 100644 docs/development/design-principles.md create mode 100644 docs/development/index.md create mode 100644 docs/index.md create mode 100644 docs/manual/cli.md create mode 100644 docs/manual/getting-started.md create mode 100644 docs/manual/index.md create mode 100644 docs/manual/runs.md create mode 100644 docs/manual/writing-specs.md create mode 100755 scripts/build-docs.sh diff --git a/.env.local.example b/.env.local.example index 6380f95..bed23ea 100644 --- a/.env.local.example +++ b/.env.local.example @@ -3,7 +3,7 @@ # Copy to `.env.local` (gitignored) and fill in values ONLY if you need to # fire a real release from your laptop. Day-to-day work and the `release-cli` # / `release-android-local` / `release-npm-dry` Make targets don't need any -# of these — they're snapshot/local-only. +# of these. They're snapshot/local-only. # # In CI, these are provided via GitHub Actions secrets (see .github/workflows/release.yml). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 057378d..12de457 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,8 @@ name: ci on: - # Runs on PRs (opened / synchronize / reopened — the defaults) and manual - # dispatch only. We deliberately don't run on direct pushes to master: + # Runs on PRs (opened / synchronize / reopened, which are the defaults) and + # manual dispatch only. We deliberately don't run on direct pushes to master: # master is PR-merge-only, and PR validation already covers the merge # commit via the `synchronize` event on the PR branch. pull_request: diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..3141fe2 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,45 @@ +name: docs + +on: + push: + branches: [master] + paths: + - "docs/**" + - "scripts/build-docs.sh" + - ".github/workflows/docs.yml" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install pandoc + run: sudo apt-get update && sudo apt-get install -y pandoc + + - name: Build site + run: ./scripts/build-docs.sh + + - uses: actions/upload-pages-artifact@v3 + with: + path: build/site + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/deploy-pages@v4 + id: deployment diff --git a/.gitignore b/.gitignore index 511bea6..4ca7534 100644 --- a/.gitignore +++ b/.gitignore @@ -27,7 +27,7 @@ node_modules/ .env .env.local -# Sidecar fat JAR — build artifact copied in by `make uatu` before +# Sidecar fat JAR. Build artifact copied in by `make uatu` before # `go build -tags withsidecar`. Never commit: it's ~130 MB. internal/sidecar/assets/sidecar-all.jar @@ -36,3 +36,7 @@ pkg/spec-api/dist/ # goreleaser local output dist/ + +# coding agent files +.claude/ +.claude/* \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b73800f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,25 @@ +## Project Guidelines + +- Do not call the task done until it is fully complete and tested. +- Always write tests for new features and bug fixes. +- Do not dismiss bug as a pre-existing" issue even if it was present before your change. It does not matter, it's still your responsibility to fix it. When you see a bug, fix it. Don't ignore it. +- Always build a feature in a new branch. Do not push directly to the main branch. Always check current branch before pushing. + +## Coding Guidelines + + - Keep code simple and easy to read. + - Avoid excessive comments. Only comment when absolutely necessary. + - Create WIP pull requests when you start working on a feature, and update the PR as you make progress + +## Git Branch Rules + + - No slashes in branch names (e.g., use `fix-something` not `fix/something`). + +## Git Commit Rules + + - Commit after every small, atomic change. Each commit should touch 1-3 files max. + - Use conventional commit format: `feat|fix|refactor|docs|test|chore|ci(scope): message` + - Never use `git add .` or `git add -A`. Always stage specific files by name. + - Keep commits small: aim for under 20 lines changed per commit. + - Don't batch multiple unrelated changes into one commit. + - Commit early and often. A working 5-line change is better than a pending 200-line change. diff --git a/Makefile b/Makefile index 6afbc01..33e1688 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ SIDECAR_EMBED := internal/sidecar/assets/sidecar-all.jar SDK_AAR := sdk/android/build/outputs/aar/sdk-android-release.aar UATU_BIN := bin/uatu -.PHONY: bootstrap proto sidecar sdk-android sdk-android-publish uatu test test-go test-kotlin test-spec-api clean release-cli release-android-local release-npm-dry +.PHONY: bootstrap proto sidecar sdk-android sdk-android-publish uatu test test-go test-kotlin test-spec-api docs clean release-cli release-android-local release-npm-dry bootstrap: $(GO) mod download @@ -52,9 +52,12 @@ test-kotlin: test-spec-api: cd pkg/spec-api && npm test --silent +docs: + ./scripts/build-docs.sh + clean: $(GO) clean - rm -rf bin dist pkg/spec-api/dist + rm -rf bin dist pkg/spec-api/dist build/site $(GRADLE) clean # Local release dry-runs. None of these touch remote registries. diff --git a/README.md b/README.md index 85ff953..e356236 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,15 @@ # uatu -Testing framework and spec in ts/js used for blackbox testing and property based testing +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. Full scope in the [v0.1.0 roadmap](https://github.com/priyanshujain/uatu/issues/4). -## Supported Platforms -- android -- ios +## Docs -## Install +Everything is in the docs site: **[priyanshujain.github.io/uatu](https://priyanshujain.github.io/uatu/)** -### CLI +Or browse the sources in [`docs/`](./docs). -Download the platform tarball from [GitHub Releases](https://github.com/priyanshujain/uatu/releases/latest): - -```sh -# macOS arm64 -curl -L https://github.com/priyanshujain/uatu/releases/latest/download/uatu__darwin_arm64.tar.gz | tar xz -# Linux amd64 -curl -L https://github.com/priyanshujain/uatu/releases/latest/download/uatu__linux_amd64.tar.gz | tar xz - -./uatu version -``` - -Pre-built for `darwin/arm64`, `darwin/amd64`, `linux/amd64`, `linux/arm64`. - -### Spec API (npm) - -```sh -npm install --save-dev @uatu/spec -``` - -```ts -import { extract, always, actions } from "@uatu/spec"; -``` - -### Android SDK (Maven Central) - -```kotlin -// settings.gradle.kts -dependencyResolutionManagement { - repositories { - mavenCentral() - } -} - -// app/build.gradle.kts -dependencies { - implementation("io.github.priyanshujain:sdk-android:") -} -``` +- [Getting started](./docs/manual/getting-started.md) +- [Writing specs](./docs/manual/writing-specs.md) +- [Architecture](./docs/development/architecture.md) diff --git a/cmd/uatu/doctor.go b/cmd/uatu/doctor.go index 5e9d095..4e32c02 100644 --- a/cmd/uatu/doctor.go +++ b/cmd/uatu/doctor.go @@ -30,7 +30,7 @@ func defaultDoctorChecks() []doctorCheck { func checkSidecarJAR(_ context.Context) error { if sidecar.IsPlaceholder() { - return fmt.Errorf("placeholder JAR embedded — run `make sidecar && make uatu` to embed the real fat JAR") + return fmt.Errorf("placeholder JAR embedded; run `make sidecar && make uatu` to embed the real fat JAR") } if sidecar.EmbeddedSize() == 0 { return fmt.Errorf("embedded JAR is empty") diff --git a/docs/_assets/style.css b/docs/_assets/style.css new file mode 100644 index 0000000..51fb6ce --- /dev/null +++ b/docs/_assets/style.css @@ -0,0 +1,174 @@ +:root { + --bg: #ffffff; + --fg: #2a2a2a; + --muted: #6a6a6a; + --border: #e5e5e5; + --accent: #0a66c2; + --code-bg: #f6f6f6; + --sidebar-width: 240px; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0f0f10; + --fg: #e5e5e5; + --muted: #a0a0a0; + --border: #2a2a2a; + --accent: #4aa3ff; + --code-bg: #1a1a1b; + } +} + +* { box-sizing: border-box; } + +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; +} + +.layout { + display: grid; + grid-template-columns: var(--sidebar-width) 1fr; + min-height: 100vh; + max-width: 1280px; + margin: 0 auto; +} + +.sidebar { + border-right: 1px solid var(--border); + padding: 1.5rem 1rem; + position: sticky; + top: 0; + height: 100vh; + overflow-y: auto; +} + +.sidebar .brand { + display: block; + font-weight: 700; + font-size: 1.25rem; + text-decoration: none; + color: var(--fg); + margin-bottom: 1.5rem; +} + +.sidebar h3 { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted); + margin: 1.25rem 0 0.25rem; + font-weight: 600; +} + +.sidebar nav ul { + list-style: none; + padding: 0; + margin: 0; +} + +.sidebar nav li a { + display: block; + padding: 0.2rem 0; + color: var(--fg); + text-decoration: none; + font-size: 0.95rem; +} + +.sidebar nav li a:hover { + color: var(--accent); +} + +main { + padding: 2rem 2.5rem 4rem; + max-width: 860px; + 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; } + +a { color: var(--accent); } + +code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.9em; + background: var(--code-bg); + padding: 0.1rem 0.3rem; + border-radius: 3px; +} + +pre { + background: var(--code-bg); + padding: 1rem; + border-radius: 4px; + overflow-x: auto; + font-size: 0.875rem; + line-height: 1.5; +} + +pre code { + background: transparent; + padding: 0; + font-size: inherit; +} + +table { + border-collapse: collapse; + margin: 1rem 0; + width: 100%; + font-size: 0.95rem; +} + +th, td { + border: 1px solid var(--border); + padding: 0.5rem 0.75rem; + text-align: left; + vertical-align: top; +} + +th { background: var(--code-bg); font-weight: 600; } + +blockquote { + border-left: 3px solid var(--border); + padding-left: 1rem; + margin: 1rem 0; + color: var(--muted); +} + +hr { + border: none; + border-top: 1px solid var(--border); + margin: 2rem 0; +} + +footer { + margin-top: 4rem; + padding-top: 1rem; + border-top: 1px solid var(--border); + font-size: 0.85rem; + color: var(--muted); +} + +footer a { color: var(--muted); } + +.mermaid { + text-align: center; + margin: 1.5rem 0; +} + +@media (max-width: 768px) { + .layout { grid-template-columns: 1fr; } + .sidebar { + position: static; + height: auto; + border-right: none; + border-bottom: 1px solid var(--border); + } + main { padding: 1.5rem; } +} diff --git a/docs/_template/page.html b/docs/_template/page.html new file mode 100644 index 0000000..c59666d --- /dev/null +++ b/docs/_template/page.html @@ -0,0 +1,52 @@ + + + + + +$if(title)$$title$ · $endif$uatu + + + + +
+ +
+
+$body$ +
+ +
+
+ + + diff --git a/docs/development/architecture.md b/docs/development/architecture.md new file mode 100644 index 0000000..6a145da --- /dev/null +++ b/docs/development/architecture.md @@ -0,0 +1,69 @@ +--- +title: Architecture +--- + +# Architecture + +Three processes, two transports. + +```mermaid +flowchart TB + subgraph Go["uatu (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[uatu-sdk
pause / hierarchy
logs / coverage] + end + end + + Driver -- gRPC --> Maestro + Maestro -- UIAutomator --> App + Runner -- Unix socket --> SDK + Trace --> Runs[(runs/)] +``` + +## Processes + +**uatu (Go).** The top-level binary. Bundles the spec with esbuild, evaluates it in goja, runs the main loop, dispatches actions through the driver, writes the trace. + +**Maestro sidecar (JVM).** A Kotlin process that wraps `maestro-client` and exposes a gRPC surface matching the `driver.Driver` interface. Handles UI input, screenshots, the system accessibility tree, and OS-level alerts. + +**In-app SDK.** A Kotlin (or Swift for iOS) library linked into the app under test. Exposes a Unix socket to the runner. Provides pause and resume, view-hierarchy dumps, coverage reads, log capture, and user-registered state extractors. + +## Transports + +| Channel | Transport | Purpose | +|---|---|---| +| Go to Maestro sidecar | gRPC (localhost TCP) | UI input, screenshots, system alerts | +| Go to in-app SDK | Unix domain socket | Pause / resume, hierarchy, coverage, logs, extractors | + +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. + +## Per-step cycle + +The heart of the system is: + +``` +pause ─► capture state ─► evaluate properties ─► pick action ─► resume ─► dispatch +``` + +1. The runner asks the driver to wait until the UI is idle. +2. The runner sends `PAUSE` to the SDK over the agent socket. The SDK freezes the main runloop at a safe point. +3. The SDK sends back a `STATE` message: view hierarchy, coverage delta, logs since last step, exception list, snapshot values. +4. The runner feeds state into goja. Extractors re-read; properties re-evaluate; the action generator returns a weighted tree. +5. The runner writes the trace entry for this step. +6. The runner picks an action by weight. +7. The runner sends `RESUME` to the SDK, then dispatches the action through the driver (gRPC to sidecar, which talks to Maestro, which talks to UIAutomator or XCTest). +8. Loop. + +The cycle runs hundreds of times per minute. Every step produces one row in `trace.jsonl` and one screenshot. + diff --git a/docs/development/design-principles.md b/docs/development/design-principles.md new file mode 100644 index 0000000..a96b5be --- /dev/null +++ b/docs/development/design-principles.md @@ -0,0 +1,49 @@ +--- +title: Design principles +--- + +# Design principles + +## 1. The app owns introspection; the driver owns input + +The in-app SDK knows the state: view hierarchy, coverage, logs, exceptions, custom extractors. Maestro causes the state to change through taps, swipes, typed text, and deep links. The Go runner decides what to do. + +Splitting these responsibilities is what makes the system work across iOS and Android with one spec surface. Neither Maestro nor the SDK alone is sufficient. + +- Maestro can read a coarse accessibility tree, but not the real `UIView` or `View` hierarchy, not coverage, not in-process logs. +- The SDK can see everything inside the app, but cannot dispatch UI events the way the OS would. Touch injection through Maestro goes through XCTest or UIAutomator, which the OS treats as real input. + +## 2. One TypeScript surface across platforms + +Spec authors write against `state.ax`, `state.logs`, `state.snapshots`, and so on, regardless of iOS or Android. Platform differences (back button semantics, system alerts, coverage format) are absorbed in the Go runner and the SDKs. + +Corollary: if a concept only exists on one platform, it does not belong in the spec API. It belongs behind a feature flag or an extractor. + +## 3. The driver is an interface + +Today `driver.Driver` has one production implementation (`maestro`) and a `mock` for tests. Tomorrow it might be Appium, direct XCTest, or UIAutomator. The runner never knows. This keeps the Maestro dependency contained. If we ever outgrow it, the blast radius is one package. + +## 4. Hot loops bypass Maestro + +Per-step introspection (hierarchy dump, coverage read, pause and resume) goes over a local Unix socket directly to the SDK. Only physical UI events go through Maestro's gRPC. + +A 30-minute run is about 10,000 steps. Every step has at least one hierarchy dump and one coverage read. If those went through Maestro, the JVM sidecar would be the bottleneck. Instead the hot path is a 2 ms round-trip to an in-process Swift or Kotlin SDK. + +## 5. Deterministic where it can be + +A seeded PRNG drives action selection. Spec evaluation is pure given state and snapshots. The bundle hash and seed are recorded in `meta.json`. + +uatu does not attempt byte-exact replay. Animation timing, keyboard popup timing, and system daemons are non-deterministic on mobile, and the cost of trying to suppress that is not worth the payoff. Same seed produces a similar trajectory, which is usually enough to reproduce the bug. + +## 6. Fail honest + +If coverage is not available (release build, instrumentation off), tell the user. Do not pretend exploration is guided when it is random. If the SDK is not linked, say so. If a property is unparseable, fail the run at startup, not step 1000. + +The alternative, graceful degradation that silently weakens guarantees, is how testing tools lose trust. + +## 7. Specs are authoritative; no hidden setup + +There is exactly one authoring surface: the TypeScript spec. There is no separate YAML for login, no fixtures directory, no `setup.sh`. Login, onboarding, permission prompts, and teardown are all expressed as action generators or extractors, evaluated in the same loop as the rest of the spec. + +This is intentional. A test harness with two authoring languages (YAML plus code, JSON plus code) splits concerns in a way that always drifts. Something works in one surface and not the other, and debugging requires holding both in your head. + diff --git a/docs/development/index.md b/docs/development/index.md new file mode 100644 index 0000000..c8b72f3 --- /dev/null +++ b/docs/development/index.md @@ -0,0 +1,21 @@ +--- +title: Development +--- + +# Development + +- [Design principles](./design-principles.html) +- [Architecture](./architecture.html) +- v0.1.0 scope: [issue #4](https://github.com/priyanshujain/uatu/issues/4) + +## Building the docs site locally + +``` +make docs +``` + +Outputs to `build/site/`. Requires [pandoc](https://pandoc.org/) on your PATH. Preview with: + +``` +cd build/site && python3 -m http.server 8000 +``` diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..5060221 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,21 @@ +--- +title: uatu +--- + +# uatu + +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/uatu/issues/4). + +## Manual + +- [Getting started](./manual/getting-started.html) +- [Writing specs](./manual/writing-specs.html) +- [Runs](./manual/runs.html) +- [CLI reference](./manual/cli.html) + +## Development + +- [Design principles](./development/design-principles.html) +- [Architecture](./development/architecture.html) diff --git a/docs/manual/cli.md b/docs/manual/cli.md new file mode 100644 index 0000000..bc03a45 --- /dev/null +++ b/docs/manual/cli.md @@ -0,0 +1,41 @@ +--- +title: CLI reference +--- + +# CLI reference + +``` +uatu [flags] +``` + +## `uatu test` + +Run a spec against an app for a fixed duration. + +| Flag | Default | Description | +|---|---|---| +| `--spec` | required | Path to the TypeScript spec. | +| `--bundle-id` | required | Target app bundle ID (Android: applicationId). | +| `--launcher-activity` | resolved | Optional `/` to launch. Overrides default resolution. | +| `--platform` | `android` | Target platform. Only `android` in the current alpha. | +| `--avd` | required (android) | Android AVD name. | +| `--duration` | `5m` | Total test duration (`30s`, `5m`, `2h`, `1d`). | +| `--seed` | `0` | PRNG seed. `0` uses a random seed and records it in `meta.json`. | +| `--output` | `./runs` | Output directory for traces. | + +## `uatu doctor` + +Check the host environment for a working uatu setup: Go toolchain, JDK, Maestro availability, emulator reachability, SDK linkage hints. + +## `uatu version` + +Print the CLI version. + +## Flags coming in v0.1.0 + +- `--permissions` to pre-set OS-level permissions (for example `--permissions location=allow,notifications=deny`). +- `--max-steps` hard cap on step count. +- `--exit-on-violation` stop the run on the first property violation. +- `uatu inspect` command for browsing traces in the built-in UI. + +Tracked in [issue #4](https://github.com/priyanshujain/uatu/issues/4). diff --git a/docs/manual/getting-started.md b/docs/manual/getting-started.md new file mode 100644 index 0000000..b04a65d --- /dev/null +++ b/docs/manual/getting-started.md @@ -0,0 +1,76 @@ +--- +title: Getting started +--- + +# Getting started + +Install the CLI, link the SDK into your debug build, run a spec. + +## Prerequisites + +- An Android emulator with API level 30 or newer. +- The app under test built as a debug variant with the uatu Android SDK linked in. +- `adb` on your PATH. + +Run `uatu doctor` to check the host environment. + +## Install + +### CLI + +macOS arm64: + +```sh +curl -L https://github.com/priyanshujain/uatu/releases/latest/download/uatu__darwin_arm64.tar.gz | tar xz +./uatu version +``` + +Linux amd64: + +```sh +curl -L https://github.com/priyanshujain/uatu/releases/latest/download/uatu__linux_amd64.tar.gz | tar xz +./uatu version +``` + +Pre-built for `darwin/arm64`, `darwin/amd64`, `linux/amd64`, `linux/arm64`. + +### Spec package (npm) + +```sh +npm install --save-dev @uatu/spec +``` + +### Android SDK (Maven Central) + +```kotlin +dependencies { + implementation("io.github.priyanshujain:sdk-android:") +} +``` + +## Your first run + +The repo ships a working sample at `examples/sample-app`. From that directory: + +```sh +./gradlew :sample-app:installDebug +uatu test \ + --spec spec.ts \ + --bundle-id dev.uatu.sample \ + --platform android \ + --avd Pixel_7 \ + --duration 2m +``` + +When the run ends, the trace lands in `runs//`: + +``` +runs/2026-04-18T12-34-56/ +├── trace.jsonl +├── screenshots/ +└── meta.json +``` + +Open the screenshots directory to scrub visually, or read `trace.jsonl` step by step. + +Next: [writing specs](./writing-specs.html). diff --git a/docs/manual/index.md b/docs/manual/index.md new file mode 100644 index 0000000..5ccbd9e --- /dev/null +++ b/docs/manual/index.md @@ -0,0 +1,10 @@ +--- +title: Manual +--- + +# Manual + +- [Getting started](./getting-started.html) +- [Writing specs](./writing-specs.html) +- [Runs](./runs.html) +- [CLI reference](./cli.html) diff --git a/docs/manual/runs.md b/docs/manual/runs.md new file mode 100644 index 0000000..91542d1 --- /dev/null +++ b/docs/manual/runs.md @@ -0,0 +1,65 @@ +--- +title: Runs +--- + +# What is a run? + +One `uatu test` invocation. Fresh install, spec-driven exploration, then the trace lands in `runs//`. Typically minutes to hours, not seconds. + +A run is not analogous to a unit test. A closer framing is: boot a fuzzer for an hour and see what breaks. Violations are recorded in the trace and exploration continues, so one run can surface many bugs. + +## Lifecycle + +``` +uatu test --spec spec.ts --bundle-id com.example.app --avd Pixel_7 --duration 30m + │ + ├── uninstall and reinstall the app (clean slate, every run) + ├── boot the sidecar, connect the agent socket + ├── bundle the spec, load it into goja + │ + ├── step 0..N: pause, capture state, evaluate properties, pick action, resume, dispatch + │ + └── terminate when --duration elapses (or SIGINT) + └── trace written to ./runs// + ├── trace.jsonl + ├── screenshots/ + └── meta.json +``` + +## Why runs are long and linear + +uatu does not restart the app every N steps. Each restart throws away two things. + +**Novelty and coverage signal.** The exploration strategy weights actions by whether they reach previously unseen state. Restarting resets that history. + +**Deep app states.** Many screens take many actions to reach: nested settings, a loaded cart, post-checkout flows. A 50-step prefix to reach "cart with 3 items" does not happen if every run starts cold. + +Long-linear trajectories find bugs that restart-based testing structurally cannot. + +## Setup cost amortizes + +Preconditions (login, onboarding, consent dialogs) are written as weighted action generators gated on extractors. See [writing specs](./writing-specs.html#pattern-preconditions-login-onboarding). They fire only when applicable, so login happens once per run, not per step. + +| Run length | Login cost | % of run | +|---|---|---| +| 5 min | ~15s | 5% | +| 30 min | ~15s | 0.8% | +| 1 hour | ~15s | 0.4% | +| CI: 3 seeds × 10 min | ~45s total | 2.5% | + +At any non-trivial run length, preconditions are a rounding error. + +## Session state + +Session tokens, keychain, shared prefs, cookies, and other app-managed persistence survive the full run. If the app logs the user out mid-run, the `doLogin` generator re-fires automatically because its gating extractor (`onLoginScreen`) becomes true again. No retry logic. No special-casing. + +## Termination + +A run ends when either of these happens. + +- `--duration` elapses. +- The process is interrupted (SIGINT). + +The trace is written incrementally, so an interrupted run is still fully inspectable. + +Additional termination conditions (`--max-steps`, `--exit-on-violation`, hard crash handling) land in [v0.1.0](https://github.com/priyanshujain/uatu/issues/4). diff --git a/docs/manual/writing-specs.md b/docs/manual/writing-specs.md new file mode 100644 index 0000000..7bb3165 --- /dev/null +++ b/docs/manual/writing-specs.md @@ -0,0 +1,193 @@ +--- +title: Writing specs +--- + +# Writing specs + +A spec has three parts: extractors, properties, and actions. + +```ts +import { extract, always, actions, weighted, Tap, taps, swipes } from "@uatu/spec"; + +// 1. Extractors pull values from each observed state. +const loggedIn = extract((s) => !!s.ax.find("id:home-tab-bar")); +const cartCount = extract((s) => (s.snapshots.cart_count as number) ?? 0); + +// 2. Properties are LTL formulas evaluated every step. +export const properties = { + cartNeverNegative: always(() => cartCount.current >= 0), +}; + +// 3. Actions are a weighted tree of what uatu 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: + +```ts +interface State { + ax: AccessibilityTree; // view hierarchy + snapshots: Record; // values registered by the in-app SDK + 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. + +`snapshots` is populated by the in-app SDK via `Uatu.extract("name") { value }`. Use it when the UI does not expose a value you need, such as business-logic state or hidden fields. + +## Pattern: preconditions (login, onboarding) + +uatu 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. + +```ts +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: "test@example.com" }), + Tap({ on: signInButton }), + ]; +}); +``` + +Stack these for onboarding, consent dialogs, cold-start flows: + +```ts +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": + +```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; + }), +}; +``` + +When `implies` ships in v0.1.0, this becomes: + +```ts +cartPersistsWhenLoggedIn: always(() => + implies(loggedIn.current, () => cartCount.current !== undefined) +), +``` + +## Pattern: eventually (once the operator lands) + +`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"), +``` + +Useful for liveness checks: the loading spinner eventually goes away, the deep link eventually lands on `/home`. + +## Pattern: snapshot-backed properties + +When the UI does not expose a value but the app knows it, use the SDK's extractor registry: + +```kotlin +// in the app (Android) +Uatu.extract("cart_count") { store.cart.size } +``` + +```ts +// in the spec +const cartCount = extract((s) => (s.snapshots.cart_count as number) ?? 0); + +export const properties = { + cartMonotonicAfterAdd: always(() => { + const previous = cartCount.previous; + return previous === undefined || cartCount.current >= previous; + }), +}; +``` + +This pattern lets you write properties against business logic that no UI element exposes. + +## Pattern: weighted exploration sub-trees + +Nest `weighted` to group related actions and tune their collective rate: + +```ts +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. diff --git a/pkg/spec-api/README.md b/pkg/spec-api/README.md index d2c0230..87ed45d 100644 --- a/pkg/spec-api/README.md +++ b/pkg/spec-api/README.md @@ -1,6 +1,6 @@ # @uatu/spec -TypeScript spec API for [uatu](https://github.com/priyanshujain/uatu) — a property-based UI fuzzer for mobile apps. +TypeScript spec API for [uatu](https://github.com/priyanshujain/uatu), a property-based UI fuzzer for mobile apps. Spec authors write specs in TypeScript that describe what an app should *always* do (safety invariants), generate weighted actions to exercise the app, and extract structured state from the accessibility tree. The `uatu` CLI picks up the spec and drives the app under test. diff --git a/pkg/spec-api/package.json b/pkg/spec-api/package.json index e67688a..ed6872b 100644 --- a/pkg/spec-api/package.json +++ b/pkg/spec-api/package.json @@ -1,7 +1,7 @@ { "name": "@uatu/spec", "version": "0.0.0-dev", - "description": "TypeScript spec API for uatu — a property-based UI fuzzer for mobile apps.", + "description": "TypeScript spec API for uatu, a property-based UI fuzzer for mobile apps.", "type": "module", "types": "./dist/index.d.ts", "main": "./dist/index.js", diff --git a/scripts/build-docs.sh b/scripts/build-docs.sh new file mode 100755 index 0000000..37b807d --- /dev/null +++ b/scripts/build-docs.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Build the uatu docs site using pandoc. +# Outputs static HTML to build/site/ ready for GitHub Pages. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SRC="$REPO_ROOT/docs" +OUT="$REPO_ROOT/build/site" +TEMPLATE="$SRC/_template/page.html" +ASSETS="$SRC/_assets" + +if ! command -v pandoc >/dev/null 2>&1; then + echo "pandoc not found on PATH. Install from https://pandoc.org/ or 'brew install pandoc'." >&2 + exit 1 +fi + +rm -rf "$OUT" +mkdir -p "$OUT/_assets" +cp -R "$ASSETS"/. "$OUT/_assets/" + +build_one() { + local src="$1" + local rel="${src#$SRC/}" + local out="$OUT/${rel%.md}.html" + local dir; dir=$(dirname "$rel") + + local root="" + if [ "$dir" != "." ]; then + local depth; depth=$(awk -F/ '{print NF}' <<< "$dir") + for ((i = 0; i < depth; i++)); do root="../$root"; done + fi + + mkdir -p "$(dirname "$out")" + pandoc "$src" \ + --from=gfm \ + --to=html5 \ + --standalone \ + --highlight-style=tango \ + --template="$TEMPLATE" \ + -o "$out" + + # macOS sed and GNU sed both accept `-i.bak + rm`; avoids `-i ''` portability issues. + sed -i.bak "s|__ROOT__|$root|g" "$out" && rm "$out.bak" +} + +count=0 +while IFS= read -r -d '' f; do + build_one "$f" + count=$((count + 1)) +done < <(find "$SRC" -type f -name '*.md' -not -path "$SRC/_*" -print0) + +echo "built $count pages to $OUT" diff --git a/sdk/android/build.gradle.kts b/sdk/android/build.gradle.kts index 962f9f3..a12406a 100644 --- a/sdk/android/build.gradle.kts +++ b/sdk/android/build.gradle.kts @@ -60,7 +60,7 @@ mavenPublishing { pom { name.set("uatu sdk-android") description.set( - "Android runtime SDK for uatu — a property-based UI fuzzer for mobile apps. " + + "Android runtime SDK for uatu, a property-based UI fuzzer for mobile apps. " + "Exposes a content-provider accessibility bridge consumed by the uatu CLI at test time.", ) url.set("https://github.com/priyanshujain/uatu") diff --git a/sdk/android/consumer-rules.pro b/sdk/android/consumer-rules.pro index 1822aa0..0f94388 100644 --- a/sdk/android/consumer-rules.pro +++ b/sdk/android/consumer-rules.pro @@ -1,2 +1,2 @@ # ProGuard rules shipped to consumers of dev.uatu:sdk-android. -# None needed for v0.1 — socket + semaphore APIs are all reflection-free. +# None needed for v0.1. Socket and semaphore APIs are all reflection-free. diff --git a/sidecar/src/test/kotlin/dev/uatu/sidecar/MainTest.kt b/sidecar/src/test/kotlin/dev/uatu/sidecar/MainTest.kt index de43ac4..b765c0e 100644 --- a/sidecar/src/test/kotlin/dev/uatu/sidecar/MainTest.kt +++ b/sidecar/src/test/kotlin/dev/uatu/sidecar/MainTest.kt @@ -6,6 +6,6 @@ import kotlin.test.assertTrue class MainTest { @Test fun mainExists() { - assertTrue(true, "placeholder test — real tests land with DriverService") + assertTrue(true, "placeholder test; real tests land with DriverService") } }