android-midscene-automation 0.1.13 → 0.1.14
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 +30 -0
- package/README.md +39 -51
- package/USAGE.md +1018 -0
- package/package.json +2 -1
- package/server/android-sdk.ts +104 -0
- package/server/appium-recorder/appium-runner.ts +144 -35
- package/server/appium-recorder/report.ts +138 -0
- package/server/appium-recorder/repository.ts +78 -0
- package/server/appium-recorder/routes.ts +141 -14
- package/server/config.ts +12 -0
- package/server/http-api.ts +9 -6
- package/src/App.vue +23 -23
- package/src/appium-recorder/AppiumPage.vue +403 -38
- package/src/appium-recorder/api.ts +62 -5
- package/src/appium-recorder/components/FlowStepEditor.vue +175 -0
- package/src/appium-recorder/components/RecordedSteps.vue +514 -277
- package/src/appium-recorder/types.ts +2 -0
- package/src/pages/ConfigPage.vue +28 -0
- package/src/style.css +162 -89
- package/src/types.ts +4 -0
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { computed, onMounted, reactive, shallowRef, watch } from 'vue';
|
|
2
|
+
import { computed, onMounted, onUnmounted, reactive, shallowRef, watch } from 'vue';
|
|
3
3
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
|
4
|
-
import { Check, CopyDocument, Delete, FullScreen, Refresh, VideoPlay } from '@element-plus/icons-vue';
|
|
4
|
+
import { Check, CopyDocument, Delete, Download, FullScreen, Refresh, Upload, VideoPlay } from '@element-plus/icons-vue';
|
|
5
5
|
import type { AndroidDevice, AppPreset, DeviceAction } from '../types';
|
|
6
6
|
import DevicePreviewPanel from '../components/device/DevicePreviewPanel.vue';
|
|
7
7
|
import {
|
|
8
|
+
appiumScriptDownloadUrl,
|
|
8
9
|
deleteAppiumScript,
|
|
9
10
|
getAppiumScripts,
|
|
10
11
|
getAppiumTree,
|
|
12
|
+
importAppiumScript,
|
|
13
|
+
launchAppiumDeviceApp,
|
|
11
14
|
replayAppiumScript,
|
|
12
15
|
saveAppiumScript,
|
|
13
16
|
tapAppiumDevice,
|
|
@@ -25,6 +28,8 @@ type BranchTarget = {
|
|
|
25
28
|
stepId: string;
|
|
26
29
|
branch: BranchName;
|
|
27
30
|
updateTarget?: boolean;
|
|
31
|
+
entryTargetId?: string;
|
|
32
|
+
nextTargetId?: string;
|
|
28
33
|
};
|
|
29
34
|
type RecorderAction =
|
|
30
35
|
| 'delay'
|
|
@@ -49,6 +54,36 @@ type RecorderAction =
|
|
|
49
54
|
| 'longPress'
|
|
50
55
|
| 'pinch';
|
|
51
56
|
|
|
57
|
+
const WORKBENCH_TAB_STORAGE_KEY = 'android-midscene-automation:appium-workbench-tab';
|
|
58
|
+
const AUTO_TREE_REFRESH_INTERVAL_MS = 1800;
|
|
59
|
+
|
|
60
|
+
const insertableRecorderActions: RecorderAction[] = [
|
|
61
|
+
'delay',
|
|
62
|
+
'tap',
|
|
63
|
+
'input',
|
|
64
|
+
'assertExists',
|
|
65
|
+
'waitFor',
|
|
66
|
+
'tapIfExists',
|
|
67
|
+
'inputIfExists',
|
|
68
|
+
'clearIfExists',
|
|
69
|
+
'backIfExists',
|
|
70
|
+
'popupCondition',
|
|
71
|
+
'runScript',
|
|
72
|
+
'keyBack',
|
|
73
|
+
'keyHome',
|
|
74
|
+
'keyRecent',
|
|
75
|
+
'keyPower',
|
|
76
|
+
'waitActivity',
|
|
77
|
+
'swipe',
|
|
78
|
+
'clearInput',
|
|
79
|
+
'coordinateTap',
|
|
80
|
+
'launchApp',
|
|
81
|
+
'waitDisappear',
|
|
82
|
+
'assertText',
|
|
83
|
+
'longPress',
|
|
84
|
+
'pinch',
|
|
85
|
+
];
|
|
86
|
+
|
|
52
87
|
const props = defineProps<{
|
|
53
88
|
active: boolean;
|
|
54
89
|
appPresets: AppPreset[];
|
|
@@ -78,7 +113,8 @@ const tree = shallowRef<AppiumNode | null>(null);
|
|
|
78
113
|
const selectedNode = shallowRef<AppiumNode | null>(null);
|
|
79
114
|
const scripts = shallowRef<AppiumRecordedScript[]>([]);
|
|
80
115
|
const selectedScriptId = shallowRef('');
|
|
81
|
-
const
|
|
116
|
+
const storedWorkbenchTab = window.localStorage.getItem(WORKBENCH_TAB_STORAGE_KEY);
|
|
117
|
+
const activeWorkbenchTab = shallowRef<'recording' | 'scripts'>(storedWorkbenchTab === 'scripts' ? 'scripts' : 'recording');
|
|
82
118
|
const steps = shallowRef<AppiumRecordedStep[]>([]);
|
|
83
119
|
const rawXml = shallowRef('');
|
|
84
120
|
const currentActivity = shallowRef('');
|
|
@@ -91,14 +127,21 @@ const linkedScriptBranchTarget = shallowRef<BranchTarget | null>(null);
|
|
|
91
127
|
const linkedScriptExpectedActivity = shallowRef('');
|
|
92
128
|
const loadingTree = shallowRef(false);
|
|
93
129
|
const saving = shallowRef(false);
|
|
130
|
+
const importingScript = shallowRef(false);
|
|
131
|
+
const scriptImportInput = shallowRef<HTMLInputElement | null>(null);
|
|
94
132
|
const deletingScriptId = shallowRef('');
|
|
95
133
|
const replaying = shallowRef(false);
|
|
96
134
|
const recordingTap = shallowRef(false);
|
|
97
135
|
const resolvingNavigation = shallowRef(false);
|
|
136
|
+
const launchingApp = shallowRef(false);
|
|
98
137
|
const pendingNavigation = shallowRef<{
|
|
99
138
|
beforeActivity: string;
|
|
100
139
|
afterActivity: string;
|
|
101
140
|
} | null>(null);
|
|
141
|
+
type TreePayload = Awaited<ReturnType<typeof getAppiumTree>>;
|
|
142
|
+
let treeRequest: Promise<TreePayload> | null = null;
|
|
143
|
+
let autoTreeRefreshTimer: number | null = null;
|
|
144
|
+
let autoTreeRefreshInFlight = false;
|
|
102
145
|
|
|
103
146
|
const form = reactive({
|
|
104
147
|
name: '',
|
|
@@ -106,11 +149,38 @@ const form = reactive({
|
|
|
106
149
|
appActivity: '',
|
|
107
150
|
});
|
|
108
151
|
|
|
152
|
+
const savedDraftSnapshot = shallowRef('');
|
|
153
|
+
const currentDraftSnapshot = computed(() => JSON.stringify({
|
|
154
|
+
name: form.name,
|
|
155
|
+
appPackage: form.appPackage,
|
|
156
|
+
appActivity: form.appActivity,
|
|
157
|
+
steps: steps.value,
|
|
158
|
+
}));
|
|
159
|
+
const hasUnsavedChanges = computed(() => currentDraftSnapshot.value !== savedDraftSnapshot.value);
|
|
160
|
+
|
|
161
|
+
function markDraftSaved() {
|
|
162
|
+
savedDraftSnapshot.value = currentDraftSnapshot.value;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function warnBeforeUnload(event: BeforeUnloadEvent) {
|
|
166
|
+
if (!props.active || !hasUnsavedChanges.value) return;
|
|
167
|
+
event.preventDefault();
|
|
168
|
+
event.returnValue = '';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
markDraftSaved();
|
|
172
|
+
|
|
109
173
|
const selectedScript = computed(() => scripts.value.find((script) => script.id === selectedScriptId.value) || null);
|
|
110
174
|
const linkableScripts = computed(() => scripts.value.filter((script) => script.id !== selectedScriptId.value));
|
|
111
175
|
const compatibleLinkableScripts = computed(() => linkableScripts.value.filter((script) => (
|
|
112
|
-
|
|
113
|
-
&&
|
|
176
|
+
script.appPackage === form.appPackage
|
|
177
|
+
&& (
|
|
178
|
+
Boolean(linkedScriptBranchTarget.value)
|
|
179
|
+
|| (
|
|
180
|
+
Boolean(linkedScriptExpectedActivity.value)
|
|
181
|
+
&& script.appActivity === linkedScriptExpectedActivity.value
|
|
182
|
+
)
|
|
183
|
+
)
|
|
114
184
|
)));
|
|
115
185
|
const visibleReplayOutput = computed(() => {
|
|
116
186
|
const lines = replayOutput.value.split(/\r?\n/);
|
|
@@ -142,15 +212,15 @@ function insertStep(step: AppiumRecordedStep, index?: number) {
|
|
|
142
212
|
const nextSteps = [...steps.value];
|
|
143
213
|
let insertedStep = step;
|
|
144
214
|
if (index === -1) {
|
|
145
|
-
nextSteps.unshift(
|
|
215
|
+
nextSteps.unshift(insertedStep);
|
|
146
216
|
} else if (typeof index === 'number') {
|
|
147
217
|
const previousStep = nextSteps[index];
|
|
148
218
|
const previousTargetId = previousStep?.flow?.successTargetId;
|
|
149
219
|
if (previousStep && previousTargetId) {
|
|
150
220
|
insertedStep = {
|
|
151
|
-
...
|
|
221
|
+
...insertedStep,
|
|
152
222
|
flow: {
|
|
153
|
-
...(
|
|
223
|
+
...(insertedStep.flow || {}),
|
|
154
224
|
successTargetId: previousTargetId,
|
|
155
225
|
},
|
|
156
226
|
};
|
|
@@ -384,6 +454,31 @@ function treeSignature(root: AppiumNode | null) {
|
|
|
384
454
|
.join('\n');
|
|
385
455
|
}
|
|
386
456
|
|
|
457
|
+
async function fetchTreePayload(forceFresh = false) {
|
|
458
|
+
if (forceFresh && treeRequest) await treeRequest.catch(() => undefined);
|
|
459
|
+
if (treeRequest) return treeRequest;
|
|
460
|
+
|
|
461
|
+
const request = getAppiumTree(selectedDeviceId.value);
|
|
462
|
+
treeRequest = request;
|
|
463
|
+
try {
|
|
464
|
+
return await request;
|
|
465
|
+
} finally {
|
|
466
|
+
if (treeRequest === request) treeRequest = null;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function applyTreeSnapshot(payload: TreePayload, parsedTree: AppiumNode | null, clearSelection: boolean) {
|
|
471
|
+
const previousSelection = clearSelection ? null : selectedNode.value;
|
|
472
|
+
rawXml.value = payload.xml;
|
|
473
|
+
currentActivity.value = payload.activity || '';
|
|
474
|
+
tree.value = parsedTree;
|
|
475
|
+
selectedNode.value = previousSelection
|
|
476
|
+
? flattenNodes(parsedTree).find((node) => (
|
|
477
|
+
node.id === previousSelection.id || node.xpath === previousSelection.xpath
|
|
478
|
+
)) || null
|
|
479
|
+
: null;
|
|
480
|
+
}
|
|
481
|
+
|
|
387
482
|
function currentPageSnapshot(node = selectedNode.value) {
|
|
388
483
|
return {
|
|
389
484
|
activity: currentActivity.value,
|
|
@@ -405,13 +500,10 @@ function getSelectedNode() {
|
|
|
405
500
|
return null;
|
|
406
501
|
}
|
|
407
502
|
|
|
408
|
-
async function loadTreeSnapshot(clearSelection = true) {
|
|
409
|
-
const payload = await
|
|
503
|
+
async function loadTreeSnapshot(clearSelection = true, forceFresh = false) {
|
|
504
|
+
const payload = await fetchTreePayload(forceFresh);
|
|
410
505
|
const parsedTree = parseWindowHierarchy(payload.xml);
|
|
411
|
-
|
|
412
|
-
currentActivity.value = payload.activity || '';
|
|
413
|
-
tree.value = parsedTree;
|
|
414
|
-
if (clearSelection) selectedNode.value = null;
|
|
506
|
+
applyTreeSnapshot(payload, parsedTree, clearSelection);
|
|
415
507
|
return {
|
|
416
508
|
activity: payload.activity || '',
|
|
417
509
|
signature: treeSignature(parsedTree),
|
|
@@ -419,6 +511,56 @@ async function loadTreeSnapshot(clearSelection = true) {
|
|
|
419
511
|
};
|
|
420
512
|
}
|
|
421
513
|
|
|
514
|
+
function stopAutoTreeRefresh() {
|
|
515
|
+
if (autoTreeRefreshTimer === null) return;
|
|
516
|
+
window.clearTimeout(autoTreeRefreshTimer);
|
|
517
|
+
autoTreeRefreshTimer = null;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function scheduleAutoTreeRefresh(delay = AUTO_TREE_REFRESH_INTERVAL_MS) {
|
|
521
|
+
stopAutoTreeRefresh();
|
|
522
|
+
if (!props.active || !selectedDeviceId.value || replaying.value) return;
|
|
523
|
+
autoTreeRefreshTimer = window.setTimeout(() => {
|
|
524
|
+
autoTreeRefreshTimer = null;
|
|
525
|
+
void syncTreeWhenPageChanges();
|
|
526
|
+
}, delay);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function pauseAutoTreeRefresh() {
|
|
530
|
+
stopAutoTreeRefresh();
|
|
531
|
+
const pendingRequest = treeRequest;
|
|
532
|
+
if (pendingRequest) await pendingRequest.catch(() => undefined);
|
|
533
|
+
await wait(300);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async function syncTreeWhenPageChanges() {
|
|
537
|
+
if (
|
|
538
|
+
autoTreeRefreshInFlight
|
|
539
|
+
|| loadingTree.value
|
|
540
|
+
|| recordingBusy.value
|
|
541
|
+
|| replaying.value
|
|
542
|
+
|| launchingApp.value
|
|
543
|
+
) {
|
|
544
|
+
scheduleAutoTreeRefresh();
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
autoTreeRefreshInFlight = true;
|
|
549
|
+
try {
|
|
550
|
+
const payload = await fetchTreePayload();
|
|
551
|
+
const changed = (payload.activity || '') !== currentActivity.value
|
|
552
|
+
|| payload.xml !== rawXml.value;
|
|
553
|
+
if (changed) {
|
|
554
|
+
applyTreeSnapshot(payload, parseWindowHierarchy(payload.xml), false);
|
|
555
|
+
}
|
|
556
|
+
} catch {
|
|
557
|
+
// A transient ADB/UIAutomator failure should not interrupt recording.
|
|
558
|
+
} finally {
|
|
559
|
+
autoTreeRefreshInFlight = false;
|
|
560
|
+
scheduleAutoTreeRefresh();
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
422
564
|
async function loadScripts() {
|
|
423
565
|
const payload = await getAppiumScripts();
|
|
424
566
|
scripts.value = payload.scripts || [];
|
|
@@ -428,6 +570,7 @@ async function loadScripts() {
|
|
|
428
570
|
}
|
|
429
571
|
|
|
430
572
|
async function switchDevice(deviceId: string) {
|
|
573
|
+
stopAutoTreeRefresh();
|
|
431
574
|
tree.value = null;
|
|
432
575
|
selectedNode.value = null;
|
|
433
576
|
currentActivity.value = '';
|
|
@@ -438,20 +581,21 @@ async function triggerDeviceKey(keyCode: number) {
|
|
|
438
581
|
if (!selectedDeviceId.value) return;
|
|
439
582
|
try {
|
|
440
583
|
await props.triggerDeviceKey(keyCode);
|
|
584
|
+
scheduleAutoTreeRefresh(500);
|
|
441
585
|
} catch (error) {
|
|
442
586
|
ElMessage.error(error instanceof Error ? error.message : '设备操作失败');
|
|
443
587
|
}
|
|
444
588
|
}
|
|
445
589
|
|
|
446
590
|
async function refreshTree() {
|
|
447
|
-
if (loadingTree.value) return;
|
|
591
|
+
if (loadingTree.value || replaying.value) return;
|
|
448
592
|
if (!selectedDeviceId.value) {
|
|
449
593
|
ElMessage.warning('请选择设备');
|
|
450
594
|
return;
|
|
451
595
|
}
|
|
452
596
|
loadingTree.value = true;
|
|
453
597
|
try {
|
|
454
|
-
await loadTreeSnapshot();
|
|
598
|
+
await loadTreeSnapshot(true, true);
|
|
455
599
|
props.refreshDevicePreview();
|
|
456
600
|
} catch (error) {
|
|
457
601
|
ElMessage.error(error instanceof Error ? error.message : '刷新组件树失败');
|
|
@@ -460,6 +604,42 @@ async function refreshTree() {
|
|
|
460
604
|
}
|
|
461
605
|
}
|
|
462
606
|
|
|
607
|
+
async function executeFlowStep(index: number) {
|
|
608
|
+
const step = steps.value[index];
|
|
609
|
+
if (!step || step.type !== 'launchApp') return;
|
|
610
|
+
if (launchingApp.value) return;
|
|
611
|
+
if (!selectedDeviceId.value) {
|
|
612
|
+
ElMessage.warning('请选择设备');
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
const packageName = step.value?.trim() || form.appPackage;
|
|
616
|
+
if (!packageName || !props.appPresets.some((app) => app.packageName === packageName)) {
|
|
617
|
+
ElMessage.warning('启动节点没有匹配的预设 App');
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
launchingApp.value = true;
|
|
622
|
+
try {
|
|
623
|
+
await launchAppiumDeviceApp({
|
|
624
|
+
deviceId: selectedDeviceId.value,
|
|
625
|
+
packageName,
|
|
626
|
+
});
|
|
627
|
+
props.refreshDevicePreview();
|
|
628
|
+
await wait(800);
|
|
629
|
+
await loadTreeSnapshot(true, true);
|
|
630
|
+
props.refreshDevicePreview();
|
|
631
|
+
if (scriptActivityMismatch.value) {
|
|
632
|
+
ElMessage.warning(`App 已启动,当前页面仍为 ${currentActivity.value || '-'}`);
|
|
633
|
+
} else {
|
|
634
|
+
ElMessage.success('已进入脚本绑定页面,编辑锁定已解除');
|
|
635
|
+
}
|
|
636
|
+
} catch (error) {
|
|
637
|
+
ElMessage.error(error instanceof Error ? error.message : '启动 App 失败');
|
|
638
|
+
} finally {
|
|
639
|
+
launchingApp.value = false;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
463
643
|
function selectNodeFromPoint(point: { x: number; y: number }) {
|
|
464
644
|
const node = findSmallestNodeAtPoint(tree.value, point.x, point.y);
|
|
465
645
|
if (node) {
|
|
@@ -485,6 +665,7 @@ async function swipePreview(gesture: {
|
|
|
485
665
|
gesture.endY,
|
|
486
666
|
gesture.duration,
|
|
487
667
|
);
|
|
668
|
+
scheduleAutoTreeRefresh(500);
|
|
488
669
|
} catch (error) {
|
|
489
670
|
ElMessage.error(error instanceof Error ? error.message : '滑动失败');
|
|
490
671
|
}
|
|
@@ -510,7 +691,7 @@ async function recordTapStep(index?: number) {
|
|
|
510
691
|
try {
|
|
511
692
|
await tapAppiumDevice({ deviceId: selectedDeviceId.value, x: bounds.centerX, y: bounds.centerY });
|
|
512
693
|
await wait(1200);
|
|
513
|
-
const after = await loadTreeSnapshot();
|
|
694
|
+
const after = await loadTreeSnapshot(true, true);
|
|
514
695
|
steps.value = steps.value.map((item) => (
|
|
515
696
|
item.id === step.id ? { ...item, pageAfter: currentPageSnapshot() } : item
|
|
516
697
|
));
|
|
@@ -533,7 +714,7 @@ async function recordTapStep(index?: number) {
|
|
|
533
714
|
}
|
|
534
715
|
|
|
535
716
|
async function addStep(type: NodeStepType, index?: number) {
|
|
536
|
-
if (
|
|
717
|
+
if (recordingBusy.value) return;
|
|
537
718
|
if (!selectedNode.value) return;
|
|
538
719
|
if (!ensureAppPackageSelected()) return;
|
|
539
720
|
if (type === 'tap') {
|
|
@@ -554,7 +735,7 @@ async function addStep(type: NodeStepType, index?: number) {
|
|
|
554
735
|
}
|
|
555
736
|
|
|
556
737
|
async function addDelayStep(index?: number) {
|
|
557
|
-
if (
|
|
738
|
+
if (recordingBusy.value) return;
|
|
558
739
|
const input = await ElMessageBox.prompt('请输入延时时间,单位毫秒', '添加延时', {
|
|
559
740
|
inputValue: '1000',
|
|
560
741
|
inputPattern: /^[1-9]\d{0,5}$/,
|
|
@@ -573,19 +754,24 @@ function openLinkedScriptDialog(index?: number, branchTarget?: BranchTarget) {
|
|
|
573
754
|
ElMessage.warning('暂无可连接的已保存脚本');
|
|
574
755
|
return;
|
|
575
756
|
}
|
|
757
|
+
linkedScriptBranchTarget.value = branchTarget || null;
|
|
576
758
|
linkedScriptExpectedActivity.value = expectedActivityAfterStep(index);
|
|
577
|
-
if (!linkedScriptExpectedActivity.value) {
|
|
759
|
+
if (!linkedScriptBranchTarget.value && !linkedScriptExpectedActivity.value) {
|
|
578
760
|
ElMessage.warning('无法确定当前插入点的 Activity,请先刷新组件树');
|
|
579
761
|
return;
|
|
580
762
|
}
|
|
581
763
|
if (!compatibleLinkableScripts.value.length) {
|
|
582
|
-
ElMessage.warning(
|
|
764
|
+
ElMessage.warning(
|
|
765
|
+
linkedScriptBranchTarget.value
|
|
766
|
+
? '没有属于当前 App 的可连接脚本'
|
|
767
|
+
: `没有入口 Activity 为 ${linkedScriptExpectedActivity.value} 的可连接脚本`,
|
|
768
|
+
);
|
|
583
769
|
linkedScriptExpectedActivity.value = '';
|
|
770
|
+
linkedScriptBranchTarget.value = null;
|
|
584
771
|
return;
|
|
585
772
|
}
|
|
586
773
|
linkedScriptTargetId.value = compatibleLinkableScripts.value[0]?.id || '';
|
|
587
774
|
linkedScriptInsertIndex.value = index;
|
|
588
|
-
linkedScriptBranchTarget.value = branchTarget || null;
|
|
589
775
|
linkedScriptDialogVisible.value = true;
|
|
590
776
|
}
|
|
591
777
|
|
|
@@ -595,7 +781,14 @@ function addLinkedScriptStep() {
|
|
|
595
781
|
ElMessage.warning('请选择要连接的脚本');
|
|
596
782
|
return;
|
|
597
783
|
}
|
|
598
|
-
if (
|
|
784
|
+
if (script.appPackage !== form.appPackage) {
|
|
785
|
+
ElMessage.error('连接脚本必须属于当前 App');
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
if (
|
|
789
|
+
!linkedScriptBranchTarget.value
|
|
790
|
+
&& (!linkedScriptExpectedActivity.value || script.appActivity !== linkedScriptExpectedActivity.value)
|
|
791
|
+
) {
|
|
599
792
|
ElMessage.error(
|
|
600
793
|
`无法连接:插入点 Activity 为 ${linkedScriptExpectedActivity.value || '-'},`
|
|
601
794
|
+ `目标脚本入口 Activity 为 ${script.appActivity || '-'}`,
|
|
@@ -605,11 +798,24 @@ function addLinkedScriptStep() {
|
|
|
605
798
|
const step = insertStep(createRunScriptStep(script), linkedScriptInsertIndex.value);
|
|
606
799
|
if (linkedScriptBranchTarget.value) {
|
|
607
800
|
attachStepToBranch(step.id, linkedScriptBranchTarget.value.stepId, linkedScriptBranchTarget.value.branch);
|
|
801
|
+
if (linkedScriptBranchTarget.value.nextTargetId) {
|
|
802
|
+
steps.value = steps.value.map((item) => (
|
|
803
|
+
item.id === step.id
|
|
804
|
+
? {
|
|
805
|
+
...item,
|
|
806
|
+
flow: {
|
|
807
|
+
...(item.flow || {}),
|
|
808
|
+
successTargetId: linkedScriptBranchTarget.value?.nextTargetId,
|
|
809
|
+
},
|
|
810
|
+
}
|
|
811
|
+
: item
|
|
812
|
+
));
|
|
813
|
+
}
|
|
608
814
|
if (linkedScriptBranchTarget.value.updateTarget) {
|
|
609
815
|
updateBranchTarget(
|
|
610
816
|
linkedScriptBranchTarget.value.stepId,
|
|
611
817
|
linkedScriptBranchTarget.value.branch,
|
|
612
|
-
step.id,
|
|
818
|
+
linkedScriptBranchTarget.value.entryTargetId || step.id,
|
|
613
819
|
);
|
|
614
820
|
}
|
|
615
821
|
}
|
|
@@ -633,7 +839,6 @@ async function addAction(
|
|
|
633
839
|
branchTarget?: BranchTarget,
|
|
634
840
|
) {
|
|
635
841
|
if (recordingBusy.value) return;
|
|
636
|
-
if (scriptActivityMismatch.value && !['keyBack', 'runScript', 'launchApp', 'delay'].includes(action)) return;
|
|
637
842
|
if (!ensureAppPackageSelected()) return;
|
|
638
843
|
if (action === 'launchApp' && steps.value.some((step) => step.type === 'launchApp')) {
|
|
639
844
|
ElMessage.warning('启动 APP 节点只能添加一个');
|
|
@@ -786,16 +991,91 @@ async function addAction(
|
|
|
786
991
|
async function addBranchAction(index: number, branch: BranchName, action: RecorderAction) {
|
|
787
992
|
const source = steps.value[index];
|
|
788
993
|
if (!source) return;
|
|
994
|
+
const existingBranchSteps = steps.value.filter((step) => (
|
|
995
|
+
step.flow?.parentConditionId === source.id
|
|
996
|
+
&& step.flow.parentBranch === branch
|
|
997
|
+
));
|
|
789
998
|
const currentTargetId = branch === 'yes' ? source.flow?.yesTargetId : source.flow?.noTargetId;
|
|
999
|
+
const currentTarget = steps.value.find((step) => step.id === currentTargetId);
|
|
1000
|
+
const targetIsBranchStep = currentTarget?.flow?.parentConditionId === source.id
|
|
1001
|
+
&& currentTarget.flow.parentBranch === branch;
|
|
790
1002
|
const insertIndex = branchInsertIndex(source.id, branch, index);
|
|
791
1003
|
const inserted = await addAction(action, insertIndex, {
|
|
792
1004
|
stepId: source.id,
|
|
793
1005
|
branch,
|
|
794
|
-
updateTarget: !currentTargetId,
|
|
1006
|
+
updateTarget: !currentTargetId || !targetIsBranchStep,
|
|
1007
|
+
entryTargetId: existingBranchSteps[0]?.id,
|
|
1008
|
+
nextTargetId: !targetIsBranchStep ? currentTargetId : undefined,
|
|
795
1009
|
});
|
|
796
1010
|
if (!inserted) return;
|
|
797
1011
|
attachStepToBranch(inserted.id, source.id, branch);
|
|
798
|
-
if (!currentTargetId
|
|
1012
|
+
if (!currentTargetId || !targetIsBranchStep) {
|
|
1013
|
+
steps.value = steps.value.map((step) => (
|
|
1014
|
+
step.id === inserted.id && currentTargetId
|
|
1015
|
+
? {
|
|
1016
|
+
...step,
|
|
1017
|
+
flow: { ...(step.flow || {}), successTargetId: currentTargetId },
|
|
1018
|
+
}
|
|
1019
|
+
: step
|
|
1020
|
+
));
|
|
1021
|
+
updateBranchTarget(source.id, branch, existingBranchSteps[0]?.id || inserted.id);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function connectBranchToNext(index: number, branch: BranchName) {
|
|
1026
|
+
if (recordingLocked.value) return;
|
|
1027
|
+
const condition = steps.value[index];
|
|
1028
|
+
if (!condition) return;
|
|
1029
|
+
const nextMainStep = steps.value.slice(index + 1).find((step) => !step.flow?.parentConditionId);
|
|
1030
|
+
if (!nextMainStep) {
|
|
1031
|
+
ElMessage.warning('当前判断节点后没有可连接的主流程节点');
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
const branchSteps = steps.value.filter((step) => (
|
|
1036
|
+
step.flow?.parentConditionId === condition.id
|
|
1037
|
+
&& step.flow.parentBranch === branch
|
|
1038
|
+
));
|
|
1039
|
+
if (!branchSteps.length) {
|
|
1040
|
+
updateBranchTarget(condition.id, branch, nextMainStep.id);
|
|
1041
|
+
} else {
|
|
1042
|
+
const firstBranchStep = branchSteps[0];
|
|
1043
|
+
const lastBranchStep = branchSteps[branchSteps.length - 1];
|
|
1044
|
+
updateBranchTarget(condition.id, branch, firstBranchStep.id);
|
|
1045
|
+
steps.value = steps.value.map((step) => (
|
|
1046
|
+
step.id === lastBranchStep.id
|
|
1047
|
+
? {
|
|
1048
|
+
...step,
|
|
1049
|
+
flow: { ...(step.flow || {}), successTargetId: nextMainStep.id },
|
|
1050
|
+
}
|
|
1051
|
+
: step
|
|
1052
|
+
));
|
|
1053
|
+
}
|
|
1054
|
+
ElMessage.success(`${branch === 'yes' ? '是' : '否'}分支已连接到「${nextMainStep.label}」`);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function disconnectBranchFromNext(index: number, branch: BranchName) {
|
|
1058
|
+
if (recordingLocked.value) return;
|
|
1059
|
+
const condition = steps.value[index];
|
|
1060
|
+
if (!condition) return;
|
|
1061
|
+
const branchSteps = steps.value.filter((step) => (
|
|
1062
|
+
step.flow?.parentConditionId === condition.id
|
|
1063
|
+
&& step.flow.parentBranch === branch
|
|
1064
|
+
));
|
|
1065
|
+
if (!branchSteps.length) {
|
|
1066
|
+
updateBranchTarget(condition.id, branch, '');
|
|
1067
|
+
} else {
|
|
1068
|
+
const lastBranchStep = branchSteps[branchSteps.length - 1];
|
|
1069
|
+
steps.value = steps.value.map((step) => (
|
|
1070
|
+
step.id === lastBranchStep.id
|
|
1071
|
+
? {
|
|
1072
|
+
...step,
|
|
1073
|
+
flow: { ...(step.flow || {}), successTargetId: '' },
|
|
1074
|
+
}
|
|
1075
|
+
: step
|
|
1076
|
+
));
|
|
1077
|
+
}
|
|
1078
|
+
ElMessage.success(`${branch === 'yes' ? '是' : '否'}分支已取消连接`);
|
|
799
1079
|
}
|
|
800
1080
|
|
|
801
1081
|
async function saveOnActivityChange() {
|
|
@@ -818,6 +1098,7 @@ async function saveOnActivityChange() {
|
|
|
818
1098
|
pendingNavigation.value = null;
|
|
819
1099
|
resetCurrentScript();
|
|
820
1100
|
form.appActivity = pending.afterActivity;
|
|
1101
|
+
markDraftSaved();
|
|
821
1102
|
ElMessage.success(`脚本「${payload.script.name}」已保存,可开始录制新 Activity`);
|
|
822
1103
|
} catch (error) {
|
|
823
1104
|
ElMessage.error(error instanceof Error ? error.message : '保存脚本失败');
|
|
@@ -891,6 +1172,7 @@ function loadScript(script: AppiumRecordedScript) {
|
|
|
891
1172
|
}
|
|
892
1173
|
steps.value = (script.steps || []).map(normalizeLegacyFlowScope);
|
|
893
1174
|
activeWorkbenchTab.value = 'recording';
|
|
1175
|
+
markDraftSaved();
|
|
894
1176
|
}
|
|
895
1177
|
|
|
896
1178
|
function loadSelectedScript() {
|
|
@@ -907,6 +1189,7 @@ function resetCurrentScript() {
|
|
|
907
1189
|
form.appActivity = '';
|
|
908
1190
|
steps.value = [];
|
|
909
1191
|
replayOutput.value = '';
|
|
1192
|
+
markDraftSaved();
|
|
910
1193
|
}
|
|
911
1194
|
|
|
912
1195
|
function formatScriptTime(value: string) {
|
|
@@ -943,6 +1226,38 @@ async function removeScript(script: AppiumRecordedScript) {
|
|
|
943
1226
|
}
|
|
944
1227
|
}
|
|
945
1228
|
|
|
1229
|
+
function downloadScript(script: AppiumRecordedScript) {
|
|
1230
|
+
const link = document.createElement('a');
|
|
1231
|
+
link.href = appiumScriptDownloadUrl(script.id);
|
|
1232
|
+
link.download = `${script.name}.json`;
|
|
1233
|
+
document.body.appendChild(link);
|
|
1234
|
+
link.click();
|
|
1235
|
+
link.remove();
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
function chooseScriptImportFile() {
|
|
1239
|
+
scriptImportInput.value?.click();
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
async function importScriptFile(event: Event) {
|
|
1243
|
+
const input = event.currentTarget as HTMLInputElement;
|
|
1244
|
+
const file = input.files?.[0];
|
|
1245
|
+
input.value = '';
|
|
1246
|
+
if (!file) return;
|
|
1247
|
+
importingScript.value = true;
|
|
1248
|
+
try {
|
|
1249
|
+
const payload = JSON.parse(await file.text()) as unknown;
|
|
1250
|
+
const result = await importAppiumScript(payload);
|
|
1251
|
+
await loadScripts();
|
|
1252
|
+
loadScript(result.script);
|
|
1253
|
+
ElMessage.success(`脚本「${result.script.name}」已导入`);
|
|
1254
|
+
} catch (error) {
|
|
1255
|
+
ElMessage.error(error instanceof Error ? error.message : '导入脚本失败');
|
|
1256
|
+
} finally {
|
|
1257
|
+
importingScript.value = false;
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
|
|
946
1261
|
async function saveScript() {
|
|
947
1262
|
if (!ensureAppPackageSelected()) return;
|
|
948
1263
|
saving.value = true;
|
|
@@ -957,6 +1272,7 @@ async function saveScript() {
|
|
|
957
1272
|
});
|
|
958
1273
|
await loadScripts();
|
|
959
1274
|
selectedScriptId.value = payload.script.id;
|
|
1275
|
+
markDraftSaved();
|
|
960
1276
|
ElMessage.success('Appium 脚本已保存');
|
|
961
1277
|
} catch (error) {
|
|
962
1278
|
ElMessage.error(error instanceof Error ? error.message : '保存失败');
|
|
@@ -973,18 +1289,42 @@ async function replayScript() {
|
|
|
973
1289
|
replaying.value = true;
|
|
974
1290
|
replayOutput.value = '';
|
|
975
1291
|
try {
|
|
976
|
-
|
|
1292
|
+
await pauseAutoTreeRefresh();
|
|
1293
|
+
const result = await replayAppiumScript(
|
|
1294
|
+
{ id: selectedScript.value.id, deviceId: selectedDeviceId.value },
|
|
1295
|
+
(line) => {
|
|
1296
|
+
replayOutput.value += `${replayOutput.value ? '\n' : ''}${line}`;
|
|
1297
|
+
},
|
|
1298
|
+
);
|
|
977
1299
|
replayOutput.value = result.output || '';
|
|
978
|
-
if (result.success)
|
|
1300
|
+
if (result.success) {
|
|
1301
|
+
ElMessage.success('回放完成');
|
|
1302
|
+
} else {
|
|
1303
|
+
ElMessage.error('回放失败,详情见回放输出');
|
|
1304
|
+
}
|
|
979
1305
|
} catch (error) {
|
|
980
|
-
|
|
1306
|
+
const message = error instanceof Error ? error.message : '回放失败';
|
|
1307
|
+
replayOutput.value += `${replayOutput.value ? '\n' : ''}${message}`;
|
|
981
1308
|
ElMessage.error('回放失败,详情见回放输出');
|
|
982
1309
|
} finally {
|
|
983
1310
|
replaying.value = false;
|
|
1311
|
+
scheduleAutoTreeRefresh(800);
|
|
984
1312
|
}
|
|
985
1313
|
}
|
|
986
1314
|
|
|
987
|
-
onMounted(
|
|
1315
|
+
onMounted(() => {
|
|
1316
|
+
window.addEventListener('beforeunload', warnBeforeUnload);
|
|
1317
|
+
void loadScripts();
|
|
1318
|
+
});
|
|
1319
|
+
|
|
1320
|
+
onUnmounted(() => {
|
|
1321
|
+
window.removeEventListener('beforeunload', warnBeforeUnload);
|
|
1322
|
+
stopAutoTreeRefresh();
|
|
1323
|
+
});
|
|
1324
|
+
|
|
1325
|
+
watch(activeWorkbenchTab, (tab) => {
|
|
1326
|
+
window.localStorage.setItem(WORKBENCH_TAB_STORAGE_KEY, tab);
|
|
1327
|
+
});
|
|
988
1328
|
|
|
989
1329
|
watch(() => form.appPackage, (packageName) => {
|
|
990
1330
|
if (!packageName) return;
|
|
@@ -998,7 +1338,10 @@ watch(() => form.appPackage, (packageName) => {
|
|
|
998
1338
|
watch(
|
|
999
1339
|
() => [props.active, props.playgroundDeviceId] as const,
|
|
1000
1340
|
([active, deviceId]) => {
|
|
1001
|
-
|
|
1341
|
+
stopAutoTreeRefresh();
|
|
1342
|
+
if (active && deviceId) {
|
|
1343
|
+
void refreshTree().finally(() => scheduleAutoTreeRefresh());
|
|
1344
|
+
}
|
|
1002
1345
|
},
|
|
1003
1346
|
{ immediate: true },
|
|
1004
1347
|
);
|
|
@@ -1033,7 +1376,7 @@ watch(
|
|
|
1033
1376
|
<template #header>
|
|
1034
1377
|
<div class="panel-header">
|
|
1035
1378
|
<span>App 组件树</span>
|
|
1036
|
-
<el-button :icon="Refresh" :loading="loadingTree" @click="refreshTree">
|
|
1379
|
+
<el-button :icon="Refresh" :loading="loadingTree" :disabled="replaying" @click="refreshTree">
|
|
1037
1380
|
刷新组件树
|
|
1038
1381
|
</el-button>
|
|
1039
1382
|
</div>
|
|
@@ -1105,7 +1448,7 @@ watch(
|
|
|
1105
1448
|
<strong>当前 Activity</strong>
|
|
1106
1449
|
<code>{{ currentActivity || '正在获取' }}</code>
|
|
1107
1450
|
</div>
|
|
1108
|
-
<p
|
|
1451
|
+
<p>请在录制流程中添加或执行“启动 APP”节点;Activity 匹配后会自动解除编辑锁定。</p>
|
|
1109
1452
|
</div>
|
|
1110
1453
|
</el-alert>
|
|
1111
1454
|
<NodeDetail
|
|
@@ -1120,12 +1463,16 @@ watch(
|
|
|
1120
1463
|
<RecordedSteps
|
|
1121
1464
|
:steps="steps"
|
|
1122
1465
|
:disabled="recordingLocked"
|
|
1123
|
-
:allowed-locked-actions="scriptActivityMismatch && !recordingBusy ?
|
|
1466
|
+
:allowed-locked-actions="scriptActivityMismatch && !recordingBusy ? insertableRecorderActions : []"
|
|
1467
|
+
:launching-step-id="launchingApp ? steps.find((step) => step.type === 'launchApp')?.id : ''"
|
|
1124
1468
|
@remove="removeStep"
|
|
1125
1469
|
@add-delay="addDelayStep"
|
|
1126
1470
|
@insert-action="(index, action) => addAction(action, index)"
|
|
1127
1471
|
@insert-branch-action="addBranchAction"
|
|
1472
|
+
@connect-next="connectBranchToNext"
|
|
1473
|
+
@disconnect-next="disconnectBranchFromNext"
|
|
1128
1474
|
@edit-input="editInputStep"
|
|
1475
|
+
@execute-step="executeFlowStep"
|
|
1129
1476
|
@update-step="updateStep"
|
|
1130
1477
|
/>
|
|
1131
1478
|
</section>
|
|
@@ -1167,6 +1514,18 @@ watch(
|
|
|
1167
1514
|
|
|
1168
1515
|
<el-tab-pane label="脚本列表" name="scripts">
|
|
1169
1516
|
<div class="appium-script-list">
|
|
1517
|
+
<div class="appium-script-list__toolbar">
|
|
1518
|
+
<el-button :icon="Upload" :loading="importingScript" @click="chooseScriptImportFile">
|
|
1519
|
+
导入脚本
|
|
1520
|
+
</el-button>
|
|
1521
|
+
<input
|
|
1522
|
+
ref="scriptImportInput"
|
|
1523
|
+
class="appium-script-list__file-input"
|
|
1524
|
+
type="file"
|
|
1525
|
+
accept="application/json,.json"
|
|
1526
|
+
@change="importScriptFile"
|
|
1527
|
+
>
|
|
1528
|
+
</div>
|
|
1170
1529
|
<el-empty v-if="!scripts.length" description="暂无录制脚本" />
|
|
1171
1530
|
<template v-else>
|
|
1172
1531
|
<article
|
|
@@ -1182,6 +1541,12 @@ watch(
|
|
|
1182
1541
|
</div>
|
|
1183
1542
|
<div class="appium-script-list__actions">
|
|
1184
1543
|
<el-button size="small" @click="loadScript(script)">加载</el-button>
|
|
1544
|
+
<el-button
|
|
1545
|
+
size="small"
|
|
1546
|
+
:icon="Download"
|
|
1547
|
+
title="下载脚本"
|
|
1548
|
+
@click="downloadScript(script)"
|
|
1549
|
+
/>
|
|
1185
1550
|
<el-button
|
|
1186
1551
|
size="small"
|
|
1187
1552
|
type="danger"
|
|
@@ -1234,10 +1599,10 @@ watch(
|
|
|
1234
1599
|
@closed="closeLinkedScriptDialog"
|
|
1235
1600
|
>
|
|
1236
1601
|
<el-form label-position="top">
|
|
1237
|
-
<el-form-item label="插入点 Activity">
|
|
1602
|
+
<el-form-item :label="linkedScriptBranchTarget ? '分支录制 Activity' : '插入点 Activity'">
|
|
1238
1603
|
<code class="appium-linked-script-activity">{{ linkedScriptExpectedActivity || '-' }}</code>
|
|
1239
1604
|
</el-form-item>
|
|
1240
|
-
<el-form-item label="选择入口 Activity 匹配的脚本">
|
|
1605
|
+
<el-form-item :label="linkedScriptBranchTarget ? '选择同一 App 的脚本(回放时等待入口 Activity)' : '选择入口 Activity 匹配的脚本'">
|
|
1241
1606
|
<el-select
|
|
1242
1607
|
v-model="linkedScriptTargetId"
|
|
1243
1608
|
filterable
|
|
@@ -1247,7 +1612,7 @@ watch(
|
|
|
1247
1612
|
<el-option
|
|
1248
1613
|
v-for="script in compatibleLinkableScripts"
|
|
1249
1614
|
:key="script.id"
|
|
1250
|
-
:label="`${script.name} · ${script.steps.length} 步`"
|
|
1615
|
+
:label="`${script.name} · ${script.appActivity || '未绑定 Activity'} · ${script.steps.length} 步`"
|
|
1251
1616
|
:value="script.id"
|
|
1252
1617
|
/>
|
|
1253
1618
|
</el-select>
|