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.
@@ -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,40 @@ 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>(
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 ${init?.method || 'GET'} ${path} 失败(HTTP ${response.status}):\n${detail || '未返回错误详情'}`);
260
+ throw new Error(`Appium ${method} ${path} 失败(HTTP ${response.status}):\n${detail || '未返回错误详情'}`);
203
261
  }
204
262
  return payload;
205
263
  }
206
264
 
207
- async function createSession(script: AppiumRecordedScriptRecord, deviceId: string) {
208
- const appAlreadyForeground = await isAppInForeground(deviceId, script.appPackage).catch(() => false);
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:appPackage': script.appPackage,
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
- 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;
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
- await replayLinkedScript(sessionId, deviceId, step, lines, stack);
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
- 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...');
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
- sessionId = await createSession(script, targetDeviceId);
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
- 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);
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
- lines.push(`回放终止:${detail}`);
852
- } finally {
853
- if (sessionId) {
854
- await appiumRequest(`/session/${sessionId}`, { method: 'DELETE' }).catch(() => undefined);
1070
+
1071
+ if (signal?.aborted && !stopped) {
1072
+ stopped = true;
1073
+ success = false;
1074
+ lines.push('回放已终止:用户手动终止');
855
1075
  }
856
- }
857
1076
 
858
- const completedAt = new Date();
859
- let reportPath = '';
860
- let reportId = '';
861
- try {
862
- const report = await createAppiumReplayReport({
863
- script,
864
- deviceId: targetDeviceId,
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
- 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 };
1107
+ reportPath,
1108
+ reportId,
1109
+ logPath,
1110
+ htmlReportPath,
1111
+ };
1112
+ });
877
1113
  }