@panicgit/android-test-pilot 0.1.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/.claude/skills/atp/analyze-app/SKILL.md +86 -0
- package/.claude/skills/atp/app-map/SKILL.md +36 -0
- package/.claude/skills/atp/check-logs/SKILL.md +77 -0
- package/.claude/skills/atp/run-test/SKILL.md +92 -0
- package/README.ko.md +241 -0
- package/README.md +241 -0
- package/lib/android.d.ts +96 -0
- package/lib/android.js +740 -0
- package/lib/android.js.map +1 -0
- package/lib/image-utils.d.ts +28 -0
- package/lib/image-utils.js +156 -0
- package/lib/image-utils.js.map +1 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.js +90 -0
- package/lib/index.js.map +1 -0
- package/lib/ios.d.ts +54 -0
- package/lib/ios.js +241 -0
- package/lib/ios.js.map +1 -0
- package/lib/iphone-simulator.d.ts +34 -0
- package/lib/iphone-simulator.js +227 -0
- package/lib/iphone-simulator.js.map +1 -0
- package/lib/logger.d.ts +2 -0
- package/lib/logger.js +23 -0
- package/lib/logger.js.map +1 -0
- package/lib/mobile-device.d.ts +25 -0
- package/lib/mobile-device.js +141 -0
- package/lib/mobile-device.js.map +1 -0
- package/lib/mobilecli.d.ts +32 -0
- package/lib/mobilecli.js +113 -0
- package/lib/mobilecli.js.map +1 -0
- package/lib/png.d.ts +9 -0
- package/lib/png.js +20 -0
- package/lib/png.js.map +1 -0
- package/lib/robot.d.ts +116 -0
- package/lib/robot.js +10 -0
- package/lib/robot.js.map +1 -0
- package/lib/server.d.ts +3 -0
- package/lib/server.js +692 -0
- package/lib/server.js.map +1 -0
- package/lib/tiers/abstract-tier.d.ts +48 -0
- package/lib/tiers/abstract-tier.js +35 -0
- package/lib/tiers/abstract-tier.js.map +1 -0
- package/lib/tiers/screenshot-tier.d.ts +19 -0
- package/lib/tiers/screenshot-tier.js +53 -0
- package/lib/tiers/screenshot-tier.js.map +1 -0
- package/lib/tiers/text-tier.d.ts +20 -0
- package/lib/tiers/text-tier.js +138 -0
- package/lib/tiers/text-tier.js.map +1 -0
- package/lib/tiers/tier-runner.d.ts +27 -0
- package/lib/tiers/tier-runner.js +91 -0
- package/lib/tiers/tier-runner.js.map +1 -0
- package/lib/tiers/types.d.ts +100 -0
- package/lib/tiers/types.js +12 -0
- package/lib/tiers/types.js.map +1 -0
- package/lib/tiers/uiautomator-tier.d.ts +16 -0
- package/lib/tiers/uiautomator-tier.js +91 -0
- package/lib/tiers/uiautomator-tier.js.map +1 -0
- package/lib/utils.d.ts +4 -0
- package/lib/utils.js +81 -0
- package/lib/utils.js.map +1 -0
- package/lib/webdriver-agent.d.ts +45 -0
- package/lib/webdriver-agent.js +400 -0
- package/lib/webdriver-agent.js.map +1 -0
- package/package.json +50 -0
- package/templates/scenario.md +49 -0
package/lib/android.js
ADDED
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.AndroidDeviceManager = exports.AndroidRobot = void 0;
|
|
40
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
41
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
42
|
+
const node_child_process_1 = require("node:child_process");
|
|
43
|
+
const node_fs_1 = require("node:fs");
|
|
44
|
+
const xml = __importStar(require("fast-xml-parser"));
|
|
45
|
+
const robot_1 = require("./robot");
|
|
46
|
+
const utils_1 = require("./utils");
|
|
47
|
+
const logger_1 = require("./logger");
|
|
48
|
+
/** Global store for active logcat sessions across all devices */
|
|
49
|
+
const activeSessions = new Map();
|
|
50
|
+
/** Max lines to keep in a logcat session buffer to prevent memory exhaustion */
|
|
51
|
+
const MAX_LOGCAT_LINES = 50_000;
|
|
52
|
+
/** Clean up all active logcat sessions (called on process exit) */
|
|
53
|
+
const cleanupAllSessions = () => {
|
|
54
|
+
for (const [, session] of activeSessions) {
|
|
55
|
+
clearTimeout(session.timer);
|
|
56
|
+
session.process.kill("SIGTERM");
|
|
57
|
+
}
|
|
58
|
+
activeSessions.clear();
|
|
59
|
+
};
|
|
60
|
+
process.on("exit", cleanupAllSessions);
|
|
61
|
+
process.on("SIGTERM", () => { cleanupAllSessions(); process.exit(0); });
|
|
62
|
+
process.on("SIGINT", () => { cleanupAllSessions(); process.exit(0); });
|
|
63
|
+
const getAdbPath = () => {
|
|
64
|
+
const exeName = process.env.platform === "win32" ? "adb.exe" : "adb";
|
|
65
|
+
if (process.env.ANDROID_HOME) {
|
|
66
|
+
return node_path_1.default.join(process.env.ANDROID_HOME, "platform-tools", exeName);
|
|
67
|
+
}
|
|
68
|
+
if (process.platform === "win32" && process.env.LOCALAPPDATA) {
|
|
69
|
+
const windowsAdbPath = node_path_1.default.join(process.env.LOCALAPPDATA, "Android", "Sdk", "platform-tools", "adb.exe");
|
|
70
|
+
if ((0, node_fs_1.existsSync)(windowsAdbPath)) {
|
|
71
|
+
return windowsAdbPath;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (process.platform === "darwin" && process.env.HOME) {
|
|
75
|
+
const defaultAndroidSdk = node_path_1.default.join(process.env.HOME, "Library", "Android", "sdk", "platform-tools", "adb");
|
|
76
|
+
if ((0, node_fs_1.existsSync)(defaultAndroidSdk)) {
|
|
77
|
+
return defaultAndroidSdk;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// fallthrough, hope for the best
|
|
81
|
+
return exeName;
|
|
82
|
+
};
|
|
83
|
+
const BUTTON_MAP = {
|
|
84
|
+
"BACK": "KEYCODE_BACK",
|
|
85
|
+
"HOME": "KEYCODE_HOME",
|
|
86
|
+
"VOLUME_UP": "KEYCODE_VOLUME_UP",
|
|
87
|
+
"VOLUME_DOWN": "KEYCODE_VOLUME_DOWN",
|
|
88
|
+
"ENTER": "KEYCODE_ENTER",
|
|
89
|
+
"DPAD_CENTER": "KEYCODE_DPAD_CENTER",
|
|
90
|
+
"DPAD_UP": "KEYCODE_DPAD_UP",
|
|
91
|
+
"DPAD_DOWN": "KEYCODE_DPAD_DOWN",
|
|
92
|
+
"DPAD_LEFT": "KEYCODE_DPAD_LEFT",
|
|
93
|
+
"DPAD_RIGHT": "KEYCODE_DPAD_RIGHT",
|
|
94
|
+
};
|
|
95
|
+
const TIMEOUT = 30000;
|
|
96
|
+
const MAX_BUFFER_SIZE = 1024 * 1024 * 8;
|
|
97
|
+
class AndroidRobot {
|
|
98
|
+
deviceId;
|
|
99
|
+
constructor(deviceId) {
|
|
100
|
+
this.deviceId = deviceId;
|
|
101
|
+
}
|
|
102
|
+
getDeviceId() {
|
|
103
|
+
return this.deviceId;
|
|
104
|
+
}
|
|
105
|
+
adb(...args) {
|
|
106
|
+
return (0, node_child_process_1.execFileSync)(getAdbPath(), ["-s", this.deviceId, ...args], {
|
|
107
|
+
maxBuffer: MAX_BUFFER_SIZE,
|
|
108
|
+
timeout: TIMEOUT,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
silentAdb(...args) {
|
|
112
|
+
return (0, node_child_process_1.execFileSync)(getAdbPath(), ["-s", this.deviceId, ...args], {
|
|
113
|
+
maxBuffer: MAX_BUFFER_SIZE,
|
|
114
|
+
timeout: TIMEOUT,
|
|
115
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
getSystemFeatures() {
|
|
119
|
+
return this.adb("shell", "pm", "list", "features")
|
|
120
|
+
.toString()
|
|
121
|
+
.split("\n")
|
|
122
|
+
.map(line => line.trim())
|
|
123
|
+
.filter(line => line.startsWith("feature:"))
|
|
124
|
+
.map(line => line.substring("feature:".length));
|
|
125
|
+
}
|
|
126
|
+
async getScreenSize() {
|
|
127
|
+
const screenSize = this.adb("shell", "wm", "size")
|
|
128
|
+
.toString()
|
|
129
|
+
.split(" ")
|
|
130
|
+
.pop();
|
|
131
|
+
if (!screenSize) {
|
|
132
|
+
throw new Error("Failed to get screen size");
|
|
133
|
+
}
|
|
134
|
+
const scale = 1;
|
|
135
|
+
const [width, height] = screenSize.split("x").map(Number);
|
|
136
|
+
return { width, height, scale };
|
|
137
|
+
}
|
|
138
|
+
async listApps() {
|
|
139
|
+
// only apps that have a launcher activity are returned
|
|
140
|
+
return this.adb("shell", "cmd", "package", "query-activities", "-a", "android.intent.action.MAIN", "-c", "android.intent.category.LAUNCHER")
|
|
141
|
+
.toString()
|
|
142
|
+
.split("\n")
|
|
143
|
+
.map(line => line.trim())
|
|
144
|
+
.filter(line => line.startsWith("packageName="))
|
|
145
|
+
.map(line => line.substring("packageName=".length))
|
|
146
|
+
.filter((value, index, self) => self.indexOf(value) === index)
|
|
147
|
+
.map(packageName => ({
|
|
148
|
+
packageName,
|
|
149
|
+
appName: packageName,
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
152
|
+
async listPackages() {
|
|
153
|
+
return this.adb("shell", "pm", "list", "packages")
|
|
154
|
+
.toString()
|
|
155
|
+
.split("\n")
|
|
156
|
+
.map(line => line.trim())
|
|
157
|
+
.filter(line => line.startsWith("package:"))
|
|
158
|
+
.map(line => line.substring("package:".length));
|
|
159
|
+
}
|
|
160
|
+
async launchApp(packageName, locale) {
|
|
161
|
+
(0, utils_1.validatePackageName)(packageName);
|
|
162
|
+
if (locale) {
|
|
163
|
+
(0, utils_1.validateLocale)(locale);
|
|
164
|
+
try {
|
|
165
|
+
this.silentAdb("shell", "cmd", "locale", "set-app-locales", packageName, "--locales", locale);
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
// set-app-locales requires Android 13+ (API 33), silently ignore on older versions
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
this.silentAdb("shell", "monkey", "-p", packageName, "-c", "android.intent.category.LAUNCHER", "1");
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
throw new robot_1.ActionableError(`Failed launching app with package name "${packageName}", please make sure it exists`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
async listRunningProcesses() {
|
|
179
|
+
return this.adb("shell", "ps", "-e")
|
|
180
|
+
.toString()
|
|
181
|
+
.split("\n")
|
|
182
|
+
.map(line => line.trim())
|
|
183
|
+
.filter(line => line.startsWith("u")) // non-system processes
|
|
184
|
+
.map(line => line.split(/\s+/)[8]); // get process name
|
|
185
|
+
}
|
|
186
|
+
async swipe(direction) {
|
|
187
|
+
const screenSize = await this.getScreenSize();
|
|
188
|
+
const centerX = screenSize.width >> 1;
|
|
189
|
+
let x0, y0, x1, y1;
|
|
190
|
+
switch (direction) {
|
|
191
|
+
case "up":
|
|
192
|
+
x0 = x1 = centerX;
|
|
193
|
+
y0 = Math.floor(screenSize.height * 0.80);
|
|
194
|
+
y1 = Math.floor(screenSize.height * 0.20);
|
|
195
|
+
break;
|
|
196
|
+
case "down":
|
|
197
|
+
x0 = x1 = centerX;
|
|
198
|
+
y0 = Math.floor(screenSize.height * 0.20);
|
|
199
|
+
y1 = Math.floor(screenSize.height * 0.80);
|
|
200
|
+
break;
|
|
201
|
+
case "left":
|
|
202
|
+
x0 = Math.floor(screenSize.width * 0.80);
|
|
203
|
+
x1 = Math.floor(screenSize.width * 0.20);
|
|
204
|
+
y0 = y1 = Math.floor(screenSize.height * 0.50);
|
|
205
|
+
break;
|
|
206
|
+
case "right":
|
|
207
|
+
x0 = Math.floor(screenSize.width * 0.20);
|
|
208
|
+
x1 = Math.floor(screenSize.width * 0.80);
|
|
209
|
+
y0 = y1 = Math.floor(screenSize.height * 0.50);
|
|
210
|
+
break;
|
|
211
|
+
default:
|
|
212
|
+
throw new robot_1.ActionableError(`Swipe direction "${direction}" is not supported`);
|
|
213
|
+
}
|
|
214
|
+
this.adb("shell", "input", "swipe", `${x0}`, `${y0}`, `${x1}`, `${y1}`, "1000");
|
|
215
|
+
}
|
|
216
|
+
async swipeFromCoordinate(x, y, direction, distance) {
|
|
217
|
+
const screenSize = await this.getScreenSize();
|
|
218
|
+
let x0, y0, x1, y1;
|
|
219
|
+
// Use provided distance or default to 30% of screen dimension
|
|
220
|
+
const defaultDistanceY = Math.floor(screenSize.height * 0.3);
|
|
221
|
+
const defaultDistanceX = Math.floor(screenSize.width * 0.3);
|
|
222
|
+
const swipeDistanceY = distance || defaultDistanceY;
|
|
223
|
+
const swipeDistanceX = distance || defaultDistanceX;
|
|
224
|
+
switch (direction) {
|
|
225
|
+
case "up":
|
|
226
|
+
x0 = x1 = x;
|
|
227
|
+
y0 = y;
|
|
228
|
+
y1 = Math.max(0, y - swipeDistanceY);
|
|
229
|
+
break;
|
|
230
|
+
case "down":
|
|
231
|
+
x0 = x1 = x;
|
|
232
|
+
y0 = y;
|
|
233
|
+
y1 = Math.min(screenSize.height, y + swipeDistanceY);
|
|
234
|
+
break;
|
|
235
|
+
case "left":
|
|
236
|
+
x0 = x;
|
|
237
|
+
x1 = Math.max(0, x - swipeDistanceX);
|
|
238
|
+
y0 = y1 = y;
|
|
239
|
+
break;
|
|
240
|
+
case "right":
|
|
241
|
+
x0 = x;
|
|
242
|
+
x1 = Math.min(screenSize.width, x + swipeDistanceX);
|
|
243
|
+
y0 = y1 = y;
|
|
244
|
+
break;
|
|
245
|
+
default:
|
|
246
|
+
throw new robot_1.ActionableError(`Swipe direction "${direction}" is not supported`);
|
|
247
|
+
}
|
|
248
|
+
this.adb("shell", "input", "swipe", `${x0}`, `${y0}`, `${x1}`, `${y1}`, "1000");
|
|
249
|
+
}
|
|
250
|
+
getDisplayCount() {
|
|
251
|
+
return this.adb("shell", "dumpsys", "SurfaceFlinger", "--display-id")
|
|
252
|
+
.toString()
|
|
253
|
+
.split("\n")
|
|
254
|
+
.filter(s => s.startsWith("Display "))
|
|
255
|
+
.length;
|
|
256
|
+
}
|
|
257
|
+
getFirstDisplayId() {
|
|
258
|
+
try {
|
|
259
|
+
// Try using cmd display get-displays (Android 11+)
|
|
260
|
+
const displays = this.adb("shell", "cmd", "display", "get-displays")
|
|
261
|
+
.toString()
|
|
262
|
+
.split("\n")
|
|
263
|
+
.filter(s => s.startsWith("Display id "))
|
|
264
|
+
// filter for state ON even though get-displays only returns turned on displays
|
|
265
|
+
.filter(s => s.indexOf(", state ON,") >= 0)
|
|
266
|
+
// another paranoia check
|
|
267
|
+
.filter(s => s.indexOf(", uniqueId ") >= 0);
|
|
268
|
+
if (displays.length > 0) {
|
|
269
|
+
const m = displays[0].match(/uniqueId \"([^\"]+)\"/);
|
|
270
|
+
if (m !== null) {
|
|
271
|
+
let displayId = m[1];
|
|
272
|
+
if (displayId.startsWith("local:")) {
|
|
273
|
+
displayId = displayId.substring("local:".length);
|
|
274
|
+
}
|
|
275
|
+
return displayId;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
// cmd display get-displays not available on this device
|
|
281
|
+
}
|
|
282
|
+
// fallback: parse dumpsys display for display info (compatible with older Android versions)
|
|
283
|
+
try {
|
|
284
|
+
const dumpsys = this.adb("shell", "dumpsys", "display")
|
|
285
|
+
.toString();
|
|
286
|
+
// look for DisplayViewport entries with isActive=true and type=INTERNAL
|
|
287
|
+
const viewportMatch = dumpsys.match(/DisplayViewport\{type=INTERNAL[^}]*isActive=true[^}]*uniqueId='([^']+)'/);
|
|
288
|
+
if (viewportMatch) {
|
|
289
|
+
let uniqueId = viewportMatch[1];
|
|
290
|
+
if (uniqueId.startsWith("local:")) {
|
|
291
|
+
uniqueId = uniqueId.substring("local:".length);
|
|
292
|
+
}
|
|
293
|
+
return uniqueId;
|
|
294
|
+
}
|
|
295
|
+
// fallback: look for active display with state ON
|
|
296
|
+
const displayStateMatch = dumpsys.match(/Display Id=(\d+)[\s\S]*?Display State=ON/);
|
|
297
|
+
if (displayStateMatch) {
|
|
298
|
+
return displayStateMatch[1];
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
catch (error) {
|
|
302
|
+
// dumpsys display also failed
|
|
303
|
+
}
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
async getScreenshot() {
|
|
307
|
+
if (this.getDisplayCount() <= 1) {
|
|
308
|
+
// backward compatibility for android 10 and below, and for single display devices
|
|
309
|
+
return this.adb("exec-out", "screencap", "-p");
|
|
310
|
+
}
|
|
311
|
+
// find the first display that is turned on, and capture that one
|
|
312
|
+
const displayId = this.getFirstDisplayId();
|
|
313
|
+
if (displayId === null) {
|
|
314
|
+
// no idea why, but we have displayCount >= 2, yet we failed to parse
|
|
315
|
+
// let's go with screencap's defaults and hope for the best
|
|
316
|
+
return this.adb("exec-out", "screencap", "-p");
|
|
317
|
+
}
|
|
318
|
+
return this.adb("exec-out", "screencap", "-p", "-d", `${displayId}`);
|
|
319
|
+
}
|
|
320
|
+
collectElements(node) {
|
|
321
|
+
const elements = [];
|
|
322
|
+
if (node.node) {
|
|
323
|
+
if (Array.isArray(node.node)) {
|
|
324
|
+
for (const childNode of node.node) {
|
|
325
|
+
elements.push(...this.collectElements(childNode));
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
elements.push(...this.collectElements(node.node));
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
if (node.text || node["content-desc"] || node.hint || node["resource-id"] || node.checkable === "true") {
|
|
333
|
+
const element = {
|
|
334
|
+
type: node.class || "text",
|
|
335
|
+
text: node.text,
|
|
336
|
+
label: node["content-desc"] || node.hint || "",
|
|
337
|
+
rect: this.getScreenElementRect(node),
|
|
338
|
+
};
|
|
339
|
+
if (node.focused === "true") {
|
|
340
|
+
// only provide it if it's true, otherwise don't confuse llm
|
|
341
|
+
element.focused = true;
|
|
342
|
+
}
|
|
343
|
+
const resourceId = node["resource-id"];
|
|
344
|
+
if (resourceId !== null && resourceId !== "") {
|
|
345
|
+
element.identifier = resourceId;
|
|
346
|
+
}
|
|
347
|
+
if (element.rect.width > 0 && element.rect.height > 0) {
|
|
348
|
+
elements.push(element);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return elements;
|
|
352
|
+
}
|
|
353
|
+
async getElementsOnScreen() {
|
|
354
|
+
const parsedXml = await this.getUiAutomatorXml();
|
|
355
|
+
const hierarchy = parsedXml.hierarchy;
|
|
356
|
+
const elements = this.collectElements(hierarchy.node);
|
|
357
|
+
return elements;
|
|
358
|
+
}
|
|
359
|
+
async terminateApp(packageName) {
|
|
360
|
+
(0, utils_1.validatePackageName)(packageName);
|
|
361
|
+
this.adb("shell", "am", "force-stop", packageName);
|
|
362
|
+
}
|
|
363
|
+
async installApp(path) {
|
|
364
|
+
try {
|
|
365
|
+
this.adb("install", "-r", path);
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
const stdout = error.stdout ? error.stdout.toString() : "";
|
|
369
|
+
const stderr = error.stderr ? error.stderr.toString() : "";
|
|
370
|
+
const output = (stdout + stderr).trim();
|
|
371
|
+
throw new robot_1.ActionableError(output || error.message);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
async uninstallApp(bundleId) {
|
|
375
|
+
try {
|
|
376
|
+
this.adb("uninstall", bundleId);
|
|
377
|
+
}
|
|
378
|
+
catch (error) {
|
|
379
|
+
const stdout = error.stdout ? error.stdout.toString() : "";
|
|
380
|
+
const stderr = error.stderr ? error.stderr.toString() : "";
|
|
381
|
+
const output = (stdout + stderr).trim();
|
|
382
|
+
throw new robot_1.ActionableError(output || error.message);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
async openUrl(url) {
|
|
386
|
+
this.adb("shell", "am", "start", "-a", "android.intent.action.VIEW", "-d", this.escapeShellText(url));
|
|
387
|
+
}
|
|
388
|
+
isAscii(text) {
|
|
389
|
+
return /^[\x00-\x7F]*$/.test(text);
|
|
390
|
+
}
|
|
391
|
+
escapeShellText(text) {
|
|
392
|
+
// escape all shell special characters that could be used for injection
|
|
393
|
+
return text.replace(/[\\'"` \t\n\r|&;()<>{}[\]$*?]/g, "\\$&");
|
|
394
|
+
}
|
|
395
|
+
async isDeviceKitInstalled() {
|
|
396
|
+
const packages = await this.listPackages();
|
|
397
|
+
return packages.includes("com.mobilenext.devicekit");
|
|
398
|
+
}
|
|
399
|
+
async sendKeys(text) {
|
|
400
|
+
if (text === "") {
|
|
401
|
+
// bailing early, so we don't run adb shell with empty string.
|
|
402
|
+
// this happens when you prompt with a simple "submit".
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (this.isAscii(text)) {
|
|
406
|
+
// adb shell input only supports ascii characters. and
|
|
407
|
+
// some of the keys have to be escaped.
|
|
408
|
+
const _text = this.escapeShellText(text);
|
|
409
|
+
this.adb("shell", "input", "text", _text);
|
|
410
|
+
}
|
|
411
|
+
else if (await this.isDeviceKitInstalled()) {
|
|
412
|
+
// try sending over clipboard
|
|
413
|
+
const base64 = Buffer.from(text).toString("base64");
|
|
414
|
+
// send clipboard over and immediately paste it
|
|
415
|
+
this.adb("shell", "am", "broadcast", "-a", "devicekit.clipboard.set", "-e", "encoding", "base64", "-e", "text", base64, "-n", "com.mobilenext.devicekit/.ClipboardBroadcastReceiver");
|
|
416
|
+
this.adb("shell", "input", "keyevent", "KEYCODE_PASTE");
|
|
417
|
+
// clear clipboard when we're done
|
|
418
|
+
this.adb("shell", "am", "broadcast", "-a", "devicekit.clipboard.clear", "-n", "com.mobilenext.devicekit/.ClipboardBroadcastReceiver");
|
|
419
|
+
}
|
|
420
|
+
else {
|
|
421
|
+
throw new robot_1.ActionableError("Non-ASCII text is not supported on Android, please install mobilenext devicekit, see https://github.com/mobile-next/devicekit-android");
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
async pressButton(button) {
|
|
425
|
+
if (!BUTTON_MAP[button]) {
|
|
426
|
+
throw new robot_1.ActionableError(`Button "${button}" is not supported`);
|
|
427
|
+
}
|
|
428
|
+
const mapped = BUTTON_MAP[button];
|
|
429
|
+
this.adb("shell", "input", "keyevent", mapped);
|
|
430
|
+
}
|
|
431
|
+
async tap(x, y) {
|
|
432
|
+
this.adb("shell", "input", "tap", `${x}`, `${y}`);
|
|
433
|
+
}
|
|
434
|
+
async longPress(x, y, duration) {
|
|
435
|
+
// a long press is a swipe with no movement and a long duration
|
|
436
|
+
this.adb("shell", "input", "swipe", `${x}`, `${y}`, `${x}`, `${y}`, `${duration}`);
|
|
437
|
+
}
|
|
438
|
+
async doubleTap(x, y) {
|
|
439
|
+
await this.tap(x, y);
|
|
440
|
+
await new Promise(r => setTimeout(r, 100)); // short delay
|
|
441
|
+
await this.tap(x, y);
|
|
442
|
+
}
|
|
443
|
+
async setOrientation(orientation) {
|
|
444
|
+
const value = orientation === "portrait" ? 0 : 1;
|
|
445
|
+
// disable auto-rotation prior to setting the orientation
|
|
446
|
+
this.adb("shell", "settings", "put", "system", "accelerometer_rotation", "0");
|
|
447
|
+
this.adb("shell", "content", "insert", "--uri", "content://settings/system", "--bind", "name:s:user_rotation", "--bind", `value:i:${value}`);
|
|
448
|
+
}
|
|
449
|
+
async getOrientation() {
|
|
450
|
+
const rotation = this.adb("shell", "settings", "get", "system", "user_rotation").toString().trim();
|
|
451
|
+
return rotation === "0" ? "portrait" : "landscape";
|
|
452
|
+
}
|
|
453
|
+
async getUiAutomatorDump() {
|
|
454
|
+
for (let tries = 0; tries < 10; tries++) {
|
|
455
|
+
const dump = this.adb("exec-out", "uiautomator", "dump", "/dev/tty").toString();
|
|
456
|
+
// note: we're not catching other errors here. maybe we should check for <?xml
|
|
457
|
+
if (dump.includes("null root node returned by UiTestAutomationBridge")) {
|
|
458
|
+
// uncomment for debugging
|
|
459
|
+
// const screenshot = await this.getScreenshot();
|
|
460
|
+
// console.error("Failed to get UIAutomator XML. Here's a screenshot: " + screenshot.toString("base64"));
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
return dump.substring(dump.indexOf("<?xml"));
|
|
464
|
+
}
|
|
465
|
+
throw new robot_1.ActionableError("Failed to get UIAutomator XML");
|
|
466
|
+
}
|
|
467
|
+
async getUiAutomatorXml() {
|
|
468
|
+
const dump = await this.getUiAutomatorDump();
|
|
469
|
+
const parser = new xml.XMLParser({
|
|
470
|
+
ignoreAttributes: false,
|
|
471
|
+
attributeNamePrefix: "",
|
|
472
|
+
});
|
|
473
|
+
return parser.parse(dump);
|
|
474
|
+
}
|
|
475
|
+
getScreenElementRect(node) {
|
|
476
|
+
const bounds = String(node.bounds);
|
|
477
|
+
const [, left, top, right, bottom] = bounds.match(/^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$/)?.map(Number) || [];
|
|
478
|
+
return {
|
|
479
|
+
x: left,
|
|
480
|
+
y: top,
|
|
481
|
+
width: right - left,
|
|
482
|
+
height: bottom - top,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
// ─── Dumpsys Methods (Tier 1: text-based) ───────────────────────
|
|
486
|
+
/**
|
|
487
|
+
* Get the current foreground Activity via dumpsys.
|
|
488
|
+
* Returns parsed activity info as text.
|
|
489
|
+
*/
|
|
490
|
+
getDumpsysActivity() {
|
|
491
|
+
try {
|
|
492
|
+
const output = this.adb("shell", "dumpsys", "activity", "activities")
|
|
493
|
+
.toString();
|
|
494
|
+
// Extract the focused activity line
|
|
495
|
+
const lines = output.split("\n");
|
|
496
|
+
const resumedLine = lines.find(l => l.includes("mResumedActivity") || l.includes("ResumedActivity"));
|
|
497
|
+
const focusedLine = lines.find(l => l.includes("mFocusedActivity"));
|
|
498
|
+
const topLine = lines.find(l => l.includes("topResumedActivity"));
|
|
499
|
+
return JSON.stringify({
|
|
500
|
+
resumed: resumedLine?.trim() || null,
|
|
501
|
+
focused: focusedLine?.trim() || null,
|
|
502
|
+
topResumed: topLine?.trim() || null,
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
catch (err) {
|
|
506
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
507
|
+
return JSON.stringify({ error: message });
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Get the current focused window via dumpsys.
|
|
512
|
+
*/
|
|
513
|
+
getDumpsysWindow() {
|
|
514
|
+
try {
|
|
515
|
+
const output = this.adb("shell", "dumpsys", "window", "windows")
|
|
516
|
+
.toString();
|
|
517
|
+
const lines = output.split("\n");
|
|
518
|
+
const focusedLine = lines.find(l => l.includes("mCurrentFocus") || l.includes("mFocusedWindow"));
|
|
519
|
+
const inputLine = lines.find(l => l.includes("mInputMethodTarget"));
|
|
520
|
+
return JSON.stringify({
|
|
521
|
+
currentFocus: focusedLine?.trim() || null,
|
|
522
|
+
inputMethodTarget: inputLine?.trim() || null,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
catch (err) {
|
|
526
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
527
|
+
return JSON.stringify({ error: message });
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
// ─── Logcat Session Methods (Tier 1: text-based) ────────────────
|
|
531
|
+
/**
|
|
532
|
+
* Start a logcat streaming session.
|
|
533
|
+
* Spawns `adb logcat` as a background process and buffers output.
|
|
534
|
+
*/
|
|
535
|
+
startLogcat(tags, durationSeconds) {
|
|
536
|
+
const sessionId = node_crypto_1.default.randomUUID();
|
|
537
|
+
// Validate tags to prevent filter manipulation (e.g. "*" would capture all logs)
|
|
538
|
+
const TAG_PATTERN = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
|
|
539
|
+
for (const tag of tags) {
|
|
540
|
+
if (!TAG_PATTERN.test(tag)) {
|
|
541
|
+
throw new robot_1.ActionableError(`Invalid logcat tag "${tag}". Tags must be alphanumeric/underscore, 1-64 chars, starting with a letter or underscore.`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
// Build logcat filter args: TAG:D for each tag, *:S to silence others
|
|
545
|
+
const filterArgs = tags.map(tag => `${tag}:D`);
|
|
546
|
+
filterArgs.push("*:S");
|
|
547
|
+
const adbPath = getAdbPath();
|
|
548
|
+
const args = ["-s", this.deviceId, "logcat", "-v", "time", ...filterArgs];
|
|
549
|
+
(0, logger_1.trace)(`Logcat start: ${adbPath} ${args.join(" ")}`);
|
|
550
|
+
const proc = (0, node_child_process_1.spawn)(adbPath, args, {
|
|
551
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
552
|
+
});
|
|
553
|
+
const session = {
|
|
554
|
+
id: sessionId,
|
|
555
|
+
deviceId: this.deviceId,
|
|
556
|
+
process: proc,
|
|
557
|
+
buffer: [],
|
|
558
|
+
startTime: Date.now(),
|
|
559
|
+
maxDuration: durationSeconds * 1000,
|
|
560
|
+
tags,
|
|
561
|
+
timer: setTimeout(() => {
|
|
562
|
+
(0, logger_1.trace)(`Logcat session ${sessionId} auto-stopped (timeout ${durationSeconds}s)`);
|
|
563
|
+
this.stopLogcat(sessionId);
|
|
564
|
+
}, durationSeconds * 1000),
|
|
565
|
+
};
|
|
566
|
+
// Buffer stdout line by line (capped at MAX_LOGCAT_LINES)
|
|
567
|
+
let partial = "";
|
|
568
|
+
proc.stdout?.on("data", (chunk) => {
|
|
569
|
+
partial += chunk.toString();
|
|
570
|
+
const lines = partial.split("\n");
|
|
571
|
+
partial = lines.pop() || "";
|
|
572
|
+
for (const line of lines) {
|
|
573
|
+
if (line.trim() && session.buffer.length < MAX_LOGCAT_LINES) {
|
|
574
|
+
session.buffer.push(line);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
// Flush remaining partial line on stream end
|
|
579
|
+
proc.stdout?.on("end", () => {
|
|
580
|
+
if (partial.trim() && session.buffer.length < MAX_LOGCAT_LINES) {
|
|
581
|
+
session.buffer.push(partial);
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
// Log stderr for debugging (ADB errors, device disconnects)
|
|
585
|
+
proc.stderr?.on("data", (chunk) => {
|
|
586
|
+
(0, logger_1.trace)(`Logcat session ${sessionId} stderr: ${chunk.toString().trim()}`);
|
|
587
|
+
});
|
|
588
|
+
proc.on("error", (err) => {
|
|
589
|
+
(0, logger_1.trace)(`Logcat session ${sessionId} error: ${err.message}`);
|
|
590
|
+
});
|
|
591
|
+
proc.on("exit", () => {
|
|
592
|
+
clearTimeout(session.timer);
|
|
593
|
+
activeSessions.delete(sessionId);
|
|
594
|
+
});
|
|
595
|
+
activeSessions.set(sessionId, session);
|
|
596
|
+
return session;
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Read collected log lines from an active session.
|
|
600
|
+
* @param since - If provided, only return lines after this index (for incremental reads)
|
|
601
|
+
*/
|
|
602
|
+
readLogcat(sessionId, since) {
|
|
603
|
+
const session = activeSessions.get(sessionId);
|
|
604
|
+
if (!session) {
|
|
605
|
+
throw new robot_1.ActionableError(`Logcat session "${sessionId}" not found. It may have expired or been stopped.`);
|
|
606
|
+
}
|
|
607
|
+
const startIndex = Math.max(0, since ?? 0);
|
|
608
|
+
const lines = session.buffer.slice(startIndex);
|
|
609
|
+
return {
|
|
610
|
+
lines,
|
|
611
|
+
lineCount: session.buffer.length,
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Stop a logcat streaming session and return stats.
|
|
616
|
+
*/
|
|
617
|
+
stopLogcat(sessionId) {
|
|
618
|
+
const session = activeSessions.get(sessionId);
|
|
619
|
+
if (!session) {
|
|
620
|
+
throw new robot_1.ActionableError(`Logcat session "${sessionId}" not found.`);
|
|
621
|
+
}
|
|
622
|
+
clearTimeout(session.timer);
|
|
623
|
+
session.process.kill("SIGTERM");
|
|
624
|
+
activeSessions.delete(sessionId);
|
|
625
|
+
const durationMs = Date.now() - session.startTime;
|
|
626
|
+
(0, logger_1.trace)(`Logcat session ${sessionId} stopped: ${session.buffer.length} lines, ${durationMs}ms`);
|
|
627
|
+
return {
|
|
628
|
+
totalLines: session.buffer.length,
|
|
629
|
+
durationMs,
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
/** Get an active logcat session by ID (for server.ts to check existence) */
|
|
633
|
+
static getSession(sessionId) {
|
|
634
|
+
return activeSessions.get(sessionId);
|
|
635
|
+
}
|
|
636
|
+
/** Get the most recent active logcat session for a device (for TextTier) */
|
|
637
|
+
static getSessionByDevice(deviceId) {
|
|
638
|
+
let latest;
|
|
639
|
+
for (const session of activeSessions.values()) {
|
|
640
|
+
if (session.deviceId === deviceId) {
|
|
641
|
+
if (!latest || session.startTime > latest.startTime) {
|
|
642
|
+
latest = session;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return latest;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
exports.AndroidRobot = AndroidRobot;
|
|
650
|
+
class AndroidDeviceManager {
|
|
651
|
+
getDeviceType(name) {
|
|
652
|
+
try {
|
|
653
|
+
const device = new AndroidRobot(name);
|
|
654
|
+
const features = device.getSystemFeatures();
|
|
655
|
+
if (features.includes("android.software.leanback") || features.includes("android.hardware.type.television")) {
|
|
656
|
+
return "tv";
|
|
657
|
+
}
|
|
658
|
+
return "mobile";
|
|
659
|
+
}
|
|
660
|
+
catch (error) {
|
|
661
|
+
// Fallback to mobile if we cannot determine device type
|
|
662
|
+
return "mobile";
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
getDeviceVersion(deviceId) {
|
|
666
|
+
try {
|
|
667
|
+
const output = (0, node_child_process_1.execFileSync)(getAdbPath(), ["-s", deviceId, "shell", "getprop", "ro.build.version.release"], {
|
|
668
|
+
timeout: 5000,
|
|
669
|
+
}).toString().trim();
|
|
670
|
+
return output;
|
|
671
|
+
}
|
|
672
|
+
catch (error) {
|
|
673
|
+
return "unknown";
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
getDeviceName(deviceId) {
|
|
677
|
+
try {
|
|
678
|
+
// Try getting AVD name first (for emulators)
|
|
679
|
+
const avdName = (0, node_child_process_1.execFileSync)(getAdbPath(), ["-s", deviceId, "shell", "getprop", "ro.boot.qemu.avd_name"], {
|
|
680
|
+
timeout: 5000,
|
|
681
|
+
}).toString().trim();
|
|
682
|
+
if (avdName !== "") {
|
|
683
|
+
// Replace underscores with spaces (e.g., "Pixel_9_Pro" -> "Pixel 9 Pro")
|
|
684
|
+
return avdName.replace(/_/g, " ");
|
|
685
|
+
}
|
|
686
|
+
// Fall back to product model
|
|
687
|
+
const output = (0, node_child_process_1.execFileSync)(getAdbPath(), ["-s", deviceId, "shell", "getprop", "ro.product.model"], {
|
|
688
|
+
timeout: 5000,
|
|
689
|
+
}).toString().trim();
|
|
690
|
+
return output;
|
|
691
|
+
}
|
|
692
|
+
catch (error) {
|
|
693
|
+
return deviceId;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
getConnectedDevices() {
|
|
697
|
+
try {
|
|
698
|
+
const names = (0, node_child_process_1.execFileSync)(getAdbPath(), ["devices"])
|
|
699
|
+
.toString()
|
|
700
|
+
.split("\n")
|
|
701
|
+
.map(line => line.trim())
|
|
702
|
+
.filter(line => line !== "")
|
|
703
|
+
.filter(line => !line.startsWith("List of devices attached"))
|
|
704
|
+
.filter(line => line.split("\t")[1]?.trim() === "device") // Only include devices that are online and ready
|
|
705
|
+
.map(line => line.split("\t")[0]);
|
|
706
|
+
return names.map(name => ({
|
|
707
|
+
deviceId: name,
|
|
708
|
+
deviceType: this.getDeviceType(name),
|
|
709
|
+
}));
|
|
710
|
+
}
|
|
711
|
+
catch (error) {
|
|
712
|
+
console.error("Could not execute adb command, maybe ANDROID_HOME is not set?");
|
|
713
|
+
return [];
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
getConnectedDevicesWithDetails() {
|
|
717
|
+
try {
|
|
718
|
+
const names = (0, node_child_process_1.execFileSync)(getAdbPath(), ["devices"])
|
|
719
|
+
.toString()
|
|
720
|
+
.split("\n")
|
|
721
|
+
.map(line => line.trim())
|
|
722
|
+
.filter(line => line !== "")
|
|
723
|
+
.filter(line => !line.startsWith("List of devices attached"))
|
|
724
|
+
.filter(line => line.split("\t")[1]?.trim() === "device") // Only include devices that are online and ready
|
|
725
|
+
.map(line => line.split("\t")[0]);
|
|
726
|
+
return names.map(deviceId => ({
|
|
727
|
+
deviceId,
|
|
728
|
+
deviceType: this.getDeviceType(deviceId),
|
|
729
|
+
version: this.getDeviceVersion(deviceId),
|
|
730
|
+
name: this.getDeviceName(deviceId),
|
|
731
|
+
}));
|
|
732
|
+
}
|
|
733
|
+
catch (error) {
|
|
734
|
+
console.error("Could not execute adb command, maybe ANDROID_HOME is not set?");
|
|
735
|
+
return [];
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
exports.AndroidDeviceManager = AndroidDeviceManager;
|
|
740
|
+
//# sourceMappingURL=android.js.map
|