@autobest-ui/agent 1.0.16 → 1.0.18
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;默认是 PC 端,也可切换为 iPhone 14 Pro Max 移动端模拟。后续用户活动经过防抖后以 `步骤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`。该工具打开页面,默认 PC 端;点击左下角“切换移动端”可使用 iPhone 14 Pro Max(430×932、DPR 3、触摸、iPhone User-Agent)模拟,点击“开始录制”后才真正启动 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
|
|
|
@@ -51,7 +51,7 @@ Trace 在第一次真实用户操作后才开始计时。连续用户操作停
|
|
|
51
51
|
|
|
52
52
|
- `POST /api/trace-sessions`:接收 `multipart/form-data`,必填字段为 `trace`、`target_url`,可选字段为 `version`、`title`、`operator`、`browser_name`、`browser_version`、`user_agent`、`os_name`、`viewport`;后端生成 UUID,创建并上传成功后返回 `{ "uuid": "...", "fileUrl": "...", "item": { "status": "completed" } }`。
|
|
53
53
|
|
|
54
|
-
`metadata`
|
|
54
|
+
`metadata` 包含浏览器版本、当前 User-Agent、viewport、设备模式(PC 或 iPhone 14 Pro Max)、MCP 所在操作系统信息、目标 URL 和录制业务字段。上传或状态更新失败时,本地 trace 仍会被清理,会话会尽力回写为 `failed`。
|
|
55
55
|
|
|
56
56
|
## 验证
|
|
57
57
|
|
|
@@ -15,6 +15,15 @@ import { z } from 'zod';
|
|
|
15
15
|
const DEFAULT_MAX_RECORD_DURATION = 1_800_000;
|
|
16
16
|
const USER_ACTIVITY_DEBOUNCE_DELAY = 800;
|
|
17
17
|
const IDLE_STOP_DELAY = 120_000;
|
|
18
|
+
const IPHONE_14_PRO_MAX_DEVICE = {
|
|
19
|
+
name: 'iPhone 14 Pro Max',
|
|
20
|
+
width: 430,
|
|
21
|
+
height: 932,
|
|
22
|
+
deviceScaleFactor: 3,
|
|
23
|
+
userAgent:
|
|
24
|
+
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 ' +
|
|
25
|
+
'(KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1'
|
|
26
|
+
};
|
|
18
27
|
const directory = path.dirname(fileURLToPath(import.meta.url));
|
|
19
28
|
|
|
20
29
|
function readConfiguration(environment = process.env) {
|
|
@@ -101,10 +110,12 @@ function toolError(error) {
|
|
|
101
110
|
};
|
|
102
111
|
}
|
|
103
112
|
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
function
|
|
113
|
+
// Inject an isolated recorder toolbar and emit only trusted page activity.
|
|
114
|
+
// Control clicks are excluded so they never become Trace action checkpoints.
|
|
115
|
+
function createRecorderPageScript() {
|
|
107
116
|
return `(() => {
|
|
117
|
+
if (window !== window.top) return;
|
|
118
|
+
const controlAttribute = 'data-trace-recorder-control';
|
|
108
119
|
const notify = type => Promise.resolve(globalThis.__traceRecorderUserActivity?.({
|
|
109
120
|
type,
|
|
110
121
|
title: document.title || '未命名页面'
|
|
@@ -112,9 +123,119 @@ function createUserActivityScript() {
|
|
|
112
123
|
const options = { capture: true, passive: true };
|
|
113
124
|
for (const type of ['click', 'keydown', 'input', 'change', 'wheel', 'touchstart']) {
|
|
114
125
|
document.addEventListener(type, event => {
|
|
115
|
-
|
|
126
|
+
const isRecorderControl = event.composedPath().some(node =>
|
|
127
|
+
node instanceof Element && node.hasAttribute(controlAttribute)
|
|
128
|
+
);
|
|
129
|
+
if (event.isTrusted && !isRecorderControl) notify(type);
|
|
116
130
|
}, options);
|
|
117
131
|
}
|
|
132
|
+
|
|
133
|
+
const mountToolbar = () => {
|
|
134
|
+
if (!document.documentElement || document.querySelector('[data-trace-recorder-toolbar-host]')) return;
|
|
135
|
+
const host = document.createElement('div');
|
|
136
|
+
host.setAttribute('data-trace-recorder-toolbar-host', '');
|
|
137
|
+
host.setAttribute(controlAttribute, '');
|
|
138
|
+
const shadow = host.attachShadow({ mode: 'closed' });
|
|
139
|
+
shadow.innerHTML = \`
|
|
140
|
+
<style>
|
|
141
|
+
:host { all: initial; }
|
|
142
|
+
.toolbar {
|
|
143
|
+
position: fixed;
|
|
144
|
+
left: 16px;
|
|
145
|
+
bottom: 16px;
|
|
146
|
+
z-index: 2147483647;
|
|
147
|
+
display: flex;
|
|
148
|
+
gap: 8px;
|
|
149
|
+
padding: 7px;
|
|
150
|
+
border: 1px solid rgba(255, 255, 255, .38);
|
|
151
|
+
border-radius: 8px;
|
|
152
|
+
background: rgba(24, 24, 27, .58);
|
|
153
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, .2);
|
|
154
|
+
backdrop-filter: blur(6px);
|
|
155
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
156
|
+
}
|
|
157
|
+
button {
|
|
158
|
+
box-sizing: border-box;
|
|
159
|
+
min-width: 88px;
|
|
160
|
+
height: 34px;
|
|
161
|
+
border: 1px solid rgba(255, 255, 255, .48);
|
|
162
|
+
border-radius: 6px;
|
|
163
|
+
color: #fff;
|
|
164
|
+
font: 500 14px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
165
|
+
letter-spacing: 0;
|
|
166
|
+
cursor: pointer;
|
|
167
|
+
transition: opacity .15s ease, background-color .15s ease;
|
|
168
|
+
}
|
|
169
|
+
button:hover:not(:disabled) { opacity: .9; }
|
|
170
|
+
button:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
|
|
171
|
+
button:disabled { cursor: default; opacity: .42; }
|
|
172
|
+
.start { background: rgba(22, 163, 74, .82); }
|
|
173
|
+
.stop { background: rgba(220, 38, 38, .82); }
|
|
174
|
+
.device { background: rgba(37, 99, 235, .82); min-width: 102px; }
|
|
175
|
+
</style>
|
|
176
|
+
<div class="toolbar" role="group" aria-label="Trace 录制控制">
|
|
177
|
+
<button class="start" type="button" data-action="start" data-trace-recorder-control>开始录制</button>
|
|
178
|
+
<button class="stop" type="button" data-action="stop" data-trace-recorder-control>停止录制</button>
|
|
179
|
+
<button class="device" type="button" data-action="toggle-device" data-trace-recorder-control>切换移动端</button>
|
|
180
|
+
</div>
|
|
181
|
+
\`;
|
|
182
|
+
document.documentElement.append(host);
|
|
183
|
+
const startButton = shadow.querySelector('[data-action="start"]');
|
|
184
|
+
const stopButton = shadow.querySelector('[data-action="stop"]');
|
|
185
|
+
const deviceButton = shadow.querySelector('[data-action="toggle-device"]');
|
|
186
|
+
|
|
187
|
+
const render = (state, deviceMode = 'desktop') => {
|
|
188
|
+
const recording = state === 'recording';
|
|
189
|
+
const waiting = state === 'waiting_for_start';
|
|
190
|
+
startButton.disabled = !waiting;
|
|
191
|
+
stopButton.disabled = !recording && !waiting;
|
|
192
|
+
deviceButton.disabled = state === 'starting' || state === 'stopping' || state === 'uploading';
|
|
193
|
+
startButton.textContent = recording ? '录制中' : '开始录制';
|
|
194
|
+
stopButton.textContent = state === 'stopping' || state === 'uploading' ? '正在停止' : '停止录制';
|
|
195
|
+
deviceButton.textContent = deviceMode === 'mobile' ? '切换 PC 端' : '切换移动端';
|
|
196
|
+
};
|
|
197
|
+
const control = async action => {
|
|
198
|
+
startButton.disabled = true;
|
|
199
|
+
stopButton.disabled = true;
|
|
200
|
+
try {
|
|
201
|
+
const result = await globalThis.__traceRecorderControl?.(action);
|
|
202
|
+
render(result?.state, result?.deviceMode);
|
|
203
|
+
} catch {
|
|
204
|
+
render('waiting_for_start', 'desktop');
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
startButton.addEventListener('click', event => {
|
|
208
|
+
event.preventDefault();
|
|
209
|
+
event.stopPropagation();
|
|
210
|
+
void control('start');
|
|
211
|
+
});
|
|
212
|
+
stopButton.addEventListener('click', event => {
|
|
213
|
+
event.preventDefault();
|
|
214
|
+
event.stopPropagation();
|
|
215
|
+
render('stopping');
|
|
216
|
+
void control('stop');
|
|
217
|
+
});
|
|
218
|
+
deviceButton.addEventListener('click', event => {
|
|
219
|
+
event.preventDefault();
|
|
220
|
+
event.stopPropagation();
|
|
221
|
+
void control('toggle-device');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const syncState = () => {
|
|
225
|
+
void Promise.resolve(globalThis.__traceRecorderControl?.('status'))
|
|
226
|
+
.then(result => render(result?.state, result?.deviceMode))
|
|
227
|
+
.catch(() => {});
|
|
228
|
+
};
|
|
229
|
+
syncState();
|
|
230
|
+
const stateTimer = setInterval(syncState, 500);
|
|
231
|
+
window.addEventListener('pagehide', () => clearInterval(stateTimer), { once: true });
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
if (document.readyState === 'loading') {
|
|
235
|
+
document.addEventListener('DOMContentLoaded', mountToolbar, { once: true });
|
|
236
|
+
} else {
|
|
237
|
+
mountToolbar();
|
|
238
|
+
}
|
|
118
239
|
})();`;
|
|
119
240
|
}
|
|
120
241
|
|
|
@@ -185,9 +306,13 @@ export class TraceRecorder {
|
|
|
185
306
|
if (session) return this.handleUserActivity(session, activity);
|
|
186
307
|
return undefined;
|
|
187
308
|
});
|
|
309
|
+
await context.exposeBinding('__traceRecorderControl', (_source, action) => {
|
|
310
|
+
if (!session) return { state: 'starting' };
|
|
311
|
+
return this.handlePageControl(session, action);
|
|
312
|
+
});
|
|
188
313
|
}
|
|
189
314
|
if (typeof context.addInitScript === 'function') {
|
|
190
|
-
await context.addInitScript({ content:
|
|
315
|
+
await context.addInitScript({ content: createRecorderPageScript() });
|
|
191
316
|
}
|
|
192
317
|
page = await context.newPage();
|
|
193
318
|
await page.goto(targetUrl);
|
|
@@ -201,6 +326,8 @@ export class TraceRecorder {
|
|
|
201
326
|
browserVersion: browser.version(),
|
|
202
327
|
userAgent: await page.evaluate(() => navigator.userAgent),
|
|
203
328
|
viewport: page.viewportSize(),
|
|
329
|
+
deviceMode: 'desktop',
|
|
330
|
+
deviceName: 'Desktop',
|
|
204
331
|
operatingSystem: this.platformInfo(),
|
|
205
332
|
startedAt: new Date().toISOString()
|
|
206
333
|
};
|
|
@@ -213,7 +340,7 @@ export class TraceRecorder {
|
|
|
213
340
|
maxRecordDuration: configuration.maxRecordDuration,
|
|
214
341
|
tracePath: path.join(temporaryDirectory, 'trace.zip'),
|
|
215
342
|
temporaryDirectory,
|
|
216
|
-
state: '
|
|
343
|
+
state: 'waiting_for_start',
|
|
217
344
|
stopPromise: undefined,
|
|
218
345
|
uploadPromise: undefined,
|
|
219
346
|
timer: undefined,
|
|
@@ -224,12 +351,26 @@ export class TraceRecorder {
|
|
|
224
351
|
pendingActivity: undefined,
|
|
225
352
|
stepIndex: 0,
|
|
226
353
|
tracingStarted: false,
|
|
227
|
-
lastActivityAt: undefined
|
|
354
|
+
lastActivityAt: undefined,
|
|
355
|
+
deviceMode: 'desktop',
|
|
356
|
+
deviceName: 'Desktop',
|
|
357
|
+
desktopUserAgent: metadata.userAgent,
|
|
358
|
+
cdpSessions: new Map()
|
|
228
359
|
};
|
|
229
360
|
this.activeSession = session;
|
|
230
361
|
|
|
362
|
+
if (typeof context.on === 'function') {
|
|
363
|
+
context.on('page', openedPage => {
|
|
364
|
+
if (session.deviceMode === 'mobile') {
|
|
365
|
+
void this.applyDeviceMode(session, openedPage, 'mobile').catch(error => {
|
|
366
|
+
console.error('[trace-mcp-recorder] 新页面应用移动端模拟失败:', errorMessage(error));
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
231
372
|
const failOnBrowserExit = () => {
|
|
232
|
-
if (session.state === 'recording' || session.state === '
|
|
373
|
+
if (session.state === 'recording' || session.state === 'waiting_for_start') {
|
|
233
374
|
session.stopPromise = this.failSession(session, new Error('Chromium 浏览器意外退出'));
|
|
234
375
|
void session.stopPromise.catch(failure => {
|
|
235
376
|
console.error('[trace-mcp-recorder] 浏览器异常退出:', errorMessage(failure));
|
|
@@ -239,7 +380,7 @@ export class TraceRecorder {
|
|
|
239
380
|
browser.once('disconnected', failOnBrowserExit);
|
|
240
381
|
|
|
241
382
|
return {
|
|
242
|
-
message: '
|
|
383
|
+
message: '浏览器已打开,请点击页面左下角“开始录制”;操作完成后点击“停止录制”,服务将自动上传。',
|
|
243
384
|
maxRecordDuration: configuration.maxRecordDuration,
|
|
244
385
|
metadata
|
|
245
386
|
};
|
|
@@ -270,7 +411,7 @@ export class TraceRecorder {
|
|
|
270
411
|
if (session.state === 'starting') {
|
|
271
412
|
throw new Error('录制会话正在启动,请等待 start_trace_recording 返回后再停止');
|
|
272
413
|
}
|
|
273
|
-
if (session.state === '
|
|
414
|
+
if (session.state === 'waiting_for_start') {
|
|
274
415
|
session.state = 'stopping';
|
|
275
416
|
this.clearTimer(session.timer);
|
|
276
417
|
this.clearTimer(session.idleTimer);
|
|
@@ -286,11 +427,13 @@ export class TraceRecorder {
|
|
|
286
427
|
}
|
|
287
428
|
await this.closeResources({ page: session.page, context: session.context, browser: session.browser });
|
|
288
429
|
if (this.activeSession === session) this.activeSession = undefined;
|
|
289
|
-
|
|
430
|
+
const result = {
|
|
290
431
|
state: 'not_started',
|
|
291
432
|
uploaded: false,
|
|
292
|
-
message: '
|
|
433
|
+
message: '录制已停止。停止前未点击“开始录制”,因此没有生成可上传的 trace。'
|
|
293
434
|
};
|
|
435
|
+
if (trigger !== 'manual') this.lastAutomaticResult = result;
|
|
436
|
+
return result;
|
|
294
437
|
}
|
|
295
438
|
if (session.state === 'recording') {
|
|
296
439
|
if (session.stopPromise) return session.stopPromise;
|
|
@@ -323,6 +466,11 @@ export class TraceRecorder {
|
|
|
323
466
|
try {
|
|
324
467
|
await session.activityPromise?.catch(() => {});
|
|
325
468
|
await session.activityRecordPromise?.catch(() => {});
|
|
469
|
+
if (session.pendingActivity) {
|
|
470
|
+
const pendingActivity = session.pendingActivity;
|
|
471
|
+
session.pendingActivity = undefined;
|
|
472
|
+
await this.recordActivityStep(session, pendingActivity);
|
|
473
|
+
}
|
|
326
474
|
if (session.tracingStarted) await this.recordFinalStep(session);
|
|
327
475
|
await session.context.tracing.stop({ path: session.tracePath });
|
|
328
476
|
await this.closeResources({ page: session.page, context: session.context, browser: session.browser });
|
|
@@ -339,8 +487,8 @@ export class TraceRecorder {
|
|
|
339
487
|
}
|
|
340
488
|
}
|
|
341
489
|
|
|
342
|
-
async beginRecording(session
|
|
343
|
-
if (session.tracingStarted || session.state !== '
|
|
490
|
+
async beginRecording(session) {
|
|
491
|
+
if (session.tracingStarted || session.state !== 'waiting_for_start') return;
|
|
344
492
|
await session.context.tracing.start({ screenshots: true, snapshots: true, sources: true });
|
|
345
493
|
session.tracingStarted = true;
|
|
346
494
|
session.state = 'recording';
|
|
@@ -353,18 +501,85 @@ export class TraceRecorder {
|
|
|
353
501
|
}, session.maxRecordDuration);
|
|
354
502
|
session.timer?.unref?.();
|
|
355
503
|
this.armIdleTimer(session);
|
|
356
|
-
await this.recordActivityStep(session, activity);
|
|
357
504
|
}
|
|
358
505
|
|
|
359
|
-
|
|
360
|
-
if (
|
|
361
|
-
if (session.
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
506
|
+
async applyDeviceMode(session, page, mode) {
|
|
507
|
+
if (mode === 'desktop') {
|
|
508
|
+
if (typeof session.context.newCDPSession !== 'function') return;
|
|
509
|
+
}
|
|
510
|
+
if (typeof session.context.newCDPSession !== 'function') {
|
|
511
|
+
throw new Error('当前浏览器不支持移动端 CDP 模拟');
|
|
512
|
+
}
|
|
513
|
+
let cdp = session.cdpSessions.get(page);
|
|
514
|
+
if (!cdp) {
|
|
515
|
+
cdp = await session.context.newCDPSession(page);
|
|
516
|
+
session.cdpSessions.set(page, cdp);
|
|
517
|
+
}
|
|
518
|
+
if (mode === 'mobile') {
|
|
519
|
+
await cdp.send('Emulation.setDeviceMetricsOverride', {
|
|
520
|
+
width: IPHONE_14_PRO_MAX_DEVICE.width,
|
|
521
|
+
height: IPHONE_14_PRO_MAX_DEVICE.height,
|
|
522
|
+
deviceScaleFactor: IPHONE_14_PRO_MAX_DEVICE.deviceScaleFactor,
|
|
523
|
+
mobile: true,
|
|
524
|
+
screenWidth: IPHONE_14_PRO_MAX_DEVICE.width,
|
|
525
|
+
screenHeight: IPHONE_14_PRO_MAX_DEVICE.height
|
|
526
|
+
});
|
|
527
|
+
await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 5 });
|
|
528
|
+
await cdp.send('Network.setUserAgentOverride', { userAgent: IPHONE_14_PRO_MAX_DEVICE.userAgent });
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
await cdp.send('Emulation.clearDeviceMetricsOverride');
|
|
532
|
+
await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: false });
|
|
533
|
+
await cdp.send('Network.setUserAgentOverride', { userAgent: session.desktopUserAgent });
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async toggleDeviceMode(session) {
|
|
537
|
+
if (session.state !== 'waiting_for_start' && session.state !== 'recording') {
|
|
538
|
+
return { state: session.state, deviceMode: session.deviceMode };
|
|
539
|
+
}
|
|
540
|
+
const nextMode = session.deviceMode === 'mobile' ? 'desktop' : 'mobile';
|
|
541
|
+
await this.applyDeviceMode(session, session.page, nextMode);
|
|
542
|
+
session.deviceMode = nextMode;
|
|
543
|
+
session.deviceName = nextMode === 'mobile' ? IPHONE_14_PRO_MAX_DEVICE.name : 'Desktop';
|
|
544
|
+
session.metadata.deviceMode = session.deviceMode;
|
|
545
|
+
session.metadata.deviceName = session.deviceName;
|
|
546
|
+
session.metadata.userAgent = nextMode === 'mobile'
|
|
547
|
+
? IPHONE_14_PRO_MAX_DEVICE.userAgent
|
|
548
|
+
: session.desktopUserAgent;
|
|
549
|
+
session.metadata.viewport = nextMode === 'mobile'
|
|
550
|
+
? { width: IPHONE_14_PRO_MAX_DEVICE.width, height: IPHONE_14_PRO_MAX_DEVICE.height }
|
|
551
|
+
: session.page.viewportSize();
|
|
552
|
+
return { state: session.state, deviceMode: session.deviceMode, deviceName: session.deviceName };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
handlePageControl(session, action) {
|
|
556
|
+
if (action === 'status') return { state: session.state, deviceMode: session.deviceMode };
|
|
557
|
+
if (action === 'toggle-device') return this.toggleDeviceMode(session);
|
|
558
|
+
if (action === 'start') {
|
|
559
|
+
if (session.state === 'recording') return { state: session.state };
|
|
560
|
+
if (session.state !== 'waiting_for_start') return { state: session.state };
|
|
561
|
+
if (!session.activityPromise) {
|
|
562
|
+
session.activityPromise = this.beginRecording(session).catch(error => {
|
|
563
|
+
session.state = 'failed';
|
|
564
|
+
throw error;
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
return session.activityPromise.then(() => ({ state: session.state, deviceMode: session.deviceMode }));
|
|
568
|
+
}
|
|
569
|
+
if (action === 'stop') {
|
|
570
|
+
if (session.state !== 'recording' && session.state !== 'waiting_for_start') {
|
|
571
|
+
return { state: session.state };
|
|
572
|
+
}
|
|
573
|
+
void this.stop({ trigger: 'page-control', confirm: 'upload' }).catch(error => {
|
|
574
|
+
this.lastAutomaticError = error;
|
|
575
|
+
console.error('[trace-mcp-recorder] 页面按钮停止录制失败:', errorMessage(error));
|
|
365
576
|
});
|
|
366
|
-
return
|
|
577
|
+
return { state: 'stopping' };
|
|
367
578
|
}
|
|
579
|
+
return { state: session.state };
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
handleUserActivity(session, activity = {}) {
|
|
368
583
|
if (session.state !== 'recording') return undefined;
|
|
369
584
|
session.lastActivityAt = Date.now();
|
|
370
585
|
session.pendingActivity = activity;
|
|
@@ -386,7 +601,7 @@ export class TraceRecorder {
|
|
|
386
601
|
}
|
|
387
602
|
|
|
388
603
|
async recordActivityStep(session, activity = {}) {
|
|
389
|
-
if (!session.tracingStarted || session.state
|
|
604
|
+
if (!session.tracingStarted || !['recording', 'stopping'].includes(session.state)) return;
|
|
390
605
|
session.stepIndex += 1;
|
|
391
606
|
const type = typeof activity.type === 'string' && activity.type ? activity.type : 'unknown';
|
|
392
607
|
const title = typeof activity.title === 'string' && activity.title ? activity.title : '未命名页面';
|
|
@@ -441,7 +656,9 @@ export class TraceRecorder {
|
|
|
441
656
|
['browser_version', session.metadata.browserVersion],
|
|
442
657
|
['user_agent', session.metadata.userAgent],
|
|
443
658
|
['os_name', session.metadata.operatingSystem.platform],
|
|
444
|
-
['viewport', JSON.stringify(session.metadata.viewport)]
|
|
659
|
+
['viewport', JSON.stringify(session.metadata.viewport)],
|
|
660
|
+
['device_mode', session.metadata.deviceMode],
|
|
661
|
+
['device_name', session.metadata.deviceName]
|
|
445
662
|
]) {
|
|
446
663
|
if (value !== undefined && value !== null) form.append(field, String(value));
|
|
447
664
|
}
|
|
@@ -465,16 +682,19 @@ export class TraceRecorder {
|
|
|
465
682
|
version: session.metadata.version,
|
|
466
683
|
title: session.metadata.title,
|
|
467
684
|
operator: session.metadata.operator,
|
|
468
|
-
browserVersion: session.metadata.browserVersion
|
|
685
|
+
browserVersion: session.metadata.browserVersion,
|
|
686
|
+
deviceMode: session.metadata.deviceMode,
|
|
687
|
+
deviceName: session.metadata.deviceName,
|
|
688
|
+
viewport: session.metadata.viewport
|
|
469
689
|
}
|
|
470
690
|
};
|
|
471
691
|
session.state = 'completed';
|
|
472
|
-
if (trigger
|
|
692
|
+
if (trigger !== 'manual') this.lastAutomaticResult = result;
|
|
473
693
|
return result;
|
|
474
694
|
})()
|
|
475
695
|
.catch(async error => {
|
|
476
696
|
session.state = 'failed';
|
|
477
|
-
if (trigger
|
|
697
|
+
if (trigger !== 'manual') this.lastAutomaticError = error;
|
|
478
698
|
await this.removeTemporaryDirectory(session.temporaryDirectory).catch(() => {});
|
|
479
699
|
if (this.activeSession === session) this.activeSession = undefined;
|
|
480
700
|
throw error;
|
|
@@ -494,7 +714,7 @@ export class TraceRecorder {
|
|
|
494
714
|
}
|
|
495
715
|
|
|
496
716
|
async failSession(session, error) {
|
|
497
|
-
if (session.state !== 'recording' && session.state !== '
|
|
717
|
+
if (session.state !== 'recording' && session.state !== 'waiting_for_start') return;
|
|
498
718
|
session.state = 'failed';
|
|
499
719
|
this.clearTimer(session.timer);
|
|
500
720
|
this.clearTimer(session.idleTimer);
|
|
@@ -538,8 +758,8 @@ export function createServer(options = {}) {
|
|
|
538
758
|
{ name: 'trace-mcp-recorder', version: '1.0.0' },
|
|
539
759
|
{
|
|
540
760
|
instructions:
|
|
541
|
-
'Use start_trace_recording to open a visible Chromium window
|
|
542
|
-
'When the user asks to stop recording
|
|
761
|
+
'Use start_trace_recording to open a visible Chromium window with page recording controls. ' +
|
|
762
|
+
'When the user asks to stop recording or retrieve a page-control result, call stop_trace_recording.'
|
|
543
763
|
}
|
|
544
764
|
);
|
|
545
765
|
|
|
@@ -547,7 +767,7 @@ export function createServer(options = {}) {
|
|
|
547
767
|
'start_trace_recording',
|
|
548
768
|
{
|
|
549
769
|
title: '启动 Playwright Trace 人工录制',
|
|
550
|
-
description: '启动非无头 Chromium
|
|
770
|
+
description: '启动非无头 Chromium 并访问指定 URL;测试人员通过页面左下角按钮开始和停止 trace。',
|
|
551
771
|
inputSchema: {
|
|
552
772
|
url: z.string().min(1).describe('必填,被测页面的 HTTP(S) 地址'),
|
|
553
773
|
version: z.string().min(1).optional().describe('可选,被测版本号,例如 v2.1.0'),
|
|
@@ -12,6 +12,7 @@ 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]),
|
|
@@ -28,6 +29,14 @@ function createFixture(options = {}) {
|
|
|
28
29
|
};
|
|
29
30
|
const context = {
|
|
30
31
|
tracing,
|
|
32
|
+
newCDPSession: async page => ({
|
|
33
|
+
send: async (method, params) => calls.push(['cdp', method, params])
|
|
34
|
+
}),
|
|
35
|
+
exposeBinding: async (name, callback) => {
|
|
36
|
+
calls.push(['expose-binding', name]);
|
|
37
|
+
bindings[name] = callback;
|
|
38
|
+
},
|
|
39
|
+
addInitScript: async script => calls.push(['init-script', script]),
|
|
31
40
|
newPage: async () => page,
|
|
32
41
|
close: async () => calls.push(['context-close'])
|
|
33
42
|
};
|
|
@@ -77,11 +86,11 @@ function createFixture(options = {}) {
|
|
|
77
86
|
clearTimer: value => calls.push(['clear-timer', value]),
|
|
78
87
|
platformInfo: () => ({ platform: 'fixture-os' })
|
|
79
88
|
});
|
|
80
|
-
return { browser, calls, recorder, timer };
|
|
89
|
+
return { bindings, browser, calls, recorder, timer };
|
|
81
90
|
}
|
|
82
91
|
|
|
83
|
-
test('
|
|
84
|
-
const { calls, recorder } = createFixture();
|
|
92
|
+
test('page controls start recording, record activity, upload and clean up one session', async () => {
|
|
93
|
+
const { bindings, calls, recorder } = createFixture();
|
|
85
94
|
const started = await recorder.start(
|
|
86
95
|
{
|
|
87
96
|
url: 'https://app.example.test/login',
|
|
@@ -95,7 +104,13 @@ test('starts, records metadata, uploads, completes and cleans up one session', a
|
|
|
95
104
|
assert.equal(started.uuid, undefined);
|
|
96
105
|
assert.equal(started.metadata.operator, '上下文操作人');
|
|
97
106
|
assert.equal(started.metadata.browserVersion, 'Fixture Chromium 1');
|
|
98
|
-
|
|
107
|
+
assert.equal(calls.some(call => call[0] === 'trace-start'), false);
|
|
108
|
+
assert.match(calls.find(call => call[0] === 'init-script')[1].content, /开始录制/);
|
|
109
|
+
assert.deepEqual(await bindings.__traceRecorderControl({}, 'start'), {
|
|
110
|
+
state: 'recording',
|
|
111
|
+
deviceMode: 'desktop'
|
|
112
|
+
});
|
|
113
|
+
recorder.handleUserActivity(recorder.activeSession, {
|
|
99
114
|
type: 'click',
|
|
100
115
|
title: '首页功能'
|
|
101
116
|
});
|
|
@@ -131,7 +146,7 @@ test('stops waiting sessions immediately without reporting an error', async () =
|
|
|
131
146
|
assert.deepEqual(result, {
|
|
132
147
|
state: 'not_started',
|
|
133
148
|
uploaded: false,
|
|
134
|
-
message: '
|
|
149
|
+
message: '录制已停止。停止前未点击“开始录制”,因此没有生成可上传的 trace。'
|
|
135
150
|
});
|
|
136
151
|
assert.equal(recorder.activeSession, undefined);
|
|
137
152
|
assert.ok(calls.some(call => call[0] === 'browser-close'));
|
|
@@ -150,7 +165,8 @@ test('cleans up when Chromium launch fails without a backend session', async ()
|
|
|
150
165
|
test('marks the session failed and removes the local trace when upload fails', async () => {
|
|
151
166
|
const { calls, recorder } = createFixture({ uploadError: new Error('upload failed') });
|
|
152
167
|
await recorder.start({ url: 'https://app.example.test' });
|
|
153
|
-
await recorder.
|
|
168
|
+
await recorder.handlePageControl(recorder.activeSession, 'start');
|
|
169
|
+
recorder.handleUserActivity(recorder.activeSession);
|
|
154
170
|
|
|
155
171
|
await recorder.stop();
|
|
156
172
|
await assert.rejects(recorder.stop({ confirm: 'upload' }), /upload failed/);
|
|
@@ -160,7 +176,7 @@ test('marks the session failed and removes the local trace when upload fails', a
|
|
|
160
176
|
test('automatically stops on timeout and returns that result on the next stop call', async () => {
|
|
161
177
|
const { recorder, timer } = createFixture();
|
|
162
178
|
await recorder.start({ url: 'https://app.example.test' });
|
|
163
|
-
await recorder.
|
|
179
|
+
await recorder.handlePageControl(recorder.activeSession, 'start');
|
|
164
180
|
timer.callback();
|
|
165
181
|
|
|
166
182
|
while (recorder.activeSession) await new Promise(resolve => setImmediate(resolve));
|
|
@@ -169,6 +185,41 @@ test('automatically stops on timeout and returns that result on the next stop ca
|
|
|
169
185
|
await assert.rejects(recorder.stop(), /当前没有正在录制/);
|
|
170
186
|
});
|
|
171
187
|
|
|
188
|
+
test('page stop control immediately stops and uploads, then exposes the result to MCP', async () => {
|
|
189
|
+
const { bindings, recorder } = createFixture();
|
|
190
|
+
await recorder.start({ url: 'https://app.example.test' });
|
|
191
|
+
await bindings.__traceRecorderControl({}, 'start');
|
|
192
|
+
|
|
193
|
+
assert.deepEqual(await bindings.__traceRecorderControl({}, 'stop'), { state: 'stopping' });
|
|
194
|
+
while (recorder.activeSession) await new Promise(resolve => setImmediate(resolve));
|
|
195
|
+
|
|
196
|
+
const result = await recorder.stop();
|
|
197
|
+
assert.equal(result.stoppedBy, 'page-control');
|
|
198
|
+
assert.match(result.replayLink, /trace-viewer/);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('toggles desktop and iPhone 14 Pro Max emulation in the same session', async () => {
|
|
202
|
+
const { bindings, calls, recorder } = createFixture();
|
|
203
|
+
await recorder.start({ url: 'https://app.example.test' });
|
|
204
|
+
|
|
205
|
+
const mobile = await bindings.__traceRecorderControl({}, 'toggle-device');
|
|
206
|
+
assert.deepEqual(mobile, {
|
|
207
|
+
state: 'waiting_for_start',
|
|
208
|
+
deviceMode: 'mobile',
|
|
209
|
+
deviceName: 'iPhone 14 Pro Max'
|
|
210
|
+
});
|
|
211
|
+
assert.ok(calls.some(call => call[0] === 'cdp' && call[1] === 'Emulation.setDeviceMetricsOverride'));
|
|
212
|
+
assert.deepEqual(recorder.activeSession.metadata.viewport, { width: 430, height: 932 });
|
|
213
|
+
assert.match(recorder.activeSession.metadata.userAgent, /iPhone/);
|
|
214
|
+
|
|
215
|
+
const desktop = await bindings.__traceRecorderControl({}, 'toggle-device');
|
|
216
|
+
assert.equal(desktop.deviceMode, 'desktop');
|
|
217
|
+
assert.ok(calls.some(call => call[0] === 'cdp' && call[1] === 'Emulation.clearDeviceMetricsOverride'));
|
|
218
|
+
assert.equal(recorder.activeSession.metadata.userAgent, 'Fixture User Agent');
|
|
219
|
+
|
|
220
|
+
await recorder.stop();
|
|
221
|
+
});
|
|
222
|
+
|
|
172
223
|
test('exposes both tools and the bundled skill over stdio', async () => {
|
|
173
224
|
const client = new Client({ name: 'trace-mcp-recorder-test', version: '1.0.0' });
|
|
174
225
|
const transport = new StdioClientTransport({
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@modelcontextprotocol/sdk": "1.30.0",
|
|
12
12
|
"axios": "1.20.0",
|
|
13
|
+
"fflate": "0.8.2",
|
|
13
14
|
"form-data": "4.0.6",
|
|
15
|
+
"jpeg-js": "0.4.4",
|
|
14
16
|
"playwright": "1.63.0",
|
|
15
17
|
"uuid": "14.0.2",
|
|
16
18
|
"zod": "4.4.3"
|
|
@@ -496,6 +498,11 @@
|
|
|
496
498
|
}
|
|
497
499
|
]
|
|
498
500
|
},
|
|
501
|
+
"node_modules/fflate": {
|
|
502
|
+
"version": "0.8.2",
|
|
503
|
+
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
|
504
|
+
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="
|
|
505
|
+
},
|
|
499
506
|
"node_modules/finalhandler": {
|
|
500
507
|
"version": "2.1.1",
|
|
501
508
|
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
|
|
@@ -768,6 +775,11 @@
|
|
|
768
775
|
"url": "https://github.com/sponsors/panva"
|
|
769
776
|
}
|
|
770
777
|
},
|
|
778
|
+
"node_modules/jpeg-js": {
|
|
779
|
+
"version": "0.4.4",
|
|
780
|
+
"resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz",
|
|
781
|
+
"integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="
|
|
782
|
+
},
|
|
771
783
|
"node_modules/json-schema-traverse": {
|
|
772
784
|
"version": "1.0.0",
|
|
773
785
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
|
@@ -1613,6 +1625,11 @@
|
|
|
1613
1625
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
|
|
1614
1626
|
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg=="
|
|
1615
1627
|
},
|
|
1628
|
+
"fflate": {
|
|
1629
|
+
"version": "0.8.2",
|
|
1630
|
+
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
|
1631
|
+
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="
|
|
1632
|
+
},
|
|
1616
1633
|
"finalhandler": {
|
|
1617
1634
|
"version": "2.1.1",
|
|
1618
1635
|
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
|
|
@@ -1789,6 +1806,11 @@
|
|
|
1789
1806
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz",
|
|
1790
1807
|
"integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw=="
|
|
1791
1808
|
},
|
|
1809
|
+
"jpeg-js": {
|
|
1810
|
+
"version": "0.4.4",
|
|
1811
|
+
"resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz",
|
|
1812
|
+
"integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="
|
|
1813
|
+
},
|
|
1792
1814
|
"json-schema-traverse": {
|
|
1793
1815
|
"version": "1.0.0",
|
|
1794
1816
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
package/package.json
CHANGED
|
@@ -14,14 +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. 告知用户浏览器已打开:默认是 PC 端;如需移动端测试,先点击左下角“切换移动端”(iPhone 14 Pro Max,430×932,DPR 3),再点击“开始录制”;操作完成后点击“停止录制”。不要代替用户点击按钮或操作页面。点击开始后才启动 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
24
|
|
|
25
|
-
|
|
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;若返回上传结果,则只整理上传返回的回放链接。
|
|
26
28
|
|
|
27
29
|
若工具返回错误,如没有活动会话、后端不可达或上传失败,原样说明关键原因,不编造回放链接。
|