@autobest-ui/agent 1.0.15 → 1.0.16

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;后续用户活动经过防抖后写入轻量 Playwright Action,空闲超过 2 分钟自动结束,然后上传原生 `trace.zip` 和浏览器元数据,并返回私有后端的 Trace Viewer 链接。同一进程同一时间只允许一个录制任务。
3
+ `trace-mcp-recorder` 是基于 `@modelcontextprotocol/sdk` 和 `StdioServerTransport` 的 Node.js MCP Server。它打开非无头 Chromium 供测试人员手动操作,页面加载后等待第一次真实用户操作才启动 Trace;后续用户活动经过防抖后以 `步骤N | 页面标题 | 事件类型` 的 Trace Group 写入轻量 Playwright Action,空闲超过 2 分钟自动结束,然后上传原生 `trace.zip` 和浏览器元数据,并返回私有后端的 Trace Viewer 链接。同一进程同一时间只允许一个录制任务。
4
4
 
5
5
  ## 安装与启动
6
6
 
@@ -43,7 +43,7 @@ npm start
43
43
 
44
44
  录制超时后服务自动停止并自动上传(无需人工确认),随后第一次人工调用 `stop_trace_recording` 会取回自动停止的结果;再调用则返回没有活动会话。
45
45
 
46
- Trace 在第一次真实用户操作后才开始计时。连续用户操作停止 800ms 后写入一个轻量 `waitForTimeout(1)` Trace Action;连续 2 分钟没有真实用户操作时自动停止并上传。停止录制时会补一个最终 Action。页面自身的轮播、动画和图片加载不会触发录制节点。
46
+ Trace 在第一次真实用户操作后才开始计时。连续用户操作停止 800ms 后写入一个带自定义名称的 `tracing.group()`,组内使用 `waitForTimeout(1)` 推进官方 Viewer 播放;连续 2 分钟没有真实用户操作时自动停止并上传。停止录制时会补一个最终 Action。页面自身的轮播、动画和图片加载不会触发录制节点。官方 Viewer 的具体显示可能是 Group 标签或 Group 内嵌 Wait 节点,取决于 Viewer 版本。
47
47
 
48
48
  ## 后端接口契约
49
49
 
@@ -105,11 +105,14 @@ function toolError(error) {
105
105
  // details; it uses the signal to control Trace timing and debounce checkpoints.
106
106
  function createUserActivityScript() {
107
107
  return `(() => {
108
- const notify = () => Promise.resolve(globalThis.__traceRecorderUserActivity?.()).catch(() => {});
108
+ const notify = type => Promise.resolve(globalThis.__traceRecorderUserActivity?.({
109
+ type,
110
+ title: document.title || '未命名页面'
111
+ })).catch(() => {});
109
112
  const options = { capture: true, passive: true };
110
- for (const type of ['pointerdown', 'keydown', 'input', 'change', 'wheel', 'touchstart']) {
113
+ for (const type of ['click', 'keydown', 'input', 'change', 'wheel', 'touchstart']) {
111
114
  document.addEventListener(type, event => {
112
- if (event.isTrusted) notify();
115
+ if (event.isTrusted) notify(type);
113
116
  }, options);
114
117
  }
115
118
  })();`;
@@ -178,8 +181,8 @@ export class TraceRecorder {
178
181
  context = await browser.newContext();
179
182
  let session;
180
183
  if (typeof context.exposeBinding === 'function') {
181
- await context.exposeBinding('__traceRecorderUserActivity', () => {
182
- if (session) return this.handleUserActivity(session);
184
+ await context.exposeBinding('__traceRecorderUserActivity', (_source, activity) => {
185
+ if (session) return this.handleUserActivity(session, activity);
183
186
  return undefined;
184
187
  });
185
188
  }
@@ -217,6 +220,9 @@ export class TraceRecorder {
217
220
  idleTimer: undefined,
218
221
  activityDebounceTimer: undefined,
219
222
  activityPromise: undefined,
223
+ activityRecordPromise: undefined,
224
+ pendingActivity: undefined,
225
+ stepIndex: 0,
220
226
  tracingStarted: false,
221
227
  lastActivityAt: undefined
222
228
  };
@@ -265,9 +271,26 @@ export class TraceRecorder {
265
271
  throw new Error('录制会话正在启动,请等待 start_trace_recording 返回后再停止');
266
272
  }
267
273
  if (session.state === 'waiting_for_activity') {
274
+ session.state = 'stopping';
275
+ this.clearTimer(session.timer);
276
+ this.clearTimer(session.idleTimer);
277
+ this.clearTimer(session.activityDebounceTimer);
278
+ await session.activityPromise?.catch(() => {});
279
+ if (session.tracingStarted) {
280
+ session.stopPromise = this.prepareSession(session).finally(() => {
281
+ session.stopPromise = undefined;
282
+ });
283
+ const prepared = await session.stopPromise;
284
+ if (trigger === 'timeout' || confirm === 'upload') return this.uploadSession(session, trigger);
285
+ return prepared;
286
+ }
268
287
  await this.closeResources({ page: session.page, context: session.context, browser: session.browser });
269
288
  if (this.activeSession === session) this.activeSession = undefined;
270
- throw new Error('尚未检测到真实用户操作,未生成 trace');
289
+ return {
290
+ state: 'not_started',
291
+ uploaded: false,
292
+ message: '录制已停止。停止前未检测到真实用户操作,因此没有生成可上传的 trace。'
293
+ };
271
294
  }
272
295
  if (session.state === 'recording') {
273
296
  if (session.stopPromise) return session.stopPromise;
@@ -299,7 +322,8 @@ export class TraceRecorder {
299
322
  async prepareSession(session) {
300
323
  try {
301
324
  await session.activityPromise?.catch(() => {});
302
- if (session.tracingStarted) await session.page.waitForTimeout(1).catch(() => {});
325
+ await session.activityRecordPromise?.catch(() => {});
326
+ if (session.tracingStarted) await this.recordFinalStep(session);
303
327
  await session.context.tracing.stop({ path: session.tracePath });
304
328
  await this.closeResources({ page: session.page, context: session.context, browser: session.browser });
305
329
  session.state = 'awaiting_upload';
@@ -315,7 +339,7 @@ export class TraceRecorder {
315
339
  }
316
340
  }
317
341
 
318
- async beginRecording(session) {
342
+ async beginRecording(session, activity = {}) {
319
343
  if (session.tracingStarted || session.state !== 'waiting_for_activity') return;
320
344
  await session.context.tracing.start({ screenshots: true, snapshots: true, sources: true });
321
345
  session.tracingStarted = true;
@@ -329,13 +353,13 @@ export class TraceRecorder {
329
353
  }, session.maxRecordDuration);
330
354
  session.timer?.unref?.();
331
355
  this.armIdleTimer(session);
332
- await session.page.waitForTimeout(1);
356
+ await this.recordActivityStep(session, activity);
333
357
  }
334
358
 
335
- handleUserActivity(session) {
359
+ handleUserActivity(session, activity = {}) {
336
360
  if (session.state === 'waiting_for_activity') {
337
361
  if (session.activityPromise) return session.activityPromise;
338
- session.activityPromise = this.beginRecording(session).catch(error => {
362
+ session.activityPromise = this.beginRecording(session, activity).catch(error => {
339
363
  session.state = 'failed';
340
364
  throw error;
341
365
  });
@@ -343,16 +367,53 @@ export class TraceRecorder {
343
367
  }
344
368
  if (session.state !== 'recording') return undefined;
345
369
  session.lastActivityAt = Date.now();
370
+ session.pendingActivity = activity;
346
371
  this.armIdleTimer(session);
347
372
  if (session.activityDebounceTimer) this.clearTimer(session.activityDebounceTimer);
348
373
  session.activityDebounceTimer = this.setTimer(() => {
349
374
  session.activityDebounceTimer = undefined;
350
- session.activityPromise = session.page.waitForTimeout(1).catch(() => {});
375
+ const pendingActivity = session.pendingActivity;
376
+ session.pendingActivity = undefined;
377
+ session.activityRecordPromise = (session.activityRecordPromise ?? Promise.resolve())
378
+ .then(() => this.recordActivityStep(session, pendingActivity))
379
+ .catch(() => {})
380
+ .finally(() => {
381
+ session.activityRecordPromise = undefined;
382
+ });
351
383
  }, USER_ACTIVITY_DEBOUNCE_DELAY);
352
384
  session.activityDebounceTimer?.unref?.();
353
385
  return undefined;
354
386
  }
355
387
 
388
+ async recordActivityStep(session, activity = {}) {
389
+ if (!session.tracingStarted || session.state !== 'recording') return;
390
+ session.stepIndex += 1;
391
+ const type = typeof activity.type === 'string' && activity.type ? activity.type : 'unknown';
392
+ const title = typeof activity.title === 'string' && activity.title ? activity.title : '未命名页面';
393
+ const label = `步骤${session.stepIndex} | ${title} | ${type}`;
394
+ await session.context.tracing.group(label);
395
+ try {
396
+ await session.page.waitForTimeout(1);
397
+ } finally {
398
+ await session.context.tracing.groupEnd();
399
+ }
400
+ }
401
+
402
+ async recordFinalStep(session) {
403
+ session.stepIndex += 1;
404
+ let title = '未命名页面';
405
+ if (typeof session.page.title === 'function') {
406
+ title = await session.page.title().catch(() => title) || title;
407
+ }
408
+ const label = `步骤${session.stepIndex} | ${title} | stop`;
409
+ await session.context.tracing.group(label);
410
+ try {
411
+ await session.page.waitForTimeout(1);
412
+ } finally {
413
+ await session.context.tracing.groupEnd();
414
+ }
415
+ }
416
+
356
417
  armIdleTimer(session) {
357
418
  this.clearTimer(session.idleTimer);
358
419
  session.idleTimer = this.setTimer(() => {
@@ -15,7 +15,9 @@ function createFixture(options = {}) {
15
15
  const browser = new EventEmitter();
16
16
  const tracing = {
17
17
  start: async options => calls.push(['trace-start', options]),
18
- stop: async options => calls.push(['trace-stop', options])
18
+ stop: async options => calls.push(['trace-stop', options]),
19
+ group: async label => calls.push(['trace-group', label]),
20
+ groupEnd: async () => calls.push(['trace-group-end'])
19
21
  };
20
22
  const page = {
21
23
  goto: async url => calls.push(['goto', url]),
@@ -93,7 +95,10 @@ test('starts, records metadata, uploads, completes and cleans up one session', a
93
95
  assert.equal(started.uuid, undefined);
94
96
  assert.equal(started.metadata.operator, '上下文操作人');
95
97
  assert.equal(started.metadata.browserVersion, 'Fixture Chromium 1');
96
- await recorder.handleUserActivity(recorder.activeSession);
98
+ await recorder.handleUserActivity(recorder.activeSession, {
99
+ type: 'click',
100
+ title: '首页功能'
101
+ });
97
102
  await assert.rejects(
98
103
  recorder.start({ url: 'https://app.example.test/another' }),
99
104
  /已有录制任务/
@@ -108,6 +113,7 @@ test('starts, records metadata, uploads, completes and cleans up one session', a
108
113
  );
109
114
  assert.equal(stopped.metadata.title, 'bug3452');
110
115
  assert.ok(calls.some(call => call[0] === 'trace-start' && call[1].sources === true));
116
+ assert.ok(calls.some(call => call[0] === 'trace-group' && call[1] === '步骤1 | 首页功能 | click'));
111
117
  assert.ok(calls.some(call => call[0] === 'post' && call[1] === '/api/trace-sessions'));
112
118
  assert.ok(calls.some(call => call[0] === 'form-append' && call[1] === 'target_url'));
113
119
  assert.ok(calls.some(call => call[0] === 'remove' && call[1] === '/tmp/trace-fixture'));
@@ -118,6 +124,19 @@ test('rejects missing configuration before contacting the backend', async () =>
118
124
  await assert.rejects(recorder.start({ url: 'https://app.example.test' }), /BACKEND_BASE_URL/);
119
125
  });
120
126
 
127
+ test('stops waiting sessions immediately without reporting an error', async () => {
128
+ const { recorder, calls } = createFixture();
129
+ await recorder.start({ url: 'https://app.example.test' });
130
+ const result = await recorder.stop();
131
+ assert.deepEqual(result, {
132
+ state: 'not_started',
133
+ uploaded: false,
134
+ message: '录制已停止。停止前未检测到真实用户操作,因此没有生成可上传的 trace。'
135
+ });
136
+ assert.equal(recorder.activeSession, undefined);
137
+ assert.ok(calls.some(call => call[0] === 'browser-close'));
138
+ });
139
+
121
140
  test('cleans up when Chromium launch fails without a backend session', async () => {
122
141
  const { calls, recorder } = createFixture();
123
142
  recorder.launchChromium = async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobest-ui/agent",
3
- "version": "1.0.15",
3
+ "version": "1.0.16",
4
4
  "private": false,
5
5
  "description": "Autobest Agent skills/plugins/mcp assets + sync cli",
6
6
  "files": [
@@ -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. 告知用户浏览器已打开、正在等待首次真实操作,并保留工具返回的 `uuid` 和最大录制时长;Trace 会在第一次真实操作时开始计时,不要代替用户操作页面。连续 2 分钟没有真实操作时服务会自动停止并上传。
17
+ 4. 告知用户浏览器已打开、正在等待首次真实操作,并保留工具返回的 `uuid` 和最大录制时长;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
 
@@ -22,4 +22,6 @@ description: 通过 trace-mcp-recorder 启动或停止 Playwright Trace 人工
22
22
 
23
23
  用户表达“停止录制”“结束 trace”“完成录制”等意图时,调用 `stop_trace_recording`。第一次调用只会停止录制并询问是否上传;不要擅自上传。用户明确回答“上传”后,再次调用 `stop_trace_recording` 并传入 `confirm=upload`。用户回答“不上传”时,调用该工具并传入 `confirm=cancel`。上传成功后,将回放链接和 `version`、`title`、`operator`、`browserVersion` 整理给用户。
24
24
 
25
+ 停止调用必须以工具本次返回结果为准,不要根据启动时的 `waiting_for_activity` 提示推断最终状态。若工具返回 `state=not_started`,应说明本次没有检测到真实操作且没有生成 trace;若返回上传结果,则只整理上传返回的回放链接。
26
+
25
27
  若工具返回错误,如没有活动会话、后端不可达或上传失败,原样说明关键原因,不编造回放链接。