@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.
Files changed (32) hide show
  1. package/bin/sync-assets.test.mjs +2 -0
  2. package/mcp/code-mcp-pr/README.md +3 -2
  3. package/mcp/code-mcp-pr/azure-devops.js +81 -2
  4. package/mcp/code-mcp-pr/index.js +34 -1
  5. package/mcp/code-mcp-pr/index.test.js +161 -11
  6. package/package.json +1 -1
  7. package/plugins/autobest-delivery/.codex-plugin/plugin.json +1 -0
  8. package/plugins/autobest-delivery/README.md +52 -10
  9. package/plugins/autobest-delivery/mcp-server/package.json +1 -1
  10. package/plugins/autobest-delivery/mcp-server/src/fast-runner.mjs +470 -0
  11. package/plugins/autobest-delivery/mcp-server/src/paths.mjs +12 -7
  12. package/plugins/autobest-delivery/mcp-server/src/report.mjs +118 -16
  13. package/plugins/autobest-delivery/mcp-server/src/runner.mjs +163 -1
  14. package/plugins/autobest-delivery/mcp-server/src/server.mjs +173 -3
  15. package/plugins/autobest-delivery/mcp-server/tests/fast-runner.test.mjs +232 -0
  16. package/plugins/autobest-delivery/mcp-server/tests/fixture-server.mjs +5 -0
  17. package/plugins/autobest-delivery/mcp-server/tests/mcp-smoke.test.mjs +71 -0
  18. package/plugins/autobest-delivery/mcp-server/tests/report.test.mjs +107 -1
  19. package/plugins/autobest-delivery/mcp-server/tests/runner.test.mjs +279 -0
  20. package/plugins/autobest-delivery/skills/code-audit/SKILL.md +3 -3
  21. package/plugins/autobest-delivery/skills/delivery-loop/SKILL.md +10 -7
  22. package/plugins/autobest-delivery/skills/delivery-loop/references/delivery-contract.md +63 -6
  23. package/plugins/autobest-delivery/skills/delivery-loop-fast/SKILL.md +62 -0
  24. package/plugins/autobest-delivery/skills/delivery-loop-fast/agents/openai.yaml +7 -0
  25. package/plugins/autobest-delivery/skills/e2e-gen-spec/SKILL.md +7 -6
  26. package/plugins/autobest-delivery/skills/e2e-ui-checker/SKILL.md +5 -4
  27. package/plugins/autobest-delivery/skills/export-report/SKILL.md +6 -6
  28. package/plugins/autobest-delivery/skills/export-report/agents/openai.yaml +1 -2
  29. package/skills/README.md +1 -0
  30. package/skills/common/code-pr-submit/SKILL.md +25 -4
  31. package/skills/common/repo-setup/SKILL.md +91 -0
  32. 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) => currentPage.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
- '调用 run_feature_e2e 前先调用 check_environment。headed 运行在 Blocked 时可保留浏览器,随后通过 resolve_blocked_run 记录人工决议并关闭会话。'
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 运行器执行无依赖冻结场景;scene 隔离可在单个场景阻塞后继续执行其余场景,并在功能目录下写入结构化证据。',
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
+ });