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.
- package/LICENSE +21 -0
- package/README.md +504 -2
- package/bin/covgate-postinstall.js +13 -0
- package/bin/covgate.js +5 -0
- package/docs/ai-workflow.md +197 -0
- package/docs/design.md +327 -0
- package/docs/setup.md +165 -0
- package/package.json +48 -4
- package/schema/covgate.schema.json +295 -0
- package/src/annotations.js +224 -0
- package/src/args.js +189 -0
- package/src/cli.js +486 -0
- package/src/config.js +442 -0
- package/src/coverage/index.js +85 -0
- package/src/coverage/istanbul.js +215 -0
- package/src/coverage/lcov.js +234 -0
- package/src/coverage/model.js +316 -0
- package/src/coverage/paths.js +40 -0
- package/src/errors.js +34 -0
- package/src/gate.js +203 -0
- package/src/glob.js +242 -0
- package/src/index.js +28 -0
- package/src/process.js +170 -0
- package/src/reason.js +77 -0
- package/src/report/build.js +135 -0
- package/src/report/index.js +29 -0
- package/src/report/markdown.js +343 -0
- package/src/report/text.js +267 -0
- package/src/reports.js +113 -0
- package/src/runner.js +95 -0
- package/src/scope.js +440 -0
- package/src/setup/agent.js +329 -0
- package/src/setup/config.js +129 -0
- package/src/setup/index.js +270 -0
- package/src/setup/project.js +391 -0
- package/src/setup/prompt.js +43 -0
- package/src/util.js +163 -0
- package/src/version.js +8 -0
- package/templates/AGENTS.md.snippet.md +10 -0
- package/templates/agent-stop-hook.sh +7 -0
- package/templates/covgate.config.json +98 -0
- package/templates/github-actions-coverage.yml +41 -0
- package/templates/gitignore +5 -0
- 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
|
+
}
|