@szc-ft/mcp-szcd-client 0.27.0 → 0.27.2
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/commands/szcd-mcp-browser-test.md +1 -1
- package/lib/browser-engine.js +434 -4
- package/lib/shared-deps.js +80 -40
- package/lib/visual-compare.js +3 -3
- package/local-browser-executor.js +194 -7
- package/opencode-extension/commands/szcd-mcp-browser-test.md +1 -1
- package/opencode-extension/skills/local-browser-test/SKILL.md +225 -26
- package/opencode-extension/skills/local-browser-test/local-browser-executor-old.cjs +395 -0
- package/opencode-extension/skills/local-browser-test/local-browser-executor.cjs +727 -0
- package/opencode-extension/skills/szcd-design-to-code/SKILL.md +3 -2
- package/package.json +1 -1
- package/qwen-extension/commands/szcd-mcp-browser-test.md +1 -1
- package/qwen-extension/qwen-extension.json +1 -1
- package/qwen-extension/skills/local-browser-test/SKILL.md +225 -26
- package/qwen-extension/skills/local-browser-test/local-browser-executor-old.cjs +395 -0
- package/qwen-extension/skills/local-browser-test/local-browser-executor.cjs +727 -0
- package/qwen-extension/skills/szcd-design-to-code/SKILL.md +3 -2
- package/standard-skill/local-browser-test/SKILL.md +225 -26
- package/standard-skill/local-browser-test/local-browser-executor-old.cjs +395 -0
- package/standard-skill/local-browser-test/local-browser-executor.cjs +727 -0
|
@@ -53,5 +53,5 @@ argument-hint: <目标页面URL> [可选:设计稿图片路径]
|
|
|
53
53
|
|
|
54
54
|
**注意事项**:
|
|
55
55
|
- 首次使用需确保 Chrome 以调试模式启动:`google-chrome --remote-debugging-port=9222`
|
|
56
|
-
-
|
|
56
|
+
- 如执行器报错依赖缺失,优先按提示安装核心依赖;仅设计稿像素对比需要额外安装 `pixelmatch pngjs sharp`
|
|
57
57
|
- connect 模式下天然继承用户浏览器的登录态和微前端环境
|
package/lib/browser-engine.js
CHANGED
|
@@ -61,7 +61,7 @@ export class BrowserEngine {
|
|
|
61
61
|
*/
|
|
62
62
|
async connect(cdpUrl, options = {}) {
|
|
63
63
|
const puppeteer = await loadPuppeteer();
|
|
64
|
-
this.browser = await puppeteer.connect({ browserURL: cdpUrl });
|
|
64
|
+
this.browser = await puppeteer.connect({ browserURL: cdpUrl, defaultViewport: null });
|
|
65
65
|
this.mode = "connect";
|
|
66
66
|
this.ownedBrowser = false;
|
|
67
67
|
|
|
@@ -178,11 +178,441 @@ export class BrowserEngine {
|
|
|
178
178
|
}
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
async observe(options = {}) {
|
|
182
|
+
const targetFrame = await this._resolveObserveFrame(options);
|
|
183
|
+
const selector = options.selector || "body";
|
|
184
|
+
const depth = Number.parseInt(options.depth ?? 5, 10);
|
|
185
|
+
const maxInteractiveElements = Number.parseInt(options.maxInteractiveElements ?? 200, 10);
|
|
186
|
+
const frames = await this._getFrameSummaries();
|
|
187
|
+
const tree = await this._buildDomTree(targetFrame.frame, selector, depth);
|
|
188
|
+
const interactiveElements = await this._collectInteractiveElements(
|
|
189
|
+
targetFrame.frame,
|
|
190
|
+
targetFrame.frameId,
|
|
191
|
+
maxInteractiveElements
|
|
192
|
+
);
|
|
193
|
+
const title = await this.page.title().catch(() => "");
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
type: "observe",
|
|
197
|
+
url: this.page.url(),
|
|
198
|
+
title,
|
|
199
|
+
timestamp: new Date().toISOString(),
|
|
200
|
+
mode: this.mode,
|
|
201
|
+
target: {
|
|
202
|
+
frameId: targetFrame.frameId,
|
|
203
|
+
frameUrl: targetFrame.frame.url(),
|
|
204
|
+
matchedBy: targetFrame.matchedBy,
|
|
205
|
+
},
|
|
206
|
+
frames,
|
|
207
|
+
tree,
|
|
208
|
+
interactiveElements,
|
|
209
|
+
summary: {
|
|
210
|
+
frames: frames.length,
|
|
211
|
+
interactiveElements: interactiveElements.length,
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async act(options = {}) {
|
|
217
|
+
const targetFrame = await this._resolveObserveFrame(options);
|
|
218
|
+
const index = options.index !== undefined ? Number.parseInt(options.index, 10) : undefined;
|
|
219
|
+
const selector = this._resolveSelector(options.selector || options.click || options.hover);
|
|
220
|
+
const typeText = options.typeText ?? options.text;
|
|
221
|
+
const action = options.hover ? "hover" : typeText !== undefined ? "type" : "click";
|
|
222
|
+
|
|
223
|
+
const result = await targetFrame.frame.evaluate((actOptions) => {
|
|
224
|
+
function getText(el) {
|
|
225
|
+
return (el.innerText || el.textContent || "").replace(/\s+/g, " ").trim().slice(0, 120);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function roleOf(el) {
|
|
229
|
+
const explicit = el.getAttribute("role");
|
|
230
|
+
if (explicit) return explicit;
|
|
231
|
+
const tag = el.tagName.toLowerCase();
|
|
232
|
+
if (tag === "button") return "button";
|
|
233
|
+
if (tag === "a") return "link";
|
|
234
|
+
if (["input", "textarea"].includes(tag)) return "textbox";
|
|
235
|
+
if (tag === "select") return "combobox";
|
|
236
|
+
return "generic";
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function getName(el, text) {
|
|
240
|
+
return (
|
|
241
|
+
el.getAttribute("aria-label") ||
|
|
242
|
+
el.getAttribute("title") ||
|
|
243
|
+
el.getAttribute("placeholder") ||
|
|
244
|
+
el.getAttribute("alt") ||
|
|
245
|
+
text ||
|
|
246
|
+
""
|
|
247
|
+
).slice(0, 120);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function isCandidate(el) {
|
|
251
|
+
const tag = el.tagName.toLowerCase();
|
|
252
|
+
return ["a", "button", "input", "select", "textarea", "summary"].includes(tag) ||
|
|
253
|
+
el.getAttribute("role") ||
|
|
254
|
+
el.getAttribute("tabindex") !== null ||
|
|
255
|
+
el.onclick ||
|
|
256
|
+
el.getAttribute("data-testid") ||
|
|
257
|
+
(typeof el.className === "string" && /btn|button|menu-item|ant-btn|ant-menu|ant-select|ant-input/.test(el.className));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function interactiveElements() {
|
|
261
|
+
return Array.from(document.querySelectorAll("a,button,input,select,textarea,summary,[role],[tabindex],[data-testid],[class*='btn'],[class*='button'],[class*='menu-item'],[class*='ant-btn'],[class*='ant-menu'],[class*='ant-select'],[class*='ant-input']"))
|
|
262
|
+
.filter(isCandidate);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function findBySelector(selector) {
|
|
266
|
+
if (!selector) return null;
|
|
267
|
+
if (selector.startsWith("text=")) {
|
|
268
|
+
const expected = selector.slice(5).trim();
|
|
269
|
+
return interactiveElements().find((el) => getText(el).includes(expected) || getName(el, getText(el)).includes(expected)) ||
|
|
270
|
+
Array.from(document.querySelectorAll("*")).find((el) => getText(el).includes(expected));
|
|
271
|
+
}
|
|
272
|
+
if (selector.startsWith("role=")) {
|
|
273
|
+
const match = selector.match(/^role=([^\[]+)(?:\[name="([^"]+)"\])?/);
|
|
274
|
+
const expectedRole = match?.[1]?.trim();
|
|
275
|
+
const expectedName = match?.[2]?.trim();
|
|
276
|
+
return interactiveElements().find((el) => {
|
|
277
|
+
const text = getText(el);
|
|
278
|
+
const name = getName(el, text);
|
|
279
|
+
return roleOf(el) === expectedRole && (!expectedName || name.includes(expectedName) || text.includes(expectedName));
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
return document.querySelector(selector);
|
|
284
|
+
} catch {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const el = actOptions.index !== undefined
|
|
290
|
+
? interactiveElements()[actOptions.index - 1]
|
|
291
|
+
: findBySelector(actOptions.selector);
|
|
292
|
+
|
|
293
|
+
if (!el) {
|
|
294
|
+
return { found: false };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
el.scrollIntoView({ block: "center", inline: "center" });
|
|
298
|
+
const rect = el.getBoundingClientRect();
|
|
299
|
+
const style = window.getComputedStyle(el);
|
|
300
|
+
const visible = rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
|
|
301
|
+
const enabled = !el.disabled && el.getAttribute("aria-disabled") !== "true";
|
|
302
|
+
const text = getText(el);
|
|
303
|
+
const name = getName(el, text);
|
|
304
|
+
const baseResult = {
|
|
305
|
+
found: true,
|
|
306
|
+
tagName: el.tagName,
|
|
307
|
+
role: roleOf(el),
|
|
308
|
+
name,
|
|
309
|
+
text,
|
|
310
|
+
visible,
|
|
311
|
+
enabled,
|
|
312
|
+
bbox: {
|
|
313
|
+
x: Math.round(rect.x),
|
|
314
|
+
y: Math.round(rect.y),
|
|
315
|
+
width: Math.round(rect.width),
|
|
316
|
+
height: Math.round(rect.height),
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
if (!visible || !enabled) {
|
|
321
|
+
return { ...baseResult, performed: false };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (actOptions.action === "hover") {
|
|
325
|
+
el.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
|
326
|
+
el.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
|
327
|
+
return { ...baseResult, performed: true, action: "hover" };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (actOptions.action === "type") {
|
|
331
|
+
el.focus();
|
|
332
|
+
if ("value" in el) {
|
|
333
|
+
el.value = actOptions.clear ? "" : el.value;
|
|
334
|
+
el.value += actOptions.typeText;
|
|
335
|
+
} else if (el.isContentEditable) {
|
|
336
|
+
el.textContent = actOptions.clear ? actOptions.typeText : `${el.textContent || ""}${actOptions.typeText}`;
|
|
337
|
+
} else {
|
|
338
|
+
return { ...baseResult, performed: false, error: "Target is not typeable" };
|
|
339
|
+
}
|
|
340
|
+
el.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: actOptions.typeText }));
|
|
341
|
+
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
342
|
+
return { ...baseResult, performed: true, action: "type", value: "value" in el ? el.value : el.textContent };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
el.click();
|
|
346
|
+
return { ...baseResult, performed: true, action: "click" };
|
|
347
|
+
}, {
|
|
348
|
+
index,
|
|
349
|
+
selector,
|
|
350
|
+
action,
|
|
351
|
+
typeText,
|
|
352
|
+
clear: Boolean(options.clear),
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
if (!result.found) {
|
|
356
|
+
throw new Error(index !== undefined ? `Interactive element index not found: ${index}` : `Element not found: ${selector}`);
|
|
357
|
+
}
|
|
358
|
+
if (!result.performed) {
|
|
359
|
+
throw new Error(result.error || `Element is not actionable: ${index ?? selector}`);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return {
|
|
363
|
+
acted: true,
|
|
364
|
+
action: result.action,
|
|
365
|
+
index,
|
|
366
|
+
selector,
|
|
367
|
+
target: {
|
|
368
|
+
frameId: targetFrame.frameId,
|
|
369
|
+
frameUrl: targetFrame.frame.url(),
|
|
370
|
+
matchedBy: targetFrame.matchedBy,
|
|
371
|
+
},
|
|
372
|
+
element: result,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async assert(options = {}) {
|
|
377
|
+
const targetFrame = await this._resolveObserveFrame(options);
|
|
378
|
+
const previousFrame = this._activeFrame;
|
|
379
|
+
this._activeFrame = targetFrame.frame === this.page.mainFrame() ? null : targetFrame.frame;
|
|
380
|
+
|
|
381
|
+
try {
|
|
382
|
+
const checks = {};
|
|
383
|
+
let passed = true;
|
|
384
|
+
|
|
385
|
+
if (options.selector) {
|
|
386
|
+
const elementResult = await this._checkElement({ selector: options.selector, expect: options.expect || {} });
|
|
387
|
+
checks.element = elementResult;
|
|
388
|
+
if (!elementResult.passed) passed = false;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (options.urlContains) {
|
|
392
|
+
const currentUrl = this.page.url();
|
|
393
|
+
checks.urlContains = { expected: options.urlContains, actual: currentUrl, passed: currentUrl.includes(options.urlContains) };
|
|
394
|
+
if (!checks.urlContains.passed) passed = false;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (options.textContains) {
|
|
398
|
+
const textResult = await this.safeEval((text) => {
|
|
399
|
+
const bodyText = document.body?.innerText || "";
|
|
400
|
+
return { expected: text, passed: bodyText.includes(text) };
|
|
401
|
+
}, options.textContains);
|
|
402
|
+
checks.textContains = textResult;
|
|
403
|
+
if (!textResult.passed) passed = false;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return {
|
|
407
|
+
passed,
|
|
408
|
+
target: {
|
|
409
|
+
frameId: targetFrame.frameId,
|
|
410
|
+
frameUrl: targetFrame.frame.url(),
|
|
411
|
+
matchedBy: targetFrame.matchedBy,
|
|
412
|
+
},
|
|
413
|
+
checks,
|
|
414
|
+
};
|
|
415
|
+
} finally {
|
|
416
|
+
this._activeFrame = previousFrame;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async _resolveObserveFrame(options = {}) {
|
|
421
|
+
const frames = this.page.frames();
|
|
422
|
+
const mainFrame = this.page.mainFrame();
|
|
423
|
+
|
|
424
|
+
if (options.frameIndex !== undefined) {
|
|
425
|
+
const index = Number.parseInt(options.frameIndex, 10);
|
|
426
|
+
if (frames[index]) {
|
|
427
|
+
return { frame: frames[index], frameId: index === 0 ? "main" : `frame-${index}`, matchedBy: `frameIndex ${index}` };
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (options.frameUrlContains) {
|
|
432
|
+
const index = frames.findIndex((frame) => frame.url().includes(options.frameUrlContains));
|
|
433
|
+
if (index >= 0) {
|
|
434
|
+
return { frame: frames[index], frameId: index === 0 ? "main" : `frame-${index}`, matchedBy: `url contains "${options.frameUrlContains}"` };
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (options.frameContentContains) {
|
|
439
|
+
for (let i = 0; i < frames.length; i++) {
|
|
440
|
+
const matched = await frames[i].evaluate((text) =>
|
|
441
|
+
document.body?.innerText?.includes(text) || false,
|
|
442
|
+
options.frameContentContains
|
|
443
|
+
).catch(() => false);
|
|
444
|
+
if (matched) {
|
|
445
|
+
return { frame: frames[i], frameId: i === 0 ? "main" : `frame-${i}`, matchedBy: `body contains "${options.frameContentContains}"` };
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const mainIndex = frames.indexOf(mainFrame);
|
|
451
|
+
return { frame: mainFrame, frameId: mainIndex <= 0 ? "main" : `frame-${mainIndex}`, matchedBy: "main" };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async _getFrameSummaries() {
|
|
455
|
+
const frames = this.page.frames();
|
|
456
|
+
const mainFrame = this.page.mainFrame();
|
|
457
|
+
const summaries = [];
|
|
458
|
+
for (let i = 0; i < frames.length; i++) {
|
|
459
|
+
const frame = frames[i];
|
|
460
|
+
const title = await frame.evaluate(() => document.title || "").catch(() => "");
|
|
461
|
+
summaries.push({
|
|
462
|
+
frameId: frame === mainFrame ? "main" : `frame-${i}`,
|
|
463
|
+
url: frame.url(),
|
|
464
|
+
title,
|
|
465
|
+
isMain: frame === mainFrame,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
return summaries;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async _buildDomTree(frame, selector, depth) {
|
|
472
|
+
return frame.evaluate((rootSelector, maxDepth) => {
|
|
473
|
+
function attrsOf(el) {
|
|
474
|
+
const attrs = {};
|
|
475
|
+
for (const attr of Array.from(el.attributes || [])) {
|
|
476
|
+
if (["id", "class", "role", "aria-label", "title", "placeholder", "href", "src", "data-testid"].includes(attr.name)) {
|
|
477
|
+
attrs[attr.name] = attr.value.slice(0, 120);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return attrs;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function directTextOf(el) {
|
|
484
|
+
return Array.from(el.childNodes || [])
|
|
485
|
+
.filter((node) => node.nodeType === Node.TEXT_NODE)
|
|
486
|
+
.map((node) => node.textContent.trim())
|
|
487
|
+
.filter(Boolean)
|
|
488
|
+
.join(" ")
|
|
489
|
+
.slice(0, 120);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function build(el, currentDepth) {
|
|
493
|
+
if (!el || currentDepth > maxDepth) return null;
|
|
494
|
+
const tag = el.tagName?.toLowerCase();
|
|
495
|
+
if (!tag || ["script", "style", "path"].includes(tag)) return null;
|
|
496
|
+
const rect = el.getBoundingClientRect();
|
|
497
|
+
const node = {
|
|
498
|
+
tag,
|
|
499
|
+
attrs: attrsOf(el),
|
|
500
|
+
text: directTextOf(el),
|
|
501
|
+
visible: rect.width > 0 && rect.height > 0,
|
|
502
|
+
children: [],
|
|
503
|
+
};
|
|
504
|
+
for (const child of Array.from(el.children || [])) {
|
|
505
|
+
const childNode = build(child, currentDepth + 1);
|
|
506
|
+
if (childNode) node.children.push(childNode);
|
|
507
|
+
}
|
|
508
|
+
return node;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return build(document.querySelector(rootSelector) || document.body, 0);
|
|
512
|
+
}, selector, depth).catch((err) => ({ error: err.message, tag: "error", attrs: {}, text: "", visible: false, children: [] }));
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async _collectInteractiveElements(frame, frameId, maxElements) {
|
|
516
|
+
return frame.evaluate((currentFrameId, limit) => {
|
|
517
|
+
function cssEscape(value) {
|
|
518
|
+
if (window.CSS?.escape) return window.CSS.escape(value);
|
|
519
|
+
return String(value).replace(/["'\\]/g, "\\$&");
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function getText(el) {
|
|
523
|
+
return (el.innerText || el.textContent || "").replace(/\s+/g, " ").trim().slice(0, 120);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function getName(el, text) {
|
|
527
|
+
return (
|
|
528
|
+
el.getAttribute("aria-label") ||
|
|
529
|
+
el.getAttribute("title") ||
|
|
530
|
+
el.getAttribute("placeholder") ||
|
|
531
|
+
el.getAttribute("alt") ||
|
|
532
|
+
text ||
|
|
533
|
+
""
|
|
534
|
+
).slice(0, 120);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function roleOf(el) {
|
|
538
|
+
const explicit = el.getAttribute("role");
|
|
539
|
+
if (explicit) return explicit;
|
|
540
|
+
const tag = el.tagName.toLowerCase();
|
|
541
|
+
if (tag === "button") return "button";
|
|
542
|
+
if (tag === "a") return "link";
|
|
543
|
+
if (["input", "textarea"].includes(tag)) return "textbox";
|
|
544
|
+
if (tag === "select") return "combobox";
|
|
545
|
+
return "generic";
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function selectorCandidates(el, role, name, text) {
|
|
549
|
+
const selectors = [];
|
|
550
|
+
const testId = el.getAttribute("data-testid");
|
|
551
|
+
if (testId) selectors.push(`[data-testid="${cssEscape(testId)}"]`);
|
|
552
|
+
if (el.id) selectors.push(`#${cssEscape(el.id)}`);
|
|
553
|
+
if (role && role !== "generic" && name) selectors.push(`role=${role}[name="${name.replace(/"/g, "\\\"")}"]`);
|
|
554
|
+
if (text) selectors.push(`text=${text.replace(/"/g, "\\\"")}`);
|
|
555
|
+
const tag = el.tagName.toLowerCase();
|
|
556
|
+
const className = typeof el.className === "string" ? el.className.trim().split(/\s+/).filter(Boolean)[0] : "";
|
|
557
|
+
if (className) selectors.push(`${tag}.${cssEscape(className)}`);
|
|
558
|
+
selectors.push(tag);
|
|
559
|
+
return [...new Set(selectors)].slice(0, 5);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function isCandidate(el) {
|
|
563
|
+
const tag = el.tagName.toLowerCase();
|
|
564
|
+
return ["a", "button", "input", "select", "textarea", "summary"].includes(tag) ||
|
|
565
|
+
el.getAttribute("role") ||
|
|
566
|
+
el.getAttribute("tabindex") !== null ||
|
|
567
|
+
el.onclick ||
|
|
568
|
+
el.getAttribute("data-testid") ||
|
|
569
|
+
(typeof el.className === "string" && /btn|button|menu-item|ant-btn|ant-menu|ant-select|ant-input/.test(el.className));
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
|
|
573
|
+
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
|
574
|
+
return Array.from(document.querySelectorAll("a,button,input,select,textarea,summary,[role],[tabindex],[data-testid],[class*='btn'],[class*='button'],[class*='menu-item'],[class*='ant-btn'],[class*='ant-menu'],[class*='ant-select'],[class*='ant-input']"))
|
|
575
|
+
.filter(isCandidate)
|
|
576
|
+
.slice(0, limit)
|
|
577
|
+
.map((el, idx) => {
|
|
578
|
+
const rect = el.getBoundingClientRect();
|
|
579
|
+
const style = window.getComputedStyle(el);
|
|
580
|
+
const text = getText(el);
|
|
581
|
+
const role = roleOf(el);
|
|
582
|
+
const name = getName(el, text);
|
|
583
|
+
const visible = rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
|
|
584
|
+
return {
|
|
585
|
+
index: idx + 1,
|
|
586
|
+
frameId: currentFrameId,
|
|
587
|
+
tag: el.tagName.toLowerCase(),
|
|
588
|
+
role,
|
|
589
|
+
name,
|
|
590
|
+
text,
|
|
591
|
+
selectorCandidates: selectorCandidates(el, role, name, text),
|
|
592
|
+
bbox: {
|
|
593
|
+
x: Math.round(rect.x),
|
|
594
|
+
y: Math.round(rect.y),
|
|
595
|
+
width: Math.round(rect.width),
|
|
596
|
+
height: Math.round(rect.height),
|
|
597
|
+
},
|
|
598
|
+
state: {
|
|
599
|
+
visible,
|
|
600
|
+
enabled: !el.disabled && el.getAttribute("aria-disabled") !== "true",
|
|
601
|
+
inViewport: rect.bottom >= 0 && rect.right >= 0 && rect.top <= viewportHeight && rect.left <= viewportWidth,
|
|
602
|
+
},
|
|
603
|
+
};
|
|
604
|
+
});
|
|
605
|
+
}, frameId, maxElements).catch(() => []);
|
|
606
|
+
}
|
|
607
|
+
|
|
181
608
|
/**
|
|
182
609
|
* 步骤分发
|
|
183
610
|
*/
|
|
184
611
|
async _dispatchStep(step, context) {
|
|
185
612
|
switch (step.type) {
|
|
613
|
+
case "observe": return this.observe(step);
|
|
614
|
+
case "act": return this.act(step);
|
|
615
|
+
case "assert": return this.assert(step);
|
|
186
616
|
case "navigate": return this._navigate(step);
|
|
187
617
|
case "screenshot": return this._screenshot(step, context);
|
|
188
618
|
case "compare": return this._compare(step, context);
|
|
@@ -649,7 +1079,7 @@ export class BrowserEngine {
|
|
|
649
1079
|
* 动态加载 puppeteer-core,从共享缓存目录加载,缺失时自动安装
|
|
650
1080
|
*/
|
|
651
1081
|
async function loadPuppeteer() {
|
|
652
|
-
const {
|
|
1082
|
+
const { ensureCoreDeps, loadFromShared } = await import("./shared-deps.js");
|
|
653
1083
|
|
|
654
1084
|
// 1. 尝试从项目本地 node_modules 加载(开发环境或已手动安装)
|
|
655
1085
|
try {
|
|
@@ -659,8 +1089,8 @@ async function loadPuppeteer() {
|
|
|
659
1089
|
// 本地未安装
|
|
660
1090
|
}
|
|
661
1091
|
|
|
662
|
-
// 2.
|
|
663
|
-
await
|
|
1092
|
+
// 2. observe/act/assert 主链路只需要 puppeteer-core
|
|
1093
|
+
await ensureCoreDeps();
|
|
664
1094
|
|
|
665
1095
|
try {
|
|
666
1096
|
return loadFromShared("puppeteer-core");
|
package/lib/shared-deps.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 共享依赖加载器
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* 首次使用时自动安装,后续直接加载。
|
|
4
|
+
* 浏览器自动化依赖安装到 ~/.szcd-mcp/deps/node_modules/,所有项目共享复用。
|
|
5
|
+
* 主链路 observe/act/assert 只安装 puppeteer-core;视觉对比按需安装 pixelmatch/pngjs/sharp。
|
|
7
6
|
*/
|
|
8
7
|
|
|
9
8
|
import { createRequire } from "node:module";
|
|
@@ -14,11 +13,14 @@ import fs from "node:fs";
|
|
|
14
13
|
/** 共享缓存目录 */
|
|
15
14
|
const SHARED_DEPS_DIR = path.join(homedir(), ".szcd-mcp", "deps");
|
|
16
15
|
|
|
17
|
-
/**
|
|
18
|
-
const
|
|
16
|
+
/** 核心浏览器自动化依赖 */
|
|
17
|
+
const CORE_PACKAGES = ["puppeteer-core"];
|
|
19
18
|
|
|
20
|
-
/**
|
|
21
|
-
const
|
|
19
|
+
/** 视觉对比依赖 */
|
|
20
|
+
const VISUAL_COMPARE_PACKAGES = ["pixelmatch", "pngjs", "sharp"];
|
|
21
|
+
|
|
22
|
+
/** 兼容旧导出 */
|
|
23
|
+
const PACKAGES = [...CORE_PACKAGES, ...VISUAL_COMPARE_PACKAGES];
|
|
22
24
|
|
|
23
25
|
/**
|
|
24
26
|
* 从共享缓存目录加载模块
|
|
@@ -30,27 +32,9 @@ export function loadFromShared(moduleName) {
|
|
|
30
32
|
return require_(moduleName);
|
|
31
33
|
}
|
|
32
34
|
|
|
33
|
-
|
|
34
|
-
* 确保共享依赖已安装,未安装则自动安装
|
|
35
|
-
* @returns {Promise<void>}
|
|
36
|
-
*/
|
|
37
|
-
export async function ensureSharedDeps() {
|
|
38
|
-
// 检查标记文件
|
|
39
|
-
if (fs.existsSync(INSTALLED_MARKER)) {
|
|
40
|
-
// 快速验证:puppeteer-core 目录是否存在
|
|
41
|
-
const pcoreDir = path.join(SHARED_DEPS_DIR, "node_modules", "puppeteer-core");
|
|
42
|
-
if (fs.existsSync(pcoreDir)) {
|
|
43
|
-
return; // 已安装
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
process.stderr.write("[shared-deps] 首次使用浏览器测试功能,正在安装共享依赖...\n");
|
|
48
|
-
process.stderr.write(`[shared-deps] 安装目录: ${SHARED_DEPS_DIR}\n`);
|
|
49
|
-
|
|
50
|
-
// 确保目录存在
|
|
35
|
+
function ensurePackageJson() {
|
|
51
36
|
fs.mkdirSync(SHARED_DEPS_DIR, { recursive: true });
|
|
52
37
|
|
|
53
|
-
// 初始化 package.json(如果不存在)
|
|
54
38
|
const pkgPath = path.join(SHARED_DEPS_DIR, "package.json");
|
|
55
39
|
if (!fs.existsSync(pkgPath)) {
|
|
56
40
|
fs.writeFileSync(pkgPath, JSON.stringify({
|
|
@@ -59,39 +43,95 @@ export async function ensureSharedDeps() {
|
|
|
59
43
|
private: true,
|
|
60
44
|
}, null, 2));
|
|
61
45
|
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isPackageInstalled(packageName) {
|
|
49
|
+
try {
|
|
50
|
+
const require_ = createRequire(path.join(SHARED_DEPS_DIR, "node_modules", "_"));
|
|
51
|
+
require_.resolve(packageName);
|
|
52
|
+
return true;
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function installPackages(packages, label) {
|
|
59
|
+
const missing = packages.filter((packageName) => !isPackageInstalled(packageName));
|
|
60
|
+
if (missing.length === 0) return;
|
|
61
|
+
|
|
62
|
+
process.stderr.write(`[shared-deps] 正在安装${label}: ${missing.join(" ")}\n`);
|
|
63
|
+
process.stderr.write(`[shared-deps] 安装目录: ${SHARED_DEPS_DIR}\n`);
|
|
64
|
+
|
|
65
|
+
ensurePackageJson();
|
|
62
66
|
|
|
63
|
-
// 安装依赖
|
|
64
67
|
const { execSync } = await import("node:child_process");
|
|
65
|
-
const pkgList =
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
+
const pkgList = missing.join(" ");
|
|
69
|
+
const registryFlags = [
|
|
70
|
+
"--registry=https://registry.npmmirror.com",
|
|
71
|
+
"--@img:registry=https://registry.npmmirror.com",
|
|
72
|
+
];
|
|
73
|
+
const speedFlags = [
|
|
74
|
+
"--prefer-offline",
|
|
75
|
+
"--no-audit",
|
|
76
|
+
"--no-fund",
|
|
77
|
+
"--omit=dev",
|
|
78
|
+
"--package-lock=false",
|
|
79
|
+
"--loglevel=warn",
|
|
80
|
+
];
|
|
81
|
+
const sharpFlags = missing.includes("sharp") ? [
|
|
82
|
+
"--sharp_binary_host=https://npmmirror.com/mirrors/sharp",
|
|
83
|
+
"--sharp_libvips_binary_host=https://npmmirror.com/mirrors/sharp-libvips",
|
|
84
|
+
] : [];
|
|
85
|
+
const installFlags = [...registryFlags, ...speedFlags, ...sharpFlags].join(" ");
|
|
68
86
|
|
|
69
87
|
try {
|
|
70
|
-
execSync(`npm install ${
|
|
88
|
+
execSync(`npm install ${installFlags} ${pkgList}`.trim(), {
|
|
71
89
|
cwd: SHARED_DEPS_DIR,
|
|
72
90
|
stdio: "inherit",
|
|
73
91
|
timeout: 180000,
|
|
74
92
|
});
|
|
75
93
|
} catch (err) {
|
|
76
94
|
throw new Error(
|
|
77
|
-
|
|
78
|
-
` cd ${SHARED_DEPS_DIR} && npm install ${pkgList}\n` +
|
|
95
|
+
`${label}安装失败。请手动执行:\n` +
|
|
96
|
+
` cd ${SHARED_DEPS_DIR} && npm install ${installFlags} ${pkgList}\n` +
|
|
79
97
|
`原始错误:${err.message}`
|
|
80
98
|
);
|
|
81
99
|
}
|
|
82
100
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
101
|
+
process.stderr.write(`[shared-deps] ${label}安装完成。\n`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function ensureCoreDeps() {
|
|
105
|
+
await installPackages(CORE_PACKAGES, "浏览器核心依赖");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function ensureVisualCompareDeps() {
|
|
109
|
+
await ensureCoreDeps();
|
|
110
|
+
await installPackages(VISUAL_COMPARE_PACKAGES, "视觉对比依赖");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 确保全部共享依赖已安装,保留给旧调用兼容。
|
|
115
|
+
* @returns {Promise<void>}
|
|
116
|
+
*/
|
|
117
|
+
export async function ensureSharedDeps() {
|
|
118
|
+
await ensureVisualCompareDeps();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 检查核心依赖是否已安装
|
|
123
|
+
* @returns {boolean}
|
|
124
|
+
*/
|
|
125
|
+
export function isCoreDepsInstalled() {
|
|
126
|
+
return CORE_PACKAGES.every(isPackageInstalled);
|
|
86
127
|
}
|
|
87
128
|
|
|
88
129
|
/**
|
|
89
|
-
*
|
|
130
|
+
* 检查全部共享依赖是否已安装
|
|
90
131
|
* @returns {boolean}
|
|
91
132
|
*/
|
|
92
133
|
export function isSharedDepsInstalled() {
|
|
93
|
-
return
|
|
94
|
-
fs.existsSync(path.join(SHARED_DEPS_DIR, "node_modules", "puppeteer-core"));
|
|
134
|
+
return PACKAGES.every(isPackageInstalled);
|
|
95
135
|
}
|
|
96
136
|
|
|
97
|
-
export { SHARED_DEPS_DIR, PACKAGES };
|
|
137
|
+
export { SHARED_DEPS_DIR, PACKAGES, CORE_PACKAGES, VISUAL_COMPARE_PACKAGES };
|
package/lib/visual-compare.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import fs from "node:fs";
|
|
17
17
|
import path from "node:path";
|
|
18
|
-
import {
|
|
18
|
+
import { ensureVisualCompareDeps, loadFromShared } from "./shared-deps.js";
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* 全局视觉对比
|
|
@@ -27,8 +27,8 @@ import { ensureSharedDeps, loadFromShared } from "./shared-deps.js";
|
|
|
27
27
|
* @returns {Promise<object>} 对比结果
|
|
28
28
|
*/
|
|
29
29
|
export async function compareDesign(screenshotPath, designImagePath, options = {}) {
|
|
30
|
-
//
|
|
31
|
-
await
|
|
30
|
+
// 视觉对比才安装 pixelmatch/pngjs/sharp,避免 observe 主链路被重依赖阻塞
|
|
31
|
+
await ensureVisualCompareDeps();
|
|
32
32
|
const sharp = loadFromShared("sharp");
|
|
33
33
|
const pixelmatch = loadFromShared("pixelmatch");
|
|
34
34
|
const { PNG } = loadFromShared("pngjs");
|