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.
@@ -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 appiumServerUrl = () => (process.env.APPIUM_SERVER_URL || 'http://127.0.0.1:4723').replace(/\/+$/, '');
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
- return new Promise((resolve) => setTimeout(resolve, ms));
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 ${init?.method || 'GET'} ${path} 失败(HTTP ${response.status}):\n${detail || '未返回错误详情'}`);
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
- const matched = await evaluateCondition(sessionId, deviceId, step);
721
- const branchTargetId = matched ? step.flow?.yesTargetId : step.flow?.noTargetId;
722
- const targetId = branchTargetId || step.flow?.successTargetId || '';
723
- lines.push(`[节点 ${index + 1}] 判断:${matched ? '是' : '否'}${targetId ? `,进入 ${targetId}` : ',流程结束'}`);
724
- index = targetId ? idToIndex.get(targetId) : undefined;
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
- await replayLinkedScript(sessionId, deviceId, step, lines, stack);
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
- lines.push(
818
- `Appium 服务:${appiumServerUrl()}`,
819
- `目标设备:${targetDeviceId}`,
820
- `App 包名:${script.appPackage}`,
821
- `录制步骤:${script.steps.length}`,
822
- );
823
- let sessionId = '';
824
- let success = false;
825
- try {
826
- lines.push('正在检测 Android SDK...');
827
- const androidSdk = ensureAndroidSdkAvailable();
828
- lines.push(`Android SDK 已就绪:${androidSdk.root}`);
829
- lines.push('正在创建 Appium session...');
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
- sessionId = await createSession(script, targetDeviceId);
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
- if (!isUiAutomationDisconnected(error)) throw error;
834
- lines.push('检测到 UiAutomation 连接冲突,正在清理残留进程并重试...');
835
- await resetUiAutomator2(targetDeviceId);
836
- sessionId = await createSession(script, targetDeviceId);
837
- }
838
- lines.push(`Appium session 已创建:${sessionId}`);
839
- if (hasFlowSteps(script.steps)) lines.push('按流程图路径回放...');
840
- await replayScriptSteps(sessionId, targetDeviceId, script.steps, lines, [script.id]);
841
- lines.push('回放完成');
842
- success = true;
843
- } catch (error) {
844
- const detail = errorDetail(error);
845
- if (detail.includes('Appium Settings app is not running')) {
846
- await appendSettingsDiagnostics(lines, targetDeviceId);
847
- }
848
- if (detail.includes('Neither ANDROID_HOME nor ANDROID_SDK_ROOT')) {
849
- appendAndroidSdkDiagnostics(lines);
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
- lines.push(`回放终止:${detail}`);
852
- } finally {
853
- if (sessionId) {
854
- await appiumRequest(`/session/${sessionId}`, { method: 'DELETE' }).catch(() => undefined);
1049
+
1050
+ if (signal?.aborted && !stopped) {
1051
+ stopped = true;
1052
+ success = false;
1053
+ lines.push('回放已终止:用户手动终止');
855
1054
  }
856
- }
857
1055
 
858
- const completedAt = new Date();
859
- let reportPath = '';
860
- let reportId = '';
861
- try {
862
- const report = await createAppiumReplayReport({
863
- script,
864
- deviceId: targetDeviceId,
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
- startedAt,
868
- completedAt,
869
- });
870
- reportPath = report.filePath;
871
- reportId = report.id;
872
- lines.push(`回放报告已生成:${reportPath}`);
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
  }