@szc-ft/mcp-szcd-client 0.19.1 → 0.20.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.
@@ -19,6 +19,7 @@ export class BrowserEngine {
19
19
  this.page = null;
20
20
  this.mode = null; // 'connect' | 'launch'
21
21
  this.ownedBrowser = false; // launch 模式为 true,决定关闭时是否 kill
22
+ this._activeFrame = null; // 当前活跃 iframe 上下文(微前端场景)
22
23
  }
23
24
 
24
25
  /**
@@ -160,6 +161,23 @@ export class BrowserEngine {
160
161
  }
161
162
  }
162
163
 
164
+ /**
165
+ * 安全执行 page/frame evaluate,捕获重定向导致的 context destroyed 错误
166
+ */
167
+ async safeEval(fn, ...args) {
168
+ const target = this._activeFrame || this.page;
169
+ try {
170
+ return await target.evaluate(fn, ...args);
171
+ } catch (err) {
172
+ if (err.message.includes("Not attached") || err.message.includes("Execution context")) {
173
+ // 重定向后 page/frame 可能已失效,重新获取
174
+ this._activeFrame = null;
175
+ return await this.page.evaluate(fn, ...args);
176
+ }
177
+ throw err;
178
+ }
179
+ }
180
+
163
181
  /**
164
182
  * 步骤分发
165
183
  */
@@ -173,6 +191,10 @@ export class BrowserEngine {
173
191
  case "waitFor": return this._waitFor(step);
174
192
  case "evaluate": return this._evaluate(step);
175
193
  case "checkElement": return this._checkElement(step);
194
+ case "findPage": return this._findPage(step);
195
+ case "findFrame": return this._findFrame(step);
196
+ case "loginWait": return this._loginWait(step);
197
+ case "aiAssert": return this._aiAssert(step, context);
176
198
  default:
177
199
  throw new Error(`Unknown step type: ${step.type}`);
178
200
  }
@@ -180,11 +202,26 @@ export class BrowserEngine {
180
202
 
181
203
  async _navigate(step) {
182
204
  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();
205
+ // 微前端/SSO 重定向链场景:用 domcontentloaded 避免 page 对象被销毁
206
+ const waitUntil = step.waitUntil || (step.allowRedirects ? "domcontentloaded" : "networkidle2");
207
+ try {
208
+ await this.page.goto(step.url, {
209
+ waitUntil,
210
+ timeout: step.timeout || 30000,
211
+ });
212
+ } catch (err) {
213
+ // 重定向链可能触发超时,但页面可能已加载
214
+ if (err.name === "TimeoutError" && step.allowRedirects) {
215
+ // 忽略超时,页面可能正在重定向中
216
+ } else {
217
+ throw err;
218
+ }
219
+ }
220
+ // 重定向后等待页面稳定
221
+ if (step.allowRedirects) {
222
+ await new Promise((r) => setTimeout(r, step.redirectWait || 5000));
223
+ }
224
+ const title = await this.safeEval(() => document.title).catch(() => "");
188
225
  return {
189
226
  url: this.page.url(),
190
227
  title,
@@ -238,32 +275,58 @@ export class BrowserEngine {
238
275
  }
239
276
 
240
277
  async _click(step) {
241
- const selector = step.selector;
242
- await this.page.waitForSelector(selector, { timeout: step.timeout || 5000 });
278
+ const target = this._activeFrame || this.page;
279
+ const selector = this._resolveSelector(step.selector);
280
+
281
+ // iframe 内使用 evaluate 点击(坐标点击无法到达 iframe 内容)
282
+ if (this._activeFrame) {
283
+ const clicked = await this.safeEval((sel) => {
284
+ const el = document.querySelector(sel);
285
+ if (!el) return { found: false };
286
+ el.click();
287
+ return { found: true, tagName: el.tagName };
288
+ }, selector);
289
+ if (!clicked.found) {
290
+ // 回退:模糊匹配
291
+ const fuzzyResult = await this.safeEval((sel) => {
292
+ const base = sel.replace(/\[class\*="([^"]+)"\]/, "$1").replace(/[.#]/, "");
293
+ const el = [...document.querySelectorAll("*")].find(e =>
294
+ (e.className || "").includes(base)
295
+ );
296
+ if (el) { el.click(); return { found: true, tagName: el.tagName, matchedClass: el.className } ; }
297
+ return { found: false };
298
+ }, selector);
299
+ if (!fuzzyResult.found) throw new Error(`Element not found in frame: ${selector}`);
300
+ return { clicked: true, selector, ...fuzzyResult, fuzzyMatch: true };
301
+ }
302
+ return { clicked: true, selector, ...clicked };
303
+ }
243
304
 
305
+ // 主页面:标准 puppeteer 点击
306
+ await target.waitForSelector(selector, { timeout: step.timeout || 5000 }).catch(() => {});
244
307
  if (step.waitForNavigation) {
245
308
  await Promise.all([
246
309
  this.page.waitForNavigation({ timeout: 10000 }),
247
- this.page.click(selector),
310
+ target.click(selector),
248
311
  ]);
249
312
  } else {
250
- await this.page.click(selector);
313
+ await target.click(selector);
251
314
  }
252
-
253
- const tagName = await this.page.$eval(selector, (el) => el.tagName).catch(() => "unknown");
315
+ const tagName = await target.$eval(selector, (el) => el.tagName).catch(() => "unknown");
254
316
  return { clicked: true, selector, tagName };
255
317
  }
256
318
 
257
319
  async _type(step) {
258
- const selector = step.selector;
259
- await this.page.waitForSelector(selector, { timeout: step.timeout || 5000 });
320
+ const target = this._activeFrame || this.page;
321
+ const selector = this._resolveSelector(step.selector);
322
+ await target.waitForSelector(selector, { timeout: step.timeout || 5000 });
260
323
 
261
324
  if (step.clear) {
262
- await this.page.click(selector, { clickCount: 3 });
325
+ await target.click(selector, { clickCount: 3 });
263
326
  }
264
327
 
265
- await this.page.type(selector, step.text);
266
- const value = await this.page.$eval(selector, (el) => el.value).catch(() => "");
328
+ await target.type(selector, step.text);
329
+ const value = await target.$eval(selector, (el) => el.value).catch(() => "");
267
330
  return { typed: true, selector, value };
268
331
  }
269
332
 
@@ -287,15 +350,187 @@ export class BrowserEngine {
287
350
  }
288
351
 
289
352
  async _evaluate(step) {
290
- const result = await this.page.evaluate(step.expression);
353
+ const result = await this.safeEval(step.expression);
291
354
  return { result };
292
355
  }
293
356
 
357
+ /**
358
+ * 查找微前端 iframe(wujie/qiankun/single-spa 等)
359
+ */
360
+ async _findFrame(step) {
361
+ const frames = this.page.frames();
362
+ let matched = null;
363
+ let matchReason = "";
364
+
365
+ if (step.urlIncludes) {
366
+ matched = frames.find((f) => f.url().includes(step.urlIncludes));
367
+ if (matched) matchReason = `url contains "${step.urlIncludes}"`;
368
+ } else if (step.urlRegex) {
369
+ const re = new RegExp(step.urlRegex);
370
+ matched = frames.find((f) => re.test(f.url()));
371
+ if (matched) matchReason = `url matches /${step.urlRegex}/`;
372
+ } else if (step.titleIncludes) {
373
+ // 按页面标题查找 iframe
374
+ for (const f of frames) {
375
+ try {
376
+ const title = await f.evaluate(() => document.title).catch(() => "");
377
+ if (title && title.includes(step.titleIncludes)) {
378
+ matched = f;
379
+ matchReason = `title contains "${step.titleIncludes}"`;
380
+ break;
381
+ }
382
+ } catch { /* context destroyed, skip */ }
383
+ }
384
+ } else if (step.contentIncludes) {
385
+ // 按页面内容查找 iframe(查找包含指定文本的 iframe)
386
+ for (const f of frames) {
387
+ try {
388
+ const has = await f.evaluate((text) =>
389
+ document.body?.innerText?.includes(text) || false,
390
+ step.contentIncludes
391
+ ).catch(() => false);
392
+ if (has) {
393
+ matched = f;
394
+ matchReason = `body contains "${step.contentIncludes}"`;
395
+ break;
396
+ }
397
+ } catch { /* context destroyed, skip */ }
398
+ }
399
+ } else if (step.frameIndex !== undefined) {
400
+ matched = frames[step.frameIndex];
401
+ if (matched) matchReason = `frameIndex ${step.frameIndex}`;
402
+ }
403
+
404
+ if (!matched) {
405
+ // 收集各 frame 的标题以便调试
406
+ const frameInfos = [];
407
+ for (const f of frames) {
408
+ try {
409
+ const title = await f.evaluate(() => document.title).catch(() => "");
410
+ frameInfos.push({ url: f.url(), title: title || "(empty)" });
411
+ } catch {
412
+ frameInfos.push({ url: f.url(), title: "(error)" });
413
+ }
414
+ }
415
+ return {
416
+ found: false,
417
+ totalFrames: frames.length,
418
+ frames: frameInfos,
419
+ error: "No matching frame found",
420
+ };
421
+ }
422
+
423
+ this._activeFrame = matched;
424
+ return {
425
+ found: true,
426
+ frameUrl: matched.url(),
427
+ matchedBy: matchReason,
428
+ totalFrames: frames.length,
429
+ };
430
+ }
431
+
432
+ /**
433
+ * 查找已打开的标签页(connect 模式,端口可变场景)
434
+ */
435
+ async _findPage(step) {
436
+ const pages = await this.browser.pages();
437
+ let matched = null;
438
+ let matchReason = "";
439
+
440
+ if (step.urlIncludes) {
441
+ matched = pages.find((p) => p.url().includes(step.urlIncludes));
442
+ if (matched) matchReason = `url contains "${step.urlIncludes}"`;
443
+ } else if (step.urlRegex) {
444
+ const re = new RegExp(step.urlRegex);
445
+ matched = pages.find((p) => re.test(p.url()));
446
+ if (matched) matchReason = `url matches /${step.urlRegex}/`;
447
+ } else if (step.titleIncludes) {
448
+ for (const p of pages) {
449
+ try {
450
+ const title = await p.title();
451
+ if (title && title.includes(step.titleIncludes)) {
452
+ matched = p;
453
+ matchReason = `title contains "${step.titleIncludes}"`;
454
+ break;
455
+ }
456
+ } catch { /* page may be closed */ }
457
+ }
458
+ }
459
+
460
+ if (!matched) {
461
+ const pageInfos = await Promise.all(
462
+ pages.map(async (p) => {
463
+ try {
464
+ return { url: p.url(), title: (await p.title()) || "(empty)" };
465
+ } catch {
466
+ return { url: p.url(), title: "(error)" };
467
+ }
468
+ })
469
+ );
470
+ return {
471
+ found: false,
472
+ totalPages: pages.length,
473
+ pages: pageInfos,
474
+ error: "No matching page/tab found",
475
+ };
476
+ }
477
+
478
+ this.page = matched;
479
+ this._activeFrame = null; // 重置 frame 上下文
480
+ if (step.viewport) {
481
+ await this.page.setViewport(step.viewport);
482
+ }
483
+ return {
484
+ found: true,
485
+ url: matched.url(),
486
+ title: await matched.title().catch(() => ""),
487
+ matchedBy: matchReason,
488
+ totalPages: pages.length,
489
+ };
490
+ }
491
+
492
+ /**
493
+ * 等待登录完成(SSO 重定向场景)
494
+ * 轮询检测登录页是否消失
495
+ */
496
+ async _loginWait(step) {
497
+ const timeout = step.timeout || 120000;
498
+ const interval = step.interval || 3000;
499
+ const loginSelector = step.loginSelector || 'input[type="password"]';
500
+ const startTime = Date.now();
501
+
502
+ // 检测当前是否在登录页
503
+ const isLoginPage = async () => {
504
+ return this.safeEval((sel) => !!document.querySelector(sel), loginSelector).catch(() => false);
505
+ };
506
+
507
+ let loggedIn = !(await isLoginPage());
508
+ while (!loggedIn && Date.now() - startTime < timeout) {
509
+ await new Promise((r) => setTimeout(r, interval));
510
+ loggedIn = !(await isLoginPage());
511
+ }
512
+
513
+ const elapsed = Date.now() - startTime;
514
+ if (!loggedIn) {
515
+ return { loggedIn: false, elapsed, error: "Login timeout" };
516
+ }
517
+
518
+ // 登录后等待页面稳定
519
+ await new Promise((r) => setTimeout(r, step.postLoginWait || 5000));
520
+ return {
521
+ loggedIn: true,
522
+ elapsed,
523
+ currentUrl: this.page.url(),
524
+ };
525
+ }
526
+
294
527
  async _checkElement(step) {
295
- const selector = step.selector;
528
+ const selector = this._resolveSelector(step.selector);
296
529
  const expect = step.expect || {};
297
530
 
298
- const info = await this.page.evaluate((sel) => {
531
+ // frame 内使用 safeEval,主页面也使用 safeEval 防止 context destroyed
532
+ const info = await this.safeEval((sel) => {
533
+ // 支持 CSS Modules 模糊匹配:[class*=xxx]
299
534
  const els = document.querySelectorAll(sel);
300
535
  return {
301
536
  count: els.length,
@@ -339,6 +574,56 @@ export class BrowserEngine {
339
574
  };
340
575
  }
341
576
 
577
+ /**
578
+ * 解析选择器:自动处理 CSS Modules 哈希类名
579
+ * 如果 selector 以 ~ 开头,转换为 [class*=xxx] 模糊匹配
580
+ */
581
+ /**
582
+ * AI 语义断言:截图并标记需要视觉审查
583
+ * 执行器只负责截图,判断由 Agent 或 MCP 服务端 VLM 完成
584
+ */
585
+ async _aiAssert(step, context) {
586
+ const fs = await import("node:fs");
587
+ const path = await import("node:path");
588
+ const outputDir = context.outputDir || "/tmp";
589
+ if (!fs.existsSync(outputDir)) {
590
+ fs.mkdirSync(outputDir, { recursive: true });
591
+ }
592
+
593
+ const filename = step.filename || `ai-assert-${Date.now()}.png`;
594
+ const filepath = path.join(outputDir, filename);
595
+
596
+ await this.page.screenshot({
597
+ path: filepath,
598
+ fullPage: step.fullPage !== false,
599
+ });
600
+
601
+ return {
602
+ screenshotPath: filepath,
603
+ assertion: step.assertion,
604
+ needsVisualReview: true,
605
+ reviewHint: "请通过以下方式验证此截图:\n" +
606
+ "1. 如果你能读取图片 → 直接查看截图文件判断是否满足断言\n" +
607
+ "2. 如果 MCP 服务器可用 → 上传截图后调用 assert_page_screenshot 工具\n" +
608
+ "3. 降级方案 → 使用 checkElement 步骤做 DOM 级替代验证",
609
+ };
610
+ }
611
+
612
+ _resolveSelector(selector) {
613
+ if (!selector) return selector;
614
+ // ~.editKnowledge → [class*="editKnowledge"]
615
+ if (selector.startsWith("~.")) {
616
+ const base = selector.slice(2);
617
+ return `[class*="${base}"]`;
618
+ }
619
+ // ~#editKnowledge → [id*="editKnowledge"]
620
+ if (selector.startsWith("~#")) {
621
+ const base = selector.slice(2);
622
+ return `[id*="${base}"]`;
623
+ }
624
+ return selector;
625
+ }
626
+
342
627
  /**
343
628
  * 关闭浏览器
344
629
  * connect 模式: disconnect(不关闭用户浏览器)
@@ -361,44 +646,26 @@ export class BrowserEngine {
361
646
  }
362
647
 
363
648
  /**
364
- * 动态加载 puppeteer-core,缺失时自动安装
649
+ * 动态加载 puppeteer-core,从共享缓存目录加载,缺失时自动安装
365
650
  */
366
651
  async function loadPuppeteer() {
367
- // 首次尝试直接加载
652
+ const { ensureSharedDeps, loadFromShared } = await import("./shared-deps.js");
653
+
654
+ // 1. 尝试从项目本地 node_modules 加载(开发环境或已手动安装)
368
655
  try {
369
656
  const mod = await import("puppeteer-core");
370
657
  return mod.default || mod;
371
658
  } catch {
372
- // 未安装,尝试自动安装
659
+ // 本地未安装
373
660
  }
374
661
 
375
- process.stderr.write("[browser-engine] puppeteer-core 未安装,正在自动安装...\n");
376
- const { execSync } = await import("node:child_process");
377
-
378
- const mirrorFlag = "--sharp_binary_host=https://npmmirror.com/mirrors/sharp";
379
- const packages = "puppeteer-core pixelmatch pngjs sharp";
380
-
381
- try {
382
- execSync(`npm install --no-save ${mirrorFlag} ${packages}`, {
383
- stdio: "inherit",
384
- timeout: 120000,
385
- });
386
- } catch (installErr) {
387
- const error = new Error(
388
- `浏览器测试依赖安装失败。请手动执行:\n` +
389
- ` npm install puppeteer-core pixelmatch pngjs sharp --sharp_binary_host=https://npmmirror.com/mirrors/sharp\n` +
390
- `原始错误:${installErr.message}`
391
- );
392
- error.code = "DEPENDENCY_INSTALL_FAILED";
393
- throw error;
394
- }
662
+ // 2. 确保共享缓存已安装,然后从共享目录加载
663
+ await ensureSharedDeps();
395
664
 
396
- // 安装成功,重新加载
397
665
  try {
398
- const mod = await import("puppeteer-core");
399
- return mod.default || mod;
666
+ return loadFromShared("puppeteer-core");
400
667
  } catch (err) {
401
- const error = new Error(`puppeteer-core 安装后仍无法加载:${err.message}`);
668
+ const error = new Error(`puppeteer-core 加载失败:${err.message}`);
402
669
  error.code = "DEPENDENCY_MISSING";
403
670
  throw error;
404
671
  }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * 共享依赖加载器
3
+ *
4
+ * 浏览器自动化依赖(puppeteer-core, pixelmatch, pngjs, sharp)
5
+ * 安装到 ~/.szcd-mcp/deps/node_modules/,所有项目共享复用。
6
+ * 首次使用时自动安装,后续直接加载。
7
+ */
8
+
9
+ import { createRequire } from "node:module";
10
+ import { homedir } from "node:os";
11
+ import path from "node:path";
12
+ import fs from "node:fs";
13
+
14
+ /** 共享缓存目录 */
15
+ const SHARED_DEPS_DIR = path.join(homedir(), ".szcd-mcp", "deps");
16
+
17
+ /** 需要共享安装的包列表 */
18
+ const PACKAGES = ["puppeteer-core", "pixelmatch", "pngjs", "sharp"];
19
+
20
+ /** 标记文件,存在表示依赖已安装 */
21
+ const INSTALLED_MARKER = path.join(SHARED_DEPS_DIR, ".installed");
22
+
23
+ /**
24
+ * 从共享缓存目录加载模块
25
+ * @param {string} moduleName - 模块名
26
+ * @returns {*} 模块导出
27
+ */
28
+ export function loadFromShared(moduleName) {
29
+ const require_ = createRequire(path.join(SHARED_DEPS_DIR, "node_modules", "_"));
30
+ return require_(moduleName);
31
+ }
32
+
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
+ // 确保目录存在
51
+ fs.mkdirSync(SHARED_DEPS_DIR, { recursive: true });
52
+
53
+ // 初始化 package.json(如果不存在)
54
+ const pkgPath = path.join(SHARED_DEPS_DIR, "package.json");
55
+ if (!fs.existsSync(pkgPath)) {
56
+ fs.writeFileSync(pkgPath, JSON.stringify({
57
+ name: "szcd-mcp-shared-deps",
58
+ version: "1.0.0",
59
+ private: true,
60
+ }, null, 2));
61
+ }
62
+
63
+ // 安装依赖
64
+ const { execSync } = await import("node:child_process");
65
+ const pkgList = PACKAGES.join(" ");
66
+ const mirrorFlag = "--sharp_binary_host=https://npmmirror.com/mirrors/sharp";
67
+ const registryFlag = "--registry=https://npmmirror.com";
68
+
69
+ try {
70
+ execSync(`npm install ${registryFlag} ${mirrorFlag} ${pkgList}`, {
71
+ cwd: SHARED_DEPS_DIR,
72
+ stdio: "inherit",
73
+ timeout: 180000,
74
+ });
75
+ } catch (err) {
76
+ throw new Error(
77
+ `浏览器测试依赖安装失败。请手动执行:\n` +
78
+ ` cd ${SHARED_DEPS_DIR} && npm install ${pkgList}\n` +
79
+ `原始错误:${err.message}`
80
+ );
81
+ }
82
+
83
+ // 写入标记文件
84
+ fs.writeFileSync(INSTALLED_MARKER, new Date().toISOString());
85
+ process.stderr.write("[shared-deps] 共享依赖安装完成!后续所有项目将复用此安装。\n");
86
+ }
87
+
88
+ /**
89
+ * 检查共享依赖是否已安装
90
+ * @returns {boolean}
91
+ */
92
+ export function isSharedDepsInstalled() {
93
+ return fs.existsSync(INSTALLED_MARKER) &&
94
+ fs.existsSync(path.join(SHARED_DEPS_DIR, "node_modules", "puppeteer-core"));
95
+ }
96
+
97
+ export { SHARED_DEPS_DIR, PACKAGES };
@@ -7,7 +7,7 @@
7
7
  * - 三色 diff 图输出(红=真实差异,黄=抗锯齿,蓝=轻微色差)
8
8
  * - 自动 resize 设计稿到截图尺寸
9
9
  *
10
- * 依赖(optionalDependencies):
10
+ * 依赖从共享缓存加载(~/.szcd-mcp/deps/):
11
11
  * - sharp: 图片处理和 resize
12
12
  * - pixelmatch: 像素对比
13
13
  * - pngjs: PNG 编解码
@@ -15,6 +15,7 @@
15
15
 
16
16
  import fs from "node:fs";
17
17
  import path from "node:path";
18
+ import { ensureSharedDeps, loadFromShared } from "./shared-deps.js";
18
19
 
19
20
  /**
20
21
  * 全局视觉对比
@@ -26,9 +27,11 @@ import path from "node:path";
26
27
  * @returns {Promise<object>} 对比结果
27
28
  */
28
29
  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");
30
+ // 确保共享依赖已安装,然后加载
31
+ await ensureSharedDeps();
32
+ const sharp = loadFromShared("sharp");
33
+ const pixelmatch = loadFromShared("pixelmatch");
34
+ const { PNG } = loadFromShared("pngjs");
32
35
 
33
36
  // 1. 读取图片元数据
34
37
  const screenshotMeta = await sharp(screenshotPath).metadata();
@@ -12,6 +12,7 @@
12
12
  * --output 结果输出文件路径(默认 stdout)
13
13
  * --cdp-url Chrome DevTools Protocol URL(默认自动检测 localhost:9222)
14
14
  * --page-url 连接已有 Chrome 时的目标页面 URL
15
+ * --base-url 基础 URL,替换计划中的 {{baseUrl}} 占位符
15
16
  *
16
17
  * 退出码:
17
18
  * 0 - 全部步骤执行成功
@@ -50,6 +51,9 @@ function parseArgs() {
50
51
  case "--page-url":
51
52
  parsed.pageUrl = args[++i];
52
53
  break;
54
+ case "--base-url":
55
+ parsed.baseUrl = args[++i];
56
+ break;
53
57
  case "--help":
54
58
  case "-h":
55
59
  printHelp();
@@ -73,19 +77,27 @@ function printHelp() {
73
77
  --output <path> 结果输出文件路径(默认 stdout)
74
78
  --cdp-url <url> Chrome DevTools Protocol URL(默认 http://localhost:9222)
75
79
  --page-url <url> 目标页面 URL(connect 模式时匹配标签页)
80
+ --base-url <url> 基础 URL,替换计划中的 {{baseUrl}} 占位符
76
81
  -h, --help 显示帮助
77
82
  `);
78
83
  }
79
84
 
80
85
  function loadPlan(args) {
86
+ let raw;
81
87
  if (args.planJson) {
82
- return JSON.parse(args.planJson);
88
+ raw = args.planJson;
89
+ } else if (args.planFile) {
90
+ raw = fs.readFileSync(args.planFile, "utf8");
91
+ } else {
92
+ throw new Error("必须提供 --plan 或 --plan-file 参数");
83
93
  }
84
- if (args.planFile) {
85
- const content = fs.readFileSync(args.planFile, "utf8");
86
- return JSON.parse(content);
94
+
95
+ // 模板变量替换:{{baseUrl}} --base-url 参数值
96
+ if (args.baseUrl) {
97
+ raw = raw.replace(/\{\{baseUrl\}\}/g, args.baseUrl);
87
98
  }
88
- throw new Error("必须提供 --plan 或 --plan-file 参数");
99
+
100
+ return JSON.parse(raw);
89
101
  }
90
102
 
91
103
  // ==================== 执行引擎 ====================
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@szc-ft/mcp-szcd-client",
3
- "version": "0.19.1",
3
+ "version": "0.20.0",
4
4
  "description": "MCP client for szcd component library - auto-configures AI coding tools with MCP server, skills, agents and commands",
5
5
  "keywords": [
6
6
  "mcp",
@@ -57,12 +57,6 @@
57
57
  "dependencies": {
58
58
  "@modelcontextprotocol/sdk": "^1.29.0"
59
59
  },
60
- "optionalDependencies": {
61
- "puppeteer-core": "^22.15.0",
62
- "pixelmatch": "^5.3.0",
63
- "pngjs": "^7.0.0",
64
- "sharp": "^0.33.5"
65
- },
66
60
  "repository": {
67
61
  "type": "git",
68
62
  "url": "git+https://github.com/szc-ft/mcp-szcd.git"
@@ -135,6 +135,14 @@ szcd 是基于 Ant Design 5.27 封装的企业级 React 组件库,采用分层
135
135
  - API 路径从代码中的接口调用读取
136
136
  - checkElement 的 expect 从代码逻辑推断(columns 数量、数据行数等)
137
137
  - 有设计稿时添加 compare 步骤,无设计稿时跳过
138
+ - 需要语义级验证时添加 `aiAssert` 步骤(如布局结构、内容正确性、功能完整性)
139
+ - `aiAssert` 截图后:如果你能读图则直接判断;否则上传截图调用 `assert_page_screenshot` 工具;都不可用则降级为 `checkElement`
140
+
141
+ **微前端场景特别注意**:
142
+ - 端口可变,**不要硬编码 URL**。用 `findPage` 按路径/标题发现标签页,或用 `{{baseUrl}}` 模板变量 + `--base-url` 运行时传入
143
+ - 子应用在 iframe 内,用 `findFrame` 定位(优先 `contentIncludes` 按内容发现),后续步骤自动在 iframe 内执行
144
+ - CSS Modules 哈希类名用 `~.className` 语法模糊匹配
145
+ - 详见 `local-browser-test` skill 的「微前端测试指南」章节
138
146
 
139
147
  ### 步骤7:收集用户反馈(必做,质量闭环)
140
148