android-midscene-automation 0.1.21 → 0.1.22
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 +55 -128
- package/README.md +71 -145
- package/USAGE.md +16 -17
- package/bin/android-midscene-automation.js +4 -0
- package/package.json +35 -20
- package/server/appium-recorder/appium-runner.ts +43 -6
- package/server/appium-recorder/repository.ts +1 -0
- package/server/appium-recorder/routes.ts +14 -1
- package/server/http-api.ts +8 -4
- package/server/model-tester.ts +22 -6
- package/src/App.vue +4 -1
- package/src/api.ts +3 -1
- package/src/appium-recorder/AppiumPage.vue +175 -87
- package/src/appium-recorder/api.ts +4 -0
- package/src/appium-recorder/components/FlowCanvas.vue +201 -0
- package/src/appium-recorder/components/FlowNodeCard.vue +264 -0
- package/src/appium-recorder/components/FlowRoundedEdge.vue +67 -0
- package/src/appium-recorder/components/NestedConditionBranches.vue +24 -29
- package/src/appium-recorder/components/RecordedSteps.vue +119 -1457
- package/src/appium-recorder/flow-graph.ts +709 -0
- package/src/appium-recorder/flow-labels.ts +81 -0
- package/src/appium-recorder/types.ts +1 -0
- package/src/components/device/DevicePreviewPanel.vue +4 -1
- package/src/main.ts +3 -0
- package/src/style.css +387 -38
- package/src/ui-refresh.css +619 -0
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
import { Position, type Edge, type Node } from '@vue-flow/core';
|
|
2
|
+
import * as dagre from '@dagrejs/dagre';
|
|
3
|
+
import type { AppiumRecordedStep } from './types';
|
|
4
|
+
|
|
5
|
+
type DagreApi = typeof dagre;
|
|
6
|
+
const dagreApi = ((dagre as DagreApi & { default?: DagreApi }).layout
|
|
7
|
+
? dagre
|
|
8
|
+
: (dagre as DagreApi & { default?: DagreApi }).default || dagre) as DagreApi;
|
|
9
|
+
|
|
10
|
+
export type FlowKind = 'action' | 'condition' | 'assertion';
|
|
11
|
+
export type FlowBranch = 'yes' | 'no';
|
|
12
|
+
export 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
|
+
| 'clearAppData'
|
|
27
|
+
| 'popupCondition'
|
|
28
|
+
| 'tapIfExists'
|
|
29
|
+
| 'inputIfExists'
|
|
30
|
+
| 'clearIfExists'
|
|
31
|
+
| 'backIfExists'
|
|
32
|
+
| 'waitFor'
|
|
33
|
+
| 'assertExists'
|
|
34
|
+
| 'assertText'
|
|
35
|
+
| 'waitDisappear'
|
|
36
|
+
| 'waitActivity'
|
|
37
|
+
| 'runScript';
|
|
38
|
+
|
|
39
|
+
export const PASTE_COMMAND = '__paste_flow_nodes__';
|
|
40
|
+
export const FLOW_STEP_NODE_WIDTH = 340;
|
|
41
|
+
export const FLOW_STEP_NODE_HEIGHT = 78;
|
|
42
|
+
export const FLOW_EXPANDED_NODE_HEIGHT = 336;
|
|
43
|
+
export const FLOW_START_NODE_WIDTH = 180;
|
|
44
|
+
export const FLOW_START_NODE_HEIGHT = 62;
|
|
45
|
+
export const FLOW_INSERT_NODE_SIZE = 34;
|
|
46
|
+
export const FLOW_BRANCH_NODE_WIDTH = 34;
|
|
47
|
+
export const FLOW_BRANCH_NODE_HEIGHT = 26;
|
|
48
|
+
export const FLOW_SPLIT_NODE_SIZE = 10;
|
|
49
|
+
const FLOW_STANDARD_LINE_GAP = 24;
|
|
50
|
+
const FLOW_BRANCH_TRUNK_GAP = 96;
|
|
51
|
+
const FLOW_BRANCH_LABEL_GAP = 58;
|
|
52
|
+
const FLOW_BRANCH_MIN_SPREAD = 260;
|
|
53
|
+
|
|
54
|
+
export type FlowActionGroup = {
|
|
55
|
+
title: string;
|
|
56
|
+
actions: Array<{ type: InsertAction; label: string }>;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type FlowGraphNodeData =
|
|
60
|
+
| {
|
|
61
|
+
kind: 'start';
|
|
62
|
+
actionGroups: FlowActionGroup[];
|
|
63
|
+
clipboardCount?: number;
|
|
64
|
+
canOpenInsertMenu: boolean;
|
|
65
|
+
isActionDisabled: (action: InsertAction) => boolean;
|
|
66
|
+
}
|
|
67
|
+
| {
|
|
68
|
+
kind: 'step';
|
|
69
|
+
step: AppiumRecordedStep;
|
|
70
|
+
index: number;
|
|
71
|
+
flowKind: FlowKind;
|
|
72
|
+
title: string;
|
|
73
|
+
meta: string;
|
|
74
|
+
note?: string;
|
|
75
|
+
cardMinHeight: number;
|
|
76
|
+
expanded: boolean;
|
|
77
|
+
selected: boolean;
|
|
78
|
+
copyMode: boolean;
|
|
79
|
+
disabled?: boolean;
|
|
80
|
+
removeDisabled?: boolean;
|
|
81
|
+
launching?: boolean;
|
|
82
|
+
canCopy: boolean;
|
|
83
|
+
canEditInput: boolean;
|
|
84
|
+
canExecute: boolean;
|
|
85
|
+
}
|
|
86
|
+
| {
|
|
87
|
+
kind: 'insert';
|
|
88
|
+
actionGroups: FlowActionGroup[];
|
|
89
|
+
afterIndex: number;
|
|
90
|
+
branch?: FlowBranch;
|
|
91
|
+
conditionIndex?: number;
|
|
92
|
+
clipboardCount?: number;
|
|
93
|
+
canOpenInsertMenu: boolean;
|
|
94
|
+
isActionDisabled: (action: InsertAction) => boolean;
|
|
95
|
+
}
|
|
96
|
+
| {
|
|
97
|
+
kind: 'branch';
|
|
98
|
+
branch: FlowBranch;
|
|
99
|
+
conditionIndex: number;
|
|
100
|
+
}
|
|
101
|
+
| {
|
|
102
|
+
kind: 'split';
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export type FlowGraphNode = Node<FlowGraphNodeData>;
|
|
106
|
+
export type FlowGraphEdge = Edge<{ branch?: FlowBranch; soft?: boolean }>;
|
|
107
|
+
|
|
108
|
+
type StepItem = {
|
|
109
|
+
step: AppiumRecordedStep;
|
|
110
|
+
index: number;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
type BuildFlowGraphOptions = {
|
|
114
|
+
expandedStepIndex: number | null;
|
|
115
|
+
selectedCopyIndexes: number[];
|
|
116
|
+
copyMode: boolean;
|
|
117
|
+
disabled?: boolean;
|
|
118
|
+
removeDisabled?: boolean;
|
|
119
|
+
launchingStepId?: string;
|
|
120
|
+
clipboardCount?: number;
|
|
121
|
+
canOpenInsertMenu: boolean;
|
|
122
|
+
isStartActionDisabled: (action: InsertAction) => boolean;
|
|
123
|
+
isInsertActionDisabled: (action: InsertAction) => boolean;
|
|
124
|
+
isAppExecutionDisabled: (action: 'launchApp' | 'clearAppData') => boolean;
|
|
125
|
+
startActionGroups: FlowActionGroup[];
|
|
126
|
+
mainActionGroups: FlowActionGroup[];
|
|
127
|
+
measuredNodeHeights?: Record<string, number>;
|
|
128
|
+
labelStep: (step: AppiumRecordedStep) => { title: string; meta: string; note?: string };
|
|
129
|
+
isCopySelected: (index: number) => boolean;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
type Link = {
|
|
133
|
+
id: string;
|
|
134
|
+
source: string;
|
|
135
|
+
target: string;
|
|
136
|
+
branch?: FlowBranch;
|
|
137
|
+
visual?: boolean;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
function defaultKind(step: AppiumRecordedStep): FlowKind {
|
|
141
|
+
if (step.flow?.nodeKind) return step.flow.nodeKind;
|
|
142
|
+
if (step.type === 'assertExists' || step.type === 'assertText') return 'assertion';
|
|
143
|
+
return 'action';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function nodeIdForStep(step: AppiumRecordedStep) {
|
|
147
|
+
return `step:${step.id}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function branchNodeId(condition: AppiumRecordedStep, branch: FlowBranch) {
|
|
151
|
+
return `branch:${condition.id}:${branch}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function splitNodeId(condition: AppiumRecordedStep) {
|
|
155
|
+
return `split:${condition.id}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function insertNodeId(afterIndex: number, branch?: FlowBranch, conditionId?: string) {
|
|
159
|
+
return `insert:${conditionId || 'main'}:${branch || 'main'}:${afterIndex}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function collectDescendantIds(steps: AppiumRecordedStep[], rootIds: Iterable<string>) {
|
|
163
|
+
const ids = new Set(rootIds);
|
|
164
|
+
let changed = true;
|
|
165
|
+
while (changed) {
|
|
166
|
+
changed = false;
|
|
167
|
+
steps.forEach((step) => {
|
|
168
|
+
if (step.flow?.parentConditionId && ids.has(step.flow.parentConditionId) && !ids.has(step.id)) {
|
|
169
|
+
ids.add(step.id);
|
|
170
|
+
changed = true;
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return ids;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function directBranchItems(items: StepItem[], conditionId: string, branch: FlowBranch) {
|
|
178
|
+
return items.filter(({ step }) => (
|
|
179
|
+
step.flow?.parentConditionId === conditionId && step.flow.parentBranch === branch
|
|
180
|
+
));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function mainItems(items: StepItem[]) {
|
|
184
|
+
return items.filter(({ step }) => !step.flow?.parentConditionId);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function isDescendantStep(steps: AppiumRecordedStep[], descendantIndex: number, ancestorIndex: number) {
|
|
188
|
+
const ancestorId = steps[ancestorIndex]?.id;
|
|
189
|
+
let parentId = steps[descendantIndex]?.flow?.parentConditionId;
|
|
190
|
+
while (ancestorId && parentId) {
|
|
191
|
+
if (parentId === ancestorId) return true;
|
|
192
|
+
parentId = steps.find((item) => item.id === parentId)?.flow?.parentConditionId;
|
|
193
|
+
}
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function followingMainStepId(items: StepItem[], index: number) {
|
|
198
|
+
return items.slice(index + 1).find(({ step }) => !step.flow?.parentConditionId)?.step.id || '';
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function addUniqueLink(links: Link[], seen: Set<string>, link: Link) {
|
|
202
|
+
if (!link.target || link.source === link.target) return;
|
|
203
|
+
const key = `${link.source}->${link.target}:${link.id}`;
|
|
204
|
+
if (seen.has(key)) return;
|
|
205
|
+
seen.add(key);
|
|
206
|
+
links.push(link);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function branchConnectionTargetId(items: StepItem[], condition: AppiumRecordedStep, branch: FlowBranch) {
|
|
210
|
+
const branchItems = directBranchItems(items, condition.id, branch);
|
|
211
|
+
if (branchItems.length) {
|
|
212
|
+
const last = branchItems[branchItems.length - 1].step;
|
|
213
|
+
return defaultKind(last) === 'condition' ? '' : last.flow?.successTargetId || '';
|
|
214
|
+
}
|
|
215
|
+
const targetId = branch === 'yes' ? condition.flow?.yesTargetId : condition.flow?.noTargetId;
|
|
216
|
+
const target = items.find(({ step }) => step.id === targetId)?.step;
|
|
217
|
+
return target && !target.flow?.parentConditionId ? target.id : '';
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function estimateTextLines(text: string | undefined, charsPerLine: number) {
|
|
221
|
+
if (!text) return 0;
|
|
222
|
+
return text.split('\n').reduce((total, line) => (
|
|
223
|
+
total + Math.max(1, Math.ceil(Array.from(line).length / charsPerLine))
|
|
224
|
+
), 0);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function estimateStepNodeHeight(label: { title: string; meta: string; note?: string }) {
|
|
228
|
+
const titleLines = estimateTextLines(label.title, 20);
|
|
229
|
+
const metaLines = estimateTextLines(label.meta, 24);
|
|
230
|
+
const noteLines = estimateTextLines(label.note, 24);
|
|
231
|
+
const textHeight = titleLines * 19 + metaLines * 17 + noteLines * 17;
|
|
232
|
+
return Math.max(FLOW_STEP_NODE_HEIGHT, 34 + textHeight);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function buildFlowGraph(
|
|
236
|
+
steps: AppiumRecordedStep[],
|
|
237
|
+
options: BuildFlowGraphOptions,
|
|
238
|
+
) {
|
|
239
|
+
const items = steps.map((step, index) => ({ step, index }));
|
|
240
|
+
const nodes: FlowGraphNode[] = [];
|
|
241
|
+
const links: Link[] = [];
|
|
242
|
+
const layoutLinks: Link[] = [];
|
|
243
|
+
const seenLinks = new Set<string>();
|
|
244
|
+
const allMainItems = mainItems(items);
|
|
245
|
+
|
|
246
|
+
const addNode = (node: FlowGraphNode) => {
|
|
247
|
+
nodes.push(node);
|
|
248
|
+
};
|
|
249
|
+
const addVisibleLink = (source: string, target: string, branch?: FlowBranch) => {
|
|
250
|
+
addUniqueLink(links, seenLinks, {
|
|
251
|
+
id: `e:${source}:${target}:${branch || 'main'}`,
|
|
252
|
+
source,
|
|
253
|
+
target,
|
|
254
|
+
branch,
|
|
255
|
+
visual: true,
|
|
256
|
+
});
|
|
257
|
+
};
|
|
258
|
+
const addLayoutLink = (source: string, target: string) => {
|
|
259
|
+
layoutLinks.push({ id: `layout:${source}:${target}`, source, target });
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const addInsertNode = (
|
|
263
|
+
afterIndex: number,
|
|
264
|
+
branch?: FlowBranch,
|
|
265
|
+
condition?: StepItem,
|
|
266
|
+
) => {
|
|
267
|
+
const id = insertNodeId(afterIndex, branch, condition?.step.id);
|
|
268
|
+
const isStartInsert = afterIndex < 0 && !branch;
|
|
269
|
+
addNode({
|
|
270
|
+
id,
|
|
271
|
+
type: 'flow-node',
|
|
272
|
+
position: { x: 0, y: 0 },
|
|
273
|
+
sourcePosition: Position.Bottom,
|
|
274
|
+
targetPosition: Position.Top,
|
|
275
|
+
width: FLOW_INSERT_NODE_SIZE,
|
|
276
|
+
height: FLOW_INSERT_NODE_SIZE,
|
|
277
|
+
selectable: false,
|
|
278
|
+
draggable: false,
|
|
279
|
+
connectable: false,
|
|
280
|
+
data: {
|
|
281
|
+
kind: 'insert',
|
|
282
|
+
actionGroups: isStartInsert ? options.startActionGroups : options.mainActionGroups,
|
|
283
|
+
afterIndex,
|
|
284
|
+
branch,
|
|
285
|
+
conditionIndex: condition?.index,
|
|
286
|
+
clipboardCount: options.clipboardCount,
|
|
287
|
+
canOpenInsertMenu: options.canOpenInsertMenu,
|
|
288
|
+
isActionDisabled: isStartInsert ? options.isStartActionDisabled : options.isInsertActionDisabled,
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
return id;
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const addStepNode = (item: StepItem) => {
|
|
295
|
+
const id = nodeIdForStep(item.step);
|
|
296
|
+
const flowKind = defaultKind(item.step);
|
|
297
|
+
const label = options.labelStep(item.step);
|
|
298
|
+
const cardMinHeight = estimateStepNodeHeight(label);
|
|
299
|
+
const estimatedNodeHeight = options.expandedStepIndex === item.index
|
|
300
|
+
? cardMinHeight + FLOW_EXPANDED_NODE_HEIGHT
|
|
301
|
+
: cardMinHeight;
|
|
302
|
+
const nodeHeight = Math.max(estimatedNodeHeight, Math.ceil(options.measuredNodeHeights?.[id] || 0));
|
|
303
|
+
addNode({
|
|
304
|
+
id,
|
|
305
|
+
type: 'flow-node',
|
|
306
|
+
position: { x: 0, y: 0 },
|
|
307
|
+
sourcePosition: Position.Bottom,
|
|
308
|
+
targetPosition: Position.Top,
|
|
309
|
+
width: FLOW_STEP_NODE_WIDTH,
|
|
310
|
+
height: nodeHeight,
|
|
311
|
+
selectable: false,
|
|
312
|
+
draggable: false,
|
|
313
|
+
connectable: false,
|
|
314
|
+
data: {
|
|
315
|
+
kind: 'step',
|
|
316
|
+
step: item.step,
|
|
317
|
+
index: item.index,
|
|
318
|
+
flowKind,
|
|
319
|
+
title: label.title,
|
|
320
|
+
meta: label.meta,
|
|
321
|
+
note: label.note,
|
|
322
|
+
cardMinHeight,
|
|
323
|
+
expanded: options.expandedStepIndex === item.index,
|
|
324
|
+
selected: options.isCopySelected(item.index),
|
|
325
|
+
copyMode: options.copyMode,
|
|
326
|
+
disabled: item.step.type === 'launchApp' || item.step.type === 'clearAppData'
|
|
327
|
+
? options.isAppExecutionDisabled(item.step.type)
|
|
328
|
+
: options.disabled,
|
|
329
|
+
removeDisabled: options.removeDisabled,
|
|
330
|
+
launching: options.launchingStepId === item.step.id,
|
|
331
|
+
canCopy: item.step.type !== 'launchApp' && item.step.type !== 'clearAppData',
|
|
332
|
+
canEditInput: item.step.type === 'input' || item.step.type === 'inputIfExists',
|
|
333
|
+
canExecute: item.step.type === 'launchApp' || item.step.type === 'clearAppData',
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
const addBranchNode = (condition: StepItem, branch: FlowBranch) => {
|
|
339
|
+
const id = branchNodeId(condition.step, branch);
|
|
340
|
+
addNode({
|
|
341
|
+
id,
|
|
342
|
+
type: 'flow-node',
|
|
343
|
+
position: { x: 0, y: 0 },
|
|
344
|
+
sourcePosition: Position.Bottom,
|
|
345
|
+
targetPosition: Position.Top,
|
|
346
|
+
width: FLOW_BRANCH_NODE_WIDTH,
|
|
347
|
+
height: FLOW_BRANCH_NODE_HEIGHT,
|
|
348
|
+
selectable: false,
|
|
349
|
+
draggable: false,
|
|
350
|
+
connectable: false,
|
|
351
|
+
data: { kind: 'branch', branch, conditionIndex: condition.index },
|
|
352
|
+
});
|
|
353
|
+
return id;
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
const addSplitNode = (condition: StepItem) => {
|
|
357
|
+
const id = splitNodeId(condition.step);
|
|
358
|
+
addNode({
|
|
359
|
+
id,
|
|
360
|
+
type: 'flow-node',
|
|
361
|
+
position: { x: 0, y: 0 },
|
|
362
|
+
sourcePosition: Position.Bottom,
|
|
363
|
+
targetPosition: Position.Top,
|
|
364
|
+
width: FLOW_SPLIT_NODE_SIZE,
|
|
365
|
+
height: FLOW_SPLIT_NODE_SIZE,
|
|
366
|
+
selectable: false,
|
|
367
|
+
draggable: false,
|
|
368
|
+
connectable: false,
|
|
369
|
+
data: { kind: 'split' },
|
|
370
|
+
});
|
|
371
|
+
return id;
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
addNode({
|
|
375
|
+
id: 'start',
|
|
376
|
+
type: 'flow-node',
|
|
377
|
+
position: { x: 0, y: 0 },
|
|
378
|
+
sourcePosition: Position.Bottom,
|
|
379
|
+
width: FLOW_START_NODE_WIDTH,
|
|
380
|
+
height: FLOW_START_NODE_HEIGHT,
|
|
381
|
+
selectable: false,
|
|
382
|
+
draggable: false,
|
|
383
|
+
connectable: false,
|
|
384
|
+
data: {
|
|
385
|
+
kind: 'start',
|
|
386
|
+
actionGroups: options.startActionGroups,
|
|
387
|
+
clipboardCount: options.clipboardCount,
|
|
388
|
+
canOpenInsertMenu: options.canOpenInsertMenu,
|
|
389
|
+
isActionDisabled: options.isStartActionDisabled,
|
|
390
|
+
},
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
items.forEach(addStepNode);
|
|
394
|
+
|
|
395
|
+
const startInsertId = addInsertNode(-1);
|
|
396
|
+
addVisibleLink('start', startInsertId);
|
|
397
|
+
if (allMainItems[0]) addVisibleLink(startInsertId, nodeIdForStep(allMainItems[0].step));
|
|
398
|
+
|
|
399
|
+
const stepById = new Map(items.map((item) => [item.step.id, item]));
|
|
400
|
+
const wireBranch = (condition: StepItem, branch: FlowBranch, splitId: string) => {
|
|
401
|
+
const labelId = addBranchNode(condition, branch);
|
|
402
|
+
const directItems = directBranchItems(items, condition.step.id, branch);
|
|
403
|
+
const connectedTargetId = branchConnectionTargetId(items, condition.step, branch);
|
|
404
|
+
const targetStep = connectedTargetId ? stepById.get(connectedTargetId) : undefined;
|
|
405
|
+
addVisibleLink(splitId, labelId, branch);
|
|
406
|
+
if (directItems[0]) {
|
|
407
|
+
addVisibleLink(labelId, nodeIdForStep(directItems[0].step), branch);
|
|
408
|
+
} else {
|
|
409
|
+
const entryInsertId = addInsertNode(condition.index, branch, condition);
|
|
410
|
+
addVisibleLink(labelId, entryInsertId, branch);
|
|
411
|
+
if (targetStep) {
|
|
412
|
+
addVisibleLink(entryInsertId, nodeIdForStep(targetStep.step), branch);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
directItems.forEach((item, itemIndex) => {
|
|
417
|
+
wireStepContinuation(item, directItems[itemIndex + 1], branch, condition);
|
|
418
|
+
});
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
function wireStepContinuation(
|
|
422
|
+
item: StepItem,
|
|
423
|
+
nextSibling?: StepItem,
|
|
424
|
+
branch?: FlowBranch,
|
|
425
|
+
condition?: StepItem,
|
|
426
|
+
) {
|
|
427
|
+
const kind = defaultKind(item.step);
|
|
428
|
+
if (kind === 'condition') {
|
|
429
|
+
const splitId = addSplitNode(item);
|
|
430
|
+
addVisibleLink(nodeIdForStep(item.step), splitId);
|
|
431
|
+
wireBranch(item, 'yes', splitId);
|
|
432
|
+
wireBranch(item, 'no', splitId);
|
|
433
|
+
const nextTarget = item.step.flow?.successTargetId
|
|
434
|
+
? stepById.get(item.step.flow.successTargetId)
|
|
435
|
+
: nextSibling;
|
|
436
|
+
if (nextTarget) addLayoutLink(nodeIdForStep(item.step), nodeIdForStep(nextTarget.step));
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const targetId = item.step.flow?.successTargetId;
|
|
441
|
+
const explicitTarget = targetId ? stepById.get(targetId) : undefined;
|
|
442
|
+
const explicitMainTarget = explicitTarget && !explicitTarget.step.flow?.parentConditionId
|
|
443
|
+
? explicitTarget
|
|
444
|
+
: undefined;
|
|
445
|
+
const target = explicitMainTarget || nextSibling;
|
|
446
|
+
const insertId = addInsertNode(
|
|
447
|
+
item.index,
|
|
448
|
+
branch,
|
|
449
|
+
condition,
|
|
450
|
+
);
|
|
451
|
+
addVisibleLink(nodeIdForStep(item.step), insertId, branch);
|
|
452
|
+
if (!target) return;
|
|
453
|
+
if (branch && explicitMainTarget) {
|
|
454
|
+
addVisibleLink(insertId, nodeIdForStep(target.step), branch);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
addVisibleLink(insertId, nodeIdForStep(target.step), branch);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
allMainItems.forEach((item, index) => {
|
|
461
|
+
wireStepContinuation(item, allMainItems[index + 1]);
|
|
462
|
+
if (defaultKind(item.step) === 'condition' && allMainItems[index + 1]) {
|
|
463
|
+
addLayoutLink(nodeIdForStep(item.step), nodeIdForStep(allMainItems[index + 1].step));
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
const selectedDescendantIds = collectDescendantIds(
|
|
468
|
+
steps,
|
|
469
|
+
options.selectedCopyIndexes.map((index) => steps[index]?.id).filter((id): id is string => Boolean(id)),
|
|
470
|
+
);
|
|
471
|
+
nodes.forEach((node) => {
|
|
472
|
+
const data = node.data;
|
|
473
|
+
if (!data || data.kind !== 'step') return;
|
|
474
|
+
data.selected = data.selected || selectedDescendantIds.has(data.step.id);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
const dagreGraph = new dagreApi.graphlib.Graph();
|
|
478
|
+
dagreGraph.setDefaultEdgeLabel(() => ({}));
|
|
479
|
+
dagreGraph.setGraph({
|
|
480
|
+
rankdir: 'TB',
|
|
481
|
+
nodesep: 90,
|
|
482
|
+
edgesep: 24,
|
|
483
|
+
ranksep: 58,
|
|
484
|
+
marginx: 36,
|
|
485
|
+
marginy: 28,
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
nodes.forEach((node) => {
|
|
489
|
+
dagreGraph.setNode(node.id, {
|
|
490
|
+
width: Number(node.width) || FLOW_STEP_NODE_WIDTH,
|
|
491
|
+
height: Number(node.height) || FLOW_STEP_NODE_HEIGHT,
|
|
492
|
+
});
|
|
493
|
+
});
|
|
494
|
+
[...links, ...layoutLinks].forEach((link) => {
|
|
495
|
+
dagreGraph.setEdge(link.source, link.target, { weight: link.visual ? 2 : 1 });
|
|
496
|
+
});
|
|
497
|
+
dagreApi.layout(dagreGraph);
|
|
498
|
+
|
|
499
|
+
const positionedNodes = nodes.map((node) => {
|
|
500
|
+
const layoutNode = dagreGraph.node(node.id);
|
|
501
|
+
const width = Number(node.width) || FLOW_STEP_NODE_WIDTH;
|
|
502
|
+
const height = Number(node.height) || FLOW_STEP_NODE_HEIGHT;
|
|
503
|
+
return {
|
|
504
|
+
...node,
|
|
505
|
+
position: {
|
|
506
|
+
x: Math.round((layoutNode?.x || 0) - width / 2),
|
|
507
|
+
y: Math.round((layoutNode?.y || 0) - height / 2),
|
|
508
|
+
},
|
|
509
|
+
};
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
const nodeById = new Map(positionedNodes.map((node) => [node.id, node]));
|
|
513
|
+
const nodeWidth = (node: FlowGraphNode) => Number(node.width) || FLOW_STEP_NODE_WIDTH;
|
|
514
|
+
const nodeHeight = (node: FlowGraphNode) => Number(node.height) || FLOW_STEP_NODE_HEIGHT;
|
|
515
|
+
const centerX = (node: FlowGraphNode) => (
|
|
516
|
+
node.position.x + nodeWidth(node) / 2
|
|
517
|
+
);
|
|
518
|
+
const setCenterX = (node: FlowGraphNode, x: number) => {
|
|
519
|
+
node.position.x = Math.round(x - nodeWidth(node) / 2);
|
|
520
|
+
};
|
|
521
|
+
const normalizeBranchPositions = () => {
|
|
522
|
+
positionedNodes
|
|
523
|
+
.filter((node) => node.data?.kind === 'split')
|
|
524
|
+
.forEach((splitNode) => {
|
|
525
|
+
const branchLinks = links.filter((link) => link.source === splitNode.id && link.branch);
|
|
526
|
+
const yesNode = nodeById.get(branchLinks.find((link) => link.branch === 'yes')?.target || '');
|
|
527
|
+
const noNode = nodeById.get(branchLinks.find((link) => link.branch === 'no')?.target || '');
|
|
528
|
+
const splitX = centerX(splitNode);
|
|
529
|
+
const spread = FLOW_BRANCH_MIN_SPREAD;
|
|
530
|
+
if (yesNode) setCenterX(yesNode, splitX - spread);
|
|
531
|
+
if (noNode) setCenterX(noNode, splitX + spread);
|
|
532
|
+
});
|
|
533
|
+
};
|
|
534
|
+
const canAlignStepAfterFlowPoint = (sourceNode: FlowGraphNode, stepNode: FlowGraphNode) => {
|
|
535
|
+
if (stepNode.data?.kind !== 'step') return false;
|
|
536
|
+
return sourceNode.data?.kind === 'insert' || sourceNode.data?.kind === 'branch';
|
|
537
|
+
};
|
|
538
|
+
const outgoingLinks = [...links, ...layoutLinks].reduce((map, link) => {
|
|
539
|
+
const targets = map.get(link.source) || [];
|
|
540
|
+
targets.push(link.target);
|
|
541
|
+
map.set(link.source, targets);
|
|
542
|
+
return map;
|
|
543
|
+
}, new Map<string, string[]>());
|
|
544
|
+
const moveNodeTree = (rootId: string, deltaY: number) => {
|
|
545
|
+
const queue = [rootId];
|
|
546
|
+
const seen = new Set<string>();
|
|
547
|
+
while (queue.length) {
|
|
548
|
+
const id = queue.shift();
|
|
549
|
+
if (!id || seen.has(id)) continue;
|
|
550
|
+
seen.add(id);
|
|
551
|
+
const node = nodeById.get(id);
|
|
552
|
+
if (node) node.position.y = Math.round(node.position.y + deltaY);
|
|
553
|
+
(outgoingLinks.get(id) || []).forEach((targetId) => queue.push(targetId));
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
const collisionOrder = (node: FlowGraphNode) => {
|
|
557
|
+
const data = node.data;
|
|
558
|
+
if (data?.kind === 'step') return data.index;
|
|
559
|
+
if (data?.kind === 'insert') return data.afterIndex + 0.5;
|
|
560
|
+
if (data?.kind === 'branch') return data.conditionIndex + (data.branch === 'yes' ? 0.1 : 0.2);
|
|
561
|
+
return -1;
|
|
562
|
+
};
|
|
563
|
+
const resolveStepCollisions = () => {
|
|
564
|
+
for (let pass = 0; pass < 12; pass += 1) {
|
|
565
|
+
let moved = false;
|
|
566
|
+
const layoutNodes = positionedNodes.filter((node) => (
|
|
567
|
+
node.data?.kind === 'step' || node.data?.kind === 'insert' || node.data?.kind === 'branch'
|
|
568
|
+
));
|
|
569
|
+
for (let aIndex = 0; aIndex < layoutNodes.length; aIndex += 1) {
|
|
570
|
+
for (let bIndex = aIndex + 1; bIndex < layoutNodes.length; bIndex += 1) {
|
|
571
|
+
const a = layoutNodes[aIndex];
|
|
572
|
+
const b = layoutNodes[bIndex];
|
|
573
|
+
const xOverlap = Math.min(a.position.x + nodeWidth(a), b.position.x + nodeWidth(b))
|
|
574
|
+
- Math.max(a.position.x, b.position.x);
|
|
575
|
+
const yOverlap = Math.min(a.position.y + nodeHeight(a), b.position.y + nodeHeight(b))
|
|
576
|
+
- Math.max(a.position.y, b.position.y);
|
|
577
|
+
if (xOverlap <= 4 || yOverlap <= 4) continue;
|
|
578
|
+
|
|
579
|
+
const movingNode = collisionOrder(a) > collisionOrder(b) ? a : b;
|
|
580
|
+
const blockingNode = movingNode === a ? b : a;
|
|
581
|
+
const nextY = blockingNode.position.y + nodeHeight(blockingNode) + FLOW_STANDARD_LINE_GAP;
|
|
582
|
+
if (movingNode.position.y >= nextY) continue;
|
|
583
|
+
moveNodeTree(movingNode.id, nextY - movingNode.position.y);
|
|
584
|
+
moved = true;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
if (!moved) break;
|
|
588
|
+
}
|
|
589
|
+
};
|
|
590
|
+
const resolveInsertCrowding = () => {
|
|
591
|
+
for (let pass = 0; pass < 8; pass += 1) {
|
|
592
|
+
let moved = false;
|
|
593
|
+
const insertNodes = positionedNodes.filter((node) => node.data?.kind === 'insert');
|
|
594
|
+
const stepNodes = positionedNodes.filter((node) => node.data?.kind === 'step');
|
|
595
|
+
for (let aIndex = 0; aIndex < insertNodes.length; aIndex += 1) {
|
|
596
|
+
for (let bIndex = aIndex + 1; bIndex < insertNodes.length; bIndex += 1) {
|
|
597
|
+
const a = insertNodes[aIndex];
|
|
598
|
+
const b = insertNodes[bIndex];
|
|
599
|
+
if (Math.abs(centerX(a) - centerX(b)) > 2) continue;
|
|
600
|
+
const upper = a.position.y <= b.position.y ? a : b;
|
|
601
|
+
const lower = upper === a ? b : a;
|
|
602
|
+
const hasStepBetween = stepNodes.some((stepNode) => {
|
|
603
|
+
const stepLeft = stepNode.position.x;
|
|
604
|
+
const stepRight = stepNode.position.x + nodeWidth(stepNode);
|
|
605
|
+
const columnX = centerX(a);
|
|
606
|
+
return columnX >= stepLeft
|
|
607
|
+
&& columnX <= stepRight
|
|
608
|
+
&& stepNode.position.y < lower.position.y
|
|
609
|
+
&& stepNode.position.y + nodeHeight(stepNode) > upper.position.y + nodeHeight(upper);
|
|
610
|
+
});
|
|
611
|
+
if (hasStepBetween) continue;
|
|
612
|
+
const gap = lower.position.y - (upper.position.y + nodeHeight(upper));
|
|
613
|
+
const minGap = FLOW_INSERT_NODE_SIZE + FLOW_STANDARD_LINE_GAP * 3;
|
|
614
|
+
const minInlineGap = FLOW_INSERT_NODE_SIZE + FLOW_STANDARD_LINE_GAP * 6;
|
|
615
|
+
|
|
616
|
+
const movingNode = collisionOrder(a) > collisionOrder(b) ? a : b;
|
|
617
|
+
const blockingNode = movingNode === a ? b : a;
|
|
618
|
+
if (gap < minInlineGap) {
|
|
619
|
+
const side = movingNode.data?.kind === 'insert' && movingNode.data.branch === 'no' ? 1 : -1;
|
|
620
|
+
setCenterX(movingNode, centerX(movingNode) + side * (FLOW_INSERT_NODE_SIZE + FLOW_STANDARD_LINE_GAP * 2));
|
|
621
|
+
moved = true;
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const nextY = Math.max(
|
|
626
|
+
blockingNode.position.y + nodeHeight(blockingNode) + minGap,
|
|
627
|
+
lower.position.y + Math.max(0, minGap - gap),
|
|
628
|
+
);
|
|
629
|
+
if (movingNode.position.y >= nextY) continue;
|
|
630
|
+
moveNodeTree(movingNode.id, nextY - movingNode.position.y);
|
|
631
|
+
moved = true;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
if (!moved) break;
|
|
635
|
+
}
|
|
636
|
+
};
|
|
637
|
+
const alignVisibleChainGeometry = () => {
|
|
638
|
+
for (let pass = 0; pass < 4; pass += 1) {
|
|
639
|
+
links.forEach((link) => {
|
|
640
|
+
const source = nodeById.get(link.source);
|
|
641
|
+
const target = nodeById.get(link.target);
|
|
642
|
+
if (!source || !target?.data) return;
|
|
643
|
+
if (!['branch', 'split'].includes(target.data.kind)) return;
|
|
644
|
+
|
|
645
|
+
const lineGap = target.data.kind === 'split'
|
|
646
|
+
? FLOW_BRANCH_TRUNK_GAP
|
|
647
|
+
: FLOW_BRANCH_LABEL_GAP;
|
|
648
|
+
target.position.y = Math.round(source.position.y + nodeHeight(source) + lineGap);
|
|
649
|
+
if (target.data.kind === 'split') {
|
|
650
|
+
setCenterX(target, centerX(source));
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
normalizeBranchPositions();
|
|
654
|
+
|
|
655
|
+
links.forEach((link) => {
|
|
656
|
+
const source = nodeById.get(link.source);
|
|
657
|
+
const target = nodeById.get(link.target);
|
|
658
|
+
if (!source || target?.data?.kind !== 'insert') return;
|
|
659
|
+
target.position.y = Math.round(source.position.y + nodeHeight(source) + FLOW_STANDARD_LINE_GAP);
|
|
660
|
+
setCenterX(target, centerX(source));
|
|
661
|
+
});
|
|
662
|
+
alignableLinks.forEach((link) => {
|
|
663
|
+
const source = nodeById.get(link.source);
|
|
664
|
+
const target = nodeById.get(link.target);
|
|
665
|
+
if (!source || !target || !canAlignStepAfterFlowPoint(source, target)) return;
|
|
666
|
+
target.position.y = Math.round(source.position.y + nodeHeight(source) + FLOW_STANDARD_LINE_GAP);
|
|
667
|
+
setCenterX(target, centerX(source));
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
const alignableLinks = [...links, ...layoutLinks];
|
|
673
|
+
|
|
674
|
+
alignVisibleChainGeometry();
|
|
675
|
+
|
|
676
|
+
resolveStepCollisions();
|
|
677
|
+
alignVisibleChainGeometry();
|
|
678
|
+
resolveStepCollisions();
|
|
679
|
+
alignVisibleChainGeometry();
|
|
680
|
+
resolveStepCollisions();
|
|
681
|
+
resolveInsertCrowding();
|
|
682
|
+
alignVisibleChainGeometry();
|
|
683
|
+
resolveStepCollisions();
|
|
684
|
+
alignVisibleChainGeometry();
|
|
685
|
+
|
|
686
|
+
const edges: FlowGraphEdge[] = links.map((link) => ({
|
|
687
|
+
id: link.id,
|
|
688
|
+
source: link.source,
|
|
689
|
+
target: link.target,
|
|
690
|
+
type: 'flow-rounded',
|
|
691
|
+
sourceHandle: 'bottom',
|
|
692
|
+
targetHandle: 'top',
|
|
693
|
+
selectable: false,
|
|
694
|
+
focusable: false,
|
|
695
|
+
class: 'appium-vue-flow-edge',
|
|
696
|
+
data: { branch: link.branch },
|
|
697
|
+
}));
|
|
698
|
+
|
|
699
|
+
return { nodes: positionedNodes, edges };
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
export function canSelectCopyIndex(
|
|
703
|
+
steps: AppiumRecordedStep[],
|
|
704
|
+
selectedIndexes: number[],
|
|
705
|
+
index: number,
|
|
706
|
+
) {
|
|
707
|
+
if (selectedIndexes.includes(index)) return true;
|
|
708
|
+
return !selectedIndexes.some((selectedIndex) => isDescendantStep(steps, index, selectedIndex));
|
|
709
|
+
}
|