android-midscene-automation 0.1.13 → 0.1.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "android-midscene-automation",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "android-midscene-automation": "./bin/android-midscene-automation.js"
@@ -14,6 +14,7 @@
14
14
  "tsconfig*.json",
15
15
  "vite.config.ts",
16
16
  "README.md",
17
+ "USAGE.md",
17
18
  "CHANGELOG.md"
18
19
  ],
19
20
  "scripts": {
@@ -0,0 +1,104 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { loadConfig } from './config';
5
+ import { appDataPath } from './paths';
6
+
7
+ export type AndroidSdkInfo = {
8
+ root: string;
9
+ adbPath: string;
10
+ source: 'config' | 'environment' | 'default' | 'path';
11
+ };
12
+
13
+ const adbFileName = process.platform === 'win32' ? 'adb.exe' : 'adb';
14
+
15
+ function expandPath(value: string) {
16
+ const trimmed = value.trim().replace(/^(["'])(.*)\1$/, '$2');
17
+ if (trimmed === '~') return os.homedir();
18
+ if (trimmed.startsWith(`~${path.sep}`) || trimmed.startsWith('~/')) {
19
+ return path.join(os.homedir(), trimmed.slice(2));
20
+ }
21
+ return path.isAbsolute(trimmed) ? path.normalize(trimmed) : appDataPath(trimmed);
22
+ }
23
+
24
+ function sdkInfoFromPath(value: string, source: AndroidSdkInfo['source']): AndroidSdkInfo | null {
25
+ const candidate = expandPath(value);
26
+ const lowerBaseName = path.basename(candidate).toLowerCase();
27
+ const root = lowerBaseName === adbFileName.toLowerCase()
28
+ ? path.dirname(path.dirname(candidate))
29
+ : lowerBaseName === 'platform-tools'
30
+ ? path.dirname(candidate)
31
+ : candidate;
32
+ const adbPath = path.join(root, 'platform-tools', adbFileName);
33
+ if (!fs.existsSync(adbPath)) return null;
34
+ return { root, adbPath, source };
35
+ }
36
+
37
+ function findAdbOnPath() {
38
+ const directories = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
39
+ for (const directory of directories) {
40
+ const adbPath = path.join(directory.replace(/^(["'])(.*)\1$/, '$2'), adbFileName);
41
+ if (fs.existsSync(adbPath)) return fs.realpathSync(adbPath);
42
+ }
43
+ return '';
44
+ }
45
+
46
+ function defaultSdkPaths() {
47
+ if (process.platform === 'darwin') return [path.join(os.homedir(), 'Library', 'Android', 'sdk')];
48
+ if (process.platform === 'win32') {
49
+ return process.env.LOCALAPPDATA ? [path.join(process.env.LOCALAPPDATA, 'Android', 'Sdk')] : [];
50
+ }
51
+ return [path.join(os.homedir(), 'Android', 'Sdk')];
52
+ }
53
+
54
+ export function resolveAndroidSdk(): AndroidSdkInfo {
55
+ const configuredPath = loadConfig().runtime.androidSdkPath.trim();
56
+ if (configuredPath) {
57
+ const configured = sdkInfoFromPath(configuredPath, 'config');
58
+ if (configured) return configured;
59
+ throw new Error(`参数配置中的 Android SDK 路径无效:${configuredPath}。请选择包含 platform-tools/${adbFileName} 的 SDK 目录。`);
60
+ }
61
+
62
+ const candidates = [process.env.ANDROID_SDK_ROOT, process.env.ANDROID_HOME]
63
+ .filter((value): value is string => Boolean(value));
64
+ for (const candidate of candidates) {
65
+ const resolved = sdkInfoFromPath(candidate, 'environment');
66
+ if (resolved) return resolved;
67
+ }
68
+
69
+ for (const candidate of defaultSdkPaths()) {
70
+ const resolved = sdkInfoFromPath(candidate, 'default');
71
+ if (resolved) return resolved;
72
+ }
73
+
74
+ const adbPath = findAdbOnPath();
75
+ if (adbPath) {
76
+ return {
77
+ root: path.dirname(path.dirname(adbPath)),
78
+ adbPath,
79
+ source: 'path',
80
+ };
81
+ }
82
+
83
+ throw new Error('未检测到 Android SDK。请在“参数配置 > 运行配置”中指定 Android SDK 路径,或配置 ANDROID_SDK_ROOT/ANDROID_HOME。');
84
+ }
85
+
86
+ export function ensureAndroidSdkAvailable() {
87
+ const sdk = resolveAndroidSdk();
88
+ process.env.ANDROID_HOME = sdk.root;
89
+ process.env.ANDROID_SDK_ROOT = sdk.root;
90
+ const platformTools = path.dirname(sdk.adbPath);
91
+ const pathEntries = (process.env.PATH || '').split(path.delimiter);
92
+ if (!pathEntries.includes(platformTools)) {
93
+ process.env.PATH = [platformTools, ...pathEntries].filter(Boolean).join(path.delimiter);
94
+ }
95
+ return sdk;
96
+ }
97
+
98
+ export function getAdbCommand() {
99
+ try {
100
+ return ensureAndroidSdkAvailable().adbPath;
101
+ } catch {
102
+ return process.platform === 'win32' ? 'adb.exe' : 'adb';
103
+ }
104
+ }
@@ -3,6 +3,8 @@ import { mkdir, writeFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { getAppiumRecordedScript, type AppiumRecordedScriptRecord, type AppiumRecordedStepRecord } from './repository';
5
5
  import { appDataPath } from '../paths';
6
+ import { ensureAndroidSdkAvailable, getAdbCommand } from '../android-sdk';
7
+ import { createAppiumReplayReport } from './report';
6
8
 
7
9
  type AppiumSessionResponse = {
8
10
  value?: {
@@ -41,9 +43,13 @@ function wait(ms: number) {
41
43
  return new Promise((resolve) => setTimeout(resolve, ms));
42
44
  }
43
45
 
46
+ function normalizeTextForContains(value: string) {
47
+ return value.replace(/\s+/g, ' ').trim();
48
+ }
49
+
44
50
  function adbTap(deviceId: string, x: number, y: number) {
45
51
  return new Promise<void>((resolve, reject) => {
46
- execFile('adb', ['-s', deviceId, 'shell', 'input', 'tap', String(Math.round(x)), String(Math.round(y))], (error, _stdout, stderr) => {
52
+ execFile(getAdbCommand(), ['-s', deviceId, 'shell', 'input', 'tap', String(Math.round(x)), String(Math.round(y))], (error, _stdout, stderr) => {
47
53
  if (error) {
48
54
  reject(new Error(stderr || error.message));
49
55
  return;
@@ -53,10 +59,10 @@ function adbTap(deviceId: string, x: number, y: number) {
53
59
  });
54
60
  }
55
61
 
56
- function adbLaunchApp(deviceId: string, packageName: string) {
62
+ export function launchAppOnDevice(deviceId: string, packageName: string) {
57
63
  if (!packageName) return Promise.reject(new Error('启动 APP 缺少包名'));
58
64
  return new Promise<void>((resolve, reject) => {
59
- execFile('adb', [
65
+ execFile(getAdbCommand(), [
60
66
  '-s',
61
67
  deviceId,
62
68
  'shell',
@@ -79,7 +85,7 @@ function adbLaunchApp(deviceId: string, packageName: string) {
79
85
 
80
86
  function adbSwipe(deviceId: string, step: Required<AppiumRecordedStepRecord>['swipe']) {
81
87
  return new Promise<void>((resolve, reject) => {
82
- execFile('adb', [
88
+ execFile(getAdbCommand(), [
83
89
  '-s',
84
90
  deviceId,
85
91
  'shell',
@@ -102,7 +108,7 @@ function adbSwipe(deviceId: string, step: Required<AppiumRecordedStepRecord>['sw
102
108
 
103
109
  function adbText(deviceId: string, args: string[]) {
104
110
  return new Promise<string>((resolve) => {
105
- execFile('adb', ['-s', deviceId, ...args], { maxBuffer: 4 * 1024 * 1024 }, (_error, stdout, stderr) => {
111
+ execFile(getAdbCommand(), ['-s', deviceId, ...args], { maxBuffer: 4 * 1024 * 1024 }, (_error, stdout, stderr) => {
106
112
  resolve((stdout || stderr || '').trim());
107
113
  });
108
114
  });
@@ -116,6 +122,12 @@ async function getCurrentActivity(deviceId: string) {
116
122
  return resumedLine?.match(/\s([\w.$]+\/[\w.$]+)\s/)?.[1] || '';
117
123
  }
118
124
 
125
+ async function isAppInForeground(deviceId: string, packageName: string) {
126
+ if (!packageName) return false;
127
+ const currentActivity = await getCurrentActivity(deviceId);
128
+ return currentActivity.split('/')[0] === packageName;
129
+ }
130
+
119
131
  async function waitForActivity(deviceId: string, step: AppiumRecordedStepRecord) {
120
132
  const expectedActivity = step.value || '';
121
133
  if (!expectedActivity) throw new Error(`${step.label} 缺少目标 Activity`);
@@ -143,12 +155,13 @@ async function appendSettingsDiagnostics(lines: string[], deviceId: string) {
143
155
 
144
156
  function appendAndroidSdkDiagnostics(lines: string[]) {
145
157
  lines.push('Android SDK 环境变量未配置:');
158
+ lines.push('- 可在本项目“参数配置 > 运行配置”中指定 Android SDK 路径。');
146
159
  lines.push('- Windows 常见 SDK 路径:%LOCALAPPDATA%\\Android\\Sdk');
147
160
  lines.push('- PowerShell 设置示例:');
148
161
  lines.push(' [Environment]::SetEnvironmentVariable("ANDROID_HOME", "$env:LOCALAPPDATA\\Android\\Sdk", "User")');
149
162
  lines.push(' [Environment]::SetEnvironmentVariable("ANDROID_SDK_ROOT", "$env:LOCALAPPDATA\\Android\\Sdk", "User")');
150
163
  lines.push(' [Environment]::SetEnvironmentVariable("Path", $env:Path + ";$env:LOCALAPPDATA\\Android\\Sdk\\platform-tools", "User")');
151
- lines.push('- 设置后请重新打开终端,并重启 appium 服务。');
164
+ lines.push('- Appium 是独立进程时不会继承网页中后设置的环境变量;设置后请重新打开终端,并重启 appium 服务。');
152
165
  }
153
166
 
154
167
  async function appiumRequest<T>(path: string, init?: RequestInit) {
@@ -192,6 +205,7 @@ async function appiumRequest<T>(path: string, init?: RequestInit) {
192
205
  }
193
206
 
194
207
  async function createSession(script: AppiumRecordedScriptRecord, deviceId: string) {
208
+ const appAlreadyForeground = await isAppInForeground(deviceId, script.appPackage).catch(() => false);
195
209
  const payload = await appiumRequest<AppiumSessionResponse>('/session', {
196
210
  method: 'POST',
197
211
  body: JSON.stringify({
@@ -203,6 +217,7 @@ async function createSession(script: AppiumRecordedScriptRecord, deviceId: strin
203
217
  'appium:appPackage': script.appPackage,
204
218
  ...(script.appActivity ? { 'appium:appActivity': script.appActivity } : {}),
205
219
  'appium:noReset': true,
220
+ ...(appAlreadyForeground ? { 'appium:autoLaunch': false } : {}),
206
221
  'appium:skipDeviceInitialization': true,
207
222
  'appium:ignoreHiddenApiPolicyError': true,
208
223
  },
@@ -214,6 +229,17 @@ async function createSession(script: AppiumRecordedScriptRecord, deviceId: strin
214
229
  return sessionId;
215
230
  }
216
231
 
232
+ function isUiAutomationDisconnected(error: unknown) {
233
+ return errorDetail(error).includes('UiAutomation not connected');
234
+ }
235
+
236
+ async function resetUiAutomator2(deviceId: string) {
237
+ await adbText(deviceId, ['shell', 'pkill', '-f', 'com.android.commands.uiautomator.Launcher']);
238
+ await adbText(deviceId, ['shell', 'am', 'force-stop', 'io.appium.uiautomator2.server']);
239
+ await adbText(deviceId, ['shell', 'am', 'force-stop', 'io.appium.uiautomator2.server.test']);
240
+ await wait(800);
241
+ }
242
+
217
243
  function toAppiumUsing(selector: NonNullable<AppiumRecordedStepRecord['selector']>) {
218
244
  if (selector.strategy === 'accessibilityId') return { using: 'accessibility id', value: selector.value || '' };
219
245
  if (selector.strategy === 'id') return { using: 'id', value: selector.value || '' };
@@ -351,7 +377,10 @@ async function runStep(sessionId: string, deviceId: string, step: AppiumRecorded
351
377
 
352
378
  if (step.type === 'launchApp') {
353
379
  const packageName = step.value || '';
354
- await adbLaunchApp(deviceId, packageName);
380
+ if (await isAppInForeground(deviceId, packageName).catch(() => false)) {
381
+ return `APP 已在前台,跳过启动 ${packageName}`;
382
+ }
383
+ await launchAppOnDevice(deviceId, packageName);
355
384
  return `ADB 已启动 ${packageName}`;
356
385
  }
357
386
 
@@ -517,10 +546,14 @@ async function evaluateCondition(sessionId: string, deviceId: string, step: Appi
517
546
  await waitForElementGone(sessionId, step);
518
547
  return true;
519
548
  }
520
- if (step.type === 'assertText') {
549
+ if (step.type === 'assertText' || (step.type === 'assertExists' && step.value)) {
521
550
  const elementId = await findElement(sessionId, step);
522
551
  const payload = await appiumRequest<AppiumValueResponse<string>>(`/session/${sessionId}/element/${elementId}/text`);
523
- return (payload.value || '').includes(step.value || '');
552
+ const actualText = payload.value || '';
553
+ const expectedText = step.value || '';
554
+ return step.flow?.textMatch === 'exact'
555
+ ? actualText === expectedText
556
+ : normalizeTextForContains(actualText).includes(normalizeTextForContains(expectedText));
524
557
  }
525
558
  await waitForElement(sessionId, step);
526
559
  return true;
@@ -574,12 +607,26 @@ async function replayLinkedScript(
574
607
  const linkedScript = getAppiumRecordedScript(scriptId);
575
608
  if (!linkedScript) throw new Error(`${step.label} 指向的脚本不存在`);
576
609
 
577
- const currentActivity = await getCurrentActivity(deviceId).catch(() => '');
578
- if (linkedScript.appActivity && currentActivity !== linkedScript.appActivity) {
579
- throw new Error(
580
- `${step.label} 无法执行:当前 Activity 为 ${currentActivity || '-'},`
581
- + `目标脚本入口 Activity 为 ${linkedScript.appActivity}`,
582
- );
610
+ if (linkedScript.appActivity) {
611
+ const currentActivity = await getCurrentActivity(deviceId).catch(() => '');
612
+ if (currentActivity !== linkedScript.appActivity) {
613
+ lines.push(`等待连接脚本入口 Activity:${linkedScript.appActivity}`);
614
+ try {
615
+ await waitForActivity(deviceId, {
616
+ id: `${step.id}_wait_activity`,
617
+ type: 'waitActivity',
618
+ label: step.label,
619
+ value: linkedScript.appActivity,
620
+ timeoutMs: 10000,
621
+ });
622
+ } catch {
623
+ const latestActivity = await getCurrentActivity(deviceId).catch(() => currentActivity);
624
+ throw new Error(
625
+ `${step.label} 无法执行:当前 Activity 为 ${latestActivity || '-'},`
626
+ + `目标脚本入口 Activity 为 ${linkedScript.appActivity}`,
627
+ );
628
+ }
629
+ }
583
630
  }
584
631
 
585
632
  lines.push(`连接脚本开始:${linkedScript.name}`);
@@ -600,24 +647,24 @@ async function replayLinearSteps(
600
647
  ) {
601
648
  for (const [index, step] of steps.entries()) {
602
649
  if (options.skipLaunchApp && step.type === 'launchApp') {
603
- lines.push(`[步骤 ${index + 1}] 跳过:${step.label}(连接脚本不重复启动 App)`);
650
+ lines.push(`[节点 ${index + 1}] 跳过:${step.label}(连接脚本不重复启动 App)`);
604
651
  continue;
605
652
  }
606
- lines.push(`[步骤 ${index + 1}] 开始:${step.label}`);
653
+ lines.push(`[节点 ${index + 1}] 开始:${step.label}`);
607
654
  try {
608
655
  if (step.type === 'runScript') {
609
656
  await replayLinkedScript(sessionId, deviceId, step, lines, stack);
610
657
  } else {
611
658
  const result = await runStep(sessionId, deviceId, step);
612
- if (result) lines.push(`[步骤 ${index + 1}] 结果:${result}`);
659
+ if (result) lines.push(`[节点 ${index + 1}] 结果:${result}`);
613
660
  }
614
- lines.push(`[步骤 ${index + 1}] 完成:${step.label}`);
661
+ lines.push(`[节点 ${index + 1}] 完成:${step.label}`);
615
662
  } catch (error) {
616
663
  if (step.optional) {
617
- lines.push(`[步骤 ${index + 1}] 跳过:${errorDetail(error)}`);
664
+ lines.push(`[节点 ${index + 1}] 跳过:${errorDetail(error)}`);
618
665
  continue;
619
666
  }
620
- lines.push(`[步骤 ${index + 1}] 失败:${errorDetail(error)}`);
667
+ lines.push(`[节点 ${index + 1}] 失败:${errorDetail(error)}`);
621
668
  await appendStepDiagnostics(lines, deviceId, step);
622
669
  throw error;
623
670
  }
@@ -633,6 +680,23 @@ async function replayFlowSteps(
633
680
  options: ReplayStepOptions = {},
634
681
  ) {
635
682
  const idToIndex = new Map(steps.map((step, index) => [step.id, index]));
683
+ const conditionContinuationIndex = (conditionId: string) => {
684
+ const conditionIndex = idToIndex.get(conditionId);
685
+ if (conditionIndex === undefined) return undefined;
686
+ const explicitTargetId = steps[conditionIndex]?.flow?.successTargetId;
687
+ return explicitTargetId ? idToIndex.get(explicitTargetId) : undefined;
688
+ };
689
+ const nextIndexAfterStep = (currentIndex: number, step: AppiumRecordedStepRecord) => {
690
+ const explicitTargetId = step.flow?.successTargetId;
691
+ if (explicitTargetId) return idToIndex.get(explicitTargetId);
692
+ if (!step.flow?.parentConditionId || !step.flow.parentBranch) return currentIndex + 1;
693
+ const nextStep = steps[currentIndex + 1];
694
+ const staysInBranch = nextStep?.flow?.parentConditionId === step.flow.parentConditionId
695
+ && nextStep.flow.parentBranch === step.flow.parentBranch;
696
+ return staysInBranch
697
+ ? currentIndex + 1
698
+ : conditionContinuationIndex(step.flow.parentConditionId);
699
+ };
636
700
  const visitedPath: string[] = [];
637
701
  let index: number | undefined = 0;
638
702
  let guard = 0;
@@ -645,8 +709,7 @@ async function replayFlowSteps(
645
709
  const step = steps[index];
646
710
  if (options.skipLaunchApp && step.type === 'launchApp') {
647
711
  lines.push(`[节点 ${index + 1}] 跳过:${step.label}(连接脚本不重复启动 App)`);
648
- const targetId = step.flow?.successTargetId;
649
- index = targetId ? idToIndex.get(targetId) : index + 1;
712
+ index = nextIndexAfterStep(index, step);
650
713
  continue;
651
714
  }
652
715
  visitedPath.push(step.label);
@@ -655,9 +718,10 @@ async function replayFlowSteps(
655
718
 
656
719
  if (nodeKind === 'condition') {
657
720
  const matched = await evaluateCondition(sessionId, deviceId, step);
658
- const targetId = matched ? step.flow?.yesTargetId : step.flow?.noTargetId;
721
+ const branchTargetId = matched ? step.flow?.yesTargetId : step.flow?.noTargetId;
722
+ const targetId = branchTargetId || step.flow?.successTargetId || '';
659
723
  lines.push(`[节点 ${index + 1}] 判断:${matched ? '是' : '否'}${targetId ? `,进入 ${targetId}` : ',流程结束'}`);
660
- index = targetId ? idToIndex.get(targetId) : (matched ? index + 1 : undefined);
724
+ index = targetId ? idToIndex.get(targetId) : undefined;
661
725
  continue;
662
726
  }
663
727
 
@@ -669,12 +733,11 @@ async function replayFlowSteps(
669
733
  if (result) lines.push(`[节点 ${index + 1}] 结果:${result}`);
670
734
  }
671
735
  lines.push(`[节点 ${index + 1}] 完成:${step.label}`);
672
- const targetId = step.flow?.successTargetId;
673
- index = targetId ? idToIndex.get(targetId) : index + 1;
736
+ index = nextIndexAfterStep(index, step);
674
737
  } catch (error) {
675
738
  if (step.optional) {
676
739
  lines.push(`[节点 ${index + 1}] 跳过:${errorDetail(error)}`);
677
- index += 1;
740
+ index = nextIndexAfterStep(index, step);
678
741
  continue;
679
742
  }
680
743
  const failureTarget = step.flow?.failureTargetId;
@@ -716,7 +779,11 @@ async function replayScriptSteps(
716
779
  options: ReplayStepOptions = {},
717
780
  ) {
718
781
  let trailingLinkIndex = steps.length;
719
- while (trailingLinkIndex > 0 && steps[trailingLinkIndex - 1]?.type === 'runScript') {
782
+ while (
783
+ trailingLinkIndex > 0
784
+ && steps[trailingLinkIndex - 1]?.type === 'runScript'
785
+ && !steps[trailingLinkIndex - 1]?.flow?.parentConditionId
786
+ ) {
720
787
  trailingLinkIndex -= 1;
721
788
  }
722
789
 
@@ -730,26 +797,49 @@ async function replayScriptSteps(
730
797
  }
731
798
  }
732
799
 
733
- export async function replayAppiumScript(script: AppiumRecordedScriptRecord, deviceId: string) {
800
+ export async function replayAppiumScript(
801
+ script: AppiumRecordedScriptRecord,
802
+ deviceId: string,
803
+ onOutput?: (line: string) => void,
804
+ ) {
734
805
  const targetDeviceId = deviceId || script.deviceId;
735
806
  if (!targetDeviceId) throw new Error('未检测到可用设备');
736
807
  if (!script.steps.length) throw new Error('脚本没有可回放步骤');
737
808
 
738
- const lines: string[] = [
809
+ const startedAt = new Date();
810
+ const lines: string[] = [];
811
+ const pushLine = lines.push.bind(lines);
812
+ lines.push = (...items: string[]) => {
813
+ const length = pushLine(...items);
814
+ items.forEach((line) => onOutput?.(line));
815
+ return length;
816
+ };
817
+ lines.push(
739
818
  `Appium 服务:${appiumServerUrl()}`,
740
819
  `目标设备:${targetDeviceId}`,
741
820
  `App 包名:${script.appPackage}`,
742
821
  `录制步骤:${script.steps.length}`,
743
- ];
822
+ );
744
823
  let sessionId = '';
824
+ let success = false;
745
825
  try {
826
+ lines.push('正在检测 Android SDK...');
827
+ const androidSdk = ensureAndroidSdkAvailable();
828
+ lines.push(`Android SDK 已就绪:${androidSdk.root}`);
746
829
  lines.push('正在创建 Appium session...');
747
- sessionId = await createSession(script, targetDeviceId);
830
+ try {
831
+ sessionId = await createSession(script, targetDeviceId);
832
+ } catch (error) {
833
+ if (!isUiAutomationDisconnected(error)) throw error;
834
+ lines.push('检测到 UiAutomation 连接冲突,正在清理残留进程并重试...');
835
+ await resetUiAutomator2(targetDeviceId);
836
+ sessionId = await createSession(script, targetDeviceId);
837
+ }
748
838
  lines.push(`Appium session 已创建:${sessionId}`);
749
839
  if (hasFlowSteps(script.steps)) lines.push('按流程图路径回放...');
750
840
  await replayScriptSteps(sessionId, targetDeviceId, script.steps, lines, [script.id]);
751
841
  lines.push('回放完成');
752
- return { success: true, output: lines.join('\n') };
842
+ success = true;
753
843
  } catch (error) {
754
844
  const detail = errorDetail(error);
755
845
  if (detail.includes('Appium Settings app is not running')) {
@@ -759,10 +849,29 @@ export async function replayAppiumScript(script: AppiumRecordedScriptRecord, dev
759
849
  appendAndroidSdkDiagnostics(lines);
760
850
  }
761
851
  lines.push(`回放终止:${detail}`);
762
- return { success: false, output: lines.join('\n') };
763
852
  } finally {
764
853
  if (sessionId) {
765
854
  await appiumRequest(`/session/${sessionId}`, { method: 'DELETE' }).catch(() => undefined);
766
855
  }
767
856
  }
857
+
858
+ const completedAt = new Date();
859
+ let reportPath = '';
860
+ let reportId = '';
861
+ try {
862
+ const report = await createAppiumReplayReport({
863
+ script,
864
+ deviceId: targetDeviceId,
865
+ success,
866
+ 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 };
768
877
  }
@@ -0,0 +1,138 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { appDataPath } from '../paths';
4
+ import { loadConfig } from '../config';
5
+ import { saveAppiumReplayReport, type AppiumRecordedScriptRecord, type AppiumRecordedStepRecord } from './repository';
6
+
7
+ function pad(value: number, length = 2) {
8
+ return String(value).padStart(length, '0');
9
+ }
10
+
11
+ function fileDateTime(date: Date) {
12
+ return [
13
+ date.getFullYear(),
14
+ pad(date.getMonth() + 1),
15
+ pad(date.getDate()),
16
+ ].join('-') + `_${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}-${pad(date.getMilliseconds(), 3)}`;
17
+ }
18
+
19
+ function safeFileName(value: string) {
20
+ return value.replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_').replace(/\s+/g, ' ').trim() || '未命名脚本';
21
+ }
22
+
23
+ function markdownValue(value: unknown) {
24
+ if (value === undefined || value === null || value === '') return '-';
25
+ return String(value).replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>');
26
+ }
27
+
28
+ function selectorText(selector?: AppiumRecordedStepRecord['selector']) {
29
+ if (!selector) return '-';
30
+ if (selector.strategy === 'bounds') return `bounds (${selector.centerX ?? '-'}, ${selector.centerY ?? '-'})`;
31
+ return `${selector.strategy} ${selector.value || ''}`.trim();
32
+ }
33
+
34
+ function stepExecutionLines(outputLines: string[], index: number) {
35
+ const prefixes = [`[节点 ${index + 1}]`, `[步骤 ${index + 1}]`];
36
+ return outputLines.filter((line) => prefixes.some((prefix) => line.startsWith(prefix)));
37
+ }
38
+
39
+ function executionStatus(lines: string[]) {
40
+ if (!lines.length) return '未执行';
41
+ if (lines.some((line) => line.includes('失败:') || line.includes('失败分支:'))) return '失败';
42
+ if (lines.some((line) => line.includes('跳过:'))) return '跳过';
43
+ if (lines.some((line) => line.includes('判断:'))) return '判断完成';
44
+ if (lines.some((line) => line.includes('完成:'))) return '成功';
45
+ return '中断';
46
+ }
47
+
48
+ function stepSection(step: AppiumRecordedStepRecord, index: number, outputLines: string[]) {
49
+ const executionLines = stepExecutionLines(outputLines, index);
50
+ const config = JSON.stringify(step, null, 2);
51
+ return [
52
+ `### ${index + 1}. ${step.label}`,
53
+ '',
54
+ '| 配置项 | 值 |',
55
+ '| --- | --- |',
56
+ `| 节点 ID | ${markdownValue(step.id)} |`,
57
+ `| 操作类型 | ${markdownValue(step.type)} |`,
58
+ `| 节点类型 | ${markdownValue(step.flow?.nodeKind || 'action')} |`,
59
+ `| 备注 | ${markdownValue(step.note)} |`,
60
+ `| Selector | ${markdownValue(selectorText(step.selector))} |`,
61
+ `| 上下文 Selector | ${markdownValue(selectorText(step.contextSelector))} |`,
62
+ `| 输入/目标值 | ${markdownValue(step.value)} |`,
63
+ `| 超时时间 | ${markdownValue(step.timeoutMs === undefined ? '-' : `${step.timeoutMs}ms`)} |`,
64
+ `| 可选步骤 | ${step.optional ? '是' : '否'} |`,
65
+ `| 是分支 | ${markdownValue(step.flow?.yesTargetId)} |`,
66
+ `| 否分支 | ${markdownValue(step.flow?.noTargetId)} |`,
67
+ `| 成功后续节点 | ${markdownValue(step.flow?.successTargetId)} |`,
68
+ `| 执行状态 | **${executionStatus(executionLines)}** |`,
69
+ '',
70
+ '#### 执行信息',
71
+ '',
72
+ executionLines.length ? '```text' : '',
73
+ executionLines.length ? executionLines.join('\n') : '该节点未进入执行路径,或在前序失败后未执行。',
74
+ executionLines.length ? '```' : '',
75
+ '',
76
+ '<details>',
77
+ '<summary>完整节点配置</summary>',
78
+ '',
79
+ '```json',
80
+ config,
81
+ '```',
82
+ '</details>',
83
+ '',
84
+ ].filter((line, lineIndex, lines) => line !== '' || lines[lineIndex - 1] !== '').join('\n');
85
+ }
86
+
87
+ export async function createAppiumReplayReport(input: {
88
+ script: AppiumRecordedScriptRecord;
89
+ deviceId: string;
90
+ success: boolean;
91
+ output: string;
92
+ startedAt: Date;
93
+ completedAt: Date;
94
+ }) {
95
+ const configuredOutputPath = loadConfig().runtime.reportOutputPath.trim();
96
+ const outputDir = appDataPath(configuredOutputPath || 'output');
97
+ await mkdir(outputDir, { recursive: true });
98
+ const fileName = `${fileDateTime(input.completedAt)}-${safeFileName(input.script.name)}.md`;
99
+ const filePath = join(outputDir, fileName);
100
+ const outputLines = input.output.split(/\r?\n/);
101
+ const durationMs = input.completedAt.getTime() - input.startedAt.getTime();
102
+ const markdown = [
103
+ '# Appium 回放报告',
104
+ '',
105
+ '| 项目 | 内容 |',
106
+ '| --- | --- |',
107
+ `| 脚本名称 | ${markdownValue(input.script.name)} |`,
108
+ `| 脚本 ID | ${markdownValue(input.script.id)} |`,
109
+ `| App 包名 | ${markdownValue(input.script.appPackage)} |`,
110
+ `| App Activity | ${markdownValue(input.script.appActivity)} |`,
111
+ `| 设备 ID | ${markdownValue(input.deviceId)} |`,
112
+ `| 开始时间 | ${input.startedAt.toLocaleString()} |`,
113
+ `| 完成时间 | ${input.completedAt.toLocaleString()} |`,
114
+ `| 总耗时 | ${durationMs}ms |`,
115
+ `| 执行结果 | **${input.success ? '成功' : '失败'}** |`,
116
+ `| 节点数量 | ${input.script.steps.length} |`,
117
+ '',
118
+ '## 节点明细',
119
+ '',
120
+ ...input.script.steps.map((step, index) => stepSection(step, index, outputLines)),
121
+ '## 完整回放日志',
122
+ '',
123
+ '```text',
124
+ input.output || '-',
125
+ '```',
126
+ '',
127
+ ].join('\n');
128
+ await writeFile(filePath, markdown, 'utf8');
129
+ const record = saveAppiumReplayReport({
130
+ scriptId: input.script.id,
131
+ scriptName: input.script.name,
132
+ success: input.success,
133
+ filePath,
134
+ startedAt: input.startedAt.toISOString(),
135
+ completedAt: input.completedAt.toISOString(),
136
+ });
137
+ return { id: record.id, filePath, fileName };
138
+ }