@autobest-ui/agent 1.0.8 → 1.0.11
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/bin/sync-assets.test.mjs +2 -0
- package/mcp/code-mcp-pr/README.md +3 -2
- package/mcp/code-mcp-pr/azure-devops.js +81 -2
- package/mcp/code-mcp-pr/index.js +34 -1
- package/mcp/code-mcp-pr/index.test.js +161 -11
- package/package.json +1 -1
- package/plugins/autobest-delivery/.codex-plugin/plugin.json +1 -0
- package/plugins/autobest-delivery/README.md +52 -10
- package/plugins/autobest-delivery/mcp-server/package.json +1 -1
- package/plugins/autobest-delivery/mcp-server/src/fast-runner.mjs +470 -0
- package/plugins/autobest-delivery/mcp-server/src/paths.mjs +12 -7
- package/plugins/autobest-delivery/mcp-server/src/report.mjs +118 -16
- package/plugins/autobest-delivery/mcp-server/src/runner.mjs +163 -1
- package/plugins/autobest-delivery/mcp-server/src/server.mjs +173 -3
- package/plugins/autobest-delivery/mcp-server/tests/fast-runner.test.mjs +232 -0
- package/plugins/autobest-delivery/mcp-server/tests/fixture-server.mjs +5 -0
- package/plugins/autobest-delivery/mcp-server/tests/mcp-smoke.test.mjs +71 -0
- package/plugins/autobest-delivery/mcp-server/tests/report.test.mjs +107 -1
- package/plugins/autobest-delivery/mcp-server/tests/runner.test.mjs +279 -0
- package/plugins/autobest-delivery/skills/code-audit/SKILL.md +3 -3
- package/plugins/autobest-delivery/skills/delivery-loop/SKILL.md +10 -7
- package/plugins/autobest-delivery/skills/delivery-loop/references/delivery-contract.md +63 -6
- package/plugins/autobest-delivery/skills/delivery-loop-fast/SKILL.md +62 -0
- package/plugins/autobest-delivery/skills/delivery-loop-fast/agents/openai.yaml +7 -0
- package/plugins/autobest-delivery/skills/e2e-gen-spec/SKILL.md +7 -6
- package/plugins/autobest-delivery/skills/e2e-ui-checker/SKILL.md +5 -4
- package/plugins/autobest-delivery/skills/export-report/SKILL.md +6 -6
- package/plugins/autobest-delivery/skills/export-report/agents/openai.yaml +1 -2
- package/skills/README.md +1 -0
- package/skills/common/code-pr-submit/SKILL.md +25 -4
- package/skills/common/repo-setup/SKILL.md +91 -0
- package/skills/common/repo-setup/agents/openai.yaml +4 -0
|
@@ -155,6 +155,7 @@ export async function runFeatureE2E({
|
|
|
155
155
|
scenarioPath,
|
|
156
156
|
baseUrl,
|
|
157
157
|
outputDir,
|
|
158
|
+
apiMode,
|
|
158
159
|
iteration = 1,
|
|
159
160
|
timeoutMs = 300000,
|
|
160
161
|
headed = false,
|
|
@@ -175,12 +176,14 @@ export async function runFeatureE2E({
|
|
|
175
176
|
const scenarioBlockers = [];
|
|
176
177
|
const consoleErrors = [];
|
|
177
178
|
const networkFailures = [];
|
|
179
|
+
const apiCalls = [];
|
|
178
180
|
const invokedScenes = new Set();
|
|
179
181
|
let browser;
|
|
180
182
|
let context;
|
|
181
183
|
let page;
|
|
182
184
|
let blockedPage;
|
|
183
185
|
let metadata = {};
|
|
186
|
+
let apiTargets = [];
|
|
184
187
|
let timeoutId;
|
|
185
188
|
let traceStarted = false;
|
|
186
189
|
let traceError;
|
|
@@ -197,11 +200,63 @@ export async function runFeatureE2E({
|
|
|
197
200
|
throw new TypeError('冻结场景必须导出一个默认异步函数');
|
|
198
201
|
}
|
|
199
202
|
metadata = scenarioModule.metadata || {};
|
|
203
|
+
const apiModeSchemaVersion = metadata.apiModeSchemaVersion;
|
|
200
204
|
const reportSchemaVersion = metadata.reportSchemaVersion;
|
|
205
|
+
const reportCaptureSchemaVersion = metadata.reportCaptureSchemaVersion;
|
|
206
|
+
if (apiModeSchemaVersion !== undefined && apiModeSchemaVersion !== 1) {
|
|
207
|
+
throw new TypeError('metadata.apiModeSchemaVersion 仅支持 1');
|
|
208
|
+
}
|
|
209
|
+
if (apiModeSchemaVersion === 1) {
|
|
210
|
+
if (!['real', 'mock'].includes(metadata.apiMode)) {
|
|
211
|
+
throw new TypeError('metadata.apiMode 必须是 real 或 mock');
|
|
212
|
+
}
|
|
213
|
+
if (!['real', 'mock'].includes(apiMode)) {
|
|
214
|
+
throw new TypeError('run_feature_e2e 必须显式传入 apiMode: real 或 mock');
|
|
215
|
+
}
|
|
216
|
+
if (apiMode !== metadata.apiMode) {
|
|
217
|
+
throw new TypeError(
|
|
218
|
+
`执行 API 模式 ${apiMode} 与冻结场景模式 ${metadata.apiMode} 不一致`
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
if (!Array.isArray(metadata.apiTargets) || metadata.apiTargets.length === 0) {
|
|
222
|
+
throw new TypeError('metadata.apiTargets 必须是非空数组');
|
|
223
|
+
}
|
|
224
|
+
const targetIds = new Set();
|
|
225
|
+
apiTargets = metadata.apiTargets.map(target => {
|
|
226
|
+
if (!target || typeof target.id !== 'string' || !/^[a-z0-9][a-z0-9._-]*$/.test(target.id)) {
|
|
227
|
+
throw new TypeError('apiTarget.id 必须是稳定的 ASCII ID');
|
|
228
|
+
}
|
|
229
|
+
if (targetIds.has(target.id)) {
|
|
230
|
+
throw new TypeError(`apiTarget.id 不得重复:${target.id}`);
|
|
231
|
+
}
|
|
232
|
+
targetIds.add(target.id);
|
|
233
|
+
if (typeof target.method !== 'string' || !/^[A-Z]+$/.test(target.method)) {
|
|
234
|
+
throw new TypeError(`apiTarget ${target.id} 的 method 必须是大写 HTTP 方法`);
|
|
235
|
+
}
|
|
236
|
+
if (
|
|
237
|
+
typeof target.pathname !== 'string' ||
|
|
238
|
+
!target.pathname.startsWith('/') ||
|
|
239
|
+
target.pathname.includes('?') ||
|
|
240
|
+
target.pathname.includes('#')
|
|
241
|
+
) {
|
|
242
|
+
throw new TypeError(`apiTarget ${target.id} 的 pathname 必须是无查询参数的绝对路径`);
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
id: target.id,
|
|
246
|
+
method: target.method,
|
|
247
|
+
pathname: target.pathname
|
|
248
|
+
};
|
|
249
|
+
});
|
|
250
|
+
} else if (apiMode !== undefined && !['real', 'mock'].includes(apiMode)) {
|
|
251
|
+
throw new TypeError('apiMode 仅支持 real 或 mock');
|
|
252
|
+
}
|
|
201
253
|
const traceabilitySchemaVersion = metadata.traceabilitySchemaVersion;
|
|
202
254
|
if (reportSchemaVersion !== undefined && reportSchemaVersion !== 1) {
|
|
203
255
|
throw new TypeError('metadata.reportSchemaVersion 仅支持 1');
|
|
204
256
|
}
|
|
257
|
+
if (reportCaptureSchemaVersion !== undefined && reportCaptureSchemaVersion !== 1) {
|
|
258
|
+
throw new TypeError('metadata.reportCaptureSchemaVersion 仅支持 1');
|
|
259
|
+
}
|
|
205
260
|
if (traceabilitySchemaVersion !== undefined && traceabilitySchemaVersion !== 1) {
|
|
206
261
|
throw new TypeError('metadata.traceabilitySchemaVersion 仅支持 1');
|
|
207
262
|
}
|
|
@@ -227,6 +282,14 @@ export async function runFeatureE2E({
|
|
|
227
282
|
throw new TypeError(`visualMapping 引用了未知需求 ID:${mapping.requirementId}`);
|
|
228
283
|
}
|
|
229
284
|
}
|
|
285
|
+
for (const mapping of metadata.reportCaptures || []) {
|
|
286
|
+
if (!mapping || typeof mapping.requirementId !== 'string') {
|
|
287
|
+
throw new TypeError('reportCapture 必须包含 requirementId');
|
|
288
|
+
}
|
|
289
|
+
if (!requirementIds.has(mapping.requirementId)) {
|
|
290
|
+
throw new TypeError(`reportCapture 引用了未知需求 ID:${mapping.requirementId}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
230
293
|
}
|
|
231
294
|
if (reportSchemaVersion === 1) {
|
|
232
295
|
for (const mapping of metadata.visualMappings || []) {
|
|
@@ -243,6 +306,43 @@ export async function runFeatureE2E({
|
|
|
243
306
|
}
|
|
244
307
|
}
|
|
245
308
|
}
|
|
309
|
+
if (reportCaptureSchemaVersion === 1) {
|
|
310
|
+
if (!Array.isArray(metadata.reportCaptures) || metadata.reportCaptures.length === 0) {
|
|
311
|
+
throw new TypeError('metadata.reportCaptures 必须是非空数组');
|
|
312
|
+
}
|
|
313
|
+
const captureNames = new Set();
|
|
314
|
+
for (const mapping of metadata.reportCaptures) {
|
|
315
|
+
for (const field of [
|
|
316
|
+
'capture',
|
|
317
|
+
'scene',
|
|
318
|
+
'locator',
|
|
319
|
+
'reportModule',
|
|
320
|
+
'reportGroup',
|
|
321
|
+
'reportTitle',
|
|
322
|
+
'reportDevice'
|
|
323
|
+
]) {
|
|
324
|
+
if (!mapping || typeof mapping[field] !== 'string' || !mapping[field].trim()) {
|
|
325
|
+
throw new TypeError(`reportCapture 必须包含非空字符串字段 ${field}`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*\.png$/.test(mapping.capture)) {
|
|
329
|
+
throw new TypeError('reportCapture.capture 必须是简单的 PNG 文件名');
|
|
330
|
+
}
|
|
331
|
+
if (captureNames.has(mapping.capture)) {
|
|
332
|
+
throw new TypeError(`reportCapture.capture 不得重复:${mapping.capture}`);
|
|
333
|
+
}
|
|
334
|
+
captureNames.add(mapping.capture);
|
|
335
|
+
if (!['desktop', 'mobile'].includes(mapping.reportDevice)) {
|
|
336
|
+
throw new TypeError('reportCapture.reportDevice 必须是 desktop 或 mobile');
|
|
337
|
+
}
|
|
338
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/.test(mapping.reportGroup)) {
|
|
339
|
+
throw new TypeError('reportCapture.reportGroup 必须是稳定的 ASCII ID');
|
|
340
|
+
}
|
|
341
|
+
if (Array.isArray(metadata.scenes) && !metadata.scenes.includes(mapping.scene)) {
|
|
342
|
+
throw new TypeError(`reportCapture 引用了未知 scene:${mapping.scene}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
246
346
|
|
|
247
347
|
browser = await chromium.launch({ headless: !headed });
|
|
248
348
|
context = await browser.newContext();
|
|
@@ -261,6 +361,28 @@ export async function runFeatureE2E({
|
|
|
261
361
|
error: request.failure()?.errorText || '未知请求失败'
|
|
262
362
|
});
|
|
263
363
|
});
|
|
364
|
+
currentPage.on('response', response => {
|
|
365
|
+
const request = response.request();
|
|
366
|
+
const requestUrl = new URL(response.url());
|
|
367
|
+
for (const target of apiTargets) {
|
|
368
|
+
if (request.method() === target.method && requestUrl.pathname === target.pathname) {
|
|
369
|
+
apiCalls.push({
|
|
370
|
+
targetId: target.id,
|
|
371
|
+
method: target.method,
|
|
372
|
+
origin: requestUrl.origin,
|
|
373
|
+
pathname: target.pathname,
|
|
374
|
+
status: response.status()
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
if (metadata.apiMode === 'real') {
|
|
380
|
+
const rejectInterception = async () => {
|
|
381
|
+
throw new TypeError('real API 模式禁止 route() 或 page.route() 请求拦截');
|
|
382
|
+
};
|
|
383
|
+
Object.defineProperty(currentPage, 'route', { value: rejectInterception });
|
|
384
|
+
Object.defineProperty(currentPage, 'routeFromHAR', { value: rejectInterception });
|
|
385
|
+
}
|
|
264
386
|
return currentPage;
|
|
265
387
|
};
|
|
266
388
|
page = await createPage();
|
|
@@ -323,7 +445,12 @@ export async function runFeatureE2E({
|
|
|
323
445
|
expect,
|
|
324
446
|
check,
|
|
325
447
|
scene,
|
|
326
|
-
route: (pattern, handler) =>
|
|
448
|
+
route: (pattern, handler) => {
|
|
449
|
+
if (metadata.apiMode === 'real') {
|
|
450
|
+
throw new TypeError('real API 模式禁止 route() 请求拦截');
|
|
451
|
+
}
|
|
452
|
+
return currentPage.route(pattern, handler);
|
|
453
|
+
},
|
|
327
454
|
capture,
|
|
328
455
|
artifact,
|
|
329
456
|
baseUrl,
|
|
@@ -399,6 +526,37 @@ export async function runFeatureE2E({
|
|
|
399
526
|
throw new TypeError(`以下需求 ID 没有运行或视觉覆盖:${uncovered.join(', ')}`);
|
|
400
527
|
}
|
|
401
528
|
}
|
|
529
|
+
if (metadata.apiModeSchemaVersion === 1 && scenarioBlockers.length === 0) {
|
|
530
|
+
const observedTargets = new Set(apiCalls.map(call => call.targetId));
|
|
531
|
+
const missingTargets = apiTargets
|
|
532
|
+
.filter(target => !observedTargets.has(target.id))
|
|
533
|
+
.map(target => target.id);
|
|
534
|
+
if (missingTargets.length > 0) {
|
|
535
|
+
throw new TypeError(`以下声明 API 未产生响应证据:${missingTargets.join(', ')}`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
if (reportCaptureSchemaVersion === 1 && scenarioBlockers.length === 0) {
|
|
539
|
+
const declaredCaptures = new Set(metadata.reportCaptures.map(item => item.capture));
|
|
540
|
+
const actualCaptures = new Set(captures.map(item => path.basename(item)));
|
|
541
|
+
const missingCaptures = [...declaredCaptures].filter(name => !actualCaptures.has(name));
|
|
542
|
+
if (missingCaptures.length > 0) {
|
|
543
|
+
throw new TypeError(`以下报告截图未生成:${missingCaptures.join(', ')}`);
|
|
544
|
+
}
|
|
545
|
+
const captureGroups = new Set(metadata.reportCaptures.map(item => item.reportGroup));
|
|
546
|
+
const missingGroups = [...new Set(checks.map(item => item.reportGroup))]
|
|
547
|
+
.filter(group => group && !captureGroups.has(group));
|
|
548
|
+
if (missingGroups.length > 0) {
|
|
549
|
+
throw new TypeError(`以下报告功能组没有截图:${missingGroups.join(', ')}`);
|
|
550
|
+
}
|
|
551
|
+
const declaredEvidence = new Set([
|
|
552
|
+
...declaredCaptures,
|
|
553
|
+
...(metadata.visualMappings || []).map(item => item.capture)
|
|
554
|
+
]);
|
|
555
|
+
const undeclaredCaptures = [...actualCaptures].filter(name => !declaredEvidence.has(name));
|
|
556
|
+
if (undeclaredCaptures.length > 0) {
|
|
557
|
+
throw new TypeError(`以下截图未在 metadata 中声明:${undeclaredCaptures.join(', ')}`);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
402
560
|
if (blockedPage && !blockedPage.isClosed()) {
|
|
403
561
|
await blockedPage.bringToFront().catch(() => undefined);
|
|
404
562
|
page = blockedPage;
|
|
@@ -412,6 +570,8 @@ export async function runFeatureE2E({
|
|
|
412
570
|
scenarioPath: toEvidencePath(root, scenario),
|
|
413
571
|
scenarioSha256: beforeHash,
|
|
414
572
|
metadata,
|
|
573
|
+
apiMode: metadata.apiMode || null,
|
|
574
|
+
apiCalls,
|
|
415
575
|
checks,
|
|
416
576
|
captures,
|
|
417
577
|
tracePath: toEvidencePath(root, tracePath),
|
|
@@ -432,6 +592,8 @@ export async function runFeatureE2E({
|
|
|
432
592
|
scenarioPath: toEvidencePath(root, scenario),
|
|
433
593
|
scenarioSha256: beforeHash,
|
|
434
594
|
metadata,
|
|
595
|
+
apiMode: metadata.apiMode || null,
|
|
596
|
+
apiCalls,
|
|
435
597
|
checks,
|
|
436
598
|
captures,
|
|
437
599
|
tracePath: traceStarted ? toEvidencePath(root, tracePath) : null,
|
|
@@ -9,6 +9,17 @@ import {
|
|
|
9
9
|
resolveBlockedRun,
|
|
10
10
|
runFeatureE2E
|
|
11
11
|
} from './runner.mjs';
|
|
12
|
+
import {
|
|
13
|
+
abortFastCheck,
|
|
14
|
+
captureFastCheck,
|
|
15
|
+
closeAllFastChecks,
|
|
16
|
+
fastBrowserAction,
|
|
17
|
+
fastBrowserCheck,
|
|
18
|
+
fastBrowserSnapshot,
|
|
19
|
+
finishFastCheck,
|
|
20
|
+
mockFastCheckRoute,
|
|
21
|
+
startFastCheck
|
|
22
|
+
} from './fast-runner.mjs';
|
|
12
23
|
|
|
13
24
|
const server = new McpServer(
|
|
14
25
|
{
|
|
@@ -17,10 +28,164 @@ const server = new McpServer(
|
|
|
17
28
|
},
|
|
18
29
|
{
|
|
19
30
|
instructions:
|
|
20
|
-
'调用
|
|
31
|
+
'调用 E2E 或快速浏览器工具前先调用 check_environment。run_feature_e2e 执行冻结场景;start_fast_check 提供不生成场景文件的交互式自测。'
|
|
21
32
|
}
|
|
22
33
|
);
|
|
23
34
|
|
|
35
|
+
function jsonResult(result) {
|
|
36
|
+
return {
|
|
37
|
+
structuredContent: result,
|
|
38
|
+
content: [{ type: 'text', text: JSON.stringify(result) }]
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
server.registerTool(
|
|
43
|
+
'start_fast_check',
|
|
44
|
+
{
|
|
45
|
+
title: '启动快速 Playwright 自测',
|
|
46
|
+
description: '启动无需 E2E 场景文件的交互式浏览器会话,并在工作区内准备快速证据目录。',
|
|
47
|
+
inputSchema: {
|
|
48
|
+
workspaceRoot: z.string().min(1),
|
|
49
|
+
baseUrl: z.string().url(),
|
|
50
|
+
outputDir: z.string().min(1),
|
|
51
|
+
apiMode: z.enum(['real', 'mock', 'none']).default('none'),
|
|
52
|
+
iteration: z.number().int().positive().default(1),
|
|
53
|
+
headed: z.boolean().default(false),
|
|
54
|
+
viewport: z.object({
|
|
55
|
+
width: z.number().int().min(320).max(3840),
|
|
56
|
+
height: z.number().int().min(480).max(2160)
|
|
57
|
+
}).default({ width: 1400, height: 1000 }),
|
|
58
|
+
requirementIds: z.array(z.string().regex(/^REQ-[A-Z][A-Z0-9]{1,15}-\d{3}$/)).default([]),
|
|
59
|
+
sessionTtlMs: z.number().int().min(60000).max(3600000).default(3600000)
|
|
60
|
+
},
|
|
61
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true }
|
|
62
|
+
},
|
|
63
|
+
async input => jsonResult(await startFastCheck(input))
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
server.registerTool(
|
|
67
|
+
'fast_browser_snapshot',
|
|
68
|
+
{
|
|
69
|
+
title: '读取快速自测页面',
|
|
70
|
+
description: '返回当前页面的 URL、标题和精简可访问性快照,供后续交互定位。',
|
|
71
|
+
inputSchema: {
|
|
72
|
+
sessionId: z.string().uuid(),
|
|
73
|
+
timeoutMs: z.number().int().min(1000).max(120000).default(10000)
|
|
74
|
+
},
|
|
75
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true }
|
|
76
|
+
},
|
|
77
|
+
async input => jsonResult(await fastBrowserSnapshot(input))
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
server.registerTool(
|
|
81
|
+
'fast_browser_action',
|
|
82
|
+
{
|
|
83
|
+
title: '执行快速自测页面操作',
|
|
84
|
+
description: '在快速自测会话中执行导航、点击、填写、选择、按键、等待或视口切换。',
|
|
85
|
+
inputSchema: {
|
|
86
|
+
sessionId: z.string().uuid(),
|
|
87
|
+
action: z.enum(['navigate', 'click', 'fill', 'select', 'press', 'wait', 'viewport']),
|
|
88
|
+
locator: z.string().optional(),
|
|
89
|
+
value: z.string().optional(),
|
|
90
|
+
url: z.string().optional(),
|
|
91
|
+
width: z.number().int().min(320).max(3840).optional(),
|
|
92
|
+
height: z.number().int().min(480).max(2160).optional(),
|
|
93
|
+
timeoutMs: z.number().int().min(1000).max(120000).default(10000)
|
|
94
|
+
},
|
|
95
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true }
|
|
96
|
+
},
|
|
97
|
+
async input => jsonResult(await fastBrowserAction(input))
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
server.registerTool(
|
|
101
|
+
'mock_fast_check_route',
|
|
102
|
+
{
|
|
103
|
+
title: '设置快速自测 Mock',
|
|
104
|
+
description: '仅在 mock 模式为指定 HTTP method 和 pathname 设置 JSON 响应。',
|
|
105
|
+
inputSchema: {
|
|
106
|
+
sessionId: z.string().uuid(),
|
|
107
|
+
method: z.string().regex(/^[A-Z]+$/).default('GET'),
|
|
108
|
+
pathname: z.string().startsWith('/'),
|
|
109
|
+
status: z.number().int().min(100).max(599).default(200),
|
|
110
|
+
body: z.unknown().default({})
|
|
111
|
+
},
|
|
112
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }
|
|
113
|
+
},
|
|
114
|
+
async input => jsonResult(await mockFastCheckRoute(input))
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const reportFields = {
|
|
118
|
+
requirementId: z.string().optional(),
|
|
119
|
+
reportModule: z.string().min(1),
|
|
120
|
+
reportGroup: z.string().regex(/^[a-z0-9][a-z0-9._-]*$/),
|
|
121
|
+
reportTitle: z.string().min(1),
|
|
122
|
+
reportMethod: z.string().min(1)
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
server.registerTool(
|
|
126
|
+
'fast_browser_check',
|
|
127
|
+
{
|
|
128
|
+
title: '记录快速自测检查',
|
|
129
|
+
description: '执行一个结构化页面检查并记录可供报告导出的检查证据。',
|
|
130
|
+
inputSchema: {
|
|
131
|
+
sessionId: z.string().uuid(),
|
|
132
|
+
id: z.string().regex(/^[a-z0-9][a-z0-9._-]*$/),
|
|
133
|
+
...reportFields,
|
|
134
|
+
specSnippet: z.string().min(1),
|
|
135
|
+
expect: z.string().min(1),
|
|
136
|
+
checkType: z.enum(['visible', 'hidden', 'text', 'value', 'count', 'url', 'attribute', 'css']),
|
|
137
|
+
locator: z.string().optional(),
|
|
138
|
+
expected: z.string().optional(),
|
|
139
|
+
expectedNumber: z.number().int().nonnegative().optional(),
|
|
140
|
+
property: z.string().optional(),
|
|
141
|
+
exact: z.boolean().default(false),
|
|
142
|
+
timeoutMs: z.number().int().min(1000).max(120000).default(10000)
|
|
143
|
+
},
|
|
144
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true }
|
|
145
|
+
},
|
|
146
|
+
async input => jsonResult(await fastBrowserCheck(input))
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
server.registerTool(
|
|
150
|
+
'capture_fast_check',
|
|
151
|
+
{
|
|
152
|
+
title: '保存快速自测报告截图',
|
|
153
|
+
description: '保存页面或组件 PNG,并记录功能分组和桌面端/移动端报告映射。',
|
|
154
|
+
inputSchema: {
|
|
155
|
+
sessionId: z.string().uuid(),
|
|
156
|
+
...reportFields,
|
|
157
|
+
capture: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*\.png$/),
|
|
158
|
+
locator: z.string().optional(),
|
|
159
|
+
reportDevice: z.enum(['desktop', 'mobile']),
|
|
160
|
+
fullPage: z.boolean().default(true)
|
|
161
|
+
},
|
|
162
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true }
|
|
163
|
+
},
|
|
164
|
+
async input => jsonResult(await captureFastCheck(input))
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
server.registerTool(
|
|
168
|
+
'finish_fast_check',
|
|
169
|
+
{
|
|
170
|
+
title: '完成快速 Playwright 自测',
|
|
171
|
+
description: '关闭会话并写入 fast-check-result.json、Markdown、Trace 和截图索引。',
|
|
172
|
+
inputSchema: { sessionId: z.string().uuid() },
|
|
173
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }
|
|
174
|
+
},
|
|
175
|
+
async input => jsonResult(await finishFastCheck(input))
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
server.registerTool(
|
|
179
|
+
'abort_fast_check',
|
|
180
|
+
{
|
|
181
|
+
title: '放弃快速 Playwright 自测',
|
|
182
|
+
description: '关闭快速自测浏览器且不生成完成证据。',
|
|
183
|
+
inputSchema: { sessionId: z.string().uuid() },
|
|
184
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }
|
|
185
|
+
},
|
|
186
|
+
async input => jsonResult(await abortFastCheck(input))
|
|
187
|
+
);
|
|
188
|
+
|
|
24
189
|
server.registerTool(
|
|
25
190
|
'check_environment',
|
|
26
191
|
{
|
|
@@ -61,12 +226,13 @@ server.registerTool(
|
|
|
61
226
|
{
|
|
62
227
|
title: '执行冻结的功能 E2E 场景',
|
|
63
228
|
description:
|
|
64
|
-
'使用插件自带的 Playwright
|
|
229
|
+
'使用插件自带的 Playwright 运行器执行无依赖冻结场景;显式校验 real/mock API 模式,scene 隔离可在单个场景阻塞后继续执行其余场景,并在功能目录下写入结构化证据。',
|
|
65
230
|
inputSchema: {
|
|
66
231
|
workspaceRoot: z.string().min(1),
|
|
67
232
|
scenarioPath: z.string().min(1),
|
|
68
233
|
baseUrl: z.string().url(),
|
|
69
234
|
outputDir: z.string().min(1),
|
|
235
|
+
apiMode: z.enum(['real', 'mock']).optional(),
|
|
70
236
|
iteration: z.number().int().positive().default(1),
|
|
71
237
|
timeoutMs: z.number().int().min(1000).max(900000).default(300000),
|
|
72
238
|
headed: z.boolean().default(false),
|
|
@@ -79,6 +245,8 @@ server.registerTool(
|
|
|
79
245
|
scenarioPath: z.string(),
|
|
80
246
|
scenarioSha256: z.string().nullable(),
|
|
81
247
|
metadata: z.record(z.string(), z.unknown()),
|
|
248
|
+
apiMode: z.enum(['real', 'mock']).nullable(),
|
|
249
|
+
apiCalls: z.array(z.record(z.string(), z.unknown())),
|
|
82
250
|
checks: z.array(z.record(z.string(), z.unknown())),
|
|
83
251
|
captures: z.array(z.string()),
|
|
84
252
|
tracePath: z.string().nullable(),
|
|
@@ -119,6 +287,8 @@ server.registerTool(
|
|
|
119
287
|
scenarioPath: input.scenarioPath,
|
|
120
288
|
scenarioSha256: null,
|
|
121
289
|
metadata: {},
|
|
290
|
+
apiMode: input.apiMode || null,
|
|
291
|
+
apiCalls: [],
|
|
122
292
|
checks: [],
|
|
123
293
|
captures: [],
|
|
124
294
|
tracePath: null,
|
|
@@ -193,7 +363,7 @@ await server.connect(transport);
|
|
|
193
363
|
|
|
194
364
|
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
195
365
|
process.once(signal, async () => {
|
|
196
|
-
await closeAllBlockedRuns();
|
|
366
|
+
await Promise.all([closeAllBlockedRuns(), closeAllFastChecks()]);
|
|
197
367
|
process.exit(0);
|
|
198
368
|
});
|
|
199
369
|
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
abortFastCheck,
|
|
9
|
+
activeFastCheckCount,
|
|
10
|
+
captureFastCheck,
|
|
11
|
+
fastBrowserAction,
|
|
12
|
+
fastBrowserCheck,
|
|
13
|
+
fastBrowserSnapshot,
|
|
14
|
+
finishFastCheck,
|
|
15
|
+
mockFastCheckRoute,
|
|
16
|
+
startFastCheck
|
|
17
|
+
} from '../src/fast-runner.mjs';
|
|
18
|
+
import { startFixtureServer } from './fixture-server.mjs';
|
|
19
|
+
|
|
20
|
+
const report = {
|
|
21
|
+
reportModule: '交付测试页',
|
|
22
|
+
reportGroup: 'reveal-result',
|
|
23
|
+
reportTitle: '结果展示',
|
|
24
|
+
reportMethod: '通过交互式 Playwright 点击按钮并检查结果文案。'
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
test('交互式快速自测生成检查、截图、Trace 和结果文件,不生成场景代码', async t => {
|
|
28
|
+
const fixture = await startFixtureServer();
|
|
29
|
+
t.after(fixture.close);
|
|
30
|
+
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'autobest-fast-check-'));
|
|
31
|
+
t.after(() => fs.rm(workspaceRoot, { recursive: true, force: true }));
|
|
32
|
+
|
|
33
|
+
const started = await startFastCheck({
|
|
34
|
+
workspaceRoot,
|
|
35
|
+
baseUrl: fixture.baseUrl,
|
|
36
|
+
outputDir: 'feature/fast-check/runs/iteration-01',
|
|
37
|
+
iteration: 1
|
|
38
|
+
});
|
|
39
|
+
assert.equal(activeFastCheckCount(), 1);
|
|
40
|
+
|
|
41
|
+
const snapshot = await fastBrowserSnapshot({ sessionId: started.sessionId });
|
|
42
|
+
assert.match(snapshot.snapshot, /显示结果/);
|
|
43
|
+
|
|
44
|
+
const click = await fastBrowserAction({
|
|
45
|
+
sessionId: started.sessionId,
|
|
46
|
+
action: 'click',
|
|
47
|
+
locator: '#reveal'
|
|
48
|
+
});
|
|
49
|
+
assert.equal(click.success, true);
|
|
50
|
+
|
|
51
|
+
const checked = await fastBrowserCheck({
|
|
52
|
+
sessionId: started.sessionId,
|
|
53
|
+
id: 'story-01',
|
|
54
|
+
...report,
|
|
55
|
+
specSnippet: '点击按钮后显示运行结果。',
|
|
56
|
+
expect: '结果区域显示“运行器运行正常”',
|
|
57
|
+
checkType: 'text',
|
|
58
|
+
locator: '#result',
|
|
59
|
+
expected: '运行器运行正常',
|
|
60
|
+
timeoutMs: 10000
|
|
61
|
+
});
|
|
62
|
+
assert.equal(checked.check.isPass, true);
|
|
63
|
+
|
|
64
|
+
const captured = await captureFastCheck({
|
|
65
|
+
sessionId: started.sessionId,
|
|
66
|
+
...report,
|
|
67
|
+
capture: 'result-desktop.png',
|
|
68
|
+
locator: 'main',
|
|
69
|
+
reportDevice: 'desktop'
|
|
70
|
+
});
|
|
71
|
+
assert.match(captured.capture.path, /result-desktop\.png$/);
|
|
72
|
+
|
|
73
|
+
const result = await finishFastCheck({ sessionId: started.sessionId });
|
|
74
|
+
assert.equal(result.status, 'passed');
|
|
75
|
+
assert.equal(result.evidenceSource, 'interactive-playwright');
|
|
76
|
+
assert.equal(result.scenarioPath, null);
|
|
77
|
+
assert.equal(result.scenarioSha256, null);
|
|
78
|
+
assert.equal(result.checks.length, 1);
|
|
79
|
+
assert.equal(result.captureRecords.length, 1);
|
|
80
|
+
assert.equal(activeFastCheckCount(), 0);
|
|
81
|
+
await assert.rejects(
|
|
82
|
+
fastBrowserSnapshot({ sessionId: started.sessionId }),
|
|
83
|
+
/会话不存在或已结束/
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const resultPath = path.join(workspaceRoot, result.resultPath);
|
|
87
|
+
const persisted = JSON.parse(await fs.readFile(resultPath, 'utf8'));
|
|
88
|
+
assert.equal(persisted.status, 'passed');
|
|
89
|
+
await fs.access(path.join(workspaceRoot, result.captureRecords[0].path));
|
|
90
|
+
await fs.access(path.join(workspaceRoot, result.tracePath));
|
|
91
|
+
await fs.access(path.join(workspaceRoot, result.markdownPath));
|
|
92
|
+
await assert.rejects(fs.access(path.join(workspaceRoot, 'feature', 'e2e', 'e2e.feature.mjs')));
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('快速自测失败会记录结果,会话也可以显式放弃', async t => {
|
|
96
|
+
const fixture = await startFixtureServer();
|
|
97
|
+
t.after(fixture.close);
|
|
98
|
+
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'autobest-fast-check-failed-'));
|
|
99
|
+
t.after(() => fs.rm(workspaceRoot, { recursive: true, force: true }));
|
|
100
|
+
|
|
101
|
+
const failedSession = await startFastCheck({
|
|
102
|
+
workspaceRoot,
|
|
103
|
+
baseUrl: fixture.baseUrl,
|
|
104
|
+
outputDir: 'feature/fast-check/runs/iteration-01'
|
|
105
|
+
});
|
|
106
|
+
await fastBrowserCheck({
|
|
107
|
+
sessionId: failedSession.sessionId,
|
|
108
|
+
id: 'story-01',
|
|
109
|
+
...report,
|
|
110
|
+
specSnippet: '结果应立即显示。',
|
|
111
|
+
expect: '结果文案可见',
|
|
112
|
+
checkType: 'text',
|
|
113
|
+
locator: '#result',
|
|
114
|
+
expected: '不存在的结果',
|
|
115
|
+
timeoutMs: 1000
|
|
116
|
+
});
|
|
117
|
+
await captureFastCheck({
|
|
118
|
+
sessionId: failedSession.sessionId,
|
|
119
|
+
...report,
|
|
120
|
+
capture: 'failed-desktop.png',
|
|
121
|
+
reportDevice: 'desktop'
|
|
122
|
+
});
|
|
123
|
+
assert.equal((await finishFastCheck({ sessionId: failedSession.sessionId })).status, 'failed');
|
|
124
|
+
|
|
125
|
+
const abortedSession = await startFastCheck({
|
|
126
|
+
workspaceRoot,
|
|
127
|
+
baseUrl: fixture.baseUrl,
|
|
128
|
+
outputDir: 'feature/fast-check/runs/iteration-02'
|
|
129
|
+
});
|
|
130
|
+
assert.deepEqual(await abortFastCheck({ sessionId: abortedSession.sessionId }), {
|
|
131
|
+
status: 'aborted',
|
|
132
|
+
sessionId: abortedSession.sessionId
|
|
133
|
+
});
|
|
134
|
+
assert.equal(activeFastCheckCount(), 0);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('快速自测拒绝越界输出,且只有 mock 模式允许安装响应', async t => {
|
|
138
|
+
const fixture = await startFixtureServer();
|
|
139
|
+
t.after(fixture.close);
|
|
140
|
+
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'autobest-fast-check-policy-'));
|
|
141
|
+
t.after(() => fs.rm(workspaceRoot, { recursive: true, force: true }));
|
|
142
|
+
|
|
143
|
+
await assert.rejects(
|
|
144
|
+
startFastCheck({
|
|
145
|
+
workspaceRoot,
|
|
146
|
+
baseUrl: fixture.baseUrl,
|
|
147
|
+
outputDir: '../outside'
|
|
148
|
+
}),
|
|
149
|
+
/outputDir 必须位于 workspaceRoot 内/
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const realSession = await startFastCheck({
|
|
153
|
+
workspaceRoot,
|
|
154
|
+
baseUrl: fixture.baseUrl,
|
|
155
|
+
outputDir: 'feature/fast-check/runs/iteration-01',
|
|
156
|
+
apiMode: 'real'
|
|
157
|
+
});
|
|
158
|
+
await assert.rejects(
|
|
159
|
+
mockFastCheckRoute({
|
|
160
|
+
sessionId: realSession.sessionId,
|
|
161
|
+
pathname: '/api/status'
|
|
162
|
+
}),
|
|
163
|
+
/只有 mock 模式可以安装请求响应/
|
|
164
|
+
);
|
|
165
|
+
await abortFastCheck({ sessionId: realSession.sessionId });
|
|
166
|
+
|
|
167
|
+
const mockSession = await startFastCheck({
|
|
168
|
+
workspaceRoot,
|
|
169
|
+
baseUrl: fixture.baseUrl,
|
|
170
|
+
outputDir: 'feature/fast-check/runs/iteration-02',
|
|
171
|
+
apiMode: 'mock'
|
|
172
|
+
});
|
|
173
|
+
assert.deepEqual(
|
|
174
|
+
await mockFastCheckRoute({
|
|
175
|
+
sessionId: mockSession.sessionId,
|
|
176
|
+
pathname: '/api/status',
|
|
177
|
+
body: { source: 'mock' }
|
|
178
|
+
}),
|
|
179
|
+
{
|
|
180
|
+
sessionId: mockSession.sessionId,
|
|
181
|
+
method: 'GET',
|
|
182
|
+
pathname: '/api/status',
|
|
183
|
+
status: 200,
|
|
184
|
+
success: true
|
|
185
|
+
}
|
|
186
|
+
);
|
|
187
|
+
await abortFastCheck({ sessionId: mockSession.sessionId });
|
|
188
|
+
assert.equal(activeFastCheckCount(), 0);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('追踪需求或报告功能组缺少证据时快速自测不能通过', async t => {
|
|
192
|
+
const fixture = await startFixtureServer();
|
|
193
|
+
t.after(fixture.close);
|
|
194
|
+
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'autobest-fast-check-coverage-'));
|
|
195
|
+
t.after(() => fs.rm(workspaceRoot, { recursive: true, force: true }));
|
|
196
|
+
|
|
197
|
+
const started = await startFastCheck({
|
|
198
|
+
workspaceRoot,
|
|
199
|
+
baseUrl: fixture.baseUrl,
|
|
200
|
+
outputDir: 'feature/fast-check/runs/iteration-01',
|
|
201
|
+
requirementIds: ['REQ-PAGE-001', 'REQ-PAGE-002']
|
|
202
|
+
});
|
|
203
|
+
await fastBrowserCheck({
|
|
204
|
+
sessionId: started.sessionId,
|
|
205
|
+
id: 'req-page-001-heading',
|
|
206
|
+
requirementId: 'REQ-PAGE-001',
|
|
207
|
+
...report,
|
|
208
|
+
reportGroup: 'page-heading',
|
|
209
|
+
reportTitle: '页面标题',
|
|
210
|
+
specSnippet: '页面显示标题。',
|
|
211
|
+
expect: '标题可见',
|
|
212
|
+
checkType: 'visible',
|
|
213
|
+
locator: 'h1'
|
|
214
|
+
});
|
|
215
|
+
await captureFastCheck({
|
|
216
|
+
sessionId: started.sessionId,
|
|
217
|
+
requirementId: 'REQ-PAGE-001',
|
|
218
|
+
...report,
|
|
219
|
+
reportGroup: 'other-group',
|
|
220
|
+
capture: 'other-group-desktop.png',
|
|
221
|
+
reportDevice: 'desktop'
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const result = await finishFastCheck({ sessionId: started.sessionId });
|
|
225
|
+
assert.equal(result.status, 'blocked');
|
|
226
|
+
assert.ok(result.blockers.some(blocker =>
|
|
227
|
+
blocker.kind === 'traceability' && blocker.detail.includes('REQ-PAGE-002')
|
|
228
|
+
));
|
|
229
|
+
assert.ok(result.blockers.some(blocker =>
|
|
230
|
+
blocker.kind === 'report-capture' && blocker.detail.includes('page-heading')
|
|
231
|
+
));
|
|
232
|
+
});
|