@autobest-ui/agent 1.0.11 → 1.0.12
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.
- package/README.md +20 -6
- package/bin/sync-assets.test.mjs +2 -0
- package/mcp/trace-mcp-recorder/.env.example +6 -0
- package/mcp/trace-mcp-recorder/README.md +58 -0
- package/mcp/trace-mcp-recorder/config.toml.example +11 -0
- package/mcp/trace-mcp-recorder/index.js +469 -0
- package/mcp/trace-mcp-recorder/index.test.js +179 -0
- package/mcp/trace-mcp-recorder/package.json +25 -0
- package/package.json +9 -2
- package/skills/README.md +1 -0
- package/skills/common/trace-recorder/SKILL.md +25 -0
- package/skills/common/trace-recorder/agents/openai.yaml +11 -0
package/README.md
CHANGED
|
@@ -40,21 +40,35 @@ npx --yes \
|
|
|
40
40
|
autobest-azurepr-mcp
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
+
启动 Playwright Trace 人工录制 MCP:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npx --yes --package=@autobest-ui/agent@latest playwright install chromium
|
|
47
|
+
|
|
48
|
+
BACKEND_BASE_URL="http://127.0.0.1:3001" \
|
|
49
|
+
MAX_RECORD_DURATION="1800000" \
|
|
50
|
+
npx --yes \
|
|
51
|
+
--package=@autobest-ui/agent@latest \
|
|
52
|
+
autobest-trace-mcp-recorder
|
|
53
|
+
```
|
|
54
|
+
|
|
43
55
|
启动成功后,MCP 会通过 stdio 持续等待客户端请求,终端没有输出属于正常状态,可按 `Ctrl+C` 停止。供 Codex 长期使用时,不要只执行上述临时命令,应将完整配置加入 `~/.codex/config.toml` 后重启 Codex。
|
|
44
56
|
|
|
45
|
-
同一个 npm
|
|
57
|
+
同一个 npm 包暴露多个独立命令:
|
|
46
58
|
|
|
47
|
-
| 命令
|
|
48
|
-
|
|
|
49
|
-
| `autobest-rag-mcp`
|
|
50
|
-
| `autobest-azurepr-mcp`
|
|
51
|
-
| `
|
|
59
|
+
| 命令 | 用途 | 必需配置 |
|
|
60
|
+
| ------------------------------- | ------------------------------ | ------------------ |
|
|
61
|
+
| `autobest-rag-mcp` | 连接 PRD Knowledge RAG API | `RAG_API_BASE_URL` |
|
|
62
|
+
| `autobest-azurepr-mcp` | 读取 Azure DevOps Pull Request | `AZURE_DEVOPS_PAT` |
|
|
63
|
+
| `autobest-trace-mcp-recorder` | 录制并上传 Playwright Trace | `BACKEND_BASE_URL` |
|
|
64
|
+
| `figma-mcp-bridge` | 连接本机 Figma Plugin | 无 |
|
|
52
65
|
|
|
53
66
|
Codex 配置示例分别位于:
|
|
54
67
|
|
|
55
68
|
- [RAG MCP 配置](mcp/rag-mcp-bridge/config.toml.example)
|
|
56
69
|
- [Azure PR MCP 配置](mcp/azurepr-mcp-bridge/config.toml.example)
|
|
57
70
|
- [Figma MCP 配置](mcp/figma-mcp-bridge/config.toml.example)
|
|
71
|
+
- [Trace Recorder MCP 配置](mcp/trace-mcp-recorder/config.toml.example)
|
|
58
72
|
|
|
59
73
|
生产环境可将 `@latest` 替换为明确版本,以固定 MCP 行为。
|
|
60
74
|
|
package/bin/sync-assets.test.mjs
CHANGED
|
@@ -25,6 +25,7 @@ test("installs common skills into the user-level skills directory", async () =>
|
|
|
25
25
|
"make-spec",
|
|
26
26
|
"repo-setup",
|
|
27
27
|
"review-from-docs",
|
|
28
|
+
"trace-recorder",
|
|
28
29
|
"ui-prd-scope",
|
|
29
30
|
]);
|
|
30
31
|
await access(
|
|
@@ -35,6 +36,7 @@ test("installs common skills into the user-level skills directory", async () =>
|
|
|
35
36
|
path.join(result.targetRoot, "make-spec", "scripts", "validate-spec-traceability.mjs")
|
|
36
37
|
);
|
|
37
38
|
await access(path.join(result.targetRoot, "repo-setup", "SKILL.md"));
|
|
39
|
+
await access(path.join(result.targetRoot, "trace-recorder", "SKILL.md"));
|
|
38
40
|
await access(
|
|
39
41
|
path.join(result.targetRoot, "review-from-docs", "references", "review-result-schema.md")
|
|
40
42
|
);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# trace-mcp-recorder
|
|
2
|
+
|
|
3
|
+
`trace-mcp-recorder` 是基于 `@modelcontextprotocol/sdk` 和 `StdioServerTransport` 的 Node.js MCP Server。它打开非无头 Chromium 供测试人员手动操作,停止后上传 `trace.zip` 和浏览器元数据,并返回私有后端的 Trace Viewer 链接。同一进程同一时间只允许一个录制任务。
|
|
4
|
+
|
|
5
|
+
## 安装与启动
|
|
6
|
+
|
|
7
|
+
要求 Node.js 20 或更高版本。在本目录安装依赖及 Chromium:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install
|
|
11
|
+
npx playwright install chromium
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
复制 `.env.example` 中的变量到 MCP 客户端环境。服务不会自动读取 `.env` 文件,也不会在代码中提供后端默认地址。直接启动:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
BACKEND_BASE_URL="http://127.0.0.1:3001" \
|
|
18
|
+
MAX_RECORD_DURATION="1800000" \
|
|
19
|
+
npm start
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
服务使用 stdio 通信,启动后持续等待请求且不会向标准输出写日志。
|
|
23
|
+
|
|
24
|
+
## Codex 接入
|
|
25
|
+
|
|
26
|
+
将 [config.toml.example](config.toml.example) 的内容加入 `~/.codex/config.toml` 并按实际部署修改 `BACKEND_BASE_URL`,然后重启 Codex。发布包暴露的命令是 `autobest-trace-mcp-recorder`。
|
|
27
|
+
|
|
28
|
+
配套 Skill 位于 `../../skills/common/trace-recorder/`,由 `autobest-agent-sync common` 安装,并作为 `skill://trace-recorder/SKILL.md` MCP 资源提供。Skill 的 `agents/openai.yaml` 声明了对 `trace-mcp-recorder` 的工具依赖。
|
|
29
|
+
|
|
30
|
+
## 环境变量
|
|
31
|
+
|
|
32
|
+
| 变量 | 默认值 | 说明 |
|
|
33
|
+
| --- | --- | --- |
|
|
34
|
+
| `BACKEND_BASE_URL` | 无,必填 | 私有后端基础地址,必须为 HTTP(S) URL |
|
|
35
|
+
| `MAX_RECORD_DURATION` | `1800000` | 最大录制时长,单位毫秒;超时后自动停止并上传 |
|
|
36
|
+
|
|
37
|
+
回放链接按 `${BACKEND_BASE_URL}/trace-viewer/?trace=${fileUrl}` 生成。服务地址、域名和端口均来自 `BACKEND_BASE_URL`。
|
|
38
|
+
|
|
39
|
+
## MCP 工具
|
|
40
|
+
|
|
41
|
+
- `start_trace_recording`:参数为必填 `url` 和可选 `version`、`title`、`operator`。`url` 独立确定被测页面;`title` 是录制标题或功能描述,也可以直接填写 `bug3452` 这类 Bug 编号。系统不额外传递独立的 Bug 字段。MCP 认证上下文中的 `operator`、`name` 或 `preferred_username` 优先于参数。
|
|
42
|
+
- `stop_trace_recording`:首次调用不传参数,只停止 trace 并询问是否上传;用户确认后传 `confirm=upload`,取消时传 `confirm=cancel`。上传成功后返回回放链接及版本、标题、操作人、浏览器版本。
|
|
43
|
+
|
|
44
|
+
录制超时后服务自动停止并自动上传(无需人工确认),随后第一次人工调用 `stop_trace_recording` 会取回自动停止的结果;再调用则返回没有活动会话。
|
|
45
|
+
|
|
46
|
+
## 后端接口契约
|
|
47
|
+
|
|
48
|
+
服务期望后端提供以下接口:
|
|
49
|
+
|
|
50
|
+
- `POST /api/trace-sessions`:接收 `multipart/form-data`,必填字段为 `trace`、`target_url`,可选字段为 `version`、`title`、`operator`、`browser_name`、`browser_version`、`user_agent`、`os_name`、`viewport`;后端生成 UUID,创建并上传成功后返回 `{ "uuid": "...", "fileUrl": "...", "item": { "status": "completed" } }`。
|
|
51
|
+
|
|
52
|
+
`metadata` 包含浏览器版本、User-Agent、viewport、MCP 所在操作系统信息、目标 URL 和录制业务字段。上传或状态更新失败时,本地 trace 仍会被清理,会话会尽力回写为 `failed`。
|
|
53
|
+
|
|
54
|
+
## 验证
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
npm run verify
|
|
58
|
+
```
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
[mcp_servers.trace-mcp-recorder]
|
|
2
|
+
command = "npx"
|
|
3
|
+
args = ["--yes", "--package=@autobest-ui/agent@latest", "autobest-trace-mcp-recorder"]
|
|
4
|
+
startup_timeout_sec = 30
|
|
5
|
+
tool_timeout_sec = 180
|
|
6
|
+
enabled = true
|
|
7
|
+
|
|
8
|
+
[mcp_servers.trace-mcp-recorder.env]
|
|
9
|
+
BACKEND_BASE_URL = "http://127.0.0.1:3001"
|
|
10
|
+
MAX_RECORD_DURATION = "1800000"
|
|
11
|
+
|
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createReadStream, existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
4
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import axios from 'axios';
|
|
9
|
+
import FormData from 'form-data';
|
|
10
|
+
import { chromium } from 'playwright';
|
|
11
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
12
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
13
|
+
import { z } from 'zod';
|
|
14
|
+
|
|
15
|
+
const DEFAULT_MAX_RECORD_DURATION = 1_800_000;
|
|
16
|
+
const directory = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
|
|
18
|
+
function readConfiguration(environment = process.env) {
|
|
19
|
+
const configuredBaseUrl = environment.BACKEND_BASE_URL?.trim();
|
|
20
|
+
if (!configuredBaseUrl) {
|
|
21
|
+
throw new Error('BACKEND_BASE_URL 未配置,无法创建录制会话');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let baseUrl;
|
|
25
|
+
try {
|
|
26
|
+
baseUrl = new URL(configuredBaseUrl);
|
|
27
|
+
} catch {
|
|
28
|
+
throw new Error('BACKEND_BASE_URL 必须是有效的 URL');
|
|
29
|
+
}
|
|
30
|
+
if (!['http:', 'https:'].includes(baseUrl.protocol)) {
|
|
31
|
+
throw new Error('BACKEND_BASE_URL 必须使用 http 或 https 协议');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const durationText = environment.MAX_RECORD_DURATION ?? String(DEFAULT_MAX_RECORD_DURATION);
|
|
35
|
+
const maxRecordDuration = Number(durationText);
|
|
36
|
+
if (!Number.isSafeInteger(maxRecordDuration) || maxRecordDuration <= 0) {
|
|
37
|
+
throw new Error('MAX_RECORD_DURATION 必须是正整数毫秒值');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
baseUrl: baseUrl.toString().replace(/\/$/, ''),
|
|
42
|
+
maxRecordDuration
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function validateTargetUrl(value) {
|
|
47
|
+
let url;
|
|
48
|
+
try {
|
|
49
|
+
url = new URL(value);
|
|
50
|
+
} catch {
|
|
51
|
+
throw new Error('url 必须是有效的网页地址');
|
|
52
|
+
}
|
|
53
|
+
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
54
|
+
throw new Error('url 必须使用 http 或 https 协议');
|
|
55
|
+
}
|
|
56
|
+
return url.toString();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function optionalTrimmed(value) {
|
|
60
|
+
const trimmed = value?.trim();
|
|
61
|
+
return trimmed || undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Prefer authenticated/request MCP identity data, then the explicit tool argument. */
|
|
65
|
+
function resolveOperator(extra, suppliedOperator) {
|
|
66
|
+
const authExtra = extra?.authInfo?.extra;
|
|
67
|
+
const requestMeta = extra?._meta;
|
|
68
|
+
const candidates = [
|
|
69
|
+
authExtra?.operator,
|
|
70
|
+
authExtra?.name,
|
|
71
|
+
authExtra?.preferred_username,
|
|
72
|
+
requestMeta?.operator,
|
|
73
|
+
requestMeta?.user?.name,
|
|
74
|
+
suppliedOperator
|
|
75
|
+
];
|
|
76
|
+
return candidates.find(value => typeof value === 'string' && value.trim())?.trim();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function errorMessage(error) {
|
|
80
|
+
if (axios.isAxiosError(error)) {
|
|
81
|
+
const responseData = error.response?.data;
|
|
82
|
+
const details =
|
|
83
|
+
typeof responseData === 'string' ? responseData : responseData ? JSON.stringify(responseData) : error.message;
|
|
84
|
+
return `后端请求失败${error.response?.status ? ` (HTTP ${error.response.status})` : ''}: ${details}`;
|
|
85
|
+
}
|
|
86
|
+
return error instanceof Error ? error.message : String(error);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function toolText(payload) {
|
|
90
|
+
return {
|
|
91
|
+
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }]
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function toolError(error) {
|
|
96
|
+
return {
|
|
97
|
+
isError: true,
|
|
98
|
+
content: [{ type: 'text', text: errorMessage(error) }]
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export class TraceRecorder {
|
|
103
|
+
constructor(options = {}) {
|
|
104
|
+
this.environment = options.environment ?? process.env;
|
|
105
|
+
this.launchChromium = options.launchChromium ?? (launchOptions => chromium.launch(launchOptions));
|
|
106
|
+
this.createHttpClient =
|
|
107
|
+
options.createHttpClient ??
|
|
108
|
+
(baseURL =>
|
|
109
|
+
axios.create({
|
|
110
|
+
baseURL,
|
|
111
|
+
timeout: 120_000,
|
|
112
|
+
maxBodyLength: Infinity,
|
|
113
|
+
maxContentLength: Infinity
|
|
114
|
+
}));
|
|
115
|
+
this.createFormData = options.createFormData ?? (() => new FormData());
|
|
116
|
+
this.createReadStream = options.createReadStream ?? createReadStream;
|
|
117
|
+
this.makeTemporaryDirectory =
|
|
118
|
+
options.makeTemporaryDirectory ?? (() => mkdtemp(path.join(os.tmpdir(), 'trace-mcp-recorder-')));
|
|
119
|
+
this.removeTemporaryDirectory =
|
|
120
|
+
options.removeTemporaryDirectory ?? (temporaryDirectory => rm(temporaryDirectory, { recursive: true, force: true }));
|
|
121
|
+
this.setTimer = options.setTimer ?? setTimeout;
|
|
122
|
+
this.clearTimer = options.clearTimer ?? clearTimeout;
|
|
123
|
+
this.platformInfo = options.platformInfo ?? (() => ({
|
|
124
|
+
platform: os.platform(),
|
|
125
|
+
type: os.type(),
|
|
126
|
+
release: os.release(),
|
|
127
|
+
architecture: os.arch()
|
|
128
|
+
}));
|
|
129
|
+
this.activeSession = undefined;
|
|
130
|
+
this.lastAutomaticResult = undefined;
|
|
131
|
+
this.lastAutomaticError = undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async start(input, extra = {}) {
|
|
135
|
+
if (this.activeSession) {
|
|
136
|
+
throw new Error(`已有录制任务正在运行 (uuid: ${this.activeSession.uuid}),请先停止当前录制`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const configuration = readConfiguration(this.environment);
|
|
140
|
+
const targetUrl = validateTargetUrl(input.url.trim());
|
|
141
|
+
const sessionInput = {
|
|
142
|
+
url: targetUrl,
|
|
143
|
+
version: optionalTrimmed(input.version),
|
|
144
|
+
title: optionalTrimmed(input.title),
|
|
145
|
+
operator: resolveOperator(extra, input.operator)
|
|
146
|
+
};
|
|
147
|
+
const reservation = { uuid: 'pending', state: 'starting' };
|
|
148
|
+
this.activeSession = reservation;
|
|
149
|
+
this.lastAutomaticResult = undefined;
|
|
150
|
+
this.lastAutomaticError = undefined;
|
|
151
|
+
let temporaryDirectory;
|
|
152
|
+
let browser;
|
|
153
|
+
let context;
|
|
154
|
+
let page;
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
temporaryDirectory = await this.makeTemporaryDirectory();
|
|
158
|
+
browser = await this.launchChromium({ headless: false });
|
|
159
|
+
context = await browser.newContext();
|
|
160
|
+
await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
|
|
161
|
+
page = await context.newPage();
|
|
162
|
+
await page.goto(targetUrl);
|
|
163
|
+
|
|
164
|
+
const metadata = {
|
|
165
|
+
version: sessionInput.version,
|
|
166
|
+
title: sessionInput.title,
|
|
167
|
+
operator: sessionInput.operator,
|
|
168
|
+
targetUrl,
|
|
169
|
+
browserName: 'Chromium',
|
|
170
|
+
browserVersion: browser.version(),
|
|
171
|
+
userAgent: await page.evaluate(() => navigator.userAgent),
|
|
172
|
+
viewport: page.viewportSize(),
|
|
173
|
+
operatingSystem: this.platformInfo(),
|
|
174
|
+
startedAt: new Date().toISOString()
|
|
175
|
+
};
|
|
176
|
+
const session = {
|
|
177
|
+
browser,
|
|
178
|
+
context,
|
|
179
|
+
page,
|
|
180
|
+
metadata,
|
|
181
|
+
baseUrl: configuration.baseUrl,
|
|
182
|
+
tracePath: path.join(temporaryDirectory, 'trace.zip'),
|
|
183
|
+
temporaryDirectory,
|
|
184
|
+
state: 'recording',
|
|
185
|
+
stopPromise: undefined,
|
|
186
|
+
uploadPromise: undefined,
|
|
187
|
+
timer: undefined
|
|
188
|
+
};
|
|
189
|
+
this.activeSession = session;
|
|
190
|
+
|
|
191
|
+
const failOnBrowserExit = () => {
|
|
192
|
+
if (session.state === 'recording') {
|
|
193
|
+
session.stopPromise = this.failSession(session, new Error('Chromium 浏览器意外退出'));
|
|
194
|
+
void session.stopPromise.catch(failure => {
|
|
195
|
+
console.error('[trace-mcp-recorder] 浏览器异常退出:', errorMessage(failure));
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
browser.once('disconnected', failOnBrowserExit);
|
|
200
|
+
session.timer = this.setTimer(() => {
|
|
201
|
+
void this.stop({ trigger: 'timeout', confirm: 'upload' }).catch(error => {
|
|
202
|
+
this.lastAutomaticError = error;
|
|
203
|
+
console.error('[trace-mcp-recorder] 自动停止录制失败:', errorMessage(error));
|
|
204
|
+
});
|
|
205
|
+
}, configuration.maxRecordDuration);
|
|
206
|
+
session.timer?.unref?.();
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
message: '浏览器已打开,请手动操作页面,完成后调用 stop_trace_recording',
|
|
210
|
+
maxRecordDuration: configuration.maxRecordDuration,
|
|
211
|
+
metadata
|
|
212
|
+
};
|
|
213
|
+
} catch (error) {
|
|
214
|
+
await this.closeResources({ browser, context, page, temporaryDirectory });
|
|
215
|
+
if (this.activeSession === reservation) {
|
|
216
|
+
this.activeSession = undefined;
|
|
217
|
+
}
|
|
218
|
+
throw error;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async stop({ trigger = 'manual', confirm } = {}) {
|
|
223
|
+
const session = this.activeSession;
|
|
224
|
+
if (!session) {
|
|
225
|
+
if (trigger === 'manual' && this.lastAutomaticError) {
|
|
226
|
+
const error = this.lastAutomaticError;
|
|
227
|
+
this.lastAutomaticError = undefined;
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
230
|
+
if (trigger === 'manual' && this.lastAutomaticResult) {
|
|
231
|
+
const result = this.lastAutomaticResult;
|
|
232
|
+
this.lastAutomaticResult = undefined;
|
|
233
|
+
return result;
|
|
234
|
+
}
|
|
235
|
+
throw new Error('当前没有正在录制的 trace 会话');
|
|
236
|
+
}
|
|
237
|
+
if (session.state === 'starting') {
|
|
238
|
+
throw new Error('录制会话正在启动,请等待 start_trace_recording 返回后再停止');
|
|
239
|
+
}
|
|
240
|
+
if (session.state === 'recording') {
|
|
241
|
+
if (session.stopPromise) return session.stopPromise;
|
|
242
|
+
session.state = 'stopping';
|
|
243
|
+
this.clearTimer(session.timer);
|
|
244
|
+
session.stopPromise = this.prepareSession(session).finally(() => {
|
|
245
|
+
session.stopPromise = undefined;
|
|
246
|
+
});
|
|
247
|
+
const prepared = await session.stopPromise;
|
|
248
|
+
if (trigger === 'timeout' || confirm === 'upload') return this.uploadSession(session, trigger);
|
|
249
|
+
return prepared;
|
|
250
|
+
}
|
|
251
|
+
if (session.state === 'stopping' && session.stopPromise) return session.stopPromise;
|
|
252
|
+
if (session.state === 'awaiting_upload') {
|
|
253
|
+
if (confirm === 'upload') return this.uploadSession(session, trigger);
|
|
254
|
+
if (confirm === 'cancel') return this.cancelSession(session);
|
|
255
|
+
return {
|
|
256
|
+
uuid: undefined,
|
|
257
|
+
message: 'trace.zip 已生成,是否上传录制内容并创建回放记录?请确认“上传”或“不上传”。',
|
|
258
|
+
metadata: session.metadata
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
if (session.state === 'uploading' && session.uploadPromise) return session.uploadPromise;
|
|
262
|
+
throw new Error('当前录制会话不可停止');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async prepareSession(session) {
|
|
266
|
+
try {
|
|
267
|
+
await session.context.tracing.stop({ path: session.tracePath });
|
|
268
|
+
await this.closeResources({ page: session.page, context: session.context, browser: session.browser });
|
|
269
|
+
session.state = 'awaiting_upload';
|
|
270
|
+
return {
|
|
271
|
+
message: 'trace.zip 已生成,是否上传录制内容并创建回放记录?请确认“上传”或“不上传”。',
|
|
272
|
+
metadata: session.metadata
|
|
273
|
+
};
|
|
274
|
+
} catch (error) {
|
|
275
|
+
session.state = 'failed';
|
|
276
|
+
await this.removeTemporaryDirectory(session.temporaryDirectory).catch(() => {});
|
|
277
|
+
if (this.activeSession === session) this.activeSession = undefined;
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async uploadSession(session, trigger) {
|
|
283
|
+
if (session.uploadPromise) return session.uploadPromise;
|
|
284
|
+
session.state = 'uploading';
|
|
285
|
+
session.uploadPromise = (async () => {
|
|
286
|
+
const configuration = readConfiguration(this.environment);
|
|
287
|
+
const http = this.createHttpClient(configuration.baseUrl);
|
|
288
|
+
const form = this.createFormData();
|
|
289
|
+
form.append('target_url', session.metadata.targetUrl);
|
|
290
|
+
for (const [field, value] of [
|
|
291
|
+
['version', session.metadata.version],
|
|
292
|
+
['title', session.metadata.title],
|
|
293
|
+
['operator', session.metadata.operator],
|
|
294
|
+
['browser_name', session.metadata.browserName],
|
|
295
|
+
['browser_version', session.metadata.browserVersion],
|
|
296
|
+
['user_agent', session.metadata.userAgent],
|
|
297
|
+
['os_name', session.metadata.operatingSystem.platform],
|
|
298
|
+
['viewport', JSON.stringify(session.metadata.viewport)]
|
|
299
|
+
]) {
|
|
300
|
+
if (value !== undefined && value !== null) form.append(field, String(value));
|
|
301
|
+
}
|
|
302
|
+
form.append('trace', this.createReadStream(session.tracePath), {
|
|
303
|
+
filename: 'trace.zip',
|
|
304
|
+
contentType: 'application/zip'
|
|
305
|
+
});
|
|
306
|
+
const uploadResponse = await http.post('/api/trace-sessions', form, {
|
|
307
|
+
headers: form.getHeaders()
|
|
308
|
+
});
|
|
309
|
+
const fileUrl = uploadResponse.data?.fileUrl;
|
|
310
|
+
if (typeof fileUrl !== 'string' || !fileUrl.trim()) throw new Error('上传响应缺少 fileUrl');
|
|
311
|
+
const replayLink = `${session.baseUrl}/trace-viewer/?trace=${fileUrl}`;
|
|
312
|
+
const result = {
|
|
313
|
+
uuid: uploadResponse.data?.uuid,
|
|
314
|
+
message: `✅录制完成,回放链接:${replayLink}`,
|
|
315
|
+
replayLink,
|
|
316
|
+
fileUrl,
|
|
317
|
+
stoppedBy: trigger,
|
|
318
|
+
metadata: {
|
|
319
|
+
version: session.metadata.version,
|
|
320
|
+
title: session.metadata.title,
|
|
321
|
+
operator: session.metadata.operator,
|
|
322
|
+
browserVersion: session.metadata.browserVersion
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
session.state = 'completed';
|
|
326
|
+
if (trigger === 'timeout') this.lastAutomaticResult = result;
|
|
327
|
+
return result;
|
|
328
|
+
})().catch(async error => {
|
|
329
|
+
session.state = 'failed';
|
|
330
|
+
if (trigger === 'timeout') this.lastAutomaticError = error;
|
|
331
|
+
await this.removeTemporaryDirectory(session.temporaryDirectory).catch(() => {});
|
|
332
|
+
if (this.activeSession === session) this.activeSession = undefined;
|
|
333
|
+
throw error;
|
|
334
|
+
}).finally(async () => {
|
|
335
|
+
await this.removeTemporaryDirectory(session.temporaryDirectory).catch(() => {});
|
|
336
|
+
if (this.activeSession === session && session.state === 'completed') this.activeSession = undefined;
|
|
337
|
+
});
|
|
338
|
+
return session.uploadPromise;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async cancelSession(session) {
|
|
342
|
+
session.state = 'cancelled';
|
|
343
|
+
await this.removeTemporaryDirectory(session.temporaryDirectory).catch(() => {});
|
|
344
|
+
if (this.activeSession === session) this.activeSession = undefined;
|
|
345
|
+
return { message: '已取消上传,本地 trace.zip 已删除。' };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async failSession(session, error) {
|
|
349
|
+
if (session.state !== 'recording') return;
|
|
350
|
+
session.state = 'failed';
|
|
351
|
+
this.clearTimer(session.timer);
|
|
352
|
+
await this.closeResources(session);
|
|
353
|
+
await this.removeTemporaryDirectory(session.temporaryDirectory).catch(() => {});
|
|
354
|
+
if (this.activeSession === session) this.activeSession = undefined;
|
|
355
|
+
throw error;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async closeResources({ browser, context, page, temporaryDirectory }) {
|
|
359
|
+
// Each cleanup step is independent so one closed/crashed Playwright object cannot block the rest.
|
|
360
|
+
await page?.close().catch(() => {});
|
|
361
|
+
await context?.close().catch(() => {});
|
|
362
|
+
await browser?.close().catch(() => {});
|
|
363
|
+
if (temporaryDirectory) await this.removeTemporaryDirectory(temporaryDirectory).catch(() => {});
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function registerSkillResources(server) {
|
|
368
|
+
const skillPath = path.join(directory, '../../skills/common/trace-recorder/SKILL.md');
|
|
369
|
+
if (!existsSync(skillPath)) return;
|
|
370
|
+
const uri = 'skill://trace-recorder/SKILL.md';
|
|
371
|
+
server.registerResource(
|
|
372
|
+
'trace-recorder',
|
|
373
|
+
uri,
|
|
374
|
+
{
|
|
375
|
+
title: 'Skill: trace-recorder',
|
|
376
|
+
description: 'trace-mcp-recorder 的自然语言调用说明',
|
|
377
|
+
mimeType: 'text/markdown'
|
|
378
|
+
},
|
|
379
|
+
async () => ({
|
|
380
|
+
contents: [{ uri, mimeType: 'text/markdown', text: readFileSync(skillPath, 'utf8') }]
|
|
381
|
+
})
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function createServer(options = {}) {
|
|
386
|
+
const recorder = options.recorder ?? new TraceRecorder(options);
|
|
387
|
+
const server = new McpServer(
|
|
388
|
+
{ name: 'trace-mcp-recorder', version: '1.0.0' },
|
|
389
|
+
{
|
|
390
|
+
instructions:
|
|
391
|
+
'Use start_trace_recording to open a visible Chromium window for manual testing. ' +
|
|
392
|
+
'When the user asks to stop recording, always call stop_trace_recording and return its replay link.'
|
|
393
|
+
}
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
server.registerTool(
|
|
397
|
+
'start_trace_recording',
|
|
398
|
+
{
|
|
399
|
+
title: '启动 Playwright Trace 人工录制',
|
|
400
|
+
description: '启动非无头 Chromium、开启 trace 并访问指定 URL,供测试人员在浏览器窗口中手动操作。',
|
|
401
|
+
inputSchema: {
|
|
402
|
+
url: z.string().min(1).describe('必填,被测页面的 HTTP(S) 地址'),
|
|
403
|
+
version: z.string().min(1).optional().describe('可选,被测版本号,例如 v2.1.0'),
|
|
404
|
+
title: z.string().min(1).optional().describe('可选,录制标题,例如登录页面'),
|
|
405
|
+
operator: z.string().min(1).optional().describe('可选,操作人;MCP 上下文中的身份优先')
|
|
406
|
+
},
|
|
407
|
+
annotations: {
|
|
408
|
+
readOnlyHint: false,
|
|
409
|
+
destructiveHint: false,
|
|
410
|
+
idempotentHint: false,
|
|
411
|
+
openWorldHint: true
|
|
412
|
+
}
|
|
413
|
+
},
|
|
414
|
+
async (input, extra) => {
|
|
415
|
+
try {
|
|
416
|
+
return toolText(await recorder.start(input, extra));
|
|
417
|
+
} catch (error) {
|
|
418
|
+
return toolError(error);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
server.registerTool(
|
|
424
|
+
'stop_trace_recording',
|
|
425
|
+
{
|
|
426
|
+
title: '停止 Playwright Trace 录制',
|
|
427
|
+
description: '停止当前 trace 并询问上传;确认后上传 trace.zip 和浏览器元数据,返回回放链接。',
|
|
428
|
+
inputSchema: {
|
|
429
|
+
confirm: z.enum(['upload', 'cancel']).optional().describe('首次停止不传;用户确认上传传 upload,拒绝上传传 cancel')
|
|
430
|
+
},
|
|
431
|
+
annotations: {
|
|
432
|
+
readOnlyHint: false,
|
|
433
|
+
destructiveHint: false,
|
|
434
|
+
idempotentHint: false,
|
|
435
|
+
openWorldHint: true
|
|
436
|
+
}
|
|
437
|
+
},
|
|
438
|
+
async ({ confirm } = {}) => {
|
|
439
|
+
try {
|
|
440
|
+
return toolText(await recorder.stop({ confirm }));
|
|
441
|
+
} catch (error) {
|
|
442
|
+
return toolError(error);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
);
|
|
446
|
+
|
|
447
|
+
registerSkillResources(server);
|
|
448
|
+
return server;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function main() {
|
|
452
|
+
await createServer().connect(new StdioServerTransport());
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
let isCommandEntry = false;
|
|
456
|
+
if (process.argv[1]) {
|
|
457
|
+
try {
|
|
458
|
+
isCommandEntry = realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
459
|
+
} catch {
|
|
460
|
+
isCommandEntry = false;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (isCommandEntry) {
|
|
465
|
+
main().catch(error => {
|
|
466
|
+
console.error('[trace-mcp-recorder] 服务启动失败:', errorMessage(error));
|
|
467
|
+
process.exitCode = 1;
|
|
468
|
+
});
|
|
469
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import test from 'node:test';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
7
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
8
|
+
import { TraceRecorder } from './index.js';
|
|
9
|
+
|
|
10
|
+
const directory = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const sessionUuid = '123e4567-e89b-42d3-a456-426614174000';
|
|
12
|
+
|
|
13
|
+
function createFixture(options = {}) {
|
|
14
|
+
const calls = [];
|
|
15
|
+
const browser = new EventEmitter();
|
|
16
|
+
const tracing = {
|
|
17
|
+
start: async options => calls.push(['trace-start', options]),
|
|
18
|
+
stop: async options => calls.push(['trace-stop', options])
|
|
19
|
+
};
|
|
20
|
+
const page = {
|
|
21
|
+
goto: async url => calls.push(['goto', url]),
|
|
22
|
+
evaluate: async () => 'Fixture User Agent',
|
|
23
|
+
viewportSize: () => ({ width: 1280, height: 720 }),
|
|
24
|
+
close: async () => calls.push(['page-close'])
|
|
25
|
+
};
|
|
26
|
+
const context = {
|
|
27
|
+
tracing,
|
|
28
|
+
newPage: async () => page,
|
|
29
|
+
close: async () => calls.push(['context-close'])
|
|
30
|
+
};
|
|
31
|
+
browser.newContext = async () => context;
|
|
32
|
+
browser.version = () => 'Fixture Chromium 1';
|
|
33
|
+
browser.close = async () => calls.push(['browser-close']);
|
|
34
|
+
|
|
35
|
+
const http = {
|
|
36
|
+
post: async (endpoint, body) => {
|
|
37
|
+
calls.push(['post', endpoint, body]);
|
|
38
|
+
if (endpoint === '/api/trace-sessions' && body !== form) return { data: { uuid: sessionUuid } };
|
|
39
|
+
if (options.uploadError) throw options.uploadError;
|
|
40
|
+
return { data: { fileUrl: '/private/traces/trace.zip' } };
|
|
41
|
+
},
|
|
42
|
+
patch: async (endpoint, body) => {
|
|
43
|
+
calls.push(['patch', endpoint, body]);
|
|
44
|
+
return { data: {} };
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
const form = {
|
|
48
|
+
append: (...args) => calls.push(['form-append', ...args]),
|
|
49
|
+
getHeaders: () => ({ 'content-type': 'multipart/form-data; boundary=fixture' })
|
|
50
|
+
};
|
|
51
|
+
const timer = { unref() {} };
|
|
52
|
+
const recorder = new TraceRecorder({
|
|
53
|
+
environment: {
|
|
54
|
+
BACKEND_BASE_URL: 'https://trace.example.test/root/',
|
|
55
|
+
MAX_RECORD_DURATION: '1000'
|
|
56
|
+
},
|
|
57
|
+
launchChromium: async options => {
|
|
58
|
+
calls.push(['launch', options]);
|
|
59
|
+
return browser;
|
|
60
|
+
},
|
|
61
|
+
createHttpClient: baseUrl => {
|
|
62
|
+
calls.push(['http-client', baseUrl]);
|
|
63
|
+
return http;
|
|
64
|
+
},
|
|
65
|
+
createFormData: () => form,
|
|
66
|
+
createReadStream: tracePath => ({ tracePath }),
|
|
67
|
+
makeTemporaryDirectory: async () => '/tmp/trace-fixture',
|
|
68
|
+
removeTemporaryDirectory: async temporaryDirectory => calls.push(['remove', temporaryDirectory]),
|
|
69
|
+
setTimer: (callback, duration) => {
|
|
70
|
+
calls.push(['timer', duration]);
|
|
71
|
+
timer.callback = callback;
|
|
72
|
+
return timer;
|
|
73
|
+
},
|
|
74
|
+
clearTimer: value => calls.push(['clear-timer', value]),
|
|
75
|
+
platformInfo: () => ({ platform: 'fixture-os' })
|
|
76
|
+
});
|
|
77
|
+
return { browser, calls, recorder, timer };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
test('starts, records metadata, uploads, completes and cleans up one session', async () => {
|
|
81
|
+
const { calls, recorder } = createFixture();
|
|
82
|
+
const started = await recorder.start(
|
|
83
|
+
{
|
|
84
|
+
url: 'https://app.example.test/login',
|
|
85
|
+
version: 'v2.1.0',
|
|
86
|
+
title: 'bug3452',
|
|
87
|
+
operator: '传入操作人'
|
|
88
|
+
},
|
|
89
|
+
{ authInfo: { extra: { operator: '上下文操作人' } } }
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
assert.equal(started.uuid, undefined);
|
|
93
|
+
assert.equal(started.metadata.operator, '上下文操作人');
|
|
94
|
+
assert.equal(started.metadata.browserVersion, 'Fixture Chromium 1');
|
|
95
|
+
await assert.rejects(
|
|
96
|
+
recorder.start({ url: 'https://app.example.test/another' }),
|
|
97
|
+
/已有录制任务/
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const confirmation = await recorder.stop();
|
|
101
|
+
assert.match(confirmation.message, /是否上传/);
|
|
102
|
+
const stopped = await recorder.stop({ confirm: 'upload' });
|
|
103
|
+
assert.equal(
|
|
104
|
+
stopped.replayLink,
|
|
105
|
+
'https://trace.example.test/root/trace-viewer/?trace=/private/traces/trace.zip'
|
|
106
|
+
);
|
|
107
|
+
assert.equal(stopped.metadata.title, 'bug3452');
|
|
108
|
+
assert.ok(calls.some(call => call[0] === 'trace-start' && call[1].sources === true));
|
|
109
|
+
assert.ok(calls.some(call => call[0] === 'post' && call[1] === '/api/trace-sessions'));
|
|
110
|
+
assert.ok(calls.some(call => call[0] === 'form-append' && call[1] === 'target_url'));
|
|
111
|
+
assert.ok(calls.some(call => call[0] === 'remove' && call[1] === '/tmp/trace-fixture'));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('rejects missing configuration before contacting the backend', async () => {
|
|
115
|
+
const recorder = new TraceRecorder({ environment: {} });
|
|
116
|
+
await assert.rejects(recorder.start({ url: 'https://app.example.test' }), /BACKEND_BASE_URL/);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('cleans up when Chromium launch fails without a backend session', async () => {
|
|
120
|
+
const { calls, recorder } = createFixture();
|
|
121
|
+
recorder.launchChromium = async () => {
|
|
122
|
+
throw new Error('launch failed');
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
await assert.rejects(recorder.start({ url: 'https://app.example.test' }), /launch failed/);
|
|
126
|
+
assert.equal(calls.some(call => call[0] === 'post'), false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('marks the session failed and removes the local trace when upload fails', async () => {
|
|
130
|
+
const { calls, recorder } = createFixture({ uploadError: new Error('upload failed') });
|
|
131
|
+
await recorder.start({ url: 'https://app.example.test' });
|
|
132
|
+
|
|
133
|
+
await recorder.stop();
|
|
134
|
+
await assert.rejects(recorder.stop({ confirm: 'upload' }), /upload failed/);
|
|
135
|
+
assert.ok(calls.some(call => call[0] === 'remove' && call[1] === '/tmp/trace-fixture'));
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('automatically stops on timeout and returns that result on the next stop call', async () => {
|
|
139
|
+
const { recorder, timer } = createFixture();
|
|
140
|
+
await recorder.start({ url: 'https://app.example.test' });
|
|
141
|
+
timer.callback();
|
|
142
|
+
|
|
143
|
+
while (recorder.activeSession) await new Promise(resolve => setImmediate(resolve));
|
|
144
|
+
const result = await recorder.stop();
|
|
145
|
+
assert.equal(result.stoppedBy, 'timeout');
|
|
146
|
+
await assert.rejects(recorder.stop(), /当前没有正在录制/);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('exposes both tools and the bundled skill over stdio', async () => {
|
|
150
|
+
const client = new Client({ name: 'trace-mcp-recorder-test', version: '1.0.0' });
|
|
151
|
+
const transport = new StdioClientTransport({
|
|
152
|
+
command: process.execPath,
|
|
153
|
+
args: [path.join(directory, 'index.js')],
|
|
154
|
+
cwd: directory,
|
|
155
|
+
env: { ...process.env },
|
|
156
|
+
stderr: 'pipe'
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
await client.connect(transport);
|
|
161
|
+
assert.equal(client.getServerVersion()?.name, 'trace-mcp-recorder');
|
|
162
|
+
const { tools } = await client.listTools();
|
|
163
|
+
assert.deepEqual(
|
|
164
|
+
tools.map(tool => tool.name),
|
|
165
|
+
['start_trace_recording', 'stop_trace_recording']
|
|
166
|
+
);
|
|
167
|
+
const { resources } = await client.listResources();
|
|
168
|
+
assert.ok(resources.some(resource => resource.uri === 'skill://trace-recorder/SKILL.md'));
|
|
169
|
+
|
|
170
|
+
const result = await client.callTool({
|
|
171
|
+
name: 'start_trace_recording',
|
|
172
|
+
arguments: { url: 'https://app.example.test' }
|
|
173
|
+
});
|
|
174
|
+
assert.equal(result.isError, true);
|
|
175
|
+
assert.match(result.content[0].text, /BACKEND_BASE_URL/);
|
|
176
|
+
} finally {
|
|
177
|
+
await client.close();
|
|
178
|
+
}
|
|
179
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@autobest/trace-mcp-recorder",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "MCP server for recording and uploading manual Playwright traces",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "index.js",
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=20"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"start": "node index.js",
|
|
13
|
+
"lint": "node --check index.js && node --check index.test.js",
|
|
14
|
+
"test": "node --test index.test.js",
|
|
15
|
+
"verify": "npm run lint && npm test"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@modelcontextprotocol/sdk": "1.30.0",
|
|
19
|
+
"axios": "1.20.0",
|
|
20
|
+
"form-data": "4.0.6",
|
|
21
|
+
"playwright": "1.63.0",
|
|
22
|
+
"uuid": "14.0.2",
|
|
23
|
+
"zod": "4.4.3"
|
|
24
|
+
}
|
|
25
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@autobest-ui/agent",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.12",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Autobest Agent skills/plugins/mcp assets + sync cli",
|
|
6
6
|
"files": [
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"autobest-agent-sync": "./bin/sync-assets.mjs",
|
|
20
20
|
"autobest-code-mcp-pr": "./mcp/code-mcp-pr/index.js",
|
|
21
21
|
"autobest-azurepr-mcp": "./mcp/azurepr-mcp-bridge/index.js",
|
|
22
|
+
"autobest-trace-mcp-recorder": "./mcp/trace-mcp-recorder/index.js",
|
|
22
23
|
"autobest-delivery-setup": "./plugins/autobest-delivery/scripts/setup.mjs",
|
|
23
24
|
"autobest-rag-mcp": "./mcp/rag-mcp-bridge/index.js",
|
|
24
25
|
"figma-mcp-bridge": "./mcp/figma-mcp-bridge/src/index.js",
|
|
@@ -29,20 +30,26 @@
|
|
|
29
30
|
"mcp:code-mcp-pr": "node ./mcp/code-mcp-pr/index.js",
|
|
30
31
|
"mcp:figma": "node ./mcp/figma-mcp-bridge/src/index.js",
|
|
31
32
|
"mcp:rag": "node ./mcp/rag-mcp-bridge/index.js",
|
|
33
|
+
"mcp:trace-recorder": "node ./mcp/trace-mcp-recorder/index.js",
|
|
32
34
|
"test": "npm run test:skills && npm run test:mcp && npm run test:plugin",
|
|
33
35
|
"test:skills": "node --test ./bin/sync-assets.test.mjs ./bin/traceability-validators.test.mjs",
|
|
34
36
|
"test:mcp:azurepr": "node --test ./mcp/azurepr-mcp-bridge/index.test.js",
|
|
35
37
|
"test:mcp:code-mcp-pr": "node --test ./mcp/code-mcp-pr/index.test.js",
|
|
36
38
|
"test:mcp:figma": "node --test ./mcp/figma-mcp-bridge/index.test.js",
|
|
37
39
|
"test:mcp:rag": "node --test ./mcp/rag-mcp-bridge/index.test.js",
|
|
38
|
-
"test:mcp": "
|
|
40
|
+
"test:mcp:trace-recorder": "node --test ./mcp/trace-mcp-recorder/index.test.js",
|
|
41
|
+
"test:mcp": "npm run test:mcp:azurepr && npm run test:mcp:code-mcp-pr && npm run test:mcp:figma && npm run test:mcp:rag && npm run test:mcp:trace-recorder",
|
|
39
42
|
"test:plugin": "node --test ./plugins/autobest-delivery/scripts/setup.test.mjs ./plugins/figma-plugin/scripts/setup.test.mjs"
|
|
40
43
|
},
|
|
41
44
|
"dependencies": {
|
|
42
45
|
"@modelcontextprotocol/sdk": "1.30.0",
|
|
46
|
+
"axios": "1.20.0",
|
|
43
47
|
"diff": "9.0.0",
|
|
48
|
+
"form-data": "4.0.6",
|
|
44
49
|
"fs-extra": "11.3.0",
|
|
45
50
|
"mdast-util-from-markdown": "2.0.2",
|
|
51
|
+
"playwright": "1.63.0",
|
|
52
|
+
"uuid": "14.0.2",
|
|
46
53
|
"ws": "8.18.3",
|
|
47
54
|
"zod": "4.4.3"
|
|
48
55
|
},
|
package/skills/README.md
CHANGED
|
@@ -17,6 +17,7 @@ npx --yes --package=@autobest-ui/agent@latest autobest-agent-sync common
|
|
|
17
17
|
- `make-spec`
|
|
18
18
|
- `repo-setup`
|
|
19
19
|
- `review-from-docs`
|
|
20
|
+
- `trace-recorder`
|
|
20
21
|
- `ui-prd-scope`
|
|
21
22
|
|
|
22
23
|
其中 `ui-prd-scope` 使用 `REQ-<页面或Scope简称>-<三位序号>` 分配稳定需求 ID;`review-from-docs` 为每个 REQ 建立决策树,逐轮询问当前完整 frontier 并记录用户确认的结论;`make-spec` 只从已完成的 review 生成逐项可验收的 `spec.md` 和机器可校验的映射。后续交付角色只引用编号,不重新编号。
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: trace-recorder
|
|
3
|
+
description: 通过 trace-mcp-recorder 启动或停止 Playwright Trace 人工录制;当用户要求打开页面录制、停止录制或获取 trace 回放链接时使用。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Trace MCP Recorder
|
|
7
|
+
|
|
8
|
+
将用户的自然语言请求映射到 `trace-mcp-recorder` 的 MCP 工具。`url` 独立确定被测页面;`title` 是本次录制的标题或功能描述,也可以直接填写 Bug 编号(例如 `bug3452`)。系统不额外传递独立的 Bug 字段。
|
|
9
|
+
|
|
10
|
+
## 启动录制
|
|
11
|
+
|
|
12
|
+
当用户要求启动、开始或打开页面进行 trace 录制时:
|
|
13
|
+
|
|
14
|
+
1. 从原话提取 `url`、`version`、`title`、`operator`。支持 `key=value` 和自然语言表达,不改写用户给出的值。
|
|
15
|
+
2. `url` 必填;缺少时只询问目标页面地址。其他字段缺失时不要阻断录制。
|
|
16
|
+
3. 调用 `start_trace_recording`,只传入用户明确提供的可选字段。服务端会优先使用 MCP 上下文中的操作人身份。
|
|
17
|
+
4. 告知用户浏览器已打开,并保留工具返回的 `uuid` 和最大录制时长;不要代替用户操作页面。
|
|
18
|
+
|
|
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
|
+
|
|
21
|
+
## 停止录制
|
|
22
|
+
|
|
23
|
+
用户表达“停止录制”“结束 trace”“完成录制”等意图时,调用 `stop_trace_recording`。第一次调用只会停止录制并询问是否上传;不要擅自上传。用户明确回答“上传”后,再次调用 `stop_trace_recording` 并传入 `confirm=upload`。用户回答“不上传”时,调用该工具并传入 `confirm=cancel`。上传成功后,将回放链接和 `version`、`title`、`operator`、`browserVersion` 整理给用户。
|
|
24
|
+
|
|
25
|
+
若工具返回错误,如没有活动会话、后端不可达或上传失败,原样说明关键原因,不编造回放链接。
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
interface:
|
|
2
|
+
display_name: "trace-recorder"
|
|
3
|
+
short_description: "启动或停止 Playwright Trace 人工录制并返回回放链接"
|
|
4
|
+
default_prompt: "使用 $trace-recorder 启动指定页面的 Playwright Trace 人工录制。"
|
|
5
|
+
|
|
6
|
+
dependencies:
|
|
7
|
+
tools:
|
|
8
|
+
- type: "mcp"
|
|
9
|
+
value: "trace-mcp-recorder"
|
|
10
|
+
description: "启动、停止、上传 Playwright Trace 的本地 STDIO MCP 服务"
|
|
11
|
+
|