@rvaim/deveco-mobile-mcp 0.1.0-rvaim.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +15 -0
- package/README.md +129 -0
- package/lib/android.js +596 -0
- package/lib/harmony.js +805 -0
- package/lib/image-utils.js +155 -0
- package/lib/index.js +60 -0
- package/lib/ios.js +282 -0
- package/lib/iphone-simulator.js +266 -0
- package/lib/jpeg.js +48 -0
- package/lib/logger.js +22 -0
- package/lib/mobile-device.js +159 -0
- package/lib/mobilecli.js +112 -0
- package/lib/png.js +19 -0
- package/lib/robot.js +9 -0
- package/lib/server.js +615 -0
- package/lib/utils.js +80 -0
- package/lib/webdriver-agent.js +399 -0
- package/package.json +86 -0
package/lib/harmony.js
ADDED
|
@@ -0,0 +1,805 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.HarmonyDeviceManager = exports.HarmonyRobot = void 0;
|
|
7
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
8
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
9
|
+
const node_child_process_1 = require("node:child_process");
|
|
10
|
+
const node_fs_1 = require("node:fs");
|
|
11
|
+
const robot_1 = require("./robot");
|
|
12
|
+
const utils_1 = require("./utils");
|
|
13
|
+
const getHdcPath = () => {
|
|
14
|
+
const exeName = process.platform === "win32" ? "hdc.exe" : "hdc";
|
|
15
|
+
if (process.env.DEVECO_PATH) {
|
|
16
|
+
const sdkPath = node_path_1.default.join(process.env.DEVECO_PATH, "sdk", "default", "openharmony", "toolchains", exeName);
|
|
17
|
+
if ((0, node_fs_1.existsSync)(sdkPath)) {
|
|
18
|
+
return sdkPath;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return exeName;
|
|
22
|
+
};
|
|
23
|
+
// HarmonyOS uiInput keyEvent 支持的按键映射
|
|
24
|
+
// 参考:hdc shell uitest uiInput --help
|
|
25
|
+
// 支持的符号名称:Back, Home, Power
|
|
26
|
+
// 其他按键需要使用数字键码值(KeyEvent 键值)
|
|
27
|
+
const BUTTON_MAP = {
|
|
28
|
+
"BACK": "Back", // 返回键
|
|
29
|
+
"HOME": "Home", // 主页键
|
|
30
|
+
"VOLUME_UP": "24", // 音量加键码
|
|
31
|
+
"VOLUME_DOWN": "25", // 音量减键码
|
|
32
|
+
"ENTER": "2054", // 回车键码
|
|
33
|
+
"DEL": "2055", // 删除键码
|
|
34
|
+
"DPAD_CENTER": "23", // 方向键中心确认
|
|
35
|
+
"DPAD_UP": "19", // 方向键上
|
|
36
|
+
"DPAD_DOWN": "20", // 方向键下
|
|
37
|
+
"DPAD_LEFT": "21", // 方向键左
|
|
38
|
+
"DPAD_RIGHT": "22", // 方向键右
|
|
39
|
+
};
|
|
40
|
+
const TIMEOUT = 30000;
|
|
41
|
+
const MAX_BUFFER_SIZE = 1024 * 1024 * 8;
|
|
42
|
+
class HarmonyRobot {
|
|
43
|
+
deviceId;
|
|
44
|
+
constructor(deviceId) {
|
|
45
|
+
this.deviceId = deviceId;
|
|
46
|
+
}
|
|
47
|
+
hdc(...args) {
|
|
48
|
+
return (0, node_child_process_1.execFileSync)(getHdcPath(), ["-t", this.deviceId, ...args], {
|
|
49
|
+
maxBuffer: MAX_BUFFER_SIZE,
|
|
50
|
+
timeout: TIMEOUT,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* 异步执行 hdc 命令,使用流式读取避免 ENOBUFS 错误
|
|
55
|
+
*/
|
|
56
|
+
async hdcAsync(...args) {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const child = (0, node_child_process_1.spawn)(getHdcPath(), ["-t", this.deviceId, ...args], {
|
|
59
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
60
|
+
});
|
|
61
|
+
let stdout = "";
|
|
62
|
+
let stderr = "";
|
|
63
|
+
let settled = false;
|
|
64
|
+
const timeoutId = setTimeout(() => {
|
|
65
|
+
if (!settled) {
|
|
66
|
+
settled = true;
|
|
67
|
+
child.kill();
|
|
68
|
+
reject(new Error(`Command timeout after ${TIMEOUT}ms`));
|
|
69
|
+
}
|
|
70
|
+
}, TIMEOUT);
|
|
71
|
+
child.stdout?.on("data", (chunk) => {
|
|
72
|
+
stdout += chunk.toString();
|
|
73
|
+
});
|
|
74
|
+
child.stderr?.on("data", (chunk) => {
|
|
75
|
+
stderr += chunk.toString();
|
|
76
|
+
});
|
|
77
|
+
child.on("close", code => {
|
|
78
|
+
if (!settled) {
|
|
79
|
+
clearTimeout(timeoutId);
|
|
80
|
+
settled = true;
|
|
81
|
+
if (code === 0) {
|
|
82
|
+
resolve(stdout);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
reject(new Error(stderr || `Command exited with code ${code}`));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
child.on("error", err => {
|
|
90
|
+
if (!settled) {
|
|
91
|
+
clearTimeout(timeoutId);
|
|
92
|
+
settled = true;
|
|
93
|
+
reject(err);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* 获取屏幕尺寸
|
|
100
|
+
* 使用 hidumper 命令获取 WindowManagerService 的窗口信息,遍历所有窗口找到最大尺寸作为屏幕分辨率
|
|
101
|
+
*/
|
|
102
|
+
async getScreenSize() {
|
|
103
|
+
try {
|
|
104
|
+
const output = this.hdc("shell", "hidumper", "-s", "WindowManagerService", "-a", "-a").toString();
|
|
105
|
+
// 解析窗口信息,遍历所有窗口找到最大的 w × h 作为屏幕分辨率
|
|
106
|
+
// 格式:WindowName ... [ x y w h ]
|
|
107
|
+
const lines = output.split("\n");
|
|
108
|
+
let maxWidth = 0;
|
|
109
|
+
let maxHeight = 0;
|
|
110
|
+
for (const line of lines) {
|
|
111
|
+
// 跳过表头和非窗口行
|
|
112
|
+
if (!line.includes("[")) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const rectMatch = line.match(/\[\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s*\]/);
|
|
116
|
+
if (!rectMatch) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const width = parseInt(rectMatch[3], 10);
|
|
120
|
+
const height = parseInt(rectMatch[4], 10);
|
|
121
|
+
// 记录最大的窗口尺寸
|
|
122
|
+
if (width > maxWidth) {
|
|
123
|
+
maxWidth = width;
|
|
124
|
+
}
|
|
125
|
+
if (height > maxHeight) {
|
|
126
|
+
maxHeight = height;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// 返回找到的最大尺寸作为屏幕分辨率
|
|
130
|
+
if (maxWidth > 0 && maxHeight > 0) {
|
|
131
|
+
return { width: maxWidth, height: maxHeight, scale: 1 };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch (error) { }
|
|
135
|
+
throw new robot_1.ActionableError("Failed to get screen size");
|
|
136
|
+
}
|
|
137
|
+
async listApps() {
|
|
138
|
+
try {
|
|
139
|
+
const output = this.hdc("shell", "bm", "dump", "-a").toString();
|
|
140
|
+
const lines = output.split("\n");
|
|
141
|
+
const apps = [];
|
|
142
|
+
const seenPackages = new Set();
|
|
143
|
+
for (const line of lines) {
|
|
144
|
+
// 匹配以 tab 开头的包名行(格式:\tcom.xxx.xxx)
|
|
145
|
+
const bundleMatch = line.match(/^\t([a-zA-Z0-9_.]+)/);
|
|
146
|
+
if (bundleMatch) {
|
|
147
|
+
const bundleName = bundleMatch[1];
|
|
148
|
+
if (seenPackages.has(bundleName)) {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
seenPackages.add(bundleName);
|
|
152
|
+
apps.push({ packageName: bundleName, appName: bundleName });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return apps;
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
throw new robot_1.ActionableError("Failed to list installed apps");
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* 启动应用
|
|
163
|
+
* @param packageName 应用包名
|
|
164
|
+
* @param locale 语言环境(可选,当前版本暂未使用,保留参数兼容性)
|
|
165
|
+
*
|
|
166
|
+
* 使用 bm dump 获取应用的 mainAbility 信息,然后使用 aa start 启动应用
|
|
167
|
+
*/
|
|
168
|
+
async launchApp(packageName, locale) {
|
|
169
|
+
(0, utils_1.validatePackageName)(packageName);
|
|
170
|
+
if (locale) {
|
|
171
|
+
(0, utils_1.validateLocale)(locale);
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
// 使用 bm dump 获取应用的 mainAbility
|
|
175
|
+
const bundleOutput = this.hdc("shell", "bm", "dump", "-n", packageName).toString();
|
|
176
|
+
const abilityMatch = bundleOutput.match(/"mainAbility"\s*:\s*"([^"]+)"/);
|
|
177
|
+
if (!abilityMatch) {
|
|
178
|
+
throw new robot_1.ActionableError(`Failed to find mainAbility for package "${packageName}"`);
|
|
179
|
+
}
|
|
180
|
+
const abilityName = abilityMatch[1];
|
|
181
|
+
// 使用 aa start 启动应用
|
|
182
|
+
this.hdc("shell", "aa", "start", "-a", abilityName, "-b", packageName);
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
throw new robot_1.ActionableError(`Failed launching app: ${error.message}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
async terminateApp(packageName) {
|
|
189
|
+
(0, utils_1.validatePackageName)(packageName);
|
|
190
|
+
try {
|
|
191
|
+
this.hdc("shell", "aa", "force-stop", packageName);
|
|
192
|
+
}
|
|
193
|
+
catch (error) { }
|
|
194
|
+
}
|
|
195
|
+
async installApp(filePath) {
|
|
196
|
+
try {
|
|
197
|
+
this.hdc("install", "-r", filePath);
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
const stdout = error.stdout ? error.stdout.toString() : "";
|
|
201
|
+
const stderr = error.stderr ? error.stderr.toString() : "";
|
|
202
|
+
const output = (stdout + stderr).trim();
|
|
203
|
+
throw new robot_1.ActionableError(output || error.message);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
async uninstallApp(bundleId) {
|
|
207
|
+
try {
|
|
208
|
+
this.hdc("uninstall", bundleId);
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
const stdout = error.stdout ? error.stdout.toString() : "";
|
|
212
|
+
const stderr = error.stderr ? error.stderr.toString() : "";
|
|
213
|
+
const output = (stdout + stderr).trim();
|
|
214
|
+
throw new robot_1.ActionableError(output || error.message);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
async openUrl(url) {
|
|
218
|
+
// 使用引号包裹参数,确保特殊字符和URL参数正确传递
|
|
219
|
+
const quotedUrl = `'${url}'`;
|
|
220
|
+
const shellCmd = `aa start -U ${quotedUrl} -A 'ohos.want.action.viewData' -e 'entity.system.browsable'`;
|
|
221
|
+
// 对于支付宝链接,先尝试显式启动支付宝应用
|
|
222
|
+
if (url.startsWith("alipays://")) {
|
|
223
|
+
try {
|
|
224
|
+
this.hdc("shell", `aa start -a EntryAbility -b com.alipay.mobile.client -U ${quotedUrl}`);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
// 如果失败,尝试隐式启动
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// 使用隐式启动
|
|
232
|
+
try {
|
|
233
|
+
this.hdc("shell", shellCmd);
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
// 如果失败,尝试不使用 -A -e 参数
|
|
237
|
+
try {
|
|
238
|
+
this.hdc("shell", `aa start -U ${quotedUrl}`);
|
|
239
|
+
}
|
|
240
|
+
catch (fallbackError) {
|
|
241
|
+
throw new robot_1.ActionableError(`Failed to open URL: ${url}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* 发送文字输入
|
|
247
|
+
* @param text 要输入的文字
|
|
248
|
+
* @param x 输入框的 x 坐标(可选,默认屏幕中心)
|
|
249
|
+
* @param y 输入框的 y 坐标(可选,默认屏幕中心)
|
|
250
|
+
*
|
|
251
|
+
* 注意:鸿蒙的 uitest uiInput inputText 需要指定输入框的坐标位置
|
|
252
|
+
* 建议先点击输入框获取焦点,然后传入输入框坐标调用此方法
|
|
253
|
+
*/
|
|
254
|
+
async sendKeys(text, x, y) {
|
|
255
|
+
if (text === "") {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
try {
|
|
259
|
+
// 如果没有传入坐标,使用屏幕中心
|
|
260
|
+
let inputX = x;
|
|
261
|
+
let inputY = y;
|
|
262
|
+
if (inputX === undefined || inputY === undefined) {
|
|
263
|
+
const screenSize = await this.getScreenSize();
|
|
264
|
+
inputX = inputX ?? Math.floor(screenSize.width / 2);
|
|
265
|
+
inputY = inputY ?? Math.floor(screenSize.height / 2);
|
|
266
|
+
}
|
|
267
|
+
const cmd = ["shell", "uitest", "uiInput", "inputText", `${inputX}`, `${inputY}`, text];
|
|
268
|
+
this.hdc(...cmd);
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
throw new robot_1.ActionableError(`Failed to send keys: ${text}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
async pressButton(button) {
|
|
275
|
+
if (!BUTTON_MAP[button]) {
|
|
276
|
+
throw new robot_1.ActionableError(`Button "${button}" is not supported on HarmonyOS`);
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
const mapped = BUTTON_MAP[button];
|
|
280
|
+
this.hdc("shell", "uitest", "uiInput", "keyEvent", mapped);
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
throw new robot_1.ActionableError(`Failed to press button: ${button}`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
async tap(x, y) {
|
|
287
|
+
try {
|
|
288
|
+
this.hdc("shell", "uitest", "uiInput", "click", `${x}`, `${y}`);
|
|
289
|
+
}
|
|
290
|
+
catch (error) {
|
|
291
|
+
throw new robot_1.ActionableError(`Failed to tap at coordinates: ${x}, ${y}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
async doubleTap(x, y) {
|
|
295
|
+
try {
|
|
296
|
+
await this.tap(x, y);
|
|
297
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
298
|
+
await this.tap(x, y);
|
|
299
|
+
}
|
|
300
|
+
catch (error) {
|
|
301
|
+
throw new robot_1.ActionableError(`Failed to double-tap at coordinates: ${x}, ${y}`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
async longPress(x, y, duration) {
|
|
305
|
+
try {
|
|
306
|
+
this.hdc("shell", "uitest", "uiInput", "longClick", `${x}`, `${y}`);
|
|
307
|
+
await new Promise(resolve => setTimeout(resolve, duration));
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
throw new robot_1.ActionableError(`Failed to long-press at coordinates: ${x}, ${y}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
async swipe(direction) {
|
|
314
|
+
// 使用基于坐标的 swipe 命令,从屏幕中心滑动
|
|
315
|
+
// velocity: 滑动速度 (px/s),范围 200-40000,默认 600
|
|
316
|
+
try {
|
|
317
|
+
const screenSize = await this.getScreenSize();
|
|
318
|
+
const centerX = Math.floor(screenSize.width / 2);
|
|
319
|
+
const centerY = Math.floor(screenSize.height / 2);
|
|
320
|
+
const distance = Math.floor(Math.min(screenSize.width, screenSize.height) * 0.4); // 滑动距离为屏幕短边的 40%
|
|
321
|
+
let fromX, fromY, toX, toY;
|
|
322
|
+
switch (direction) {
|
|
323
|
+
case "left":
|
|
324
|
+
fromX = centerX + Math.floor(distance / 2);
|
|
325
|
+
fromY = centerY;
|
|
326
|
+
toX = centerX - Math.floor(distance / 2);
|
|
327
|
+
toY = centerY;
|
|
328
|
+
break;
|
|
329
|
+
case "right":
|
|
330
|
+
fromX = centerX - Math.floor(distance / 2);
|
|
331
|
+
fromY = centerY;
|
|
332
|
+
toX = centerX + Math.floor(distance / 2);
|
|
333
|
+
toY = centerY;
|
|
334
|
+
break;
|
|
335
|
+
case "up":
|
|
336
|
+
fromX = centerX;
|
|
337
|
+
fromY = centerY + Math.floor(distance / 2);
|
|
338
|
+
toX = centerX;
|
|
339
|
+
toY = centerY - Math.floor(distance / 2);
|
|
340
|
+
break;
|
|
341
|
+
case "down":
|
|
342
|
+
fromX = centerX;
|
|
343
|
+
fromY = centerY - Math.floor(distance / 2);
|
|
344
|
+
toX = centerX;
|
|
345
|
+
toY = centerY + Math.floor(distance / 2);
|
|
346
|
+
break;
|
|
347
|
+
default:
|
|
348
|
+
throw new robot_1.ActionableError(`Swipe direction "${direction}" is not supported`);
|
|
349
|
+
}
|
|
350
|
+
// 使用 swipe 命令:swipe <fromX> <fromY> <toX> <toY> [velocity]
|
|
351
|
+
this.hdc("shell", "uitest", "uiInput", "swipe", `${fromX}`, `${fromY}`, `${toX}`, `${toY}`, "600");
|
|
352
|
+
}
|
|
353
|
+
catch (error) {
|
|
354
|
+
throw new robot_1.ActionableError(`Failed to swipe ${direction}`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
async swipeFromCoordinate(x, y, direction, distance) {
|
|
358
|
+
// 使用基于坐标的 swipe 命令,从指定位置开始滑动
|
|
359
|
+
// velocity: 滑动速度 (px/s),范围 200-40000,默认 600
|
|
360
|
+
const swipeDistance = distance || 300;
|
|
361
|
+
let toX, toY;
|
|
362
|
+
switch (direction) {
|
|
363
|
+
case "left":
|
|
364
|
+
toX = x - swipeDistance;
|
|
365
|
+
toY = y;
|
|
366
|
+
break;
|
|
367
|
+
case "right":
|
|
368
|
+
toX = x + swipeDistance;
|
|
369
|
+
toY = y;
|
|
370
|
+
break;
|
|
371
|
+
case "up":
|
|
372
|
+
toX = x;
|
|
373
|
+
toY = y - swipeDistance;
|
|
374
|
+
break;
|
|
375
|
+
case "down":
|
|
376
|
+
toX = x;
|
|
377
|
+
toY = y + swipeDistance;
|
|
378
|
+
break;
|
|
379
|
+
default:
|
|
380
|
+
throw new robot_1.ActionableError(`Swipe direction "${direction}" is not supported`);
|
|
381
|
+
}
|
|
382
|
+
try {
|
|
383
|
+
// 使用 swipe 命令:swipe <fromX> <fromY> <toX> <toY> [velocity]
|
|
384
|
+
this.hdc("shell", "uitest", "uiInput", "swipe", `${x}`, `${y}`, `${toX}`, `${toY}`, "600");
|
|
385
|
+
}
|
|
386
|
+
catch (error) {
|
|
387
|
+
throw new robot_1.ActionableError(`Failed to swipe ${direction} from coordinates: ${x}, ${y}`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* 获取屏幕截图
|
|
392
|
+
*
|
|
393
|
+
* 注意:由于鸿蒙系统的隐私保护机制,部分应用(如银行类、支付类、安全类应用)
|
|
394
|
+
* 的界面可能无法被截图,会显示为黑屏。
|
|
395
|
+
* 如果遇到截图黑屏的情况,建议使用 getElementsOnScreen() 接口获取页面元素信息作为替代方案。
|
|
396
|
+
*
|
|
397
|
+
* @returns 截图的 Buffer 数据
|
|
398
|
+
*/
|
|
399
|
+
async getScreenshot() {
|
|
400
|
+
try {
|
|
401
|
+
const timestamp = Date.now();
|
|
402
|
+
const devicePath = `/data/local/tmp/screenshot_${timestamp}.jpeg`;
|
|
403
|
+
this.hdc("shell", "snapshot_display", "-f", devicePath);
|
|
404
|
+
// Use temp file instead of stdout since hdc file recv doesn't support "-"
|
|
405
|
+
const localPath = node_path_1.default.join(node_os_1.default.tmpdir(), `screenshot_${timestamp}.jpeg`);
|
|
406
|
+
this.hdc("file", "recv", devicePath, localPath);
|
|
407
|
+
const buffer = (0, node_fs_1.readFileSync)(localPath);
|
|
408
|
+
(0, node_fs_1.unlinkSync)(localPath);
|
|
409
|
+
return buffer;
|
|
410
|
+
}
|
|
411
|
+
catch (error) {
|
|
412
|
+
throw new robot_1.ActionableError("Failed to get screenshot");
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* 获取屏幕上可见的元素信息
|
|
417
|
+
*
|
|
418
|
+
* 此接口通过 hidumper 获取当前窗口的 UI 元素树,可以用于:
|
|
419
|
+
* 1. 替代截图:当应用因隐私保护无法截图时,可使用此接口获取页面元素信息
|
|
420
|
+
* 2. 元素定位:获取元素的层级结构和属性,用于自动化测试
|
|
421
|
+
*
|
|
422
|
+
* @returns 元素 dump 文件的路径
|
|
423
|
+
*/
|
|
424
|
+
async getElementsOnScreen() {
|
|
425
|
+
try {
|
|
426
|
+
// Step 1: Get the highlighted window ID using hidumper (async)
|
|
427
|
+
const windowListOutput = await this.hdcAsync("shell", "hidumper", "-s", "WindowManagerService", "-a", "-a");
|
|
428
|
+
const windowIdMatch = windowListOutput.match(/Highlighted\s+windows:\s*(\d+)/);
|
|
429
|
+
if (!windowIdMatch) {
|
|
430
|
+
throw new robot_1.ActionableError("Failed to find highlighted window ID");
|
|
431
|
+
}
|
|
432
|
+
const windowId = windowIdMatch[1];
|
|
433
|
+
// Step 2: Set test mode for inspector
|
|
434
|
+
await this.hdcAsync("shell", "param", "set", "persist.ace.testmode.enabled", "1");
|
|
435
|
+
// Step 3: Get element dump using hidumper with -w windowId -inspector (async)
|
|
436
|
+
const elementOutput = await this.hdcAsync("shell", "hidumper", "-s", "WindowManagerService", "-a", `-w ${windowId} -inspector`);
|
|
437
|
+
// Step 4: Save raw dump to file and return file path
|
|
438
|
+
const timestamp = new Date().toISOString().replace(/[-:T]/g, "").replace(/\..+/, "").replace("Z", "");
|
|
439
|
+
const fileName = `harmony_elements_${timestamp}.txt`;
|
|
440
|
+
const filePath = node_path_1.default.join(node_os_1.default.tmpdir(), fileName);
|
|
441
|
+
(0, node_fs_1.writeFileSync)(filePath, elementOutput, "utf-8");
|
|
442
|
+
return filePath;
|
|
443
|
+
}
|
|
444
|
+
catch (error) {
|
|
445
|
+
throw new robot_1.ActionableError(`Failed to get elements on screen: ${error.message}`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
async setOrientation(orientation) {
|
|
449
|
+
try {
|
|
450
|
+
const value = orientation === "portrait" ? 0 : 1;
|
|
451
|
+
this.hdc("shell", "settings", "put", "system", "user_rotation", `${value}`);
|
|
452
|
+
}
|
|
453
|
+
catch (error) {
|
|
454
|
+
throw new robot_1.ActionableError(`Failed to set orientation to ${orientation}`);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
async getOrientation() {
|
|
458
|
+
try {
|
|
459
|
+
const output = this.hdc("shell", "settings", "get", "system", "user_rotation").toString().trim();
|
|
460
|
+
return output === "0" ? "portrait" : "landscape";
|
|
461
|
+
}
|
|
462
|
+
catch (error) {
|
|
463
|
+
throw new robot_1.ActionableError("Failed to get orientation");
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* 获取应用进程 ID
|
|
468
|
+
* 通过 `aa dump -a` 命令查找应用进程
|
|
469
|
+
* @param bundleName - 应用包名
|
|
470
|
+
* @returns 进程 ID,如果未找到返回 null
|
|
471
|
+
*/
|
|
472
|
+
async getAppPid(bundleName) {
|
|
473
|
+
try {
|
|
474
|
+
const output = await this.hdcAsync("shell", "aa", "dump", "-a");
|
|
475
|
+
// 解析输出查找应用进程
|
|
476
|
+
// 格式示例:
|
|
477
|
+
// AppRunningRecord ID #9
|
|
478
|
+
// process name [com.example.myapplication]
|
|
479
|
+
// pid #3820 uid #20020059
|
|
480
|
+
const lines = output.split("\n");
|
|
481
|
+
let currentBundleName = "";
|
|
482
|
+
for (const line of lines) {
|
|
483
|
+
const processMatch = line.match(/process name \[(.+)\]/);
|
|
484
|
+
if (processMatch) {
|
|
485
|
+
currentBundleName = processMatch[1];
|
|
486
|
+
}
|
|
487
|
+
if (currentBundleName === bundleName) {
|
|
488
|
+
const pidMatch = line.match(/pid #(\d+)/);
|
|
489
|
+
if (pidMatch) {
|
|
490
|
+
return parseInt(pidMatch[1], 10);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
catch (error) {
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* 获取设备日志 (hilog)
|
|
502
|
+
* 参考: hdc shell hilog -P {pid} 查看应用日志
|
|
503
|
+
* @param options - 可选的过滤选项
|
|
504
|
+
* @param options.bundleName - 按应用包名过滤(自动获取进程ID)
|
|
505
|
+
* @param options.pid - 按进程 ID 过滤
|
|
506
|
+
* @param options.level - 最低日志级别 (D/I/W/E/F)
|
|
507
|
+
* @param options.lines - 获取最近 N 行日志 (默认: 200)
|
|
508
|
+
* @param options.clear - 获取前清除日志缓冲区
|
|
509
|
+
*/
|
|
510
|
+
async getDeviceLogs(options) {
|
|
511
|
+
try {
|
|
512
|
+
// 如果需要清除日志
|
|
513
|
+
if (options?.clear) {
|
|
514
|
+
await this.hdcAsync("shell", "hilog", "-r");
|
|
515
|
+
}
|
|
516
|
+
// 构建 hilog 命令参数
|
|
517
|
+
const args = ["shell", "hilog", "-x"]; // -x 表示非阻塞模式
|
|
518
|
+
// 添加行数限制
|
|
519
|
+
const lines = options?.lines || 200;
|
|
520
|
+
args.push("-n", lines.toString());
|
|
521
|
+
// 添加日志级别过滤
|
|
522
|
+
if (options?.level) {
|
|
523
|
+
args.push("-L", options.level.toUpperCase());
|
|
524
|
+
}
|
|
525
|
+
// 添加进程 ID 过滤(优先使用 bundleName 自动获取 pid)
|
|
526
|
+
let pid = options?.pid;
|
|
527
|
+
if (!pid && options?.bundleName) {
|
|
528
|
+
const appPid = await this.getAppPid(options.bundleName);
|
|
529
|
+
if (appPid !== null) {
|
|
530
|
+
pid = appPid;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
if (pid) {
|
|
534
|
+
args.push("-P", pid.toString());
|
|
535
|
+
}
|
|
536
|
+
const output = await this.hdcAsync(...args);
|
|
537
|
+
return output;
|
|
538
|
+
}
|
|
539
|
+
catch (error) {
|
|
540
|
+
throw new robot_1.ActionableError(`Failed to get device logs: ${error.message}`);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
exports.HarmonyRobot = HarmonyRobot;
|
|
545
|
+
/**
|
|
546
|
+
* HarmonyOS 设备管理器
|
|
547
|
+
*
|
|
548
|
+
* 启动模拟器步骤:
|
|
549
|
+
* 1. emulator -list -details 获取模拟器详情(JSON)
|
|
550
|
+
* 2. emulator -hvd <name> -path <parentDir> -imageRoot <sdkPath> 启动模拟器
|
|
551
|
+
*
|
|
552
|
+
* 关键字段(从 -list -details 获取):
|
|
553
|
+
* - instancePath: 模拟器实例路径,-path 参数取其父目录
|
|
554
|
+
* - imageRoot: SDK 路径
|
|
555
|
+
* - name: 模拟器名称
|
|
556
|
+
*/
|
|
557
|
+
class HarmonyDeviceManager {
|
|
558
|
+
getEmulatorExecPath() {
|
|
559
|
+
const exeName = process.platform === "win32" ? "emulator.exe" : "emulator";
|
|
560
|
+
// 1. Try to find emulator in PATH using "which" command
|
|
561
|
+
try {
|
|
562
|
+
const whichCmd = process.platform === "win32" ? "where" : "which";
|
|
563
|
+
const result = (0, node_child_process_1.execFileSync)(whichCmd, [exeName], {
|
|
564
|
+
encoding: "utf-8",
|
|
565
|
+
timeout: 5000,
|
|
566
|
+
}).toString().trim();
|
|
567
|
+
if (result && (0, node_fs_1.existsSync)(result)) {
|
|
568
|
+
return result;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
catch {
|
|
572
|
+
// which command failed, continue to other methods
|
|
573
|
+
}
|
|
574
|
+
// 2. Check PATH environment variable directly
|
|
575
|
+
if (process.env.PATH) {
|
|
576
|
+
const pathDirs = process.env.PATH.split(process.platform === "win32" ? ";" : ":");
|
|
577
|
+
for (const dir of pathDirs) {
|
|
578
|
+
const emulatorPath = node_path_1.default.join(dir, exeName);
|
|
579
|
+
if ((0, node_fs_1.existsSync)(emulatorPath)) {
|
|
580
|
+
return emulatorPath;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
// 3. Check common DevEco Studio installation paths
|
|
585
|
+
const commonStudioPaths = [
|
|
586
|
+
"/Applications/DevEco-Studio.app/Contents",
|
|
587
|
+
"/Applications/DevEco Studio.app/Contents",
|
|
588
|
+
process.env.DEVECO_PATH,
|
|
589
|
+
process.env.DEVECO_HOME,
|
|
590
|
+
process.env.HOS_SDK_HOME,
|
|
591
|
+
].filter(Boolean);
|
|
592
|
+
for (const studioPath of commonStudioPaths) {
|
|
593
|
+
// Try tools/emulator path first (primary location)
|
|
594
|
+
const toolsEmulatorPath = node_path_1.default.join(studioPath, "tools", "emulator", exeName);
|
|
595
|
+
if ((0, node_fs_1.existsSync)(toolsEmulatorPath)) {
|
|
596
|
+
return toolsEmulatorPath;
|
|
597
|
+
}
|
|
598
|
+
// Fallback to sdk/emulator path
|
|
599
|
+
const sdkEmulatorPath = node_path_1.default.join(studioPath, "sdk", "emulator", exeName);
|
|
600
|
+
if ((0, node_fs_1.existsSync)(sdkEmulatorPath)) {
|
|
601
|
+
return sdkEmulatorPath;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
// 4. Check additional SDK paths
|
|
605
|
+
const additionalSdkPaths = [
|
|
606
|
+
process.env.HOS_SDK_HOME,
|
|
607
|
+
process.env.HARMONYOS_SDK_HOME,
|
|
608
|
+
"~/Library/Huawei/Sdk",
|
|
609
|
+
"/opt/harmonyos/sdk",
|
|
610
|
+
"/usr/local/harmonyos/sdk",
|
|
611
|
+
].filter(Boolean);
|
|
612
|
+
for (const sdkPath of additionalSdkPaths) {
|
|
613
|
+
const expandedPath = sdkPath.startsWith("~")
|
|
614
|
+
? node_path_1.default.join(node_os_1.default.homedir(), sdkPath.slice(1))
|
|
615
|
+
: sdkPath;
|
|
616
|
+
const toolsEmulatorPath = node_path_1.default.join(expandedPath, "tools", "emulator", exeName);
|
|
617
|
+
if ((0, node_fs_1.existsSync)(toolsEmulatorPath)) {
|
|
618
|
+
return toolsEmulatorPath;
|
|
619
|
+
}
|
|
620
|
+
const sdkEmulatorPath = node_path_1.default.join(expandedPath, "emulator", exeName);
|
|
621
|
+
if ((0, node_fs_1.existsSync)(sdkEmulatorPath)) {
|
|
622
|
+
return sdkEmulatorPath;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
getConnectedDeviceIds() {
|
|
628
|
+
try {
|
|
629
|
+
const output = (0, node_child_process_1.execFileSync)(getHdcPath(), ["list", "targets"], {
|
|
630
|
+
maxBuffer: MAX_BUFFER_SIZE,
|
|
631
|
+
timeout: TIMEOUT,
|
|
632
|
+
}).toString();
|
|
633
|
+
return output
|
|
634
|
+
.split("\n")
|
|
635
|
+
.map(line => line.trim())
|
|
636
|
+
.filter(line => line !== "" && line !== "[Empty]")
|
|
637
|
+
.map(line => line.split(/\s+/)[0])
|
|
638
|
+
.filter(Boolean);
|
|
639
|
+
}
|
|
640
|
+
catch (error) {
|
|
641
|
+
return [];
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
isEmulatorDevice(deviceId) {
|
|
645
|
+
return /^\d+\.\d+\.\d+\.\d+:\d+$/.test(deviceId);
|
|
646
|
+
}
|
|
647
|
+
getDeviceName(deviceId) {
|
|
648
|
+
try {
|
|
649
|
+
if (this.isEmulatorDevice(deviceId)) {
|
|
650
|
+
const output = (0, node_child_process_1.execFileSync)(getHdcPath(), ["-t", deviceId, "shell", "param", "get", "ohos.qemu.hvd.name"], {
|
|
651
|
+
maxBuffer: MAX_BUFFER_SIZE,
|
|
652
|
+
timeout: TIMEOUT,
|
|
653
|
+
}).toString().trim();
|
|
654
|
+
return output || deviceId;
|
|
655
|
+
}
|
|
656
|
+
else {
|
|
657
|
+
const output = (0, node_child_process_1.execFileSync)(getHdcPath(), ["-t", deviceId, "shell", "param", "get", "const.product.name"], {
|
|
658
|
+
maxBuffer: MAX_BUFFER_SIZE,
|
|
659
|
+
timeout: TIMEOUT,
|
|
660
|
+
}).toString().trim();
|
|
661
|
+
return output || deviceId;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
catch (error) {
|
|
665
|
+
return deviceId;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
getDeviceVersion(deviceId) {
|
|
669
|
+
try {
|
|
670
|
+
const output = (0, node_child_process_1.execFileSync)(getHdcPath(), ["-t", deviceId, "shell", "getprop", "hw_sc.build.platform.version"], {
|
|
671
|
+
maxBuffer: MAX_BUFFER_SIZE,
|
|
672
|
+
timeout: TIMEOUT,
|
|
673
|
+
}).toString().trim();
|
|
674
|
+
return output || "unknown";
|
|
675
|
+
}
|
|
676
|
+
catch (error) {
|
|
677
|
+
return "unknown";
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
listInstalledEmulators() {
|
|
681
|
+
const emulatorPath = this.getEmulatorExecPath();
|
|
682
|
+
if (!emulatorPath) {
|
|
683
|
+
return [];
|
|
684
|
+
}
|
|
685
|
+
try {
|
|
686
|
+
const output = (0, node_child_process_1.execFileSync)(emulatorPath, ["-list"], {
|
|
687
|
+
maxBuffer: MAX_BUFFER_SIZE,
|
|
688
|
+
timeout: TIMEOUT,
|
|
689
|
+
}).toString();
|
|
690
|
+
return output
|
|
691
|
+
.split("\n")
|
|
692
|
+
.map(line => line.trim())
|
|
693
|
+
.filter(line => line !== "");
|
|
694
|
+
}
|
|
695
|
+
catch (error) {
|
|
696
|
+
return [];
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
getEmulatorDetails(emulatorName) {
|
|
700
|
+
const emulatorPath = this.getEmulatorExecPath();
|
|
701
|
+
if (!emulatorPath) {
|
|
702
|
+
return null;
|
|
703
|
+
}
|
|
704
|
+
try {
|
|
705
|
+
const output = (0, node_child_process_1.execFileSync)(emulatorPath, ["-list", "-details"], {
|
|
706
|
+
maxBuffer: MAX_BUFFER_SIZE,
|
|
707
|
+
timeout: TIMEOUT,
|
|
708
|
+
}).toString();
|
|
709
|
+
const details = JSON.parse(output);
|
|
710
|
+
return details.find(emu => emu.name === emulatorName) || null;
|
|
711
|
+
}
|
|
712
|
+
catch (error) {
|
|
713
|
+
return null;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
getConnectedDevices() {
|
|
717
|
+
const connectedIds = this.getConnectedDeviceIds();
|
|
718
|
+
const devices = [];
|
|
719
|
+
for (const deviceId of connectedIds) {
|
|
720
|
+
const isEmulator = this.isEmulatorDevice(deviceId);
|
|
721
|
+
devices.push({
|
|
722
|
+
deviceId,
|
|
723
|
+
deviceType: isEmulator ? "emulator" : "real",
|
|
724
|
+
name: this.getDeviceName(deviceId),
|
|
725
|
+
version: this.getDeviceVersion(deviceId),
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
return devices;
|
|
729
|
+
}
|
|
730
|
+
getAllAvailableDevices() {
|
|
731
|
+
const devices = this.getConnectedDevices();
|
|
732
|
+
const installedEmulators = this.listInstalledEmulators();
|
|
733
|
+
for (const emulatorName of installedEmulators) {
|
|
734
|
+
if (!devices.some(d => d.name === emulatorName)) {
|
|
735
|
+
devices.push({
|
|
736
|
+
deviceId: "",
|
|
737
|
+
deviceType: "emulator",
|
|
738
|
+
name: emulatorName,
|
|
739
|
+
version: "unknown",
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
return devices;
|
|
744
|
+
}
|
|
745
|
+
async startEmulator(emulatorName) {
|
|
746
|
+
const emulatorPath = this.getEmulatorExecPath();
|
|
747
|
+
if (!emulatorPath) {
|
|
748
|
+
throw new robot_1.ActionableError("Emulator executable not found");
|
|
749
|
+
}
|
|
750
|
+
// Get emulator details from -list -details (most reliable source)
|
|
751
|
+
const details = this.getEmulatorDetails(emulatorName);
|
|
752
|
+
if (!details) {
|
|
753
|
+
throw new robot_1.ActionableError(`Emulator "${emulatorName}" not found`);
|
|
754
|
+
}
|
|
755
|
+
// Build command arguments
|
|
756
|
+
// IMPORTANT: -path should be the parent directory of instancePath
|
|
757
|
+
// -imageRoot is already the Sdk directory from -list -details
|
|
758
|
+
const args = [
|
|
759
|
+
"-hvd", emulatorName,
|
|
760
|
+
"-path", node_path_1.default.dirname(details.instancePath),
|
|
761
|
+
"-imageRoot", details.imageRoot,
|
|
762
|
+
];
|
|
763
|
+
const child = (0, node_child_process_1.spawn)(emulatorPath, args, {
|
|
764
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
765
|
+
detached: true,
|
|
766
|
+
});
|
|
767
|
+
let stdoutData = "";
|
|
768
|
+
let stderrData = "";
|
|
769
|
+
let processExited = false;
|
|
770
|
+
let exitCode = null;
|
|
771
|
+
child.stdout?.on("data", data => {
|
|
772
|
+
stdoutData += data.toString();
|
|
773
|
+
});
|
|
774
|
+
child.stderr?.on("data", data => {
|
|
775
|
+
stderrData += data.toString();
|
|
776
|
+
});
|
|
777
|
+
child.on("exit", code => {
|
|
778
|
+
processExited = true;
|
|
779
|
+
exitCode = code;
|
|
780
|
+
});
|
|
781
|
+
// Wait for emulator to appear in connected devices (up to 60 seconds)
|
|
782
|
+
for (let i = 0; i < 30; i++) {
|
|
783
|
+
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
784
|
+
// Check if process exited prematurely
|
|
785
|
+
if (processExited) {
|
|
786
|
+
const outputStr = stdoutData + stderrData;
|
|
787
|
+
if (outputStr.includes("already exists") || outputStr.includes("already running")) {
|
|
788
|
+
continue;
|
|
789
|
+
}
|
|
790
|
+
throw new robot_1.ActionableError(`Emulator exited with code ${exitCode}. Output: ${outputStr}`);
|
|
791
|
+
}
|
|
792
|
+
const connectedIds = this.getConnectedDeviceIds();
|
|
793
|
+
for (const deviceId of connectedIds) {
|
|
794
|
+
if (this.isEmulatorDevice(deviceId)) {
|
|
795
|
+
const name = this.getDeviceName(deviceId);
|
|
796
|
+
if (name === emulatorName) {
|
|
797
|
+
return deviceId;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
return null;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
exports.HarmonyDeviceManager = HarmonyDeviceManager;
|