@taqwright/taqwright 0.0.24
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/LICENSE +201 -0
- package/README.md +108 -0
- package/dist/auto-appium.d.ts +12 -0
- package/dist/auto-appium.js +77 -0
- package/dist/bin/branding.d.ts +6 -0
- package/dist/bin/branding.js +22 -0
- package/dist/bin/index.d.ts +2 -0
- package/dist/bin/index.js +321 -0
- package/dist/bin/init.d.ts +26 -0
- package/dist/bin/init.js +902 -0
- package/dist/bin/inspect.d.ts +9 -0
- package/dist/bin/inspect.js +91 -0
- package/dist/bin/report-branding.d.ts +2 -0
- package/dist/bin/report-branding.js +42 -0
- package/dist/branding-assets.d.ts +1 -0
- package/dist/branding-assets.js +1 -0
- package/dist/capabilities-helpers.d.ts +7 -0
- package/dist/capabilities-helpers.js +14 -0
- package/dist/capabilities.d.ts +6 -0
- package/dist/capabilities.js +86 -0
- package/dist/config.d.ts +17 -0
- package/dist/config.js +235 -0
- package/dist/discovery-setup.d.ts +1 -0
- package/dist/discovery-setup.js +61 -0
- package/dist/discovery.d.ts +17 -0
- package/dist/discovery.js +55 -0
- package/dist/docs/configuration.html +376 -0
- package/dist/docs/custom-reporters.html +265 -0
- package/dist/docs/docker.html +339 -0
- package/dist/docs/docs.js +173 -0
- package/dist/docs/generating-tests.html +161 -0
- package/dist/docs/images/taqwright-html-report.png +0 -0
- package/dist/docs/index.html +13 -0
- package/dist/docs/installation.html +686 -0
- package/dist/docs/parallel.html +271 -0
- package/dist/docs/running-tests.html +385 -0
- package/dist/docs/styles.css +460 -0
- package/dist/docs/writing-tests.html +565 -0
- package/dist/doctor.d.ts +33 -0
- package/dist/doctor.js +508 -0
- package/dist/expect.d.ts +38 -0
- package/dist/expect.js +96 -0
- package/dist/fixture/artifact-mode.d.ts +2 -0
- package/dist/fixture/artifact-mode.js +7 -0
- package/dist/fixture/index.d.ts +15 -0
- package/dist/fixture/index.js +324 -0
- package/dist/images/taqwright-html-report.png +0 -0
- package/dist/images/taqwright_favicon.png +0 -0
- package/dist/images/taqwright_logo.png +0 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +7 -0
- package/dist/inspector/codegen-appium.d.ts +3 -0
- package/dist/inspector/codegen-appium.js +228 -0
- package/dist/inspector/devices.d.ts +41 -0
- package/dist/inspector/devices.js +422 -0
- package/dist/inspector/locator-suggester.d.ts +23 -0
- package/dist/inspector/locator-suggester.js +539 -0
- package/dist/inspector/recorder.d.ts +128 -0
- package/dist/inspector/recorder.js +162 -0
- package/dist/inspector/server.d.ts +39 -0
- package/dist/inspector/server.js +1210 -0
- package/dist/inspector/session.d.ts +84 -0
- package/dist/inspector/session.js +262 -0
- package/dist/inspector/ui.d.ts +1 -0
- package/dist/inspector/ui.js +5508 -0
- package/dist/keys.d.ts +3 -0
- package/dist/keys.js +28 -0
- package/dist/locator/index.d.ts +206 -0
- package/dist/locator/index.js +1506 -0
- package/dist/logger.d.ts +5 -0
- package/dist/logger.js +5 -0
- package/dist/mobile/index.d.ts +130 -0
- package/dist/mobile/index.js +762 -0
- package/dist/network/android.d.ts +5 -0
- package/dist/network/android.js +87 -0
- package/dist/network/ca.d.ts +10 -0
- package/dist/network/ca.js +136 -0
- package/dist/network/har.d.ts +90 -0
- package/dist/network/har.js +101 -0
- package/dist/network/host-proxy.d.ts +16 -0
- package/dist/network/host-proxy.js +134 -0
- package/dist/network/index.d.ts +26 -0
- package/dist/network/index.js +105 -0
- package/dist/network/ios-sim.d.ts +3 -0
- package/dist/network/ios-sim.js +29 -0
- package/dist/network/proxy.d.ts +13 -0
- package/dist/network/proxy.js +310 -0
- package/dist/providers/appium.d.ts +23 -0
- package/dist/providers/appium.js +288 -0
- package/dist/providers/browserstack/index.d.ts +5 -0
- package/dist/providers/browserstack/index.js +77 -0
- package/dist/providers/browserstack/utils.d.ts +1 -0
- package/dist/providers/browserstack/utils.js +6 -0
- package/dist/providers/cloud.d.ts +53 -0
- package/dist/providers/cloud.js +117 -0
- package/dist/providers/emulator/index.d.ts +8 -0
- package/dist/providers/emulator/index.js +47 -0
- package/dist/providers/index.d.ts +10 -0
- package/dist/providers/index.js +33 -0
- package/dist/providers/lambdatest/index.d.ts +28 -0
- package/dist/providers/lambdatest/index.js +99 -0
- package/dist/providers/lambdatest/utils.d.ts +1 -0
- package/dist/providers/lambdatest/utils.js +6 -0
- package/dist/providers/local/index.d.ts +9 -0
- package/dist/providers/local/index.js +53 -0
- package/dist/providers/local-session.d.ts +16 -0
- package/dist/providers/local-session.js +55 -0
- package/dist/setup/archive.d.ts +2 -0
- package/dist/setup/archive.js +43 -0
- package/dist/setup/avd.d.ts +12 -0
- package/dist/setup/avd.js +103 -0
- package/dist/setup/index.d.ts +6 -0
- package/dist/setup/index.js +55 -0
- package/dist/setup/install-android.d.ts +2 -0
- package/dist/setup/install-android.js +70 -0
- package/dist/setup/install-appium.d.ts +1 -0
- package/dist/setup/install-appium.js +64 -0
- package/dist/setup/install-jdk.d.ts +1 -0
- package/dist/setup/install-jdk.js +58 -0
- package/dist/setup/paths.d.ts +16 -0
- package/dist/setup/paths.js +88 -0
- package/dist/setup/spawn-tool.d.ts +3 -0
- package/dist/setup/spawn-tool.js +11 -0
- package/dist/tracer/index.d.ts +34 -0
- package/dist/tracer/index.js +687 -0
- package/dist/tracer/proxy.d.ts +3 -0
- package/dist/tracer/proxy.js +60 -0
- package/dist/types/index.d.ts +189 -0
- package/dist/types/index.js +6 -0
- package/dist/utils.d.ts +2 -0
- package/dist/utils.js +37 -0
- package/package.json +79 -0
|
@@ -0,0 +1,1506 @@
|
|
|
1
|
+
import { Platform, W3C_ELEMENT_KEY, } from '../types/index.js';
|
|
2
|
+
import { ANDROID_NAMED_KEYS, IOS_NAMED_KEYS, KEY_TO_UNICODE } from '../keys.js';
|
|
3
|
+
const DEFAULT_TIMEOUT = 30_000;
|
|
4
|
+
const POLL_INTERVAL = 200;
|
|
5
|
+
export class Locator {
|
|
6
|
+
ctx;
|
|
7
|
+
strategy;
|
|
8
|
+
state;
|
|
9
|
+
pinnedIds;
|
|
10
|
+
constructor(ctx, strategy, state, pinnedIds) {
|
|
11
|
+
this.ctx = ctx;
|
|
12
|
+
this.strategy = strategy;
|
|
13
|
+
this.state = state ?? { chainOps: [] };
|
|
14
|
+
this.pinnedIds = pinnedIds;
|
|
15
|
+
}
|
|
16
|
+
static fromStrategy(ctx, strategy) {
|
|
17
|
+
return new Locator(ctx, strategy);
|
|
18
|
+
}
|
|
19
|
+
static pinnedToIds(ctx, ids) {
|
|
20
|
+
return new Locator(ctx, { using: 'xpath', value: '__pinned__' }, { chainOps: [] }, ids);
|
|
21
|
+
}
|
|
22
|
+
derive(patch) {
|
|
23
|
+
return new Locator(this.ctx, this.strategy, {
|
|
24
|
+
parent: 'parent' in patch ? patch.parent : this.state.parent,
|
|
25
|
+
chainOps: patch.chainOps ?? this.state.chainOps,
|
|
26
|
+
indexSelector: 'indexSelector' in patch ? patch.indexSelector : this.state.indexSelector,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
rebaseRoot(newRoot) {
|
|
30
|
+
if (!this.state.parent) {
|
|
31
|
+
return this.derive({ parent: newRoot });
|
|
32
|
+
}
|
|
33
|
+
return this.derive({ parent: this.state.parent.rebaseRoot(newRoot) });
|
|
34
|
+
}
|
|
35
|
+
async click(opts) {
|
|
36
|
+
const id = await this.resolveActionable(opts);
|
|
37
|
+
await this.ctx.driver.elementClick(id);
|
|
38
|
+
}
|
|
39
|
+
async tap(opts) {
|
|
40
|
+
return this.click(opts);
|
|
41
|
+
}
|
|
42
|
+
async clickWithCoordFallback(opts) {
|
|
43
|
+
const driver = this.ctx.driver;
|
|
44
|
+
const snap = async () => {
|
|
45
|
+
try {
|
|
46
|
+
return await driver.getPageSource();
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
53
|
+
const tapAt = async (px, py) => {
|
|
54
|
+
try {
|
|
55
|
+
await driver.performActions([
|
|
56
|
+
{
|
|
57
|
+
type: 'pointer',
|
|
58
|
+
id: 'finger1',
|
|
59
|
+
parameters: { pointerType: 'touch' },
|
|
60
|
+
actions: [
|
|
61
|
+
{ type: 'pointerMove', duration: 0, x: px, y: py },
|
|
62
|
+
{ type: 'pointerDown', button: 0 },
|
|
63
|
+
{ type: 'pointerUp', button: 0 },
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
]);
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
await driver.releaseActions().catch(() => { });
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const idEarly = await this.resolveOnce();
|
|
73
|
+
let hasClickableSpan = false;
|
|
74
|
+
if (idEarly) {
|
|
75
|
+
try {
|
|
76
|
+
const v = await driver.getElementAttribute(idEarly, 'text-has-clickable-span');
|
|
77
|
+
hasClickableSpan = v === 'true';
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (!hasClickableSpan) {
|
|
83
|
+
const before1 = await snap();
|
|
84
|
+
let clickThrew = false;
|
|
85
|
+
try {
|
|
86
|
+
await this.click({ timeout: opts?.timeout ?? 10_000 });
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
clickThrew = true;
|
|
90
|
+
}
|
|
91
|
+
if (!clickThrew && before1 != null) {
|
|
92
|
+
await sleep(800);
|
|
93
|
+
const after = await snap();
|
|
94
|
+
if (after != null && after !== before1)
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const id = idEarly ?? (await this.resolveOnce());
|
|
99
|
+
if (!id)
|
|
100
|
+
return;
|
|
101
|
+
let rect = null;
|
|
102
|
+
try {
|
|
103
|
+
rect = await driver.getElementRect(id);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
}
|
|
107
|
+
if (!rect || rect.width <= 0 || rect.height <= 0)
|
|
108
|
+
return;
|
|
109
|
+
const cy = Math.round(rect.y + rect.height * 0.5);
|
|
110
|
+
const probes = [
|
|
111
|
+
{ x: Math.round(rect.x + rect.width * 0.5), y: cy },
|
|
112
|
+
{ x: Math.round(rect.x + rect.width * 0.1), y: cy },
|
|
113
|
+
{ x: Math.round(rect.x + rect.width * 0.9), y: cy },
|
|
114
|
+
];
|
|
115
|
+
for (const pt of probes) {
|
|
116
|
+
const before = await snap();
|
|
117
|
+
await tapAt(pt.x, pt.y);
|
|
118
|
+
if (before == null)
|
|
119
|
+
return;
|
|
120
|
+
await sleep(800);
|
|
121
|
+
const after = await snap();
|
|
122
|
+
if (after != null && after !== before)
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
async doubleClick(opts) {
|
|
127
|
+
const id = await this.resolveActionable(opts);
|
|
128
|
+
const driver = this.ctx.driver;
|
|
129
|
+
if (this.ctx.platform === Platform.IOS) {
|
|
130
|
+
try {
|
|
131
|
+
await driver.executeScript('mobile: doubleTap', [{ elementId: id }]);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
else if (this.ctx.platform === Platform.ANDROID) {
|
|
138
|
+
try {
|
|
139
|
+
await driver.executeScript('mobile: doubleClickGesture', [{ elementId: id }]);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const rect = await driver.getElementRect(id);
|
|
146
|
+
const x = Math.floor(rect.x + rect.width / 2);
|
|
147
|
+
const y = Math.floor(rect.y + rect.height / 2);
|
|
148
|
+
try {
|
|
149
|
+
await driver.performActions([
|
|
150
|
+
{
|
|
151
|
+
type: 'pointer',
|
|
152
|
+
id: 'finger1',
|
|
153
|
+
parameters: { pointerType: 'touch' },
|
|
154
|
+
actions: [
|
|
155
|
+
{ type: 'pointerMove', duration: 0, x, y },
|
|
156
|
+
{ type: 'pointerDown', button: 0 },
|
|
157
|
+
{ type: 'pointerUp', button: 0 },
|
|
158
|
+
{ type: 'pause', duration: 50 },
|
|
159
|
+
{ type: 'pointerDown', button: 0 },
|
|
160
|
+
{ type: 'pointerUp', button: 0 },
|
|
161
|
+
],
|
|
162
|
+
},
|
|
163
|
+
]);
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
await driver.releaseActions().catch(() => { });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
async doubleTap(opts) {
|
|
170
|
+
return this.doubleClick(opts);
|
|
171
|
+
}
|
|
172
|
+
async longPress(opts) {
|
|
173
|
+
const id = await this.resolveActionable(opts);
|
|
174
|
+
const driver = this.ctx.driver;
|
|
175
|
+
const duration = opts?.duration ?? 1_000;
|
|
176
|
+
const rect = await driver.getElementRect(id);
|
|
177
|
+
const x = Math.floor(rect.x + rect.width / 2);
|
|
178
|
+
const y = Math.floor(rect.y + rect.height / 2);
|
|
179
|
+
try {
|
|
180
|
+
await driver.performActions([
|
|
181
|
+
{
|
|
182
|
+
type: 'pointer',
|
|
183
|
+
id: 'finger1',
|
|
184
|
+
parameters: { pointerType: 'touch' },
|
|
185
|
+
actions: [
|
|
186
|
+
{ type: 'pointerMove', duration: 0, x, y },
|
|
187
|
+
{ type: 'pointerDown', button: 0 },
|
|
188
|
+
{ type: 'pause', duration },
|
|
189
|
+
{ type: 'pointerUp', button: 0 },
|
|
190
|
+
],
|
|
191
|
+
},
|
|
192
|
+
]);
|
|
193
|
+
}
|
|
194
|
+
finally {
|
|
195
|
+
await driver.releaseActions().catch(() => { });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
async clear(opts) {
|
|
199
|
+
const id = await this.resolveActionable(opts);
|
|
200
|
+
try {
|
|
201
|
+
await this.ctx.driver.elementClick(id);
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
await this.ctx.driver.elementClear(id);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
async fill(value, opts) {
|
|
212
|
+
const id = await this.resolveActionable(opts);
|
|
213
|
+
try {
|
|
214
|
+
await this.typeInto(id, value);
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
if (!isInvalidElementState(err))
|
|
218
|
+
throw err;
|
|
219
|
+
const editableId = await this.findEditableNode(id);
|
|
220
|
+
if (!editableId)
|
|
221
|
+
throw err;
|
|
222
|
+
await this.typeInto(editableId, value);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
async typeInto(id, value) {
|
|
226
|
+
try {
|
|
227
|
+
await this.ctx.driver.elementClick(id);
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
await this.ctx.driver.elementClear(id);
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
}
|
|
236
|
+
await this.ctx.driver.elementSendKeys(id, value);
|
|
237
|
+
}
|
|
238
|
+
async findEditableNode(id) {
|
|
239
|
+
const descendant = await this.findEditableDescendant(id);
|
|
240
|
+
if (descendant)
|
|
241
|
+
return descendant;
|
|
242
|
+
try {
|
|
243
|
+
const active = (await this.ctx.driver.getActiveElement());
|
|
244
|
+
const activeId = active?.[W3C_ELEMENT_KEY];
|
|
245
|
+
if (activeId && activeId !== id && (await this.isEditableElement(activeId))) {
|
|
246
|
+
return activeId;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
async findEditableDescendant(id) {
|
|
254
|
+
const xpath = this.ctx.platform === Platform.IOS
|
|
255
|
+
? './/*[self::XCUIElementTypeTextField or self::XCUIElementTypeSecureTextField]'
|
|
256
|
+
: './/android.widget.EditText';
|
|
257
|
+
const refs = (await this.ctx.driver
|
|
258
|
+
.findElementsFromElement(id, 'xpath', xpath)
|
|
259
|
+
.catch(() => []));
|
|
260
|
+
return refs.length > 0 ? refs[0][W3C_ELEMENT_KEY] : null;
|
|
261
|
+
}
|
|
262
|
+
async isEditableElement(id) {
|
|
263
|
+
const isIOS = this.ctx.platform === Platform.IOS;
|
|
264
|
+
const cls = await this.ctx.driver
|
|
265
|
+
.getElementAttribute(id, isIOS ? 'type' : 'class')
|
|
266
|
+
.catch(() => null);
|
|
267
|
+
if (!cls)
|
|
268
|
+
return false;
|
|
269
|
+
return isIOS ? /TextField|SecureTextField/.test(cls) : /EditText/.test(cls);
|
|
270
|
+
}
|
|
271
|
+
async sendKeyStrokes(value, opts) {
|
|
272
|
+
return this.fill(value, opts);
|
|
273
|
+
}
|
|
274
|
+
async focus(opts) {
|
|
275
|
+
const id = await this.resolveActionable(opts);
|
|
276
|
+
await this.ctx.driver.elementClick(id);
|
|
277
|
+
}
|
|
278
|
+
async blur(_opts) {
|
|
279
|
+
try {
|
|
280
|
+
if (await this.ctx.driver.isKeyboardShown()) {
|
|
281
|
+
if (this.ctx.platform === Platform.IOS) {
|
|
282
|
+
await this.ctx.driver.executeScript('mobile: hideKeyboard', [{}]).catch(async () => {
|
|
283
|
+
await this.ctx.driver.hideKeyboard().catch(() => { });
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
await this.ctx.driver.hideKeyboard().catch(() => { });
|
|
288
|
+
}
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
}
|
|
294
|
+
try {
|
|
295
|
+
await this.ctx.driver.performActions([
|
|
296
|
+
{
|
|
297
|
+
type: 'pointer',
|
|
298
|
+
id: 'finger1',
|
|
299
|
+
parameters: { pointerType: 'touch' },
|
|
300
|
+
actions: [
|
|
301
|
+
{ type: 'pointerMove', duration: 0, x: 1, y: 1 },
|
|
302
|
+
{ type: 'pointerDown', button: 0 },
|
|
303
|
+
{ type: 'pointerUp', button: 0 },
|
|
304
|
+
],
|
|
305
|
+
},
|
|
306
|
+
]);
|
|
307
|
+
}
|
|
308
|
+
finally {
|
|
309
|
+
await this.ctx.driver.releaseActions().catch(() => { });
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
async isChecked(opts) {
|
|
313
|
+
const id = await this.resolveVisible(opts);
|
|
314
|
+
const state = await this.readChecked(id);
|
|
315
|
+
if (state === undefined) {
|
|
316
|
+
throw new Error(`isChecked: element (${this.strategy.using}=${this.strategy.value}) is not a checkable control — no readable checked/value attribute`);
|
|
317
|
+
}
|
|
318
|
+
return state;
|
|
319
|
+
}
|
|
320
|
+
async check(opts) {
|
|
321
|
+
const id = await this.resolveActionable(opts);
|
|
322
|
+
const current = await this.readChecked(id);
|
|
323
|
+
if (current === true)
|
|
324
|
+
return;
|
|
325
|
+
if (current === undefined) {
|
|
326
|
+
throw new Error(`check: element (${this.strategy.using}=${this.strategy.value}) is not a checkable control`);
|
|
327
|
+
}
|
|
328
|
+
await this.ctx.driver.elementClick(id);
|
|
329
|
+
await this.waitForChecked(true, opts);
|
|
330
|
+
}
|
|
331
|
+
async uncheck(opts) {
|
|
332
|
+
const id = await this.resolveActionable(opts);
|
|
333
|
+
const current = await this.readChecked(id);
|
|
334
|
+
if (current === false)
|
|
335
|
+
return;
|
|
336
|
+
if (current === undefined) {
|
|
337
|
+
throw new Error(`uncheck: element (${this.strategy.using}=${this.strategy.value}) is not a checkable control`);
|
|
338
|
+
}
|
|
339
|
+
await this.ctx.driver.elementClick(id);
|
|
340
|
+
await this.waitForChecked(false, opts);
|
|
341
|
+
}
|
|
342
|
+
async readChecked(id) {
|
|
343
|
+
if (this.ctx.platform === Platform.ANDROID) {
|
|
344
|
+
const v = await this.ctx.driver.getElementAttribute(id, 'checked').catch(() => null);
|
|
345
|
+
if (v === 'true')
|
|
346
|
+
return true;
|
|
347
|
+
if (v === 'false')
|
|
348
|
+
return false;
|
|
349
|
+
return undefined;
|
|
350
|
+
}
|
|
351
|
+
const v = await this.ctx.driver.getElementAttribute(id, 'value').catch(() => null);
|
|
352
|
+
if (v === '1' || v === 'true')
|
|
353
|
+
return true;
|
|
354
|
+
if (v === '0' || v === 'false' || v === '')
|
|
355
|
+
return false;
|
|
356
|
+
return undefined;
|
|
357
|
+
}
|
|
358
|
+
async waitForChecked(expected, opts) {
|
|
359
|
+
const timeout = opts?.timeout ?? this.ctx.defaultTimeout;
|
|
360
|
+
const deadline = Date.now() + timeout;
|
|
361
|
+
let last;
|
|
362
|
+
while (Date.now() < deadline) {
|
|
363
|
+
const id = await this.resolveOnce();
|
|
364
|
+
if (id) {
|
|
365
|
+
last = await this.readChecked(id);
|
|
366
|
+
if (last === expected)
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
await sleep(POLL_INTERVAL);
|
|
370
|
+
}
|
|
371
|
+
throw new Error(`${expected ? 'check' : 'uncheck'}: toggle did not reach state ${expected} within ${timeout}ms (last observed: ${last ?? '<unknown>'})`);
|
|
372
|
+
}
|
|
373
|
+
async press(key, opts) {
|
|
374
|
+
const id = await this.resolveActionable(opts);
|
|
375
|
+
await this.ctx.driver.elementClick(id).catch(() => { });
|
|
376
|
+
const unicode = KEY_TO_UNICODE[key];
|
|
377
|
+
if (unicode !== undefined) {
|
|
378
|
+
await this.ctx.driver.elementSendKeys(id, unicode);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (key.length === 1) {
|
|
382
|
+
await this.ctx.driver.elementSendKeys(id, key);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (this.ctx.platform === Platform.ANDROID) {
|
|
386
|
+
const code = ANDROID_NAMED_KEYS[key];
|
|
387
|
+
if (code !== undefined) {
|
|
388
|
+
await this.ctx.driver.executeScript('mobile: pressKey', [{ keycode: code }]);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
const name = IOS_NAMED_KEYS[key];
|
|
394
|
+
if (name !== undefined) {
|
|
395
|
+
await this.ctx.driver.executeScript('mobile: keys', [{ keys: [{ name }] }]);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
throw new Error(`press: key "${key}" is not supported on ${this.ctx.platform}. ` +
|
|
400
|
+
`Supported: single chars, ${Object.keys(KEY_TO_UNICODE).join(', ')}, ` +
|
|
401
|
+
`${Object.keys(this.ctx.platform === Platform.ANDROID ? ANDROID_NAMED_KEYS : IOS_NAMED_KEYS).join(', ')}.`);
|
|
402
|
+
}
|
|
403
|
+
async pressSequentially(text, opts) {
|
|
404
|
+
const id = await this.resolveActionable(opts);
|
|
405
|
+
await this.ctx.driver.elementClick(id).catch(() => { });
|
|
406
|
+
const delay = opts?.delay ?? 0;
|
|
407
|
+
const ctx = await this.ctx.driver.getAppiumContext().catch(() => 'NATIVE_APP');
|
|
408
|
+
const replaces = this.ctx.platform === Platform.ANDROID && String(ctx) === 'NATIVE_APP';
|
|
409
|
+
let acc = '';
|
|
410
|
+
for (const ch of text) {
|
|
411
|
+
acc += ch;
|
|
412
|
+
await this.ctx.driver.elementSendKeys(id, replaces ? acc : ch);
|
|
413
|
+
if (delay > 0)
|
|
414
|
+
await sleep(delay);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
async selectOption(value, opts) {
|
|
418
|
+
const input = typeof value === 'string' ? { label: value } : value;
|
|
419
|
+
const id = await this.resolveActionable(opts);
|
|
420
|
+
const type = await this.detectPickerType(id);
|
|
421
|
+
switch (type) {
|
|
422
|
+
case 'ios-picker-wheel':
|
|
423
|
+
return this.selectPickerWheel(id, input);
|
|
424
|
+
case 'ios-date-picker':
|
|
425
|
+
return this.selectIosDatePicker(id, input);
|
|
426
|
+
case 'android-spinner':
|
|
427
|
+
return this.selectAndroidSpinner(id, input);
|
|
428
|
+
case 'android-date-picker':
|
|
429
|
+
return this.selectAndroidDatePicker(id, input);
|
|
430
|
+
case 'android-time-picker':
|
|
431
|
+
return this.selectAndroidTimePicker(id, input);
|
|
432
|
+
case 'menu':
|
|
433
|
+
return this.selectMenuOption(id, input);
|
|
434
|
+
default:
|
|
435
|
+
throw new Error(`selectOption: element type "${type ?? 'unknown'}" is not a supported picker. ` +
|
|
436
|
+
`Supported: iOS XCUIElementTypePickerWheel / DatePicker, ` +
|
|
437
|
+
`Android Spinner / DatePicker / TimePicker, popup menus.`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
async detectPickerType(id) {
|
|
441
|
+
if (this.ctx.platform === Platform.IOS) {
|
|
442
|
+
const t = await this.ctx.driver.getElementAttribute(id, 'type').catch(() => null);
|
|
443
|
+
if (!t)
|
|
444
|
+
return undefined;
|
|
445
|
+
if (t === 'XCUIElementTypePickerWheel')
|
|
446
|
+
return 'ios-picker-wheel';
|
|
447
|
+
if (t === 'XCUIElementTypeDatePicker')
|
|
448
|
+
return 'ios-date-picker';
|
|
449
|
+
if (t === 'XCUIElementTypePicker')
|
|
450
|
+
return 'ios-date-picker';
|
|
451
|
+
return undefined;
|
|
452
|
+
}
|
|
453
|
+
const cls = await this.ctx.driver.getElementAttribute(id, 'class').catch(() => null);
|
|
454
|
+
if (!cls)
|
|
455
|
+
return undefined;
|
|
456
|
+
if (cls.endsWith('Spinner'))
|
|
457
|
+
return 'android-spinner';
|
|
458
|
+
if (cls.endsWith('DatePicker'))
|
|
459
|
+
return 'android-date-picker';
|
|
460
|
+
if (cls.endsWith('TimePicker'))
|
|
461
|
+
return 'android-time-picker';
|
|
462
|
+
if (cls.endsWith('PopupMenu') || cls.endsWith('ListPopupWindow'))
|
|
463
|
+
return 'menu';
|
|
464
|
+
return undefined;
|
|
465
|
+
}
|
|
466
|
+
async selectPickerWheel(id, input) {
|
|
467
|
+
if (input.label !== undefined) {
|
|
468
|
+
await this.ctx.driver.executeScript('mobile: setPickerValue', [
|
|
469
|
+
{ elementId: id, order: 'next', offset: 0.15, value: input.label },
|
|
470
|
+
]);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (input.index !== undefined) {
|
|
474
|
+
throw new Error('selectOption({index}) is not supported for XCUIElementTypePickerWheel; pass {label} instead');
|
|
475
|
+
}
|
|
476
|
+
throw new Error('selectOption: PickerWheel requires {label}');
|
|
477
|
+
}
|
|
478
|
+
async selectIosDatePicker(id, input) {
|
|
479
|
+
const wheels = (await this.ctx.driver.findElementsFromElement(id, 'class name', 'XCUIElementTypePickerWheel'));
|
|
480
|
+
if (!wheels || wheels.length === 0) {
|
|
481
|
+
throw new Error('selectOption: iOS DatePicker has no PickerWheel children — is it open?');
|
|
482
|
+
}
|
|
483
|
+
const parts = parseDateOrTime(input);
|
|
484
|
+
if (parts.length === 0) {
|
|
485
|
+
throw new Error('selectOption: iOS DatePicker requires {date: "YYYY-MM-DD"} or {time: "HH:mm"}');
|
|
486
|
+
}
|
|
487
|
+
if (parts.length > wheels.length) {
|
|
488
|
+
throw new Error(`selectOption: ${parts.length} value parts but DatePicker only has ${wheels.length} wheel(s)`);
|
|
489
|
+
}
|
|
490
|
+
for (let i = 0; i < parts.length; i++) {
|
|
491
|
+
const wheelId = wheels[i][W3C_ELEMENT_KEY];
|
|
492
|
+
await this.ctx.driver.executeScript('mobile: setPickerValue', [
|
|
493
|
+
{ elementId: wheelId, order: 'next', offset: 0.15, value: parts[i] },
|
|
494
|
+
]);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
async selectAndroidSpinner(id, input) {
|
|
498
|
+
if (input.label === undefined) {
|
|
499
|
+
throw new Error('selectOption: Android Spinner requires {label}');
|
|
500
|
+
}
|
|
501
|
+
await this.ctx.driver.elementClick(id);
|
|
502
|
+
await sleep(200);
|
|
503
|
+
const optionRef = await this.ctx.driver
|
|
504
|
+
.findElement('-android uiautomator', `new UiSelector().text(${JSON.stringify(input.label)})`)
|
|
505
|
+
.catch(() => null);
|
|
506
|
+
if (!optionRef) {
|
|
507
|
+
throw new Error(`selectOption: Spinner option "${input.label}" not found after opening`);
|
|
508
|
+
}
|
|
509
|
+
const optId = optionRef[W3C_ELEMENT_KEY];
|
|
510
|
+
await this.ctx.driver.elementClick(optId);
|
|
511
|
+
}
|
|
512
|
+
async selectAndroidDatePicker(id, input) {
|
|
513
|
+
if (!input.date) {
|
|
514
|
+
throw new Error('selectOption: Android DatePicker requires {date: "YYYY-MM-DD"}');
|
|
515
|
+
}
|
|
516
|
+
try {
|
|
517
|
+
await this.ctx.driver.executeScript('mobile: setDate', [
|
|
518
|
+
{ elementId: id, datestring: input.date },
|
|
519
|
+
]);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
catch {
|
|
523
|
+
}
|
|
524
|
+
throw new Error('selectOption: Android DatePicker — native `mobile: setDate` failed. ' +
|
|
525
|
+
'This picker variant likely requires manual interaction; use locator.click() + wheel-by-wheel scrolling.');
|
|
526
|
+
}
|
|
527
|
+
async selectAndroidTimePicker(id, input) {
|
|
528
|
+
if (!input.time) {
|
|
529
|
+
throw new Error('selectOption: Android TimePicker requires {time: "HH:mm"}');
|
|
530
|
+
}
|
|
531
|
+
try {
|
|
532
|
+
await this.ctx.driver.executeScript('mobile: setTime', [
|
|
533
|
+
{ elementId: id, timestring: input.time },
|
|
534
|
+
]);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
catch {
|
|
538
|
+
}
|
|
539
|
+
throw new Error('selectOption: Android TimePicker — native `mobile: setTime` failed. ' +
|
|
540
|
+
'This picker variant likely requires manual interaction.');
|
|
541
|
+
}
|
|
542
|
+
async selectMenuOption(_id, input) {
|
|
543
|
+
if (input.label === undefined) {
|
|
544
|
+
throw new Error('selectOption: menu requires {label}');
|
|
545
|
+
}
|
|
546
|
+
const optionRef = await this.ctx.driver
|
|
547
|
+
.findElement('xpath', `//*[@text=${xpathLiteral(input.label)} or @label=${xpathLiteral(input.label)} or @name=${xpathLiteral(input.label)}]`)
|
|
548
|
+
.catch(() => null);
|
|
549
|
+
if (!optionRef) {
|
|
550
|
+
throw new Error(`selectOption: menu option "${input.label}" not found`);
|
|
551
|
+
}
|
|
552
|
+
const optId = optionRef[W3C_ELEMENT_KEY];
|
|
553
|
+
await this.ctx.driver.elementClick(optId);
|
|
554
|
+
}
|
|
555
|
+
async screenshot(opts) {
|
|
556
|
+
const id = await this.resolveVisible(opts);
|
|
557
|
+
const data = await this.ctx.driver.takeElementScreenshot(id);
|
|
558
|
+
return Buffer.from(data, 'base64');
|
|
559
|
+
}
|
|
560
|
+
async scrollIntoView(opts) {
|
|
561
|
+
await this.scrollUntilVisible(opts);
|
|
562
|
+
if (opts?.bottomMargin && opts.bottomMargin > 0) {
|
|
563
|
+
await this.nudgeAboveBottom(opts.bottomMargin, opts);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
async nudgeAboveBottom(margin, opts) {
|
|
567
|
+
const driver = this.ctx.driver;
|
|
568
|
+
const xFrac = opts?.from?.x ?? 0.5;
|
|
569
|
+
for (let i = 0; i < 5; i++) {
|
|
570
|
+
const rect = await driver.getWindowRect();
|
|
571
|
+
const box = await this.boundingBox({ timeout: opts?.visibleTimeout ?? 500 }).catch(() => null);
|
|
572
|
+
if (!box)
|
|
573
|
+
return;
|
|
574
|
+
const bottom = box.y + box.height;
|
|
575
|
+
const limit = rect.height * (1 - margin);
|
|
576
|
+
if (bottom <= limit)
|
|
577
|
+
return;
|
|
578
|
+
const overshootFrac = Math.min(0.4, (bottom - limit) / rect.height);
|
|
579
|
+
const x = Math.floor(rect.width * xFrac);
|
|
580
|
+
const fromY = Math.floor(rect.height * (0.5 + overshootFrac / 2));
|
|
581
|
+
const toY = Math.floor(rect.height * (0.5 - overshootFrac / 2));
|
|
582
|
+
try {
|
|
583
|
+
await driver.performActions([
|
|
584
|
+
{
|
|
585
|
+
type: 'pointer',
|
|
586
|
+
id: 'finger1',
|
|
587
|
+
parameters: { pointerType: 'touch' },
|
|
588
|
+
actions: [
|
|
589
|
+
{ type: 'pointerMove', duration: 0, x, y: fromY },
|
|
590
|
+
{ type: 'pointerDown', button: 0 },
|
|
591
|
+
{ type: 'pointerMove', duration: opts?.duration ?? 600, x, y: toY },
|
|
592
|
+
{ type: 'pointerUp', button: 0 },
|
|
593
|
+
],
|
|
594
|
+
},
|
|
595
|
+
]);
|
|
596
|
+
}
|
|
597
|
+
finally {
|
|
598
|
+
await driver.releaseActions().catch(() => { });
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
async scrollUntilVisible(opts) {
|
|
603
|
+
const direction = opts?.direction ?? 'down';
|
|
604
|
+
const maxAttempts = opts?.maxAttempts ?? 10;
|
|
605
|
+
const visibleTimeout = opts?.visibleTimeout ?? 500;
|
|
606
|
+
const driver = this.ctx.driver;
|
|
607
|
+
if (!opts?.forceGesture) {
|
|
608
|
+
if (this.ctx.platform === Platform.IOS) {
|
|
609
|
+
const id = await this.resolveOnce();
|
|
610
|
+
if (id) {
|
|
611
|
+
try {
|
|
612
|
+
await driver.executeScript('mobile: scroll', [{ elementId: id, toVisible: true }]);
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
catch {
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
else if (this.ctx.platform === Platform.ANDROID &&
|
|
620
|
+
this.strategy.using === '-android uiautomator') {
|
|
621
|
+
const wrapped = `new UiScrollable(new UiSelector().scrollable(true).instance(0)).scrollIntoView(${this.strategy.value})`;
|
|
622
|
+
try {
|
|
623
|
+
await driver.findElement('-android uiautomator', wrapped);
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
catch {
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
631
|
+
if (await this.isVisible({ timeout: visibleTimeout }))
|
|
632
|
+
return;
|
|
633
|
+
const rect = await driver.getWindowRect();
|
|
634
|
+
const cx = Math.floor(rect.width / 2);
|
|
635
|
+
const cy = Math.floor(rect.height / 2);
|
|
636
|
+
const span = Math.floor(Math.min(rect.width, rect.height) * (opts?.distance ?? 0.4));
|
|
637
|
+
const defaultFromX = direction === 'left' ? cx - span : direction === 'right' ? cx + span : cx;
|
|
638
|
+
const defaultFromY = direction === 'up' ? cy - span : direction === 'down' ? cy + span : cy;
|
|
639
|
+
const defaultToX = direction === 'left' ? cx + span : direction === 'right' ? cx - span : cx;
|
|
640
|
+
const defaultToY = direction === 'up' ? cy + span : direction === 'down' ? cy - span : cy;
|
|
641
|
+
const fromX = opts?.from?.x !== undefined ? Math.floor(rect.width * opts.from.x) : defaultFromX;
|
|
642
|
+
const fromY = opts?.from?.y !== undefined ? Math.floor(rect.height * opts.from.y) : defaultFromY;
|
|
643
|
+
const toX = opts?.to?.x !== undefined ? Math.floor(rect.width * opts.to.x) : defaultToX;
|
|
644
|
+
const toY = opts?.to?.y !== undefined ? Math.floor(rect.height * opts.to.y) : defaultToY;
|
|
645
|
+
try {
|
|
646
|
+
await driver.performActions([
|
|
647
|
+
{
|
|
648
|
+
type: 'pointer',
|
|
649
|
+
id: 'finger1',
|
|
650
|
+
parameters: { pointerType: 'touch' },
|
|
651
|
+
actions: [
|
|
652
|
+
{ type: 'pointerMove', duration: 0, x: fromX, y: fromY },
|
|
653
|
+
{ type: 'pointerDown', button: 0 },
|
|
654
|
+
{ type: 'pointerMove', duration: opts?.duration ?? 300, x: toX, y: toY },
|
|
655
|
+
{ type: 'pointerUp', button: 0 },
|
|
656
|
+
],
|
|
657
|
+
},
|
|
658
|
+
]);
|
|
659
|
+
}
|
|
660
|
+
finally {
|
|
661
|
+
await driver.releaseActions().catch(() => { });
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
if (await this.isVisible({ timeout: visibleTimeout }))
|
|
665
|
+
return;
|
|
666
|
+
throw new Error(`scrollIntoView: locator (${this.strategy.using}=${this.strategy.value}) did not become visible after ${maxAttempts} ${direction} swipe attempt(s)`);
|
|
667
|
+
}
|
|
668
|
+
async swipeLeft(opts) {
|
|
669
|
+
return this.swipeElement('left', opts);
|
|
670
|
+
}
|
|
671
|
+
async swipeRight(opts) {
|
|
672
|
+
return this.swipeElement('right', opts);
|
|
673
|
+
}
|
|
674
|
+
async swipeUp(opts) {
|
|
675
|
+
return this.swipeElement('up', opts);
|
|
676
|
+
}
|
|
677
|
+
async swipeDown(opts) {
|
|
678
|
+
return this.swipeElement('down', opts);
|
|
679
|
+
}
|
|
680
|
+
async pinchIn(opts) {
|
|
681
|
+
const id = await this.resolveActionable(opts);
|
|
682
|
+
if (this.ctx.platform === Platform.IOS) {
|
|
683
|
+
await this.iosTwoFingerPinch(id, 'in');
|
|
684
|
+
}
|
|
685
|
+
else {
|
|
686
|
+
await this.ctx.driver.executeScript('mobile: pinchCloseGesture', [
|
|
687
|
+
{ elementId: id, percent: 0.75 },
|
|
688
|
+
]);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
async pinchOut(opts) {
|
|
692
|
+
const id = await this.resolveActionable(opts);
|
|
693
|
+
if (this.ctx.platform === Platform.IOS) {
|
|
694
|
+
await this.iosTwoFingerPinch(id, 'out');
|
|
695
|
+
}
|
|
696
|
+
else {
|
|
697
|
+
await this.ctx.driver.executeScript('mobile: pinchOpenGesture', [
|
|
698
|
+
{ elementId: id, percent: 0.75 },
|
|
699
|
+
]);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
async iosTwoFingerPinch(id, mode) {
|
|
703
|
+
const driver = this.ctx.driver;
|
|
704
|
+
const rect = await driver.getElementRect(id);
|
|
705
|
+
const cx = Math.floor(rect.x + rect.width / 2);
|
|
706
|
+
const cy = Math.floor(rect.y + rect.height / 2);
|
|
707
|
+
const span = Math.max(20, Math.floor(Math.min(rect.width, rect.height) * 0.4));
|
|
708
|
+
const close = Math.max(8, Math.floor(span * 0.25));
|
|
709
|
+
const fromOffset = mode === 'in' ? span : close;
|
|
710
|
+
const toOffset = mode === 'in' ? close : span;
|
|
711
|
+
try {
|
|
712
|
+
await driver.performActions([
|
|
713
|
+
{
|
|
714
|
+
type: 'pointer',
|
|
715
|
+
id: 'finger1',
|
|
716
|
+
parameters: { pointerType: 'touch' },
|
|
717
|
+
actions: [
|
|
718
|
+
{ type: 'pointerMove', duration: 0, x: cx - fromOffset, y: cy },
|
|
719
|
+
{ type: 'pointerDown', button: 0 },
|
|
720
|
+
{ type: 'pause', duration: 100 },
|
|
721
|
+
{ type: 'pointerMove', duration: 400, x: cx - toOffset, y: cy },
|
|
722
|
+
{ type: 'pointerUp', button: 0 },
|
|
723
|
+
],
|
|
724
|
+
},
|
|
725
|
+
{
|
|
726
|
+
type: 'pointer',
|
|
727
|
+
id: 'finger2',
|
|
728
|
+
parameters: { pointerType: 'touch' },
|
|
729
|
+
actions: [
|
|
730
|
+
{ type: 'pointerMove', duration: 0, x: cx + fromOffset, y: cy },
|
|
731
|
+
{ type: 'pointerDown', button: 0 },
|
|
732
|
+
{ type: 'pause', duration: 100 },
|
|
733
|
+
{ type: 'pointerMove', duration: 400, x: cx + toOffset, y: cy },
|
|
734
|
+
{ type: 'pointerUp', button: 0 },
|
|
735
|
+
],
|
|
736
|
+
},
|
|
737
|
+
]);
|
|
738
|
+
}
|
|
739
|
+
finally {
|
|
740
|
+
await driver.releaseActions().catch(() => {
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
async dragTo(target, opts) {
|
|
745
|
+
const sourceId = await this.resolveActionable(opts);
|
|
746
|
+
const targetId = await target.resolveActionable(opts);
|
|
747
|
+
const driver = this.ctx.driver;
|
|
748
|
+
const sourceRect = await driver.getElementRect(sourceId);
|
|
749
|
+
const targetRect = await driver.getElementRect(targetId);
|
|
750
|
+
const fromX = Math.floor(sourceRect.x + sourceRect.width * (opts?.from?.x ?? 0.5));
|
|
751
|
+
const fromY = Math.floor(sourceRect.y + sourceRect.height * (opts?.from?.y ?? 0.5));
|
|
752
|
+
const toX = Math.floor(targetRect.x + targetRect.width * (opts?.to?.x ?? 0.5));
|
|
753
|
+
const toY = Math.floor(targetRect.y + targetRect.height * (opts?.to?.y ?? 0.5));
|
|
754
|
+
await this.executeDrag({ x: fromX, y: fromY }, { x: toX, y: toY }, opts);
|
|
755
|
+
}
|
|
756
|
+
async dragToPoint(point, opts) {
|
|
757
|
+
const sourceId = await this.resolveActionable(opts);
|
|
758
|
+
const driver = this.ctx.driver;
|
|
759
|
+
const sourceRect = await driver.getElementRect(sourceId);
|
|
760
|
+
const fromX = Math.floor(sourceRect.x + sourceRect.width * (opts?.from?.x ?? 0.5));
|
|
761
|
+
const fromY = Math.floor(sourceRect.y + sourceRect.height * (opts?.from?.y ?? 0.5));
|
|
762
|
+
await this.executeDrag({ x: fromX, y: fromY }, point, opts);
|
|
763
|
+
}
|
|
764
|
+
async isVisible(opts) {
|
|
765
|
+
const timeout = opts?.timeout ?? this.ctx.defaultTimeout;
|
|
766
|
+
const deadline = Date.now() + timeout;
|
|
767
|
+
while (Date.now() < deadline) {
|
|
768
|
+
const id = await this.resolveOnce();
|
|
769
|
+
if (id) {
|
|
770
|
+
try {
|
|
771
|
+
if (await this.ctx.driver.isElementDisplayed(id))
|
|
772
|
+
return true;
|
|
773
|
+
}
|
|
774
|
+
catch {
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
await sleep(POLL_INTERVAL);
|
|
778
|
+
}
|
|
779
|
+
return false;
|
|
780
|
+
}
|
|
781
|
+
async isEnabled(opts) {
|
|
782
|
+
const id = await this.resolveActionable(opts);
|
|
783
|
+
return this.ctx.driver.isElementEnabled(id);
|
|
784
|
+
}
|
|
785
|
+
async getText(opts) {
|
|
786
|
+
const id = await this.resolveVisible(opts);
|
|
787
|
+
return this.ctx.driver.getElementText(id);
|
|
788
|
+
}
|
|
789
|
+
async getValue(opts) {
|
|
790
|
+
const id = await this.resolveVisible(opts);
|
|
791
|
+
if (this.ctx.platform === Platform.ANDROID) {
|
|
792
|
+
const text = await this.ctx.driver.getElementAttribute(id, 'text');
|
|
793
|
+
if (text !== null && text !== undefined)
|
|
794
|
+
return text;
|
|
795
|
+
return (await this.ctx.driver.getElementText(id).catch(() => '')) ?? '';
|
|
796
|
+
}
|
|
797
|
+
const attr = await this.ctx.driver.getElementAttribute(id, 'value');
|
|
798
|
+
return attr ?? '';
|
|
799
|
+
}
|
|
800
|
+
async boundingBox(opts) {
|
|
801
|
+
const id = await this.resolveVisible(opts);
|
|
802
|
+
const rect = await this.ctx.driver.getElementRect(id);
|
|
803
|
+
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
|
804
|
+
}
|
|
805
|
+
async getAttribute(name, opts) {
|
|
806
|
+
const id = await this.resolveVisible(opts);
|
|
807
|
+
const v = await this.ctx.driver.getElementAttribute(id, name).catch(() => null);
|
|
808
|
+
return v ?? null;
|
|
809
|
+
}
|
|
810
|
+
async isFocused(opts) {
|
|
811
|
+
const id = await this.resolveVisible(opts);
|
|
812
|
+
if (this.ctx.platform === Platform.ANDROID) {
|
|
813
|
+
const v = await this.ctx.driver.getElementAttribute(id, 'focused').catch(() => null);
|
|
814
|
+
return v === 'true';
|
|
815
|
+
}
|
|
816
|
+
const v = await this.ctx.driver.getElementAttribute(id, 'hasKeyboardFocus').catch(() => null);
|
|
817
|
+
if (v === 'true' || v === '1')
|
|
818
|
+
return true;
|
|
819
|
+
if (v === 'false' || v === '0')
|
|
820
|
+
return false;
|
|
821
|
+
const alt = await this.ctx.driver.getElementAttribute(id, 'focused').catch(() => null);
|
|
822
|
+
return alt === 'true' || alt === '1';
|
|
823
|
+
}
|
|
824
|
+
async isEditable(opts) {
|
|
825
|
+
const id = await this.resolveVisible(opts);
|
|
826
|
+
const enabled = await this.ctx.driver.isElementEnabled(id).catch(() => false);
|
|
827
|
+
if (!enabled)
|
|
828
|
+
return false;
|
|
829
|
+
if (this.ctx.platform === Platform.ANDROID) {
|
|
830
|
+
const cls = await this.ctx.driver.getElementAttribute(id, 'class').catch(() => null);
|
|
831
|
+
return typeof cls === 'string' && cls.includes('EditText');
|
|
832
|
+
}
|
|
833
|
+
const type = await this.ctx.driver.getElementAttribute(id, 'type').catch(() => null);
|
|
834
|
+
return (type === 'XCUIElementTypeTextField' ||
|
|
835
|
+
type === 'XCUIElementTypeSecureTextField' ||
|
|
836
|
+
type === 'XCUIElementTypeTextView');
|
|
837
|
+
}
|
|
838
|
+
async isInViewport(opts) {
|
|
839
|
+
const id = await this.resolveVisible(opts);
|
|
840
|
+
try {
|
|
841
|
+
const [rect, win] = await Promise.all([
|
|
842
|
+
this.ctx.driver.getElementRect(id),
|
|
843
|
+
this.ctx.driver.getWindowRect(),
|
|
844
|
+
]);
|
|
845
|
+
if (rect.width <= 0 || rect.height <= 0)
|
|
846
|
+
return false;
|
|
847
|
+
return (rect.x < win.width &&
|
|
848
|
+
rect.x + rect.width > 0 &&
|
|
849
|
+
rect.y < win.height &&
|
|
850
|
+
rect.y + rect.height > 0);
|
|
851
|
+
}
|
|
852
|
+
catch {
|
|
853
|
+
return false;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
async isEmpty(opts) {
|
|
857
|
+
const id = await this.resolveVisible(opts);
|
|
858
|
+
try {
|
|
859
|
+
const children = (await this.ctx.driver.findElementsFromElement(id, 'xpath', './*'));
|
|
860
|
+
if (children && children.length > 0)
|
|
861
|
+
return false;
|
|
862
|
+
}
|
|
863
|
+
catch {
|
|
864
|
+
}
|
|
865
|
+
const text = await this.readElementText(id);
|
|
866
|
+
return text.length === 0;
|
|
867
|
+
}
|
|
868
|
+
async assertVisible(opts) {
|
|
869
|
+
return this.waitFor({ state: 'visible', timeout: opts?.timeout });
|
|
870
|
+
}
|
|
871
|
+
async assertHidden(opts) {
|
|
872
|
+
return this.waitFor({ state: 'hidden', timeout: opts?.timeout });
|
|
873
|
+
}
|
|
874
|
+
async assertEnabled(opts) {
|
|
875
|
+
return this.waitFor({ state: 'enabled', timeout: opts?.timeout });
|
|
876
|
+
}
|
|
877
|
+
async assertDisabled(opts) {
|
|
878
|
+
return this.waitFor({ state: 'disabled', timeout: opts?.timeout });
|
|
879
|
+
}
|
|
880
|
+
async assertChecked(opts) {
|
|
881
|
+
return this.waitForChecked(true, opts);
|
|
882
|
+
}
|
|
883
|
+
async assertUnchecked(opts) {
|
|
884
|
+
return this.waitForChecked(false, opts);
|
|
885
|
+
}
|
|
886
|
+
async assertAttached(opts) {
|
|
887
|
+
return this.waitFor({ state: 'attached', timeout: opts?.timeout });
|
|
888
|
+
}
|
|
889
|
+
async assertEditable(opts) {
|
|
890
|
+
await this.pollBooleanQuery('editable', () => this.isEditable(opts), true, opts);
|
|
891
|
+
}
|
|
892
|
+
async assertReadonly(opts) {
|
|
893
|
+
await this.pollBooleanQuery('editable', () => this.isEditable(opts), false, opts);
|
|
894
|
+
}
|
|
895
|
+
async assertFocused(opts) {
|
|
896
|
+
await this.pollBooleanQuery('focused', () => this.isFocused(opts), true, opts);
|
|
897
|
+
}
|
|
898
|
+
async assertEmpty(opts) {
|
|
899
|
+
await this.pollBooleanQuery('empty', () => this.isEmpty(opts), true, opts);
|
|
900
|
+
}
|
|
901
|
+
async assertInViewport(opts) {
|
|
902
|
+
await this.pollBooleanQuery('inViewport', () => this.isInViewport(opts), true, opts);
|
|
903
|
+
}
|
|
904
|
+
async assertCount(expected, opts) {
|
|
905
|
+
const timeout = opts?.timeout ?? this.ctx.defaultTimeout;
|
|
906
|
+
const deadline = Date.now() + timeout;
|
|
907
|
+
let last = -1;
|
|
908
|
+
while (Date.now() < deadline) {
|
|
909
|
+
try {
|
|
910
|
+
last = await this.count();
|
|
911
|
+
if (last === expected)
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
catch {
|
|
915
|
+
}
|
|
916
|
+
await sleep(POLL_INTERVAL);
|
|
917
|
+
}
|
|
918
|
+
throw new Error(`assertCount: expected ${expected} but found ${last} within ${timeout}ms ` +
|
|
919
|
+
`(${this.strategy.using}=${this.strategy.value})`);
|
|
920
|
+
}
|
|
921
|
+
async assertAttribute(name, expected, opts) {
|
|
922
|
+
const predicate = typeof expected === 'string'
|
|
923
|
+
? (v) => v === expected
|
|
924
|
+
: (v) => v !== null && expected.test(v);
|
|
925
|
+
const timeout = opts?.timeout ?? this.ctx.defaultTimeout;
|
|
926
|
+
const deadline = Date.now() + timeout;
|
|
927
|
+
let last = null;
|
|
928
|
+
while (Date.now() < deadline) {
|
|
929
|
+
const id = await this.resolveOnce();
|
|
930
|
+
if (id) {
|
|
931
|
+
last = await this.ctx.driver.getElementAttribute(id, name).catch(() => null);
|
|
932
|
+
if (predicate(last))
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
await sleep(POLL_INTERVAL);
|
|
936
|
+
}
|
|
937
|
+
const expectedStr = expected instanceof RegExp ? expected.toString() : JSON.stringify(expected);
|
|
938
|
+
throw new Error(`assertAttribute(${JSON.stringify(name)}): expected ${expectedStr} within ${timeout}ms ` +
|
|
939
|
+
`(got: ${last !== null ? JSON.stringify(last) : '<null>'})`);
|
|
940
|
+
}
|
|
941
|
+
async pollBooleanQuery(label, getter, expected, opts) {
|
|
942
|
+
const timeout = opts?.timeout ?? this.ctx.defaultTimeout;
|
|
943
|
+
const deadline = Date.now() + timeout;
|
|
944
|
+
let last;
|
|
945
|
+
while (Date.now() < deadline) {
|
|
946
|
+
try {
|
|
947
|
+
last = await getter();
|
|
948
|
+
if (last === expected)
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
catch {
|
|
952
|
+
}
|
|
953
|
+
await sleep(POLL_INTERVAL);
|
|
954
|
+
}
|
|
955
|
+
throw new Error(`assert ${label}: expected ${expected} within ${timeout}ms (last: ${last ?? '<unresolved>'})`);
|
|
956
|
+
}
|
|
957
|
+
async assertText(expected, opts) {
|
|
958
|
+
const predicate = typeof expected === 'string'
|
|
959
|
+
? (v) => v === expected
|
|
960
|
+
: (v) => expected.test(v);
|
|
961
|
+
await this.assertGetter('text', () => this.getText(), predicate, expected, opts);
|
|
962
|
+
}
|
|
963
|
+
async assertContainsText(expected, opts) {
|
|
964
|
+
await this.assertGetter('text', () => this.getText(), (v) => v.includes(expected), `contains ${JSON.stringify(expected)}`, opts);
|
|
965
|
+
}
|
|
966
|
+
async assertValue(expected, opts) {
|
|
967
|
+
const predicate = typeof expected === 'string'
|
|
968
|
+
? (v) => v === expected
|
|
969
|
+
: (v) => expected.test(v);
|
|
970
|
+
await this.assertGetter('value', () => this.getValue(), predicate, expected, opts);
|
|
971
|
+
}
|
|
972
|
+
async assertGetter(field, getter, predicate, expected, opts) {
|
|
973
|
+
const timeout = opts?.timeout ?? this.ctx.defaultTimeout;
|
|
974
|
+
const deadline = Date.now() + timeout;
|
|
975
|
+
let last;
|
|
976
|
+
while (Date.now() < deadline) {
|
|
977
|
+
try {
|
|
978
|
+
last = await getter();
|
|
979
|
+
if (predicate(last))
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
catch {
|
|
983
|
+
}
|
|
984
|
+
await sleep(POLL_INTERVAL);
|
|
985
|
+
}
|
|
986
|
+
const expectedStr = expected instanceof RegExp ? expected.toString() : JSON.stringify(expected);
|
|
987
|
+
throw new Error(`assert ${field}: expected ${expectedStr} within ${timeout}ms ` +
|
|
988
|
+
`(got: ${last !== undefined ? JSON.stringify(last) : '<unresolved>'})`);
|
|
989
|
+
}
|
|
990
|
+
async waitFor(opts) {
|
|
991
|
+
const state = opts?.state ?? 'visible';
|
|
992
|
+
const timeout = opts?.timeout ?? this.ctx.defaultTimeout;
|
|
993
|
+
const deadline = Date.now() + timeout;
|
|
994
|
+
while (Date.now() < deadline) {
|
|
995
|
+
if (await this.matchesState(state))
|
|
996
|
+
return;
|
|
997
|
+
await sleep(POLL_INTERVAL);
|
|
998
|
+
}
|
|
999
|
+
throw new Error(`waitFor: locator (${this.strategy.using}=${this.strategy.value}) did not reach state "${state}" within ${timeout}ms`);
|
|
1000
|
+
}
|
|
1001
|
+
filter(opts) {
|
|
1002
|
+
if (opts.visible === false) {
|
|
1003
|
+
throw new Error('filter({ visible: false }) is not supported; use assertHidden or filter({ hasNot: ... }) instead');
|
|
1004
|
+
}
|
|
1005
|
+
return this.derive({
|
|
1006
|
+
chainOps: [...this.state.chainOps, { kind: 'filter', filter: opts }],
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
first() {
|
|
1010
|
+
return this.derive({ indexSelector: { kind: 'first' } });
|
|
1011
|
+
}
|
|
1012
|
+
last() {
|
|
1013
|
+
return this.derive({ indexSelector: { kind: 'last' } });
|
|
1014
|
+
}
|
|
1015
|
+
nth(n) {
|
|
1016
|
+
if (!Number.isInteger(n)) {
|
|
1017
|
+
throw new Error(`Locator.nth(${n}): index must be an integer`);
|
|
1018
|
+
}
|
|
1019
|
+
return this.derive({ indexSelector: { kind: 'nth', n } });
|
|
1020
|
+
}
|
|
1021
|
+
locator(child) {
|
|
1022
|
+
let childLoc;
|
|
1023
|
+
if (child instanceof Locator) {
|
|
1024
|
+
if (child.ctx !== this.ctx) {
|
|
1025
|
+
throw new Error('locator.locator(): child Locator was created against a different Mobile/session');
|
|
1026
|
+
}
|
|
1027
|
+
childLoc = child;
|
|
1028
|
+
}
|
|
1029
|
+
else {
|
|
1030
|
+
childLoc = Locator.fromStrategy(this.ctx, child);
|
|
1031
|
+
}
|
|
1032
|
+
return childLoc.rebaseRoot(this);
|
|
1033
|
+
}
|
|
1034
|
+
and(other) {
|
|
1035
|
+
if (other.ctx !== this.ctx) {
|
|
1036
|
+
throw new Error('Locator.and(): other Locator was created against a different Mobile/session');
|
|
1037
|
+
}
|
|
1038
|
+
return this.derive({
|
|
1039
|
+
chainOps: [...this.state.chainOps, { kind: 'and', other }],
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
or(other) {
|
|
1043
|
+
if (other.ctx !== this.ctx) {
|
|
1044
|
+
throw new Error('Locator.or(): other Locator was created against a different Mobile/session');
|
|
1045
|
+
}
|
|
1046
|
+
return this.derive({
|
|
1047
|
+
chainOps: [...this.state.chainOps, { kind: 'or', other }],
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
async count() {
|
|
1051
|
+
return (await this.resolveAll()).length;
|
|
1052
|
+
}
|
|
1053
|
+
async all() {
|
|
1054
|
+
const n = (await this.resolveAll()).length;
|
|
1055
|
+
return Array.from({ length: n }, (_, i) => this.nth(i));
|
|
1056
|
+
}
|
|
1057
|
+
async allInnerTexts() {
|
|
1058
|
+
const ids = await this.resolveAll();
|
|
1059
|
+
return Promise.all(ids.map((id) => this.readElementText(id)));
|
|
1060
|
+
}
|
|
1061
|
+
async allTextContents() {
|
|
1062
|
+
return this.allInnerTexts();
|
|
1063
|
+
}
|
|
1064
|
+
async readElementText(id) {
|
|
1065
|
+
const text = await this.ctx.driver.getElementText(id).catch(() => '');
|
|
1066
|
+
if (text)
|
|
1067
|
+
return text;
|
|
1068
|
+
if (this.ctx.platform === Platform.ANDROID) {
|
|
1069
|
+
const attr = await this.ctx.driver.getElementAttribute(id, 'text').catch(() => null);
|
|
1070
|
+
if (attr !== null && attr !== undefined)
|
|
1071
|
+
return attr;
|
|
1072
|
+
}
|
|
1073
|
+
return '';
|
|
1074
|
+
}
|
|
1075
|
+
async matchesState(state) {
|
|
1076
|
+
const id = await this.resolveOnce();
|
|
1077
|
+
if (state === 'attached')
|
|
1078
|
+
return id !== null;
|
|
1079
|
+
if (state === 'hidden') {
|
|
1080
|
+
if (!id)
|
|
1081
|
+
return true;
|
|
1082
|
+
try {
|
|
1083
|
+
return !(await this.ctx.driver.isElementDisplayed(id));
|
|
1084
|
+
}
|
|
1085
|
+
catch {
|
|
1086
|
+
return true;
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
if (!id)
|
|
1090
|
+
return false;
|
|
1091
|
+
try {
|
|
1092
|
+
if (state === 'visible')
|
|
1093
|
+
return await this.ctx.driver.isElementDisplayed(id);
|
|
1094
|
+
if (state === 'enabled')
|
|
1095
|
+
return await this.ctx.driver.isElementEnabled(id);
|
|
1096
|
+
if (state === 'disabled')
|
|
1097
|
+
return !(await this.ctx.driver.isElementEnabled(id));
|
|
1098
|
+
}
|
|
1099
|
+
catch {
|
|
1100
|
+
return false;
|
|
1101
|
+
}
|
|
1102
|
+
return false;
|
|
1103
|
+
}
|
|
1104
|
+
async resolveAll() {
|
|
1105
|
+
if (this.pinnedIds !== undefined) {
|
|
1106
|
+
return [...this.pinnedIds];
|
|
1107
|
+
}
|
|
1108
|
+
let candidates = [];
|
|
1109
|
+
const shortCircuitFirst = this.state.indexSelector?.kind === 'first' &&
|
|
1110
|
+
this.state.chainOps.length === 0 &&
|
|
1111
|
+
this.strategy.textFilter === undefined;
|
|
1112
|
+
if (this.state.parent) {
|
|
1113
|
+
let parentIds;
|
|
1114
|
+
try {
|
|
1115
|
+
parentIds = await this.state.parent.resolveAll();
|
|
1116
|
+
}
|
|
1117
|
+
catch {
|
|
1118
|
+
parentIds = [];
|
|
1119
|
+
}
|
|
1120
|
+
const seen = new Set();
|
|
1121
|
+
outer: for (const pid of parentIds) {
|
|
1122
|
+
try {
|
|
1123
|
+
const refs = (await this.ctx.driver.findElementsFromElement(pid, this.strategy.using, this.strategy.value));
|
|
1124
|
+
for (const ref of refs ?? []) {
|
|
1125
|
+
const id = ref[W3C_ELEMENT_KEY];
|
|
1126
|
+
if (!seen.has(id)) {
|
|
1127
|
+
seen.add(id);
|
|
1128
|
+
candidates.push(id);
|
|
1129
|
+
if (shortCircuitFirst)
|
|
1130
|
+
break outer;
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
catch {
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
else {
|
|
1139
|
+
try {
|
|
1140
|
+
const refs = (await this.ctx.driver.findElements(this.strategy.using, this.strategy.value));
|
|
1141
|
+
candidates = (refs ?? []).map((r) => r[W3C_ELEMENT_KEY]);
|
|
1142
|
+
}
|
|
1143
|
+
catch {
|
|
1144
|
+
candidates = [];
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
if (this.strategy.textFilter !== undefined) {
|
|
1148
|
+
candidates = await this.filterIdsByText(candidates, this.strategy.textFilter, true);
|
|
1149
|
+
}
|
|
1150
|
+
for (const op of this.state.chainOps) {
|
|
1151
|
+
if (candidates.length === 0)
|
|
1152
|
+
break;
|
|
1153
|
+
if (op.kind === 'filter') {
|
|
1154
|
+
candidates = await this.applyFilterOp(candidates, op.filter);
|
|
1155
|
+
}
|
|
1156
|
+
else if (op.kind === 'and') {
|
|
1157
|
+
const otherIds = new Set(await op.other.resolveAll());
|
|
1158
|
+
candidates = candidates.filter((id) => otherIds.has(id));
|
|
1159
|
+
}
|
|
1160
|
+
else if (op.kind === 'or') {
|
|
1161
|
+
const others = await op.other.resolveAll();
|
|
1162
|
+
const seen = new Set(candidates);
|
|
1163
|
+
for (const id of others) {
|
|
1164
|
+
if (!seen.has(id)) {
|
|
1165
|
+
seen.add(id);
|
|
1166
|
+
candidates.push(id);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
if (this.state.indexSelector) {
|
|
1172
|
+
const sel = this.state.indexSelector;
|
|
1173
|
+
if (sel.kind === 'first') {
|
|
1174
|
+
return candidates.length > 0 ? [candidates[0]] : [];
|
|
1175
|
+
}
|
|
1176
|
+
if (sel.kind === 'last') {
|
|
1177
|
+
return candidates.length > 0 ? [candidates[candidates.length - 1]] : [];
|
|
1178
|
+
}
|
|
1179
|
+
const picked = candidates.at(sel.n);
|
|
1180
|
+
return picked !== undefined ? [picked] : [];
|
|
1181
|
+
}
|
|
1182
|
+
return candidates;
|
|
1183
|
+
}
|
|
1184
|
+
async resolveOnce() {
|
|
1185
|
+
try {
|
|
1186
|
+
const ids = await this.resolveAll();
|
|
1187
|
+
return ids[0] ?? null;
|
|
1188
|
+
}
|
|
1189
|
+
catch {
|
|
1190
|
+
return null;
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
async elementMatchesText(id, pattern, exact) {
|
|
1194
|
+
const matchOne = (s) => {
|
|
1195
|
+
if (s === null || s === undefined)
|
|
1196
|
+
return false;
|
|
1197
|
+
if (typeof pattern === 'string') {
|
|
1198
|
+
return exact ? s === pattern : s.includes(pattern);
|
|
1199
|
+
}
|
|
1200
|
+
return pattern.test(s);
|
|
1201
|
+
};
|
|
1202
|
+
const labelAttr = this.ctx.platform === Platform.IOS ? 'name' : 'content-desc';
|
|
1203
|
+
const probes = await Promise.all([
|
|
1204
|
+
this.ctx.driver.getElementText(id).catch(() => null),
|
|
1205
|
+
this.ctx.driver.getElementAttribute(id, 'value').catch(() => null),
|
|
1206
|
+
this.ctx.driver.getElementAttribute(id, labelAttr).catch(() => null),
|
|
1207
|
+
]);
|
|
1208
|
+
return probes.some(matchOne);
|
|
1209
|
+
}
|
|
1210
|
+
async filterIdsByText(ids, pattern, exact) {
|
|
1211
|
+
const out = [];
|
|
1212
|
+
for (const id of ids) {
|
|
1213
|
+
if (await this.elementMatchesText(id, pattern, exact))
|
|
1214
|
+
out.push(id);
|
|
1215
|
+
}
|
|
1216
|
+
return out;
|
|
1217
|
+
}
|
|
1218
|
+
async applyFilterOp(candidates, filter) {
|
|
1219
|
+
if (filter.visible === false) {
|
|
1220
|
+
throw new Error('filter({ visible: false }) is not supported; use assertHidden or filter({ hasNot: ... }) instead');
|
|
1221
|
+
}
|
|
1222
|
+
const out = [];
|
|
1223
|
+
for (const id of candidates) {
|
|
1224
|
+
if (filter.visible === true) {
|
|
1225
|
+
const ok = await this.ctx.driver.isElementDisplayed(id).catch(() => false);
|
|
1226
|
+
if (!ok)
|
|
1227
|
+
continue;
|
|
1228
|
+
}
|
|
1229
|
+
if (filter.hasText !== undefined) {
|
|
1230
|
+
if (!(await this.elementMatchesText(id, filter.hasText, false)))
|
|
1231
|
+
continue;
|
|
1232
|
+
}
|
|
1233
|
+
if (filter.hasNotText !== undefined) {
|
|
1234
|
+
if (await this.elementMatchesText(id, filter.hasNotText, false))
|
|
1235
|
+
continue;
|
|
1236
|
+
}
|
|
1237
|
+
if (filter.has) {
|
|
1238
|
+
if (!(await this.existsUnder(id, filter.has)))
|
|
1239
|
+
continue;
|
|
1240
|
+
}
|
|
1241
|
+
if (filter.hasNot) {
|
|
1242
|
+
if (await this.existsUnder(id, filter.hasNot))
|
|
1243
|
+
continue;
|
|
1244
|
+
}
|
|
1245
|
+
out.push(id);
|
|
1246
|
+
}
|
|
1247
|
+
return out;
|
|
1248
|
+
}
|
|
1249
|
+
async existsUnder(parentId, child) {
|
|
1250
|
+
const pinned = Locator.pinnedToIds(this.ctx, [parentId]);
|
|
1251
|
+
const rooted = child.rebaseRoot(pinned);
|
|
1252
|
+
const ids = await rooted.resolveAll();
|
|
1253
|
+
return ids.length > 0;
|
|
1254
|
+
}
|
|
1255
|
+
async resolveVisible(opts) {
|
|
1256
|
+
await this.waitFor({ state: 'visible', timeout: opts?.timeout });
|
|
1257
|
+
const id = await this.resolveOnce();
|
|
1258
|
+
if (!id) {
|
|
1259
|
+
throw new Error(`Element (${this.strategy.using}=${this.strategy.value}) disappeared after being found`);
|
|
1260
|
+
}
|
|
1261
|
+
return id;
|
|
1262
|
+
}
|
|
1263
|
+
async resolveActionable(opts, retried = false) {
|
|
1264
|
+
const id = await this.resolveVisible(opts);
|
|
1265
|
+
try {
|
|
1266
|
+
const enabled = await this.ctx.driver.isElementEnabled(id);
|
|
1267
|
+
if (enabled)
|
|
1268
|
+
return id;
|
|
1269
|
+
throw new Error(`Element (${this.strategy.using}=${this.strategy.value}) is visible but not enabled`);
|
|
1270
|
+
}
|
|
1271
|
+
catch (err) {
|
|
1272
|
+
if (err instanceof Error && err.message.includes('not enabled'))
|
|
1273
|
+
throw err;
|
|
1274
|
+
if (retried)
|
|
1275
|
+
throw err;
|
|
1276
|
+
return this.resolveActionable(opts, true);
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
async swipeElement(direction, opts) {
|
|
1280
|
+
const id = await this.resolveActionable();
|
|
1281
|
+
const driver = this.ctx.driver;
|
|
1282
|
+
const hasOverride = opts?.from !== undefined || opts?.to !== undefined;
|
|
1283
|
+
if (!hasOverride) {
|
|
1284
|
+
if (this.ctx.platform === Platform.IOS) {
|
|
1285
|
+
if (opts?.distance === undefined) {
|
|
1286
|
+
try {
|
|
1287
|
+
await driver.executeScript('mobile: swipe', [{ elementId: id, direction }]);
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
catch {
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
else if (this.ctx.platform === Platform.ANDROID) {
|
|
1295
|
+
try {
|
|
1296
|
+
await driver.executeScript('mobile: swipeGesture', [
|
|
1297
|
+
{ elementId: id, direction, percent: opts?.distance ?? 0.4 },
|
|
1298
|
+
]);
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
catch {
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
const rect = await driver.getElementRect(id);
|
|
1306
|
+
const cx = Math.floor(rect.x + rect.width / 2);
|
|
1307
|
+
const cy = Math.floor(rect.y + rect.height / 2);
|
|
1308
|
+
const span = Math.floor(Math.min(rect.width, rect.height) * (opts?.distance ?? 0.4));
|
|
1309
|
+
const defaultFromX = direction === 'left' ? cx + span : direction === 'right' ? cx - span : cx;
|
|
1310
|
+
const defaultFromY = direction === 'up' ? cy + span : direction === 'down' ? cy - span : cy;
|
|
1311
|
+
const defaultToX = direction === 'left' ? cx - span : direction === 'right' ? cx + span : cx;
|
|
1312
|
+
const defaultToY = direction === 'up' ? cy - span : direction === 'down' ? cy + span : cy;
|
|
1313
|
+
const fromX = opts?.from?.x !== undefined ? Math.floor(rect.x + rect.width * opts.from.x) : defaultFromX;
|
|
1314
|
+
const fromY = opts?.from?.y !== undefined ? Math.floor(rect.y + rect.height * opts.from.y) : defaultFromY;
|
|
1315
|
+
const toX = opts?.to?.x !== undefined ? Math.floor(rect.x + rect.width * opts.to.x) : defaultToX;
|
|
1316
|
+
const toY = opts?.to?.y !== undefined ? Math.floor(rect.y + rect.height * opts.to.y) : defaultToY;
|
|
1317
|
+
try {
|
|
1318
|
+
await driver.performActions([
|
|
1319
|
+
{
|
|
1320
|
+
type: 'pointer',
|
|
1321
|
+
id: 'finger1',
|
|
1322
|
+
parameters: { pointerType: 'touch' },
|
|
1323
|
+
actions: [
|
|
1324
|
+
{ type: 'pointerMove', duration: 0, x: fromX, y: fromY },
|
|
1325
|
+
{ type: 'pointerDown', button: 0 },
|
|
1326
|
+
{ type: 'pointerMove', duration: opts?.duration ?? 300, x: toX, y: toY },
|
|
1327
|
+
{ type: 'pointerUp', button: 0 },
|
|
1328
|
+
],
|
|
1329
|
+
},
|
|
1330
|
+
]);
|
|
1331
|
+
}
|
|
1332
|
+
finally {
|
|
1333
|
+
await driver.releaseActions().catch(() => { });
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
async executeDrag(from, to, opts) {
|
|
1337
|
+
const driver = this.ctx.driver;
|
|
1338
|
+
const holdMs = opts?.duration ?? 500;
|
|
1339
|
+
if (this.ctx.platform === Platform.IOS) {
|
|
1340
|
+
try {
|
|
1341
|
+
await driver.executeScript('mobile: dragFromToForDuration', [
|
|
1342
|
+
{
|
|
1343
|
+
duration: holdMs / 1000,
|
|
1344
|
+
fromX: from.x,
|
|
1345
|
+
fromY: from.y,
|
|
1346
|
+
toX: to.x,
|
|
1347
|
+
toY: to.y,
|
|
1348
|
+
},
|
|
1349
|
+
]);
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
catch {
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
else if (this.ctx.platform === Platform.ANDROID) {
|
|
1356
|
+
try {
|
|
1357
|
+
await driver.executeScript('mobile: dragGesture', [
|
|
1358
|
+
{
|
|
1359
|
+
startX: from.x,
|
|
1360
|
+
startY: from.y,
|
|
1361
|
+
endX: to.x,
|
|
1362
|
+
endY: to.y,
|
|
1363
|
+
speed: opts?.speed ?? 2500,
|
|
1364
|
+
},
|
|
1365
|
+
]);
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1368
|
+
catch {
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
const moveMs = opts?.moveDuration ?? 300;
|
|
1372
|
+
try {
|
|
1373
|
+
await driver.performActions([
|
|
1374
|
+
{
|
|
1375
|
+
type: 'pointer',
|
|
1376
|
+
id: 'finger1',
|
|
1377
|
+
parameters: { pointerType: 'touch' },
|
|
1378
|
+
actions: [
|
|
1379
|
+
{ type: 'pointerMove', duration: 0, x: from.x, y: from.y },
|
|
1380
|
+
{ type: 'pointerDown', button: 0 },
|
|
1381
|
+
{ type: 'pause', duration: holdMs },
|
|
1382
|
+
{ type: 'pointerMove', duration: moveMs, x: to.x, y: to.y },
|
|
1383
|
+
{ type: 'pointerUp', button: 0 },
|
|
1384
|
+
],
|
|
1385
|
+
},
|
|
1386
|
+
]);
|
|
1387
|
+
}
|
|
1388
|
+
finally {
|
|
1389
|
+
await driver.releaseActions().catch(() => { });
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
function sleep(ms) {
|
|
1394
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1395
|
+
}
|
|
1396
|
+
function isInvalidElementState(err) {
|
|
1397
|
+
const msg = err?.message ?? String(err);
|
|
1398
|
+
return /invalid element state|Cannot set the element/i.test(msg);
|
|
1399
|
+
}
|
|
1400
|
+
function parseDateOrTime(input) {
|
|
1401
|
+
if (input.date) {
|
|
1402
|
+
const m = input.date.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
1403
|
+
if (!m) {
|
|
1404
|
+
throw new Error(`selectOption: invalid date "${input.date}" — expected YYYY-MM-DD`);
|
|
1405
|
+
}
|
|
1406
|
+
const monthIdx = Number(m[2]) - 1;
|
|
1407
|
+
const monthNames = [
|
|
1408
|
+
'January',
|
|
1409
|
+
'February',
|
|
1410
|
+
'March',
|
|
1411
|
+
'April',
|
|
1412
|
+
'May',
|
|
1413
|
+
'June',
|
|
1414
|
+
'July',
|
|
1415
|
+
'August',
|
|
1416
|
+
'September',
|
|
1417
|
+
'October',
|
|
1418
|
+
'November',
|
|
1419
|
+
'December',
|
|
1420
|
+
];
|
|
1421
|
+
return [m[1], monthNames[monthIdx] ?? m[2], m[3]];
|
|
1422
|
+
}
|
|
1423
|
+
if (input.time) {
|
|
1424
|
+
const m = input.time.match(/^(\d{1,2}):(\d{2})$/);
|
|
1425
|
+
if (!m) {
|
|
1426
|
+
throw new Error(`selectOption: invalid time "${input.time}" — expected HH:mm`);
|
|
1427
|
+
}
|
|
1428
|
+
return [m[1], m[2]];
|
|
1429
|
+
}
|
|
1430
|
+
return [];
|
|
1431
|
+
}
|
|
1432
|
+
function xpathLiteral(s) {
|
|
1433
|
+
if (!s.includes("'"))
|
|
1434
|
+
return `'${s}'`;
|
|
1435
|
+
if (!s.includes('"'))
|
|
1436
|
+
return `"${s}"`;
|
|
1437
|
+
const parts = s.split("'");
|
|
1438
|
+
const pieces = [];
|
|
1439
|
+
parts.forEach((part, i) => {
|
|
1440
|
+
if (i > 0)
|
|
1441
|
+
pieces.push(`"'"`);
|
|
1442
|
+
if (part.length > 0)
|
|
1443
|
+
pieces.push(`'${part}'`);
|
|
1444
|
+
});
|
|
1445
|
+
return `concat(${pieces.join(', ')})`;
|
|
1446
|
+
}
|
|
1447
|
+
export { DEFAULT_TIMEOUT as DEFAULT_LOCATOR_TIMEOUT };
|
|
1448
|
+
export function buildLocatorFromDescriptor(ctx, desc) {
|
|
1449
|
+
switch (desc.kind) {
|
|
1450
|
+
case 'leaf': {
|
|
1451
|
+
const strategy = { using: desc.using, value: desc.value };
|
|
1452
|
+
const tf = deserializeText(desc.textFilter);
|
|
1453
|
+
if (tf !== undefined)
|
|
1454
|
+
strategy.textFilter = tf;
|
|
1455
|
+
return Locator.fromStrategy(ctx, strategy);
|
|
1456
|
+
}
|
|
1457
|
+
case 'first':
|
|
1458
|
+
return buildLocatorFromDescriptor(ctx, desc.on).first();
|
|
1459
|
+
case 'last':
|
|
1460
|
+
return buildLocatorFromDescriptor(ctx, desc.on).last();
|
|
1461
|
+
case 'nth':
|
|
1462
|
+
return buildLocatorFromDescriptor(ctx, desc.on).nth(desc.n);
|
|
1463
|
+
case 'filter': {
|
|
1464
|
+
const base = buildLocatorFromDescriptor(ctx, desc.on);
|
|
1465
|
+
return base.filter(deserializeFilter(ctx, desc.filter));
|
|
1466
|
+
}
|
|
1467
|
+
case 'child': {
|
|
1468
|
+
const parent = buildLocatorFromDescriptor(ctx, desc.parent);
|
|
1469
|
+
const child = buildLocatorFromDescriptor(ctx, desc.child);
|
|
1470
|
+
return parent.locator(child);
|
|
1471
|
+
}
|
|
1472
|
+
case 'and': {
|
|
1473
|
+
const left = buildLocatorFromDescriptor(ctx, desc.left);
|
|
1474
|
+
const right = buildLocatorFromDescriptor(ctx, desc.right);
|
|
1475
|
+
return left.and(right);
|
|
1476
|
+
}
|
|
1477
|
+
case 'or': {
|
|
1478
|
+
const left = buildLocatorFromDescriptor(ctx, desc.left);
|
|
1479
|
+
const right = buildLocatorFromDescriptor(ctx, desc.right);
|
|
1480
|
+
return left.or(right);
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
function deserializeText(t) {
|
|
1485
|
+
if (t === undefined)
|
|
1486
|
+
return undefined;
|
|
1487
|
+
if (typeof t === 'string')
|
|
1488
|
+
return t;
|
|
1489
|
+
return new RegExp(t.regex, t.flags);
|
|
1490
|
+
}
|
|
1491
|
+
function deserializeFilter(ctx, f) {
|
|
1492
|
+
const out = {};
|
|
1493
|
+
if (f.has)
|
|
1494
|
+
out.has = buildLocatorFromDescriptor(ctx, f.has);
|
|
1495
|
+
if (f.hasNot)
|
|
1496
|
+
out.hasNot = buildLocatorFromDescriptor(ctx, f.hasNot);
|
|
1497
|
+
const ht = deserializeText(f.hasText);
|
|
1498
|
+
if (ht !== undefined)
|
|
1499
|
+
out.hasText = ht;
|
|
1500
|
+
const hnt = deserializeText(f.hasNotText);
|
|
1501
|
+
if (hnt !== undefined)
|
|
1502
|
+
out.hasNotText = hnt;
|
|
1503
|
+
if (f.visible !== undefined)
|
|
1504
|
+
out.visible = f.visible;
|
|
1505
|
+
return out;
|
|
1506
|
+
}
|