@szc-ft/mcp-szcd-client 0.35.0 → 0.38.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/build.js +59 -1
- package/agents/platforms.json +7 -0
- package/agents/szcd-component-expert.qoder.md +2 -0
- package/opencode-extension/opencode.json +9 -0
- package/opencode-extension/skills/local-browser-test/SKILL.md +91 -0
- package/opencode-extension/skills/local-browser-test/config.example.yaml +51 -0
- package/opencode-extension/skills/local-browser-test/lib/api-call-executor.js +251 -0
- package/opencode-extension/skills/local-browser-test/lib/browser-engine.js +95 -21
- package/opencode-extension/skills/local-browser-test/lib/config-loader.js +104 -0
- package/opencode-extension/skills/local-browser-test/lib/console-archiver.js +100 -0
- package/opencode-extension/skills/local-browser-test/lib/global-setup.js +72 -0
- package/opencode-extension/skills/local-browser-test/lib/har-collector.js +124 -0
- package/opencode-extension/skills/local-browser-test/lib/html-reporter.js +248 -0
- package/opencode-extension/skills/local-browser-test/lib/projects.js +46 -0
- package/opencode-extension/skills/local-browser-test/lib/raw-script-helpers.js +93 -0
- package/opencode-extension/skills/local-browser-test/lib/redact.js +94 -0
- package/opencode-extension/skills/local-browser-test/lib/report-retain.js +84 -0
- package/opencode-extension/skills/local-browser-test/lib/retry-policy.js +160 -0
- package/opencode-extension/skills/local-browser-test/lib/storage-state.js +111 -0
- package/opencode-extension/skills/local-browser-test/lib/tag-runner.js +103 -0
- package/opencode-extension/skills/local-browser-test/lib/test-plan.js +2 -0
- package/opencode-extension/skills/local-browser-test/lib/trace-collector.js +94 -0
- package/opencode-extension/skills/local-browser-test/lib/trend-aggregator.js +97 -0
- package/opencode-extension/skills/local-browser-test/lib/workspace-init.js +178 -0
- package/opencode-extension/skills/local-browser-test/local-browser-executor.js +226 -2
- package/opencode-extension/skills/local-browser-test/tests/integration/pr-01.test.js +254 -0
- package/opencode-extension/skills/local-browser-test/tests/integration/pr-02.test.js +263 -0
- package/opencode-extension/skills/local-browser-test/tests/integration/pr-03.test.js +308 -0
- package/opencode-extension/skills/local-browser-test/tests/integration/pr-04.test.js +168 -0
- package/opencode-extension/skills/local-browser-test/tests/integration/pr-05.test.js +183 -0
- package/opencode-extension/skills/local-browser-test/tests/integration/pr-06.test.js +252 -0
- package/opencode-extension/skills/local-browser-test/tests/integration/pr-07.test.js +168 -0
- package/opencode-extension/skills/local-browser-test/tests/integration/pr-08.test.js +293 -0
- package/opencode-extension/skills/shadcn-design-to-code/SKILL.md +352 -0
- package/opencode-extension/skills/shadcn-design-to-code/examples/1.1-overview-page.tsx +382 -0
- package/opencode-extension/skills/shadcn-design-to-code/references/install-pitfalls.md +526 -0
- package/opencode-extension/skills/shadcn-design-to-code/references/layouts.md +551 -0
- package/opencode-extension/skills/shadcn-design-to-code/references/sketch-mapping.md +247 -0
- package/opencode-extension/skills/shadcn-design-to-code/references/tokens.md +534 -0
- package/opencode-extension/skills/szcd-upload-zip/SKILL.md +45 -0
- package/package.json +70 -70
- package/qwen-extension/qwen-extension.json +10 -1
- package/qwen-extension/skills/local-browser-test/SKILL.md +91 -0
- package/qwen-extension/skills/local-browser-test/config.example.yaml +51 -0
- package/qwen-extension/skills/local-browser-test/lib/api-call-executor.js +251 -0
- package/qwen-extension/skills/local-browser-test/lib/browser-engine.js +95 -21
- package/qwen-extension/skills/local-browser-test/lib/config-loader.js +104 -0
- package/qwen-extension/skills/local-browser-test/lib/console-archiver.js +100 -0
- package/qwen-extension/skills/local-browser-test/lib/global-setup.js +72 -0
- package/qwen-extension/skills/local-browser-test/lib/har-collector.js +124 -0
- package/qwen-extension/skills/local-browser-test/lib/html-reporter.js +248 -0
- package/qwen-extension/skills/local-browser-test/lib/projects.js +46 -0
- package/qwen-extension/skills/local-browser-test/lib/raw-script-helpers.js +93 -0
- package/qwen-extension/skills/local-browser-test/lib/redact.js +94 -0
- package/qwen-extension/skills/local-browser-test/lib/report-retain.js +84 -0
- package/qwen-extension/skills/local-browser-test/lib/retry-policy.js +160 -0
- package/qwen-extension/skills/local-browser-test/lib/storage-state.js +111 -0
- package/qwen-extension/skills/local-browser-test/lib/tag-runner.js +103 -0
- package/qwen-extension/skills/local-browser-test/lib/test-plan.js +2 -0
- package/qwen-extension/skills/local-browser-test/lib/trace-collector.js +94 -0
- package/qwen-extension/skills/local-browser-test/lib/trend-aggregator.js +97 -0
- package/qwen-extension/skills/local-browser-test/lib/workspace-init.js +178 -0
- package/qwen-extension/skills/local-browser-test/local-browser-executor.js +226 -2
- package/qwen-extension/skills/local-browser-test/tests/integration/pr-01.test.js +254 -0
- package/qwen-extension/skills/local-browser-test/tests/integration/pr-02.test.js +263 -0
- package/qwen-extension/skills/local-browser-test/tests/integration/pr-03.test.js +308 -0
- package/qwen-extension/skills/local-browser-test/tests/integration/pr-04.test.js +168 -0
- package/qwen-extension/skills/local-browser-test/tests/integration/pr-05.test.js +183 -0
- package/qwen-extension/skills/local-browser-test/tests/integration/pr-06.test.js +252 -0
- package/qwen-extension/skills/local-browser-test/tests/integration/pr-07.test.js +168 -0
- package/qwen-extension/skills/local-browser-test/tests/integration/pr-08.test.js +293 -0
- package/qwen-extension/skills/shadcn-design-to-code/SKILL.md +352 -0
- package/qwen-extension/skills/shadcn-design-to-code/examples/1.1-overview-page.tsx +382 -0
- package/qwen-extension/skills/shadcn-design-to-code/references/install-pitfalls.md +526 -0
- package/qwen-extension/skills/shadcn-design-to-code/references/layouts.md +551 -0
- package/qwen-extension/skills/shadcn-design-to-code/references/sketch-mapping.md +247 -0
- package/qwen-extension/skills/shadcn-design-to-code/references/tokens.md +534 -0
- package/qwen-extension/skills/szcd-upload-zip/SKILL.md +45 -0
- package/scripts/lib/common.js +7 -0
- package/standard-skill/local-browser-test/SKILL.md +91 -0
- package/standard-skill/local-browser-test/config.example.yaml +51 -0
- package/standard-skill/local-browser-test/lib/api-call-executor.js +251 -0
- package/standard-skill/local-browser-test/lib/browser-engine.js +95 -21
- package/standard-skill/local-browser-test/lib/config-loader.js +104 -0
- package/standard-skill/local-browser-test/lib/console-archiver.js +100 -0
- package/standard-skill/local-browser-test/lib/global-setup.js +72 -0
- package/standard-skill/local-browser-test/lib/har-collector.js +124 -0
- package/standard-skill/local-browser-test/lib/html-reporter.js +248 -0
- package/standard-skill/local-browser-test/lib/projects.js +46 -0
- package/standard-skill/local-browser-test/lib/raw-script-helpers.js +93 -0
- package/standard-skill/local-browser-test/lib/redact.js +94 -0
- package/standard-skill/local-browser-test/lib/report-retain.js +84 -0
- package/standard-skill/local-browser-test/lib/retry-policy.js +160 -0
- package/standard-skill/local-browser-test/lib/storage-state.js +111 -0
- package/standard-skill/local-browser-test/lib/tag-runner.js +103 -0
- package/standard-skill/local-browser-test/lib/test-plan.js +2 -0
- package/standard-skill/local-browser-test/lib/trace-collector.js +94 -0
- package/standard-skill/local-browser-test/lib/trend-aggregator.js +97 -0
- package/standard-skill/local-browser-test/lib/workspace-init.js +178 -0
- package/standard-skill/local-browser-test/local-browser-executor.js +226 -2
- package/standard-skill/local-browser-test/tests/integration/pr-01.test.js +254 -0
- package/standard-skill/local-browser-test/tests/integration/pr-02.test.js +263 -0
- package/standard-skill/local-browser-test/tests/integration/pr-03.test.js +308 -0
- package/standard-skill/local-browser-test/tests/integration/pr-04.test.js +168 -0
- package/standard-skill/local-browser-test/tests/integration/pr-05.test.js +183 -0
- package/standard-skill/local-browser-test/tests/integration/pr-06.test.js +252 -0
- package/standard-skill/local-browser-test/tests/integration/pr-07.test.js +168 -0
- package/standard-skill/local-browser-test/tests/integration/pr-08.test.js +293 -0
- package/standard-skill/shadcn-design-to-code/SKILL.md +352 -0
- package/standard-skill/shadcn-design-to-code/examples/1.1-overview-page.tsx +382 -0
- package/standard-skill/shadcn-design-to-code/references/install-pitfalls.md +526 -0
- package/standard-skill/shadcn-design-to-code/references/layouts.md +551 -0
- package/standard-skill/shadcn-design-to-code/references/sketch-mapping.md +247 -0
- package/standard-skill/shadcn-design-to-code/references/tokens.md +534 -0
- package/standard-skill/szcd-upload-zip/SKILL.md +45 -0
- package/opencode-extension/skills/local-browser-test/local-browser-executor.cjs +0 -742
- package/qwen-extension/skills/local-browser-test/local-browser-executor.cjs +0 -742
package/agents/build.js
CHANGED
|
@@ -346,6 +346,11 @@ function build() {
|
|
|
346
346
|
const srcSkillPath = path.join(srcSkillDir, "SKILL.md");
|
|
347
347
|
const destSkillDir = path.join(qwenExtDir, "skills", skillDir);
|
|
348
348
|
if (fs.existsSync(srcSkillPath)) {
|
|
349
|
+
// PR #0 v3: 先 rm 旧副本,避免脏数据残留(cjs 等已废弃文件)
|
|
350
|
+
if (fs.existsSync(destSkillDir)) {
|
|
351
|
+
fs.rmSync(destSkillDir, { recursive: true, force: true });
|
|
352
|
+
console.log(` ✓ Cleaned stale ${skillDir}/`);
|
|
353
|
+
}
|
|
349
354
|
copyDirRecursive(srcSkillDir, destSkillDir);
|
|
350
355
|
console.log(` ✓ Synced skill to qwen-extension/skills/${skillDir}/`);
|
|
351
356
|
}
|
|
@@ -376,6 +381,10 @@ function build() {
|
|
|
376
381
|
const srcSkillPath = path.join(srcSkillDir, "SKILL.md");
|
|
377
382
|
const destSkillDir = path.join(opencodeExtDir, "skills", skillDir);
|
|
378
383
|
if (fs.existsSync(srcSkillPath)) {
|
|
384
|
+
// PR #0 v3: 先 rm 旧副本(与 5a 同步逻辑保持一致)
|
|
385
|
+
if (fs.existsSync(destSkillDir)) {
|
|
386
|
+
fs.rmSync(destSkillDir, { recursive: true, force: true });
|
|
387
|
+
}
|
|
379
388
|
copyDirRecursive(srcSkillDir, destSkillDir);
|
|
380
389
|
}
|
|
381
390
|
}
|
|
@@ -496,8 +505,57 @@ function build() {
|
|
|
496
505
|
console.log("\n✅ Agent build complete!");
|
|
497
506
|
}
|
|
498
507
|
|
|
508
|
+
// ★ PR #0 v3 修复:build 锁(防止并发 build 时 ENOTEMPTY)
|
|
509
|
+
// 跨进程互斥:第一个 build 写 .build.lock,后续 build 等到锁释放
|
|
510
|
+
const lockFile = path.join(process.cwd(), ".build.lock");
|
|
511
|
+
const lockMaxWait = 30000; // 30s 超时
|
|
512
|
+
const lockPollInterval = 100; // 100ms 检查一次
|
|
513
|
+
const lockStaleAge = 60000; // 60s 视为 stale 锁
|
|
514
|
+
async function acquireBuildLock() {
|
|
515
|
+
const start = Date.now();
|
|
516
|
+
while (Date.now() - start < lockMaxWait) {
|
|
517
|
+
try {
|
|
518
|
+
const fd = fs.openSync(lockFile, "wx"); // 独占创建(原子操作)
|
|
519
|
+
fs.writeSync(fd, String(process.pid));
|
|
520
|
+
return fd;
|
|
521
|
+
} catch (e) {
|
|
522
|
+
if (e.code === "EEXIST") {
|
|
523
|
+
// 锁存在 → 检查是否 stale
|
|
524
|
+
try {
|
|
525
|
+
const stat = fs.statSync(lockFile);
|
|
526
|
+
if (Date.now() - stat.mtimeMs > lockStaleAge) {
|
|
527
|
+
console.log(` ⚠ 锁文件陈旧 (>${lockStaleAge/1000}s),强制删除`);
|
|
528
|
+
try { fs.unlinkSync(lockFile); } catch {}
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
} catch {}
|
|
532
|
+
// 等待
|
|
533
|
+
await new Promise((r) => setTimeout(r, lockPollInterval));
|
|
534
|
+
} else {
|
|
535
|
+
throw e;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return null; // 超时
|
|
540
|
+
}
|
|
541
|
+
function releaseBuildLock(fd) {
|
|
542
|
+
if (fd) {
|
|
543
|
+
try { fs.closeSync(fd); } catch {}
|
|
544
|
+
try { fs.unlinkSync(lockFile); } catch {}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
499
548
|
try {
|
|
500
|
-
|
|
549
|
+
const lockFd = await acquireBuildLock();
|
|
550
|
+
if (!lockFd) {
|
|
551
|
+
process.stderr.write(`❌ build 锁获取超时 (${lockMaxWait}ms)\n`);
|
|
552
|
+
process.exit(2);
|
|
553
|
+
}
|
|
554
|
+
try {
|
|
555
|
+
build();
|
|
556
|
+
} finally {
|
|
557
|
+
releaseBuildLock(lockFd);
|
|
558
|
+
}
|
|
501
559
|
} catch (error) {
|
|
502
560
|
console.error("❌ Build failed:", error.message);
|
|
503
561
|
console.error(error.stack);
|
package/agents/platforms.json
CHANGED
|
@@ -6,6 +6,13 @@
|
|
|
6
6
|
"type": "stdio",
|
|
7
7
|
"command": "sketch-mcp-server",
|
|
8
8
|
"args": []
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"name": "shadcn",
|
|
12
|
+
"type": "stdio",
|
|
13
|
+
"command": "npx",
|
|
14
|
+
"args": ["-y", "shadcn@latest", "mcp"],
|
|
15
|
+
"description": "shadcn/ui 官方 stdio MCP(registry 搜索/查看/add 命令)。需项目根目录有 components.json,否则调 list_items_in_registries 报 'No registries are configured'。"
|
|
9
16
|
}
|
|
10
17
|
],
|
|
11
18
|
"platforms": {
|
|
@@ -15,12 +15,14 @@ tools:
|
|
|
15
15
|
skills:
|
|
16
16
|
- local-api-tool
|
|
17
17
|
- local-browser-test
|
|
18
|
+
- shadcn-design-to-code
|
|
18
19
|
- szcd-component-helper
|
|
19
20
|
- szcd-design-to-code
|
|
20
21
|
- szcd-upload-zip
|
|
21
22
|
mcpServers:
|
|
22
23
|
- szcd-component-helper
|
|
23
24
|
- sketch-mcp-server
|
|
25
|
+
- shadcn
|
|
24
26
|
---
|
|
25
27
|
|
|
26
28
|
# szcd-component-expert: szcd 组件库专家
|
|
@@ -1307,3 +1307,94 @@ NODE_PATH=~/.szcd-mcp/deps/node_modules node "$EXECUTOR" --action test-case --te
|
|
|
1307
1307
|
- ❌ 单元测试 / 组件级测试 → Vitest / Jest / RTL
|
|
1308
1308
|
- ❌ 视觉回归全量对比(无 baseline 设计稿)→ Percy / Chromatic
|
|
1309
1309
|
- ✅ 真实浏览器内的功能验证、API 时序捕获、设计稿单图对比、动态菜单批量扫描
|
|
1310
|
+
|
|
1311
|
+
## rawScript 可用 API(PR #4 v3)
|
|
1312
|
+
|
|
1313
|
+
`rawScript` 步骤支持两种模式:
|
|
1314
|
+
|
|
1315
|
+
### 模式 A:inline code
|
|
1316
|
+
```json
|
|
1317
|
+
{ "type": "rawScript", "code": "return { passed: document.title.includes('登录') }" }
|
|
1318
|
+
```
|
|
1319
|
+
|
|
1320
|
+
### 模式 B:codeFile(PR #4 新增)
|
|
1321
|
+
```json
|
|
1322
|
+
{ "type": "rawScript", "codeFile": ".lbt/scripts/check-title.js" }
|
|
1323
|
+
```
|
|
1324
|
+
- 相对路径相对 `process.cwd()`
|
|
1325
|
+
- 文件不存在 → 明确报错(指出绝对路径)
|
|
1326
|
+
- 文件读失败 → 明确报错
|
|
1327
|
+
|
|
1328
|
+
### rawScript 函数签名
|
|
1329
|
+
|
|
1330
|
+
```js
|
|
1331
|
+
async (page, frame, params, browser) => {
|
|
1332
|
+
// page: puppeteer Page 对象
|
|
1333
|
+
// frame: PuppeteerFrame(iframe 上下文)
|
|
1334
|
+
// params: step.params 对象
|
|
1335
|
+
// browser: puppeteer Browser 对象
|
|
1336
|
+
return { passed: true, ... }; // 或 { passed: false, reason: '...' }
|
|
1337
|
+
}
|
|
1338
|
+
```
|
|
1339
|
+
|
|
1340
|
+
### ✅ 浏览器侧可用 API
|
|
1341
|
+
|
|
1342
|
+
| 类别 | API |
|
|
1343
|
+
|------|-----|
|
|
1344
|
+
| DOM | `document.*` / `window.*` / `frame.document` |
|
|
1345
|
+
| 存储 | `localStorage.*` / `sessionStorage.*` / `cookieStore` |
|
|
1346
|
+
| 网络 | `fetch()` / `XMLHttpRequest` / `WebSocket` |
|
|
1347
|
+
| JS 内置 | `JSON` / `Date` / `Math` / `Array` / `Object` / `Promise` / `async/await` |
|
|
1348
|
+
| 浏览器事件 | `MouseEvent` / `KeyboardEvent` / `CustomEvent` |
|
|
1349
|
+
| puppeteer | `page.*`(evaluate / click / type / waitForSelector 等)|
|
|
1350
|
+
|
|
1351
|
+
### ❌ Node 侧 API 不可用(浏览器无)
|
|
1352
|
+
|
|
1353
|
+
| 错误信息 | 原因 | 浏览器侧替代 |
|
|
1354
|
+
|---------|------|------------|
|
|
1355
|
+
| `TypeError: require is not a function` | 浏览器无 `require` | `await fetch()` / `page.evaluate(() => ...)` |
|
|
1356
|
+
| `ReferenceError: __dirname is not defined` | 浏览器无 Node 全局 | `window.location.origin` / `frame.url()` |
|
|
1357
|
+
| `ReferenceError: process is not defined` | 浏览器无 `process` | `localStorage` / `URL` 替代 |
|
|
1358
|
+
| `ReferenceError: Buffer is not defined` | 浏览器无 Node `Buffer` | `Uint8Array` / `TextEncoder` |
|
|
1359
|
+
| `TypeError: require.main is not a function` | 浏览器无 `require.main` | 不需要 |
|
|
1360
|
+
|
|
1361
|
+
**★ PR #4 修复**:rawScript 执行器会在跑前**静态扫描**代码,发现 Node-only API 时**立即 fail**,错误信息包含"详见 SKILL.md > ## rawScript 可用 API",避免用户踩坑。
|
|
1362
|
+
|
|
1363
|
+
### 常见错误速查
|
|
1364
|
+
|
|
1365
|
+
| 错误 | 修复 |
|
|
1366
|
+
|------|------|
|
|
1367
|
+
| `codeFile 不存在: /path/to/file` | 检查路径或用 inline `code` 替代 |
|
|
1368
|
+
| `rawScript 检测到 Node-only API: require('fs')` | 改用 `page.evaluate(() => ...)` 走浏览器 |
|
|
1369
|
+
| `script compile error: Unexpected token` | 检查 `code` 字段 JSON 转义(`"` → `\"`) |
|
|
1370
|
+
| `script execution error: ...` | 看具体堆栈;可能是 page 已 close 或网络超时 |
|
|
1371
|
+
|
|
1372
|
+
### 示例:检查页面 title
|
|
1373
|
+
|
|
1374
|
+
```js
|
|
1375
|
+
// 模式 A:inline
|
|
1376
|
+
{ "type": "rawScript", "code": "return { passed: document.title.includes('数据集目录'), title: document.title }" }
|
|
1377
|
+
|
|
1378
|
+
// 模式 B:codeFile
|
|
1379
|
+
{ "type": "rawScript", "codeFile": ".lbt/scripts/check-dataset-title.js" }
|
|
1380
|
+
// .lbt/scripts/check-dataset-title.js 内容:
|
|
1381
|
+
// return { passed: document.title.includes('数据集目录'), title: document.title };
|
|
1382
|
+
```
|
|
1383
|
+
|
|
1384
|
+
### 示例:拿所有按钮文本
|
|
1385
|
+
|
|
1386
|
+
```js
|
|
1387
|
+
{
|
|
1388
|
+
"type": "rawScript",
|
|
1389
|
+
"code": "return { passed: true, buttons: [...document.querySelectorAll('button')].map(b => b.textContent.trim()) }"
|
|
1390
|
+
}
|
|
1391
|
+
```
|
|
1392
|
+
|
|
1393
|
+
### 示例:调浏览器 fetch 验证 API
|
|
1394
|
+
|
|
1395
|
+
```js
|
|
1396
|
+
{
|
|
1397
|
+
"type": "rawScript",
|
|
1398
|
+
"code": "const r = await fetch('/api/health'); const j = await r.json(); return { passed: j.status === 'ok', data: j }"
|
|
1399
|
+
}
|
|
1400
|
+
```
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# local-browser-test 配置示例
|
|
2
|
+
# 复制为 .lbt/config.yaml 后填值(config.yaml 已在 .gitignore 内)
|
|
3
|
+
#
|
|
4
|
+
# 占位符 ${ENV_VAR} 走 process.env,CI 推荐用:
|
|
5
|
+
# export LBT_TOKEN=xxx
|
|
6
|
+
# export LBT_PASSWORD=xxx
|
|
7
|
+
|
|
8
|
+
# 应用基础配置
|
|
9
|
+
app:
|
|
10
|
+
baseURL: http://10.2.7.60:30020
|
|
11
|
+
timeout: 30000
|
|
12
|
+
|
|
13
|
+
# 鉴权(强烈建议用环境变量,避免明文)
|
|
14
|
+
auth:
|
|
15
|
+
type: bearer # bearer | cookie | oauth2
|
|
16
|
+
token: ${LBT_TOKEN} # 走环境变量
|
|
17
|
+
# password: ${LBT_PASSWORD}
|
|
18
|
+
# username: ${LBT_USERNAME}
|
|
19
|
+
# verifyCode: ${LBT_VERIFY_CODE}
|
|
20
|
+
storageState: null # 可选:复用 .lbt/auth/<project>.json
|
|
21
|
+
|
|
22
|
+
# 多环境配置(配合 --project <name> 切换)
|
|
23
|
+
projects:
|
|
24
|
+
- name: corpus-test
|
|
25
|
+
baseURL: http://119.0.87.158
|
|
26
|
+
auth:
|
|
27
|
+
type: bearer
|
|
28
|
+
token: ${LBT_TOKEN}
|
|
29
|
+
- name: corpus-stg
|
|
30
|
+
baseURL: http://10.2.7.60:30020
|
|
31
|
+
auth:
|
|
32
|
+
type: bearer
|
|
33
|
+
token: ${LBT_TOKEN}
|
|
34
|
+
- name: corpus-prod
|
|
35
|
+
baseURL: http://172.16.0.172:30010
|
|
36
|
+
auth:
|
|
37
|
+
type: bearer
|
|
38
|
+
token: ${LBT_TOKEN}
|
|
39
|
+
|
|
40
|
+
# 重试策略
|
|
41
|
+
retry:
|
|
42
|
+
maxRetries: 0 # 用例级(CI 跑 2 次)
|
|
43
|
+
maxRetriesPerStep: 2 # 步骤级(验证码过期等)
|
|
44
|
+
flakyThreshold: 5 # 5 次中 1 挂 4 过 → flaky
|
|
45
|
+
noRetryActions: true # 写操作(删除/提交/上线)永不重试
|
|
46
|
+
|
|
47
|
+
# 报告(v4 Playwright 风格)
|
|
48
|
+
report:
|
|
49
|
+
retainRuns: 30 # 保留最近 30 次 run
|
|
50
|
+
retainDays: 7 # 保留最近 7 天
|
|
51
|
+
retainFailedForever: true # 失败 run 永久保留
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* apiCall step 增强执行器(PR #8 v3 走法 P)
|
|
3
|
+
*
|
|
4
|
+
* 关键设计:
|
|
5
|
+
* - API 步骤走 Node 18+ 内置 fetch(**不通过浏览器**,但自动共享 context.cookies())
|
|
6
|
+
* - UI 步骤走 page(真实浏览器,CDP)
|
|
7
|
+
* - 同一 testCase 跑(UI + API 混合),cookie 自动共享
|
|
8
|
+
* - 首次 = 回归 = 真实行为(永远一致)
|
|
9
|
+
* - 不引入 newman 依赖
|
|
10
|
+
* - 不引入 axios 依赖(用 fetch 即可,Node 18+ 内置)
|
|
11
|
+
*
|
|
12
|
+
* 6 个新字段:
|
|
13
|
+
* - assert: L1 status / L2 header / L3 body path / L4 responseTime
|
|
14
|
+
* - captureTrace: HAR-like trace 录制(PR #5 trace-collector 集成)
|
|
15
|
+
* - noRetry: 写操作禁重试(PR #6 retry-policy 集成)
|
|
16
|
+
* - strategy: plan 显式(newman / browser / auto)
|
|
17
|
+
* - headers: 自定义请求头
|
|
18
|
+
* - tags: 用例标签(PR #7)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { redact } from './redact.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* apiCall step 增强 schema
|
|
25
|
+
* @typedef {object} ApiCallStep
|
|
26
|
+
* @property {'apiCall'} type
|
|
27
|
+
* @property {string} method
|
|
28
|
+
* @property {string} url
|
|
29
|
+
* @property {object} [headers]
|
|
30
|
+
* @property {object} [body]
|
|
31
|
+
* @property {object} [assert] - L1-L4 断言
|
|
32
|
+
* @property {boolean} [captureTrace] - 是否记录 trace
|
|
33
|
+
* @property {boolean} [noRetry] - 写操作禁重试(默认 true)
|
|
34
|
+
* @property {string} [strategy] - plan 显式(newman / browser / auto)
|
|
35
|
+
* @property {string[]} [tags] - 用例标签
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 共享 cookie header(PR #8 关键设计)
|
|
40
|
+
* @param {Array<{name: string, value: string, domain?: string}>} cookies
|
|
41
|
+
* @param {string} [targetOrigin]
|
|
42
|
+
* @returns {string} Cookie 头(空字符串如果无 cookie)
|
|
43
|
+
*/
|
|
44
|
+
export function buildCookieHeader(cookies, targetOrigin) {
|
|
45
|
+
if (!Array.isArray(cookies) || cookies.length === 0) return '';
|
|
46
|
+
const filtered = targetOrigin
|
|
47
|
+
? cookies.filter((c) => c.domain?.endsWith(targetOrigin.replace(/^https?:\/\//, '')))
|
|
48
|
+
: cookies;
|
|
49
|
+
return filtered.map((c) => `${c.name}=${c.value}`).join('; ');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* ★ axios interceptor 工厂:记录 timings(PR #5 + PR #8)
|
|
54
|
+
* @param {object} collector - { timings: Array<{name, url, method, total}> }
|
|
55
|
+
* @returns {{ request: Function, response: Function, error: Function }}
|
|
56
|
+
*/
|
|
57
|
+
export function createTimingsInterceptor(collector) {
|
|
58
|
+
return {
|
|
59
|
+
request(config) {
|
|
60
|
+
config.metadata = { startTime: process.hrtime.bigint() };
|
|
61
|
+
return config;
|
|
62
|
+
},
|
|
63
|
+
response(response) {
|
|
64
|
+
const ns = process.hrtime.bigint() - response.config.metadata.startTime;
|
|
65
|
+
const total = Number(ns / 1000000n); // ms
|
|
66
|
+
collector.timings.push({
|
|
67
|
+
name: response.config.metadata.name ?? '',
|
|
68
|
+
url: response.config.url,
|
|
69
|
+
method: response.config.method?.toUpperCase(),
|
|
70
|
+
total,
|
|
71
|
+
status: response.status
|
|
72
|
+
});
|
|
73
|
+
response.config.metadata.total = total;
|
|
74
|
+
return response;
|
|
75
|
+
},
|
|
76
|
+
error(error) {
|
|
77
|
+
if (error.config?.metadata) {
|
|
78
|
+
const ns = process.hrtime.bigint() - error.config.metadata.startTime;
|
|
79
|
+
error.config.metadata.total = Number(ns / 1000000n);
|
|
80
|
+
}
|
|
81
|
+
return Promise.reject(error);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* L1-L4 断言引擎
|
|
88
|
+
* @param {object} response - axios response
|
|
89
|
+
* @param {object} assertSpec - 断言规格
|
|
90
|
+
* @returns {{passed: boolean, checks: Array<{name: string, level: number, passed: boolean, actual: any, expected: any, error?: string}>}}
|
|
91
|
+
*/
|
|
92
|
+
export function assertApiResponse(response, assertSpec) {
|
|
93
|
+
const checks = [];
|
|
94
|
+
let allPassed = true;
|
|
95
|
+
|
|
96
|
+
// L1: status code
|
|
97
|
+
if (assertSpec.status !== undefined) {
|
|
98
|
+
const actual = response.status;
|
|
99
|
+
const expected = assertSpec.status;
|
|
100
|
+
const passed = actual === expected;
|
|
101
|
+
checks.push({ name: `Status is ${expected}`, level: 1, actual, expected, passed });
|
|
102
|
+
if (!passed) allPassed = false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// L2: header
|
|
106
|
+
if (assertSpec.headers) {
|
|
107
|
+
for (const [key, expected] of Object.entries(assertSpec.headers)) {
|
|
108
|
+
const actual = response.headers?.[key.toLowerCase()];
|
|
109
|
+
const passed = actual === expected;
|
|
110
|
+
checks.push({ name: `Header ${key}=${expected}`, level: 2, actual, expected, passed });
|
|
111
|
+
if (!passed) allPassed = false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// L3: body path
|
|
116
|
+
if (assertSpec.body) {
|
|
117
|
+
for (const [path, expected] of Object.entries(assertSpec.body)) {
|
|
118
|
+
const actual = getByPath(response.data, path);
|
|
119
|
+
const passed = matchValue(actual, expected);
|
|
120
|
+
checks.push({ name: `Body ${path} = ${JSON.stringify(expected)}`, level: 3, actual, expected, passed });
|
|
121
|
+
if (!passed) allPassed = false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// L4: response time
|
|
126
|
+
if (assertSpec.responseTime !== undefined) {
|
|
127
|
+
const total = response.config?.metadata?.total ?? 0;
|
|
128
|
+
const expected = assertSpec.responseTime;
|
|
129
|
+
const passed = total < expected;
|
|
130
|
+
checks.push({ name: `Response time < ${expected}ms`, level: 4, actual: total, expected, passed });
|
|
131
|
+
if (!passed) allPassed = false;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { passed: allPassed, checks };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 路径取值(点号访问嵌套对象)
|
|
139
|
+
*/
|
|
140
|
+
function getByPath(obj, path) {
|
|
141
|
+
return String(path).split('.').reduce((o, k) => o?.[k], obj);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 操作符匹配
|
|
146
|
+
* @param {any} actual
|
|
147
|
+
* @param {any} expected - 可以是值 / { $gt: n } / { $lt: n } / { $contains: 'x' } / { $regex: 'p' }
|
|
148
|
+
*/
|
|
149
|
+
function matchValue(actual, expected) {
|
|
150
|
+
if (expected && typeof expected === 'object' && !Array.isArray(expected)) {
|
|
151
|
+
if ('$gt' in expected && actual > expected.$gt) return true;
|
|
152
|
+
if ('$lt' in expected && actual < expected.$lt) return true;
|
|
153
|
+
if ('$gte' in expected && actual >= expected.$gte) return true;
|
|
154
|
+
if ('$lte' in expected && actual <= expected.$lte) return true;
|
|
155
|
+
if ('$contains' in expected && String(actual ?? '').includes(expected.$contains)) return true;
|
|
156
|
+
if ('$regex' in expected && new RegExp(expected.$regex).test(String(actual ?? ''))) return true;
|
|
157
|
+
if ('$in' in expected && Array.isArray(expected.$in) && expected.$in.includes(actual)) return true;
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
return actual === expected;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 执行 apiCall step(v3 走法 P:不通过浏览器,走 axios)
|
|
165
|
+
* @param {ApiCallStep} step
|
|
166
|
+
* @param {object} ctx - { baseURL, cookies, traceCollector? }
|
|
167
|
+
* @returns {Promise<{
|
|
168
|
+
* name: string, status: 'passed'|'failed', duration: number,
|
|
169
|
+
* request: object, response: object,
|
|
170
|
+
* assertions: Array, trace: object|null, error: object|null
|
|
171
|
+
* }>}
|
|
172
|
+
*/
|
|
173
|
+
export async function executeApiCallStep(step, ctx = {}) {
|
|
174
|
+
const startTime = Date.now();
|
|
175
|
+
const method = (step.method || 'POST').toUpperCase();
|
|
176
|
+
const timings = [];
|
|
177
|
+
const collector = { timings };
|
|
178
|
+
|
|
179
|
+
// 1. 构造 axios request:自动共享 context.cookies()
|
|
180
|
+
const cookieHeader = buildCookieHeader(ctx.cookies ?? [], ctx.baseURL);
|
|
181
|
+
const request = axios.create({
|
|
182
|
+
baseURL: ctx.baseURL,
|
|
183
|
+
headers: {
|
|
184
|
+
Cookie: cookieHeader,
|
|
185
|
+
'Content-Type': 'application/json',
|
|
186
|
+
...step.headers
|
|
187
|
+
},
|
|
188
|
+
// 让 axios 接受任意状态码(不抛错)
|
|
189
|
+
validateStatus: () => true
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// 2. ★ axios interceptor(PR #5 + PR #8)
|
|
193
|
+
const interceptor = createTimingsInterceptor(collector);
|
|
194
|
+
request.interceptors.request.use(interceptor.request);
|
|
195
|
+
request.interceptors.response.use(interceptor.response, interceptor.error);
|
|
196
|
+
|
|
197
|
+
// 3. 发请求
|
|
198
|
+
let response;
|
|
199
|
+
let error = null;
|
|
200
|
+
try {
|
|
201
|
+
response = await request({
|
|
202
|
+
method,
|
|
203
|
+
url: step.url,
|
|
204
|
+
data: step.body
|
|
205
|
+
});
|
|
206
|
+
} catch (e) {
|
|
207
|
+
error = {
|
|
208
|
+
message: e.message ?? String(e),
|
|
209
|
+
code: e.code ?? null
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const duration = Date.now() - startTime;
|
|
214
|
+
const status = response?.status ?? 0;
|
|
215
|
+
|
|
216
|
+
// 4. ★ 断言(PR #8 L1-L4)
|
|
217
|
+
let assertionResults = [];
|
|
218
|
+
let passed = error == null && status >= 200 && status < 300; // 默认通过 = 2xx
|
|
219
|
+
if (step.assert && response) {
|
|
220
|
+
const assertResult = assertApiResponse(response, step.assert);
|
|
221
|
+
assertionResults = assertResult.checks;
|
|
222
|
+
if (!assertResult.passed) {
|
|
223
|
+
passed = false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// 5. ★ 构造返回值(脱敏 + 统一 schema)
|
|
228
|
+
const safeResponse = response ? {
|
|
229
|
+
status: response.status,
|
|
230
|
+
statusText: response.statusText,
|
|
231
|
+
duration: response.config?.metadata?.total ?? 0,
|
|
232
|
+
headers: redact(response.headers ?? {}),
|
|
233
|
+
body: redact(response.data)
|
|
234
|
+
} : null;
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
name: step.name ?? `${method} ${step.url}`,
|
|
238
|
+
status: passed ? 'passed' : 'failed',
|
|
239
|
+
duration,
|
|
240
|
+
request: {
|
|
241
|
+
method,
|
|
242
|
+
url: step.url,
|
|
243
|
+
headers: redact(step.headers ?? {}),
|
|
244
|
+
body: redact(step.body)
|
|
245
|
+
},
|
|
246
|
+
response: safeResponse,
|
|
247
|
+
assertions: assertionResults,
|
|
248
|
+
trace: step.captureTrace ? { timings: collector.timings } : null,
|
|
249
|
+
error
|
|
250
|
+
};
|
|
251
|
+
}
|
|
@@ -16,6 +16,8 @@ import { AntAdapter } from "./ant-adapter.js";
|
|
|
16
16
|
import { createExpect, locator as expectLocator, validation as expectValidation, url as expectUrl, api as expectApi } from "./expect.js";
|
|
17
17
|
import { getBrowserConfigFromProcess } from "./cdp-browser-config.js";
|
|
18
18
|
import { decryptAllPasswords } from "./decrypt-passwords.js";
|
|
19
|
+
// ★ PR #6 v3: 重试 + flaky + 写操作禁重试
|
|
20
|
+
import { RetryPolicy, retryPolicyFromPlan } from "./retry-policy.js";
|
|
19
21
|
import { existsSync, readFileSync, writeFileSync, statSync } from 'fs';
|
|
20
22
|
import { join, dirname } from 'path';
|
|
21
23
|
import { homedir } from 'os';
|
|
@@ -3246,24 +3248,83 @@ export class BrowserEngine {
|
|
|
3246
3248
|
|
|
3247
3249
|
async _rawScript(step) {
|
|
3248
3250
|
var target = await this._getTargetFrame(step);
|
|
3251
|
+
// ★ PR #4 v3 修复:抽 helper 便于测试 + 支持 step.codeFile + 静态扫描 Node-only API
|
|
3252
|
+
var resolve = require('./raw-script-helpers.js');
|
|
3253
|
+
var codeResult = resolve.resolveCodeFile(step, process.cwd());
|
|
3254
|
+
if (codeResult.error) {
|
|
3255
|
+
return resolve.buildErrorResult(codeResult.reason);
|
|
3256
|
+
}
|
|
3257
|
+
var code = codeResult.code;
|
|
3258
|
+
|
|
3259
|
+
// 静态扫描:检测常见 Node-only API(浏览器侧不可用)
|
|
3260
|
+
var nodeOnlyHints = resolve.detectNodeOnlyApi(code);
|
|
3261
|
+
if (nodeOnlyHints.length > 0) {
|
|
3262
|
+
return resolve.buildErrorResult(
|
|
3263
|
+
'rawScript 检测到 Node-only API:\n - ' + nodeOnlyHints.join('\n - ') +
|
|
3264
|
+
'\n\n → 修复:改用浏览器侧 API(document.* / localStorage / fetch 等)\n' +
|
|
3265
|
+
' → 详见 SKILL.md > ## rawScript 可用 API'
|
|
3266
|
+
);
|
|
3267
|
+
}
|
|
3268
|
+
|
|
3249
3269
|
var fn;
|
|
3250
|
-
try {
|
|
3251
|
-
|
|
3270
|
+
try {
|
|
3271
|
+
// rawScript 签名:async (page, frame, params, browser) => { return { passed, ... } }
|
|
3272
|
+
fn = new Function('page', 'frame', 'params', 'browser', 'return (async () => { ' + code + ' })()');
|
|
3273
|
+
} catch (e) {
|
|
3274
|
+
return resolve.buildErrorResult('script compile error: ' + e.message + '\n → 详见 SKILL.md > ## rawScript 可用 API');
|
|
3275
|
+
}
|
|
3252
3276
|
try {
|
|
3253
3277
|
var result = await fn(this.page, target.frame, step.params || {}, this.browser);
|
|
3254
3278
|
return Object.assign({ type: 'rawScript' }, result);
|
|
3255
|
-
} catch (e) {
|
|
3279
|
+
} catch (e) {
|
|
3280
|
+
return resolve.buildErrorResult('script execution error: ' + e.message + '\n → 详见 SKILL.md > ## rawScript 可用 API');
|
|
3281
|
+
}
|
|
3256
3282
|
}
|
|
3257
3283
|
|
|
3258
3284
|
async _apiCall(step) {
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3285
|
+
// ★ PR #8 v3: 走法 P — 增强 apiCall step(不通过浏览器,走 axios)
|
|
3286
|
+
// 自动从 context.cookies() 共享 Cookie
|
|
3287
|
+
// 6 个新字段:assert / captureTrace / noRetry / strategy / headers / tags
|
|
3288
|
+
try {
|
|
3289
|
+
// 从 currentPage 拿 cookies
|
|
3290
|
+
let cookies = [];
|
|
3291
|
+
try {
|
|
3292
|
+
if (this.page && this.page.browserContext) {
|
|
3293
|
+
cookies = await this.page.browserContext().cookies();
|
|
3294
|
+
}
|
|
3295
|
+
} catch (e) {
|
|
3296
|
+
// 跨域 iframe / about:blank 等忽略
|
|
3297
|
+
}
|
|
3298
|
+
|
|
3299
|
+
// 调新 executor
|
|
3300
|
+
const { executeApiCallStep } = await import('./api-call-executor.js');
|
|
3301
|
+
const baseURL = this._baseURL || (this.page ? (new URL(this.page.url()).origin) : '');
|
|
3302
|
+
const result = await executeApiCallStep(step, {
|
|
3303
|
+
baseURL,
|
|
3304
|
+
cookies
|
|
3305
|
+
});
|
|
3306
|
+
|
|
3307
|
+
return {
|
|
3308
|
+
type: 'apiCall',
|
|
3309
|
+
passed: result.status === 'passed',
|
|
3310
|
+
name: result.name,
|
|
3311
|
+
status: result.response?.status ?? 0,
|
|
3312
|
+
duration: result.duration,
|
|
3313
|
+
request: result.request,
|
|
3314
|
+
response: result.response,
|
|
3315
|
+
assertions: result.assertions,
|
|
3316
|
+
trace: result.trace,
|
|
3317
|
+
error: result.error,
|
|
3318
|
+
reason: result.status === 'passed' ? null : (result.error?.message ?? `API returned ${result.response?.status}`)
|
|
3319
|
+
};
|
|
3320
|
+
} catch (e) {
|
|
3321
|
+
return {
|
|
3322
|
+
type: 'apiCall',
|
|
3323
|
+
passed: false,
|
|
3324
|
+
reason: 'apiCall 执行失败: ' + e.message + '\n → 详见 SKILL.md > ## apiCall step',
|
|
3325
|
+
status: 'FAIL'
|
|
3326
|
+
};
|
|
3327
|
+
}
|
|
3267
3328
|
}
|
|
3268
3329
|
|
|
3269
3330
|
async _switchPage(step) {
|
|
@@ -3356,21 +3417,34 @@ export class BrowserEngine {
|
|
|
3356
3417
|
|
|
3357
3418
|
if (stepResult.status === "FAIL") {
|
|
3358
3419
|
allPassed = false;
|
|
3359
|
-
|
|
3360
|
-
const
|
|
3361
|
-
|
|
3362
|
-
const
|
|
3420
|
+
// ★ PR #6 v3:用 RetryPolicy 替换简单 retry 逻辑
|
|
3421
|
+
const policy = retryPolicyFromPlan(testCase.recovery);
|
|
3422
|
+
if (policy.isStepRetryable(step)) {
|
|
3423
|
+
const maxRetries = policy.maxRetriesPerStep;
|
|
3424
|
+
for (let retry = 0; retry < maxRetries; retry++) {
|
|
3425
|
+
const retryResult = await this.executeStep(step, context);
|
|
3426
|
+
rowResults.push({
|
|
3427
|
+
...retryResult,
|
|
3428
|
+
stepIndex,
|
|
3429
|
+
dataRowIndex,
|
|
3430
|
+
retry: retry + 1,
|
|
3431
|
+
});
|
|
3432
|
+
results.push(rowResults[rowResults.length - 1]);
|
|
3433
|
+
if (retryResult.status === "PASS") {
|
|
3434
|
+
allPassed = true;
|
|
3435
|
+
break;
|
|
3436
|
+
}
|
|
3437
|
+
}
|
|
3438
|
+
} else {
|
|
3439
|
+
// 写操作禁重试:标记 step 为 noRetry
|
|
3363
3440
|
rowResults.push({
|
|
3364
|
-
...
|
|
3441
|
+
...stepResult,
|
|
3365
3442
|
stepIndex,
|
|
3366
3443
|
dataRowIndex,
|
|
3367
|
-
|
|
3444
|
+
noRetry: true,
|
|
3445
|
+
reason: (stepResult.reason ?? 'fail') + ' (写操作禁重试)'
|
|
3368
3446
|
});
|
|
3369
3447
|
results.push(rowResults[rowResults.length - 1]);
|
|
3370
|
-
if (retryResult.status === "PASS") {
|
|
3371
|
-
allPassed = true;
|
|
3372
|
-
break;
|
|
3373
|
-
}
|
|
3374
3448
|
}
|
|
3375
3449
|
if (!allPassed && step.continueOnFail === false) break;
|
|
3376
3450
|
}
|