android-midscene-automation 0.1.36 → 0.1.38
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 +11 -0
- package/README.md +11 -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/ai-recognition.ts +4 -2
- package/server/appium-recorder/appium-runner.ts +64 -30
- package/server/appium-recorder/condition-timeout.ts +4 -0
- package/server/appium-recorder/image-check.ts +6 -2
- package/server/appium-recorder/report.ts +1 -1
- package/server/appium-recorder/repository.ts +2 -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 +23 -57
- package/src/appium-recorder/action-descriptions.ts +1 -0
- package/src/appium-recorder/components/BranchTimeoutSettings.vue +23 -0
- package/src/appium-recorder/components/FlowStepEditor.vue +8 -17
- package/src/appium-recorder/components/ImageCheckDialog.vue +11 -5
- 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/image-check.ts +3 -1
- package/src/appium-recorder/node-timeout.ts +1 -0
- package/src/appium-recorder/types.ts +2 -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,15 @@
|
|
|
1
1
|
# 更新记录
|
|
2
|
+
## v0.1.38
|
|
3
|
+
- 移除节点类型切换;新节点及未配置的判断等待超时统一为 3000ms,已有自定义时间和延时、长按等操作时长保持不变。
|
|
4
|
+
- 取消 Activity 不匹配时的脚本编辑锁定,不在脚本绑定页面也可编辑节点;页面差异仅作提示,保留录制操作及回放期间的编辑保护。
|
|
5
|
+
- 所有分支节点新增“超时后处理”,可预设终止流程、进入左侧或右侧分支,默认终止;覆盖元素等待、文字匹配、勾选控件等待、AI 识别及图像观察超时,日志和 HTML 报告标明超时分支。正常 false、循环次数上限与超时区分处理,设备断开及 Appium 无响应仍终止。
|
|
6
|
+
|
|
7
|
+
## v0.1.37
|
|
8
|
+
- 修复元素查找超时未覆盖 Appium 请求的问题,驱动无响应时及时中止并明确报错,不再误判为找不到元素;唯一原生定位优先使用 ID 或 Accessibility ID。
|
|
9
|
+
- 设备预览新增设备信息,展示设备名称、品牌、型号、处理器、Android 版本、物理分辨率及当前分辨率,支持远程设备;问号位于标题右侧,样式与全局变量帮助图标一致。
|
|
10
|
+
- 新增独立“杀死 APP”操作节点,默认使用当前选中应用包名,支持编辑目标包名;停止当前设备用户下的应用进程,不清除应用数据。
|
|
11
|
+
- Midscene 相关模块由 1.12.5 统一升级至 1.12.6。
|
|
12
|
+
|
|
2
13
|
## v0.1.36
|
|
3
14
|
- 新合并支持取消:原合并按钮切换为取消合并,还原分支和被删除的重复节点,保留配置编辑;公共流程新增的普通操作可选择保留左侧、右侧或两侧,结构冲突时阻止还原。本地保存还原记录,导出及节点复制不携带历史记录。
|
|
4
15
|
- 启动相册前先停止当前设备用户下的目标相册进程,再重新打开;保留相册自动适配,不清除照片及应用数据,停止失败时明确报错。
|
package/README.md
CHANGED
|
@@ -1229,6 +1229,17 @@ Linux: ~/.local/share/android-midscene-automation
|
|
|
1229
1229
|
|
|
1230
1230
|
# 项目更新记录
|
|
1231
1231
|
|
|
1232
|
+
## v0.1.38
|
|
1233
|
+
- 移除节点类型切换;新节点及未配置的判断等待超时统一为 3000ms,已有自定义时间和延时、长按等操作时长保持不变。
|
|
1234
|
+
- 取消 Activity 不匹配时的脚本编辑锁定,不在脚本绑定页面也可编辑节点;页面差异仅作提示,保留录制操作及回放期间的编辑保护。
|
|
1235
|
+
- 所有分支节点新增“超时后处理”,可预设终止流程、进入左侧或右侧分支,默认终止;覆盖元素等待、文字匹配、勾选控件等待、AI 识别及图像观察超时,日志和 HTML 报告标明超时分支。正常 false、循环次数上限与超时区分处理,设备断开及 Appium 无响应仍终止。
|
|
1236
|
+
|
|
1237
|
+
## v0.1.37
|
|
1238
|
+
- 修复元素查找超时未覆盖 Appium 请求的问题,驱动无响应时及时中止并明确报错,不再误判为找不到元素;唯一原生定位优先使用 ID 或 Accessibility ID。
|
|
1239
|
+
- 设备预览新增设备信息,展示设备名称、品牌、型号、处理器、Android 版本、物理分辨率及当前分辨率,支持远程设备;问号位于标题右侧,样式与全局变量帮助图标一致。
|
|
1240
|
+
- 新增独立“杀死 APP”操作节点,默认使用当前选中应用包名,支持编辑目标包名;停止当前设备用户下的应用进程,不清除应用数据。
|
|
1241
|
+
- Midscene 相关模块由 1.12.5 统一升级至 1.12.6。
|
|
1242
|
+
|
|
1232
1243
|
## v0.1.36
|
|
1233
1244
|
- 新合并支持取消:原合并按钮切换为取消合并,还原分支和被删除的重复节点,保留配置编辑;公共流程新增的普通操作可选择保留左侧、右侧或两侧,结构冲突时阻止还原。本地保存还原记录,导出及节点复制不携带历史记录。
|
|
1234
1245
|
- 启动相册前先停止当前设备用户下的目标相册进程,再重新打开;保留相册自动适配,不清除照片及应用数据,停止失败时明确报错。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "android-midscene-automation",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.38",
|
|
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
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import OpenAI from 'openai';
|
|
2
|
+
import { DEFAULT_NODE_TIMEOUT_MS } from '../../src/appium-recorder/node-timeout';
|
|
3
|
+
import { ConditionTimeoutError } from './condition-timeout';
|
|
2
4
|
import { loadConfig } from '../config';
|
|
3
5
|
import { adbScreenshotBase64 } from './screenshot';
|
|
4
6
|
import {
|
|
@@ -35,7 +37,7 @@ export async function recognizeDeviceScreen(input: {
|
|
|
35
37
|
if (!['http:', 'https:'].includes(baseURL.protocol)) throw new Error('AI 识别模型 Base URL 必须为 HTTP(S) 地址');
|
|
36
38
|
const startedAt = Date.now();
|
|
37
39
|
const timeoutMs = Number.isFinite(input.timeoutMs) && input.timeoutMs! > 0
|
|
38
|
-
? Math.min(300000, Math.max(1000, input.timeoutMs!)) :
|
|
40
|
+
? Math.min(300000, Math.max(1000, input.timeoutMs!)) : DEFAULT_NODE_TIMEOUT_MS;
|
|
39
41
|
const deadline = AbortSignal.timeout(timeoutMs);
|
|
40
42
|
const signal = input.signal ? AbortSignal.any([input.signal, deadline]) : deadline;
|
|
41
43
|
signal.throwIfAborted();
|
|
@@ -59,7 +61,7 @@ export async function recognizeDeviceScreen(input: {
|
|
|
59
61
|
return { ...parseAiRecognitionResult(choice.message.content), durationMs: Date.now() - startedAt, imageBase64 };
|
|
60
62
|
} catch (error) {
|
|
61
63
|
if (input.signal?.aborted) throw input.signal.reason;
|
|
62
|
-
if (deadline.aborted) throw new
|
|
64
|
+
if (deadline.aborted) throw new ConditionTimeoutError(`AI 识别超时(${timeoutMs}ms)`);
|
|
63
65
|
// 不透传模型服务的原始响应,避免 API Key 或敏感请求信息进入报告。
|
|
64
66
|
if (error instanceof OpenAI.APIError) throw new Error(`AI 识别模型请求失败(HTTP ${error.status || '连接异常'}),请检查模型配置及图片输入支持`);
|
|
65
67
|
throw error;
|
|
@@ -11,6 +11,10 @@ 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 { DEFAULT_NODE_TIMEOUT_MS } from '../../src/appium-recorder/node-timeout';
|
|
16
|
+
import { ConditionTimeoutError, AppiumServiceError } from './condition-timeout';
|
|
17
|
+
import { AppiumRequestTimeoutError, timedAppiumFetch, withElementDeadline } from './request-timeout';
|
|
14
18
|
import { textClickSelector } from '../../src/appium-recorder/text-click';
|
|
15
19
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
16
20
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
@@ -83,6 +87,8 @@ function isReplayStopped(error?: unknown) {
|
|
|
83
87
|
|
|
84
88
|
function throwIfReplayStopped(error?: unknown) {
|
|
85
89
|
if (isReplayStopped(error)) throw new ReplayStoppedError();
|
|
90
|
+
// 驱动失去响应不是“元素不存在”,不能被备用定位或可选步骤吞掉。
|
|
91
|
+
if (error instanceof AppiumRequestTimeoutError || error instanceof AppiumServiceError) throw error;
|
|
86
92
|
}
|
|
87
93
|
|
|
88
94
|
function errorDetail(error: unknown) {
|
|
@@ -205,16 +211,17 @@ function adbSwipe(deviceId: string, step: Required<AppiumRecordedStepRecord>['sw
|
|
|
205
211
|
});
|
|
206
212
|
}
|
|
207
213
|
|
|
208
|
-
function adbText(deviceId: string, args: string[]) {
|
|
209
|
-
return new Promise<string>((resolve) => {
|
|
210
|
-
execFile(getAdbCommand(), ['-s', deviceId, ...args], { maxBuffer: 4 * 1024 * 1024 }, (
|
|
214
|
+
function adbText(deviceId: string, args: string[], strict = false) {
|
|
215
|
+
return new Promise<string>((resolve, reject) => {
|
|
216
|
+
execFile(getAdbCommand(), ['-s', deviceId, ...args], { maxBuffer: 4 * 1024 * 1024, timeout: 10000, signal: replayContext.getStore()?.signal }, (error, stdout, stderr) => {
|
|
217
|
+
if (strict && error) { reject(new AppiumServiceError(`读取设备状态失败:${stderr || error.message}`)); return; }
|
|
211
218
|
resolve((stdout || stderr || '').trim());
|
|
212
219
|
});
|
|
213
220
|
});
|
|
214
221
|
}
|
|
215
222
|
|
|
216
223
|
async function getCurrentActivity(deviceId: string) {
|
|
217
|
-
const output = await adbText(deviceId, ['shell', 'dumpsys', 'activity', 'activities']);
|
|
224
|
+
const output = await adbText(deviceId, ['shell', 'dumpsys', 'activity', 'activities'], true);
|
|
218
225
|
const resumedLine = output
|
|
219
226
|
.split(/\r?\n/)
|
|
220
227
|
.find((line) => /(?:topResumedActivity|ResumedActivity|mResumedActivity)/.test(line));
|
|
@@ -231,7 +238,7 @@ async function waitForActivity(deviceId: string, step: AppiumRecordedStepRecord)
|
|
|
231
238
|
const expectedActivity = step.value || '';
|
|
232
239
|
if (!expectedActivity) throw new Error(`${step.label} 缺少目标 Activity`);
|
|
233
240
|
const startedAt = Date.now();
|
|
234
|
-
const timeoutMs = step.timeoutMs
|
|
241
|
+
const timeoutMs = step.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS;
|
|
235
242
|
let currentActivity = '';
|
|
236
243
|
while (Date.now() - startedAt <= timeoutMs) {
|
|
237
244
|
throwIfReplayStopped();
|
|
@@ -239,7 +246,7 @@ async function waitForActivity(deviceId: string, step: AppiumRecordedStepRecord)
|
|
|
239
246
|
if (currentActivity === expectedActivity) return;
|
|
240
247
|
await wait(500);
|
|
241
248
|
}
|
|
242
|
-
throw new
|
|
249
|
+
throw new ConditionTimeoutError(`${step.label} 等待超时,当前 Activity:${currentActivity || '-'}`);
|
|
243
250
|
}
|
|
244
251
|
|
|
245
252
|
async function appendSettingsDiagnostics(lines: string[], deviceId: string) {
|
|
@@ -276,30 +283,31 @@ async function appiumRequest<T>(
|
|
|
276
283
|
if (!options?.ignoreAbort) throwIfReplayStopped();
|
|
277
284
|
context?.appiumLog(`[Appium] 请求:${method} ${path}`);
|
|
278
285
|
let response: Response;
|
|
286
|
+
let responseText: string;
|
|
279
287
|
try {
|
|
280
|
-
|
|
288
|
+
const result = await timedAppiumFetch(requestUrl, {
|
|
281
289
|
...init,
|
|
282
|
-
signal: options?.ignoreAbort
|
|
283
|
-
? (options.timeoutMs ? AbortSignal.timeout(options.timeoutMs) : undefined)
|
|
284
|
-
: context?.signal || init?.signal,
|
|
290
|
+
signal: options?.ignoreAbort ? undefined : context?.signal || init?.signal,
|
|
285
291
|
headers: {
|
|
286
292
|
'Content-Type': 'application/json',
|
|
287
293
|
...(init?.headers || {}),
|
|
288
294
|
},
|
|
289
|
-
});
|
|
295
|
+
}, options?.timeoutMs ?? (path === '/session' && method === 'POST' ? 120000 : 30000));
|
|
296
|
+
response = result.response;
|
|
297
|
+
responseText = result.text;
|
|
290
298
|
} catch (error) {
|
|
291
299
|
if (isReplayStopped(error) && !options?.ignoreAbort) {
|
|
292
300
|
context?.appiumLog(`[Appium] 取消:${method} ${path}`);
|
|
293
301
|
throw new ReplayStoppedError();
|
|
294
302
|
}
|
|
295
303
|
context?.appiumLog(`[Appium] 异常:${method} ${path}(${errorDetail(error)})`);
|
|
304
|
+
if (error instanceof AppiumRequestTimeoutError) throw error;
|
|
296
305
|
if (isTimeoutError(error)) {
|
|
297
|
-
throw new
|
|
306
|
+
throw new AppiumServiceError(`Appium 请求超时 ${requestUrl},${errorDetail(error)}`);
|
|
298
307
|
}
|
|
299
|
-
throw new
|
|
308
|
+
throw new AppiumServiceError(`无法连接 Appium 服务 ${requestUrl},${errorDetail(error)}`);
|
|
300
309
|
}
|
|
301
310
|
|
|
302
|
-
const responseText = await response.text();
|
|
303
311
|
context?.appiumLog(`[Appium] 响应:HTTP ${response.status} ${method} ${path}(${Date.now() - startedAt}ms)`);
|
|
304
312
|
const payload = (() => {
|
|
305
313
|
try {
|
|
@@ -320,7 +328,8 @@ async function appiumRequest<T>(
|
|
|
320
328
|
payload.value?.stacktrace || payload.stacktrace ? `堆栈信息:\n${payload.value?.stacktrace || payload.stacktrace}` : '',
|
|
321
329
|
responseText ? `原始响应:\n${responseText}` : '',
|
|
322
330
|
].filter(Boolean).join('\n');
|
|
323
|
-
|
|
331
|
+
const ErrorType = ['no such element', 'stale element reference'].includes(payload.value?.error || payload.error || '') ? Error : AppiumServiceError;
|
|
332
|
+
throw new ErrorType(`Appium ${method} ${path} 失败(HTTP ${response.status}):\n${detail || '未返回错误详情'}`);
|
|
324
333
|
}
|
|
325
334
|
return payload;
|
|
326
335
|
}
|
|
@@ -439,7 +448,11 @@ async function findElementBySelector(
|
|
|
439
448
|
return elementId;
|
|
440
449
|
}
|
|
441
450
|
|
|
442
|
-
async function findElement(sessionId: string, step: AppiumRecordedStepRecord) {
|
|
451
|
+
async function findElement(sessionId: string, step: AppiumRecordedStepRecord, timeoutMs = step.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS) {
|
|
452
|
+
return withElementDeadline(timeoutMs, () => findElementWithinDeadline(sessionId, step));
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function findElementWithinDeadline(sessionId: string, step: AppiumRecordedStepRecord) {
|
|
443
456
|
if (!step.selector) throw new Error(`${step.label} 缺少 selector`);
|
|
444
457
|
const attempts: string[] = [];
|
|
445
458
|
|
|
@@ -454,7 +467,9 @@ async function findElement(sessionId: string, step: AppiumRecordedStepRecord) {
|
|
|
454
467
|
}
|
|
455
468
|
}
|
|
456
469
|
|
|
457
|
-
|
|
470
|
+
// 唯一原生定位优先;非唯一元素仍保留父级与备用 XPath 的精确匹配。
|
|
471
|
+
const preferPrimary = step.selector.unique === true && ['id', 'accessibilityId'].includes(step.selector.strategy);
|
|
472
|
+
for (const selector of [...(preferPrimary ? [step.selector] : []), ...(step.selectorChain || [])]) {
|
|
458
473
|
try {
|
|
459
474
|
attempts.push(`${selector.strategy} ${selector.value || ''}`);
|
|
460
475
|
return await findElementBySelector(sessionId, selector);
|
|
@@ -502,26 +517,26 @@ async function tapFallback(deviceId: string, step: AppiumRecordedStepRecord) {
|
|
|
502
517
|
|
|
503
518
|
async function waitForElement(sessionId: string, step: AppiumRecordedStepRecord) {
|
|
504
519
|
const startedAt = Date.now();
|
|
505
|
-
const timeoutMs = step.timeoutMs
|
|
520
|
+
const timeoutMs = step.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS;
|
|
506
521
|
let lastError: unknown;
|
|
507
522
|
while (Date.now() - startedAt <= timeoutMs) {
|
|
508
523
|
try {
|
|
509
|
-
return await findElement(sessionId, step);
|
|
524
|
+
return await findElement(sessionId, step, Math.max(1, timeoutMs - (Date.now() - startedAt)));
|
|
510
525
|
} catch (error) {
|
|
511
526
|
throwIfReplayStopped(error);
|
|
512
527
|
lastError = error;
|
|
513
528
|
await wait(500);
|
|
514
529
|
}
|
|
515
530
|
}
|
|
516
|
-
throw
|
|
531
|
+
throw new ConditionTimeoutError(`${step.label} 等待元素超时(${timeoutMs}ms):${errorDetail(lastError)}`);
|
|
517
532
|
}
|
|
518
533
|
|
|
519
534
|
async function findOptionalElement(sessionId: string, step: AppiumRecordedStepRecord) {
|
|
520
535
|
const startedAt = Date.now();
|
|
521
|
-
const timeoutMs = step.timeoutMs ??
|
|
536
|
+
const timeoutMs = step.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS;
|
|
522
537
|
while (Date.now() - startedAt <= timeoutMs) {
|
|
523
538
|
try {
|
|
524
|
-
return await findElement(sessionId, step);
|
|
539
|
+
return await findElement(sessionId, step, Math.max(1, timeoutMs - (Date.now() - startedAt)));
|
|
525
540
|
} catch (error) {
|
|
526
541
|
throwIfReplayStopped(error);
|
|
527
542
|
await wait(250);
|
|
@@ -532,17 +547,17 @@ async function findOptionalElement(sessionId: string, step: AppiumRecordedStepRe
|
|
|
532
547
|
|
|
533
548
|
async function waitForElementGone(sessionId: string, step: AppiumRecordedStepRecord) {
|
|
534
549
|
const startedAt = Date.now();
|
|
535
|
-
const timeoutMs = step.timeoutMs
|
|
550
|
+
const timeoutMs = step.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS;
|
|
536
551
|
while (Date.now() - startedAt <= timeoutMs) {
|
|
537
552
|
try {
|
|
538
|
-
await findElement(sessionId, step);
|
|
553
|
+
await findElement(sessionId, step, Math.max(1, timeoutMs - (Date.now() - startedAt)));
|
|
539
554
|
} catch (error) {
|
|
540
555
|
throwIfReplayStopped(error);
|
|
541
556
|
return;
|
|
542
557
|
}
|
|
543
558
|
await wait(500);
|
|
544
559
|
}
|
|
545
|
-
throw new
|
|
560
|
+
throw new ConditionTimeoutError(`${step.label} 等待消失超时`);
|
|
546
561
|
}
|
|
547
562
|
|
|
548
563
|
async function saveScreenshot(deviceId: string) {
|
|
@@ -768,6 +783,10 @@ async function runStep(
|
|
|
768
783
|
return `ADB 已启动 ${packageName}`;
|
|
769
784
|
}
|
|
770
785
|
|
|
786
|
+
if (step.type === 'stopApp') {
|
|
787
|
+
return stopAppOnDevice(deviceId, step.value || '', replayContext.getStore()?.signal);
|
|
788
|
+
}
|
|
789
|
+
|
|
771
790
|
if (step.type === 'clearAppData') {
|
|
772
791
|
const packageName = step.value || '';
|
|
773
792
|
await clearAppDataOnDevice(deviceId, packageName);
|
|
@@ -983,7 +1002,7 @@ async function evaluateCondition(sessionId: string, deviceId: string, step: Appi
|
|
|
983
1002
|
}
|
|
984
1003
|
if (step.type === 'textClick') {
|
|
985
1004
|
const selector = textClickSelector(step);
|
|
986
|
-
const deadline = Date.now() + Math.max(0, step.timeoutMs ??
|
|
1005
|
+
const deadline = Date.now() + Math.max(0, step.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS);
|
|
987
1006
|
// 查询空结果才是未匹配;通信错误、重复匹配和点击失败走现有异常处理。
|
|
988
1007
|
do {
|
|
989
1008
|
const payload = await appiumRequest<AppiumValueResponse<Record<string, string>[]>>(`/session/${sessionId}/elements`, {
|
|
@@ -998,7 +1017,10 @@ async function evaluateCondition(sessionId: string, deviceId: string, step: Appi
|
|
|
998
1017
|
await appiumRequest(`/session/${sessionId}/element/${id}/click`, { method: 'POST', body: '{}' });
|
|
999
1018
|
return true;
|
|
1000
1019
|
}
|
|
1001
|
-
if (Date.now() >= deadline)
|
|
1020
|
+
if (Date.now() >= deadline) {
|
|
1021
|
+
if (step.timeoutMs === 0) return false;
|
|
1022
|
+
throw new ConditionTimeoutError(`文字点击等待匹配超时(${step.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS}ms)`);
|
|
1023
|
+
}
|
|
1002
1024
|
await wait(Math.max(0, Math.min(500, deadline - Date.now())));
|
|
1003
1025
|
} while (true);
|
|
1004
1026
|
}
|
|
@@ -1014,7 +1036,7 @@ async function evaluateCondition(sessionId: string, deviceId: string, step: Appi
|
|
|
1014
1036
|
return true;
|
|
1015
1037
|
}
|
|
1016
1038
|
if (step.type === 'assertText' || (step.type === 'assertExists' && step.value)) {
|
|
1017
|
-
const elementId = await
|
|
1039
|
+
const elementId = await waitForElement(sessionId, step);
|
|
1018
1040
|
const payload = await appiumRequest<AppiumValueResponse<string>>(`/session/${sessionId}/element/${elementId}/text`);
|
|
1019
1041
|
const actualText = payload.value || '';
|
|
1020
1042
|
const expectedText = step.value || '';
|
|
@@ -1026,7 +1048,7 @@ async function evaluateCondition(sessionId: string, deviceId: string, step: Appi
|
|
|
1026
1048
|
return true;
|
|
1027
1049
|
} catch (error) {
|
|
1028
1050
|
throwIfReplayStopped(error);
|
|
1029
|
-
|
|
1051
|
+
throw error;
|
|
1030
1052
|
}
|
|
1031
1053
|
}
|
|
1032
1054
|
|
|
@@ -1102,7 +1124,7 @@ async function replayLinkedScript(
|
|
|
1102
1124
|
type: 'waitActivity',
|
|
1103
1125
|
label: step.label,
|
|
1104
1126
|
value: linkedScript.appActivity,
|
|
1105
|
-
timeoutMs:
|
|
1127
|
+
timeoutMs: DEFAULT_NODE_TIMEOUT_MS,
|
|
1106
1128
|
});
|
|
1107
1129
|
} catch (error) {
|
|
1108
1130
|
throwIfReplayStopped(error);
|
|
@@ -1255,6 +1277,7 @@ async function replayFlowSteps(
|
|
|
1255
1277
|
replayContext.getStore()?.imageChecks.push({ ...imageResult, nodeId: step.id, nodeNumber: index + 1, nodeLabel: frameStep.label, scriptName: options.scriptName || '', settings });
|
|
1256
1278
|
lines.push(`[节点 ${index + 1}] 图像判断:${imageResult.result === null ? '无法判定' : imageResult.result},${imageResult.message};${settings};采样 ${imageResult.sampleCount} 帧,耗时 ${imageResult.durationMs}ms;指标 ${JSON.stringify(imageResult.metrics)}`);
|
|
1257
1279
|
if (imageResult.result === null) throw new Error(imageResult.message);
|
|
1280
|
+
if (imageResult.timedOut) throw new ConditionTimeoutError(imageResult.message);
|
|
1258
1281
|
}
|
|
1259
1282
|
const matched = imageResult ? imageResult.result! : recognition ? recognition.result : await evaluateCondition(sessionId, deviceId, step);
|
|
1260
1283
|
if (recognition) lines.push(`[节点 ${index + 1}] AI 识别:${recognition.result},耗时 ${recognition.durationMs}ms${recognition.reason ? `,依据:${recognition.reason}` : ''}`);
|
|
@@ -1279,6 +1302,17 @@ async function replayFlowSteps(
|
|
|
1279
1302
|
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'stopped', '已终止');
|
|
1280
1303
|
throw new ReplayStoppedError();
|
|
1281
1304
|
}
|
|
1305
|
+
if (error instanceof ConditionTimeoutError && (step.timeoutBranch === 'yes' || step.timeoutBranch === 'no')) {
|
|
1306
|
+
const branch = step.timeoutBranch;
|
|
1307
|
+
const status = `判断超时,按配置进入${branch === 'yes' ? '左' : '右'}侧分支(${flowBranchLabel(step, branch)})`;
|
|
1308
|
+
lines.push(`[节点 ${index + 1}] ${status};${error.message}`);
|
|
1309
|
+
await captureReplayFrame(sessionId, frameStep, index + 1, options.scriptName || '', 'after', status);
|
|
1310
|
+
// 循环体必须经过 enter,才能正确记轮次及受最大次数保护。
|
|
1311
|
+
index = step.type === 'loop'
|
|
1312
|
+
? branch === 'yes' ? traversal.enter(step).next : traversal.exit(step)
|
|
1313
|
+
: traversal.branch(step, branch === 'yes');
|
|
1314
|
+
continue;
|
|
1315
|
+
}
|
|
1282
1316
|
lines.push(`[节点 ${index + 1}] 失败:${errorDetail(error)}`);
|
|
1283
1317
|
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'error', '失败');
|
|
1284
1318
|
await appendStepDiagnostics(lines, deviceId, step);
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { PNG } from 'pngjs';
|
|
2
|
+
import { AppiumRequestTimeoutError } from './request-timeout';
|
|
3
|
+
import { AppiumServiceError } from './condition-timeout';
|
|
2
4
|
import { validateImageCheck, type ImageCheckConfig, type ImageCheckResult } from '../../src/appium-recorder/image-check';
|
|
3
5
|
import type { AppiumVisualChangeRegion } from '../../src/appium-recorder/types';
|
|
4
6
|
|
|
@@ -138,20 +140,22 @@ export async function checkImage(input: {
|
|
|
138
140
|
const changed = maximum >= config.ratio;
|
|
139
141
|
if (changed || (result.sampleCount >= 2 && now() - observedAt >= config.durationMs)) {
|
|
140
142
|
result.result = config.expectation === 'present' ? changed : !changed;
|
|
143
|
+
result.timedOut = !changed && config.expectation === 'present' && config.durationMs > 0;
|
|
141
144
|
break;
|
|
142
145
|
}
|
|
143
146
|
} else {
|
|
144
147
|
consecutive = measurement.matched ? consecutive + 1 : 0;
|
|
145
148
|
result.metrics['连续满足帧数'] = consecutive;
|
|
146
149
|
if (consecutive >= config.consecutive) { result.result = true; break; }
|
|
147
|
-
if (now() - observedAt >= config.durationMs) { result.result = false; break; }
|
|
150
|
+
if (now() - observedAt >= config.durationMs) { result.result = false; result.timedOut = config.durationMs > 0; break; }
|
|
148
151
|
}
|
|
149
152
|
await input.wait(Math.min(config.intervalMs, Math.max(1, config.durationMs - (now() - observedAt))));
|
|
150
153
|
}
|
|
151
154
|
if (last) keep('判定帧', last);
|
|
152
|
-
result.message = result.result ? '条件成立' : '条件不成立';
|
|
155
|
+
result.message = result.timedOut ? '观察超时,未在规定时间内满足条件' : result.result ? '条件成立' : '条件不成立';
|
|
153
156
|
} catch (error) {
|
|
154
157
|
if (input.signal?.aborted) throw error;
|
|
158
|
+
if (error instanceof AppiumRequestTimeoutError || error instanceof AppiumServiceError) throw error;
|
|
155
159
|
result.message = `无法判定:${error instanceof Error ? error.message : '图像检测异常'}`;
|
|
156
160
|
result.result = null;
|
|
157
161
|
}
|
|
@@ -605,7 +605,7 @@ function createReplayHtml(input: {
|
|
|
605
605
|
article.append(title);
|
|
606
606
|
const detail = document.createElement('dl');
|
|
607
607
|
const region = check.region || {};
|
|
608
|
-
const entries = [['结果', check.result === null ? '无法判定' : String(check.result)], ['配置', check.settings],
|
|
608
|
+
const entries = [['结果', check.timedOut ? '观察超时' : check.result === null ? '无法判定' : String(check.result)], ['配置', check.settings],
|
|
609
609
|
['区域', [region.x, region.y, region.width, region.height].join(', ')],
|
|
610
610
|
['采样', check.sampleCount + ' 帧'], ['耗时', check.durationMs + 'ms'], ['说明', check.message], ...Object.entries(check.metrics)];
|
|
611
611
|
entries.forEach(([key, value]) => {
|
|
@@ -38,6 +38,7 @@ export type AppiumRecordedStepRecord = {
|
|
|
38
38
|
| 'swipe'
|
|
39
39
|
| 'screenshot'
|
|
40
40
|
| 'launchApp'
|
|
41
|
+
| 'stopApp'
|
|
41
42
|
| 'openGallery'
|
|
42
43
|
| 'endFlow'
|
|
43
44
|
| 'loop'
|
|
@@ -98,6 +99,7 @@ export type AppiumRecordedStepRecord = {
|
|
|
98
99
|
returns?: ScriptReturn[];
|
|
99
100
|
keyCode?: number;
|
|
100
101
|
timeoutMs?: number;
|
|
102
|
+
timeoutBranch?: 'stop' | 'yes' | 'no';
|
|
101
103
|
longPressMode?: 'element' | 'coordinates';
|
|
102
104
|
breakLoopTargetId?: string;
|
|
103
105
|
loop?: {
|
|
@@ -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;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, h, onMounted, onUnmounted, provide, reactive, shallowRef, watch } from 'vue';
|
|
3
3
|
import { flowBackgroundKey, normalizeFlowBackground } from './flow-appearance';
|
|
4
|
+
import { DEFAULT_NODE_TIMEOUT_MS } from './node-timeout';
|
|
4
5
|
import { ElForm, ElFormItem, ElInputNumber, ElMessage, ElMessageBox, ElOption, ElSelect } from 'element-plus';
|
|
5
6
|
import { Check, CircleClose, CopyDocument, Delete, Document, Download, Edit, Plus, Refresh, Upload, VideoPlay, View, Clock } from '@element-plus/icons-vue';
|
|
6
7
|
import RunHistoryDialog from './RunHistoryDialog.vue';
|
|
@@ -95,46 +96,6 @@ const workspaceRef = shallowRef<InstanceType<typeof RecorderWorkspace>>();
|
|
|
95
96
|
const historyScript = shallowRef<{ id: string; name: string } | null>(null);
|
|
96
97
|
const AUTO_TREE_REFRESH_INTERVAL_MS = 1800;
|
|
97
98
|
|
|
98
|
-
const insertableRecorderActions: RecorderAction[] = [
|
|
99
|
-
'extractVariable',
|
|
100
|
-
'delay',
|
|
101
|
-
'tap',
|
|
102
|
-
'input',
|
|
103
|
-
'waitFor',
|
|
104
|
-
'tapIfExists',
|
|
105
|
-
'inputIfExists',
|
|
106
|
-
'clearIfExists',
|
|
107
|
-
'backIfExists',
|
|
108
|
-
'popupCondition',
|
|
109
|
-
'checkboxState',
|
|
110
|
-
'checkedState',
|
|
111
|
-
'radioButtonState',
|
|
112
|
-
'aiRecognition',
|
|
113
|
-
'imageCheck',
|
|
114
|
-
'textClick',
|
|
115
|
-
'runScript',
|
|
116
|
-
'keyBack',
|
|
117
|
-
'keyHome',
|
|
118
|
-
'keyRecent',
|
|
119
|
-
'keyPower',
|
|
120
|
-
'waitActivity',
|
|
121
|
-
'swipe',
|
|
122
|
-
'clearInput',
|
|
123
|
-
'coordinateTap',
|
|
124
|
-
'launchApp',
|
|
125
|
-
'openGallery',
|
|
126
|
-
'endFlow',
|
|
127
|
-
'loop',
|
|
128
|
-
'breakLoop',
|
|
129
|
-
'clearAppData',
|
|
130
|
-
'waitDisappear',
|
|
131
|
-
'longPress',
|
|
132
|
-
'pinch',
|
|
133
|
-
'noop',
|
|
134
|
-
'log',
|
|
135
|
-
'visualChangeStart',
|
|
136
|
-
'visualChangeEnd',
|
|
137
|
-
];
|
|
138
99
|
const readonlyFlowActionGroups: FlowActionGroup[] = [];
|
|
139
100
|
const readonlyFlowSelectedIndexes: number[] = [];
|
|
140
101
|
|
|
@@ -293,7 +254,7 @@ const recordingBusy = computed(() => (
|
|
|
293
254
|
|| resolvingNavigation.value
|
|
294
255
|
|| Boolean(pendingNavigation.value)
|
|
295
256
|
));
|
|
296
|
-
const recordingLocked = computed(() => recordingBusy.value ||
|
|
257
|
+
const recordingLocked = computed(() => recordingBusy.value || replaying.value);
|
|
297
258
|
const newScriptDisabled = computed(() => saving.value || replaying.value || recordingBusy.value || launchingApp.value || importingScript.value);
|
|
298
259
|
const overlayBounds = computed(() => flattenNodes(tree.value).flatMap((node) => (
|
|
299
260
|
node.bounds ? [{ id: node.id, ...node.bounds }] : []
|
|
@@ -509,7 +470,7 @@ function createStep(type: NodeStepType, node: AppiumNode): AppiumRecordedStep {
|
|
|
509
470
|
? [{ strategy: 'xpath', value: node.xpath, unique: true, matchCount: 1 }]
|
|
510
471
|
: undefined,
|
|
511
472
|
fallback: node.bounds ? { strategy: 'bounds', centerX: node.bounds.centerX, centerY: node.bounds.centerY } : undefined,
|
|
512
|
-
timeoutMs: type === 'waitFor' ?
|
|
473
|
+
timeoutMs: type === 'waitFor' ? DEFAULT_NODE_TIMEOUT_MS : undefined,
|
|
513
474
|
pageBefore: currentPageSnapshot(node),
|
|
514
475
|
snapshot: {
|
|
515
476
|
text: node.text,
|
|
@@ -613,7 +574,7 @@ function createWaitActivityStep(activity: string): AppiumRecordedStep {
|
|
|
613
574
|
type: 'waitActivity',
|
|
614
575
|
label: `等待 Activity ${activity}`,
|
|
615
576
|
value: activity,
|
|
616
|
-
timeoutMs:
|
|
577
|
+
timeoutMs: DEFAULT_NODE_TIMEOUT_MS,
|
|
617
578
|
};
|
|
618
579
|
}
|
|
619
580
|
|
|
@@ -975,7 +936,7 @@ async function executeFlowStep(index: number) {
|
|
|
975
936
|
} else if (scriptActivityMismatch.value) {
|
|
976
937
|
ElMessage.warning(`App 已启动,当前页面仍为 ${currentActivity.value || '-'}`);
|
|
977
938
|
} else {
|
|
978
|
-
ElMessage.success('
|
|
939
|
+
ElMessage.success('已进入脚本绑定页面');
|
|
979
940
|
}
|
|
980
941
|
} catch (error) {
|
|
981
942
|
ElMessage.error(error instanceof Error ? error.message : 'App 操作失败');
|
|
@@ -1442,13 +1403,13 @@ async function addAction(
|
|
|
1442
1403
|
if (!input) return;
|
|
1443
1404
|
return insertStep({
|
|
1444
1405
|
id: createStepId(), type: 'aiRecognition', label: 'AI 识别',
|
|
1445
|
-
value: validateAiRecognitionPrompt(input.value), timeoutMs:
|
|
1406
|
+
value: validateAiRecognitionPrompt(input.value), timeoutMs: DEFAULT_NODE_TIMEOUT_MS,
|
|
1446
1407
|
flow: { nodeKind: 'condition' },
|
|
1447
1408
|
}, index, branchTarget);
|
|
1448
1409
|
}
|
|
1449
1410
|
if (action === 'textClick') {
|
|
1450
1411
|
const step = reactive<AppiumRecordedStep>({
|
|
1451
|
-
id: createStepId(), type: 'textClick', label: '文字点击', value: '', timeoutMs:
|
|
1412
|
+
id: createStepId(), type: 'textClick', label: '文字点击', value: '', timeoutMs: DEFAULT_NODE_TIMEOUT_MS,
|
|
1452
1413
|
flow: { nodeKind: 'condition', textMatch: 'exact' },
|
|
1453
1414
|
});
|
|
1454
1415
|
const result = await ElMessageBox({
|
|
@@ -1580,6 +1541,10 @@ async function addAction(
|
|
|
1580
1541
|
value: form.appPackage,
|
|
1581
1542
|
}, index, branchTarget);
|
|
1582
1543
|
}
|
|
1544
|
+
if (action === 'stopApp') {
|
|
1545
|
+
return insertStep({ id: createStepId(), type: 'stopApp', label: `杀死 APP ${form.appPackage}`,
|
|
1546
|
+
value: form.appPackage, flow: { nodeKind: 'action' } }, index, branchTarget);
|
|
1547
|
+
}
|
|
1583
1548
|
if (action === 'clearAppData') {
|
|
1584
1549
|
return insertStep({
|
|
1585
1550
|
id: `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
|
|
@@ -1612,7 +1577,7 @@ async function addAction(
|
|
|
1612
1577
|
ElMessage.warning(`请选择原生 ${controlName} 元素,当前选中:${node.className || '未知类型'}`);
|
|
1613
1578
|
return;
|
|
1614
1579
|
}
|
|
1615
|
-
const step = createNodeActionStep(action, action === 'checkedState' ? '判断勾选' : `判断 ${controlName} 状态`, node, { timeoutMs:
|
|
1580
|
+
const step = createNodeActionStep(action, action === 'checkedState' ? '判断勾选' : `判断 ${controlName} 状态`, node, { timeoutMs: DEFAULT_NODE_TIMEOUT_MS });
|
|
1616
1581
|
if ((!step.selector?.value || step.selector.strategy === 'bounds') && node.xpath) {
|
|
1617
1582
|
step.selector = { strategy: 'xpath', value: node.xpath };
|
|
1618
1583
|
step.contextSelector = undefined;
|
|
@@ -1628,13 +1593,13 @@ async function addAction(
|
|
|
1628
1593
|
return insertStep(step, index, branchTarget);
|
|
1629
1594
|
}
|
|
1630
1595
|
if (action === 'popupCondition') {
|
|
1631
|
-
const step = createNodeActionStep('assertExists', '判断存在', node, { timeoutMs:
|
|
1596
|
+
const step = createNodeActionStep('assertExists', '判断存在', node, { timeoutMs: DEFAULT_NODE_TIMEOUT_MS });
|
|
1632
1597
|
const inserted = insertStep({ ...step, flow: { nodeKind: 'condition' } }, index, branchTarget);
|
|
1633
1598
|
ElMessage.success('已添加判断节点,请在节点面板配置是/否分支');
|
|
1634
1599
|
return inserted;
|
|
1635
1600
|
}
|
|
1636
1601
|
if (action === 'tapIfExists') {
|
|
1637
|
-
return insertStep(createNodeActionStep('tapIfExists', '存在则点击', node, { timeoutMs:
|
|
1602
|
+
return insertStep(createNodeActionStep('tapIfExists', '存在则点击', node, { timeoutMs: DEFAULT_NODE_TIMEOUT_MS }), index, branchTarget);
|
|
1638
1603
|
}
|
|
1639
1604
|
if (action === 'inputIfExists') {
|
|
1640
1605
|
const input = await ElMessageBox.prompt('', '存在则输入', {
|
|
@@ -1645,16 +1610,16 @@ async function addAction(
|
|
|
1645
1610
|
}).catch(() => null);
|
|
1646
1611
|
if (!input) return;
|
|
1647
1612
|
return insertStep(
|
|
1648
|
-
createNodeActionStep('inputIfExists', '存在则输入', node, { timeoutMs:
|
|
1613
|
+
createNodeActionStep('inputIfExists', '存在则输入', node, { timeoutMs: DEFAULT_NODE_TIMEOUT_MS, value: input.value }),
|
|
1649
1614
|
index,
|
|
1650
1615
|
branchTarget,
|
|
1651
1616
|
);
|
|
1652
1617
|
}
|
|
1653
1618
|
if (action === 'clearIfExists') {
|
|
1654
|
-
return insertStep(createNodeActionStep('clearIfExists', '存在则清空', node, { timeoutMs:
|
|
1619
|
+
return insertStep(createNodeActionStep('clearIfExists', '存在则清空', node, { timeoutMs: DEFAULT_NODE_TIMEOUT_MS }), index, branchTarget);
|
|
1655
1620
|
}
|
|
1656
1621
|
if (action === 'backIfExists') {
|
|
1657
|
-
return insertStep(createNodeActionStep('backIfExists', '存在则返回', node, { timeoutMs:
|
|
1622
|
+
return insertStep(createNodeActionStep('backIfExists', '存在则返回', node, { timeoutMs: DEFAULT_NODE_TIMEOUT_MS }), index, branchTarget);
|
|
1658
1623
|
}
|
|
1659
1624
|
if (action === 'clearInput') {
|
|
1660
1625
|
return insertStep(createNodeActionStep('clearInput', '清空输入', node), index, branchTarget);
|
|
@@ -1701,7 +1666,7 @@ async function addAction(
|
|
|
1701
1666
|
return insertStep({ ...step }, index, branchTarget);
|
|
1702
1667
|
}
|
|
1703
1668
|
if (action === 'waitDisappear') {
|
|
1704
|
-
return insertStep(createNodeActionStep('waitDisappear', '等待元素消失', node, { timeoutMs:
|
|
1669
|
+
return insertStep(createNodeActionStep('waitDisappear', '等待元素消失', node, { timeoutMs: DEFAULT_NODE_TIMEOUT_MS }), index, branchTarget);
|
|
1705
1670
|
}
|
|
1706
1671
|
}
|
|
1707
1672
|
|
|
@@ -2282,7 +2247,7 @@ watch(
|
|
|
2282
2247
|
v-if="scriptActivityMismatch"
|
|
2283
2248
|
class="appium-activity-summary"
|
|
2284
2249
|
>
|
|
2285
|
-
<summary
|
|
2250
|
+
<summary>当前页面与脚本绑定的 Activity 不一致</summary>
|
|
2286
2251
|
<div class="appium-activity-lock-alert__details">
|
|
2287
2252
|
<div>
|
|
2288
2253
|
<strong>脚本绑定</strong>
|
|
@@ -2292,7 +2257,7 @@ watch(
|
|
|
2292
2257
|
<strong>当前 Activity</strong>
|
|
2293
2258
|
<code>{{ currentActivity || '正在获取' }}</code>
|
|
2294
2259
|
</div>
|
|
2295
|
-
<p
|
|
2260
|
+
<p>不影响脚本编辑;如需录制当前页面的组件操作,请确认设备页面及所选组件。</p>
|
|
2296
2261
|
</div>
|
|
2297
2262
|
</details>
|
|
2298
2263
|
</section>
|
|
@@ -2304,9 +2269,8 @@ watch(
|
|
|
2304
2269
|
:steps="steps"
|
|
2305
2270
|
:clipboard-count="flowClipboardCount"
|
|
2306
2271
|
:disabled="recordingLocked"
|
|
2307
|
-
:remove-disabled="
|
|
2272
|
+
:remove-disabled="recordingLocked"
|
|
2308
2273
|
:merge-disabled="recordingBusy || replaying || saving"
|
|
2309
|
-
:allowed-locked-actions="scriptActivityMismatch && !recordingBusy ? insertableRecorderActions : []"
|
|
2310
2274
|
:launching-step-id="executingAppStepId"
|
|
2311
2275
|
@remove="removeStep"
|
|
2312
2276
|
@copy="copyFlowNodes"
|
|
@@ -2429,6 +2393,8 @@ watch(
|
|
|
2429
2393
|
|
|
2430
2394
|
<ImageCheckDialog
|
|
2431
2395
|
v-if="imageCheckDraft?.imageCheck"
|
|
2396
|
+
:timeout-branch="imageCheckDraft.timeoutBranch"
|
|
2397
|
+
@timeout-branch="imageCheckDraft = { ...imageCheckDraft!, timeoutBranch: $event }"
|
|
2432
2398
|
:config="imageCheckDraft.imageCheck" :device-id="selectedDeviceId"
|
|
2433
2399
|
:has-element="Boolean(selectedNode?.bounds)" :editing="imageCheckEditing" :picking="imageCheckPicking"
|
|
2434
2400
|
@update="updateImageCheck" @close="imageCheckDraft = null; imageCheckPicking = false"
|
|
@@ -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: '重复执行循环体,支持固定次数或条件退出,必须设置最大次数。',
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { AppiumRecordedStep } from '../types';
|
|
3
|
+
import { flowBranchLabel } from '../flow-labels';
|
|
4
|
+
defineProps<{ step: AppiumRecordedStep; disabled?: boolean }>();
|
|
5
|
+
defineEmits<{ update: [value: NonNullable<AppiumRecordedStep['timeoutBranch']>] }>();
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<template>
|
|
9
|
+
<el-form-item label="超时后处理">
|
|
10
|
+
<el-select class="branch-timeout-select" size="small" popper-class="branch-timeout-popper" :model-value="step.timeoutBranch || 'stop'" :disabled="disabled" @update:model-value="$emit('update', $event)">
|
|
11
|
+
<el-option label="未配置分支(超时终止)" value="stop" />
|
|
12
|
+
<el-option :label="`进入左侧分支(${flowBranchLabel(step, 'yes')})`" value="yes" />
|
|
13
|
+
<el-option :label="`进入右侧分支(${flowBranchLabel(step, 'no')})`" value="no" />
|
|
14
|
+
</el-select>
|
|
15
|
+
</el-form-item>
|
|
16
|
+
</template>
|
|
17
|
+
|
|
18
|
+
<style>
|
|
19
|
+
.el-select.branch-timeout-select { width: 240px; max-width: 100%; flex: none; }
|
|
20
|
+
.branch-timeout-popper .el-select-dropdown__list { padding: 4px 0; }
|
|
21
|
+
.branch-timeout-popper .el-select-dropdown__item { height: 28px; line-height: 28px; padding: 0 12px; font-size: 12px; }
|
|
22
|
+
.branch-timeout-popper .el-select-dropdown__item.is-selected { color: var(--el-color-primary); }
|
|
23
|
+
</style>
|
|
@@ -5,6 +5,8 @@ import { normalizeVisualChangeConfig } from '../visual-change';
|
|
|
5
5
|
import { longPressMode } from '../long-press';
|
|
6
6
|
import { defaultFlowKind, isBooleanCondition } from '../flow-labels';
|
|
7
7
|
import TextClickSettings from './TextClickSettings.vue';
|
|
8
|
+
import BranchTimeoutSettings from './BranchTimeoutSettings.vue';
|
|
9
|
+
import { DEFAULT_NODE_TIMEOUT_MS } from '../node-timeout';
|
|
8
10
|
import LongPressSettings from './LongPressSettings.vue';
|
|
9
11
|
import StageLogSettings from './StageLogSettings.vue';
|
|
10
12
|
import LoopSettings from './LoopSettings.vue';
|
|
@@ -116,8 +118,7 @@ function patchVisualRegion(key: keyof VisualChangeConfig['region'], value: unkno
|
|
|
116
118
|
}
|
|
117
119
|
|
|
118
120
|
function patchTimeout(value: unknown) {
|
|
119
|
-
|
|
120
|
-
patchStep({ timeoutMs: timeout || undefined });
|
|
121
|
+
patchStep({ timeoutMs: value == null || value === '' ? undefined : Math.max(0, toInteger(value, DEFAULT_NODE_TIMEOUT_MS)) });
|
|
121
122
|
}
|
|
122
123
|
|
|
123
124
|
const showSelector = computed(() => props.step.type !== 'loop' && props.step.selector && (
|
|
@@ -152,21 +153,10 @@ function patchSelector(patch: Partial<AppiumSelector>) {
|
|
|
152
153
|
@update:model-value="patchStep({ note: String($event) || undefined })"
|
|
153
154
|
/>
|
|
154
155
|
</el-form-item>
|
|
155
|
-
<div
|
|
156
|
-
<el-form-item label="
|
|
157
|
-
<el-select
|
|
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'"
|
|
160
|
-
@update:model-value="patchFlow({ nodeKind: $event as FlowKind })"
|
|
161
|
-
>
|
|
162
|
-
<el-option label="操作" value="action" />
|
|
163
|
-
<el-option label="判断" value="condition" />
|
|
164
|
-
<el-option label="校验" value="assertion" />
|
|
165
|
-
</el-select>
|
|
166
|
-
</el-form-item>
|
|
167
|
-
<el-form-item v-if="!['longPress', 'log', 'openGallery', 'endFlow', 'loop', 'breakLoop'].includes(step.type)" label="超时时间 ms">
|
|
156
|
+
<div>
|
|
157
|
+
<el-form-item v-if="!['longPress', 'stopApp', 'log', 'openGallery', 'endFlow', 'loop', 'breakLoop'].includes(step.type)" label="超时时间 ms">
|
|
168
158
|
<el-input-number
|
|
169
|
-
:model-value="step.timeoutMs
|
|
159
|
+
:model-value="step.timeoutMs ?? (step.type === 'delay' ? 1000 : DEFAULT_NODE_TIMEOUT_MS)"
|
|
170
160
|
:disabled="disabled"
|
|
171
161
|
:min="0"
|
|
172
162
|
:max="999999"
|
|
@@ -174,9 +164,10 @@ function patchSelector(patch: Partial<AppiumSelector>) {
|
|
|
174
164
|
@update:model-value="patchTimeout($event)"
|
|
175
165
|
/>
|
|
176
166
|
</el-form-item>
|
|
167
|
+
<BranchTimeoutSettings v-if="defaultKind() === 'condition'" :step="step" :disabled="disabled" @update="patchStep({ timeoutBranch: $event })" />
|
|
177
168
|
</div>
|
|
178
169
|
<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 包名'">
|
|
170
|
+
<el-form-item v-if="['waitActivity', 'launchApp', 'stopApp', 'clearAppData'].includes(step.type)" :label="step.type === 'waitActivity' ? '目标 Activity' : '目标 APP 包名'">
|
|
180
171
|
<el-input :model-value="step.value || ''" :disabled="disabled" @update:model-value="patchStep({ value: String($event) })" />
|
|
181
172
|
</el-form-item>
|
|
182
173
|
<template v-if="step.type === 'pinch'">
|
|
@@ -4,9 +4,11 @@ import { ElMessage } from 'element-plus';
|
|
|
4
4
|
import { Aim, Camera, Upload, QuestionFilled } from '@element-plus/icons-vue';
|
|
5
5
|
import { IMAGE_CHECK_MODES, validateImageCheck, imageTemplateSize, imageTemplateRegionIssue, expandedImageTemplateRegion, type ImageCheckConfig } from '../image-check';
|
|
6
6
|
import { captureImageCheckRegion } from '../api';
|
|
7
|
+
import BranchTimeoutSettings from './BranchTimeoutSettings.vue';
|
|
8
|
+
import type { AppiumRecordedStep } from '../types';
|
|
7
9
|
|
|
8
|
-
const props = defineProps<{ config: ImageCheckConfig; deviceId: string; hasElement: boolean; editing: boolean; picking: boolean }>();
|
|
9
|
-
const emit = defineEmits<{ update: [config: ImageCheckConfig]; close: []; confirm: []; pick: []; useElement: [] }>();
|
|
10
|
+
const props = defineProps<{ config: ImageCheckConfig; deviceId: string; hasElement: boolean; editing: boolean; picking: boolean; timeoutBranch?: AppiumRecordedStep['timeoutBranch'] }>();
|
|
11
|
+
const emit = defineEmits<{ update: [config: ImageCheckConfig]; timeoutBranch: [value: NonNullable<AppiumRecordedStep['timeoutBranch']>]; close: []; confirm: []; pick: []; useElement: [] }>();
|
|
10
12
|
const busy = ref(false);
|
|
11
13
|
const regionIssue = computed(() => imageTemplateRegionIssue(props.config));
|
|
12
14
|
const expandedRegion = computed(() => expandedImageTemplateRegion(props.config));
|
|
@@ -119,10 +121,14 @@ async function upload(event: Event) {
|
|
|
119
121
|
<el-input :model-value="config.color" maxlength="7" @update:model-value="patch({ color: $event })" />
|
|
120
122
|
</el-form-item>
|
|
121
123
|
<div class="image-check-grid">
|
|
122
|
-
<
|
|
123
|
-
<el-
|
|
124
|
-
|
|
124
|
+
<template v-for="field in numericFields" :key="field.key">
|
|
125
|
+
<el-form-item :label="field.label">
|
|
126
|
+
<el-input-number :model-value="config[field.key]" :min="field.min" :max="field.max" :step="field.step" controls-position="right" @update:model-value="$event !== undefined && patch({ [field.key]: $event })" />
|
|
127
|
+
</el-form-item>
|
|
128
|
+
<BranchTimeoutSettings v-if="field.key === 'durationMs'" style="grid-column: 1 / -1" :step="{ id: '', label: '', type: 'imageCheck', timeoutBranch }" :disabled="busy" @update="emit('timeoutBranch', $event)" />
|
|
129
|
+
</template>
|
|
125
130
|
</div>
|
|
131
|
+
<BranchTimeoutSettings v-if="config.mode === 'state'" :step="{ id: '', label: '', type: 'imageCheck', timeoutBranch }" :disabled="busy" @update="emit('timeoutBranch', $event)" />
|
|
126
132
|
</el-form>
|
|
127
133
|
<template #footer><el-button :disabled="busy" @click="emit('close')">取消</el-button><el-button type="primary" :disabled="busy" @click="confirm">{{ editing ? '保存' : '添加' }}</el-button></template>
|
|
128
134
|
</el-dialog>
|
|
@@ -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 '';
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AppiumVisualChangeRegion } from './types';
|
|
2
|
+
import { DEFAULT_NODE_TIMEOUT_MS } from './node-timeout';
|
|
2
3
|
|
|
3
4
|
export const IMAGE_CHECK_MODES = { template: '模板匹配', state: '图片状态判断', black: '黑屏检测', color: '区域颜色判断', change: '多帧变化检测' } as const;
|
|
4
5
|
export type ImageCheckConfig = {
|
|
@@ -20,6 +21,7 @@ export type ImageCheckConfig = {
|
|
|
20
21
|
consecutive: number;
|
|
21
22
|
};
|
|
22
23
|
export type ImageCheckResult = {
|
|
24
|
+
timedOut?: boolean;
|
|
23
25
|
result: boolean | null;
|
|
24
26
|
mode: ImageCheckConfig['mode'];
|
|
25
27
|
region: AppiumVisualChangeRegion;
|
|
@@ -33,7 +35,7 @@ export type ImageCheckResult = {
|
|
|
33
35
|
export function createImageCheckConfig(): ImageCheckConfig {
|
|
34
36
|
return { mode: 'template', target: 'region', region: { x: 0, y: 0, width: 100, height: 100 },
|
|
35
37
|
screenWidth: 0, screenHeight: 0, expectation: 'present', threshold: 0.9, minScoreGap: 0.1,
|
|
36
|
-
color: '#000000', tolerance: 30, ratio: 95, durationMs:
|
|
38
|
+
color: '#000000', tolerance: 30, ratio: 95, durationMs: DEFAULT_NODE_TIMEOUT_MS, intervalMs: 1000, consecutive: 1 };
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
// 只读取 PNG 头部尺寸,避免为了表单校验解码整张模板。
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const DEFAULT_NODE_TIMEOUT_MS = 3000;
|
|
@@ -93,6 +93,7 @@ export type AppiumRecordedStep = {
|
|
|
93
93
|
| 'swipe'
|
|
94
94
|
| 'screenshot'
|
|
95
95
|
| 'launchApp'
|
|
96
|
+
| 'stopApp'
|
|
96
97
|
| 'openGallery'
|
|
97
98
|
| 'endFlow'
|
|
98
99
|
| 'loop'
|
|
@@ -120,6 +121,7 @@ export type AppiumRecordedStep = {
|
|
|
120
121
|
returns?: ScriptReturn[];
|
|
121
122
|
keyCode?: number;
|
|
122
123
|
timeoutMs?: number;
|
|
124
|
+
timeoutBranch?: 'stop' | 'yes' | 'no';
|
|
123
125
|
longPressMode?: 'element' | 'coordinates';
|
|
124
126
|
breakLoopTargetId?: string;
|
|
125
127
|
loop?: {
|
|
@@ -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
|
+
};
|