@autobest-ui/agent 1.0.21 → 1.0.22
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,并在页面左下角注入半透明的“开始录制”“停止录制”和设备切换按钮供测试人员手动控制 Trace;默认是 PC 端,也可切换为 iPhone 14 Pro Max
|
|
3
|
+
`trace-mcp-recorder` 是基于 `@modelcontextprotocol/sdk` 和 `StdioServerTransport` 的 Node.js MCP Server。它打开非无头 Chromium,并在页面左下角注入半透明的“开始录制”“停止录制”和设备切换按钮供测试人员手动控制 Trace;默认是 PC 端,也可切换为 iPhone 14 Pro Max 移动端模拟。页面先正常打开并等待,点击开始后先启动 Trace、刷新一次当前 SPA 页面并等待 DOM、CSS、字体和图片稳定,再进入正式录制;后续用户活动经过防抖后以 `步骤N | 页面标题 | 事件类型` 的 Trace Group 写入轻量 Playwright Action,空闲超过 2 分钟自动结束,然后上传原生 `trace.zip` 和浏览器元数据,并返回私有后端的 Trace Viewer 链接。同一进程同一时间只允许一个录制任务。
|
|
4
4
|
|
|
5
5
|
## 安装与启动
|
|
6
6
|
|
|
@@ -187,10 +187,11 @@ function createRecorderPageScript() {
|
|
|
187
187
|
const render = (state, deviceMode = 'desktop') => {
|
|
188
188
|
const recording = state === 'recording';
|
|
189
189
|
const waiting = state === 'waiting_for_start';
|
|
190
|
+
const preparing = state === 'starting';
|
|
190
191
|
startButton.disabled = !waiting;
|
|
191
192
|
stopButton.disabled = !recording && !waiting;
|
|
192
193
|
deviceButton.disabled = state === 'starting' || state === 'stopping' || state === 'uploading';
|
|
193
|
-
startButton.textContent = recording ? '录制中' : '开始录制';
|
|
194
|
+
startButton.textContent = preparing ? '准备页面' : recording ? '录制中' : '开始录制';
|
|
194
195
|
stopButton.textContent = state === 'stopping' || state === 'uploading' ? '正在停止' : '停止录制';
|
|
195
196
|
deviceButton.textContent = deviceMode === 'mobile' ? '切换 PC 端' : '切换移动端';
|
|
196
197
|
};
|
|
@@ -409,7 +410,13 @@ export class TraceRecorder {
|
|
|
409
410
|
throw new Error('当前没有正在录制的 trace 会话');
|
|
410
411
|
}
|
|
411
412
|
if (session.state === 'starting') {
|
|
412
|
-
|
|
413
|
+
await session.activityPromise?.catch(error => {
|
|
414
|
+
throw error;
|
|
415
|
+
});
|
|
416
|
+
if (session.state === 'failed') {
|
|
417
|
+
throw new Error('录制初始化失败,无法停止当前 trace');
|
|
418
|
+
}
|
|
419
|
+
return this.stop({ trigger, confirm });
|
|
413
420
|
}
|
|
414
421
|
if (session.state === 'waiting_for_start') {
|
|
415
422
|
session.state = 'stopping';
|
|
@@ -489,8 +496,49 @@ export class TraceRecorder {
|
|
|
489
496
|
|
|
490
497
|
async beginRecording(session) {
|
|
491
498
|
if (session.tracingStarted || session.state !== 'waiting_for_start') return;
|
|
499
|
+
// The initial page was opened before the user clicked the button. Start the
|
|
500
|
+
// trace first, then reload once so SPA CSS, fonts, images and DOM resources
|
|
501
|
+
// are captured by Trace Viewer instead of being only browser-cache state.
|
|
502
|
+
session.state = 'starting';
|
|
492
503
|
await session.context.tracing.start({ screenshots: true, snapshots: true, sources: true });
|
|
493
504
|
session.tracingStarted = true;
|
|
505
|
+
|
|
506
|
+
const currentUrl = typeof session.page.url === 'function' ? session.page.url() : session.metadata.targetUrl;
|
|
507
|
+
const scrollPosition = await session.page
|
|
508
|
+
.evaluate(() => ({ x: window.scrollX, y: window.scrollY }))
|
|
509
|
+
.catch(() => ({ x: 0, y: 0 }));
|
|
510
|
+
|
|
511
|
+
await session.page.reload({ waitUntil: 'domcontentloaded' });
|
|
512
|
+
// networkidle is intentionally best-effort: SPAs with polling/websockets
|
|
513
|
+
// may never become idle, while the resource checks below remain bounded.
|
|
514
|
+
if (typeof session.page.waitForLoadState === 'function') {
|
|
515
|
+
await session.page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => {});
|
|
516
|
+
}
|
|
517
|
+
await session.page
|
|
518
|
+
.evaluate(async () => {
|
|
519
|
+
await document.fonts?.ready?.catch?.(() => {});
|
|
520
|
+
const pendingImages = Array.from(document.images).filter(image => !image.complete);
|
|
521
|
+
await Promise.race([
|
|
522
|
+
Promise.all(pendingImages.map(image => new Promise(resolve => {
|
|
523
|
+
image.addEventListener('load', resolve, { once: true });
|
|
524
|
+
image.addEventListener('error', resolve, { once: true });
|
|
525
|
+
}))),
|
|
526
|
+
new Promise(resolve => setTimeout(resolve, 10_000))
|
|
527
|
+
]);
|
|
528
|
+
})
|
|
529
|
+
.catch(() => {});
|
|
530
|
+
if (currentUrl && typeof session.page.url === 'function' && session.page.url() !== currentUrl) {
|
|
531
|
+
await session.page.goto(currentUrl, { waitUntil: 'domcontentloaded' });
|
|
532
|
+
}
|
|
533
|
+
await session.page
|
|
534
|
+
.evaluate(({ x, y }) => window.scrollTo(x, y), scrollPosition)
|
|
535
|
+
.catch(() => {});
|
|
536
|
+
|
|
537
|
+
session.metadata.targetUrl = currentUrl || session.metadata.targetUrl;
|
|
538
|
+
session.metadata.userAgent = await session.page
|
|
539
|
+
.evaluate(() => navigator.userAgent)
|
|
540
|
+
.catch(() => session.metadata.userAgent);
|
|
541
|
+
session.metadata.viewport = session.page.viewportSize?.() ?? session.metadata.viewport;
|
|
494
542
|
session.state = 'recording';
|
|
495
543
|
session.lastActivityAt = Date.now();
|
|
496
544
|
session.timer = this.setTimer(() => {
|
|
@@ -561,7 +609,9 @@ export class TraceRecorder {
|
|
|
561
609
|
if (!session.activityPromise) {
|
|
562
610
|
session.activityPromise = this.beginRecording(session).catch(error => this.failSession(session, error));
|
|
563
611
|
}
|
|
564
|
-
|
|
612
|
+
// Do not hold the page binding open while reload navigates the document.
|
|
613
|
+
// The toolbar polls status and will switch to recording after the reload.
|
|
614
|
+
return { state: 'starting', deviceMode: session.deviceMode };
|
|
565
615
|
}
|
|
566
616
|
if (action === 'stop') {
|
|
567
617
|
if (session.state !== 'recording' && session.state !== 'waiting_for_start') {
|
|
@@ -711,7 +761,7 @@ export class TraceRecorder {
|
|
|
711
761
|
}
|
|
712
762
|
|
|
713
763
|
async failSession(session, error) {
|
|
714
|
-
if (
|
|
764
|
+
if (!['starting', 'recording', 'waiting_for_start'].includes(session.state)) return;
|
|
715
765
|
session.state = 'failed';
|
|
716
766
|
this.clearTimer(session.timer);
|
|
717
767
|
this.clearTimer(session.idleTimer);
|
|
@@ -108,10 +108,13 @@ test('page controls start recording, record activity, upload and clean up one se
|
|
|
108
108
|
assert.equal(calls.some(call => call[0] === 'trace-start'), false);
|
|
109
109
|
assert.match(calls.find(call => call[0] === 'init-script')[1].content, /开始录制/);
|
|
110
110
|
assert.deepEqual(await bindings.__traceRecorderControl({}, 'start'), {
|
|
111
|
-
state: '
|
|
111
|
+
state: 'starting',
|
|
112
112
|
deviceMode: 'desktop'
|
|
113
113
|
});
|
|
114
|
-
|
|
114
|
+
await recorder.activeSession.activityPromise;
|
|
115
|
+
assert.equal(recorder.activeSession.state, 'recording');
|
|
116
|
+
assert.ok(calls.some(call => call[0] === 'reload' && call[1].waitUntil === 'domcontentloaded'));
|
|
117
|
+
assert.ok(calls.findIndex(call => call[0] === 'trace-start') < calls.findIndex(call => call[0] === 'reload'));
|
|
115
118
|
recorder.handleUserActivity(recorder.activeSession, {
|
|
116
119
|
type: 'click',
|
|
117
120
|
title: '首页功能'
|
|
@@ -168,6 +171,7 @@ test('marks the session failed and removes the local trace when upload fails', a
|
|
|
168
171
|
const { calls, recorder } = createFixture({ uploadError: new Error('upload failed') });
|
|
169
172
|
await recorder.start({ url: 'https://app.example.test' });
|
|
170
173
|
await recorder.handlePageControl(recorder.activeSession, 'start');
|
|
174
|
+
await recorder.activeSession.activityPromise;
|
|
171
175
|
recorder.handleUserActivity(recorder.activeSession);
|
|
172
176
|
|
|
173
177
|
await recorder.stop();
|
|
@@ -179,6 +183,7 @@ test('automatically stops on timeout and returns that result on the next stop ca
|
|
|
179
183
|
const { recorder, timer } = createFixture();
|
|
180
184
|
await recorder.start({ url: 'https://app.example.test' });
|
|
181
185
|
await recorder.handlePageControl(recorder.activeSession, 'start');
|
|
186
|
+
await recorder.activeSession.activityPromise;
|
|
182
187
|
timer.callback();
|
|
183
188
|
|
|
184
189
|
while (recorder.activeSession) await new Promise(resolve => setImmediate(resolve));
|
|
@@ -191,6 +196,7 @@ test('page stop control immediately stops and uploads, then exposes the result t
|
|
|
191
196
|
const { bindings, recorder } = createFixture();
|
|
192
197
|
await recorder.start({ url: 'https://app.example.test' });
|
|
193
198
|
await bindings.__traceRecorderControl({}, 'start');
|
|
199
|
+
await recorder.activeSession.activityPromise;
|
|
194
200
|
|
|
195
201
|
assert.deepEqual(await bindings.__traceRecorderControl({}, 'stop'), { state: 'stopping' });
|
|
196
202
|
while (recorder.activeSession) await new Promise(resolve => setImmediate(resolve));
|
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. 告知用户浏览器已打开:默认是 PC 端;如需移动端测试,先点击左下角“切换移动端”(iPhone 14 Pro Max,430×932,DPR 3
|
|
17
|
+
4. 告知用户浏览器已打开:默认是 PC 端;如需移动端测试,先点击左下角“切换移动端”(iPhone 14 Pro Max,430×932,DPR 3),再点击“开始录制”;点击开始后服务会先启动 Trace、刷新当前 SPA 页面一次并等待 DOM、CSS、字体和图片稳定,随后才进入正式录制计时。刷新期间不要操作页面;完成后点击“停止录制”。同一 BrowserContext 会保留 cookies、localStorage 和 sessionStorage,新打开的页面会继承当前设备模式。连续 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
|
|