android-midscene-automation 0.2.2 → 0.2.4

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,6 +1,15 @@
1
1
  # 更新记录
2
+ ## v0.2.4
3
+ - Midscene Android、Android Playground、Core、Playground、Shared 和 Web 模块统一升级至 1.14.0。
4
+ - 修复 Midscene 测试脚本生成过程中切换“AI 生成 / 自定义步骤编排”会清空生成状态和结果的问题;生成任务继续在后台执行,模式切换后保留已生成步骤与代码。
5
+
6
+ ## v0.2.3
7
+ - 修复长时间执行 ADB 操作、延时或 AI 识别后,Appium 因默认 60 秒命令空闲超时销毁会话,导致后续 Home 键、点击等节点报“invalid session id”的问题;托管服务关闭会话空闲计时,外部服务通过心跳保活并保留 60 秒空闲回收,避免创建请求取消或清理失败后遗留永久会话。
8
+ - 为启动 App、清理数据、坐标点击与滑动补齐 ADB 超时和取消控制;终止前台检查后不再继续启动 App,异常结束仍清理会话。
9
+ - 修复远程回放固定 10 分钟超时后丢失结果的问题:任务随代理心跳续期,离线任务支持恢复连接后终止与补交历史记录,结果上报失败自动重试且不会重复执行脚本。
10
+ - 修复远程设备无法手动终止回放的问题,通过独立心跳传递取消信号,兼容任务派发前取消,并阻止同一设备重复提交回放。
11
+
2
12
  ## v0.2.2
3
- - 更新 Vben 风格的浅色后台界面:蓝色主色、白色卡片、浅灰背景、可折叠侧边栏与顶部页签。
4
13
  - 新增“概览 → 分析页”,根据已保存脚本和真实执行记录分别汇总 Midscene、Appium 测试数据。
5
14
  - 修复配置加载失败后出现空白可保存表单的问题,增加独立错误提示和重试;后端断线后自动尝试重连并支持手动重连,保留页面编辑内容。
6
15
  - Appium 配置自动保存失败后保留最新待保存内容并提供“重试保存”;修复切换或关闭 Appium 页签后,离开页面时未保存草稿保护失效的问题。
package/README.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  一个面向 Android App 的自动化测试工具,同时支持 Midscene AI 测试和 Appium 组件树录制回放。
4
4
 
5
+ ## 界面预览
6
+
7
+ ![](./screen/c1.jpg)
8
+
9
+ ### Midscene
10
+
11
+ ![](./screen/m1.jpg)
12
+
13
+ ![](./screen/m2.jpg)
14
+
15
+ ![](./screen/m3.jpg)
16
+
17
+ ### Appium
18
+
19
+ ![](./screen/a1.jpg)
20
+
21
+ ![](./screen/a2.jpg)
22
+
23
+ ![](./screen/a3.jpg)
24
+
25
+ ![](./screen/a4.jpg)
26
+
27
+ ![](./screen/a5.jpg)
28
+
5
29
  ## 支持的测试方式
6
30
 
7
31
  | 测试方式 | 页面入口 | 工作方式 | 模型要求 | 适用场景 |
@@ -1358,8 +1382,17 @@ Linux: ~/.local/share/android-midscene-automation
1358
1382
 
1359
1383
  # 项目更新记录
1360
1384
 
1385
+ ## v0.2.4
1386
+ - Midscene Android、Android Playground、Core、Playground、Shared 和 Web 模块统一升级至 1.14.0。
1387
+ - 修复 Midscene 测试脚本生成过程中切换“AI 生成 / 自定义步骤编排”会清空生成状态和结果的问题;生成任务继续在后台执行,模式切换后保留已生成步骤与代码。
1388
+
1389
+ ## v0.2.3
1390
+ - 修复长时间执行 ADB 操作、延时或 AI 识别后,Appium 因默认 60 秒命令空闲超时销毁会话,导致后续 Home 键、点击等节点报“invalid session id”的问题;托管服务关闭会话空闲计时,外部服务通过心跳保活并保留 60 秒空闲回收,避免创建请求取消或清理失败后遗留永久会话。
1391
+ - 为启动 App、清理数据、坐标点击与滑动补齐 ADB 超时和取消控制;终止前台检查后不再继续启动 App,异常结束仍清理会话。
1392
+ - 修复远程回放固定 10 分钟超时后丢失结果的问题:任务随代理心跳续期,离线任务支持恢复连接后终止与补交历史记录,结果上报失败自动重试且不会重复执行脚本。
1393
+ - 修复远程设备无法手动终止回放的问题,通过独立心跳传递取消信号,兼容任务派发前取消,并阻止同一设备重复提交回放。
1394
+
1361
1395
  ## v0.2.2
1362
- - 更新 Vben 风格的浅色后台界面:蓝色主色、白色卡片、浅灰背景、可折叠侧边栏与顶部页签。
1363
1396
  - 新增“概览 → 分析页”,根据已保存脚本和真实执行记录分别汇总 Midscene、Appium 测试数据。
1364
1397
  - 修复配置加载失败后出现空白可保存表单的问题,增加独立错误提示和重试;后端断线后自动尝试重连并支持手动重连,保留页面编辑内容。
1365
1398
  - Appium 配置自动保存失败后保留最新待保存内容并提供“重试保存”;修复切换或关闭 Appium 页签后,离开页面时未保存草稿保护失效的问题。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "android-midscene-automation",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/oooooooko/android-midscene-automation.git"
@@ -45,12 +45,12 @@
45
45
  "mediabunny": "1.58.0",
46
46
  "@dagrejs/dagre": "1.1.5",
47
47
  "@element-plus/icons-vue": "2.3.2",
48
- "@midscene/android": "https://registry.npmjs.org/@midscene/android/-/android-1.12.7.tgz",
49
- "@midscene/android-playground": "https://registry.npmjs.org/@midscene/android-playground/-/android-playground-1.12.7.tgz",
50
- "@midscene/core": "https://registry.npmjs.org/@midscene/core/-/core-1.12.7.tgz",
51
- "@midscene/playground": "https://registry.npmjs.org/@midscene/playground/-/playground-1.12.7.tgz",
52
- "@midscene/shared": "https://registry.npmjs.org/@midscene/shared/-/shared-1.12.7.tgz",
53
- "@midscene/web": "https://registry.npmjs.org/@midscene/web/-/web-1.12.7.tgz",
48
+ "@midscene/android": "https://registry.npmjs.org/@midscene/android/-/android-1.14.0.tgz",
49
+ "@midscene/android-playground": "https://registry.npmjs.org/@midscene/android-playground/-/android-playground-1.14.0.tgz",
50
+ "@midscene/core": "https://registry.npmjs.org/@midscene/core/-/core-1.14.0.tgz",
51
+ "@midscene/playground": "https://registry.npmjs.org/@midscene/playground/-/playground-1.14.0.tgz",
52
+ "@midscene/shared": "https://registry.npmjs.org/@midscene/shared/-/shared-1.14.0.tgz",
53
+ "@midscene/web": "https://registry.npmjs.org/@midscene/web/-/web-1.14.0.tgz",
54
54
  "@techstark/opencv-js": "5.0.0-release.1",
55
55
  "@vitejs/plugin-vue": "6.0.7",
56
56
  "@vue-flow/core": "1.48.2",
@@ -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));
@@ -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();
@@ -8,12 +8,14 @@ import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
8
8
  import { tmpdir } from 'node:os';
9
9
  import { join } from 'node:path';
10
10
 
11
- test('owned device instrumentation is cleaned after success, abort and failed session creation; external servers are untouched', async () => {
11
+ test('replay survives command idle periods and cleans sessions after success, abort and creation failure', async () => {
12
12
  const root = await mkdtemp(join(tmpdir(), 'managed-cleanup-'));
13
13
  const env = { ...process.env };
14
14
  const original = { execFile: childProcess.execFile, spawn: childProcess.spawn, fetch: globalThis.fetch };
15
15
  const calls: string[][] = [];
16
16
  let mode = 'success', stopped = false;
17
+ let elapsedMs = 0, lastCommandMs = 0, commandTimeoutMs = 60_000;
18
+ let keyCommands = 0, deletedSessions = 0;
17
19
  let controller = new AbortController();
18
20
  try {
19
21
  const sdk = join(root, 'sdk');
@@ -44,8 +46,20 @@ test('owned device instrumentation is cleaned after success, abort and failed se
44
46
  const path = new URL(String(input)).pathname;
45
47
  if (path === '/session' && init?.method === 'POST') {
46
48
  if (mode === 'creation-failure') return Response.json({ value: { error: 'session not created', message: 'creation failed' } }, { status: 500 });
49
+ const capabilities = JSON.parse(String(init.body)).capabilities.alwaysMatch;
50
+ commandTimeoutMs = (capabilities['appium:newCommandTimeout'] ?? 60) * 1000;
51
+ lastCommandMs = elapsedMs;
47
52
  return Response.json({ value: { sessionId: 'test-session' } });
48
53
  }
54
+ if (path.endsWith('/press_keycode')) {
55
+ keyCommands++;
56
+ // Model Appium's idle expiry without waiting a real minute or touching a device.
57
+ if (commandTimeoutMs && elapsedMs - lastCommandMs > commandTimeoutMs) {
58
+ return Response.json({ value: { error: 'invalid session id', message: 'New Command Timeout expired' } }, { status: 404 });
59
+ }
60
+ lastCommandMs = elapsedMs;
61
+ }
62
+ if (path === '/session/test-session' && init?.method === 'DELETE') deletedSessions++;
49
63
  return Response.json({ value: {} });
50
64
  };
51
65
  const { replayAppiumScript } = await import('./appium-runner');
@@ -53,19 +67,35 @@ test('owned device instrumentation is cleaned after success, abort and failed se
53
67
  id: 'cleanup', name: 'cleanup', deviceId: 'test-device', appPackage: '', appActivity: '', createdAt: '', updatedAt: '',
54
68
  steps: [{ id: 'log', type: 'log' as const, label: 'log', value: 'ok' }],
55
69
  };
56
- for (mode of ['success', 'abort', 'creation-failure', 'cleanup-failure', 'external']) {
70
+ for (mode of ['success', 'long-idle', 'abort', 'creation-failure', 'cleanup-failure', 'external', 'external-abort']) {
57
71
  calls.length = 0;
72
+ elapsedMs = lastCommandMs = keyCommands = deletedSessions = 0;
58
73
  controller = new AbortController();
59
- if (mode === 'external') process.env.APPIUM_SERVER_URL = 'http://external.invalid';
60
- const result = await replayAppiumScript(script, 'test-device', line => {
61
- if (mode === 'abort' && line.includes('Appium session 已创建')) controller.abort();
74
+ const external = mode.startsWith('external');
75
+ const aborted = mode.endsWith('abort');
76
+ const longIdle = mode.endsWith('long-idle');
77
+ if (external) process.env.APPIUM_SERVER_URL = 'http://external.invalid';
78
+ else delete process.env.APPIUM_SERVER_URL;
79
+ const replayScript = longIdle ? { ...script, steps: [
80
+ ...script.steps,
81
+ { id: 'home', type: 'key' as const, label: 'Home', keyCode: 3 },
82
+ ] } : script;
83
+ const result = await replayAppiumScript(replayScript, 'test-device', line => {
84
+ // ADB operations and model inference do not reset Appium's command timer.
85
+ if (longIdle && line.includes('[节点 1] 完成')) elapsedMs += 120_000;
86
+ if (aborted && line.includes('Appium session 已创建')) controller.abort();
62
87
  }, controller.signal);
63
- assert.equal(calls.length, mode === 'external' ? 0 : 2);
64
- if (mode !== 'external') {
88
+ assert.equal(calls.length, external ? 0 : 2);
89
+ assert.equal(deletedSessions, mode === 'creation-failure' || (aborted && !external) ? 0 : 1, 'release the replay session even when idle expiry is disabled');
90
+ if (!external) {
65
91
  assert.deepEqual(calls.map(args => args.slice(0, 5)), Array(2).fill(['-s', 'test-device', 'shell', 'am', 'force-stop']));
66
92
  assert.deepEqual(calls.map(args => args.at(-1)).sort(), ['io.appium.uiautomator2.server', 'io.appium.uiautomator2.server.test']);
67
93
  }
68
- if (mode === 'abort') assert.equal(result.stopped, true);
94
+ if (longIdle) {
95
+ assert.equal(keyCommands, 1, 'execute Home after the long interval without Appium commands');
96
+ assert.equal(result.success, true, result.output);
97
+ }
98
+ if (aborted) assert.equal(result.stopped, true);
69
99
  if (mode === 'creation-failure') assert.equal(result.success, false);
70
100
  if (mode === 'cleanup-failure') assert.match(result.output, /UiAutomator2 清理失败/);
71
101
  }
@@ -0,0 +1,123 @@
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 { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { createServer } from 'node:http';
9
+ import type { AppiumRecordedScriptRecord, AppiumRecordedStepRecord } from './repository';
10
+
11
+ test('ADB cancellation and deadlines cover actions; external sessions survive idle work and expire after creation cancellation', async () => {
12
+ const root = await mkdtemp(join(tmpdir(), 'replay-lifecycle-'));
13
+ const env = { ...process.env };
14
+ const original = { execFile: childProcess.execFile, fetch: globalThis.fetch, setInterval: globalThis.setInterval };
15
+ let controller = new AbortController();
16
+ let mode = '', deletes = 0, launchedAfterAbort = false;
17
+ let requestCount = 0;
18
+ try {
19
+ const sdk = join(root, 'sdk');
20
+ await mkdir(join(sdk, 'platform-tools'), { recursive: true });
21
+ await writeFile(join(sdk, 'platform-tools', 'adb'), '');
22
+ Object.assign(process.env, { ANDROID_MIDSCENE_DATA_ROOT: root, ANDROID_HOME: sdk, ANDROID_SDK_ROOT: sdk, APPIUM_SERVER_URL: 'http://external.invalid' });
23
+ childProcess.execFile = ((_cmd, args: string[], options, callback) => {
24
+ requestCount++;
25
+ if (mode === 'foreground-abort' && args.includes('activities')) {
26
+ queueMicrotask(() => { controller.abort(); callback(new Error('aborted'), '', ''); });
27
+ } else if (args.includes('monkey') || args.includes('clear') || args.includes('input')) {
28
+ launchedAfterAbort ||= controller.signal.aborted;
29
+ assert.ok(options.timeout > 0, 'every device action has a deadline');
30
+ assert.equal(options.signal, controller.signal);
31
+ if (mode === 'timeout') {
32
+ queueMicrotask(() => callback(new Error('ADB command timed out'), '', ''));
33
+ } else {
34
+ options.signal.addEventListener('abort', () => callback(new Error('aborted'), '', ''), { once: true });
35
+ queueMicrotask(() => controller.abort());
36
+ }
37
+ } else queueMicrotask(() => callback(null, '', ''));
38
+ return {};
39
+ }) as typeof childProcess.execFile;
40
+ syncBuiltinESMExports();
41
+ globalThis.fetch = async (input, init) => {
42
+ if (init?.method === 'DELETE') deletes++;
43
+ return Response.json({ value: new URL(String(input)).pathname === '/session' ? { sessionId: 'test-session' } : {} });
44
+ };
45
+ const { replayAppiumScript, launchAppOnDevice, clearAppDataOnDevice } = await import('./appium-runner');
46
+ const script: AppiumRecordedScriptRecord = { id: 'audit', name: 'audit', deviceId: 'fake-device', appPackage: '', appActivity: '', createdAt: '', updatedAt: '', steps: [] };
47
+ const run = (steps: AppiumRecordedStepRecord[]) => replayAppiumScript({ ...script, steps }, 'fake-device', undefined, controller.signal, { screenshotReport: false, reportSummaryEnabled: false });
48
+ const actions: AppiumRecordedStepRecord[] = [
49
+ { id: 'launch', type: 'launchApp', label: 'launch', value: 'example.test' },
50
+ { id: 'clear', type: 'clearAppData', label: 'clear', value: 'example.test' },
51
+ { id: 'tap', type: 'coordinateTap', label: 'tap', fallback: { strategy: 'bounds', centerX: 10, centerY: 10 } },
52
+ { id: 'swipe', type: 'swipe', label: 'swipe', swipe: { startX: 10, startY: 10, endX: 50, endY: 50, duration: 100 } },
53
+ ];
54
+ for (const action of actions) {
55
+ mode = 'abort'; controller = new AbortController(); deletes = 0;
56
+ assert.equal((await run([action])).stopped, true, action.type);
57
+ assert.equal(deletes, 1, 'abort releases the known external session');
58
+ }
59
+ mode = 'foreground-abort'; controller = new AbortController(); launchedAfterAbort = false;
60
+ assert.equal((await run([actions[0]!])).stopped, true);
61
+ assert.equal(launchedAfterAbort, false, 'foreground cancellation must not launch monkey');
62
+ const count = requestCount;
63
+ assert.throws(() => launchAppOnDevice('fake', 'example.test', controller.signal));
64
+ assert.throws(() => clearAppDataOnDevice('fake', 'example.test', controller.signal));
65
+ assert.equal(requestCount, count, 'already-cancelled actions never spawn');
66
+ mode = 'timeout'; controller = new AbortController(); deletes = 0;
67
+ const failed = await run([actions[0]!]);
68
+ assert.equal(failed.success, false); assert.match(failed.output, /timed out/); assert.equal(deletes, 1);
69
+
70
+ // A local HTTP server models the idle lease at accelerated time, without a real device.
71
+ globalThis.fetch = original.fetch;
72
+ globalThis.setInterval = ((fn, ms, ...args) => original.setInterval(fn, ms === 15000 ? 15 : ms, ...args)) as typeof setInterval;
73
+ let active = false, timeoutSeconds = 0, heartbeats = 0, homeKeys = 0, deleted = 0;
74
+ let expiry: ReturnType<typeof setTimeout> | undefined;
75
+ let createdResolve: () => void = () => {};
76
+ const renew = () => { clearTimeout(expiry); expiry = setTimeout(() => { active = false; }, timeoutSeconds * 3); };
77
+ const server = createServer(async (req, res) => {
78
+ res.setHeader('Content-Type', 'application/json');
79
+ if (req.url === '/session' && req.method === 'POST') {
80
+ let body = ''; for await (const chunk of req) body += chunk;
81
+ timeoutSeconds = JSON.parse(body).capabilities.alwaysMatch['appium:newCommandTimeout'];
82
+ if (mode === 'creation-abort') { controller.abort(); await new Promise(resolve => setTimeout(resolve, 20)); }
83
+ active = true; renew(); createdResolve();
84
+ res.end(JSON.stringify({ value: { sessionId: 'delayed-session' } }));
85
+ } else if (req.method === 'DELETE') {
86
+ deleted++; active = false; clearTimeout(expiry); res.end('{"value":null}');
87
+ } else if (!active) {
88
+ res.statusCode = 404; res.end('{"value":{"error":"invalid session id"}}');
89
+ } else {
90
+ renew();
91
+ if (req.url?.endsWith('/timeouts')) heartbeats++;
92
+ if (req.url?.endsWith('/press_keycode')) homeKeys++;
93
+ res.end('{"value":{}}');
94
+ }
95
+ });
96
+ await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
97
+ process.env.APPIUM_SERVER_URL = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
98
+ try {
99
+ mode = 'long-idle'; controller = new AbortController();
100
+ const result = await run([{ id: 'wait', type: 'delay', label: 'idle', timeoutMs: 450 }, { id: 'home', type: 'key', label: 'Home', keyCode: 3 }]);
101
+ assert.equal(result.success, true, result.output);
102
+ assert.equal(timeoutSeconds, 60); assert.ok(heartbeats > 3); assert.equal(homeKeys, 1); assert.equal(deleted, 1);
103
+ const heartbeatCount = heartbeats;
104
+ await new Promise(resolve => setTimeout(resolve, 40));
105
+ assert.equal(heartbeats, heartbeatCount, 'heartbeat stops after cleanup');
106
+ mode = 'creation-abort'; controller = new AbortController();
107
+ const created = new Promise<void>(resolve => { createdResolve = resolve; });
108
+ assert.equal((await run([actions[0]!])).stopped, true);
109
+ await created;
110
+ assert.equal(active, true, 'server can create a session after the client cancelled');
111
+ await new Promise(resolve => setTimeout(resolve, 220));
112
+ assert.equal(active, false, 'unclaimed external session has a finite idle lease');
113
+ } finally {
114
+ clearTimeout(expiry); server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve()));
115
+ }
116
+ } finally {
117
+ childProcess.execFile = original.execFile; globalThis.fetch = original.fetch; globalThis.setInterval = original.setInterval; syncBuiltinESMExports();
118
+ for (const key of ['ANDROID_MIDSCENE_DATA_ROOT', 'ANDROID_HOME', 'ANDROID_SDK_ROOT', 'APPIUM_SERVER_URL']) {
119
+ if (env[key] === undefined) delete process.env[key]; else process.env[key] = env[key];
120
+ }
121
+ await rm(root, { recursive: true, force: true });
122
+ }
123
+ });
@@ -24,7 +24,7 @@ import {
24
24
  type AppiumRecordedStepRecord,
25
25
  } from './repository';
26
26
  import { clearAppDataOnDevice, launchAppOnDevice, replayAppiumScript } from './appium-runner';
27
- import { isRemoteDeviceId, sendRemoteCommand } from '../remote-agents/registry';
27
+ import { isRemoteDeviceId, sendRemoteCommand, stopRemoteReplay } from '../remote-agents/registry';
28
28
  import { getAdbCommand } from '../android-sdk';
29
29
  import { createAppiumScriptExport } from './script-export';
30
30
  import { resolveReportSummary } from '../../src/appium-recorder/report-summary';
@@ -47,7 +47,7 @@ function sendStreamEvent(res: ServerResponse, payload: unknown) {
47
47
 
48
48
  function execFileText(command: string, args: string[] = []) {
49
49
  return new Promise<string>((resolve, reject) => {
50
- execFile(command, args, { maxBuffer: 20 * 1024 * 1024 }, (error, stdout, stderr) => {
50
+ execFile(command, args, { maxBuffer: 20 * 1024 * 1024, timeout: 10000 }, (error, stdout, stderr) => {
51
51
  if (error) {
52
52
  reject(new Error(stderr || error.message));
53
53
  return;
@@ -376,6 +376,10 @@ export async function handleAppiumRecorderRequest(
376
376
  const parsed = await readBody<{ deviceId?: string }>(req);
377
377
  const deviceId = parsed.deviceId || selectedDeviceId;
378
378
  assertDeviceAllowed(deviceId);
379
+ if (isRemoteDeviceId(deviceId)) {
380
+ sendJson(res, { success: true, stopped: stopRemoteReplay(deviceId) });
381
+ return true;
382
+ }
379
383
  const controller = replayAbortControllers.get(deviceId);
380
384
  controller?.abort();
381
385
  sendJson(res, { success: true, stopped: Boolean(controller) });
@@ -434,10 +438,6 @@ export async function handleAppiumRecorderRequest(
434
438
  globalVariables: getPresetVariables(),
435
439
  linkedScripts: linkedScriptSnapshot(script),
436
440
  }) as Awaited<ReturnType<typeof replayAppiumScript>>;
437
- if (result.history) {
438
- try { saveRunHistory({ ...result.history, scriptId: script.id, deviceId }); }
439
- catch { result.output += '\n历史记录保存失败'; }
440
- }
441
441
  const { history: _history, ...response } = result;
442
442
  if (streamOutput) {
443
443
  result.output?.split(/\r?\n/).forEach((line) => sendStreamEvent(res, { type: 'log', line }));
@@ -79,7 +79,7 @@ function delay(ms: number) {
79
79
  });
80
80
  }
81
81
 
82
- // Work around @midscene/android-playground v1.12.x ESM __dirname usage on Node 24.
82
+ // Keep the Android Playground launcher compatible with Node 24 ESM execution.
83
83
  const START_ANDROID_PLAYGROUND_WITHOUT_BROWSER = [
84
84
  "import { createRequire } from 'node:module';",
85
85
  'const require = createRequire(import.meta.url);',
@@ -20,6 +20,7 @@ export type RemoteCommand = {
20
20
  type: RemoteCommandType;
21
21
  deviceId: string;
22
22
  payload?: Record<string, unknown>;
23
+ cancelled?: boolean;
23
24
  };
24
25
 
25
26
  export type RemoteCommandResult = {