@szc-ft/mcp-szcd-client 0.18.0 → 0.19.0
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/qwen-extension/agents/szcd-component-expert.md +22 -0
- package/agents/src/szcd-component-expert.md +32 -2
- package/agents/szcd-component-expert.md +22 -0
- package/agents/szcd-component-expert.qoder.md +29 -2
- package/agents/szcd-component-expert.trae.md +29 -2
- package/commands/szcd-mcp-browser-test.md +57 -0
- package/commands/szcd-mcp-feedback.md +42 -0
- package/lib/browser-engine.js +379 -0
- package/lib/chrome-finder.js +125 -0
- package/lib/visual-compare.js +189 -0
- package/local-browser-executor.js +239 -0
- package/package.json +9 -1
- package/qwen-extension/agents/szcd-component-expert.md +22 -0
- package/qwen-extension/commands/szcd-mcp-browser-test.md +57 -0
- package/qwen-extension/commands/szcd-mcp-feedback.md +42 -0
- package/qwen-extension/qwen-extension.json +1 -1
- package/qwen-extension/skills/local-api-tool/SKILL.md +14 -1
- package/qwen-extension/skills/local-browser-test/SKILL.md +153 -0
- package/standard-skill/local-api-tool/SKILL.md +14 -1
- package/standard-skill/local-browser-test/SKILL.md +153 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 浏览器控制引擎 - 双模式(connect / launch)
|
|
3
|
+
*
|
|
4
|
+
* 模式 A: connect(优先)
|
|
5
|
+
* 连接用户已启动的调试 Chrome(--remote-debugging-port=9222)
|
|
6
|
+
* 天然继承登录态/Cookie/SSO/微前端环境
|
|
7
|
+
*
|
|
8
|
+
* 模式 B: launch(回退)
|
|
9
|
+
* 启动新的 headless Chrome,需要用户提供可访问的 URL
|
|
10
|
+
*
|
|
11
|
+
* 依赖(optionalDependencies):puppeteer-core
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { findChrome, getChromeInstallGuide } from "./chrome-finder.js";
|
|
15
|
+
|
|
16
|
+
export class BrowserEngine {
|
|
17
|
+
constructor() {
|
|
18
|
+
this.browser = null;
|
|
19
|
+
this.page = null;
|
|
20
|
+
this.mode = null; // 'connect' | 'launch'
|
|
21
|
+
this.ownedBrowser = false; // launch 模式为 true,决定关闭时是否 kill
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 初始化:自动选择 connect 或 launch 模式
|
|
26
|
+
* @param {object} options
|
|
27
|
+
* @param {string} [options.cdpUrl='http://localhost:9222'] - CDP 调试地址
|
|
28
|
+
* @param {string} [options.pageUrl] - 目标页面 URL(connect 时用于匹配标签页)
|
|
29
|
+
* @param {object} [options.viewport] - 视口尺寸 { width, height }
|
|
30
|
+
* @returns {Promise<object>} 初始化结果
|
|
31
|
+
*/
|
|
32
|
+
async init(options = {}) {
|
|
33
|
+
const cdpUrl = options.cdpUrl || "http://localhost:9222";
|
|
34
|
+
|
|
35
|
+
// 优先尝试 connect 模式
|
|
36
|
+
if (await this.isDebuggerAvailable(cdpUrl)) {
|
|
37
|
+
return this.connect(cdpUrl, options);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 回退到 launch 模式
|
|
41
|
+
return this.launch(options);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 检查调试端口是否可用
|
|
46
|
+
*/
|
|
47
|
+
async isDebuggerAvailable(cdpUrl) {
|
|
48
|
+
try {
|
|
49
|
+
const res = await fetch(`${cdpUrl}/json/version`, {
|
|
50
|
+
signal: AbortSignal.timeout(2000),
|
|
51
|
+
});
|
|
52
|
+
return res.ok;
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 模式 A:连接到用户已有的 Chrome
|
|
60
|
+
*/
|
|
61
|
+
async connect(cdpUrl, options = {}) {
|
|
62
|
+
const puppeteer = await loadPuppeteer();
|
|
63
|
+
this.browser = await puppeteer.connect({ browserURL: cdpUrl });
|
|
64
|
+
this.mode = "connect";
|
|
65
|
+
this.ownedBrowser = false;
|
|
66
|
+
|
|
67
|
+
// 获取页面:优先使用指定 URL 匹配的标签页
|
|
68
|
+
const pages = await this.browser.pages();
|
|
69
|
+
if (options.pageUrl) {
|
|
70
|
+
this.page = pages.find((p) => p.url().includes(options.pageUrl))
|
|
71
|
+
|| await this.browser.newPage();
|
|
72
|
+
} else {
|
|
73
|
+
// 选择第一个非 chrome:// 的页面
|
|
74
|
+
this.page = pages.find((p) => !p.url().startsWith("chrome://"))
|
|
75
|
+
|| pages[0];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (options.viewport && this.page) {
|
|
79
|
+
await this.page.setViewport(options.viewport);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
mode: "connect",
|
|
84
|
+
cdpUrl,
|
|
85
|
+
currentUrl: this.page?.url() || null,
|
|
86
|
+
totalPages: pages.length,
|
|
87
|
+
allPageUrls: pages.map((p) => p.url()),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 模式 B:启动新的 Chrome
|
|
93
|
+
*/
|
|
94
|
+
async launch(options = {}) {
|
|
95
|
+
const puppeteer = await loadPuppeteer();
|
|
96
|
+
const chrome = findChrome();
|
|
97
|
+
|
|
98
|
+
if (!chrome.path) {
|
|
99
|
+
const guide = getChromeInstallGuide();
|
|
100
|
+
const error = new Error(
|
|
101
|
+
`${guide.message}\n安装方式:\n${guide.installCommands.join("\n")}\n\n` +
|
|
102
|
+
`或使用 connect 模式:以 --remote-debugging-port=9222 启动 Chrome`
|
|
103
|
+
);
|
|
104
|
+
error.code = "CHROME_NOT_FOUND";
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
this.browser = await puppeteer.launch({
|
|
109
|
+
executablePath: chrome.path,
|
|
110
|
+
headless: true,
|
|
111
|
+
args: [
|
|
112
|
+
"--no-sandbox",
|
|
113
|
+
"--disable-setuid-sandbox",
|
|
114
|
+
"--disable-dev-shm-usage",
|
|
115
|
+
"--disable-gpu",
|
|
116
|
+
"--no-first-run",
|
|
117
|
+
"--no-default-browser-check",
|
|
118
|
+
],
|
|
119
|
+
});
|
|
120
|
+
this.page = await this.browser.newPage();
|
|
121
|
+
this.mode = "launch";
|
|
122
|
+
this.ownedBrowser = true;
|
|
123
|
+
|
|
124
|
+
if (options.viewport) {
|
|
125
|
+
await this.page.setViewport(options.viewport);
|
|
126
|
+
} else {
|
|
127
|
+
await this.page.setViewport({ width: 1440, height: 900 });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
mode: "launch",
|
|
132
|
+
browser: chrome.name,
|
|
133
|
+
chromePath: chrome.path,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 执行单个步骤
|
|
139
|
+
* @param {object} step - 步骤定义
|
|
140
|
+
* @param {object} context - 执行上下文 { outputDir }
|
|
141
|
+
* @returns {Promise<object>} 步骤执行结果
|
|
142
|
+
*/
|
|
143
|
+
async executeStep(step, context = {}) {
|
|
144
|
+
const startTime = Date.now();
|
|
145
|
+
try {
|
|
146
|
+
const result = await this._dispatchStep(step, context);
|
|
147
|
+
return {
|
|
148
|
+
...result,
|
|
149
|
+
step: step.type,
|
|
150
|
+
duration: Date.now() - startTime,
|
|
151
|
+
status: "PASS",
|
|
152
|
+
};
|
|
153
|
+
} catch (err) {
|
|
154
|
+
return {
|
|
155
|
+
step: step.type,
|
|
156
|
+
duration: Date.now() - startTime,
|
|
157
|
+
status: "FAIL",
|
|
158
|
+
error: err.message,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 步骤分发
|
|
165
|
+
*/
|
|
166
|
+
async _dispatchStep(step, context) {
|
|
167
|
+
switch (step.type) {
|
|
168
|
+
case "navigate": return this._navigate(step);
|
|
169
|
+
case "screenshot": return this._screenshot(step, context);
|
|
170
|
+
case "compare": return this._compare(step, context);
|
|
171
|
+
case "click": return this._click(step);
|
|
172
|
+
case "type": return this._type(step);
|
|
173
|
+
case "waitFor": return this._waitFor(step);
|
|
174
|
+
case "evaluate": return this._evaluate(step);
|
|
175
|
+
case "checkElement": return this._checkElement(step);
|
|
176
|
+
default:
|
|
177
|
+
throw new Error(`Unknown step type: ${step.type}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async _navigate(step) {
|
|
182
|
+
const start = Date.now();
|
|
183
|
+
await this.page.goto(step.url, {
|
|
184
|
+
waitUntil: step.waitUntil || "networkidle2",
|
|
185
|
+
timeout: step.timeout || 30000,
|
|
186
|
+
});
|
|
187
|
+
const title = await this.page.title();
|
|
188
|
+
return {
|
|
189
|
+
url: this.page.url(),
|
|
190
|
+
title,
|
|
191
|
+
loadTime: Date.now() - start,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async _screenshot(step, context) {
|
|
196
|
+
const fs = await import("node:fs");
|
|
197
|
+
const path = await import("node:path");
|
|
198
|
+
const outputDir = context.outputDir || "/tmp";
|
|
199
|
+
if (!fs.existsSync(outputDir)) {
|
|
200
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const filename = step.filename || `screenshot-${Date.now()}.png`;
|
|
204
|
+
const filepath = path.join(outputDir, filename);
|
|
205
|
+
|
|
206
|
+
await this.page.screenshot({
|
|
207
|
+
path: filepath,
|
|
208
|
+
fullPage: step.fullPage !== false,
|
|
209
|
+
...(step.clip ? { clip: step.clip } : {}),
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const viewport = this.page.viewport();
|
|
213
|
+
return { filepath, width: viewport.width, height: viewport.height };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async _compare(step, context) {
|
|
217
|
+
const { compareDesign } = await import("./visual-compare.js");
|
|
218
|
+
const fs = await import("node:fs");
|
|
219
|
+
const path = await import("node:path");
|
|
220
|
+
const outputDir = context.outputDir || "/tmp";
|
|
221
|
+
|
|
222
|
+
// 先截图
|
|
223
|
+
if (!fs.existsSync(outputDir)) {
|
|
224
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
225
|
+
}
|
|
226
|
+
const screenshotPath = path.join(outputDir, `compare-screenshot-${Date.now()}.png`);
|
|
227
|
+
await this.page.screenshot({ path: screenshotPath, fullPage: true });
|
|
228
|
+
|
|
229
|
+
// 执行对比
|
|
230
|
+
const result = await compareDesign(screenshotPath, step.designImagePath, {
|
|
231
|
+
threshold: step.threshold,
|
|
232
|
+
outputDir,
|
|
233
|
+
regions: step.regions,
|
|
234
|
+
page: this.page,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
return result;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async _click(step) {
|
|
241
|
+
const selector = step.selector;
|
|
242
|
+
await this.page.waitForSelector(selector, { timeout: step.timeout || 5000 });
|
|
243
|
+
|
|
244
|
+
if (step.waitForNavigation) {
|
|
245
|
+
await Promise.all([
|
|
246
|
+
this.page.waitForNavigation({ timeout: 10000 }),
|
|
247
|
+
this.page.click(selector),
|
|
248
|
+
]);
|
|
249
|
+
} else {
|
|
250
|
+
await this.page.click(selector);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const tagName = await this.page.$eval(selector, (el) => el.tagName).catch(() => "unknown");
|
|
254
|
+
return { clicked: true, selector, tagName };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async _type(step) {
|
|
258
|
+
const selector = step.selector;
|
|
259
|
+
await this.page.waitForSelector(selector, { timeout: step.timeout || 5000 });
|
|
260
|
+
|
|
261
|
+
if (step.clear) {
|
|
262
|
+
await this.page.click(selector, { clickCount: 3 });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
await this.page.type(selector, step.text);
|
|
266
|
+
const value = await this.page.$eval(selector, (el) => el.value).catch(() => "");
|
|
267
|
+
return { typed: true, selector, value };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async _waitFor(step) {
|
|
271
|
+
const timeout = step.timeout || 10000;
|
|
272
|
+
|
|
273
|
+
if (step.selector) {
|
|
274
|
+
await this.page.waitForSelector(step.selector, { timeout });
|
|
275
|
+
return { matched: true, type: "selector", value: step.selector };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (step.url) {
|
|
279
|
+
await this.page.waitForResponse(
|
|
280
|
+
(r) => r.url().includes(step.url),
|
|
281
|
+
{ timeout }
|
|
282
|
+
);
|
|
283
|
+
return { matched: true, type: "url", value: step.url };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
throw new Error("waitFor requires either 'selector' or 'url'");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async _evaluate(step) {
|
|
290
|
+
const result = await this.page.evaluate(step.expression);
|
|
291
|
+
return { result };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async _checkElement(step) {
|
|
295
|
+
const selector = step.selector;
|
|
296
|
+
const expect = step.expect || {};
|
|
297
|
+
|
|
298
|
+
const info = await this.page.evaluate((sel) => {
|
|
299
|
+
const els = document.querySelectorAll(sel);
|
|
300
|
+
return {
|
|
301
|
+
count: els.length,
|
|
302
|
+
visible: els.length > 0 && els[0].offsetParent !== null,
|
|
303
|
+
texts: Array.from(els).slice(0, 5).map((el) =>
|
|
304
|
+
el.textContent?.substring(0, 100) || ""
|
|
305
|
+
),
|
|
306
|
+
};
|
|
307
|
+
}, selector);
|
|
308
|
+
|
|
309
|
+
let passed = true;
|
|
310
|
+
const checks = {};
|
|
311
|
+
|
|
312
|
+
if (expect.visible !== undefined) {
|
|
313
|
+
checks.visible = { expected: expect.visible, actual: info.visible };
|
|
314
|
+
if (info.visible !== expect.visible) passed = false;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (expect.minCount !== undefined) {
|
|
318
|
+
checks.minCount = { expected: expect.minCount, actual: info.count };
|
|
319
|
+
if (info.count < expect.minCount) passed = false;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (expect.maxCount !== undefined) {
|
|
323
|
+
checks.maxCount = { expected: expect.maxCount, actual: info.count };
|
|
324
|
+
if (info.count > expect.maxCount) passed = false;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (expect.hasText !== undefined) {
|
|
328
|
+
const allText = info.texts.join(" ");
|
|
329
|
+
checks.hasText = { expected: expect.hasText, actual: allText };
|
|
330
|
+
if (!allText.includes(expect.hasText)) passed = false;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return {
|
|
334
|
+
passed,
|
|
335
|
+
selector,
|
|
336
|
+
checks,
|
|
337
|
+
elementCount: info.count,
|
|
338
|
+
visible: info.visible,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* 关闭浏览器
|
|
344
|
+
* connect 模式: disconnect(不关闭用户浏览器)
|
|
345
|
+
* launch 模式: close(关闭我们启动的浏览器)
|
|
346
|
+
*/
|
|
347
|
+
async close() {
|
|
348
|
+
if (!this.browser) return;
|
|
349
|
+
try {
|
|
350
|
+
if (this.ownedBrowser) {
|
|
351
|
+
await this.browser.close();
|
|
352
|
+
} else {
|
|
353
|
+
this.browser.disconnect();
|
|
354
|
+
}
|
|
355
|
+
} catch {
|
|
356
|
+
// ignore close errors
|
|
357
|
+
}
|
|
358
|
+
this.browser = null;
|
|
359
|
+
this.page = null;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* 动态加载 puppeteer-core
|
|
365
|
+
*/
|
|
366
|
+
async function loadPuppeteer() {
|
|
367
|
+
try {
|
|
368
|
+
const mod = await import("puppeteer-core");
|
|
369
|
+
return mod.default || mod;
|
|
370
|
+
} catch (err) {
|
|
371
|
+
const error = new Error(
|
|
372
|
+
`puppeteer-core 未安装。请执行:npm install puppeteer-core\n原始错误:${err.message}`
|
|
373
|
+
);
|
|
374
|
+
error.code = "DEPENDENCY_MISSING";
|
|
375
|
+
throw error;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export default BrowserEngine;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chrome 路径检测 - 跨平台查找 Chrome/Chromium 可执行文件
|
|
3
|
+
* 仅 launch 模式需要,connect 模式通过 CDP 连接不需要此模块
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import { execSync } from "node:child_process";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 跨平台 Chrome 路径候选列表
|
|
12
|
+
*/
|
|
13
|
+
const CHROME_PATHS = {
|
|
14
|
+
linux: [
|
|
15
|
+
"/usr/bin/google-chrome",
|
|
16
|
+
"/usr/bin/google-chrome-stable",
|
|
17
|
+
"/usr/bin/chromium",
|
|
18
|
+
"/usr/bin/chromium-browser",
|
|
19
|
+
"/snap/bin/chromium",
|
|
20
|
+
"/usr/local/bin/chrome",
|
|
21
|
+
],
|
|
22
|
+
darwin: [
|
|
23
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
24
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
25
|
+
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
|
26
|
+
],
|
|
27
|
+
win32: [
|
|
28
|
+
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
|
29
|
+
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
|
|
30
|
+
`${process.env.LOCALAPPDATA || ""}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
31
|
+
],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 通过 PATH 搜索命令
|
|
36
|
+
* @param {string} cmd - 命令名
|
|
37
|
+
* @returns {string|null}
|
|
38
|
+
*/
|
|
39
|
+
function findInPath(cmd) {
|
|
40
|
+
try {
|
|
41
|
+
const platform = os.platform();
|
|
42
|
+
const command = platform === "win32" ? `where ${cmd}` : `which ${cmd}`;
|
|
43
|
+
const result = execSync(command, { encoding: "utf8", timeout: 5000 }).trim();
|
|
44
|
+
const firstLine = result.split("\n")[0].trim();
|
|
45
|
+
if (firstLine && fs.existsSync(firstLine)) {
|
|
46
|
+
return firstLine;
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
// not found
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 查找本地 Chrome 浏览器路径
|
|
56
|
+
* @returns {{ path: string|null, name: string, error?: string }}
|
|
57
|
+
*/
|
|
58
|
+
export function findChrome() {
|
|
59
|
+
const platform = os.platform();
|
|
60
|
+
const candidates = CHROME_PATHS[platform] || CHROME_PATHS.linux;
|
|
61
|
+
|
|
62
|
+
// 1. 检查已知路径
|
|
63
|
+
for (const p of candidates) {
|
|
64
|
+
if (p && fs.existsSync(p)) {
|
|
65
|
+
const name = p.includes("Chromium") ? "Chromium" :
|
|
66
|
+
p.includes("Canary") ? "Chrome Canary" : "Google Chrome";
|
|
67
|
+
return { path: p, name };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 2. PATH 搜索
|
|
72
|
+
const pathCmds = platform === "win32"
|
|
73
|
+
? ["chrome", "chromium"]
|
|
74
|
+
: ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"];
|
|
75
|
+
|
|
76
|
+
for (const cmd of pathCmds) {
|
|
77
|
+
const found = findInPath(cmd);
|
|
78
|
+
if (found) {
|
|
79
|
+
const name = cmd.includes("chromium") ? "Chromium" : "Google Chrome";
|
|
80
|
+
return { path: found, name };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 3. 未找到
|
|
85
|
+
return {
|
|
86
|
+
path: null,
|
|
87
|
+
name: "unknown",
|
|
88
|
+
error: "CHROME_NOT_FOUND",
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 获取 Chrome 未找到时的安装引导信息
|
|
94
|
+
* @returns {object}
|
|
95
|
+
*/
|
|
96
|
+
export function getChromeInstallGuide() {
|
|
97
|
+
const platform = os.platform();
|
|
98
|
+
const guides = {
|
|
99
|
+
linux: {
|
|
100
|
+
message: "未检测到 Chrome/Chromium 浏览器",
|
|
101
|
+
installCommands: [
|
|
102
|
+
"Ubuntu/Debian: sudo apt install google-chrome-stable",
|
|
103
|
+
"Ubuntu/Debian (Chromium): sudo apt install chromium-browser",
|
|
104
|
+
"CentOS/RHEL: sudo yum install google-chrome-stable",
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
darwin: {
|
|
108
|
+
message: "未检测到 Chrome/Chromium 浏览器",
|
|
109
|
+
installCommands: [
|
|
110
|
+
"Homebrew: brew install --cask google-chrome",
|
|
111
|
+
"或从 https://www.google.com/chrome/ 下载",
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
win32: {
|
|
115
|
+
message: "未检测到 Chrome 浏览器",
|
|
116
|
+
installCommands: [
|
|
117
|
+
"从 https://www.google.com/chrome/ 下载安装",
|
|
118
|
+
"或 winget install Google.Chrome",
|
|
119
|
+
],
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
return guides[platform] || guides.linux;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export default { findChrome, getChromeInstallGuide };
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 视觉对比引擎 - pixelmatch + sharp
|
|
3
|
+
*
|
|
4
|
+
* 功能:
|
|
5
|
+
* - 全局像素对比,输出还原度评分
|
|
6
|
+
* - 分区域对比(按 CSS selector 裁剪后分别评分)
|
|
7
|
+
* - 三色 diff 图输出(红=真实差异,黄=抗锯齿,蓝=轻微色差)
|
|
8
|
+
* - 自动 resize 设计稿到截图尺寸
|
|
9
|
+
*
|
|
10
|
+
* 依赖(optionalDependencies):
|
|
11
|
+
* - sharp: 图片处理和 resize
|
|
12
|
+
* - pixelmatch: 像素对比
|
|
13
|
+
* - pngjs: PNG 编解码
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 全局视觉对比
|
|
21
|
+
* @param {string} screenshotPath - 截图文件路径
|
|
22
|
+
* @param {string} designImagePath - 设计稿图片路径
|
|
23
|
+
* @param {object} options
|
|
24
|
+
* @param {number} [options.threshold=0.1] - 颜色差异阈值 (0-1)
|
|
25
|
+
* @param {string} [options.outputDir] - diff 图输出目录
|
|
26
|
+
* @returns {Promise<object>} 对比结果
|
|
27
|
+
*/
|
|
28
|
+
export async function compareDesign(screenshotPath, designImagePath, options = {}) {
|
|
29
|
+
const sharp = (await import("sharp")).default;
|
|
30
|
+
const pixelmatch = (await import("pixelmatch")).default;
|
|
31
|
+
const { PNG } = await import("pngjs");
|
|
32
|
+
|
|
33
|
+
// 1. 读取图片元数据
|
|
34
|
+
const screenshotMeta = await sharp(screenshotPath).metadata();
|
|
35
|
+
const screenshotBuf = await sharp(screenshotPath).png().toBuffer();
|
|
36
|
+
const designBuf = await sharp(designImagePath).png().toBuffer();
|
|
37
|
+
|
|
38
|
+
// 2. 以截图尺寸为基准 resize 设计稿
|
|
39
|
+
const { width, height } = screenshotMeta;
|
|
40
|
+
const designResized = await sharp(designBuf)
|
|
41
|
+
.resize(width, height, { fit: "fill" })
|
|
42
|
+
.png()
|
|
43
|
+
.toBuffer();
|
|
44
|
+
|
|
45
|
+
// 3. 解码为像素数据
|
|
46
|
+
const img1 = PNG.sync.read(screenshotBuf);
|
|
47
|
+
const img2 = PNG.sync.read(designResized);
|
|
48
|
+
const diff = new PNG({ width, height });
|
|
49
|
+
|
|
50
|
+
// 4. pixelmatch 对比
|
|
51
|
+
const threshold = options.threshold ?? 0.1;
|
|
52
|
+
const diffPixels = pixelmatch(img1.data, img2.data, diff.data, width, height, {
|
|
53
|
+
threshold,
|
|
54
|
+
includeAA: false,
|
|
55
|
+
alpha: 0.1,
|
|
56
|
+
aaColor: [255, 255, 0], // 黄色 = 抗锯齿差异
|
|
57
|
+
diffColor: [255, 0, 0], // 红色 = 真实差异
|
|
58
|
+
diffColorAlt: [0, 100, 255], // 蓝色 = 轻微色差(低于阈值)
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const totalPixels = width * height;
|
|
62
|
+
const fidelity = parseFloat(((totalPixels - diffPixels) / totalPixels * 100).toFixed(2));
|
|
63
|
+
|
|
64
|
+
// 5. 保存 diff 图
|
|
65
|
+
let diffImagePath = null;
|
|
66
|
+
if (options.outputDir) {
|
|
67
|
+
if (!fs.existsSync(options.outputDir)) {
|
|
68
|
+
fs.mkdirSync(options.outputDir, { recursive: true });
|
|
69
|
+
}
|
|
70
|
+
diffImagePath = path.join(options.outputDir, `diff-global-${Date.now()}.png`);
|
|
71
|
+
fs.writeFileSync(diffImagePath, PNG.sync.write(diff));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 6. 区域对比(如果有)
|
|
75
|
+
const regions = [];
|
|
76
|
+
if (options.regions?.length && options.page) {
|
|
77
|
+
for (const region of options.regions) {
|
|
78
|
+
try {
|
|
79
|
+
const regionResult = await compareRegion(
|
|
80
|
+
screenshotPath, designImagePath, region, { ...options, sharp, PNG, pixelmatch }
|
|
81
|
+
);
|
|
82
|
+
regions.push(regionResult);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
regions.push({
|
|
85
|
+
name: region.name,
|
|
86
|
+
selector: region.selector,
|
|
87
|
+
error: err.message,
|
|
88
|
+
fidelity: null,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
fidelity,
|
|
96
|
+
diffPixels,
|
|
97
|
+
totalPixels,
|
|
98
|
+
width,
|
|
99
|
+
height,
|
|
100
|
+
diffImagePath,
|
|
101
|
+
regions,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 区域对比 - 通过 Puppeteer 获取元素位置后裁剪对比
|
|
107
|
+
* @param {string} screenshotPath
|
|
108
|
+
* @param {string} designImagePath
|
|
109
|
+
* @param {object} region - { name, selector }
|
|
110
|
+
* @param {object} deps - { sharp, PNG, pixelmatch, page, outputDir, threshold }
|
|
111
|
+
* @returns {Promise<object>}
|
|
112
|
+
*/
|
|
113
|
+
async function compareRegion(screenshotPath, designImagePath, region, deps) {
|
|
114
|
+
const { sharp, PNG, pixelmatch, page, outputDir } = deps;
|
|
115
|
+
const threshold = deps.threshold ?? 0.1;
|
|
116
|
+
|
|
117
|
+
// 获取元素边界框
|
|
118
|
+
const boundingBox = await page.evaluate((sel) => {
|
|
119
|
+
const el = document.querySelector(sel);
|
|
120
|
+
if (!el) return null;
|
|
121
|
+
const rect = el.getBoundingClientRect();
|
|
122
|
+
return {
|
|
123
|
+
x: Math.round(rect.x),
|
|
124
|
+
y: Math.round(rect.y),
|
|
125
|
+
width: Math.round(rect.width),
|
|
126
|
+
height: Math.round(rect.height),
|
|
127
|
+
};
|
|
128
|
+
}, region.selector);
|
|
129
|
+
|
|
130
|
+
if (!boundingBox) {
|
|
131
|
+
return {
|
|
132
|
+
name: region.name,
|
|
133
|
+
selector: region.selector,
|
|
134
|
+
error: `Element not found: ${region.selector}`,
|
|
135
|
+
fidelity: null,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 用 sharp 裁剪两张图的对应区域
|
|
140
|
+
const screenshotRegion = await sharp(screenshotPath)
|
|
141
|
+
.extract(boundingBox)
|
|
142
|
+
.png()
|
|
143
|
+
.toBuffer();
|
|
144
|
+
|
|
145
|
+
const designRegion = await sharp(designImagePath)
|
|
146
|
+
.resize(boundingBox.width, boundingBox.height, { fit: "fill" })
|
|
147
|
+
.extract({
|
|
148
|
+
left: 0,
|
|
149
|
+
top: 0,
|
|
150
|
+
width: boundingBox.width,
|
|
151
|
+
height: boundingBox.height,
|
|
152
|
+
})
|
|
153
|
+
.png()
|
|
154
|
+
.toBuffer();
|
|
155
|
+
|
|
156
|
+
// 像素对比
|
|
157
|
+
const img1 = PNG.sync.read(screenshotRegion);
|
|
158
|
+
const img2 = PNG.sync.read(designRegion);
|
|
159
|
+
const diff = new PNG({ width: img1.width, height: img1.height });
|
|
160
|
+
|
|
161
|
+
const diffPixels = pixelmatch(img1.data, img2.data, diff.data, img1.width, img1.height, {
|
|
162
|
+
threshold,
|
|
163
|
+
includeAA: false,
|
|
164
|
+
alpha: 0.1,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const totalPixels = img1.width * img1.height;
|
|
168
|
+
const fidelity = parseFloat(((totalPixels - diffPixels) / totalPixels * 100).toFixed(2));
|
|
169
|
+
|
|
170
|
+
// 保存区域 diff 图
|
|
171
|
+
let diffImagePath = null;
|
|
172
|
+
if (outputDir) {
|
|
173
|
+
const safeName = region.name.replace(/[^a-zA-Z0-9\u4e00-\u9fff]/g, "_");
|
|
174
|
+
diffImagePath = path.join(outputDir, `diff-region-${safeName}-${Date.now()}.png`);
|
|
175
|
+
fs.writeFileSync(diffImagePath, PNG.sync.write(diff));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
name: region.name,
|
|
180
|
+
selector: region.selector,
|
|
181
|
+
boundingBox,
|
|
182
|
+
fidelity,
|
|
183
|
+
diffPixels,
|
|
184
|
+
totalPixels,
|
|
185
|
+
diffImagePath,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export default { compareDesign };
|