@szc-ft/mcp-szcd-client 0.27.0 → 0.27.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/opencode-extension/skills/local-browser-test/SKILL.md +70 -13
- package/opencode-extension/skills/local-browser-test/local-browser-executor.cjs +395 -0
- package/opencode-extension/skills/szcd-design-to-code/SKILL.md +3 -2
- package/package.json +1 -1
- package/qwen-extension/qwen-extension.json +1 -1
- package/qwen-extension/skills/local-browser-test/SKILL.md +70 -13
- package/qwen-extension/skills/local-browser-test/local-browser-executor.cjs +395 -0
- package/qwen-extension/skills/szcd-design-to-code/SKILL.md +3 -2
- package/standard-skill/local-browser-test/SKILL.md +70 -13
- package/standard-skill/local-browser-test/local-browser-executor.cjs +395 -0
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* local-browser-executor.js - 本地浏览器自动化执行器
|
|
4
|
+
*
|
|
5
|
+
* 功能:
|
|
6
|
+
* --action diagnose 全面诊断(JS错误、网络请求、API检查、资源加载)
|
|
7
|
+
* --action list-pages 列出所有标签页
|
|
8
|
+
* --action screenshot 截图
|
|
9
|
+
* --action api-check 检查 API 请求状态
|
|
10
|
+
* --action plan 执行测试计划 JSON
|
|
11
|
+
*
|
|
12
|
+
* 连接方式:
|
|
13
|
+
* --cdp-url http://localhost:9222 (默认自动检测 9222)
|
|
14
|
+
* --auto-detect 自动检测 Chrome/Edge 进程
|
|
15
|
+
*
|
|
16
|
+
* 通用参数:
|
|
17
|
+
* --url <url> 目标页面 URL
|
|
18
|
+
* --url-contains <s> 按关键词查找已打开的页面
|
|
19
|
+
* --wait <ms> 额外等待时间(默认 5000)
|
|
20
|
+
* --output <path> 结果输出文件
|
|
21
|
+
* --output-dir <dir> 截图/产物输出目录
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const puppeteer = require('puppeteer-core');
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const http = require('http');
|
|
28
|
+
|
|
29
|
+
// ===== 参数解析 =====
|
|
30
|
+
const args = process.argv.slice(2);
|
|
31
|
+
function getArg(name, defaultVal) {
|
|
32
|
+
const idx = args.indexOf(`--${name}`);
|
|
33
|
+
if (idx === -1) return defaultVal;
|
|
34
|
+
return args[idx + 1] || defaultVal;
|
|
35
|
+
}
|
|
36
|
+
function hasFlag(name) {
|
|
37
|
+
return args.includes(`--${name}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const ACTION = getArg('action', 'diagnose');
|
|
41
|
+
const CDP_URL = getArg('cdp-url', 'http://localhost:9222');
|
|
42
|
+
const TARGET_URL = getArg('url', null);
|
|
43
|
+
const URL_CONTAINS = getArg('url-contains', null);
|
|
44
|
+
const WAIT_MS = parseInt(getArg('wait', '5000'), 10);
|
|
45
|
+
const OUTPUT = getArg('output', null);
|
|
46
|
+
const OUTPUT_DIR = getArg('output-dir', '/tmp/browser-test');
|
|
47
|
+
const PLAN_FILE = getArg('plan-file', null);
|
|
48
|
+
|
|
49
|
+
// ===== 工具函数 =====
|
|
50
|
+
function log(msg) { console.log(msg); }
|
|
51
|
+
function ensureDir(dir) { fs.mkdirSync(dir, { recursive: true }); }
|
|
52
|
+
|
|
53
|
+
function checkCDP(url) {
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
const req = http.get(url + '/json/version', { timeout: 2000 }, (res) => {
|
|
56
|
+
let data = '';
|
|
57
|
+
res.on('data', c => data += c);
|
|
58
|
+
res.on('end', () => {
|
|
59
|
+
try { resolve(JSON.parse(data)); } catch { resolve(null); }
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
req.on('error', () => resolve(null));
|
|
63
|
+
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function findPage(browser) {
|
|
68
|
+
const pages = await browser.pages();
|
|
69
|
+
if (TARGET_URL) {
|
|
70
|
+
for (const p of pages) {
|
|
71
|
+
if (p.url().includes(TARGET_URL) || p.url() === TARGET_URL) return p;
|
|
72
|
+
}
|
|
73
|
+
// 导航到目标 URL
|
|
74
|
+
const p = pages[0] || await browser.newPage();
|
|
75
|
+
await p.goto(TARGET_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
76
|
+
return p;
|
|
77
|
+
}
|
|
78
|
+
if (URL_CONTAINS) {
|
|
79
|
+
for (const p of pages) {
|
|
80
|
+
if (p.url().includes(URL_CONTAINS)) return p;
|
|
81
|
+
}
|
|
82
|
+
log(`[WARN] 未找到包含 "${URL_CONTAINS}" 的页面,使用第一个页面`);
|
|
83
|
+
}
|
|
84
|
+
// 找到非 about:blank 的页面
|
|
85
|
+
for (const p of pages) {
|
|
86
|
+
if (!p.url().startsWith('about:') && !p.url().startsWith('devtools:')) return p;
|
|
87
|
+
}
|
|
88
|
+
return pages[0];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ===== Action: list-pages =====
|
|
92
|
+
async function listPages(browser) {
|
|
93
|
+
const pages = await browser.pages();
|
|
94
|
+
const result = [];
|
|
95
|
+
for (let i = 0; i < pages.length; i++) {
|
|
96
|
+
const p = pages[i];
|
|
97
|
+
let title = '';
|
|
98
|
+
try { title = await p.title(); } catch {}
|
|
99
|
+
result.push({ index: i, url: p.url(), title });
|
|
100
|
+
log(`[${i}] ${p.url()}`);
|
|
101
|
+
if (title) log(` title: ${title}`);
|
|
102
|
+
}
|
|
103
|
+
return { pages: result, total: result.length };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ===== Action: diagnose =====
|
|
107
|
+
async function diagnose(browser) {
|
|
108
|
+
ensureDir(OUTPUT_DIR);
|
|
109
|
+
const page = await findPage(browser);
|
|
110
|
+
log(`[PAGE] ${page.url()}`);
|
|
111
|
+
|
|
112
|
+
// 收集数据
|
|
113
|
+
const pageErrors = [];
|
|
114
|
+
const consoleMessages = [];
|
|
115
|
+
const failedRequests = [];
|
|
116
|
+
const apiRequests = [];
|
|
117
|
+
const resourceRequests = [];
|
|
118
|
+
|
|
119
|
+
page.on('pageerror', err => {
|
|
120
|
+
pageErrors.push({ message: err.message, stack: err.stack?.split('\n').slice(0, 5).join('\n') });
|
|
121
|
+
});
|
|
122
|
+
page.on('console', msg => {
|
|
123
|
+
const type = msg.type();
|
|
124
|
+
const text = msg.text().substring(0, 500);
|
|
125
|
+
consoleMessages.push({ type, text });
|
|
126
|
+
});
|
|
127
|
+
page.on('response', resp => {
|
|
128
|
+
const url = resp.url();
|
|
129
|
+
const status = resp.status();
|
|
130
|
+
const type = resp.request().resourceType();
|
|
131
|
+
if (type === 'xhr' || type === 'fetch') {
|
|
132
|
+
apiRequests.push({ status, url, method: resp.request().method() });
|
|
133
|
+
}
|
|
134
|
+
if (type === 'stylesheet' || type === 'script') {
|
|
135
|
+
resourceRequests.push({ status, url, type });
|
|
136
|
+
}
|
|
137
|
+
if (status >= 400) {
|
|
138
|
+
failedRequests.push({ status, url, type });
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
page.on('requestfailed', req => {
|
|
142
|
+
failedRequests.push({ status: 'ABORTED', url: req.url(), error: req.failure()?.errorText });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// 刷新页面收集
|
|
146
|
+
log('[INFO] 刷新页面收集诊断数据...');
|
|
147
|
+
try {
|
|
148
|
+
await page.reload({ waitUntil: 'load', timeout: 30000 });
|
|
149
|
+
} catch (e) {
|
|
150
|
+
log(`[WARN] 刷新超时: ${e.message.substring(0, 100)}`);
|
|
151
|
+
}
|
|
152
|
+
await new Promise(r => setTimeout(r, WAIT_MS));
|
|
153
|
+
|
|
154
|
+
// 截图
|
|
155
|
+
const screenshotPath = path.join(OUTPUT_DIR, 'diagnose-screenshot.png');
|
|
156
|
+
await page.screenshot({ path: screenshotPath, fullPage: false });
|
|
157
|
+
log(`[OK] 截图: ${screenshotPath}`);
|
|
158
|
+
|
|
159
|
+
// 检查 iframe
|
|
160
|
+
const frames = page.frames();
|
|
161
|
+
const iframeInfo = [];
|
|
162
|
+
for (const frame of frames) {
|
|
163
|
+
const url = frame.url();
|
|
164
|
+
if (url === page.url() || url === 'about:blank') continue;
|
|
165
|
+
let bodyText = '';
|
|
166
|
+
let rootContent = '';
|
|
167
|
+
try {
|
|
168
|
+
const info = await frame.evaluate(() => ({
|
|
169
|
+
bodyText: document.body?.innerText?.substring(0, 200) || '',
|
|
170
|
+
rootHTML: document.getElementById('root')?.innerHTML?.substring(0, 200) || 'no root',
|
|
171
|
+
hasReactRoot: !document.getElementById('root')?.innerHTML?.includes('loading-spinner'),
|
|
172
|
+
antdElements: document.querySelectorAll('[class*="ant-"]').length,
|
|
173
|
+
}));
|
|
174
|
+
bodyText = info.bodyText;
|
|
175
|
+
rootContent = info.rootHTML;
|
|
176
|
+
iframeInfo.push({ url, ...info });
|
|
177
|
+
} catch (e) {
|
|
178
|
+
iframeInfo.push({ url, error: e.message.substring(0, 100) });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// 输出结果
|
|
183
|
+
const errors = consoleMessages.filter(m => m.type === 'error');
|
|
184
|
+
const warnings = consoleMessages.filter(m => m.type === 'warning');
|
|
185
|
+
|
|
186
|
+
const result = {
|
|
187
|
+
url: page.url(),
|
|
188
|
+
timestamp: new Date().toISOString(),
|
|
189
|
+
screenshot: screenshotPath,
|
|
190
|
+
pageErrors,
|
|
191
|
+
consoleErrors: errors,
|
|
192
|
+
consoleWarnings: warnings,
|
|
193
|
+
consoleTotal: consoleMessages.length,
|
|
194
|
+
failedRequests,
|
|
195
|
+
apiRequests,
|
|
196
|
+
resourceStatus: resourceRequests.map(r => ({
|
|
197
|
+
status: r.status, type: r.type, name: r.url.split('/').pop()
|
|
198
|
+
})),
|
|
199
|
+
iframes: iframeInfo,
|
|
200
|
+
summary: {
|
|
201
|
+
jsErrors: pageErrors.length,
|
|
202
|
+
consoleErrors: consoleMessages.filter(m => m.type === 'error').length,
|
|
203
|
+
failedNetwork: failedRequests.length,
|
|
204
|
+
totalAPI: apiRequests.length,
|
|
205
|
+
failedAPI: apiRequests.filter(r => r.status >= 400).length,
|
|
206
|
+
totalResources: resourceRequests.length,
|
|
207
|
+
failedResources: resourceRequests.filter(r => r.status >= 400).length,
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// 打印摘要
|
|
212
|
+
log('\n========== 诊断结果 ==========');
|
|
213
|
+
log(`JS 异常: ${result.summary.jsErrors}`);
|
|
214
|
+
pageErrors.forEach(e => log(` [PAGEERROR] ${e.message}`));
|
|
215
|
+
log(`Console 错误: ${errors.length}`);
|
|
216
|
+
errors.forEach(e => log(` [ERROR] ${e.text}`));
|
|
217
|
+
log(`Console 警告: ${warnings.length}`);
|
|
218
|
+
warnings.forEach(e => log(` [WARN] ${e.text}`));
|
|
219
|
+
log(`Console 消息总数: ${consoleMessages.length}`);
|
|
220
|
+
log(`失败网络请求: ${result.summary.failedNetwork}`);
|
|
221
|
+
failedRequests.forEach(r => log(` [${r.status}] ${r.url}`));
|
|
222
|
+
log(`API 请求: ${result.summary.totalAPI} (失败: ${result.summary.failedAPI})`);
|
|
223
|
+
apiRequests.forEach(r => {
|
|
224
|
+
const marker = r.status >= 400 ? '❌' : '✅';
|
|
225
|
+
log(` ${marker} [${r.status}] ${r.method} ${r.url}`);
|
|
226
|
+
});
|
|
227
|
+
log(`CSS/JS 资源: ${result.summary.totalResources} (失败: ${result.summary.failedResources})`);
|
|
228
|
+
resourceRequests.filter(r => r.status >= 400).forEach(r => log(` ❌ [${r.status}] ${r.url}`));
|
|
229
|
+
if (iframeInfo.length > 0) {
|
|
230
|
+
log(`\niframe 信息:`);
|
|
231
|
+
iframeInfo.forEach(f => log(` ${f.url} → ${f.error || (f.hasReactRoot ? 'React已挂载' : '未挂载')}`));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// 保存结果
|
|
235
|
+
if (OUTPUT) {
|
|
236
|
+
fs.writeFileSync(OUTPUT, JSON.stringify(result, null, 2));
|
|
237
|
+
log(`\n[OK] 结果已保存: ${OUTPUT}`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return result;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ===== Action: screenshot =====
|
|
244
|
+
async function screenshot(browser) {
|
|
245
|
+
ensureDir(OUTPUT_DIR);
|
|
246
|
+
const page = await findPage(browser);
|
|
247
|
+
const filename = getArg('filename', 'screenshot.png');
|
|
248
|
+
const fullPath = path.join(OUTPUT_DIR, filename);
|
|
249
|
+
const fullPage = hasFlag('full-page');
|
|
250
|
+
await page.screenshot({ path: fullPath, fullPage });
|
|
251
|
+
log(`[OK] 截图已保存: ${fullPath}`);
|
|
252
|
+
return { filepath: fullPath };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ===== Action: api-check =====
|
|
256
|
+
async function apiCheck(browser) {
|
|
257
|
+
const page = await findPage(browser);
|
|
258
|
+
log(`[PAGE] ${page.url()}`);
|
|
259
|
+
|
|
260
|
+
const requests = [];
|
|
261
|
+
const failed = [];
|
|
262
|
+
|
|
263
|
+
page.on('response', resp => {
|
|
264
|
+
const type = resp.request().resourceType();
|
|
265
|
+
if (type === 'xhr' || type === 'fetch') {
|
|
266
|
+
requests.push({
|
|
267
|
+
status: resp.status(),
|
|
268
|
+
url: resp.url(),
|
|
269
|
+
method: resp.request().method(),
|
|
270
|
+
frameUrl: resp.request().frame()?.url?.() || '',
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
page.on('requestfailed', req => {
|
|
275
|
+
failed.push({ url: req.url(), error: req.failure()?.errorText });
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
log('[INFO] 刷新页面收集 API 请求...');
|
|
279
|
+
try { await page.reload({ waitUntil: 'load', timeout: 30000 }); } catch {}
|
|
280
|
+
await new Promise(r => setTimeout(r, WAIT_MS));
|
|
281
|
+
|
|
282
|
+
log('\n========== API 请求 ==========');
|
|
283
|
+
requests.forEach(r => {
|
|
284
|
+
const marker = r.status >= 400 ? '❌' : '✅';
|
|
285
|
+
const isIframe = r.frameUrl && r.frameUrl !== page.url() ? ' [iframe]' : '';
|
|
286
|
+
log(`${marker} [${r.status}] ${r.method} ${r.url}${isIframe}`);
|
|
287
|
+
});
|
|
288
|
+
if (failed.length > 0) {
|
|
289
|
+
log('\n========== 失败请求 ==========');
|
|
290
|
+
failed.forEach(r => log(`[FAILED] ${r.url} → ${r.error}`));
|
|
291
|
+
}
|
|
292
|
+
log(`\n总计: ${requests.length} 请求, 失败: ${requests.filter(r => r.status >= 400).length + failed.length}`);
|
|
293
|
+
|
|
294
|
+
return { requests, failed };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ===== Action: plan =====
|
|
298
|
+
async function executePlan(browser) {
|
|
299
|
+
const planPath = PLAN_FILE;
|
|
300
|
+
if (!planPath) { log('[ERROR] --plan-file 未指定'); return; }
|
|
301
|
+
const plan = JSON.parse(fs.readFileSync(planPath, 'utf-8'));
|
|
302
|
+
ensureDir(plan.options?.outputDir || OUTPUT_DIR);
|
|
303
|
+
|
|
304
|
+
log(`[PLAN] ${plan.description || plan.planId}`);
|
|
305
|
+
log(`[STEPS] ${plan.steps.length} 个步骤`);
|
|
306
|
+
|
|
307
|
+
const results = [];
|
|
308
|
+
let currentPage = await findPage(browser);
|
|
309
|
+
|
|
310
|
+
for (let i = 0; i < plan.steps.length; i++) {
|
|
311
|
+
const step = plan.steps[i];
|
|
312
|
+
const start = Date.now();
|
|
313
|
+
try {
|
|
314
|
+
let result = {};
|
|
315
|
+
switch (step.type) {
|
|
316
|
+
case 'navigate':
|
|
317
|
+
await currentPage.goto(step.url, { waitUntil: step.waitUntil || 'domcontentloaded', timeout: 30000 });
|
|
318
|
+
result = { url: currentPage.url() };
|
|
319
|
+
break;
|
|
320
|
+
case 'screenshot':
|
|
321
|
+
const fp = path.join(plan.options?.outputDir || OUTPUT_DIR, step.filename || `step-${i}.png`);
|
|
322
|
+
await currentPage.screenshot({ path: fp, fullPage: step.fullPage });
|
|
323
|
+
result = { filepath: fp };
|
|
324
|
+
break;
|
|
325
|
+
case 'waitFor':
|
|
326
|
+
if (step.selector) await currentPage.waitForSelector(step.selector, { timeout: step.timeout || 10000 });
|
|
327
|
+
result = { matched: true };
|
|
328
|
+
break;
|
|
329
|
+
case 'evaluate':
|
|
330
|
+
const evalResult = await currentPage.evaluate(step.expression);
|
|
331
|
+
result = { result: evalResult };
|
|
332
|
+
break;
|
|
333
|
+
case 'click':
|
|
334
|
+
await currentPage.click(step.selector);
|
|
335
|
+
result = { clicked: true };
|
|
336
|
+
break;
|
|
337
|
+
case 'checkElement':
|
|
338
|
+
const count = await currentPage.$$eval(step.selector, els => els.length).catch(() => 0);
|
|
339
|
+
const passed = step.expect?.minCount ? count >= step.expect.minCount : count > 0;
|
|
340
|
+
result = { passed, elementCount: count };
|
|
341
|
+
break;
|
|
342
|
+
default:
|
|
343
|
+
result = { skipped: true, reason: `unknown step type: ${step.type}` };
|
|
344
|
+
}
|
|
345
|
+
const duration = Date.now() - start;
|
|
346
|
+
results.push({ index: i, step: step.type, status: 'PASS', duration, ...result });
|
|
347
|
+
log(` [${i + 1}/${plan.steps.length}] ✅ ${step.type} (${duration}ms)`);
|
|
348
|
+
} catch (e) {
|
|
349
|
+
const duration = Date.now() - start;
|
|
350
|
+
results.push({ index: i, step: step.type, status: 'FAIL', duration, error: e.message });
|
|
351
|
+
log(` [${i + 1}/${plan.steps.length}] ❌ ${step.type} (${duration}ms) → ${e.message.substring(0, 100)}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const summary = {
|
|
356
|
+
total: results.length,
|
|
357
|
+
passed: results.filter(r => r.status === 'PASS').length,
|
|
358
|
+
failed: results.filter(r => r.status === 'FAIL').length,
|
|
359
|
+
};
|
|
360
|
+
log(`\n[RESULT] ${summary.passed}/${summary.total} 通过`);
|
|
361
|
+
|
|
362
|
+
if (OUTPUT) {
|
|
363
|
+
fs.writeFileSync(OUTPUT, JSON.stringify({ steps: results, summary }, null, 2));
|
|
364
|
+
}
|
|
365
|
+
return { steps: results, summary };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ===== 主入口 =====
|
|
369
|
+
(async () => {
|
|
370
|
+
// 检测 CDP
|
|
371
|
+
const cdpInfo = await checkCDP(CDP_URL);
|
|
372
|
+
if (!cdpInfo) {
|
|
373
|
+
log(`[ERROR] 无法连接到 CDP: ${CDP_URL}`);
|
|
374
|
+
log('请确保浏览器已启动调试端口:');
|
|
375
|
+
log(' Windows Edge: "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe" --remote-debugging-port=9222');
|
|
376
|
+
log(' Windows Chrome: "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe" --remote-debugging-port=9222');
|
|
377
|
+
process.exit(1);
|
|
378
|
+
}
|
|
379
|
+
log(`[CDP] ${cdpInfo.Browser || 'connected'} (${CDP_URL})`);
|
|
380
|
+
|
|
381
|
+
const browser = await puppeteer.connect({ browserURL: CDP_URL, defaultViewport: null });
|
|
382
|
+
|
|
383
|
+
let result;
|
|
384
|
+
switch (ACTION) {
|
|
385
|
+
case 'list-pages': result = await listPages(browser); break;
|
|
386
|
+
case 'diagnose': result = await diagnose(browser); break;
|
|
387
|
+
case 'screenshot': result = await screenshot(browser); break;
|
|
388
|
+
case 'api-check': result = await apiCheck(browser); break;
|
|
389
|
+
case 'plan': result = await executePlan(browser); break;
|
|
390
|
+
default: log(`[ERROR] 未知 action: ${ACTION}`);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
browser.disconnect();
|
|
394
|
+
log('\n[DONE]');
|
|
395
|
+
})();
|