mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
UX refactor (#52)
* feat(ltl): bound fields on AlwaysFormula and named thunks Add StepBound/Duration/Deadline to AlwaysFormula as the dual of bounded Eventually, give ThunkFormula a Name for stable identity, add ThunkNamed, and surface both in describe() and MarshalJSON. * feat(ltl): negation normal form pass nnf/pushNot rewrite a formula so every Not wraps only a Thunk or Error leaf, dualizing Always<->Eventually and preserving bounds. * feat(ltl): NNF in NewEvaluator, bounded-always, Finalize, collapse Apply nnf on construction, reduce bounded Always symmetric to bounded Eventually (vacuous holds once the window closes), add Finalize to resolve undischarged liveness obligations to Violated at run end, and collapse structurally-identical pending obligations. * test(ltl): property-based NNF laws Lock double-negation identity, Always/Eventually duality with bound preservation, leaf pushdown, and not(always true) reaching Violated. * test(ltl): Finalize, bounded eventually, latch, collapse Property tests for monotonic violation latch and eventually-within violating iff n consecutive false, plus Finalize and collapse cases. * feat(inspect): within clause on always residual node A negated bounded eventually serializes as a bounded always; render its bound instead of dropping it. * feat(ltl): witness violations and (bool,error) predicate thunks * test(ltl): migrate thunk call sites to (bool,error) * feat(ltl): flag thrown-predicate witnesses with IsError * refactor(verifier): replace predicate err side-channel with violation witness * test(verifier): witness API for thrown predicates * feat(trace): witnesses map and skipped-verification marker on Step * feat(runner): thread violation witnesses, finalize, skip marker into trace * test(ltl): lock violation witness reason, IsError, and step * test(verifier): finalize surfaces unmet eventually with witness * fix(ltl): eliminate implies and bounded-always false-negatives Rewrite a -> b to (not a) or b in NNF so a pending temporal antecedent can no longer defer the whole implication and drop a consequent that was false at the current step. Carry a pending inner past a bounded-Always window close instead of dropping it to holds, so a deferred obligation is resolved by a later step or Finalize. * test(ltl): lock implies and bounded-always false-negative regressions * fix(web-runtime): seed PRNG for reproducible runs and align weighted pick * feat(testrun): inject seed into web bundle via SANDERLING_SEED define * test: cover web-runtime seeded PRNG, weighted pick, and seed define wiring * test(spec): add Go math/rand/v2 PCG oracle and golden fixture * feat(spec): bit-exact PCG port of Go math/rand/v2 * test(spec): assert pcg.ts matches the PCG golden fixture * feat(spec): shared input corpus and press-key pools * feat(spec): action-tree types and Host interface * feat(spec): verb support matrix and warn-once helper * feat(spec): deterministic shared action picker * test(spec): verb matrix and warn-once semantics * test(spec): picker draw-order and determinism * refactor(spec): actions.ts returns pure GeneratorNode data trees * refactor(spec): wire from() sampling through the picker rng * feat(spec): shared runtime-entry installs next-action over pick.ts * feat(spec): export LongPress/Scroll/longPresses/scrolls factories * test(spec): assert data-tree shapes for action factories * test(spec): runtime-entry serializeAction wire-contract round-trip * refactor(spec): bridge data-tree nodes to the legacy goja picker tags * fix(spec): web runtime walks the spec's globalThis.actions data tree * test(spec): tolerate legacy bridge fields on builtin nodes * refactor(spec): installRuntime accepts a lazy root resolver The web bundle imports the runtime before the spec, so the action root on globalThis.actions only exists after the spec evaluates. Accept a function form so the goja and web hosts resolve the root per tick. * refactor(spec): web-runtime becomes the WEB Host, delegates to shared picker Delete the duplicate picker (resolveGenerator/pickWeighted/randomTap/ randomInput/randomSwipe/randomPressKey/pickFromArray, the mulberry32 PRNG, and the snake_case serializeAction) plus the __sanderling__ action factory binds. web-runtime now implements Host (platform/seedHi/seedLo from the injected 64-bit seed via BigInt, queryCandidates over the live DOM with a per-tick cache, reportUnsupported) and calls installRuntime so both engines run pick.ts over the same Pcg. Swipe/longPress/scroll follow the verbs.ts matrix instead of silently returning null. Keeps the DOM helpers (selector translation, queryElement, elementHandle, buildState, sanitize, extractors) and the global locking. Net -214 lines (741 -> 527). * test(spec): cover the WEB Host surface and seed precision Replace the deleted-picker tests with Host coverage: platform()==web, seedHi() parsing a 64-bit seed without Number precision loss, seedLo()==0, reportUnsupported warning, the installed next-action/extractor globals, and queryCandidates verb routing + per-tick caching over a querySelectorAll stub. * refactor(spec): picker emits native selector + scroll endpoints, setup precedence * feat(spec): goja runtime entry wires the shared picker over the Go host * feat(bundler): optional RuntimeFile prepends a runtime-entry import via stdin * feat(testrun): bundle the goja runtime entry so the verifier runs the shared picker * refactor(spec): drop the legacy goja bridge fields from action factories * feat(spec): serialize selector-only string targets for the runner to re-resolve * refactor(verifier): one DecodeAction reads the unified flat wire contract * refactor(verifier): goja host + shared picker replace the duplicate Go picker * refactor(runner): decode V8 actions via the unified DecodeAction; wire goja runtime * test(verifier): author specs through the shared picker path * test(runner): bundle authored specs with the goja runtime entry * feat(verifier): collect unsupported verbs for the run report * refactor(runner): collapse WebDriver forks behind ActionSource/ExtractorSource * feat(testrun): surface unsupported verbs in run report * test(verifier): cross-runtime goja/node parity gate on the shared picker * test(verifier): unsupported verbs collected deduped in first-seen order * test(runner): summary reports no unsupported verbs on a clean run * test(spec): golden-fixture cross-runtime parity gate for the node picker Replace the env-driven parity harness with a shared scenario module and a committed golden the node picker asserts independently. The goja side asserts the same golden, so neither runtime invokes the other at test time. * test(verifier): assert goja picker against the same cross-runtime golden Drop the node-subprocess coupling: the goja side now installs a stub __sanderlingHost__ with the fixed candidate list and asserts the committed golden, matching pkg/spec/test/parity.test.ts. * refactor(spec): rename pressKey generator export to pressKeys * refactor(spec): update barrel re-exports for pressKeys * test(spec): update pressKeys generator export name * docs(spec): rename pressKey generator to pressKeys * refactor(spec): extract samplerRng into shared sampler-rng module * feat(spec): add fluent seeded value generators (strings/integers/emails/edgeCaseText) * test(spec): cover fluent value generators determinism and chaining * refactor(bundler): inject globalThis trailer from spec named exports * refactor(bundler): reuse registration trailer in web bundler * test(bundler): cover named-export globalThis registration * feat(spec): add named() to Extracted handle type * feat(web-runtime): named() and cross-extractor read guard * feat(verifier): named() and cross-extractor read guard in goja * test(verifier): cross-extractor read guard and named() * test(web-runtime): export runtime and extractors for tests * test(web-runtime): named() and cross-extractor read guard * refactor(folio): drop manual globalThis trailer (bundler injects it) * refactor(folio): seed txn amounts via integers().between(1,500) * refactor(folio-web): drop manual globalThis trailer (bundler injects it) * fix(folio-web): seed card/txn-type selection via from().generate() for reproducible runs * refactor(folio-web): weight valid generators against edgeCaseText for names/amounts * refactor(folio-web): name extractors so violation witnesses are readable * fix(web-runtime): propagate extractor getter throws and unpoison locked global Stop swallowing getter errors in evaluateExtractors so the cross-extractor read guard aborts loudly, matching goja's PushSnapshot. Make the __sanderling__ lock configurable (still non-writable) so a shared test process can reinstall a fake. * test(spec): install fake runtime via defineProperty to survive locked global * test(web-runtime): assert uncaught cross-extractor read aborts evaluateExtractors * feat(runner): add MaxSteps bound to Options * test(runner): MaxSteps stops after exactly N steps * test(driverpb): drop proto getter round-trip tautology * test(sidecar): drop stub-mode placeholder tautology tests * test(mock): drop default-field-value assertion test * test(ltl): drop Verdict.String tautology tests * refactor(runner): extract RenderSummary for snapshot testing * test(runner): golden snapshots for trace stream and violation summary * feat(web-runtime): capture uncaught errors into state.exceptions * test(integration): add throwing and counter web fixtures * test(integration): add specs for the web fixtures * test(integration): drive web fixtures through the real pipeline in headless Chrome * chore(make): add test-browser target for the Chrome-driven suite * ci: run the Chrome-driven browser suite in a separate job * refactor(test): relocate browser suite to test/browser * refactor(permissions): delete dead internal/permissions package * refactor(test): rename package to browser_test * refactor(sidecarassets): rename internal/sidecar to internal/sidecarassets * chore(make): point test-browser at test/browser * docs(decisions): record internal/permissions deletion * refactor(doctor): use sidecarassets package * refactor(testrun): use sidecarassets package * fix(test): resolve testdata relative to browser_test.go * refactor(verifier): remove dead __sanderlingIndex compat alias * refactor(bundler): use encoding/json for JS string literals * docs(action-space): use vendor-neutral native driver wording * refactor(hierarchy): scrub backend tool name from comments * refactor(driver): scrub backend tool name from comments * refactor(driver): add DoubleTap and DoubleTapSelector to DeviceDriver * refactor(sidecar): implement DoubleTap with the sub-100ms inter-tap gap * refactor(chrome): implement DoubleTap as two taps with the gap * refactor(mock): record DoubleTap and DoubleTapSelector actions * refactor(runner): delegate double-tap to driver, drop gesture timing * test(runner): assert double-tap delegates to driver DoubleTap * docs(cmd): add package docs to CLI and developer tools * docs(driver): add package docs to driver interface and chrome backend * docs(driver): add package docs to mock and sidecar backends * docs(platform): add package docs to android and ios device prep * docs: add package docs to bundler and inspect * docs(ltl): add package doc to temporal logic evaluator * docs: add package docs to runner and testrun pipeline * docs: add package docs to trace and verifier * docs(sidecarassets): add package doc for embedded JAR loader * fix(chrome): add disable-dev-shm-usage so Chrome starts in CI * test(chrome): gate real-Chrome driver tests behind the browser tag * chore(make): run chrome driver tests in the browser job * fix(web-runtime): guard global error listeners for non-browser hosts The module registered window error/unhandledrejection listeners at top level, which threw under Node (the spec-api test runner) where globalThis.addEventListener is absent. Register only when the API exists; the real browser run is unaffected. * ci(browser): re-enable unprivileged user namespaces for headless Chrome ubuntu-latest moved to 24.04, whose AppArmor restriction on unprivileged user namespaces stops headless Chrome from opening its DevTools socket even with --no-sandbox, surfacing as the driver's 'websocket url timeout'. Relax the sysctl for the job and add a direct launch check so a future breakage shows Chrome's own stderr rather than an opaque driver timeout. * ci(browser): pin stable Chrome for the driver tests setup-chrome's default latest pulled a dev Chromium (150) whose remote debugging socket never came up under chromedp, while plain --dump-dom worked. Pin the stable channel, which the driver is tested against. * feat(defaults): add scroll and rebalance action weights Use relative-integer weights (taps/typing co-primary 100, scrolls 50, swipes 25, doubleTaps 10); the picker normalizes by their total. Adds scrolls to defaultActions as a first-class reveal behavior. * feat(defaults): trim scroll action weight wiring * fix(build): point sidecar jar ignore and embed paths at sidecarassets * test(defaults): drop stale longPresses re-export assertion longPresses is opt-in vocabulary, no longer re-exported from defaults/actions.ts since e0d3b20; its builtin resolution is already covered by api.test.ts. Trim the defaults test to scrolls, which is an actual default export. * fix(chrome): raise DevTools websocket read timeout to 60s Chrome cold-start on a loaded CI runner can exceed chromedp's 20s default for reading the DevTools websocket URL, flaking the browser tests with "websocket url timeout reached". Give launch more headroom.
This commit is contained in:
98 files changed
+6898
-2227
No files matched your search
@@ -83,3 +83,40 @@ jobs:
|
|||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: make test
|
run: make test
|
||||||
|
|
||||||
|
browser:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
# Pin stable: the action's default (latest) pulls a dev Chromium whose
|
||||||
|
# remote-debugging socket is flaky under the driver, even though the
|
||||||
|
# browser otherwise launches headless.
|
||||||
|
- name: Set up Chrome
|
||||||
|
uses: browser-actions/setup-chrome@v1
|
||||||
|
with:
|
||||||
|
chrome-version: stable
|
||||||
|
|
||||||
|
# Ubuntu 24.04 (current ubuntu-latest) restricts unprivileged user
|
||||||
|
# namespaces via AppArmor, which stops headless Chrome from starting even
|
||||||
|
# with --no-sandbox: the process launches but never opens its DevTools
|
||||||
|
# socket. Re-enable them so the driver's Chrome can come up.
|
||||||
|
- name: Allow Chrome under unprivileged user namespaces
|
||||||
|
run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||||
|
|
||||||
|
# Fail here with Chrome's own stderr if the browser can't launch, instead
|
||||||
|
# of letting the driver report an opaque DevTools timeout downstream.
|
||||||
|
- name: Verify headless Chrome starts
|
||||||
|
run: |
|
||||||
|
chrome --version
|
||||||
|
chrome --headless --no-sandbox --disable-gpu --disable-dev-shm-usage \
|
||||||
|
--dump-dom 'data:text/html,<title>ok</title>'
|
||||||
|
|
||||||
|
- name: Drive web fixtures through headless Chrome
|
||||||
|
run: make test-browser
|
||||||
+1
-1
@@ -30,7 +30,7 @@ node_modules/
|
|||||||
|
|
||||||
# Sidecar fat JAR. Build artifact copied in by `make sanderling` before
|
# Sidecar fat JAR. Build artifact copied in by `make sanderling` before
|
||||||
# `go build -tags withsidecar`. Never commit: it's ~130 MB.
|
# `go build -tags withsidecar`. Never commit: it's ~130 MB.
|
||||||
internal/sidecar/assets/sidecar-all.jar
|
internal/sidecarassets/assets/sidecar-all.jar
|
||||||
|
|
||||||
# spec-api compiled output
|
# spec-api compiled output
|
||||||
pkg/spec/dist/
|
pkg/spec/dist/
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ BUF := buf
|
|||||||
|
|
||||||
GO_PACKAGES := ./...
|
GO_PACKAGES := ./...
|
||||||
SIDECAR_JAR := sidecar/build/libs/sidecar-all.jar
|
SIDECAR_JAR := sidecar/build/libs/sidecar-all.jar
|
||||||
SIDECAR_EMBED := internal/sidecar/assets/sidecar-all.jar
|
SIDECAR_EMBED := internal/sidecarassets/assets/sidecar-all.jar
|
||||||
SIDECAR_SRC := $(shell find sidecar/src -type f \( -name '*.kt' -o -name '*.kts' \) 2>/dev/null) build.gradle.kts settings.gradle.kts
|
SIDECAR_SRC := $(shell find sidecar/src -type f \( -name '*.kt' -o -name '*.kts' \) 2>/dev/null) build.gradle.kts settings.gradle.kts
|
||||||
SANDERLING_BIN := bin/sanderling
|
SANDERLING_BIN := bin/sanderling
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ DOCS_TEMPLATE := docs/_template/page.html
|
|||||||
INSPECT_DIST := internal/inspect/dist
|
INSPECT_DIST := internal/inspect/dist
|
||||||
WEB_DIST := inspect-ui/dist
|
WEB_DIST := inspect-ui/dist
|
||||||
|
|
||||||
.PHONY: bootstrap proto sidecar sanderling install test test-go test-kotlin test-spec-api web-typecheck web-build web-dev inspect-dev docs clean release-cli release-npm-dry
|
.PHONY: bootstrap proto sidecar sanderling install test test-go test-browser test-kotlin test-spec-api web-typecheck web-build web-dev inspect-dev docs clean release-cli release-npm-dry
|
||||||
|
|
||||||
bootstrap:
|
bootstrap:
|
||||||
$(GO) mod download
|
$(GO) mod download
|
||||||
@@ -74,6 +74,11 @@ test: test-go test-spec-api web-typecheck
|
|||||||
test-go:
|
test-go:
|
||||||
$(GO) test $(GO_PACKAGES)
|
$(GO) test $(GO_PACKAGES)
|
||||||
|
|
||||||
|
# Drives small web fixtures and the chrome driver through real headless Chrome.
|
||||||
|
# Kept out of `test` because it needs a Chrome binary on PATH.
|
||||||
|
test-browser:
|
||||||
|
$(GO) test -tags browser ./test/browser/... ./internal/driver/chrome/...
|
||||||
|
|
||||||
test-kotlin:
|
test-kotlin:
|
||||||
ANDROID_HOME=$(ANDROID_HOME) $(GRADLE) :sidecar:test
|
ANDROID_HOME=$(ANDROID_HOME) $(GRADLE) :sidecar:test
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Command bundle-check is a developer tool that bundles a spec file to confirm it compiles.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Command hier-check is a developer tool that parses a hierarchy dump and reports selector matches.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
|
|
||||||
"github.com/chromedp/chromedp"
|
"github.com/chromedp/chromedp"
|
||||||
|
|
||||||
"github.com/priyanshujain/sanderling/internal/sidecar"
|
"github.com/priyanshujain/sanderling/internal/sidecarassets"
|
||||||
)
|
)
|
||||||
|
|
||||||
type doctorCheck struct {
|
type doctorCheck struct {
|
||||||
@@ -99,10 +99,10 @@ func checkChromiumLaunch(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func checkSidecarJAR(_ context.Context) error {
|
func checkSidecarJAR(_ context.Context) error {
|
||||||
if sidecar.IsPlaceholder() {
|
if sidecarassets.IsPlaceholder() {
|
||||||
return fmt.Errorf("placeholder JAR embedded; run `make sidecar && make sanderling` to embed the real fat JAR")
|
return fmt.Errorf("placeholder JAR embedded; run `make sidecar && make sanderling` to embed the real fat JAR")
|
||||||
}
|
}
|
||||||
if sidecar.EmbeddedSize() == 0 {
|
if sidecarassets.EmbeddedSize() == 0 {
|
||||||
return fmt.Errorf("embedded JAR is empty")
|
return fmt.Errorf("embedded JAR is empty")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Command sanderling is the CLI entry point for the property-based UI fuzzer.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Action Space
|
# Action Space
|
||||||
|
|
||||||
Inventory of user interaction primitives Sanderling currently supports, compared against the full set available through the Maestro driver layer.
|
Inventory of user interaction primitives Sanderling currently supports, compared against the full set available through the native driver layer.
|
||||||
|
|
||||||
## Current Actions
|
## Current Actions
|
||||||
|
|
||||||
@@ -15,9 +15,9 @@ Inventory of user interaction primitives Sanderling currently supports, compared
|
|||||||
| `PressKey` | `key` | back, home, enter, tab, up, down, left, right |
|
| `PressKey` | `key` | back, home, enter, tab, up, down, left, right |
|
||||||
| `Wait` | `durationMillis` | Sleep for N ms |
|
| `Wait` | `durationMillis` | Sleep for N ms |
|
||||||
|
|
||||||
## Gaps vs Maestro Driver
|
## Gaps vs Native Driver
|
||||||
|
|
||||||
The Maestro driver layer (both `AndroidDriver` and `IOSDriver`) already implements these actions. They are not yet surfaced in Sanderling's action model.
|
The native driver layer (both `AndroidDriver` and `IOSDriver`) already implements these actions. They are not yet surfaced in Sanderling's action model.
|
||||||
|
|
||||||
### User Interaction Gaps
|
### User Interaction Gaps
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ These are less about user gesture modeling and more about test environment setup
|
|||||||
|
|
||||||
### Out of Scope
|
### Out of Scope
|
||||||
|
|
||||||
These Maestro commands don't fit Sanderling's interaction model:
|
These native driver commands don't fit Sanderling's interaction model:
|
||||||
|
|
||||||
- `LaunchApp`, `StopApp`, `KillApp`, `ClearState` - app lifecycle, handled outside the action stream
|
- `LaunchApp`, `StopApp`, `KillApp`, `ClearState` - app lifecycle, handled outside the action stream
|
||||||
- `AssertVisible`, `AssertWithAI` - Sanderling has its own property and assertion model
|
- `AssertVisible`, `AssertWithAI` - Sanderling has its own property and assertion model
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ The `driver.go` file defines the `DeviceDriver` interface. Concrete implementati
|
|||||||
|
|
||||||
`bindings.go` currently holds shared types (`Action`, `ActionKind`, `LogEntry`, `Exception`) alongside JavaScript runtime wiring. The types half moves to `types.go` so the two concerns are separately navigable.
|
`bindings.go` currently holds shared types (`Action`, `ActionKind`, `LogEntry`, `Exception`) alongside JavaScript runtime wiring. The types half moves to `types.go` so the two concerns are separately navigable.
|
||||||
|
|
||||||
### `internal/permissions/` stays as-is
|
### `internal/permissions/` is deleted
|
||||||
|
|
||||||
Android-only package but there is no iOS equivalent yet. Revisit if iOS gets similar permission setup.
|
Dead code with zero importers. Removed. Reintroduce a permission helper only when a platform actually needs one.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ On web, `"back"` maps to Backspace and `"home"` is not supported. All other keys
|
|||||||
| `taps` | Random tap on a clickable element |
|
| `taps` | Random tap on a clickable element |
|
||||||
| `swipes` | Random swipe gesture |
|
| `swipes` | Random swipe gesture |
|
||||||
| `waitOnce` | Idles one step |
|
| `waitOnce` | Idles one step |
|
||||||
| `pressKey` | Presses a random supported key |
|
| `pressKeys` | Presses a random supported key |
|
||||||
|
|
||||||
### `actions(generator)`
|
### `actions(generator)`
|
||||||
|
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ Action generators return a list of actions to perform. The runner samples one fr
|
|||||||
- `taps` - autonomous random taps on clickable elements.
|
- `taps` - autonomous random taps on clickable elements.
|
||||||
- `swipes` - autonomous random swipe gestures.
|
- `swipes` - autonomous random swipe gestures.
|
||||||
- `waitOnce` - idles one step.
|
- `waitOnce` - idles one step.
|
||||||
- `pressKey` - presses a random supported key.
|
- `pressKeys` - presses a random supported key.
|
||||||
|
|
||||||
**Action constructors:**
|
**Action constructors:**
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import {
|
|||||||
Tap,
|
Tap,
|
||||||
actions,
|
actions,
|
||||||
always,
|
always,
|
||||||
|
edgeCaseText,
|
||||||
eventually,
|
eventually,
|
||||||
extract,
|
extract,
|
||||||
from,
|
from,
|
||||||
|
integers,
|
||||||
next,
|
next,
|
||||||
now,
|
now,
|
||||||
taps,
|
taps,
|
||||||
@@ -15,11 +17,11 @@ import {
|
|||||||
import { noUncaughtExceptions } from "@sanderling/spec/defaults/properties";
|
import { noUncaughtExceptions } from "@sanderling/spec/defaults/properties";
|
||||||
|
|
||||||
// Page-presence checks via stable element ids.
|
// Page-presence checks via stable element ids.
|
||||||
const onLoginPage = extract((s) => !!s.ax.find({ id: "email" }));
|
const onLoginPage = extract((s) => !!s.ax.find({ id: "email" })).named("onLoginPage");
|
||||||
const onHomePage = extract((s) => !!s.ax.find({ id: "add-account" }));
|
const onHomePage = extract((s) => !!s.ax.find({ id: "add-account" })).named("onHomePage");
|
||||||
const onAddAccountPage = extract((s) => !!s.ax.find({ id: "account-name" }));
|
const onAddAccountPage = extract((s) => !!s.ax.find({ id: "account-name" })).named("onAddAccountPage");
|
||||||
const onLedgerPage = extract((s) => !!s.ax.find({ id: "ledger" }));
|
const onLedgerPage = extract((s) => !!s.ax.find({ id: "ledger" })).named("onLedgerPage");
|
||||||
const onAddTxnPage = extract((s) => !!s.ax.find({ id: "txn-amount" }));
|
const onAddTxnPage = extract((s) => !!s.ax.find({ id: "txn-amount" })).named("onAddTxnPage");
|
||||||
|
|
||||||
// Auth state: true on any authenticated page, false only on login page.
|
// Auth state: true on any authenticated page, false only on login page.
|
||||||
const loggedIn = extract((s) => {
|
const loggedIn = extract((s) => {
|
||||||
@@ -32,7 +34,7 @@ const loggedIn = extract((s) => {
|
|||||||
s.ax.find({ id: "txn-amount" }) ||
|
s.ax.find({ id: "txn-amount" }) ||
|
||||||
s.ax.find({ id: "add-txn" })
|
s.ax.find({ id: "add-txn" })
|
||||||
);
|
);
|
||||||
});
|
}).named("loggedIn");
|
||||||
|
|
||||||
// Read raw cents off explicit data-cents attributes; no aria-label parsing.
|
// Read raw cents off explicit data-cents attributes; no aria-label parsing.
|
||||||
function readCents(value: string | undefined): number {
|
function readCents(value: string | undefined): number {
|
||||||
@@ -44,7 +46,7 @@ function readCents(value: string | undefined): number {
|
|||||||
const totalBalance = extract((s) => {
|
const totalBalance = extract((s) => {
|
||||||
const el = s.ax.find({ id: "total-balance" });
|
const el = s.ax.find({ id: "total-balance" });
|
||||||
return readCents(el?.attrs?.["data-cents"]);
|
return readCents(el?.attrs?.["data-cents"]);
|
||||||
});
|
}).named("totalBalance");
|
||||||
|
|
||||||
// Account cards expose `data-account-id` + `data-balance` so the spec reads
|
// Account cards expose `data-account-id` + `data-balance` so the spec reads
|
||||||
// structured data without parsing aria-label.
|
// structured data without parsing aria-label.
|
||||||
@@ -54,33 +56,33 @@ const accountCards = extract((s) => {
|
|||||||
id: el.attrs?.["data-account-id"] ?? "",
|
id: el.attrs?.["data-account-id"] ?? "",
|
||||||
balance: readCents(el.attrs?.["data-balance"]),
|
balance: readCents(el.attrs?.["data-balance"]),
|
||||||
}));
|
}));
|
||||||
});
|
}).named("accountCards");
|
||||||
|
|
||||||
const ledgerTxnCount = extract((s) => {
|
const ledgerTxnCount = extract((s) => {
|
||||||
const el = s.ax.find({ id: "ledger" });
|
const el = s.ax.find({ id: "ledger" });
|
||||||
return readCents(el?.attrs?.["data-txn-count"]);
|
return readCents(el?.attrs?.["data-txn-count"]);
|
||||||
});
|
}).named("ledgerTxnCount");
|
||||||
|
|
||||||
const ledgerBalance = extract((s) => {
|
const ledgerBalance = extract((s) => {
|
||||||
const el = s.ax.find({ id: "ledger-balance" });
|
const el = s.ax.find({ id: "ledger-balance" });
|
||||||
return readCents(el?.attrs?.["data-cents"]);
|
return readCents(el?.attrs?.["data-cents"]);
|
||||||
});
|
}).named("ledgerBalance");
|
||||||
|
|
||||||
// UI element handles.
|
// UI element handles.
|
||||||
const emailField = extract((s) => s.ax.find({ id: "email" }));
|
const emailField = extract((s) => s.ax.find({ id: "email" })).named("emailField");
|
||||||
const passwordField = extract((s) => s.ax.find({ id: "password" }));
|
const passwordField = extract((s) => s.ax.find({ id: "password" })).named("passwordField");
|
||||||
const loginSubmit = extract((s) => s.ax.find({ id: "login-submit" }));
|
const loginSubmit = extract((s) => s.ax.find({ id: "login-submit" })).named("loginSubmit");
|
||||||
const logoutButton = extract((s) => s.ax.find({ id: "logout" }));
|
const logoutButton = extract((s) => s.ax.find({ id: "logout" })).named("logoutButton");
|
||||||
const addAccountButton = extract((s) => s.ax.find({ id: "add-account" }));
|
const addAccountButton = extract((s) => s.ax.find({ id: "add-account" })).named("addAccountButton");
|
||||||
const accountNameField = extract((s) => s.ax.find({ id: "account-name" }));
|
const accountNameField = extract((s) => s.ax.find({ id: "account-name" })).named("accountNameField");
|
||||||
const addAccountSubmit = extract((s) => s.ax.find({ id: "add-account-submit" }));
|
const addAccountSubmit = extract((s) => s.ax.find({ id: "add-account-submit" })).named("addAccountSubmit");
|
||||||
const addTxnButton = extract((s) => s.ax.find({ id: "add-txn" }));
|
const addTxnButton = extract((s) => s.ax.find({ id: "add-txn" })).named("addTxnButton");
|
||||||
const txnAmountField = extract((s) => s.ax.find({ id: "txn-amount" }));
|
const txnAmountField = extract((s) => s.ax.find({ id: "txn-amount" })).named("txnAmountField");
|
||||||
const txnNoteField = extract((s) => s.ax.find({ id: "txn-note" }));
|
const txnNoteField = extract((s) => s.ax.find({ id: "txn-note" })).named("txnNoteField");
|
||||||
const txnCreditButton = extract((s) => s.ax.find({ id: "txn-credit" }));
|
const txnCreditButton = extract((s) => s.ax.find({ id: "txn-credit" })).named("txnCreditButton");
|
||||||
const txnDebitButton = extract((s) => s.ax.find({ id: "txn-debit" }));
|
const txnDebitButton = extract((s) => s.ax.find({ id: "txn-debit" })).named("txnDebitButton");
|
||||||
const txnSubmit = extract((s) => s.ax.find({ id: "txn-submit" }));
|
const txnSubmit = extract((s) => s.ax.find({ id: "txn-submit" })).named("txnSubmit");
|
||||||
const backButton = extract((s) => s.ax.find({ id: "back" }));
|
const backButton = extract((s) => s.ax.find({ id: "back" })).named("backButton");
|
||||||
|
|
||||||
// -- Properties --
|
// -- Properties --
|
||||||
|
|
||||||
@@ -175,7 +177,9 @@ const openAddAccount = actions(() => {
|
|||||||
return btn ? [Tap({ on: btn })] : [];
|
return btn ? [Tap({ on: btn })] : [];
|
||||||
});
|
});
|
||||||
|
|
||||||
const accountNameSampler = from([
|
// Readable enumeration keeps the demo legible; repeats over a run still
|
||||||
|
// exercise duplicate-name handling.
|
||||||
|
const accountNames = from([
|
||||||
"Checking",
|
"Checking",
|
||||||
"Savings",
|
"Savings",
|
||||||
"Travel",
|
"Travel",
|
||||||
@@ -183,17 +187,19 @@ const accountNameSampler = from([
|
|||||||
"Emergency Fund",
|
"Emergency Fund",
|
||||||
"Investments",
|
"Investments",
|
||||||
"Groceries",
|
"Groceries",
|
||||||
" ",
|
|
||||||
"Checking",
|
|
||||||
"A".repeat(41),
|
|
||||||
"Petty Cash",
|
"Petty Cash",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const typeAccountName = actions(() => {
|
function typeAccountNameWith(sampler: { generate(): string }) {
|
||||||
if (!onAddAccountPage.current) return [];
|
return actions(() => {
|
||||||
const field = accountNameField.current;
|
if (!onAddAccountPage.current) return [];
|
||||||
return field ? [InputText({ into: field, text: accountNameSampler.generate() })] : [];
|
const field = accountNameField.current;
|
||||||
});
|
return field ? [InputText({ into: field, text: sampler.generate() })] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeAccountName = typeAccountNameWith(accountNames);
|
||||||
|
const typeAccountNameEdge = typeAccountNameWith(edgeCaseText());
|
||||||
|
|
||||||
const submitAddAccount = actions(() => {
|
const submitAddAccount = actions(() => {
|
||||||
if (!onAddAccountPage.current) return [];
|
if (!onAddAccountPage.current) return [];
|
||||||
@@ -205,8 +211,7 @@ const openAccount = actions(() => {
|
|||||||
if (!onHomePage.current) return [];
|
if (!onHomePage.current) return [];
|
||||||
const cards = accountCards.current;
|
const cards = accountCards.current;
|
||||||
if (cards.length === 0) return [];
|
if (cards.length === 0) return [];
|
||||||
const card = cards[Math.floor(Math.random() * cards.length)];
|
return [Tap({ on: from(cards).generate().element })];
|
||||||
return [Tap({ on: card.element })];
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const openAddTxn = actions(() => {
|
const openAddTxn = actions(() => {
|
||||||
@@ -215,25 +220,20 @@ const openAddTxn = actions(() => {
|
|||||||
return btn ? [Tap({ on: btn })] : [];
|
return btn ? [Tap({ on: btn })] : [];
|
||||||
});
|
});
|
||||||
|
|
||||||
const amountSampler = from([
|
// Valid happy-path amounts keep the balance properties exercised; the edge
|
||||||
"12.34",
|
// branch (weighted in actionsRoot) stresses parsing with the adversarial corpus.
|
||||||
"100",
|
const validAmounts = integers().between(1, 99999);
|
||||||
"0.01",
|
|
||||||
"999.99",
|
|
||||||
"5.5",
|
|
||||||
"42",
|
|
||||||
"0",
|
|
||||||
"",
|
|
||||||
"1e4",
|
|
||||||
"0.001",
|
|
||||||
"-5",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const typeAmount = actions(() => {
|
function typeAmountWith(sampler: { generate(): string }) {
|
||||||
if (!onAddTxnPage.current) return [];
|
return actions(() => {
|
||||||
const field = txnAmountField.current;
|
if (!onAddTxnPage.current) return [];
|
||||||
return field ? [InputText({ into: field, text: amountSampler.generate() })] : [];
|
const field = txnAmountField.current;
|
||||||
});
|
return field ? [InputText({ into: field, text: sampler.generate() })] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeAmount = typeAmountWith({ generate: () => String(validAmounts.generate()) });
|
||||||
|
const typeAmountEdge = typeAmountWith(edgeCaseText());
|
||||||
|
|
||||||
const noteSampler = from([
|
const noteSampler = from([
|
||||||
"Coffee",
|
"Coffee",
|
||||||
@@ -252,10 +252,9 @@ const typeNote = actions(() => {
|
|||||||
|
|
||||||
const toggleTxnType = actions(() => {
|
const toggleTxnType = actions(() => {
|
||||||
if (!onAddTxnPage.current) return [];
|
if (!onAddTxnPage.current) return [];
|
||||||
const credit = txnCreditButton.current;
|
const targets = [txnCreditButton.current, txnDebitButton.current].filter(Boolean);
|
||||||
const debit = txnDebitButton.current;
|
if (targets.length === 0) return [];
|
||||||
const target = Math.random() < 0.5 ? credit : debit;
|
return [Tap({ on: from(targets).generate() })];
|
||||||
return target ? [Tap({ on: target })] : [];
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const submitTxn = actions(() => {
|
const submitTxn = actions(() => {
|
||||||
@@ -279,11 +278,13 @@ export const actionsRoot = weighted(
|
|||||||
[30, loginHelper],
|
[30, loginHelper],
|
||||||
[2, adversarialLogin],
|
[2, adversarialLogin],
|
||||||
[14, openAddAccount],
|
[14, openAddAccount],
|
||||||
[18, typeAccountName],
|
[14, typeAccountName],
|
||||||
|
[4, typeAccountNameEdge],
|
||||||
[14, submitAddAccount],
|
[14, submitAddAccount],
|
||||||
[14, openAccount],
|
[14, openAccount],
|
||||||
[12, openAddTxn],
|
[12, openAddTxn],
|
||||||
[18, typeAmount],
|
[14, typeAmount],
|
||||||
|
[4, typeAmountEdge],
|
||||||
[8, typeNote],
|
[8, typeNote],
|
||||||
[6, toggleTxnType],
|
[6, toggleTxnType],
|
||||||
[16, submitTxn],
|
[16, submitTxn],
|
||||||
@@ -292,6 +293,3 @@ export const actionsRoot = weighted(
|
|||||||
[4, taps],
|
[4, taps],
|
||||||
[2, waitOnce],
|
[2, waitOnce],
|
||||||
);
|
);
|
||||||
|
|
||||||
(globalThis as { actions?: unknown; properties?: unknown }).actions = actionsRoot;
|
|
||||||
(globalThis as { properties?: unknown }).properties = properties;
|
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
always,
|
always,
|
||||||
extract,
|
extract,
|
||||||
from,
|
from,
|
||||||
|
integers,
|
||||||
next,
|
next,
|
||||||
weighted,
|
weighted,
|
||||||
whenRoute,
|
whenRoute,
|
||||||
@@ -146,7 +147,7 @@ const addAccount = whenRoute(route, ["home", "add-account"], () => {
|
|||||||
return opts;
|
return opts;
|
||||||
});
|
});
|
||||||
|
|
||||||
const amounts = from(["10", "50", "25", "100", "5"]);
|
const amounts = integers().between(1, 500);
|
||||||
|
|
||||||
const addTxn = whenRoute(route, ["home", "ledger", "add-transaction"], () => {
|
const addTxn = whenRoute(route, ["home", "ledger", "add-transaction"], () => {
|
||||||
if (route.current === "home") {
|
if (route.current === "home") {
|
||||||
@@ -161,7 +162,7 @@ const addTxn = whenRoute(route, ["home", "ledger", "add-transaction"], () => {
|
|||||||
const field = txnAmountField.current;
|
const field = txnAmountField.current;
|
||||||
const submit = txnSubmit.current;
|
const submit = txnSubmit.current;
|
||||||
const opts = [];
|
const opts = [];
|
||||||
if (field) opts.push(InputText({ into: field, text: amounts.generate() }));
|
if (field) opts.push(InputText({ into: field, text: String(amounts.generate()) }));
|
||||||
if (submit) opts.push(Tap({ on: submit }));
|
if (submit) opts.push(Tap({ on: submit }));
|
||||||
return opts;
|
return opts;
|
||||||
});
|
});
|
||||||
@@ -181,7 +182,3 @@ export const actionsRoot = weighted(
|
|||||||
[30, addTxn],
|
[30, addTxn],
|
||||||
[20, defaultActions],
|
[20, defaultActions],
|
||||||
);
|
);
|
||||||
|
|
||||||
(globalThis as { actions?: unknown; properties?: unknown; setup?: unknown }).actions = actionsRoot;
|
|
||||||
(globalThis as { properties?: unknown }).properties = properties;
|
|
||||||
(globalThis as { setup?: unknown }).setup = setup;
|
|
||||||
@@ -103,7 +103,8 @@ export interface Exception {
|
|||||||
export type ResidualNode =
|
export type ResidualNode =
|
||||||
| { op: "true" }
|
| { op: "true" }
|
||||||
| { op: "false" }
|
| { op: "false" }
|
||||||
| { op: "always" | "now" | "next" | "not"; arg: ResidualNode }
|
| { op: "now" | "next" | "not"; arg: ResidualNode }
|
||||||
|
| { op: "always"; arg: ResidualNode; within?: { amount: number; unit: string } }
|
||||||
| { op: "eventually"; arg: ResidualNode; within?: { amount: number; unit: string } }
|
| { op: "eventually"; arg: ResidualNode; within?: { amount: number; unit: string } }
|
||||||
| { op: "and" | "or" | "implies"; left: ResidualNode; right: ResidualNode }
|
| { op: "and" | "or" | "implies"; left: ResidualNode; right: ResidualNode }
|
||||||
| { op: "predicate"; name?: string }
|
| { op: "predicate"; name?: string }
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
//go:build ignore
|
||||||
|
|
||||||
|
// Oracle generator for the bit-exact PCG port.
|
||||||
|
//
|
||||||
|
// Emits the golden fixture consumed by pkg/spec/test/pcg.test.ts. The fixture
|
||||||
|
// pins the exact draw sequences produced by Go's math/rand/v2 over a PCG source
|
||||||
|
// so the TypeScript port (pkg/spec/src/pcg.ts) can be asserted bit-for-bit.
|
||||||
|
//
|
||||||
|
// Run from the spec package to regenerate:
|
||||||
|
//
|
||||||
|
// go run ./internal/_oracle > pkg/spec/test/fixtures/pcg-golden.json
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"math/rand/v2"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seed carries the PCG seed pair. The values are emitted as decimal strings so
|
||||||
|
// JavaScript parses them into BigInt without the float precision loss it would
|
||||||
|
// suffer on values above 2^53.
|
||||||
|
type seed struct {
|
||||||
|
Hi string `json:"hi"`
|
||||||
|
Lo string `json:"lo"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type caseEntry struct {
|
||||||
|
Seed seed `json:"seed"`
|
||||||
|
Uint64 []string `json:"uint64"`
|
||||||
|
Float64 []float64 `json:"float64"`
|
||||||
|
IntN map[string][]int `json:"intN"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const drawCount = 40
|
||||||
|
|
||||||
|
var seeds = [][2]uint64{
|
||||||
|
{1718000000000000000, 0},
|
||||||
|
{42, 0},
|
||||||
|
{0, 0},
|
||||||
|
{1, 2},
|
||||||
|
{18446744073709551615, 18446744073709551615},
|
||||||
|
}
|
||||||
|
|
||||||
|
var intNValues = []int{2, 3, 7, 15, 1000}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cases := make([]caseEntry, 0, len(seeds))
|
||||||
|
|
||||||
|
for _, pair := range seeds {
|
||||||
|
hi, lo := pair[0], pair[1]
|
||||||
|
entry := caseEntry{
|
||||||
|
Seed: seed{Hi: formatUint64(hi), Lo: formatUint64(lo)},
|
||||||
|
IntN: map[string][]int{},
|
||||||
|
}
|
||||||
|
|
||||||
|
r := rand.New(rand.NewPCG(hi, lo))
|
||||||
|
for i := 0; i < drawCount; i++ {
|
||||||
|
entry.Uint64 = append(entry.Uint64, formatUint64(r.Uint64()))
|
||||||
|
}
|
||||||
|
|
||||||
|
r = rand.New(rand.NewPCG(hi, lo))
|
||||||
|
for i := 0; i < drawCount; i++ {
|
||||||
|
entry.Float64 = append(entry.Float64, r.Float64())
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, n := range intNValues {
|
||||||
|
r = rand.New(rand.NewPCG(hi, lo))
|
||||||
|
draws := make([]int, 0, drawCount)
|
||||||
|
for i := 0; i < drawCount; i++ {
|
||||||
|
draws = append(draws, r.IntN(n))
|
||||||
|
}
|
||||||
|
entry.IntN[itoa(n)] = draws
|
||||||
|
}
|
||||||
|
|
||||||
|
cases = append(cases, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
encoder := json.NewEncoder(os.Stdout)
|
||||||
|
encoder.SetIndent("", " ")
|
||||||
|
if err := encoder.Encode(cases); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatUint64 renders a uint64 as a decimal string so JavaScript can parse it
|
||||||
|
// into a BigInt without precision loss.
|
||||||
|
func formatUint64(v uint64) string {
|
||||||
|
if v == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
var buf [20]byte
|
||||||
|
i := len(buf)
|
||||||
|
for v > 0 {
|
||||||
|
i--
|
||||||
|
buf[i] = byte('0' + v%10)
|
||||||
|
v /= 10
|
||||||
|
}
|
||||||
|
return string(buf[i:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(n int) string {
|
||||||
|
if n == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
neg := n < 0
|
||||||
|
if neg {
|
||||||
|
n = -n
|
||||||
|
}
|
||||||
|
var buf [20]byte
|
||||||
|
i := len(buf)
|
||||||
|
for n > 0 {
|
||||||
|
i--
|
||||||
|
buf[i] = byte('0' + n%10)
|
||||||
|
n /= 10
|
||||||
|
}
|
||||||
|
if neg {
|
||||||
|
i--
|
||||||
|
buf[i] = '-'
|
||||||
|
}
|
||||||
|
return string(buf[i:])
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package android boots and prepares an Android device or emulator for testing via adb.
|
||||||
package android
|
package android
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
+53
-15
@@ -1,3 +1,4 @@
|
|||||||
|
// Package bundler compiles a TypeScript spec into a single JavaScript bundle via esbuild.
|
||||||
package bundler
|
package bundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -6,6 +7,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
esbuild "github.com/evanw/esbuild/pkg/api"
|
esbuild "github.com/evanw/esbuild/pkg/api"
|
||||||
@@ -13,9 +15,14 @@ import (
|
|||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
EntryFile string
|
EntryFile string
|
||||||
Defines map[string]string
|
// RuntimeFile, when set, is imported BEFORE the spec via a stdin entry so
|
||||||
Aliases map[string]string
|
// the bundle installs __sanderlingNextAction__ / __sanderlingExtractors__
|
||||||
Sourcemap bool
|
// (the goja runtime entry wires the shared picker). Empty bundles the spec
|
||||||
|
// alone, as the bundle-check tool and unit fixtures do.
|
||||||
|
RuntimeFile string
|
||||||
|
Defines map[string]string
|
||||||
|
Aliases map[string]string
|
||||||
|
Sourcemap bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Result struct {
|
type Result struct {
|
||||||
@@ -45,18 +52,36 @@ func Bundle(options Options) (Result, error) {
|
|||||||
sourcemap = esbuild.SourceMapInline
|
sourcemap = esbuild.SourceMapInline
|
||||||
}
|
}
|
||||||
|
|
||||||
output := esbuild.Build(esbuild.BuildOptions{
|
buildOptions := esbuild.BuildOptions{
|
||||||
EntryPoints: []string{options.EntryFile},
|
Bundle: true,
|
||||||
Bundle: true,
|
Format: esbuild.FormatIIFE,
|
||||||
Format: esbuild.FormatIIFE,
|
Target: esbuild.ES2020,
|
||||||
Target: esbuild.ES2020,
|
Platform: esbuild.PlatformNeutral,
|
||||||
Platform: esbuild.PlatformNeutral,
|
Define: defines,
|
||||||
Define: defines,
|
Alias: options.Aliases,
|
||||||
Alias: options.Aliases,
|
Sourcemap: sourcemap,
|
||||||
Sourcemap: sourcemap,
|
Write: false,
|
||||||
Write: false,
|
LogLevel: esbuild.LogLevelSilent,
|
||||||
LogLevel: esbuild.LogLevelSilent,
|
}
|
||||||
})
|
if options.RuntimeFile == "" {
|
||||||
|
buildOptions.EntryPoints = []string{options.EntryFile}
|
||||||
|
} else {
|
||||||
|
runtimeAbs, err := filepath.Abs(options.RuntimeFile)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, fmt.Errorf("runtime path: %w", err)
|
||||||
|
}
|
||||||
|
specAbs, err := filepath.Abs(options.EntryFile)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, fmt.Errorf("entry path: %w", err)
|
||||||
|
}
|
||||||
|
buildOptions.Stdin = &esbuild.StdinOptions{
|
||||||
|
Contents: fmt.Sprintf("import %q;\n%s", runtimeAbs, registrationEntry(specAbs)),
|
||||||
|
ResolveDir: filepath.Dir(specAbs),
|
||||||
|
Loader: esbuild.LoaderTS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output := esbuild.Build(buildOptions)
|
||||||
|
|
||||||
if len(output.Errors) > 0 {
|
if len(output.Errors) > 0 {
|
||||||
var messages []string
|
var messages []string
|
||||||
@@ -76,3 +101,16 @@ func Bundle(options Options) (Result, error) {
|
|||||||
SHA256: hex.EncodeToString(sum[:]),
|
SHA256: hex.EncodeToString(sum[:]),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// registrationEntry imports the spec as a namespace and copies its named
|
||||||
|
// exports onto globalThis so authors write plain `export const properties /
|
||||||
|
// actionsRoot / setup` without hand-assigning globalThis. The guards let web
|
||||||
|
// specs omit setup. esbuild keeps the exports because the trailer references
|
||||||
|
// them; output stays an IIFE.
|
||||||
|
func registrationEntry(specAbs string) string {
|
||||||
|
return fmt.Sprintf(`import * as __spec from %q;
|
||||||
|
if (__spec.actionsRoot !== undefined) globalThis.actions = __spec.actionsRoot;
|
||||||
|
if (__spec.properties !== undefined) globalThis.properties = __spec.properties;
|
||||||
|
if (__spec.setup !== undefined) globalThis.setup = __spec.setup;
|
||||||
|
`, specAbs)
|
||||||
|
}
|
||||||
@@ -146,6 +146,59 @@ func TestBundle_ImportResolution(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBundle_RegistersNamedExportsOnGlobalThis(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
runtimePath := filepath.Join(directory, "runtime.ts")
|
||||||
|
specPath := filepath.Join(directory, "spec.ts")
|
||||||
|
if err := os.WriteFile(runtimePath, []byte(`export {};`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
spec := `
|
||||||
|
export const properties = "PROPS_MARKER";
|
||||||
|
export const actionsRoot = "ACTIONS_MARKER";
|
||||||
|
export const setup = "SETUP_MARKER";
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(specPath, []byte(spec), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, err := Bundle(Options{EntryFile: specPath, RuntimeFile: runtimePath})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body := string(result.JavaScript)
|
||||||
|
for _, marker := range []string{"PROPS_MARKER", "ACTIONS_MARKER", "SETUP_MARKER"} {
|
||||||
|
if !strings.Contains(body, marker) {
|
||||||
|
t.Errorf("named export %q not registered in bundle:\n%s", marker, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "globalThis.actions") {
|
||||||
|
t.Errorf("trailer should assign globalThis.actions:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBundle_RegistersWithoutSetupExport(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
runtimePath := filepath.Join(directory, "runtime.ts")
|
||||||
|
specPath := filepath.Join(directory, "spec.ts")
|
||||||
|
if err := os.WriteFile(runtimePath, []byte(`export {};`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
spec := `
|
||||||
|
export const properties = "PROPS_MARKER";
|
||||||
|
export const actionsRoot = "ACTIONS_MARKER";
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(specPath, []byte(spec), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, err := Bundle(Options{EntryFile: specPath, RuntimeFile: runtimePath})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("spec without setup export should still bundle: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(result.JavaScript), "PROPS_MARKER") {
|
||||||
|
t.Errorf("properties export not registered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBundle_ReportsSyntaxErrors(t *testing.T) {
|
func TestBundle_ReportsSyntaxErrors(t *testing.T) {
|
||||||
entry := writeFixture(t, "broken.ts", `const x = ;`)
|
entry := writeFixture(t, "broken.ts", `const x = ;`)
|
||||||
_, err := Bundle(Options{EntryFile: entry})
|
_, err := Bundle(Options{EntryFile: entry})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package bundler
|
|||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -47,12 +48,14 @@ func BundleWeb(options WebOptions) (Result, error) {
|
|||||||
|
|
||||||
defines := map[string]string{}
|
defines := map[string]string{}
|
||||||
for key, value := range options.Defines {
|
for key, value := range options.Defines {
|
||||||
defines["process.env."+key] = quoteJSString(value)
|
encoded, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, fmt.Errorf("encode define %q: %w", key, err)
|
||||||
|
}
|
||||||
|
defines["process.env."+key] = string(encoded)
|
||||||
}
|
}
|
||||||
|
|
||||||
stdinContents := fmt.Sprintf(`import %q;
|
stdinContents := fmt.Sprintf("import %q;\n%s", runtimeAbs, registrationEntry(specAbs))
|
||||||
import %q;
|
|
||||||
`, runtimeAbs, specAbs)
|
|
||||||
|
|
||||||
output := esbuild.Build(esbuild.BuildOptions{
|
output := esbuild.Build(esbuild.BuildOptions{
|
||||||
Stdin: &esbuild.StdinOptions{
|
Stdin: &esbuild.StdinOptions{
|
||||||
@@ -88,26 +91,3 @@ import %q;
|
|||||||
SHA256: hex.EncodeToString(sum[:]),
|
SHA256: hex.EncodeToString(sum[:]),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func quoteJSString(value string) string {
|
|
||||||
var builder strings.Builder
|
|
||||||
builder.WriteByte('"')
|
|
||||||
for index := 0; index < len(value); index++ {
|
|
||||||
c := value[index]
|
|
||||||
switch c {
|
|
||||||
case '"', '\\':
|
|
||||||
builder.WriteByte('\\')
|
|
||||||
builder.WriteByte(c)
|
|
||||||
case '\n':
|
|
||||||
builder.WriteString("\\n")
|
|
||||||
case '\r':
|
|
||||||
builder.WriteString("\\r")
|
|
||||||
case '\t':
|
|
||||||
builder.WriteString("\\t")
|
|
||||||
default:
|
|
||||||
builder.WriteByte(c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
builder.WriteByte('"')
|
|
||||||
return builder.String()
|
|
||||||
}
|
|
||||||
@@ -34,8 +34,8 @@ export {};
|
|||||||
|
|
||||||
const fakeSpec = `
|
const fakeSpec = `
|
||||||
const handle = (globalThis as { __sanderling__: { extract: (g: () => unknown) => unknown } }).__sanderling__.extract(() => 42);
|
const handle = (globalThis as { __sanderling__: { extract: (g: () => unknown) => unknown } }).__sanderling__.extract(() => 42);
|
||||||
(globalThis as { actions?: unknown }).actions = handle;
|
export const actionsRoot = handle;
|
||||||
export {};
|
export const properties = "WEB_PROPS_MARKER";
|
||||||
`
|
`
|
||||||
|
|
||||||
func TestBundleWeb_RegistersExpectedGlobals(t *testing.T) {
|
func TestBundleWeb_RegistersExpectedGlobals(t *testing.T) {
|
||||||
@@ -61,6 +61,8 @@ func TestBundleWeb_RegistersExpectedGlobals(t *testing.T) {
|
|||||||
"__sanderlingExtractors__",
|
"__sanderlingExtractors__",
|
||||||
"__sanderlingNextAction__",
|
"__sanderlingNextAction__",
|
||||||
"__sanderling__",
|
"__sanderling__",
|
||||||
|
"WEB_PROPS_MARKER",
|
||||||
|
"globalThis.actions",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(source, expected) {
|
if !strings.Contains(source, expected) {
|
||||||
t.Errorf("bundle missing %q\nsource head:\n%s", expected, head(source, 500))
|
t.Errorf("bundle missing %q\nsource head:\n%s", expected, head(source, 500))
|
||||||
@@ -122,6 +124,38 @@ func TestBundleWeb_IsDeterministic(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestBundleWeb_InjectsSeedDefine asserts that a SANDERLING_SEED define is
|
||||||
|
// inlined into the bundle so --seed reaches the web runtime PRNG. esbuild
|
||||||
|
// replaces process.env.SANDERLING_SEED with the quoted literal at build time.
|
||||||
|
func TestBundleWeb_InjectsSeedDefine(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
runtimePath := filepath.Join(directory, "web-runtime.ts")
|
||||||
|
specPath := filepath.Join(directory, "spec.ts")
|
||||||
|
runtime := fakeRuntime + "\n(globalThis as Record<string, unknown>).__seed = process.env.SANDERLING_SEED;\n"
|
||||||
|
if err := os.WriteFile(runtimePath, []byte(runtime), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(specPath, []byte(fakeSpec), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := BundleWeb(WebOptions{
|
||||||
|
EntryFile: specPath,
|
||||||
|
WebRuntimeFile: runtimePath,
|
||||||
|
Defines: map[string]string{"SANDERLING_SEED": "8675309"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BundleWeb: %v", err)
|
||||||
|
}
|
||||||
|
source := string(result.JavaScript)
|
||||||
|
if !strings.Contains(source, "8675309") {
|
||||||
|
t.Errorf("seed literal not inlined into bundle\nsource head:\n%s", head(source, 500))
|
||||||
|
}
|
||||||
|
if strings.Contains(source, "process.env.SANDERLING_SEED") {
|
||||||
|
t.Errorf("define left unsubstituted in bundle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func head(text string, max int) string {
|
func head(text string, max int) string {
|
||||||
if len(text) <= max {
|
if len(text) <= max {
|
||||||
return text
|
return text
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package chrome implements the device driver for web targets by driving Chrome over the DevTools protocol.
|
||||||
package chrome
|
package chrome
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -37,6 +38,13 @@ func New() *Driver {
|
|||||||
chromedp.Flag("headless", true),
|
chromedp.Flag("headless", true),
|
||||||
chromedp.Flag("disable-gpu", true),
|
chromedp.Flag("disable-gpu", true),
|
||||||
chromedp.NoSandbox,
|
chromedp.NoSandbox,
|
||||||
|
// CI runners give Chrome a tiny /dev/shm; without this the browser
|
||||||
|
// process hangs on startup and never reports its DevTools socket.
|
||||||
|
chromedp.Flag("disable-dev-shm-usage", true),
|
||||||
|
// Cold-starting Chrome on a loaded CI runner can take longer than the
|
||||||
|
// 20s default to print its DevTools websocket URL; give it more room
|
||||||
|
// so launch does not flake with "websocket url timeout reached".
|
||||||
|
chromedp.WSURLReadTimeout(60*time.Second),
|
||||||
)...,
|
)...,
|
||||||
)
|
)
|
||||||
tabCtx, tabCancel := chromedp.NewContext(allocCtx)
|
tabCtx, tabCancel := chromedp.NewContext(allocCtx)
|
||||||
@@ -142,6 +150,33 @@ func (d *Driver) TapSelector(_ context.Context, selector string) error {
|
|||||||
return chromedp.Run(d.tabCtx, chromedp.Click(target, chromedp.NodeVisible))
|
return chromedp.Run(d.tabCtx, chromedp.Click(target, chromedp.NodeVisible))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// doubleTapGap is the inter-tap delay for DoubleTap: short enough to land both
|
||||||
|
// events inside a sub-100 ms race window. The browser has no single double-tap
|
||||||
|
// primitive, so the gesture is two taps with this gap.
|
||||||
|
const doubleTapGap = 50 * time.Millisecond
|
||||||
|
|
||||||
|
func (d *Driver) DoubleTap(ctx context.Context, x, y int) error {
|
||||||
|
return webDoubleTap(ctx, func() error { return d.Tap(ctx, x, y) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) DoubleTapSelector(ctx context.Context, selector string) error {
|
||||||
|
return webDoubleTap(ctx, func() error { return d.TapSelector(ctx, selector) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func webDoubleTap(ctx context.Context, tap func() error) error {
|
||||||
|
if err := tap(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(doubleTapGap)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
return tap()
|
||||||
|
}
|
||||||
|
|
||||||
func (d *Driver) InputText(_ context.Context, text string) error {
|
func (d *Driver) InputText(_ context.Context, text string) error {
|
||||||
return chromedp.Run(d.tabCtx,
|
return chromedp.Run(d.tabCtx,
|
||||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//go:build browser
|
||||||
|
|
||||||
package chrome
|
package chrome
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ var attrNamePattern = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]*$`)
|
|||||||
// "descPrefix:bar") into a CSS selector or XPath expression usable from the
|
// "descPrefix:bar") into a CSS selector or XPath expression usable from the
|
||||||
// chrome driver's TapSelector fallback path. The boolean return is true when
|
// chrome driver's TapSelector fallback path. The boolean return is true when
|
||||||
// the result is XPath rather than CSS. Unknown prefixes pass through to a CSS
|
// the result is XPath rather than CSS. Unknown prefixes pass through to a CSS
|
||||||
// attribute match by the same name so a Maestro-side addition (e.g. a future
|
// attribute match by the same name so a sidecar-side addition (e.g. a future
|
||||||
// "role:") works without a Sanderling release.
|
// "role:") works without a Sanderling release.
|
||||||
func TranslateStringSelector(selector string) (string, bool, error) {
|
func TranslateStringSelector(selector string) (string, bool, error) {
|
||||||
if selector == "" {
|
if selector == "" {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package driver defines the platform-agnostic device automation interface and shared types.
|
||||||
package driver
|
package driver
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -16,6 +17,8 @@ type DeviceDriver interface {
|
|||||||
|
|
||||||
Tap(ctx context.Context, x, y int) error
|
Tap(ctx context.Context, x, y int) error
|
||||||
TapSelector(ctx context.Context, selector string) error
|
TapSelector(ctx context.Context, selector string) error
|
||||||
|
DoubleTap(ctx context.Context, x, y int) error
|
||||||
|
DoubleTapSelector(ctx context.Context, selector string) error
|
||||||
InputText(ctx context.Context, text string) error
|
InputText(ctx context.Context, text string) error
|
||||||
Swipe(ctx context.Context, fromX, fromY, toX, toY int, duration time.Duration) error
|
Swipe(ctx context.Context, fromX, fromY, toX, toY int, duration time.Duration) error
|
||||||
PressKey(ctx context.Context, key string) error
|
PressKey(ctx context.Context, key string) error
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package mock provides an in-memory device driver that records actions for tests.
|
||||||
package mock
|
package mock
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -11,37 +12,39 @@ import (
|
|||||||
type ActionKind string
|
type ActionKind string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ActionLaunch ActionKind = "launch"
|
ActionLaunch ActionKind = "launch"
|
||||||
ActionTerminate ActionKind = "terminate"
|
ActionTerminate ActionKind = "terminate"
|
||||||
ActionTap ActionKind = "tap"
|
ActionTap ActionKind = "tap"
|
||||||
ActionTapSelector ActionKind = "tap_selector"
|
ActionTapSelector ActionKind = "tap_selector"
|
||||||
ActionInputText ActionKind = "input_text"
|
ActionDoubleTap ActionKind = "double_tap"
|
||||||
ActionSwipe ActionKind = "swipe"
|
ActionDoubleTapSelector ActionKind = "double_tap_selector"
|
||||||
ActionPressKey ActionKind = "press_key"
|
ActionInputText ActionKind = "input_text"
|
||||||
ActionLongPress ActionKind = "long_press"
|
ActionSwipe ActionKind = "swipe"
|
||||||
ActionHierarchy ActionKind = "hierarchy"
|
ActionPressKey ActionKind = "press_key"
|
||||||
ActionScreenshot ActionKind = "screenshot"
|
ActionLongPress ActionKind = "long_press"
|
||||||
ActionSnapshot ActionKind = "snapshot"
|
ActionHierarchy ActionKind = "hierarchy"
|
||||||
ActionRecentLogs ActionKind = "recent_logs"
|
ActionScreenshot ActionKind = "screenshot"
|
||||||
ActionWaitForIdle ActionKind = "wait_for_idle"
|
ActionSnapshot ActionKind = "snapshot"
|
||||||
ActionHealth ActionKind = "health"
|
ActionRecentLogs ActionKind = "recent_logs"
|
||||||
ActionMetrics ActionKind = "metrics"
|
ActionWaitForIdle ActionKind = "wait_for_idle"
|
||||||
|
ActionHealth ActionKind = "health"
|
||||||
|
ActionMetrics ActionKind = "metrics"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Action struct {
|
type Action struct {
|
||||||
Kind ActionKind
|
Kind ActionKind
|
||||||
BundleID string
|
BundleID string
|
||||||
ClearState bool
|
ClearState bool
|
||||||
X, Y int
|
X, Y int
|
||||||
FromX, FromY int
|
FromX, FromY int
|
||||||
ToX, ToY int
|
ToX, ToY int
|
||||||
Duration time.Duration
|
Duration time.Duration
|
||||||
Selector string
|
Selector string
|
||||||
Text string
|
Text string
|
||||||
Key string
|
Key string
|
||||||
LogLevel string
|
LogLevel string
|
||||||
LogSince time.Time
|
LogSince time.Time
|
||||||
Idle time.Duration
|
Idle time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// Driver is an in-memory Driver implementation for unit tests.
|
// Driver is an in-memory Driver implementation for unit tests.
|
||||||
@@ -183,6 +186,22 @@ func (d *Driver) TapSelector(ctx context.Context, selector string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d *Driver) DoubleTap(ctx context.Context, x, y int) error {
|
||||||
|
if err := d.failure(ActionDoubleTap); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
d.record(Action{Kind: ActionDoubleTap, X: x, Y: y})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) DoubleTapSelector(ctx context.Context, selector string) error {
|
||||||
|
if err := d.failure(ActionDoubleTapSelector); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
d.record(Action{Kind: ActionDoubleTapSelector, Selector: selector})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (d *Driver) InputText(ctx context.Context, text string) error {
|
func (d *Driver) InputText(ctx context.Context, text string) error {
|
||||||
if err := d.failure(ActionInputText); err != nil {
|
if err := d.failure(ActionInputText); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -9,22 +9,6 @@ import (
|
|||||||
"github.com/priyanshujain/sanderling/internal/driver"
|
"github.com/priyanshujain/sanderling/internal/driver"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNew_Defaults(t *testing.T) {
|
|
||||||
mock := New()
|
|
||||||
if !mock.HealthInfo.Ready {
|
|
||||||
t.Errorf("default HealthInfo should be ready")
|
|
||||||
}
|
|
||||||
if mock.HealthInfo.Platform != "android" {
|
|
||||||
t.Errorf("default platform: got %q", mock.HealthInfo.Platform)
|
|
||||||
}
|
|
||||||
if mock.HierarchyJSON == "" {
|
|
||||||
t.Errorf("default hierarchy should be a non-empty JSON")
|
|
||||||
}
|
|
||||||
if len(mock.Actions()) != 0 {
|
|
||||||
t.Errorf("fresh mock should have zero recorded actions")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRecordsAllActionsInOrder(t *testing.T) {
|
func TestRecordsAllActionsInOrder(t *testing.T) {
|
||||||
mock := New()
|
mock := New()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package sidecar implements the device driver by talking to the native sidecar over gRPC.
|
||||||
package sidecar
|
package sidecar
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -103,6 +104,34 @@ func (c *Client) TapSelector(ctx context.Context, selector string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// doubleTapGap is the inter-tap delay for DoubleTap: short enough to land both
|
||||||
|
// events inside a sub-100 ms race window, long enough for the sidecar to
|
||||||
|
// serialize two MotionEvent streams. The sidecar exposes no native double-tap
|
||||||
|
// RPC, so the gesture is two Taps with this gap.
|
||||||
|
const doubleTapGap = 50 * time.Millisecond
|
||||||
|
|
||||||
|
func (c *Client) DoubleTap(ctx context.Context, x, y int) error {
|
||||||
|
return doubleTap(ctx, func() error { return c.Tap(ctx, x, y) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) DoubleTapSelector(ctx context.Context, selector string) error {
|
||||||
|
return doubleTap(ctx, func() error { return c.TapSelector(ctx, selector) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func doubleTap(ctx context.Context, tap func() error) error {
|
||||||
|
if err := tap(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(doubleTapGap)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
return tap()
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) InputText(ctx context.Context, text string) error {
|
func (c *Client) InputText(ctx context.Context, text string) error {
|
||||||
_, err := c.stub.InputText(ctx, &driverpb.Text{Value: text})
|
_, err := c.stub.InputText(ctx, &driverpb.Text{Value: text})
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Package hierarchy parses the TreeNode JSON produced by the Maestro sidecar
|
// Package hierarchy parses the TreeNode JSON produced by the native sidecar
|
||||||
// and resolves selectors against it.
|
// and resolves selectors against it.
|
||||||
//
|
//
|
||||||
// Selector grammar (v2.0):
|
// Selector grammar (v2.0):
|
||||||
@@ -83,7 +83,7 @@ type Tree struct {
|
|||||||
Elements []*Element `json:"elements"`
|
Elements []*Element `json:"elements"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// treeNodeJSON mirrors the Maestro TreeNode JSON structure.
|
// treeNodeJSON mirrors the sidecar TreeNode JSON structure.
|
||||||
type treeNodeJSON struct {
|
type treeNodeJSON struct {
|
||||||
Attributes map[string]string `json:"attributes"`
|
Attributes map[string]string `json:"attributes"`
|
||||||
Children []treeNodeJSON `json:"children"`
|
Children []treeNodeJSON `json:"children"`
|
||||||
@@ -110,7 +110,7 @@ type AttrFilter struct {
|
|||||||
// in the TreeNode attributes map. Both directions are listed so cross-platform
|
// in the TreeNode attributes map. Both directions are listed so cross-platform
|
||||||
// matching works regardless of which name the caller uses.
|
// matching works regardless of which name the caller uses.
|
||||||
var attributeAliases = map[string][]string{
|
var attributeAliases = map[string][]string{
|
||||||
// Android XML legacy name; web driver uses content-desc; Maestro normalises to accessibilityText
|
// Android XML legacy name; web driver uses content-desc; the sidecar normalises to accessibilityText
|
||||||
"content-desc": {"accessibilityText"},
|
"content-desc": {"accessibilityText"},
|
||||||
// iOS AXElement / UIKit names
|
// iOS AXElement / UIKit names
|
||||||
"label": {"accessibilityText"},
|
"label": {"accessibilityText"},
|
||||||
@@ -164,7 +164,7 @@ func matchSelector(element *Element, sel Selector) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse parses a Maestro TreeNode JSON hierarchy.
|
// Parse parses a sidecar TreeNode JSON hierarchy.
|
||||||
func Parse(text string) (*Tree, error) {
|
func Parse(text string) (*Tree, error) {
|
||||||
text = strings.TrimSpace(text)
|
text = strings.TrimSpace(text)
|
||||||
if text == "" {
|
if text == "" {
|
||||||
@@ -550,7 +550,7 @@ func match(element *Element, kind, value string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// boundsPattern matches "[l,t,r,b]" (4-value Android/Maestro format).
|
// boundsPattern matches "[l,t,r,b]" (4-value Android/sidecar format).
|
||||||
var boundsPattern = regexp.MustCompile(`^\[(-?\d+),(-?\d+),(-?\d+),(-?\d+)\]$`)
|
var boundsPattern = regexp.MustCompile(`^\[(-?\d+),(-?\d+),(-?\d+),(-?\d+)\]$`)
|
||||||
|
|
||||||
// boundsPatternTwo matches "[x1,y1][x2,y2]" (iOS XCUITest format).
|
// boundsPatternTwo matches "[x1,y1][x2,y2]" (iOS XCUITest format).
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package hierarchy
|
|||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
// sampleDump is a Maestro TreeNode JSON equivalent of the old XML fixture.
|
// sampleDump is a sidecar TreeNode JSON equivalent of the old XML fixture.
|
||||||
const sampleDump = `{
|
const sampleDump = `{
|
||||||
"attributes": {"class": "android.widget.LinearLayout", "package": "app", "bounds": "[0,0,1080,2340]"},
|
"attributes": {"class": "android.widget.LinearLayout", "package": "app", "bounds": "[0,0,1080,2340]"},
|
||||||
"children": [
|
"children": [
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package inspect serves the embedded web UI for browsing recorded runs.
|
||||||
package inspect
|
package inspect
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package ios boots and prepares an iOS simulator for testing via simctl.
|
||||||
package ios
|
package ios
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
+228
-28
@@ -1,3 +1,4 @@
|
|||||||
|
// Package ltl evaluates linear temporal logic formulas incrementally over observed steps.
|
||||||
package ltl
|
package ltl
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -31,13 +32,27 @@ func (v Verdict) String() string {
|
|||||||
// violated) or carries them forward as residuals. Once a single obligation
|
// violated) or carries them forward as residuals. Once a single obligation
|
||||||
// violates, the overall verdict latches to Violated.
|
// violates, the overall verdict latches to Violated.
|
||||||
type Evaluator struct {
|
type Evaluator struct {
|
||||||
root Formula
|
root Formula
|
||||||
pending []Formula
|
pending []Formula
|
||||||
violated bool
|
violated bool
|
||||||
|
steps int
|
||||||
|
violation *Violation
|
||||||
|
}
|
||||||
|
|
||||||
|
// Violation is the witness for a latched verdict: the failing sub-formula, a
|
||||||
|
// human-readable reason, and the observation step it fired at. A thrown
|
||||||
|
// predicate carries the goja error text as its reason and sets IsError; a plain
|
||||||
|
// false carries "predicate false"; Finalize fills it for liveness obligations
|
||||||
|
// that never discharged.
|
||||||
|
type Violation struct {
|
||||||
|
Formula Formula
|
||||||
|
Reason string
|
||||||
|
Step int
|
||||||
|
IsError bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewEvaluator(formula Formula) *Evaluator {
|
func NewEvaluator(formula Formula) *Evaluator {
|
||||||
return &Evaluator{root: formula}
|
return &Evaluator{root: nnf(formula)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Observe evaluates the formula against the current state and returns the
|
// Observe evaluates the formula against the current state and returns the
|
||||||
@@ -52,6 +67,7 @@ func (e *Evaluator) ObserveAt(now time.Time) Verdict {
|
|||||||
if e.violated {
|
if e.violated {
|
||||||
return VerdictViolated
|
return VerdictViolated
|
||||||
}
|
}
|
||||||
|
e.steps++
|
||||||
|
|
||||||
fresh := rootObligation(e.root)
|
fresh := rootObligation(e.root)
|
||||||
obligations := append(e.pending, fresh)
|
obligations := append(e.pending, fresh)
|
||||||
@@ -65,18 +81,136 @@ func (e *Evaluator) ObserveAt(now time.Time) Verdict {
|
|||||||
case statusViolated:
|
case statusViolated:
|
||||||
e.violated = true
|
e.violated = true
|
||||||
e.pending = nil
|
e.pending = nil
|
||||||
|
e.violation = result.witness
|
||||||
|
if e.violation != nil {
|
||||||
|
e.violation.Step = e.steps
|
||||||
|
}
|
||||||
return VerdictViolated
|
return VerdictViolated
|
||||||
case statusPending:
|
case statusPending:
|
||||||
e.pending = append(e.pending, result.formula)
|
e.pending = append(e.pending, result.formula)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
e.pending = collapse(e.pending)
|
||||||
|
|
||||||
if len(e.pending) > 0 {
|
if len(e.pending) > 0 {
|
||||||
return VerdictPending
|
return VerdictPending
|
||||||
}
|
}
|
||||||
return VerdictHolds
|
return VerdictHolds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// collapse removes structurally-identical obligations, keeping the first
|
||||||
|
// occurrence in order. Distinct predicates never merge because ThunkFormula's
|
||||||
|
// name participates in its describe() key, so deduping cannot hide a violation.
|
||||||
|
func collapse(obligations []Formula) []Formula {
|
||||||
|
if len(obligations) < 2 {
|
||||||
|
return obligations
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(obligations))
|
||||||
|
result := obligations[:0]
|
||||||
|
for _, obligation := range obligations {
|
||||||
|
key := obligation.describe()
|
||||||
|
if _, ok := seen[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
result = append(result, obligation)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finalize reports the terminal verdict for the run. Pending obligations that
|
||||||
|
// can never be discharged by a future step (an unbounded eventually that never
|
||||||
|
// fired, a strong next with no successor) resolve to Violated; safety
|
||||||
|
// obligations that were never breached resolve to Holds.
|
||||||
|
func (e *Evaluator) Finalize() Verdict {
|
||||||
|
if e.violated {
|
||||||
|
return VerdictViolated
|
||||||
|
}
|
||||||
|
for _, obligation := range e.pending {
|
||||||
|
if finalize(obligation) == statusViolated {
|
||||||
|
e.violated = true
|
||||||
|
e.pending = nil
|
||||||
|
e.violation = &Violation{
|
||||||
|
Formula: obligation,
|
||||||
|
Reason: finalizeReason(obligation),
|
||||||
|
Step: e.steps,
|
||||||
|
}
|
||||||
|
return VerdictViolated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return VerdictHolds
|
||||||
|
}
|
||||||
|
|
||||||
|
// Violation returns the witness for a latched violation, or nil if the
|
||||||
|
// evaluator has not violated. The witness is set by ObserveAt at the step a
|
||||||
|
// reduction first violated, or by Finalize for a liveness obligation that
|
||||||
|
// never discharged.
|
||||||
|
func (e *Evaluator) Violation() *Violation {
|
||||||
|
return e.violation
|
||||||
|
}
|
||||||
|
|
||||||
|
// finalizeReason describes why an undischarged obligation resolves to violated
|
||||||
|
// at run end.
|
||||||
|
func finalizeReason(formula Formula) string {
|
||||||
|
switch formula.(type) {
|
||||||
|
case EventuallyFormula:
|
||||||
|
return "eventually never satisfied"
|
||||||
|
case NextFormula:
|
||||||
|
return "next obligation unmet at run end"
|
||||||
|
case ThunkFormula:
|
||||||
|
return "obligation unmet at run end"
|
||||||
|
default:
|
||||||
|
return "liveness obligation unmet at run end"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// finalize collapses a pending obligation to its terminal status assuming no
|
||||||
|
// further steps will occur.
|
||||||
|
func finalize(formula Formula) residualStatus {
|
||||||
|
switch concrete := formula.(type) {
|
||||||
|
case PureFormula:
|
||||||
|
if concrete.Value {
|
||||||
|
return statusHolds
|
||||||
|
}
|
||||||
|
return statusViolated
|
||||||
|
case ThunkFormula:
|
||||||
|
return statusViolated
|
||||||
|
case EventuallyFormula:
|
||||||
|
return statusViolated
|
||||||
|
case NextFormula:
|
||||||
|
return statusViolated
|
||||||
|
case AlwaysFormula:
|
||||||
|
return statusHolds
|
||||||
|
case NowFormula:
|
||||||
|
return finalize(concrete.Inner)
|
||||||
|
case NotFormula:
|
||||||
|
switch finalize(concrete.Inner) {
|
||||||
|
case statusViolated:
|
||||||
|
return statusHolds
|
||||||
|
default:
|
||||||
|
return statusViolated
|
||||||
|
}
|
||||||
|
case AndFormula:
|
||||||
|
if finalize(concrete.Left) == statusViolated || finalize(concrete.Right) == statusViolated {
|
||||||
|
return statusViolated
|
||||||
|
}
|
||||||
|
return statusHolds
|
||||||
|
case OrFormula:
|
||||||
|
if finalize(concrete.Left) == statusHolds || finalize(concrete.Right) == statusHolds {
|
||||||
|
return statusHolds
|
||||||
|
}
|
||||||
|
return statusViolated
|
||||||
|
case ImpliesFormula:
|
||||||
|
if finalize(concrete.Antecedent) == statusViolated {
|
||||||
|
return statusHolds
|
||||||
|
}
|
||||||
|
return finalize(concrete.Consequent)
|
||||||
|
default:
|
||||||
|
return statusHolds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Residual returns a single Formula describing what the evaluator still has
|
// Residual returns a single Formula describing what the evaluator still has
|
||||||
// to prove after the most recent ObserveAt. PureFormula{true} means the
|
// to prove after the most recent ObserveAt. PureFormula{true} means the
|
||||||
// property holds for the run so far; PureFormula{false} means it has latched
|
// property holds for the run so far; PureFormula{false} means it has latched
|
||||||
@@ -119,10 +253,46 @@ const (
|
|||||||
type reduceResult struct {
|
type reduceResult struct {
|
||||||
status residualStatus
|
status residualStatus
|
||||||
formula Formula
|
formula Formula
|
||||||
|
witness *Violation
|
||||||
}
|
}
|
||||||
|
|
||||||
func holds() reduceResult { return reduceResult{status: statusHolds} }
|
func holds() reduceResult { return reduceResult{status: statusHolds} }
|
||||||
|
|
||||||
|
// violated reports a violation without an attached witness. Used where the
|
||||||
|
// failing sub-formula is recovered from a child result whose own witness is
|
||||||
|
// carried up by violatedFrom.
|
||||||
func violated() reduceResult { return reduceResult{status: statusViolated} }
|
func violated() reduceResult { return reduceResult{status: statusViolated} }
|
||||||
|
|
||||||
|
// violatedWith reports a violation that originates at the given sub-formula
|
||||||
|
// with the given reason. The reason distinguishes a thrown predicate from a
|
||||||
|
// plain false so callers (and the inspect UI) can render the cause.
|
||||||
|
func violatedWith(formula Formula, reason string) reduceResult {
|
||||||
|
return reduceResult{
|
||||||
|
status: statusViolated,
|
||||||
|
witness: &Violation{Formula: formula, Reason: reason},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// violatedByError reports a violation caused by a predicate that threw. The
|
||||||
|
// witness keeps the error text as its reason and flags IsError so callers can
|
||||||
|
// render it as a thrown-predicate error rather than a plain false.
|
||||||
|
func violatedByError(formula Formula, reason string) reduceResult {
|
||||||
|
return reduceResult{
|
||||||
|
status: statusViolated,
|
||||||
|
witness: &Violation{Formula: formula, Reason: reason, IsError: true},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// violatedFrom propagates a child violation, preferring the child's witness so
|
||||||
|
// the deepest failing leaf survives. When the child carried no witness the
|
||||||
|
// fallback formula and reason describe this level instead.
|
||||||
|
func violatedFrom(child reduceResult, fallback Formula, reason string) reduceResult {
|
||||||
|
if child.witness != nil {
|
||||||
|
return reduceResult{status: statusViolated, witness: child.witness}
|
||||||
|
}
|
||||||
|
return violatedWith(fallback, reason)
|
||||||
|
}
|
||||||
|
|
||||||
func pending(f Formula) reduceResult {
|
func pending(f Formula) reduceResult {
|
||||||
return reduceResult{status: statusPending, formula: f}
|
return reduceResult{status: statusPending, formula: f}
|
||||||
}
|
}
|
||||||
@@ -133,13 +303,17 @@ func reduce(formula Formula, now time.Time) reduceResult {
|
|||||||
if concrete.Value {
|
if concrete.Value {
|
||||||
return holds()
|
return holds()
|
||||||
}
|
}
|
||||||
return violated()
|
return violatedWith(concrete, "pure false")
|
||||||
|
|
||||||
case ThunkFormula:
|
case ThunkFormula:
|
||||||
if concrete.Func() {
|
result, err := concrete.Func()
|
||||||
|
if err != nil {
|
||||||
|
return violatedByError(concrete, err.Error())
|
||||||
|
}
|
||||||
|
if result {
|
||||||
return holds()
|
return holds()
|
||||||
}
|
}
|
||||||
return violated()
|
return violatedWith(concrete, "predicate false")
|
||||||
|
|
||||||
case NowFormula:
|
case NowFormula:
|
||||||
return reduce(concrete.Inner, now)
|
return reduce(concrete.Inner, now)
|
||||||
@@ -162,10 +336,10 @@ func reduce(formula Formula, now time.Time) reduceResult {
|
|||||||
return holds()
|
return holds()
|
||||||
}
|
}
|
||||||
if concrete.HasStepBound && concrete.StepBound <= 1 {
|
if concrete.HasStepBound && concrete.StepBound <= 1 {
|
||||||
return violated()
|
return violatedFrom(innerResult, concrete, "eventually bound exhausted")
|
||||||
}
|
}
|
||||||
if concrete.HasDeadline && !now.Before(concrete.Deadline) {
|
if concrete.HasDeadline && !now.Before(concrete.Deadline) {
|
||||||
return violated()
|
return violatedFrom(innerResult, concrete, "eventually deadline reached")
|
||||||
}
|
}
|
||||||
next := concrete
|
next := concrete
|
||||||
if concrete.HasStepBound {
|
if concrete.HasStepBound {
|
||||||
@@ -174,18 +348,14 @@ func reduce(formula Formula, now time.Time) reduceResult {
|
|||||||
return pending(next)
|
return pending(next)
|
||||||
|
|
||||||
case ImpliesFormula:
|
case ImpliesFormula:
|
||||||
antecedent := reduce(concrete.Antecedent, now)
|
// NewEvaluator runs nnf, which rewrites a -> b to (not a) or b, so this
|
||||||
switch antecedent.status {
|
// case is unreachable from a normal evaluator. A directly-constructed
|
||||||
case statusHolds:
|
// formula reduced here is evaluated through the same equivalence so a
|
||||||
return reduce(concrete.Consequent, now)
|
// pending antecedent cannot drop the consequent.
|
||||||
case statusViolated:
|
return reduce(OrFormula{
|
||||||
return holds()
|
Left: pushNot(concrete.Antecedent),
|
||||||
case statusPending:
|
Right: nnf(concrete.Consequent),
|
||||||
return pending(ImpliesFormula{
|
}, now)
|
||||||
Antecedent: antecedent.formula,
|
|
||||||
Consequent: concrete.Consequent,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
case OrFormula:
|
case OrFormula:
|
||||||
left := reduce(concrete.Left, now)
|
left := reduce(concrete.Left, now)
|
||||||
@@ -194,7 +364,7 @@ func reduce(formula Formula, now time.Time) reduceResult {
|
|||||||
return holds()
|
return holds()
|
||||||
}
|
}
|
||||||
if left.status == statusViolated && right.status == statusViolated {
|
if left.status == statusViolated && right.status == statusViolated {
|
||||||
return violated()
|
return violatedFrom(left, concrete, "both disjuncts violated")
|
||||||
}
|
}
|
||||||
if left.status == statusViolated {
|
if left.status == statusViolated {
|
||||||
return pending(right.formula)
|
return pending(right.formula)
|
||||||
@@ -207,8 +377,11 @@ func reduce(formula Formula, now time.Time) reduceResult {
|
|||||||
case AndFormula:
|
case AndFormula:
|
||||||
left := reduce(concrete.Left, now)
|
left := reduce(concrete.Left, now)
|
||||||
right := reduce(concrete.Right, now)
|
right := reduce(concrete.Right, now)
|
||||||
if left.status == statusViolated || right.status == statusViolated {
|
if left.status == statusViolated {
|
||||||
return violated()
|
return violatedFrom(left, concrete, "conjunct violated")
|
||||||
|
}
|
||||||
|
if right.status == statusViolated {
|
||||||
|
return violatedFrom(right, concrete, "conjunct violated")
|
||||||
}
|
}
|
||||||
if left.status == statusHolds && right.status == statusHolds {
|
if left.status == statusHolds && right.status == statusHolds {
|
||||||
return holds()
|
return holds()
|
||||||
@@ -225,7 +398,7 @@ func reduce(formula Formula, now time.Time) reduceResult {
|
|||||||
inner := reduce(concrete.Inner, now)
|
inner := reduce(concrete.Inner, now)
|
||||||
switch inner.status {
|
switch inner.status {
|
||||||
case statusHolds:
|
case statusHolds:
|
||||||
return violated()
|
return violatedWith(concrete, "negated formula held")
|
||||||
case statusViolated:
|
case statusViolated:
|
||||||
return holds()
|
return holds()
|
||||||
case statusPending:
|
case statusPending:
|
||||||
@@ -233,11 +406,38 @@ func reduce(formula Formula, now time.Time) reduceResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case AlwaysFormula:
|
case AlwaysFormula:
|
||||||
|
// First-reduction deadline resolution mirrors EventuallyFormula so a
|
||||||
|
// relative duration becomes a stable absolute deadline.
|
||||||
|
if !concrete.HasDeadline && concrete.Duration > 0 {
|
||||||
|
concrete.Deadline = now.Add(concrete.Duration)
|
||||||
|
concrete.HasDeadline = true
|
||||||
|
}
|
||||||
innerResult := reduce(concrete.Inner, now)
|
innerResult := reduce(concrete.Inner, now)
|
||||||
if innerResult.status == statusViolated {
|
if innerResult.status == statusViolated {
|
||||||
return violated()
|
return violatedFrom(innerResult, concrete, "always inner violated")
|
||||||
|
}
|
||||||
|
// A bounded Always is the dual of a bounded Eventually: once the window
|
||||||
|
// closes without a breach it is vacuously satisfied. A pending inner at
|
||||||
|
// the closing step is a deferred obligation (a strong next, or an inner
|
||||||
|
// liveness that has not discharged); it must be carried so a later step
|
||||||
|
// or Finalize resolves it, never dropped to holds.
|
||||||
|
if concrete.HasStepBound && concrete.StepBound <= 1 {
|
||||||
|
if innerResult.status == statusHolds {
|
||||||
|
return holds()
|
||||||
|
}
|
||||||
|
return pending(innerResult.formula)
|
||||||
|
}
|
||||||
|
if concrete.HasDeadline && !now.Before(concrete.Deadline) {
|
||||||
|
if innerResult.status == statusHolds {
|
||||||
|
return holds()
|
||||||
|
}
|
||||||
|
return pending(innerResult.formula)
|
||||||
|
}
|
||||||
|
next := concrete
|
||||||
|
next.Inner = concrete.Inner
|
||||||
|
if concrete.HasStepBound {
|
||||||
|
next.StepBound = concrete.StepBound - 1
|
||||||
}
|
}
|
||||||
next := AlwaysFormula{Inner: concrete.Inner}
|
|
||||||
if innerResult.status == statusHolds {
|
if innerResult.status == statusHolds {
|
||||||
return pending(next)
|
return pending(next)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,10 +36,10 @@ func TestPure_FalseImmediatelyViolates(t *testing.T) {
|
|||||||
func TestThunk_TransitionFromHoldToViolate(t *testing.T) {
|
func TestThunk_TransitionFromHoldToViolate(t *testing.T) {
|
||||||
values := []bool{true, true, false, true, true}
|
values := []bool{true, true, false, true, true}
|
||||||
step := 0
|
step := 0
|
||||||
evaluator := NewEvaluator(Always(Thunk(func() bool {
|
evaluator := NewEvaluator(Always(Thunk(func() (bool, error) {
|
||||||
current := values[step]
|
current := values[step]
|
||||||
step++
|
step++
|
||||||
return current
|
return current, nil
|
||||||
})))
|
})))
|
||||||
|
|
||||||
wantSequence := []Verdict{
|
wantSequence := []Verdict{
|
||||||
@@ -59,7 +59,7 @@ func TestThunk_TransitionFromHoldToViolate(t *testing.T) {
|
|||||||
|
|
||||||
func TestEvaluator_StickinessAfterViolation(t *testing.T) {
|
func TestEvaluator_StickinessAfterViolation(t *testing.T) {
|
||||||
state := true
|
state := true
|
||||||
evaluator := NewEvaluator(Always(Thunk(func() bool { return state })))
|
evaluator := NewEvaluator(Always(Thunk(func() (bool, error) { return state, nil })))
|
||||||
|
|
||||||
if got := evaluator.Observe(); got != VerdictHolds {
|
if got := evaluator.Observe(); got != VerdictHolds {
|
||||||
t.Fatalf("step 1: got %v, want holds", got)
|
t.Fatalf("step 1: got %v, want holds", got)
|
||||||
@@ -83,7 +83,7 @@ func TestEvaluator_TopLevelPureCountedAtEachStep(t *testing.T) {
|
|||||||
|
|
||||||
func TestEvaluator_TopLevelThunkRespectsObservation(t *testing.T) {
|
func TestEvaluator_TopLevelThunkRespectsObservation(t *testing.T) {
|
||||||
state := true
|
state := true
|
||||||
evaluator := NewEvaluator(Thunk(func() bool { return state }))
|
evaluator := NewEvaluator(Thunk(func() (bool, error) { return state, nil }))
|
||||||
if got := evaluator.Observe(); got != VerdictHolds {
|
if got := evaluator.Observe(); got != VerdictHolds {
|
||||||
t.Errorf("expected holds, got %v", got)
|
t.Errorf("expected holds, got %v", got)
|
||||||
}
|
}
|
||||||
@@ -93,21 +93,12 @@ func TestEvaluator_TopLevelThunkRespectsObservation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestVerdict_String(t *testing.T) {
|
|
||||||
if VerdictHolds.String() != "holds" {
|
|
||||||
t.Errorf("VerdictHolds.String() = %q", VerdictHolds.String())
|
|
||||||
}
|
|
||||||
if VerdictViolated.String() != "violated" {
|
|
||||||
t.Errorf("VerdictViolated.String() = %q", VerdictViolated.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDescribe(t *testing.T) {
|
func TestDescribe(t *testing.T) {
|
||||||
formula := Always(Pure(true))
|
formula := Always(Pure(true))
|
||||||
if got := Describe(formula); !strings.Contains(got, "Always") || !strings.Contains(got, "Pure(true)") {
|
if got := Describe(formula); !strings.Contains(got, "Always") || !strings.Contains(got, "Pure(true)") {
|
||||||
t.Errorf("Describe wrong: %q", got)
|
t.Errorf("Describe wrong: %q", got)
|
||||||
}
|
}
|
||||||
thunk := Always(Thunk(func() bool { return true }))
|
thunk := Always(Thunk(func() (bool, error) { return true, nil }))
|
||||||
if got := Describe(thunk); !strings.Contains(got, "Thunk") {
|
if got := Describe(thunk); !strings.Contains(got, "Thunk") {
|
||||||
t.Errorf("Describe(thunk) wrong: %q", got)
|
t.Errorf("Describe(thunk) wrong: %q", got)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package ltl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// thunkSeq returns a predicate that yields the given boolean per observation,
|
||||||
|
// repeating the last value once the sequence is exhausted.
|
||||||
|
func thunkSeq(values ...bool) func() (bool, error) {
|
||||||
|
step := 0
|
||||||
|
return func() (bool, error) {
|
||||||
|
value := values[len(values)-1]
|
||||||
|
if step < len(values) {
|
||||||
|
value = values[step]
|
||||||
|
}
|
||||||
|
step++
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAndFinalize(formula Formula, steps int) (Verdict, *Evaluator) {
|
||||||
|
evaluator := NewEvaluator(formula)
|
||||||
|
var last Verdict
|
||||||
|
for index := range steps {
|
||||||
|
last = evaluator.ObserveAt(time.Unix(int64(index), 0))
|
||||||
|
if last == VerdictViolated {
|
||||||
|
return last, evaluator
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return evaluator.Finalize(), evaluator
|
||||||
|
}
|
||||||
|
|
||||||
|
// Implies with a temporal antecedent must still evaluate the consequent at the
|
||||||
|
// current step. Always(p) holds over the observed run, so a false consequent
|
||||||
|
// Not(q) at the last step makes the implication violated. The pre-fix engine
|
||||||
|
// deferred the whole implication and reported Holds.
|
||||||
|
func TestImplies_TemporalAntecedent_ConsequentFalseViolates(t *testing.T) {
|
||||||
|
p := Thunk(thunkSeq(true, true, true))
|
||||||
|
q := Thunk(thunkSeq(false, false, true))
|
||||||
|
formula := Implies(Always(p), Not(q))
|
||||||
|
if verdict, _ := runAndFinalize(formula, 3); verdict != VerdictViolated {
|
||||||
|
t.Fatalf("Implies(Always(p),Not(q)) p=TTT q=FFT: got %v, want violated", verdict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-step witness of the same class: antecedent holds, consequent false.
|
||||||
|
func TestImplies_TemporalAntecedent_SingleStepViolates(t *testing.T) {
|
||||||
|
formula := Implies(Always(Thunk(thunkSeq(true))), Not(Thunk(thunkSeq(true))))
|
||||||
|
if verdict, _ := runAndFinalize(formula, 1); verdict != VerdictViolated {
|
||||||
|
t.Fatalf("Implies(Always(true),Not(true)): got %v, want violated", verdict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A consequent inside an Or must not mask the violation either.
|
||||||
|
func TestImplies_TemporalAntecedent_OrConsequentViolates(t *testing.T) {
|
||||||
|
formula := Implies(Always(Thunk(thunkSeq(true))), Or(Not(Thunk(thunkSeq(true))), Pure(false)))
|
||||||
|
if verdict, _ := runAndFinalize(formula, 1); verdict != VerdictViolated {
|
||||||
|
t.Fatalf("Implies(Always(true),Or(Not(true),false)): got %v, want violated", verdict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Control: a false antecedent makes the implication vacuously hold.
|
||||||
|
func TestImplies_TemporalAntecedent_FalseAntecedentHolds(t *testing.T) {
|
||||||
|
p := Thunk(thunkSeq(true, true, false))
|
||||||
|
q := Thunk(thunkSeq(false, false, true))
|
||||||
|
formula := Implies(Always(p), Not(q))
|
||||||
|
if verdict, _ := runAndFinalize(formula, 3); verdict != VerdictHolds {
|
||||||
|
t.Fatalf("Implies(Always(p),Not(q)) p=TTF q=FFT: got %v, want holds", verdict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A bounded Always whose inner is a still-pending deferred Next obligation must
|
||||||
|
// carry that obligation past the window, not drop it to holds. The pre-fix
|
||||||
|
// window-close branch returned holds() unconditionally and lost the violation.
|
||||||
|
func TestBoundedAlways_PendingInnerCarried(t *testing.T) {
|
||||||
|
inner := AlwaysFormula{Inner: Next(Thunk(thunkSeq(false))), StepBound: 1, HasStepBound: true}
|
||||||
|
formula := Always(inner)
|
||||||
|
if verdict, _ := runAndFinalize(formula, 3); verdict != VerdictViolated {
|
||||||
|
t.Fatalf("Always(boundedAlways(Next(false),1)): got %v, want violated", verdict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A bounded Always whose inner genuinely holds each step stays satisfied: the
|
||||||
|
// fix must not turn a satisfied bounded window into a false positive.
|
||||||
|
func TestBoundedAlways_HoldingInnerStillHolds(t *testing.T) {
|
||||||
|
inner := AlwaysFormula{Inner: Not(Thunk(thunkSeq(false))), StepBound: 1, HasStepBound: true}
|
||||||
|
formula := Always(inner)
|
||||||
|
if verdict, _ := runAndFinalize(formula, 3); verdict != VerdictHolds {
|
||||||
|
t.Fatalf("Always(boundedAlways(Not(false),1)): got %v, want holds", verdict)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package ltl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"testing/quick"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFinalize_UnboundedEventuallyUnmetIsViolated(t *testing.T) {
|
||||||
|
evaluator := NewEvaluator(Eventually(ThunkNamed("p", func() (bool, error) { return false, nil })))
|
||||||
|
for index := range 3 {
|
||||||
|
if got := evaluator.ObserveAt(time.Unix(int64(index), 0)); got != VerdictPending {
|
||||||
|
t.Fatalf("step %d: got %v, want pending", index, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := evaluator.Finalize(); got != VerdictViolated {
|
||||||
|
t.Errorf("Finalize = %v, want violated", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinalize_FinalStepNextIsViolated(t *testing.T) {
|
||||||
|
evaluator := NewEvaluator(Next(ThunkNamed("p", func() (bool, error) { return true, nil })))
|
||||||
|
if got := evaluator.Observe(); got != VerdictPending {
|
||||||
|
t.Fatalf("step 1: got %v, want pending", got)
|
||||||
|
}
|
||||||
|
if got := evaluator.Finalize(); got != VerdictViolated {
|
||||||
|
t.Errorf("Finalize = %v, want violated", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinalize_HoldingRunStaysHolds(t *testing.T) {
|
||||||
|
evaluator := NewEvaluator(Always(Pure(true)))
|
||||||
|
evaluator.Observe()
|
||||||
|
if got := evaluator.Finalize(); got != VerdictHolds {
|
||||||
|
t.Errorf("Finalize = %v, want holds", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinalize_AlreadyViolatedStaysViolated(t *testing.T) {
|
||||||
|
evaluator := NewEvaluator(Always(Pure(false)))
|
||||||
|
if got := evaluator.Observe(); got != VerdictViolated {
|
||||||
|
t.Fatalf("expected violated, got %v", got)
|
||||||
|
}
|
||||||
|
if got := evaluator.Finalize(); got != VerdictViolated {
|
||||||
|
t.Errorf("Finalize = %v, want violated", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinalize_BoundedAlwaysVacuouslyHolds(t *testing.T) {
|
||||||
|
// A bounded Always whose window never closed (still pending) is safe.
|
||||||
|
evaluator := NewEvaluator(EventuallyWithinSteps(Pure(false), 5))
|
||||||
|
evaluator.Observe()
|
||||||
|
// The negated form of this is a bounded Always; build it directly.
|
||||||
|
bounded := NewEvaluator(Always(Not(EventuallyWithinSteps(ThunkNamed("p", func() (bool, error) { return false, nil }), 5))))
|
||||||
|
bounded.Observe()
|
||||||
|
if got := bounded.Finalize(); got == VerdictViolated {
|
||||||
|
t.Errorf("bounded always should not finalize to violated, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEventuallyWithin_ViolatesIffNConsecutiveFalse locks the bounded
|
||||||
|
// eventually contract: with a step bound of n and an inner that is false for
|
||||||
|
// the first n observations, the verdict violates exactly at step n, and with at
|
||||||
|
// least one true observation inside the window it holds.
|
||||||
|
func TestEventuallyWithin_ViolatesIffNConsecutiveFalse(t *testing.T) {
|
||||||
|
law := func(boundSeed uint8, trueAtSeed uint8) bool {
|
||||||
|
bound := int(boundSeed%5) + 1
|
||||||
|
// trueAt < 0 means inner is never true.
|
||||||
|
trueAt := int(trueAtSeed)%(bound+2) - 1
|
||||||
|
step := 0
|
||||||
|
inner := ThunkNamed("p", func() (bool, error) {
|
||||||
|
current := trueAt >= 0 && step == trueAt
|
||||||
|
return current, nil
|
||||||
|
})
|
||||||
|
evaluator := NewEvaluator(EventuallyWithinSteps(inner, bound))
|
||||||
|
|
||||||
|
satisfiedInWindow := trueAt >= 0 && trueAt < bound
|
||||||
|
var final Verdict = VerdictPending
|
||||||
|
for index := range bound {
|
||||||
|
step = index
|
||||||
|
final = evaluator.ObserveAt(time.Unix(int64(index), 0))
|
||||||
|
if final == VerdictHolds || final == VerdictViolated {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if satisfiedInWindow {
|
||||||
|
return final == VerdictHolds
|
||||||
|
}
|
||||||
|
return final == VerdictViolated
|
||||||
|
}
|
||||||
|
if err := quick.Check(law, nil); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestViolationLatchIsMonotonic locks: once an evaluator reports Violated, every
|
||||||
|
// subsequent observation (and Finalize) stays Violated regardless of inputs.
|
||||||
|
func TestViolationLatchIsMonotonic(t *testing.T) {
|
||||||
|
law := func(seed uint64) bool {
|
||||||
|
values := make([]bool, 8)
|
||||||
|
for index := range values {
|
||||||
|
values[index] = (seed>>uint(index))&1 == 1
|
||||||
|
}
|
||||||
|
step := 0
|
||||||
|
evaluator := NewEvaluator(Always(ThunkNamed("p", func() (bool, error) {
|
||||||
|
current := values[step%len(values)]
|
||||||
|
step++
|
||||||
|
return current, nil
|
||||||
|
})))
|
||||||
|
seenViolated := false
|
||||||
|
for index := range 16 {
|
||||||
|
got := evaluator.ObserveAt(time.Unix(int64(index), 0))
|
||||||
|
if got == VerdictViolated {
|
||||||
|
seenViolated = true
|
||||||
|
} else if seenViolated {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if seenViolated && evaluator.Finalize() != VerdictViolated {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if err := quick.Check(law, nil); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollapse_IdenticalObligationsMerge(t *testing.T) {
|
||||||
|
merged := collapse([]Formula{
|
||||||
|
Next(Pure(true)),
|
||||||
|
Next(Pure(true)),
|
||||||
|
Next(Pure(true)),
|
||||||
|
})
|
||||||
|
if len(merged) != 1 {
|
||||||
|
t.Errorf("expected 1 obligation after collapse, got %d", len(merged))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollapse_DistinctPredicatesDoNotMerge(t *testing.T) {
|
||||||
|
merged := collapse([]Formula{
|
||||||
|
Eventually(ThunkNamed("p3", func() (bool, error) { return false, nil })),
|
||||||
|
Eventually(ThunkNamed("p4", func() (bool, error) { return false, nil })),
|
||||||
|
})
|
||||||
|
if len(merged) != 2 {
|
||||||
|
t.Errorf("distinct predicates must not merge, got %d", len(merged))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollapse_NamedThunkLeakBoundsPendingSet(t *testing.T) {
|
||||||
|
// Always(Eventually(sameThunk)): each step spawns an identical obligation.
|
||||||
|
// Without collapse the pending set grows unboundedly.
|
||||||
|
evaluator := NewEvaluator(Always(Eventually(ThunkNamed("p", func() (bool, error) { return false, nil }))))
|
||||||
|
for index := range 20 {
|
||||||
|
evaluator.ObserveAt(time.Unix(int64(index), 0))
|
||||||
|
}
|
||||||
|
if len(evaluator.pending) > 2 {
|
||||||
|
t.Errorf("pending set leaked to %d obligations", len(evaluator.pending))
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
-15
@@ -13,9 +13,9 @@ type Formula interface {
|
|||||||
describe() string
|
describe() string
|
||||||
}
|
}
|
||||||
|
|
||||||
// PredicateLabel lets a ThunkFormula carry a human-readable name for the
|
// PredicateLabel lets a ThunkFormula expose the identity of the closure it
|
||||||
// closure it wraps. Verifier wires this in when the spec gives the predicate
|
// wraps. ThunkFormula satisfies it through its Name field; an empty name
|
||||||
// a property name; otherwise it stays empty and serializes without a name.
|
// serializes without a name.
|
||||||
type PredicateLabel interface {
|
type PredicateLabel interface {
|
||||||
PredicateName() string
|
PredicateName() string
|
||||||
}
|
}
|
||||||
@@ -33,18 +33,35 @@ func (e ErrorFormula) describe() string {
|
|||||||
return fmt.Sprintf("Error(%q)", e.Message)
|
return fmt.Sprintf("Error(%q)", e.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AlwaysFormula obliges its inner formula to hold at every step. A bounded
|
||||||
|
// Always (the dual of a bounded Eventually) holds for the steps inside its
|
||||||
|
// window and is vacuously satisfied once the window closes. An unbounded
|
||||||
|
// Always carries no bound fields and is checked at every observed step.
|
||||||
type AlwaysFormula struct {
|
type AlwaysFormula struct {
|
||||||
Inner Formula
|
Inner Formula
|
||||||
|
StepBound int
|
||||||
|
HasStepBound bool
|
||||||
|
Duration time.Duration
|
||||||
|
Deadline time.Time
|
||||||
|
HasDeadline bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type PureFormula struct {
|
type PureFormula struct {
|
||||||
Value bool
|
Value bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ThunkFormula wraps an opaque predicate closure. Func returns the predicate's
|
||||||
|
// boolean result and a non-nil error when the predicate threw; a thrown
|
||||||
|
// predicate is a witnessed violation distinct from a plain false. Name carries
|
||||||
|
// the predicate's identity so two distinct predicates produce distinct
|
||||||
|
// describe() keys and are never merged during obligation collapse.
|
||||||
type ThunkFormula struct {
|
type ThunkFormula struct {
|
||||||
Func func() bool
|
Func func() (bool, error)
|
||||||
|
Name string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t ThunkFormula) PredicateName() string { return t.Name }
|
||||||
|
|
||||||
// NowFormula marks its inner formula for evaluation at the current step only.
|
// NowFormula marks its inner formula for evaluation at the current step only.
|
||||||
// Primarily used so that now(...).implies(...) parses unambiguously.
|
// Primarily used so that now(...).implies(...) parses unambiguously.
|
||||||
type NowFormula struct {
|
type NowFormula struct {
|
||||||
@@ -96,7 +113,11 @@ func Always(inner Formula) Formula { return AlwaysFormula{Inner: inner} }
|
|||||||
|
|
||||||
func Pure(value bool) Formula { return PureFormula{Value: value} }
|
func Pure(value bool) Formula { return PureFormula{Value: value} }
|
||||||
|
|
||||||
func Thunk(function func() bool) Formula { return ThunkFormula{Func: function} }
|
func Thunk(function func() (bool, error)) Formula { return ThunkFormula{Func: function} }
|
||||||
|
|
||||||
|
func ThunkNamed(name string, function func() (bool, error)) Formula {
|
||||||
|
return ThunkFormula{Func: function, Name: name}
|
||||||
|
}
|
||||||
|
|
||||||
func Now(inner Formula) Formula { return NowFormula{Inner: inner} }
|
func Now(inner Formula) Formula { return NowFormula{Inner: inner} }
|
||||||
|
|
||||||
@@ -137,11 +158,27 @@ func (OrFormula) isFormula() {}
|
|||||||
func (AndFormula) isFormula() {}
|
func (AndFormula) isFormula() {}
|
||||||
func (NotFormula) isFormula() {}
|
func (NotFormula) isFormula() {}
|
||||||
|
|
||||||
func (a AlwaysFormula) describe() string { return "Always(" + a.Inner.describe() + ")" }
|
func (a AlwaysFormula) describe() string {
|
||||||
func (p PureFormula) describe() string { return fmt.Sprintf("Pure(%t)", p.Value) }
|
parts := []string{a.Inner.describe()}
|
||||||
func (ThunkFormula) describe() string { return "Thunk(...)" }
|
if a.HasStepBound {
|
||||||
func (n NowFormula) describe() string { return "Now(" + n.Inner.describe() + ")" }
|
parts = append(parts, fmt.Sprintf("steps=%d", a.StepBound))
|
||||||
func (n NextFormula) describe() string { return "Next(" + n.Inner.describe() + ")" }
|
}
|
||||||
|
if a.HasDeadline {
|
||||||
|
parts = append(parts, "deadline="+a.Deadline.Format(time.RFC3339Nano))
|
||||||
|
} else if a.Duration > 0 {
|
||||||
|
parts = append(parts, "within="+a.Duration.String())
|
||||||
|
}
|
||||||
|
return "Always(" + strings.Join(parts, ", ") + ")"
|
||||||
|
}
|
||||||
|
func (p PureFormula) describe() string { return fmt.Sprintf("Pure(%t)", p.Value) }
|
||||||
|
func (t ThunkFormula) describe() string {
|
||||||
|
if t.Name != "" {
|
||||||
|
return "Thunk(" + t.Name + ")"
|
||||||
|
}
|
||||||
|
return "Thunk(...)"
|
||||||
|
}
|
||||||
|
func (n NowFormula) describe() string { return "Now(" + n.Inner.describe() + ")" }
|
||||||
|
func (n NextFormula) describe() string { return "Next(" + n.Inner.describe() + ")" }
|
||||||
func (e EventuallyFormula) describe() string {
|
func (e EventuallyFormula) describe() string {
|
||||||
parts := []string{e.Inner.describe()}
|
parts := []string{e.Inner.describe()}
|
||||||
if e.HasStepBound {
|
if e.HasStepBound {
|
||||||
@@ -176,10 +213,20 @@ type withinNode struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a AlwaysFormula) MarshalJSON() ([]byte, error) {
|
func (a AlwaysFormula) MarshalJSON() ([]byte, error) {
|
||||||
return json.Marshal(struct {
|
payload := struct {
|
||||||
Op string `json:"op"`
|
Op string `json:"op"`
|
||||||
Arg Formula `json:"arg"`
|
Arg Formula `json:"arg"`
|
||||||
}{"always", a.Inner})
|
Within *withinNode `json:"within,omitempty"`
|
||||||
|
}{Op: "always", Arg: a.Inner}
|
||||||
|
switch {
|
||||||
|
case a.HasStepBound:
|
||||||
|
payload.Within = &withinNode{Amount: int64(a.StepBound), Unit: "steps"}
|
||||||
|
case a.Duration > 0:
|
||||||
|
payload.Within = &withinNode{Amount: a.Duration.Milliseconds(), Unit: "milliseconds"}
|
||||||
|
case a.HasDeadline:
|
||||||
|
payload.Within = &withinNode{Amount: a.Deadline.UnixMilli(), Unit: "deadline"}
|
||||||
|
}
|
||||||
|
return json.Marshal(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n NowFormula) MarshalJSON() ([]byte, error) {
|
func (n NowFormula) MarshalJSON() ([]byte, error) {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func TestAlways_Now_ViolatesImmediately(t *testing.T) {
|
|||||||
|
|
||||||
func TestAlways_Next_PendingThenViolated(t *testing.T) {
|
func TestAlways_Next_PendingThenViolated(t *testing.T) {
|
||||||
y := true
|
y := true
|
||||||
evaluator := NewEvaluator(Always(Next(Thunk(func() bool { return y }))))
|
evaluator := NewEvaluator(Always(Next(Thunk(func() (bool, error) { return y, nil }))))
|
||||||
|
|
||||||
if got := evaluator.Observe(); got != VerdictPending {
|
if got := evaluator.Observe(); got != VerdictPending {
|
||||||
t.Errorf("step 1: got %v, want pending", got)
|
t.Errorf("step 1: got %v, want pending", got)
|
||||||
@@ -62,7 +62,7 @@ func TestAlways_Next_PendingThenViolated(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlways_Next_StaysPendingWhileInnerHolds(t *testing.T) {
|
func TestAlways_Next_StaysPendingWhileInnerHolds(t *testing.T) {
|
||||||
evaluator := NewEvaluator(Always(Next(Thunk(func() bool { return true }))))
|
evaluator := NewEvaluator(Always(Next(Thunk(func() (bool, error) { return true, nil }))))
|
||||||
for index := range 3 {
|
for index := range 3 {
|
||||||
if got := evaluator.ObserveAt(time.Unix(int64(index), 0)); got != VerdictPending {
|
if got := evaluator.ObserveAt(time.Unix(int64(index), 0)); got != VerdictPending {
|
||||||
t.Errorf("step %d: got %v, want pending", index+1, got)
|
t.Errorf("step %d: got %v, want pending", index+1, got)
|
||||||
@@ -76,8 +76,8 @@ func TestAlways_NowImpliesEventuallyWithin_ViolatesWhenYLate(t *testing.T) {
|
|||||||
xValues := []bool{true, false, false, false, false}
|
xValues := []bool{true, false, false, false, false}
|
||||||
yValues := []bool{false, false, false, true, true}
|
yValues := []bool{false, false, false, true, true}
|
||||||
step := 0
|
step := 0
|
||||||
predX := Thunk(func() bool { return xValues[step] })
|
predX := Thunk(func() (bool, error) { return xValues[step], nil })
|
||||||
predY := Thunk(func() bool { return yValues[step] })
|
predY := Thunk(func() (bool, error) { return yValues[step], nil })
|
||||||
|
|
||||||
formula := Always(Implies(Now(predX), EventuallyWithinSteps(predY, 3)))
|
formula := Always(Implies(Now(predX), EventuallyWithinSteps(predY, 3)))
|
||||||
evaluator := NewEvaluator(formula)
|
evaluator := NewEvaluator(formula)
|
||||||
@@ -107,8 +107,8 @@ func TestAlways_NowImpliesEventuallyWithin_HoldsWhenYInBound(t *testing.T) {
|
|||||||
xValues := []bool{true, false, false}
|
xValues := []bool{true, false, false}
|
||||||
yValues := []bool{false, false, true}
|
yValues := []bool{false, false, true}
|
||||||
step := 0
|
step := 0
|
||||||
predX := Thunk(func() bool { return xValues[step] })
|
predX := Thunk(func() (bool, error) { return xValues[step], nil })
|
||||||
predY := Thunk(func() bool { return yValues[step] })
|
predY := Thunk(func() (bool, error) { return yValues[step], nil })
|
||||||
|
|
||||||
formula := Always(Implies(Now(predX), EventuallyWithinSteps(predY, 3)))
|
formula := Always(Implies(Now(predX), EventuallyWithinSteps(predY, 3)))
|
||||||
evaluator := NewEvaluator(formula)
|
evaluator := NewEvaluator(formula)
|
||||||
@@ -187,12 +187,6 @@ func TestNot_InvertsPure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestVerdict_StringPending(t *testing.T) {
|
|
||||||
if got := VerdictPending.String(); got != "pending" {
|
|
||||||
t.Errorf("VerdictPending.String() = %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMarshalJSON_AlwaysImpliesEventually(t *testing.T) {
|
func TestMarshalJSON_AlwaysImpliesEventually(t *testing.T) {
|
||||||
formula := Always(Implies(Now(Pure(true)), EventuallyWithinSteps(Pure(false), 3)))
|
formula := Always(Implies(Now(Pure(true)), EventuallyWithinSteps(Pure(false), 3)))
|
||||||
body, err := json.Marshal(formula)
|
body, err := json.Marshal(formula)
|
||||||
@@ -231,7 +225,7 @@ func TestMarshalJSON_NextAndThunkAndError(t *testing.T) {
|
|||||||
if string(body) != `{"op":"next","arg":{"op":"true"}}` {
|
if string(body) != `{"op":"next","arg":{"op":"true"}}` {
|
||||||
t.Errorf("next marshal wrong: %s", body)
|
t.Errorf("next marshal wrong: %s", body)
|
||||||
}
|
}
|
||||||
body, _ = json.Marshal(Thunk(func() bool { return true }))
|
body, _ = json.Marshal(Thunk(func() (bool, error) { return true, nil }))
|
||||||
if string(body) != `{"op":"predicate"}` {
|
if string(body) != `{"op":"predicate"}` {
|
||||||
t.Errorf("thunk marshal wrong: %s", body)
|
t.Errorf("thunk marshal wrong: %s", body)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package ltl
|
||||||
|
|
||||||
|
// nnf rewrites a formula into negation normal form: every NotFormula is pushed
|
||||||
|
// down until it wraps only an opaque leaf (a ThunkFormula or ErrorFormula).
|
||||||
|
// Temporal operators are dualized along the way (Always <-> Eventually) so the
|
||||||
|
// evaluator never has to reduce a negated temporal obligation, which it cannot
|
||||||
|
// do soundly across steps.
|
||||||
|
func nnf(formula Formula) Formula {
|
||||||
|
switch concrete := formula.(type) {
|
||||||
|
case NotFormula:
|
||||||
|
return pushNot(concrete.Inner)
|
||||||
|
case AlwaysFormula:
|
||||||
|
next := concrete
|
||||||
|
next.Inner = nnf(concrete.Inner)
|
||||||
|
return next
|
||||||
|
case EventuallyFormula:
|
||||||
|
next := concrete
|
||||||
|
next.Inner = nnf(concrete.Inner)
|
||||||
|
return next
|
||||||
|
case NextFormula:
|
||||||
|
return NextFormula{Inner: nnf(concrete.Inner)}
|
||||||
|
case NowFormula:
|
||||||
|
return NowFormula{Inner: nnf(concrete.Inner)}
|
||||||
|
case AndFormula:
|
||||||
|
return AndFormula{Left: nnf(concrete.Left), Right: nnf(concrete.Right)}
|
||||||
|
case OrFormula:
|
||||||
|
return OrFormula{Left: nnf(concrete.Left), Right: nnf(concrete.Right)}
|
||||||
|
case ImpliesFormula:
|
||||||
|
// a -> b is rewritten to (not a) or b so the consequent is always
|
||||||
|
// reduced live each step. Keeping it as ImpliesFormula let a pending
|
||||||
|
// (temporal) antecedent defer the whole implication and silently drop a
|
||||||
|
// consequent that was false at the current step.
|
||||||
|
return OrFormula{
|
||||||
|
Left: pushNot(concrete.Antecedent),
|
||||||
|
Right: nnf(concrete.Consequent),
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return formula
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushNot returns the negation normal form of NOT f.
|
||||||
|
func pushNot(formula Formula) Formula {
|
||||||
|
switch concrete := formula.(type) {
|
||||||
|
case PureFormula:
|
||||||
|
return PureFormula{Value: !concrete.Value}
|
||||||
|
case ThunkFormula:
|
||||||
|
return NotFormula{Inner: concrete}
|
||||||
|
case ErrorFormula:
|
||||||
|
return NotFormula{Inner: concrete}
|
||||||
|
case NotFormula:
|
||||||
|
return nnf(concrete.Inner)
|
||||||
|
case AndFormula:
|
||||||
|
return OrFormula{Left: pushNot(concrete.Left), Right: pushNot(concrete.Right)}
|
||||||
|
case OrFormula:
|
||||||
|
return AndFormula{Left: pushNot(concrete.Left), Right: pushNot(concrete.Right)}
|
||||||
|
case ImpliesFormula:
|
||||||
|
return AndFormula{
|
||||||
|
Left: nnf(concrete.Antecedent),
|
||||||
|
Right: pushNot(concrete.Consequent),
|
||||||
|
}
|
||||||
|
case NowFormula:
|
||||||
|
return NowFormula{Inner: pushNot(concrete.Inner)}
|
||||||
|
case NextFormula:
|
||||||
|
return NextFormula{Inner: pushNot(concrete.Inner)}
|
||||||
|
case AlwaysFormula:
|
||||||
|
return EventuallyFormula{
|
||||||
|
Inner: pushNot(concrete.Inner),
|
||||||
|
StepBound: concrete.StepBound,
|
||||||
|
HasStepBound: concrete.HasStepBound,
|
||||||
|
Duration: concrete.Duration,
|
||||||
|
Deadline: concrete.Deadline,
|
||||||
|
HasDeadline: concrete.HasDeadline,
|
||||||
|
}
|
||||||
|
case EventuallyFormula:
|
||||||
|
return AlwaysFormula{
|
||||||
|
Inner: pushNot(concrete.Inner),
|
||||||
|
StepBound: concrete.StepBound,
|
||||||
|
HasStepBound: concrete.HasStepBound,
|
||||||
|
Duration: concrete.Duration,
|
||||||
|
Deadline: concrete.Deadline,
|
||||||
|
HasDeadline: concrete.HasDeadline,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return NotFormula{Inner: formula}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package ltl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"testing/quick"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// leaf builds a small set of representative atomic formulas indexed by a seed.
|
||||||
|
func leafFor(seed uint8) Formula {
|
||||||
|
switch seed % 3 {
|
||||||
|
case 0:
|
||||||
|
return Pure(true)
|
||||||
|
case 1:
|
||||||
|
return Pure(false)
|
||||||
|
default:
|
||||||
|
return ThunkNamed("p", func() (bool, error) { return true, nil })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNNF_DoubleNegationIsIdentity(t *testing.T) {
|
||||||
|
law := func(seed uint8) bool {
|
||||||
|
leaf := leafFor(seed)
|
||||||
|
doubled := nnf(Not(Not(leaf)))
|
||||||
|
direct := nnf(leaf)
|
||||||
|
return doubled.describe() == direct.describe()
|
||||||
|
}
|
||||||
|
if err := quick.Check(law, nil); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNNF_NotAlwaysIsEventuallyNot(t *testing.T) {
|
||||||
|
law := func(seed uint8) bool {
|
||||||
|
leaf := leafFor(seed)
|
||||||
|
negated := nnf(Not(Always(leaf)))
|
||||||
|
expected := nnf(Eventually(Not(leaf)))
|
||||||
|
return negated.describe() == expected.describe()
|
||||||
|
}
|
||||||
|
if err := quick.Check(law, nil); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNNF_NotEventuallyIsAlwaysNot(t *testing.T) {
|
||||||
|
law := func(seed uint8) bool {
|
||||||
|
leaf := leafFor(seed)
|
||||||
|
negated := nnf(Not(Eventually(leaf)))
|
||||||
|
expected := nnf(Always(Not(leaf)))
|
||||||
|
return negated.describe() == expected.describe()
|
||||||
|
}
|
||||||
|
if err := quick.Check(law, nil); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNNF_BoundedEventuallyDualKeepsBound(t *testing.T) {
|
||||||
|
negated := nnf(Not(EventuallyWithinSteps(Pure(true), 4)))
|
||||||
|
always, ok := negated.(AlwaysFormula)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected AlwaysFormula, got %T", negated)
|
||||||
|
}
|
||||||
|
if !always.HasStepBound || always.StepBound != 4 {
|
||||||
|
t.Errorf("bound not preserved: %+v", always)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNNF_PushesNotToThunkLeaf(t *testing.T) {
|
||||||
|
formula := nnf(Always(Not(Always(ThunkNamed("p", func() (bool, error) { return true, nil })))))
|
||||||
|
always, ok := formula.(AlwaysFormula)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected AlwaysFormula, got %T", formula)
|
||||||
|
}
|
||||||
|
eventually, ok := always.Inner.(EventuallyFormula)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected inner EventuallyFormula, got %T", always.Inner)
|
||||||
|
}
|
||||||
|
not, ok := eventually.Inner.(NotFormula)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected NotFormula leaf, got %T", eventually.Inner)
|
||||||
|
}
|
||||||
|
if _, ok := not.Inner.(ThunkFormula); !ok {
|
||||||
|
t.Errorf("expected Not to wrap a Thunk, got %T", not.Inner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNNF_NotAlwaysTrueViaEvaluatorReportsViolated(t *testing.T) {
|
||||||
|
evaluator := NewEvaluator(Always(Not(Always(ThunkNamed("p", func() (bool, error) { return true, nil })))))
|
||||||
|
for index := range 4 {
|
||||||
|
if got := evaluator.ObserveAt(time.Unix(int64(index), 0)); got == VerdictViolated {
|
||||||
|
t.Fatalf("step %d latched violated prematurely", index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := evaluator.Finalize(); got != VerdictViolated {
|
||||||
|
t.Errorf("Finalize = %v, want violated", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package ltl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestViolation_PredicateFalseCarriesReasonAndStep(t *testing.T) {
|
||||||
|
values := []bool{true, false}
|
||||||
|
step := 0
|
||||||
|
evaluator := NewEvaluator(Always(ThunkNamed("p", func() (bool, error) {
|
||||||
|
current := values[step]
|
||||||
|
step++
|
||||||
|
return current, nil
|
||||||
|
})))
|
||||||
|
if got := evaluator.ObserveAt(time.Unix(0, 0)); got != VerdictHolds {
|
||||||
|
t.Fatalf("step 1: got %v, want holds", got)
|
||||||
|
}
|
||||||
|
if got := evaluator.ObserveAt(time.Unix(1, 0)); got != VerdictViolated {
|
||||||
|
t.Fatalf("step 2: got %v, want violated", got)
|
||||||
|
}
|
||||||
|
witness := evaluator.Violation()
|
||||||
|
if witness == nil {
|
||||||
|
t.Fatal("Violation = nil, want non-nil")
|
||||||
|
}
|
||||||
|
if witness.Reason != "predicate false" {
|
||||||
|
t.Errorf("Reason = %q, want %q", witness.Reason, "predicate false")
|
||||||
|
}
|
||||||
|
if witness.Step != 2 {
|
||||||
|
t.Errorf("Step = %d, want 2", witness.Step)
|
||||||
|
}
|
||||||
|
if witness.IsError {
|
||||||
|
t.Errorf("IsError = true, want false for a plain false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestViolation_ThrownPredicateSetsIsError(t *testing.T) {
|
||||||
|
evaluator := NewEvaluator(Always(ThunkNamed("p", func() (bool, error) {
|
||||||
|
return false, errors.New("boom")
|
||||||
|
})))
|
||||||
|
if got := evaluator.Observe(); got != VerdictViolated {
|
||||||
|
t.Fatalf("got %v, want violated", got)
|
||||||
|
}
|
||||||
|
witness := evaluator.Violation()
|
||||||
|
if witness == nil {
|
||||||
|
t.Fatal("Violation = nil, want non-nil")
|
||||||
|
}
|
||||||
|
if !witness.IsError {
|
||||||
|
t.Errorf("IsError = false, want true for a thrown predicate")
|
||||||
|
}
|
||||||
|
if witness.Reason != "boom" {
|
||||||
|
t.Errorf("Reason = %q, want %q", witness.Reason, "boom")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestViolation_FinalizeFillsWitness(t *testing.T) {
|
||||||
|
evaluator := NewEvaluator(Eventually(ThunkNamed("p", func() (bool, error) {
|
||||||
|
return false, nil
|
||||||
|
})))
|
||||||
|
evaluator.ObserveAt(time.Unix(0, 0))
|
||||||
|
if got := evaluator.Finalize(); got != VerdictViolated {
|
||||||
|
t.Fatalf("Finalize = %v, want violated", got)
|
||||||
|
}
|
||||||
|
witness := evaluator.Violation()
|
||||||
|
if witness == nil {
|
||||||
|
t.Fatal("Violation = nil after Finalize, want non-nil")
|
||||||
|
}
|
||||||
|
if witness.Reason != "eventually never satisfied" {
|
||||||
|
t.Errorf("Reason = %q, want %q", witness.Reason, "eventually never satisfied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestViolation_NilBeforeViolation(t *testing.T) {
|
||||||
|
evaluator := NewEvaluator(Always(Pure(true)))
|
||||||
|
evaluator.Observe()
|
||||||
|
if got := evaluator.Violation(); got != nil {
|
||||||
|
t.Errorf("Violation = %+v, want nil for a holding run", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
package permissions
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os/exec"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Inspector returns the list of uses-permission entries declared by the APK.
|
|
||||||
type Inspector func(ctx context.Context, apkPath string) ([]string, error)
|
|
||||||
|
|
||||||
// Granter grants a single permission to the given package on the connected
|
|
||||||
// device. Implementations typically wrap `adb shell pm grant`.
|
|
||||||
type Granter func(ctx context.Context, packageName, permission string) error
|
|
||||||
|
|
||||||
// GrantDangerous filters declared permissions to the dangerous set and grants
|
|
||||||
// each one via the supplied granter. Errors from individual grants are
|
|
||||||
// collected as warnings rather than aborting — Android refuses to grant
|
|
||||||
// non-runtime permissions and we prefer to soldier on.
|
|
||||||
func GrantDangerous(
|
|
||||||
ctx context.Context,
|
|
||||||
apkPath, packageName string,
|
|
||||||
inspector Inspector,
|
|
||||||
granter Granter,
|
|
||||||
) (granted []string, warnings []string, err error) {
|
|
||||||
declared, err := inspector(ctx, apkPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("inspect permissions: %w", err)
|
|
||||||
}
|
|
||||||
for _, permission := range declared {
|
|
||||||
if !IsDangerous(permission) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := granter(ctx, packageName, permission); err != nil {
|
|
||||||
warnings = append(warnings, fmt.Sprintf("%s: %v", permission, err))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
granted = append(granted, permission)
|
|
||||||
}
|
|
||||||
return granted, warnings, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var aaptPermissionPattern = regexp.MustCompile(`uses-permission:\s+name='([^']+)'`)
|
|
||||||
|
|
||||||
// AaptInspector shells out to `aapt dump permissions` to list permissions.
|
|
||||||
func AaptInspector(ctx context.Context, apkPath string) ([]string, error) {
|
|
||||||
output, err := exec.CommandContext(ctx, "aapt", "dump", "permissions", apkPath).Output()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("aapt dump permissions: %w", err)
|
|
||||||
}
|
|
||||||
var permissions []string
|
|
||||||
for _, match := range aaptPermissionPattern.FindAllStringSubmatch(string(output), -1) {
|
|
||||||
permissions = append(permissions, match[1])
|
|
||||||
}
|
|
||||||
return permissions, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdbGranter returns a Granter that runs `adb shell pm grant` against the
|
|
||||||
// supplied device serial (empty = default device).
|
|
||||||
func AdbGranter(adbPath, deviceSerial string) Granter {
|
|
||||||
return func(ctx context.Context, packageName, permission string) error {
|
|
||||||
arguments := []string{"shell", "pm", "grant", packageName, permission}
|
|
||||||
if deviceSerial != "" {
|
|
||||||
arguments = append([]string{"-s", deviceSerial}, arguments...)
|
|
||||||
}
|
|
||||||
command := exec.CommandContext(ctx, adbPath, arguments...)
|
|
||||||
output, err := command.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("adb pm grant %s: %w (%s)", permission, err, strings.TrimSpace(string(output)))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// dangerousPermissions captures Android's PROTECTION_DANGEROUS set as of
|
|
||||||
// API 34. New runtime permissions added in later releases should be appended
|
|
||||||
// here when needed.
|
|
||||||
var dangerousPermissions = map[string]bool{
|
|
||||||
"android.permission.READ_CALENDAR": true,
|
|
||||||
"android.permission.WRITE_CALENDAR": true,
|
|
||||||
"android.permission.CAMERA": true,
|
|
||||||
"android.permission.READ_CONTACTS": true,
|
|
||||||
"android.permission.WRITE_CONTACTS": true,
|
|
||||||
"android.permission.GET_ACCOUNTS": true,
|
|
||||||
"android.permission.ACCESS_FINE_LOCATION": true,
|
|
||||||
"android.permission.ACCESS_COARSE_LOCATION": true,
|
|
||||||
"android.permission.ACCESS_BACKGROUND_LOCATION": true,
|
|
||||||
"android.permission.RECORD_AUDIO": true,
|
|
||||||
"android.permission.READ_PHONE_STATE": true,
|
|
||||||
"android.permission.READ_PHONE_NUMBERS": true,
|
|
||||||
"android.permission.CALL_PHONE": true,
|
|
||||||
"android.permission.ANSWER_PHONE_CALLS": true,
|
|
||||||
"android.permission.READ_CALL_LOG": true,
|
|
||||||
"android.permission.WRITE_CALL_LOG": true,
|
|
||||||
"android.permission.ADD_VOICEMAIL": true,
|
|
||||||
"android.permission.USE_SIP": true,
|
|
||||||
"android.permission.PROCESS_OUTGOING_CALLS": true,
|
|
||||||
"android.permission.BODY_SENSORS": true,
|
|
||||||
"android.permission.SEND_SMS": true,
|
|
||||||
"android.permission.RECEIVE_SMS": true,
|
|
||||||
"android.permission.READ_SMS": true,
|
|
||||||
"android.permission.RECEIVE_WAP_PUSH": true,
|
|
||||||
"android.permission.RECEIVE_MMS": true,
|
|
||||||
"android.permission.READ_EXTERNAL_STORAGE": true,
|
|
||||||
"android.permission.WRITE_EXTERNAL_STORAGE": true,
|
|
||||||
"android.permission.ACCESS_MEDIA_LOCATION": true,
|
|
||||||
"android.permission.ACTIVITY_RECOGNITION": true,
|
|
||||||
"android.permission.POST_NOTIFICATIONS": true,
|
|
||||||
"android.permission.NEARBY_WIFI_DEVICES": true,
|
|
||||||
"android.permission.READ_MEDIA_IMAGES": true,
|
|
||||||
"android.permission.READ_MEDIA_VIDEO": true,
|
|
||||||
"android.permission.READ_MEDIA_AUDIO": true,
|
|
||||||
"android.permission.READ_MEDIA_VISUAL_USER_SELECTED": true,
|
|
||||||
"android.permission.BLUETOOTH_CONNECT": true,
|
|
||||||
"android.permission.BLUETOOTH_ADVERTISE": true,
|
|
||||||
"android.permission.BLUETOOTH_SCAN": true,
|
|
||||||
"android.permission.UWB_RANGING": true,
|
|
||||||
"android.permission.BODY_SENSORS_BACKGROUND": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
func IsDangerous(permission string) bool {
|
|
||||||
return dangerousPermissions[permission]
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
package permissions
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"slices"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGrantDangerous_FiltersAndCallsGranter(t *testing.T) {
|
|
||||||
declared := []string{
|
|
||||||
"android.permission.INTERNET", // not dangerous
|
|
||||||
"android.permission.CAMERA", // dangerous
|
|
||||||
"android.permission.WAKE_LOCK", // not dangerous
|
|
||||||
"android.permission.ACCESS_FINE_LOCATION", // dangerous
|
|
||||||
"android.permission.READ_EXTERNAL_STORAGE", // dangerous
|
|
||||||
"android.permission.SYSTEM_ALERT_WINDOW", // not dangerous
|
|
||||||
}
|
|
||||||
inspector := func(_ context.Context, _ string) ([]string, error) { return declared, nil }
|
|
||||||
var requested []string
|
|
||||||
granter := func(_ context.Context, packageName, permission string) error {
|
|
||||||
if packageName != "com.example" {
|
|
||||||
t.Errorf("granter received wrong package: %q", packageName)
|
|
||||||
}
|
|
||||||
requested = append(requested, permission)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
granted, warnings, err := GrantDangerous(context.Background(), "/path/to/apk", "com.example", inspector, granter)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
want := []string{
|
|
||||||
"android.permission.CAMERA",
|
|
||||||
"android.permission.ACCESS_FINE_LOCATION",
|
|
||||||
"android.permission.READ_EXTERNAL_STORAGE",
|
|
||||||
}
|
|
||||||
if !slices.Equal(granted, want) {
|
|
||||||
t.Errorf("granted permissions: got %v, want %v", granted, want)
|
|
||||||
}
|
|
||||||
if !slices.Equal(requested, want) {
|
|
||||||
t.Errorf("granter calls: got %v, want %v", requested, want)
|
|
||||||
}
|
|
||||||
if len(warnings) != 0 {
|
|
||||||
t.Errorf("expected no warnings, got %v", warnings)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGrantDangerous_CollectsGranterFailuresAsWarnings(t *testing.T) {
|
|
||||||
declared := []string{"android.permission.CAMERA", "android.permission.RECORD_AUDIO"}
|
|
||||||
inspector := func(_ context.Context, _ string) ([]string, error) { return declared, nil }
|
|
||||||
granter := func(_ context.Context, _, permission string) error {
|
|
||||||
if permission == "android.permission.CAMERA" {
|
|
||||||
return errors.New("device denied")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
granted, warnings, err := GrantDangerous(context.Background(), "/x", "com.example", inspector, granter)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !slices.Equal(granted, []string{"android.permission.RECORD_AUDIO"}) {
|
|
||||||
t.Errorf("granted: %v", granted)
|
|
||||||
}
|
|
||||||
if len(warnings) != 1 || warnings[0] != "android.permission.CAMERA: device denied" {
|
|
||||||
t.Errorf("warnings: %v", warnings)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGrantDangerous_InspectorErrorBubbles(t *testing.T) {
|
|
||||||
inspector := func(_ context.Context, _ string) ([]string, error) {
|
|
||||||
return nil, errors.New("aapt missing")
|
|
||||||
}
|
|
||||||
_, _, err := GrantDangerous(context.Background(), "/x", "com.example", inspector, nil)
|
|
||||||
if err == nil || err.Error() == "" {
|
|
||||||
t.Errorf("expected wrapped inspector error, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsDangerous_KnownPermissions(t *testing.T) {
|
|
||||||
dangerous := []string{
|
|
||||||
"android.permission.CAMERA",
|
|
||||||
"android.permission.RECORD_AUDIO",
|
|
||||||
"android.permission.POST_NOTIFICATIONS",
|
|
||||||
}
|
|
||||||
for _, permission := range dangerous {
|
|
||||||
if !IsDangerous(permission) {
|
|
||||||
t.Errorf("expected %q to be dangerous", permission)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
normal := []string{
|
|
||||||
"android.permission.INTERNET",
|
|
||||||
"android.permission.WAKE_LOCK",
|
|
||||||
"android.permission.SYSTEM_ALERT_WINDOW",
|
|
||||||
}
|
|
||||||
for _, permission := range normal {
|
|
||||||
if IsDangerous(permission) {
|
|
||||||
t.Errorf("expected %q not to be dangerous", permission)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAaptInspector_ParsesFixtureOutput(t *testing.T) {
|
|
||||||
// Direct test of the regex; avoids spawning aapt.
|
|
||||||
output := `
|
|
||||||
package: com.example
|
|
||||||
sdkVersion:'24'
|
|
||||||
uses-permission: name='android.permission.INTERNET'
|
|
||||||
uses-permission: name='android.permission.CAMERA'
|
|
||||||
uses-permission: name='android.permission.WAKE_LOCK'
|
|
||||||
`
|
|
||||||
matches := aaptPermissionPattern.FindAllStringSubmatch(output, -1)
|
|
||||||
got := make([]string, 0, len(matches))
|
|
||||||
for _, match := range matches {
|
|
||||||
got = append(got, match[1])
|
|
||||||
}
|
|
||||||
want := []string{
|
|
||||||
"android.permission.INTERNET",
|
|
||||||
"android.permission.CAMERA",
|
|
||||||
"android.permission.WAKE_LOCK",
|
|
||||||
}
|
|
||||||
if !slices.Equal(got, want) {
|
|
||||||
t.Errorf("got %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/priyanshujain/sanderling/internal/verifier"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mustSeededVerifier builds a verifier with a fixed seed so the JS picker's PRNG
|
||||||
|
// is reproducible across runs, making the trace byte-stable.
|
||||||
|
func mustSeededVerifier(t *testing.T, spec string, seed uint64) *verifier.Verifier {
|
||||||
|
t.Helper()
|
||||||
|
instance, err := verifier.New(verifier.WithSeed(seed))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := instance.Load(bundleSpec(t, spec)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return instance
|
||||||
|
}
|
||||||
|
|
||||||
|
// goldenSeed and goldenSteps fix the run so the mock-driven trace and summary
|
||||||
|
// are byte-reproducible. Set UPDATE_GOLDEN=1 to rewrite the committed goldens;
|
||||||
|
// the checked-in files are the asserted source of truth.
|
||||||
|
const (
|
||||||
|
goldenSeed = 0x5eed
|
||||||
|
goldenSteps = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
// canonicalizeTrace rewrites the sole non-deterministic field, "timestamp"
|
||||||
|
// (wall-clock at step start), to a fixed zero value so equality compares only
|
||||||
|
// the run's observable shape. Every other field is a pure function of the seed,
|
||||||
|
// the spec, and the programmed mock hierarchy.
|
||||||
|
func canonicalizeTrace(t *testing.T, raw []byte) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var out bytes.Buffer
|
||||||
|
scanner := bufio.NewScanner(bytes.NewReader(raw))
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
|
||||||
|
for scanner.Scan() {
|
||||||
|
var line map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(scanner.Bytes(), &line); err != nil {
|
||||||
|
t.Fatalf("decode trace line: %v", err)
|
||||||
|
}
|
||||||
|
line["timestamp"] = json.RawMessage(`"0001-01-01T00:00:00Z"`)
|
||||||
|
encoded, err := json.Marshal(line)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encode trace line: %v", err)
|
||||||
|
}
|
||||||
|
out.Write(encoded)
|
||||||
|
out.WriteByte('\n')
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
t.Fatalf("scan trace: %v", err)
|
||||||
|
}
|
||||||
|
return out.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertGolden(t *testing.T, name string, got []byte) {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join("testdata", name)
|
||||||
|
if os.Getenv("UPDATE_GOLDEN") == "1" {
|
||||||
|
if err := os.WriteFile(path, got, 0o644); err != nil {
|
||||||
|
t.Fatalf("write golden %s: %v", name, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
want, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read golden %s (run with UPDATE_GOLDEN=1 to create): %v", name, err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(want, got) {
|
||||||
|
t.Errorf("golden %s mismatch\n--- want ---\n%s\n--- got ---\n%s", name, want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGolden_TraceStreamIsReproducible drives a fixed-seed, fixed-step run
|
||||||
|
// against the mock driver and snapshots the canonicalized trace.jsonl stream.
|
||||||
|
// A drift here means the run is no longer deterministic or the trace shape
|
||||||
|
// changed; both must be reviewed, not papered over.
|
||||||
|
func TestGolden_TraceStreamIsReproducible(t *testing.T) {
|
||||||
|
state := newHarnessWithSpec(t, fixtureSpec)
|
||||||
|
state.verifier = mustSeededVerifier(t, fixtureSpec, goldenSeed)
|
||||||
|
state.mock.HierarchyJSON = `{"attributes":{"resource-id":"HomeScreen"},"children":[{"attributes":{"resource-id":"next","bounds":"[40,80,240,160]"},"children":[],"clickable":true,"enabled":true}]}`
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_, err := Run(ctx, Options{
|
||||||
|
Duration: time.Hour,
|
||||||
|
IdleTimeout: 10 * time.Millisecond,
|
||||||
|
MaxSteps: goldenSteps,
|
||||||
|
Driver: state.mock,
|
||||||
|
Verifier: state.verifier,
|
||||||
|
TraceWriter: state.writer,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run: %v", err)
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(filepath.Join(state.writer.Directory(), "trace.jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertGolden(t, "trace.jsonl", canonicalizeTrace(t, raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGolden_ViolationSummaryIsReproducible drives a known violation and
|
||||||
|
// snapshots the rendered summary string produced by RenderSummary (the same
|
||||||
|
// function the CLI prints). It also asserts the violated property is named, so
|
||||||
|
// a regression that drops the property from the report cannot pass silently.
|
||||||
|
func TestGolden_ViolationSummaryIsReproducible(t *testing.T) {
|
||||||
|
state := newHarnessWithSpec(t, violationSpec)
|
||||||
|
state.verifier = mustSeededVerifier(t, violationSpec, goldenSeed)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
summary, err := Run(ctx, Options{
|
||||||
|
Duration: time.Hour,
|
||||||
|
IdleTimeout: 10 * time.Millisecond,
|
||||||
|
MaxSteps: goldenSteps,
|
||||||
|
Driver: state.mock,
|
||||||
|
Verifier: state.verifier,
|
||||||
|
TraceWriter: state.writer,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rendered bytes.Buffer
|
||||||
|
RenderSummary(&rendered, summary, "android")
|
||||||
|
|
||||||
|
if !strings.Contains(rendered.String(), "balanceNonNegative") {
|
||||||
|
t.Errorf("summary must name the violated property balanceNonNegative, got:\n%s", rendered.String())
|
||||||
|
}
|
||||||
|
assertGolden(t, "violation-summary.txt", rendered.Bytes())
|
||||||
|
}
|
||||||
+112
-105
@@ -1,3 +1,4 @@
|
|||||||
|
// Package runner drives the observe-decide-act loop that steps a spec against a device.
|
||||||
package runner
|
package runner
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -5,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -20,15 +22,15 @@ import (
|
|||||||
"github.com/priyanshujain/sanderling/internal/verifier"
|
"github.com/priyanshujain/sanderling/internal/verifier"
|
||||||
)
|
)
|
||||||
|
|
||||||
// doubleTapGap is the inter-tap delay for ActionKindDoubleTap: short enough to
|
|
||||||
// land both events inside a sub-100 ms race window, long enough for adb
|
|
||||||
// `input tap` to serialize two MotionEvent streams.
|
|
||||||
const doubleTapGap = 50 * time.Millisecond
|
|
||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
Duration time.Duration
|
Duration time.Duration
|
||||||
IdleTimeout time.Duration
|
IdleTimeout time.Duration
|
||||||
|
|
||||||
|
// MaxSteps caps the run at a fixed number of steps for reproducible
|
||||||
|
// bounded runs. 0 means unbounded (the duration deadline governs); a
|
||||||
|
// positive value stops the loop once that many steps have run.
|
||||||
|
MaxSteps int
|
||||||
|
|
||||||
BundleID string
|
BundleID string
|
||||||
Driver driver.DeviceDriver
|
Driver driver.DeviceDriver
|
||||||
Verifier *verifier.Verifier
|
Verifier *verifier.Verifier
|
||||||
@@ -41,6 +43,10 @@ type Summary struct {
|
|||||||
EndTime time.Time
|
EndTime time.Time
|
||||||
Steps int
|
Steps int
|
||||||
Violations []ViolationRecord
|
Violations []ViolationRecord
|
||||||
|
// UnsupportedVerbs lists verbs the picker requested that the platform
|
||||||
|
// could not dispatch, deduped, so the report can flag a spec exercising
|
||||||
|
// gestures this target does not support.
|
||||||
|
UnsupportedVerbs []string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ViolationRecord struct {
|
type ViolationRecord struct {
|
||||||
@@ -65,6 +71,11 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
|||||||
// before the deadline is set so the settle time does not eat the run.
|
// before the deadline is set so the settle time does not eat the run.
|
||||||
waitForForeground(ctx, options, logger)
|
waitForForeground(ctx, options, logger)
|
||||||
|
|
||||||
|
// Pick the action and extractor sources once from the driver's
|
||||||
|
// capabilities so the step loop runs one uniform path with no per-step
|
||||||
|
// driver type assertion.
|
||||||
|
actionSource, extractorSource := pickSources(options)
|
||||||
|
|
||||||
summary := Summary{StartTime: time.Now()}
|
summary := Summary{StartTime: time.Now()}
|
||||||
deadline := summary.StartTime.Add(options.Duration)
|
deadline := summary.StartTime.Add(options.Duration)
|
||||||
stepIndex := 0
|
stepIndex := 0
|
||||||
@@ -74,6 +85,9 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
|||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
if options.MaxSteps > 0 && stepIndex >= options.MaxSteps {
|
||||||
|
break
|
||||||
|
}
|
||||||
stepIndex++
|
stepIndex++
|
||||||
stepStart := time.Now()
|
stepStart := time.Now()
|
||||||
|
|
||||||
@@ -116,17 +130,15 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
var v8Overrides map[int]json.RawMessage
|
var v8Overrides map[int]json.RawMessage
|
||||||
if web, ok := options.Driver.(driver.WebDriver); ok {
|
g.Go(func() error {
|
||||||
g.Go(func() error {
|
overrides, err := extractorSource.ExtractorOverrides(gctx)
|
||||||
overrides, err := web.EvaluateExtractors(gctx)
|
if err != nil {
|
||||||
if err != nil {
|
logger.Warn("v8 extractor evaluation failed", "step", si, "err", err)
|
||||||
logger.Warn("v8 extractor evaluation failed", "step", si, "err", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
v8Overrides = overrides
|
|
||||||
return nil
|
return nil
|
||||||
})
|
}
|
||||||
}
|
v8Overrides = overrides
|
||||||
|
return nil
|
||||||
|
})
|
||||||
// All goroutines write to local variables and return nil, so the Wait
|
// All goroutines write to local variables and return nil, so the Wait
|
||||||
// error is always nil; ignored intentionally.
|
// error is always nil; ignored intentionally.
|
||||||
_ = g.Wait()
|
_ = g.Wait()
|
||||||
@@ -166,6 +178,8 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
|||||||
// progressing.
|
// progressing.
|
||||||
var violations []string
|
var violations []string
|
||||||
var extractorChanges map[string]trace.ExtractorChange
|
var extractorChanges map[string]trace.ExtractorChange
|
||||||
|
var witnesses map[string]trace.Witness
|
||||||
|
skippedVerification := false
|
||||||
if !transitional {
|
if !transitional {
|
||||||
if err := options.Verifier.PushSnapshot(verifier.SnapshotInput{
|
if err := options.Verifier.PushSnapshot(verifier.SnapshotInput{
|
||||||
Tree: tree,
|
Tree: tree,
|
||||||
@@ -186,25 +200,16 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
|||||||
}
|
}
|
||||||
options.Verifier.EvaluateProperties()
|
options.Verifier.EvaluateProperties()
|
||||||
violations = options.Verifier.NewlyViolatedProperties()
|
violations = options.Verifier.NewlyViolatedProperties()
|
||||||
for _, name := range violations {
|
witnesses = collectWitnesses(options.Verifier, violations, logger, stepIndex)
|
||||||
if predicateErr := options.Verifier.PredicateError(name); predicateErr != nil {
|
|
||||||
logger.Warn("predicate error", "step", stepIndex, "property", name, "err", predicateErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
extractorChanges = encodeExtractorChanges(options.Verifier.ChangedExtractors())
|
extractorChanges = encodeExtractorChanges(options.Verifier.ChangedExtractors())
|
||||||
} else {
|
} else {
|
||||||
|
skippedVerification = true
|
||||||
logger.Warn("transitional tree after retry budget; skipping verifier",
|
logger.Warn("transitional tree after retry budget; skipping verifier",
|
||||||
"step", stepIndex, "screen", screen, "nodes", treeSize)
|
"step", stepIndex, "screen", screen, "nodes", treeSize)
|
||||||
}
|
}
|
||||||
logger.Info("step", "index", stepIndex, "screen", screen, "nodes", treeSize)
|
logger.Info("step", "index", stepIndex, "screen", screen, "nodes", treeSize)
|
||||||
|
|
||||||
var nextAction verifier.Action
|
nextAction, nextErr := actionSource.NextAction(ctx)
|
||||||
var nextErr error
|
|
||||||
if web, ok := options.Driver.(driver.WebDriver); ok {
|
|
||||||
nextAction, nextErr = nextActionFromV8(ctx, web)
|
|
||||||
} else {
|
|
||||||
nextAction, nextErr = options.Verifier.NextAction()
|
|
||||||
}
|
|
||||||
var traceAction *trace.Action
|
var traceAction *trace.Action
|
||||||
if nextErr == nil {
|
if nextErr == nil {
|
||||||
traceAction = traceActionFor(nextAction, tree)
|
traceAction = traceActionFor(nextAction, tree)
|
||||||
@@ -240,16 +245,18 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
step := trace.Step{
|
step := trace.Step{
|
||||||
Index: stepIndex,
|
Index: stepIndex,
|
||||||
Timestamp: stepStart,
|
Timestamp: stepStart,
|
||||||
Screen: screen,
|
Screen: screen,
|
||||||
NextAction: traceAction,
|
NextAction: traceAction,
|
||||||
Violations: violations,
|
Violations: violations,
|
||||||
Hierarchy: tree,
|
Hierarchy: tree,
|
||||||
Residuals: residuals,
|
Residuals: residuals,
|
||||||
Metrics: metrics,
|
Metrics: metrics,
|
||||||
ExtractorChanges: extractorChanges,
|
ExtractorChanges: extractorChanges,
|
||||||
Transitional: transitional,
|
Transitional: transitional,
|
||||||
|
SkippedVerification: skippedVerification,
|
||||||
|
Witnesses: witnesses,
|
||||||
}
|
}
|
||||||
if err := options.TraceWriter.WriteStep(step); err != nil {
|
if err := options.TraceWriter.WriteStep(step); err != nil {
|
||||||
return summary, fmt.Errorf("step %d trace: %w", stepIndex, err)
|
return summary, fmt.Errorf("step %d trace: %w", stepIndex, err)
|
||||||
@@ -276,10 +283,51 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Finalize each evaluator once the loop ends so liveness obligations that
|
||||||
|
// never discharged (an unbounded eventually that never fired, a strong
|
||||||
|
// next with no successor) are reported as violations rather than silently
|
||||||
|
// left pending. Properties already violated mid-run are not re-reported.
|
||||||
|
if ended := options.Verifier.Finalize(); len(ended) > 0 {
|
||||||
|
witnesses := collectWitnesses(options.Verifier, ended, logger, stepIndex)
|
||||||
|
summary.Violations = append(summary.Violations, ViolationRecord{
|
||||||
|
StepIndex: stepIndex,
|
||||||
|
Properties: ended,
|
||||||
|
})
|
||||||
|
finalStep := trace.Step{
|
||||||
|
Index: stepIndex,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
Violations: ended,
|
||||||
|
Witnesses: witnesses,
|
||||||
|
}
|
||||||
|
if err := options.TraceWriter.WriteStep(finalStep); err != nil {
|
||||||
|
return summary, fmt.Errorf("finalize trace: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.UnsupportedVerbs = options.Verifier.UnsupportedVerbs()
|
||||||
summary.EndTime = time.Now()
|
summary.EndTime = time.Now()
|
||||||
return summary, nil
|
return summary, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RenderSummary writes the human-facing run summary: step count, each violation
|
||||||
|
// record, and any unsupported verbs. The wall-clock duration is excluded so the
|
||||||
|
// output is deterministic and snapshot-testable; the CLI prints it separately.
|
||||||
|
func RenderSummary(w io.Writer, summary Summary, platform string) {
|
||||||
|
fmt.Fprintf(w, "\nrun complete: %d steps\n", summary.Steps)
|
||||||
|
if len(summary.Violations) == 0 {
|
||||||
|
fmt.Fprintln(w, "no violations.")
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(w, "%d violation record(s):\n", len(summary.Violations))
|
||||||
|
for _, violation := range summary.Violations {
|
||||||
|
fmt.Fprintf(w, " step %d: %v\n", violation.StepIndex, violation.Properties)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(summary.UnsupportedVerbs) > 0 {
|
||||||
|
fmt.Fprintf(w, "unsupported on %s: %s\n",
|
||||||
|
platform, strings.Join(summary.UnsupportedVerbs, ", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func validate(options Options) error {
|
func validate(options Options) error {
|
||||||
if options.Driver == nil {
|
if options.Driver == nil {
|
||||||
return errors.New("runner: Driver is required")
|
return errors.New("runner: Driver is required")
|
||||||
@@ -417,26 +465,13 @@ func applyAction(ctx context.Context, drv driver.DeviceDriver, action verifier.A
|
|||||||
return drv.Tap(ctx, x, y)
|
return drv.Tap(ctx, x, y)
|
||||||
case verifier.ActionKindDoubleTap:
|
case verifier.ActionKindDoubleTap:
|
||||||
x, y, ok := resolveCoordinates(action, tree)
|
x, y, ok := resolveCoordinates(action, tree)
|
||||||
tap := func() error {
|
if !ok {
|
||||||
if !ok {
|
if action.On == "" {
|
||||||
if action.On == "" {
|
return nil
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return drv.TapSelector(ctx, action.On)
|
|
||||||
}
|
}
|
||||||
return drv.Tap(ctx, x, y)
|
return drv.DoubleTapSelector(ctx, action.On)
|
||||||
}
|
}
|
||||||
if err := tap(); err != nil {
|
return drv.DoubleTap(ctx, x, y)
|
||||||
return err
|
|
||||||
}
|
|
||||||
timer := time.NewTimer(doubleTapGap)
|
|
||||||
defer timer.Stop()
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
case <-timer.C:
|
|
||||||
}
|
|
||||||
return tap()
|
|
||||||
case verifier.ActionKindLongPress:
|
case verifier.ActionKindLongPress:
|
||||||
x, y, ok := resolveCoordinates(action, tree)
|
x, y, ok := resolveCoordinates(action, tree)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -748,59 +783,31 @@ func captureMetrics(ctx context.Context, options Options, logger *slog.Logger, s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// nextActionFromV8 invokes the V8-side action generator and decodes the
|
// collectWitnesses gathers the violation witness for each newly-violated
|
||||||
// resulting JSON into a verifier.Action. ErrNoAction is returned when the
|
// property, logs its cause, and returns them keyed by property name for the
|
||||||
// generator declined to act this tick.
|
// trace. Properties without a captured witness are skipped.
|
||||||
func nextActionFromV8(ctx context.Context, web driver.WebDriver) (verifier.Action, error) {
|
func collectWitnesses(verifierInstance *verifier.Verifier, properties []string, logger *slog.Logger, stepIndex int) map[string]trace.Witness {
|
||||||
raw, err := web.NextActionFromV8(ctx)
|
if len(properties) == 0 {
|
||||||
if err != nil {
|
return nil
|
||||||
return verifier.Action{}, fmt.Errorf("v8 next action: %w", err)
|
|
||||||
}
|
}
|
||||||
if len(raw) == 0 || string(raw) == "null" {
|
witnesses := map[string]trace.Witness{}
|
||||||
return verifier.Action{}, verifier.ErrNoAction
|
for _, name := range properties {
|
||||||
|
witness := verifierInstance.Witness(name)
|
||||||
|
if witness == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
logger.Warn("property violated",
|
||||||
|
"step", stepIndex, "property", name, "reason", witness.Reason, "error", witness.IsError)
|
||||||
|
witnesses[name] = trace.Witness{
|
||||||
|
Reason: witness.Reason,
|
||||||
|
IsError: witness.IsError,
|
||||||
|
Extractors: witness.Extractors,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
var decoded struct {
|
if len(witnesses) == 0 {
|
||||||
Kind string `json:"kind"`
|
return nil
|
||||||
X int `json:"x"`
|
|
||||||
Y int `json:"y"`
|
|
||||||
FromX int `json:"from_x"`
|
|
||||||
FromY int `json:"from_y"`
|
|
||||||
ToX int `json:"to_x"`
|
|
||||||
ToY int `json:"to_y"`
|
|
||||||
Key string `json:"key"`
|
|
||||||
Text string `json:"text"`
|
|
||||||
DurationMillis int `json:"duration_millis"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
|
||||||
return verifier.Action{}, fmt.Errorf("decode v8 action: %w", err)
|
|
||||||
}
|
|
||||||
switch decoded.Kind {
|
|
||||||
case "Tap":
|
|
||||||
return verifier.Action{Kind: verifier.ActionKindTap, X: decoded.X, Y: decoded.Y}, nil
|
|
||||||
case "DoubleTap":
|
|
||||||
return verifier.Action{Kind: verifier.ActionKindDoubleTap, X: decoded.X, Y: decoded.Y}, nil
|
|
||||||
case "InputText":
|
|
||||||
return verifier.Action{
|
|
||||||
Kind: verifier.ActionKindInputText,
|
|
||||||
X: decoded.X, Y: decoded.Y,
|
|
||||||
Text: decoded.Text,
|
|
||||||
}, nil
|
|
||||||
case "Swipe":
|
|
||||||
return verifier.Action{
|
|
||||||
Kind: verifier.ActionKindSwipe,
|
|
||||||
FromX: decoded.FromX,
|
|
||||||
FromY: decoded.FromY,
|
|
||||||
ToX: decoded.ToX,
|
|
||||||
ToY: decoded.ToY,
|
|
||||||
DurationMillis: decoded.DurationMillis,
|
|
||||||
}, nil
|
|
||||||
case "PressKey":
|
|
||||||
return verifier.Action{Kind: verifier.ActionKindPressKey, Key: decoded.Key}, nil
|
|
||||||
case "Wait":
|
|
||||||
return verifier.Action{Kind: verifier.ActionKindWait, DurationMillis: decoded.DurationMillis}, nil
|
|
||||||
default:
|
|
||||||
return verifier.Action{}, verifier.ErrNoAction
|
|
||||||
}
|
}
|
||||||
|
return witnesses
|
||||||
}
|
}
|
||||||
|
|
||||||
func encodeExtractorChanges(changes map[string]verifier.ExtractorChange) map[string]trace.ExtractorChange {
|
func encodeExtractorChanges(changes map[string]verifier.ExtractorChange) map[string]trace.ExtractorChange {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
|
"github.com/priyanshujain/sanderling/internal/bundler"
|
||||||
"github.com/priyanshujain/sanderling/internal/driver"
|
"github.com/priyanshujain/sanderling/internal/driver"
|
||||||
mockdriver "github.com/priyanshujain/sanderling/internal/driver/mock"
|
mockdriver "github.com/priyanshujain/sanderling/internal/driver/mock"
|
||||||
"github.com/priyanshujain/sanderling/internal/hierarchy"
|
"github.com/priyanshujain/sanderling/internal/hierarchy"
|
||||||
@@ -26,18 +27,20 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const fixtureSpec = `
|
const fixtureSpec = `
|
||||||
const balance = __sanderling__.extract(state => state.snapshots.balance ?? 0);
|
import { actions, always, extract, Tap } from "@sanderling/spec";
|
||||||
|
const balance = extract(state => state.snapshots.balance ?? 0);
|
||||||
globalThis.properties = {
|
globalThis.properties = {
|
||||||
balanceNonNegative: __sanderling__.always(() => balance.current >= 0),
|
balanceNonNegative: always(() => balance.current >= 0),
|
||||||
};
|
};
|
||||||
globalThis.actions = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:next" })]);
|
globalThis.actions = actions(() => [Tap({ on: "id:next" })]);
|
||||||
`
|
`
|
||||||
|
|
||||||
const violationSpec = `
|
const violationSpec = `
|
||||||
|
import { actions, always } from "@sanderling/spec";
|
||||||
globalThis.properties = {
|
globalThis.properties = {
|
||||||
balanceNonNegative: __sanderling__.always(() => false),
|
balanceNonNegative: always(() => false),
|
||||||
};
|
};
|
||||||
globalThis.actions = __sanderling__.actions(() => []);
|
globalThis.actions = actions(() => []);
|
||||||
`
|
`
|
||||||
|
|
||||||
type harness struct {
|
type harness struct {
|
||||||
@@ -50,6 +53,34 @@ func newHarness(t *testing.T) *harness {
|
|||||||
return newHarnessWithSpec(t, fixtureSpec)
|
return newHarnessWithSpec(t, fixtureSpec)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bundleSpec compiles an authored TS spec with the goja runtime entry so the
|
||||||
|
// loaded bundle installs __sanderlingNextAction__ (the shared picker).
|
||||||
|
func bundleSpec(t *testing.T, specSource string) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
specPath := filepath.Join(dir, "spec.ts")
|
||||||
|
if err := os.WriteFile(specPath, []byte(specSource), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
apiPath, err := filepath.Abs("../../pkg/spec/src/index.ts")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runtimePath, err := filepath.Abs("../../pkg/spec/src/goja-runtime.ts")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bundle, err := bundler.Bundle(bundler.Options{
|
||||||
|
EntryFile: specPath,
|
||||||
|
RuntimeFile: runtimePath,
|
||||||
|
Aliases: map[string]string{"@sanderling/spec": apiPath},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(bundle.JavaScript)
|
||||||
|
}
|
||||||
|
|
||||||
func newHarnessWithSpec(t *testing.T, spec string) *harness {
|
func newHarnessWithSpec(t *testing.T, spec string) *harness {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
@@ -61,7 +92,7 @@ func newHarnessWithSpec(t *testing.T, spec string) *harness {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := verifierInstance.Load(spec); err != nil {
|
if err := verifierInstance.Load(bundleSpec(t, spec)); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
state := &harness{
|
state := &harness{
|
||||||
@@ -94,6 +125,11 @@ func TestRunner_HappyPathStepsAndTraces(t *testing.T) {
|
|||||||
if len(summary.Violations) != 0 {
|
if len(summary.Violations) != 0 {
|
||||||
t.Errorf("no violations expected, got %v", summary.Violations)
|
t.Errorf("no violations expected, got %v", summary.Violations)
|
||||||
}
|
}
|
||||||
|
// Every builtin verb is supported on every platform, so a clean run must
|
||||||
|
// report no unsupported verbs (the runner still wires the field through).
|
||||||
|
if len(summary.UnsupportedVerbs) != 0 {
|
||||||
|
t.Errorf("expected no unsupported verbs, got %v", summary.UnsupportedVerbs)
|
||||||
|
}
|
||||||
|
|
||||||
actions := state.mock.Actions()
|
actions := state.mock.Actions()
|
||||||
if !containsAction(actions, mockdriver.ActionTapSelector, "id:next") {
|
if !containsAction(actions, mockdriver.ActionTapSelector, "id:next") {
|
||||||
@@ -101,6 +137,29 @@ func TestRunner_HappyPathStepsAndTraces(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunner_MaxStepsStopsAfterExactlyNSteps(t *testing.T) {
|
||||||
|
state := newHarness(t)
|
||||||
|
|
||||||
|
const maxSteps = 3
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
// A long duration ensures MaxSteps, not the deadline, ends the run.
|
||||||
|
summary, err := Run(ctx, Options{
|
||||||
|
Duration: time.Hour,
|
||||||
|
IdleTimeout: 10 * time.Millisecond,
|
||||||
|
MaxSteps: maxSteps,
|
||||||
|
Driver: state.mock,
|
||||||
|
Verifier: state.verifier,
|
||||||
|
TraceWriter: state.writer,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run: %v", err)
|
||||||
|
}
|
||||||
|
if summary.Steps != maxSteps {
|
||||||
|
t.Errorf("expected exactly %d steps, got %d", maxSteps, summary.Steps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunner_ViolationSurfacesInSummary(t *testing.T) {
|
func TestRunner_ViolationSurfacesInSummary(t *testing.T) {
|
||||||
state := newHarnessWithSpec(t, violationSpec)
|
state := newHarnessWithSpec(t, violationSpec)
|
||||||
|
|
||||||
@@ -196,10 +255,11 @@ func TestRunner_ViolationSurfacesOnlyOnOnsetStep(t *testing.T) {
|
|||||||
|
|
||||||
func TestRunner_ThrowingPredicateIsLoggedNotPanic(t *testing.T) {
|
func TestRunner_ThrowingPredicateIsLoggedNotPanic(t *testing.T) {
|
||||||
const throwingSpec = `
|
const throwingSpec = `
|
||||||
|
import { actions, always, Tap } from "@sanderling/spec";
|
||||||
globalThis.properties = {
|
globalThis.properties = {
|
||||||
broken: __sanderling__.always(() => { throw new Error("bad predicate"); }),
|
broken: always(() => { throw new Error("bad predicate"); }),
|
||||||
};
|
};
|
||||||
globalThis.actions = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:next" })]);
|
globalThis.actions = actions(() => [Tap({ on: "id:next" })]);
|
||||||
`
|
`
|
||||||
state := newHarnessWithSpec(t, throwingSpec)
|
state := newHarnessWithSpec(t, throwingSpec)
|
||||||
|
|
||||||
@@ -370,30 +430,25 @@ func TestApplyAction_V8InputTextAtOriginStillTaps(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestApplyAction_DoubleTapDispatchesTwoTapsAtCoordinates(t *testing.T) {
|
func TestApplyAction_DoubleTapDispatchesDoubleTapAtCoordinates(t *testing.T) {
|
||||||
driverMock := mockdriver.New()
|
driverMock := mockdriver.New()
|
||||||
action := verifier.Action{Kind: verifier.ActionKindDoubleTap, X: 100, Y: 200}
|
action := verifier.Action{Kind: verifier.ActionKindDoubleTap, X: 100, Y: 200}
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
if err := applyAction(context.Background(), driverMock, action, nil); err != nil {
|
if err := applyAction(context.Background(), driverMock, action, nil); err != nil {
|
||||||
t.Fatalf("apply action: %v", err)
|
t.Fatalf("apply action: %v", err)
|
||||||
}
|
}
|
||||||
elapsed := time.Since(start)
|
|
||||||
if elapsed < 40*time.Millisecond {
|
|
||||||
t.Errorf("expected >= 40ms gap between taps, elapsed %v", elapsed)
|
|
||||||
}
|
|
||||||
taps := 0
|
taps := 0
|
||||||
for _, a := range driverMock.Actions() {
|
for _, a := range driverMock.Actions() {
|
||||||
if a.Kind == mockdriver.ActionTap && a.X == 100 && a.Y == 200 {
|
if a.Kind == mockdriver.ActionDoubleTap && a.X == 100 && a.Y == 200 {
|
||||||
taps++
|
taps++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if taps != 2 {
|
if taps != 1 {
|
||||||
t.Errorf("expected 2 Tap calls at (100,200), got %d in %v", taps, driverMock.Actions())
|
t.Errorf("expected 1 DoubleTap call at (100,200), got %d in %v", taps, driverMock.Actions())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestApplyAction_DoubleTapDispatchesTwoSelectorTaps(t *testing.T) {
|
func TestApplyAction_DoubleTapDispatchesDoubleTapSelector(t *testing.T) {
|
||||||
driverMock := mockdriver.New()
|
driverMock := mockdriver.New()
|
||||||
action := verifier.Action{Kind: verifier.ActionKindDoubleTap, On: "id:save"}
|
action := verifier.Action{Kind: verifier.ActionKindDoubleTap, On: "id:save"}
|
||||||
|
|
||||||
@@ -402,12 +457,12 @@ func TestApplyAction_DoubleTapDispatchesTwoSelectorTaps(t *testing.T) {
|
|||||||
}
|
}
|
||||||
taps := 0
|
taps := 0
|
||||||
for _, a := range driverMock.Actions() {
|
for _, a := range driverMock.Actions() {
|
||||||
if a.Kind == mockdriver.ActionTapSelector && a.Selector == "id:save" {
|
if a.Kind == mockdriver.ActionDoubleTapSelector && a.Selector == "id:save" {
|
||||||
taps++
|
taps++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if taps != 2 {
|
if taps != 1 {
|
||||||
t.Errorf("expected 2 TapSelector calls with id:save, got %d in %v", taps, driverMock.Actions())
|
t.Errorf("expected 1 DoubleTapSelector call with id:save, got %d in %v", taps, driverMock.Actions())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -962,7 +1017,8 @@ func TestIsTransientApplyError_Classification(t *testing.T) {
|
|||||||
// after a Wait action - the action already provides settling time.
|
// after a Wait action - the action already provides settling time.
|
||||||
func TestRunner_WaitActionSkipsIdle(t *testing.T) {
|
func TestRunner_WaitActionSkipsIdle(t *testing.T) {
|
||||||
const waitSpec = `
|
const waitSpec = `
|
||||||
globalThis.actions = __sanderling__.actions(() => [__sanderling__.wait({ durationMillis: 5 })]);
|
import { actions, Wait } from "@sanderling/spec";
|
||||||
|
globalThis.actions = actions(() => [Wait({ durationMillis: 5 })]);
|
||||||
`
|
`
|
||||||
state := newHarnessWithSpec(t, waitSpec)
|
state := newHarnessWithSpec(t, waitSpec)
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/priyanshujain/sanderling/internal/driver"
|
||||||
|
"github.com/priyanshujain/sanderling/internal/verifier"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ActionSource resolves the next action for a step. Both runtimes (the goja
|
||||||
|
// picker and the web/V8 picker) implement it so the runner loop has one path
|
||||||
|
// and no per-step driver type assertion. NextAction returns verifier.ErrNoAction
|
||||||
|
// when the picker declined to act this tick.
|
||||||
|
type ActionSource interface {
|
||||||
|
NextAction(ctx context.Context) (verifier.Action, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractorSource yields per-step extractor overrides the runner applies after
|
||||||
|
// PushSnapshot. The mobile path has none (returns nil); the web path returns the
|
||||||
|
// values its extractors computed in V8 against the real DOM.
|
||||||
|
type ExtractorSource interface {
|
||||||
|
ExtractorOverrides(ctx context.Context) (map[int]json.RawMessage, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// gojaSource drives both action selection and (trivially) extractor overrides
|
||||||
|
// for the mobile path, where the goja-bundled picker runs in-process and no V8
|
||||||
|
// extractor values exist.
|
||||||
|
type gojaSource struct {
|
||||||
|
verifier *verifier.Verifier
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s gojaSource) NextAction(context.Context) (verifier.Action, error) {
|
||||||
|
return s.verifier.NextAction()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gojaSource) ExtractorOverrides(context.Context) (map[int]json.RawMessage, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// webSource adapts the chrome driver's V8 picker: it evaluates the bundled
|
||||||
|
// __sanderlingNextAction__ / __sanderlingExtractors__ globals and decodes their
|
||||||
|
// JSON into the unified action and override shapes.
|
||||||
|
type webSource struct {
|
||||||
|
web driver.WebDriver
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s webSource) NextAction(ctx context.Context) (verifier.Action, error) {
|
||||||
|
raw, err := s.web.NextActionFromV8(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return verifier.Action{}, fmt.Errorf("v8 next action: %w", err)
|
||||||
|
}
|
||||||
|
// Both engines emit the unified flat camelCase wire contract; one decoder
|
||||||
|
// reads it. A null payload means the generator declined to act this tick.
|
||||||
|
return verifier.DecodeAction(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s webSource) ExtractorOverrides(ctx context.Context) (map[int]json.RawMessage, error) {
|
||||||
|
return s.web.EvaluateExtractors(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickSources selects the runtime's action and extractor sources ONCE at setup
|
||||||
|
// from the driver's capabilities, so the step loop never type-asserts.
|
||||||
|
func pickSources(options Options) (ActionSource, ExtractorSource) {
|
||||||
|
if web, ok := options.Driver.(driver.WebDriver); ok {
|
||||||
|
source := webSource{web: web}
|
||||||
|
return source, source
|
||||||
|
}
|
||||||
|
source := gojaSource{verifier: options.Verifier}
|
||||||
|
return source, source
|
||||||
|
}
|
||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
{"extractor_changes":{"extractor_0":{"prev":null,"curr":0}},"hierarchy":{"elements":[{"resourceId":"HomeScreen","bounds":{"left":0,"top":0,"right":0,"bottom":0},"attrs":{"editable":"false","resource-id":"HomeScreen"}},{"resourceId":"next","clickable":true,"enabled":true,"bounds":{"left":40,"top":80,"right":240,"bottom":160},"attrs":{"bounds":"[40,80,240,160]","clickable":"true","editable":"false","enabled":"true","resource-id":"next"}}]},"next_action":{"kind":"Tap","selector":"id:next","resolved_bounds":{"x":40,"y":80,"width":200,"height":80},"tap_point":{"x":140,"y":120}},"residuals":{"balanceNonNegative":{"op":"true"}},"step":1,"timestamp":"0001-01-01T00:00:00Z"}
|
||||||
|
{"hierarchy":{"elements":[{"resourceId":"HomeScreen","bounds":{"left":0,"top":0,"right":0,"bottom":0},"attrs":{"editable":"false","resource-id":"HomeScreen"}},{"resourceId":"next","clickable":true,"enabled":true,"bounds":{"left":40,"top":80,"right":240,"bottom":160},"attrs":{"bounds":"[40,80,240,160]","clickable":"true","editable":"false","enabled":"true","resource-id":"next"}}]},"next_action":{"kind":"Tap","selector":"id:next","resolved_bounds":{"x":40,"y":80,"width":200,"height":80},"tap_point":{"x":140,"y":120}},"residuals":{"balanceNonNegative":{"op":"true"}},"step":2,"timestamp":"0001-01-01T00:00:00Z"}
|
||||||
|
{"hierarchy":{"elements":[{"resourceId":"HomeScreen","bounds":{"left":0,"top":0,"right":0,"bottom":0},"attrs":{"editable":"false","resource-id":"HomeScreen"}},{"resourceId":"next","clickable":true,"enabled":true,"bounds":{"left":40,"top":80,"right":240,"bottom":160},"attrs":{"bounds":"[40,80,240,160]","clickable":"true","editable":"false","enabled":"true","resource-id":"next"}}]},"next_action":{"kind":"Tap","selector":"id:next","resolved_bounds":{"x":40,"y":80,"width":200,"height":80},"tap_point":{"x":140,"y":120}},"residuals":{"balanceNonNegative":{"op":"true"}},"step":3,"timestamp":"0001-01-01T00:00:00Z"}
|
||||||
|
{"hierarchy":{"elements":[{"resourceId":"HomeScreen","bounds":{"left":0,"top":0,"right":0,"bottom":0},"attrs":{"editable":"false","resource-id":"HomeScreen"}},{"resourceId":"next","clickable":true,"enabled":true,"bounds":{"left":40,"top":80,"right":240,"bottom":160},"attrs":{"bounds":"[40,80,240,160]","clickable":"true","editable":"false","enabled":"true","resource-id":"next"}}]},"next_action":{"kind":"Tap","selector":"id:next","resolved_bounds":{"x":40,"y":80,"width":200,"height":80},"tap_point":{"x":140,"y":120}},"residuals":{"balanceNonNegative":{"op":"true"}},"step":4,"timestamp":"0001-01-01T00:00:00Z"}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
run complete: 4 steps
|
||||||
|
1 violation record(s):
|
||||||
|
step 1: [balanceNonNegative]
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
//go:build !withsidecar
|
|
||||||
|
|
||||||
package sidecar
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestStub_IsPlaceholder(t *testing.T) {
|
|
||||||
if !IsPlaceholder() {
|
|
||||||
t.Errorf("default build should be a placeholder")
|
|
||||||
}
|
|
||||||
if EmbeddedSize() != 0 {
|
|
||||||
t.Errorf("stub should have empty embedded JAR, got size %d", EmbeddedSize())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStub_ExtractFails(t *testing.T) {
|
|
||||||
if _, err := Extract(t.TempDir()); err == nil {
|
|
||||||
t.Fatal("expected Extract to fail in stub mode")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
package sidecar
|
// Package sidecarassets embeds the native sidecar JAR and extracts it to disk at runtime.
|
||||||
|
package sidecarassets
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//go:build withsidecar
|
//go:build withsidecar
|
||||||
|
|
||||||
package sidecar
|
package sidecarassets
|
||||||
|
|
||||||
import _ "embed"
|
import _ "embed"
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//go:build !withsidecar
|
//go:build !withsidecar
|
||||||
|
|
||||||
package sidecar
|
package sidecarassets
|
||||||
|
|
||||||
var embeddedJAR []byte
|
var embeddedJAR []byte
|
||||||
|
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
//go:build withsidecar
|
//go:build withsidecar
|
||||||
|
|
||||||
package sidecar
|
package sidecarassets
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
"github.com/priyanshujain/sanderling/internal/driver/chrome"
|
"github.com/priyanshujain/sanderling/internal/driver/chrome"
|
||||||
driverSidecar "github.com/priyanshujain/sanderling/internal/driver/sidecar"
|
driverSidecar "github.com/priyanshujain/sanderling/internal/driver/sidecar"
|
||||||
"github.com/priyanshujain/sanderling/internal/ios"
|
"github.com/priyanshujain/sanderling/internal/ios"
|
||||||
"github.com/priyanshujain/sanderling/internal/sidecar"
|
"github.com/priyanshujain/sanderling/internal/sidecarassets"
|
||||||
)
|
)
|
||||||
|
|
||||||
// buildDriver creates the appropriate DeviceDriver for the platform and returns
|
// buildDriver creates the appropriate DeviceDriver for the platform and returns
|
||||||
@@ -30,11 +30,11 @@ func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver
|
|||||||
}
|
}
|
||||||
|
|
||||||
sidecarDirectory := os.TempDir() + "/sanderling-sidecar"
|
sidecarDirectory := os.TempDir() + "/sanderling-sidecar"
|
||||||
jarPath, err := sidecar.Extract(sidecarDirectory)
|
jarPath, err := sidecarassets.Extract(sidecarDirectory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("extract sidecar: %w", err)
|
return nil, nil, fmt.Errorf("extract sidecar: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(stdout, "sidecar JAR: %s (size=%d)\n", jarPath, sidecar.EmbeddedSize())
|
fmt.Fprintf(stdout, "sidecar JAR: %s (size=%d)\n", jarPath, sidecarassets.EmbeddedSize())
|
||||||
|
|
||||||
sidecarPort, err := pickFreePort()
|
sidecarPort, err := pickFreePort()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+41
-23
@@ -1,12 +1,13 @@
|
|||||||
|
// Package testrun wires together the device, bundler, runner, and verifier into a single test pipeline.
|
||||||
package testrun
|
package testrun
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math/rand/v2"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/priyanshujain/sanderling/internal/android"
|
"github.com/priyanshujain/sanderling/internal/android"
|
||||||
@@ -53,14 +54,21 @@ func Execute(ctx context.Context, options Options, stdout io.Writer) error {
|
|||||||
aliases["@sanderling/spec/defaults"] = filepath.Join(base, "defaults/index.ts")
|
aliases["@sanderling/spec/defaults"] = filepath.Join(base, "defaults/index.ts")
|
||||||
aliases["@sanderling/spec/defaults/properties"] = filepath.Join(base, "defaults/properties.ts")
|
aliases["@sanderling/spec/defaults/properties"] = filepath.Join(base, "defaults/properties.ts")
|
||||||
}
|
}
|
||||||
|
seed := resolveSeed(options.Seed)
|
||||||
defines := map[string]string{
|
defines := map[string]string{
|
||||||
"SANDERLING_TEST_PHONE": os.Getenv("SANDERLING_TEST_PHONE"),
|
"SANDERLING_TEST_PHONE": os.Getenv("SANDERLING_TEST_PHONE"),
|
||||||
"SANDERLING_TEST_OTP": os.Getenv("SANDERLING_TEST_OTP"),
|
"SANDERLING_TEST_OTP": os.Getenv("SANDERLING_TEST_OTP"),
|
||||||
|
"SANDERLING_SEED": strconv.FormatInt(seed, 10),
|
||||||
|
}
|
||||||
|
gojaRuntimePath := resolveGojaRuntimePath(specAPIPath, options.Spec)
|
||||||
|
if gojaRuntimePath == "" {
|
||||||
|
return fmt.Errorf("goja-runtime.ts not found near %s; checkout pkg/spec or set @sanderling/spec alias", options.Spec)
|
||||||
}
|
}
|
||||||
bundle, err := bundler.Bundle(bundler.Options{
|
bundle, err := bundler.Bundle(bundler.Options{
|
||||||
EntryFile: options.Spec,
|
EntryFile: options.Spec,
|
||||||
Defines: defines,
|
RuntimeFile: gojaRuntimePath,
|
||||||
Aliases: aliases,
|
Defines: defines,
|
||||||
|
Aliases: aliases,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("bundle spec: %w", err)
|
return fmt.Errorf("bundle spec: %w", err)
|
||||||
@@ -101,12 +109,9 @@ func Execute(ctx context.Context, options Options, stdout io.Writer) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
seed := options.Seed
|
|
||||||
if seed == 0 {
|
|
||||||
seed = time.Now().UnixNano()
|
|
||||||
}
|
|
||||||
verifierInstance, err := verifier.New(
|
verifierInstance, err := verifier.New(
|
||||||
verifier.WithRand(rand.New(rand.NewPCG(uint64(seed), 0))),
|
verifier.WithSeed(uint64(seed)),
|
||||||
|
verifier.WithPlatform(options.Platform),
|
||||||
verifier.WithAppPackage(options.BundleID),
|
verifier.WithAppPackage(options.BundleID),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -161,25 +166,38 @@ func Execute(ctx context.Context, options Options, stdout io.Writer) error {
|
|||||||
return fmt.Errorf("runner: %w", err)
|
return fmt.Errorf("runner: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintf(stdout, "\nrun complete: %d steps in %s\n", summary.Steps, summary.EndTime.Sub(summary.StartTime).Round(time.Millisecond))
|
fmt.Fprintf(stdout, "\nelapsed: %s\n", summary.EndTime.Sub(summary.StartTime).Round(time.Millisecond))
|
||||||
if len(summary.Violations) == 0 {
|
runner.RenderSummary(stdout, summary, options.Platform)
|
||||||
fmt.Fprintln(stdout, "no violations.")
|
|
||||||
} else {
|
|
||||||
fmt.Fprintf(stdout, "%d violation record(s):\n", len(summary.Violations))
|
|
||||||
for _, violation := range summary.Violations {
|
|
||||||
fmt.Fprintf(stdout, " step %d: %v\n", violation.StepIndex, violation.Properties)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveSeed returns the configured seed, or a time-derived one when unset.
|
||||||
|
// The same value seeds both the goja PRNG and the web bundle's SANDERLING_SEED
|
||||||
|
// define, so a single run is reproducible across both runtimes.
|
||||||
|
func resolveSeed(configured int64) int64 {
|
||||||
|
if configured != 0 {
|
||||||
|
return configured
|
||||||
|
}
|
||||||
|
return time.Now().UnixNano()
|
||||||
|
}
|
||||||
|
|
||||||
// resolveWebRuntimePath returns the path to pkg/spec/src/web-runtime.ts.
|
// resolveWebRuntimePath returns the path to pkg/spec/src/web-runtime.ts.
|
||||||
// Tries the spec-API checkout first (so monorepo development works without
|
|
||||||
// publishing the package), then falls back to a sibling of the resolved
|
|
||||||
// @sanderling/spec entry, and finally to a node_modules path.
|
|
||||||
func resolveWebRuntimePath(specAPIPath, userSpecPath string) string {
|
func resolveWebRuntimePath(specAPIPath, userSpecPath string) string {
|
||||||
|
return resolveRuntimeSibling(specAPIPath, userSpecPath, "web-runtime.ts")
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveGojaRuntimePath returns the path to pkg/spec/src/goja-runtime.ts, the
|
||||||
|
// native verifier's runtime entry that installs __sanderlingNextAction__.
|
||||||
|
func resolveGojaRuntimePath(specAPIPath, userSpecPath string) string {
|
||||||
|
return resolveRuntimeSibling(specAPIPath, userSpecPath, "goja-runtime.ts")
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveRuntimeSibling finds a runtime-entry file that sits beside the spec-API
|
||||||
|
// index.ts. Tries the spec-API checkout first (so monorepo development works
|
||||||
|
// without publishing the package), then falls back to a node_modules path.
|
||||||
|
func resolveRuntimeSibling(specAPIPath, userSpecPath, filename string) string {
|
||||||
if specAPIPath != "" {
|
if specAPIPath != "" {
|
||||||
candidate := filepath.Join(filepath.Dir(specAPIPath), "web-runtime.ts")
|
candidate := filepath.Join(filepath.Dir(specAPIPath), filename)
|
||||||
if _, err := os.Stat(candidate); err == nil {
|
if _, err := os.Stat(candidate); err == nil {
|
||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
@@ -187,7 +205,7 @@ func resolveWebRuntimePath(specAPIPath, userSpecPath string) string {
|
|||||||
if absoluteSpec, err := filepath.Abs(userSpecPath); err == nil {
|
if absoluteSpec, err := filepath.Abs(userSpecPath); err == nil {
|
||||||
directory := filepath.Dir(absoluteSpec)
|
directory := filepath.Dir(absoluteSpec)
|
||||||
for {
|
for {
|
||||||
candidate := filepath.Join(directory, "node_modules", "@sanderling", "spec", "src", "web-runtime.ts")
|
candidate := filepath.Join(directory, "node_modules", "@sanderling", "spec", "src", filename)
|
||||||
if _, err := os.Stat(candidate); err == nil {
|
if _, err := os.Stat(candidate); err == nil {
|
||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,31 @@ package testrun
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestResolveSeed_UsesConfiguredWhenNonZero(t *testing.T) {
|
||||||
|
if got := resolveSeed(42); got != 42 {
|
||||||
|
t.Fatalf("got %d, want 42", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSeed_DerivesWhenZero(t *testing.T) {
|
||||||
|
if got := resolveSeed(0); got == 0 {
|
||||||
|
t.Fatal("expected a non-zero time-derived seed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSeedDefineFormatsAsDecimal guards the contract that Execute uses to put
|
||||||
|
// the seed into the web bundle: strconv.FormatInt base 10, which web-runtime
|
||||||
|
// folds to a 32-bit PRNG seed.
|
||||||
|
func TestSeedDefineFormatsAsDecimal(t *testing.T) {
|
||||||
|
if got := strconv.FormatInt(resolveSeed(8675309), 10); got != "8675309" {
|
||||||
|
t.Fatalf("got %q, want 8675309", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveSpecAPIPath_FindsUpwardSibling(t *testing.T) {
|
func TestResolveSpecAPIPath_FindsUpwardSibling(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
apiPath := filepath.Join(root, "pkg", "spec", "src", "index.ts")
|
apiPath := filepath.Join(root, "pkg", "spec", "src", "index.ts")
|
||||||
|
|||||||
+35
-19
@@ -1,3 +1,4 @@
|
|||||||
|
// Package trace records each run's steps, snapshots, and violations to disk for later inspection.
|
||||||
package trace
|
package trace
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -13,23 +14,38 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Step struct {
|
type Step struct {
|
||||||
Index int `json:"step"`
|
Index int `json:"step"`
|
||||||
Timestamp time.Time `json:"timestamp"`
|
Timestamp time.Time `json:"timestamp"`
|
||||||
Screen string `json:"screen,omitempty"`
|
Screen string `json:"screen,omitempty"`
|
||||||
Snapshots map[string]json.RawMessage `json:"snapshots,omitempty"`
|
Snapshots map[string]json.RawMessage `json:"snapshots,omitempty"`
|
||||||
// NextAction is the action chosen for the next iteration based on observing this step.
|
// NextAction is the action chosen for the next iteration based on observing this step.
|
||||||
NextAction *Action `json:"next_action,omitempty"`
|
NextAction *Action `json:"next_action,omitempty"`
|
||||||
Exceptions []Exception `json:"exceptions,omitempty"`
|
Exceptions []Exception `json:"exceptions,omitempty"`
|
||||||
Violations []string `json:"violations,omitempty"`
|
Violations []string `json:"violations,omitempty"`
|
||||||
Hierarchy *hierarchy.Tree `json:"hierarchy,omitempty"`
|
Hierarchy *hierarchy.Tree `json:"hierarchy,omitempty"`
|
||||||
Residuals map[string]json.RawMessage `json:"residuals,omitempty"`
|
Residuals map[string]json.RawMessage `json:"residuals,omitempty"`
|
||||||
Metrics *Metrics `json:"metrics,omitempty"`
|
Metrics *Metrics `json:"metrics,omitempty"`
|
||||||
ExtractorChanges map[string]ExtractorChange `json:"extractor_changes,omitempty"`
|
ExtractorChanges map[string]ExtractorChange `json:"extractor_changes,omitempty"`
|
||||||
// Transitional marks a step whose hierarchy still showed a NavHost
|
// Transitional marks a step whose hierarchy still showed a NavHost
|
||||||
// cross-fade (multiple route-level *Screen ids) after the runner's
|
// cross-fade (multiple route-level *Screen ids) after the runner's
|
||||||
// retry budget. The verifier is skipped for these steps so transient
|
// retry budget. The verifier is skipped for these steps so transient
|
||||||
// state does not poison the previous/current extractor advance.
|
// state does not poison the previous/current extractor advance.
|
||||||
Transitional bool `json:"transitional,omitempty"`
|
Transitional bool `json:"transitional,omitempty"`
|
||||||
|
// SkippedVerification is set true exactly when the verifier was skipped
|
||||||
|
// for this step, so downstream tooling can tell a deliberately-skipped
|
||||||
|
// step from one that was verified and came back clean.
|
||||||
|
SkippedVerification bool `json:"skipped_verification,omitempty"`
|
||||||
|
// Witnesses records the violation witness for each property that newly
|
||||||
|
// violated at this step: the cause and the extractor values at onset.
|
||||||
|
Witnesses map[string]Witness `json:"witnesses,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Witness is the trace-side record of a property violation: why it fired and a
|
||||||
|
// snapshot of every extractor's value at the violating step.
|
||||||
|
type Witness struct {
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
IsError bool `json:"is_error,omitempty"`
|
||||||
|
Extractors map[string]json.RawMessage `json:"extractors,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtractorChange records the prev/curr JSON values of an extractor whose
|
// ExtractorChange records the prev/curr JSON values of an extractor whose
|
||||||
@@ -83,14 +99,14 @@ type Exception struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Meta struct {
|
type Meta struct {
|
||||||
Seed int64 `json:"seed"`
|
Seed int64 `json:"seed"`
|
||||||
SpecPath string `json:"spec_path"`
|
SpecPath string `json:"spec_path"`
|
||||||
BundleSHA256 string `json:"bundle_sha256"`
|
BundleSHA256 string `json:"bundle_sha256"`
|
||||||
Platform string `json:"platform"`
|
Platform string `json:"platform"`
|
||||||
BundleID string `json:"bundle_id"`
|
BundleID string `json:"bundle_id"`
|
||||||
StartedAt time.Time `json:"started_at"`
|
StartedAt time.Time `json:"started_at"`
|
||||||
EndedAt *time.Time `json:"ended_at,omitempty"`
|
EndedAt *time.Time `json:"ended_at,omitempty"`
|
||||||
SanderlingVersion string `json:"sanderling_version"`
|
SanderlingVersion string `json:"sanderling_version"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Writer struct {
|
type Writer struct {
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ func TestStateAxObjectSelectorTestTagAlias(t *testing.T) {
|
|||||||
|
|
||||||
// TestStateAxFindWorks verifies that a Parse+PushSnapshot+extract round trip
|
// TestStateAxFindWorks verifies that a Parse+PushSnapshot+extract round trip
|
||||||
// actually lets the spec resolve selectors through state.ax.find.
|
// actually lets the spec resolve selectors through state.ax.find.
|
||||||
// Reads /tmp/live-dump.json (Maestro TreeNode JSON format); skipped if absent.
|
// Reads /tmp/live-dump.json (sidecar TreeNode JSON format); skipped if absent.
|
||||||
func TestStateAxFindWorks(t *testing.T) {
|
func TestStateAxFindWorks(t *testing.T) {
|
||||||
jsonText, err := os.ReadFile("/tmp/live-dump.json")
|
jsonText, err := os.ReadFile("/tmp/live-dump.json")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+105
-245
@@ -2,6 +2,7 @@ package verifier
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/big"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
@@ -9,8 +10,10 @@ import (
|
|||||||
|
|
||||||
type extractorState struct {
|
type extractorState struct {
|
||||||
getter goja.Callable
|
getter goja.Callable
|
||||||
handle *goja.Object
|
|
||||||
name string
|
name string
|
||||||
|
// currentValue/previousValue back the handle's current/previous accessors.
|
||||||
|
currentValue goja.Value
|
||||||
|
previousValue goja.Value
|
||||||
// prev/curr cache the JSON-encoded extractor values from the prior and
|
// prev/curr cache the JSON-encoded extractor values from the prior and
|
||||||
// current PushSnapshot, used by ChangedExtractors to surface per-step
|
// current PushSnapshot, used by ChangedExtractors to surface per-step
|
||||||
// diffs in the trace.
|
// diffs in the trace.
|
||||||
@@ -20,11 +23,6 @@ type extractorState struct {
|
|||||||
|
|
||||||
type formulaState struct {
|
type formulaState struct {
|
||||||
predicate goja.Callable
|
predicate goja.Callable
|
||||||
// err holds the goja error from this thunk's most recent invocation, or
|
|
||||||
// nil if the latest call succeeded. The thunk returns false on error so
|
|
||||||
// the LTL evaluator marks the property violated; PredicateError surfaces
|
|
||||||
// the underlying cause for the current step.
|
|
||||||
err error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type specKind int
|
type specKind int
|
||||||
@@ -62,20 +60,7 @@ type formulaSpec struct {
|
|||||||
const (
|
const (
|
||||||
tagFormula = "__sanderlingFormula"
|
tagFormula = "__sanderlingFormula"
|
||||||
tagFormulaSpecIndex = "__sanderlingFormulaSpec"
|
tagFormulaSpecIndex = "__sanderlingFormulaSpec"
|
||||||
tagActionGenerator = "__sanderlingActionGenerator"
|
|
||||||
tagInternalKind = "__sanderlingKind"
|
|
||||||
tagSelector = "__sanderlingSelector"
|
tagSelector = "__sanderlingSelector"
|
||||||
|
|
||||||
internalKindActions = "actions"
|
|
||||||
internalKindWeighted = "weighted"
|
|
||||||
internalKindBuiltinTaps = "taps"
|
|
||||||
internalKindBuiltinDoubleTaps = "doubleTaps"
|
|
||||||
internalKindBuiltinTyping = "typing"
|
|
||||||
internalKindBuiltinSwipes = "swipes"
|
|
||||||
internalKindBuiltinWaitOnce = "waitOnce"
|
|
||||||
internalKindBuiltinPressKey = "pressKey"
|
|
||||||
internalKindBuiltinLongPresses = "longPresses"
|
|
||||||
internalKindBuiltinScrolls = "scrolls"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// installRuntimeBindings exposes globalThis.__sanderling__ to the loaded spec.
|
// installRuntimeBindings exposes globalThis.__sanderling__ to the loaded spec.
|
||||||
@@ -97,65 +82,75 @@ func (v *Verifier) installRuntimeBindings() error {
|
|||||||
if err := sanderling.Set("eventually", v.bindEventually); err != nil {
|
if err := sanderling.Set("eventually", v.bindEventually); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := sanderling.Set("actions", v.bindActions); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := sanderling.Set("weighted", v.bindWeighted); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := sanderling.Set("from", v.bindFrom); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := sanderling.Set("tap", v.bindTap); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := sanderling.Set("doubleTap", v.bindDoubleTap); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := sanderling.Set("longPress", v.bindLongPress); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := sanderling.Set("scroll", v.bindScroll); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := sanderling.Set("inputText", v.bindInputText); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := sanderling.Set("swipe", v.bindSwipe); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := sanderling.Set("pressKey", v.bindPressKey); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := sanderling.Set("wait", v.bindWait); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := sanderling.Set("taps", v.builtinGenerator(internalKindBuiltinTaps)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := sanderling.Set("doubleTaps", v.builtinGenerator(internalKindBuiltinDoubleTaps)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := sanderling.Set("typing", v.builtinGenerator(internalKindBuiltinTyping)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := sanderling.Set("swipes", v.builtinGenerator(internalKindBuiltinSwipes)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := sanderling.Set("waitOnce", v.builtinGenerator(internalKindBuiltinWaitOnce)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := sanderling.Set("pressKeys", v.builtinGenerator(internalKindBuiltinPressKey)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := sanderling.Set("longPresses", v.builtinGenerator(internalKindBuiltinLongPresses)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := sanderling.Set("scrolls", v.builtinGenerator(internalKindBuiltinScrolls)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return v.runtime.GlobalObject().Set("__sanderling__", sanderling)
|
if err := v.runtime.GlobalObject().Set("__sanderling__", sanderling); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return v.installHost()
|
||||||
|
}
|
||||||
|
|
||||||
|
// installHost exposes globalThis.__sanderlingHost__ for the goja runtime entry.
|
||||||
|
// The shared picker (pick.ts) draws against it: platform() drives the verb
|
||||||
|
// matrix and press-key pool; seedHi/seedLo construct its Pcg; queryCandidates
|
||||||
|
// enumerates targets over the hierarchy tree; reportUnsupported records the
|
||||||
|
// verb for the run report.
|
||||||
|
func (v *Verifier) installHost() error {
|
||||||
|
host := v.runtime.NewObject()
|
||||||
|
if err := host.Set("platform", func(goja.FunctionCall) goja.Value {
|
||||||
|
return v.runtime.ToValue(v.platform)
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := host.Set("seedHi", func(goja.FunctionCall) goja.Value {
|
||||||
|
return v.runtime.ToValue(new(big.Int).SetUint64(v.seed))
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := host.Set("seedLo", func(goja.FunctionCall) goja.Value {
|
||||||
|
return v.runtime.ToValue(big.NewInt(0))
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := host.Set("queryCandidates", v.bindQueryCandidates); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := host.Set("reportUnsupported", func(call goja.FunctionCall) goja.Value {
|
||||||
|
v.recordUnsupported(call.Argument(0).String())
|
||||||
|
return goja.Undefined()
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return v.runtime.GlobalObject().Set("__sanderlingHost__", host)
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordUnsupported notes a verb the picker requested that this platform
|
||||||
|
// cannot dispatch, deduped and in first-seen order.
|
||||||
|
func (v *Verifier) recordUnsupported(verb string) {
|
||||||
|
if verb == "" || v.unsupportedSeen[verb] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v.unsupportedSeen[verb] = true
|
||||||
|
v.unsupported = append(v.unsupported, verb)
|
||||||
|
}
|
||||||
|
|
||||||
|
// bindQueryCandidates returns the host-enumerated targets for a verb as an
|
||||||
|
// array of {x, y, selector, width, height}, in tree order.
|
||||||
|
func (v *Verifier) bindQueryCandidates(call goja.FunctionCall) goja.Value {
|
||||||
|
verb := call.Argument(0).String()
|
||||||
|
candidates := v.candidatesForVerb(verb)
|
||||||
|
array := v.runtime.NewArray()
|
||||||
|
for index, candidate := range candidates {
|
||||||
|
item := v.runtime.NewObject()
|
||||||
|
_ = item.Set("x", candidate.x)
|
||||||
|
_ = item.Set("y", candidate.y)
|
||||||
|
_ = item.Set("width", candidate.width)
|
||||||
|
_ = item.Set("height", candidate.height)
|
||||||
|
if candidate.selector != "" {
|
||||||
|
_ = item.Set("selector", candidate.selector)
|
||||||
|
}
|
||||||
|
_ = array.Set(fmt.Sprintf("%d", index), item)
|
||||||
|
}
|
||||||
|
return array
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *Verifier) bindExtract(call goja.FunctionCall) goja.Value {
|
func (v *Verifier) bindExtract(call goja.FunctionCall) goja.Value {
|
||||||
@@ -177,14 +172,43 @@ func (v *Verifier) bindExtract(call goja.FunctionCall) goja.Value {
|
|||||||
name = fmt.Sprintf("extractor_%d", len(v.extractors))
|
name = fmt.Sprintf("extractor_%d", len(v.extractors))
|
||||||
}
|
}
|
||||||
|
|
||||||
handle := v.runtime.NewObject()
|
state := &extractorState{
|
||||||
_ = handle.Set("current", goja.Undefined())
|
getter: getter,
|
||||||
_ = handle.Set("previous", goja.Undefined())
|
name: name,
|
||||||
|
currentValue: goja.Undefined(),
|
||||||
|
previousValue: goja.Undefined(),
|
||||||
|
}
|
||||||
|
|
||||||
v.extractors = append(v.extractors, &extractorState{getter: getter, handle: handle, name: name})
|
handle := v.runtime.NewObject()
|
||||||
|
_ = handle.DefineAccessorProperty("current", v.runtime.ToValue(func(goja.FunctionCall) goja.Value {
|
||||||
|
v.checkNotExtracting("current")
|
||||||
|
return state.currentValue
|
||||||
|
}), nil, goja.FLAG_FALSE, goja.FLAG_TRUE)
|
||||||
|
_ = handle.DefineAccessorProperty("previous", v.runtime.ToValue(func(goja.FunctionCall) goja.Value {
|
||||||
|
v.checkNotExtracting("previous")
|
||||||
|
return state.previousValue
|
||||||
|
}), nil, goja.FLAG_FALSE, goja.FLAG_TRUE)
|
||||||
|
_ = handle.Set("named", func(call goja.FunctionCall) goja.Value {
|
||||||
|
state.name = call.Argument(0).String()
|
||||||
|
return handle
|
||||||
|
})
|
||||||
|
|
||||||
|
v.extractors = append(v.extractors, state)
|
||||||
return handle
|
return handle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkNotExtracting panics with a JS error when an extractor getter tries to
|
||||||
|
// read another extractor handle's current/previous. The message is identical to
|
||||||
|
// the web runtime's so authors see one diagnostic across engines.
|
||||||
|
func (v *Verifier) checkNotExtracting(slot string) {
|
||||||
|
if v.extracting {
|
||||||
|
panic(v.runtime.NewGoError(fmt.Errorf(
|
||||||
|
"reading .%s of an extractor inside another extractor is not allowed; extractor getters may read only from the state argument",
|
||||||
|
slot,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// bindAlways accepts either a predicate function (legacy shape) or a formula
|
// bindAlways accepts either a predicate function (legacy shape) or a formula
|
||||||
// handle (new shape). Both produce a formula handle tagged with
|
// handle (new shape). Both produce a formula handle tagged with
|
||||||
// __sanderlingFormulaSpec.
|
// __sanderlingFormulaSpec.
|
||||||
@@ -273,9 +297,6 @@ func (v *Verifier) formulaHandle(kind specKind, index int) *goja.Object {
|
|||||||
handle := v.runtime.NewObject()
|
handle := v.runtime.NewObject()
|
||||||
_ = handle.Set(tagFormula, true)
|
_ = handle.Set(tagFormula, true)
|
||||||
_ = handle.Set(tagFormulaSpecIndex, index)
|
_ = handle.Set(tagFormulaSpecIndex, index)
|
||||||
// Keep __sanderlingIndex as an alias so older property shapes that read it keep
|
|
||||||
// working during backward-compat transitions.
|
|
||||||
_ = handle.Set("__sanderlingIndex", index)
|
|
||||||
|
|
||||||
_ = handle.Set("implies", v.binaryChain(index, specKindImplies))
|
_ = handle.Set("implies", v.binaryChain(index, specKindImplies))
|
||||||
_ = handle.Set("or", v.binaryChain(index, specKindOr))
|
_ = handle.Set("or", v.binaryChain(index, specKindOr))
|
||||||
@@ -356,164 +377,3 @@ func (v *Verifier) extractSpecIndex(value goja.Value) (int, bool) {
|
|||||||
}
|
}
|
||||||
return int(indexValue.ToInteger()), true
|
return int(indexValue.ToInteger()), true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *Verifier) bindActions(call goja.FunctionCall) goja.Value {
|
|
||||||
if len(call.Arguments) != 1 {
|
|
||||||
panic(v.runtime.NewTypeError("actions requires a single generator argument"))
|
|
||||||
}
|
|
||||||
if _, ok := goja.AssertFunction(call.Arguments[0]); !ok {
|
|
||||||
panic(v.runtime.NewTypeError("actions argument must be a function"))
|
|
||||||
}
|
|
||||||
handle := v.runtime.NewObject()
|
|
||||||
_ = handle.Set(tagActionGenerator, true)
|
|
||||||
_ = handle.Set(tagInternalKind, internalKindActions)
|
|
||||||
_ = handle.Set("generate", call.Arguments[0])
|
|
||||||
return handle
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *Verifier) bindWeighted(call goja.FunctionCall) goja.Value {
|
|
||||||
entries := v.runtime.NewArray()
|
|
||||||
for index, argument := range call.Arguments {
|
|
||||||
object := argument.ToObject(v.runtime)
|
|
||||||
if object == nil {
|
|
||||||
panic(v.runtime.NewTypeError(fmt.Sprintf("weighted entry %d must be a [number, generator] tuple", index)))
|
|
||||||
}
|
|
||||||
if err := entries.Set(fmt.Sprintf("%d", index), object); err != nil {
|
|
||||||
panic(v.runtime.NewGoError(err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
handle := v.runtime.NewObject()
|
|
||||||
_ = handle.Set(tagActionGenerator, true)
|
|
||||||
_ = handle.Set(tagInternalKind, internalKindWeighted)
|
|
||||||
_ = handle.Set("entries", entries)
|
|
||||||
return handle
|
|
||||||
}
|
|
||||||
|
|
||||||
// bindFrom returns a `{ generate }` that picks uniformly at random from the
|
|
||||||
// provided items using the verifier's seeded rng.
|
|
||||||
func (v *Verifier) bindFrom(call goja.FunctionCall) goja.Value {
|
|
||||||
if len(call.Arguments) != 1 {
|
|
||||||
panic(v.runtime.NewTypeError("from requires an array argument"))
|
|
||||||
}
|
|
||||||
itemsValue := call.Arguments[0]
|
|
||||||
itemsObject := itemsValue.ToObject(v.runtime)
|
|
||||||
if itemsObject == nil {
|
|
||||||
panic(v.runtime.NewTypeError("from argument must be an array"))
|
|
||||||
}
|
|
||||||
lengthValue := itemsObject.Get("length")
|
|
||||||
if lengthValue == nil {
|
|
||||||
panic(v.runtime.NewTypeError("from argument must be array-like"))
|
|
||||||
}
|
|
||||||
length := int(lengthValue.ToInteger())
|
|
||||||
|
|
||||||
handle := v.runtime.NewObject()
|
|
||||||
_ = handle.Set("generate", func(goja.FunctionCall) goja.Value {
|
|
||||||
if length == 0 {
|
|
||||||
return goja.Undefined()
|
|
||||||
}
|
|
||||||
index := v.rng.IntN(length)
|
|
||||||
return itemsObject.Get(fmt.Sprintf("%d", index))
|
|
||||||
})
|
|
||||||
return handle
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *Verifier) bindTap(call goja.FunctionCall) goja.Value {
|
|
||||||
parameters := call.Argument(0).ToObject(v.runtime)
|
|
||||||
if parameters == nil {
|
|
||||||
panic(v.runtime.NewTypeError("Tap requires {on}"))
|
|
||||||
}
|
|
||||||
handle := v.runtime.NewObject()
|
|
||||||
_ = handle.Set("kind", "Tap")
|
|
||||||
_ = handle.Set("on", parameters.Get("on"))
|
|
||||||
return handle
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *Verifier) bindDoubleTap(call goja.FunctionCall) goja.Value {
|
|
||||||
parameters := call.Argument(0).ToObject(v.runtime)
|
|
||||||
if parameters == nil {
|
|
||||||
panic(v.runtime.NewTypeError("DoubleTap requires {on}"))
|
|
||||||
}
|
|
||||||
handle := v.runtime.NewObject()
|
|
||||||
_ = handle.Set("kind", "DoubleTap")
|
|
||||||
_ = handle.Set("on", parameters.Get("on"))
|
|
||||||
return handle
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *Verifier) bindLongPress(call goja.FunctionCall) goja.Value {
|
|
||||||
parameters := call.Argument(0).ToObject(v.runtime)
|
|
||||||
if parameters == nil {
|
|
||||||
panic(v.runtime.NewTypeError("LongPress requires {on}"))
|
|
||||||
}
|
|
||||||
handle := v.runtime.NewObject()
|
|
||||||
_ = handle.Set("kind", "LongPress")
|
|
||||||
_ = handle.Set("on", parameters.Get("on"))
|
|
||||||
return handle
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *Verifier) bindScroll(call goja.FunctionCall) goja.Value {
|
|
||||||
parameters := call.Argument(0).ToObject(v.runtime)
|
|
||||||
if parameters == nil {
|
|
||||||
panic(v.runtime.NewTypeError("Scroll requires {direction}"))
|
|
||||||
}
|
|
||||||
handle := v.runtime.NewObject()
|
|
||||||
_ = handle.Set("kind", "Scroll")
|
|
||||||
_ = handle.Set("direction", parameters.Get("direction"))
|
|
||||||
_ = handle.Set("in", parameters.Get("in"))
|
|
||||||
return handle
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *Verifier) bindInputText(call goja.FunctionCall) goja.Value {
|
|
||||||
parameters := call.Argument(0).ToObject(v.runtime)
|
|
||||||
if parameters == nil {
|
|
||||||
panic(v.runtime.NewTypeError("InputText requires {into, text}"))
|
|
||||||
}
|
|
||||||
handle := v.runtime.NewObject()
|
|
||||||
_ = handle.Set("kind", "InputText")
|
|
||||||
_ = handle.Set("into", parameters.Get("into"))
|
|
||||||
_ = handle.Set("text", parameters.Get("text"))
|
|
||||||
return handle
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *Verifier) bindSwipe(call goja.FunctionCall) goja.Value {
|
|
||||||
parameters := call.Argument(0).ToObject(v.runtime)
|
|
||||||
if parameters == nil {
|
|
||||||
panic(v.runtime.NewTypeError("Swipe requires {from, to}"))
|
|
||||||
}
|
|
||||||
handle := v.runtime.NewObject()
|
|
||||||
_ = handle.Set("kind", "Swipe")
|
|
||||||
_ = handle.Set("from", parameters.Get("from"))
|
|
||||||
_ = handle.Set("to", parameters.Get("to"))
|
|
||||||
if duration := parameters.Get("durationMillis"); duration != nil && !goja.IsUndefined(duration) {
|
|
||||||
_ = handle.Set("durationMillis", duration)
|
|
||||||
}
|
|
||||||
return handle
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *Verifier) bindPressKey(call goja.FunctionCall) goja.Value {
|
|
||||||
parameters := call.Argument(0).ToObject(v.runtime)
|
|
||||||
if parameters == nil {
|
|
||||||
panic(v.runtime.NewTypeError("PressKey requires {key}"))
|
|
||||||
}
|
|
||||||
handle := v.runtime.NewObject()
|
|
||||||
_ = handle.Set("kind", "PressKey")
|
|
||||||
_ = handle.Set("key", parameters.Get("key"))
|
|
||||||
return handle
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *Verifier) bindWait(call goja.FunctionCall) goja.Value {
|
|
||||||
parameters := call.Argument(0).ToObject(v.runtime)
|
|
||||||
if parameters == nil {
|
|
||||||
panic(v.runtime.NewTypeError("Wait requires {durationMillis}"))
|
|
||||||
}
|
|
||||||
handle := v.runtime.NewObject()
|
|
||||||
_ = handle.Set("kind", "Wait")
|
|
||||||
_ = handle.Set("durationMillis", parameters.Get("durationMillis"))
|
|
||||||
return handle
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *Verifier) builtinGenerator(kind string) *goja.Object {
|
|
||||||
handle := v.runtime.NewObject()
|
|
||||||
_ = handle.Set(tagActionGenerator, true)
|
|
||||||
_ = handle.Set(tagInternalKind, kind)
|
|
||||||
return handle
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package verifier
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPushSnapshot_CrossExtractorReadIsGuarded(t *testing.T) {
|
||||||
|
verifier := newVerifier(t)
|
||||||
|
mustLoad(t, verifier, `
|
||||||
|
const a = __sanderling__.extract(state => 1);
|
||||||
|
const b = __sanderling__.extract(() => a.current);
|
||||||
|
globalThis.a = a;
|
||||||
|
globalThis.b = b;
|
||||||
|
`)
|
||||||
|
err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected guard error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "inside another extractor is not allowed") {
|
||||||
|
t.Errorf("guard error wrong: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtract_NamedSetsExtractorName(t *testing.T) {
|
||||||
|
verifier := newVerifier(t)
|
||||||
|
mustLoad(t, verifier, `
|
||||||
|
const route = __sanderling__.extract(state => "home").named("route");
|
||||||
|
globalThis.route = route;
|
||||||
|
`)
|
||||||
|
if got := verifier.extractors[0].name; got != "route" {
|
||||||
|
t.Errorf("named() did not set name: got %q, want %q", got, "route")
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
-116
@@ -266,10 +266,10 @@ func selectorPathFromJS(runtime *goja.Runtime, arg goja.Value) ([]hierarchy.Sele
|
|||||||
}
|
}
|
||||||
|
|
||||||
// selectorStringFromJS returns a string representation of the selector argument
|
// selectorStringFromJS returns a string representation of the selector argument
|
||||||
// for tagging returned element objects (used by selectorOf to reconstruct the
|
// for tagging returned ax element objects (state.ax.find/findAll), so a spec
|
||||||
// selector when the element is passed back as an action target). Output
|
// reading an element's selector sees the canonical form. Output follows the
|
||||||
// follows the canonical hierarchy selector grammar: "k:v" pairs space-joined
|
// hierarchy selector grammar: "k:v" pairs space-joined per object, chains
|
||||||
// per object, chains joined by " > ".
|
// joined by " > ".
|
||||||
func selectorStringFromJS(runtime *goja.Runtime, arg goja.Value) string {
|
func selectorStringFromJS(runtime *goja.Runtime, arg goja.Value) string {
|
||||||
if arg == nil || goja.IsUndefined(arg) || goja.IsNull(arg) {
|
if arg == nil || goja.IsUndefined(arg) || goja.IsNull(arg) {
|
||||||
return ""
|
return ""
|
||||||
@@ -414,132 +414,68 @@ func jsonToJSValue(runtime *goja.Runtime, raw json.RawMessage) (goja.Value, erro
|
|||||||
return runtime.ToValue(generic), nil
|
return runtime.ToValue(generic), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// jsValueToAction converts a JS-side action object into a Go Action.
|
// wireAction is the unified flat wire contract both runtime entries emit
|
||||||
func jsValueToAction(runtime *goja.Runtime, value goja.Value) (Action, error) {
|
// (runtime-entry.ts serializeAction). ONE decoder reads it on both the goja
|
||||||
if value == nil || goja.IsNull(value) || goja.IsUndefined(value) {
|
// (native) and the runner (web) sides, so a field rename cannot silently turn
|
||||||
return Action{}, fmt.Errorf("nil action")
|
// an action into a no-op on one path only.
|
||||||
|
type wireAction struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
X int `json:"x"`
|
||||||
|
Y int `json:"y"`
|
||||||
|
Selector string `json:"selector"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
Direction string `json:"direction"`
|
||||||
|
FromX int `json:"fromX"`
|
||||||
|
FromY int `json:"fromY"`
|
||||||
|
ToX int `json:"toX"`
|
||||||
|
ToY int `json:"toY"`
|
||||||
|
DurationMillis int `json:"durationMillis"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeAction turns one serialized action (the flat camelCase wire contract)
|
||||||
|
// into a Go Action. An empty payload or JSON "null" reports ErrNoAction.
|
||||||
|
func DecodeAction(raw json.RawMessage) (Action, error) {
|
||||||
|
if len(raw) == 0 || string(raw) == "null" {
|
||||||
|
return Action{}, ErrNoAction
|
||||||
}
|
}
|
||||||
object := value.ToObject(runtime)
|
var wire wireAction
|
||||||
kindValue := object.Get("kind")
|
if err := json.Unmarshal(raw, &wire); err != nil {
|
||||||
if kindValue == nil {
|
return Action{}, fmt.Errorf("decode action: %w", err)
|
||||||
return Action{}, fmt.Errorf("action missing kind")
|
|
||||||
}
|
}
|
||||||
kind := kindValue.String()
|
switch wire.Kind {
|
||||||
switch kind {
|
|
||||||
case "Tap":
|
case "Tap":
|
||||||
on := object.Get("on")
|
return Action{Kind: ActionKindTap, On: wire.Selector, X: wire.X, Y: wire.Y}, nil
|
||||||
x, y := coordinatesOf(runtime, on)
|
|
||||||
return Action{Kind: ActionKindTap, On: selectorOf(runtime, on), X: x, Y: y}, nil
|
|
||||||
case "DoubleTap":
|
case "DoubleTap":
|
||||||
on := object.Get("on")
|
return Action{Kind: ActionKindDoubleTap, On: wire.Selector, X: wire.X, Y: wire.Y}, nil
|
||||||
x, y := coordinatesOf(runtime, on)
|
case "LongPress":
|
||||||
return Action{Kind: ActionKindDoubleTap, On: selectorOf(runtime, on), X: x, Y: y}, nil
|
return Action{Kind: ActionKindLongPress, On: wire.Selector, X: wire.X, Y: wire.Y}, nil
|
||||||
case "InputText":
|
case "InputText":
|
||||||
into := object.Get("into")
|
return Action{Kind: ActionKindInputText, On: wire.Selector, Text: wire.Text, X: wire.X, Y: wire.Y}, nil
|
||||||
text := object.Get("text")
|
|
||||||
x, y := coordinatesOf(runtime, into)
|
|
||||||
return Action{Kind: ActionKindInputText, On: selectorOf(runtime, into), Text: stringOf(text), X: x, Y: y}, nil
|
|
||||||
case "Swipe":
|
case "Swipe":
|
||||||
from := object.Get("from")
|
|
||||||
to := object.Get("to")
|
|
||||||
fromX, fromY := coordinatesOf(runtime, from)
|
|
||||||
toX, toY := coordinatesOf(runtime, to)
|
|
||||||
if fromX == 0 && fromY == 0 {
|
|
||||||
fromX, fromY = pointCoordinates(runtime, from)
|
|
||||||
}
|
|
||||||
if toX == 0 && toY == 0 {
|
|
||||||
toX, toY = pointCoordinates(runtime, to)
|
|
||||||
}
|
|
||||||
return Action{
|
return Action{
|
||||||
Kind: ActionKindSwipe,
|
Kind: ActionKindSwipe,
|
||||||
FromX: fromX,
|
FromX: wire.FromX,
|
||||||
FromY: fromY,
|
FromY: wire.FromY,
|
||||||
ToX: toX,
|
ToX: wire.ToX,
|
||||||
ToY: toY,
|
ToY: wire.ToY,
|
||||||
DurationMillis: intField(object, "durationMillis"),
|
DurationMillis: wire.DurationMillis,
|
||||||
}, nil
|
}, nil
|
||||||
case "LongPress":
|
|
||||||
on := object.Get("on")
|
|
||||||
x, y := coordinatesOf(runtime, on)
|
|
||||||
return Action{Kind: ActionKindLongPress, On: selectorOf(runtime, on), X: x, Y: y}, nil
|
|
||||||
case "Scroll":
|
case "Scroll":
|
||||||
in := object.Get("in")
|
|
||||||
x, y := coordinatesOf(runtime, in)
|
|
||||||
return Action{
|
return Action{
|
||||||
Kind: ActionKindScroll,
|
Kind: ActionKindScroll,
|
||||||
Direction: stringOf(object.Get("direction")),
|
Direction: wire.Direction,
|
||||||
On: selectorOf(runtime, in),
|
FromX: wire.FromX,
|
||||||
X: x,
|
FromY: wire.FromY,
|
||||||
Y: y,
|
ToX: wire.ToX,
|
||||||
|
ToY: wire.ToY,
|
||||||
|
DurationMillis: wire.DurationMillis,
|
||||||
}, nil
|
}, nil
|
||||||
case "PressKey":
|
case "PressKey":
|
||||||
return Action{Kind: ActionKindPressKey, Key: stringOf(object.Get("key"))}, nil
|
return Action{Kind: ActionKindPressKey, Key: wire.Key}, nil
|
||||||
case "Wait":
|
case "Wait":
|
||||||
return Action{Kind: ActionKindWait, DurationMillis: intField(object, "durationMillis")}, nil
|
return Action{Kind: ActionKindWait, DurationMillis: wire.DurationMillis}, nil
|
||||||
default:
|
default:
|
||||||
return Action{}, fmt.Errorf("unknown action kind %q", kind)
|
return Action{}, fmt.Errorf("unknown action kind %q", wire.Kind)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// pointCoordinates reads a plain {x, y} literal (not an AX element), which is
|
|
||||||
// how Swipe endpoints are commonly expressed in specs.
|
|
||||||
func pointCoordinates(runtime *goja.Runtime, value goja.Value) (int, int) {
|
|
||||||
if value == nil || goja.IsNull(value) || goja.IsUndefined(value) {
|
|
||||||
return 0, 0
|
|
||||||
}
|
|
||||||
object := value.ToObject(runtime)
|
|
||||||
if object == nil {
|
|
||||||
return 0, 0
|
|
||||||
}
|
|
||||||
x := object.Get("x")
|
|
||||||
y := object.Get("y")
|
|
||||||
if x == nil || y == nil {
|
|
||||||
return 0, 0
|
|
||||||
}
|
|
||||||
return int(x.ToInteger()), int(y.ToInteger())
|
|
||||||
}
|
|
||||||
|
|
||||||
func intField(object *goja.Object, name string) int {
|
|
||||||
value := object.Get(name)
|
|
||||||
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return int(value.ToInteger())
|
|
||||||
}
|
|
||||||
|
|
||||||
func selectorOf(runtime *goja.Runtime, value goja.Value) string {
|
|
||||||
if value == nil || goja.IsNull(value) || goja.IsUndefined(value) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
object := value.ToObject(runtime)
|
|
||||||
if object == nil {
|
|
||||||
return value.String()
|
|
||||||
}
|
|
||||||
if tag := object.Get(tagSelector); tag != nil && !goja.IsUndefined(tag) {
|
|
||||||
return tag.String()
|
|
||||||
}
|
|
||||||
return value.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func coordinatesOf(runtime *goja.Runtime, value goja.Value) (int, int) {
|
|
||||||
if value == nil || goja.IsNull(value) || goja.IsUndefined(value) {
|
|
||||||
return 0, 0
|
|
||||||
}
|
|
||||||
object := value.ToObject(runtime)
|
|
||||||
if object == nil {
|
|
||||||
return 0, 0
|
|
||||||
}
|
|
||||||
xValue := object.Get("x")
|
|
||||||
yValue := object.Get("y")
|
|
||||||
if xValue == nil || yValue == nil || goja.IsUndefined(xValue) || goja.IsUndefined(yValue) {
|
|
||||||
return 0, 0
|
|
||||||
}
|
|
||||||
return int(xValue.ToInteger()), int(yValue.ToInteger())
|
|
||||||
}
|
|
||||||
|
|
||||||
func stringOf(value goja.Value) string {
|
|
||||||
if value == nil || goja.IsNull(value) || goja.IsUndefined(value) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return value.String()
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package verifier
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestCrossRuntimeParity is the W2 acceptance gate: for a FIXED seed and a FIXED
|
||||||
|
// candidate state, the goja-bundled picker must emit the SAME action stream as
|
||||||
|
// the node/web pick.ts. Both engines run the SHARED pick.ts over the SHARED Pcg,
|
||||||
|
// so each asserts the SAME committed golden (pkg/spec/test/fixtures/
|
||||||
|
// parity-golden.json); the node side does so in pkg/spec/test/parity.test.ts.
|
||||||
|
// Matching one golden on both sides proves they match each other without either
|
||||||
|
// invoking the other.
|
||||||
|
//
|
||||||
|
// The candidate ORDER and the per-tick PCG draw order are the parity contract.
|
||||||
|
// A stub __sanderlingHost__ feeds the SAME three candidates (in the SAME order)
|
||||||
|
// for every verb, so the hierarchy filter is out of the picture and the only
|
||||||
|
// variable left is the JS engine. The weighted root mixes a tap branch with a
|
||||||
|
// typing branch, exercising weighted selection, a builtin, and the input corpus
|
||||||
|
// within the 20-tick window; reordering candidates or adding/dropping a draw on
|
||||||
|
// either side shifts the stream and fails the golden.
|
||||||
|
func TestCrossRuntimeParity(t *testing.T) {
|
||||||
|
const seed uint64 = 0x9e3779b97f4a7c15
|
||||||
|
golden := loadParityGolden(t)
|
||||||
|
|
||||||
|
verifier := newVerifier(t, WithSeed(seed))
|
||||||
|
installStubHost(t, verifier)
|
||||||
|
loadActionSpec(t, verifier, `
|
||||||
|
import { taps, typing, weighted } from "@sanderling/spec";
|
||||||
|
globalThis.actions = weighted([3, taps], [1, typing]);
|
||||||
|
`)
|
||||||
|
|
||||||
|
if len(golden) == 0 {
|
||||||
|
t.Fatal("golden stream is empty")
|
||||||
|
}
|
||||||
|
for step, want := range golden {
|
||||||
|
got, err := verifier.NextAction()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("goja next action at step %d: %v", step, err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("step %d diverged from golden:\n goja=%+v\n want=%+v", step, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// installStubHost replaces the verifier's hierarchy-backed __sanderlingHost__
|
||||||
|
// with one returning a FIXED candidate list for every verb, keeping the seed the
|
||||||
|
// verifier was constructed with. It must run BEFORE Load, because the bundled
|
||||||
|
// goja runtime entry captures the host when the spec evaluates.
|
||||||
|
func installStubHost(t *testing.T, verifier *Verifier) {
|
||||||
|
t.Helper()
|
||||||
|
const stub = `
|
||||||
|
const candidates = [
|
||||||
|
{ x: 50, y: 60, selector: "id:alpha", width: 100, height: 40 },
|
||||||
|
{ x: 150, y: 160, selector: "id:beta", width: 120, height: 48 },
|
||||||
|
{ x: 250, y: 260, selector: "id:gamma", width: 80, height: 32 },
|
||||||
|
];
|
||||||
|
const seedHi = globalThis.__sanderlingHost__.seedHi;
|
||||||
|
const seedLo = globalThis.__sanderlingHost__.seedLo;
|
||||||
|
globalThis.__sanderlingHost__ = {
|
||||||
|
platform: () => "android",
|
||||||
|
queryCandidates: () => candidates,
|
||||||
|
reportUnsupported: () => {},
|
||||||
|
seedHi,
|
||||||
|
seedLo,
|
||||||
|
};
|
||||||
|
`
|
||||||
|
if _, err := verifier.runtime.RunString(stub); err != nil {
|
||||||
|
t.Fatalf("install stub host: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadParityGolden decodes the shared golden stream with the SAME DecodeAction
|
||||||
|
// the runner uses, so the goja comparison is apples-to-apples with the wire the
|
||||||
|
// node picker emits.
|
||||||
|
func loadParityGolden(t *testing.T) []Action {
|
||||||
|
t.Helper()
|
||||||
|
path, err := filepath.Abs("../../pkg/spec/test/fixtures/parity-golden.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read golden: %v", err)
|
||||||
|
}
|
||||||
|
var raw []json.RawMessage
|
||||||
|
if err := json.Unmarshal(body, &raw); err != nil {
|
||||||
|
t.Fatalf("decode golden: %v", err)
|
||||||
|
}
|
||||||
|
stream := make([]Action, len(raw))
|
||||||
|
for i, message := range raw {
|
||||||
|
action, err := DecodeAction(message)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode golden action %d: %v", i, err)
|
||||||
|
}
|
||||||
|
stream[i] = action
|
||||||
|
}
|
||||||
|
return stream
|
||||||
|
}
|
||||||
@@ -33,7 +33,10 @@ func pushTree(t *testing.T, v *Verifier, treeJSON string) {
|
|||||||
// the result lands on its center regardless of seed.
|
// the result lands on its center regardless of seed.
|
||||||
func TestTaps_ExcludeOffAppPackage(t *testing.T) {
|
func TestTaps_ExcludeOffAppPackage(t *testing.T) {
|
||||||
verifier := newVerifier(t, WithAppPackage("com.folio"))
|
verifier := newVerifier(t, WithAppPackage("com.folio"))
|
||||||
mustLoad(t, verifier, `globalThis.actions = __sanderling__.taps;`)
|
loadActionSpec(t, verifier, `
|
||||||
|
import { taps } from "@sanderling/spec";
|
||||||
|
globalThis.actions = taps;
|
||||||
|
`)
|
||||||
pushTree(t, verifier, scopedTreeJSON)
|
pushTree(t, verifier, scopedTreeJSON)
|
||||||
|
|
||||||
action, err := verifier.NextAction()
|
action, err := verifier.NextAction()
|
||||||
@@ -56,7 +59,10 @@ func TestTyping_ExcludeOffAppPackage(t *testing.T) {
|
|||||||
]
|
]
|
||||||
}`
|
}`
|
||||||
verifier := newVerifier(t, WithAppPackage("com.folio"))
|
verifier := newVerifier(t, WithAppPackage("com.folio"))
|
||||||
mustLoad(t, verifier, `globalThis.actions = __sanderling__.typing;`)
|
loadActionSpec(t, verifier, `
|
||||||
|
import { typing } from "@sanderling/spec";
|
||||||
|
globalThis.actions = typing;
|
||||||
|
`)
|
||||||
pushTree(t, verifier, treeJSON)
|
pushTree(t, verifier, treeJSON)
|
||||||
|
|
||||||
action, err := verifier.NextAction()
|
action, err := verifier.NextAction()
|
||||||
@@ -72,7 +78,10 @@ func TestTyping_ExcludeOffAppPackage(t *testing.T) {
|
|||||||
// exploration never scrolls the keyboard's emoji list instead of the app.
|
// exploration never scrolls the keyboard's emoji list instead of the app.
|
||||||
func TestSwipes_ExcludeOffAppPackage(t *testing.T) {
|
func TestSwipes_ExcludeOffAppPackage(t *testing.T) {
|
||||||
verifier := newVerifier(t, WithAppPackage("com.folio"))
|
verifier := newVerifier(t, WithAppPackage("com.folio"))
|
||||||
mustLoad(t, verifier, `globalThis.actions = __sanderling__.swipes;`)
|
loadActionSpec(t, verifier, `
|
||||||
|
import { swipes } from "@sanderling/spec";
|
||||||
|
globalThis.actions = swipes;
|
||||||
|
`)
|
||||||
pushTree(t, verifier, scopedTreeJSON)
|
pushTree(t, verifier, scopedTreeJSON)
|
||||||
|
|
||||||
// Both the root and SubmitButton (com.folio) are valid anchors; only the
|
// Both the root and SubmitButton (com.folio) are valid anchors; only the
|
||||||
@@ -103,7 +112,10 @@ func TestTaps_AllOffAppYieldsErrNoAction(t *testing.T) {
|
|||||||
]
|
]
|
||||||
}`
|
}`
|
||||||
verifier := newVerifier(t, WithAppPackage("com.folio"))
|
verifier := newVerifier(t, WithAppPackage("com.folio"))
|
||||||
mustLoad(t, verifier, `globalThis.actions = __sanderling__.taps;`)
|
loadActionSpec(t, verifier, `
|
||||||
|
import { taps } from "@sanderling/spec";
|
||||||
|
globalThis.actions = taps;
|
||||||
|
`)
|
||||||
pushTree(t, verifier, treeJSON)
|
pushTree(t, verifier, treeJSON)
|
||||||
|
|
||||||
if _, err := verifier.NextAction(); !errors.Is(err, ErrNoAction) {
|
if _, err := verifier.NextAction(); !errors.Is(err, ErrNoAction) {
|
||||||
@@ -121,7 +133,10 @@ func TestTaps_UnsetAppPackageKeepsAllNodes(t *testing.T) {
|
|||||||
]
|
]
|
||||||
}`
|
}`
|
||||||
verifier := newVerifier(t)
|
verifier := newVerifier(t)
|
||||||
mustLoad(t, verifier, `globalThis.actions = __sanderling__.taps;`)
|
loadActionSpec(t, verifier, `
|
||||||
|
import { taps } from "@sanderling/spec";
|
||||||
|
globalThis.actions = taps;
|
||||||
|
`)
|
||||||
pushTree(t, verifier, treeJSON)
|
pushTree(t, verifier, treeJSON)
|
||||||
|
|
||||||
action, err := verifier.NextAction()
|
action, err := verifier.NextAction()
|
||||||
@@ -137,7 +152,10 @@ func TestTaps_UnsetAppPackageKeepsAllNodes(t *testing.T) {
|
|||||||
// mirrors taps: it yields a LongPress on the only clickable in-app node.
|
// mirrors taps: it yields a LongPress on the only clickable in-app node.
|
||||||
func TestLongPresses_TargetsClickableElement(t *testing.T) {
|
func TestLongPresses_TargetsClickableElement(t *testing.T) {
|
||||||
verifier := newVerifier(t, WithAppPackage("com.folio"))
|
verifier := newVerifier(t, WithAppPackage("com.folio"))
|
||||||
mustLoad(t, verifier, `globalThis.actions = __sanderling__.longPresses;`)
|
loadActionSpec(t, verifier, `
|
||||||
|
import { longPresses } from "@sanderling/spec";
|
||||||
|
globalThis.actions = longPresses;
|
||||||
|
`)
|
||||||
pushTree(t, verifier, scopedTreeJSON)
|
pushTree(t, verifier, scopedTreeJSON)
|
||||||
|
|
||||||
action, err := verifier.NextAction()
|
action, err := verifier.NextAction()
|
||||||
@@ -163,7 +181,10 @@ func TestScrolls_TargetsScrollableContainer(t *testing.T) {
|
|||||||
]
|
]
|
||||||
}`
|
}`
|
||||||
verifier := newVerifier(t, WithAppPackage("com.folio"))
|
verifier := newVerifier(t, WithAppPackage("com.folio"))
|
||||||
mustLoad(t, verifier, `globalThis.actions = __sanderling__.scrolls;`)
|
loadActionSpec(t, verifier, `
|
||||||
|
import { scrolls } from "@sanderling/spec";
|
||||||
|
globalThis.actions = scrolls;
|
||||||
|
`)
|
||||||
pushTree(t, verifier, treeJSON)
|
pushTree(t, verifier, treeJSON)
|
||||||
|
|
||||||
for range 50 {
|
for range 50 {
|
||||||
@@ -190,8 +211,8 @@ func TestScrolls_TargetsScrollableContainer(t *testing.T) {
|
|||||||
default:
|
default:
|
||||||
t.Fatalf("unexpected direction %q", action.Direction)
|
t.Fatalf("unexpected direction %q", action.Direction)
|
||||||
}
|
}
|
||||||
if action.DurationMillis != 300 {
|
if action.DurationMillis != 250 {
|
||||||
t.Fatalf("durationMillis = %d, want 300", action.DurationMillis)
|
t.Fatalf("durationMillis = %d, want 250", action.DurationMillis)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,7 +221,10 @@ func TestScrolls_TargetsScrollableContainer(t *testing.T) {
|
|||||||
// no scrollable container is present.
|
// no scrollable container is present.
|
||||||
func TestScrolls_NoScrollableYieldsErrNoAction(t *testing.T) {
|
func TestScrolls_NoScrollableYieldsErrNoAction(t *testing.T) {
|
||||||
verifier := newVerifier(t, WithAppPackage("com.folio"))
|
verifier := newVerifier(t, WithAppPackage("com.folio"))
|
||||||
mustLoad(t, verifier, `globalThis.actions = __sanderling__.scrolls;`)
|
loadActionSpec(t, verifier, `
|
||||||
|
import { scrolls } from "@sanderling/spec";
|
||||||
|
globalThis.actions = scrolls;
|
||||||
|
`)
|
||||||
pushTree(t, verifier, scopedTreeJSON)
|
pushTree(t, verifier, scopedTreeJSON)
|
||||||
|
|
||||||
if _, err := verifier.NextAction(); !errors.Is(err, ErrNoAction) {
|
if _, err := verifier.NextAction(); !errors.Is(err, ErrNoAction) {
|
||||||
@@ -219,7 +243,10 @@ func TestTaps_EmptyPackageNodeStaysInScope(t *testing.T) {
|
|||||||
]
|
]
|
||||||
}`
|
}`
|
||||||
verifier := newVerifier(t, WithAppPackage("com.folio"))
|
verifier := newVerifier(t, WithAppPackage("com.folio"))
|
||||||
mustLoad(t, verifier, `globalThis.actions = __sanderling__.taps;`)
|
loadActionSpec(t, verifier, `
|
||||||
|
import { taps } from "@sanderling/spec";
|
||||||
|
globalThis.actions = taps;
|
||||||
|
`)
|
||||||
pushTree(t, verifier, treeJSON)
|
pushTree(t, verifier, treeJSON)
|
||||||
|
|
||||||
action, err := verifier.NextAction()
|
action, err := verifier.NextAction()
|
||||||
|
|||||||
@@ -23,12 +23,17 @@ func bundleIntegrationSpec(t *testing.T) string {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
runtimePath, err := filepath.Abs("../../pkg/spec/src/goja-runtime.ts")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
defaultsPath, err := filepath.Abs("../../pkg/spec/src/defaults/properties.ts")
|
defaultsPath, err := filepath.Abs("../../pkg/spec/src/defaults/properties.ts")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
bundle, err := bundler.Bundle(bundler.Options{
|
bundle, err := bundler.Bundle(bundler.Options{
|
||||||
EntryFile: specPath,
|
EntryFile: specPath,
|
||||||
|
RuntimeFile: runtimePath,
|
||||||
Aliases: map[string]string{
|
Aliases: map[string]string{
|
||||||
"@sanderling/spec": apiPath,
|
"@sanderling/spec": apiPath,
|
||||||
"@sanderling/spec/defaults/properties": defaultsPath,
|
"@sanderling/spec/defaults/properties": defaultsPath,
|
||||||
|
|||||||
@@ -3,13 +3,15 @@ package verifier
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"math/rand/v2"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
|
|
||||||
|
"github.com/priyanshujain/sanderling/internal/bundler"
|
||||||
"github.com/priyanshujain/sanderling/internal/hierarchy"
|
"github.com/priyanshujain/sanderling/internal/hierarchy"
|
||||||
"github.com/priyanshujain/sanderling/internal/ltl"
|
"github.com/priyanshujain/sanderling/internal/ltl"
|
||||||
)
|
)
|
||||||
@@ -30,6 +32,43 @@ func mustLoad(t *testing.T, verifier *Verifier, source string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bundleActionSpec bundles an inline TS spec authored against @sanderling/spec
|
||||||
|
// together with the goja runtime entry, so loading it installs
|
||||||
|
// __sanderlingNextAction__ (the shared picker). Action targets must be resolved
|
||||||
|
// ax elements (carrying x/y) or builtins; raw selector strings no longer
|
||||||
|
// resolve to coordinates in the unified contract.
|
||||||
|
func bundleActionSpec(t *testing.T, specSource string) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
specPath := filepath.Join(dir, "spec.ts")
|
||||||
|
if err := os.WriteFile(specPath, []byte(specSource), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
apiPath, err := filepath.Abs("../../pkg/spec/src/index.ts")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runtimePath, err := filepath.Abs("../../pkg/spec/src/goja-runtime.ts")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bundle, err := bundler.Bundle(bundler.Options{
|
||||||
|
EntryFile: specPath,
|
||||||
|
RuntimeFile: runtimePath,
|
||||||
|
Aliases: map[string]string{"@sanderling/spec": apiPath},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(bundle.JavaScript)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadActionSpec bundles and loads an inline authored spec into the verifier.
|
||||||
|
func loadActionSpec(t *testing.T, verifier *Verifier, specSource string) {
|
||||||
|
t.Helper()
|
||||||
|
mustLoad(t, verifier, bundleActionSpec(t, specSource))
|
||||||
|
}
|
||||||
|
|
||||||
const helloSpec = `
|
const helloSpec = `
|
||||||
const screen = __sanderling__.extract(state => state.snapshots.screen ?? "");
|
const screen = __sanderling__.extract(state => state.snapshots.screen ?? "");
|
||||||
const balance = __sanderling__.extract(state => state.snapshots["ledger.balance"] ?? 0);
|
const balance = __sanderling__.extract(state => state.snapshots["ledger.balance"] ?? 0);
|
||||||
@@ -40,10 +79,6 @@ globalThis.balance = balance;
|
|||||||
globalThis.properties = {
|
globalThis.properties = {
|
||||||
balanceNonNegative: __sanderling__.always(() => balance.current >= 0),
|
balanceNonNegative: __sanderling__.always(() => balance.current >= 0),
|
||||||
};
|
};
|
||||||
|
|
||||||
globalThis.actions = __sanderling__.actions(() => [
|
|
||||||
__sanderling__.tap({ on: "id:home_button" }),
|
|
||||||
]);
|
|
||||||
`
|
`
|
||||||
|
|
||||||
func TestLoad_ExposesRuntimeBindings(t *testing.T) {
|
func TestLoad_ExposesRuntimeBindings(t *testing.T) {
|
||||||
@@ -231,9 +266,25 @@ globalThis.properties = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNextAction_FromActionsGenerator(t *testing.T) {
|
func TestNextAction_FromActionsGenerator(t *testing.T) {
|
||||||
|
const treeJSON = `{
|
||||||
|
"attributes": {"resource-id": "root", "bounds": "[0,0,100,100]"},
|
||||||
|
"children": [
|
||||||
|
{"attributes": {"resource-id": "home_button", "bounds": "[0,40,100,80]"}, "clickable": true, "enabled": true, "children": []}
|
||||||
|
]
|
||||||
|
}`
|
||||||
verifier := newVerifier(t)
|
verifier := newVerifier(t)
|
||||||
mustLoad(t, verifier, helloSpec)
|
loadActionSpec(t, verifier, `
|
||||||
_ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}})
|
import { actions, Tap } from "@sanderling/spec";
|
||||||
|
globalThis.actions = actions(() => {
|
||||||
|
const home = state.ax.find("id:home_button");
|
||||||
|
return home ? [Tap({ on: home })] : [];
|
||||||
|
});
|
||||||
|
`)
|
||||||
|
tree, err := hierarchy.Parse(treeJSON)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}, Tree: tree})
|
||||||
|
|
||||||
action, err := verifier.NextAction()
|
action, err := verifier.NextAction()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -248,16 +299,31 @@ func TestNextAction_FromActionsGenerator(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNextAction_WeightedSelectsByWeight(t *testing.T) {
|
func TestNextAction_WeightedSelectsByWeight(t *testing.T) {
|
||||||
verifier := newVerifier(t, WithRand(rand.New(rand.NewPCG(42, 0))))
|
const treeJSON = `{
|
||||||
mustLoad(t, verifier, `
|
"attributes": {"resource-id": "root", "bounds": "[0,0,100,100]"},
|
||||||
const tapHome = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:home" })]);
|
"children": [
|
||||||
const tapAway = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:away" })]);
|
{"attributes": {"resource-id": "home", "bounds": "[0,0,100,40]"}, "clickable": true, "enabled": true, "children": []},
|
||||||
globalThis.actions = __sanderling__.weighted(
|
{"attributes": {"resource-id": "away", "bounds": "[0,40,100,80]"}, "clickable": true, "enabled": true, "children": []}
|
||||||
[1, tapHome],
|
]
|
||||||
[99, tapAway],
|
}`
|
||||||
);
|
verifier := newVerifier(t, WithSeed(42))
|
||||||
|
loadActionSpec(t, verifier, `
|
||||||
|
import { actions, weighted, Tap } from "@sanderling/spec";
|
||||||
|
const tapHome = actions(() => {
|
||||||
|
const home = state.ax.find("id:home");
|
||||||
|
return home ? [Tap({ on: home })] : [];
|
||||||
|
});
|
||||||
|
const tapAway = actions(() => {
|
||||||
|
const away = state.ax.find("id:away");
|
||||||
|
return away ? [Tap({ on: away })] : [];
|
||||||
|
});
|
||||||
|
globalThis.actions = weighted([1, tapHome], [99, tapAway]);
|
||||||
`)
|
`)
|
||||||
_ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}})
|
tree, err := hierarchy.Parse(treeJSON)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}, Tree: tree})
|
||||||
|
|
||||||
awayCount := 0
|
awayCount := 0
|
||||||
homeCount := 0
|
homeCount := 0
|
||||||
@@ -280,8 +346,9 @@ func TestNextAction_WeightedSelectsByWeight(t *testing.T) {
|
|||||||
|
|
||||||
func TestNextAction_EmptyGeneratorReturnsErrNoAction(t *testing.T) {
|
func TestNextAction_EmptyGeneratorReturnsErrNoAction(t *testing.T) {
|
||||||
verifier := newVerifier(t)
|
verifier := newVerifier(t)
|
||||||
mustLoad(t, verifier, `
|
loadActionSpec(t, verifier, `
|
||||||
globalThis.actions = __sanderling__.actions(() => []);
|
import { actions } from "@sanderling/spec";
|
||||||
|
globalThis.actions = actions(() => []);
|
||||||
`)
|
`)
|
||||||
_ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}})
|
_ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}})
|
||||||
|
|
||||||
@@ -291,13 +358,34 @@ func TestNextAction_EmptyGeneratorReturnsErrNoAction(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setupTree carries the three targets the setup-precedence tests resolve.
|
||||||
|
const setupTree = `{
|
||||||
|
"attributes": {"resource-id": "root", "bounds": "[0,0,100,120]"},
|
||||||
|
"children": [
|
||||||
|
{"attributes": {"resource-id": "setup", "bounds": "[0,0,100,40]"}, "clickable": true, "enabled": true, "children": []},
|
||||||
|
{"attributes": {"resource-id": "main", "bounds": "[0,40,100,80]"}, "clickable": true, "enabled": true, "children": []},
|
||||||
|
{"attributes": {"resource-id": "login", "bounds": "[0,80,100,120]"}, "clickable": true, "enabled": true, "children": []}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
func TestNextAction_SetupTakesPrecedenceWhenYielding(t *testing.T) {
|
func TestNextAction_SetupTakesPrecedenceWhenYielding(t *testing.T) {
|
||||||
verifier := newVerifier(t)
|
verifier := newVerifier(t)
|
||||||
mustLoad(t, verifier, `
|
loadActionSpec(t, verifier, `
|
||||||
globalThis.setup = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:setup" })]);
|
import { actions, Tap } from "@sanderling/spec";
|
||||||
globalThis.actions = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:main" })]);
|
globalThis.setup = actions(() => {
|
||||||
|
const target = state.ax.find("id:setup");
|
||||||
|
return target ? [Tap({ on: target })] : [];
|
||||||
|
});
|
||||||
|
globalThis.actions = actions(() => {
|
||||||
|
const target = state.ax.find("id:main");
|
||||||
|
return target ? [Tap({ on: target })] : [];
|
||||||
|
});
|
||||||
`)
|
`)
|
||||||
_ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}})
|
tree, err := hierarchy.Parse(setupTree)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}, Tree: tree})
|
||||||
|
|
||||||
action, err := verifier.NextAction()
|
action, err := verifier.NextAction()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -310,11 +398,19 @@ func TestNextAction_SetupTakesPrecedenceWhenYielding(t *testing.T) {
|
|||||||
|
|
||||||
func TestNextAction_FallsThroughToActionsWhenSetupEmpty(t *testing.T) {
|
func TestNextAction_FallsThroughToActionsWhenSetupEmpty(t *testing.T) {
|
||||||
verifier := newVerifier(t)
|
verifier := newVerifier(t)
|
||||||
mustLoad(t, verifier, `
|
loadActionSpec(t, verifier, `
|
||||||
globalThis.setup = __sanderling__.actions(() => []);
|
import { actions, Tap } from "@sanderling/spec";
|
||||||
globalThis.actions = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:main" })]);
|
globalThis.setup = actions(() => []);
|
||||||
|
globalThis.actions = actions(() => {
|
||||||
|
const target = state.ax.find("id:main");
|
||||||
|
return target ? [Tap({ on: target })] : [];
|
||||||
|
});
|
||||||
`)
|
`)
|
||||||
_ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}})
|
tree, err := hierarchy.Parse(setupTree)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}, Tree: tree})
|
||||||
|
|
||||||
action, err := verifier.NextAction()
|
action, err := verifier.NextAction()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -327,21 +423,30 @@ func TestNextAction_FallsThroughToActionsWhenSetupEmpty(t *testing.T) {
|
|||||||
|
|
||||||
func TestNextAction_SetupReengagesAfterRegression(t *testing.T) {
|
func TestNextAction_SetupReengagesAfterRegression(t *testing.T) {
|
||||||
verifier := newVerifier(t)
|
verifier := newVerifier(t)
|
||||||
mustLoad(t, verifier, `
|
loadActionSpec(t, verifier, `
|
||||||
globalThis.loggedIn = __sanderling__.extract(state => state.snapshots["loggedIn"] === true);
|
import { actions, extract, Tap } from "@sanderling/spec";
|
||||||
globalThis.setup = __sanderling__.actions(() => {
|
const loggedIn = extract(state => state.snapshots["loggedIn"] === true);
|
||||||
|
globalThis.setup = actions(() => {
|
||||||
if (loggedIn.current) return [];
|
if (loggedIn.current) return [];
|
||||||
return [__sanderling__.tap({ on: "id:login" })];
|
const target = state.ax.find("id:login");
|
||||||
|
return target ? [Tap({ on: target })] : [];
|
||||||
|
});
|
||||||
|
globalThis.actions = actions(() => {
|
||||||
|
const target = state.ax.find("id:main");
|
||||||
|
return target ? [Tap({ on: target })] : [];
|
||||||
});
|
});
|
||||||
globalThis.actions = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:main" })]);
|
|
||||||
`)
|
`)
|
||||||
|
tree, err := hierarchy.Parse(setupTree)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
push := func(loggedIn bool) {
|
push := func(loggedIn bool) {
|
||||||
raw := json.RawMessage(`false`)
|
raw := json.RawMessage(`false`)
|
||||||
if loggedIn {
|
if loggedIn {
|
||||||
raw = json.RawMessage(`true`)
|
raw = json.RawMessage(`true`)
|
||||||
}
|
}
|
||||||
if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{"loggedIn": raw}}); err != nil {
|
if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{"loggedIn": raw}, Tree: tree}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -376,14 +481,19 @@ func TestNextAction_SetupReengagesAfterRegression(t *testing.T) {
|
|||||||
|
|
||||||
func TestNextAction_NoSetupRegistered(t *testing.T) {
|
func TestNextAction_NoSetupRegistered(t *testing.T) {
|
||||||
verifier := newVerifier(t)
|
verifier := newVerifier(t)
|
||||||
mustLoad(t, verifier, `
|
loadActionSpec(t, verifier, `
|
||||||
globalThis.actions = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:main" })]);
|
import { actions, Tap } from "@sanderling/spec";
|
||||||
|
globalThis.actions = actions(() => {
|
||||||
|
const target = state.ax.find("id:main");
|
||||||
|
return target ? [Tap({ on: target })] : [];
|
||||||
|
});
|
||||||
`)
|
`)
|
||||||
_ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}})
|
tree, err := hierarchy.Parse(setupTree)
|
||||||
|
if err != nil {
|
||||||
if verifier.setupGenerator != nil {
|
t.Fatal(err)
|
||||||
t.Errorf("setupGenerator should be nil when spec does not export setup")
|
|
||||||
}
|
}
|
||||||
|
_ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}, Tree: tree})
|
||||||
|
|
||||||
action, err := verifier.NextAction()
|
action, err := verifier.NextAction()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -403,7 +513,10 @@ func TestDoubleTapsBuiltin_TargetsClickable(t *testing.T) {
|
|||||||
]
|
]
|
||||||
}`
|
}`
|
||||||
verifier := newVerifier(t)
|
verifier := newVerifier(t)
|
||||||
mustLoad(t, verifier, `globalThis.actions = __sanderling__.doubleTaps;`)
|
loadActionSpec(t, verifier, `
|
||||||
|
import { doubleTaps } from "@sanderling/spec";
|
||||||
|
globalThis.actions = doubleTaps;
|
||||||
|
`)
|
||||||
tree, err := hierarchy.Parse(treeJSON)
|
tree, err := hierarchy.Parse(treeJSON)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -442,13 +555,25 @@ func TestDoubleTapsBuiltin_TargetsClickable(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDoubleTap_RoundTrip(t *testing.T) {
|
func TestDoubleTap_RoundTrip(t *testing.T) {
|
||||||
|
const treeJSON = `{
|
||||||
|
"attributes": {"resource-id": "root", "bounds": "[0,0,100,100]"},
|
||||||
|
"children": [
|
||||||
|
{"attributes": {"resource-id": "save", "bounds": "[0,0,100,40]"}, "clickable": true, "enabled": true, "children": []}
|
||||||
|
]
|
||||||
|
}`
|
||||||
verifier := newVerifier(t)
|
verifier := newVerifier(t)
|
||||||
mustLoad(t, verifier, `
|
loadActionSpec(t, verifier, `
|
||||||
globalThis.actions = __sanderling__.actions(() => [
|
import { actions, DoubleTap } from "@sanderling/spec";
|
||||||
__sanderling__.doubleTap({ on: "id:save" }),
|
globalThis.actions = actions(() => {
|
||||||
]);
|
const save = state.ax.find("id:save");
|
||||||
|
return save ? [DoubleTap({ on: save })] : [];
|
||||||
|
});
|
||||||
`)
|
`)
|
||||||
_ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}})
|
tree, err := hierarchy.Parse(treeJSON)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}, Tree: tree})
|
||||||
|
|
||||||
action, err := verifier.NextAction()
|
action, err := verifier.NextAction()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -463,13 +588,25 @@ func TestDoubleTap_RoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestInputText_RoundTrip(t *testing.T) {
|
func TestInputText_RoundTrip(t *testing.T) {
|
||||||
|
const treeJSON = `{
|
||||||
|
"attributes": {"resource-id": "root", "bounds": "[0,0,100,100]"},
|
||||||
|
"children": [
|
||||||
|
{"attributes": {"resource-id": "phone", "bounds": "[0,0,100,40]"}, "editable": true, "enabled": true, "children": []}
|
||||||
|
]
|
||||||
|
}`
|
||||||
verifier := newVerifier(t)
|
verifier := newVerifier(t)
|
||||||
mustLoad(t, verifier, `
|
loadActionSpec(t, verifier, `
|
||||||
globalThis.actions = __sanderling__.actions(() => [
|
import { actions, InputText } from "@sanderling/spec";
|
||||||
__sanderling__.inputText({ into: "id:phone", text: "+919876543210" }),
|
globalThis.actions = actions(() => {
|
||||||
]);
|
const phone = state.ax.find("id:phone");
|
||||||
|
return phone ? [InputText({ into: phone, text: "+919876543210" })] : [];
|
||||||
|
});
|
||||||
`)
|
`)
|
||||||
_ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}})
|
tree, err := hierarchy.Parse(treeJSON)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}, Tree: tree})
|
||||||
|
|
||||||
action, err := verifier.NextAction()
|
action, err := verifier.NextAction()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -495,7 +632,10 @@ func TestTypingBuiltin_TargetsEditableField(t *testing.T) {
|
|||||||
]
|
]
|
||||||
}`
|
}`
|
||||||
verifier := newVerifier(t)
|
verifier := newVerifier(t)
|
||||||
mustLoad(t, verifier, `globalThis.actions = __sanderling__.typing;`)
|
loadActionSpec(t, verifier, `
|
||||||
|
import { typing } from "@sanderling/spec";
|
||||||
|
globalThis.actions = typing;
|
||||||
|
`)
|
||||||
tree, err := hierarchy.Parse(treeJSON)
|
tree, err := hierarchy.Parse(treeJSON)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -513,11 +653,21 @@ func TestTypingBuiltin_TargetsEditableField(t *testing.T) {
|
|||||||
if action.X != 50 || action.Y != 20 {
|
if action.X != 50 || action.Y != 20 {
|
||||||
t.Errorf("coords = (%d,%d), want (50,20) at EmailField center", action.X, action.Y)
|
t.Errorf("coords = (%d,%d), want (50,20) at EmailField center", action.X, action.Y)
|
||||||
}
|
}
|
||||||
if !slices.Contains(inputCorpus, action.Text) {
|
// The typing builtin draws from corpus.ts INPUT_CORPUS; the single editable
|
||||||
t.Errorf("text %q not drawn from inputCorpus", action.Text)
|
// field means any draw lands here, so the text must be a corpus member.
|
||||||
|
if !slices.Contains(testInputCorpus, action.Text) {
|
||||||
|
t.Errorf("text %q not drawn from the input corpus", action.Text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// testInputCorpus mirrors pkg/spec/src/corpus.ts INPUT_CORPUS so the typing
|
||||||
|
// builtin's emitted text can be asserted to come from the shared pool.
|
||||||
|
var testInputCorpus = []string{
|
||||||
|
"", "a", strings.Repeat("a", 4096), "🙂🔥💸", " ", "\t\n", "-1",
|
||||||
|
"999999999999999999999", "0.0000001", "1e10", "'; DROP TABLE--",
|
||||||
|
"<script>alert(1)</script>", "../../etc/passwd", "%s%n", "NaN",
|
||||||
|
}
|
||||||
|
|
||||||
// TestTypingBuiltin_NoEditableYieldsErrNoAction verifies the typing generator
|
// TestTypingBuiltin_NoEditableYieldsErrNoAction verifies the typing generator
|
||||||
// declines (ErrNoAction) when no editable element is present, so a weighted
|
// declines (ErrNoAction) when no editable element is present, so a weighted
|
||||||
// layer falls through to another generator.
|
// layer falls through to another generator.
|
||||||
@@ -529,7 +679,10 @@ func TestTypingBuiltin_NoEditableYieldsErrNoAction(t *testing.T) {
|
|||||||
]
|
]
|
||||||
}`
|
}`
|
||||||
verifier := newVerifier(t)
|
verifier := newVerifier(t)
|
||||||
mustLoad(t, verifier, `globalThis.actions = __sanderling__.typing;`)
|
loadActionSpec(t, verifier, `
|
||||||
|
import { typing } from "@sanderling/spec";
|
||||||
|
globalThis.actions = typing;
|
||||||
|
`)
|
||||||
tree, err := hierarchy.Parse(treeJSON)
|
tree, err := hierarchy.Parse(treeJSON)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -582,12 +735,50 @@ globalThis.properties = {
|
|||||||
t.Errorf("verdict: got %v, want %v", got, ltl.VerdictViolated)
|
t.Errorf("verdict: got %v, want %v", got, ltl.VerdictViolated)
|
||||||
}
|
}
|
||||||
|
|
||||||
predicateErr := verifier.PredicateError("broken")
|
witness := verifier.Witness("broken")
|
||||||
if predicateErr == nil {
|
if witness == nil {
|
||||||
t.Fatal("PredicateError: got nil, want non-nil")
|
t.Fatal("Witness: got nil, want non-nil")
|
||||||
}
|
}
|
||||||
if !strings.Contains(predicateErr.Error(), "bad predicate") {
|
if !witness.IsError {
|
||||||
t.Errorf("PredicateError message: got %q, want to contain %q", predicateErr.Error(), "bad predicate")
|
t.Errorf("Witness.IsError = false, want true for a thrown predicate")
|
||||||
|
}
|
||||||
|
if !strings.Contains(witness.Reason, "bad predicate") {
|
||||||
|
t.Errorf("Witness.Reason: got %q, want to contain %q", witness.Reason, "bad predicate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unbounded eventually that never fires stays pending during the run and is
|
||||||
|
// only reported by Finalize at run end. The witness records why it failed.
|
||||||
|
func TestFinalize_UnmetEventuallyReportedWithWitness(t *testing.T) {
|
||||||
|
const spec = `
|
||||||
|
globalThis.flag = __sanderling__.extract(state => state.snapshots["flag"] ?? false, "flag");
|
||||||
|
globalThis.properties = {
|
||||||
|
flagEventuallyTrue: __sanderling__.always(__sanderling__.eventually(() => flag.current === true)),
|
||||||
|
};
|
||||||
|
`
|
||||||
|
verifier := newVerifier(t)
|
||||||
|
mustLoad(t, verifier, spec)
|
||||||
|
|
||||||
|
for step := 0; step < 3; step++ {
|
||||||
|
if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{"flag": json.RawMessage(`false`)}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
verdicts := verifier.EvaluateProperties()
|
||||||
|
if got := verdicts["flagEventuallyTrue"]; got != ltl.VerdictPending {
|
||||||
|
t.Fatalf("step %d: verdict = %v, want pending", step, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ended := verifier.Finalize()
|
||||||
|
if !slices.Contains(ended, "flagEventuallyTrue") {
|
||||||
|
t.Fatalf("Finalize ended = %v, want to contain flagEventuallyTrue", ended)
|
||||||
|
}
|
||||||
|
witness := verifier.Witness("flagEventuallyTrue")
|
||||||
|
if witness == nil {
|
||||||
|
t.Fatal("Witness = nil after Finalize, want non-nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(witness.Reason, "eventually") {
|
||||||
|
t.Errorf("Witness.Reason = %q, want to mention eventually", witness.Reason)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -702,15 +893,30 @@ func TestSelector_BooleanValue(t *testing.T) {
|
|||||||
// dynamic array picks the same element under the same seed across runs. The
|
// dynamic array picks the same element under the same seed across runs. The
|
||||||
// folio spec relies on this to replace Math.random() in account-card taps.
|
// folio spec relies on this to replace Math.random() in account-card taps.
|
||||||
func TestFrom_SeededReplayIsDeterministic(t *testing.T) {
|
func TestFrom_SeededReplayIsDeterministic(t *testing.T) {
|
||||||
|
const treeJSON = `{
|
||||||
|
"attributes": {"resource-id": "root", "bounds": "[0,0,100,200]"},
|
||||||
|
"children": [
|
||||||
|
{"attributes": {"resource-id": "card_a", "bounds": "[0,0,100,40]"}, "clickable": true, "enabled": true, "children": []},
|
||||||
|
{"attributes": {"resource-id": "card_b", "bounds": "[0,40,100,80]"}, "clickable": true, "enabled": true, "children": []},
|
||||||
|
{"attributes": {"resource-id": "card_c", "bounds": "[0,80,100,120]"}, "clickable": true, "enabled": true, "children": []},
|
||||||
|
{"attributes": {"resource-id": "card_d", "bounds": "[0,120,100,160]"}, "clickable": true, "enabled": true, "children": []}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
tree, err := hierarchy.Parse(treeJSON)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
pickedSequence := func(seed uint64) []string {
|
pickedSequence := func(seed uint64) []string {
|
||||||
verifier := newVerifier(t, WithRand(rand.New(rand.NewPCG(seed, 0))))
|
verifier := newVerifier(t, WithSeed(seed))
|
||||||
mustLoad(t, verifier, `
|
loadActionSpec(t, verifier, `
|
||||||
globalThis.actions = __sanderling__.actions(() => {
|
import { actions, from, Tap } from "@sanderling/spec";
|
||||||
const cards = ["card_a", "card_b", "card_c", "card_d"];
|
const cards = ["id:card_a", "id:card_b", "id:card_c", "id:card_d"];
|
||||||
return [__sanderling__.tap({ on: __sanderling__.from(cards).generate() })];
|
globalThis.actions = actions(() => {
|
||||||
|
const target = state.ax.find(from(cards).generate());
|
||||||
|
return target ? [Tap({ on: target })] : [];
|
||||||
});
|
});
|
||||||
`)
|
`)
|
||||||
_ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}})
|
_ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}, Tree: tree})
|
||||||
var picks []string
|
var picks []string
|
||||||
for range 20 {
|
for range 20 {
|
||||||
action, err := verifier.NextAction()
|
action, err := verifier.NextAction()
|
||||||
@@ -741,13 +947,14 @@ func TestFrom_SeededReplayIsDeterministic(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PredicateError must reflect the most recent step's predicate result, not a
|
// A thrown predicate is a witnessed violation at the step it first throws, and
|
||||||
// latched first-step error. The runner logs PredicateError once per step; if it
|
// the property latches violated thereafter (sticky always semantics). The
|
||||||
// stays pinned to step 1 forever, downstream debugging looks frozen even though
|
// witness captures the onset step's error text (count=1) and the extractor
|
||||||
// the underlying state is changing.
|
// snapshot at that step; it does not keep updating to later counts because the
|
||||||
func TestPredicateError_ReflectsCurrentStepNotFirstStep(t *testing.T) {
|
// verdict has latched.
|
||||||
|
func TestThrowingPredicate_WitnessCapturesOnsetStep(t *testing.T) {
|
||||||
const spec = `
|
const spec = `
|
||||||
globalThis.counter = __sanderling__.extract(state => state.snapshots["count"]);
|
globalThis.counter = __sanderling__.extract(state => state.snapshots["count"], "counter");
|
||||||
globalThis.properties = {
|
globalThis.properties = {
|
||||||
reportsCounter: __sanderling__.always(() => { throw new Error("count=" + counter.current); }),
|
reportsCounter: __sanderling__.always(() => { throw new Error("count=" + counter.current); }),
|
||||||
};
|
};
|
||||||
@@ -760,16 +967,24 @@ globalThis.properties = {
|
|||||||
if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{"count": raw}}); err != nil {
|
if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{"count": raw}}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
_ = verifier.EvaluateProperties()
|
verdicts := verifier.EvaluateProperties()
|
||||||
|
if got := verdicts["reportsCounter"]; got != ltl.VerdictViolated {
|
||||||
|
t.Fatalf("step %d: verdict = %v, want violated", step, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
got := verifier.PredicateError("reportsCounter")
|
witness := verifier.Witness("reportsCounter")
|
||||||
if got == nil {
|
if witness == nil {
|
||||||
t.Fatalf("step %d: PredicateError = nil, want non-nil", step)
|
t.Fatal("Witness = nil, want non-nil")
|
||||||
}
|
}
|
||||||
want := "count=" + string(rune('0'+step))
|
if witness.Step != 1 {
|
||||||
if !strings.Contains(got.Error(), want) {
|
t.Errorf("Witness.Step = %d, want 1 (onset)", witness.Step)
|
||||||
t.Errorf("step %d: PredicateError = %q, want to contain %q", step, got.Error(), want)
|
}
|
||||||
}
|
if !strings.Contains(witness.Reason, "count=1") {
|
||||||
|
t.Errorf("Witness.Reason = %q, want to contain %q", witness.Reason, "count=1")
|
||||||
|
}
|
||||||
|
if got := string(witness.Extractors["counter"]); got != `"1"` {
|
||||||
|
t.Errorf("Witness.Extractors[counter] = %s, want %q", got, `"1"`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -845,8 +1060,6 @@ globalThis.card = card;
|
|||||||
globalThis.properties = {
|
globalThis.properties = {
|
||||||
hasTestTag: __sanderling__.always(() => typeof card.current.attrs.testTag === "string"),
|
hasTestTag: __sanderling__.always(() => typeof card.current.attrs.testTag === "string"),
|
||||||
};
|
};
|
||||||
|
|
||||||
globalThis.actions = __sanderling__.actions(() => []);
|
|
||||||
`
|
`
|
||||||
|
|
||||||
// TestSelectorStringFromJS_CanonicalGrammar guarantees the selector tag stamped
|
// TestSelectorStringFromJS_CanonicalGrammar guarantees the selector tag stamped
|
||||||
@@ -1019,3 +1232,27 @@ func TestOverrideExtractorValues_PropagatesNestedObjectFields(t *testing.T) {
|
|||||||
t.Errorf("scalar field missing: card.current.balance = %d, want 12345", got)
|
t.Errorf("scalar field missing: card.current.balance = %d, want 12345", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestUnsupportedVerbs_CollectedDedupedInOrder drives the real host binding the
|
||||||
|
// shared picker invokes (__sanderlingHost__.reportUnsupported) and asserts the
|
||||||
|
// verifier collects each verb once, in first-seen order, for the run report.
|
||||||
|
func TestUnsupportedVerbs_CollectedDedupedInOrder(t *testing.T) {
|
||||||
|
verifier := newVerifier(t)
|
||||||
|
report, ok := goja.AssertFunction(
|
||||||
|
verifier.runtime.GlobalObject().Get("__sanderlingHost__").
|
||||||
|
ToObject(verifier.runtime).Get("reportUnsupported"),
|
||||||
|
)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("reportUnsupported host binding missing")
|
||||||
|
}
|
||||||
|
for _, verb := range []string{"scrolls", "swipes", "scrolls", "longPresses"} {
|
||||||
|
if _, err := report(goja.Undefined(), verifier.runtime.ToValue(verb)); err != nil {
|
||||||
|
t.Fatalf("reportUnsupported(%q): %v", verb, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got := verifier.UnsupportedVerbs()
|
||||||
|
want := []string{"scrolls", "swipes", "longPresses"}
|
||||||
|
if !slices.Equal(got, want) {
|
||||||
|
t.Errorf("UnsupportedVerbs = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
+222
-429
@@ -1,3 +1,4 @@
|
|||||||
|
// Package verifier runs the spec's extractors and property formulas against each observed step.
|
||||||
package verifier
|
package verifier
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -6,9 +7,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
"math/rand/v2"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
@@ -23,14 +22,17 @@ type Verifier struct {
|
|||||||
formulas []*formulaState
|
formulas []*formulaState
|
||||||
formulaSpecs []formulaSpec
|
formulaSpecs []formulaSpec
|
||||||
|
|
||||||
properties map[string]int // property name -> formula-spec index
|
properties map[string]int // property name -> formula-spec index
|
||||||
actionGenerator goja.Value
|
|
||||||
setupGenerator goja.Value
|
// nextActionFn is the bundle-installed __sanderlingNextAction__, which runs
|
||||||
|
// the shared picker (pick.ts) over the shared Pcg.
|
||||||
|
nextActionFn goja.Callable
|
||||||
|
|
||||||
evaluators map[string]*ltl.Evaluator
|
evaluators map[string]*ltl.Evaluator
|
||||||
|
|
||||||
priorVerdicts map[string]ltl.Verdict
|
priorVerdicts map[string]ltl.Verdict
|
||||||
newlyViolated []string
|
newlyViolated []string
|
||||||
|
witnesses map[string]Witness
|
||||||
|
|
||||||
lastTree *hierarchy.Tree
|
lastTree *hierarchy.Tree
|
||||||
lastAction *Action
|
lastAction *Action
|
||||||
@@ -40,14 +42,41 @@ type Verifier struct {
|
|||||||
runStart time.Time
|
runStart time.Time
|
||||||
|
|
||||||
appPackage string
|
appPackage string
|
||||||
|
platform string
|
||||||
|
seed uint64
|
||||||
|
|
||||||
rng *rand.Rand
|
// unsupported collects verbs the picker requested but the platform cannot
|
||||||
|
// dispatch (reportUnsupported host callback), deduped and in first-seen
|
||||||
|
// order, so the runner can surface them in the run report.
|
||||||
|
unsupported []string
|
||||||
|
unsupportedSeen map[string]bool
|
||||||
|
|
||||||
|
// extracting is true only while an extractor getter is running. The handle's
|
||||||
|
// current/previous accessors consult it so a getter that reaches into
|
||||||
|
// another extractor's handle throws instead of reading a stale value.
|
||||||
|
extracting bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnsupportedVerbs returns the verbs the picker requested that this platform
|
||||||
|
// cannot dispatch, deduped and in first-seen order.
|
||||||
|
func (v *Verifier) UnsupportedVerbs() []string {
|
||||||
|
return v.unsupported
|
||||||
}
|
}
|
||||||
|
|
||||||
type Option func(*Verifier)
|
type Option func(*Verifier)
|
||||||
|
|
||||||
func WithRand(rng *rand.Rand) Option {
|
// WithSeed sets the 64-bit seed the JS picker constructs its Pcg from
|
||||||
return func(v *Verifier) { v.rng = rng }
|
// (new Pcg(seed, 0), matching the web bundle's SANDERLING_SEED). The verifier
|
||||||
|
// exposes it to the bundle via the __sanderlingHost__.seedHi/seedLo binds.
|
||||||
|
func WithSeed(seed uint64) Option {
|
||||||
|
return func(v *Verifier) { v.seed = seed }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPlatform names the platform the host reports to the picker
|
||||||
|
// ("android"/"ios"/"web"); it drives the verb-support matrix and the press-key
|
||||||
|
// pool. Empty defaults to "android".
|
||||||
|
func WithPlatform(platform string) Option {
|
||||||
|
return func(v *Verifier) { v.platform = platform }
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithAppPackage scopes random-action target selection to the app under test.
|
// WithAppPackage scopes random-action target selection to the app under test.
|
||||||
@@ -60,15 +89,20 @@ func WithAppPackage(appPackage string) Option {
|
|||||||
|
|
||||||
func New(options ...Option) (*Verifier, error) {
|
func New(options ...Option) (*Verifier, error) {
|
||||||
verifier := &Verifier{
|
verifier := &Verifier{
|
||||||
runtime: goja.New(),
|
runtime: goja.New(),
|
||||||
properties: map[string]int{},
|
properties: map[string]int{},
|
||||||
evaluators: map[string]*ltl.Evaluator{},
|
evaluators: map[string]*ltl.Evaluator{},
|
||||||
priorVerdicts: map[string]ltl.Verdict{},
|
priorVerdicts: map[string]ltl.Verdict{},
|
||||||
rng: rand.New(rand.NewPCG(0, 0)),
|
witnesses: map[string]Witness{},
|
||||||
|
platform: "android",
|
||||||
|
unsupportedSeen: map[string]bool{},
|
||||||
}
|
}
|
||||||
for _, option := range options {
|
for _, option := range options {
|
||||||
option(verifier)
|
option(verifier)
|
||||||
}
|
}
|
||||||
|
if verifier.platform == "" {
|
||||||
|
verifier.platform = "android"
|
||||||
|
}
|
||||||
if err := verifier.installRuntimeBindings(); err != nil {
|
if err := verifier.installRuntimeBindings(); err != nil {
|
||||||
return nil, fmt.Errorf("install bindings: %w", err)
|
return nil, fmt.Errorf("install bindings: %w", err)
|
||||||
}
|
}
|
||||||
@@ -105,12 +139,14 @@ func (v *Verifier) Load(source string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if actionsValue := v.runtime.GlobalObject().Get("actions"); actionsValue != nil && !goja.IsUndefined(actionsValue) && !goja.IsNull(actionsValue) {
|
// The bundle's goja runtime entry installs __sanderlingNextAction__ once the
|
||||||
v.actionGenerator = actionsValue
|
// spec assigned globalThis.actions. Capture it; a spec bundled without the
|
||||||
}
|
// runtime entry (raw-JS unit fixtures) leaves it nil and NextAction reports
|
||||||
|
// ErrNoAction.
|
||||||
if setupValue := v.runtime.GlobalObject().Get("setup"); setupValue != nil && !goja.IsUndefined(setupValue) && !goja.IsNull(setupValue) {
|
if fn := v.runtime.GlobalObject().Get("__sanderlingNextAction__"); fn != nil {
|
||||||
v.setupGenerator = setupValue
|
if callable, ok := goja.AssertFunction(fn); ok {
|
||||||
|
v.nextActionFn = callable
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -139,7 +175,8 @@ func (v *Verifier) buildFormulaNode(index int) (ltl.Formula, error) {
|
|||||||
case specKindPure:
|
case specKindPure:
|
||||||
return ltl.Pure(spec.pureValue), nil
|
return ltl.Pure(spec.pureValue), nil
|
||||||
case specKindThunk:
|
case specKindThunk:
|
||||||
return ltl.Thunk(v.formulaThunk(spec.predicateIndex)), nil
|
name := fmt.Sprintf("p%d", spec.predicateIndex)
|
||||||
|
return ltl.ThunkNamed(name, v.formulaThunk(spec.predicateIndex)), nil
|
||||||
case specKindNow:
|
case specKindNow:
|
||||||
child, err := v.buildFormulaNode(spec.childA)
|
child, err := v.buildFormulaNode(spec.childA)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -244,21 +281,28 @@ func (v *Verifier) PushSnapshot(input SnapshotInput) error {
|
|||||||
// Extractor previous/current advance exactly once per PushSnapshot.
|
// Extractor previous/current advance exactly once per PushSnapshot.
|
||||||
// Predicate thunks read these slots but never trigger advancement, so
|
// Predicate thunks read these slots but never trigger advancement, so
|
||||||
// invoking a thunk multiple times between snapshots is value-stable.
|
// invoking a thunk multiple times between snapshots is value-stable.
|
||||||
// refreshPredicateErrors relies on this to safely re-call predicates.
|
|
||||||
for index, extractor := range v.extractors {
|
for index, extractor := range v.extractors {
|
||||||
previous := extractor.handle.Get("current")
|
extractor.previousValue = extractor.currentValue
|
||||||
_ = extractor.handle.Set("previous", previous)
|
newValue, err := v.runExtractor(extractor, state)
|
||||||
newValue, err := extractor.getter(goja.Undefined(), state)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("extractor %d: %w", index, err)
|
return fmt.Errorf("extractor %d: %w", index, err)
|
||||||
}
|
}
|
||||||
_ = extractor.handle.Set("current", newValue)
|
extractor.currentValue = newValue
|
||||||
extractor.prev = extractor.curr
|
extractor.prev = extractor.curr
|
||||||
extractor.curr = encodeExtractorValue(newValue)
|
extractor.curr = encodeExtractorValue(newValue)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runExtractor invokes an extractor's getter with the extracting flag set, so a
|
||||||
|
// getter that reads another extractor's current/previous throws. The flag is
|
||||||
|
// cleared even if the getter panics.
|
||||||
|
func (v *Verifier) runExtractor(extractor *extractorState, state goja.Value) (goja.Value, error) {
|
||||||
|
v.extracting = true
|
||||||
|
defer func() { v.extracting = false }()
|
||||||
|
return extractor.getter(goja.Undefined(), state)
|
||||||
|
}
|
||||||
|
|
||||||
// encodeExtractorValue produces a stable JSON encoding of an extractor's
|
// encodeExtractorValue produces a stable JSON encoding of an extractor's
|
||||||
// current value for diff comparison. goja values that don't survive Export
|
// current value for diff comparison. goja values that don't survive Export
|
||||||
// (e.g. wrapped host functions) yield nil; callers treat nil as "unknown" and
|
// (e.g. wrapped host functions) yield nil; callers treat nil as "unknown" and
|
||||||
@@ -328,7 +372,7 @@ func (v *Verifier) OverrideExtractorValues(overrides map[int]json.RawMessage) (s
|
|||||||
if conversionErr != nil {
|
if conversionErr != nil {
|
||||||
return skipped, fmt.Errorf("extractor override %d: %w", index, conversionErr)
|
return skipped, fmt.Errorf("extractor override %d: %w", index, conversionErr)
|
||||||
}
|
}
|
||||||
_ = v.extractors[index].handle.Set("current", value)
|
v.extractors[index].currentValue = value
|
||||||
}
|
}
|
||||||
return skipped, nil
|
return skipped, nil
|
||||||
}
|
}
|
||||||
@@ -362,12 +406,12 @@ func (v *Verifier) EvaluateProperties() map[string]ltl.Verdict {
|
|||||||
for name, evaluator := range v.evaluators {
|
for name, evaluator := range v.evaluators {
|
||||||
verdicts[name] = evaluator.ObserveAt(stepTime)
|
verdicts[name] = evaluator.ObserveAt(stepTime)
|
||||||
}
|
}
|
||||||
v.refreshPredicateErrors()
|
|
||||||
|
|
||||||
var onset []string
|
var onset []string
|
||||||
for name, verdict := range verdicts {
|
for name, verdict := range verdicts {
|
||||||
if verdict == ltl.VerdictViolated && v.priorVerdicts[name] != ltl.VerdictViolated {
|
if verdict == ltl.VerdictViolated && v.priorVerdicts[name] != ltl.VerdictViolated {
|
||||||
onset = append(onset, name)
|
onset = append(onset, name)
|
||||||
|
v.captureWitness(name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort.Strings(onset)
|
sort.Strings(onset)
|
||||||
@@ -380,6 +424,89 @@ func (v *Verifier) EvaluateProperties() map[string]ltl.Verdict {
|
|||||||
return verdicts
|
return verdicts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Witness is the verifier-level record of a property violation: the LTL reason
|
||||||
|
// (a predicate's thrown-error text, "predicate false", or a liveness failure),
|
||||||
|
// the step it fired at, and a snapshot of every extractor's current value at
|
||||||
|
// that step. The snapshot lets a reader see the state that produced the
|
||||||
|
// violation without replaying the run.
|
||||||
|
type Witness struct {
|
||||||
|
Property string
|
||||||
|
Reason string
|
||||||
|
Step int
|
||||||
|
IsError bool
|
||||||
|
Extractors map[string]json.RawMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
// captureWitness records the witness for a property that just transitioned to
|
||||||
|
// violated, snapshotting the current extractor values so the cause is visible
|
||||||
|
// after the run.
|
||||||
|
func (v *Verifier) captureWitness(name string) {
|
||||||
|
evaluator, ok := v.evaluators[name]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
violation := evaluator.Violation()
|
||||||
|
if violation == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v.witnesses[name] = Witness{
|
||||||
|
Property: name,
|
||||||
|
Reason: violation.Reason,
|
||||||
|
Step: violation.Step,
|
||||||
|
IsError: violation.IsError,
|
||||||
|
Extractors: v.extractorSnapshot(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractorSnapshot encodes every named extractor's current value as JSON. A
|
||||||
|
// nil value (extractor never advanced or its value did not survive Export)
|
||||||
|
// is recorded as JSON null.
|
||||||
|
func (v *Verifier) extractorSnapshot() map[string]json.RawMessage {
|
||||||
|
if len(v.extractors) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
snapshot := make(map[string]json.RawMessage, len(v.extractors))
|
||||||
|
for _, extractor := range v.extractors {
|
||||||
|
value := extractor.curr
|
||||||
|
if value == nil {
|
||||||
|
value = []byte("null")
|
||||||
|
}
|
||||||
|
snapshot[extractor.name] = append(json.RawMessage(nil), value...)
|
||||||
|
}
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
// Witness returns the captured violation witness for a property, or nil if the
|
||||||
|
// property has not violated. Callers consult this after EvaluateProperties (or
|
||||||
|
// Finalize) reports a violation to surface the cause and the state at onset.
|
||||||
|
func (v *Verifier) Witness(name string) *Witness {
|
||||||
|
witness, ok := v.witnesses[name]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &witness
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finalize drives each evaluator to its terminal verdict and returns the names
|
||||||
|
// of properties that violate only at run end (a liveness obligation that never
|
||||||
|
// discharged), capturing a witness for each. Properties already violated
|
||||||
|
// mid-run are not re-reported here.
|
||||||
|
func (v *Verifier) Finalize() []string {
|
||||||
|
var ended []string
|
||||||
|
for name, evaluator := range v.evaluators {
|
||||||
|
if v.priorVerdicts[name] == ltl.VerdictViolated {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if evaluator.Finalize() == ltl.VerdictViolated {
|
||||||
|
ended = append(ended, name)
|
||||||
|
v.captureWitness(name)
|
||||||
|
v.priorVerdicts[name] = ltl.VerdictViolated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(ended)
|
||||||
|
return ended
|
||||||
|
}
|
||||||
|
|
||||||
// NewlyViolatedProperties returns the names of properties whose verdict
|
// NewlyViolatedProperties returns the names of properties whose verdict
|
||||||
// transitioned from non-Violated to Violated on the most recent
|
// transitioned from non-Violated to Violated on the most recent
|
||||||
// EvaluateProperties call, sorted lexicographically. Returns nil if no
|
// EvaluateProperties call, sorted lexicographically. Returns nil if no
|
||||||
@@ -396,14 +523,14 @@ func (v *Verifier) NewlyViolatedProperties() []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Residuals returns the residual formula for each registered property after
|
// Residuals returns the residual formula for each registered property after
|
||||||
// the most recent EvaluateProperties call. Properties that errored during
|
// the most recent EvaluateProperties call. Properties whose violation was
|
||||||
// predicate evaluation surface as ErrorFormula so the inspect UI can render
|
// caused by a thrown predicate surface as ErrorFormula, sourced from the
|
||||||
// "predicate threw" inline.
|
// captured witness, so the inspect UI can render "predicate threw" inline.
|
||||||
func (v *Verifier) Residuals() map[string]ltl.Formula {
|
func (v *Verifier) Residuals() map[string]ltl.Formula {
|
||||||
residuals := map[string]ltl.Formula{}
|
residuals := map[string]ltl.Formula{}
|
||||||
for name, evaluator := range v.evaluators {
|
for name, evaluator := range v.evaluators {
|
||||||
if predicateErr := v.PredicateError(name); predicateErr != nil {
|
if witness, ok := v.witnesses[name]; ok && witness.IsError {
|
||||||
residuals[name] = ltl.ErrorFormula{Message: predicateErr.Error()}
|
residuals[name] = ltl.ErrorFormula{Message: witness.Reason}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
residuals[name] = evaluator.Residual()
|
residuals[name] = evaluator.Residual()
|
||||||
@@ -411,174 +538,42 @@ func (v *Verifier) Residuals() map[string]ltl.Formula {
|
|||||||
return residuals
|
return residuals
|
||||||
}
|
}
|
||||||
|
|
||||||
// NextAction resolves an action for the current step. The setup generator,
|
// NextAction resolves an action for the current step by invoking the bundled
|
||||||
// when registered, runs first; if it yields an action, that wins. When setup
|
// __sanderlingNextAction__(), which runs the SHARED picker (pick.ts) over the
|
||||||
// returns ErrNoAction (all branches empty) the call falls through to the
|
// shared Pcg. Setup-generator precedence and the 16-attempt retry both live in
|
||||||
// root action generator with the existing retry semantics. Setup is consulted
|
// runtime-entry.ts now, so this is a thin call-and-decode. A null result (the
|
||||||
// every step, so state regression (e.g. a logout under fuzz) automatically
|
// generator declined to act) reports ErrNoAction.
|
||||||
// re-engages the precondition.
|
|
||||||
func (v *Verifier) NextAction() (Action, error) {
|
func (v *Verifier) NextAction() (Action, error) {
|
||||||
if v.setupGenerator != nil {
|
if v.nextActionFn == nil {
|
||||||
action, err := v.resolveGenerator(v.setupGenerator)
|
|
||||||
if err == nil {
|
|
||||||
return action, nil
|
|
||||||
}
|
|
||||||
if !errors.Is(err, ErrNoAction) {
|
|
||||||
return Action{}, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if v.actionGenerator == nil {
|
|
||||||
return Action{}, ErrNoAction
|
return Action{}, ErrNoAction
|
||||||
}
|
}
|
||||||
const maxRetries = 16
|
value, err := v.nextActionFn(goja.Undefined())
|
||||||
for range maxRetries {
|
if err != nil {
|
||||||
action, err := v.resolveGenerator(v.actionGenerator)
|
return Action{}, fmt.Errorf("next action: %w", err)
|
||||||
if err == nil {
|
|
||||||
return action, nil
|
|
||||||
}
|
|
||||||
if !errors.Is(err, ErrNoAction) {
|
|
||||||
return Action{}, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return Action{}, ErrNoAction
|
if value == nil || goja.IsNull(value) || goja.IsUndefined(value) {
|
||||||
|
return Action{}, ErrNoAction
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(value.Export())
|
||||||
|
if err != nil {
|
||||||
|
return Action{}, fmt.Errorf("marshal action: %w", err)
|
||||||
|
}
|
||||||
|
return DecodeAction(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
var ErrNoAction = errors.New("verifier: no action available")
|
var ErrNoAction = errors.New("verifier: no action available")
|
||||||
|
|
||||||
func (v *Verifier) formulaThunk(index int) func() bool {
|
func (v *Verifier) formulaThunk(index int) func() (bool, error) {
|
||||||
return func() bool {
|
return func() (bool, error) {
|
||||||
formula := v.formulas[index]
|
formula := v.formulas[index]
|
||||||
result, err := formula.predicate(goja.Undefined())
|
result, err := formula.predicate(goja.Undefined())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
formula.err = err
|
return false, err
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
formula.err = nil
|
return result.ToBoolean(), nil
|
||||||
return result.ToBoolean()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// refreshPredicateErrors re-invokes every registered predicate so that
|
|
||||||
// formula.err reflects the current step rather than a latched first-step
|
|
||||||
// throw. EvaluateProperties short-circuits once a property has latched to
|
|
||||||
// violated, so without this refresh the runner's per-step "predicate error"
|
|
||||||
// log freezes on whatever the predicate threw at step 1. The refreshed errors
|
|
||||||
// have no effect on verdicts.
|
|
||||||
//
|
|
||||||
// Invariant: predicates may be re-invoked here outside the LTL gate that
|
|
||||||
// would normally skip them (e.g. an `implies` consequent whose antecedent is
|
|
||||||
// false). They must therefore be side-effect-free reads of extractor state;
|
|
||||||
// any spec that asserts internal preconditions inside a predicate could
|
|
||||||
// surface a spurious error in the inspect UI without affecting verdicts.
|
|
||||||
func (v *Verifier) refreshPredicateErrors() {
|
|
||||||
for _, formula := range v.formulas {
|
|
||||||
result, err := formula.predicate(goja.Undefined())
|
|
||||||
if err != nil {
|
|
||||||
formula.err = err
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
_ = result
|
|
||||||
formula.err = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PredicateError returns the first goja error raised by any thunk in the
|
|
||||||
// named property's formula tree, or nil if none fired. Callers typically
|
|
||||||
// consult this after EvaluateProperties reports a violation to distinguish
|
|
||||||
// a genuine predicate-false verdict from a malformed spec.
|
|
||||||
func (v *Verifier) PredicateError(name string) error {
|
|
||||||
rootIndex, ok := v.properties[name]
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return v.firstThunkError(rootIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *Verifier) firstThunkError(index int) error {
|
|
||||||
if index < 0 || index >= len(v.formulaSpecs) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
spec := v.formulaSpecs[index]
|
|
||||||
switch spec.kind {
|
|
||||||
case specKindThunk:
|
|
||||||
return v.formulas[spec.predicateIndex].err
|
|
||||||
case specKindImplies, specKindOr, specKindAnd:
|
|
||||||
if err := v.firstThunkError(spec.childA); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return v.firstThunkError(spec.childB)
|
|
||||||
case specKindNow, specKindNext, specKindEventually, specKindNot, specKindAlways:
|
|
||||||
return v.firstThunkError(spec.childA)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *Verifier) resolveGenerator(generator goja.Value) (Action, error) {
|
|
||||||
object := generator.ToObject(v.runtime)
|
|
||||||
if object == nil {
|
|
||||||
return Action{}, fmt.Errorf("generator is not an object")
|
|
||||||
}
|
|
||||||
kindValue := object.Get(tagInternalKind)
|
|
||||||
if kindValue == nil {
|
|
||||||
return Action{}, fmt.Errorf("generator missing internal kind tag")
|
|
||||||
}
|
|
||||||
switch kindValue.String() {
|
|
||||||
case internalKindActions:
|
|
||||||
generateValue := object.Get("generate")
|
|
||||||
generate, ok := goja.AssertFunction(generateValue)
|
|
||||||
if !ok {
|
|
||||||
return Action{}, fmt.Errorf("actions handle missing generate function")
|
|
||||||
}
|
|
||||||
result, err := generate(goja.Undefined())
|
|
||||||
if err != nil {
|
|
||||||
return Action{}, fmt.Errorf("generate: %w", err)
|
|
||||||
}
|
|
||||||
return v.pickFromResult(result)
|
|
||||||
case internalKindWeighted:
|
|
||||||
entries := object.Get("entries").ToObject(v.runtime)
|
|
||||||
if entries == nil {
|
|
||||||
return Action{}, fmt.Errorf("weighted handle missing entries")
|
|
||||||
}
|
|
||||||
picked, err := v.pickWeighted(entries)
|
|
||||||
if err != nil {
|
|
||||||
return Action{}, err
|
|
||||||
}
|
|
||||||
return v.resolveGenerator(picked)
|
|
||||||
case internalKindBuiltinTaps:
|
|
||||||
return v.generateRandomTap()
|
|
||||||
case internalKindBuiltinDoubleTaps:
|
|
||||||
return v.generateRandomDoubleTap()
|
|
||||||
case internalKindBuiltinTyping:
|
|
||||||
return v.generateRandomInput()
|
|
||||||
case internalKindBuiltinSwipes:
|
|
||||||
return v.generateRandomSwipe()
|
|
||||||
case internalKindBuiltinWaitOnce:
|
|
||||||
return Action{Kind: ActionKindWait, DurationMillis: 500}, nil
|
|
||||||
case internalKindBuiltinPressKey:
|
|
||||||
return v.generateRandomPressKey()
|
|
||||||
case internalKindBuiltinLongPresses:
|
|
||||||
return v.generateRandomLongPress()
|
|
||||||
case internalKindBuiltinScrolls:
|
|
||||||
return v.generateRandomScroll()
|
|
||||||
default:
|
|
||||||
return Action{}, fmt.Errorf("unknown generator kind %q", kindValue.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// generateRandomTap picks a visible, tappable element from the last
|
|
||||||
// hierarchy snapshot and returns a Tap action targeting its center.
|
|
||||||
func (v *Verifier) generateRandomTap() (Action, error) {
|
|
||||||
return v.generateRandomTapKind(ActionKindTap)
|
|
||||||
}
|
|
||||||
|
|
||||||
// generateRandomDoubleTap is the DoubleTap counterpart of generateRandomTap.
|
|
||||||
// Real user gestures include double-tap (image zoom, like-to-favorite,
|
|
||||||
// play/pause); a fuzzer that never emits one cannot exercise either those
|
|
||||||
// features or the sub-100ms race windows that single-step Tap cadence misses.
|
|
||||||
func (v *Verifier) generateRandomDoubleTap() (Action, error) {
|
|
||||||
return v.generateRandomTapKind(ActionKindDoubleTap)
|
|
||||||
}
|
|
||||||
|
|
||||||
// inScope reports whether an element belongs to the app under test. Nodes from
|
// inScope reports whether an element belongs to the app under test. Nodes from
|
||||||
// another package (the soft keyboard, system UI, permission dialogs) are out of
|
// another package (the soft keyboard, system UI, permission dialogs) are out of
|
||||||
// scope. An unset app package or an element with no package falls through to in
|
// scope. An unset app package or an element with no package falls through to in
|
||||||
@@ -590,31 +585,6 @@ func (v *Verifier) inScope(element *hierarchy.Element) bool {
|
|||||||
return element.Package == v.appPackage
|
return element.Package == v.appPackage
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *Verifier) generateRandomTapKind(kind ActionKind) (Action, error) {
|
|
||||||
if v.lastTree == nil {
|
|
||||||
return Action{}, ErrNoAction
|
|
||||||
}
|
|
||||||
candidates := make([]*hierarchy.Element, 0, len(v.lastTree.Elements))
|
|
||||||
for _, element := range v.lastTree.Elements {
|
|
||||||
if !element.Clickable || !element.Enabled {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !v.inScope(element) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if element.Bounds.Right-element.Bounds.Left <= 0 || element.Bounds.Bottom-element.Bounds.Top <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
candidates = append(candidates, element)
|
|
||||||
}
|
|
||||||
if len(candidates) == 0 {
|
|
||||||
return Action{}, ErrNoAction
|
|
||||||
}
|
|
||||||
picked := candidates[v.rng.IntN(len(candidates))]
|
|
||||||
x, y := picked.Bounds.Center()
|
|
||||||
return Action{Kind: kind, On: selectorForElement(v.lastTree, picked), X: x, Y: y}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// selectorForElement builds a canonical "key:value" selector that resolves
|
// selectorForElement builds a canonical "key:value" selector that resolves
|
||||||
// back to the given element via hierarchy.Tree.Find. Prefers resource-id (the
|
// back to the given element via hierarchy.Tree.Find. Prefers resource-id (the
|
||||||
// testTag carrier on Android / accessibilityIdentifier on iOS), falling back
|
// testTag carrier on Android / accessibilityIdentifier on iOS), falling back
|
||||||
@@ -656,237 +626,60 @@ func selectorForElement(tree *hierarchy.Tree, element *hierarchy.Element) string
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// inputCorpus is the edge-case string pool the typing builtin draws from to
|
// candidatesForVerb enumerates the host-side targets a builtin verb may draw
|
||||||
// stress field parsing: empty, whitespace, overflow length, unicode, numeric
|
// from, in v.lastTree.Elements ORDER (the order is part of the picker's parity
|
||||||
// boundaries, and common injection payloads.
|
// contract). The filters are LIFTED from the old Go picker:
|
||||||
var inputCorpus = []string{
|
//
|
||||||
"",
|
// taps/doubleTaps/longPresses: clickable + enabled + positive bounds
|
||||||
"a",
|
// typing: editable + enabled + positive bounds
|
||||||
strings.Repeat("a", 4096),
|
// scrolls: scrollable attribute + positive bounds
|
||||||
"🙂🔥💸",
|
// swipes: any in-scope element
|
||||||
" ",
|
//
|
||||||
"\t\n",
|
// Every candidate carries the resolving selector so the runner can re-route by
|
||||||
"-1",
|
// id/text. Out-of-scope nodes (the soft keyboard, system UI) are always dropped.
|
||||||
"999999999999999999999",
|
func (v *Verifier) candidatesForVerb(verb string) []candidate {
|
||||||
"0.0000001",
|
|
||||||
"1e10",
|
|
||||||
"'; DROP TABLE--",
|
|
||||||
"<script>alert(1)</script>",
|
|
||||||
"../../etc/passwd",
|
|
||||||
"%s%n",
|
|
||||||
"NaN",
|
|
||||||
}
|
|
||||||
|
|
||||||
// generateRandomInput picks a visible, editable, enabled element from the last
|
|
||||||
// hierarchy snapshot and types a random edge-case value into it. The runner
|
|
||||||
// taps the target coordinates to focus before typing, so this works on both
|
|
||||||
// native and web with no driver-side dispatch change.
|
|
||||||
func (v *Verifier) generateRandomInput() (Action, error) {
|
|
||||||
if v.lastTree == nil {
|
if v.lastTree == nil {
|
||||||
return Action{}, ErrNoAction
|
return nil
|
||||||
}
|
}
|
||||||
candidates := make([]*hierarchy.Element, 0, len(v.lastTree.Elements))
|
var result []candidate
|
||||||
for _, element := range v.lastTree.Elements {
|
for _, element := range v.lastTree.Elements {
|
||||||
if !element.Editable || !element.Enabled {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !v.inScope(element) {
|
if !v.inScope(element) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if element.Bounds.Right-element.Bounds.Left <= 0 || element.Bounds.Bottom-element.Bounds.Top <= 0 {
|
if !verbAccepts(verb, element) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
candidates = append(candidates, element)
|
x, y := element.Bounds.Center()
|
||||||
|
result = append(result, candidate{
|
||||||
|
x: x,
|
||||||
|
y: y,
|
||||||
|
width: element.Bounds.Width(),
|
||||||
|
height: element.Bounds.Height(),
|
||||||
|
selector: selectorForElement(v.lastTree, element),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if len(candidates) == 0 {
|
return result
|
||||||
return Action{}, ErrNoAction
|
|
||||||
}
|
|
||||||
picked := candidates[v.rng.IntN(len(candidates))]
|
|
||||||
x, y := picked.Bounds.Center()
|
|
||||||
value := inputCorpus[v.rng.IntN(len(inputCorpus))]
|
|
||||||
return Action{Kind: ActionKindInputText, X: x, Y: y, Text: value}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateRandomSwipe emits a swipe over a random enabled element or the
|
type candidate struct {
|
||||||
// whole screen, in a random direction. Returns ErrNoAction only when we have
|
x, y int
|
||||||
// no tree to size a gesture off of.
|
width, height int
|
||||||
func (v *Verifier) generateRandomSwipe() (Action, error) {
|
selector string
|
||||||
if v.lastTree == nil || len(v.lastTree.Elements) == 0 {
|
|
||||||
return Action{}, ErrNoAction
|
|
||||||
}
|
|
||||||
candidates := make([]*hierarchy.Element, 0, len(v.lastTree.Elements))
|
|
||||||
for _, element := range v.lastTree.Elements {
|
|
||||||
if v.inScope(element) {
|
|
||||||
candidates = append(candidates, element)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(candidates) == 0 {
|
|
||||||
return Action{}, ErrNoAction
|
|
||||||
}
|
|
||||||
element := candidates[v.rng.IntN(len(candidates))]
|
|
||||||
cx, cy := element.Bounds.Center()
|
|
||||||
if cx <= 0 || cy <= 0 {
|
|
||||||
return Action{}, ErrNoAction
|
|
||||||
}
|
|
||||||
// Pick a direction: 0=up 1=down 2=left 3=right; magnitude 200-600 px.
|
|
||||||
magnitude := 200 + v.rng.IntN(401)
|
|
||||||
toX, toY := cx, cy
|
|
||||||
switch v.rng.IntN(4) {
|
|
||||||
case 0:
|
|
||||||
toY = cy - magnitude
|
|
||||||
case 1:
|
|
||||||
toY = cy + magnitude
|
|
||||||
case 2:
|
|
||||||
toX = cx - magnitude
|
|
||||||
case 3:
|
|
||||||
toX = cx + magnitude
|
|
||||||
}
|
|
||||||
if toX < 0 {
|
|
||||||
toX = 0
|
|
||||||
}
|
|
||||||
if toY < 0 {
|
|
||||||
toY = 0
|
|
||||||
}
|
|
||||||
return Action{
|
|
||||||
Kind: ActionKindSwipe,
|
|
||||||
FromX: cx,
|
|
||||||
FromY: cy,
|
|
||||||
ToX: toX,
|
|
||||||
ToY: toY,
|
|
||||||
DurationMillis: 250,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateRandomLongPress mirrors generateRandomTap: it picks a visible,
|
// verbAccepts applies the per-verb element filter.
|
||||||
// clickable, enabled, in-scope element and targets its center. Real users
|
func verbAccepts(verb string, element *hierarchy.Element) bool {
|
||||||
// long-press (context menus, reorder handles, multi-select), so a fuzzer that
|
positiveBounds := element.Bounds.Width() > 0 && element.Bounds.Height() > 0
|
||||||
// never emits one cannot reach those affordances.
|
switch verb {
|
||||||
func (v *Verifier) generateRandomLongPress() (Action, error) {
|
case "taps", "doubleTaps", "longPresses":
|
||||||
return v.generateRandomTapKind(ActionKindLongPress)
|
return element.Clickable && element.Enabled && positiveBounds
|
||||||
}
|
case "typing":
|
||||||
|
return element.Editable && element.Enabled && positiveBounds
|
||||||
// generateRandomScroll picks a scrollable, in-scope container and emits a swipe
|
case "scrolls":
|
||||||
// across it in a random direction.
|
return element.Attributes["scrollable"] == "true" && positiveBounds
|
||||||
func (v *Verifier) generateRandomScroll() (Action, error) {
|
case "swipes":
|
||||||
if v.lastTree == nil {
|
return true
|
||||||
return Action{}, ErrNoAction
|
default:
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
candidates := make([]*hierarchy.Element, 0, len(v.lastTree.Elements))
|
|
||||||
for _, element := range v.lastTree.Elements {
|
|
||||||
if element.Attributes["scrollable"] != "true" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !v.inScope(element) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if element.Bounds.Width() <= 0 || element.Bounds.Height() <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
candidates = append(candidates, element)
|
|
||||||
}
|
|
||||||
if len(candidates) == 0 {
|
|
||||||
return Action{}, ErrNoAction
|
|
||||||
}
|
|
||||||
picked := candidates[v.rng.IntN(len(candidates))]
|
|
||||||
cx, cy := picked.Bounds.Center()
|
|
||||||
width := picked.Bounds.Width()
|
|
||||||
height := picked.Bounds.Height()
|
|
||||||
directions := []string{"up", "down", "left", "right"}
|
|
||||||
dir := directions[v.rng.IntN(len(directions))]
|
|
||||||
toX, toY := cx, cy
|
|
||||||
// Scroll direction names the content motion; the gesture swipes the
|
|
||||||
// opposite way. Revealing content below ("down") means dragging the
|
|
||||||
// finger up, so toY decreases, and likewise for the other directions.
|
|
||||||
switch dir {
|
|
||||||
case "down":
|
|
||||||
toY = cy - (4*height)/10
|
|
||||||
case "up":
|
|
||||||
toY = cy + (4*height)/10
|
|
||||||
case "left":
|
|
||||||
toX = cx + (4*width)/10
|
|
||||||
case "right":
|
|
||||||
toX = cx - (4*width)/10
|
|
||||||
}
|
|
||||||
if toX < 0 {
|
|
||||||
toX = 0
|
|
||||||
}
|
|
||||||
if toY < 0 {
|
|
||||||
toY = 0
|
|
||||||
}
|
|
||||||
return Action{
|
|
||||||
Kind: ActionKindScroll,
|
|
||||||
Direction: dir,
|
|
||||||
FromX: cx,
|
|
||||||
FromY: cy,
|
|
||||||
ToX: toX,
|
|
||||||
ToY: toY,
|
|
||||||
DurationMillis: 300,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *Verifier) generateRandomPressKey() (Action, error) {
|
|
||||||
// Keep exploration gentle: only "back" for now. Home/menu would navigate
|
|
||||||
// away from the app under test.
|
|
||||||
return Action{Kind: ActionKindPressKey, Key: "back"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *Verifier) pickFromResult(result goja.Value) (Action, error) {
|
|
||||||
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
|
||||||
return Action{}, ErrNoAction
|
|
||||||
}
|
|
||||||
object := result.ToObject(v.runtime)
|
|
||||||
if object == nil {
|
|
||||||
return Action{}, ErrNoAction
|
|
||||||
}
|
|
||||||
lengthValue := object.Get("length")
|
|
||||||
if lengthValue == nil {
|
|
||||||
return jsValueToAction(v.runtime, result)
|
|
||||||
}
|
|
||||||
length := int(lengthValue.ToInteger())
|
|
||||||
if length == 0 {
|
|
||||||
return Action{}, ErrNoAction
|
|
||||||
}
|
|
||||||
pick := v.rng.IntN(length)
|
|
||||||
return jsValueToAction(v.runtime, object.Get(fmt.Sprintf("%d", pick)))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *Verifier) pickWeighted(entries *goja.Object) (goja.Value, error) {
|
|
||||||
lengthValue := entries.Get("length")
|
|
||||||
if lengthValue == nil {
|
|
||||||
return nil, fmt.Errorf("weighted entries missing length")
|
|
||||||
}
|
|
||||||
length := int(lengthValue.ToInteger())
|
|
||||||
if length == 0 {
|
|
||||||
return nil, ErrNoAction
|
|
||||||
}
|
|
||||||
|
|
||||||
weights := make([]float64, length)
|
|
||||||
generators := make([]goja.Value, length)
|
|
||||||
totalWeight := 0.0
|
|
||||||
for index := range length {
|
|
||||||
entry := entries.Get(fmt.Sprintf("%d", index)).ToObject(v.runtime)
|
|
||||||
if entry == nil {
|
|
||||||
return nil, fmt.Errorf("weighted entry %d not an array", index)
|
|
||||||
}
|
|
||||||
weight := entry.Get("0").ToFloat()
|
|
||||||
generator := entry.Get("1")
|
|
||||||
if weight < 0 {
|
|
||||||
weight = 0
|
|
||||||
}
|
|
||||||
weights[index] = weight
|
|
||||||
generators[index] = generator
|
|
||||||
totalWeight += weight
|
|
||||||
}
|
|
||||||
if totalWeight == 0 {
|
|
||||||
return nil, ErrNoAction
|
|
||||||
}
|
|
||||||
pick := v.rng.Float64() * totalWeight
|
|
||||||
cumulative := 0.0
|
|
||||||
for index := range length {
|
|
||||||
cumulative += weights[index]
|
|
||||||
if pick < cumulative {
|
|
||||||
return generators[index], nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return generators[length-1], nil
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// Shared action-generator tree and host interface for W2 approach B.
|
||||||
|
//
|
||||||
|
// Both engines (the goja verifier and the V8 web runtime) walk ONE tree with
|
||||||
|
// ONE picker (pick.ts), drawing through the shared PCG (pcg.ts). This module
|
||||||
|
// only declares the data shapes; pick.ts holds the traversal and the parity
|
||||||
|
// contract for draw order.
|
||||||
|
//
|
||||||
|
// Element references never cross the V8/host boundary: builtin generators emit
|
||||||
|
// an ActionDescriptor whose target is already a resolved Point (plus an
|
||||||
|
// optional selector on native), so the host serializer never has to chase a
|
||||||
|
// live element handle.
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AccessibilityElement,
|
||||||
|
AttrSelector,
|
||||||
|
Direction,
|
||||||
|
Point,
|
||||||
|
SelectorPath,
|
||||||
|
} from "./types.ts";
|
||||||
|
|
||||||
|
// Target is any target shape an action may carry before serialization. Author
|
||||||
|
// specs supply a string/selector/element; builtin generators resolve to a
|
||||||
|
// {x, y} Point (so no element handle crosses the V8/host boundary).
|
||||||
|
export type Target = string | AttrSelector | SelectorPath | AccessibilityElement | Point;
|
||||||
|
|
||||||
|
// BuiltinVerb names a leaf generator backed by host-enumerated candidates
|
||||||
|
// rather than author-supplied actions.
|
||||||
|
export type BuiltinVerb =
|
||||||
|
| "taps"
|
||||||
|
| "doubleTaps"
|
||||||
|
| "longPresses"
|
||||||
|
| "scrolls"
|
||||||
|
| "typing"
|
||||||
|
| "swipes"
|
||||||
|
| "waitOnce"
|
||||||
|
| "pressKeys";
|
||||||
|
|
||||||
|
// ActionDescriptor is the camelCase author shape an action takes before the
|
||||||
|
// engine serializes it to the wire format. Mirrors the factory return shapes
|
||||||
|
// in actions.ts (Tap/DoubleTap/LongPress {on}; Scroll {direction; in?};
|
||||||
|
// InputText {into; text}; Swipe {from; to; durationMillis?}; PressKey {key};
|
||||||
|
// Wait {durationMillis}).
|
||||||
|
export type ActionDescriptor =
|
||||||
|
| { kind: "Tap"; on: Target }
|
||||||
|
| { kind: "DoubleTap"; on: Target }
|
||||||
|
| { kind: "LongPress"; on: Target }
|
||||||
|
// Scroll carries the author target (in) for spec-written scrolls; the builtin
|
||||||
|
// generator additionally pre-computes the swipe from/to so the wire contract
|
||||||
|
// carries a real gesture rather than a zero-length one.
|
||||||
|
| { kind: "Scroll"; direction: Direction; in?: Target; from?: Target; to?: Target }
|
||||||
|
| { kind: "InputText"; into: Target; text: string }
|
||||||
|
| { kind: "Swipe"; from: Target; to: Target; durationMillis?: number }
|
||||||
|
| { kind: "PressKey"; key: string }
|
||||||
|
| { kind: "Wait"; durationMillis: number };
|
||||||
|
|
||||||
|
// GeneratorNode is a node in the action-generator tree.
|
||||||
|
// weighted: probabilistic choice over child nodes, scanned ascending.
|
||||||
|
// actions: author callback returning a list to pick uniformly from.
|
||||||
|
// builtin: host-backed leaf identified by a verb.
|
||||||
|
export type GeneratorNode =
|
||||||
|
| { kind: "weighted"; branches: ReadonlyArray<readonly [number, GeneratorNode]> }
|
||||||
|
| { kind: "actions"; generate: () => ActionDescriptor[] }
|
||||||
|
| { kind: "builtin"; verb: BuiltinVerb };
|
||||||
|
|
||||||
|
// Candidate is one host-enumerated target for a builtin verb. The host
|
||||||
|
// resolves geometry (and a native selector) so no element handle crosses into
|
||||||
|
// the picker. width/height let swipe/scroll size a gesture off the element.
|
||||||
|
export interface Candidate {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
selector?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Host is the platform backing the picker draws against. In this foundation
|
||||||
|
// workflow only the interface is defined and exercised against a stub; the
|
||||||
|
// goja and DOM implementations land in the rewire workflow.
|
||||||
|
export interface Host {
|
||||||
|
platform(): "android" | "ios" | "web";
|
||||||
|
// queryCandidates returns the host-enumerated targets for a verb, in a
|
||||||
|
// deterministic order. The picker indexes into this list with the PCG, so
|
||||||
|
// the order is part of the parity contract.
|
||||||
|
queryCandidates(verb: BuiltinVerb): Candidate[];
|
||||||
|
// reportUnsupported is invoked at most once per verb@platform (see verbs.ts)
|
||||||
|
// when a verb has no support on this platform.
|
||||||
|
reportUnsupported(verb: BuiltinVerb): void;
|
||||||
|
seedHi(): bigint;
|
||||||
|
seedLo(): bigint;
|
||||||
|
}
|
||||||
+48
-45
@@ -1,7 +1,12 @@
|
|||||||
|
// Author-facing action factories. Each returns plain GeneratorNode /
|
||||||
|
// ActionDescriptor DATA (see action-tree.ts); the shared picker (pick.ts) walks
|
||||||
|
// the tree and the runtime entry (runtime-entry.ts) serializes the result. No
|
||||||
|
// factory forwards to globalThis.__sanderling__ anymore: the same data tree
|
||||||
|
// drives both the goja verifier and the V8 web runtime.
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AccessibilityElement,
|
AccessibilityElement,
|
||||||
Action,
|
Action,
|
||||||
ActionGenerator,
|
|
||||||
Direction,
|
Direction,
|
||||||
DoubleTapAction,
|
DoubleTapAction,
|
||||||
InputTextAction,
|
InputTextAction,
|
||||||
@@ -16,16 +21,24 @@ import type {
|
|||||||
WaitAction,
|
WaitAction,
|
||||||
WeightedEntry,
|
WeightedEntry,
|
||||||
} from "./types.ts";
|
} from "./types.ts";
|
||||||
|
import type { ActionDescriptor, BuiltinVerb, GeneratorNode } from "./action-tree.ts";
|
||||||
|
import { getSamplerRng } from "./sampler-rng.ts";
|
||||||
|
|
||||||
export function actions(generator: () => Action[]): ActionGenerator {
|
export { setSamplerRng } from "./sampler-rng.ts";
|
||||||
return globalThis.__sanderling__.actions(generator);
|
|
||||||
|
function builtinNode(verb: BuiltinVerb): GeneratorNode {
|
||||||
|
return { kind: "builtin", verb };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function actions(generator: () => Action[]): GeneratorNode {
|
||||||
|
return { kind: "actions", generate: generator as () => ActionDescriptor[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function whenRoute(
|
export function whenRoute(
|
||||||
routeExtractor: { readonly current: string | null },
|
routeExtractor: { readonly current: string | null },
|
||||||
routes: string | readonly string[],
|
routes: string | readonly string[],
|
||||||
body: () => Action[],
|
body: () => Action[],
|
||||||
): ActionGenerator {
|
): GeneratorNode {
|
||||||
const allowed = typeof routes === "string" ? [routes] : routes;
|
const allowed = typeof routes === "string" ? [routes] : routes;
|
||||||
return actions(() => {
|
return actions(() => {
|
||||||
const current = routeExtractor.current;
|
const current = routeExtractor.current;
|
||||||
@@ -34,38 +47,45 @@ export function whenRoute(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function weighted(...entries: WeightedEntry[]): ActionGenerator {
|
export function weighted(...entries: WeightedEntry[]): GeneratorNode {
|
||||||
return globalThis.__sanderling__.weighted(...entries);
|
return { kind: "weighted", branches: entries };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function from<T>(items: readonly T[]): Sampler<T> {
|
export function from<T>(items: readonly T[]): Sampler<T> {
|
||||||
return globalThis.__sanderling__.from(items);
|
return {
|
||||||
|
generate(): T {
|
||||||
|
if (items.length <= 1) return items[0] as T;
|
||||||
|
const rng = getSamplerRng();
|
||||||
|
const index = rng ? rng.intN(items.length) : 0;
|
||||||
|
return items[index] as T;
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Tap(parameters: { on: string | AccessibilityElement }): TapAction {
|
export function Tap(parameters: { on: string | AccessibilityElement }): TapAction {
|
||||||
return globalThis.__sanderling__.tap(parameters);
|
return { kind: "Tap", on: parameters.on };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DoubleTap(parameters: { on: string | AccessibilityElement }): DoubleTapAction {
|
export function DoubleTap(parameters: { on: string | AccessibilityElement }): DoubleTapAction {
|
||||||
return globalThis.__sanderling__.doubleTap(parameters);
|
return { kind: "DoubleTap", on: parameters.on };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LongPress(parameters: { on: string | AccessibilityElement }): LongPressAction {
|
export function LongPress(parameters: { on: string | AccessibilityElement }): LongPressAction {
|
||||||
return globalThis.__sanderling__.longPress(parameters);
|
return { kind: "LongPress", on: parameters.on };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Scroll(parameters: {
|
export function Scroll(parameters: {
|
||||||
direction: Direction;
|
direction: Direction;
|
||||||
in?: string | AccessibilityElement;
|
in?: string | AccessibilityElement;
|
||||||
}): ScrollAction {
|
}): ScrollAction {
|
||||||
return globalThis.__sanderling__.scroll(parameters);
|
return { kind: "Scroll", direction: parameters.direction, in: parameters.in };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InputText(parameters: {
|
export function InputText(parameters: {
|
||||||
into: string | AccessibilityElement;
|
into: string | AccessibilityElement;
|
||||||
text: string;
|
text: string;
|
||||||
}): InputTextAction {
|
}): InputTextAction {
|
||||||
return globalThis.__sanderling__.inputText(parameters);
|
return { kind: "InputText", into: parameters.into, text: parameters.text };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Swipe(parameters: {
|
export function Swipe(parameters: {
|
||||||
@@ -73,44 +93,27 @@ export function Swipe(parameters: {
|
|||||||
to: Point | AccessibilityElement;
|
to: Point | AccessibilityElement;
|
||||||
durationMillis?: number;
|
durationMillis?: number;
|
||||||
}): SwipeAction {
|
}): SwipeAction {
|
||||||
return globalThis.__sanderling__.swipe(parameters);
|
return {
|
||||||
|
kind: "Swipe",
|
||||||
|
from: parameters.from,
|
||||||
|
to: parameters.to,
|
||||||
|
durationMillis: parameters.durationMillis,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PressKey(parameters: { key: Key }): PressKeyAction {
|
export function PressKey(parameters: { key: Key }): PressKeyAction {
|
||||||
return globalThis.__sanderling__.pressKey(parameters);
|
return { kind: "PressKey", key: parameters.key };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Wait(parameters: { durationMillis: number }): WaitAction {
|
export function Wait(parameters: { durationMillis: number }): WaitAction {
|
||||||
return globalThis.__sanderling__.wait(parameters);
|
return { kind: "Wait", durationMillis: parameters.durationMillis };
|
||||||
}
|
}
|
||||||
|
|
||||||
function builtinGenerator(
|
export const taps: GeneratorNode = builtinNode("taps");
|
||||||
name:
|
export const doubleTaps: GeneratorNode = builtinNode("doubleTaps");
|
||||||
| "taps"
|
export const longPresses: GeneratorNode = builtinNode("longPresses");
|
||||||
| "doubleTaps"
|
export const scrolls: GeneratorNode = builtinNode("scrolls");
|
||||||
| "longPresses"
|
export const typing: GeneratorNode = builtinNode("typing");
|
||||||
| "scrolls"
|
export const swipes: GeneratorNode = builtinNode("swipes");
|
||||||
| "typing"
|
export const waitOnce: GeneratorNode = builtinNode("waitOnce");
|
||||||
| "swipes"
|
export const pressKeys: GeneratorNode = builtinNode("pressKeys");
|
||||||
| "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 doubleTaps: ActionGenerator = builtinGenerator("doubleTaps");
|
|
||||||
export const longPresses: ActionGenerator = builtinGenerator("longPresses");
|
|
||||||
export const scrolls: ActionGenerator = builtinGenerator("scrolls");
|
|
||||||
export const typing: ActionGenerator = builtinGenerator("typing");
|
|
||||||
export const swipes: ActionGenerator = builtinGenerator("swipes");
|
|
||||||
export const waitOnce: ActionGenerator = builtinGenerator("waitOnce");
|
|
||||||
export const pressKey: ActionGenerator = builtinGenerator("pressKeys");
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// Single source of truth for the data both action-generator engines draw from:
|
||||||
|
// the goja verifier (internal/verifier/worker.go) and the V8 web runtime
|
||||||
|
// (web-runtime.ts). The entries and their ORDER are load-bearing: both engines
|
||||||
|
// index into these arrays with the shared PCG, so any reordering shifts every
|
||||||
|
// downstream pick for a given seed and breaks cross-engine reproducibility.
|
||||||
|
|
||||||
|
// INPUT_CORPUS is the edge-case string pool the typing builtin draws from to
|
||||||
|
// stress field parsing: empty, single char, overflow length, unicode, blank /
|
||||||
|
// whitespace, numeric boundaries, and common injection payloads. It must stay
|
||||||
|
// byte-for-byte identical to worker.go inputCorpus.
|
||||||
|
export const INPUT_CORPUS: readonly string[] = [
|
||||||
|
"",
|
||||||
|
"a",
|
||||||
|
"a".repeat(4096),
|
||||||
|
"🙂🔥💸",
|
||||||
|
" ",
|
||||||
|
"\t\n",
|
||||||
|
"-1",
|
||||||
|
"999999999999999999999",
|
||||||
|
"0.0000001",
|
||||||
|
"1e10",
|
||||||
|
"'; DROP TABLE--",
|
||||||
|
"<script>alert(1)</script>",
|
||||||
|
"../../etc/passwd",
|
||||||
|
"%s%n",
|
||||||
|
"NaN",
|
||||||
|
];
|
||||||
|
|
||||||
|
// NATIVE_PRESS_KEYS is the key pool the native (goja) press-key builtin may
|
||||||
|
// emit. Exploration stays gentle: only "back" is in play today, because
|
||||||
|
// "home"/"menu" navigate away from the app under test. Kept as an array so the
|
||||||
|
// matrix can declare native support over the full set without the picker
|
||||||
|
// hardcoding a single value.
|
||||||
|
export const NATIVE_PRESS_KEYS: readonly string[] = ["back"];
|
||||||
|
|
||||||
|
// WEB_PRESS_KEYS is the key pool the web press-key builtin draws from. Only
|
||||||
|
// keys with meaningful in-page semantics are included; "back"/"home" would not
|
||||||
|
// navigate a browser tab the way they navigate a native app.
|
||||||
|
export const WEB_PRESS_KEYS: readonly string[] = [
|
||||||
|
"enter",
|
||||||
|
"tab",
|
||||||
|
"escape",
|
||||||
|
"up",
|
||||||
|
"down",
|
||||||
|
"left",
|
||||||
|
"right",
|
||||||
|
];
|
||||||
@@ -1,24 +1,12 @@
|
|||||||
import { doubleTaps, swipes, taps, typing, weighted } from "../actions.ts";
|
import { doubleTaps, scrolls, swipes, taps, typing, weighted } from "../actions.ts";
|
||||||
import type { ActionGenerator } from "../types.ts";
|
import type { ActionGenerator } from "../types.ts";
|
||||||
|
|
||||||
export { doubleTaps, pressKey, swipes, taps, typing, waitOnce } from "../actions.ts";
|
export { doubleTaps, scrolls, swipes, taps, typing } from "../actions.ts";
|
||||||
// longPresses and scrolls are opt-in generators: not part of defaultActions,
|
|
||||||
// but authors can include them in their own weighted() set.
|
|
||||||
export { longPresses, scrolls } from "../actions.ts";
|
|
||||||
|
|
||||||
// A broad exploration generator: tap things, type edge-case values into fields,
|
|
||||||
// and swipe. Layer it under targeted depth flows so the fuzzer wanders the whole
|
|
||||||
// app while still driving the paths an author wrote. It deliberately omits the
|
|
||||||
// hardware back key: backing out past the app's root screen exits the app under
|
|
||||||
// test, and exploration must stay within the app.
|
|
||||||
//
|
|
||||||
// doubleTaps fires the same target twice ~50ms apart inside one step. Real users
|
|
||||||
// double-tap (image zoom, like-to-favorite, play/pause); without this the fuzzer
|
|
||||||
// can never produce sub-100ms event spacing, so race-window bugs (debounce gaps,
|
|
||||||
// in-flight guards, init races) stay unreachable.
|
|
||||||
export const defaultActions: ActionGenerator = weighted(
|
export const defaultActions: ActionGenerator = weighted(
|
||||||
[45, taps],
|
[100, taps],
|
||||||
[15, doubleTaps],
|
[100, typing],
|
||||||
[25, typing],
|
[50, scrolls],
|
||||||
[15, swipes],
|
[25, swipes],
|
||||||
|
[10, doubleTaps],
|
||||||
);
|
);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Goja-side runtime entry for the native verifier (internal/verifier).
|
||||||
|
//
|
||||||
|
// Go installs globalThis.__sanderlingHost__ (platform/seed/queryCandidates/
|
||||||
|
// reportUnsupported, implemented over the hierarchy tree in bindings.go) before
|
||||||
|
// the spec evaluates. This module bundles AFTER the spec, reads that host, and
|
||||||
|
// wires the shared picker via installRuntime so the goja verifier and the V8 web
|
||||||
|
// runtime run the SAME pick.ts over the SAME Pcg.
|
||||||
|
//
|
||||||
|
// Extractors are driven by Go (bindExtract + PushSnapshot advance current/
|
||||||
|
// previous against the Go-built `state`), so the extractor callback is a no-op
|
||||||
|
// here; Go never invokes __sanderlingExtractors__ on native.
|
||||||
|
|
||||||
|
import { installRuntime } from "./runtime-entry.ts";
|
||||||
|
import type { GeneratorNode, Host } from "./action-tree.ts";
|
||||||
|
|
||||||
|
const host = (globalThis as { __sanderlingHost__?: Host }).__sanderlingHost__;
|
||||||
|
if (!host) throw new Error("goja runtime: __sanderlingHost__ not installed");
|
||||||
|
|
||||||
|
installRuntime(
|
||||||
|
host,
|
||||||
|
() => (globalThis as { actions?: GeneratorNode }).actions ?? null,
|
||||||
|
() => ({}),
|
||||||
|
);
|
||||||
@@ -33,14 +33,18 @@ export { always, eventually, next, now } from "./ltl.ts";
|
|||||||
export {
|
export {
|
||||||
DoubleTap,
|
DoubleTap,
|
||||||
InputText,
|
InputText,
|
||||||
|
LongPress,
|
||||||
PressKey,
|
PressKey,
|
||||||
|
Scroll,
|
||||||
Swipe,
|
Swipe,
|
||||||
Tap,
|
Tap,
|
||||||
Wait,
|
Wait,
|
||||||
actions,
|
actions,
|
||||||
doubleTaps,
|
doubleTaps,
|
||||||
from,
|
from,
|
||||||
pressKey,
|
longPresses,
|
||||||
|
pressKeys,
|
||||||
|
scrolls,
|
||||||
swipes,
|
swipes,
|
||||||
taps,
|
taps,
|
||||||
typing,
|
typing,
|
||||||
@@ -48,3 +52,4 @@ export {
|
|||||||
weighted,
|
weighted,
|
||||||
whenRoute,
|
whenRoute,
|
||||||
} from "./actions.ts";
|
} from "./actions.ts";
|
||||||
|
export { edgeCaseText, emails, integers, strings } from "./values.ts";
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// Bit-exact port of Go's math/rand/v2 PCG source wrapped in rand.Rand.
|
||||||
|
//
|
||||||
|
// This MUST match `rand.New(rand.NewPCG(hi, lo))` draw-for-draw, because every
|
||||||
|
// random decision the action picker makes is shared between the goja verifier
|
||||||
|
// and the V8 web runtime. Divergence here breaks reproducibility across engines.
|
||||||
|
//
|
||||||
|
// The golden fixture in test/fixtures/pcg-golden.json is generated by
|
||||||
|
// internal/_oracle/main.go and pins the exact sequences asserted in pcg.test.ts.
|
||||||
|
|
||||||
|
const MASK64 = (1n << 64n) - 1n;
|
||||||
|
|
||||||
|
const MUL_HI = 2549297995355413924n;
|
||||||
|
const MUL_LO = 4865540595714422341n;
|
||||||
|
const INC_HI = 6364136223846793005n;
|
||||||
|
const INC_LO = 1442695040888963407n;
|
||||||
|
const CHEAP_MUL = 0xda942042e4dd58b5n;
|
||||||
|
|
||||||
|
const POW53 = 1n << 53n;
|
||||||
|
|
||||||
|
// mul64 returns the 128-bit product of two uint64 values split into high and
|
||||||
|
// low 64-bit halves, matching Go's math/bits.Mul64.
|
||||||
|
function mul64(a: bigint, b: bigint): { hi: bigint; lo: bigint } {
|
||||||
|
const product = a * b;
|
||||||
|
return { hi: (product >> 64n) & MASK64, lo: product & MASK64 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Pcg {
|
||||||
|
private hi: bigint;
|
||||||
|
private lo: bigint;
|
||||||
|
|
||||||
|
constructor(hi: bigint, lo: bigint) {
|
||||||
|
this.hi = hi & MASK64;
|
||||||
|
this.lo = lo & MASK64;
|
||||||
|
}
|
||||||
|
|
||||||
|
// next advances the 128-bit LCG state and returns the new (hi, lo) pair.
|
||||||
|
private next(): { hi: bigint; lo: bigint } {
|
||||||
|
// 128-bit multiply of state by the 128-bit multiplier, keeping 128 bits.
|
||||||
|
let { hi, lo } = mul64(this.lo, MUL_LO);
|
||||||
|
hi = (hi + this.hi * MUL_LO + this.lo * MUL_HI) & MASK64;
|
||||||
|
|
||||||
|
// 128-bit add of the increment with carry propagation.
|
||||||
|
const loSum = lo + INC_LO;
|
||||||
|
const carry = loSum >> 64n;
|
||||||
|
lo = loSum & MASK64;
|
||||||
|
hi = (hi + INC_HI + carry) & MASK64;
|
||||||
|
|
||||||
|
this.lo = lo;
|
||||||
|
this.hi = hi;
|
||||||
|
return { hi, lo };
|
||||||
|
}
|
||||||
|
|
||||||
|
// uint64 mirrors PCG.Uint64: the DXSM output permutation over next().
|
||||||
|
uint64(): bigint {
|
||||||
|
let { hi, lo } = this.next();
|
||||||
|
hi ^= hi >> 32n;
|
||||||
|
hi = (hi * CHEAP_MUL) & MASK64;
|
||||||
|
hi ^= hi >> 48n;
|
||||||
|
hi = (hi * (lo | 1n)) & MASK64;
|
||||||
|
return hi & MASK64;
|
||||||
|
}
|
||||||
|
|
||||||
|
// float64 mirrors rand.Rand.Float64: float64(Uint64()<<11>>11) / (1<<53),
|
||||||
|
// which keeps the low 53 bits and divides by 2^53.
|
||||||
|
float64(): number {
|
||||||
|
const bits = this.uint64() & (POW53 - 1n);
|
||||||
|
return Number(bits) / Number(POW53);
|
||||||
|
}
|
||||||
|
|
||||||
|
// intN mirrors rand.Rand.IntN via uint64n (Lemire with a rejection threshold).
|
||||||
|
// The host arch is 64-bit, so the 32-bit fast path never applies.
|
||||||
|
intN(n: number | bigint): number {
|
||||||
|
const bound = typeof n === "bigint" ? n : BigInt(n);
|
||||||
|
if (bound <= 0n) {
|
||||||
|
throw new Error("invalid argument to intN");
|
||||||
|
}
|
||||||
|
return Number(this.uint64n(bound));
|
||||||
|
}
|
||||||
|
|
||||||
|
private uint64n(n: bigint): bigint {
|
||||||
|
if ((n & (n - 1n)) === 0n) {
|
||||||
|
// n is a power of two: mask the low bits.
|
||||||
|
return this.uint64() & (n - 1n);
|
||||||
|
}
|
||||||
|
|
||||||
|
let { hi, lo } = mul64(this.uint64(), n);
|
||||||
|
if (lo < n) {
|
||||||
|
// Go computes thresh := -n % n in uint64 math, where -n wraps to 2^64 - n.
|
||||||
|
const threshold = ((1n << 64n) - n) % n;
|
||||||
|
while (lo < threshold) {
|
||||||
|
({ hi, lo } = mul64(this.uint64(), n));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hi;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
// The shared deterministic action picker for W2 approach B.
|
||||||
|
//
|
||||||
|
// walk() traverses a GeneratorNode tree, and nextAction() wraps it with the
|
||||||
|
// 16-attempt retry that matches worker.go NextAction. Both engines (the goja
|
||||||
|
// verifier and the V8 web runtime) run THIS code, drawing through the shared
|
||||||
|
// Pcg, so a given seed yields an identical action stream on every platform.
|
||||||
|
//
|
||||||
|
// PARITY CONTRACT - draw order.
|
||||||
|
// Every random decision goes through the Pcg in a FIXED, pinned order. Changing
|
||||||
|
// this order shifts the stream for a seed and breaks cross-engine
|
||||||
|
// reproducibility, so treat it as load-bearing:
|
||||||
|
//
|
||||||
|
// weighted node: ONE float64() draw, then an ASCENDING cumulative scan over
|
||||||
|
// max(0, weight). (matches worker.go pickWeighted.)
|
||||||
|
// actions node: the generator runs FIRST (any from(...).generate() inside
|
||||||
|
// draws intN(itemCount) for >1 items, nothing otherwise),
|
||||||
|
// THEN if the returned list has >1 entry, ONE intN(len) draw;
|
||||||
|
// a 0- or 1-element list draws nothing. (pickFromResult.)
|
||||||
|
// builtin node, per verb, in this exact sequence:
|
||||||
|
// taps/doubleTaps/longPresses: intN(candidateCount) [1 draw]
|
||||||
|
// typing: intN(candidateCount), intN(corpusLength)
|
||||||
|
// swipes: intN(candidateCount),
|
||||||
|
// 200 + intN(401) magnitude, intN(4) direction
|
||||||
|
// scrolls: intN(candidateCount), intN(4) direction
|
||||||
|
// pressKeys: intN(keyCount)
|
||||||
|
// waitOnce: no draw
|
||||||
|
//
|
||||||
|
// Builtin targets are resolved to a {x, y} Point by the host BEFORE the picker
|
||||||
|
// sees them, so no element handle crosses into this module.
|
||||||
|
|
||||||
|
import type { Pcg } from "./pcg.ts";
|
||||||
|
import type {
|
||||||
|
ActionDescriptor,
|
||||||
|
BuiltinVerb,
|
||||||
|
GeneratorNode,
|
||||||
|
Host,
|
||||||
|
} from "./action-tree.ts";
|
||||||
|
import type { Direction, Point } from "./types.ts";
|
||||||
|
import { INPUT_CORPUS, NATIVE_PRESS_KEYS, WEB_PRESS_KEYS } from "./corpus.ts";
|
||||||
|
import { setSamplerRng } from "./sampler-rng.ts";
|
||||||
|
import { supports, warnUnsupportedOnce } from "./verbs.ts";
|
||||||
|
|
||||||
|
// SWIPE_MIN_MAGNITUDE / SWIPE_MAGNITUDE_SPAN reproduce worker.go's
|
||||||
|
// `200 + rng.IntN(401)` swipe distance in pixels (200..600 inclusive).
|
||||||
|
const SWIPE_MIN_MAGNITUDE = 200;
|
||||||
|
const SWIPE_MAGNITUDE_SPAN = 401;
|
||||||
|
const SWIPE_DURATION_MILLIS = 250;
|
||||||
|
|
||||||
|
const DIRECTIONS: readonly Direction[] = ["up", "down", "left", "right"];
|
||||||
|
|
||||||
|
const MAX_RETRIES = 16;
|
||||||
|
|
||||||
|
// nextAction resolves an action for the current step, retrying walk() up to 16
|
||||||
|
// times when it yields null (matches worker.go NextAction). Returns null when
|
||||||
|
// every attempt comes up empty.
|
||||||
|
export function nextAction(
|
||||||
|
root: GeneratorNode,
|
||||||
|
rng: Pcg,
|
||||||
|
host: Host,
|
||||||
|
): ActionDescriptor | null {
|
||||||
|
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||||
|
const action = walk(root, rng, host);
|
||||||
|
if (action !== null) return action;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// walk resolves a single node to an ActionDescriptor or null.
|
||||||
|
export function walk(
|
||||||
|
node: GeneratorNode,
|
||||||
|
rng: Pcg,
|
||||||
|
host: Host,
|
||||||
|
): ActionDescriptor | null {
|
||||||
|
switch (node.kind) {
|
||||||
|
case "weighted":
|
||||||
|
return walkWeighted(node.branches, rng, host);
|
||||||
|
case "actions": {
|
||||||
|
// Expose the picker's rng to from(...).generate() calls inside the
|
||||||
|
// generator so author sampling shares this single deterministic stream.
|
||||||
|
setSamplerRng(rng);
|
||||||
|
try {
|
||||||
|
return walkActions(node.generate(), rng);
|
||||||
|
} finally {
|
||||||
|
setSamplerRng(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "builtin":
|
||||||
|
return walkBuiltin(node.verb, rng, host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkWeighted(
|
||||||
|
branches: ReadonlyArray<readonly [number, GeneratorNode]>,
|
||||||
|
rng: Pcg,
|
||||||
|
host: Host,
|
||||||
|
): ActionDescriptor | null {
|
||||||
|
let total = 0;
|
||||||
|
for (const [weight] of branches) total += Math.max(0, weight);
|
||||||
|
if (total <= 0) return null;
|
||||||
|
const draw = rng.float64() * total;
|
||||||
|
let cumulative = 0;
|
||||||
|
for (const [weight, child] of branches) {
|
||||||
|
cumulative += Math.max(0, weight);
|
||||||
|
if (draw < cumulative) return walk(child, rng, host);
|
||||||
|
}
|
||||||
|
// Floating-point slack: draw can equal total. Fall to the last branch,
|
||||||
|
// matching worker.go's trailing `return generators[length-1]`.
|
||||||
|
const last = branches[branches.length - 1];
|
||||||
|
return last ? walk(last[1], rng, host) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkActions(
|
||||||
|
generated: ActionDescriptor[],
|
||||||
|
rng: Pcg,
|
||||||
|
): ActionDescriptor | null {
|
||||||
|
if (generated.length === 0) return null;
|
||||||
|
if (generated.length === 1) return generated[0] ?? null;
|
||||||
|
return generated[rng.intN(generated.length)] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkBuiltin(
|
||||||
|
verb: BuiltinVerb,
|
||||||
|
rng: Pcg,
|
||||||
|
host: Host,
|
||||||
|
): ActionDescriptor | null {
|
||||||
|
if (!supports(verb, host.platform())) {
|
||||||
|
warnUnsupportedOnce(host, verb);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (verb === "waitOnce") {
|
||||||
|
return { kind: "Wait", durationMillis: 500 };
|
||||||
|
}
|
||||||
|
if (verb === "pressKeys") {
|
||||||
|
return walkPressKey(rng, host);
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = host.queryCandidates(verb);
|
||||||
|
if (candidates.length === 0) return null;
|
||||||
|
const picked = candidates[rng.intN(candidates.length)];
|
||||||
|
if (!picked) return null;
|
||||||
|
const point: Point = { x: picked.x, y: picked.y };
|
||||||
|
|
||||||
|
switch (verb) {
|
||||||
|
case "taps":
|
||||||
|
return tapDescriptor("Tap", point, picked.selector);
|
||||||
|
case "doubleTaps":
|
||||||
|
return tapDescriptor("DoubleTap", point, picked.selector);
|
||||||
|
case "longPresses":
|
||||||
|
return tapDescriptor("LongPress", point, picked.selector);
|
||||||
|
case "typing": {
|
||||||
|
const text = INPUT_CORPUS[rng.intN(INPUT_CORPUS.length)] ?? "";
|
||||||
|
return { kind: "InputText", into: withSelector(point, picked.selector), text };
|
||||||
|
}
|
||||||
|
case "swipes":
|
||||||
|
return buildSwipe(point, rng);
|
||||||
|
case "scrolls": {
|
||||||
|
const direction = DIRECTIONS[rng.intN(DIRECTIONS.length)] ?? "down";
|
||||||
|
return buildScroll(point, direction, picked, rng);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// withSelector attaches a native selector to a resolved Point so the runner can
|
||||||
|
// re-resolve the target by id/text. The web host omits it (point-only).
|
||||||
|
function withSelector(point: Point, selector?: string): Point {
|
||||||
|
if (selector === undefined) return point;
|
||||||
|
return { ...point, selector } as Point;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tapDescriptor(
|
||||||
|
kind: "Tap" | "DoubleTap" | "LongPress",
|
||||||
|
point: Point,
|
||||||
|
selector?: string,
|
||||||
|
): ActionDescriptor {
|
||||||
|
return { kind, on: withSelector(point, selector) } as ActionDescriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildScroll lowers a scroll to a swipe over the container, matching
|
||||||
|
// worker.go's geometry: the gesture drags opposite the named content motion,
|
||||||
|
// magnitude 40% of the container extent. Missing width/height (web root) yields
|
||||||
|
// a zero-length endpoint, which the runner re-derives from container bounds.
|
||||||
|
function buildScroll(
|
||||||
|
from: Point,
|
||||||
|
direction: Direction,
|
||||||
|
candidate: { width?: number; height?: number },
|
||||||
|
_rng: Pcg,
|
||||||
|
): ActionDescriptor {
|
||||||
|
const width = candidate.width ?? 0;
|
||||||
|
const height = candidate.height ?? 0;
|
||||||
|
let toX = from.x;
|
||||||
|
let toY = from.y;
|
||||||
|
switch (direction) {
|
||||||
|
case "down":
|
||||||
|
toY = from.y - Math.trunc((4 * height) / 10);
|
||||||
|
break;
|
||||||
|
case "up":
|
||||||
|
toY = from.y + Math.trunc((4 * height) / 10);
|
||||||
|
break;
|
||||||
|
case "left":
|
||||||
|
toX = from.x + Math.trunc((4 * width) / 10);
|
||||||
|
break;
|
||||||
|
case "right":
|
||||||
|
toX = from.x - Math.trunc((4 * width) / 10);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: "Scroll",
|
||||||
|
direction,
|
||||||
|
in: from,
|
||||||
|
from,
|
||||||
|
to: { x: Math.max(0, toX), y: Math.max(0, toY) },
|
||||||
|
} as ActionDescriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkPressKey(rng: Pcg, host: Host): ActionDescriptor | null {
|
||||||
|
const keys = host.platform() === "web" ? WEB_PRESS_KEYS : NATIVE_PRESS_KEYS;
|
||||||
|
if (keys.length === 0) return null;
|
||||||
|
const key = keys[rng.intN(keys.length)] ?? keys[0];
|
||||||
|
if (key === undefined) return null;
|
||||||
|
return { kind: "PressKey", key };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSwipe(from: Point, rng: Pcg): ActionDescriptor {
|
||||||
|
const magnitude = SWIPE_MIN_MAGNITUDE + rng.intN(SWIPE_MAGNITUDE_SPAN);
|
||||||
|
let toX = from.x;
|
||||||
|
let toY = from.y;
|
||||||
|
switch (rng.intN(4)) {
|
||||||
|
case 0:
|
||||||
|
toY = from.y - magnitude;
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
toY = from.y + magnitude;
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
toX = from.x - magnitude;
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
toX = from.x + magnitude;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: "Swipe",
|
||||||
|
from,
|
||||||
|
to: { x: Math.max(0, toX), y: Math.max(0, toY) },
|
||||||
|
durationMillis: SWIPE_DURATION_MILLIS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
// The single next-action entry shared by both engines (goja verifier and V8 web
|
||||||
|
// runtime). installRuntime wires the spec's root generator, the shared Pcg, and
|
||||||
|
// a Host into the globals the host invokes each tick:
|
||||||
|
// __sanderlingNextAction__() -> one serialized action (unified wire contract)
|
||||||
|
// __sanderlingExtractors__() -> the engine's extractor snapshot
|
||||||
|
//
|
||||||
|
// Both engines call THIS picker over the SAME Pcg, so a given seed yields an
|
||||||
|
// identical action stream by construction.
|
||||||
|
|
||||||
|
import { Pcg } from "./pcg.ts";
|
||||||
|
import { nextAction, walk } from "./pick.ts";
|
||||||
|
import type { ActionDescriptor, GeneratorNode, Host } from "./action-tree.ts";
|
||||||
|
import type { Point } from "./types.ts";
|
||||||
|
|
||||||
|
// SerializedAction is the flat, camelCase wire shape JS emits and Go decodes
|
||||||
|
// (ONE decoder on each side). Builtin targets are already resolved to a Point,
|
||||||
|
// and serializeAction collapses author targets that carry {x, y}; a target that
|
||||||
|
// does not resolve to coordinates drops the action (returns null).
|
||||||
|
export type SerializedAction =
|
||||||
|
| { kind: "Tap" | "DoubleTap" | "LongPress"; x: number; y: number; selector?: string }
|
||||||
|
| { kind: "InputText"; x: number; y: number; text: string; selector?: string }
|
||||||
|
| { kind: "Swipe"; fromX: number; fromY: number; toX: number; toY: number; durationMillis: number }
|
||||||
|
| {
|
||||||
|
kind: "Scroll";
|
||||||
|
direction: string;
|
||||||
|
fromX: number;
|
||||||
|
fromY: number;
|
||||||
|
toX: number;
|
||||||
|
toY: number;
|
||||||
|
durationMillis: number;
|
||||||
|
}
|
||||||
|
| { kind: "PressKey"; key: string }
|
||||||
|
| { kind: "Wait"; durationMillis: number };
|
||||||
|
|
||||||
|
const DEFAULT_SWIPE_DURATION = 250;
|
||||||
|
|
||||||
|
// pointOf resolves a target to {x, y, selector?}. Builtins and resolved ax
|
||||||
|
// elements carry numeric x/y; a bare selector string carries no geometry, so it
|
||||||
|
// serializes with (0, 0) and the selector, leaving the native runner to
|
||||||
|
// re-resolve coordinates by id/text. A target with neither shape (null, an
|
||||||
|
// unrecognized object) returns undefined so the action is dropped.
|
||||||
|
function pointOf(target: unknown): (Point & { selector?: string }) | undefined {
|
||||||
|
if (typeof target === "string") {
|
||||||
|
return target.length > 0 ? { x: 0, y: 0, selector: target } : undefined;
|
||||||
|
}
|
||||||
|
if (!target || typeof target !== "object") return undefined;
|
||||||
|
const obj = target as Record<string, unknown>;
|
||||||
|
if (typeof obj.x === "number" && typeof obj.y === "number") {
|
||||||
|
const point: Point & { selector?: string } = { x: obj.x, y: obj.y };
|
||||||
|
// The picker's builtin candidates carry `selector`; a goja ax element
|
||||||
|
// carries it under the runtime tag. Either lets the runner re-resolve.
|
||||||
|
const selector = obj.selector ?? obj.__sanderlingSelector;
|
||||||
|
if (typeof selector === "string" && selector.length > 0) point.selector = selector;
|
||||||
|
return point;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeAction(action: ActionDescriptor | null): SerializedAction | null {
|
||||||
|
if (!action) return null;
|
||||||
|
switch (action.kind) {
|
||||||
|
case "Tap":
|
||||||
|
case "DoubleTap":
|
||||||
|
case "LongPress": {
|
||||||
|
const point = pointOf(action.on);
|
||||||
|
if (!point) return null;
|
||||||
|
const out: SerializedAction = { kind: action.kind, x: point.x, y: point.y };
|
||||||
|
if (point.selector) out.selector = point.selector;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
case "InputText": {
|
||||||
|
const point = pointOf(action.into);
|
||||||
|
if (!point) return null;
|
||||||
|
const out: SerializedAction = { kind: "InputText", x: point.x, y: point.y, text: action.text };
|
||||||
|
if (point.selector) out.selector = point.selector;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
case "Swipe": {
|
||||||
|
const from = pointOf(action.from);
|
||||||
|
const to = pointOf(action.to);
|
||||||
|
if (!from || !to) return null;
|
||||||
|
return {
|
||||||
|
kind: "Swipe",
|
||||||
|
fromX: from.x,
|
||||||
|
fromY: from.y,
|
||||||
|
toX: to.x,
|
||||||
|
toY: to.y,
|
||||||
|
durationMillis: action.durationMillis ?? DEFAULT_SWIPE_DURATION,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "Scroll": {
|
||||||
|
const from = pointOf(action.from) ?? pointOf(action.in);
|
||||||
|
if (!from) return null;
|
||||||
|
// The builtin generator pre-computes `to`; an author Scroll without it
|
||||||
|
// collapses to a zero-length gesture the runner re-derives from bounds.
|
||||||
|
const to = pointOf(action.to) ?? from;
|
||||||
|
return {
|
||||||
|
kind: "Scroll",
|
||||||
|
direction: action.direction,
|
||||||
|
fromX: from.x,
|
||||||
|
fromY: from.y,
|
||||||
|
toX: to.x,
|
||||||
|
toY: to.y,
|
||||||
|
durationMillis: DEFAULT_SWIPE_DURATION,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "PressKey":
|
||||||
|
return { kind: "PressKey", key: action.key };
|
||||||
|
case "Wait":
|
||||||
|
return { kind: "Wait", durationMillis: action.durationMillis };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// installRuntime defines the next-action and extractor globals for one engine.
|
||||||
|
// root is the generator the spec assigned; pass a function when the spec runs
|
||||||
|
// AFTER this call (the web bundle imports the runtime before the spec, so the
|
||||||
|
// root only exists on globalThis.actions once the spec has evaluated).
|
||||||
|
// evaluateExtractors is the engine's snapshot of the spec's extract() handles.
|
||||||
|
//
|
||||||
|
// Setup precedence: when the spec assigned globalThis.setup, it is walked ONCE
|
||||||
|
// per tick first; if it yields an action that wins, otherwise the call falls
|
||||||
|
// through to the action root's 16-attempt retry. This matches the native
|
||||||
|
// verifier's prior NextAction precedence and applies on both engines.
|
||||||
|
export function installRuntime(
|
||||||
|
host: Host,
|
||||||
|
root: GeneratorNode | null | (() => GeneratorNode | null),
|
||||||
|
evaluateExtractors: () => Record<number, unknown>,
|
||||||
|
): void {
|
||||||
|
const rng = new Pcg(host.seedHi(), host.seedLo());
|
||||||
|
const resolveRoot = typeof root === "function" ? root : () => root;
|
||||||
|
const resolveSetup = () =>
|
||||||
|
(globalThis as { setup?: GeneratorNode }).setup ?? null;
|
||||||
|
defineLockedGlobal("__sanderlingExtractors__", () => evaluateExtractors());
|
||||||
|
defineLockedGlobal("__sanderlingNextAction__", () => {
|
||||||
|
// resolveRoot runs first: on web it also resets the per-tick candidate
|
||||||
|
// cache, which setup's walk below must see fresh.
|
||||||
|
const current = resolveRoot();
|
||||||
|
const setup = resolveSetup();
|
||||||
|
if (setup) {
|
||||||
|
const setupAction = walk(setup, rng, host);
|
||||||
|
if (setupAction) return serializeAction(setupAction);
|
||||||
|
}
|
||||||
|
if (!current) return null;
|
||||||
|
return serializeAction(nextAction(current, rng, host));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function defineLockedGlobal(name: string, value: unknown): void {
|
||||||
|
Object.defineProperty(globalThis, name, {
|
||||||
|
value,
|
||||||
|
writable: false,
|
||||||
|
configurable: false,
|
||||||
|
enumerable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// samplerRng is the picker's Pcg while it evaluates an `actions` node's
|
||||||
|
// generator (set by pick.ts walkActions). Author sampling, from(...).generate()
|
||||||
|
// and the values.ts generators, draws from it so every sample shares the single
|
||||||
|
// deterministic stream. It is null outside a walk; eager spec-time generate()
|
||||||
|
// calls then fall back to a fixed deterministic default.
|
||||||
|
|
||||||
|
import type { Pcg } from "./pcg.ts";
|
||||||
|
|
||||||
|
let samplerRng: Pcg | null = null;
|
||||||
|
|
||||||
|
export function setSamplerRng(rng: Pcg | null): void {
|
||||||
|
samplerRng = rng;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSamplerRng(): Pcg | null {
|
||||||
|
return samplerRng;
|
||||||
|
}
|
||||||
+10
-33
@@ -125,6 +125,7 @@ export interface WebState extends State {
|
|||||||
export interface Extracted<T> {
|
export interface Extracted<T> {
|
||||||
readonly current: T;
|
readonly current: T;
|
||||||
readonly previous: T | undefined;
|
readonly previous: T | undefined;
|
||||||
|
named(name: string): Extracted<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Point {
|
export interface Point {
|
||||||
@@ -175,10 +176,10 @@ export type Key =
|
|||||||
| "left"
|
| "left"
|
||||||
| "right";
|
| "right";
|
||||||
|
|
||||||
export interface ActionGenerator {
|
// ActionGenerator is a node in the action-generator tree (see action-tree.ts).
|
||||||
readonly __sanderlingActionGenerator: true;
|
// Author specs treat it as an opaque handle they compose with weighted(); the
|
||||||
generate(): Action[];
|
// shared picker walks the underlying GeneratorNode.
|
||||||
}
|
export type ActionGenerator = GeneratorNode;
|
||||||
|
|
||||||
export interface Formula {
|
export interface Formula {
|
||||||
readonly __sanderlingFormula: true;
|
readonly __sanderlingFormula: true;
|
||||||
@@ -196,45 +197,21 @@ export interface Sampler<T> {
|
|||||||
generate(): T;
|
generate(): T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SanderlingRuntime is the host-bound surface that stays on
|
||||||
|
// globalThis.__sanderling__: extract plus the LTL formula constructors. Action
|
||||||
|
// factories no longer live here; they return plain data trees (see actions.ts).
|
||||||
export interface SanderlingRuntime {
|
export interface SanderlingRuntime {
|
||||||
extract: <T>(getter: (state: State) => T, name?: string) => Extracted<T>;
|
extract: <T>(getter: (state: State) => T, name?: string) => Extracted<T>;
|
||||||
always: (predicateOrFormula: (() => boolean) | Formula) => Formula;
|
always: (predicateOrFormula: (() => boolean) | Formula) => Formula;
|
||||||
now: (predicate: () => boolean) => Formula;
|
now: (predicate: () => boolean) => Formula;
|
||||||
next: (predicate: () => boolean) => Formula;
|
next: (predicate: () => boolean) => Formula;
|
||||||
eventually: (predicate: () => boolean) => EventuallyFormula;
|
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;
|
|
||||||
doubleTap: (parameters: { on: string | AccessibilityElement }) => DoubleTapAction;
|
|
||||||
longPress: (parameters: { on: string | AccessibilityElement }) => LongPressAction;
|
|
||||||
scroll: (parameters: {
|
|
||||||
direction: Direction;
|
|
||||||
in?: string | AccessibilityElement;
|
|
||||||
}) => ScrollAction;
|
|
||||||
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;
|
|
||||||
doubleTaps: ActionGenerator;
|
|
||||||
longPresses: ActionGenerator;
|
|
||||||
scrolls: ActionGenerator;
|
|
||||||
typing: ActionGenerator;
|
|
||||||
swipes: ActionGenerator;
|
|
||||||
waitOnce: ActionGenerator;
|
|
||||||
pressKeys: ActionGenerator;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WeightedEntry = readonly [number, ActionGenerator];
|
export type WeightedEntry = readonly [number, ActionGenerator];
|
||||||
|
|
||||||
|
import type { GeneratorNode } from "./action-tree.ts";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
// eslint-disable-next-line no-var
|
// eslint-disable-next-line no-var
|
||||||
var __sanderling__: SanderlingRuntime;
|
var __sanderling__: SanderlingRuntime;
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// Author-facing, fluent, seeded value generators. Each builder is itself a
|
||||||
|
// Sampler<T> (it has .generate()), so `integers().between(0, 9)` is usable
|
||||||
|
// anywhere a Sampler is expected and chains read left to right.
|
||||||
|
//
|
||||||
|
// Every draw goes through the shared samplerRng (sampler-rng.ts) that from()
|
||||||
|
// uses, so generators are deterministic AND identical across the goja verifier
|
||||||
|
// and the V8 web runtime. Outside an actions() walk samplerRng is null and each
|
||||||
|
// generator returns a fixed deterministic default (mirroring from()'s index 0),
|
||||||
|
// so module-load-time calls never throw and never use an unseeded source.
|
||||||
|
|
||||||
|
import type { Pcg } from "./pcg.ts";
|
||||||
|
import type { Sampler } from "./types.ts";
|
||||||
|
import { getSamplerRng } from "./sampler-rng.ts";
|
||||||
|
import { INPUT_CORPUS } from "./corpus.ts";
|
||||||
|
|
||||||
|
const ALPHA = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||||
|
const DIGITS = "0123456789";
|
||||||
|
const ALPHANUMERIC = ALPHA + DIGITS;
|
||||||
|
|
||||||
|
function draw(rng: Pcg | null, bound: number): number {
|
||||||
|
return rng && bound > 1 ? rng.intN(bound) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
class StringBuilder implements Sampler<string> {
|
||||||
|
private minLength = 1;
|
||||||
|
private maxLength = 16;
|
||||||
|
private charset = ALPHANUMERIC;
|
||||||
|
|
||||||
|
length(min: number, max: number): this {
|
||||||
|
this.minLength = min;
|
||||||
|
this.maxLength = max;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
alpha(): this {
|
||||||
|
this.charset = ALPHA;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
generate(): string {
|
||||||
|
const rng = getSamplerRng();
|
||||||
|
const span = this.maxLength - this.minLength + 1;
|
||||||
|
const count = this.minLength + draw(rng, span);
|
||||||
|
let result = "";
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
result += this.charset[draw(rng, this.charset.length)];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class IntegerBuilder implements Sampler<number> {
|
||||||
|
private minValue = 0;
|
||||||
|
private maxValue = 2 ** 31 - 1;
|
||||||
|
|
||||||
|
between(min: number, max: number): this {
|
||||||
|
this.minValue = min;
|
||||||
|
this.maxValue = max;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
generate(): number {
|
||||||
|
const rng = getSamplerRng();
|
||||||
|
const span = this.maxValue - this.minValue + 1;
|
||||||
|
return this.minValue + draw(rng, span);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class EmailBuilder implements Sampler<string> {
|
||||||
|
private host = "example.com";
|
||||||
|
|
||||||
|
domain(domain: string): this {
|
||||||
|
this.host = domain;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
generate(): string {
|
||||||
|
const local = new StringBuilder().length(3, 8).alpha().generate();
|
||||||
|
return `${local}@${this.host}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class EdgeCaseTextBuilder implements Sampler<string> {
|
||||||
|
generate(): string {
|
||||||
|
const rng = getSamplerRng();
|
||||||
|
return INPUT_CORPUS[draw(rng, INPUT_CORPUS.length)] ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function strings(): StringBuilder {
|
||||||
|
return new StringBuilder();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function integers(): IntegerBuilder {
|
||||||
|
return new IntegerBuilder();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emails(): EmailBuilder {
|
||||||
|
return new EmailBuilder();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function edgeCaseText(): EdgeCaseTextBuilder {
|
||||||
|
return new EdgeCaseTextBuilder();
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// Verb support matrix and a warn-once helper, shared by both engines.
|
||||||
|
//
|
||||||
|
// The matrix is the single declared decision about which builtin verbs each
|
||||||
|
// platform supports. The picker (pick.ts) consults it before drawing so an
|
||||||
|
// unsupported verb is surfaced once (via host.reportUnsupported) rather than
|
||||||
|
// silently producing a null action every tick.
|
||||||
|
|
||||||
|
import type { BuiltinVerb, Host } from "./action-tree.ts";
|
||||||
|
|
||||||
|
export type Platform = "android" | "ios" | "web";
|
||||||
|
|
||||||
|
// VERB_SUPPORT declares, per verb, the platforms that can execute it.
|
||||||
|
// Tap/DoubleTap/InputText/Wait: native + web.
|
||||||
|
// PressKey: native (full key set incl. back/home) + web (enter/tab/escape/arrows).
|
||||||
|
// Swipe/LongPress/Scroll: native yes; web yes (the chrome driver supports
|
||||||
|
// pointer drags / long-press / wheel), declared here rather than silently
|
||||||
|
// no-op as the older web-runtime did.
|
||||||
|
const VERB_SUPPORT: Record<BuiltinVerb, ReadonlyArray<Platform>> = {
|
||||||
|
taps: ["android", "ios", "web"],
|
||||||
|
doubleTaps: ["android", "ios", "web"],
|
||||||
|
typing: ["android", "ios", "web"],
|
||||||
|
waitOnce: ["android", "ios", "web"],
|
||||||
|
pressKeys: ["android", "ios", "web"],
|
||||||
|
swipes: ["android", "ios", "web"],
|
||||||
|
longPresses: ["android", "ios", "web"],
|
||||||
|
scrolls: ["android", "ios", "web"],
|
||||||
|
};
|
||||||
|
|
||||||
|
// supports reports whether a verb is executable on a platform.
|
||||||
|
export function supports(verb: BuiltinVerb, platform: Platform): boolean {
|
||||||
|
return VERB_SUPPORT[verb].includes(platform);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A warn-once registry keyed by `${verb}@${platform}`. Module-level so the
|
||||||
|
// "warn at most once" guarantee holds across every picker walk in a run.
|
||||||
|
const warnedKeys = new Set<string>();
|
||||||
|
|
||||||
|
function warnKey(verb: BuiltinVerb, platform: Platform): string {
|
||||||
|
return `${verb}@${platform}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// warnUnsupportedOnce calls host.reportUnsupported(verb) at most once per
|
||||||
|
// verb@platform for the lifetime of the process. Returns true on the first
|
||||||
|
// (reporting) call for a key, false on subsequent suppressed calls. The picker
|
||||||
|
// uses the boolean only for testability; behavior is the single report.
|
||||||
|
export function warnUnsupportedOnce(host: Host, verb: BuiltinVerb): boolean {
|
||||||
|
const key = warnKey(verb, host.platform());
|
||||||
|
if (warnedKeys.has(key)) return false;
|
||||||
|
warnedKeys.add(key);
|
||||||
|
host.reportUnsupported(verb);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetWarnings clears the warn-once registry. Test-only: production never
|
||||||
|
// resets, so a verb is reported once per process.
|
||||||
|
export function resetWarnings(): void {
|
||||||
|
warnedKeys.clear();
|
||||||
|
}
|
||||||
+228
-300
@@ -2,36 +2,75 @@
|
|||||||
|
|
||||||
// V8-side runtime for `sanderling test --platform web`.
|
// V8-side runtime for `sanderling test --platform web`.
|
||||||
//
|
//
|
||||||
// The user spec is bundled with this file as the first import so that
|
// This file is the WEB Host. It installs globalThis.__sanderling__ (extract +
|
||||||
// globalThis.__sanderling__ is installed before the spec evaluates. The host
|
// LTL formula binds) before the spec evaluates, implements the Host interface
|
||||||
// invokes window.__sanderlingExtractors__() and window.__sanderlingNextAction__()
|
// (platform/seed/queryCandidates/reportUnsupported) over the live DOM, and then
|
||||||
// over CDP each tick. LTL predicates are intentionally stubbed: properties
|
// delegates ALL action generation to the shared picker via installRuntime
|
||||||
// run host-side in goja, which loads its own bundle of the same spec.
|
// (runtime-entry.ts -> pick.ts). The goja verifier runs the SAME picker over the
|
||||||
|
// SAME Pcg, so a given seed yields an identical action stream by construction.
|
||||||
//
|
//
|
||||||
// Element references never cross V8/host. Action targets that reference an
|
// The host invokes window.__sanderlingExtractors__() and
|
||||||
// AccessibilityElement collapse to `{x, y}` from getBoundingClientRect()
|
// window.__sanderlingNextAction__() over CDP each tick. LTL predicates are
|
||||||
// before serialization.
|
// stubbed: properties run host-side in goja, which loads its own bundle.
|
||||||
|
//
|
||||||
|
// Element references never cross V8/host. queryCandidates resolves each element
|
||||||
|
// to a {x, y} Point via getBoundingClientRect before the picker sees it.
|
||||||
|
|
||||||
|
import { installRuntime } from "./runtime-entry.ts";
|
||||||
|
import type { BuiltinVerb, Candidate, Host } from "./action-tree.ts";
|
||||||
|
|
||||||
interface Handle {
|
interface Handle {
|
||||||
current: unknown;
|
readonly current: unknown;
|
||||||
previous: unknown;
|
readonly previous: unknown;
|
||||||
|
named(name: string): Handle;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExtractorEntry {
|
interface ExtractorEntry {
|
||||||
getter: (state: unknown) => unknown;
|
getter: (state: unknown) => unknown;
|
||||||
handle: Handle;
|
handle: Handle;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
currentValue: unknown;
|
||||||
|
previousValue: unknown;
|
||||||
interface ActionGeneratorHandle {
|
|
||||||
__sanderlingActionGenerator: true;
|
|
||||||
__sanderlingKind: string;
|
|
||||||
generate?: () => unknown;
|
|
||||||
entries?: readonly [number, ActionGeneratorHandle][];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const extractors: ExtractorEntry[] = [];
|
const extractors: ExtractorEntry[] = [];
|
||||||
let actionsRoot: ActionGeneratorHandle | null = null;
|
|
||||||
|
// extracting is true only while an extractor getter is running. The current/
|
||||||
|
// previous accessors consult it so a getter that reaches into another
|
||||||
|
// extractor's handle throws instead of reading a stale cross-extractor value.
|
||||||
|
let extracting = false;
|
||||||
|
|
||||||
|
function checkNotExtracting(slot: "current" | "previous"): void {
|
||||||
|
if (extracting) {
|
||||||
|
throw new Error(
|
||||||
|
`reading .${slot} of an extractor inside another extractor is not allowed; extractor getters may read only from the state argument`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SANDERLING_SEED is the host-computed 64-bit seed, injected as a decimal
|
||||||
|
// string via the bundle define. We parse it into a BigInt without ever going
|
||||||
|
// through a JS Number (which loses precision above 2^53), matching the goja
|
||||||
|
// side's rand.NewPCG(seed, 0): hi = seed, lo = 0.
|
||||||
|
function injectedSeed(): string | undefined {
|
||||||
|
try {
|
||||||
|
return process.env.SANDERLING_SEED;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedBigInt(): bigint {
|
||||||
|
const raw = injectedSeed();
|
||||||
|
if (!raw) return 0n;
|
||||||
|
try {
|
||||||
|
return BigInt(raw);
|
||||||
|
} catch {
|
||||||
|
return 0n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEED_HI = seedBigInt();
|
||||||
|
|
||||||
function noopFormula(): unknown {
|
function noopFormula(): unknown {
|
||||||
const formula: Record<string, unknown> = { __sanderlingFormula: true };
|
const formula: Record<string, unknown> = { __sanderlingFormula: true };
|
||||||
@@ -277,6 +316,38 @@ function buildAx(): unknown {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Uncaught errors and unhandled rejections are buffered here as they fire, so
|
||||||
|
// the default noUncaughtExceptions property can observe them. Without this the
|
||||||
|
// web state.exceptions would always be empty and a page that throws would
|
||||||
|
// silently pass.
|
||||||
|
interface CapturedException {
|
||||||
|
class: string;
|
||||||
|
message: string;
|
||||||
|
stackTrace: string;
|
||||||
|
unixMillis: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const capturedExceptions: CapturedException[] = [];
|
||||||
|
|
||||||
|
function recordException(error: unknown): void {
|
||||||
|
const asError = error instanceof Error ? error : undefined;
|
||||||
|
capturedExceptions.push({
|
||||||
|
class: asError?.name ?? "Error",
|
||||||
|
message: asError?.message ?? String(error),
|
||||||
|
stackTrace: asError?.stack ?? "",
|
||||||
|
unixMillis: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof globalThis.addEventListener === "function") {
|
||||||
|
globalThis.addEventListener("error", (event: ErrorEvent) => {
|
||||||
|
recordException(event.error ?? event.message);
|
||||||
|
});
|
||||||
|
globalThis.addEventListener("unhandledrejection", (event: PromiseRejectionEvent) => {
|
||||||
|
recordException(event.reason);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function buildState(): unknown {
|
function buildState(): unknown {
|
||||||
return {
|
return {
|
||||||
snapshots: {},
|
snapshots: {},
|
||||||
@@ -286,91 +357,42 @@ function buildState(): unknown {
|
|||||||
lastAction: null,
|
lastAction: null,
|
||||||
time: 0,
|
time: 0,
|
||||||
logs: [],
|
logs: [],
|
||||||
exceptions: [],
|
exceptions: capturedExceptions.slice(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const runtime = {
|
const runtime = {
|
||||||
extract<T>(getter: (state: unknown) => T, name?: string): Handle {
|
extract<T>(getter: (state: unknown) => T, name?: string): Handle {
|
||||||
const handle: Handle = { current: undefined, previous: undefined };
|
|
||||||
const resolvedName = name && name.length > 0 ? name : `extractor_${extractors.length}`;
|
const resolvedName = name && name.length > 0 ? name : `extractor_${extractors.length}`;
|
||||||
extractors.push({
|
const entry: ExtractorEntry = {
|
||||||
getter: getter as (s: unknown) => unknown,
|
getter: getter as (s: unknown) => unknown,
|
||||||
handle,
|
handle: undefined as unknown as Handle,
|
||||||
name: resolvedName,
|
name: resolvedName,
|
||||||
});
|
currentValue: undefined,
|
||||||
|
previousValue: undefined,
|
||||||
|
};
|
||||||
|
const handle: Handle = {
|
||||||
|
get current() {
|
||||||
|
checkNotExtracting("current");
|
||||||
|
return entry.currentValue;
|
||||||
|
},
|
||||||
|
get previous() {
|
||||||
|
checkNotExtracting("previous");
|
||||||
|
return entry.previousValue;
|
||||||
|
},
|
||||||
|
named(name: string): Handle {
|
||||||
|
entry.name = name;
|
||||||
|
return handle;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
entry.handle = handle;
|
||||||
|
extractors.push(entry);
|
||||||
return handle;
|
return handle;
|
||||||
},
|
},
|
||||||
always: noopFormula,
|
always: noopFormula,
|
||||||
now: noopFormula,
|
now: noopFormula,
|
||||||
next: noopFormula,
|
next: noopFormula,
|
||||||
eventually: noopFormula,
|
eventually: noopFormula,
|
||||||
actions(generator: () => unknown): ActionGeneratorHandle {
|
|
||||||
const handle: ActionGeneratorHandle = {
|
|
||||||
__sanderlingActionGenerator: true,
|
|
||||||
__sanderlingKind: "actions",
|
|
||||||
generate: generator,
|
|
||||||
};
|
|
||||||
if (!actionsRoot) actionsRoot = handle;
|
|
||||||
return handle;
|
|
||||||
},
|
|
||||||
weighted(...entries: [number, ActionGeneratorHandle][]): ActionGeneratorHandle {
|
|
||||||
const handle: ActionGeneratorHandle = {
|
|
||||||
__sanderlingActionGenerator: true,
|
|
||||||
__sanderlingKind: "weighted",
|
|
||||||
entries,
|
|
||||||
};
|
|
||||||
actionsRoot = handle;
|
|
||||||
return handle;
|
|
||||||
},
|
|
||||||
from<T>(items: readonly T[]): { generate: () => T | undefined } {
|
|
||||||
return {
|
|
||||||
generate(): T | undefined {
|
|
||||||
if (items.length === 0) return undefined;
|
|
||||||
return items[Math.floor(Math.random() * items.length)];
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
tap(p: { on: unknown }): unknown {
|
|
||||||
return { kind: "Tap", on: p.on };
|
|
||||||
},
|
|
||||||
doubleTap(p: { on: unknown }): unknown {
|
|
||||||
return { kind: "DoubleTap", on: p.on };
|
|
||||||
},
|
|
||||||
longPress(): unknown {
|
|
||||||
// Why: web has no long-press gesture for v1; the factory returns null so LongPress() calls no-op.
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
scroll(): unknown {
|
|
||||||
// Why: web has no native scroll gesture for v1; the factory returns null so Scroll() calls no-op.
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
inputText(p: { into: unknown; text: string }): unknown {
|
|
||||||
return { kind: "InputText", into: p.into, text: p.text };
|
|
||||||
},
|
|
||||||
swipe(_p: { from: unknown; to: unknown; durationMillis?: number }): unknown {
|
|
||||||
// Why: web has no swipe gesture; the factory returns null so Swipe() calls in specs no-op.
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
pressKey(p: { key: string }): unknown {
|
|
||||||
if (!WEB_PRESS_KEYS.includes(p.key)) {
|
|
||||||
throw new Error(
|
|
||||||
`pressKey: unsupported key ${JSON.stringify(p.key)} (allowed: ${WEB_PRESS_KEYS.join(", ")})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return { kind: "PressKey", key: p.key };
|
|
||||||
},
|
|
||||||
wait(p: { durationMillis: number }): unknown {
|
|
||||||
return { kind: "Wait", durationMillis: p.durationMillis };
|
|
||||||
},
|
|
||||||
taps: { __sanderlingActionGenerator: true, __sanderlingKind: "taps" } as ActionGeneratorHandle,
|
|
||||||
doubleTaps: { __sanderlingActionGenerator: true, __sanderlingKind: "doubleTaps" } as ActionGeneratorHandle,
|
|
||||||
longPresses: { __sanderlingActionGenerator: true, __sanderlingKind: "longPresses" } as ActionGeneratorHandle,
|
|
||||||
scrolls: { __sanderlingActionGenerator: true, __sanderlingKind: "scrolls" } as ActionGeneratorHandle,
|
|
||||||
typing: { __sanderlingActionGenerator: true, __sanderlingKind: "typing" } as ActionGeneratorHandle,
|
|
||||||
swipes: { __sanderlingActionGenerator: true, __sanderlingKind: "swipes" } as ActionGeneratorHandle,
|
|
||||||
waitOnce: { __sanderlingActionGenerator: true, __sanderlingKind: "waitOnce" } as ActionGeneratorHandle,
|
|
||||||
pressKeys: { __sanderlingActionGenerator: true, __sanderlingKind: "pressKey" } as ActionGeneratorHandle,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Lock the runtime globals so a misbehaving (or malicious) page script can't
|
// Lock the runtime globals so a misbehaving (or malicious) page script can't
|
||||||
@@ -378,11 +400,16 @@ const runtime = {
|
|||||||
// the host invoking the extractor/next-action callbacks.
|
// the host invoking the extractor/next-action callbacks.
|
||||||
defineLockedGlobal("__sanderling__", runtime);
|
defineLockedGlobal("__sanderling__", runtime);
|
||||||
|
|
||||||
|
// writable:false stops a page script from shadowing the runtime via plain
|
||||||
|
// assignment (the realistic in-page threat). configurable:true is required so
|
||||||
|
// unit tests sharing one process can reinstall a fake via defineProperty; a
|
||||||
|
// non-configurable lock would poison globalThis.__sanderling__ for every later
|
||||||
|
// test in the run.
|
||||||
function defineLockedGlobal(name: string, value: unknown): void {
|
function defineLockedGlobal(name: string, value: unknown): void {
|
||||||
Object.defineProperty(globalThis, name, {
|
Object.defineProperty(globalThis, name, {
|
||||||
value,
|
value,
|
||||||
writable: false,
|
writable: false,
|
||||||
configurable: false,
|
configurable: true,
|
||||||
enumerable: false,
|
enumerable: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -393,14 +420,19 @@ function evaluateExtractors(): Record<number, unknown> {
|
|||||||
for (let i = 0; i < extractors.length; i++) {
|
for (let i = 0; i < extractors.length; i++) {
|
||||||
const entry = extractors[i];
|
const entry = extractors[i];
|
||||||
if (!entry) continue;
|
if (!entry) continue;
|
||||||
entry.handle.previous = entry.handle.current;
|
entry.previousValue = entry.currentValue;
|
||||||
|
// Let getter throws propagate, matching the goja side, where a getter
|
||||||
|
// error aborts PushSnapshot rather than yielding undefined. Swallowing
|
||||||
|
// here would silence the cross-extractor read guard (and every other
|
||||||
|
// author error) on web only, breaking cross-engine parity.
|
||||||
let value: unknown;
|
let value: unknown;
|
||||||
|
extracting = true;
|
||||||
try {
|
try {
|
||||||
value = entry.getter(state);
|
value = entry.getter(state);
|
||||||
} catch {
|
} finally {
|
||||||
value = undefined;
|
extracting = false;
|
||||||
}
|
}
|
||||||
entry.handle.current = value;
|
entry.currentValue = value;
|
||||||
result[i] = sanitize(value);
|
result[i] = sanitize(value);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@@ -435,96 +467,21 @@ function sanitizeAt(value: unknown, depth: number, seen: WeakSet<object>): unkno
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickWeighted(handle: ActionGeneratorHandle): ActionGeneratorHandle | null {
|
// Per-verb DOM selector sets. The tappable set backs taps/doubleTaps/longPresses
|
||||||
const entries = handle.entries ?? [];
|
// (every tappable element is also a valid long-press target); the editable set
|
||||||
if (entries.length === 0) return null;
|
// backs typing; swipes/scrolls target the scrollable element.
|
||||||
let total = 0;
|
const TAPPABLE_SELECTOR = 'a, button, input, select, textarea, [role="button"], [onclick]';
|
||||||
for (const [weight] of entries) total += Math.max(0, weight);
|
const EDITABLE_SELECTOR = "input, textarea, [contenteditable]";
|
||||||
if (total <= 0) return null;
|
|
||||||
let pick = Math.random() * total;
|
|
||||||
for (const [weight, generator] of entries) {
|
|
||||||
pick -= Math.max(0, weight);
|
|
||||||
if (pick <= 0) return generator;
|
|
||||||
}
|
|
||||||
return entries[entries.length - 1]?.[1] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveGenerator(handle: ActionGeneratorHandle): unknown {
|
|
||||||
switch (handle.__sanderlingKind) {
|
|
||||||
case "actions": {
|
|
||||||
const generated = handle.generate?.();
|
|
||||||
return pickFromArray(generated);
|
|
||||||
}
|
|
||||||
case "weighted": {
|
|
||||||
const inner = pickWeighted(handle);
|
|
||||||
if (!inner) return null;
|
|
||||||
return resolveGenerator(inner);
|
|
||||||
}
|
|
||||||
case "taps":
|
|
||||||
return randomTap("Tap");
|
|
||||||
case "doubleTaps":
|
|
||||||
return randomTap("DoubleTap");
|
|
||||||
case "typing":
|
|
||||||
return randomInput();
|
|
||||||
case "swipes":
|
|
||||||
return randomSwipe();
|
|
||||||
case "longPresses":
|
|
||||||
case "scrolls":
|
|
||||||
// Why: web has no long-press or native scroll gesture for v1; both no-op.
|
|
||||||
return null;
|
|
||||||
case "waitOnce":
|
|
||||||
return { kind: "Wait", durationMillis: 500 };
|
|
||||||
case "pressKey":
|
|
||||||
return randomPressKey();
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-tick cache so the 16-attempt retry loop in __sanderlingNextAction__
|
|
||||||
// doesn't re-walk the DOM and re-flush layout on every iteration.
|
|
||||||
let randomTapCandidates: HTMLElement[] | null = null;
|
|
||||||
|
|
||||||
function randomTap(kind: "Tap" | "DoubleTap"): unknown {
|
|
||||||
if (!randomTapCandidates) {
|
|
||||||
randomTapCandidates = Array.from(
|
|
||||||
document.querySelectorAll<HTMLElement>(
|
|
||||||
'a, button, input, select, textarea, [role="button"], [onclick]',
|
|
||||||
),
|
|
||||||
).filter((element) => {
|
|
||||||
if ((element as HTMLButtonElement).disabled) return false;
|
|
||||||
const rect = element.getBoundingClientRect();
|
|
||||||
return rect.width > 0 && rect.height > 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const candidates = randomTapCandidates;
|
|
||||||
if (candidates.length === 0) return null;
|
|
||||||
const picked = candidates[Math.floor(Math.random() * candidates.length)];
|
|
||||||
if (!picked) return null;
|
|
||||||
const rect = picked.getBoundingClientRect();
|
|
||||||
return {
|
|
||||||
kind,
|
|
||||||
on: {
|
|
||||||
x: Math.round(rect.left + rect.width / 2),
|
|
||||||
y: Math.round(rect.top + rect.height / 2),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-tick cache mirroring randomTapCandidates, reset each NextAction tick.
|
|
||||||
let randomInputCandidates: HTMLElement[] | null = null;
|
|
||||||
|
|
||||||
const NON_TEXT_INPUT_TYPES = [
|
const NON_TEXT_INPUT_TYPES = [
|
||||||
"button", "submit", "checkbox", "radio", "range", "color", "file", "image", "reset",
|
"button", "submit", "checkbox", "radio", "range", "color", "file", "image", "reset",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Edge-case input pool mirroring the goja-side inputCorpus: empty, whitespace,
|
function isVisible(element: HTMLElement): boolean {
|
||||||
// overflow length, unicode, numeric boundaries, and common injection payloads.
|
if ((element as HTMLButtonElement).disabled) return false;
|
||||||
const WEB_INPUT_CORPUS = [
|
const rect = element.getBoundingClientRect();
|
||||||
"", "a", "a".repeat(4096), "🙂🔥💸", " ", "\t\n", "-1",
|
return rect.width > 0 && rect.height > 0;
|
||||||
"999999999999999999999", "0.0000001", "1e10", "'; DROP TABLE--",
|
}
|
||||||
"<script>alert(1)</script>", "../../etc/passwd", "%s%n", "NaN",
|
|
||||||
];
|
|
||||||
|
|
||||||
function isEditableElement(element: HTMLElement): boolean {
|
function isEditableElement(element: HTMLElement): boolean {
|
||||||
if (element.isContentEditable) return true;
|
if (element.isContentEditable) return true;
|
||||||
@@ -537,141 +494,112 @@ function isEditableElement(element: HTMLElement): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function randomInput(): unknown {
|
function pointOf(element: Element): Candidate {
|
||||||
if (!randomInputCandidates) {
|
const rect = element.getBoundingClientRect();
|
||||||
randomInputCandidates = Array.from(
|
|
||||||
document.querySelectorAll<HTMLElement>("input, textarea, [contenteditable]"),
|
|
||||||
).filter((element) => {
|
|
||||||
if (!isEditableElement(element)) return false;
|
|
||||||
if ((element as HTMLInputElement).disabled) return false;
|
|
||||||
const rect = element.getBoundingClientRect();
|
|
||||||
return rect.width > 0 && rect.height > 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const candidates = randomInputCandidates;
|
|
||||||
if (candidates.length === 0) return null;
|
|
||||||
const picked = candidates[Math.floor(Math.random() * candidates.length)];
|
|
||||||
if (!picked) return null;
|
|
||||||
const rect = picked.getBoundingClientRect();
|
|
||||||
const text = WEB_INPUT_CORPUS[Math.floor(Math.random() * WEB_INPUT_CORPUS.length)];
|
|
||||||
return {
|
return {
|
||||||
kind: "InputText",
|
x: Math.round(rect.left + rect.width / 2),
|
||||||
into: {
|
y: Math.round(rect.top + rect.height / 2),
|
||||||
x: Math.round(rect.left + rect.width / 2),
|
width: Math.round(rect.width),
|
||||||
y: Math.round(rect.top + rect.height / 2),
|
height: Math.round(rect.height),
|
||||||
},
|
|
||||||
text,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function randomSwipe(): unknown {
|
function tappableCandidates(): Candidate[] {
|
||||||
// Why: web has no swipe gesture; pointer drags into empty divs are noise.
|
return Array.from(document.querySelectorAll<HTMLElement>(TAPPABLE_SELECTOR))
|
||||||
return null;
|
.filter(isVisible)
|
||||||
|
.map(pointOf);
|
||||||
}
|
}
|
||||||
|
|
||||||
const WEB_PRESS_KEYS = ["enter", "tab", "escape", "up", "down", "left", "right"];
|
function editableCandidates(): Candidate[] {
|
||||||
|
return Array.from(document.querySelectorAll<HTMLElement>(EDITABLE_SELECTOR))
|
||||||
function randomPressKey(): unknown {
|
.filter((element) => isEditableElement(element) && isVisible(element))
|
||||||
// Why: only emit keys with meaningful browser semantics; "back"/"home" don't navigate.
|
.map(pointOf);
|
||||||
const key = WEB_PRESS_KEYS[Math.floor(Math.random() * WEB_PRESS_KEYS.length)];
|
|
||||||
return { kind: "PressKey", key };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickFromArray(value: unknown): unknown {
|
// Scrollable candidates back swipe/scroll: elements that overflow their box,
|
||||||
if (!value) return null;
|
// plus the scrolling root so a page-level scroll always has a target.
|
||||||
if (!Array.isArray(value)) return value;
|
function scrollableCandidates(): Candidate[] {
|
||||||
if (value.length === 0) return null;
|
const root = document.scrollingElement ?? document.documentElement;
|
||||||
return value[Math.floor(Math.random() * value.length)];
|
const candidates: Candidate[] = root ? [pointOf(root)] : [];
|
||||||
}
|
for (const element of Array.from(document.querySelectorAll<HTMLElement>("*"))) {
|
||||||
|
if (element === root) continue;
|
||||||
function serializeAction(action: unknown): unknown {
|
if (element.scrollHeight <= element.clientHeight && element.scrollWidth <= element.clientWidth) {
|
||||||
if (!action || typeof action !== "object") return null;
|
continue;
|
||||||
const obj = action as Record<string, unknown>;
|
|
||||||
switch (obj.kind) {
|
|
||||||
case "Tap": {
|
|
||||||
const point = pointOf(obj.on);
|
|
||||||
if (!point) {
|
|
||||||
console.warn("[sanderling] Tap target did not resolve to coordinates");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return { kind: "Tap", x: point.x, y: point.y };
|
|
||||||
}
|
}
|
||||||
case "DoubleTap": {
|
if (!isVisible(element)) continue;
|
||||||
const point = pointOf(obj.on);
|
candidates.push(pointOf(element));
|
||||||
if (!point) {
|
|
||||||
console.warn("[sanderling] DoubleTap target did not resolve to coordinates");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return { kind: "DoubleTap", x: point.x, y: point.y };
|
|
||||||
}
|
|
||||||
case "InputText": {
|
|
||||||
const point = pointOf(obj.into);
|
|
||||||
if (!point) {
|
|
||||||
console.warn("[sanderling] InputText target did not resolve to coordinates");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
kind: "InputText",
|
|
||||||
x: point.x,
|
|
||||||
y: point.y,
|
|
||||||
text: obj.text ?? "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
case "Swipe": {
|
|
||||||
const from = pointOf(obj.from);
|
|
||||||
const to = pointOf(obj.to);
|
|
||||||
if (!from || !to) {
|
|
||||||
console.warn("[sanderling] Swipe endpoints did not resolve to coordinates");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
kind: "Swipe",
|
|
||||||
from_x: from.x,
|
|
||||||
from_y: from.y,
|
|
||||||
to_x: to.x,
|
|
||||||
to_y: to.y,
|
|
||||||
duration_millis: obj.durationMillis ?? 250,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
case "LongPress":
|
|
||||||
case "Scroll":
|
|
||||||
// Why: web has no long-press or native scroll gesture for v1; drop the action.
|
|
||||||
return null;
|
|
||||||
case "PressKey":
|
|
||||||
return { kind: "PressKey", key: obj.key };
|
|
||||||
case "Wait":
|
|
||||||
return { kind: "Wait", duration_millis: obj.durationMillis ?? 0 };
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
return candidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pointOf(value: unknown): { x: number; y: number } | undefined {
|
// Per-tick candidate cache: the picker's 16-attempt retry re-queries the same
|
||||||
if (!value || typeof value !== "object") return undefined;
|
// verb, so we avoid re-walking the DOM and re-flushing layout within one tick.
|
||||||
const obj = value as Record<string, unknown>;
|
// installRuntime resets it before each __sanderlingNextAction__ invocation.
|
||||||
if (typeof obj.x === "number" && typeof obj.y === "number") {
|
const candidateCache = new Map<BuiltinVerb, Candidate[]>();
|
||||||
return { x: obj.x, y: obj.y };
|
|
||||||
}
|
function resetCandidateCache(): void {
|
||||||
return undefined;
|
candidateCache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
defineLockedGlobal("__sanderlingExtractors__", function (): Record<number, unknown> {
|
const host: Host = {
|
||||||
return evaluateExtractors();
|
platform: () => "web",
|
||||||
});
|
seedHi: () => SEED_HI,
|
||||||
|
// lo = 0 matches the goja side's rand.NewPCG(seed, 0).
|
||||||
|
seedLo: () => 0n,
|
||||||
|
queryCandidates(verb: BuiltinVerb): Candidate[] {
|
||||||
|
const cached = candidateCache.get(verb);
|
||||||
|
if (cached) return cached;
|
||||||
|
let candidates: Candidate[];
|
||||||
|
switch (verb) {
|
||||||
|
case "taps":
|
||||||
|
case "doubleTaps":
|
||||||
|
case "longPresses":
|
||||||
|
candidates = tappableCandidates();
|
||||||
|
break;
|
||||||
|
case "typing":
|
||||||
|
candidates = editableCandidates();
|
||||||
|
break;
|
||||||
|
case "swipes":
|
||||||
|
case "scrolls":
|
||||||
|
candidates = scrollableCandidates();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
candidates = [];
|
||||||
|
}
|
||||||
|
candidateCache.set(verb, candidates);
|
||||||
|
return candidates;
|
||||||
|
},
|
||||||
|
reportUnsupported(verb: BuiltinVerb): void {
|
||||||
|
console.warn(`[sanderling] verb ${verb} is unsupported on web`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
defineLockedGlobal("__sanderlingNextAction__", function (): unknown {
|
// The spec assigns its action root to globalThis.actions, and it runs AFTER
|
||||||
if (!actionsRoot) return null;
|
// this module (the web bundle imports the runtime first). Resolve the root
|
||||||
// Reset per-tick caches so each invocation gets a fresh DOM scan.
|
// lazily so installRuntime captures it once the spec has evaluated. The root
|
||||||
randomTapCandidates = null;
|
// resolver runs once per __sanderlingNextAction__ tick (before the retry loop),
|
||||||
randomInputCandidates = null;
|
// so it is also where we reset the per-tick candidate cache.
|
||||||
// Match the goja runtime: retry up to 16 times when a weighted entry's
|
installRuntime(
|
||||||
// generator returns []. Otherwise on routes where most generators are
|
host,
|
||||||
// gated to other pages, ~80% of ticks would emit no action.
|
() => {
|
||||||
for (let attempt = 0; attempt < 16; attempt++) {
|
resetCandidateCache();
|
||||||
const action = serializeAction(resolveGenerator(actionsRoot));
|
return (globalThis as { actions?: import("./action-tree.ts").GeneratorNode }).actions ?? null;
|
||||||
if (action !== null) return action;
|
},
|
||||||
}
|
evaluateExtractors,
|
||||||
return null;
|
);
|
||||||
});
|
|
||||||
|
// Test-only exports. The IIFE bundle the host installs has no export surface,
|
||||||
|
// so these are stripped from production output; they only exist for unit tests.
|
||||||
|
export const __testing__ = {
|
||||||
|
host,
|
||||||
|
seedBigInt,
|
||||||
|
tappableCandidates,
|
||||||
|
editableCandidates,
|
||||||
|
scrollableCandidates,
|
||||||
|
resetCandidateCache,
|
||||||
|
runtime,
|
||||||
|
extractors,
|
||||||
|
evaluateExtractors,
|
||||||
|
};
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
+126
-135
@@ -4,38 +4,46 @@ import { test } from "node:test";
|
|||||||
import {
|
import {
|
||||||
DoubleTap,
|
DoubleTap,
|
||||||
InputText,
|
InputText,
|
||||||
|
LongPress,
|
||||||
PressKey,
|
PressKey,
|
||||||
|
Scroll,
|
||||||
Swipe,
|
Swipe,
|
||||||
Tap,
|
Tap,
|
||||||
Wait,
|
Wait,
|
||||||
actions,
|
actions,
|
||||||
always,
|
always,
|
||||||
|
doubleTaps,
|
||||||
eventually,
|
eventually,
|
||||||
extract,
|
extract,
|
||||||
from,
|
from,
|
||||||
keyedBy,
|
keyedBy,
|
||||||
|
longPresses,
|
||||||
next,
|
next,
|
||||||
now,
|
now,
|
||||||
pressKey,
|
pressKeys,
|
||||||
|
scrolls,
|
||||||
swipes,
|
swipes,
|
||||||
taps,
|
taps,
|
||||||
|
typing,
|
||||||
waitOnce,
|
waitOnce,
|
||||||
weighted,
|
weighted,
|
||||||
whenRoute,
|
whenRoute,
|
||||||
} from "../src/index.ts";
|
} from "../src/index.ts";
|
||||||
|
import { setSamplerRng } from "../src/actions.ts";
|
||||||
|
import { Pcg } from "../src/pcg.ts";
|
||||||
|
import type { GeneratorNode } from "../src/action-tree.ts";
|
||||||
import type {
|
import type {
|
||||||
AccessibilityElement,
|
AccessibilityElement,
|
||||||
Action,
|
|
||||||
ActionGenerator,
|
|
||||||
EventuallyFormula,
|
EventuallyFormula,
|
||||||
Extracted,
|
Extracted,
|
||||||
Formula,
|
Formula,
|
||||||
Sampler,
|
|
||||||
State,
|
State,
|
||||||
SanderlingRuntime,
|
SanderlingRuntime,
|
||||||
WeightedEntry,
|
|
||||||
} from "../src/types.ts";
|
} from "../src/types.ts";
|
||||||
|
|
||||||
|
// extract() and the LTL constructors stay host-bound on __sanderling__; the
|
||||||
|
// action factories now return plain data and need no runtime. This fake records
|
||||||
|
// the host-bound calls so the forwarding tests can assert them.
|
||||||
interface RecordedRuntime extends SanderlingRuntime {
|
interface RecordedRuntime extends SanderlingRuntime {
|
||||||
extracts: Array<(state: State) => unknown>;
|
extracts: Array<(state: State) => unknown>;
|
||||||
extractNames: Array<string | undefined>;
|
extractNames: Array<string | undefined>;
|
||||||
@@ -48,13 +56,10 @@ interface RecordedRuntime extends SanderlingRuntime {
|
|||||||
orCalls: number;
|
orCalls: number;
|
||||||
andCalls: number;
|
andCalls: number;
|
||||||
notCalls: number;
|
notCalls: number;
|
||||||
actionGenerators: Array<() => Action[]>;
|
|
||||||
weightedCalls: WeightedEntry[][];
|
|
||||||
fromCalls: unknown[][];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeChainableFormula(record: RecordedRuntime): Formula {
|
function makeChainableFormula(record: RecordedRuntime): Formula {
|
||||||
const formula: Formula = {
|
return {
|
||||||
__sanderlingFormula: true,
|
__sanderlingFormula: true,
|
||||||
implies(other: Formula): Formula {
|
implies(other: Formula): Formula {
|
||||||
record.impliesCalls++;
|
record.impliesCalls++;
|
||||||
@@ -76,7 +81,6 @@ function makeChainableFormula(record: RecordedRuntime): Formula {
|
|||||||
return makeChainableFormula(record);
|
return makeChainableFormula(record);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return formula;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeChainableEventually(record: RecordedRuntime): EventuallyFormula {
|
function makeChainableEventually(record: RecordedRuntime): EventuallyFormula {
|
||||||
@@ -103,11 +107,8 @@ function installFakeRuntime(): RecordedRuntime {
|
|||||||
orCalls: 0,
|
orCalls: 0,
|
||||||
andCalls: 0,
|
andCalls: 0,
|
||||||
notCalls: 0,
|
notCalls: 0,
|
||||||
actionGenerators: [] as Array<() => Action[]>,
|
|
||||||
weightedCalls: [] as WeightedEntry[][],
|
|
||||||
fromCalls: [] as unknown[][],
|
|
||||||
};
|
};
|
||||||
const runtime = {
|
const runtime: SanderlingRuntime = {
|
||||||
extract: <T>(getter: (state: State) => T, name?: string): Extracted<T> => {
|
extract: <T>(getter: (state: State) => T, name?: string): Extracted<T> => {
|
||||||
calls.extracts.push(getter as (state: State) => unknown);
|
calls.extracts.push(getter as (state: State) => unknown);
|
||||||
calls.extractNames.push(name);
|
calls.extractNames.push(name);
|
||||||
@@ -129,42 +130,17 @@ function installFakeRuntime(): RecordedRuntime {
|
|||||||
calls.eventuallyPredicates.push(predicate);
|
calls.eventuallyPredicates.push(predicate);
|
||||||
return makeChainableEventually(recorded);
|
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 }),
|
|
||||||
doubleTap: ({ on }) => ({ kind: "DoubleTap", on }),
|
|
||||||
longPress: ({ on }) => ({ kind: "LongPress", on }),
|
|
||||||
scroll: ({ direction, in: container }) => ({ kind: "Scroll", direction, in: container }),
|
|
||||||
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: () => [] },
|
|
||||||
doubleTaps: { __sanderlingActionGenerator: true, generate: () => [] },
|
|
||||||
longPresses: { __sanderlingActionGenerator: true, generate: () => [] },
|
|
||||||
scrolls: { __sanderlingActionGenerator: true, generate: () => [] },
|
|
||||||
typing: { __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;
|
const recorded = Object.assign(runtime, calls) as RecordedRuntime;
|
||||||
globalThis.__sanderling__ = recorded;
|
// web-runtime.ts locks __sanderling__ as non-writable (configurable) when it
|
||||||
|
// is imported earlier in the same bun-test process, so a plain assignment
|
||||||
|
// would throw. defineProperty installs (and reinstalls) the fake regardless.
|
||||||
|
Object.defineProperty(globalThis, "__sanderling__", {
|
||||||
|
value: recorded,
|
||||||
|
writable: false,
|
||||||
|
configurable: true,
|
||||||
|
enumerable: false,
|
||||||
|
});
|
||||||
return recorded;
|
return recorded;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,14 +178,6 @@ test("always wraps a predicate into a formula via the runtime", () => {
|
|||||||
assert.equal(formula.__sanderlingFormula, true);
|
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", () => {
|
test("now/next/eventually forward predicates", () => {
|
||||||
const runtime = installFakeRuntime();
|
const runtime = installFakeRuntime();
|
||||||
const p1 = () => true;
|
const p1 = () => true;
|
||||||
@@ -240,20 +208,30 @@ test("formula chaining exposes implies/or/and/not", () => {
|
|||||||
assert.equal(runtime.notCalls, 1);
|
assert.equal(runtime.notCalls, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Tap returns a TapAction with the supplied selector", () => {
|
test("Tap returns a TapAction descriptor", () => {
|
||||||
installFakeRuntime();
|
assert.deepEqual(Tap({ on: "id:login_continue" }), {
|
||||||
const action = Tap({ on: "id:login_continue" });
|
kind: "Tap",
|
||||||
assert.deepEqual(action, { kind: "Tap", on: "id:login_continue" });
|
on: "id:login_continue",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("DoubleTap returns a DoubleTapAction with the supplied selector", () => {
|
test("DoubleTap returns a DoubleTapAction descriptor", () => {
|
||||||
installFakeRuntime();
|
assert.deepEqual(DoubleTap({ on: "id:save" }), { kind: "DoubleTap", on: "id:save" });
|
||||||
const action = DoubleTap({ on: "id:save" });
|
});
|
||||||
assert.deepEqual(action, { kind: "DoubleTap", on: "id:save" });
|
|
||||||
|
test("LongPress returns a LongPressAction descriptor", () => {
|
||||||
|
assert.deepEqual(LongPress({ on: "id:row" }), { kind: "LongPress", on: "id:row" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Scroll returns a ScrollAction descriptor", () => {
|
||||||
|
assert.deepEqual(Scroll({ direction: "down", in: "id:list" }), {
|
||||||
|
kind: "Scroll",
|
||||||
|
direction: "down",
|
||||||
|
in: "id:list",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Tap accepts an AccessibilityElement", () => {
|
test("Tap accepts an AccessibilityElement", () => {
|
||||||
installFakeRuntime();
|
|
||||||
const element: AccessibilityElement = {
|
const element: AccessibilityElement = {
|
||||||
id: "login_continue",
|
id: "login_continue",
|
||||||
find: () => undefined,
|
find: () => undefined,
|
||||||
@@ -264,63 +242,92 @@ test("Tap accepts an AccessibilityElement", () => {
|
|||||||
assert.equal(action.on, element);
|
assert.equal(action.on, element);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("InputText returns an InputTextAction", () => {
|
test("InputText returns an InputTextAction descriptor", () => {
|
||||||
installFakeRuntime();
|
assert.deepEqual(InputText({ into: "id:phone", text: "+1234567890" }), {
|
||||||
const action = InputText({ into: "id:phone", text: "+1234567890" });
|
kind: "InputText",
|
||||||
assert.deepEqual(action, { kind: "InputText", into: "id:phone", text: "+1234567890" });
|
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", () => {
|
test("Swipe returns a SwipeAction descriptor", () => {
|
||||||
installFakeRuntime();
|
assert.deepEqual(
|
||||||
const action = PressKey({ key: "back" });
|
Swipe({ from: { x: 10, y: 20 }, to: { x: 30, y: 40 }, durationMillis: 400 }),
|
||||||
assert.deepEqual(action, { kind: "PressKey", key: "back" });
|
{ kind: "Swipe", from: { x: 10, y: 20 }, to: { x: 30, y: 40 }, durationMillis: 400 },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Wait returns a WaitAction", () => {
|
test("PressKey returns a PressKeyAction descriptor", () => {
|
||||||
installFakeRuntime();
|
assert.deepEqual(PressKey({ key: "back" }), { kind: "PressKey", key: "back" });
|
||||||
const action = Wait({ durationMillis: 500 });
|
|
||||||
assert.deepEqual(action, { kind: "Wait", durationMillis: 500 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("actions wraps a generator into the runtime's ActionGenerator", () => {
|
test("Wait returns a WaitAction descriptor", () => {
|
||||||
const runtime = installFakeRuntime();
|
assert.deepEqual(Wait({ durationMillis: 500 }), { kind: "Wait", durationMillis: 500 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("actions returns an actions node carrying the generator", () => {
|
||||||
const generator = () => [Tap({ on: "id:x" })];
|
const generator = () => [Tap({ on: "id:x" })];
|
||||||
const wrapped = actions(generator);
|
const node = actions(generator);
|
||||||
assert.equal(runtime.actionGenerators[0], generator);
|
assert.equal(node.kind, "actions");
|
||||||
assert.equal(wrapped.__sanderlingActionGenerator, true);
|
assert.equal((node as { generate: unknown }).generate, generator);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("weighted forwards weighted entries to the runtime", () => {
|
test("weighted returns a weighted node carrying the branches", () => {
|
||||||
const runtime = installFakeRuntime();
|
const node = weighted([80, taps], [20, swipes]);
|
||||||
const entries: WeightedEntry[] = [
|
assert.equal(node.kind, "weighted");
|
||||||
|
assert.deepEqual((node as { branches: unknown }).branches, [
|
||||||
[80, taps],
|
[80, taps],
|
||||||
[20, swipes],
|
[20, swipes],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("builtin factories return builtin nodes carrying their verb", () => {
|
||||||
|
const cases: Array<[GeneratorNode, string]> = [
|
||||||
|
[taps, "taps"],
|
||||||
|
[doubleTaps, "doubleTaps"],
|
||||||
|
[longPresses, "longPresses"],
|
||||||
|
[scrolls, "scrolls"],
|
||||||
|
[typing, "typing"],
|
||||||
|
[swipes, "swipes"],
|
||||||
|
[waitOnce, "waitOnce"],
|
||||||
|
[pressKeys, "pressKeys"],
|
||||||
];
|
];
|
||||||
weighted(...entries);
|
for (const [node, verb] of cases) {
|
||||||
assert.deepEqual(runtime.weightedCalls[0], entries);
|
assert.equal(node.kind, "builtin");
|
||||||
|
assert.equal((node as { verb: string }).verb, verb);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("from forwards items to the runtime", () => {
|
test("from with a single item returns it without drawing", () => {
|
||||||
const runtime = installFakeRuntime();
|
const sampler = from(["only"]);
|
||||||
const sampler = from(["a", "b", "c"]);
|
setSamplerRng(new Pcg(42n, 0n));
|
||||||
assert.deepEqual(runtime.fromCalls[0], ["a", "b", "c"]);
|
try {
|
||||||
assert.equal(sampler.generate(), "a");
|
assert.equal(sampler.generate(), "only");
|
||||||
|
} finally {
|
||||||
|
setSamplerRng(null);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function elementWithChildren(
|
test("from draws intN(len) from the active picker rng", () => {
|
||||||
cells: Record<string, string>,
|
const items = ["a", "b", "c"];
|
||||||
): AccessibilityElement {
|
const sampler = from(items);
|
||||||
|
const rng = new Pcg(42n, 0n);
|
||||||
|
const oracle = new Pcg(42n, 0n);
|
||||||
|
setSamplerRng(rng);
|
||||||
|
try {
|
||||||
|
const index = oracle.intN(items.length);
|
||||||
|
assert.equal(sampler.generate(), items[index]);
|
||||||
|
} finally {
|
||||||
|
setSamplerRng(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("from falls back to the first item outside a picker walk", () => {
|
||||||
|
setSamplerRng(null);
|
||||||
|
assert.equal(from(["a", "b", "c"]).generate(), "a");
|
||||||
|
});
|
||||||
|
|
||||||
|
function elementWithChildren(cells: Record<string, string>): AccessibilityElement {
|
||||||
return {
|
return {
|
||||||
find: selector => {
|
find: selector => {
|
||||||
if (typeof selector === "string" || Array.isArray(selector)) return undefined;
|
if (typeof selector === "string" || Array.isArray(selector)) return undefined;
|
||||||
@@ -346,58 +353,42 @@ test("keyedBy joins testTag-resolved texts with a stable delimiter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("keyedBy returns empty string for an undefined element", () => {
|
test("keyedBy returns empty string for an undefined element", () => {
|
||||||
installFakeRuntime();
|
|
||||||
assert.equal(keyedBy(undefined, ["TxnDate"]), "");
|
assert.equal(keyedBy(undefined, ["TxnDate"]), "");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keyedBy substitutes empty strings for missing children", () => {
|
test("keyedBy substitutes empty strings for missing children", () => {
|
||||||
installFakeRuntime();
|
|
||||||
const row = elementWithChildren({ TxnDate: "2026-04-26" });
|
const row = elementWithChildren({ TxnDate: "2026-04-26" });
|
||||||
assert.equal(
|
assert.equal(keyedBy(row, ["TxnDate", "TxnNote", "TxnAmount"]), "2026-04-26\x1f\x1f");
|
||||||
keyedBy(row, ["TxnDate", "TxnNote", "TxnAmount"]),
|
|
||||||
"2026-04-26\x1f\x1f",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("whenRoute returns [] when current route does not match", () => {
|
test("whenRoute body is skipped when the current route does not match", () => {
|
||||||
installFakeRuntime();
|
|
||||||
const route = { current: "home" as string | null };
|
const route = { current: "home" as string | null };
|
||||||
let bodyCalled = false;
|
let bodyCalled = false;
|
||||||
const generator = whenRoute(route, "ledger", () => {
|
const node = whenRoute(route, "ledger", () => {
|
||||||
bodyCalled = true;
|
bodyCalled = true;
|
||||||
return [Tap({ on: "id:x" })];
|
return [Tap({ on: "id:x" })];
|
||||||
});
|
});
|
||||||
assert.deepEqual(generator.generate(), []);
|
assert.equal(node.kind, "actions");
|
||||||
|
assert.deepEqual((node as { generate: () => unknown }).generate(), []);
|
||||||
assert.equal(bodyCalled, false);
|
assert.equal(bodyCalled, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("whenRoute calls body when current route matches", () => {
|
test("whenRoute runs the body when the current route matches", () => {
|
||||||
installFakeRuntime();
|
|
||||||
const route = { current: "ledger" as string | null };
|
const route = { current: "ledger" as string | null };
|
||||||
const generator = whenRoute(route, "ledger", () => [Tap({ on: "id:x" })]);
|
const node = whenRoute(route, "ledger", () => [Tap({ on: "id:x" })]);
|
||||||
const result = generator.generate();
|
const result = (node as { generate: () => Array<{ kind: string }> }).generate();
|
||||||
assert.equal(result.length, 1);
|
assert.equal(result.length, 1);
|
||||||
assert.equal(result[0]?.kind, "Tap");
|
assert.equal(result[0]?.kind, "Tap");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("whenRoute accepts an array of allowed routes", () => {
|
test("whenRoute accepts an array of allowed routes", () => {
|
||||||
installFakeRuntime();
|
|
||||||
const route = { current: "add-account" as string | null };
|
const route = { current: "add-account" as string | null };
|
||||||
const generator = whenRoute(route, ["home", "add-account"], () => [Tap({ on: "id:x" })]);
|
const node = whenRoute(route, ["home", "add-account"], () => [Tap({ on: "id:x" })]);
|
||||||
assert.equal(generator.generate().length, 1);
|
assert.equal((node as { generate: () => unknown[] }).generate().length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("whenRoute returns [] for null route", () => {
|
test("whenRoute body is skipped for a null route", () => {
|
||||||
installFakeRuntime();
|
|
||||||
const route = { current: null as string | null };
|
const route = { current: null as string | null };
|
||||||
const generator = whenRoute(route, ["home"], () => [Tap({ on: "id:x" })]);
|
const node = whenRoute(route, ["home"], () => [Tap({ on: "id:x" })]);
|
||||||
assert.deepEqual(generator.generate(), []);
|
assert.deepEqual((node as { generate: () => unknown }).generate(), []);
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
@@ -2,99 +2,29 @@ import assert from "node:assert/strict";
|
|||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
Action,
|
|
||||||
ActionGenerator,
|
|
||||||
EventuallyFormula,
|
EventuallyFormula,
|
||||||
Extracted,
|
Extracted,
|
||||||
Formula,
|
Formula,
|
||||||
Sampler,
|
|
||||||
State,
|
State,
|
||||||
SanderlingRuntime,
|
SanderlingRuntime,
|
||||||
WeightedEntry,
|
|
||||||
} from "../src/types.ts";
|
} from "../src/types.ts";
|
||||||
|
|
||||||
interface RecordedRuntime extends SanderlingRuntime {
|
// The defaults bundle still calls extract() and the LTL constructors, which
|
||||||
currentState: State;
|
// stay host-bound on __sanderling__. Action factories now return plain data
|
||||||
extractors: Array<(state: State) => unknown>;
|
// trees, so the fake runtime only needs the extract + formula surface.
|
||||||
alwaysArgs: Array<(() => boolean) | Formula>;
|
function installRuntime(initialState: State): void {
|
||||||
lastPredicate: (() => boolean) | undefined;
|
const state = { current: initialState };
|
||||||
}
|
const runtime: SanderlingRuntime = {
|
||||||
|
extract: <T>(getter: (s: State) => T): Extracted<T> => ({
|
||||||
function installRuntime(initialState: State): RecordedRuntime {
|
current: getter(state.current),
|
||||||
const extractors: Array<(state: State) => unknown> = [];
|
previous: undefined,
|
||||||
const extracted: Array<{ value: unknown }> = [];
|
}),
|
||||||
const alwaysArgs: Array<(() => boolean) | Formula> = [];
|
always: () => ({ __sanderlingFormula: true } as 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),
|
now: () => ({ __sanderlingFormula: true } as Formula),
|
||||||
next: () => ({ __sanderlingFormula: true } as Formula),
|
next: () => ({ __sanderlingFormula: true } as Formula),
|
||||||
eventually: () => ({ __sanderlingFormula: true } as EventuallyFormula),
|
eventually: () => ({ __sanderlingFormula: true } as EventuallyFormula),
|
||||||
actions: (generator: () => Action[]): ActionGenerator => ({
|
};
|
||||||
__sanderlingActionGenerator: true,
|
globalThis.__sanderling__ = runtime;
|
||||||
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 }),
|
|
||||||
doubleTap: ({ on }) => ({ kind: "DoubleTap", on }),
|
|
||||||
longPress: ({ on }) => ({ kind: "LongPress", on }),
|
|
||||||
scroll: ({ direction, in: container }) => ({ kind: "Scroll", direction, in: container }),
|
|
||||||
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,
|
|
||||||
doubleTaps: { __sanderlingActionGenerator: true, generate: () => [] } as ActionGenerator,
|
|
||||||
longPresses: { __sanderlingActionGenerator: true, generate: () => [] } as ActionGenerator,
|
|
||||||
scrolls: { __sanderlingActionGenerator: true, generate: () => [] } as ActionGenerator,
|
|
||||||
typing: { __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 = {
|
const emptyState: State = {
|
||||||
@@ -116,29 +46,29 @@ test("defaults bundle exports formulas tagged as LTL properties", async () => {
|
|||||||
assert.equal(defaults.noLogcatErrors.__sanderlingFormula, true);
|
assert.equal(defaults.noLogcatErrors.__sanderlingFormula, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("defaults bundle exports defaultActions as a valid ActionGenerator", async () => {
|
test("defaults bundle exports defaultActions as a weighted node", async () => {
|
||||||
installRuntime(emptyState);
|
installRuntime(emptyState);
|
||||||
const defaults = await import("../src/defaults/actions.ts");
|
const defaults = await import("../src/defaults/actions.ts");
|
||||||
assert.equal(defaults.defaultActions.__sanderlingActionGenerator, true);
|
assert.equal(defaults.defaultActions.kind, "weighted");
|
||||||
assert.equal(typeof defaults.defaultActions.generate, "function");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("typing resolves as a valid ActionGenerator", async () => {
|
test("typing resolves as a builtin node", async () => {
|
||||||
installRuntime(emptyState);
|
installRuntime(emptyState);
|
||||||
const { typing } = await import("../src/defaults/actions.ts");
|
const { typing } = await import("../src/defaults/actions.ts");
|
||||||
assert.equal(typing.__sanderlingActionGenerator, true);
|
assert.equal(typing.kind, "builtin");
|
||||||
|
assert.equal((typing as { verb: string }).verb, "typing");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("longPresses and scrolls resolve as valid ActionGenerators", async () => {
|
test("scrolls resolves as a builtin node", async () => {
|
||||||
installRuntime(emptyState);
|
installRuntime(emptyState);
|
||||||
const { longPresses, scrolls } = await import("../src/defaults/actions.ts");
|
const { scrolls } = await import("../src/defaults/actions.ts");
|
||||||
assert.equal(longPresses.__sanderlingActionGenerator, true);
|
assert.equal(scrolls.kind, "builtin");
|
||||||
assert.equal(scrolls.__sanderlingActionGenerator, true);
|
assert.equal((scrolls as { verb: string }).verb, "scrolls");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("defaults barrel re-exports both properties and actions", async () => {
|
test("defaults barrel re-exports both properties and actions", async () => {
|
||||||
installRuntime(emptyState);
|
installRuntime(emptyState);
|
||||||
const barrel = await import("../src/defaults/index.ts");
|
const barrel = await import("../src/defaults/index.ts");
|
||||||
assert.equal(barrel.defaultActions.__sanderlingActionGenerator, true);
|
assert.equal(barrel.defaultActions.kind, "weighted");
|
||||||
assert.equal(barrel.noUncaughtExceptions.__sanderlingFormula, true);
|
assert.equal(barrel.noUncaughtExceptions.__sanderlingFormula, true);
|
||||||
});
|
});
|
||||||
+127
@@ -0,0 +1,127 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"kind": "Tap",
|
||||||
|
"x": 250,
|
||||||
|
"y": 260,
|
||||||
|
"selector": "id:gamma"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Tap",
|
||||||
|
"x": 150,
|
||||||
|
"y": 160,
|
||||||
|
"selector": "id:beta"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Tap",
|
||||||
|
"x": 50,
|
||||||
|
"y": 60,
|
||||||
|
"selector": "id:alpha"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "InputText",
|
||||||
|
"x": 150,
|
||||||
|
"y": 160,
|
||||||
|
"text": "a",
|
||||||
|
"selector": "id:beta"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "InputText",
|
||||||
|
"x": 50,
|
||||||
|
"y": 60,
|
||||||
|
"text": "\t\n",
|
||||||
|
"selector": "id:alpha"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Tap",
|
||||||
|
"x": 250,
|
||||||
|
"y": 260,
|
||||||
|
"selector": "id:gamma"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Tap",
|
||||||
|
"x": 150,
|
||||||
|
"y": 160,
|
||||||
|
"selector": "id:beta"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Tap",
|
||||||
|
"x": 250,
|
||||||
|
"y": 260,
|
||||||
|
"selector": "id:gamma"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Tap",
|
||||||
|
"x": 150,
|
||||||
|
"y": 160,
|
||||||
|
"selector": "id:beta"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Tap",
|
||||||
|
"x": 250,
|
||||||
|
"y": 260,
|
||||||
|
"selector": "id:gamma"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Tap",
|
||||||
|
"x": 250,
|
||||||
|
"y": 260,
|
||||||
|
"selector": "id:gamma"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Tap",
|
||||||
|
"x": 150,
|
||||||
|
"y": 160,
|
||||||
|
"selector": "id:beta"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Tap",
|
||||||
|
"x": 250,
|
||||||
|
"y": 260,
|
||||||
|
"selector": "id:gamma"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Tap",
|
||||||
|
"x": 50,
|
||||||
|
"y": 60,
|
||||||
|
"selector": "id:alpha"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "InputText",
|
||||||
|
"x": 150,
|
||||||
|
"y": 160,
|
||||||
|
"text": "999999999999999999999",
|
||||||
|
"selector": "id:beta"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "InputText",
|
||||||
|
"x": 150,
|
||||||
|
"y": 160,
|
||||||
|
"text": "-1",
|
||||||
|
"selector": "id:beta"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "InputText",
|
||||||
|
"x": 250,
|
||||||
|
"y": 260,
|
||||||
|
"text": "-1",
|
||||||
|
"selector": "id:gamma"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Tap",
|
||||||
|
"x": 50,
|
||||||
|
"y": 60,
|
||||||
|
"selector": "id:alpha"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Tap",
|
||||||
|
"x": 250,
|
||||||
|
"y": 260,
|
||||||
|
"selector": "id:gamma"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Tap",
|
||||||
|
"x": 50,
|
||||||
|
"y": 60,
|
||||||
|
"selector": "id:alpha"
|
||||||
|
}
|
||||||
|
]
|
||||||
+1512
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,52 @@
|
|||||||
|
// Shared cross-runtime parity scenario: the FIXED seed, FIXED candidate list,
|
||||||
|
// and FIXED action root that both the node test (parity.test.ts) and the goja
|
||||||
|
// test (internal/verifier/parity_test.go) drive. Each side runs the SAME
|
||||||
|
// pick.ts over the SAME Pcg and asserts the SAME committed golden
|
||||||
|
// (fixtures/parity-golden.json); matching one golden on both sides proves the
|
||||||
|
// two engines agree without either invoking the other.
|
||||||
|
//
|
||||||
|
// The candidate ORDER and the per-tick PCG draw order are the parity contract.
|
||||||
|
// The weighted root mixes a tap branch (1 candidate draw) with a typing branch
|
||||||
|
// (1 candidate draw + 1 corpus draw), so a tick exercises weighted selection, a
|
||||||
|
// builtin, and the input corpus together; reordering candidates or adding or
|
||||||
|
// dropping a draw on either side shifts the stream and fails the golden.
|
||||||
|
|
||||||
|
import { Pcg } from "../src/pcg.ts";
|
||||||
|
import { nextAction } from "../src/pick.ts";
|
||||||
|
import { serializeAction, type SerializedAction } from "../src/runtime-entry.ts";
|
||||||
|
import { taps, typing, weighted } from "../src/actions.ts";
|
||||||
|
import type { BuiltinVerb, Candidate, GeneratorNode, Host } from "../src/action-tree.ts";
|
||||||
|
|
||||||
|
export const PARITY_SEED_HI = 0x9e3779b97f4a7c15n;
|
||||||
|
export const PARITY_STEPS = 20;
|
||||||
|
|
||||||
|
export const PARITY_CANDIDATES: Candidate[] = [
|
||||||
|
{ x: 50, y: 60, selector: "id:alpha", width: 100, height: 40 },
|
||||||
|
{ x: 150, y: 160, selector: "id:beta", width: 120, height: 48 },
|
||||||
|
{ x: 250, y: 260, selector: "id:gamma", width: 80, height: 32 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// A 3:1 weighted split over taps and typing. The stub host returns the same
|
||||||
|
// candidate list for every verb so the only variables are the draw order and
|
||||||
|
// the JS engine's number/bigint behavior.
|
||||||
|
export const PARITY_ROOT: GeneratorNode = weighted([3, taps], [1, typing]);
|
||||||
|
|
||||||
|
const HOST: Host = {
|
||||||
|
platform: () => "android",
|
||||||
|
queryCandidates: (_verb: BuiltinVerb) => PARITY_CANDIDATES,
|
||||||
|
reportUnsupported: () => {},
|
||||||
|
seedHi: () => PARITY_SEED_HI,
|
||||||
|
seedLo: () => 0n,
|
||||||
|
};
|
||||||
|
|
||||||
|
// runParity emits the parity scenario's action stream from a fresh Pcg. It
|
||||||
|
// drives pick.ts directly (not installRuntime, whose globals are locked once)
|
||||||
|
// so the caller can run it repeatedly to assert determinism.
|
||||||
|
export function runParity(): (SerializedAction | null)[] {
|
||||||
|
const rng = new Pcg(PARITY_SEED_HI, 0n);
|
||||||
|
const stream: (SerializedAction | null)[] = [];
|
||||||
|
for (let i = 0; i < PARITY_STEPS; i++) {
|
||||||
|
stream.push(serializeAction(nextAction(PARITY_ROOT, rng, HOST)));
|
||||||
|
}
|
||||||
|
return stream;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { test } from "node:test";
|
||||||
|
|
||||||
|
import { PARITY_STEPS, runParity } from "./parity-harness.ts";
|
||||||
|
|
||||||
|
const goldenPath = fileURLToPath(new URL("./fixtures/parity-golden.json", import.meta.url));
|
||||||
|
const golden = JSON.parse(readFileSync(goldenPath, "utf8"));
|
||||||
|
|
||||||
|
// The node side of the W2 acceptance gate: the shared picker's stream for the
|
||||||
|
// fixed parity scenario must match the committed golden byte-for-byte. The goja
|
||||||
|
// test asserts the SAME golden, so a match on both sides proves cross-runtime
|
||||||
|
// parity. A drift here means pick.ts, the corpus, or the Pcg changed.
|
||||||
|
test("node picker matches the cross-runtime golden", () => {
|
||||||
|
const stream = runParity();
|
||||||
|
assert.equal(stream.length, PARITY_STEPS);
|
||||||
|
assert.deepEqual(stream, golden);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Determinism: a second run from the same seed yields the identical stream.
|
||||||
|
test("node picker is deterministic across repeated runs", () => {
|
||||||
|
assert.deepEqual(runParity(), runParity());
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { Pcg } from "../src/pcg.ts";
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const fixturePath = join(here, "fixtures", "pcg-golden.json");
|
||||||
|
|
||||||
|
interface GoldenCase {
|
||||||
|
seed: { hi: string; lo: string };
|
||||||
|
uint64: string[];
|
||||||
|
float64: number[];
|
||||||
|
intN: Record<string, number[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cases: GoldenCase[] = JSON.parse(readFileSync(fixturePath, "utf8"));
|
||||||
|
|
||||||
|
function seedLabel(c: GoldenCase): string {
|
||||||
|
return `seed(${c.seed.hi}, ${c.seed.lo})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("golden fixture has the expected shape", () => {
|
||||||
|
assert.ok(cases.length >= 5, "expected several seed cases");
|
||||||
|
for (const c of cases) {
|
||||||
|
assert.equal(c.uint64.length, 40);
|
||||||
|
assert.equal(c.float64.length, 40);
|
||||||
|
for (const draws of Object.values(c.intN)) {
|
||||||
|
assert.equal(draws.length, 40);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const c of cases) {
|
||||||
|
test(`${seedLabel(c)} uint64 sequence is bit-exact`, () => {
|
||||||
|
const pcg = new Pcg(BigInt(c.seed.hi), BigInt(c.seed.lo));
|
||||||
|
c.uint64.forEach((want, i) => {
|
||||||
|
assert.equal(pcg.uint64(), BigInt(want), `uint64 draw ${i}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${seedLabel(c)} float64 sequence is bit-exact`, () => {
|
||||||
|
const pcg = new Pcg(BigInt(c.seed.hi), BigInt(c.seed.lo));
|
||||||
|
c.float64.forEach((want, i) => {
|
||||||
|
assert.equal(pcg.float64(), want, `float64 draw ${i}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [n, expected] of Object.entries(c.intN)) {
|
||||||
|
test(`${seedLabel(c)} intN(${n}) sequence is bit-exact`, () => {
|
||||||
|
const pcg = new Pcg(BigInt(c.seed.hi), BigInt(c.seed.lo));
|
||||||
|
expected.forEach((want, i) => {
|
||||||
|
assert.equal(pcg.intN(Number(n)), want, `intN(${n}) draw ${i}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { Pcg } from "../src/pcg.ts";
|
||||||
|
import { nextAction, walk } from "../src/pick.ts";
|
||||||
|
import { INPUT_CORPUS, NATIVE_PRESS_KEYS, WEB_PRESS_KEYS } from "../src/corpus.ts";
|
||||||
|
import { resetWarnings } from "../src/verbs.ts";
|
||||||
|
import type {
|
||||||
|
ActionDescriptor,
|
||||||
|
BuiltinVerb,
|
||||||
|
Candidate,
|
||||||
|
GeneratorNode,
|
||||||
|
Host,
|
||||||
|
} from "../src/action-tree.ts";
|
||||||
|
|
||||||
|
type Platform = "android" | "ios" | "web";
|
||||||
|
|
||||||
|
// stubHost returns a fixed candidate list for every verb and records each
|
||||||
|
// reportUnsupported call so warn-once semantics are observable.
|
||||||
|
function stubHost(
|
||||||
|
platform: Platform,
|
||||||
|
candidates: Candidate[],
|
||||||
|
): Host & { unsupported: BuiltinVerb[] } {
|
||||||
|
const unsupported: BuiltinVerb[] = [];
|
||||||
|
return {
|
||||||
|
unsupported,
|
||||||
|
platform: () => platform,
|
||||||
|
queryCandidates: () => candidates,
|
||||||
|
reportUnsupported: (verb) => {
|
||||||
|
unsupported.push(verb);
|
||||||
|
},
|
||||||
|
seedHi: () => 0n,
|
||||||
|
seedLo: () => 0n,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const POINTS: Candidate[] = [
|
||||||
|
{ x: 10, y: 20, selector: "id:a", width: 100, height: 200 },
|
||||||
|
{ x: 30, y: 40, selector: "id:b", width: 100, height: 200 },
|
||||||
|
{ x: 50, y: 60, selector: "id:c", width: 100, height: 200 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function builtin(verb: BuiltinVerb): GeneratorNode {
|
||||||
|
return { kind: "builtin", verb };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("taps draws one candidate index and targets its point", () => {
|
||||||
|
resetWarnings();
|
||||||
|
const host = stubHost("android", POINTS);
|
||||||
|
const rng = new Pcg(42n, 0n);
|
||||||
|
const oracle = new Pcg(42n, 0n);
|
||||||
|
const expectedIndex = oracle.intN(POINTS.length);
|
||||||
|
|
||||||
|
const action = walk(builtin("taps"), rng, host);
|
||||||
|
assert.deepEqual(action, {
|
||||||
|
kind: "Tap",
|
||||||
|
on: {
|
||||||
|
x: POINTS[expectedIndex]!.x,
|
||||||
|
y: POINTS[expectedIndex]!.y,
|
||||||
|
selector: POINTS[expectedIndex]!.selector,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("typing draws candidate index then corpus index, in that order", () => {
|
||||||
|
resetWarnings();
|
||||||
|
const host = stubHost("android", POINTS);
|
||||||
|
const rng = new Pcg(42n, 0n);
|
||||||
|
const oracle = new Pcg(42n, 0n);
|
||||||
|
const candidateIndex = oracle.intN(POINTS.length);
|
||||||
|
const corpusIndex = oracle.intN(INPUT_CORPUS.length);
|
||||||
|
|
||||||
|
const action = walk(builtin("typing"), rng, host) as ActionDescriptor & {
|
||||||
|
kind: "InputText";
|
||||||
|
};
|
||||||
|
assert.equal(action.kind, "InputText");
|
||||||
|
assert.deepEqual(action.into, {
|
||||||
|
x: POINTS[candidateIndex]!.x,
|
||||||
|
y: POINTS[candidateIndex]!.y,
|
||||||
|
selector: POINTS[candidateIndex]!.selector,
|
||||||
|
});
|
||||||
|
assert.equal(action.text, INPUT_CORPUS[corpusIndex]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("swipes draw candidate, magnitude (200+intN(401)), then direction", () => {
|
||||||
|
resetWarnings();
|
||||||
|
const host = stubHost("android", POINTS);
|
||||||
|
const rng = new Pcg(7n, 0n);
|
||||||
|
const oracle = new Pcg(7n, 0n);
|
||||||
|
const candidateIndex = oracle.intN(POINTS.length);
|
||||||
|
const magnitude = 200 + oracle.intN(401);
|
||||||
|
const direction = oracle.intN(4);
|
||||||
|
|
||||||
|
const from = { x: POINTS[candidateIndex]!.x, y: POINTS[candidateIndex]!.y };
|
||||||
|
const expectedTo = { x: from.x, y: from.y };
|
||||||
|
switch (direction) {
|
||||||
|
case 0:
|
||||||
|
expectedTo.y = Math.max(0, from.y - magnitude);
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
expectedTo.y = Math.max(0, from.y + magnitude);
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
expectedTo.x = Math.max(0, from.x - magnitude);
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
expectedTo.x = Math.max(0, from.x + magnitude);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = walk(builtin("swipes"), rng, host) as ActionDescriptor & {
|
||||||
|
kind: "Swipe";
|
||||||
|
};
|
||||||
|
assert.equal(action.kind, "Swipe");
|
||||||
|
assert.deepEqual(action.from, from);
|
||||||
|
assert.deepEqual(action.to, expectedTo);
|
||||||
|
assert.equal(action.durationMillis, 250);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("scrolls draw candidate index then direction", () => {
|
||||||
|
resetWarnings();
|
||||||
|
const host = stubHost("android", POINTS);
|
||||||
|
const rng = new Pcg(99n, 0n);
|
||||||
|
const oracle = new Pcg(99n, 0n);
|
||||||
|
const candidateIndex = oracle.intN(POINTS.length);
|
||||||
|
const directionIndex = oracle.intN(4);
|
||||||
|
const directions = ["up", "down", "left", "right"] as const;
|
||||||
|
|
||||||
|
const action = walk(builtin("scrolls"), rng, host) as ActionDescriptor & {
|
||||||
|
kind: "Scroll";
|
||||||
|
};
|
||||||
|
assert.equal(action.kind, "Scroll");
|
||||||
|
assert.equal(action.direction, directions[directionIndex]);
|
||||||
|
assert.deepEqual(action.in, {
|
||||||
|
x: POINTS[candidateIndex]!.x,
|
||||||
|
y: POINTS[candidateIndex]!.y,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("doubleTaps and longPresses draw exactly one candidate index", () => {
|
||||||
|
resetWarnings();
|
||||||
|
for (const verb of ["doubleTaps", "longPresses"] as const) {
|
||||||
|
const host = stubHost("android", POINTS);
|
||||||
|
const rng = new Pcg(123n, 0n);
|
||||||
|
const oracle = new Pcg(123n, 0n);
|
||||||
|
const index = oracle.intN(POINTS.length);
|
||||||
|
const action = walk(builtin(verb), rng, host);
|
||||||
|
const kind = verb === "doubleTaps" ? "DoubleTap" : "LongPress";
|
||||||
|
assert.deepEqual(action, {
|
||||||
|
kind,
|
||||||
|
on: {
|
||||||
|
x: POINTS[index]!.x,
|
||||||
|
y: POINTS[index]!.y,
|
||||||
|
selector: POINTS[index]!.selector,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("waitOnce emits a 500ms wait and draws nothing", () => {
|
||||||
|
resetWarnings();
|
||||||
|
const host = stubHost("android", POINTS);
|
||||||
|
const rng = new Pcg(42n, 0n);
|
||||||
|
const before = rng.float64();
|
||||||
|
const after = rng.float64();
|
||||||
|
|
||||||
|
const fresh = new Pcg(42n, 0n);
|
||||||
|
fresh.float64();
|
||||||
|
const action = walk(builtin("waitOnce"), fresh, host);
|
||||||
|
const afterWait = fresh.float64();
|
||||||
|
|
||||||
|
assert.deepEqual(action, { kind: "Wait", durationMillis: 500 });
|
||||||
|
// The wait consumed no draw, so the stream is identical with or without it.
|
||||||
|
assert.equal(afterWait, after);
|
||||||
|
assert.notEqual(before, after);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pressKeys on native draws from NATIVE_PRESS_KEYS", () => {
|
||||||
|
resetWarnings();
|
||||||
|
const host = stubHost("android", POINTS);
|
||||||
|
const rng = new Pcg(42n, 0n);
|
||||||
|
const oracle = new Pcg(42n, 0n);
|
||||||
|
const index = oracle.intN(NATIVE_PRESS_KEYS.length);
|
||||||
|
const action = walk(builtin("pressKeys"), rng, host);
|
||||||
|
assert.deepEqual(action, { kind: "PressKey", key: NATIVE_PRESS_KEYS[index] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pressKeys on web draws from WEB_PRESS_KEYS", () => {
|
||||||
|
resetWarnings();
|
||||||
|
const host = stubHost("web", POINTS);
|
||||||
|
const rng = new Pcg(2024n, 0n);
|
||||||
|
const oracle = new Pcg(2024n, 0n);
|
||||||
|
const index = oracle.intN(WEB_PRESS_KEYS.length);
|
||||||
|
const action = walk(builtin("pressKeys"), rng, host);
|
||||||
|
assert.deepEqual(action, { kind: "PressKey", key: WEB_PRESS_KEYS[index] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty candidate list yields null without drawing", () => {
|
||||||
|
resetWarnings();
|
||||||
|
const host = stubHost("android", []);
|
||||||
|
const rng = new Pcg(42n, 0n);
|
||||||
|
assert.equal(walk(builtin("taps"), rng, host), null);
|
||||||
|
// No draw consumed: next float64 matches a fresh stream's first draw.
|
||||||
|
assert.equal(rng.float64(), new Pcg(42n, 0n).float64());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("weighted: one float64 draw, ascending cumulative scan", () => {
|
||||||
|
resetWarnings();
|
||||||
|
const host = stubHost("android", POINTS);
|
||||||
|
const tap: GeneratorNode = builtin("taps");
|
||||||
|
const wait: GeneratorNode = builtin("waitOnce");
|
||||||
|
// total = 3; draw = float64()*3 decides the branch by ascending cumulative.
|
||||||
|
const node: GeneratorNode = {
|
||||||
|
kind: "weighted",
|
||||||
|
branches: [
|
||||||
|
[1, tap],
|
||||||
|
[2, wait],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const rng = new Pcg(42n, 0n);
|
||||||
|
const oracle = new Pcg(42n, 0n);
|
||||||
|
const draw = oracle.float64() * 3;
|
||||||
|
const expectFirst = draw < 1; // cumulative after branch 0 is 1.
|
||||||
|
|
||||||
|
const action = walk(node, rng, host);
|
||||||
|
if (expectFirst) {
|
||||||
|
assert.equal((action as ActionDescriptor).kind, "Tap");
|
||||||
|
} else {
|
||||||
|
assert.deepEqual(action, { kind: "Wait", durationMillis: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("weighted: a zero-weight branch is never selected", () => {
|
||||||
|
resetWarnings();
|
||||||
|
const host = stubHost("android", POINTS);
|
||||||
|
const poison: GeneratorNode = {
|
||||||
|
kind: "actions",
|
||||||
|
generate: () => {
|
||||||
|
throw new Error("zero-weight branch must never be walked");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const node: GeneratorNode = {
|
||||||
|
kind: "weighted",
|
||||||
|
branches: [
|
||||||
|
[0, poison],
|
||||||
|
[1, builtin("waitOnce")],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
// Drive many seeds: the zero-weight branch must never be entered.
|
||||||
|
for (let seed = 1; seed <= 200; seed++) {
|
||||||
|
const action = walk(node, new Pcg(BigInt(seed), 0n), host);
|
||||||
|
assert.deepEqual(action, { kind: "Wait", durationMillis: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("weighted: all-zero (or negative) weights return null", () => {
|
||||||
|
resetWarnings();
|
||||||
|
const host = stubHost("android", POINTS);
|
||||||
|
const node: GeneratorNode = {
|
||||||
|
kind: "weighted",
|
||||||
|
branches: [
|
||||||
|
[0, builtin("waitOnce")],
|
||||||
|
[-5, builtin("taps")],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert.equal(walk(node, new Pcg(42n, 0n), host), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("actions: single-element list draws nothing", () => {
|
||||||
|
resetWarnings();
|
||||||
|
const only: ActionDescriptor = { kind: "Wait", durationMillis: 10 };
|
||||||
|
const node: GeneratorNode = { kind: "actions", generate: () => [only] };
|
||||||
|
const rng = new Pcg(42n, 0n);
|
||||||
|
const action = walk(node, rng, stubHost("android", POINTS));
|
||||||
|
assert.deepEqual(action, only);
|
||||||
|
// No draw consumed for a singleton list.
|
||||||
|
assert.equal(rng.float64(), new Pcg(42n, 0n).float64());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("actions: multi-element list draws one intN(len) index", () => {
|
||||||
|
resetWarnings();
|
||||||
|
const list: ActionDescriptor[] = [
|
||||||
|
{ kind: "Wait", durationMillis: 1 },
|
||||||
|
{ kind: "Wait", durationMillis: 2 },
|
||||||
|
{ kind: "Wait", durationMillis: 3 },
|
||||||
|
];
|
||||||
|
const node: GeneratorNode = { kind: "actions", generate: () => [...list] };
|
||||||
|
const rng = new Pcg(42n, 0n);
|
||||||
|
const oracle = new Pcg(42n, 0n);
|
||||||
|
const index = oracle.intN(list.length);
|
||||||
|
assert.deepEqual(walk(node, rng, stubHost("android", POINTS)), list[index]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("actions: empty list yields null", () => {
|
||||||
|
resetWarnings();
|
||||||
|
const node: GeneratorNode = { kind: "actions", generate: () => [] };
|
||||||
|
assert.equal(walk(node, new Pcg(42n, 0n), stubHost("android", POINTS)), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("corpus reproducibility: same seed yields the same typed text", () => {
|
||||||
|
resetWarnings();
|
||||||
|
const host = stubHost("android", POINTS);
|
||||||
|
const first = walk(builtin("typing"), new Pcg(2024n, 0n), host) as ActionDescriptor & {
|
||||||
|
kind: "InputText";
|
||||||
|
};
|
||||||
|
const second = walk(builtin("typing"), new Pcg(2024n, 0n), host) as ActionDescriptor & {
|
||||||
|
kind: "InputText";
|
||||||
|
};
|
||||||
|
assert.equal(first.text, second.text);
|
||||||
|
assert.ok(INPUT_CORPUS.includes(first.text));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nextAction retries up to 16 times then returns null", () => {
|
||||||
|
resetWarnings();
|
||||||
|
// An empty weighted tree always returns null; nextAction must give up, not loop.
|
||||||
|
const empty: GeneratorNode = { kind: "weighted", branches: [] };
|
||||||
|
assert.equal(nextAction(empty, new Pcg(42n, 0n), stubHost("android", POINTS)), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nextAction returns the first non-null walk result", () => {
|
||||||
|
resetWarnings();
|
||||||
|
let calls = 0;
|
||||||
|
// First two walks yield null (empty list), third yields an action. nextAction
|
||||||
|
// should return on the third attempt.
|
||||||
|
const node: GeneratorNode = {
|
||||||
|
kind: "actions",
|
||||||
|
generate: () => {
|
||||||
|
calls++;
|
||||||
|
return calls >= 3 ? [{ kind: "Wait", durationMillis: 5 }] : [];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const action = nextAction(node, new Pcg(42n, 0n), stubHost("android", POINTS));
|
||||||
|
assert.deepEqual(action, { kind: "Wait", durationMillis: 5 });
|
||||||
|
assert.equal(calls, 3);
|
||||||
|
});
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
|
||||||
|
import { serializeAction } from "../src/runtime-entry.ts";
|
||||||
|
import type { ActionDescriptor } from "../src/action-tree.ts";
|
||||||
|
|
||||||
|
// decodeWire mirrors the Go decodeAction reading the unified flat contract: a
|
||||||
|
// field rename (fromX vs from_x) would make these assertions fail here, the
|
||||||
|
// same way it would silently no-op web actions in production.
|
||||||
|
const POINT = { x: 12, y: 34 };
|
||||||
|
|
||||||
|
test("Tap serializes to the flat point contract", () => {
|
||||||
|
assert.deepEqual(serializeAction({ kind: "Tap", on: POINT }), {
|
||||||
|
kind: "Tap",
|
||||||
|
x: 12,
|
||||||
|
y: 34,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Tap carries a resolved selector when present", () => {
|
||||||
|
const target = { x: 1, y: 2, selector: "id:save" };
|
||||||
|
assert.deepEqual(serializeAction({ kind: "Tap", on: target }), {
|
||||||
|
kind: "Tap",
|
||||||
|
x: 1,
|
||||||
|
y: 2,
|
||||||
|
selector: "id:save",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("DoubleTap and LongPress share the point contract", () => {
|
||||||
|
assert.deepEqual(serializeAction({ kind: "DoubleTap", on: POINT }), {
|
||||||
|
kind: "DoubleTap",
|
||||||
|
x: 12,
|
||||||
|
y: 34,
|
||||||
|
});
|
||||||
|
assert.deepEqual(serializeAction({ kind: "LongPress", on: POINT }), {
|
||||||
|
kind: "LongPress",
|
||||||
|
x: 12,
|
||||||
|
y: 34,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("InputText carries x, y and text", () => {
|
||||||
|
assert.deepEqual(serializeAction({ kind: "InputText", into: POINT, text: "hi" }), {
|
||||||
|
kind: "InputText",
|
||||||
|
x: 12,
|
||||||
|
y: 34,
|
||||||
|
text: "hi",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Swipe emits camelCase fromX/fromY/toX/toY/durationMillis", () => {
|
||||||
|
const action: ActionDescriptor = {
|
||||||
|
kind: "Swipe",
|
||||||
|
from: { x: 5, y: 6 },
|
||||||
|
to: { x: 7, y: 8 },
|
||||||
|
durationMillis: 300,
|
||||||
|
};
|
||||||
|
assert.deepEqual(serializeAction(action), {
|
||||||
|
kind: "Swipe",
|
||||||
|
fromX: 5,
|
||||||
|
fromY: 6,
|
||||||
|
toX: 7,
|
||||||
|
toY: 8,
|
||||||
|
durationMillis: 300,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Swipe defaults durationMillis to 250", () => {
|
||||||
|
const action = serializeAction({
|
||||||
|
kind: "Swipe",
|
||||||
|
from: { x: 0, y: 0 },
|
||||||
|
to: { x: 0, y: 1 },
|
||||||
|
}) as { durationMillis: number };
|
||||||
|
assert.equal(action.durationMillis, 250);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Scroll emits direction plus from/to point and duration", () => {
|
||||||
|
assert.deepEqual(serializeAction({ kind: "Scroll", direction: "down", in: POINT }), {
|
||||||
|
kind: "Scroll",
|
||||||
|
direction: "down",
|
||||||
|
fromX: 12,
|
||||||
|
fromY: 34,
|
||||||
|
toX: 12,
|
||||||
|
toY: 34,
|
||||||
|
durationMillis: 250,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("PressKey and Wait pass through their fields", () => {
|
||||||
|
assert.deepEqual(serializeAction({ kind: "PressKey", key: "enter" }), {
|
||||||
|
kind: "PressKey",
|
||||||
|
key: "enter",
|
||||||
|
});
|
||||||
|
assert.deepEqual(serializeAction({ kind: "Wait", durationMillis: 500 }), {
|
||||||
|
kind: "Wait",
|
||||||
|
durationMillis: 500,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("builtin Scroll carries the pre-computed to endpoint", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
serializeAction({
|
||||||
|
kind: "Scroll",
|
||||||
|
direction: "down",
|
||||||
|
in: { x: 100, y: 200 },
|
||||||
|
from: { x: 100, y: 200 },
|
||||||
|
to: { x: 100, y: 120 },
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
kind: "Scroll",
|
||||||
|
direction: "down",
|
||||||
|
fromX: 100,
|
||||||
|
fromY: 200,
|
||||||
|
toX: 100,
|
||||||
|
toY: 120,
|
||||||
|
durationMillis: 250,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a string target serializes selector-only for the runner to re-resolve", () => {
|
||||||
|
assert.deepEqual(serializeAction({ kind: "Tap", on: "id:save" }), {
|
||||||
|
kind: "Tap",
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
selector: "id:save",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an empty target or null action is dropped", () => {
|
||||||
|
assert.equal(serializeAction({ kind: "Tap", on: "" }), null);
|
||||||
|
assert.equal(serializeAction(null), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wire round-trip: serialize -> JSON -> parse asserts every field the Go
|
||||||
|
// decodeAction reads survives by its exact camelCase name. A rename (fromX vs
|
||||||
|
// from_x) would silently turn web/native actions into no-ops; this catches it.
|
||||||
|
test("serialized actions JSON round-trip with the decoder's field names", () => {
|
||||||
|
const cases: ActionDescriptor[] = [
|
||||||
|
{ kind: "Tap", on: { x: 1, y: 2, selector: "id:a" } as never },
|
||||||
|
{ kind: "InputText", into: { x: 3, y: 4 } as never, text: "x" },
|
||||||
|
{ kind: "Swipe", from: { x: 5, y: 6 }, to: { x: 7, y: 8 }, durationMillis: 9 },
|
||||||
|
{ kind: "Scroll", direction: "up", in: { x: 1, y: 1 } as never },
|
||||||
|
{ kind: "PressKey", key: "enter" },
|
||||||
|
{ kind: "Wait", durationMillis: 10 },
|
||||||
|
];
|
||||||
|
for (const descriptor of cases) {
|
||||||
|
const wire = serializeAction(descriptor);
|
||||||
|
assert.ok(wire, `expected ${descriptor.kind} to serialize`);
|
||||||
|
const decoded = JSON.parse(JSON.stringify(wire)) as Record<string, unknown>;
|
||||||
|
assert.equal(decoded.kind, wire!.kind);
|
||||||
|
switch (wire!.kind) {
|
||||||
|
case "Tap":
|
||||||
|
assert.equal(typeof decoded.x, "number");
|
||||||
|
assert.equal(typeof decoded.y, "number");
|
||||||
|
assert.equal(decoded.selector, "id:a");
|
||||||
|
break;
|
||||||
|
case "InputText":
|
||||||
|
assert.equal(typeof decoded.x, "number");
|
||||||
|
assert.equal(decoded.text, "x");
|
||||||
|
break;
|
||||||
|
case "Swipe":
|
||||||
|
assert.equal(decoded.fromX, 5);
|
||||||
|
assert.equal(decoded.toY, 8);
|
||||||
|
assert.equal(decoded.durationMillis, 9);
|
||||||
|
break;
|
||||||
|
case "Scroll":
|
||||||
|
assert.equal(decoded.direction, "up");
|
||||||
|
assert.equal(typeof decoded.fromX, "number");
|
||||||
|
assert.equal(typeof decoded.toX, "number");
|
||||||
|
break;
|
||||||
|
case "PressKey":
|
||||||
|
assert.equal(decoded.key, "enter");
|
||||||
|
break;
|
||||||
|
case "Wait":
|
||||||
|
assert.equal(decoded.durationMillis, 10);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { Pcg } from "../src/pcg.ts";
|
||||||
|
import { setSamplerRng } from "../src/sampler-rng.ts";
|
||||||
|
import { edgeCaseText, emails, integers, strings } from "../src/values.ts";
|
||||||
|
import { INPUT_CORPUS } from "../src/corpus.ts";
|
||||||
|
import type { Sampler } from "../src/types.ts";
|
||||||
|
|
||||||
|
function withRng<T>(seed: bigint, body: () => T): T {
|
||||||
|
setSamplerRng(new Pcg(seed, 0n));
|
||||||
|
try {
|
||||||
|
return body();
|
||||||
|
} finally {
|
||||||
|
setSamplerRng(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("same seed yields an identical draw sequence", () => {
|
||||||
|
const drawAll = () => [
|
||||||
|
strings().generate(),
|
||||||
|
integers().between(0, 999).generate(),
|
||||||
|
emails().domain("folio.app").generate(),
|
||||||
|
edgeCaseText().generate(),
|
||||||
|
];
|
||||||
|
assert.deepEqual(withRng(7n, drawAll), withRng(7n, drawAll));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a different seed yields a different sequence", () => {
|
||||||
|
const drawAll = () => [strings().generate(), integers().generate()];
|
||||||
|
assert.notDeepEqual(withRng(1n, drawAll), withRng(2n, drawAll));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("integers().between constrains the output range", () => {
|
||||||
|
withRng(42n, () => {
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
const value = integers().between(5, 9).generate();
|
||||||
|
assert.ok(Number.isInteger(value));
|
||||||
|
assert.ok(value >= 5 && value <= 9, `out of range: ${value}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("strings().length(min, max).alpha() constrains length and charset", () => {
|
||||||
|
withRng(99n, () => {
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
const value = strings().length(3, 6).alpha().generate();
|
||||||
|
assert.ok(value.length >= 3 && value.length <= 6, `bad length: ${value}`);
|
||||||
|
assert.match(value, /^[A-Za-z]*$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("emails().domain sets the host and edgeCaseText draws from the corpus", () => {
|
||||||
|
withRng(3n, () => {
|
||||||
|
const email = emails().domain("folio.app").generate();
|
||||||
|
assert.match(email, /^[A-Za-z]+@folio\.app$/);
|
||||||
|
assert.ok(INPUT_CORPUS.includes(edgeCaseText().generate()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a builder is itself a Sampler<T>", () => {
|
||||||
|
withRng(11n, () => {
|
||||||
|
const sampler: Sampler<number> = integers().between(0, 9);
|
||||||
|
const value = sampler.generate();
|
||||||
|
assert.ok(value >= 0 && value <= 9);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("outside a walk generators return a fixed deterministic default", () => {
|
||||||
|
setSamplerRng(null);
|
||||||
|
assert.equal(integers().between(10, 20).generate(), 10);
|
||||||
|
assert.equal(strings().length(4, 8).alpha().generate(), "aaaa");
|
||||||
|
assert.equal(emails().domain("folio.app").generate(), "[email protected]");
|
||||||
|
assert.equal(edgeCaseText().generate(), INPUT_CORPUS[0]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { supports, warnUnsupportedOnce, resetWarnings } from "../src/verbs.ts";
|
||||||
|
import type { BuiltinVerb, Host } from "../src/action-tree.ts";
|
||||||
|
|
||||||
|
const ALL_VERBS: BuiltinVerb[] = [
|
||||||
|
"taps",
|
||||||
|
"doubleTaps",
|
||||||
|
"longPresses",
|
||||||
|
"scrolls",
|
||||||
|
"typing",
|
||||||
|
"swipes",
|
||||||
|
"waitOnce",
|
||||||
|
"pressKeys",
|
||||||
|
];
|
||||||
|
|
||||||
|
function countingHost(platform: "android" | "ios" | "web"): Host & { calls: BuiltinVerb[] } {
|
||||||
|
const calls: BuiltinVerb[] = [];
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
platform: () => platform,
|
||||||
|
queryCandidates: () => [],
|
||||||
|
reportUnsupported: (verb) => {
|
||||||
|
calls.push(verb);
|
||||||
|
},
|
||||||
|
seedHi: () => 0n,
|
||||||
|
seedLo: () => 0n,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("every verb is supported on native platforms", () => {
|
||||||
|
for (const verb of ALL_VERBS) {
|
||||||
|
assert.equal(supports(verb, "android"), true, `android ${verb}`);
|
||||||
|
assert.equal(supports(verb, "ios"), true, `ios ${verb}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("web supports the full builtin verb set (declared, not silently no-op)", () => {
|
||||||
|
for (const verb of ALL_VERBS) {
|
||||||
|
assert.equal(supports(verb, "web"), true, `web ${verb}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("warn-once reports a verb exactly once per platform", () => {
|
||||||
|
resetWarnings();
|
||||||
|
const host = countingHost("web");
|
||||||
|
assert.equal(warnUnsupportedOnce(host, "swipes"), true);
|
||||||
|
assert.equal(warnUnsupportedOnce(host, "swipes"), false);
|
||||||
|
assert.equal(warnUnsupportedOnce(host, "swipes"), false);
|
||||||
|
assert.deepEqual(host.calls, ["swipes"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("warn-once keys by verb@platform independently", () => {
|
||||||
|
resetWarnings();
|
||||||
|
const web = countingHost("web");
|
||||||
|
const android = countingHost("android");
|
||||||
|
warnUnsupportedOnce(web, "scrolls");
|
||||||
|
warnUnsupportedOnce(android, "scrolls");
|
||||||
|
warnUnsupportedOnce(web, "scrolls");
|
||||||
|
// Same verb on two platforms: one report each, suppressed on repeat.
|
||||||
|
assert.deepEqual(web.calls, ["scrolls"]);
|
||||||
|
assert.deepEqual(android.calls, ["scrolls"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resetWarnings re-arms reporting", () => {
|
||||||
|
resetWarnings();
|
||||||
|
const host = countingHost("web");
|
||||||
|
warnUnsupportedOnce(host, "longPresses");
|
||||||
|
resetWarnings();
|
||||||
|
warnUnsupportedOnce(host, "longPresses");
|
||||||
|
assert.deepEqual(host.calls, ["longPresses", "longPresses"]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
|
||||||
|
// The web runtime is the WEB Host: it parses the injected seed, reports its
|
||||||
|
// platform, and delegates action generation to the shared picker. These tests
|
||||||
|
// guard the Host surface and the seed-precision contract. DOM candidate
|
||||||
|
// enumeration is exercised against a minimal querySelectorAll stub.
|
||||||
|
|
||||||
|
// A 64-bit seed that loses precision as a JS Number must survive as a BigInt.
|
||||||
|
process.env.SANDERLING_SEED = "9007199254740993";
|
||||||
|
|
||||||
|
const { __testing__ } = await import("../src/web-runtime.ts");
|
||||||
|
const { host } = __testing__;
|
||||||
|
|
||||||
|
test("platform is web", () => {
|
||||||
|
assert.equal(host.platform(), "web");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("seedHi parses the injected 64-bit seed without Number precision loss", () => {
|
||||||
|
assert.equal(host.seedHi(), 9007199254740993n);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("seedLo is 0 to match goja rand.NewPCG(seed, 0)", () => {
|
||||||
|
assert.equal(host.seedLo(), 0n);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reportUnsupported warns once via console.warn", () => {
|
||||||
|
const original = console.warn;
|
||||||
|
const messages: string[] = [];
|
||||||
|
console.warn = (m: string) => messages.push(m);
|
||||||
|
try {
|
||||||
|
host.reportUnsupported("swipes");
|
||||||
|
} finally {
|
||||||
|
console.warn = original;
|
||||||
|
}
|
||||||
|
assert.equal(messages.length, 1);
|
||||||
|
assert.match(messages[0]!, /swipes/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// installRuntime locked the next-action and extractor globals at import.
|
||||||
|
test("installRuntime defined the host-invoked globals", () => {
|
||||||
|
const g = globalThis as Record<string, unknown>;
|
||||||
|
assert.equal(typeof g.__sanderlingNextAction__, "function");
|
||||||
|
assert.equal(typeof g.__sanderlingExtractors__, "function");
|
||||||
|
assert.equal(typeof g.__sanderling__, "object");
|
||||||
|
});
|
||||||
|
|
||||||
|
// A button and a text input, each with a deterministic bounding box, exercise
|
||||||
|
// the per-verb selector routing without a full DOM.
|
||||||
|
function fakeElement(tag: string, rect: { x: number; y: number; w: number; h: number }) {
|
||||||
|
return {
|
||||||
|
tagName: tag.toUpperCase(),
|
||||||
|
disabled: false,
|
||||||
|
isContentEditable: false,
|
||||||
|
type: tag === "input" ? "text" : "",
|
||||||
|
scrollHeight: 0,
|
||||||
|
clientHeight: 0,
|
||||||
|
scrollWidth: 0,
|
||||||
|
clientWidth: 0,
|
||||||
|
getBoundingClientRect: () => ({
|
||||||
|
left: rect.x,
|
||||||
|
top: rect.y,
|
||||||
|
width: rect.w,
|
||||||
|
height: rect.h,
|
||||||
|
right: rect.x + rect.w,
|
||||||
|
bottom: rect.y + rect.h,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function withFakeDocument(map: Record<string, unknown[]>, run: () => void) {
|
||||||
|
const g = globalThis as Record<string, unknown>;
|
||||||
|
const original = g.document;
|
||||||
|
g.document = {
|
||||||
|
querySelectorAll: (selector: string) => map[selector] ?? [],
|
||||||
|
scrollingElement: null,
|
||||||
|
documentElement: null,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
run();
|
||||||
|
} finally {
|
||||||
|
g.document = original;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("queryCandidates routes taps to the tappable selector set", () => {
|
||||||
|
const button = fakeElement("button", { x: 10, y: 20, w: 40, h: 8 });
|
||||||
|
withFakeDocument(
|
||||||
|
{ 'a, button, input, select, textarea, [role="button"], [onclick]': [button] },
|
||||||
|
() => {
|
||||||
|
__testing__.resetCandidateCache();
|
||||||
|
const candidates = host.queryCandidates("taps");
|
||||||
|
assert.equal(candidates.length, 1);
|
||||||
|
assert.deepEqual({ x: candidates[0]!.x, y: candidates[0]!.y }, { x: 30, y: 24 });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("queryCandidates routes typing to editable inputs only", () => {
|
||||||
|
const input = fakeElement("input", { x: 0, y: 0, w: 100, h: 20 });
|
||||||
|
withFakeDocument({ "input, textarea, [contenteditable]": [input] }, () => {
|
||||||
|
__testing__.resetCandidateCache();
|
||||||
|
const candidates = host.queryCandidates("typing");
|
||||||
|
assert.equal(candidates.length, 1);
|
||||||
|
assert.deepEqual({ x: candidates[0]!.x, y: candidates[0]!.y }, { x: 50, y: 10 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("queryCandidates caches within a tick until reset", () => {
|
||||||
|
const first = fakeElement("button", { x: 0, y: 0, w: 10, h: 10 });
|
||||||
|
withFakeDocument(
|
||||||
|
{ 'a, button, input, select, textarea, [role="button"], [onclick]': [first] },
|
||||||
|
() => {
|
||||||
|
__testing__.resetCandidateCache();
|
||||||
|
const a = host.queryCandidates("taps");
|
||||||
|
const b = host.queryCandidates("taps");
|
||||||
|
assert.equal(a, b);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// evaluateExtractors builds State, which references document and window.
|
||||||
|
const emptyDocument = {
|
||||||
|
querySelector: () => null,
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function withState(run: () => void) {
|
||||||
|
const g = globalThis as Record<string, unknown>;
|
||||||
|
const originalDocument = g.document;
|
||||||
|
const originalWindow = g.window;
|
||||||
|
g.document = emptyDocument;
|
||||||
|
g.window = {};
|
||||||
|
try {
|
||||||
|
run();
|
||||||
|
} finally {
|
||||||
|
g.document = originalDocument;
|
||||||
|
g.window = originalWindow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("named() sets the extractor's display name", () => {
|
||||||
|
const handle = __testing__.runtime.extract(() => "home").named("route");
|
||||||
|
const entry = __testing__.extractors.find((e) => e.handle === handle);
|
||||||
|
assert.equal(entry?.name, "route");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reading another extractor's current inside a getter throws", () => {
|
||||||
|
const first = __testing__.runtime.extract(() => 1);
|
||||||
|
let caught: Error | undefined;
|
||||||
|
__testing__.runtime.extract(() => {
|
||||||
|
try {
|
||||||
|
return first.current;
|
||||||
|
} catch (error) {
|
||||||
|
caught = error as Error;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
withState(() => __testing__.evaluateExtractors());
|
||||||
|
assert.match(
|
||||||
|
caught?.message ?? "",
|
||||||
|
/inside another extractor is not allowed/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// An uncaught cross-extractor read must abort evaluateExtractors loudly, exactly
|
||||||
|
// like goja's PushSnapshot. If the getter throw were swallowed, web would
|
||||||
|
// silently yield undefined and the guard would be a no-op for real authors.
|
||||||
|
test("an uncaught cross-extractor read aborts evaluateExtractors", () => {
|
||||||
|
__testing__.extractors.length = 0;
|
||||||
|
const first = __testing__.runtime.extract(() => 1);
|
||||||
|
__testing__.runtime.extract(() => first.previous);
|
||||||
|
let thrown: Error | undefined;
|
||||||
|
withState(() => {
|
||||||
|
try {
|
||||||
|
__testing__.evaluateExtractors();
|
||||||
|
} catch (error) {
|
||||||
|
thrown = error as Error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert.match(
|
||||||
|
thrown?.message ?? "",
|
||||||
|
/inside another extractor is not allowed/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -44,25 +44,3 @@ func TestDriverServiceDescriptor(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMessageFields(t *testing.T) {
|
|
||||||
lr := &LaunchRequest{BundleId: "com.example", ClearState: true}
|
|
||||||
if lr.GetBundleId() != "com.example" || !lr.GetClearState() {
|
|
||||||
t.Fatalf("LaunchRequest field round-trip failed: %+v", lr)
|
|
||||||
}
|
|
||||||
|
|
||||||
tap := &Point{X: 10, Y: 20}
|
|
||||||
if tap.GetX() != 10 || tap.GetY() != 20 {
|
|
||||||
t.Fatalf("Point field round-trip failed: %+v", tap)
|
|
||||||
}
|
|
||||||
|
|
||||||
img := &Image{Png: []byte{1, 2, 3}, Width: 100, Height: 200}
|
|
||||||
if len(img.GetPng()) != 3 || img.GetWidth() != 100 || img.GetHeight() != 200 {
|
|
||||||
t.Fatalf("Image field round-trip failed: %+v", img)
|
|
||||||
}
|
|
||||||
|
|
||||||
hs := &HealthStatus{Ready: true, Version: "0.0.1", Platform: "android"}
|
|
||||||
if !hs.GetReady() || hs.GetVersion() != "0.0.1" || hs.GetPlatform() != "android" {
|
|
||||||
t.Fatalf("HealthStatus field round-trip failed: %+v", hs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
//go:build browser
|
||||||
|
|
||||||
|
// Package browser_test drives small self-contained web fixtures through the real
|
||||||
|
// bundle -> run -> verify pipeline against headless Chrome, proving the web path
|
||||||
|
// surfaces a known violation by property name and leaves a correct page clean.
|
||||||
|
//
|
||||||
|
// The suite is gated behind the `browser` build tag so the default
|
||||||
|
// `go test ./...` (which has no Chrome dependency) never runs it. Invoke it with
|
||||||
|
// `go test -tags browser ./test/browser/...` or `make test-browser`.
|
||||||
|
package browser_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/priyanshujain/sanderling/internal/bundler"
|
||||||
|
"github.com/priyanshujain/sanderling/internal/driver"
|
||||||
|
"github.com/priyanshujain/sanderling/internal/driver/chrome"
|
||||||
|
chromerunner "github.com/priyanshujain/sanderling/internal/runner"
|
||||||
|
"github.com/priyanshujain/sanderling/internal/trace"
|
||||||
|
"github.com/priyanshujain/sanderling/internal/verifier"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
fixtureSeed = 1
|
||||||
|
fixtureMaxSteps = 12
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestBrowserUncaughtExceptionSurfaces drives a page whose button throws an
|
||||||
|
// uncaught Error on the third click and asserts the default noUncaughtExceptions
|
||||||
|
// property fires within the step budget.
|
||||||
|
func TestBrowserUncaughtExceptionSurfaces(t *testing.T) {
|
||||||
|
violations := runFixture(t, "throwing")
|
||||||
|
if !slices.Contains(violations, "noUncaughtExceptions") {
|
||||||
|
t.Fatalf("noUncaughtExceptions did not fire; violations=%v", violations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBrowserCounterInvariantHolds drives a correct counter page whose buttons
|
||||||
|
// move the value by exactly one and asserts the changesByAtMostOne property
|
||||||
|
// never fires.
|
||||||
|
func TestBrowserCounterInvariantHolds(t *testing.T) {
|
||||||
|
violations := runFixture(t, "counter")
|
||||||
|
if slices.Contains(violations, "changesByAtMostOne") {
|
||||||
|
t.Fatalf("changesByAtMostOne fired on a correct page; violations=%v", violations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runFixture serves the named testdata case over an in-process file server,
|
||||||
|
// drives it through headless Chrome with a fixed seed and a bounded step count,
|
||||||
|
// and returns every property name that was ever reported violated.
|
||||||
|
func runFixture(t *testing.T, name string) []string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.FileServer(http.Dir(testdataDir(t))))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
url := server.URL + "/" + name + "/"
|
||||||
|
|
||||||
|
specPath := filepath.Join(testdataDir(t), name, "spec.ts")
|
||||||
|
gojaBundle, webBundle := bundleSpec(t, specPath)
|
||||||
|
|
||||||
|
driverInstance := chrome.New()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = driverInstance.Terminate(ctx)
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := driverInstance.Launch(ctx, url, false, nil); err != nil {
|
||||||
|
t.Fatalf("launch %s: %v", url, err)
|
||||||
|
}
|
||||||
|
web, ok := any(driverInstance).(driver.WebDriver)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("chrome driver does not satisfy WebDriver")
|
||||||
|
}
|
||||||
|
if err := web.InstallBundle(ctx, webBundle); err != nil {
|
||||||
|
t.Fatalf("install web bundle: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
verifierInstance, err := verifier.New(
|
||||||
|
verifier.WithSeed(fixtureSeed),
|
||||||
|
verifier.WithPlatform("web"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("verifier: %v", err)
|
||||||
|
}
|
||||||
|
if err := verifierInstance.Load(string(gojaBundle)); err != nil {
|
||||||
|
t.Fatalf("load spec: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
traceWriter, err := trace.NewWriter(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("trace writer: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = traceWriter.Close() })
|
||||||
|
|
||||||
|
summary, err := chromerunner.Run(ctx, chromerunner.Options{
|
||||||
|
Duration: 90 * time.Second,
|
||||||
|
IdleTimeout: time.Second,
|
||||||
|
MaxSteps: fixtureMaxSteps,
|
||||||
|
Driver: driverInstance,
|
||||||
|
Verifier: verifierInstance,
|
||||||
|
TraceWriter: traceWriter,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("run: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var violated []string
|
||||||
|
for _, record := range summary.Violations {
|
||||||
|
violated = append(violated, record.Properties...)
|
||||||
|
}
|
||||||
|
return violated
|
||||||
|
}
|
||||||
|
|
||||||
|
// bundleSpec compiles the fixture spec for both runtimes: the goja bundle the
|
||||||
|
// host verifier loads and the web bundle injected into the page. Aliases and
|
||||||
|
// runtime-entry paths are resolved relative to the repo's pkg/spec checkout.
|
||||||
|
func bundleSpec(t *testing.T, specPath string) (goja, web []byte) {
|
||||||
|
t.Helper()
|
||||||
|
specSrc := specSrcDir(t)
|
||||||
|
aliases := map[string]string{
|
||||||
|
"@sanderling/spec": filepath.Join(specSrc, "index.ts"),
|
||||||
|
"@sanderling/spec/defaults": filepath.Join(specSrc, "defaults/index.ts"),
|
||||||
|
"@sanderling/spec/defaults/properties": filepath.Join(specSrc, "defaults/properties.ts"),
|
||||||
|
}
|
||||||
|
defines := map[string]string{"SANDERLING_SEED": strconv.Itoa(fixtureSeed)}
|
||||||
|
|
||||||
|
gojaResult, err := bundler.Bundle(bundler.Options{
|
||||||
|
EntryFile: specPath,
|
||||||
|
RuntimeFile: filepath.Join(specSrc, "goja-runtime.ts"),
|
||||||
|
Defines: defines,
|
||||||
|
Aliases: aliases,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bundle goja spec: %v", err)
|
||||||
|
}
|
||||||
|
webResult, err := bundler.BundleWeb(bundler.WebOptions{
|
||||||
|
EntryFile: specPath,
|
||||||
|
WebRuntimeFile: filepath.Join(specSrc, "web-runtime.ts"),
|
||||||
|
Defines: defines,
|
||||||
|
Aliases: aliases,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bundle web spec: %v", err)
|
||||||
|
}
|
||||||
|
return gojaResult.JavaScript, webResult.JavaScript
|
||||||
|
}
|
||||||
|
|
||||||
|
// repoRoot walks up from this test file to the module root (the directory
|
||||||
|
// holding go.mod), so paths resolve regardless of the working directory.
|
||||||
|
func repoRoot(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
_, thisFile, _, ok := runtime.Caller(0)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("cannot resolve caller path")
|
||||||
|
}
|
||||||
|
return filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", ".."))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testdataDir(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
_, thisFile, _, ok := runtime.Caller(0)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("cannot resolve caller path")
|
||||||
|
}
|
||||||
|
return filepath.Join(filepath.Dir(thisFile), "testdata")
|
||||||
|
}
|
||||||
|
|
||||||
|
func specSrcDir(t *testing.T) string {
|
||||||
|
return filepath.Join(repoRoot(t), "pkg", "spec", "src")
|
||||||
|
}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>counter</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<button id="decrement">-</button>
|
||||||
|
<div id="counter">0</div>
|
||||||
|
<button id="increment">+</button>
|
||||||
|
<script>
|
||||||
|
let value = 0;
|
||||||
|
const display = document.getElementById("counter");
|
||||||
|
function render() {
|
||||||
|
display.textContent = String(value);
|
||||||
|
}
|
||||||
|
document.getElementById("increment").addEventListener("click", function () {
|
||||||
|
value += 1;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
document.getElementById("decrement").addEventListener("click", function () {
|
||||||
|
value -= 1;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
import { always, extract, taps } from "@sanderling/spec";
|
||||||
|
|
||||||
|
const counter = extract((s) => {
|
||||||
|
const el = s.ax.find({ id: "counter" });
|
||||||
|
return el ? parseInt(el.text, 10) || 0 : 0;
|
||||||
|
}).named("counter");
|
||||||
|
|
||||||
|
// The + and - buttons each move the counter by exactly 1, so no step ever
|
||||||
|
// changes it by more than 1. A bug that double-applied a click would break this.
|
||||||
|
const changesByAtMostOne = always(() => {
|
||||||
|
const previous = counter.previous;
|
||||||
|
if (previous === undefined) return true;
|
||||||
|
return Math.abs(counter.current - previous) <= 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const properties = { changesByAtMostOne };
|
||||||
|
|
||||||
|
export const actionsRoot = taps;
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>throwing</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<button id="press">press</button>
|
||||||
|
<div id="count">0</div>
|
||||||
|
<script>
|
||||||
|
let presses = 0;
|
||||||
|
document.getElementById("press").addEventListener("click", function () {
|
||||||
|
presses += 1;
|
||||||
|
document.getElementById("count").textContent = String(presses);
|
||||||
|
if (presses >= 3) {
|
||||||
|
throw new Error("pressed too many times");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
import { taps } from "@sanderling/spec";
|
||||||
|
import { noUncaughtExceptions } from "@sanderling/spec/defaults/properties";
|
||||||
|
|
||||||
|
export const properties = { noUncaughtExceptions };
|
||||||
|
|
||||||
|
export const actionsRoot = taps;
|
||||||
Reference in new issue
Block a user