android-midscene-automation 0.1.34 → 0.1.36

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 (32) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +10 -0
  3. package/package.json +2 -1
  4. package/server/appium-recorder/appium-runner.ts +39 -1
  5. package/server/appium-recorder/image-check.ts +160 -0
  6. package/server/appium-recorder/open-gallery.ts +10 -1
  7. package/server/appium-recorder/report.ts +41 -0
  8. package/server/appium-recorder/repository.ts +6 -0
  9. package/server/appium-recorder/routes.ts +19 -0
  10. package/server/appium-recorder/script-export.ts +1 -1
  11. package/src/appium-recorder/AppiumPage.vue +112 -8
  12. package/src/appium-recorder/action-descriptions.ts +46 -0
  13. package/src/appium-recorder/api.ts +5 -0
  14. package/src/appium-recorder/components/ComponentInspector.vue +9 -2
  15. package/src/appium-recorder/components/FlowActionMenu.vue +26 -5
  16. package/src/appium-recorder/components/FlowCanvas.vue +5 -4
  17. package/src/appium-recorder/components/FlowNodeCard.vue +9 -7
  18. package/src/appium-recorder/components/FlowStepEditor.vue +23 -4
  19. package/src/appium-recorder/components/ImageCheckDialog.vue +143 -0
  20. package/src/appium-recorder/components/MergeBranchesDialog.vue +31 -3
  21. package/src/appium-recorder/components/NestedConditionBranches.vue +6 -2
  22. package/src/appium-recorder/components/RecordedSteps.vue +7 -6
  23. package/src/appium-recorder/flow-copy.ts +6 -0
  24. package/src/appium-recorder/flow-graph.ts +22 -10
  25. package/src/appium-recorder/flow-insert.ts +27 -0
  26. package/src/appium-recorder/flow-labels.ts +4 -1
  27. package/src/appium-recorder/flow-merge.ts +12 -0
  28. package/src/appium-recorder/flow-unmerge.ts +145 -0
  29. package/src/appium-recorder/image-check.ts +117 -0
  30. package/src/appium-recorder/types.ts +15 -0
  31. package/src/appium-recorder/visual-change-pairs.ts +2 -0
  32. package/src/appium-recorder/visual-change.ts +13 -1
@@ -0,0 +1,145 @@
1
+ import type { AppiumRecordedStep } from './types';
2
+ import { branchSteps } from './flow-merge';
3
+ import { defaultFlowKind } from './flow-labels';
4
+ import { createStepId } from './visual-change';
5
+
6
+ const edges = ['yesTargetId', 'noTargetId', 'successTargetId', 'failureTargetId'] as const;
7
+ const structure = [...edges, 'parentConditionId', 'parentBranch'] as const;
8
+ export type AddedNodeSide = 'yes' | 'no' | 'both';
9
+
10
+ export function unmergeBranches(steps: AppiumRecordedStep[], conditionId: string, addedSide?: AddedNodeSide) {
11
+ const condition = steps.find(step => step.id === conditionId);
12
+ const record = condition?.mergeUndo;
13
+ if (!record || record.version !== 1 || record.conditionId !== conditionId) {
14
+ throw new Error('该合并没有可用的还原记录,无法安全取消;仅新执行的合并支持还原');
15
+ }
16
+ const original = new Set(record.originalIds);
17
+ const common = new Set(record.commonIds);
18
+ const affected = new Set(record.flows.map(item => item.id));
19
+ const byId = new Map(steps.map(step => [step.id, step]));
20
+ if (record.flows.some(item => !byId.has(item.id)) || record.removed.some(step => byId.has(step.id))) {
21
+ throw new Error('合并涉及的节点已删除或 ID 冲突,无法安全取消');
22
+ }
23
+ const boundary = record.boundaryId ? steps.findIndex(step => step.id === record.boundaryId) : steps.length;
24
+ if (boundary < 0) throw new Error('公共流程边界已删除,无法安全取消');
25
+ const added = steps.slice(steps.indexOf(condition!) + 1, boundary).filter(step => !original.has(step.id)
26
+ && step.flow?.parentConditionId === condition!.flow?.parentConditionId
27
+ && step.flow?.parentBranch === condition!.flow?.parentBranch);
28
+ const shared = new Set([...common, ...added.map(step => step.id)]);
29
+ // 普通新增操作可分配到任一侧;控制节点和跨节点配对不做猜测性复制。
30
+ if (added.some(step => defaultFlowKind(step) === 'condition'
31
+ || ['visualChange', 'breakLoop', 'endFlow'].includes(step.type) || step.mergeUndo)) {
32
+ throw new Error('公共流程新增了判断、循环、终止或配对节点,请先移出这些节点再取消合并');
33
+ }
34
+ if (added.some(step => edges.some(key => step.flow?.[key]
35
+ && !shared.has(step.flow[key]!) && step.flow[key] !== record.boundaryId))) {
36
+ throw new Error('公共流程新增操作存在外部连接,请先处理该连接再取消合并');
37
+ }
38
+ for (const item of record.flows) {
39
+ const current = byId.get(item.id)!;
40
+ for (const key of structure) {
41
+ if (current.flow?.[key] === item.after?.[key]) continue;
42
+ const target = edges.includes(key as typeof edges[number]) ? byId.get(current.flow?.[key] || '') : undefined;
43
+ if (!target || original.has(target.id) || defaultFlowKind(target) === 'condition') {
44
+ throw new Error('合并后的连接或分支归属已改变,请先处理结构冲突再取消合并');
45
+ }
46
+ }
47
+ }
48
+ for (const step of steps) {
49
+ if (!affected.has(step.id) && original.has(step.id) && edges.some(key => shared.has(step.flow?.[key] || ''))) {
50
+ throw new Error('其他流程引用了公共节点,无法安全取消合并');
51
+ }
52
+ }
53
+ const side = addedSide || record.source;
54
+ const next: AppiumRecordedStep[] = JSON.parse(JSON.stringify(steps));
55
+ const nextById = new Map(next.map(step => [step.id, step]));
56
+ // 只撤回合并写入的结构字段,保留用户修改的名称、参数、备注等。
57
+ for (const item of record.flows) {
58
+ const step = nextById.get(item.id)!;
59
+ const flow = step.flow ||= {};
60
+ for (const key of structure) {
61
+ if (item.before?.[key] === item.after?.[key]) continue;
62
+ const current = flow[key];
63
+ if (current !== item.after?.[key] && !shared.has(current || '')) continue;
64
+ if (item.before?.[key] === undefined) delete flow[key];
65
+ else Object.assign(flow, { [key]: item.before[key] });
66
+ }
67
+ }
68
+ // 恢复被合并删除的另一侧节点,按原始邻接顺序插回,而非覆盖现有节点。
69
+ for (const backup of record.removed) {
70
+ const restored: AppiumRecordedStep = JSON.parse(JSON.stringify(backup));
71
+ const later = record.originalIds.slice(record.originalIds.indexOf(backup.id) + 1);
72
+ const anchor = later.map(id => next.findIndex(step => step.id === id)).find(index => index >= 0);
73
+ next.splice(anchor ?? next.length, 0, restored);
74
+ nextById.set(restored.id, restored);
75
+ }
76
+ const assigned = side === 'both' ? record.source : side;
77
+ for (const item of added) {
78
+ const step = nextById.get(item.id)!;
79
+ step.flow = { ...step.flow, parentConditionId: conditionId, parentBranch: assigned };
80
+ for (const key of edges) delete step.flow[key];
81
+ }
82
+ if (assigned !== record.source) {
83
+ for (const item of added) {
84
+ const index = next.findIndex(step => step.id === item.id);
85
+ next.push(...next.splice(index, 1));
86
+ }
87
+ }
88
+ if (side === 'both') {
89
+ for (const item of added) {
90
+ const copy: AppiumRecordedStep = JSON.parse(JSON.stringify(nextById.get(item.id)));
91
+ copy.id = createStepId();
92
+ copy.flow!.parentBranch = record.source === 'yes' ? 'no' : 'yes';
93
+ next.push(copy);
94
+ }
95
+ }
96
+ const other = record.source === 'yes' ? 'no' : 'yes';
97
+ if (added.length && (side === 'both' || side === other)) {
98
+ const siblings = branchSteps(next, conditionId, other);
99
+ const appended = siblings.slice(-added.length);
100
+ const previous = siblings[siblings.length - appended.length - 1];
101
+ if (previous && (defaultFlowKind(previous) === 'condition' || ['endFlow', 'breakLoop'].includes(previous.type))) {
102
+ throw new Error('另一侧末尾是判断、循环或终止操作,新增节点不能安全追加,请仅保留在原来源分支');
103
+ }
104
+ if (previous?.flow?.successTargetId) {
105
+ appended[appended.length - 1]!.flow!.successTargetId = previous.flow.successTargetId;
106
+ previous.flow.successTargetId = appended[0]!.id;
107
+ }
108
+ }
109
+ const restoredCondition = nextById.get(conditionId)!;
110
+ delete restoredCondition.mergeUndo;
111
+ const ownerBranch = (step: AppiumRecordedStep): 'yes' | 'no' | undefined => {
112
+ const visited = new Set<string>();
113
+ let current: AppiumRecordedStep | undefined = step;
114
+ while (current?.flow?.parentConditionId && !visited.has(current.id)) {
115
+ visited.add(current.id);
116
+ if (current.flow.parentConditionId === conditionId) return current.flow.parentBranch;
117
+ current = nextById.get(current.flow.parentConditionId);
118
+ }
119
+ };
120
+ // 新增的分支尾节点可能仍指向公共入口,改接自己一侧的后续节点。
121
+ for (const step of next) {
122
+ const branch = ownerBranch(step);
123
+ if (!branch) continue;
124
+ const siblings = next.filter(item => item.flow?.parentConditionId === step.flow?.parentConditionId && item.flow?.parentBranch === step.flow?.parentBranch);
125
+ const sibling = siblings[siblings.indexOf(step) + 1];
126
+ for (const key of edges) {
127
+ const target = nextById.get(step.flow?.[key] || '');
128
+ if (!target || !shared.has(target.id)) continue;
129
+ if (ownerBranch(target) === branch && !added.some(item => item.id === target.id)) continue;
130
+ if (sibling) step.flow![key] = sibling.id;
131
+ else delete step.flow![key];
132
+ }
133
+ }
134
+ for (const branch of ['yes', 'no'] as const) {
135
+ const first = branchSteps(next, conditionId, branch)[0];
136
+ const key = branch === 'yes' ? 'yesTargetId' : 'noTargetId';
137
+ if (first) restoredCondition.flow![key] = first.id;
138
+ else delete restoredCondition.flow![key];
139
+ }
140
+ // 将两个分支的完整子树重新放到判断之后,公共后继保持原位。
141
+ const owned = next.filter(step => ownerBranch(step));
142
+ const result = next.filter(step => !owned.includes(step));
143
+ result.splice(result.indexOf(restoredCondition) + 1, 0, ...owned);
144
+ return { steps: result, addedCount: added.length };
145
+ }
@@ -0,0 +1,117 @@
1
+ import type { AppiumVisualChangeRegion } from './types';
2
+
3
+ export const IMAGE_CHECK_MODES = { template: '模板匹配', state: '图片状态判断', black: '黑屏检测', color: '区域颜色判断', change: '多帧变化检测' } as const;
4
+ export type ImageCheckConfig = {
5
+ mode: keyof typeof IMAGE_CHECK_MODES;
6
+ target: 'region' | 'element';
7
+ region: AppiumVisualChangeRegion;
8
+ screenWidth: number;
9
+ screenHeight: number;
10
+ template?: string;
11
+ negativeTemplate?: string;
12
+ expectation: 'present' | 'absent';
13
+ threshold: number;
14
+ minScoreGap: number;
15
+ color: string;
16
+ tolerance: number;
17
+ ratio: number;
18
+ durationMs: number;
19
+ intervalMs: number;
20
+ consecutive: number;
21
+ };
22
+ export type ImageCheckResult = {
23
+ result: boolean | null;
24
+ mode: ImageCheckConfig['mode'];
25
+ region: AppiumVisualChangeRegion;
26
+ message: string;
27
+ durationMs: number;
28
+ sampleCount: number;
29
+ metrics: Record<string, number>;
30
+ images: { label: string; base64: string }[];
31
+ };
32
+
33
+ export function createImageCheckConfig(): ImageCheckConfig {
34
+ return { mode: 'template', target: 'region', region: { x: 0, y: 0, width: 100, height: 100 },
35
+ screenWidth: 0, screenHeight: 0, expectation: 'present', threshold: 0.9, minScoreGap: 0.1,
36
+ color: '#000000', tolerance: 30, ratio: 95, durationMs: 5000, intervalMs: 1000, consecutive: 1 };
37
+ }
38
+
39
+ // 只读取 PNG 头部尺寸,避免为了表单校验解码整张模板。
40
+ export function imageTemplateSize(base64?: string) {
41
+ if (!base64) return undefined;
42
+ try {
43
+ const header = atob(base64.slice(0, 32));
44
+ if (header.length < 24 || header.slice(0, 8) !== '\x89PNG\r\n\x1a\n') return undefined;
45
+ const uint = (offset: number) => (header.charCodeAt(offset) * 0x1000000
46
+ + header.charCodeAt(offset + 1) * 0x10000 + header.charCodeAt(offset + 2) * 0x100 + header.charCodeAt(offset + 3));
47
+ const width = uint(16), height = uint(20);
48
+ return width && height ? { width, height } : undefined;
49
+ } catch { return undefined; }
50
+ }
51
+
52
+ export function imageTemplateRegionIssue(config: ImageCheckConfig) {
53
+ if (!['template', 'state'].includes(config.mode)) return '';
54
+ const templates = [{ value: config.template, label: config.mode === 'state' ? '选中模板' : '模板' },
55
+ ...(config.mode === 'state' ? [{ value: config.negativeTemplate, label: '未选中模板' }] : [])];
56
+ for (const template of templates) {
57
+ const size = imageTemplateSize(template.value);
58
+ if (size && (size.width > config.region.width || size.height > config.region.height)) {
59
+ return `${template.label} ${size.width}×${size.height} 超过检测区域 ${config.region.width}×${config.region.height},${config.target === 'region' ? '请扩大区域或重新采集模板' : '请重新选择组件或采集对应模板'}`;
60
+ }
61
+ }
62
+ return '';
63
+ }
64
+
65
+ export function expandedImageTemplateRegion(config: ImageCheckConfig) {
66
+ if (config.target !== 'region' || !['template', 'state'].includes(config.mode)) return undefined;
67
+ const sizes = [config.template, ...(config.mode === 'state' ? [config.negativeTemplate] : [])].map(imageTemplateSize);
68
+ const width = Math.max(config.region.width, ...sizes.map(size => size?.width || 0));
69
+ const height = Math.max(config.region.height, ...sizes.map(size => size?.height || 0));
70
+ if (width > config.screenWidth || height > config.screenHeight || !Number.isFinite(width + height)) return undefined;
71
+ // 用户明确选择扩区时以原区域中心扩展,并保证仍在屏幕内,不改变模板像素。
72
+ const x = Math.max(0, Math.min(config.screenWidth - width, Math.floor(config.region.x + (config.region.width - width) / 2)));
73
+ const y = Math.max(0, Math.min(config.screenHeight - height, Math.floor(config.region.y + (config.region.height - height) / 2)));
74
+ return { x, y, width, height };
75
+ }
76
+
77
+ export function validateImageCheck(config: ImageCheckConfig | undefined) {
78
+ if (!config || !Object.prototype.hasOwnProperty.call(IMAGE_CHECK_MODES, config.mode)) throw new Error('请选择图像判断模式');
79
+ const range = (value: number, min: number, max: number, name: string, integer = false) => {
80
+ if (!Number.isFinite(value) || value < min || value > max || (integer && !Number.isInteger(value))) throw new Error(`${name}必须在 ${min} 至 ${max} 之间${integer ? ',且为整数' : ''}`);
81
+ };
82
+ if (!['region', 'element'].includes(config.target)) throw new Error('检测目标无效');
83
+ if (!['present', 'absent'].includes(config.expectation)) throw new Error('判断条件无效');
84
+ range(config.region?.x, 0, 16000, '区域 X', true);
85
+ range(config.region?.y, 0, 16000, '区域 Y', true);
86
+ range(config.region?.width, 1, 16000, '区域宽度', true);
87
+ range(config.region?.height, 1, 16000, '区域高度', true);
88
+ if (config.target === 'region') {
89
+ range(config.screenWidth, 1, 16000, '采集屏幕宽度', true);
90
+ range(config.screenHeight, 1, 16000, '采集屏幕高度', true);
91
+ }
92
+ range(config.threshold, 0.01, 1, '模板阈值');
93
+ range(config.minScoreGap, 0.001, 1, '最小得分差');
94
+ range(config.tolerance, 0, 255, '像素容差', true);
95
+ range(config.ratio, 0.01, 100, '像素占比');
96
+ range(config.durationMs, 0, 60000, '观察时长', true);
97
+ range(config.intervalMs, 200, 10000, '采样间隔', true);
98
+ range(config.consecutive, 1, 100, '连续满足帧数', true);
99
+ if (config.consecutive > Math.floor(config.durationMs / config.intervalMs) + 1) throw new Error('观察时间不足以采集连续满足帧数');
100
+ if (config.mode === 'change' && config.durationMs < config.intervalMs) throw new Error('多帧变化的观察时长不能小于采样间隔');
101
+ if (!/^#[0-9a-f]{6}$/i.test(config.color)) throw new Error('目标颜色必须为 #RRGGBB 格式');
102
+ for (const value of [config.template, config.negativeTemplate]) {
103
+ if (value && (value.length > 4 * 1024 * 1024 || !/^[A-Za-z0-9+/]+={0,2}$/.test(value))) throw new Error('模板必须是小于 3MB 的 PNG 图片');
104
+ }
105
+ if (['template', 'state'].includes(config.mode) && !config.template) throw new Error('请采集或上传模板');
106
+ if (config.mode === 'state' && !config.negativeTemplate) throw new Error('请采集或上传未选中模板');
107
+ if (config.target === 'region') {
108
+ const issue = imageTemplateRegionIssue(config);
109
+ if (issue) throw new Error(issue);
110
+ }
111
+ return config;
112
+ }
113
+
114
+ export function imageCheckSummary(config?: ImageCheckConfig) {
115
+ if (!config) return '未配置图像判断';
116
+ return `${IMAGE_CHECK_MODES[config.mode]} · ${config.target === 'element' ? '组件区域' : '框选区域'} · ${config.durationMs}ms`;
117
+ }
@@ -1,4 +1,5 @@
1
1
  import type { TestVariable, VariableExtraction, ScriptReturn } from './variables';
2
+ import type { ImageCheckConfig } from './image-check';
2
3
 
3
4
  export type AppiumBounds = {
4
5
  left: number;
@@ -67,6 +68,7 @@ export type AppiumNode = {
67
68
  };
68
69
 
69
70
  export type AppiumRecordedStep = {
71
+ mergeUndo?: BranchMergeUndo;
70
72
  id: string;
71
73
  type:
72
74
  | 'tap'
@@ -81,6 +83,7 @@ export type AppiumRecordedStep = {
81
83
  | 'checkedState'
82
84
  | 'radioButtonState'
83
85
  | 'aiRecognition'
86
+ | 'imageCheck'
84
87
  | 'textClick'
85
88
  | 'key'
86
89
  | 'waitActivity'
@@ -150,6 +153,7 @@ export type AppiumRecordedStep = {
150
153
  percent: number;
151
154
  };
152
155
  visualChange?: AppiumVisualChangeConfig;
156
+ imageCheck?: ImageCheckConfig;
153
157
  snapshot?: {
154
158
  text: string;
155
159
  resourceId: string;
@@ -158,6 +162,17 @@ export type AppiumRecordedStep = {
158
162
  };
159
163
  };
160
164
 
165
+ export type BranchMergeUndo = {
166
+ version: 1;
167
+ conditionId: string;
168
+ source: 'yes' | 'no';
169
+ commonIds: string[];
170
+ originalIds: string[];
171
+ boundaryId?: string;
172
+ flows: { id: string; before: AppiumRecordedStep['flow']; after: AppiumRecordedStep['flow'] }[];
173
+ removed: AppiumRecordedStep[];
174
+ };
175
+
161
176
  export type AppiumRecordedScript = {
162
177
  id: string;
163
178
  name: string;
@@ -1,6 +1,7 @@
1
1
  import type { AppiumRecordedStep } from './types';
2
2
 
3
3
  export type VisualChangeBranchTarget = {
4
+ beforeStepId?: string;
4
5
  stepId: string;
5
6
  branch: 'yes' | 'no';
6
7
  };
@@ -85,6 +86,7 @@ export function branchPathForInsert(
85
86
  branchTarget?: VisualChangeBranchTarget,
86
87
  ) {
87
88
  if (branchTarget) {
89
+ if (branchTarget.beforeStepId) return branchPathForStep(steps, steps.find(step => step.id === branchTarget.beforeStepId));
88
90
  const condition = steps.find((step) => step.id === branchTarget.stepId);
89
91
  return [...branchPathForStep(steps, condition), `${branchTarget.stepId}:${branchTarget.branch}`];
90
92
  }
@@ -11,6 +11,18 @@ export function createStepId() {
11
11
  return `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
12
12
  }
13
13
 
14
+ export function syncVisualChangeThreshold(steps: AppiumRecordedStep[], source: AppiumRecordedStep) {
15
+ const config = source.visualChange;
16
+ if (source.type !== 'visualChange' || config?.role !== 'end' || !config.pairId) return steps;
17
+ const { changeRatioThreshold } = normalizeVisualChangeConfig(config);
18
+ // 回放以开始节点配置为准,结束节点修改阈值时同步整组;不同配对互不影响。
19
+ return steps.map(step => (
20
+ step.type === 'visualChange' && step.visualChange && step.visualChange.pairId === config.pairId
21
+ ? { ...step, visualChange: { ...step.visualChange, changeRatioThreshold } }
22
+ : step
23
+ ));
24
+ }
25
+
14
26
  export function boundsToVisualRegion(bounds: AppiumBounds) {
15
27
  return {
16
28
  x: bounds.left,
@@ -47,7 +59,7 @@ export function normalizeVisualChangeConfig(
47
59
  changeRatioThreshold: Math.max(0.01, Number(config?.changeRatioThreshold) || VISUAL_CHANGE_DEFAULTS.changeRatioThreshold),
48
60
  pixelmatchThreshold: Math.min(
49
61
  1,
50
- Math.max(0, Number(config?.pixelmatchThreshold) || VISUAL_CHANGE_DEFAULTS.pixelmatchThreshold),
62
+ Math.max(0, Number.isFinite(config?.pixelmatchThreshold) ? Number(config?.pixelmatchThreshold) : VISUAL_CHANGE_DEFAULTS.pixelmatchThreshold),
51
63
  ),
52
64
  };
53
65
  }