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
|
@@ -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
|
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# 可视化编排 IR(供模型生成 JSON)
|
|
2
|
+
|
|
3
|
+
本文档供 **对话编排 / 自动生成 `visualFlow`** 的系统提示使用:只描述 **JSON 形状、字段含义与校验约束**,不含代码生成、HTTP 接口等平台实现细节。人类可读的平台语义、codegen 对照与落库说明见 `**VISUAL_FLOW_IR.md`**;字段以 `**backend/src/contexts/visual-flow/types.ts`**、校验以 `**validate.ts`** 为准。
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. 根对象 `VisualFlowDocument`
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
| 字段 | 必填 | 说明 |
|
|
11
|
+
| ------------ | --- | ------------------------------------ |
|
|
12
|
+
| `version` | 是 | 固定为数字 `2`,其它值保存失败。 |
|
|
13
|
+
| `scriptVars` | 否 | 执行前由人填的变量声明数组;步骤文案里用 `{{变量名}}` 引用。 |
|
|
14
|
+
| `networkMocks` | 否 | 网络 mock 规则数组,在测试开始前启动代理并配置到设备。每条规则描述一个 API 的 URL 匹配与 mock 响应序列。 |
|
|
15
|
+
| `steps` | 是 | 顶层步骤数组,按顺序执行;**展开后总条数 ≤ 500**(含子步骤)。 |
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### 1.1 `scriptVars[]` 每项
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
| 字段 | 必填 | 选填 / 备注 |
|
|
22
|
+
| -------------- | --- | ---------------------------------- |
|
|
23
|
+
| `name` | 是 | — |
|
|
24
|
+
| `description` | 否 | 给人看的说明。 |
|
|
25
|
+
| `defaultValue` | 否 | 默认填值,字符串。 |
|
|
26
|
+
| `scope` | 否 | `global`、`local` 或 `temp`;缺省按平台约定。 |
|
|
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
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## 2. 步骤类型与设计原则
|
|
96
|
+
|
|
97
|
+
### 核心原则:所有 UI 交互都使用 `aiAct`
|
|
98
|
+
|
|
99
|
+
- `aiAct` 是**唯一**的 UI 交互步骤类型。
|
|
100
|
+
- 一个 `aiAct` 覆盖**一个完整的用户意图**,由视觉模型自行规划具体的点击、长按、滑动、输入等动作。
|
|
101
|
+
- 例如,"长按第一个订单,在弹出菜单中选择「删除」,如果有确认弹窗则确认删除"——这是一个完整的意图,应当用**一个** `aiAct` 表达。
|
|
102
|
+
|
|
103
|
+
### 其他步骤类型
|
|
104
|
+
|
|
105
|
+
除 `aiAct` 外的步骤类型分为以下几类,各自有明确的非交互用途:
|
|
106
|
+
|
|
107
|
+
| 类别 | 类型 | 说明 |
|
|
108
|
+
|------|------|------|
|
|
109
|
+
| **应用管理** | `launch` / `closeApp` / `installApp` / `uninstallApp` | 启动、关闭、安装、卸载应用 |
|
|
110
|
+
| **等待** | `sleep` | 固定延时等待(页面跳转、动画、列表更新等) |
|
|
111
|
+
| **断言** | `assert` | 视觉断言,验证 UI 状态是否符合预期 |
|
|
112
|
+
| **上下文** | `setAIActContext` | 设置后续 `aiAct` 的突发情况处理策略(如权限弹窗、营销弹窗) |
|
|
113
|
+
| **报告** | `recordToReport` | 向测试报告写入标题和内容 |
|
|
114
|
+
| **变量** | `setVar` / `assignVar` / `transformVar` | 从屏幕读取数据、赋值或转换变量 |
|
|
115
|
+
| **流程控制** | `if` / `ifDeviceType` / `whileLoop` / `forLoop` | 条件判断、按设备类型分支、循环 |
|
|
116
|
+
| **脚本调用** | `callScript` | 调用其他测试用例 |
|
|
117
|
+
|
|
118
|
+
### 每条步骤的公共规则
|
|
119
|
+
|
|
120
|
+
- 每个步骤对象 **必须有** `type`(字符串)。
|
|
121
|
+
- `**launch` / `closeApp` / `uninstallApp`**:使用包名类字段(`packageName` 或 `bundleId`)表达应用目标。
|
|
122
|
+
- `aiAct` / `assert` / `setAIActContext`:用 `prompt` 写 **短而可执行** 的界面描述(可见文案、区域)。
|
|
123
|
+
- 突发情况处理使用 `setAIActContext`:例如 `"遇到权限弹窗请同意,营销弹窗请拒绝"`。该上下文会带给后续 act 操作,由视觉模型自行处理弹窗等临时干扰。
|
|
124
|
+
- `prompt` 保持简洁明确,只写必要信息。**`assert` 的 `prompt` 只需写明判断逻辑**,已有变量用 `{{}}` 插值即可,不要重复上下文。好:`"屏幕上的登录状态文本是{{expectedStatus}}"`;差:一段描述"看当前屏幕……由于……说明……"的长句。
|
|
125
|
+
- 页面跳转、启动、刷新、动画结束、列表更新等"页面稳定"场景,使用 `sleep` 固定等待(如 2000~5000ms)。
|
|
126
|
+
- `assert` 放在关键校验点,通常是本次改动点或必要回归点;普通步骤执行失败会自然阻塞流程。
|
|
127
|
+
- `**if` / `ifDeviceType`**:`thenSteps` 非空数组;`**elseSteps`** 为 **选填**:可整段省略、或 `[]`、或 `null`(均表示「无 else 分支」);若写出 `elseSteps` 且为非空数组,则其中为正常子步骤。
|
|
128
|
+
- `**whileLoop` / `forLoop`**:`bodySteps` **非空数组**(无选填分支数组名)。
|
|
129
|
+
- **同一条「线性执行路径」上**:`setVar` 的 `name` **不得重复**(含分支内继承规则,与校验一致)。
|
|
130
|
+
- `**setVar.name` / `assignVar.name` / `transformVar.name`**:支持 Unicode 字母(含中文)/数字/`_`/`$`,且首字符为非数字;须匹配 `^[$_\\p{L}][\\p{L}\\p{N}_$]*$`(`/u` 语义),长度 1~64,并避开运行时保留名(如 `agent`、`page`、`console`、`process`、`sleep` 等,详见校验)。
|
|
131
|
+
- 字符串字段中引用已声明变量时,**必须**使用 `**{{变量名}}`**、`{{变量名[0]}}`、`{{变量名.1}}`、`{{变量名.length}}` 等插值形式;其中 `.length` 用于读取数组长度(非数组按 `0` 处理)。
|
|
132
|
+
- 已声明变量在字符串字段中统一使用插值形式;例如已声明 `timeBefore`、`timeAfter` 时,写成 `"{{timeBefore}}和{{timeAfter}}不同"`。
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## 3. 步骤类型:必填、选填与约束
|
|
137
|
+
|
|
138
|
+
列 **选填**:除「—」外,写出可出现的字段名;未列出者表示不宜随意加未知键(以 `types.ts` 为准)。
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
| `type` | 必填字段 | 选填字段 | 说明与取值约束 |
|
|
142
|
+
| ---------------------------------------------------------------- | ----------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
143
|
+
| `launch` | `packageName` | — | Android 包名 / iOS bundle id / 鸿蒙 bundle name。 |
|
|
144
|
+
| `installApp` | `appRef` | — | 本地路径、`file://` 或 `http(s)` 安装包地址。 |
|
|
145
|
+
| `uninstallApp` | `bundleId` | — | 要卸载的应用标识。 |
|
|
146
|
+
| `closeApp` | `packageName` | — | 关闭正在运行的应用。 |
|
|
147
|
+
| `setAIActContext` | `prompt` | — | 设置后续 aiAct 操作的上下文,适合声明权限弹窗、营销弹窗、升级提示等突发情况处理策略。 |
|
|
148
|
+
| `recordToReport` | `title`, `content` | — | 二者均为字符串,**允许空串**;有长度上限(校验)。 |
|
|
149
|
+
| `assert` | `prompt` | — | 断言命题。 |
|
|
150
|
+
| `sleep` | `ms` | — | 整数 **0~3600000**(毫秒);页面稳定、动画结束、跳转缓冲使用本步骤。 |
|
|
151
|
+
| `aiAct` | `prompt` | — | **唯一的 UI 交互步骤类型**。描述完整的用户意图、关键约束和完成条件,让视觉模型自行规划具体操作。 |
|
|
152
|
+
| `if` | `conditionPrompt`, `thenSteps` | `**elseSteps`** | 条件为真执行 `thenSteps`;无 else 则省略 `elseSteps` / `[]` / `null`。 |
|
|
153
|
+
| `ifDeviceType` | `interfaceType`, `thenSteps` | `**elseSteps`** | `interfaceType` 仅 `android`、`ios`、`harmony`;无 else 同上。 |
|
|
154
|
+
| `whileLoop` | `conditionPrompt`, `maxIterations`, `bodySteps` | — | `maxIterations`:1~1000 整数。 |
|
|
155
|
+
| `forLoop` | `count`, `bodySteps` | — | `count`:1~500 整数。 |
|
|
156
|
+
| `setVar` | `name`, `method`, `expression` | — | `method` 仅 `aiQuery`、`aiAsk`、`aiBoolean`、`aiNumber`、`aiString`。`aiQuery` 的 `expression` 可为自然语言或整段 JSON 抽取需求;其余 method 的 `expression` 为对应 API 的 prompt。 |
|
|
157
|
+
| `assignVar` | `name`, `value` | — | 字面值或带 `{{}}` 的模板;**不用**于从屏读数(读屏用 `setVar`)。 |
|
|
158
|
+
| `transformVar` | `name`, `rule` | `source`、`start`、`end`、`jsonPath`、`pattern`、`replacement` | `rule`:`onlyNumber`、`cut`、`jsonPath`、`replace`、`handleAmount`;按规则选用上述选填字段。 |
|
|
159
|
+
| `callScript` | `targetTestCaseId`, `scopeId`, `varBindings` | `**targetName`** | `targetTestCaseId`:24 位 hex;`scopeId`:`sub` + 12 位小写 hex;`varBindings` 可为 `{}`;`targetName` 仅展示用。 |
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
步骤 `type` 使用上表枚举;生成后先调用 `validate_visual_flow`,按返回信息修正结构。
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## 4. 易错校验(生成后自查)
|
|
167
|
+
|
|
168
|
+
- `version !== 2` → 失败。
|
|
169
|
+
- `networkMocks` 超过 50 条规则或单条规则的 `responses` 超过 50 项 → 失败。
|
|
170
|
+
- `networkMocks[].urlPattern` 为空或过长(>1000)→ 失败。
|
|
171
|
+
- `networkMocks[].responses` 为空数组 → 失败。
|
|
172
|
+
- `if` / `ifDeviceType` 的 `thenSteps` 为空,或 `whileLoop` / `forLoop` 的 `bodySteps` 为空 → 失败。
|
|
173
|
+
- `sleep.ms`、`whileLoop.maxIterations`、`forLoop.count` 超出上表范围 → 失败。
|
|
174
|
+
- `callScript.targetTestCaseId` 非 24 位 hex,或 `scopeId` 不符合 `sub`+12hex → 失败。
|
|
175
|
+
- 同一路径重复 `setVar.name` → 失败。
|
|
176
|
+
- 声明过的变量在 `prompt` / `value` / `expression` / `varBindings` 等字符串字段中使用 `{{}}` 插值。
|
|
177
|
+
- 页面稳定等待使用 `sleep`;关键结果检查使用 `assert`。
|
|
178
|
+
- `assert` 聚焦关键校验点,通常是本次改动点或必要回归点。
|
|
179
|
+
- **所有 UI 交互必须使用 `aiAct`**。
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## 5. 完整示例(含 `scriptVars`、条件分支、`assert`)
|
|
184
|
+
|
|
185
|
+
下列为**可直接通过结构校验**的示意:请把包名、文案改成目标应用真实情况。
|
|
186
|
+
|
|
187
|
+
```json
|
|
188
|
+
{
|
|
189
|
+
"version": 1,
|
|
190
|
+
"scriptVars": [
|
|
191
|
+
{ "name": "phone", "description": "登录手机号", "defaultValue": "" }
|
|
192
|
+
],
|
|
193
|
+
"steps": [
|
|
194
|
+
{ "type": "launch", "packageName": "com.example.app" },
|
|
195
|
+
{ "type": "setAIActContext", "prompt": "遇到权限弹窗请同意,营销弹窗请拒绝" },
|
|
196
|
+
{ "type": "sleep", "ms": 3000 },
|
|
197
|
+
{
|
|
198
|
+
"type": "if",
|
|
199
|
+
"conditionPrompt": "当前是否已在登录页(能看到手机号输入框)",
|
|
200
|
+
"thenSteps": [
|
|
201
|
+
{
|
|
202
|
+
"type": "aiAct",
|
|
203
|
+
"prompt": "在手机号输入框中输入{{phone}},然后点击「获取验证码」按钮"
|
|
204
|
+
},
|
|
205
|
+
{ "type": "sleep", "ms": 3000 },
|
|
206
|
+
{ "type": "assert", "prompt": "出现短信验证码输入框或倒计时提示" }
|
|
207
|
+
],
|
|
208
|
+
"elseSteps": [
|
|
209
|
+
{ "type": "aiAct", "prompt": "点击底部或顶部的「我的」或个人中心入口" }
|
|
210
|
+
]
|
|
211
|
+
},
|
|
212
|
+
{ "type": "assert", "prompt": "页面显示已登录态(头像、昵称或「退出登录」其一)" },
|
|
213
|
+
{ "type": "sleep", "ms": 500 },
|
|
214
|
+
{ "type": "closeApp", "packageName": "com.example.app" }
|
|
215
|
+
]
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
**维护约定**:新增或变更步骤类型时,须同步 `**types.ts`** / `**validate.ts`** / `**VISUAL_FLOW_IR_LLM.md`** / `**VISUAL_FLOW_IR.md`**,保持模型提示与保存校验一致。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "preflite",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.3",
|
|
4
4
|
"description": "Preflight — Local mobile AI testing via MCP. AI-assisted testing on real Android/iOS/Harmony devices.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"dist/",
|
|
12
|
+
"docs/visual-flow-ir-llm.md",
|
|
13
|
+
"scripts/",
|
|
12
14
|
"README.md",
|
|
13
15
|
"LICENSE"
|
|
14
16
|
],
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# shellcheck shell=bash
|
|
2
|
+
# Sourced by run-midscene-task.sh: load nvm and run `nvm use` in the repo root.
|
|
3
|
+
# No side effects: failure does not exit; caller decides whether to error on insufficient Node version.
|
|
4
|
+
|
|
5
|
+
_try_source_nvm() {
|
|
6
|
+
if [[ -n "${NVM_DIR:-}" && -s "${NVM_DIR}/nvm.sh" ]]; then
|
|
7
|
+
# shellcheck disable=SC1090
|
|
8
|
+
source "${NVM_DIR}/nvm.sh"
|
|
9
|
+
return 0
|
|
10
|
+
fi
|
|
11
|
+
local d
|
|
12
|
+
for d in "${HOME}/.nvm" "/usr/local/opt/nvm"; do
|
|
13
|
+
if [[ -s "${d}/nvm.sh" ]]; then
|
|
14
|
+
export NVM_DIR="${d}"
|
|
15
|
+
# shellcheck disable=SC1090
|
|
16
|
+
source "${d}/nvm.sh"
|
|
17
|
+
return 0
|
|
18
|
+
fi
|
|
19
|
+
done
|
|
20
|
+
return 1
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
_nvm_use_repo() {
|
|
24
|
+
local repo_root="${1:?repo root}"
|
|
25
|
+
_try_source_nvm || return 1
|
|
26
|
+
local dsave="${PWD}"
|
|
27
|
+
cd "${repo_root}" || return 1
|
|
28
|
+
nvm use >/dev/null 2>&1 || true
|
|
29
|
+
cd "${dsave}" || true
|
|
30
|
+
return 0
|
|
31
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Midscene subprocess entry: prefer Node/tsx from runtime package; fall back to nvm + npx in dev mode.
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
|
|
5
|
+
TASK_SCRIPT="${1:?usage: run-midscene-task.sh <path-to-task-script.ts>}"
|
|
6
|
+
|
|
7
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
8
|
+
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
|
9
|
+
# shellcheck disable=SC1091
|
|
10
|
+
source "${SCRIPT_DIR}/nvm-use-repo.sh"
|
|
11
|
+
|
|
12
|
+
cd "${REPO_ROOT}"
|
|
13
|
+
|
|
14
|
+
NODE_BIN="${PREFLIGHT_RUNTIME_NODE:-}"
|
|
15
|
+
if [[ -z "${NODE_BIN}" && -x "${REPO_ROOT}/node/bin/node" ]]; then
|
|
16
|
+
NODE_BIN="${REPO_ROOT}/node/bin/node"
|
|
17
|
+
fi
|
|
18
|
+
if [[ -z "${NODE_BIN}" ]]; then
|
|
19
|
+
_nvm_use_repo "${REPO_ROOT}" || true
|
|
20
|
+
NODE_BIN="$(command -v node || true)"
|
|
21
|
+
fi
|
|
22
|
+
if [[ -z "${NODE_BIN}" || ! -x "${NODE_BIN}" ]]; then
|
|
23
|
+
echo "[run-midscene-task] Node not found. Install Node 20+ or use the runtime package's node/bin/node." >&2
|
|
24
|
+
exit 1
|
|
25
|
+
fi
|
|
26
|
+
|
|
27
|
+
if [[ -d "${REPO_ROOT}/node/bin" ]]; then
|
|
28
|
+
export PATH="${REPO_ROOT}/node/bin:${PATH}"
|
|
29
|
+
fi
|
|
30
|
+
|
|
31
|
+
major="$("${NODE_BIN}" -p "parseInt(process.versions.node,10)" 2>/dev/null || echo 0)"
|
|
32
|
+
if [[ "${major}" -lt 20 ]]; then
|
|
33
|
+
echo "[run-midscene-task] Node >= 20 required (current: $("${NODE_BIN}" -v 2>/dev/null || echo unknown))." >&2
|
|
34
|
+
echo "[run-midscene-task] Install Node 20+ or set PREFLIGHT_RUNTIME_NODE or MIDSCENE_RUN_COMMAND to override." >&2
|
|
35
|
+
exit 1
|
|
36
|
+
fi
|
|
37
|
+
|
|
38
|
+
TSX_CLI="${REPO_ROOT}/node_modules/tsx/dist/cli.mjs"
|
|
39
|
+
if [[ -f "${TSX_CLI}" ]]; then
|
|
40
|
+
exec "${NODE_BIN}" "${TSX_CLI}" "${TASK_SCRIPT}"
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
exec npx tsx "${TASK_SCRIPT}"
|