@autobest-ui/agent 1.0.12 → 1.0.13
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 供测试人员手动操作,使用 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 才写入一张截图 Action,持续变化超过 3 秒时强制写入一张。停止录制时会补一张最终截图。探测截图不会写入 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,83 @@ 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
|
+
await session.page.screenshot();
|
|
382
|
+
session.lastRecordedFrameAt = Date.now();
|
|
383
|
+
session.visualPendingSince = undefined;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async stopVisualCapture(session, recordFinalFrame = false) {
|
|
387
|
+
if (!session.cdpSession) return;
|
|
388
|
+
if (session.visualProbeTimer) {
|
|
389
|
+
this.clearInterval(session.visualProbeTimer);
|
|
390
|
+
session.visualProbeTimer = undefined;
|
|
391
|
+
}
|
|
392
|
+
await session.visualProbePromise?.catch(() => {});
|
|
393
|
+
if (session.visualDebounceTimer) {
|
|
394
|
+
this.clearTimer(session.visualDebounceTimer);
|
|
395
|
+
session.visualDebounceTimer = undefined;
|
|
396
|
+
}
|
|
397
|
+
await session.visualRecordPromise?.catch(() => {});
|
|
398
|
+
session.visualPendingSince = undefined;
|
|
399
|
+
if (recordFinalFrame && session.page) {
|
|
400
|
+
await session.page.screenshot().catch(() => {});
|
|
401
|
+
}
|
|
402
|
+
await session.cdpSession?.detach().catch(() => {});
|
|
403
|
+
session.cdpSession = undefined;
|
|
404
|
+
}
|
|
405
|
+
|
|
282
406
|
async uploadSession(session, trigger) {
|
|
283
407
|
if (session.uploadPromise) return session.uploadPromise;
|
|
284
408
|
session.state = 'uploading';
|
|
@@ -349,6 +473,7 @@ export class TraceRecorder {
|
|
|
349
473
|
if (session.state !== 'recording') return;
|
|
350
474
|
session.state = 'failed';
|
|
351
475
|
this.clearTimer(session.timer);
|
|
476
|
+
await this.stopVisualCapture(session);
|
|
352
477
|
await this.closeResources(session);
|
|
353
478
|
await this.removeTemporaryDirectory(session.temporaryDirectory).catch(() => {});
|
|
354
479
|
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 {
|
|
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();
|