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.
- package/README.md +113 -62
- package/dist/infrastructure/midscene/MidsceneRuntimeReal.js +41 -3
- package/dist/mcp/agentHttpClient.js +19 -6
- package/dist/mcp/cli.js +27 -1
- 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/runtimeInstall.js +44 -10
- package/dist/mcp/server.js +229 -23
- package/dist/mcp/setup.js +9 -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 +221 -0
- package/package.json +3 -1
- package/scripts/hdc-bridge.sh +4 -0
- package/scripts/nvm-use-repo.sh +31 -0
- package/scripts/run-midscene-task.sh +43 -0
- package/scripts/start-ios-wda.sh +328 -0
- package/scripts/stop-ios-wda.sh +44 -0
|
@@ -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
|
]);
|
|
@@ -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
|
-
|
|
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(
|
|
13
|
-
await cp(join(
|
|
14
|
-
await cp(join(
|
|
15
|
-
await cp(join(
|
|
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(
|
|
18
|
-
await
|
|
19
|
-
|
|
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" });
|
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
|
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({
|
|
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
|
|
129
|
-
11. 执行中调用 \`watch_run\`
|
|
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\`
|
|
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
|
|