@autobest-ui/agent 1.0.0

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 (59) hide show
  1. package/README.md +182 -0
  2. package/bin/sync-assets.mjs +126 -0
  3. package/bin/sync-assets.test.mjs +64 -0
  4. package/mcp/azurepr-mcp-bridge/README.md +37 -0
  5. package/mcp/azurepr-mcp-bridge/azure-devops.js +327 -0
  6. package/mcp/azurepr-mcp-bridge/config.toml.example +7 -0
  7. package/mcp/azurepr-mcp-bridge/index.js +65 -0
  8. package/mcp/azurepr-mcp-bridge/index.test.js +116 -0
  9. package/mcp/azurepr-mcp-bridge/package.json +22 -0
  10. package/mcp/rag-mcp-bridge/README.md +42 -0
  11. package/mcp/rag-mcp-bridge/codex-system-prompt.md +20 -0
  12. package/mcp/rag-mcp-bridge/config.toml.example +12 -0
  13. package/mcp/rag-mcp-bridge/index.js +361 -0
  14. package/mcp/rag-mcp-bridge/index.test.js +56 -0
  15. package/mcp/rag-mcp-bridge/package.json +21 -0
  16. package/package.json +44 -0
  17. package/plugins/autobest-delivery/.codex-plugin/plugin.json +25 -0
  18. package/plugins/autobest-delivery/.mcp.json +11 -0
  19. package/plugins/autobest-delivery/README.md +164 -0
  20. package/plugins/autobest-delivery/assets/delivery-report-template.xlsx +0 -0
  21. package/plugins/autobest-delivery/mcp-server/npm-shrinkwrap.json +3511 -0
  22. package/plugins/autobest-delivery/mcp-server/package.json +23 -0
  23. package/plugins/autobest-delivery/mcp-server/src/paths.mjs +43 -0
  24. package/plugins/autobest-delivery/mcp-server/src/report.mjs +605 -0
  25. package/plugins/autobest-delivery/mcp-server/src/runner.mjs +489 -0
  26. package/plugins/autobest-delivery/mcp-server/src/server.mjs +199 -0
  27. package/plugins/autobest-delivery/mcp-server/tests/fixture-server.mjs +36 -0
  28. package/plugins/autobest-delivery/mcp-server/tests/fixtures/basic.feature.mjs +68 -0
  29. package/plugins/autobest-delivery/mcp-server/tests/mcp-smoke.test.mjs +83 -0
  30. package/plugins/autobest-delivery/mcp-server/tests/report.test.mjs +254 -0
  31. package/plugins/autobest-delivery/mcp-server/tests/runner.test.mjs +354 -0
  32. package/plugins/autobest-delivery/scripts/export-delivery-report.mjs +41 -0
  33. package/plugins/autobest-delivery/scripts/setup.mjs +295 -0
  34. package/plugins/autobest-delivery/scripts/setup.test.mjs +145 -0
  35. package/plugins/autobest-delivery/scripts/start-mcp.mjs +7 -0
  36. package/plugins/autobest-delivery/skills/code-audit/SKILL.md +24 -0
  37. package/plugins/autobest-delivery/skills/code-audit/agents/openai.yaml +7 -0
  38. package/plugins/autobest-delivery/skills/code-craft/SKILL.md +27 -0
  39. package/plugins/autobest-delivery/skills/code-craft/agents/openai.yaml +7 -0
  40. package/plugins/autobest-delivery/skills/delivery-loop/SKILL.md +43 -0
  41. package/plugins/autobest-delivery/skills/delivery-loop/agents/openai.yaml +7 -0
  42. package/plugins/autobest-delivery/skills/delivery-loop/references/delivery-contract.md +235 -0
  43. package/plugins/autobest-delivery/skills/e2e-gen-spec/SKILL.md +35 -0
  44. package/plugins/autobest-delivery/skills/e2e-gen-spec/agents/openai.yaml +7 -0
  45. package/plugins/autobest-delivery/skills/e2e-ui-checker/SKILL.md +30 -0
  46. package/plugins/autobest-delivery/skills/e2e-ui-checker/agents/openai.yaml +7 -0
  47. package/plugins/autobest-delivery/skills/export-report/SKILL.md +66 -0
  48. package/plugins/autobest-delivery/skills/export-report/agents/openai.yaml +8 -0
  49. package/plugins/autobest-delivery/skills/ui-structure-guard/SKILL.md +24 -0
  50. package/plugins/autobest-delivery/skills/ui-structure-guard/agents/openai.yaml +7 -0
  51. package/skills/README.md +38 -0
  52. package/skills/common/figma-ui-capture/SKILL.md +197 -0
  53. package/skills/common/figma-ui-capture/agents/openai.yaml +4 -0
  54. package/skills/common/ui-prd-scope/SKILL.md +67 -0
  55. package/skills/common/ui-prd-scope/agents/openai.yaml +4 -0
  56. package/skills/common/ui-prd-scope/references/scope-schema.md +158 -0
  57. package/skills/common/ui-prd-scope/scripts/validate-scope-bundle.mjs +302 -0
  58. package/skills/react/react-code-standards/SKILL.md +78 -0
  59. package/skills/react/react-code-standards/agents/openai.yaml +4 -0
@@ -0,0 +1,489 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import { fileURLToPath, pathToFileURL } from 'node:url';
4
+ import path from 'node:path';
5
+ import { resolveArtifactPath, resolveWorkspacePaths } from './paths.mjs';
6
+
7
+ const serverRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
8
+ const defaultBrowserRoot = path.join(serverRoot, '.runtime', 'ms-playwright');
9
+ const blockedSessions = new Map();
10
+ process.env.PLAYWRIGHT_BROWSERS_PATH =
11
+ process.env.AUTOBEST_DELIVERY_BROWSERS_PATH || defaultBrowserRoot;
12
+
13
+ async function loadPlaywright() {
14
+ return import('@playwright/test');
15
+ }
16
+
17
+ async function sha256(filePath) {
18
+ const content = await fs.readFile(filePath);
19
+ return crypto.createHash('sha256').update(content).digest('hex');
20
+ }
21
+
22
+ async function validateScenarioSource(filePath) {
23
+ const source = await fs.readFile(filePath, 'utf8');
24
+ const forbidden = [
25
+ { pattern: /(^|\n)\s*import(?:\s|\()/, label: 'import' },
26
+ { pattern: /\brequire\s*\(/, label: 'require' },
27
+ {
28
+ pattern: /\b(process|__dirname|__filename|globalThis)\b/,
29
+ label: 'Node 全局变量'
30
+ }
31
+ ];
32
+ const violation = forbidden.find(item => item.pattern.test(source));
33
+ if (violation) {
34
+ throw new TypeError(`冻结场景不得使用 ${violation.label}`);
35
+ }
36
+ }
37
+
38
+ function toEvidencePath(workspaceRoot, absolutePath) {
39
+ return path.relative(workspaceRoot, absolutePath).split(path.sep).join('/');
40
+ }
41
+
42
+ function errorDetail(error) {
43
+ return error instanceof Error ? error.message : String(error);
44
+ }
45
+
46
+ async function writeJson(filePath, value) {
47
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
48
+ await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
49
+ }
50
+
51
+ async function closeBlockedSession(session) {
52
+ clearTimeout(session.timeoutId);
53
+ await session.context?.close().catch(() => undefined);
54
+ await session.browser?.close().catch(() => undefined);
55
+ }
56
+
57
+ function decisionEffect(decision) {
58
+ return {
59
+ retry: 'retry-checker',
60
+ accept: 'continue-with-waiver',
61
+ skip: 'continue-with-waiver',
62
+ implementation_defect: 'maker',
63
+ test_defect: 'test-author',
64
+ stop: 'terminal-blocked',
65
+ expired: 'retry-checker'
66
+ }[decision];
67
+ }
68
+
69
+ async function recordBlockedDecision(session, decision, note) {
70
+ const result = {
71
+ status: 'resolved',
72
+ sessionId: session.sessionId,
73
+ decision,
74
+ effect: decisionEffect(decision),
75
+ note,
76
+ runnerResult: session.runnerResult,
77
+ decidedAt: new Date().toISOString()
78
+ };
79
+ await writeJson(session.decisionPath, result);
80
+ return {
81
+ ...result,
82
+ decisionPath: toEvidencePath(session.root, session.decisionPath)
83
+ };
84
+ }
85
+
86
+ export async function resolveBlockedRun({ sessionId, decision, note = '' }) {
87
+ const session = blockedSessions.get(sessionId);
88
+ if (!session) {
89
+ return {
90
+ status: 'not-found',
91
+ sessionId,
92
+ decision,
93
+ effect: null,
94
+ note,
95
+ runnerResult: null,
96
+ decisionPath: null,
97
+ decidedAt: new Date().toISOString()
98
+ };
99
+ }
100
+
101
+ blockedSessions.delete(sessionId);
102
+ try {
103
+ return await recordBlockedDecision(session, decision, note);
104
+ } finally {
105
+ await closeBlockedSession(session);
106
+ }
107
+ }
108
+
109
+ export async function closeAllBlockedRuns() {
110
+ const sessions = [...blockedSessions.values()];
111
+ blockedSessions.clear();
112
+ await Promise.all(sessions.map(closeBlockedSession));
113
+ }
114
+
115
+ export function activeBlockedRunCount() {
116
+ return blockedSessions.size;
117
+ }
118
+
119
+ export async function checkEnvironment({ headed = false, load = loadPlaywright } = {}) {
120
+ const packageJson = JSON.parse(
121
+ await fs.readFile(path.join(serverRoot, 'package.json'), 'utf8')
122
+ );
123
+
124
+ try {
125
+ const { chromium } = await load();
126
+ const browser = await chromium.launch({ headless: !headed });
127
+ const browserVersion = browser.version();
128
+ await browser.close();
129
+ return {
130
+ ready: true,
131
+ nodeVersion: process.version,
132
+ runnerVersion: packageJson.version,
133
+ playwrightVersion: packageJson.dependencies['@playwright/test'],
134
+ browserMode: headed ? 'headed' : 'headless',
135
+ browserVersion,
136
+ browserPath: chromium.executablePath(),
137
+ browserStorage: process.env.PLAYWRIGHT_BROWSERS_PATH
138
+ };
139
+ } catch (error) {
140
+ return {
141
+ ready: false,
142
+ nodeVersion: process.version,
143
+ runnerVersion: packageJson.version,
144
+ playwrightVersion: packageJson.dependencies['@playwright/test'],
145
+ browserMode: headed ? 'headed' : 'headless',
146
+ browserStorage: process.env.PLAYWRIGHT_BROWSERS_PATH,
147
+ error: errorDetail(error)
148
+ };
149
+ }
150
+ }
151
+
152
+ export async function runFeatureE2E({
153
+ workspaceRoot,
154
+ scenarioPath,
155
+ baseUrl,
156
+ outputDir,
157
+ iteration = 1,
158
+ timeoutMs = 300000,
159
+ headed = false,
160
+ keepBrowserOpenOnBlock = false,
161
+ blockedSessionTtlMs = 1800000
162
+ }) {
163
+ const startedAt = new Date().toISOString();
164
+ const { root, scenario, output } = await resolveWorkspacePaths({
165
+ workspaceRoot,
166
+ scenarioPath,
167
+ outputDir
168
+ });
169
+ const resultPath = resolveArtifactPath(output, 'runner-result.json');
170
+ const tracePath = resolveArtifactPath(output, 'trace.zip');
171
+ const beforeHash = await sha256(scenario);
172
+ const checks = [];
173
+ const captures = [];
174
+ const scenarioBlockers = [];
175
+ const consoleErrors = [];
176
+ const networkFailures = [];
177
+ const invokedScenes = new Set();
178
+ let browser;
179
+ let context;
180
+ let page;
181
+ let blockedPage;
182
+ let metadata = {};
183
+ let timeoutId;
184
+ let traceStarted = false;
185
+ let traceError;
186
+ let result;
187
+
188
+ try {
189
+ const { chromium, expect } = await loadPlaywright();
190
+ await validateScenarioSource(scenario);
191
+ const scenarioUrl = `${pathToFileURL(scenario).href}?sha256=${beforeHash}`;
192
+ const scenarioModule = await import(scenarioUrl);
193
+ if (typeof scenarioModule.default !== 'function') {
194
+ throw new TypeError('冻结场景必须导出一个默认异步函数');
195
+ }
196
+ metadata = scenarioModule.metadata || {};
197
+ const reportSchemaVersion = metadata.reportSchemaVersion;
198
+ if (reportSchemaVersion !== undefined && reportSchemaVersion !== 1) {
199
+ throw new TypeError('metadata.reportSchemaVersion 仅支持 1');
200
+ }
201
+ if (reportSchemaVersion === 1) {
202
+ for (const mapping of metadata.visualMappings || []) {
203
+ for (const field of ['reportModule', 'reportGroup', 'reportTitle', 'reportDevice']) {
204
+ if (!mapping || typeof mapping[field] !== 'string' || !mapping[field].trim()) {
205
+ throw new TypeError(`visualMapping 必须包含非空字符串字段 ${field}`);
206
+ }
207
+ }
208
+ if (!['desktop', 'mobile'].includes(mapping.reportDevice)) {
209
+ throw new TypeError('visualMapping.reportDevice 必须是 desktop 或 mobile');
210
+ }
211
+ if (!/^[a-z0-9][a-z0-9._-]*$/.test(mapping.reportGroup)) {
212
+ throw new TypeError('visualMapping.reportGroup 必须是稳定的 ASCII ID');
213
+ }
214
+ }
215
+ }
216
+
217
+ browser = await chromium.launch({ headless: !headed });
218
+ context = await browser.newContext();
219
+ await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
220
+ traceStarted = true;
221
+ const createPage = async () => {
222
+ const currentPage = await context.newPage();
223
+ currentPage.on('console', message => {
224
+ if (message.type() === 'error') {
225
+ consoleErrors.push(message.text());
226
+ }
227
+ });
228
+ currentPage.on('requestfailed', request => {
229
+ networkFailures.push({
230
+ url: request.url(),
231
+ error: request.failure()?.errorText || '未知请求失败'
232
+ });
233
+ });
234
+ return currentPage;
235
+ };
236
+ page = await createPage();
237
+
238
+ const check = async (definition, assertion) => {
239
+ const required = ['id', 'specSnippet', 'scene', 'errorType', 'expect'];
240
+ if (reportSchemaVersion === 1) {
241
+ required.push('reportModule', 'reportGroup', 'reportTitle', 'reportMethod');
242
+ }
243
+ for (const field of required) {
244
+ if (!definition || typeof definition[field] !== 'string' || !definition[field].trim()) {
245
+ throw new TypeError(`check 定义必须包含非空字符串字段 ${field}`);
246
+ }
247
+ }
248
+ if (reportSchemaVersion === 1 && !/^[a-z0-9][a-z0-9._-]*$/.test(definition.reportGroup)) {
249
+ throw new TypeError('check.reportGroup 必须是稳定的 ASCII ID');
250
+ }
251
+ try {
252
+ const actual = await assertion();
253
+ checks.push({ ...definition, actual: actual ?? '符合预期', isPass: true });
254
+ return true;
255
+ } catch (error) {
256
+ checks.push({ ...definition, actual: errorDetail(error), isPass: false });
257
+ return false;
258
+ }
259
+ };
260
+
261
+ const capture = async (locator, filename) => {
262
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*\.png$/.test(filename)) {
263
+ throw new TypeError('capture 文件名必须是简单的 PNG 文件名');
264
+ }
265
+ const capturePath = resolveArtifactPath(output, path.join('captures', filename));
266
+ await fs.mkdir(path.dirname(capturePath), { recursive: true });
267
+ await locator.screenshot({ path: capturePath });
268
+ const evidencePath = toEvidencePath(root, capturePath);
269
+ captures.push(evidencePath);
270
+ return evidencePath;
271
+ };
272
+
273
+ const artifact = async (filename, value) => {
274
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*\.json$/.test(filename)) {
275
+ throw new TypeError('artifact 文件名必须是简单的 JSON 文件名');
276
+ }
277
+ const artifactPath = resolveArtifactPath(output, path.join('artifacts', filename));
278
+ await writeJson(artifactPath, value);
279
+ return toEvidencePath(root, artifactPath);
280
+ };
281
+
282
+ let scene;
283
+ const injectedApi = currentPage => ({
284
+ page: currentPage,
285
+ expect,
286
+ check,
287
+ scene,
288
+ route: (pattern, handler) => currentPage.route(pattern, handler),
289
+ capture,
290
+ artifact,
291
+ baseUrl,
292
+ parseUrl: value => new URL(value),
293
+ metadata
294
+ });
295
+ scene = async (sceneId, executeScene) => {
296
+ if (typeof sceneId !== 'string' || sceneId.length === 0) {
297
+ throw new TypeError('scene id 必须是非空字符串');
298
+ }
299
+ if (typeof executeScene !== 'function') {
300
+ throw new TypeError(`scene ${sceneId} 必须提供异步执行函数`);
301
+ }
302
+ if (invokedScenes.has(sceneId)) {
303
+ throw new TypeError(`scene ${sceneId} 不得重复执行`);
304
+ }
305
+ if (Array.isArray(metadata.scenes) && !metadata.scenes.includes(sceneId)) {
306
+ throw new TypeError(`scene ${sceneId} 未在 metadata.scenes 中声明`);
307
+ }
308
+
309
+ invokedScenes.add(sceneId);
310
+ const scenePage = await createPage();
311
+ try {
312
+ await executeScene(injectedApi(scenePage));
313
+ await scenePage.close();
314
+ return true;
315
+ } catch (error) {
316
+ scenarioBlockers.push({
317
+ kind: 'scenario',
318
+ scene: sceneId,
319
+ detail: errorDetail(error)
320
+ });
321
+ blockedPage ||= scenePage;
322
+ return false;
323
+ }
324
+ };
325
+
326
+ const execute = scenarioModule.default(injectedApi(page));
327
+ const deadline = new Promise((_, reject) => {
328
+ timeoutId = setTimeout(() => {
329
+ const error = new Error(`场景执行超过 ${timeoutMs}ms 超时限制`);
330
+ error.name = 'DeliveryTimeoutError';
331
+ reject(error);
332
+ }, timeoutMs);
333
+ });
334
+ await Promise.race([execute, deadline]);
335
+
336
+ const afterHash = await sha256(scenario);
337
+ if (afterHash !== beforeHash) {
338
+ throw new Error('冻结场景在执行过程中发生了变化');
339
+ }
340
+ if (checks.length === 0 && scenarioBlockers.length === 0) {
341
+ throw new Error('冻结场景执行完成,但没有记录原子检查项');
342
+ }
343
+ if (invokedScenes.size > 0 && Array.isArray(metadata.scenes)) {
344
+ for (const sceneId of metadata.scenes) {
345
+ if (!invokedScenes.has(sceneId)) {
346
+ scenarioBlockers.push({
347
+ kind: 'scenario',
348
+ scene: sceneId,
349
+ detail: `metadata.scenes 声明的 ${sceneId} 未执行`
350
+ });
351
+ }
352
+ }
353
+ }
354
+ if (blockedPage && !blockedPage.isClosed()) {
355
+ await blockedPage.bringToFront().catch(() => undefined);
356
+ page = blockedPage;
357
+ }
358
+
359
+ result = {
360
+ status: scenarioBlockers.length > 0
361
+ ? 'blocked'
362
+ : checks.every(item => item.isPass) ? 'passed' : 'failed',
363
+ iteration,
364
+ scenarioPath: toEvidencePath(root, scenario),
365
+ scenarioSha256: beforeHash,
366
+ metadata,
367
+ checks,
368
+ captures,
369
+ tracePath: toEvidencePath(root, tracePath),
370
+ consoleErrors,
371
+ networkFailures,
372
+ blockers: scenarioBlockers,
373
+ browserMode: headed ? 'headed' : 'headless',
374
+ blockedSessionId: null,
375
+ blockedSessionExpiresAt: null,
376
+ decisionPath: null,
377
+ startedAt,
378
+ finishedAt: new Date().toISOString()
379
+ };
380
+ } catch (error) {
381
+ result = {
382
+ status: 'blocked',
383
+ iteration,
384
+ scenarioPath: toEvidencePath(root, scenario),
385
+ scenarioSha256: beforeHash,
386
+ metadata,
387
+ checks,
388
+ captures,
389
+ tracePath: traceStarted ? toEvidencePath(root, tracePath) : null,
390
+ consoleErrors,
391
+ networkFailures,
392
+ blockers: [
393
+ {
394
+ kind: error?.name === 'DeliveryTimeoutError' ? 'environment' : 'scenario',
395
+ scene: 'runner',
396
+ detail: errorDetail(error)
397
+ }
398
+ ],
399
+ browserMode: headed ? 'headed' : 'headless',
400
+ blockedSessionId: null,
401
+ blockedSessionExpiresAt: null,
402
+ decisionPath: null,
403
+ startedAt,
404
+ finishedAt: new Date().toISOString()
405
+ };
406
+ } finally {
407
+ clearTimeout(timeoutId);
408
+ if (traceStarted && context) {
409
+ try {
410
+ await context.tracing.stop({ path: tracePath });
411
+ } catch (error) {
412
+ traceError = error;
413
+ }
414
+ }
415
+ const preserveBrowser =
416
+ result?.status === 'blocked' &&
417
+ keepBrowserOpenOnBlock &&
418
+ browser &&
419
+ context &&
420
+ page &&
421
+ !page.isClosed();
422
+
423
+ if (preserveBrowser) {
424
+ const sessionId = crypto.randomUUID();
425
+ const expiresAt = new Date(Date.now() + blockedSessionTtlMs).toISOString();
426
+ const decisionPath = resolveArtifactPath(output, 'human-decision.json');
427
+ const session = {
428
+ sessionId,
429
+ root,
430
+ browser,
431
+ context,
432
+ page,
433
+ decisionPath,
434
+ runnerResult: toEvidencePath(root, resultPath),
435
+ timeoutId: null
436
+ };
437
+ session.timeoutId = setTimeout(async () => {
438
+ if (!blockedSessions.delete(sessionId)) {
439
+ return;
440
+ }
441
+ await recordBlockedDecision(
442
+ session,
443
+ 'expired',
444
+ 'Blocked browser session expired before a user decision.'
445
+ ).catch(() => undefined);
446
+ await closeBlockedSession(session);
447
+ }, blockedSessionTtlMs);
448
+ session.timeoutId.unref?.();
449
+ blockedSessions.set(sessionId, session);
450
+ result.blockedSessionId = sessionId;
451
+ result.blockedSessionExpiresAt = expiresAt;
452
+ result.decisionPath = toEvidencePath(root, decisionPath);
453
+ } else {
454
+ await context?.close().catch(() => undefined);
455
+ await browser?.close().catch(() => undefined);
456
+ }
457
+ }
458
+
459
+ const finalHash = await sha256(scenario);
460
+ if (finalHash !== beforeHash) {
461
+ result.status = 'blocked';
462
+ result.blockers = [
463
+ {
464
+ kind: 'evidence',
465
+ scene: 'runner',
466
+ detail: '冻结场景的哈希在执行期间发生变化'
467
+ }
468
+ ];
469
+ }
470
+ if (traceStarted) {
471
+ try {
472
+ await fs.access(tracePath);
473
+ } catch (error) {
474
+ traceError ||= error;
475
+ }
476
+ }
477
+ if (traceError) {
478
+ result.status = 'blocked';
479
+ result.blockers = [
480
+ {
481
+ kind: 'evidence',
482
+ scene: 'runner',
483
+ detail: `无法完成 Trace 证据写入:${errorDetail(traceError)}`
484
+ }
485
+ ];
486
+ }
487
+ await writeJson(resultPath, result);
488
+ return { ...result, resultPath: toEvidencePath(root, resultPath) };
489
+ }
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
+ import { z } from 'zod';
6
+ import {
7
+ checkEnvironment,
8
+ closeAllBlockedRuns,
9
+ resolveBlockedRun,
10
+ runFeatureE2E
11
+ } from './runner.mjs';
12
+
13
+ const server = new McpServer(
14
+ {
15
+ name: 'autobest-delivery',
16
+ version: '0.1.0'
17
+ },
18
+ {
19
+ instructions:
20
+ '调用 run_feature_e2e 前先调用 check_environment。headed 运行在 Blocked 时可保留浏览器,随后通过 resolve_blocked_run 记录人工决议并关闭会话。'
21
+ }
22
+ );
23
+
24
+ server.registerTool(
25
+ 'check_environment',
26
+ {
27
+ title: '检查隔离 E2E 环境',
28
+ description:
29
+ '在启动交付闭环前,验证插件自带的 Playwright 运行器和 Chromium。',
30
+ inputSchema: {
31
+ headed: z.boolean().default(false)
32
+ },
33
+ outputSchema: {
34
+ ready: z.boolean(),
35
+ nodeVersion: z.string(),
36
+ runnerVersion: z.string(),
37
+ playwrightVersion: z.string(),
38
+ browserMode: z.enum(['headed', 'headless']),
39
+ browserVersion: z.string().optional(),
40
+ browserPath: z.string().optional(),
41
+ browserStorage: z.string(),
42
+ error: z.string().optional()
43
+ },
44
+ annotations: {
45
+ readOnlyHint: true,
46
+ destructiveHint: false,
47
+ openWorldHint: false
48
+ }
49
+ },
50
+ async input => {
51
+ const result = await checkEnvironment({ headed: input.headed });
52
+ return {
53
+ structuredContent: result,
54
+ content: [{ type: 'text', text: JSON.stringify(result) }]
55
+ };
56
+ }
57
+ );
58
+
59
+ server.registerTool(
60
+ 'run_feature_e2e',
61
+ {
62
+ title: '执行冻结的功能 E2E 场景',
63
+ description:
64
+ '使用插件自带的 Playwright 运行器执行无依赖冻结场景;scene 隔离可在单个场景阻塞后继续执行其余场景,并在功能目录下写入结构化证据。',
65
+ inputSchema: {
66
+ workspaceRoot: z.string().min(1),
67
+ scenarioPath: z.string().min(1),
68
+ baseUrl: z.string().url(),
69
+ outputDir: z.string().min(1),
70
+ iteration: z.number().int().positive().default(1),
71
+ timeoutMs: z.number().int().min(1000).max(900000).default(300000),
72
+ headed: z.boolean().default(false),
73
+ keepBrowserOpenOnBlock: z.boolean().default(false),
74
+ blockedSessionTtlMs: z.number().int().min(60000).max(3600000).default(1800000)
75
+ },
76
+ outputSchema: {
77
+ status: z.enum(['passed', 'failed', 'blocked']),
78
+ iteration: z.number().int().positive(),
79
+ scenarioPath: z.string(),
80
+ scenarioSha256: z.string().nullable(),
81
+ metadata: z.record(z.string(), z.unknown()),
82
+ checks: z.array(z.record(z.string(), z.unknown())),
83
+ captures: z.array(z.string()),
84
+ tracePath: z.string().nullable(),
85
+ consoleErrors: z.array(z.string()),
86
+ networkFailures: z.array(z.record(z.string(), z.unknown())),
87
+ blockers: z.array(
88
+ z.object({
89
+ kind: z.string(),
90
+ scene: z.string(),
91
+ detail: z.string()
92
+ })
93
+ ),
94
+ browserMode: z.enum(['headed', 'headless']),
95
+ blockedSessionId: z.string().nullable(),
96
+ blockedSessionExpiresAt: z.string().nullable(),
97
+ decisionPath: z.string().nullable(),
98
+ resultPath: z.string().nullable(),
99
+ startedAt: z.string().optional(),
100
+ finishedAt: z.string().optional()
101
+ },
102
+ annotations: {
103
+ readOnlyHint: false,
104
+ destructiveHint: false,
105
+ openWorldHint: false
106
+ }
107
+ },
108
+ async input => {
109
+ try {
110
+ const result = await runFeatureE2E(input);
111
+ return {
112
+ structuredContent: result,
113
+ content: [{ type: 'text', text: JSON.stringify(result) }]
114
+ };
115
+ } catch (error) {
116
+ const result = {
117
+ status: 'blocked',
118
+ iteration: input.iteration,
119
+ scenarioPath: input.scenarioPath,
120
+ scenarioSha256: null,
121
+ metadata: {},
122
+ checks: [],
123
+ captures: [],
124
+ tracePath: null,
125
+ consoleErrors: [],
126
+ networkFailures: [],
127
+ blockers: [
128
+ {
129
+ kind: 'tool',
130
+ scene: 'runner',
131
+ detail: error instanceof Error ? error.message : String(error)
132
+ }
133
+ ],
134
+ browserMode: input.headed ? 'headed' : 'headless',
135
+ blockedSessionId: null,
136
+ blockedSessionExpiresAt: null,
137
+ decisionPath: null,
138
+ resultPath: null
139
+ };
140
+ return {
141
+ structuredContent: result,
142
+ content: [{ type: 'text', text: JSON.stringify(result) }]
143
+ };
144
+ }
145
+ }
146
+ );
147
+
148
+ server.registerTool(
149
+ 'resolve_blocked_run',
150
+ {
151
+ title: '处理 Blocked E2E 会话',
152
+ description:
153
+ '记录用户对可见 Blocked 浏览器会话的决定并关闭会话。skip 仅接受阻塞场景的缺失证据,不会从异常语句恢复执行;其余独立 scene 已由运行器继续执行。',
154
+ inputSchema: {
155
+ sessionId: z.string().uuid(),
156
+ decision: z.enum([
157
+ 'retry',
158
+ 'accept',
159
+ 'skip',
160
+ 'implementation_defect',
161
+ 'test_defect',
162
+ 'stop'
163
+ ]),
164
+ note: z.string().max(2000).default('')
165
+ },
166
+ outputSchema: {
167
+ status: z.enum(['resolved', 'not-found']),
168
+ sessionId: z.string(),
169
+ decision: z.string(),
170
+ effect: z.string().nullable(),
171
+ note: z.string(),
172
+ runnerResult: z.string().nullable(),
173
+ decisionPath: z.string().nullable(),
174
+ decidedAt: z.string()
175
+ },
176
+ annotations: {
177
+ readOnlyHint: false,
178
+ destructiveHint: false,
179
+ openWorldHint: false
180
+ }
181
+ },
182
+ async input => {
183
+ const result = await resolveBlockedRun(input);
184
+ return {
185
+ structuredContent: result,
186
+ content: [{ type: 'text', text: JSON.stringify(result) }]
187
+ };
188
+ }
189
+ );
190
+
191
+ const transport = new StdioServerTransport();
192
+ await server.connect(transport);
193
+
194
+ for (const signal of ['SIGINT', 'SIGTERM']) {
195
+ process.once(signal, async () => {
196
+ await closeAllBlockedRuns();
197
+ process.exit(0);
198
+ });
199
+ }