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,12 +1,20 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
|
+
import { adbScreenshotBase64 } from './screenshot';
|
|
3
|
+
import { recognizeDeviceScreen } from './ai-recognition';
|
|
4
|
+
import { formatStageLog } from '../../src/appium-recorder/stage-log';
|
|
5
|
+
import { textClickSelector } from '../../src/appium-recorder/text-click';
|
|
2
6
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
3
7
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
4
8
|
import { join } from 'node:path';
|
|
5
9
|
import { getAppiumRecordedScript, type AppiumRecordedScriptRecord, type AppiumRecordedStepRecord } from './repository';
|
|
6
10
|
import { appDataPath } from '../paths';
|
|
7
11
|
import { ensureAndroidSdkAvailable, getAdbCommand } from '../android-sdk';
|
|
8
|
-
import { createAppiumReplayReport, type AppiumReplayFrame } from './report';
|
|
12
|
+
import { createAppiumReplayReport, type AppiumReplayFrame, type AppiumReplayVisualCheck } from './report';
|
|
9
13
|
import { startManagedAppiumServer, usesManagedAppiumServer } from './managed-appium';
|
|
14
|
+
import { compareVisualChangeFrames, type VisualChangeRegion } from './visual-change';
|
|
15
|
+
import { longPressMode, validateLongPress } from '../../src/appium-recorder/long-press';
|
|
16
|
+
import { defaultFlowKind, flowBranchLabel } from '../../src/appium-recorder/flow-labels';
|
|
17
|
+
import { isNativeStateCondition, nativeControlName, nativeControlType, parseNativeBoolean } from '../../src/appium-recorder/native-control-state';
|
|
10
18
|
|
|
11
19
|
type AppiumSessionResponse = {
|
|
12
20
|
value?: {
|
|
@@ -34,11 +42,21 @@ class ReplayStoppedError extends Error {
|
|
|
34
42
|
}
|
|
35
43
|
}
|
|
36
44
|
|
|
45
|
+
type PendingVisualChangeCheck = {
|
|
46
|
+
step: AppiumRecordedStepRecord;
|
|
47
|
+
nodeNumber: number;
|
|
48
|
+
scriptName: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
37
51
|
const replayContext = new AsyncLocalStorage<{
|
|
38
52
|
signal?: AbortSignal;
|
|
39
53
|
appiumLog: (line: string) => void;
|
|
40
54
|
serverUrl: string;
|
|
55
|
+
deviceId: string;
|
|
41
56
|
frames: AppiumReplayFrame[];
|
|
57
|
+
visualChecks: AppiumReplayVisualCheck[];
|
|
58
|
+
pendingVisualChecks: PendingVisualChangeCheck[];
|
|
59
|
+
softFailureCount: number;
|
|
42
60
|
}>();
|
|
43
61
|
|
|
44
62
|
const appiumServerUrl = () => replayContext.getStore()?.serverUrl || configuredAppiumServerUrl();
|
|
@@ -60,6 +78,11 @@ function errorDetail(error: unknown) {
|
|
|
60
78
|
return error.message;
|
|
61
79
|
}
|
|
62
80
|
|
|
81
|
+
function isTimeoutError(error: unknown) {
|
|
82
|
+
const detail = errorDetail(error).toLowerCase();
|
|
83
|
+
return detail.includes('timeout') || detail.includes('timed out');
|
|
84
|
+
}
|
|
85
|
+
|
|
63
86
|
function isStaleElementError(error: unknown) {
|
|
64
87
|
const detail = errorDetail(error);
|
|
65
88
|
return detail.includes('stale element reference') || detail.includes('does not exist in DOM anymore');
|
|
@@ -254,6 +277,9 @@ async function appiumRequest<T>(
|
|
|
254
277
|
throw new ReplayStoppedError();
|
|
255
278
|
}
|
|
256
279
|
context?.appiumLog(`[Appium] 异常:${method} ${path}(${errorDetail(error)})`);
|
|
280
|
+
if (isTimeoutError(error)) {
|
|
281
|
+
throw new Error(`Appium 请求超时 ${requestUrl},${errorDetail(error)}`);
|
|
282
|
+
}
|
|
257
283
|
throw new Error(`无法连接 Appium 服务 ${requestUrl},${errorDetail(error)}`);
|
|
258
284
|
}
|
|
259
285
|
|
|
@@ -284,6 +310,7 @@ async function appiumRequest<T>(
|
|
|
284
310
|
}
|
|
285
311
|
|
|
286
312
|
function replayFrameSelector(step: AppiumRecordedStepRecord) {
|
|
313
|
+
if (step.type === 'textClick') return `${step.flow?.textMatch === 'exact' ? '精准匹配' : '模糊匹配'}:${step.value || ''}`;
|
|
287
314
|
const selector = step.selector;
|
|
288
315
|
if (!selector) return '-';
|
|
289
316
|
if (selector.strategy === 'bounds') return `bounds (${selector.centerX ?? '-'}, ${selector.centerY ?? '-'})`;
|
|
@@ -291,15 +318,16 @@ function replayFrameSelector(step: AppiumRecordedStepRecord) {
|
|
|
291
318
|
}
|
|
292
319
|
|
|
293
320
|
async function captureReplayFrame(
|
|
294
|
-
|
|
321
|
+
_sessionId: string,
|
|
295
322
|
step: AppiumRecordedStepRecord,
|
|
296
323
|
nodeNumber: number,
|
|
297
324
|
scriptName: string,
|
|
298
325
|
phase: AppiumReplayFrame['phase'],
|
|
299
326
|
status: string,
|
|
327
|
+
imageBase64?: string,
|
|
300
328
|
) {
|
|
301
329
|
const context = replayContext.getStore();
|
|
302
|
-
if (!context || !
|
|
330
|
+
if (!context || !context.deviceId) return;
|
|
303
331
|
const appendFrame = (imageBase64: string) => {
|
|
304
332
|
context.frames.push({
|
|
305
333
|
sequence: context.frames.length + 1,
|
|
@@ -308,6 +336,7 @@ async function captureReplayFrame(
|
|
|
308
336
|
nodeNumber,
|
|
309
337
|
nodeLabel: step.label,
|
|
310
338
|
nodeType: step.type,
|
|
339
|
+
logContent: step.type === 'log' && phase === 'after' ? formatStageLog(step) : undefined,
|
|
311
340
|
note: step.note || '',
|
|
312
341
|
selector: replayFrameSelector(step),
|
|
313
342
|
phase,
|
|
@@ -324,14 +353,8 @@ async function captureReplayFrame(
|
|
|
324
353
|
}
|
|
325
354
|
|
|
326
355
|
try {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
undefined,
|
|
330
|
-
{ ignoreAbort: true, timeoutMs: 5000 },
|
|
331
|
-
);
|
|
332
|
-
const imageBase64 = typeof payload.value === 'string' ? payload.value : '';
|
|
333
|
-
if (!imageBase64) throw new Error('Appium 未返回截图内容');
|
|
334
|
-
appendFrame(imageBase64);
|
|
356
|
+
// Report screenshots use ADB so a slow capture cannot block Appium's command queue.
|
|
357
|
+
appendFrame(imageBase64 || await adbScreenshotBase64(context.deviceId));
|
|
335
358
|
} catch (error) {
|
|
336
359
|
context.appiumLog(`[节点 ${nodeNumber}] 截图失败:${errorDetail(error)}`);
|
|
337
360
|
}
|
|
@@ -443,8 +466,7 @@ async function waitForElement(sessionId: string, step: AppiumRecordedStepRecord)
|
|
|
443
466
|
let lastError: unknown;
|
|
444
467
|
while (Date.now() - startedAt <= timeoutMs) {
|
|
445
468
|
try {
|
|
446
|
-
await findElement(sessionId, step);
|
|
447
|
-
return;
|
|
469
|
+
return await findElement(sessionId, step);
|
|
448
470
|
} catch (error) {
|
|
449
471
|
throwIfReplayStopped(error);
|
|
450
472
|
lastError = error;
|
|
@@ -483,16 +505,183 @@ async function waitForElementGone(sessionId: string, step: AppiumRecordedStepRec
|
|
|
483
505
|
throw new Error(`${step.label} 等待消失超时`);
|
|
484
506
|
}
|
|
485
507
|
|
|
486
|
-
async function saveScreenshot(
|
|
487
|
-
const payload = await appiumRequest<AppiumValueResponse<string>>(`/session/${sessionId}/screenshot`);
|
|
488
|
-
if (!payload.value) throw new Error('Appium 未返回截图数据');
|
|
508
|
+
async function saveScreenshot(deviceId: string) {
|
|
489
509
|
const dir = appDataPath('.midscene-app', 'screenshots');
|
|
490
510
|
await mkdir(dir, { recursive: true });
|
|
491
511
|
const file = join(dir, `appium-${Date.now()}.png`);
|
|
492
|
-
await writeFile(file, Buffer.from(
|
|
512
|
+
await writeFile(file, Buffer.from(await adbScreenshotBase64(deviceId), 'base64'));
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
type RunStepMeta = {
|
|
516
|
+
nodeNumber: number;
|
|
517
|
+
scriptName: string;
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
type RunStepResult = string | {
|
|
521
|
+
message?: string;
|
|
522
|
+
softFailed?: boolean;
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
function completedStepStatus(step: AppiumRecordedStepRecord, softFailed: boolean) {
|
|
526
|
+
if (step.type === 'visualChange') {
|
|
527
|
+
if (step.visualChange?.role === 'start') return '已记录基准帧';
|
|
528
|
+
if (step.visualChange?.role === 'end') return '已记录对比帧';
|
|
529
|
+
}
|
|
530
|
+
return softFailed ? '视觉变化未达预期' : '成功';
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function visualChangeNumber(value: unknown, fallback: number, min: number, max = Number.POSITIVE_INFINITY) {
|
|
534
|
+
const number = Number(value);
|
|
535
|
+
if (!Number.isFinite(number)) return fallback;
|
|
536
|
+
return Math.min(max, Math.max(min, number));
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function visualChangeRegion(step: AppiumRecordedStepRecord): VisualChangeRegion {
|
|
540
|
+
const config = step.visualChange;
|
|
541
|
+
if (!config?.region) throw new Error(`${step.label} 缺少检测区域`);
|
|
542
|
+
return {
|
|
543
|
+
x: Math.max(0, Math.round(Number(config.region.x) || 0)),
|
|
544
|
+
y: Math.max(0, Math.round(Number(config.region.y) || 0)),
|
|
545
|
+
width: Math.max(1, Math.round(Number(config.region.width) || 1)),
|
|
546
|
+
height: Math.max(1, Math.round(Number(config.region.height) || 1)),
|
|
547
|
+
};
|
|
493
548
|
}
|
|
494
549
|
|
|
495
|
-
|
|
550
|
+
function findVisualFrame(
|
|
551
|
+
frames: AppiumReplayFrame[],
|
|
552
|
+
nodeId: string,
|
|
553
|
+
scriptName: string,
|
|
554
|
+
preferredPhase: AppiumReplayFrame['phase'],
|
|
555
|
+
) {
|
|
556
|
+
const matches = frames.filter((frame) => frame.nodeId === nodeId && frame.scriptName === scriptName);
|
|
557
|
+
if (!matches.length) return undefined;
|
|
558
|
+
const preferred = preferredPhase === 'before'
|
|
559
|
+
? matches.find((frame) => frame.phase === preferredPhase)
|
|
560
|
+
: [...matches].reverse().find((frame) => frame.phase === preferredPhase);
|
|
561
|
+
return preferred || (preferredPhase === 'before' ? matches[0] : matches[matches.length - 1]);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const EMPTY_PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=';
|
|
565
|
+
|
|
566
|
+
function registerVisualChangeStep(
|
|
567
|
+
step: AppiumRecordedStepRecord,
|
|
568
|
+
meta: RunStepMeta,
|
|
569
|
+
): RunStepResult {
|
|
570
|
+
const config = step.visualChange;
|
|
571
|
+
if (!config) throw new Error(`${step.label} 缺少画面变化检测配置`);
|
|
572
|
+
if (config.role === 'start') {
|
|
573
|
+
return {
|
|
574
|
+
message: `已记录画面变化基准点:${config.pairLabel || step.label}`,
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
const context = replayContext.getStore();
|
|
578
|
+
const endStep = {
|
|
579
|
+
...step,
|
|
580
|
+
visualChange: {
|
|
581
|
+
...config,
|
|
582
|
+
endStepId: config.endStepId || step.id,
|
|
583
|
+
},
|
|
584
|
+
};
|
|
585
|
+
context?.pendingVisualChecks.push({ step: endStep, nodeNumber: meta.nodeNumber, scriptName: meta.scriptName });
|
|
586
|
+
return {
|
|
587
|
+
message: `已记录画面变化对比点:${config.pairLabel || step.label}`,
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function appendVisualCheckFailure(lines: string[], check: PendingVisualChangeCheck, message: string) {
|
|
592
|
+
const context = replayContext.getStore();
|
|
593
|
+
if (context) {
|
|
594
|
+
const config = check.step.visualChange;
|
|
595
|
+
context.softFailureCount += 1;
|
|
596
|
+
context.visualChecks.push({
|
|
597
|
+
nodeId: check.step.id,
|
|
598
|
+
nodeNumber: check.nodeNumber,
|
|
599
|
+
nodeLabel: check.step.label,
|
|
600
|
+
status: 'failed',
|
|
601
|
+
message,
|
|
602
|
+
region: config?.region ? visualChangeRegion(check.step) : { x: 0, y: 0, width: 1, height: 1 },
|
|
603
|
+
durationMs: 0,
|
|
604
|
+
intervalMs: 0,
|
|
605
|
+
sampleCount: 0,
|
|
606
|
+
changeRatioThreshold: visualChangeNumber(config?.changeRatioThreshold, 2, 0.01),
|
|
607
|
+
maxChangeRatio: 0,
|
|
608
|
+
baselineBase64: EMPTY_PNG_BASE64,
|
|
609
|
+
comparisonBase64: EMPTY_PNG_BASE64,
|
|
610
|
+
diffBase64: EMPTY_PNG_BASE64,
|
|
611
|
+
startNodeId: config?.startStepId,
|
|
612
|
+
endNodeId: config?.endStepId,
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
lines.push(`[节点 ${check.nodeNumber}] 视觉变化检测异常:${message}(继续执行)`);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function processPendingVisualChecks(lines: string[]) {
|
|
619
|
+
const context = replayContext.getStore();
|
|
620
|
+
if (!context || !context.pendingVisualChecks.length) return;
|
|
621
|
+
|
|
622
|
+
// Visual checks compare captured replay frames after the path finishes, so
|
|
623
|
+
// they never block or mutate the device state for downstream nodes.
|
|
624
|
+
context.pendingVisualChecks.forEach((check) => {
|
|
625
|
+
const config = check.step.visualChange;
|
|
626
|
+
if (!config?.startStepId || !config.endStepId) {
|
|
627
|
+
appendVisualCheckFailure(lines, check, `${check.step.label} 缺少开始节点或结束节点`);
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
const baselineFrame = findVisualFrame(context.frames, config.startStepId, check.scriptName, 'after');
|
|
631
|
+
const comparisonFrame = findVisualFrame(context.frames, config.endStepId, check.scriptName, 'after');
|
|
632
|
+
if (!baselineFrame || !comparisonFrame) {
|
|
633
|
+
const missing = [
|
|
634
|
+
baselineFrame ? '' : '开始节点截图',
|
|
635
|
+
comparisonFrame ? '' : '结束节点截图',
|
|
636
|
+
].filter(Boolean).join('、');
|
|
637
|
+
appendVisualCheckFailure(lines, check, `${check.step.label} 未捕获到${missing}`);
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
let result: ReturnType<typeof compareVisualChangeFrames>;
|
|
641
|
+
try {
|
|
642
|
+
result = compareVisualChangeFrames({
|
|
643
|
+
baselineScreenshot: Buffer.from(baselineFrame.imageBase64, 'base64'),
|
|
644
|
+
comparisonScreenshot: Buffer.from(comparisonFrame.imageBase64, 'base64'),
|
|
645
|
+
region: visualChangeRegion(check.step),
|
|
646
|
+
changeRatioThreshold: visualChangeNumber(config.changeRatioThreshold, 2, 0.01),
|
|
647
|
+
pixelmatchThreshold: visualChangeNumber(config.pixelmatchThreshold, 0.1, 0, 1),
|
|
648
|
+
});
|
|
649
|
+
} catch (error) {
|
|
650
|
+
appendVisualCheckFailure(lines, check, `${check.step.label} 对比失败:${errorDetail(error)}`);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
context.visualChecks.push({
|
|
654
|
+
nodeId: check.step.id,
|
|
655
|
+
nodeNumber: check.nodeNumber,
|
|
656
|
+
nodeLabel: check.step.label,
|
|
657
|
+
status: result.passed ? 'passed' : 'failed',
|
|
658
|
+
message: result.message,
|
|
659
|
+
region: result.region,
|
|
660
|
+
durationMs: result.durationMs,
|
|
661
|
+
intervalMs: result.intervalMs,
|
|
662
|
+
sampleCount: result.sampleCount,
|
|
663
|
+
changeRatioThreshold: result.changeRatioThreshold,
|
|
664
|
+
maxChangeRatio: result.maxChangeRatio,
|
|
665
|
+
baselineBase64: result.baselineBase64,
|
|
666
|
+
comparisonBase64: result.comparisonBase64,
|
|
667
|
+
diffBase64: result.diffBase64,
|
|
668
|
+
startNodeId: config.startStepId,
|
|
669
|
+
startNodeLabel: `${baselineFrame.nodeNumber}. ${baselineFrame.nodeLabel}`,
|
|
670
|
+
endNodeId: config.endStepId,
|
|
671
|
+
endNodeLabel: `${comparisonFrame.nodeNumber}. ${comparisonFrame.nodeLabel}`,
|
|
672
|
+
});
|
|
673
|
+
if (!result.passed) context.softFailureCount += 1;
|
|
674
|
+
lines.push(`[节点 ${check.nodeNumber}] 视觉变化检测${result.passed ? '有变化' : '无明显变化'}:${result.message}${result.passed ? '' : '(继续执行)'}`);
|
|
675
|
+
});
|
|
676
|
+
context.pendingVisualChecks = [];
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
async function runStep(
|
|
680
|
+
sessionId: string,
|
|
681
|
+
deviceId: string,
|
|
682
|
+
step: AppiumRecordedStepRecord,
|
|
683
|
+
meta: RunStepMeta,
|
|
684
|
+
): Promise<RunStepResult | void> {
|
|
496
685
|
throwIfReplayStopped();
|
|
497
686
|
if (step.type === 'delay') {
|
|
498
687
|
await wait(Math.max(0, step.timeoutMs || 1000));
|
|
@@ -528,10 +717,19 @@ async function runStep(sessionId: string, deviceId: string, step: AppiumRecorded
|
|
|
528
717
|
}
|
|
529
718
|
|
|
530
719
|
if (step.type === 'screenshot') {
|
|
531
|
-
await saveScreenshot(
|
|
720
|
+
await saveScreenshot(deviceId);
|
|
532
721
|
return;
|
|
533
722
|
}
|
|
534
723
|
|
|
724
|
+
if (step.type === 'visualChange') {
|
|
725
|
+
return registerVisualChangeStep(step, meta);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
if (step.type === 'noop') {
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
if (step.type === 'log') return formatStageLog(step);
|
|
732
|
+
|
|
535
733
|
if (step.type === 'coordinateTap') {
|
|
536
734
|
await tapFallback(deviceId, step);
|
|
537
735
|
return;
|
|
@@ -544,13 +742,21 @@ async function runStep(sessionId: string, deviceId: string, step: AppiumRecorded
|
|
|
544
742
|
}
|
|
545
743
|
|
|
546
744
|
if (step.type === 'longPress') {
|
|
547
|
-
|
|
548
|
-
|
|
745
|
+
const error = validateLongPress(step);
|
|
746
|
+
if (error) throw new Error(`${step.label}:${error}`);
|
|
747
|
+
const duration = step.timeoutMs ?? 800;
|
|
748
|
+
if (longPressMode(step) === 'element') {
|
|
749
|
+
const elementId = await findElement(sessionId, step);
|
|
750
|
+
await appiumRequest(`/session/${sessionId}/execute/sync`, {
|
|
751
|
+
method: 'POST',
|
|
752
|
+
body: JSON.stringify({ script: 'mobile: longClickGesture', args: [{ elementId, duration }] }),
|
|
753
|
+
});
|
|
754
|
+
return `已长按元素 ${duration}ms`;
|
|
549
755
|
}
|
|
550
|
-
const x = Number(step.fallback
|
|
551
|
-
const y = Number(step.fallback
|
|
552
|
-
await adbSwipe(deviceId, { startX: x, startY: y, endX: x, endY: y, duration
|
|
553
|
-
return
|
|
756
|
+
const x = Number(step.fallback!.centerX);
|
|
757
|
+
const y = Number(step.fallback!.centerY);
|
|
758
|
+
await adbSwipe(deviceId, { startX: x, startY: y, endX: x, endY: y, duration });
|
|
759
|
+
return `已长按坐标 ${x},${y} ${duration}ms`;
|
|
554
760
|
}
|
|
555
761
|
|
|
556
762
|
if (step.type === 'pinch') {
|
|
@@ -681,7 +887,48 @@ async function runStep(sessionId: string, deviceId: string, step: AppiumRecorded
|
|
|
681
887
|
}
|
|
682
888
|
}
|
|
683
889
|
|
|
890
|
+
async function readNativeControlState(sessionId: string, step: AppiumRecordedStepRecord) {
|
|
891
|
+
if (!isNativeStateCondition(step)) throw new Error('不是原生控件状态判断节点');
|
|
892
|
+
const elementId = await waitForElement(sessionId, step);
|
|
893
|
+
const attribute = async (name: string) => (
|
|
894
|
+
await appiumRequest<AppiumValueResponse<unknown>>(`/session/${sessionId}/element/${elementId}/attribute/${name}`)
|
|
895
|
+
).value;
|
|
896
|
+
const className = await attribute('className');
|
|
897
|
+
if (nativeControlType(className) !== step.type) {
|
|
898
|
+
throw new Error(`${step.label} 目标不是原生 ${nativeControlName(step.type)},实际类型:${String(className ?? '未知')}`);
|
|
899
|
+
}
|
|
900
|
+
if (parseNativeBoolean(await attribute('checkable')) !== true) {
|
|
901
|
+
throw new Error(`${step.label} 目标不支持原生勾选状态(checkable 不为 true)`);
|
|
902
|
+
}
|
|
903
|
+
const checked = parseNativeBoolean(await attribute('checked'));
|
|
904
|
+
if (checked === undefined) throw new Error(`${step.label} 无法读取有效的 checked 属性`);
|
|
905
|
+
return checked;
|
|
906
|
+
}
|
|
907
|
+
|
|
684
908
|
async function evaluateCondition(sessionId: string, deviceId: string, step: AppiumRecordedStepRecord) {
|
|
909
|
+
if (step.type === 'textClick') {
|
|
910
|
+
const selector = textClickSelector(step);
|
|
911
|
+
const deadline = Date.now() + Math.max(0, step.timeoutMs ?? 10000);
|
|
912
|
+
// 查询空结果才是未匹配;通信错误、重复匹配和点击失败走现有异常处理。
|
|
913
|
+
do {
|
|
914
|
+
const payload = await appiumRequest<AppiumValueResponse<Record<string, string>[]>>(`/session/${sessionId}/elements`, {
|
|
915
|
+
method: 'POST', body: JSON.stringify(selector),
|
|
916
|
+
});
|
|
917
|
+
if (!Array.isArray(payload.value)) throw new Error('文字点击查询返回了无效结果');
|
|
918
|
+
if (payload.value.length > 1) throw new Error(`匹配到 ${payload.value.length} 个文字元素,请使用更精确的文字`);
|
|
919
|
+
if (payload.value.length === 1) {
|
|
920
|
+
const element = payload.value[0]!;
|
|
921
|
+
const id = element[ELEMENT_KEY] || element.ELEMENT;
|
|
922
|
+
if (!id) throw new Error('文字点击查询缺少元素 ID');
|
|
923
|
+
await appiumRequest(`/session/${sessionId}/element/${id}/click`, { method: 'POST', body: '{}' });
|
|
924
|
+
return true;
|
|
925
|
+
}
|
|
926
|
+
if (Date.now() >= deadline) return false;
|
|
927
|
+
await wait(Math.max(0, Math.min(500, deadline - Date.now())));
|
|
928
|
+
} while (true);
|
|
929
|
+
}
|
|
930
|
+
// 未找到控件、类型错误和属性读取异常不等于未勾选,必须交给回放错误处理。
|
|
931
|
+
if (isNativeStateCondition(step)) return readNativeControlState(sessionId, step);
|
|
685
932
|
try {
|
|
686
933
|
if (step.type === 'waitActivity') {
|
|
687
934
|
await waitForActivity(deviceId, step);
|
|
@@ -710,7 +957,7 @@ async function evaluateCondition(sessionId: string, deviceId: string, step: Appi
|
|
|
710
957
|
|
|
711
958
|
function hasFlowSteps(steps: AppiumRecordedStepRecord[]) {
|
|
712
959
|
return steps.some((step) => (
|
|
713
|
-
step
|
|
960
|
+
defaultFlowKind(step) === 'condition'
|
|
714
961
|
|| Boolean(step.flow?.yesTargetId)
|
|
715
962
|
|| Boolean(step.flow?.noTargetId)
|
|
716
963
|
|| Boolean(step.flow?.successTargetId)
|
|
@@ -797,6 +1044,20 @@ async function replayLinkedScript(
|
|
|
797
1044
|
lines.push(`连接脚本完成:${linkedScript.name}`);
|
|
798
1045
|
}
|
|
799
1046
|
|
|
1047
|
+
function appendRunStepResult(lines: string[], nodeNumber: number, result: RunStepResult | void) {
|
|
1048
|
+
if (!result) return false;
|
|
1049
|
+
if (typeof result === 'string') {
|
|
1050
|
+
if (result) lines.push(`[节点 ${nodeNumber}] 结果:${result}`);
|
|
1051
|
+
return false;
|
|
1052
|
+
}
|
|
1053
|
+
if (result.softFailed) {
|
|
1054
|
+
lines.push(`[节点 ${nodeNumber}] 视觉变化未达预期:${result.message || '未检测到明显变化'}(继续执行)`);
|
|
1055
|
+
return true;
|
|
1056
|
+
}
|
|
1057
|
+
if (result.message) lines.push(`[节点 ${nodeNumber}] 结果:${result.message}`);
|
|
1058
|
+
return false;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
800
1061
|
async function replayLinearSteps(
|
|
801
1062
|
sessionId: string,
|
|
802
1063
|
deviceId: string,
|
|
@@ -814,25 +1075,24 @@ async function replayLinearSteps(
|
|
|
814
1075
|
lines.push(`[节点 ${index + 1}] 开始:${step.label}`);
|
|
815
1076
|
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'before', '执行前');
|
|
816
1077
|
try {
|
|
1078
|
+
let softFailed = false;
|
|
817
1079
|
if (step.type === 'runScript') {
|
|
818
1080
|
await replayLinkedScript(sessionId, deviceId, step, lines, stack);
|
|
819
1081
|
} else {
|
|
820
|
-
const result = await runStep(sessionId, deviceId, step
|
|
821
|
-
|
|
1082
|
+
const result = await runStep(sessionId, deviceId, step, {
|
|
1083
|
+
nodeNumber: index + 1,
|
|
1084
|
+
scriptName: options.scriptName || '',
|
|
1085
|
+
});
|
|
1086
|
+
softFailed = appendRunStepResult(lines, index + 1, result);
|
|
822
1087
|
}
|
|
823
|
-
lines.push(`[节点 ${index + 1}] 完成:${step.label}`);
|
|
824
|
-
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'after',
|
|
1088
|
+
lines.push(`[节点 ${index + 1}] 完成:${step.label}${softFailed ? '(存在软失败,已继续)' : ''}`);
|
|
1089
|
+
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'after', completedStepStatus(step, softFailed));
|
|
825
1090
|
} catch (error) {
|
|
826
1091
|
if (isReplayStopped(error)) {
|
|
827
1092
|
lines.push(`[节点 ${index + 1}] 已终止:${step.label}`);
|
|
828
1093
|
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'stopped', '已终止');
|
|
829
1094
|
throw new ReplayStoppedError();
|
|
830
1095
|
}
|
|
831
|
-
if (step.optional) {
|
|
832
|
-
lines.push(`[节点 ${index + 1}] 跳过:${errorDetail(error)}`);
|
|
833
|
-
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'error', '已跳过');
|
|
834
|
-
continue;
|
|
835
|
-
}
|
|
836
1096
|
lines.push(`[节点 ${index + 1}] 失败:${errorDetail(error)}`);
|
|
837
1097
|
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'error', '失败');
|
|
838
1098
|
await appendStepDiagnostics(lines, deviceId, step);
|
|
@@ -884,23 +1144,33 @@ async function replayFlowSteps(
|
|
|
884
1144
|
continue;
|
|
885
1145
|
}
|
|
886
1146
|
visitedPath.push(step.label);
|
|
887
|
-
const nodeKind =
|
|
1147
|
+
const nodeKind = defaultFlowKind(step);
|
|
888
1148
|
lines.push(`[节点 ${index + 1}] 开始:${step.label}`);
|
|
889
1149
|
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'before', '执行前');
|
|
890
1150
|
|
|
891
1151
|
if (nodeKind === 'condition') {
|
|
892
1152
|
try {
|
|
893
|
-
|
|
1153
|
+
// AI 节点使用本次执行前截图,并将同一张图片和识别依据写入结果帧。
|
|
1154
|
+
const beforeFrame = replayContext.getStore()?.frames.at(-1);
|
|
1155
|
+
const recognition = step.type === 'aiRecognition' ? await recognizeDeviceScreen({
|
|
1156
|
+
deviceId, prompt: step.value, timeoutMs: step.timeoutMs,
|
|
1157
|
+
signal: replayContext.getStore()?.signal,
|
|
1158
|
+
imageBase64: beforeFrame?.nodeId === step.id && beforeFrame.phase === 'before' ? beforeFrame.imageBase64 : undefined,
|
|
1159
|
+
}) : undefined;
|
|
1160
|
+
const matched = recognition ? recognition.result : await evaluateCondition(sessionId, deviceId, step);
|
|
1161
|
+
if (recognition) lines.push(`[节点 ${index + 1}] AI 识别:${recognition.result},耗时 ${recognition.durationMs}ms${recognition.reason ? `,依据:${recognition.reason}` : ''}`);
|
|
894
1162
|
const branchTargetId = matched ? step.flow?.yesTargetId : step.flow?.noTargetId;
|
|
895
1163
|
const targetId = branchTargetId || step.flow?.successTargetId || '';
|
|
896
|
-
|
|
1164
|
+
const branchLabel = flowBranchLabel(step, matched ? 'yes' : 'no');
|
|
1165
|
+
lines.push(`[节点 ${index + 1}] 判断:${branchLabel}${isNativeStateCondition(step) ? `(checked=${matched})` : ''}${targetId ? `,进入 ${targetId}` : ',流程结束'}`);
|
|
897
1166
|
await captureReplayFrame(
|
|
898
1167
|
sessionId,
|
|
899
1168
|
step,
|
|
900
1169
|
index + 1,
|
|
901
1170
|
options.scriptName || '',
|
|
902
1171
|
'after',
|
|
903
|
-
`判断:${
|
|
1172
|
+
`判断:${branchLabel}${recognition?.reason ? `;${recognition.reason}` : ''}`,
|
|
1173
|
+
recognition?.imageBase64,
|
|
904
1174
|
);
|
|
905
1175
|
index = targetId ? idToIndex.get(targetId) : undefined;
|
|
906
1176
|
} catch (error) {
|
|
@@ -909,20 +1179,27 @@ async function replayFlowSteps(
|
|
|
909
1179
|
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'stopped', '已终止');
|
|
910
1180
|
throw new ReplayStoppedError();
|
|
911
1181
|
}
|
|
1182
|
+
lines.push(`[节点 ${index + 1}] 失败:${errorDetail(error)}`);
|
|
1183
|
+
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'error', '失败');
|
|
1184
|
+
await appendStepDiagnostics(lines, deviceId, step);
|
|
912
1185
|
throw error;
|
|
913
1186
|
}
|
|
914
1187
|
continue;
|
|
915
1188
|
}
|
|
916
1189
|
|
|
917
1190
|
try {
|
|
1191
|
+
let softFailed = false;
|
|
918
1192
|
if (step.type === 'runScript') {
|
|
919
1193
|
await replayLinkedScript(sessionId, deviceId, step, lines, stack);
|
|
920
1194
|
} else {
|
|
921
|
-
const result = await runStep(sessionId, deviceId, step
|
|
922
|
-
|
|
1195
|
+
const result = await runStep(sessionId, deviceId, step, {
|
|
1196
|
+
nodeNumber: index + 1,
|
|
1197
|
+
scriptName: options.scriptName || '',
|
|
1198
|
+
});
|
|
1199
|
+
softFailed = appendRunStepResult(lines, index + 1, result);
|
|
923
1200
|
}
|
|
924
|
-
lines.push(`[节点 ${index + 1}] 完成:${step.label}`);
|
|
925
|
-
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'after',
|
|
1201
|
+
lines.push(`[节点 ${index + 1}] 完成:${step.label}${softFailed ? '(存在软失败,已继续)' : ''}`);
|
|
1202
|
+
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'after', completedStepStatus(step, softFailed));
|
|
926
1203
|
index = nextIndexAfterStep(index, step);
|
|
927
1204
|
} catch (error) {
|
|
928
1205
|
if (isReplayStopped(error)) {
|
|
@@ -930,12 +1207,6 @@ async function replayFlowSteps(
|
|
|
930
1207
|
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'stopped', '已终止');
|
|
931
1208
|
throw new ReplayStoppedError();
|
|
932
1209
|
}
|
|
933
|
-
if (step.optional) {
|
|
934
|
-
lines.push(`[节点 ${index + 1}] 跳过:${errorDetail(error)}`);
|
|
935
|
-
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'error', '已跳过');
|
|
936
|
-
index = nextIndexAfterStep(index, step);
|
|
937
|
-
continue;
|
|
938
|
-
}
|
|
939
1210
|
const failureTarget = step.flow?.failureTargetId;
|
|
940
1211
|
if (failureTarget && idToIndex.has(failureTarget)) {
|
|
941
1212
|
lines.push(`[节点 ${index + 1}] 失败分支:${errorDetail(error)}`);
|
|
@@ -997,7 +1268,7 @@ async function replayScriptSteps(
|
|
|
997
1268
|
try {
|
|
998
1269
|
await replayLinkedScript(sessionId, deviceId, step, lines, stack);
|
|
999
1270
|
lines.push(`[节点 ${nodeNumber}] 完成:${step.label}`);
|
|
1000
|
-
await captureReplayFrame(sessionId, step, nodeNumber, options.scriptName || '', 'after',
|
|
1271
|
+
await captureReplayFrame(sessionId, step, nodeNumber, options.scriptName || '', 'after', completedStepStatus(step, false));
|
|
1001
1272
|
} catch (error) {
|
|
1002
1273
|
if (isReplayStopped(error)) {
|
|
1003
1274
|
lines.push(`[节点 ${nodeNumber}] 已终止:${step.label}`);
|
|
@@ -1033,7 +1304,11 @@ export async function replayAppiumScript(
|
|
|
1033
1304
|
signal,
|
|
1034
1305
|
appiumLog: (line) => lines.push(line),
|
|
1035
1306
|
serverUrl: configuredAppiumServerUrl(),
|
|
1307
|
+
deviceId: targetDeviceId,
|
|
1036
1308
|
frames: [] as AppiumReplayFrame[],
|
|
1309
|
+
visualChecks: [] as AppiumReplayVisualCheck[],
|
|
1310
|
+
pendingVisualChecks: [] as PendingVisualChangeCheck[],
|
|
1311
|
+
softFailureCount: 0,
|
|
1037
1312
|
};
|
|
1038
1313
|
return replayContext.run(context, async () => {
|
|
1039
1314
|
lines.push(
|
|
@@ -1071,8 +1346,14 @@ export async function replayAppiumScript(
|
|
|
1071
1346
|
await replayScriptSteps(sessionId, targetDeviceId, script.steps, lines, [script.id], {
|
|
1072
1347
|
scriptName: script.name,
|
|
1073
1348
|
});
|
|
1074
|
-
lines
|
|
1075
|
-
|
|
1349
|
+
processPendingVisualChecks(lines);
|
|
1350
|
+
if (context.softFailureCount > 0) {
|
|
1351
|
+
lines.push(`回放完成,但存在 ${context.softFailureCount} 个视觉变化未达预期节点`);
|
|
1352
|
+
success = false;
|
|
1353
|
+
} else {
|
|
1354
|
+
lines.push('回放完成');
|
|
1355
|
+
success = true;
|
|
1356
|
+
}
|
|
1076
1357
|
} catch (error) {
|
|
1077
1358
|
stopped = isReplayStopped(error);
|
|
1078
1359
|
const detail = errorDetail(error);
|
|
@@ -1126,6 +1407,7 @@ export async function replayAppiumScript(
|
|
|
1126
1407
|
startedAt,
|
|
1127
1408
|
completedAt,
|
|
1128
1409
|
frames: context.frames,
|
|
1410
|
+
visualChecks: context.visualChecks,
|
|
1129
1411
|
});
|
|
1130
1412
|
reportPath = report.filePath;
|
|
1131
1413
|
reportId = report.id;
|
|
@@ -1145,6 +1427,7 @@ export async function replayAppiumScript(
|
|
|
1145
1427
|
reportId,
|
|
1146
1428
|
logPath,
|
|
1147
1429
|
htmlReportPath,
|
|
1430
|
+
softFailureCount: context.softFailureCount,
|
|
1148
1431
|
};
|
|
1149
1432
|
});
|
|
1150
1433
|
}
|