covgate 0.0.0-stage → 0.2.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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +504 -2
  3. package/bin/covgate-postinstall.js +13 -0
  4. package/bin/covgate.js +5 -0
  5. package/docs/ai-workflow.md +197 -0
  6. package/docs/design.md +327 -0
  7. package/docs/setup.md +165 -0
  8. package/package.json +48 -4
  9. package/schema/covgate.schema.json +295 -0
  10. package/src/annotations.js +224 -0
  11. package/src/args.js +189 -0
  12. package/src/cli.js +486 -0
  13. package/src/config.js +442 -0
  14. package/src/coverage/index.js +85 -0
  15. package/src/coverage/istanbul.js +215 -0
  16. package/src/coverage/lcov.js +234 -0
  17. package/src/coverage/model.js +316 -0
  18. package/src/coverage/paths.js +40 -0
  19. package/src/errors.js +34 -0
  20. package/src/gate.js +203 -0
  21. package/src/glob.js +242 -0
  22. package/src/index.js +28 -0
  23. package/src/process.js +170 -0
  24. package/src/reason.js +77 -0
  25. package/src/report/build.js +135 -0
  26. package/src/report/index.js +29 -0
  27. package/src/report/markdown.js +343 -0
  28. package/src/report/text.js +267 -0
  29. package/src/reports.js +113 -0
  30. package/src/runner.js +95 -0
  31. package/src/scope.js +440 -0
  32. package/src/setup/agent.js +329 -0
  33. package/src/setup/config.js +129 -0
  34. package/src/setup/index.js +270 -0
  35. package/src/setup/project.js +391 -0
  36. package/src/setup/prompt.js +43 -0
  37. package/src/util.js +163 -0
  38. package/src/version.js +8 -0
  39. package/templates/AGENTS.md.snippet.md +10 -0
  40. package/templates/agent-stop-hook.sh +7 -0
  41. package/templates/covgate.config.json +98 -0
  42. package/templates/github-actions-coverage.yml +41 -0
  43. package/templates/gitignore +5 -0
  44. package/templates/pre-commit +10 -0
@@ -0,0 +1,270 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import {
5
+ SetupError,
6
+ addScripts,
7
+ coverageScriptFor,
8
+ detectFramework,
9
+ detectPackageManager,
10
+ detectProfile,
11
+ detectScopeSpecs,
12
+ detectSourceGlob,
13
+ displayPath,
14
+ installVitest,
15
+ readPackage,
16
+ writePackage
17
+ } from "./project.js";
18
+ import { buildStarterConfig } from "./config.js";
19
+ import { scaffoldAgent } from "./agent.js";
20
+ import { askYesNo, canPrompt } from "./prompt.js";
21
+
22
+ const DEFAULT_COVERAGE_PATH = "coverage/coverage-final.json";
23
+
24
+ /**
25
+ * @param {string} root
26
+ * @param {string} value
27
+ */
28
+ function normalizeGlob(root, value) {
29
+ return String(value || "")
30
+ .trim()
31
+ .replaceAll("\\", "/")
32
+ .replace(/^\.\//, "");
33
+ }
34
+
35
+ /**
36
+ * @param {{
37
+ * root?: string,
38
+ * yes?: boolean,
39
+ * installFramework?: boolean,
40
+ * profile?: "client" | "server",
41
+ * source?: string,
42
+ * coverage?: string,
43
+ * testCommand?: string,
44
+ * noAgent?: boolean,
45
+ * noConfig?: boolean,
46
+ * force?: boolean,
47
+ * dryRun?: boolean,
48
+ * postinstall?: boolean,
49
+ * log?: (message: string) => void,
50
+ * stderr?: NodeJS.WritableStream
51
+ * }} options
52
+ */
53
+ export async function runSetup(options = {}) {
54
+ const root = path.resolve(options.root || process.cwd());
55
+ const log = options.log || ((message) => process.stdout.write(message + String.fromCharCode(10)));
56
+ const writeError = options.stderr || process.stderr;
57
+ const project = await readPackage(root);
58
+ if (!project) {
59
+ throw new SetupError("当前目录没有 package.json。CovGate 的自动配置目前面向 Node.js 项目。");
60
+ }
61
+ let pkg = project.data;
62
+ const packageManager = detectPackageManager(root, pkg);
63
+ log("[covgate] 项目: " + root);
64
+ log("[covgate] 包管理器: " + packageManager);
65
+
66
+ let framework = detectFramework(pkg);
67
+ let installedFramework = false;
68
+
69
+ if (!framework.id && options.installFramework !== false) {
70
+ const approved =
71
+ options.yes === true
72
+ ? true
73
+ : await askYesNo(
74
+ "[covgate] 未检测到测试框架。是否安装 Vitest + @vitest/coverage-v8?",
75
+ { defaultYes: true, output: options.stderr || process.stderr }
76
+ );
77
+ if (approved) {
78
+ log("[covgate] 安装 Vitest 与 V8 覆盖率插件……");
79
+ const result = await installVitest(root, packageManager, options.dryRun === true, {
80
+ stdout: process.stdout,
81
+ stderr: writeError
82
+ });
83
+ if (result.ok) {
84
+ installedFramework = true;
85
+ framework = { id: "vitest", label: "Vitest", hasCoverageScript: true };
86
+ const refreshed = await readPackage(root);
87
+ if (refreshed) pkg = refreshed.data;
88
+ } else {
89
+ log("[covgate] 自动安装失败。可手动运行: " + result.command);
90
+ }
91
+ } else {
92
+ log("[covgate] 跳过测试框架安装。");
93
+ }
94
+ }
95
+
96
+ let packageChanged = false;
97
+ const scriptChanges = addScripts(pkg, framework.id, {
98
+ testCommand: options.testCommand,
99
+ force: options.force === true
100
+ });
101
+ if (scriptChanges.length > 0) {
102
+ packageChanged = true;
103
+ if (!options.dryRun) await writePackage(root, pkg);
104
+ log("[covgate] package.json 脚本: " + scriptChanges.join(", "));
105
+ }
106
+
107
+ let testCommand = options.testCommand;
108
+ if (!testCommand && pkg.scripts && pkg.scripts["test:coverage"]) {
109
+ testCommand = "npm run test:coverage";
110
+ }
111
+
112
+ let scopeSpecs;
113
+ if (options.profile || options.source) {
114
+ const profile = options.profile || detectProfile(root, pkg);
115
+ if (!["client", "server"].includes(profile)) {
116
+ throw new SetupError("profile 必须是 client 或 server");
117
+ }
118
+ const sourceGlob = normalizeGlob(
119
+ root,
120
+ options.source || detectSourceGlob(root, profile) || ""
121
+ );
122
+ scopeSpecs = sourceGlob
123
+ ? [
124
+ {
125
+ name: profile,
126
+ profile,
127
+ label: profile === "client" ? "客户端" : "服务端",
128
+ sourceGlob
129
+ }
130
+ ]
131
+ : [];
132
+ } else {
133
+ scopeSpecs = detectScopeSpecs(root, pkg);
134
+ }
135
+ const coveragePath = normalizeGlob(root, options.coverage || DEFAULT_COVERAGE_PATH);
136
+ const configPath = path.join(root, "covgate.config.json");
137
+ let configWritten = false;
138
+
139
+ if (options.noConfig !== true && testCommand && scopeSpecs.length > 0) {
140
+ if (existsSync(configPath) && options.force !== true) {
141
+ log("[covgate] " + displayPath(root, configPath) + " 已存在,保留现有配置");
142
+ } else {
143
+ const config = await buildStarterConfig({
144
+ root,
145
+ scopes: scopeSpecs,
146
+ command: testCommand,
147
+ coveragePath
148
+ });
149
+ if (!options.dryRun) {
150
+ await mkdir(path.dirname(configPath), { recursive: true });
151
+ await writeFile(configPath, JSON.stringify(config, null, 2) + String.fromCharCode(10), "utf8");
152
+ }
153
+ configWritten = true;
154
+ log(
155
+ "[covgate] 生成 " +
156
+ displayPath(root, configPath) +
157
+ "(" +
158
+ scopeSpecs
159
+ .map((scope) => scope.profile + ":" + scope.sourceGlob)
160
+ .join(",") +
161
+ ")"
162
+ );
163
+ if (framework.id && !coverageScriptFor(framework.id) && !options.testCommand) {
164
+ log("[covgate] 注意:该测试框架的覆盖率命令需要你确认报告路径。");
165
+ }
166
+ }
167
+ } else if (options.noConfig !== true) {
168
+ if (!testCommand) {
169
+ log("[covgate] 未找到可用的覆盖率测试命令,跳过 covgate.config.json。");
170
+ }
171
+ if (scopeSpecs.length === 0) {
172
+ log("[covgate] 未找到源码目录,跳过 covgate.config.json;请用 --source 指定。");
173
+ }
174
+ }
175
+
176
+ let agentChanges = [];
177
+ if (options.noAgent !== true) {
178
+ agentChanges = await scaffoldAgent({
179
+ root,
180
+ packageManager,
181
+ force: options.force === true,
182
+ dryRun: options.dryRun === true,
183
+ log
184
+ });
185
+ }
186
+
187
+ const summary = {
188
+ root,
189
+ packageManager,
190
+ framework: framework.id || null,
191
+ installedFramework,
192
+ profile: scopeSpecs.length === 1 ? scopeSpecs[0].profile : null,
193
+ sourceGlob: scopeSpecs.length === 1 ? scopeSpecs[0].sourceGlob : null,
194
+ scopes: scopeSpecs,
195
+ testCommand: testCommand || null,
196
+ coveragePath,
197
+ configPath: configWritten ? configPath : existsSync(configPath) ? configPath : null,
198
+ packageChanged,
199
+ packageScripts: scriptChanges,
200
+ agentChanges,
201
+ dryRun: options.dryRun === true,
202
+ postinstall: options.postinstall === true
203
+ };
204
+
205
+ log("");
206
+ log("[covgate] 完成。");
207
+ if (summary.configPath) {
208
+ log("[covgate] 配置: " + displayPath(root, summary.configPath));
209
+ }
210
+ if (summary.framework) {
211
+ log("[covgate] 测试框架: " + (framework.label || summary.framework));
212
+ }
213
+ if (scopeSpecs.length > 0) {
214
+ log(
215
+ "[covgate] Scope: " +
216
+ scopeSpecs
217
+ .map((scope) => scope.name + "/" + scope.profile + " (" + scope.sourceGlob + ")")
218
+ .join(", ")
219
+ );
220
+ }
221
+ log("[covgate] 运行门禁: " + (summary.dryRun ? "npx covgate run" : "npx --no-install covgate run"));
222
+ log("[covgate] 注意:CovGate 不会生成或补写测试;项目必须为行为变更补真实测试后才能通过门禁。");
223
+ if (!options.noAgent) {
224
+ log("[covgate] AI 接入: AGENTS.md 与 scripts/covgate-agent-gate.*");
225
+ }
226
+ return summary;
227
+ }
228
+
229
+ /**
230
+ * @param {{
231
+ * root: string,
232
+ * stderr?: NodeJS.WritableStream
233
+ * }} options
234
+ */
235
+ export async function runPostinstall(options) {
236
+ const stderr = options.stderr || process.stderr;
237
+ const root = path.resolve(options.root || process.cwd());
238
+ const project = await readPackage(root).catch(() => null);
239
+ if (!project) return 0;
240
+ if (project.data?.name === "covgate") return 0;
241
+ if (process.env.COVGATE_SKIP_SETUP === "1") return 0;
242
+ if (process.env.CI) return 0;
243
+ if (process.env.npm_config_global === "true") return 0;
244
+ const hasDirectDependency =
245
+ project.data?.dependencies?.covgate ||
246
+ project.data?.devDependencies?.covgate ||
247
+ project.data?.optionalDependencies?.covgate;
248
+ if (!hasDirectDependency) return 0;
249
+
250
+ if (!canPrompt({ output: stderr }) && process.env.COVGATE_SETUP_ASSUME_YES !== "1") {
251
+ stderr.write(
252
+ "[covgate] 已安装。未检测到交互终端,未自动配置;可运行 npx covgate setup。\n"
253
+ );
254
+ return 0;
255
+ }
256
+
257
+ try {
258
+ await runSetup({
259
+ root,
260
+ postinstall: true,
261
+ installFramework: true,
262
+ stderr,
263
+ log: (message) => console.log(message)
264
+ });
265
+ } catch (error) {
266
+ stderr.write("[covgate] 安装后自动配置失败:" + (error?.message || error) + "\n");
267
+ stderr.write("[covgate] 可稍后运行 npx covgate setup。\n");
268
+ }
269
+ return 0;
270
+ }
@@ -0,0 +1,391 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { readFile, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+
6
+ const FRAMEWORK_LABELS = {
7
+ vitest: "Vitest",
8
+ jest: "Jest",
9
+ playwright: "Playwright Test",
10
+ mocha: "Mocha",
11
+ ava: "AVA",
12
+ tap: "node-tap",
13
+ "node-test": "Node.js 内置 test runner"
14
+ };
15
+
16
+ const BROWSER_DEPS = new Set([
17
+ "react",
18
+ "react-dom",
19
+ "vue",
20
+ "svelte",
21
+ "solid-js",
22
+ "next",
23
+ "nuxt",
24
+ "@angular/core",
25
+ "preact"
26
+ ]);
27
+
28
+ const SERVER_DEPS = new Set([
29
+ "express",
30
+ "fastify",
31
+ "koa",
32
+ "hapi",
33
+ "@hapi/hapi",
34
+ "@nestjs/core"
35
+ ]);
36
+
37
+ const TEST_DEPS = [
38
+ ["vitest", "vitest"],
39
+ ["jest", "jest"],
40
+ ["@playwright/test", "playwright"],
41
+ ["mocha", "mocha"],
42
+ ["ava", "ava"],
43
+ ["tap", "tap"]
44
+ ];
45
+
46
+ const SOURCE_EXT = "{js,jsx,ts,tsx,mjs,cjs}";
47
+
48
+ export class SetupError extends Error {
49
+ constructor(message) {
50
+ super(message);
51
+ this.name = "SetupError";
52
+ this.exitCode = 2;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * @param {string} root
58
+ */
59
+ export function packageJsonPath(root) {
60
+ return path.join(root, "package.json");
61
+ }
62
+
63
+ /**
64
+ * @param {string} root
65
+ */
66
+ export async function readPackage(root) {
67
+ const file = packageJsonPath(root);
68
+ let text;
69
+ try {
70
+ text = await readFile(file, "utf8");
71
+ } catch (error) {
72
+ if (error && error.code === "ENOENT") return null;
73
+ throw new SetupError("无法读取 package.json: " + error.message);
74
+ }
75
+ try {
76
+ return { path: file, text, data: JSON.parse(text.replace(/^\uFEFF/, "")) };
77
+ } catch (error) {
78
+ throw new SetupError("package.json 不是合法 JSON: " + error.message);
79
+ }
80
+ }
81
+
82
+ /**
83
+ * @param {string} root
84
+ * @param {any} data
85
+ */
86
+ export async function writePackage(root, data) {
87
+ await writeFile(
88
+ packageJsonPath(root),
89
+ JSON.stringify(data, null, 2) + "\n",
90
+ "utf8"
91
+ );
92
+ }
93
+
94
+ /**
95
+ * @param {string} root
96
+ * @param {any} pkg
97
+ */
98
+ export function detectPackageManager(root, pkg) {
99
+ const declared = String(pkg?.packageManager || "").split("@")[0];
100
+ if (["npm", "pnpm", "yarn", "bun"].includes(declared)) return declared;
101
+ if (existsSync(path.join(root, "pnpm-lock.yaml"))) return "pnpm";
102
+ if (existsSync(path.join(root, "yarn.lock"))) return "yarn";
103
+ if (existsSync(path.join(root, "bun.lockb")) || existsSync(path.join(root, "bun.lock"))) return "bun";
104
+ return "npm";
105
+ }
106
+
107
+ /**
108
+ * @param {any} pkg
109
+ */
110
+ export function directDependencyNames(pkg) {
111
+ return new Set([
112
+ ...Object.keys(pkg?.dependencies || {}),
113
+ ...Object.keys(pkg?.devDependencies || {}),
114
+ ...Object.keys(pkg?.optionalDependencies || {})
115
+ ]);
116
+ }
117
+
118
+ /**
119
+ * @param {any} pkg
120
+ */
121
+ export function hasCovgateDependency(pkg) {
122
+ return directDependencyNames(pkg).has("covgate");
123
+ }
124
+
125
+ /**
126
+ * @param {any} pkg
127
+ * @returns {{ id: string | null, label: string, hasCoverageScript: boolean }}
128
+ */
129
+ export function detectFramework(pkg) {
130
+ const deps = directDependencyNames(pkg);
131
+ const scripts = pkg?.scripts || {};
132
+ for (const [dependency, id] of TEST_DEPS) {
133
+ if (deps.has(dependency)) {
134
+ return {
135
+ id,
136
+ label: FRAMEWORK_LABELS[id],
137
+ hasCoverageScript: Boolean(scripts["test:coverage"] || scripts.test)
138
+ };
139
+ }
140
+ }
141
+ const testScript = String(scripts["test:coverage"] || scripts.test || "");
142
+ if (/\bnode\s+--test\b/.test(testScript)) {
143
+ return {
144
+ id: "node-test",
145
+ label: FRAMEWORK_LABELS["node-test"],
146
+ hasCoverageScript: Boolean(scripts["test:coverage"])
147
+ };
148
+ }
149
+ return { id: null, label: "未检测到", hasCoverageScript: false };
150
+ }
151
+
152
+ /**
153
+ * @param {string} root
154
+ * @param {any} pkg
155
+ * @returns {"client" | "server"}
156
+ */
157
+ export function detectProfile(root, pkg) {
158
+ const deps = directDependencyNames(pkg);
159
+ const htmlLocations = [
160
+ "index.html",
161
+ "src/index.html",
162
+ "app/index.html",
163
+ "renderer/index.html"
164
+ ];
165
+ if (htmlLocations.some((file) => existsSync(path.join(root, file)))) return "client";
166
+ if (pkg?.browser) return "client";
167
+ if (deps.has("electron") || deps.has("electron-builder")) return "client";
168
+ if ([...BROWSER_DEPS].some((dependency) => deps.has(dependency))) return "client";
169
+ if ([...SERVER_DEPS].some((dependency) => deps.has(dependency))) return "server";
170
+ return "server";
171
+ }
172
+
173
+ /**
174
+ * @param {string} directory
175
+ */
176
+ export function sourceGlobForDir(directory) {
177
+ return directory + "/**/*." + SOURCE_EXT;
178
+ }
179
+
180
+ /**
181
+ * @param {string} root
182
+ * @param {"client" | "server"} profile
183
+ * @returns {string | null}
184
+ */
185
+ export function detectSourceGlob(root, profile) {
186
+ if (existsSync(path.join(root, "src"))) {
187
+ return sourceGlobForDir("src");
188
+ }
189
+ if (profile === "client" && existsSync(path.join(root, "app.js"))) {
190
+ return "app.js";
191
+ }
192
+ const candidate = profile === "client" ? "client" : "server";
193
+ if (existsSync(path.join(root, candidate))) {
194
+ return sourceGlobForDir(candidate);
195
+ }
196
+ if (existsSync(path.join(root, "app"))) {
197
+ return sourceGlobForDir("app");
198
+ }
199
+ if (existsSync(path.join(root, "lib"))) {
200
+ return sourceGlobForDir("lib");
201
+ }
202
+ return null;
203
+ }
204
+
205
+ /**
206
+ * Electron apps usually have a renderer tree (src) and a main-process tree
207
+ * (desktop). Treating src as server (the old setup default) was wrong.
208
+ *
209
+ * @param {string} root
210
+ * @param {any} pkg
211
+ */
212
+ export function detectElectronApp(root, pkg) {
213
+ const deps = directDependencyNames(pkg);
214
+ if (deps.has("electron") || deps.has("electron-builder") || deps.has("@electron/packager")) {
215
+ return true;
216
+ }
217
+ const main = typeof pkg?.main === "string" ? pkg.main.replaceAll("\\", "/") : "";
218
+ return main.startsWith("desktop/") && existsSync(path.join(root, "src"));
219
+ }
220
+
221
+ /**
222
+ * @param {string} root
223
+ * @param {any} pkg
224
+ * @returns {Array<{ name: string, profile: "client" | "server", label: string, sourceGlob: string }>}
225
+ */
226
+ export function detectScopeSpecs(root, pkg) {
227
+ if (detectElectronApp(root, pkg)) {
228
+ const clientDir = ["src", "app", "renderer", "client"].find((directory) =>
229
+ existsSync(path.join(root, directory))
230
+ );
231
+ const serverDir = ["desktop", "electron", "main"].find((directory) =>
232
+ existsSync(path.join(root, directory))
233
+ );
234
+ const specs = [];
235
+ if (clientDir) {
236
+ specs.push({
237
+ name: "client",
238
+ profile: "client",
239
+ label: "桌面客户端",
240
+ sourceGlob: sourceGlobForDir(clientDir)
241
+ });
242
+ }
243
+ if (serverDir) {
244
+ specs.push({
245
+ name: "server",
246
+ profile: "server",
247
+ label: "桌面主进程",
248
+ sourceGlob: sourceGlobForDir(serverDir)
249
+ });
250
+ }
251
+ if (specs.length > 0) return specs;
252
+ }
253
+ const profile = detectProfile(root, pkg);
254
+ const sourceGlob = detectSourceGlob(root, profile);
255
+ if (!sourceGlob) return [];
256
+ return [
257
+ {
258
+ name: profile,
259
+ profile,
260
+ label: profile === "client" ? "客户端" : "服务端",
261
+ sourceGlob
262
+ }
263
+ ];
264
+ }
265
+
266
+ /**
267
+ * @param {"vitest" | "jest" | string} frameworkId
268
+ */
269
+ export function coverageScriptFor(frameworkId) {
270
+ if (frameworkId === "vitest") {
271
+ return "vitest run --coverage --coverage.reporter=json --coverage.reporter=lcov";
272
+ }
273
+ if (frameworkId === "jest") {
274
+ return "jest --coverage --coverageReporters=json --coverageReporters=lcov";
275
+ }
276
+ return null;
277
+ }
278
+
279
+ /**
280
+ * @param {"vitest" | "jest" | string} frameworkId
281
+ */
282
+ export function testScriptFor(frameworkId) {
283
+ if (frameworkId === "vitest") return "vitest run";
284
+ if (frameworkId === "jest") return "jest";
285
+ return null;
286
+ }
287
+
288
+ /**
289
+ * Add idempotent package scripts. Existing user scripts are preserved unless
290
+ * force is true.
291
+ * @param {any} pkg
292
+ * @param {"vitest" | "jest" | string} frameworkId
293
+ * @param {{ force?: boolean, testCommand?: string }} [options]
294
+ */
295
+ export function addScripts(pkg, frameworkId, options = {}) {
296
+ pkg.scripts = pkg.scripts || {};
297
+ const changed = [];
298
+ if (!pkg.scripts["coverage:gate"] || options.force) {
299
+ pkg.scripts["coverage:gate"] = "covgate run";
300
+ changed.push("coverage:gate");
301
+ }
302
+ if (options.testCommand) {
303
+ if (!pkg.scripts["test:coverage"] || options.force) {
304
+ pkg.scripts["test:coverage"] = options.testCommand;
305
+ changed.push("test:coverage");
306
+ }
307
+ } else {
308
+ const coverageScript = coverageScriptFor(frameworkId);
309
+ if (coverageScript && (!pkg.scripts["test:coverage"] || options.force)) {
310
+ pkg.scripts["test:coverage"] = coverageScript;
311
+ changed.push("test:coverage");
312
+ }
313
+ const testScript = testScriptFor(frameworkId);
314
+ if (testScript && !pkg.scripts.test) {
315
+ pkg.scripts.test = testScript;
316
+ changed.push("test");
317
+ }
318
+ }
319
+ return changed;
320
+ }
321
+
322
+ /**
323
+ * @param {string} root
324
+ * @param {"npm" | "pnpm" | "yarn" | "bun"} packageManager
325
+ * @param {boolean} dryRun
326
+ * @param {{ stdout?: NodeJS.WritableStream, stderr?: NodeJS.WritableStream }} [io]
327
+ */
328
+ export async function installVitest(root, packageManager, dryRun = false, io = {}) {
329
+ const packages = ["vitest", "@vitest/coverage-v8"];
330
+ const command =
331
+ packageManager === "pnpm"
332
+ ? "pnpm add -D --ignore-scripts " + packages.join(" ")
333
+ : packageManager === "yarn"
334
+ ? "yarn add -D --ignore-scripts " + packages.join(" ")
335
+ : packageManager === "bun"
336
+ ? "bun add -d --ignore-scripts " + packages.join(" ")
337
+ : "npm install --save-dev --ignore-scripts --no-audit --no-fund " + packages.join(" ");
338
+ if (dryRun) {
339
+ (io.stdout || process.stdout).write("[dry-run] " + command + "\n");
340
+ return { ok: true, command, dryRun: true };
341
+ }
342
+ return new Promise((resolve) => {
343
+ const timeout = 180000;
344
+ let settled = false;
345
+ const finish = (result) => {
346
+ if (settled) return;
347
+ settled = true;
348
+ clearTimeout(timer);
349
+ resolve(result);
350
+ };
351
+ const child = spawn(command, {
352
+ cwd: root,
353
+ shell: true,
354
+ detached: process.platform !== "win32",
355
+ stdio: "inherit",
356
+ windowsHide: false
357
+ });
358
+ const timer = setTimeout(() => {
359
+ try {
360
+ if (process.platform === "win32" && child.pid) {
361
+ spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
362
+ stdio: "ignore",
363
+ windowsHide: true
364
+ });
365
+ } else if (child.pid) {
366
+ process.kill(-child.pid, "SIGKILL");
367
+ }
368
+ } catch {
369
+ try { child.kill(); } catch {}
370
+ }
371
+ (io.stderr || process.stderr).write("[covgate] 安装测试框架超时(180s),已终止。\n");
372
+ finish({ ok: false, command, timedOut: true });
373
+ }, timeout);
374
+ child.on("error", (error) => {
375
+ (io.stderr || process.stderr).write("[covgate] 无法启动测试框架安装:" + error.message + "\n");
376
+ finish({ ok: false, command });
377
+ });
378
+ child.on("exit", (code) => {
379
+ finish({ ok: code === 0, command, code });
380
+ });
381
+ });
382
+ }
383
+
384
+ /**
385
+ * @param {string} root
386
+ * @param {string} file
387
+ */
388
+ export function displayPath(root, file) {
389
+ const relative = path.relative(root, file).replaceAll("\\", "/");
390
+ return relative && !relative.startsWith("..") ? relative : file;
391
+ }
@@ -0,0 +1,43 @@
1
+ import readline from "node:readline/promises";
2
+
3
+ /**
4
+ * @param {{
5
+ * input?: NodeJS.ReadableStream & { isTTY?: boolean },
6
+ * output?: NodeJS.WritableStream & { isTTY?: boolean }
7
+ * }} [io]
8
+ */
9
+ export function canPrompt(io = {}) {
10
+ const input = io.input || process.stdin;
11
+ const output = io.output || process.stderr;
12
+ return Boolean(input.isTTY && output.isTTY && !process.env.CI);
13
+ }
14
+
15
+ /**
16
+ * @param {string} question
17
+ * @param {{
18
+ * defaultYes?: boolean,
19
+ * forceYes?: boolean,
20
+ * input?: any,
21
+ * output?: any
22
+ * }} [options]
23
+ */
24
+ export async function askYesNo(question, options = {}) {
25
+ const forceYes =
26
+ options.forceYes === true || process.env.COVGATE_SETUP_ASSUME_YES === "1";
27
+ if (forceYes) return true;
28
+ const input = options.input || process.stdin;
29
+ const output = options.output || process.stderr;
30
+ if (!input.isTTY || !output.isTTY) return false;
31
+
32
+ const rl = readline.createInterface({ input, output });
33
+ try {
34
+ const suffix = options.defaultYes === false ? " [y/N] " : " [Y/n] ";
35
+ const answer = (await rl.question(question + suffix)).trim().toLowerCase();
36
+ if (!answer) return options.defaultYes !== false;
37
+ return answer === "y" || answer === "yes" || answer === "是";
38
+ } catch {
39
+ return false;
40
+ } finally {
41
+ rl.close();
42
+ }
43
+ }