covgate 0.2.3 → 0.3.1

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 CHANGED
@@ -2,7 +2,39 @@
2
2
 
3
3
  > 面向 AI 编码工作流的覆盖率门禁:**客户端必须 100%,服务端至少 95%;无法覆盖的代码必须写明原因才能豁免。**
4
4
 
5
- CovGate 是一个零依赖的 Node.js CLI。它不产生覆盖率,而是把测试、覆盖率报告、源码范围、豁免说明和阈值合成一道**可机器执行、可审计、可进 CI 的门禁**。
5
+ CovGate 是一个零依赖的 Node.js 工具。它不产生覆盖率,而是把测试、覆盖率报告、源码范围、豁免说明和阈值合成一道**可机器执行、可审计、可进 CI 的门禁**;同时提供本地可视化工作台和 HTML 覆盖率查看页。
6
+
7
+ ## 可视化工作台
8
+
9
+ 在项目根目录运行:
10
+
11
+ ```bash
12
+ npx --no-install covgate ui
13
+ ```
14
+
15
+ 默认打开 `http://127.0.0.1:4318`,也可以指定项目、端口或关闭自动打开浏览器:
16
+
17
+ ```bash
18
+ npx --no-install covgate ui --root D:/Projects/my-app --port 4319 --no-open
19
+ ```
20
+
21
+ ![CovGate 可视化工作台](docs/ui-viewer.png)
22
+
23
+ 运行后在工具内直接查看报告:
24
+
25
+ ![CovGate 工作台内嵌覆盖率报告](docs/ui-result.png)
26
+
27
+ 工作台支持:
28
+
29
+ - 指定客户端、服务端源码路径,路径有自动检测的建议列表;
30
+ - 从项目 `package.json` 脚本中**下拉选择测试命令**,不需要手敲;
31
+ - 单独指定客户端/服务端命令,自动生成临时配置并运行;
32
+ - 工具内嵌覆盖率报告:左侧文件列表,中间源码,绿色已覆盖、白色未覆盖;
33
+ - 运行前检查测试文件;没有测试时禁用运行,并提供可复制的 **AI 补测任务**,避免生成空报告;
34
+ - 测试命令失败或没有生成报告时,直接显示日志和原因,不展示空结果页;
35
+ - 不覆盖项目已有的 `covgate.config.json`,运行结果仍写入 `.covgate/`。
36
+
37
+ `covgate ui` 只监听本机回环地址,适合本地使用;CI 和 Agent 仍用 `covgate run`。
6
38
 
7
39
  ## 为什么需要它
8
40
 
@@ -48,7 +80,7 @@ covgate run
48
80
 
49
81
  ### 从 npm registry 安装(推荐)
50
82
 
51
- 包已发布到 npm:[covgate](https://www.npmjs.com/package/covgate),当前 latest 为 **0.2.3**。
83
+ 包已发布到 npm:[covgate](https://www.npmjs.com/package/covgate),当前 latest 为 **0.3.1**。
52
84
 
53
85
  ```bash
54
86
  npm install --save-dev covgate
@@ -245,6 +277,10 @@ npx --no-install covgate validate
245
277
  | `covgate run` | 执行测试命令,然后检查覆盖率。默认命令。 |
246
278
  | `covgate check` | 不执行测试,只检查已有报告。 |
247
279
  | `covgate validate` | 只校验配置和排除说明。 |
280
+ | `covgate ui` | 启动本地可视化覆盖率工作台。 |
281
+ | `--root <dir>` | `ui` 的项目根目录,默认当前目录。 |
282
+ | `--port <port>` | `ui` 的本地端口,默认 4318。 |
283
+ | `--no-open` | `ui` 启动后不自动打开浏览器。 |
248
284
  | `--config <path>` | 指定配置文件。 |
249
285
  | `--scope <name>` | 只检查指定 scope,可重复或逗号分隔。 |
250
286
  | `--json` | stdout 只输出 JSON;过程日志走 stderr。 |
@@ -473,7 +509,7 @@ macOS 用 `open .covgate/report.html`,Linux 用 `xdg-open .covgate/report.html
473
509
  文本输出示例:
474
510
 
475
511
  ```text
476
- CovGate · 覆盖率门禁 v0.2.3
512
+ CovGate · 覆盖率门禁 v0.3.1
477
513
  模式 执行测试并检查 · 配置 covgate.config.json · 用时 12.4s
478
514
 
479
515
  ✗ 客户端 [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.2.3**。
12
+ 包页面:[covgate](https://www.npmjs.com/package/covgate)。当前 latest 是 **0.3.1**。
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.2.3 (postinstall: node bin/covgate-postinstall.js)
41
+ npm warn install-scripts covgate@0.3.1 (postinstall: node bin/covgate-postinstall.js)
42
42
  ```
43
43
 
44
44
  因此:
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "covgate",
3
- "version": "0.2.3",
3
+ "version": "0.3.1",
4
4
  "description": "覆盖率门禁:客户端 100%,服务端至少 95%,安装时自动检测/配置测试框架与 AI 编码代理接入。",
5
5
  "type": "module",
6
6
  "bin": {
package/src/args.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { ConfigError } from "./errors.js";
2
2
 
3
- const COMMANDS = new Set(["run", "check", "validate", "setup"]);
3
+ const COMMANDS = new Set(["run", "check", "validate", "setup", "ui"]);
4
4
  const NL = String.fromCharCode(10);
5
5
 
6
6
  export const HELP_TEXT = [
@@ -11,6 +11,7 @@ export const HELP_TEXT = [
11
11
  " covgate run [选项] 执行测试并检查覆盖率(默认命令)",
12
12
  " covgate check [选项] 不执行测试,只检查已有覆盖率报告",
13
13
  " covgate validate [选项] 只校验配置",
14
+ " covgate ui [--root <dir>] [--port <port>] 启动可视化覆盖率工作台",
14
15
  "",
15
16
  "通用选项:",
16
17
  " --config <path> 配置文件路径(默认 ./covgate.config.json)",
@@ -38,6 +39,11 @@ export const HELP_TEXT = [
38
39
  " --force 覆盖已有配置和 setup 生成的文件",
39
40
  " --dry-run 只打印计划,不写文件、不安装依赖",
40
41
  "",
42
+ "ui 选项:",
43
+ " --root <dir> 项目根目录,默认当前目录",
44
+ " --port <port> 本地端口,默认 4318",
45
+ " --no-open 不自动打开浏览器",
46
+ "",
41
47
  "硬性门槛(配置不可低于):",
42
48
  " profile=client 行/语句/函数/分支覆盖率必须 100%",
43
49
  " profile=server 行/语句/函数/分支覆盖率必须至少 95%",
@@ -78,7 +84,10 @@ export function parseArgs(argv) {
78
84
  profile: undefined,
79
85
  source: undefined,
80
86
  coverage: undefined,
81
- testCommand: undefined
87
+ testCommand: undefined,
88
+ root: undefined,
89
+ port: undefined,
90
+ noOpen: false
82
91
  };
83
92
 
84
93
  let index = 0;
@@ -92,7 +101,7 @@ export function parseArgs(argv) {
92
101
  explicitCommand = true;
93
102
  index = 1;
94
103
  } else if (argv[0] && !argv[0].startsWith("-")) {
95
- throw new ConfigError('未知命令 "' + argv[0] + '",可用命令:setup、run、check、validate');
104
+ throw new ConfigError('未知命令 "' + argv[0] + '",可用命令:setup、run、check、validate、ui');
96
105
  }
97
106
 
98
107
  for (; index < argv.length; index += 1) {
@@ -173,6 +182,15 @@ export function parseArgs(argv) {
173
182
  options.coverage = value();
174
183
  } else if (key === "--test-command") {
175
184
  options.testCommand = value();
185
+ } else if (key === "--root") {
186
+ options.root = value();
187
+ } else if (key === "--port") {
188
+ options.port = Number(value());
189
+ if (!Number.isInteger(options.port) || options.port < 0 || options.port > 65535) {
190
+ throw new ConfigError("--port 必须是 0 到 65535 之间的整数");
191
+ }
192
+ } else if (key === "--no-open") {
193
+ options.noOpen = true;
176
194
  } else if (key === "--help" || key === "-h") {
177
195
  options.help = true;
178
196
  } else if (key === "--version") {
@@ -186,6 +204,15 @@ export function parseArgs(argv) {
186
204
  if (options.command !== "setup" && (options.yes || options.profile || options.source || options.coverage || options.testCommand || options.noAgent || options.noConfig || options.force || options.dryRun || !options.installFramework)) {
187
205
  throw new ConfigError("setup 相关选项只能和 covgate setup 一起使用");
188
206
  }
207
+ if (options.command === "ui") {
208
+ if (options.json || options.configPath || options.scopes.length > 0 || options.noRun || options.noFiles || options.reportDir || options.changed || options.base || options.verbose || options.color !== undefined) {
209
+ throw new ConfigError("covgate ui 只支持 --root、--port、--no-open 和 --help");
210
+ }
211
+ return options;
212
+ }
213
+ if (options.root || options.port !== undefined || options.noOpen) {
214
+ throw new ConfigError("--root、--port、--no-open 只能和 covgate ui 一起使用");
215
+ }
189
216
  if (options.base && !options.changed) {
190
217
  throw new ConfigError("--base 必须和 --changed 一起使用");
191
218
  }
package/src/cli.js CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  import { printCommandFailure, runScopeCommands } from "./runner.js";
25
25
  import { evaluateScope } from "./scope.js";
26
26
  import { runSetup } from "./setup/index.js";
27
+ import { openBrowser, startUiServer } from "./ui/server.js";
27
28
  import { validateReason } from "./reason.js";
28
29
  import {
29
30
  formatThreshold,
@@ -124,6 +125,28 @@ async function runSetupCommand(options) {
124
125
  }
125
126
  }
126
127
 
128
+ /**
129
+ * @param {any} options
130
+ */
131
+ async function runUiCommand(options) {
132
+ const root = path.resolve(options.root || process.cwd());
133
+ try {
134
+ const ui = await startUiServer({ root, port: options.port ?? 4318 });
135
+ process.stdout.write("CovGate UI: " + ui.url + String.fromCharCode(10));
136
+ process.stdout.write("按 Ctrl+C 停止。" + String.fromCharCode(10));
137
+ if (!options.noOpen) openBrowser(ui.url);
138
+ const shutdown = async () => {
139
+ await ui.close().catch(() => {});
140
+ process.exit(0);
141
+ };
142
+ process.once("SIGINT", shutdown);
143
+ process.once("SIGTERM", shutdown);
144
+ return 0;
145
+ } catch (error) {
146
+ return printEarlyError(error, false, options.verbose);
147
+ }
148
+ }
149
+
127
150
  /**
128
151
  * @param {any} config
129
152
  * @param {any[]} scopes
@@ -487,6 +510,9 @@ export async function main(argv) {
487
510
  if (options.command === "setup") {
488
511
  return runSetupCommand(options);
489
512
  }
513
+ if (options.command === "ui") {
514
+ return runUiCommand(options);
515
+ }
490
516
 
491
517
  let config;
492
518
  let scopes;
@@ -0,0 +1,617 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>CovGate 可视化覆盖率工具</title>
7
+ <style>
8
+ :root {
9
+ --bg: #eef3f7;
10
+ --panel: #ffffff;
11
+ --ink: #17212b;
12
+ --muted: #687583;
13
+ --line: #dfe6ec;
14
+ --accent: #1473e6;
15
+ --green: #0f7b44;
16
+ --green-bg: #e6f7ed;
17
+ --red: #c53138;
18
+ --red-bg: #fdeced;
19
+ --amber: #8a6200;
20
+ --amber-bg: #fff6d9;
21
+ --shadow: 0 12px 30px rgba(24, 44, 66, 0.08);
22
+ }
23
+ * { box-sizing: border-box; }
24
+ html, body { margin: 0; height: 100%; }
25
+ body {
26
+ font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
27
+ background: var(--bg);
28
+ color: var(--ink);
29
+ }
30
+ button, input, select { font: inherit; }
31
+ button { cursor: pointer; }
32
+ .topbar {
33
+ height: 64px;
34
+ background: #10253a;
35
+ color: #fff;
36
+ display: flex;
37
+ align-items: center;
38
+ gap: 18px;
39
+ padding: 0 22px;
40
+ position: relative;
41
+ z-index: 2;
42
+ }
43
+ .brand { display: flex; align-items: center; gap: 10px; min-width: 210px; }
44
+ .logo {
45
+ width: 30px; height: 30px; border-radius: 8px;
46
+ background: linear-gradient(135deg, #46d39a, #1473e6);
47
+ display: grid; place-items: center; font-weight: 800; font-size: 13px;
48
+ }
49
+ .brand strong { font-size: 15px; letter-spacing: .2px; }
50
+ .brand span { color: #a9bdce; font-size: 11px; margin-left: 1px; }
51
+ .project-box { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }
52
+ .project-box label { color: #9db2c4; font-size: 12px; white-space: nowrap; }
53
+ .project-box input {
54
+ min-width: 0; flex: 1; max-width: 660px; height: 34px;
55
+ border: 1px solid #35506a; border-radius: 7px;
56
+ background: #19344d; color: #eaf3fb; padding: 0 10px; outline: none;
57
+ font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 12px;
58
+ }
59
+ .project-box input:focus { border-color: #5aa6ff; box-shadow: 0 0 0 3px #1473e633; }
60
+ .topbar button {
61
+ height: 34px; border: 1px solid #4a6580; border-radius: 7px;
62
+ background: #24445f; color: #fff; padding: 0 12px; font-size: 12px;
63
+ }
64
+ .topbar button:hover { background: #2d5475; }
65
+ .layout { display: grid; grid-template-columns: 390px minmax(0, 1fr); height: calc(100vh - 64px); }
66
+ .panel {
67
+ overflow: auto; background: #f8fafc; border-right: 1px solid var(--line);
68
+ padding: 18px;
69
+ }
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
+ }
83
+ .card {
84
+ background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
85
+ padding: 14px; margin-bottom: 12px; box-shadow: 0 2px 5px rgba(24,44,66,.03);
86
+ }
87
+ .card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 12px; }
88
+ .card-title { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 13px; }
89
+ .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
90
+ .dot.server { background: #8c67d8; }
91
+ .hint { color: var(--muted); font-size: 11px; }
92
+ .field { display: flex; flex-direction: column; gap: 5px; margin-bottom: 10px; }
93
+ .field:last-child { margin-bottom: 0; }
94
+ .field label { font-size: 11px; color: var(--muted); }
95
+ .field input, .field select {
96
+ width: 100%; height: 35px; border: 1px solid var(--line); border-radius: 7px;
97
+ background: #fff; color: var(--ink); padding: 0 9px; outline: none; font-size: 12px;
98
+ }
99
+ .field input:focus, .field select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px #1473e61a; }
100
+ .field input { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
101
+ .path-grid { display: grid; grid-template-columns: 1fr 94px; gap: 7px; }
102
+ .config-chip {
103
+ display: inline-flex; align-items: center; gap: 5px; border-radius: 99px;
104
+ background: #eef5ff; color: #28639f; padding: 4px 8px; font-size: 10px;
105
+ }
106
+ .config-chip.temp { background: var(--amber-bg); color: var(--amber); }
107
+ .run-button {
108
+ width: 100%; height: 46px; border: 0; border-radius: 10px;
109
+ background: linear-gradient(135deg, #1376e8, #0f5dc0); color: #fff;
110
+ font-weight: 700; font-size: 14px; margin-top: 4px;
111
+ box-shadow: 0 8px 18px #1473e62e;
112
+ }
113
+ .run-button:hover { filter: brightness(1.05); }
114
+ .run-button:disabled { opacity: .55; cursor: not-allowed; box-shadow: none; }
115
+ .panel-status { min-height: 18px; margin-top: 10px; font-size: 11px; color: var(--muted); line-height: 1.6; }
116
+ .panel-status.error { color: var(--red); }
117
+ .panel-status.ok { color: var(--green); }
118
+ .workspace { overflow: hidden; display: flex; flex-direction: column; min-width: 0; }
119
+ .empty {
120
+ margin: auto; max-width: 540px; text-align: center; padding: 32px;
121
+ }
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; }
123
+ .empty h1 { margin: 0 0 8px; font-size: 22px; letter-spacing: -.3px; }
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); }
133
+ .run-view { padding: 22px; overflow: auto; flex: 1; }
134
+ .run-card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 18px; box-shadow: var(--shadow); }
135
+ .run-head { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
136
+ .spinner { width: 18px; height: 18px; border: 2px solid #cfe1f5; border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; }
137
+ @keyframes spin { to { transform: rotate(360deg); } }
138
+ .run-head strong { font-size: 14px; }
139
+ .run-head span { color: var(--muted); font-size: 12px; }
140
+ .logs {
141
+ margin: 0; background: #0e1d2c; color: #bfe0f5; border-radius: 9px;
142
+ padding: 14px; min-height: 160px; max-height: 280px; overflow: auto;
143
+ font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px; line-height: 1.6;
144
+ white-space: pre-wrap; word-break: break-word;
145
+ }
146
+ .result-view { display: flex; flex-direction: column; min-height: 0; flex: 1; }
147
+ .result-head { background: var(--panel); border-bottom: 1px solid var(--line); padding: 16px 20px; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
148
+ .result-head h2 { margin: 0; font-size: 15px; flex: 1; min-width: 150px; }
149
+ .status-pill { border-radius: 99px; padding: 5px 10px; font-size: 11px; font-weight: 700; }
150
+ .status-pill.pass { background: var(--green-bg); color: var(--green); }
151
+ .status-pill.fail { background: var(--red-bg); color: var(--red); }
152
+ .status-pill.error { background: var(--amber-bg); color: var(--amber); }
153
+ .head-link { color: var(--accent); text-decoration: none; font-size: 12px; }
154
+ .head-link:hover { text-decoration: underline; }
155
+ .scope-strip { display: flex; gap: 10px; flex-wrap: wrap; padding: 14px 20px; background: #f8fafc; border-bottom: 1px solid var(--line); }
156
+ .scope-card { background: #fff; border: 1px solid var(--line); border-radius: 9px; padding: 9px 12px; min-width: 180px; }
157
+ .scope-card .name { display: flex; justify-content: space-between; gap: 12px; font-size: 11px; color: var(--muted); }
158
+ .scope-card .name b { color: var(--ink); font-size: 12px; }
159
+ .scope-card .value { margin-top: 5px; font-size: 18px; font-weight: 800; font-variant-numeric: tabular-nums; }
160
+ .scope-card .value.good { color: var(--green); }
161
+ .scope-card .value.bad { color: var(--red); }
162
+ .scope-card .sub { font-size: 10px; color: var(--muted); margin-top: 2px; }
163
+ .report-frame-wrap { flex: 1; min-height: 0; background: #fff; }
164
+ .report-frame { width: 100%; height: 100%; border: 0; display: block; }
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
+ }
172
+ [hidden] { display: none !important; }
173
+ @media (max-width: 980px) {
174
+ .layout { grid-template-columns: 1fr; height: auto; }
175
+ .panel { border-right: 0; border-bottom: 1px solid var(--line); max-height: none; }
176
+ .workspace { min-height: 70vh; }
177
+ .topbar { height: auto; min-height: 64px; flex-wrap: wrap; padding: 10px 14px; gap: 10px; }
178
+ .brand { min-width: 0; }
179
+ .project-box { order: 3; flex-basis: 100%; }
180
+ .project-box input { max-width: none; }
181
+ }
182
+ </style>
183
+ </head>
184
+ <body>
185
+ <header class="topbar">
186
+ <div class="brand">
187
+ <div class="logo">CG</div>
188
+ <div><strong>CovGate</strong> <span>覆盖率工作台</span></div>
189
+ </div>
190
+ <div class="project-box">
191
+ <label for="root">项目</label>
192
+ <input id="root" spellcheck="false" placeholder="项目根目录">
193
+ <button id="detectBtn" type="button">检测项目</button>
194
+ </div>
195
+ </header>
196
+
197
+ <main class="layout">
198
+ <aside class="panel">
199
+ <p class="panel-intro">选择客户端、服务端的源码路径和测试命令。运行时会使用临时配置,不会覆盖项目里的 <code>covgate.config.json</code>。</p>
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
+
210
+ <section class="card">
211
+ <div class="card-head">
212
+ <div class="card-title"><span class="dot"></span>客户端</div>
213
+ <span class="hint">100%</span>
214
+ </div>
215
+ <div class="field">
216
+ <label for="clientPath">源码路径</label>
217
+ <input id="clientPath" list="sourceDirs" spellcheck="false" placeholder="例如 src">
218
+ </div>
219
+ <div class="field">
220
+ <label for="clientCommand">测试命令</label>
221
+ <select id="clientCommand"></select>
222
+ </div>
223
+ </section>
224
+
225
+ <section class="card">
226
+ <div class="card-head">
227
+ <div class="card-title"><span class="dot server"></span>服务端</div>
228
+ <span class="hint">95%</span>
229
+ </div>
230
+ <div class="field">
231
+ <label for="serverPath">源码路径</label>
232
+ <input id="serverPath" list="sourceDirs" spellcheck="false" placeholder="例如 desktop,留空则不检查">
233
+ </div>
234
+ <div class="field">
235
+ <label for="serverCommand">测试命令</label>
236
+ <select id="serverCommand"></select>
237
+ </div>
238
+ </section>
239
+
240
+ <section class="card">
241
+ <div class="card-head">
242
+ <div class="card-title"><span class="dot"></span>覆盖率报告</div>
243
+ <span class="hint">自动检测</span>
244
+ </div>
245
+ <div class="field">
246
+ <label for="coveragePath">报告文件</label>
247
+ <input id="coveragePath" list="coverageOptions" spellcheck="false" placeholder="coverage/coverage-final.json">
248
+ </div>
249
+ <div id="configChip" class="config-chip temp">使用临时配置</div>
250
+ </section>
251
+
252
+ <button id="runBtn" class="run-button" type="button" disabled>运行覆盖率</button>
253
+ <div id="panelStatus" class="panel-status"></div>
254
+
255
+ <datalist id="sourceDirs"></datalist>
256
+ <datalist id="coverageOptions"></datalist>
257
+ </aside>
258
+
259
+ <section class="workspace">
260
+ <div id="emptyView" class="empty">
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>
268
+ </div>
269
+
270
+ <div id="runView" class="run-view" hidden>
271
+ <div class="run-card">
272
+ <div class="run-head"><div class="spinner"></div><strong id="runTitle">正在运行覆盖率…</strong><span id="runMeta"></span></div>
273
+ <div id="runError" class="error-box" hidden></div>
274
+ <pre id="logs" class="logs">等待测试命令输出…</pre>
275
+ </div>
276
+ </div>
277
+
278
+ <div id="resultView" class="result-view" hidden>
279
+ <div class="result-head">
280
+ <h2 id="resultTitle">覆盖率结果</h2>
281
+ <span id="statusPill" class="status-pill"></span>
282
+ <a id="openReport" class="head-link" href="#" target="_blank" rel="noreferrer">在新标签打开报告</a>
283
+ </div>
284
+ <div id="scopeStrip" class="scope-strip"></div>
285
+ <div class="report-frame-wrap">
286
+ <iframe id="reportFrame" class="report-frame" title="CovGate 覆盖率报告"></iframe>
287
+ </div>
288
+ </div>
289
+ </section>
290
+ </main>
291
+
292
+ <script>
293
+ (function () {
294
+ var $ = function (id) { return document.getElementById(id); };
295
+ var state = null;
296
+ var activeJob = null;
297
+ var pollTimer = null;
298
+ var allowNoTests = false;
299
+
300
+ function text(value) { return value === null || value === undefined ? "" : String(value); }
301
+ function setStatus(message, kind) {
302
+ var el = $("panelStatus");
303
+ el.textContent = message || "";
304
+ el.className = "panel-status" + (kind ? " " + kind : "");
305
+ }
306
+ function setRunning(running) {
307
+ var noTests = Boolean(state && state.testCount === 0 && !allowNoTests);
308
+ $("runBtn").disabled = running || noTests;
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);
378
+ }
379
+ function showView(name) {
380
+ $("emptyView").hidden = name !== "empty";
381
+ $("runView").hidden = name !== "run";
382
+ $("resultView").hidden = name !== "result";
383
+ }
384
+ function fillCommands(select, commands, selected) {
385
+ select.innerHTML = "";
386
+ if (!commands.length) {
387
+ var none = document.createElement("option");
388
+ none.value = "";
389
+ none.textContent = "未检测到可用的测试命令";
390
+ select.appendChild(none);
391
+ select.disabled = true;
392
+ return;
393
+ }
394
+ select.disabled = false;
395
+ commands.forEach(function (command) {
396
+ var option = document.createElement("option");
397
+ option.value = command.run;
398
+ option.textContent = command.label;
399
+ option.title = command.run;
400
+ select.appendChild(option);
401
+ });
402
+ if (selected && !commands.some(function (command) { return command.run === selected; })) {
403
+ var custom = document.createElement("option");
404
+ custom.value = selected;
405
+ custom.textContent = "当前配置命令:" + selected;
406
+ select.appendChild(custom);
407
+ }
408
+ select.value = selected || commands[0].run;
409
+ }
410
+ function fillDatalist(id, values) {
411
+ var list = $(id);
412
+ list.innerHTML = "";
413
+ values.forEach(function (value) {
414
+ var option = document.createElement("option");
415
+ option.value = value;
416
+ list.appendChild(option);
417
+ });
418
+ }
419
+ function applyState(data) {
420
+ state = data;
421
+ allowNoTests = false;
422
+ $("root").value = data.root;
423
+ $("clientPath").value = data.clientPath || "";
424
+ $("serverPath").value = data.serverPath || "";
425
+ $("coveragePath").value = data.coveragePath || "coverage/coverage-final.json";
426
+ fillCommands($("clientCommand"), data.commands || [], data.clientCommand);
427
+ fillCommands($("serverCommand"), data.commands || [], data.serverCommand);
428
+ fillDatalist("sourceDirs", data.sourceDirs || []);
429
+ var coverageOptions = (data.coverageFiles || []).slice();
430
+ if (data.coveragePath && coverageOptions.indexOf(data.coveragePath) === -1) coverageOptions.unshift(data.coveragePath);
431
+ fillDatalist("coverageOptions", coverageOptions);
432
+ var chip = $("configChip");
433
+ chip.textContent = data.existingConfig ? "已加载现有 covgate.config.json" : "使用临时配置";
434
+ chip.className = "config-chip" + (data.existingConfig ? "" : " temp");
435
+ updateHealth(data);
436
+ setStatus(data.name ? "已检测:" + data.name + " · " + (data.frameworkLabel || "未检测到测试框架") : "已检测项目");
437
+ }
438
+ async function api(url, options) {
439
+ var response = await fetch(url, options);
440
+ var data = null;
441
+ try { data = await response.json(); } catch (_) {}
442
+ if (!response.ok) throw new Error((data && data.error) || ("HTTP " + response.status));
443
+ return data;
444
+ }
445
+ async function detect() {
446
+ var root = $("root").value.trim();
447
+ setStatus("正在检测项目…");
448
+ try {
449
+ var data = await api("/api/state" + (root ? "?root=" + encodeURIComponent(root) : ""));
450
+ applyState(data);
451
+ showView(activeJob ? "run" : "empty");
452
+ } catch (error) {
453
+ setStatus(error.message, "error");
454
+ }
455
+ }
456
+ function renderSummary(job) {
457
+ var report = job.report;
458
+ if (!report) throw new Error(job.error || "没有报告");
459
+ var passed = report.status === "pass";
460
+ $("statusPill").textContent = passed ? "通过" : report.status === "fail" ? "未通过" : "运行失败";
461
+ $("statusPill").className = "status-pill " + (passed ? "pass" : report.status === "fail" ? "fail" : "error");
462
+ $("resultTitle").textContent = "覆盖率结果 · " + (report.tool && report.tool.version ? "v" + report.tool.version : "");
463
+ $("openReport").href = job.htmlUrl || "#";
464
+ $("openReport").hidden = !job.htmlUrl;
465
+ var strip = $("scopeStrip");
466
+ strip.innerHTML = "";
467
+ (report.scopes || []).forEach(function (scope) {
468
+ var card = document.createElement("div");
469
+ card.className = "scope-card";
470
+ var name = document.createElement("div");
471
+ name.className = "name";
472
+ var label = document.createElement("b");
473
+ label.textContent = scope.label || scope.name;
474
+ var profile = document.createElement("span");
475
+ profile.textContent = String(scope.profile || "").toUpperCase();
476
+ name.appendChild(label);
477
+ name.appendChild(profile);
478
+ var value = document.createElement("div");
479
+ var line = scope.metrics && scope.metrics.lines;
480
+ var linePercent = line && line.applicable && line.percent !== null ? line.percent : null;
481
+ value.className = "value " + (scope.skipped || (linePercent !== null && linePercent >= line.threshold) ? "good" : "bad");
482
+ value.textContent = scope.skipped ? "未涉及" : linePercent === null ? "N/A" : linePercent.toFixed(2) + "%";
483
+ var sub = document.createElement("div");
484
+ sub.className = "sub";
485
+ sub.textContent = scope.skipped ? "本次无变更" : line && line.applicable ? line.covered + "/" + line.total + " 行 · 目标 " + line.threshold + "%" : "无行覆盖率数据";
486
+ card.appendChild(name);
487
+ card.appendChild(value);
488
+ card.appendChild(sub);
489
+ strip.appendChild(card);
490
+ });
491
+ $("reportFrame").src = job.htmlUrl ? job.htmlUrl + "?v=" + Date.now() : "about:blank";
492
+ }
493
+ function finishJob(job) {
494
+ setRunning(false);
495
+ activeJob = null;
496
+ pollTimer = null;
497
+ $("runView").hidden = true;
498
+ var report = job.report;
499
+ var emptyReport = !report || report.status === "error" || (report.scopes || []).length === 0;
500
+ if (job.status === "failed" || emptyReport) {
501
+ showView("run");
502
+ $("runTitle").textContent = "运行失败";
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
+ }
510
+ $("runError").hidden = false;
511
+ $("runError").textContent = message;
512
+ $("logs").textContent = job.logs || "测试命令没有输出。";
513
+ setStatus("运行失败", "error");
514
+ return;
515
+ }
516
+ try {
517
+ renderSummary(job);
518
+ showView("result");
519
+ setStatus("覆盖率完成:" + (report.status === "pass" ? "通过" : "未通过"), report.status === "pass" ? "ok" : "error");
520
+ } catch (error) {
521
+ showView("run");
522
+ $("runTitle").textContent = "结果解析失败";
523
+ $("runError").hidden = false;
524
+ $("runError").textContent = error.message;
525
+ setStatus(error.message, "error");
526
+ }
527
+ }
528
+ function pollJob(id) {
529
+ pollTimer = setTimeout(async function () {
530
+ try {
531
+ var job = await api("/api/jobs/" + encodeURIComponent(id));
532
+ $("logs").textContent = job.logs || "等待测试命令输出…";
533
+ $("logs").scrollTop = $("logs").scrollHeight;
534
+ $("runMeta").textContent = job.report ? "正在生成报告…" : "";
535
+ if (job.status === "running") pollJob(id);
536
+ else finishJob(job);
537
+ } catch (error) {
538
+ setRunning(false);
539
+ activeJob = null;
540
+ $("runTitle").textContent = "查询任务失败";
541
+ $("runError").hidden = false;
542
+ $("runError").textContent = error.message;
543
+ setStatus(error.message, "error");
544
+ }
545
+ }, 700);
546
+ }
547
+ async function run() {
548
+ if (activeJob) return;
549
+ if (state && state.testCount === 0 && !allowNoTests) {
550
+ showView("empty");
551
+ setStatus("项目没有测试文件。先补测试,或选择“仍然运行一次”。", "error");
552
+ return;
553
+ }
554
+ var clientPath = $("clientPath").value.trim();
555
+ var serverPath = $("serverPath").value.trim();
556
+ if (!clientPath && !serverPath) {
557
+ setStatus("至少填写客户端或服务端源码路径。", "error");
558
+ return;
559
+ }
560
+ var clientCommand = $("clientCommand").value;
561
+ var serverCommand = $("serverCommand").value;
562
+ if (clientPath && !clientCommand) { setStatus("请选择客户端测试命令。", "error"); return; }
563
+ if (serverPath && !serverCommand) { setStatus("请选择服务端测试命令。", "error"); return; }
564
+ setRunning(true);
565
+ setStatus("正在运行覆盖率…");
566
+ showView("run");
567
+ $("runTitle").textContent = "正在运行覆盖率…";
568
+ $("runMeta").textContent = "";
569
+ $("runError").hidden = true;
570
+ $("logs").textContent = "正在启动测试命令…";
571
+ try {
572
+ var job = await api("/api/run", {
573
+ method: "POST",
574
+ headers: { "Content-Type": "application/json" },
575
+ body: JSON.stringify({
576
+ root: $("root").value.trim(),
577
+ clientPath: clientPath,
578
+ serverPath: serverPath,
579
+ clientCommand: clientCommand,
580
+ serverCommand: serverCommand,
581
+ coveragePath: $("coveragePath").value.trim(),
582
+ allowNoTests: allowNoTests
583
+ })
584
+ });
585
+ activeJob = job.jobId;
586
+ pollJob(job.jobId);
587
+ } catch (error) {
588
+ setRunning(false);
589
+ setStatus(error.message, "error");
590
+ showView("empty");
591
+ if (/没有检测到测试文件/.test(error.message)) {
592
+ allowNoTests = false;
593
+ if (state) updateHealth(state);
594
+ }
595
+ }
596
+ }
597
+
598
+ $("detectBtn").addEventListener("click", detect);
599
+ $("root").addEventListener("keydown", function (event) { if (event.key === "Enter") detect(); });
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
+ });
613
+ detect();
614
+ })();
615
+ </script>
616
+ </body>
617
+ </html>
@@ -0,0 +1,496 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { existsSync } from "node:fs";
4
+ import { readFile, stat, unlink, writeFile } from "node:fs/promises";
5
+ import http from "node:http";
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { expandFiles, matchGlob } from "../glob.js";
9
+ import { detectFramework, detectPackageManager } from "../setup/project.js";
10
+
11
+ const BIN = fileURLToPath(new URL("../../bin/covgate.js", import.meta.url));
12
+ const PAGE = fileURLToPath(new URL("./page.html", import.meta.url));
13
+ const SOURCE_EXT = "{js,jsx,ts,tsx,mjs,cjs}";
14
+ const COVERAGE_CANDIDATES = [
15
+ "coverage/coverage-final.json",
16
+ "coverage/lcov.info",
17
+ "coverage/lcov-report/lcov.info"
18
+ ];
19
+ const SOURCE_DIR_CANDIDATES = [
20
+ "src",
21
+ "desktop",
22
+ "app",
23
+ "client",
24
+ "server",
25
+ "renderer",
26
+ "web",
27
+ "lib"
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
+ ];
40
+ const MAX_BODY = 1024 * 1024;
41
+
42
+ class HttpError extends Error {
43
+ constructor(status, message, code) {
44
+ super(message);
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 [];
72
+ }
73
+ }
74
+
75
+ function toPosix(value) {
76
+ return String(value).replaceAll("\\", "/");
77
+ }
78
+
79
+ function relativeInput(root, value) {
80
+ const raw = String(value || "").trim().replaceAll("\\", "/");
81
+ if (!raw) return "";
82
+ if (path.isAbsolute(raw)) return toPosix(path.relative(root, raw));
83
+ return raw.replace(/^\.\//, "");
84
+ }
85
+
86
+ function sourceGlob(value) {
87
+ const input = String(value || "").trim().replaceAll("\\", "/").replace(/^\.\//, "");
88
+ if (!input) return "";
89
+ if (/[*?[\]{}]/.test(input)) return input;
90
+ if (path.extname(input)) return input;
91
+ return input.replace(/\/+$/, "") + "/**/*." + SOURCE_EXT;
92
+ }
93
+
94
+ function pathFromGlob(glob) {
95
+ const value = String(glob || "").trim();
96
+ if (!value) return "";
97
+ return value
98
+ .replace(/\/\*\*\/\*\..*$/, "")
99
+ .replace(/\/\*\*.*$/, "")
100
+ .replace(/\/\*\..*$/, "")
101
+ .replace(/\/\*.*$/, "");
102
+ }
103
+
104
+ function scriptCommand(packageManager, name) {
105
+ if (packageManager === "yarn") return "yarn " + name;
106
+ if (packageManager === "pnpm") return "pnpm run " + name;
107
+ if (packageManager === "bun") return "bun run " + name;
108
+ return "npm run " + name;
109
+ }
110
+
111
+ function buildCommands(packageManager, pkg) {
112
+ const scripts = pkg?.scripts || {};
113
+ const commands = [];
114
+ const seen = new Set();
115
+ const add = (label, run, scriptName) => {
116
+ if (!run || seen.has(run)) return;
117
+ seen.add(run);
118
+ commands.push({ label, run, script: scriptName || null });
119
+ };
120
+ const names = Object.keys(scripts).filter((name) => {
121
+ const script = String(scripts[name] || "");
122
+ if (name === "coverage:gate" || /\bcovgate\b/i.test(script)) return false;
123
+ return /(^|:)(test|spec|coverage)(:|$)|test|coverage|spec/i.test(name);
124
+ });
125
+ names.sort((left, right) => {
126
+ const score = (name) =>
127
+ name === "test:coverage" ? 0 : name.includes("coverage") ? 1 : name === "test" ? 2 : 3;
128
+ return score(left) - score(right) || left.localeCompare(right, "en");
129
+ });
130
+ for (const name of names) {
131
+ const script = String(scripts[name] || "");
132
+ add(name + " — " + script, scriptCommand(packageManager, name), name);
133
+ }
134
+ const framework = detectFramework(pkg);
135
+ if (framework.id === "vitest") {
136
+ add("Vitest 覆盖率", "npx vitest run --coverage --coverage.reporter=json --coverage.reporter=lcov");
137
+ } else if (framework.id === "jest") {
138
+ add("Jest 覆盖率", "npx jest --coverage --coverageReporters=json --coverageReporters=lcov");
139
+ }
140
+ return { commands, framework, defaultCommand: commands[0]?.run || "" };
141
+ }
142
+
143
+ function existingPath(scopes, name) {
144
+ const include = scopes?.[name]?.sources?.include;
145
+ if (!Array.isArray(include) || include.length === 0) return "";
146
+ return pathFromGlob(include[0]);
147
+ }
148
+
149
+ async function readJson(file) {
150
+ try {
151
+ return JSON.parse(await readFile(file, "utf8"));
152
+ } catch {
153
+ return null;
154
+ }
155
+ }
156
+
157
+ async function inspectProject(root) {
158
+ const projectRoot = path.resolve(root || process.cwd());
159
+ let info;
160
+ try {
161
+ info = await stat(projectRoot);
162
+ } catch {
163
+ throw new HttpError(400, "项目目录不存在:" + projectRoot);
164
+ }
165
+ if (!info.isDirectory()) throw new HttpError(400, "项目路径不是目录:" + projectRoot);
166
+
167
+ const pkg = (await readJson(path.join(projectRoot, "package.json"))) || {};
168
+ const packageManager = detectPackageManager(projectRoot, pkg);
169
+ const { commands, framework, defaultCommand } = buildCommands(packageManager, pkg);
170
+ const existing = (await readJson(path.join(projectRoot, "covgate.config.json"))) || null;
171
+ const scopes = existing?.scopes || {};
172
+
173
+ const sourceDirs = [];
174
+ for (const candidate of SOURCE_DIR_CANDIDATES) {
175
+ if (existsSync(path.join(projectRoot, candidate))) sourceDirs.push(candidate);
176
+ }
177
+ const coverageFiles = [];
178
+ for (const candidate of COVERAGE_CANDIDATES) {
179
+ if (existsSync(path.join(projectRoot, candidate))) coverageFiles.push(candidate);
180
+ }
181
+ const configuredCoverage = scopes.client?.coverage?.paths?.[0] || scopes.server?.coverage?.paths?.[0];
182
+ const coveragePath = configuredCoverage || coverageFiles[0] || COVERAGE_CANDIDATES[0];
183
+
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"]);
191
+ const clientCommand = scopes.client?.commands?.[0]?.run || defaultCommand;
192
+ const serverCommand = scopes.server?.commands?.[0]?.run || defaultCommand;
193
+ const testFiles = await findTestFiles(projectRoot);
194
+
195
+ return {
196
+ root: projectRoot,
197
+ name: pkg.name || path.basename(projectRoot),
198
+ packageManager,
199
+ framework: framework.id,
200
+ frameworkLabel: framework.label,
201
+ commands,
202
+ sourceDirs,
203
+ coverageFiles,
204
+ coveragePath,
205
+ clientPath,
206
+ serverPath,
207
+ clientCommand,
208
+ serverCommand,
209
+ testCount: testFiles.length,
210
+ testFiles: testFiles.slice(0, 50),
211
+ existingConfig: Boolean(existing),
212
+ reportsDir: existing?.reports?.dir || ".covgate"
213
+ };
214
+ }
215
+
216
+ async function buildConfig(state, input) {
217
+ const clientPath = relativeInput(state.root, input.clientPath);
218
+ const serverPath = relativeInput(state.root, input.serverPath);
219
+ const coveragePath = relativeInput(state.root, input.coveragePath || state.coveragePath);
220
+ const existing = state.existing || {};
221
+ const scopes = {};
222
+ const makeScope = async (name, profile, label, sourcePath, command) => {
223
+ if (!sourcePath) return;
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
+ });
234
+ scopes[name] = {
235
+ profile,
236
+ label,
237
+ ...(old.thresholds ? { thresholds: old.thresholds } : {}),
238
+ commands: [
239
+ {
240
+ name: label + "测试与覆盖率",
241
+ run: command || state.defaultCommand,
242
+ timeoutSeconds: 900
243
+ }
244
+ ],
245
+ coverage: { format: "auto", paths: [coveragePath] },
246
+ sources: { include: [sourceGlob(sourcePath)] },
247
+ exclude
248
+ };
249
+ };
250
+ await makeScope("client", "client", "客户端", clientPath, input.clientCommand || state.clientCommand);
251
+ await makeScope("server", "server", "服务端", serverPath, input.serverCommand || state.serverCommand);
252
+ if (Object.keys(scopes).length === 0) {
253
+ throw new HttpError(400, "至少需要指定客户端或服务端源码路径。");
254
+ }
255
+ return {
256
+ version: 1,
257
+ scopes,
258
+ waivers: existing.waivers || {
259
+ minReasonLength: 8,
260
+ requireOwner: false,
261
+ stale: "fail",
262
+ expiry: "fail"
263
+ },
264
+ reports: {
265
+ dir: existing.reports?.dir || ".covgate",
266
+ formats: ["json", "html"]
267
+ }
268
+ };
269
+ }
270
+
271
+ function parseReport(stdout) {
272
+ const text = stdout.trim();
273
+ if (!text) return null;
274
+ try {
275
+ return JSON.parse(text);
276
+ } catch {
277
+ const start = text.indexOf("{");
278
+ const end = text.lastIndexOf("}");
279
+ if (start >= 0 && end > start) {
280
+ try {
281
+ return JSON.parse(text.slice(start, end + 1));
282
+ } catch {
283
+ return null;
284
+ }
285
+ }
286
+ return null;
287
+ }
288
+ }
289
+
290
+ function truncateLog(value, max = 16000) {
291
+ const text = String(value || "");
292
+ return text.length > max ? text.slice(text.length - max) : text;
293
+ }
294
+
295
+ async function readBody(req) {
296
+ let body = "";
297
+ for await (const chunk of req) {
298
+ body += chunk;
299
+ if (body.length > MAX_BODY) throw new HttpError(413, "请求体过大");
300
+ }
301
+ if (!body) return {};
302
+ try {
303
+ return JSON.parse(body);
304
+ } catch {
305
+ throw new HttpError(400, "请求 JSON 无效");
306
+ }
307
+ }
308
+
309
+ function send(res, status, body, contentType = "application/json; charset=utf-8") {
310
+ const payload = typeof body === "string" ? body : JSON.stringify(body);
311
+ res.writeHead(status, {
312
+ "Content-Type": contentType,
313
+ "Cache-Control": "no-store"
314
+ });
315
+ res.end(payload);
316
+ }
317
+
318
+ /**
319
+ * @param {{ root?: string, port?: number, host?: string }} options
320
+ */
321
+ export async function startUiServer(options = {}) {
322
+ const defaultRoot = path.resolve(options.root || process.cwd());
323
+ const host = options.host || "127.0.0.1";
324
+ const port = Number(options.port ?? 4318);
325
+ const page = await readFile(PAGE, "utf8");
326
+ const jobs = new Map();
327
+ let activeJob = null;
328
+
329
+ const server = http.createServer(async (req, res) => {
330
+ try {
331
+ const url = new URL(req.url || "/", "http://" + host);
332
+ if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) {
333
+ send(res, 200, page, "text/html; charset=utf-8");
334
+ return;
335
+ }
336
+ if (req.method === "GET" && url.pathname === "/favicon.ico") {
337
+ res.writeHead(204);
338
+ res.end();
339
+ return;
340
+ }
341
+ if (req.method === "GET" && url.pathname === "/api/state") {
342
+ const root = url.searchParams.get("root") || defaultRoot;
343
+ const state = await inspectProject(root);
344
+ send(res, 200, state);
345
+ return;
346
+ }
347
+ if (req.method === "POST" && url.pathname === "/api/run") {
348
+ if (activeJob) throw new HttpError(409, "已有覆盖率任务正在运行,请等待完成。");
349
+ const body = await readBody(req);
350
+ const root = path.resolve(body.root || defaultRoot);
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
+ }
359
+ const existing = (await readJson(path.join(root, "covgate.config.json"))) || null;
360
+ state.existing = existing || {};
361
+ state.defaultCommand = state.commands[0]?.run || "";
362
+ const config = await buildConfig(state, body);
363
+ const id = randomUUID();
364
+ const configPath = path.join(root, ".covgate-ui-" + id + ".json");
365
+ await writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
366
+ const job = {
367
+ id,
368
+ root,
369
+ status: "running",
370
+ logs: "",
371
+ startedAt: new Date().toISOString(),
372
+ finishedAt: null,
373
+ exitCode: null,
374
+ report: null,
375
+ error: null,
376
+ htmlPath: null,
377
+ spec: {
378
+ clientPath: body.clientPath || "",
379
+ serverPath: body.serverPath || "",
380
+ clientCommand: body.clientCommand || state.clientCommand || "",
381
+ serverCommand: body.serverCommand || state.serverCommand || "",
382
+ coveragePath: body.coveragePath || state.coveragePath || ""
383
+ }
384
+ };
385
+ jobs.set(id, job);
386
+ activeJob = id;
387
+ const child = spawn(process.execPath, [BIN, "run", "--config", configPath, "--json"], {
388
+ cwd: root,
389
+ env: { ...process.env, NO_COLOR: "1" },
390
+ windowsHide: true,
391
+ stdio: ["ignore", "pipe", "pipe"]
392
+ });
393
+ let stdout = "";
394
+ let stderr = "";
395
+ const timer = setTimeout(() => {
396
+ job.error = "覆盖率任务超过 15 分钟,已终止。";
397
+ child.kill();
398
+ }, 15 * 60 * 1000);
399
+ child.stdout.on("data", (chunk) => {
400
+ stdout += chunk.toString();
401
+ });
402
+ child.stderr.on("data", (chunk) => {
403
+ stderr += chunk.toString();
404
+ job.logs = truncateLog(stderr);
405
+ });
406
+ child.on("error", (error) => {
407
+ job.error = error.message;
408
+ });
409
+ child.on("close", async (code) => {
410
+ clearTimeout(timer);
411
+ job.exitCode = code;
412
+ job.finishedAt = new Date().toISOString();
413
+ job.status = job.error ? "failed" : "completed";
414
+ job.report = parseReport(stdout);
415
+ if (!job.report && !job.error) {
416
+ job.status = "failed";
417
+ job.error = "CovGate 没有返回 JSON 报告。" + (stderr ? "\n" + truncateLog(stderr) : "");
418
+ }
419
+ if (job.report) {
420
+ const htmlRelative = job.report.artifacts?.html;
421
+ if (htmlRelative) {
422
+ job.htmlPath = path.resolve(root, htmlRelative);
423
+ if (!existsSync(job.htmlPath)) job.htmlPath = null;
424
+ }
425
+ }
426
+ await unlink(configPath).catch(() => {});
427
+ activeJob = null;
428
+ });
429
+ send(res, 202, { jobId: id });
430
+ return;
431
+ }
432
+ const jobMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)$/);
433
+ if (req.method === "GET" && jobMatch) {
434
+ const job = jobs.get(jobMatch[1]);
435
+ if (!job) throw new HttpError(404, "任务不存在");
436
+ const { htmlPath, root, ...safe } = job;
437
+ send(res, 200, { ...safe, htmlUrl: htmlPath ? "/api/jobs/" + job.id + "/report.html" : null });
438
+ return;
439
+ }
440
+ const reportMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)\/report\.html$/);
441
+ if (req.method === "GET" && reportMatch) {
442
+ const job = jobs.get(reportMatch[1]);
443
+ if (!job) throw new HttpError(404, "任务不存在");
444
+ if (!job.htmlPath) throw new HttpError(404, "HTML 报告尚未生成");
445
+ const html = await readFile(job.htmlPath, "utf8");
446
+ send(res, 200, html, "text/html; charset=utf-8");
447
+ return;
448
+ }
449
+ throw new HttpError(404, "Not found");
450
+ } catch (error) {
451
+ const status = error instanceof HttpError ? error.status : 500;
452
+ send(res, status, {
453
+ error: error.message || String(error),
454
+ ...(error instanceof HttpError && error.code ? { code: error.code } : {})
455
+ });
456
+ }
457
+ });
458
+
459
+ await new Promise((resolve, reject) => {
460
+ server.once("error", reject);
461
+ server.listen(port, host, resolve);
462
+ });
463
+ const address = server.address();
464
+ const actualPort = typeof address === "object" && address ? address.port : port;
465
+ return {
466
+ server,
467
+ port: actualPort,
468
+ url: "http://" + host + ":" + actualPort,
469
+ close: () => new Promise((resolve) => server.close(resolve))
470
+ };
471
+ }
472
+
473
+ /**
474
+ * @param {string} url
475
+ */
476
+ export function openBrowser(url) {
477
+ let command;
478
+ let args;
479
+ if (process.platform === "win32") {
480
+ command = "cmd";
481
+ args = ["/c", "start", "", url];
482
+ } else if (process.platform === "darwin") {
483
+ command = "open";
484
+ args = [url];
485
+ } else {
486
+ command = "xdg-open";
487
+ args = [url];
488
+ }
489
+ try {
490
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
491
+ child.unref();
492
+ return true;
493
+ } catch {
494
+ return false;
495
+ }
496
+ }