@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.
Files changed (117) hide show
  1. package/agents/build.js +59 -1
  2. package/agents/platforms.json +7 -0
  3. package/agents/szcd-component-expert.qoder.md +2 -0
  4. package/opencode-extension/opencode.json +9 -0
  5. package/opencode-extension/skills/local-browser-test/SKILL.md +91 -0
  6. package/opencode-extension/skills/local-browser-test/config.example.yaml +51 -0
  7. package/opencode-extension/skills/local-browser-test/lib/api-call-executor.js +251 -0
  8. package/opencode-extension/skills/local-browser-test/lib/browser-engine.js +95 -21
  9. package/opencode-extension/skills/local-browser-test/lib/config-loader.js +104 -0
  10. package/opencode-extension/skills/local-browser-test/lib/console-archiver.js +100 -0
  11. package/opencode-extension/skills/local-browser-test/lib/global-setup.js +72 -0
  12. package/opencode-extension/skills/local-browser-test/lib/har-collector.js +124 -0
  13. package/opencode-extension/skills/local-browser-test/lib/html-reporter.js +248 -0
  14. package/opencode-extension/skills/local-browser-test/lib/projects.js +46 -0
  15. package/opencode-extension/skills/local-browser-test/lib/raw-script-helpers.js +93 -0
  16. package/opencode-extension/skills/local-browser-test/lib/redact.js +94 -0
  17. package/opencode-extension/skills/local-browser-test/lib/report-retain.js +84 -0
  18. package/opencode-extension/skills/local-browser-test/lib/retry-policy.js +160 -0
  19. package/opencode-extension/skills/local-browser-test/lib/storage-state.js +111 -0
  20. package/opencode-extension/skills/local-browser-test/lib/tag-runner.js +103 -0
  21. package/opencode-extension/skills/local-browser-test/lib/test-plan.js +2 -0
  22. package/opencode-extension/skills/local-browser-test/lib/trace-collector.js +94 -0
  23. package/opencode-extension/skills/local-browser-test/lib/trend-aggregator.js +97 -0
  24. package/opencode-extension/skills/local-browser-test/lib/workspace-init.js +178 -0
  25. package/opencode-extension/skills/local-browser-test/local-browser-executor.js +226 -2
  26. package/opencode-extension/skills/local-browser-test/tests/integration/pr-01.test.js +254 -0
  27. package/opencode-extension/skills/local-browser-test/tests/integration/pr-02.test.js +263 -0
  28. package/opencode-extension/skills/local-browser-test/tests/integration/pr-03.test.js +308 -0
  29. package/opencode-extension/skills/local-browser-test/tests/integration/pr-04.test.js +168 -0
  30. package/opencode-extension/skills/local-browser-test/tests/integration/pr-05.test.js +183 -0
  31. package/opencode-extension/skills/local-browser-test/tests/integration/pr-06.test.js +252 -0
  32. package/opencode-extension/skills/local-browser-test/tests/integration/pr-07.test.js +168 -0
  33. package/opencode-extension/skills/local-browser-test/tests/integration/pr-08.test.js +293 -0
  34. package/opencode-extension/skills/shadcn-design-to-code/SKILL.md +352 -0
  35. package/opencode-extension/skills/shadcn-design-to-code/examples/1.1-overview-page.tsx +382 -0
  36. package/opencode-extension/skills/shadcn-design-to-code/references/install-pitfalls.md +526 -0
  37. package/opencode-extension/skills/shadcn-design-to-code/references/layouts.md +551 -0
  38. package/opencode-extension/skills/shadcn-design-to-code/references/sketch-mapping.md +247 -0
  39. package/opencode-extension/skills/shadcn-design-to-code/references/tokens.md +534 -0
  40. package/opencode-extension/skills/szcd-upload-zip/SKILL.md +45 -0
  41. package/package.json +70 -70
  42. package/qwen-extension/qwen-extension.json +10 -1
  43. package/qwen-extension/skills/local-browser-test/SKILL.md +91 -0
  44. package/qwen-extension/skills/local-browser-test/config.example.yaml +51 -0
  45. package/qwen-extension/skills/local-browser-test/lib/api-call-executor.js +251 -0
  46. package/qwen-extension/skills/local-browser-test/lib/browser-engine.js +95 -21
  47. package/qwen-extension/skills/local-browser-test/lib/config-loader.js +104 -0
  48. package/qwen-extension/skills/local-browser-test/lib/console-archiver.js +100 -0
  49. package/qwen-extension/skills/local-browser-test/lib/global-setup.js +72 -0
  50. package/qwen-extension/skills/local-browser-test/lib/har-collector.js +124 -0
  51. package/qwen-extension/skills/local-browser-test/lib/html-reporter.js +248 -0
  52. package/qwen-extension/skills/local-browser-test/lib/projects.js +46 -0
  53. package/qwen-extension/skills/local-browser-test/lib/raw-script-helpers.js +93 -0
  54. package/qwen-extension/skills/local-browser-test/lib/redact.js +94 -0
  55. package/qwen-extension/skills/local-browser-test/lib/report-retain.js +84 -0
  56. package/qwen-extension/skills/local-browser-test/lib/retry-policy.js +160 -0
  57. package/qwen-extension/skills/local-browser-test/lib/storage-state.js +111 -0
  58. package/qwen-extension/skills/local-browser-test/lib/tag-runner.js +103 -0
  59. package/qwen-extension/skills/local-browser-test/lib/test-plan.js +2 -0
  60. package/qwen-extension/skills/local-browser-test/lib/trace-collector.js +94 -0
  61. package/qwen-extension/skills/local-browser-test/lib/trend-aggregator.js +97 -0
  62. package/qwen-extension/skills/local-browser-test/lib/workspace-init.js +178 -0
  63. package/qwen-extension/skills/local-browser-test/local-browser-executor.js +226 -2
  64. package/qwen-extension/skills/local-browser-test/tests/integration/pr-01.test.js +254 -0
  65. package/qwen-extension/skills/local-browser-test/tests/integration/pr-02.test.js +263 -0
  66. package/qwen-extension/skills/local-browser-test/tests/integration/pr-03.test.js +308 -0
  67. package/qwen-extension/skills/local-browser-test/tests/integration/pr-04.test.js +168 -0
  68. package/qwen-extension/skills/local-browser-test/tests/integration/pr-05.test.js +183 -0
  69. package/qwen-extension/skills/local-browser-test/tests/integration/pr-06.test.js +252 -0
  70. package/qwen-extension/skills/local-browser-test/tests/integration/pr-07.test.js +168 -0
  71. package/qwen-extension/skills/local-browser-test/tests/integration/pr-08.test.js +293 -0
  72. package/qwen-extension/skills/shadcn-design-to-code/SKILL.md +352 -0
  73. package/qwen-extension/skills/shadcn-design-to-code/examples/1.1-overview-page.tsx +382 -0
  74. package/qwen-extension/skills/shadcn-design-to-code/references/install-pitfalls.md +526 -0
  75. package/qwen-extension/skills/shadcn-design-to-code/references/layouts.md +551 -0
  76. package/qwen-extension/skills/shadcn-design-to-code/references/sketch-mapping.md +247 -0
  77. package/qwen-extension/skills/shadcn-design-to-code/references/tokens.md +534 -0
  78. package/qwen-extension/skills/szcd-upload-zip/SKILL.md +45 -0
  79. package/scripts/lib/common.js +7 -0
  80. package/standard-skill/local-browser-test/SKILL.md +91 -0
  81. package/standard-skill/local-browser-test/config.example.yaml +51 -0
  82. package/standard-skill/local-browser-test/lib/api-call-executor.js +251 -0
  83. package/standard-skill/local-browser-test/lib/browser-engine.js +95 -21
  84. package/standard-skill/local-browser-test/lib/config-loader.js +104 -0
  85. package/standard-skill/local-browser-test/lib/console-archiver.js +100 -0
  86. package/standard-skill/local-browser-test/lib/global-setup.js +72 -0
  87. package/standard-skill/local-browser-test/lib/har-collector.js +124 -0
  88. package/standard-skill/local-browser-test/lib/html-reporter.js +248 -0
  89. package/standard-skill/local-browser-test/lib/projects.js +46 -0
  90. package/standard-skill/local-browser-test/lib/raw-script-helpers.js +93 -0
  91. package/standard-skill/local-browser-test/lib/redact.js +94 -0
  92. package/standard-skill/local-browser-test/lib/report-retain.js +84 -0
  93. package/standard-skill/local-browser-test/lib/retry-policy.js +160 -0
  94. package/standard-skill/local-browser-test/lib/storage-state.js +111 -0
  95. package/standard-skill/local-browser-test/lib/tag-runner.js +103 -0
  96. package/standard-skill/local-browser-test/lib/test-plan.js +2 -0
  97. package/standard-skill/local-browser-test/lib/trace-collector.js +94 -0
  98. package/standard-skill/local-browser-test/lib/trend-aggregator.js +97 -0
  99. package/standard-skill/local-browser-test/lib/workspace-init.js +178 -0
  100. package/standard-skill/local-browser-test/local-browser-executor.js +226 -2
  101. package/standard-skill/local-browser-test/tests/integration/pr-01.test.js +254 -0
  102. package/standard-skill/local-browser-test/tests/integration/pr-02.test.js +263 -0
  103. package/standard-skill/local-browser-test/tests/integration/pr-03.test.js +308 -0
  104. package/standard-skill/local-browser-test/tests/integration/pr-04.test.js +168 -0
  105. package/standard-skill/local-browser-test/tests/integration/pr-05.test.js +183 -0
  106. package/standard-skill/local-browser-test/tests/integration/pr-06.test.js +252 -0
  107. package/standard-skill/local-browser-test/tests/integration/pr-07.test.js +168 -0
  108. package/standard-skill/local-browser-test/tests/integration/pr-08.test.js +293 -0
  109. package/standard-skill/shadcn-design-to-code/SKILL.md +352 -0
  110. package/standard-skill/shadcn-design-to-code/examples/1.1-overview-page.tsx +382 -0
  111. package/standard-skill/shadcn-design-to-code/references/install-pitfalls.md +526 -0
  112. package/standard-skill/shadcn-design-to-code/references/layouts.md +551 -0
  113. package/standard-skill/shadcn-design-to-code/references/sketch-mapping.md +247 -0
  114. package/standard-skill/shadcn-design-to-code/references/tokens.md +534 -0
  115. package/standard-skill/szcd-upload-zip/SKILL.md +45 -0
  116. package/opencode-extension/skills/local-browser-test/local-browser-executor.cjs +0 -742
  117. package/qwen-extension/skills/local-browser-test/local-browser-executor.cjs +0 -742
@@ -0,0 +1,72 @@
1
+ /**
2
+ * globalSetup: 两种自动登录模式
3
+ *
4
+ * 模式 1(接管浏览器):用户已在浏览器里登录 → 直接导出 cookies + localStorage
5
+ * 模式 2(自动填表):调 decrypt-passwords.js + 填表 + 拿 cookie
6
+ */
7
+
8
+ import fs from "node:fs";
9
+ import { captureStorageState } from "./storage-state.js";
10
+ import { redact } from "./redact.js";
11
+
12
+ /**
13
+ * 接管浏览器模式:用户已在浏览器里登录,直接导出 storageState
14
+ * @param {import('puppeteer').Page} page
15
+ * @param {string} outputPath 落盘路径(推荐 .lbt/auth/<project>.json)
16
+ */
17
+ export async function globalSetupTakeover(page, outputPath) {
18
+ const state = await captureStorageState(page);
19
+
20
+ // 落盘前 redact(cookie value 也可能含敏感 token)
21
+ const safe = redact(state);
22
+ fs.mkdirSync(fs.dirname(outputPath), { recursive: true });
23
+ fs.writeFileSync(outputPath, JSON.stringify(safe, null, 2));
24
+ console.log(`✓ storageState 已保存: ${outputPath}`);
25
+ console.log(` cookies: ${safe.cookies?.length ?? 0}, origins: ${safe.origins?.length ?? 0}`);
26
+ }
27
+
28
+ /**
29
+ * 自动填表模式:调 decrypt-passwords + 填表 + 拿 cookie
30
+ * @param {import('puppeteer').Page} page
31
+ * @param {object} options
32
+ * @param {string} options.loginUrl - 登录页 URL
33
+ * @param {string} [options.usernameSelector='#username']
34
+ * @param {string} [options.passwordSelector='#password']
35
+ * @param {string} [options.submitSelector='button[type=submit]']
36
+ * @param {string} [options.verifyCodeSelector=null]
37
+ * @param {string} options.username
38
+ * @param {string} options.password
39
+ * @param {string} [options.verifyCode=null]
40
+ * @param {string} outputPath
41
+ */
42
+ export async function globalSetupFillForm(page, options, outputPath) {
43
+ const {
44
+ loginUrl,
45
+ usernameSelector = "#username",
46
+ passwordSelector = "#password",
47
+ submitSelector = "button[type=submit]",
48
+ verifyCodeSelector = null,
49
+ username,
50
+ password,
51
+ verifyCode = null
52
+ } = options;
53
+
54
+ // 1. 打开登录页
55
+ await page.goto(loginUrl, { waitUntil: "networkidle2" });
56
+
57
+ // 2. 填表
58
+ await page.type(usernameSelector, username);
59
+ await page.type(passwordSelector, password);
60
+ if (verifyCodeSelector && verifyCode) {
61
+ await page.type(verifyCodeSelector, verifyCode);
62
+ }
63
+
64
+ // 3. 提交(等导航完成)
65
+ await Promise.all([
66
+ page.waitForNavigation({ waitUntil: "networkidle2", timeout: 30000 }).catch(() => {}),
67
+ page.click(submitSelector)
68
+ ]);
69
+
70
+ // 4. 复用 takeover 模式导出 storageState
71
+ await globalSetupTakeover(page, outputPath);
72
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * HAR 抓包(PR #5)
3
+ *
4
+ * 用 puppeteer 的 Network.enable + Network.requestWillBeSent/responseReceived 事件,
5
+ * 停止时落盘 network.har(HAR 1.2 格式)。
6
+ *
7
+ * HAR 格式参考:https://w3c.github.io/web-performance/specs/HAR/Overview.html
8
+ */
9
+
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+
13
+ const REDACT_PATTERNS = [
14
+ /token=[^&]+/g,
15
+ /password=[^&]+/g,
16
+ /verifyCode=[^&]+/g,
17
+ /code=[^&]+/g,
18
+ ];
19
+
20
+ function redactHeaderValue(value) {
21
+ if (typeof value !== 'string') return value;
22
+ if (/^Bearer\s+/i.test(value)) return 'Bearer ***REDACTED***';
23
+ if (/^[\da-f]{20,}$/i.test(value)) return '***REDACTED***'; // 长 hex token
24
+ return value;
25
+ }
26
+
27
+ function redactUrl(url) {
28
+ if (typeof url !== 'string') return url;
29
+ let result = url;
30
+ for (const pat of REDACT_PATTERNS) {
31
+ result = result.replace(pat, (m) => m.split('=')[0] + '=***');
32
+ }
33
+ return result;
34
+ }
35
+
36
+ export class HarCollector {
37
+ /**
38
+ * @param {string} runDir
39
+ */
40
+ constructor(runDir) {
41
+ this.runDir = runDir;
42
+ this.requests = [];
43
+ this.session = null;
44
+ this.collecting = false;
45
+ }
46
+
47
+ /**
48
+ * 启动 HAR 抓包
49
+ * @param {import('puppeteer').Page} page
50
+ */
51
+ async start(page) {
52
+ if (this.collecting) return;
53
+ try {
54
+ this.session = await page.target().createCDPSession();
55
+ await this.session.send('Network.enable');
56
+
57
+ this.session.on('Network.requestWillBeSent', (e) => {
58
+ this.requests.push({
59
+ url: redactUrl(e.request.url ?? ''),
60
+ method: e.request.method ?? 'GET',
61
+ headers: (e.request.headers ?? []).map((h) => ({
62
+ name: h.name,
63
+ value: redactHeaderValue(h.value)
64
+ })),
65
+ postData: e.request.postData ?? null,
66
+ startedDateTime: new Date((e.timestamp ?? 0) * 1000).toISOString(),
67
+ type: e.type ?? null
68
+ });
69
+ });
70
+
71
+ this.session.on('Network.responseReceived', (e) => {
72
+ const r = this.requests.find((r) => r.url === redactUrl(e.request.url));
73
+ if (r) {
74
+ r.status = e.response.status;
75
+ r.statusText = e.response.statusText;
76
+ r.headers = (e.response.headers ?? []).map((h) => ({
77
+ name: h.name,
78
+ value: redactHeaderValue(h.value)
79
+ }));
80
+ r.mimeType = e.response.mimeType;
81
+ }
82
+ });
83
+
84
+ this.session.on('Network.loadingFinished', (e) => {
85
+ const r = this.requests.find((r) => r.url === redactUrl(
86
+ // 没有 url 字段,需要在 requestWillBeSent 时存 requestId 关联
87
+ ''
88
+ ));
89
+ });
90
+
91
+ this.collecting = true;
92
+ } catch (e) {
93
+ console.warn(`⚠️ HarCollector.start 失败: ${e.message}`);
94
+ }
95
+ }
96
+
97
+ /**
98
+ * 停止并落盘 network.har
99
+ * @returns {Promise<string|null>}
100
+ */
101
+ async stop() {
102
+ // 注:不做 collecting 检查(测试场景可能直接落盘构造的 requests)
103
+ try {
104
+ fs.mkdirSync(this.runDir, { recursive: true });
105
+ const har = {
106
+ log: {
107
+ version: '1.2',
108
+ creator: {
109
+ name: 'szcd local-browser-test',
110
+ version: '0.37.0'
111
+ },
112
+ entries: this.requests.filter((r) => r.status) // 只保留有响应的请求
113
+ }
114
+ };
115
+ const harPath = path.join(this.runDir, 'network.har');
116
+ fs.writeFileSync(harPath, JSON.stringify(har, null, 2));
117
+ this.collecting = false;
118
+ return harPath;
119
+ } catch (e) {
120
+ console.warn(`⚠️ HarCollector 落盘失败: ${e.message}`);
121
+ return null;
122
+ }
123
+ }
124
+ }
@@ -0,0 +1,248 @@
1
+ /**
2
+ * v4 Playwright 风格 HTML 报告生成
3
+ *
4
+ * 关键设计:
5
+ * - 入口 index.html:最近 20 次 run 列表 + 趋势图(echarts CDN)
6
+ * - 单 run runs/<id>/index.html:自包含可分享
7
+ * - 单 run 含 trace.zip / screenshots / console.log 引用
8
+ * - 失败用例展开详情(Request/Response/Assertions/Timings 多个 tab)
9
+ */
10
+
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+
14
+ const TYPE_ICONS = {
15
+ ui: "🖱️",
16
+ api: "🅰️"
17
+ };
18
+
19
+ /**
20
+ * 初始化入口 index.html(幂等:已存在则不覆盖)
21
+ * @param {string} reportsDir - 报告根目录(如 <workspace>/reports)
22
+ */
23
+ export function initIndexHtml(reportsDir) {
24
+ const indexPath = path.join(reportsDir, "index.html");
25
+ if (fs.existsSync(indexPath)) return; // 幂等
26
+
27
+ const html = `<!DOCTYPE html>
28
+ <html lang="zh-CN">
29
+ <head>
30
+ <meta charset="UTF-8">
31
+ <title>szcd local-browser-test 报告</title>
32
+ <script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
33
+ <style>
34
+ body { font-family: -apple-system, "Segoe UI", sans-serif; margin: 20px; background: #fafafa; }
35
+ h1 { color: #1677ff; }
36
+ h2 { color: #333; margin-top: 30px; }
37
+ table { border-collapse: collapse; width: 100%; margin: 12px 0; background: #fff; }
38
+ th, td { border: 1px solid #d9d9d9; padding: 8px 12px; text-align: left; }
39
+ th { background: #f5f5f5; }
40
+ .pass { color: #52c41a; font-weight: bold; }
41
+ .fail { color: #ff4d4f; font-weight: bold; }
42
+ .icon-api { color: #722ed1; }
43
+ .icon-ui { color: #1677ff; }
44
+ a { color: #1677ff; text-decoration: none; }
45
+ a:hover { text-decoration: underline; }
46
+ #trend-chart { width: 100%; height: 200px; background: #fff; border-radius: 4px; padding: 10px; }
47
+ .filter-section { background: #fff; padding: 12px; border-radius: 4px; margin: 12px 0; }
48
+ .filter-section select { margin: 0 8px; padding: 4px; }
49
+ </style>
50
+ </head>
51
+ <body>
52
+ <h1>szcd local-browser-test 报告</h1>
53
+ <div id="trend-section">
54
+ <h2>📊 趋势(最近 7 天)</h2>
55
+ <div id="trend-chart"></div>
56
+ </div>
57
+ <div class="filter-section">
58
+ <h3>🔍 过滤</h3>
59
+ <select id="filter-tag"><option value="">所有 tag</option></select>
60
+ <select id="filter-project"><option value="">所有 project</option></select>
61
+ <select id="filter-branch"><option value="">所有 branch</option></select>
62
+ <select id="filter-status"><option value="">所有状态</option><option value="pass">通过</option><option value="fail">失败</option></select>
63
+ </div>
64
+ <div id="runs-section">
65
+ <h2>📋 最近 20 次运行</h2>
66
+ <table id="runs-table">
67
+ <thead><tr>
68
+ <th>时间</th><th>类型</th><th>策略</th><th>project</th>
69
+ <th>tag</th><th>通过</th><th>耗时</th><th>链接</th>
70
+ </tr></thead>
71
+ <tbody id="runs-tbody"></tbody>
72
+ </table>
73
+ </div>
74
+ <div id="index-section">
75
+ <h3>📦 按视图浏览</h3>
76
+ <a href="by-tag/">by tag</a> |
77
+ <a href="by-project/">by project</a> |
78
+ <a href="by-branch/">by branch</a> |
79
+ <a href="runs-failed/">失败归档</a>
80
+ </div>
81
+ <div id="cleanup-section">
82
+ <h4>⚙ 清理策略:保留最近 30 次 / 7 天(失败永久保留)</h4>
83
+ </div>
84
+ <script>
85
+ // 客户端脚本(占位,未来可加过滤交互)
86
+ </script>
87
+ </body>
88
+ </html>`;
89
+ fs.writeFileSync(indexPath, html);
90
+ }
91
+
92
+ /**
93
+ * 在入口 index.html 表格里追加一行(按时间倒序:最新在最上面)
94
+ * @param {object} result
95
+ * @param {string} indexPath
96
+ */
97
+ export function appendRunCardToIndex(result, indexPath) {
98
+ if (!fs.existsSync(indexPath)) return; // 入口未初始化
99
+
100
+ let html = fs.readFileSync(indexPath, "utf8");
101
+ const card = renderRunCard(result);
102
+
103
+ // 插入到 <tbody id="runs-tbody"></tbody> 开头
104
+ const insertRe = /<tbody id="runs-tbody"><\/tbody>/;
105
+ if (insertRe.test(html)) {
106
+ html = html.replace(insertRe, `<tbody id="runs-tbody">\n${card}\n</tbody>`);
107
+ } else {
108
+ // 已有 tbody → 在开头插入
109
+ html = html.replace(/<tbody id="runs-tbody">/, `<tbody id="runs-tbody">\n${card}`);
110
+ }
111
+
112
+ // 限制最近 20 行
113
+ const tbodyMatch = html.match(/<tbody id="runs-tbody">([\s\S]*?)<\/tbody>/);
114
+ if (tbodyMatch) {
115
+ const rows = tbodyMatch[1].split("\n").filter((l) => l.trimStart().startsWith("<tr>"));
116
+ if (rows.length > 20) {
117
+ const kept = rows.slice(0, 20).join("\n");
118
+ html = html.replace(tbodyMatch[0], `<tbody id="runs-tbody">\n${kept}\n</tbody>`);
119
+ }
120
+ }
121
+
122
+ fs.writeFileSync(indexPath, html);
123
+ }
124
+
125
+ function renderRunCard(result) {
126
+ const icon = TYPE_ICONS[result.type] ?? "🖥️";
127
+ const cls = result.stats.failed === 0 ? "pass" : "fail";
128
+ const time = (result.startedAt ?? "").replace("T", " ").slice(0, 19);
129
+ const dur = ((result.durationMs ?? 0) / 1000).toFixed(1);
130
+ return ` <tr>
131
+ <td>${time}</td>
132
+ <td><span class="icon-${result.type ?? "ui"}">${icon}</span> ${result.type ?? "ui"}</td>
133
+ <td>${result.strategy ?? "-"}</td>
134
+ <td>${result.project ?? "-"}</td>
135
+ <td>${result.tag ?? "-"}</td>
136
+ <td><span class="${cls}">${result.stats.passed}/${result.stats.total}</span></td>
137
+ <td>${dur}s</td>
138
+ <td><a href="runs/${result.runId}/index.html">→ 详情</a></td>
139
+ </tr>`;
140
+ }
141
+
142
+ /**
143
+ * 生成单 run 的 HTML 报告
144
+ * @param {object} result
145
+ * @param {string} runDir
146
+ */
147
+ export function generateRunHtml(result, runDir) {
148
+ fs.mkdirSync(runDir, { recursive: true });
149
+ const html = renderRunDetail(result);
150
+ fs.writeFileSync(path.join(runDir, "index.html"), html);
151
+
152
+ // 复制失败截图(如有)
153
+ if (Array.isArray(result.cases)) {
154
+ const shotsDir = path.join(runDir, "screenshots");
155
+ for (const c of result.cases) {
156
+ if (c.screenshot && fs.existsSync(c.screenshot)) {
157
+ fs.mkdirSync(shotsDir, { recursive: true });
158
+ const safeName = (c.name ?? "unknown").replace(/[^a-z0-9_-]/gi, "-");
159
+ fs.copyFileSync(c.screenshot, path.join(shotsDir, `${safeName}.png`));
160
+ }
161
+ }
162
+ }
163
+ }
164
+
165
+ function renderRunDetail(result) {
166
+ const cases = result.cases ?? [];
167
+ const failedCases = cases.filter((c) => c.status === "failed");
168
+ return `<!DOCTYPE html>
169
+ <html lang="zh-CN">
170
+ <head>
171
+ <meta charset="UTF-8">
172
+ <title>Run @ ${result.startedAt ?? result.runId}</title>
173
+ <style>
174
+ body { font-family: -apple-system, "Segoe UI", sans-serif; margin: 20px; background: #fafafa; }
175
+ h1, h2, h3 { color: #1677ff; }
176
+ h1 { margin-bottom: 8px; }
177
+ .meta { color: #666; margin-bottom: 16px; }
178
+ .meta b { color: #333; }
179
+ table { border-collapse: collapse; width: 100%; margin: 12px 0; background: #fff; }
180
+ th, td { border: 1px solid #d9d9d9; padding: 8px 12px; text-align: left; }
181
+ th { background: #f5f5f5; }
182
+ .pass { color: #52c41a; font-weight: bold; }
183
+ .fail { color: #ff4d4f; font-weight: bold; }
184
+ .case { border: 1px solid #d9d9d9; margin: 12px 0; padding: 12px; border-radius: 4px; background: #fff; }
185
+ .case.failed { border-color: #ff4d4f; background: #fff1f0; }
186
+ pre { background: #f5f5f5; padding: 8px; border-radius: 4px; overflow-x: auto; }
187
+ .back-link { margin-top: 30px; }
188
+ </style>
189
+ </head>
190
+ <body>
191
+ <h1>Run @ ${result.startedAt ?? result.runId} (${result.runId})</h1>
192
+ <p class="meta">
193
+ type: <b>${result.type ?? "ui"}</b> (${result.strategy ?? "-"})
194
+ project: <b>${result.project ?? "-"}</b>
195
+ tag: <b>${result.tag ?? "-"}</b>
196
+ branch: <b>${result.branch ?? "-"}</b>
197
+ commit: <b>${result.commit ?? "-"}</b>
198
+ </p>
199
+ <p class="meta">
200
+ executor: <b>${result.executor?.version ?? "-"}</b>
201
+ 浏览器: <b>${result.executor?.browserVersion ?? "-"}</b>
202
+ </p>
203
+ <p class="meta">
204
+ 总用例: <b>${result.stats?.total ?? 0}</b>
205
+ 通过: <span class="pass">${result.stats?.passed ?? 0}</span>
206
+ 失败: <span class="fail">${result.stats?.failed ?? 0}</span>
207
+ 跳过: <b>${result.stats?.skipped ?? 0}</b>
208
+ 耗时: <b>${((result.durationMs ?? 0) / 1000).toFixed(1)}s</b>
209
+ </p>
210
+ <h2>用例列表</h2>
211
+ <table>
212
+ <thead><tr>
213
+ <th>用例</th><th>状态</th><th>耗时</th><th>trace</th><th>截图</th>
214
+ </tr></thead>
215
+ <tbody>
216
+ ${cases.map((c) => `<tr>
217
+ <td>${c.name}</td>
218
+ <td><span class="${c.status === "passed" ? "pass" : "fail"}">${c.status ?? "?"}</span></td>
219
+ <td>${c.duration ?? 0}ms</td>
220
+ <td>${c.trace ? '<a href="trace.zip">trace</a>' : "-"}</td>
221
+ <td>${c.screenshot ? `<a href="screenshots/${(c.name ?? "x").replace(/[^a-z0-9_-]/gi, "-")}.png">截图</a>` : "-"}</td>
222
+ </tr>`).join("\n")}
223
+ </tbody>
224
+ </table>
225
+ ${failedCases.map((c) => renderFailedCaseDetail(c)).join("\n")}
226
+ <p class="back-link"><a href="../index.html">← 返回报告列表</a></p>
227
+ </body>
228
+ </html>`;
229
+ }
230
+
231
+ function renderFailedCaseDetail(c) {
232
+ const req = c.request ?? {};
233
+ const resp = c.response ?? {};
234
+ return `<div class="case failed">
235
+ <h3>❌ ${c.name} · ${req.method ?? ""} ${req.url ?? ""}</h3>
236
+ <h4>Request</h4>
237
+ <pre>${req.method ?? ""} ${req.url ?? ""}
238
+ Headers: ${JSON.stringify(req.headers ?? {}, null, 2)}
239
+ Body: ${req.body ?? ""}</pre>
240
+ <h4>Response</h4>
241
+ <pre>${resp.status ?? "-"} ${resp.statusText ?? ""} ${resp.duration ?? "-"}ms
242
+ Headers: ${JSON.stringify(resp.headers ?? {}, null, 2)}
243
+ Body: ${(resp.body ?? "").toString().slice(0, 2000)}</pre>
244
+ <h4>Assertions</h4>
245
+ ${(c.assertions ?? []).map((a) => `<p>${a.status === "passed" ? "✅" : "❌"} ${a.name}${a.error ? "<br><code>" + (a.error.message ?? "") + "</code>" : ""}</p>`).join("") || "<p>(none)</p>"}
246
+ ${c.screenshot ? `<p>截图:<a href="screenshots/${(c.name ?? "x").replace(/[^a-z0-9_-]/gi, "-")}.png">${c.name}</a></p>` : ""}
247
+ </div>`;
248
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * config.projects 解析 + --project <name> 切换
3
+ *
4
+ * 用于 PR #2 多环境支持:用户在 config.yaml 定义多个 project,
5
+ * 跑测试时用 --project <name> 切换 baseURL + auth
6
+ */
7
+
8
+ export function selectProject(config, projectName) {
9
+ if (!projectName) return null;
10
+ const project = config.projects?.find((p) => p.name === projectName);
11
+ if (!project) {
12
+ const available = config.projects?.map((p) => p.name).join(", ") || "(none)";
13
+ throw new Error(
14
+ `config 中找不到 project "${projectName}"\n` +
15
+ ` 可用: ${available}\n` +
16
+ ` → 修复:在 .lbt/config.yaml 的 projects 列表中添加,或不传 --project 用默认`
17
+ );
18
+ }
19
+ return project;
20
+ }
21
+
22
+ /**
23
+ * 把 project 配置合并到 config(深合并)
24
+ * @param {object} config 完整 config
25
+ * @param {string} projectName --project <name>
26
+ * @returns {object} 合并后的 config
27
+ */
28
+ export function applyProject(config, projectName) {
29
+ if (!projectName) return config;
30
+ const project = selectProject(config, projectName);
31
+ return {
32
+ ...config,
33
+ app: { ...(config.app ?? {}), ...(project.baseURL ? { baseURL: project.baseURL } : {}) },
34
+ auth: { ...(config.auth ?? {}), ...(project.auth ?? {}) },
35
+ _project: project // 标记来源,便于 trace
36
+ };
37
+ }
38
+
39
+ /**
40
+ * 列出 config.projects 所有可用 project 名
41
+ * @param {object} config
42
+ * @returns {string[]}
43
+ */
44
+ export function listProjects(config) {
45
+ return (config.projects ?? []).map((p) => p.name);
46
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * rawScript 辅助函数(PR #4 v3)
3
+ *
4
+ * 抽出来便于独立测试 + 复用:
5
+ * - resolveCodeFile(step, cwd): 从 step.code / step.codeFile 解析代码
6
+ * - detectNodeOnlyApi(code): 静态扫描 Node-only API
7
+ * - buildErrorResult(reason): 构造 rawScript 失败响应
8
+ */
9
+
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+
13
+ /**
14
+ * 从 step 解析 rawScript 代码
15
+ * @param {object} step
16
+ * @param {string} cwd - process.cwd()
17
+ * @returns {{code: string} | {error: string, reason: string, status: string}}
18
+ */
19
+ export function resolveCodeFile(step, cwd = process.cwd()) {
20
+ let code = step.code;
21
+ if (!code && step.codeFile) {
22
+ try {
23
+ let codePath = step.codeFile;
24
+ if (!path.isAbsolute(codePath)) {
25
+ codePath = path.resolve(cwd, codePath);
26
+ }
27
+ if (!fs.existsSync(codePath)) {
28
+ return {
29
+ error: true,
30
+ reason: `codeFile 不存在: ${codePath}\n → 修复:检查路径是否正确,或用 inline code (step.code) 替代\n → 详见 SKILL.md > ## rawScript 可用 API`,
31
+ status: 'FAIL'
32
+ };
33
+ }
34
+ code = fs.readFileSync(codePath, 'utf8');
35
+ } catch (e) {
36
+ return {
37
+ error: true,
38
+ reason: `codeFile 读取失败: ${e.message}\n → 详见 SKILL.md > ## rawScript 可用 API`,
39
+ status: 'FAIL'
40
+ };
41
+ }
42
+ }
43
+ if (!code || typeof code !== 'string') {
44
+ return {
45
+ error: true,
46
+ reason: 'rawScript 必须有 step.code 或 step.codeFile 之一\n → 详见 SKILL.md > ## rawScript 可用 API',
47
+ status: 'FAIL'
48
+ };
49
+ }
50
+ return { code };
51
+ }
52
+
53
+ /**
54
+ * 静态扫描 rawScript 代码,检测 Node-only API
55
+ * @param {string} code
56
+ * @returns {string[]} 提示列表(空数组 = 无 Node-only API)
57
+ */
58
+ export function detectNodeOnlyApi(code) {
59
+ const hints = [];
60
+ if (/\brequire\s*\(/.test(code)) {
61
+ hints.push("require('fs') / require('path') 等 Node 模块(浏览器无 require)");
62
+ }
63
+ if (/\b__dirname\b/.test(code)) {
64
+ hints.push('__dirname(浏览器无 Node 全局)');
65
+ }
66
+ if (/\b__filename\b/.test(code)) {
67
+ hints.push('__filename(浏览器无 Node 全局)');
68
+ }
69
+ if (/\bprocess\b\./.test(code)) {
70
+ hints.push('process.env / process.cwd()(浏览器无 process)');
71
+ }
72
+ if (/\bBuffer\b/.test(code)) {
73
+ hints.push('Buffer(浏览器无 Node Buffer)');
74
+ }
75
+ if (/\brequire\.main\b/.test(code)) {
76
+ hints.push('require.main(浏览器无 Node require)');
77
+ }
78
+ return hints;
79
+ }
80
+
81
+ /**
82
+ * 构造 rawScript 失败响应(统一格式)
83
+ * @param {string} reason
84
+ * @returns {{type: string, passed: boolean, reason: string, status: string}}
85
+ */
86
+ export function buildErrorResult(reason) {
87
+ return {
88
+ type: 'rawScript',
89
+ passed: false,
90
+ reason,
91
+ status: 'FAIL'
92
+ };
93
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * 通用脱敏函数。所有落盘 JSON 必走。
3
+ *
4
+ * 覆盖字段:password / verifyCode / credentials / token / apiKey / cookie 等
5
+ * 覆盖字符串:URL query 参数、Authorization header value、JSON 字段值
6
+ *
7
+ * 完整覆盖范围见 REDACT_KEYS / REDACT_PATTERNS
8
+ */
9
+
10
+ const REDACT_KEYS = [
11
+ 'password', 'verifyCode', 'credentials', 'auth',
12
+ 'token', 'accessToken', 'refreshToken', 'apiKey',
13
+ 'authorization', 'cookie', 'set-cookie', 'x-auth', 'x-token', 'x-api-key',
14
+ 'secret', 'privateKey', 'clientSecret'
15
+ ];
16
+
17
+ const REDACT_PATTERNS = [
18
+ // URL query 参数:(?token=abc | &password=xxx | ?code=6)
19
+ /([?&])(token|password|verifyCode|code|apiKey|secret)=([^&\s]+)/g,
20
+ // Authorization header value
21
+ /(Bearer\s+)[A-Za-z0-9\-._~+/]+=*/g,
22
+ // JSON 字段值
23
+ /"(token|password|verifyCode|apiKey|secret)"\s*:\s*"[^"]+"/g,
24
+ ];
25
+
26
+ /**
27
+ * 递归脱敏对象
28
+ * @param {any} obj
29
+ * @returns {any} 新的脱敏后对象(深拷贝,不修改原对象)
30
+ */
31
+ export function redact(obj) {
32
+ if (obj === null || obj === undefined) return obj;
33
+ if (Array.isArray(obj)) return obj.map(redact);
34
+ if (typeof obj !== 'object') return redactString(String(obj));
35
+ return Object.fromEntries(
36
+ Object.entries(obj).map(([k, v]) => {
37
+ if (REDACT_KEYS.some(r => k.toLowerCase().includes(r.toLowerCase()))) {
38
+ return [k, '***REDACTED***'];
39
+ }
40
+ return [k, redact(v)];
41
+ })
42
+ );
43
+ }
44
+
45
+ function redactString(str) {
46
+ let result = str;
47
+ for (const pat of REDACT_PATTERNS) {
48
+ result = result.replace(pat, (match, p1, p2, p3) => {
49
+ if (p1 === '?' || p1 === '&') return `${p1}${p2}=***`;
50
+ if (p1 === 'Bearer ') return `${p1}***`;
51
+ // JSON 字段值:把 value 部分替换为 "***"
52
+ return match.replace(/:\s*"[^"]+"/, ': "***"');
53
+ });
54
+ }
55
+ return result;
56
+ }
57
+
58
+ /**
59
+ * 脱敏 HTTP headers 数组 [{ key, value }]
60
+ * @param {Array<{key: string, value: string}>} headers
61
+ * @returns {Array<{key: string, value: string}>}
62
+ */
63
+ export function redactHeaders(headers) {
64
+ if (!Array.isArray(headers)) return headers;
65
+ return headers.map(h => {
66
+ if (REDACT_KEYS.some(r => h.key.toLowerCase().includes(r.toLowerCase()))) {
67
+ return { key: h.key, value: '***REDACTED***' };
68
+ }
69
+ return h;
70
+ });
71
+ }
72
+
73
+ /**
74
+ * 脱敏 URL(保留路径和 host,redact query 参数)
75
+ * @param {string} url
76
+ * @returns {string}
77
+ */
78
+ export function redactUrl(url) {
79
+ if (typeof url !== 'string') return url;
80
+ return url.replace(
81
+ /([?&])(token|password|verifyCode|code|apiKey|secret)=([^&\s]+)/g,
82
+ '$1$2=***'
83
+ );
84
+ }
85
+
86
+ /**
87
+ * 脱敏 JSON 字符串中的敏感字段(用于已经序列化的字符串)
88
+ * @param {string} jsonStr
89
+ * @returns {string}
90
+ */
91
+ export function redactJsonString(jsonStr) {
92
+ if (typeof jsonStr !== 'string') return jsonStr;
93
+ return redactString(jsonStr);
94
+ }