android-midscene-automation 0.1.30 → 0.1.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -26
- package/README.md +16 -26
- package/bin/sync-readme-docs.mjs +49 -0
- package/index.html +1 -1
- package/package.json +8 -8
- package/remote-agent/index.ts +1 -1
- package/server/appium-recorder/appium-runner.ts +97 -43
- package/server/appium-recorder/bounded-loop.ts +118 -0
- package/server/appium-recorder/history-capture.ts +34 -0
- package/server/appium-recorder/report.ts +1 -1
- package/server/appium-recorder/repository.ts +9 -0
- package/server/appium-recorder/routes.ts +29 -5
- package/server/appium-recorder/run-history.ts +34 -0
- package/src/appium-recorder/AppiumPage.vue +114 -54
- package/src/appium-recorder/RunHistoryDialog.vue +153 -0
- package/src/appium-recorder/bounded-loop.ts +66 -0
- package/src/appium-recorder/components/BreakLoopSettings.vue +29 -0
- package/src/appium-recorder/components/ComponentInspector.vue +26 -0
- package/src/appium-recorder/components/ComponentTree.vue +24 -4
- package/src/appium-recorder/components/FlowCanvas.vue +19 -1
- package/src/appium-recorder/components/FlowNodeCard.vue +6 -3
- package/src/appium-recorder/components/FlowNodeSearch.vue +62 -0
- package/src/appium-recorder/components/FlowStepEditor.vue +9 -4
- package/src/appium-recorder/components/LoopSettings.vue +37 -0
- package/src/appium-recorder/components/NestedConditionBranches.vue +11 -3
- package/src/appium-recorder/components/NodeDetail.vue +30 -6
- package/src/appium-recorder/components/RecordedSteps.vue +24 -4
- package/src/appium-recorder/components/RecorderWorkspace.vue +132 -0
- package/src/appium-recorder/components/VisualChangeEndPicker.vue +21 -0
- package/src/appium-recorder/flow-copy.ts +1 -0
- package/src/appium-recorder/flow-graph.ts +4 -9
- package/src/appium-recorder/flow-labels.ts +7 -0
- package/src/appium-recorder/flow-merge.ts +3 -2
- package/src/appium-recorder/flow-normalize.ts +1 -0
- package/src/appium-recorder/flow-search.ts +29 -0
- package/src/appium-recorder/node-locators.ts +25 -0
- package/src/appium-recorder/run-history.ts +50 -0
- package/src/appium-recorder/tree.ts +2 -5
- package/src/appium-recorder/types.ts +7 -0
- package/src/appium-recorder/visual-change-pairs.ts +16 -10
- package/src/components/device/DevicePreviewPanel.vue +62 -10
- package/src/style.css +24 -7
- package/src/ui-refresh.css +40 -0
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
|
+
import { captureHistoryFrames, readAppVersion } from './history-capture';
|
|
3
|
+
import { BoundedLoopTraversal } from './bounded-loop';
|
|
4
|
+
import { validateLoopSteps } from '../../src/appium-recorder/bounded-loop';
|
|
2
5
|
import { adbScreenshotBase64 } from './screenshot';
|
|
3
6
|
import { recognizeDeviceScreen } from './ai-recognition';
|
|
4
7
|
import { formatStageLog } from '../../src/appium-recorder/stage-log';
|
|
@@ -47,6 +50,8 @@ type PendingVisualChangeCheck = {
|
|
|
47
50
|
step: AppiumRecordedStepRecord;
|
|
48
51
|
nodeNumber: number;
|
|
49
52
|
scriptName: string;
|
|
53
|
+
frameStart: number;
|
|
54
|
+
endFrameStart: number;
|
|
50
55
|
};
|
|
51
56
|
|
|
52
57
|
const replayContext = new AsyncLocalStorage<{
|
|
@@ -55,8 +60,10 @@ const replayContext = new AsyncLocalStorage<{
|
|
|
55
60
|
serverUrl: string;
|
|
56
61
|
deviceId: string;
|
|
57
62
|
frames: AppiumReplayFrame[];
|
|
63
|
+
historyEvents: AppiumReplayFrame[];
|
|
58
64
|
visualChecks: AppiumReplayVisualCheck[];
|
|
59
65
|
pendingVisualChecks: PendingVisualChangeCheck[];
|
|
66
|
+
visualStartOffsets: Map<string, number>;
|
|
60
67
|
softFailureCount: number;
|
|
61
68
|
flowEnded?: boolean;
|
|
62
69
|
}>();
|
|
@@ -330,6 +337,10 @@ async function captureReplayFrame(
|
|
|
330
337
|
) {
|
|
331
338
|
const context = replayContext.getStore();
|
|
332
339
|
if (!context || !context.deviceId) return;
|
|
340
|
+
// 执行记录独立于截图,设备截图失败不能让失败节点从统计中消失。
|
|
341
|
+
context.historyEvents.push({ sequence: context.historyEvents.length + 1, scriptName, nodeId: step.id,
|
|
342
|
+
nodeNumber, nodeLabel: step.label, nodeType: step.type, note: step.note || '',
|
|
343
|
+
selector: replayFrameSelector(step), phase, status, capturedAt: new Date().toISOString(), imageBase64: '' });
|
|
333
344
|
const appendFrame = (imageBase64: string) => {
|
|
334
345
|
context.frames.push({
|
|
335
346
|
sequence: context.frames.length + 1,
|
|
@@ -517,6 +528,7 @@ async function saveScreenshot(deviceId: string) {
|
|
|
517
528
|
type RunStepMeta = {
|
|
518
529
|
nodeNumber: number;
|
|
519
530
|
scriptName: string;
|
|
531
|
+
frameStart?: number;
|
|
520
532
|
};
|
|
521
533
|
|
|
522
534
|
type RunStepResult = string | {
|
|
@@ -558,10 +570,9 @@ function findVisualFrame(
|
|
|
558
570
|
) {
|
|
559
571
|
const matches = frames.filter((frame) => frame.nodeId === nodeId && frame.scriptName === scriptName);
|
|
560
572
|
if (!matches.length) return undefined;
|
|
561
|
-
const
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
return preferred || (preferredPhase === 'before' ? matches[0] : matches[matches.length - 1]);
|
|
573
|
+
const frame = preferredPhase === 'before' ? matches[0] : matches[matches.length - 1];
|
|
574
|
+
// A failed capture must not fall back to an older round or a before/error image.
|
|
575
|
+
return frame?.phase === preferredPhase ? frame : undefined;
|
|
565
576
|
}
|
|
566
577
|
|
|
567
578
|
const EMPTY_PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=';
|
|
@@ -572,12 +583,14 @@ function registerVisualChangeStep(
|
|
|
572
583
|
): RunStepResult {
|
|
573
584
|
const config = step.visualChange;
|
|
574
585
|
if (!config) throw new Error(`${step.label} 缺少画面变化检测配置`);
|
|
586
|
+
const context = replayContext.getStore();
|
|
575
587
|
if (config.role === 'start') {
|
|
588
|
+
// Invalidate the previous capture even when this start's after screenshot fails.
|
|
589
|
+
context?.visualStartOffsets.set(JSON.stringify([meta.frameStart || 0, step.id]), context.frames.length);
|
|
576
590
|
return {
|
|
577
591
|
message: `已记录画面变化基准点:${config.pairLabel || step.label}`,
|
|
578
592
|
};
|
|
579
593
|
}
|
|
580
|
-
const context = replayContext.getStore();
|
|
581
594
|
const endStep = {
|
|
582
595
|
...step,
|
|
583
596
|
visualChange: {
|
|
@@ -585,7 +598,9 @@ function registerVisualChangeStep(
|
|
|
585
598
|
endStepId: config.endStepId || step.id,
|
|
586
599
|
},
|
|
587
600
|
};
|
|
588
|
-
context?.pendingVisualChecks.push({ step: endStep, nodeNumber: meta.nodeNumber, scriptName: meta.scriptName
|
|
601
|
+
context?.pendingVisualChecks.push({ step: endStep, nodeNumber: meta.nodeNumber, scriptName: meta.scriptName,
|
|
602
|
+
frameStart: context.visualStartOffsets.get(JSON.stringify([meta.frameStart || 0, config.startStepId])) ?? meta.frameStart ?? 0,
|
|
603
|
+
endFrameStart: context.frames.length });
|
|
589
604
|
return {
|
|
590
605
|
message: `已记录画面变化对比点:${config.pairLabel || step.label}`,
|
|
591
606
|
};
|
|
@@ -622,16 +637,16 @@ function processPendingVisualChecks(lines: string[]) {
|
|
|
622
637
|
const context = replayContext.getStore();
|
|
623
638
|
if (!context || !context.pendingVisualChecks.length) return;
|
|
624
639
|
|
|
625
|
-
//
|
|
626
|
-
//
|
|
640
|
+
// Resolve each pair immediately after its end capture, before later rounds
|
|
641
|
+
// or linked-script calls can replace the images associated with these IDs.
|
|
627
642
|
context.pendingVisualChecks.forEach((check) => {
|
|
628
643
|
const config = check.step.visualChange;
|
|
629
644
|
if (!config?.startStepId || !config.endStepId) {
|
|
630
645
|
appendVisualCheckFailure(lines, check, `${check.step.label} 缺少开始节点或结束节点`);
|
|
631
646
|
return;
|
|
632
647
|
}
|
|
633
|
-
const baselineFrame = findVisualFrame(context.frames, config.startStepId, check.scriptName, 'after');
|
|
634
|
-
const comparisonFrame = findVisualFrame(context.frames, config.endStepId, check.scriptName, 'after');
|
|
648
|
+
const baselineFrame = findVisualFrame(context.frames.slice(check.frameStart, check.endFrameStart), config.startStepId, check.scriptName, 'after');
|
|
649
|
+
const comparisonFrame = findVisualFrame(context.frames.slice(check.endFrameStart), config.endStepId, check.scriptName, 'after');
|
|
635
650
|
if (!baselineFrame || !comparisonFrame) {
|
|
636
651
|
const missing = [
|
|
637
652
|
baselineFrame ? '' : '开始节点截图',
|
|
@@ -916,6 +931,15 @@ async function readNativeControlState(sessionId: string, step: AppiumRecordedSte
|
|
|
916
931
|
}
|
|
917
932
|
|
|
918
933
|
async function evaluateCondition(sessionId: string, deviceId: string, step: AppiumRecordedStepRecord) {
|
|
934
|
+
if (step.type === 'loop') {
|
|
935
|
+
if (step.loop?.exitWhen === 'never') return false;
|
|
936
|
+
const selector = toAppiumUsing(step.selector!);
|
|
937
|
+
const payload = await appiumRequest<AppiumValueResponse<Record<string, string>[]>>(`/session/${sessionId}/elements`, {
|
|
938
|
+
method: 'POST', body: JSON.stringify(selector),
|
|
939
|
+
});
|
|
940
|
+
if (!Array.isArray(payload.value)) throw new Error('循环退出条件查询返回了无效结果');
|
|
941
|
+
return step.loop?.exitWhen === 'exists' ? payload.value.length > 0 : payload.value.length === 0;
|
|
942
|
+
}
|
|
919
943
|
if (step.type === 'textClick') {
|
|
920
944
|
const selector = textClickSelector(step);
|
|
921
945
|
const deadline = Date.now() + Math.max(0, step.timeoutMs ?? 10000);
|
|
@@ -994,12 +1018,17 @@ async function appendStepDiagnostics(lines: string[], deviceId: string, step: Ap
|
|
|
994
1018
|
}
|
|
995
1019
|
|
|
996
1020
|
type ReplayStepOptions = {
|
|
1021
|
+
frameStart?: number;
|
|
997
1022
|
skipAppInitialization?: boolean;
|
|
998
1023
|
scriptName?: string;
|
|
999
1024
|
};
|
|
1000
1025
|
|
|
1001
|
-
function shouldSkipLinkedScriptInitStep(options: ReplayStepOptions, step: AppiumRecordedStepRecord) {
|
|
1002
|
-
|
|
1026
|
+
function shouldSkipLinkedScriptInitStep(options: ReplayStepOptions, step: AppiumRecordedStepRecord, steps: AppiumRecordedStepRecord[]) {
|
|
1027
|
+
if (!options.skipAppInitialization) return false;
|
|
1028
|
+
// Only the leading initialization sequence is redundant in a linked script.
|
|
1029
|
+
// Launches inside branches or after other operations are intentional actions.
|
|
1030
|
+
return steps.slice(0, steps.indexOf(step) + 1).every(item =>
|
|
1031
|
+
!item.flow?.parentConditionId && (item.type === 'launchApp' || item.type === 'clearAppData'));
|
|
1003
1032
|
}
|
|
1004
1033
|
|
|
1005
1034
|
function linkedScriptInitSkipReason(step: AppiumRecordedStepRecord) {
|
|
@@ -1079,7 +1108,7 @@ async function replayLinearSteps(
|
|
|
1079
1108
|
for (const [index, step] of steps.entries()) {
|
|
1080
1109
|
if (replayContext.getStore()?.flowEnded) return;
|
|
1081
1110
|
throwIfReplayStopped();
|
|
1082
|
-
if (shouldSkipLinkedScriptInitStep(options, step)) {
|
|
1111
|
+
if (shouldSkipLinkedScriptInitStep(options, step, steps)) {
|
|
1083
1112
|
lines.push(`[节点 ${index + 1}] 跳过:${step.label}(${linkedScriptInitSkipReason(step)})`);
|
|
1084
1113
|
continue;
|
|
1085
1114
|
}
|
|
@@ -1093,11 +1122,13 @@ async function replayLinearSteps(
|
|
|
1093
1122
|
const result = await runStep(sessionId, deviceId, step, {
|
|
1094
1123
|
nodeNumber: index + 1,
|
|
1095
1124
|
scriptName: options.scriptName || '',
|
|
1125
|
+
frameStart: options.frameStart,
|
|
1096
1126
|
});
|
|
1097
1127
|
softFailed = appendRunStepResult(lines, index + 1, result);
|
|
1098
1128
|
}
|
|
1099
1129
|
lines.push(`[节点 ${index + 1}] 完成:${step.label}${softFailed ? '(存在软失败,已继续)' : ''}`);
|
|
1100
1130
|
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'after', completedStepStatus(step, softFailed));
|
|
1131
|
+
processPendingVisualChecks(lines);
|
|
1101
1132
|
} catch (error) {
|
|
1102
1133
|
if (isReplayStopped(error)) {
|
|
1103
1134
|
lines.push(`[节点 ${index + 1}] 已终止:${step.label}`);
|
|
@@ -1121,47 +1152,47 @@ async function replayFlowSteps(
|
|
|
1121
1152
|
options: ReplayStepOptions = {},
|
|
1122
1153
|
) {
|
|
1123
1154
|
const idToIndex = new Map(steps.map((step, index) => [step.id, index]));
|
|
1124
|
-
const
|
|
1125
|
-
|
|
1126
|
-
if (conditionIndex === undefined) return undefined;
|
|
1127
|
-
const explicitTargetId = steps[conditionIndex]?.flow?.successTargetId;
|
|
1128
|
-
return explicitTargetId ? idToIndex.get(explicitTargetId) : undefined;
|
|
1129
|
-
};
|
|
1130
|
-
const nextIndexAfterStep = (currentIndex: number, step: AppiumRecordedStepRecord) => {
|
|
1131
|
-
const explicitTargetId = step.flow?.successTargetId;
|
|
1132
|
-
if (explicitTargetId) return idToIndex.get(explicitTargetId);
|
|
1133
|
-
if (!step.flow?.parentConditionId || !step.flow.parentBranch) return currentIndex + 1;
|
|
1134
|
-
const nextStep = steps[currentIndex + 1];
|
|
1135
|
-
const staysInBranch = nextStep?.flow?.parentConditionId === step.flow.parentConditionId
|
|
1136
|
-
&& nextStep.flow.parentBranch === step.flow.parentBranch;
|
|
1137
|
-
return staysInBranch
|
|
1138
|
-
? currentIndex + 1
|
|
1139
|
-
: conditionContinuationIndex(step.flow.parentConditionId);
|
|
1140
|
-
};
|
|
1155
|
+
const traversal = new BoundedLoopTraversal(steps);
|
|
1156
|
+
const nextIndexAfterStep = (index: number, step: AppiumRecordedStepRecord) => traversal.next(step, index);
|
|
1141
1157
|
const visitedPath: string[] = [];
|
|
1142
1158
|
let index: number | undefined = 0;
|
|
1143
|
-
let guard = 0;
|
|
1144
|
-
const maxVisits = Math.max(steps.length * 4, 20);
|
|
1145
1159
|
|
|
1146
1160
|
while (typeof index === 'number' && index >= 0 && index < steps.length) {
|
|
1147
1161
|
if (replayContext.getStore()?.flowEnded) return;
|
|
1148
1162
|
throwIfReplayStopped();
|
|
1149
|
-
guard += 1;
|
|
1150
|
-
if (guard > maxVisits) throw new Error('流程图可能存在循环,已终止回放');
|
|
1151
|
-
|
|
1152
1163
|
const step = steps[index];
|
|
1153
|
-
|
|
1164
|
+
traversal.visit(step);
|
|
1165
|
+
const loopContext = traversal.context(step);
|
|
1166
|
+
const frameStep = loopContext ? { ...step, label: `${step.label}(${loopContext})` } : step;
|
|
1167
|
+
if (shouldSkipLinkedScriptInitStep(options, step, steps)) {
|
|
1154
1168
|
lines.push(`[节点 ${index + 1}] 跳过:${step.label}(${linkedScriptInitSkipReason(step)})`);
|
|
1155
1169
|
index = nextIndexAfterStep(index, step);
|
|
1156
1170
|
continue;
|
|
1157
1171
|
}
|
|
1158
1172
|
visitedPath.push(step.label);
|
|
1159
1173
|
const nodeKind = defaultFlowKind(step);
|
|
1160
|
-
lines.push(`[节点 ${index + 1}] 开始:${
|
|
1161
|
-
await captureReplayFrame(sessionId,
|
|
1174
|
+
lines.push(`[节点 ${index + 1}] 开始:${frameStep.label}`);
|
|
1175
|
+
await captureReplayFrame(sessionId, frameStep, index + 1, options.scriptName || '', 'before', '执行前');
|
|
1162
1176
|
|
|
1163
1177
|
if (nodeKind === 'condition') {
|
|
1164
1178
|
try {
|
|
1179
|
+
if (step.type === 'loop') {
|
|
1180
|
+
const iterations = traversal.iteration(step);
|
|
1181
|
+
const atLimit = iterations >= step.loop!.maxIterations;
|
|
1182
|
+
const exitMatched = !atLimit && await evaluateCondition(sessionId, deviceId, step);
|
|
1183
|
+
if (atLimit || exitMatched) {
|
|
1184
|
+
const reason = atLimit ? '达到最大次数' : '满足退出条件';
|
|
1185
|
+
lines.push(`[循环 ${step.label}] 结束:${reason},已执行 ${iterations} 轮`);
|
|
1186
|
+
await captureReplayFrame(sessionId, frameStep, index + 1, options.scriptName || '', 'after', `${reason},已执行 ${iterations} 轮`);
|
|
1187
|
+
index = traversal.exit(step);
|
|
1188
|
+
} else {
|
|
1189
|
+
const entered = traversal.enter(step);
|
|
1190
|
+
lines.push(`[循环 ${step.label}] 第 ${entered.iteration}/${step.loop!.maxIterations} 轮开始`);
|
|
1191
|
+
await captureReplayFrame(sessionId, frameStep, index + 1, options.scriptName || '', 'after', `第 ${entered.iteration}/${step.loop!.maxIterations} 轮`);
|
|
1192
|
+
index = entered.next;
|
|
1193
|
+
}
|
|
1194
|
+
continue;
|
|
1195
|
+
}
|
|
1165
1196
|
// AI 节点使用本次执行前截图,并将同一张图片和识别依据写入结果帧。
|
|
1166
1197
|
const beforeFrame = replayContext.getStore()?.frames.at(-1);
|
|
1167
1198
|
const recognition = step.type === 'aiRecognition' ? await recognizeDeviceScreen({
|
|
@@ -1172,19 +1203,20 @@ async function replayFlowSteps(
|
|
|
1172
1203
|
const matched = recognition ? recognition.result : await evaluateCondition(sessionId, deviceId, step);
|
|
1173
1204
|
if (recognition) lines.push(`[节点 ${index + 1}] AI 识别:${recognition.result},耗时 ${recognition.durationMs}ms${recognition.reason ? `,依据:${recognition.reason}` : ''}`);
|
|
1174
1205
|
const branchTargetId = matched ? step.flow?.yesTargetId : step.flow?.noTargetId;
|
|
1175
|
-
const
|
|
1206
|
+
const next = traversal.branch(step, matched);
|
|
1207
|
+
const targetId = branchTargetId || (next === undefined ? '' : steps[next]?.id) || '';
|
|
1176
1208
|
const branchLabel = flowBranchLabel(step, matched ? 'yes' : 'no');
|
|
1177
1209
|
lines.push(`[节点 ${index + 1}] 判断:${branchLabel}${isNativeStateCondition(step) ? `(checked=${matched})` : ''}${targetId ? `,进入 ${targetId}` : ',流程结束'}`);
|
|
1178
1210
|
await captureReplayFrame(
|
|
1179
1211
|
sessionId,
|
|
1180
|
-
|
|
1212
|
+
frameStep,
|
|
1181
1213
|
index + 1,
|
|
1182
1214
|
options.scriptName || '',
|
|
1183
1215
|
'after',
|
|
1184
1216
|
`判断:${branchLabel}${recognition?.reason ? `;${recognition.reason}` : ''}`,
|
|
1185
1217
|
recognition?.imageBase64,
|
|
1186
1218
|
);
|
|
1187
|
-
index =
|
|
1219
|
+
index = next;
|
|
1188
1220
|
} catch (error) {
|
|
1189
1221
|
if (isReplayStopped(error)) {
|
|
1190
1222
|
lines.push(`[节点 ${index + 1}] 已终止:${step.label}`);
|
|
@@ -1201,17 +1233,25 @@ async function replayFlowSteps(
|
|
|
1201
1233
|
|
|
1202
1234
|
try {
|
|
1203
1235
|
let softFailed = false;
|
|
1204
|
-
if (step.type === '
|
|
1236
|
+
if (step.type === 'breakLoop') {
|
|
1237
|
+
const exited = traversal.break(step);
|
|
1238
|
+
lines.push(`[循环 ${exited.loop.label}] 第 ${exited.iteration} 轮主动退出`);
|
|
1239
|
+
await captureReplayFrame(sessionId, frameStep, index + 1, options.scriptName || '', 'after', `已退出循环:${exited.loop.label}`);
|
|
1240
|
+
index = exited.next;
|
|
1241
|
+
continue;
|
|
1242
|
+
} else if (step.type === 'runScript') {
|
|
1205
1243
|
await replayLinkedScript(sessionId, deviceId, step, lines, stack);
|
|
1206
1244
|
} else {
|
|
1207
1245
|
const result = await runStep(sessionId, deviceId, step, {
|
|
1208
1246
|
nodeNumber: index + 1,
|
|
1209
1247
|
scriptName: options.scriptName || '',
|
|
1248
|
+
frameStart: options.frameStart,
|
|
1210
1249
|
});
|
|
1211
1250
|
softFailed = appendRunStepResult(lines, index + 1, result);
|
|
1212
1251
|
}
|
|
1213
1252
|
lines.push(`[节点 ${index + 1}] 完成:${step.label}${softFailed ? '(存在软失败,已继续)' : ''}`);
|
|
1214
|
-
await captureReplayFrame(sessionId,
|
|
1253
|
+
await captureReplayFrame(sessionId, frameStep, index + 1, options.scriptName || '', 'after', completedStepStatus(step, softFailed));
|
|
1254
|
+
processPendingVisualChecks(lines);
|
|
1215
1255
|
index = nextIndexAfterStep(index, step);
|
|
1216
1256
|
} catch (error) {
|
|
1217
1257
|
if (isReplayStopped(error)) {
|
|
@@ -1259,6 +1299,8 @@ async function replayScriptSteps(
|
|
|
1259
1299
|
stack: string[],
|
|
1260
1300
|
options: ReplayStepOptions = {},
|
|
1261
1301
|
) {
|
|
1302
|
+
validateLoopSteps(steps);
|
|
1303
|
+
options = { ...options, frameStart: replayContext.getStore()?.frames.length || 0 };
|
|
1262
1304
|
let trailingLinkIndex = steps.length;
|
|
1263
1305
|
while (
|
|
1264
1306
|
trailingLinkIndex > 0
|
|
@@ -1306,6 +1348,7 @@ export async function replayAppiumScript(
|
|
|
1306
1348
|
if (!script.steps.length) throw new Error('脚本没有可回放步骤');
|
|
1307
1349
|
|
|
1308
1350
|
const startedAt = new Date();
|
|
1351
|
+
const appVersion = await readAppVersion(targetDeviceId, script.appPackage).catch(() => '');
|
|
1309
1352
|
const lines: string[] = [];
|
|
1310
1353
|
const pushLine = lines.push.bind(lines);
|
|
1311
1354
|
lines.push = (...items: string[]) => {
|
|
@@ -1319,7 +1362,9 @@ export async function replayAppiumScript(
|
|
|
1319
1362
|
serverUrl: configuredAppiumServerUrl(),
|
|
1320
1363
|
deviceId: targetDeviceId,
|
|
1321
1364
|
frames: [] as AppiumReplayFrame[],
|
|
1365
|
+
historyEvents: [] as AppiumReplayFrame[],
|
|
1322
1366
|
visualChecks: [] as AppiumReplayVisualCheck[],
|
|
1367
|
+
visualStartOffsets: new Map<string, number>(),
|
|
1323
1368
|
pendingVisualChecks: [] as PendingVisualChangeCheck[],
|
|
1324
1369
|
softFailureCount: 0,
|
|
1325
1370
|
};
|
|
@@ -1334,6 +1379,7 @@ export async function replayAppiumScript(
|
|
|
1334
1379
|
let stopped = false;
|
|
1335
1380
|
let managedAppium: Awaited<ReturnType<typeof startManagedAppiumServer>> | null = null;
|
|
1336
1381
|
try {
|
|
1382
|
+
validateLoopSteps(script.steps);
|
|
1337
1383
|
lines.push('正在检测 Android SDK...');
|
|
1338
1384
|
const androidSdk = ensureAndroidSdkAvailable();
|
|
1339
1385
|
lines.push(`Android SDK 已就绪:${androidSdk.root}`);
|
|
@@ -1441,6 +1487,14 @@ export async function replayAppiumScript(
|
|
|
1441
1487
|
logPath,
|
|
1442
1488
|
htmlReportPath,
|
|
1443
1489
|
softFailureCount: context.softFailureCount,
|
|
1490
|
+
history: {
|
|
1491
|
+
scriptId: script.id, scriptName: script.name, appPackage: script.appPackage,
|
|
1492
|
+
appVersion, deviceId: targetDeviceId, startedAt: startedAt.toISOString(),
|
|
1493
|
+
durationMs: completedAt.getTime() - startedAt.getTime(),
|
|
1494
|
+
status: stopped ? 'stopped' as const : success ? 'passed' as const : 'failed' as const,
|
|
1495
|
+
output: lines.join('\n'),
|
|
1496
|
+
...captureHistoryFrames(context.frames, context.visualChecks, context.historyEvents),
|
|
1497
|
+
},
|
|
1444
1498
|
};
|
|
1445
1499
|
});
|
|
1446
1500
|
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { AppiumRecordedStep } from '../../src/appium-recorder/types';
|
|
2
|
+
import { enclosingLoop, enclosingLoops, breakLoopTarget, validateLoopSteps } from '../../src/appium-recorder/bounded-loop';
|
|
3
|
+
import { defaultFlowKind } from '../../src/appium-recorder/flow-labels';
|
|
4
|
+
|
|
5
|
+
/** Virtual returns keep the saved flow graph acyclic and preserve branch layout. */
|
|
6
|
+
export class BoundedLoopTraversal {
|
|
7
|
+
private readonly byId: Map<string, number>;
|
|
8
|
+
private readonly active = new Map<string, { iteration: number; visited: Set<string> }>();
|
|
9
|
+
private readonly rootVisited = new Set<string>();
|
|
10
|
+
private readonly owners = new Map<string, AppiumRecordedStep | undefined>();
|
|
11
|
+
|
|
12
|
+
constructor(private readonly steps: AppiumRecordedStep[]) {
|
|
13
|
+
validateLoopSteps(steps);
|
|
14
|
+
this.byId = new Map(steps.map((step, index) => [step.id, index]));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
private target(id?: string) { return id ? this.byId.get(id) : undefined; }
|
|
18
|
+
private owner(step: AppiumRecordedStep) {
|
|
19
|
+
if (!this.owners.has(step.id)) this.owners.set(step.id, enclosingLoop(this.steps, step));
|
|
20
|
+
return this.owners.get(step.id);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
visit(step: AppiumRecordedStep) {
|
|
24
|
+
if (step.type === 'loop' && this.active.has(step.id)) return;
|
|
25
|
+
const owner = this.owner(step);
|
|
26
|
+
const frame = owner ? this.active.get(owner.id) : undefined;
|
|
27
|
+
if (owner && !frame) throw new Error('不能从循环外直接进入循环体');
|
|
28
|
+
const visited = frame?.visited || this.rootVisited;
|
|
29
|
+
if (visited.has(step.id)) throw new Error('流程存在非有界循环回边,已终止回放');
|
|
30
|
+
visited.add(step.id);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private continuation(step: AppiumRecordedStep, seen = new Set<string>()): number | undefined {
|
|
34
|
+
if (seen.has(step.id)) throw new Error('流程分支归属存在循环');
|
|
35
|
+
seen.add(step.id);
|
|
36
|
+
if (step.flow?.successTargetId) return this.target(step.flow.successTargetId);
|
|
37
|
+
if (defaultFlowKind(step) === 'condition') {
|
|
38
|
+
const start = this.byId.get(step.id)!;
|
|
39
|
+
const sibling = this.steps.findIndex((item, index) => index > start && item.flow?.parentConditionId === step.flow?.parentConditionId && item.flow?.parentBranch === step.flow?.parentBranch);
|
|
40
|
+
if (sibling >= 0) return sibling;
|
|
41
|
+
}
|
|
42
|
+
const parentIndex = this.target(step.flow?.parentConditionId);
|
|
43
|
+
if (parentIndex === undefined) return;
|
|
44
|
+
const parent = this.steps[parentIndex]!;
|
|
45
|
+
if (parent.type === 'loop' && step.flow?.parentBranch === 'yes') return parentIndex;
|
|
46
|
+
return this.continuation(parent, seen);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private returnAtBoundary(step: AppiumRecordedStep, next: number | undefined) {
|
|
50
|
+
const owner = this.owner(step);
|
|
51
|
+
if (!owner || !this.active.has(owner.id)) return next;
|
|
52
|
+
// The end of any nested branch returns to the loop, not to the other branch.
|
|
53
|
+
if (next === undefined || (this.steps[next]?.id !== owner.id && !this.isInBody(this.steps[next]!, owner.id))) {
|
|
54
|
+
return this.target(owner.id);
|
|
55
|
+
}
|
|
56
|
+
return next;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private isInBody(step: AppiumRecordedStep, loopId: string) {
|
|
60
|
+
let owner = this.owner(step);
|
|
61
|
+
const seen = new Set<string>();
|
|
62
|
+
while (owner && !seen.has(owner.id)) {
|
|
63
|
+
if (owner.id === loopId) return true;
|
|
64
|
+
seen.add(owner.id); owner = this.owner(owner);
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
next(step: AppiumRecordedStep, index: number) {
|
|
70
|
+
let next = this.target(step.flow?.successTargetId);
|
|
71
|
+
if (!step.flow?.successTargetId) {
|
|
72
|
+
const sibling = this.steps[index + 1];
|
|
73
|
+
next = sibling && sibling.flow?.parentConditionId === step.flow?.parentConditionId && sibling.flow?.parentBranch === step.flow?.parentBranch
|
|
74
|
+
? index + 1 : this.continuation(step);
|
|
75
|
+
}
|
|
76
|
+
return this.returnAtBoundary(step, next);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
branch(step: AppiumRecordedStep, matched: boolean) {
|
|
80
|
+
const id = matched ? step.flow?.yesTargetId : step.flow?.noTargetId;
|
|
81
|
+
return this.returnAtBoundary(step, id ? this.target(id) : this.continuation(step));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
iteration(step: AppiumRecordedStep) { return this.active.get(step.id)?.iteration || 0; }
|
|
85
|
+
|
|
86
|
+
enter(step: AppiumRecordedStep) {
|
|
87
|
+
const iteration = this.iteration(step) + 1;
|
|
88
|
+
if (iteration > step.loop!.maxIterations) throw new Error('循环次数超过上限');
|
|
89
|
+
this.active.set(step.id, { iteration, visited: new Set() });
|
|
90
|
+
return { iteration, next: this.target(step.flow?.yesTargetId) ?? this.target(step.id) };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
exit(step: AppiumRecordedStep) {
|
|
94
|
+
this.active.delete(step.id);
|
|
95
|
+
return this.returnAtBoundary(step, this.target(step.flow?.noTargetId) ?? this.continuation(step));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
break(step: AppiumRecordedStep) {
|
|
99
|
+
const owner = breakLoopTarget(this.steps, step);
|
|
100
|
+
if (!this.active.has(owner.id)) throw new Error('目标循环当前未执行');
|
|
101
|
+
// Unwind inner frames without running their completion branches.
|
|
102
|
+
for (const inner of enclosingLoops(this.steps, step)) {
|
|
103
|
+
if (inner.id === owner.id) break;
|
|
104
|
+
this.active.delete(inner.id);
|
|
105
|
+
}
|
|
106
|
+
return { loop: owner, iteration: this.iteration(owner), next: this.exit(owner) };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
context(step: AppiumRecordedStep) {
|
|
110
|
+
const parts: string[] = [];
|
|
111
|
+
let owner = this.owner(step);
|
|
112
|
+
while (owner) {
|
|
113
|
+
parts.unshift(`${owner.label} 第 ${this.iteration(owner)}/${owner.loop!.maxIterations} 轮`);
|
|
114
|
+
owner = this.owner(owner);
|
|
115
|
+
}
|
|
116
|
+
return parts.join(' / ');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { getAdbCommand } from '../android-sdk';
|
|
3
|
+
import { htmlImageUrl, type AppiumReplayFrame, type AppiumReplayVisualCheck } from './report';
|
|
4
|
+
import type { HistoryNode } from '../../src/appium-recorder/run-history';
|
|
5
|
+
|
|
6
|
+
export function readAppVersion(deviceId: string, appPackage: string): Promise<string> {
|
|
7
|
+
if (!appPackage) return Promise.resolve('');
|
|
8
|
+
return new Promise(resolve => {
|
|
9
|
+
execFile(getAdbCommand(), ['-s', deviceId, 'shell', 'dumpsys', 'package', appPackage], { timeout: 5000, maxBuffer: 4 * 1024 * 1024 }, (error, stdout) => {
|
|
10
|
+
if (error) return resolve('');
|
|
11
|
+
const name = stdout.match(/\bversionName=([^\r\n]+)/)?.[1]?.trim();
|
|
12
|
+
const code = stdout.match(/\bversionCode=(\d+)/)?.[1];
|
|
13
|
+
resolve(name ? `${name}${code ? ` (${code})` : ''}` : '');
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function captureHistoryFrames(frames: AppiumReplayFrame[], checks: AppiumReplayVisualCheck[], events = frames) {
|
|
19
|
+
const cache = new Map<string, string>();
|
|
20
|
+
const nodes: HistoryNode[] = [];
|
|
21
|
+
const pending = new Map<string, AppiumReplayFrame>();
|
|
22
|
+
for (const frame of events) {
|
|
23
|
+
const key = frame.nodeId;
|
|
24
|
+
if (frame.phase === 'before') { pending.set(key, frame); continue; }
|
|
25
|
+
const start = pending.get(key);
|
|
26
|
+
pending.delete(key);
|
|
27
|
+
if (frame.phase !== 'stopped') nodes.push({ key, label: `${frame.scriptName} / ${frame.nodeLabel}`, failed: frame.phase === 'error', durationMs: start ? Math.max(0, Date.parse(frame.capturedAt) - Date.parse(start.capturedAt)) : null });
|
|
28
|
+
}
|
|
29
|
+
for (const check of checks.filter(check => check.status === 'failed')) {
|
|
30
|
+
const node = nodes.find(node => node.key === check.nodeId);
|
|
31
|
+
if (node) node.failed = true;
|
|
32
|
+
}
|
|
33
|
+
return { nodes, frames: frames.map(frame => ({ key: frame.nodeId, label: frame.nodeLabel, phase: frame.phase, capturedAt: frame.capturedAt, imageUrl: htmlImageUrl(frame.imageBase64, cache) })) };
|
|
34
|
+
}
|
|
@@ -126,7 +126,7 @@ function compressHtmlPngBase64(base64: string) {
|
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
function htmlImageUrl(base64: string, cache: Map<string, string>) {
|
|
129
|
+
export function htmlImageUrl(base64: string, cache: Map<string, string>) {
|
|
130
130
|
const cached = cache.get(base64);
|
|
131
131
|
if (cached) return cached;
|
|
132
132
|
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
sqlString,
|
|
8
8
|
} from '../storage/sqlite';
|
|
9
9
|
import { normalizeLegacyNestedConditionBranches } from '../../src/appium-recorder/flow-normalize';
|
|
10
|
+
import { validateLoopSteps } from '../../src/appium-recorder/bounded-loop';
|
|
10
11
|
|
|
11
12
|
export type AppiumRecordedStepRecord = {
|
|
12
13
|
id: string;
|
|
@@ -34,6 +35,8 @@ export type AppiumRecordedStepRecord = {
|
|
|
34
35
|
| 'launchApp'
|
|
35
36
|
| 'openGallery'
|
|
36
37
|
| 'endFlow'
|
|
38
|
+
| 'loop'
|
|
39
|
+
| 'breakLoop'
|
|
37
40
|
| 'clearAppData'
|
|
38
41
|
| 'waitDisappear'
|
|
39
42
|
| 'assertText'
|
|
@@ -86,6 +89,11 @@ export type AppiumRecordedStepRecord = {
|
|
|
86
89
|
keyCode?: number;
|
|
87
90
|
timeoutMs?: number;
|
|
88
91
|
longPressMode?: 'element' | 'coordinates';
|
|
92
|
+
breakLoopTargetId?: string;
|
|
93
|
+
loop?: {
|
|
94
|
+
maxIterations: number;
|
|
95
|
+
exitWhen: 'never' | 'exists' | 'notExists';
|
|
96
|
+
};
|
|
89
97
|
flow?: {
|
|
90
98
|
nodeKind?: 'action' | 'condition' | 'assertion';
|
|
91
99
|
yesTargetId?: string;
|
|
@@ -338,6 +346,7 @@ export function saveAppiumRecordedScript(input: {
|
|
|
338
346
|
const name = input.name.trim();
|
|
339
347
|
const appPackage = input.appPackage.trim();
|
|
340
348
|
const steps = normalizeLegacyNestedConditionBranches(input.steps || []);
|
|
349
|
+
validateLoopSteps(steps);
|
|
341
350
|
const appActivity = normalizeScriptActivity(appPackage, input.appActivity, steps);
|
|
342
351
|
|
|
343
352
|
if (!name) throw new Error('脚本名称不能为空');
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
|
+
import { deleteRunHistory, getRunHistory, listRunHistory, saveRunHistory } from './run-history';
|
|
2
3
|
import { recognizeDeviceScreen } from './ai-recognition';
|
|
3
4
|
import { readFreshWindowHierarchy } from './tree-dump';
|
|
4
5
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
@@ -307,6 +308,21 @@ export async function handleAppiumRecorderRequest(
|
|
|
307
308
|
return true;
|
|
308
309
|
}
|
|
309
310
|
|
|
311
|
+
const historyMatch = pathname.match(/^\/api\/appium-recorder\/scripts\/([^/]+)\/history(?:\/([^/]+))?$/);
|
|
312
|
+
if (historyMatch) {
|
|
313
|
+
const scriptId = decodeURIComponent(historyMatch[1]);
|
|
314
|
+
const id = historyMatch[2] ? decodeURIComponent(historyMatch[2]) : '';
|
|
315
|
+
if (req.method === 'GET') {
|
|
316
|
+
const data = id ? getRunHistory(scriptId, id) : listRunHistory(scriptId);
|
|
317
|
+
sendJson(res, data, data ? 200 : 404);
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
if (req.method === 'DELETE' && id) {
|
|
321
|
+
deleteRunHistory(scriptId, id);
|
|
322
|
+
sendJson(res, { success: true });
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
310
326
|
const replayMatch = pathname.match(/^\/api\/appium-recorder\/scripts\/([^/]+)\/replay$/);
|
|
311
327
|
if (replayMatch && req.method === 'POST') {
|
|
312
328
|
const parsed = await readBody<{ deviceId?: string }>(req);
|
|
@@ -323,13 +339,18 @@ export async function handleAppiumRecorderRequest(
|
|
|
323
339
|
res.flushHeaders();
|
|
324
340
|
}
|
|
325
341
|
if (isRemoteDeviceId(deviceId)) {
|
|
326
|
-
const result = await sendRemoteCommand(deviceId, 'replay', { script }) as
|
|
342
|
+
const result = await sendRemoteCommand(deviceId, 'replay', { script }) as Awaited<ReturnType<typeof replayAppiumScript>>;
|
|
343
|
+
if (result.history) {
|
|
344
|
+
try { saveRunHistory({ ...result.history, scriptId: script.id, deviceId }); }
|
|
345
|
+
catch { result.output += '\n历史记录保存失败'; }
|
|
346
|
+
}
|
|
347
|
+
const { history: _history, ...response } = result;
|
|
327
348
|
if (streamOutput) {
|
|
328
349
|
result.output?.split(/\r?\n/).forEach((line) => sendStreamEvent(res, { type: 'log', line }));
|
|
329
|
-
sendStreamEvent(res, { type: 'result', ...
|
|
350
|
+
sendStreamEvent(res, { type: 'result', ...response });
|
|
330
351
|
res.end();
|
|
331
352
|
} else {
|
|
332
|
-
sendJson(res,
|
|
353
|
+
sendJson(res, response, result.success ? 200 : 500);
|
|
333
354
|
}
|
|
334
355
|
return true;
|
|
335
356
|
}
|
|
@@ -346,11 +367,14 @@ export async function handleAppiumRecorderRequest(
|
|
|
346
367
|
streamOutput ? (line) => sendStreamEvent(res, { type: 'log', line }) : undefined,
|
|
347
368
|
replayAbortController.signal,
|
|
348
369
|
);
|
|
370
|
+
try { saveRunHistory(result.history); }
|
|
371
|
+
catch { result.output += '\n历史记录保存失败'; }
|
|
372
|
+
const { history: _history, ...response } = result;
|
|
349
373
|
if (streamOutput) {
|
|
350
|
-
sendStreamEvent(res, { type: 'result', ...
|
|
374
|
+
sendStreamEvent(res, { type: 'result', ...response });
|
|
351
375
|
res.end();
|
|
352
376
|
} else {
|
|
353
|
-
sendJson(res,
|
|
377
|
+
sendJson(res, response, result.success ? 200 : 500);
|
|
354
378
|
}
|
|
355
379
|
} finally {
|
|
356
380
|
replayingDevices.delete(deviceId);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createId, querySql, runSql, sqlJson, sqlString } from '../storage/sqlite';
|
|
2
|
+
import type { RunDetail, RunSummary } from '../../src/appium-recorder/run-history';
|
|
3
|
+
|
|
4
|
+
function ensureTable() {
|
|
5
|
+
runSql(`CREATE TABLE IF NOT EXISTS appium_run_history (
|
|
6
|
+
id TEXT PRIMARY KEY, script_id TEXT NOT NULL, started_at TEXT NOT NULL,
|
|
7
|
+
summary TEXT NOT NULL, detail TEXT NOT NULL
|
|
8
|
+
); CREATE INDEX IF NOT EXISTS appium_run_history_script ON appium_run_history(script_id, started_at);`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function saveRunHistory(input: Omit<RunDetail, 'id'>) {
|
|
12
|
+
ensureTable();
|
|
13
|
+
const record = { ...input, id: createId('run') };
|
|
14
|
+
const { frames, output, ...summary } = record;
|
|
15
|
+
runSql(`INSERT INTO appium_run_history VALUES (${sqlString(record.id)}, ${sqlString(record.scriptId)}, ${sqlString(record.startedAt)}, ${sqlJson(summary)}, ${sqlJson({ frames, output })});`);
|
|
16
|
+
return record.id;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function listRunHistory(scriptId: string): RunSummary[] {
|
|
20
|
+
ensureTable();
|
|
21
|
+
return (querySql<{ summary: string }>(`SELECT summary FROM appium_run_history WHERE script_id=${sqlString(scriptId)} ORDER BY started_at DESC;`) || []).map(row => JSON.parse(row.summary));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getRunHistory(scriptId: string, id: string): RunDetail | null {
|
|
25
|
+
ensureTable();
|
|
26
|
+
const row = querySql<{ summary: string; detail: string }>(`SELECT summary, detail FROM appium_run_history WHERE script_id=${sqlString(scriptId)} AND id=${sqlString(id)};`)?.[0];
|
|
27
|
+
return row ? { ...JSON.parse(row.summary), ...JSON.parse(row.detail) } : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function deleteRunHistory(scriptId: string, id: string) {
|
|
31
|
+
ensureTable();
|
|
32
|
+
// 不接受文件路径,也不删除原始报告;只删除这条历史的自包含快照。
|
|
33
|
+
runSql(`PRAGMA secure_delete=ON; DELETE FROM appium_run_history WHERE script_id=${sqlString(scriptId)} AND id=${sqlString(id)};`);
|
|
34
|
+
}
|