@autobest-ui/agent 1.0.14 → 1.0.16
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/mcp/trace-mcp-recorder/README.md +2 -2
- package/mcp/trace-mcp-recorder/index.js +169 -144
- package/mcp/trace-mcp-recorder/index.test.js +26 -13
- package/mcp/trace-mcp-recorder/package-lock.json +0 -14
- package/mcp/trace-mcp-recorder/package.json +0 -1
- package/package.json +1 -1
- package/skills/common/trace-recorder/SKILL.md +3 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# trace-mcp-recorder
|
|
2
2
|
|
|
3
|
-
`trace-mcp-recorder` 是基于 `@modelcontextprotocol/sdk` 和 `StdioServerTransport` 的 Node.js MCP Server。它打开非无头 Chromium
|
|
3
|
+
`trace-mcp-recorder` 是基于 `@modelcontextprotocol/sdk` 和 `StdioServerTransport` 的 Node.js MCP Server。它打开非无头 Chromium 供测试人员手动操作,页面加载后等待第一次真实用户操作才启动 Trace;后续用户活动经过防抖后以 `步骤N | 页面标题 | 事件类型` 的 Trace Group 写入轻量 Playwright Action,空闲超过 2 分钟自动结束,然后上传原生 `trace.zip` 和浏览器元数据,并返回私有后端的 Trace Viewer 链接。同一进程同一时间只允许一个录制任务。
|
|
4
4
|
|
|
5
5
|
## 安装与启动
|
|
6
6
|
|
|
@@ -43,7 +43,7 @@ npm start
|
|
|
43
43
|
|
|
44
44
|
录制超时后服务自动停止并自动上传(无需人工确认),随后第一次人工调用 `stop_trace_recording` 会取回自动停止的结果;再调用则返回没有活动会话。
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
Trace 在第一次真实用户操作后才开始计时。连续用户操作停止 800ms 后写入一个带自定义名称的 `tracing.group()`,组内使用 `waitForTimeout(1)` 推进官方 Viewer 播放;连续 2 分钟没有真实用户操作时自动停止并上传。停止录制时会补一个最终 Action。页面自身的轮播、动画和图片加载不会触发录制节点。官方 Viewer 的具体显示可能是 Group 标签或 Group 内嵌 Wait 节点,取决于 Viewer 版本。
|
|
47
47
|
|
|
48
48
|
## 后端接口契约
|
|
49
49
|
|
|
@@ -8,16 +8,13 @@ import { fileURLToPath } from 'node:url';
|
|
|
8
8
|
import axios from 'axios';
|
|
9
9
|
import FormData from 'form-data';
|
|
10
10
|
import { chromium } from 'playwright';
|
|
11
|
-
import { PNG } from 'pngjs';
|
|
12
11
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
13
12
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
14
13
|
import { z } from 'zod';
|
|
15
14
|
|
|
16
15
|
const DEFAULT_MAX_RECORD_DURATION = 1_800_000;
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
const VISUAL_DEBOUNCE_DELAY = 800;
|
|
20
|
-
const VISUAL_MAX_PENDING_DELAY = 3_000;
|
|
16
|
+
const USER_ACTIVITY_DEBOUNCE_DELAY = 800;
|
|
17
|
+
const IDLE_STOP_DELAY = 120_000;
|
|
21
18
|
const directory = path.dirname(fileURLToPath(import.meta.url));
|
|
22
19
|
|
|
23
20
|
function readConfiguration(environment = process.env) {
|
|
@@ -104,30 +101,21 @@ function toolError(error) {
|
|
|
104
101
|
};
|
|
105
102
|
}
|
|
106
103
|
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
}
|
|
104
|
+
// Only emit a signal for trusted user input. The Node side stores no action
|
|
105
|
+
// details; it uses the signal to control Trace timing and debounce checkpoints.
|
|
106
|
+
function createUserActivityScript() {
|
|
107
|
+
return `(() => {
|
|
108
|
+
const notify = type => Promise.resolve(globalThis.__traceRecorderUserActivity?.({
|
|
109
|
+
type,
|
|
110
|
+
title: document.title || '未命名页面'
|
|
111
|
+
})).catch(() => {});
|
|
112
|
+
const options = { capture: true, passive: true };
|
|
113
|
+
for (const type of ['click', 'keydown', 'input', 'change', 'wheel', 'touchstart']) {
|
|
114
|
+
document.addEventListener(type, event => {
|
|
115
|
+
if (event.isTrusted) notify(type);
|
|
116
|
+
}, options);
|
|
126
117
|
}
|
|
127
|
-
|
|
128
|
-
} catch {
|
|
129
|
-
return 1;
|
|
130
|
-
}
|
|
118
|
+
})();`;
|
|
131
119
|
}
|
|
132
120
|
|
|
133
121
|
export class TraceRecorder {
|
|
@@ -148,21 +136,18 @@ export class TraceRecorder {
|
|
|
148
136
|
this.makeTemporaryDirectory =
|
|
149
137
|
options.makeTemporaryDirectory ?? (() => mkdtemp(path.join(os.tmpdir(), 'trace-mcp-recorder-')));
|
|
150
138
|
this.removeTemporaryDirectory =
|
|
151
|
-
options.removeTemporaryDirectory ??
|
|
139
|
+
options.removeTemporaryDirectory ??
|
|
140
|
+
(temporaryDirectory => rm(temporaryDirectory, { recursive: true, force: true }));
|
|
152
141
|
this.setTimer = options.setTimer ?? setTimeout;
|
|
153
142
|
this.clearTimer = options.clearTimer ?? clearTimeout;
|
|
154
|
-
this.
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
type: os.type(),
|
|
163
|
-
release: os.release(),
|
|
164
|
-
architecture: os.arch()
|
|
165
|
-
}));
|
|
143
|
+
this.platformInfo =
|
|
144
|
+
options.platformInfo ??
|
|
145
|
+
(() => ({
|
|
146
|
+
platform: os.platform(),
|
|
147
|
+
type: os.type(),
|
|
148
|
+
release: os.release(),
|
|
149
|
+
architecture: os.arch()
|
|
150
|
+
}));
|
|
166
151
|
this.activeSession = undefined;
|
|
167
152
|
this.lastAutomaticResult = undefined;
|
|
168
153
|
this.lastAutomaticError = undefined;
|
|
@@ -194,7 +179,16 @@ export class TraceRecorder {
|
|
|
194
179
|
temporaryDirectory = await this.makeTemporaryDirectory();
|
|
195
180
|
browser = await this.launchChromium({ headless: false });
|
|
196
181
|
context = await browser.newContext();
|
|
197
|
-
|
|
182
|
+
let session;
|
|
183
|
+
if (typeof context.exposeBinding === 'function') {
|
|
184
|
+
await context.exposeBinding('__traceRecorderUserActivity', (_source, activity) => {
|
|
185
|
+
if (session) return this.handleUserActivity(session, activity);
|
|
186
|
+
return undefined;
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
if (typeof context.addInitScript === 'function') {
|
|
190
|
+
await context.addInitScript({ content: createUserActivityScript() });
|
|
191
|
+
}
|
|
198
192
|
page = await context.newPage();
|
|
199
193
|
await page.goto(targetUrl);
|
|
200
194
|
|
|
@@ -210,32 +204,32 @@ export class TraceRecorder {
|
|
|
210
204
|
operatingSystem: this.platformInfo(),
|
|
211
205
|
startedAt: new Date().toISOString()
|
|
212
206
|
};
|
|
213
|
-
|
|
207
|
+
session = {
|
|
214
208
|
browser,
|
|
215
209
|
context,
|
|
216
210
|
page,
|
|
217
211
|
metadata,
|
|
218
212
|
baseUrl: configuration.baseUrl,
|
|
213
|
+
maxRecordDuration: configuration.maxRecordDuration,
|
|
219
214
|
tracePath: path.join(temporaryDirectory, 'trace.zip'),
|
|
220
215
|
temporaryDirectory,
|
|
221
|
-
state: '
|
|
216
|
+
state: 'waiting_for_activity',
|
|
222
217
|
stopPromise: undefined,
|
|
223
218
|
uploadPromise: undefined,
|
|
224
219
|
timer: undefined,
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
220
|
+
idleTimer: undefined,
|
|
221
|
+
activityDebounceTimer: undefined,
|
|
222
|
+
activityPromise: undefined,
|
|
223
|
+
activityRecordPromise: undefined,
|
|
224
|
+
pendingActivity: undefined,
|
|
225
|
+
stepIndex: 0,
|
|
226
|
+
tracingStarted: false,
|
|
227
|
+
lastActivityAt: undefined
|
|
233
228
|
};
|
|
234
229
|
this.activeSession = session;
|
|
235
|
-
await this.startVisualCapture(session);
|
|
236
230
|
|
|
237
231
|
const failOnBrowserExit = () => {
|
|
238
|
-
if (session.state === 'recording') {
|
|
232
|
+
if (session.state === 'recording' || session.state === 'waiting_for_activity') {
|
|
239
233
|
session.stopPromise = this.failSession(session, new Error('Chromium 浏览器意外退出'));
|
|
240
234
|
void session.stopPromise.catch(failure => {
|
|
241
235
|
console.error('[trace-mcp-recorder] 浏览器异常退出:', errorMessage(failure));
|
|
@@ -243,16 +237,9 @@ export class TraceRecorder {
|
|
|
243
237
|
}
|
|
244
238
|
};
|
|
245
239
|
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
240
|
|
|
254
241
|
return {
|
|
255
|
-
message: '
|
|
242
|
+
message: '浏览器已打开,等待首次真实操作后开始录制;完成后调用 stop_trace_recording',
|
|
256
243
|
maxRecordDuration: configuration.maxRecordDuration,
|
|
257
244
|
metadata
|
|
258
245
|
};
|
|
@@ -283,10 +270,34 @@ export class TraceRecorder {
|
|
|
283
270
|
if (session.state === 'starting') {
|
|
284
271
|
throw new Error('录制会话正在启动,请等待 start_trace_recording 返回后再停止');
|
|
285
272
|
}
|
|
273
|
+
if (session.state === 'waiting_for_activity') {
|
|
274
|
+
session.state = 'stopping';
|
|
275
|
+
this.clearTimer(session.timer);
|
|
276
|
+
this.clearTimer(session.idleTimer);
|
|
277
|
+
this.clearTimer(session.activityDebounceTimer);
|
|
278
|
+
await session.activityPromise?.catch(() => {});
|
|
279
|
+
if (session.tracingStarted) {
|
|
280
|
+
session.stopPromise = this.prepareSession(session).finally(() => {
|
|
281
|
+
session.stopPromise = undefined;
|
|
282
|
+
});
|
|
283
|
+
const prepared = await session.stopPromise;
|
|
284
|
+
if (trigger === 'timeout' || confirm === 'upload') return this.uploadSession(session, trigger);
|
|
285
|
+
return prepared;
|
|
286
|
+
}
|
|
287
|
+
await this.closeResources({ page: session.page, context: session.context, browser: session.browser });
|
|
288
|
+
if (this.activeSession === session) this.activeSession = undefined;
|
|
289
|
+
return {
|
|
290
|
+
state: 'not_started',
|
|
291
|
+
uploaded: false,
|
|
292
|
+
message: '录制已停止。停止前未检测到真实用户操作,因此没有生成可上传的 trace。'
|
|
293
|
+
};
|
|
294
|
+
}
|
|
286
295
|
if (session.state === 'recording') {
|
|
287
296
|
if (session.stopPromise) return session.stopPromise;
|
|
288
297
|
session.state = 'stopping';
|
|
289
298
|
this.clearTimer(session.timer);
|
|
299
|
+
this.clearTimer(session.idleTimer);
|
|
300
|
+
this.clearTimer(session.activityDebounceTimer);
|
|
290
301
|
session.stopPromise = this.prepareSession(session).finally(() => {
|
|
291
302
|
session.stopPromise = undefined;
|
|
292
303
|
});
|
|
@@ -310,7 +321,9 @@ export class TraceRecorder {
|
|
|
310
321
|
|
|
311
322
|
async prepareSession(session) {
|
|
312
323
|
try {
|
|
313
|
-
await
|
|
324
|
+
await session.activityPromise?.catch(() => {});
|
|
325
|
+
await session.activityRecordPromise?.catch(() => {});
|
|
326
|
+
if (session.tracingStarted) await this.recordFinalStep(session);
|
|
314
327
|
await session.context.tracing.stop({ path: session.tracePath });
|
|
315
328
|
await this.closeResources({ page: session.page, context: session.context, browser: session.browser });
|
|
316
329
|
session.state = 'awaiting_upload';
|
|
@@ -326,84 +339,90 @@ export class TraceRecorder {
|
|
|
326
339
|
}
|
|
327
340
|
}
|
|
328
341
|
|
|
329
|
-
async
|
|
330
|
-
if (
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
session.
|
|
334
|
-
session.
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
}
|
|
340
|
-
}).finally(() => {
|
|
341
|
-
session.visualProbePromise = undefined;
|
|
342
|
+
async beginRecording(session, activity = {}) {
|
|
343
|
+
if (session.tracingStarted || session.state !== 'waiting_for_activity') return;
|
|
344
|
+
await session.context.tracing.start({ screenshots: true, snapshots: true, sources: true });
|
|
345
|
+
session.tracingStarted = true;
|
|
346
|
+
session.state = 'recording';
|
|
347
|
+
session.lastActivityAt = Date.now();
|
|
348
|
+
session.timer = this.setTimer(() => {
|
|
349
|
+
void this.stop({ trigger: 'timeout', confirm: 'upload' }).catch(error => {
|
|
350
|
+
this.lastAutomaticError = error;
|
|
351
|
+
console.error('[trace-mcp-recorder] 自动停止录制失败:', errorMessage(error));
|
|
342
352
|
});
|
|
343
|
-
},
|
|
344
|
-
session.
|
|
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
|
+
}, session.maxRecordDuration);
|
|
354
|
+
session.timer?.unref?.();
|
|
355
|
+
this.armIdleTimer(session);
|
|
356
|
+
await this.recordActivityStep(session, activity);
|
|
353
357
|
}
|
|
354
358
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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;
|
|
359
|
+
handleUserActivity(session, activity = {}) {
|
|
360
|
+
if (session.state === 'waiting_for_activity') {
|
|
361
|
+
if (session.activityPromise) return session.activityPromise;
|
|
362
|
+
session.activityPromise = this.beginRecording(session, activity).catch(error => {
|
|
363
|
+
session.state = 'failed';
|
|
364
|
+
throw error;
|
|
374
365
|
});
|
|
375
|
-
|
|
376
|
-
|
|
366
|
+
return session.activityPromise;
|
|
367
|
+
}
|
|
368
|
+
if (session.state !== 'recording') return undefined;
|
|
369
|
+
session.lastActivityAt = Date.now();
|
|
370
|
+
session.pendingActivity = activity;
|
|
371
|
+
this.armIdleTimer(session);
|
|
372
|
+
if (session.activityDebounceTimer) this.clearTimer(session.activityDebounceTimer);
|
|
373
|
+
session.activityDebounceTimer = this.setTimer(() => {
|
|
374
|
+
session.activityDebounceTimer = undefined;
|
|
375
|
+
const pendingActivity = session.pendingActivity;
|
|
376
|
+
session.pendingActivity = undefined;
|
|
377
|
+
session.activityRecordPromise = (session.activityRecordPromise ?? Promise.resolve())
|
|
378
|
+
.then(() => this.recordActivityStep(session, pendingActivity))
|
|
379
|
+
.catch(() => {})
|
|
380
|
+
.finally(() => {
|
|
381
|
+
session.activityRecordPromise = undefined;
|
|
382
|
+
});
|
|
383
|
+
}, USER_ACTIVITY_DEBOUNCE_DELAY);
|
|
384
|
+
session.activityDebounceTimer?.unref?.();
|
|
385
|
+
return undefined;
|
|
377
386
|
}
|
|
378
387
|
|
|
379
|
-
async
|
|
380
|
-
if (session.state !== 'recording'
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
session.
|
|
386
|
-
|
|
388
|
+
async recordActivityStep(session, activity = {}) {
|
|
389
|
+
if (!session.tracingStarted || session.state !== 'recording') return;
|
|
390
|
+
session.stepIndex += 1;
|
|
391
|
+
const type = typeof activity.type === 'string' && activity.type ? activity.type : 'unknown';
|
|
392
|
+
const title = typeof activity.title === 'string' && activity.title ? activity.title : '未命名页面';
|
|
393
|
+
const label = `步骤${session.stepIndex} | ${title} | ${type}`;
|
|
394
|
+
await session.context.tracing.group(label);
|
|
395
|
+
try {
|
|
396
|
+
await session.page.waitForTimeout(1);
|
|
397
|
+
} finally {
|
|
398
|
+
await session.context.tracing.groupEnd();
|
|
399
|
+
}
|
|
387
400
|
}
|
|
388
401
|
|
|
389
|
-
async
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
session.
|
|
402
|
+
async recordFinalStep(session) {
|
|
403
|
+
session.stepIndex += 1;
|
|
404
|
+
let title = '未命名页面';
|
|
405
|
+
if (typeof session.page.title === 'function') {
|
|
406
|
+
title = await session.page.title().catch(() => title) || title;
|
|
394
407
|
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
session.
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
session.visualPendingSince = undefined;
|
|
402
|
-
if (recordFinalFrame && session.page) {
|
|
403
|
-
await session.page.waitForTimeout(1).catch(() => {});
|
|
408
|
+
const label = `步骤${session.stepIndex} | ${title} | stop`;
|
|
409
|
+
await session.context.tracing.group(label);
|
|
410
|
+
try {
|
|
411
|
+
await session.page.waitForTimeout(1);
|
|
412
|
+
} finally {
|
|
413
|
+
await session.context.tracing.groupEnd();
|
|
404
414
|
}
|
|
405
|
-
|
|
406
|
-
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
armIdleTimer(session) {
|
|
418
|
+
this.clearTimer(session.idleTimer);
|
|
419
|
+
session.idleTimer = this.setTimer(() => {
|
|
420
|
+
void this.stop({ trigger: 'idle', confirm: 'upload' }).catch(error => {
|
|
421
|
+
this.lastAutomaticError = error;
|
|
422
|
+
console.error('[trace-mcp-recorder] 空闲自动停止失败:', errorMessage(error));
|
|
423
|
+
});
|
|
424
|
+
}, IDLE_STOP_DELAY);
|
|
425
|
+
session.idleTimer?.unref?.();
|
|
407
426
|
}
|
|
408
427
|
|
|
409
428
|
async uploadSession(session, trigger) {
|
|
@@ -452,16 +471,18 @@ export class TraceRecorder {
|
|
|
452
471
|
session.state = 'completed';
|
|
453
472
|
if (trigger === 'timeout') this.lastAutomaticResult = result;
|
|
454
473
|
return result;
|
|
455
|
-
})()
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
474
|
+
})()
|
|
475
|
+
.catch(async error => {
|
|
476
|
+
session.state = 'failed';
|
|
477
|
+
if (trigger === 'timeout') this.lastAutomaticError = error;
|
|
478
|
+
await this.removeTemporaryDirectory(session.temporaryDirectory).catch(() => {});
|
|
479
|
+
if (this.activeSession === session) this.activeSession = undefined;
|
|
480
|
+
throw error;
|
|
481
|
+
})
|
|
482
|
+
.finally(async () => {
|
|
483
|
+
await this.removeTemporaryDirectory(session.temporaryDirectory).catch(() => {});
|
|
484
|
+
if (this.activeSession === session && session.state === 'completed') this.activeSession = undefined;
|
|
485
|
+
});
|
|
465
486
|
return session.uploadPromise;
|
|
466
487
|
}
|
|
467
488
|
|
|
@@ -473,10 +494,11 @@ export class TraceRecorder {
|
|
|
473
494
|
}
|
|
474
495
|
|
|
475
496
|
async failSession(session, error) {
|
|
476
|
-
if (session.state !== 'recording') return;
|
|
497
|
+
if (session.state !== 'recording' && session.state !== 'waiting_for_activity') return;
|
|
477
498
|
session.state = 'failed';
|
|
478
499
|
this.clearTimer(session.timer);
|
|
479
|
-
|
|
500
|
+
this.clearTimer(session.idleTimer);
|
|
501
|
+
this.clearTimer(session.activityDebounceTimer);
|
|
480
502
|
await this.closeResources(session);
|
|
481
503
|
await this.removeTemporaryDirectory(session.temporaryDirectory).catch(() => {});
|
|
482
504
|
if (this.activeSession === session) this.activeSession = undefined;
|
|
@@ -554,7 +576,10 @@ export function createServer(options = {}) {
|
|
|
554
576
|
title: '停止 Playwright Trace 录制',
|
|
555
577
|
description: '停止当前 trace 并询问上传;确认后上传 trace.zip 和浏览器元数据,返回回放链接。',
|
|
556
578
|
inputSchema: {
|
|
557
|
-
confirm: z
|
|
579
|
+
confirm: z
|
|
580
|
+
.enum(['upload', 'cancel'])
|
|
581
|
+
.optional()
|
|
582
|
+
.describe('首次停止不传;用户确认上传传 upload,拒绝上传传 cancel')
|
|
558
583
|
},
|
|
559
584
|
annotations: {
|
|
560
585
|
readOnlyHint: false,
|
|
@@ -5,31 +5,24 @@ import test from 'node:test';
|
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
7
7
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
8
|
-
import {
|
|
9
|
-
import { pixelDifference, TraceRecorder } from './index.js';
|
|
8
|
+
import { TraceRecorder } from './index.js';
|
|
10
9
|
|
|
11
10
|
const directory = path.dirname(fileURLToPath(import.meta.url));
|
|
12
11
|
const sessionUuid = '123e4567-e89b-42d3-a456-426614174000';
|
|
13
12
|
|
|
14
|
-
test('calculates pixel differences for visual probes', () => {
|
|
15
|
-
const first = new PNG({ width: 2, height: 1 });
|
|
16
|
-
const second = new PNG({ width: 2, height: 1 });
|
|
17
|
-
first.data.fill(255);
|
|
18
|
-
second.data.fill(255);
|
|
19
|
-
second.data[0] = 0;
|
|
20
|
-
assert.equal(pixelDifference(PNG.sync.write(first), PNG.sync.write(second)), 0.5);
|
|
21
|
-
});
|
|
22
|
-
|
|
23
13
|
function createFixture(options = {}) {
|
|
24
14
|
const calls = [];
|
|
25
15
|
const browser = new EventEmitter();
|
|
26
16
|
const tracing = {
|
|
27
17
|
start: async options => calls.push(['trace-start', options]),
|
|
28
|
-
stop: async options => calls.push(['trace-stop', options])
|
|
18
|
+
stop: async options => calls.push(['trace-stop', options]),
|
|
19
|
+
group: async label => calls.push(['trace-group', label]),
|
|
20
|
+
groupEnd: async () => calls.push(['trace-group-end'])
|
|
29
21
|
};
|
|
30
22
|
const page = {
|
|
31
23
|
goto: async url => calls.push(['goto', url]),
|
|
32
24
|
evaluate: async () => 'Fixture User Agent',
|
|
25
|
+
waitForTimeout: async duration => calls.push(['wait', duration]),
|
|
33
26
|
viewportSize: () => ({ width: 1280, height: 720 }),
|
|
34
27
|
close: async () => calls.push(['page-close'])
|
|
35
28
|
};
|
|
@@ -78,7 +71,7 @@ function createFixture(options = {}) {
|
|
|
78
71
|
removeTemporaryDirectory: async temporaryDirectory => calls.push(['remove', temporaryDirectory]),
|
|
79
72
|
setTimer: (callback, duration) => {
|
|
80
73
|
calls.push(['timer', duration]);
|
|
81
|
-
timer.callback = callback;
|
|
74
|
+
if (duration === 1000) timer.callback = callback;
|
|
82
75
|
return timer;
|
|
83
76
|
},
|
|
84
77
|
clearTimer: value => calls.push(['clear-timer', value]),
|
|
@@ -102,6 +95,10 @@ test('starts, records metadata, uploads, completes and cleans up one session', a
|
|
|
102
95
|
assert.equal(started.uuid, undefined);
|
|
103
96
|
assert.equal(started.metadata.operator, '上下文操作人');
|
|
104
97
|
assert.equal(started.metadata.browserVersion, 'Fixture Chromium 1');
|
|
98
|
+
await recorder.handleUserActivity(recorder.activeSession, {
|
|
99
|
+
type: 'click',
|
|
100
|
+
title: '首页功能'
|
|
101
|
+
});
|
|
105
102
|
await assert.rejects(
|
|
106
103
|
recorder.start({ url: 'https://app.example.test/another' }),
|
|
107
104
|
/已有录制任务/
|
|
@@ -116,6 +113,7 @@ test('starts, records metadata, uploads, completes and cleans up one session', a
|
|
|
116
113
|
);
|
|
117
114
|
assert.equal(stopped.metadata.title, 'bug3452');
|
|
118
115
|
assert.ok(calls.some(call => call[0] === 'trace-start' && call[1].sources === true));
|
|
116
|
+
assert.ok(calls.some(call => call[0] === 'trace-group' && call[1] === '步骤1 | 首页功能 | click'));
|
|
119
117
|
assert.ok(calls.some(call => call[0] === 'post' && call[1] === '/api/trace-sessions'));
|
|
120
118
|
assert.ok(calls.some(call => call[0] === 'form-append' && call[1] === 'target_url'));
|
|
121
119
|
assert.ok(calls.some(call => call[0] === 'remove' && call[1] === '/tmp/trace-fixture'));
|
|
@@ -126,6 +124,19 @@ test('rejects missing configuration before contacting the backend', async () =>
|
|
|
126
124
|
await assert.rejects(recorder.start({ url: 'https://app.example.test' }), /BACKEND_BASE_URL/);
|
|
127
125
|
});
|
|
128
126
|
|
|
127
|
+
test('stops waiting sessions immediately without reporting an error', async () => {
|
|
128
|
+
const { recorder, calls } = createFixture();
|
|
129
|
+
await recorder.start({ url: 'https://app.example.test' });
|
|
130
|
+
const result = await recorder.stop();
|
|
131
|
+
assert.deepEqual(result, {
|
|
132
|
+
state: 'not_started',
|
|
133
|
+
uploaded: false,
|
|
134
|
+
message: '录制已停止。停止前未检测到真实用户操作,因此没有生成可上传的 trace。'
|
|
135
|
+
});
|
|
136
|
+
assert.equal(recorder.activeSession, undefined);
|
|
137
|
+
assert.ok(calls.some(call => call[0] === 'browser-close'));
|
|
138
|
+
});
|
|
139
|
+
|
|
129
140
|
test('cleans up when Chromium launch fails without a backend session', async () => {
|
|
130
141
|
const { calls, recorder } = createFixture();
|
|
131
142
|
recorder.launchChromium = async () => {
|
|
@@ -139,6 +150,7 @@ test('cleans up when Chromium launch fails without a backend session', async ()
|
|
|
139
150
|
test('marks the session failed and removes the local trace when upload fails', async () => {
|
|
140
151
|
const { calls, recorder } = createFixture({ uploadError: new Error('upload failed') });
|
|
141
152
|
await recorder.start({ url: 'https://app.example.test' });
|
|
153
|
+
await recorder.handleUserActivity(recorder.activeSession);
|
|
142
154
|
|
|
143
155
|
await recorder.stop();
|
|
144
156
|
await assert.rejects(recorder.stop({ confirm: 'upload' }), /upload failed/);
|
|
@@ -148,6 +160,7 @@ test('marks the session failed and removes the local trace when upload fails', a
|
|
|
148
160
|
test('automatically stops on timeout and returns that result on the next stop call', async () => {
|
|
149
161
|
const { recorder, timer } = createFixture();
|
|
150
162
|
await recorder.start({ url: 'https://app.example.test' });
|
|
163
|
+
await recorder.handleUserActivity(recorder.activeSession);
|
|
151
164
|
timer.callback();
|
|
152
165
|
|
|
153
166
|
while (recorder.activeSession) await new Promise(resolve => setImmediate(resolve));
|
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
"axios": "1.20.0",
|
|
13
13
|
"form-data": "4.0.6",
|
|
14
14
|
"playwright": "1.63.0",
|
|
15
|
-
"pngjs": "^7.0.0",
|
|
16
15
|
"uuid": "14.0.2",
|
|
17
16
|
"zod": "4.4.3"
|
|
18
17
|
},
|
|
@@ -961,14 +960,6 @@
|
|
|
961
960
|
"node": ">=20"
|
|
962
961
|
}
|
|
963
962
|
},
|
|
964
|
-
"node_modules/pngjs": {
|
|
965
|
-
"version": "7.0.0",
|
|
966
|
-
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
|
|
967
|
-
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
|
|
968
|
-
"engines": {
|
|
969
|
-
"node": ">=14.19.0"
|
|
970
|
-
}
|
|
971
|
-
},
|
|
972
963
|
"node_modules/proxy-addr": {
|
|
973
964
|
"version": "2.0.7",
|
|
974
965
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
|
@@ -1915,11 +1906,6 @@
|
|
|
1915
1906
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
|
1916
1907
|
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="
|
|
1917
1908
|
},
|
|
1918
|
-
"pngjs": {
|
|
1919
|
-
"version": "7.0.0",
|
|
1920
|
-
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
|
|
1921
|
-
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="
|
|
1922
|
-
},
|
|
1923
1909
|
"proxy-addr": {
|
|
1924
1910
|
"version": "2.0.7",
|
|
1925
1911
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
package/package.json
CHANGED
|
@@ -14,7 +14,7 @@ description: 通过 trace-mcp-recorder 启动或停止 Playwright Trace 人工
|
|
|
14
14
|
1. 从原话提取 `url`、`version`、`title`、`operator`。支持 `key=value` 和自然语言表达,不改写用户给出的值。
|
|
15
15
|
2. `url` 必填;缺少时只询问目标页面地址。其他字段缺失时不要阻断录制。
|
|
16
16
|
3. 调用 `start_trace_recording`,只传入用户明确提供的可选字段。服务端会优先使用 MCP 上下文中的操作人身份。
|
|
17
|
-
4.
|
|
17
|
+
4. 告知用户浏览器已打开、正在等待首次真实操作,并保留工具返回的 `uuid` 和最大录制时长;Trace 会在第一次真实操作时开始计时,不要代替用户操作页面。连续 2 分钟没有真实操作时服务会自动停止并上传。真实操作经防抖后会在官方 Trace Viewer 中以 `步骤N | 页面标题 | 事件类型` 的分组节点呈现。
|
|
18
18
|
|
|
19
19
|
示例:`trace-mcp-recorder 启动Playwright trace 访问https://cpd.dev.autobestdevops.com,version=v2.1.0 title=bug3452 operator=张三`。这里 `title=bug3452` 是本次录制的标题/功能描述,页面由 `url=https://cpd.dev.autobestdevops.com` 确定。
|
|
20
20
|
|
|
@@ -22,4 +22,6 @@ description: 通过 trace-mcp-recorder 启动或停止 Playwright Trace 人工
|
|
|
22
22
|
|
|
23
23
|
用户表达“停止录制”“结束 trace”“完成录制”等意图时,调用 `stop_trace_recording`。第一次调用只会停止录制并询问是否上传;不要擅自上传。用户明确回答“上传”后,再次调用 `stop_trace_recording` 并传入 `confirm=upload`。用户回答“不上传”时,调用该工具并传入 `confirm=cancel`。上传成功后,将回放链接和 `version`、`title`、`operator`、`browserVersion` 整理给用户。
|
|
24
24
|
|
|
25
|
+
停止调用必须以工具本次返回结果为准,不要根据启动时的 `waiting_for_activity` 提示推断最终状态。若工具返回 `state=not_started`,应说明本次没有检测到真实操作且没有生成 trace;若返回上传结果,则只整理上传返回的回放链接。
|
|
26
|
+
|
|
25
27
|
若工具返回错误,如没有活动会话、后端不可达或上传失败,原样说明关键原因,不编造回放链接。
|