preflite 1.1.0 → 1.1.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.
@@ -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,3 @@
1
+ export { NetworkMockServer } from "./NetworkMockServer.js";
2
+ export { NetworkMockService } from "./NetworkMockService.js";
3
+ export { configureDeviceProxy, removeDeviceProxy, proxyHostForPlatform } from "./device-proxy.js";
@@ -0,0 +1 @@
1
+ export {};
@@ -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
  ]);
@@ -1,28 +1,62 @@
1
1
  import { spawn } from "node:child_process";
2
- import { cp, mkdir, rm, writeFile } from "node:fs/promises";
2
+ import { access, cp, mkdir, rm, writeFile } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
- import { join } from "node:path";
4
+ import { basename, dirname, join, sep } from "node:path";
5
5
  export async function installLocalRuntime(options) {
6
6
  const runtimeRoot = options.runtimeRoot ?? join(homedir(), ".preflight", "runtime");
7
- await runNpm(["run", "build"], options.projectRoot);
7
+ const sourceRoot = options.sourceRoot ?? options.projectRoot;
8
+ const targetProjectRoot = options.targetProjectRoot ?? options.projectRoot ?? process.cwd();
9
+ const npmRunner = options.runNpm ?? runNpm;
10
+ if (!sourceRoot) {
11
+ throw new Error("Preflight runtime source root is required.");
12
+ }
13
+ if (await exists(join(sourceRoot, "tsconfig.build.json"))) {
14
+ await npmRunner(["run", "build"], sourceRoot);
15
+ }
8
16
  await rm(runtimeRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 500 });
9
17
  await mkdir(runtimeRoot, { recursive: true });
10
18
  await mkdir(join(runtimeRoot, "node", "bin"), { recursive: true });
11
19
  await cp(process.execPath, join(runtimeRoot, "node", "bin", "node"));
12
- await cp(join(options.projectRoot, "dist"), join(runtimeRoot, "dist"), { recursive: true });
13
- await cp(join(options.projectRoot, "package.json"), join(runtimeRoot, "package.json"));
14
- await cp(join(options.projectRoot, "package-lock.json"), join(runtimeRoot, "package-lock.json"));
15
- await cp(join(options.projectRoot, "scripts"), join(runtimeRoot, "scripts"), { recursive: true }).catch(() => { });
20
+ await cp(join(sourceRoot, "dist"), join(runtimeRoot, "dist"), { recursive: true });
21
+ await cp(join(sourceRoot, "package.json"), join(runtimeRoot, "package.json"));
22
+ await cp(join(sourceRoot, "package-lock.json"), join(runtimeRoot, "package-lock.json")).catch(() => { });
23
+ await cp(join(sourceRoot, "scripts"), join(runtimeRoot, "scripts"), { recursive: true }).catch(() => { });
16
24
  await mkdir(join(runtimeRoot, "docs"), { recursive: true });
17
- await cp(join(options.projectRoot, "docs", "visual-flow-ir-llm.md"), join(runtimeRoot, "docs", "visual-flow-ir-llm.md"));
18
- await runNpm(["ci", "--omit=dev"], runtimeRoot);
19
- await writeFile(join(runtimeRoot, "preflight-runtime.json"), `${JSON.stringify({ installedAt: new Date().toISOString(), source: options.projectRoot }, null, 2)}\n`, "utf8");
25
+ await cp(join(sourceRoot, "docs", "visual-flow-ir-llm.md"), join(runtimeRoot, "docs", "visual-flow-ir-llm.md"));
26
+ const reusedDependencies = await reuseInstalledDependencies(sourceRoot, runtimeRoot);
27
+ if (!reusedDependencies) {
28
+ await npmRunner(await exists(join(runtimeRoot, "package-lock.json")) ? ["ci", "--omit=dev"] : ["install", "--omit=dev"], runtimeRoot);
29
+ }
30
+ await writeFile(join(runtimeRoot, "preflight-runtime.json"), `${JSON.stringify({ installedAt: new Date().toISOString(), source: sourceRoot, targetProjectRoot }, null, 2)}\n`, "utf8");
20
31
  return {
21
32
  runtimeRoot,
22
33
  nodeBin: join(runtimeRoot, "node", "bin", "node"),
23
34
  mcpEntry: join(runtimeRoot, "dist", "mcp", "cli.js"),
24
35
  };
25
36
  }
37
+ async function reuseInstalledDependencies(sourceRoot, runtimeRoot) {
38
+ const packageNodeModules = join(sourceRoot, "node_modules");
39
+ if (await exists(packageNodeModules)) {
40
+ await cp(packageNodeModules, join(runtimeRoot, "node_modules"), { recursive: true });
41
+ return true;
42
+ }
43
+ const parentNodeModules = dirname(sourceRoot);
44
+ const isNpxPackage = basename(parentNodeModules) === "node_modules" && parentNodeModules.includes(`${sep}_npx${sep}`);
45
+ if (isNpxPackage) {
46
+ await cp(parentNodeModules, join(runtimeRoot, "node_modules"), { recursive: true });
47
+ return true;
48
+ }
49
+ return false;
50
+ }
51
+ async function exists(path) {
52
+ try {
53
+ await access(path);
54
+ return true;
55
+ }
56
+ catch {
57
+ return false;
58
+ }
59
+ }
26
60
  function run(command, args, cwd) {
27
61
  return new Promise((resolve, reject) => {
28
62
  const child = spawn(command, args, { cwd, stdio: "inherit" });
@@ -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
- "Calculate as: number of visual flow steps × 60000 (1 minute per step). " +
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
- "MCP transport has a 60s hard timeout if timeoutMs exceeds 60s, " +
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({ ...started, visualFlow: parsed.value });
151
- return jsonResult(await runManager.waitForRun(started.runId, input.timeoutMs ?? 120_000, 2_000));
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 with minIntervalMs (e.g. 30000 = check every 30s) to poll. " +
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
- "Based on the total number of steps in the original visual flow: steps × 60000. " +
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
- "Due to MCP 60s transport timeout, prefer polling without waitForCompletion for long runs."),
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, minIntervalMs }) => {
206
+ }, async ({ runId, waitForCompletion, timeoutMs }) => {
179
207
  await runtime.ensureStarted();
180
- return jsonResult(waitForCompletion ? await runManager.waitForRun(runId, timeoutMs ?? 120_000, 2_000) : await runManager.watchRun(runId, minIntervalMs));
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
- return jsonResult(await runManager.cancelRun(runId, "model", reason ?? "no reason given"));
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
package/dist/mcp/setup.js CHANGED
@@ -7,7 +7,11 @@ export async function setupLocalMcp(options) {
7
7
  const livePort = options.livePort ?? 18999;
8
8
  const shouldInstallRuntime = options.installRuntime ?? true;
9
9
  const installedRuntime = shouldInstallRuntime
10
- ? await installLocalRuntime({ projectRoot: options.projectRoot, runtimeRoot: options.runtimeRoot })
10
+ ? await installLocalRuntime({
11
+ sourceRoot: options.runtimeSourceRoot ?? options.projectRoot,
12
+ targetProjectRoot: options.projectRoot,
13
+ runtimeRoot: options.runtimeRoot,
14
+ })
11
15
  : undefined;
12
16
  const runtimeRoot = installedRuntime?.runtimeRoot ?? options.runtimeRoot ?? process.env.AGENT_RUNTIME_ROOT?.trim();
13
17
  const isRuntime = !!runtimeRoot;
@@ -125,8 +129,8 @@ alwaysApply: false
125
129
  7. 调用 \`validate_visual_flow\`。如果校验失败,按 message 修正 visualFlow 后再次校验。
126
130
  8. 生成最小测试用例:先覆盖改动点,再补必要回归。
127
131
  9. 如用户给了 app 包,先调用 \`install_app\`。
128
- 10. 调用 \`run_flow\`,把返回的 liveUrl 明确给用户,让用户可以打开浏览器看实时执行。
129
- 11. 执行中调用 \`watch_run\` 观察状态。失败时先判断是环境/设备、IR 用例步骤、agent runtime 还是真实业务问题。
132
+ 10. 调用 \`run_flow\` 时必须设置 \`waitForCompletion: false\`,让工具立即返回 runId/liveUrl;不要用 \`waitForCompletion: true\` 等待完整流程,避免 MCP 60 秒传输超时。
133
+ 11. 执行中调用 \`watch_run\` 观察状态;工具默认等待最多 45 秒,run 一旦成功或失败会提前返回。失败时先判断是环境/设备、IR 用例步骤、agent runtime 还是真实业务问题。
130
134
  12. 如果失败原因是 IR 步骤不合理,只能调整 visualFlow 后重跑;不要读取或手写 Midscene TS 脚本。若是 Preflight 编译器/runtime 内部错误,停止并报告为工具缺陷。
131
135
  13. 最终调用 \`save_report\`,并在回复中给出测试报告、report/liveUrl 和 PASS/FAIL 结论。
132
136
  `;
@@ -161,8 +165,8 @@ Workflow:
161
165
  6. Read \`get_visual_flow_ir_rules\` and generate visualFlow JSON, not raw Midscene TypeScript.
162
166
  7. Validate with \`validate_visual_flow\`; fix the JSON until validation passes.
163
167
  8. Install the app when an app package path is provided.
164
- 9. Start the run with \`run_flow\` and show the returned liveUrl.
165
- 10. Poll with \`watch_run\`.
168
+ 9. Start the run with \`run_flow\` using \`waitForCompletion: false\`, then show the returned liveUrl. Never wait for a full flow inside \`run_flow\`; MCP transport can time out after about 60 seconds.
169
+ 10. Poll with \`watch_run\`; it waits up to 45s by default and returns early when the run succeeds or fails.
166
170
  11. Analyze failures before retrying. Distinguish device/env failures, brittle IR steps, agent runtime failures, and real app bugs. For IR problems, revise visualFlow only; never switch to raw Midscene script repair.
167
171
  12. Save report with \`save_report\`.
168
172