android-midscene-automation 0.2.2 → 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.
- package/CHANGELOG.md +7 -1
- package/README.md +7 -1
- package/package.json +1 -1
- package/remote-agent/index.ts +50 -22
- package/server/appium-recorder/appium-runner.ts +30 -9
- package/server/appium-recorder/managed-cleanup.test.ts +38 -8
- package/server/appium-recorder/replay-lifecycle.test.ts +123 -0
- package/server/appium-recorder/routes.ts +6 -6
- package/server/remote-agents/protocol.ts +1 -0
- package/server/remote-agents/registry.test.ts +65 -0
- package/server/remote-agents/registry.ts +60 -5
- package/server/remote-agents/replay-integration.test.ts +79 -0
- package/server/remote-agents/routes.ts +10 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
# 更新记录
|
|
2
|
+
## v0.2.3
|
|
3
|
+
- 修复长时间执行 ADB 操作、延时或 AI 识别后,Appium 因默认 60 秒命令空闲超时销毁会话,导致后续 Home 键、点击等节点报“invalid session id”的问题;托管服务关闭会话空闲计时,外部服务通过心跳保活并保留 60 秒空闲回收,避免创建请求取消或清理失败后遗留永久会话。
|
|
4
|
+
- 为启动 App、清理数据、坐标点击与滑动补齐 ADB 超时和取消控制;终止前台检查后不再继续启动 App,异常结束仍清理会话。
|
|
5
|
+
- 修复远程回放固定 10 分钟超时后丢失结果的问题:任务随代理心跳续期,离线任务支持恢复连接后终止与补交历史记录,结果上报失败自动重试且不会重复执行脚本。
|
|
6
|
+
- 修复远程设备无法手动终止回放的问题,通过独立心跳传递取消信号,兼容任务派发前取消,并阻止同一设备重复提交回放。
|
|
7
|
+
|
|
2
8
|
## v0.2.2
|
|
3
|
-
-
|
|
9
|
+
- 更新浅色后台界面:蓝色主色、白色卡片、浅灰背景、可折叠侧边栏与顶部页签。
|
|
4
10
|
- 新增“概览 → 分析页”,根据已保存脚本和真实执行记录分别汇总 Midscene、Appium 测试数据。
|
|
5
11
|
- 修复配置加载失败后出现空白可保存表单的问题,增加独立错误提示和重试;后端断线后自动尝试重连并支持手动重连,保留页面编辑内容。
|
|
6
12
|
- Appium 配置自动保存失败后保留最新待保存内容并提供“重试保存”;修复切换或关闭 Appium 页签后,离开页面时未保存草稿保护失效的问题。
|
package/README.md
CHANGED
|
@@ -1358,8 +1358,14 @@ 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
|
+
|
|
1361
1367
|
## v0.2.2
|
|
1362
|
-
-
|
|
1368
|
+
- 更新浅色后台界面:蓝色主色、白色卡片、浅灰背景、可折叠侧边栏与顶部页签。
|
|
1363
1369
|
- 新增“概览 → 分析页”,根据已保存脚本和真实执行记录分别汇总 Midscene、Appium 测试数据。
|
|
1364
1370
|
- 修复配置加载失败后出现空白可保存表单的问题,增加独立错误提示和重试;后端断线后自动尝试重连并支持手动重连,保留页面编辑内容。
|
|
1365
1371
|
- Appium 配置自动保存失败后保留最新待保存内容并提供“重试保存”;修复切换或关闭 Appium 页签后,离开页面时未保存草稿保护失效的问题。
|
package/package.json
CHANGED
package/remote-agent/index.ts
CHANGED
|
@@ -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.
|
|
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()
|
|
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,
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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
|
|
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(()
|
|
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(()
|
|
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('
|
|
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
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
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,
|
|
64
|
-
|
|
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 (
|
|
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 }));
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { registerRemoteAgent, makeRemoteDeviceId, sendRemoteCommand, pollRemoteCommand, completeRemoteCommand, stopRemoteReplay } from './registry';
|
|
4
|
+
|
|
5
|
+
test('replay heartbeat renews beyond ten minutes, cancellation survives dispatch, late results save once', async () => {
|
|
6
|
+
const originalSetTimeout = globalThis.setTimeout;
|
|
7
|
+
const originalClearTimeout = globalThis.clearTimeout;
|
|
8
|
+
const originalNow = Date.now;
|
|
9
|
+
const token = process.env.REMOTE_AGENT_TOKEN;
|
|
10
|
+
let now = originalNow();
|
|
11
|
+
const timers = new Set<{ at: number; callback: () => void; refresh: () => void }>();
|
|
12
|
+
const advance = (ms: number) => {
|
|
13
|
+
now += ms;
|
|
14
|
+
for (const timer of [...timers]) if (timer.at <= now) { timers.delete(timer); timer.callback(); }
|
|
15
|
+
};
|
|
16
|
+
try {
|
|
17
|
+
Date.now = () => now;
|
|
18
|
+
globalThis.setTimeout = ((callback: () => void, ms: number) => {
|
|
19
|
+
const timer = { at: now + ms, callback, refresh: () => { timer.at = now + ms; } };
|
|
20
|
+
timers.add(timer); return timer;
|
|
21
|
+
}) as unknown as typeof setTimeout;
|
|
22
|
+
globalThis.clearTimeout = ((timer: any) => { timers.delete(timer); }) as typeof clearTimeout;
|
|
23
|
+
const agentId = 'test-lease-agent';
|
|
24
|
+
const heartbeat = () => registerRemoteAgent({ agentId, token, devices: [{ id: 'device', status: 'device' }] });
|
|
25
|
+
const device = makeRemoteDeviceId(agentId, 'device');
|
|
26
|
+
heartbeat();
|
|
27
|
+
const result = sendRemoteCommand(device, 'replay', { script: { id: 'script' } });
|
|
28
|
+
let completed = false;
|
|
29
|
+
void result.then(() => { completed = true; });
|
|
30
|
+
const command = pollRemoteCommand({ agentId, token })!;
|
|
31
|
+
assert.throws(() => sendRemoteCommand(device, 'replay'), /已有回放任务/);
|
|
32
|
+
for (let i = 0; i < 70; i++) { advance(10000); heartbeat(); }
|
|
33
|
+
assert.equal(completed, false, 'healthy replay remains pending after 11 minutes');
|
|
34
|
+
assert.equal(stopRemoteReplay(device), true);
|
|
35
|
+
assert.deepEqual(heartbeat().cancelledCommandIds, [command.id]);
|
|
36
|
+
assert.throws(() => completeRemoteCommand({ agentId: 'other', token, commandId: command.id, ok: true }), /不属于/);
|
|
37
|
+
let saves = 0;
|
|
38
|
+
const stopped = { stopped: true, history: { status: 'stopped' } };
|
|
39
|
+
completeRemoteCommand({ agentId, token, commandId: command.id, ok: true, data: stopped }, () => { saves++; });
|
|
40
|
+
assert.deepEqual(await result, stopped);
|
|
41
|
+
completeRemoteCommand({ agentId, token, commandId: command.id, ok: true, data: stopped }, () => { saves++; });
|
|
42
|
+
assert.equal(saves, 1, 'result retry must not duplicate history');
|
|
43
|
+
assert.equal(stopRemoteReplay(device), false);
|
|
44
|
+
|
|
45
|
+
const queuedResult = sendRemoteCommand(device, 'replay');
|
|
46
|
+
assert.equal(stopRemoteReplay(device), true);
|
|
47
|
+
const queued = pollRemoteCommand({ agentId, token })!;
|
|
48
|
+
assert.equal(queued.cancelled, true, 'cancel before poll survives delivery');
|
|
49
|
+
completeRemoteCommand({ agentId, token, commandId: queued.id, ok: true }); await queuedResult;
|
|
50
|
+
|
|
51
|
+
const lateResult = sendRemoteCommand(device, 'replay', { script: { id: 'late-script' } });
|
|
52
|
+
const rejected = assert.rejects(lateResult, /心跳超时/);
|
|
53
|
+
const late = pollRemoteCommand({ agentId, token })!;
|
|
54
|
+
advance(45001); await rejected;
|
|
55
|
+
assert.deepEqual(heartbeat().cancelledCommandIds, [late.id], 'reconnecting agent stops expired replay');
|
|
56
|
+
const input = { agentId, token, commandId: late.id, ok: true, data: { history: {} } };
|
|
57
|
+
assert.throws(() => completeRemoteCommand(input, () => { throw new Error('disk busy'); }), /disk busy/);
|
|
58
|
+
completeRemoteCommand(input, command => { assert.equal((command.payload?.script as { id: string }).id, 'late-script'); saves++; });
|
|
59
|
+
completeRemoteCommand(input, () => { saves++; });
|
|
60
|
+
assert.equal(saves, 2, 'late result is saved once even after a temporary persistence failure');
|
|
61
|
+
assert.deepEqual(heartbeat().cancelledCommandIds, []);
|
|
62
|
+
} finally {
|
|
63
|
+
globalThis.setTimeout = originalSetTimeout; globalThis.clearTimeout = originalClearTimeout; Date.now = originalNow;
|
|
64
|
+
}
|
|
65
|
+
});
|
|
@@ -3,7 +3,9 @@ import type { RemoteAgentDevice, RemoteAndroidDevice, RemoteCommand, RemoteComma
|
|
|
3
3
|
const REMOTE_DEVICE_PREFIX = 'remote:';
|
|
4
4
|
const AGENT_TTL_MS = 15_000;
|
|
5
5
|
const COMMAND_TIMEOUT_MS = 15_000;
|
|
6
|
-
|
|
6
|
+
// 回放由代理心跳续期,运行时长不受固定十分钟限制。
|
|
7
|
+
const REPLAY_COMMAND_TIMEOUT_MS = 45_000;
|
|
8
|
+
const LATE_RESULT_TTL_MS = 24 * 60 * 60_000;
|
|
7
9
|
|
|
8
10
|
type RegisteredAgent = {
|
|
9
11
|
id: string;
|
|
@@ -14,6 +16,9 @@ type RegisteredAgent = {
|
|
|
14
16
|
};
|
|
15
17
|
|
|
16
18
|
type PendingCommand = RemoteCommand & {
|
|
19
|
+
agentId: string;
|
|
20
|
+
delivered: boolean;
|
|
21
|
+
cancelRequested: boolean;
|
|
17
22
|
resolve: (result: unknown) => void;
|
|
18
23
|
reject: (error: Error) => void;
|
|
19
24
|
timer: ReturnType<typeof setTimeout>;
|
|
@@ -22,6 +27,24 @@ type PendingCommand = RemoteCommand & {
|
|
|
22
27
|
const agents = new Map<string, RegisteredAgent>();
|
|
23
28
|
const queues = new Map<string, RemoteCommand[]>();
|
|
24
29
|
const pending = new Map<string, PendingCommand>();
|
|
30
|
+
// ponytail: 当前进程保留离线回放身份 24 小时;跨服务重启补交需要持久化任务表。
|
|
31
|
+
const lateReplays = new Map<string, { command: RemoteCommand; agentId: string; expiresAt: number }>();
|
|
32
|
+
function pruneLateReplays() {
|
|
33
|
+
for (const [id, receipt] of lateReplays) if (receipt.expiresAt <= Date.now()) lateReplays.delete(id);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function stopRemoteReplay(deviceId: string) {
|
|
37
|
+
const target = parseRemoteDeviceId(deviceId);
|
|
38
|
+
if (!target) return false;
|
|
39
|
+
let stopped = false;
|
|
40
|
+
for (const command of pending.values()) {
|
|
41
|
+
if (command.type === 'replay' && command.agentId === target.agentId && command.deviceId === target.deviceSerial) {
|
|
42
|
+
command.cancelRequested = true;
|
|
43
|
+
stopped = true;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return stopped;
|
|
47
|
+
}
|
|
25
48
|
|
|
26
49
|
function validateToken(token?: string) {
|
|
27
50
|
const expected = process.env.REMOTE_AGENT_TOKEN || '';
|
|
@@ -70,6 +93,17 @@ export function registerRemoteAgent(input: {
|
|
|
70
93
|
devices: input.devices || [],
|
|
71
94
|
lastSeenAt: Date.now(),
|
|
72
95
|
});
|
|
96
|
+
pruneLateReplays();
|
|
97
|
+
const cancelledCommandIds: string[] = [];
|
|
98
|
+
for (const command of pending.values()) {
|
|
99
|
+
if (command.agentId !== agentId || command.type !== 'replay') continue;
|
|
100
|
+
command.timer.refresh();
|
|
101
|
+
if (command.cancelRequested) cancelledCommandIds.push(command.id);
|
|
102
|
+
}
|
|
103
|
+
for (const receipt of lateReplays.values()) {
|
|
104
|
+
if (receipt.agentId === agentId) cancelledCommandIds.push(receipt.command.id);
|
|
105
|
+
}
|
|
106
|
+
return { cancelledCommandIds };
|
|
73
107
|
}
|
|
74
108
|
|
|
75
109
|
export function listRemoteAndroidDevices(): RemoteAndroidDevice[] {
|
|
@@ -88,15 +122,27 @@ export function listRemoteAndroidDevices(): RemoteAndroidDevice[] {
|
|
|
88
122
|
export function pollRemoteCommand(input: { agentId: string; token?: string }) {
|
|
89
123
|
validateToken(input.token);
|
|
90
124
|
const queue = queues.get(input.agentId) || [];
|
|
91
|
-
|
|
125
|
+
const command = queue.shift();
|
|
126
|
+
if (!command) return null;
|
|
127
|
+
const task = pending.get(command.id);
|
|
128
|
+
if (!task) return null;
|
|
129
|
+
task.delivered = true;
|
|
130
|
+
return { ...command, cancelled: task.cancelRequested };
|
|
92
131
|
}
|
|
93
132
|
|
|
94
133
|
export function completeRemoteCommand(input: {
|
|
95
134
|
agentId: string;
|
|
96
135
|
token?: string;
|
|
97
|
-
} & RemoteCommandResult) {
|
|
136
|
+
} & RemoteCommandResult, saveResult?: (command: RemoteCommand) => void) {
|
|
98
137
|
validateToken(input.token);
|
|
138
|
+
pruneLateReplays();
|
|
99
139
|
const command = pending.get(input.commandId);
|
|
140
|
+
const late = lateReplays.get(input.commandId);
|
|
141
|
+
if (!command && !late) return;
|
|
142
|
+
if ((command?.agentId ?? late?.agentId) !== input.agentId) throw new Error('远程命令不属于当前代理');
|
|
143
|
+
// 先落库再确认;保存失败时保留任务身份,让代理重试上报。
|
|
144
|
+
if (input.ok) saveResult?.(command ?? late!.command);
|
|
145
|
+
lateReplays.delete(input.commandId);
|
|
100
146
|
if (!command) return;
|
|
101
147
|
pending.delete(input.commandId);
|
|
102
148
|
clearTimeout(command.timer);
|
|
@@ -112,6 +158,10 @@ export function sendRemoteCommand(deviceId: string, type: RemoteCommandType, pay
|
|
|
112
158
|
if (!parsed) throw new Error('远程设备 ID 无效');
|
|
113
159
|
const agent = agents.get(parsed.agentId);
|
|
114
160
|
if (!agent || !isOnline(agent)) throw new Error('远程设备代理已离线');
|
|
161
|
+
if (type === 'replay' && [...pending.values()].some(command =>
|
|
162
|
+
command.type === 'replay' && command.agentId === parsed.agentId && command.deviceId === parsed.deviceSerial)) {
|
|
163
|
+
throw new Error('该设备已有回放任务正在执行');
|
|
164
|
+
}
|
|
115
165
|
const commandId = `remote_cmd_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
116
166
|
const command: RemoteCommand = {
|
|
117
167
|
id: commandId,
|
|
@@ -123,12 +173,17 @@ export function sendRemoteCommand(deviceId: string, type: RemoteCommandType, pay
|
|
|
123
173
|
return new Promise<unknown>((resolve, reject) => {
|
|
124
174
|
const timeoutMs = type === 'replay' ? REPLAY_COMMAND_TIMEOUT_MS : COMMAND_TIMEOUT_MS;
|
|
125
175
|
const timer = setTimeout(() => {
|
|
176
|
+
const task = pending.get(commandId);
|
|
126
177
|
pending.delete(commandId);
|
|
178
|
+
if (type === 'replay' && task?.delivered) {
|
|
179
|
+
pruneLateReplays();
|
|
180
|
+
lateReplays.set(commandId, { command, agentId: parsed.agentId, expiresAt: Date.now() + LATE_RESULT_TTL_MS });
|
|
181
|
+
}
|
|
127
182
|
const queue = queues.get(parsed.agentId) || [];
|
|
128
183
|
queues.set(parsed.agentId, queue.filter((item) => item.id !== commandId));
|
|
129
|
-
reject(new Error('远程设备命令超时'));
|
|
184
|
+
reject(new Error(type === 'replay' ? '远程代理心跳超时,恢复连接后将终止任务并补交结果' : '远程设备命令超时'));
|
|
130
185
|
}, timeoutMs);
|
|
131
|
-
pending.set(commandId, { ...command, resolve, reject, timer });
|
|
186
|
+
pending.set(commandId, { ...command, agentId: parsed.agentId, delivered: false, cancelRequested: false, resolve, reject, timer });
|
|
132
187
|
const queue = queues.get(parsed.agentId) || [];
|
|
133
188
|
queue.push(command);
|
|
134
189
|
queues.set(parsed.agentId, queue);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { join, resolve } from 'node:path';
|
|
8
|
+
|
|
9
|
+
test('real agent receives stop during replay and retries result delivery without replaying', { skip: process.platform === 'win32', timeout: 25000 }, async () => {
|
|
10
|
+
const root = await mkdtemp(join(tmpdir(), 'remote-replay-'));
|
|
11
|
+
const env = { ...process.env };
|
|
12
|
+
process.env.ANDROID_MIDSCENE_DATA_ROOT = join(root, 'central');
|
|
13
|
+
process.env.REMOTE_AGENT_TOKEN = 'test-only-token';
|
|
14
|
+
const { handleRemoteAgentRequest } = await import('./routes');
|
|
15
|
+
const { handleAppiumRecorderRequest } = await import('../appium-recorder/routes');
|
|
16
|
+
const { listRemoteAndroidDevices } = await import('./registry');
|
|
17
|
+
const { saveAppiumRecordedScript } = await import('../appium-recorder/repository');
|
|
18
|
+
const { listRunHistory } = await import('../appium-recorder/run-history');
|
|
19
|
+
let sessions = 0, deletes = 0, keys = 0, resultAttempts = 0;
|
|
20
|
+
const server = createServer(async (req, res) => {
|
|
21
|
+
if (req.url === '/session') {
|
|
22
|
+
sessions++; res.setHeader('Content-Type', 'application/json'); res.end('{"value":{"sessionId":"test-session"}}'); return;
|
|
23
|
+
}
|
|
24
|
+
if (req.url?.startsWith('/session/')) {
|
|
25
|
+
if (req.method === 'DELETE') deletes++;
|
|
26
|
+
if (req.url.endsWith('/press_keycode')) keys++;
|
|
27
|
+
res.setHeader('Content-Type', 'application/json'); res.end('{"value":{}}'); return;
|
|
28
|
+
}
|
|
29
|
+
if (req.url === '/api/remote-agents/result' && ++resultAttempts === 1) {
|
|
30
|
+
req.resume(); res.statusCode = 503; res.end('{"message":"temporary failure"}'); return;
|
|
31
|
+
}
|
|
32
|
+
if (await handleRemoteAgentRequest(req, res)) return;
|
|
33
|
+
if (await handleAppiumRecorderRequest(req, res, '')) return;
|
|
34
|
+
res.statusCode = 404; res.end();
|
|
35
|
+
});
|
|
36
|
+
let agent: ReturnType<typeof spawn> | undefined;
|
|
37
|
+
const waitFor = async (check: () => boolean) => {
|
|
38
|
+
const until = Date.now() + 12000;
|
|
39
|
+
while (!check()) { assert.ok(Date.now() < until, 'agent action completed within deadline'); await new Promise(resolve => setTimeout(resolve, 25)); }
|
|
40
|
+
};
|
|
41
|
+
try {
|
|
42
|
+
const sdk = join(root, 'sdk'), bin = join(sdk, 'platform-tools');
|
|
43
|
+
await mkdir(bin, { recursive: true });
|
|
44
|
+
await writeFile(join(bin, 'adb'), '#!/usr/bin/env node\nif(process.argv.includes("devices")) console.log("List of devices attached\\nfake-device device model:Test");\n', { mode: 0o755 });
|
|
45
|
+
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
46
|
+
const base = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
|
|
47
|
+
const script = saveAppiumRecordedScript({ name: 'remote-test', appPackage: 'example.test', steps: [
|
|
48
|
+
{ id: 'wait', type: 'delay', label: 'wait', timeoutMs: 60000 },
|
|
49
|
+
{ id: 'home', type: 'key', label: 'Home', keyCode: 3 },
|
|
50
|
+
] });
|
|
51
|
+
agent = spawn(process.execPath, ['--import', 'tsx', resolve('remote-agent/index.ts'), '--server', base, '--agent-id', 'integration-agent'], {
|
|
52
|
+
env: { ...process.env, ANDROID_MIDSCENE_DATA_ROOT: join(root, 'agent'), ANDROID_HOME: sdk, ANDROID_SDK_ROOT: sdk, APPIUM_SERVER_URL: base, PATH: `${bin}:${process.env.PATH}` },
|
|
53
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
54
|
+
});
|
|
55
|
+
let stderr = ''; agent.stderr?.on('data', chunk => { stderr += chunk; });
|
|
56
|
+
await waitFor(() => listRemoteAndroidDevices().length > 0);
|
|
57
|
+
const deviceId = listRemoteAndroidDevices()[0]!.id;
|
|
58
|
+
const post = (path: string, data: unknown) => fetch(base + path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), signal: AbortSignal.timeout(18000) });
|
|
59
|
+
const running = post(`/api/appium-recorder/scripts/${script.id}/replay`, { deviceId, reportSummaryEnabled: false }).then(response => response.json() as Promise<{ stopped: boolean }>);
|
|
60
|
+
await waitFor(() => sessions === 1);
|
|
61
|
+
const stop = await (await post('/api/appium-recorder/replay/stop', { deviceId })).json() as { stopped: boolean };
|
|
62
|
+
assert.equal(stop.stopped, true);
|
|
63
|
+
const result = await running;
|
|
64
|
+
assert.equal(result.stopped, true, stderr);
|
|
65
|
+
assert.equal(sessions, 1, 'retrying the result never replays device actions');
|
|
66
|
+
assert.equal(deletes, 1); assert.equal(keys, 0, 'Home must not run after cancellation');
|
|
67
|
+
assert.equal(resultAttempts, 2, 'temporary result failure is retried');
|
|
68
|
+
const history = listRunHistory(script.id);
|
|
69
|
+
assert.equal(history.length, 1); assert.equal(history[0]?.status, 'stopped'); assert.equal(history[0]?.deviceId, deviceId);
|
|
70
|
+
} finally {
|
|
71
|
+
if (agent && agent.exitCode === null) {
|
|
72
|
+
const exited = new Promise<void>(resolve => agent!.once('exit', () => resolve()));
|
|
73
|
+
agent.kill('SIGKILL'); await exited;
|
|
74
|
+
}
|
|
75
|
+
server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve()));
|
|
76
|
+
for (const key of ['ANDROID_MIDSCENE_DATA_ROOT', 'REMOTE_AGENT_TOKEN']) { if (env[key] === undefined) delete process.env[key]; else process.env[key] = env[key]; }
|
|
77
|
+
await rm(root, { recursive: true, force: true });
|
|
78
|
+
}
|
|
79
|
+
});
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { saveRunHistory } from '../appium-recorder/run-history';
|
|
2
|
+
import type { RunDetail } from '../../src/appium-recorder/run-history';
|
|
1
3
|
import { readBody, requestErrorStatus } from '../request-body';
|
|
2
4
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
3
|
-
import { completeRemoteCommand, listRemoteAndroidDevices, pollRemoteCommand, registerRemoteAgent } from './registry';
|
|
5
|
+
import { completeRemoteCommand, makeRemoteDeviceId, listRemoteAndroidDevices, pollRemoteCommand, registerRemoteAgent } from './registry';
|
|
4
6
|
import type { RemoteAgentDevice } from './protocol';
|
|
5
7
|
|
|
6
8
|
function sendJson(res: ServerResponse, payload: unknown, statusCode = 200) {
|
|
@@ -29,14 +31,14 @@ export async function handleRemoteAgentRequest(req: IncomingMessage, res: Server
|
|
|
29
31
|
token?: string;
|
|
30
32
|
devices?: RemoteAgentDevice[];
|
|
31
33
|
}>(req);
|
|
32
|
-
registerRemoteAgent({
|
|
34
|
+
const control = registerRemoteAgent({
|
|
33
35
|
agentId: parsed.agentId || '',
|
|
34
36
|
agentName: parsed.agentName,
|
|
35
37
|
version: parsed.version,
|
|
36
38
|
token: parsed.token,
|
|
37
39
|
devices: parsed.devices || [],
|
|
38
40
|
});
|
|
39
|
-
sendJson(res, { success: true });
|
|
41
|
+
sendJson(res, { success: true, ...control });
|
|
40
42
|
return true;
|
|
41
43
|
}
|
|
42
44
|
|
|
@@ -65,6 +67,11 @@ export async function handleRemoteAgentRequest(req: IncomingMessage, res: Server
|
|
|
65
67
|
ok: parsed.ok === true,
|
|
66
68
|
data: parsed.data,
|
|
67
69
|
error: parsed.error,
|
|
70
|
+
}, command => {
|
|
71
|
+
const result = parsed.data as { history?: RunDetail } | undefined;
|
|
72
|
+
if (command.type !== 'replay' || !result?.history) return;
|
|
73
|
+
const script = command.payload?.script as { id: string };
|
|
74
|
+
saveRunHistory({ ...result.history, scriptId: script.id, deviceId: makeRemoteDeviceId(parsed.agentId!, command.deviceId) });
|
|
68
75
|
});
|
|
69
76
|
sendJson(res, { success: true });
|
|
70
77
|
return true;
|