preflite 1.1.1 → 1.1.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/README.md +113 -62
- package/dist/infrastructure/midscene/MidsceneRuntimeReal.js +41 -3
- package/dist/mcp/agentHttpClient.js +19 -6
- package/dist/mcp/exploration/tools-intelligent.js +50 -7
- package/dist/mcp/exploration/tools-session.js +1 -0
- package/dist/mcp/network-mocks/NetworkMockServer.js +579 -0
- package/dist/mcp/network-mocks/NetworkMockService.js +156 -0
- package/dist/mcp/network-mocks/device-proxy.js +31 -0
- package/dist/mcp/network-mocks/index.js +3 -0
- package/dist/mcp/network-mocks/types.js +1 -0
- package/dist/mcp/runManager.js +4 -1
- package/dist/mcp/server.js +229 -23
- package/dist/mcp/setup.js +135 -5
- package/dist/mcp/visual-flow/types.js +1 -1
- package/dist/mcp/visual-flow/validate.js +171 -0
- package/docs/visual-flow-ir-llm.md +70 -2
- package/package.json +1 -1
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { NetworkMockServer } from "./NetworkMockServer.js";
|
|
2
|
+
import { configureDeviceProxy, removeDeviceProxy, proxyHostForPlatform } from "./device-proxy.js";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { execSync } from "node:child_process";
|
|
5
|
+
export class NetworkMockService {
|
|
6
|
+
server = new NetworkMockServer();
|
|
7
|
+
activeConfig = null;
|
|
8
|
+
certServer = null;
|
|
9
|
+
certServerPort = 0;
|
|
10
|
+
qrPng = null;
|
|
11
|
+
async start(config) {
|
|
12
|
+
if (this.server.getPort() > 0) {
|
|
13
|
+
await this.stop();
|
|
14
|
+
}
|
|
15
|
+
const port = await this.server.start(config.rules, "0.0.0.0", config.preferredPort ?? 0);
|
|
16
|
+
const proxyHost = proxyHostForPlatform(config.platform);
|
|
17
|
+
configureDeviceProxy({
|
|
18
|
+
platform: config.platform,
|
|
19
|
+
deviceId: config.deviceId,
|
|
20
|
+
proxyHost,
|
|
21
|
+
proxyPort: port,
|
|
22
|
+
});
|
|
23
|
+
this.activeConfig = { platform: config.platform, deviceId: config.deviceId };
|
|
24
|
+
this.startCertServer(proxyHost, port);
|
|
25
|
+
return this.server.getStats();
|
|
26
|
+
}
|
|
27
|
+
async stop() {
|
|
28
|
+
if (this.activeConfig) {
|
|
29
|
+
try {
|
|
30
|
+
removeDeviceProxy(this.activeConfig.platform, this.activeConfig.deviceId);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// best-effort cleanup
|
|
34
|
+
}
|
|
35
|
+
this.activeConfig = null;
|
|
36
|
+
}
|
|
37
|
+
this.stopCertServer();
|
|
38
|
+
await this.server.stop();
|
|
39
|
+
return this.server.getStats();
|
|
40
|
+
}
|
|
41
|
+
getCertServerUrl() {
|
|
42
|
+
if (!this.certServer || this.certServerPort === 0)
|
|
43
|
+
return null;
|
|
44
|
+
const host = proxyHostForPlatform(this.activeConfig?.platform ?? "ios");
|
|
45
|
+
return `http://${host}:${this.certServerPort}`;
|
|
46
|
+
}
|
|
47
|
+
startCertServer(proxyHost, proxyPort) {
|
|
48
|
+
this.stopCertServer();
|
|
49
|
+
const caCert = this.server.getRootCACert();
|
|
50
|
+
const mobileConfig = this.server.generateMobileConfig(proxyHost, proxyPort);
|
|
51
|
+
// Generate QR PNG using Python qrcode library
|
|
52
|
+
const qrUrl = `http://${proxyHost}:0/preflight.mobileconfig`; // port placeholder
|
|
53
|
+
this.qrPng = this.generateQrPng(qrUrl);
|
|
54
|
+
const qrPng = this.qrPng;
|
|
55
|
+
const html = this.buildCertPage(proxyHost, proxyPort);
|
|
56
|
+
this.certServer = createServer((req, res) => {
|
|
57
|
+
const url = req.url ?? "/";
|
|
58
|
+
if (url === "/" || url === "/index.html") {
|
|
59
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
60
|
+
res.end(html);
|
|
61
|
+
}
|
|
62
|
+
else if (url === "/preflight-ca.pem" && caCert) {
|
|
63
|
+
res.writeHead(200, { "Content-Type": "application/x-pem-file" });
|
|
64
|
+
res.end(caCert);
|
|
65
|
+
}
|
|
66
|
+
else if (url === "/preflight.mobileconfig" && mobileConfig) {
|
|
67
|
+
res.writeHead(200, { "Content-Type": "application/x-apple-aspen-config" });
|
|
68
|
+
res.end(mobileConfig);
|
|
69
|
+
}
|
|
70
|
+
else if (url === "/qr.png" && qrPng) {
|
|
71
|
+
res.writeHead(200, { "Content-Type": "image/png" });
|
|
72
|
+
res.end(qrPng);
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
res.writeHead(404);
|
|
76
|
+
res.end("Not found");
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
this.certServer.listen(0, "0.0.0.0", () => {
|
|
80
|
+
const addr = this.certServer.address();
|
|
81
|
+
this.certServerPort = addr && typeof addr !== "string" ? addr.port : 0;
|
|
82
|
+
// Re-generate QR with the actual port
|
|
83
|
+
const actualUrl = `http://${proxyHost}:${this.certServerPort}/preflight.mobileconfig`;
|
|
84
|
+
this.qrPng = this.generateQrPng(actualUrl);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
stopCertServer() {
|
|
88
|
+
if (this.certServer) {
|
|
89
|
+
this.certServer.close();
|
|
90
|
+
this.certServer = null;
|
|
91
|
+
this.certServerPort = 0;
|
|
92
|
+
this.qrPng = null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
generateQrPng(url) {
|
|
96
|
+
try {
|
|
97
|
+
const script = `import qrcode,sys;qrcode.make(sys.argv[1]).save('/dev/stdout')`;
|
|
98
|
+
return execSync(`python3 -c "${script}" "${url}"`, { maxBuffer: 100_000, timeout: 5000 });
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
buildCertPage(proxyHost, proxyPort) {
|
|
105
|
+
return `<!DOCTYPE html>
|
|
106
|
+
<html><head><meta charset="utf-8"><title>Preflight Mock</title>
|
|
107
|
+
<style>
|
|
108
|
+
body { font-family: -apple-system, sans-serif; text-align: center; padding: 20px; background: #f5f5f7; }
|
|
109
|
+
.card { background: white; border-radius: 16px; padding: 24px; max-width: 400px; margin: 0 auto; box-shadow: 0 2px 12px rgba(0,0,0,0.08); }
|
|
110
|
+
h1 { font-size: 20px; margin-bottom: 4px; }
|
|
111
|
+
.sub { color: #666; font-size: 13px; margin-bottom: 16px; }
|
|
112
|
+
.qr { margin: 16px 0; }
|
|
113
|
+
.btn { display: inline-block; background: #007aff; color: white; padding: 10px 24px; border-radius: 8px;
|
|
114
|
+
text-decoration: none; font-weight: 600; margin: 6px; font-size: 14px; }
|
|
115
|
+
.steps { text-align: left; font-size: 12px; color: #888; margin-top: 20px; line-height: 1.8; }
|
|
116
|
+
</style></head><body>
|
|
117
|
+
<div class="card">
|
|
118
|
+
<h1>Preflight Network Mock</h1>
|
|
119
|
+
<p class="sub">Proxy ${proxyHost}:${proxyPort}</p>
|
|
120
|
+
<div class="qr"><img src="qr.png" width="200" alt="QR Code"></div>
|
|
121
|
+
<a class="btn" href="preflight-ca.pem">CA 证书 (PEM)</a>
|
|
122
|
+
<a class="btn" href="preflight.mobileconfig">.mobileconfig</a>
|
|
123
|
+
<div class="steps">
|
|
124
|
+
<b>iOS:</b> 扫描二维码 或下载 .mobileconfig → 安装 →<br>
|
|
125
|
+
Settings > General > About > Certificate Trust Settings<br>
|
|
126
|
+
→ 开启 <b>Preflight Mock CA</b><br>
|
|
127
|
+
<b>Android:</b> 下载 PEM → Settings > Security →<br>
|
|
128
|
+
Install certificate > CA certificate
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
</body></html>`;
|
|
132
|
+
}
|
|
133
|
+
isRunning() {
|
|
134
|
+
return this.server.getPort() > 0;
|
|
135
|
+
}
|
|
136
|
+
getStats() {
|
|
137
|
+
return this.server.getStats();
|
|
138
|
+
}
|
|
139
|
+
updateRules(rules) {
|
|
140
|
+
this.server.updateRules(rules);
|
|
141
|
+
}
|
|
142
|
+
getRootCACert() {
|
|
143
|
+
return this.server.getRootCACert();
|
|
144
|
+
}
|
|
145
|
+
generateMobileConfig(proxyHost, proxyPort) {
|
|
146
|
+
return this.server.generateMobileConfig(proxyHost, proxyPort);
|
|
147
|
+
}
|
|
148
|
+
setRecording(enabled) { this.server.setRecording(enabled); }
|
|
149
|
+
isRecording() { return this.server.isRecording(); }
|
|
150
|
+
getRecordedCount() { return this.server.getRecordedCount(); }
|
|
151
|
+
exportRecordedRules() {
|
|
152
|
+
const rules = this.server.exportRecordedRules();
|
|
153
|
+
this.server.clearRecording();
|
|
154
|
+
return rules;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
export function configureAndroidEmulatorProxy(config) {
|
|
3
|
+
const { deviceId, proxyHost, proxyPort } = config;
|
|
4
|
+
const proxy = `${proxyHost}:${proxyPort}`;
|
|
5
|
+
execSync(`adb -s ${deviceId} shell settings put global http_proxy ${proxy}`, {
|
|
6
|
+
stdio: "pipe",
|
|
7
|
+
timeout: 10_000,
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
export function removeAndroidEmulatorProxy(serial) {
|
|
11
|
+
execSync(`adb -s ${serial} shell settings put global http_proxy :0`, {
|
|
12
|
+
stdio: "pipe",
|
|
13
|
+
timeout: 10_000,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
export function configureDeviceProxy(config) {
|
|
17
|
+
if (config.platform === "android") {
|
|
18
|
+
configureAndroidEmulatorProxy(config);
|
|
19
|
+
}
|
|
20
|
+
// iOS simulator proxy deferred to phase 2
|
|
21
|
+
}
|
|
22
|
+
export function removeDeviceProxy(platform, deviceId) {
|
|
23
|
+
if (platform === "android") {
|
|
24
|
+
removeAndroidEmulatorProxy(deviceId);
|
|
25
|
+
}
|
|
26
|
+
// iOS simulator proxy deferred to phase 2
|
|
27
|
+
}
|
|
28
|
+
/** Android emulator reaches the host at 10.0.2.2; iOS simulator uses loopback. */
|
|
29
|
+
export function proxyHostForPlatform(platform) {
|
|
30
|
+
return platform === "android" ? "10.0.2.2" : "127.0.0.1";
|
|
31
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/mcp/runManager.js
CHANGED
|
@@ -134,7 +134,10 @@ export class RunManager {
|
|
|
134
134
|
async refreshRun(runId) {
|
|
135
135
|
const run = this.mustGet(runId);
|
|
136
136
|
const [task, events, artifacts] = await Promise.all([
|
|
137
|
-
this.client.getTask(run.taskId)
|
|
137
|
+
this.client.getTask(run.taskId).catch((error) => {
|
|
138
|
+
logRun(runId, "REFRESH_ERROR", `getTask: ${error instanceof Error ? error.message : String(error)}`);
|
|
139
|
+
return run.task;
|
|
140
|
+
}),
|
|
138
141
|
this.client.listEvents(run.taskId).catch(() => run.events),
|
|
139
142
|
this.client.listArtifacts(run.taskId).catch(() => run.artifacts),
|
|
140
143
|
]);
|
package/dist/mcp/server.js
CHANGED
|
@@ -13,8 +13,12 @@ import { loadPreflightUserConfig } from "./userConfig.js";
|
|
|
13
13
|
import { compileVisualFlow, validateVisualFlow } from "./visual-flow/index.js";
|
|
14
14
|
import { registerExplorationTools } from "./exploration/index.js";
|
|
15
15
|
import { createMidsceneSessionFromResourceId, ensureIosWdaStarted } from "./exploration/tools-session.js";
|
|
16
|
+
import { NetworkMockService } from "./network-mocks/NetworkMockService.js";
|
|
17
|
+
import { proxyHostForPlatform } from "./network-mocks/device-proxy.js";
|
|
16
18
|
import { readFile } from "node:fs/promises";
|
|
17
19
|
import { join } from "node:path";
|
|
20
|
+
const MCP_SAFE_WAIT_MS = 45_000;
|
|
21
|
+
const RUN_POLL_INTERVAL_MS = 2_000;
|
|
18
22
|
export function createPreflightMcpServer(options = {}) {
|
|
19
23
|
const agentBaseUrl = options.agentBaseUrl ?? process.env.AGENT_BASE_URL ?? "http://127.0.0.1:18998";
|
|
20
24
|
const livePort = options.livePort ?? Number(process.env.MCP_LIVE_PORT ?? "18999");
|
|
@@ -25,6 +29,7 @@ export function createPreflightMcpServer(options = {}) {
|
|
|
25
29
|
const runtime = new AgentRuntimeManager({ projectRoot, agentBaseUrl, client, env: process.env, runtimeRoot: options.runtimeRoot, loadConfigEnv });
|
|
26
30
|
const liveBaseUrl = `http://127.0.0.1:${livePort}`;
|
|
27
31
|
const runManager = new RunManager(client, liveBaseUrl);
|
|
32
|
+
const networkMockService = new NetworkMockService();
|
|
28
33
|
let liveServerStarted;
|
|
29
34
|
const server = new McpServer({ name: "Preflight", version: "0.1.0" });
|
|
30
35
|
server.registerTool("agent_health", {
|
|
@@ -119,18 +124,38 @@ export function createPreflightMcpServer(options = {}) {
|
|
|
119
124
|
waitForCompletion: z.boolean().optional().describe("Set to false for multi-step flows (3+ steps) — returns immediately with a runId, then poll with watch_run. " +
|
|
120
125
|
"Set to true only for very short flows (1-2 steps) that finish within the MCP 60s transport timeout."),
|
|
121
126
|
timeoutMs: z.number().int().positive().optional().describe("Maximum wait time in milliseconds for the test to complete. " +
|
|
122
|
-
"
|
|
123
|
-
"For example, a 5-step flow should set timeoutMs to 300000. " +
|
|
124
|
-
"Default is 120000 (2 minutes) which is only suitable for very short flows (1-2 steps). " +
|
|
127
|
+
"Capped internally at 45000ms so the MCP response returns before the 60s transport timeout. " +
|
|
125
128
|
"NOTE: Only effective when waitForCompletion is true. " +
|
|
126
|
-
"
|
|
127
|
-
"use waitForCompletion: false and poll with watch_run instead."),
|
|
129
|
+
"For long runs, use waitForCompletion: false and poll with watch_run instead."),
|
|
128
130
|
},
|
|
129
131
|
}, async (input) => {
|
|
130
132
|
await runtime.ensureStarted();
|
|
131
133
|
const parsed = validateVisualFlow(input.visualFlow);
|
|
132
134
|
if (!parsed.ok)
|
|
133
135
|
return jsonResult(parsed);
|
|
136
|
+
const hasNetworkMocks = (parsed.value.networkMocks?.length ?? 0) > 0;
|
|
137
|
+
let mocksStarted = false;
|
|
138
|
+
if (hasNetworkMocks && input.resourceId) {
|
|
139
|
+
const deviceId = input.resourceId.includes(":") ? input.resourceId.split(":")[1] : input.resourceId;
|
|
140
|
+
const platform = input.platform.toLowerCase();
|
|
141
|
+
try {
|
|
142
|
+
await networkMockService.start({
|
|
143
|
+
rules: parsed.value.networkMocks,
|
|
144
|
+
platform,
|
|
145
|
+
deviceId,
|
|
146
|
+
});
|
|
147
|
+
mocksStarted = true;
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
return jsonResult({
|
|
151
|
+
ok: false,
|
|
152
|
+
message: `启动网络 mock 失败: ${err instanceof Error ? err.message : String(err)}`,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
else if (hasNetworkMocks && !input.resourceId) {
|
|
157
|
+
// Mocks defined but no device — note for manual setup
|
|
158
|
+
}
|
|
134
159
|
liveServerStarted ??= startLiveViewer(livePort, runManager).then((viewer) => {
|
|
135
160
|
runManager.setLiveBaseUrl(viewer.baseUrl);
|
|
136
161
|
});
|
|
@@ -146,38 +171,51 @@ export function createPreflightMcpServer(options = {}) {
|
|
|
146
171
|
runtimeEnv: { ...preflightRunDefaults(), ...(await loadConfigEnv()), ...input.runtimeEnv },
|
|
147
172
|
visualFlow: parsed.value,
|
|
148
173
|
});
|
|
149
|
-
if (!input.waitForCompletion)
|
|
150
|
-
return jsonResult({
|
|
151
|
-
|
|
174
|
+
if (!input.waitForCompletion) {
|
|
175
|
+
return jsonResult({
|
|
176
|
+
...started,
|
|
177
|
+
visualFlow: parsed.value,
|
|
178
|
+
...(mocksStarted ? { networkMocksActive: true } : {}),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
const result = await runManager.waitForRun(started.runId, safeMcpWaitMs(input.timeoutMs), RUN_POLL_INTERVAL_MS);
|
|
182
|
+
if (mocksStarted) {
|
|
183
|
+
try {
|
|
184
|
+
await networkMockService.stop();
|
|
185
|
+
}
|
|
186
|
+
catch { /* cleanup */ }
|
|
187
|
+
}
|
|
188
|
+
return jsonResult(result);
|
|
152
189
|
});
|
|
153
190
|
server.registerTool("watch_run", {
|
|
154
191
|
title: "Watch Test Run",
|
|
155
192
|
description: "Refresh and summarize a test run. Use while the live viewer is open. " +
|
|
156
193
|
"For long-running tests that exceed the MCP transport timeout (60s): " +
|
|
157
194
|
"(1) Call run_flow with waitForCompletion: false to get a runId. " +
|
|
158
|
-
"(2) Call watch_run
|
|
159
|
-
"minIntervalMs blocks on the server side and polls the agent every 2s internally, " +
|
|
160
|
-
"so one call covers one full interval without consuming tokens on intermediate checks. " +
|
|
195
|
+
"(2) Call watch_run to poll the run. It waits up to 45s by default and returns early when the run succeeds or fails. " +
|
|
161
196
|
"Do NOT call run_flow again — that creates a duplicate run.",
|
|
162
197
|
inputSchema: {
|
|
163
198
|
runId: z.string(),
|
|
164
199
|
waitForCompletion: z.boolean().optional().describe("Set to true to block until the run finishes (respects timeoutMs). " +
|
|
165
200
|
"Omit or set to false for a lightweight status check — use this for polling."),
|
|
166
201
|
timeoutMs: z.number().int().positive().optional().describe("Maximum wait time in milliseconds. " +
|
|
167
|
-
"
|
|
168
|
-
"Default is 120000 (2 minutes). " +
|
|
202
|
+
"Capped internally at 45000ms so the MCP response returns before the 60s transport timeout. " +
|
|
169
203
|
"NOTE: Only effective when waitForCompletion is true. " +
|
|
170
|
-
"
|
|
171
|
-
minIntervalMs: z.number().int().positive().optional().describe("Block for at least this many milliseconds before returning. " +
|
|
172
|
-
"While blocked, the server polls the agent every 2s internally. " +
|
|
173
|
-
"If the run finishes early, returns immediately. " +
|
|
174
|
-
"Use 30000 for a ~30s polling cadence without consuming extra tokens " +
|
|
175
|
-
"on intermediate calls. Capped internally to 55000 to leave headroom " +
|
|
176
|
-
"for the 60s MCP transport timeout."),
|
|
204
|
+
"Prefer polling without waitForCompletion for long runs."),
|
|
177
205
|
},
|
|
178
|
-
}, async ({ runId, waitForCompletion, timeoutMs
|
|
206
|
+
}, async ({ runId, waitForCompletion, timeoutMs }) => {
|
|
179
207
|
await runtime.ensureStarted();
|
|
180
|
-
|
|
208
|
+
const summary = waitForCompletion
|
|
209
|
+
? await runManager.waitForRun(runId, safeMcpWaitMs(timeoutMs), RUN_POLL_INTERVAL_MS)
|
|
210
|
+
: await runManager.watchRun(runId, MCP_SAFE_WAIT_MS);
|
|
211
|
+
// Auto-cleanup mocks on terminal state
|
|
212
|
+
if (["SUCCESS", "FAILED", "CANCELLED"].includes(summary.status) && networkMockService.isRunning()) {
|
|
213
|
+
try {
|
|
214
|
+
await networkMockService.stop();
|
|
215
|
+
}
|
|
216
|
+
catch { /* cleanup */ }
|
|
217
|
+
}
|
|
218
|
+
return jsonResult(summary);
|
|
181
219
|
});
|
|
182
220
|
server.registerTool("cancel_run", {
|
|
183
221
|
title: "Cancel Test Run",
|
|
@@ -189,7 +227,15 @@ export function createPreflightMcpServer(options = {}) {
|
|
|
189
227
|
},
|
|
190
228
|
}, async ({ runId, reason }) => {
|
|
191
229
|
await runtime.ensureStarted();
|
|
192
|
-
|
|
230
|
+
const result = await runManager.cancelRun(runId, "model", reason ?? "no reason given");
|
|
231
|
+
// Clean up network mocks if they were auto-started for this run
|
|
232
|
+
if (networkMockService.isRunning()) {
|
|
233
|
+
try {
|
|
234
|
+
await networkMockService.stop();
|
|
235
|
+
}
|
|
236
|
+
catch { /* cleanup */ }
|
|
237
|
+
}
|
|
238
|
+
return jsonResult(result);
|
|
193
239
|
});
|
|
194
240
|
server.registerTool("save_report", {
|
|
195
241
|
title: "Save Test Report",
|
|
@@ -226,6 +272,162 @@ export function createPreflightMcpServer(options = {}) {
|
|
|
226
272
|
const result = await readReport(reportDir);
|
|
227
273
|
return jsonResult(result);
|
|
228
274
|
});
|
|
275
|
+
server.registerTool("start_network_mocks", {
|
|
276
|
+
title: "Start Network Mocks",
|
|
277
|
+
description: "Start the network mock HTTP/HTTPS proxy server and configure the device to route traffic through it. " +
|
|
278
|
+
"Matching requests return mock responses; non-matching traffic is forwarded transparently. " +
|
|
279
|
+
"Automatically generates a Root CA certificate for HTTPS MITM interception. " +
|
|
280
|
+
"Use before a test run to mock API responses that the app depends on. " +
|
|
281
|
+
"Currently supports Android emulator (iOS simulator deferred to phase 2).",
|
|
282
|
+
inputSchema: {
|
|
283
|
+
platform: z.enum(["ANDROID", "IOS"]).describe("Device platform"),
|
|
284
|
+
resourceId: z.string().describe("Device resource ID from list_devices (e.g., android:emulator-5554)"),
|
|
285
|
+
port: z.number().int().positive().optional().describe("Preferred port (e.g., to match existing device proxy config)"),
|
|
286
|
+
rules: z.array(z.object({
|
|
287
|
+
urlPattern: z.string().describe("Substring to match against the full request URL"),
|
|
288
|
+
method: z.enum(["GET", "POST", "PUT", "DELETE", "PATCH"]).optional().describe("HTTP method to match (default: any)"),
|
|
289
|
+
responses: z.array(z.object({
|
|
290
|
+
status: z.number().int().min(100).max(599).optional().describe("HTTP status code (default: 200)"),
|
|
291
|
+
body: z.string().describe("Response body, typically a JSON string"),
|
|
292
|
+
requestBodyMatch: z.record(z.string()).optional().describe("Key-value pairs that must match in the request body"),
|
|
293
|
+
callIndex: z.number().int().positive().optional().describe("Only match on the nth call (1-based, for stateful sequences)"),
|
|
294
|
+
headers: z.record(z.string()).optional().describe("Optional response headers"),
|
|
295
|
+
delay: z.number().int().min(0).optional().describe("Delay in ms before responding"),
|
|
296
|
+
})),
|
|
297
|
+
description: z.string().optional().describe("Human-readable description"),
|
|
298
|
+
})),
|
|
299
|
+
},
|
|
300
|
+
}, async (input) => {
|
|
301
|
+
await runtime.ensureStarted();
|
|
302
|
+
const platform = input.platform.toLowerCase();
|
|
303
|
+
const deviceId = input.resourceId.includes(":") ? input.resourceId.split(":")[1] : input.resourceId;
|
|
304
|
+
const rules = input.rules.map((r) => ({
|
|
305
|
+
urlPattern: r.urlPattern,
|
|
306
|
+
...(r.method ? { method: r.method } : {}),
|
|
307
|
+
responses: r.responses.map((resp) => ({
|
|
308
|
+
...(resp.status != null ? { status: resp.status } : {}),
|
|
309
|
+
body: resp.body,
|
|
310
|
+
...(resp.requestBodyMatch ? { requestBodyMatch: resp.requestBodyMatch } : {}),
|
|
311
|
+
...(resp.callIndex != null ? { callIndex: resp.callIndex } : {}),
|
|
312
|
+
...(resp.headers ? { headers: resp.headers } : {}),
|
|
313
|
+
...(resp.delay != null ? { delay: resp.delay } : {}),
|
|
314
|
+
})),
|
|
315
|
+
...(r.description ? { description: r.description } : {}),
|
|
316
|
+
}));
|
|
317
|
+
return jsonResult(await networkMockService.start({ rules, platform, deviceId, preferredPort: input.port }));
|
|
318
|
+
});
|
|
319
|
+
server.registerTool("stop_network_mocks", {
|
|
320
|
+
title: "Stop Network Mocks",
|
|
321
|
+
description: "Stop the network mock server and remove device proxy configuration. " +
|
|
322
|
+
"Call this after the test completes to restore normal network traffic.",
|
|
323
|
+
}, async () => jsonResult(await networkMockService.stop()));
|
|
324
|
+
server.registerTool("get_network_mock_status", {
|
|
325
|
+
title: "Get Network Mock Status",
|
|
326
|
+
description: "Get the current network mock server status and per-rule call statistics. " +
|
|
327
|
+
"Use to verify that mocks are being hit as expected during a test.",
|
|
328
|
+
}, async () => jsonResult(networkMockService.getStats()));
|
|
329
|
+
server.registerTool("update_network_mock_rules", {
|
|
330
|
+
title: "Update Network Mock Rules",
|
|
331
|
+
description: "Hot-reload mock rules without stopping the server. " +
|
|
332
|
+
"Use to change mock responses mid-test without restarting the proxy.",
|
|
333
|
+
inputSchema: {
|
|
334
|
+
rules: z.array(z.object({
|
|
335
|
+
urlPattern: z.string(),
|
|
336
|
+
method: z.enum(["GET", "POST", "PUT", "DELETE", "PATCH"]).optional(),
|
|
337
|
+
responses: z.array(z.object({
|
|
338
|
+
status: z.number().int().min(100).max(599).optional(),
|
|
339
|
+
body: z.string(),
|
|
340
|
+
requestBodyMatch: z.record(z.string()).optional(),
|
|
341
|
+
callIndex: z.number().int().positive().optional(),
|
|
342
|
+
headers: z.record(z.string()).optional(),
|
|
343
|
+
delay: z.number().int().min(0).optional(),
|
|
344
|
+
})),
|
|
345
|
+
description: z.string().optional(),
|
|
346
|
+
})),
|
|
347
|
+
},
|
|
348
|
+
}, async ({ rules }) => {
|
|
349
|
+
const parsed = rules.map((r) => ({
|
|
350
|
+
urlPattern: r.urlPattern,
|
|
351
|
+
...(r.method ? { method: r.method } : {}),
|
|
352
|
+
responses: r.responses.map((resp) => ({
|
|
353
|
+
...(resp.status != null ? { status: resp.status } : {}),
|
|
354
|
+
body: resp.body,
|
|
355
|
+
...(resp.requestBodyMatch ? { requestBodyMatch: resp.requestBodyMatch } : {}),
|
|
356
|
+
...(resp.callIndex != null ? { callIndex: resp.callIndex } : {}),
|
|
357
|
+
...(resp.headers ? { headers: resp.headers } : {}),
|
|
358
|
+
...(resp.delay != null ? { delay: resp.delay } : {}),
|
|
359
|
+
})),
|
|
360
|
+
...(r.description ? { description: r.description } : {}),
|
|
361
|
+
}));
|
|
362
|
+
networkMockService.updateRules(parsed);
|
|
363
|
+
return jsonResult({ ok: true, updated: parsed.length });
|
|
364
|
+
});
|
|
365
|
+
server.registerTool("get_root_ca_cert", {
|
|
366
|
+
title: "Get Root CA Certificate",
|
|
367
|
+
description: "Export the Preflight MITM Root CA certificate (PEM format). " +
|
|
368
|
+
"Install this certificate on your iOS/Android device to enable HTTPS interception. " +
|
|
369
|
+
"iOS: Send the cert to the device (e.g. AirDrop), open it, go to Settings > Profile Downloaded > Install. " +
|
|
370
|
+
"Then Settings > General > About > Certificate Trust Settings > Enable full trust for 'Preflight Mock CA'. " +
|
|
371
|
+
"Android: Settings > Security > Encryption & credentials > Install a certificate > CA certificate. " +
|
|
372
|
+
"Only available when network mocks are running.",
|
|
373
|
+
}, async () => {
|
|
374
|
+
const cert = networkMockService.getRootCACert();
|
|
375
|
+
if (!cert)
|
|
376
|
+
return jsonResult({ ok: false, message: "network mocks not running — call start_network_mocks first" });
|
|
377
|
+
return { content: [{ type: "text", text: cert }] };
|
|
378
|
+
});
|
|
379
|
+
server.registerTool("get_mobile_config", {
|
|
380
|
+
title: "Get iOS Mobile Config",
|
|
381
|
+
description: "Generate an iOS .mobileconfig profile that installs the CA certificate AND configures the WiFi proxy " +
|
|
382
|
+
"in one step. Send this file to the iOS device (e.g. AirDrop), open it to install. " +
|
|
383
|
+
"After installation, go to Settings > General > About > Certificate Trust Settings > enable 'Preflight Mock CA'. " +
|
|
384
|
+
"The proxy host will be auto-detected as this machine's local IP. " +
|
|
385
|
+
"Only available when network mocks are running.",
|
|
386
|
+
}, async () => {
|
|
387
|
+
if (!networkMockService.isRunning()) {
|
|
388
|
+
return jsonResult({ ok: false, message: "network mocks not running — call start_network_mocks first" });
|
|
389
|
+
}
|
|
390
|
+
const port = networkMockService.getStats().port;
|
|
391
|
+
if (!port)
|
|
392
|
+
return jsonResult({ ok: false, message: "mock server port unknown" });
|
|
393
|
+
const proxyHost = proxyHostForPlatform("ios") === "127.0.0.1" ? "172.23.166.53" : proxyHostForPlatform("ios");
|
|
394
|
+
const config = networkMockService.generateMobileConfig(proxyHost, port);
|
|
395
|
+
if (!config)
|
|
396
|
+
return jsonResult({ ok: false, message: "failed to generate mobileconfig" });
|
|
397
|
+
return { content: [{ type: "text", text: config }] };
|
|
398
|
+
});
|
|
399
|
+
server.registerTool("start_recording", {
|
|
400
|
+
title: "Start Network Recording",
|
|
401
|
+
description: "Start recording network traffic through the mock proxy. All requests and responses are captured " +
|
|
402
|
+
"and can be exported as mock rules via export_recorded_rules. Network mocks must already be running.",
|
|
403
|
+
}, async () => {
|
|
404
|
+
if (!networkMockService.isRunning()) {
|
|
405
|
+
return jsonResult({ ok: false, message: "network mocks not running — call start_network_mocks first" });
|
|
406
|
+
}
|
|
407
|
+
networkMockService.setRecording(true);
|
|
408
|
+
return jsonResult({ ok: true, message: "Recording started" });
|
|
409
|
+
});
|
|
410
|
+
server.registerTool("stop_recording", {
|
|
411
|
+
title: "Stop Network Recording",
|
|
412
|
+
description: "Stop recording and return the count of captured requests.",
|
|
413
|
+
}, async () => {
|
|
414
|
+
if (!networkMockService.isRunning()) {
|
|
415
|
+
return jsonResult({ ok: false, message: "network mocks not running" });
|
|
416
|
+
}
|
|
417
|
+
const count = networkMockService.getRecordedCount();
|
|
418
|
+
networkMockService.setRecording(false);
|
|
419
|
+
return jsonResult({ ok: true, recorded: count });
|
|
420
|
+
});
|
|
421
|
+
server.registerTool("export_recorded_rules", {
|
|
422
|
+
title: "Export Recorded Rules",
|
|
423
|
+
description: "Export recorded network traffic as NetworkMockRule[]. " +
|
|
424
|
+
"Duplicated URLs are merged, request bodies generate requestBodyMatch rules, " +
|
|
425
|
+
"and sequential responses are assigned callIndex. " +
|
|
426
|
+
"Use after stop_recording to convert captured traffic into reusable mock rules.",
|
|
427
|
+
}, async () => {
|
|
428
|
+
const rules = networkMockService.exportRecordedRules();
|
|
429
|
+
return jsonResult({ ok: true, count: rules.length, rules });
|
|
430
|
+
});
|
|
229
431
|
registerExplorationTools(server, { client, loadConfigEnv, ensureAgentStarted: async () => { await runtime.ensureStarted(); }, createSessionFromMeta: createMidsceneSessionFromResourceId, projectRoot });
|
|
230
432
|
return server;
|
|
231
433
|
}
|
|
@@ -234,6 +436,9 @@ function jsonResult(value) {
|
|
|
234
436
|
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
|
|
235
437
|
};
|
|
236
438
|
}
|
|
439
|
+
function safeMcpWaitMs(timeoutMs) {
|
|
440
|
+
return Math.min(timeoutMs ?? MCP_SAFE_WAIT_MS, MCP_SAFE_WAIT_MS);
|
|
441
|
+
}
|
|
237
442
|
function preflightRunDefaults() {
|
|
238
443
|
return {
|
|
239
444
|
MIDSCENE_OUTPUT_FORMAT: "html-and-external-assets",
|
|
@@ -243,6 +448,7 @@ function preflightRunDefaults() {
|
|
|
243
448
|
MIDSCENE_RECORD_VIDEO_CRF: "32",
|
|
244
449
|
MIDSCENE_RECORD_VIDEO_PRESET: "fast",
|
|
245
450
|
MIDSCENE_RUN_TIMEOUT_MS: "1200000", // 20分钟,适配多步骤流程
|
|
451
|
+
MIDSCENE_REPLANNING_CYCLE_LIMIT: "10", // 避免死循环
|
|
246
452
|
};
|
|
247
453
|
}
|
|
248
454
|
const VISUAL_FLOW_LLM_HARD_RULES = `# Preflight Visual Flow IR hard rules
|