@autobest-ui/agent 1.0.12 → 1.0.14

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.zip` 和浏览器元数据,并返回私有后端的 Trace Viewer 链接。同一进程同一时间只允许一个录制任务。
3
+ `trace-mcp-recorder` 是基于 `@modelcontextprotocol/sdk` 和 `StdioServerTransport` 的 Node.js MCP Server。它打开非无头 Chromium 供测试人员手动操作,使用 Chromium CDP 在内存中探测画面差异,仅在画面发生明显变化时调用 Playwright 截图写入原生 `trace.zip`,然后上传 trace 和浏览器元数据,并返回私有后端的 Trace Viewer 链接。同一进程同一时间只允许一个录制任务。
4
4
 
5
5
  ## 安装与启动
6
6
 
@@ -43,6 +43,8 @@ npm start
43
43
 
44
44
  录制超时后服务自动停止并自动上传(无需人工确认),随后第一次人工调用 `stop_trace_recording` 会取回自动停止的结果;再调用则返回没有活动会话。
45
45
 
46
+ 画面探测默认每 500ms 执行一次,像素差异比例达到 0.5% 后进入防抖冷却;画面连续稳定 800ms 才写入一个轻量 `waitForTimeout(1)` Trace Action,持续变化超过 3 秒时强制写入一个。Trace 自带的 screenshots/snapshots 会记录这些 Action 前后的页面状态,停止录制时会补一个最终 Action。探测截图不会写入 Trace,因此静止页面和连续滚动不会产生大量重复图片。
47
+
46
48
  ## 后端接口契约
47
49
 
48
50
  服务期望后端提供以下接口:
@@ -8,11 +8,16 @@ import { fileURLToPath } from 'node:url';
8
8
  import axios from 'axios';
9
9
  import FormData from 'form-data';
10
10
  import { chromium } from 'playwright';
11
+ import { PNG } from 'pngjs';
11
12
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
12
13
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
13
14
  import { z } from 'zod';
14
15
 
15
16
  const DEFAULT_MAX_RECORD_DURATION = 1_800_000;
17
+ const VISUAL_PROBE_INTERVAL = 500;
18
+ const VISUAL_DIFF_THRESHOLD = 0.005;
19
+ const VISUAL_DEBOUNCE_DELAY = 800;
20
+ const VISUAL_MAX_PENDING_DELAY = 3_000;
16
21
  const directory = path.dirname(fileURLToPath(import.meta.url));
17
22
 
18
23
  function readConfiguration(environment = process.env) {
@@ -99,6 +104,32 @@ function toolError(error) {
99
104
  };
100
105
  }
101
106
 
107
+ // Compare decoded PNG pixels. A small tolerance avoids frames caused only by
108
+ // anti-aliasing noise being written to the Trace.
109
+ export function pixelDifference(previousBuffer, currentBuffer, channelTolerance = 12) {
110
+ if (!previousBuffer || !currentBuffer) return 1;
111
+ try {
112
+ const previous = PNG.sync.read(previousBuffer);
113
+ const current = PNG.sync.read(currentBuffer);
114
+ if (previous.width !== current.width || previous.height !== current.height) return 1;
115
+ const pixelCount = previous.width * previous.height;
116
+ let changedPixels = 0;
117
+ for (let offset = 0; offset < previous.data.length; offset += 4) {
118
+ if (
119
+ Math.abs(previous.data[offset] - current.data[offset]) > channelTolerance ||
120
+ Math.abs(previous.data[offset + 1] - current.data[offset + 1]) > channelTolerance ||
121
+ Math.abs(previous.data[offset + 2] - current.data[offset + 2]) > channelTolerance ||
122
+ Math.abs(previous.data[offset + 3] - current.data[offset + 3]) > channelTolerance
123
+ ) {
124
+ changedPixels += 1;
125
+ }
126
+ }
127
+ return changedPixels / pixelCount;
128
+ } catch {
129
+ return 1;
130
+ }
131
+ }
132
+
102
133
  export class TraceRecorder {
103
134
  constructor(options = {}) {
104
135
  this.environment = options.environment ?? process.env;
@@ -120,6 +151,12 @@ export class TraceRecorder {
120
151
  options.removeTemporaryDirectory ?? (temporaryDirectory => rm(temporaryDirectory, { recursive: true, force: true }));
121
152
  this.setTimer = options.setTimer ?? setTimeout;
122
153
  this.clearTimer = options.clearTimer ?? clearTimeout;
154
+ this.setInterval = options.setInterval ?? setInterval;
155
+ this.clearInterval = options.clearInterval ?? clearInterval;
156
+ this.visualProbeInterval = options.visualProbeInterval ?? VISUAL_PROBE_INTERVAL;
157
+ this.visualDiffThreshold = options.visualDiffThreshold ?? VISUAL_DIFF_THRESHOLD;
158
+ this.visualDebounceDelay = options.visualDebounceDelay ?? VISUAL_DEBOUNCE_DELAY;
159
+ this.visualMaxPendingDelay = options.visualMaxPendingDelay ?? VISUAL_MAX_PENDING_DELAY;
123
160
  this.platformInfo = options.platformInfo ?? (() => ({
124
161
  platform: os.platform(),
125
162
  type: os.type(),
@@ -184,9 +221,18 @@ export class TraceRecorder {
184
221
  state: 'recording',
185
222
  stopPromise: undefined,
186
223
  uploadPromise: undefined,
187
- timer: undefined
224
+ timer: undefined,
225
+ visualProbeTimer: undefined,
226
+ visualProbePromise: undefined,
227
+ visualDebounceTimer: undefined,
228
+ visualRecordPromise: undefined,
229
+ visualPendingSince: undefined,
230
+ cdpSession: undefined,
231
+ previousProbeFrame: undefined,
232
+ lastRecordedFrameAt: Date.now()
188
233
  };
189
234
  this.activeSession = session;
235
+ await this.startVisualCapture(session);
190
236
 
191
237
  const failOnBrowserExit = () => {
192
238
  if (session.state === 'recording') {
@@ -264,6 +310,7 @@ export class TraceRecorder {
264
310
 
265
311
  async prepareSession(session) {
266
312
  try {
313
+ await this.stopVisualCapture(session, true);
267
314
  await session.context.tracing.stop({ path: session.tracePath });
268
315
  await this.closeResources({ page: session.page, context: session.context, browser: session.browser });
269
316
  session.state = 'awaiting_upload';
@@ -279,6 +326,86 @@ export class TraceRecorder {
279
326
  }
280
327
  }
281
328
 
329
+ async startVisualCapture(session) {
330
+ if (typeof session.context.newCDPSession !== 'function') return;
331
+ session.cdpSession = await session.context.newCDPSession(session.page);
332
+ await session.cdpSession.send('Page.enable');
333
+ session.previousProbeFrame = await this.captureProbeFrame(session);
334
+ session.visualProbeTimer = this.setInterval(() => {
335
+ if (session.visualProbePromise) return;
336
+ session.visualProbePromise = this.probeAndRecordVisualChange(session).catch(error => {
337
+ if (session.state === 'recording') {
338
+ console.error('[trace-mcp-recorder] 画面差异探测失败:', errorMessage(error));
339
+ }
340
+ }).finally(() => {
341
+ session.visualProbePromise = undefined;
342
+ });
343
+ }, this.visualProbeInterval);
344
+ session.visualProbeTimer?.unref?.();
345
+ }
346
+
347
+ async captureProbeFrame(session) {
348
+ const result = await session.cdpSession.send('Page.captureScreenshot', {
349
+ format: 'png',
350
+ fromSurface: true
351
+ });
352
+ return Buffer.from(result.data, 'base64');
353
+ }
354
+
355
+ async probeAndRecordVisualChange(session) {
356
+ const currentFrame = await this.captureProbeFrame(session);
357
+ const difference = pixelDifference(session.previousProbeFrame, currentFrame);
358
+ session.previousProbeFrame = currentFrame;
359
+ if (difference >= this.visualDiffThreshold) {
360
+ this.scheduleVisualRecord(session);
361
+ }
362
+ }
363
+
364
+ scheduleVisualRecord(session) {
365
+ const now = Date.now();
366
+ session.visualPendingSince ??= now;
367
+ if (session.visualDebounceTimer) this.clearTimer(session.visualDebounceTimer);
368
+ const pendingFor = now - session.visualPendingSince;
369
+ const delay = pendingFor >= this.visualMaxPendingDelay ? 0 : this.visualDebounceDelay;
370
+ session.visualDebounceTimer = this.setTimer(() => {
371
+ session.visualDebounceTimer = undefined;
372
+ session.visualRecordPromise = this.recordPendingVisual(session).finally(() => {
373
+ session.visualRecordPromise = undefined;
374
+ });
375
+ }, delay);
376
+ session.visualDebounceTimer?.unref?.();
377
+ }
378
+
379
+ async recordPendingVisual(session) {
380
+ if (session.state !== 'recording' || !session.visualPendingSince) return;
381
+ // waitForTimeout is a traceable Playwright action. The trace screenshots and
382
+ // snapshots captured around it represent the changed page state, while the
383
+ // native Trace Viewer can advance through this action during playback.
384
+ await session.page.waitForTimeout(1);
385
+ session.lastRecordedFrameAt = Date.now();
386
+ session.visualPendingSince = undefined;
387
+ }
388
+
389
+ async stopVisualCapture(session, recordFinalFrame = false) {
390
+ if (!session.cdpSession) return;
391
+ if (session.visualProbeTimer) {
392
+ this.clearInterval(session.visualProbeTimer);
393
+ session.visualProbeTimer = undefined;
394
+ }
395
+ await session.visualProbePromise?.catch(() => {});
396
+ if (session.visualDebounceTimer) {
397
+ this.clearTimer(session.visualDebounceTimer);
398
+ session.visualDebounceTimer = undefined;
399
+ }
400
+ await session.visualRecordPromise?.catch(() => {});
401
+ session.visualPendingSince = undefined;
402
+ if (recordFinalFrame && session.page) {
403
+ await session.page.waitForTimeout(1).catch(() => {});
404
+ }
405
+ await session.cdpSession?.detach().catch(() => {});
406
+ session.cdpSession = undefined;
407
+ }
408
+
282
409
  async uploadSession(session, trigger) {
283
410
  if (session.uploadPromise) return session.uploadPromise;
284
411
  session.state = 'uploading';
@@ -349,6 +476,7 @@ export class TraceRecorder {
349
476
  if (session.state !== 'recording') return;
350
477
  session.state = 'failed';
351
478
  this.clearTimer(session.timer);
479
+ await this.stopVisualCapture(session);
352
480
  await this.closeResources(session);
353
481
  await this.removeTemporaryDirectory(session.temporaryDirectory).catch(() => {});
354
482
  if (this.activeSession === session) this.activeSession = undefined;
@@ -5,11 +5,21 @@ import test from 'node:test';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
7
7
  import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
8
- import { TraceRecorder } from './index.js';
8
+ import { PNG } from 'pngjs';
9
+ import { pixelDifference, TraceRecorder } from './index.js';
9
10
 
10
11
  const directory = path.dirname(fileURLToPath(import.meta.url));
11
12
  const sessionUuid = '123e4567-e89b-42d3-a456-426614174000';
12
13
 
14
+ test('calculates pixel differences for visual probes', () => {
15
+ const first = new PNG({ width: 2, height: 1 });
16
+ const second = new PNG({ width: 2, height: 1 });
17
+ first.data.fill(255);
18
+ second.data.fill(255);
19
+ second.data[0] = 0;
20
+ assert.equal(pixelDifference(PNG.sync.write(first), PNG.sync.write(second)), 0.5);
21
+ });
22
+
13
23
  function createFixture(options = {}) {
14
24
  const calls = [];
15
25
  const browser = new EventEmitter();