@autobest-ui/agent 1.0.11 → 1.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -6
- package/bin/sync-assets.test.mjs +2 -0
- package/mcp/trace-mcp-recorder/.env.example +6 -0
- package/mcp/trace-mcp-recorder/README.md +60 -0
- package/mcp/trace-mcp-recorder/config.toml.example +11 -0
- package/mcp/trace-mcp-recorder/index.js +594 -0
- package/mcp/trace-mcp-recorder/index.test.js +189 -0
- package/mcp/trace-mcp-recorder/package-lock.json +2142 -0
- package/mcp/trace-mcp-recorder/package.json +26 -0
- package/package.json +9 -2
- package/skills/README.md +1 -0
- package/skills/common/trace-recorder/SKILL.md +25 -0
- package/skills/common/trace-recorder/agents/openai.yaml +11 -0
|
@@ -0,0 +1,594 @@
|
|
|
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 { PNG } from 'pngjs';
|
|
12
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
13
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
14
|
+
import { z } from 'zod';
|
|
15
|
+
|
|
16
|
+
const DEFAULT_MAX_RECORD_DURATION = 1_800_000;
|
|
17
|
+
const VISUAL_PROBE_INTERVAL = 500;
|
|
18
|
+
const VISUAL_DIFF_THRESHOLD = 0.005;
|
|
19
|
+
const VISUAL_DEBOUNCE_DELAY = 800;
|
|
20
|
+
const VISUAL_MAX_PENDING_DELAY = 3_000;
|
|
21
|
+
const directory = path.dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
|
|
23
|
+
function readConfiguration(environment = process.env) {
|
|
24
|
+
const configuredBaseUrl = environment.BACKEND_BASE_URL?.trim();
|
|
25
|
+
if (!configuredBaseUrl) {
|
|
26
|
+
throw new Error('BACKEND_BASE_URL 未配置,无法创建录制会话');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let baseUrl;
|
|
30
|
+
try {
|
|
31
|
+
baseUrl = new URL(configuredBaseUrl);
|
|
32
|
+
} catch {
|
|
33
|
+
throw new Error('BACKEND_BASE_URL 必须是有效的 URL');
|
|
34
|
+
}
|
|
35
|
+
if (!['http:', 'https:'].includes(baseUrl.protocol)) {
|
|
36
|
+
throw new Error('BACKEND_BASE_URL 必须使用 http 或 https 协议');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const durationText = environment.MAX_RECORD_DURATION ?? String(DEFAULT_MAX_RECORD_DURATION);
|
|
40
|
+
const maxRecordDuration = Number(durationText);
|
|
41
|
+
if (!Number.isSafeInteger(maxRecordDuration) || maxRecordDuration <= 0) {
|
|
42
|
+
throw new Error('MAX_RECORD_DURATION 必须是正整数毫秒值');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
baseUrl: baseUrl.toString().replace(/\/$/, ''),
|
|
47
|
+
maxRecordDuration
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function validateTargetUrl(value) {
|
|
52
|
+
let url;
|
|
53
|
+
try {
|
|
54
|
+
url = new URL(value);
|
|
55
|
+
} catch {
|
|
56
|
+
throw new Error('url 必须是有效的网页地址');
|
|
57
|
+
}
|
|
58
|
+
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
59
|
+
throw new Error('url 必须使用 http 或 https 协议');
|
|
60
|
+
}
|
|
61
|
+
return url.toString();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function optionalTrimmed(value) {
|
|
65
|
+
const trimmed = value?.trim();
|
|
66
|
+
return trimmed || undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Prefer authenticated/request MCP identity data, then the explicit tool argument. */
|
|
70
|
+
function resolveOperator(extra, suppliedOperator) {
|
|
71
|
+
const authExtra = extra?.authInfo?.extra;
|
|
72
|
+
const requestMeta = extra?._meta;
|
|
73
|
+
const candidates = [
|
|
74
|
+
authExtra?.operator,
|
|
75
|
+
authExtra?.name,
|
|
76
|
+
authExtra?.preferred_username,
|
|
77
|
+
requestMeta?.operator,
|
|
78
|
+
requestMeta?.user?.name,
|
|
79
|
+
suppliedOperator
|
|
80
|
+
];
|
|
81
|
+
return candidates.find(value => typeof value === 'string' && value.trim())?.trim();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function errorMessage(error) {
|
|
85
|
+
if (axios.isAxiosError(error)) {
|
|
86
|
+
const responseData = error.response?.data;
|
|
87
|
+
const details =
|
|
88
|
+
typeof responseData === 'string' ? responseData : responseData ? JSON.stringify(responseData) : error.message;
|
|
89
|
+
return `后端请求失败${error.response?.status ? ` (HTTP ${error.response.status})` : ''}: ${details}`;
|
|
90
|
+
}
|
|
91
|
+
return error instanceof Error ? error.message : String(error);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function toolText(payload) {
|
|
95
|
+
return {
|
|
96
|
+
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }]
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function toolError(error) {
|
|
101
|
+
return {
|
|
102
|
+
isError: true,
|
|
103
|
+
content: [{ type: 'text', text: errorMessage(error) }]
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Compare decoded PNG pixels. A small tolerance avoids frames caused only by
|
|
108
|
+
// anti-aliasing noise being written to the Trace.
|
|
109
|
+
export function pixelDifference(previousBuffer, currentBuffer, channelTolerance = 12) {
|
|
110
|
+
if (!previousBuffer || !currentBuffer) return 1;
|
|
111
|
+
try {
|
|
112
|
+
const previous = PNG.sync.read(previousBuffer);
|
|
113
|
+
const current = PNG.sync.read(currentBuffer);
|
|
114
|
+
if (previous.width !== current.width || previous.height !== current.height) return 1;
|
|
115
|
+
const pixelCount = previous.width * previous.height;
|
|
116
|
+
let changedPixels = 0;
|
|
117
|
+
for (let offset = 0; offset < previous.data.length; offset += 4) {
|
|
118
|
+
if (
|
|
119
|
+
Math.abs(previous.data[offset] - current.data[offset]) > channelTolerance ||
|
|
120
|
+
Math.abs(previous.data[offset + 1] - current.data[offset + 1]) > channelTolerance ||
|
|
121
|
+
Math.abs(previous.data[offset + 2] - current.data[offset + 2]) > channelTolerance ||
|
|
122
|
+
Math.abs(previous.data[offset + 3] - current.data[offset + 3]) > channelTolerance
|
|
123
|
+
) {
|
|
124
|
+
changedPixels += 1;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return changedPixels / pixelCount;
|
|
128
|
+
} catch {
|
|
129
|
+
return 1;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export class TraceRecorder {
|
|
134
|
+
constructor(options = {}) {
|
|
135
|
+
this.environment = options.environment ?? process.env;
|
|
136
|
+
this.launchChromium = options.launchChromium ?? (launchOptions => chromium.launch(launchOptions));
|
|
137
|
+
this.createHttpClient =
|
|
138
|
+
options.createHttpClient ??
|
|
139
|
+
(baseURL =>
|
|
140
|
+
axios.create({
|
|
141
|
+
baseURL,
|
|
142
|
+
timeout: 120_000,
|
|
143
|
+
maxBodyLength: Infinity,
|
|
144
|
+
maxContentLength: Infinity
|
|
145
|
+
}));
|
|
146
|
+
this.createFormData = options.createFormData ?? (() => new FormData());
|
|
147
|
+
this.createReadStream = options.createReadStream ?? createReadStream;
|
|
148
|
+
this.makeTemporaryDirectory =
|
|
149
|
+
options.makeTemporaryDirectory ?? (() => mkdtemp(path.join(os.tmpdir(), 'trace-mcp-recorder-')));
|
|
150
|
+
this.removeTemporaryDirectory =
|
|
151
|
+
options.removeTemporaryDirectory ?? (temporaryDirectory => rm(temporaryDirectory, { recursive: true, force: true }));
|
|
152
|
+
this.setTimer = options.setTimer ?? setTimeout;
|
|
153
|
+
this.clearTimer = options.clearTimer ?? clearTimeout;
|
|
154
|
+
this.setInterval = options.setInterval ?? setInterval;
|
|
155
|
+
this.clearInterval = options.clearInterval ?? clearInterval;
|
|
156
|
+
this.visualProbeInterval = options.visualProbeInterval ?? VISUAL_PROBE_INTERVAL;
|
|
157
|
+
this.visualDiffThreshold = options.visualDiffThreshold ?? VISUAL_DIFF_THRESHOLD;
|
|
158
|
+
this.visualDebounceDelay = options.visualDebounceDelay ?? VISUAL_DEBOUNCE_DELAY;
|
|
159
|
+
this.visualMaxPendingDelay = options.visualMaxPendingDelay ?? VISUAL_MAX_PENDING_DELAY;
|
|
160
|
+
this.platformInfo = options.platformInfo ?? (() => ({
|
|
161
|
+
platform: os.platform(),
|
|
162
|
+
type: os.type(),
|
|
163
|
+
release: os.release(),
|
|
164
|
+
architecture: os.arch()
|
|
165
|
+
}));
|
|
166
|
+
this.activeSession = undefined;
|
|
167
|
+
this.lastAutomaticResult = undefined;
|
|
168
|
+
this.lastAutomaticError = undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async start(input, extra = {}) {
|
|
172
|
+
if (this.activeSession) {
|
|
173
|
+
throw new Error(`已有录制任务正在运行 (uuid: ${this.activeSession.uuid}),请先停止当前录制`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const configuration = readConfiguration(this.environment);
|
|
177
|
+
const targetUrl = validateTargetUrl(input.url.trim());
|
|
178
|
+
const sessionInput = {
|
|
179
|
+
url: targetUrl,
|
|
180
|
+
version: optionalTrimmed(input.version),
|
|
181
|
+
title: optionalTrimmed(input.title),
|
|
182
|
+
operator: resolveOperator(extra, input.operator)
|
|
183
|
+
};
|
|
184
|
+
const reservation = { uuid: 'pending', state: 'starting' };
|
|
185
|
+
this.activeSession = reservation;
|
|
186
|
+
this.lastAutomaticResult = undefined;
|
|
187
|
+
this.lastAutomaticError = undefined;
|
|
188
|
+
let temporaryDirectory;
|
|
189
|
+
let browser;
|
|
190
|
+
let context;
|
|
191
|
+
let page;
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
temporaryDirectory = await this.makeTemporaryDirectory();
|
|
195
|
+
browser = await this.launchChromium({ headless: false });
|
|
196
|
+
context = await browser.newContext();
|
|
197
|
+
await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
|
|
198
|
+
page = await context.newPage();
|
|
199
|
+
await page.goto(targetUrl);
|
|
200
|
+
|
|
201
|
+
const metadata = {
|
|
202
|
+
version: sessionInput.version,
|
|
203
|
+
title: sessionInput.title,
|
|
204
|
+
operator: sessionInput.operator,
|
|
205
|
+
targetUrl,
|
|
206
|
+
browserName: 'Chromium',
|
|
207
|
+
browserVersion: browser.version(),
|
|
208
|
+
userAgent: await page.evaluate(() => navigator.userAgent),
|
|
209
|
+
viewport: page.viewportSize(),
|
|
210
|
+
operatingSystem: this.platformInfo(),
|
|
211
|
+
startedAt: new Date().toISOString()
|
|
212
|
+
};
|
|
213
|
+
const session = {
|
|
214
|
+
browser,
|
|
215
|
+
context,
|
|
216
|
+
page,
|
|
217
|
+
metadata,
|
|
218
|
+
baseUrl: configuration.baseUrl,
|
|
219
|
+
tracePath: path.join(temporaryDirectory, 'trace.zip'),
|
|
220
|
+
temporaryDirectory,
|
|
221
|
+
state: 'recording',
|
|
222
|
+
stopPromise: undefined,
|
|
223
|
+
uploadPromise: undefined,
|
|
224
|
+
timer: undefined,
|
|
225
|
+
visualProbeTimer: undefined,
|
|
226
|
+
visualProbePromise: undefined,
|
|
227
|
+
visualDebounceTimer: undefined,
|
|
228
|
+
visualRecordPromise: undefined,
|
|
229
|
+
visualPendingSince: undefined,
|
|
230
|
+
cdpSession: undefined,
|
|
231
|
+
previousProbeFrame: undefined,
|
|
232
|
+
lastRecordedFrameAt: Date.now()
|
|
233
|
+
};
|
|
234
|
+
this.activeSession = session;
|
|
235
|
+
await this.startVisualCapture(session);
|
|
236
|
+
|
|
237
|
+
const failOnBrowserExit = () => {
|
|
238
|
+
if (session.state === 'recording') {
|
|
239
|
+
session.stopPromise = this.failSession(session, new Error('Chromium 浏览器意外退出'));
|
|
240
|
+
void session.stopPromise.catch(failure => {
|
|
241
|
+
console.error('[trace-mcp-recorder] 浏览器异常退出:', errorMessage(failure));
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
browser.once('disconnected', failOnBrowserExit);
|
|
246
|
+
session.timer = this.setTimer(() => {
|
|
247
|
+
void this.stop({ trigger: 'timeout', confirm: 'upload' }).catch(error => {
|
|
248
|
+
this.lastAutomaticError = error;
|
|
249
|
+
console.error('[trace-mcp-recorder] 自动停止录制失败:', errorMessage(error));
|
|
250
|
+
});
|
|
251
|
+
}, configuration.maxRecordDuration);
|
|
252
|
+
session.timer?.unref?.();
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
message: '浏览器已打开,请手动操作页面,完成后调用 stop_trace_recording',
|
|
256
|
+
maxRecordDuration: configuration.maxRecordDuration,
|
|
257
|
+
metadata
|
|
258
|
+
};
|
|
259
|
+
} catch (error) {
|
|
260
|
+
await this.closeResources({ browser, context, page, temporaryDirectory });
|
|
261
|
+
if (this.activeSession === reservation) {
|
|
262
|
+
this.activeSession = undefined;
|
|
263
|
+
}
|
|
264
|
+
throw error;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async stop({ trigger = 'manual', confirm } = {}) {
|
|
269
|
+
const session = this.activeSession;
|
|
270
|
+
if (!session) {
|
|
271
|
+
if (trigger === 'manual' && this.lastAutomaticError) {
|
|
272
|
+
const error = this.lastAutomaticError;
|
|
273
|
+
this.lastAutomaticError = undefined;
|
|
274
|
+
throw error;
|
|
275
|
+
}
|
|
276
|
+
if (trigger === 'manual' && this.lastAutomaticResult) {
|
|
277
|
+
const result = this.lastAutomaticResult;
|
|
278
|
+
this.lastAutomaticResult = undefined;
|
|
279
|
+
return result;
|
|
280
|
+
}
|
|
281
|
+
throw new Error('当前没有正在录制的 trace 会话');
|
|
282
|
+
}
|
|
283
|
+
if (session.state === 'starting') {
|
|
284
|
+
throw new Error('录制会话正在启动,请等待 start_trace_recording 返回后再停止');
|
|
285
|
+
}
|
|
286
|
+
if (session.state === 'recording') {
|
|
287
|
+
if (session.stopPromise) return session.stopPromise;
|
|
288
|
+
session.state = 'stopping';
|
|
289
|
+
this.clearTimer(session.timer);
|
|
290
|
+
session.stopPromise = this.prepareSession(session).finally(() => {
|
|
291
|
+
session.stopPromise = undefined;
|
|
292
|
+
});
|
|
293
|
+
const prepared = await session.stopPromise;
|
|
294
|
+
if (trigger === 'timeout' || confirm === 'upload') return this.uploadSession(session, trigger);
|
|
295
|
+
return prepared;
|
|
296
|
+
}
|
|
297
|
+
if (session.state === 'stopping' && session.stopPromise) return session.stopPromise;
|
|
298
|
+
if (session.state === 'awaiting_upload') {
|
|
299
|
+
if (confirm === 'upload') return this.uploadSession(session, trigger);
|
|
300
|
+
if (confirm === 'cancel') return this.cancelSession(session);
|
|
301
|
+
return {
|
|
302
|
+
uuid: undefined,
|
|
303
|
+
message: 'trace.zip 已生成,是否上传录制内容并创建回放记录?请确认“上传”或“不上传”。',
|
|
304
|
+
metadata: session.metadata
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
if (session.state === 'uploading' && session.uploadPromise) return session.uploadPromise;
|
|
308
|
+
throw new Error('当前录制会话不可停止');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async prepareSession(session) {
|
|
312
|
+
try {
|
|
313
|
+
await this.stopVisualCapture(session, true);
|
|
314
|
+
await session.context.tracing.stop({ path: session.tracePath });
|
|
315
|
+
await this.closeResources({ page: session.page, context: session.context, browser: session.browser });
|
|
316
|
+
session.state = 'awaiting_upload';
|
|
317
|
+
return {
|
|
318
|
+
message: 'trace.zip 已生成,是否上传录制内容并创建回放记录?请确认“上传”或“不上传”。',
|
|
319
|
+
metadata: session.metadata
|
|
320
|
+
};
|
|
321
|
+
} catch (error) {
|
|
322
|
+
session.state = 'failed';
|
|
323
|
+
await this.removeTemporaryDirectory(session.temporaryDirectory).catch(() => {});
|
|
324
|
+
if (this.activeSession === session) this.activeSession = undefined;
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async startVisualCapture(session) {
|
|
330
|
+
if (typeof session.context.newCDPSession !== 'function') return;
|
|
331
|
+
session.cdpSession = await session.context.newCDPSession(session.page);
|
|
332
|
+
await session.cdpSession.send('Page.enable');
|
|
333
|
+
session.previousProbeFrame = await this.captureProbeFrame(session);
|
|
334
|
+
session.visualProbeTimer = this.setInterval(() => {
|
|
335
|
+
if (session.visualProbePromise) return;
|
|
336
|
+
session.visualProbePromise = this.probeAndRecordVisualChange(session).catch(error => {
|
|
337
|
+
if (session.state === 'recording') {
|
|
338
|
+
console.error('[trace-mcp-recorder] 画面差异探测失败:', errorMessage(error));
|
|
339
|
+
}
|
|
340
|
+
}).finally(() => {
|
|
341
|
+
session.visualProbePromise = undefined;
|
|
342
|
+
});
|
|
343
|
+
}, this.visualProbeInterval);
|
|
344
|
+
session.visualProbeTimer?.unref?.();
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async captureProbeFrame(session) {
|
|
348
|
+
const result = await session.cdpSession.send('Page.captureScreenshot', {
|
|
349
|
+
format: 'png',
|
|
350
|
+
fromSurface: true
|
|
351
|
+
});
|
|
352
|
+
return Buffer.from(result.data, 'base64');
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async probeAndRecordVisualChange(session) {
|
|
356
|
+
const currentFrame = await this.captureProbeFrame(session);
|
|
357
|
+
const difference = pixelDifference(session.previousProbeFrame, currentFrame);
|
|
358
|
+
session.previousProbeFrame = currentFrame;
|
|
359
|
+
if (difference >= this.visualDiffThreshold) {
|
|
360
|
+
this.scheduleVisualRecord(session);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
scheduleVisualRecord(session) {
|
|
365
|
+
const now = Date.now();
|
|
366
|
+
session.visualPendingSince ??= now;
|
|
367
|
+
if (session.visualDebounceTimer) this.clearTimer(session.visualDebounceTimer);
|
|
368
|
+
const pendingFor = now - session.visualPendingSince;
|
|
369
|
+
const delay = pendingFor >= this.visualMaxPendingDelay ? 0 : this.visualDebounceDelay;
|
|
370
|
+
session.visualDebounceTimer = this.setTimer(() => {
|
|
371
|
+
session.visualDebounceTimer = undefined;
|
|
372
|
+
session.visualRecordPromise = this.recordPendingVisual(session).finally(() => {
|
|
373
|
+
session.visualRecordPromise = undefined;
|
|
374
|
+
});
|
|
375
|
+
}, delay);
|
|
376
|
+
session.visualDebounceTimer?.unref?.();
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async recordPendingVisual(session) {
|
|
380
|
+
if (session.state !== 'recording' || !session.visualPendingSince) return;
|
|
381
|
+
await session.page.screenshot();
|
|
382
|
+
session.lastRecordedFrameAt = Date.now();
|
|
383
|
+
session.visualPendingSince = undefined;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async stopVisualCapture(session, recordFinalFrame = false) {
|
|
387
|
+
if (!session.cdpSession) return;
|
|
388
|
+
if (session.visualProbeTimer) {
|
|
389
|
+
this.clearInterval(session.visualProbeTimer);
|
|
390
|
+
session.visualProbeTimer = undefined;
|
|
391
|
+
}
|
|
392
|
+
await session.visualProbePromise?.catch(() => {});
|
|
393
|
+
if (session.visualDebounceTimer) {
|
|
394
|
+
this.clearTimer(session.visualDebounceTimer);
|
|
395
|
+
session.visualDebounceTimer = undefined;
|
|
396
|
+
}
|
|
397
|
+
await session.visualRecordPromise?.catch(() => {});
|
|
398
|
+
session.visualPendingSince = undefined;
|
|
399
|
+
if (recordFinalFrame && session.page) {
|
|
400
|
+
await session.page.screenshot().catch(() => {});
|
|
401
|
+
}
|
|
402
|
+
await session.cdpSession?.detach().catch(() => {});
|
|
403
|
+
session.cdpSession = undefined;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async uploadSession(session, trigger) {
|
|
407
|
+
if (session.uploadPromise) return session.uploadPromise;
|
|
408
|
+
session.state = 'uploading';
|
|
409
|
+
session.uploadPromise = (async () => {
|
|
410
|
+
const configuration = readConfiguration(this.environment);
|
|
411
|
+
const http = this.createHttpClient(configuration.baseUrl);
|
|
412
|
+
const form = this.createFormData();
|
|
413
|
+
form.append('target_url', session.metadata.targetUrl);
|
|
414
|
+
for (const [field, value] of [
|
|
415
|
+
['version', session.metadata.version],
|
|
416
|
+
['title', session.metadata.title],
|
|
417
|
+
['operator', session.metadata.operator],
|
|
418
|
+
['browser_name', session.metadata.browserName],
|
|
419
|
+
['browser_version', session.metadata.browserVersion],
|
|
420
|
+
['user_agent', session.metadata.userAgent],
|
|
421
|
+
['os_name', session.metadata.operatingSystem.platform],
|
|
422
|
+
['viewport', JSON.stringify(session.metadata.viewport)]
|
|
423
|
+
]) {
|
|
424
|
+
if (value !== undefined && value !== null) form.append(field, String(value));
|
|
425
|
+
}
|
|
426
|
+
form.append('trace', this.createReadStream(session.tracePath), {
|
|
427
|
+
filename: 'trace.zip',
|
|
428
|
+
contentType: 'application/zip'
|
|
429
|
+
});
|
|
430
|
+
const uploadResponse = await http.post('/api/trace-sessions', form, {
|
|
431
|
+
headers: form.getHeaders()
|
|
432
|
+
});
|
|
433
|
+
const fileUrl = uploadResponse.data?.fileUrl;
|
|
434
|
+
if (typeof fileUrl !== 'string' || !fileUrl.trim()) throw new Error('上传响应缺少 fileUrl');
|
|
435
|
+
const replayLink = `${session.baseUrl}/trace-viewer/?trace=${fileUrl}`;
|
|
436
|
+
const result = {
|
|
437
|
+
uuid: uploadResponse.data?.uuid,
|
|
438
|
+
message: `✅录制完成,回放链接:${replayLink}`,
|
|
439
|
+
replayLink,
|
|
440
|
+
fileUrl,
|
|
441
|
+
stoppedBy: trigger,
|
|
442
|
+
metadata: {
|
|
443
|
+
version: session.metadata.version,
|
|
444
|
+
title: session.metadata.title,
|
|
445
|
+
operator: session.metadata.operator,
|
|
446
|
+
browserVersion: session.metadata.browserVersion
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
session.state = 'completed';
|
|
450
|
+
if (trigger === 'timeout') this.lastAutomaticResult = result;
|
|
451
|
+
return result;
|
|
452
|
+
})().catch(async error => {
|
|
453
|
+
session.state = 'failed';
|
|
454
|
+
if (trigger === 'timeout') this.lastAutomaticError = error;
|
|
455
|
+
await this.removeTemporaryDirectory(session.temporaryDirectory).catch(() => {});
|
|
456
|
+
if (this.activeSession === session) this.activeSession = undefined;
|
|
457
|
+
throw error;
|
|
458
|
+
}).finally(async () => {
|
|
459
|
+
await this.removeTemporaryDirectory(session.temporaryDirectory).catch(() => {});
|
|
460
|
+
if (this.activeSession === session && session.state === 'completed') this.activeSession = undefined;
|
|
461
|
+
});
|
|
462
|
+
return session.uploadPromise;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async cancelSession(session) {
|
|
466
|
+
session.state = 'cancelled';
|
|
467
|
+
await this.removeTemporaryDirectory(session.temporaryDirectory).catch(() => {});
|
|
468
|
+
if (this.activeSession === session) this.activeSession = undefined;
|
|
469
|
+
return { message: '已取消上传,本地 trace.zip 已删除。' };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async failSession(session, error) {
|
|
473
|
+
if (session.state !== 'recording') return;
|
|
474
|
+
session.state = 'failed';
|
|
475
|
+
this.clearTimer(session.timer);
|
|
476
|
+
await this.stopVisualCapture(session);
|
|
477
|
+
await this.closeResources(session);
|
|
478
|
+
await this.removeTemporaryDirectory(session.temporaryDirectory).catch(() => {});
|
|
479
|
+
if (this.activeSession === session) this.activeSession = undefined;
|
|
480
|
+
throw error;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async closeResources({ browser, context, page, temporaryDirectory }) {
|
|
484
|
+
// Each cleanup step is independent so one closed/crashed Playwright object cannot block the rest.
|
|
485
|
+
await page?.close().catch(() => {});
|
|
486
|
+
await context?.close().catch(() => {});
|
|
487
|
+
await browser?.close().catch(() => {});
|
|
488
|
+
if (temporaryDirectory) await this.removeTemporaryDirectory(temporaryDirectory).catch(() => {});
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function registerSkillResources(server) {
|
|
493
|
+
const skillPath = path.join(directory, '../../skills/common/trace-recorder/SKILL.md');
|
|
494
|
+
if (!existsSync(skillPath)) return;
|
|
495
|
+
const uri = 'skill://trace-recorder/SKILL.md';
|
|
496
|
+
server.registerResource(
|
|
497
|
+
'trace-recorder',
|
|
498
|
+
uri,
|
|
499
|
+
{
|
|
500
|
+
title: 'Skill: trace-recorder',
|
|
501
|
+
description: 'trace-mcp-recorder 的自然语言调用说明',
|
|
502
|
+
mimeType: 'text/markdown'
|
|
503
|
+
},
|
|
504
|
+
async () => ({
|
|
505
|
+
contents: [{ uri, mimeType: 'text/markdown', text: readFileSync(skillPath, 'utf8') }]
|
|
506
|
+
})
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export function createServer(options = {}) {
|
|
511
|
+
const recorder = options.recorder ?? new TraceRecorder(options);
|
|
512
|
+
const server = new McpServer(
|
|
513
|
+
{ name: 'trace-mcp-recorder', version: '1.0.0' },
|
|
514
|
+
{
|
|
515
|
+
instructions:
|
|
516
|
+
'Use start_trace_recording to open a visible Chromium window for manual testing. ' +
|
|
517
|
+
'When the user asks to stop recording, always call stop_trace_recording and return its replay link.'
|
|
518
|
+
}
|
|
519
|
+
);
|
|
520
|
+
|
|
521
|
+
server.registerTool(
|
|
522
|
+
'start_trace_recording',
|
|
523
|
+
{
|
|
524
|
+
title: '启动 Playwright Trace 人工录制',
|
|
525
|
+
description: '启动非无头 Chromium、开启 trace 并访问指定 URL,供测试人员在浏览器窗口中手动操作。',
|
|
526
|
+
inputSchema: {
|
|
527
|
+
url: z.string().min(1).describe('必填,被测页面的 HTTP(S) 地址'),
|
|
528
|
+
version: z.string().min(1).optional().describe('可选,被测版本号,例如 v2.1.0'),
|
|
529
|
+
title: z.string().min(1).optional().describe('可选,录制标题,例如登录页面'),
|
|
530
|
+
operator: z.string().min(1).optional().describe('可选,操作人;MCP 上下文中的身份优先')
|
|
531
|
+
},
|
|
532
|
+
annotations: {
|
|
533
|
+
readOnlyHint: false,
|
|
534
|
+
destructiveHint: false,
|
|
535
|
+
idempotentHint: false,
|
|
536
|
+
openWorldHint: true
|
|
537
|
+
}
|
|
538
|
+
},
|
|
539
|
+
async (input, extra) => {
|
|
540
|
+
try {
|
|
541
|
+
return toolText(await recorder.start(input, extra));
|
|
542
|
+
} catch (error) {
|
|
543
|
+
return toolError(error);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
server.registerTool(
|
|
549
|
+
'stop_trace_recording',
|
|
550
|
+
{
|
|
551
|
+
title: '停止 Playwright Trace 录制',
|
|
552
|
+
description: '停止当前 trace 并询问上传;确认后上传 trace.zip 和浏览器元数据,返回回放链接。',
|
|
553
|
+
inputSchema: {
|
|
554
|
+
confirm: z.enum(['upload', 'cancel']).optional().describe('首次停止不传;用户确认上传传 upload,拒绝上传传 cancel')
|
|
555
|
+
},
|
|
556
|
+
annotations: {
|
|
557
|
+
readOnlyHint: false,
|
|
558
|
+
destructiveHint: false,
|
|
559
|
+
idempotentHint: false,
|
|
560
|
+
openWorldHint: true
|
|
561
|
+
}
|
|
562
|
+
},
|
|
563
|
+
async ({ confirm } = {}) => {
|
|
564
|
+
try {
|
|
565
|
+
return toolText(await recorder.stop({ confirm }));
|
|
566
|
+
} catch (error) {
|
|
567
|
+
return toolError(error);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
);
|
|
571
|
+
|
|
572
|
+
registerSkillResources(server);
|
|
573
|
+
return server;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async function main() {
|
|
577
|
+
await createServer().connect(new StdioServerTransport());
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
let isCommandEntry = false;
|
|
581
|
+
if (process.argv[1]) {
|
|
582
|
+
try {
|
|
583
|
+
isCommandEntry = realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
584
|
+
} catch {
|
|
585
|
+
isCommandEntry = false;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (isCommandEntry) {
|
|
590
|
+
main().catch(error => {
|
|
591
|
+
console.error('[trace-mcp-recorder] 服务启动失败:', errorMessage(error));
|
|
592
|
+
process.exitCode = 1;
|
|
593
|
+
});
|
|
594
|
+
}
|