mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
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:
10 files changed
+112
-5
No files matched your search
@@ -23,6 +23,7 @@ type testOptions struct {
|
|||||||
duration time.Duration
|
duration time.Duration
|
||||||
seed int64
|
seed int64
|
||||||
output string
|
output string
|
||||||
|
clearData bool
|
||||||
}
|
}
|
||||||
|
|
||||||
const topUsage = `sanderling is a property-based UI fuzzer for mobile apps.
|
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.DurationVar(&options.duration, "duration", 5*time.Minute, "total test duration")
|
||||||
flagSet.Int64Var(&options.seed, "seed", 0, "RNG seed (0 = random)")
|
flagSet.Int64Var(&options.seed, "seed", 0, "RNG seed (0 = random)")
|
||||||
flagSet.StringVar(&options.output, "output", "./runs", "output directory for traces")
|
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 {
|
if err := flagSet.Parse(args); err != nil {
|
||||||
return testOptions{}, err
|
return testOptions{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,23 @@ func TestParseTestArgs_Defaults(t *testing.T) {
|
|||||||
if options.seed != 0 {
|
if options.seed != 0 {
|
||||||
t.Errorf("seed default: got %d, want 0", options.seed)
|
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) {
|
func TestParseTestArgs_AllFlags(t *testing.T) {
|
||||||
|
|||||||
@@ -17,5 +17,6 @@ func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer)
|
|||||||
Duration: options.duration,
|
Duration: options.duration,
|
||||||
Seed: options.seed,
|
Seed: options.seed,
|
||||||
Output: options.output,
|
Output: options.output,
|
||||||
|
ClearData: options.clearData,
|
||||||
}, stdout)
|
}, stdout)
|
||||||
}
|
}
|
||||||
@@ -22,6 +22,7 @@ Run a spec against an app for a fixed duration.
|
|||||||
| `--duration` | `5m` | Total test duration (`30s`, `5m`, `2h`, `1d`). |
|
| `--duration` | `5m` | Total test duration (`30s`, `5m`, `2h`, `1d`). |
|
||||||
| `--seed` | `0` | PRNG seed. `0` uses a random seed and records it in `meta.json`. |
|
| `--seed` | `0` | PRNG seed. `0` uses a random seed and records it in `meta.json`. |
|
||||||
| `--output` | `./runs` | Output directory for traces. |
|
| `--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]`
|
## `sanderling inspect [run-or-runs-dir]`
|
||||||
|
|
||||||
|
|||||||
@@ -64,11 +64,13 @@ Pass an object to apply multiple attribute filters with AND semantics:
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
s.ax.find({ accessibilityText: "LoginScreen" })
|
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.
|
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
|
### 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`.
|
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`.
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ const ledgerRows = extract<LedgerRow[]>(s =>
|
|||||||
const ledgerBalance = extract(s =>
|
const ledgerBalance = extract(s =>
|
||||||
parseDollarCents(s.ax.find({ testTag: "LedgerBalance" })?.text));
|
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 =>
|
const loginEmailField = extract(s =>
|
||||||
s.ax.find([{ testTag: "LoginScreen" }, { testTag: "LoginEmail" }]));
|
s.ax.find([{ testTag: "LoginScreen" }, { testTag: "LoginEmail" }]));
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type Options struct {
|
|||||||
Duration time.Duration
|
Duration time.Duration
|
||||||
Seed int64
|
Seed int64
|
||||||
Output string
|
Output string
|
||||||
|
ClearData bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute runs the full test pipeline: bundle, launch app, verify properties.
|
// 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()
|
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)
|
return fmt.Errorf("launch app: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// TestFrom_SeededReplayIsDeterministic guarantees `from()` over a per-step
|
||||||
// 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.
|
||||||
|
|||||||
@@ -3,15 +3,18 @@ export type {
|
|||||||
AccessibilityTree,
|
AccessibilityTree,
|
||||||
Action,
|
Action,
|
||||||
ActionGenerator,
|
ActionGenerator,
|
||||||
|
AttrSelector,
|
||||||
EventuallyFormula,
|
EventuallyFormula,
|
||||||
ExceptionRecord,
|
ExceptionRecord,
|
||||||
Extracted,
|
Extracted,
|
||||||
Formula,
|
Formula,
|
||||||
InputTextAction,
|
InputTextAction,
|
||||||
Key,
|
Key,
|
||||||
|
KnownAttrSelectors,
|
||||||
LogEntry,
|
LogEntry,
|
||||||
Point,
|
Point,
|
||||||
PressKeyAction,
|
PressKeyAction,
|
||||||
|
RawAttrs,
|
||||||
Sampler,
|
Sampler,
|
||||||
SelectorPath,
|
SelectorPath,
|
||||||
Snapshots,
|
Snapshots,
|
||||||
|
|||||||
+52
-2
@@ -1,8 +1,58 @@
|
|||||||
export type Snapshots = Record<string, unknown>;
|
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[];
|
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 {
|
export interface AccessibilityElement {
|
||||||
id?: string;
|
id?: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
@@ -16,7 +66,7 @@ export interface AccessibilityElement {
|
|||||||
bounds?: { left: number; top: number; right: number; bottom: number };
|
bounds?: { left: number; top: number; right: number; bottom: number };
|
||||||
x?: number;
|
x?: number;
|
||||||
y?: number;
|
y?: number;
|
||||||
attrs?: Record<string, string>;
|
attrs?: RawAttrs;
|
||||||
find(selector: string | AttrSelector | SelectorPath): AccessibilityElement | undefined;
|
find(selector: string | AttrSelector | SelectorPath): AccessibilityElement | undefined;
|
||||||
findAll(selector: string | AttrSelector | SelectorPath): AccessibilityElement[];
|
findAll(selector: string | AttrSelector | SelectorPath): AccessibilityElement[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user