@astur-mobile/android 0.1.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/agent/astur-android-agent-debug-androidTest.apk +0 -0
- package/assets/agent/astur-android-agent-debug.apk +0 -0
- package/dist/command.d.ts +10 -0
- package/dist/command.d.ts.map +1 -0
- package/dist/command.js +49 -0
- package/dist/command.js.map +1 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1338 -0
- package/dist/index.js.map +1 -0
- package/dist/uiautomatorXml.d.ts +3 -0
- package/dist/uiautomatorXml.d.ts.map +1 -0
- package/dist/uiautomatorXml.js +67 -0
- package/dist/uiautomatorXml.js.map +1 -0
- package/package.json +39 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1338 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { get as httpGet } from 'node:http';
|
|
3
|
+
import { createServer as createNetServer } from 'node:net';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { centerOf, connectNativeAgentClient, findElement, formatSelector, AsturError, delay, preparePointerTargetForKeyboard, waitFor } from '@astur-mobile/core';
|
|
7
|
+
import { run, runText, spawnCommand, spawnDetached } from './command.js';
|
|
8
|
+
import { parseUiAutomatorXml } from './uiautomatorXml.js';
|
|
9
|
+
export function createAndroidDriver(options = {}) {
|
|
10
|
+
return new AndroidDriver(options);
|
|
11
|
+
}
|
|
12
|
+
export class AndroidDriver {
|
|
13
|
+
platform = 'android';
|
|
14
|
+
adbPath;
|
|
15
|
+
emulatorPath;
|
|
16
|
+
aaptPath;
|
|
17
|
+
constructor(options = {}) {
|
|
18
|
+
this.adbPath = options.adbPath ?? process.env.ASTUR_ADB ?? 'adb';
|
|
19
|
+
this.emulatorPath = options.emulatorPath ?? process.env.ASTUR_EMULATOR ?? resolveEmulatorPath();
|
|
20
|
+
this.aaptPath = options.aaptPath ?? process.env.ASTUR_AAPT;
|
|
21
|
+
}
|
|
22
|
+
async doctor() {
|
|
23
|
+
const checks = [];
|
|
24
|
+
try {
|
|
25
|
+
const version = await runText(this.adbPath, ['version']);
|
|
26
|
+
checks.push({
|
|
27
|
+
id: 'android.adb',
|
|
28
|
+
label: 'ADB',
|
|
29
|
+
status: 'pass',
|
|
30
|
+
message: firstLine(version)
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
checks.push({
|
|
35
|
+
id: 'android.adb',
|
|
36
|
+
label: 'ADB',
|
|
37
|
+
status: 'fail',
|
|
38
|
+
message: error instanceof Error ? error.message : String(error),
|
|
39
|
+
fix: 'Install Android SDK platform-tools and ensure adb is on PATH.'
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
const sdkRoot = process.env.ANDROID_HOME ?? process.env.ANDROID_SDK_ROOT;
|
|
43
|
+
checks.push({
|
|
44
|
+
id: 'android.sdk',
|
|
45
|
+
label: 'Android SDK',
|
|
46
|
+
status: sdkRoot ? 'pass' : 'warn',
|
|
47
|
+
message: sdkRoot ? sdkRoot : 'ANDROID_HOME or ANDROID_SDK_ROOT is not set.',
|
|
48
|
+
fix: sdkRoot ? undefined : 'Set ANDROID_HOME or ANDROID_SDK_ROOT to your Android SDK path.'
|
|
49
|
+
});
|
|
50
|
+
try {
|
|
51
|
+
const avds = await this.listAvds();
|
|
52
|
+
checks.push({
|
|
53
|
+
id: 'android.avds',
|
|
54
|
+
label: 'Android AVDs',
|
|
55
|
+
status: avds.length ? 'pass' : 'warn',
|
|
56
|
+
message: avds.length ? `${avds.length} AVD(s) available: ${avds.join(', ')}` : 'No Android AVDs detected.',
|
|
57
|
+
fix: avds.length ? undefined : 'Create an emulator from Android Studio Device Manager.'
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
checks.push({
|
|
62
|
+
id: 'android.avds',
|
|
63
|
+
label: 'Android AVDs',
|
|
64
|
+
status: 'warn',
|
|
65
|
+
message: error instanceof Error ? error.message : String(error),
|
|
66
|
+
fix: 'Ensure the Android emulator binary is on PATH.'
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
const devices = await this.listDevices();
|
|
71
|
+
checks.push({
|
|
72
|
+
id: 'android.devices',
|
|
73
|
+
label: 'Android devices',
|
|
74
|
+
status: devices.some((device) => device.state === 'online') ? 'pass' : 'warn',
|
|
75
|
+
message: devices.length ? `${devices.length} device(s) detected.` : 'No Android devices detected.',
|
|
76
|
+
fix: devices.length ? undefined : 'Start an emulator or connect a device with USB debugging enabled.'
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
checks.push({
|
|
81
|
+
id: 'android.devices',
|
|
82
|
+
label: 'Android devices',
|
|
83
|
+
status: 'fail',
|
|
84
|
+
message: error instanceof Error ? error.message : String(error)
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return checks;
|
|
88
|
+
}
|
|
89
|
+
async listDevices() {
|
|
90
|
+
const output = await runText(this.adbPath, ['devices', '-l']);
|
|
91
|
+
return parseAdbDevices(output);
|
|
92
|
+
}
|
|
93
|
+
async listAvds() {
|
|
94
|
+
const output = await runText(this.emulatorPath, ['-list-avds']);
|
|
95
|
+
return output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
96
|
+
}
|
|
97
|
+
async createSession(capabilities) {
|
|
98
|
+
const resolvedCapabilities = await this.resolveCapabilities(capabilities);
|
|
99
|
+
if (resolvedCapabilities.device.cloud) {
|
|
100
|
+
throw new AsturError('CLOUD_PROVIDER_NOT_IMPLEMENTED', 'BrowserStack execution is scaffolded by Astur init, but the cloud driver is not implemented in this alpha. Use a local Android emulator or real device for now.', { cloud: resolvedCapabilities.device.cloud });
|
|
101
|
+
}
|
|
102
|
+
let devices = await this.listDevices();
|
|
103
|
+
let device = selectDevice(devices, resolvedCapabilities.device);
|
|
104
|
+
if (!device && shouldAutoBoot(resolvedCapabilities.device)) {
|
|
105
|
+
await this.bootConfiguredEmulator(resolvedCapabilities.device);
|
|
106
|
+
devices = await this.listDevices();
|
|
107
|
+
device = selectDevice(devices, resolvedCapabilities.device);
|
|
108
|
+
}
|
|
109
|
+
if (!device) {
|
|
110
|
+
const selector = resolvedCapabilities.device;
|
|
111
|
+
const pinnedEmulator = selector.kind === 'emulator' || Boolean(selector.id?.startsWith('emulator-'));
|
|
112
|
+
const hint = pinnedEmulator && !selector.avd
|
|
113
|
+
? ' To let Astur boot it automatically when it is offline, also set device.avd (and optionally autoBoot: true) — an emulator cannot be started from its id alone.'
|
|
114
|
+
: '';
|
|
115
|
+
throw new AsturError('DEVICE_NOT_FOUND', `No matching Android device is online.${hint}`, {
|
|
116
|
+
selector,
|
|
117
|
+
devices
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
const nativeAgent = await this.resolveNativeAgent(resolvedCapabilities, device);
|
|
121
|
+
return new AndroidSession(this.adbPath, device, resolvedCapabilities, nativeAgent);
|
|
122
|
+
}
|
|
123
|
+
async resolveNativeAgent(capabilities, device) {
|
|
124
|
+
if (capabilities.agent.mode === 'off') {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
const endpoint = capabilities.agent.endpoint ?? process.env.ASTUR_ANDROID_AGENT_ENDPOINT;
|
|
128
|
+
if (!endpoint) {
|
|
129
|
+
const hasBundledAgentArtifacts = capabilities.agent.install && Boolean(resolveAndroidAgentArtifacts());
|
|
130
|
+
const runtime = await this.tryBootstrapBundledNativeAgent(capabilities, device);
|
|
131
|
+
if (runtime) {
|
|
132
|
+
return runtime;
|
|
133
|
+
}
|
|
134
|
+
if (capabilities.agent.mode === 'required' || !allowsLegacyFallback(capabilities, 'failure')) {
|
|
135
|
+
throw new AsturError('ANDROID_AGENT_ENDPOINT_REQUIRED', 'Android native-agent mode is required, but no endpoint or built Android agent APKs were available. Set use.astur.agent.endpoint, ASTUR_ANDROID_AGENT_ENDPOINT, or build packages/android-agent.');
|
|
136
|
+
}
|
|
137
|
+
if (!hasBundledAgentArtifacts) {
|
|
138
|
+
warnAndroidAgentFallback('Android native agent is not configured and built agent APKs were not found. Using legacy ADB/XML interaction path.');
|
|
139
|
+
}
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
const client = await connectNativeAgentClient({
|
|
144
|
+
endpoint,
|
|
145
|
+
platform: 'android',
|
|
146
|
+
handshakeTimeout: capabilities.agent.launchTimeout,
|
|
147
|
+
commandTimeout: capabilities.agent.commandTimeout
|
|
148
|
+
});
|
|
149
|
+
return { client };
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
if (capabilities.agent.mode === 'required' || !allowsLegacyFallback(capabilities, 'failure')) {
|
|
153
|
+
throw new AsturError('ANDROID_AGENT_CONNECT_FAILED', `Failed to connect to Android native agent at ${endpoint}.`, {
|
|
154
|
+
endpoint,
|
|
155
|
+
cause: error
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
warnAndroidAgentFallback(`Failed to connect to Android native agent at ${endpoint}. Using legacy ADB/XML interaction path.`);
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
async tryBootstrapBundledNativeAgent(capabilities, device) {
|
|
163
|
+
if (!capabilities.agent.install) {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
const artifacts = resolveAndroidAgentArtifacts();
|
|
167
|
+
if (!artifacts) {
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
const config = resolveAndroidAgentRuntimeConfig();
|
|
171
|
+
const hostPort = config.hostPort ?? await findFreePort();
|
|
172
|
+
const endpoint = `http://127.0.0.1:${hostPort}`;
|
|
173
|
+
const adb = (args) => run(this.adbPath, ['-s', device.id, ...args]);
|
|
174
|
+
let agentProcess;
|
|
175
|
+
try {
|
|
176
|
+
if (await shouldInstallAndroidAgent(adb, config)) {
|
|
177
|
+
await adb(['install', '-r', artifacts.appApkPath]);
|
|
178
|
+
await adb(['install', '-r', artifacts.testApkPath]);
|
|
179
|
+
}
|
|
180
|
+
await adb(['forward', `tcp:${hostPort}`, `tcp:${config.devicePort}`]);
|
|
181
|
+
agentProcess = spawnCommand(this.adbPath, [
|
|
182
|
+
'-s',
|
|
183
|
+
device.id,
|
|
184
|
+
'shell',
|
|
185
|
+
'am',
|
|
186
|
+
'instrument',
|
|
187
|
+
'-w',
|
|
188
|
+
'-e',
|
|
189
|
+
'asturPort',
|
|
190
|
+
String(config.devicePort),
|
|
191
|
+
`${config.testPackage}/${config.runnerClass}`
|
|
192
|
+
]);
|
|
193
|
+
const client = await waitForAndroidAgent(endpoint, capabilities);
|
|
194
|
+
return {
|
|
195
|
+
client,
|
|
196
|
+
hostPort,
|
|
197
|
+
packageName: config.packageName,
|
|
198
|
+
process: agentProcess
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
agentProcess?.kill('SIGINT');
|
|
203
|
+
await adb(['forward', '--remove', `tcp:${hostPort}`]).catch(() => undefined);
|
|
204
|
+
if (capabilities.agent.mode === 'required' || !allowsLegacyFallback(capabilities, 'failure')) {
|
|
205
|
+
throw new AsturError('ANDROID_AGENT_START_FAILED', 'Failed to install or start the Android native agent.', {
|
|
206
|
+
device,
|
|
207
|
+
artifacts,
|
|
208
|
+
endpoint,
|
|
209
|
+
cause: error
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
warnAndroidAgentFallback('Failed to bootstrap the built Android native agent. Using legacy ADB/XML interaction path.');
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
async resolveCapabilities(capabilities) {
|
|
217
|
+
return {
|
|
218
|
+
...capabilities,
|
|
219
|
+
app: await this.resolveApp(capabilities.app)
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
async resolveApp(app) {
|
|
223
|
+
if (!app?.path || (app.packageName && app.activity)) {
|
|
224
|
+
return app;
|
|
225
|
+
}
|
|
226
|
+
const metadata = await this.readApkMetadata(app.path);
|
|
227
|
+
return {
|
|
228
|
+
...app,
|
|
229
|
+
packageName: app.packageName ?? metadata.packageName,
|
|
230
|
+
activity: app.activity ?? metadata.launchActivity
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
async readApkMetadata(path) {
|
|
234
|
+
const aaptPath = this.aaptPath ?? resolveAaptPath();
|
|
235
|
+
if (!aaptPath) {
|
|
236
|
+
throw new AsturError('AAPT_NOT_FOUND', 'Cannot infer Android package metadata because aapt was not found. Set ASTUR_AAPT or provide app.packageName in config.');
|
|
237
|
+
}
|
|
238
|
+
const output = await runText(aaptPath, ['dump', 'badging', path]);
|
|
239
|
+
return parseAaptBadging(output);
|
|
240
|
+
}
|
|
241
|
+
async bootConfiguredEmulator(selector) {
|
|
242
|
+
if (!selector.avd) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const args = [
|
|
246
|
+
'-avd',
|
|
247
|
+
selector.avd,
|
|
248
|
+
'-no-snapshot-save',
|
|
249
|
+
'-no-audio',
|
|
250
|
+
'-no-boot-anim'
|
|
251
|
+
];
|
|
252
|
+
if (selector.headless !== false) {
|
|
253
|
+
args.push('-no-window');
|
|
254
|
+
}
|
|
255
|
+
if (selector.wipeData) {
|
|
256
|
+
args.push('-wipe-data');
|
|
257
|
+
}
|
|
258
|
+
args.push(...(selector.emulatorArgs ?? []));
|
|
259
|
+
const child = spawnDetached(this.emulatorPath, args);
|
|
260
|
+
let launchError;
|
|
261
|
+
child.once('error', (error) => {
|
|
262
|
+
launchError = new AsturError('EMULATOR_LAUNCH_FAILED', `Could not launch the Android emulator "${this.emulatorPath}": ${error.message}. Install the Android SDK emulator and make sure it is on PATH, or set ASTUR_EMULATOR / ANDROID_HOME.`, { avd: selector.avd, emulatorPath: this.emulatorPath });
|
|
263
|
+
});
|
|
264
|
+
child.once('exit', (code, signal) => {
|
|
265
|
+
if (code != null && code !== 0) {
|
|
266
|
+
launchError = new AsturError('EMULATOR_LAUNCH_FAILED', `The Android emulator for AVD "${selector.avd}" exited (code ${code}) before finishing boot. The AVD name may be wrong, or an instance of it may already be running.`, { avd: selector.avd, code, signal });
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
await this.waitForBoot(selector, () => launchError);
|
|
270
|
+
}
|
|
271
|
+
async waitForBoot(selector, getLaunchError) {
|
|
272
|
+
const timeout = selector.bootTimeout ?? 120_000;
|
|
273
|
+
const startedAt = Date.now();
|
|
274
|
+
let lastDevices = [];
|
|
275
|
+
while (Date.now() - startedAt <= timeout) {
|
|
276
|
+
const launchError = getLaunchError?.();
|
|
277
|
+
if (launchError) {
|
|
278
|
+
throw launchError;
|
|
279
|
+
}
|
|
280
|
+
lastDevices = await this.listDevices().catch(() => []);
|
|
281
|
+
const device = selectDevice(lastDevices, { ...selector, kind: 'emulator' });
|
|
282
|
+
if (device) {
|
|
283
|
+
const bootCompleted = await runText(this.adbPath, ['-s', device.id, 'shell', 'getprop', 'sys.boot_completed'])
|
|
284
|
+
.then((value) => value.trim() === '1')
|
|
285
|
+
.catch(() => false);
|
|
286
|
+
if (bootCompleted) {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
await delay(1_000);
|
|
291
|
+
}
|
|
292
|
+
throw new AsturError('EMULATOR_BOOT_TIMEOUT', `Timed out waiting for Android AVD ${selector.avd} to boot.`, {
|
|
293
|
+
selector,
|
|
294
|
+
devices: lastDevices
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
class AndroidSession {
|
|
299
|
+
deviceInfo;
|
|
300
|
+
capabilities;
|
|
301
|
+
adbPath;
|
|
302
|
+
nativeAgent;
|
|
303
|
+
nativeAgentRuntime;
|
|
304
|
+
unsupportedAgentMethods = new Set();
|
|
305
|
+
recording;
|
|
306
|
+
forwardedWebViewPorts = new Set();
|
|
307
|
+
constructor(adbPath, deviceInfo, capabilities, nativeAgentRuntime) {
|
|
308
|
+
this.adbPath = adbPath;
|
|
309
|
+
this.deviceInfo = deviceInfo;
|
|
310
|
+
this.capabilities = capabilities;
|
|
311
|
+
this.nativeAgentRuntime = nativeAgentRuntime;
|
|
312
|
+
this.nativeAgent = nativeAgentRuntime?.client;
|
|
313
|
+
}
|
|
314
|
+
async close() {
|
|
315
|
+
if (this.recording) {
|
|
316
|
+
await this.stopRecording().catch(() => undefined);
|
|
317
|
+
}
|
|
318
|
+
await Promise.all([...this.forwardedWebViewPorts].map((port) => this.adb(['forward', '--remove', `tcp:${port}`]).catch(() => undefined)));
|
|
319
|
+
this.forwardedWebViewPorts.clear();
|
|
320
|
+
if (this.nativeAgentRuntime?.hostPort) {
|
|
321
|
+
await this.adb(['forward', '--remove', `tcp:${this.nativeAgentRuntime.hostPort}`]).catch(() => undefined);
|
|
322
|
+
}
|
|
323
|
+
this.nativeAgentRuntime?.process?.kill('SIGINT');
|
|
324
|
+
if (this.nativeAgentRuntime?.packageName) {
|
|
325
|
+
await this.adb(['shell', 'am', 'force-stop', this.nativeAgentRuntime.packageName]).catch(() => undefined);
|
|
326
|
+
}
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
async installApp(path) {
|
|
330
|
+
await this.adb(['install', '-r', path]);
|
|
331
|
+
}
|
|
332
|
+
async isAppInstalled(identifier) {
|
|
333
|
+
const output = await this.adbText(['shell', 'pm', 'path', identifier]).catch(() => '');
|
|
334
|
+
return output
|
|
335
|
+
.split(/\r?\n/)
|
|
336
|
+
.map((line) => line.trim())
|
|
337
|
+
.some((line) => line.startsWith('package:'));
|
|
338
|
+
}
|
|
339
|
+
async uninstallApp(identifier) {
|
|
340
|
+
await this.adb(['uninstall', identifier]);
|
|
341
|
+
}
|
|
342
|
+
async launchApp(options = {}) {
|
|
343
|
+
if (options.url) {
|
|
344
|
+
await this.openWeb(options.url);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
const app = options.app ?? this.capabilities.app;
|
|
348
|
+
const packageName = app?.packageName ?? app?.bundleId;
|
|
349
|
+
if (!packageName) {
|
|
350
|
+
throw new AsturError('ANDROID_PACKAGE_REQUIRED', 'Android launch requires app.packageName. Astur does not infer package names from APKs yet.');
|
|
351
|
+
}
|
|
352
|
+
if (app?.activity) {
|
|
353
|
+
await this.adb(['shell', 'am', 'start', '-n', `${packageName}/${app.activity}`]);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
await this.adb([
|
|
357
|
+
'shell',
|
|
358
|
+
'monkey',
|
|
359
|
+
'-p',
|
|
360
|
+
packageName,
|
|
361
|
+
'-c',
|
|
362
|
+
'android.intent.category.LAUNCHER',
|
|
363
|
+
'1'
|
|
364
|
+
]);
|
|
365
|
+
}
|
|
366
|
+
async terminateApp() {
|
|
367
|
+
const packageName = this.resolvePackageName();
|
|
368
|
+
await this.adb(['shell', 'am', 'force-stop', packageName]);
|
|
369
|
+
}
|
|
370
|
+
async clearAppData(identifier) {
|
|
371
|
+
await this.adb(['shell', 'pm', 'clear', identifier]);
|
|
372
|
+
}
|
|
373
|
+
async clearAppCache(identifier) {
|
|
374
|
+
await this.adb(['shell', 'pm', 'clear', '--cache-only', identifier]);
|
|
375
|
+
}
|
|
376
|
+
async grantPermission(identifier, permission) {
|
|
377
|
+
await this.adb(['shell', 'pm', 'grant', identifier, normalizeAndroidPermission(permission)]);
|
|
378
|
+
}
|
|
379
|
+
async revokePermission(identifier, permission) {
|
|
380
|
+
await this.adb(['shell', 'pm', 'revoke', identifier, normalizeAndroidPermission(permission)]);
|
|
381
|
+
}
|
|
382
|
+
async resetApp(options = {}) {
|
|
383
|
+
const app = options.app ?? {
|
|
384
|
+
...this.capabilities.app,
|
|
385
|
+
path: options.path ?? this.capabilities.app?.path,
|
|
386
|
+
packageName: options.packageName ?? this.capabilities.app?.packageName,
|
|
387
|
+
bundleId: options.bundleId ?? this.capabilities.app?.bundleId
|
|
388
|
+
};
|
|
389
|
+
const packageName = options.packageName ?? options.bundleId ?? app.packageName ?? app.bundleId;
|
|
390
|
+
if (!packageName) {
|
|
391
|
+
throw new AsturError('ANDROID_PACKAGE_REQUIRED', 'Android reset requires app.packageName.');
|
|
392
|
+
}
|
|
393
|
+
await this.adb(['shell', 'am', 'force-stop', packageName]).catch(() => undefined);
|
|
394
|
+
if (options.reinstall) {
|
|
395
|
+
const path = options.path ?? app.path;
|
|
396
|
+
if (!path) {
|
|
397
|
+
throw new AsturError('APP_PATH_REQUIRED', 'Android reinstall reset requires an app path.');
|
|
398
|
+
}
|
|
399
|
+
await this.adb(['uninstall', packageName]).catch(() => undefined);
|
|
400
|
+
await this.installApp(path);
|
|
401
|
+
}
|
|
402
|
+
else {
|
|
403
|
+
await this.clearAppData(packageName);
|
|
404
|
+
}
|
|
405
|
+
if (options.launch) {
|
|
406
|
+
await this.launchApp({ app });
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
async setOrientation(orientation) {
|
|
410
|
+
if (this.nativeAgent?.info.capabilities.includes('device.setOrientation')) {
|
|
411
|
+
try {
|
|
412
|
+
const command = await this.tryNativeCommand('device.setOrientation', { orientation });
|
|
413
|
+
if (command.ok) {
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
// Orientation is a lifecycle control; fall through to Android shell APIs
|
|
419
|
+
// when the agent cannot apply it directly.
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
const rotation = androidRotationForOrientation(orientation);
|
|
423
|
+
await this.adb(['shell', 'settings', 'put', 'system', 'accelerometer_rotation', '0']).catch(() => undefined);
|
|
424
|
+
await this.adb(['shell', 'settings', 'put', 'system', 'user_rotation', String(rotation)]).catch(() => undefined);
|
|
425
|
+
try {
|
|
426
|
+
await this.adb(['shell', 'cmd', 'window', 'user-rotation', 'lock', String(rotation)]);
|
|
427
|
+
await this.adb(['shell', 'cmd', 'window', 'fixed-to-user-rotation', 'enabled']).catch(() => undefined);
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
// Older Android images may not expose cmd window user-rotation.
|
|
431
|
+
// The settings writes above are the compatibility path.
|
|
432
|
+
}
|
|
433
|
+
await delay(500);
|
|
434
|
+
}
|
|
435
|
+
async lockDevice() {
|
|
436
|
+
await this.pressKey('SLEEP');
|
|
437
|
+
}
|
|
438
|
+
async unlockDevice() {
|
|
439
|
+
await this.pressKey('WAKEUP');
|
|
440
|
+
await this.adb(['shell', 'wm', 'dismiss-keyguard']).catch(() => undefined);
|
|
441
|
+
}
|
|
442
|
+
async isDeviceLocked() {
|
|
443
|
+
return parseAndroidLockState(await this.adbText(['shell', 'dumpsys', 'window']));
|
|
444
|
+
}
|
|
445
|
+
async getTree() {
|
|
446
|
+
const command = await this.tryNativeCommand('tree.get');
|
|
447
|
+
if (command.ok) {
|
|
448
|
+
return command.result;
|
|
449
|
+
}
|
|
450
|
+
const remotePath = '/sdcard/astur-window.xml';
|
|
451
|
+
await this.adb(['shell', 'uiautomator', 'dump', remotePath]);
|
|
452
|
+
const xml = await this.adbText(['shell', 'cat', remotePath]);
|
|
453
|
+
return parseUiAutomatorXml(xml);
|
|
454
|
+
}
|
|
455
|
+
async findElement(selector) {
|
|
456
|
+
const command = await this.tryNativeCommand('element.find', { selector });
|
|
457
|
+
if (command.ok) {
|
|
458
|
+
return command.result;
|
|
459
|
+
}
|
|
460
|
+
return findElement(await this.getTree(), selector);
|
|
461
|
+
}
|
|
462
|
+
async waitForElement(selector, options = {}) {
|
|
463
|
+
const state = options.state ?? 'attached';
|
|
464
|
+
const command = await this.tryNativeCommand('element.wait', {
|
|
465
|
+
selector,
|
|
466
|
+
options: {
|
|
467
|
+
...options,
|
|
468
|
+
state
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
if (command.ok && command.result) {
|
|
472
|
+
return command.result;
|
|
473
|
+
}
|
|
474
|
+
return waitFor(async () => {
|
|
475
|
+
const element = await this.findElement(selector);
|
|
476
|
+
if (!element) {
|
|
477
|
+
return undefined;
|
|
478
|
+
}
|
|
479
|
+
if (state === 'visible' && !element.visible) {
|
|
480
|
+
return undefined;
|
|
481
|
+
}
|
|
482
|
+
if (state === 'hidden') {
|
|
483
|
+
return element.visible ? undefined : element;
|
|
484
|
+
}
|
|
485
|
+
return element;
|
|
486
|
+
}, {
|
|
487
|
+
timeout: options.timeout ?? this.capabilities.timeout,
|
|
488
|
+
interval: options.interval,
|
|
489
|
+
message: `Timed out waiting for ${formatSelector(selector)} to be ${state}`
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
async waitForElementHidden(selector, options = {}) {
|
|
493
|
+
const command = await this.tryNativeCommand('element.wait', {
|
|
494
|
+
selector,
|
|
495
|
+
options: {
|
|
496
|
+
...options,
|
|
497
|
+
state: 'hidden'
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
if (command.ok) {
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
await waitFor(async () => {
|
|
504
|
+
const element = await this.findElement(selector);
|
|
505
|
+
return !element || !element.visible;
|
|
506
|
+
}, {
|
|
507
|
+
timeout: options.timeout ?? this.capabilities.timeout,
|
|
508
|
+
interval: options.interval,
|
|
509
|
+
message: `Timed out waiting for ${formatSelector(selector)} to be hidden`
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
async tapElement(selector, options = {}) {
|
|
513
|
+
const command = await this.tryNativeCommand('element.tap', { selector, options });
|
|
514
|
+
if (command.ok) {
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
let element = await this.waitForElement(selector, { ...options, state: 'visible' });
|
|
518
|
+
const dismissed = await preparePointerTargetForKeyboard(this, centerOf(element.bounds), options.keyboard);
|
|
519
|
+
if (dismissed) {
|
|
520
|
+
element = await this.waitForElement(selector, { ...options, state: 'visible' });
|
|
521
|
+
}
|
|
522
|
+
await this.tap(centerOf(element.bounds));
|
|
523
|
+
}
|
|
524
|
+
async doubleTapElement(selector, options = {}) {
|
|
525
|
+
const command = await this.tryNativeCommand('element.doubleTap', { selector, options });
|
|
526
|
+
if (command.ok) {
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
let element = await this.waitForElement(selector, { ...options, state: 'visible' });
|
|
530
|
+
const dismissed = await preparePointerTargetForKeyboard(this, centerOf(element.bounds), options.keyboard);
|
|
531
|
+
if (dismissed) {
|
|
532
|
+
element = await this.waitForElement(selector, { ...options, state: 'visible' });
|
|
533
|
+
}
|
|
534
|
+
await this.doubleTap(centerOf(element.bounds), { intervalMs: options.intervalMs });
|
|
535
|
+
}
|
|
536
|
+
async longPressElement(selector, options = {}) {
|
|
537
|
+
const command = await this.tryNativeCommand('element.longPress', { selector, options });
|
|
538
|
+
if (command.ok) {
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
let element = await this.waitForElement(selector, { ...options, state: 'visible' });
|
|
542
|
+
const dismissed = await preparePointerTargetForKeyboard(this, centerOf(element.bounds), options.keyboard);
|
|
543
|
+
if (dismissed) {
|
|
544
|
+
element = await this.waitForElement(selector, { ...options, state: 'visible' });
|
|
545
|
+
}
|
|
546
|
+
await this.longPress(centerOf(element.bounds), { durationMs: options.durationMs });
|
|
547
|
+
}
|
|
548
|
+
async fillElement(selector, value, options = {}) {
|
|
549
|
+
const command = await this.tryNativeCommand('element.fill', { selector, value, options });
|
|
550
|
+
if (command.ok) {
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
const element = await this.waitForElement(selector, { ...options, state: 'visible' });
|
|
554
|
+
await preparePointerTargetForKeyboard(this, centerOf(element.bounds), options.keyboard);
|
|
555
|
+
await this.tap(centerOf(element.bounds));
|
|
556
|
+
await this.adb(['shell', 'input', 'text', escapeAndroidInputText(value)]);
|
|
557
|
+
}
|
|
558
|
+
async dragElement(selector, target, options = {}) {
|
|
559
|
+
const command = await this.tryNativeCommand('element.drag', { selector, target, options });
|
|
560
|
+
if (command.ok) {
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
let source = await this.waitForElement(selector, { ...options, state: 'visible' });
|
|
564
|
+
const dismissed = await preparePointerTargetForKeyboard(this, centerOf(source.bounds), options.keyboard);
|
|
565
|
+
if (dismissed) {
|
|
566
|
+
source = await this.waitForElement(selector, { ...options, state: 'visible' });
|
|
567
|
+
}
|
|
568
|
+
const end = isElementSelectorTarget(target)
|
|
569
|
+
? centerOf((await this.waitForElement(target.selector, {
|
|
570
|
+
timeout: options.timeout,
|
|
571
|
+
interval: options.interval,
|
|
572
|
+
state: 'visible'
|
|
573
|
+
})).bounds)
|
|
574
|
+
: target;
|
|
575
|
+
await this.drag({
|
|
576
|
+
start: centerOf(source.bounds),
|
|
577
|
+
end,
|
|
578
|
+
durationMs: options.durationMs
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
async tap(target) {
|
|
582
|
+
const command = await this.tryNativeCommand('gesture.tap', { target });
|
|
583
|
+
if (command.ok) {
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
await this.adb(['shell', 'input', 'tap', String(target.x), String(target.y)]);
|
|
587
|
+
}
|
|
588
|
+
async doubleTap(target, options = {}) {
|
|
589
|
+
const command = await this.tryNativeCommand('gesture.doubleTap', { target, options });
|
|
590
|
+
if (command.ok) {
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
await this.tap(target);
|
|
594
|
+
await delay(options.intervalMs ?? 80);
|
|
595
|
+
await this.tap(target);
|
|
596
|
+
}
|
|
597
|
+
async longPress(target, options = {}) {
|
|
598
|
+
const command = await this.tryNativeCommand('gesture.longPress', { target, options });
|
|
599
|
+
if (command.ok) {
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
const durationMs = options.durationMs ?? 800;
|
|
603
|
+
await this.adb([
|
|
604
|
+
'shell',
|
|
605
|
+
'input',
|
|
606
|
+
'swipe',
|
|
607
|
+
String(target.x),
|
|
608
|
+
String(target.y),
|
|
609
|
+
String(target.x),
|
|
610
|
+
String(target.y),
|
|
611
|
+
String(durationMs)
|
|
612
|
+
]);
|
|
613
|
+
}
|
|
614
|
+
async fill(selector, value) {
|
|
615
|
+
await this.fillElement(selector, value);
|
|
616
|
+
}
|
|
617
|
+
async pressKey(key) {
|
|
618
|
+
await this.adb(['shell', 'input', 'keyevent', normalizeAndroidKey(key)]);
|
|
619
|
+
}
|
|
620
|
+
async swipe(gesture) {
|
|
621
|
+
const command = await this.tryNativeCommand('gesture.swipe', { gesture });
|
|
622
|
+
if (command.ok) {
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
await this.adb([
|
|
626
|
+
'shell',
|
|
627
|
+
'input',
|
|
628
|
+
'swipe',
|
|
629
|
+
String(gesture.start.x),
|
|
630
|
+
String(gesture.start.y),
|
|
631
|
+
String(gesture.end.x),
|
|
632
|
+
String(gesture.end.y),
|
|
633
|
+
String(gesture.durationMs ?? 300)
|
|
634
|
+
]);
|
|
635
|
+
}
|
|
636
|
+
async drag(gesture) {
|
|
637
|
+
const command = await this.tryNativeCommand('gesture.drag', { gesture });
|
|
638
|
+
if (command.ok) {
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
await this.adb([
|
|
642
|
+
'shell',
|
|
643
|
+
'input',
|
|
644
|
+
'swipe',
|
|
645
|
+
String(gesture.start.x),
|
|
646
|
+
String(gesture.start.y),
|
|
647
|
+
String(gesture.end.x),
|
|
648
|
+
String(gesture.end.y),
|
|
649
|
+
String(gesture.durationMs ?? 700)
|
|
650
|
+
]);
|
|
651
|
+
}
|
|
652
|
+
async startRecording() {
|
|
653
|
+
if (this.recording) {
|
|
654
|
+
throw new AsturError('RECORDING_ALREADY_STARTED', 'Android screen recording is already running for this session.');
|
|
655
|
+
}
|
|
656
|
+
const remotePath = `/sdcard/astur-recording-${process.pid}-${Date.now()}.mp4`;
|
|
657
|
+
const child = spawnCommand(this.adbPath, ['-s', this.deviceInfo.id, 'shell', 'screenrecord', remotePath]);
|
|
658
|
+
this.recording = { child, remotePath };
|
|
659
|
+
await delay(500);
|
|
660
|
+
}
|
|
661
|
+
async stopRecording(options = {}) {
|
|
662
|
+
const recording = this.recording;
|
|
663
|
+
if (!recording) {
|
|
664
|
+
return undefined;
|
|
665
|
+
}
|
|
666
|
+
this.recording = undefined;
|
|
667
|
+
await this.adb(['shell', 'pkill', '-2', 'screenrecord']).catch(() => undefined);
|
|
668
|
+
await waitForProcessExit(recording.child, 10_000);
|
|
669
|
+
await this.waitForRemoteFileStable(recording.remotePath, 5_000);
|
|
670
|
+
if (options.discard) {
|
|
671
|
+
await this.adb(['shell', 'rm', '-f', recording.remotePath]).catch(() => undefined);
|
|
672
|
+
return undefined;
|
|
673
|
+
}
|
|
674
|
+
try {
|
|
675
|
+
const result = await this.adb(['exec-out', 'cat', recording.remotePath], 200 * 1024 * 1024);
|
|
676
|
+
return result.stdout.length ? result.stdout : undefined;
|
|
677
|
+
}
|
|
678
|
+
finally {
|
|
679
|
+
await this.adb(['shell', 'rm', '-f', recording.remotePath]).catch(() => undefined);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
async waitForRemoteFileStable(remotePath, timeoutMs) {
|
|
683
|
+
const deadline = Date.now() + timeoutMs;
|
|
684
|
+
let lastSize = -1;
|
|
685
|
+
let stableReads = 0;
|
|
686
|
+
while (Date.now() <= deadline) {
|
|
687
|
+
const size = await this.remoteFileSize(remotePath).catch(() => 0);
|
|
688
|
+
if (size > 0 && size === lastSize) {
|
|
689
|
+
stableReads += 1;
|
|
690
|
+
if (stableReads >= 2) {
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
else {
|
|
695
|
+
stableReads = 0;
|
|
696
|
+
lastSize = size;
|
|
697
|
+
}
|
|
698
|
+
await delay(250);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
async remoteFileSize(remotePath) {
|
|
702
|
+
const output = await this.adbText(['shell', 'stat', '-c', '%s', remotePath]);
|
|
703
|
+
const size = Number(output.trim());
|
|
704
|
+
return Number.isFinite(size) ? size : 0;
|
|
705
|
+
}
|
|
706
|
+
async screenshot() {
|
|
707
|
+
const result = await this.adb(['exec-out', 'screencap', '-p']);
|
|
708
|
+
return result.stdout;
|
|
709
|
+
}
|
|
710
|
+
async pushFile(localPath, remotePath) {
|
|
711
|
+
await this.adb(['push', localPath, remotePath]);
|
|
712
|
+
}
|
|
713
|
+
async pullFile(remotePath) {
|
|
714
|
+
const result = await this.adb(['exec-out', 'cat', remotePath], 200 * 1024 * 1024);
|
|
715
|
+
return result.stdout;
|
|
716
|
+
}
|
|
717
|
+
async removeFile(remotePath) {
|
|
718
|
+
await this.adb(['shell', 'rm', '-rf', remotePath]);
|
|
719
|
+
}
|
|
720
|
+
async listFiles(remotePath) {
|
|
721
|
+
const output = await this.adbText(['shell', 'ls', '-la', remotePath]);
|
|
722
|
+
return parseAndroidLs(output, remotePath);
|
|
723
|
+
}
|
|
724
|
+
async openWeb(url) {
|
|
725
|
+
await this.adb(['shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', url]);
|
|
726
|
+
}
|
|
727
|
+
async listContexts() {
|
|
728
|
+
const sockets = parseAndroidWebViewSockets(await this.adbText(['shell', 'cat', '/proc/net/unix']));
|
|
729
|
+
return [
|
|
730
|
+
{
|
|
731
|
+
id: 'native',
|
|
732
|
+
type: 'native',
|
|
733
|
+
title: 'Native'
|
|
734
|
+
},
|
|
735
|
+
...sockets.map((socket) => ({
|
|
736
|
+
id: socket,
|
|
737
|
+
type: 'webview',
|
|
738
|
+
socket
|
|
739
|
+
}))
|
|
740
|
+
];
|
|
741
|
+
}
|
|
742
|
+
async connectWebView(selector = {}) {
|
|
743
|
+
const timeout = selector.timeout ?? this.capabilities.timeout;
|
|
744
|
+
const startedAt = Date.now();
|
|
745
|
+
let lastContexts = [];
|
|
746
|
+
while (Date.now() - startedAt <= timeout) {
|
|
747
|
+
const sockets = parseAndroidWebViewSockets(await this.adbText(['shell', 'cat', '/proc/net/unix']).catch(() => ''));
|
|
748
|
+
const candidates = selector.id
|
|
749
|
+
? sockets.filter((socket) => socket === selector.id)
|
|
750
|
+
: sockets;
|
|
751
|
+
for (const socket of candidates) {
|
|
752
|
+
const port = await this.forwardWebView(socket);
|
|
753
|
+
const cdpUrl = `http://127.0.0.1:${port}`;
|
|
754
|
+
const targets = await readDevtoolsTargets(cdpUrl).catch(() => []);
|
|
755
|
+
if (!targets.length && matchesWebViewSelector({ id: socket, type: 'webview', socket }, selector)) {
|
|
756
|
+
return {
|
|
757
|
+
context: { id: socket, type: 'webview', socket },
|
|
758
|
+
cdpUrl
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
for (const target of targets) {
|
|
762
|
+
const context = webViewContextFromTarget(socket, target);
|
|
763
|
+
lastContexts.push(context);
|
|
764
|
+
if (matchesWebViewSelector(context, selector)) {
|
|
765
|
+
return {
|
|
766
|
+
context,
|
|
767
|
+
cdpUrl
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
await delay(500);
|
|
773
|
+
}
|
|
774
|
+
throw new AsturError('WEBVIEW_NOT_FOUND', 'No matching Android WebView context was available through DevTools.', {
|
|
775
|
+
selector,
|
|
776
|
+
contexts: lastContexts
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
async getKeyboardState() {
|
|
780
|
+
const command = await this.tryNativeCommand('keyboard.state');
|
|
781
|
+
if (command.ok) {
|
|
782
|
+
return command.result;
|
|
783
|
+
}
|
|
784
|
+
const output = await this.adbText(['shell', 'dumpsys', 'window']);
|
|
785
|
+
return parseAndroidKeyboardState(output);
|
|
786
|
+
}
|
|
787
|
+
async dismissKeyboard() {
|
|
788
|
+
const command = await this.tryNativeCommand('keyboard.dismiss');
|
|
789
|
+
if (command.ok) {
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
if (!(await this.getKeyboardState()).visible) {
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
await this.pressKey('BACK');
|
|
796
|
+
const deadline = Date.now() + 1_500;
|
|
797
|
+
while (Date.now() <= deadline) {
|
|
798
|
+
if (!(await this.getKeyboardState()).visible) {
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
await delay(100);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
async tryNativeCommand(method, params) {
|
|
805
|
+
if (!this.nativeAgent) {
|
|
806
|
+
return { ok: false };
|
|
807
|
+
}
|
|
808
|
+
if (this.unsupportedAgentMethods.has(method)) {
|
|
809
|
+
if (this.capabilities.agent.mode === 'required' || !allowsLegacyFallback(this.capabilities, 'unsupported')) {
|
|
810
|
+
throw new AsturError('ANDROID_AGENT_COMMAND_UNSUPPORTED', `Android native agent does not advertise support for ${method}.`, {
|
|
811
|
+
endpoint: this.nativeAgent.endpoint,
|
|
812
|
+
method,
|
|
813
|
+
capabilities: this.nativeAgent.info.capabilities
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
return { ok: false };
|
|
817
|
+
}
|
|
818
|
+
if (!this.nativeAgent.info.capabilities.includes(method)) {
|
|
819
|
+
this.unsupportedAgentMethods.add(method);
|
|
820
|
+
if (this.capabilities.agent.mode === 'required' || !allowsLegacyFallback(this.capabilities, 'unsupported')) {
|
|
821
|
+
throw new AsturError('ANDROID_AGENT_COMMAND_UNSUPPORTED', `Android native agent does not advertise support for ${method}.`, {
|
|
822
|
+
endpoint: this.nativeAgent.endpoint,
|
|
823
|
+
method,
|
|
824
|
+
capabilities: this.nativeAgent.info.capabilities
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
return { ok: false };
|
|
828
|
+
}
|
|
829
|
+
try {
|
|
830
|
+
const result = await this.nativeAgent.command(method, params);
|
|
831
|
+
return {
|
|
832
|
+
ok: true,
|
|
833
|
+
result
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
catch (error) {
|
|
837
|
+
if (isAgentCommandUnsupported(error)) {
|
|
838
|
+
this.unsupportedAgentMethods.add(method);
|
|
839
|
+
if (allowsLegacyFallback(this.capabilities, 'unsupported')) {
|
|
840
|
+
return { ok: false };
|
|
841
|
+
}
|
|
842
|
+
throw new AsturError('ANDROID_AGENT_COMMAND_UNSUPPORTED', `Android native agent does not support ${method}.`, {
|
|
843
|
+
endpoint: this.nativeAgent.endpoint,
|
|
844
|
+
method,
|
|
845
|
+
cause: error
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
if (isAgentCommandFailure(error)) {
|
|
849
|
+
if (allowsLegacyFallback(this.capabilities, 'failure')) {
|
|
850
|
+
return { ok: false };
|
|
851
|
+
}
|
|
852
|
+
throw new AsturError('ANDROID_AGENT_COMMAND_FAILED', `Android native agent command ${method} failed.`, {
|
|
853
|
+
endpoint: this.nativeAgent.endpoint,
|
|
854
|
+
method,
|
|
855
|
+
cause: error
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
this.nativeAgent = undefined;
|
|
859
|
+
if (!allowsLegacyFallback(this.capabilities, 'failure')) {
|
|
860
|
+
throw new AsturError('ANDROID_AGENT_COMMAND_FAILED', `Android native agent command ${method} failed.`, {
|
|
861
|
+
method,
|
|
862
|
+
cause: error
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
return { ok: false };
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
adb(args, maxBuffer) {
|
|
869
|
+
return run(this.adbPath, ['-s', this.deviceInfo.id, ...args], maxBuffer);
|
|
870
|
+
}
|
|
871
|
+
adbText(args) {
|
|
872
|
+
return runText(this.adbPath, ['-s', this.deviceInfo.id, ...args]);
|
|
873
|
+
}
|
|
874
|
+
async forwardWebView(socket) {
|
|
875
|
+
const output = await this.adbText(['forward', 'tcp:0', `localabstract:${socket}`]);
|
|
876
|
+
const port = Number(output.trim());
|
|
877
|
+
if (!Number.isInteger(port) || port <= 0) {
|
|
878
|
+
throw new AsturError('WEBVIEW_FORWARD_FAILED', `ADB did not return a TCP port for ${socket}.`, { output });
|
|
879
|
+
}
|
|
880
|
+
this.forwardedWebViewPorts.add(port);
|
|
881
|
+
return port;
|
|
882
|
+
}
|
|
883
|
+
resolvePackageName(app = this.capabilities.app) {
|
|
884
|
+
const packageName = app?.packageName ?? app?.bundleId;
|
|
885
|
+
if (!packageName) {
|
|
886
|
+
throw new AsturError('ANDROID_PACKAGE_REQUIRED', 'Android app management requires app.packageName.');
|
|
887
|
+
}
|
|
888
|
+
return packageName;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
export function parseAdbDevices(output) {
|
|
892
|
+
return output
|
|
893
|
+
.split(/\r?\n/)
|
|
894
|
+
.map((line) => line.trim())
|
|
895
|
+
.filter((line) => line && !line.startsWith('List of devices'))
|
|
896
|
+
.map((line) => {
|
|
897
|
+
const [id, stateRaw, ...details] = line.split(/\s+/);
|
|
898
|
+
const detailMap = parseDetailTokens(details);
|
|
899
|
+
const model = detailMap.model?.replaceAll('_', ' ');
|
|
900
|
+
return {
|
|
901
|
+
id,
|
|
902
|
+
name: model ?? id,
|
|
903
|
+
platform: 'android',
|
|
904
|
+
kind: id.startsWith('emulator-') ? 'emulator' : 'real',
|
|
905
|
+
state: normalizeAdbState(stateRaw),
|
|
906
|
+
model,
|
|
907
|
+
raw: line
|
|
908
|
+
};
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
export function parseAaptBadging(output) {
|
|
912
|
+
const packageName = output.match(/package: name='([^']+)'/)?.[1];
|
|
913
|
+
if (!packageName) {
|
|
914
|
+
throw new AsturError('APK_PACKAGE_NOT_FOUND', 'Could not find package name in APK badging output.');
|
|
915
|
+
}
|
|
916
|
+
return {
|
|
917
|
+
packageName,
|
|
918
|
+
launchActivity: output.match(/launchable-activity: name='([^']+)'/)?.[1],
|
|
919
|
+
versionName: output.match(/versionName='([^']+)'/)?.[1]
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
export function parseAndroidKeyboardState(output) {
|
|
923
|
+
const imeSource = output.match(/InsetsSource id=.*? type=ime .*?(?=\n)/);
|
|
924
|
+
const imeSourceLine = imeSource?.[0] ?? '';
|
|
925
|
+
const visible = /\bvisible=true\b/.test(imeSourceLine) || /\bmImeShowing=true\b/.test(output);
|
|
926
|
+
if (!visible) {
|
|
927
|
+
return { visible: false };
|
|
928
|
+
}
|
|
929
|
+
const visibleFrame = imeSourceLine.match(/visibleFrame=(\[[^\]]+\]\[[^\]]+\])/);
|
|
930
|
+
const frame = imeSourceLine.match(/frame=(\[[^\]]+\]\[[^\]]+\])/);
|
|
931
|
+
const sourceFrame = output.match(/mSourceFrame=Rect\((\d+),\s*(\d+)\s*-\s*(\d+),\s*(\d+)\)/);
|
|
932
|
+
const bounds = parseAndroidWindowBounds(visibleFrame?.[1])
|
|
933
|
+
?? parseAndroidWindowBounds(frame?.[1])
|
|
934
|
+
?? (sourceFrame
|
|
935
|
+
? rectToBounds(Number(sourceFrame[1]), Number(sourceFrame[2]), Number(sourceFrame[3]), Number(sourceFrame[4]))
|
|
936
|
+
: undefined);
|
|
937
|
+
return bounds ? { visible: true, bounds } : { visible: true };
|
|
938
|
+
}
|
|
939
|
+
export function parseAndroidLockState(output) {
|
|
940
|
+
return /\bshowing=true\b/.test(output)
|
|
941
|
+
|| /\bmDreamingLockscreen=true\b/.test(output)
|
|
942
|
+
|| /\bmInputRestricted=true\b/.test(output)
|
|
943
|
+
|| /\bmAwake=false\b/.test(output)
|
|
944
|
+
|| /\bmScreenOn(?:Early|Fully)?=false\b/.test(output);
|
|
945
|
+
}
|
|
946
|
+
export function parseAndroidWebViewSockets(output) {
|
|
947
|
+
const sockets = new Set();
|
|
948
|
+
const pattern = /@?((?:webview|chrome|content_shell)_devtools_remote(?:_[A-Za-z0-9_.-]+)?)/g;
|
|
949
|
+
let match;
|
|
950
|
+
while ((match = pattern.exec(output)) !== null) {
|
|
951
|
+
sockets.add(match[1]);
|
|
952
|
+
}
|
|
953
|
+
return [...sockets].sort();
|
|
954
|
+
}
|
|
955
|
+
export function parseAndroidLs(output, remotePath) {
|
|
956
|
+
return output
|
|
957
|
+
.split(/\r?\n/)
|
|
958
|
+
.map((line) => line.trim())
|
|
959
|
+
.filter((line) => line && !line.startsWith('total '))
|
|
960
|
+
.map((line) => parseAndroidLsLine(line, remotePath))
|
|
961
|
+
.filter((entry) => Boolean(entry));
|
|
962
|
+
}
|
|
963
|
+
function webViewContextFromTarget(socket, target) {
|
|
964
|
+
return {
|
|
965
|
+
id: target.id ? `${socket}:${target.id}` : socket,
|
|
966
|
+
type: 'webview',
|
|
967
|
+
title: target.title,
|
|
968
|
+
url: target.url,
|
|
969
|
+
socket,
|
|
970
|
+
pageId: target.id
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
function matchesWebViewSelector(context, selector) {
|
|
974
|
+
if (selector.id && selector.id !== context.id && selector.id !== context.socket && selector.id !== context.pageId) {
|
|
975
|
+
return false;
|
|
976
|
+
}
|
|
977
|
+
if (selector.packageName && selector.packageName !== context.packageName) {
|
|
978
|
+
return false;
|
|
979
|
+
}
|
|
980
|
+
if (selector.title && !matchesText(context.title, selector.title)) {
|
|
981
|
+
return false;
|
|
982
|
+
}
|
|
983
|
+
if (selector.url && !matchesText(context.url, selector.url)) {
|
|
984
|
+
return false;
|
|
985
|
+
}
|
|
986
|
+
return true;
|
|
987
|
+
}
|
|
988
|
+
function matchesText(actual, expected) {
|
|
989
|
+
if (!actual) {
|
|
990
|
+
return false;
|
|
991
|
+
}
|
|
992
|
+
return expected instanceof RegExp ? expected.test(actual) : actual.includes(expected);
|
|
993
|
+
}
|
|
994
|
+
function readDevtoolsTargets(cdpUrl) {
|
|
995
|
+
return new Promise((resolve, reject) => {
|
|
996
|
+
const request = httpGet(`${cdpUrl}/json/list`, (response) => {
|
|
997
|
+
const chunks = [];
|
|
998
|
+
response.on('data', (chunk) => {
|
|
999
|
+
chunks.push(chunk);
|
|
1000
|
+
});
|
|
1001
|
+
response.on('end', () => {
|
|
1002
|
+
try {
|
|
1003
|
+
const body = Buffer.concat(chunks).toString('utf8');
|
|
1004
|
+
const parsed = JSON.parse(body);
|
|
1005
|
+
resolve(parsed.filter((target) => !target.type || target.type === 'page' || target.type === 'webview'));
|
|
1006
|
+
}
|
|
1007
|
+
catch (error) {
|
|
1008
|
+
reject(error);
|
|
1009
|
+
}
|
|
1010
|
+
});
|
|
1011
|
+
});
|
|
1012
|
+
request.on('error', reject);
|
|
1013
|
+
request.setTimeout(3_000, () => {
|
|
1014
|
+
request.destroy(new Error(`Timed out reading ${cdpUrl}/json/list`));
|
|
1015
|
+
});
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
function selectDevice(devices, selector) {
|
|
1019
|
+
return devices.find((device) => {
|
|
1020
|
+
if (device.state !== 'online') {
|
|
1021
|
+
return false;
|
|
1022
|
+
}
|
|
1023
|
+
if (selector.id && selector.id !== device.id) {
|
|
1024
|
+
return false;
|
|
1025
|
+
}
|
|
1026
|
+
if (selector.kind && selector.kind !== device.kind) {
|
|
1027
|
+
return false;
|
|
1028
|
+
}
|
|
1029
|
+
if (selector.name instanceof RegExp && !selector.name.test(device.name)) {
|
|
1030
|
+
return false;
|
|
1031
|
+
}
|
|
1032
|
+
if (typeof selector.name === 'string' && selector.name !== device.name) {
|
|
1033
|
+
return false;
|
|
1034
|
+
}
|
|
1035
|
+
return true;
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
function isElementSelectorTarget(target) {
|
|
1039
|
+
return 'selector' in target;
|
|
1040
|
+
}
|
|
1041
|
+
function shouldAutoBoot(selector) {
|
|
1042
|
+
return Boolean(selector.avd && selector.autoBoot !== false);
|
|
1043
|
+
}
|
|
1044
|
+
function resolveAndroidAgentArtifacts() {
|
|
1045
|
+
const appApkPath = process.env.ASTUR_ANDROID_AGENT_APK
|
|
1046
|
+
?? resolveFirstExistingAndroidAgentArtifact('astur-android-agent-debug.apk', 'debug');
|
|
1047
|
+
const testApkPath = process.env.ASTUR_ANDROID_AGENT_TEST_APK
|
|
1048
|
+
?? resolveFirstExistingAndroidAgentArtifact('astur-android-agent-debug-androidTest.apk', 'androidTest/debug');
|
|
1049
|
+
if (!existsSync(appApkPath) || !existsSync(testApkPath)) {
|
|
1050
|
+
return undefined;
|
|
1051
|
+
}
|
|
1052
|
+
return {
|
|
1053
|
+
appApkPath,
|
|
1054
|
+
testApkPath
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
function resolveFirstExistingAndroidAgentArtifact(fileName, variantPath) {
|
|
1058
|
+
const androidPackageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
1059
|
+
const candidates = [
|
|
1060
|
+
join(androidPackageRoot, 'assets', 'agent', fileName),
|
|
1061
|
+
resolveDefaultAndroidAgentArtifact(fileName, variantPath)
|
|
1062
|
+
];
|
|
1063
|
+
return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0];
|
|
1064
|
+
}
|
|
1065
|
+
function resolveDefaultAndroidAgentArtifact(fileName, variantPath) {
|
|
1066
|
+
const androidPackageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
1067
|
+
return join(androidPackageRoot, '..', 'android-agent', 'build', 'outputs', 'apk', variantPath, fileName);
|
|
1068
|
+
}
|
|
1069
|
+
function resolveAndroidAgentRuntimeConfig() {
|
|
1070
|
+
return {
|
|
1071
|
+
devicePort: parseOptionalPort(process.env.ASTUR_ANDROID_AGENT_DEVICE_PORT) ?? 8729,
|
|
1072
|
+
hostPort: parseOptionalPort(process.env.ASTUR_ANDROID_AGENT_HOST_PORT),
|
|
1073
|
+
packageName: process.env.ASTUR_ANDROID_AGENT_PACKAGE ?? 'dev.astur.agent',
|
|
1074
|
+
testPackage: process.env.ASTUR_ANDROID_AGENT_TEST_PACKAGE ?? 'dev.astur.agent.test',
|
|
1075
|
+
runnerClass: process.env.ASTUR_ANDROID_AGENT_RUNNER ?? 'dev.astur.agent.AsturInstrumentationRunner'
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
async function shouldInstallAndroidAgent(adb, config) {
|
|
1079
|
+
if (isTruthy(process.env.ASTUR_ANDROID_AGENT_FORCE_INSTALL)) {
|
|
1080
|
+
return true;
|
|
1081
|
+
}
|
|
1082
|
+
const [appInstalled, testInstalled] = await Promise.all([
|
|
1083
|
+
isAndroidPackageInstalled(adb, config.packageName),
|
|
1084
|
+
isAndroidPackageInstalled(adb, config.testPackage)
|
|
1085
|
+
]);
|
|
1086
|
+
return !appInstalled || !testInstalled;
|
|
1087
|
+
}
|
|
1088
|
+
async function isAndroidPackageInstalled(adb, packageName) {
|
|
1089
|
+
try {
|
|
1090
|
+
const result = await adb(['shell', 'pm', 'path', packageName]);
|
|
1091
|
+
return result.stdout.toString('utf8').trim().startsWith('package:');
|
|
1092
|
+
}
|
|
1093
|
+
catch {
|
|
1094
|
+
return false;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
function isTruthy(value) {
|
|
1098
|
+
return value === '1' || value?.toLowerCase() === 'true' || value?.toLowerCase() === 'yes';
|
|
1099
|
+
}
|
|
1100
|
+
function androidRotationForOrientation(orientation) {
|
|
1101
|
+
switch (orientation) {
|
|
1102
|
+
case 'portrait':
|
|
1103
|
+
return 0;
|
|
1104
|
+
case 'landscape':
|
|
1105
|
+
case 'landscape-left':
|
|
1106
|
+
return 1;
|
|
1107
|
+
case 'portrait-upside-down':
|
|
1108
|
+
return 2;
|
|
1109
|
+
case 'landscape-right':
|
|
1110
|
+
return 3;
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
function normalizeAndroidPermission(permission) {
|
|
1114
|
+
const value = permission.trim();
|
|
1115
|
+
if (value.includes('.')) {
|
|
1116
|
+
return value;
|
|
1117
|
+
}
|
|
1118
|
+
return `android.permission.${value.replace(/[\s-]+/g, '_').toUpperCase()}`;
|
|
1119
|
+
}
|
|
1120
|
+
function parseOptionalPort(value) {
|
|
1121
|
+
if (!value) {
|
|
1122
|
+
return undefined;
|
|
1123
|
+
}
|
|
1124
|
+
const port = Number(value);
|
|
1125
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65_535) {
|
|
1126
|
+
throw new AsturError('ANDROID_AGENT_PORT_INVALID', `Invalid Android agent port: ${value}`);
|
|
1127
|
+
}
|
|
1128
|
+
return port;
|
|
1129
|
+
}
|
|
1130
|
+
function findFreePort() {
|
|
1131
|
+
return new Promise((resolve, reject) => {
|
|
1132
|
+
const server = createNetServer();
|
|
1133
|
+
server.unref();
|
|
1134
|
+
server.once('error', reject);
|
|
1135
|
+
server.listen(0, '127.0.0.1', () => {
|
|
1136
|
+
const address = server.address();
|
|
1137
|
+
server.close(() => {
|
|
1138
|
+
if (!address || typeof address === 'string') {
|
|
1139
|
+
reject(new AsturError('ANDROID_AGENT_PORT_UNAVAILABLE', 'Failed to allocate a host port for the Android agent.'));
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
resolve(address.port);
|
|
1143
|
+
});
|
|
1144
|
+
});
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
async function waitForAndroidAgent(endpoint, capabilities) {
|
|
1148
|
+
const deadline = Date.now() + capabilities.agent.launchTimeout;
|
|
1149
|
+
let lastError;
|
|
1150
|
+
while (Date.now() <= deadline) {
|
|
1151
|
+
try {
|
|
1152
|
+
const remaining = Math.max(100, deadline - Date.now());
|
|
1153
|
+
return await connectNativeAgentClient({
|
|
1154
|
+
endpoint,
|
|
1155
|
+
platform: 'android',
|
|
1156
|
+
handshakeTimeout: Math.min(1_000, remaining),
|
|
1157
|
+
commandTimeout: capabilities.agent.commandTimeout
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
catch (error) {
|
|
1161
|
+
lastError = error;
|
|
1162
|
+
await delay(250);
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
throw new AsturError('ANDROID_AGENT_CONNECT_FAILED', `Timed out waiting for Android native agent at ${endpoint}.`, {
|
|
1166
|
+
endpoint,
|
|
1167
|
+
timeout: capabilities.agent.launchTimeout,
|
|
1168
|
+
cause: lastError
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
function resolveEmulatorPath() {
|
|
1172
|
+
const executable = process.platform === 'win32' ? 'emulator.exe' : 'emulator';
|
|
1173
|
+
const sdkRoot = process.env.ANDROID_HOME ?? process.env.ANDROID_SDK_ROOT;
|
|
1174
|
+
if (sdkRoot) {
|
|
1175
|
+
const candidate = join(sdkRoot, 'emulator', executable);
|
|
1176
|
+
if (existsSync(candidate)) {
|
|
1177
|
+
return candidate;
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
// Fall back to PATH lookup so an explicitly-configured emulator still works.
|
|
1181
|
+
return executable;
|
|
1182
|
+
}
|
|
1183
|
+
function resolveAaptPath() {
|
|
1184
|
+
const executable = process.platform === 'win32' ? 'aapt.exe' : 'aapt';
|
|
1185
|
+
const sdkRoot = process.env.ANDROID_HOME ?? process.env.ANDROID_SDK_ROOT;
|
|
1186
|
+
if (!sdkRoot) {
|
|
1187
|
+
return undefined;
|
|
1188
|
+
}
|
|
1189
|
+
const buildToolsPath = join(sdkRoot, 'build-tools');
|
|
1190
|
+
if (!existsSync(buildToolsPath)) {
|
|
1191
|
+
return undefined;
|
|
1192
|
+
}
|
|
1193
|
+
const versions = readdirSync(buildToolsPath).sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
|
|
1194
|
+
for (const version of versions) {
|
|
1195
|
+
const candidate = join(buildToolsPath, version, executable);
|
|
1196
|
+
if (existsSync(candidate)) {
|
|
1197
|
+
return candidate;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
return undefined;
|
|
1201
|
+
}
|
|
1202
|
+
function parseDetailTokens(tokens) {
|
|
1203
|
+
const details = {};
|
|
1204
|
+
for (const token of tokens) {
|
|
1205
|
+
const index = token.indexOf(':');
|
|
1206
|
+
if (index > 0) {
|
|
1207
|
+
details[token.slice(0, index)] = token.slice(index + 1);
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
return details;
|
|
1211
|
+
}
|
|
1212
|
+
function parseAndroidWindowBounds(value) {
|
|
1213
|
+
const match = value?.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
|
|
1214
|
+
if (!match) {
|
|
1215
|
+
return undefined;
|
|
1216
|
+
}
|
|
1217
|
+
return rectToBounds(Number(match[1]), Number(match[2]), Number(match[3]), Number(match[4]));
|
|
1218
|
+
}
|
|
1219
|
+
function rectToBounds(left, top, right, bottom) {
|
|
1220
|
+
const width = right - left;
|
|
1221
|
+
const height = bottom - top;
|
|
1222
|
+
if (width <= 0 || height <= 0) {
|
|
1223
|
+
return undefined;
|
|
1224
|
+
}
|
|
1225
|
+
return {
|
|
1226
|
+
x: left,
|
|
1227
|
+
y: top,
|
|
1228
|
+
width,
|
|
1229
|
+
height
|
|
1230
|
+
};
|
|
1231
|
+
}
|
|
1232
|
+
function parseAndroidLsLine(line, remotePath) {
|
|
1233
|
+
const parts = line.split(/\s+/);
|
|
1234
|
+
if (parts.length < 8) {
|
|
1235
|
+
return undefined;
|
|
1236
|
+
}
|
|
1237
|
+
const mode = parts[0];
|
|
1238
|
+
const size = Number(parts[4]);
|
|
1239
|
+
const name = parts.slice(7).join(' ');
|
|
1240
|
+
if (!name || name === '.' || name === '..') {
|
|
1241
|
+
return undefined;
|
|
1242
|
+
}
|
|
1243
|
+
return {
|
|
1244
|
+
name,
|
|
1245
|
+
path: joinRemotePath(remotePath, name),
|
|
1246
|
+
type: mode.startsWith('d') ? 'directory' : mode.startsWith('-') ? 'file' : 'other',
|
|
1247
|
+
size: Number.isFinite(size) ? size : undefined
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
function joinRemotePath(remotePath, name) {
|
|
1251
|
+
return `${remotePath.replace(/\/+$/, '')}/${name}`;
|
|
1252
|
+
}
|
|
1253
|
+
function waitForProcessExit(child, timeout) {
|
|
1254
|
+
return new Promise((resolve) => {
|
|
1255
|
+
if (child.exitCode !== null || child.killed) {
|
|
1256
|
+
resolve();
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
const timer = setTimeout(() => {
|
|
1260
|
+
child.kill('SIGKILL');
|
|
1261
|
+
resolve();
|
|
1262
|
+
}, timeout);
|
|
1263
|
+
child.once('exit', () => {
|
|
1264
|
+
clearTimeout(timer);
|
|
1265
|
+
resolve();
|
|
1266
|
+
});
|
|
1267
|
+
});
|
|
1268
|
+
}
|
|
1269
|
+
export function normalizeAndroidKey(key) {
|
|
1270
|
+
const normalized = key.trim().toUpperCase().replace(/[\s-]+/g, '_');
|
|
1271
|
+
const aliases = {
|
|
1272
|
+
APP_SWITCH: 'KEYCODE_APP_SWITCH',
|
|
1273
|
+
BACK: 'KEYCODE_BACK',
|
|
1274
|
+
ENTER: 'KEYCODE_ENTER',
|
|
1275
|
+
HOME: 'KEYCODE_HOME',
|
|
1276
|
+
MENU: 'KEYCODE_MENU',
|
|
1277
|
+
POWER: 'KEYCODE_POWER',
|
|
1278
|
+
RECENT_APPS: 'KEYCODE_APP_SWITCH',
|
|
1279
|
+
RECENTS: 'KEYCODE_APP_SWITCH',
|
|
1280
|
+
SEARCH: 'KEYCODE_SEARCH',
|
|
1281
|
+
SLEEP: 'KEYCODE_SLEEP',
|
|
1282
|
+
TAB: 'KEYCODE_TAB',
|
|
1283
|
+
WAKEUP: 'KEYCODE_WAKEUP',
|
|
1284
|
+
VOLUME_DOWN: 'KEYCODE_VOLUME_DOWN',
|
|
1285
|
+
VOLUME_UP: 'KEYCODE_VOLUME_UP'
|
|
1286
|
+
};
|
|
1287
|
+
return aliases[normalized] ?? (normalized.startsWith('KEYCODE_') ? normalized : key);
|
|
1288
|
+
}
|
|
1289
|
+
function normalizeAdbState(state) {
|
|
1290
|
+
if (state === 'device') {
|
|
1291
|
+
return 'online';
|
|
1292
|
+
}
|
|
1293
|
+
if (state === 'offline' || state === 'unauthorized') {
|
|
1294
|
+
return state;
|
|
1295
|
+
}
|
|
1296
|
+
return 'unknown';
|
|
1297
|
+
}
|
|
1298
|
+
function escapeAndroidInputText(value) {
|
|
1299
|
+
return value.replaceAll('%', '%25').replace(/\s/g, '%s');
|
|
1300
|
+
}
|
|
1301
|
+
function firstLine(value) {
|
|
1302
|
+
return value.split(/\r?\n/).find(Boolean) ?? value.trim();
|
|
1303
|
+
}
|
|
1304
|
+
function warnAndroidAgentFallback(message) {
|
|
1305
|
+
if (process.env.ASTUR_ANDROID_AGENT_QUIET === '1') {
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
console.warn(`[astur/android] ${message}`);
|
|
1309
|
+
}
|
|
1310
|
+
function isAgentCommandUnsupported(error) {
|
|
1311
|
+
if (!(error instanceof AsturError)) {
|
|
1312
|
+
return false;
|
|
1313
|
+
}
|
|
1314
|
+
return error.code === 'NOT_IMPLEMENTED' || error.code === 'UNKNOWN_COMMAND';
|
|
1315
|
+
}
|
|
1316
|
+
function isAgentCommandFailure(error) {
|
|
1317
|
+
if (!(error instanceof AsturError)) {
|
|
1318
|
+
return false;
|
|
1319
|
+
}
|
|
1320
|
+
return !isAgentTransportFailure(error);
|
|
1321
|
+
}
|
|
1322
|
+
function isAgentTransportFailure(error) {
|
|
1323
|
+
return error.code.startsWith('AGENT_') && !isAgentCommandUnsupported(error);
|
|
1324
|
+
}
|
|
1325
|
+
function allowsLegacyFallback(capabilities, reason) {
|
|
1326
|
+
if (capabilities.agent.mode === 'required') {
|
|
1327
|
+
return false;
|
|
1328
|
+
}
|
|
1329
|
+
switch (capabilities.agent.legacyFallback) {
|
|
1330
|
+
case 'never':
|
|
1331
|
+
return false;
|
|
1332
|
+
case 'on-unsupported-command':
|
|
1333
|
+
return reason === 'unsupported';
|
|
1334
|
+
case 'on-agent-failure':
|
|
1335
|
+
return true;
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
//# sourceMappingURL=index.js.map
|