android-midscene-automation 0.1.26 → 0.1.28
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 +18 -0
- package/FAQ.md +14 -0
- package/README.md +39 -3
- package/bin/android-midscene-automation.js +71 -1
- package/package.json +7 -2
- package/remote-agent/index.ts +2 -11
- package/server/appium-recorder/ai-recognition.ts +67 -0
- package/server/appium-recorder/appium-runner.ts +335 -52
- package/server/appium-recorder/report.ts +297 -16
- package/server/appium-recorder/repository.ts +31 -4
- package/server/appium-recorder/routes.ts +31 -32
- package/server/appium-recorder/screenshot.ts +21 -0
- package/server/appium-recorder/script-export.ts +67 -0
- package/server/appium-recorder/tree-dump.ts +22 -0
- package/server/appium-recorder/visual-change.ts +184 -0
- package/server/config.ts +10 -0
- package/server/http-api.ts +4 -5
- package/src/App.vue +14 -7
- package/src/api.ts +1 -1
- package/src/appium-recorder/AppiumPage.vue +658 -162
- package/src/appium-recorder/ai-recognition.ts +16 -0
- package/src/appium-recorder/api.ts +20 -3
- package/src/appium-recorder/components/AiRecognitionTestDialog.vue +71 -0
- package/src/appium-recorder/components/FlowActionMenu.vue +206 -0
- package/src/appium-recorder/components/FlowCanvas.vue +16 -1
- package/src/appium-recorder/components/FlowNodeCard.vue +23 -37
- package/src/appium-recorder/components/FlowStepEditor.vue +152 -25
- package/src/appium-recorder/components/LongPressSettings.vue +58 -0
- package/src/appium-recorder/components/NestedConditionBranches.vue +55 -35
- package/src/appium-recorder/components/NewScriptDialog.vue +27 -0
- package/src/appium-recorder/components/NodeDetail.vue +2 -0
- package/src/appium-recorder/components/RecordedSteps.vue +24 -4
- package/src/appium-recorder/components/StageLogSettings.vue +17 -0
- package/src/appium-recorder/components/TextClickSettings.vue +19 -0
- package/src/appium-recorder/components/VisualChangeDialog.vue +151 -0
- package/src/appium-recorder/flow-copy.ts +47 -1
- package/src/appium-recorder/flow-graph.ts +147 -64
- package/src/appium-recorder/flow-labels.ts +35 -6
- package/src/appium-recorder/flow-normalize.ts +76 -0
- package/src/appium-recorder/flow-remove.ts +69 -0
- package/src/appium-recorder/long-press.ts +27 -0
- package/src/appium-recorder/native-control-state.ts +26 -0
- package/src/appium-recorder/stage-log.ts +21 -0
- package/src/appium-recorder/text-click.ts +9 -0
- package/src/appium-recorder/tree.ts +3 -0
- package/src/appium-recorder/types.ts +34 -2
- package/src/appium-recorder/visual-change-pairs.ts +147 -0
- package/src/appium-recorder/visual-change.ts +75 -0
- package/src/components/config/AppiumConfigPanel.vue +48 -0
- package/src/components/config/BaseConfigPanel.vue +90 -0
- package/src/components/config/MidsceneConfigPanel.vue +204 -0
- package/src/components/device/DevicePreviewPanel.vue +148 -4
- package/src/pages/ConfigPage.vue +107 -265
- package/src/style.css +43 -4
- package/src/types.ts +2 -0
- package/src/ui-refresh.css +5 -0
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { PNG } from 'pngjs';
|
|
3
4
|
import { appDataPath } from '../paths';
|
|
4
5
|
import { loadConfig } from '../config';
|
|
5
6
|
import { saveAppiumReplayReport, type AppiumRecordedScriptRecord, type AppiumRecordedStepRecord } from './repository';
|
|
7
|
+
import { flowBranchLabel } from '../../src/appium-recorder/flow-labels';
|
|
6
8
|
|
|
7
9
|
export type AppiumReplayFrame = {
|
|
8
10
|
sequence: number;
|
|
@@ -11,6 +13,7 @@ export type AppiumReplayFrame = {
|
|
|
11
13
|
nodeNumber: number;
|
|
12
14
|
nodeLabel: string;
|
|
13
15
|
nodeType: string;
|
|
16
|
+
logContent?: string;
|
|
14
17
|
note: string;
|
|
15
18
|
selector: string;
|
|
16
19
|
phase: 'before' | 'after' | 'error' | 'stopped';
|
|
@@ -19,6 +22,35 @@ export type AppiumReplayFrame = {
|
|
|
19
22
|
imageBase64: string;
|
|
20
23
|
};
|
|
21
24
|
|
|
25
|
+
export type AppiumReplayVisualCheck = {
|
|
26
|
+
nodeId: string;
|
|
27
|
+
nodeNumber: number;
|
|
28
|
+
nodeLabel: string;
|
|
29
|
+
startNodeId?: string;
|
|
30
|
+
startNodeLabel?: string;
|
|
31
|
+
endNodeId?: string;
|
|
32
|
+
endNodeLabel?: string;
|
|
33
|
+
status: 'passed' | 'failed';
|
|
34
|
+
message: string;
|
|
35
|
+
region: {
|
|
36
|
+
x: number;
|
|
37
|
+
y: number;
|
|
38
|
+
width: number;
|
|
39
|
+
height: number;
|
|
40
|
+
};
|
|
41
|
+
durationMs: number;
|
|
42
|
+
intervalMs: number;
|
|
43
|
+
sampleCount: number;
|
|
44
|
+
changeRatioThreshold: number;
|
|
45
|
+
maxChangeRatio: number;
|
|
46
|
+
baselineBase64: string;
|
|
47
|
+
comparisonBase64: string;
|
|
48
|
+
diffBase64: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const HTML_REPORT_IMAGE_MAX_WIDTH = 540;
|
|
52
|
+
const HTML_REPORT_IMAGE_MAX_HEIGHT = 1200;
|
|
53
|
+
|
|
22
54
|
function pad(value: number, length = 2) {
|
|
23
55
|
return String(value).padStart(length, '0');
|
|
24
56
|
}
|
|
@@ -55,6 +87,54 @@ function jsonForHtml(value: unknown) {
|
|
|
55
87
|
.replace(/&/g, '\\u0026');
|
|
56
88
|
}
|
|
57
89
|
|
|
90
|
+
function compressHtmlPngBase64(base64: string) {
|
|
91
|
+
if (!base64) return base64;
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const source = PNG.sync.read(Buffer.from(base64, 'base64'));
|
|
95
|
+
const scale = Math.min(
|
|
96
|
+
1,
|
|
97
|
+
HTML_REPORT_IMAGE_MAX_WIDTH / source.width,
|
|
98
|
+
HTML_REPORT_IMAGE_MAX_HEIGHT / source.height,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
if (scale >= 1) return base64;
|
|
102
|
+
|
|
103
|
+
const width = Math.max(1, Math.round(source.width * scale));
|
|
104
|
+
const height = Math.max(1, Math.round(source.height * scale));
|
|
105
|
+
const target = new PNG({ width, height });
|
|
106
|
+
const scaleX = source.width / width;
|
|
107
|
+
const scaleY = source.height / height;
|
|
108
|
+
|
|
109
|
+
// HTML 回放只用于预览,降采样能明显降低报告体积,不影响实际检测结果。
|
|
110
|
+
for (let y = 0; y < height; y += 1) {
|
|
111
|
+
const sourceY = Math.min(source.height - 1, Math.floor(y * scaleY));
|
|
112
|
+
for (let x = 0; x < width; x += 1) {
|
|
113
|
+
const sourceX = Math.min(source.width - 1, Math.floor(x * scaleX));
|
|
114
|
+
const sourceIndex = (sourceY * source.width + sourceX) << 2;
|
|
115
|
+
const targetIndex = (y * width + x) << 2;
|
|
116
|
+
target.data[targetIndex] = source.data[sourceIndex];
|
|
117
|
+
target.data[targetIndex + 1] = source.data[sourceIndex + 1];
|
|
118
|
+
target.data[targetIndex + 2] = source.data[sourceIndex + 2];
|
|
119
|
+
target.data[targetIndex + 3] = source.data[sourceIndex + 3];
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return PNG.sync.write(target).toString('base64');
|
|
124
|
+
} catch {
|
|
125
|
+
return base64;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function htmlImageUrl(base64: string, cache: Map<string, string>) {
|
|
130
|
+
const cached = cache.get(base64);
|
|
131
|
+
if (cached) return cached;
|
|
132
|
+
|
|
133
|
+
const imageUrl = `data:image/png;base64,${compressHtmlPngBase64(base64)}`;
|
|
134
|
+
cache.set(base64, imageUrl);
|
|
135
|
+
return imageUrl;
|
|
136
|
+
}
|
|
137
|
+
|
|
58
138
|
function markdownValue(value: unknown) {
|
|
59
139
|
if (value === undefined || value === null || value === '') return '-';
|
|
60
140
|
return String(value).replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>');
|
|
@@ -73,14 +153,51 @@ function stepExecutionLines(outputLines: string[], index: number) {
|
|
|
73
153
|
|
|
74
154
|
function executionStatus(lines: string[]) {
|
|
75
155
|
if (!lines.length) return '未执行';
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (
|
|
79
|
-
if (
|
|
156
|
+
// 只识别执行器的状态前缀,用户日志中的“失败”等文字不是执行结果。
|
|
157
|
+
const messages = lines.map((line) => line.replace(/^\[(?:节点|步骤) \d+\] /, ''));
|
|
158
|
+
if (messages.some((line) => line.startsWith('失败:') || line.startsWith('失败分支:'))) return '失败';
|
|
159
|
+
if (messages.some((line) => line.startsWith('跳过:'))) return '跳过';
|
|
160
|
+
if (messages.some((line) => line.startsWith('判断:'))) return '判断完成';
|
|
161
|
+
if (messages.some((line) => line.startsWith('完成:'))) return '成功';
|
|
80
162
|
return '中断';
|
|
81
163
|
}
|
|
82
164
|
|
|
83
|
-
function
|
|
165
|
+
function visualCheckStatusLabel(status: AppiumReplayVisualCheck['status']) {
|
|
166
|
+
return status === 'passed' ? '有变化' : '无明显变化';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function visualCheckSection(check?: AppiumReplayVisualCheck) {
|
|
170
|
+
if (!check) return '';
|
|
171
|
+
const statusLabel = visualCheckStatusLabel(check.status);
|
|
172
|
+
const region = `${check.region.x}, ${check.region.y}, ${check.region.width} x ${check.region.height}`;
|
|
173
|
+
return [
|
|
174
|
+
'#### 画面变化检测',
|
|
175
|
+
'',
|
|
176
|
+
'| 项目 | 内容 |',
|
|
177
|
+
'| --- | --- |',
|
|
178
|
+
`| 检测结果 | **${statusLabel}** |`,
|
|
179
|
+
`| 开始节点 | ${markdownValue(check.startNodeLabel || check.startNodeId)} |`,
|
|
180
|
+
`| 结束节点 | ${markdownValue(check.endNodeLabel || check.endNodeId)} |`,
|
|
181
|
+
`| 检测区域 | ${markdownValue(region)} |`,
|
|
182
|
+
`| 对比方式 | ${check.durationMs ? markdownValue(`${check.durationMs}ms / 每 ${check.intervalMs}ms`) : '起止节点截图对比'} |`,
|
|
183
|
+
`| 采样次数 | ${check.sampleCount} |`,
|
|
184
|
+
`| 变化阈值 | ${check.changeRatioThreshold}% |`,
|
|
185
|
+
`| 最大变化比例 | ${check.maxChangeRatio.toFixed(2)}% |`,
|
|
186
|
+
`| 说明 | ${markdownValue(check.message)} |`,
|
|
187
|
+
'',
|
|
188
|
+
'| 基准帧 | 对比帧 | 差异图 |',
|
|
189
|
+
'| --- | --- | --- |',
|
|
190
|
+
`|  |  |  |`,
|
|
191
|
+
'',
|
|
192
|
+
].join('\n');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function stepSection(
|
|
196
|
+
step: AppiumRecordedStepRecord,
|
|
197
|
+
index: number,
|
|
198
|
+
outputLines: string[],
|
|
199
|
+
visualCheck?: AppiumReplayVisualCheck,
|
|
200
|
+
) {
|
|
84
201
|
const executionLines = stepExecutionLines(outputLines, index);
|
|
85
202
|
const config = JSON.stringify(step, null, 2);
|
|
86
203
|
return [
|
|
@@ -96,9 +213,8 @@ function stepSection(step: AppiumRecordedStepRecord, index: number, outputLines:
|
|
|
96
213
|
`| 上下文 Selector | ${markdownValue(selectorText(step.contextSelector))} |`,
|
|
97
214
|
`| 输入/目标值 | ${markdownValue(step.value)} |`,
|
|
98
215
|
`| 超时时间 | ${markdownValue(step.timeoutMs === undefined ? '-' : `${step.timeoutMs}ms`)} |`,
|
|
99
|
-
`|
|
|
100
|
-
`|
|
|
101
|
-
`| 否分支 | ${markdownValue(step.flow?.noTargetId)} |`,
|
|
216
|
+
`| ${flowBranchLabel(step, 'yes')} 分支 | ${markdownValue(step.flow?.yesTargetId)} |`,
|
|
217
|
+
`| ${flowBranchLabel(step, 'no')} 分支 | ${markdownValue(step.flow?.noTargetId)} |`,
|
|
102
218
|
`| 成功后续节点 | ${markdownValue(step.flow?.successTargetId)} |`,
|
|
103
219
|
`| 执行状态 | **${executionStatus(executionLines)}** |`,
|
|
104
220
|
'',
|
|
@@ -108,6 +224,7 @@ function stepSection(step: AppiumRecordedStepRecord, index: number, outputLines:
|
|
|
108
224
|
executionLines.length ? executionLines.join('\n') : '该节点未进入执行路径,或在前序失败后未执行。',
|
|
109
225
|
executionLines.length ? '```' : '',
|
|
110
226
|
'',
|
|
227
|
+
visualCheckSection(visualCheck),
|
|
111
228
|
'<details>',
|
|
112
229
|
'<summary>完整节点配置</summary>',
|
|
113
230
|
'',
|
|
@@ -127,16 +244,27 @@ function createReplayHtml(input: {
|
|
|
127
244
|
startedAt: Date;
|
|
128
245
|
completedAt: Date;
|
|
129
246
|
frames: AppiumReplayFrame[];
|
|
247
|
+
visualChecks: AppiumReplayVisualCheck[];
|
|
130
248
|
output: string;
|
|
131
249
|
}) {
|
|
250
|
+
const imageCache = new Map<string, string>();
|
|
132
251
|
const payload = jsonForHtml({
|
|
133
252
|
startedAt: input.startedAt.toISOString(),
|
|
134
253
|
durationMs: input.durationMs,
|
|
135
254
|
frames: input.frames.map((frame) => ({
|
|
136
255
|
...frame,
|
|
137
|
-
imageUrl:
|
|
256
|
+
imageUrl: htmlImageUrl(frame.imageBase64, imageCache),
|
|
138
257
|
imageBase64: undefined,
|
|
139
258
|
})),
|
|
259
|
+
visualChecks: input.visualChecks.map((check) => ({
|
|
260
|
+
...check,
|
|
261
|
+
baselineImageUrl: htmlImageUrl(check.baselineBase64, imageCache),
|
|
262
|
+
comparisonImageUrl: htmlImageUrl(check.comparisonBase64, imageCache),
|
|
263
|
+
diffImageUrl: htmlImageUrl(check.diffBase64, imageCache),
|
|
264
|
+
baselineBase64: undefined,
|
|
265
|
+
comparisonBase64: undefined,
|
|
266
|
+
diffBase64: undefined,
|
|
267
|
+
})),
|
|
140
268
|
output: input.output,
|
|
141
269
|
});
|
|
142
270
|
const resultClass = input.resultText === '成功' ? 'success' : input.resultText === '已终止' ? 'stopped' : 'failed';
|
|
@@ -174,6 +302,8 @@ function createReplayHtml(input: {
|
|
|
174
302
|
.frame-title, .frame-subtitle { display: block; overflow-wrap: anywhere; }
|
|
175
303
|
.frame-title { font-size: 13px; font-weight: 600; }
|
|
176
304
|
.frame-subtitle, .frame-time { margin-top: 3px; color: #7a818b; font-size: 11px; }
|
|
305
|
+
.frame-log, #node-log { white-space: pre-wrap; overflow-wrap: anywhere; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
|
|
306
|
+
.frame-log { display: block; margin-top: 6px; color: #4b515a; font-size: 12px; line-height: 1.5; }
|
|
177
307
|
.workspace { display: grid; grid-template-rows: 178px minmax(0, 1fr); min-width: 0; min-height: 0; }
|
|
178
308
|
.timeline-panel { min-width: 0; overflow-x: auto; overflow-y: hidden; border-bottom: 1px solid #dfe3e8; background: #fff; }
|
|
179
309
|
.timeline-track { position: relative; height: 178px; min-width: 100%; cursor: pointer; user-select: none; }
|
|
@@ -199,6 +329,25 @@ function createReplayHtml(input: {
|
|
|
199
329
|
details { margin: 4px 10px 16px; border: 1px solid #dfe3e8; border-radius: 6px; background: #fff; }
|
|
200
330
|
summary { padding: 12px 14px; cursor: pointer; font-weight: 600; }
|
|
201
331
|
pre { max-height: 360px; margin: 0; overflow: auto; padding: 14px; border-top: 1px solid #e5e8ec; white-space: pre; font: 11px/1.6 ui-monospace, SFMono-Regular, Consolas, monospace; }
|
|
332
|
+
.visual-check-list { display: grid; gap: 12px; padding: 0 12px 12px; border-top: 1px solid #e5e8ec; }
|
|
333
|
+
.visual-check { padding: 12px 0 0; border-top: 1px solid #eef1f5; }
|
|
334
|
+
.visual-check:first-child { border-top: 0; }
|
|
335
|
+
.visual-check h3 { margin: 0 0 8px; font-size: 13px; overflow-wrap: anywhere; }
|
|
336
|
+
.visual-check dl { display: grid; grid-template-columns: 72px minmax(0, 1fr); gap: 5px 8px; margin: 0 0 10px; font-size: 12px; }
|
|
337
|
+
.visual-check dt { color: #8a9099; }
|
|
338
|
+
.visual-check dd { margin: 0; overflow-wrap: anywhere; }
|
|
339
|
+
.visual-check .passed { color: #237804; font-weight: 700; }
|
|
340
|
+
.visual-check .failed { color: #cf1322; font-weight: 700; }
|
|
341
|
+
.visual-images { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; }
|
|
342
|
+
.visual-image span { display: block; margin-bottom: 4px; color: #646a73; font-size: 11px; text-align: center; }
|
|
343
|
+
.visual-image img { display: block; width: 100%; max-height: 120px; object-fit: contain; border: 1px solid #dfe3e8; border-radius: 3px; background: #101827; cursor: zoom-in; }
|
|
344
|
+
.visual-image img:hover { border-color: #1677ff; }
|
|
345
|
+
.image-preview { position: fixed; inset: 0; z-index: 20; display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 12px; padding: 20px; background: rgba(16, 24, 39, .88); }
|
|
346
|
+
.image-preview[hidden] { display: none; }
|
|
347
|
+
.image-preview__bar { display: flex; align-items: center; justify-content: space-between; gap: 16px; color: #fff; }
|
|
348
|
+
.image-preview__title { min-width: 0; overflow: hidden; font-size: 14px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
|
|
349
|
+
.image-preview__close { flex: 0 0 auto; border-color: rgba(255, 255, 255, .28); color: #fff; background: rgba(255, 255, 255, .1); }
|
|
350
|
+
.image-preview__image { place-self: center; max-width: 100%; max-height: calc(100vh - 88px); object-fit: contain; border-radius: 4px; background: #101827; box-shadow: 0 20px 60px rgba(0, 0, 0, .35); }
|
|
202
351
|
@media (max-width: 860px) {
|
|
203
352
|
body { overflow: auto; }
|
|
204
353
|
main { grid-template-columns: 1fr; height: auto; }
|
|
@@ -231,12 +380,14 @@ function createReplayHtml(input: {
|
|
|
231
380
|
<dt>节点</dt><dd id="node">-</dd>
|
|
232
381
|
<dt>阶段</dt><dd id="phase">-</dd>
|
|
233
382
|
<dt>结果</dt><dd id="frame-status">-</dd>
|
|
383
|
+
<dt id="node-log-label" hidden>日志内容</dt><dd id="node-log" hidden></dd>
|
|
234
384
|
<dt>备注</dt><dd id="note">-</dd>
|
|
235
385
|
<dt>定位</dt><dd id="selector">-</dd>
|
|
236
386
|
<dt>时间</dt><dd id="time">-</dd>
|
|
237
387
|
</dl>
|
|
238
388
|
</section>
|
|
239
389
|
<nav id="step-list" class="step-list" aria-label="执行步骤"></nav>
|
|
390
|
+
<details><summary>视觉变化检测</summary><div id="visual-checks" class="visual-check-list"></div></details>
|
|
240
391
|
<details><summary>完整回放日志</summary><pre id="log"></pre></details>
|
|
241
392
|
</aside>
|
|
242
393
|
<section class="workspace">
|
|
@@ -259,21 +410,61 @@ function createReplayHtml(input: {
|
|
|
259
410
|
</section>
|
|
260
411
|
</section>
|
|
261
412
|
</main>
|
|
413
|
+
<div id="image-preview" class="image-preview" hidden>
|
|
414
|
+
<div class="image-preview__bar">
|
|
415
|
+
<span id="image-preview-title" class="image-preview__title"></span>
|
|
416
|
+
<button id="image-preview-close" class="image-preview__close" type="button">关闭</button>
|
|
417
|
+
</div>
|
|
418
|
+
<img id="image-preview-image" class="image-preview__image" alt="" />
|
|
419
|
+
</div>
|
|
262
420
|
<script id="replay-data" type="application/json">${payload}</script>
|
|
263
421
|
<script>
|
|
264
422
|
const data = JSON.parse(document.querySelector('#replay-data').textContent || '{}');
|
|
423
|
+
const visualChecks = Array.isArray(data.visualChecks) ? data.visualChecks : [];
|
|
265
424
|
const reportStartedAt = Date.parse(data.startedAt) || 0;
|
|
266
425
|
const frames = (Array.isArray(data.frames) ? data.frames : []).map((frame) => ({
|
|
267
426
|
...frame,
|
|
268
427
|
offsetMs: Math.max(0, (Date.parse(frame.capturedAt) || reportStartedAt) - reportStartedAt),
|
|
269
428
|
}));
|
|
270
429
|
const durationMs = Math.max(1000, Number(data.durationMs) || 0, frames.at(-1)?.offsetMs || 0);
|
|
271
|
-
|
|
430
|
+
// 按一次执行配对,而不是按节点 ID 全局去重;连接脚本和重复执行仍保留独立步骤。
|
|
431
|
+
const steps = [];
|
|
432
|
+
const frameSteps = [];
|
|
433
|
+
const pendingSteps = new Map();
|
|
434
|
+
frames.forEach((frame, index) => {
|
|
435
|
+
const key = JSON.stringify([frame.scriptName, frame.nodeId, frame.nodeNumber]);
|
|
436
|
+
const pending = pendingSteps.get(key) || [];
|
|
437
|
+
let stepIndex = frame.phase === 'before' ? undefined : pending.pop();
|
|
438
|
+
if (stepIndex === undefined) {
|
|
439
|
+
stepIndex = steps.length;
|
|
440
|
+
steps.push({ firstIndex: index, lastIndex: index });
|
|
441
|
+
} else {
|
|
442
|
+
steps[stepIndex].lastIndex = index;
|
|
443
|
+
}
|
|
444
|
+
if (frame.phase === 'before') pending.push(stepIndex);
|
|
445
|
+
pendingSteps.set(key, pending);
|
|
446
|
+
frameSteps[index] = stepIndex;
|
|
447
|
+
});
|
|
448
|
+
const elements = Object.fromEntries(['screen','empty','previous','play','next','seek','clock','caption','title','script','node','phase','frame-status','note','selector','time','step-list','timeline-track','ruler','playhead','visual-checks','log'].map((id) => [id, document.querySelector('#' + id)]));
|
|
449
|
+
const imagePreview = document.querySelector('#image-preview');
|
|
450
|
+
const imagePreviewImage = document.querySelector('#image-preview-image');
|
|
451
|
+
const imagePreviewTitle = document.querySelector('#image-preview-title');
|
|
452
|
+
const closeImagePreview = () => {
|
|
453
|
+
imagePreview.hidden = true;
|
|
454
|
+
imagePreviewImage.removeAttribute('src');
|
|
455
|
+
};
|
|
456
|
+
const openImagePreview = (url, title) => {
|
|
457
|
+
imagePreviewImage.src = url;
|
|
458
|
+
imagePreviewImage.alt = title;
|
|
459
|
+
imagePreviewTitle.textContent = title;
|
|
460
|
+
imagePreview.hidden = false;
|
|
461
|
+
};
|
|
272
462
|
let currentFrame = frames.length ? 0 : -1;
|
|
273
463
|
let currentMs = 0;
|
|
274
464
|
let animationFrame = 0;
|
|
275
465
|
let playbackStartedAt = 0;
|
|
276
466
|
const phaseLabels = { before: '执行前', after: '执行后', error: '失败', stopped: '已终止' };
|
|
467
|
+
const visualCheckStatusLabel = (status) => status === 'passed' ? '有变化' : '无明显变化';
|
|
277
468
|
const formatTime = (milliseconds) => {
|
|
278
469
|
const seconds = Math.max(0, Math.floor(milliseconds / 1000));
|
|
279
470
|
return String(Math.floor(seconds / 60)).padStart(2, '0') + ':' + String(seconds % 60).padStart(2, '0');
|
|
@@ -297,6 +488,10 @@ function createReplayHtml(input: {
|
|
|
297
488
|
elements.node.textContent = String(frame.nodeNumber || '-');
|
|
298
489
|
elements.phase.textContent = phaseLabels[frame.phase] || frame.phase || '-';
|
|
299
490
|
elements['frame-status'].textContent = frame.status || '-';
|
|
491
|
+
const logContent = frames[steps[frameSteps[currentFrame]].lastIndex].logContent;
|
|
492
|
+
document.querySelector('#node-log-label').hidden = !logContent;
|
|
493
|
+
document.querySelector('#node-log').hidden = !logContent;
|
|
494
|
+
document.querySelector('#node-log').textContent = logContent || '';
|
|
300
495
|
elements.note.textContent = frame.note || '-';
|
|
301
496
|
elements.selector.textContent = frame.selector || '-';
|
|
302
497
|
elements.time.textContent = new Date(frame.capturedAt).toLocaleString();
|
|
@@ -308,7 +503,8 @@ function createReplayHtml(input: {
|
|
|
308
503
|
elements.seek.value = String(Math.round(currentMs / durationMs * 1000));
|
|
309
504
|
elements.clock.textContent = formatTime(currentMs) + ' / ' + formatTime(durationMs);
|
|
310
505
|
elements.playhead.style.left = (currentMs / durationMs * 100) + '%';
|
|
311
|
-
document.querySelectorAll('.frame-item
|
|
506
|
+
document.querySelectorAll('.frame-item').forEach((item) => item.classList.toggle('active', Number(item.dataset.step) === frameSteps[currentFrame]));
|
|
507
|
+
document.querySelectorAll('.thumbnail').forEach((item) => item.classList.toggle('active', Number(item.dataset.index) === currentFrame));
|
|
312
508
|
if (scrollStep) document.querySelector('.frame-item.active')?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
313
509
|
};
|
|
314
510
|
const pause = () => {
|
|
@@ -340,11 +536,13 @@ function createReplayHtml(input: {
|
|
|
340
536
|
tick.append(label);
|
|
341
537
|
elements.ruler.append(tick);
|
|
342
538
|
}
|
|
343
|
-
|
|
539
|
+
steps.forEach((step, index) => {
|
|
540
|
+
const frame = frames[step.firstIndex];
|
|
541
|
+
const result = frames[step.lastIndex];
|
|
344
542
|
const button = document.createElement('button');
|
|
345
543
|
button.type = 'button';
|
|
346
544
|
button.className = 'frame-item';
|
|
347
|
-
button.dataset.
|
|
545
|
+
button.dataset.step = String(index);
|
|
348
546
|
const badge = document.createElement('span');
|
|
349
547
|
badge.className = 'frame-index';
|
|
350
548
|
badge.textContent = String(index + 1);
|
|
@@ -354,15 +552,28 @@ function createReplayHtml(input: {
|
|
|
354
552
|
title.textContent = frame.nodeNumber + '. ' + frame.nodeLabel;
|
|
355
553
|
const subtitle = document.createElement('span');
|
|
356
554
|
subtitle.className = 'frame-subtitle';
|
|
357
|
-
subtitle.textContent =
|
|
555
|
+
subtitle.textContent = result.phase === 'before' ? '未完成' : result.status;
|
|
358
556
|
content.append(title, subtitle);
|
|
557
|
+
if (frame.nodeType === 'textClick') {
|
|
558
|
+
const text = document.createElement('span');
|
|
559
|
+
text.className = 'frame-log frame-click-text';
|
|
560
|
+
text.textContent = frame.selector || '未设置点击文字';
|
|
561
|
+
content.append(text);
|
|
562
|
+
}
|
|
563
|
+
if (result.logContent) {
|
|
564
|
+
const log = document.createElement('span');
|
|
565
|
+
log.className = 'frame-log';
|
|
566
|
+
log.textContent = result.logContent;
|
|
567
|
+
content.append(log);
|
|
568
|
+
}
|
|
359
569
|
const time = document.createElement('span');
|
|
360
570
|
time.className = 'frame-time';
|
|
361
571
|
time.textContent = formatTime(frame.offsetMs);
|
|
362
572
|
button.append(badge, content, time);
|
|
363
573
|
button.addEventListener('click', () => { pause(); seekTo(frame.offsetMs); });
|
|
364
574
|
elements['step-list'].append(button);
|
|
365
|
-
|
|
575
|
+
});
|
|
576
|
+
frames.forEach((frame, index) => {
|
|
366
577
|
const thumbnail = document.createElement('button');
|
|
367
578
|
thumbnail.type = 'button';
|
|
368
579
|
thumbnail.className = 'thumbnail';
|
|
@@ -376,6 +587,62 @@ function createReplayHtml(input: {
|
|
|
376
587
|
thumbnail.addEventListener('click', (event) => { event.stopPropagation(); pause(); seekTo(frame.offsetMs); });
|
|
377
588
|
elements['timeline-track'].append(thumbnail);
|
|
378
589
|
});
|
|
590
|
+
if (!visualChecks.length) {
|
|
591
|
+
elements['visual-checks'].textContent = '本次没有视觉变化检测节点';
|
|
592
|
+
}
|
|
593
|
+
visualChecks.forEach((check) => {
|
|
594
|
+
const article = document.createElement('article');
|
|
595
|
+
article.className = 'visual-check';
|
|
596
|
+
const title = document.createElement('h3');
|
|
597
|
+
title.textContent = check.nodeNumber + '. ' + check.nodeLabel;
|
|
598
|
+
const detail = document.createElement('dl');
|
|
599
|
+
[
|
|
600
|
+
['结果', visualCheckStatusLabel(check.status), check.status],
|
|
601
|
+
['开始节点', check.startNodeLabel || check.startNodeId || '-'],
|
|
602
|
+
['结束节点', check.endNodeLabel || check.endNodeId || '-'],
|
|
603
|
+
['区域', check.region.x + ', ' + check.region.y + ', ' + check.region.width + ' x ' + check.region.height],
|
|
604
|
+
['对比', check.durationMs ? check.sampleCount + ' 次 / 每 ' + check.intervalMs + 'ms' : '起止节点截图对比'],
|
|
605
|
+
['阈值', check.changeRatioThreshold + '%'],
|
|
606
|
+
['最大变化', Number(check.maxChangeRatio || 0).toFixed(2) + '%'],
|
|
607
|
+
['说明', check.message || '-'],
|
|
608
|
+
].forEach(([label, value, className]) => {
|
|
609
|
+
const dt = document.createElement('dt');
|
|
610
|
+
const dd = document.createElement('dd');
|
|
611
|
+
dt.textContent = label;
|
|
612
|
+
dd.textContent = value;
|
|
613
|
+
if (className) dd.className = className;
|
|
614
|
+
detail.append(dt, dd);
|
|
615
|
+
});
|
|
616
|
+
const images = document.createElement('div');
|
|
617
|
+
images.className = 'visual-images';
|
|
618
|
+
[
|
|
619
|
+
['基准帧', check.baselineImageUrl],
|
|
620
|
+
['对比帧', check.comparisonImageUrl],
|
|
621
|
+
['差异图', check.diffImageUrl],
|
|
622
|
+
].forEach(([label, url]) => {
|
|
623
|
+
const figure = document.createElement('div');
|
|
624
|
+
figure.className = 'visual-image';
|
|
625
|
+
const caption = document.createElement('span');
|
|
626
|
+
caption.textContent = label;
|
|
627
|
+
const img = document.createElement('img');
|
|
628
|
+
img.src = url;
|
|
629
|
+
img.alt = label;
|
|
630
|
+
img.title = '点击放大 ' + label;
|
|
631
|
+
img.tabIndex = 0;
|
|
632
|
+
img.addEventListener('click', () => openImagePreview(url, check.nodeNumber + '. ' + check.nodeLabel + ' · ' + label));
|
|
633
|
+
img.addEventListener('keydown', (event) => {
|
|
634
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
635
|
+
event.preventDefault();
|
|
636
|
+
event.stopPropagation();
|
|
637
|
+
openImagePreview(url, check.nodeNumber + '. ' + check.nodeLabel + ' · ' + label);
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
figure.append(caption, img);
|
|
641
|
+
images.append(figure);
|
|
642
|
+
});
|
|
643
|
+
article.append(title, detail, images);
|
|
644
|
+
elements['visual-checks'].append(article);
|
|
645
|
+
});
|
|
379
646
|
elements.previous.addEventListener('click', () => { pause(); seekTo(frames[Math.max(0, currentFrame - 1)]?.offsetMs || 0); });
|
|
380
647
|
elements.next.addEventListener('click', () => { pause(); seekTo(frames[Math.min(frames.length - 1, currentFrame + 1)]?.offsetMs || durationMs); });
|
|
381
648
|
elements.play.addEventListener('click', () => {
|
|
@@ -393,10 +660,18 @@ function createReplayHtml(input: {
|
|
|
393
660
|
seekTo((event.clientX - bounds.left) / bounds.width * durationMs);
|
|
394
661
|
});
|
|
395
662
|
document.addEventListener('keydown', (event) => {
|
|
663
|
+
if (!imagePreview.hidden) {
|
|
664
|
+
if (event.key === 'Escape') closeImagePreview();
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
396
667
|
if (event.key === 'ArrowLeft') elements.previous.click();
|
|
397
668
|
if (event.key === 'ArrowRight') elements.next.click();
|
|
398
669
|
if (event.key === ' ') { event.preventDefault(); elements.play.click(); }
|
|
399
670
|
});
|
|
671
|
+
imagePreview.addEventListener('click', (event) => {
|
|
672
|
+
if (event.target === imagePreview) closeImagePreview();
|
|
673
|
+
});
|
|
674
|
+
document.querySelector('#image-preview-close').addEventListener('click', closeImagePreview);
|
|
400
675
|
elements.log.textContent = data.output || '';
|
|
401
676
|
seekTo(frames[0]?.offsetMs || 0, false);
|
|
402
677
|
</script>
|
|
@@ -413,6 +688,7 @@ export async function createAppiumReplayReport(input: {
|
|
|
413
688
|
startedAt: Date;
|
|
414
689
|
completedAt: Date;
|
|
415
690
|
frames?: AppiumReplayFrame[];
|
|
691
|
+
visualChecks?: AppiumReplayVisualCheck[];
|
|
416
692
|
}) {
|
|
417
693
|
const configuredOutputPath = loadConfig().runtime.reportOutputPath.trim();
|
|
418
694
|
const outputDir = appDataPath(configuredOutputPath || 'output');
|
|
@@ -433,6 +709,9 @@ export async function createAppiumReplayReport(input: {
|
|
|
433
709
|
const outputLines = persistedOutput.split(/\r?\n/);
|
|
434
710
|
const durationMs = input.completedAt.getTime() - input.startedAt.getTime();
|
|
435
711
|
const resultText = input.stopped ? '已终止' : input.success ? '成功' : '失败';
|
|
712
|
+
const visualChecks = input.visualChecks || [];
|
|
713
|
+
const visualCheckByStepId = new Map(visualChecks.map((check) => [check.nodeId, check]));
|
|
714
|
+
const visualFailureCount = visualChecks.filter((check) => check.status === 'failed').length;
|
|
436
715
|
const html = createReplayHtml({
|
|
437
716
|
script: input.script,
|
|
438
717
|
deviceId: input.deviceId,
|
|
@@ -441,6 +720,7 @@ export async function createAppiumReplayReport(input: {
|
|
|
441
720
|
startedAt: input.startedAt,
|
|
442
721
|
completedAt: input.completedAt,
|
|
443
722
|
frames: input.frames || [],
|
|
723
|
+
visualChecks,
|
|
444
724
|
output: persistedOutput,
|
|
445
725
|
});
|
|
446
726
|
const markdown = [
|
|
@@ -459,11 +739,12 @@ export async function createAppiumReplayReport(input: {
|
|
|
459
739
|
`| 执行结果 | **${resultText}** |`,
|
|
460
740
|
`| 节点数量 | ${input.script.steps.length} |`,
|
|
461
741
|
`| 截图帧数 | ${(input.frames || []).length} |`,
|
|
742
|
+
`| 视觉检测未达预期 | ${visualFailureCount} |`,
|
|
462
743
|
`| 截图回放 | ${markdownValue(htmlReportPath)} |`,
|
|
463
744
|
'',
|
|
464
745
|
'## 节点明细',
|
|
465
746
|
'',
|
|
466
|
-
...input.script.steps.map((step, index) => stepSection(step, index, outputLines)),
|
|
747
|
+
...input.script.steps.map((step, index) => stepSection(step, index, outputLines, visualCheckByStepId.get(step.id))),
|
|
467
748
|
'## 完整回放日志',
|
|
468
749
|
'',
|
|
469
750
|
'```text',
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
sqlNullableString,
|
|
7
7
|
sqlString,
|
|
8
8
|
} from '../storage/sqlite';
|
|
9
|
+
import { normalizeLegacyNestedConditionBranches } from '../../src/appium-recorder/flow-normalize';
|
|
9
10
|
|
|
10
11
|
export type AppiumRecordedStepRecord = {
|
|
11
12
|
id: string;
|
|
@@ -18,6 +19,10 @@ export type AppiumRecordedStepRecord = {
|
|
|
18
19
|
| 'backIfExists'
|
|
19
20
|
| 'waitFor'
|
|
20
21
|
| 'assertExists'
|
|
22
|
+
| 'checkboxState'
|
|
23
|
+
| 'radioButtonState'
|
|
24
|
+
| 'aiRecognition'
|
|
25
|
+
| 'textClick'
|
|
21
26
|
| 'key'
|
|
22
27
|
| 'waitActivity'
|
|
23
28
|
| 'delay'
|
|
@@ -31,7 +36,10 @@ export type AppiumRecordedStepRecord = {
|
|
|
31
36
|
| 'assertText'
|
|
32
37
|
| 'longPress'
|
|
33
38
|
| 'pinch'
|
|
34
|
-
| 'runScript'
|
|
39
|
+
| 'runScript'
|
|
40
|
+
| 'noop'
|
|
41
|
+
| 'log'
|
|
42
|
+
| 'visualChange';
|
|
35
43
|
label: string;
|
|
36
44
|
note?: string;
|
|
37
45
|
selector?: {
|
|
@@ -71,9 +79,10 @@ export type AppiumRecordedStepRecord = {
|
|
|
71
79
|
source?: 'node' | 'ancestor' | 'fallback';
|
|
72
80
|
};
|
|
73
81
|
value?: string;
|
|
74
|
-
|
|
82
|
+
logPrefix?: string;
|
|
75
83
|
keyCode?: number;
|
|
76
84
|
timeoutMs?: number;
|
|
85
|
+
longPressMode?: 'element' | 'coordinates';
|
|
77
86
|
flow?: {
|
|
78
87
|
nodeKind?: 'action' | 'condition' | 'assertion';
|
|
79
88
|
yesTargetId?: string;
|
|
@@ -110,6 +119,24 @@ export type AppiumRecordedStepRecord = {
|
|
|
110
119
|
centerY: number;
|
|
111
120
|
percent: number;
|
|
112
121
|
};
|
|
122
|
+
visualChange?: {
|
|
123
|
+
mode: 'selectedElement' | 'region';
|
|
124
|
+
region: {
|
|
125
|
+
x: number;
|
|
126
|
+
y: number;
|
|
127
|
+
width: number;
|
|
128
|
+
height: number;
|
|
129
|
+
};
|
|
130
|
+
role?: 'start' | 'end';
|
|
131
|
+
pairId?: string;
|
|
132
|
+
pairLabel?: string;
|
|
133
|
+
startStepId?: string;
|
|
134
|
+
endStepId?: string;
|
|
135
|
+
durationMs: number;
|
|
136
|
+
intervalMs: number;
|
|
137
|
+
changeRatioThreshold: number;
|
|
138
|
+
pixelmatchThreshold: number;
|
|
139
|
+
};
|
|
113
140
|
snapshot?: {
|
|
114
141
|
text: string;
|
|
115
142
|
resourceId: string;
|
|
@@ -245,7 +272,7 @@ function normalizeScriptActivity(
|
|
|
245
272
|
}
|
|
246
273
|
|
|
247
274
|
function rowToRecord(row: AppiumRecordedScriptRow): AppiumRecordedScriptRecord {
|
|
248
|
-
const steps = parseStepList(row.flow_json);
|
|
275
|
+
const steps = normalizeLegacyNestedConditionBranches(parseStepList(row.flow_json));
|
|
249
276
|
return {
|
|
250
277
|
id: row.id,
|
|
251
278
|
name: row.name,
|
|
@@ -307,7 +334,7 @@ export function saveAppiumRecordedScript(input: {
|
|
|
307
334
|
const id = input.id || createId('appium_script');
|
|
308
335
|
const name = input.name.trim();
|
|
309
336
|
const appPackage = input.appPackage.trim();
|
|
310
|
-
const steps = input.steps || [];
|
|
337
|
+
const steps = normalizeLegacyNestedConditionBranches(input.steps || []);
|
|
311
338
|
const appActivity = normalizeScriptActivity(appPackage, input.appActivity, steps);
|
|
312
339
|
|
|
313
340
|
if (!name) throw new Error('脚本名称不能为空');
|