android-midscene-automation 0.1.21 → 0.1.22

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/USAGE.md CHANGED
@@ -459,14 +459,14 @@ Home 键
459
459
  4. 点击判断节点,按需填写“指定文本”。
460
460
  5. 选择“模糊匹配(包含)”或“精准匹配(完全一致)”。
461
461
  6. 在“是”或“否”分支下点击“插入操作”,分别添加分支步骤。
462
- 7. 需要回到判断后的主流程时,点击分支旁的“连接到下一节点”;连接成功后按钮变为“取消连接”。
462
+ 7. 如果两个分支后续要执行相同操作,可以复制公共节点到对应分支,或把公共流程拆成“连接脚本”复用。
463
463
 
464
464
  示例:
465
465
 
466
466
  ```text
467
467
  判断存在 确认按钮
468
468
  是 -> 点击 确认按钮
469
- 否 -> 继续主流程
469
+ 否 -> 不添加操作
470
470
  ```
471
471
 
472
472
  分支说明:
@@ -476,7 +476,6 @@ Home 键
476
476
  - 模糊匹配会忽略换行和连续空白差异,只要规范化后的文本包含目标内容即可。
477
477
  - 精准匹配要求 Appium 返回的完整文本与指定文本完全一致。
478
478
  - 分支可以包含多个连续步骤,也可以在分支末尾连接其他脚本。
479
- - “连接到下一节点”只在判断后仍有主流程节点时显示。
480
479
 
481
480
  适用场景:
482
481
 
@@ -496,7 +495,7 @@ Home 键
496
495
  3. 选择“判断存在”。
497
496
  4. 展开节点,在“指定文本”中输入该弹窗特有的文案。
498
497
  5. 根据需要选择模糊匹配或精准匹配。
499
- 6. 在“是”分支添加该弹窗对应的确认、取消或关闭步骤;“否”分支继续下一个判断或主流程。
498
+ 6. 在“是”分支添加该弹窗对应的确认、取消或关闭步骤;“否”分支继续下一个判断或不添加操作。
500
499
 
501
500
  示例:
502
501
 
@@ -505,7 +504,7 @@ Home 键
505
504
  是 -> 点击 确定
506
505
  否 -> 判断存在:指定文本“是否退出登录”
507
506
  是 -> 点击 确定
508
- 否 -> 继续主流程
507
+ 否 -> 不添加操作
509
508
  ```
510
509
 
511
510
  适用场景:
@@ -867,12 +866,8 @@ output/2026-08-21_17-13-42-831-登录流程.html
867
866
  输入 账号
868
867
  输入 密码
869
868
  判断存在 用户协议弹窗
870
- 是 -> 点击 同意
871
- 否 -> 连接到下一节点
872
- 点击 登录
873
- 判断登录是否成功
874
- 是 -> 连接脚本 首页脚本
875
- 否 -> 断言文本 登录错误提示
869
+ 是 -> 点击 同意 -> 点击 登录 -> 判断登录是否成功 -> 连接脚本 首页脚本
870
+ 否 -> 点击 登录 -> 判断登录是否成功 -> 连接脚本 首页脚本
876
871
  ```
877
872
 
878
873
  首页脚本:
@@ -906,16 +901,15 @@ output/2026-08-21_17-13-42-831-登录流程.html
906
901
  2. 选择用于区分登录结果的稳定元素,添加“判断存在”。
907
902
  3. 在“是”分支插入首页校验或“连接脚本”。
908
903
  4. 在“否”分支插入错误提示断言。
909
- 5. 需要继续执行判断后的公共步骤时,点击对应分支的“连接到下一节点”。
904
+ 5. 如果两个分支后续有相同公共步骤,使用批量复制把公共节点复制到对应分支。
910
905
 
911
906
  ### 示例 3:一次性弹窗
912
907
 
913
908
  ```text
914
909
  启动 App
915
910
  判断存在 确认按钮
916
- 是 -> 点击 确认按钮
917
- 否 -> 连接到下一节点
918
- 断言存在 首页
911
+ 是 -> 点击 确认按钮 -> 断言存在 首页
912
+ 否 -> 断言存在 首页
919
913
  ```
920
914
 
921
915
  适合场景:
@@ -984,6 +978,12 @@ output/2026-08-21_17-13-42-831-登录流程.html
984
978
  - 分支连接允许选择同一 App 的其他 Activity,但不会主动跳转页面;前面的点击或输入必须真正触发跳转。
985
979
  - 如果页面未跳转,回放会在等待目标 Activity 超时后失败,这是为了防止在错误页面执行目标脚本。
986
980
 
981
+ ### 为什么在桌面点击 App 后回放无效
982
+
983
+ 部分厂商系统会限制桌面或启动器页面的控件信息,Appium 可能无法获取桌面图标的稳定 id。此时在桌面录制“点击 App 图标”可能无法回放。
984
+
985
+ 建议在开始节点后添加“启动 App”操作,由系统按包名打开目标 App,替代桌面点击图标。需要重置登录态或缓存时,可以先添加“清理 App 缓存”,再添加“启动 App”。
986
+
987
987
  ### 弹窗只出现一次怎么办
988
988
 
989
989
  使用“判断存在”。
@@ -991,7 +991,7 @@ output/2026-08-21_17-13-42-831-登录流程.html
991
991
  ```text
992
992
  判断存在 确认按钮
993
993
  是 -> 点击 确认按钮
994
- 否 -> 继续主流程
994
+ 否 -> 不添加操作
995
995
  ```
996
996
 
997
997
  不要直接把弹窗确认按钮作为普通必选点击,否则弹窗不出现时脚本会失败。
@@ -1043,5 +1043,4 @@ output/2026-08-21_17-13-42-831-登录流程.html
1043
1043
  - 弹窗或其他可选组件使用“判断存在”,不要直接写死必点。
1044
1044
  - 坐标点击只作为兜底。
1045
1045
  - 给关键节点填写“登录按钮”“账号输入框”等备注,方便查看流程和报告。
1046
- - 判断分支需要回到主流程时,明确点击“连接到下一节点”。
1047
1046
  - 回放失败后优先查看“回放输出”中的失败节点、Activity 和 selector,再打开 `output` 中的 Markdown 报告查看完整配置。
@@ -10,12 +10,16 @@ const viteRoot = path.dirname(require.resolve('vite/package.json'));
10
10
  const viteBin = path.join(viteRoot, 'bin', 'vite.js');
11
11
  const args = process.argv.slice(2);
12
12
  const userRoot = process.cwd();
13
+ const githubUrl = 'https://github.com/oooooooko/android-midscene-automation';
13
14
 
14
15
  if (!args.includes('--host')) {
15
16
  args.unshift('127.0.0.1');
16
17
  args.unshift('--host');
17
18
  }
18
19
 
20
+ console.log(`\nGitHub 源码 / 二次修改:${githubUrl}`);
21
+ console.log(`问题反馈 / 功能建议:${githubUrl}/issues\n`);
22
+
19
23
  const child = spawn(process.execPath, [viteBin, ...args], {
20
24
  cwd: packageRoot,
21
25
  env: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "android-midscene-automation",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/oooooooko/android-midscene-automation.git"
@@ -11,7 +11,7 @@
11
11
  "homepage": "https://github.com/oooooooko/android-midscene-automation#readme",
12
12
  "type": "module",
13
13
  "bin": {
14
- "android-midscene-automation": "./bin/android-midscene-automation.js"
14
+ "android-midscene-automation": "bin/android-midscene-automation.js"
15
15
  },
16
16
  "files": [
17
17
  "bin",
@@ -35,25 +35,40 @@
35
35
  "test:e2e": "playwright test"
36
36
  },
37
37
  "dependencies": {
38
- "@element-plus/icons-vue": "^2.3.2",
39
- "@midscene/android": "1.10.12",
40
- "@midscene/web": "1.10.12",
41
- "@vitejs/plugin-vue": "^6.0.1",
42
- "element-plus": "^2.11.1",
43
- "openai": "^5.12.2",
44
- "tsx": "^4.20.3",
45
- "typescript": "^5.8.3",
46
- "vite": "^7.0.4",
47
- "vue": "^3.5.18",
48
- "word-extractor": "^1.0.4",
49
- "xlsx": "^0.18.5",
50
- "yaml": "^2.8.1"
38
+ "@dagrejs/dagre": "1.1.5",
39
+ "@element-plus/icons-vue": "2.3.2",
40
+ "@midscene/android": "https://registry.npmjs.org/@midscene/android/-/android-1.12.0.tgz",
41
+ "@midscene/android-playground": "1.12.0",
42
+ "@midscene/core": "https://registry.npmjs.org/@midscene/core/-/core-1.12.0.tgz",
43
+ "@midscene/playground": "https://registry.npmjs.org/@midscene/playground/-/playground-1.12.0.tgz",
44
+ "@midscene/shared": "https://registry.npmjs.org/@midscene/shared/-/shared-1.12.0.tgz",
45
+ "@midscene/web": "1.12.0",
46
+ "@vitejs/plugin-vue": "6.0.7",
47
+ "@vue-flow/core": "1.48.2",
48
+ "element-plus": "2.11.1",
49
+ "openai": "5.23.2",
50
+ "tsx": "4.23.0",
51
+ "typescript": "5.9.3",
52
+ "vite": "7.3.6",
53
+ "vue": "3.5.39",
54
+ "word-extractor": "1.0.4",
55
+ "xlsx": "0.18.5",
56
+ "yaml": "2.9.0"
57
+ },
58
+ "overrides": {
59
+ "@floating-ui/core": "1.7.5",
60
+ "@floating-ui/dom": "1.7.6",
61
+ "nanoid": "3.3.15",
62
+ "playwright": "1.61.1",
63
+ "playwright-core": "1.61.1",
64
+ "postcss": "8.5.16",
65
+ "rollup": "4.62.2"
51
66
  },
52
67
  "devDependencies": {
53
- "@playwright/test": "^1.54.1",
54
- "@types/node": "^24.0.13",
55
- "@types/word-extractor": "^1.0.6",
56
- "@vue/tsconfig": "^0.7.0",
57
- "vue-tsc": "^3.0.3"
68
+ "@playwright/test": "1.61.1",
69
+ "@types/node": "24.13.2",
70
+ "@types/word-extractor": "1.0.6",
71
+ "@vue/tsconfig": "0.7.0",
72
+ "vue-tsc": "3.3.6"
58
73
  }
59
74
  }
@@ -122,6 +122,27 @@ export function launchAppOnDevice(deviceId: string, packageName: string) {
122
122
  });
123
123
  }
124
124
 
125
+ export function clearAppDataOnDevice(deviceId: string, packageName: string) {
126
+ if (!packageName) return Promise.reject(new Error('清理 App 缓存缺少包名'));
127
+ return new Promise<void>((resolve, reject) => {
128
+ execFile(getAdbCommand(), [
129
+ '-s',
130
+ deviceId,
131
+ 'shell',
132
+ 'pm',
133
+ 'clear',
134
+ packageName,
135
+ ], { maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => {
136
+ const output = `${stdout || ''}\n${stderr || ''}`.trim();
137
+ if (error || !/^success$/im.test(output)) {
138
+ reject(new Error(output || error?.message || `ADB 清理 ${packageName} 失败`));
139
+ return;
140
+ }
141
+ resolve();
142
+ });
143
+ });
144
+ }
145
+
125
146
  function adbSwipe(deviceId: string, step: Required<AppiumRecordedStepRecord>['swipe']) {
126
147
  return new Promise<void>((resolve, reject) => {
127
148
  execFile(getAdbCommand(), [
@@ -500,6 +521,12 @@ async function runStep(sessionId: string, deviceId: string, step: AppiumRecorded
500
521
  return `ADB 已启动 ${packageName}`;
501
522
  }
502
523
 
524
+ if (step.type === 'clearAppData') {
525
+ const packageName = step.value || '';
526
+ await clearAppDataOnDevice(deviceId, packageName);
527
+ return `ADB 已清除 ${packageName} 的应用数据与缓存`;
528
+ }
529
+
503
530
  if (step.type === 'screenshot') {
504
531
  await saveScreenshot(sessionId);
505
532
  return;
@@ -710,10 +737,20 @@ async function appendStepDiagnostics(lines: string[], deviceId: string, step: Ap
710
737
  }
711
738
 
712
739
  type ReplayStepOptions = {
713
- skipLaunchApp?: boolean;
740
+ skipAppInitialization?: boolean;
714
741
  scriptName?: string;
715
742
  };
716
743
 
744
+ function shouldSkipLinkedScriptInitStep(options: ReplayStepOptions, step: AppiumRecordedStepRecord) {
745
+ return Boolean(options.skipAppInitialization && (step.type === 'launchApp' || step.type === 'clearAppData'));
746
+ }
747
+
748
+ function linkedScriptInitSkipReason(step: AppiumRecordedStepRecord) {
749
+ return step.type === 'clearAppData'
750
+ ? '连接脚本不重复清理 App'
751
+ : '连接脚本不重复启动 App';
752
+ }
753
+
717
754
  async function replayLinkedScript(
718
755
  sessionId: string,
719
756
  deviceId: string,
@@ -754,7 +791,7 @@ async function replayLinkedScript(
754
791
  lines.push(`连接脚本开始:${linkedScript.name}`);
755
792
  const nextStack = [...stack, linkedScript.id];
756
793
  await replayScriptSteps(sessionId, deviceId, linkedScript.steps, lines, nextStack, {
757
- skipLaunchApp: true,
794
+ skipAppInitialization: true,
758
795
  scriptName: linkedScript.name,
759
796
  });
760
797
  lines.push(`连接脚本完成:${linkedScript.name}`);
@@ -770,8 +807,8 @@ async function replayLinearSteps(
770
807
  ) {
771
808
  for (const [index, step] of steps.entries()) {
772
809
  throwIfReplayStopped();
773
- if (options.skipLaunchApp && step.type === 'launchApp') {
774
- lines.push(`[节点 ${index + 1}] 跳过:${step.label}(连接脚本不重复启动 App)`);
810
+ if (shouldSkipLinkedScriptInitStep(options, step)) {
811
+ lines.push(`[节点 ${index + 1}] 跳过:${step.label}(${linkedScriptInitSkipReason(step)})`);
775
812
  continue;
776
813
  }
777
814
  lines.push(`[节点 ${index + 1}] 开始:${step.label}`);
@@ -841,8 +878,8 @@ async function replayFlowSteps(
841
878
  if (guard > maxVisits) throw new Error('流程图可能存在循环,已终止回放');
842
879
 
843
880
  const step = steps[index];
844
- if (options.skipLaunchApp && step.type === 'launchApp') {
845
- lines.push(`[节点 ${index + 1}] 跳过:${step.label}(连接脚本不重复启动 App)`);
881
+ if (shouldSkipLinkedScriptInitStep(options, step)) {
882
+ lines.push(`[节点 ${index + 1}] 跳过:${step.label}(${linkedScriptInitSkipReason(step)})`);
846
883
  index = nextIndexAfterStep(index, step);
847
884
  continue;
848
885
  }
@@ -26,6 +26,7 @@ export type AppiumRecordedStepRecord = {
26
26
  | 'swipe'
27
27
  | 'screenshot'
28
28
  | 'launchApp'
29
+ | 'clearAppData'
29
30
  | 'waitDisappear'
30
31
  | 'assertText'
31
32
  | 'longPress'
@@ -11,7 +11,7 @@ import {
11
11
  saveAppiumRecordedScript,
12
12
  type AppiumRecordedStepRecord,
13
13
  } from './repository';
14
- import { launchAppOnDevice, replayAppiumScript } from './appium-runner';
14
+ import { clearAppDataOnDevice, launchAppOnDevice, replayAppiumScript } from './appium-runner';
15
15
  import { isRemoteDeviceId, sendRemoteCommand } from '../remote-agents/registry';
16
16
  import { getAdbCommand } from '../android-sdk';
17
17
 
@@ -205,6 +205,19 @@ export async function handleAppiumRecorderRequest(
205
205
  return true;
206
206
  }
207
207
 
208
+ if (pathname === '/api/appium-recorder/clear-app-data' && req.method === 'POST') {
209
+ const parsed = await readBody<{ deviceId?: string; packageName?: string }>(req);
210
+ const deviceId = parsed.deviceId?.trim() || selectedDeviceId;
211
+ const packageName = parsed.packageName?.trim() || '';
212
+ if (!deviceId) throw new Error('未检测到可用设备');
213
+ if (!packageName) throw new Error('请选择预设 App');
214
+ assertDeviceAllowed(deviceId);
215
+ if (isRemoteDeviceId(deviceId)) throw new Error('远程设备暂不支持清理 App 缓存');
216
+ await clearAppDataOnDevice(deviceId, packageName);
217
+ sendJson(res, { success: true });
218
+ return true;
219
+ }
220
+
208
221
  if (pathname === '/api/appium-recorder/scripts' && req.method === 'GET') {
209
222
  sendJson(res, { scripts: listAppiumRecordedScripts() });
210
223
  return true;
@@ -95,9 +95,13 @@ function writeNdjson(res: ServerResponse, event: RunGeneratedScriptEvent | { typ
95
95
  res.write(`${JSON.stringify(event)}\n`);
96
96
  }
97
97
 
98
- function npxCommand() {
99
- return process.platform === 'win32' ? 'npx.cmd' : 'npx';
100
- }
98
+ const START_ANDROID_PLAYGROUND_WITHOUT_BROWSER = [
99
+ "import { androidPlaygroundPlatform, ScrcpyServer } from '@midscene/android-playground';",
100
+ "import { launchPreparedPlaygroundPlatform } from '@midscene/playground';",
101
+ 'const scrcpyServer = new ScrcpyServer();',
102
+ 'const prepared = await androidPlaygroundPlatform.prepare({ scrcpyServer });',
103
+ 'await launchPreparedPlaygroundPlatform(prepared);',
104
+ ].join('\n');
101
105
 
102
106
  type AndroidDevice = {
103
107
  id: string;
@@ -513,7 +517,7 @@ export function createApiMiddleware() {
513
517
  };
514
518
  }
515
519
 
516
- playgroundProcess = spawn(npxCommand(), ['--yes', '@midscene/android-playground'], {
520
+ playgroundProcess = spawn(process.execPath, ['--input-type=module', '--eval', START_ANDROID_PLAYGROUND_WITHOUT_BROWSER], {
517
521
  cwd: appPath(),
518
522
  env: {
519
523
  ...process.env,
@@ -30,6 +30,18 @@ type ModelTestResult = {
30
30
  usage?: ModelUsage;
31
31
  };
32
32
 
33
+ function normalizeBaseUrl(value: string) {
34
+ return value.trim().replace(/\/+$/, '');
35
+ }
36
+
37
+ function normalizeModelName(value: string) {
38
+ return value.trim().replace(/[‐‑‒–—―]/g, '-');
39
+ }
40
+
41
+ function visibleContent(value: string) {
42
+ return value.replace(/<think>[\s\S]*?<\/think>/g, '').trim() || value.trim();
43
+ }
44
+
33
45
  function execCodex(args: string[]) {
34
46
  return new Promise<string>((resolve, reject) => {
35
47
  execFile('codex', args, { timeout: 10_000 }, (error, stdout, stderr) => {
@@ -63,23 +75,27 @@ async function testCodexConnection(input: ModelInput): Promise<ModelTestResult>
63
75
  }
64
76
 
65
77
  export async function testModelConnection(input: ModelInput): Promise<ModelTestResult> {
66
- if (input.provider === 'codex' || input.baseUrl === 'codex://app-server') {
78
+ const baseUrl = normalizeBaseUrl(input.baseUrl);
79
+ const modelName = normalizeModelName(input.name);
80
+
81
+ if (input.provider === 'codex' || baseUrl === 'codex://app-server') {
67
82
  return testCodexConnection(input);
68
83
  }
69
84
 
70
- if (!input.baseUrl || !input.apiKey || !input.name) {
85
+ if (!baseUrl || !input.apiKey.trim() || !modelName) {
71
86
  throw new Error('模型配置不完整');
72
87
  }
73
88
 
74
89
  const startedAt = Date.now();
75
90
  const client = new OpenAI({
76
- apiKey: input.apiKey,
77
- baseURL: input.baseUrl,
91
+ apiKey: input.apiKey.trim(),
92
+ baseURL: baseUrl,
78
93
  });
79
94
 
80
95
  const result = await client.chat.completions.create({
81
- model: input.name,
96
+ model: modelName,
82
97
  temperature: 0,
98
+ max_completion_tokens: 128,
83
99
  messages: [
84
100
  {
85
101
  role: 'user',
@@ -88,7 +104,7 @@ export async function testModelConnection(input: ModelInput): Promise<ModelTestR
88
104
  ],
89
105
  });
90
106
 
91
- const content = result.choices[0]?.message?.content?.trim() || '';
107
+ const content = visibleContent(result.choices[0]?.message?.content || '');
92
108
  const usage: ModelUsage = {
93
109
  promptTokens: result.usage?.prompt_tokens,
94
110
  completionTokens: result.usage?.completion_tokens,
package/src/App.vue CHANGED
@@ -1391,7 +1391,10 @@ onUnmounted(() => {
1391
1391
 
1392
1392
  <main
1393
1393
  class="page-body"
1394
- :class="{ 'page-body--fixed': activeMenu === 'generator' && activeGeneratorMode === 'ai' }"
1394
+ :class="{
1395
+ 'page-body--fixed': activeMenu === 'generator' && activeGeneratorMode === 'ai',
1396
+ 'page-body--appium': activeMenu === 'appium',
1397
+ }"
1395
1398
  >
1396
1399
  <el-alert v-if="activeMenu !== 'appium' && errorMessage" type="error" :closable="false" show-icon class="page-alert">
1397
1400
  <template #title>{{ errorMessage }}</template>
package/src/api.ts CHANGED
@@ -32,6 +32,8 @@ async function postJson<T>(url: string, body?: unknown) {
32
32
  method: 'POST',
33
33
  headers: { 'Content-Type': 'application/json' },
34
34
  body: JSON.stringify(body || {}),
35
+ }).catch((error) => {
36
+ throw new Error(`无法连接本地后端 ${url}:${error instanceof Error ? error.message : '网络请求失败'}`);
35
37
  });
36
38
  return readJson<T>(response);
37
39
  }
@@ -287,4 +289,4 @@ export async function runScript(
287
289
  if (buffer.trim()) {
288
290
  onEvent(JSON.parse(buffer) as RunScriptStreamEvent);
289
291
  }
290
- }
292
+ }