@szc-ft/mcp-szcd-client 0.28.2 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/szcd-component-expert.qoder.md +1 -0
- package/opencode-extension/skills/local-browser-test/SKILL.md +157 -21
- package/opencode-extension/skills/local-browser-test/lib/ant-adapter.js +128 -109
- package/opencode-extension/skills/local-browser-test/lib/browser-engine.js +210 -11
- package/opencode-extension/skills/local-browser-test/local-browser-executor.js +50 -3
- package/opencode-extension/skills/local-browser-test/package.json +1 -0
- package/opencode-extension/skills/szcd-upload-zip/SKILL.md +122 -0
- package/package.json +1 -1
- package/qwen-extension/qwen-extension.json +1 -1
- package/qwen-extension/skills/local-browser-test/SKILL.md +157 -21
- package/qwen-extension/skills/local-browser-test/lib/ant-adapter.js +128 -109
- package/qwen-extension/skills/local-browser-test/lib/browser-engine.js +210 -11
- package/qwen-extension/skills/local-browser-test/local-browser-executor.js +50 -3
- package/qwen-extension/skills/local-browser-test/package.json +1 -0
- package/qwen-extension/skills/szcd-upload-zip/SKILL.md +122 -0
- package/standard-skill/local-browser-test/SKILL.md +157 -21
- package/standard-skill/local-browser-test/lib/ant-adapter.js +128 -109
- package/standard-skill/local-browser-test/lib/browser-engine.js +210 -11
- package/standard-skill/local-browser-test/local-browser-executor.js +50 -3
- package/standard-skill/local-browser-test/package.json +1 -0
- package/standard-skill/szcd-upload-zip/SKILL.md +122 -0
|
@@ -24,6 +24,7 @@ export class BrowserEngine {
|
|
|
24
24
|
this._activeFrame = null; // 当前活跃 iframe 上下文(微前端场景)
|
|
25
25
|
this._adapter = null; // 懒加载 AntAdapter
|
|
26
26
|
this._lastActWasDropdown = false; // 用于 act 入口自动 closeAllDropdowns
|
|
27
|
+
this._wujieContext = null; // wujie 微前端模式 { type, frameCount, wujieApp? }
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
/**
|
|
@@ -72,6 +73,138 @@ export class BrowserEngine {
|
|
|
72
73
|
}
|
|
73
74
|
}
|
|
74
75
|
|
|
76
|
+
/**
|
|
77
|
+
* 检测 wujie 微前端模式(WebComponent / iframe)
|
|
78
|
+
* @returns {{ type: 'webcomponent'|'iframe'|'none', frameCount: number, wujieApp?: object }}
|
|
79
|
+
*/
|
|
80
|
+
async _detectWujieMode() {
|
|
81
|
+
const frames = this.page.frames();
|
|
82
|
+
const frameCount = frames.length;
|
|
83
|
+
const mainFrame = this.page.mainFrame();
|
|
84
|
+
// JSON 输出返回完整文本不截断;stderr 日志仅截取前 200 字符预览
|
|
85
|
+
const LOG_PREVIEW_LEN = 200;
|
|
86
|
+
|
|
87
|
+
// 检查主文档中是否有 <wujie-app> 自定义元素(WebComponent 模式标志)
|
|
88
|
+
const wujieInfo = await mainFrame.evaluate(() => {
|
|
89
|
+
const app = document.querySelector('wujie-app');
|
|
90
|
+
if (!app) return null;
|
|
91
|
+
const sr = app.shadowRoot;
|
|
92
|
+
const body = sr?.querySelector('body');
|
|
93
|
+
const fullText = body?.innerText || '';
|
|
94
|
+
return {
|
|
95
|
+
tagName: app.tagName,
|
|
96
|
+
className: app.className,
|
|
97
|
+
hasShadowRoot: !!sr,
|
|
98
|
+
shadowChildren: sr?.children?.length || 0,
|
|
99
|
+
shadowBodyText: fullText,
|
|
100
|
+
shadowBodyTextLength: fullText.length,
|
|
101
|
+
};
|
|
102
|
+
}).catch(() => null);
|
|
103
|
+
|
|
104
|
+
if (wujieInfo) {
|
|
105
|
+
const ctx = { type: 'webcomponent', frameCount, wujieApp: wujieInfo };
|
|
106
|
+
process.stderr.write(`[wujie] WebComponent 模式: <${wujieInfo.tagName} class="${wujieInfo.className}"> shadowRoot=${wujieInfo.hasShadowRoot} children=${wujieInfo.shadowChildren} frames=${frameCount}\n`);
|
|
107
|
+
// stderr 只输出预览(不刷屏),JSON 中返回全文
|
|
108
|
+
const preview = wujieInfo.shadowBodyText?.substring(0, LOG_PREVIEW_LEN) || '';
|
|
109
|
+
process.stderr.write(`[wujie] shadow body (${wujieInfo.shadowBodyTextLength} chars): "${preview}${wujieInfo.shadowBodyTextLength > LOG_PREVIEW_LEN ? '...' : ''}"\n`);
|
|
110
|
+
// 列出所有 frame 供排障
|
|
111
|
+
for (let i = 0; i < frames.length; i++) {
|
|
112
|
+
process.stderr.write(`[wujie] frame[${i}]: ${frames[i].url().substring(0, 100)}\n`);
|
|
113
|
+
}
|
|
114
|
+
return ctx;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (frameCount > 1) {
|
|
118
|
+
process.stderr.write(`[wujie] iframe 模式: ${frameCount} frames\n`);
|
|
119
|
+
for (let i = 0; i < frames.length; i++) {
|
|
120
|
+
process.stderr.write(`[wujie] frame[${i}]: ${frames[i].url().substring(0, 100)}\n`);
|
|
121
|
+
}
|
|
122
|
+
return { type: 'iframe', frameCount };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
process.stderr.write(`[wujie] 未检测到微前端 (frames=${frameCount})\n`);
|
|
126
|
+
return { type: 'none', frameCount };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 安全获取页面列表(优先同步 targets() 避免 pages() 挂死 + 泄露 WebSocket)
|
|
131
|
+
*/
|
|
132
|
+
async _safeGetPages() {
|
|
133
|
+
// 1. 优先使用同步 targets(),不会挂死也不会泄露未完成的 promise
|
|
134
|
+
try {
|
|
135
|
+
const targets = this.browser.targets();
|
|
136
|
+
const pageTargets = targets.filter((t) => t.type() === "page");
|
|
137
|
+
if (pageTargets.length > 0) {
|
|
138
|
+
const pages = [];
|
|
139
|
+
for (const t of pageTargets) {
|
|
140
|
+
try {
|
|
141
|
+
const p = await Promise.race([
|
|
142
|
+
t.page(),
|
|
143
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("page() timeout")), 3000)),
|
|
144
|
+
]);
|
|
145
|
+
if (p) pages.push(p);
|
|
146
|
+
} catch {
|
|
147
|
+
// 跳过无法获取的 target
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (pages.length > 0) return pages;
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
// targets() 失败
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 2. 回退:CDP Target.getTargets
|
|
157
|
+
try {
|
|
158
|
+
const client = await this.browser.target().createCDPSession();
|
|
159
|
+
const { targetInfos } = await client.send("Target.getTargets");
|
|
160
|
+
const pageInfos = targetInfos.filter((t) => t.type === "page");
|
|
161
|
+
const pages = [];
|
|
162
|
+
for (const info of pageInfos) {
|
|
163
|
+
try {
|
|
164
|
+
const target = await Promise.race([
|
|
165
|
+
this.browser.waitForTarget((t) => t.url() === info.url, { timeout: 2000 }),
|
|
166
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("waitForTarget timeout")), 3000)),
|
|
167
|
+
]);
|
|
168
|
+
if (target) {
|
|
169
|
+
const p = await Promise.race([
|
|
170
|
+
target.page(),
|
|
171
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("page() timeout")), 3000)),
|
|
172
|
+
]);
|
|
173
|
+
if (p) pages.push(p);
|
|
174
|
+
}
|
|
175
|
+
} catch {
|
|
176
|
+
// skip
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (pages.length > 0) return pages;
|
|
180
|
+
} catch {
|
|
181
|
+
// CDP 回退也失败
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 3. 最后手段:只能尝试 pages()(带严格超时,用完立即 disconnect/reconnect 清理)
|
|
185
|
+
try {
|
|
186
|
+
const pages = await Promise.race([
|
|
187
|
+
this.browser.pages(),
|
|
188
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("pages() timeout")), 5000)),
|
|
189
|
+
]);
|
|
190
|
+
if (pages && pages.length > 0) return pages;
|
|
191
|
+
} catch {
|
|
192
|
+
// pages() 超时;注意此时 browser.pages() 的 WebSocket 可能泄露
|
|
193
|
+
// 清理:断开后重新连接
|
|
194
|
+
try {
|
|
195
|
+
const wsUrl = this.browser.wsEndpoint();
|
|
196
|
+
const puppeteer = await loadPuppeteer();
|
|
197
|
+
await this.browser.disconnect();
|
|
198
|
+
this.browser = await puppeteer.connect({ browserWSEndpoint: wsUrl, defaultViewport: null });
|
|
199
|
+
} catch {
|
|
200
|
+
// reconnect 失败,继续用当前 browser
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 4. 最后手段:newPage()
|
|
205
|
+
return [await this.browser.newPage()];
|
|
206
|
+
}
|
|
207
|
+
|
|
75
208
|
/**
|
|
76
209
|
* 模式 A:连接到用户已有的 Chrome
|
|
77
210
|
*/
|
|
@@ -82,7 +215,7 @@ export class BrowserEngine {
|
|
|
82
215
|
this.ownedBrowser = false;
|
|
83
216
|
|
|
84
217
|
// 获取页面:优先使用指定 URL 匹配的标签页
|
|
85
|
-
const pages = await this.
|
|
218
|
+
const pages = await this._safeGetPages();
|
|
86
219
|
if (options.pageUrl) {
|
|
87
220
|
this.page = pages.find((p) => p.url().includes(options.pageUrl))
|
|
88
221
|
|| await this.browser.newPage();
|
|
@@ -96,6 +229,9 @@ export class BrowserEngine {
|
|
|
96
229
|
await this.page.setViewport(options.viewport);
|
|
97
230
|
}
|
|
98
231
|
|
|
232
|
+
// 微前端模式检测
|
|
233
|
+
this._wujieContext = await this._detectWujieMode();
|
|
234
|
+
|
|
99
235
|
return {
|
|
100
236
|
mode: "connect",
|
|
101
237
|
cdpUrl,
|
|
@@ -223,6 +359,7 @@ export class BrowserEngine {
|
|
|
223
359
|
frameUrl: targetFrame.frame.url(),
|
|
224
360
|
matchedBy: targetFrame.matchedBy,
|
|
225
361
|
},
|
|
362
|
+
wujieContext: this._wujieContext,
|
|
226
363
|
frames,
|
|
227
364
|
tree,
|
|
228
365
|
interactiveElements,
|
|
@@ -247,7 +384,10 @@ export class BrowserEngine {
|
|
|
247
384
|
|
|
248
385
|
const targetFrame = await this._resolveObserveFrame(options);
|
|
249
386
|
if (options.menuPath) {
|
|
250
|
-
|
|
387
|
+
// 菜单在 main frame 外壳中渲染,不随子应用切换
|
|
388
|
+
const menuFrame = this.page.mainFrame();
|
|
389
|
+
const menuTarget = { frame: menuFrame, frameId: "main", matchedBy: "main (menu shell)" };
|
|
390
|
+
return this._actMenuPath(menuTarget, options);
|
|
251
391
|
}
|
|
252
392
|
|
|
253
393
|
const index = options.index !== undefined ? Number.parseInt(options.index, 10) : undefined;
|
|
@@ -308,17 +448,36 @@ export class BrowserEngine {
|
|
|
308
448
|
|
|
309
449
|
function findBySelector(selector) {
|
|
310
450
|
if (!selector) return null;
|
|
451
|
+
// text== 精确匹配(Playwright 兼容):只匹配 innerText 完全相等的元素
|
|
452
|
+
if (selector.startsWith("text==")) {
|
|
453
|
+
const expected = selector.slice(6).trim();
|
|
454
|
+
const filter = (el) => isVisibleEnabled(el);
|
|
455
|
+
const interactive = interactiveElements().filter(filter);
|
|
456
|
+
let hit = interactive.find((el) => getText(el) === expected || getName(el, getText(el)) === expected);
|
|
457
|
+
if (hit) return hit;
|
|
458
|
+
const all = Array.from(document.querySelectorAll("*")).filter(filter);
|
|
459
|
+
return all.find((el) => getText(el) === expected) || null;
|
|
460
|
+
}
|
|
461
|
+
// text= 模糊匹配:优先选 innerText 最短的元素(离目标文字最近的叶子节点)
|
|
462
|
+
// 避免命中包含目标文字的大容器(如 drawer 整体 div)
|
|
311
463
|
if (selector.startsWith("text=")) {
|
|
312
464
|
const expected = selector.slice(5).trim();
|
|
313
|
-
// 默认仅匹配 visible+enabled 元素,避免命中 ant-select 的隐藏 input、收起菜单里的 li
|
|
314
|
-
// 需要包含隐藏元素时显式 includeHidden=true
|
|
315
465
|
const includeHidden = actOptions.includeHidden === true;
|
|
316
466
|
const filter = (el) => includeHidden || isVisibleEnabled(el);
|
|
317
467
|
const interactive = interactiveElements().filter(filter);
|
|
318
|
-
let
|
|
319
|
-
if (
|
|
468
|
+
let hits = interactive.filter((el) => getText(el).includes(expected) || getName(el, getText(el)).includes(expected));
|
|
469
|
+
if (hits.length > 0) {
|
|
470
|
+
// 按 innerText 长度升序:最短的最可能是目标元素本身而非父容器
|
|
471
|
+
hits.sort((a, b) => getText(a).length - getText(b).length);
|
|
472
|
+
return hits[0];
|
|
473
|
+
}
|
|
320
474
|
const all = Array.from(document.querySelectorAll("*")).filter(filter);
|
|
321
|
-
|
|
475
|
+
hits = all.filter((el) => getText(el).includes(expected));
|
|
476
|
+
if (hits.length > 0) {
|
|
477
|
+
hits.sort((a, b) => getText(a).length - getText(b).length);
|
|
478
|
+
return hits[0];
|
|
479
|
+
}
|
|
480
|
+
return null;
|
|
322
481
|
}
|
|
323
482
|
if (selector.startsWith("role=")) {
|
|
324
483
|
const match = selector.match(/^role=([^\[]+)(?:\[name="([^"]+)"\])?/);
|
|
@@ -944,6 +1103,15 @@ export class BrowserEngine {
|
|
|
944
1103
|
}
|
|
945
1104
|
|
|
946
1105
|
async _resolveObserveFrame(options = {}) {
|
|
1106
|
+
// testCase setup 已预定位 iframe,且调用方未显式传 frame 参数 → 直接复用
|
|
1107
|
+
if (this._activeFrame && options.frameIndex === undefined && !options.frameUrlContains && !options.frameContentContains) {
|
|
1108
|
+
const frames = this.page.frames();
|
|
1109
|
+
const idx = frames.indexOf(this._activeFrame);
|
|
1110
|
+
if (idx >= 0) {
|
|
1111
|
+
return { frame: this._activeFrame, frameId: idx === 0 ? "main" : `frame-${idx}`, matchedBy: "activeFrame (from testCase setup)" };
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
947
1115
|
const frames = this.page.frames();
|
|
948
1116
|
const mainFrame = this.page.mainFrame();
|
|
949
1117
|
|
|
@@ -973,6 +1141,14 @@ export class BrowserEngine {
|
|
|
973
1141
|
}
|
|
974
1142
|
}
|
|
975
1143
|
|
|
1144
|
+
// 无显式 frame 参数时:WebComponent 模式默认用 sandbox iframe(含子应用实际 DOM)
|
|
1145
|
+
if (this._wujieContext?.type === 'webcomponent' && frames.length > 1) {
|
|
1146
|
+
const sandboxIdx = frames.findIndex((f) => f !== mainFrame && !f.url().startsWith('about:'));
|
|
1147
|
+
if (sandboxIdx >= 0) {
|
|
1148
|
+
return { frame: frames[sandboxIdx], frameId: `frame-${sandboxIdx}`, matchedBy: 'webcomponent sandbox iframe' };
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
|
|
976
1152
|
const mainIndex = frames.indexOf(mainFrame);
|
|
977
1153
|
return { frame: mainFrame, frameId: mainIndex <= 0 ? "main" : `frame-${mainIndex}`, matchedBy: "main" };
|
|
978
1154
|
}
|
|
@@ -1097,8 +1273,23 @@ export class BrowserEngine {
|
|
|
1097
1273
|
|
|
1098
1274
|
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
|
|
1099
1275
|
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
|
1100
|
-
|
|
1101
|
-
|
|
1276
|
+
|
|
1277
|
+
// 1. 先收集 portal 弹窗/抽屉中的元素(排在前面,避免 text= 匹配到外层容器)
|
|
1278
|
+
const portalSelectors = '.ant-drawer:not(.ant-drawer-closed), .ant-drawer-open .ant-drawer-content-wrapper, .ant-modal-wrap:not([style*="display: none"]), .ant-dropdown:not(.ant-dropdown-hidden)';
|
|
1279
|
+
const portalEls = [];
|
|
1280
|
+
document.querySelectorAll(portalSelectors).forEach(portal => {
|
|
1281
|
+
portal.querySelectorAll('a,button,input,select,textarea,summary,[role],[tabindex],[data-testid],[class*="btn"],[class*="button"],[class*="ant-btn"]').forEach(el => {
|
|
1282
|
+
if (isCandidate(el)) portalEls.push(el);
|
|
1283
|
+
});
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
// 2. 再收集 body 中的元素,但要排除已在 portal 中的(避免重复)
|
|
1287
|
+
const portalSet = new Set(portalEls);
|
|
1288
|
+
const bodyEls = 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']"))
|
|
1289
|
+
.filter(el => isCandidate(el) && !portalSet.has(el));
|
|
1290
|
+
|
|
1291
|
+
// portal 元素排前面(优先级更高),body 元素排后面
|
|
1292
|
+
return portalEls.concat(bodyEls)
|
|
1102
1293
|
.slice(0, limit)
|
|
1103
1294
|
.map((el, idx) => {
|
|
1104
1295
|
const rect = el.getBoundingClientRect();
|
|
@@ -1693,7 +1884,7 @@ export class BrowserEngine {
|
|
|
1693
1884
|
* 查找已打开的标签页(connect 模式,端口可变场景)
|
|
1694
1885
|
*/
|
|
1695
1886
|
async _findPage(step) {
|
|
1696
|
-
const pages = await this.
|
|
1887
|
+
const pages = await this._safeGetPages();
|
|
1697
1888
|
let matched = null;
|
|
1698
1889
|
let matchReason = "";
|
|
1699
1890
|
|
|
@@ -2114,7 +2305,15 @@ export class BrowserEngine {
|
|
|
2114
2305
|
const results = [];
|
|
2115
2306
|
const dataset = testCase.dataset || [{ __row: 0 }];
|
|
2116
2307
|
|
|
2117
|
-
// setup 阶段:frame 预定位
|
|
2308
|
+
// setup 阶段:page 切换 + frame 预定位
|
|
2309
|
+
if (testCase.setup?.findPage) {
|
|
2310
|
+
const pageResult = await this._findPage(testCase.setup.findPage);
|
|
2311
|
+
if (!pageResult.found) {
|
|
2312
|
+
throw new Error(`findPage failed: ${pageResult.error} - available pages: ${JSON.stringify(pageResult.pages?.map(p => p.url))}`);
|
|
2313
|
+
}
|
|
2314
|
+
// page 切换后需要重新检测 wujie 模式
|
|
2315
|
+
this._wujieContext = await this._detectWujieMode();
|
|
2316
|
+
}
|
|
2118
2317
|
if (testCase.setup?.findFrame && !this._activeFrame) {
|
|
2119
2318
|
const resolved = await this._resolveObserveFrame(testCase.setup.findFrame);
|
|
2120
2319
|
this._activeFrame = resolved.frame;
|
|
@@ -8,11 +8,12 @@
|
|
|
8
8
|
* node local-browser-executor.js --action act --menu-path "目录管理/数据集目录" --observe-after
|
|
9
9
|
* node local-browser-executor.js --action api-capture --wait 15000 --include-response-body
|
|
10
10
|
* node local-browser-executor.js --action assert-api --capture-file /tmp/browser-api-capture.json --max-failed 0
|
|
11
|
+
* node local-browser-executor.js --action evaluate --expression "(() => ({a:1}))()" --frame-index 1
|
|
11
12
|
* node local-browser-executor.js --plan '<JSON>' --output /tmp/result.json
|
|
12
13
|
* node local-browser-executor.js --plan-file /path/to/plan.json --output /tmp/result.json
|
|
13
14
|
*
|
|
14
15
|
* 参数:
|
|
15
|
-
* --action 直接执行动作,当前支持 observe/act/assert/api-capture
|
|
16
|
+
* --action 直接执行动作,当前支持 observe/act/assert/evaluate/api-capture/assert-api/run-task/test-case/plan
|
|
16
17
|
* --plan 测试计划 JSON 字符串
|
|
17
18
|
* --plan-file 测试计划 JSON 文件路径(与 --plan 二选一)
|
|
18
19
|
* --output 结果输出文件路径(默认 stdout)
|
|
@@ -228,6 +229,15 @@ function parseArgs() {
|
|
|
228
229
|
case "--test-report":
|
|
229
230
|
parsed.testReport = args[++i];
|
|
230
231
|
break;
|
|
232
|
+
case "--expression":
|
|
233
|
+
parsed.expression = args[++i];
|
|
234
|
+
break;
|
|
235
|
+
case "--expect-result-contains":
|
|
236
|
+
parsed.expectResultContains = args[++i];
|
|
237
|
+
break;
|
|
238
|
+
case "--fail-on-falsy":
|
|
239
|
+
parsed.failOnFalsy = true;
|
|
240
|
+
break;
|
|
231
241
|
case "--help":
|
|
232
242
|
case "-h":
|
|
233
243
|
printHelp();
|
|
@@ -249,11 +259,12 @@ function printHelp() {
|
|
|
249
259
|
node local-browser-executor.js --action api-capture --wait 15000 --include-response-body [options]
|
|
250
260
|
node local-browser-executor.js --action assert-api --capture-file /tmp/browser-api-capture.json --max-failed 0 [options]
|
|
251
261
|
node local-browser-executor.js --action run-task --task menu-api-scan --capture-input-file /tmp/menu-tree.json [options]
|
|
262
|
+
node local-browser-executor.js --action evaluate --expression "(() => ({a:1}))()" [options]
|
|
252
263
|
node local-browser-executor.js --plan '<JSON>' [options]
|
|
253
264
|
node local-browser-executor.js --plan-file <path> [options]
|
|
254
265
|
|
|
255
266
|
选项:
|
|
256
|
-
--action <name> 直接执行动作,当前支持 observe/act/assert/api-capture/assert-api/run-task
|
|
267
|
+
--action <name> 直接执行动作,当前支持 observe/act/assert/evaluate/api-capture/assert-api/run-task/test-case/plan
|
|
257
268
|
--plan <json> 测试计划 JSON 字符串
|
|
258
269
|
--plan-file <path> 测试计划 JSON 文件路径
|
|
259
270
|
--output <path> 结果输出文件路径(默认 stdout)
|
|
@@ -274,6 +285,9 @@ function printHelp() {
|
|
|
274
285
|
--menu-path <a/b> act 时按菜单路径展开并点击,如 "目录管理/数据集目录"
|
|
275
286
|
--wait-after-each <ms> menu-path 每步点击后的等待时间(默认 300)
|
|
276
287
|
--observe-after act 后再次 observe,返回 before/act/after 闭环结果
|
|
288
|
+
--expression <js> evaluate 时执行的 JS 表达式(需用 IIFE 包裹)
|
|
289
|
+
--expect-result-contains <text> evaluate 时期望结果包含的文本
|
|
290
|
+
--fail-on-falsy evaluate 时结果为 falsy 则标记失败
|
|
277
291
|
--url-contains-assert <text> assert 当前 URL 包含文本
|
|
278
292
|
--text-contains <text> assert 页面文本包含内容
|
|
279
293
|
--expect-visible assert selector 可见
|
|
@@ -493,6 +507,34 @@ async function executeAction(args) {
|
|
|
493
507
|
});
|
|
494
508
|
return { type: "actObserve", act: actResult, after, browserInfo: initResult, startTime: startedAt, endTime: new Date().toISOString() };
|
|
495
509
|
}
|
|
510
|
+
case "evaluate": {
|
|
511
|
+
// 先通过 _resolveObserveFrame 设置 _activeFrame,使 safeEval 在正确的 frame 中执行
|
|
512
|
+
const resolved = await engine._resolveObserveFrame({
|
|
513
|
+
frameUrlContains: args.frameUrlContains,
|
|
514
|
+
frameContentContains: args.frameContentContains,
|
|
515
|
+
frameIndex: args.frameIndex,
|
|
516
|
+
});
|
|
517
|
+
engine._activeFrame = resolved.frame;
|
|
518
|
+
const evalResult = await engine.safeEval(args.expression);
|
|
519
|
+
const evalPassed = args.failOnFalsy ? !!evalResult : true;
|
|
520
|
+
const containsMatch = args.expectResultContains
|
|
521
|
+
? JSON.stringify(evalResult).includes(args.expectResultContains)
|
|
522
|
+
: true;
|
|
523
|
+
return {
|
|
524
|
+
type: "evaluate",
|
|
525
|
+
expression: args.expression,
|
|
526
|
+
result: evalResult,
|
|
527
|
+
passed: evalPassed && containsMatch,
|
|
528
|
+
frameTarget: resolved.frameId,
|
|
529
|
+
checks: {
|
|
530
|
+
failOnFalsy: args.failOnFalsy ? { expected: "truthy", actual: !!evalResult, passed: !!evalResult } : null,
|
|
531
|
+
expectResultContains: args.expectResultContains ? { expected: args.expectResultContains, passed: containsMatch } : null,
|
|
532
|
+
},
|
|
533
|
+
browserInfo: initResult,
|
|
534
|
+
startTime: startedAt,
|
|
535
|
+
endTime: new Date().toISOString(),
|
|
536
|
+
};
|
|
537
|
+
}
|
|
496
538
|
case "assert": {
|
|
497
539
|
const expect = {};
|
|
498
540
|
if (args.expectVisible) expect.visible = true;
|
|
@@ -509,6 +551,11 @@ async function executeAction(args) {
|
|
|
509
551
|
});
|
|
510
552
|
return { type: "assert", ...result, browserInfo: initResult, startTime: startedAt, endTime: new Date().toISOString() };
|
|
511
553
|
}
|
|
554
|
+
case "plan": {
|
|
555
|
+
const plan = loadPlan(args);
|
|
556
|
+
const result = await executePlan(plan, { cdpUrl: args.cdpUrl, pageUrl: args.pageUrl || args.urlContains });
|
|
557
|
+
return { type: "plan", ...result, startTime: startedAt, endTime: new Date().toISOString() };
|
|
558
|
+
}
|
|
512
559
|
case "api-capture":
|
|
513
560
|
case "apiCapture": {
|
|
514
561
|
const result = await engine.captureApi({
|
|
@@ -628,7 +675,7 @@ async function executeAction(args) {
|
|
|
628
675
|
return {
|
|
629
676
|
error: `未知 action: ${args.action}`,
|
|
630
677
|
errorCode: "UNKNOWN_ACTION",
|
|
631
|
-
supportedActions: ["observe", "act", "assert", "api-capture", "assert-api", "run-task", "test-case"],
|
|
678
|
+
supportedActions: ["observe", "act", "assert", "evaluate", "api-capture", "assert-api", "run-task", "test-case", "plan"],
|
|
632
679
|
startTime: startedAt,
|
|
633
680
|
endTime: new Date().toISOString(),
|
|
634
681
|
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type": "module"}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: szcd-upload-zip
|
|
3
|
+
description: |
|
|
4
|
+
本地 ZIP 文件上传与解压工具。将用户本地的 zip 压缩包通过 `upload-zip` REST 端点上传到 szcd-mcp-server,
|
|
5
|
+
自动解压到服务器的 `uploads/<zip包名>/` 目录,并返回解压后的文件列表。
|
|
6
|
+
上传后解压的文件可通过文件下载接口按路径获取或供其他流程使用。
|
|
7
|
+
compatibility:
|
|
8
|
+
tools:
|
|
9
|
+
- run_command
|
|
10
|
+
runtime:
|
|
11
|
+
- node >= 18(读取配置用,仅 `cat ~/.szcd-mcp-config.json` 场景)
|
|
12
|
+
- curl(文件上传所需)
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
# ZIP 上传与解压工具(szcd-upload-zip)
|
|
16
|
+
|
|
17
|
+
## 概述
|
|
18
|
+
|
|
19
|
+
当 AI 需要将用户本地的 ZIP 压缩包上传到 szcd-mcp-server 时,使用此技能。
|
|
20
|
+
上传后服务器自动解压,开发者或 AI 可通过下载 API 访问解压后的文件。
|
|
21
|
+
|
|
22
|
+
## 适用场景
|
|
23
|
+
|
|
24
|
+
- 用户有一个本地 zip 包需要上传到服务器存储
|
|
25
|
+
- zipped 源码/资源包需要上传到服务器供后续流程使用
|
|
26
|
+
- 需要在服务器端解压 zip 并取用其中的文件
|
|
27
|
+
|
|
28
|
+
## 工作流
|
|
29
|
+
|
|
30
|
+
### 第 0 步:探测运行时
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
node --version 2>/dev/null && echo "NODE_OK"; \
|
|
34
|
+
curl --version 2>/dev/null | head -1 && echo "CURL_OK"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### 第 1 步:读取配置获取服务器地址和 API Key
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
cat ~/.szcd-mcp-config.json
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
配置示例:
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"MCP_SERVER_URL": "http://localhost:3456",
|
|
47
|
+
"MCP_TIMEOUT": 30000,
|
|
48
|
+
"MCP_API_KEY": "your-api-key"
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
- 若 `MCP_API_KEY` 为空字符串,则上传时不带 `X-API-Key` header
|
|
53
|
+
- 若 `MCP_SERVER_URL` 未配置,默认使用 `http://localhost:3456`
|
|
54
|
+
|
|
55
|
+
### 第 2 步:上传 ZIP 文件
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
# 从 ~/.szcd-mcp-config.json 取 URL 和 API Key
|
|
59
|
+
MCP_URL=$(node -e "const c=require('fs').readFileSync(require('os').homedir()+'/.szcd-mcp-config.json','utf8');const j=JSON.parse(c);console.log(j.MCP_SERVER_URL||'http://localhost:3456')")
|
|
60
|
+
MCP_KEY=$(node -e "const c=require('fs').readFileSync(require('os').homedir()+'/.szcd-mcp-config.json','utf8');const j=JSON.parse(c);console.log(j.MCP_API_KEY||'')")
|
|
61
|
+
|
|
62
|
+
# 上传 zip 文件
|
|
63
|
+
if [ -n "$MCP_KEY" ]; then
|
|
64
|
+
curl -s -X POST "$MCP_URL/upload-zip" -H "X-API-Key: $MCP_KEY" -F "file=@<zip文件路径>"
|
|
65
|
+
else
|
|
66
|
+
curl -s -X POST "$MCP_URL/upload-zip" -F "file=@<zip文件路径>"
|
|
67
|
+
fi
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
其中 `<zip文件路径>` 替换为实际 zip 文件的绝对路径。
|
|
71
|
+
|
|
72
|
+
**响应示例:**
|
|
73
|
+
```json
|
|
74
|
+
{
|
|
75
|
+
"ok": true,
|
|
76
|
+
"dir_name": "my-package",
|
|
77
|
+
"extract_dir": "/scity/zengzhijie/mcp/uploads/my-package",
|
|
78
|
+
"file_count": 5,
|
|
79
|
+
"files": [
|
|
80
|
+
{ "path": "index.html", "size": 2048 },
|
|
81
|
+
{ "path": "js/app.js", "size": 15360 },
|
|
82
|
+
{ "path": "css/style.css", "size": 4096 },
|
|
83
|
+
{ "path": "images/logo.png", "size": 819200 },
|
|
84
|
+
{ "path": "data/config.json", "size": 512 }
|
|
85
|
+
]
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### 第 3 步:查看解压后的文件
|
|
90
|
+
|
|
91
|
+
上传成功后,可通过下载 API 查看解压后的文件或取单个文件内容:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
# 列出解压目录
|
|
95
|
+
curl -s "$MCP_URL/api/download/list?dir=/scity/zengzhijie/mcp/uploads/my-package" \
|
|
96
|
+
${MCP_KEY:+-H "X-API-Key: $MCP_KEY"}
|
|
97
|
+
|
|
98
|
+
# 下载单个文件
|
|
99
|
+
curl -s "$MCP_URL/api/download?path=/scity/zengzhijie/mcp/uploads/my-package/data/config.json" \
|
|
100
|
+
${MCP_KEY:+-H "X-API-Key: $MCP_KEY"}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## 限制说明
|
|
104
|
+
|
|
105
|
+
- 仅支持真正的 ZIP 格式(magic number `PK\x03\x04` 校验),改扩展名无用
|
|
106
|
+
- 单文件大小上限 100MB
|
|
107
|
+
- 同名 zip 包名会覆盖服务器上已有的目录
|
|
108
|
+
- 解压后文件持久存储,无自动过期
|
|
109
|
+
- 上传路径始终为 `/upload-zip`(不含 `/api/` 前缀)
|
|
110
|
+
|
|
111
|
+
## 完整命令示例(一步完成)
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
# 读取配置
|
|
115
|
+
MCP_URL=$(node -e "try{const c=require('fs').readFileSync(require('os').homedir()+'/.szcd-mcp-config.json','utf8');const j=JSON.parse(c);console.log(j.MCP_SERVER_URL||'http://localhost:3456')}catch{console.log('http://localhost:3456')}")
|
|
116
|
+
MCP_KEY=$(node -e "try{const c=require('fs').readFileSync(require('os').homedir()+'/.szcd-mcp-config.json','utf8');const j=JSON.parse(c);console.log(j.MCP_API_KEY||'')}catch{console.log('')}")
|
|
117
|
+
|
|
118
|
+
# 上传
|
|
119
|
+
curl -s -X POST "$MCP_URL/upload-zip" \
|
|
120
|
+
${MCP_KEY:+-H "X-API-Key: $MCP_KEY"} \
|
|
121
|
+
-F "file=@/path/to/archive.zip" | python3 -m json.tool
|
|
122
|
+
```
|
package/package.json
CHANGED