android-midscene-automation 0.1.26 → 0.1.28
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 +18 -0
- package/FAQ.md +14 -0
- package/README.md +39 -3
- package/bin/android-midscene-automation.js +71 -1
- package/package.json +7 -2
- package/remote-agent/index.ts +2 -11
- package/server/appium-recorder/ai-recognition.ts +67 -0
- package/server/appium-recorder/appium-runner.ts +335 -52
- package/server/appium-recorder/report.ts +297 -16
- package/server/appium-recorder/repository.ts +31 -4
- package/server/appium-recorder/routes.ts +31 -32
- package/server/appium-recorder/screenshot.ts +21 -0
- package/server/appium-recorder/script-export.ts +67 -0
- package/server/appium-recorder/tree-dump.ts +22 -0
- package/server/appium-recorder/visual-change.ts +184 -0
- package/server/config.ts +10 -0
- package/server/http-api.ts +4 -5
- package/src/App.vue +14 -7
- package/src/api.ts +1 -1
- package/src/appium-recorder/AppiumPage.vue +658 -162
- package/src/appium-recorder/ai-recognition.ts +16 -0
- package/src/appium-recorder/api.ts +20 -3
- package/src/appium-recorder/components/AiRecognitionTestDialog.vue +71 -0
- package/src/appium-recorder/components/FlowActionMenu.vue +206 -0
- package/src/appium-recorder/components/FlowCanvas.vue +16 -1
- package/src/appium-recorder/components/FlowNodeCard.vue +23 -37
- package/src/appium-recorder/components/FlowStepEditor.vue +152 -25
- package/src/appium-recorder/components/LongPressSettings.vue +58 -0
- package/src/appium-recorder/components/NestedConditionBranches.vue +55 -35
- package/src/appium-recorder/components/NewScriptDialog.vue +27 -0
- package/src/appium-recorder/components/NodeDetail.vue +2 -0
- package/src/appium-recorder/components/RecordedSteps.vue +24 -4
- package/src/appium-recorder/components/StageLogSettings.vue +17 -0
- package/src/appium-recorder/components/TextClickSettings.vue +19 -0
- package/src/appium-recorder/components/VisualChangeDialog.vue +151 -0
- package/src/appium-recorder/flow-copy.ts +47 -1
- package/src/appium-recorder/flow-graph.ts +147 -64
- package/src/appium-recorder/flow-labels.ts +35 -6
- package/src/appium-recorder/flow-normalize.ts +76 -0
- package/src/appium-recorder/flow-remove.ts +69 -0
- package/src/appium-recorder/long-press.ts +27 -0
- package/src/appium-recorder/native-control-state.ts +26 -0
- package/src/appium-recorder/stage-log.ts +21 -0
- package/src/appium-recorder/text-click.ts +9 -0
- package/src/appium-recorder/tree.ts +3 -0
- package/src/appium-recorder/types.ts +34 -2
- package/src/appium-recorder/visual-change-pairs.ts +147 -0
- package/src/appium-recorder/visual-change.ts +75 -0
- package/src/components/config/AppiumConfigPanel.vue +48 -0
- package/src/components/config/BaseConfigPanel.vue +90 -0
- package/src/components/config/MidsceneConfigPanel.vue +204 -0
- package/src/components/device/DevicePreviewPanel.vue +148 -4
- package/src/pages/ConfigPage.vue +107 -265
- package/src/style.css +43 -4
- package/src/types.ts +2 -0
- package/src/ui-refresh.css +5 -0
|
@@ -1,12 +1,28 @@
|
|
|
1
|
-
import type { FlowKind } from './flow-graph';
|
|
1
|
+
import type { FlowBranch, FlowKind } from './flow-graph';
|
|
2
2
|
import type { AppiumRecordedStep } from './types';
|
|
3
|
+
import { visualChangeMeta } from './visual-change';
|
|
4
|
+
import { longPressMode } from './long-press';
|
|
5
|
+
import { isNativeStateCondition } from './native-control-state';
|
|
6
|
+
import { DEFAULT_LOG_PREFIX } from './stage-log';
|
|
7
|
+
|
|
8
|
+
export function isBooleanCondition(step: Pick<AppiumRecordedStep, 'type'>) {
|
|
9
|
+
return isNativeStateCondition(step) || step.type === 'aiRecognition' || step.type === 'textClick';
|
|
10
|
+
}
|
|
3
11
|
|
|
4
12
|
export function defaultFlowKind(step: AppiumRecordedStep): FlowKind {
|
|
13
|
+
if (step.type === 'log') return 'action';
|
|
14
|
+
if (isBooleanCondition(step)) return 'condition';
|
|
5
15
|
if (step.flow?.nodeKind) return step.flow.nodeKind;
|
|
6
|
-
if (step.type === 'assertExists' || step.type === 'assertText') return 'assertion';
|
|
16
|
+
if (step.type === 'assertExists' || step.type === 'assertText' || step.type === 'visualChange') return 'assertion';
|
|
7
17
|
return 'action';
|
|
8
18
|
}
|
|
9
19
|
|
|
20
|
+
export function flowBranchLabel(step: Pick<AppiumRecordedStep, 'type'>, branch: FlowBranch) {
|
|
21
|
+
if (step.type === 'textClick') return branch === 'yes' ? '匹配到文字' : '未匹配到文字';
|
|
22
|
+
if (isBooleanCondition(step)) return branch === 'yes' ? 'true' : 'false';
|
|
23
|
+
return branch === 'yes' ? '是' : '否';
|
|
24
|
+
}
|
|
25
|
+
|
|
10
26
|
export function flowKindLabel(kind: FlowKind) {
|
|
11
27
|
return { action: '操作', condition: '判断', assertion: '校验' }[kind];
|
|
12
28
|
}
|
|
@@ -23,6 +39,10 @@ export function flowTypeLabel(step: AppiumRecordedStep) {
|
|
|
23
39
|
waitFor: '等待出现',
|
|
24
40
|
waitDisappear: '等待消失',
|
|
25
41
|
assertExists: '断言存在',
|
|
42
|
+
checkboxState: 'Checkbox 状态',
|
|
43
|
+
radioButtonState: 'RadioButton 状态',
|
|
44
|
+
aiRecognition: 'AI 识别',
|
|
45
|
+
textClick: '文字点击',
|
|
26
46
|
assertText: '断言文本',
|
|
27
47
|
key: '按键',
|
|
28
48
|
waitActivity: '等待 Activity',
|
|
@@ -35,11 +55,22 @@ export function flowTypeLabel(step: AppiumRecordedStep) {
|
|
|
35
55
|
longPress: '长按',
|
|
36
56
|
pinch: '双指缩放',
|
|
37
57
|
runScript: '连接脚本',
|
|
58
|
+
noop: '空节点',
|
|
59
|
+
log: '输出日志',
|
|
60
|
+
visualChange: '检测画面变化',
|
|
38
61
|
};
|
|
39
62
|
return labelMap[step.type] || step.type;
|
|
40
63
|
}
|
|
41
64
|
|
|
42
65
|
export function flowStepMeta(step: AppiumRecordedStep) {
|
|
66
|
+
if (step.type === 'log') return `${step.logPrefix ?? DEFAULT_LOG_PREFIX}:${step.value || ''}`;
|
|
67
|
+
if (step.type === 'aiRecognition') return step.value || '未填写识别内容';
|
|
68
|
+
if (step.type === 'longPress') {
|
|
69
|
+
const target = longPressMode(step) === 'element'
|
|
70
|
+
? `元素 ${step.selector?.strategy || ''} ${step.selector?.value || ''}`
|
|
71
|
+
: `坐标 ${step.fallback?.centerX ?? ''},${step.fallback?.centerY ?? ''}`;
|
|
72
|
+
return `${target} · ${step.timeoutMs ?? 800}ms`;
|
|
73
|
+
}
|
|
43
74
|
if (step.type === 'delay') return `${step.timeoutMs || 1000}ms`;
|
|
44
75
|
if (step.type === 'input' || step.type === 'inputIfExists') return `输入内容:${step.value || '空'}`;
|
|
45
76
|
if (defaultFlowKind(step) === 'condition' && step.value) {
|
|
@@ -53,15 +84,14 @@ export function flowStepMeta(step: AppiumRecordedStep) {
|
|
|
53
84
|
if (step.type === 'launchApp') return step.value || '';
|
|
54
85
|
if (step.type === 'clearAppData') return `${step.value || ''}(清除数据与缓存)`;
|
|
55
86
|
if (step.type === 'runScript') return step.value ? `脚本 ${step.value}` : '';
|
|
87
|
+
if (step.type === 'noop') return '';
|
|
56
88
|
if (step.type === 'screenshot') return '保存当前截图';
|
|
89
|
+
if (step.type === 'visualChange') return visualChangeMeta(step);
|
|
57
90
|
if (step.type === 'swipe') {
|
|
58
91
|
const swipe = step.swipe;
|
|
59
92
|
return swipe ? `[${swipe.startX},${swipe.startY}] -> [${swipe.endX},${swipe.endY}]` : '';
|
|
60
93
|
}
|
|
61
94
|
if (step.type === 'pinch') return step.pinch?.direction === 'out' ? '放大' : '缩小';
|
|
62
|
-
if (step.type === 'longPress') {
|
|
63
|
-
return `${step.fallback?.centerX || ''},${step.fallback?.centerY || ''} · ${step.timeoutMs || 800}ms`;
|
|
64
|
-
}
|
|
65
95
|
if (step.type === 'coordinateTap') {
|
|
66
96
|
return `${step.fallback?.centerX || ''},${step.fallback?.centerY || ''}`;
|
|
67
97
|
}
|
|
@@ -73,7 +103,6 @@ export function labelFlowStep(step: AppiumRecordedStep) {
|
|
|
73
103
|
const meta = [
|
|
74
104
|
flowKindLabel(defaultFlowKind(step)),
|
|
75
105
|
flowTypeLabel(step),
|
|
76
|
-
step.optional ? '可选' : '',
|
|
77
106
|
flowStepMeta(step),
|
|
78
107
|
].filter(Boolean).join(' · ');
|
|
79
108
|
return {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { AppiumRecordedStep } from './types';
|
|
2
|
+
import { isBooleanCondition } from './flow-labels';
|
|
3
|
+
|
|
4
|
+
type BranchName = 'yes' | 'no';
|
|
5
|
+
|
|
6
|
+
function isCondition(step: AppiumRecordedStep) {
|
|
7
|
+
return step.flow?.nodeKind === 'condition';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function isEmptyNestedCondition(step: AppiumRecordedStep) {
|
|
11
|
+
return isCondition(step)
|
|
12
|
+
// 新类型不存在旧版分支数据,不应把后续同级节点迁入 true 分支。
|
|
13
|
+
&& !isBooleanCondition(step)
|
|
14
|
+
&& Boolean(step.flow?.parentConditionId)
|
|
15
|
+
&& Boolean(step.flow?.parentBranch)
|
|
16
|
+
&& !step.flow?.yesTargetId
|
|
17
|
+
&& !step.flow?.noTargetId
|
|
18
|
+
&& !step.flow?.successTargetId;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function sameParentBranch(step: AppiumRecordedStep, parentConditionId: string, parentBranch: BranchName) {
|
|
22
|
+
return step.flow?.parentConditionId === parentConditionId
|
|
23
|
+
&& step.flow?.parentBranch === parentBranch;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function normalizeLegacyNestedConditionBranches<T extends AppiumRecordedStep>(steps: T[]) {
|
|
27
|
+
// 清理旧脚本已移除的可选步骤设置,避免继续导出或保存失效字段。
|
|
28
|
+
const nextSteps = steps.map((step) => {
|
|
29
|
+
if (!('optional' in step)) return step;
|
|
30
|
+
const next = { ...step };
|
|
31
|
+
Reflect.deleteProperty(next, 'optional');
|
|
32
|
+
return next;
|
|
33
|
+
});
|
|
34
|
+
let changed = nextSteps.some((step, index) => step !== steps[index]);
|
|
35
|
+
|
|
36
|
+
for (let index = 0; index < nextSteps.length; index += 1) {
|
|
37
|
+
const condition = nextSteps[index];
|
|
38
|
+
if (!condition || !isEmptyNestedCondition(condition)) continue;
|
|
39
|
+
|
|
40
|
+
const parentConditionId = condition.flow?.parentConditionId || '';
|
|
41
|
+
const parentBranch = condition.flow?.parentBranch;
|
|
42
|
+
if (!parentConditionId || !parentBranch) continue;
|
|
43
|
+
|
|
44
|
+
const childIndexes: number[] = [];
|
|
45
|
+
for (let childIndex = index + 1; childIndex < nextSteps.length; childIndex += 1) {
|
|
46
|
+
if (!sameParentBranch(nextSteps[childIndex], parentConditionId, parentBranch)) break;
|
|
47
|
+
childIndexes.push(childIndex);
|
|
48
|
+
}
|
|
49
|
+
if (!childIndexes.length) continue;
|
|
50
|
+
|
|
51
|
+
const firstChild = nextSteps[childIndexes[0]];
|
|
52
|
+
nextSteps[index] = {
|
|
53
|
+
...condition,
|
|
54
|
+
flow: {
|
|
55
|
+
...(condition.flow || {}),
|
|
56
|
+
nodeKind: 'condition',
|
|
57
|
+
yesTargetId: firstChild.id,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
childIndexes.forEach((childIndex) => {
|
|
62
|
+
const child = nextSteps[childIndex];
|
|
63
|
+
nextSteps[childIndex] = {
|
|
64
|
+
...child,
|
|
65
|
+
flow: {
|
|
66
|
+
...(child.flow || {}),
|
|
67
|
+
parentConditionId: condition.id,
|
|
68
|
+
parentBranch: 'yes',
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
changed = true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return changed ? nextSteps : steps;
|
|
76
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { AppiumRecordedStep } from './types';
|
|
2
|
+
|
|
3
|
+
type FlowTargetKey = 'yesTargetId' | 'noTargetId' | 'successTargetId' | 'failureTargetId';
|
|
4
|
+
|
|
5
|
+
const FLOW_TARGET_KEYS: FlowTargetKey[] = ['yesTargetId', 'noTargetId', 'successTargetId', 'failureTargetId'];
|
|
6
|
+
|
|
7
|
+
function sameBranch(step: AppiumRecordedStep, removed: AppiumRecordedStep) {
|
|
8
|
+
return Boolean(
|
|
9
|
+
removed.flow?.parentConditionId
|
|
10
|
+
&& removed.flow?.parentBranch
|
|
11
|
+
&& step.flow?.parentConditionId === removed.flow.parentConditionId
|
|
12
|
+
&& step.flow?.parentBranch === removed.flow.parentBranch,
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function nextSequentialStep(steps: AppiumRecordedStep[], removed: AppiumRecordedStep, index: number, removedIds: Set<string>) {
|
|
17
|
+
const followingSteps = steps.slice(index + 1).filter((step) => !removedIds.has(step.id));
|
|
18
|
+
if (removed.flow?.parentConditionId && removed.flow.parentBranch) {
|
|
19
|
+
return followingSteps.find((step) => sameBranch(step, removed));
|
|
20
|
+
}
|
|
21
|
+
return followingSteps.find((step) => !step.flow?.parentConditionId);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function cleanFlow(flow: NonNullable<AppiumRecordedStep['flow']>) {
|
|
25
|
+
return Object.keys(flow).length ? flow : undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function removeFlowStep(steps: AppiumRecordedStep[], index: number) {
|
|
29
|
+
const removed = steps[index];
|
|
30
|
+
if (!removed) return steps;
|
|
31
|
+
|
|
32
|
+
// 分支归属决定删除范围,不能沿跳转连线删除共享的后续节点。
|
|
33
|
+
const removedIds = new Set([removed.id]);
|
|
34
|
+
let changed = true;
|
|
35
|
+
while (changed) {
|
|
36
|
+
changed = false;
|
|
37
|
+
for (const step of steps) {
|
|
38
|
+
if (step.flow?.parentConditionId && removedIds.has(step.flow.parentConditionId) && !removedIds.has(step.id)) {
|
|
39
|
+
removedIds.add(step.id);
|
|
40
|
+
changed = true;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const sequentialStep = nextSequentialStep(steps, removed, index, removedIds);
|
|
45
|
+
const continuation = steps.find((step) => step.id === removed.flow?.successTargetId && !removedIds.has(step.id));
|
|
46
|
+
const replacementId = continuation?.id || sequentialStep?.id || '';
|
|
47
|
+
|
|
48
|
+
return steps
|
|
49
|
+
.filter((step) => !removedIds.has(step.id))
|
|
50
|
+
.map((step) => {
|
|
51
|
+
if (!step.flow) return step;
|
|
52
|
+
|
|
53
|
+
const flow = { ...step.flow };
|
|
54
|
+
let changed = false;
|
|
55
|
+
|
|
56
|
+
FLOW_TARGET_KEYS.forEach((key) => {
|
|
57
|
+
if (!flow[key] || !removedIds.has(flow[key])) return;
|
|
58
|
+
// 只有指向被点击节点的入口可以接到后续节点,其他子节点引用直接清除。
|
|
59
|
+
if (flow[key] === removed.id && replacementId && replacementId !== step.id) {
|
|
60
|
+
flow[key] = replacementId;
|
|
61
|
+
} else {
|
|
62
|
+
delete flow[key];
|
|
63
|
+
}
|
|
64
|
+
changed = true;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return changed ? { ...step, flow: cleanFlow(flow) } : step;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { AppiumRecordedStep } from './types';
|
|
2
|
+
|
|
3
|
+
export function longPressMode(step: Pick<AppiumRecordedStep, 'longPressMode'>) {
|
|
4
|
+
// 旧脚本没有模式字段,继续使用录制坐标;新增节点显式设置为 element。
|
|
5
|
+
return step.longPressMode ?? 'coordinates';
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function validateLongPress(step: AppiumRecordedStep) {
|
|
9
|
+
const duration = step.timeoutMs ?? 800;
|
|
10
|
+
if (!Number.isInteger(duration) || duration < 80) return '长按时间必须是至少 80 毫秒的整数';
|
|
11
|
+
const mode = longPressMode(step);
|
|
12
|
+
if (mode === 'element') {
|
|
13
|
+
if (!step.selector || step.selector.strategy === 'bounds' || !step.selector.value?.trim()) {
|
|
14
|
+
return '请设置有效的长按元素定位器';
|
|
15
|
+
}
|
|
16
|
+
} else if (mode === 'coordinates') {
|
|
17
|
+
const point = step.fallback;
|
|
18
|
+
if (point?.strategy !== 'bounds'
|
|
19
|
+
|| !Number.isFinite(point.centerX) || Number(point.centerX) < 0
|
|
20
|
+
|| !Number.isFinite(point.centerY) || Number(point.centerY) < 0) {
|
|
21
|
+
return '请填写有效的长按坐标';
|
|
22
|
+
}
|
|
23
|
+
} else {
|
|
24
|
+
return '长按方式无效';
|
|
25
|
+
}
|
|
26
|
+
return '';
|
|
27
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { AppiumRecordedStep } from './types';
|
|
2
|
+
|
|
3
|
+
export type NativeStateType = 'checkboxState' | 'radioButtonState';
|
|
4
|
+
|
|
5
|
+
export function isNativeStateCondition(step: Pick<AppiumRecordedStep, 'type'>): step is { type: NativeStateType } {
|
|
6
|
+
return step.type === 'checkboxState' || step.type === 'radioButtonState';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function nativeControlName(type: NativeStateType) {
|
|
10
|
+
return type === 'checkboxState' ? 'Checkbox' : 'RadioButton';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function nativeControlType(className: unknown): NativeStateType | undefined {
|
|
14
|
+
if (typeof className !== 'string') return undefined;
|
|
15
|
+
// 识别原生、AppCompat 和 Material 控件;仅有 checkable 的 View 不能证明控件类型。
|
|
16
|
+
if (/(?:^|\.)(?:AppCompat|Material)?CheckBox$/.test(className)) return 'checkboxState';
|
|
17
|
+
if (/(?:^|\.)(?:AppCompat|Material)?RadioButton$/.test(className)) return 'radioButtonState';
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// UIAutomator XML 使用字符串,Appium 属性响应也可能返回布尔值;缺失值不能视为 false。
|
|
22
|
+
export function parseNativeBoolean(value: unknown): boolean | undefined {
|
|
23
|
+
if (value === true || value === 'true') return true;
|
|
24
|
+
if (value === false || value === 'false') return false;
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { AppiumRecordedStep } from './types';
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_LOG_PREFIX = 'stageLog';
|
|
4
|
+
type LogStep = Pick<AppiumRecordedStep, 'logPrefix' | 'value'>;
|
|
5
|
+
|
|
6
|
+
export function validateStageLog(step: LogStep) {
|
|
7
|
+
const prefix = step.logPrefix ?? DEFAULT_LOG_PREFIX;
|
|
8
|
+
if (typeof prefix !== 'string' || !prefix.trim() || /[\r\n::]/.test(prefix) || prefix.length > 100) {
|
|
9
|
+
return '关键字不能为空,不能包含冒号或换行,最多 100 字';
|
|
10
|
+
}
|
|
11
|
+
if (typeof step.value !== 'string' || !step.value.trim()) return '请输入日志内容';
|
|
12
|
+
if (step.value.length > 4000) return '日志内容不能超过 4000 字';
|
|
13
|
+
return '';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function formatStageLog(step: LogStep) {
|
|
17
|
+
const error = validateStageLog(step);
|
|
18
|
+
if (error) throw new Error(error);
|
|
19
|
+
// 多行内容逐行添加关键字,便于搜索;内容仅作为文本,不执行插值或代码。
|
|
20
|
+
return step.value!.split(/\r?\n/).map((line) => `${(step.logPrefix ?? DEFAULT_LOG_PREFIX).trim()}:${line}`).join('\n');
|
|
21
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { AppiumRecordedStep } from './types';
|
|
2
|
+
|
|
3
|
+
export function textClickSelector(step: Pick<AppiumRecordedStep, 'value' | 'flow'>) {
|
|
4
|
+
if (!step.value?.trim()) throw new Error('请输入要点击的文字');
|
|
5
|
+
if (step.value.length > 1000) throw new Error('点击文字不能超过 1000 字');
|
|
6
|
+
// JSON 字符串转义防止引号、反斜杠等内容改变 UiSelector 表达式。
|
|
7
|
+
const method = step.flow?.textMatch === 'exact' ? 'text' : 'textContains';
|
|
8
|
+
return { using: '-android uiautomator', value: `new UiSelector().${method}(${JSON.stringify(step.value)})` };
|
|
9
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AppiumBounds, AppiumNode, AppiumSelector } from './types';
|
|
2
|
+
import { parseNativeBoolean } from './native-control-state';
|
|
2
3
|
|
|
3
4
|
type SelectorCounts = {
|
|
4
5
|
contentDesc: Map<string, number>;
|
|
@@ -112,6 +113,8 @@ function transformNode(
|
|
|
112
113
|
contentDesc: attr(element, 'content-desc'),
|
|
113
114
|
className,
|
|
114
115
|
packageName: attr(element, 'package'),
|
|
116
|
+
checkable: parseNativeBoolean(attr(element, 'checkable')),
|
|
117
|
+
checked: parseNativeBoolean(attr(element, 'checked')),
|
|
115
118
|
clickable: attr(element, 'clickable') === 'true',
|
|
116
119
|
enabled: attr(element, 'enabled') !== 'false',
|
|
117
120
|
xpath,
|
|
@@ -24,6 +24,27 @@ export type AppiumPageSnapshot = {
|
|
|
24
24
|
selectedNodePath?: string;
|
|
25
25
|
};
|
|
26
26
|
|
|
27
|
+
export type AppiumVisualChangeRegion = {
|
|
28
|
+
x: number;
|
|
29
|
+
y: number;
|
|
30
|
+
width: number;
|
|
31
|
+
height: number;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type AppiumVisualChangeConfig = {
|
|
35
|
+
mode: 'selectedElement' | 'region';
|
|
36
|
+
region: AppiumVisualChangeRegion;
|
|
37
|
+
role?: 'start' | 'end';
|
|
38
|
+
pairId?: string;
|
|
39
|
+
pairLabel?: string;
|
|
40
|
+
startStepId?: string;
|
|
41
|
+
endStepId?: string;
|
|
42
|
+
durationMs: number;
|
|
43
|
+
intervalMs: number;
|
|
44
|
+
changeRatioThreshold: number;
|
|
45
|
+
pixelmatchThreshold: number;
|
|
46
|
+
};
|
|
47
|
+
|
|
27
48
|
export type AppiumNode = {
|
|
28
49
|
id: string;
|
|
29
50
|
label: string;
|
|
@@ -34,6 +55,8 @@ export type AppiumNode = {
|
|
|
34
55
|
packageName: string;
|
|
35
56
|
clickable: boolean;
|
|
36
57
|
enabled: boolean;
|
|
58
|
+
checkable?: boolean;
|
|
59
|
+
checked?: boolean;
|
|
37
60
|
xpath: string;
|
|
38
61
|
bounds?: AppiumBounds;
|
|
39
62
|
selector: AppiumSelector;
|
|
@@ -52,6 +75,10 @@ export type AppiumRecordedStep = {
|
|
|
52
75
|
| 'backIfExists'
|
|
53
76
|
| 'waitFor'
|
|
54
77
|
| 'assertExists'
|
|
78
|
+
| 'checkboxState'
|
|
79
|
+
| 'radioButtonState'
|
|
80
|
+
| 'aiRecognition'
|
|
81
|
+
| 'textClick'
|
|
55
82
|
| 'key'
|
|
56
83
|
| 'waitActivity'
|
|
57
84
|
| 'delay'
|
|
@@ -65,7 +92,10 @@ export type AppiumRecordedStep = {
|
|
|
65
92
|
| 'assertText'
|
|
66
93
|
| 'longPress'
|
|
67
94
|
| 'pinch'
|
|
68
|
-
| 'runScript'
|
|
95
|
+
| 'runScript'
|
|
96
|
+
| 'noop'
|
|
97
|
+
| 'log'
|
|
98
|
+
| 'visualChange';
|
|
69
99
|
label: string;
|
|
70
100
|
note?: string;
|
|
71
101
|
selector?: AppiumSelector;
|
|
@@ -73,9 +103,10 @@ export type AppiumRecordedStep = {
|
|
|
73
103
|
selectorChain?: AppiumSelector[];
|
|
74
104
|
fallback?: AppiumSelector;
|
|
75
105
|
value?: string;
|
|
76
|
-
|
|
106
|
+
logPrefix?: string;
|
|
77
107
|
keyCode?: number;
|
|
78
108
|
timeoutMs?: number;
|
|
109
|
+
longPressMode?: 'element' | 'coordinates';
|
|
79
110
|
flow?: {
|
|
80
111
|
nodeKind?: 'action' | 'condition' | 'assertion';
|
|
81
112
|
yesTargetId?: string;
|
|
@@ -102,6 +133,7 @@ export type AppiumRecordedStep = {
|
|
|
102
133
|
centerY: number;
|
|
103
134
|
percent: number;
|
|
104
135
|
};
|
|
136
|
+
visualChange?: AppiumVisualChangeConfig;
|
|
105
137
|
snapshot?: {
|
|
106
138
|
text: string;
|
|
107
139
|
resourceId: string;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { AppiumRecordedStep } from './types';
|
|
2
|
+
|
|
3
|
+
export type VisualChangeBranchTarget = {
|
|
4
|
+
stepId: string;
|
|
5
|
+
branch: 'yes' | 'no';
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const FLOW_TARGET_KEYS = ['successTargetId', 'failureTargetId'] as const;
|
|
9
|
+
|
|
10
|
+
export function visualChangePairNumber(label?: string) {
|
|
11
|
+
const match = /^检测画面变化(\d+)$/.exec(label || '');
|
|
12
|
+
return match ? Number(match[1]) : 0;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function nextVisualChangePairLabel(steps: AppiumRecordedStep[]) {
|
|
16
|
+
const maxNumber = steps.reduce((max, step) => Math.max(
|
|
17
|
+
max,
|
|
18
|
+
visualChangePairNumber(step.visualChange?.pairLabel),
|
|
19
|
+
), 0);
|
|
20
|
+
return `检测画面变化${maxNumber + 1}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function rawBranchPathForStep(steps: AppiumRecordedStep[], step?: AppiumRecordedStep) {
|
|
24
|
+
const path: string[] = [];
|
|
25
|
+
let current = step;
|
|
26
|
+
let guard = 0;
|
|
27
|
+
while (current?.flow?.parentConditionId && current.flow.parentBranch && guard < steps.length) {
|
|
28
|
+
path.unshift(`${current.flow.parentConditionId}:${current.flow.parentBranch}`);
|
|
29
|
+
current = steps.find((item) => item.id === current?.flow?.parentConditionId);
|
|
30
|
+
guard += 1;
|
|
31
|
+
}
|
|
32
|
+
return path;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function uniqueBranchPaths(paths: string[][]) {
|
|
36
|
+
const seen = new Set<string>();
|
|
37
|
+
return paths.filter((path) => {
|
|
38
|
+
const key = path.join('|');
|
|
39
|
+
if (!path.length || seen.has(key)) return false;
|
|
40
|
+
seen.add(key);
|
|
41
|
+
return true;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function branchTargetPath(
|
|
46
|
+
steps: AppiumRecordedStep[],
|
|
47
|
+
condition: AppiumRecordedStep,
|
|
48
|
+
branch: 'yes' | 'no',
|
|
49
|
+
seen: Set<string>,
|
|
50
|
+
) {
|
|
51
|
+
return [...branchPathForStep(steps, condition, new Set(seen)), `${condition.id}:${branch}`];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function branchPathForStep(
|
|
55
|
+
steps: AppiumRecordedStep[],
|
|
56
|
+
step?: AppiumRecordedStep,
|
|
57
|
+
seen = new Set<string>(),
|
|
58
|
+
) {
|
|
59
|
+
const rawPath = rawBranchPathForStep(steps, step);
|
|
60
|
+
if (rawPath.length || !step || seen.has(step.id)) return rawPath;
|
|
61
|
+
seen.add(step.id);
|
|
62
|
+
|
|
63
|
+
const incomingPaths = uniqueBranchPaths(steps.flatMap((source) => {
|
|
64
|
+
if (!source.flow || source.id === step.id) return [];
|
|
65
|
+
const paths: string[][] = [];
|
|
66
|
+
if (source.flow.nodeKind === 'condition') {
|
|
67
|
+
if (source.flow.yesTargetId === step.id) paths.push(branchTargetPath(steps, source, 'yes', seen));
|
|
68
|
+
if (source.flow.noTargetId === step.id) paths.push(branchTargetPath(steps, source, 'no', seen));
|
|
69
|
+
}
|
|
70
|
+
FLOW_TARGET_KEYS.forEach((key) => {
|
|
71
|
+
if (source.flow?.[key] === step.id) paths.push(branchPathForStep(steps, source, new Set(seen)));
|
|
72
|
+
});
|
|
73
|
+
return paths;
|
|
74
|
+
}));
|
|
75
|
+
|
|
76
|
+
return incomingPaths.length === 1 ? incomingPaths[0] : [];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Branch paths are stored as an ancestry chain like
|
|
80
|
+
// ["outer:yes", "inner:no"]. A root node before a condition has an empty
|
|
81
|
+
// path, so it can be closed by an end marker in either downstream branch.
|
|
82
|
+
export function branchPathForInsert(
|
|
83
|
+
steps: AppiumRecordedStep[],
|
|
84
|
+
index?: number,
|
|
85
|
+
branchTarget?: VisualChangeBranchTarget,
|
|
86
|
+
) {
|
|
87
|
+
if (branchTarget) {
|
|
88
|
+
const condition = steps.find((step) => step.id === branchTarget.stepId);
|
|
89
|
+
return [...branchPathForStep(steps, condition), `${branchTarget.stepId}:${branchTarget.branch}`];
|
|
90
|
+
}
|
|
91
|
+
return typeof index === 'number' && index >= 0
|
|
92
|
+
? branchPathForStep(steps, steps[index])
|
|
93
|
+
: [];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function sameBranchPath(left: string[], right: string[]) {
|
|
97
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function branchPathContains(parent: string[], child: string[]) {
|
|
101
|
+
return parent.length <= child.length && parent.every((value, index) => value === child[index]);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function visualChangeRole(step: AppiumRecordedStep) {
|
|
105
|
+
return step.visualChange?.role;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isEndForStart(endStep: AppiumRecordedStep, startStep: AppiumRecordedStep) {
|
|
109
|
+
const start = startStep.visualChange;
|
|
110
|
+
const end = endStep.visualChange;
|
|
111
|
+
if (!start || end?.role !== 'end') return false;
|
|
112
|
+
if (start.pairId && end.pairId === start.pairId) return true;
|
|
113
|
+
if (end.startStepId && end.startStepId === startStep.id) return true;
|
|
114
|
+
return Boolean(
|
|
115
|
+
start.pairLabel
|
|
116
|
+
&& end.pairLabel === start.pairLabel
|
|
117
|
+
&& (!start.pairId || !end.pairId || !end.startStepId),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function findOpenVisualChangeStart(
|
|
122
|
+
steps: AppiumRecordedStep[],
|
|
123
|
+
index?: number,
|
|
124
|
+
branchTarget?: VisualChangeBranchTarget,
|
|
125
|
+
) {
|
|
126
|
+
const insertPath = branchPathForInsert(steps, index, branchTarget);
|
|
127
|
+
const insertIndex = typeof index === 'number' ? index : steps.length - 1;
|
|
128
|
+
// A start marker may be closed once per concrete branch path. This allows a
|
|
129
|
+
// root start before a condition to have independent yes/no end markers, while
|
|
130
|
+
// preventing a yes-branch start from pairing with a no-branch end.
|
|
131
|
+
const hasEndInInsertBranch = (startStep: AppiumRecordedStep) => steps.some((step) => (
|
|
132
|
+
isEndForStart(step, startStep)
|
|
133
|
+
&& sameBranchPath(branchPathForStep(steps, step), insertPath)
|
|
134
|
+
));
|
|
135
|
+
|
|
136
|
+
return steps
|
|
137
|
+
.map((step, stepIndex) => ({ step, stepIndex, path: branchPathForStep(steps, step) }))
|
|
138
|
+
.filter(({ step, stepIndex, path }) => (
|
|
139
|
+
visualChangeRole(step) === 'start'
|
|
140
|
+
&& stepIndex <= insertIndex
|
|
141
|
+
&& branchPathContains(path, insertPath)
|
|
142
|
+
&& !hasEndInInsertBranch(step)
|
|
143
|
+
))
|
|
144
|
+
.sort((left, right) => (
|
|
145
|
+
right.path.length - left.path.length || right.stepIndex - left.stepIndex
|
|
146
|
+
))[0]?.step;
|
|
147
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { AppiumBounds, AppiumRecordedStep } from './types';
|
|
2
|
+
|
|
3
|
+
export const VISUAL_CHANGE_DEFAULTS = {
|
|
4
|
+
durationMs: 5000,
|
|
5
|
+
intervalMs: 1000,
|
|
6
|
+
changeRatioThreshold: 2,
|
|
7
|
+
pixelmatchThreshold: 0.1,
|
|
8
|
+
} as const;
|
|
9
|
+
|
|
10
|
+
export function createStepId() {
|
|
11
|
+
return `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function boundsToVisualRegion(bounds: AppiumBounds) {
|
|
15
|
+
return {
|
|
16
|
+
x: bounds.left,
|
|
17
|
+
y: bounds.top,
|
|
18
|
+
width: Math.max(0, bounds.right - bounds.left),
|
|
19
|
+
height: Math.max(0, bounds.bottom - bounds.top),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function normalizeVisualRegion(
|
|
24
|
+
region: NonNullable<AppiumRecordedStep['visualChange']>['region'],
|
|
25
|
+
) {
|
|
26
|
+
return {
|
|
27
|
+
x: Math.max(0, Math.round(Number(region?.x) || 0)),
|
|
28
|
+
y: Math.max(0, Math.round(Number(region?.y) || 0)),
|
|
29
|
+
width: Math.max(1, Math.round(Number(region?.width) || 1)),
|
|
30
|
+
height: Math.max(1, Math.round(Number(region?.height) || 1)),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function normalizeVisualChangeConfig(
|
|
35
|
+
config?: AppiumRecordedStep['visualChange'],
|
|
36
|
+
): NonNullable<AppiumRecordedStep['visualChange']> {
|
|
37
|
+
return {
|
|
38
|
+
mode: config?.mode === 'selectedElement' ? 'selectedElement' : 'region',
|
|
39
|
+
region: normalizeVisualRegion(config?.region || { x: 0, y: 0, width: 1, height: 1 }),
|
|
40
|
+
role: config?.role === 'start' ? 'start' : config?.role === 'end' ? 'end' : undefined,
|
|
41
|
+
pairId: config?.pairId || '',
|
|
42
|
+
pairLabel: config?.pairLabel || '',
|
|
43
|
+
startStepId: config?.startStepId || '',
|
|
44
|
+
endStepId: config?.endStepId || '',
|
|
45
|
+
durationMs: Math.max(1000, Math.round(Number(config?.durationMs) || VISUAL_CHANGE_DEFAULTS.durationMs)),
|
|
46
|
+
intervalMs: Math.max(200, Math.round(Number(config?.intervalMs) || VISUAL_CHANGE_DEFAULTS.intervalMs)),
|
|
47
|
+
changeRatioThreshold: Math.max(0.01, Number(config?.changeRatioThreshold) || VISUAL_CHANGE_DEFAULTS.changeRatioThreshold),
|
|
48
|
+
pixelmatchThreshold: Math.min(
|
|
49
|
+
1,
|
|
50
|
+
Math.max(0, Number(config?.pixelmatchThreshold) || VISUAL_CHANGE_DEFAULTS.pixelmatchThreshold),
|
|
51
|
+
),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function visualRegionToBounds(
|
|
56
|
+
id: string,
|
|
57
|
+
region: NonNullable<AppiumRecordedStep['visualChange']>['region'],
|
|
58
|
+
) {
|
|
59
|
+
const normalized = normalizeVisualRegion(region);
|
|
60
|
+
return {
|
|
61
|
+
id,
|
|
62
|
+
left: normalized.x,
|
|
63
|
+
top: normalized.y,
|
|
64
|
+
right: normalized.x + normalized.width,
|
|
65
|
+
bottom: normalized.y + normalized.height,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function visualChangeMeta(step: AppiumRecordedStep) {
|
|
70
|
+
const config = normalizeVisualChangeConfig(step.visualChange);
|
|
71
|
+
if (config.role === 'start') return `${config.pairLabel || '检测画面变化'} · 开始 · 阈值 ${config.changeRatioThreshold}%`;
|
|
72
|
+
if (config.role === 'end') return `${config.pairLabel || '检测画面变化'} · 结束`;
|
|
73
|
+
const range = config.startStepId && config.endStepId ? '起止节点截图对比' : '请选择起止节点';
|
|
74
|
+
return `${range} · 阈值 ${config.changeRatioThreshold}%`;
|
|
75
|
+
}
|