android-midscene-automation 0.1.31 → 0.1.32
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 -1
- package/README.md +10 -1
- package/bin/sync-readme-docs.mjs +49 -0
- package/package.json +8 -8
- package/remote-agent/index.ts +1 -1
- package/server/appium-recorder/appium-runner.ts +48 -14
- package/server/appium-recorder/history-capture.ts +34 -0
- package/server/appium-recorder/report.ts +1 -1
- package/server/appium-recorder/routes.ts +29 -5
- package/server/appium-recorder/run-history.ts +34 -0
- package/src/appium-recorder/AppiumPage.vue +32 -16
- package/src/appium-recorder/RunHistoryDialog.vue +153 -0
- package/src/appium-recorder/components/NestedConditionBranches.vue +3 -2
- package/src/appium-recorder/components/RecordedSteps.vue +2 -4
- package/src/appium-recorder/components/VisualChangeEndPicker.vue +21 -0
- package/src/appium-recorder/flow-graph.ts +1 -8
- package/src/appium-recorder/run-history.ts +50 -0
- package/src/appium-recorder/visual-change-pairs.ts +16 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
# 更新记录
|
|
2
2
|
|
|
3
|
+
## v0.1.32
|
|
4
|
+
- 添加画面变化结束节点时,多组可配对检测支持按名称及开始节点编号选择,允许交叉结束;只有一组时直接添加,保留分支及重复结束校验。
|
|
5
|
+
- 画面变化结束节点校验覆盖祖先及子分支:同一执行路径已存在对应结束节点时,后续分支不能重复添加;互斥分支仍可分别结束。
|
|
6
|
+
- 脚本列表新增“历史结果对比”:保存运行结果、应用版本、设备、截图和日志,支持筛选查看通过率、耗时趋势与失败节点分布,对比两次运行并删除选中历史;手动终止不计入通过率,旧报告不自动补录。
|
|
7
|
+
- 画面变化检测在每个结束节点执行后立即对比本次截图,避免循环及重复调用脚本时多组检测复用最后一轮图片;截图缺失时明确报告异常,不使用旧图替代。
|
|
8
|
+
- “启动 APP”支持在流程中途、判断分支及循环体内多次添加,按所选位置执行;连接脚本仅跳过开头的应用初始化,中途启动操作正常保留。
|
|
9
|
+
- Midscene 相关模块统一从 1.12.2 升级至 1.12.5。
|
|
10
|
+
|
|
11
|
+
|
|
3
12
|
## v0.1.31
|
|
4
13
|
- 组件详情展示 Resource ID、Accessibility ID、XPath 和 UiAutomator 四种定位内容,支持分别复制,方便配置循环退出条件;缺少对应属性时显示未提供。
|
|
5
14
|
- “流程控制”新增有界循环及退出循环:支持固定次数、元素出现或消失时退出,必须设置最大执行次数;循环体支持嵌套判断、合流和连接脚本。退出节点可选择当前或外层循环,退出外层时同时结束内部循环并直接执行目标循环结束路径,日志及 HTML 报告标注轮次和退出目标。
|
|
6
15
|
- 新增流程节点搜索,支持按名称、操作类型、定位内容和日志关键字筛选,展示所属分支并在多个结果间切换;普通画布与流程总览支持定位高亮,不改变节点布局及批量选中状态。
|
|
7
|
-
- Midscene 相关模块统一从 1.12.0 升级至 1.12.2
|
|
16
|
+
- Midscene 相关模块统一从 1.12.0 升级至 1.12.2。
|
|
8
17
|
- 录制工作区支持拖拽调整设备预览、组件树栏宽,自动保存布局并支持恢复默认。
|
|
9
18
|
|
|
10
19
|
## v0.1.30
|
package/README.md
CHANGED
|
@@ -1229,11 +1229,20 @@ Linux: ~/.local/share/android-midscene-automation
|
|
|
1229
1229
|
|
|
1230
1230
|
# 项目更新记录
|
|
1231
1231
|
|
|
1232
|
+
## v0.1.32
|
|
1233
|
+
- 添加画面变化结束节点时,多组可配对检测支持按名称及开始节点编号选择,允许交叉结束;只有一组时直接添加,保留分支及重复结束校验。
|
|
1234
|
+
- 画面变化结束节点校验覆盖祖先及子分支:同一执行路径已存在对应结束节点时,后续分支不能重复添加;互斥分支仍可分别结束。
|
|
1235
|
+
- 脚本列表新增“历史结果对比”:保存运行结果、应用版本、设备、截图和日志,支持筛选查看通过率、耗时趋势与失败节点分布,对比两次运行并删除选中历史;手动终止不计入通过率,旧报告不自动补录。
|
|
1236
|
+
- 画面变化检测在每个结束节点执行后立即对比本次截图,避免循环及重复调用脚本时多组检测复用最后一轮图片;截图缺失时明确报告异常,不使用旧图替代。
|
|
1237
|
+
- “启动 APP”支持在流程中途、判断分支及循环体内多次添加,按所选位置执行;连接脚本仅跳过开头的应用初始化,中途启动操作正常保留。
|
|
1238
|
+
- Midscene 相关模块统一从 1.12.2 升级至 1.12.5。
|
|
1239
|
+
|
|
1240
|
+
|
|
1232
1241
|
## v0.1.31
|
|
1233
1242
|
- 组件详情展示 Resource ID、Accessibility ID、XPath 和 UiAutomator 四种定位内容,支持分别复制,方便配置循环退出条件;缺少对应属性时显示未提供。
|
|
1234
1243
|
- “流程控制”新增有界循环及退出循环:支持固定次数、元素出现或消失时退出,必须设置最大执行次数;循环体支持嵌套判断、合流和连接脚本。退出节点可选择当前或外层循环,退出外层时同时结束内部循环并直接执行目标循环结束路径,日志及 HTML 报告标注轮次和退出目标。
|
|
1235
1244
|
- 新增流程节点搜索,支持按名称、操作类型、定位内容和日志关键字筛选,展示所属分支并在多个结果间切换;普通画布与流程总览支持定位高亮,不改变节点布局及批量选中状态。
|
|
1236
|
-
- Midscene 相关模块统一从 1.12.0 升级至 1.12.2
|
|
1245
|
+
- Midscene 相关模块统一从 1.12.0 升级至 1.12.2。
|
|
1237
1246
|
- 录制工作区支持拖拽调整设备预览、组件树栏宽,自动保存布局并支持恢复默认。
|
|
1238
1247
|
|
|
1239
1248
|
## v0.1.30
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
const generatedStart = '<!-- generated-docs:start -->';
|
|
4
|
+
const generatedEnd = '<!-- generated-docs:end -->';
|
|
5
|
+
const readmeUrl = new URL('../README.md', import.meta.url);
|
|
6
|
+
|
|
7
|
+
const stripFirstHeading = (markdown) =>
|
|
8
|
+
markdown.replace(/^#\s+[^\r\n]+\r?\n+/, '').trim();
|
|
9
|
+
|
|
10
|
+
const stripUsageFaqPointer = (markdown) =>
|
|
11
|
+
markdown.replace(
|
|
12
|
+
/\n## 常见问题\r?\n\r?\n常见问题已独立整理到 \[FAQ\.md\]\(\.\/FAQ\.md\)。\r?\n/g,
|
|
13
|
+
'\n',
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
const linkReadmeAnchors = (markdown) =>
|
|
17
|
+
markdown.replace(/\[FAQ\.md\]\(\.\/FAQ\.md\)/g, '[常见问题](#常见问题)');
|
|
18
|
+
|
|
19
|
+
const [readme, usage, faq, changelog] = await Promise.all([
|
|
20
|
+
readFile(readmeUrl, 'utf8'),
|
|
21
|
+
readFile(new URL('../USAGE.md', import.meta.url), 'utf8'),
|
|
22
|
+
readFile(new URL('../FAQ.md', import.meta.url), 'utf8'),
|
|
23
|
+
readFile(new URL('../CHANGELOG.md', import.meta.url), 'utf8'),
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
const generatedIndex = readme.indexOf(generatedStart);
|
|
27
|
+
const readmeBody = (
|
|
28
|
+
generatedIndex >= 0 ? readme.slice(0, generatedIndex) : readme
|
|
29
|
+
).trimEnd();
|
|
30
|
+
|
|
31
|
+
const generatedDocs = [
|
|
32
|
+
generatedStart,
|
|
33
|
+
'',
|
|
34
|
+
'# Appium 录制器使用说明',
|
|
35
|
+
'',
|
|
36
|
+
linkReadmeAnchors(stripUsageFaqPointer(stripFirstHeading(usage))),
|
|
37
|
+
'',
|
|
38
|
+
'# 常见问题',
|
|
39
|
+
'',
|
|
40
|
+
stripFirstHeading(faq),
|
|
41
|
+
'',
|
|
42
|
+
'# 项目更新记录',
|
|
43
|
+
'',
|
|
44
|
+
stripFirstHeading(changelog),
|
|
45
|
+
'',
|
|
46
|
+
generatedEnd,
|
|
47
|
+
].join('\n');
|
|
48
|
+
|
|
49
|
+
await writeFile(readmeUrl, `${readmeBody}\n\n${generatedDocs}\n`, 'utf8');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "android-midscene-automation",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.32",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/oooooooko/android-midscene-automation.git"
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
],
|
|
29
29
|
"scripts": {
|
|
30
30
|
"dev": "vite --host 127.0.0.1",
|
|
31
|
-
"prepack": "node
|
|
31
|
+
"prepack": "node bin/sync-readme-docs.mjs",
|
|
32
32
|
"remote-agent": "tsx remote-agent/index.ts",
|
|
33
33
|
"build": "vue-tsc -b && vite build",
|
|
34
34
|
"preview": "vite preview --host 127.0.0.1",
|
|
@@ -38,12 +38,12 @@
|
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@dagrejs/dagre": "1.1.5",
|
|
40
40
|
"@element-plus/icons-vue": "2.3.2",
|
|
41
|
-
"@midscene/android": "https://registry.npmjs.org/@midscene/android/-/android-1.12.
|
|
42
|
-
"@midscene/android-playground": "https://registry.npmjs.org/@midscene/android-playground/-/android-playground-1.12.
|
|
43
|
-
"@midscene/core": "https://registry.npmjs.org/@midscene/core/-/core-1.12.
|
|
44
|
-
"@midscene/playground": "https://registry.npmjs.org/@midscene/playground/-/playground-1.12.
|
|
45
|
-
"@midscene/shared": "https://registry.npmjs.org/@midscene/shared/-/shared-1.12.
|
|
46
|
-
"@midscene/web": "https://registry.npmjs.org/@midscene/web/-/web-1.12.
|
|
41
|
+
"@midscene/android": "https://registry.npmjs.org/@midscene/android/-/android-1.12.5.tgz",
|
|
42
|
+
"@midscene/android-playground": "https://registry.npmjs.org/@midscene/android-playground/-/android-playground-1.12.5.tgz",
|
|
43
|
+
"@midscene/core": "https://registry.npmjs.org/@midscene/core/-/core-1.12.5.tgz",
|
|
44
|
+
"@midscene/playground": "https://registry.npmjs.org/@midscene/playground/-/playground-1.12.5.tgz",
|
|
45
|
+
"@midscene/shared": "https://registry.npmjs.org/@midscene/shared/-/shared-1.12.5.tgz",
|
|
46
|
+
"@midscene/web": "https://registry.npmjs.org/@midscene/web/-/web-1.12.5.tgz",
|
|
47
47
|
"@vitejs/plugin-vue": "6.0.7",
|
|
48
48
|
"@vue-flow/core": "1.48.2",
|
|
49
49
|
"archiver": "7.0.1",
|
package/remote-agent/index.ts
CHANGED
|
@@ -18,7 +18,7 @@ const agentName = argValue('agent-name', agentId);
|
|
|
18
18
|
const token = argValue('token', process.env.REMOTE_AGENT_TOKEN || '');
|
|
19
19
|
|
|
20
20
|
if (!server) {
|
|
21
|
-
console.error('缺少 --server,例如:npm run remote-agent -- --server http
|
|
21
|
+
console.error('缺少 --server,例如:npm run remote-agent -- --server http://<控制台主机地址>:5173');
|
|
22
22
|
process.exit(1);
|
|
23
23
|
}
|
|
24
24
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
|
+
import { captureHistoryFrames, readAppVersion } from './history-capture';
|
|
2
3
|
import { BoundedLoopTraversal } from './bounded-loop';
|
|
3
4
|
import { validateLoopSteps } from '../../src/appium-recorder/bounded-loop';
|
|
4
5
|
import { adbScreenshotBase64 } from './screenshot';
|
|
@@ -49,6 +50,8 @@ type PendingVisualChangeCheck = {
|
|
|
49
50
|
step: AppiumRecordedStepRecord;
|
|
50
51
|
nodeNumber: number;
|
|
51
52
|
scriptName: string;
|
|
53
|
+
frameStart: number;
|
|
54
|
+
endFrameStart: number;
|
|
52
55
|
};
|
|
53
56
|
|
|
54
57
|
const replayContext = new AsyncLocalStorage<{
|
|
@@ -57,8 +60,10 @@ const replayContext = new AsyncLocalStorage<{
|
|
|
57
60
|
serverUrl: string;
|
|
58
61
|
deviceId: string;
|
|
59
62
|
frames: AppiumReplayFrame[];
|
|
63
|
+
historyEvents: AppiumReplayFrame[];
|
|
60
64
|
visualChecks: AppiumReplayVisualCheck[];
|
|
61
65
|
pendingVisualChecks: PendingVisualChangeCheck[];
|
|
66
|
+
visualStartOffsets: Map<string, number>;
|
|
62
67
|
softFailureCount: number;
|
|
63
68
|
flowEnded?: boolean;
|
|
64
69
|
}>();
|
|
@@ -332,6 +337,10 @@ async function captureReplayFrame(
|
|
|
332
337
|
) {
|
|
333
338
|
const context = replayContext.getStore();
|
|
334
339
|
if (!context || !context.deviceId) return;
|
|
340
|
+
// 执行记录独立于截图,设备截图失败不能让失败节点从统计中消失。
|
|
341
|
+
context.historyEvents.push({ sequence: context.historyEvents.length + 1, scriptName, nodeId: step.id,
|
|
342
|
+
nodeNumber, nodeLabel: step.label, nodeType: step.type, note: step.note || '',
|
|
343
|
+
selector: replayFrameSelector(step), phase, status, capturedAt: new Date().toISOString(), imageBase64: '' });
|
|
335
344
|
const appendFrame = (imageBase64: string) => {
|
|
336
345
|
context.frames.push({
|
|
337
346
|
sequence: context.frames.length + 1,
|
|
@@ -519,6 +528,7 @@ async function saveScreenshot(deviceId: string) {
|
|
|
519
528
|
type RunStepMeta = {
|
|
520
529
|
nodeNumber: number;
|
|
521
530
|
scriptName: string;
|
|
531
|
+
frameStart?: number;
|
|
522
532
|
};
|
|
523
533
|
|
|
524
534
|
type RunStepResult = string | {
|
|
@@ -560,10 +570,9 @@ function findVisualFrame(
|
|
|
560
570
|
) {
|
|
561
571
|
const matches = frames.filter((frame) => frame.nodeId === nodeId && frame.scriptName === scriptName);
|
|
562
572
|
if (!matches.length) return undefined;
|
|
563
|
-
const
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
return preferred || (preferredPhase === 'before' ? matches[0] : matches[matches.length - 1]);
|
|
573
|
+
const frame = preferredPhase === 'before' ? matches[0] : matches[matches.length - 1];
|
|
574
|
+
// A failed capture must not fall back to an older round or a before/error image.
|
|
575
|
+
return frame?.phase === preferredPhase ? frame : undefined;
|
|
567
576
|
}
|
|
568
577
|
|
|
569
578
|
const EMPTY_PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=';
|
|
@@ -574,12 +583,14 @@ function registerVisualChangeStep(
|
|
|
574
583
|
): RunStepResult {
|
|
575
584
|
const config = step.visualChange;
|
|
576
585
|
if (!config) throw new Error(`${step.label} 缺少画面变化检测配置`);
|
|
586
|
+
const context = replayContext.getStore();
|
|
577
587
|
if (config.role === 'start') {
|
|
588
|
+
// Invalidate the previous capture even when this start's after screenshot fails.
|
|
589
|
+
context?.visualStartOffsets.set(JSON.stringify([meta.frameStart || 0, step.id]), context.frames.length);
|
|
578
590
|
return {
|
|
579
591
|
message: `已记录画面变化基准点:${config.pairLabel || step.label}`,
|
|
580
592
|
};
|
|
581
593
|
}
|
|
582
|
-
const context = replayContext.getStore();
|
|
583
594
|
const endStep = {
|
|
584
595
|
...step,
|
|
585
596
|
visualChange: {
|
|
@@ -587,7 +598,9 @@ function registerVisualChangeStep(
|
|
|
587
598
|
endStepId: config.endStepId || step.id,
|
|
588
599
|
},
|
|
589
600
|
};
|
|
590
|
-
context?.pendingVisualChecks.push({ step: endStep, nodeNumber: meta.nodeNumber, scriptName: meta.scriptName
|
|
601
|
+
context?.pendingVisualChecks.push({ step: endStep, nodeNumber: meta.nodeNumber, scriptName: meta.scriptName,
|
|
602
|
+
frameStart: context.visualStartOffsets.get(JSON.stringify([meta.frameStart || 0, config.startStepId])) ?? meta.frameStart ?? 0,
|
|
603
|
+
endFrameStart: context.frames.length });
|
|
591
604
|
return {
|
|
592
605
|
message: `已记录画面变化对比点:${config.pairLabel || step.label}`,
|
|
593
606
|
};
|
|
@@ -624,16 +637,16 @@ function processPendingVisualChecks(lines: string[]) {
|
|
|
624
637
|
const context = replayContext.getStore();
|
|
625
638
|
if (!context || !context.pendingVisualChecks.length) return;
|
|
626
639
|
|
|
627
|
-
//
|
|
628
|
-
//
|
|
640
|
+
// Resolve each pair immediately after its end capture, before later rounds
|
|
641
|
+
// or linked-script calls can replace the images associated with these IDs.
|
|
629
642
|
context.pendingVisualChecks.forEach((check) => {
|
|
630
643
|
const config = check.step.visualChange;
|
|
631
644
|
if (!config?.startStepId || !config.endStepId) {
|
|
632
645
|
appendVisualCheckFailure(lines, check, `${check.step.label} 缺少开始节点或结束节点`);
|
|
633
646
|
return;
|
|
634
647
|
}
|
|
635
|
-
const baselineFrame = findVisualFrame(context.frames, config.startStepId, check.scriptName, 'after');
|
|
636
|
-
const comparisonFrame = findVisualFrame(context.frames, config.endStepId, check.scriptName, 'after');
|
|
648
|
+
const baselineFrame = findVisualFrame(context.frames.slice(check.frameStart, check.endFrameStart), config.startStepId, check.scriptName, 'after');
|
|
649
|
+
const comparisonFrame = findVisualFrame(context.frames.slice(check.endFrameStart), config.endStepId, check.scriptName, 'after');
|
|
637
650
|
if (!baselineFrame || !comparisonFrame) {
|
|
638
651
|
const missing = [
|
|
639
652
|
baselineFrame ? '' : '开始节点截图',
|
|
@@ -1005,12 +1018,17 @@ async function appendStepDiagnostics(lines: string[], deviceId: string, step: Ap
|
|
|
1005
1018
|
}
|
|
1006
1019
|
|
|
1007
1020
|
type ReplayStepOptions = {
|
|
1021
|
+
frameStart?: number;
|
|
1008
1022
|
skipAppInitialization?: boolean;
|
|
1009
1023
|
scriptName?: string;
|
|
1010
1024
|
};
|
|
1011
1025
|
|
|
1012
|
-
function shouldSkipLinkedScriptInitStep(options: ReplayStepOptions, step: AppiumRecordedStepRecord) {
|
|
1013
|
-
|
|
1026
|
+
function shouldSkipLinkedScriptInitStep(options: ReplayStepOptions, step: AppiumRecordedStepRecord, steps: AppiumRecordedStepRecord[]) {
|
|
1027
|
+
if (!options.skipAppInitialization) return false;
|
|
1028
|
+
// Only the leading initialization sequence is redundant in a linked script.
|
|
1029
|
+
// Launches inside branches or after other operations are intentional actions.
|
|
1030
|
+
return steps.slice(0, steps.indexOf(step) + 1).every(item =>
|
|
1031
|
+
!item.flow?.parentConditionId && (item.type === 'launchApp' || item.type === 'clearAppData'));
|
|
1014
1032
|
}
|
|
1015
1033
|
|
|
1016
1034
|
function linkedScriptInitSkipReason(step: AppiumRecordedStepRecord) {
|
|
@@ -1090,7 +1108,7 @@ async function replayLinearSteps(
|
|
|
1090
1108
|
for (const [index, step] of steps.entries()) {
|
|
1091
1109
|
if (replayContext.getStore()?.flowEnded) return;
|
|
1092
1110
|
throwIfReplayStopped();
|
|
1093
|
-
if (shouldSkipLinkedScriptInitStep(options, step)) {
|
|
1111
|
+
if (shouldSkipLinkedScriptInitStep(options, step, steps)) {
|
|
1094
1112
|
lines.push(`[节点 ${index + 1}] 跳过:${step.label}(${linkedScriptInitSkipReason(step)})`);
|
|
1095
1113
|
continue;
|
|
1096
1114
|
}
|
|
@@ -1104,11 +1122,13 @@ async function replayLinearSteps(
|
|
|
1104
1122
|
const result = await runStep(sessionId, deviceId, step, {
|
|
1105
1123
|
nodeNumber: index + 1,
|
|
1106
1124
|
scriptName: options.scriptName || '',
|
|
1125
|
+
frameStart: options.frameStart,
|
|
1107
1126
|
});
|
|
1108
1127
|
softFailed = appendRunStepResult(lines, index + 1, result);
|
|
1109
1128
|
}
|
|
1110
1129
|
lines.push(`[节点 ${index + 1}] 完成:${step.label}${softFailed ? '(存在软失败,已继续)' : ''}`);
|
|
1111
1130
|
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'after', completedStepStatus(step, softFailed));
|
|
1131
|
+
processPendingVisualChecks(lines);
|
|
1112
1132
|
} catch (error) {
|
|
1113
1133
|
if (isReplayStopped(error)) {
|
|
1114
1134
|
lines.push(`[节点 ${index + 1}] 已终止:${step.label}`);
|
|
@@ -1144,7 +1164,7 @@ async function replayFlowSteps(
|
|
|
1144
1164
|
traversal.visit(step);
|
|
1145
1165
|
const loopContext = traversal.context(step);
|
|
1146
1166
|
const frameStep = loopContext ? { ...step, label: `${step.label}(${loopContext})` } : step;
|
|
1147
|
-
if (shouldSkipLinkedScriptInitStep(options, step)) {
|
|
1167
|
+
if (shouldSkipLinkedScriptInitStep(options, step, steps)) {
|
|
1148
1168
|
lines.push(`[节点 ${index + 1}] 跳过:${step.label}(${linkedScriptInitSkipReason(step)})`);
|
|
1149
1169
|
index = nextIndexAfterStep(index, step);
|
|
1150
1170
|
continue;
|
|
@@ -1225,11 +1245,13 @@ async function replayFlowSteps(
|
|
|
1225
1245
|
const result = await runStep(sessionId, deviceId, step, {
|
|
1226
1246
|
nodeNumber: index + 1,
|
|
1227
1247
|
scriptName: options.scriptName || '',
|
|
1248
|
+
frameStart: options.frameStart,
|
|
1228
1249
|
});
|
|
1229
1250
|
softFailed = appendRunStepResult(lines, index + 1, result);
|
|
1230
1251
|
}
|
|
1231
1252
|
lines.push(`[节点 ${index + 1}] 完成:${step.label}${softFailed ? '(存在软失败,已继续)' : ''}`);
|
|
1232
1253
|
await captureReplayFrame(sessionId, frameStep, index + 1, options.scriptName || '', 'after', completedStepStatus(step, softFailed));
|
|
1254
|
+
processPendingVisualChecks(lines);
|
|
1233
1255
|
index = nextIndexAfterStep(index, step);
|
|
1234
1256
|
} catch (error) {
|
|
1235
1257
|
if (isReplayStopped(error)) {
|
|
@@ -1278,6 +1300,7 @@ async function replayScriptSteps(
|
|
|
1278
1300
|
options: ReplayStepOptions = {},
|
|
1279
1301
|
) {
|
|
1280
1302
|
validateLoopSteps(steps);
|
|
1303
|
+
options = { ...options, frameStart: replayContext.getStore()?.frames.length || 0 };
|
|
1281
1304
|
let trailingLinkIndex = steps.length;
|
|
1282
1305
|
while (
|
|
1283
1306
|
trailingLinkIndex > 0
|
|
@@ -1325,6 +1348,7 @@ export async function replayAppiumScript(
|
|
|
1325
1348
|
if (!script.steps.length) throw new Error('脚本没有可回放步骤');
|
|
1326
1349
|
|
|
1327
1350
|
const startedAt = new Date();
|
|
1351
|
+
const appVersion = await readAppVersion(targetDeviceId, script.appPackage).catch(() => '');
|
|
1328
1352
|
const lines: string[] = [];
|
|
1329
1353
|
const pushLine = lines.push.bind(lines);
|
|
1330
1354
|
lines.push = (...items: string[]) => {
|
|
@@ -1338,7 +1362,9 @@ export async function replayAppiumScript(
|
|
|
1338
1362
|
serverUrl: configuredAppiumServerUrl(),
|
|
1339
1363
|
deviceId: targetDeviceId,
|
|
1340
1364
|
frames: [] as AppiumReplayFrame[],
|
|
1365
|
+
historyEvents: [] as AppiumReplayFrame[],
|
|
1341
1366
|
visualChecks: [] as AppiumReplayVisualCheck[],
|
|
1367
|
+
visualStartOffsets: new Map<string, number>(),
|
|
1342
1368
|
pendingVisualChecks: [] as PendingVisualChangeCheck[],
|
|
1343
1369
|
softFailureCount: 0,
|
|
1344
1370
|
};
|
|
@@ -1461,6 +1487,14 @@ export async function replayAppiumScript(
|
|
|
1461
1487
|
logPath,
|
|
1462
1488
|
htmlReportPath,
|
|
1463
1489
|
softFailureCount: context.softFailureCount,
|
|
1490
|
+
history: {
|
|
1491
|
+
scriptId: script.id, scriptName: script.name, appPackage: script.appPackage,
|
|
1492
|
+
appVersion, deviceId: targetDeviceId, startedAt: startedAt.toISOString(),
|
|
1493
|
+
durationMs: completedAt.getTime() - startedAt.getTime(),
|
|
1494
|
+
status: stopped ? 'stopped' as const : success ? 'passed' as const : 'failed' as const,
|
|
1495
|
+
output: lines.join('\n'),
|
|
1496
|
+
...captureHistoryFrames(context.frames, context.visualChecks, context.historyEvents),
|
|
1497
|
+
},
|
|
1464
1498
|
};
|
|
1465
1499
|
});
|
|
1466
1500
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { getAdbCommand } from '../android-sdk';
|
|
3
|
+
import { htmlImageUrl, type AppiumReplayFrame, type AppiumReplayVisualCheck } from './report';
|
|
4
|
+
import type { HistoryNode } from '../../src/appium-recorder/run-history';
|
|
5
|
+
|
|
6
|
+
export function readAppVersion(deviceId: string, appPackage: string): Promise<string> {
|
|
7
|
+
if (!appPackage) return Promise.resolve('');
|
|
8
|
+
return new Promise(resolve => {
|
|
9
|
+
execFile(getAdbCommand(), ['-s', deviceId, 'shell', 'dumpsys', 'package', appPackage], { timeout: 5000, maxBuffer: 4 * 1024 * 1024 }, (error, stdout) => {
|
|
10
|
+
if (error) return resolve('');
|
|
11
|
+
const name = stdout.match(/\bversionName=([^\r\n]+)/)?.[1]?.trim();
|
|
12
|
+
const code = stdout.match(/\bversionCode=(\d+)/)?.[1];
|
|
13
|
+
resolve(name ? `${name}${code ? ` (${code})` : ''}` : '');
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function captureHistoryFrames(frames: AppiumReplayFrame[], checks: AppiumReplayVisualCheck[], events = frames) {
|
|
19
|
+
const cache = new Map<string, string>();
|
|
20
|
+
const nodes: HistoryNode[] = [];
|
|
21
|
+
const pending = new Map<string, AppiumReplayFrame>();
|
|
22
|
+
for (const frame of events) {
|
|
23
|
+
const key = frame.nodeId;
|
|
24
|
+
if (frame.phase === 'before') { pending.set(key, frame); continue; }
|
|
25
|
+
const start = pending.get(key);
|
|
26
|
+
pending.delete(key);
|
|
27
|
+
if (frame.phase !== 'stopped') nodes.push({ key, label: `${frame.scriptName} / ${frame.nodeLabel}`, failed: frame.phase === 'error', durationMs: start ? Math.max(0, Date.parse(frame.capturedAt) - Date.parse(start.capturedAt)) : null });
|
|
28
|
+
}
|
|
29
|
+
for (const check of checks.filter(check => check.status === 'failed')) {
|
|
30
|
+
const node = nodes.find(node => node.key === check.nodeId);
|
|
31
|
+
if (node) node.failed = true;
|
|
32
|
+
}
|
|
33
|
+
return { nodes, frames: frames.map(frame => ({ key: frame.nodeId, label: frame.nodeLabel, phase: frame.phase, capturedAt: frame.capturedAt, imageUrl: htmlImageUrl(frame.imageBase64, cache) })) };
|
|
34
|
+
}
|
|
@@ -126,7 +126,7 @@ function compressHtmlPngBase64(base64: string) {
|
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
function htmlImageUrl(base64: string, cache: Map<string, string>) {
|
|
129
|
+
export function htmlImageUrl(base64: string, cache: Map<string, string>) {
|
|
130
130
|
const cached = cache.get(base64);
|
|
131
131
|
if (cached) return cached;
|
|
132
132
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
|
+
import { deleteRunHistory, getRunHistory, listRunHistory, saveRunHistory } from './run-history';
|
|
2
3
|
import { recognizeDeviceScreen } from './ai-recognition';
|
|
3
4
|
import { readFreshWindowHierarchy } from './tree-dump';
|
|
4
5
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
@@ -307,6 +308,21 @@ export async function handleAppiumRecorderRequest(
|
|
|
307
308
|
return true;
|
|
308
309
|
}
|
|
309
310
|
|
|
311
|
+
const historyMatch = pathname.match(/^\/api\/appium-recorder\/scripts\/([^/]+)\/history(?:\/([^/]+))?$/);
|
|
312
|
+
if (historyMatch) {
|
|
313
|
+
const scriptId = decodeURIComponent(historyMatch[1]);
|
|
314
|
+
const id = historyMatch[2] ? decodeURIComponent(historyMatch[2]) : '';
|
|
315
|
+
if (req.method === 'GET') {
|
|
316
|
+
const data = id ? getRunHistory(scriptId, id) : listRunHistory(scriptId);
|
|
317
|
+
sendJson(res, data, data ? 200 : 404);
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
if (req.method === 'DELETE' && id) {
|
|
321
|
+
deleteRunHistory(scriptId, id);
|
|
322
|
+
sendJson(res, { success: true });
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
310
326
|
const replayMatch = pathname.match(/^\/api\/appium-recorder\/scripts\/([^/]+)\/replay$/);
|
|
311
327
|
if (replayMatch && req.method === 'POST') {
|
|
312
328
|
const parsed = await readBody<{ deviceId?: string }>(req);
|
|
@@ -323,13 +339,18 @@ export async function handleAppiumRecorderRequest(
|
|
|
323
339
|
res.flushHeaders();
|
|
324
340
|
}
|
|
325
341
|
if (isRemoteDeviceId(deviceId)) {
|
|
326
|
-
const result = await sendRemoteCommand(deviceId, 'replay', { script }) as
|
|
342
|
+
const result = await sendRemoteCommand(deviceId, 'replay', { script }) as Awaited<ReturnType<typeof replayAppiumScript>>;
|
|
343
|
+
if (result.history) {
|
|
344
|
+
try { saveRunHistory({ ...result.history, scriptId: script.id, deviceId }); }
|
|
345
|
+
catch { result.output += '\n历史记录保存失败'; }
|
|
346
|
+
}
|
|
347
|
+
const { history: _history, ...response } = result;
|
|
327
348
|
if (streamOutput) {
|
|
328
349
|
result.output?.split(/\r?\n/).forEach((line) => sendStreamEvent(res, { type: 'log', line }));
|
|
329
|
-
sendStreamEvent(res, { type: 'result', ...
|
|
350
|
+
sendStreamEvent(res, { type: 'result', ...response });
|
|
330
351
|
res.end();
|
|
331
352
|
} else {
|
|
332
|
-
sendJson(res,
|
|
353
|
+
sendJson(res, response, result.success ? 200 : 500);
|
|
333
354
|
}
|
|
334
355
|
return true;
|
|
335
356
|
}
|
|
@@ -346,11 +367,14 @@ export async function handleAppiumRecorderRequest(
|
|
|
346
367
|
streamOutput ? (line) => sendStreamEvent(res, { type: 'log', line }) : undefined,
|
|
347
368
|
replayAbortController.signal,
|
|
348
369
|
);
|
|
370
|
+
try { saveRunHistory(result.history); }
|
|
371
|
+
catch { result.output += '\n历史记录保存失败'; }
|
|
372
|
+
const { history: _history, ...response } = result;
|
|
349
373
|
if (streamOutput) {
|
|
350
|
-
sendStreamEvent(res, { type: 'result', ...
|
|
374
|
+
sendStreamEvent(res, { type: 'result', ...response });
|
|
351
375
|
res.end();
|
|
352
376
|
} else {
|
|
353
|
-
sendJson(res,
|
|
377
|
+
sendJson(res, response, result.success ? 200 : 500);
|
|
354
378
|
}
|
|
355
379
|
} finally {
|
|
356
380
|
replayingDevices.delete(deviceId);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createId, querySql, runSql, sqlJson, sqlString } from '../storage/sqlite';
|
|
2
|
+
import type { RunDetail, RunSummary } from '../../src/appium-recorder/run-history';
|
|
3
|
+
|
|
4
|
+
function ensureTable() {
|
|
5
|
+
runSql(`CREATE TABLE IF NOT EXISTS appium_run_history (
|
|
6
|
+
id TEXT PRIMARY KEY, script_id TEXT NOT NULL, started_at TEXT NOT NULL,
|
|
7
|
+
summary TEXT NOT NULL, detail TEXT NOT NULL
|
|
8
|
+
); CREATE INDEX IF NOT EXISTS appium_run_history_script ON appium_run_history(script_id, started_at);`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function saveRunHistory(input: Omit<RunDetail, 'id'>) {
|
|
12
|
+
ensureTable();
|
|
13
|
+
const record = { ...input, id: createId('run') };
|
|
14
|
+
const { frames, output, ...summary } = record;
|
|
15
|
+
runSql(`INSERT INTO appium_run_history VALUES (${sqlString(record.id)}, ${sqlString(record.scriptId)}, ${sqlString(record.startedAt)}, ${sqlJson(summary)}, ${sqlJson({ frames, output })});`);
|
|
16
|
+
return record.id;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function listRunHistory(scriptId: string): RunSummary[] {
|
|
20
|
+
ensureTable();
|
|
21
|
+
return (querySql<{ summary: string }>(`SELECT summary FROM appium_run_history WHERE script_id=${sqlString(scriptId)} ORDER BY started_at DESC;`) || []).map(row => JSON.parse(row.summary));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getRunHistory(scriptId: string, id: string): RunDetail | null {
|
|
25
|
+
ensureTable();
|
|
26
|
+
const row = querySql<{ summary: string; detail: string }>(`SELECT summary, detail FROM appium_run_history WHERE script_id=${sqlString(scriptId)} AND id=${sqlString(id)};`)?.[0];
|
|
27
|
+
return row ? { ...JSON.parse(row.summary), ...JSON.parse(row.detail) } : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function deleteRunHistory(scriptId: string, id: string) {
|
|
31
|
+
ensureTable();
|
|
32
|
+
// 不接受文件路径,也不删除原始报告;只删除这条历史的自包含快照。
|
|
33
|
+
runSql(`PRAGMA secure_delete=ON; DELETE FROM appium_run_history WHERE script_id=${sqlString(scriptId)} AND id=${sqlString(id)};`);
|
|
34
|
+
}
|
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
import { computed, h, onMounted, onUnmounted, provide, reactive, shallowRef, watch } from 'vue';
|
|
3
3
|
import { flowBackgroundKey, normalizeFlowBackground } from './flow-appearance';
|
|
4
4
|
import { ElForm, ElFormItem, ElInputNumber, ElMessage, ElMessageBox, ElOption, ElSelect } from 'element-plus';
|
|
5
|
-
import { Check, CircleClose, CopyDocument, Delete, Document, Download, Edit, Plus, Refresh, Upload, VideoPlay, View } from '@element-plus/icons-vue';
|
|
5
|
+
import { Check, CircleClose, CopyDocument, Delete, Document, Download, Edit, Plus, Refresh, Upload, VideoPlay, View, Clock } from '@element-plus/icons-vue';
|
|
6
|
+
import RunHistoryDialog from './RunHistoryDialog.vue';
|
|
6
7
|
import type { AndroidDevice, AppPreset, DeviceAction } from '../types';
|
|
7
8
|
import DevicePreviewPanel from '../components/device/DevicePreviewPanel.vue';
|
|
8
9
|
import LoopSettings from './components/LoopSettings.vue';
|
|
9
10
|
import BreakLoopSettings from './components/BreakLoopSettings.vue';
|
|
11
|
+
import VisualChangeEndPicker from './components/VisualChangeEndPicker.vue';
|
|
10
12
|
import { defaultLoopConfig, enclosingLoops, breakLoopTarget, validateLoop, validateLoopSteps } from './bounded-loop';
|
|
11
13
|
import {
|
|
12
14
|
clearAppiumDeviceAppData,
|
|
@@ -53,7 +55,7 @@ import {
|
|
|
53
55
|
visualRegionToBounds,
|
|
54
56
|
} from './visual-change';
|
|
55
57
|
import {
|
|
56
|
-
|
|
58
|
+
findOpenVisualChangeStarts,
|
|
57
59
|
nextVisualChangePairLabel,
|
|
58
60
|
} from './visual-change-pairs';
|
|
59
61
|
import type {
|
|
@@ -80,6 +82,7 @@ type RecorderAction = InsertAction;
|
|
|
80
82
|
|
|
81
83
|
const WORKBENCH_TAB_STORAGE_KEY = 'android-midscene-automation:appium-workbench-tab';
|
|
82
84
|
const workspaceRef = shallowRef<InstanceType<typeof RecorderWorkspace>>();
|
|
85
|
+
const historyScript = shallowRef<{ id: string; name: string } | null>(null);
|
|
83
86
|
const AUTO_TREE_REFRESH_INTERVAL_MS = 1800;
|
|
84
87
|
|
|
85
88
|
const insertableRecorderActions: RecorderAction[] = [
|
|
@@ -1266,11 +1269,6 @@ async function addAction(
|
|
|
1266
1269
|
) {
|
|
1267
1270
|
if (recordingBusy.value) return;
|
|
1268
1271
|
if (!ensureAppPackageSelected()) return;
|
|
1269
|
-
const previousStep = typeof index === 'number' && index >= 0 ? steps.value[index] : undefined;
|
|
1270
|
-
if (action === 'launchApp' && steps.value.some((step) => step.type === 'launchApp')) {
|
|
1271
|
-
ElMessage.warning('启动 APP 节点只能添加一个');
|
|
1272
|
-
return;
|
|
1273
|
-
}
|
|
1274
1272
|
if (action === 'clearAppData' && steps.value.some((step) => step.type === 'clearAppData')) {
|
|
1275
1273
|
ElMessage.warning('清理 App 缓存节点只能添加一个');
|
|
1276
1274
|
return;
|
|
@@ -1279,10 +1277,6 @@ async function addAction(
|
|
|
1279
1277
|
ElMessage.warning('清理 App 缓存只能从开始节点添加');
|
|
1280
1278
|
return;
|
|
1281
1279
|
}
|
|
1282
|
-
if (action === 'launchApp' && index !== -1 && previousStep?.type !== 'clearAppData') {
|
|
1283
|
-
ElMessage.warning('启动 APP 只能从开始节点或清理 App 缓存后添加');
|
|
1284
|
-
return;
|
|
1285
|
-
}
|
|
1286
1280
|
if (action === 'runScript') {
|
|
1287
1281
|
openLinkedScriptDialog(index, branchTarget);
|
|
1288
1282
|
return;
|
|
@@ -1406,11 +1400,31 @@ async function addAction(
|
|
|
1406
1400
|
return;
|
|
1407
1401
|
}
|
|
1408
1402
|
if (action === 'visualChangeEnd') {
|
|
1409
|
-
const
|
|
1410
|
-
if (!
|
|
1403
|
+
const candidates = findOpenVisualChangeStarts(steps.value, index, branchTarget);
|
|
1404
|
+
if (!candidates.length) {
|
|
1411
1405
|
ElMessage.warning('当前分支没有可对应的检测画面变化开始节点');
|
|
1412
1406
|
return;
|
|
1413
1407
|
}
|
|
1408
|
+
let selectedId = candidates.length === 1 ? candidates[0].id : '';
|
|
1409
|
+
if (candidates.length > 1) {
|
|
1410
|
+
selectedId = await new Promise<string>((resolve) => { void ElMessageBox({
|
|
1411
|
+
title: '选择要结束的画面变化检测',
|
|
1412
|
+
showConfirmButton: false,
|
|
1413
|
+
showCancelButton: false,
|
|
1414
|
+
callback: () => resolve(''),
|
|
1415
|
+
message: h(VisualChangeEndPicker, {
|
|
1416
|
+
candidates: candidates.map(step => ({ id: step.id, label: step.visualChange?.pairLabel || step.label, number: steps.value.findIndex(item => item.id === step.id) + 1 })),
|
|
1417
|
+
onSelect: (id: string) => { resolve(id); ElMessageBox.close(); },
|
|
1418
|
+
}),
|
|
1419
|
+
}); });
|
|
1420
|
+
if (!selectedId) return;
|
|
1421
|
+
}
|
|
1422
|
+
// 选择期间流程可能发生变化;插入前重新检查配对,不能使用过期候选。
|
|
1423
|
+
const startStep = findOpenVisualChangeStarts(steps.value, index, branchTarget).find(step => step.id === selectedId);
|
|
1424
|
+
if (!startStep) {
|
|
1425
|
+
ElMessage.warning('该开始节点已结束或不再属于当前路径,请重新选择');
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1414
1428
|
const inserted = insertStep(createVisualChangeEndStep(startStep), index, branchTarget);
|
|
1415
1429
|
ElMessage.success(`已添加 ${inserted.label}`);
|
|
1416
1430
|
return inserted;
|
|
@@ -1460,14 +1474,12 @@ async function addAction(
|
|
|
1460
1474
|
return;
|
|
1461
1475
|
}
|
|
1462
1476
|
if (action === 'launchApp') {
|
|
1463
|
-
const clearStepIndex = steps.value.findIndex((step) => step.type === 'clearAppData');
|
|
1464
|
-
const insertIndex = previousStep?.type === 'clearAppData' ? index : clearStepIndex >= 0 ? clearStepIndex : index;
|
|
1465
1477
|
return insertStep({
|
|
1466
1478
|
id: `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
|
|
1467
1479
|
type: 'launchApp',
|
|
1468
1480
|
label: `启动 APP ${form.appPackage}`,
|
|
1469
1481
|
value: form.appPackage,
|
|
1470
|
-
},
|
|
1482
|
+
}, index, branchTarget);
|
|
1471
1483
|
}
|
|
1472
1484
|
if (action === 'clearAppData') {
|
|
1473
1485
|
return insertStep({
|
|
@@ -2208,6 +2220,9 @@ watch(
|
|
|
2208
2220
|
<small>{{ script.steps.length }} 步 · {{ formatScriptTime(script.updatedAt) }}</small>
|
|
2209
2221
|
</div>
|
|
2210
2222
|
<div class="appium-script-list__actions">
|
|
2223
|
+
<el-tooltip content="历史结果对比" placement="top" :show-after="200"><span class="appium-script-icon">
|
|
2224
|
+
<el-button size="small" :icon="Clock" aria-label="历史结果对比" @click.stop="historyScript = { id: script.id, name: script.name }" />
|
|
2225
|
+
</span></el-tooltip>
|
|
2211
2226
|
<el-tooltip content="加载" placement="top" :show-after="200"><span class="appium-script-icon">
|
|
2212
2227
|
<el-button size="small" :icon="Document" aria-label="加载" @click.stop="loadScript(script)" />
|
|
2213
2228
|
</span></el-tooltip>
|
|
@@ -2258,6 +2273,7 @@ watch(
|
|
|
2258
2273
|
</el-card>
|
|
2259
2274
|
</RecorderWorkspace>
|
|
2260
2275
|
|
|
2276
|
+
<RunHistoryDialog v-if="historyScript" :script-id="historyScript.id" :script-name="historyScript.name" @close="historyScript = null" />
|
|
2261
2277
|
<NewScriptDialog
|
|
2262
2278
|
v-if="newScriptDialogVisible"
|
|
2263
2279
|
:script-name="form.name"
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, ref } from 'vue';
|
|
3
|
+
import { Delete, Refresh } from '@element-plus/icons-vue';
|
|
4
|
+
import { ElMessage, ElMessageBox } from 'element-plus';
|
|
5
|
+
import { historyStatistics, type RunSummary, type RunDetail } from './run-history';
|
|
6
|
+
|
|
7
|
+
const props = defineProps<{ scriptId: string; scriptName: string }>();
|
|
8
|
+
const emit = defineEmits<{ close: [] }>();
|
|
9
|
+
const runs = ref<RunSummary[]>([]);
|
|
10
|
+
const selected = ref<RunSummary[]>([]);
|
|
11
|
+
const details = ref<RunDetail[]>([]);
|
|
12
|
+
const loading = ref(false);
|
|
13
|
+
const comparing = ref(false);
|
|
14
|
+
const version = ref('');
|
|
15
|
+
const device = ref('');
|
|
16
|
+
const error = ref('');
|
|
17
|
+
const nodeKey = ref('');
|
|
18
|
+
const logQuery = ref('');
|
|
19
|
+
const base = `/api/appium-recorder/scripts/${encodeURIComponent(props.scriptId)}/history`;
|
|
20
|
+
const labels = { passed: '通过', failed: '失败', stopped: '已终止' };
|
|
21
|
+
const versions = computed(() => [...new Set(runs.value.map(run => run.appVersion || '未知'))]);
|
|
22
|
+
const devices = computed(() => [...new Set(runs.value.map(run => run.deviceId))]);
|
|
23
|
+
const filtered = computed(() => runs.value.filter(run => (!version.value || (run.appVersion || '未知') === version.value) && (!device.value || run.deviceId === device.value)));
|
|
24
|
+
const stats = computed(() => historyStatistics(filtered.value));
|
|
25
|
+
const trend = computed(() => [...filtered.value].sort((a, b) => a.startedAt.localeCompare(b.startedAt)));
|
|
26
|
+
const maxDuration = computed(() => Math.max(1, ...trend.value.map(run => run.durationMs)));
|
|
27
|
+
const points = computed(() => trend.value.map((run, index) => `${20 + index * 760 / Math.max(1, trend.value.length - 1)},${130 - run.durationMs / maxDuration.value * 110}`).join(' '));
|
|
28
|
+
const comparisonNodes = computed(() => [...new Map(details.value.flatMap(run => run.nodes.map(node => [node.key, node] as const))).values()]);
|
|
29
|
+
const time = (value: string) => new Date(value).toLocaleString('zh-CN', { hour12: false });
|
|
30
|
+
const seconds = (value: number) => `${(value / 1000).toFixed(2)}s`;
|
|
31
|
+
|
|
32
|
+
async function request<T>(url: string, method = 'GET'): Promise<T> {
|
|
33
|
+
const response = await fetch(url, { method });
|
|
34
|
+
if (!response.ok) throw new Error('历史结果请求失败');
|
|
35
|
+
return response.json();
|
|
36
|
+
}
|
|
37
|
+
async function load() {
|
|
38
|
+
loading.value = true; error.value = '';
|
|
39
|
+
try { runs.value = await request<RunSummary[]>(base); selected.value = []; }
|
|
40
|
+
catch (cause) { error.value = String(cause); }
|
|
41
|
+
finally { loading.value = false; }
|
|
42
|
+
}
|
|
43
|
+
async function compare() {
|
|
44
|
+
comparing.value = true;
|
|
45
|
+
try {
|
|
46
|
+
details.value = await Promise.all([...selected.value].sort((a, b) => a.startedAt.localeCompare(b.startedAt)).map(run => request<RunDetail>(`${base}/${encodeURIComponent(run.id)}`)));
|
|
47
|
+
nodeKey.value = '';
|
|
48
|
+
} catch (cause) { ElMessage.error(String(cause)); }
|
|
49
|
+
finally { comparing.value = false; }
|
|
50
|
+
}
|
|
51
|
+
async function remove() {
|
|
52
|
+
try { await ElMessageBox.confirm(`删除选中的 ${selected.value.length} 条历史及截图、日志快照?原始报告文件不受影响。`, '删除历史结果', { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' }); }
|
|
53
|
+
catch { return; }
|
|
54
|
+
loading.value = true;
|
|
55
|
+
try {
|
|
56
|
+
for (const run of selected.value) await request(`${base}/${encodeURIComponent(run.id)}`, 'DELETE');
|
|
57
|
+
details.value = [];
|
|
58
|
+
await load();
|
|
59
|
+
} catch (cause) { ElMessage.error(String(cause)); await load(); }
|
|
60
|
+
finally { loading.value = false; }
|
|
61
|
+
}
|
|
62
|
+
function nodeDuration(run: RunDetail) {
|
|
63
|
+
const matches = run.nodes.filter(node => node.key === nodeKey.value);
|
|
64
|
+
if (!matches.length) return '未执行';
|
|
65
|
+
if (matches.some(node => node.durationMs === null)) return '耗时不完整';
|
|
66
|
+
return seconds(matches.reduce((sum, node) => sum + (node.durationMs || 0), 0));
|
|
67
|
+
}
|
|
68
|
+
onMounted(load);
|
|
69
|
+
</script>
|
|
70
|
+
|
|
71
|
+
<template>
|
|
72
|
+
<el-dialog :model-value="true" :title="`${scriptName} · 历史结果对比`" width="min(1200px, 96vw)" align-center append-to-body @closed="emit('close')">
|
|
73
|
+
<div v-loading="loading" class="run-history">
|
|
74
|
+
<div class="history-toolbar">
|
|
75
|
+
<el-select v-model="version" clearable placeholder="全部应用版本" aria-label="应用版本"><el-option v-for="item in versions" :key="item" :value="item" /></el-select>
|
|
76
|
+
<el-select v-model="device" clearable placeholder="全部设备" aria-label="设备"><el-option v-for="item in devices" :key="item" :value="item" /></el-select>
|
|
77
|
+
<el-tooltip content="刷新历史"><el-button :icon="Refresh" aria-label="刷新历史" @click="load" /></el-tooltip>
|
|
78
|
+
<el-button :disabled="selected.length !== 2" :loading="comparing" @click="compare">对比两次运行</el-button>
|
|
79
|
+
<el-tooltip content="删除选中历史"><el-button :icon="Delete" type="danger" plain :disabled="!selected.length" aria-label="删除选中历史" @click="remove" /></el-tooltip>
|
|
80
|
+
</div>
|
|
81
|
+
<el-alert v-if="error" :title="error" type="error" :closable="false" />
|
|
82
|
+
<el-empty v-else-if="!runs.length && !loading" description="暂无历史结果,后续运行会自动记录" />
|
|
83
|
+
<template v-else>
|
|
84
|
+
<p>{{ filtered.length }} 次运行 · 通过率 {{ stats.passRate === null ? '—' : `${(stats.passRate * 100).toFixed(1)}%` }}(不含手动终止)</p>
|
|
85
|
+
<h3>耗时趋势</h3>
|
|
86
|
+
<svg class="history-trend" viewBox="0 0 800 160" role="img" aria-label="按时间从早到晚的运行耗时趋势">
|
|
87
|
+
<path d="M20 10V130H790" fill="none" stroke="#a0a8ad" />
|
|
88
|
+
<polyline :points="points" fill="none" stroke="#168a75" stroke-width="2" />
|
|
89
|
+
<circle v-for="(run, index) in trend" :key="run.id" :cx="20 + index * 760 / Math.max(1, trend.length - 1)" :cy="130 - run.durationMs / maxDuration * 110" r="4" :fill="run.status === 'failed' ? '#d14343' : '#168a75'">
|
|
90
|
+
<title>{{ time(run.startedAt) }} · {{ seconds(run.durationMs) }} · {{ run.appVersion || '未知版本' }}</title>
|
|
91
|
+
</circle>
|
|
92
|
+
<text x="24" y="14">{{ seconds(maxDuration) }}</text><text x="20" y="153">{{ trend[0] ? time(trend[0].startedAt) : '' }}</text><text x="780" y="153" text-anchor="end">{{ trend.length > 1 ? time(trend[trend.length - 1].startedAt) : '' }}</text>
|
|
93
|
+
</svg>
|
|
94
|
+
<el-table :data="filtered" row-key="id" max-height="270" @selection-change="selected = $event">
|
|
95
|
+
<el-table-column type="selection" width="42" />
|
|
96
|
+
<el-table-column label="运行时间" min-width="180"><template #default="{ row }">{{ time(row.startedAt) }}</template></el-table-column>
|
|
97
|
+
<el-table-column prop="appVersion" label="应用版本" min-width="140"><template #default="{ row }">{{ row.appVersion || '未知' }}</template></el-table-column>
|
|
98
|
+
<el-table-column prop="deviceId" label="设备" min-width="140" />
|
|
99
|
+
<el-table-column label="结果" width="85"><template #default="{ row }">{{ labels[row.status as keyof typeof labels] }}</template></el-table-column>
|
|
100
|
+
<el-table-column label="耗时" width="100"><template #default="{ row }">{{ seconds(row.durationMs) }}</template></el-table-column>
|
|
101
|
+
</el-table>
|
|
102
|
+
<h3>失败节点分布</h3>
|
|
103
|
+
<el-table :data="stats.failures" max-height="220" empty-text="暂无节点失败记录">
|
|
104
|
+
<el-table-column prop="label" label="节点" min-width="220" />
|
|
105
|
+
<el-table-column label="失败 / 到达次数" width="145"><template #default="{ row }">{{ row.failures }} / {{ row.runs }}</template></el-table-column>
|
|
106
|
+
<el-table-column label="观察结果" width="130"><template #default="{ row }">{{ row.runs < 2 ? '样本不足' : row.failures === row.runs ? '持续失败' : '偶发失败' }}</template></el-table-column>
|
|
107
|
+
<el-table-column label="首次失败 / 版本" min-width="190"><template #default="{ row }">{{ time(row.firstFailure) }} · {{ row.version || '未知' }}</template></el-table-column>
|
|
108
|
+
</el-table>
|
|
109
|
+
</template>
|
|
110
|
+
<section v-if="details.length === 2" class="history-comparison">
|
|
111
|
+
<h3>运行对比 · 总耗时差 {{ seconds(details[1].durationMs - details[0].durationMs) }}</h3>
|
|
112
|
+
<el-alert v-if="details[0].deviceId !== details[1].deviceId || details[0].appPackage !== details[1].appPackage" title="两次运行的设备或应用不同,耗时差不能直接归因于版本变化" type="warning" :closable="false" />
|
|
113
|
+
<el-select v-model="nodeKey" clearable filterable placeholder="全部节点截图" aria-label="对比节点"><el-option v-for="node in comparisonNodes" :key="node.key" :label="node.label" :value="node.key" /></el-select>
|
|
114
|
+
<div class="comparison-columns">
|
|
115
|
+
<section v-for="run in details" :key="run.id">
|
|
116
|
+
<h4>{{ time(run.startedAt) }} · {{ labels[run.status] }}</h4>
|
|
117
|
+
<p>{{ run.appVersion || '未知版本' }} · {{ run.deviceId }} · {{ seconds(run.durationMs) }}</p>
|
|
118
|
+
<p v-if="nodeKey">节点累计耗时(含截图开销):{{ nodeDuration(run) }}</p>
|
|
119
|
+
<p v-if="!run.frames.some(frame => !nodeKey || frame.key === nodeKey)">未捕获到对应截图</p>
|
|
120
|
+
<div class="history-frames">
|
|
121
|
+
<figure v-for="(frame, index) in run.frames.filter(frame => !nodeKey || frame.key === nodeKey)" :key="index">
|
|
122
|
+
<figcaption>{{ frame.label }} · {{ frame.phase === 'before' ? '执行前' : frame.phase === 'after' ? '执行后' : frame.phase }} · {{ time(frame.capturedAt) }}</figcaption>
|
|
123
|
+
<el-image :src="frame.imageUrl" :preview-src-list="[frame.imageUrl]" preview-teleported fit="contain" loading="lazy" />
|
|
124
|
+
</figure>
|
|
125
|
+
</div>
|
|
126
|
+
<h4>节点结果</h4><ul><li v-for="(node, index) in run.nodes.filter(node => !nodeKey || node.key === nodeKey)" :key="index">{{ node.label }} · {{ node.failed ? '失败' : '完成' }} · {{ node.durationMs === null ? '耗时未知' : seconds(node.durationMs) }}</li></ul>
|
|
127
|
+
<h4>关键日志 / 完整日志</h4><el-input v-model="logQuery" clearable placeholder="日志关键字,如 stageLog" aria-label="日志关键字" /><pre>{{ logQuery ? run.output.split('\n').filter(line => line.toLowerCase().includes(logQuery.toLowerCase())).join('\n') || '无匹配日志' : run.output }}</pre>
|
|
128
|
+
</section>
|
|
129
|
+
</div>
|
|
130
|
+
</section>
|
|
131
|
+
</div>
|
|
132
|
+
</el-dialog>
|
|
133
|
+
</template>
|
|
134
|
+
|
|
135
|
+
<style scoped>
|
|
136
|
+
.run-history { max-height: 78vh; overflow: auto; }
|
|
137
|
+
.history-toolbar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
|
138
|
+
.history-toolbar .el-select { width: 190px; }
|
|
139
|
+
.history-trend { width: 100%; height: 160px; background: #f3faf7; }
|
|
140
|
+
.history-trend text { font-size: 12px; fill: #52615b; }
|
|
141
|
+
h3 { font-size: 15px; margin: 18px 0 10px; }
|
|
142
|
+
h4 { font-size: 14px; margin: 12px 0 8px; }
|
|
143
|
+
.history-comparison > .el-select { width: 100%; }
|
|
144
|
+
.comparison-columns { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 20px; }
|
|
145
|
+
.comparison-columns section { min-width: 0; overflow-wrap: anywhere; }
|
|
146
|
+
.history-frames { max-height: 360px; overflow: auto; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
|
147
|
+
figure { margin: 0; }
|
|
148
|
+
figcaption { font-size: 12px; }
|
|
149
|
+
.el-image { width: 100%; height: 150px; }
|
|
150
|
+
pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 280px; overflow: auto; background: #f1f5f4; padding: 10px; font-size: 12px; }
|
|
151
|
+
ul { max-height: 160px; overflow: auto; padding-left: 20px; }
|
|
152
|
+
@media (max-width: 650px) { .comparison-columns { grid-template-columns: minmax(0, 1fr); } }
|
|
153
|
+
</style>
|
|
@@ -99,6 +99,7 @@ const actionGroups: Array<{ title: string; actions: Array<{ type: InsertAction;
|
|
|
99
99
|
{
|
|
100
100
|
title: '设备操作',
|
|
101
101
|
actions: [
|
|
102
|
+
{ type: 'launchApp', label: '启动 App' },
|
|
102
103
|
{ type: 'keyBack', label: '系统返回' },
|
|
103
104
|
{ type: 'keyHome', label: 'Home 键' },
|
|
104
105
|
{ type: 'openGallery', label: '启动相册' },
|
|
@@ -123,8 +124,8 @@ const actionGroups: Array<{ title: string; actions: Array<{ type: InsertAction;
|
|
|
123
124
|
{ type: 'assertText', label: '断言文本' },
|
|
124
125
|
{ type: 'waitDisappear', label: '等待元素消失' },
|
|
125
126
|
{ type: 'waitActivity', label: '等待 Activity' },
|
|
126
|
-
{ type: 'visualChangeStart', label: '
|
|
127
|
-
{ type: 'visualChangeEnd', label: '
|
|
127
|
+
{ type: 'visualChangeStart', label: '检测画面变化开始' },
|
|
128
|
+
{ type: 'visualChangeEnd', label: '检测画面变化结束' },
|
|
128
129
|
],
|
|
129
130
|
},
|
|
130
131
|
{
|
|
@@ -116,10 +116,9 @@ const insertActionGroups: FlowActionGroup[] = [
|
|
|
116
116
|
|
|
117
117
|
const mainActionGroups = insertActionGroups.map((group) => ({
|
|
118
118
|
...group,
|
|
119
|
-
actions: group.actions.filter((action) => action.type !== '
|
|
119
|
+
actions: group.actions.filter((action) => action.type !== 'clearAppData'),
|
|
120
120
|
}));
|
|
121
121
|
const startActionGroups = insertActionGroups;
|
|
122
|
-
const hasLaunchAppStep = computed(() => props.steps.some((step) => step.type === 'launchApp'));
|
|
123
122
|
const hasClearAppDataStep = computed(() => props.steps.some((step) => step.type === 'clearAppData'));
|
|
124
123
|
|
|
125
124
|
function isDescendantStep(descendantIndex: number, ancestorIndex: number) {
|
|
@@ -247,8 +246,7 @@ function isInsertActionDisabled(action: InsertAction) {
|
|
|
247
246
|
}
|
|
248
247
|
|
|
249
248
|
function isStartActionDisabled(action: InsertAction) {
|
|
250
|
-
return (action === '
|
|
251
|
-
|| (action === 'clearAppData' && hasClearAppDataStep.value)
|
|
249
|
+
return (action === 'clearAppData' && hasClearAppDataStep.value)
|
|
252
250
|
|| isInsertActionDisabled(action);
|
|
253
251
|
}
|
|
254
252
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
defineProps<{ candidates: { id: string; label: string; number: number }[] }>();
|
|
3
|
+
const emit = defineEmits<{ select: [id: string] }>();
|
|
4
|
+
</script>
|
|
5
|
+
|
|
6
|
+
<template>
|
|
7
|
+
<div class="visual-end-picker" aria-label="可结束的画面变化检测">
|
|
8
|
+
<button v-for="item in candidates" :key="item.id" type="button" @click="emit('select', item.id)">
|
|
9
|
+
<strong>{{ item.label }}</strong>
|
|
10
|
+
<span>开始节点 #{{ item.number }}</span>
|
|
11
|
+
</button>
|
|
12
|
+
</div>
|
|
13
|
+
</template>
|
|
14
|
+
|
|
15
|
+
<style scoped>
|
|
16
|
+
.visual-end-picker { display: grid; gap: 4px; max-height: 320px; overflow: auto; }
|
|
17
|
+
.visual-end-picker button { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 6px 16px; width: 100%; padding: 10px 12px; border: 0; border-radius: 4px; background: transparent; color: var(--el-text-color-primary); font: inherit; text-align: left; cursor: pointer; }
|
|
18
|
+
.visual-end-picker button:hover, .visual-end-picker button:focus-visible { background: var(--el-fill-color-light); outline: 2px solid var(--el-color-primary); outline-offset: -2px; }
|
|
19
|
+
.visual-end-picker strong { font-size: 14px; overflow-wrap: anywhere; }
|
|
20
|
+
.visual-end-picker span { font-size: 12px; color: var(--el-text-color-secondary); }
|
|
21
|
+
</style>
|
|
@@ -361,11 +361,6 @@ export function buildFlowGraph(
|
|
|
361
361
|
) => {
|
|
362
362
|
const id = insertNodeId(afterIndex, branch, condition?.step.id);
|
|
363
363
|
const isStartInsert = afterIndex < 0 && !branch;
|
|
364
|
-
const previousStep = afterIndex >= 0 ? steps[afterIndex] : undefined;
|
|
365
|
-
const canLaunchAfterClearAppData = !isStartInsert
|
|
366
|
-
&& !branch
|
|
367
|
-
&& previousStep?.type === 'clearAppData'
|
|
368
|
-
&& !steps.some((step) => step.type === 'launchApp');
|
|
369
364
|
addNode({
|
|
370
365
|
id,
|
|
371
366
|
type: 'flow-node',
|
|
@@ -381,9 +376,7 @@ export function buildFlowGraph(
|
|
|
381
376
|
kind: 'insert',
|
|
382
377
|
actionGroups: isStartInsert
|
|
383
378
|
? options.startActionGroups
|
|
384
|
-
:
|
|
385
|
-
? withLaunchAppAction(options.mainActionGroups)
|
|
386
|
-
: options.mainActionGroups,
|
|
379
|
+
: withLaunchAppAction(options.mainActionGroups),
|
|
387
380
|
afterIndex,
|
|
388
381
|
branch,
|
|
389
382
|
branchLabel: branch && condition ? flowBranchLabel(condition.step, branch) : undefined,
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export type HistoryNode = {
|
|
2
|
+
key: string;
|
|
3
|
+
label: string;
|
|
4
|
+
failed: boolean;
|
|
5
|
+
durationMs: number | null;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type RunSummary = {
|
|
9
|
+
id: string;
|
|
10
|
+
scriptId: string;
|
|
11
|
+
scriptName: string;
|
|
12
|
+
appPackage: string;
|
|
13
|
+
appVersion: string;
|
|
14
|
+
deviceId: string;
|
|
15
|
+
startedAt: string;
|
|
16
|
+
durationMs: number;
|
|
17
|
+
status: 'passed' | 'failed' | 'stopped';
|
|
18
|
+
nodes: HistoryNode[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type RunDetail = RunSummary & {
|
|
22
|
+
output: string;
|
|
23
|
+
frames: { key: string; label: string; phase: string; capturedAt: string; imageUrl: string }[];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// 分母只计算实际到达该节点的运行;未执行的分支不能当作通过。
|
|
27
|
+
export function historyStatistics(runs: RunSummary[]) {
|
|
28
|
+
const completed = runs.filter(run => run.status !== 'stopped');
|
|
29
|
+
const nodes = new Map<string, { key: string; label: string; runs: number; failures: number; firstFailure: string; version: string }>();
|
|
30
|
+
for (const run of [...completed].sort((a, b) => a.startedAt.localeCompare(b.startedAt))) {
|
|
31
|
+
const unique = new Map<string, HistoryNode>();
|
|
32
|
+
for (const node of run.nodes) {
|
|
33
|
+
const prior = unique.get(node.key);
|
|
34
|
+
unique.set(node.key, { ...node, failed: node.failed || Boolean(prior?.failed) });
|
|
35
|
+
}
|
|
36
|
+
for (const node of unique.values()) {
|
|
37
|
+
const item = nodes.get(node.key) || { key: node.key, label: node.label, runs: 0, failures: 0, firstFailure: '', version: '' };
|
|
38
|
+
item.runs++;
|
|
39
|
+
if (node.failed) {
|
|
40
|
+
item.failures++;
|
|
41
|
+
if (!item.firstFailure) { item.firstFailure = run.startedAt; item.version = run.appVersion; }
|
|
42
|
+
}
|
|
43
|
+
nodes.set(node.key, item);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
passRate: completed.length ? completed.filter(run => run.status === 'passed').length / completed.length : null,
|
|
48
|
+
failures: [...nodes.values()].filter(node => node.failures).sort((a, b) => b.failures - a.failures),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -93,10 +93,6 @@ export function branchPathForInsert(
|
|
|
93
93
|
: [];
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
function sameBranchPath(left: string[], right: string[]) {
|
|
97
|
-
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
96
|
function branchPathContains(parent: string[], child: string[]) {
|
|
101
97
|
return parent.length <= child.length && parent.every((value, index) => value === child[index]);
|
|
102
98
|
}
|
|
@@ -118,19 +114,21 @@ function isEndForStart(endStep: AppiumRecordedStep, startStep: AppiumRecordedSte
|
|
|
118
114
|
);
|
|
119
115
|
}
|
|
120
116
|
|
|
121
|
-
export function
|
|
117
|
+
export function findOpenVisualChangeStarts(
|
|
122
118
|
steps: AppiumRecordedStep[],
|
|
123
119
|
index?: number,
|
|
124
120
|
branchTarget?: VisualChangeBranchTarget,
|
|
125
121
|
) {
|
|
126
122
|
const insertPath = branchPathForInsert(steps, index, branchTarget);
|
|
127
123
|
const insertIndex = typeof index === 'number' ? index : steps.length - 1;
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
// preventing a yes-branch start from pairing with a no-branch end.
|
|
124
|
+
// 祖先或子分支中的结束节点可能与新节点在同一路径执行,不能重复关闭。
|
|
125
|
+
// 互斥的兄弟分支不包含彼此,仍允许分别结束同一个开始节点。
|
|
131
126
|
const hasEndInInsertBranch = (startStep: AppiumRecordedStep) => steps.some((step) => (
|
|
132
127
|
isEndForStart(step, startStep)
|
|
133
|
-
&&
|
|
128
|
+
&& (
|
|
129
|
+
branchPathContains(branchPathForStep(steps, step), insertPath)
|
|
130
|
+
|| branchPathContains(insertPath, branchPathForStep(steps, step))
|
|
131
|
+
)
|
|
134
132
|
));
|
|
135
133
|
|
|
136
134
|
return steps
|
|
@@ -143,5 +141,13 @@ export function findOpenVisualChangeStart(
|
|
|
143
141
|
))
|
|
144
142
|
.sort((left, right) => (
|
|
145
143
|
right.path.length - left.path.length || right.stepIndex - left.stepIndex
|
|
146
|
-
))
|
|
144
|
+
)).map(({ step }) => step);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function findOpenVisualChangeStart(
|
|
148
|
+
steps: AppiumRecordedStep[],
|
|
149
|
+
index?: number,
|
|
150
|
+
branchTarget?: VisualChangeBranchTarget,
|
|
151
|
+
) {
|
|
152
|
+
return findOpenVisualChangeStarts(steps, index, branchTarget)[0];
|
|
147
153
|
}
|