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<string, string> 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
This commit is contained in:
pj authored and GitHub committed 2026-04-27 01:02:21 +07:00
1 parent c76745e5f1
commit dd54c24c4e
10 files changed
+112 -5

No files matched your search

+2
View File
@@ -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
}
+17
View File
@@ -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) {
+1
View File
@@ -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)
}
+1
View File
@@ -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]`
+3 -1
View File
@@ -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`.
+1 -1
View File
@@ -58,7 +58,7 @@ const ledgerRows = extract<LedgerRow[]>(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" }]));
+2 -1
View File
@@ -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)
}
+30
View File
@@ -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.
+3
View File
@@ -3,15 +3,18 @@ export type {
AccessibilityTree,
Action,
ActionGenerator,
AttrSelector,
EventuallyFormula,
ExceptionRecord,
Extracted,
Formula,
InputTextAction,
Key,
KnownAttrSelectors,
LogEntry,
Point,
PressKeyAction,
RawAttrs,
Sampler,
SelectorPath,
Snapshots,
+52 -2
View File
@@ -1,8 +1,58 @@
export type Snapshots = Record<string, unknown>;
export type AttrSelector = Record<string, string>;
/**
* 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<string, string>;
attrs?: RawAttrs;
find(selector: string | AttrSelector | SelectorPath): AccessibilityElement | undefined;
findAll(selector: string | AttrSelector | SelectorPath): AccessibilityElement[];
}