android-midscene-automation 0.1.23 → 0.1.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # 更新记录
2
2
 
3
+ ## v0.1.24
4
+
5
+ - 参数配置的运行配置新增保存校验,Android SDK 路径必须包含 `platform-tools/adb`,回放报告目录必须填写已存在且可写入的绝对路径。
6
+ - Midscene 自定义提供方的 `Model Name` 和 `Model Family` 改为输入框,方便填写官方支持列表之外的模型名称和模型系列。
7
+
3
8
  ## v0.1.23
4
9
 
5
10
  - 常见问题拆分为独立 `FAQ.md` 文档,并在 README 的“功能文档”中增加跳转入口。
package/README.md CHANGED
@@ -52,7 +52,7 @@ npx android-midscene-automation --port 5174
52
52
  ## 功能文档
53
53
 
54
54
  - [Appium 录制器使用说明](#appium-录制器使用说明)
55
- - [常见问题](./FAQ.md)
55
+ - [常见问题](#常见问题)
56
56
  - [项目更新记录](#项目更新记录)
57
57
 
58
58
  ## 源码开发
@@ -165,7 +165,7 @@ midscene_run/ # Midscene 执行报告和运行产物
165
165
 
166
166
  本文档说明 Appium 组件树录制器的完整使用方式,包括每个操作按钮的用途、录制方法和示例。
167
167
 
168
- 本文档随 npm 包发布,也可以从 README 的“功能文档”直接打开。常见问题请查看 [FAQ.md](./FAQ.md)。
168
+ 本文档随 npm 包发布,也可以从 README 的“功能文档”直接打开。常见问题请查看 [常见问题](#常见问题)。
169
169
 
170
170
  ## 使用前准备
171
171
 
@@ -1106,9 +1106,6 @@ output/2026-08-21_17-13-42-831-登录流程.html
1106
1106
  - 校验页面返回路径。
1107
1107
  - 校验返回后列表仍可见。
1108
1108
 
1109
- ## 常见问题
1110
-
1111
- 常见问题已独立整理到 [FAQ.md](./FAQ.md)。
1112
1109
 
1113
1110
  ## 推荐录制规范
1114
1111
 
@@ -1120,8 +1117,105 @@ output/2026-08-21_17-13-42-831-登录流程.html
1120
1117
  - 给关键节点填写“登录按钮”“账号输入框”等备注,方便查看流程和报告。
1121
1118
  - 回放失败后优先查看“回放输出”中的失败节点、Activity 和 selector,再打开 `output` 中的 Markdown 报告查看完整配置。
1122
1119
 
1120
+ # 常见问题
1121
+
1122
+ ## 同一个 id 定位到错误输入框怎么办
1123
+
1124
+ 如果账号框和密码框都是 `id/tg_edit`,只按 id 回放可能输入到第一个输入框。
1125
+
1126
+ 建议:
1127
+
1128
+ - 直接分别选择账号框和密码框录制输入。
1129
+ - 在节点详情里确认 selector 是否显示“重复 N”,以及“推荐定位”是否显示父级 + 子级。
1130
+ - 如果推荐定位不准确,展开录制步骤,手动修改父级上下文 selector。
1131
+ - 坐标点击只作为最后兜底。
1132
+
1133
+ ## 点击后没有检测到页面跳转怎么办
1134
+
1135
+ 可能原因:
1136
+
1137
+ - 按钮当前禁用。
1138
+ - 账号密码不正确。
1139
+ - App 使用单 Activity,Activity 不变化。
1140
+ - 页面跳转依赖网络。
1141
+
1142
+ 推荐:
1143
+
1144
+ - 使用“断言存在”判断下个页面核心元素。
1145
+ - 多 Activity 页面可以拆成两个脚本,在判断分支中用“连接脚本”串起来;回放会等待目标 Activity 出现。
1146
+ - 单 Activity App 不要依赖 Activity 变化,应使用页面核心元素判断是否已进入目标状态。
1147
+
1148
+ ## 为什么连接脚本提示入口 Activity 不匹配
1149
+
1150
+ - 主流程连接要求目标脚本入口 Activity 与当前插入点一致,适合复用同一页面上的公共流程。
1151
+ - 登录后进入首页等跨 Activity 场景,应先添加“判断存在”,再在“是”或“否”分支中添加“连接脚本”。
1152
+ - 分支连接允许选择同一 App 的其他 Activity,但不会主动跳转页面;前面的点击或输入必须真正触发跳转。
1153
+ - 如果页面未跳转,回放会在等待目标 Activity 超时后失败,这是为了防止在错误页面执行目标脚本。
1154
+
1155
+ ## 为什么在桌面点击 App 后回放无效
1156
+
1157
+ 部分厂商系统会限制桌面或启动器页面的控件信息,Appium 可能无法获取桌面图标的稳定 id。此时在桌面录制“点击 App 图标”可能无法回放。
1158
+
1159
+ 建议在开始节点后添加“启动 App”操作,由系统按包名打开目标 App,替代桌面点击图标。需要重置登录态或缓存时,可以先添加“清理 App 缓存”,再添加“启动 App”。
1160
+
1161
+ ## 弹窗只出现一次怎么办
1162
+
1163
+ 使用“判断存在”。
1164
+
1165
+ ```text
1166
+ 判断存在 确认按钮
1167
+ 是 -> 点击 确认按钮
1168
+ 否 -> 不添加操作
1169
+ ```
1170
+
1171
+ 不要直接把弹窗确认按钮作为普通必选点击,否则弹窗不出现时脚本会失败。
1172
+
1173
+ ## 什么时候用等待 Activity
1174
+
1175
+ 适合多 Activity App。
1176
+
1177
+ 如果 App 是单 Activity 架构,优先用:
1178
+
1179
+ ```text
1180
+ 断言存在 页面核心元素
1181
+ ```
1182
+
1183
+ ## 什么时候用可选步骤
1184
+
1185
+ 可选步骤适合非主流程阻塞项:
1186
+
1187
+ - 权限弹窗
1188
+ - 协议弹窗
1189
+ - 活动弹窗
1190
+ - 首次引导
1191
+
1192
+ 不建议把登录按钮、提交按钮、核心断言设置为可选。
1193
+
1194
+ ## 回放时出现 UiAutomation not connected 怎么办
1195
+
1196
+ 组件树刷新和 Appium 回放不能同时占用 UiAutomation。当前版本会在回放期间暂停组件树自动刷新,并在断开时清理残留抓取进程后重试一次。
1197
+
1198
+ 如果仍然失败:
1199
+
1200
+ 1. 确认手机保持解锁,USB 调试授权没有失效。
1201
+ 2. 确认 Appium 和 UiAutomator2 Driver 已正常启动。
1202
+ 3. 停止其他正在抓取同一设备组件树的工具。
1203
+ 4. 重新连接设备后再次回放。
1204
+
1205
+ ## 设备预览或组件树没有更新怎么办
1206
+
1207
+ 1. 先确认设备仍显示在设备下拉框中。
1208
+ 2. 点击设备预览刷新按钮重新获取画面。
1209
+ 3. 点击“刷新组件树”重新抓取页面结构。
1210
+ 4. 回放期间组件树自动刷新会暂停,回放完成后会自动恢复。
1211
+
1123
1212
  # 项目更新记录
1124
1213
 
1214
+ ## v0.1.24
1215
+
1216
+ - 参数配置的运行配置新增保存校验,Android SDK 路径必须包含 `platform-tools/adb`,回放报告目录必须填写已存在且可写入的绝对路径。
1217
+ - Midscene 自定义提供方的 `Model Name` 和 `Model Family` 改为输入框,方便填写官方支持列表之外的模型名称和模型系列。
1218
+
1125
1219
  ## v0.1.23
1126
1220
 
1127
1221
  - 常见问题拆分为独立 `FAQ.md` 文档,并在 README 的“功能文档”中增加跳转入口。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "android-midscene-automation",
3
- "version": "0.1.23",
3
+ "version": "0.1.24",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/oooooooko/android-midscene-automation.git"
package/server/config.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs';
2
+ import os from 'node:os';
2
3
  import path from 'node:path';
3
4
  import YAML from 'yaml';
4
5
  import { appDataPath } from './paths';
@@ -31,6 +32,14 @@ export type AppConfig = {
31
32
  let cachedConfig: AppConfig | null = null;
32
33
  const configPath = appDataPath('config.json');
33
34
  const legacyConfigPath = appDataPath('config.yaml');
35
+ const adbFileName = process.platform === 'win32' ? 'adb.exe' : 'adb';
36
+
37
+ export class ConfigValidationError extends Error {
38
+ constructor(message: string) {
39
+ super(message);
40
+ this.name = 'ConfigValidationError';
41
+ }
42
+ }
34
43
 
35
44
  function defaultConfig(): AppConfig {
36
45
  return {
@@ -95,6 +104,65 @@ function normalizeConfig(config: Partial<AppConfig> | null | undefined): AppConf
95
104
  };
96
105
  }
97
106
 
107
+ function expandRuntimePath(value: string) {
108
+ const trimmed = value.trim().replace(/^(["'])(.*)\1$/, '$2');
109
+ if (trimmed === '~') return os.homedir();
110
+ if (trimmed.startsWith(`~${path.sep}`) || trimmed.startsWith('~/')) {
111
+ return path.join(os.homedir(), trimmed.slice(2));
112
+ }
113
+ return path.isAbsolute(trimmed) ? path.normalize(trimmed) : appDataPath(trimmed);
114
+ }
115
+
116
+ function isAbsoluteRuntimePath(value: string) {
117
+ const trimmed = value.trim().replace(/^(["'])(.*)\1$/, '$2');
118
+ return trimmed === '~'
119
+ || trimmed.startsWith(`~${path.sep}`)
120
+ || trimmed.startsWith('~/')
121
+ || path.isAbsolute(trimmed);
122
+ }
123
+
124
+ function validateAndroidSdkPath(value: string) {
125
+ if (!value) return;
126
+ const candidate = expandRuntimePath(value);
127
+ const lowerBaseName = path.basename(candidate).toLowerCase();
128
+ const root = lowerBaseName === adbFileName.toLowerCase()
129
+ ? path.dirname(path.dirname(candidate))
130
+ : lowerBaseName === 'platform-tools'
131
+ ? path.dirname(candidate)
132
+ : candidate;
133
+ const adbPath = path.join(root, 'platform-tools', adbFileName);
134
+ if (!fs.existsSync(adbPath)) {
135
+ throw new ConfigValidationError(
136
+ `Android SDK 路径无效:${value}。请选择包含 platform-tools/${adbFileName} 的 SDK 目录。`,
137
+ );
138
+ }
139
+ }
140
+
141
+ function validateReportOutputPath(value: string) {
142
+ if (!value) return;
143
+ if (!isAbsoluteRuntimePath(value)) {
144
+ throw new ConfigValidationError(`回放报告目录无效:${value}。请填写绝对路径,或留空使用默认 output 目录。`);
145
+ }
146
+ const outputDir = expandRuntimePath(value);
147
+ try {
148
+ if (!fs.existsSync(outputDir)) {
149
+ throw new Error('目录不存在');
150
+ }
151
+ if (!fs.statSync(outputDir).isDirectory()) {
152
+ throw new Error('不是目录');
153
+ }
154
+ fs.accessSync(outputDir, fs.constants.W_OK);
155
+ } catch (error) {
156
+ const detail = error instanceof Error && error.message ? `(${error.message})` : '';
157
+ throw new ConfigValidationError(`回放报告目录无效:${value}。请选择已经存在并可写入的目录${detail}。`);
158
+ }
159
+ }
160
+
161
+ function validateRuntimeConfig(config: AppConfig) {
162
+ validateAndroidSdkPath(config.runtime.androidSdkPath);
163
+ validateReportOutputPath(config.runtime.reportOutputPath);
164
+ }
165
+
98
166
  export function loadConfig(): AppConfig {
99
167
  if (cachedConfig) {
100
168
  return cachedConfig;
@@ -126,7 +194,9 @@ export function loadConfig(): AppConfig {
126
194
  }
127
195
 
128
196
  export function saveConfig(config: AppConfig) {
129
- cachedConfig = normalizeConfig(config);
197
+ const normalized = normalizeConfig(config);
198
+ validateRuntimeConfig(normalized);
199
+ cachedConfig = normalized;
130
200
  saveModelConfigToDb(cachedConfig);
131
201
  }
132
202
 
@@ -9,7 +9,7 @@ type NextHandleFunction = (req: IncomingMessage, res: ServerResponse, next: Next
9
9
 
10
10
  import { execFile, spawn } from 'node:child_process';
11
11
  import { appPath } from './paths';
12
- import { loadConfig, saveConfig, type AppConfig } from './config';
12
+ import { ConfigValidationError, loadConfig, saveConfig, type AppConfig } from './config';
13
13
  import { listAppPresetRecords, removeAppPresetRecord, saveAppPresetRecord } from './config-store';
14
14
  import { generatePlan } from './script-agent';
15
15
  import { importMidsceneModelUsage } from './model-call-usage-importer';
@@ -648,7 +648,7 @@ export function createApiMiddleware() {
648
648
  res.setHeader('Content-Type', 'application/json; charset=utf-8');
649
649
  res.end(JSON.stringify({ success: true }));
650
650
  } catch (error) {
651
- res.statusCode = 500;
651
+ res.statusCode = error instanceof ConfigValidationError ? 400 : 500;
652
652
  res.setHeader('Content-Type', 'application/json; charset=utf-8');
653
653
  res.end(JSON.stringify({ message: error instanceof Error ? error.message : 'Unknown error' }));
654
654
  }
@@ -1,11 +1,9 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from 'vue';
3
- import { Delete, Edit } from '@element-plus/icons-vue';
3
+ import { Delete, Edit, QuestionFilled } from '@element-plus/icons-vue';
4
4
  import ModelUsageChart from '../components/config/ModelUsageChart.vue';
5
5
  import {
6
6
  codexMidsceneModelOptions,
7
- midsceneModelFamilyOptions,
8
- midsceneModelOptions,
9
7
  midsceneModelPresets,
10
8
  type MidsceneModelProvider,
11
9
  type MidsceneModelPresetKey,
@@ -45,6 +43,7 @@ const emit = defineEmits<{
45
43
  const activeMidsceneProvider = computed<MidsceneModelProvider>(() =>
46
44
  props.configForm.midscene.model.provider === 'codex' ? 'codex' : 'custom',
47
45
  );
46
+ const modelConfigGuideUrl = 'https://midscenejs.com/zh/model-common-config.html';
48
47
 
49
48
  const activeMidscenePresetKey = computed(() => {
50
49
  const model = props.configForm.midscene.model;
@@ -56,13 +55,9 @@ const activeMidscenePresetKey = computed(() => {
56
55
  )?.key || '';
57
56
  });
58
57
 
59
- const activeMidsceneModelOptions = computed(() =>
60
- activeMidsceneProvider.value === 'codex' ? codexMidsceneModelOptions : midsceneModelOptions,
61
- );
62
-
63
58
  const updateMidsceneModelName = (value: string) => {
64
59
  props.configForm.midscene.model.name = value;
65
- const option = activeMidsceneModelOptions.value.find((item) => item.value === value);
60
+ const option = codexMidsceneModelOptions.find((item) => item.value === value);
66
61
  if (option) {
67
62
  props.configForm.midscene.model.family = option.family;
68
63
  }
@@ -79,6 +74,10 @@ const updateMidsceneProvider = (value: string) => {
79
74
  emit('updateMidsceneModelProvider', value);
80
75
  }
81
76
  };
77
+
78
+ const openModelConfigGuide = () => {
79
+ window.open(modelConfigGuideUrl, '_blank', 'noopener,noreferrer');
80
+ };
82
81
  </script>
83
82
 
84
83
  <template>
@@ -204,29 +203,41 @@ const updateMidsceneProvider = (value: string) => {
204
203
  <el-form-item label="API Key">
205
204
  <el-input v-model="configForm.midscene.model.apiKey" show-password />
206
205
  </el-form-item>
207
- <el-form-item label="Model Name">
208
- <el-select
209
- :model-value="configForm.midscene.model.name"
210
- filterable
211
- @change="updateMidsceneModelName"
212
- >
213
- <el-option
214
- v-for="option in activeMidsceneModelOptions"
215
- :key="option.value"
216
- :label="option.label"
217
- :value="option.value"
218
- />
219
- </el-select>
206
+ <el-form-item>
207
+ <template #label>
208
+ <span class="config-field-label">
209
+ <span>Model Name</span>
210
+ <el-tooltip content="查看模型填写参考" placement="top">
211
+ <el-button
212
+ class="config-field-help"
213
+ text
214
+ size="small"
215
+ :icon="QuestionFilled"
216
+ aria-label="查看 Model Name 填写参考"
217
+ @click.stop="openModelConfigGuide"
218
+ />
219
+ </el-tooltip>
220
+ </span>
221
+ </template>
222
+ <el-input v-model="configForm.midscene.model.name" placeholder="例如:gpt-5.5" />
220
223
  </el-form-item>
221
- <el-form-item label="Model Family">
222
- <el-select v-model="configForm.midscene.model.family" filterable>
223
- <el-option
224
- v-for="option in midsceneModelFamilyOptions"
225
- :key="option.value"
226
- :label="option.label"
227
- :value="option.value"
228
- />
229
- </el-select>
224
+ <el-form-item>
225
+ <template #label>
226
+ <span class="config-field-label">
227
+ <span>Model Family</span>
228
+ <el-tooltip content="查看模型填写参考" placement="top">
229
+ <el-button
230
+ class="config-field-help"
231
+ text
232
+ size="small"
233
+ :icon="QuestionFilled"
234
+ aria-label="查看 Model Family 填写参考"
235
+ @click.stop="openModelConfigGuide"
236
+ />
237
+ </el-tooltip>
238
+ </span>
239
+ </template>
240
+ <el-input v-model="configForm.midscene.model.family" placeholder="例如:gpt-5" />
230
241
  </el-form-item>
231
242
  </template>
232
243
 
@@ -246,7 +257,7 @@ const updateMidsceneProvider = (value: string) => {
246
257
  @change="updateMidsceneModelName"
247
258
  >
248
259
  <el-option
249
- v-for="option in activeMidsceneModelOptions"
260
+ v-for="option in codexMidsceneModelOptions"
250
261
  :key="option.value"
251
262
  :label="option.label"
252
263
  :value="option.value"
package/src/style.css CHANGED
@@ -1587,6 +1587,18 @@ select {
1587
1587
  gap: 8px;
1588
1588
  }
1589
1589
 
1590
+ .config-field-label {
1591
+ display: inline-flex;
1592
+ align-items: center;
1593
+ gap: 4px;
1594
+ }
1595
+
1596
+ .config-field-help.el-button {
1597
+ height: 18px;
1598
+ padding: 0;
1599
+ color: var(--el-text-color-secondary);
1600
+ }
1601
+
1590
1602
  .config-module-card {
1591
1603
  min-width: 0;
1592
1604
  }