From dd54c24c4ef7d2f8fc2ae11232119e1afbc52361 Mon Sep 17 00:00:00 2001 From: pjay Date: Mon, 27 Apr 2026 01:02:21 +0700 Subject: [PATCH] feat: --clear-data flag + typed attribute selectors (#48) * feat(test): add --clear-data flag to clear app data on launch * test+docs: cover --clear-data flag in CLI parser test and reference * feat(spec): type AttrSelector with known attribute names Replace AttrSelector = Record with KnownAttrSelectors plus a string|boolean index signature, so authors get autocomplete and type-checking on testTag / focused / clickable / etc. while raw driver attributes still type-check via the fallback. Boolean state attributes accept native booleans; goja stringifies them at the marshal boundary. AccessibilityElement.attrs becomes RawAttrs (typed string-valued shape of the same canonical names) so element.attrs.testTag autocompletes. * test(verifier): native boolean selector value matches focused=true * docs+folio: use native boolean for focused selector and document typed attrs --- cmd/sanderling/main.go | 2 ++ cmd/sanderling/main_test.go | 17 ++++++++++ cmd/sanderling/test_run.go | 1 + docs/manual/cli.md | 1 + docs/manual/spec-language.md | 4 ++- examples/folio/sanderling/spec.ts | 2 +- internal/testrun/testrun.go | 3 +- internal/verifier/verifier_test.go | 30 +++++++++++++++++ pkg/spec/src/index.ts | 3 ++ pkg/spec/src/types.ts | 54 ++++++++++++++++++++++++++++-- 10 files changed, 112 insertions(+), 5 deletions(-) diff --git a/cmd/sanderling/main.go b/cmd/sanderling/main.go index aafc828..53b49fa 100644 --- a/cmd/sanderling/main.go +++ b/cmd/sanderling/main.go @@ -23,6 +23,7 @@ type testOptions struct { duration time.Duration seed int64 output string + clearData bool } const topUsage = `sanderling is a property-based UI fuzzer for mobile apps. @@ -51,6 +52,7 @@ func parseTestArgs(args []string, stderr io.Writer) (testOptions, error) { flagSet.DurationVar(&options.duration, "duration", 5*time.Minute, "total test duration") flagSet.Int64Var(&options.seed, "seed", 0, "RNG seed (0 = random)") flagSet.StringVar(&options.output, "output", "./runs", "output directory for traces") + flagSet.BoolVar(&options.clearData, "clear-data", false, "clear app data before launching so the run starts from a fresh install") if err := flagSet.Parse(args); err != nil { return testOptions{}, err } diff --git a/cmd/sanderling/main_test.go b/cmd/sanderling/main_test.go index 470f180..3bcfc82 100644 --- a/cmd/sanderling/main_test.go +++ b/cmd/sanderling/main_test.go @@ -32,6 +32,23 @@ func TestParseTestArgs_Defaults(t *testing.T) { if options.seed != 0 { t.Errorf("seed default: got %d, want 0", options.seed) } + if options.clearData { + t.Errorf("clearData default: got true, want false") + } +} + +func TestParseTestArgs_ClearDataFlag(t *testing.T) { + options, err := parseTestArgs([]string{ + "--spec", "s.ts", + "--bundle-id", "com.example", + "--clear-data", + }, io.Discard) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !options.clearData { + t.Errorf("expected clearData=true, got false") + } } func TestParseTestArgs_AllFlags(t *testing.T) { diff --git a/cmd/sanderling/test_run.go b/cmd/sanderling/test_run.go index a3ffe1c..96d32e1 100644 --- a/cmd/sanderling/test_run.go +++ b/cmd/sanderling/test_run.go @@ -17,5 +17,6 @@ func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer) Duration: options.duration, Seed: options.seed, Output: options.output, + ClearData: options.clearData, }, stdout) } diff --git a/docs/manual/cli.md b/docs/manual/cli.md index c9d1bf0..9d375f6 100644 --- a/docs/manual/cli.md +++ b/docs/manual/cli.md @@ -22,6 +22,7 @@ Run a spec against an app for a fixed duration. | `--duration` | `5m` | Total test duration (`30s`, `5m`, `2h`, `1d`). | | `--seed` | `0` | PRNG seed. `0` uses a random seed and records it in `meta.json`. | | `--output` | `./runs` | Output directory for traces. | +| `--clear-data` | `false` | Clear app data before launching so the run starts from a fresh install. | ## `sanderling inspect [run-or-runs-dir]` diff --git a/docs/manual/spec-language.md b/docs/manual/spec-language.md index e394fef..143fdf2 100644 --- a/docs/manual/spec-language.md +++ b/docs/manual/spec-language.md @@ -64,11 +64,13 @@ Pass an object to apply multiple attribute filters with AND semantics: ```ts s.ax.find({ accessibilityText: "LoginScreen" }) -s.ax.find({ accessibilityText: "account_card", clickable: "true" }) +s.ax.find({ testTag: "AccountCard", clickable: true }) ``` Every key-value pair must match. Substring and boolean rules apply per attribute. +Known attribute names are typed; you get autocomplete on `testTag`, `text`, `content-desc`, the boolean states (`clickable`, `enabled`, `focused`, `checked`, `selected`), and the cross-platform aliases (`identifier`, `accessibilityIdentifier`, `accessibilityText`, `accessibilityLabel`, `label`, `resource-id`, `class`, `elementType`, `package`, `placeholderValue`, `hintText`). Boolean state attributes accept a native `true` / `false`. Other attribute keys still type-check as a string-valued fallback so raw driver attributes remain reachable. + ### Path queries Chains of string selectors separated by ` > ` scope each segment to the subtree of the previous match. Path queries are only supported on the tree root (`ax.find`, `ax.findAll`), not on element-scoped `.find`/`.findAll`. diff --git a/examples/folio/sanderling/spec.ts b/examples/folio/sanderling/spec.ts index d08f056..43314ca 100644 --- a/examples/folio/sanderling/spec.ts +++ b/examples/folio/sanderling/spec.ts @@ -58,7 +58,7 @@ const ledgerRows = extract(s => const ledgerBalance = extract(s => parseDollarCents(s.ax.find({ testTag: "LedgerBalance" })?.text)); -const focusedFieldTag = extract(s => s.ax.find({ focused: "true" })?.id ?? null); +const focusedFieldTag = extract(s => s.ax.find({ focused: true })?.id ?? null); const loginEmailField = extract(s => s.ax.find([{ testTag: "LoginScreen" }, { testTag: "LoginEmail" }])); diff --git a/internal/testrun/testrun.go b/internal/testrun/testrun.go index a960fe5..fb25818 100644 --- a/internal/testrun/testrun.go +++ b/internal/testrun/testrun.go @@ -29,6 +29,7 @@ type Options struct { Duration time.Duration Seed int64 Output string + ClearData bool } // Execute runs the full test pipeline: bundle, launch app, verify properties. @@ -68,7 +69,7 @@ func Execute(ctx context.Context, options Options, stdout io.Writer) error { } defer cleanup() - if err := activeDriver.Launch(ctx, options.BundleID, false, nil); err != nil { + if err := activeDriver.Launch(ctx, options.BundleID, options.ClearData, nil); err != nil { return fmt.Errorf("launch app: %w", err) } diff --git a/internal/verifier/verifier_test.go b/internal/verifier/verifier_test.go index 523349a..47dbb68 100644 --- a/internal/verifier/verifier_test.go +++ b/internal/verifier/verifier_test.go @@ -428,6 +428,36 @@ func TestSelectorPath_ScopedDescent(t *testing.T) { } } +// TestSelector_BooleanValue ensures a native JS boolean in the selector +// (e.g. `find({ focused: true })`) matches the "true"/"false" string +// serialization the hierarchy uses for boolean state attributes. +func TestSelector_BooleanValue(t *testing.T) { + const treeJSON = `{ + "attributes": {"resource-id": "root", "bounds": "[0,0,100,100]"}, + "children": [ + {"attributes": {"testTag": "EmailField", "bounds": "[0,0,100,40]"}, "focused": true, "children": []}, + {"attributes": {"testTag": "PasswordField", "bounds": "[0,40,100,80]"}, "focused": false, "children": []} + ] + }` + verifier := newVerifier(t) + mustLoad(t, verifier, ` + globalThis.focusedTag = __sanderling__.extract(state => + state.ax.find({ focused: true })?.attrs?.testTag ?? null + ); + `) + tree, err := hierarchy.Parse(treeJSON) + if err != nil { + t.Fatal(err) + } + if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}, Tree: tree}); err != nil { + t.Fatal(err) + } + got := verifier.runtime.GlobalObject().Get("focusedTag").ToObject(verifier.runtime).Get("current") + if got == nil || got.String() != "EmailField" { + t.Fatalf("expected EmailField, got %v", got) + } +} + // TestFrom_SeededReplayIsDeterministic guarantees `from()` over a per-step // 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. diff --git a/pkg/spec/src/index.ts b/pkg/spec/src/index.ts index e60355f..39f9f46 100644 --- a/pkg/spec/src/index.ts +++ b/pkg/spec/src/index.ts @@ -3,15 +3,18 @@ export type { AccessibilityTree, Action, ActionGenerator, + AttrSelector, EventuallyFormula, ExceptionRecord, Extracted, Formula, InputTextAction, Key, + KnownAttrSelectors, LogEntry, Point, PressKeyAction, + RawAttrs, Sampler, SelectorPath, Snapshots, diff --git a/pkg/spec/src/types.ts b/pkg/spec/src/types.ts index a3c53ea..a72eb3d 100644 --- a/pkg/spec/src/types.ts +++ b/pkg/spec/src/types.ts @@ -1,8 +1,58 @@ export type Snapshots = Record; -export type AttrSelector = Record; + +/** + * Attribute names with known canonical types. Cross-platform aliases (e.g. + * `testTag` -> resource-id / accessibilityIdentifier) are listed so authors + * get autocomplete on whichever name they prefer. + * + * Boolean state attributes accept a native boolean; the runtime stringifies + * to "true"/"false" before matching. + */ +export interface KnownAttrSelectors { + testTag?: string; + identifier?: string; + accessibilityIdentifier?: string; + "resource-id"?: string; + + "content-desc"?: string; + accessibilityText?: string; + accessibilityLabel?: string; + label?: string; + + text?: string; + class?: string; + elementType?: string; + package?: string; + placeholderValue?: string; + hintText?: string; + + clickable?: boolean; + enabled?: boolean; + focused?: boolean; + checked?: boolean; + selected?: boolean; +} + +/** + * Object-form selector for `find` / `findAll`. Known attributes are typed + * via `KnownAttrSelectors`; arbitrary string keys are still allowed for + * raw driver attributes the typed surface doesn't yet cover. + */ +export type AttrSelector = KnownAttrSelectors & { + [key: string]: string | boolean | undefined; +}; export type SelectorPath = readonly AttrSelector[]; +/** String-valued attribute names with known canonical keys. */ +export type RawAttrs = { + [K in keyof KnownAttrSelectors]?: KnownAttrSelectors[K] extends boolean | undefined + ? "true" | "false" + : string; +} & { + [key: string]: string | undefined; +}; + export interface AccessibilityElement { id?: string; text?: string; @@ -16,7 +66,7 @@ export interface AccessibilityElement { bounds?: { left: number; top: number; right: number; bottom: number }; x?: number; y?: number; - attrs?: Record; + attrs?: RawAttrs; find(selector: string | AttrSelector | SelectorPath): AccessibilityElement | undefined; findAll(selector: string | AttrSelector | SelectorPath): AccessibilityElement[]; }