android-midscene-automation 0.1.26 → 0.1.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/FAQ.md +14 -0
- package/README.md +31 -3
- package/bin/android-midscene-automation.js +71 -1
- package/package.json +4 -1
- package/server/appium-recorder/appium-runner.ts +273 -28
- package/server/appium-recorder/report.ts +186 -3
- package/server/appium-recorder/repository.ts +24 -3
- package/server/appium-recorder/visual-change.ts +184 -0
- package/server/http-api.ts +3 -4
- package/src/appium-recorder/AppiumPage.vue +465 -139
- package/src/appium-recorder/api.ts +1 -0
- package/src/appium-recorder/components/FlowCanvas.vue +14 -1
- package/src/appium-recorder/components/FlowStepEditor.vue +121 -2
- package/src/appium-recorder/components/NestedConditionBranches.vue +26 -4
- package/src/appium-recorder/components/RecordedSteps.vue +13 -1
- package/src/appium-recorder/components/VisualChangeDialog.vue +151 -0
- package/src/appium-recorder/flow-copy.ts +47 -1
- package/src/appium-recorder/flow-graph.ts +101 -6
- package/src/appium-recorder/flow-labels.ts +6 -1
- package/src/appium-recorder/flow-normalize.ts +67 -0
- package/src/appium-recorder/flow-remove.ts +67 -0
- package/src/appium-recorder/types.ts +25 -1
- package/src/appium-recorder/visual-change-pairs.ts +147 -0
- package/src/appium-recorder/visual-change.ts +75 -0
- package/src/components/device/DevicePreviewPanel.vue +148 -4
- package/src/style.css +39 -0
|
@@ -34,7 +34,11 @@ export type InsertAction =
|
|
|
34
34
|
| 'assertText'
|
|
35
35
|
| 'waitDisappear'
|
|
36
36
|
| 'waitActivity'
|
|
37
|
-
| 'runScript'
|
|
37
|
+
| 'runScript'
|
|
38
|
+
| 'noop'
|
|
39
|
+
| 'visualChangeStart'
|
|
40
|
+
| 'visualChangeEnd'
|
|
41
|
+
| 'visualChange';
|
|
38
42
|
|
|
39
43
|
export const PASTE_COMMAND = '__paste_flow_nodes__';
|
|
40
44
|
export const FLOW_STEP_NODE_WIDTH = 340;
|
|
@@ -50,6 +54,7 @@ const FLOW_STANDARD_LINE_GAP = 24;
|
|
|
50
54
|
const FLOW_BRANCH_TRUNK_GAP = 96;
|
|
51
55
|
const FLOW_BRANCH_LABEL_GAP = 58;
|
|
52
56
|
const FLOW_BRANCH_MIN_SPREAD = 260;
|
|
57
|
+
const FLOW_BRANCH_SUBTREE_GAP = 80;
|
|
53
58
|
|
|
54
59
|
export type FlowActionGroup = {
|
|
55
60
|
title: string;
|
|
@@ -137,9 +142,16 @@ type Link = {
|
|
|
137
142
|
visual?: boolean;
|
|
138
143
|
};
|
|
139
144
|
|
|
145
|
+
type BranchLayout = {
|
|
146
|
+
yesWidth: number;
|
|
147
|
+
noWidth: number;
|
|
148
|
+
centerGap: number;
|
|
149
|
+
totalWidth: number;
|
|
150
|
+
};
|
|
151
|
+
|
|
140
152
|
function defaultKind(step: AppiumRecordedStep): FlowKind {
|
|
141
153
|
if (step.flow?.nodeKind) return step.flow.nodeKind;
|
|
142
|
-
if (step.type === 'assertExists' || step.type === 'assertText') return 'assertion';
|
|
154
|
+
if (step.type === 'assertExists' || step.type === 'assertText' || step.type === 'visualChange') return 'assertion';
|
|
143
155
|
return 'action';
|
|
144
156
|
}
|
|
145
157
|
|
|
@@ -232,6 +244,21 @@ function estimateStepNodeHeight(label: { title: string; meta: string; note?: str
|
|
|
232
244
|
return Math.max(FLOW_STEP_NODE_HEIGHT, 34 + textHeight);
|
|
233
245
|
}
|
|
234
246
|
|
|
247
|
+
function withLaunchAppAction(groups: FlowActionGroup[]) {
|
|
248
|
+
if (groups.some((group) => group.actions.some((action) => action.type === 'launchApp'))) return groups;
|
|
249
|
+
const launchAction = { type: 'launchApp' as const, label: '启动 App' };
|
|
250
|
+
let added = false;
|
|
251
|
+
const nextGroups = groups.map((group) => {
|
|
252
|
+
if (group.title !== '设备操作') return group;
|
|
253
|
+
added = true;
|
|
254
|
+
return {
|
|
255
|
+
...group,
|
|
256
|
+
actions: [...group.actions, launchAction],
|
|
257
|
+
};
|
|
258
|
+
});
|
|
259
|
+
return added ? nextGroups : [...nextGroups, { title: '设备操作', actions: [launchAction] }];
|
|
260
|
+
}
|
|
261
|
+
|
|
235
262
|
export function buildFlowGraph(
|
|
236
263
|
steps: AppiumRecordedStep[],
|
|
237
264
|
options: BuildFlowGraphOptions,
|
|
@@ -242,9 +269,63 @@ export function buildFlowGraph(
|
|
|
242
269
|
const layoutLinks: Link[] = [];
|
|
243
270
|
const seenLinks = new Set<string>();
|
|
244
271
|
const allMainItems = mainItems(items);
|
|
272
|
+
const branchLayouts = new Map<string, BranchLayout>();
|
|
273
|
+
const measuringBranches = new Set<string>();
|
|
274
|
+
|
|
275
|
+
function conditionBranchLayout(condition: AppiumRecordedStep): BranchLayout {
|
|
276
|
+
const cached = branchLayouts.get(condition.id);
|
|
277
|
+
if (cached) return cached;
|
|
278
|
+
if (measuringBranches.has(condition.id)) {
|
|
279
|
+
return {
|
|
280
|
+
yesWidth: FLOW_STEP_NODE_WIDTH,
|
|
281
|
+
noWidth: FLOW_STEP_NODE_WIDTH,
|
|
282
|
+
centerGap: FLOW_BRANCH_MIN_SPREAD * 2,
|
|
283
|
+
totalWidth: FLOW_BRANCH_MIN_SPREAD * 2 + FLOW_STEP_NODE_WIDTH,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
measuringBranches.add(condition.id);
|
|
288
|
+
const branchWidth = (branch: FlowBranch) => Math.max(
|
|
289
|
+
FLOW_STEP_NODE_WIDTH,
|
|
290
|
+
...directBranchItems(items, condition.id, branch).map(({ step }) => (
|
|
291
|
+
defaultKind(step) === 'condition'
|
|
292
|
+
? conditionBranchLayout(step).totalWidth
|
|
293
|
+
: FLOW_STEP_NODE_WIDTH
|
|
294
|
+
)),
|
|
295
|
+
);
|
|
296
|
+
const yesWidth = branchWidth('yes');
|
|
297
|
+
const noWidth = branchWidth('no');
|
|
298
|
+
const centerGap = Math.max(
|
|
299
|
+
FLOW_BRANCH_MIN_SPREAD * 2,
|
|
300
|
+
yesWidth / 2 + noWidth / 2 + FLOW_BRANCH_SUBTREE_GAP,
|
|
301
|
+
);
|
|
302
|
+
const sideExtent = Math.max(
|
|
303
|
+
centerGap / 2 + yesWidth / 2,
|
|
304
|
+
centerGap / 2 + noWidth / 2,
|
|
305
|
+
);
|
|
306
|
+
const layout = {
|
|
307
|
+
yesWidth,
|
|
308
|
+
noWidth,
|
|
309
|
+
centerGap,
|
|
310
|
+
totalWidth: Math.max(FLOW_STEP_NODE_WIDTH, sideExtent * 2),
|
|
311
|
+
};
|
|
312
|
+
measuringBranches.delete(condition.id);
|
|
313
|
+
branchLayouts.set(condition.id, layout);
|
|
314
|
+
return layout;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
items
|
|
318
|
+
.filter(({ step }) => defaultKind(step) === 'condition')
|
|
319
|
+
.forEach(({ step }) => conditionBranchLayout(step));
|
|
245
320
|
|
|
246
321
|
const addNode = (node: FlowGraphNode) => {
|
|
247
|
-
nodes.push(
|
|
322
|
+
nodes.push({
|
|
323
|
+
...node,
|
|
324
|
+
dimensions: {
|
|
325
|
+
width: Number(node.width) || FLOW_STEP_NODE_WIDTH,
|
|
326
|
+
height: Number(node.height) || FLOW_STEP_NODE_HEIGHT,
|
|
327
|
+
},
|
|
328
|
+
} as FlowGraphNode);
|
|
248
329
|
};
|
|
249
330
|
const addVisibleLink = (source: string, target: string, branch?: FlowBranch) => {
|
|
250
331
|
addUniqueLink(links, seenLinks, {
|
|
@@ -266,6 +347,11 @@ export function buildFlowGraph(
|
|
|
266
347
|
) => {
|
|
267
348
|
const id = insertNodeId(afterIndex, branch, condition?.step.id);
|
|
268
349
|
const isStartInsert = afterIndex < 0 && !branch;
|
|
350
|
+
const previousStep = afterIndex >= 0 ? steps[afterIndex] : undefined;
|
|
351
|
+
const canLaunchAfterClearAppData = !isStartInsert
|
|
352
|
+
&& !branch
|
|
353
|
+
&& previousStep?.type === 'clearAppData'
|
|
354
|
+
&& !steps.some((step) => step.type === 'launchApp');
|
|
269
355
|
addNode({
|
|
270
356
|
id,
|
|
271
357
|
type: 'flow-node',
|
|
@@ -279,7 +365,11 @@ export function buildFlowGraph(
|
|
|
279
365
|
connectable: false,
|
|
280
366
|
data: {
|
|
281
367
|
kind: 'insert',
|
|
282
|
-
actionGroups: isStartInsert
|
|
368
|
+
actionGroups: isStartInsert
|
|
369
|
+
? options.startActionGroups
|
|
370
|
+
: canLaunchAfterClearAppData
|
|
371
|
+
? withLaunchAppAction(options.mainActionGroups)
|
|
372
|
+
: options.mainActionGroups,
|
|
283
373
|
afterIndex,
|
|
284
374
|
branch,
|
|
285
375
|
conditionIndex: condition?.index,
|
|
@@ -328,7 +418,9 @@ export function buildFlowGraph(
|
|
|
328
418
|
: options.disabled,
|
|
329
419
|
removeDisabled: options.removeDisabled,
|
|
330
420
|
launching: options.launchingStepId === item.step.id,
|
|
331
|
-
canCopy: item.step.type !== 'launchApp'
|
|
421
|
+
canCopy: item.step.type !== 'launchApp'
|
|
422
|
+
&& item.step.type !== 'clearAppData'
|
|
423
|
+
&& item.step.visualChange?.role !== 'end',
|
|
332
424
|
canEditInput: item.step.type === 'input' || item.step.type === 'inputIfExists',
|
|
333
425
|
canExecute: item.step.type === 'launchApp' || item.step.type === 'clearAppData',
|
|
334
426
|
},
|
|
@@ -527,7 +619,10 @@ export function buildFlowGraph(
|
|
|
527
619
|
const yesNode = nodeById.get(branchLinks.find((link) => link.branch === 'yes')?.target || '');
|
|
528
620
|
const noNode = nodeById.get(branchLinks.find((link) => link.branch === 'no')?.target || '');
|
|
529
621
|
const splitX = centerX(splitNode);
|
|
530
|
-
const
|
|
622
|
+
const conditionId = splitNode.id.startsWith('split:')
|
|
623
|
+
? splitNode.id.slice('split:'.length)
|
|
624
|
+
: '';
|
|
625
|
+
const spread = (branchLayouts.get(conditionId)?.centerGap || FLOW_BRANCH_MIN_SPREAD * 2) / 2;
|
|
531
626
|
if (yesNode) setCenterX(yesNode, splitX - spread);
|
|
532
627
|
if (noNode) setCenterX(noNode, splitX + spread);
|
|
533
628
|
});
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { FlowKind } from './flow-graph';
|
|
2
2
|
import type { AppiumRecordedStep } from './types';
|
|
3
|
+
import { visualChangeMeta } from './visual-change';
|
|
3
4
|
|
|
4
5
|
export function defaultFlowKind(step: AppiumRecordedStep): FlowKind {
|
|
5
6
|
if (step.flow?.nodeKind) return step.flow.nodeKind;
|
|
6
|
-
if (step.type === 'assertExists' || step.type === 'assertText') return 'assertion';
|
|
7
|
+
if (step.type === 'assertExists' || step.type === 'assertText' || step.type === 'visualChange') return 'assertion';
|
|
7
8
|
return 'action';
|
|
8
9
|
}
|
|
9
10
|
|
|
@@ -35,6 +36,8 @@ export function flowTypeLabel(step: AppiumRecordedStep) {
|
|
|
35
36
|
longPress: '长按',
|
|
36
37
|
pinch: '双指缩放',
|
|
37
38
|
runScript: '连接脚本',
|
|
39
|
+
noop: '空节点',
|
|
40
|
+
visualChange: '检测画面变化',
|
|
38
41
|
};
|
|
39
42
|
return labelMap[step.type] || step.type;
|
|
40
43
|
}
|
|
@@ -53,7 +56,9 @@ export function flowStepMeta(step: AppiumRecordedStep) {
|
|
|
53
56
|
if (step.type === 'launchApp') return step.value || '';
|
|
54
57
|
if (step.type === 'clearAppData') return `${step.value || ''}(清除数据与缓存)`;
|
|
55
58
|
if (step.type === 'runScript') return step.value ? `脚本 ${step.value}` : '';
|
|
59
|
+
if (step.type === 'noop') return '';
|
|
56
60
|
if (step.type === 'screenshot') return '保存当前截图';
|
|
61
|
+
if (step.type === 'visualChange') return visualChangeMeta(step);
|
|
57
62
|
if (step.type === 'swipe') {
|
|
58
63
|
const swipe = step.swipe;
|
|
59
64
|
return swipe ? `[${swipe.startX},${swipe.startY}] -> [${swipe.endX},${swipe.endY}]` : '';
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { AppiumRecordedStep } from './types';
|
|
2
|
+
|
|
3
|
+
type BranchName = 'yes' | 'no';
|
|
4
|
+
|
|
5
|
+
function isCondition(step: AppiumRecordedStep) {
|
|
6
|
+
return step.flow?.nodeKind === 'condition';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function isEmptyNestedCondition(step: AppiumRecordedStep) {
|
|
10
|
+
return isCondition(step)
|
|
11
|
+
&& Boolean(step.flow?.parentConditionId)
|
|
12
|
+
&& Boolean(step.flow?.parentBranch)
|
|
13
|
+
&& !step.flow?.yesTargetId
|
|
14
|
+
&& !step.flow?.noTargetId
|
|
15
|
+
&& !step.flow?.successTargetId;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function sameParentBranch(step: AppiumRecordedStep, parentConditionId: string, parentBranch: BranchName) {
|
|
19
|
+
return step.flow?.parentConditionId === parentConditionId
|
|
20
|
+
&& step.flow?.parentBranch === parentBranch;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function normalizeLegacyNestedConditionBranches<T extends AppiumRecordedStep>(steps: T[]) {
|
|
24
|
+
const nextSteps = [...steps];
|
|
25
|
+
let changed = false;
|
|
26
|
+
|
|
27
|
+
for (let index = 0; index < nextSteps.length; index += 1) {
|
|
28
|
+
const condition = nextSteps[index];
|
|
29
|
+
if (!condition || !isEmptyNestedCondition(condition)) continue;
|
|
30
|
+
|
|
31
|
+
const parentConditionId = condition.flow?.parentConditionId || '';
|
|
32
|
+
const parentBranch = condition.flow?.parentBranch;
|
|
33
|
+
if (!parentConditionId || !parentBranch) continue;
|
|
34
|
+
|
|
35
|
+
const childIndexes: number[] = [];
|
|
36
|
+
for (let childIndex = index + 1; childIndex < nextSteps.length; childIndex += 1) {
|
|
37
|
+
if (!sameParentBranch(nextSteps[childIndex], parentConditionId, parentBranch)) break;
|
|
38
|
+
childIndexes.push(childIndex);
|
|
39
|
+
}
|
|
40
|
+
if (!childIndexes.length) continue;
|
|
41
|
+
|
|
42
|
+
const firstChild = nextSteps[childIndexes[0]];
|
|
43
|
+
nextSteps[index] = {
|
|
44
|
+
...condition,
|
|
45
|
+
flow: {
|
|
46
|
+
...(condition.flow || {}),
|
|
47
|
+
nodeKind: 'condition',
|
|
48
|
+
yesTargetId: firstChild.id,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
childIndexes.forEach((childIndex) => {
|
|
53
|
+
const child = nextSteps[childIndex];
|
|
54
|
+
nextSteps[childIndex] = {
|
|
55
|
+
...child,
|
|
56
|
+
flow: {
|
|
57
|
+
...(child.flow || {}),
|
|
58
|
+
parentConditionId: condition.id,
|
|
59
|
+
parentBranch: 'yes',
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
changed = true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return changed ? nextSteps : steps;
|
|
67
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
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) {
|
|
17
|
+
if (removed.flow?.parentConditionId && removed.flow.parentBranch) {
|
|
18
|
+
return steps.slice(index + 1).find((step) => sameBranch(step, removed));
|
|
19
|
+
}
|
|
20
|
+
return steps.slice(index + 1).find((step) => !step.flow?.parentConditionId);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function cleanFlow(flow: NonNullable<AppiumRecordedStep['flow']>) {
|
|
24
|
+
return Object.keys(flow).length ? flow : undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function removeFlowStep(steps: AppiumRecordedStep[], index: number) {
|
|
28
|
+
const removed = steps[index];
|
|
29
|
+
if (!removed) return steps;
|
|
30
|
+
|
|
31
|
+
const sequentialStep = nextSequentialStep(steps, removed, index);
|
|
32
|
+
const replacementId = removed.flow?.successTargetId || sequentialStep?.id || '';
|
|
33
|
+
const removedParentConditionId = removed.flow?.parentConditionId;
|
|
34
|
+
const removedParentBranch = removed.flow?.parentBranch;
|
|
35
|
+
|
|
36
|
+
return steps
|
|
37
|
+
.filter((_step, stepIndex) => stepIndex !== index)
|
|
38
|
+
.map((step) => {
|
|
39
|
+
if (!step.flow) return step;
|
|
40
|
+
|
|
41
|
+
const flow = { ...step.flow };
|
|
42
|
+
let changed = false;
|
|
43
|
+
|
|
44
|
+
FLOW_TARGET_KEYS.forEach((key) => {
|
|
45
|
+
if (flow[key] !== removed.id) return;
|
|
46
|
+
if (replacementId) {
|
|
47
|
+
flow[key] = replacementId;
|
|
48
|
+
} else {
|
|
49
|
+
delete flow[key];
|
|
50
|
+
}
|
|
51
|
+
changed = true;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (flow.parentConditionId === removed.id) {
|
|
55
|
+
if (removedParentConditionId && removedParentBranch) {
|
|
56
|
+
flow.parentConditionId = removedParentConditionId;
|
|
57
|
+
flow.parentBranch = removedParentBranch;
|
|
58
|
+
} else {
|
|
59
|
+
delete flow.parentConditionId;
|
|
60
|
+
delete flow.parentBranch;
|
|
61
|
+
}
|
|
62
|
+
changed = true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return changed ? { ...step, flow: cleanFlow(flow) } : step;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -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;
|
|
@@ -65,7 +86,9 @@ export type AppiumRecordedStep = {
|
|
|
65
86
|
| 'assertText'
|
|
66
87
|
| 'longPress'
|
|
67
88
|
| 'pinch'
|
|
68
|
-
| 'runScript'
|
|
89
|
+
| 'runScript'
|
|
90
|
+
| 'noop'
|
|
91
|
+
| 'visualChange';
|
|
69
92
|
label: string;
|
|
70
93
|
note?: string;
|
|
71
94
|
selector?: AppiumSelector;
|
|
@@ -102,6 +125,7 @@ export type AppiumRecordedStep = {
|
|
|
102
125
|
centerY: number;
|
|
103
126
|
percent: number;
|
|
104
127
|
};
|
|
128
|
+
visualChange?: AppiumVisualChangeConfig;
|
|
105
129
|
snapshot?: {
|
|
106
130
|
text: string;
|
|
107
131
|
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
|
+
}
|