@vostride/agent-qa-android 0.0.1 → 0.1.1
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.md +105 -0
- package/NOTICE.md +11 -0
- package/dist/index.cjs +958 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +50 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.js +920 -0
- package/dist/index.js.map +1 -0
- package/package.json +57 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,920 @@
|
|
|
1
|
+
// src/adapter.ts
|
|
2
|
+
import { MobileSetupError as MobileSetupError2, parseMobileSource, MobileElementResolver, KEY_MAP, computeSwipe, computePinch, computeFingerPositions, warnIfOutOfBounds } from "@vostride/agent-qa-core";
|
|
3
|
+
|
|
4
|
+
// src/session.ts
|
|
5
|
+
import { remote } from "webdriverio";
|
|
6
|
+
import { MobileSetupError } from "@vostride/agent-qa-core";
|
|
7
|
+
async function createAndroidSession(config, farmSession) {
|
|
8
|
+
const match = config.device?.match ?? {};
|
|
9
|
+
const appiumUrl = process.env.AGENT_QA_APPIUM_URL ?? config.appiumUrl ?? "http://localhost:4723";
|
|
10
|
+
const url = new URL(appiumUrl);
|
|
11
|
+
const browserName = config.browserName ?? match.browserName;
|
|
12
|
+
const appPath = config.appPath;
|
|
13
|
+
if (browserName && appPath) {
|
|
14
|
+
throw new MobileSetupError({
|
|
15
|
+
category: "app-install",
|
|
16
|
+
message: "Mobile browser mode cannot be combined with app.path",
|
|
17
|
+
platform: "android",
|
|
18
|
+
deviceName: config.device?.name,
|
|
19
|
+
appId: appPath
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
const appStateNoReset = config.appState === "preserve" ? true : config.appState === "reset" ? false : void 0;
|
|
23
|
+
const capabilities = {
|
|
24
|
+
platformName: "Android",
|
|
25
|
+
"appium:automationName": match.automationName ?? "UiAutomator2",
|
|
26
|
+
"appium:deviceName": config.device?.name ?? "Android Emulator",
|
|
27
|
+
"appium:newCommandTimeout": config.timeouts?.test ? Math.ceil(config.timeouts.test / 1e3) : 300,
|
|
28
|
+
"appium:chromedriverAutodownload": true
|
|
29
|
+
};
|
|
30
|
+
if (match.platformVersion) capabilities["appium:platformVersion"] = match.platformVersion;
|
|
31
|
+
if (match.avd) capabilities["appium:avd"] = match.avd;
|
|
32
|
+
if (config.avd) capabilities["appium:avd"] = config.avd;
|
|
33
|
+
if (appPath) capabilities["appium:app"] = appPath;
|
|
34
|
+
if (browserName) {
|
|
35
|
+
capabilities.browserName = browserName;
|
|
36
|
+
capabilities["appium:autoWebview"] = true;
|
|
37
|
+
} else {
|
|
38
|
+
if (appStateNoReset !== void 0) capabilities["appium:noReset"] = appStateNoReset;
|
|
39
|
+
if (match.appPackage) capabilities["appium:appPackage"] = match.appPackage;
|
|
40
|
+
if (config.appPackage) capabilities["appium:appPackage"] = config.appPackage;
|
|
41
|
+
if (match.appActivity) capabilities["appium:appActivity"] = match.appActivity;
|
|
42
|
+
if (config.appActivity) capabilities["appium:appActivity"] = config.appActivity;
|
|
43
|
+
}
|
|
44
|
+
for (const key of Object.keys(capabilities)) {
|
|
45
|
+
if (capabilities[key] === void 0) {
|
|
46
|
+
delete capabilities[key];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (farmSession) {
|
|
50
|
+
Object.assign(capabilities, farmSession.capabilities);
|
|
51
|
+
return remote({
|
|
52
|
+
protocol: "https",
|
|
53
|
+
hostname: farmSession.hostname,
|
|
54
|
+
port: farmSession.port,
|
|
55
|
+
path: farmSession.path,
|
|
56
|
+
logLevel: config.verbose ? "info" : "silent",
|
|
57
|
+
capabilities
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return remote({
|
|
61
|
+
hostname: url.hostname,
|
|
62
|
+
port: parseInt(url.port || "4723", 10),
|
|
63
|
+
path: url.pathname === "/" ? "/" : url.pathname,
|
|
64
|
+
logLevel: config.verbose ? "info" : "silent",
|
|
65
|
+
capabilities
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// src/adapter.ts
|
|
70
|
+
var ANDROID_FILLABLE_ROLES = /* @__PURE__ */ new Set(["textbox", "searchbox", "edittext", "combobox"]);
|
|
71
|
+
var ANDROID_NATIVE_SELECT_ROLES = /* @__PURE__ */ new Set(["combobox", "spinbutton", "listbox", "list"]);
|
|
72
|
+
var ANDROID_NATIVE_SELECT_TYPES = /* @__PURE__ */ new Set([
|
|
73
|
+
"android.widget.Spinner",
|
|
74
|
+
"android.widget.NumberPicker",
|
|
75
|
+
"android.widget.DatePicker",
|
|
76
|
+
"android.widget.TimePicker",
|
|
77
|
+
"android.widget.ListView",
|
|
78
|
+
"androidx.recyclerview.widget.RecyclerView"
|
|
79
|
+
]);
|
|
80
|
+
async function safePerform(chain) {
|
|
81
|
+
try {
|
|
82
|
+
await chain.perform();
|
|
83
|
+
} catch (err) {
|
|
84
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
85
|
+
if (msg.includes("actions") && msg.includes("DELETE")) return;
|
|
86
|
+
throw err;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
var LOGCAT_REGEX = /^(\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2}\.\d{3})\s+(\d+)\s+(\d+)\s+([VDIWEF])\s+(.+?)\s*:\s+(.*)/;
|
|
90
|
+
var ANDROID_LEVEL_MAP = {
|
|
91
|
+
V: "debug",
|
|
92
|
+
D: "debug",
|
|
93
|
+
I: "info",
|
|
94
|
+
W: "warn",
|
|
95
|
+
E: "error",
|
|
96
|
+
F: "error"
|
|
97
|
+
};
|
|
98
|
+
function firstNonEmpty(...values) {
|
|
99
|
+
for (const value of values) {
|
|
100
|
+
const trimmed = typeof value === "string" ? value.trim() : "";
|
|
101
|
+
if (trimmed) return trimmed;
|
|
102
|
+
}
|
|
103
|
+
return void 0;
|
|
104
|
+
}
|
|
105
|
+
function isAndroidNativeSelectTarget(refData) {
|
|
106
|
+
const role = refData?.role?.toLowerCase();
|
|
107
|
+
return Boolean(
|
|
108
|
+
role && ANDROID_NATIVE_SELECT_ROLES.has(role) || refData?.nativeType && ANDROID_NATIVE_SELECT_TYPES.has(refData.nativeType)
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
function androidUiSelectorText(value) {
|
|
112
|
+
return `android=new UiSelector().text(${JSON.stringify(value)})`;
|
|
113
|
+
}
|
|
114
|
+
function androidUiSelectorDescription(value) {
|
|
115
|
+
return `android=new UiSelector().description(${JSON.stringify(value)})`;
|
|
116
|
+
}
|
|
117
|
+
async function findAndroidNativeOption(driver, value) {
|
|
118
|
+
for (const [selector, matchStrategy] of [
|
|
119
|
+
[androidUiSelectorText(value), "text"],
|
|
120
|
+
[androidUiSelectorDescription(value), "description"]
|
|
121
|
+
]) {
|
|
122
|
+
try {
|
|
123
|
+
const element = await driver.$(selector);
|
|
124
|
+
if (await element.isExisting()) {
|
|
125
|
+
return { element, matchedValue: value, matchStrategy };
|
|
126
|
+
}
|
|
127
|
+
} catch {
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
function parseLogcatLine(message, timestamp, appPid) {
|
|
133
|
+
const match = message.match(LOGCAT_REGEX);
|
|
134
|
+
if (!match) return null;
|
|
135
|
+
const [, , , pid, , levelChar, tag, text] = match;
|
|
136
|
+
if (appPid && pid !== appPid) return null;
|
|
137
|
+
return {
|
|
138
|
+
level: ANDROID_LEVEL_MAP[levelChar] ?? "debug",
|
|
139
|
+
text: `[${tag.trim()}] ${text.trim()}`,
|
|
140
|
+
timestamp
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
var WEB_OBSERVER_SCRIPT = `
|
|
144
|
+
return (function() {
|
|
145
|
+
var TAG_ROLES = {
|
|
146
|
+
A: 'link', BUTTON: 'button', INPUT: 'textbox', TEXTAREA: 'textbox',
|
|
147
|
+
SELECT: 'combobox', H1: 'heading', H2: 'heading', H3: 'heading',
|
|
148
|
+
H4: 'heading', H5: 'heading', H6: 'heading', IMG: 'image'
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
var vh = window.innerHeight;
|
|
152
|
+
var vw = window.innerWidth;
|
|
153
|
+
var refCounter = 0;
|
|
154
|
+
var elements = [];
|
|
155
|
+
var refs = {};
|
|
156
|
+
var lines = [];
|
|
157
|
+
var seen = {};
|
|
158
|
+
|
|
159
|
+
var all = document.querySelectorAll(
|
|
160
|
+
'a, button, input, textarea, select, h1, h2, h3, h4, h5, h6, img[alt], [role]'
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
for (var i = 0; i < all.length; i++) {
|
|
164
|
+
var el = all[i];
|
|
165
|
+
var rect = el.getBoundingClientRect();
|
|
166
|
+
if (rect.width === 0 && rect.height === 0) continue;
|
|
167
|
+
|
|
168
|
+
var style = window.getComputedStyle(el);
|
|
169
|
+
if (style.display === 'none' || style.visibility === 'hidden') continue;
|
|
170
|
+
|
|
171
|
+
var tag = el.tagName;
|
|
172
|
+
var ariaRole = el.getAttribute('role');
|
|
173
|
+
var role = ariaRole || TAG_ROLES[tag] || null;
|
|
174
|
+
if (!role) continue;
|
|
175
|
+
|
|
176
|
+
var name = '';
|
|
177
|
+
var ariaLabel = el.getAttribute('aria-label');
|
|
178
|
+
if (ariaLabel) {
|
|
179
|
+
name = ariaLabel;
|
|
180
|
+
} else if (tag === 'IMG') {
|
|
181
|
+
name = el.alt || '';
|
|
182
|
+
} else if (tag === 'INPUT' || tag === 'TEXTAREA') {
|
|
183
|
+
name = el.getAttribute('placeholder') || el.value || '';
|
|
184
|
+
} else {
|
|
185
|
+
name = (el.textContent || '').trim();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
name = name.slice(0, 100).replace(/[\\n\\r]+/g, ' ').trim();
|
|
189
|
+
if (!name && role !== 'textbox') continue;
|
|
190
|
+
|
|
191
|
+
var key = role + ':' + name;
|
|
192
|
+
if (seen[key]) continue;
|
|
193
|
+
seen[key] = true;
|
|
194
|
+
|
|
195
|
+
refCounter++;
|
|
196
|
+
var ref = 'e' + refCounter;
|
|
197
|
+
|
|
198
|
+
var bounds = {
|
|
199
|
+
x: Math.round(rect.left),
|
|
200
|
+
y: Math.round(rect.top),
|
|
201
|
+
width: Math.round(rect.width),
|
|
202
|
+
height: Math.round(rect.height)
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
var offscreen = bounds.y + bounds.height < 0 || bounds.y > vh;
|
|
206
|
+
|
|
207
|
+
elements.push({ ref: ref, role: role, name: name, attributes: {} });
|
|
208
|
+
refs[ref] = { role: role, name: name, bounds: bounds };
|
|
209
|
+
|
|
210
|
+
var line = '- ' + role + ' "' + name.replace(/"/g, "'") + '" [ref=' + ref + ']';
|
|
211
|
+
if (offscreen) line += ' [offscreen]';
|
|
212
|
+
lines.push(line);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
var visCount = 0, aboveCount = 0, belowCount = 0;
|
|
216
|
+
var refKeys = Object.keys(refs);
|
|
217
|
+
for (var j = 0; j < refKeys.length; j++) {
|
|
218
|
+
var b = refs[refKeys[j]].bounds;
|
|
219
|
+
if (b.y + b.height <= 0) aboveCount++;
|
|
220
|
+
else if (b.y >= vh) belowCount++;
|
|
221
|
+
else visCount++;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
var header = 'Current page: ' + location.href + '\\n';
|
|
225
|
+
header += '[Viewport: ' + vw + 'x' + vh + ']';
|
|
226
|
+
if (aboveCount > 0 || belowCount > 0) {
|
|
227
|
+
header += ' [' + visCount + ' visible, ' + aboveCount + ' above, ' + belowCount + ' below]';
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
tree: header + '\\n' + lines.join('\\n'),
|
|
232
|
+
elements: elements,
|
|
233
|
+
refs: refs,
|
|
234
|
+
url: location.href,
|
|
235
|
+
viewportHeight: vh,
|
|
236
|
+
viewportWidth: vw
|
|
237
|
+
};
|
|
238
|
+
})();
|
|
239
|
+
`;
|
|
240
|
+
var AndroidPlatformAdapter = class {
|
|
241
|
+
platform = "android";
|
|
242
|
+
driver = null;
|
|
243
|
+
lastRefs = {};
|
|
244
|
+
config = null;
|
|
245
|
+
// Tracks the active web context name (e.g. 'CHROMIUM', 'WEBVIEW_...').
|
|
246
|
+
// null means last observation was native-only.
|
|
247
|
+
activeWebContext = null;
|
|
248
|
+
// Cached so we skip redundant switchContext round-trips.
|
|
249
|
+
currentContext = null;
|
|
250
|
+
// Video recording state
|
|
251
|
+
recordingEnabled = false;
|
|
252
|
+
videoDir = null;
|
|
253
|
+
videoPath = null;
|
|
254
|
+
consoleLogs = [];
|
|
255
|
+
consoleDrainIndex = 0;
|
|
256
|
+
captureConsole = true;
|
|
257
|
+
appPackage = null;
|
|
258
|
+
appPid = null;
|
|
259
|
+
async setup(config) {
|
|
260
|
+
this.config = config;
|
|
261
|
+
this.captureConsole = config.logCapture?.console !== false;
|
|
262
|
+
this.appPackage = this.config.appPackage ?? config.device?.match?.appPackage ?? null;
|
|
263
|
+
this.driver = await createAndroidSession(this.config, config.farmSession);
|
|
264
|
+
const appPackage = this.appPackage;
|
|
265
|
+
if (appPackage && !this.config.browserName && !this.config.device?.match?.browserName) {
|
|
266
|
+
try {
|
|
267
|
+
await this.driver.activateApp(appPackage);
|
|
268
|
+
} catch (err) {
|
|
269
|
+
throw new MobileSetupError2({
|
|
270
|
+
category: "app-launch",
|
|
271
|
+
message: `Failed to launch Android app "${appPackage}": ${err instanceof Error ? err.message : String(err)}`,
|
|
272
|
+
platform: "android",
|
|
273
|
+
deviceName: config.device?.name,
|
|
274
|
+
appId: appPackage,
|
|
275
|
+
cause: err
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (this.appPackage && this.driver) {
|
|
280
|
+
try {
|
|
281
|
+
const result = String(await this.driver.execute("mobile: shell", { command: "pidof", args: [this.appPackage] }));
|
|
282
|
+
const pid = result?.trim();
|
|
283
|
+
if (pid && /^\d+$/.test(pid)) this.appPid = pid;
|
|
284
|
+
} catch {
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (config.recording?.enabled && this.driver) {
|
|
288
|
+
this.videoDir = config.recording.videoDir;
|
|
289
|
+
const videoSize = config.recording.videoSize ? `${config.recording.videoSize.width}x${config.recording.videoSize.height}` : void 0;
|
|
290
|
+
try {
|
|
291
|
+
await this.driver.startRecordingScreen({ videoSize, timeLimit: 1800, forceRestart: true });
|
|
292
|
+
this.recordingEnabled = true;
|
|
293
|
+
} catch {
|
|
294
|
+
try {
|
|
295
|
+
await this.driver.execute("mobile: startScreenRecording", {
|
|
296
|
+
videoSize,
|
|
297
|
+
timeLimit: 1800,
|
|
298
|
+
forceRestart: true
|
|
299
|
+
});
|
|
300
|
+
this.recordingEnabled = true;
|
|
301
|
+
} catch {
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
async cleanup() {
|
|
307
|
+
if (this.recordingEnabled && this.driver) {
|
|
308
|
+
try {
|
|
309
|
+
await this.stopAndSaveRecording();
|
|
310
|
+
} catch {
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
try {
|
|
314
|
+
if (this.driver) {
|
|
315
|
+
await this.driver.deleteSession();
|
|
316
|
+
}
|
|
317
|
+
} catch {
|
|
318
|
+
}
|
|
319
|
+
this.driver = null;
|
|
320
|
+
this.lastRefs = {};
|
|
321
|
+
this.config = null;
|
|
322
|
+
this.consoleLogs = [];
|
|
323
|
+
this.appPackage = null;
|
|
324
|
+
this.appPid = null;
|
|
325
|
+
this.activeWebContext = null;
|
|
326
|
+
this.currentContext = null;
|
|
327
|
+
this.recordingEnabled = false;
|
|
328
|
+
}
|
|
329
|
+
// Whether we've ever successfully observed via a web context.
|
|
330
|
+
// Once true, we know a WebView/browser exists and should retry rather than
|
|
331
|
+
// falling back to native (which returns an opaque WebView and can crash UiAutomator2).
|
|
332
|
+
hasSeenWebContext = false;
|
|
333
|
+
async observe(_options) {
|
|
334
|
+
if (!this.driver) {
|
|
335
|
+
throw new Error("AndroidPlatformAdapter not initialized \u2014 call setup() first");
|
|
336
|
+
}
|
|
337
|
+
const webState = await this.tryObserveWeb();
|
|
338
|
+
if (webState) {
|
|
339
|
+
this.hasSeenWebContext = true;
|
|
340
|
+
return webState;
|
|
341
|
+
}
|
|
342
|
+
if (this.hasSeenWebContext) {
|
|
343
|
+
for (const delay of [2e3, 3e3]) {
|
|
344
|
+
await this.driver.pause(delay);
|
|
345
|
+
const retry = await this.tryObserveWeb();
|
|
346
|
+
if (retry) return retry;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
this.activeWebContext = null;
|
|
350
|
+
try {
|
|
351
|
+
return await this.observeNative();
|
|
352
|
+
} catch {
|
|
353
|
+
return {
|
|
354
|
+
tree: "(native observation unavailable \u2014 WebView content not accessible)",
|
|
355
|
+
elements: [],
|
|
356
|
+
timestamp: Date.now(),
|
|
357
|
+
metadata: { coordSpace: "viewport", viewportWidth: 0, viewportHeight: 0 }
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
async execute(action) {
|
|
362
|
+
if (!this.driver) {
|
|
363
|
+
throw new Error("AndroidPlatformAdapter not initialized \u2014 call setup() first");
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
if (this.activeWebContext) {
|
|
367
|
+
await this.ensureContext(this.activeWebContext);
|
|
368
|
+
}
|
|
369
|
+
return await this.executeAction(action);
|
|
370
|
+
} catch (err) {
|
|
371
|
+
let screenshot;
|
|
372
|
+
try {
|
|
373
|
+
screenshot = await this.captureScreenshot();
|
|
374
|
+
} catch {
|
|
375
|
+
}
|
|
376
|
+
return {
|
|
377
|
+
success: false,
|
|
378
|
+
error: err instanceof Error ? err.message : String(err),
|
|
379
|
+
screenshot
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
async screenshot() {
|
|
384
|
+
if (this.activeWebContext) {
|
|
385
|
+
await this.ensureContext(this.activeWebContext);
|
|
386
|
+
} else {
|
|
387
|
+
try {
|
|
388
|
+
await this.ensureContext("NATIVE_APP");
|
|
389
|
+
} catch {
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
const buf = await this.captureScreenshot();
|
|
393
|
+
if (buf && this.driver) {
|
|
394
|
+
try {
|
|
395
|
+
const { getImageDimensions } = await import("@vostride/agent-qa-core");
|
|
396
|
+
const dims = await getImageDimensions(buf);
|
|
397
|
+
const { width } = await this.driver.getWindowSize();
|
|
398
|
+
if (dims && Math.abs(dims.width - width) > 1) {
|
|
399
|
+
console.warn(`[android-adapter] screenshot width ${dims.width} != window width ${width} (coord-space divergence, handled by scaleFactor per D-19/D-20)`);
|
|
400
|
+
}
|
|
401
|
+
} catch {
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return buf;
|
|
405
|
+
}
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
// Device log capture
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
async pollDeviceLogs() {
|
|
410
|
+
if (!this.captureConsole) return;
|
|
411
|
+
if (!this.driver) return;
|
|
412
|
+
try {
|
|
413
|
+
const logs = await this.driver.getLogs("logcat");
|
|
414
|
+
for (const entry of logs) {
|
|
415
|
+
const parsed = parseLogcatLine(entry.message, entry.timestamp ?? Date.now(), this.appPid);
|
|
416
|
+
if (!parsed) continue;
|
|
417
|
+
this.consoleLogs.push(parsed);
|
|
418
|
+
}
|
|
419
|
+
if (this.consoleLogs.length > 1e3) {
|
|
420
|
+
this.consoleLogs = this.consoleLogs.slice(-500);
|
|
421
|
+
}
|
|
422
|
+
} catch {
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
drainConsoleLogs(level) {
|
|
426
|
+
const newLogs = this.consoleLogs.slice(this.consoleDrainIndex);
|
|
427
|
+
this.consoleDrainIndex = this.consoleLogs.length;
|
|
428
|
+
if (!level) return newLogs;
|
|
429
|
+
return newLogs.filter((e) => e.level === level);
|
|
430
|
+
}
|
|
431
|
+
// ---------------------------------------------------------------------------
|
|
432
|
+
// Video recording
|
|
433
|
+
// ---------------------------------------------------------------------------
|
|
434
|
+
async getVideoPath() {
|
|
435
|
+
if (this.recordingEnabled && !this.videoPath && this.driver) {
|
|
436
|
+
await this.stopAndSaveRecording();
|
|
437
|
+
}
|
|
438
|
+
return this.videoPath;
|
|
439
|
+
}
|
|
440
|
+
async stopAndSaveRecording() {
|
|
441
|
+
if (!this.driver || !this.videoDir) return;
|
|
442
|
+
this.recordingEnabled = false;
|
|
443
|
+
let base64Video;
|
|
444
|
+
try {
|
|
445
|
+
base64Video = await this.driver.stopRecordingScreen();
|
|
446
|
+
} catch {
|
|
447
|
+
try {
|
|
448
|
+
base64Video = await this.driver.execute("mobile: stopScreenRecording");
|
|
449
|
+
} catch {
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (!base64Video) return;
|
|
453
|
+
const { mkdirSync, writeFileSync } = await import("fs");
|
|
454
|
+
const { join } = await import("path");
|
|
455
|
+
mkdirSync(this.videoDir, { recursive: true });
|
|
456
|
+
const filename = `android-${Date.now()}.mp4`;
|
|
457
|
+
const filePath = join(this.videoDir, filename);
|
|
458
|
+
writeFileSync(filePath, Buffer.from(base64Video, "base64"));
|
|
459
|
+
this.videoPath = filePath;
|
|
460
|
+
}
|
|
461
|
+
// ---------------------------------------------------------------------------
|
|
462
|
+
// Observation
|
|
463
|
+
// ---------------------------------------------------------------------------
|
|
464
|
+
async tryObserveWeb() {
|
|
465
|
+
const driver = this.driver;
|
|
466
|
+
try {
|
|
467
|
+
const contexts = await driver.getContexts();
|
|
468
|
+
const webCtx = contexts.find(
|
|
469
|
+
(c) => typeof c === "string" && (c.includes("CHROMIUM") || c.includes("WEBVIEW"))
|
|
470
|
+
);
|
|
471
|
+
if (!webCtx) return null;
|
|
472
|
+
await this.ensureContext(webCtx);
|
|
473
|
+
const result = await driver.execute(WEB_OBSERVER_SCRIPT);
|
|
474
|
+
if (!result || !result.elements || result.elements.length === 0) return null;
|
|
475
|
+
this.lastRefs = result.refs;
|
|
476
|
+
this.activeWebContext = webCtx;
|
|
477
|
+
return {
|
|
478
|
+
tree: result.tree,
|
|
479
|
+
elements: result.elements,
|
|
480
|
+
url: result.url,
|
|
481
|
+
timestamp: Date.now(),
|
|
482
|
+
metadata: {
|
|
483
|
+
coordSpace: "viewport",
|
|
484
|
+
viewportWidth: result.viewportWidth,
|
|
485
|
+
viewportHeight: result.viewportHeight,
|
|
486
|
+
refMap: result.refs
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
} catch {
|
|
490
|
+
return null;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
async observeNative() {
|
|
494
|
+
const driver = this.driver;
|
|
495
|
+
await this.ensureContext("NATIVE_APP");
|
|
496
|
+
const [xml, viewport] = await Promise.all([
|
|
497
|
+
driver.getPageSource(),
|
|
498
|
+
driver.getWindowSize()
|
|
499
|
+
]);
|
|
500
|
+
const parsed = parseMobileSource(xml, "android", viewport);
|
|
501
|
+
this.lastRefs = parsed.refs;
|
|
502
|
+
return {
|
|
503
|
+
tree: parsed.tree,
|
|
504
|
+
elements: parsed.elements,
|
|
505
|
+
timestamp: Date.now(),
|
|
506
|
+
metadata: { coordSpace: "viewport", viewportWidth: viewport.width, viewportHeight: viewport.height, refMap: parsed.refs }
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
// ---------------------------------------------------------------------------
|
|
510
|
+
// Context management
|
|
511
|
+
// ---------------------------------------------------------------------------
|
|
512
|
+
async ensureContext(target) {
|
|
513
|
+
if (this.currentContext === target) return;
|
|
514
|
+
try {
|
|
515
|
+
await this.driver.switchContext(target);
|
|
516
|
+
this.currentContext = target;
|
|
517
|
+
} catch {
|
|
518
|
+
this.currentContext = null;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
// ---------------------------------------------------------------------------
|
|
522
|
+
// Screenshots
|
|
523
|
+
// ---------------------------------------------------------------------------
|
|
524
|
+
async captureScreenshot() {
|
|
525
|
+
try {
|
|
526
|
+
if (this.driver) {
|
|
527
|
+
const base64 = await this.driver.takeScreenshot();
|
|
528
|
+
return Buffer.from(base64, "base64");
|
|
529
|
+
}
|
|
530
|
+
return void 0;
|
|
531
|
+
} catch {
|
|
532
|
+
return void 0;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
// ---------------------------------------------------------------------------
|
|
536
|
+
// Actions
|
|
537
|
+
// ---------------------------------------------------------------------------
|
|
538
|
+
async executeAction(action) {
|
|
539
|
+
const driver = this.driver;
|
|
540
|
+
const resolver = new MobileElementResolver(this.lastRefs);
|
|
541
|
+
const inWeb = !!this.activeWebContext;
|
|
542
|
+
switch (action.type) {
|
|
543
|
+
case "tap":
|
|
544
|
+
case "click": {
|
|
545
|
+
const { center, bounds } = resolver.resolve(action.ref);
|
|
546
|
+
if (inWeb) {
|
|
547
|
+
await driver.execute(
|
|
548
|
+
"var el = document.elementFromPoint(arguments[0], arguments[1]); if (el) el.click();",
|
|
549
|
+
center.x,
|
|
550
|
+
center.y
|
|
551
|
+
);
|
|
552
|
+
} else {
|
|
553
|
+
await safePerform(
|
|
554
|
+
driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x: Math.round(center.x), y: Math.round(center.y) }).down({ button: 0 }).up({ button: 0 })
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
return { success: true, coordinates: center, boundingBox: bounds };
|
|
558
|
+
}
|
|
559
|
+
case "fill": {
|
|
560
|
+
const { center, bounds } = resolver.resolve(action.ref);
|
|
561
|
+
const refData = this.lastRefs[action.ref];
|
|
562
|
+
const role = refData?.role?.toLowerCase();
|
|
563
|
+
if (role && !ANDROID_FILLABLE_ROLES.has(role)) {
|
|
564
|
+
return {
|
|
565
|
+
success: false,
|
|
566
|
+
error: `Element ${action.ref} is not fillable (role: ${role}). Use a textbox or input element instead.`
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
if (inWeb) {
|
|
570
|
+
await driver.execute(
|
|
571
|
+
"var el = document.elementFromPoint(arguments[0], arguments[1]);if (el) { el.focus(); el.click(); }",
|
|
572
|
+
center.x,
|
|
573
|
+
center.y
|
|
574
|
+
);
|
|
575
|
+
await driver.execute(
|
|
576
|
+
'var el = document.activeElement;if (el) { el.value = ""; el.value = arguments[0];el.dispatchEvent(new Event("input", {bubbles:true})); }',
|
|
577
|
+
action.value
|
|
578
|
+
);
|
|
579
|
+
return { success: true, coordinates: center, boundingBox: bounds };
|
|
580
|
+
}
|
|
581
|
+
await safePerform(
|
|
582
|
+
driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x: Math.round(center.x), y: Math.round(center.y) }).down({ button: 0 }).up({ button: 0 })
|
|
583
|
+
);
|
|
584
|
+
try {
|
|
585
|
+
const el = await driver.$('//*[@focused="true"]');
|
|
586
|
+
if (await el.isExisting()) {
|
|
587
|
+
await el.setValue(action.value);
|
|
588
|
+
return { success: true, coordinates: center, boundingBox: bounds };
|
|
589
|
+
}
|
|
590
|
+
} catch {
|
|
591
|
+
}
|
|
592
|
+
await driver.keys([action.value]);
|
|
593
|
+
return { success: true, coordinates: center, boundingBox: bounds };
|
|
594
|
+
}
|
|
595
|
+
case "select": {
|
|
596
|
+
const { center, bounds } = resolver.resolve(action.ref);
|
|
597
|
+
if (inWeb) {
|
|
598
|
+
await driver.execute(
|
|
599
|
+
'var el = document.elementFromPoint(arguments[0], arguments[1]);if (el) { el.value = arguments[2];el.dispatchEvent(new Event("change", {bubbles:true})); }',
|
|
600
|
+
center.x,
|
|
601
|
+
center.y,
|
|
602
|
+
action.value
|
|
603
|
+
);
|
|
604
|
+
return { success: true, coordinates: center, boundingBox: bounds };
|
|
605
|
+
} else {
|
|
606
|
+
return {
|
|
607
|
+
success: false,
|
|
608
|
+
error: "select is for HTML dropdowns. Use nativeSelect for Android native picker/dropdown/list controls, or tap/swipe/tapCoordinate for custom controls.",
|
|
609
|
+
coordinates: center,
|
|
610
|
+
boundingBox: bounds
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
case "nativeSelect": {
|
|
615
|
+
const { center, bounds } = resolver.resolve(action.ref);
|
|
616
|
+
const refData = this.lastRefs[action.ref];
|
|
617
|
+
if (!isAndroidNativeSelectTarget(refData)) {
|
|
618
|
+
return {
|
|
619
|
+
success: false,
|
|
620
|
+
error: "nativeSelect only supports native Android picker/dropdown/list controls. Use tap, swipe, or tapCoordinate for custom controls.",
|
|
621
|
+
coordinates: center,
|
|
622
|
+
boundingBox: bounds
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
await safePerform(
|
|
626
|
+
driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x: Math.round(center.x), y: Math.round(center.y) }).down({ button: 0 }).up({ button: 0 })
|
|
627
|
+
);
|
|
628
|
+
const option = await findAndroidNativeOption(driver, action.value);
|
|
629
|
+
if (!option) {
|
|
630
|
+
return {
|
|
631
|
+
success: false,
|
|
632
|
+
error: `Could not find native Android option "${action.value}". Open the control with tap, then use swipe/tap/tapCoordinate for custom controls.`,
|
|
633
|
+
coordinates: center,
|
|
634
|
+
boundingBox: bounds
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
if (typeof option.element.click === "function") {
|
|
638
|
+
await option.element.click();
|
|
639
|
+
} else {
|
|
640
|
+
return {
|
|
641
|
+
success: false,
|
|
642
|
+
error: `Could not click native Android option "${action.value}". Use tap, swipe, or tapCoordinate for custom controls.`,
|
|
643
|
+
coordinates: center,
|
|
644
|
+
boundingBox: bounds
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
return {
|
|
648
|
+
success: true,
|
|
649
|
+
coordinates: center,
|
|
650
|
+
boundingBox: bounds,
|
|
651
|
+
data: {
|
|
652
|
+
requestedValue: action.value,
|
|
653
|
+
matchedValue: option.matchedValue,
|
|
654
|
+
matchStrategy: option.matchStrategy
|
|
655
|
+
}
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
case "scroll": {
|
|
659
|
+
if (inWeb) {
|
|
660
|
+
const dx = action.scrollType === "horizontal" ? action.value : 0;
|
|
661
|
+
const dy = action.scrollType === "vertical" ? action.value : 0;
|
|
662
|
+
await driver.execute(
|
|
663
|
+
"window.scrollBy(arguments[0], arguments[1]);",
|
|
664
|
+
dx,
|
|
665
|
+
dy
|
|
666
|
+
);
|
|
667
|
+
return { success: true };
|
|
668
|
+
}
|
|
669
|
+
const { width, height } = await driver.getWindowSize();
|
|
670
|
+
const centerX = Math.round(width / 2);
|
|
671
|
+
const centerY = Math.round(height / 2);
|
|
672
|
+
const scrollStartX = centerX;
|
|
673
|
+
const scrollStartY = centerY;
|
|
674
|
+
const scrollEndX = action.scrollType === "horizontal" ? centerX - action.value : centerX;
|
|
675
|
+
const scrollEndY = action.scrollType === "vertical" ? centerY - action.value : centerY;
|
|
676
|
+
const scrollDuration = action.duration ?? 300;
|
|
677
|
+
await safePerform(
|
|
678
|
+
driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x: scrollStartX, y: scrollStartY }).down({ button: 0 }).move({ x: scrollEndX, y: scrollEndY, duration: scrollDuration }).up({ button: 0 })
|
|
679
|
+
);
|
|
680
|
+
return { success: true };
|
|
681
|
+
}
|
|
682
|
+
case "swipe": {
|
|
683
|
+
if (inWeb) {
|
|
684
|
+
await this.ensureContext("NATIVE_APP");
|
|
685
|
+
}
|
|
686
|
+
const { width, height } = await driver.getWindowSize();
|
|
687
|
+
let sx = Math.round(width / 2);
|
|
688
|
+
let sy = Math.round(height / 2);
|
|
689
|
+
if (action.ref) {
|
|
690
|
+
const { center } = resolver.resolve(action.ref);
|
|
691
|
+
sx = Math.round(center.x);
|
|
692
|
+
sy = Math.round(center.y);
|
|
693
|
+
}
|
|
694
|
+
const distance = Math.round(height * 0.6);
|
|
695
|
+
const pts = action.startX !== void 0 && action.startY !== void 0 && action.endX !== void 0 && action.endY !== void 0 ? {
|
|
696
|
+
startX: action.startX,
|
|
697
|
+
startY: action.startY,
|
|
698
|
+
endX: action.endX,
|
|
699
|
+
endY: action.endY
|
|
700
|
+
} : computeSwipe(sx, sy, action.direction, distance);
|
|
701
|
+
warnIfOutOfBounds({ x: pts.startX, y: pts.startY }, { width, height }, "swipe");
|
|
702
|
+
warnIfOutOfBounds({ x: pts.endX, y: pts.endY }, { width, height }, "swipe");
|
|
703
|
+
const swipeDuration = action.duration ?? 300;
|
|
704
|
+
await safePerform(
|
|
705
|
+
driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x: pts.startX, y: pts.startY }).down({ button: 0 }).move({ x: pts.endX, y: pts.endY, duration: swipeDuration }).up({ button: 0 })
|
|
706
|
+
);
|
|
707
|
+
if (inWeb) {
|
|
708
|
+
await this.ensureContext(this.activeWebContext);
|
|
709
|
+
}
|
|
710
|
+
return {
|
|
711
|
+
success: true,
|
|
712
|
+
startCoordinates: { x: pts.startX, y: pts.startY },
|
|
713
|
+
endCoordinates: { x: pts.endX, y: pts.endY }
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
case "pinch": {
|
|
717
|
+
const { width: pinchW, height: pinchH } = await driver.getWindowSize();
|
|
718
|
+
let pcx = Math.round(pinchW / 2);
|
|
719
|
+
let pcy = Math.round(pinchH / 2);
|
|
720
|
+
if (action.ref) {
|
|
721
|
+
const { center } = resolver.resolve(action.ref);
|
|
722
|
+
pcx = Math.round(center.x);
|
|
723
|
+
pcy = Math.round(center.y);
|
|
724
|
+
}
|
|
725
|
+
if (action.x !== void 0) pcx = action.x;
|
|
726
|
+
if (action.y !== void 0) pcy = action.y;
|
|
727
|
+
warnIfOutOfBounds({ x: pcx, y: pcy }, { width: pinchW, height: pinchH }, "pinch");
|
|
728
|
+
const { finger1Start, finger1End, finger2Start, finger2End } = computePinch(pcx, pcy, action.scale);
|
|
729
|
+
const pinchDuration = 500;
|
|
730
|
+
await driver.actions([
|
|
731
|
+
driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x: Math.round(finger1Start.x), y: Math.round(finger1Start.y) }).down({ button: 0 }).move({ x: Math.round(finger1End.x), y: Math.round(finger1End.y), duration: pinchDuration }).up({ button: 0 }),
|
|
732
|
+
driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x: Math.round(finger2Start.x), y: Math.round(finger2Start.y) }).down({ button: 0 }).move({ x: Math.round(finger2End.x), y: Math.round(finger2End.y), duration: pinchDuration }).up({ button: 0 })
|
|
733
|
+
]);
|
|
734
|
+
return { success: true, coordinates: { x: pcx, y: pcy } };
|
|
735
|
+
}
|
|
736
|
+
case "multiTap": {
|
|
737
|
+
const { width: mtW, height: mtH } = await driver.getWindowSize();
|
|
738
|
+
let mtcx = Math.round(mtW / 2);
|
|
739
|
+
let mtcy = Math.round(mtH / 2);
|
|
740
|
+
if (action.ref) {
|
|
741
|
+
const { center } = resolver.resolve(action.ref);
|
|
742
|
+
mtcx = Math.round(center.x);
|
|
743
|
+
mtcy = Math.round(center.y);
|
|
744
|
+
}
|
|
745
|
+
if (action.x !== void 0) mtcx = action.x;
|
|
746
|
+
if (action.y !== void 0) mtcy = action.y;
|
|
747
|
+
warnIfOutOfBounds({ x: mtcx, y: mtcy }, { width: mtW, height: mtH }, "multiTap");
|
|
748
|
+
const positions = computeFingerPositions(mtcx, mtcy, action.fingers);
|
|
749
|
+
await driver.actions(
|
|
750
|
+
positions.map(
|
|
751
|
+
(pos) => driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x: Math.round(pos.x), y: Math.round(pos.y) }).down({ button: 0 }).pause(100).up({ button: 0 })
|
|
752
|
+
)
|
|
753
|
+
);
|
|
754
|
+
return { success: true };
|
|
755
|
+
}
|
|
756
|
+
case "longpress": {
|
|
757
|
+
const { center } = resolver.resolve(action.ref);
|
|
758
|
+
await safePerform(
|
|
759
|
+
driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x: Math.round(center.x), y: Math.round(center.y) }).down({ button: 0 }).pause(action.duration ?? 1e3).up({ button: 0 })
|
|
760
|
+
);
|
|
761
|
+
return { success: true };
|
|
762
|
+
}
|
|
763
|
+
case "waitFor": {
|
|
764
|
+
await driver.pause(action.timeout ?? 2e3);
|
|
765
|
+
return { success: true };
|
|
766
|
+
}
|
|
767
|
+
case "delay": {
|
|
768
|
+
await driver.pause(action.ms);
|
|
769
|
+
return { success: true };
|
|
770
|
+
}
|
|
771
|
+
case "keypress": {
|
|
772
|
+
for (const rawKey of action.keys) {
|
|
773
|
+
const mapped = KEY_MAP[rawKey] ?? rawKey;
|
|
774
|
+
await driver.keys([mapped]);
|
|
775
|
+
}
|
|
776
|
+
return { success: true };
|
|
777
|
+
}
|
|
778
|
+
case "navigate": {
|
|
779
|
+
try {
|
|
780
|
+
await driver.url(action.url);
|
|
781
|
+
return { success: true };
|
|
782
|
+
} catch {
|
|
783
|
+
return { success: false, error: "navigate is not supported for this app. Use tap/fill on UI elements to navigate." };
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
case "hover": {
|
|
787
|
+
return { success: false, error: "hover is a web-only action. Use tap or longpress instead." };
|
|
788
|
+
}
|
|
789
|
+
case "assert": {
|
|
790
|
+
return { success: true };
|
|
791
|
+
}
|
|
792
|
+
// --- New actions ---
|
|
793
|
+
case "hideKeyboard": {
|
|
794
|
+
try {
|
|
795
|
+
await driver.hideKeyboard();
|
|
796
|
+
} catch {
|
|
797
|
+
try {
|
|
798
|
+
await driver.execute("mobile: pressKey", { keycode: 4 });
|
|
799
|
+
} catch {
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
return { success: true };
|
|
803
|
+
}
|
|
804
|
+
case "clearText": {
|
|
805
|
+
const { center, bounds } = resolver.resolve(action.ref);
|
|
806
|
+
const clearRefData = this.lastRefs[action.ref];
|
|
807
|
+
const clearRole = clearRefData?.role?.toLowerCase();
|
|
808
|
+
if (clearRole && !ANDROID_FILLABLE_ROLES.has(clearRole)) {
|
|
809
|
+
return {
|
|
810
|
+
success: false,
|
|
811
|
+
error: `Element ${action.ref} is not fillable (role: ${clearRole}). clearText only works on text inputs. Use a textbox or input element instead.`
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
await safePerform(
|
|
815
|
+
driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x: Math.round(center.x), y: Math.round(center.y) }).down({ button: 0 }).up({ button: 0 })
|
|
816
|
+
);
|
|
817
|
+
try {
|
|
818
|
+
const el = await driver.$('//*[@focused="true"]');
|
|
819
|
+
if (await el.isExisting()) {
|
|
820
|
+
await el.clearValue();
|
|
821
|
+
}
|
|
822
|
+
} catch {
|
|
823
|
+
}
|
|
824
|
+
return { success: true, coordinates: center, boundingBox: bounds };
|
|
825
|
+
}
|
|
826
|
+
case "openLink": {
|
|
827
|
+
const appPackage = firstNonEmpty(action.appId, action.appPackage, this.appPackage);
|
|
828
|
+
const isWebUrl = /^https?:\/\//i.test(action.url);
|
|
829
|
+
if (!appPackage && !isWebUrl) {
|
|
830
|
+
return { success: false, error: `Missing Android app package for native deep link: ${action.url}` };
|
|
831
|
+
}
|
|
832
|
+
try {
|
|
833
|
+
if (appPackage) {
|
|
834
|
+
await driver.execute("mobile: deepLink", { url: action.url, package: appPackage });
|
|
835
|
+
} else {
|
|
836
|
+
await driver.url(action.url);
|
|
837
|
+
}
|
|
838
|
+
return { success: true };
|
|
839
|
+
} catch (err) {
|
|
840
|
+
if (!isWebUrl) {
|
|
841
|
+
return { success: false, error: `Failed to open link ${action.url} in Android app ${appPackage}: ${err instanceof Error ? err.message : String(err)}` };
|
|
842
|
+
}
|
|
843
|
+
try {
|
|
844
|
+
await driver.url(action.url);
|
|
845
|
+
return { success: true };
|
|
846
|
+
} catch (fallbackErr) {
|
|
847
|
+
return { success: false, error: `Failed to open link: ${action.url}: ${fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr)}` };
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
case "drag": {
|
|
852
|
+
const from = resolver.resolve(action.fromRef);
|
|
853
|
+
const to = resolver.resolve(action.toRef);
|
|
854
|
+
if (inWeb) await this.ensureContext("NATIVE_APP");
|
|
855
|
+
await safePerform(
|
|
856
|
+
driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x: Math.round(from.center.x), y: Math.round(from.center.y) }).down({ button: 0 }).pause(300).move({ x: Math.round(to.center.x), y: Math.round(to.center.y), duration: 500 }).up({ button: 0 })
|
|
857
|
+
);
|
|
858
|
+
if (inWeb) await this.ensureContext(this.activeWebContext);
|
|
859
|
+
return { success: true };
|
|
860
|
+
}
|
|
861
|
+
case "doubleTap": {
|
|
862
|
+
const { center, bounds } = resolver.resolve(action.ref);
|
|
863
|
+
const x = Math.round(center.x);
|
|
864
|
+
const y = Math.round(center.y);
|
|
865
|
+
await safePerform(
|
|
866
|
+
driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x, y }).down({ button: 0 }).up({ button: 0 }).pause(50).down({ button: 0 }).up({ button: 0 })
|
|
867
|
+
);
|
|
868
|
+
return { success: true, coordinates: center, boundingBox: bounds };
|
|
869
|
+
}
|
|
870
|
+
case "launchApp": {
|
|
871
|
+
await driver.activateApp(action.bundleId);
|
|
872
|
+
return { success: true };
|
|
873
|
+
}
|
|
874
|
+
case "stopApp": {
|
|
875
|
+
await driver.terminateApp(action.bundleId);
|
|
876
|
+
return { success: true };
|
|
877
|
+
}
|
|
878
|
+
case "setOrientation": {
|
|
879
|
+
await driver.setOrientation(action.orientation === "landscape" ? "LANDSCAPE" : "PORTRAIT");
|
|
880
|
+
return { success: true };
|
|
881
|
+
}
|
|
882
|
+
case "executeScript": {
|
|
883
|
+
const result = await driver.execute(action.command, action.args ?? {});
|
|
884
|
+
return { success: true, data: result };
|
|
885
|
+
}
|
|
886
|
+
case "tapCoordinate": {
|
|
887
|
+
const x = action.x;
|
|
888
|
+
const y = action.y;
|
|
889
|
+
const tcVp = await driver.getWindowSize();
|
|
890
|
+
warnIfOutOfBounds({ x, y }, { width: tcVp.width, height: tcVp.height }, "tapCoordinate");
|
|
891
|
+
if (inWeb) {
|
|
892
|
+
await driver.execute(
|
|
893
|
+
"var el = document.elementFromPoint(arguments[0], arguments[1]); if (el) el.click();",
|
|
894
|
+
x,
|
|
895
|
+
y
|
|
896
|
+
);
|
|
897
|
+
} else {
|
|
898
|
+
await safePerform(
|
|
899
|
+
driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x, y }).down({ button: 0 }).up({ button: 0 })
|
|
900
|
+
);
|
|
901
|
+
}
|
|
902
|
+
return { success: true, coordinates: { x, y } };
|
|
903
|
+
}
|
|
904
|
+
case "readConsoleLogs": {
|
|
905
|
+
await this.pollDeviceLogs();
|
|
906
|
+
const all = [...this.consoleLogs];
|
|
907
|
+
const entries = action.level ? all.filter((e) => e.level === action.level) : all;
|
|
908
|
+
return { success: true, data: entries };
|
|
909
|
+
}
|
|
910
|
+
default: {
|
|
911
|
+
return { success: false, error: `Unknown action type: ${action.type}` };
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
};
|
|
916
|
+
export {
|
|
917
|
+
AndroidPlatformAdapter,
|
|
918
|
+
createAndroidSession
|
|
919
|
+
};
|
|
920
|
+
//# sourceMappingURL=index.js.map
|