android-midscene-automation 0.1.28 → 0.1.30

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.
@@ -10,7 +10,7 @@ export function isBooleanCondition(step: Pick<AppiumRecordedStep, 'type'>) {
10
10
  }
11
11
 
12
12
  export function defaultFlowKind(step: AppiumRecordedStep): FlowKind {
13
- if (step.type === 'log') return 'action';
13
+ if (step.type === 'log' || step.type === 'openGallery' || step.type === 'endFlow') return 'action';
14
14
  if (isBooleanCondition(step)) return 'condition';
15
15
  if (step.flow?.nodeKind) return step.flow.nodeKind;
16
16
  if (step.type === 'assertExists' || step.type === 'assertText' || step.type === 'visualChange') return 'assertion';
@@ -40,6 +40,7 @@ export function flowTypeLabel(step: AppiumRecordedStep) {
40
40
  waitDisappear: '等待消失',
41
41
  assertExists: '断言存在',
42
42
  checkboxState: 'Checkbox 状态',
43
+ checkedState: '判断勾选',
43
44
  radioButtonState: 'RadioButton 状态',
44
45
  aiRecognition: 'AI 识别',
45
46
  textClick: '文字点击',
@@ -51,6 +52,8 @@ export function flowTypeLabel(step: AppiumRecordedStep) {
51
52
  swipe: '滑动',
52
53
  screenshot: '截图',
53
54
  launchApp: '启动 APP',
55
+ openGallery: '启动相册',
56
+ endFlow: '终止流程',
54
57
  clearAppData: '清理 APP 缓存',
55
58
  longPress: '长按',
56
59
  pinch: '双指缩放',
@@ -0,0 +1,110 @@
1
+ import type { AppiumRecordedStep } from './types';
2
+ import { defaultFlowKind } from './flow-labels';
3
+
4
+ type Branch = 'yes' | 'no';
5
+ export function branchSteps(steps: AppiumRecordedStep[], conditionId: string, branch: Branch) {
6
+ return steps.filter((step) => step.flow?.parentConditionId === conditionId && step.flow.parentBranch === branch);
7
+ }
8
+
9
+ function descendants(steps: AppiumRecordedStep[], roots: string[]) {
10
+ const ids = new Set(roots);
11
+ for (let changed = true; changed;) {
12
+ changed = false;
13
+ for (const step of steps) {
14
+ if (step.flow?.parentConditionId && ids.has(step.flow.parentConditionId) && !ids.has(step.id)) {
15
+ ids.add(step.id);
16
+ changed = true;
17
+ }
18
+ }
19
+ }
20
+ return ids;
21
+ }
22
+
23
+ // 合流仅提升明确选中的分支后缀,不按名称猜测重复节点,也不改写其他判断的分支。
24
+ export function mergeBranches(steps: AppiumRecordedStep[], conditionId: string, commonId: string, duplicateId = '') {
25
+ const condition = steps.find((step) => step.id === conditionId);
26
+ const common = steps.find((step) => step.id === commonId);
27
+ if (!condition || defaultFlowKind(condition) !== 'condition') throw new Error('请选择判断节点');
28
+ if (condition.flow?.successTargetId) throw new Error('该判断已有后续公共流程,不能重复合流');
29
+ if (!common || common.flow?.parentConditionId !== conditionId || !common.flow.parentBranch) throw new Error('公共起点必须属于当前判断的直接分支');
30
+ const side = common.flow.parentBranch;
31
+ const otherSide = side === 'yes' ? 'no' : 'yes';
32
+ const source = branchSteps(steps, conditionId, side);
33
+ const other = branchSteps(steps, conditionId, otherSide);
34
+ const commonRoots = source.slice(source.findIndex((step) => step.id === commonId));
35
+ const commonIds = descendants(steps, commonRoots.map((step) => step.id));
36
+ const duplicateIndex = other.findIndex((step) => step.id === duplicateId);
37
+ if (duplicateId && duplicateIndex < 0) throw new Error('重复部分必须属于另一侧直接分支');
38
+ const removedIds = descendants(steps, duplicateId ? other.slice(duplicateIndex).map((step) => step.id) : []);
39
+ const ownedIds = descendants(steps, [conditionId]);
40
+ const keys = ['yesTargetId', 'noTargetId', 'successTargetId', 'failureTargetId'] as const;
41
+ for (const step of steps) {
42
+ for (const key of keys) {
43
+ const target = step.flow?.[key];
44
+ if (!target) continue;
45
+ if (commonIds.has(step.id) && ownedIds.has(target) && !commonIds.has(target)) throw new Error('公共流程存在返回分支的连接,合流会形成循环');
46
+ if (!ownedIds.has(step.id) && removedIds.has(target)) throw new Error('待删除节点被其他流程引用,不能合并');
47
+ if (ownedIds.has(step.id) && !commonIds.has(step.id) && !removedIds.has(step.id) && !ownedIds.has(target)) throw new Error('分支已有外部连接,请先处理该连接');
48
+ }
49
+ }
50
+ const next = steps.filter((step) => !removedIds.has(step.id)).map((step) => ({ ...step, flow: { ...step.flow } }));
51
+ const byId = new Map(next.map((step) => [step.id, step]));
52
+ const mergedCondition = byId.get(conditionId)!;
53
+ mergedCondition.flow.successTargetId = commonId;
54
+ // 只改变公共后缀的直接归属;后缀内部的嵌套判断仍拥有自己的子节点。
55
+ for (const root of commonRoots) {
56
+ const flow = byId.get(root.id)!.flow;
57
+ delete flow.parentConditionId;
58
+ delete flow.parentBranch;
59
+ if (condition.flow?.parentConditionId) {
60
+ flow.parentConditionId = condition.flow.parentConditionId;
61
+ flow.parentBranch = condition.flow.parentBranch;
62
+ }
63
+ }
64
+ function connectEnd(step: typeof next[number]) {
65
+ if (step.type === 'endFlow') return;
66
+ step.flow.successTargetId = commonId;
67
+ if (defaultFlowKind(step) === 'condition') {
68
+ for (const branch of ['yes', 'no'] as const) {
69
+ const children = branchSteps(next, step.id, branch);
70
+ if (children.length) connectEnd(byId.get(children[children.length - 1]!.id)!);
71
+ else step.flow[branch === 'yes' ? 'yesTargetId' : 'noTargetId'] = commonId;
72
+ }
73
+ }
74
+ }
75
+ for (const branch of ['yes', 'no'] as const) {
76
+ const remaining = branchSteps(next, conditionId, branch);
77
+ mergedCondition.flow[branch === 'yes' ? 'yesTargetId' : 'noTargetId'] = remaining[0]?.id || commonId;
78
+ if (remaining.length) connectEnd(byId.get(remaining[remaining.length - 1]!.id)!);
79
+ }
80
+ // 公共节点排在整个分支子树之后,保存和顺序回放不会插入到另一侧分支中。
81
+ const commonBlock = next.filter((step) => commonIds.has(step.id));
82
+ const rest = next.filter((step) => !commonIds.has(step.id));
83
+ let after = rest.findIndex((step) => step.id === conditionId);
84
+ rest.forEach((step, index) => { if (ownedIds.has(step.id)) after = Math.max(after, index); });
85
+ rest.splice(after + 1, 0, ...commonBlock);
86
+ for (const step of rest) {
87
+ for (const key of keys) {
88
+ if (step.flow[key] && removedIds.has(step.flow[key]!)) step.flow[key] = commonId;
89
+ }
90
+ }
91
+ // 校验完整执行图,包含隐式顺序边,拒绝回边及跨分支错误连接。
92
+ const visiting = new Set<string>();
93
+ const visited = new Set<string>();
94
+ function visit(step: typeof next[number]) {
95
+ if (visiting.has(step.id)) throw new Error('合流会形成循环,请检查节点连接');
96
+ if (visited.has(step.id)) return;
97
+ visiting.add(step.id);
98
+ const index = rest.indexOf(step);
99
+ const targets = keys.map((key) => step.flow[key]).filter(Boolean) as string[];
100
+ if (defaultFlowKind(step) !== 'condition' && step.type !== 'endFlow' && !step.flow.successTargetId) {
101
+ const sibling = rest.slice(index + 1).find((item) => item.flow.parentConditionId === step.flow.parentConditionId && item.flow.parentBranch === step.flow.parentBranch);
102
+ if (sibling) targets.push(sibling.id);
103
+ }
104
+ for (const id of targets) { const target = byId.get(id); if (target) visit(target); }
105
+ visiting.delete(step.id);
106
+ visited.add(step.id);
107
+ }
108
+ rest.forEach(visit);
109
+ return { steps: rest, removedCount: removedIds.size };
110
+ }
@@ -4,6 +4,12 @@ type FlowTargetKey = 'yesTargetId' | 'noTargetId' | 'successTargetId' | 'failure
4
4
 
5
5
  const FLOW_TARGET_KEYS: FlowTargetKey[] = ['yesTargetId', 'noTargetId', 'successTargetId', 'failureTargetId'];
6
6
 
7
+ export function removeFlowSteps(steps: AppiumRecordedStep[], indexes: number[]) {
8
+ const ids = [...new Set(indexes)].sort((a, b) => b - a).map((index) => steps[index]?.id).filter(Boolean);
9
+ // 使用稳定 ID 逐个删除,避免前一次删除导致后续索引错位。
10
+ return ids.reduce((current, id) => removeFlowStep(current, current.findIndex((step) => step.id === id)), steps);
11
+ }
12
+
7
13
  function sameBranch(step: AppiumRecordedStep, removed: AppiumRecordedStep) {
8
14
  return Boolean(
9
15
  removed.flow?.parentConditionId
@@ -1,13 +1,13 @@
1
1
  import type { AppiumRecordedStep } from './types';
2
2
 
3
- export type NativeStateType = 'checkboxState' | 'radioButtonState';
3
+ export type NativeStateType = 'checkboxState' | 'radioButtonState' | 'checkedState';
4
4
 
5
5
  export function isNativeStateCondition(step: Pick<AppiumRecordedStep, 'type'>): step is { type: NativeStateType } {
6
- return step.type === 'checkboxState' || step.type === 'radioButtonState';
6
+ return step.type === 'checkboxState' || step.type === 'radioButtonState' || step.type === 'checkedState';
7
7
  }
8
8
 
9
9
  export function nativeControlName(type: NativeStateType) {
10
- return type === 'checkboxState' ? 'Checkbox' : 'RadioButton';
10
+ return type === 'checkedState' ? '勾选组件(Checkbox / RadioButton / Switch)' : type === 'checkboxState' ? 'Checkbox' : 'RadioButton';
11
11
  }
12
12
 
13
13
  export function nativeControlType(className: unknown): NativeStateType | undefined {
@@ -15,9 +15,16 @@ export function nativeControlType(className: unknown): NativeStateType | undefin
15
15
  // 识别原生、AppCompat 和 Material 控件;仅有 checkable 的 View 不能证明控件类型。
16
16
  if (/(?:^|\.)(?:AppCompat|Material)?CheckBox$/.test(className)) return 'checkboxState';
17
17
  if (/(?:^|\.)(?:AppCompat|Material)?RadioButton$/.test(className)) return 'radioButtonState';
18
+ if (/(?:^|\.)(?:Switch|SwitchCompat|SwitchMaterial|MaterialSwitch)$/.test(className)) return 'checkedState';
18
19
  return undefined;
19
20
  }
20
21
 
22
+ export function matchesNativeControl(type: NativeStateType, className: unknown) {
23
+ const detected = nativeControlType(className);
24
+ // 新节点自动识别三类控件,旧节点仍保留原有的类型约束。
25
+ return type === 'checkedState' ? detected !== undefined : detected === type;
26
+ }
27
+
21
28
  // UIAutomator XML 使用字符串,Appium 属性响应也可能返回布尔值;缺失值不能视为 false。
22
29
  export function parseNativeBoolean(value: unknown): boolean | undefined {
23
30
  if (value === true || value === 'true') return true;
@@ -76,6 +76,7 @@ export type AppiumRecordedStep = {
76
76
  | 'waitFor'
77
77
  | 'assertExists'
78
78
  | 'checkboxState'
79
+ | 'checkedState'
79
80
  | 'radioButtonState'
80
81
  | 'aiRecognition'
81
82
  | 'textClick'
@@ -87,6 +88,8 @@ export type AppiumRecordedStep = {
87
88
  | 'swipe'
88
89
  | 'screenshot'
89
90
  | 'launchApp'
91
+ | 'openGallery'
92
+ | 'endFlow'
90
93
  | 'clearAppData'
91
94
  | 'waitDisappear'
92
95
  | 'assertText'
@@ -1,5 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import type { ConfigForm } from '../../types';
3
+ import FlowAppearanceSettings from './FlowAppearanceSettings.vue';
4
+ import { DEFAULT_FLOW_BACKGROUND, isHexColor } from '../../appium-recorder/flow-appearance';
3
5
 
4
6
  defineProps<{
5
7
  configForm: ConfigForm;
@@ -15,7 +17,7 @@ defineEmits<{ testModel: []; saveModelConfig: [] }>();
15
17
  <template #header>
16
18
  <div class="panel-header">
17
19
  <span>节点模型配置</span>
18
- <el-button type="primary" :loading="isSavingModelConfig" @click="$emit('saveModelConfig')">
20
+ <el-button type="primary" :loading="isSavingModelConfig" :disabled="!isHexColor(configForm.appium.flowBackgroundColor ?? DEFAULT_FLOW_BACKGROUND)" @click="$emit('saveModelConfig')">
19
21
  保存模型配置
20
22
  </el-button>
21
23
  </div>
@@ -41,6 +43,7 @@ defineEmits<{ testModel: []; saveModelConfig: [] }>();
41
43
  </el-form>
42
44
  </section>
43
45
  </el-card>
46
+ <FlowAppearanceSettings v-model="configForm.appium.flowBackgroundColor" :saving="isSavingModelConfig" @save="$emit('saveModelConfig')" />
44
47
  </template>
45
48
 
46
49
  <style scoped>
@@ -0,0 +1,51 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { RefreshLeft } from '@element-plus/icons-vue';
4
+ import { DEFAULT_FLOW_BACKGROUND, FLOW_BACKGROUND_PRESETS, isHexColor, normalizeFlowBackground } from '../../appium-recorder/flow-appearance';
5
+
6
+ const props = defineProps<{ modelValue?: string; saving: boolean }>();
7
+ const emit = defineEmits<{ 'update:modelValue': [color: string]; save: [] }>();
8
+ const color = computed(() => props.modelValue ?? DEFAULT_FLOW_BACKGROUND);
9
+ const error = computed(() => isHexColor(color.value) ? '' : '请输入有效的十六进制颜色,如 #ABC 或 #AABBCC');
10
+ </script>
11
+
12
+ <template>
13
+ <el-card shadow="never" class="config-module-card config-appium-card flow-appearance-settings">
14
+ <template #header>
15
+ <div class="panel-header">
16
+ <span>流程背景色</span>
17
+ <el-button type="primary" :loading="saving" :disabled="Boolean(error)" @click="emit('save')">保存背景色</el-button>
18
+ </div>
19
+ </template>
20
+ <el-form label-position="top">
21
+ <el-form-item label="预选颜色">
22
+ <div class="flow-color-presets">
23
+ <button v-for="preset in FLOW_BACKGROUND_PRESETS" :key="preset" type="button"
24
+ :style="{ backgroundColor: preset }" :aria-label="`选择颜色 ${preset}`" :title="preset"
25
+ :aria-pressed="normalizeFlowBackground(color) === preset && !error"
26
+ @click="emit('update:modelValue', preset)" />
27
+ </div>
28
+ </el-form-item>
29
+ <el-form-item label="自定义颜色" :error="error">
30
+ <div class="flow-color-input">
31
+ <el-color-picker :model-value="normalizeFlowBackground(color)" color-format="hex" :predefine="FLOW_BACKGROUND_PRESETS"
32
+ aria-label="选择流程背景色" @update:model-value="emit('update:modelValue', $event || DEFAULT_FLOW_BACKGROUND)" />
33
+ <el-input :model-value="color" aria-label="十六进制背景色" placeholder="#D4E8DD"
34
+ @update:model-value="emit('update:modelValue', String($event))" />
35
+ <el-button :icon="RefreshLeft" title="恢复默认颜色" aria-label="恢复默认颜色" @click="emit('update:modelValue', DEFAULT_FLOW_BACKGROUND)" />
36
+ </div>
37
+ </el-form-item>
38
+ </el-form>
39
+ </el-card>
40
+ </template>
41
+
42
+ <style scoped>
43
+ .flow-appearance-settings { margin-top: 20px; }
44
+ .flow-appearance-settings .el-form { max-width: 640px; }
45
+ .flow-color-presets, .flow-color-input { display: flex; align-items: center; gap: 10px; }
46
+ .flow-color-presets { flex-wrap: wrap; }
47
+ .flow-color-presets button { width: 32px; height: 32px; border: 1px solid var(--el-border-color-darker); border-radius: 4px; cursor: pointer; }
48
+ .flow-color-presets button[aria-pressed="true"] { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
49
+ .flow-color-input { width: 100%; }
50
+ .flow-color-input .el-input { flex: 1; min-width: 0; }
51
+ </style>
package/src/style.css CHANGED
@@ -2814,7 +2814,7 @@ select {
2814
2814
  overflow: hidden;
2815
2815
  border: 1px solid #e6edf5;
2816
2816
  border-radius: 12px;
2817
- background: #d4e8dd;
2817
+ background: var(--flow-background, #d4e8dd);
2818
2818
  }
2819
2819
 
2820
2820
  .appium-flow-canvas--dialog.appium-flow-canvas--vue {
@@ -2827,7 +2827,7 @@ select {
2827
2827
  height: 100%;
2828
2828
  background:
2829
2829
  radial-gradient(circle at 1px 1px, #aecfbd 1px, transparent 0) 0 0 / 22px 22px,
2830
- #d4e8dd;
2830
+ var(--flow-background, #d4e8dd);
2831
2831
  }
2832
2832
 
2833
2833
  .appium-vue-flow .vue-flow__pane {
package/src/types.ts CHANGED
@@ -12,7 +12,7 @@ export type GeneratorForm = {
12
12
  };
13
13
 
14
14
  export type ConfigForm = {
15
- appium: { model: AiRecognitionModel };
15
+ appium: { model: AiRecognitionModel; flowBackgroundColor?: string };
16
16
  runtime: {
17
17
  androidSdkPath: string;
18
18
  reportOutputPath: string;