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 +10 -1
- package/README.md +34 -1
- package/package.json +7 -7
- 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/http-api.ts +1 -1
- 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/src/App.vue +62 -18
- package/src/style.css +29 -65
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
|
+

|
|
8
|
+
|
|
9
|
+
### Midscene
|
|
10
|
+
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+

|
|
14
|
+
|
|
15
|
+

|
|
16
|
+
|
|
17
|
+
### Appium
|
|
18
|
+
|
|
19
|
+

|
|
20
|
+
|
|
21
|
+

|
|
22
|
+
|
|
23
|
+

|
|
24
|
+
|
|
25
|
+

|
|
26
|
+
|
|
27
|
+

|
|
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.
|
|
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.
|
|
49
|
-
"@midscene/android-playground": "https://registry.npmjs.org/@midscene/android-playground/-/android-playground-1.
|
|
50
|
-
"@midscene/core": "https://registry.npmjs.org/@midscene/core/-/core-1.
|
|
51
|
-
"@midscene/playground": "https://registry.npmjs.org/@midscene/playground/-/playground-1.
|
|
52
|
-
"@midscene/shared": "https://registry.npmjs.org/@midscene/shared/-/shared-1.
|
|
53
|
-
"@midscene/web": "https://registry.npmjs.org/@midscene/web/-/web-1.
|
|
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",
|
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 }));
|
package/server/http-api.ts
CHANGED
|
@@ -79,7 +79,7 @@ function delay(ms: number) {
|
|
|
79
79
|
});
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
//
|
|
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);',
|