@autobest-ui/agent 1.0.28 → 1.0.29

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.
@@ -27,6 +27,93 @@ npm start
27
27
 
28
28
  配套 Skill 位于 `../../skills/common/trace-recorder/`,由 `autobest-agent-sync common` 安装,并作为 `skill://trace-recorder/SKILL.md` MCP 资源提供。Skill 的 `agents/openai.yaml` 声明了对 `trace-mcp-recorder` 的工具依赖。
29
29
 
30
+ ### Skill 如何使用
31
+
32
+ Skill 不是需要单独运行的服务。完成 MCP 配置并重启 Codex 后,在对话中使用 `$trace-recorder` 或直接描述录制意图,Codex 会按照 Skill 规则调用 `trace-mcp-recorder` 的 MCP 工具。
33
+
34
+ 启动示例:
35
+
36
+ ```text
37
+ $trace-recorder 启动 Playwright trace,访问 https://cpd.dev.autobestdevops.com,version=v2.1.0,title=首页功能02,operator=张三
38
+ ```
39
+
40
+ 也可以不写 Skill 名称:
41
+
42
+ ```text
43
+ 请打开 https://cpd.dev.autobestdevops.com 进行 Trace 录制,版本 v2.1.0,标题为首页功能02,操作人张三
44
+ ```
45
+
46
+ Codex 会调用 `start_trace_recording`,打开可见 Chromium。浏览器打开后:
47
+
48
+ 1. 默认使用 PC viewport `1400×740`;需要移动端时点击“切换移动端”。
49
+ 2. 点击“开始录制”,服务会启动 Trace、临时禁用缓存并刷新页面,等待页面资源稳定。
50
+ 3. 页面稳定后手动操作。普通点击、输入和滚动不会生成自定义步骤。
51
+ 4. 需要保留某个页面状态时,点击“画笔”标注,再点击“截图”;截图步骤显示为 `步骤N | 页面标题`。
52
+ 5. 录制完成后点击“停止录制”,或在 Codex 中发送停止命令。
53
+
54
+ 停止示例:
55
+
56
+ ```text
57
+ $trace-recorder 停止录制
58
+ ```
59
+
60
+ 首次停止会生成 trace 并询问是否上传:
61
+
62
+ ```text
63
+ $trace-recorder 上传录制内容
64
+ ```
65
+
66
+ 也可以一步完成:
67
+
68
+ ```text
69
+ $trace-recorder 停止录制并上传
70
+ ```
71
+
72
+ Skill 会把上传返回的回放链接以及版本、标题、操作人、浏览器版本整理返回。若用户只点击页面上的“停止录制”,服务会自动上传,随后在 Codex 中发送“获取回放链接”即可取回结果。
73
+
74
+ ### MCP 工具直接调用
75
+
76
+ 需要绕过自然语言 Skill 时,可直接调用两个 MCP 工具:
77
+
78
+ ```json
79
+ {
80
+ "name": "start_trace_recording",
81
+ "arguments": {
82
+ "url": "https://cpd.dev.autobestdevops.com",
83
+ "version": "v2.1.0",
84
+ "title": "首页功能02",
85
+ "operator": "张三"
86
+ }
87
+ }
88
+ ```
89
+
90
+ 停止并先询问上传:
91
+
92
+ ```json
93
+ {
94
+ "name": "stop_trace_recording",
95
+ "arguments": {}
96
+ }
97
+ ```
98
+
99
+ 确认上传:
100
+
101
+ ```json
102
+ {
103
+ "name": "stop_trace_recording",
104
+ "arguments": { "confirm": "upload" }
105
+ }
106
+ ```
107
+
108
+ 取消上传:
109
+
110
+ ```json
111
+ {
112
+ "name": "stop_trace_recording",
113
+ "arguments": { "confirm": "cancel" }
114
+ }
115
+ ```
116
+
30
117
  ## 环境变量
31
118
 
32
119
  | 变量 | 默认值 | 说明 |
@@ -34,16 +121,18 @@ npm start
34
121
  | `BACKEND_BASE_URL` | 无,必填 | 私有后端基础地址,必须为 HTTP(S) URL |
35
122
  | `MAX_RECORD_DURATION` | `1800000` | 最大录制时长,单位毫秒;超时后自动停止并上传 |
36
123
 
124
+ 页面初始导航、录制开始后的刷新以及路由恢复导航超时均为 1 小时;`MAX_RECORD_DURATION` 只控制录制时长,不控制页面导航。
125
+
37
126
  回放链接按 `${BACKEND_BASE_URL}/trace-viewer/?trace=${fileUrl}` 生成。服务地址、域名和端口均来自 `BACKEND_BASE_URL`。
38
127
 
39
128
  ## MCP 工具
40
129
 
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` 优先于参数。
130
+ - `start_trace_recording`:参数为必填 `url` 和可选 `version`、`title`、`operator`。该工具打开页面,默认 PC 端;点击左下角按钮可在 PC 与 iPhone 14 Pro Max(430×932、DPR 3、触摸、iPhone User-Agent)之间来回切换。切换可以发生在录制过程中,当前页面会立即调整为对应 viewport,后续截图按当前模式生成。点击“开始录制”后才真正启动 Trace。`url` 独立确定被测页面;`title` 是录制标题或功能描述,也可以直接填写 `bug3452` 这类 Bug 编号。系统不额外传递独立的 Bug 字段。MCP 认证上下文中的 `operator`、`name` 或 `preferred_username` 优先于参数。
42
131
  - `stop_trace_recording`:首次调用不传参数,只停止 trace 并询问是否上传;用户确认后传 `confirm=upload`,取消时传 `confirm=cancel`。上传成功后返回回放链接及版本、标题、操作人、浏览器版本。
43
132
 
44
133
  录制超时后服务自动停止并自动上传(无需人工确认),随后第一次人工调用 `stop_trace_recording` 会取回自动停止的结果;再调用则返回没有活动会话。
45
134
 
46
- Trace 在点击页面左下角“开始录制”后先刷新页面,再开始计时;点击“停止录制”会立即停止并自动上传,随后可通过 `stop_trace_recording` 取回回放链接。设备切换和录制控制按钮不会写入操作步骤;新打开的页面会继承当前设备模式。连续用户操作停止 800ms 后写入一个带自定义名称的 `tracing.group()`,组内使用 `waitForTimeout(1)` 推进官方 Viewer 播放;连续 2 分钟没有真实用户操作时自动停止并上传。停止录制时会补一个最终 Action。页面自身的轮播、动画和图片加载不会触发录制节点。官方 Viewer 的具体显示可能是 Group 标签或 Group 内嵌 Wait 节点,取决于 Viewer 版本。
135
+ Trace 在点击页面左下角“开始录制”后先临时禁用缓存并刷新页面,等待资源稳定后再开始计时;点击“停止录制”会停止并自动上传,随后可通过 `stop_trace_recording` 取回回放链接。设备切换和录制控制按钮不会写入操作步骤;新打开的页面会继承当前设备模式。普通点击、输入、滚动、动画和图片加载不会生成自定义步骤,只有点击“截图”才会创建一个 `tracing.group()`,组名为 `步骤N | 页面标题`,组内保存当前页面截图并使用 `waitForTimeout(1)` 推进官方 Viewer 播放。画笔使用 SVG 路径覆盖层,截图时会带上标注;退出画笔会清空已有标注。连续 2 分钟没有页面活动时自动停止并上传。官方 Viewer 的具体显示可能是 Group 标签或 Group 内嵌 screenshot/Wait 节点,取决于 Viewer 版本。
47
136
 
48
137
  ## 后端接口契约
49
138
 
@@ -13,6 +13,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
13
13
  import { z } from 'zod';
14
14
 
15
15
  const DEFAULT_MAX_RECORD_DURATION = 1_800_000;
16
+ const PAGE_NAVIGATION_TIMEOUT = 3_600_000;
16
17
  const USER_ACTIVITY_DEBOUNCE_DELAY = 800;
17
18
  const IDLE_STOP_DELAY = 120_000;
18
19
  const DESKTOP_VIEWPORT = { width: 1400, height: 740 };
@@ -313,10 +314,12 @@ function createRecorderPageScript() {
313
314
  toolbar.addEventListener('pointercancel', stopDragging);
314
315
  renderAnnotation();
315
316
 
316
- const render = (state, deviceMode = 'desktop') => {
317
+ let currentDeviceMode = 'desktop';
318
+ const render = (state, deviceMode) => {
317
319
  const recording = state === 'recording';
318
320
  const waiting = state === 'waiting_for_start';
319
321
  const preparing = state === 'starting';
322
+ currentDeviceMode = deviceMode ?? currentDeviceMode;
320
323
  annotationEnabled = recording;
321
324
  if (!annotationEnabled) penEnabled = false;
322
325
  renderAnnotation();
@@ -326,7 +329,7 @@ function createRecorderPageScript() {
326
329
  deviceButton.disabled = state === 'starting' || state === 'stopping' || state === 'uploading';
327
330
  startButton.textContent = preparing ? '准备页面' : recording ? '录制中' : '开始录制';
328
331
  stopButton.textContent = state === 'stopping' || state === 'uploading' ? '正在停止' : '停止录制';
329
- deviceButton.textContent = deviceMode === 'mobile' ? '切换 PC 端' : '切换移动端';
332
+ deviceButton.textContent = currentDeviceMode === 'mobile' ? '移动端已启用' : '切换移动端';
330
333
  };
331
334
  const control = async action => {
332
335
  startButton.disabled = true;
@@ -336,7 +339,7 @@ function createRecorderPageScript() {
336
339
  const result = await globalThis.__traceRecorderControl?.(action);
337
340
  render(result?.state, result?.deviceMode);
338
341
  } catch {
339
- render('waiting_for_start', 'desktop');
342
+ render('waiting_for_start');
340
343
  }
341
344
  };
342
345
  startButton.addEventListener('click', event => {
@@ -455,7 +458,7 @@ export class TraceRecorder {
455
458
  await context.addInitScript({ content: createRecorderPageScript() });
456
459
  }
457
460
  page = await context.newPage();
458
- await page.goto(targetUrl);
461
+ await page.goto(targetUrl, { timeout: PAGE_NAVIGATION_TIMEOUT });
459
462
 
460
463
  const metadata = {
461
464
  version: sessionInput.version,
@@ -661,7 +664,7 @@ export class TraceRecorder {
661
664
  cacheWasDisabled = true;
662
665
  }
663
666
 
664
- await session.page.reload({ waitUntil: 'domcontentloaded' });
667
+ await session.page.reload({ waitUntil: 'domcontentloaded', timeout: PAGE_NAVIGATION_TIMEOUT });
665
668
  // networkidle is intentionally best-effort: SPAs with polling/websockets
666
669
  // may never become idle, while the resource checks below remain bounded.
667
670
  if (typeof session.page.waitForLoadState === 'function') {
@@ -686,7 +689,12 @@ export class TraceRecorder {
686
689
  }
687
690
  }
688
691
  if (currentUrl && typeof session.page.url === 'function' && session.page.url() !== currentUrl) {
689
- await session.page.goto(currentUrl, { waitUntil: 'domcontentloaded' });
692
+ await session.page.goto(currentUrl, { waitUntil: 'domcontentloaded', timeout: PAGE_NAVIGATION_TIMEOUT });
693
+ }
694
+ // A reload can reset parts of Chromium's emulation state. Re-apply the
695
+ // selected device after all navigation and resource loading has finished.
696
+ if (session.deviceMode === 'mobile') {
697
+ await this.applyDeviceMode(session, session.page, 'mobile');
690
698
  }
691
699
  await session.page
692
700
  .evaluate(({ x, y }) => window.scrollTo(x, y), scrollPosition)
@@ -711,8 +719,17 @@ export class TraceRecorder {
711
719
 
712
720
  async applyDeviceMode(session, page, mode) {
713
721
  if (mode === 'desktop') {
722
+ await page.setViewportSize?.({ ...DESKTOP_VIEWPORT });
714
723
  if (typeof session.context.newCDPSession !== 'function') return;
715
724
  }
725
+ if (mode === 'mobile') {
726
+ // CDP changes the mobile UA/touch model; setViewportSize also changes
727
+ // Playwright's screenshot dimensions from the desktop context size.
728
+ await page.setViewportSize?.({
729
+ width: IPHONE_14_PRO_MAX_DEVICE.width,
730
+ height: IPHONE_14_PRO_MAX_DEVICE.height
731
+ });
732
+ }
716
733
  if (typeof session.context.newCDPSession !== 'function') {
717
734
  throw new Error('当前浏览器不支持移动端 CDP 模拟');
718
735
  }
@@ -763,7 +780,11 @@ export class TraceRecorder {
763
780
  if (action === 'toggle-device') return this.toggleDeviceMode(session);
764
781
  if (action === 'screenshot') {
765
782
  if (session.state !== 'recording') return { state: session.state, captured: false };
766
- return this.captureScreenshot(session).then(() => ({ state: session.state, captured: true }));
783
+ return this.captureScreenshot(session).then(() => ({
784
+ state: session.state,
785
+ deviceMode: session.deviceMode,
786
+ captured: true
787
+ }));
767
788
  }
768
789
  if (action === 'start') {
769
790
  if (session.state === 'recording') return { state: session.state };
@@ -790,6 +811,12 @@ export class TraceRecorder {
790
811
 
791
812
  async captureScreenshot(session) {
792
813
  if (!session.tracingStarted || session.state !== 'recording') return;
814
+ // Re-assert the live device mode immediately before capture. This is
815
+ // needed when the user switches devices after recording has started.
816
+ await this.applyDeviceMode(session, session.page, session.deviceMode);
817
+ session.metadata.viewport = session.deviceMode === 'mobile'
818
+ ? { width: IPHONE_14_PRO_MAX_DEVICE.width, height: IPHONE_14_PRO_MAX_DEVICE.height }
819
+ : { ...DESKTOP_VIEWPORT };
793
820
  session.stepIndex += 1;
794
821
  let title = '未命名页面';
795
822
  if (typeof session.page.title === 'function') {
@@ -21,8 +21,9 @@ function createFixture(options = {}) {
21
21
  groupEnd: async () => calls.push(['trace-group-end'])
22
22
  };
23
23
  const page = {
24
- goto: async url => calls.push(['goto', url]),
24
+ goto: async (url, options) => calls.push(['goto', url, options]),
25
25
  reload: async options => calls.push(['reload', options]),
26
+ setViewportSize: async viewport => calls.push(['viewport', viewport]),
26
27
  evaluate: async () => 'Fixture User Agent',
27
28
  waitForTimeout: async duration => calls.push(['wait', duration]),
28
29
  screenshot: async options => calls.push(['screenshot', options]),
@@ -42,7 +43,10 @@ function createFixture(options = {}) {
42
43
  newPage: async () => page,
43
44
  close: async () => calls.push(['context-close'])
44
45
  };
45
- browser.newContext = async () => context;
46
+ browser.newContext = async options => {
47
+ calls.push(['new-context', options]);
48
+ return context;
49
+ };
46
50
  browser.version = () => 'Fixture Chromium 1';
47
51
  browser.close = async () => calls.push(['browser-close']);
48
52
 
@@ -106,6 +110,9 @@ test('page controls start recording, record activity, upload and clean up one se
106
110
  assert.equal(started.uuid, undefined);
107
111
  assert.equal(started.metadata.operator, '上下文操作人');
108
112
  assert.equal(started.metadata.browserVersion, 'Fixture Chromium 1');
113
+ assert.equal(calls.find(call => call[0] === 'goto')[1], 'https://app.example.test/login');
114
+ assert.equal(calls.find(call => call[0] === 'goto')[2].timeout, 3_600_000);
115
+ assert.deepEqual(calls.find(call => call[0] === 'new-context')[1].viewport, { width: 1400, height: 740 });
109
116
  assert.equal(calls.some(call => call[0] === 'trace-start'), false);
110
117
  assert.match(calls.find(call => call[0] === 'init-script')[1].content, /开始录制/);
111
118
  assert.match(calls.find(call => call[0] === 'init-script')[1].content, /data-trace-recorder-annotation/);
@@ -118,7 +125,7 @@ test('page controls start recording, record activity, upload and clean up one se
118
125
  await recorder.activeSession.activityPromise;
119
126
  assert.equal(recorder.activeSession.state, 'recording');
120
127
  const screenshotResult = await bindings.__traceRecorderControl({}, 'screenshot');
121
- assert.deepEqual(screenshotResult, { state: 'recording', captured: true });
128
+ assert.deepEqual(screenshotResult, { state: 'recording', deviceMode: 'desktop', captured: true });
122
129
  assert.ok(calls.some(call => call[0] === 'screenshot' && call[1].fullPage === false));
123
130
  assert.ok(calls.some(call => call[0] === 'trace-group' && call[1] === '步骤1 | 未命名页面'));
124
131
  assert.ok(calls.some(call => call[0] === 'reload' && call[1].waitUntil === 'domcontentloaded'));
@@ -219,7 +226,7 @@ test('page stop control immediately stops and uploads, then exposes the result t
219
226
  assert.match(result.replayLink, /trace-viewer/);
220
227
  });
221
228
 
222
- test('toggles desktop and iPhone 14 Pro Max emulation in the same session', async () => {
229
+ test('switches between PC and iPhone 14 Pro Max during one session', async () => {
223
230
  const { bindings, calls, recorder } = createFixture();
224
231
  await recorder.start({ url: 'https://app.example.test' });
225
232
 
@@ -230,6 +237,7 @@ test('toggles desktop and iPhone 14 Pro Max emulation in the same session', asyn
230
237
  deviceName: 'iPhone 14 Pro Max'
231
238
  });
232
239
  assert.ok(calls.some(call => call[0] === 'cdp' && call[1] === 'Emulation.setDeviceMetricsOverride'));
240
+ assert.ok(calls.some(call => call[0] === 'viewport' && call[1].width === 430 && call[1].height === 932));
233
241
  assert.deepEqual(recorder.activeSession.metadata.viewport, { width: 430, height: 932 });
234
242
  assert.match(recorder.activeSession.metadata.userAgent, /iPhone/);
235
243
 
@@ -237,6 +245,7 @@ test('toggles desktop and iPhone 14 Pro Max emulation in the same session', asyn
237
245
  assert.equal(desktop.deviceMode, 'desktop');
238
246
  assert.ok(calls.some(call => call[0] === 'cdp' && call[1] === 'Emulation.clearDeviceMetricsOverride'));
239
247
  assert.equal(recorder.activeSession.metadata.userAgent, 'Fixture User Agent');
248
+ assert.deepEqual(recorder.activeSession.metadata.viewport, { width: 1400, height: 740 });
240
249
 
241
250
  await recorder.stop();
242
251
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobest-ui/agent",
3
- "version": "1.0.28",
3
+ "version": "1.0.29",
4
4
  "private": false,
5
5
  "description": "Autobest Agent skills/plugins/mcp assets + sync cli",
6
6
  "files": [
@@ -14,10 +14,40 @@ 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 端(viewport 1400×740);左下角显示可拖拽的 2×2 半透明控制栏,第一行是“开始录制”“停止录制”,第二行是“切换移动端”“截图”,下方提供“画笔”“撤销”“清空”。画笔使用 SVG 路径覆盖层,开启后可用鼠标或触摸标注页面;点击“退出画笔”会立即关闭并清空已有标注,避免后续页面操作继续带着旧标注,截图会带上标注。先点击“切换移动端”(iPhone 14 Pro Max,430×932,DPR 3)或保持 PC 端,再点击“开始录制”;点击开始后服务会先启动 Trace,临时禁用 Chromium 缓存并刷新当前 SPA 页面,等待 DOM、CSS、字体和图片稳定后恢复缓存,随后才进入正式录制计时。刷新期间不要操作页面;页面稳定后按需操作,只有点击“截图”才会保存当前渲染画面,普通点击、输入和滚动不会生成自定义 Action。截图时所有控制按钮会临时隐藏。同一 BrowserContext 会保留 cookies、localStorage 和 sessionStorage,新打开的页面会继承当前设备模式。连续 2 分钟没有页面活动时服务会自动停止并上传。人工截图在官方 Trace Viewer 中显示为 `步骤N | 页面标题`。
17
+ 4. 告知用户浏览器已打开:默认是 PC 端(viewport 1400×740);左下角显示可拖拽的 2×2 半透明控制栏,第一行是“开始录制”“停止录制”,第二行是“切换移动端”“截图”,下方提供“画笔”“撤销”“清空”。画笔使用 SVG 路径覆盖层,开启后可用鼠标或触摸标注页面;点击“退出画笔”会立即关闭并清空已有标注,避免后续页面操作继续带着旧标注,截图会带上标注。“切换移动端 / 切换 PC 端”可以在录制过程中来回切换:移动端为 iPhone 14 Pro Max(430×932,DPR 3),PC 端为 1400×740;切换后当前页面立即调整 viewport,截图按当前模式生成。再点击“开始录制”;点击开始后服务会先启动 Trace,临时禁用 Chromium 缓存并刷新当前 SPA 页面,等待 DOM、CSS、字体和图片稳定后恢复缓存,随后才进入正式录制计时。页面导航和录制开始后的刷新最长等待 1 小时。刷新期间不要操作页面;页面稳定后按需操作,只有点击“截图”才会保存当前渲染画面,普通点击、输入和滚动不会生成自定义 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
 
21
+ ## 使用示例
22
+
23
+ 用户可以显式调用 Skill:
24
+
25
+ ```text
26
+ $trace-recorder 启动 Playwright trace,访问 https://cpd.dev.autobestdevops.com,version=v2.1.0,title=首页功能02,operator=张三
27
+ ```
28
+
29
+ 也可以直接表达录制意图,Skill 会自动匹配:
30
+
31
+ ```text
32
+ 请打开 https://cpd.dev.autobestdevops.com 录制首页功能,版本 v2.1.0,操作人张三
33
+ ```
34
+
35
+ 浏览器打开后,指导用户按以下顺序操作:
36
+
37
+ 1. 默认使用 PC `1400×740`;需要移动端时点击“切换移动端”。
38
+ 2. 点击左下角“开始录制”,等待页面刷新和资源稳定完成。
39
+ 3. 普通页面操作不会生成自定义步骤;需要记录页面状态时点击“画笔”标注,再点击“截图”。
40
+ 4. 完成后点击“停止录制”,或发送 `$trace-recorder 停止录制`。
41
+
42
+ 上传确认示例:
43
+
44
+ ```text
45
+ $trace-recorder 停止录制
46
+ $trace-recorder 上传录制内容
47
+ ```
48
+
49
+ 用户明确说“停止录制并上传”时,直接调用 `stop_trace_recording` 并传入 `confirm=upload`。用户说“不上传”时传入 `confirm=cancel`。返回结果以工具本次响应为准,成功时整理回放链接、版本、标题、操作人和浏览器版本。
50
+
21
51
  ## 停止录制
22
52
 
23
53
  页面左下角“停止录制”会立即停止并自动上传。用户随后要求获取回放链接时,调用 `stop_trace_recording` 读取这次按钮停止的结果,并整理回放链接和元数据。