preflite 1.1.4 → 1.1.6
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/dist/mcp/server.js +0 -203
- package/dist/mcp/setup.js +13 -11
- package/dist/mcp/visual-flow/validate.js +0 -171
- package/docs/visual-flow-ir-llm.md +0 -68
- package/package.json +1 -1
- package/dist/mcp/network-mocks/NetworkMockServer.js +0 -579
- package/dist/mcp/network-mocks/NetworkMockService.js +0 -156
- package/dist/mcp/network-mocks/device-proxy.js +0 -31
- package/dist/mcp/network-mocks/index.js +0 -3
- package/dist/mcp/network-mocks/types.js +0 -1
package/dist/mcp/server.js
CHANGED
|
@@ -13,8 +13,6 @@ 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";
|
|
18
16
|
import { readFile } from "node:fs/promises";
|
|
19
17
|
import { join } from "node:path";
|
|
20
18
|
const MCP_SAFE_WAIT_MS = 45_000;
|
|
@@ -29,7 +27,6 @@ export function createPreflightMcpServer(options = {}) {
|
|
|
29
27
|
const runtime = new AgentRuntimeManager({ projectRoot, agentBaseUrl, client, env: process.env, runtimeRoot: options.runtimeRoot, loadConfigEnv });
|
|
30
28
|
const liveBaseUrl = `http://127.0.0.1:${livePort}`;
|
|
31
29
|
const runManager = new RunManager(client, liveBaseUrl);
|
|
32
|
-
const networkMockService = new NetworkMockService();
|
|
33
30
|
let liveServerStarted;
|
|
34
31
|
const server = new McpServer({ name: "Preflight", version: "0.1.0" });
|
|
35
32
|
server.registerTool("agent_health", {
|
|
@@ -133,29 +130,6 @@ export function createPreflightMcpServer(options = {}) {
|
|
|
133
130
|
const parsed = validateVisualFlow(input.visualFlow);
|
|
134
131
|
if (!parsed.ok)
|
|
135
132
|
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
|
-
}
|
|
159
133
|
liveServerStarted ??= startLiveViewer(livePort, runManager).then((viewer) => {
|
|
160
134
|
runManager.setLiveBaseUrl(viewer.baseUrl);
|
|
161
135
|
});
|
|
@@ -175,16 +149,9 @@ export function createPreflightMcpServer(options = {}) {
|
|
|
175
149
|
return jsonResult({
|
|
176
150
|
...started,
|
|
177
151
|
visualFlow: parsed.value,
|
|
178
|
-
...(mocksStarted ? { networkMocksActive: true } : {}),
|
|
179
152
|
});
|
|
180
153
|
}
|
|
181
154
|
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
155
|
return jsonResult(result);
|
|
189
156
|
});
|
|
190
157
|
server.registerTool("watch_run", {
|
|
@@ -208,13 +175,6 @@ export function createPreflightMcpServer(options = {}) {
|
|
|
208
175
|
const summary = waitForCompletion
|
|
209
176
|
? await runManager.waitForRun(runId, safeMcpWaitMs(timeoutMs), RUN_POLL_INTERVAL_MS)
|
|
210
177
|
: 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
178
|
return jsonResult(summary);
|
|
219
179
|
});
|
|
220
180
|
server.registerTool("cancel_run", {
|
|
@@ -228,13 +188,6 @@ export function createPreflightMcpServer(options = {}) {
|
|
|
228
188
|
}, async ({ runId, reason }) => {
|
|
229
189
|
await runtime.ensureStarted();
|
|
230
190
|
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
191
|
return jsonResult(result);
|
|
239
192
|
});
|
|
240
193
|
server.registerTool("save_report", {
|
|
@@ -272,162 +225,6 @@ export function createPreflightMcpServer(options = {}) {
|
|
|
272
225
|
const result = await readReport(reportDir);
|
|
273
226
|
return jsonResult(result);
|
|
274
227
|
});
|
|
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
|
-
});
|
|
431
228
|
registerExplorationTools(server, { client, loadConfigEnv, ensureAgentStarted: async () => { await runtime.ensureStarted(); }, createSessionFromMeta: createMidsceneSessionFromResourceId, projectRoot });
|
|
432
229
|
return server;
|
|
433
230
|
}
|
package/dist/mcp/setup.js
CHANGED
|
@@ -131,12 +131,13 @@ alwaysApply: false
|
|
|
131
131
|
5. 调用 \`list_devices\`,优先选择与改动相关的平台设备。
|
|
132
132
|
6. 调用 \`get_visual_flow_ir_rules\`,按 IR 规范生成 visualFlow JSON。默认不要直接写 Midscene TS 脚本。
|
|
133
133
|
7. 调用 \`validate_visual_flow\`。如果校验失败,按 message 修正 visualFlow 后再次校验。
|
|
134
|
-
8.
|
|
135
|
-
9.
|
|
136
|
-
10.
|
|
137
|
-
11.
|
|
138
|
-
12.
|
|
139
|
-
13.
|
|
134
|
+
8. **每条 visualFlow 必须以冷启动开始**:第一个步骤总是 \`closeApp\` + \`launch\`(同一个 packageName),确保 app 从已知初始状态执行。不要在已打开的 app 上接着跑测试;不确定 app 已在哪个页面时,一律重启。
|
|
135
|
+
9. 生成最小测试用例:先覆盖改动点,再补必要回归。
|
|
136
|
+
10. 如用户给了 app 包,先调用 \`install_app\`。
|
|
137
|
+
11. 调用 \`run_flow\` 时必须设置 \`waitForCompletion: false\`,让工具立即返回 runId/liveUrl;不要用 \`waitForCompletion: true\` 等待完整流程,避免 MCP 60 秒传输超时。
|
|
138
|
+
12. 执行中调用 \`watch_run\` 观察状态;工具默认等待最多 45 秒,run 一旦成功或失败会提前返回。失败时先判断是环境/设备、IR 用例步骤、agent runtime 还是真实业务问题。
|
|
139
|
+
13. 如果失败原因是 IR 步骤不合理,只能调整 visualFlow 后重跑;不要读取或手写 Midscene TS 脚本。若是 Preflight 编译器/runtime 内部错误,停止并报告为工具缺陷。
|
|
140
|
+
14. 最终调用 \`save_report\`,并在回复中给出测试报告、report/liveUrl 和 PASS/FAIL 结论。
|
|
140
141
|
`;
|
|
141
142
|
}
|
|
142
143
|
function skillText() {
|
|
@@ -168,11 +169,12 @@ Workflow:
|
|
|
168
169
|
5. List devices with \`list_devices\`.
|
|
169
170
|
6. Read \`get_visual_flow_ir_rules\` and generate visualFlow JSON, not raw Midscene TypeScript.
|
|
170
171
|
7. Validate with \`validate_visual_flow\`; fix the JSON until validation passes.
|
|
171
|
-
8.
|
|
172
|
-
9.
|
|
173
|
-
10.
|
|
174
|
-
11.
|
|
175
|
-
12.
|
|
172
|
+
8. **Every visualFlow MUST begin with a cold start.** The first two steps are always \`closeApp\` then \`launch\` (same packageName). This guarantees the app starts from a known, clean initial state. Never skip this — even if the app is already open on the target screen.
|
|
173
|
+
9. Install the app when an app package path is provided.
|
|
174
|
+
10. 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.
|
|
175
|
+
11. Poll with \`watch_run\`; it waits up to 45s by default and returns early when the run succeeds or fails.
|
|
176
|
+
12. 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.
|
|
177
|
+
13. Save report with \`save_report\`.
|
|
176
178
|
|
|
177
179
|
Reports are written under \`~/.preflight/midscene_run/report/<reportName>/\`.
|
|
178
180
|
|
|
@@ -61,156 +61,6 @@ function parseScriptVars(raw) {
|
|
|
61
61
|
}
|
|
62
62
|
return { ok: true, value: out };
|
|
63
63
|
}
|
|
64
|
-
function parseSingleMockRule(o, path) {
|
|
65
|
-
const urlPattern = typeof o.urlPattern === 'string' && o.urlPattern.trim() ? o.urlPattern.trim() : "";
|
|
66
|
-
const urlRegex = typeof o.urlRegex === 'string' && o.urlRegex.trim() ? o.urlRegex.trim() : "";
|
|
67
|
-
if (!urlPattern && !urlRegex)
|
|
68
|
-
return { ok: false, message: `${path}.urlPattern 或 urlRegex 必填其一` };
|
|
69
|
-
if (urlPattern && urlPattern.length > 1000)
|
|
70
|
-
return { ok: false, message: `${path}.urlPattern 过长` };
|
|
71
|
-
if (urlRegex) {
|
|
72
|
-
try {
|
|
73
|
-
new RegExp(urlRegex);
|
|
74
|
-
}
|
|
75
|
-
catch {
|
|
76
|
-
return { ok: false, message: `${path}.urlRegex 无效` };
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
const queryParams = o.queryParams != null && typeof o.queryParams === 'object' && !Array.isArray(o.queryParams) ? Object.fromEntries(Object.entries(o.queryParams).filter(([, v]) => typeof v === 'string').map(([k, v]) => [k, v])) : undefined;
|
|
80
|
-
const method = typeof o.method === 'string' ? o.method.trim().toUpperCase() : '';
|
|
81
|
-
if (method && !['GET', 'POST', 'PUT', 'DELETE', 'PATCH'].includes(method))
|
|
82
|
-
return { ok: false, message: `${path}.method 须为 GET|POST|PUT|DELETE|PATCH` };
|
|
83
|
-
if (!Array.isArray(o.responses) || o.responses.length === 0)
|
|
84
|
-
return { ok: false, message: `${path}.responses 须为非空数组` };
|
|
85
|
-
if (o.responses.length > 50)
|
|
86
|
-
return { ok: false, message: `${path}.responses 超过上限 50` };
|
|
87
|
-
const responses = [];
|
|
88
|
-
const bodyStr = (b) => String(b);
|
|
89
|
-
for (let j = 0; j < o.responses.length; j++) {
|
|
90
|
-
const rPath = `${path}.responses[${j}]`;
|
|
91
|
-
const ri = o.responses[j];
|
|
92
|
-
if (!ri || typeof ri !== 'object' || Array.isArray(ri))
|
|
93
|
-
return { ok: false, message: `${rPath} 须为对象` };
|
|
94
|
-
const r = ri;
|
|
95
|
-
if (r.body == null)
|
|
96
|
-
return { ok: false, message: `${rPath}.body 必填` };
|
|
97
|
-
const body = bodyStr(r.body);
|
|
98
|
-
if (body.length > 1_000_000)
|
|
99
|
-
return { ok: false, message: `${rPath}.body 过长` };
|
|
100
|
-
const status = r.status != null ? Number(r.status) : 200;
|
|
101
|
-
if (!Number.isFinite(status) || status < 100 || status > 599)
|
|
102
|
-
return { ok: false, message: `${rPath}.status 须为 100~599` };
|
|
103
|
-
const callIndex = r.callIndex != null ? Number(r.callIndex) : undefined;
|
|
104
|
-
if (callIndex != null && (!Number.isFinite(callIndex) || callIndex < 1))
|
|
105
|
-
return { ok: false, message: `${rPath}.callIndex 须为正整数` };
|
|
106
|
-
const delay = r.delay != null ? Number(r.delay) : undefined;
|
|
107
|
-
if (delay != null && (!Number.isFinite(delay) || delay < 0 || delay > 60_000))
|
|
108
|
-
return { ok: false, message: `${rPath}.delay 须为 0~60000` };
|
|
109
|
-
responses.push({ status: Math.floor(status), body, ...(callIndex != null ? { callIndex: Math.floor(callIndex) } : {}), ...(delay != null ? { delay: Math.floor(delay) } : {}), ...(typeof r.headers === 'object' && r.headers && !Array.isArray(r.headers) && Object.keys(r.headers).length > 0 ? { headers: Object.fromEntries(Object.entries(r.headers).filter(([, v]) => typeof v === 'string').map(([k, v]) => [k, v])) } : {}), ...(typeof r.requestBodyMatch === 'object' && r.requestBodyMatch && !Array.isArray(r.requestBodyMatch) && Object.keys(r.requestBodyMatch).length > 0 ? { requestBodyMatch: Object.fromEntries(Object.entries(r.requestBodyMatch).filter(([, v]) => typeof v === 'string').map(([k, v]) => [k, v])) } : {}) });
|
|
110
|
-
}
|
|
111
|
-
const description = typeof o.description === 'string' && o.description.trim() ? o.description.trim().slice(0, 500) : undefined;
|
|
112
|
-
return { ok: true, value: { ...(urlPattern ? { urlPattern } : {}), ...(urlRegex ? { urlRegex } : {}), ...(queryParams && Object.keys(queryParams).length > 0 ? { queryParams } : {}), ...(method ? { method: method } : {}), responses, ...(description ? { description } : {}) } };
|
|
113
|
-
}
|
|
114
|
-
function parseNetworkMocks(raw) {
|
|
115
|
-
if (raw === undefined || raw === null)
|
|
116
|
-
return { ok: true, value: [] };
|
|
117
|
-
if (!Array.isArray(raw))
|
|
118
|
-
return { ok: false, message: 'visualFlow.networkMocks 须为数组' };
|
|
119
|
-
if (raw.length > 50)
|
|
120
|
-
return { ok: false, message: 'mock 规则数量超过上限 50' };
|
|
121
|
-
const out = [];
|
|
122
|
-
for (let i = 0; i < raw.length; i++) {
|
|
123
|
-
const path = `networkMocks[${i}]`;
|
|
124
|
-
const item = raw[i];
|
|
125
|
-
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
126
|
-
return { ok: false, message: `${path} 须为对象` };
|
|
127
|
-
}
|
|
128
|
-
const o = item;
|
|
129
|
-
const urlPattern = typeof o.urlPattern === 'string' && o.urlPattern.trim() ? o.urlPattern.trim() : "";
|
|
130
|
-
const urlRegex = typeof o.urlRegex === 'string' && o.urlRegex.trim() ? o.urlRegex.trim() : "";
|
|
131
|
-
if (!urlPattern && !urlRegex)
|
|
132
|
-
return { ok: false, message: `${path}.urlPattern 或 urlRegex 必填其一` };
|
|
133
|
-
if (urlPattern && urlPattern.length > 1000)
|
|
134
|
-
return { ok: false, message: `${path}.urlPattern 过长` };
|
|
135
|
-
if (urlRegex) {
|
|
136
|
-
try {
|
|
137
|
-
new RegExp(urlRegex);
|
|
138
|
-
}
|
|
139
|
-
catch {
|
|
140
|
-
return { ok: false, message: `${path}.urlRegex 不是有效的正则表达式` };
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
const queryParams = o.queryParams != null && typeof o.queryParams === 'object' && !Array.isArray(o.queryParams)
|
|
144
|
-
? Object.fromEntries(Object.entries(o.queryParams).filter(([, v]) => typeof v === "string").map(([k, v]) => [k, v]))
|
|
145
|
-
: undefined;
|
|
146
|
-
const method = typeof o.method === 'string' ? o.method.trim().toUpperCase() : '';
|
|
147
|
-
if (method && !['GET', 'POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) {
|
|
148
|
-
return { ok: false, message: `${path}.method 须为 GET|POST|PUT|DELETE|PATCH` };
|
|
149
|
-
}
|
|
150
|
-
if (!Array.isArray(o.responses) || o.responses.length === 0) {
|
|
151
|
-
return { ok: false, message: `${path}.responses 须为非空数组` };
|
|
152
|
-
}
|
|
153
|
-
if (o.responses.length > 50) {
|
|
154
|
-
return { ok: false, message: `${path}.responses 数量超过上限 50` };
|
|
155
|
-
}
|
|
156
|
-
const responses = [];
|
|
157
|
-
for (let j = 0; j < o.responses.length; j++) {
|
|
158
|
-
const rPath = `${path}.responses[${j}]`;
|
|
159
|
-
const rItem = o.responses[j];
|
|
160
|
-
if (!rItem || typeof rItem !== 'object' || Array.isArray(rItem)) {
|
|
161
|
-
return { ok: false, message: `${rPath} 须为对象` };
|
|
162
|
-
}
|
|
163
|
-
const r = rItem;
|
|
164
|
-
if (r.body == null)
|
|
165
|
-
return { ok: false, message: `${rPath}.body 必填` };
|
|
166
|
-
const body = String(r.body);
|
|
167
|
-
if (body.length > 1_000_000)
|
|
168
|
-
return { ok: false, message: `${rPath}.body 过长(上限 1MB)` };
|
|
169
|
-
const status = r.status != null ? Number(r.status) : 200;
|
|
170
|
-
if (!Number.isFinite(status) || status < 100 || status > 599) {
|
|
171
|
-
return { ok: false, message: `${rPath}.status 须为 100~599` };
|
|
172
|
-
}
|
|
173
|
-
const callIndex = r.callIndex != null ? Number(r.callIndex) : undefined;
|
|
174
|
-
if (callIndex != null && (!Number.isFinite(callIndex) || callIndex < 1)) {
|
|
175
|
-
return { ok: false, message: `${rPath}.callIndex 须为正整数` };
|
|
176
|
-
}
|
|
177
|
-
const delay = r.delay != null ? Number(r.delay) : undefined;
|
|
178
|
-
if (delay != null && (!Number.isFinite(delay) || delay < 0 || delay > 60_000)) {
|
|
179
|
-
return { ok: false, message: `${rPath}.delay 须为 0~60000` };
|
|
180
|
-
}
|
|
181
|
-
const headers = r.headers != null && typeof r.headers === 'object' && !Array.isArray(r.headers)
|
|
182
|
-
? Object.fromEntries(Object.entries(r.headers)
|
|
183
|
-
.filter(([, v]) => typeof v === 'string')
|
|
184
|
-
.map(([k, v]) => [k, v]))
|
|
185
|
-
: undefined;
|
|
186
|
-
const requestBodyMatch = r.requestBodyMatch != null && typeof r.requestBodyMatch === 'object' && !Array.isArray(r.requestBodyMatch)
|
|
187
|
-
? Object.fromEntries(Object.entries(r.requestBodyMatch)
|
|
188
|
-
.filter(([, v]) => typeof v === 'string')
|
|
189
|
-
.map(([k, v]) => [k, v]))
|
|
190
|
-
: undefined;
|
|
191
|
-
responses.push({
|
|
192
|
-
status: Math.floor(status),
|
|
193
|
-
body,
|
|
194
|
-
...(callIndex != null ? { callIndex: Math.floor(callIndex) } : {}),
|
|
195
|
-
...(delay != null ? { delay: Math.floor(delay) } : {}),
|
|
196
|
-
...(headers && Object.keys(headers).length > 0 ? { headers } : {}),
|
|
197
|
-
...(requestBodyMatch && Object.keys(requestBodyMatch).length > 0 ? { requestBodyMatch } : {}),
|
|
198
|
-
});
|
|
199
|
-
}
|
|
200
|
-
const description = typeof o.description === 'string' && o.description.trim()
|
|
201
|
-
? o.description.trim().slice(0, 500)
|
|
202
|
-
: undefined;
|
|
203
|
-
out.push({
|
|
204
|
-
...(urlPattern ? { urlPattern } : {}),
|
|
205
|
-
...(urlRegex ? { urlRegex } : {}),
|
|
206
|
-
...(queryParams && Object.keys(queryParams).length > 0 ? { queryParams } : {}),
|
|
207
|
-
...(method ? { method: method } : {}),
|
|
208
|
-
responses,
|
|
209
|
-
...(description ? { description } : {}),
|
|
210
|
-
});
|
|
211
|
-
}
|
|
212
|
-
return { ok: true, value: out };
|
|
213
|
-
}
|
|
214
64
|
function isNonEmptyString(v) {
|
|
215
65
|
return typeof v === 'string' && v.trim().length > 0;
|
|
216
66
|
}
|
|
@@ -471,23 +321,6 @@ function parseStep(raw, path) {
|
|
|
471
321
|
},
|
|
472
322
|
};
|
|
473
323
|
}
|
|
474
|
-
case 'setMock': {
|
|
475
|
-
if (!o.rule || typeof o.rule !== 'object' || Array.isArray(o.rule)) {
|
|
476
|
-
return { ok: false, message: `${path}.rule 须为 NetworkMockRule 对象` };
|
|
477
|
-
}
|
|
478
|
-
const mockParsed = parseSingleMockRule(o.rule, `${path}.rule`);
|
|
479
|
-
if (!mockParsed.ok)
|
|
480
|
-
return mockParsed;
|
|
481
|
-
return { ok: true, step: { type: 'setMock', rule: mockParsed.value } };
|
|
482
|
-
}
|
|
483
|
-
case 'removeMock': {
|
|
484
|
-
if (!isNonEmptyString(o.urlPattern))
|
|
485
|
-
return { ok: false, message: `${path}.urlPattern 必填` };
|
|
486
|
-
return { ok: true, step: { type: 'removeMock', urlPattern: o.urlPattern.trim() } };
|
|
487
|
-
}
|
|
488
|
-
case 'clearMocks': {
|
|
489
|
-
return { ok: true, step: { type: 'clearMocks' } };
|
|
490
|
-
}
|
|
491
324
|
case 'callScript': {
|
|
492
325
|
if (!isNonEmptyString(o.targetTestCaseId)) {
|
|
493
326
|
return { ok: false, message: `${path}.targetTestCaseId 必填` };
|
|
@@ -758,9 +591,6 @@ export function tryParseVisualFlow(raw) {
|
|
|
758
591
|
const scriptVarsParsed = parseScriptVars(o.scriptVars);
|
|
759
592
|
if (!scriptVarsParsed.ok)
|
|
760
593
|
return scriptVarsParsed;
|
|
761
|
-
const networkMocksParsed = parseNetworkMocks(o.networkMocks);
|
|
762
|
-
if (!networkMocksParsed.ok)
|
|
763
|
-
return networkMocksParsed;
|
|
764
594
|
const steps = [];
|
|
765
595
|
for (let i = 0; i < o.steps.length; i++) {
|
|
766
596
|
const r = parseStep(o.steps[i], `steps[${i}]`);
|
|
@@ -782,7 +612,6 @@ export function tryParseVisualFlow(raw) {
|
|
|
782
612
|
version: VISUAL_FLOW_VERSION,
|
|
783
613
|
...(scriptVarsParsed.value.length ? { scriptVars: scriptVarsParsed.value } : {}),
|
|
784
614
|
steps,
|
|
785
|
-
...(networkMocksParsed.value.length ? { networkMocks: networkMocksParsed.value } : {}),
|
|
786
615
|
},
|
|
787
616
|
};
|
|
788
617
|
}
|
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
| ------------ | --- | ------------------------------------ |
|
|
12
12
|
| `version` | 是 | 固定为数字 `2`,其它值保存失败。 |
|
|
13
13
|
| `scriptVars` | 否 | 执行前由人填的变量声明数组;步骤文案里用 `{{变量名}}` 引用。 |
|
|
14
|
-
| `networkMocks` | 否 | 网络 mock 规则数组,在测试开始前启动代理并配置到设备。每条规则描述一个 API 的 URL 匹配与 mock 响应序列。 |
|
|
15
14
|
| `steps` | 是 | 顶层步骤数组,按顺序执行;**展开后总条数 ≤ 500**(含子步骤)。 |
|
|
16
15
|
|
|
17
16
|
|
|
@@ -26,70 +25,6 @@
|
|
|
26
25
|
| `scope` | 否 | `global`、`local` 或 `temp`;缺省按平台约定。 |
|
|
27
26
|
|
|
28
27
|
|
|
29
|
-
### 1.2 `networkMocks[]` 每项
|
|
30
|
-
|
|
31
|
-
| 字段 | 必填 | 说明 |
|
|
32
|
-
| -------------- | --- | -------------------------------------- |
|
|
33
|
-
| `urlPattern` | 是 | 请求 URL 的子串匹配(`includes` 语义)。首个匹配的规则生效。 |
|
|
34
|
-
| `method` | 否 | HTTP 方法:`GET`、`POST`、`PUT`、`DELETE` 或 `PATCH`。不填则匹配任意方法。 |
|
|
35
|
-
| `responses` | 是 | 非空响应序列数组,按顺序匹配第一项满足条件的响应。 |
|
|
36
|
-
| `description` | 否 | 给人看的说明。 |
|
|
37
|
-
|
|
38
|
-
#### `responses[]` 每项
|
|
39
|
-
|
|
40
|
-
| 字段 | 必填 | 说明 |
|
|
41
|
-
| ------------------ | --- | -------------------------------------- |
|
|
42
|
-
| `body` | 是 | 响应体字符串(通常为 JSON)。上限 1MB。 |
|
|
43
|
-
| `status` | 否 | HTTP 状态码,默认 200。范围 100~599。 |
|
|
44
|
-
| `callIndex` | 否 | 仅第 n 次调用时匹配(1-based)。用于实现状态性 mock:首次返回错误,二次返回成功。 |
|
|
45
|
-
| `requestBodyMatch` | 否 | 键值对映射,必须在请求体的 JSON 中存在且值匹配才会命中。 |
|
|
46
|
-
| `headers` | 否 | 额外的响应头。Content-Type 默认为 `application/json; charset=utf-8`。 |
|
|
47
|
-
| `delay` | 否 | 响应延迟毫秒数。范围 0~60000。 |
|
|
48
|
-
|
|
49
|
-
#### 匹配规则
|
|
50
|
-
|
|
51
|
-
1. 遍历 `networkMocks`,找到第一条 `urlPattern` 是请求 URL 子串的规则。
|
|
52
|
-
2. 递增该规则的调用计数器。
|
|
53
|
-
3. 按顺序遍历 `responses`,找到第一项满足 `callIndex` 和 `requestBodyMatch` 的响应并返回。
|
|
54
|
-
4. 若无匹配响应或请求不匹配任何规则,透明转发到真实服务器。
|
|
55
|
-
|
|
56
|
-
#### 示例
|
|
57
|
-
|
|
58
|
-
```json
|
|
59
|
-
{
|
|
60
|
-
"networkMocks": [
|
|
61
|
-
{
|
|
62
|
-
"urlPattern": "getMafangRosterNewFlowSwitch",
|
|
63
|
-
"description": "启用码放新流程",
|
|
64
|
-
"responses": [
|
|
65
|
-
{
|
|
66
|
-
"status": 200,
|
|
67
|
-
"body": "{\"code\":200,\"data\":{\"newFlowEnabled\":true},\"subcode\":200}"
|
|
68
|
-
}
|
|
69
|
-
]
|
|
70
|
-
},
|
|
71
|
-
{
|
|
72
|
-
"urlPattern": "copyWorkScheduleGroup",
|
|
73
|
-
"method": "POST",
|
|
74
|
-
"description": "日复制先弹确认再成功",
|
|
75
|
-
"responses": [
|
|
76
|
-
{
|
|
77
|
-
"callIndex": 1,
|
|
78
|
-
"status": 200,
|
|
79
|
-
"body": "{\"code\":\"WORK_SCHEDULE_PARTITION_MAFANG_COPY_SKIP_CONFIRM\",\"data\":{\"blockedCount\":2}}"
|
|
80
|
-
},
|
|
81
|
-
{
|
|
82
|
-
"callIndex": 2,
|
|
83
|
-
"requestBodyMatch": {"mafangSkipConfirmed": "true"},
|
|
84
|
-
"status": 200,
|
|
85
|
-
"body": "{\"code\":200,\"data\":{\"flag\":true}}"
|
|
86
|
-
}
|
|
87
|
-
]
|
|
88
|
-
}
|
|
89
|
-
]
|
|
90
|
-
}
|
|
91
|
-
```
|
|
92
|
-
|
|
93
28
|
---
|
|
94
29
|
|
|
95
30
|
## 2. 步骤类型与设计原则
|
|
@@ -166,9 +101,6 @@
|
|
|
166
101
|
## 4. 易错校验(生成后自查)
|
|
167
102
|
|
|
168
103
|
- `version !== 2` → 失败。
|
|
169
|
-
- `networkMocks` 超过 50 条规则或单条规则的 `responses` 超过 50 项 → 失败。
|
|
170
|
-
- `networkMocks[].urlPattern` 为空或过长(>1000)→ 失败。
|
|
171
|
-
- `networkMocks[].responses` 为空数组 → 失败。
|
|
172
104
|
- `if` / `ifDeviceType` 的 `thenSteps` 为空,或 `whileLoop` / `forLoop` 的 `bodySteps` 为空 → 失败。
|
|
173
105
|
- `sleep.ms`、`whileLoop.maxIterations`、`forLoop.count` 超出上表范围 → 失败。
|
|
174
106
|
- `callScript.targetTestCaseId` 非 24 位 hex,或 `scopeId` 不符合 `sub`+12hex → 失败。
|