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/dist/drivers/adb.js
CHANGED
|
@@ -6,6 +6,7 @@ exports.escapeText = escapeText;
|
|
|
6
6
|
const errors_1 = require("../errors");
|
|
7
7
|
const exec_1 = require("../exec");
|
|
8
8
|
const android_parse_1 = require("../ui/android-parse");
|
|
9
|
+
const viewport_1 = require("../ui/viewport");
|
|
9
10
|
const ADB = process.env.ADB || 'adb';
|
|
10
11
|
const ADB_HINT = 'install the Android platform-tools (`brew install --cask android-platform-tools`), or point ADB at the binary';
|
|
11
12
|
/** Is `adb` present and runnable? Shared by `vk doctor` and AdbDriver.preflight() so
|
|
@@ -96,6 +97,11 @@ class AdbDriver {
|
|
|
96
97
|
platform = 'android';
|
|
97
98
|
requested;
|
|
98
99
|
cachedSerial;
|
|
100
|
+
/** null = asked and failed. Cached either way: getElements runs on a ~300ms poll
|
|
101
|
+
* during auto-wait, and a broken `wm size` must not cost a round-trip every time. */
|
|
102
|
+
cachedScreen;
|
|
103
|
+
/** Rotation of the most recent dump — see viewport(). */
|
|
104
|
+
lastRotation;
|
|
99
105
|
constructor(serial) {
|
|
100
106
|
this.requested = serial;
|
|
101
107
|
}
|
|
@@ -178,7 +184,28 @@ class AdbDriver {
|
|
|
178
184
|
return (0, exec_1.runText)(ADB, this.withSerial(['shell', ...args]), { timeout }).stdout;
|
|
179
185
|
}
|
|
180
186
|
getElements(opts = {}) {
|
|
181
|
-
|
|
187
|
+
const xml = this.dumpXml();
|
|
188
|
+
// Read off the dump we already have: free, and it refreshes every capture, so a
|
|
189
|
+
// device rotated mid-run is handled without re-asking for the screen size.
|
|
190
|
+
this.lastRotation = (0, android_parse_1.parseRotation)(xml);
|
|
191
|
+
return (0, android_parse_1.parseHierarchy)(xml, { ...opts, screen: this.screenOrNull() ?? undefined });
|
|
192
|
+
}
|
|
193
|
+
viewport() {
|
|
194
|
+
const screen = this.screenOrNull();
|
|
195
|
+
return screen ? (0, viewport_1.viewportFor)(screen, this.lastRotation) : null;
|
|
196
|
+
}
|
|
197
|
+
/** screenSize() memoized, failure included — the parser uses it to mark elements
|
|
198
|
+
* scrolled out of view, and "we could not tell" must degrade to "all visible". */
|
|
199
|
+
screenOrNull() {
|
|
200
|
+
if (this.cachedScreen === undefined) {
|
|
201
|
+
try {
|
|
202
|
+
this.cachedScreen = this.screenSize();
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
this.cachedScreen = null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return this.cachedScreen;
|
|
182
209
|
}
|
|
183
210
|
dumpXml() {
|
|
184
211
|
let lastErr = '';
|
package/dist/drivers/ios.js
CHANGED
|
@@ -10,6 +10,7 @@ const node_fs_1 = require("node:fs");
|
|
|
10
10
|
const errors_1 = require("../errors");
|
|
11
11
|
const exec_1 = require("../exec");
|
|
12
12
|
const ios_parse_1 = require("../ui/ios-parse");
|
|
13
|
+
const viewport_1 = require("../ui/viewport");
|
|
13
14
|
// iOS driver. `xcrun simctl` / `devicectl` cover device discovery and — on a
|
|
14
15
|
// simulator — screenshots, app lifecycle, and logs (no extra install needed).
|
|
15
16
|
// Everything interactive (UI hierarchy, tap, type, swipe, keys, screen size) and
|
|
@@ -147,6 +148,9 @@ class IdbDriver {
|
|
|
147
148
|
requested;
|
|
148
149
|
cachedSerial;
|
|
149
150
|
cachedIsSim;
|
|
151
|
+
/** null = asked and failed. Memoized because screenSize()'s fallback path runs a
|
|
152
|
+
* SECOND full hierarchy dump, which auto-wait would otherwise pay every poll. */
|
|
153
|
+
cachedScreen;
|
|
150
154
|
constructor(device) {
|
|
151
155
|
// 'booted' is a simctl-only alias idb can't address, so treat it as "auto-resolve".
|
|
152
156
|
this.requested = device && device !== 'booted' ? device : undefined;
|
|
@@ -250,7 +254,28 @@ class IdbDriver {
|
|
|
250
254
|
getElements(opts = {}) {
|
|
251
255
|
// `idb ui describe-all` prints the accessibility tree as JSON (array or NDJSON);
|
|
252
256
|
// parseIosHierarchy handles either.
|
|
253
|
-
return (0, ios_parse_1.parseIosHierarchy)(this.idbText(['ui', 'describe-all'], { timeout: 15000 }),
|
|
257
|
+
return (0, ios_parse_1.parseIosHierarchy)(this.idbText(['ui', 'describe-all'], { timeout: 15000 }), {
|
|
258
|
+
...opts,
|
|
259
|
+
screen: this.screenOrNull() ?? undefined,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
viewport() {
|
|
263
|
+
const screen = this.screenOrNull();
|
|
264
|
+
// No orientation signal from idb, so viewportFor uses the max(w,h) square: exact
|
|
265
|
+
// on the vertical axis lists scroll on, permissive across it.
|
|
266
|
+
return screen ? (0, viewport_1.viewportFor)(screen) : null;
|
|
267
|
+
}
|
|
268
|
+
/** screenSize() memoized, failure included — see AdbDriver.screenOrNull. */
|
|
269
|
+
screenOrNull() {
|
|
270
|
+
if (this.cachedScreen === undefined) {
|
|
271
|
+
try {
|
|
272
|
+
this.cachedScreen = this.screenSize();
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
this.cachedScreen = null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return this.cachedScreen;
|
|
254
279
|
}
|
|
255
280
|
screenshot() {
|
|
256
281
|
const tmp = (0, node_path_1.join)((0, node_os_1.tmpdir)(), `verikun-ios-${process.pid}.png`);
|
package/dist/ui/android-parse.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.isInteresting = isInteresting;
|
|
4
|
+
exports.parseRotation = parseRotation;
|
|
4
5
|
exports.parseHierarchy = parseHierarchy;
|
|
6
|
+
const viewport_1 = require("./viewport");
|
|
5
7
|
// Parses uiautomator XML (from `adb shell uiautomator dump`) into normalized
|
|
6
8
|
// Element[]. We hand-roll a tiny tag scanner rather than pulling in an XML
|
|
7
9
|
// dependency: uiautomator output is regular and entity-escaped, so a
|
|
@@ -99,6 +101,18 @@ function isInteresting(el) {
|
|
|
99
101
|
return true;
|
|
100
102
|
return false;
|
|
101
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* The dump's `<hierarchy rotation="N">` (Surface.ROTATION_*), or undefined if absent.
|
|
106
|
+
*
|
|
107
|
+
* `adb shell wm size` always reports the device's NATURAL orientation, so this is the
|
|
108
|
+
* only thing in the dump that says whether the coordinates are portrait or landscape.
|
|
109
|
+
* Exported so the driver can read it off the same XML it just fetched, rather than
|
|
110
|
+
* paying another round-trip to ask the device which way up it is.
|
|
111
|
+
*/
|
|
112
|
+
function parseRotation(xml) {
|
|
113
|
+
const m = /<hierarchy\b[^>]*\brotation="(\d+)"/.exec(xml);
|
|
114
|
+
return m ? Number(m[1]) : undefined;
|
|
115
|
+
}
|
|
102
116
|
function parseHierarchy(xml, opts = {}) {
|
|
103
117
|
const all = [];
|
|
104
118
|
const n = xml.length;
|
|
@@ -142,8 +156,13 @@ function parseHierarchy(xml, opts = {}) {
|
|
|
142
156
|
}
|
|
143
157
|
}
|
|
144
158
|
const result = opts.all ? all : all.filter(isInteresting);
|
|
159
|
+
// No screen size (the driver could not read one) → nothing is marked, which is
|
|
160
|
+
// exactly the behaviour before viewport awareness existed.
|
|
161
|
+
const vp = opts.screen ? (0, viewport_1.screenRect)((0, viewport_1.viewportFor)(opts.screen, parseRotation(xml))) : undefined;
|
|
145
162
|
result.forEach((el, idx) => {
|
|
146
163
|
el.index = idx;
|
|
164
|
+
if (vp && (0, viewport_1.isOffscreen)(el.bounds, vp))
|
|
165
|
+
el.offscreen = true;
|
|
147
166
|
});
|
|
148
167
|
return result;
|
|
149
168
|
}
|
package/dist/ui/format.js
CHANGED
|
@@ -34,6 +34,10 @@ function formatInline(el) {
|
|
|
34
34
|
flags.push('selected');
|
|
35
35
|
if (!el.enabled)
|
|
36
36
|
flags.push('disabled');
|
|
37
|
+
// Shown rather than hidden: the element IS there, just scrolled away — and an
|
|
38
|
+
// action will scroll it into view. Hiding it would only make the miss mysterious.
|
|
39
|
+
if (el.offscreen)
|
|
40
|
+
flags.push('offscreen');
|
|
37
41
|
if (flags.length)
|
|
38
42
|
parts.push(flags.join(','));
|
|
39
43
|
return parts.join(' ');
|
|
@@ -67,5 +71,6 @@ function toJsonShape(el) {
|
|
|
67
71
|
password: el.password || undefined,
|
|
68
72
|
enabled: el.enabled,
|
|
69
73
|
selected: el.selected || undefined,
|
|
74
|
+
offscreen: el.offscreen || undefined,
|
|
70
75
|
};
|
|
71
76
|
}
|
package/dist/ui/ios-parse.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.IOS_UNREPORTED_STATE = void 0;
|
|
3
4
|
exports.isInteresting = isInteresting;
|
|
4
5
|
exports.parseIosHierarchy = parseIosHierarchy;
|
|
6
|
+
const viewport_1 = require("./viewport");
|
|
5
7
|
// XCUIElementType names (idb `type`) that are meaningful tap targets — used to
|
|
6
8
|
// derive `clickable`, which iOS accessibility does not expose as a flag.
|
|
7
9
|
const TAPPABLE_TYPES = new Set([
|
|
@@ -12,6 +14,23 @@ const TAPPABLE_TYPES = new Set([
|
|
|
12
14
|
const SCROLLABLE_TYPES = new Set(['ScrollView', 'Table', 'TableView', 'CollectionView', 'WebView']);
|
|
13
15
|
const CHECKABLE_TYPES = new Set(['Switch', 'Toggle', 'CheckBox', 'RadioButton']);
|
|
14
16
|
const TEXT_INPUT_TYPES = new Set(['TextField', 'SecureTextField', 'SearchField', 'TextView']);
|
|
17
|
+
/**
|
|
18
|
+
* State attributes idb does not report, so `Element` can only ever say `false`.
|
|
19
|
+
*
|
|
20
|
+
* MEASURED against the Flutter fixture's `@vk_state` screen on an iPhone 17 Pro simulator
|
|
21
|
+
* (iOS 26.5): a selected and an unselected segment came back byte-identical apart from
|
|
22
|
+
* label and frame, and a focused text field was indistinguishable from an unfocused one.
|
|
23
|
+
* The key is not merely unset — `idb ui describe-all` has no such key in its schema at all
|
|
24
|
+
* (it emits AXFrame, AXLabel, AXUniqueId, AXValue, content_required, custom_actions,
|
|
25
|
+
* enabled, frame, help, role, role_description, subrole, title, type, and nothing else),
|
|
26
|
+
* so no app can supply it and there is nothing to derive it from.
|
|
27
|
+
*
|
|
28
|
+
* Exported so `--selected` / `--focused` can be REJECTED on iOS rather than silently
|
|
29
|
+
* matching nothing — a filter that can never match is exactly the false-green failure the
|
|
30
|
+
* modifier exists to prevent. Keep this list next to the hard-coded `false`s below; if idb
|
|
31
|
+
* ever starts reporting one, delete it from here in the same change that parses it.
|
|
32
|
+
*/
|
|
33
|
+
exports.IOS_UNREPORTED_STATE = ['selected', 'focused'];
|
|
15
34
|
function str(v) {
|
|
16
35
|
return typeof v === 'string' ? v : v == null ? '' : String(v);
|
|
17
36
|
}
|
|
@@ -80,6 +99,7 @@ function buildElement(raw) {
|
|
|
80
99
|
checkable,
|
|
81
100
|
checked: checkable && isTrue(raw.AXValue),
|
|
82
101
|
focusable: false,
|
|
102
|
+
// `focused` and `selected` are not derivable — see IOS_UNREPORTED_STATE above.
|
|
83
103
|
focused: false,
|
|
84
104
|
scrollable: SCROLLABLE_TYPES.has(type),
|
|
85
105
|
enabled: raw.enabled === undefined ? true : isTrue(raw.enabled),
|
|
@@ -111,8 +131,13 @@ function isInteresting(el) {
|
|
|
111
131
|
function parseIosHierarchy(jsonText, opts = {}) {
|
|
112
132
|
const all = parseIdbJson(jsonText).map(buildElement);
|
|
113
133
|
const result = opts.all ? all : all.filter(isInteresting);
|
|
134
|
+
// idb reports no orientation, so viewportFor falls back to the max(w,h) square —
|
|
135
|
+
// exact vertically (the axis lists scroll on), deliberately permissive across.
|
|
136
|
+
const vp = opts.screen ? (0, viewport_1.screenRect)((0, viewport_1.viewportFor)(opts.screen)) : undefined;
|
|
114
137
|
result.forEach((el, idx) => {
|
|
115
138
|
el.index = idx;
|
|
139
|
+
if (vp && (0, viewport_1.isOffscreen)(el.bounds, vp))
|
|
140
|
+
el.offscreen = true;
|
|
116
141
|
});
|
|
117
142
|
return result;
|
|
118
143
|
}
|
package/dist/ui/selector.js
CHANGED
|
@@ -1,19 +1,68 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.STATE_ATTRS = void 0;
|
|
3
4
|
exports.parseSelector = parseSelector;
|
|
4
5
|
exports.matchElements = matchElements;
|
|
5
6
|
exports.resolveOne = resolveOne;
|
|
6
7
|
const errors_1 = require("../errors");
|
|
7
8
|
const format_1 = require("./format");
|
|
9
|
+
/**
|
|
10
|
+
* The element state a selector can require. Each is TRI-STATE on a Selector:
|
|
11
|
+
* unset = don't care, `true` = must be, `false` = must NOT be.
|
|
12
|
+
*
|
|
13
|
+
* The negative half is not symmetry for its own sake. A segmented control whose
|
|
14
|
+
* options share one handler *flips* on any tap, so "tap it unless it is already
|
|
15
|
+
* selected" is the only safe way to land on a known option — and a plan that
|
|
16
|
+
* cannot say that taps blind, completes either way, and passes having exercised
|
|
17
|
+
* the opposite mode.
|
|
18
|
+
*/
|
|
19
|
+
exports.STATE_ATTRS = ['enabled', 'selected', 'checked', 'focused'];
|
|
20
|
+
/** Trailing ` --selected` / ` --not-checked` / … on a selector STRING. */
|
|
21
|
+
const STATE_MODIFIER = new RegExp(`\\s+--(not-)?(${exports.STATE_ATTRS.join('|')})\\s*$`, 'i');
|
|
22
|
+
/**
|
|
23
|
+
* Peel state modifiers off the END of a selector string.
|
|
24
|
+
*
|
|
25
|
+
* Flags normally arrive as flags, but a control node's selector has nowhere to put
|
|
26
|
+
* one: `if-present` / `when` / `repeat` / `while-present` / `read` all hold a bare
|
|
27
|
+
* `selector: string` (and `swipe --on` is a flag value). Guards are exactly where
|
|
28
|
+
* "tap it only if it is not already selected" needs to be expressed, so the string
|
|
29
|
+
* itself carries them and every caller converges on this one parser:
|
|
30
|
+
*
|
|
31
|
+
* if-present "id:mode_video --not-selected" { tap id:mode_video }
|
|
32
|
+
*
|
|
33
|
+
* Only these modifiers, only at the end, and only after whitespace — so `text:--selected`
|
|
34
|
+
* and `text:a --selected b` are still plain values. A `text:` value that genuinely ENDS
|
|
35
|
+
* in " --selected" would be misread; use `--contains` on a shorter substring if you ever
|
|
36
|
+
* meet one. Note the engine interpolates `{{ctx.…}}` before parsing, so a value captured
|
|
37
|
+
* by `read` could in principle end in a modifier — end-anchoring plus the required space
|
|
38
|
+
* is what keeps that from being a practical concern.
|
|
39
|
+
*/
|
|
40
|
+
function splitStateModifiers(raw) {
|
|
41
|
+
const state = {};
|
|
42
|
+
let rest = raw;
|
|
43
|
+
for (;;) {
|
|
44
|
+
const m = STATE_MODIFIER.exec(rest);
|
|
45
|
+
if (!m)
|
|
46
|
+
return { rest, state };
|
|
47
|
+
const attr = m[2].toLowerCase();
|
|
48
|
+
const want = !m[1];
|
|
49
|
+
if (state[attr] !== undefined && state[attr] !== want) {
|
|
50
|
+
throw new errors_1.CliError(`Selector '${raw}' asks for both --${attr} and --not-${attr}.`, 2);
|
|
51
|
+
}
|
|
52
|
+
state[attr] = want;
|
|
53
|
+
rest = rest.slice(0, m.index);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
8
56
|
function parseSelector(raw, opts = {}) {
|
|
57
|
+
const { rest, state } = splitStateModifiers(raw);
|
|
9
58
|
let kind = 'text';
|
|
10
|
-
let value =
|
|
11
|
-
if (
|
|
59
|
+
let value = rest;
|
|
60
|
+
if (rest.startsWith('@')) {
|
|
12
61
|
kind = 'id';
|
|
13
|
-
value =
|
|
62
|
+
value = rest.slice(1);
|
|
14
63
|
}
|
|
15
64
|
else {
|
|
16
|
-
const m = /^(id|text|desc|class):([\s\S]*)$/.exec(
|
|
65
|
+
const m = /^(id|text|desc|class):([\s\S]*)$/.exec(rest);
|
|
17
66
|
if (m) {
|
|
18
67
|
kind = m[1];
|
|
19
68
|
value = m[2];
|
|
@@ -21,20 +70,35 @@ function parseSelector(raw, opts = {}) {
|
|
|
21
70
|
}
|
|
22
71
|
if (!value)
|
|
23
72
|
throw new errors_1.CliError(`Empty selector value in '${raw}'`, 2);
|
|
24
|
-
|
|
73
|
+
const sel = { kind, value, contains: !!opts.contains, index: opts.index, raw };
|
|
74
|
+
for (const attr of exports.STATE_ATTRS) {
|
|
75
|
+
// An explicit flag beats one embedded in the string; absent means absent, not false.
|
|
76
|
+
const want = opts[attr] !== undefined ? opts[attr] : state[attr];
|
|
77
|
+
if (want !== undefined)
|
|
78
|
+
sel[attr] = want;
|
|
79
|
+
}
|
|
80
|
+
return sel;
|
|
25
81
|
}
|
|
26
|
-
/**
|
|
82
|
+
/** Keep only elements whose state matches every attribute the selector pins.
|
|
27
83
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* app drift and sends the
|
|
37
|
-
|
|
84
|
+
* Each predicate is exactly the one a11y attribute and nothing else — `--enabled` is
|
|
85
|
+
* `enabled`, matching what Maestro's `enabled: true` means. An earlier version also
|
|
86
|
+
* required `clickable || longClickable`, reasoning that a disabled Button might report
|
|
87
|
+
* clickable=false. That was speculation and it was wrong in the direction that hurts:
|
|
88
|
+
* plenty of legitimate tap targets are CONTAINERS whose own clickable flag is false (the
|
|
89
|
+
* tappable child is inside), so the extra conjunct filtered out real elements and turned
|
|
90
|
+
* `--enabled` into a source of phantom "not found" misses — which then burned model
|
|
91
|
+
* repairs. Prefer under-filtering here: a tap on a present-but-odd element fails loudly,
|
|
92
|
+
* whereas a selector that silently matches nothing looks like app drift and sends the
|
|
93
|
+
* heal loop chasing it. Same rule for any attribute added to STATE_ATTRS — do not
|
|
94
|
+
* strengthen a predicate with a conjunct the platform reports unreliably (`--not-checked`
|
|
95
|
+
* deliberately does NOT also require `checkable`). */
|
|
96
|
+
function filterByState(elements, sel) {
|
|
97
|
+
const pinned = exports.STATE_ATTRS.filter((attr) => sel[attr] !== undefined);
|
|
98
|
+
if (pinned.length === 0)
|
|
99
|
+
return elements;
|
|
100
|
+
return elements.filter((el) => pinned.every((attr) => el[attr] === sel[attr]));
|
|
101
|
+
}
|
|
38
102
|
const norm = (s) => s.trim().toLowerCase();
|
|
39
103
|
const strip = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '');
|
|
40
104
|
/** The ordered match tiers for a selector. First tier with a hit wins. */
|
|
@@ -91,8 +155,7 @@ function tiers(sel) {
|
|
|
91
155
|
function matchElements(elements, sel) {
|
|
92
156
|
// Applied BEFORE the tier ladder, not after: filtering the candidate pool keeps a
|
|
93
157
|
// disabled exact match from shadowing an enabled partial one.
|
|
94
|
-
|
|
95
|
-
elements = elements.filter(isActionable);
|
|
158
|
+
elements = filterByState(elements, sel);
|
|
96
159
|
for (const { tier, test } of tiers(sel)) {
|
|
97
160
|
const found = elements.filter(test);
|
|
98
161
|
if (found.length === 0)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.unsupportedStateAttrs = unsupportedStateAttrs;
|
|
4
|
+
exports.assertStateSupported = assertStateSupported;
|
|
5
|
+
const errors_1 = require("../errors");
|
|
6
|
+
const selector_1 = require("./selector");
|
|
7
|
+
const ios_parse_1 = require("./ios-parse");
|
|
8
|
+
// Which state attributes a platform's accessibility backend can actually answer for.
|
|
9
|
+
//
|
|
10
|
+
// A state modifier narrows the candidate pool, so one the platform never populates does
|
|
11
|
+
// not fail — it matches NOTHING, quietly, after burning the full auto-wait window, and
|
|
12
|
+
// then reports "No element matched selector", which is a claim about the screen that
|
|
13
|
+
// isn't true. That is the same false-signal failure mode `--selected` was added to fix,
|
|
14
|
+
// so it is refused here instead: an unsupported modifier is an environment error (exit
|
|
15
|
+
// 3), the way `clearApp` and `currentApp` already refuse on iOS rather than pretending.
|
|
16
|
+
//
|
|
17
|
+
// This is a capability table, not device I/O — the per-platform truth lives with the
|
|
18
|
+
// parser that hard-codes the value (see IOS_UNREPORTED_STATE), so the two cannot drift.
|
|
19
|
+
const UNREPORTED = {
|
|
20
|
+
android: [], // uiautomator dumps all four as real node attributes
|
|
21
|
+
ios: ios_parse_1.IOS_UNREPORTED_STATE,
|
|
22
|
+
};
|
|
23
|
+
/** State attributes this selector pins that the platform cannot report. */
|
|
24
|
+
function unsupportedStateAttrs(sel, platform) {
|
|
25
|
+
const blind = UNREPORTED[platform];
|
|
26
|
+
if (blind.length === 0)
|
|
27
|
+
return [];
|
|
28
|
+
return selector_1.STATE_ATTRS.filter((attr) => sel[attr] !== undefined && blind.includes(attr));
|
|
29
|
+
}
|
|
30
|
+
/** Throw unless every state modifier on `sel` means something on `platform`. */
|
|
31
|
+
function assertStateSupported(sel, platform) {
|
|
32
|
+
const bad = unsupportedStateAttrs(sel, platform);
|
|
33
|
+
if (bad.length === 0)
|
|
34
|
+
return;
|
|
35
|
+
// Name both polarities: --not-selected is refused for the same reason as --selected,
|
|
36
|
+
// and echoing only the attribute reads as though the wrong flag was typed.
|
|
37
|
+
const named = bad.map((a) => `--${a}/--not-${a}`).join(' and ');
|
|
38
|
+
throw new errors_1.CliError(`${named} cannot be used on ${platform}: its accessibility backend does not report ` +
|
|
39
|
+
`${bad.join(' or ')}, so the selector could only ever match nothing. ` +
|
|
40
|
+
`Match on ${platform === 'ios' ? '@id, text or --enabled/--checked' : 'another attribute'} instead.`, 3);
|
|
41
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEFAULT_SWIPE_FRACTION = void 0;
|
|
4
|
+
exports.viewportFor = viewportFor;
|
|
5
|
+
exports.screenRect = screenRect;
|
|
6
|
+
exports.isOffscreen = isOffscreen;
|
|
7
|
+
exports.visibleRect = visibleRect;
|
|
8
|
+
exports.isFullyVisible = isFullyVisible;
|
|
9
|
+
exports.visibleFraction = visibleFraction;
|
|
10
|
+
exports.tapPoint = tapPoint;
|
|
11
|
+
exports.clipRegion = clipRegion;
|
|
12
|
+
exports.swipeVector = swipeVector;
|
|
13
|
+
exports.scrollSurface = scrollSurface;
|
|
14
|
+
exports.reachablePoint = reachablePoint;
|
|
15
|
+
exports.isOccluded = isOccluded;
|
|
16
|
+
exports.swipeDurationMs = swipeDurationMs;
|
|
17
|
+
exports.scrollPlan = scrollPlan;
|
|
18
|
+
/** How much of a region a directional swipe drags across, as a fraction of its span. */
|
|
19
|
+
exports.DEFAULT_SWIPE_FRACTION = 0.6;
|
|
20
|
+
/** Below this the gesture reads as a tap rather than a drag, so scrolling by less
|
|
21
|
+
* than this is refused — pressing the wrong control is exactly what we are fixing. */
|
|
22
|
+
const MIN_DRAG_PX = 40;
|
|
23
|
+
/** Bounds within a pixel of the viewport edge count as inside: uiautomator rounds,
|
|
24
|
+
* and a 1px overhang is not a reason to swipe a screen. */
|
|
25
|
+
const EDGE_TOLERANCE_PX = 1;
|
|
26
|
+
/**
|
|
27
|
+
* The screen rectangle a dump's coordinates live in.
|
|
28
|
+
*
|
|
29
|
+
* `rotation` is Android's `<hierarchy rotation="N">` (Surface.ROTATION_*): `adb shell
|
|
30
|
+
* wm size` always reports the NATURAL orientation, so on a landscape device (1/3) the
|
|
31
|
+
* axes must be swapped — without that, everything past the portrait width looks
|
|
32
|
+
* off-screen and every tap on a landscape app would try to scroll.
|
|
33
|
+
*
|
|
34
|
+
* `undefined` means no orientation signal (iOS: `idb describe` has none). Rather than
|
|
35
|
+
* guess, use the max(w,h) square: it can never be SMALLER than the true viewport, so
|
|
36
|
+
* it can only under-detect off-screen elements — i.e. degrade to today's behaviour —
|
|
37
|
+
* and never claim a visible element is out of view.
|
|
38
|
+
*/
|
|
39
|
+
function viewportFor(screen, rotation) {
|
|
40
|
+
const w = Math.max(0, Math.round(screen.width));
|
|
41
|
+
const h = Math.max(0, Math.round(screen.height));
|
|
42
|
+
if (rotation === undefined) {
|
|
43
|
+
const square = Math.max(w, h);
|
|
44
|
+
return { width: square, height: square };
|
|
45
|
+
}
|
|
46
|
+
return rotation === 1 || rotation === 3 ? { width: h, height: w } : { width: w, height: h };
|
|
47
|
+
}
|
|
48
|
+
/** The screen as a rectangle — the outermost clip region. */
|
|
49
|
+
function screenRect(vp) {
|
|
50
|
+
return { x1: 0, y1: 0, x2: vp.width, y2: vp.height };
|
|
51
|
+
}
|
|
52
|
+
/** No overlap with the region at all — there is no honest point to press. */
|
|
53
|
+
function isOffscreen(b, region) {
|
|
54
|
+
return b.x2 <= region.x1 || b.y2 <= region.y1 || b.x1 >= region.x2 || b.y1 >= region.y2;
|
|
55
|
+
}
|
|
56
|
+
/** The part of `b` inside the region (empty rect when there is no overlap). */
|
|
57
|
+
function visibleRect(b, region) {
|
|
58
|
+
return {
|
|
59
|
+
x1: Math.max(b.x1, region.x1),
|
|
60
|
+
y1: Math.max(b.y1, region.y1),
|
|
61
|
+
x2: Math.min(b.x2, region.x2),
|
|
62
|
+
y2: Math.min(b.y2, region.y2),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** Entirely within the region — the state "scroll into view" aims for. */
|
|
66
|
+
function isFullyVisible(b, region) {
|
|
67
|
+
return (b.x1 >= region.x1 - EDGE_TOLERANCE_PX &&
|
|
68
|
+
b.y1 >= region.y1 - EDGE_TOLERANCE_PX &&
|
|
69
|
+
b.x2 <= region.x2 + EDGE_TOLERANCE_PX &&
|
|
70
|
+
b.y2 <= region.y2 + EDGE_TOLERANCE_PX);
|
|
71
|
+
}
|
|
72
|
+
const area = (b) => Math.max(0, b.x2 - b.x1) * Math.max(0, b.y2 - b.y1);
|
|
73
|
+
/** 0..1 — how much of the element the region shows. 1 when fully visible (or degenerate). */
|
|
74
|
+
function visibleFraction(b, region) {
|
|
75
|
+
const total = area(b);
|
|
76
|
+
if (total <= 0)
|
|
77
|
+
return isOffscreen(b, region) ? 0 : 1;
|
|
78
|
+
return area(visibleRect(b, region)) / total;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Where a tap on this element should land.
|
|
82
|
+
*
|
|
83
|
+
* The centre of the VISIBLE part, not of the raw bounds: an element straddling an
|
|
84
|
+
* edge has a geometric centre that can sit outside it, and pressing that is the blind
|
|
85
|
+
* tap this module exists to prevent. Identical to `el.center` whenever the element is
|
|
86
|
+
* fully visible (or no region is known), so the ordinary path is unchanged.
|
|
87
|
+
*/
|
|
88
|
+
function tapPoint(el, region) {
|
|
89
|
+
if (!region || isOffscreen(el.bounds, region) || isFullyVisible(el.bounds, region))
|
|
90
|
+
return el.center;
|
|
91
|
+
const v = visibleRect(el.bounds, region);
|
|
92
|
+
return { x: Math.floor((v.x1 + v.x2) / 2), y: Math.floor((v.y1 + v.y2) / 2) };
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The region an element must be inside to be reliably tappable: the screen, further
|
|
96
|
+
* clipped to its nearest SCROLLABLE ancestor.
|
|
97
|
+
*
|
|
98
|
+
* This is what catches the case the platform hides from us. Android reports a row
|
|
99
|
+
* scrolled past the fold with bounds already clipped to the display, so nothing about
|
|
100
|
+
* the row itself says it is cut off — but it still overflows the list it lives in,
|
|
101
|
+
* and that is visible in the tree. Elements outside any scroll container (a toolbar,
|
|
102
|
+
* a sticky bar) get the plain screen, so nothing pins them into a smaller box.
|
|
103
|
+
*
|
|
104
|
+
* The hierarchy is pre-order, so an element's ancestors are the nodes before it whose
|
|
105
|
+
* depth keeps decreasing — walking back is exact, not a containment guess.
|
|
106
|
+
*/
|
|
107
|
+
function clipRegion(els, target, screen) {
|
|
108
|
+
const at = els.indexOf(target);
|
|
109
|
+
let depth = target.depth;
|
|
110
|
+
for (let i = at - 1; i >= 0; i--) {
|
|
111
|
+
const el = els[i];
|
|
112
|
+
if (el.depth >= depth)
|
|
113
|
+
continue; // not an ancestor: a sibling or its subtree
|
|
114
|
+
depth = el.depth;
|
|
115
|
+
if (el.scrollable)
|
|
116
|
+
return visibleRect(el.bounds, screen);
|
|
117
|
+
}
|
|
118
|
+
return screen;
|
|
119
|
+
}
|
|
120
|
+
/** The two points of a directional swipe across `region`, `frac` of its span. */
|
|
121
|
+
function swipeVector(region, dir, frac) {
|
|
122
|
+
const cx = Math.floor((region.x1 + region.x2) / 2);
|
|
123
|
+
const cy = Math.floor((region.y1 + region.y2) / 2);
|
|
124
|
+
const dx = Math.floor(((region.x2 - region.x1) * frac) / 2);
|
|
125
|
+
const dy = Math.floor(((region.y2 - region.y1) * frac) / 2);
|
|
126
|
+
switch (dir) {
|
|
127
|
+
case 'up':
|
|
128
|
+
return { from: { x: cx, y: cy + dy }, to: { x: cx, y: cy - dy } };
|
|
129
|
+
case 'down':
|
|
130
|
+
return { from: { x: cx, y: cy - dy }, to: { x: cx, y: cy + dy } };
|
|
131
|
+
case 'left':
|
|
132
|
+
return { from: { x: cx + dx, y: cy }, to: { x: cx - dx, y: cy } };
|
|
133
|
+
case 'right':
|
|
134
|
+
return { from: { x: cx - dx, y: cy }, to: { x: cx + dx, y: cy } };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* The region to swipe within to move `target`: its own scroll container when it has
|
|
139
|
+
* one, else the largest scrollable that spans it, else the whole screen.
|
|
140
|
+
*
|
|
141
|
+
* The ancestor is exact (see clipRegion) and therefore preferred. The span-based
|
|
142
|
+
* fallback picks by extent ALONG THE SCROLL AXIS, so a page-level vertical list wins
|
|
143
|
+
* over a horizontal carousel nested inside it. Falling back to the screen matters: a
|
|
144
|
+
* framework that never sets `scrollable` still gets a working full-screen swipe.
|
|
145
|
+
*/
|
|
146
|
+
function scrollSurface(els, target, screen, axis) {
|
|
147
|
+
const ancestor = clipRegion(els, target, screen);
|
|
148
|
+
if (ancestor !== screen)
|
|
149
|
+
return ancestor;
|
|
150
|
+
const spans = (b) => (axis === 'y' ? b.y2 - b.y1 : b.x2 - b.x1);
|
|
151
|
+
const crossCentre = axis === 'y' ? target.center.x : target.center.y;
|
|
152
|
+
const covers = (b) => axis === 'y' ? b.x1 <= crossCentre && b.x2 >= crossCentre : b.y1 <= crossCentre && b.y2 >= crossCentre;
|
|
153
|
+
let best = null;
|
|
154
|
+
for (const el of els) {
|
|
155
|
+
if (!el.scrollable || el === target)
|
|
156
|
+
continue;
|
|
157
|
+
if (isOffscreen(el.bounds, screen))
|
|
158
|
+
continue;
|
|
159
|
+
const visible = visibleRect(el.bounds, screen);
|
|
160
|
+
if (spans(visible) < MIN_DRAG_PX || !covers(el.bounds))
|
|
161
|
+
continue;
|
|
162
|
+
if (!best || spans(visible) > spans(best))
|
|
163
|
+
best = visible;
|
|
164
|
+
}
|
|
165
|
+
return best ?? screen;
|
|
166
|
+
}
|
|
167
|
+
const contains = (b, p) => p.x >= b.x1 && p.x < b.x2 && p.y >= b.y1 && p.y < b.y2;
|
|
168
|
+
/**
|
|
169
|
+
* Where a tap on this element can actually reach it: the centre of its visible part,
|
|
170
|
+
* or the nearest point to that centre which nothing else is drawn over.
|
|
171
|
+
*
|
|
172
|
+
* MEASURED, and the other half of the bug in #42: a row can be entirely inside the
|
|
173
|
+
* screen AND inside its list, yet have a sticky bottom bar drawn across its lower
|
|
174
|
+
* half — so pressing its centre presses the bar, and the step reports success having
|
|
175
|
+
* tapped a completely different control. There is no occlusion flag in any dump; what
|
|
176
|
+
* there IS, is order. The hierarchy is pre-order, later siblings paint over earlier
|
|
177
|
+
* ones, so an element listed AFTER the target which covers the point is on top of it.
|
|
178
|
+
*
|
|
179
|
+
* Ancestors are excluded for free (they precede the target); descendants are the
|
|
180
|
+
* contiguous run after it with greater depth, and they ARE the target for tapping
|
|
181
|
+
* purposes. Returns null when every candidate point is covered — the caller warns
|
|
182
|
+
* rather than failing, since this is an ordering heuristic and a wrong refusal would
|
|
183
|
+
* be worse than the tap it prevents.
|
|
184
|
+
*/
|
|
185
|
+
function reachablePoint(els, target, region) {
|
|
186
|
+
const visible = visibleRect(target.bounds, region);
|
|
187
|
+
if (visible.x2 <= visible.x1 || visible.y2 <= visible.y1)
|
|
188
|
+
return null;
|
|
189
|
+
// Centre first, then inward-inset points: any interior point of a control taps it,
|
|
190
|
+
// and staying inset keeps us off a neighbour's edge.
|
|
191
|
+
const xs = [0.5, 0.5, 0.5, 0.25, 0.75, 0.25, 0.75];
|
|
192
|
+
const ys = [0.5, 0.25, 0.75, 0.5, 0.5, 0.25, 0.75];
|
|
193
|
+
for (let i = 0; i < xs.length; i++) {
|
|
194
|
+
const p = {
|
|
195
|
+
x: Math.floor(visible.x1 + (visible.x2 - visible.x1) * xs[i]),
|
|
196
|
+
y: Math.floor(visible.y1 + (visible.y2 - visible.y1) * ys[i]),
|
|
197
|
+
};
|
|
198
|
+
if (contains(visible, p) && !isOccluded(els, target, p))
|
|
199
|
+
return p;
|
|
200
|
+
}
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
/** Is `point` covered by an element painted after `target` (and not part of it)? */
|
|
204
|
+
function isOccluded(els, target, point) {
|
|
205
|
+
const at = els.indexOf(target);
|
|
206
|
+
if (at < 0)
|
|
207
|
+
return false;
|
|
208
|
+
let i = at + 1;
|
|
209
|
+
while (i < els.length && els[i].depth > target.depth)
|
|
210
|
+
i++; // the target's own subtree is the target
|
|
211
|
+
for (; i < els.length; i++)
|
|
212
|
+
if (contains(els[i].bounds, point))
|
|
213
|
+
return true;
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* How long to spend dragging `distance` pixels.
|
|
218
|
+
*
|
|
219
|
+
* MEASURED on emulator-5554 (API 34), and the reason this is not a constant: the same
|
|
220
|
+
* 1118px swipe delivered over 400ms took the app off the screen entirely — the fling
|
|
221
|
+
* velocity is read as another gesture — while over 1500ms it scrolled cleanly and
|
|
222
|
+
* stopped where it was put. Pacing by distance keeps a scroll a DRAG: the list lands
|
|
223
|
+
* where we aimed, so the next measurement is of the screen we asked for.
|
|
224
|
+
*/
|
|
225
|
+
function swipeDurationMs(distance) {
|
|
226
|
+
const PX_PER_MS = 0.75;
|
|
227
|
+
return Math.min(2000, Math.max(300, Math.round(Math.abs(distance) / PX_PER_MS)));
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* The swipe that brings `target` into view within `surface`, or null when no useful
|
|
231
|
+
* one exists (already visible, or the move would be too small to be a drag).
|
|
232
|
+
*
|
|
233
|
+
* Aims to CENTRE the target rather than merely nudge it past the edge: an element
|
|
234
|
+
* resting against a screen edge is the case most likely to sit under a sticky app
|
|
235
|
+
* bar, which no hierarchy dump can tell us about. Centring costs nothing extra —
|
|
236
|
+
* the swipe is capped by the surface either way — and lands well clear of both.
|
|
237
|
+
*/
|
|
238
|
+
function scrollPlan(target, surface, clip, opts = {}) {
|
|
239
|
+
if (!opts.centre && isFullyVisible(target, clip))
|
|
240
|
+
return null;
|
|
241
|
+
// Distance the CONTENT must move for the target to end up centred. Positive means
|
|
242
|
+
// the target is too far down/right, so the content has to move up/left.
|
|
243
|
+
const needY = Math.round((target.y1 + target.y2) / 2 - (clip.y1 + clip.y2) / 2);
|
|
244
|
+
const needX = Math.round((target.x1 + target.x2) / 2 - (clip.x1 + clip.x2) / 2);
|
|
245
|
+
const fitsY = target.y2 - target.y1 <= clip.y2 - clip.y1;
|
|
246
|
+
const fitsX = target.x2 - target.x1 <= clip.x2 - clip.x1;
|
|
247
|
+
// Only chase an axis the element is actually clipped on: a full-width row is
|
|
248
|
+
// "outside the region in x" by its own size, which no sideways swipe fixes.
|
|
249
|
+
// `centre` overrides that — the caller wants the element moved to the middle even
|
|
250
|
+
// though it is already inside the region (something is drawn over it there).
|
|
251
|
+
const wantY = fitsY && (opts.centre || target.y1 < clip.y1 || target.y2 > clip.y2) ? Math.abs(needY) : 0;
|
|
252
|
+
const wantX = fitsX && (opts.centre || target.x1 < clip.x1 || target.x2 > clip.x2) ? Math.abs(needX) : 0;
|
|
253
|
+
if (wantY === 0 && wantX === 0)
|
|
254
|
+
return null;
|
|
255
|
+
const axis = wantY >= wantX ? 'y' : 'x';
|
|
256
|
+
const need = axis === 'y' ? needY : needX;
|
|
257
|
+
const region = visibleRect(surface, clip);
|
|
258
|
+
const span = axis === 'y' ? region.y2 - region.y1 : region.x2 - region.x1;
|
|
259
|
+
if (span < MIN_DRAG_PX * 2)
|
|
260
|
+
return null;
|
|
261
|
+
const drag = Math.min(Math.abs(need), Math.floor(span * exports.DEFAULT_SWIPE_FRACTION));
|
|
262
|
+
if (drag < MIN_DRAG_PX)
|
|
263
|
+
return null;
|
|
264
|
+
// `up` scrolls the page DOWN (content moves up), revealing what was below.
|
|
265
|
+
const direction = axis === 'y' ? (need > 0 ? 'up' : 'down') : need > 0 ? 'left' : 'right';
|
|
266
|
+
return { ...swipeVector(region, direction, drag / span), axis, direction, distance: drag };
|
|
267
|
+
}
|
package/dist/version.js
CHANGED
|
@@ -3,4 +3,4 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.VERSION = void 0;
|
|
4
4
|
// GENERATED by scripts/gen-version.mjs from package.json's "version" at build time
|
|
5
5
|
// (the `prebuild` script). Do NOT edit by hand; bump package.json instead.
|
|
6
|
-
exports.VERSION = '0.
|
|
6
|
+
exports.VERSION = '0.16.0';
|