android-midscene-automation 0.1.18 → 0.1.20
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 +30 -0
- package/README.md +62 -3
- package/USAGE.md +32 -3
- package/package.json +1 -1
- package/server/appium-recorder/appium-runner.ts +312 -76
- package/server/appium-recorder/managed-appium.ts +147 -0
- package/server/appium-recorder/report.ts +358 -6
- package/server/appium-recorder/repository.ts +43 -6
- package/server/appium-recorder/routes.ts +18 -0
- package/src/appium-recorder/AppiumPage.vue +114 -50
- package/src/appium-recorder/api.ts +10 -0
- package/src/appium-recorder/components/FlowStepEditor.vue +0 -8
- package/src/appium-recorder/components/NestedConditionBranches.vue +373 -0
- package/src/appium-recorder/components/RecordedSteps.vue +607 -23
- package/src/appium-recorder/flow-copy.ts +193 -0
- package/src/appium-recorder/flow-layout.ts +88 -0
- package/src/style.css +163 -34
|
@@ -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,40 @@ function appendAndroidSdkDiagnostics(lines: string[]) {
|
|
|
164
204
|
lines.push('- Appium 是独立进程时不会继承网页中后设置的环境变量;设置后请重新打开终端,并重启 appium 服务。');
|
|
165
205
|
}
|
|
166
206
|
|
|
167
|
-
async function appiumRequest<T>(
|
|
207
|
+
async function appiumRequest<T>(
|
|
208
|
+
path: string,
|
|
209
|
+
init?: RequestInit,
|
|
210
|
+
options?: { ignoreAbort?: boolean; timeoutMs?: number },
|
|
211
|
+
) {
|
|
168
212
|
const requestUrl = `${appiumServerUrl()}${path}`;
|
|
213
|
+
const context = replayContext.getStore();
|
|
214
|
+
const method = init?.method || 'GET';
|
|
215
|
+
const startedAt = Date.now();
|
|
216
|
+
if (!options?.ignoreAbort) throwIfReplayStopped();
|
|
217
|
+
context?.appiumLog(`[Appium] 请求:${method} ${path}`);
|
|
169
218
|
let response: Response;
|
|
170
219
|
try {
|
|
171
220
|
response = await fetch(requestUrl, {
|
|
172
221
|
...init,
|
|
222
|
+
signal: options?.ignoreAbort
|
|
223
|
+
? (options.timeoutMs ? AbortSignal.timeout(options.timeoutMs) : undefined)
|
|
224
|
+
: context?.signal || init?.signal,
|
|
173
225
|
headers: {
|
|
174
226
|
'Content-Type': 'application/json',
|
|
175
227
|
...(init?.headers || {}),
|
|
176
228
|
},
|
|
177
229
|
});
|
|
178
230
|
} catch (error) {
|
|
231
|
+
if (isReplayStopped(error) && !options?.ignoreAbort) {
|
|
232
|
+
context?.appiumLog(`[Appium] 取消:${method} ${path}`);
|
|
233
|
+
throw new ReplayStoppedError();
|
|
234
|
+
}
|
|
235
|
+
context?.appiumLog(`[Appium] 异常:${method} ${path}(${errorDetail(error)})`);
|
|
179
236
|
throw new Error(`无法连接 Appium 服务 ${requestUrl},${errorDetail(error)}`);
|
|
180
237
|
}
|
|
181
238
|
|
|
182
239
|
const responseText = await response.text();
|
|
240
|
+
context?.appiumLog(`[Appium] 响应:HTTP ${response.status} ${method} ${path}(${Date.now() - startedAt}ms)`);
|
|
183
241
|
const payload = (() => {
|
|
184
242
|
try {
|
|
185
243
|
return responseText ? JSON.parse(responseText) : {};
|
|
@@ -199,13 +257,66 @@ async function appiumRequest<T>(path: string, init?: RequestInit) {
|
|
|
199
257
|
payload.value?.stacktrace || payload.stacktrace ? `堆栈信息:\n${payload.value?.stacktrace || payload.stacktrace}` : '',
|
|
200
258
|
responseText ? `原始响应:\n${responseText}` : '',
|
|
201
259
|
].filter(Boolean).join('\n');
|
|
202
|
-
throw new Error(`Appium ${
|
|
260
|
+
throw new Error(`Appium ${method} ${path} 失败(HTTP ${response.status}):\n${detail || '未返回错误详情'}`);
|
|
203
261
|
}
|
|
204
262
|
return payload;
|
|
205
263
|
}
|
|
206
264
|
|
|
207
|
-
|
|
208
|
-
const
|
|
265
|
+
function replayFrameSelector(step: AppiumRecordedStepRecord) {
|
|
266
|
+
const selector = step.selector;
|
|
267
|
+
if (!selector) return '-';
|
|
268
|
+
if (selector.strategy === 'bounds') return `bounds (${selector.centerX ?? '-'}, ${selector.centerY ?? '-'})`;
|
|
269
|
+
return `${selector.strategy} ${selector.value || ''}`.trim();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function captureReplayFrame(
|
|
273
|
+
sessionId: string,
|
|
274
|
+
step: AppiumRecordedStepRecord,
|
|
275
|
+
nodeNumber: number,
|
|
276
|
+
scriptName: string,
|
|
277
|
+
phase: AppiumReplayFrame['phase'],
|
|
278
|
+
status: string,
|
|
279
|
+
) {
|
|
280
|
+
const context = replayContext.getStore();
|
|
281
|
+
if (!context || !sessionId) return;
|
|
282
|
+
const appendFrame = (imageBase64: string) => {
|
|
283
|
+
context.frames.push({
|
|
284
|
+
sequence: context.frames.length + 1,
|
|
285
|
+
scriptName,
|
|
286
|
+
nodeId: step.id,
|
|
287
|
+
nodeNumber,
|
|
288
|
+
nodeLabel: step.label,
|
|
289
|
+
nodeType: step.type,
|
|
290
|
+
note: step.note || '',
|
|
291
|
+
selector: replayFrameSelector(step),
|
|
292
|
+
phase,
|
|
293
|
+
status,
|
|
294
|
+
capturedAt: new Date().toISOString(),
|
|
295
|
+
imageBase64,
|
|
296
|
+
});
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
if (phase === 'stopped' && context.signal?.aborted) {
|
|
300
|
+
const previousFrame = context.frames.at(-1);
|
|
301
|
+
if (previousFrame) appendFrame(previousFrame.imageBase64);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
const payload = await appiumRequest<AppiumValueResponse<string>>(
|
|
307
|
+
`/session/${sessionId}/screenshot`,
|
|
308
|
+
undefined,
|
|
309
|
+
{ ignoreAbort: true, timeoutMs: 5000 },
|
|
310
|
+
);
|
|
311
|
+
const imageBase64 = typeof payload.value === 'string' ? payload.value : '';
|
|
312
|
+
if (!imageBase64) throw new Error('Appium 未返回截图内容');
|
|
313
|
+
appendFrame(imageBase64);
|
|
314
|
+
} catch (error) {
|
|
315
|
+
context.appiumLog(`[节点 ${nodeNumber}] 截图失败:${errorDetail(error)}`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function createSession(deviceId: string) {
|
|
209
320
|
const payload = await appiumRequest<AppiumSessionResponse>('/session', {
|
|
210
321
|
method: 'POST',
|
|
211
322
|
body: JSON.stringify({
|
|
@@ -214,10 +325,8 @@ async function createSession(script: AppiumRecordedScriptRecord, deviceId: strin
|
|
|
214
325
|
platformName: 'Android',
|
|
215
326
|
'appium:automationName': 'UiAutomator2',
|
|
216
327
|
'appium:udid': deviceId,
|
|
217
|
-
'appium:
|
|
218
|
-
...(script.appActivity ? { 'appium:appActivity': script.appActivity } : {}),
|
|
328
|
+
'appium:autoLaunch': false,
|
|
219
329
|
'appium:noReset': true,
|
|
220
|
-
...(appAlreadyForeground ? { 'appium:autoLaunch': false } : {}),
|
|
221
330
|
'appium:skipDeviceInitialization': true,
|
|
222
331
|
'appium:ignoreHiddenApiPolicyError': true,
|
|
223
332
|
},
|
|
@@ -275,7 +384,8 @@ async function findElement(sessionId: string, step: AppiumRecordedStepRecord) {
|
|
|
275
384
|
attempts.push(`context ${step.contextSelector.strategy} ${step.contextSelector.value || ''} -> ${step.selector.strategy} ${step.selector.value || ''}`);
|
|
276
385
|
const parentId = await findElementBySelector(sessionId, step.contextSelector);
|
|
277
386
|
return await findElementBySelector(sessionId, step.selector, parentId);
|
|
278
|
-
} catch {
|
|
387
|
+
} catch (error) {
|
|
388
|
+
throwIfReplayStopped(error);
|
|
279
389
|
// Continue with the fallback chain below.
|
|
280
390
|
}
|
|
281
391
|
}
|
|
@@ -284,7 +394,8 @@ async function findElement(sessionId: string, step: AppiumRecordedStepRecord) {
|
|
|
284
394
|
try {
|
|
285
395
|
attempts.push(`${selector.strategy} ${selector.value || ''}`);
|
|
286
396
|
return await findElementBySelector(sessionId, selector);
|
|
287
|
-
} catch {
|
|
397
|
+
} catch (error) {
|
|
398
|
+
throwIfReplayStopped(error);
|
|
288
399
|
// Continue.
|
|
289
400
|
}
|
|
290
401
|
}
|
|
@@ -293,6 +404,7 @@ async function findElement(sessionId: string, step: AppiumRecordedStepRecord) {
|
|
|
293
404
|
attempts.push(`${step.selector.strategy} ${step.selector.value || ''}`);
|
|
294
405
|
return await findElementBySelector(sessionId, step.selector);
|
|
295
406
|
} catch (error) {
|
|
407
|
+
throwIfReplayStopped(error);
|
|
296
408
|
throw new Error(`${step.label} 未找到元素;尝试过:${attempts.filter(Boolean).join(';') || '-'};${errorDetail(error)}`);
|
|
297
409
|
}
|
|
298
410
|
}
|
|
@@ -313,6 +425,7 @@ async function waitForElement(sessionId: string, step: AppiumRecordedStepRecord)
|
|
|
313
425
|
await findElement(sessionId, step);
|
|
314
426
|
return;
|
|
315
427
|
} catch (error) {
|
|
428
|
+
throwIfReplayStopped(error);
|
|
316
429
|
lastError = error;
|
|
317
430
|
await wait(500);
|
|
318
431
|
}
|
|
@@ -326,7 +439,8 @@ async function findOptionalElement(sessionId: string, step: AppiumRecordedStepRe
|
|
|
326
439
|
while (Date.now() - startedAt <= timeoutMs) {
|
|
327
440
|
try {
|
|
328
441
|
return await findElement(sessionId, step);
|
|
329
|
-
} catch {
|
|
442
|
+
} catch (error) {
|
|
443
|
+
throwIfReplayStopped(error);
|
|
330
444
|
await wait(250);
|
|
331
445
|
}
|
|
332
446
|
}
|
|
@@ -339,7 +453,8 @@ async function waitForElementGone(sessionId: string, step: AppiumRecordedStepRec
|
|
|
339
453
|
while (Date.now() - startedAt <= timeoutMs) {
|
|
340
454
|
try {
|
|
341
455
|
await findElement(sessionId, step);
|
|
342
|
-
} catch {
|
|
456
|
+
} catch (error) {
|
|
457
|
+
throwIfReplayStopped(error);
|
|
343
458
|
return;
|
|
344
459
|
}
|
|
345
460
|
await wait(500);
|
|
@@ -357,6 +472,7 @@ async function saveScreenshot(sessionId: string) {
|
|
|
357
472
|
}
|
|
358
473
|
|
|
359
474
|
async function runStep(sessionId: string, deviceId: string, step: AppiumRecordedStepRecord) {
|
|
475
|
+
throwIfReplayStopped();
|
|
360
476
|
if (step.type === 'delay') {
|
|
361
477
|
await wait(Math.max(0, step.timeoutMs || 1000));
|
|
362
478
|
return;
|
|
@@ -497,6 +613,7 @@ async function runStep(sessionId: string, deviceId: string, step: AppiumRecorded
|
|
|
497
613
|
});
|
|
498
614
|
return;
|
|
499
615
|
} catch (error) {
|
|
616
|
+
throwIfReplayStopped(error);
|
|
500
617
|
if (attempt === 0 && isStaleElementError(error)) {
|
|
501
618
|
await wait(300);
|
|
502
619
|
continue;
|
|
@@ -518,6 +635,7 @@ async function runStep(sessionId: string, deviceId: string, step: AppiumRecorded
|
|
|
518
635
|
});
|
|
519
636
|
return;
|
|
520
637
|
} catch (error) {
|
|
638
|
+
throwIfReplayStopped(error);
|
|
521
639
|
if (attempt === 0 && isStaleElementError(error)) {
|
|
522
640
|
await wait(300);
|
|
523
641
|
continue;
|
|
@@ -557,7 +675,8 @@ async function evaluateCondition(sessionId: string, deviceId: string, step: Appi
|
|
|
557
675
|
}
|
|
558
676
|
await waitForElement(sessionId, step);
|
|
559
677
|
return true;
|
|
560
|
-
} catch {
|
|
678
|
+
} catch (error) {
|
|
679
|
+
throwIfReplayStopped(error);
|
|
561
680
|
return false;
|
|
562
681
|
}
|
|
563
682
|
}
|
|
@@ -592,6 +711,7 @@ async function appendStepDiagnostics(lines: string[], deviceId: string, step: Ap
|
|
|
592
711
|
|
|
593
712
|
type ReplayStepOptions = {
|
|
594
713
|
skipLaunchApp?: boolean;
|
|
714
|
+
scriptName?: string;
|
|
595
715
|
};
|
|
596
716
|
|
|
597
717
|
async function replayLinkedScript(
|
|
@@ -601,6 +721,7 @@ async function replayLinkedScript(
|
|
|
601
721
|
lines: string[],
|
|
602
722
|
stack: string[],
|
|
603
723
|
) {
|
|
724
|
+
throwIfReplayStopped();
|
|
604
725
|
const scriptId = step.value || '';
|
|
605
726
|
if (!scriptId) throw new Error(`${step.label} 缺少连接脚本 ID`);
|
|
606
727
|
if (stack.includes(scriptId)) throw new Error(`${step.label} 检测到循环连接脚本`);
|
|
@@ -619,7 +740,8 @@ async function replayLinkedScript(
|
|
|
619
740
|
value: linkedScript.appActivity,
|
|
620
741
|
timeoutMs: 10000,
|
|
621
742
|
});
|
|
622
|
-
} catch {
|
|
743
|
+
} catch (error) {
|
|
744
|
+
throwIfReplayStopped(error);
|
|
623
745
|
const latestActivity = await getCurrentActivity(deviceId).catch(() => currentActivity);
|
|
624
746
|
throw new Error(
|
|
625
747
|
`${step.label} 无法执行:当前 Activity 为 ${latestActivity || '-'},`
|
|
@@ -633,6 +755,7 @@ async function replayLinkedScript(
|
|
|
633
755
|
const nextStack = [...stack, linkedScript.id];
|
|
634
756
|
await replayScriptSteps(sessionId, deviceId, linkedScript.steps, lines, nextStack, {
|
|
635
757
|
skipLaunchApp: true,
|
|
758
|
+
scriptName: linkedScript.name,
|
|
636
759
|
});
|
|
637
760
|
lines.push(`连接脚本完成:${linkedScript.name}`);
|
|
638
761
|
}
|
|
@@ -646,11 +769,13 @@ async function replayLinearSteps(
|
|
|
646
769
|
options: ReplayStepOptions = {},
|
|
647
770
|
) {
|
|
648
771
|
for (const [index, step] of steps.entries()) {
|
|
772
|
+
throwIfReplayStopped();
|
|
649
773
|
if (options.skipLaunchApp && step.type === 'launchApp') {
|
|
650
774
|
lines.push(`[节点 ${index + 1}] 跳过:${step.label}(连接脚本不重复启动 App)`);
|
|
651
775
|
continue;
|
|
652
776
|
}
|
|
653
777
|
lines.push(`[节点 ${index + 1}] 开始:${step.label}`);
|
|
778
|
+
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'before', '执行前');
|
|
654
779
|
try {
|
|
655
780
|
if (step.type === 'runScript') {
|
|
656
781
|
await replayLinkedScript(sessionId, deviceId, step, lines, stack);
|
|
@@ -659,12 +784,20 @@ async function replayLinearSteps(
|
|
|
659
784
|
if (result) lines.push(`[节点 ${index + 1}] 结果:${result}`);
|
|
660
785
|
}
|
|
661
786
|
lines.push(`[节点 ${index + 1}] 完成:${step.label}`);
|
|
787
|
+
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'after', '成功');
|
|
662
788
|
} catch (error) {
|
|
789
|
+
if (isReplayStopped(error)) {
|
|
790
|
+
lines.push(`[节点 ${index + 1}] 已终止:${step.label}`);
|
|
791
|
+
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'stopped', '已终止');
|
|
792
|
+
throw new ReplayStoppedError();
|
|
793
|
+
}
|
|
663
794
|
if (step.optional) {
|
|
664
795
|
lines.push(`[节点 ${index + 1}] 跳过:${errorDetail(error)}`);
|
|
796
|
+
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'error', '已跳过');
|
|
665
797
|
continue;
|
|
666
798
|
}
|
|
667
799
|
lines.push(`[节点 ${index + 1}] 失败:${errorDetail(error)}`);
|
|
800
|
+
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'error', '失败');
|
|
668
801
|
await appendStepDiagnostics(lines, deviceId, step);
|
|
669
802
|
throw error;
|
|
670
803
|
}
|
|
@@ -703,6 +836,7 @@ async function replayFlowSteps(
|
|
|
703
836
|
const maxVisits = Math.max(steps.length * 4, 20);
|
|
704
837
|
|
|
705
838
|
while (typeof index === 'number' && index >= 0 && index < steps.length) {
|
|
839
|
+
throwIfReplayStopped();
|
|
706
840
|
guard += 1;
|
|
707
841
|
if (guard > maxVisits) throw new Error('流程图可能存在循环,已终止回放');
|
|
708
842
|
|
|
@@ -715,13 +849,31 @@ async function replayFlowSteps(
|
|
|
715
849
|
visitedPath.push(step.label);
|
|
716
850
|
const nodeKind = step.flow?.nodeKind || (step.type === 'assertExists' || step.type === 'assertText' ? 'assertion' : 'action');
|
|
717
851
|
lines.push(`[节点 ${index + 1}] 开始:${step.label}`);
|
|
852
|
+
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'before', '执行前');
|
|
718
853
|
|
|
719
854
|
if (nodeKind === 'condition') {
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
855
|
+
try {
|
|
856
|
+
const matched = await evaluateCondition(sessionId, deviceId, step);
|
|
857
|
+
const branchTargetId = matched ? step.flow?.yesTargetId : step.flow?.noTargetId;
|
|
858
|
+
const targetId = branchTargetId || step.flow?.successTargetId || '';
|
|
859
|
+
lines.push(`[节点 ${index + 1}] 判断:${matched ? '是' : '否'}${targetId ? `,进入 ${targetId}` : ',流程结束'}`);
|
|
860
|
+
await captureReplayFrame(
|
|
861
|
+
sessionId,
|
|
862
|
+
step,
|
|
863
|
+
index + 1,
|
|
864
|
+
options.scriptName || '',
|
|
865
|
+
'after',
|
|
866
|
+
`判断:${matched ? '是' : '否'}`,
|
|
867
|
+
);
|
|
868
|
+
index = targetId ? idToIndex.get(targetId) : undefined;
|
|
869
|
+
} catch (error) {
|
|
870
|
+
if (isReplayStopped(error)) {
|
|
871
|
+
lines.push(`[节点 ${index + 1}] 已终止:${step.label}`);
|
|
872
|
+
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'stopped', '已终止');
|
|
873
|
+
throw new ReplayStoppedError();
|
|
874
|
+
}
|
|
875
|
+
throw error;
|
|
876
|
+
}
|
|
725
877
|
continue;
|
|
726
878
|
}
|
|
727
879
|
|
|
@@ -733,20 +885,29 @@ async function replayFlowSteps(
|
|
|
733
885
|
if (result) lines.push(`[节点 ${index + 1}] 结果:${result}`);
|
|
734
886
|
}
|
|
735
887
|
lines.push(`[节点 ${index + 1}] 完成:${step.label}`);
|
|
888
|
+
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'after', '成功');
|
|
736
889
|
index = nextIndexAfterStep(index, step);
|
|
737
890
|
} catch (error) {
|
|
891
|
+
if (isReplayStopped(error)) {
|
|
892
|
+
lines.push(`[节点 ${index + 1}] 已终止:${step.label}`);
|
|
893
|
+
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'stopped', '已终止');
|
|
894
|
+
throw new ReplayStoppedError();
|
|
895
|
+
}
|
|
738
896
|
if (step.optional) {
|
|
739
897
|
lines.push(`[节点 ${index + 1}] 跳过:${errorDetail(error)}`);
|
|
898
|
+
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'error', '已跳过');
|
|
740
899
|
index = nextIndexAfterStep(index, step);
|
|
741
900
|
continue;
|
|
742
901
|
}
|
|
743
902
|
const failureTarget = step.flow?.failureTargetId;
|
|
744
903
|
if (failureTarget && idToIndex.has(failureTarget)) {
|
|
745
904
|
lines.push(`[节点 ${index + 1}] 失败分支:${errorDetail(error)}`);
|
|
905
|
+
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'error', '进入失败分支');
|
|
746
906
|
index = idToIndex.get(failureTarget);
|
|
747
907
|
continue;
|
|
748
908
|
}
|
|
749
909
|
lines.push(`[节点 ${index + 1}] 失败:${errorDetail(error)}`);
|
|
910
|
+
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'error', '失败');
|
|
750
911
|
lines.push(`执行路径:${visitedPath.join(' -> ')}`);
|
|
751
912
|
await appendStepDiagnostics(lines, deviceId, step);
|
|
752
913
|
throw error;
|
|
@@ -791,9 +952,25 @@ async function replayScriptSteps(
|
|
|
791
952
|
const trailingLinkedSteps = steps.slice(trailingLinkIndex);
|
|
792
953
|
await replayStepGroup(sessionId, deviceId, mainSteps, lines, stack, options);
|
|
793
954
|
|
|
794
|
-
for (const step of trailingLinkedSteps) {
|
|
955
|
+
for (const [offset, step] of trailingLinkedSteps.entries()) {
|
|
956
|
+
const nodeNumber = trailingLinkIndex + offset + 1;
|
|
795
957
|
lines.push(`当前脚本步骤完成,开始执行:${step.label}`);
|
|
796
|
-
|
|
958
|
+
lines.push(`[节点 ${nodeNumber}] 开始:${step.label}`);
|
|
959
|
+
await captureReplayFrame(sessionId, step, nodeNumber, options.scriptName || '', 'before', '执行前');
|
|
960
|
+
try {
|
|
961
|
+
await replayLinkedScript(sessionId, deviceId, step, lines, stack);
|
|
962
|
+
lines.push(`[节点 ${nodeNumber}] 完成:${step.label}`);
|
|
963
|
+
await captureReplayFrame(sessionId, step, nodeNumber, options.scriptName || '', 'after', '成功');
|
|
964
|
+
} catch (error) {
|
|
965
|
+
if (isReplayStopped(error)) {
|
|
966
|
+
lines.push(`[节点 ${nodeNumber}] 已终止:${step.label}`);
|
|
967
|
+
await captureReplayFrame(sessionId, step, nodeNumber, options.scriptName || '', 'stopped', '已终止');
|
|
968
|
+
throw new ReplayStoppedError();
|
|
969
|
+
}
|
|
970
|
+
lines.push(`[节点 ${nodeNumber}] 失败:${errorDetail(error)}`);
|
|
971
|
+
await captureReplayFrame(sessionId, step, nodeNumber, options.scriptName || '', 'error', '失败');
|
|
972
|
+
throw error;
|
|
973
|
+
}
|
|
797
974
|
}
|
|
798
975
|
}
|
|
799
976
|
|
|
@@ -801,6 +978,7 @@ export async function replayAppiumScript(
|
|
|
801
978
|
script: AppiumRecordedScriptRecord,
|
|
802
979
|
deviceId: string,
|
|
803
980
|
onOutput?: (line: string) => void,
|
|
981
|
+
signal?: AbortSignal,
|
|
804
982
|
) {
|
|
805
983
|
const targetDeviceId = deviceId || script.deviceId;
|
|
806
984
|
if (!targetDeviceId) throw new Error('未检测到可用设备');
|
|
@@ -814,64 +992,122 @@ export async function replayAppiumScript(
|
|
|
814
992
|
items.forEach((line) => onOutput?.(line));
|
|
815
993
|
return length;
|
|
816
994
|
};
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
995
|
+
const context = {
|
|
996
|
+
signal,
|
|
997
|
+
appiumLog: (line) => lines.push(line),
|
|
998
|
+
serverUrl: configuredAppiumServerUrl(),
|
|
999
|
+
frames: [] as AppiumReplayFrame[],
|
|
1000
|
+
};
|
|
1001
|
+
return replayContext.run(context, async () => {
|
|
1002
|
+
lines.push(
|
|
1003
|
+
`目标设备:${targetDeviceId}`,
|
|
1004
|
+
`App 包名:${script.appPackage}`,
|
|
1005
|
+
`录制步骤:${script.steps.length}`,
|
|
1006
|
+
);
|
|
1007
|
+
let sessionId = '';
|
|
1008
|
+
let success = false;
|
|
1009
|
+
let stopped = false;
|
|
1010
|
+
let managedAppium: Awaited<ReturnType<typeof startManagedAppiumServer>> | null = null;
|
|
830
1011
|
try {
|
|
831
|
-
|
|
1012
|
+
lines.push('正在检测 Android SDK...');
|
|
1013
|
+
const androidSdk = ensureAndroidSdkAvailable();
|
|
1014
|
+
lines.push(`Android SDK 已就绪:${androidSdk.root}`);
|
|
1015
|
+
if (usesManagedAppiumServer()) {
|
|
1016
|
+
lines.push('正在启动本次回放的 Appium 服务...');
|
|
1017
|
+
lines.push('----- Appium 服务端原始日志开始 -----');
|
|
1018
|
+
managedAppium = await startManagedAppiumServer((line) => lines.push(line), signal);
|
|
1019
|
+
context.serverUrl = managedAppium.serverUrl;
|
|
1020
|
+
}
|
|
1021
|
+
lines.push(`Appium 服务:${appiumServerUrl()}`);
|
|
1022
|
+
lines.push('正在创建 Appium session...');
|
|
1023
|
+
try {
|
|
1024
|
+
sessionId = await createSession(targetDeviceId);
|
|
1025
|
+
} catch (error) {
|
|
1026
|
+
throwIfReplayStopped(error);
|
|
1027
|
+
if (!isUiAutomationDisconnected(error)) throw error;
|
|
1028
|
+
lines.push('检测到 UiAutomation 连接冲突,正在清理残留进程并重试...');
|
|
1029
|
+
await resetUiAutomator2(targetDeviceId);
|
|
1030
|
+
sessionId = await createSession(targetDeviceId);
|
|
1031
|
+
}
|
|
1032
|
+
lines.push(`Appium session 已创建:${sessionId}`);
|
|
1033
|
+
if (hasFlowSteps(script.steps)) lines.push('按流程图路径回放...');
|
|
1034
|
+
await replayScriptSteps(sessionId, targetDeviceId, script.steps, lines, [script.id], {
|
|
1035
|
+
scriptName: script.name,
|
|
1036
|
+
});
|
|
1037
|
+
lines.push('回放完成');
|
|
1038
|
+
success = true;
|
|
832
1039
|
} catch (error) {
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
1040
|
+
stopped = isReplayStopped(error);
|
|
1041
|
+
const detail = errorDetail(error);
|
|
1042
|
+
if (stopped) {
|
|
1043
|
+
lines.push('回放已终止:用户手动终止');
|
|
1044
|
+
} else {
|
|
1045
|
+
if (detail.includes('Appium Settings app is not running')) {
|
|
1046
|
+
await appendSettingsDiagnostics(lines, targetDeviceId);
|
|
1047
|
+
}
|
|
1048
|
+
if (detail.includes('Neither ANDROID_HOME nor ANDROID_SDK_ROOT')) {
|
|
1049
|
+
appendAndroidSdkDiagnostics(lines);
|
|
1050
|
+
}
|
|
1051
|
+
lines.push(`回放终止:${detail}`);
|
|
1052
|
+
}
|
|
1053
|
+
} finally {
|
|
1054
|
+
let managedAppiumStopped = false;
|
|
1055
|
+
if (signal?.aborted && managedAppium) {
|
|
1056
|
+
await managedAppium.stop();
|
|
1057
|
+
managedAppiumStopped = true;
|
|
1058
|
+
} else if (sessionId) {
|
|
1059
|
+
await appiumRequest(
|
|
1060
|
+
`/session/${sessionId}`,
|
|
1061
|
+
{ method: 'DELETE' },
|
|
1062
|
+
{ ignoreAbort: true, timeoutMs: 2000 },
|
|
1063
|
+
).catch(() => undefined);
|
|
1064
|
+
}
|
|
1065
|
+
if (managedAppium) {
|
|
1066
|
+
if (!managedAppiumStopped) await managedAppium.stop();
|
|
1067
|
+
lines.push('----- Appium 服务端原始日志结束 -----');
|
|
1068
|
+
}
|
|
850
1069
|
}
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
1070
|
+
|
|
1071
|
+
if (signal?.aborted && !stopped) {
|
|
1072
|
+
stopped = true;
|
|
1073
|
+
success = false;
|
|
1074
|
+
lines.push('回放已终止:用户手动终止');
|
|
855
1075
|
}
|
|
856
|
-
}
|
|
857
1076
|
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
1077
|
+
const completedAt = new Date();
|
|
1078
|
+
let reportPath = '';
|
|
1079
|
+
let reportId = '';
|
|
1080
|
+
let logPath = '';
|
|
1081
|
+
let htmlReportPath = '';
|
|
1082
|
+
try {
|
|
1083
|
+
const report = await createAppiumReplayReport({
|
|
1084
|
+
script,
|
|
1085
|
+
deviceId: targetDeviceId,
|
|
1086
|
+
success,
|
|
1087
|
+
stopped,
|
|
1088
|
+
output: lines.join('\n'),
|
|
1089
|
+
startedAt,
|
|
1090
|
+
completedAt,
|
|
1091
|
+
frames: context.frames,
|
|
1092
|
+
});
|
|
1093
|
+
reportPath = report.filePath;
|
|
1094
|
+
reportId = report.id;
|
|
1095
|
+
logPath = report.logPath;
|
|
1096
|
+
htmlReportPath = report.htmlReportPath;
|
|
1097
|
+
lines.push(`回放报告已生成:${reportPath}`);
|
|
1098
|
+
lines.push(`截图回放已生成:${report.htmlReportPath}`);
|
|
1099
|
+
lines.push(`回放日志已生成:${logPath}`);
|
|
1100
|
+
} catch (error) {
|
|
1101
|
+
lines.push(`回放报告和日志生成失败:${errorDetail(error)}`);
|
|
1102
|
+
}
|
|
1103
|
+
return {
|
|
865
1104
|
success,
|
|
1105
|
+
stopped,
|
|
866
1106
|
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 };
|
|
1107
|
+
reportPath,
|
|
1108
|
+
reportId,
|
|
1109
|
+
logPath,
|
|
1110
|
+
htmlReportPath,
|
|
1111
|
+
};
|
|
1112
|
+
});
|
|
877
1113
|
}
|