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.
Files changed (56) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/FAQ.md +14 -0
  3. package/README.md +39 -3
  4. package/bin/android-midscene-automation.js +71 -1
  5. package/package.json +7 -2
  6. package/remote-agent/index.ts +2 -11
  7. package/server/appium-recorder/ai-recognition.ts +67 -0
  8. package/server/appium-recorder/appium-runner.ts +335 -52
  9. package/server/appium-recorder/report.ts +297 -16
  10. package/server/appium-recorder/repository.ts +31 -4
  11. package/server/appium-recorder/routes.ts +31 -32
  12. package/server/appium-recorder/screenshot.ts +21 -0
  13. package/server/appium-recorder/script-export.ts +67 -0
  14. package/server/appium-recorder/tree-dump.ts +22 -0
  15. package/server/appium-recorder/visual-change.ts +184 -0
  16. package/server/config.ts +10 -0
  17. package/server/http-api.ts +4 -5
  18. package/src/App.vue +14 -7
  19. package/src/api.ts +1 -1
  20. package/src/appium-recorder/AppiumPage.vue +658 -162
  21. package/src/appium-recorder/ai-recognition.ts +16 -0
  22. package/src/appium-recorder/api.ts +20 -3
  23. package/src/appium-recorder/components/AiRecognitionTestDialog.vue +71 -0
  24. package/src/appium-recorder/components/FlowActionMenu.vue +206 -0
  25. package/src/appium-recorder/components/FlowCanvas.vue +16 -1
  26. package/src/appium-recorder/components/FlowNodeCard.vue +23 -37
  27. package/src/appium-recorder/components/FlowStepEditor.vue +152 -25
  28. package/src/appium-recorder/components/LongPressSettings.vue +58 -0
  29. package/src/appium-recorder/components/NestedConditionBranches.vue +55 -35
  30. package/src/appium-recorder/components/NewScriptDialog.vue +27 -0
  31. package/src/appium-recorder/components/NodeDetail.vue +2 -0
  32. package/src/appium-recorder/components/RecordedSteps.vue +24 -4
  33. package/src/appium-recorder/components/StageLogSettings.vue +17 -0
  34. package/src/appium-recorder/components/TextClickSettings.vue +19 -0
  35. package/src/appium-recorder/components/VisualChangeDialog.vue +151 -0
  36. package/src/appium-recorder/flow-copy.ts +47 -1
  37. package/src/appium-recorder/flow-graph.ts +147 -64
  38. package/src/appium-recorder/flow-labels.ts +35 -6
  39. package/src/appium-recorder/flow-normalize.ts +76 -0
  40. package/src/appium-recorder/flow-remove.ts +69 -0
  41. package/src/appium-recorder/long-press.ts +27 -0
  42. package/src/appium-recorder/native-control-state.ts +26 -0
  43. package/src/appium-recorder/stage-log.ts +21 -0
  44. package/src/appium-recorder/text-click.ts +9 -0
  45. package/src/appium-recorder/tree.ts +3 -0
  46. package/src/appium-recorder/types.ts +34 -2
  47. package/src/appium-recorder/visual-change-pairs.ts +147 -0
  48. package/src/appium-recorder/visual-change.ts +75 -0
  49. package/src/components/config/AppiumConfigPanel.vue +48 -0
  50. package/src/components/config/BaseConfigPanel.vue +90 -0
  51. package/src/components/config/MidsceneConfigPanel.vue +204 -0
  52. package/src/components/device/DevicePreviewPanel.vue +148 -4
  53. package/src/pages/ConfigPage.vue +107 -265
  54. package/src/style.css +43 -4
  55. package/src/types.ts +2 -0
  56. 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
- if (lines.some((line) => line.includes('失败:') || line.includes('失败分支:'))) return '失败';
77
- if (lines.some((line) => line.includes('跳过:'))) return '跳过';
78
- if (lines.some((line) => line.includes('判断:'))) return '判断完成';
79
- if (lines.some((line) => line.includes('完成:'))) return '成功';
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 stepSection(step: AppiumRecordedStepRecord, index: number, outputLines: string[]) {
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
+ `| ![基准帧](data:image/png;base64,${check.baselineBase64}) | ![对比帧](data:image/png;base64,${check.comparisonBase64}) | ![差异图](data:image/png;base64,${check.diffBase64}) |`,
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
- `| 可选步骤 | ${step.optional ? '是' : '否'} |`,
100
- `| 是分支 | ${markdownValue(step.flow?.yesTargetId)} |`,
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: `data:image/png;base64,${frame.imageBase64}`,
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
- 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','log'].map((id) => [id, document.querySelector('#' + id)]));
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, .thumbnail').forEach((item) => item.classList.toggle('active', Number(item.dataset.index) === currentFrame));
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
- frames.forEach((frame, index) => {
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.index = String(index);
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 = (phaseLabels[frame.phase] || frame.phase) + ' · ' + frame.status;
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
- optional?: boolean;
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('脚本名称不能为空');