android-midscene-automation 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -1
- package/README.md +15 -1
- package/package.json +1 -1
- package/remote-agent/index.ts +50 -22
- package/server/analytics.test.ts +46 -0
- package/server/analytics.ts +24 -0
- package/server/api-safety-analytics.test.ts +57 -0
- package/server/appium-auto-save.test.ts +6 -0
- package/server/appium-recorder/appium-runner.ts +30 -9
- package/server/appium-recorder/appium-version.test.ts +55 -0
- package/server/appium-recorder/managed-appium.ts +37 -1
- 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 +8 -18
- package/server/appium-recorder/run-history.ts +11 -0
- package/server/config-access.test.ts +27 -0
- package/server/config-access.ts +27 -0
- package/server/http-api.ts +63 -54
- package/server/operations/repository.ts +8 -0
- 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 +12 -15
- package/server/request-body.test.ts +40 -0
- package/server/request-body.ts +48 -0
- package/src/App.vue +96 -758
- package/src/analytics/summary.ts +75 -0
- package/src/api.ts +11 -0
- package/src/appium-recorder/AppiumPage.vue +4 -1
- package/src/components/AppSidebar.vue +31 -0
- package/src/components/analytics/AnalysisCharts.vue +189 -0
- package/src/components/analytics/ReportAnalysis.vue +78 -0
- package/src/components/config/AppiumConfigPanel.vue +4 -1
- package/src/components/config/AppiumModelSettings.vue +1 -1
- package/src/components/config/BaseConfigPanel.vue +6 -2
- package/src/components/config/MidsceneConfigPanel.vue +4 -5
- package/src/components/help/HelpCenter.vue +185 -0
- package/src/composables/use-device-preview.ts +279 -0
- package/src/composables/use-initial-resource.ts +17 -0
- package/src/composables/use-script-execution.ts +277 -0
- package/src/config/credentials.ts +2 -0
- package/src/config/use-appium-auto-save.ts +22 -11
- package/src/config/use-model-config.ts +157 -0
- package/src/main.ts +2 -2
- package/src/pages/AnalysisPage.vue +60 -0
- package/src/pages/ConfigPage.vue +36 -5
- package/src/style.css +711 -123
- package/src/types.ts +7 -1
- package/src/components/help/HelpCenterDialog.vue +0 -160
- package/src/ui-refresh.css +0 -677
package/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
# 更新记录
|
|
2
|
+
## v0.2.3
|
|
3
|
+
- 修复长时间执行 ADB 操作、延时或 AI 识别后,Appium 因默认 60 秒命令空闲超时销毁会话,导致后续 Home 键、点击等节点报“invalid session id”的问题;托管服务关闭会话空闲计时,外部服务通过心跳保活并保留 60 秒空闲回收,避免创建请求取消或清理失败后遗留永久会话。
|
|
4
|
+
- 为启动 App、清理数据、坐标点击与滑动补齐 ADB 超时和取消控制;终止前台检查后不再继续启动 App,异常结束仍清理会话。
|
|
5
|
+
- 修复远程回放固定 10 分钟超时后丢失结果的问题:任务随代理心跳续期,离线任务支持恢复连接后终止与补交历史记录,结果上报失败自动重试且不会重复执行脚本。
|
|
6
|
+
- 修复远程设备无法手动终止回放的问题,通过独立心跳传递取消信号,兼容任务派发前取消,并阻止同一设备重复提交回放。
|
|
7
|
+
|
|
8
|
+
## v0.2.2
|
|
9
|
+
- 更新浅色后台界面:蓝色主色、白色卡片、浅灰背景、可折叠侧边栏与顶部页签。
|
|
10
|
+
- 新增“概览 → 分析页”,根据已保存脚本和真实执行记录分别汇总 Midscene、Appium 测试数据。
|
|
11
|
+
- 修复配置加载失败后出现空白可保存表单的问题,增加独立错误提示和重试;后端断线后自动尝试重连并支持手动重连,保留页面编辑内容。
|
|
12
|
+
- Appium 配置自动保存失败后保留最新待保存内容并提供“重试保存”;修复切换或关闭 Appium 页签后,离开页面时未保存草稿保护失效的问题。
|
|
13
|
+
- Appium 顶部工具栏改为随页面布局排列,窄屏自动换行,避免遮挡面包屑;统一全站样式与中文组件文案,合并重复主题声明。
|
|
14
|
+
- 完善隔离数据环境下的浏览器回归测试,以及请求校验、配置保护、自动保存重试和统计口径测试。
|
|
15
|
+
|
|
2
16
|
## v0.2.1
|
|
3
17
|
- 侧边栏底部新增“帮助与版本”入口。
|
|
4
18
|
- AI 识别预设提示词支持编辑测试场景名称和提示词内容,默认预设与自定义预设均可修改并自动保存。
|
|
5
|
-
- AI 识别启用分支后可勾选“启用兜底”,再选择进入左侧 true 或右侧 false
|
|
19
|
+
- AI 识别启用分支后可勾选“启用兜底”,再选择进入左侧 true 或右侧 false 分支;正常识别结果不受影响。
|
|
6
20
|
|
|
7
21
|
## v0.1.43
|
|
8
22
|
- 新增独立的“提示词优化模型”,统一用于优化 AI 识别提示词、报告提示词和生成回放测试报告总结;AI 识别与回放报告均可输入简要要求后由模型补充提示词,模型测试成功改为弹窗提示。
|
package/README.md
CHANGED
|
@@ -1358,10 +1358,24 @@ Linux: ~/.local/share/android-midscene-automation
|
|
|
1358
1358
|
|
|
1359
1359
|
# 项目更新记录
|
|
1360
1360
|
|
|
1361
|
+
## v0.2.3
|
|
1362
|
+
- 修复长时间执行 ADB 操作、延时或 AI 识别后,Appium 因默认 60 秒命令空闲超时销毁会话,导致后续 Home 键、点击等节点报“invalid session id”的问题;托管服务关闭会话空闲计时,外部服务通过心跳保活并保留 60 秒空闲回收,避免创建请求取消或清理失败后遗留永久会话。
|
|
1363
|
+
- 为启动 App、清理数据、坐标点击与滑动补齐 ADB 超时和取消控制;终止前台检查后不再继续启动 App,异常结束仍清理会话。
|
|
1364
|
+
- 修复远程回放固定 10 分钟超时后丢失结果的问题:任务随代理心跳续期,离线任务支持恢复连接后终止与补交历史记录,结果上报失败自动重试且不会重复执行脚本。
|
|
1365
|
+
- 修复远程设备无法手动终止回放的问题,通过独立心跳传递取消信号,兼容任务派发前取消,并阻止同一设备重复提交回放。
|
|
1366
|
+
|
|
1367
|
+
## v0.2.2
|
|
1368
|
+
- 更新浅色后台界面:蓝色主色、白色卡片、浅灰背景、可折叠侧边栏与顶部页签。
|
|
1369
|
+
- 新增“概览 → 分析页”,根据已保存脚本和真实执行记录分别汇总 Midscene、Appium 测试数据。
|
|
1370
|
+
- 修复配置加载失败后出现空白可保存表单的问题,增加独立错误提示和重试;后端断线后自动尝试重连并支持手动重连,保留页面编辑内容。
|
|
1371
|
+
- Appium 配置自动保存失败后保留最新待保存内容并提供“重试保存”;修复切换或关闭 Appium 页签后,离开页面时未保存草稿保护失效的问题。
|
|
1372
|
+
- Appium 顶部工具栏改为随页面布局排列,窄屏自动换行,避免遮挡面包屑;统一全站样式与中文组件文案,合并重复主题声明。
|
|
1373
|
+
- 完善隔离数据环境下的浏览器回归测试,以及请求校验、配置保护、自动保存重试和统计口径测试。
|
|
1374
|
+
|
|
1361
1375
|
## v0.2.1
|
|
1362
1376
|
- 侧边栏底部新增“帮助与版本”入口。
|
|
1363
1377
|
- AI 识别预设提示词支持编辑测试场景名称和提示词内容,默认预设与自定义预设均可修改并自动保存。
|
|
1364
|
-
- AI 识别启用分支后可勾选“启用兜底”,再选择进入左侧 true 或右侧 false
|
|
1378
|
+
- AI 识别启用分支后可勾选“启用兜底”,再选择进入左侧 true 或右侧 false 分支;正常识别结果不受影响。
|
|
1365
1379
|
|
|
1366
1380
|
## v0.1.43
|
|
1367
1381
|
- 新增独立的“提示词优化模型”,统一用于优化 AI 识别提示词、报告提示词和生成回放测试报告总结;AI 识别与回放报告均可输入简要要求后由模型补充提示词,模型测试成功改为弹窗提示。
|
package/package.json
CHANGED
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));
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { summarizeTests, type TestRun } from '../src/analytics/summary';
|
|
4
|
+
|
|
5
|
+
test('analytics includes unrun and historical scripts and excludes stopped/running from pass rate and average duration', () => {
|
|
6
|
+
const runs: TestRun[] = ['passed', 'failed', 'stopped', 'running'].map((status, i) => ({
|
|
7
|
+
scriptId: 'a', scriptName: 'old name', status: status as TestRun['status'], startedAt: `2026-09-2${i}T00:00:00Z`, durationMs: 1000 * (i + 1),
|
|
8
|
+
}));
|
|
9
|
+
runs.push({ scriptId: 'deleted', scriptName: '历史测试', status: 'passed', startedAt: '2026-09-01T00:00:00Z', durationMs: 10 });
|
|
10
|
+
const result = summarizeTests([{ id: 'a', name: '新名称' }, { id: 'b', name: '未执行' }], runs);
|
|
11
|
+
assert.equal(result.scripts, 2);
|
|
12
|
+
assert.equal(result.runs, 5);
|
|
13
|
+
assert.equal(result.testedScripts, 1);
|
|
14
|
+
assert.equal(result.passRate, 2 / 3);
|
|
15
|
+
const a = result.rows.find(row => row.id === 'a')!;
|
|
16
|
+
assert.equal(a.name, '新名称');
|
|
17
|
+
assert.equal(a.averageDurationMs, 1500);
|
|
18
|
+
assert.equal(a.passRate, 0.5);
|
|
19
|
+
assert.equal(a.lastStatus, 'running');
|
|
20
|
+
assert.equal(result.rows.find(row => row.id === 'b')?.passRate, null);
|
|
21
|
+
assert.equal(result.rows.find(row => row.id === 'deleted')?.archived, true);
|
|
22
|
+
const recent = summarizeTests([{ id: 'b', name: '未执行' }], runs, Date.parse('2026-09-22T00:00:00Z'));
|
|
23
|
+
assert.equal(recent.runs, 2);
|
|
24
|
+
assert.equal(recent.passRate, null);
|
|
25
|
+
assert.equal(recent.rows.find(row => row.id === 'a')?.averageDurationMs, null);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
test('daily/monthly charts zero-fill calendar buckets and retain older runs in all-time totals', () => {
|
|
30
|
+
const now = new Date(2026, 0, 2, 12).getTime();
|
|
31
|
+
const runs: TestRun[] = [
|
|
32
|
+
{ scriptId: 'a', scriptName: 'a', status: 'passed', startedAt: new Date(2026, 0, 2, 8).toISOString(), durationMs: 10 },
|
|
33
|
+
{ scriptId: 'a', scriptName: 'a', status: 'failed', startedAt: new Date(2024, 0, 1).toISOString(), durationMs: 10 },
|
|
34
|
+
];
|
|
35
|
+
const all = summarizeTests([], runs, 0, now);
|
|
36
|
+
assert.equal(all.runs, 2);
|
|
37
|
+
assert.equal(all.daily.length, 30);
|
|
38
|
+
assert.equal(all.monthly.length, 12);
|
|
39
|
+
assert.deepEqual(all.monthly.at(-1), { date: '2026-01', runs: 1 });
|
|
40
|
+
assert.equal(all.daily.reduce((sum, point) => sum + point.runs, 0), 1);
|
|
41
|
+
const recent = summarizeTests([], runs, new Date(2025, 11, 27).getTime(), now);
|
|
42
|
+
assert.equal(recent.daily.length, 7);
|
|
43
|
+
assert.equal(recent.monthly.length, 2);
|
|
44
|
+
assert.equal(recent.runs, 1);
|
|
45
|
+
assert.equal(recent.daily.at(-1)?.date, '2026-01-02');
|
|
46
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { summarizeTests, type AnalysisResponse, type TestStatus } from '../src/analytics/summary';
|
|
2
|
+
import { listScriptRecords } from './script-db';
|
|
3
|
+
import { listAppiumRecordedScripts } from './appium-recorder/repository';
|
|
4
|
+
import { listOperationAnalytics } from './operations/repository';
|
|
5
|
+
import { listRunAnalytics } from './appium-recorder/run-history';
|
|
6
|
+
|
|
7
|
+
export function getTestAnalytics(days = 0): AnalysisResponse {
|
|
8
|
+
const generatedAt = new Date().toISOString();
|
|
9
|
+
const start = new Date(generatedAt);
|
|
10
|
+
start.setDate(start.getDate() - Math.max(0, days - 1));
|
|
11
|
+
start.setHours(0, 0, 0, 0);
|
|
12
|
+
const since = days ? start.getTime() : 0;
|
|
13
|
+
const status: Record<string, TestStatus> = { succeeded: 'passed', failed: 'failed', cancelled: 'stopped', running: 'running' };
|
|
14
|
+
return {
|
|
15
|
+
generatedAt, days,
|
|
16
|
+
// Midscene's historical records identify scripts by name, not database ID.
|
|
17
|
+
midscene: summarizeTests(listScriptRecords().map(script => ({ id: script.name, name: script.name })), listOperationAnalytics().map(run => ({
|
|
18
|
+
scriptId: run.script_name, scriptName: run.script_name, startedAt: run.started_at,
|
|
19
|
+
durationMs: run.finished_at ? Date.parse(run.finished_at) - Date.parse(run.started_at) : null,
|
|
20
|
+
status: status[run.status],
|
|
21
|
+
})), since, Date.parse(generatedAt)),
|
|
22
|
+
appium: summarizeTests(listAppiumRecordedScripts().map(script => ({ id: script.id, name: script.name })), listRunAnalytics(), since, Date.parse(generatedAt)),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import type { AnalysisResponse } from '../src/analytics/summary';
|
|
8
|
+
|
|
9
|
+
test('API rejects malformed requests safely and analytics reads all stored executions without a 100-record cap', async () => {
|
|
10
|
+
const root = await mkdtemp(join(tmpdir(), 'console-api-'));
|
|
11
|
+
const oldRoot = process.env.ANDROID_MIDSCENE_DATA_ROOT;
|
|
12
|
+
process.env.ANDROID_MIDSCENE_DATA_ROOT = root;
|
|
13
|
+
const { createApiMiddleware } = await import('./http-api');
|
|
14
|
+
const handler = createApiMiddleware();
|
|
15
|
+
const server = createServer((req, res) => handler(req, res, () => { res.statusCode = 404; res.end(); }));
|
|
16
|
+
try {
|
|
17
|
+
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
18
|
+
const address = server.address();
|
|
19
|
+
assert.ok(address && typeof address === 'object');
|
|
20
|
+
const base = `http://127.0.0.1:${address.port}`;
|
|
21
|
+
for (const path of ['/api/config', '/api/config/appium', '/api/test-model', '/api/appium-recorder/scripts', '/api/generate']) {
|
|
22
|
+
assert.equal((await fetch(base + path, { method: 'POST', body: '{' })).status, 400, path);
|
|
23
|
+
}
|
|
24
|
+
assert.equal((await fetch(base + '/api/config', { headers: { Origin: 'https://foreign.example' } })).status, 403);
|
|
25
|
+
assert.equal((await fetch(base + '/api/config')).status, 200);
|
|
26
|
+
const { upsertScriptRecord } = await import('./script-db');
|
|
27
|
+
upsertScriptRecord({ name: 'Midscene 测试', promptTitle: '', sourcePrompt: '', code: 'private-code', filePath: '', steps: [] });
|
|
28
|
+
upsertScriptRecord({ name: '未执行', promptTitle: '', sourcePrompt: '', code: '', filePath: '', steps: [] });
|
|
29
|
+
const { initOperationRepository } = await import('./operations/repository');
|
|
30
|
+
const { runSql, sqlString } = await import('./storage/sqlite');
|
|
31
|
+
initOperationRepository();
|
|
32
|
+
const started = new Date(Date.now() - 10_000).toISOString();
|
|
33
|
+
const finished = new Date().toISOString();
|
|
34
|
+
runSql(Array.from({ length: 105 }, (_, i) => `INSERT INTO operations (id, kind, status, script_name, device_id, session_id, created_at, started_at, finished_at) VALUES ('op-${i}', 'script_run', '${i === 0 ? 'failed' : 'succeeded'}', 'Midscene 测试', '', '', ${sqlString(started)}, ${sqlString(started)}, ${sqlString(finished)});`).join('\n'));
|
|
35
|
+
const { saveAppiumRecordedScript } = await import('./appium-recorder/repository');
|
|
36
|
+
const script = saveAppiumRecordedScript({ name: 'Appium 测试', appPackage: 'example.test', steps: [] });
|
|
37
|
+
const { saveRunHistory } = await import('./appium-recorder/run-history');
|
|
38
|
+
saveRunHistory({ scriptId: script.id, scriptName: script.name, appPackage: 'example.test', appVersion: '1', deviceId: '', startedAt: started, durationMs: 4000, status: 'passed', nodes: [], output: 'private-output', frames: [] });
|
|
39
|
+
const response = await fetch(base + '/api/analytics');
|
|
40
|
+
const body = await response.text();
|
|
41
|
+
const summary = JSON.parse(body) as AnalysisResponse;
|
|
42
|
+
assert.equal(response.status, 200);
|
|
43
|
+
assert.equal(summary.midscene.runs, 105);
|
|
44
|
+
assert.equal(summary.midscene.scripts, 2);
|
|
45
|
+
assert.equal(summary.midscene.failed, 1);
|
|
46
|
+
assert.equal(summary.midscene.rows.find(row => row.name === '未执行')?.runs, 0);
|
|
47
|
+
assert.equal(summary.appium.runs, 1);
|
|
48
|
+
assert.equal(summary.appium.passRate, 1);
|
|
49
|
+
assert.equal(body.includes('private-'), false);
|
|
50
|
+
assert.equal((await fetch(base + '/api/analytics?days=invalid')).status, 400);
|
|
51
|
+
} finally {
|
|
52
|
+
server.closeAllConnections();
|
|
53
|
+
await new Promise<void>(resolve => server.close(() => resolve()));
|
|
54
|
+
if (oldRoot === undefined) delete process.env.ANDROID_MIDSCENE_DATA_ROOT; else process.env.ANDROID_MIDSCENE_DATA_ROOT = oldRoot;
|
|
55
|
+
await rm(root, { recursive: true, force: true });
|
|
56
|
+
}
|
|
57
|
+
});
|
|
@@ -52,7 +52,13 @@ test('loads quietly, serializes rapid edits, validates colors and surfaces save
|
|
|
52
52
|
await delay(400);
|
|
53
53
|
assert.equal(errors.length, 1);
|
|
54
54
|
assert.match(auto.status.value, /失败/);
|
|
55
|
+
assert.equal(auto.failed.value, true);
|
|
56
|
+
assert.equal(auto.dirty.value, true);
|
|
55
57
|
fail = false;
|
|
58
|
+
await auto.retry();
|
|
59
|
+
assert.equal(auto.failed.value, false);
|
|
60
|
+
assert.equal(auto.dirty.value, false);
|
|
61
|
+
assert.equal(submissions.at(-1)?.screenshotReport, true);
|
|
56
62
|
form.appium.flowBackgroundColor = '#abcdef';
|
|
57
63
|
await delay(400);
|
|
58
64
|
assert.equal(auto.status.value, '已自动保存');
|
|
@@ -139,8 +139,9 @@ function normalizeTextForContains(value: string) {
|
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
function adbTap(deviceId: string, x: number, y: number) {
|
|
142
|
+
throwIfReplayStopped();
|
|
142
143
|
return new Promise<void>((resolve, reject) => {
|
|
143
|
-
execFile(getAdbCommand(), ['-s', deviceId, 'shell', 'input', 'tap', String(Math.round(x)), String(Math.round(y))], (error, _stdout, stderr) => {
|
|
144
|
+
execFile(getAdbCommand(), ['-s', deviceId, 'shell', 'input', 'tap', String(Math.round(x)), String(Math.round(y))], { timeout: 10000, signal: replayContext.getStore()?.signal }, (error, _stdout, stderr) => {
|
|
144
145
|
if (error) {
|
|
145
146
|
reject(new Error(stderr || error.message));
|
|
146
147
|
return;
|
|
@@ -150,7 +151,8 @@ function adbTap(deviceId: string, x: number, y: number) {
|
|
|
150
151
|
});
|
|
151
152
|
}
|
|
152
153
|
|
|
153
|
-
export function launchAppOnDevice(deviceId: string, packageName: string) {
|
|
154
|
+
export function launchAppOnDevice(deviceId: string, packageName: string, signal = replayContext.getStore()?.signal) {
|
|
155
|
+
signal?.throwIfAborted();
|
|
154
156
|
if (!packageName) return Promise.reject(new Error('启动 APP 缺少包名'));
|
|
155
157
|
return new Promise<void>((resolve, reject) => {
|
|
156
158
|
execFile(getAdbCommand(), [
|
|
@@ -163,7 +165,7 @@ export function launchAppOnDevice(deviceId: string, packageName: string) {
|
|
|
163
165
|
'-c',
|
|
164
166
|
'android.intent.category.LAUNCHER',
|
|
165
167
|
'1',
|
|
166
|
-
], { maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
168
|
+
], { maxBuffer: 4 * 1024 * 1024, timeout: 30000, signal }, (error, stdout, stderr) => {
|
|
167
169
|
const output = `${stdout || ''}\n${stderr || ''}`.trim();
|
|
168
170
|
if (error || /no activities found|monkey aborted/i.test(output)) {
|
|
169
171
|
reject(new Error(output || error?.message || `ADB 启动 ${packageName} 失败`));
|
|
@@ -174,7 +176,8 @@ export function launchAppOnDevice(deviceId: string, packageName: string) {
|
|
|
174
176
|
});
|
|
175
177
|
}
|
|
176
178
|
|
|
177
|
-
export function clearAppDataOnDevice(deviceId: string, packageName: string) {
|
|
179
|
+
export function clearAppDataOnDevice(deviceId: string, packageName: string, signal = replayContext.getStore()?.signal) {
|
|
180
|
+
signal?.throwIfAborted();
|
|
178
181
|
if (!packageName) return Promise.reject(new Error('清理 App 缓存缺少包名'));
|
|
179
182
|
return new Promise<void>((resolve, reject) => {
|
|
180
183
|
execFile(getAdbCommand(), [
|
|
@@ -184,7 +187,7 @@ export function clearAppDataOnDevice(deviceId: string, packageName: string) {
|
|
|
184
187
|
'pm',
|
|
185
188
|
'clear',
|
|
186
189
|
packageName,
|
|
187
|
-
], { maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
190
|
+
], { maxBuffer: 4 * 1024 * 1024, timeout: 30000, signal }, (error, stdout, stderr) => {
|
|
188
191
|
const output = `${stdout || ''}\n${stderr || ''}`.trim();
|
|
189
192
|
if (error || !/^success$/im.test(output)) {
|
|
190
193
|
reject(new Error(output || error?.message || `ADB 清理 ${packageName} 失败`));
|
|
@@ -196,6 +199,7 @@ export function clearAppDataOnDevice(deviceId: string, packageName: string) {
|
|
|
196
199
|
}
|
|
197
200
|
|
|
198
201
|
function adbSwipe(deviceId: string, step: Required<AppiumRecordedStepRecord>['swipe']) {
|
|
202
|
+
throwIfReplayStopped();
|
|
199
203
|
return new Promise<void>((resolve, reject) => {
|
|
200
204
|
execFile(getAdbCommand(), [
|
|
201
205
|
'-s',
|
|
@@ -208,7 +212,7 @@ function adbSwipe(deviceId: string, step: Required<AppiumRecordedStepRecord>['sw
|
|
|
208
212
|
String(Math.round(step.endX)),
|
|
209
213
|
String(Math.round(step.endY)),
|
|
210
214
|
String(Math.round(step.duration)),
|
|
211
|
-
], (error, _stdout, stderr) => {
|
|
215
|
+
], { timeout: Math.max(10000, step.duration + 5000), signal: replayContext.getStore()?.signal }, (error, _stdout, stderr) => {
|
|
212
216
|
if (error) {
|
|
213
217
|
reject(new Error(stderr || error.message));
|
|
214
218
|
return;
|
|
@@ -294,7 +298,8 @@ async function appiumRequest<T>(
|
|
|
294
298
|
try {
|
|
295
299
|
const result = await timedAppiumFetch(requestUrl, {
|
|
296
300
|
...init,
|
|
297
|
-
signal: options?.ignoreAbort ? undefined : context?.signal
|
|
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();
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import childProcess from 'node:child_process';
|
|
4
|
+
import { syncBuiltinESMExports } from 'node:module';
|
|
5
|
+
import { getAppiumVersion } from './managed-appium';
|
|
6
|
+
|
|
7
|
+
test('Appium version uses the configured runtime and reports unavailable versions without a guessed fallback', async () => {
|
|
8
|
+
const original = { execFile: childProcess.execFile, fetch: globalThis.fetch };
|
|
9
|
+
const env = { executable: process.env.APPIUM_EXECUTABLE, url: process.env.APPIUM_SERVER_URL };
|
|
10
|
+
let output = '3.5.0-beta.1\n';
|
|
11
|
+
let commandError: Error | null = null;
|
|
12
|
+
let executions = 0;
|
|
13
|
+
try {
|
|
14
|
+
delete process.env.APPIUM_SERVER_URL;
|
|
15
|
+
process.env.APPIUM_EXECUTABLE = '/custom/appium';
|
|
16
|
+
childProcess.execFile = ((command, args, options, callback) => {
|
|
17
|
+
executions++;
|
|
18
|
+
assert.equal(command, '/custom/appium');
|
|
19
|
+
assert.deepEqual(args, ['--version']);
|
|
20
|
+
assert.equal(options.timeout, 5000);
|
|
21
|
+
queueMicrotask(() => callback(commandError, output, ''));
|
|
22
|
+
return {};
|
|
23
|
+
}) as typeof childProcess.execFile;
|
|
24
|
+
syncBuiltinESMExports();
|
|
25
|
+
assert.equal((await getAppiumVersion()).version, '3.5.0-beta.1');
|
|
26
|
+
output = 'Warning: Node 24.19.0 is unsupported';
|
|
27
|
+
assert.equal((await getAppiumVersion()).version, null, 'do not mistake diagnostic output for the Appium version');
|
|
28
|
+
commandError = Object.assign(new Error('missing'), { code: 'ENOENT' });
|
|
29
|
+
assert.match((await getAppiumVersion()).message, /未检测到/);
|
|
30
|
+
commandError = new Error('timeout');
|
|
31
|
+
assert.match((await getAppiumVersion()).message, /读取.*失败/);
|
|
32
|
+
|
|
33
|
+
process.env.APPIUM_SERVER_URL = 'http://appium.example/wd/hub/';
|
|
34
|
+
const localExecutions = executions;
|
|
35
|
+
globalThis.fetch = async (url, options) => {
|
|
36
|
+
assert.equal(url, 'http://appium.example/wd/hub/status');
|
|
37
|
+
assert.ok(options?.signal);
|
|
38
|
+
return Response.json({ value: { ready: true, build: { version: '2.19.0' } } });
|
|
39
|
+
};
|
|
40
|
+
assert.deepEqual(await getAppiumVersion(), { version: '2.19.0', source: 'server', message: '当前配置的 Appium 服务' });
|
|
41
|
+
globalThis.fetch = async () => Response.json({ value: {} });
|
|
42
|
+
assert.equal((await getAppiumVersion()).version, null);
|
|
43
|
+
globalThis.fetch = async () => new Response('unavailable', { status: 503 });
|
|
44
|
+
assert.match((await getAppiumVersion()).message, /无法连接/);
|
|
45
|
+
globalThis.fetch = async () => { throw new Error('offline'); };
|
|
46
|
+
assert.equal((await getAppiumVersion()).version, null);
|
|
47
|
+
assert.equal(executions, localExecutions, 'external service failures must not show an unrelated local version');
|
|
48
|
+
} finally {
|
|
49
|
+
childProcess.execFile = original.execFile;
|
|
50
|
+
globalThis.fetch = original.fetch;
|
|
51
|
+
syncBuiltinESMExports();
|
|
52
|
+
if (env.executable === undefined) delete process.env.APPIUM_EXECUTABLE; else process.env.APPIUM_EXECUTABLE = env.executable;
|
|
53
|
+
if (env.url === undefined) delete process.env.APPIUM_SERVER_URL; else process.env.APPIUM_SERVER_URL = env.url;
|
|
54
|
+
}
|
|
55
|
+
});
|
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
import { execFile, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
2
2
|
import { createServer } from 'node:net';
|
|
3
|
+
import type { AppiumVersionInfo } from '../../src/types';
|
|
4
|
+
|
|
5
|
+
function appiumExecutable() {
|
|
6
|
+
return process.env.APPIUM_EXECUTABLE?.trim() || (process.platform === 'win32' ? 'appium.cmd' : 'appium');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function getAppiumVersion(): Promise<AppiumVersionInfo> {
|
|
10
|
+
const source = usesManagedAppiumServer() ? 'local' : 'server';
|
|
11
|
+
try {
|
|
12
|
+
let output: unknown;
|
|
13
|
+
if (source === 'server') {
|
|
14
|
+
const url = process.env.APPIUM_SERVER_URL!.trim().replace(/\/+$/, '');
|
|
15
|
+
const response = await fetch(`${url}/status`, { signal: AbortSignal.timeout(3000) });
|
|
16
|
+
if (!response.ok) throw new Error('Appium status request failed');
|
|
17
|
+
const payload = await response.json() as { value?: { build?: { version?: unknown } } } | null;
|
|
18
|
+
output = payload?.value?.build?.version;
|
|
19
|
+
} else {
|
|
20
|
+
output = await new Promise<string>((resolve, reject) => {
|
|
21
|
+
execFile(appiumExecutable(), ['--version'], {
|
|
22
|
+
timeout: 5000, maxBuffer: 64 * 1024, windowsHide: true, shell: process.platform === 'win32',
|
|
23
|
+
}, (error, stdout) => error ? reject(error) : resolve(stdout));
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
const version = typeof output === 'string'
|
|
27
|
+
? output.match(/^\s*v?(\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?)\s*$/m)?.[1]
|
|
28
|
+
: undefined;
|
|
29
|
+
return { version: version ?? null, source, message: version
|
|
30
|
+
? (source === 'local' ? '本机已安装 · Android 自动化引擎' : '当前配置的 Appium 服务')
|
|
31
|
+
: '未返回有效版本号,请检查 Appium 安装或服务状态' };
|
|
32
|
+
} catch (error) {
|
|
33
|
+
const missing = error instanceof Error && 'code' in error && error.code === 'ENOENT';
|
|
34
|
+
return { version: null, source, message: source === 'server'
|
|
35
|
+
? '暂时无法连接 Appium 服务,请检查服务后刷新'
|
|
36
|
+
: missing ? '未检测到 Appium,请安装或检查可执行文件路径' : '读取 Appium 版本失败,请检查安装后刷新' };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
3
39
|
|
|
4
40
|
type ManagedAppiumServer = {
|
|
5
41
|
serverUrl: string;
|
|
@@ -110,7 +146,7 @@ export async function startManagedAppiumServer(
|
|
|
110
146
|
): Promise<ManagedAppiumServer> {
|
|
111
147
|
const port = await reserveLocalPort();
|
|
112
148
|
const serverUrl = `http://127.0.0.1:${port}`;
|
|
113
|
-
const executable =
|
|
149
|
+
const executable = appiumExecutable();
|
|
114
150
|
const args = [
|
|
115
151
|
'--address',
|
|
116
152
|
'127.0.0.1',
|