android-midscene-automation 0.2.1 → 0.2.3

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +15 -1
  2. package/README.md +15 -1
  3. package/package.json +1 -1
  4. package/remote-agent/index.ts +50 -22
  5. package/server/analytics.test.ts +46 -0
  6. package/server/analytics.ts +24 -0
  7. package/server/api-safety-analytics.test.ts +57 -0
  8. package/server/appium-auto-save.test.ts +6 -0
  9. package/server/appium-recorder/appium-runner.ts +30 -9
  10. package/server/appium-recorder/appium-version.test.ts +55 -0
  11. package/server/appium-recorder/managed-appium.ts +37 -1
  12. package/server/appium-recorder/managed-cleanup.test.ts +38 -8
  13. package/server/appium-recorder/replay-lifecycle.test.ts +123 -0
  14. package/server/appium-recorder/routes.ts +8 -18
  15. package/server/appium-recorder/run-history.ts +11 -0
  16. package/server/config-access.test.ts +27 -0
  17. package/server/config-access.ts +27 -0
  18. package/server/http-api.ts +63 -54
  19. package/server/operations/repository.ts +8 -0
  20. package/server/remote-agents/protocol.ts +1 -0
  21. package/server/remote-agents/registry.test.ts +65 -0
  22. package/server/remote-agents/registry.ts +60 -5
  23. package/server/remote-agents/replay-integration.test.ts +79 -0
  24. package/server/remote-agents/routes.ts +12 -15
  25. package/server/request-body.test.ts +40 -0
  26. package/server/request-body.ts +48 -0
  27. package/src/App.vue +96 -758
  28. package/src/analytics/summary.ts +75 -0
  29. package/src/api.ts +11 -0
  30. package/src/appium-recorder/AppiumPage.vue +4 -1
  31. package/src/components/AppSidebar.vue +31 -0
  32. package/src/components/analytics/AnalysisCharts.vue +189 -0
  33. package/src/components/analytics/ReportAnalysis.vue +78 -0
  34. package/src/components/config/AppiumConfigPanel.vue +4 -1
  35. package/src/components/config/AppiumModelSettings.vue +1 -1
  36. package/src/components/config/BaseConfigPanel.vue +6 -2
  37. package/src/components/config/MidsceneConfigPanel.vue +4 -5
  38. package/src/components/help/HelpCenter.vue +185 -0
  39. package/src/composables/use-device-preview.ts +279 -0
  40. package/src/composables/use-initial-resource.ts +17 -0
  41. package/src/composables/use-script-execution.ts +277 -0
  42. package/src/config/credentials.ts +2 -0
  43. package/src/config/use-appium-auto-save.ts +22 -11
  44. package/src/config/use-model-config.ts +157 -0
  45. package/src/main.ts +2 -2
  46. package/src/pages/AnalysisPage.vue +60 -0
  47. package/src/pages/ConfigPage.vue +36 -5
  48. package/src/style.css +711 -123
  49. package/src/types.ts +7 -1
  50. package/src/components/help/HelpCenterDialog.vue +0 -160
  51. package/src/ui-refresh.css +0 -677
package/CHANGELOG.md CHANGED
@@ -1,8 +1,22 @@
1
1
  # 更新记录
2
+ ## v0.2.3
3
+ - 修复长时间执行 ADB 操作、延时或 AI 识别后,Appium 因默认 60 秒命令空闲超时销毁会话,导致后续 Home 键、点击等节点报“invalid session id”的问题;托管服务关闭会话空闲计时,外部服务通过心跳保活并保留 60 秒空闲回收,避免创建请求取消或清理失败后遗留永久会话。
4
+ - 为启动 App、清理数据、坐标点击与滑动补齐 ADB 超时和取消控制;终止前台检查后不再继续启动 App,异常结束仍清理会话。
5
+ - 修复远程回放固定 10 分钟超时后丢失结果的问题:任务随代理心跳续期,离线任务支持恢复连接后终止与补交历史记录,结果上报失败自动重试且不会重复执行脚本。
6
+ - 修复远程设备无法手动终止回放的问题,通过独立心跳传递取消信号,兼容任务派发前取消,并阻止同一设备重复提交回放。
7
+
8
+ ## v0.2.2
9
+ - 更新浅色后台界面:蓝色主色、白色卡片、浅灰背景、可折叠侧边栏与顶部页签。
10
+ - 新增“概览 → 分析页”,根据已保存脚本和真实执行记录分别汇总 Midscene、Appium 测试数据。
11
+ - 修复配置加载失败后出现空白可保存表单的问题,增加独立错误提示和重试;后端断线后自动尝试重连并支持手动重连,保留页面编辑内容。
12
+ - Appium 配置自动保存失败后保留最新待保存内容并提供“重试保存”;修复切换或关闭 Appium 页签后,离开页面时未保存草稿保护失效的问题。
13
+ - Appium 顶部工具栏改为随页面布局排列,窄屏自动换行,避免遮挡面包屑;统一全站样式与中文组件文案,合并重复主题声明。
14
+ - 完善隔离数据环境下的浏览器回归测试,以及请求校验、配置保护、自动保存重试和统计口径测试。
15
+
2
16
  ## v0.2.1
3
17
  - 侧边栏底部新增“帮助与版本”入口。
4
18
  - AI 识别预设提示词支持编辑测试场景名称和提示词内容,默认预设与自定义预设均可修改并自动保存。
5
- - AI 识别启用分支后可勾选“启用兜底”,再选择进入左侧 true 或右侧 false 分支;适用范围改为问号提示,正常识别结果不受影响。
19
+ - AI 识别启用分支后可勾选“启用兜底”,再选择进入左侧 true 或右侧 false 分支;正常识别结果不受影响。
6
20
 
7
21
  ## v0.1.43
8
22
  - 新增独立的“提示词优化模型”,统一用于优化 AI 识别提示词、报告提示词和生成回放测试报告总结;AI 识别与回放报告均可输入简要要求后由模型补充提示词,模型测试成功改为弹窗提示。
package/README.md CHANGED
@@ -1358,10 +1358,24 @@ Linux: ~/.local/share/android-midscene-automation
1358
1358
 
1359
1359
  # 项目更新记录
1360
1360
 
1361
+ ## v0.2.3
1362
+ - 修复长时间执行 ADB 操作、延时或 AI 识别后,Appium 因默认 60 秒命令空闲超时销毁会话,导致后续 Home 键、点击等节点报“invalid session id”的问题;托管服务关闭会话空闲计时,外部服务通过心跳保活并保留 60 秒空闲回收,避免创建请求取消或清理失败后遗留永久会话。
1363
+ - 为启动 App、清理数据、坐标点击与滑动补齐 ADB 超时和取消控制;终止前台检查后不再继续启动 App,异常结束仍清理会话。
1364
+ - 修复远程回放固定 10 分钟超时后丢失结果的问题:任务随代理心跳续期,离线任务支持恢复连接后终止与补交历史记录,结果上报失败自动重试且不会重复执行脚本。
1365
+ - 修复远程设备无法手动终止回放的问题,通过独立心跳传递取消信号,兼容任务派发前取消,并阻止同一设备重复提交回放。
1366
+
1367
+ ## v0.2.2
1368
+ - 更新浅色后台界面:蓝色主色、白色卡片、浅灰背景、可折叠侧边栏与顶部页签。
1369
+ - 新增“概览 → 分析页”,根据已保存脚本和真实执行记录分别汇总 Midscene、Appium 测试数据。
1370
+ - 修复配置加载失败后出现空白可保存表单的问题,增加独立错误提示和重试;后端断线后自动尝试重连并支持手动重连,保留页面编辑内容。
1371
+ - Appium 配置自动保存失败后保留最新待保存内容并提供“重试保存”;修复切换或关闭 Appium 页签后,离开页面时未保存草稿保护失效的问题。
1372
+ - Appium 顶部工具栏改为随页面布局排列,窄屏自动换行,避免遮挡面包屑;统一全站样式与中文组件文案,合并重复主题声明。
1373
+ - 完善隔离数据环境下的浏览器回归测试,以及请求校验、配置保护、自动保存重试和统计口径测试。
1374
+
1361
1375
  ## v0.2.1
1362
1376
  - 侧边栏底部新增“帮助与版本”入口。
1363
1377
  - AI 识别预设提示词支持编辑测试场景名称和提示词内容,默认预设与自定义预设均可修改并自动保存。
1364
- - AI 识别启用分支后可勾选“启用兜底”,再选择进入左侧 true 或右侧 false 分支;适用范围改为问号提示,正常识别结果不受影响。
1378
+ - AI 识别启用分支后可勾选“启用兜底”,再选择进入左侧 true 或右侧 false 分支;正常识别结果不受影响。
1365
1379
 
1366
1380
  ## v0.1.43
1367
1381
  - 新增独立的“提示词优化模型”,统一用于优化 AI 识别提示词、报告提示词和生成回放测试报告总结;AI 识别与回放报告均可输入简要要求后由模型补充提示词,模型测试成功改为弹窗提示。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "android-midscene-automation",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/oooooooko/android-midscene-automation.git"
@@ -6,7 +6,9 @@ import type { RemoteAgentDevice, RemoteCommand } from '../server/remote-agents/p
6
6
  import { replayAppiumScript } from '../server/appium-recorder/appium-runner';
7
7
  import type { AppiumRecordedScriptRecord } from '../server/appium-recorder/repository';
8
8
 
9
- const VERSION = '0.1.0';
9
+ const VERSION = '0.2.3';
10
+ const replays = new Map<string, AbortController>();
11
+ let lastHeartbeatAt = Date.now();
10
12
 
11
13
  function argValue(name: string, fallback = '') {
12
14
  const index = process.argv.indexOf(`--${name}`);
@@ -25,7 +27,7 @@ if (!server) {
25
27
 
26
28
  function execText(command: string, args: string[] = []) {
27
29
  return new Promise<string>((resolve, reject) => {
28
- execFile(command, args, { maxBuffer: 20 * 1024 * 1024 }, (error, stdout, stderr) => {
30
+ execFile(command, args, { maxBuffer: 20 * 1024 * 1024, timeout: 10000 }, (error, stdout, stderr) => {
29
31
  if (error) {
30
32
  reject(new Error(stderr || error.message));
31
33
  return;
@@ -37,7 +39,7 @@ function execText(command: string, args: string[] = []) {
37
39
 
38
40
  function execBuffer(command: string, args: string[] = []) {
39
41
  return new Promise<Buffer>((resolve, reject) => {
40
- execFile(command, args, { encoding: 'buffer', maxBuffer: 20 * 1024 * 1024 }, (error, stdout, stderr) => {
42
+ execFile(command, args, { encoding: 'buffer', maxBuffer: 20 * 1024 * 1024, timeout: 10000 }, (error, stdout, stderr) => {
41
43
  if (error) {
42
44
  reject(new Error(Buffer.isBuffer(stderr) ? stderr.toString() : stderr || error.message));
43
45
  return;
@@ -50,10 +52,11 @@ function execBuffer(command: string, args: string[] = []) {
50
52
  async function postJson<T>(url: string, body: unknown) {
51
53
  const response = await fetch(url, {
52
54
  method: 'POST',
55
+ signal: AbortSignal.timeout(10000),
53
56
  headers: { 'Content-Type': 'application/json' },
54
57
  body: JSON.stringify(body),
55
58
  });
56
- const payload = await response.json().catch(() => ({})) as T & { message?: string };
59
+ const payload = await response.json() as T & { message?: string };
57
60
  if (!response.ok) throw new Error(payload.message || `HTTP ${response.status}`);
58
61
  return payload;
59
62
  }
@@ -94,7 +97,7 @@ async function getCurrentActivity(deviceId: string) {
94
97
  return resumedLine?.match(/\s([\w.$]+\/[\w.$]+)\s/)?.[1] || '';
95
98
  }
96
99
 
97
- async function handleCommand(command: RemoteCommand) {
100
+ async function handleCommand(command: RemoteCommand, signal?: AbortSignal) {
98
101
  const payload = command.payload || {};
99
102
  if (command.type === 'screenshot') {
100
103
  const image = await execBuffer('adb', ['-s', command.deviceId, 'exec-out', 'screencap', '-p']);
@@ -135,8 +138,10 @@ async function handleCommand(command: RemoteCommand) {
135
138
  if (command.type === 'replay') {
136
139
  const script = payload.script as AppiumRecordedScriptRecord | undefined;
137
140
  if (!script) throw new Error('远程回放缺少脚本内容');
138
- return await replayAppiumScript(script, command.deviceId, undefined, undefined, {
141
+ return await replayAppiumScript(script, command.deviceId, undefined, signal, {
139
142
  screenshotReport: payload.screenshotReport === true,
143
+ reportSummaryEnabled: typeof payload.reportSummaryEnabled === 'boolean' ? payload.reportSummaryEnabled : undefined,
144
+ reportSummaryPrompt: typeof payload.reportSummaryPrompt === 'string' ? payload.reportSummaryPrompt : undefined,
140
145
  parameters: payload.parameters as import('../src/appium-recorder/variables').TestVariable[] | undefined,
141
146
  globalVariables: payload.globalVariables as import('../src/appium-recorder/variables').TestVariable[] | undefined,
142
147
  linkedScripts: payload.linkedScripts as AppiumRecordedScriptRecord[] | undefined,
@@ -150,48 +155,71 @@ async function heartbeat() {
150
155
  console.warn(`扫描设备失败:${error instanceof Error ? error.message : String(error)}`);
151
156
  return [];
152
157
  });
153
- await postJson(`${server}/api/remote-agents/heartbeat`, {
158
+ const control = await postJson<{ cancelledCommandIds?: string[] }>(`${server}/api/remote-agents/heartbeat`, {
154
159
  agentId,
155
160
  agentName,
156
161
  version: VERSION,
157
162
  token,
158
163
  devices,
159
164
  });
165
+ lastHeartbeatAt = Date.now();
166
+ for (const id of control.cancelledCommandIds || []) replays.get(id)?.abort();
160
167
  console.log(`已上报 ${devices.length} 台设备到 ${server}`);
161
168
  }
162
169
 
163
170
  async function reportResult(command: RemoteCommand, ok: boolean, data?: unknown, error?: string) {
164
- await postJson(`${server}/api/remote-agents/result`, {
165
- agentId,
166
- token,
167
- commandId: command.id,
168
- ok,
169
- data,
170
- error,
171
- });
171
+ // 上报失败只重试结果,绝不重新执行已经操作过设备的脚本。
172
+ for (;;) {
173
+ try {
174
+ await postJson(`${server}/api/remote-agents/result`, { agentId, token, commandId: command.id, ok, data, error });
175
+ return;
176
+ } catch (error) {
177
+ if (command.type !== 'replay') throw error;
178
+ console.warn(`结果上报失败,正在重试:${error instanceof Error ? error.message : String(error)}`);
179
+ await new Promise(resolve => setTimeout(resolve, 3000));
180
+ }
181
+ }
172
182
  }
173
183
 
174
184
  async function pollOnce() {
175
185
  const url = new URL(`${server}/api/remote-agents/poll`);
176
186
  url.searchParams.set('agentId', agentId);
177
187
  if (token) url.searchParams.set('token', token);
178
- const response = await fetch(url);
188
+ const response = await fetch(url, { signal: AbortSignal.timeout(10000) });
179
189
  const payload = await response.json().catch(() => ({})) as { command?: RemoteCommand | null; message?: string };
180
190
  if (!response.ok) throw new Error(payload.message || `HTTP ${response.status}`);
181
191
  if (!payload.command) return;
182
- try {
183
- const result = await handleCommand(payload.command);
184
- await reportResult(payload.command, true, result);
185
- } catch (error) {
186
- await reportResult(payload.command, false, undefined, error instanceof Error ? error.message : String(error));
192
+ const command = payload.command;
193
+ const controller = command.type === 'replay' ? new AbortController() : undefined;
194
+ if (controller) {
195
+ replays.set(command.id, controller);
196
+ if (command.cancelled) controller.abort();
187
197
  }
198
+ try {
199
+ let result: unknown;
200
+ try {
201
+ result = await handleCommand(command, controller?.signal);
202
+ } catch (error) {
203
+ await reportResult(command, false, undefined, error instanceof Error ? error.message : String(error));
204
+ return;
205
+ }
206
+ await reportResult(command, true, result);
207
+ } finally { replays.delete(command.id); }
188
208
  }
189
209
 
190
210
  async function main() {
191
211
  console.log(`Remote Agent 启动:${agentId}`);
192
212
  console.log(`中央服务:${server}`);
193
213
  await heartbeat();
194
- setInterval(() => void heartbeat().catch((error) => console.warn(`心跳失败:${error.message}`)), 3000);
214
+ let heartbeatTask: Promise<void> | undefined;
215
+ setInterval(() => {
216
+ if (Date.now() - lastHeartbeatAt >= 45000) {
217
+ for (const controller of replays.values()) controller.abort();
218
+ }
219
+ if (!heartbeatTask) heartbeatTask = heartbeat()
220
+ .catch(error => console.warn(`心跳失败:${error.message}`))
221
+ .finally(() => { heartbeatTask = undefined; });
222
+ }, 3000);
195
223
  for (;;) {
196
224
  await pollOnce().catch((error) => console.warn(`轮询失败:${error.message}`));
197
225
  await new Promise((resolve) => setTimeout(resolve, 300));
@@ -0,0 +1,46 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { summarizeTests, type TestRun } from '../src/analytics/summary';
4
+
5
+ test('analytics includes unrun and historical scripts and excludes stopped/running from pass rate and average duration', () => {
6
+ const runs: TestRun[] = ['passed', 'failed', 'stopped', 'running'].map((status, i) => ({
7
+ scriptId: 'a', scriptName: 'old name', status: status as TestRun['status'], startedAt: `2026-09-2${i}T00:00:00Z`, durationMs: 1000 * (i + 1),
8
+ }));
9
+ runs.push({ scriptId: 'deleted', scriptName: '历史测试', status: 'passed', startedAt: '2026-09-01T00:00:00Z', durationMs: 10 });
10
+ const result = summarizeTests([{ id: 'a', name: '新名称' }, { id: 'b', name: '未执行' }], runs);
11
+ assert.equal(result.scripts, 2);
12
+ assert.equal(result.runs, 5);
13
+ assert.equal(result.testedScripts, 1);
14
+ assert.equal(result.passRate, 2 / 3);
15
+ const a = result.rows.find(row => row.id === 'a')!;
16
+ assert.equal(a.name, '新名称');
17
+ assert.equal(a.averageDurationMs, 1500);
18
+ assert.equal(a.passRate, 0.5);
19
+ assert.equal(a.lastStatus, 'running');
20
+ assert.equal(result.rows.find(row => row.id === 'b')?.passRate, null);
21
+ assert.equal(result.rows.find(row => row.id === 'deleted')?.archived, true);
22
+ const recent = summarizeTests([{ id: 'b', name: '未执行' }], runs, Date.parse('2026-09-22T00:00:00Z'));
23
+ assert.equal(recent.runs, 2);
24
+ assert.equal(recent.passRate, null);
25
+ assert.equal(recent.rows.find(row => row.id === 'a')?.averageDurationMs, null);
26
+ });
27
+
28
+
29
+ test('daily/monthly charts zero-fill calendar buckets and retain older runs in all-time totals', () => {
30
+ const now = new Date(2026, 0, 2, 12).getTime();
31
+ const runs: TestRun[] = [
32
+ { scriptId: 'a', scriptName: 'a', status: 'passed', startedAt: new Date(2026, 0, 2, 8).toISOString(), durationMs: 10 },
33
+ { scriptId: 'a', scriptName: 'a', status: 'failed', startedAt: new Date(2024, 0, 1).toISOString(), durationMs: 10 },
34
+ ];
35
+ const all = summarizeTests([], runs, 0, now);
36
+ assert.equal(all.runs, 2);
37
+ assert.equal(all.daily.length, 30);
38
+ assert.equal(all.monthly.length, 12);
39
+ assert.deepEqual(all.monthly.at(-1), { date: '2026-01', runs: 1 });
40
+ assert.equal(all.daily.reduce((sum, point) => sum + point.runs, 0), 1);
41
+ const recent = summarizeTests([], runs, new Date(2025, 11, 27).getTime(), now);
42
+ assert.equal(recent.daily.length, 7);
43
+ assert.equal(recent.monthly.length, 2);
44
+ assert.equal(recent.runs, 1);
45
+ assert.equal(recent.daily.at(-1)?.date, '2026-01-02');
46
+ });
@@ -0,0 +1,24 @@
1
+ import { summarizeTests, type AnalysisResponse, type TestStatus } from '../src/analytics/summary';
2
+ import { listScriptRecords } from './script-db';
3
+ import { listAppiumRecordedScripts } from './appium-recorder/repository';
4
+ import { listOperationAnalytics } from './operations/repository';
5
+ import { listRunAnalytics } from './appium-recorder/run-history';
6
+
7
+ export function getTestAnalytics(days = 0): AnalysisResponse {
8
+ const generatedAt = new Date().toISOString();
9
+ const start = new Date(generatedAt);
10
+ start.setDate(start.getDate() - Math.max(0, days - 1));
11
+ start.setHours(0, 0, 0, 0);
12
+ const since = days ? start.getTime() : 0;
13
+ const status: Record<string, TestStatus> = { succeeded: 'passed', failed: 'failed', cancelled: 'stopped', running: 'running' };
14
+ return {
15
+ generatedAt, days,
16
+ // Midscene's historical records identify scripts by name, not database ID.
17
+ midscene: summarizeTests(listScriptRecords().map(script => ({ id: script.name, name: script.name })), listOperationAnalytics().map(run => ({
18
+ scriptId: run.script_name, scriptName: run.script_name, startedAt: run.started_at,
19
+ durationMs: run.finished_at ? Date.parse(run.finished_at) - Date.parse(run.started_at) : null,
20
+ status: status[run.status],
21
+ })), since, Date.parse(generatedAt)),
22
+ appium: summarizeTests(listAppiumRecordedScripts().map(script => ({ id: script.id, name: script.name })), listRunAnalytics(), since, Date.parse(generatedAt)),
23
+ };
24
+ }
@@ -0,0 +1,57 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createServer } from 'node:http';
4
+ import { mkdtemp, rm } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import type { AnalysisResponse } from '../src/analytics/summary';
8
+
9
+ test('API rejects malformed requests safely and analytics reads all stored executions without a 100-record cap', async () => {
10
+ const root = await mkdtemp(join(tmpdir(), 'console-api-'));
11
+ const oldRoot = process.env.ANDROID_MIDSCENE_DATA_ROOT;
12
+ process.env.ANDROID_MIDSCENE_DATA_ROOT = root;
13
+ const { createApiMiddleware } = await import('./http-api');
14
+ const handler = createApiMiddleware();
15
+ const server = createServer((req, res) => handler(req, res, () => { res.statusCode = 404; res.end(); }));
16
+ try {
17
+ await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
18
+ const address = server.address();
19
+ assert.ok(address && typeof address === 'object');
20
+ const base = `http://127.0.0.1:${address.port}`;
21
+ for (const path of ['/api/config', '/api/config/appium', '/api/test-model', '/api/appium-recorder/scripts', '/api/generate']) {
22
+ assert.equal((await fetch(base + path, { method: 'POST', body: '{' })).status, 400, path);
23
+ }
24
+ assert.equal((await fetch(base + '/api/config', { headers: { Origin: 'https://foreign.example' } })).status, 403);
25
+ assert.equal((await fetch(base + '/api/config')).status, 200);
26
+ const { upsertScriptRecord } = await import('./script-db');
27
+ upsertScriptRecord({ name: 'Midscene 测试', promptTitle: '', sourcePrompt: '', code: 'private-code', filePath: '', steps: [] });
28
+ upsertScriptRecord({ name: '未执行', promptTitle: '', sourcePrompt: '', code: '', filePath: '', steps: [] });
29
+ const { initOperationRepository } = await import('./operations/repository');
30
+ const { runSql, sqlString } = await import('./storage/sqlite');
31
+ initOperationRepository();
32
+ const started = new Date(Date.now() - 10_000).toISOString();
33
+ const finished = new Date().toISOString();
34
+ runSql(Array.from({ length: 105 }, (_, i) => `INSERT INTO operations (id, kind, status, script_name, device_id, session_id, created_at, started_at, finished_at) VALUES ('op-${i}', 'script_run', '${i === 0 ? 'failed' : 'succeeded'}', 'Midscene 测试', '', '', ${sqlString(started)}, ${sqlString(started)}, ${sqlString(finished)});`).join('\n'));
35
+ const { saveAppiumRecordedScript } = await import('./appium-recorder/repository');
36
+ const script = saveAppiumRecordedScript({ name: 'Appium 测试', appPackage: 'example.test', steps: [] });
37
+ const { saveRunHistory } = await import('./appium-recorder/run-history');
38
+ saveRunHistory({ scriptId: script.id, scriptName: script.name, appPackage: 'example.test', appVersion: '1', deviceId: '', startedAt: started, durationMs: 4000, status: 'passed', nodes: [], output: 'private-output', frames: [] });
39
+ const response = await fetch(base + '/api/analytics');
40
+ const body = await response.text();
41
+ const summary = JSON.parse(body) as AnalysisResponse;
42
+ assert.equal(response.status, 200);
43
+ assert.equal(summary.midscene.runs, 105);
44
+ assert.equal(summary.midscene.scripts, 2);
45
+ assert.equal(summary.midscene.failed, 1);
46
+ assert.equal(summary.midscene.rows.find(row => row.name === '未执行')?.runs, 0);
47
+ assert.equal(summary.appium.runs, 1);
48
+ assert.equal(summary.appium.passRate, 1);
49
+ assert.equal(body.includes('private-'), false);
50
+ assert.equal((await fetch(base + '/api/analytics?days=invalid')).status, 400);
51
+ } finally {
52
+ server.closeAllConnections();
53
+ await new Promise<void>(resolve => server.close(() => resolve()));
54
+ if (oldRoot === undefined) delete process.env.ANDROID_MIDSCENE_DATA_ROOT; else process.env.ANDROID_MIDSCENE_DATA_ROOT = oldRoot;
55
+ await rm(root, { recursive: true, force: true });
56
+ }
57
+ });
@@ -52,7 +52,13 @@ test('loads quietly, serializes rapid edits, validates colors and surfaces save
52
52
  await delay(400);
53
53
  assert.equal(errors.length, 1);
54
54
  assert.match(auto.status.value, /失败/);
55
+ assert.equal(auto.failed.value, true);
56
+ assert.equal(auto.dirty.value, true);
55
57
  fail = false;
58
+ await auto.retry();
59
+ assert.equal(auto.failed.value, false);
60
+ assert.equal(auto.dirty.value, false);
61
+ assert.equal(submissions.at(-1)?.screenshotReport, true);
56
62
  form.appium.flowBackgroundColor = '#abcdef';
57
63
  await delay(400);
58
64
  assert.equal(auto.status.value, '已自动保存');
@@ -139,8 +139,9 @@ function normalizeTextForContains(value: string) {
139
139
  }
140
140
 
141
141
  function adbTap(deviceId: string, x: number, y: number) {
142
+ throwIfReplayStopped();
142
143
  return new Promise<void>((resolve, reject) => {
143
- execFile(getAdbCommand(), ['-s', deviceId, 'shell', 'input', 'tap', String(Math.round(x)), String(Math.round(y))], (error, _stdout, stderr) => {
144
+ execFile(getAdbCommand(), ['-s', deviceId, 'shell', 'input', 'tap', String(Math.round(x)), String(Math.round(y))], { timeout: 10000, signal: replayContext.getStore()?.signal }, (error, _stdout, stderr) => {
144
145
  if (error) {
145
146
  reject(new Error(stderr || error.message));
146
147
  return;
@@ -150,7 +151,8 @@ function adbTap(deviceId: string, x: number, y: number) {
150
151
  });
151
152
  }
152
153
 
153
- export function launchAppOnDevice(deviceId: string, packageName: string) {
154
+ export function launchAppOnDevice(deviceId: string, packageName: string, signal = replayContext.getStore()?.signal) {
155
+ signal?.throwIfAborted();
154
156
  if (!packageName) return Promise.reject(new Error('启动 APP 缺少包名'));
155
157
  return new Promise<void>((resolve, reject) => {
156
158
  execFile(getAdbCommand(), [
@@ -163,7 +165,7 @@ export function launchAppOnDevice(deviceId: string, packageName: string) {
163
165
  '-c',
164
166
  'android.intent.category.LAUNCHER',
165
167
  '1',
166
- ], { maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => {
168
+ ], { maxBuffer: 4 * 1024 * 1024, timeout: 30000, signal }, (error, stdout, stderr) => {
167
169
  const output = `${stdout || ''}\n${stderr || ''}`.trim();
168
170
  if (error || /no activities found|monkey aborted/i.test(output)) {
169
171
  reject(new Error(output || error?.message || `ADB 启动 ${packageName} 失败`));
@@ -174,7 +176,8 @@ export function launchAppOnDevice(deviceId: string, packageName: string) {
174
176
  });
175
177
  }
176
178
 
177
- export function clearAppDataOnDevice(deviceId: string, packageName: string) {
179
+ export function clearAppDataOnDevice(deviceId: string, packageName: string, signal = replayContext.getStore()?.signal) {
180
+ signal?.throwIfAborted();
178
181
  if (!packageName) return Promise.reject(new Error('清理 App 缓存缺少包名'));
179
182
  return new Promise<void>((resolve, reject) => {
180
183
  execFile(getAdbCommand(), [
@@ -184,7 +187,7 @@ export function clearAppDataOnDevice(deviceId: string, packageName: string) {
184
187
  'pm',
185
188
  'clear',
186
189
  packageName,
187
- ], { maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => {
190
+ ], { maxBuffer: 4 * 1024 * 1024, timeout: 30000, signal }, (error, stdout, stderr) => {
188
191
  const output = `${stdout || ''}\n${stderr || ''}`.trim();
189
192
  if (error || !/^success$/im.test(output)) {
190
193
  reject(new Error(output || error?.message || `ADB 清理 ${packageName} 失败`));
@@ -196,6 +199,7 @@ export function clearAppDataOnDevice(deviceId: string, packageName: string) {
196
199
  }
197
200
 
198
201
  function adbSwipe(deviceId: string, step: Required<AppiumRecordedStepRecord>['swipe']) {
202
+ throwIfReplayStopped();
199
203
  return new Promise<void>((resolve, reject) => {
200
204
  execFile(getAdbCommand(), [
201
205
  '-s',
@@ -208,7 +212,7 @@ function adbSwipe(deviceId: string, step: Required<AppiumRecordedStepRecord>['sw
208
212
  String(Math.round(step.endX)),
209
213
  String(Math.round(step.endY)),
210
214
  String(Math.round(step.duration)),
211
- ], (error, _stdout, stderr) => {
215
+ ], { timeout: Math.max(10000, step.duration + 5000), signal: replayContext.getStore()?.signal }, (error, _stdout, stderr) => {
212
216
  if (error) {
213
217
  reject(new Error(stderr || error.message));
214
218
  return;
@@ -294,7 +298,8 @@ async function appiumRequest<T>(
294
298
  try {
295
299
  const result = await timedAppiumFetch(requestUrl, {
296
300
  ...init,
297
- signal: options?.ignoreAbort ? undefined : context?.signal || init?.signal,
301
+ signal: options?.ignoreAbort ? undefined : (context?.signal && init?.signal
302
+ ? AbortSignal.any([context.signal, init.signal]) : context?.signal || init?.signal),
298
303
  headers: {
299
304
  'Content-Type': 'application/json',
300
305
  ...(init?.headers || {}),
@@ -415,6 +420,8 @@ async function createSession(deviceId: string) {
415
420
  'appium:udid': deviceId,
416
421
  'appium:autoLaunch': false,
417
422
  'appium:noReset': true,
423
+ // 托管进程由 finally 释放;外部服务保留回收期限,避免创建请求取消后遗留永久会话。
424
+ 'appium:newCommandTimeout': usesManagedAppiumServer() ? 0 : 60,
418
425
  'appium:skipDeviceInitialization': true,
419
426
  'appium:ignoreHiddenApiPolicyError': true,
420
427
  },
@@ -803,7 +810,7 @@ async function runStep(
803
810
 
804
811
  if (step.type === 'launchApp') {
805
812
  const packageName = step.value || '';
806
- if (await isAppInForeground(deviceId, packageName).catch(() => false)) {
813
+ if (await isAppInForeground(deviceId, packageName).catch(error => { throwIfReplayStopped(error); return false; })) {
807
814
  return `APP 已在前台,跳过启动 ${packageName}`;
808
815
  }
809
816
  await launchAppOnDevice(deviceId, packageName);
@@ -1566,6 +1573,7 @@ export async function replayAppiumScript(
1566
1573
  );
1567
1574
  let sessionId = '';
1568
1575
  let sessionCreationStarted = false;
1576
+ let stopHeartbeat: (() => Promise<void>) | undefined;
1569
1577
  let success = false;
1570
1578
  let stopped = false;
1571
1579
  let recording: Awaited<ReturnType<typeof startReplayVideo>> | undefined;
@@ -1594,6 +1602,18 @@ export async function replayAppiumScript(
1594
1602
  await resetUiAutomator2(targetDeviceId);
1595
1603
  sessionId = await createSession(targetDeviceId);
1596
1604
  }
1605
+ if (!managedAppium) {
1606
+ const heartbeatAbort = new AbortController();
1607
+ let heartbeatTask: Promise<unknown> | undefined;
1608
+ const timer = setInterval(() => {
1609
+ if (heartbeatTask || signal?.aborted) return;
1610
+ heartbeatTask = appiumRequest(`/session/${sessionId}/timeouts`, { signal: heartbeatAbort.signal }, { timeoutMs: 5000 })
1611
+ .catch(error => { if (!heartbeatAbort.signal.aborted && !signal?.aborted) lines.push(`Appium 保活失败:${errorDetail(error)}`); })
1612
+ .finally(() => { heartbeatTask = undefined; });
1613
+ }, 15000);
1614
+ timer.unref();
1615
+ stopHeartbeat = async () => { clearInterval(timer); heartbeatAbort.abort(); await heartbeatTask; };
1616
+ }
1597
1617
  lines.push(`Appium session 已创建:${sessionId}`);
1598
1618
  if (runOptions.recordVideo) {
1599
1619
  if (scope.privacy.enabled) throw new Error('本次运行包含敏感变量,为避免泄露已禁止录屏,请关闭录制回放视频');
@@ -1660,6 +1680,7 @@ export async function replayAppiumScript(
1660
1680
  lines.push('[录屏提示] 本次录像未能完整保存。请检查报告目录的写入权限、磁盘空间及设备连接;录像保存失败不等同于流程节点失败,请以节点执行日志为准。');
1661
1681
  }
1662
1682
  }
1683
+ await stopHeartbeat?.();
1663
1684
  let managedAppiumStopped = false;
1664
1685
  if (signal?.aborted && managedAppium) {
1665
1686
  await managedAppium.stop();
@@ -1669,7 +1690,7 @@ export async function replayAppiumScript(
1669
1690
  `/session/${sessionId}`,
1670
1691
  { method: 'DELETE' },
1671
1692
  { ignoreAbort: true, timeoutMs: 2000 },
1672
- ).catch(() => undefined);
1693
+ ).catch(error => { lines.push(`Appium 会话清理失败:${errorDetail(error)}${managedAppium ? '' : ';外部服务将在命令空闲 60 秒后回收会话'}`); });
1673
1694
  }
1674
1695
  if (managedAppium) {
1675
1696
  if (!managedAppiumStopped) await managedAppium.stop();
@@ -0,0 +1,55 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import childProcess from 'node:child_process';
4
+ import { syncBuiltinESMExports } from 'node:module';
5
+ import { getAppiumVersion } from './managed-appium';
6
+
7
+ test('Appium version uses the configured runtime and reports unavailable versions without a guessed fallback', async () => {
8
+ const original = { execFile: childProcess.execFile, fetch: globalThis.fetch };
9
+ const env = { executable: process.env.APPIUM_EXECUTABLE, url: process.env.APPIUM_SERVER_URL };
10
+ let output = '3.5.0-beta.1\n';
11
+ let commandError: Error | null = null;
12
+ let executions = 0;
13
+ try {
14
+ delete process.env.APPIUM_SERVER_URL;
15
+ process.env.APPIUM_EXECUTABLE = '/custom/appium';
16
+ childProcess.execFile = ((command, args, options, callback) => {
17
+ executions++;
18
+ assert.equal(command, '/custom/appium');
19
+ assert.deepEqual(args, ['--version']);
20
+ assert.equal(options.timeout, 5000);
21
+ queueMicrotask(() => callback(commandError, output, ''));
22
+ return {};
23
+ }) as typeof childProcess.execFile;
24
+ syncBuiltinESMExports();
25
+ assert.equal((await getAppiumVersion()).version, '3.5.0-beta.1');
26
+ output = 'Warning: Node 24.19.0 is unsupported';
27
+ assert.equal((await getAppiumVersion()).version, null, 'do not mistake diagnostic output for the Appium version');
28
+ commandError = Object.assign(new Error('missing'), { code: 'ENOENT' });
29
+ assert.match((await getAppiumVersion()).message, /未检测到/);
30
+ commandError = new Error('timeout');
31
+ assert.match((await getAppiumVersion()).message, /读取.*失败/);
32
+
33
+ process.env.APPIUM_SERVER_URL = 'http://appium.example/wd/hub/';
34
+ const localExecutions = executions;
35
+ globalThis.fetch = async (url, options) => {
36
+ assert.equal(url, 'http://appium.example/wd/hub/status');
37
+ assert.ok(options?.signal);
38
+ return Response.json({ value: { ready: true, build: { version: '2.19.0' } } });
39
+ };
40
+ assert.deepEqual(await getAppiumVersion(), { version: '2.19.0', source: 'server', message: '当前配置的 Appium 服务' });
41
+ globalThis.fetch = async () => Response.json({ value: {} });
42
+ assert.equal((await getAppiumVersion()).version, null);
43
+ globalThis.fetch = async () => new Response('unavailable', { status: 503 });
44
+ assert.match((await getAppiumVersion()).message, /无法连接/);
45
+ globalThis.fetch = async () => { throw new Error('offline'); };
46
+ assert.equal((await getAppiumVersion()).version, null);
47
+ assert.equal(executions, localExecutions, 'external service failures must not show an unrelated local version');
48
+ } finally {
49
+ childProcess.execFile = original.execFile;
50
+ globalThis.fetch = original.fetch;
51
+ syncBuiltinESMExports();
52
+ if (env.executable === undefined) delete process.env.APPIUM_EXECUTABLE; else process.env.APPIUM_EXECUTABLE = env.executable;
53
+ if (env.url === undefined) delete process.env.APPIUM_SERVER_URL; else process.env.APPIUM_SERVER_URL = env.url;
54
+ }
55
+ });
@@ -1,5 +1,41 @@
1
1
  import { execFile, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
2
2
  import { createServer } from 'node:net';
3
+ import type { AppiumVersionInfo } from '../../src/types';
4
+
5
+ function appiumExecutable() {
6
+ return process.env.APPIUM_EXECUTABLE?.trim() || (process.platform === 'win32' ? 'appium.cmd' : 'appium');
7
+ }
8
+
9
+ export async function getAppiumVersion(): Promise<AppiumVersionInfo> {
10
+ const source = usesManagedAppiumServer() ? 'local' : 'server';
11
+ try {
12
+ let output: unknown;
13
+ if (source === 'server') {
14
+ const url = process.env.APPIUM_SERVER_URL!.trim().replace(/\/+$/, '');
15
+ const response = await fetch(`${url}/status`, { signal: AbortSignal.timeout(3000) });
16
+ if (!response.ok) throw new Error('Appium status request failed');
17
+ const payload = await response.json() as { value?: { build?: { version?: unknown } } } | null;
18
+ output = payload?.value?.build?.version;
19
+ } else {
20
+ output = await new Promise<string>((resolve, reject) => {
21
+ execFile(appiumExecutable(), ['--version'], {
22
+ timeout: 5000, maxBuffer: 64 * 1024, windowsHide: true, shell: process.platform === 'win32',
23
+ }, (error, stdout) => error ? reject(error) : resolve(stdout));
24
+ });
25
+ }
26
+ const version = typeof output === 'string'
27
+ ? output.match(/^\s*v?(\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?)\s*$/m)?.[1]
28
+ : undefined;
29
+ return { version: version ?? null, source, message: version
30
+ ? (source === 'local' ? '本机已安装 · Android 自动化引擎' : '当前配置的 Appium 服务')
31
+ : '未返回有效版本号,请检查 Appium 安装或服务状态' };
32
+ } catch (error) {
33
+ const missing = error instanceof Error && 'code' in error && error.code === 'ENOENT';
34
+ return { version: null, source, message: source === 'server'
35
+ ? '暂时无法连接 Appium 服务,请检查服务后刷新'
36
+ : missing ? '未检测到 Appium,请安装或检查可执行文件路径' : '读取 Appium 版本失败,请检查安装后刷新' };
37
+ }
38
+ }
3
39
 
4
40
  type ManagedAppiumServer = {
5
41
  serverUrl: string;
@@ -110,7 +146,7 @@ export async function startManagedAppiumServer(
110
146
  ): Promise<ManagedAppiumServer> {
111
147
  const port = await reserveLocalPort();
112
148
  const serverUrl = `http://127.0.0.1:${port}`;
113
- const executable = process.env.APPIUM_EXECUTABLE?.trim() || (process.platform === 'win32' ? 'appium.cmd' : 'appium');
149
+ const executable = appiumExecutable();
114
150
  const args = [
115
151
  '--address',
116
152
  '127.0.0.1',