@szc-ft/mcp-szcd-client 0.35.0 → 0.39.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 +186 -18
  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 +73 -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 +425 -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 +264 -4
  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 +186 -18
  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 +73 -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 +425 -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 +264 -4
  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 +186 -18
  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 +73 -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 +425 -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 +264 -4
  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,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,425 @@
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
+ function normalizeRunResult(r) {
21
+ const s = r.summary ?? r.stats ?? {};
22
+ return {
23
+ ...r,
24
+ startedAt: r.startTime ?? r.startedAt,
25
+ durationMs: parseInt(r.duration ?? "0", 10) || r.durationMs || 0,
26
+ stats: {
27
+ total: parseInt(s.total ?? "0", 10),
28
+ passed: parseInt(s.passed ?? "0", 10),
29
+ failed: parseInt(s.failed ?? "0", 10),
30
+ skipped: parseInt(s.skipped ?? "0", 10),
31
+ },
32
+ cases: r.steps?.map((step, i) => ({
33
+ name: step.step ?? `步骤 ${i+1}`,
34
+ status: step.status === "PASS" ? "passed" : "failed",
35
+ duration: parseInt(step.duration ?? "0", 10),
36
+ durationMs: parseInt(step.duration ?? "0", 10),
37
+ error: step.error || (step.status !== "PASS" ? JSON.stringify(step) : undefined),
38
+ screenshot: step.filepath,
39
+ step,
40
+ })) ?? r.cases ?? [],
41
+ executor: r.browserInfo ? {
42
+ version: r.executor?.version ?? "-",
43
+ browserVersion: `${r.browserInfo.browserName ?? ""} ${r.browserInfo.browserVersion ?? ""}`.trim() || "-",
44
+ } : r.executor,
45
+ };
46
+ }
47
+
48
+ const STYLE = `
49
+ * { box-sizing: border-box; margin: 0; padding: 0; }
50
+ body {
51
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
52
+ background: #f0f2f5; color: #1d1d1f; padding: 0;
53
+ }
54
+ .page { max-width: 1100px; margin: 0 auto; padding: 32px 24px; }
55
+ h1 { font-size: 24px; font-weight: 700; color: #1d1d1f; margin-bottom: 24px; display: flex; align-items: center; gap: 10px; }
56
+ h2 { font-size: 18px; font-weight: 600; color: #1d1d1f; margin: 28px 0 16px; }
57
+ .card {
58
+ background: #fff; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.06);
59
+ padding: 20px 24px; margin-bottom: 16px;
60
+ }
61
+ .badge {
62
+ display: inline-flex; align-items: center; gap: 4px;
63
+ padding: 2px 10px; border-radius: 100px; font-size: 13px; font-weight: 600;
64
+ }
65
+ .badge-pass { background: #e6f7e6; color: #1a7d1a; }
66
+ .badge-fail { background: #fce8e8; color: #c41d1d; }
67
+ table {
68
+ width: 100%; border-collapse: collapse; font-size: 14px;
69
+ }
70
+ th {
71
+ text-align: left; padding: 10px 12px; font-weight: 600; color: #6b7280;
72
+ border-bottom: 1px solid #e5e7eb; font-size: 13px; text-transform: uppercase; letter-spacing: .03em;
73
+ }
74
+ td { padding: 10px 12px; border-bottom: 1px solid #f3f4f6; }
75
+ tr:last-child td { border-bottom: none; }
76
+ tr:hover td { background: #f9fafb; }
77
+ a { color: #2563eb; text-decoration: none; }
78
+ a:hover { text-decoration: underline; }
79
+ .stat-group { display: flex; gap: 32px; }
80
+ .stat-item { text-align: center; }
81
+ .stat-num { font-size: 28px; font-weight: 700; line-height: 1.2; }
82
+ .stat-label { font-size: 13px; color: #9ca3af; margin-top: 2px; }
83
+ #trend-chart { width: 100%; height: 240px; }
84
+ .filter-bar {
85
+ display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
86
+ }
87
+ .filter-bar select, .filter-bar input {
88
+ padding: 6px 12px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 14px;
89
+ background: #fff; outline: none;
90
+ }
91
+ .filter-bar select:focus, .filter-bar input:focus { border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37,99,235,.15); }
92
+ .row-hidden { display: none !important; }
93
+ .back-link { display: inline-flex; align-items: center; gap: 4px; color: #6b7280; font-size: 14px; margin-top: 24px; }
94
+ .back-link:hover { color: #2563eb; }
95
+ .step-table td:first-child { color: #9ca3af; font-size: 13px; }
96
+ .detail-row { cursor: pointer; }
97
+ .detail-row td:last-child { text-align: right; color: #2563eb; font-size: 13px; }
98
+ .detail-expanded { background: #f9fafb; }
99
+ .detail-expanded td { padding: 0; }
100
+ .detail-expanded pre {
101
+ margin: 0; padding: 12px 16px; font-size: 13px; line-height: 1.6;
102
+ background: #1d1d1f; color: #e5e7eb; border-radius: 0 0 8px 8px; overflow-x: auto;
103
+ max-height: 300px;
104
+ }
105
+ .screenshot-link { display: inline-block; margin-top: 8px; }
106
+ .screenshot-link img { max-width: 100%; border-radius: 8px; border: 1px solid #e5e7eb; }
107
+ `;
108
+
109
+ function renderStat(num, label, cls = "") {
110
+ return `<div class="stat-item"><div class="stat-num ${cls}">${num}</div><div class="stat-label">${label}</div></div>`;
111
+ }
112
+
113
+ /** 收集 runs 中去重的字段值,生成 <option> HTML */
114
+ function collectOpts(runs, field) {
115
+ const set = new Set();
116
+ for (const r of runs) {
117
+ const v = r[field];
118
+ if (v && v !== "-") set.add(v);
119
+ }
120
+ return Array.from(set).sort().map(v => `<option value="${v.replace(/"/g, "&quot;")}">${v}</option>`).join("\n");
121
+ }
122
+
123
+ /**
124
+ * 重新生成入口 index.html(读取所有 runs/ 重建)
125
+ * @param {string} reportsDir
126
+ */
127
+ export function initIndexHtml(reportsDir) {
128
+ const indexPath = path.join(reportsDir, "index.html");
129
+ const runsDir = path.join(reportsDir, "runs");
130
+
131
+ let allRuns = [];
132
+ if (fs.existsSync(runsDir)) {
133
+ allRuns = fs.readdirSync(runsDir, { withFileTypes: true })
134
+ .filter(d => d.isDirectory())
135
+ .map(d => {
136
+ const resultPath = path.join(runsDir, d.name, "result.json");
137
+ try {
138
+ const raw = JSON.parse(fs.readFileSync(resultPath, "utf-8"));
139
+ return normalizeRunResult(raw);
140
+ }
141
+ catch { return null; }
142
+ })
143
+ .filter(Boolean)
144
+ .sort((a, b) => (b.startedAt ?? "").localeCompare(a.startedAt ?? ""))
145
+ .slice(0, 20);
146
+ }
147
+
148
+ const passed = allRuns.filter(r => (r.stats?.failed ?? 0) === 0).length;
149
+ const failed = allRuns.filter(r => (r.stats?.failed ?? 0) > 0).length;
150
+ const rows = allRuns.map(r => renderRunCard(r)).join("\n");
151
+
152
+ const html = `<!DOCTYPE html>
153
+ <html lang="zh-CN">
154
+ <head>
155
+ <meta charset="UTF-8">
156
+ <meta name="viewport" content="width=device-width, initial-scale=1">
157
+ <title>szcd local-browser-test 报告</title>
158
+ <script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
159
+ <style>${STYLE}</style>
160
+ </head>
161
+ <body>
162
+ <div class="page">
163
+ <h1>
164
+ <span style="background:linear-gradient(135deg,#2563eb,#7c3aed);width:32px;height:32px;border-radius:8px;display:inline-flex;align-items:center;justify-content:center;color:#fff;font-size:18px">🧪</span>
165
+ 测试报告
166
+ </h1>
167
+
168
+ <div class="card">
169
+ <h2>趋势</h2>
170
+ <div id="trend-chart"></div>
171
+ </div>
172
+
173
+ <div class="card">
174
+ <div class="stat-group">
175
+ ${renderStat(allRuns.length, "总运行")}
176
+ ${renderStat(passed, "通过", "pass")}
177
+ ${renderStat(failed, "失败", "fail")}
178
+ </div>
179
+ </div>
180
+
181
+ <div class="card">
182
+ <div class="filter-bar">
183
+ <select id="filter-status"><option value="">所有状态</option><option value="pass">通过</option><option value="fail">失败</option></select>
184
+ <select id="filter-tag"><option value="">所有 tag</option>${collectOpts(allRuns, 'tag')}</select>
185
+ <select id="filter-project"><option value="">所有 project</option>${collectOpts(allRuns, 'project')}</select>
186
+ <select id="filter-branch"><option value="">所有 branch</option>${collectOpts(allRuns, 'branch')}</select>
187
+ <input id="filter-text" type="search" placeholder="搜索计划名…">
188
+ <span id="filter-count" style="color:#9ca3af;font-size:14px;margin-left:auto"></span>
189
+ </div>
190
+ </div>
191
+
192
+ <div class="card" style="padding:0 24px">
193
+ <table>
194
+ <thead><tr>
195
+ <th style="width:160px">时间</th>
196
+ <th>计划</th>
197
+ <th style="width:100px">结果</th>
198
+ <th style="width:80px">耗时</th>
199
+ <th style="width:60px"></th>
200
+ </tr></thead>
201
+ <tbody id="runs-tbody">
202
+ ${rows}
203
+ </tbody>
204
+ </table>
205
+ </div>
206
+
207
+ <div style="margin-top:24px;font-size:12px;color:#9ca3af;text-align:center">
208
+ 保留最近 30 次 / 7 天 · 失败永久保留
209
+ </div>
210
+ </div>
211
+
212
+ <script>
213
+ const trendData = ${JSON.stringify(allRuns.map(r => ({
214
+ date: (r.startedAt ?? "").slice(0, 10),
215
+ passed: r.stats?.passed ?? 0,
216
+ failed: r.stats?.failed ?? 0,
217
+ total: r.stats?.total ?? 0,
218
+ })).reverse())};
219
+
220
+ function renderTrend() {
221
+ if (typeof echarts === "undefined") return;
222
+ const byDate = {};
223
+ for (const d of trendData) {
224
+ if (!d.date) continue;
225
+ if (!byDate[d.date]) byDate[d.date] = { passed: 0, failed: 0 };
226
+ byDate[d.date].passed += d.passed;
227
+ byDate[d.date].failed += d.failed;
228
+ }
229
+ const dates = Object.keys(byDate).sort().slice(-7);
230
+ const chart = echarts.init(document.getElementById("trend-chart"));
231
+ chart.setOption({
232
+ tooltip: { trigger: "axis", axisPointer: { type: "shadow" } },
233
+ grid: { left: "3%", right: "4%", bottom: "3%", top: "5%", containLabel: true },
234
+ xAxis: { type: "category", data: dates, axisLabel: { fontSize: 12 } },
235
+ yAxis: { type: "value", min: 0, splitLine: { lineStyle: { color: "#f3f4f6" } } },
236
+ series: [
237
+ { name: "通过", type: "bar", stack: "total", color: "#22c55e", barWidth: "40%",
238
+ itemStyle: { borderRadius: [4, 4, 0, 0] },
239
+ data: dates.map(d => byDate[d].passed) },
240
+ { name: "失败", type: "bar", stack: "total", color: "#ef4444",
241
+ itemStyle: { borderRadius: [4, 4, 0, 0] },
242
+ data: dates.map(d => byDate[d].failed) }
243
+ ]
244
+ });
245
+ window.addEventListener("resize", () => chart.resize());
246
+ }
247
+ renderTrend();
248
+
249
+ function doFilter() {
250
+ const status = document.getElementById("filter-status").value;
251
+ const tag = document.getElementById("filter-tag").value;
252
+ const project = document.getElementById("filter-project").value;
253
+ const branch = document.getElementById("filter-branch").value;
254
+ const text = document.getElementById("filter-text").value.toLowerCase();
255
+ let visible = 0, totalPassed = 0, totalFailed = 0;
256
+ document.querySelectorAll("#runs-tbody tr").forEach(tr => {
257
+ const s = tr.dataset.status || "";
258
+ const desc = (tr.dataset.desc || "").toLowerCase();
259
+ const show =
260
+ (!status || s === status) &&
261
+ (!tag || tr.dataset.tag === tag) &&
262
+ (!project || tr.dataset.project === project) &&
263
+ (!branch || tr.dataset.branch === branch) &&
264
+ (!text || desc.includes(text));
265
+ tr.classList.toggle("row-hidden", !show);
266
+ if (show) {
267
+ visible++;
268
+ if (s === "pass") totalPassed++;
269
+ else if (s === "fail") totalFailed++;
270
+ }
271
+ });
272
+ document.getElementById("filter-count").textContent = visible + " / " + document.querySelectorAll("#runs-tbody tr").length;
273
+ }
274
+ document.getElementById("filter-status").addEventListener("change", doFilter);
275
+ document.getElementById("filter-tag").addEventListener("change", doFilter);
276
+ document.getElementById("filter-project").addEventListener("change", doFilter);
277
+ document.getElementById("filter-branch").addEventListener("change", doFilter);
278
+ document.getElementById("filter-text").addEventListener("input", doFilter);
279
+ doFilter();
280
+ </script>
281
+ </body>
282
+ </html>`;
283
+ fs.writeFileSync(indexPath, html);
284
+ }
285
+
286
+ /**
287
+ * 在入口 index.html 表格里追加一行(按时间倒序:最新在最上面)
288
+ * @param {object} result
289
+ * @param {string} indexPath
290
+ */
291
+ export function appendRunCardToIndex(_result, indexPath) {
292
+ // 重建整个入口(从 runs/ 读取所有历史)
293
+ initIndexHtml(path.dirname(indexPath));
294
+ }
295
+
296
+ function renderRunCard(result) {
297
+ const s = result.stats ?? {};
298
+ const failed = (s.failed ?? 0) > 0;
299
+ const cls = failed ? "fail" : "pass";
300
+ const badgeCls = failed ? "badge-fail" : "badge-pass";
301
+ const badgeText = failed ? "失败" : "通过";
302
+ const time = (result.startedAt ?? "").slice(0, 19).replace("T", " ");
303
+ const dur = ((result.durationMs ?? 0) / 1000).toFixed(1);
304
+ const desc = result.description || result.planId || "ui";
305
+ const esc = (v) => (v ?? "").replace(/"/g, "&quot;");
306
+ return ` <tr data-status="${cls}" data-desc="${esc(desc + " " + (result.planId ?? ""))}"
307
+ data-tag="${esc(result.tag)}" data-project="${esc(result.project)}" data-branch="${esc(result.branch)}">
308
+ <td style="white-space:nowrap;color:#6b7280">${time || "-"}</td>
309
+ <td style="font-weight:500">${desc}</td>
310
+ <td><span class="badge ${badgeCls}">${badgeText} ${s.passed ?? 0}/${s.total ?? 0}</span></td>
311
+ <td style="color:#6b7280">${dur}s</td>
312
+ <td><a href="runs/${result.runId}/index.html" style="font-size:13px">详情 →</a></td>
313
+ </tr>`;
314
+ }
315
+
316
+ /**
317
+ * 生成单 run 的 HTML 报告
318
+ * @param {object} result
319
+ * @param {string} runDir
320
+ */
321
+ export function generateRunHtml(result, runDir) {
322
+ fs.mkdirSync(runDir, { recursive: true });
323
+ const html = renderRunDetail(result);
324
+ fs.writeFileSync(path.join(runDir, "index.html"), html);
325
+
326
+ // 复制截图(含序号防同名覆盖)
327
+ if (Array.isArray(result.cases)) {
328
+ const shotsDir = path.join(runDir, "screenshots");
329
+ for (let i = 0; i < result.cases.length; i++) {
330
+ const c = result.cases[i];
331
+ if (c.screenshot && fs.existsSync(c.screenshot)) {
332
+ fs.mkdirSync(shotsDir, { recursive: true });
333
+ const base = (c.name ?? "unknown").replace(/[^a-z0-9_-]/gi, "-");
334
+ const safeName = `${String(i + 1).padStart(2, "0")}-${base}`;
335
+ fs.copyFileSync(c.screenshot, path.join(shotsDir, `${safeName}.png`));
336
+ }
337
+ }
338
+ }
339
+ }
340
+
341
+ function renderRunDetail(result) {
342
+ const cases = result.cases ?? [];
343
+ const s = result.stats ?? {};
344
+ const failedCases = cases.filter((c) => c.status === "failed");
345
+ const title = `Run${result.planId ? " " + result.planId : ""} @ ${(result.startedAt ?? "").slice(0, 19).replace("T", " ")}`;
346
+ return `<!DOCTYPE html>
347
+ <html lang="zh-CN">
348
+ <head>
349
+ <meta charset="UTF-8">
350
+ <meta name="viewport" content="width=device-width, initial-scale=1">
351
+ <title>${title}</title>
352
+ <style>${STYLE}
353
+ .detail-expanded pre { display: none; }
354
+ .detail-expanded.open pre { display: block; }
355
+ .detail-expanded td { padding: 0; }
356
+ .detail-expanded .expander { padding: 10px 12px; display: flex; justify-content: space-between; align-items: center; cursor: pointer; }
357
+ .detail-expanded .expander:hover { background: #f3f4f6; }
358
+ .detail-expanded .arrow { transition: transform .2s; font-size: 12px; color: #9ca3af; }
359
+ .detail-expanded.open .arrow { transform: rotate(90deg); }
360
+ </style>
361
+ </head>
362
+ <body>
363
+ <div class="page">
364
+ <a href="../../index.html" class="back-link">← 返回列表</a>
365
+
366
+ <h1 style="margin-top:16px">${result.description || result.planId || "运行详情"}</h1>
367
+
368
+ <p style="color:#6b7280;font-size:14px;margin-bottom:20px">
369
+ ${result.runId ? `<code style="background:#f3f4f6;padding:2px 6px;border-radius:4px;font-size:12px">${result.runId}</code>` : ""}
370
+ ${result.startedAt ? ` &middot; ${result.startedAt.slice(0, 19).replace("T", " ")}` : ""}
371
+ ${result.mode ? ` &middot; ${result.mode}` : ""}
372
+ ${result.browserInfo?.currentUrl ? ` &middot; <a href="${result.browserInfo.currentUrl}" target="_blank">${result.browserInfo.currentUrl}</a>` : ""}
373
+ </p>
374
+
375
+ <div class="card">
376
+ <div class="stat-group">
377
+ ${renderStat(s.total ?? 0, "总步骤")}
378
+ ${renderStat(s.passed ?? 0, "通过", "pass")}
379
+ ${renderStat(s.failed ?? 0, "失败", "fail")}
380
+ ${renderStat(((result.durationMs ?? 0) / 1000).toFixed(1) + "s", "耗时")}
381
+ </div>
382
+ </div>
383
+
384
+ <div class="card" style="padding:0 24px">
385
+ <table class="step-table">
386
+ <thead><tr>
387
+ <th style="width:40px">#</th>
388
+ <th>步骤</th>
389
+ <th style="width:100px">状态</th>
390
+ <th style="width:80px">耗时</th>
391
+ <th style="width:auto">结果</th>
392
+ </tr></thead>
393
+ <tbody>
394
+ ${cases.map((c, i) => {
395
+ const isFailed = c.status === "failed";
396
+ const badgeCls = isFailed ? "badge-fail" : "badge-pass";
397
+ const badgeText = isFailed ? "✗ 失败" : "✓ 通过";
398
+ const hasDetail = c.error || c.screenshot;
399
+ return `<tr class="${hasDetail ? "detail-row" : ""}" onclick="${hasDetail ? `this.nextElementSibling.classList.toggle('open')` : ""}">
400
+ <td>${i + 1}</td>
401
+ <td style="font-weight:500">${c.name}</td>
402
+ <td><span class="badge ${badgeCls}">${badgeText}</span></td>
403
+ <td style="color:#6b7280">${c.durationMs ?? c.duration ?? 0}ms</td>
404
+ <td style="color:#6b7280;font-size:13px;max-width:350px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${c.result ?? ""}</td>
405
+ </tr>
406
+ ${hasDetail ? `<tr class="detail-expanded${isFailed ? " open" : ""}">
407
+ <td colspan="5">
408
+ <div class="expander" onclick="this.parentElement.classList.toggle('open');event.stopPropagation()">
409
+ <span style="font-weight:500;font-size:13px">${isFailed ? "❌ 错误详情" : "📎 详情"}</span>
410
+ <span class="arrow">▶</span>
411
+ </div>
412
+ <pre>${(c.error || "").slice(0, 3000)}</pre>
413
+ ${c.screenshot ? (() => { const base = (c.name ?? "unknown").replace(/[^a-z0-9_-]/gi, "-"); const sn = `${String(i + 1).padStart(2, "0")}-${base}`; return `<div style="padding:0 16px 12px"><a href="screenshots/${sn}.png" target="_blank" class="screenshot-link"><img src="screenshots/${sn}.png" alt="screenshot"></a></div>`; })() : ""}
414
+ </td>
415
+ </tr>` : ""}`;
416
+ }).join("\n")}
417
+ </tbody>
418
+ </table>
419
+ </div>
420
+
421
+ <p class="back-link" style="margin-top:32px"><a href="../../index.html">← 返回列表</a></p>
422
+ </div>
423
+ </body>
424
+ </html>`;
425
+ }
@@ -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
+ }