covgate 0.3.0 → 0.3.2
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/README.md +6 -2
- package/docs/setup.md +5 -3
- package/docs/ui-result.png +0 -0
- package/docs/ui-viewer.png +0 -0
- package/package.json +1 -1
- package/src/setup/index.js +55 -12
- package/src/ui/page.html +152 -11
- package/src/ui/server.js +75 -10
package/README.md
CHANGED
|
@@ -30,6 +30,8 @@ npx --no-install covgate ui --root D:/Projects/my-app --port 4319 --no-open
|
|
|
30
30
|
- 从项目 `package.json` 脚本中**下拉选择测试命令**,不需要手敲;
|
|
31
31
|
- 单独指定客户端/服务端命令,自动生成临时配置并运行;
|
|
32
32
|
- 工具内嵌覆盖率报告:左侧文件列表,中间源码,绿色已覆盖、白色未覆盖;
|
|
33
|
+
- 运行前检查测试文件;没有测试时禁用运行,并提供可复制的 **AI 补测任务**,避免生成空报告;
|
|
34
|
+
- 测试命令失败或没有生成报告时,直接显示日志和原因,不展示空结果页;
|
|
33
35
|
- 不覆盖项目已有的 `covgate.config.json`,运行结果仍写入 `.covgate/`。
|
|
34
36
|
|
|
35
37
|
`covgate ui` 只监听本机回环地址,适合本地使用;CI 和 Agent 仍用 `covgate run`。
|
|
@@ -78,7 +80,7 @@ covgate run
|
|
|
78
80
|
|
|
79
81
|
### 从 npm registry 安装(推荐)
|
|
80
82
|
|
|
81
|
-
包已发布到 npm:[covgate](https://www.npmjs.com/package/covgate),当前 latest 为 **0.3.
|
|
83
|
+
包已发布到 npm:[covgate](https://www.npmjs.com/package/covgate),当前 latest 为 **0.3.2**。
|
|
82
84
|
|
|
83
85
|
```bash
|
|
84
86
|
npm install --save-dev covgate
|
|
@@ -130,6 +132,8 @@ setup 会:
|
|
|
130
132
|
|
|
131
133
|
**CovGate 不生成测试,也不补测试。** 行为变更的测试必须由项目自己补;没有测试时 `test:coverage` 会直接失败,不会生成空覆盖率来蒙混过关。
|
|
132
134
|
|
|
135
|
+
如果 setup 找不到覆盖率命令,不会静默跳过:会打印具体的 `test:coverage` 建议、`--test-command` 用法和 GUI 命令。已有 `covgate.config.json` 会保留,并在输出中说明现有 scopes。
|
|
136
|
+
|
|
133
137
|
`postinstall` 也会尝试同样的流程,但 **npm 11.19+ 默认不执行依赖安装脚本**。如果安装时没有弹出询问,直接运行 `npx covgate setup` 即可;详细说明见 [安装与自动配置](docs/setup.md)。
|
|
134
138
|
|
|
135
139
|
### 在 CovGate 仓库中开发
|
|
@@ -507,7 +511,7 @@ macOS 用 `open .covgate/report.html`,Linux 用 `xdg-open .covgate/report.html
|
|
|
507
511
|
文本输出示例:
|
|
508
512
|
|
|
509
513
|
```text
|
|
510
|
-
CovGate · 覆盖率门禁 v0.3.
|
|
514
|
+
CovGate · 覆盖率门禁 v0.3.2
|
|
511
515
|
模式 执行测试并检查 · 配置 covgate.config.json · 用时 12.4s
|
|
512
516
|
|
|
513
517
|
✗ 客户端 [client/client] 未通过
|
package/docs/setup.md
CHANGED
|
@@ -9,7 +9,7 @@ npm install --save-dev covgate
|
|
|
9
9
|
npx --no-install covgate setup
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
包页面:[covgate](https://www.npmjs.com/package/covgate)。当前 latest 是 **0.3.
|
|
12
|
+
包页面:[covgate](https://www.npmjs.com/package/covgate)。当前 latest 是 **0.3.2**。
|
|
13
13
|
|
|
14
14
|
> `0.0.0-stage` 是 npm 暂存发布产生的占位版本,不会被 `npm install covgate` 选中,可以忽略。
|
|
15
15
|
|
|
@@ -38,7 +38,7 @@ npm 11.19+ 默认不执行依赖的 `postinstall`,会显示类似:
|
|
|
38
38
|
|
|
39
39
|
```text
|
|
40
40
|
npm warn install-scripts 1 package has install scripts not yet covered by allowScripts
|
|
41
|
-
npm warn install-scripts covgate@0.3.
|
|
41
|
+
npm warn install-scripts covgate@0.3.2 (postinstall: node bin/covgate-postinstall.js)
|
|
42
42
|
```
|
|
43
43
|
|
|
44
44
|
因此:
|
|
@@ -82,7 +82,9 @@ npm warn install-scripts covgate@0.3.0 (postinstall: node bin/covgate-postinst
|
|
|
82
82
|
- `.github/workflows/covgate.yml`(如果存在 `.github/`)
|
|
83
83
|
- `.gitignore` 加入 `.covgate/`、`coverage/`
|
|
84
84
|
- 若已有 `CLAUDE.md`、`.cursor`、`.github/copilot-instructions.md`,同步写入对应说明。
|
|
85
|
-
6.
|
|
85
|
+
6. 输出下一步命令,包括可视化工作台:`npx --no-install covgate ui`。
|
|
86
|
+
|
|
87
|
+
> 找不到覆盖率测试命令时,setup 不会静默跳过:会给出 `test:coverage` 建议、`--test-command` 示例和 GUI 命令。如果项目已有 `covgate.config.json`,会保留并列出其中 scopes,不会因为缺少通用命令而覆盖或误报。
|
|
86
88
|
|
|
87
89
|
> **CovGate 不生成测试,也不补测试。** setup 只配置测试框架和门禁;本次行为变更的测试必须由项目自己补。没有测试时 `test:coverage` 会直接失败(已移除 `--passWithNoTests`),不会产生可蒙混过关的空覆盖率。
|
|
88
90
|
|
package/docs/ui-result.png
CHANGED
|
Binary file
|
package/docs/ui-viewer.png
CHANGED
|
Binary file
|
package/package.json
CHANGED
package/src/setup/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import {
|
|
5
5
|
SetupError,
|
|
@@ -20,6 +20,33 @@ import { scaffoldAgent } from "./agent.js";
|
|
|
20
20
|
import { askYesNo, canPrompt } from "./prompt.js";
|
|
21
21
|
|
|
22
22
|
const DEFAULT_COVERAGE_PATH = "coverage/coverage-final.json";
|
|
23
|
+
const UI_COMMAND = "npx --no-install covgate ui";
|
|
24
|
+
|
|
25
|
+
async function existingScopeNames(configPath) {
|
|
26
|
+
try {
|
|
27
|
+
const data = JSON.parse(await readFile(configPath, "utf8"));
|
|
28
|
+
return Object.keys(data.scopes || {});
|
|
29
|
+
} catch {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function printConfigGuidance(log, framework) {
|
|
35
|
+
log("[covgate] 未生成 covgate.config.json:缺少可用的覆盖率测试命令。");
|
|
36
|
+
if (framework.id === "vitest") {
|
|
37
|
+
log('[covgate] 在 package.json 添加 "test:coverage": "vitest run --coverage --coverage.reporter=json --coverage.reporter=lcov",然后重新运行 setup。');
|
|
38
|
+
} else if (framework.id === "jest") {
|
|
39
|
+
log('[covgate] 在 package.json 添加 "test:coverage": "jest --coverage --coverageReporters=json --coverageReporters=lcov",然后重新运行 setup。');
|
|
40
|
+
} else if (framework.id === "node-test") {
|
|
41
|
+
log("[covgate] 检测到 Node.js 内置 test runner。安装 c8 后添加覆盖率命令:");
|
|
42
|
+
log(' "test:coverage": "c8 --reporter=json --reporter=lcov node --test"');
|
|
43
|
+
} else {
|
|
44
|
+
log("[covgate] 先安装/配置测试框架,并在 package.json 添加 test:coverage。");
|
|
45
|
+
}
|
|
46
|
+
log('[covgate] 也可以直接指定命令:npx --no-install covgate setup --test-command "<覆盖率命令>"');
|
|
47
|
+
log("[covgate] 然后运行 npx --no-install covgate validate 检查配置。");
|
|
48
|
+
log("[covgate] 可视化配置工作台:" + UI_COMMAND);
|
|
49
|
+
}
|
|
23
50
|
|
|
24
51
|
/**
|
|
25
52
|
* @param {string} root
|
|
@@ -134,12 +161,22 @@ export async function runSetup(options = {}) {
|
|
|
134
161
|
}
|
|
135
162
|
const coveragePath = normalizeGlob(root, options.coverage || DEFAULT_COVERAGE_PATH);
|
|
136
163
|
const configPath = path.join(root, "covgate.config.json");
|
|
164
|
+
const configExists = existsSync(configPath);
|
|
137
165
|
let configWritten = false;
|
|
166
|
+
let configPreserved = false;
|
|
138
167
|
|
|
139
|
-
if (options.noConfig !== true
|
|
140
|
-
if (
|
|
141
|
-
|
|
142
|
-
|
|
168
|
+
if (options.noConfig !== true) {
|
|
169
|
+
if (configExists && options.force !== true) {
|
|
170
|
+
configPreserved = true;
|
|
171
|
+
const names = await existingScopeNames(configPath);
|
|
172
|
+
log(
|
|
173
|
+
"[covgate] " +
|
|
174
|
+
displayPath(root, configPath) +
|
|
175
|
+
" 已存在" +
|
|
176
|
+
(names.length ? "(scopes: " + names.join(", ") + ")" : "") +
|
|
177
|
+
",保留现有配置,不因缺少通用覆盖率命令而覆盖或跳过。"
|
|
178
|
+
);
|
|
179
|
+
} else if (testCommand && scopeSpecs.length > 0) {
|
|
143
180
|
const config = await buildStarterConfig({
|
|
144
181
|
root,
|
|
145
182
|
scopes: scopeSpecs,
|
|
@@ -163,13 +200,16 @@ export async function runSetup(options = {}) {
|
|
|
163
200
|
if (framework.id && !coverageScriptFor(framework.id) && !options.testCommand) {
|
|
164
201
|
log("[covgate] 注意:该测试框架的覆盖率命令需要你确认报告路径。");
|
|
165
202
|
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
203
|
+
} else if (configExists) {
|
|
204
|
+
configPreserved = true;
|
|
205
|
+
log("[covgate] " + displayPath(root, configPath) + " 无法重新生成,保留现有配置。");
|
|
206
|
+
if (!testCommand) {
|
|
207
|
+
log("[covgate] 现有配置仍可使用;如需重新生成,请先补覆盖率命令。");
|
|
208
|
+
}
|
|
209
|
+
} else if (!testCommand) {
|
|
210
|
+
printConfigGuidance(log, framework);
|
|
211
|
+
} else if (scopeSpecs.length === 0) {
|
|
212
|
+
log("[covgate] 未找到源码目录,未生成 covgate.config.json;请用 --source 指定。");
|
|
173
213
|
}
|
|
174
214
|
}
|
|
175
215
|
|
|
@@ -195,6 +235,8 @@ export async function runSetup(options = {}) {
|
|
|
195
235
|
testCommand: testCommand || null,
|
|
196
236
|
coveragePath,
|
|
197
237
|
configPath: configWritten ? configPath : existsSync(configPath) ? configPath : null,
|
|
238
|
+
configPreserved,
|
|
239
|
+
uiCommand: UI_COMMAND,
|
|
198
240
|
packageChanged,
|
|
199
241
|
packageScripts: scriptChanges,
|
|
200
242
|
agentChanges,
|
|
@@ -219,6 +261,7 @@ export async function runSetup(options = {}) {
|
|
|
219
261
|
);
|
|
220
262
|
}
|
|
221
263
|
log("[covgate] 运行门禁: " + (summary.dryRun ? "npx covgate run" : "npx --no-install covgate run"));
|
|
264
|
+
log("[covgate] 可视化工作台: " + UI_COMMAND);
|
|
222
265
|
log("[covgate] 注意:CovGate 不会生成或补写测试;项目必须为行为变更补真实测试后才能通过门禁。");
|
|
223
266
|
if (!options.noAgent) {
|
|
224
267
|
log("[covgate] AI 接入: AGENTS.md 与 scripts/covgate-agent-gate.*");
|
package/src/ui/page.html
CHANGED
|
@@ -68,6 +68,18 @@
|
|
|
68
68
|
padding: 18px;
|
|
69
69
|
}
|
|
70
70
|
.panel-intro { margin: 0 0 16px; color: var(--muted); font-size: 12px; line-height: 1.7; }
|
|
71
|
+
.health {
|
|
72
|
+
background: #edf6ff; border: 1px solid #cfe3f8; border-radius: 10px;
|
|
73
|
+
padding: 10px 12px; margin-bottom: 12px; display: grid; gap: 6px; font-size: 11px;
|
|
74
|
+
}
|
|
75
|
+
.health-row { display: flex; justify-content: space-between; gap: 10px; color: #4d6b86; }
|
|
76
|
+
.health-row b { color: #17324a; font-weight: 700; text-align: right; }
|
|
77
|
+
.health-row b.bad { color: var(--red); }
|
|
78
|
+
.health-row b.good { color: var(--green); }
|
|
79
|
+
.health-warn {
|
|
80
|
+
border: 1px solid #f1d58a; background: #fff8e3; color: #735700;
|
|
81
|
+
border-radius: 9px; padding: 9px 10px; margin: 0 0 12px; font-size: 11px; line-height: 1.55;
|
|
82
|
+
}
|
|
71
83
|
.card {
|
|
72
84
|
background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
|
|
73
85
|
padding: 14px; margin-bottom: 12px; box-shadow: 0 2px 5px rgba(24,44,66,.03);
|
|
@@ -110,6 +122,14 @@
|
|
|
110
122
|
.empty-mark { width: 64px; height: 64px; margin: 0 auto 18px; border-radius: 18px; background: #dcecfb; color: #1473e6; display: grid; place-items: center; font-size: 26px; }
|
|
111
123
|
.empty h1 { margin: 0 0 8px; font-size: 22px; letter-spacing: -.3px; }
|
|
112
124
|
.empty p { color: var(--muted); font-size: 13px; line-height: 1.8; margin: 0; }
|
|
125
|
+
.empty-actions { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; margin-top: 16px; }
|
|
126
|
+
.secondary-btn {
|
|
127
|
+
height: 38px; border: 1px solid #b9cde2; border-radius: 8px;
|
|
128
|
+
background: #fff; color: #17324a; padding: 0 14px; font-size: 12px;
|
|
129
|
+
}
|
|
130
|
+
.secondary-btn:hover { border-color: var(--accent); color: var(--accent); }
|
|
131
|
+
.text-btn { border: 0; background: transparent; color: var(--muted); font-size: 11px; padding: 6px; text-decoration: underline; }
|
|
132
|
+
.text-btn:hover { color: var(--accent); }
|
|
113
133
|
.run-view { padding: 22px; overflow: auto; flex: 1; }
|
|
114
134
|
.run-card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 18px; box-shadow: var(--shadow); }
|
|
115
135
|
.run-head { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
|
|
@@ -143,6 +163,12 @@
|
|
|
143
163
|
.report-frame-wrap { flex: 1; min-height: 0; background: #fff; }
|
|
144
164
|
.report-frame { width: 100%; height: 100%; border: 0; display: block; }
|
|
145
165
|
.error-box { background: var(--red-bg); border: 1px solid #f5c2c5; color: #8d1f24; border-radius: 9px; padding: 12px; font-size: 12px; line-height: 1.7; margin-bottom: 12px; white-space: pre-wrap; }
|
|
166
|
+
.error-box .hint-line { margin-top: 7px; color: #6d3a3d; font-size: 11px; }
|
|
167
|
+
.toast {
|
|
168
|
+
position: fixed; left: 50%; bottom: 26px; transform: translateX(-50%);
|
|
169
|
+
background: #17324a; color: #fff; border-radius: 8px; padding: 9px 14px;
|
|
170
|
+
font-size: 12px; box-shadow: var(--shadow); z-index: 20;
|
|
171
|
+
}
|
|
146
172
|
[hidden] { display: none !important; }
|
|
147
173
|
@media (max-width: 980px) {
|
|
148
174
|
.layout { grid-template-columns: 1fr; height: auto; }
|
|
@@ -172,6 +198,15 @@
|
|
|
172
198
|
<aside class="panel">
|
|
173
199
|
<p class="panel-intro">选择客户端、服务端的源码路径和测试命令。运行时会使用临时配置,不会覆盖项目里的 <code>covgate.config.json</code>。</p>
|
|
174
200
|
|
|
201
|
+
<div id="healthCard" class="health" hidden>
|
|
202
|
+
<div class="health-row"><span>测试框架</span><b id="healthFramework">—</b></div>
|
|
203
|
+
<div class="health-row"><span>测试文件</span><b id="healthTests">—</b></div>
|
|
204
|
+
<div class="health-row"><span>覆盖率报告</span><b id="healthCoverage">—</b></div>
|
|
205
|
+
</div>
|
|
206
|
+
<div id="healthWarn" class="health-warn" hidden>
|
|
207
|
+
没有检测到测试文件。CovGate 不会生成测试,请先让项目/AI 补真实测试。
|
|
208
|
+
</div>
|
|
209
|
+
|
|
175
210
|
<section class="card">
|
|
176
211
|
<div class="card-head">
|
|
177
212
|
<div class="card-title"><span class="dot"></span>客户端</div>
|
|
@@ -214,7 +249,7 @@
|
|
|
214
249
|
<div id="configChip" class="config-chip temp">使用临时配置</div>
|
|
215
250
|
</section>
|
|
216
251
|
|
|
217
|
-
<button id="runBtn" class="run-button" type="button">运行覆盖率</button>
|
|
252
|
+
<button id="runBtn" class="run-button" type="button" disabled>运行覆盖率</button>
|
|
218
253
|
<div id="panelStatus" class="panel-status"></div>
|
|
219
254
|
|
|
220
255
|
<datalist id="sourceDirs"></datalist>
|
|
@@ -223,9 +258,13 @@
|
|
|
223
258
|
|
|
224
259
|
<section class="workspace">
|
|
225
260
|
<div id="emptyView" class="empty">
|
|
226
|
-
<div class="empty-mark">⌁</div>
|
|
227
|
-
<h1>把覆盖率跑起来</h1>
|
|
228
|
-
<p>在左侧选择源码路径和测试命令,点击“运行覆盖率”。<br>完成后会在这里显示文件列表和逐行覆盖情况。</p>
|
|
261
|
+
<div id="emptyMark" class="empty-mark">⌁</div>
|
|
262
|
+
<h1 id="emptyTitle">把覆盖率跑起来</h1>
|
|
263
|
+
<p id="emptyText">在左侧选择源码路径和测试命令,点击“运行覆盖率”。<br>完成后会在这里显示文件列表和逐行覆盖情况。</p>
|
|
264
|
+
<div id="emptyActions" class="empty-actions" hidden>
|
|
265
|
+
<button id="copyPromptBtn" class="secondary-btn" type="button">复制 AI 补测任务</button>
|
|
266
|
+
<button id="allowRunBtn" class="text-btn" type="button">仍然运行一次</button>
|
|
267
|
+
</div>
|
|
229
268
|
</div>
|
|
230
269
|
|
|
231
270
|
<div id="runView" class="run-view" hidden>
|
|
@@ -256,6 +295,7 @@
|
|
|
256
295
|
var state = null;
|
|
257
296
|
var activeJob = null;
|
|
258
297
|
var pollTimer = null;
|
|
298
|
+
var allowNoTests = false;
|
|
259
299
|
|
|
260
300
|
function text(value) { return value === null || value === undefined ? "" : String(value); }
|
|
261
301
|
function setStatus(message, kind) {
|
|
@@ -264,8 +304,77 @@
|
|
|
264
304
|
el.className = "panel-status" + (kind ? " " + kind : "");
|
|
265
305
|
}
|
|
266
306
|
function setRunning(running) {
|
|
267
|
-
|
|
307
|
+
var noTests = Boolean(state && state.testCount === 0 && !allowNoTests);
|
|
308
|
+
$("runBtn").disabled = running || noTests;
|
|
268
309
|
$("detectBtn").disabled = running;
|
|
310
|
+
$("runBtn").title = noTests ? "没有测试文件:先补测试,或使用“仍然运行一次”" : "";
|
|
311
|
+
}
|
|
312
|
+
function toast(message) {
|
|
313
|
+
var el = document.createElement("div");
|
|
314
|
+
el.className = "toast";
|
|
315
|
+
el.textContent = message;
|
|
316
|
+
document.body.appendChild(el);
|
|
317
|
+
setTimeout(function () { el.remove(); }, 1800);
|
|
318
|
+
}
|
|
319
|
+
function buildPrompt() {
|
|
320
|
+
var root = $("root").value.trim() || (state && state.root) || "";
|
|
321
|
+
var client = $("clientPath").value.trim() || "(未指定)";
|
|
322
|
+
var server = $("serverPath").value.trim() || "(未指定)";
|
|
323
|
+
var clientCommand = $("clientCommand").value || "(未选择)";
|
|
324
|
+
var serverCommand = $("serverCommand").value || "(未选择)";
|
|
325
|
+
var coverage = $("coveragePath").value.trim() || "(未指定)";
|
|
326
|
+
var framework = state && state.frameworkLabel ? state.frameworkLabel : "项目现有测试框架";
|
|
327
|
+
return [
|
|
328
|
+
"你正在为以下项目补真实测试,目标是让 CovGate 真正通过。",
|
|
329
|
+
"",
|
|
330
|
+
"项目根目录:" + root,
|
|
331
|
+
"测试框架:" + framework,
|
|
332
|
+
"客户端源码:" + client + "(目标 100%)",
|
|
333
|
+
"服务端源码:" + server + "(目标 95%)",
|
|
334
|
+
"客户端测试命令:" + clientCommand,
|
|
335
|
+
"服务端测试命令:" + serverCommand,
|
|
336
|
+
"覆盖率报告:" + coverage,
|
|
337
|
+
"",
|
|
338
|
+
"当前测试文件数量:" + ((state && state.testCount) || 0) + "。请先建立/完善测试基础设施,再补真实测试。",
|
|
339
|
+
"",
|
|
340
|
+
"硬性规则:",
|
|
341
|
+
"- 不得使用 ignore、伪造覆盖率、空报告或降低阈值;客户端 100%、服务端 95%。",
|
|
342
|
+
"- 不得整体排除客户端 UI、主进程或核心模块;只允许对第三方生成物或确实不可达的薄启动层提出窄范围 exclude/waiver,并写 reason/owner/expires,等待人工批准。",
|
|
343
|
+
"- 先补测试,再考虑配置;不要通过缩小 sources.include 或扩大 exclude 让门禁通过。",
|
|
344
|
+
"- 每轮至少完成一个可验证的测试里程碑;每轮运行 npx covgate run --json,退出码 0 才算完成。",
|
|
345
|
+
"- 最终通过后再提交/合并;WIP 请提交到独立分支并明确标注 coverage WIP。",
|
|
346
|
+
"",
|
|
347
|
+
"请从测试基础设施和第一批核心模块测试开始,直接修改代码并运行测试,不要只给计划。"
|
|
348
|
+
].join(String.fromCharCode(10));
|
|
349
|
+
}
|
|
350
|
+
function copyText(value) {
|
|
351
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
352
|
+
return navigator.clipboard.writeText(value);
|
|
353
|
+
}
|
|
354
|
+
var area = document.createElement("textarea");
|
|
355
|
+
area.value = value;
|
|
356
|
+
document.body.appendChild(area);
|
|
357
|
+
area.select();
|
|
358
|
+
document.execCommand("copy");
|
|
359
|
+
area.remove();
|
|
360
|
+
return Promise.resolve();
|
|
361
|
+
}
|
|
362
|
+
function updateHealth(data) {
|
|
363
|
+
var hasTests = (data.testCount || 0) > 0;
|
|
364
|
+
$("healthCard").hidden = false;
|
|
365
|
+
$("healthFramework").textContent = data.frameworkLabel || "未检测到";
|
|
366
|
+
$("healthFramework").className = data.framework ? "good" : "bad";
|
|
367
|
+
$("healthTests").textContent = hasTests ? data.testCount + " 个" : "0 个 · 需要补测试";
|
|
368
|
+
$("healthTests").className = hasTests ? "good" : "bad";
|
|
369
|
+
$("healthCoverage").textContent = data.coveragePath || "未指定";
|
|
370
|
+
$("healthWarn").hidden = hasTests;
|
|
371
|
+
$("emptyMark").textContent = hasTests ? "⌁" : "!";
|
|
372
|
+
$("emptyTitle").textContent = hasTests ? "把覆盖率跑起来" : "项目还没有测试";
|
|
373
|
+
$("emptyText").innerHTML = hasTests
|
|
374
|
+
? "在左侧选择源码路径和测试命令,点击“运行覆盖率”。<br>完成后会在这里显示文件列表和逐行覆盖情况。"
|
|
375
|
+
: "CovGate 不会生成测试。先让项目或编码代理补真实测试,再运行覆盖率。<br>可以复制下面的补测任务作为 AI 的输入。";
|
|
376
|
+
$("emptyActions").hidden = hasTests;
|
|
377
|
+
setRunning(false);
|
|
269
378
|
}
|
|
270
379
|
function showView(name) {
|
|
271
380
|
$("emptyView").hidden = name !== "empty";
|
|
@@ -309,6 +418,7 @@
|
|
|
309
418
|
}
|
|
310
419
|
function applyState(data) {
|
|
311
420
|
state = data;
|
|
421
|
+
allowNoTests = false;
|
|
312
422
|
$("root").value = data.root;
|
|
313
423
|
$("clientPath").value = data.clientPath || "";
|
|
314
424
|
$("serverPath").value = data.serverPath || "";
|
|
@@ -322,6 +432,7 @@
|
|
|
322
432
|
var chip = $("configChip");
|
|
323
433
|
chip.textContent = data.existingConfig ? "已加载现有 covgate.config.json" : "使用临时配置";
|
|
324
434
|
chip.className = "config-chip" + (data.existingConfig ? "" : " temp");
|
|
435
|
+
updateHealth(data);
|
|
325
436
|
setStatus(data.name ? "已检测:" + data.name + " · " + (data.frameworkLabel || "未检测到测试框架") : "已检测项目");
|
|
326
437
|
}
|
|
327
438
|
async function api(url, options) {
|
|
@@ -384,20 +495,28 @@
|
|
|
384
495
|
activeJob = null;
|
|
385
496
|
pollTimer = null;
|
|
386
497
|
$("runView").hidden = true;
|
|
387
|
-
|
|
498
|
+
var report = job.report;
|
|
499
|
+
var emptyReport = !report || report.status === "error" || (report.scopes || []).length === 0;
|
|
500
|
+
if (job.status === "failed" || emptyReport) {
|
|
388
501
|
showView("run");
|
|
389
502
|
$("runTitle").textContent = "运行失败";
|
|
390
|
-
$("runMeta").textContent = job.
|
|
503
|
+
$("runMeta").textContent = job.exitCode === null ? "" : "退出码 " + job.exitCode;
|
|
504
|
+
var message = job.error;
|
|
505
|
+
if (!message && report && report.issues && report.issues.length) message = report.issues[0].message;
|
|
506
|
+
if (!message) message = "没有生成可用的覆盖率报告。";
|
|
507
|
+
if (/No test files found|没有测试文件/i.test(job.logs || "")) {
|
|
508
|
+
message += "\n\n检测到测试命令没有测试文件可执行。请先补真实测试,再重新运行。";
|
|
509
|
+
}
|
|
391
510
|
$("runError").hidden = false;
|
|
392
|
-
$("runError").textContent =
|
|
393
|
-
$("logs").textContent = job.logs || "";
|
|
511
|
+
$("runError").textContent = message;
|
|
512
|
+
$("logs").textContent = job.logs || "测试命令没有输出。";
|
|
394
513
|
setStatus("运行失败", "error");
|
|
395
514
|
return;
|
|
396
515
|
}
|
|
397
516
|
try {
|
|
398
517
|
renderSummary(job);
|
|
399
518
|
showView("result");
|
|
400
|
-
setStatus("覆盖率完成:" + (
|
|
519
|
+
setStatus("覆盖率完成:" + (report.status === "pass" ? "通过" : "未通过"), report.status === "pass" ? "ok" : "error");
|
|
401
520
|
} catch (error) {
|
|
402
521
|
showView("run");
|
|
403
522
|
$("runTitle").textContent = "结果解析失败";
|
|
@@ -427,6 +546,11 @@
|
|
|
427
546
|
}
|
|
428
547
|
async function run() {
|
|
429
548
|
if (activeJob) return;
|
|
549
|
+
if (state && state.testCount === 0 && !allowNoTests) {
|
|
550
|
+
showView("empty");
|
|
551
|
+
setStatus("项目没有测试文件。先补测试,或选择“仍然运行一次”。", "error");
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
430
554
|
var clientPath = $("clientPath").value.trim();
|
|
431
555
|
var serverPath = $("serverPath").value.trim();
|
|
432
556
|
if (!clientPath && !serverPath) {
|
|
@@ -454,7 +578,8 @@
|
|
|
454
578
|
serverPath: serverPath,
|
|
455
579
|
clientCommand: clientCommand,
|
|
456
580
|
serverCommand: serverCommand,
|
|
457
|
-
coveragePath: $("coveragePath").value.trim()
|
|
581
|
+
coveragePath: $("coveragePath").value.trim(),
|
|
582
|
+
allowNoTests: allowNoTests
|
|
458
583
|
})
|
|
459
584
|
});
|
|
460
585
|
activeJob = job.jobId;
|
|
@@ -463,12 +588,28 @@
|
|
|
463
588
|
setRunning(false);
|
|
464
589
|
setStatus(error.message, "error");
|
|
465
590
|
showView("empty");
|
|
591
|
+
if (/没有检测到测试文件/.test(error.message)) {
|
|
592
|
+
allowNoTests = false;
|
|
593
|
+
if (state) updateHealth(state);
|
|
594
|
+
}
|
|
466
595
|
}
|
|
467
596
|
}
|
|
468
597
|
|
|
469
598
|
$("detectBtn").addEventListener("click", detect);
|
|
470
599
|
$("root").addEventListener("keydown", function (event) { if (event.key === "Enter") detect(); });
|
|
471
600
|
$("runBtn").addEventListener("click", run);
|
|
601
|
+
$("copyPromptBtn").addEventListener("click", function () {
|
|
602
|
+
copyText(buildPrompt()).then(function () {
|
|
603
|
+
toast("补测任务已复制");
|
|
604
|
+
}).catch(function () {
|
|
605
|
+
toast("复制失败,请手动选择文本");
|
|
606
|
+
});
|
|
607
|
+
});
|
|
608
|
+
$("allowRunBtn").addEventListener("click", function () {
|
|
609
|
+
allowNoTests = true;
|
|
610
|
+
updateHealth(state);
|
|
611
|
+
run();
|
|
612
|
+
});
|
|
472
613
|
detect();
|
|
473
614
|
})();
|
|
474
615
|
</script>
|
package/src/ui/server.js
CHANGED
|
@@ -5,6 +5,7 @@ import { readFile, stat, unlink, writeFile } from "node:fs/promises";
|
|
|
5
5
|
import http from "node:http";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { expandFiles, matchGlob } from "../glob.js";
|
|
8
9
|
import { detectFramework, detectPackageManager } from "../setup/project.js";
|
|
9
10
|
|
|
10
11
|
const BIN = fileURLToPath(new URL("../../bin/covgate.js", import.meta.url));
|
|
@@ -25,12 +26,49 @@ const SOURCE_DIR_CANDIDATES = [
|
|
|
25
26
|
"web",
|
|
26
27
|
"lib"
|
|
27
28
|
];
|
|
29
|
+
const TEST_PATTERNS = [
|
|
30
|
+
"**/*.{test,spec}.{js,jsx,ts,tsx,mjs,cjs}",
|
|
31
|
+
"tests/**/*.{js,jsx,ts,tsx,mjs,cjs}",
|
|
32
|
+
"test/**/*.{js,jsx,ts,tsx,mjs,cjs}",
|
|
33
|
+
"__tests__/**/*.{js,jsx,ts,tsx,mjs,cjs}"
|
|
34
|
+
];
|
|
35
|
+
const TEST_EXCLUDES = [
|
|
36
|
+
{ pattern: "**/*.test.*", reason: "测试文件,不是产品运行时代码" },
|
|
37
|
+
{ pattern: "**/*.spec.*", reason: "测试文件,不是产品运行时代码" },
|
|
38
|
+
{ pattern: "**/__tests__/**", reason: "测试目录,不是产品运行时代码" }
|
|
39
|
+
];
|
|
28
40
|
const MAX_BODY = 1024 * 1024;
|
|
29
41
|
|
|
30
42
|
class HttpError extends Error {
|
|
31
|
-
constructor(status, message) {
|
|
43
|
+
constructor(status, message, code) {
|
|
32
44
|
super(message);
|
|
33
45
|
this.status = status;
|
|
46
|
+
this.code = code || null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function pickTestExcludes(root, sourcePath) {
|
|
51
|
+
let files = [];
|
|
52
|
+
try {
|
|
53
|
+
files = await expandFiles([sourceGlob(sourcePath)], root);
|
|
54
|
+
} catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
const relative = files.map((file) => toPosix(path.relative(root, file)));
|
|
58
|
+
return TEST_EXCLUDES.filter((entry) =>
|
|
59
|
+
relative.some((file) => matchGlob(file, entry.pattern))
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function findTestFiles(root) {
|
|
64
|
+
try {
|
|
65
|
+
const files = await expandFiles(TEST_PATTERNS, root);
|
|
66
|
+
return files
|
|
67
|
+
.map((file) => toPosix(path.relative(root, file)))
|
|
68
|
+
.filter((file) => !file.startsWith("../"))
|
|
69
|
+
.sort((left, right) => left.localeCompare(right, "en"));
|
|
70
|
+
} catch {
|
|
71
|
+
return [];
|
|
34
72
|
}
|
|
35
73
|
}
|
|
36
74
|
|
|
@@ -143,10 +181,16 @@ async function inspectProject(root) {
|
|
|
143
181
|
const configuredCoverage = scopes.client?.coverage?.paths?.[0] || scopes.server?.coverage?.paths?.[0];
|
|
144
182
|
const coveragePath = configuredCoverage || coverageFiles[0] || COVERAGE_CANDIDATES[0];
|
|
145
183
|
|
|
146
|
-
const
|
|
147
|
-
const
|
|
184
|
+
const pickExisting = (names) => names.find((name) => existsSync(path.join(projectRoot, name))) || "";
|
|
185
|
+
const clientPath =
|
|
186
|
+
existingPath(scopes, "client") ||
|
|
187
|
+
pickExisting(["src", "web", "client", "app", "renderer"]);
|
|
188
|
+
const serverPath =
|
|
189
|
+
existingPath(scopes, "server") ||
|
|
190
|
+
pickExisting(["server", "desktop", "api"]);
|
|
148
191
|
const clientCommand = scopes.client?.commands?.[0]?.run || defaultCommand;
|
|
149
192
|
const serverCommand = scopes.server?.commands?.[0]?.run || defaultCommand;
|
|
193
|
+
const testFiles = await findTestFiles(projectRoot);
|
|
150
194
|
|
|
151
195
|
return {
|
|
152
196
|
root: projectRoot,
|
|
@@ -162,20 +206,31 @@ async function inspectProject(root) {
|
|
|
162
206
|
serverPath,
|
|
163
207
|
clientCommand,
|
|
164
208
|
serverCommand,
|
|
209
|
+
testCount: testFiles.length,
|
|
210
|
+
testFiles: testFiles.slice(0, 50),
|
|
165
211
|
existingConfig: Boolean(existing),
|
|
166
212
|
reportsDir: existing?.reports?.dir || ".covgate"
|
|
167
213
|
};
|
|
168
214
|
}
|
|
169
215
|
|
|
170
|
-
function buildConfig(state, input) {
|
|
216
|
+
async function buildConfig(state, input) {
|
|
171
217
|
const clientPath = relativeInput(state.root, input.clientPath);
|
|
172
218
|
const serverPath = relativeInput(state.root, input.serverPath);
|
|
173
219
|
const coveragePath = relativeInput(state.root, input.coveragePath || state.coveragePath);
|
|
174
220
|
const existing = state.existing || {};
|
|
175
221
|
const scopes = {};
|
|
176
|
-
const makeScope = (name, profile, label, sourcePath, command) => {
|
|
222
|
+
const makeScope = async (name, profile, label, sourcePath, command) => {
|
|
177
223
|
if (!sourcePath) return;
|
|
178
224
|
const old = existing.scopes?.[name] || {};
|
|
225
|
+
const oldExclude = Array.isArray(old.exclude) ? old.exclude : [];
|
|
226
|
+
const testExclude = await pickTestExcludes(state.root, sourcePath);
|
|
227
|
+
const merged = [...oldExclude, ...testExclude];
|
|
228
|
+
const seen = new Set();
|
|
229
|
+
const exclude = merged.filter((entry) => {
|
|
230
|
+
if (!entry || seen.has(entry.pattern)) return false;
|
|
231
|
+
seen.add(entry.pattern);
|
|
232
|
+
return true;
|
|
233
|
+
});
|
|
179
234
|
scopes[name] = {
|
|
180
235
|
profile,
|
|
181
236
|
label,
|
|
@@ -189,11 +244,11 @@ function buildConfig(state, input) {
|
|
|
189
244
|
],
|
|
190
245
|
coverage: { format: "auto", paths: [coveragePath] },
|
|
191
246
|
sources: { include: [sourceGlob(sourcePath)] },
|
|
192
|
-
exclude
|
|
247
|
+
exclude
|
|
193
248
|
};
|
|
194
249
|
};
|
|
195
|
-
makeScope("client", "client", "客户端", clientPath, input.clientCommand || state.clientCommand);
|
|
196
|
-
makeScope("server", "server", "服务端", serverPath, input.serverCommand || state.serverCommand);
|
|
250
|
+
await makeScope("client", "client", "客户端", clientPath, input.clientCommand || state.clientCommand);
|
|
251
|
+
await makeScope("server", "server", "服务端", serverPath, input.serverCommand || state.serverCommand);
|
|
197
252
|
if (Object.keys(scopes).length === 0) {
|
|
198
253
|
throw new HttpError(400, "至少需要指定客户端或服务端源码路径。");
|
|
199
254
|
}
|
|
@@ -294,10 +349,17 @@ export async function startUiServer(options = {}) {
|
|
|
294
349
|
const body = await readBody(req);
|
|
295
350
|
const root = path.resolve(body.root || defaultRoot);
|
|
296
351
|
const state = await inspectProject(root);
|
|
352
|
+
if (state.testCount === 0 && body.allowNoTests !== true) {
|
|
353
|
+
throw new HttpError(
|
|
354
|
+
422,
|
|
355
|
+
"项目中没有检测到测试文件。请先让项目补真实测试,再运行覆盖率;CovGate 不会生成测试。",
|
|
356
|
+
"no-tests"
|
|
357
|
+
);
|
|
358
|
+
}
|
|
297
359
|
const existing = (await readJson(path.join(root, "covgate.config.json"))) || null;
|
|
298
360
|
state.existing = existing || {};
|
|
299
361
|
state.defaultCommand = state.commands[0]?.run || "";
|
|
300
|
-
const config = buildConfig(state, body);
|
|
362
|
+
const config = await buildConfig(state, body);
|
|
301
363
|
const id = randomUUID();
|
|
302
364
|
const configPath = path.join(root, ".covgate-ui-" + id + ".json");
|
|
303
365
|
await writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
@@ -387,7 +449,10 @@ export async function startUiServer(options = {}) {
|
|
|
387
449
|
throw new HttpError(404, "Not found");
|
|
388
450
|
} catch (error) {
|
|
389
451
|
const status = error instanceof HttpError ? error.status : 500;
|
|
390
|
-
send(res, status, {
|
|
452
|
+
send(res, status, {
|
|
453
|
+
error: error.message || String(error),
|
|
454
|
+
...(error instanceof HttpError && error.code ? { code: error.code } : {})
|
|
455
|
+
});
|
|
391
456
|
}
|
|
392
457
|
});
|
|
393
458
|
|