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.
Files changed (56) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/FAQ.md +14 -0
  3. package/README.md +39 -3
  4. package/bin/android-midscene-automation.js +71 -1
  5. package/package.json +7 -2
  6. package/remote-agent/index.ts +2 -11
  7. package/server/appium-recorder/ai-recognition.ts +67 -0
  8. package/server/appium-recorder/appium-runner.ts +335 -52
  9. package/server/appium-recorder/report.ts +297 -16
  10. package/server/appium-recorder/repository.ts +31 -4
  11. package/server/appium-recorder/routes.ts +31 -32
  12. package/server/appium-recorder/screenshot.ts +21 -0
  13. package/server/appium-recorder/script-export.ts +67 -0
  14. package/server/appium-recorder/tree-dump.ts +22 -0
  15. package/server/appium-recorder/visual-change.ts +184 -0
  16. package/server/config.ts +10 -0
  17. package/server/http-api.ts +4 -5
  18. package/src/App.vue +14 -7
  19. package/src/api.ts +1 -1
  20. package/src/appium-recorder/AppiumPage.vue +658 -162
  21. package/src/appium-recorder/ai-recognition.ts +16 -0
  22. package/src/appium-recorder/api.ts +20 -3
  23. package/src/appium-recorder/components/AiRecognitionTestDialog.vue +71 -0
  24. package/src/appium-recorder/components/FlowActionMenu.vue +206 -0
  25. package/src/appium-recorder/components/FlowCanvas.vue +16 -1
  26. package/src/appium-recorder/components/FlowNodeCard.vue +23 -37
  27. package/src/appium-recorder/components/FlowStepEditor.vue +152 -25
  28. package/src/appium-recorder/components/LongPressSettings.vue +58 -0
  29. package/src/appium-recorder/components/NestedConditionBranches.vue +55 -35
  30. package/src/appium-recorder/components/NewScriptDialog.vue +27 -0
  31. package/src/appium-recorder/components/NodeDetail.vue +2 -0
  32. package/src/appium-recorder/components/RecordedSteps.vue +24 -4
  33. package/src/appium-recorder/components/StageLogSettings.vue +17 -0
  34. package/src/appium-recorder/components/TextClickSettings.vue +19 -0
  35. package/src/appium-recorder/components/VisualChangeDialog.vue +151 -0
  36. package/src/appium-recorder/flow-copy.ts +47 -1
  37. package/src/appium-recorder/flow-graph.ts +147 -64
  38. package/src/appium-recorder/flow-labels.ts +35 -6
  39. package/src/appium-recorder/flow-normalize.ts +76 -0
  40. package/src/appium-recorder/flow-remove.ts +69 -0
  41. package/src/appium-recorder/long-press.ts +27 -0
  42. package/src/appium-recorder/native-control-state.ts +26 -0
  43. package/src/appium-recorder/stage-log.ts +21 -0
  44. package/src/appium-recorder/text-click.ts +9 -0
  45. package/src/appium-recorder/tree.ts +3 -0
  46. package/src/appium-recorder/types.ts +34 -2
  47. package/src/appium-recorder/visual-change-pairs.ts +147 -0
  48. package/src/appium-recorder/visual-change.ts +75 -0
  49. package/src/components/config/AppiumConfigPanel.vue +48 -0
  50. package/src/components/config/BaseConfigPanel.vue +90 -0
  51. package/src/components/config/MidsceneConfigPanel.vue +204 -0
  52. package/src/components/device/DevicePreviewPanel.vue +148 -4
  53. package/src/pages/ConfigPage.vue +107 -265
  54. package/src/style.css +43 -4
  55. package/src/types.ts +2 -0
  56. 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
- optional?: boolean;
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
+ }