From 1f9af6e8e55a25a658f0697efba76a8d54abe850 Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 15 Aug 2026 13:27:37 +0530 Subject: [PATCH 1/8] fix(build): clean pkg/spec/dist, not the dead spec-api path --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index f86a2cf..fb40b4e 100644 --- a/Makefile +++ b/Makefile @@ -171,7 +171,7 @@ $(PAGE_OUT): build/site/%/index.html: docs/%.md $(DOCS_TEMPLATE) clean: $(GO) clean - rm -rf bin dist pkg/spec-api/dist build/site + rm -rf bin dist pkg/spec/dist build/site $(GRADLE) clean # Local release dry-runs. None of these touch remote registries. From 56fe509150ed9b46b5a90b906c62cbb06e74e37e Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 15 Aug 2026 13:27:44 +0530 Subject: [PATCH 2/8] chore: point stale spec-api comments at pkg/spec --- .gitignore | 2 +- internal/verifier/marshal.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 390b12e..fd41a17 100644 --- a/.gitignore +++ b/.gitignore @@ -50,7 +50,7 @@ internal/driver/ioscompanion/companionassets/assets/companion-*.tar.gz # before `go build -tags withcompanion`. Never commit: it's ~5 MB. internal/driver/ioscompanion/runnerassets/assets/runner-*.tar.gz -# spec-api compiled output +# spec package compiled output pkg/spec/dist/ # goreleaser local output diff --git a/internal/verifier/marshal.go b/internal/verifier/marshal.go index f9604b5..c5b311a 100644 --- a/internal/verifier/marshal.go +++ b/internal/verifier/marshal.go @@ -26,7 +26,7 @@ type stateInput struct { } // stateObject builds the JS-side `state` object matching the State type from -// pkg/spec-api. Fields beyond snapshots/ax are included when the caller +// pkg/spec. Fields beyond snapshots/ax are included when the caller // populated them on stateInput. func stateObject(runtime *goja.Runtime, input stateInput) (*goja.Object, error) { state := runtime.NewObject() From b3d4c5c213b00cdd1773f8c8d230c88695b3eb04 Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 15 Aug 2026 13:29:14 +0530 Subject: [PATCH 3/8] fix(spec): publish src so an installed package carries the runtime entries --- internal/testrun/testrun_test.go | 102 +++++++++++++++++++++++++++++++ pkg/spec/package.json | 1 + 2 files changed, 103 insertions(+) diff --git a/internal/testrun/testrun_test.go b/internal/testrun/testrun_test.go index bc3f376..e8664ac 100644 --- a/internal/testrun/testrun_test.go +++ b/internal/testrun/testrun_test.go @@ -2,7 +2,9 @@ package testrun import ( "context" + "encoding/json" "errors" + "io/fs" "os" "path/filepath" "strings" @@ -304,3 +306,103 @@ func TestLaunchAppBoundsWedgedDriver(t *testing.T) { t.Fatal("launchApp never returned: the pre-run launch is unbounded, so a wedged driver hangs the run forever") } } + +// repoFile walks up from the test's working directory and returns the absolute +// path of rel inside the sanderling checkout. +func repoFile(t *testing.T, rel string) string { + t.Helper() + directory, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + for { + candidate := filepath.Join(directory, rel) + if _, err := os.Stat(candidate); err == nil { + return candidate + } + parent := filepath.Dir(directory) + if parent == directory { + t.Fatalf("%s not found above the test directory", rel) + } + directory = parent + } +} + +// publishedFiles returns the "files" entries of pkg/spec/package.json, the +// exact set npm ships in the @sanderling/spec tarball. +func publishedFiles(t *testing.T) []string { + t.Helper() + raw, err := os.ReadFile(repoFile(t, "pkg/spec/package.json")) + if err != nil { + t.Fatal(err) + } + var manifest struct { + Files []string `json:"files"` + } + if err := json.Unmarshal(raw, &manifest); err != nil { + t.Fatal(err) + } + return manifest.Files +} + +// installPublishedPackage reproduces what `npm install @sanderling/spec` +// unpacks into node_modules: only the paths package.json publishes. +func installPublishedPackage(t *testing.T, dest string) { + t.Helper() + specDir := filepath.Dir(repoFile(t, "pkg/spec/package.json")) + for _, entry := range publishedFiles(t) { + source := filepath.Join(specDir, entry) + if _, err := os.Stat(source); err != nil { + continue + } + copyTree(t, source, filepath.Join(dest, entry)) + } +} + +func copyTree(t *testing.T, source, dest string) { + t.Helper() + err := filepath.WalkDir(source, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + relative, err := filepath.Rel(source, path) + if err != nil { + return err + } + target := filepath.Join(dest, relative) + if entry.IsDir() { + return os.MkdirAll(target, 0o755) + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + return os.WriteFile(target, data, 0o644) + }) + if err != nil { + t.Fatal(err) + } +} + +// TestResolveRuntimeSibling_PublishedPackageShipsTheRuntimes pins npm's "files" +// list against the resolver that consumes it. The tarball shipped dist/ alone +// while the node_modules fallback looks for src/goja-runtime.ts, so every +// `npm install @sanderling/spec` user hit "goja-runtime.ts not found". +func TestResolveRuntimeSibling_PublishedPackageShipsTheRuntimes(t *testing.T) { + root := t.TempDir() + installPublishedPackage(t, filepath.Join(root, "node_modules", "@sanderling", "spec")) + specPath := filepath.Join(root, "spec.ts") + if err := os.WriteFile(specPath, []byte(""), 0o644); err != nil { + t.Fatal(err) + } + + for _, filename := range []string{"goja-runtime.ts", "web-runtime.ts"} { + if resolveRuntimeSibling("", specPath, filename) == "" { + t.Errorf("%s unreachable from a published install; package.json publishes %v", + filename, publishedFiles(t)) + } + } +} diff --git a/pkg/spec/package.json b/pkg/spec/package.json index ffbfb84..05db2dd 100644 --- a/pkg/spec/package.json +++ b/pkg/spec/package.json @@ -21,6 +21,7 @@ }, "files": [ "dist", + "src", "README.md" ], "repository": { From fb57ab2f56321d93109821837c97bd55ed92be7e Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 15 Aug 2026 13:30:21 +0530 Subject: [PATCH 4/8] fix(testrun): alias the installed spec package so one module graph loads --- internal/testrun/testrun.go | 20 ++++++++----- internal/testrun/testrun_test.go | 50 ++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 8 deletions(-) diff --git a/internal/testrun/testrun.go b/internal/testrun/testrun.go index af57b28..893e5ce 100644 --- a/internal/testrun/testrun.go +++ b/internal/testrun/testrun.go @@ -350,16 +350,20 @@ func resolveRuntimeSibling(specAPIPath, userSpecPath, filename string) string { return "" } -// resolveSpecAPIPath returns the path to pkg/spec/src/index.ts inside -// a sanderling source checkout, searched upward from the spec file and the cwd. -// Returns "" when not found, in which case esbuild resolves @sanderling/spec via -// node_modules the way a downstream user's project would. +// resolveSpecAPIPath returns the path to the spec API's index.ts: a sanderling +// source checkout first, searched upward from the spec file and the cwd, then +// an installed node_modules/@sanderling/spec. Aliasing the installed copy is +// what keeps the spec and the runtime entry on one module graph; resolving the +// bare specifier through package.json "exports" would load dist/ alongside the +// runtime's src/ and give sampler-rng.ts two instances. func resolveSpecAPIPath(specPath string) string { - var candidates []string + var checkout, installed []string if absoluteSpec, err := filepath.Abs(specPath); err == nil { directory := filepath.Dir(absoluteSpec) for { - candidates = append(candidates, filepath.Join(directory, "pkg/spec/src/index.ts")) + checkout = append(checkout, filepath.Join(directory, "pkg/spec/src/index.ts")) + installed = append(installed, + filepath.Join(directory, "node_modules/@sanderling/spec/src/index.ts")) parent := filepath.Dir(directory) if parent == directory { break @@ -368,9 +372,9 @@ func resolveSpecAPIPath(specPath string) string { } } if cwd, err := os.Getwd(); err == nil { - candidates = append(candidates, filepath.Join(cwd, "pkg/spec/src/index.ts")) + checkout = append(checkout, filepath.Join(cwd, "pkg/spec/src/index.ts")) } - for _, candidate := range candidates { + for _, candidate := range append(checkout, installed...) { if _, err := os.Stat(candidate); err == nil { return candidate } diff --git a/internal/testrun/testrun_test.go b/internal/testrun/testrun_test.go index e8664ac..fd6303e 100644 --- a/internal/testrun/testrun_test.go +++ b/internal/testrun/testrun_test.go @@ -406,3 +406,53 @@ func TestResolveRuntimeSibling_PublishedPackageShipsTheRuntimes(t *testing.T) { } } } + +// TestPrepareBundleInputs_InstalledPackageSharesOneModuleGraph pins the +// downstream case: with no sanderling checkout above the spec, the aliases and +// the runtime entry must name the SAME installed copy. An unset alias let +// esbuild resolve @sanderling/spec to dist/ while the runtime came from src/, +// which loads sampler-rng.ts twice; from(), strings(), integers() and emails() +// then read an rng the picker never set and collapse to a fixed default. +func TestPrepareBundleInputs_InstalledPackageSharesOneModuleGraph(t *testing.T) { + root := t.TempDir() + installed := filepath.Join(root, "node_modules", "@sanderling", "spec") + installPublishedPackage(t, installed) + specPath := filepath.Join(root, "sanderling", "spec.ts") + if err := os.MkdirAll(filepath.Dir(specPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(specPath, []byte(""), 0o644); err != nil { + t.Fatal(err) + } + + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(cwd) }) + if err := os.Chdir(root); err != nil { + t.Fatal(err) + } + + prep, err := prepareBundleInputs(Options{Spec: specPath}) + if err != nil { + t.Fatal(err) + } + source := filepath.Join(installed, "src") + want := map[string]string{ + "@sanderling/spec": filepath.Join(source, "index.ts"), + "@sanderling/spec/defaults": filepath.Join(source, "defaults/index.ts"), + "@sanderling/spec/defaults/properties": filepath.Join(source, "defaults/properties.ts"), + } + for key, wantValue := range want { + if prep.aliases[key] != wantValue { + t.Errorf("alias %q = %q, want %q", key, prep.aliases[key], wantValue) + } + } + if got := prep.gojaRuntimePath; got != filepath.Join(source, "goja-runtime.ts") { + t.Errorf("gojaRuntimePath = %q, want it beside the aliased index.ts", got) + } + if got := resolveWebRuntimePath(prep.specAPIPath, specPath); got != filepath.Join(source, "web-runtime.ts") { + t.Errorf("webRuntimePath = %q, want it beside the aliased index.ts", got) + } +} From 4b22b45b684250ab60d8d815d96eb67b5df14219 Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 15 Aug 2026 13:31:32 +0530 Subject: [PATCH 5/8] fix(spec): export Direction, ScrollAction and LongPressAction from the entry --- pkg/spec/src/index.ts | 3 +++ pkg/spec/test/api.test.ts | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/pkg/spec/src/index.ts b/pkg/spec/src/index.ts index 4ac8c31..724e297 100644 --- a/pkg/spec/src/index.ts +++ b/pkg/spec/src/index.ts @@ -4,6 +4,7 @@ export type { Action, ActionGenerator, AttrSelector, + Direction, DoubleTapAction, EventuallyFormula, ExceptionRecord, @@ -13,10 +14,12 @@ export type { Key, KnownAttrSelectors, LogEntry, + LongPressAction, Point, PressKeyAction, RawAttrs, Sampler, + ScrollAction, SelectorPath, Snapshots, State, diff --git a/pkg/spec/test/api.test.ts b/pkg/spec/test/api.test.ts index eb38207..68a3945 100644 --- a/pkg/spec/test/api.test.ts +++ b/pkg/spec/test/api.test.ts @@ -29,6 +29,12 @@ import { weighted, whenRoute, } from "../src/index.ts"; +import type { + Action, + Direction, + LongPressAction, + ScrollAction, +} from "../src/index.ts"; import { setSamplerRng } from "../src/actions.ts"; import { Pcg } from "../src/pcg.ts"; import type { GeneratorNode } from "../src/action-tree.ts"; @@ -397,3 +403,18 @@ test("whenRoute body is skipped for a null route", () => { const node = whenRoute(route, ["home"], () => [Tap({ on: "id:x" })]); assert.deepEqual((node as { generate: () => unknown }).generate(), []); }); + +// The package entry is the only module a spec author can import from, so every +// member of the exported Action union, and the Direction needed to build a +// Scroll, has to be reachable there rather than only from src/types.ts. +test("index exports every action type a spec author annotates with", () => { + const direction: Direction = "down"; + const scroll: ScrollAction = Scroll({ direction, in: "id:list" }); + const longPress: LongPressAction = LongPress({ on: "id:row" }); + const built: Action[] = [scroll, longPress]; + + assert.deepEqual( + built.map(action => action.kind), + ["Scroll", "LongPress"], + ); +}); From e8beddfd700410491a3f544071fdbaea298e890d Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 15 Aug 2026 13:32:23 +0530 Subject: [PATCH 6/8] docs(spec): cut the package readme to a description and doc links --- pkg/spec/README.md | 62 +++------------------------------------------- 1 file changed, 4 insertions(+), 58 deletions(-) diff --git a/pkg/spec/README.md b/pkg/spec/README.md index c651ddf..abfc57b 100644 --- a/pkg/spec/README.md +++ b/pkg/spec/README.md @@ -1,67 +1,13 @@ # @sanderling/spec -TypeScript spec API for [sanderling](https://github.com/priyanshujain/sanderling), a property-based UI fuzzer for mobile and web apps. +TypeScript spec API for [sanderling](https://github.com/priyanshujain/sanderling), a property-based UI fuzzer for Android, iOS and web apps. -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 +A spec exports properties (what the app must always or eventually do), extractors (structured state read off the UI), and action generators (what sanderling is allowed to do). The `sanderling` CLI evaluates the spec against a running app once per step. ```sh npm install --save-dev @sanderling/spec ``` -## Usage +[Getting started](https://priyanshujain.github.io/sanderling/manual/getting-started/) installs the CLI and runs a first spec. The [spec language reference](https://priyanshujain.github.io/sanderling/manual/spec-language/) lists every primitive, and the [case study](https://priyanshujain.github.io/sanderling/manual/case-study/) walks a complete spec end to end. -```ts -import { extract, always, eventually, actions, weighted, taps, swipes, InputText, Tap } from "@sanderling/spec"; - -const loggedIn = extract((s) => !!s.ax.find("id:home-tab-bar")); -const balance = extract((s) => (s.snapshots.balance as number) ?? 0); -const emailField = extract((s) => s.ax.find("id:email-field")); -const submitButton = extract((s) => s.ax.find("id:sign-in-button")); - -export const properties = { - balanceNeverNegative: always(() => balance.current >= 0), - loginSucceeds: eventually(() => loggedIn.current).within(30, "seconds"), -}; - -const doLogin = actions(() => { - if (loggedIn.current) return []; - const email = emailField.current; - const submit = submitButton.current; - if (!email || !submit) return []; - return [InputText({ into: email, text: "test@example.com" }), Tap({ on: submit })]; -}); - -export const actionsRoot = weighted( - [50, doLogin], - [10, taps], - [2, swipes], -); -``` - -## Setup actions - -Some action generators are not fuzz targets but preconditions: they drive the -app from a fresh state into the surface you actually want to fuzz (login, -onboarding, permission grants, seed data). Export them as `setup` instead of -mixing them into `actionsRoot`. The runner tries `setup` first; if it yields -no action, it falls through to `actionsRoot`. State regressing back across the -precondition (e.g. logout under fuzz) automatically re-engages setup. - -```ts -const login = actions(() => { - if (loggedIn.current) return []; - return [InputText({ into: emailField.current!, text: "demo@app.test" }), Tap({ on: submitButton.current! })]; -}); - -export const setup = login; -export const actionsRoot = weighted([60, browse], [40, edit]); - -(globalThis as { setup?: unknown }).setup = setup; -``` - -Setup is just an `ActionGenerator`; compose with `actions`, `weighted`, or -`whenRoute` exactly like the main pool. - -Works identically across Android, iOS, and web targets. +The CLI bundles this package's TypeScript sources at run time, so keep the CLI and the package on the same release. From 47481fed93ce78ed0b8843aec29bdc1cbd60ca9c Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 15 Aug 2026 13:32:28 +0530 Subject: [PATCH 7/8] docs: say how the cli and spec package versions relate --- docs/manual/getting-started.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/manual/getting-started.md b/docs/manual/getting-started.md index 7250ae3..5555db7 100644 --- a/docs/manual/getting-started.md +++ b/docs/manual/getting-started.md @@ -20,6 +20,8 @@ The spec package, in your project: npm install --save-dev @sanderling/spec ``` +Both come from the same release tag, and the CLI bundles the package's TypeScript sources when it evaluates your spec, so upgrade them together. Pre-releases are published under npm's `next` tag; `npm install @sanderling/spec` gives you the current stable one. + ## Check your environment ```sh From 2a807b38fc3758745b2b87d8dc71e4a40f46c96e Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 15 Aug 2026 13:36:19 +0530 Subject: [PATCH 8/8] fix(release): stage the sidecar jar at the renamed embed path --- .goreleaser.yaml | 8 ++++---- Makefile | 6 +++++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 1647206..e83ff41 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -5,10 +5,10 @@ project_name: sanderling before: hooks: # The Go binary embeds the sidecar fat JAR via //go:embed gated by the - # `withsidecar` build tag. Rebuild the JAR and stage it at the embed - # path so the `go build` below picks up fresh bytes. - - make sidecar - - sh -c 'mkdir -p internal/sidecar/assets && cp sidecar/build/libs/sidecar-all.jar internal/sidecar/assets/sidecar-all.jar' + # `withsidecar` build tag. The Makefile target rebuilds the JAR and stages + # it at the embed path, so the `go build` below picks up fresh bytes and + # that path stays spelled out in exactly one place. + - make sidecar-embed builds: - id: sanderling diff --git a/Makefile b/Makefile index fb40b4e..632f8b0 100644 --- a/Makefile +++ b/Makefile @@ -29,7 +29,7 @@ WEB_DIST := replay-ui/dist GOLINES := $(shell $(GO) env GOPATH)/bin/golines -.PHONY: bootstrap proto sidecar sanderling sanderling-web sanderling-android sanderling-ios install test test-go test-browser test-companion test-kotlin test-spec-api spec-typecheck web-test web-typecheck web-build web-dev replay-dev docs clean release-cli release-npm-dry fmt fmt-go fmt-kotlin fmt-ts fmt-swift +.PHONY: bootstrap proto sidecar sidecar-embed sanderling sanderling-web sanderling-android sanderling-ios install test test-go test-browser test-companion test-kotlin test-spec-api spec-typecheck web-test web-typecheck web-build web-dev replay-dev docs clean release-cli release-npm-dry fmt fmt-go fmt-kotlin fmt-ts fmt-swift bootstrap: $(GO) mod download @@ -42,6 +42,10 @@ proto: sidecar: $(SIDECAR_JAR) +# Stages the JAR where //go:embed expects it. Release tooling calls this rather +# than copying the JAR itself, so the embed path is spelled out in one place. +sidecar-embed: $(SIDECAR_EMBED) + sanderling: $(SANDERLING_BIN) $(SANDERLING_BIN): $(SIDECAR_EMBED) $(COMPANION_EMBED) $(RUNNER_EMBED) web-build