mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
refactor: code organization cleanup (#35)
* chore: fix gitignore + decisions doc after web->inspect-ui rename Update web/ references to inspect-ui/ in .gitignore and Makefile. Add decisions.md tracking architectural decisions from code-org discussion. * refactor: rename pkg/spec-api to pkg/spec Aligns the directory name with the npm package name @sanderling/spec. Updates Makefile, package.json directory field, and resolveSpecAPIPath. * refactor(verifier): split bindings.go into types.go + bindings.go Move shared public types (Action, ActionKind, LogEntry, Exception) to types.go. bindings.go retains internal JS runtime wiring only. * refactor(inspect): split runs.go into runs.go, runs_cache.go, runs_decode.go runs.go: types (RunSummary, StepSummary, RunDetail, Run) and Scan. runs_cache.go: Cache type, Open/Step/Detail methods, parseRun, scanSteps. runs_decode.go: readMeta, tallyTrace, decodeStepSummary, validRunID. * refactor: move android_env.go to internal/android/ Extracts Android device/AVD/adb logic into internal/android package. Exports EnsureDevice, AdbReverse, AdbReverseRemove, EnvWithAndroidPlatformTools, AdbBinary. Moves tests to internal/android/android_test.go. cmd/sanderling becomes a thin caller. * refactor: extract test pipeline to internal/testrun/ runTestPipeline logic moves to testrun.Execute. buildDriver, resolveSpecAPIPath, pickFreePort, and the progress logger move to internal/testrun/. cmd/sanderling/test_run.go becomes a thin adapter. Tests follow their code. * ci: update workflow paths after pkg/spec-api -> pkg/spec rename
This commit is contained in:
32 files changed
+870
-745
No files matched your search
@@ -0,0 +1,39 @@
|
||||
# @sanderling/spec
|
||||
|
||||
TypeScript spec API for [sanderling](https://github.com/priyanshujain/sanderling), 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 `sanderling` CLI picks up the spec and drives the app under test.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
npm install --save-dev @sanderling/spec
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { extract, always, actions, Tap, weighted } from "@sanderling/spec";
|
||||
|
||||
export const spec = {
|
||||
extract: extract((tree) => ({
|
||||
onHomeScreen: tree.some((n) => n.text === "Home"),
|
||||
})),
|
||||
|
||||
always: always(({ state }) => state.onHomeScreen || !state.startedOnHome),
|
||||
|
||||
actions: actions(({ tree }) =>
|
||||
weighted([
|
||||
[1, Tap(tree.first((n) => n.text === "Checkout"))],
|
||||
]),
|
||||
),
|
||||
};
|
||||
```
|
||||
|
||||
## 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
|
||||
Generated
+591
@@ -0,0 +1,591 @@
|
||||
{
|
||||
"name": "@sanderling/spec",
|
||||
"version": "0.0.0-dev",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@sanderling/spec",
|
||||
"version": "0.0.0-dev",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.7.5",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
||||
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
|
||||
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
|
||||
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
|
||||
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
|
||||
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
|
||||
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
|
||||
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
|
||||
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
|
||||
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
|
||||
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
|
||||
"integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
|
||||
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.7",
|
||||
"@esbuild/android-arm": "0.27.7",
|
||||
"@esbuild/android-arm64": "0.27.7",
|
||||
"@esbuild/android-x64": "0.27.7",
|
||||
"@esbuild/darwin-arm64": "0.27.7",
|
||||
"@esbuild/darwin-x64": "0.27.7",
|
||||
"@esbuild/freebsd-arm64": "0.27.7",
|
||||
"@esbuild/freebsd-x64": "0.27.7",
|
||||
"@esbuild/linux-arm": "0.27.7",
|
||||
"@esbuild/linux-arm64": "0.27.7",
|
||||
"@esbuild/linux-ia32": "0.27.7",
|
||||
"@esbuild/linux-loong64": "0.27.7",
|
||||
"@esbuild/linux-mips64el": "0.27.7",
|
||||
"@esbuild/linux-ppc64": "0.27.7",
|
||||
"@esbuild/linux-riscv64": "0.27.7",
|
||||
"@esbuild/linux-s390x": "0.27.7",
|
||||
"@esbuild/linux-x64": "0.27.7",
|
||||
"@esbuild/netbsd-arm64": "0.27.7",
|
||||
"@esbuild/netbsd-x64": "0.27.7",
|
||||
"@esbuild/openbsd-arm64": "0.27.7",
|
||||
"@esbuild/openbsd-x64": "0.27.7",
|
||||
"@esbuild/openharmony-arm64": "0.27.7",
|
||||
"@esbuild/sunos-x64": "0.27.7",
|
||||
"@esbuild/win32-arm64": "0.27.7",
|
||||
"@esbuild/win32-ia32": "0.27.7",
|
||||
"@esbuild/win32-x64": "0.27.7"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-tsconfig": {
|
||||
"version": "4.14.0",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
|
||||
"integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"resolve-pkg-maps": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@sanderling/spec",
|
||||
"version": "0.0.0-dev",
|
||||
"description": "TypeScript spec API for sanderling, a property-based UI fuzzer for mobile apps.",
|
||||
"type": "module",
|
||||
"types": "./dist/index.d.ts",
|
||||
"main": "./dist/index.js",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./defaults/properties": {
|
||||
"types": "./dist/defaults/properties.d.ts",
|
||||
"import": "./dist/defaults/properties.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/priyanshujain/sanderling.git",
|
||||
"directory": "pkg/spec"
|
||||
},
|
||||
"homepage": "https://github.com/priyanshujain/sanderling",
|
||||
"bugs": {
|
||||
"url": "https://github.com/priyanshujain/sanderling/issues"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"author": "Priyanshu Jain",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"prepublishOnly": "npm run build",
|
||||
"check": "tsc --noEmit",
|
||||
"test": "node --import tsx --test test/*.test.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.7.5",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type {
|
||||
AccessibilityElement,
|
||||
Action,
|
||||
ActionGenerator,
|
||||
InputTextAction,
|
||||
Key,
|
||||
Point,
|
||||
PressKeyAction,
|
||||
Sampler,
|
||||
SwipeAction,
|
||||
TapAction,
|
||||
WaitAction,
|
||||
WeightedEntry,
|
||||
} from "./types.ts";
|
||||
|
||||
export function actions(generator: () => Action[]): ActionGenerator {
|
||||
return globalThis.__sanderling__.actions(generator);
|
||||
}
|
||||
|
||||
export function weighted(...entries: WeightedEntry[]): ActionGenerator {
|
||||
return globalThis.__sanderling__.weighted(...entries);
|
||||
}
|
||||
|
||||
export function from<T>(items: readonly T[]): Sampler<T> {
|
||||
return globalThis.__sanderling__.from(items);
|
||||
}
|
||||
|
||||
export function Tap(parameters: { on: string | AccessibilityElement }): TapAction {
|
||||
return globalThis.__sanderling__.tap(parameters);
|
||||
}
|
||||
|
||||
export function InputText(parameters: {
|
||||
into: string | AccessibilityElement;
|
||||
text: string;
|
||||
}): InputTextAction {
|
||||
return globalThis.__sanderling__.inputText(parameters);
|
||||
}
|
||||
|
||||
export function Swipe(parameters: {
|
||||
from: Point | AccessibilityElement;
|
||||
to: Point | AccessibilityElement;
|
||||
durationMillis?: number;
|
||||
}): SwipeAction {
|
||||
return globalThis.__sanderling__.swipe(parameters);
|
||||
}
|
||||
|
||||
export function PressKey(parameters: { key: Key }): PressKeyAction {
|
||||
return globalThis.__sanderling__.pressKey(parameters);
|
||||
}
|
||||
|
||||
export function Wait(parameters: { durationMillis: number }): WaitAction {
|
||||
return globalThis.__sanderling__.wait(parameters);
|
||||
}
|
||||
|
||||
function builtinGenerator(name: "taps" | "swipes" | "waitOnce" | "pressKeys"): ActionGenerator {
|
||||
return new Proxy({} as ActionGenerator, {
|
||||
get(_target, property) {
|
||||
const runtime = globalThis.__sanderling__[name] as unknown as Record<
|
||||
string | symbol,
|
||||
unknown
|
||||
>;
|
||||
return runtime[property];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const taps: ActionGenerator = builtinGenerator("taps");
|
||||
export const swipes: ActionGenerator = builtinGenerator("swipes");
|
||||
export const waitOnce: ActionGenerator = builtinGenerator("waitOnce");
|
||||
export const pressKey: ActionGenerator = builtinGenerator("pressKeys");
|
||||
@@ -0,0 +1,21 @@
|
||||
import { always } from "../ltl.ts";
|
||||
import { extract } from "../extract.ts";
|
||||
import type { Formula } from "../types.ts";
|
||||
|
||||
const exceptionCount = extract<number>((state) => state.exceptions.length);
|
||||
|
||||
// Fails when the SDK captured an uncaught throwable or a Sanderling.reportError
|
||||
// call surfaced one during the run.
|
||||
export const noUncaughtExceptions: Formula = always(
|
||||
() => exceptionCount.current === 0,
|
||||
);
|
||||
|
||||
const errorLogCount = extract<number>(
|
||||
(state) => state.logs.reduce((count, log) => count + (log.level === "E" ? 1 : 0), 0),
|
||||
);
|
||||
|
||||
// Fails when the runner's logcat fetch observed any error-level lines since
|
||||
// the previous step.
|
||||
export const noLogcatErrors: Formula = always(
|
||||
() => errorLogCount.current === 0,
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { Extracted, State } from "./types.ts";
|
||||
|
||||
export function extract<T>(getter: (state: State) => T): Extracted<T> {
|
||||
return globalThis.__sanderling__.extract(getter);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export type {
|
||||
AccessibilityElement,
|
||||
AccessibilityTree,
|
||||
Action,
|
||||
ActionGenerator,
|
||||
EventuallyFormula,
|
||||
ExceptionRecord,
|
||||
Extracted,
|
||||
Formula,
|
||||
InputTextAction,
|
||||
Key,
|
||||
LogEntry,
|
||||
Point,
|
||||
PressKeyAction,
|
||||
Sampler,
|
||||
Snapshots,
|
||||
State,
|
||||
SwipeAction,
|
||||
TapAction,
|
||||
SanderlingRuntime,
|
||||
WaitAction,
|
||||
WeightedEntry,
|
||||
} from "./types.ts";
|
||||
|
||||
export { extract } from "./extract.ts";
|
||||
export { always, eventually, next, now } from "./ltl.ts";
|
||||
export {
|
||||
InputText,
|
||||
PressKey,
|
||||
Swipe,
|
||||
Tap,
|
||||
Wait,
|
||||
actions,
|
||||
from,
|
||||
pressKey,
|
||||
swipes,
|
||||
taps,
|
||||
waitOnce,
|
||||
weighted,
|
||||
} from "./actions.ts";
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { EventuallyFormula, Formula } from "./types.ts";
|
||||
|
||||
export function always(predicateOrFormula: (() => boolean) | Formula): Formula {
|
||||
return globalThis.__sanderling__.always(predicateOrFormula);
|
||||
}
|
||||
|
||||
export function now(predicate: () => boolean): Formula {
|
||||
return globalThis.__sanderling__.now(predicate);
|
||||
}
|
||||
|
||||
export function next(predicate: () => boolean): Formula {
|
||||
return globalThis.__sanderling__.next(predicate);
|
||||
}
|
||||
|
||||
// An unbounded `eventually` never forces a violation within a finite run —
|
||||
// prefer `.within(n, unit)` when you want the verifier to fail a property
|
||||
// that stalls.
|
||||
export function eventually(predicate: () => boolean): EventuallyFormula {
|
||||
return globalThis.__sanderling__.eventually(predicate);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
export type Snapshots = Record<string, unknown>;
|
||||
|
||||
export interface AccessibilityElement {
|
||||
id?: string;
|
||||
text?: string;
|
||||
bounds?: { left: number; top: number; right: number; bottom: number };
|
||||
x?: number;
|
||||
y?: number;
|
||||
}
|
||||
|
||||
export interface AccessibilityTree {
|
||||
find(selector: string): AccessibilityElement | undefined;
|
||||
findAll(selector: string): AccessibilityElement[];
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
unixMillis: number;
|
||||
level: string;
|
||||
tag: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ExceptionRecord {
|
||||
class: string;
|
||||
message: string;
|
||||
stackTrace: string;
|
||||
unixMillis?: number;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
snapshots: Snapshots;
|
||||
ax: AccessibilityTree;
|
||||
lastAction: Action | null;
|
||||
time: number;
|
||||
logs: readonly LogEntry[];
|
||||
exceptions: readonly ExceptionRecord[];
|
||||
}
|
||||
|
||||
export interface Extracted<T> {
|
||||
readonly current: T;
|
||||
readonly previous: T | undefined;
|
||||
}
|
||||
|
||||
export interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export type TapAction = { kind: "Tap"; on: string | AccessibilityElement };
|
||||
export type InputTextAction = {
|
||||
kind: "InputText";
|
||||
into: string | AccessibilityElement;
|
||||
text: string;
|
||||
};
|
||||
export type SwipeAction = {
|
||||
kind: "Swipe";
|
||||
from: Point | AccessibilityElement;
|
||||
to: Point | AccessibilityElement;
|
||||
durationMillis?: number;
|
||||
};
|
||||
export type PressKeyAction = { kind: "PressKey"; key: Key };
|
||||
export type WaitAction = { kind: "Wait"; durationMillis: number };
|
||||
export type Action =
|
||||
| TapAction
|
||||
| InputTextAction
|
||||
| SwipeAction
|
||||
| PressKeyAction
|
||||
| WaitAction;
|
||||
|
||||
export type Key =
|
||||
| "back"
|
||||
| "home"
|
||||
| "enter"
|
||||
| "tab"
|
||||
| "up"
|
||||
| "down"
|
||||
| "left"
|
||||
| "right";
|
||||
|
||||
export interface ActionGenerator {
|
||||
readonly __sanderlingActionGenerator: true;
|
||||
generate(): Action[];
|
||||
}
|
||||
|
||||
export interface Formula {
|
||||
readonly __sanderlingFormula: true;
|
||||
implies(other: Formula): Formula;
|
||||
or(other: Formula): Formula;
|
||||
and(other: Formula): Formula;
|
||||
not(): Formula;
|
||||
}
|
||||
|
||||
export interface EventuallyFormula extends Formula {
|
||||
within(amount: number, unit: "milliseconds" | "seconds" | "steps"): Formula;
|
||||
}
|
||||
|
||||
export interface Sampler<T> {
|
||||
generate(): T;
|
||||
}
|
||||
|
||||
export interface SanderlingRuntime {
|
||||
extract: <T>(getter: (state: State) => T) => Extracted<T>;
|
||||
always: (predicateOrFormula: (() => boolean) | Formula) => Formula;
|
||||
now: (predicate: () => boolean) => Formula;
|
||||
next: (predicate: () => boolean) => Formula;
|
||||
eventually: (predicate: () => boolean) => EventuallyFormula;
|
||||
actions: (generator: () => Action[]) => ActionGenerator;
|
||||
weighted: (...entries: WeightedEntry[]) => ActionGenerator;
|
||||
from: <T>(items: readonly T[]) => Sampler<T>;
|
||||
tap: (parameters: { on: string | AccessibilityElement }) => TapAction;
|
||||
inputText: (parameters: {
|
||||
into: string | AccessibilityElement;
|
||||
text: string;
|
||||
}) => InputTextAction;
|
||||
swipe: (parameters: {
|
||||
from: Point | AccessibilityElement;
|
||||
to: Point | AccessibilityElement;
|
||||
durationMillis?: number;
|
||||
}) => SwipeAction;
|
||||
pressKey: (parameters: { key: Key }) => PressKeyAction;
|
||||
wait: (parameters: { durationMillis: number }) => WaitAction;
|
||||
taps: ActionGenerator;
|
||||
swipes: ActionGenerator;
|
||||
waitOnce: ActionGenerator;
|
||||
pressKeys: ActionGenerator;
|
||||
}
|
||||
|
||||
export type WeightedEntry = readonly [number, ActionGenerator];
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __sanderling__: SanderlingRuntime;
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import {
|
||||
InputText,
|
||||
PressKey,
|
||||
Swipe,
|
||||
Tap,
|
||||
Wait,
|
||||
actions,
|
||||
always,
|
||||
eventually,
|
||||
extract,
|
||||
from,
|
||||
next,
|
||||
now,
|
||||
pressKey,
|
||||
swipes,
|
||||
taps,
|
||||
waitOnce,
|
||||
weighted,
|
||||
} from "../src/index.ts";
|
||||
import type {
|
||||
AccessibilityElement,
|
||||
Action,
|
||||
ActionGenerator,
|
||||
EventuallyFormula,
|
||||
Extracted,
|
||||
Formula,
|
||||
Sampler,
|
||||
State,
|
||||
SanderlingRuntime,
|
||||
WeightedEntry,
|
||||
} from "../src/types.ts";
|
||||
|
||||
interface RecordedRuntime extends SanderlingRuntime {
|
||||
extracts: Array<(state: State) => unknown>;
|
||||
alwaysArgs: Array<(() => boolean) | Formula>;
|
||||
nowPredicates: Array<() => boolean>;
|
||||
nextPredicates: Array<() => boolean>;
|
||||
eventuallyPredicates: Array<() => boolean>;
|
||||
withinCalls: Array<{ amount: number; unit: string }>;
|
||||
impliesCalls: number;
|
||||
orCalls: number;
|
||||
andCalls: number;
|
||||
notCalls: number;
|
||||
actionGenerators: Array<() => Action[]>;
|
||||
weightedCalls: WeightedEntry[][];
|
||||
fromCalls: unknown[][];
|
||||
}
|
||||
|
||||
function makeChainableFormula(record: RecordedRuntime): Formula {
|
||||
const formula: Formula = {
|
||||
__sanderlingFormula: true,
|
||||
implies(other: Formula): Formula {
|
||||
record.impliesCalls++;
|
||||
void other;
|
||||
return makeChainableFormula(record);
|
||||
},
|
||||
or(other: Formula): Formula {
|
||||
record.orCalls++;
|
||||
void other;
|
||||
return makeChainableFormula(record);
|
||||
},
|
||||
and(other: Formula): Formula {
|
||||
record.andCalls++;
|
||||
void other;
|
||||
return makeChainableFormula(record);
|
||||
},
|
||||
not(): Formula {
|
||||
record.notCalls++;
|
||||
return makeChainableFormula(record);
|
||||
},
|
||||
};
|
||||
return formula;
|
||||
}
|
||||
|
||||
function makeChainableEventually(record: RecordedRuntime): EventuallyFormula {
|
||||
const base = makeChainableFormula(record);
|
||||
return {
|
||||
...base,
|
||||
within(amount, unit) {
|
||||
record.withinCalls.push({ amount, unit });
|
||||
return makeChainableFormula(record);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function installFakeRuntime(): RecordedRuntime {
|
||||
const calls = {
|
||||
extracts: [] as Array<(state: State) => unknown>,
|
||||
alwaysArgs: [] as Array<(() => boolean) | Formula>,
|
||||
nowPredicates: [] as Array<() => boolean>,
|
||||
nextPredicates: [] as Array<() => boolean>,
|
||||
eventuallyPredicates: [] as Array<() => boolean>,
|
||||
withinCalls: [] as Array<{ amount: number; unit: string }>,
|
||||
impliesCalls: 0,
|
||||
orCalls: 0,
|
||||
andCalls: 0,
|
||||
notCalls: 0,
|
||||
actionGenerators: [] as Array<() => Action[]>,
|
||||
weightedCalls: [] as WeightedEntry[][],
|
||||
fromCalls: [] as unknown[][],
|
||||
};
|
||||
const runtime = {
|
||||
extract: <T>(getter: (state: State) => T): Extracted<T> => {
|
||||
calls.extracts.push(getter as (state: State) => unknown);
|
||||
return { current: undefined as unknown as T, previous: undefined };
|
||||
},
|
||||
always: (predicateOrFormula: (() => boolean) | Formula): Formula => {
|
||||
calls.alwaysArgs.push(predicateOrFormula);
|
||||
return makeChainableFormula(recorded);
|
||||
},
|
||||
now: (predicate: () => boolean): Formula => {
|
||||
calls.nowPredicates.push(predicate);
|
||||
return makeChainableFormula(recorded);
|
||||
},
|
||||
next: (predicate: () => boolean): Formula => {
|
||||
calls.nextPredicates.push(predicate);
|
||||
return makeChainableFormula(recorded);
|
||||
},
|
||||
eventually: (predicate: () => boolean): EventuallyFormula => {
|
||||
calls.eventuallyPredicates.push(predicate);
|
||||
return makeChainableEventually(recorded);
|
||||
},
|
||||
actions: (generator: () => Action[]): ActionGenerator => {
|
||||
calls.actionGenerators.push(generator);
|
||||
return { __sanderlingActionGenerator: true, generate: generator };
|
||||
},
|
||||
weighted: (...entries: WeightedEntry[]): ActionGenerator => {
|
||||
calls.weightedCalls.push(entries);
|
||||
return { __sanderlingActionGenerator: true, generate: () => [] };
|
||||
},
|
||||
from: <T>(items: readonly T[]): Sampler<T> => {
|
||||
calls.fromCalls.push(items as unknown[]);
|
||||
return { generate: () => items[0] as T };
|
||||
},
|
||||
tap: ({ on }) => ({ kind: "Tap", on }),
|
||||
inputText: ({ into, text }) => ({ kind: "InputText", into, text }),
|
||||
swipe: ({ from: fromPoint, to, durationMillis }) => ({
|
||||
kind: "Swipe",
|
||||
from: fromPoint,
|
||||
to,
|
||||
durationMillis,
|
||||
}),
|
||||
pressKey: ({ key }) => ({ kind: "PressKey", key }),
|
||||
wait: ({ durationMillis }) => ({ kind: "Wait", durationMillis }),
|
||||
taps: { __sanderlingActionGenerator: true, generate: () => [] },
|
||||
swipes: { __sanderlingActionGenerator: true, generate: () => [] },
|
||||
waitOnce: { __sanderlingActionGenerator: true, generate: () => [] },
|
||||
pressKeys: { __sanderlingActionGenerator: true, generate: () => [] },
|
||||
} satisfies SanderlingRuntime;
|
||||
const recorded = Object.assign(runtime, calls) as RecordedRuntime;
|
||||
globalThis.__sanderling__ = recorded;
|
||||
return recorded;
|
||||
}
|
||||
|
||||
test("extract forwards the getter to the runtime", () => {
|
||||
const runtime = installFakeRuntime();
|
||||
const getter = (state: State) => state.snapshots["balance"];
|
||||
extract<unknown>(getter);
|
||||
assert.equal(runtime.extracts.length, 1);
|
||||
assert.equal(runtime.extracts[0], getter);
|
||||
});
|
||||
|
||||
test("always wraps a predicate into a formula via the runtime", () => {
|
||||
const runtime = installFakeRuntime();
|
||||
const predicate = () => true;
|
||||
const formula = always(predicate);
|
||||
assert.equal(runtime.alwaysArgs[0], predicate);
|
||||
assert.equal(formula.__sanderlingFormula, true);
|
||||
});
|
||||
|
||||
test("always accepts a formula handle", () => {
|
||||
const runtime = installFakeRuntime();
|
||||
const inner = now(() => true);
|
||||
const wrapped = always(inner);
|
||||
assert.equal(runtime.alwaysArgs.at(-1), inner);
|
||||
assert.equal(wrapped.__sanderlingFormula, true);
|
||||
});
|
||||
|
||||
test("now/next/eventually forward predicates", () => {
|
||||
const runtime = installFakeRuntime();
|
||||
const p1 = () => true;
|
||||
const p2 = () => false;
|
||||
const p3 = () => true;
|
||||
now(p1);
|
||||
next(p2);
|
||||
eventually(p3);
|
||||
assert.equal(runtime.nowPredicates[0], p1);
|
||||
assert.equal(runtime.nextPredicates[0], p2);
|
||||
assert.equal(runtime.eventuallyPredicates[0], p3);
|
||||
});
|
||||
|
||||
test("eventually().within forwards unit and amount", () => {
|
||||
const runtime = installFakeRuntime();
|
||||
eventually(() => true).within(3, "seconds");
|
||||
assert.deepEqual(runtime.withinCalls[0], { amount: 3, unit: "seconds" });
|
||||
});
|
||||
|
||||
test("formula chaining exposes implies/or/and/not", () => {
|
||||
const runtime = installFakeRuntime();
|
||||
const a = now(() => true);
|
||||
const b = now(() => false);
|
||||
a.implies(b).or(b).and(b).not();
|
||||
assert.equal(runtime.impliesCalls, 1);
|
||||
assert.equal(runtime.orCalls, 1);
|
||||
assert.equal(runtime.andCalls, 1);
|
||||
assert.equal(runtime.notCalls, 1);
|
||||
});
|
||||
|
||||
test("Tap returns a TapAction with the supplied selector", () => {
|
||||
installFakeRuntime();
|
||||
const action = Tap({ on: "id:login_continue" });
|
||||
assert.deepEqual(action, { kind: "Tap", on: "id:login_continue" });
|
||||
});
|
||||
|
||||
test("Tap accepts an AccessibilityElement", () => {
|
||||
installFakeRuntime();
|
||||
const element: AccessibilityElement = { id: "login_continue" };
|
||||
const action = Tap({ on: element });
|
||||
assert.equal(action.kind, "Tap");
|
||||
assert.equal(action.on, element);
|
||||
});
|
||||
|
||||
test("InputText returns an InputTextAction", () => {
|
||||
installFakeRuntime();
|
||||
const action = InputText({ into: "id:phone", text: "+1234567890" });
|
||||
assert.deepEqual(action, { kind: "InputText", into: "id:phone", text: "+1234567890" });
|
||||
});
|
||||
|
||||
test("Swipe returns a SwipeAction with the supplied endpoints", () => {
|
||||
installFakeRuntime();
|
||||
const action = Swipe({ from: { x: 10, y: 20 }, to: { x: 30, y: 40 }, durationMillis: 400 });
|
||||
assert.deepEqual(action, {
|
||||
kind: "Swipe",
|
||||
from: { x: 10, y: 20 },
|
||||
to: { x: 30, y: 40 },
|
||||
durationMillis: 400,
|
||||
});
|
||||
});
|
||||
|
||||
test("PressKey returns a PressKeyAction", () => {
|
||||
installFakeRuntime();
|
||||
const action = PressKey({ key: "back" });
|
||||
assert.deepEqual(action, { kind: "PressKey", key: "back" });
|
||||
});
|
||||
|
||||
test("Wait returns a WaitAction", () => {
|
||||
installFakeRuntime();
|
||||
const action = Wait({ durationMillis: 500 });
|
||||
assert.deepEqual(action, { kind: "Wait", durationMillis: 500 });
|
||||
});
|
||||
|
||||
test("actions wraps a generator into the runtime's ActionGenerator", () => {
|
||||
const runtime = installFakeRuntime();
|
||||
const generator = () => [Tap({ on: "id:x" })];
|
||||
const wrapped = actions(generator);
|
||||
assert.equal(runtime.actionGenerators[0], generator);
|
||||
assert.equal(wrapped.__sanderlingActionGenerator, true);
|
||||
});
|
||||
|
||||
test("weighted forwards weighted entries to the runtime", () => {
|
||||
const runtime = installFakeRuntime();
|
||||
const entries: WeightedEntry[] = [
|
||||
[80, taps],
|
||||
[20, swipes],
|
||||
];
|
||||
weighted(...entries);
|
||||
assert.deepEqual(runtime.weightedCalls[0], entries);
|
||||
});
|
||||
|
||||
test("from forwards items to the runtime", () => {
|
||||
const runtime = installFakeRuntime();
|
||||
const sampler = from(["a", "b", "c"]);
|
||||
assert.deepEqual(runtime.fromCalls[0], ["a", "b", "c"]);
|
||||
assert.equal(sampler.generate(), "a");
|
||||
});
|
||||
|
||||
test("default generators proxy through to the runtime", () => {
|
||||
installFakeRuntime();
|
||||
assert.equal(taps.__sanderlingActionGenerator, true);
|
||||
assert.equal(swipes.__sanderlingActionGenerator, true);
|
||||
assert.equal(waitOnce.__sanderlingActionGenerator, true);
|
||||
assert.equal(pressKey.__sanderlingActionGenerator, true);
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import type {
|
||||
Action,
|
||||
ActionGenerator,
|
||||
EventuallyFormula,
|
||||
Extracted,
|
||||
Formula,
|
||||
Sampler,
|
||||
State,
|
||||
SanderlingRuntime,
|
||||
WeightedEntry,
|
||||
} from "../src/types.ts";
|
||||
|
||||
interface RecordedRuntime extends SanderlingRuntime {
|
||||
currentState: State;
|
||||
extractors: Array<(state: State) => unknown>;
|
||||
alwaysArgs: Array<(() => boolean) | Formula>;
|
||||
lastPredicate: (() => boolean) | undefined;
|
||||
}
|
||||
|
||||
function installRuntime(initialState: State): RecordedRuntime {
|
||||
const extractors: Array<(state: State) => unknown> = [];
|
||||
const extracted: Array<{ value: unknown }> = [];
|
||||
const alwaysArgs: Array<(() => boolean) | Formula> = [];
|
||||
let lastPredicate: (() => boolean) | undefined;
|
||||
|
||||
const runtime = {
|
||||
extract: <T>(getter: (state: State) => T): Extracted<T> => {
|
||||
extractors.push(getter as (state: State) => unknown);
|
||||
const slot = { value: getter(state.currentState) };
|
||||
extracted.push(slot);
|
||||
return {
|
||||
get current(): T {
|
||||
return slot.value as T;
|
||||
},
|
||||
previous: undefined,
|
||||
};
|
||||
},
|
||||
always: (predicateOrFormula: (() => boolean) | Formula): Formula => {
|
||||
alwaysArgs.push(predicateOrFormula);
|
||||
if (typeof predicateOrFormula === "function") {
|
||||
lastPredicate = predicateOrFormula;
|
||||
}
|
||||
return { __sanderlingFormula: true } as Formula;
|
||||
},
|
||||
now: () => ({ __sanderlingFormula: true } as Formula),
|
||||
next: () => ({ __sanderlingFormula: true } as Formula),
|
||||
eventually: () => ({ __sanderlingFormula: true } as EventuallyFormula),
|
||||
actions: (generator: () => Action[]): ActionGenerator => ({
|
||||
__sanderlingActionGenerator: true,
|
||||
generate: generator,
|
||||
}),
|
||||
weighted: (..._entries: WeightedEntry[]): ActionGenerator => ({
|
||||
__sanderlingActionGenerator: true,
|
||||
generate: () => [],
|
||||
}),
|
||||
from: <T>(_items: readonly T[]): Sampler<T> => ({ generate: () => _items[0] as T }),
|
||||
tap: ({ on }) => ({ kind: "Tap", on }),
|
||||
inputText: ({ into, text }) => ({ kind: "InputText", into, text }),
|
||||
swipe: (p) => ({ kind: "Swipe", from: p.from, to: p.to, durationMillis: p.durationMillis }),
|
||||
pressKey: ({ key }) => ({ kind: "PressKey", key }),
|
||||
wait: ({ durationMillis }) => ({ kind: "Wait", durationMillis }),
|
||||
taps: { __sanderlingActionGenerator: true, generate: () => [] } as ActionGenerator,
|
||||
swipes: { __sanderlingActionGenerator: true, generate: () => [] } as ActionGenerator,
|
||||
waitOnce: { __sanderlingActionGenerator: true, generate: () => [] } as ActionGenerator,
|
||||
pressKeys: { __sanderlingActionGenerator: true, generate: () => [] } as ActionGenerator,
|
||||
} satisfies SanderlingRuntime;
|
||||
|
||||
const state = { currentState: initialState };
|
||||
const recorded = Object.assign(runtime, {
|
||||
currentState: initialState,
|
||||
extractors,
|
||||
alwaysArgs,
|
||||
get lastPredicate() {
|
||||
return lastPredicate;
|
||||
},
|
||||
}) as unknown as RecordedRuntime;
|
||||
globalThis.__sanderling__ = recorded;
|
||||
// Re-bind state ref so subsequent extract() calls read the up-to-date state.
|
||||
Object.defineProperty(recorded, "currentState", {
|
||||
get() {
|
||||
return state.currentState;
|
||||
},
|
||||
set(next: State) {
|
||||
state.currentState = next;
|
||||
},
|
||||
});
|
||||
return recorded;
|
||||
}
|
||||
|
||||
const emptyState: State = {
|
||||
snapshots: {},
|
||||
ax: { find: () => undefined, findAll: () => [] },
|
||||
lastAction: null,
|
||||
time: 0,
|
||||
logs: [],
|
||||
exceptions: [],
|
||||
};
|
||||
|
||||
test("defaults bundle exports formulas tagged as LTL properties", async () => {
|
||||
installRuntime({
|
||||
...emptyState,
|
||||
logs: [{ unixMillis: 1, level: "W", tag: "X", message: "warn" }],
|
||||
});
|
||||
const defaults = await import("../src/defaults/properties.ts");
|
||||
assert.equal(defaults.noUncaughtExceptions.__sanderlingFormula, true);
|
||||
assert.equal(defaults.noLogcatErrors.__sanderlingFormula, true);
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"emitDeclarationOnly": false,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"allowImportingTsExtensions": true,
|
||||
"rewriteRelativeImportExtensions": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["test/**/*", "dist/**/*", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": ["ES2020"],
|
||||
"types": ["node"],
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
}
|
||||
Reference in new issue
Block a user