android-midscene-automation 0.1.27 → 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 +10 -2
- package/README.md +10 -2
- package/package.json +4 -2
- package/remote-agent/index.ts +2 -11
- package/server/appium-recorder/ai-recognition.ts +67 -0
- package/server/appium-recorder/appium-runner.ts +86 -48
- package/server/appium-recorder/report.ts +114 -16
- package/server/appium-recorder/repository.ts +7 -1
- 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/config.ts +10 -0
- package/server/http-api.ts +1 -1
- package/src/App.vue +14 -7
- package/src/api.ts +1 -1
- package/src/appium-recorder/AppiumPage.vue +195 -25
- package/src/appium-recorder/ai-recognition.ts +16 -0
- package/src/appium-recorder/api.ts +19 -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 +2 -0
- package/src/appium-recorder/components/FlowNodeCard.vue +23 -37
- package/src/appium-recorder/components/FlowStepEditor.vue +36 -28
- package/src/appium-recorder/components/LongPressSettings.vue +58 -0
- package/src/appium-recorder/components/NestedConditionBranches.vue +31 -33
- 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 +12 -4
- package/src/appium-recorder/components/StageLogSettings.vue +17 -0
- package/src/appium-recorder/components/TextClickSettings.vue +19 -0
- package/src/appium-recorder/flow-graph.ts +52 -64
- package/src/appium-recorder/flow-labels.ts +29 -5
- package/src/appium-recorder/flow-normalize.ts +11 -2
- package/src/appium-recorder/flow-remove.ts +23 -21
- 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 +9 -1
- 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/pages/ConfigPage.vue +107 -265
- package/src/style.css +4 -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';
|
|
@@ -45,6 +48,9 @@ export type AppiumReplayVisualCheck = {
|
|
|
45
48
|
diffBase64: string;
|
|
46
49
|
};
|
|
47
50
|
|
|
51
|
+
const HTML_REPORT_IMAGE_MAX_WIDTH = 540;
|
|
52
|
+
const HTML_REPORT_IMAGE_MAX_HEIGHT = 1200;
|
|
53
|
+
|
|
48
54
|
function pad(value: number, length = 2) {
|
|
49
55
|
return String(value).padStart(length, '0');
|
|
50
56
|
}
|
|
@@ -81,6 +87,54 @@ function jsonForHtml(value: unknown) {
|
|
|
81
87
|
.replace(/&/g, '\\u0026');
|
|
82
88
|
}
|
|
83
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
|
+
|
|
84
138
|
function markdownValue(value: unknown) {
|
|
85
139
|
if (value === undefined || value === null || value === '') return '-';
|
|
86
140
|
return String(value).replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>');
|
|
@@ -99,10 +153,12 @@ function stepExecutionLines(outputLines: string[], index: number) {
|
|
|
99
153
|
|
|
100
154
|
function executionStatus(lines: string[]) {
|
|
101
155
|
if (!lines.length) return '未执行';
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
if (
|
|
105
|
-
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 '成功';
|
|
106
162
|
return '中断';
|
|
107
163
|
}
|
|
108
164
|
|
|
@@ -157,9 +213,8 @@ function stepSection(
|
|
|
157
213
|
`| 上下文 Selector | ${markdownValue(selectorText(step.contextSelector))} |`,
|
|
158
214
|
`| 输入/目标值 | ${markdownValue(step.value)} |`,
|
|
159
215
|
`| 超时时间 | ${markdownValue(step.timeoutMs === undefined ? '-' : `${step.timeoutMs}ms`)} |`,
|
|
160
|
-
`|
|
|
161
|
-
`|
|
|
162
|
-
`| 否分支 | ${markdownValue(step.flow?.noTargetId)} |`,
|
|
216
|
+
`| ${flowBranchLabel(step, 'yes')} 分支 | ${markdownValue(step.flow?.yesTargetId)} |`,
|
|
217
|
+
`| ${flowBranchLabel(step, 'no')} 分支 | ${markdownValue(step.flow?.noTargetId)} |`,
|
|
163
218
|
`| 成功后续节点 | ${markdownValue(step.flow?.successTargetId)} |`,
|
|
164
219
|
`| 执行状态 | **${executionStatus(executionLines)}** |`,
|
|
165
220
|
'',
|
|
@@ -192,19 +247,20 @@ function createReplayHtml(input: {
|
|
|
192
247
|
visualChecks: AppiumReplayVisualCheck[];
|
|
193
248
|
output: string;
|
|
194
249
|
}) {
|
|
250
|
+
const imageCache = new Map<string, string>();
|
|
195
251
|
const payload = jsonForHtml({
|
|
196
252
|
startedAt: input.startedAt.toISOString(),
|
|
197
253
|
durationMs: input.durationMs,
|
|
198
254
|
frames: input.frames.map((frame) => ({
|
|
199
255
|
...frame,
|
|
200
|
-
imageUrl:
|
|
256
|
+
imageUrl: htmlImageUrl(frame.imageBase64, imageCache),
|
|
201
257
|
imageBase64: undefined,
|
|
202
258
|
})),
|
|
203
259
|
visualChecks: input.visualChecks.map((check) => ({
|
|
204
260
|
...check,
|
|
205
|
-
baselineImageUrl:
|
|
206
|
-
comparisonImageUrl:
|
|
207
|
-
diffImageUrl:
|
|
261
|
+
baselineImageUrl: htmlImageUrl(check.baselineBase64, imageCache),
|
|
262
|
+
comparisonImageUrl: htmlImageUrl(check.comparisonBase64, imageCache),
|
|
263
|
+
diffImageUrl: htmlImageUrl(check.diffBase64, imageCache),
|
|
208
264
|
baselineBase64: undefined,
|
|
209
265
|
comparisonBase64: undefined,
|
|
210
266
|
diffBase64: undefined,
|
|
@@ -246,6 +302,8 @@ function createReplayHtml(input: {
|
|
|
246
302
|
.frame-title, .frame-subtitle { display: block; overflow-wrap: anywhere; }
|
|
247
303
|
.frame-title { font-size: 13px; font-weight: 600; }
|
|
248
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; }
|
|
249
307
|
.workspace { display: grid; grid-template-rows: 178px minmax(0, 1fr); min-width: 0; min-height: 0; }
|
|
250
308
|
.timeline-panel { min-width: 0; overflow-x: auto; overflow-y: hidden; border-bottom: 1px solid #dfe3e8; background: #fff; }
|
|
251
309
|
.timeline-track { position: relative; height: 178px; min-width: 100%; cursor: pointer; user-select: none; }
|
|
@@ -322,6 +380,7 @@ function createReplayHtml(input: {
|
|
|
322
380
|
<dt>节点</dt><dd id="node">-</dd>
|
|
323
381
|
<dt>阶段</dt><dd id="phase">-</dd>
|
|
324
382
|
<dt>结果</dt><dd id="frame-status">-</dd>
|
|
383
|
+
<dt id="node-log-label" hidden>日志内容</dt><dd id="node-log" hidden></dd>
|
|
325
384
|
<dt>备注</dt><dd id="note">-</dd>
|
|
326
385
|
<dt>定位</dt><dd id="selector">-</dd>
|
|
327
386
|
<dt>时间</dt><dd id="time">-</dd>
|
|
@@ -368,6 +427,24 @@ function createReplayHtml(input: {
|
|
|
368
427
|
offsetMs: Math.max(0, (Date.parse(frame.capturedAt) || reportStartedAt) - reportStartedAt),
|
|
369
428
|
}));
|
|
370
429
|
const durationMs = Math.max(1000, Number(data.durationMs) || 0, frames.at(-1)?.offsetMs || 0);
|
|
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
|
+
});
|
|
371
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)]));
|
|
372
449
|
const imagePreview = document.querySelector('#image-preview');
|
|
373
450
|
const imagePreviewImage = document.querySelector('#image-preview-image');
|
|
@@ -387,6 +464,7 @@ function createReplayHtml(input: {
|
|
|
387
464
|
let animationFrame = 0;
|
|
388
465
|
let playbackStartedAt = 0;
|
|
389
466
|
const phaseLabels = { before: '执行前', after: '执行后', error: '失败', stopped: '已终止' };
|
|
467
|
+
const visualCheckStatusLabel = (status) => status === 'passed' ? '有变化' : '无明显变化';
|
|
390
468
|
const formatTime = (milliseconds) => {
|
|
391
469
|
const seconds = Math.max(0, Math.floor(milliseconds / 1000));
|
|
392
470
|
return String(Math.floor(seconds / 60)).padStart(2, '0') + ':' + String(seconds % 60).padStart(2, '0');
|
|
@@ -410,6 +488,10 @@ function createReplayHtml(input: {
|
|
|
410
488
|
elements.node.textContent = String(frame.nodeNumber || '-');
|
|
411
489
|
elements.phase.textContent = phaseLabels[frame.phase] || frame.phase || '-';
|
|
412
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 || '';
|
|
413
495
|
elements.note.textContent = frame.note || '-';
|
|
414
496
|
elements.selector.textContent = frame.selector || '-';
|
|
415
497
|
elements.time.textContent = new Date(frame.capturedAt).toLocaleString();
|
|
@@ -421,7 +503,8 @@ function createReplayHtml(input: {
|
|
|
421
503
|
elements.seek.value = String(Math.round(currentMs / durationMs * 1000));
|
|
422
504
|
elements.clock.textContent = formatTime(currentMs) + ' / ' + formatTime(durationMs);
|
|
423
505
|
elements.playhead.style.left = (currentMs / durationMs * 100) + '%';
|
|
424
|
-
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));
|
|
425
508
|
if (scrollStep) document.querySelector('.frame-item.active')?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
426
509
|
};
|
|
427
510
|
const pause = () => {
|
|
@@ -453,11 +536,13 @@ function createReplayHtml(input: {
|
|
|
453
536
|
tick.append(label);
|
|
454
537
|
elements.ruler.append(tick);
|
|
455
538
|
}
|
|
456
|
-
|
|
539
|
+
steps.forEach((step, index) => {
|
|
540
|
+
const frame = frames[step.firstIndex];
|
|
541
|
+
const result = frames[step.lastIndex];
|
|
457
542
|
const button = document.createElement('button');
|
|
458
543
|
button.type = 'button';
|
|
459
544
|
button.className = 'frame-item';
|
|
460
|
-
button.dataset.
|
|
545
|
+
button.dataset.step = String(index);
|
|
461
546
|
const badge = document.createElement('span');
|
|
462
547
|
badge.className = 'frame-index';
|
|
463
548
|
badge.textContent = String(index + 1);
|
|
@@ -467,15 +552,28 @@ function createReplayHtml(input: {
|
|
|
467
552
|
title.textContent = frame.nodeNumber + '. ' + frame.nodeLabel;
|
|
468
553
|
const subtitle = document.createElement('span');
|
|
469
554
|
subtitle.className = 'frame-subtitle';
|
|
470
|
-
subtitle.textContent =
|
|
555
|
+
subtitle.textContent = result.phase === 'before' ? '未完成' : result.status;
|
|
471
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
|
+
}
|
|
472
569
|
const time = document.createElement('span');
|
|
473
570
|
time.className = 'frame-time';
|
|
474
571
|
time.textContent = formatTime(frame.offsetMs);
|
|
475
572
|
button.append(badge, content, time);
|
|
476
573
|
button.addEventListener('click', () => { pause(); seekTo(frame.offsetMs); });
|
|
477
574
|
elements['step-list'].append(button);
|
|
478
|
-
|
|
575
|
+
});
|
|
576
|
+
frames.forEach((frame, index) => {
|
|
479
577
|
const thumbnail = document.createElement('button');
|
|
480
578
|
thumbnail.type = 'button';
|
|
481
579
|
thumbnail.className = 'thumbnail';
|
|
@@ -19,6 +19,10 @@ export type AppiumRecordedStepRecord = {
|
|
|
19
19
|
| 'backIfExists'
|
|
20
20
|
| 'waitFor'
|
|
21
21
|
| 'assertExists'
|
|
22
|
+
| 'checkboxState'
|
|
23
|
+
| 'radioButtonState'
|
|
24
|
+
| 'aiRecognition'
|
|
25
|
+
| 'textClick'
|
|
22
26
|
| 'key'
|
|
23
27
|
| 'waitActivity'
|
|
24
28
|
| 'delay'
|
|
@@ -34,6 +38,7 @@ export type AppiumRecordedStepRecord = {
|
|
|
34
38
|
| 'pinch'
|
|
35
39
|
| 'runScript'
|
|
36
40
|
| 'noop'
|
|
41
|
+
| 'log'
|
|
37
42
|
| 'visualChange';
|
|
38
43
|
label: string;
|
|
39
44
|
note?: string;
|
|
@@ -74,9 +79,10 @@ export type AppiumRecordedStepRecord = {
|
|
|
74
79
|
source?: 'node' | 'ancestor' | 'fallback';
|
|
75
80
|
};
|
|
76
81
|
value?: string;
|
|
77
|
-
|
|
82
|
+
logPrefix?: string;
|
|
78
83
|
keyCode?: number;
|
|
79
84
|
timeoutMs?: number;
|
|
85
|
+
longPressMode?: 'element' | 'coordinates';
|
|
80
86
|
flow?: {
|
|
81
87
|
nodeKind?: 'action' | 'condition' | 'assertion';
|
|
82
88
|
yesTargetId?: string;
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import path from 'node:path';
|
|
2
|
+
import { recognizeDeviceScreen } from './ai-recognition';
|
|
3
|
+
import { readFreshWindowHierarchy } from './tree-dump';
|
|
5
4
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
6
5
|
import {
|
|
7
6
|
deleteAppiumRecordedScript,
|
|
@@ -14,6 +13,7 @@ import {
|
|
|
14
13
|
import { clearAppDataOnDevice, launchAppOnDevice, replayAppiumScript } from './appium-runner';
|
|
15
14
|
import { isRemoteDeviceId, sendRemoteCommand } from '../remote-agents/registry';
|
|
16
15
|
import { getAdbCommand } from '../android-sdk';
|
|
16
|
+
import { createAppiumScriptExport } from './script-export';
|
|
17
17
|
|
|
18
18
|
const treeDumpTasks = new Map<string, Promise<string>>();
|
|
19
19
|
const replayingDevices = new Set<string>();
|
|
@@ -25,13 +25,6 @@ function sendJson(res: ServerResponse, payload: unknown, statusCode = 200) {
|
|
|
25
25
|
res.end(JSON.stringify(payload));
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
function sendJsonDownload(res: ServerResponse, payload: unknown, fileName: string) {
|
|
29
|
-
res.statusCode = 200;
|
|
30
|
-
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
31
|
-
res.setHeader('Content-Disposition', `attachment; filename="appium-script.json"; filename*=UTF-8''${encodeURIComponent(fileName)}`);
|
|
32
|
-
res.end(JSON.stringify(payload, null, 2));
|
|
33
|
-
}
|
|
34
|
-
|
|
35
28
|
function sendStreamEvent(res: ServerResponse, payload: unknown) {
|
|
36
29
|
res.write(`${JSON.stringify(payload)}\n`);
|
|
37
30
|
}
|
|
@@ -64,17 +57,7 @@ async function dumpWindowHierarchy(deviceId: string) {
|
|
|
64
57
|
const activeTask = treeDumpTasks.get(deviceId);
|
|
65
58
|
if (activeTask) return activeTask;
|
|
66
59
|
|
|
67
|
-
const task = (
|
|
68
|
-
const localPath = path.join(os.tmpdir(), `midscene-appium-${deviceId.replace(/[^\w.-]/g, '_')}-${Date.now()}.xml`);
|
|
69
|
-
const remotePath = '/data/local/tmp/midscene_appium_uidump.xml';
|
|
70
|
-
await execFileText(getAdbCommand(), ['-s', deviceId, 'shell', 'uiautomator', 'dump', remotePath]);
|
|
71
|
-
await execFileText(getAdbCommand(), ['-s', deviceId, 'pull', remotePath, localPath]);
|
|
72
|
-
try {
|
|
73
|
-
return await fs.readFile(localPath, 'utf8');
|
|
74
|
-
} finally {
|
|
75
|
-
await fs.unlink(localPath).catch(() => undefined);
|
|
76
|
-
}
|
|
77
|
-
})();
|
|
60
|
+
const task = readFreshWindowHierarchy(deviceId, getAdbCommand(), execFileText);
|
|
78
61
|
treeDumpTasks.set(deviceId, task);
|
|
79
62
|
try {
|
|
80
63
|
return await task;
|
|
@@ -134,6 +117,7 @@ export async function handleAppiumRecorderRequest(
|
|
|
134
117
|
};
|
|
135
118
|
|
|
136
119
|
if (pathname === '/api/appium-recorder/tree' && req.method === 'GET') {
|
|
120
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
137
121
|
const deviceId = requestUrl.searchParams.get('deviceId')?.trim() || selectedDeviceId;
|
|
138
122
|
if (!deviceId) throw new Error('未检测到可用设备');
|
|
139
123
|
assertDeviceAllowed(deviceId);
|
|
@@ -192,6 +176,24 @@ export async function handleAppiumRecorderRequest(
|
|
|
192
176
|
return true;
|
|
193
177
|
}
|
|
194
178
|
|
|
179
|
+
if (pathname === '/api/appium-recorder/ai-recognition/test' && req.method === 'POST') {
|
|
180
|
+
const parsed = await readBody<{ deviceId?: string; prompt?: unknown; timeoutMs?: number }>(req);
|
|
181
|
+
const deviceId = parsed.deviceId?.trim() || selectedDeviceId;
|
|
182
|
+
if (!deviceId) throw new Error('请选择设备');
|
|
183
|
+
assertDeviceAllowed(deviceId);
|
|
184
|
+
if (isRemoteDeviceId(deviceId)) throw new Error('远程设备暂不支持 AI 识别测试');
|
|
185
|
+
if (replayingDevices.has(deviceId)) { sendJson(res, { message: '设备正在回放,请稍后测试' }, 409); return true; }
|
|
186
|
+
const controller = new AbortController();
|
|
187
|
+
const abort = () => controller.abort();
|
|
188
|
+
res.once('close', abort);
|
|
189
|
+
try {
|
|
190
|
+
sendJson(res, await recognizeDeviceScreen({ deviceId, prompt: parsed.prompt, timeoutMs: parsed.timeoutMs, signal: controller.signal }));
|
|
191
|
+
} finally {
|
|
192
|
+
res.off('close', abort);
|
|
193
|
+
}
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
|
|
195
197
|
if (pathname === '/api/appium-recorder/launch-app' && req.method === 'POST') {
|
|
196
198
|
const parsed = await readBody<{ deviceId?: string; packageName?: string }>(req);
|
|
197
199
|
const deviceId = parsed.deviceId?.trim() || selectedDeviceId;
|
|
@@ -277,17 +279,14 @@ export async function handleAppiumRecorderRequest(
|
|
|
277
279
|
if (exportMatch && req.method === 'GET') {
|
|
278
280
|
const script = getAppiumRecordedScript(decodeURIComponent(exportMatch[1]));
|
|
279
281
|
if (!script) throw new Error('Appium 录制脚本不存在');
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
steps: script.steps,
|
|
289
|
-
},
|
|
290
|
-
}, `${script.name}.json`);
|
|
282
|
+
const exported = await createAppiumScriptExport(script, getAppiumRecordedScript);
|
|
283
|
+
const fileName = encodeURIComponent(exported.fileName)
|
|
284
|
+
.replace(/['()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
285
|
+
res.statusCode = 200;
|
|
286
|
+
res.setHeader('Content-Type', exported.contentType);
|
|
287
|
+
res.setHeader('Content-Disposition', `attachment; filename="appium-script.${exported.contentType === 'application/zip' ? 'zip' : 'json'}"; filename*=UTF-8''${fileName}`);
|
|
288
|
+
res.setHeader('Content-Length', exported.body.length);
|
|
289
|
+
res.end(exported.body);
|
|
291
290
|
return true;
|
|
292
291
|
}
|
|
293
292
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { getAdbCommand } from '../android-sdk';
|
|
3
|
+
|
|
4
|
+
// 直接读取设备截图,不使用预览缓存,也不进入 Appium 命令队列。
|
|
5
|
+
export function adbScreenshotBase64(deviceId: string, signal?: AbortSignal) {
|
|
6
|
+
return new Promise<string>((resolve, reject) => {
|
|
7
|
+
execFile(
|
|
8
|
+
getAdbCommand(),
|
|
9
|
+
['-s', deviceId, 'exec-out', 'screencap', '-p'],
|
|
10
|
+
{ encoding: 'buffer', maxBuffer: 20 * 1024 * 1024, timeout: 8000, signal },
|
|
11
|
+
(error, stdout, stderr) => {
|
|
12
|
+
if (error) {
|
|
13
|
+
reject(new Error(stderr?.toString('utf8').trim() || error.message || 'ADB 截图失败'));
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (!stdout.length) { reject(new Error('ADB 未返回截图内容')); return; }
|
|
17
|
+
resolve(stdout.toString('base64'));
|
|
18
|
+
},
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { buffer } from 'node:stream/consumers';
|
|
2
|
+
import archiver from 'archiver';
|
|
3
|
+
import type { AppiumRecordedScriptRecord } from './repository';
|
|
4
|
+
|
|
5
|
+
function safeExportName(name: string) {
|
|
6
|
+
const base = Array.from(name.trim().replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_'))
|
|
7
|
+
.slice(0, 80).join('').replace(/[. ]+$/, '') || 'script';
|
|
8
|
+
return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i.test(base) ? `_${base}` : base;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function createAppiumScriptExport(
|
|
12
|
+
root: AppiumRecordedScriptRecord,
|
|
13
|
+
getScript: (id: string) => AppiumRecordedScriptRecord | null,
|
|
14
|
+
) {
|
|
15
|
+
const scripts = [root];
|
|
16
|
+
const visited = new Set([root.id]);
|
|
17
|
+
// 按脚本 ID 去重,既保留所有分支的依赖,也避免重复引用和循环连接无限展开。
|
|
18
|
+
for (const script of scripts) {
|
|
19
|
+
for (const step of script.steps) {
|
|
20
|
+
if (step.type !== 'runScript') continue;
|
|
21
|
+
const targetId = step.value || '';
|
|
22
|
+
if (!targetId) throw new Error(`脚本「${script.name}」的「${step.label}」缺少连接脚本 ID,无法导出`);
|
|
23
|
+
if (visited.has(targetId)) continue;
|
|
24
|
+
const target = getScript(targetId);
|
|
25
|
+
if (!target) throw new Error(`脚本「${script.name}」连接的脚本不存在(${targetId}),无法导出完整脚本包`);
|
|
26
|
+
visited.add(targetId);
|
|
27
|
+
scripts.push(target);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const exportedAt = new Date().toISOString();
|
|
32
|
+
const serialize = (script: AppiumRecordedScriptRecord) => JSON.stringify({
|
|
33
|
+
schemaVersion: 1,
|
|
34
|
+
exportedAt,
|
|
35
|
+
script: {
|
|
36
|
+
id: script.id,
|
|
37
|
+
name: script.name,
|
|
38
|
+
appPackage: script.appPackage,
|
|
39
|
+
appActivity: script.appActivity,
|
|
40
|
+
deviceId: script.deviceId,
|
|
41
|
+
steps: script.steps,
|
|
42
|
+
},
|
|
43
|
+
}, null, 2);
|
|
44
|
+
const baseName = safeExportName(root.name);
|
|
45
|
+
if (!root.steps.some((step) => step.type === 'runScript')) {
|
|
46
|
+
return {
|
|
47
|
+
fileName: `${baseName}.json`,
|
|
48
|
+
contentType: 'application/json; charset=utf-8',
|
|
49
|
+
body: Buffer.from(serialize(root)),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const archive = archiver('zip', { zlib: { level: 9 } });
|
|
54
|
+
const usedNames = new Set<string>();
|
|
55
|
+
for (const script of scripts) {
|
|
56
|
+
const name = safeExportName(script.name);
|
|
57
|
+
let fileName = `${name}.json`;
|
|
58
|
+
let suffix = 2;
|
|
59
|
+
// 清理非法字符后可能重名,Windows 解压时还需忽略大小写进行去重。
|
|
60
|
+
while (usedNames.has(fileName.toLowerCase())) fileName = `${name} (${suffix++}).json`;
|
|
61
|
+
usedNames.add(fileName.toLowerCase());
|
|
62
|
+
archive.append(serialize(script), { name: fileName });
|
|
63
|
+
}
|
|
64
|
+
// 生成完整压缩包后再发送响应,压缩失败仍可返回普通 JSON 错误。
|
|
65
|
+
const [body] = await Promise.all([buffer(archive), archive.finalize()]);
|
|
66
|
+
return { fileName: `${baseName}.zip`, contentType: 'application/zip', body };
|
|
67
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
type ExecText = (command: string, args: string[]) => Promise<string>;
|
|
4
|
+
|
|
5
|
+
export async function readFreshWindowHierarchy(deviceId: string, adb: string, execText: ExecText) {
|
|
6
|
+
// dump 失败可能仍以 0 退出。每次独立文件,避免误读上一次成功抓取的树。
|
|
7
|
+
const remotePath = `/data/local/tmp/midscene_uidump_${randomUUID()}.xml`;
|
|
8
|
+
const run = (args: string[]) => execText(adb, ['-s', deviceId, ...args]);
|
|
9
|
+
try {
|
|
10
|
+
const output = await run(['shell', 'uiautomator', 'dump', remotePath]);
|
|
11
|
+
if (/ERROR:|could not|failed/i.test(output)) throw new Error(output.trim());
|
|
12
|
+
const xml = await run(['shell', 'cat', remotePath]);
|
|
13
|
+
if (!xml.includes('<hierarchy') || !xml.includes('</hierarchy>')) {
|
|
14
|
+
throw new Error('设备未返回有效的组件树');
|
|
15
|
+
}
|
|
16
|
+
return xml;
|
|
17
|
+
} catch (error) {
|
|
18
|
+
throw new Error(`刷新组件树失败:${error instanceof Error ? error.message : String(error)}`);
|
|
19
|
+
} finally {
|
|
20
|
+
await run(['shell', 'rm', '-f', remotePath]).catch(() => undefined);
|
|
21
|
+
}
|
|
22
|
+
}
|
package/server/config.ts
CHANGED
|
@@ -4,8 +4,10 @@ import path from 'node:path';
|
|
|
4
4
|
import YAML from 'yaml';
|
|
5
5
|
import { appDataPath } from './paths';
|
|
6
6
|
import { loadModelConfigFromDb, saveModelConfigToDb } from './config-store';
|
|
7
|
+
import type { AiRecognitionModel } from '../src/appium-recorder/ai-recognition';
|
|
7
8
|
|
|
8
9
|
export type AppConfig = {
|
|
10
|
+
appium: { model: AiRecognitionModel };
|
|
9
11
|
runtime: {
|
|
10
12
|
androidSdkPath: string;
|
|
11
13
|
reportOutputPath: string;
|
|
@@ -43,6 +45,7 @@ export class ConfigValidationError extends Error {
|
|
|
43
45
|
|
|
44
46
|
function defaultConfig(): AppConfig {
|
|
45
47
|
return {
|
|
48
|
+
appium: { model: { baseUrl: '', apiKey: '', name: '' } },
|
|
46
49
|
runtime: {
|
|
47
50
|
androidSdkPath: '',
|
|
48
51
|
reportOutputPath: '',
|
|
@@ -78,6 +81,13 @@ function normalizeEnv(env: unknown): Record<string, string> {
|
|
|
78
81
|
function normalizeConfig(config: Partial<AppConfig> | null | undefined): AppConfig {
|
|
79
82
|
const fallback = defaultConfig();
|
|
80
83
|
return {
|
|
84
|
+
// 旧配置没有 Appium 模型时保留空值,不借用其他模型或改变已有配置。
|
|
85
|
+
appium: {
|
|
86
|
+
model: Object.fromEntries(['baseUrl', 'apiKey', 'name'].map((key) => {
|
|
87
|
+
const value = config?.appium?.model?.[key as keyof AiRecognitionModel];
|
|
88
|
+
return [key, typeof value === 'string' ? value.trim() : ''];
|
|
89
|
+
})) as AiRecognitionModel,
|
|
90
|
+
},
|
|
81
91
|
runtime: {
|
|
82
92
|
androidSdkPath: config?.runtime?.androidSdkPath?.trim() || fallback.runtime.androidSdkPath,
|
|
83
93
|
reportOutputPath: config?.runtime?.reportOutputPath?.trim() || fallback.runtime.reportOutputPath,
|
package/server/http-api.ts
CHANGED
|
@@ -1070,7 +1070,7 @@ export function createApiMiddleware() {
|
|
|
1070
1070
|
if (req.url === '/api/test-model' && req.method === 'POST') {
|
|
1071
1071
|
try {
|
|
1072
1072
|
const parsed = await readBody<{
|
|
1073
|
-
modelKey?: 'midscene' | 'scriptOptimizer';
|
|
1073
|
+
modelKey?: 'midscene' | 'scriptOptimizer' | 'appium';
|
|
1074
1074
|
model?: {
|
|
1075
1075
|
provider?: 'custom' | 'codex';
|
|
1076
1076
|
baseUrl?: string;
|
package/src/App.vue
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import { isAiRecognitionModelConfigured } from './appium-recorder/ai-recognition';
|
|
2
3
|
import { computed, onMounted, onUnmounted, reactive, ref, shallowRef, watch } from 'vue';
|
|
3
4
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
|
4
5
|
import {
|
|
@@ -99,7 +100,9 @@ const appPresets = ref<AppPreset[]>([]);
|
|
|
99
100
|
const modelTestStatus = reactive({
|
|
100
101
|
midscene: '',
|
|
101
102
|
scriptOptimizer: '',
|
|
103
|
+
appium: '',
|
|
102
104
|
});
|
|
105
|
+
const aiRecognitionModelConfigured = shallowRef(false);
|
|
103
106
|
const actionDialog = reactive({
|
|
104
107
|
visible: false,
|
|
105
108
|
title: '',
|
|
@@ -149,6 +152,7 @@ const appPresetForm = reactive({
|
|
|
149
152
|
});
|
|
150
153
|
|
|
151
154
|
const configForm = reactive<ConfigForm>({
|
|
155
|
+
appium: { model: { baseUrl: '', apiKey: '', name: '' } },
|
|
152
156
|
runtime: {
|
|
153
157
|
androidSdkPath: '',
|
|
154
158
|
reportOutputPath: '',
|
|
@@ -656,6 +660,8 @@ const loadConfig = async () => {
|
|
|
656
660
|
});
|
|
657
661
|
Object.assign(configForm.midscene.env, payload.midscene.env || {});
|
|
658
662
|
Object.assign(configForm.scriptOptimizer.model, payload.scriptOptimizer.model);
|
|
663
|
+
Object.assign(configForm.appium.model, payload.appium?.model || { baseUrl: '', apiKey: '', name: '' });
|
|
664
|
+
aiRecognitionModelConfigured.value = isAiRecognitionModelConfigured(configForm.appium.model);
|
|
659
665
|
if (configForm.midscene.model.provider !== 'codex') {
|
|
660
666
|
rememberCustomMidsceneModel();
|
|
661
667
|
}
|
|
@@ -692,7 +698,7 @@ const getMidsceneModelConfigError = () => {
|
|
|
692
698
|
if (model.provider !== 'codex' && !model.apiKey.trim()) missing.push('API Key');
|
|
693
699
|
|
|
694
700
|
if (!missing.length) return '';
|
|
695
|
-
return `Midscene 模型配置不完整:缺少 ${missing.join('、')}。请在“参数配置 > Midscene 模型”中选择“使用 Codex”,或补齐自定义提供方后保存。`;
|
|
701
|
+
return `Midscene 模型配置不完整:缺少 ${missing.join('、')}。请在“参数配置 > Midscene配置 > Midscene 模型”中选择“使用 Codex”,或补齐自定义提供方后保存。`;
|
|
696
702
|
};
|
|
697
703
|
|
|
698
704
|
const saveModelConfig = async () => {
|
|
@@ -700,7 +706,10 @@ const saveModelConfig = async () => {
|
|
|
700
706
|
errorMessage.value = '';
|
|
701
707
|
openActionDialog('保存参数配置');
|
|
702
708
|
try {
|
|
703
|
-
|
|
709
|
+
// 保存的是本次提交快照,不能把请求期间尚未保存的改动标记为已配置。
|
|
710
|
+
const snapshot = JSON.parse(JSON.stringify(configForm)) as ConfigForm;
|
|
711
|
+
await api.saveConfig(snapshot);
|
|
712
|
+
aiRecognitionModelConfigured.value = isAiRecognitionModelConfigured(snapshot.appium.model);
|
|
704
713
|
closeActionDialog();
|
|
705
714
|
ElMessage.success('参数配置已保存');
|
|
706
715
|
} catch (error) {
|
|
@@ -776,14 +785,11 @@ const removeAppPreset = async (id: string) => {
|
|
|
776
785
|
}
|
|
777
786
|
};
|
|
778
787
|
|
|
779
|
-
const testModel = async (key: 'midscene' | 'scriptOptimizer') => {
|
|
788
|
+
const testModel = async (key: 'midscene' | 'scriptOptimizer' | 'appium') => {
|
|
780
789
|
testingModelKey.value = key;
|
|
781
790
|
errorMessage.value = '';
|
|
782
791
|
modelTestStatus[key] = '';
|
|
783
|
-
const model =
|
|
784
|
-
key === 'midscene'
|
|
785
|
-
? configForm.midscene.model
|
|
786
|
-
: configForm.scriptOptimizer.model;
|
|
792
|
+
const model = configForm[key].model;
|
|
787
793
|
|
|
788
794
|
try {
|
|
789
795
|
const payload = await api.testModel({ modelKey: key, model });
|
|
@@ -1557,6 +1563,7 @@ onUnmounted(() => {
|
|
|
1557
1563
|
v-show="activeMenu === 'appium'"
|
|
1558
1564
|
:active="activeMenu === 'appium'"
|
|
1559
1565
|
:app-presets="appPresets"
|
|
1566
|
+
:ai-recognition-model-configured="aiRecognitionModelConfigured"
|
|
1560
1567
|
:device-actions="deviceActions"
|
|
1561
1568
|
:playground-available="playgroundAvailable"
|
|
1562
1569
|
:playground-device-id="playgroundDeviceId"
|
package/src/api.ts
CHANGED
|
@@ -116,7 +116,7 @@ export function saveConfig(input: ConfigForm) {
|
|
|
116
116
|
|
|
117
117
|
// 测试指定模型配置是否可用,通常用于保存前验证 baseUrl/apiKey/model。
|
|
118
118
|
export function testModel(input: {
|
|
119
|
-
modelKey: 'midscene' | 'scriptOptimizer';
|
|
119
|
+
modelKey: 'midscene' | 'scriptOptimizer' | 'appium';
|
|
120
120
|
model: {
|
|
121
121
|
provider?: 'custom' | 'codex';
|
|
122
122
|
baseUrl: string;
|