android-midscene-automation 0.1.36 → 0.1.37
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/CHANGELOG.md +6 -0
- package/README.md +6 -0
- package/package.json +7 -7
- package/remote-agent/index.ts +2 -0
- package/server/android-device-info.ts +28 -0
- package/server/appium-recorder/appium-runner.ts +26 -11
- package/server/appium-recorder/repository.ts +1 -0
- package/server/appium-recorder/request-timeout.ts +25 -0
- package/server/appium-recorder/stop-app.ts +18 -0
- package/server/http-api.ts +19 -0
- package/server/remote-agents/protocol.ts +1 -0
- package/src/appium-recorder/AppiumPage.vue +5 -0
- package/src/appium-recorder/action-descriptions.ts +1 -0
- package/src/appium-recorder/components/FlowStepEditor.vue +3 -3
- package/src/appium-recorder/components/NestedConditionBranches.vue +3 -1
- package/src/appium-recorder/components/RecordedSteps.vue +1 -0
- package/src/appium-recorder/flow-graph.ts +1 -0
- package/src/appium-recorder/flow-labels.ts +2 -0
- package/src/appium-recorder/types.ts +1 -0
- package/src/components/device/DeviceInfoTooltip.vue +55 -0
- package/src/components/device/DevicePreviewPanel.vue +3 -1
- package/src/types.ts +9 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
# 更新记录
|
|
2
|
+
## v0.1.37
|
|
3
|
+
- 修复元素查找超时未覆盖 Appium 请求的问题,驱动无响应时及时中止并明确报错,不再误判为找不到元素;唯一原生定位优先使用 ID 或 Accessibility ID。
|
|
4
|
+
- 设备预览新增设备信息,展示设备名称、品牌、型号、处理器、Android 版本、物理分辨率及当前分辨率,支持远程设备;问号位于标题右侧,样式与全局变量帮助图标一致。
|
|
5
|
+
- 新增独立“杀死 APP”操作节点,默认使用当前选中应用包名,支持编辑目标包名;停止当前设备用户下的应用进程,不清除应用数据。
|
|
6
|
+
- Midscene 相关模块由 1.12.5 统一升级至 1.12.6。
|
|
7
|
+
|
|
2
8
|
## v0.1.36
|
|
3
9
|
- 新合并支持取消:原合并按钮切换为取消合并,还原分支和被删除的重复节点,保留配置编辑;公共流程新增的普通操作可选择保留左侧、右侧或两侧,结构冲突时阻止还原。本地保存还原记录,导出及节点复制不携带历史记录。
|
|
4
10
|
- 启动相册前先停止当前设备用户下的目标相册进程,再重新打开;保留相册自动适配,不清除照片及应用数据,停止失败时明确报错。
|
package/README.md
CHANGED
|
@@ -1229,6 +1229,12 @@ Linux: ~/.local/share/android-midscene-automation
|
|
|
1229
1229
|
|
|
1230
1230
|
# 项目更新记录
|
|
1231
1231
|
|
|
1232
|
+
## v0.1.37
|
|
1233
|
+
- 修复元素查找超时未覆盖 Appium 请求的问题,驱动无响应时及时中止并明确报错,不再误判为找不到元素;唯一原生定位优先使用 ID 或 Accessibility ID。
|
|
1234
|
+
- 设备预览新增设备信息,展示设备名称、品牌、型号、处理器、Android 版本、物理分辨率及当前分辨率,支持远程设备;问号位于标题右侧,样式与全局变量帮助图标一致。
|
|
1235
|
+
- 新增独立“杀死 APP”操作节点,默认使用当前选中应用包名,支持编辑目标包名;停止当前设备用户下的应用进程,不清除应用数据。
|
|
1236
|
+
- Midscene 相关模块由 1.12.5 统一升级至 1.12.6。
|
|
1237
|
+
|
|
1232
1238
|
## v0.1.36
|
|
1233
1239
|
- 新合并支持取消:原合并按钮切换为取消合并,还原分支和被删除的重复节点,保留配置编辑;公共流程新增的普通操作可选择保留左侧、右侧或两侧,结构冲突时阻止还原。本地保存还原记录,导出及节点复制不携带历史记录。
|
|
1234
1240
|
- 启动相册前先停止当前设备用户下的目标相册进程,再重新打开;保留相册自动适配,不清除照片及应用数据,停止失败时明确报错。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "android-midscene-automation",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.37",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/oooooooko/android-midscene-automation.git"
|
|
@@ -38,12 +38,12 @@
|
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@dagrejs/dagre": "1.1.5",
|
|
40
40
|
"@element-plus/icons-vue": "2.3.2",
|
|
41
|
-
"@midscene/android": "https://registry.npmjs.org/@midscene/android/-/android-1.12.
|
|
42
|
-
"@midscene/android-playground": "https://registry.npmjs.org/@midscene/android-playground/-/android-playground-1.12.
|
|
43
|
-
"@midscene/core": "https://registry.npmjs.org/@midscene/core/-/core-1.12.
|
|
44
|
-
"@midscene/playground": "https://registry.npmjs.org/@midscene/playground/-/playground-1.12.
|
|
45
|
-
"@midscene/shared": "https://registry.npmjs.org/@midscene/shared/-/shared-1.12.
|
|
46
|
-
"@midscene/web": "https://registry.npmjs.org/@midscene/web/-/web-1.12.
|
|
41
|
+
"@midscene/android": "https://registry.npmjs.org/@midscene/android/-/android-1.12.6.tgz",
|
|
42
|
+
"@midscene/android-playground": "https://registry.npmjs.org/@midscene/android-playground/-/android-playground-1.12.6.tgz",
|
|
43
|
+
"@midscene/core": "https://registry.npmjs.org/@midscene/core/-/core-1.12.6.tgz",
|
|
44
|
+
"@midscene/playground": "https://registry.npmjs.org/@midscene/playground/-/playground-1.12.6.tgz",
|
|
45
|
+
"@midscene/shared": "https://registry.npmjs.org/@midscene/shared/-/shared-1.12.6.tgz",
|
|
46
|
+
"@midscene/web": "https://registry.npmjs.org/@midscene/web/-/web-1.12.6.tgz",
|
|
47
47
|
"@techstark/opencv-js": "5.0.0-release.1",
|
|
48
48
|
"@vitejs/plugin-vue": "6.0.7",
|
|
49
49
|
"@vue-flow/core": "1.48.2",
|
package/remote-agent/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import os from 'node:os';
|
|
3
|
+
import { readAndroidDeviceInfo } from '../server/android-device-info';
|
|
3
4
|
import { readFreshWindowHierarchy } from '../server/appium-recorder/tree-dump';
|
|
4
5
|
import type { RemoteAgentDevice, RemoteCommand } from '../server/remote-agents/protocol';
|
|
5
6
|
import { replayAppiumScript } from '../server/appium-recorder/appium-runner';
|
|
@@ -100,6 +101,7 @@ async function handleCommand(command: RemoteCommand) {
|
|
|
100
101
|
return { base64: image.toString('base64') };
|
|
101
102
|
}
|
|
102
103
|
if (command.type === 'displayInfo') return await getDisplayInfo(command.deviceId);
|
|
104
|
+
if (command.type === 'deviceInfo') return await readAndroidDeviceInfo(command.deviceId, 'adb');
|
|
103
105
|
if (command.type === 'tree') {
|
|
104
106
|
const [xml, activity] = await Promise.all([
|
|
105
107
|
dumpWindowHierarchy(command.deviceId),
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import type { AndroidDeviceDetails } from '../src/types';
|
|
3
|
+
|
|
4
|
+
export function parseAndroidDeviceInfo(properties: string, size: string, deviceName = ''): AndroidDeviceDetails {
|
|
5
|
+
const values = new Map([...properties.matchAll(/^\[([^\]]+)\]: \[(.*)\]\s*$/gm)].map(match => [match[1], match[2]]));
|
|
6
|
+
const get = (...keys: string[]) => keys.map(key => values.get(key)?.trim()).find(Boolean) || '';
|
|
7
|
+
const physical = size.match(/Physical size:\s*(\d+)x(\d+)/i);
|
|
8
|
+
const current = size.match(/Override size:\s*(\d+)x(\d+)/i) || physical;
|
|
9
|
+
const resolution = (match: RegExpMatchArray | null) => match ? `${match[1]} × ${match[2]}` : '';
|
|
10
|
+
return {
|
|
11
|
+
name: deviceName.trim() && deviceName.trim() !== 'null' ? deviceName.trim() : get('persist.sys.device_name', 'ro.product.marketname', 'ro.product.model'),
|
|
12
|
+
brand: get('ro.product.brand', 'ro.product.manufacturer'), model: get('ro.product.model'),
|
|
13
|
+
processor: get('ro.soc.model', 'ro.board.platform', 'ro.hardware'),
|
|
14
|
+
androidVersion: get('ro.build.version.release'), physicalResolution: resolution(physical), resolution: resolution(current),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function readAndroidDeviceInfo(deviceId: string, adb: string) {
|
|
19
|
+
const run = (args: string[]) => new Promise<string>((resolve, reject) => {
|
|
20
|
+
execFile(adb, ['-s', deviceId, 'shell', ...args], { timeout: 4000, maxBuffer: 1024 * 1024 }, (error, stdout) => {
|
|
21
|
+
if (error) reject(error); else resolve(stdout);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
const properties = await run(['getprop']);
|
|
25
|
+
const size = await run(['wm', 'size']).catch(() => '');
|
|
26
|
+
const name = await run(['settings', 'get', 'global', 'device_name']).catch(() => '');
|
|
27
|
+
return parseAndroidDeviceInfo(properties, size, name);
|
|
28
|
+
}
|
|
@@ -11,6 +11,8 @@ import { checkImage } from './image-check';
|
|
|
11
11
|
import { IMAGE_CHECK_MODES, type ImageCheckResult } from '../../src/appium-recorder/image-check';
|
|
12
12
|
import { formatStageLog } from '../../src/appium-recorder/stage-log';
|
|
13
13
|
import { openGalleryOnDevice } from './open-gallery';
|
|
14
|
+
import { stopAppOnDevice } from './stop-app';
|
|
15
|
+
import { AppiumRequestTimeoutError, timedAppiumFetch, withElementDeadline } from './request-timeout';
|
|
14
16
|
import { textClickSelector } from '../../src/appium-recorder/text-click';
|
|
15
17
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
16
18
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
@@ -83,6 +85,8 @@ function isReplayStopped(error?: unknown) {
|
|
|
83
85
|
|
|
84
86
|
function throwIfReplayStopped(error?: unknown) {
|
|
85
87
|
if (isReplayStopped(error)) throw new ReplayStoppedError();
|
|
88
|
+
// 驱动失去响应不是“元素不存在”,不能被备用定位或可选步骤吞掉。
|
|
89
|
+
if (error instanceof AppiumRequestTimeoutError) throw error;
|
|
86
90
|
}
|
|
87
91
|
|
|
88
92
|
function errorDetail(error: unknown) {
|
|
@@ -276,30 +280,31 @@ async function appiumRequest<T>(
|
|
|
276
280
|
if (!options?.ignoreAbort) throwIfReplayStopped();
|
|
277
281
|
context?.appiumLog(`[Appium] 请求:${method} ${path}`);
|
|
278
282
|
let response: Response;
|
|
283
|
+
let responseText: string;
|
|
279
284
|
try {
|
|
280
|
-
|
|
285
|
+
const result = await timedAppiumFetch(requestUrl, {
|
|
281
286
|
...init,
|
|
282
|
-
signal: options?.ignoreAbort
|
|
283
|
-
? (options.timeoutMs ? AbortSignal.timeout(options.timeoutMs) : undefined)
|
|
284
|
-
: context?.signal || init?.signal,
|
|
287
|
+
signal: options?.ignoreAbort ? undefined : context?.signal || init?.signal,
|
|
285
288
|
headers: {
|
|
286
289
|
'Content-Type': 'application/json',
|
|
287
290
|
...(init?.headers || {}),
|
|
288
291
|
},
|
|
289
|
-
});
|
|
292
|
+
}, options?.timeoutMs ?? (path === '/session' && method === 'POST' ? 120000 : 30000));
|
|
293
|
+
response = result.response;
|
|
294
|
+
responseText = result.text;
|
|
290
295
|
} catch (error) {
|
|
291
296
|
if (isReplayStopped(error) && !options?.ignoreAbort) {
|
|
292
297
|
context?.appiumLog(`[Appium] 取消:${method} ${path}`);
|
|
293
298
|
throw new ReplayStoppedError();
|
|
294
299
|
}
|
|
295
300
|
context?.appiumLog(`[Appium] 异常:${method} ${path}(${errorDetail(error)})`);
|
|
301
|
+
if (error instanceof AppiumRequestTimeoutError) throw error;
|
|
296
302
|
if (isTimeoutError(error)) {
|
|
297
303
|
throw new Error(`Appium 请求超时 ${requestUrl},${errorDetail(error)}`);
|
|
298
304
|
}
|
|
299
305
|
throw new Error(`无法连接 Appium 服务 ${requestUrl},${errorDetail(error)}`);
|
|
300
306
|
}
|
|
301
307
|
|
|
302
|
-
const responseText = await response.text();
|
|
303
308
|
context?.appiumLog(`[Appium] 响应:HTTP ${response.status} ${method} ${path}(${Date.now() - startedAt}ms)`);
|
|
304
309
|
const payload = (() => {
|
|
305
310
|
try {
|
|
@@ -439,7 +444,11 @@ async function findElementBySelector(
|
|
|
439
444
|
return elementId;
|
|
440
445
|
}
|
|
441
446
|
|
|
442
|
-
async function findElement(sessionId: string, step: AppiumRecordedStepRecord) {
|
|
447
|
+
async function findElement(sessionId: string, step: AppiumRecordedStepRecord, timeoutMs = step.timeoutMs ?? 10000) {
|
|
448
|
+
return withElementDeadline(timeoutMs, () => findElementWithinDeadline(sessionId, step));
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function findElementWithinDeadline(sessionId: string, step: AppiumRecordedStepRecord) {
|
|
443
452
|
if (!step.selector) throw new Error(`${step.label} 缺少 selector`);
|
|
444
453
|
const attempts: string[] = [];
|
|
445
454
|
|
|
@@ -454,7 +463,9 @@ async function findElement(sessionId: string, step: AppiumRecordedStepRecord) {
|
|
|
454
463
|
}
|
|
455
464
|
}
|
|
456
465
|
|
|
457
|
-
|
|
466
|
+
// 唯一原生定位优先;非唯一元素仍保留父级与备用 XPath 的精确匹配。
|
|
467
|
+
const preferPrimary = step.selector.unique === true && ['id', 'accessibilityId'].includes(step.selector.strategy);
|
|
468
|
+
for (const selector of [...(preferPrimary ? [step.selector] : []), ...(step.selectorChain || [])]) {
|
|
458
469
|
try {
|
|
459
470
|
attempts.push(`${selector.strategy} ${selector.value || ''}`);
|
|
460
471
|
return await findElementBySelector(sessionId, selector);
|
|
@@ -506,7 +517,7 @@ async function waitForElement(sessionId: string, step: AppiumRecordedStepRecord)
|
|
|
506
517
|
let lastError: unknown;
|
|
507
518
|
while (Date.now() - startedAt <= timeoutMs) {
|
|
508
519
|
try {
|
|
509
|
-
return await findElement(sessionId, step);
|
|
520
|
+
return await findElement(sessionId, step, Math.max(1, timeoutMs - (Date.now() - startedAt)));
|
|
510
521
|
} catch (error) {
|
|
511
522
|
throwIfReplayStopped(error);
|
|
512
523
|
lastError = error;
|
|
@@ -521,7 +532,7 @@ async function findOptionalElement(sessionId: string, step: AppiumRecordedStepRe
|
|
|
521
532
|
const timeoutMs = step.timeoutMs ?? 2000;
|
|
522
533
|
while (Date.now() - startedAt <= timeoutMs) {
|
|
523
534
|
try {
|
|
524
|
-
return await findElement(sessionId, step);
|
|
535
|
+
return await findElement(sessionId, step, Math.max(1, timeoutMs - (Date.now() - startedAt)));
|
|
525
536
|
} catch (error) {
|
|
526
537
|
throwIfReplayStopped(error);
|
|
527
538
|
await wait(250);
|
|
@@ -535,7 +546,7 @@ async function waitForElementGone(sessionId: string, step: AppiumRecordedStepRec
|
|
|
535
546
|
const timeoutMs = step.timeoutMs || 10000;
|
|
536
547
|
while (Date.now() - startedAt <= timeoutMs) {
|
|
537
548
|
try {
|
|
538
|
-
await findElement(sessionId, step);
|
|
549
|
+
await findElement(sessionId, step, Math.max(1, timeoutMs - (Date.now() - startedAt)));
|
|
539
550
|
} catch (error) {
|
|
540
551
|
throwIfReplayStopped(error);
|
|
541
552
|
return;
|
|
@@ -768,6 +779,10 @@ async function runStep(
|
|
|
768
779
|
return `ADB 已启动 ${packageName}`;
|
|
769
780
|
}
|
|
770
781
|
|
|
782
|
+
if (step.type === 'stopApp') {
|
|
783
|
+
return stopAppOnDevice(deviceId, step.value || '', replayContext.getStore()?.signal);
|
|
784
|
+
}
|
|
785
|
+
|
|
771
786
|
if (step.type === 'clearAppData') {
|
|
772
787
|
const packageName = step.value || '';
|
|
773
788
|
await clearAppDataOnDevice(deviceId, packageName);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
|
|
3
|
+
const deadline = new AsyncLocalStorage<number>();
|
|
4
|
+
export class AppiumRequestTimeoutError extends Error {}
|
|
5
|
+
|
|
6
|
+
export function withElementDeadline<T>(timeoutMs: number, action: () => Promise<T>) {
|
|
7
|
+
return deadline.run(Date.now() + Math.max(1, timeoutMs), action);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// 响应体也受同一个期限约束,避免收到 HTTP 头后继续无限等待。
|
|
11
|
+
export async function timedAppiumFetch(url: string, init: RequestInit, timeoutMs: number) {
|
|
12
|
+
const remaining = Math.max(1, Math.min(timeoutMs, (deadline.getStore() ?? Infinity) - Date.now()));
|
|
13
|
+
const timeout = AbortSignal.timeout(Math.ceil(remaining));
|
|
14
|
+
const signal = init.signal ? AbortSignal.any([init.signal, timeout]) : timeout;
|
|
15
|
+
try {
|
|
16
|
+
const response = await fetch(url, { ...init, signal });
|
|
17
|
+
const text = await response.text();
|
|
18
|
+
return { response, text };
|
|
19
|
+
} catch (error) {
|
|
20
|
+
if (timeout.aborted && !init.signal?.aborted) {
|
|
21
|
+
throw new AppiumRequestTimeoutError(`设备自动化服务无响应:Appium 请求超过 ${Math.ceil(remaining)}ms,已停止等待(${url})`);
|
|
22
|
+
}
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { getAdbCommand } from '../android-sdk';
|
|
3
|
+
|
|
4
|
+
export function stopAppOnDevice(deviceId: string, packageName: string, signal?: AbortSignal) {
|
|
5
|
+
if (!/^[A-Za-z][\w]*(?:\.[A-Za-z][\w]*)+$/.test(packageName)) {
|
|
6
|
+
throw new Error('杀死 APP 需要有效的应用包名');
|
|
7
|
+
}
|
|
8
|
+
// force-stop 只停止指定应用,不使用 pm clear,不删除应用数据。
|
|
9
|
+
return new Promise<string>((resolve, reject) => {
|
|
10
|
+
execFile(getAdbCommand(), ['-s', deviceId, 'shell', 'am', 'force-stop', '--user', 'current', packageName],
|
|
11
|
+
{ timeout: 10000, maxBuffer: 1024 * 1024, signal }, (error, stdout, stderr) => {
|
|
12
|
+
const output = `${stdout || ''}\n${stderr || ''}`.trim();
|
|
13
|
+
if (error || /Error:|Exception|Permission Denial/i.test(output)) {
|
|
14
|
+
reject(new Error(`停止 APP 失败(${packageName}):${output || error?.message}`));
|
|
15
|
+
} else resolve(`已停止 APP 进程:${packageName}(保留应用数据)`);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
}
|
package/server/http-api.ts
CHANGED
|
@@ -38,6 +38,7 @@ import { handleAppiumRecorderRequest } from './appium-recorder/routes';
|
|
|
38
38
|
import { handleRemoteAgentRequest } from './remote-agents/routes';
|
|
39
39
|
import { isRemoteDeviceId, listRemoteAndroidDevices, sendRemoteCommand } from './remote-agents/registry';
|
|
40
40
|
import { getAdbCommand } from './android-sdk';
|
|
41
|
+
import { readAndroidDeviceInfo } from './android-device-info';
|
|
41
42
|
|
|
42
43
|
async function readBody<T>(req: IncomingMessage) {
|
|
43
44
|
let body = '';
|
|
@@ -873,6 +874,24 @@ export function createApiMiddleware() {
|
|
|
873
874
|
return;
|
|
874
875
|
}
|
|
875
876
|
|
|
877
|
+
if (req.url?.startsWith('/api/android-device-info') && req.method === 'GET') {
|
|
878
|
+
try {
|
|
879
|
+
const deviceId = getRequestedDeviceId(req) || selectedDeviceId;
|
|
880
|
+
if (!deviceId) throw new Error('未检测到可用设备');
|
|
881
|
+
assertDeviceAllowedForRequest(req, deviceId);
|
|
882
|
+
const details = isRemoteDeviceId(deviceId)
|
|
883
|
+
? await sendRemoteCommand(deviceId, 'deviceInfo')
|
|
884
|
+
: await readAndroidDeviceInfo(deviceId, getAdbCommand());
|
|
885
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
886
|
+
res.end(JSON.stringify({ deviceId, details }));
|
|
887
|
+
} catch (error) {
|
|
888
|
+
res.statusCode = 500;
|
|
889
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
890
|
+
res.end(JSON.stringify({ message: error instanceof Error ? error.message : '读取设备信息失败' }));
|
|
891
|
+
}
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
|
|
876
895
|
if (req.url?.startsWith('/api/android-display-info') && req.method === 'GET') {
|
|
877
896
|
try {
|
|
878
897
|
const deviceId = getRequestedDeviceId(req) || selectedDeviceId;
|
|
@@ -122,6 +122,7 @@ const insertableRecorderActions: RecorderAction[] = [
|
|
|
122
122
|
'clearInput',
|
|
123
123
|
'coordinateTap',
|
|
124
124
|
'launchApp',
|
|
125
|
+
'stopApp',
|
|
125
126
|
'openGallery',
|
|
126
127
|
'endFlow',
|
|
127
128
|
'loop',
|
|
@@ -1580,6 +1581,10 @@ async function addAction(
|
|
|
1580
1581
|
value: form.appPackage,
|
|
1581
1582
|
}, index, branchTarget);
|
|
1582
1583
|
}
|
|
1584
|
+
if (action === 'stopApp') {
|
|
1585
|
+
return insertStep({ id: createStepId(), type: 'stopApp', label: `杀死 APP ${form.appPackage}`,
|
|
1586
|
+
value: form.appPackage, flow: { nodeKind: 'action' } }, index, branchTarget);
|
|
1587
|
+
}
|
|
1583
1588
|
if (action === 'clearAppData') {
|
|
1584
1589
|
return insertStep({
|
|
1585
1590
|
id: `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
|
|
@@ -16,6 +16,7 @@ export const actionDescriptions: Record<InsertAction, string> = {
|
|
|
16
16
|
swipe: '按指定起点、终点和时长滑动屏幕。',
|
|
17
17
|
pinch: '执行双指放大或缩小手势,需要当前页面支持缩放。',
|
|
18
18
|
launchApp: '启动指定应用,可放在流程中途或分支中执行。',
|
|
19
|
+
stopApp: '停止当前所选包名的 APP 进程,不删除应用或清除数据;可在节点配置中修改目标包名。',
|
|
19
20
|
openGallery: '打开设备相册应用,不自动选择照片或视频。',
|
|
20
21
|
endFlow: '立即结束当前回放流程,后续节点不再执行。',
|
|
21
22
|
loop: '重复执行循环体,支持固定次数或条件退出,必须设置最大次数。',
|
|
@@ -156,7 +156,7 @@ function patchSelector(patch: Partial<AppiumSelector>) {
|
|
|
156
156
|
<el-form-item label="节点类型">
|
|
157
157
|
<el-select
|
|
158
158
|
:model-value="defaultKind()"
|
|
159
|
-
:disabled="disabled || isBooleanCondition(step) || step.type === 'extractVariable' || step.type === 'log' || step.type === 'openGallery' || step.type === 'endFlow' || step.type === 'loop' || step.type === 'breakLoop'"
|
|
159
|
+
:disabled="disabled || isBooleanCondition(step) || step.type === 'stopApp' || step.type === 'extractVariable' || step.type === 'log' || step.type === 'openGallery' || step.type === 'endFlow' || step.type === 'loop' || step.type === 'breakLoop'"
|
|
160
160
|
@update:model-value="patchFlow({ nodeKind: $event as FlowKind })"
|
|
161
161
|
>
|
|
162
162
|
<el-option label="操作" value="action" />
|
|
@@ -164,7 +164,7 @@ function patchSelector(patch: Partial<AppiumSelector>) {
|
|
|
164
164
|
<el-option label="校验" value="assertion" />
|
|
165
165
|
</el-select>
|
|
166
166
|
</el-form-item>
|
|
167
|
-
<el-form-item v-if="!['longPress', 'log', 'openGallery', 'endFlow', 'loop', 'breakLoop'].includes(step.type)" label="超时时间 ms">
|
|
167
|
+
<el-form-item v-if="!['longPress', 'stopApp', 'log', 'openGallery', 'endFlow', 'loop', 'breakLoop'].includes(step.type)" label="超时时间 ms">
|
|
168
168
|
<el-input-number
|
|
169
169
|
:model-value="step.timeoutMs || undefined"
|
|
170
170
|
:disabled="disabled"
|
|
@@ -176,7 +176,7 @@ function patchSelector(patch: Partial<AppiumSelector>) {
|
|
|
176
176
|
</el-form-item>
|
|
177
177
|
</div>
|
|
178
178
|
<LongPressSettings v-if="step.type === 'longPress'" :step="step" :disabled="disabled" @update="patchStep" />
|
|
179
|
-
<el-form-item v-if="['waitActivity', 'launchApp', 'clearAppData'].includes(step.type)" :label="step.type === 'waitActivity' ? '目标 Activity' : '目标 APP 包名'">
|
|
179
|
+
<el-form-item v-if="['waitActivity', 'launchApp', 'stopApp', 'clearAppData'].includes(step.type)" :label="step.type === 'waitActivity' ? '目标 Activity' : '目标 APP 包名'">
|
|
180
180
|
<el-input :model-value="step.value || ''" :disabled="disabled" @update:model-value="patchStep({ value: String($event) })" />
|
|
181
181
|
</el-form-item>
|
|
182
182
|
<template v-if="step.type === 'pinch'">
|
|
@@ -23,6 +23,7 @@ type InsertAction =
|
|
|
23
23
|
| 'swipe'
|
|
24
24
|
| 'pinch'
|
|
25
25
|
| 'launchApp'
|
|
26
|
+
| 'stopApp'
|
|
26
27
|
| 'openGallery'
|
|
27
28
|
| 'endFlow'
|
|
28
29
|
| 'loop'
|
|
@@ -103,6 +104,7 @@ const actionGroups: Array<{ title: string; actions: Array<{ type: InsertAction;
|
|
|
103
104
|
title: '设备操作',
|
|
104
105
|
actions: [
|
|
105
106
|
{ type: 'launchApp', label: '启动 App' },
|
|
107
|
+
{ type: 'stopApp', label: '杀死 APP' },
|
|
106
108
|
{ type: 'keyBack', label: '系统返回' },
|
|
107
109
|
{ type: 'keyHome', label: 'Home 键' },
|
|
108
110
|
{ type: 'openGallery', label: '启动相册' },
|
|
@@ -153,7 +155,7 @@ function typeLabel(step: AppiumRecordedStep) {
|
|
|
153
155
|
clearIfExists: '存在则清空', backIfExists: '存在则返回', clearInput: '清空',
|
|
154
156
|
waitFor: '等待出现', waitDisappear: '等待消失', assertExists: '断言存在',
|
|
155
157
|
assertText: '断言文本', key: '按键', waitActivity: '等待 Activity', delay: '延时',
|
|
156
|
-
coordinateTap: '坐标点击', swipe: '滑动', launchApp: '启动 APP', clearAppData: '清理 APP 缓存', longPress: '长按',
|
|
158
|
+
coordinateTap: '坐标点击', swipe: '滑动', launchApp: '启动 APP', stopApp: '杀死 APP', clearAppData: '清理 APP 缓存', longPress: '长按',
|
|
157
159
|
pinch: '双指缩放', runScript: '连接脚本', screenshot: '截图', visualChange: '检测画面变化',
|
|
158
160
|
noop: '空节点',
|
|
159
161
|
endFlow: '终止流程',
|
|
@@ -80,6 +80,7 @@ const insertActionGroups: FlowActionGroup[] = [
|
|
|
80
80
|
{ type: 'pinch', label: '双指缩放' },
|
|
81
81
|
{ type: 'clearAppData', label: '清理 App 缓存' },
|
|
82
82
|
{ type: 'launchApp', label: '启动 App' },
|
|
83
|
+
{ type: 'stopApp', label: '杀死 APP' },
|
|
83
84
|
{ type: 'openGallery', label: '启动相册' },
|
|
84
85
|
],
|
|
85
86
|
},
|
|
@@ -58,6 +58,7 @@ export function flowTypeLabel(step: AppiumRecordedStep) {
|
|
|
58
58
|
swipe: '滑动',
|
|
59
59
|
screenshot: '截图',
|
|
60
60
|
launchApp: '启动 APP',
|
|
61
|
+
stopApp: '杀死 APP',
|
|
61
62
|
openGallery: '启动相册',
|
|
62
63
|
endFlow: '终止流程',
|
|
63
64
|
loop: '有界循环',
|
|
@@ -98,6 +99,7 @@ export function flowStepMeta(step: AppiumRecordedStep) {
|
|
|
98
99
|
if (step.type === 'key') return `keyCode ${step.keyCode || ''}`;
|
|
99
100
|
if (step.type === 'waitActivity') return step.value || '';
|
|
100
101
|
if (step.type === 'launchApp') return step.value || '';
|
|
102
|
+
if (step.type === 'stopApp') return `${step.value || ''}(仅停止进程)`;
|
|
101
103
|
if (step.type === 'clearAppData') return `${step.value || ''}(清除数据与缓存)`;
|
|
102
104
|
if (step.type === 'runScript') return step.value ? `脚本 ${step.value}` : '';
|
|
103
105
|
if (step.type === 'noop') return '';
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { shallowRef, watch, onBeforeUnmount } from 'vue';
|
|
3
|
+
import { QuestionFilled } from '@element-plus/icons-vue';
|
|
4
|
+
import { APP_BASE } from '../../api';
|
|
5
|
+
import type { AndroidDeviceDetails } from '../../types';
|
|
6
|
+
|
|
7
|
+
const props = defineProps<{ deviceId: string }>();
|
|
8
|
+
const details = shallowRef<AndroidDeviceDetails>();
|
|
9
|
+
const loading = shallowRef(false);
|
|
10
|
+
const error = shallowRef('');
|
|
11
|
+
let request: AbortController | undefined;
|
|
12
|
+
const fields: [keyof AndroidDeviceDetails, string][] = [
|
|
13
|
+
['name', '设备名称'], ['brand', '品牌'], ['model', '型号'], ['processor', '处理器'],
|
|
14
|
+
['androidVersion', 'Android 版本'], ['physicalResolution', '物理分辨率'], ['resolution', '分辨率'],
|
|
15
|
+
];
|
|
16
|
+
watch(() => props.deviceId, () => { request?.abort(); request = undefined; details.value = undefined; error.value = ''; loading.value = false; });
|
|
17
|
+
onBeforeUnmount(() => request?.abort());
|
|
18
|
+
async function load() {
|
|
19
|
+
if (!props.deviceId || loading.value) return;
|
|
20
|
+
const controller = new AbortController();
|
|
21
|
+
request = controller; loading.value = true; error.value = '';
|
|
22
|
+
try {
|
|
23
|
+
const response = await fetch(`${APP_BASE}/api/android-device-info?deviceId=${encodeURIComponent(props.deviceId)}`, { signal: controller.signal });
|
|
24
|
+
const payload = await response.json();
|
|
25
|
+
if (!response.ok) throw new Error(payload.message || '读取设备信息失败');
|
|
26
|
+
if (request === controller) details.value = payload.details;
|
|
27
|
+
} catch (cause) {
|
|
28
|
+
if (request === controller && !controller.signal.aborted) error.value = cause instanceof Error ? cause.message : '读取失败';
|
|
29
|
+
} finally { if (request === controller) loading.value = false; }
|
|
30
|
+
}
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<template>
|
|
34
|
+
<el-tooltip placement="bottom" effect="dark" :show-after="200" @before-show="load">
|
|
35
|
+
<template #content>
|
|
36
|
+
<div class="device-info">
|
|
37
|
+
<div v-if="!deviceId">未选择设备</div>
|
|
38
|
+
<template v-else>
|
|
39
|
+
<div v-if="loading">正在读取设备信息…</div>
|
|
40
|
+
<div v-if="error" role="status">{{ error }}</div>
|
|
41
|
+
<dl><template v-for="[key, label] in fields" :key="key"><dt>{{ label }}</dt><dd>{{ details?.[key] || '未知' }}</dd></template></dl>
|
|
42
|
+
</template>
|
|
43
|
+
</div>
|
|
44
|
+
</template>
|
|
45
|
+
<el-button class="device-info-help" :icon="QuestionFilled" text aria-label="设备信息" />
|
|
46
|
+
</el-tooltip>
|
|
47
|
+
</template>
|
|
48
|
+
|
|
49
|
+
<style scoped>
|
|
50
|
+
.device-info-help { width: 28px; height: 28px; padding: 0; font-size: 16px; }
|
|
51
|
+
.device-info { max-width: min(340px, calc(100vw - 40px)); font-size: 12px; overflow-wrap: anywhere; }
|
|
52
|
+
.device-info dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 6px 14px; margin: 4px 0; }
|
|
53
|
+
.device-info dt { color: #cbd5e1; }
|
|
54
|
+
.device-info dd { margin: 0; }
|
|
55
|
+
</style>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
|
|
3
3
|
import { Close, Cpu, FullScreen, Refresh } from '@element-plus/icons-vue';
|
|
4
4
|
import type { AndroidDevice, DeviceAction } from '../../types';
|
|
5
|
+
import DeviceInfoTooltip from './DeviceInfoTooltip.vue';
|
|
5
6
|
|
|
6
7
|
type DeviceOverlayBounds = {
|
|
7
8
|
id: string;
|
|
@@ -293,7 +294,7 @@ function handlePointerCancel(event: PointerEvent) {
|
|
|
293
294
|
<el-card shadow="never" class="automation-card device-preview-card" :class="{ 'device-preview-card--compact': compact, 'device-preview-card--enlarged': enlarged }">
|
|
294
295
|
<template #header>
|
|
295
296
|
<div class="panel-header">
|
|
296
|
-
<span>设备预览</span>
|
|
297
|
+
<div class="device-preview-title"><span>设备预览</span><DeviceInfoTooltip :device-id="selectedDeviceId" /></div>
|
|
297
298
|
<div class="device-status">
|
|
298
299
|
<el-tag v-if="compact" :type="available ? 'success' : 'info'">
|
|
299
300
|
{{ available ? '已连接' : '未连接' }}
|
|
@@ -444,6 +445,7 @@ function handlePointerCancel(event: PointerEvent) {
|
|
|
444
445
|
</template>
|
|
445
446
|
|
|
446
447
|
<style scoped>
|
|
448
|
+
.device-preview-title { display: flex; align-items: center; gap: 8px; }
|
|
447
449
|
.device-preview-card--compact { display: flex; flex-direction: column; min-height: 0; }
|
|
448
450
|
.device-preview-card--compact :deep(> .el-card__body) { display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; gap: 8px; }
|
|
449
451
|
.device-preview-card--compact .panel-header { flex-wrap: wrap; gap: 8px; font-size: 14px; }
|
package/src/types.ts
CHANGED
|
@@ -147,3 +147,12 @@ export type DeviceAction = {
|
|
|
147
147
|
icon: string;
|
|
148
148
|
keyCode: number;
|
|
149
149
|
};
|
|
150
|
+
export type AndroidDeviceDetails = {
|
|
151
|
+
name: string;
|
|
152
|
+
brand: string;
|
|
153
|
+
model: string;
|
|
154
|
+
processor: string;
|
|
155
|
+
androidVersion: string;
|
|
156
|
+
physicalResolution: string;
|
|
157
|
+
resolution: string;
|
|
158
|
+
};
|