@autobest-ui/agent 1.0.21 → 1.0.23
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、刷新一次当前 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;
|
|
@@ -170,32 +176,46 @@ function createRecorderPageScript() {
|
|
|
170
176
|
button:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
|
|
171
177
|
button:disabled { cursor: default; opacity: .42; }
|
|
172
178
|
.start { background: rgba(22, 163, 74, .82); }
|
|
179
|
+
.screenshot { background: rgba(234, 88, 12, .82); }
|
|
173
180
|
.stop { background: rgba(220, 38, 38, .82); }
|
|
174
181
|
.device { background: rgba(37, 99, 235, .82); min-width: 102px; }
|
|
182
|
+
.toggle {
|
|
183
|
+
flex: 0 0 24px;
|
|
184
|
+
min-width: 24px;
|
|
185
|
+
padding: 0;
|
|
186
|
+
background: rgba(255, 255, 255, .16);
|
|
187
|
+
font-size: 20px;
|
|
188
|
+
}
|
|
175
189
|
</style>
|
|
176
190
|
<div class="toolbar" role="group" aria-label="Trace 录制控制">
|
|
191
|
+
<button class="toggle" type="button" aria-label="展开录制控制" data-trace-recorder-control>›</button>
|
|
177
192
|
<button class="start" type="button" data-action="start" data-trace-recorder-control>开始录制</button>
|
|
193
|
+
<button class="screenshot" type="button" data-action="screenshot" data-trace-recorder-control>截图</button>
|
|
178
194
|
<button class="stop" type="button" data-action="stop" data-trace-recorder-control>停止录制</button>
|
|
179
195
|
<button class="device" type="button" data-action="toggle-device" data-trace-recorder-control>切换移动端</button>
|
|
180
196
|
</div>
|
|
181
197
|
\`;
|
|
182
198
|
document.documentElement.append(host);
|
|
183
199
|
const startButton = shadow.querySelector('[data-action="start"]');
|
|
200
|
+
const screenshotButton = shadow.querySelector('[data-action="screenshot"]');
|
|
184
201
|
const stopButton = shadow.querySelector('[data-action="stop"]');
|
|
185
202
|
const deviceButton = shadow.querySelector('[data-action="toggle-device"]');
|
|
186
203
|
|
|
187
204
|
const render = (state, deviceMode = 'desktop') => {
|
|
188
205
|
const recording = state === 'recording';
|
|
189
206
|
const waiting = state === 'waiting_for_start';
|
|
207
|
+
const preparing = state === 'starting';
|
|
190
208
|
startButton.disabled = !waiting;
|
|
209
|
+
screenshotButton.disabled = !recording;
|
|
191
210
|
stopButton.disabled = !recording && !waiting;
|
|
192
211
|
deviceButton.disabled = state === 'starting' || state === 'stopping' || state === 'uploading';
|
|
193
|
-
startButton.textContent = recording ? '录制中' : '开始录制';
|
|
212
|
+
startButton.textContent = preparing ? '准备页面' : recording ? '录制中' : '开始录制';
|
|
194
213
|
stopButton.textContent = state === 'stopping' || state === 'uploading' ? '正在停止' : '停止录制';
|
|
195
214
|
deviceButton.textContent = deviceMode === 'mobile' ? '切换 PC 端' : '切换移动端';
|
|
196
215
|
};
|
|
197
216
|
const control = async action => {
|
|
198
217
|
startButton.disabled = true;
|
|
218
|
+
screenshotButton.disabled = true;
|
|
199
219
|
stopButton.disabled = true;
|
|
200
220
|
try {
|
|
201
221
|
const result = await globalThis.__traceRecorderControl?.(action);
|
|
@@ -209,6 +229,11 @@ function createRecorderPageScript() {
|
|
|
209
229
|
event.stopPropagation();
|
|
210
230
|
void control('start');
|
|
211
231
|
});
|
|
232
|
+
screenshotButton.addEventListener('click', event => {
|
|
233
|
+
event.preventDefault();
|
|
234
|
+
event.stopPropagation();
|
|
235
|
+
void control('screenshot');
|
|
236
|
+
});
|
|
212
237
|
stopButton.addEventListener('click', event => {
|
|
213
238
|
event.preventDefault();
|
|
214
239
|
event.stopPropagation();
|
|
@@ -409,7 +434,13 @@ export class TraceRecorder {
|
|
|
409
434
|
throw new Error('当前没有正在录制的 trace 会话');
|
|
410
435
|
}
|
|
411
436
|
if (session.state === 'starting') {
|
|
412
|
-
|
|
437
|
+
await session.activityPromise?.catch(error => {
|
|
438
|
+
throw error;
|
|
439
|
+
});
|
|
440
|
+
if (session.state === 'failed') {
|
|
441
|
+
throw new Error('录制初始化失败,无法停止当前 trace');
|
|
442
|
+
}
|
|
443
|
+
return this.stop({ trigger, confirm });
|
|
413
444
|
}
|
|
414
445
|
if (session.state === 'waiting_for_start') {
|
|
415
446
|
session.state = 'stopping';
|
|
@@ -489,8 +520,49 @@ export class TraceRecorder {
|
|
|
489
520
|
|
|
490
521
|
async beginRecording(session) {
|
|
491
522
|
if (session.tracingStarted || session.state !== 'waiting_for_start') return;
|
|
523
|
+
// The initial page was opened before the user clicked the button. Start the
|
|
524
|
+
// trace first, then reload once so SPA CSS, fonts, images and DOM resources
|
|
525
|
+
// are captured by Trace Viewer instead of being only browser-cache state.
|
|
526
|
+
session.state = 'starting';
|
|
492
527
|
await session.context.tracing.start({ screenshots: true, snapshots: true, sources: true });
|
|
493
528
|
session.tracingStarted = true;
|
|
529
|
+
|
|
530
|
+
const currentUrl = typeof session.page.url === 'function' ? session.page.url() : session.metadata.targetUrl;
|
|
531
|
+
const scrollPosition = await session.page
|
|
532
|
+
.evaluate(() => ({ x: window.scrollX, y: window.scrollY }))
|
|
533
|
+
.catch(() => ({ x: 0, y: 0 }));
|
|
534
|
+
|
|
535
|
+
await session.page.reload({ waitUntil: 'domcontentloaded' });
|
|
536
|
+
// networkidle is intentionally best-effort: SPAs with polling/websockets
|
|
537
|
+
// may never become idle, while the resource checks below remain bounded.
|
|
538
|
+
if (typeof session.page.waitForLoadState === 'function') {
|
|
539
|
+
await session.page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => {});
|
|
540
|
+
}
|
|
541
|
+
await session.page
|
|
542
|
+
.evaluate(async () => {
|
|
543
|
+
await document.fonts?.ready?.catch?.(() => {});
|
|
544
|
+
const pendingImages = Array.from(document.images).filter(image => !image.complete);
|
|
545
|
+
await Promise.race([
|
|
546
|
+
Promise.all(pendingImages.map(image => new Promise(resolve => {
|
|
547
|
+
image.addEventListener('load', resolve, { once: true });
|
|
548
|
+
image.addEventListener('error', resolve, { once: true });
|
|
549
|
+
}))),
|
|
550
|
+
new Promise(resolve => setTimeout(resolve, 10_000))
|
|
551
|
+
]);
|
|
552
|
+
})
|
|
553
|
+
.catch(() => {});
|
|
554
|
+
if (currentUrl && typeof session.page.url === 'function' && session.page.url() !== currentUrl) {
|
|
555
|
+
await session.page.goto(currentUrl, { waitUntil: 'domcontentloaded' });
|
|
556
|
+
}
|
|
557
|
+
await session.page
|
|
558
|
+
.evaluate(({ x, y }) => window.scrollTo(x, y), scrollPosition)
|
|
559
|
+
.catch(() => {});
|
|
560
|
+
|
|
561
|
+
session.metadata.targetUrl = currentUrl || session.metadata.targetUrl;
|
|
562
|
+
session.metadata.userAgent = await session.page
|
|
563
|
+
.evaluate(() => navigator.userAgent)
|
|
564
|
+
.catch(() => session.metadata.userAgent);
|
|
565
|
+
session.metadata.viewport = session.page.viewportSize?.() ?? session.metadata.viewport;
|
|
494
566
|
session.state = 'recording';
|
|
495
567
|
session.lastActivityAt = Date.now();
|
|
496
568
|
session.timer = this.setTimer(() => {
|
|
@@ -555,13 +627,19 @@ export class TraceRecorder {
|
|
|
555
627
|
handlePageControl(session, action) {
|
|
556
628
|
if (action === 'status') return { state: session.state, deviceMode: session.deviceMode };
|
|
557
629
|
if (action === 'toggle-device') return this.toggleDeviceMode(session);
|
|
630
|
+
if (action === 'screenshot') {
|
|
631
|
+
if (session.state !== 'recording') return { state: session.state, captured: false };
|
|
632
|
+
return this.captureScreenshot(session).then(() => ({ state: session.state, captured: true }));
|
|
633
|
+
}
|
|
558
634
|
if (action === 'start') {
|
|
559
635
|
if (session.state === 'recording') return { state: session.state };
|
|
560
636
|
if (session.state !== 'waiting_for_start') return { state: session.state };
|
|
561
637
|
if (!session.activityPromise) {
|
|
562
638
|
session.activityPromise = this.beginRecording(session).catch(error => this.failSession(session, error));
|
|
563
639
|
}
|
|
564
|
-
|
|
640
|
+
// Do not hold the page binding open while reload navigates the document.
|
|
641
|
+
// The toolbar polls status and will switch to recording after the reload.
|
|
642
|
+
return { state: 'starting', deviceMode: session.deviceMode };
|
|
565
643
|
}
|
|
566
644
|
if (action === 'stop') {
|
|
567
645
|
if (session.state !== 'recording' && session.state !== 'waiting_for_start') {
|
|
@@ -576,24 +654,52 @@ export class TraceRecorder {
|
|
|
576
654
|
return { state: session.state };
|
|
577
655
|
}
|
|
578
656
|
|
|
657
|
+
async captureScreenshot(session) {
|
|
658
|
+
if (!session.tracingStarted || session.state !== 'recording') return;
|
|
659
|
+
session.stepIndex += 1;
|
|
660
|
+
let title = '未命名页面';
|
|
661
|
+
if (typeof session.page.title === 'function') {
|
|
662
|
+
title = await session.page.title().catch(() => title) || title;
|
|
663
|
+
}
|
|
664
|
+
const label = `步骤${session.stepIndex} | ${title}`;
|
|
665
|
+
await session.context.tracing.group(label);
|
|
666
|
+
let toolbarHidden = false;
|
|
667
|
+
try {
|
|
668
|
+
// Keep the recorder controls out of the user-selected page evidence.
|
|
669
|
+
await session.page.evaluate(() => {
|
|
670
|
+
const toolbar = document.querySelector('[data-trace-recorder-toolbar-host]');
|
|
671
|
+
if (toolbar) {
|
|
672
|
+
toolbar.setAttribute('data-trace-recorder-hidden', '');
|
|
673
|
+
toolbar.style.visibility = 'hidden';
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
toolbarHidden = true;
|
|
677
|
+
// page.screenshot is intentionally used inside the Trace group so the
|
|
678
|
+
// official Viewer records this deliberate visual checkpoint.
|
|
679
|
+
await session.page.screenshot({ fullPage: false });
|
|
680
|
+
await session.page.waitForTimeout(1);
|
|
681
|
+
session.lastActivityAt = Date.now();
|
|
682
|
+
this.armIdleTimer(session);
|
|
683
|
+
} finally {
|
|
684
|
+
if (toolbarHidden) {
|
|
685
|
+
await session.page.evaluate(() => {
|
|
686
|
+
const toolbar = document.querySelector('[data-trace-recorder-toolbar-host]');
|
|
687
|
+
if (toolbar) {
|
|
688
|
+
toolbar.style.visibility = '';
|
|
689
|
+
toolbar.removeAttribute('data-trace-recorder-hidden');
|
|
690
|
+
}
|
|
691
|
+
}).catch(() => {});
|
|
692
|
+
}
|
|
693
|
+
await session.context.tracing.groupEnd();
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
579
697
|
handleUserActivity(session, activity = {}) {
|
|
580
698
|
if (session.state !== 'recording') return undefined;
|
|
699
|
+
// Page activity is intentionally not recorded as an Action. The operator
|
|
700
|
+
// explicitly controls collection by pressing the Screenshot button.
|
|
581
701
|
session.lastActivityAt = Date.now();
|
|
582
|
-
session.pendingActivity = activity;
|
|
583
702
|
this.armIdleTimer(session);
|
|
584
|
-
if (session.activityDebounceTimer) this.clearTimer(session.activityDebounceTimer);
|
|
585
|
-
session.activityDebounceTimer = this.setTimer(() => {
|
|
586
|
-
session.activityDebounceTimer = undefined;
|
|
587
|
-
const pendingActivity = session.pendingActivity;
|
|
588
|
-
session.pendingActivity = undefined;
|
|
589
|
-
session.activityRecordPromise = (session.activityRecordPromise ?? Promise.resolve())
|
|
590
|
-
.then(() => this.recordActivityStep(session, pendingActivity))
|
|
591
|
-
.catch(() => {})
|
|
592
|
-
.finally(() => {
|
|
593
|
-
session.activityRecordPromise = undefined;
|
|
594
|
-
});
|
|
595
|
-
}, USER_ACTIVITY_DEBOUNCE_DELAY);
|
|
596
|
-
session.activityDebounceTimer?.unref?.();
|
|
597
703
|
return undefined;
|
|
598
704
|
}
|
|
599
705
|
|
|
@@ -602,7 +708,7 @@ export class TraceRecorder {
|
|
|
602
708
|
session.stepIndex += 1;
|
|
603
709
|
const type = typeof activity.type === 'string' && activity.type ? activity.type : 'unknown';
|
|
604
710
|
const title = typeof activity.title === 'string' && activity.title ? activity.title : '未命名页面';
|
|
605
|
-
const label = `步骤${session.stepIndex} | ${title}
|
|
711
|
+
const label = `步骤${session.stepIndex} | ${title}`;
|
|
606
712
|
await session.context.tracing.group(label);
|
|
607
713
|
try {
|
|
608
714
|
await session.page.waitForTimeout(1);
|
|
@@ -617,7 +723,7 @@ export class TraceRecorder {
|
|
|
617
723
|
if (typeof session.page.title === 'function') {
|
|
618
724
|
title = await session.page.title().catch(() => title) || title;
|
|
619
725
|
}
|
|
620
|
-
const label = `步骤${session.stepIndex} | ${title}
|
|
726
|
+
const label = `步骤${session.stepIndex} | ${title}`;
|
|
621
727
|
await session.context.tracing.group(label);
|
|
622
728
|
try {
|
|
623
729
|
await session.page.waitForTimeout(1);
|
|
@@ -711,7 +817,7 @@ export class TraceRecorder {
|
|
|
711
817
|
}
|
|
712
818
|
|
|
713
819
|
async failSession(session, error) {
|
|
714
|
-
if (
|
|
820
|
+
if (!['starting', 'recording', 'waiting_for_start'].includes(session.state)) return;
|
|
715
821
|
session.state = 'failed';
|
|
716
822
|
this.clearTimer(session.timer);
|
|
717
823
|
this.clearTimer(session.idleTimer);
|
|
@@ -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
|
};
|
|
@@ -108,10 +109,17 @@ test('page controls start recording, record activity, upload and clean up one se
|
|
|
108
109
|
assert.equal(calls.some(call => call[0] === 'trace-start'), false);
|
|
109
110
|
assert.match(calls.find(call => call[0] === 'init-script')[1].content, /开始录制/);
|
|
110
111
|
assert.deepEqual(await bindings.__traceRecorderControl({}, 'start'), {
|
|
111
|
-
state: '
|
|
112
|
+
state: 'starting',
|
|
112
113
|
deviceMode: 'desktop'
|
|
113
114
|
});
|
|
114
|
-
|
|
115
|
+
await recorder.activeSession.activityPromise;
|
|
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 | 未命名页面'));
|
|
121
|
+
assert.ok(calls.some(call => call[0] === 'reload' && call[1].waitUntil === 'domcontentloaded'));
|
|
122
|
+
assert.ok(calls.findIndex(call => call[0] === 'trace-start') < calls.findIndex(call => call[0] === 'reload'));
|
|
115
123
|
recorder.handleUserActivity(recorder.activeSession, {
|
|
116
124
|
type: 'click',
|
|
117
125
|
title: '首页功能'
|
|
@@ -130,7 +138,7 @@ test('page controls start recording, record activity, upload and clean up one se
|
|
|
130
138
|
);
|
|
131
139
|
assert.equal(stopped.metadata.title, 'bug3452');
|
|
132
140
|
assert.ok(calls.some(call => call[0] === 'trace-start' && call[1].sources === true));
|
|
133
|
-
assert.
|
|
141
|
+
assert.equal(calls.some(call => call[0] === 'trace-group' && call[1].includes('首页功能')), false);
|
|
134
142
|
assert.ok(calls.some(call => call[0] === 'post' && call[1] === '/api/trace-sessions'));
|
|
135
143
|
assert.ok(calls.some(call => call[0] === 'form-append' && call[1] === 'target_url'));
|
|
136
144
|
assert.ok(calls.some(call => call[0] === 'remove' && call[1] === '/tmp/trace-fixture'));
|
|
@@ -168,6 +176,7 @@ test('marks the session failed and removes the local trace when upload fails', a
|
|
|
168
176
|
const { calls, recorder } = createFixture({ uploadError: new Error('upload failed') });
|
|
169
177
|
await recorder.start({ url: 'https://app.example.test' });
|
|
170
178
|
await recorder.handlePageControl(recorder.activeSession, 'start');
|
|
179
|
+
await recorder.activeSession.activityPromise;
|
|
171
180
|
recorder.handleUserActivity(recorder.activeSession);
|
|
172
181
|
|
|
173
182
|
await recorder.stop();
|
|
@@ -179,6 +188,7 @@ test('automatically stops on timeout and returns that result on the next stop ca
|
|
|
179
188
|
const { recorder, timer } = createFixture();
|
|
180
189
|
await recorder.start({ url: 'https://app.example.test' });
|
|
181
190
|
await recorder.handlePageControl(recorder.activeSession, 'start');
|
|
191
|
+
await recorder.activeSession.activityPromise;
|
|
182
192
|
timer.callback();
|
|
183
193
|
|
|
184
194
|
while (recorder.activeSession) await new Promise(resolve => setImmediate(resolve));
|
|
@@ -191,6 +201,7 @@ test('page stop control immediately stops and uploads, then exposes the result t
|
|
|
191
201
|
const { bindings, recorder } = createFixture();
|
|
192
202
|
await recorder.start({ url: 'https://app.example.test' });
|
|
193
203
|
await bindings.__traceRecorderControl({}, 'start');
|
|
204
|
+
await recorder.activeSession.activityPromise;
|
|
194
205
|
|
|
195
206
|
assert.deepEqual(await bindings.__traceRecorderControl({}, 'stop'), { state: 'stopping' });
|
|
196
207
|
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
|
|
17
|
+
4. 告知用户浏览器已打开:默认是 PC 端;左上角控制栏默认收缩,悬停后展开,移出后自动收回。如需移动端测试,先展开并点击“切换移动端”(iPhone 14 Pro Max,430×932,DPR 3),再点击“开始录制”;点击开始后服务会先启动 Trace、刷新当前 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
|
|