@szc-ft/mcp-szcd-client 0.28.1 → 0.28.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.
Files changed (29) hide show
  1. package/opencode-extension/skills/local-browser-test/SKILL.md +67 -34
  2. package/opencode-extension/skills/local-browser-test/lib/ant-adapter.js +716 -0
  3. package/opencode-extension/skills/local-browser-test/lib/browser-engine.js +2275 -0
  4. package/opencode-extension/skills/local-browser-test/lib/chrome-finder.js +125 -0
  5. package/opencode-extension/skills/local-browser-test/lib/expect.js +392 -0
  6. package/opencode-extension/skills/local-browser-test/lib/shared-deps.js +137 -0
  7. package/opencode-extension/skills/local-browser-test/lib/test-plan.js +48 -0
  8. package/opencode-extension/skills/local-browser-test/lib/visual-compare.js +192 -0
  9. package/opencode-extension/skills/local-browser-test/local-browser-executor.js +735 -0
  10. package/package.json +1 -1
  11. package/qwen-extension/qwen-extension.json +1 -1
  12. package/qwen-extension/skills/local-browser-test/SKILL.md +67 -34
  13. package/qwen-extension/skills/local-browser-test/lib/ant-adapter.js +716 -0
  14. package/qwen-extension/skills/local-browser-test/lib/browser-engine.js +2275 -0
  15. package/qwen-extension/skills/local-browser-test/lib/chrome-finder.js +125 -0
  16. package/qwen-extension/skills/local-browser-test/lib/expect.js +392 -0
  17. package/qwen-extension/skills/local-browser-test/lib/shared-deps.js +137 -0
  18. package/qwen-extension/skills/local-browser-test/lib/test-plan.js +48 -0
  19. package/qwen-extension/skills/local-browser-test/lib/visual-compare.js +192 -0
  20. package/qwen-extension/skills/local-browser-test/local-browser-executor.js +735 -0
  21. package/standard-skill/local-browser-test/SKILL.md +67 -34
  22. package/standard-skill/local-browser-test/lib/ant-adapter.js +716 -0
  23. package/standard-skill/local-browser-test/lib/browser-engine.js +2275 -0
  24. package/standard-skill/local-browser-test/lib/chrome-finder.js +125 -0
  25. package/standard-skill/local-browser-test/lib/expect.js +392 -0
  26. package/standard-skill/local-browser-test/lib/shared-deps.js +137 -0
  27. package/standard-skill/local-browser-test/lib/test-plan.js +48 -0
  28. package/standard-skill/local-browser-test/lib/visual-compare.js +192 -0
  29. package/standard-skill/local-browser-test/local-browser-executor.js +735 -0
@@ -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,392 @@
1
+ /**
2
+ * 语义断言层 — Playwright expect.poll 风格
3
+ *
4
+ * 设计目标:把"15×200ms 硬循环 + ad-hoc checkElement"统一成 Promise 风格的可组合断言。
5
+ * 所有断言返回 { passed, reason, actual, expected, duration },可直接进测试报告。
6
+ *
7
+ * 用法:
8
+ * const exp = createExpect(engine);
9
+ * await exp.poll(() => frame.$$eval(".ant-select-item-option", els => els.length > 0));
10
+ * await exp.locator(frame, "text=提交").toBeVisible({ timeout: 3000 });
11
+ * await exp.validation(frame).toPass();
12
+ * await exp.url(page).toContain("/catalog/list");
13
+ * await exp.api(captureResult).toAllPass();
14
+ */
15
+
16
+ const DEFAULT_TIMEOUT = 5000;
17
+ const DEFAULT_INTERVAL = 200;
18
+
19
+ /**
20
+ * 轮询直到 predicate 返回 truthy,否则超时
21
+ * @param {Function} predicate - 异步函数,返回值参与判定
22
+ * @param {object} options - { timeout, interval, message }
23
+ * @returns {Promise<{ passed, value, attempts, duration }>}
24
+ */
25
+ export async function poll(predicate, options = {}) {
26
+ const timeout = Number.parseInt(options.timeout ?? DEFAULT_TIMEOUT, 10);
27
+ const interval = Number.parseInt(options.interval ?? DEFAULT_INTERVAL, 10);
28
+ const start = Date.now();
29
+ let attempts = 0;
30
+ let lastValue;
31
+ let lastError;
32
+
33
+ while (Date.now() - start < timeout) {
34
+ attempts += 1;
35
+ try {
36
+ lastValue = await predicate();
37
+ if (lastValue) {
38
+ return {
39
+ passed: true,
40
+ value: lastValue,
41
+ attempts,
42
+ duration: Date.now() - start,
43
+ };
44
+ }
45
+ } catch (err) {
46
+ lastError = err;
47
+ }
48
+ await new Promise((r) => setTimeout(r, interval));
49
+ }
50
+
51
+ return {
52
+ passed: false,
53
+ value: lastValue,
54
+ attempts,
55
+ duration: Date.now() - start,
56
+ reason: lastError
57
+ ? `predicate threw: ${lastError.message}`
58
+ : options.message || `poll timeout after ${timeout}ms (last value: ${JSON.stringify(lastValue)?.slice(0, 200)})`,
59
+ };
60
+ }
61
+
62
+ /**
63
+ * 元素定位断言器:locator(frame, selector) 返回带 toXxx 的对象
64
+ * selector 支持 text= / role= / ~xxx / 普通 CSS
65
+ */
66
+ export function locator(frame, selector) {
67
+ /**
68
+ * 在 frame 内查找匹配 selector 的第一个元素,返回该元素的可观测状态。
69
+ * 一次 evaluate 取齐所有状态,避免多次 round-trip。
70
+ */
71
+ async function probe() {
72
+ return frame.evaluate((sel) => {
73
+ function resolveSelector(s) {
74
+ if (!s) return null;
75
+ if (s.startsWith("~")) {
76
+ const cls = s.slice(1).replace(/^\./, "");
77
+ return `[class*="${cls}"]`;
78
+ }
79
+ return s;
80
+ }
81
+ function isVisible(el) {
82
+ const rect = el.getBoundingClientRect();
83
+ const style = window.getComputedStyle(el);
84
+ return rect.width > 0 && rect.height > 0
85
+ && style.display !== "none"
86
+ && style.visibility !== "hidden"
87
+ && Number(style.opacity) !== 0;
88
+ }
89
+ function isEnabled(el) {
90
+ return !el.disabled && el.getAttribute("aria-disabled") !== "true";
91
+ }
92
+ function findText(text) {
93
+ const candidates = Array.from(document.querySelectorAll(
94
+ "a,button,input,select,textarea,summary,span,div,label,[role],[data-testid]"
95
+ ));
96
+ return candidates.find((el) => {
97
+ if (!isVisible(el)) return false;
98
+ const t = (el.innerText || el.textContent || "").trim();
99
+ return t.includes(text);
100
+ });
101
+ }
102
+ function findRole(roleSpec) {
103
+ const match = roleSpec.match(/^([^\[]+)(?:\[name="([^"]+)"\])?/);
104
+ const role = match?.[1]?.trim();
105
+ const name = match?.[2]?.trim();
106
+ const all = Array.from(document.querySelectorAll(`[role="${role}"]`));
107
+ return all.find((el) => {
108
+ if (!isVisible(el)) return false;
109
+ if (!name) return true;
110
+ const t = (el.innerText || el.textContent || "").trim();
111
+ return t.includes(name) || (el.getAttribute("aria-label") || "").includes(name);
112
+ });
113
+ }
114
+
115
+ let el = null;
116
+ if (sel.startsWith("text=")) {
117
+ el = findText(sel.slice(5).trim().replace(/^["']|["']$/g, ""));
118
+ } else if (sel.startsWith("role=")) {
119
+ el = findRole(sel.slice(5));
120
+ } else {
121
+ try { el = document.querySelector(resolveSelector(sel)); } catch { el = null; }
122
+ }
123
+
124
+ if (!el) return { found: false };
125
+
126
+ const rect = el.getBoundingClientRect();
127
+ return {
128
+ found: true,
129
+ tagName: el.tagName,
130
+ text: (el.innerText || el.textContent || "").trim().slice(0, 200),
131
+ value: "value" in el ? el.value : null,
132
+ visible: isVisible(el),
133
+ enabled: isEnabled(el),
134
+ rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
135
+ attributes: {
136
+ id: el.id || null,
137
+ class: el.className?.toString?.() || null,
138
+ placeholder: el.getAttribute("placeholder"),
139
+ "aria-label": el.getAttribute("aria-label"),
140
+ "aria-checked": el.getAttribute("aria-checked"),
141
+ title: el.getAttribute("title"),
142
+ },
143
+ };
144
+ }, selector);
145
+ }
146
+
147
+ return {
148
+ async toBeVisible(opts = {}) {
149
+ const r = await poll(async () => {
150
+ const s = await probe();
151
+ return s.found && s.visible ? s : null;
152
+ }, { ...opts, message: `expect ${selector} to be visible` });
153
+ return formatAssertResult(r, { assertion: "toBeVisible", selector });
154
+ },
155
+
156
+ async toBeHidden(opts = {}) {
157
+ const r = await poll(async () => {
158
+ const s = await probe();
159
+ return !s.found || !s.visible ? s || { found: false } : null;
160
+ }, { ...opts, message: `expect ${selector} to be hidden` });
161
+ return formatAssertResult(r, { assertion: "toBeHidden", selector });
162
+ },
163
+
164
+ async toBeEnabled(opts = {}) {
165
+ const r = await poll(async () => {
166
+ const s = await probe();
167
+ return s.found && s.enabled ? s : null;
168
+ }, { ...opts, message: `expect ${selector} to be enabled` });
169
+ return formatAssertResult(r, { assertion: "toBeEnabled", selector });
170
+ },
171
+
172
+ async toHaveText(expected, opts = {}) {
173
+ const r = await poll(async () => {
174
+ const s = await probe();
175
+ if (!s.found) return null;
176
+ return s.text.includes(expected) ? s : null;
177
+ }, { ...opts, message: `expect ${selector} to have text "${expected}"` });
178
+ return formatAssertResult(r, { assertion: "toHaveText", selector, expected });
179
+ },
180
+
181
+ async toHaveValue(expected, opts = {}) {
182
+ const r = await poll(async () => {
183
+ const s = await probe();
184
+ if (!s.found || s.value == null) return null;
185
+ return s.value === expected || s.value.includes?.(expected) ? s : null;
186
+ }, { ...opts, message: `expect ${selector} to have value "${expected}"` });
187
+ return formatAssertResult(r, { assertion: "toHaveValue", selector, expected });
188
+ },
189
+
190
+ async toHaveCount(expected, opts = {}) {
191
+ const r = await poll(async () => {
192
+ const count = await frame.evaluate((s) => {
193
+ try { return document.querySelectorAll(s.startsWith("~") ? `[class*="${s.slice(1).replace(/^\./, "")}"]` : s).length; }
194
+ catch { return -1; }
195
+ }, selector);
196
+ return count === expected ? count : null;
197
+ }, { ...opts, message: `expect ${selector} to have count ${expected}` });
198
+ return formatAssertResult(r, { assertion: "toHaveCount", selector, expected });
199
+ },
200
+
201
+ async toHaveAttribute(name, value, opts = {}) {
202
+ const r = await poll(async () => {
203
+ const s = await probe();
204
+ if (!s.found) return null;
205
+ const actual = s.attributes[name];
206
+ if (value === undefined) return actual != null ? actual : null;
207
+ return actual === value || actual?.includes?.(value) ? actual : null;
208
+ }, { ...opts, message: `expect ${selector} attribute ${name} to be "${value}"` });
209
+ return formatAssertResult(r, { assertion: "toHaveAttribute", selector, name, expected: value });
210
+ },
211
+ };
212
+ }
213
+
214
+ /**
215
+ * 表单校验断言:检查 frame 内是否还有 form-item-explain-error
216
+ */
217
+ export function validation(frame) {
218
+ return {
219
+ async toPass(opts = {}) {
220
+ const r = await poll(async () => {
221
+ const errors = await frame.evaluate(() => {
222
+ const items = Array.from(document.querySelectorAll(".ant-form-item-explain-error, .ant-form-item-has-error .ant-form-item-explain"));
223
+ return items
224
+ .filter((el) => {
225
+ const rect = el.getBoundingClientRect();
226
+ return rect.width > 0 && rect.height > 0;
227
+ })
228
+ .map((el) => {
229
+ const formItem = el.closest(".ant-form-item");
230
+ const label = formItem?.querySelector(".ant-form-item-label label, .ant-form-item-label")?.innerText?.trim() || "";
231
+ return {
232
+ field: label,
233
+ message: (el.innerText || el.textContent || "").trim(),
234
+ };
235
+ });
236
+ });
237
+ return errors.length === 0 ? { errors: [] } : null;
238
+ }, { ...opts, message: "expect form validation to pass" });
239
+
240
+ if (r.passed) {
241
+ return formatAssertResult(r, { assertion: "validation.toPass", expected: "no errors" });
242
+ }
243
+ // 失败时把当前错误列表带回去
244
+ const currentErrors = await frame.evaluate(() => {
245
+ const items = Array.from(document.querySelectorAll(".ant-form-item-explain-error, .ant-form-item-has-error .ant-form-item-explain"));
246
+ return items.map((el) => {
247
+ const formItem = el.closest(".ant-form-item");
248
+ const label = formItem?.querySelector(".ant-form-item-label label, .ant-form-item-label")?.innerText?.trim() || "";
249
+ return { field: label, message: (el.innerText || el.textContent || "").trim() };
250
+ });
251
+ }).catch(() => []);
252
+
253
+ return formatAssertResult(r, {
254
+ assertion: "validation.toPass",
255
+ expected: "no errors",
256
+ actual: currentErrors,
257
+ });
258
+ },
259
+
260
+ async toHaveError(fieldOrMessage, opts = {}) {
261
+ const r = await poll(async () => {
262
+ const errors = await frame.evaluate(() => {
263
+ const items = Array.from(document.querySelectorAll(".ant-form-item-explain-error"));
264
+ return items.map((el) => {
265
+ const formItem = el.closest(".ant-form-item");
266
+ const label = formItem?.querySelector(".ant-form-item-label label, .ant-form-item-label")?.innerText?.trim() || "";
267
+ return { field: label, message: (el.innerText || el.textContent || "").trim() };
268
+ });
269
+ });
270
+ const matched = errors.find((e) =>
271
+ e.field.includes(fieldOrMessage) || e.message.includes(fieldOrMessage)
272
+ );
273
+ return matched || null;
274
+ }, { ...opts, message: `expect validation error for "${fieldOrMessage}"` });
275
+ return formatAssertResult(r, { assertion: "validation.toHaveError", expected: fieldOrMessage });
276
+ },
277
+ };
278
+ }
279
+
280
+ /**
281
+ * URL 断言(针对 page,不需要 poll,因为 url 通常已稳定,但提供选项化轮询)
282
+ */
283
+ export function url(page) {
284
+ return {
285
+ async toContain(expected, opts = {}) {
286
+ const r = await poll(async () => {
287
+ const u = page.url();
288
+ return u.includes(expected) ? u : null;
289
+ }, { timeout: opts.timeout ?? 1000, interval: 100, message: `expect url to contain "${expected}"` });
290
+ return formatAssertResult(r, { assertion: "url.toContain", expected, actual: page.url() });
291
+ },
292
+
293
+ async toMatch(regex, opts = {}) {
294
+ const re = regex instanceof RegExp ? regex : new RegExp(regex);
295
+ const r = await poll(async () => {
296
+ const u = page.url();
297
+ return re.test(u) ? u : null;
298
+ }, { timeout: opts.timeout ?? 1000, interval: 100 });
299
+ return formatAssertResult(r, { assertion: "url.toMatch", expected: re.toString(), actual: page.url() });
300
+ },
301
+ };
302
+ }
303
+
304
+ /**
305
+ * API 捕获结果断言(基于 captureApi 的返回值)
306
+ * @param {object} captureResult - captureApi 返回对象
307
+ * @param {object} [options] - { scope: "current-frame"|"all", frameUrlContains }
308
+ */
309
+ export function api(captureResult, options = {}) {
310
+ const allRequests = captureResult?.requests || [];
311
+ const scope = options.scope || "all";
312
+
313
+ // 按 frame 范围过滤请求
314
+ let requests = allRequests;
315
+ if (scope === "current-frame" && options.frameUrlContains) {
316
+ requests = allRequests.filter((r) => r.frameUrl && r.frameUrl.includes(options.frameUrlContains));
317
+ }
318
+
319
+ return {
320
+ toAllPass() {
321
+ const failed = requests.filter((r) => (r.status && r.status >= 400) || r.errorText);
322
+ const passed = failed.length === 0;
323
+ return {
324
+ passed,
325
+ assertion: "api.toAllPass",
326
+ expected: "all requests status < 400",
327
+ actual: failed.length === 0 ? "all ok" : failed,
328
+ totalRequests: allRequests.length,
329
+ scopedRequests: requests.length,
330
+ reason: passed ? null : `${failed.length}/${requests.length} requests failed`,
331
+ };
332
+ },
333
+
334
+ toHaveNoFailed() {
335
+ return this.toAllPass();
336
+ },
337
+
338
+ toInclude(urlPattern) {
339
+ const re = typeof urlPattern === "string" ? new RegExp(urlPattern) : urlPattern;
340
+ const matched = requests.find((r) => re.test(r.url));
341
+ return {
342
+ passed: Boolean(matched),
343
+ assertion: "api.toInclude",
344
+ expected: urlPattern.toString(),
345
+ actual: matched || null,
346
+ totalRequests: allRequests.length,
347
+ scopedRequests: requests.length,
348
+ reason: matched ? null : `no request matched ${urlPattern}`,
349
+ };
350
+ },
351
+
352
+ toHaveCount(expected) {
353
+ return {
354
+ passed: requests.length === expected,
355
+ assertion: "api.toHaveCount",
356
+ expected,
357
+ actual: requests.length,
358
+ totalRequests: allRequests.length,
359
+ scopedRequests: requests.length,
360
+ reason: requests.length === expected ? null : `got ${requests.length} requests`,
361
+ };
362
+ },
363
+ };
364
+ }
365
+
366
+ function formatAssertResult(pollResult, meta) {
367
+ return {
368
+ passed: pollResult.passed,
369
+ assertion: meta.assertion,
370
+ selector: meta.selector,
371
+ expected: meta.expected,
372
+ actual: meta.actual ?? pollResult.value,
373
+ attempts: pollResult.attempts,
374
+ duration: pollResult.duration,
375
+ reason: pollResult.passed ? null : pollResult.reason,
376
+ };
377
+ }
378
+
379
+ /**
380
+ * 工厂:返回绑定 frame/page 的 expect 接口
381
+ */
382
+ export function createExpect(target) {
383
+ return {
384
+ poll,
385
+ locator: (selector) => locator(target.frame || target, selector),
386
+ validation: () => validation(target.frame || target),
387
+ url: () => url(target.page || target),
388
+ api,
389
+ };
390
+ }
391
+
392
+ export default { poll, locator, validation, url, api, createExpect };
@@ -0,0 +1,137 @@
1
+ /**
2
+ * 共享依赖加载器
3
+ *
4
+ * 浏览器自动化依赖安装到 ~/.szcd-mcp/deps/node_modules/,所有项目共享复用。
5
+ * 主链路 observe/act/assert 只安装 puppeteer-core;视觉对比按需安装 pixelmatch/pngjs/sharp。
6
+ */
7
+
8
+ import { createRequire } from "node:module";
9
+ import { homedir } from "node:os";
10
+ import path from "node:path";
11
+ import fs from "node:fs";
12
+
13
+ /** 共享缓存目录 */
14
+ const SHARED_DEPS_DIR = path.join(homedir(), ".szcd-mcp", "deps");
15
+
16
+ /** 核心浏览器自动化依赖 */
17
+ const CORE_PACKAGES = ["puppeteer-core"];
18
+
19
+ /** 视觉对比依赖 */
20
+ const VISUAL_COMPARE_PACKAGES = ["pixelmatch", "pngjs", "sharp"];
21
+
22
+ /** 兼容旧导出 */
23
+ const PACKAGES = [...CORE_PACKAGES, ...VISUAL_COMPARE_PACKAGES];
24
+
25
+ /**
26
+ * 从共享缓存目录加载模块
27
+ * @param {string} moduleName - 模块名
28
+ * @returns {*} 模块导出
29
+ */
30
+ export function loadFromShared(moduleName) {
31
+ const require_ = createRequire(path.join(SHARED_DEPS_DIR, "node_modules", "_"));
32
+ return require_(moduleName);
33
+ }
34
+
35
+ function ensurePackageJson() {
36
+ fs.mkdirSync(SHARED_DEPS_DIR, { recursive: true });
37
+
38
+ const pkgPath = path.join(SHARED_DEPS_DIR, "package.json");
39
+ if (!fs.existsSync(pkgPath)) {
40
+ fs.writeFileSync(pkgPath, JSON.stringify({
41
+ name: "szcd-mcp-shared-deps",
42
+ version: "1.0.0",
43
+ private: true,
44
+ }, null, 2));
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();
66
+
67
+ const { execSync } = await import("node:child_process");
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(" ");
86
+
87
+ try {
88
+ execSync(`npm install ${installFlags} ${pkgList}`.trim(), {
89
+ cwd: SHARED_DEPS_DIR,
90
+ stdio: "inherit",
91
+ timeout: 180000,
92
+ });
93
+ } catch (err) {
94
+ throw new Error(
95
+ `${label}安装失败。请手动执行:\n` +
96
+ ` cd ${SHARED_DEPS_DIR} && npm install ${installFlags} ${pkgList}\n` +
97
+ `原始错误:${err.message}`
98
+ );
99
+ }
100
+
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);
127
+ }
128
+
129
+ /**
130
+ * 检查全部共享依赖是否已安装
131
+ * @returns {boolean}
132
+ */
133
+ export function isSharedDepsInstalled() {
134
+ return PACKAGES.every(isPackageInstalled);
135
+ }
136
+
137
+ export { SHARED_DEPS_DIR, PACKAGES, CORE_PACKAGES, VISUAL_COMPARE_PACKAGES };