diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 328526e..b3aa99d 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -27,9 +27,6 @@ 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 -- --version v0.7.1 - - name: Build site run: make docs diff --git a/.gitignore b/.gitignore index a53317d..2afe5ba 100644 --- a/.gitignore +++ b/.gitignore @@ -37,11 +37,10 @@ pkg/spec/dist/ # goreleaser local output /dist/ -# inspect web bundle output (real bundle wired in Stage 4 via Makefile/CI). -# Track only the stub index.html + .gitkeep so //go:embed succeeds. +# inspect web bundle output; built by `make web-build` before go build. +# Only .gitkeep is tracked so //go:embed all:dist compiles on a fresh checkout. /internal/inspect/dist/* !/internal/inspect/dist/.gitkeep -!/internal/inspect/dist/index.html # inspect web frontend inspect-ui/node_modules/ @@ -53,15 +52,9 @@ examples/folio-web/node_modules/ examples/folio-web/dist/ examples/folio-web/.vite/ -# d2 diagrams render into build/site/_assets/diagrams; keep sources only -/docs/_diagrams/*.svg -/docs/_diagrams/*.png - # coding agent files .claude/ .claude/* # Personal research notes /research/ - -internal/inspect/dist/* \ No newline at end of file diff --git a/Makefile b/Makefile index 6d19a7d..57d1920 100644 --- a/Makefile +++ b/Makefile @@ -11,11 +11,13 @@ SIDECAR_EMBED := internal/sidecar/assets/sidecar-all.jar SDK_AAR := sdk/android/build/outputs/aar/sdk-android-release.aar 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_SRC := $(shell find docs -type f -name '*.md' -not -path 'docs/_*') +INDEX_SRC := $(filter %index.md,$(DOCS_SRC)) +PAGE_SRC := $(filter-out %index.md,$(DOCS_SRC)) +INDEX_OUT := $(patsubst docs/%.md,build/site/%.html,$(INDEX_SRC)) +PAGE_OUT := $(patsubst docs/%.md,build/site/%/index.html,$(PAGE_SRC)) +DOCS_OUT := $(INDEX_OUT) $(PAGE_OUT) 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 := inspect-ui/dist @@ -84,24 +86,27 @@ test-kotlin: test-spec-api: cd pkg/spec && npm test --silent -docs: $(DOCS_OUT) build/site/_assets $(DIAGRAM_OUT) - @echo "built $(words $(DOCS_OUT)) pages, $(words $(DIAGRAM_OUT)) diagrams to build/site" +docs: $(DOCS_OUT) build/site/_assets + @echo "built $(words $(DOCS_OUT)) pages 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) +define build_page @mkdir -p $(dir $@) @pandoc $< --from=gfm --to=html5 --standalone \ --highlight-style=tango --template=$(DOCS_TEMPLATE) -o $@ @rel=$$(echo $(patsubst build/site/%,%,$@) | awk -F/ '{for(i=1;i 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 111c13c..f8c29b1 100644 --- a/docs/_template/page.html +++ b/docs/_template/page.html @@ -9,24 +9,39 @@ + +
@@ -51,6 +66,39 @@ for (const pre of document.querySelectorAll('pre.mermaid, pre > code.language-me node.replaceWith(div); } await mermaid.run(); + +const overlay = document.createElement('div'); +overlay.className = 'diagram-overlay'; +document.body.appendChild(overlay); +overlay.addEventListener('click', () => overlay.classList.remove('open')); +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') overlay.classList.remove('open'); +}); +for (const diagram of document.querySelectorAll('.mermaid')) { + diagram.addEventListener('click', () => { + const svg = diagram.querySelector('svg'); + if (!svg) return; + overlay.innerHTML = ''; + overlay.appendChild(svg.cloneNode(true)); + overlay.classList.add('open'); + }); +} + +for (const img of document.querySelectorAll('article img')) { + img.addEventListener('click', () => { + overlay.innerHTML = ''; + const clone = img.cloneNode(); + clone.style.cssText = 'max-width:95vw;max-height:95vh;width:auto;height:auto;border-radius:6px;'; + overlay.appendChild(clone); + overlay.classList.add('open'); + }); +} + +document.getElementById('theme-toggle').addEventListener('click', () => { + const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'; + document.documentElement.dataset.theme = next; + localStorage.setItem('theme', next); +}); diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 76b0a1c..a07c6d8 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -4,30 +4,54 @@ title: Architecture # Architecture -Three processes, two transports. +```mermaid +flowchart TB + subgraph go["sanderling (Go)"] + direction LR + B["Bundler / esbuild"] --> V["Verifier / goja + LTL"] + V <--> R["Runner"] + R --> D["DeviceDriver"] + R --> T["Trace writer\nJSONL + PNG"] + end -sanderling architecture + SC["Maestro sidecar (JVM)"] + SDK["in-app SDK\n(Device / Emulator)"] + CH["Chrome (CDP)"] + RD[("runs/")] + IN["sanderling inspect\nHTTP + SSE"] + UI["Web UI (React)"] + + D -->|gRPC| SC + SC -->|UIAutomator / XCTest| SDK + R -->|"Unix socket
(pause / state / logs)"| SDK + D -->|CDP| CH + + T --> RD --> IN --> UI +``` ## Processes -**sanderling (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. +**sanderling (Go).** The top-level binary. Bundles the spec with esbuild, evaluates it in goja, runs the main loop, dispatches actions through the `DeviceDriver` interface, 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. +**Maestro sidecar (JVM).** A Kotlin process that wraps `maestro-client` and exposes a gRPC surface matching the `DeviceDriver` interface. Handles UI input, screenshots, the system accessibility tree, and OS-level alerts. Native platforms only. -**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. +**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. Native platforms only. + +**Chrome (CDP).** For web targets, the Go binary drives Chrome directly over the Chrome DevTools Protocol. No sidecar or in-app SDK is involved. ## 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 | +| Channel | Platform | Transport | Purpose | +|---|---|---|---| +| Go to Maestro sidecar | Native | gRPC (localhost TCP) | UI input, screenshots, system alerts | +| Go to in-app SDK | Native | Unix domain socket | Pause / resume, hierarchy, coverage, logs, extractors | +| Go to Chrome | Web | Chrome DevTools Protocol | UI input, screenshots, DOM hierarchy, console logs | -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. +On native, the transport split exists because only real UI events need to cross process and OS-API boundaries. Introspection is cheap, frequent, and lives on a fast local socket directly to the app. On web, CDP handles both. ## 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. +`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 any driver; it only consumes the trace artifacts. ## Per-step cycle @@ -37,14 +61,20 @@ The heart of the system is: pause ─► capture state ─► evaluate properties ─► pick action ─► resume ─► dispatch ``` +**Native (Android / iOS):** + 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. +2. The runner sends `PAUSE` to the SDK over the Unix 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). +7. The runner sends `RESUME` to the SDK, then dispatches the action through the driver (gRPC to sidecar → Maestro → UIAutomator or XCTest). 8. Loop. +**Web (Chrome):** + +Steps 2-3 use CDP to capture the DOM hierarchy and console logs directly; there is no SDK pause/resume. The rest of the cycle is identical. + 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 index 36c066e..f401f0b 100644 --- a/docs/development/design-principles.md +++ b/docs/development/design-principles.md @@ -6,29 +6,33 @@ title: 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. +On native platforms, 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. +On web, Chrome DevTools Protocol handles both input and introspection. No in-app SDK is needed. + ## 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. +Spec authors write against `state.ax`, `state.logs`, `state.snapshots`, and so on, regardless of iOS, Android, or web. Platform differences (back button semantics, system alerts, coverage format) are absorbed in the Go runner and the drivers. 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. +`DeviceDriver` has two production implementations: `sidecar` (Maestro gRPC, for native) and `chrome` (CDP, for web), plus a `mock` for tests. The runner never knows which is wired in. Adding a new platform means adding a new implementation; nothing else changes. -## 4. Hot loops bypass Maestro +## 4. Hot loops bypass Maestro (native) -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. +On native, 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. +On web, CDP is fast enough that a separate introspection channel is not needed. + ## 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`. diff --git a/docs/development/index.md b/docs/development/index.md index a5ac408..285a7cd 100644 --- a/docs/development/index.md +++ b/docs/development/index.md @@ -4,8 +4,8 @@ title: Development # Development -- [Design principles](./design-principles.html) -- [Architecture](./architecture.html) +- [Design principles](./design-principles/) +- [Architecture](./architecture/) - v0.1.0 scope: [issue #4](https://github.com/priyanshujain/sanderling/issues/4) ## Building the docs site locally diff --git a/docs/index.md b/docs/index.md index a4d626d..e8268fe 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,21 +4,16 @@ title: Sanderling Manual # 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. +Autonomous property-based testing for mobile/web apps. Specs in TypeScript. Core in Go. Drives the app under test through UIAutomation/XCTest and an in-app SDK on Android/iOS and CDP on web. -Alpha: Android emulator only. Scope of v0.1.0 is tracked in [issue #4](https://github.com/priyanshujain/sanderling/issues/4). +Alpha: Scope of v0.1.0 is tracked in [issue #4](https://github.com/priyanshujain/sanderling/issues/4). -- [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) +- [Getting started](./manual/getting-started/) +- [Writing specs](./manual/writing-specs/) +- [Runs](./manual/runs/) +- [Inspect](./manual/inspect/) +- [CLI reference](./manual/cli/) --- diff --git a/docs/manual/cli.md b/docs/manual/cli.md index 5602a14..c9d1bf0 100644 --- a/docs/manual/cli.md +++ b/docs/manual/cli.md @@ -17,7 +17,7 @@ Run a spec against an app for a fixed duration. | `--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. | +| `--platform` | `android` | Target platform: `android`, `ios`, or `web`. | | `--avd` | optional (android) | Android AVD name to boot if no device is connected. Required only when no device is connected and multiple AVDs exist. | | `--duration` | `5m` | Total test duration (`30s`, `5m`, `2h`, `1d`). | | `--seed` | `0` | PRNG seed. `0` uses a random seed and records it in `meta.json`. | @@ -33,7 +33,7 @@ Serve a local web UI for browsing traces. The positional argument is optional an | `--no-open` | `false` | Skip opening the default browser on startup. | | `--dev` | `false` | Reverse-proxy non-API requests to the Vite dev server on `127.0.0.1:5173`. | -See [the inspect UI page](inspect.md) for the panel reference and keyboard shortcuts. +See [the inspect UI page](./inspect/) for the panel reference and keyboard shortcuts. ## `sanderling doctor` diff --git a/docs/manual/getting-started.md b/docs/manual/getting-started.md index 0ac76d4..c9f1cae 100644 --- a/docs/manual/getting-started.md +++ b/docs/manual/getting-started.md @@ -8,10 +8,17 @@ Install the CLI, link the SDK into your debug build, run a spec. ## Prerequisites -- An Android emulator with API level 30 or newer. +**Android / iOS:** + +- An Android emulator with API level 30 or newer (or a connected device). - The app under test built as a debug variant with the sanderling Android SDK linked in. - `adb` on your PATH. +**Web:** + +- Chrome installed. sanderling drives it via CDP; no other setup required. +- No in-app SDK needed. + Run `sanderling doctor` to check the host environment. ## Install @@ -22,13 +29,13 @@ Run `sanderling doctor` to check the host environment. curl -fsSL https://raw.githubusercontent.com/priyanshujain/sanderling/master/install.sh | bash ``` -### Spec package (npm) +### Spec package ([npm](https://www.npmjs.com/package/@sanderling/spec)) ```sh npm install --save-dev @sanderling/spec ``` -### Android SDK (Maven Central) +### Android SDK ([Maven Central](https://central.sonatype.com/artifact/io.github.priyanshujain.sanderling/sdk-android)) ```kotlin dependencies { @@ -38,6 +45,8 @@ dependencies { ## Your first run +### Android + 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 @@ -53,6 +62,18 @@ AVD=Pixel_7 just test Persistent settings can live in a `.env` alongside the justfile (`AVD=Pixel_7`, `DURATION=5m`, and so on). +### Web + +The repo also ships a web sample at `examples/folio-web`, a React/Vite app with the same domain logic. From `examples/folio-web`: + +```sh +just test # starts Chrome, runs the spec +``` + +No emulator or SDK setup needed. + +### Trace output + When the run ends, the trace lands in `sanderling/runs//`: ``` @@ -62,6 +83,6 @@ runs/2026-04-18T12-34-56/ └── meta.json ``` -Browse it with `sanderling inspect` (see [inspect](./inspect.html)), or read `trace.jsonl` step by step. +Browse it with `sanderling inspect` (see [inspect](./inspect/)), or read `trace.jsonl` step by step. -Next: [writing specs](./writing-specs.html). +Next: [writing specs](./writing-specs/). diff --git a/docs/manual/index.md b/docs/manual/index.md index 5ccbd9e..0f9c1da 100644 --- a/docs/manual/index.md +++ b/docs/manual/index.md @@ -4,7 +4,7 @@ title: Manual # Manual -- [Getting started](./getting-started.html) -- [Writing specs](./writing-specs.html) -- [Runs](./runs.html) -- [CLI reference](./cli.html) +- [Getting started](./getting-started/) +- [Writing specs](./writing-specs/) +- [Runs](./runs/) +- [CLI reference](./cli/) diff --git a/docs/manual/inspect.md b/docs/manual/inspect.md index 0990471..a41152f 100644 --- a/docs/manual/inspect.md +++ b/docs/manual/inspect.md @@ -12,7 +12,7 @@ sanderling inspect [run-or-runs-dir] [--port N] [--no-open] [--dev] The positional argument can be a runs directory or a single run directory (auto-detected by `meta.json`). Defaults to `./runs`. -![sanderling inspect](../_assets/inspect-ui.png) +![sanderling inspect](../../_assets/inspect-ui.png) ## Keyboard shortcuts diff --git a/docs/manual/runs.md b/docs/manual/runs.md index 4d0e30f..9261fb9 100644 --- a/docs/manual/runs.md +++ b/docs/manual/runs.md @@ -38,7 +38,7 @@ Long-linear trajectories find bugs that restart-based testing structurally canno ## 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. +Preconditions (login, onboarding, consent dialogs) are written as weighted action generators gated on extractors. See [writing specs](./writing-specs/#pattern-preconditions-login-onboarding). They fire only when applicable, so login happens once per run, not per step. | Run length | Login cost | % of run | |---|---|---| diff --git a/internal/inspect/dist/index.html b/internal/inspect/dist/index.html deleted file mode 100644 index 7c25179..0000000 --- a/internal/inspect/dist/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - sanderling inspect - - - - -
- - diff --git a/internal/inspect/server.go b/internal/inspect/server.go index b75daa3..637720a 100644 --- a/internal/inspect/server.go +++ b/internal/inspect/server.go @@ -18,6 +18,8 @@ import ( type ServerOptions struct { RunsDirectory string DevTarget string + // AssetsFS overrides the default embedded dist FS. Intended for tests. + AssetsFS fs.FS } // Server holds the HTTP handlers for `sanderling inspect`. @@ -33,11 +35,15 @@ type Server struct { // server reverse-proxies non-API GETs to it; otherwise it serves embedded // assets from the dist FS. func NewServer(options ServerOptions) (*Server, error) { + assetsFS := options.AssetsFS + if assetsFS == nil { + assetsFS = Assets() + } server := &Server{ options: options, cache: NewCache(options.RunsDirectory), watcher: NewWatcher(options.RunsDirectory), - assets: spaHandler(Assets()), + assets: spaHandler(assetsFS), } if options.DevTarget != "" { proxy, err := newDevProxy(options.DevTarget) @@ -207,12 +213,12 @@ func spaHandler(assets fs.FS) http.Handler { return http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { clean := strings.TrimPrefix(path.Clean(request.URL.Path), "/") if clean == "" { - serveIndex(responseWriter, request, assets) + serveIndex(responseWriter, assets) return } file, err := assets.Open(clean) if err != nil { - serveIndex(responseWriter, request, assets) + serveIndex(responseWriter, assets) return } file.Close() @@ -220,7 +226,7 @@ func spaHandler(assets fs.FS) http.Handler { }) } -func serveIndex(responseWriter http.ResponseWriter, request *http.Request, assets fs.FS) { +func serveIndex(responseWriter http.ResponseWriter, assets fs.FS) { file, err := assets.Open("index.html") if err != nil { http.Error(responseWriter, "index.html missing from embedded assets", http.StatusInternalServerError) diff --git a/internal/inspect/server_test.go b/internal/inspect/server_test.go index 905bfc2..13a4f99 100644 --- a/internal/inspect/server_test.go +++ b/internal/inspect/server_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "io" + "io/fs" "net/http" "net/http/httptest" "net/url" @@ -11,11 +12,18 @@ import ( "path/filepath" "strings" "testing" + "testing/fstest" "time" "github.com/priyanshujain/sanderling/internal/trace" ) +var testAssetsFS fs.FS = fstest.MapFS{ + "index.html": &fstest.MapFile{ + Data: []byte(`
`), + }, +} + func newFixtureServer(t *testing.T) (*Server, string) { t.Helper() root := t.TempDir() @@ -48,7 +56,7 @@ func newFixtureServer(t *testing.T) (*Server, string) { t.Fatal(err) } - server, err := NewServer(ServerOptions{RunsDirectory: root}) + server, err := NewServer(ServerOptions{RunsDirectory: root, AssetsFS: testAssetsFS}) if err != nil { t.Fatal(err) } diff --git a/pkg/spec/README.md b/pkg/spec/README.md index df033d3..6894b96 100644 --- a/pkg/spec/README.md +++ b/pkg/spec/README.md @@ -1,8 +1,8 @@ # @sanderling/spec -TypeScript spec API for [sanderling](https://github.com/priyanshujain/sanderling), a property-based UI fuzzer for mobile apps. +TypeScript spec API for [sanderling](https://github.com/priyanshujain/sanderling), a property-based UI fuzzer for mobile and web 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 `sanderling` CLI picks up the spec and drives the app under test. +Spec authors write properties (what the app must always or eventually do), extractors (structured state from the UI), and action generators (what sanderling is allowed to do). The `sanderling` CLI evaluates the spec in a loop against a running app. ## Install @@ -13,27 +13,29 @@ npm install --save-dev @sanderling/spec ## Usage ```ts -import { extract, always, actions, Tap, weighted } from "@sanderling/spec"; +import { extract, always, eventually, now, actions, weighted, taps, swipes, InputText, Tap } from "@sanderling/spec"; -export const spec = { - extract: extract((tree) => ({ - onHomeScreen: tree.some((n) => n.text === "Home"), - })), +const loggedIn = extract((s) => !!s.ax.find("id:home-tab-bar")); +const balance = extract((s) => (s.snapshots.balance as number) ?? 0); - always: always(({ state }) => state.onHomeScreen || !state.startedOnHome), - - actions: actions(({ tree }) => - weighted([ - [1, Tap(tree.first((n) => n.text === "Checkout"))], - ]), - ), +export const properties = { + balanceNeverNegative: always(() => balance.current >= 0), + loginSucceeds: eventually(() => loggedIn.current).within(30, "seconds"), }; + +const doLogin = actions(() => { + if (loggedIn.current) return []; + const email = state.ax.find("id:email-field"); + const submit = state.ax.find("id:sign-in-button"); + if (!email || !submit) return []; + return [InputText({ into: email, text: "test@example.com" }), Tap({ on: submit })]; +}); + +export const actions = weighted( + [50, doLogin], + [10, taps], + [2, swipes], +); ``` -## Version compatibility - -`@sanderling/spec` is released in lockstep with the sanderling CLI. Pin the same major/minor version as your installed `sanderling` binary. - -## License - -Apache-2.0 +Works identically across Android, iOS, and web targets. diff --git a/sanderling b/sanderling new file mode 100755 index 0000000..4c38e27 Binary files /dev/null and b/sanderling differ