@szc-ft/mcp-szcd-client 0.26.0 → 0.27.1
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/agents/opencode-extension/agents/szcd-component-expert.md +9 -31
- package/agents/qwen-extension/agents/szcd-component-expert.md +9 -31
- package/agents/src/szcd-component-expert.md +9 -31
- package/agents/src/tools.json +2 -2
- package/agents/szcd-component-expert.md +9 -31
- package/agents/szcd-component-expert.qoder.md +10 -32
- package/agents/szcd-component-expert.trae.md +9 -31
- package/opencode-extension/agents/szcd-component-expert.md +9 -31
- package/opencode-extension/skills/local-browser-test/SKILL.md +70 -13
- package/opencode-extension/skills/local-browser-test/local-browser-executor.cjs +395 -0
- package/opencode-extension/skills/szcd-component-helper/SKILL.md +12 -13
- package/opencode-extension/skills/szcd-design-to-code/SKILL.md +73 -21
- package/package.json +1 -1
- package/qwen-extension/agents/szcd-component-expert.md +9 -31
- package/qwen-extension/qwen-extension.json +1 -1
- package/qwen-extension/skills/local-browser-test/SKILL.md +70 -13
- package/qwen-extension/skills/local-browser-test/local-browser-executor.cjs +395 -0
- package/qwen-extension/skills/szcd-component-helper/SKILL.md +12 -13
- package/qwen-extension/skills/szcd-design-to-code/SKILL.md +73 -21
- package/standard-skill/local-browser-test/SKILL.md +70 -13
- package/standard-skill/local-browser-test/local-browser-executor.cjs +395 -0
- package/standard-skill/szcd-component-helper/SKILL.md +12 -13
- package/standard-skill/szcd-design-to-code/SKILL.md +73 -21
|
@@ -34,12 +34,15 @@ Ant Design → ProComponents → Cover 层 → Wrapper 层 → ProPackages →
|
|
|
34
34
|
|
|
35
35
|
### 步骤1:架构认知(必做)
|
|
36
36
|
|
|
37
|
-
调用 `mcp__szcd-component-helper__get_architecture_overview`(detail="
|
|
37
|
+
调用 `mcp__szcd-component-helper__get_architecture_overview`(detail="full")一次性获取:
|
|
38
38
|
- `templatePatterns`:4种模板模式(TreeQueryTable/QueryTabsTables/LeftRight/UpDown)
|
|
39
39
|
- `llmMappingHints.commonMistakes`:LLM 常见映射错误
|
|
40
40
|
- `recommendedUsageOrder`:推荐使用顺序
|
|
41
|
+
- **`componentSummary`**:全量组件清单(cover/wrappers/proPackages/other/echarts),每个组件附带 `layoutRole`(页面定位/区域容器/可视化等)、`variantProps`(关键 variant 参数)、`styleInjection`(样式注入入口)、`useCases`(典型场景);other/echarts 层还附带 `compositionSlots`、`requiredHooks`、`chartType`、`sketchSignals` 等元信息——LLM 据此推理组件候选
|
|
42
|
+
- **`styleMapping`**:节点样式 → 组件样式的映射规则(nodeStyleRules / injectionMechanisms / selectorMap)
|
|
43
|
+
- **`assetExtraction`**:图标/位图/图表色提取规则(bitmapRules / iconWorkflow / chartColorMapping)
|
|
41
44
|
|
|
42
|
-
根据返回的 `templatePatterns`
|
|
45
|
+
根据返回的 `templatePatterns` 初步判断用户需求匹配哪个模板,并在步骤 3.5 用 `componentSummary` 推理组件候选列表。
|
|
43
46
|
|
|
44
47
|
### 步骤2:理解需求(必做)
|
|
45
48
|
|
|
@@ -115,11 +118,50 @@ queryNodes({ pageId, type: ["text", "rectangle", "group"], limit: 500 })
|
|
|
115
118
|
|
|
116
119
|
**输入**:步骤 2.5 产出的 bitmap ID 列表 + shapePath 节点名列表
|
|
117
120
|
|
|
121
|
+
#### 🔒 资源优先级铁律(必读,违反则直接重做本步骤)
|
|
122
|
+
|
|
123
|
+
代码中的每一处图标、背景、装饰资源,必须按以下优先级**依次尝试**,前一级能命中就**不许跳到下一级**:
|
|
124
|
+
|
|
125
|
+
| 优先级 | 来源 | 适用判定 | 渲染产物 |
|
|
126
|
+
|---|---|---|---|
|
|
127
|
+
| **P0** | **sketch 自带 bitmap** | 节点是 `type=bitmap` | `extractBitmaps` 返回的 `siblingsAfterMe + position + nodeSize` 三维判定渲染产物:区域背景图 / 行内图标 / 独立装饰插图(详见下方表 6) |
|
|
128
|
+
| **P1** | **sketch 自带 shapePath(SVG 直出)** | 独立 shapePath(parent.type !== 'shapeGroup'),P1 未命中且 viewBox 校验通过 | `getShapePathData` → 内联 SVG |
|
|
129
|
+
| **P2** | **sketch 自带 shapePath(iconfont 命中)** | shapePath/shapeGroup 节点名能在 iconfont 库匹配到 | `matchIconFromName` → `<IconfontWapper type='icon-xxx' />` |
|
|
130
|
+
|
|
131
|
+
| **P3** | **antd 图标语义匹配** | 上面三级都失败:shapeGroup 无法直出、节点名无 iconfont 匹配 | `<XxxOutlined />` 按 contextTable 选语义最近的 antd 图标 |
|
|
132
|
+
| **P4** | **纯 CSS / div 渲染** | 上面四级全部失败,且元素是几何/色块(非图标语义) | `<div style={{...}}>` |
|
|
133
|
+
|
|
134
|
+
**禁止行为(违反 = 重做)**:
|
|
135
|
+
- ❌ sketch 里有 bitmap 节点,却用 `<div style={{backgroundColor}}>` 模拟(必须 P0 提取真图)
|
|
136
|
+
- ❌ shapePath 节点直接降级 antd 图标,没跑过 `matchIconFromName`(跳过 P1)
|
|
137
|
+
- ❌ 凭节点名"猜"语义就选 antd 图标,没看 parent 上下文(contextTable 在 `assetExtraction.iconWorkflow.contextTable`)
|
|
138
|
+
- ❌ 把 P0 提取出的 base64 写成 hardcoded url 而非 inline data URI(资源会丢)
|
|
139
|
+
|
|
140
|
+
**前置自检**:步骤 1 拿到的 `assetExtraction.bitmapRules` + `assetExtraction.iconWorkflow` 是上述铁律的服务端权威定义;本步开始前必须已加载,否则回到步骤 1 调 `get_architecture_overview(detail="full")`。
|
|
141
|
+
|
|
118
142
|
**工具调用**:
|
|
119
143
|
|
|
120
144
|
1. `mcp__sketch-mcp-server__extractBitmaps`(bitmapIds)— 从 .sketch 解压目录提取 bitmap PNG 资源
|
|
121
|
-
-
|
|
122
|
-
-
|
|
145
|
+
- 返回(v1.7.0+):`{ bitmaps: [{ nodeId, name, base64, mimeType, size, fileName, position:{x,y}, nodeSize:{width,height}, parentId, parent:{id,name,type,size}, siblingsBeforeMe:[...], siblingsAfterMe:[...] }] }`
|
|
146
|
+
- **`siblingsAfterMe` 是 z-order 渲染在 bitmap 上方的兄弟节点**(Sketch `layers[]` 排在它之后的项),是判定"区域背景图 vs 独立装饰"的核心信号
|
|
147
|
+
- **不要再为每个 bitmap 单独跑 `getNodeInfo` 拿 position/parent**,这些字段在 extractBitmaps 返回里已有
|
|
148
|
+
|
|
149
|
+
##### 表 6:bitmap 用途判定与代码落地(必做)
|
|
150
|
+
|
|
151
|
+
每个 bitmap 都要按本表归类,**只看 `siblingsAfterMe` + `position` + `nodeSize/parent.size`**,不要凭名字猜。判定后必须落到代码(详见经验 9 的"资源落地校验")。
|
|
152
|
+
|
|
153
|
+
| # | 判定信号(联合判断) | 用途 | 代码落地方式 |
|
|
154
|
+
|---|---|---|---|
|
|
155
|
+
| 1 | `siblingsAfterMe.length > 0` **且** `nodeSize.width / parent.size.width >= 0.7` | **区域背景图**(容器底图,内容元素叠在上面) | `parent` 包一层 wrapper div,bitmap 作为 wrapper 的 `style={{backgroundImage:` `url(data:image/png;base64,${base64})` `, backgroundSize:'cover'}}`;`siblingsAfterMe` 作为 wrapper 子节点正常渲染 |
|
|
156
|
+
| 2 | `nodeSize.width <= 32 && nodeSize.height <= 32` **且** 同 parent 内有 text 兄弟(`siblingsBeforeMe + siblingsAfterMe` 含 text) | **行内图标**(表格行图标、按钮前缀图标等) | `<img src={`data:image/png;base64,${base64}`} style={{width:N,height:N}} />` 替换原本的 `<IconfontWapper>` 占位 |
|
|
157
|
+
| 3 | `siblingsAfterMe.length === 0 && siblingsBeforeMe.length === 0`(独占父容器) | **独立装饰插图**(占位图、空状态图等) | `<img src={`data:image/png;base64,${base64}`} style={{width:nodeSize.width,height:nodeSize.height}} />` 走文档流 |
|
|
158
|
+
| 4 | `siblingsAfterMe.length > 0` **但** 占父宽 < 70% | **嵌入式插图**(区域内某子块的背景) | 同 #1,但 wrapper 是该子块而非整个 parent;定位用 `position:'absolute', top:position.y, left:position.x` |
|
|
159
|
+
|
|
160
|
+
**反例(违反 = 重做)**:
|
|
161
|
+
- ❌ 把 #1 区域背景图渲染为独立 `<img>`(反馈 #14 的 `未编目资源` 493×64 y=52 错例)——丢失"内容叠在底图上"的视觉关系
|
|
162
|
+
- ❌ 把 #2 行内图标用 `backgroundImage` 写在 `<div>` 上——`<div>` 没设宽高就塌缩;用 `<img>` 自带尺寸更稳
|
|
163
|
+
- ❌ 同一 imageRef 多个 nodeId 引用,extractBitmaps 多次调用——只调一次(按 nodeId 列表去重前判断 `fileName`),渲染时复用 base64 引用
|
|
164
|
+
|
|
123
165
|
2. `mcp__sketch-mcp-server__matchIconFromName`(nodeNames, library="newFont")— 匹配 iconfont 图标库(v1.5.0+ 评分制)
|
|
124
166
|
- 输入 shapePath 节点名列表(如 `["表 icon", "新建文件夹", "编组11备份"]`)
|
|
125
167
|
- **评分机制**:compoundIcons 强映射 +100、keyword 精确 +20、keyword 包含 +10、semanticTag +3
|
|
@@ -239,6 +281,8 @@ queryNodes({ pageId, type: ["text", "rectangle", "group"], limit: 500 })
|
|
|
239
281
|
|
|
240
282
|
识别出图表后,**图例色块**应归入 ECharts `legend` 配置(不要用 div 硬编码),**柱体色**映射到 `series.itemStyle.color`。
|
|
241
283
|
|
|
284
|
+
**图表组件类型选择**:参考步骤 1 返回的 `componentSummary.echarts`,每项含 `chartType`(line/bar/pie 等)和 `sketchSignals`(典型 Sketch 特征),按 sketchSignals 反查 chartType 即可锁定组件名(如 `Line` / `Bar` / `Pie`)。
|
|
285
|
+
|
|
242
286
|
**经验7:矢量图形用 matchIconFromName 自动匹配,无需手动解压**
|
|
243
287
|
- `shapePath` / `shapeGroup` 在 sketch 节点中占比可能高达 46.8%,但 `renderNodeAsBase64` 不支持渲染
|
|
244
288
|
- **新方案**:`matchIconFromName(shapePathNames)` 自动查询 iconfont 库 → 返回匹配结果
|
|
@@ -255,6 +299,23 @@ queryNodes({ pageId, type: ["text", "rectangle", "group"], limit: 500 })
|
|
|
255
299
|
- 若必须用 CSS class,类名要能反向追到 Sketch 节点(如 `.segmentBlock-blue` 而非 `.beforebg`),且 class 内容必须**精确等于** rectangle 的 fill/border
|
|
256
300
|
- 反例排查:写完代码后 grep 一下 CSS class 名,出现 `.bg1/.box2/.before*` 这类无语义类名、且颜色和设计稿对不上 → 几乎确定是凭空兜底
|
|
257
301
|
|
|
302
|
+
**经验9:bitmap 资源落地校验(每张 PNG 必须对应一处代码引用)**
|
|
303
|
+
|
|
304
|
+
实战教训(反馈 #14,2026-06-15):`未编目资源` bitmap(493×64, y=52)首版被渲染为独立 `<img>` 装饰插图,实际上它是 64px 高统计区域的底图(`siblingsAfterMe` 含 7 个 text/rect 内容元素叠在上面)。9 张 PNG 资源拿到了 base64,但其中 1 张用错位置——这种"提取了但用错"的回退比"忘记提取"更隐蔽。
|
|
305
|
+
|
|
306
|
+
**校验流程(写代码后必跑)**:
|
|
307
|
+
|
|
308
|
+
1. 把 `extractBitmaps` 返回的每个 `bitmap` 按表 6 归类,列出"用途清单"(区域背景 N 个、行内图标 N 个、独立装饰 N 个)
|
|
309
|
+
2. 在生成的代码里 grep `data:image/png;base64`,统计实际引用数 ≥ 用途清单总数(去重后)
|
|
310
|
+
3. 对每个 #1 区域背景图核对:是否真的写在 wrapper div 的 `style.backgroundImage` 上?wrapper 内部是否包含 `siblingsAfterMe` 列出的子节点?
|
|
311
|
+
4. 对每个 #2 行内图标核对:原本在该位置的 `<IconfontWapper>` / antd 图标是否已被 `<img>` 替换?
|
|
312
|
+
5. 对每个 #3 独立装饰核对:是否在 parent 的纵向流中出现?
|
|
313
|
+
|
|
314
|
+
**反例(违反 = 重做)**:
|
|
315
|
+
- ❌ extractBitmaps 拿到 9 张 PNG,代码里只 grep 出 8 个 `data:image/png;base64` 引用——必有 1 张被丢
|
|
316
|
+
- ❌ #1 区域背景图被错放成独立 `<img>`,导致 `siblingsAfterMe` 的内容元素失去底图衬托(这是反馈 #14 的具体回退)
|
|
317
|
+
- ❌ 同 imageRef 多次出现(如 `文件备份2/3/4` 复用同一 PNG),代码生成了 N 份 base64 字符串而非引用同一个常量——文件大小翻倍
|
|
318
|
+
|
|
258
319
|
### 步骤2.6:布局推理(必做,写代码前的最后一道关)
|
|
259
320
|
|
|
260
321
|
⚠️ 这一步专治"数据全拿到却放错位置"。反馈 #11 的 5 处错位全部由跳过此步骤导致。
|
|
@@ -391,23 +452,14 @@ queryNodes({ pageId, type: ["text", "rectangle", "group"], limit: 500 })
|
|
|
391
452
|
- ❌ 在步骤 3 内部推理组件候选列表(应只产出图片描述)
|
|
392
453
|
- ❌ 跳过此环节直接调 `get_component_full_profile`(必须先有候选列表)
|
|
393
454
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
| 中间表头 + 数据行图层 | `TableOrList` |
|
|
403
|
-
| 弹窗/抽屉图层 | `ModelOrDrawer` |
|
|
404
|
-
| 页面标题 + 返回箭头 | `TitleAndBack` |
|
|
405
|
-
| 详情展示区域(只读字段) | `FormItemOrDetailItem(type="detail")` |
|
|
406
|
-
| 卡片列表 | `TableOrList(componentType="ProList")` |
|
|
407
|
-
| 可编辑表格 | `TableOrList(componentType="EditableProTable")` |
|
|
408
|
-
| 折线趋势图 | `Line` |
|
|
409
|
-
| 柱状对比图 | `Bar` |
|
|
410
|
-
| 饼图/环形图 | `Pie` |
|
|
455
|
+
**推理依据**:步骤 1 返回的 `componentSummary` 是组件候选的权威目录,每项含 `layoutRole` / `variantProps` / `useCases` / `sketchSignals`(图表层)。按以下顺序反查:
|
|
456
|
+
|
|
457
|
+
1. **大区切分** → 看 `componentSummary.other` 里 `layoutRole === '页面定位'` 的组件(TemplateMode)匹配 `templateType` 变体
|
|
458
|
+
2. **区域容器** → `layoutRole === '区域容器'` 的组件(LeftTree / Query / TableOrList / ModelOrDrawer 等),按 `useCases` 描述匹配 Sketch 区域语义
|
|
459
|
+
3. **图表组件** → `componentSummary.echarts[*].sketchSignals` 反查 `chartType`,锁定 Line / Bar / Pie / Scatter 等
|
|
460
|
+
4. **变体选择** → 同一组件的子类型(如 TableOrList 的 ProTable / ProList / EditableProTable)查 `variantProps` 配合 useCases
|
|
461
|
+
|
|
462
|
+
不要再依赖任何硬编码映射表——`componentSummary` 由组件库源码动态生成,是唯一可信源。
|
|
411
463
|
|
|
412
464
|
### ⚠️ TemplateMode 槽位传参铁律(高频踩坑)
|
|
413
465
|
|
|
@@ -12,7 +12,7 @@ compatibility:
|
|
|
12
12
|
## 触发条件
|
|
13
13
|
|
|
14
14
|
当用户说出以下关键词时启动浏览器测试:
|
|
15
|
-
- "测试一下"、"帮我验证"、"浏览器测试"
|
|
15
|
+
- "测试一下"、"帮我验证"、"浏览器测试"、"接管浏览器"
|
|
16
16
|
- "对比设计稿"、"还原度"、"看看效果"
|
|
17
17
|
- "功能测试"、"能不能正常用"
|
|
18
18
|
- "检查页面"、"页面渲染"
|
|
@@ -159,32 +159,89 @@ wujie 微前端子应用渲染在 blob URL iframe 中,**坐标点击无法到
|
|
|
159
159
|
- `~#myId` → 自动转为 `[id*="myId"]`
|
|
160
160
|
- 普通选择器(如 `.my-class`)保持精确匹配
|
|
161
161
|
|
|
162
|
-
|
|
162
|
+
## 实战经验补充
|
|
163
|
+
|
|
164
|
+
### wujie 微前端诊断要点
|
|
165
|
+
|
|
166
|
+
1. **子应用渲染在 blob URL iframe 中**,puppeteer 的 `page.frames()` 可以访问
|
|
167
|
+
2. **iframe 内的 console 监听**:不要用 `frame.page().on()`(puppeteer-core 无此方法),改用 `page.on('console')` + `msg.frame().url()` 判断来源
|
|
168
|
+
3. **生产环境 console 被屏蔽**:代码中 `console.log = () => {}` 导致 0 条消息。需提前注入 `localStorage.setItem('showConsoleLog', 'true')`
|
|
169
|
+
4. **pageerror 事件是关键**:未捕获的 JS 异常不会出现在 console 中,必须监听 `page.on('pageerror')`
|
|
170
|
+
5. **API 404/403 检查**:刷新页面后监听 `page.on('response')`,过滤 `resourceType === 'xhr'` 检查后端接口状态
|
|
171
|
+
|
|
172
|
+
### 常见诊断流程
|
|
173
|
+
|
|
174
|
+
```
|
|
175
|
+
1. --action list-pages 确认目标页面存在
|
|
176
|
+
2. --action diagnose 全面诊断(JS错误 + 网络 + 资源 + iframe)
|
|
177
|
+
3. --action api-check 专门检查 API 请求(含 iframe 内的请求)
|
|
178
|
+
4. 根据结果定位问题
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### puppeteer-core 注意事项
|
|
182
|
+
- **连接时必须设 `defaultViewport: null`**,否则浏览器窗口会被强制缩放到指定尺寸:
|
|
183
|
+
```js
|
|
184
|
+
// 正确:保持浏览器原始窗口大小
|
|
185
|
+
puppeteer.connect({ browserURL: 'http://localhost:9222', defaultViewport: null })
|
|
186
|
+
// 错误:会把浏览器视口改成 1440x900
|
|
187
|
+
puppeteer.connect({ browserURL: '...', defaultViewport: { width: 1440, height: 900 } })
|
|
188
|
+
```
|
|
163
189
|
- `page.$x()` 已移除,用 `evaluate` + `querySelectorAll` 替代
|
|
164
190
|
- SVG 元素的 `className` 是 `SVGAnimatedString` 对象而非字符串,需先检查 `typeof`
|
|
165
191
|
- 所有 `evaluate` 调用内部使用 `safeEval` 包装,自动处理 context destroyed 错误
|
|
166
192
|
|
|
167
193
|
## 执行命令
|
|
168
194
|
|
|
195
|
+
### 快速诊断(最常用)
|
|
196
|
+
```bash
|
|
197
|
+
NODE_PATH=~/.szcd-mcp/deps/node_modules node {skill_path}/local-browser-executor.cjs \
|
|
198
|
+
--action diagnose \
|
|
199
|
+
--url-contains "platform.aicityos.com" \
|
|
200
|
+
--wait 15000 \
|
|
201
|
+
--output-dir /tmp/browser-test-diagnose
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### 列出所有标签页
|
|
205
|
+
```bash
|
|
206
|
+
NODE_PATH=~/.szcd-mcp/deps/node_modules node {skill_path}/local-browser-executor.cjs \
|
|
207
|
+
--action list-pages
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### 检查 API 请求状态
|
|
211
|
+
```bash
|
|
212
|
+
NODE_PATH=~/.szcd-mcp/deps/node_modules node {skill_path}/local-browser-executor.cjs \
|
|
213
|
+
--action api-check \
|
|
214
|
+
--url-contains "platform.aicityos.com" \
|
|
215
|
+
--wait 15000
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### 截图
|
|
169
219
|
```bash
|
|
170
|
-
node {
|
|
171
|
-
--
|
|
172
|
-
--
|
|
173
|
-
--
|
|
220
|
+
NODE_PATH=~/.szcd-mcp/deps/node_modules node {skill_path}/local-browser-executor.cjs \
|
|
221
|
+
--action screenshot \
|
|
222
|
+
--url-contains "platform.aicityos.com" \
|
|
223
|
+
--filename current-page.png \
|
|
224
|
+
--output-dir /tmp/browser-test
|
|
174
225
|
```
|
|
175
226
|
|
|
176
|
-
|
|
227
|
+
### 执行测试计划 JSON
|
|
177
228
|
```bash
|
|
178
|
-
node {
|
|
229
|
+
NODE_PATH=~/.szcd-mcp/deps/node_modules node {skill_path}/local-browser-executor.cjs \
|
|
230
|
+
--action plan \
|
|
179
231
|
--plan-file /tmp/test-plan.json \
|
|
180
|
-
--output /tmp/browser-test-result.json
|
|
181
|
-
--base-url http://localhost:8088
|
|
232
|
+
--output /tmp/browser-test-result.json
|
|
182
233
|
```
|
|
183
234
|
|
|
184
235
|
### 可选参数
|
|
185
236
|
|
|
186
|
-
- `--cdp-url http://localhost:
|
|
187
|
-
- `--
|
|
237
|
+
- `--cdp-url http://localhost:9222`:CDP 地址(默认 9222)
|
|
238
|
+
- `--url <url>`:目标页面 URL(不存在则导航)
|
|
239
|
+
- `--url-contains <keyword>`:按关键词查找已打开的页面
|
|
240
|
+
- `--wait <ms>`:额外等待时间(默认 5000ms,微前端建议 15000)
|
|
241
|
+
- `--output <path>`:JSON 结果输出路径
|
|
242
|
+
- `--output-dir <dir>`:截图/产物目录
|
|
243
|
+
- `--filename <name>`:截图文件名
|
|
244
|
+
- `--full-page`:全页截图
|
|
188
245
|
|
|
189
246
|
## AI 语义断言(aiAssert)
|
|
190
247
|
|
|
@@ -216,7 +273,7 @@ node {client_install_path}/local-browser-executor.js \
|
|
|
216
273
|
- 后续所有项目共享复用,无需重复安装
|
|
217
274
|
- 如果自动安装失败,引导用户手动执行:
|
|
218
275
|
```bash
|
|
219
|
-
cd ~/.szcd-mcp/deps && npm install puppeteer-core pixelmatch pngjs sharp --registry=https://npmmirror.com
|
|
276
|
+
cd ~/.szcd-mcp/deps && npm install puppeteer-core pixelmatch pngjs sharp --registry=https://registry.npmmirror.com
|
|
220
277
|
```
|
|
221
278
|
|
|
222
279
|
## 结果解读
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* local-browser-executor.js - 本地浏览器自动化执行器
|
|
4
|
+
*
|
|
5
|
+
* 功能:
|
|
6
|
+
* --action diagnose 全面诊断(JS错误、网络请求、API检查、资源加载)
|
|
7
|
+
* --action list-pages 列出所有标签页
|
|
8
|
+
* --action screenshot 截图
|
|
9
|
+
* --action api-check 检查 API 请求状态
|
|
10
|
+
* --action plan 执行测试计划 JSON
|
|
11
|
+
*
|
|
12
|
+
* 连接方式:
|
|
13
|
+
* --cdp-url http://localhost:9222 (默认自动检测 9222)
|
|
14
|
+
* --auto-detect 自动检测 Chrome/Edge 进程
|
|
15
|
+
*
|
|
16
|
+
* 通用参数:
|
|
17
|
+
* --url <url> 目标页面 URL
|
|
18
|
+
* --url-contains <s> 按关键词查找已打开的页面
|
|
19
|
+
* --wait <ms> 额外等待时间(默认 5000)
|
|
20
|
+
* --output <path> 结果输出文件
|
|
21
|
+
* --output-dir <dir> 截图/产物输出目录
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const puppeteer = require('puppeteer-core');
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const http = require('http');
|
|
28
|
+
|
|
29
|
+
// ===== 参数解析 =====
|
|
30
|
+
const args = process.argv.slice(2);
|
|
31
|
+
function getArg(name, defaultVal) {
|
|
32
|
+
const idx = args.indexOf(`--${name}`);
|
|
33
|
+
if (idx === -1) return defaultVal;
|
|
34
|
+
return args[idx + 1] || defaultVal;
|
|
35
|
+
}
|
|
36
|
+
function hasFlag(name) {
|
|
37
|
+
return args.includes(`--${name}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const ACTION = getArg('action', 'diagnose');
|
|
41
|
+
const CDP_URL = getArg('cdp-url', 'http://localhost:9222');
|
|
42
|
+
const TARGET_URL = getArg('url', null);
|
|
43
|
+
const URL_CONTAINS = getArg('url-contains', null);
|
|
44
|
+
const WAIT_MS = parseInt(getArg('wait', '5000'), 10);
|
|
45
|
+
const OUTPUT = getArg('output', null);
|
|
46
|
+
const OUTPUT_DIR = getArg('output-dir', '/tmp/browser-test');
|
|
47
|
+
const PLAN_FILE = getArg('plan-file', null);
|
|
48
|
+
|
|
49
|
+
// ===== 工具函数 =====
|
|
50
|
+
function log(msg) { console.log(msg); }
|
|
51
|
+
function ensureDir(dir) { fs.mkdirSync(dir, { recursive: true }); }
|
|
52
|
+
|
|
53
|
+
function checkCDP(url) {
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
const req = http.get(url + '/json/version', { timeout: 2000 }, (res) => {
|
|
56
|
+
let data = '';
|
|
57
|
+
res.on('data', c => data += c);
|
|
58
|
+
res.on('end', () => {
|
|
59
|
+
try { resolve(JSON.parse(data)); } catch { resolve(null); }
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
req.on('error', () => resolve(null));
|
|
63
|
+
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function findPage(browser) {
|
|
68
|
+
const pages = await browser.pages();
|
|
69
|
+
if (TARGET_URL) {
|
|
70
|
+
for (const p of pages) {
|
|
71
|
+
if (p.url().includes(TARGET_URL) || p.url() === TARGET_URL) return p;
|
|
72
|
+
}
|
|
73
|
+
// 导航到目标 URL
|
|
74
|
+
const p = pages[0] || await browser.newPage();
|
|
75
|
+
await p.goto(TARGET_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
76
|
+
return p;
|
|
77
|
+
}
|
|
78
|
+
if (URL_CONTAINS) {
|
|
79
|
+
for (const p of pages) {
|
|
80
|
+
if (p.url().includes(URL_CONTAINS)) return p;
|
|
81
|
+
}
|
|
82
|
+
log(`[WARN] 未找到包含 "${URL_CONTAINS}" 的页面,使用第一个页面`);
|
|
83
|
+
}
|
|
84
|
+
// 找到非 about:blank 的页面
|
|
85
|
+
for (const p of pages) {
|
|
86
|
+
if (!p.url().startsWith('about:') && !p.url().startsWith('devtools:')) return p;
|
|
87
|
+
}
|
|
88
|
+
return pages[0];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ===== Action: list-pages =====
|
|
92
|
+
async function listPages(browser) {
|
|
93
|
+
const pages = await browser.pages();
|
|
94
|
+
const result = [];
|
|
95
|
+
for (let i = 0; i < pages.length; i++) {
|
|
96
|
+
const p = pages[i];
|
|
97
|
+
let title = '';
|
|
98
|
+
try { title = await p.title(); } catch {}
|
|
99
|
+
result.push({ index: i, url: p.url(), title });
|
|
100
|
+
log(`[${i}] ${p.url()}`);
|
|
101
|
+
if (title) log(` title: ${title}`);
|
|
102
|
+
}
|
|
103
|
+
return { pages: result, total: result.length };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ===== Action: diagnose =====
|
|
107
|
+
async function diagnose(browser) {
|
|
108
|
+
ensureDir(OUTPUT_DIR);
|
|
109
|
+
const page = await findPage(browser);
|
|
110
|
+
log(`[PAGE] ${page.url()}`);
|
|
111
|
+
|
|
112
|
+
// 收集数据
|
|
113
|
+
const pageErrors = [];
|
|
114
|
+
const consoleMessages = [];
|
|
115
|
+
const failedRequests = [];
|
|
116
|
+
const apiRequests = [];
|
|
117
|
+
const resourceRequests = [];
|
|
118
|
+
|
|
119
|
+
page.on('pageerror', err => {
|
|
120
|
+
pageErrors.push({ message: err.message, stack: err.stack?.split('\n').slice(0, 5).join('\n') });
|
|
121
|
+
});
|
|
122
|
+
page.on('console', msg => {
|
|
123
|
+
const type = msg.type();
|
|
124
|
+
const text = msg.text().substring(0, 500);
|
|
125
|
+
consoleMessages.push({ type, text });
|
|
126
|
+
});
|
|
127
|
+
page.on('response', resp => {
|
|
128
|
+
const url = resp.url();
|
|
129
|
+
const status = resp.status();
|
|
130
|
+
const type = resp.request().resourceType();
|
|
131
|
+
if (type === 'xhr' || type === 'fetch') {
|
|
132
|
+
apiRequests.push({ status, url, method: resp.request().method() });
|
|
133
|
+
}
|
|
134
|
+
if (type === 'stylesheet' || type === 'script') {
|
|
135
|
+
resourceRequests.push({ status, url, type });
|
|
136
|
+
}
|
|
137
|
+
if (status >= 400) {
|
|
138
|
+
failedRequests.push({ status, url, type });
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
page.on('requestfailed', req => {
|
|
142
|
+
failedRequests.push({ status: 'ABORTED', url: req.url(), error: req.failure()?.errorText });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// 刷新页面收集
|
|
146
|
+
log('[INFO] 刷新页面收集诊断数据...');
|
|
147
|
+
try {
|
|
148
|
+
await page.reload({ waitUntil: 'load', timeout: 30000 });
|
|
149
|
+
} catch (e) {
|
|
150
|
+
log(`[WARN] 刷新超时: ${e.message.substring(0, 100)}`);
|
|
151
|
+
}
|
|
152
|
+
await new Promise(r => setTimeout(r, WAIT_MS));
|
|
153
|
+
|
|
154
|
+
// 截图
|
|
155
|
+
const screenshotPath = path.join(OUTPUT_DIR, 'diagnose-screenshot.png');
|
|
156
|
+
await page.screenshot({ path: screenshotPath, fullPage: false });
|
|
157
|
+
log(`[OK] 截图: ${screenshotPath}`);
|
|
158
|
+
|
|
159
|
+
// 检查 iframe
|
|
160
|
+
const frames = page.frames();
|
|
161
|
+
const iframeInfo = [];
|
|
162
|
+
for (const frame of frames) {
|
|
163
|
+
const url = frame.url();
|
|
164
|
+
if (url === page.url() || url === 'about:blank') continue;
|
|
165
|
+
let bodyText = '';
|
|
166
|
+
let rootContent = '';
|
|
167
|
+
try {
|
|
168
|
+
const info = await frame.evaluate(() => ({
|
|
169
|
+
bodyText: document.body?.innerText?.substring(0, 200) || '',
|
|
170
|
+
rootHTML: document.getElementById('root')?.innerHTML?.substring(0, 200) || 'no root',
|
|
171
|
+
hasReactRoot: !document.getElementById('root')?.innerHTML?.includes('loading-spinner'),
|
|
172
|
+
antdElements: document.querySelectorAll('[class*="ant-"]').length,
|
|
173
|
+
}));
|
|
174
|
+
bodyText = info.bodyText;
|
|
175
|
+
rootContent = info.rootHTML;
|
|
176
|
+
iframeInfo.push({ url, ...info });
|
|
177
|
+
} catch (e) {
|
|
178
|
+
iframeInfo.push({ url, error: e.message.substring(0, 100) });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// 输出结果
|
|
183
|
+
const errors = consoleMessages.filter(m => m.type === 'error');
|
|
184
|
+
const warnings = consoleMessages.filter(m => m.type === 'warning');
|
|
185
|
+
|
|
186
|
+
const result = {
|
|
187
|
+
url: page.url(),
|
|
188
|
+
timestamp: new Date().toISOString(),
|
|
189
|
+
screenshot: screenshotPath,
|
|
190
|
+
pageErrors,
|
|
191
|
+
consoleErrors: errors,
|
|
192
|
+
consoleWarnings: warnings,
|
|
193
|
+
consoleTotal: consoleMessages.length,
|
|
194
|
+
failedRequests,
|
|
195
|
+
apiRequests,
|
|
196
|
+
resourceStatus: resourceRequests.map(r => ({
|
|
197
|
+
status: r.status, type: r.type, name: r.url.split('/').pop()
|
|
198
|
+
})),
|
|
199
|
+
iframes: iframeInfo,
|
|
200
|
+
summary: {
|
|
201
|
+
jsErrors: pageErrors.length,
|
|
202
|
+
consoleErrors: consoleMessages.filter(m => m.type === 'error').length,
|
|
203
|
+
failedNetwork: failedRequests.length,
|
|
204
|
+
totalAPI: apiRequests.length,
|
|
205
|
+
failedAPI: apiRequests.filter(r => r.status >= 400).length,
|
|
206
|
+
totalResources: resourceRequests.length,
|
|
207
|
+
failedResources: resourceRequests.filter(r => r.status >= 400).length,
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// 打印摘要
|
|
212
|
+
log('\n========== 诊断结果 ==========');
|
|
213
|
+
log(`JS 异常: ${result.summary.jsErrors}`);
|
|
214
|
+
pageErrors.forEach(e => log(` [PAGEERROR] ${e.message}`));
|
|
215
|
+
log(`Console 错误: ${errors.length}`);
|
|
216
|
+
errors.forEach(e => log(` [ERROR] ${e.text}`));
|
|
217
|
+
log(`Console 警告: ${warnings.length}`);
|
|
218
|
+
warnings.forEach(e => log(` [WARN] ${e.text}`));
|
|
219
|
+
log(`Console 消息总数: ${consoleMessages.length}`);
|
|
220
|
+
log(`失败网络请求: ${result.summary.failedNetwork}`);
|
|
221
|
+
failedRequests.forEach(r => log(` [${r.status}] ${r.url}`));
|
|
222
|
+
log(`API 请求: ${result.summary.totalAPI} (失败: ${result.summary.failedAPI})`);
|
|
223
|
+
apiRequests.forEach(r => {
|
|
224
|
+
const marker = r.status >= 400 ? '❌' : '✅';
|
|
225
|
+
log(` ${marker} [${r.status}] ${r.method} ${r.url}`);
|
|
226
|
+
});
|
|
227
|
+
log(`CSS/JS 资源: ${result.summary.totalResources} (失败: ${result.summary.failedResources})`);
|
|
228
|
+
resourceRequests.filter(r => r.status >= 400).forEach(r => log(` ❌ [${r.status}] ${r.url}`));
|
|
229
|
+
if (iframeInfo.length > 0) {
|
|
230
|
+
log(`\niframe 信息:`);
|
|
231
|
+
iframeInfo.forEach(f => log(` ${f.url} → ${f.error || (f.hasReactRoot ? 'React已挂载' : '未挂载')}`));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// 保存结果
|
|
235
|
+
if (OUTPUT) {
|
|
236
|
+
fs.writeFileSync(OUTPUT, JSON.stringify(result, null, 2));
|
|
237
|
+
log(`\n[OK] 结果已保存: ${OUTPUT}`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return result;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ===== Action: screenshot =====
|
|
244
|
+
async function screenshot(browser) {
|
|
245
|
+
ensureDir(OUTPUT_DIR);
|
|
246
|
+
const page = await findPage(browser);
|
|
247
|
+
const filename = getArg('filename', 'screenshot.png');
|
|
248
|
+
const fullPath = path.join(OUTPUT_DIR, filename);
|
|
249
|
+
const fullPage = hasFlag('full-page');
|
|
250
|
+
await page.screenshot({ path: fullPath, fullPage });
|
|
251
|
+
log(`[OK] 截图已保存: ${fullPath}`);
|
|
252
|
+
return { filepath: fullPath };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ===== Action: api-check =====
|
|
256
|
+
async function apiCheck(browser) {
|
|
257
|
+
const page = await findPage(browser);
|
|
258
|
+
log(`[PAGE] ${page.url()}`);
|
|
259
|
+
|
|
260
|
+
const requests = [];
|
|
261
|
+
const failed = [];
|
|
262
|
+
|
|
263
|
+
page.on('response', resp => {
|
|
264
|
+
const type = resp.request().resourceType();
|
|
265
|
+
if (type === 'xhr' || type === 'fetch') {
|
|
266
|
+
requests.push({
|
|
267
|
+
status: resp.status(),
|
|
268
|
+
url: resp.url(),
|
|
269
|
+
method: resp.request().method(),
|
|
270
|
+
frameUrl: resp.request().frame()?.url?.() || '',
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
page.on('requestfailed', req => {
|
|
275
|
+
failed.push({ url: req.url(), error: req.failure()?.errorText });
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
log('[INFO] 刷新页面收集 API 请求...');
|
|
279
|
+
try { await page.reload({ waitUntil: 'load', timeout: 30000 }); } catch {}
|
|
280
|
+
await new Promise(r => setTimeout(r, WAIT_MS));
|
|
281
|
+
|
|
282
|
+
log('\n========== API 请求 ==========');
|
|
283
|
+
requests.forEach(r => {
|
|
284
|
+
const marker = r.status >= 400 ? '❌' : '✅';
|
|
285
|
+
const isIframe = r.frameUrl && r.frameUrl !== page.url() ? ' [iframe]' : '';
|
|
286
|
+
log(`${marker} [${r.status}] ${r.method} ${r.url}${isIframe}`);
|
|
287
|
+
});
|
|
288
|
+
if (failed.length > 0) {
|
|
289
|
+
log('\n========== 失败请求 ==========');
|
|
290
|
+
failed.forEach(r => log(`[FAILED] ${r.url} → ${r.error}`));
|
|
291
|
+
}
|
|
292
|
+
log(`\n总计: ${requests.length} 请求, 失败: ${requests.filter(r => r.status >= 400).length + failed.length}`);
|
|
293
|
+
|
|
294
|
+
return { requests, failed };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ===== Action: plan =====
|
|
298
|
+
async function executePlan(browser) {
|
|
299
|
+
const planPath = PLAN_FILE;
|
|
300
|
+
if (!planPath) { log('[ERROR] --plan-file 未指定'); return; }
|
|
301
|
+
const plan = JSON.parse(fs.readFileSync(planPath, 'utf-8'));
|
|
302
|
+
ensureDir(plan.options?.outputDir || OUTPUT_DIR);
|
|
303
|
+
|
|
304
|
+
log(`[PLAN] ${plan.description || plan.planId}`);
|
|
305
|
+
log(`[STEPS] ${plan.steps.length} 个步骤`);
|
|
306
|
+
|
|
307
|
+
const results = [];
|
|
308
|
+
let currentPage = await findPage(browser);
|
|
309
|
+
|
|
310
|
+
for (let i = 0; i < plan.steps.length; i++) {
|
|
311
|
+
const step = plan.steps[i];
|
|
312
|
+
const start = Date.now();
|
|
313
|
+
try {
|
|
314
|
+
let result = {};
|
|
315
|
+
switch (step.type) {
|
|
316
|
+
case 'navigate':
|
|
317
|
+
await currentPage.goto(step.url, { waitUntil: step.waitUntil || 'domcontentloaded', timeout: 30000 });
|
|
318
|
+
result = { url: currentPage.url() };
|
|
319
|
+
break;
|
|
320
|
+
case 'screenshot':
|
|
321
|
+
const fp = path.join(plan.options?.outputDir || OUTPUT_DIR, step.filename || `step-${i}.png`);
|
|
322
|
+
await currentPage.screenshot({ path: fp, fullPage: step.fullPage });
|
|
323
|
+
result = { filepath: fp };
|
|
324
|
+
break;
|
|
325
|
+
case 'waitFor':
|
|
326
|
+
if (step.selector) await currentPage.waitForSelector(step.selector, { timeout: step.timeout || 10000 });
|
|
327
|
+
result = { matched: true };
|
|
328
|
+
break;
|
|
329
|
+
case 'evaluate':
|
|
330
|
+
const evalResult = await currentPage.evaluate(step.expression);
|
|
331
|
+
result = { result: evalResult };
|
|
332
|
+
break;
|
|
333
|
+
case 'click':
|
|
334
|
+
await currentPage.click(step.selector);
|
|
335
|
+
result = { clicked: true };
|
|
336
|
+
break;
|
|
337
|
+
case 'checkElement':
|
|
338
|
+
const count = await currentPage.$$eval(step.selector, els => els.length).catch(() => 0);
|
|
339
|
+
const passed = step.expect?.minCount ? count >= step.expect.minCount : count > 0;
|
|
340
|
+
result = { passed, elementCount: count };
|
|
341
|
+
break;
|
|
342
|
+
default:
|
|
343
|
+
result = { skipped: true, reason: `unknown step type: ${step.type}` };
|
|
344
|
+
}
|
|
345
|
+
const duration = Date.now() - start;
|
|
346
|
+
results.push({ index: i, step: step.type, status: 'PASS', duration, ...result });
|
|
347
|
+
log(` [${i + 1}/${plan.steps.length}] ✅ ${step.type} (${duration}ms)`);
|
|
348
|
+
} catch (e) {
|
|
349
|
+
const duration = Date.now() - start;
|
|
350
|
+
results.push({ index: i, step: step.type, status: 'FAIL', duration, error: e.message });
|
|
351
|
+
log(` [${i + 1}/${plan.steps.length}] ❌ ${step.type} (${duration}ms) → ${e.message.substring(0, 100)}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const summary = {
|
|
356
|
+
total: results.length,
|
|
357
|
+
passed: results.filter(r => r.status === 'PASS').length,
|
|
358
|
+
failed: results.filter(r => r.status === 'FAIL').length,
|
|
359
|
+
};
|
|
360
|
+
log(`\n[RESULT] ${summary.passed}/${summary.total} 通过`);
|
|
361
|
+
|
|
362
|
+
if (OUTPUT) {
|
|
363
|
+
fs.writeFileSync(OUTPUT, JSON.stringify({ steps: results, summary }, null, 2));
|
|
364
|
+
}
|
|
365
|
+
return { steps: results, summary };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ===== 主入口 =====
|
|
369
|
+
(async () => {
|
|
370
|
+
// 检测 CDP
|
|
371
|
+
const cdpInfo = await checkCDP(CDP_URL);
|
|
372
|
+
if (!cdpInfo) {
|
|
373
|
+
log(`[ERROR] 无法连接到 CDP: ${CDP_URL}`);
|
|
374
|
+
log('请确保浏览器已启动调试端口:');
|
|
375
|
+
log(' Windows Edge: "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe" --remote-debugging-port=9222');
|
|
376
|
+
log(' Windows Chrome: "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe" --remote-debugging-port=9222');
|
|
377
|
+
process.exit(1);
|
|
378
|
+
}
|
|
379
|
+
log(`[CDP] ${cdpInfo.Browser || 'connected'} (${CDP_URL})`);
|
|
380
|
+
|
|
381
|
+
const browser = await puppeteer.connect({ browserURL: CDP_URL, defaultViewport: null });
|
|
382
|
+
|
|
383
|
+
let result;
|
|
384
|
+
switch (ACTION) {
|
|
385
|
+
case 'list-pages': result = await listPages(browser); break;
|
|
386
|
+
case 'diagnose': result = await diagnose(browser); break;
|
|
387
|
+
case 'screenshot': result = await screenshot(browser); break;
|
|
388
|
+
case 'api-check': result = await apiCheck(browser); break;
|
|
389
|
+
case 'plan': result = await executePlan(browser); break;
|
|
390
|
+
default: log(`[ERROR] 未知 action: ${ACTION}`);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
browser.disconnect();
|
|
394
|
+
log('\n[DONE]');
|
|
395
|
+
})();
|