@szc-ft/mcp-szcd-client 0.27.4 → 0.28.1
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/lib/ant-adapter.js +716 -0
- package/lib/browser-engine.js +471 -0
- package/lib/expect.js +392 -0
- package/lib/test-plan.js +48 -0
- package/local-browser-executor.js +70 -1
- package/opencode-extension/skills/local-browser-test/SKILL.md +221 -15
- package/package.json +1 -1
- package/qwen-extension/qwen-extension.json +1 -1
- package/qwen-extension/skills/local-browser-test/SKILL.md +221 -15
- package/standard-skill/local-browser-test/SKILL.md +221 -15
package/lib/expect.js
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 语义断言层 — Playwright expect.poll 风格
|
|
3
|
+
*
|
|
4
|
+
* 设计目标:把"15×200ms 硬循环 + ad-hoc checkElement"统一成 Promise 风格的可组合断言。
|
|
5
|
+
* 所有断言返回 { passed, reason, actual, expected, duration },可直接进测试报告。
|
|
6
|
+
*
|
|
7
|
+
* 用法:
|
|
8
|
+
* const exp = createExpect(engine);
|
|
9
|
+
* await exp.poll(() => frame.$$eval(".ant-select-item-option", els => els.length > 0));
|
|
10
|
+
* await exp.locator(frame, "text=提交").toBeVisible({ timeout: 3000 });
|
|
11
|
+
* await exp.validation(frame).toPass();
|
|
12
|
+
* await exp.url(page).toContain("/catalog/list");
|
|
13
|
+
* await exp.api(captureResult).toAllPass();
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const DEFAULT_TIMEOUT = 5000;
|
|
17
|
+
const DEFAULT_INTERVAL = 200;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 轮询直到 predicate 返回 truthy,否则超时
|
|
21
|
+
* @param {Function} predicate - 异步函数,返回值参与判定
|
|
22
|
+
* @param {object} options - { timeout, interval, message }
|
|
23
|
+
* @returns {Promise<{ passed, value, attempts, duration }>}
|
|
24
|
+
*/
|
|
25
|
+
export async function poll(predicate, options = {}) {
|
|
26
|
+
const timeout = Number.parseInt(options.timeout ?? DEFAULT_TIMEOUT, 10);
|
|
27
|
+
const interval = Number.parseInt(options.interval ?? DEFAULT_INTERVAL, 10);
|
|
28
|
+
const start = Date.now();
|
|
29
|
+
let attempts = 0;
|
|
30
|
+
let lastValue;
|
|
31
|
+
let lastError;
|
|
32
|
+
|
|
33
|
+
while (Date.now() - start < timeout) {
|
|
34
|
+
attempts += 1;
|
|
35
|
+
try {
|
|
36
|
+
lastValue = await predicate();
|
|
37
|
+
if (lastValue) {
|
|
38
|
+
return {
|
|
39
|
+
passed: true,
|
|
40
|
+
value: lastValue,
|
|
41
|
+
attempts,
|
|
42
|
+
duration: Date.now() - start,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
} catch (err) {
|
|
46
|
+
lastError = err;
|
|
47
|
+
}
|
|
48
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
passed: false,
|
|
53
|
+
value: lastValue,
|
|
54
|
+
attempts,
|
|
55
|
+
duration: Date.now() - start,
|
|
56
|
+
reason: lastError
|
|
57
|
+
? `predicate threw: ${lastError.message}`
|
|
58
|
+
: options.message || `poll timeout after ${timeout}ms (last value: ${JSON.stringify(lastValue)?.slice(0, 200)})`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 元素定位断言器:locator(frame, selector) 返回带 toXxx 的对象
|
|
64
|
+
* selector 支持 text= / role= / ~xxx / 普通 CSS
|
|
65
|
+
*/
|
|
66
|
+
export function locator(frame, selector) {
|
|
67
|
+
/**
|
|
68
|
+
* 在 frame 内查找匹配 selector 的第一个元素,返回该元素的可观测状态。
|
|
69
|
+
* 一次 evaluate 取齐所有状态,避免多次 round-trip。
|
|
70
|
+
*/
|
|
71
|
+
async function probe() {
|
|
72
|
+
return frame.evaluate((sel) => {
|
|
73
|
+
function resolveSelector(s) {
|
|
74
|
+
if (!s) return null;
|
|
75
|
+
if (s.startsWith("~")) {
|
|
76
|
+
const cls = s.slice(1).replace(/^\./, "");
|
|
77
|
+
return `[class*="${cls}"]`;
|
|
78
|
+
}
|
|
79
|
+
return s;
|
|
80
|
+
}
|
|
81
|
+
function isVisible(el) {
|
|
82
|
+
const rect = el.getBoundingClientRect();
|
|
83
|
+
const style = window.getComputedStyle(el);
|
|
84
|
+
return rect.width > 0 && rect.height > 0
|
|
85
|
+
&& style.display !== "none"
|
|
86
|
+
&& style.visibility !== "hidden"
|
|
87
|
+
&& Number(style.opacity) !== 0;
|
|
88
|
+
}
|
|
89
|
+
function isEnabled(el) {
|
|
90
|
+
return !el.disabled && el.getAttribute("aria-disabled") !== "true";
|
|
91
|
+
}
|
|
92
|
+
function findText(text) {
|
|
93
|
+
const candidates = Array.from(document.querySelectorAll(
|
|
94
|
+
"a,button,input,select,textarea,summary,span,div,label,[role],[data-testid]"
|
|
95
|
+
));
|
|
96
|
+
return candidates.find((el) => {
|
|
97
|
+
if (!isVisible(el)) return false;
|
|
98
|
+
const t = (el.innerText || el.textContent || "").trim();
|
|
99
|
+
return t.includes(text);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
function findRole(roleSpec) {
|
|
103
|
+
const match = roleSpec.match(/^([^\[]+)(?:\[name="([^"]+)"\])?/);
|
|
104
|
+
const role = match?.[1]?.trim();
|
|
105
|
+
const name = match?.[2]?.trim();
|
|
106
|
+
const all = Array.from(document.querySelectorAll(`[role="${role}"]`));
|
|
107
|
+
return all.find((el) => {
|
|
108
|
+
if (!isVisible(el)) return false;
|
|
109
|
+
if (!name) return true;
|
|
110
|
+
const t = (el.innerText || el.textContent || "").trim();
|
|
111
|
+
return t.includes(name) || (el.getAttribute("aria-label") || "").includes(name);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let el = null;
|
|
116
|
+
if (sel.startsWith("text=")) {
|
|
117
|
+
el = findText(sel.slice(5).trim().replace(/^["']|["']$/g, ""));
|
|
118
|
+
} else if (sel.startsWith("role=")) {
|
|
119
|
+
el = findRole(sel.slice(5));
|
|
120
|
+
} else {
|
|
121
|
+
try { el = document.querySelector(resolveSelector(sel)); } catch { el = null; }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!el) return { found: false };
|
|
125
|
+
|
|
126
|
+
const rect = el.getBoundingClientRect();
|
|
127
|
+
return {
|
|
128
|
+
found: true,
|
|
129
|
+
tagName: el.tagName,
|
|
130
|
+
text: (el.innerText || el.textContent || "").trim().slice(0, 200),
|
|
131
|
+
value: "value" in el ? el.value : null,
|
|
132
|
+
visible: isVisible(el),
|
|
133
|
+
enabled: isEnabled(el),
|
|
134
|
+
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
|
135
|
+
attributes: {
|
|
136
|
+
id: el.id || null,
|
|
137
|
+
class: el.className?.toString?.() || null,
|
|
138
|
+
placeholder: el.getAttribute("placeholder"),
|
|
139
|
+
"aria-label": el.getAttribute("aria-label"),
|
|
140
|
+
"aria-checked": el.getAttribute("aria-checked"),
|
|
141
|
+
title: el.getAttribute("title"),
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}, selector);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
async toBeVisible(opts = {}) {
|
|
149
|
+
const r = await poll(async () => {
|
|
150
|
+
const s = await probe();
|
|
151
|
+
return s.found && s.visible ? s : null;
|
|
152
|
+
}, { ...opts, message: `expect ${selector} to be visible` });
|
|
153
|
+
return formatAssertResult(r, { assertion: "toBeVisible", selector });
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
async toBeHidden(opts = {}) {
|
|
157
|
+
const r = await poll(async () => {
|
|
158
|
+
const s = await probe();
|
|
159
|
+
return !s.found || !s.visible ? s || { found: false } : null;
|
|
160
|
+
}, { ...opts, message: `expect ${selector} to be hidden` });
|
|
161
|
+
return formatAssertResult(r, { assertion: "toBeHidden", selector });
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
async toBeEnabled(opts = {}) {
|
|
165
|
+
const r = await poll(async () => {
|
|
166
|
+
const s = await probe();
|
|
167
|
+
return s.found && s.enabled ? s : null;
|
|
168
|
+
}, { ...opts, message: `expect ${selector} to be enabled` });
|
|
169
|
+
return formatAssertResult(r, { assertion: "toBeEnabled", selector });
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
async toHaveText(expected, opts = {}) {
|
|
173
|
+
const r = await poll(async () => {
|
|
174
|
+
const s = await probe();
|
|
175
|
+
if (!s.found) return null;
|
|
176
|
+
return s.text.includes(expected) ? s : null;
|
|
177
|
+
}, { ...opts, message: `expect ${selector} to have text "${expected}"` });
|
|
178
|
+
return formatAssertResult(r, { assertion: "toHaveText", selector, expected });
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
async toHaveValue(expected, opts = {}) {
|
|
182
|
+
const r = await poll(async () => {
|
|
183
|
+
const s = await probe();
|
|
184
|
+
if (!s.found || s.value == null) return null;
|
|
185
|
+
return s.value === expected || s.value.includes?.(expected) ? s : null;
|
|
186
|
+
}, { ...opts, message: `expect ${selector} to have value "${expected}"` });
|
|
187
|
+
return formatAssertResult(r, { assertion: "toHaveValue", selector, expected });
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
async toHaveCount(expected, opts = {}) {
|
|
191
|
+
const r = await poll(async () => {
|
|
192
|
+
const count = await frame.evaluate((s) => {
|
|
193
|
+
try { return document.querySelectorAll(s.startsWith("~") ? `[class*="${s.slice(1).replace(/^\./, "")}"]` : s).length; }
|
|
194
|
+
catch { return -1; }
|
|
195
|
+
}, selector);
|
|
196
|
+
return count === expected ? count : null;
|
|
197
|
+
}, { ...opts, message: `expect ${selector} to have count ${expected}` });
|
|
198
|
+
return formatAssertResult(r, { assertion: "toHaveCount", selector, expected });
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
async toHaveAttribute(name, value, opts = {}) {
|
|
202
|
+
const r = await poll(async () => {
|
|
203
|
+
const s = await probe();
|
|
204
|
+
if (!s.found) return null;
|
|
205
|
+
const actual = s.attributes[name];
|
|
206
|
+
if (value === undefined) return actual != null ? actual : null;
|
|
207
|
+
return actual === value || actual?.includes?.(value) ? actual : null;
|
|
208
|
+
}, { ...opts, message: `expect ${selector} attribute ${name} to be "${value}"` });
|
|
209
|
+
return formatAssertResult(r, { assertion: "toHaveAttribute", selector, name, expected: value });
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* 表单校验断言:检查 frame 内是否还有 form-item-explain-error
|
|
216
|
+
*/
|
|
217
|
+
export function validation(frame) {
|
|
218
|
+
return {
|
|
219
|
+
async toPass(opts = {}) {
|
|
220
|
+
const r = await poll(async () => {
|
|
221
|
+
const errors = await frame.evaluate(() => {
|
|
222
|
+
const items = Array.from(document.querySelectorAll(".ant-form-item-explain-error, .ant-form-item-has-error .ant-form-item-explain"));
|
|
223
|
+
return items
|
|
224
|
+
.filter((el) => {
|
|
225
|
+
const rect = el.getBoundingClientRect();
|
|
226
|
+
return rect.width > 0 && rect.height > 0;
|
|
227
|
+
})
|
|
228
|
+
.map((el) => {
|
|
229
|
+
const formItem = el.closest(".ant-form-item");
|
|
230
|
+
const label = formItem?.querySelector(".ant-form-item-label label, .ant-form-item-label")?.innerText?.trim() || "";
|
|
231
|
+
return {
|
|
232
|
+
field: label,
|
|
233
|
+
message: (el.innerText || el.textContent || "").trim(),
|
|
234
|
+
};
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
return errors.length === 0 ? { errors: [] } : null;
|
|
238
|
+
}, { ...opts, message: "expect form validation to pass" });
|
|
239
|
+
|
|
240
|
+
if (r.passed) {
|
|
241
|
+
return formatAssertResult(r, { assertion: "validation.toPass", expected: "no errors" });
|
|
242
|
+
}
|
|
243
|
+
// 失败时把当前错误列表带回去
|
|
244
|
+
const currentErrors = await frame.evaluate(() => {
|
|
245
|
+
const items = Array.from(document.querySelectorAll(".ant-form-item-explain-error, .ant-form-item-has-error .ant-form-item-explain"));
|
|
246
|
+
return items.map((el) => {
|
|
247
|
+
const formItem = el.closest(".ant-form-item");
|
|
248
|
+
const label = formItem?.querySelector(".ant-form-item-label label, .ant-form-item-label")?.innerText?.trim() || "";
|
|
249
|
+
return { field: label, message: (el.innerText || el.textContent || "").trim() };
|
|
250
|
+
});
|
|
251
|
+
}).catch(() => []);
|
|
252
|
+
|
|
253
|
+
return formatAssertResult(r, {
|
|
254
|
+
assertion: "validation.toPass",
|
|
255
|
+
expected: "no errors",
|
|
256
|
+
actual: currentErrors,
|
|
257
|
+
});
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
async toHaveError(fieldOrMessage, opts = {}) {
|
|
261
|
+
const r = await poll(async () => {
|
|
262
|
+
const errors = await frame.evaluate(() => {
|
|
263
|
+
const items = Array.from(document.querySelectorAll(".ant-form-item-explain-error"));
|
|
264
|
+
return items.map((el) => {
|
|
265
|
+
const formItem = el.closest(".ant-form-item");
|
|
266
|
+
const label = formItem?.querySelector(".ant-form-item-label label, .ant-form-item-label")?.innerText?.trim() || "";
|
|
267
|
+
return { field: label, message: (el.innerText || el.textContent || "").trim() };
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
const matched = errors.find((e) =>
|
|
271
|
+
e.field.includes(fieldOrMessage) || e.message.includes(fieldOrMessage)
|
|
272
|
+
);
|
|
273
|
+
return matched || null;
|
|
274
|
+
}, { ...opts, message: `expect validation error for "${fieldOrMessage}"` });
|
|
275
|
+
return formatAssertResult(r, { assertion: "validation.toHaveError", expected: fieldOrMessage });
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* URL 断言(针对 page,不需要 poll,因为 url 通常已稳定,但提供选项化轮询)
|
|
282
|
+
*/
|
|
283
|
+
export function url(page) {
|
|
284
|
+
return {
|
|
285
|
+
async toContain(expected, opts = {}) {
|
|
286
|
+
const r = await poll(async () => {
|
|
287
|
+
const u = page.url();
|
|
288
|
+
return u.includes(expected) ? u : null;
|
|
289
|
+
}, { timeout: opts.timeout ?? 1000, interval: 100, message: `expect url to contain "${expected}"` });
|
|
290
|
+
return formatAssertResult(r, { assertion: "url.toContain", expected, actual: page.url() });
|
|
291
|
+
},
|
|
292
|
+
|
|
293
|
+
async toMatch(regex, opts = {}) {
|
|
294
|
+
const re = regex instanceof RegExp ? regex : new RegExp(regex);
|
|
295
|
+
const r = await poll(async () => {
|
|
296
|
+
const u = page.url();
|
|
297
|
+
return re.test(u) ? u : null;
|
|
298
|
+
}, { timeout: opts.timeout ?? 1000, interval: 100 });
|
|
299
|
+
return formatAssertResult(r, { assertion: "url.toMatch", expected: re.toString(), actual: page.url() });
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* API 捕获结果断言(基于 captureApi 的返回值)
|
|
306
|
+
* @param {object} captureResult - captureApi 返回对象
|
|
307
|
+
* @param {object} [options] - { scope: "current-frame"|"all", frameUrlContains }
|
|
308
|
+
*/
|
|
309
|
+
export function api(captureResult, options = {}) {
|
|
310
|
+
const allRequests = captureResult?.requests || [];
|
|
311
|
+
const scope = options.scope || "all";
|
|
312
|
+
|
|
313
|
+
// 按 frame 范围过滤请求
|
|
314
|
+
let requests = allRequests;
|
|
315
|
+
if (scope === "current-frame" && options.frameUrlContains) {
|
|
316
|
+
requests = allRequests.filter((r) => r.frameUrl && r.frameUrl.includes(options.frameUrlContains));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return {
|
|
320
|
+
toAllPass() {
|
|
321
|
+
const failed = requests.filter((r) => (r.status && r.status >= 400) || r.errorText);
|
|
322
|
+
const passed = failed.length === 0;
|
|
323
|
+
return {
|
|
324
|
+
passed,
|
|
325
|
+
assertion: "api.toAllPass",
|
|
326
|
+
expected: "all requests status < 400",
|
|
327
|
+
actual: failed.length === 0 ? "all ok" : failed,
|
|
328
|
+
totalRequests: allRequests.length,
|
|
329
|
+
scopedRequests: requests.length,
|
|
330
|
+
reason: passed ? null : `${failed.length}/${requests.length} requests failed`,
|
|
331
|
+
};
|
|
332
|
+
},
|
|
333
|
+
|
|
334
|
+
toHaveNoFailed() {
|
|
335
|
+
return this.toAllPass();
|
|
336
|
+
},
|
|
337
|
+
|
|
338
|
+
toInclude(urlPattern) {
|
|
339
|
+
const re = typeof urlPattern === "string" ? new RegExp(urlPattern) : urlPattern;
|
|
340
|
+
const matched = requests.find((r) => re.test(r.url));
|
|
341
|
+
return {
|
|
342
|
+
passed: Boolean(matched),
|
|
343
|
+
assertion: "api.toInclude",
|
|
344
|
+
expected: urlPattern.toString(),
|
|
345
|
+
actual: matched || null,
|
|
346
|
+
totalRequests: allRequests.length,
|
|
347
|
+
scopedRequests: requests.length,
|
|
348
|
+
reason: matched ? null : `no request matched ${urlPattern}`,
|
|
349
|
+
};
|
|
350
|
+
},
|
|
351
|
+
|
|
352
|
+
toHaveCount(expected) {
|
|
353
|
+
return {
|
|
354
|
+
passed: requests.length === expected,
|
|
355
|
+
assertion: "api.toHaveCount",
|
|
356
|
+
expected,
|
|
357
|
+
actual: requests.length,
|
|
358
|
+
totalRequests: allRequests.length,
|
|
359
|
+
scopedRequests: requests.length,
|
|
360
|
+
reason: requests.length === expected ? null : `got ${requests.length} requests`,
|
|
361
|
+
};
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function formatAssertResult(pollResult, meta) {
|
|
367
|
+
return {
|
|
368
|
+
passed: pollResult.passed,
|
|
369
|
+
assertion: meta.assertion,
|
|
370
|
+
selector: meta.selector,
|
|
371
|
+
expected: meta.expected,
|
|
372
|
+
actual: meta.actual ?? pollResult.value,
|
|
373
|
+
attempts: pollResult.attempts,
|
|
374
|
+
duration: pollResult.duration,
|
|
375
|
+
reason: pollResult.passed ? null : pollResult.reason,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* 工厂:返回绑定 frame/page 的 expect 接口
|
|
381
|
+
*/
|
|
382
|
+
export function createExpect(target) {
|
|
383
|
+
return {
|
|
384
|
+
poll,
|
|
385
|
+
locator: (selector) => locator(target.frame || target, selector),
|
|
386
|
+
validation: () => validation(target.frame || target),
|
|
387
|
+
url: () => url(target.page || target),
|
|
388
|
+
api,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export default { poll, locator, validation, url, api, createExpect };
|
package/lib/test-plan.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* plan → testCase 兼容编译器
|
|
3
|
+
*
|
|
4
|
+
* 把现有线性 plan JSON 自动升级为 testCase 对象,无需用户迁移。
|
|
5
|
+
* 规则:
|
|
6
|
+
* - plan.steps[] → testCase.steps
|
|
7
|
+
* - plan.dataset 存在 → 参数化执行
|
|
8
|
+
* - 新 step type(antSelect/antInput/expect/formFill 等)原样保留
|
|
9
|
+
* - 旧 step type(click/type/checkElement 等)也原样保留
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 编译 plan JSON 为 testCase 对象
|
|
14
|
+
* @param {object} plan - 现有 plan 格式
|
|
15
|
+
* @returns {object} testCase 对象
|
|
16
|
+
*/
|
|
17
|
+
export function compilePlan(plan) {
|
|
18
|
+
return {
|
|
19
|
+
title: plan.description || plan.planId || `auto-${Date.now()}`,
|
|
20
|
+
labels: plan.labels || [],
|
|
21
|
+
setup: {
|
|
22
|
+
findPage: plan.findPage || undefined,
|
|
23
|
+
findFrame: plan.findFrame || undefined,
|
|
24
|
+
apiCapture: plan.apiCapture || undefined,
|
|
25
|
+
},
|
|
26
|
+
dataset: plan.dataset || [],
|
|
27
|
+
steps: plan.steps || [],
|
|
28
|
+
recovery: plan.recovery || {},
|
|
29
|
+
teardown: plan.teardown || undefined,
|
|
30
|
+
// 透传元数据,报告使用
|
|
31
|
+
_planId: plan.planId,
|
|
32
|
+
_options: plan.options,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 编译 plan 文件内容为 testCase 对象数组
|
|
38
|
+
* @param {object|object[]} fileContent - plan JSON 或 plan 数组
|
|
39
|
+
* @returns {object[]} testCase 数组
|
|
40
|
+
*/
|
|
41
|
+
export function compilePlanFile(fileContent) {
|
|
42
|
+
if (Array.isArray(fileContent)) {
|
|
43
|
+
return fileContent.map(compilePlan);
|
|
44
|
+
}
|
|
45
|
+
return [compilePlan(fileContent)];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export default { compilePlan, compilePlanFile };
|
|
@@ -216,6 +216,18 @@ function parseArgs() {
|
|
|
216
216
|
case "--capture-input-file":
|
|
217
217
|
parsed.captureInputFile = args[++i];
|
|
218
218
|
break;
|
|
219
|
+
case "--test-case":
|
|
220
|
+
parsed.testCaseJson = args[++i];
|
|
221
|
+
break;
|
|
222
|
+
case "--test-case-file":
|
|
223
|
+
parsed.testCaseFile = args[++i];
|
|
224
|
+
break;
|
|
225
|
+
case "--dataset-index":
|
|
226
|
+
parsed.datasetIndex = Number.parseInt(args[++i], 10);
|
|
227
|
+
break;
|
|
228
|
+
case "--test-report":
|
|
229
|
+
parsed.testReport = args[++i];
|
|
230
|
+
break;
|
|
219
231
|
case "--help":
|
|
220
232
|
case "-h":
|
|
221
233
|
printHelp();
|
|
@@ -555,11 +567,68 @@ async function executeAction(args) {
|
|
|
555
567
|
}, context);
|
|
556
568
|
return { type: "runTask", ...result, browserInfo: initResult, startTime: startedAt, endTime: new Date().toISOString() };
|
|
557
569
|
}
|
|
570
|
+
case "test-case":
|
|
571
|
+
case "testCase": {
|
|
572
|
+
// 加载 testCase 定义
|
|
573
|
+
let testCase;
|
|
574
|
+
if (args.testCaseFile) {
|
|
575
|
+
try {
|
|
576
|
+
testCase = JSON.parse(fs.readFileSync(args.testCaseFile, "utf8"));
|
|
577
|
+
} catch (err) {
|
|
578
|
+
return {
|
|
579
|
+
error: `读取 test-case 文件失败: ${err.message}`,
|
|
580
|
+
errorCode: "TEST_CASE_INVALID",
|
|
581
|
+
startTime: startedAt,
|
|
582
|
+
endTime: new Date().toISOString(),
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
} else if (args.testCaseJson) {
|
|
586
|
+
try {
|
|
587
|
+
testCase = JSON.parse(args.testCaseJson);
|
|
588
|
+
} catch (err) {
|
|
589
|
+
return {
|
|
590
|
+
error: `解析 --test-case JSON 失败: ${err.message}`,
|
|
591
|
+
errorCode: "TEST_CASE_INVALID",
|
|
592
|
+
startTime: startedAt,
|
|
593
|
+
endTime: new Date().toISOString(),
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
} else {
|
|
597
|
+
return {
|
|
598
|
+
error: "必须提供 --test-case 或 --test-case-file",
|
|
599
|
+
errorCode: "TEST_CASE_MISSING",
|
|
600
|
+
startTime: startedAt,
|
|
601
|
+
endTime: new Date().toISOString(),
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// 支持 --dataset-index 仅执行某一行
|
|
606
|
+
if (args.datasetIndex !== undefined && Array.isArray(testCase.dataset)) {
|
|
607
|
+
if (args.datasetIndex < 0 || args.datasetIndex >= testCase.dataset.length) {
|
|
608
|
+
return {
|
|
609
|
+
error: `--dataset-index ${args.datasetIndex} 越界(共 ${testCase.dataset.length} 行)`,
|
|
610
|
+
errorCode: "DATASET_INDEX_OUT_OF_RANGE",
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
testCase.dataset = [testCase.dataset[args.datasetIndex]];
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const result = await engine.executeTestCase(testCase, {});
|
|
617
|
+
|
|
618
|
+
if (args.testReport) {
|
|
619
|
+
const dir = path.dirname(args.testReport);
|
|
620
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
621
|
+
fs.writeFileSync(args.testReport, JSON.stringify(result, null, 2), "utf8");
|
|
622
|
+
process.stderr.write(`测试报告已写入: ${args.testReport}\n`);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return { type: "testCase", ...result, browserInfo: initResult, startTime: startedAt, endTime: new Date().toISOString() };
|
|
626
|
+
}
|
|
558
627
|
default:
|
|
559
628
|
return {
|
|
560
629
|
error: `未知 action: ${args.action}`,
|
|
561
630
|
errorCode: "UNKNOWN_ACTION",
|
|
562
|
-
supportedActions: ["observe", "act", "assert", "api-capture", "assert-api", "run-task"],
|
|
631
|
+
supportedActions: ["observe", "act", "assert", "api-capture", "assert-api", "run-task", "test-case"],
|
|
563
632
|
startTime: startedAt,
|
|
564
633
|
endTime: new Date().toISOString(),
|
|
565
634
|
};
|