android-midscene-automation 0.1.18 → 0.1.19
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 +9 -0
- package/README.md +41 -3
- package/USAGE.md +32 -3
- package/package.json +1 -1
- package/server/appium-recorder/appium-runner.ts +286 -71
- package/server/appium-recorder/managed-appium.ts +147 -0
- package/server/appium-recorder/report.ts +358 -6
- package/server/appium-recorder/repository.ts +16 -0
- package/server/appium-recorder/routes.ts +18 -0
- package/src/appium-recorder/AppiumPage.vue +38 -3
- package/src/appium-recorder/api.ts +10 -0
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
3
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
4
|
import { join } from 'node:path';
|
|
4
5
|
import { getAppiumRecordedScript, type AppiumRecordedScriptRecord, type AppiumRecordedStepRecord } from './repository';
|
|
5
6
|
import { appDataPath } from '../paths';
|
|
6
7
|
import { ensureAndroidSdkAvailable, getAdbCommand } from '../android-sdk';
|
|
7
|
-
import { createAppiumReplayReport } from './report';
|
|
8
|
+
import { createAppiumReplayReport, type AppiumReplayFrame } from './report';
|
|
9
|
+
import { startManagedAppiumServer, usesManagedAppiumServer } from './managed-appium';
|
|
8
10
|
|
|
9
11
|
type AppiumSessionResponse = {
|
|
10
12
|
value?: {
|
|
@@ -23,7 +25,31 @@ type AppiumValueResponse<T> = {
|
|
|
23
25
|
};
|
|
24
26
|
|
|
25
27
|
const ELEMENT_KEY = 'element-6066-11e4-a52e-4f735466cecf';
|
|
26
|
-
const
|
|
28
|
+
const configuredAppiumServerUrl = () => (process.env.APPIUM_SERVER_URL || 'http://127.0.0.1:4723').replace(/\/+$/, '');
|
|
29
|
+
|
|
30
|
+
class ReplayStoppedError extends Error {
|
|
31
|
+
constructor() {
|
|
32
|
+
super('用户手动终止');
|
|
33
|
+
this.name = 'ReplayStoppedError';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const replayContext = new AsyncLocalStorage<{
|
|
38
|
+
signal?: AbortSignal;
|
|
39
|
+
appiumLog: (line: string) => void;
|
|
40
|
+
serverUrl: string;
|
|
41
|
+
frames: AppiumReplayFrame[];
|
|
42
|
+
}>();
|
|
43
|
+
|
|
44
|
+
const appiumServerUrl = () => replayContext.getStore()?.serverUrl || configuredAppiumServerUrl();
|
|
45
|
+
|
|
46
|
+
function isReplayStopped(error?: unknown) {
|
|
47
|
+
return error instanceof ReplayStoppedError || replayContext.getStore()?.signal?.aborted === true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function throwIfReplayStopped(error?: unknown) {
|
|
51
|
+
if (isReplayStopped(error)) throw new ReplayStoppedError();
|
|
52
|
+
}
|
|
27
53
|
|
|
28
54
|
function errorDetail(error: unknown) {
|
|
29
55
|
if (!(error instanceof Error)) return String(error || '未知错误');
|
|
@@ -40,7 +66,20 @@ function isStaleElementError(error: unknown) {
|
|
|
40
66
|
}
|
|
41
67
|
|
|
42
68
|
function wait(ms: number) {
|
|
43
|
-
|
|
69
|
+
const signal = replayContext.getStore()?.signal;
|
|
70
|
+
if (!signal) return new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
71
|
+
throwIfReplayStopped();
|
|
72
|
+
return new Promise<void>((resolve, reject) => {
|
|
73
|
+
const onAbort = () => {
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
reject(new ReplayStoppedError());
|
|
76
|
+
};
|
|
77
|
+
const timer = setTimeout(() => {
|
|
78
|
+
signal.removeEventListener('abort', onAbort);
|
|
79
|
+
resolve();
|
|
80
|
+
}, ms);
|
|
81
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
82
|
+
});
|
|
44
83
|
}
|
|
45
84
|
|
|
46
85
|
function normalizeTextForContains(value: string) {
|
|
@@ -135,6 +174,7 @@ async function waitForActivity(deviceId: string, step: AppiumRecordedStepRecord)
|
|
|
135
174
|
const timeoutMs = step.timeoutMs || 10000;
|
|
136
175
|
let currentActivity = '';
|
|
137
176
|
while (Date.now() - startedAt <= timeoutMs) {
|
|
177
|
+
throwIfReplayStopped();
|
|
138
178
|
currentActivity = await getCurrentActivity(deviceId);
|
|
139
179
|
if (currentActivity === expectedActivity) return;
|
|
140
180
|
await wait(500);
|
|
@@ -164,22 +204,34 @@ function appendAndroidSdkDiagnostics(lines: string[]) {
|
|
|
164
204
|
lines.push('- Appium 是独立进程时不会继承网页中后设置的环境变量;设置后请重新打开终端,并重启 appium 服务。');
|
|
165
205
|
}
|
|
166
206
|
|
|
167
|
-
async function appiumRequest<T>(path: string, init?: RequestInit) {
|
|
207
|
+
async function appiumRequest<T>(path: string, init?: RequestInit, options?: { ignoreAbort?: boolean }) {
|
|
168
208
|
const requestUrl = `${appiumServerUrl()}${path}`;
|
|
209
|
+
const context = replayContext.getStore();
|
|
210
|
+
const method = init?.method || 'GET';
|
|
211
|
+
const startedAt = Date.now();
|
|
212
|
+
if (!options?.ignoreAbort) throwIfReplayStopped();
|
|
213
|
+
context?.appiumLog(`[Appium] 请求:${method} ${path}`);
|
|
169
214
|
let response: Response;
|
|
170
215
|
try {
|
|
171
216
|
response = await fetch(requestUrl, {
|
|
172
217
|
...init,
|
|
218
|
+
signal: options?.ignoreAbort ? undefined : context?.signal || init?.signal,
|
|
173
219
|
headers: {
|
|
174
220
|
'Content-Type': 'application/json',
|
|
175
221
|
...(init?.headers || {}),
|
|
176
222
|
},
|
|
177
223
|
});
|
|
178
224
|
} catch (error) {
|
|
225
|
+
if (isReplayStopped(error) && !options?.ignoreAbort) {
|
|
226
|
+
context?.appiumLog(`[Appium] 取消:${method} ${path}`);
|
|
227
|
+
throw new ReplayStoppedError();
|
|
228
|
+
}
|
|
229
|
+
context?.appiumLog(`[Appium] 异常:${method} ${path}(${errorDetail(error)})`);
|
|
179
230
|
throw new Error(`无法连接 Appium 服务 ${requestUrl},${errorDetail(error)}`);
|
|
180
231
|
}
|
|
181
232
|
|
|
182
233
|
const responseText = await response.text();
|
|
234
|
+
context?.appiumLog(`[Appium] 响应:HTTP ${response.status} ${method} ${path}(${Date.now() - startedAt}ms)`);
|
|
183
235
|
const payload = (() => {
|
|
184
236
|
try {
|
|
185
237
|
return responseText ? JSON.parse(responseText) : {};
|
|
@@ -199,11 +251,55 @@ async function appiumRequest<T>(path: string, init?: RequestInit) {
|
|
|
199
251
|
payload.value?.stacktrace || payload.stacktrace ? `堆栈信息:\n${payload.value?.stacktrace || payload.stacktrace}` : '',
|
|
200
252
|
responseText ? `原始响应:\n${responseText}` : '',
|
|
201
253
|
].filter(Boolean).join('\n');
|
|
202
|
-
throw new Error(`Appium ${
|
|
254
|
+
throw new Error(`Appium ${method} ${path} 失败(HTTP ${response.status}):\n${detail || '未返回错误详情'}`);
|
|
203
255
|
}
|
|
204
256
|
return payload;
|
|
205
257
|
}
|
|
206
258
|
|
|
259
|
+
function replayFrameSelector(step: AppiumRecordedStepRecord) {
|
|
260
|
+
const selector = step.selector;
|
|
261
|
+
if (!selector) return '-';
|
|
262
|
+
if (selector.strategy === 'bounds') return `bounds (${selector.centerX ?? '-'}, ${selector.centerY ?? '-'})`;
|
|
263
|
+
return `${selector.strategy} ${selector.value || ''}`.trim();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function captureReplayFrame(
|
|
267
|
+
sessionId: string,
|
|
268
|
+
step: AppiumRecordedStepRecord,
|
|
269
|
+
nodeNumber: number,
|
|
270
|
+
scriptName: string,
|
|
271
|
+
phase: AppiumReplayFrame['phase'],
|
|
272
|
+
status: string,
|
|
273
|
+
) {
|
|
274
|
+
const context = replayContext.getStore();
|
|
275
|
+
if (!context || !sessionId) return;
|
|
276
|
+
try {
|
|
277
|
+
const payload = await appiumRequest<AppiumValueResponse<string>>(
|
|
278
|
+
`/session/${sessionId}/screenshot`,
|
|
279
|
+
undefined,
|
|
280
|
+
{ ignoreAbort: true },
|
|
281
|
+
);
|
|
282
|
+
const imageBase64 = typeof payload.value === 'string' ? payload.value : '';
|
|
283
|
+
if (!imageBase64) throw new Error('Appium 未返回截图内容');
|
|
284
|
+
context.frames.push({
|
|
285
|
+
sequence: context.frames.length + 1,
|
|
286
|
+
scriptName,
|
|
287
|
+
nodeId: step.id,
|
|
288
|
+
nodeNumber,
|
|
289
|
+
nodeLabel: step.label,
|
|
290
|
+
nodeType: step.type,
|
|
291
|
+
note: step.note || '',
|
|
292
|
+
selector: replayFrameSelector(step),
|
|
293
|
+
phase,
|
|
294
|
+
status,
|
|
295
|
+
capturedAt: new Date().toISOString(),
|
|
296
|
+
imageBase64,
|
|
297
|
+
});
|
|
298
|
+
} catch (error) {
|
|
299
|
+
context.appiumLog(`[节点 ${nodeNumber}] 截图失败:${errorDetail(error)}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
207
303
|
async function createSession(script: AppiumRecordedScriptRecord, deviceId: string) {
|
|
208
304
|
const appAlreadyForeground = await isAppInForeground(deviceId, script.appPackage).catch(() => false);
|
|
209
305
|
const payload = await appiumRequest<AppiumSessionResponse>('/session', {
|
|
@@ -275,7 +371,8 @@ async function findElement(sessionId: string, step: AppiumRecordedStepRecord) {
|
|
|
275
371
|
attempts.push(`context ${step.contextSelector.strategy} ${step.contextSelector.value || ''} -> ${step.selector.strategy} ${step.selector.value || ''}`);
|
|
276
372
|
const parentId = await findElementBySelector(sessionId, step.contextSelector);
|
|
277
373
|
return await findElementBySelector(sessionId, step.selector, parentId);
|
|
278
|
-
} catch {
|
|
374
|
+
} catch (error) {
|
|
375
|
+
throwIfReplayStopped(error);
|
|
279
376
|
// Continue with the fallback chain below.
|
|
280
377
|
}
|
|
281
378
|
}
|
|
@@ -284,7 +381,8 @@ async function findElement(sessionId: string, step: AppiumRecordedStepRecord) {
|
|
|
284
381
|
try {
|
|
285
382
|
attempts.push(`${selector.strategy} ${selector.value || ''}`);
|
|
286
383
|
return await findElementBySelector(sessionId, selector);
|
|
287
|
-
} catch {
|
|
384
|
+
} catch (error) {
|
|
385
|
+
throwIfReplayStopped(error);
|
|
288
386
|
// Continue.
|
|
289
387
|
}
|
|
290
388
|
}
|
|
@@ -293,6 +391,7 @@ async function findElement(sessionId: string, step: AppiumRecordedStepRecord) {
|
|
|
293
391
|
attempts.push(`${step.selector.strategy} ${step.selector.value || ''}`);
|
|
294
392
|
return await findElementBySelector(sessionId, step.selector);
|
|
295
393
|
} catch (error) {
|
|
394
|
+
throwIfReplayStopped(error);
|
|
296
395
|
throw new Error(`${step.label} 未找到元素;尝试过:${attempts.filter(Boolean).join(';') || '-'};${errorDetail(error)}`);
|
|
297
396
|
}
|
|
298
397
|
}
|
|
@@ -313,6 +412,7 @@ async function waitForElement(sessionId: string, step: AppiumRecordedStepRecord)
|
|
|
313
412
|
await findElement(sessionId, step);
|
|
314
413
|
return;
|
|
315
414
|
} catch (error) {
|
|
415
|
+
throwIfReplayStopped(error);
|
|
316
416
|
lastError = error;
|
|
317
417
|
await wait(500);
|
|
318
418
|
}
|
|
@@ -326,7 +426,8 @@ async function findOptionalElement(sessionId: string, step: AppiumRecordedStepRe
|
|
|
326
426
|
while (Date.now() - startedAt <= timeoutMs) {
|
|
327
427
|
try {
|
|
328
428
|
return await findElement(sessionId, step);
|
|
329
|
-
} catch {
|
|
429
|
+
} catch (error) {
|
|
430
|
+
throwIfReplayStopped(error);
|
|
330
431
|
await wait(250);
|
|
331
432
|
}
|
|
332
433
|
}
|
|
@@ -339,7 +440,8 @@ async function waitForElementGone(sessionId: string, step: AppiumRecordedStepRec
|
|
|
339
440
|
while (Date.now() - startedAt <= timeoutMs) {
|
|
340
441
|
try {
|
|
341
442
|
await findElement(sessionId, step);
|
|
342
|
-
} catch {
|
|
443
|
+
} catch (error) {
|
|
444
|
+
throwIfReplayStopped(error);
|
|
343
445
|
return;
|
|
344
446
|
}
|
|
345
447
|
await wait(500);
|
|
@@ -357,6 +459,7 @@ async function saveScreenshot(sessionId: string) {
|
|
|
357
459
|
}
|
|
358
460
|
|
|
359
461
|
async function runStep(sessionId: string, deviceId: string, step: AppiumRecordedStepRecord) {
|
|
462
|
+
throwIfReplayStopped();
|
|
360
463
|
if (step.type === 'delay') {
|
|
361
464
|
await wait(Math.max(0, step.timeoutMs || 1000));
|
|
362
465
|
return;
|
|
@@ -497,6 +600,7 @@ async function runStep(sessionId: string, deviceId: string, step: AppiumRecorded
|
|
|
497
600
|
});
|
|
498
601
|
return;
|
|
499
602
|
} catch (error) {
|
|
603
|
+
throwIfReplayStopped(error);
|
|
500
604
|
if (attempt === 0 && isStaleElementError(error)) {
|
|
501
605
|
await wait(300);
|
|
502
606
|
continue;
|
|
@@ -518,6 +622,7 @@ async function runStep(sessionId: string, deviceId: string, step: AppiumRecorded
|
|
|
518
622
|
});
|
|
519
623
|
return;
|
|
520
624
|
} catch (error) {
|
|
625
|
+
throwIfReplayStopped(error);
|
|
521
626
|
if (attempt === 0 && isStaleElementError(error)) {
|
|
522
627
|
await wait(300);
|
|
523
628
|
continue;
|
|
@@ -557,7 +662,8 @@ async function evaluateCondition(sessionId: string, deviceId: string, step: Appi
|
|
|
557
662
|
}
|
|
558
663
|
await waitForElement(sessionId, step);
|
|
559
664
|
return true;
|
|
560
|
-
} catch {
|
|
665
|
+
} catch (error) {
|
|
666
|
+
throwIfReplayStopped(error);
|
|
561
667
|
return false;
|
|
562
668
|
}
|
|
563
669
|
}
|
|
@@ -592,6 +698,7 @@ async function appendStepDiagnostics(lines: string[], deviceId: string, step: Ap
|
|
|
592
698
|
|
|
593
699
|
type ReplayStepOptions = {
|
|
594
700
|
skipLaunchApp?: boolean;
|
|
701
|
+
scriptName?: string;
|
|
595
702
|
};
|
|
596
703
|
|
|
597
704
|
async function replayLinkedScript(
|
|
@@ -601,6 +708,7 @@ async function replayLinkedScript(
|
|
|
601
708
|
lines: string[],
|
|
602
709
|
stack: string[],
|
|
603
710
|
) {
|
|
711
|
+
throwIfReplayStopped();
|
|
604
712
|
const scriptId = step.value || '';
|
|
605
713
|
if (!scriptId) throw new Error(`${step.label} 缺少连接脚本 ID`);
|
|
606
714
|
if (stack.includes(scriptId)) throw new Error(`${step.label} 检测到循环连接脚本`);
|
|
@@ -619,7 +727,8 @@ async function replayLinkedScript(
|
|
|
619
727
|
value: linkedScript.appActivity,
|
|
620
728
|
timeoutMs: 10000,
|
|
621
729
|
});
|
|
622
|
-
} catch {
|
|
730
|
+
} catch (error) {
|
|
731
|
+
throwIfReplayStopped(error);
|
|
623
732
|
const latestActivity = await getCurrentActivity(deviceId).catch(() => currentActivity);
|
|
624
733
|
throw new Error(
|
|
625
734
|
`${step.label} 无法执行:当前 Activity 为 ${latestActivity || '-'},`
|
|
@@ -633,6 +742,7 @@ async function replayLinkedScript(
|
|
|
633
742
|
const nextStack = [...stack, linkedScript.id];
|
|
634
743
|
await replayScriptSteps(sessionId, deviceId, linkedScript.steps, lines, nextStack, {
|
|
635
744
|
skipLaunchApp: true,
|
|
745
|
+
scriptName: linkedScript.name,
|
|
636
746
|
});
|
|
637
747
|
lines.push(`连接脚本完成:${linkedScript.name}`);
|
|
638
748
|
}
|
|
@@ -646,11 +756,13 @@ async function replayLinearSteps(
|
|
|
646
756
|
options: ReplayStepOptions = {},
|
|
647
757
|
) {
|
|
648
758
|
for (const [index, step] of steps.entries()) {
|
|
759
|
+
throwIfReplayStopped();
|
|
649
760
|
if (options.skipLaunchApp && step.type === 'launchApp') {
|
|
650
761
|
lines.push(`[节点 ${index + 1}] 跳过:${step.label}(连接脚本不重复启动 App)`);
|
|
651
762
|
continue;
|
|
652
763
|
}
|
|
653
764
|
lines.push(`[节点 ${index + 1}] 开始:${step.label}`);
|
|
765
|
+
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'before', '执行前');
|
|
654
766
|
try {
|
|
655
767
|
if (step.type === 'runScript') {
|
|
656
768
|
await replayLinkedScript(sessionId, deviceId, step, lines, stack);
|
|
@@ -659,12 +771,20 @@ async function replayLinearSteps(
|
|
|
659
771
|
if (result) lines.push(`[节点 ${index + 1}] 结果:${result}`);
|
|
660
772
|
}
|
|
661
773
|
lines.push(`[节点 ${index + 1}] 完成:${step.label}`);
|
|
774
|
+
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'after', '成功');
|
|
662
775
|
} catch (error) {
|
|
776
|
+
if (isReplayStopped(error)) {
|
|
777
|
+
lines.push(`[节点 ${index + 1}] 已终止:${step.label}`);
|
|
778
|
+
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'stopped', '已终止');
|
|
779
|
+
throw new ReplayStoppedError();
|
|
780
|
+
}
|
|
663
781
|
if (step.optional) {
|
|
664
782
|
lines.push(`[节点 ${index + 1}] 跳过:${errorDetail(error)}`);
|
|
783
|
+
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'error', '已跳过');
|
|
665
784
|
continue;
|
|
666
785
|
}
|
|
667
786
|
lines.push(`[节点 ${index + 1}] 失败:${errorDetail(error)}`);
|
|
787
|
+
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'error', '失败');
|
|
668
788
|
await appendStepDiagnostics(lines, deviceId, step);
|
|
669
789
|
throw error;
|
|
670
790
|
}
|
|
@@ -703,6 +823,7 @@ async function replayFlowSteps(
|
|
|
703
823
|
const maxVisits = Math.max(steps.length * 4, 20);
|
|
704
824
|
|
|
705
825
|
while (typeof index === 'number' && index >= 0 && index < steps.length) {
|
|
826
|
+
throwIfReplayStopped();
|
|
706
827
|
guard += 1;
|
|
707
828
|
if (guard > maxVisits) throw new Error('流程图可能存在循环,已终止回放');
|
|
708
829
|
|
|
@@ -715,13 +836,31 @@ async function replayFlowSteps(
|
|
|
715
836
|
visitedPath.push(step.label);
|
|
716
837
|
const nodeKind = step.flow?.nodeKind || (step.type === 'assertExists' || step.type === 'assertText' ? 'assertion' : 'action');
|
|
717
838
|
lines.push(`[节点 ${index + 1}] 开始:${step.label}`);
|
|
839
|
+
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'before', '执行前');
|
|
718
840
|
|
|
719
841
|
if (nodeKind === 'condition') {
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
842
|
+
try {
|
|
843
|
+
const matched = await evaluateCondition(sessionId, deviceId, step);
|
|
844
|
+
const branchTargetId = matched ? step.flow?.yesTargetId : step.flow?.noTargetId;
|
|
845
|
+
const targetId = branchTargetId || step.flow?.successTargetId || '';
|
|
846
|
+
lines.push(`[节点 ${index + 1}] 判断:${matched ? '是' : '否'}${targetId ? `,进入 ${targetId}` : ',流程结束'}`);
|
|
847
|
+
await captureReplayFrame(
|
|
848
|
+
sessionId,
|
|
849
|
+
step,
|
|
850
|
+
index + 1,
|
|
851
|
+
options.scriptName || '',
|
|
852
|
+
'after',
|
|
853
|
+
`判断:${matched ? '是' : '否'}`,
|
|
854
|
+
);
|
|
855
|
+
index = targetId ? idToIndex.get(targetId) : undefined;
|
|
856
|
+
} catch (error) {
|
|
857
|
+
if (isReplayStopped(error)) {
|
|
858
|
+
lines.push(`[节点 ${index + 1}] 已终止:${step.label}`);
|
|
859
|
+
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'stopped', '已终止');
|
|
860
|
+
throw new ReplayStoppedError();
|
|
861
|
+
}
|
|
862
|
+
throw error;
|
|
863
|
+
}
|
|
725
864
|
continue;
|
|
726
865
|
}
|
|
727
866
|
|
|
@@ -733,20 +872,29 @@ async function replayFlowSteps(
|
|
|
733
872
|
if (result) lines.push(`[节点 ${index + 1}] 结果:${result}`);
|
|
734
873
|
}
|
|
735
874
|
lines.push(`[节点 ${index + 1}] 完成:${step.label}`);
|
|
875
|
+
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'after', '成功');
|
|
736
876
|
index = nextIndexAfterStep(index, step);
|
|
737
877
|
} catch (error) {
|
|
878
|
+
if (isReplayStopped(error)) {
|
|
879
|
+
lines.push(`[节点 ${index + 1}] 已终止:${step.label}`);
|
|
880
|
+
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'stopped', '已终止');
|
|
881
|
+
throw new ReplayStoppedError();
|
|
882
|
+
}
|
|
738
883
|
if (step.optional) {
|
|
739
884
|
lines.push(`[节点 ${index + 1}] 跳过:${errorDetail(error)}`);
|
|
885
|
+
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'error', '已跳过');
|
|
740
886
|
index = nextIndexAfterStep(index, step);
|
|
741
887
|
continue;
|
|
742
888
|
}
|
|
743
889
|
const failureTarget = step.flow?.failureTargetId;
|
|
744
890
|
if (failureTarget && idToIndex.has(failureTarget)) {
|
|
745
891
|
lines.push(`[节点 ${index + 1}] 失败分支:${errorDetail(error)}`);
|
|
892
|
+
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'error', '进入失败分支');
|
|
746
893
|
index = idToIndex.get(failureTarget);
|
|
747
894
|
continue;
|
|
748
895
|
}
|
|
749
896
|
lines.push(`[节点 ${index + 1}] 失败:${errorDetail(error)}`);
|
|
897
|
+
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'error', '失败');
|
|
750
898
|
lines.push(`执行路径:${visitedPath.join(' -> ')}`);
|
|
751
899
|
await appendStepDiagnostics(lines, deviceId, step);
|
|
752
900
|
throw error;
|
|
@@ -791,9 +939,25 @@ async function replayScriptSteps(
|
|
|
791
939
|
const trailingLinkedSteps = steps.slice(trailingLinkIndex);
|
|
792
940
|
await replayStepGroup(sessionId, deviceId, mainSteps, lines, stack, options);
|
|
793
941
|
|
|
794
|
-
for (const step of trailingLinkedSteps) {
|
|
942
|
+
for (const [offset, step] of trailingLinkedSteps.entries()) {
|
|
943
|
+
const nodeNumber = trailingLinkIndex + offset + 1;
|
|
795
944
|
lines.push(`当前脚本步骤完成,开始执行:${step.label}`);
|
|
796
|
-
|
|
945
|
+
lines.push(`[节点 ${nodeNumber}] 开始:${step.label}`);
|
|
946
|
+
await captureReplayFrame(sessionId, step, nodeNumber, options.scriptName || '', 'before', '执行前');
|
|
947
|
+
try {
|
|
948
|
+
await replayLinkedScript(sessionId, deviceId, step, lines, stack);
|
|
949
|
+
lines.push(`[节点 ${nodeNumber}] 完成:${step.label}`);
|
|
950
|
+
await captureReplayFrame(sessionId, step, nodeNumber, options.scriptName || '', 'after', '成功');
|
|
951
|
+
} catch (error) {
|
|
952
|
+
if (isReplayStopped(error)) {
|
|
953
|
+
lines.push(`[节点 ${nodeNumber}] 已终止:${step.label}`);
|
|
954
|
+
await captureReplayFrame(sessionId, step, nodeNumber, options.scriptName || '', 'stopped', '已终止');
|
|
955
|
+
throw new ReplayStoppedError();
|
|
956
|
+
}
|
|
957
|
+
lines.push(`[节点 ${nodeNumber}] 失败:${errorDetail(error)}`);
|
|
958
|
+
await captureReplayFrame(sessionId, step, nodeNumber, options.scriptName || '', 'error', '失败');
|
|
959
|
+
throw error;
|
|
960
|
+
}
|
|
797
961
|
}
|
|
798
962
|
}
|
|
799
963
|
|
|
@@ -801,6 +965,7 @@ export async function replayAppiumScript(
|
|
|
801
965
|
script: AppiumRecordedScriptRecord,
|
|
802
966
|
deviceId: string,
|
|
803
967
|
onOutput?: (line: string) => void,
|
|
968
|
+
signal?: AbortSignal,
|
|
804
969
|
) {
|
|
805
970
|
const targetDeviceId = deviceId || script.deviceId;
|
|
806
971
|
if (!targetDeviceId) throw new Error('未检测到可用设备');
|
|
@@ -814,64 +979,114 @@ export async function replayAppiumScript(
|
|
|
814
979
|
items.forEach((line) => onOutput?.(line));
|
|
815
980
|
return length;
|
|
816
981
|
};
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
982
|
+
const context = {
|
|
983
|
+
signal,
|
|
984
|
+
appiumLog: (line) => lines.push(line),
|
|
985
|
+
serverUrl: configuredAppiumServerUrl(),
|
|
986
|
+
frames: [] as AppiumReplayFrame[],
|
|
987
|
+
};
|
|
988
|
+
return replayContext.run(context, async () => {
|
|
989
|
+
lines.push(
|
|
990
|
+
`目标设备:${targetDeviceId}`,
|
|
991
|
+
`App 包名:${script.appPackage}`,
|
|
992
|
+
`录制步骤:${script.steps.length}`,
|
|
993
|
+
);
|
|
994
|
+
let sessionId = '';
|
|
995
|
+
let success = false;
|
|
996
|
+
let stopped = false;
|
|
997
|
+
let managedAppium: Awaited<ReturnType<typeof startManagedAppiumServer>> | null = null;
|
|
830
998
|
try {
|
|
831
|
-
|
|
999
|
+
lines.push('正在检测 Android SDK...');
|
|
1000
|
+
const androidSdk = ensureAndroidSdkAvailable();
|
|
1001
|
+
lines.push(`Android SDK 已就绪:${androidSdk.root}`);
|
|
1002
|
+
if (usesManagedAppiumServer()) {
|
|
1003
|
+
lines.push('正在启动本次回放的 Appium 服务...');
|
|
1004
|
+
lines.push('----- Appium 服务端原始日志开始 -----');
|
|
1005
|
+
managedAppium = await startManagedAppiumServer((line) => lines.push(line), signal);
|
|
1006
|
+
context.serverUrl = managedAppium.serverUrl;
|
|
1007
|
+
}
|
|
1008
|
+
lines.push(`Appium 服务:${appiumServerUrl()}`);
|
|
1009
|
+
lines.push('正在创建 Appium session...');
|
|
1010
|
+
try {
|
|
1011
|
+
sessionId = await createSession(script, targetDeviceId);
|
|
1012
|
+
} catch (error) {
|
|
1013
|
+
throwIfReplayStopped(error);
|
|
1014
|
+
if (!isUiAutomationDisconnected(error)) throw error;
|
|
1015
|
+
lines.push('检测到 UiAutomation 连接冲突,正在清理残留进程并重试...');
|
|
1016
|
+
await resetUiAutomator2(targetDeviceId);
|
|
1017
|
+
sessionId = await createSession(script, targetDeviceId);
|
|
1018
|
+
}
|
|
1019
|
+
lines.push(`Appium session 已创建:${sessionId}`);
|
|
1020
|
+
if (hasFlowSteps(script.steps)) lines.push('按流程图路径回放...');
|
|
1021
|
+
await replayScriptSteps(sessionId, targetDeviceId, script.steps, lines, [script.id], {
|
|
1022
|
+
scriptName: script.name,
|
|
1023
|
+
});
|
|
1024
|
+
lines.push('回放完成');
|
|
1025
|
+
success = true;
|
|
832
1026
|
} catch (error) {
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
1027
|
+
stopped = isReplayStopped(error);
|
|
1028
|
+
const detail = errorDetail(error);
|
|
1029
|
+
if (stopped) {
|
|
1030
|
+
lines.push('回放已终止:用户手动终止');
|
|
1031
|
+
} else {
|
|
1032
|
+
if (detail.includes('Appium Settings app is not running')) {
|
|
1033
|
+
await appendSettingsDiagnostics(lines, targetDeviceId);
|
|
1034
|
+
}
|
|
1035
|
+
if (detail.includes('Neither ANDROID_HOME nor ANDROID_SDK_ROOT')) {
|
|
1036
|
+
appendAndroidSdkDiagnostics(lines);
|
|
1037
|
+
}
|
|
1038
|
+
lines.push(`回放终止:${detail}`);
|
|
1039
|
+
}
|
|
1040
|
+
} finally {
|
|
1041
|
+
if (sessionId) {
|
|
1042
|
+
await appiumRequest(`/session/${sessionId}`, { method: 'DELETE' }, { ignoreAbort: true }).catch(() => undefined);
|
|
1043
|
+
}
|
|
1044
|
+
if (managedAppium) {
|
|
1045
|
+
await managedAppium.stop();
|
|
1046
|
+
lines.push('----- Appium 服务端原始日志结束 -----');
|
|
1047
|
+
}
|
|
850
1048
|
}
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
1049
|
+
|
|
1050
|
+
if (signal?.aborted && !stopped) {
|
|
1051
|
+
stopped = true;
|
|
1052
|
+
success = false;
|
|
1053
|
+
lines.push('回放已终止:用户手动终止');
|
|
855
1054
|
}
|
|
856
|
-
}
|
|
857
1055
|
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
1056
|
+
const completedAt = new Date();
|
|
1057
|
+
let reportPath = '';
|
|
1058
|
+
let reportId = '';
|
|
1059
|
+
let logPath = '';
|
|
1060
|
+
let htmlReportPath = '';
|
|
1061
|
+
try {
|
|
1062
|
+
const report = await createAppiumReplayReport({
|
|
1063
|
+
script,
|
|
1064
|
+
deviceId: targetDeviceId,
|
|
1065
|
+
success,
|
|
1066
|
+
stopped,
|
|
1067
|
+
output: lines.join('\n'),
|
|
1068
|
+
startedAt,
|
|
1069
|
+
completedAt,
|
|
1070
|
+
frames: context.frames,
|
|
1071
|
+
});
|
|
1072
|
+
reportPath = report.filePath;
|
|
1073
|
+
reportId = report.id;
|
|
1074
|
+
logPath = report.logPath;
|
|
1075
|
+
htmlReportPath = report.htmlReportPath;
|
|
1076
|
+
lines.push(`回放报告已生成:${reportPath}`);
|
|
1077
|
+
lines.push(`截图回放已生成:${report.htmlReportPath}`);
|
|
1078
|
+
lines.push(`回放日志已生成:${logPath}`);
|
|
1079
|
+
} catch (error) {
|
|
1080
|
+
lines.push(`回放报告和日志生成失败:${errorDetail(error)}`);
|
|
1081
|
+
}
|
|
1082
|
+
return {
|
|
865
1083
|
success,
|
|
1084
|
+
stopped,
|
|
866
1085
|
output: lines.join('\n'),
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
} catch (error) {
|
|
874
|
-
lines.push(`回放报告生成失败:${errorDetail(error)}`);
|
|
875
|
-
}
|
|
876
|
-
return { success, output: lines.join('\n'), reportPath, reportId };
|
|
1086
|
+
reportPath,
|
|
1087
|
+
reportId,
|
|
1088
|
+
logPath,
|
|
1089
|
+
htmlReportPath,
|
|
1090
|
+
};
|
|
1091
|
+
});
|
|
877
1092
|
}
|