@autobest-ui/agent 1.0.10 → 1.0.12

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 (35) hide show
  1. package/README.md +20 -6
  2. package/bin/sync-assets.test.mjs +4 -0
  3. package/mcp/code-mcp-pr/README.md +3 -2
  4. package/mcp/code-mcp-pr/azure-devops.js +81 -2
  5. package/mcp/code-mcp-pr/index.js +34 -1
  6. package/mcp/code-mcp-pr/index.test.js +161 -11
  7. package/mcp/trace-mcp-recorder/.env.example +6 -0
  8. package/mcp/trace-mcp-recorder/README.md +58 -0
  9. package/mcp/trace-mcp-recorder/config.toml.example +11 -0
  10. package/mcp/trace-mcp-recorder/index.js +469 -0
  11. package/mcp/trace-mcp-recorder/index.test.js +179 -0
  12. package/mcp/trace-mcp-recorder/package.json +25 -0
  13. package/package.json +9 -2
  14. package/plugins/autobest-delivery/.codex-plugin/plugin.json +1 -0
  15. package/plugins/autobest-delivery/README.md +25 -4
  16. package/plugins/autobest-delivery/mcp-server/package.json +1 -1
  17. package/plugins/autobest-delivery/mcp-server/src/fast-runner.mjs +470 -0
  18. package/plugins/autobest-delivery/mcp-server/src/paths.mjs +12 -7
  19. package/plugins/autobest-delivery/mcp-server/src/report.mjs +57 -12
  20. package/plugins/autobest-delivery/mcp-server/src/server.mjs +167 -2
  21. package/plugins/autobest-delivery/mcp-server/tests/fast-runner.test.mjs +232 -0
  22. package/plugins/autobest-delivery/mcp-server/tests/mcp-smoke.test.mjs +71 -0
  23. package/plugins/autobest-delivery/mcp-server/tests/report.test.mjs +69 -1
  24. package/plugins/autobest-delivery/skills/code-audit/SKILL.md +3 -3
  25. package/plugins/autobest-delivery/skills/delivery-loop/references/delivery-contract.md +11 -0
  26. package/plugins/autobest-delivery/skills/delivery-loop-fast/SKILL.md +62 -0
  27. package/plugins/autobest-delivery/skills/delivery-loop-fast/agents/openai.yaml +7 -0
  28. package/plugins/autobest-delivery/skills/export-report/SKILL.md +3 -3
  29. package/plugins/autobest-delivery/skills/export-report/agents/openai.yaml +1 -2
  30. package/skills/README.md +2 -0
  31. package/skills/common/code-pr-submit/SKILL.md +25 -4
  32. package/skills/common/repo-setup/SKILL.md +91 -0
  33. package/skills/common/repo-setup/agents/openai.yaml +4 -0
  34. package/skills/common/trace-recorder/SKILL.md +25 -0
  35. package/skills/common/trace-recorder/agents/openai.yaml +11 -0
@@ -0,0 +1,469 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createReadStream, existsSync, readFileSync, realpathSync } from 'node:fs';
4
+ import { mkdtemp, rm } from 'node:fs/promises';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import axios from 'axios';
9
+ import FormData from 'form-data';
10
+ import { chromium } from 'playwright';
11
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
12
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
13
+ import { z } from 'zod';
14
+
15
+ const DEFAULT_MAX_RECORD_DURATION = 1_800_000;
16
+ const directory = path.dirname(fileURLToPath(import.meta.url));
17
+
18
+ function readConfiguration(environment = process.env) {
19
+ const configuredBaseUrl = environment.BACKEND_BASE_URL?.trim();
20
+ if (!configuredBaseUrl) {
21
+ throw new Error('BACKEND_BASE_URL 未配置,无法创建录制会话');
22
+ }
23
+
24
+ let baseUrl;
25
+ try {
26
+ baseUrl = new URL(configuredBaseUrl);
27
+ } catch {
28
+ throw new Error('BACKEND_BASE_URL 必须是有效的 URL');
29
+ }
30
+ if (!['http:', 'https:'].includes(baseUrl.protocol)) {
31
+ throw new Error('BACKEND_BASE_URL 必须使用 http 或 https 协议');
32
+ }
33
+
34
+ const durationText = environment.MAX_RECORD_DURATION ?? String(DEFAULT_MAX_RECORD_DURATION);
35
+ const maxRecordDuration = Number(durationText);
36
+ if (!Number.isSafeInteger(maxRecordDuration) || maxRecordDuration <= 0) {
37
+ throw new Error('MAX_RECORD_DURATION 必须是正整数毫秒值');
38
+ }
39
+
40
+ return {
41
+ baseUrl: baseUrl.toString().replace(/\/$/, ''),
42
+ maxRecordDuration
43
+ };
44
+ }
45
+
46
+ function validateTargetUrl(value) {
47
+ let url;
48
+ try {
49
+ url = new URL(value);
50
+ } catch {
51
+ throw new Error('url 必须是有效的网页地址');
52
+ }
53
+ if (!['http:', 'https:'].includes(url.protocol)) {
54
+ throw new Error('url 必须使用 http 或 https 协议');
55
+ }
56
+ return url.toString();
57
+ }
58
+
59
+ function optionalTrimmed(value) {
60
+ const trimmed = value?.trim();
61
+ return trimmed || undefined;
62
+ }
63
+
64
+ /** Prefer authenticated/request MCP identity data, then the explicit tool argument. */
65
+ function resolveOperator(extra, suppliedOperator) {
66
+ const authExtra = extra?.authInfo?.extra;
67
+ const requestMeta = extra?._meta;
68
+ const candidates = [
69
+ authExtra?.operator,
70
+ authExtra?.name,
71
+ authExtra?.preferred_username,
72
+ requestMeta?.operator,
73
+ requestMeta?.user?.name,
74
+ suppliedOperator
75
+ ];
76
+ return candidates.find(value => typeof value === 'string' && value.trim())?.trim();
77
+ }
78
+
79
+ function errorMessage(error) {
80
+ if (axios.isAxiosError(error)) {
81
+ const responseData = error.response?.data;
82
+ const details =
83
+ typeof responseData === 'string' ? responseData : responseData ? JSON.stringify(responseData) : error.message;
84
+ return `后端请求失败${error.response?.status ? ` (HTTP ${error.response.status})` : ''}: ${details}`;
85
+ }
86
+ return error instanceof Error ? error.message : String(error);
87
+ }
88
+
89
+ function toolText(payload) {
90
+ return {
91
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }]
92
+ };
93
+ }
94
+
95
+ function toolError(error) {
96
+ return {
97
+ isError: true,
98
+ content: [{ type: 'text', text: errorMessage(error) }]
99
+ };
100
+ }
101
+
102
+ export class TraceRecorder {
103
+ constructor(options = {}) {
104
+ this.environment = options.environment ?? process.env;
105
+ this.launchChromium = options.launchChromium ?? (launchOptions => chromium.launch(launchOptions));
106
+ this.createHttpClient =
107
+ options.createHttpClient ??
108
+ (baseURL =>
109
+ axios.create({
110
+ baseURL,
111
+ timeout: 120_000,
112
+ maxBodyLength: Infinity,
113
+ maxContentLength: Infinity
114
+ }));
115
+ this.createFormData = options.createFormData ?? (() => new FormData());
116
+ this.createReadStream = options.createReadStream ?? createReadStream;
117
+ this.makeTemporaryDirectory =
118
+ options.makeTemporaryDirectory ?? (() => mkdtemp(path.join(os.tmpdir(), 'trace-mcp-recorder-')));
119
+ this.removeTemporaryDirectory =
120
+ options.removeTemporaryDirectory ?? (temporaryDirectory => rm(temporaryDirectory, { recursive: true, force: true }));
121
+ this.setTimer = options.setTimer ?? setTimeout;
122
+ this.clearTimer = options.clearTimer ?? clearTimeout;
123
+ this.platformInfo = options.platformInfo ?? (() => ({
124
+ platform: os.platform(),
125
+ type: os.type(),
126
+ release: os.release(),
127
+ architecture: os.arch()
128
+ }));
129
+ this.activeSession = undefined;
130
+ this.lastAutomaticResult = undefined;
131
+ this.lastAutomaticError = undefined;
132
+ }
133
+
134
+ async start(input, extra = {}) {
135
+ if (this.activeSession) {
136
+ throw new Error(`已有录制任务正在运行 (uuid: ${this.activeSession.uuid}),请先停止当前录制`);
137
+ }
138
+
139
+ const configuration = readConfiguration(this.environment);
140
+ const targetUrl = validateTargetUrl(input.url.trim());
141
+ const sessionInput = {
142
+ url: targetUrl,
143
+ version: optionalTrimmed(input.version),
144
+ title: optionalTrimmed(input.title),
145
+ operator: resolveOperator(extra, input.operator)
146
+ };
147
+ const reservation = { uuid: 'pending', state: 'starting' };
148
+ this.activeSession = reservation;
149
+ this.lastAutomaticResult = undefined;
150
+ this.lastAutomaticError = undefined;
151
+ let temporaryDirectory;
152
+ let browser;
153
+ let context;
154
+ let page;
155
+
156
+ try {
157
+ temporaryDirectory = await this.makeTemporaryDirectory();
158
+ browser = await this.launchChromium({ headless: false });
159
+ context = await browser.newContext();
160
+ await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
161
+ page = await context.newPage();
162
+ await page.goto(targetUrl);
163
+
164
+ const metadata = {
165
+ version: sessionInput.version,
166
+ title: sessionInput.title,
167
+ operator: sessionInput.operator,
168
+ targetUrl,
169
+ browserName: 'Chromium',
170
+ browserVersion: browser.version(),
171
+ userAgent: await page.evaluate(() => navigator.userAgent),
172
+ viewport: page.viewportSize(),
173
+ operatingSystem: this.platformInfo(),
174
+ startedAt: new Date().toISOString()
175
+ };
176
+ const session = {
177
+ browser,
178
+ context,
179
+ page,
180
+ metadata,
181
+ baseUrl: configuration.baseUrl,
182
+ tracePath: path.join(temporaryDirectory, 'trace.zip'),
183
+ temporaryDirectory,
184
+ state: 'recording',
185
+ stopPromise: undefined,
186
+ uploadPromise: undefined,
187
+ timer: undefined
188
+ };
189
+ this.activeSession = session;
190
+
191
+ const failOnBrowserExit = () => {
192
+ if (session.state === 'recording') {
193
+ session.stopPromise = this.failSession(session, new Error('Chromium 浏览器意外退出'));
194
+ void session.stopPromise.catch(failure => {
195
+ console.error('[trace-mcp-recorder] 浏览器异常退出:', errorMessage(failure));
196
+ });
197
+ }
198
+ };
199
+ browser.once('disconnected', failOnBrowserExit);
200
+ session.timer = this.setTimer(() => {
201
+ void this.stop({ trigger: 'timeout', confirm: 'upload' }).catch(error => {
202
+ this.lastAutomaticError = error;
203
+ console.error('[trace-mcp-recorder] 自动停止录制失败:', errorMessage(error));
204
+ });
205
+ }, configuration.maxRecordDuration);
206
+ session.timer?.unref?.();
207
+
208
+ return {
209
+ message: '浏览器已打开,请手动操作页面,完成后调用 stop_trace_recording',
210
+ maxRecordDuration: configuration.maxRecordDuration,
211
+ metadata
212
+ };
213
+ } catch (error) {
214
+ await this.closeResources({ browser, context, page, temporaryDirectory });
215
+ if (this.activeSession === reservation) {
216
+ this.activeSession = undefined;
217
+ }
218
+ throw error;
219
+ }
220
+ }
221
+
222
+ async stop({ trigger = 'manual', confirm } = {}) {
223
+ const session = this.activeSession;
224
+ if (!session) {
225
+ if (trigger === 'manual' && this.lastAutomaticError) {
226
+ const error = this.lastAutomaticError;
227
+ this.lastAutomaticError = undefined;
228
+ throw error;
229
+ }
230
+ if (trigger === 'manual' && this.lastAutomaticResult) {
231
+ const result = this.lastAutomaticResult;
232
+ this.lastAutomaticResult = undefined;
233
+ return result;
234
+ }
235
+ throw new Error('当前没有正在录制的 trace 会话');
236
+ }
237
+ if (session.state === 'starting') {
238
+ throw new Error('录制会话正在启动,请等待 start_trace_recording 返回后再停止');
239
+ }
240
+ if (session.state === 'recording') {
241
+ if (session.stopPromise) return session.stopPromise;
242
+ session.state = 'stopping';
243
+ this.clearTimer(session.timer);
244
+ session.stopPromise = this.prepareSession(session).finally(() => {
245
+ session.stopPromise = undefined;
246
+ });
247
+ const prepared = await session.stopPromise;
248
+ if (trigger === 'timeout' || confirm === 'upload') return this.uploadSession(session, trigger);
249
+ return prepared;
250
+ }
251
+ if (session.state === 'stopping' && session.stopPromise) return session.stopPromise;
252
+ if (session.state === 'awaiting_upload') {
253
+ if (confirm === 'upload') return this.uploadSession(session, trigger);
254
+ if (confirm === 'cancel') return this.cancelSession(session);
255
+ return {
256
+ uuid: undefined,
257
+ message: 'trace.zip 已生成,是否上传录制内容并创建回放记录?请确认“上传”或“不上传”。',
258
+ metadata: session.metadata
259
+ };
260
+ }
261
+ if (session.state === 'uploading' && session.uploadPromise) return session.uploadPromise;
262
+ throw new Error('当前录制会话不可停止');
263
+ }
264
+
265
+ async prepareSession(session) {
266
+ try {
267
+ await session.context.tracing.stop({ path: session.tracePath });
268
+ await this.closeResources({ page: session.page, context: session.context, browser: session.browser });
269
+ session.state = 'awaiting_upload';
270
+ return {
271
+ message: 'trace.zip 已生成,是否上传录制内容并创建回放记录?请确认“上传”或“不上传”。',
272
+ metadata: session.metadata
273
+ };
274
+ } catch (error) {
275
+ session.state = 'failed';
276
+ await this.removeTemporaryDirectory(session.temporaryDirectory).catch(() => {});
277
+ if (this.activeSession === session) this.activeSession = undefined;
278
+ throw error;
279
+ }
280
+ }
281
+
282
+ async uploadSession(session, trigger) {
283
+ if (session.uploadPromise) return session.uploadPromise;
284
+ session.state = 'uploading';
285
+ session.uploadPromise = (async () => {
286
+ const configuration = readConfiguration(this.environment);
287
+ const http = this.createHttpClient(configuration.baseUrl);
288
+ const form = this.createFormData();
289
+ form.append('target_url', session.metadata.targetUrl);
290
+ for (const [field, value] of [
291
+ ['version', session.metadata.version],
292
+ ['title', session.metadata.title],
293
+ ['operator', session.metadata.operator],
294
+ ['browser_name', session.metadata.browserName],
295
+ ['browser_version', session.metadata.browserVersion],
296
+ ['user_agent', session.metadata.userAgent],
297
+ ['os_name', session.metadata.operatingSystem.platform],
298
+ ['viewport', JSON.stringify(session.metadata.viewport)]
299
+ ]) {
300
+ if (value !== undefined && value !== null) form.append(field, String(value));
301
+ }
302
+ form.append('trace', this.createReadStream(session.tracePath), {
303
+ filename: 'trace.zip',
304
+ contentType: 'application/zip'
305
+ });
306
+ const uploadResponse = await http.post('/api/trace-sessions', form, {
307
+ headers: form.getHeaders()
308
+ });
309
+ const fileUrl = uploadResponse.data?.fileUrl;
310
+ if (typeof fileUrl !== 'string' || !fileUrl.trim()) throw new Error('上传响应缺少 fileUrl');
311
+ const replayLink = `${session.baseUrl}/trace-viewer/?trace=${fileUrl}`;
312
+ const result = {
313
+ uuid: uploadResponse.data?.uuid,
314
+ message: `✅录制完成,回放链接:${replayLink}`,
315
+ replayLink,
316
+ fileUrl,
317
+ stoppedBy: trigger,
318
+ metadata: {
319
+ version: session.metadata.version,
320
+ title: session.metadata.title,
321
+ operator: session.metadata.operator,
322
+ browserVersion: session.metadata.browserVersion
323
+ }
324
+ };
325
+ session.state = 'completed';
326
+ if (trigger === 'timeout') this.lastAutomaticResult = result;
327
+ return result;
328
+ })().catch(async error => {
329
+ session.state = 'failed';
330
+ if (trigger === 'timeout') this.lastAutomaticError = error;
331
+ await this.removeTemporaryDirectory(session.temporaryDirectory).catch(() => {});
332
+ if (this.activeSession === session) this.activeSession = undefined;
333
+ throw error;
334
+ }).finally(async () => {
335
+ await this.removeTemporaryDirectory(session.temporaryDirectory).catch(() => {});
336
+ if (this.activeSession === session && session.state === 'completed') this.activeSession = undefined;
337
+ });
338
+ return session.uploadPromise;
339
+ }
340
+
341
+ async cancelSession(session) {
342
+ session.state = 'cancelled';
343
+ await this.removeTemporaryDirectory(session.temporaryDirectory).catch(() => {});
344
+ if (this.activeSession === session) this.activeSession = undefined;
345
+ return { message: '已取消上传,本地 trace.zip 已删除。' };
346
+ }
347
+
348
+ async failSession(session, error) {
349
+ if (session.state !== 'recording') return;
350
+ session.state = 'failed';
351
+ this.clearTimer(session.timer);
352
+ await this.closeResources(session);
353
+ await this.removeTemporaryDirectory(session.temporaryDirectory).catch(() => {});
354
+ if (this.activeSession === session) this.activeSession = undefined;
355
+ throw error;
356
+ }
357
+
358
+ async closeResources({ browser, context, page, temporaryDirectory }) {
359
+ // Each cleanup step is independent so one closed/crashed Playwright object cannot block the rest.
360
+ await page?.close().catch(() => {});
361
+ await context?.close().catch(() => {});
362
+ await browser?.close().catch(() => {});
363
+ if (temporaryDirectory) await this.removeTemporaryDirectory(temporaryDirectory).catch(() => {});
364
+ }
365
+ }
366
+
367
+ function registerSkillResources(server) {
368
+ const skillPath = path.join(directory, '../../skills/common/trace-recorder/SKILL.md');
369
+ if (!existsSync(skillPath)) return;
370
+ const uri = 'skill://trace-recorder/SKILL.md';
371
+ server.registerResource(
372
+ 'trace-recorder',
373
+ uri,
374
+ {
375
+ title: 'Skill: trace-recorder',
376
+ description: 'trace-mcp-recorder 的自然语言调用说明',
377
+ mimeType: 'text/markdown'
378
+ },
379
+ async () => ({
380
+ contents: [{ uri, mimeType: 'text/markdown', text: readFileSync(skillPath, 'utf8') }]
381
+ })
382
+ );
383
+ }
384
+
385
+ export function createServer(options = {}) {
386
+ const recorder = options.recorder ?? new TraceRecorder(options);
387
+ const server = new McpServer(
388
+ { name: 'trace-mcp-recorder', version: '1.0.0' },
389
+ {
390
+ instructions:
391
+ 'Use start_trace_recording to open a visible Chromium window for manual testing. ' +
392
+ 'When the user asks to stop recording, always call stop_trace_recording and return its replay link.'
393
+ }
394
+ );
395
+
396
+ server.registerTool(
397
+ 'start_trace_recording',
398
+ {
399
+ title: '启动 Playwright Trace 人工录制',
400
+ description: '启动非无头 Chromium、开启 trace 并访问指定 URL,供测试人员在浏览器窗口中手动操作。',
401
+ inputSchema: {
402
+ url: z.string().min(1).describe('必填,被测页面的 HTTP(S) 地址'),
403
+ version: z.string().min(1).optional().describe('可选,被测版本号,例如 v2.1.0'),
404
+ title: z.string().min(1).optional().describe('可选,录制标题,例如登录页面'),
405
+ operator: z.string().min(1).optional().describe('可选,操作人;MCP 上下文中的身份优先')
406
+ },
407
+ annotations: {
408
+ readOnlyHint: false,
409
+ destructiveHint: false,
410
+ idempotentHint: false,
411
+ openWorldHint: true
412
+ }
413
+ },
414
+ async (input, extra) => {
415
+ try {
416
+ return toolText(await recorder.start(input, extra));
417
+ } catch (error) {
418
+ return toolError(error);
419
+ }
420
+ }
421
+ );
422
+
423
+ server.registerTool(
424
+ 'stop_trace_recording',
425
+ {
426
+ title: '停止 Playwright Trace 录制',
427
+ description: '停止当前 trace 并询问上传;确认后上传 trace.zip 和浏览器元数据,返回回放链接。',
428
+ inputSchema: {
429
+ confirm: z.enum(['upload', 'cancel']).optional().describe('首次停止不传;用户确认上传传 upload,拒绝上传传 cancel')
430
+ },
431
+ annotations: {
432
+ readOnlyHint: false,
433
+ destructiveHint: false,
434
+ idempotentHint: false,
435
+ openWorldHint: true
436
+ }
437
+ },
438
+ async ({ confirm } = {}) => {
439
+ try {
440
+ return toolText(await recorder.stop({ confirm }));
441
+ } catch (error) {
442
+ return toolError(error);
443
+ }
444
+ }
445
+ );
446
+
447
+ registerSkillResources(server);
448
+ return server;
449
+ }
450
+
451
+ async function main() {
452
+ await createServer().connect(new StdioServerTransport());
453
+ }
454
+
455
+ let isCommandEntry = false;
456
+ if (process.argv[1]) {
457
+ try {
458
+ isCommandEntry = realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
459
+ } catch {
460
+ isCommandEntry = false;
461
+ }
462
+ }
463
+
464
+ if (isCommandEntry) {
465
+ main().catch(error => {
466
+ console.error('[trace-mcp-recorder] 服务启动失败:', errorMessage(error));
467
+ process.exitCode = 1;
468
+ });
469
+ }
@@ -0,0 +1,179 @@
1
+ import assert from 'node:assert/strict';
2
+ import { EventEmitter } from 'node:events';
3
+ import path from 'node:path';
4
+ import test from 'node:test';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
7
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
8
+ import { TraceRecorder } from './index.js';
9
+
10
+ const directory = path.dirname(fileURLToPath(import.meta.url));
11
+ const sessionUuid = '123e4567-e89b-42d3-a456-426614174000';
12
+
13
+ function createFixture(options = {}) {
14
+ const calls = [];
15
+ const browser = new EventEmitter();
16
+ const tracing = {
17
+ start: async options => calls.push(['trace-start', options]),
18
+ stop: async options => calls.push(['trace-stop', options])
19
+ };
20
+ const page = {
21
+ goto: async url => calls.push(['goto', url]),
22
+ evaluate: async () => 'Fixture User Agent',
23
+ viewportSize: () => ({ width: 1280, height: 720 }),
24
+ close: async () => calls.push(['page-close'])
25
+ };
26
+ const context = {
27
+ tracing,
28
+ newPage: async () => page,
29
+ close: async () => calls.push(['context-close'])
30
+ };
31
+ browser.newContext = async () => context;
32
+ browser.version = () => 'Fixture Chromium 1';
33
+ browser.close = async () => calls.push(['browser-close']);
34
+
35
+ const http = {
36
+ post: async (endpoint, body) => {
37
+ calls.push(['post', endpoint, body]);
38
+ if (endpoint === '/api/trace-sessions' && body !== form) return { data: { uuid: sessionUuid } };
39
+ if (options.uploadError) throw options.uploadError;
40
+ return { data: { fileUrl: '/private/traces/trace.zip' } };
41
+ },
42
+ patch: async (endpoint, body) => {
43
+ calls.push(['patch', endpoint, body]);
44
+ return { data: {} };
45
+ }
46
+ };
47
+ const form = {
48
+ append: (...args) => calls.push(['form-append', ...args]),
49
+ getHeaders: () => ({ 'content-type': 'multipart/form-data; boundary=fixture' })
50
+ };
51
+ const timer = { unref() {} };
52
+ const recorder = new TraceRecorder({
53
+ environment: {
54
+ BACKEND_BASE_URL: 'https://trace.example.test/root/',
55
+ MAX_RECORD_DURATION: '1000'
56
+ },
57
+ launchChromium: async options => {
58
+ calls.push(['launch', options]);
59
+ return browser;
60
+ },
61
+ createHttpClient: baseUrl => {
62
+ calls.push(['http-client', baseUrl]);
63
+ return http;
64
+ },
65
+ createFormData: () => form,
66
+ createReadStream: tracePath => ({ tracePath }),
67
+ makeTemporaryDirectory: async () => '/tmp/trace-fixture',
68
+ removeTemporaryDirectory: async temporaryDirectory => calls.push(['remove', temporaryDirectory]),
69
+ setTimer: (callback, duration) => {
70
+ calls.push(['timer', duration]);
71
+ timer.callback = callback;
72
+ return timer;
73
+ },
74
+ clearTimer: value => calls.push(['clear-timer', value]),
75
+ platformInfo: () => ({ platform: 'fixture-os' })
76
+ });
77
+ return { browser, calls, recorder, timer };
78
+ }
79
+
80
+ test('starts, records metadata, uploads, completes and cleans up one session', async () => {
81
+ const { calls, recorder } = createFixture();
82
+ const started = await recorder.start(
83
+ {
84
+ url: 'https://app.example.test/login',
85
+ version: 'v2.1.0',
86
+ title: 'bug3452',
87
+ operator: '传入操作人'
88
+ },
89
+ { authInfo: { extra: { operator: '上下文操作人' } } }
90
+ );
91
+
92
+ assert.equal(started.uuid, undefined);
93
+ assert.equal(started.metadata.operator, '上下文操作人');
94
+ assert.equal(started.metadata.browserVersion, 'Fixture Chromium 1');
95
+ await assert.rejects(
96
+ recorder.start({ url: 'https://app.example.test/another' }),
97
+ /已有录制任务/
98
+ );
99
+
100
+ const confirmation = await recorder.stop();
101
+ assert.match(confirmation.message, /是否上传/);
102
+ const stopped = await recorder.stop({ confirm: 'upload' });
103
+ assert.equal(
104
+ stopped.replayLink,
105
+ 'https://trace.example.test/root/trace-viewer/?trace=/private/traces/trace.zip'
106
+ );
107
+ assert.equal(stopped.metadata.title, 'bug3452');
108
+ assert.ok(calls.some(call => call[0] === 'trace-start' && call[1].sources === true));
109
+ assert.ok(calls.some(call => call[0] === 'post' && call[1] === '/api/trace-sessions'));
110
+ assert.ok(calls.some(call => call[0] === 'form-append' && call[1] === 'target_url'));
111
+ assert.ok(calls.some(call => call[0] === 'remove' && call[1] === '/tmp/trace-fixture'));
112
+ });
113
+
114
+ test('rejects missing configuration before contacting the backend', async () => {
115
+ const recorder = new TraceRecorder({ environment: {} });
116
+ await assert.rejects(recorder.start({ url: 'https://app.example.test' }), /BACKEND_BASE_URL/);
117
+ });
118
+
119
+ test('cleans up when Chromium launch fails without a backend session', async () => {
120
+ const { calls, recorder } = createFixture();
121
+ recorder.launchChromium = async () => {
122
+ throw new Error('launch failed');
123
+ };
124
+
125
+ await assert.rejects(recorder.start({ url: 'https://app.example.test' }), /launch failed/);
126
+ assert.equal(calls.some(call => call[0] === 'post'), false);
127
+ });
128
+
129
+ test('marks the session failed and removes the local trace when upload fails', async () => {
130
+ const { calls, recorder } = createFixture({ uploadError: new Error('upload failed') });
131
+ await recorder.start({ url: 'https://app.example.test' });
132
+
133
+ await recorder.stop();
134
+ await assert.rejects(recorder.stop({ confirm: 'upload' }), /upload failed/);
135
+ assert.ok(calls.some(call => call[0] === 'remove' && call[1] === '/tmp/trace-fixture'));
136
+ });
137
+
138
+ test('automatically stops on timeout and returns that result on the next stop call', async () => {
139
+ const { recorder, timer } = createFixture();
140
+ await recorder.start({ url: 'https://app.example.test' });
141
+ timer.callback();
142
+
143
+ while (recorder.activeSession) await new Promise(resolve => setImmediate(resolve));
144
+ const result = await recorder.stop();
145
+ assert.equal(result.stoppedBy, 'timeout');
146
+ await assert.rejects(recorder.stop(), /当前没有正在录制/);
147
+ });
148
+
149
+ test('exposes both tools and the bundled skill over stdio', async () => {
150
+ const client = new Client({ name: 'trace-mcp-recorder-test', version: '1.0.0' });
151
+ const transport = new StdioClientTransport({
152
+ command: process.execPath,
153
+ args: [path.join(directory, 'index.js')],
154
+ cwd: directory,
155
+ env: { ...process.env },
156
+ stderr: 'pipe'
157
+ });
158
+
159
+ try {
160
+ await client.connect(transport);
161
+ assert.equal(client.getServerVersion()?.name, 'trace-mcp-recorder');
162
+ const { tools } = await client.listTools();
163
+ assert.deepEqual(
164
+ tools.map(tool => tool.name),
165
+ ['start_trace_recording', 'stop_trace_recording']
166
+ );
167
+ const { resources } = await client.listResources();
168
+ assert.ok(resources.some(resource => resource.uri === 'skill://trace-recorder/SKILL.md'));
169
+
170
+ const result = await client.callTool({
171
+ name: 'start_trace_recording',
172
+ arguments: { url: 'https://app.example.test' }
173
+ });
174
+ assert.equal(result.isError, true);
175
+ assert.match(result.content[0].text, /BACKEND_BASE_URL/);
176
+ } finally {
177
+ await client.close();
178
+ }
179
+ });
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@autobest/trace-mcp-recorder",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "description": "MCP server for recording and uploading manual Playwright traces",
6
+ "type": "module",
7
+ "main": "index.js",
8
+ "engines": {
9
+ "node": ">=20"
10
+ },
11
+ "scripts": {
12
+ "start": "node index.js",
13
+ "lint": "node --check index.js && node --check index.test.js",
14
+ "test": "node --test index.test.js",
15
+ "verify": "npm run lint && npm test"
16
+ },
17
+ "dependencies": {
18
+ "@modelcontextprotocol/sdk": "1.30.0",
19
+ "axios": "1.20.0",
20
+ "form-data": "4.0.6",
21
+ "playwright": "1.63.0",
22
+ "uuid": "14.0.2",
23
+ "zod": "4.4.3"
24
+ }
25
+ }