verikun 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -5
- package/dist/agent/engine.js +8 -0
- package/dist/agent/grammar.js +30 -5
- package/dist/args.js +14 -0
- package/dist/cli.js +237 -45
- package/dist/drivers/adb.js +28 -1
- package/dist/drivers/ios.js +26 -1
- package/dist/ui/android-parse.js +19 -0
- package/dist/ui/format.js +5 -0
- package/dist/ui/ios-parse.js +25 -0
- package/dist/ui/selector.js +81 -18
- package/dist/ui/state-support.js +41 -0
- package/dist/ui/viewport.js +267 -0
- package/dist/version.js +1 -1
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -390,15 +390,108 @@ class:Button simplified type ("Button") or full class ("android.widget.Button
|
|
|
390
390
|
```
|
|
391
391
|
|
|
392
392
|
Modifiers: `--contains` makes text/desc matches substring-based; `--index N`
|
|
393
|
-
selects the Nth match (0-based) when a selector intentionally matches several
|
|
394
|
-
`--enabled` matches only a control that is **actionable right now** — use it for a
|
|
395
|
-
Submit/Check button the app disables until a form is valid, since such a button is
|
|
396
|
-
present long before it is usable and tapping presence taps a dead control (with
|
|
397
|
-
auto-wait this reads as "wait until it is pressable").
|
|
393
|
+
selects the Nth match (0-based) when a selector intentionally matches several.
|
|
398
394
|
If a selector for an action matches more than one element and no `--index` is
|
|
399
395
|
given, the command fails with exit code 2 and lists the candidates — it never
|
|
400
396
|
taps a guess.
|
|
401
397
|
|
|
398
|
+
### State modifiers
|
|
399
|
+
|
|
400
|
+
A selector can also require an element's a11y **state**, in both polarities:
|
|
401
|
+
|
|
402
|
+
| Modifier | Matches | Negative form |
|
|
403
|
+
|---|---|---|
|
|
404
|
+
| `--enabled` | actionable right now | `--not-enabled` |
|
|
405
|
+
| `--selected` | the current option of a segmented control / tab bar / mode picker | `--not-selected` |
|
|
406
|
+
| `--checked` | a ticked checkbox / switch / radio | `--not-checked` |
|
|
407
|
+
| `--focused` | the element holding input focus | `--not-focused` |
|
|
408
|
+
|
|
409
|
+
Unset means *don't care*; these never narrow a selector you didn't ask them to.
|
|
410
|
+
|
|
411
|
+
Reach for `--enabled` on a Submit/Check button the app disables until a form is
|
|
412
|
+
valid: such a button is present long before it is usable, so tapping presence taps
|
|
413
|
+
a dead control (with auto-wait this reads as "wait until it is pressable").
|
|
414
|
+
|
|
415
|
+
The **negative** forms are what make a toggle drivable. A segmented control whose
|
|
416
|
+
options share one handler *flips* on any tap, so "tap the option I want" lands on
|
|
417
|
+
the other one whenever it was already chosen — exit 0, nothing to notice, and the
|
|
418
|
+
run exercises the wrong mode. Guard it instead:
|
|
419
|
+
|
|
420
|
+
```sh
|
|
421
|
+
vk find "@mode_video --not-selected" --no-wait && vk tap @mode_video
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
A modifier can be written as a flag **or appended to the selector string**, as
|
|
425
|
+
above. The string form exists because a `vk ai` control node (`if-present`,
|
|
426
|
+
`when`, `repeat`, `while-present`, `read`) holds a bare selector with nowhere to
|
|
427
|
+
put a flag — and a guard is exactly where the toggle case needs one:
|
|
428
|
+
|
|
429
|
+
```
|
|
430
|
+
if-present "id:mode_video --not-selected" { tap id:mode_video }
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
**`--selected` and `--focused` are Android-only.** `idb` reports no such
|
|
434
|
+
attribute for iOS — not merely unset, the key does not exist in its output — so
|
|
435
|
+
using them with `--ios` exits **3** rather than silently matching nothing.
|
|
436
|
+
`--enabled` and `--checked` work on both.
|
|
437
|
+
|
|
438
|
+
### Auto scroll-into-view
|
|
439
|
+
|
|
440
|
+
An element can be in the hierarchy without being reachable at the point a tap
|
|
441
|
+
would land: scrolled past the edge of its list, or with a sticky bar drawn across
|
|
442
|
+
its middle. Pressing its coordinates then hits whatever is actually there — and
|
|
443
|
+
the step reports success, so the run continues from the wrong place.
|
|
444
|
+
|
|
445
|
+
So **actions scroll; inspection does not**. `tap` and `text` bring their target
|
|
446
|
+
into the clear first — into its scroll container, and out from under anything
|
|
447
|
+
drawn over it — then act, adding `(scrolled into view: N swipes)` to the
|
|
448
|
+
confirmation. "Scroll down to X and tap it" is therefore just `vk tap X`; you
|
|
449
|
+
rarely need an explicit `swipe`.
|
|
450
|
+
|
|
451
|
+
`ui`, `find` and `assert` never scroll and never hide anything: an element with no
|
|
452
|
+
pixel on screen is listed as usual, marked `offscreen`. Where an element cannot be
|
|
453
|
+
reached at all, the action **fails with exit 1** rather than pressing blind
|
|
454
|
+
coordinates. `--no-scroll` opts out of the scrolling.
|
|
455
|
+
|
|
456
|
+
> Note what this cannot see: a control covered by something the accessibility tree
|
|
457
|
+
> does not contain (a decorative container with no label or id) is invisible to any
|
|
458
|
+
> tool reading that tree. Scrolling the target clear of screen edges is what
|
|
459
|
+
> avoids most of these; verikun warns on stderr when it presses an element it
|
|
460
|
+
> believes is covered.
|
|
461
|
+
|
|
462
|
+
### Which selector to reach for: `@id` first, `text:` second, `desc:` never
|
|
463
|
+
|
|
464
|
+
Not all four selector kinds travel equally well. If a flow has to run on both
|
|
465
|
+
Android and iOS, this ordering matters:
|
|
466
|
+
|
|
467
|
+
| selector | Android | iOS | portable? |
|
|
468
|
+
|---|---|---|---|
|
|
469
|
+
| `@id` | `resource-id` | `AXUniqueId` | **yes — always prefer this** |
|
|
470
|
+
| `text:` | visible text, falling back to `content-desc` | `AXLabel` / `title` / `AXValue` | yes |
|
|
471
|
+
| `desc:` | `content-desc` | `accessibilityHint` only | **no — Android in practice** |
|
|
472
|
+
| `class:` | widget class | element role | no — see below |
|
|
473
|
+
|
|
474
|
+
Two traps worth knowing:
|
|
475
|
+
|
|
476
|
+
- **`desc:` does not fall back.** `text:` falls back to `desc` when no text
|
|
477
|
+
matches, so a `text:Submit` selector finds an element carrying only an
|
|
478
|
+
accessibility label. The reverse is not true — `desc:Submit` will never match
|
|
479
|
+
visible text. On iOS an accessibility label arrives as `text`, so a `desc:`
|
|
480
|
+
selector written against Android silently stops matching there.
|
|
481
|
+
- **`class:` is mostly useless on a cross-platform UI toolkit.** Flutter text
|
|
482
|
+
inputs report as `android.widget.EditText` / `TextField`, but almost everything
|
|
483
|
+
else is `android.view.View` — so `class:Button` cannot match a Flutter button
|
|
484
|
+
regardless of what the widget is.
|
|
485
|
+
|
|
486
|
+
There is a further, sharper reason to prefer `@id`: it is the only selector that
|
|
487
|
+
is not text, so it survives **localisation**. A flow pinned with `text:` breaks
|
|
488
|
+
the moment the device is in a different language.
|
|
489
|
+
|
|
490
|
+
For a Flutter app, `@id` comes from `Semantics(identifier:)`; `Semantics(label:)`
|
|
491
|
+
gives you `desc` on Android but `text` on iOS. A worked example, with the
|
|
492
|
+
cross-platform gotchas measured on real hardware, is in
|
|
493
|
+
[`example/flutter-app`](example/flutter-app/).
|
|
494
|
+
|
|
402
495
|
## Auto-wait
|
|
403
496
|
|
|
404
497
|
A UI rarely settles the instant the previous action returns. So selector
|
package/dist/agent/engine.js
CHANGED
|
@@ -4,6 +4,7 @@ exports.DEFAULT_GUARD_SETTLE_MS = exports.DEFAULT_RUN_TIMEOUT_MS = void 0;
|
|
|
4
4
|
exports.runPlan = runPlan;
|
|
5
5
|
const node_crypto_1 = require("node:crypto");
|
|
6
6
|
const selector_1 = require("../ui/selector");
|
|
7
|
+
const state_support_1 = require("../ui/state-support");
|
|
7
8
|
const errors_1 = require("../errors");
|
|
8
9
|
const ir_1 = require("./ir");
|
|
9
10
|
/** An outcome is environment-flavoured if it carries an exit-3 CliError, or simply
|
|
@@ -191,6 +192,11 @@ async function runPlan(plan, deps) {
|
|
|
191
192
|
deps.log(`[ai] guard selector '${selector}' did not parse (${e.message}) — treating as not present`);
|
|
192
193
|
return false;
|
|
193
194
|
}
|
|
195
|
+
// Outside the catch on purpose. A guard pinning state this platform cannot report
|
|
196
|
+
// would match nothing forever, so "not present" is a lie that silently skips the
|
|
197
|
+
// body — the plan is unrunnable HERE and must say so, not quietly do nothing.
|
|
198
|
+
if (deps.platform)
|
|
199
|
+
(0, state_support_1.assertStateSupported)(sel, deps.platform);
|
|
194
200
|
const deadline = Date.now() + Math.max(0, settleMs);
|
|
195
201
|
// A non-zero window must buy at least one SECOND look, independent of the clock.
|
|
196
202
|
// Measured on emulator-5554: one uiautomator dump costs ~2.4s, which already exceeds
|
|
@@ -368,6 +374,8 @@ async function runPlan(plan, deps) {
|
|
|
368
374
|
catch (e) {
|
|
369
375
|
return { status: 'fail', where, reason: `read selector '${selector}' did not parse: ${e.message}` };
|
|
370
376
|
}
|
|
377
|
+
if (deps.platform)
|
|
378
|
+
(0, state_support_1.assertStateSupported)(sel, deps.platform);
|
|
371
379
|
// Same patience as a conditional guard: the value may not have rendered yet.
|
|
372
380
|
const deadline = Date.now() + Math.max(0, guardSettleMs);
|
|
373
381
|
let looks = 0;
|
package/dist/agent/grammar.js
CHANGED
|
@@ -20,7 +20,7 @@ Each step is one of three node types:
|
|
|
20
20
|
rerun starts FRESH (--clear also wipes data → fresh-install;
|
|
21
21
|
--no-restart skips the force-stop, just bringing it forward)
|
|
22
22
|
stop <package> — force-stop the app
|
|
23
|
-
tap <selector> — tap the element a selector resolves to
|
|
23
|
+
tap <selector> — tap the element a selector resolves to (scrolls it into view first)
|
|
24
24
|
text <selector> <value...> — focus a field and type value (--clear to clear first, --enter to submit)
|
|
25
25
|
type <value...> — type into the already-focused field
|
|
26
26
|
key <name> | back | home | enter
|
|
@@ -35,9 +35,9 @@ Each step is one of three node types:
|
|
|
35
35
|
keep a flow from breaking when an extra screen sometimes appears.
|
|
36
36
|
|
|
37
37
|
3. REPEAT — { "type":"repeat", "selector":<sel>, "cap":<n>, "body":[<nodes>] }
|
|
38
|
-
Repeat body UNTIL the selector appears, up to cap iterations. Use for "
|
|
39
|
-
|
|
40
|
-
10). The engine also stops early if the screen stops changing. A repeat that finishes
|
|
38
|
+
Repeat body UNTIL the selector appears, up to cap iterations. Use for "keep answering
|
|
39
|
+
until the results screen" — NOT for scrolling to something that is already in the
|
|
40
|
+
hierarchy, since tap scrolls to its own target. Always set a sane cap (e.g. 10). The engine also stops early if the screen stops changing. A repeat that finishes
|
|
41
41
|
without its selector ever appearing FAILS the test — it did not do its job.
|
|
42
42
|
|
|
43
43
|
4. WHEN — { "type":"when", "branches":[{ "selector":<sel>, "body":[<nodes>] }, ...],
|
|
@@ -112,14 +112,36 @@ SELECTORS (the engine auto-heals case/whitespace/partial, so prefer stable ident
|
|
|
112
112
|
class:Button type or class
|
|
113
113
|
"Sign in" bare string == text:Sign in
|
|
114
114
|
|
|
115
|
+
A selector may also pin ELEMENT STATE, in both polarities:
|
|
116
|
+
--enabled / --not-enabled actionable right now
|
|
117
|
+
--selected / --not-selected current option of a segmented control / tab bar / mode picker
|
|
118
|
+
--checked / --not-checked checkbox / switch / radio state
|
|
119
|
+
--focused / --not-focused holds input focus
|
|
120
|
+
On a command leaf write it as a flag. On a CONTROL NODE append it to the selector string —
|
|
121
|
+
that is the only place one can go, and it is what makes a state-conditional guard possible:
|
|
122
|
+
{ "type":"if-present", "selector":"id:mode_video --not-selected",
|
|
123
|
+
"body":[ { "type":"command","command":"tap","positionals":["id:mode_video"],"flags":[] } ] }
|
|
124
|
+
|
|
115
125
|
RULES:
|
|
116
126
|
- --enabled on a tap makes it match only a control that is ACTIONABLE right now, and (with
|
|
117
127
|
auto-wait) wait until it becomes so. Use it for any button that the app disables until
|
|
118
128
|
something else is done — a Check/Submit/Continue that only lights up once an answer is
|
|
119
129
|
selected or a form is valid. Without it the step taps a dead control, does nothing, and
|
|
120
130
|
the failure surfaces later as a confusing timeout on the NEXT step.
|
|
131
|
+
- A picker or toggle whose options share ONE handler FLIPS on any tap, so an unconditional
|
|
132
|
+
"tap the option you want" lands on the option you did NOT want whenever it was already
|
|
133
|
+
chosen — and its starting state is usually content-driven, so you cannot know it now.
|
|
134
|
+
Guard it: if-present "id:<option> --not-selected" { tap id:<option> }. The guard makes an
|
|
135
|
+
already-correct state a no-op instead of a flip. Unguarded, the flow completes either way
|
|
136
|
+
and the test PASSES having exercised the opposite mode — a false green, worse than a fail.
|
|
137
|
+
Same shape for a checkbox that toggles: guard with --not-checked / --checked.
|
|
121
138
|
- assert is for VERIFICATION only and is terminal — never use it as a step you expect to
|
|
122
139
|
fail. Put genuinely-optional UI behind if-present.
|
|
140
|
+
- tap/text SCROLL THEIR TARGET INTO VIEW automatically, so "scroll down to X and tap it"
|
|
141
|
+
is just \`tap X\`. Do NOT wrap a tap in a repeat-until-visible loop to reach something
|
|
142
|
+
below the fold — that is now redundant. Emit an explicit swipe only when the SCROLLING
|
|
143
|
+
ITSELF is what the test asks for ("scroll the feed three times"), or to reveal content
|
|
144
|
+
that is not in the hierarchy until it is built (an infinite/lazy list).
|
|
123
145
|
- Prefer resource-id / accessibility selectors over visible text where possible.
|
|
124
146
|
- Translate the test literally and minimally: do not invent ACTION steps (tap/text/swipe/key/assert)
|
|
125
147
|
the prose does not imply. The ONE exception is screenshot — insert screenshot steps liberally as
|
|
@@ -149,4 +171,7 @@ Emit ONLY an object matching the schema:
|
|
|
149
171
|
{ "decision":"repair", "step": { "type":"command","command","positionals":[...],"flags":[{"name","value"}] } }
|
|
150
172
|
{ "decision":"give_up", "reason": "<why no element on this screen matches the intent>" }
|
|
151
173
|
Prefer a stable selector (resource-id / accessibility label) visible in the hierarchy.
|
|
152
|
-
Do not invent elements that are not in the hierarchy
|
|
174
|
+
Do not invent elements that are not in the hierarchy.
|
|
175
|
+
An element tagged \`offscreen\` is in the tree but scrolled out of view; tap/text scroll
|
|
176
|
+
to their target on their own, so the fact a step failed on one means scrolling could not
|
|
177
|
+
reach it — pick a different element only if one genuinely serves the same purpose.`;
|
package/dist/args.js
CHANGED
|
@@ -35,6 +35,7 @@ const BOOLEAN = new Set([
|
|
|
35
35
|
'android',
|
|
36
36
|
'fix',
|
|
37
37
|
'no-wait',
|
|
38
|
+
'no-scroll',
|
|
38
39
|
'full',
|
|
39
40
|
'more',
|
|
40
41
|
'show-plan',
|
|
@@ -43,6 +44,19 @@ const BOOLEAN = new Set([
|
|
|
43
44
|
'no-restart',
|
|
44
45
|
'allow-install',
|
|
45
46
|
'allow-unsafe-anonymous',
|
|
47
|
+
// Selector state modifiers (STATE_ATTRS in ui/selector.ts) and their negations.
|
|
48
|
+
// `enabled` was missing here until 0.15.0, and the omission was not cosmetic: a
|
|
49
|
+
// non-BOOLEAN flag swallows the next token, so `vk tap --enabled @submit` bound
|
|
50
|
+
// the SELECTOR as the flag's value and died with "Missing selector". Only the
|
|
51
|
+
// trailing form worked. Any new modifier must be listed here.
|
|
52
|
+
'enabled',
|
|
53
|
+
'selected',
|
|
54
|
+
'checked',
|
|
55
|
+
'focused',
|
|
56
|
+
'not-enabled',
|
|
57
|
+
'not-selected',
|
|
58
|
+
'not-checked',
|
|
59
|
+
'not-focused',
|
|
46
60
|
]);
|
|
47
61
|
function parseArgs(argv) {
|
|
48
62
|
const positionals = [];
|
package/dist/cli.js
CHANGED
|
@@ -35,11 +35,13 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.platformFromFlags = platformFromFlags;
|
|
37
37
|
exports.deviceFromFlags = deviceFromFlags;
|
|
38
|
+
exports.stateFromFlags = stateFromFlags;
|
|
38
39
|
exports.parsePoint = parsePoint;
|
|
39
40
|
exports.healNote = healNote;
|
|
40
41
|
exports.parseDuration = parseDuration;
|
|
41
42
|
exports.waitWindowMs = waitWindowMs;
|
|
42
43
|
exports.waitNote = waitNote;
|
|
44
|
+
exports.scrollNote = scrollNote;
|
|
43
45
|
exports.formatDeviceTable = formatDeviceTable;
|
|
44
46
|
exports.guardSettleMs = guardSettleMs;
|
|
45
47
|
exports.confineToCwd = confineToCwd;
|
|
@@ -57,7 +59,9 @@ const errors_1 = require("./errors");
|
|
|
57
59
|
const exec_1 = require("./exec");
|
|
58
60
|
const drivers_1 = require("./drivers");
|
|
59
61
|
const selector_1 = require("./ui/selector");
|
|
62
|
+
const state_support_1 = require("./ui/state-support");
|
|
60
63
|
const format_1 = require("./ui/format");
|
|
64
|
+
const viewport_1 = require("./ui/viewport");
|
|
61
65
|
const output_1 = require("./output");
|
|
62
66
|
const run_1 = require("./run");
|
|
63
67
|
const image_1 = require("./image");
|
|
@@ -91,15 +95,42 @@ function deviceFromFlags(flags, platform) {
|
|
|
91
95
|
(platform === 'android' ? process.env.ANDROID_SERIAL : undefined) ||
|
|
92
96
|
undefined);
|
|
93
97
|
}
|
|
94
|
-
|
|
98
|
+
/**
|
|
99
|
+
* Read the `--enabled` / `--not-enabled` / `--selected` / … pairs off the flags.
|
|
100
|
+
*
|
|
101
|
+
* An ABSENT flag must stay `undefined`, never `false`: these modifiers are tri-state
|
|
102
|
+
* ("must be" / "must not be" / "don't care"), so passing `flagBool()` straight through —
|
|
103
|
+
* which is what this replaced — would quietly turn every selector on every command into
|
|
104
|
+
* "must be disabled, unselected, unchecked and unfocused".
|
|
105
|
+
*/
|
|
106
|
+
function stateFromFlags(flags) {
|
|
107
|
+
const state = {};
|
|
108
|
+
for (const attr of selector_1.STATE_ATTRS) {
|
|
109
|
+
const yes = (0, args_1.flagBool)(flags, attr);
|
|
110
|
+
const no = (0, args_1.flagBool)(flags, `not-${attr}`);
|
|
111
|
+
if (yes && no)
|
|
112
|
+
throw new errors_1.CliError(`Cannot combine --${attr} with --not-${attr}.`, 2);
|
|
113
|
+
if (yes)
|
|
114
|
+
state[attr] = true;
|
|
115
|
+
else if (no)
|
|
116
|
+
state[attr] = false;
|
|
117
|
+
}
|
|
118
|
+
return state;
|
|
119
|
+
}
|
|
120
|
+
function buildSelector(ctx, raw) {
|
|
95
121
|
if (!raw) {
|
|
96
122
|
throw new errors_1.CliError('Missing selector. e.g. `@login_button`, `text:Login`, `desc:Submit`.', 2);
|
|
97
123
|
}
|
|
98
|
-
|
|
99
|
-
contains: (0, args_1.flagBool)(flags, 'contains'),
|
|
100
|
-
index: (0, args_1.flagNum)(flags, 'index'),
|
|
101
|
-
|
|
124
|
+
const sel = (0, selector_1.parseSelector)(raw, {
|
|
125
|
+
contains: (0, args_1.flagBool)(ctx.flags, 'contains'),
|
|
126
|
+
index: (0, args_1.flagNum)(ctx.flags, 'index'),
|
|
127
|
+
...stateFromFlags(ctx.flags),
|
|
102
128
|
});
|
|
129
|
+
// Every command's selector — and so every `vk ai` leaf, which reaches these handlers
|
|
130
|
+
// through executeOutcome — funnels through here, which is why the platform check lives
|
|
131
|
+
// at this seam rather than in the (platform-free) selector layer.
|
|
132
|
+
(0, state_support_1.assertStateSupported)(sel, ctx.platform);
|
|
133
|
+
return sel;
|
|
103
134
|
}
|
|
104
135
|
function parsePoint(s) {
|
|
105
136
|
const m = /^(-?\d+)\s*,\s*(-?\d+)$/.exec(s.trim());
|
|
@@ -173,7 +204,10 @@ async function resolveOneWaiting(ctx, sel, opts = {}) {
|
|
|
173
204
|
const els = ctx.driver.getElements(opts);
|
|
174
205
|
if ((0, selector_1.matchElements)(els, sel).matches.length >= 1) {
|
|
175
206
|
const { element, tier } = (0, selector_1.resolveOne)(els, sel); // 1 → resolved; >1 → throws ambiguity
|
|
176
|
-
|
|
207
|
+
// The snapshot rides along: scroll-into-view needs the scrollable containers
|
|
208
|
+
// from the SAME dump the element came from, and re-capturing to find them
|
|
209
|
+
// would both cost a round-trip and risk describing a screen that moved on.
|
|
210
|
+
return { element, tier, waitedMs: Date.now() - start, elements: els };
|
|
177
211
|
}
|
|
178
212
|
if (Date.now() >= deadline) {
|
|
179
213
|
const waited = windowMs > 0 ? ` after ${(windowMs / 1000).toFixed(1)}s` : '';
|
|
@@ -182,6 +216,146 @@ async function resolveOneWaiting(ctx, sel, opts = {}) {
|
|
|
182
216
|
await sleep(pollStep(ctx.flags, deadline));
|
|
183
217
|
}
|
|
184
218
|
}
|
|
219
|
+
// --- Auto scroll-into-view --------------------------------------------------
|
|
220
|
+
// An element's centre is not always a point that reaches it, and a tap on the
|
|
221
|
+
// wrong point still reported success — so the run carried on from the wrong place
|
|
222
|
+
// and failed several steps later on an unrelated symptom (issue #42). Actions
|
|
223
|
+
// therefore bring their target into the clear first, the same contract as
|
|
224
|
+
// Playwright's scrollIntoViewIfNeeded.
|
|
225
|
+
//
|
|
226
|
+
// MEASURED, and it is not only the obvious case: Android's dumper already drops
|
|
227
|
+
// nodes it considers invisible and clips the rest to the display, so the usual
|
|
228
|
+
// shape is an element that IS on screen — a row cut off by its list, or one with a
|
|
229
|
+
// sticky bar drawn across its middle. Both are handled by asking the question
|
|
230
|
+
// against `clipRegion()` (screen ∩ scroll container) and `isOccluded()` (what is
|
|
231
|
+
// painted after it), not against the screen alone.
|
|
232
|
+
//
|
|
233
|
+
// Load-bearing split, mirroring auto-wait: ACTIONS scroll, INSPECTION does not.
|
|
234
|
+
// `ui`/`find`/`assert` report an element exactly as it is (tagged `offscreen` when
|
|
235
|
+
// it has no pixel on screen) — hiding it would turn a wrong tap into a mysterious
|
|
236
|
+
// miss — while `tap`/`text` refuse to press a point that would hit something else.
|
|
237
|
+
//
|
|
238
|
+
// Scrolling only ever happens when the alternative is a wrong tap, so a target
|
|
239
|
+
// already in the clear costs nothing: no extra dump, no swipe.
|
|
240
|
+
const SCROLL_INTO_VIEW_MAX = 10;
|
|
241
|
+
/** Let the scroll settle before re-dumping — a mid-fling hierarchy reads as a stall. */
|
|
242
|
+
const SCROLL_SETTLE_MS = 500;
|
|
243
|
+
/** Movement below this is measurement noise, not progress. */
|
|
244
|
+
const NO_PROGRESS_PX = 8;
|
|
245
|
+
/** Consecutive non-moving swipes before we accept the list will not go further. */
|
|
246
|
+
const NO_PROGRESS_STRIKES = 2;
|
|
247
|
+
/** A short note appended to action output when the target had to be scrolled to. */
|
|
248
|
+
function scrollNote(swipes) {
|
|
249
|
+
return swipes > 0 ? ` (scrolled into view: ${swipes} swipe${swipes === 1 ? '' : 's'})` : '';
|
|
250
|
+
}
|
|
251
|
+
/** Swipe until `target` sits fully inside its clip region, or we run out of room. */
|
|
252
|
+
async function scrollIntoView(ctx, sel, target, elements, screen, opts = {}) {
|
|
253
|
+
let current = target;
|
|
254
|
+
let snapshot = elements;
|
|
255
|
+
let swipes = 0;
|
|
256
|
+
let stalled = 0;
|
|
257
|
+
while (swipes < SCROLL_INTO_VIEW_MAX && stalled < NO_PROGRESS_STRIKES) {
|
|
258
|
+
// Re-derived every iteration: scrolling can change which container holds the
|
|
259
|
+
// element, and a stale clip would aim the next swipe at the wrong box.
|
|
260
|
+
const clip = (0, viewport_1.clipRegion)(snapshot, current, screen);
|
|
261
|
+
const axis = Math.abs(current.center.y - (clip.y1 + clip.y2) / 2) >= Math.abs(current.center.x - (clip.x1 + clip.x2) / 2)
|
|
262
|
+
? 'y'
|
|
263
|
+
: 'x';
|
|
264
|
+
// A covered element is scrolled to the MIDDLE of its container even though it is
|
|
265
|
+
// technically in view: a sticky bar overlaps the edges of a list, and moving the
|
|
266
|
+
// target away from them is the one reliable way to get a touch through to it.
|
|
267
|
+
const centre = (0, viewport_1.isOccluded)(snapshot, current, (0, viewport_1.tapPoint)(current, screen));
|
|
268
|
+
const plan = (0, viewport_1.scrollPlan)(current.bounds, (0, viewport_1.scrollSurface)(snapshot, current, screen, axis), clip, { centre });
|
|
269
|
+
if (!plan)
|
|
270
|
+
break; // in view, or no swipe big enough to be worth making
|
|
271
|
+
ctx.driver.swipe(plan.from.x, plan.from.y, plan.to.x, plan.to.y, (0, viewport_1.swipeDurationMs)(plan.distance));
|
|
272
|
+
swipes++;
|
|
273
|
+
await sleep(SCROLL_SETTLE_MS);
|
|
274
|
+
const before = current.bounds;
|
|
275
|
+
snapshot = ctx.driver.getElements(opts);
|
|
276
|
+
// An empty tree is a bad read, not a screen (the device returns partial dumps
|
|
277
|
+
// mid-transition) — retry rather than conclude the element is gone.
|
|
278
|
+
if (snapshot.length === 0)
|
|
279
|
+
continue;
|
|
280
|
+
if ((0, selector_1.matchElements)(snapshot, sel).matches.length === 0) {
|
|
281
|
+
// Android drops a node that scrolls out of view, so overshooting LOSES the
|
|
282
|
+
// target rather than leaving it visibly off-position. Give the last swipe back
|
|
283
|
+
// (half of it, to land between the two) and look once more before giving up.
|
|
284
|
+
const mid = { x: Math.round((plan.from.x + plan.to.x) / 2), y: Math.round((plan.from.y + plan.to.y) / 2) };
|
|
285
|
+
ctx.driver.swipe(plan.to.x, plan.to.y, mid.x, mid.y, (0, viewport_1.swipeDurationMs)(plan.distance / 2));
|
|
286
|
+
await sleep(SCROLL_SETTLE_MS);
|
|
287
|
+
snapshot = ctx.driver.getElements(opts);
|
|
288
|
+
if ((0, selector_1.matchElements)(snapshot, sel).matches.length === 0) {
|
|
289
|
+
throw new errors_1.SelectorNotFoundError(`'${sel.raw}' left the hierarchy while being scrolled into view (after ${swipes} swipe(s)) — ` +
|
|
290
|
+
'a lazy list may have recycled it. Run `verikun ui` to inspect the current screen.');
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
current = (0, selector_1.resolveOne)(snapshot, sel).element; // >1 → ambiguity, exit 2, as everywhere else
|
|
294
|
+
const moved = Math.abs(plan.axis === 'y' ? current.bounds.y1 - before.y1 : current.bounds.x1 - before.x1);
|
|
295
|
+
stalled = moved < NO_PROGRESS_PX ? stalled + 1 : 0;
|
|
296
|
+
}
|
|
297
|
+
return { element: current, swipes, elements: snapshot };
|
|
298
|
+
}
|
|
299
|
+
/** The screen as a rectangle, or null on a device whose size could not be read. */
|
|
300
|
+
function screenOf(ctx) {
|
|
301
|
+
const vp = ctx.driver.viewport();
|
|
302
|
+
return vp ? (0, viewport_1.screenRect)(vp) : null;
|
|
303
|
+
}
|
|
304
|
+
/** The point to press for `el`, preferring one nothing else is drawn over. Falls back
|
|
305
|
+
* to the visible centre — an ordering-based guess must never block a real tap. */
|
|
306
|
+
function pressPoint(els, el, screen) {
|
|
307
|
+
if (!screen)
|
|
308
|
+
return el.center;
|
|
309
|
+
return (0, viewport_1.reachablePoint)(els, el, screen) ?? (0, viewport_1.tapPoint)(el, screen);
|
|
310
|
+
}
|
|
311
|
+
/** Say so when the element we are about to press is only partly on screen, or is
|
|
312
|
+
* covered by something drawn over it — both mean the touch may not reach it. */
|
|
313
|
+
function reachWarning(els, el, screen) {
|
|
314
|
+
if (!screen)
|
|
315
|
+
return;
|
|
316
|
+
if (!(0, viewport_1.isFullyVisible)(el.bounds, screen)) {
|
|
317
|
+
(0, output_1.err)(`(only ${Math.round((0, viewport_1.visibleFraction)(el.bounds, screen) * 100)}% of ${(0, format_1.formatInline)(el)} is on screen)`);
|
|
318
|
+
}
|
|
319
|
+
if (!(0, viewport_1.reachablePoint)(els, el, screen)) {
|
|
320
|
+
(0, output_1.err)(`(${(0, format_1.formatInline)(el)} is covered by another element — the tap may land on whatever is on top)`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Resolve a selector to something that can actually be pressed: wait for it, scroll it
|
|
325
|
+
* into view when it is not fully inside its scroll container, and fail loudly rather
|
|
326
|
+
* than tap blind.
|
|
327
|
+
*
|
|
328
|
+
* When the screen size is unknown this is exactly the old behaviour — resolve and
|
|
329
|
+
* press the element's centre.
|
|
330
|
+
*/
|
|
331
|
+
async function resolveTappable(ctx, sel, opts = {}) {
|
|
332
|
+
const { element, tier, waitedMs, elements } = await resolveOneWaiting(ctx, sel, opts);
|
|
333
|
+
const screen = screenOf(ctx);
|
|
334
|
+
const clip = screen ? (0, viewport_1.clipRegion)(elements, element, screen) : null;
|
|
335
|
+
// Scroll when the element is cut off by its container, and also when something is
|
|
336
|
+
// drawn over the point we would press — but only if it HAS a container to scroll
|
|
337
|
+
// (clip !== screen). Swiping the whole screen at a covered toolbar button would be
|
|
338
|
+
// a random gesture, and the point-picking fallback below handles that case.
|
|
339
|
+
const covered = !!screen && !!clip && clip !== screen && (0, viewport_1.isOccluded)(elements, element, (0, viewport_1.tapPoint)(element, screen));
|
|
340
|
+
if (!screen || !clip || ((0, viewport_1.isFullyVisible)(element.bounds, clip) && !covered)) {
|
|
341
|
+
reachWarning(elements, element, screen);
|
|
342
|
+
return { element, tier, waitedMs, swipes: 0, point: pressPoint(elements, element, screen) };
|
|
343
|
+
}
|
|
344
|
+
const scrolled = (0, args_1.flagBool)(ctx.flags, 'no-scroll')
|
|
345
|
+
? { element, swipes: 0, elements }
|
|
346
|
+
: await scrollIntoView(ctx, sel, element, elements, screen, opts);
|
|
347
|
+
if ((0, viewport_1.isOffscreen)(scrolled.element.bounds, screen)) {
|
|
348
|
+
const why = (0, args_1.flagBool)(ctx.flags, 'no-scroll')
|
|
349
|
+
? '--no-scroll is set'
|
|
350
|
+
: scrolled.swipes === 0
|
|
351
|
+
? 'no scrollable container could move it'
|
|
352
|
+
: `${scrolled.swipes} swipe(s) did not bring it into view`;
|
|
353
|
+
throw new errors_1.SelectorNotFoundError(`'${sel.raw}' is in the screen's element tree but scrolled out of view (${why}), so tapping it ` +
|
|
354
|
+
'would press whatever is at those coordinates instead. Run `verikun ui` to inspect the current screen.');
|
|
355
|
+
}
|
|
356
|
+
reachWarning(scrolled.elements, scrolled.element, screen);
|
|
357
|
+
return { ...scrolled, tier, waitedMs, point: pressPoint(scrolled.elements, scrolled.element, screen) };
|
|
358
|
+
}
|
|
185
359
|
// ---------------------------------------------------------------------------
|
|
186
360
|
// Commands
|
|
187
361
|
// ---------------------------------------------------------------------------
|
|
@@ -313,7 +487,7 @@ function cmdUi(ctx) {
|
|
|
313
487
|
return 0;
|
|
314
488
|
}
|
|
315
489
|
async function cmdFind(ctx) {
|
|
316
|
-
const sel = buildSelector(ctx.positionals[0]
|
|
490
|
+
const sel = buildSelector(ctx, ctx.positionals[0]);
|
|
317
491
|
const { matches, tier } = await matchWaiting(ctx, sel, { all: (0, args_1.flagBool)(ctx.flags, 'all') });
|
|
318
492
|
if ((0, args_1.flagBool)(ctx.flags, 'json'))
|
|
319
493
|
(0, output_1.json)(matches.map(format_1.toJsonShape));
|
|
@@ -346,38 +520,55 @@ async function cmdTap(ctx) {
|
|
|
346
520
|
let target;
|
|
347
521
|
let tier = null;
|
|
348
522
|
let waitedMs = 0;
|
|
523
|
+
let swipes = 0;
|
|
524
|
+
let point;
|
|
349
525
|
if (isBareIndex) {
|
|
350
526
|
const els = ctx.driver.getElements({ all: (0, args_1.flagBool)(ctx.flags, 'all') });
|
|
351
527
|
const idx = Number(raw);
|
|
352
528
|
const found = els.find((e) => e.index === idx);
|
|
353
529
|
if (!found)
|
|
354
530
|
throw new errors_1.CliError(`No element with index [${idx}] on the current screen. Run \`verikun ui\`.`, 1);
|
|
531
|
+
// An index names a row of one specific dump, and scrolling renumbers every row —
|
|
532
|
+
// so this path cannot scroll. Refuse instead of pressing coordinates off-screen.
|
|
533
|
+
if (found.offscreen) {
|
|
534
|
+
throw new errors_1.CliError(`Element [${idx}] ${(0, format_1.formatInline)(found)} is scrolled out of view. Tap it by selector ` +
|
|
535
|
+
`(e.g. \`verikun tap @${found.idShort || 'id'}\`) so verikun can scroll it into view first.`, 1);
|
|
536
|
+
}
|
|
355
537
|
target = found;
|
|
538
|
+
reachWarning(els, target, screenOf(ctx));
|
|
539
|
+
point = pressPoint(els, target, screenOf(ctx));
|
|
356
540
|
ctx.record?.note({ element: target, message: `tapped by index [${idx}]` });
|
|
357
541
|
}
|
|
358
542
|
else {
|
|
359
|
-
const sel = buildSelector(
|
|
360
|
-
({ element: target, tier, waitedMs } = await
|
|
361
|
-
|
|
543
|
+
const sel = buildSelector(ctx, raw);
|
|
544
|
+
({ element: target, tier, waitedMs, swipes, point } = await resolveTappable(ctx, sel, {
|
|
545
|
+
all: (0, args_1.flagBool)(ctx.flags, 'all'),
|
|
546
|
+
}));
|
|
547
|
+
ctx.record?.note({
|
|
548
|
+
selector: sel,
|
|
549
|
+
tier,
|
|
550
|
+
element: target,
|
|
551
|
+
message: swipes > 0 ? `scrolled into view (${swipes} swipe(s)) and tapped` : undefined,
|
|
552
|
+
});
|
|
362
553
|
}
|
|
363
|
-
ctx.driver.tap(
|
|
364
|
-
(0, output_1.out)(`tapped ${(0, format_1.formatInline)(target)}${healNote(tier)}${waitNote(waitedMs)}`);
|
|
554
|
+
ctx.driver.tap(point.x, point.y);
|
|
555
|
+
(0, output_1.out)(`tapped ${(0, format_1.formatInline)(target)}${healNote(tier)}${waitNote(waitedMs)}${scrollNote(swipes)}`);
|
|
365
556
|
return 0;
|
|
366
557
|
}
|
|
367
558
|
async function cmdText(ctx) {
|
|
368
559
|
if (ctx.positionals.length < 2) {
|
|
369
560
|
throw new errors_1.CliError('Usage: verikun text <selector> <text...> (use -- before text starting with "-")', 2);
|
|
370
561
|
}
|
|
371
|
-
const sel = buildSelector(ctx.positionals[0]
|
|
562
|
+
const sel = buildSelector(ctx, ctx.positionals[0]);
|
|
372
563
|
const value = ctx.positionals.slice(1).join(' ');
|
|
373
|
-
const { element: target, tier, waitedMs } = await
|
|
564
|
+
const { element: target, tier, waitedMs, swipes, point } = await resolveTappable(ctx, sel);
|
|
374
565
|
ctx.record?.note({
|
|
375
566
|
selector: sel,
|
|
376
567
|
tier,
|
|
377
568
|
element: target,
|
|
378
569
|
message: target.password ? 'typed «redacted»' : `typed ${JSON.stringify(value)}`,
|
|
379
570
|
});
|
|
380
|
-
ctx.driver.tap(
|
|
571
|
+
ctx.driver.tap(point.x, point.y);
|
|
381
572
|
// Wait for field to be focused after tap
|
|
382
573
|
await sleep(100);
|
|
383
574
|
if ((0, args_1.flagBool)(ctx.flags, 'clear') && target.text) {
|
|
@@ -394,7 +585,7 @@ async function cmdText(ctx) {
|
|
|
394
585
|
ctx.driver.inputText(value);
|
|
395
586
|
if ((0, args_1.flagBool)(ctx.flags, 'enter'))
|
|
396
587
|
ctx.driver.pressKey('enter');
|
|
397
|
-
(0, output_1.out)(`typed ${JSON.stringify(value)} into ${(0, format_1.formatInline)(target)}${healNote(tier)}${waitNote(waitedMs)}`);
|
|
588
|
+
(0, output_1.out)(`typed ${JSON.stringify(value)} into ${(0, format_1.formatInline)(target)}${healNote(tier)}${waitNote(waitedMs)}${scrollNote(swipes)}`);
|
|
398
589
|
return 0;
|
|
399
590
|
}
|
|
400
591
|
function cmdType(ctx) {
|
|
@@ -444,7 +635,9 @@ async function cmdSwipe(ctx) {
|
|
|
444
635
|
let waitedMs = 0;
|
|
445
636
|
const on = (0, args_1.flagStr)(ctx.flags, 'on');
|
|
446
637
|
if (on) {
|
|
447
|
-
|
|
638
|
+
// Through buildSelector, not parseSelector: --on used to see only --contains, so
|
|
639
|
+
// `swipe --on X --enabled` silently ignored the modifier it was given.
|
|
640
|
+
const onSel = buildSelector(ctx, on);
|
|
448
641
|
const { element, waitedMs: w } = await resolveOneWaiting(ctx, onSel);
|
|
449
642
|
waitedMs = w;
|
|
450
643
|
ctx.record?.note({ selector: onSel, element });
|
|
@@ -454,33 +647,11 @@ async function cmdSwipe(ctx) {
|
|
|
454
647
|
const { width, height } = ctx.driver.screenSize();
|
|
455
648
|
region = { x1: 0, y1: 0, x2: width, y2: height };
|
|
456
649
|
}
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
const frac = Math.min(Math.max((0, args_1.flagNum)(ctx.flags, 'distance') ?? 0.6, 0.1), 0.95);
|
|
460
|
-
const dx = Math.floor(((region.x2 - region.x1) * frac) / 2);
|
|
461
|
-
const dy = Math.floor(((region.y2 - region.y1) * frac) / 2);
|
|
462
|
-
let a;
|
|
463
|
-
let b;
|
|
464
|
-
switch (dir) {
|
|
465
|
-
case 'up':
|
|
466
|
-
a = { x: cx, y: cy + dy };
|
|
467
|
-
b = { x: cx, y: cy - dy };
|
|
468
|
-
break;
|
|
469
|
-
case 'down':
|
|
470
|
-
a = { x: cx, y: cy - dy };
|
|
471
|
-
b = { x: cx, y: cy + dy };
|
|
472
|
-
break;
|
|
473
|
-
case 'left':
|
|
474
|
-
a = { x: cx + dx, y: cy };
|
|
475
|
-
b = { x: cx - dx, y: cy };
|
|
476
|
-
break;
|
|
477
|
-
case 'right':
|
|
478
|
-
a = { x: cx - dx, y: cy };
|
|
479
|
-
b = { x: cx + dx, y: cy };
|
|
480
|
-
break;
|
|
481
|
-
default:
|
|
482
|
-
throw new errors_1.CliError(`Unknown direction '${dir}' (use up|down|left|right)`, 2);
|
|
650
|
+
if (dir !== 'up' && dir !== 'down' && dir !== 'left' && dir !== 'right') {
|
|
651
|
+
throw new errors_1.CliError(`Unknown direction '${dir}' (use up|down|left|right)`, 2);
|
|
483
652
|
}
|
|
653
|
+
const frac = Math.min(Math.max((0, args_1.flagNum)(ctx.flags, 'distance') ?? viewport_1.DEFAULT_SWIPE_FRACTION, 0.1), 0.95);
|
|
654
|
+
const { from: a, to: b } = (0, viewport_1.swipeVector)(region, dir, frac);
|
|
484
655
|
ctx.driver.swipe(a.x, a.y, b.x, b.y, duration);
|
|
485
656
|
ctx.record?.note({ message: `swiped ${dir}${on ? ` on ${on}` : ''}` });
|
|
486
657
|
(0, output_1.out)(`swiped ${dir}${waitNote(waitedMs)}`);
|
|
@@ -622,7 +793,7 @@ function cmdLog(ctx) {
|
|
|
622
793
|
return 0;
|
|
623
794
|
}
|
|
624
795
|
async function cmdWait(ctx) {
|
|
625
|
-
const sel = buildSelector(ctx.positionals[0]
|
|
796
|
+
const sel = buildSelector(ctx, ctx.positionals[0]);
|
|
626
797
|
const gone = (0, args_1.flagBool)(ctx.flags, 'gone');
|
|
627
798
|
const timeout = (0, args_1.flagNum)(ctx.flags, 'timeout') ?? 10000;
|
|
628
799
|
const interval = (0, args_1.flagNum)(ctx.flags, 'interval') ?? 400;
|
|
@@ -682,7 +853,7 @@ function evalAssert(els, sel, flags) {
|
|
|
682
853
|
return { pass, reason, matches };
|
|
683
854
|
}
|
|
684
855
|
async function cmdAssert(ctx) {
|
|
685
|
-
const sel = buildSelector(ctx.positionals[0]
|
|
856
|
+
const sel = buildSelector(ctx, ctx.positionals[0]);
|
|
686
857
|
// Auto-wait subsumes the common "wait then assert": poll until the assertion
|
|
687
858
|
// passes or the window elapses. `--gone` therefore waits for disappearance.
|
|
688
859
|
const deadline = Date.now() + waitWindowMs(ctx.flags);
|
|
@@ -1213,6 +1384,9 @@ async function runAiTest(file, opts, backend, platform, device) {
|
|
|
1213
1384
|
guardSettleMs: guardSettleMs(),
|
|
1214
1385
|
runId: started.id,
|
|
1215
1386
|
deadline,
|
|
1387
|
+
// The RESOLVED platform — for --server that is the server's, which supersedes
|
|
1388
|
+
// the client's --platform (leaves are gated server-side; guards run here).
|
|
1389
|
+
platform,
|
|
1216
1390
|
});
|
|
1217
1391
|
}
|
|
1218
1392
|
catch (e) {
|
|
@@ -1719,6 +1893,15 @@ SELECTORS
|
|
|
1719
1893
|
class:Button type or full class name
|
|
1720
1894
|
"Sign in" bare string == text:"Sign in"
|
|
1721
1895
|
Modifiers: --contains (substring), --index N (pick Nth match)
|
|
1896
|
+
State: --enabled / --selected / --checked / --focused, each with a --not-
|
|
1897
|
+
form (--not-selected). Unset = don't care. Use the negative to guard
|
|
1898
|
+
a toggle: tapping a picker whose options share a handler FLIPS it, so
|
|
1899
|
+
an unconditional tap lands on the wrong mode and still exits 0.
|
|
1900
|
+
May be written as a flag or appended to the selector string
|
|
1901
|
+
("@mode_video --not-selected") — the latter is how a \`vk ai\`
|
|
1902
|
+
if-present/when/repeat guard carries one.
|
|
1903
|
+
--selected and --focused are Android-only (idb reports neither);
|
|
1904
|
+
on iOS they exit 3 rather than matching nothing.
|
|
1722
1905
|
|
|
1723
1906
|
AUTO-WAIT (selector lookups retry until they resolve)
|
|
1724
1907
|
Selector commands (tap, text, find, assert, swipe --on) re-poll the screen for
|
|
@@ -1730,6 +1913,15 @@ AUTO-WAIT (selector lookups retry until they resolve)
|
|
|
1730
1913
|
Ambiguity is never waited on (the elements are already there). The \`wait\`
|
|
1731
1914
|
command stays for explicit polling, including --gone, with --timeout/--interval.
|
|
1732
1915
|
|
|
1916
|
+
AUTO-SCROLL (actions bring their target into view)
|
|
1917
|
+
\`tap\` / \`text\` first scroll their target into the clear — inside its scroll
|
|
1918
|
+
container, and out from under anything drawn over it (a sticky bar) — then act,
|
|
1919
|
+
so "scroll down then tap X" is just \`tap X\`. \`ui\` / \`find\` / \`assert\` never
|
|
1920
|
+
scroll and hide nothing: an element with no pixel on screen is listed as usual,
|
|
1921
|
+
marked \`offscreen\`. One that cannot be reached fails with exit 1 rather than
|
|
1922
|
+
being tapped at coordinates that would hit something else.
|
|
1923
|
+
--no-scroll act where the element is; do not scroll to it
|
|
1924
|
+
|
|
1733
1925
|
GLOBAL FLAGS
|
|
1734
1926
|
-d, --device <serial> target a specific device (or VERIKUN_DEVICE / ANDROID_SERIAL)
|
|
1735
1927
|
-p, --platform <android|ios> (default: android; --ios / --android shortcuts)
|