android-midscene-automation 0.1.42 → 0.1.43
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/AI_RECOGNITION_GUIDE.md +370 -0
- package/CHANGELOG.md +7 -2
- package/README.md +24 -5
- package/USAGE.md +16 -3
- package/package.json +1 -1
- package/server/appium-auto-save.test.ts +2 -1
- package/server/appium-config.test.ts +48 -1
- package/server/appium-recorder/appium-runner.ts +21 -1
- package/server/appium-recorder/bounded-loop.test.ts +55 -0
- package/server/appium-recorder/bounded-loop.ts +12 -1
- package/server/appium-recorder/prompt-optimizer.ts +57 -0
- package/server/appium-recorder/report-summary.test.ts +78 -0
- package/server/appium-recorder/report-summary.ts +60 -0
- package/server/appium-recorder/report.ts +31 -2
- package/server/appium-recorder/repository.ts +2 -0
- package/server/appium-recorder/routes.ts +53 -3
- package/server/config.ts +17 -2
- package/server/http-api.ts +8 -4
- package/src/App.vue +16 -5
- package/src/api.ts +2 -2
- package/src/appium-recorder/AppiumPage.vue +126 -62
- package/src/appium-recorder/action-descriptions.ts +1 -0
- package/src/appium-recorder/api.ts +22 -2
- package/src/appium-recorder/bounded-loop.ts +9 -1
- package/src/appium-recorder/components/AiRecognitionSettings.vue +30 -6
- package/src/appium-recorder/components/BreakLoopSettings.vue +19 -3
- package/src/appium-recorder/components/ComponentTree.vue +5 -5
- package/src/appium-recorder/components/CoordinatePointPicker.vue +23 -0
- package/src/appium-recorder/components/FlowActionMenu.vue +50 -20
- package/src/appium-recorder/components/FlowStepEditor.vue +10 -2
- package/src/appium-recorder/components/NestedConditionBranches.vue +18 -49
- package/src/appium-recorder/components/RecordedSteps.vue +16 -5
- package/src/appium-recorder/coordinate-preview.ts +37 -0
- package/src/appium-recorder/flow-copy.ts +1 -0
- package/src/appium-recorder/flow-graph.ts +5 -2
- package/src/appium-recorder/flow-labels.ts +3 -1
- package/src/appium-recorder/flow-merge.ts +1 -1
- package/src/appium-recorder/flow-unmerge.ts +2 -2
- package/src/appium-recorder/report-summary.ts +60 -0
- package/src/appium-recorder/types.ts +2 -0
- package/src/components/config/AiDeduplicationSettings.vue +5 -4
- package/src/components/config/AiPromptPresetsSettings.vue +9 -12
- package/src/components/config/AppiumConfigPanel.vue +66 -38
- package/src/components/config/AppiumModelSettings.vue +45 -0
- package/src/components/config/FlowAppearanceSettings.vue +0 -1
- package/src/components/config/ReplayReportSummarySettings.vue +118 -0
- package/src/components/device/DevicePreviewPanel.vue +18 -3
- package/src/config/use-appium-auto-save.ts +12 -4
- package/src/pages/ConfigPage.vue +4 -2
- package/src/style.css +0 -45
- package/src/types.ts +2 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { nextTick, shallowRef } from 'vue';
|
|
3
3
|
import type { MenuInstance, PopoverInstance } from 'element-plus';
|
|
4
|
-
import { PASTE_COMMAND, type FlowActionGroup, type InsertAction } from '../flow-graph';
|
|
4
|
+
import { PASTE_COMMAND, type FlowActionGroup, type FlowActionSubgroup, type InsertAction } from '../flow-graph';
|
|
5
5
|
import { actionDescriptions } from '../action-descriptions';
|
|
6
6
|
|
|
7
7
|
const props = defineProps<{
|
|
@@ -21,6 +21,10 @@ const menuRef = shallowRef<HTMLElement>();
|
|
|
21
21
|
const menuControlRef = shallowRef<MenuInstance>();
|
|
22
22
|
const menuMounted = shallowRef(false);
|
|
23
23
|
|
|
24
|
+
function isSubgroup(item: FlowActionGroup['actions'][number]): item is FlowActionSubgroup {
|
|
25
|
+
return 'actions' in item;
|
|
26
|
+
}
|
|
27
|
+
|
|
24
28
|
async function openMenu() {
|
|
25
29
|
// 每次打开重新创建面板,避免上次选中的操作被当成当前值而无法重复添加。
|
|
26
30
|
menuMounted.value = true;
|
|
@@ -55,7 +59,7 @@ async function handleMenuKeydown(event: KeyboardEvent) {
|
|
|
55
59
|
} else if (group) {
|
|
56
60
|
menuControlRef.value?.open(group);
|
|
57
61
|
await nextTick();
|
|
58
|
-
target.querySelector<HTMLElement>('
|
|
62
|
+
target.querySelector<HTMLElement>('[role="menuitem"]:not(.is-disabled)')?.focus();
|
|
59
63
|
} else if (event.key !== 'ArrowRight') {
|
|
60
64
|
target.click();
|
|
61
65
|
}
|
|
@@ -119,25 +123,51 @@ function selectAction(value: string) {
|
|
|
119
123
|
@click="openGroupOnClick($event, group.title)"
|
|
120
124
|
>
|
|
121
125
|
<template #title>{{ group.title }}</template>
|
|
122
|
-
<
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
126
|
+
<template v-for="item in group.actions" :key="isSubgroup(item) ? item.label : item.type">
|
|
127
|
+
<el-sub-menu
|
|
128
|
+
v-if="isSubgroup(item)"
|
|
129
|
+
:index="`${group.title}/${item.label}`"
|
|
130
|
+
:data-group="`${group.title}/${item.label}`"
|
|
131
|
+
:aria-label="item.label"
|
|
132
|
+
tabindex="-1"
|
|
133
|
+
:teleported="true"
|
|
134
|
+
popper-class="appium-action-submenu appium-action-submenu--third"
|
|
135
|
+
@click.stop="openGroupOnClick($event, `${group.title}/${item.label}`)"
|
|
136
|
+
>
|
|
137
|
+
<template #title>{{ item.label }}</template>
|
|
138
|
+
<el-tooltip
|
|
139
|
+
v-for="action in item.actions"
|
|
140
|
+
:key="action.type"
|
|
141
|
+
:content="actionDescriptions[action.type]"
|
|
142
|
+
placement="right"
|
|
143
|
+
effect="dark"
|
|
144
|
+
:show-after="350"
|
|
145
|
+
:hide-after="0"
|
|
146
|
+
:enterable="false"
|
|
147
|
+
:popper-options="{ modifiers: [{ name: 'preventOverflow', options: { altAxis: true, padding: 8 } }] }"
|
|
148
|
+
popper-class="appium-action-description"
|
|
149
|
+
>
|
|
150
|
+
<el-menu-item :index="action.type" :disabled="isActionDisabled(action.type)">
|
|
151
|
+
{{ action.label }}
|
|
152
|
+
</el-menu-item>
|
|
153
|
+
</el-tooltip>
|
|
154
|
+
</el-sub-menu>
|
|
155
|
+
<el-tooltip
|
|
156
|
+
v-else
|
|
157
|
+
:content="actionDescriptions[item.type]"
|
|
158
|
+
placement="right"
|
|
159
|
+
effect="dark"
|
|
160
|
+
:show-after="350"
|
|
161
|
+
:hide-after="0"
|
|
162
|
+
:enterable="false"
|
|
163
|
+
:popper-options="{ modifiers: [{ name: 'preventOverflow', options: { altAxis: true, padding: 8 } }] }"
|
|
164
|
+
popper-class="appium-action-description"
|
|
137
165
|
>
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
166
|
+
<el-menu-item :index="item.type" :disabled="isActionDisabled(item.type)">
|
|
167
|
+
{{ item.label }}
|
|
168
|
+
</el-menu-item>
|
|
169
|
+
</el-tooltip>
|
|
170
|
+
</template>
|
|
141
171
|
</el-sub-menu>
|
|
142
172
|
<el-menu-item v-if="clipboardCount" :index="PASTE_COMMAND" class="appium-action-paste">
|
|
143
173
|
粘贴 {{ clipboardCount }} 个节点
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed } from 'vue';
|
|
3
|
+
import CoordinatePointPicker from './CoordinatePointPicker.vue';
|
|
3
4
|
import type { AppiumRecordedStep, AppiumSelector } from '../types';
|
|
4
5
|
import { normalizeVisualChangeConfig } from '../visual-change';
|
|
5
6
|
import { longPressMode } from '../long-press';
|
|
@@ -99,6 +100,12 @@ function patchTapCoordinate(axis: 'centerX' | 'centerY', value: number | undefin
|
|
|
99
100
|
? { label: `点击坐标 ${fallback.centerX},${fallback.centerY}` } : {}) });
|
|
100
101
|
}
|
|
101
102
|
|
|
103
|
+
function applyTapPoint(point: { x: number; y: number }) {
|
|
104
|
+
const fallback = { ...props.step.fallback, strategy: 'bounds' as const, centerX: point.x, centerY: point.y };
|
|
105
|
+
patchStep({ fallback, ...(/^点击坐标 \d+,\d+$/.test(props.step.label)
|
|
106
|
+
? { label: `点击坐标 ${point.x},${point.y}` } : {}) });
|
|
107
|
+
}
|
|
108
|
+
|
|
102
109
|
function patchVisualChange(patch: Partial<VisualChangeConfig>) {
|
|
103
110
|
patchStep({
|
|
104
111
|
visualChange: normalizeVisualChangeConfig({
|
|
@@ -158,7 +165,7 @@ function patchSelector(patch: Partial<AppiumSelector>) {
|
|
|
158
165
|
<el-form-item v-if="step.type === 'aiRecognition'">
|
|
159
166
|
<el-checkbox :model-value="step.aiTimeoutEnabled === true" :disabled="disabled" @update:model-value="patchStep({ aiTimeoutEnabled: $event === true })">启用超时</el-checkbox>
|
|
160
167
|
</el-form-item>
|
|
161
|
-
<el-form-item v-if="!['longPress', 'stopApp', 'log', 'openGallery', 'endFlow', 'loop', 'breakLoop'].includes(step.type) && (step.type !== 'aiRecognition' || step.aiTimeoutEnabled === true)" :label="step.type === 'aiRecognition' && step.aiObservation ? '模型请求超时 ms' : '超时时间 ms'">
|
|
168
|
+
<el-form-item v-if="!['longPress', 'stopApp', 'log', 'openGallery', 'endFlow', 'loop', 'breakLoop', 'continueLoop'].includes(step.type) && (step.type !== 'aiRecognition' || step.aiTimeoutEnabled === true)" :label="step.type === 'aiRecognition' && step.aiObservation ? '模型请求超时 ms' : '超时时间 ms'">
|
|
162
169
|
<el-input-number
|
|
163
170
|
:model-value="step.timeoutMs ?? (step.type === 'delay' ? 1000 : DEFAULT_NODE_TIMEOUT_MS)"
|
|
164
171
|
:disabled="disabled"
|
|
@@ -196,11 +203,12 @@ function patchSelector(patch: Partial<AppiumSelector>) {
|
|
|
196
203
|
<el-input-number :model-value="step.fallback?.centerY ?? 0" :disabled="disabled" :min="0" :max="99999" :precision="0" controls-position="right" aria-label="坐标 Y" @update:model-value="patchTapCoordinate('centerY', $event)" />
|
|
197
204
|
</el-form-item>
|
|
198
205
|
</div>
|
|
206
|
+
<CoordinatePointPicker v-if="step.type === 'coordinateTap'" :disabled="disabled" @select="applyTapPoint" />
|
|
199
207
|
<StageLogSettings v-if="step.type === 'log'" :step="step" :disabled="disabled" @update="patchStep" />
|
|
200
208
|
<VariableExtractionSettings v-if="step.type === 'extractVariable'" :step="step" :disabled="disabled" @update="patchStep" />
|
|
201
209
|
<ScriptParameterSettings v-if="step.type === 'runScript'" :step="step" :disabled="disabled" @update="patchStep" />
|
|
202
210
|
<LoopSettings v-if="step.type === 'loop'" :step="step" :disabled="disabled" @update="patchStep" />
|
|
203
|
-
<BreakLoopSettings v-if="step.type === 'breakLoop'" :step="step" :steps="steps" :disabled="disabled" @update="patchStep" />
|
|
211
|
+
<BreakLoopSettings v-if="step.type === 'breakLoop' || step.type === 'continueLoop'" :step="step" :steps="steps" :disabled="disabled" :mode="step.type === 'continueLoop' ? 'continue' : 'break'" @update="patchStep" />
|
|
204
212
|
<TextClickSettings v-if="step.type === 'textClick'" :step="step" :disabled="disabled" @update="patchStep" />
|
|
205
213
|
<AiRecognitionSettings v-if="step.type === 'aiRecognition'" :step="step" :steps="steps" :disabled="disabled" @update="patchStep" />
|
|
206
214
|
<el-form-item
|
|
@@ -6,53 +6,10 @@ import type { AppiumRecordedStep } from '../types';
|
|
|
6
6
|
import { buildConditionLayouts, FLOW_BRANCH_GAP, FLOW_NODE_WIDTH } from '../flow-layout';
|
|
7
7
|
import FlowStepEditor from './FlowStepEditor.vue';
|
|
8
8
|
import FlowActionMenu from './FlowActionMenu.vue';
|
|
9
|
+
import type { FlowActionGroup, InsertAction } from '../flow-graph';
|
|
9
10
|
|
|
10
11
|
type BranchName = 'yes' | 'no';
|
|
11
12
|
type FlowKind = 'action' | 'condition' | 'assertion';
|
|
12
|
-
type InsertAction =
|
|
13
|
-
| 'delay'
|
|
14
|
-
| 'tap'
|
|
15
|
-
| 'input'
|
|
16
|
-
| 'clearInput'
|
|
17
|
-
| 'coordinateTap'
|
|
18
|
-
| 'longPress'
|
|
19
|
-
| 'keyBack'
|
|
20
|
-
| 'keyHome'
|
|
21
|
-
| 'keyRecent'
|
|
22
|
-
| 'keyPower'
|
|
23
|
-
| 'swipe'
|
|
24
|
-
| 'pinch'
|
|
25
|
-
| 'launchApp'
|
|
26
|
-
| 'stopApp'
|
|
27
|
-
| 'openGallery'
|
|
28
|
-
| 'endFlow'
|
|
29
|
-
| 'loop'
|
|
30
|
-
| 'breakLoop'
|
|
31
|
-
| 'clearAppData'
|
|
32
|
-
| 'popupCondition'
|
|
33
|
-
| 'checkboxState'
|
|
34
|
-
| 'checkedState'
|
|
35
|
-
| 'radioButtonState'
|
|
36
|
-
| 'aiRecognition'
|
|
37
|
-
| 'imageCheck'
|
|
38
|
-
| 'textClick'
|
|
39
|
-
| 'tapIfExists'
|
|
40
|
-
| 'inputIfExists'
|
|
41
|
-
| 'clearIfExists'
|
|
42
|
-
| 'backIfExists'
|
|
43
|
-
| 'waitFor'
|
|
44
|
-
| 'assertExists'
|
|
45
|
-
| 'assertText'
|
|
46
|
-
| 'waitDisappear'
|
|
47
|
-
| 'waitActivity'
|
|
48
|
-
| 'runScript'
|
|
49
|
-
| 'extractVariable'
|
|
50
|
-
| 'noop'
|
|
51
|
-
| 'log'
|
|
52
|
-
| 'visualChangeStart'
|
|
53
|
-
| 'visualChangeEnd'
|
|
54
|
-
| 'visualChange';
|
|
55
|
-
|
|
56
13
|
const PASTE_COMMAND = '__paste_flow_nodes__';
|
|
57
14
|
|
|
58
15
|
const props = defineProps<{
|
|
@@ -86,7 +43,7 @@ const layout = computed(() => buildConditionLayouts(props.steps).get(props.condi
|
|
|
86
43
|
yesAxis: 25,
|
|
87
44
|
noAxis: 75,
|
|
88
45
|
});
|
|
89
|
-
const actionGroups:
|
|
46
|
+
const actionGroups: FlowActionGroup[] = [
|
|
90
47
|
{
|
|
91
48
|
title: '组件操作',
|
|
92
49
|
actions: [
|
|
@@ -126,8 +83,13 @@ const actionGroups: Array<{ title: string; actions: Array<{ type: InsertAction;
|
|
|
126
83
|
{ type: 'waitFor', label: '等待出现' },
|
|
127
84
|
{ type: 'waitDisappear', label: '等待元素消失' },
|
|
128
85
|
{ type: 'waitActivity', label: '等待 Activity' },
|
|
129
|
-
{
|
|
130
|
-
|
|
86
|
+
{
|
|
87
|
+
label: '检测画面变化',
|
|
88
|
+
actions: [
|
|
89
|
+
{ type: 'visualChangeStart', label: '开始检测' },
|
|
90
|
+
{ type: 'visualChangeEnd', label: '结束检测' },
|
|
91
|
+
],
|
|
92
|
+
},
|
|
131
93
|
],
|
|
132
94
|
},
|
|
133
95
|
{
|
|
@@ -142,8 +104,14 @@ const actionGroups: Array<{ title: string; actions: Array<{ type: InsertAction;
|
|
|
142
104
|
actions: [
|
|
143
105
|
{ type: 'noop', label: '空节点' },
|
|
144
106
|
{ type: 'endFlow', label: '终止流程' },
|
|
145
|
-
{
|
|
146
|
-
|
|
107
|
+
{
|
|
108
|
+
label: '循环',
|
|
109
|
+
actions: [
|
|
110
|
+
{ type: 'loop', label: '有界循环' },
|
|
111
|
+
{ type: 'continueLoop', label: '继续下一次循环' },
|
|
112
|
+
{ type: 'breakLoop', label: '退出循环' },
|
|
113
|
+
],
|
|
114
|
+
},
|
|
147
115
|
{ type: 'log', label: '输出日志' },
|
|
148
116
|
{ type: 'runScript', label: '连接脚本' },
|
|
149
117
|
],
|
|
@@ -166,6 +134,7 @@ function typeLabel(step: AppiumRecordedStep) {
|
|
|
166
134
|
endFlow: '终止流程',
|
|
167
135
|
loop: '有界循环',
|
|
168
136
|
breakLoop: '退出循环',
|
|
137
|
+
continueLoop: '继续下一次循环',
|
|
169
138
|
log: '输出日志',
|
|
170
139
|
checkboxState: 'Checkbox 状态',
|
|
171
140
|
checkedState: '判断勾选',
|
|
@@ -96,8 +96,13 @@ const insertActionGroups: FlowActionGroup[] = [
|
|
|
96
96
|
{ type: 'waitFor', label: '等待出现' },
|
|
97
97
|
{ type: 'waitDisappear', label: '等待元素消失' },
|
|
98
98
|
{ type: 'waitActivity', label: '等待 Activity' },
|
|
99
|
-
{
|
|
100
|
-
|
|
99
|
+
{
|
|
100
|
+
label: '检测画面变化',
|
|
101
|
+
actions: [
|
|
102
|
+
{ type: 'visualChangeStart', label: '开始检测' },
|
|
103
|
+
{ type: 'visualChangeEnd', label: '结束检测' },
|
|
104
|
+
],
|
|
105
|
+
},
|
|
101
106
|
],
|
|
102
107
|
},
|
|
103
108
|
{
|
|
@@ -112,8 +117,14 @@ const insertActionGroups: FlowActionGroup[] = [
|
|
|
112
117
|
actions: [
|
|
113
118
|
{ type: 'noop', label: '空节点' },
|
|
114
119
|
{ type: 'endFlow', label: '终止流程' },
|
|
115
|
-
{
|
|
116
|
-
|
|
120
|
+
{
|
|
121
|
+
label: '循环',
|
|
122
|
+
actions: [
|
|
123
|
+
{ type: 'loop', label: '有界循环' },
|
|
124
|
+
{ type: 'continueLoop', label: '继续下一次循环' },
|
|
125
|
+
{ type: 'breakLoop', label: '退出循环' },
|
|
126
|
+
],
|
|
127
|
+
},
|
|
117
128
|
{ type: 'log', label: '输出日志' },
|
|
118
129
|
{ type: 'runScript', label: '连接脚本' },
|
|
119
130
|
],
|
|
@@ -122,7 +133,7 @@ const insertActionGroups: FlowActionGroup[] = [
|
|
|
122
133
|
|
|
123
134
|
const mainActionGroups = insertActionGroups.map((group) => ({
|
|
124
135
|
...group,
|
|
125
|
-
actions: group.actions.filter((action) => action.type !== 'clearAppData'),
|
|
136
|
+
actions: group.actions.filter((action) => !('type' in action) || action.type !== 'clearAppData'),
|
|
126
137
|
}));
|
|
127
138
|
const startActionGroups = insertActionGroups;
|
|
128
139
|
const hasClearAppDataStep = computed(() => props.steps.some((step) => step.type === 'clearAppData'));
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { computed, provide, shallowRef, watch, type InjectionKey, type Ref } from 'vue';
|
|
2
|
+
|
|
3
|
+
type Point = { x: number; y: number };
|
|
4
|
+
interface CoordinatePicker {
|
|
5
|
+
enabled: Readonly<Ref<boolean>>;
|
|
6
|
+
owner: Readonly<Ref<symbol | undefined>>;
|
|
7
|
+
start: (owner: symbol, select: (point: Point) => void) => void;
|
|
8
|
+
cancel: (owner?: symbol) => void;
|
|
9
|
+
}
|
|
10
|
+
export const coordinatePreviewKey: InjectionKey<CoordinatePicker> = Symbol('coordinate-preview');
|
|
11
|
+
|
|
12
|
+
export function useCoordinatePicker(enabled: Readonly<Ref<boolean>>, showPreview: () => void) {
|
|
13
|
+
const session = shallowRef<{ owner: symbol; select: (point: Point) => void }>();
|
|
14
|
+
function cancel(owner?: symbol) {
|
|
15
|
+
if (!owner || session.value?.owner === owner) session.value = undefined;
|
|
16
|
+
}
|
|
17
|
+
provide(coordinatePreviewKey, {
|
|
18
|
+
enabled,
|
|
19
|
+
owner: computed(() => session.value?.owner),
|
|
20
|
+
start(owner, select) {
|
|
21
|
+
if (!enabled.value) return;
|
|
22
|
+
session.value = { owner, select };
|
|
23
|
+
showPreview();
|
|
24
|
+
},
|
|
25
|
+
cancel,
|
|
26
|
+
});
|
|
27
|
+
watch(enabled, (value) => { if (!value) cancel(); });
|
|
28
|
+
return {
|
|
29
|
+
picking: computed(() => Boolean(session.value)),
|
|
30
|
+
cancel,
|
|
31
|
+
select(point: Point) {
|
|
32
|
+
const current = session.value;
|
|
33
|
+
cancel();
|
|
34
|
+
if (enabled.value) current?.select(point);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -184,6 +184,7 @@ export function pasteFlowClipboard(
|
|
|
184
184
|
delete step.mergeUndo;
|
|
185
185
|
step.id = idMap.get(source.id) || createId();
|
|
186
186
|
if (step.breakLoopTargetId) step.breakLoopTargetId = idMap.get(step.breakLoopTargetId) || step.breakLoopTargetId;
|
|
187
|
+
if (step.continueLoopTargetId) step.continueLoopTargetId = idMap.get(step.continueLoopTargetId) || step.continueLoopTargetId;
|
|
187
188
|
const flow = { ...(step.flow || {}) };
|
|
188
189
|
for (const key of targetKeys) {
|
|
189
190
|
const mappedId = flow[key] ? idMap.get(flow[key] || '') : '';
|
|
@@ -29,6 +29,7 @@ export type InsertAction =
|
|
|
29
29
|
| 'endFlow'
|
|
30
30
|
| 'loop'
|
|
31
31
|
| 'breakLoop'
|
|
32
|
+
| 'continueLoop'
|
|
32
33
|
| 'clearAppData'
|
|
33
34
|
| 'popupCondition'
|
|
34
35
|
| 'checkboxState'
|
|
@@ -70,9 +71,11 @@ const FLOW_BRANCH_LABEL_GAP = 58;
|
|
|
70
71
|
const FLOW_BRANCH_MIN_SPREAD = 260;
|
|
71
72
|
const FLOW_BRANCH_SUBTREE_GAP = 80;
|
|
72
73
|
|
|
74
|
+
export type FlowAction = { type: InsertAction; label: string };
|
|
75
|
+
export type FlowActionSubgroup = { label: string; actions: FlowAction[] };
|
|
73
76
|
export type FlowActionGroup = {
|
|
74
77
|
title: string;
|
|
75
|
-
actions: Array<
|
|
78
|
+
actions: Array<FlowAction | FlowActionSubgroup>;
|
|
76
79
|
};
|
|
77
80
|
|
|
78
81
|
export type FlowGraphNodeData =
|
|
@@ -283,7 +286,7 @@ function estimateStepNodeHeight(label: { title: string; meta: string; note?: str
|
|
|
283
286
|
}
|
|
284
287
|
|
|
285
288
|
function withLaunchAppAction(groups: FlowActionGroup[]) {
|
|
286
|
-
if (groups.some((group) => group.actions.some((action) => action.type === 'launchApp'))) return groups;
|
|
289
|
+
if (groups.some((group) => group.actions.some((action) => 'type' in action && action.type === 'launchApp'))) return groups;
|
|
287
290
|
const launchAction = { type: 'launchApp' as const, label: '启动 App' };
|
|
288
291
|
let added = false;
|
|
289
292
|
const nextGroups = groups.map((group) => {
|
|
@@ -15,7 +15,7 @@ export function defaultFlowKind(step: AppiumRecordedStep): FlowKind {
|
|
|
15
15
|
if (step.type === 'aiRecognition') return isAiBranchEnabled(step) ? 'condition' : 'action';
|
|
16
16
|
if (step.type === 'extractVariable') return 'action';
|
|
17
17
|
if (step.type === 'loop') return 'condition';
|
|
18
|
-
if (step.type === 'breakLoop') return 'action';
|
|
18
|
+
if (step.type === 'breakLoop' || step.type === 'continueLoop') return 'action';
|
|
19
19
|
if (step.type === 'log' || step.type === 'openGallery' || step.type === 'endFlow') return 'action';
|
|
20
20
|
if (isBooleanCondition(step)) return 'condition';
|
|
21
21
|
if (step.flow?.nodeKind) return step.flow.nodeKind;
|
|
@@ -65,6 +65,7 @@ export function flowTypeLabel(step: AppiumRecordedStep) {
|
|
|
65
65
|
endFlow: '终止流程',
|
|
66
66
|
loop: '有界循环',
|
|
67
67
|
breakLoop: '退出循环',
|
|
68
|
+
continueLoop: '继续下一次循环',
|
|
68
69
|
clearAppData: '清理 APP 缓存',
|
|
69
70
|
longPress: '长按',
|
|
70
71
|
pinch: '双指缩放',
|
|
@@ -82,6 +83,7 @@ export function flowStepMeta(step: AppiumRecordedStep) {
|
|
|
82
83
|
if (step.type === 'extractVariable') return `${step.extractVariable?.attribute || 'text'} → ${step.extractVariable?.name || '未设置变量名'}`;
|
|
83
84
|
if (step.type === 'loop') return `最多 ${step.loop?.maxIterations ?? '?'} 次 · ${step.loop?.exitWhen === 'exists' ? '元素出现时退出' : step.loop?.exitWhen === 'notExists' ? '元素消失时退出' : '固定次数'}${step.loop?.exitWhen !== 'never' ? ` ${step.selector?.value || ''}` : ''}`;
|
|
84
85
|
if (step.type === 'breakLoop') return step.breakLoopTargetId ? '退出指定循环,继续循环结束后的流程' : '退出当前循环,继续循环结束后的流程';
|
|
86
|
+
if (step.type === 'continueLoop') return step.continueLoopTargetId ? '结束本轮,进入指定循环的下一轮' : '结束本轮,进入当前循环的下一轮';
|
|
85
87
|
if (step.type === 'log') return `${step.logPrefix ?? DEFAULT_LOG_PREFIX}:${step.value || ''}`;
|
|
86
88
|
if (step.type === 'aiRecognition') return `AI ${step.aiObservation?.mode === 'untilMatch' ? '命中即结束' : step.aiObservation ? '持续观察' : '识别'} · ${step.value || '未填写识别内容'}`;
|
|
87
89
|
if (step.type === 'longPress') {
|
|
@@ -63,7 +63,7 @@ export function mergeBranches(steps: AppiumRecordedStep[], conditionId: string,
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
function connectEnd(step: typeof next[number]) {
|
|
66
|
-
if (step.type === 'endFlow' || step.type === 'breakLoop') return;
|
|
66
|
+
if (step.type === 'endFlow' || step.type === 'breakLoop' || step.type === 'continueLoop') return;
|
|
67
67
|
step.flow.successTargetId = commonId;
|
|
68
68
|
if (defaultFlowKind(step) === 'condition') {
|
|
69
69
|
for (const branch of (step.type === 'loop' ? ['no'] : ['yes', 'no']) as Branch[]) {
|
|
@@ -28,7 +28,7 @@ export function unmergeBranches(steps: AppiumRecordedStep[], conditionId: string
|
|
|
28
28
|
const shared = new Set([...common, ...added.map(step => step.id)]);
|
|
29
29
|
// 普通新增操作可分配到任一侧;控制节点和跨节点配对不做猜测性复制。
|
|
30
30
|
if (added.some(step => defaultFlowKind(step) === 'condition'
|
|
31
|
-
|| ['visualChange', 'breakLoop', 'endFlow'].includes(step.type) || step.mergeUndo)) {
|
|
31
|
+
|| ['visualChange', 'breakLoop', 'continueLoop', 'endFlow'].includes(step.type) || step.mergeUndo)) {
|
|
32
32
|
throw new Error('公共流程新增了判断、循环、终止或配对节点,请先移出这些节点再取消合并');
|
|
33
33
|
}
|
|
34
34
|
if (added.some(step => edges.some(key => step.flow?.[key]
|
|
@@ -98,7 +98,7 @@ export function unmergeBranches(steps: AppiumRecordedStep[], conditionId: string
|
|
|
98
98
|
const siblings = branchSteps(next, conditionId, other);
|
|
99
99
|
const appended = siblings.slice(-added.length);
|
|
100
100
|
const previous = siblings[siblings.length - appended.length - 1];
|
|
101
|
-
if (previous && (defaultFlowKind(previous) === 'condition' || ['endFlow', 'breakLoop'].includes(previous.type))) {
|
|
101
|
+
if (previous && (defaultFlowKind(previous) === 'condition' || ['endFlow', 'breakLoop', 'continueLoop'].includes(previous.type))) {
|
|
102
102
|
throw new Error('另一侧末尾是判断、循环或终止操作,新增节点不能安全追加,请仅保留在原来源分支');
|
|
103
103
|
}
|
|
104
104
|
if (previous?.flow?.successTargetId) {
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export const DEFAULT_REPORT_SUMMARY_PROMPT = `请根据提供的回放日志、执行元数据和脚本配置,生成中文 Markdown 测试报告。默认包含:
|
|
2
|
+
1. 测试概览:脚本名称、开始时间、结束时间、执行结果(成功/失败/终止)、总时长、设备和 App 信息。
|
|
3
|
+
2. 执行配置:主脚本及连接脚本名称、节点类型、参数、超时、分支、循环、AI 识别条件等,用表格展示关键配置。
|
|
4
|
+
3. 用例描述表格:序号、所属脚本、测试场景/用例描述、操作步骤、预期结果、实际结果、通过/失败/未执行;循环多次执行时区分尝试次数。
|
|
5
|
+
4. 失败详情:失败节点、错误日志证据、实际与预期差异、可能原因及排查建议;没有失败则明确说明。
|
|
6
|
+
5. 汇总:执行情况、主要问题、重试结果及建议。
|
|
7
|
+
严格依据日志,未执行节点不能计为通过;AI 判断 false 与执行异常要区分。无法确认的信息写“日志未提供”,推测原因明确标为推测。不要编造截图内容或测试结果。只输出 Markdown 正文,不要在全文外包裹代码块。`;
|
|
8
|
+
|
|
9
|
+
export const IMAGE_GENERATION_TIME_REPORT_SUMMARY_PROMPT = `${DEFAULT_REPORT_SUMMARY_PROMPT}
|
|
10
|
+
|
|
11
|
+
另外必须包含“出图时间分析”:
|
|
12
|
+
- 定位每次触发图片生成的操作,以及其后用于判断已出图的 AI 识别或图像判断节点。
|
|
13
|
+
- 首次出图时间必须采用日志中“距观察开始 Nms”或首次命中帧的明确时间,换算为秒并保留两位小数。
|
|
14
|
+
- AI 识别耗时或图像判断耗时是包含截图采样、模型请求和响应等待的检测节点总耗时,不能直接作为首次出图时间。
|
|
15
|
+
- 如果没有首次命中帧时间,首次出图时间写“日志未提供”,并单独展示检测节点总耗时;不得根据观察时长、超时时间或节点顺序推测。
|
|
16
|
+
- 多次生成、循环或重试须逐次列出,表格列为:次数、触发出图操作、检测条件、首次出图时间、检测节点总耗时、判定结果、日志证据。
|
|
17
|
+
- 汇总成功出图次数、失败或超时次数、最快、最慢和平均首次出图时间;只有至少两次具有明确时间时才计算平均值。`;
|
|
18
|
+
|
|
19
|
+
export type ReportSummaryPromptPreset = { name: string; prompt: string };
|
|
20
|
+
|
|
21
|
+
export const REPORT_SUMMARY_PROMPT_PRESETS: readonly ReportSummaryPromptPreset[] = [
|
|
22
|
+
{ name: '默认测试报告', prompt: DEFAULT_REPORT_SUMMARY_PROMPT },
|
|
23
|
+
{ name: '出图时间分析', prompt: IMAGE_GENERATION_TIME_REPORT_SUMMARY_PROMPT },
|
|
24
|
+
] as const;
|
|
25
|
+
|
|
26
|
+
export interface ReportSummaryConfig {
|
|
27
|
+
enabled: boolean;
|
|
28
|
+
prompt: string;
|
|
29
|
+
customPresets: ReportSummaryPromptPreset[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function validateReportSummaryPrompt(value: unknown) {
|
|
33
|
+
if (typeof value !== 'string' || !value.trim() || value.trim().length > 20000) {
|
|
34
|
+
throw new Error('报告提示词不能为空,且不能超过 20000 字');
|
|
35
|
+
}
|
|
36
|
+
return value.trim();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function resolveReportSummary(value?: Partial<ReportSummaryConfig>): ReportSummaryConfig {
|
|
40
|
+
if (value != null && (typeof value !== 'object' || Array.isArray(value))) throw new Error('回放报告总结配置无效');
|
|
41
|
+
if (value?.enabled !== undefined && typeof value.enabled !== 'boolean') throw new Error('回放报告总结开关无效');
|
|
42
|
+
const prompt = validateReportSummaryPrompt(value?.prompt ?? DEFAULT_REPORT_SUMMARY_PROMPT);
|
|
43
|
+
const rawPresets = value?.customPresets;
|
|
44
|
+
if (rawPresets !== undefined && !Array.isArray(rawPresets)) throw new Error('报告测试场景必须是列表');
|
|
45
|
+
if ((rawPresets?.length || 0) > 50) throw new Error('报告测试场景不能超过 50 个');
|
|
46
|
+
const reservedNames = new Set(REPORT_SUMMARY_PROMPT_PRESETS.map(preset => preset.name));
|
|
47
|
+
const names = new Set<string>();
|
|
48
|
+
const customPresets = (rawPresets || []).map(item => {
|
|
49
|
+
const name = typeof item?.name === 'string' ? item.name.trim() : '';
|
|
50
|
+
if (!name || name.length > 80) throw new Error('报告测试场景名称不能为空,且不能超过 80 字');
|
|
51
|
+
if (reservedNames.has(name) || names.has(name)) throw new Error(`报告测试场景名称已存在:${name}`);
|
|
52
|
+
names.add(name);
|
|
53
|
+
return { name, prompt: validateReportSummaryPrompt(item?.prompt) };
|
|
54
|
+
});
|
|
55
|
+
return {
|
|
56
|
+
enabled: value?.enabled === true,
|
|
57
|
+
prompt,
|
|
58
|
+
customPresets,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -99,6 +99,7 @@ export type AppiumRecordedStep = {
|
|
|
99
99
|
| 'endFlow'
|
|
100
100
|
| 'loop'
|
|
101
101
|
| 'breakLoop'
|
|
102
|
+
| 'continueLoop'
|
|
102
103
|
| 'clearAppData'
|
|
103
104
|
| 'waitDisappear'
|
|
104
105
|
| 'assertText'
|
|
@@ -128,6 +129,7 @@ export type AppiumRecordedStep = {
|
|
|
128
129
|
timeoutBranch?: 'stop' | 'yes' | 'no';
|
|
129
130
|
longPressMode?: 'element' | 'coordinates';
|
|
130
131
|
breakLoopTargetId?: string;
|
|
132
|
+
continueLoopTargetId?: string;
|
|
131
133
|
loop?: {
|
|
132
134
|
maxIterations: number;
|
|
133
135
|
exitWhen: 'never' | 'exists' | 'notExists';
|
|
@@ -12,7 +12,7 @@ function patch(value: Partial<AiDeduplicationConfig>) {
|
|
|
12
12
|
|
|
13
13
|
<template>
|
|
14
14
|
<section class="ai-dedup-settings" aria-label="持续观察截图去重">
|
|
15
|
-
<
|
|
15
|
+
<p class="ai-dedup-description">相似截图在本地过滤后再提交模型,减少重复图片和识别开销。</p>
|
|
16
16
|
<el-form-item label="去重方式">
|
|
17
17
|
<el-radio-group :model-value="settings.method" :disabled="disabled" @update:model-value="patch({ method: $event as AiDeduplicationConfig['method'] })">
|
|
18
18
|
<el-radio-button value="none">不去重</el-radio-button>
|
|
@@ -41,6 +41,7 @@ function patch(value: Partial<AiDeduplicationConfig>) {
|
|
|
41
41
|
</el-form-item>
|
|
42
42
|
</template>
|
|
43
43
|
<div class="ai-dedup-explanation">
|
|
44
|
+
<h4>去重方式说明</h4>
|
|
44
45
|
<p><strong>不去重:</strong>全部采样截图交给模型,保留信息最多,图片输入消耗也最高。</p>
|
|
45
46
|
<p><strong>pixelmatch:</strong>比较原图的像素颜色和变化面积,适合同设备的静态画面,对细节变化敏感。</p>
|
|
46
47
|
<p><strong>OpenCV(SSIM):</strong>缩小图片后比较彩色画面的局部亮度、对比度和结构,更侧重整体相似性;可能忽略小文字,不能识别语义,也不自动对齐位移。</p>
|
|
@@ -50,10 +51,10 @@ function patch(value: Partial<AiDeduplicationConfig>) {
|
|
|
50
51
|
</template>
|
|
51
52
|
|
|
52
53
|
<style scoped>
|
|
53
|
-
.ai-dedup-
|
|
54
|
-
.ai-dedup-title { margin: 0 0 20px; font-size: 15px; font-weight: 600; color: var(--el-text-color-primary); }
|
|
54
|
+
.ai-dedup-description { margin: 0 0 18px; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.6; }
|
|
55
55
|
.ai-dedup-help { width: 100%; margin: 6px 0 0; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.6; }
|
|
56
|
-
.ai-dedup-explanation { color: var(--el-text-color-regular); font-size: 13px; line-height: 1.7; }
|
|
56
|
+
.ai-dedup-explanation { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--ui-border); color: var(--el-text-color-regular); font-size: 13px; line-height: 1.7; }
|
|
57
|
+
.ai-dedup-explanation h4 { margin: 0 0 8px; color: var(--el-text-color-primary); font-size: 13px; }
|
|
57
58
|
.ai-dedup-explanation p { margin: 6px 0; }
|
|
58
59
|
.ai-dedup-settings :deep(.el-radio-group) { gap: 4px 0; }
|
|
59
60
|
</style>
|
|
@@ -24,8 +24,8 @@ function addPreset() {
|
|
|
24
24
|
|
|
25
25
|
<template>
|
|
26
26
|
<section class="ai-prompt-settings" aria-labelledby="ai-prompt-presets-title">
|
|
27
|
-
<h3 id="ai-prompt-presets-title"
|
|
28
|
-
<p class="ai-prompt-description"
|
|
27
|
+
<h3 id="ai-prompt-presets-title" class="ai-prompt-visually-hidden">AI 识别预设提示词</h3>
|
|
28
|
+
<p class="ai-prompt-description">添加常用测试场景,录制 AI 识别节点时可以直接选择。删除预设不会修改已有节点。</p>
|
|
29
29
|
<div class="ai-prompt-layout">
|
|
30
30
|
<el-form label-position="top" @submit.prevent="addPreset">
|
|
31
31
|
<el-form-item label="测试场景">
|
|
@@ -48,19 +48,16 @@ function addPreset() {
|
|
|
48
48
|
</template>
|
|
49
49
|
|
|
50
50
|
<style scoped>
|
|
51
|
-
.ai-prompt-
|
|
52
|
-
.ai-prompt-settings h3 { margin: 0 0 8px; font-size: 15px; font-weight: 600; }
|
|
51
|
+
.ai-prompt-visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
53
52
|
.ai-prompt-description { margin: 0 0 20px; color: var(--el-text-color-secondary); font-size: 12px; }
|
|
54
|
-
.ai-prompt-layout { display: grid;
|
|
53
|
+
.ai-prompt-layout { display: grid; gap: 20px; }
|
|
55
54
|
.ai-prompt-actions { display: flex; justify-content: flex-end; }
|
|
56
|
-
.ai-prompt-list { min-width: 0;
|
|
57
|
-
.ai-prompt-row { display: flex; align-items: flex-start; gap: 12px; padding: 10px 0; }
|
|
55
|
+
.ai-prompt-list { --ai-prompt-row-height: 76px; min-width: 0; height: calc(var(--ai-prompt-row-height) * 3 + 1px); overflow-y: auto; scrollbar-gutter: stable; border-top: 1px solid var(--ui-border); }
|
|
56
|
+
.ai-prompt-row { display: flex; align-items: flex-start; gap: 12px; height: var(--ai-prompt-row-height); box-sizing: border-box; padding: 10px 0; }
|
|
58
57
|
.ai-prompt-row + .ai-prompt-row { border-top: 1px solid var(--ui-border); }
|
|
59
58
|
.ai-prompt-content { flex: 1; min-width: 0; font-size: 12px; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.6; }
|
|
60
|
-
.ai-prompt-content
|
|
59
|
+
.ai-prompt-content strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
60
|
+
.ai-prompt-content p { display: -webkit-box; margin: 4px 0 0; overflow: hidden; color: var(--el-text-color-secondary); -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
|
|
61
61
|
.ai-prompt-row .el-button { flex-shrink: 0; }
|
|
62
|
-
|
|
63
|
-
.ai-prompt-layout { grid-template-columns: minmax(0, 1fr); gap: 24px; }
|
|
64
|
-
.ai-prompt-list { padding: 16px 0 0; border-left: 0; border-top: 1px solid var(--ui-border); }
|
|
65
|
-
}
|
|
62
|
+
.ai-prompt-list :deep(.el-empty) { height: 100%; padding: 0; }
|
|
66
63
|
</style>
|