preflite 1.1.1 → 1.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +113 -62
- package/dist/infrastructure/midscene/MidsceneRuntimeReal.js +41 -3
- package/dist/mcp/agentHttpClient.js +19 -6
- package/dist/mcp/exploration/tools-intelligent.js +50 -7
- package/dist/mcp/exploration/tools-session.js +1 -0
- package/dist/mcp/network-mocks/NetworkMockServer.js +579 -0
- package/dist/mcp/network-mocks/NetworkMockService.js +156 -0
- package/dist/mcp/network-mocks/device-proxy.js +31 -0
- package/dist/mcp/network-mocks/index.js +3 -0
- package/dist/mcp/network-mocks/types.js +1 -0
- package/dist/mcp/runManager.js +4 -1
- package/dist/mcp/server.js +229 -23
- package/dist/mcp/setup.js +135 -5
- package/dist/mcp/visual-flow/types.js +1 -1
- package/dist/mcp/visual-flow/validate.js +171 -0
- package/docs/visual-flow-ir-llm.md +70 -2
- package/package.json +1 -1
package/dist/mcp/setup.js
CHANGED
|
@@ -20,6 +20,8 @@ export async function setupLocalMcp(options) {
|
|
|
20
20
|
const skillPath = join(options.projectRoot, ".preflight", "skills", "preflight.md");
|
|
21
21
|
const codexSkillPath = join(homedir(), ".codex", "skills", "preflight", "SKILL.md");
|
|
22
22
|
const agentsSkillPath = join(homedir(), ".agents", "skills", "preflight", "SKILL.md");
|
|
23
|
+
const claudeSkillPath = join(homedir(), ".claude", "skills", "preflight", "SKILL.md");
|
|
24
|
+
const androidEmulatorSetupSkillPath = join(homedir(), ".claude", "skills", "android-emulator-setup", "SKILL.md");
|
|
23
25
|
const userConfigExamplePath = join(homedir(), ".preflight", "config.example.json");
|
|
24
26
|
const codexConfigPath = join(homedir(), ".codex", "config.toml");
|
|
25
27
|
await writeCursorMcpConfig(cursorConfigPath, options.projectRoot, agentBaseUrl, livePort, isRuntime, runtimeRoot);
|
|
@@ -27,9 +29,11 @@ export async function setupLocalMcp(options) {
|
|
|
27
29
|
await writeTextFile(skillPath, skillText());
|
|
28
30
|
await writeTextFile(codexSkillPath, skillText());
|
|
29
31
|
await writeTextFile(agentsSkillPath, skillText());
|
|
32
|
+
await writeTextFile(claudeSkillPath, skillText());
|
|
33
|
+
await writeTextFile(androidEmulatorSetupSkillPath, androidEmulatorSetupSkillText());
|
|
30
34
|
await writeTextFile(userConfigExamplePath, userConfigExampleText());
|
|
31
35
|
await upsertCodexMcpConfig(codexConfigPath, options.projectRoot, agentBaseUrl, livePort, isRuntime, runtimeRoot);
|
|
32
|
-
return { cursorConfigPath, cursorRulePath, codexConfigPath, skillPath, codexSkillPath, agentsSkillPath, runtimeRoot, userConfigExamplePath };
|
|
36
|
+
return { cursorConfigPath, cursorRulePath, codexConfigPath, skillPath, codexSkillPath, agentsSkillPath, claudeSkillPath, androidEmulatorSetupSkillPath, runtimeRoot, userConfigExamplePath };
|
|
33
37
|
}
|
|
34
38
|
async function writeCursorMcpConfig(path, projectRoot, agentBaseUrl, livePort, isRuntime, runtimeRoot) {
|
|
35
39
|
let existing = {};
|
|
@@ -129,8 +133,8 @@ alwaysApply: false
|
|
|
129
133
|
7. 调用 \`validate_visual_flow\`。如果校验失败,按 message 修正 visualFlow 后再次校验。
|
|
130
134
|
8. 生成最小测试用例:先覆盖改动点,再补必要回归。
|
|
131
135
|
9. 如用户给了 app 包,先调用 \`install_app\`。
|
|
132
|
-
10. 调用 \`run_flow
|
|
133
|
-
11. 执行中调用 \`watch_run\`
|
|
136
|
+
10. 调用 \`run_flow\` 时必须设置 \`waitForCompletion: false\`,让工具立即返回 runId/liveUrl;不要用 \`waitForCompletion: true\` 等待完整流程,避免 MCP 60 秒传输超时。
|
|
137
|
+
11. 执行中调用 \`watch_run\` 观察状态;工具默认等待最多 45 秒,run 一旦成功或失败会提前返回。失败时先判断是环境/设备、IR 用例步骤、agent runtime 还是真实业务问题。
|
|
134
138
|
12. 如果失败原因是 IR 步骤不合理,只能调整 visualFlow 后重跑;不要读取或手写 Midscene TS 脚本。若是 Preflight 编译器/runtime 内部错误,停止并报告为工具缺陷。
|
|
135
139
|
13. 最终调用 \`save_report\`,并在回复中给出测试报告、report/liveUrl 和 PASS/FAIL 结论。
|
|
136
140
|
`;
|
|
@@ -165,8 +169,8 @@ Workflow:
|
|
|
165
169
|
6. Read \`get_visual_flow_ir_rules\` and generate visualFlow JSON, not raw Midscene TypeScript.
|
|
166
170
|
7. Validate with \`validate_visual_flow\`; fix the JSON until validation passes.
|
|
167
171
|
8. Install the app when an app package path is provided.
|
|
168
|
-
9. Start the run with \`run_flow\`
|
|
169
|
-
10. Poll with \`watch_run
|
|
172
|
+
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.
|
|
173
|
+
10. Poll with \`watch_run\`; it waits up to 45s by default and returns early when the run succeeds or fails.
|
|
170
174
|
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.
|
|
171
175
|
12. Save report with \`save_report\`.
|
|
172
176
|
|
|
@@ -187,3 +191,129 @@ function userConfigExampleText() {
|
|
|
187
191
|
}
|
|
188
192
|
`;
|
|
189
193
|
}
|
|
194
|
+
function androidEmulatorSetupSkillText() {
|
|
195
|
+
return `---
|
|
196
|
+
name: android-emulator-setup
|
|
197
|
+
description: Use when setting up an Android emulator from scratch, installing Android SDK command-line tools, creating AVDs, or when emulator/adb/avdmanager commands are not found. Also use when the user asks to install, configure, or bootstrap an Android development environment.
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
# Android Emulator Setup
|
|
201
|
+
|
|
202
|
+
Install the Android SDK command-line tools, create AVDs, and start emulators — the prerequisite step before Argent or Preflight can interact with a device.
|
|
203
|
+
|
|
204
|
+
## Quick Detection
|
|
205
|
+
|
|
206
|
+
Run these before doing any work. Skip sections whose tools already work.
|
|
207
|
+
|
|
208
|
+
\`\`\`bash
|
|
209
|
+
adb --version 2>/dev/null && echo "ADB OK" || echo "ADB MISSING"
|
|
210
|
+
emulator -list-avds 2>/dev/null && echo "EMULATOR OK" || echo "EMULATOR MISSING"
|
|
211
|
+
echo "ANDROID_HOME=\${ANDROID_HOME:-UNSET}"
|
|
212
|
+
\`\`\`
|
|
213
|
+
|
|
214
|
+
| Result | Action |
|
|
215
|
+
|--------|--------|
|
|
216
|
+
| ADB MISSING | Start from § Install SDK |
|
|
217
|
+
| EMULATOR MISSING, ADB OK | Jump to § Install SDK Components |
|
|
218
|
+
| Both OK, no AVDs | Jump to § Create AVD |
|
|
219
|
+
| Both OK, AVD exists | Jump to § Start Emulator |
|
|
220
|
+
|
|
221
|
+
## Install SDK
|
|
222
|
+
|
|
223
|
+
**macOS with Homebrew (recommended):**
|
|
224
|
+
|
|
225
|
+
\`\`\`bash
|
|
226
|
+
brew install android-commandlinetools
|
|
227
|
+
\`\`\`
|
|
228
|
+
|
|
229
|
+
**Manual install (macOS/Linux):**
|
|
230
|
+
|
|
231
|
+
\`\`\`bash
|
|
232
|
+
# 1. Download from https://developer.android.com/studio#command-line-tools-only
|
|
233
|
+
# 2. Unzip to the correct path:
|
|
234
|
+
mkdir -p ~/Library/Android/sdk/cmdline-tools/latest
|
|
235
|
+
cd ~/Library/Android/sdk/cmdline-tools/latest
|
|
236
|
+
unzip ~/Downloads/commandlinetools-mac-*.zip
|
|
237
|
+
\`\`\`
|
|
238
|
+
|
|
239
|
+
## Configure Environment
|
|
240
|
+
|
|
241
|
+
Add to \`~/.zshrc\` (or \`~/.bashrc\`):
|
|
242
|
+
|
|
243
|
+
\`\`\`bash
|
|
244
|
+
# With brew:
|
|
245
|
+
export ANDROID_HOME=/opt/homebrew/share/android-commandlinetools
|
|
246
|
+
# With manual install:
|
|
247
|
+
# export ANDROID_HOME=$HOME/Library/Android/sdk
|
|
248
|
+
|
|
249
|
+
export PATH=$ANDROID_HOME/emulator:$ANDROID_HOME/platform-tools:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH
|
|
250
|
+
\`\`\`
|
|
251
|
+
|
|
252
|
+
Then \`source ~/.zshrc\` and verify:
|
|
253
|
+
|
|
254
|
+
\`\`\`bash
|
|
255
|
+
sdkmanager --version # should print a version
|
|
256
|
+
\`\`\`
|
|
257
|
+
|
|
258
|
+
## Install SDK Components
|
|
259
|
+
|
|
260
|
+
\`\`\`bash
|
|
261
|
+
# Accept licenses (non-interactive)
|
|
262
|
+
yes | sdkmanager --licenses
|
|
263
|
+
|
|
264
|
+
# Install core components
|
|
265
|
+
sdkmanager "platform-tools" "emulator" "platforms;android-34"
|
|
266
|
+
|
|
267
|
+
# Install a system image (ARM Mac → arm64-v8a, Intel → x86_64)
|
|
268
|
+
sdkmanager "system-images;android-34;google_apis;arm64-v8a"
|
|
269
|
+
\`\`\`
|
|
270
|
+
|
|
271
|
+
> **Pick the right system image:** \`sdkmanager --list | grep system-images\` to see available images. API 34 is a safe default; adjust based on the app's \`minSdk\`.
|
|
272
|
+
|
|
273
|
+
## Create AVD
|
|
274
|
+
|
|
275
|
+
\`\`\`bash
|
|
276
|
+
avdmanager create avd \\
|
|
277
|
+
-n test_device \\
|
|
278
|
+
-k "system-images;android-34;google_apis;arm64-v8a" \\
|
|
279
|
+
-d "pixel_6"
|
|
280
|
+
\`\`\`
|
|
281
|
+
|
|
282
|
+
Verify: \`emulator -list-avds\` should show \`test_device\`.
|
|
283
|
+
|
|
284
|
+
## Start Emulator
|
|
285
|
+
|
|
286
|
+
\`\`\`bash
|
|
287
|
+
emulator -avd test_device &
|
|
288
|
+
\`\`\`
|
|
289
|
+
|
|
290
|
+
Wait for boot (1-2 min), then verify:
|
|
291
|
+
|
|
292
|
+
\`\`\`bash
|
|
293
|
+
adb devices
|
|
294
|
+
# List of devices attached
|
|
295
|
+
# emulator-5554 device
|
|
296
|
+
\`\`\`
|
|
297
|
+
|
|
298
|
+
## Common Issues
|
|
299
|
+
|
|
300
|
+
| Symptom | Fix |
|
|
301
|
+
|---------|-----|
|
|
302
|
+
| \`sdkmanager: command not found\` | \`cmdline-tools/latest/bin\` not in PATH; check § Configure Environment |
|
|
303
|
+
| \`avdmanager: command not found\` | Same as above — part of cmdline-tools |
|
|
304
|
+
| Emulator black screen on Apple Silicon | Ensure system image is \`arm64-v8a\`, not \`x86_64\` |
|
|
305
|
+
| \`adb devices\` shows \`unauthorized\` | Wait 30s for emulator to finish booting |
|
|
306
|
+
| \`The emulator process has terminated\` | Try \`emulator -avd test_device -wipe-data\` |
|
|
307
|
+
| \`ANDROID_HOME\` not set after brew install | Brew's path is \`/opt/homebrew/share/android-commandlinetools\` |
|
|
308
|
+
| \`PANIC: Missing emulator engine\` | Run \`sdkmanager "emulator"\` to install the emulator binary |
|
|
309
|
+
| No space left on device | System images are ~1-2 GB; \`sdkmanager --uninstall\` unused images |
|
|
310
|
+
|
|
311
|
+
## Post-Setup
|
|
312
|
+
|
|
313
|
+
Once the emulator is running, it's ready for:
|
|
314
|
+
- **Argent:** \`argent-android-emulator-setup\` skill (boot, connect, interact)
|
|
315
|
+
- **Preflight:** \`preflight\` skill (visual-flow tests via MCP)
|
|
316
|
+
|
|
317
|
+
To kill: \`adb -s emulator-5554 emu kill\`
|
|
318
|
+
`;
|
|
319
|
+
}
|
|
@@ -61,6 +61,156 @@ 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
|
+
}
|
|
64
214
|
function isNonEmptyString(v) {
|
|
65
215
|
return typeof v === 'string' && v.trim().length > 0;
|
|
66
216
|
}
|
|
@@ -321,6 +471,23 @@ function parseStep(raw, path) {
|
|
|
321
471
|
},
|
|
322
472
|
};
|
|
323
473
|
}
|
|
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
|
+
}
|
|
324
491
|
case 'callScript': {
|
|
325
492
|
if (!isNonEmptyString(o.targetTestCaseId)) {
|
|
326
493
|
return { ok: false, message: `${path}.targetTestCaseId 必填` };
|
|
@@ -591,6 +758,9 @@ export function tryParseVisualFlow(raw) {
|
|
|
591
758
|
const scriptVarsParsed = parseScriptVars(o.scriptVars);
|
|
592
759
|
if (!scriptVarsParsed.ok)
|
|
593
760
|
return scriptVarsParsed;
|
|
761
|
+
const networkMocksParsed = parseNetworkMocks(o.networkMocks);
|
|
762
|
+
if (!networkMocksParsed.ok)
|
|
763
|
+
return networkMocksParsed;
|
|
594
764
|
const steps = [];
|
|
595
765
|
for (let i = 0; i < o.steps.length; i++) {
|
|
596
766
|
const r = parseStep(o.steps[i], `steps[${i}]`);
|
|
@@ -612,6 +782,7 @@ export function tryParseVisualFlow(raw) {
|
|
|
612
782
|
version: VISUAL_FLOW_VERSION,
|
|
613
783
|
...(scriptVarsParsed.value.length ? { scriptVars: scriptVarsParsed.value } : {}),
|
|
614
784
|
steps,
|
|
785
|
+
...(networkMocksParsed.value.length ? { networkMocks: networkMocksParsed.value } : {}),
|
|
615
786
|
},
|
|
616
787
|
};
|
|
617
788
|
}
|
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
|
|
10
10
|
| 字段 | 必填 | 说明 |
|
|
11
11
|
| ------------ | --- | ------------------------------------ |
|
|
12
|
-
| `version` | 是 | 固定为数字 `
|
|
12
|
+
| `version` | 是 | 固定为数字 `2`,其它值保存失败。 |
|
|
13
13
|
| `scriptVars` | 否 | 执行前由人填的变量声明数组;步骤文案里用 `{{变量名}}` 引用。 |
|
|
14
|
+
| `networkMocks` | 否 | 网络 mock 规则数组,在测试开始前启动代理并配置到设备。每条规则描述一个 API 的 URL 匹配与 mock 响应序列。 |
|
|
14
15
|
| `steps` | 是 | 顶层步骤数组,按顺序执行;**展开后总条数 ≤ 500**(含子步骤)。 |
|
|
15
16
|
|
|
16
17
|
|
|
@@ -25,6 +26,70 @@
|
|
|
25
26
|
| `scope` | 否 | `global`、`local` 或 `temp`;缺省按平台约定。 |
|
|
26
27
|
|
|
27
28
|
|
|
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
|
+
|
|
28
93
|
---
|
|
29
94
|
|
|
30
95
|
## 2. 步骤类型与设计原则
|
|
@@ -100,7 +165,10 @@
|
|
|
100
165
|
|
|
101
166
|
## 4. 易错校验(生成后自查)
|
|
102
167
|
|
|
103
|
-
- `version !==
|
|
168
|
+
- `version !== 2` → 失败。
|
|
169
|
+
- `networkMocks` 超过 50 条规则或单条规则的 `responses` 超过 50 项 → 失败。
|
|
170
|
+
- `networkMocks[].urlPattern` 为空或过长(>1000)→ 失败。
|
|
171
|
+
- `networkMocks[].responses` 为空数组 → 失败。
|
|
104
172
|
- `if` / `ifDeviceType` 的 `thenSteps` 为空,或 `whileLoop` / `forLoop` 的 `bodySteps` 为空 → 失败。
|
|
105
173
|
- `sleep.ms`、`whileLoop.maxIterations`、`forLoop.count` 超出上表范围 → 失败。
|
|
106
174
|
- `callScript.targetTestCaseId` 非 24 位 hex,或 `scopeId` 不符合 `sub`+12hex → 失败。
|