Merge branch 'spec-npm-release' into pr-73-followups

# Conflicts:
#	Makefile
This commit is contained in:
pj committed 2026-08-15 14:04:56 +05:30
commit b2877a97b1
11 files changed
+207 -74

No files matched your search

+1 -1
View File
@@ -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
+4 -4
View File
@@ -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
+6 -2
View File
@@ -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 test-ci-scripts 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 test-ci-scripts 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
@@ -176,7 +180,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.
+2
View File
@@ -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
+12 -8
View File
@@ -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
}
+152
View File
@@ -2,7 +2,9 @@ package testrun
import (
"context"
"encoding/json"
"errors"
"io/fs"
"os"
"path/filepath"
"strings"
@@ -304,3 +306,153 @@ 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))
}
}
}
// 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)
}
}
+1 -1
View File
@@ -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()
+4 -58
View File
@@ -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<number>((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: "[email protected]" }), 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: "[email protected]" }), 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.
+1
View File
@@ -21,6 +21,7 @@
},
"files": [
"dist",
"src",
"README.md"
],
"repository": {
+3
View File
@@ -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,
+21
View File
@@ -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"],
);
});