@autobest-ui/agent 1.0.15 → 1.0.17
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.
|
@@ -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
|
|
|
@@ -38,12 +38,12 @@ npm start
|
|
|
38
38
|
|
|
39
39
|
## MCP 工具
|
|
40
40
|
|
|
41
|
-
- `start_trace_recording`:参数为必填 `url` 和可选 `version`、`title`、`operator
|
|
41
|
+
- `start_trace_recording`:参数为必填 `url` 和可选 `version`、`title`、`operator`。该工具打开页面,但点击页面左下角“开始录制”后才真正启动 Trace。`url` 独立确定被测页面;`title` 是录制标题或功能描述,也可以直接填写 `bug3452` 这类 Bug 编号。系统不额外传递独立的 Bug 字段。MCP 认证上下文中的 `operator`、`name` 或 `preferred_username` 优先于参数。
|
|
42
42
|
- `stop_trace_recording`:首次调用不传参数,只停止 trace 并询问是否上传;用户确认后传 `confirm=upload`,取消时传 `confirm=cancel`。上传成功后返回回放链接及版本、标题、操作人、浏览器版本。
|
|
43
43
|
|
|
44
44
|
录制超时后服务自动停止并自动上传(无需人工确认),随后第一次人工调用 `stop_trace_recording` 会取回自动停止的结果;再调用则返回没有活动会话。
|
|
45
45
|
|
|
46
|
-
Trace
|
|
46
|
+
Trace 在点击页面左下角“开始录制”后才开始计时;点击“停止录制”会立即停止并自动上传,随后可通过 `stop_trace_recording` 取回回放链接。控制按钮的点击不会写入操作步骤。连续用户操作停止 800ms 后写入一个带自定义名称的 `tracing.group()`,组内使用 `waitForTimeout(1)` 推进官方 Viewer 播放;连续 2 分钟没有真实用户操作时自动停止并上传。停止录制时会补一个最终 Action。页面自身的轮播、动画和图片加载不会触发录制节点。官方 Viewer 的具体显示可能是 Group 标签或 Group 内嵌 Wait 节点,取决于 Viewer 版本。
|
|
47
47
|
|
|
48
48
|
## 后端接口契约
|
|
49
49
|
|
|
@@ -101,17 +101,122 @@ function toolError(error) {
|
|
|
101
101
|
};
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
function
|
|
104
|
+
// Inject an isolated recorder toolbar and emit only trusted page activity.
|
|
105
|
+
// Control clicks are excluded so they never become Trace action checkpoints.
|
|
106
|
+
function createRecorderPageScript() {
|
|
107
107
|
return `(() => {
|
|
108
|
-
|
|
108
|
+
if (window !== window.top) return;
|
|
109
|
+
const controlAttribute = 'data-trace-recorder-control';
|
|
110
|
+
const notify = type => Promise.resolve(globalThis.__traceRecorderUserActivity?.({
|
|
111
|
+
type,
|
|
112
|
+
title: document.title || '未命名页面'
|
|
113
|
+
})).catch(() => {});
|
|
109
114
|
const options = { capture: true, passive: true };
|
|
110
|
-
for (const type of ['
|
|
115
|
+
for (const type of ['click', 'keydown', 'input', 'change', 'wheel', 'touchstart']) {
|
|
111
116
|
document.addEventListener(type, event => {
|
|
112
|
-
|
|
117
|
+
const isRecorderControl = event.composedPath().some(node =>
|
|
118
|
+
node instanceof Element && node.hasAttribute(controlAttribute)
|
|
119
|
+
);
|
|
120
|
+
if (event.isTrusted && !isRecorderControl) notify(type);
|
|
113
121
|
}, options);
|
|
114
122
|
}
|
|
123
|
+
|
|
124
|
+
const mountToolbar = () => {
|
|
125
|
+
if (!document.documentElement || document.querySelector('[data-trace-recorder-toolbar-host]')) return;
|
|
126
|
+
const host = document.createElement('div');
|
|
127
|
+
host.setAttribute('data-trace-recorder-toolbar-host', '');
|
|
128
|
+
host.setAttribute(controlAttribute, '');
|
|
129
|
+
const shadow = host.attachShadow({ mode: 'closed' });
|
|
130
|
+
shadow.innerHTML = \`
|
|
131
|
+
<style>
|
|
132
|
+
:host { all: initial; }
|
|
133
|
+
.toolbar {
|
|
134
|
+
position: fixed;
|
|
135
|
+
left: 16px;
|
|
136
|
+
bottom: 16px;
|
|
137
|
+
z-index: 2147483647;
|
|
138
|
+
display: flex;
|
|
139
|
+
gap: 8px;
|
|
140
|
+
padding: 7px;
|
|
141
|
+
border: 1px solid rgba(255, 255, 255, .38);
|
|
142
|
+
border-radius: 8px;
|
|
143
|
+
background: rgba(24, 24, 27, .58);
|
|
144
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, .2);
|
|
145
|
+
backdrop-filter: blur(6px);
|
|
146
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
147
|
+
}
|
|
148
|
+
button {
|
|
149
|
+
box-sizing: border-box;
|
|
150
|
+
min-width: 88px;
|
|
151
|
+
height: 34px;
|
|
152
|
+
border: 1px solid rgba(255, 255, 255, .48);
|
|
153
|
+
border-radius: 6px;
|
|
154
|
+
color: #fff;
|
|
155
|
+
font: 500 14px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
156
|
+
letter-spacing: 0;
|
|
157
|
+
cursor: pointer;
|
|
158
|
+
transition: opacity .15s ease, background-color .15s ease;
|
|
159
|
+
}
|
|
160
|
+
button:hover:not(:disabled) { opacity: .9; }
|
|
161
|
+
button:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
|
|
162
|
+
button:disabled { cursor: default; opacity: .42; }
|
|
163
|
+
.start { background: rgba(22, 163, 74, .82); }
|
|
164
|
+
.stop { background: rgba(220, 38, 38, .82); }
|
|
165
|
+
</style>
|
|
166
|
+
<div class="toolbar" role="group" aria-label="Trace 录制控制">
|
|
167
|
+
<button class="start" type="button" data-action="start" data-trace-recorder-control>开始录制</button>
|
|
168
|
+
<button class="stop" type="button" data-action="stop" data-trace-recorder-control>停止录制</button>
|
|
169
|
+
</div>
|
|
170
|
+
\`;
|
|
171
|
+
document.documentElement.append(host);
|
|
172
|
+
const startButton = shadow.querySelector('[data-action="start"]');
|
|
173
|
+
const stopButton = shadow.querySelector('[data-action="stop"]');
|
|
174
|
+
|
|
175
|
+
const render = state => {
|
|
176
|
+
const recording = state === 'recording';
|
|
177
|
+
const waiting = state === 'waiting_for_start';
|
|
178
|
+
startButton.disabled = !waiting;
|
|
179
|
+
stopButton.disabled = !recording && !waiting;
|
|
180
|
+
startButton.textContent = recording ? '录制中' : '开始录制';
|
|
181
|
+
stopButton.textContent = state === 'stopping' || state === 'uploading' ? '正在停止' : '停止录制';
|
|
182
|
+
};
|
|
183
|
+
const control = async action => {
|
|
184
|
+
startButton.disabled = true;
|
|
185
|
+
stopButton.disabled = true;
|
|
186
|
+
try {
|
|
187
|
+
const result = await globalThis.__traceRecorderControl?.(action);
|
|
188
|
+
render(result?.state);
|
|
189
|
+
} catch {
|
|
190
|
+
render('waiting_for_start');
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
startButton.addEventListener('click', event => {
|
|
194
|
+
event.preventDefault();
|
|
195
|
+
event.stopPropagation();
|
|
196
|
+
void control('start');
|
|
197
|
+
});
|
|
198
|
+
stopButton.addEventListener('click', event => {
|
|
199
|
+
event.preventDefault();
|
|
200
|
+
event.stopPropagation();
|
|
201
|
+
render('stopping');
|
|
202
|
+
void control('stop');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const syncState = () => {
|
|
206
|
+
void Promise.resolve(globalThis.__traceRecorderControl?.('status'))
|
|
207
|
+
.then(result => render(result?.state))
|
|
208
|
+
.catch(() => {});
|
|
209
|
+
};
|
|
210
|
+
syncState();
|
|
211
|
+
const stateTimer = setInterval(syncState, 500);
|
|
212
|
+
window.addEventListener('pagehide', () => clearInterval(stateTimer), { once: true });
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
if (document.readyState === 'loading') {
|
|
216
|
+
document.addEventListener('DOMContentLoaded', mountToolbar, { once: true });
|
|
217
|
+
} else {
|
|
218
|
+
mountToolbar();
|
|
219
|
+
}
|
|
115
220
|
})();`;
|
|
116
221
|
}
|
|
117
222
|
|
|
@@ -178,13 +283,17 @@ export class TraceRecorder {
|
|
|
178
283
|
context = await browser.newContext();
|
|
179
284
|
let session;
|
|
180
285
|
if (typeof context.exposeBinding === 'function') {
|
|
181
|
-
await context.exposeBinding('__traceRecorderUserActivity', () => {
|
|
182
|
-
if (session) return this.handleUserActivity(session);
|
|
286
|
+
await context.exposeBinding('__traceRecorderUserActivity', (_source, activity) => {
|
|
287
|
+
if (session) return this.handleUserActivity(session, activity);
|
|
183
288
|
return undefined;
|
|
184
289
|
});
|
|
290
|
+
await context.exposeBinding('__traceRecorderControl', (_source, action) => {
|
|
291
|
+
if (!session) return { state: 'starting' };
|
|
292
|
+
return this.handlePageControl(session, action);
|
|
293
|
+
});
|
|
185
294
|
}
|
|
186
295
|
if (typeof context.addInitScript === 'function') {
|
|
187
|
-
await context.addInitScript({ content:
|
|
296
|
+
await context.addInitScript({ content: createRecorderPageScript() });
|
|
188
297
|
}
|
|
189
298
|
page = await context.newPage();
|
|
190
299
|
await page.goto(targetUrl);
|
|
@@ -210,20 +319,23 @@ export class TraceRecorder {
|
|
|
210
319
|
maxRecordDuration: configuration.maxRecordDuration,
|
|
211
320
|
tracePath: path.join(temporaryDirectory, 'trace.zip'),
|
|
212
321
|
temporaryDirectory,
|
|
213
|
-
state: '
|
|
322
|
+
state: 'waiting_for_start',
|
|
214
323
|
stopPromise: undefined,
|
|
215
324
|
uploadPromise: undefined,
|
|
216
325
|
timer: undefined,
|
|
217
326
|
idleTimer: undefined,
|
|
218
327
|
activityDebounceTimer: undefined,
|
|
219
328
|
activityPromise: undefined,
|
|
329
|
+
activityRecordPromise: undefined,
|
|
330
|
+
pendingActivity: undefined,
|
|
331
|
+
stepIndex: 0,
|
|
220
332
|
tracingStarted: false,
|
|
221
333
|
lastActivityAt: undefined
|
|
222
334
|
};
|
|
223
335
|
this.activeSession = session;
|
|
224
336
|
|
|
225
337
|
const failOnBrowserExit = () => {
|
|
226
|
-
if (session.state === 'recording' || session.state === '
|
|
338
|
+
if (session.state === 'recording' || session.state === 'waiting_for_start') {
|
|
227
339
|
session.stopPromise = this.failSession(session, new Error('Chromium 浏览器意外退出'));
|
|
228
340
|
void session.stopPromise.catch(failure => {
|
|
229
341
|
console.error('[trace-mcp-recorder] 浏览器异常退出:', errorMessage(failure));
|
|
@@ -233,7 +345,7 @@ export class TraceRecorder {
|
|
|
233
345
|
browser.once('disconnected', failOnBrowserExit);
|
|
234
346
|
|
|
235
347
|
return {
|
|
236
|
-
message: '
|
|
348
|
+
message: '浏览器已打开,请点击页面左下角“开始录制”;操作完成后点击“停止录制”,服务将自动上传。',
|
|
237
349
|
maxRecordDuration: configuration.maxRecordDuration,
|
|
238
350
|
metadata
|
|
239
351
|
};
|
|
@@ -264,10 +376,29 @@ export class TraceRecorder {
|
|
|
264
376
|
if (session.state === 'starting') {
|
|
265
377
|
throw new Error('录制会话正在启动,请等待 start_trace_recording 返回后再停止');
|
|
266
378
|
}
|
|
267
|
-
if (session.state === '
|
|
379
|
+
if (session.state === 'waiting_for_start') {
|
|
380
|
+
session.state = 'stopping';
|
|
381
|
+
this.clearTimer(session.timer);
|
|
382
|
+
this.clearTimer(session.idleTimer);
|
|
383
|
+
this.clearTimer(session.activityDebounceTimer);
|
|
384
|
+
await session.activityPromise?.catch(() => {});
|
|
385
|
+
if (session.tracingStarted) {
|
|
386
|
+
session.stopPromise = this.prepareSession(session).finally(() => {
|
|
387
|
+
session.stopPromise = undefined;
|
|
388
|
+
});
|
|
389
|
+
const prepared = await session.stopPromise;
|
|
390
|
+
if (trigger === 'timeout' || confirm === 'upload') return this.uploadSession(session, trigger);
|
|
391
|
+
return prepared;
|
|
392
|
+
}
|
|
268
393
|
await this.closeResources({ page: session.page, context: session.context, browser: session.browser });
|
|
269
394
|
if (this.activeSession === session) this.activeSession = undefined;
|
|
270
|
-
|
|
395
|
+
const result = {
|
|
396
|
+
state: 'not_started',
|
|
397
|
+
uploaded: false,
|
|
398
|
+
message: '录制已停止。停止前未点击“开始录制”,因此没有生成可上传的 trace。'
|
|
399
|
+
};
|
|
400
|
+
if (trigger !== 'manual') this.lastAutomaticResult = result;
|
|
401
|
+
return result;
|
|
271
402
|
}
|
|
272
403
|
if (session.state === 'recording') {
|
|
273
404
|
if (session.stopPromise) return session.stopPromise;
|
|
@@ -299,7 +430,13 @@ export class TraceRecorder {
|
|
|
299
430
|
async prepareSession(session) {
|
|
300
431
|
try {
|
|
301
432
|
await session.activityPromise?.catch(() => {});
|
|
302
|
-
|
|
433
|
+
await session.activityRecordPromise?.catch(() => {});
|
|
434
|
+
if (session.pendingActivity) {
|
|
435
|
+
const pendingActivity = session.pendingActivity;
|
|
436
|
+
session.pendingActivity = undefined;
|
|
437
|
+
await this.recordActivityStep(session, pendingActivity);
|
|
438
|
+
}
|
|
439
|
+
if (session.tracingStarted) await this.recordFinalStep(session);
|
|
303
440
|
await session.context.tracing.stop({ path: session.tracePath });
|
|
304
441
|
await this.closeResources({ page: session.page, context: session.context, browser: session.browser });
|
|
305
442
|
session.state = 'awaiting_upload';
|
|
@@ -316,7 +453,7 @@ export class TraceRecorder {
|
|
|
316
453
|
}
|
|
317
454
|
|
|
318
455
|
async beginRecording(session) {
|
|
319
|
-
if (session.tracingStarted || session.state !== '
|
|
456
|
+
if (session.tracingStarted || session.state !== 'waiting_for_start') return;
|
|
320
457
|
await session.context.tracing.start({ screenshots: true, snapshots: true, sources: true });
|
|
321
458
|
session.tracingStarted = true;
|
|
322
459
|
session.state = 'recording';
|
|
@@ -329,30 +466,84 @@ export class TraceRecorder {
|
|
|
329
466
|
}, session.maxRecordDuration);
|
|
330
467
|
session.timer?.unref?.();
|
|
331
468
|
this.armIdleTimer(session);
|
|
332
|
-
await session.page.waitForTimeout(1);
|
|
333
469
|
}
|
|
334
470
|
|
|
335
|
-
|
|
336
|
-
if (
|
|
337
|
-
|
|
338
|
-
session.
|
|
339
|
-
|
|
340
|
-
|
|
471
|
+
handlePageControl(session, action) {
|
|
472
|
+
if (action === 'status') return { state: session.state };
|
|
473
|
+
if (action === 'start') {
|
|
474
|
+
if (session.state === 'recording') return { state: session.state };
|
|
475
|
+
if (session.state !== 'waiting_for_start') return { state: session.state };
|
|
476
|
+
if (!session.activityPromise) {
|
|
477
|
+
session.activityPromise = this.beginRecording(session).catch(error => {
|
|
478
|
+
session.state = 'failed';
|
|
479
|
+
throw error;
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
return session.activityPromise.then(() => ({ state: session.state }));
|
|
483
|
+
}
|
|
484
|
+
if (action === 'stop') {
|
|
485
|
+
if (session.state !== 'recording' && session.state !== 'waiting_for_start') {
|
|
486
|
+
return { state: session.state };
|
|
487
|
+
}
|
|
488
|
+
void this.stop({ trigger: 'page-control', confirm: 'upload' }).catch(error => {
|
|
489
|
+
this.lastAutomaticError = error;
|
|
490
|
+
console.error('[trace-mcp-recorder] 页面按钮停止录制失败:', errorMessage(error));
|
|
341
491
|
});
|
|
342
|
-
return
|
|
492
|
+
return { state: 'stopping' };
|
|
343
493
|
}
|
|
494
|
+
return { state: session.state };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
handleUserActivity(session, activity = {}) {
|
|
344
498
|
if (session.state !== 'recording') return undefined;
|
|
345
499
|
session.lastActivityAt = Date.now();
|
|
500
|
+
session.pendingActivity = activity;
|
|
346
501
|
this.armIdleTimer(session);
|
|
347
502
|
if (session.activityDebounceTimer) this.clearTimer(session.activityDebounceTimer);
|
|
348
503
|
session.activityDebounceTimer = this.setTimer(() => {
|
|
349
504
|
session.activityDebounceTimer = undefined;
|
|
350
|
-
|
|
505
|
+
const pendingActivity = session.pendingActivity;
|
|
506
|
+
session.pendingActivity = undefined;
|
|
507
|
+
session.activityRecordPromise = (session.activityRecordPromise ?? Promise.resolve())
|
|
508
|
+
.then(() => this.recordActivityStep(session, pendingActivity))
|
|
509
|
+
.catch(() => {})
|
|
510
|
+
.finally(() => {
|
|
511
|
+
session.activityRecordPromise = undefined;
|
|
512
|
+
});
|
|
351
513
|
}, USER_ACTIVITY_DEBOUNCE_DELAY);
|
|
352
514
|
session.activityDebounceTimer?.unref?.();
|
|
353
515
|
return undefined;
|
|
354
516
|
}
|
|
355
517
|
|
|
518
|
+
async recordActivityStep(session, activity = {}) {
|
|
519
|
+
if (!session.tracingStarted || !['recording', 'stopping'].includes(session.state)) return;
|
|
520
|
+
session.stepIndex += 1;
|
|
521
|
+
const type = typeof activity.type === 'string' && activity.type ? activity.type : 'unknown';
|
|
522
|
+
const title = typeof activity.title === 'string' && activity.title ? activity.title : '未命名页面';
|
|
523
|
+
const label = `步骤${session.stepIndex} | ${title} | ${type}`;
|
|
524
|
+
await session.context.tracing.group(label);
|
|
525
|
+
try {
|
|
526
|
+
await session.page.waitForTimeout(1);
|
|
527
|
+
} finally {
|
|
528
|
+
await session.context.tracing.groupEnd();
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
async recordFinalStep(session) {
|
|
533
|
+
session.stepIndex += 1;
|
|
534
|
+
let title = '未命名页面';
|
|
535
|
+
if (typeof session.page.title === 'function') {
|
|
536
|
+
title = await session.page.title().catch(() => title) || title;
|
|
537
|
+
}
|
|
538
|
+
const label = `步骤${session.stepIndex} | ${title} | stop`;
|
|
539
|
+
await session.context.tracing.group(label);
|
|
540
|
+
try {
|
|
541
|
+
await session.page.waitForTimeout(1);
|
|
542
|
+
} finally {
|
|
543
|
+
await session.context.tracing.groupEnd();
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
356
547
|
armIdleTimer(session) {
|
|
357
548
|
this.clearTimer(session.idleTimer);
|
|
358
549
|
session.idleTimer = this.setTimer(() => {
|
|
@@ -408,12 +599,12 @@ export class TraceRecorder {
|
|
|
408
599
|
}
|
|
409
600
|
};
|
|
410
601
|
session.state = 'completed';
|
|
411
|
-
if (trigger
|
|
602
|
+
if (trigger !== 'manual') this.lastAutomaticResult = result;
|
|
412
603
|
return result;
|
|
413
604
|
})()
|
|
414
605
|
.catch(async error => {
|
|
415
606
|
session.state = 'failed';
|
|
416
|
-
if (trigger
|
|
607
|
+
if (trigger !== 'manual') this.lastAutomaticError = error;
|
|
417
608
|
await this.removeTemporaryDirectory(session.temporaryDirectory).catch(() => {});
|
|
418
609
|
if (this.activeSession === session) this.activeSession = undefined;
|
|
419
610
|
throw error;
|
|
@@ -433,7 +624,7 @@ export class TraceRecorder {
|
|
|
433
624
|
}
|
|
434
625
|
|
|
435
626
|
async failSession(session, error) {
|
|
436
|
-
if (session.state !== 'recording' && session.state !== '
|
|
627
|
+
if (session.state !== 'recording' && session.state !== 'waiting_for_start') return;
|
|
437
628
|
session.state = 'failed';
|
|
438
629
|
this.clearTimer(session.timer);
|
|
439
630
|
this.clearTimer(session.idleTimer);
|
|
@@ -477,8 +668,8 @@ export function createServer(options = {}) {
|
|
|
477
668
|
{ name: 'trace-mcp-recorder', version: '1.0.0' },
|
|
478
669
|
{
|
|
479
670
|
instructions:
|
|
480
|
-
'Use start_trace_recording to open a visible Chromium window
|
|
481
|
-
'When the user asks to stop recording
|
|
671
|
+
'Use start_trace_recording to open a visible Chromium window with page recording controls. ' +
|
|
672
|
+
'When the user asks to stop recording or retrieve a page-control result, call stop_trace_recording.'
|
|
482
673
|
}
|
|
483
674
|
);
|
|
484
675
|
|
|
@@ -486,7 +677,7 @@ export function createServer(options = {}) {
|
|
|
486
677
|
'start_trace_recording',
|
|
487
678
|
{
|
|
488
679
|
title: '启动 Playwright Trace 人工录制',
|
|
489
|
-
description: '启动非无头 Chromium
|
|
680
|
+
description: '启动非无头 Chromium 并访问指定 URL;测试人员通过页面左下角按钮开始和停止 trace。',
|
|
490
681
|
inputSchema: {
|
|
491
682
|
url: z.string().min(1).describe('必填,被测页面的 HTTP(S) 地址'),
|
|
492
683
|
version: z.string().min(1).optional().describe('可选,被测版本号,例如 v2.1.0'),
|
|
@@ -12,10 +12,13 @@ const sessionUuid = '123e4567-e89b-42d3-a456-426614174000';
|
|
|
12
12
|
|
|
13
13
|
function createFixture(options = {}) {
|
|
14
14
|
const calls = [];
|
|
15
|
+
const bindings = {};
|
|
15
16
|
const browser = new EventEmitter();
|
|
16
17
|
const tracing = {
|
|
17
18
|
start: async options => calls.push(['trace-start', options]),
|
|
18
|
-
stop: async options => calls.push(['trace-stop', options])
|
|
19
|
+
stop: async options => calls.push(['trace-stop', options]),
|
|
20
|
+
group: async label => calls.push(['trace-group', label]),
|
|
21
|
+
groupEnd: async () => calls.push(['trace-group-end'])
|
|
19
22
|
};
|
|
20
23
|
const page = {
|
|
21
24
|
goto: async url => calls.push(['goto', url]),
|
|
@@ -26,6 +29,11 @@ function createFixture(options = {}) {
|
|
|
26
29
|
};
|
|
27
30
|
const context = {
|
|
28
31
|
tracing,
|
|
32
|
+
exposeBinding: async (name, callback) => {
|
|
33
|
+
calls.push(['expose-binding', name]);
|
|
34
|
+
bindings[name] = callback;
|
|
35
|
+
},
|
|
36
|
+
addInitScript: async script => calls.push(['init-script', script]),
|
|
29
37
|
newPage: async () => page,
|
|
30
38
|
close: async () => calls.push(['context-close'])
|
|
31
39
|
};
|
|
@@ -75,11 +83,11 @@ function createFixture(options = {}) {
|
|
|
75
83
|
clearTimer: value => calls.push(['clear-timer', value]),
|
|
76
84
|
platformInfo: () => ({ platform: 'fixture-os' })
|
|
77
85
|
});
|
|
78
|
-
return { browser, calls, recorder, timer };
|
|
86
|
+
return { bindings, browser, calls, recorder, timer };
|
|
79
87
|
}
|
|
80
88
|
|
|
81
|
-
test('
|
|
82
|
-
const { calls, recorder } = createFixture();
|
|
89
|
+
test('page controls start recording, record activity, upload and clean up one session', async () => {
|
|
90
|
+
const { bindings, calls, recorder } = createFixture();
|
|
83
91
|
const started = await recorder.start(
|
|
84
92
|
{
|
|
85
93
|
url: 'https://app.example.test/login',
|
|
@@ -93,7 +101,13 @@ test('starts, records metadata, uploads, completes and cleans up one session', a
|
|
|
93
101
|
assert.equal(started.uuid, undefined);
|
|
94
102
|
assert.equal(started.metadata.operator, '上下文操作人');
|
|
95
103
|
assert.equal(started.metadata.browserVersion, 'Fixture Chromium 1');
|
|
96
|
-
|
|
104
|
+
assert.equal(calls.some(call => call[0] === 'trace-start'), false);
|
|
105
|
+
assert.match(calls.find(call => call[0] === 'init-script')[1].content, /开始录制/);
|
|
106
|
+
assert.deepEqual(await bindings.__traceRecorderControl({}, 'start'), { state: 'recording' });
|
|
107
|
+
recorder.handleUserActivity(recorder.activeSession, {
|
|
108
|
+
type: 'click',
|
|
109
|
+
title: '首页功能'
|
|
110
|
+
});
|
|
97
111
|
await assert.rejects(
|
|
98
112
|
recorder.start({ url: 'https://app.example.test/another' }),
|
|
99
113
|
/已有录制任务/
|
|
@@ -108,6 +122,7 @@ test('starts, records metadata, uploads, completes and cleans up one session', a
|
|
|
108
122
|
);
|
|
109
123
|
assert.equal(stopped.metadata.title, 'bug3452');
|
|
110
124
|
assert.ok(calls.some(call => call[0] === 'trace-start' && call[1].sources === true));
|
|
125
|
+
assert.ok(calls.some(call => call[0] === 'trace-group' && call[1] === '步骤1 | 首页功能 | click'));
|
|
111
126
|
assert.ok(calls.some(call => call[0] === 'post' && call[1] === '/api/trace-sessions'));
|
|
112
127
|
assert.ok(calls.some(call => call[0] === 'form-append' && call[1] === 'target_url'));
|
|
113
128
|
assert.ok(calls.some(call => call[0] === 'remove' && call[1] === '/tmp/trace-fixture'));
|
|
@@ -118,6 +133,19 @@ test('rejects missing configuration before contacting the backend', async () =>
|
|
|
118
133
|
await assert.rejects(recorder.start({ url: 'https://app.example.test' }), /BACKEND_BASE_URL/);
|
|
119
134
|
});
|
|
120
135
|
|
|
136
|
+
test('stops waiting sessions immediately without reporting an error', async () => {
|
|
137
|
+
const { recorder, calls } = createFixture();
|
|
138
|
+
await recorder.start({ url: 'https://app.example.test' });
|
|
139
|
+
const result = await recorder.stop();
|
|
140
|
+
assert.deepEqual(result, {
|
|
141
|
+
state: 'not_started',
|
|
142
|
+
uploaded: false,
|
|
143
|
+
message: '录制已停止。停止前未点击“开始录制”,因此没有生成可上传的 trace。'
|
|
144
|
+
});
|
|
145
|
+
assert.equal(recorder.activeSession, undefined);
|
|
146
|
+
assert.ok(calls.some(call => call[0] === 'browser-close'));
|
|
147
|
+
});
|
|
148
|
+
|
|
121
149
|
test('cleans up when Chromium launch fails without a backend session', async () => {
|
|
122
150
|
const { calls, recorder } = createFixture();
|
|
123
151
|
recorder.launchChromium = async () => {
|
|
@@ -131,7 +159,8 @@ test('cleans up when Chromium launch fails without a backend session', async ()
|
|
|
131
159
|
test('marks the session failed and removes the local trace when upload fails', async () => {
|
|
132
160
|
const { calls, recorder } = createFixture({ uploadError: new Error('upload failed') });
|
|
133
161
|
await recorder.start({ url: 'https://app.example.test' });
|
|
134
|
-
await recorder.
|
|
162
|
+
await recorder.handlePageControl(recorder.activeSession, 'start');
|
|
163
|
+
recorder.handleUserActivity(recorder.activeSession);
|
|
135
164
|
|
|
136
165
|
await recorder.stop();
|
|
137
166
|
await assert.rejects(recorder.stop({ confirm: 'upload' }), /upload failed/);
|
|
@@ -141,7 +170,7 @@ test('marks the session failed and removes the local trace when upload fails', a
|
|
|
141
170
|
test('automatically stops on timeout and returns that result on the next stop call', async () => {
|
|
142
171
|
const { recorder, timer } = createFixture();
|
|
143
172
|
await recorder.start({ url: 'https://app.example.test' });
|
|
144
|
-
await recorder.
|
|
173
|
+
await recorder.handlePageControl(recorder.activeSession, 'start');
|
|
145
174
|
timer.callback();
|
|
146
175
|
|
|
147
176
|
while (recorder.activeSession) await new Promise(resolve => setImmediate(resolve));
|
|
@@ -150,6 +179,19 @@ test('automatically stops on timeout and returns that result on the next stop ca
|
|
|
150
179
|
await assert.rejects(recorder.stop(), /当前没有正在录制/);
|
|
151
180
|
});
|
|
152
181
|
|
|
182
|
+
test('page stop control immediately stops and uploads, then exposes the result to MCP', async () => {
|
|
183
|
+
const { bindings, recorder } = createFixture();
|
|
184
|
+
await recorder.start({ url: 'https://app.example.test' });
|
|
185
|
+
await bindings.__traceRecorderControl({}, 'start');
|
|
186
|
+
|
|
187
|
+
assert.deepEqual(await bindings.__traceRecorderControl({}, 'stop'), { state: 'stopping' });
|
|
188
|
+
while (recorder.activeSession) await new Promise(resolve => setImmediate(resolve));
|
|
189
|
+
|
|
190
|
+
const result = await recorder.stop();
|
|
191
|
+
assert.equal(result.stoppedBy, 'page-control');
|
|
192
|
+
assert.match(result.replayLink, /trace-viewer/);
|
|
193
|
+
});
|
|
194
|
+
|
|
153
195
|
test('exposes both tools and the bundled skill over stdio', async () => {
|
|
154
196
|
const client = new Client({ name: 'trace-mcp-recorder-test', version: '1.0.0' });
|
|
155
197
|
const transport = new StdioClientTransport({
|
package/package.json
CHANGED
|
@@ -14,12 +14,16 @@ 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. 告知用户浏览器已打开:先点击页面左下角“开始录制”,操作完成后点击“停止录制”。不要代替用户点击按钮或操作页面。点击开始后才启动 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
|
|
|
21
21
|
## 停止录制
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
页面左下角“停止录制”会立即停止并自动上传。用户随后要求获取回放链接时,调用 `stop_trace_recording` 读取这次按钮停止的结果,并整理回放链接和元数据。
|
|
24
|
+
|
|
25
|
+
用户表达“停止录制”“结束 trace”“完成录制”等意图时,调用 `stop_trace_recording`。单独输入“停止录制”时首次调用不传 `confirm`,停止并询问是否上传;用户明确回答“上传”后,再次调用并传入 `confirm=upload`。用户直接输入“停止录制 & 上传”或等价表达时,停止调用直接传入 `confirm=upload`。用户回答“不上传”时,调用该工具并传入 `confirm=cancel`。上传成功后,将回放链接和 `version`、`title`、`operator`、`browserVersion` 整理给用户。
|
|
26
|
+
|
|
27
|
+
停止调用必须以工具本次返回结果为准,不要根据启动时的 `waiting_for_start` 提示推断最终状态。若工具返回 `state=not_started`,应说明本次没有点击“开始录制”且没有生成 trace;若返回上传结果,则只整理上传返回的回放链接。
|
|
24
28
|
|
|
25
29
|
若工具返回错误,如没有活动会话、后端不可达或上传失败,原样说明关键原因,不编造回放链接。
|