@autobest-ui/agent 1.0.22 → 1.0.24
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,在页面左上角注入可收缩的半透明录制控制栏,并在右上角单独显示半透明“截图”按钮;开始录制、停止录制和设备切换保持在左上角控制栏中。默认是 PC 端,也可切换为 iPhone 14 Pro Max 移动端模拟。页面先正常打开并等待,点击开始后先启动 Trace,临时禁用 Chromium 缓存并刷新一次当前 SPA 页面,等待 DOM、CSS、字体和图片稳定后恢复缓存,再进入正式录制;普通点击、输入和滚动不会生成自定义 Action,只有点击右上角“截图”才会把当前渲染画面作为 Trace Group 写入官方 Viewer,截图时所有控制按钮会临时隐藏。空闲超过 2 分钟自动结束,然后上传原生 `trace.zip` 和浏览器元数据,并返回私有后端的 Trace Viewer 链接。同一进程同一时间只允许一个录制任务。
|
|
4
4
|
|
|
5
5
|
## 安装与启动
|
|
6
6
|
|
|
@@ -141,8 +141,8 @@ function createRecorderPageScript() {
|
|
|
141
141
|
:host { all: initial; }
|
|
142
142
|
.toolbar {
|
|
143
143
|
position: fixed;
|
|
144
|
-
left:
|
|
145
|
-
|
|
144
|
+
left: 0;
|
|
145
|
+
top: 16px;
|
|
146
146
|
z-index: 2147483647;
|
|
147
147
|
display: flex;
|
|
148
148
|
gap: 8px;
|
|
@@ -153,6 +153,12 @@ function createRecorderPageScript() {
|
|
|
153
153
|
box-shadow: 0 4px 16px rgba(0, 0, 0, .2);
|
|
154
154
|
backdrop-filter: blur(6px);
|
|
155
155
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
156
|
+
transform: translateX(calc(-100% + 30px));
|
|
157
|
+
transition: transform .2s ease;
|
|
158
|
+
}
|
|
159
|
+
.toolbar:hover,
|
|
160
|
+
.toolbar:focus-within {
|
|
161
|
+
transform: translateX(0);
|
|
156
162
|
}
|
|
157
163
|
button {
|
|
158
164
|
box-sizing: border-box;
|
|
@@ -172,15 +178,37 @@ function createRecorderPageScript() {
|
|
|
172
178
|
.start { background: rgba(22, 163, 74, .82); }
|
|
173
179
|
.stop { background: rgba(220, 38, 38, .82); }
|
|
174
180
|
.device { background: rgba(37, 99, 235, .82); min-width: 102px; }
|
|
181
|
+
.toggle {
|
|
182
|
+
flex: 0 0 24px;
|
|
183
|
+
min-width: 24px;
|
|
184
|
+
padding: 0;
|
|
185
|
+
background: rgba(255, 255, 255, .16);
|
|
186
|
+
font-size: 20px;
|
|
187
|
+
}
|
|
188
|
+
.screenshot {
|
|
189
|
+
position: fixed;
|
|
190
|
+
top: 16px;
|
|
191
|
+
right: 16px;
|
|
192
|
+
z-index: 2147483647;
|
|
193
|
+
min-width: 76px;
|
|
194
|
+
height: 34px;
|
|
195
|
+
padding: 0 12px;
|
|
196
|
+
background: rgba(234, 88, 12, .68);
|
|
197
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, .18);
|
|
198
|
+
backdrop-filter: blur(6px);
|
|
199
|
+
}
|
|
175
200
|
</style>
|
|
176
201
|
<div class="toolbar" role="group" aria-label="Trace 录制控制">
|
|
202
|
+
<button class="toggle" type="button" aria-label="展开录制控制" data-trace-recorder-control>›</button>
|
|
177
203
|
<button class="start" type="button" data-action="start" data-trace-recorder-control>开始录制</button>
|
|
178
204
|
<button class="stop" type="button" data-action="stop" data-trace-recorder-control>停止录制</button>
|
|
179
205
|
<button class="device" type="button" data-action="toggle-device" data-trace-recorder-control>切换移动端</button>
|
|
180
206
|
</div>
|
|
207
|
+
<button class="screenshot" type="button" data-action="screenshot" data-trace-recorder-control>截图</button>
|
|
181
208
|
\`;
|
|
182
209
|
document.documentElement.append(host);
|
|
183
210
|
const startButton = shadow.querySelector('[data-action="start"]');
|
|
211
|
+
const screenshotButton = shadow.querySelector('[data-action="screenshot"]');
|
|
184
212
|
const stopButton = shadow.querySelector('[data-action="stop"]');
|
|
185
213
|
const deviceButton = shadow.querySelector('[data-action="toggle-device"]');
|
|
186
214
|
|
|
@@ -189,6 +217,7 @@ function createRecorderPageScript() {
|
|
|
189
217
|
const waiting = state === 'waiting_for_start';
|
|
190
218
|
const preparing = state === 'starting';
|
|
191
219
|
startButton.disabled = !waiting;
|
|
220
|
+
screenshotButton.disabled = !recording;
|
|
192
221
|
stopButton.disabled = !recording && !waiting;
|
|
193
222
|
deviceButton.disabled = state === 'starting' || state === 'stopping' || state === 'uploading';
|
|
194
223
|
startButton.textContent = preparing ? '准备页面' : recording ? '录制中' : '开始录制';
|
|
@@ -197,6 +226,7 @@ function createRecorderPageScript() {
|
|
|
197
226
|
};
|
|
198
227
|
const control = async action => {
|
|
199
228
|
startButton.disabled = true;
|
|
229
|
+
screenshotButton.disabled = true;
|
|
200
230
|
stopButton.disabled = true;
|
|
201
231
|
try {
|
|
202
232
|
const result = await globalThis.__traceRecorderControl?.(action);
|
|
@@ -210,6 +240,11 @@ function createRecorderPageScript() {
|
|
|
210
240
|
event.stopPropagation();
|
|
211
241
|
void control('start');
|
|
212
242
|
});
|
|
243
|
+
screenshotButton.addEventListener('click', event => {
|
|
244
|
+
event.preventDefault();
|
|
245
|
+
event.stopPropagation();
|
|
246
|
+
void control('screenshot');
|
|
247
|
+
});
|
|
213
248
|
stopButton.addEventListener('click', event => {
|
|
214
249
|
event.preventDefault();
|
|
215
250
|
event.stopPropagation();
|
|
@@ -508,25 +543,44 @@ export class TraceRecorder {
|
|
|
508
543
|
.evaluate(() => ({ x: window.scrollX, y: window.scrollY }))
|
|
509
544
|
.catch(() => ({ x: 0, y: 0 }));
|
|
510
545
|
|
|
511
|
-
|
|
512
|
-
//
|
|
513
|
-
//
|
|
514
|
-
|
|
515
|
-
|
|
546
|
+
// The first page load may have populated Chromium's HTTP cache before
|
|
547
|
+
// tracing started. Disable cache only for this reload so CSS, images and
|
|
548
|
+
// fonts produce fresh response bodies inside trace.zip.
|
|
549
|
+
let cacheSession;
|
|
550
|
+
let cacheWasDisabled = false;
|
|
551
|
+
try {
|
|
552
|
+
if (typeof session.context.newCDPSession === 'function') {
|
|
553
|
+
cacheSession = session.cdpSessions.get(session.page) ?? await session.context.newCDPSession(session.page);
|
|
554
|
+
session.cdpSessions.set(session.page, cacheSession);
|
|
555
|
+
await cacheSession.send('Network.enable');
|
|
556
|
+
await cacheSession.send('Network.setCacheDisabled', { cacheDisabled: true });
|
|
557
|
+
cacheWasDisabled = true;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
await session.page.reload({ waitUntil: 'domcontentloaded' });
|
|
561
|
+
// networkidle is intentionally best-effort: SPAs with polling/websockets
|
|
562
|
+
// may never become idle, while the resource checks below remain bounded.
|
|
563
|
+
if (typeof session.page.waitForLoadState === 'function') {
|
|
564
|
+
await session.page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => {});
|
|
565
|
+
}
|
|
566
|
+
await session.page
|
|
567
|
+
.evaluate(async () => {
|
|
568
|
+
await document.fonts?.ready?.catch?.(() => {});
|
|
569
|
+
const pendingImages = Array.from(document.images).filter(image => !image.complete);
|
|
570
|
+
await Promise.race([
|
|
571
|
+
Promise.all(pendingImages.map(image => new Promise(resolve => {
|
|
572
|
+
image.addEventListener('load', resolve, { once: true });
|
|
573
|
+
image.addEventListener('error', resolve, { once: true });
|
|
574
|
+
}))),
|
|
575
|
+
new Promise(resolve => setTimeout(resolve, 10_000))
|
|
576
|
+
]);
|
|
577
|
+
})
|
|
578
|
+
.catch(() => {});
|
|
579
|
+
} finally {
|
|
580
|
+
if (cacheWasDisabled) {
|
|
581
|
+
await cacheSession.send('Network.setCacheDisabled', { cacheDisabled: false }).catch(() => {});
|
|
582
|
+
}
|
|
516
583
|
}
|
|
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
584
|
if (currentUrl && typeof session.page.url === 'function' && session.page.url() !== currentUrl) {
|
|
531
585
|
await session.page.goto(currentUrl, { waitUntil: 'domcontentloaded' });
|
|
532
586
|
}
|
|
@@ -603,6 +657,10 @@ export class TraceRecorder {
|
|
|
603
657
|
handlePageControl(session, action) {
|
|
604
658
|
if (action === 'status') return { state: session.state, deviceMode: session.deviceMode };
|
|
605
659
|
if (action === 'toggle-device') return this.toggleDeviceMode(session);
|
|
660
|
+
if (action === 'screenshot') {
|
|
661
|
+
if (session.state !== 'recording') return { state: session.state, captured: false };
|
|
662
|
+
return this.captureScreenshot(session).then(() => ({ state: session.state, captured: true }));
|
|
663
|
+
}
|
|
606
664
|
if (action === 'start') {
|
|
607
665
|
if (session.state === 'recording') return { state: session.state };
|
|
608
666
|
if (session.state !== 'waiting_for_start') return { state: session.state };
|
|
@@ -626,24 +684,52 @@ export class TraceRecorder {
|
|
|
626
684
|
return { state: session.state };
|
|
627
685
|
}
|
|
628
686
|
|
|
687
|
+
async captureScreenshot(session) {
|
|
688
|
+
if (!session.tracingStarted || session.state !== 'recording') return;
|
|
689
|
+
session.stepIndex += 1;
|
|
690
|
+
let title = '未命名页面';
|
|
691
|
+
if (typeof session.page.title === 'function') {
|
|
692
|
+
title = await session.page.title().catch(() => title) || title;
|
|
693
|
+
}
|
|
694
|
+
const label = `步骤${session.stepIndex} | ${title}`;
|
|
695
|
+
await session.context.tracing.group(label);
|
|
696
|
+
let toolbarHidden = false;
|
|
697
|
+
try {
|
|
698
|
+
// Keep the recorder controls out of the user-selected page evidence.
|
|
699
|
+
await session.page.evaluate(() => {
|
|
700
|
+
const toolbar = document.querySelector('[data-trace-recorder-toolbar-host]');
|
|
701
|
+
if (toolbar) {
|
|
702
|
+
toolbar.setAttribute('data-trace-recorder-hidden', '');
|
|
703
|
+
toolbar.style.visibility = 'hidden';
|
|
704
|
+
}
|
|
705
|
+
});
|
|
706
|
+
toolbarHidden = true;
|
|
707
|
+
// page.screenshot is intentionally used inside the Trace group so the
|
|
708
|
+
// official Viewer records this deliberate visual checkpoint.
|
|
709
|
+
await session.page.screenshot({ fullPage: false });
|
|
710
|
+
await session.page.waitForTimeout(1);
|
|
711
|
+
session.lastActivityAt = Date.now();
|
|
712
|
+
this.armIdleTimer(session);
|
|
713
|
+
} finally {
|
|
714
|
+
if (toolbarHidden) {
|
|
715
|
+
await session.page.evaluate(() => {
|
|
716
|
+
const toolbar = document.querySelector('[data-trace-recorder-toolbar-host]');
|
|
717
|
+
if (toolbar) {
|
|
718
|
+
toolbar.style.visibility = '';
|
|
719
|
+
toolbar.removeAttribute('data-trace-recorder-hidden');
|
|
720
|
+
}
|
|
721
|
+
}).catch(() => {});
|
|
722
|
+
}
|
|
723
|
+
await session.context.tracing.groupEnd();
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
629
727
|
handleUserActivity(session, activity = {}) {
|
|
630
728
|
if (session.state !== 'recording') return undefined;
|
|
729
|
+
// Page activity is intentionally not recorded as an Action. The operator
|
|
730
|
+
// explicitly controls collection by pressing the Screenshot button.
|
|
631
731
|
session.lastActivityAt = Date.now();
|
|
632
|
-
session.pendingActivity = activity;
|
|
633
732
|
this.armIdleTimer(session);
|
|
634
|
-
if (session.activityDebounceTimer) this.clearTimer(session.activityDebounceTimer);
|
|
635
|
-
session.activityDebounceTimer = this.setTimer(() => {
|
|
636
|
-
session.activityDebounceTimer = undefined;
|
|
637
|
-
const pendingActivity = session.pendingActivity;
|
|
638
|
-
session.pendingActivity = undefined;
|
|
639
|
-
session.activityRecordPromise = (session.activityRecordPromise ?? Promise.resolve())
|
|
640
|
-
.then(() => this.recordActivityStep(session, pendingActivity))
|
|
641
|
-
.catch(() => {})
|
|
642
|
-
.finally(() => {
|
|
643
|
-
session.activityRecordPromise = undefined;
|
|
644
|
-
});
|
|
645
|
-
}, USER_ACTIVITY_DEBOUNCE_DELAY);
|
|
646
|
-
session.activityDebounceTimer?.unref?.();
|
|
647
733
|
return undefined;
|
|
648
734
|
}
|
|
649
735
|
|
|
@@ -652,7 +738,7 @@ export class TraceRecorder {
|
|
|
652
738
|
session.stepIndex += 1;
|
|
653
739
|
const type = typeof activity.type === 'string' && activity.type ? activity.type : 'unknown';
|
|
654
740
|
const title = typeof activity.title === 'string' && activity.title ? activity.title : '未命名页面';
|
|
655
|
-
const label = `步骤${session.stepIndex} | ${title}
|
|
741
|
+
const label = `步骤${session.stepIndex} | ${title}`;
|
|
656
742
|
await session.context.tracing.group(label);
|
|
657
743
|
try {
|
|
658
744
|
await session.page.waitForTimeout(1);
|
|
@@ -667,7 +753,7 @@ export class TraceRecorder {
|
|
|
667
753
|
if (typeof session.page.title === 'function') {
|
|
668
754
|
title = await session.page.title().catch(() => title) || title;
|
|
669
755
|
}
|
|
670
|
-
const label = `步骤${session.stepIndex} | ${title}
|
|
756
|
+
const label = `步骤${session.stepIndex} | ${title}`;
|
|
671
757
|
await session.context.tracing.group(label);
|
|
672
758
|
try {
|
|
673
759
|
await session.page.waitForTimeout(1);
|
|
@@ -25,6 +25,7 @@ function createFixture(options = {}) {
|
|
|
25
25
|
reload: async options => calls.push(['reload', options]),
|
|
26
26
|
evaluate: async () => 'Fixture User Agent',
|
|
27
27
|
waitForTimeout: async duration => calls.push(['wait', duration]),
|
|
28
|
+
screenshot: async options => calls.push(['screenshot', options]),
|
|
28
29
|
viewportSize: () => ({ width: 1280, height: 720 }),
|
|
29
30
|
close: async () => calls.push(['page-close'])
|
|
30
31
|
};
|
|
@@ -113,8 +114,17 @@ test('page controls start recording, record activity, upload and clean up one se
|
|
|
113
114
|
});
|
|
114
115
|
await recorder.activeSession.activityPromise;
|
|
115
116
|
assert.equal(recorder.activeSession.state, 'recording');
|
|
117
|
+
const screenshotResult = await bindings.__traceRecorderControl({}, 'screenshot');
|
|
118
|
+
assert.deepEqual(screenshotResult, { state: 'recording', captured: true });
|
|
119
|
+
assert.ok(calls.some(call => call[0] === 'screenshot' && call[1].fullPage === false));
|
|
120
|
+
assert.ok(calls.some(call => call[0] === 'trace-group' && call[1] === '步骤1 | 未命名页面'));
|
|
116
121
|
assert.ok(calls.some(call => call[0] === 'reload' && call[1].waitUntil === 'domcontentloaded'));
|
|
117
122
|
assert.ok(calls.findIndex(call => call[0] === 'trace-start') < calls.findIndex(call => call[0] === 'reload'));
|
|
123
|
+
const cacheOnIndex = calls.findIndex(call => call[0] === 'cdp' && call[1] === 'Network.setCacheDisabled' && call[2].cacheDisabled === true);
|
|
124
|
+
const reloadIndex = calls.findIndex(call => call[0] === 'reload');
|
|
125
|
+
const cacheOffIndex = calls.findIndex(call => call[0] === 'cdp' && call[1] === 'Network.setCacheDisabled' && call[2].cacheDisabled === false);
|
|
126
|
+
assert.ok(cacheOnIndex < reloadIndex);
|
|
127
|
+
assert.ok(reloadIndex < cacheOffIndex);
|
|
118
128
|
recorder.handleUserActivity(recorder.activeSession, {
|
|
119
129
|
type: 'click',
|
|
120
130
|
title: '首页功能'
|
|
@@ -133,7 +143,7 @@ test('page controls start recording, record activity, upload and clean up one se
|
|
|
133
143
|
);
|
|
134
144
|
assert.equal(stopped.metadata.title, 'bug3452');
|
|
135
145
|
assert.ok(calls.some(call => call[0] === 'trace-start' && call[1].sources === true));
|
|
136
|
-
assert.
|
|
146
|
+
assert.equal(calls.some(call => call[0] === 'trace-group' && call[1].includes('首页功能')), false);
|
|
137
147
|
assert.ok(calls.some(call => call[0] === 'post' && call[1] === '/api/trace-sessions'));
|
|
138
148
|
assert.ok(calls.some(call => call[0] === 'form-append' && call[1] === 'target_url'));
|
|
139
149
|
assert.ok(calls.some(call => call[0] === 'remove' && call[1] === '/tmp/trace-fixture'));
|
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
|
|
17
|
+
4. 告知用户浏览器已打开:默认是 PC 端;左上角录制控制栏默认收缩,悬停后展开,移出后自动收回;右上角单独提供半透明“截图”按钮。如需移动端测试,先展开左上角控制栏并点击“切换移动端”(iPhone 14 Pro Max,430×932,DPR 3),再点击“开始录制”;点击开始后服务会先启动 Trace,临时禁用 Chromium 缓存并刷新当前 SPA 页面,等待 DOM、CSS、字体和图片稳定后恢复缓存,随后才进入正式录制计时。刷新期间不要操作页面;页面稳定后按需操作,只有点击右上角“截图”才会保存当前渲染画面,普通点击、输入和滚动不会生成自定义 Action。截图时所有控制按钮会临时隐藏。同一 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
|
|