@autobest-ui/agent 1.0.10 → 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 (26) 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 +25 -4
  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 +57 -12
  13. package/plugins/autobest-delivery/mcp-server/src/server.mjs +167 -2
  14. package/plugins/autobest-delivery/mcp-server/tests/fast-runner.test.mjs +232 -0
  15. package/plugins/autobest-delivery/mcp-server/tests/mcp-smoke.test.mjs +71 -0
  16. package/plugins/autobest-delivery/mcp-server/tests/report.test.mjs +69 -1
  17. package/plugins/autobest-delivery/skills/code-audit/SKILL.md +3 -3
  18. package/plugins/autobest-delivery/skills/delivery-loop/references/delivery-contract.md +11 -0
  19. package/plugins/autobest-delivery/skills/delivery-loop-fast/SKILL.md +62 -0
  20. package/plugins/autobest-delivery/skills/delivery-loop-fast/agents/openai.yaml +7 -0
  21. package/plugins/autobest-delivery/skills/export-report/SKILL.md +3 -3
  22. package/plugins/autobest-delivery/skills/export-report/agents/openai.yaml +1 -2
  23. package/skills/README.md +1 -0
  24. package/skills/common/code-pr-submit/SKILL.md +25 -4
  25. package/skills/common/repo-setup/SKILL.md +91 -0
  26. package/skills/common/repo-setup/agents/openai.yaml +4 -0
@@ -0,0 +1,470 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ import { resolveArtifactPath, resolveWorkspaceOutputPaths } from './paths.mjs';
7
+
8
+ const fastSessions = new Map();
9
+ const requirementIdPattern = /^REQ-[A-Z][A-Z0-9]{1,15}-\d{3}$/;
10
+ const reportGroupPattern = /^[a-z0-9][a-z0-9._-]*$/;
11
+ const captureNamePattern = /^[a-zA-Z0-9][a-zA-Z0-9._-]*\.png$/;
12
+ const defaultBrowserRoot = path.resolve(
13
+ path.dirname(fileURLToPath(import.meta.url)),
14
+ '..',
15
+ '.runtime',
16
+ 'ms-playwright'
17
+ );
18
+ process.env.PLAYWRIGHT_BROWSERS_PATH =
19
+ process.env.AUTOBEST_DELIVERY_BROWSERS_PATH || defaultBrowserRoot;
20
+
21
+ async function loadPlaywright() {
22
+ return import('@playwright/test');
23
+ }
24
+
25
+ function errorDetail(error) {
26
+ return error instanceof Error ? error.message : String(error);
27
+ }
28
+
29
+ function toEvidencePath(root, absolutePath) {
30
+ return path.relative(root, absolutePath).split(path.sep).join('/');
31
+ }
32
+
33
+ async function sha256(filePath) {
34
+ const content = await fs.readFile(filePath);
35
+ return crypto.createHash('sha256').update(content).digest('hex');
36
+ }
37
+
38
+ async function writeJson(filePath, value) {
39
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
40
+ await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
41
+ }
42
+
43
+ function requireSession(sessionId) {
44
+ const session = fastSessions.get(sessionId);
45
+ if (!session) throw new Error(`快速验收会话不存在或已结束:${sessionId}`);
46
+ return session;
47
+ }
48
+
49
+ function validateReportFields(input) {
50
+ for (const field of ['reportModule', 'reportGroup', 'reportTitle', 'reportMethod']) {
51
+ if (typeof input[field] !== 'string' || !input[field].trim()) {
52
+ throw new TypeError(`${field} 必须是非空字符串`);
53
+ }
54
+ }
55
+ if (!reportGroupPattern.test(input.reportGroup)) {
56
+ throw new TypeError('reportGroup 必须是稳定的 ASCII ID');
57
+ }
58
+ if (input.requirementId && !requirementIdPattern.test(input.requirementId)) {
59
+ throw new TypeError(`无效需求 ID:${input.requirementId}`);
60
+ }
61
+ }
62
+
63
+ function validateRequirement(session, requirementId) {
64
+ if (!requirementId || session.requirementIds.size === 0) return;
65
+ if (!session.requirementIds.has(requirementId)) {
66
+ throw new TypeError(`引用了未声明的需求 ID:${requirementId}`);
67
+ }
68
+ }
69
+
70
+ async function closeSession(session) {
71
+ clearTimeout(session.timeoutId);
72
+ await session.context?.close().catch(() => undefined);
73
+ await session.browser?.close().catch(() => undefined);
74
+ }
75
+
76
+ function installPageEvidence(session, page) {
77
+ page.on('console', message => {
78
+ if (message.type() === 'error') session.consoleErrors.push(message.text());
79
+ });
80
+ page.on('requestfailed', request => {
81
+ session.networkFailures.push({
82
+ url: request.url(),
83
+ method: request.method(),
84
+ errorText: request.failure()?.errorText || 'unknown'
85
+ });
86
+ });
87
+ page.on('response', response => {
88
+ const request = response.request();
89
+ session.apiCalls.push({
90
+ url: response.url(),
91
+ method: request.method(),
92
+ status: response.status()
93
+ });
94
+ });
95
+ }
96
+
97
+ export async function startFastCheck({
98
+ workspaceRoot,
99
+ baseUrl,
100
+ outputDir,
101
+ apiMode = 'none',
102
+ iteration = 1,
103
+ headed = false,
104
+ viewport = { width: 1400, height: 1000 },
105
+ requirementIds = [],
106
+ sessionTtlMs = 3600000
107
+ }) {
108
+ if (requirementIds.some(requirementId => !requirementIdPattern.test(requirementId))) {
109
+ throw new TypeError('requirementIds 包含格式错误的需求 ID');
110
+ }
111
+ if (new Set(requirementIds).size !== requirementIds.length) {
112
+ throw new TypeError('requirementIds 不得重复');
113
+ }
114
+ const { root, output } = await resolveWorkspaceOutputPaths({ workspaceRoot, outputDir });
115
+ const { chromium } = await loadPlaywright();
116
+ const browser = await chromium.launch({ headless: !headed });
117
+ const context = await browser.newContext({ viewport });
118
+ await context.tracing.start({ screenshots: true, snapshots: true, sources: false });
119
+ const page = await context.newPage();
120
+ const sessionId = crypto.randomUUID();
121
+ const session = {
122
+ sessionId,
123
+ root,
124
+ output,
125
+ baseUrl,
126
+ apiMode,
127
+ iteration,
128
+ headed,
129
+ viewport,
130
+ browser,
131
+ context,
132
+ page,
133
+ requirementIds: new Set(requirementIds),
134
+ checks: [],
135
+ captures: [],
136
+ actions: [],
137
+ apiCalls: [],
138
+ consoleErrors: [],
139
+ networkFailures: [],
140
+ blockers: [],
141
+ startedAt: new Date().toISOString()
142
+ };
143
+ installPageEvidence(session, page);
144
+ session.timeoutId = setTimeout(async () => {
145
+ fastSessions.delete(sessionId);
146
+ await closeSession(session);
147
+ }, sessionTtlMs);
148
+ session.timeoutId.unref?.();
149
+ fastSessions.set(sessionId, session);
150
+ try {
151
+ await page.goto(baseUrl);
152
+ session.actions.push({ action: 'navigate', url: page.url(), success: true });
153
+ } catch (error) {
154
+ session.actions.push({ action: 'navigate', url: baseUrl, success: false, error: errorDetail(error) });
155
+ }
156
+ return {
157
+ sessionId,
158
+ url: page.url(),
159
+ title: await page.title().catch(() => ''),
160
+ browserMode: headed ? 'headed' : 'headless',
161
+ outputDir: toEvidencePath(root, output),
162
+ expiresInMs: sessionTtlMs
163
+ };
164
+ }
165
+
166
+ export async function fastBrowserSnapshot({ sessionId, timeoutMs = 10000 }) {
167
+ const session = requireSession(sessionId);
168
+ const snapshot = await session.page.locator('body').ariaSnapshot({ timeout: timeoutMs });
169
+ return {
170
+ sessionId,
171
+ url: session.page.url(),
172
+ title: await session.page.title(),
173
+ snapshot: snapshot.slice(0, 30000)
174
+ };
175
+ }
176
+
177
+ export async function fastBrowserAction({
178
+ sessionId,
179
+ action,
180
+ locator,
181
+ value,
182
+ url,
183
+ width,
184
+ height,
185
+ timeoutMs = 10000
186
+ }) {
187
+ const session = requireSession(sessionId);
188
+ const page = session.page;
189
+ try {
190
+ if (['click', 'fill', 'select', 'press', 'wait'].includes(action) && !locator) {
191
+ throw new TypeError(`${action} 操作必须提供 locator`);
192
+ }
193
+ if (action === 'viewport' && (!width || !height)) {
194
+ throw new TypeError('viewport 操作必须提供 width 和 height');
195
+ }
196
+ if (action === 'navigate') {
197
+ await page.goto(new URL(url || session.baseUrl, session.baseUrl).toString(), { timeout: timeoutMs });
198
+ } else if (action === 'click') {
199
+ await page.locator(locator).click({ timeout: timeoutMs });
200
+ } else if (action === 'fill') {
201
+ await page.locator(locator).fill(value ?? '', { timeout: timeoutMs });
202
+ } else if (action === 'select') {
203
+ await page.locator(locator).selectOption(value ?? '', { timeout: timeoutMs });
204
+ } else if (action === 'press') {
205
+ await page.locator(locator).press(value ?? '', { timeout: timeoutMs });
206
+ } else if (action === 'wait') {
207
+ await page.locator(locator).waitFor({ state: 'visible', timeout: timeoutMs });
208
+ } else if (action === 'viewport') {
209
+ await page.setViewportSize({ width, height });
210
+ session.viewport = { width, height };
211
+ } else {
212
+ throw new TypeError(`不支持的快速浏览器操作:${action}`);
213
+ }
214
+ const result = { action, locator: locator || null, value: value ?? null, url: page.url(), success: true };
215
+ session.actions.push(result);
216
+ return { sessionId, ...result };
217
+ } catch (error) {
218
+ const result = {
219
+ action,
220
+ locator: locator || null,
221
+ value: value ?? null,
222
+ url: page.url(),
223
+ success: false,
224
+ error: errorDetail(error)
225
+ };
226
+ session.actions.push(result);
227
+ return { sessionId, ...result };
228
+ }
229
+ }
230
+
231
+ export async function mockFastCheckRoute({ sessionId, method = 'GET', pathname, status = 200, body = {} }) {
232
+ const session = requireSession(sessionId);
233
+ if (session.apiMode !== 'mock') throw new Error('只有 mock 模式可以安装请求响应');
234
+ await session.page.route(`**${pathname}`, async route => {
235
+ if (route.request().method() !== method) return route.fallback();
236
+ await route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) });
237
+ });
238
+ return { sessionId, method, pathname, status, success: true };
239
+ }
240
+
241
+ async function evaluateCheck(session, input) {
242
+ const page = session.page;
243
+ const target = input.locator ? page.locator(input.locator) : null;
244
+ if (input.checkType !== 'url' && !target) {
245
+ throw new TypeError(`${input.checkType} 检查必须提供 locator`);
246
+ }
247
+ if (['text', 'value', 'url', 'attribute', 'css'].includes(input.checkType) && typeof input.expected !== 'string') {
248
+ throw new TypeError(`${input.checkType} 检查必须提供 expected`);
249
+ }
250
+ if (input.checkType === 'count' && !Number.isInteger(input.expectedNumber)) {
251
+ throw new TypeError('count 检查必须提供 expectedNumber');
252
+ }
253
+ if (['attribute', 'css'].includes(input.checkType) && !input.property) {
254
+ throw new TypeError(`${input.checkType} 检查必须提供 property`);
255
+ }
256
+ if (input.checkType === 'visible') return target.isVisible({ timeout: input.timeoutMs });
257
+ if (input.checkType === 'hidden') return !(await target.isVisible({ timeout: input.timeoutMs }));
258
+ if (input.checkType === 'text') {
259
+ const actual = (await target.textContent({ timeout: input.timeoutMs })) || '';
260
+ return { actual, isPass: input.exact ? actual.trim() === input.expected : actual.includes(input.expected) };
261
+ }
262
+ if (input.checkType === 'value') {
263
+ const actual = await target.inputValue({ timeout: input.timeoutMs });
264
+ return { actual, isPass: actual === input.expected };
265
+ }
266
+ if (input.checkType === 'count') {
267
+ const actual = await target.count();
268
+ return { actual, isPass: actual === input.expectedNumber };
269
+ }
270
+ if (input.checkType === 'url') {
271
+ const actual = page.url();
272
+ return { actual, isPass: input.exact ? actual === input.expected : actual.includes(input.expected) };
273
+ }
274
+ if (input.checkType === 'attribute') {
275
+ const actual = await target.getAttribute(input.property, { timeout: input.timeoutMs });
276
+ return { actual, isPass: actual === input.expected };
277
+ }
278
+ if (input.checkType === 'css') {
279
+ const actual = await target.evaluate((element, property) => getComputedStyle(element).getPropertyValue(property), input.property);
280
+ return { actual, isPass: actual.trim() === input.expected };
281
+ }
282
+ throw new TypeError(`不支持的检查类型:${input.checkType}`);
283
+ }
284
+
285
+ export async function fastBrowserCheck(input) {
286
+ const session = requireSession(input.sessionId);
287
+ validateReportFields(input);
288
+ validateRequirement(session, input.requirementId);
289
+ if (session.checks.some(check => check.id === input.id)) {
290
+ throw new TypeError(`检查 ID 不得重复:${input.id}`);
291
+ }
292
+ let actual;
293
+ let isPass;
294
+ try {
295
+ const evaluated = await evaluateCheck(session, input);
296
+ if (typeof evaluated === 'boolean') {
297
+ actual = evaluated ? '条件满足' : '条件不满足';
298
+ isPass = evaluated;
299
+ } else {
300
+ actual = evaluated.actual;
301
+ isPass = evaluated.isPass;
302
+ }
303
+ } catch (error) {
304
+ actual = errorDetail(error);
305
+ isPass = false;
306
+ }
307
+ const check = {
308
+ id: input.id,
309
+ requirementId: input.requirementId || null,
310
+ specSnippet: input.specSnippet,
311
+ reportModule: input.reportModule,
312
+ reportGroup: input.reportGroup,
313
+ reportTitle: input.reportTitle,
314
+ reportMethod: input.reportMethod,
315
+ checkType: input.checkType,
316
+ locator: input.locator || null,
317
+ expect: input.expect,
318
+ actual: actual === null || actual === undefined ? '' : String(actual),
319
+ isPass
320
+ };
321
+ session.checks.push(check);
322
+ return { sessionId: input.sessionId, check };
323
+ }
324
+
325
+ export async function captureFastCheck(input) {
326
+ const session = requireSession(input.sessionId);
327
+ validateReportFields(input);
328
+ validateRequirement(session, input.requirementId);
329
+ if (!captureNamePattern.test(input.capture)) {
330
+ throw new TypeError('capture 必须是简单的 PNG 文件名');
331
+ }
332
+ if (!['desktop', 'mobile'].includes(input.reportDevice)) {
333
+ throw new TypeError('reportDevice 必须是 desktop 或 mobile');
334
+ }
335
+ if (session.captures.some(capture => capture.capture === input.capture)) {
336
+ throw new TypeError(`截图文件名不得重复:${input.capture}`);
337
+ }
338
+ const capturePath = resolveArtifactPath(session.output, path.join('captures', input.capture));
339
+ await fs.mkdir(path.dirname(capturePath), { recursive: true });
340
+ if (input.locator) {
341
+ await session.page.locator(input.locator).screenshot({ path: capturePath });
342
+ } else {
343
+ await session.page.screenshot({ path: capturePath, fullPage: input.fullPage !== false });
344
+ }
345
+ const evidencePath = toEvidencePath(session.root, capturePath);
346
+ const capture = {
347
+ requirementId: input.requirementId || null,
348
+ capture: input.capture,
349
+ locator: input.locator || 'page',
350
+ reportModule: input.reportModule,
351
+ reportGroup: input.reportGroup,
352
+ reportTitle: input.reportTitle,
353
+ reportMethod: input.reportMethod,
354
+ reportDevice: input.reportDevice,
355
+ path: evidencePath,
356
+ sha256: await sha256(capturePath)
357
+ };
358
+ session.captures.push(capture);
359
+ return { sessionId: input.sessionId, capture };
360
+ }
361
+
362
+ function fastResultMarkdown(result) {
363
+ const lines = [
364
+ '# 快速 Playwright 自测结果',
365
+ '',
366
+ `- 状态:${result.status}`,
367
+ `- 证据来源:交互式 Playwright 自测`,
368
+ `- 迭代:${result.iteration}`,
369
+ `- API 模式:${result.apiMode}`,
370
+ '',
371
+ '## 检查结果',
372
+ '',
373
+ '| 检查 | 预期 | 实际 | 状态 |',
374
+ '| --- | --- | --- | --- |',
375
+ ...result.checks.map(check =>
376
+ `| ${check.reportTitle} | ${check.expect} | ${String(check.actual).replaceAll('|', '\\|')} | ${check.isPass ? '通过' : '不通过'} |`
377
+ ),
378
+ '',
379
+ '## 截图',
380
+ '',
381
+ ...result.captureRecords.map(capture => `- ${capture.reportTitle}(${capture.reportDevice}):${capture.path}`),
382
+ ''
383
+ ];
384
+ return lines.join('\n');
385
+ }
386
+
387
+ export async function finishFastCheck({ sessionId }) {
388
+ const session = requireSession(sessionId);
389
+ fastSessions.delete(sessionId);
390
+ const tracePath = resolveArtifactPath(session.output, 'trace.zip');
391
+ try {
392
+ await session.context.tracing.stop({ path: tracePath });
393
+ } catch (error) {
394
+ session.blockers.push({ kind: 'trace', detail: errorDetail(error) });
395
+ }
396
+ if (session.checks.length === 0) session.blockers.push({ kind: 'evidence', detail: '没有记录检查项' });
397
+ if (session.captures.length === 0) session.blockers.push({ kind: 'evidence', detail: '没有记录报告截图' });
398
+ const coveredRequirementIds = new Set(
399
+ session.checks.map(check => check.requirementId).filter(Boolean)
400
+ );
401
+ for (const requirementId of session.requirementIds) {
402
+ if (!coveredRequirementIds.has(requirementId)) {
403
+ session.blockers.push({ kind: 'traceability', detail: `需求 ${requirementId} 没有检查证据` });
404
+ }
405
+ }
406
+ const capturedGroups = new Set(session.captures.map(capture => capture.reportGroup));
407
+ for (const reportGroup of new Set(session.checks.map(check => check.reportGroup))) {
408
+ if (!capturedGroups.has(reportGroup)) {
409
+ session.blockers.push({ kind: 'report-capture', detail: `报告功能组 ${reportGroup} 没有截图` });
410
+ }
411
+ }
412
+ const status = session.blockers.length > 0
413
+ ? 'blocked'
414
+ : session.checks.some(check => !check.isPass) ? 'failed' : 'passed';
415
+ const resultPath = resolveArtifactPath(session.output, 'fast-check-result.json');
416
+ const markdownPath = resolveArtifactPath(session.output, 'fast-check-result.md');
417
+ const result = {
418
+ evidenceSchemaVersion: 1,
419
+ evidenceSource: 'interactive-playwright',
420
+ status,
421
+ iteration: session.iteration,
422
+ scenarioPath: null,
423
+ scenarioSha256: null,
424
+ metadata: {
425
+ apiMode: session.apiMode,
426
+ requirementIds: [...session.requirementIds],
427
+ reportCaptures: session.captures.map(({ path: _path, sha256: _sha256, ...capture }) => capture),
428
+ visualMappings: []
429
+ },
430
+ apiMode: session.apiMode,
431
+ apiCalls: session.apiCalls,
432
+ checks: session.checks,
433
+ captures: session.captures.map(capture => capture.path),
434
+ captureRecords: session.captures,
435
+ actions: session.actions,
436
+ tracePath: session.blockers.some(blocker => blocker.kind === 'trace') ? null : toEvidencePath(session.root, tracePath),
437
+ consoleErrors: session.consoleErrors,
438
+ networkFailures: session.networkFailures,
439
+ blockers: session.blockers,
440
+ browserMode: session.headed ? 'headed' : 'headless',
441
+ startedAt: session.startedAt,
442
+ finishedAt: new Date().toISOString(),
443
+ resultPath: toEvidencePath(session.root, resultPath)
444
+ };
445
+ try {
446
+ await writeJson(resultPath, result);
447
+ await fs.writeFile(markdownPath, fastResultMarkdown(result), 'utf8');
448
+ return { ...result, markdownPath: toEvidencePath(session.root, markdownPath) };
449
+ } finally {
450
+ await closeSession(session);
451
+ }
452
+ }
453
+
454
+ export async function abortFastCheck({ sessionId }) {
455
+ const session = fastSessions.get(sessionId);
456
+ if (!session) return { status: 'not-found', sessionId };
457
+ fastSessions.delete(sessionId);
458
+ await closeSession(session);
459
+ return { status: 'aborted', sessionId };
460
+ }
461
+
462
+ export async function closeAllFastChecks() {
463
+ const sessions = [...fastSessions.values()];
464
+ fastSessions.clear();
465
+ await Promise.all(sessions.map(closeSession));
466
+ }
467
+
468
+ export function activeFastCheckCount() {
469
+ return fastSessions.size;
470
+ }
@@ -13,14 +13,9 @@ function isInside(root, target) {
13
13
  return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
14
14
  }
15
15
 
16
- export async function resolveWorkspacePaths({ workspaceRoot, scenarioPath, outputDir }) {
16
+ export async function resolveWorkspaceOutputPaths({ workspaceRoot, outputDir }) {
17
17
  const root = await fs.realpath(path.resolve(workspaceRoot));
18
- const scenario = await fs.realpath(path.resolve(root, scenarioPath));
19
18
  const output = path.resolve(root, outputDir);
20
-
21
- if (!isInside(root, scenario)) {
22
- throw new PathPolicyError('scenarioPath 必须位于 workspaceRoot 内');
23
- }
24
19
  if (!isInside(root, output)) {
25
20
  throw new PathPolicyError('outputDir 必须位于 workspaceRoot 内');
26
21
  }
@@ -30,8 +25,18 @@ export async function resolveWorkspacePaths({ workspaceRoot, scenarioPath, outpu
30
25
  if (!isInside(root, realOutput)) {
31
26
  throw new PathPolicyError('outputDir 解析后的路径位于 workspaceRoot 外');
32
27
  }
28
+ return { root, output: realOutput };
29
+ }
33
30
 
34
- return { root, scenario, output: realOutput };
31
+ export async function resolveWorkspacePaths({ workspaceRoot, scenarioPath, outputDir }) {
32
+ const root = await fs.realpath(path.resolve(workspaceRoot));
33
+ const scenario = await fs.realpath(path.resolve(root, scenarioPath));
34
+
35
+ if (!isInside(root, scenario)) {
36
+ throw new PathPolicyError('scenarioPath 必须位于 workspaceRoot 内');
37
+ }
38
+ const resolved = await resolveWorkspaceOutputPaths({ workspaceRoot: root, outputDir });
39
+ return { root, scenario, output: resolved.output };
35
40
  }
36
41
 
37
42
  export function resolveArtifactPath(outputDir, relativePath) {
@@ -135,6 +135,27 @@ async function findLatestRunnerResult(featureDir) {
135
135
  return null;
136
136
  }
137
137
 
138
+ async function findLatestFastCheckResult(featureDir) {
139
+ const runsDir = path.join(featureDir, 'fast-check', 'runs');
140
+ let entries;
141
+ try {
142
+ entries = await readdir(runsDir, { withFileTypes: true });
143
+ } catch {
144
+ return null;
145
+ }
146
+ const candidates = entries
147
+ .filter(entry => entry.isDirectory() && /^iteration-\d+$/.test(entry.name))
148
+ .map(entry => ({
149
+ iteration: Number(entry.name.slice('iteration-'.length)),
150
+ filePath: path.join(runsDir, entry.name, 'fast-check-result.json')
151
+ }))
152
+ .sort((left, right) => right.iteration - left.iteration);
153
+ for (const candidate of candidates) {
154
+ if (await fileExists(candidate.filePath)) return candidate.filePath;
155
+ }
156
+ return null;
157
+ }
158
+
138
159
  async function selectRunnerResult(checker, context) {
139
160
  if (checker.runnerResult) {
140
161
  const declared = resolveEvidencePath(checker.runnerResult, context);
@@ -249,7 +270,7 @@ function resolveApiMode(runner) {
249
270
  const resultMode = nonEmptyString(runner.apiMode);
250
271
  const metadataMode = nonEmptyString(runner.metadata?.apiMode);
251
272
  for (const mode of [resultMode, metadataMode].filter(Boolean)) {
252
- if (!['real', 'mock'].includes(mode)) {
273
+ if (!['real', 'mock', 'none'].includes(mode)) {
253
274
  throw new Error(`Runner 包含无效 apiMode:${mode}`);
254
275
  }
255
276
  }
@@ -267,6 +288,9 @@ function reportMethodWithApiMode(group, apiMode) {
267
288
  if (apiMode === 'mock') {
268
289
  return `API 数据:模拟 API。\n${method}`;
269
290
  }
291
+ if (apiMode === 'none') {
292
+ return `API 数据:本次未指定业务 API。\n${method}`;
293
+ }
270
294
  return method;
271
295
  }
272
296
 
@@ -560,16 +584,35 @@ export async function exportDeliveryReport(options) {
560
584
  if (!(await fileExists(specPath))) {
561
585
  throw new Error(`缺少 spec.md:${specPath}`);
562
586
  }
563
- if (!(await fileExists(checkerPath))) {
564
- throw new Error(`缺少 Checker 结果:${checkerPath}`);
565
- }
566
587
  const specText = await readFile(specPath, 'utf8');
567
- const checker = await readJson(checkerPath, 'Checker 结果');
568
- const runnerPath = await selectRunnerResult(checker, context);
569
- if (!runnerPath) {
570
- throw new Error(`缺少 Runner 结果:${path.join(featureDir, 'e2e', 'runs')}`);
588
+ let checker;
589
+ let runnerPath;
590
+ let evidenceSource;
591
+ if (await fileExists(checkerPath)) {
592
+ checker = await readJson(checkerPath, 'Checker 结果');
593
+ runnerPath = await selectRunnerResult(checker, context);
594
+ evidenceSource = 'independent-checker';
595
+ if (!runnerPath) {
596
+ throw new Error(`缺少 Runner 结果:${path.join(featureDir, 'e2e', 'runs')}`);
597
+ }
598
+ } else {
599
+ runnerPath = await findLatestFastCheckResult(featureDir);
600
+ if (!runnerPath) {
601
+ throw new Error(`缺少 Checker 或快速自测结果:${checkerPath}`);
602
+ }
603
+ const fastResult = await readJson(runnerPath, '快速自测结果');
604
+ if (fastResult.evidenceSource !== 'interactive-playwright') {
605
+ throw new Error(`快速自测结果包含无效 evidenceSource:${fastResult.evidenceSource}`);
606
+ }
607
+ checker = {
608
+ status: fastResult.status,
609
+ iteration: fastResult.iteration,
610
+ scenarioSha256: null,
611
+ checks: []
612
+ };
613
+ evidenceSource = 'interactive-playwright';
571
614
  }
572
- const runner = await readJson(runnerPath, 'Runner 结果');
615
+ const runner = await readJson(runnerPath, evidenceSource === 'interactive-playwright' ? '快速自测结果' : 'Runner 结果');
573
616
  const apiMode = resolveApiMode(runner);
574
617
  const checks = mergeChecks(checker, runner);
575
618
  const stories = parseSpecItems(specText, checks);
@@ -649,14 +692,16 @@ export async function exportDeliveryReport(options) {
649
692
  return {
650
693
  outputPath,
651
694
  iteration: runner.iteration ?? checker.iteration ?? null,
652
- checkerStatus: checker.status,
653
- runnerStatus: runner.status,
695
+ checkerStatus: evidenceSource === 'independent-checker' ? checker.status : null,
696
+ runnerStatus: evidenceSource === 'independent-checker' ? runner.status : null,
697
+ fastCheckStatus: evidenceSource === 'interactive-playwright' ? runner.status : null,
654
698
  rows: groups.length,
655
699
  statuses: counts,
656
700
  embeddedScreenshots,
657
701
  screenshotPlacements,
658
702
  groupingSource: grouping.source,
659
703
  groupingPath: grouping.groupingPath,
660
- apiMode
704
+ apiMode,
705
+ evidenceSource
661
706
  };
662
707
  }