@51jbs/incremental-coverage-plugin 1.0.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.
@@ -0,0 +1,1360 @@
1
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
+ }) : x)(function(x) {
4
+ if (typeof require !== "undefined") return require.apply(this, arguments);
5
+ throw Error('Dynamic require of "' + x + '" is not supported');
6
+ });
7
+
8
+ // src/plugin.ts
9
+ import { createUnplugin } from "unplugin";
10
+
11
+ // src/collector.ts
12
+ import * as path from "path";
13
+ var CoverageCollector = class {
14
+ constructor() {
15
+ /**
16
+ * 全局覆盖率映射表
17
+ *
18
+ * 键是文件路径,值是该文件的覆盖率数据
19
+ * 这个对象会随着数据上报不断更新
20
+ */
21
+ this.coverageMap = {};
22
+ }
23
+ /**
24
+ * 合并新的覆盖率数据
25
+ *
26
+ * 算法说明:
27
+ * 1. 遍历新数据中的每个文件
28
+ * 2. 如果是首次出现的文件,直接存储
29
+ * 3. 如果文件已存在,合并执行次数(累加)
30
+ *
31
+ * 为什么要累加?
32
+ * - 用户可能多次执行同一段代码
33
+ * - 需要记录总的执行次数,而不是覆盖
34
+ *
35
+ * @param newCoverage - 新上报的覆盖率数据
36
+ * @returns 合并后的完整覆盖率数据
37
+ *
38
+ * @example
39
+ * // 第一次上报
40
+ * collector.merge({ 'src/utils.ts': { s: { '0': 1 } } });
41
+ * // 第二次上报(同一文件)
42
+ * collector.merge({ 'src/utils.ts': { s: { '0': 2 } } });
43
+ * // 结果:s['0'] = 3(累加)
44
+ */
45
+ merge(newCoverage) {
46
+ const projectRoot = process.cwd();
47
+ for (const [key, data] of Object.entries(newCoverage)) {
48
+ const filePath = path.isAbsolute(key) ? key : path.resolve(projectRoot, key);
49
+ if (!this.coverageMap[filePath]) {
50
+ this.coverageMap[filePath] = data;
51
+ } else {
52
+ this.coverageMap[filePath] = this.mergeCoverageData(
53
+ this.coverageMap[filePath],
54
+ data
55
+ );
56
+ }
57
+ }
58
+ return this.coverageMap;
59
+ }
60
+ /**
61
+ * 获取当前的覆盖率数据
62
+ *
63
+ * @returns 当前的完整覆盖率映射表
64
+ */
65
+ getCoverage() {
66
+ return this.coverageMap;
67
+ }
68
+ /**
69
+ * 重置覆盖率数据
70
+ *
71
+ * 清空所有已收集的数据,重新开始
72
+ * 通常在需要重新计算覆盖率时使用
73
+ */
74
+ reset() {
75
+ this.coverageMap = {};
76
+ }
77
+ /**
78
+ * 合并两个覆盖率数据对象
79
+ *
80
+ * 合并策略:
81
+ * 1. 语句覆盖(s):执行次数累加
82
+ * 2. 函数覆盖(f):执行次数累加
83
+ * 3. 分支覆盖(b):数组元素逐个累加
84
+ *
85
+ * 注意:
86
+ * - statementMap、fnMap、branchMap 不需要合并(它们是静态的位置信息)
87
+ * - 只有执行次数(s、f、b)需要累加
88
+ *
89
+ * @param existing - 已存在的覆盖率数据
90
+ * @param newData - 新的覆盖率数据
91
+ * @returns 合并后的覆盖率数据
92
+ *
93
+ * @private
94
+ */
95
+ mergeCoverageData(existing, newData) {
96
+ const merged = {
97
+ path: existing.path,
98
+ // 位置映射表保持不变(它们是静态的)
99
+ statementMap: existing.statementMap,
100
+ fnMap: existing.fnMap,
101
+ branchMap: existing.branchMap,
102
+ // 执行次数需要累加
103
+ s: { ...existing.s },
104
+ f: { ...existing.f },
105
+ b: { ...existing.b }
106
+ };
107
+ for (const [key, count] of Object.entries(newData.s)) {
108
+ merged.s[key] = (merged.s[key] || 0) + count;
109
+ }
110
+ for (const [key, count] of Object.entries(newData.f)) {
111
+ merged.f[key] = (merged.f[key] || 0) + count;
112
+ }
113
+ for (const [key, counts] of Object.entries(newData.b)) {
114
+ if (!merged.b[key]) {
115
+ merged.b[key] = counts;
116
+ } else {
117
+ merged.b[key] = counts.map((count, i) => (merged.b[key][i] || 0) + count);
118
+ }
119
+ }
120
+ return merged;
121
+ }
122
+ };
123
+
124
+ // src/differ.ts
125
+ import * as istanbulDiff from "istanbul-diff";
126
+ import * as fs from "fs";
127
+ import * as path3 from "path";
128
+
129
+ // src/git.ts
130
+ import simpleGit from "simple-git";
131
+ import * as path2 from "path";
132
+ var baseGit = simpleGit();
133
+ async function getGitRoot() {
134
+ try {
135
+ const topLevel = await baseGit.revparse(["--show-toplevel"]);
136
+ return topLevel.trim();
137
+ } catch (e) {
138
+ return process.cwd();
139
+ }
140
+ }
141
+ async function getGitDiff(base = "main") {
142
+ try {
143
+ const topLevel = await getGitRoot();
144
+ const git = simpleGit(topLevel);
145
+ const filesRaw = await git.raw(["diff", "--name-only", base]);
146
+ const changedFiles = filesRaw.split("\n").filter((f) => f.trim().length > 0);
147
+ const result = {
148
+ files: [],
149
+ // 变更的文件列表
150
+ additions: {},
151
+ // 每个文件新增的行号
152
+ deletions: {}
153
+ // 每个文件删除的行号
154
+ };
155
+ for (const relativeFile of changedFiles) {
156
+ const absoluteFile = path2.resolve(topLevel, relativeFile);
157
+ result.files.push(absoluteFile);
158
+ const diff2 = await git.diff([base, "--", relativeFile]);
159
+ const { additions, deletions } = parseDiff(diff2);
160
+ if (additions.length > 0) {
161
+ result.additions[absoluteFile] = additions;
162
+ }
163
+ if (deletions.length > 0) {
164
+ result.deletions[absoluteFile] = deletions;
165
+ }
166
+ }
167
+ return result;
168
+ } catch (error) {
169
+ console.warn("[Git] \u83B7\u53D6 diff \u5931\u8D25:", error);
170
+ return {
171
+ files: [],
172
+ additions: {},
173
+ deletions: {}
174
+ };
175
+ }
176
+ }
177
+ function parseDiff(diffText) {
178
+ const additions = [];
179
+ const deletions = [];
180
+ const lines = diffText.split("\n");
181
+ let currentLine = 0;
182
+ for (const line of lines) {
183
+ if (line.startsWith("@@")) {
184
+ const match = line.match(/\+(\d+)/);
185
+ if (match) {
186
+ currentLine = parseInt(match[1], 10);
187
+ }
188
+ continue;
189
+ }
190
+ if (!line.startsWith("+") && !line.startsWith("-") && !line.startsWith(" ")) {
191
+ continue;
192
+ }
193
+ if (line.startsWith("+") && !line.startsWith("+++")) {
194
+ additions.push(currentLine);
195
+ currentLine++;
196
+ } else if (line.startsWith("-") && !line.startsWith("---")) {
197
+ deletions.push(currentLine);
198
+ } else {
199
+ currentLine++;
200
+ }
201
+ }
202
+ return { additions, deletions };
203
+ }
204
+
205
+ // src/differ.ts
206
+ var CoverageDiffer = class {
207
+ constructor(options) {
208
+ this.options = options;
209
+ }
210
+ /**
211
+ * 计算增量覆盖率
212
+ *
213
+ * 这是最核心的方法,完整的计算流程:
214
+ *
215
+ * 1. 加载 baseline(如果存在)
216
+ * 2. 使用 istanbul-diff 计算差异
217
+ * 3. 获取 Git 变更的文件和行号
218
+ * 4. 对每个变更文件:
219
+ * a. 获取该文件的覆盖率数据
220
+ * b. 找出变更的行号
221
+ * c. 检查这些行是否被覆盖
222
+ * d. 计算覆盖率百分比
223
+ * 5. 汇总所有文件的覆盖率
224
+ * 6. 保存新的 baseline(如果需要)
225
+ *
226
+ * @param currentCoverage - 当前的覆盖率数据
227
+ * @returns 增量覆盖率计算结果
228
+ *
229
+ * @example
230
+ * const result = await differ.calculate(coverageMap);
231
+ * console.log(`Overall coverage: ${result.overall.coverageRate}%`);
232
+ */
233
+ async calculate(currentCoverage) {
234
+ console.log("[CoverageDiffer] \u5F00\u59CB\u8BA1\u7B97\u589E\u91CF\u8986\u76D6\u7387...");
235
+ const baseline = this.loadBaseline();
236
+ const diff2 = istanbulDiff.diff(baseline || {}, currentCoverage, {
237
+ pick: "lines"
238
+ });
239
+ console.log("[CoverageDiffer] Istanbul diff \u7ED3\u679C:", diff2.total);
240
+ console.log("[CoverageDiffer] Git diff base:", this.options.gitDiffBase);
241
+ const gitDiff = await getGitDiff(this.options.gitDiffBase || "main");
242
+ console.log("[CoverageDiffer] Git diff \u8FD4\u56DE\u6587\u4EF6\u6570:", gitDiff.files.length);
243
+ console.log("[CoverageDiffer] Git diff \u6587\u4EF6\u5217\u8868:", gitDiff.files);
244
+ const result = {
245
+ overall: {
246
+ totalLines: 0,
247
+ // 总变更行数
248
+ coveredLines: 0,
249
+ // 已覆盖的变更行数
250
+ coverageRate: 100
251
+ // 覆盖率百分比(默认 100%)
252
+ },
253
+ files: [],
254
+ // 每个文件的详细信息
255
+ changedFiles: gitDiff.files,
256
+ // 变更的文件列表
257
+ timestamp: Date.now()
258
+ // 时间戳
259
+ };
260
+ const normalizedCoverage = {};
261
+ const projectRoot = process.cwd();
262
+ for (const [key, data] of Object.entries(currentCoverage)) {
263
+ const absPath = path3.isAbsolute(key) ? key : path3.resolve(projectRoot, key);
264
+ normalizedCoverage[absPath] = data;
265
+ }
266
+ let validFileCount = 0;
267
+ console.log(`[CoverageDiffer] \u5904\u7406 ${gitDiff.files.length} \u4E2A Git \u53D8\u66F4\u6587\u4EF6...`);
268
+ for (const file of gitDiff.files) {
269
+ console.log(`[CoverageDiffer] \u68C0\u67E5\u6587\u4EF6: ${file}`);
270
+ const isValid = this.isValidSourceFile(file);
271
+ console.log(`[CoverageDiffer] \u6587\u4EF6\u6709\u6548\u6027: ${isValid}`);
272
+ if (!isValid) continue;
273
+ validFileCount++;
274
+ const changedLines = gitDiff.additions[file] || [];
275
+ console.log(`[CoverageDiffer] \u53D8\u66F4\u884C\u6570: ${changedLines.length}`);
276
+ if (changedLines.length === 0) continue;
277
+ let coverage = normalizedCoverage[file];
278
+ if (!coverage) {
279
+ const relativeFile = path3.relative(projectRoot, file);
280
+ coverage = currentCoverage[relativeFile] || currentCoverage[file];
281
+ }
282
+ if (!coverage) {
283
+ result.files.push({
284
+ file,
285
+ changedLines,
286
+ uncoveredLines: changedLines,
287
+ coverageRate: 0
288
+ });
289
+ result.overall.totalLines += changedLines.length;
290
+ continue;
291
+ }
292
+ const uncoveredLines = this.getUncoveredLines(coverage, changedLines);
293
+ const coveredCount = changedLines.length - uncoveredLines.length;
294
+ const coverageRate = Math.round(coveredCount / changedLines.length * 100);
295
+ result.files.push({
296
+ file,
297
+ changedLines,
298
+ uncoveredLines,
299
+ coverageRate
300
+ });
301
+ result.overall.totalLines += changedLines.length;
302
+ result.overall.coveredLines += coveredCount;
303
+ }
304
+ if (result.overall.totalLines > 0) {
305
+ result.overall.coverageRate = Math.round(
306
+ result.overall.coveredLines / result.overall.totalLines * 100
307
+ );
308
+ }
309
+ console.log(`[CoverageDiffer] \u8BA1\u7B97\u5B8C\u6210: ${validFileCount} \u4E2A\u6709\u6548\u6587\u4EF6, ${result.overall.totalLines} \u53D8\u66F4\u884C, \u8986\u76D6\u7387 ${result.overall.coverageRate}%`);
310
+ if (this.options.autoSaveBaseline && !baseline) {
311
+ console.log("[CoverageDiffer] \u4FDD\u5B58 baseline...");
312
+ this.saveBaseline(currentCoverage);
313
+ }
314
+ return result;
315
+ }
316
+ /**
317
+ * 从文件加载 baseline 覆盖率
318
+ *
319
+ * Baseline 是参考基准,用于对比当前覆盖率的变化
320
+ * 通常在首次运行时创建,之后保持不变(除非手动更新)
321
+ *
322
+ * @returns baseline 覆盖率数据,如果文件不存在则返回 null
323
+ * @private
324
+ */
325
+ loadBaseline() {
326
+ try {
327
+ const baselinePath = this.options.baselinePath || ".coverage/baseline.json";
328
+ if (fs.existsSync(baselinePath)) {
329
+ return JSON.parse(fs.readFileSync(baselinePath, "utf-8"));
330
+ }
331
+ } catch (error) {
332
+ console.warn("[CoverageDiffer] \u52A0\u8F7D baseline \u5931\u8D25:", error);
333
+ }
334
+ return null;
335
+ }
336
+ /**
337
+ * 保存 baseline 覆盖率到文件
338
+ *
339
+ * 将当前的覆盖率数据保存为 baseline,供后续对比使用
340
+ *
341
+ * 注意:
342
+ * - 会自动创建目录(如果不存在)
343
+ * - 使用 JSON 格式,便于查看和调试
344
+ * - 格式化输出(2 空格缩进)
345
+ *
346
+ * @param coverage - 要保存的覆盖率数据
347
+ * @private
348
+ */
349
+ saveBaseline(coverage) {
350
+ try {
351
+ const baselinePath = this.options.baselinePath || ".coverage/baseline.json";
352
+ const dir = path3.dirname(baselinePath);
353
+ if (!fs.existsSync(dir)) {
354
+ fs.mkdirSync(dir, { recursive: true });
355
+ }
356
+ fs.writeFileSync(baselinePath, JSON.stringify(coverage, null, 2));
357
+ console.log("[CoverageDiffer] Baseline \u5DF2\u4FDD\u5B58");
358
+ } catch (error) {
359
+ console.error("[CoverageDiffer] \u4FDD\u5B58 baseline \u5931\u8D25:", error);
360
+ }
361
+ }
362
+ /**
363
+ * 获取未覆盖的行号列表
364
+ *
365
+ * 算法说明:
366
+ * 1. 遍历每一个变更的行号
367
+ * 2. 检查是否有语句(statement)覆盖这一行
368
+ * 3. 如果有语句覆盖且执行次数 > 0,则该行被覆盖
369
+ * 4. 否则,该行未被覆盖
370
+ *
371
+ * 为什么检查 statementMap?
372
+ * - Istanbul 的覆盖率是基于语句(statement)的
373
+ * - statementMap 记录了每个语句的位置(起始行、结束行)
374
+ * - 通过检查语句的位置,可以知道某一行是否被覆盖
375
+ *
376
+ * 为什么检查执行次数?
377
+ * - coverage.s[stmtId] 记录了语句的执行次数
378
+ * - 如果执行次数 > 0,说明这个语句被执行过
379
+ * - 如果执行次数 = 0,说明这个语句没有被执行
380
+ *
381
+ * @param coverage - 文件的覆盖率数据
382
+ * @param changedLines - 变更的行号列表
383
+ * @returns 未覆盖的行号列表
384
+ *
385
+ * @example
386
+ * const uncovered = this.getUncoveredLines(coverage, [10, 11, 12]);
387
+ * // 返回: [11] (假设第 11 行没有被覆盖)
388
+ *
389
+ * @private
390
+ */
391
+ getUncoveredLines(coverage, changedLines) {
392
+ const uncovered = [];
393
+ for (const line of changedLines) {
394
+ let isCovered = false;
395
+ for (const [stmtId, loc] of Object.entries(coverage.statementMap || {})) {
396
+ const location = loc;
397
+ if (location.start.line <= line && location.end.line >= line) {
398
+ if (coverage.s[stmtId] > 0) {
399
+ isCovered = true;
400
+ break;
401
+ }
402
+ }
403
+ }
404
+ if (!isCovered) {
405
+ uncovered.push(line);
406
+ }
407
+ }
408
+ return uncovered;
409
+ }
410
+ /**
411
+ * 判断是否为有效的源码文件
412
+ * 用于过滤 node_modules, lockfiles 和非源码文件
413
+ * @private
414
+ */
415
+ isValidSourceFile(file) {
416
+ if (file.includes("node_modules")) return false;
417
+ if (file.includes("package.json") || file.includes("pnpm-lock.yaml") || file.includes("yarn.lock")) return false;
418
+ if (file.endsWith(".d.ts")) return false;
419
+ const ext = path3.extname(file).toLowerCase();
420
+ if (!ext) return false;
421
+ const validExts = [
422
+ ".js",
423
+ ".jsx",
424
+ ".ts",
425
+ ".tsx",
426
+ ".vue",
427
+ ".svelte",
428
+ ".mjs",
429
+ ".cjs",
430
+ ".mts",
431
+ ".cts"
432
+ ];
433
+ if (!validExts.includes(ext)) return false;
434
+ const projectRoot = process.cwd();
435
+ const relativePath = path3.isAbsolute(file) ? path3.relative(projectRoot, file) : file;
436
+ if (relativePath.startsWith("..")) {
437
+ return false;
438
+ }
439
+ const { include, exclude } = this.options;
440
+ const isMatch = (pattern, target) => {
441
+ if (!pattern) return false;
442
+ let effectivePattern = pattern;
443
+ if (!pattern.startsWith("/") && !pattern.startsWith("**/")) {
444
+ effectivePattern = "**/" + pattern;
445
+ }
446
+ const regexStr = effectivePattern.replace(/\*\*/g, "<GLOBSTAR>").replace(/\*/g, "<STAR>").replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/<GLOBSTAR>/g, ".*").replace(/<STAR>/g, "[^/]*");
447
+ const regex = new RegExp(`^${regexStr}$`);
448
+ return regex.test(target) || regex.test("/" + target);
449
+ };
450
+ if (exclude) {
451
+ const excludes = Array.isArray(exclude) ? exclude : [exclude];
452
+ for (const pattern of excludes) {
453
+ if (isMatch(pattern, relativePath)) {
454
+ return false;
455
+ }
456
+ }
457
+ }
458
+ if (include) {
459
+ const includes = Array.isArray(include) ? include : [include];
460
+ let matched = false;
461
+ for (const pattern of includes) {
462
+ if (isMatch(pattern, relativePath)) {
463
+ matched = true;
464
+ break;
465
+ }
466
+ }
467
+ if (!matched) {
468
+ return false;
469
+ }
470
+ }
471
+ return true;
472
+ }
473
+ };
474
+
475
+ // src/reporter.ts
476
+ import * as fs2 from "fs";
477
+ import * as path4 from "path";
478
+ var CoverageReporter = class {
479
+ constructor(options) {
480
+ this.options = options;
481
+ this.outputDir = path4.resolve(process.cwd(), options.outputDir || ".coverage");
482
+ }
483
+ /**
484
+ * 生成增量覆盖率报告
485
+ *
486
+ * @param result 增量计算结果
487
+ * @returns 报告保存的路径
488
+ */
489
+ async generate(result) {
490
+ console.log("[CoverageReporter] \u5F00\u59CB\u751F\u6210\u62A5\u544A...");
491
+ if (!fs2.existsSync(this.outputDir)) {
492
+ fs2.mkdirSync(this.outputDir, { recursive: true });
493
+ }
494
+ result.files.forEach((fileData) => {
495
+ try {
496
+ if (fs2.existsSync(fileData.file)) {
497
+ fileData.sourceCode = fs2.readFileSync(fileData.file, "utf-8");
498
+ }
499
+ } catch (e) {
500
+ console.warn(`[CoverageReporter] \u65E0\u6CD5\u8BFB\u53D6\u6E90\u7801: ${fileData.file}`);
501
+ }
502
+ });
503
+ const html = this.renderHtml(result);
504
+ const timestamp = result.timestamp || Date.now();
505
+ const date = new Date(timestamp);
506
+ const dateStr = date.getFullYear() + String(date.getMonth() + 1).padStart(2, "0") + String(date.getDate()).padStart(2, "0") + "-" + String(date.getHours()).padStart(2, "0") + String(date.getMinutes()).padStart(2, "0") + String(date.getSeconds()).padStart(2, "0");
507
+ const latestPath = path4.join(this.outputDir, "latest.html");
508
+ const historyPath = path4.join(this.outputDir, `report-${dateStr}.html`);
509
+ try {
510
+ fs2.writeFileSync(latestPath, html, "utf-8");
511
+ fs2.writeFileSync(historyPath, html, "utf-8");
512
+ console.log(`[CoverageReporter] \u62A5\u544A\u5DF2\u751F\u6210: ${latestPath}`);
513
+ console.log(`[CoverageReporter] \u7EC6\u8282\u62A5\u544A: ${historyPath}`);
514
+ this.cleanupOldReports();
515
+ if (this.options.reportFormat === "json" || this.options.reportFormat === "both") {
516
+ const jsonPath = path4.join(this.outputDir, `report-${dateStr}.json`);
517
+ fs2.writeFileSync(jsonPath, JSON.stringify(result, null, 2), "utf-8");
518
+ }
519
+ const threshold = this.options.threshold || 80;
520
+ if (result.overall.coverageRate < threshold) {
521
+ const errorMsg = `[IncrementalCoverage] \u274C \u589E\u91CF\u8986\u76D6\u7387\u8BC4\u4F30\u672A\u901A\u8FC7: \u5F53\u524D\u4E3A ${result.overall.coverageRate}%, \u9608\u503C\u8981\u6C42\u4E3A ${threshold}%`;
522
+ console.error("\x1B[31m%s\x1B[0m", errorMsg);
523
+ if (process.env.CI || this.options.failOnError) {
524
+ throw new Error(errorMsg);
525
+ }
526
+ } else {
527
+ console.log("\x1B[32m%s\x1B[0m", `[IncrementalCoverage] \u2705 \u589E\u91CF\u8986\u76D6\u7387\u8BC4\u4F30\u901A\u8FC7: ${result.overall.coverageRate}%`);
528
+ }
529
+ return latestPath;
530
+ } catch (error) {
531
+ console.error("[CoverageReporter] \u5199\u5165\u62A5\u544A\u6587\u4EF6\u5931\u8D25:", error);
532
+ throw error;
533
+ }
534
+ }
535
+ /**
536
+ * 渲染 HTML 模板
537
+ *
538
+ * @param result 计算结果
539
+ * @private
540
+ */
541
+ renderHtml(result) {
542
+ const threshold = this.options.threshold || 80;
543
+ const isPassed = result.overall.coverageRate >= threshold;
544
+ const statusColor = isPassed ? "#4caf50" : "#f44336";
545
+ const statusText = isPassed ? "\u901A\u8FC7" : "\u672A\u8FBE\u6807";
546
+ const filesData = JSON.stringify(result.files.map((f) => ({
547
+ file: f.file,
548
+ sourceCode: f.sourceCode || "",
549
+ uncoveredLines: f.uncoveredLines
550
+ })));
551
+ return `
552
+ <!DOCTYPE html>
553
+ <html lang="zh-CN">
554
+ <head>
555
+ <meta charset="UTF-8">
556
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
557
+ <title>\u589E\u91CF\u8986\u76D6\u7387\u62A5\u544A - ${new Date(result.timestamp).toLocaleString()}</title>
558
+ <link href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css" rel="stylesheet" />
559
+ <style>
560
+ :root { --primary: #667eea; --success: #4caf50; --danger: #f44336; --warning: #f6ad55; }
561
+ * { margin: 0; padding: 0; box-sizing: border-box; }
562
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background: #f4f7f9; color: #333; line-height: 1.6; }
563
+ .container { max-width: 1200px; margin: 40px auto; background: white; border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.08); overflow: hidden; }
564
+
565
+ /* Header */
566
+ .header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 40px; }
567
+ .header h1 { font-size: 32px; font-weight: 800; margin-bottom: 8px; display: flex; align-items: center; }
568
+ .header .timestamp { opacity: 0.8; font-size: 14px; }
569
+
570
+ /* Grid */
571
+ .summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 20px; padding: 30px; border-bottom: 1px solid #eee; }
572
+ .card { background: #f8fafc; padding: 25px; border-radius: 12px; border: 1px solid #e2e8f0; text-align: center; }
573
+ .card-title { font-size: 13px; font-weight: 600; color: #64748b; text-transform: uppercase; margin-bottom: 8px; }
574
+ .card-value { font-size: 36px; font-weight: 800; color: #1e293b; }
575
+ .status-badge { padding: 4px 12px; border-radius: 20px; font-size: 14px; color: white; background: ${statusColor}; }
576
+
577
+ /* Table */
578
+ .file-list { padding: 30px; }
579
+ table { width: 100%; border-collapse: collapse; }
580
+ th { text-align: left; padding: 15px; border-bottom: 2px solid #edf2f7; color: #4a5568; }
581
+ td { padding: 15px; border-bottom: 1px solid #edf2f7; vertical-align: middle; }
582
+ .file-path { cursor: pointer; color: var(--primary); font-family: monospace; font-weight: 600; }
583
+ .file-path:hover { text-decoration: underline; }
584
+ .progress-bar { height: 6px; background: #e2e8f0; border-radius: 3px; overflow: hidden; width: 80px; display: inline-block; vertical-align: middle; margin-right: 8px; }
585
+ .uncovered-tag { font-size: 12px; color: var(--danger); background: #fff5f5; padding: 2px 6px; border-radius: 4px; cursor: pointer; }
586
+
587
+ /* Modal */
588
+ .modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.5); }
589
+ .modal-content { background-color: #fff; margin: 2% auto; width: 90%; height: 90%; border-radius: 8px; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); }
590
+ .modal-header { padding: 20px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center; background: #f8fafc; }
591
+ .close { color: #aaa; font-size: 28px; font-weight: bold; cursor: pointer; }
592
+ .close:hover { color: black; }
593
+ .code-container { flex: 1; overflow: auto; position: relative; background: #2d2d2d; }
594
+
595
+ /* Line Highlighting */
596
+ .line-highlight { display: block; width: 100%; }
597
+ .line-uncovered { background-color: rgba(244, 67, 54, 0.2); border-left: 4px solid #f44336; }
598
+ .line-covered { background-color: rgba(76, 175, 80, 0.1); border-left: 4px solid #4caf50; }
599
+
600
+ /* Prism Overrides */
601
+ pre[class*="language-"] { margin: 0; border-radius: 0; padding: 0; }
602
+ code[class*="language-"] { font-family: "JetBrains Mono", Consolas, monospace; font-size: 14px; line-height: 1.5; }
603
+ .token-line { display: block; padding: 0 1em; }
604
+ </style>
605
+ </head>
606
+ <body>
607
+ <div class="container">
608
+ <div class="header">
609
+ <h1>\u589E\u91CF\u8986\u76D6\u7387\u62A5\u544A</h1>
610
+ <div class="timestamp">\u751F\u6210\u65F6\u95F4: ${new Date(result.timestamp).toLocaleString()}</div>
611
+ </div>
612
+
613
+ <div class="summary-grid">
614
+ <div class="card">
615
+ <div class="card-title">\u589E\u91CF\u8986\u76D6\u7387</div>
616
+ <div class="card-value">${result.overall.coverageRate}%</div>
617
+ </div>
618
+ <div class="card">
619
+ <div class="card-title">\u53D8\u66F4\u603B\u884C\u6570</div>
620
+ <div class="card-value">${result.overall.totalLines}</div>
621
+ </div>
622
+ <div class="card">
623
+ <div class="card-title">\u8986\u76D6\u884C\u6570</div>
624
+ <div class="card-value">${result.overall.coveredLines}</div>
625
+ </div>
626
+ <div class="card">
627
+ <div class="card-title">\u7ED3\u679C\u72B6\u6001</div>
628
+ <div class="card-value"><span class="status-badge">${statusText}</span></div>
629
+ </div>
630
+ </div>
631
+
632
+ <div class="file-list">
633
+ <table>
634
+ <thead>
635
+ <tr>
636
+ <th>\u6587\u4EF6\u8DEF\u5F84</th>
637
+ <th width="200">\u8986\u76D6\u7387</th>
638
+ <th>\u672A\u8986\u76D6\u884C\u53F7 (\u70B9\u51FB\u8DF3\u8F6C)</th>
639
+ </tr>
640
+ </thead>
641
+ <tbody>
642
+ ${result.files.map((file, index) => {
643
+ const filePass = file.coverageRate >= threshold;
644
+ const barColor = filePass ? "#4caf50" : "#f6ad55";
645
+ return `
646
+ <tr>
647
+ <td><span class="file-path" onclick="openModal(${index})">${file.file}</span></td>
648
+ <td>
649
+ <div class="progress-bar"><div class="progress-fill" style="width: ${file.coverageRate}%; height: 100%; background: ${barColor};"></div></div>
650
+ <strong>${file.coverageRate}%</strong>
651
+ </td>
652
+ <td>
653
+ ${file.uncoveredLines.length > 0 ? file.uncoveredLines.map(
654
+ (line) => `<span class="uncovered-tag" onclick="openModal(${index}, ${line})">${line}</span>`
655
+ ).join(", ") : '<span style="color: #48bb78">\u2705 \u5168\u8986\u76D6</span>'}
656
+ </td>
657
+ </tr>`;
658
+ }).join("")}
659
+ </tbody>
660
+ </table>
661
+ </div>
662
+
663
+ <div class="footer">Generated by Incremental Coverage Plugin</div>
664
+ </div>
665
+
666
+ <!-- Code Modal -->
667
+ <div id="codeModal" class="modal">
668
+ <div class="modal-content">
669
+ <div class="modal-header">
670
+ <h3 id="modalTitle" style="font-family: monospace">FileName.js</h3>
671
+ <span class="close" onclick="closeModal()">&times;</span>
672
+ </div>
673
+ <div class="code-container">
674
+ <pre><code id="codeBlock" class="language-javascript"></code></pre>
675
+ </div>
676
+ </div>
677
+ </div>
678
+
679
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
680
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-javascript.min.js"></script>
681
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-typescript.min.js"></script>
682
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-jsx.min.js"></script>
683
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-tsx.min.js"></script>
684
+
685
+ <script>
686
+ const filesData = ${filesData};
687
+
688
+ function openModal(index, lineToScroll) {
689
+ const file = filesData[index];
690
+ const modal = document.getElementById('codeModal');
691
+ const title = document.getElementById('modalTitle');
692
+ const codeBlock = document.getElementById('codeBlock');
693
+
694
+ title.innerText = file.file;
695
+ modal.style.display = 'block';
696
+
697
+ // \u7B80\u5355\u7684 HTML \u8F6C\u4E49
698
+ const safeCode = file.sourceCode
699
+ .replace(/&/g, "&amp;")
700
+ .replace(/</g, "&lt;")
701
+ .replace(/>/g, "&gt;");
702
+
703
+ // \u5904\u7406\u4EE3\u7801\u884C\u9AD8\u4EAE
704
+ // \u4E3A\u4E86\u914D\u5408 Prism\uFF0C\u6211\u4EEC\u9700\u8981\u5148\u751F\u6210\u5E26\u884C\u53F7\u7684\u7ED3\u6784\uFF0C\u6216\u8005\u624B\u52A8\u5904\u7406
705
+ // \u7B80\u5355\u65B9\u6848\uFF1A\u5148\u7528 Prism \u9AD8\u4EAE\uFF0C\u7136\u540E split \u6210\u884C\uFF0C\u518D\u5305\u88F9 div
706
+
707
+ // 1. \u8BBE\u7F6E\u539F\u59CB\u4EE3\u7801\u8BA9 Prism \u9AD8\u4EAE
708
+ // \u6CE8\u610F\uFF1APrism.highlight \u9700\u8981\u540C\u6B65\u52A0\u8F7D\u8BED\u8A00\u5305
709
+ const ext = file.file.split('.').pop();
710
+ let lang = Prism.languages.javascript;
711
+ if (ext === 'ts' || ext === 'tsx') lang = Prism.languages.typescript;
712
+ // if (ext === 'vue') ... vue \u901A\u5E38\u5305\u542B script, \u9700\u8981\u7279\u6B8A\u5904\u7406\uFF0C\u8FD9\u91CC\u7B80\u5316\u89C6\u4E3A js/ts
713
+
714
+ const highlighted = Prism.highlight(file.sourceCode, lang, 'javascript');
715
+
716
+ // 2. \u5C06\u9AD8\u4EAE\u540E\u7684 HTML \u6309\u884C\u5206\u5272\u5E76\u5305\u88F9
717
+ const lines = highlighted.split('\\n');
718
+ const numberedHtml = lines.map((lineContent, i) => {
719
+ const lineNum = i + 1;
720
+ let className = 'token-line';
721
+ if (file.uncoveredLines.includes(lineNum)) {
722
+ className += ' line-uncovered';
723
+ }
724
+ return \`<div id="L\${lineNum}" class="\${className}">\${lineContent || '&nbsp;'}</div>\`;
725
+ }).join('');
726
+
727
+ codeBlock.innerHTML = numberedHtml;
728
+
729
+ // 3. \u6EDA\u52A8\u5230\u6307\u5B9A\u884C
730
+ if (lineToScroll) {
731
+ setTimeout(() => {
732
+ const el = document.getElementById('L' + lineToScroll);
733
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
734
+ }, 100);
735
+ }
736
+ }
737
+
738
+ function closeModal() {
739
+ document.getElementById('codeModal').style.display = 'none';
740
+ }
741
+
742
+ // \u70B9\u51FB\u7A97\u53E3\u5916\u90E8\u5173\u95ED
743
+ window.onclick = function(event) {
744
+ const modal = document.getElementById('codeModal');
745
+ if (event.target == modal) {
746
+ closeModal();
747
+ }
748
+ }
749
+ </script>
750
+ </body>
751
+ </html>
752
+ `.trim();
753
+ }
754
+ /**
755
+ * 清理超出保留数量的历史报告
756
+ * @private
757
+ */
758
+ cleanupOldReports() {
759
+ try {
760
+ if (!fs2.existsSync(this.outputDir)) return;
761
+ const files = fs2.readdirSync(this.outputDir).filter((f) => f.startsWith("report-") && (f.endsWith(".html") || f.endsWith(".json"))).map((f) => ({
762
+ name: f,
763
+ path: path4.join(this.outputDir, f),
764
+ mtime: fs2.statSync(path4.join(this.outputDir, f)).mtime.getTime()
765
+ })).sort((a, b) => b.mtime - a.mtime);
766
+ const limit = this.options.historyCount || 15;
767
+ if (files.length > limit) {
768
+ const toDelete = files.slice(limit);
769
+ console.log(`[CoverageReporter] \u6B63\u5728\u6E05\u7406\u65E7\u62A5\u544A (\u8D85\u51FA\u9650\u5236 ${limit})...`);
770
+ for (const file of toDelete) {
771
+ fs2.unlinkSync(file.path);
772
+ console.log(`[CoverageReporter] \u5DF2\u5220\u9664: ${file.name}`);
773
+ }
774
+ }
775
+ } catch (error) {
776
+ console.error("[CoverageReporter] \u6E05\u7406\u65E7\u62A5\u544A\u5931\u8D25:", error);
777
+ }
778
+ }
779
+ };
780
+
781
+ // src/plugin.ts
782
+ var defaultOptions = {
783
+ // 默认只包含 src 目录下的文件
784
+ include: ["src/**"],
785
+ // 默认排除测试文件和 node_modules
786
+ exclude: ["**/*.spec.ts", "**/*.test.ts", "**/node_modules/**"],
787
+ // 默认与 main 分支对比
788
+ gitDiffBase: "main",
789
+ // 默认输出到 .coverage 目录
790
+ outputDir: ".coverage",
791
+ // 默认生成 HTML 格式报告
792
+ reportFormat: "html",
793
+ // 默认启用浏览器 UI
794
+ enableOverlay: true,
795
+ // 默认 baseline 文件路径
796
+ baselinePath: ".coverage/baseline.json",
797
+ // 默认自动保存 baseline
798
+ autoSaveBaseline: true,
799
+ // 默认覆盖率阈值 80%
800
+ threshold: 80,
801
+ // 默认报告生成间隔 10秒
802
+ reportInterval: 1e4,
803
+ // 默认保留 15 个历史报告
804
+ historyCount: 15
805
+ };
806
+ var IncrementalCoveragePlugin = createUnplugin((userOptions = {}) => {
807
+ const options = { ...defaultOptions, ...userOptions };
808
+ const collector = new CoverageCollector();
809
+ const differ = new CoverageDiffer(options);
810
+ const reporter = new CoverageReporter(options);
811
+ let lastReportTime = 0;
812
+ let pendingResult = null;
813
+ let reportTimer = null;
814
+ console.log("[IncrementalCoverage] \u63D2\u4EF6\u5DF2\u521D\u59CB\u5316");
815
+ return {
816
+ // 插件名称
817
+ name: "incremental-coverage-plugin",
818
+ /**
819
+ * Webpack 特定的钩子
820
+ *
821
+ * 在 Webpack 环境中,需要:
822
+ * 1. 注入 babel-plugin-istanbul 配置
823
+ * 2. 设置 HTTP 中间件接收覆盖率数据
824
+ * 3. 监听编译生命周期
825
+ */
826
+ webpack(compiler) {
827
+ console.log("[IncrementalCoverage] Webpack \u6A21\u5F0F");
828
+ const rules = compiler.options.module?.rules || [];
829
+ const processed = /* @__PURE__ */ new WeakSet();
830
+ const injectBabel = (rule) => {
831
+ if (!rule || typeof rule !== "object" || processed.has(rule)) return;
832
+ processed.add(rule);
833
+ const isBabel = rule.loader && typeof rule.loader === "string" && (rule.loader.includes("babel-loader") || rule.loader === "babel-loader");
834
+ if (isBabel) {
835
+ if (!rule.options) rule.options = {};
836
+ if (typeof rule.options === "object") {
837
+ rule.options.plugins = rule.options.plugins || [];
838
+ const hasIstanbul = rule.options.plugins.some((p) => {
839
+ const name = Array.isArray(p) ? p[0] : p;
840
+ return typeof name === "string" && name.includes("istanbul");
841
+ });
842
+ if (!hasIstanbul) {
843
+ rule.options.plugins.push([
844
+ "babel-plugin-istanbul",
845
+ {
846
+ extension: [".js", ".jsx", ".ts", ".tsx", ".vue"],
847
+ include: options.include,
848
+ exclude: options.exclude || ["**/*.spec.ts", "**/*.test.ts", "**/node_modules/**"]
849
+ }
850
+ ]);
851
+ console.log("[IncrementalCoverage] \u5DF2\u6CE8\u5165\u5230 babel-loader");
852
+ }
853
+ }
854
+ }
855
+ if (rule.use) {
856
+ if (Array.isArray(rule.use)) {
857
+ rule.use.forEach((u, index) => {
858
+ if (typeof u === "object") {
859
+ injectBabel(u);
860
+ } else if (typeof u === "string" && (u.includes("babel-loader") || u === "babel-loader")) {
861
+ rule.use[index] = {
862
+ loader: u,
863
+ options: {
864
+ plugins: [[
865
+ "babel-plugin-istanbul",
866
+ {
867
+ extension: [".js", ".jsx", ".ts", ".tsx", ".vue"],
868
+ include: options.include,
869
+ exclude: options.exclude || ["**/*.spec.ts", "**/*.test.ts", "**/node_modules/**"]
870
+ }
871
+ ]]
872
+ }
873
+ };
874
+ console.log("[IncrementalCoverage] \u5DF2\u5C06\u5B57\u7B26\u4E32 babel-loader \u8F6C\u6362\u4E3A\u5BF9\u8C61\u5E76\u6CE8\u5165");
875
+ }
876
+ });
877
+ } else if (typeof rule.use === "object") {
878
+ injectBabel(rule.use);
879
+ } else if (typeof rule.use === "string" && (rule.use.includes("babel-loader") || rule.use === "babel-loader")) {
880
+ rule.use = {
881
+ loader: rule.use,
882
+ options: {
883
+ plugins: [[
884
+ "babel-plugin-istanbul",
885
+ {
886
+ extension: [".js", ".jsx", ".ts", ".tsx", ".vue"],
887
+ include: options.include,
888
+ exclude: options.exclude || ["**/*.spec.ts", "**/*.test.ts", "**/node_modules/**"]
889
+ }
890
+ ]]
891
+ }
892
+ };
893
+ console.log("[IncrementalCoverage] \u5DF2\u5C06 rule.use \u5B57\u7B26\u4E32\u8F6C\u6362\u4E3A\u5BF9\u8C61\u5E76\u6CE8\u5165");
894
+ }
895
+ }
896
+ if (rule.oneOf && Array.isArray(rule.oneOf)) {
897
+ rule.oneOf.forEach(injectBabel);
898
+ }
899
+ };
900
+ rules.forEach(injectBabel);
901
+ if (!compiler.options.devServer) {
902
+ compiler.options.devServer = {};
903
+ }
904
+ const createCoverageHandler = () => {
905
+ return async (req, res) => {
906
+ const startTime = Date.now();
907
+ const handleCoverage = async (payload) => {
908
+ let coverageData;
909
+ if (payload.data && req.headers["x-coverage-compressed"]) {
910
+ try {
911
+ const compressed = payload.data;
912
+ const decompressed = Buffer.from(compressed, "base64").toString();
913
+ coverageData = JSON.parse(decodeURIComponent(decompressed));
914
+ console.log("[IncrementalCoverage] \u6570\u636E\u5DF2\u89E3\u538B\u7F29 (\u538B\u7F29\u7387: " + Math.round((1 - compressed.length / JSON.stringify(coverageData).length) * 100) + "%)");
915
+ } catch (e) {
916
+ console.warn("[IncrementalCoverage] \u89E3\u538B\u5931\u8D25\uFF0C\u56DE\u9000\u5230\u539F\u59CB\u6570\u636E:", e);
917
+ coverageData = payload.data ? payload.data : payload;
918
+ }
919
+ } else {
920
+ coverageData = payload;
921
+ }
922
+ const merged = collector.merge(coverageData);
923
+ const result = await differ.calculate(merged);
924
+ pendingResult = result;
925
+ const now = Date.now();
926
+ const elapsed = now - lastReportTime;
927
+ const interval = options.reportInterval || 1e4;
928
+ if (elapsed >= interval) {
929
+ await reporter.generate(result);
930
+ lastReportTime = now;
931
+ if (reportTimer) {
932
+ clearTimeout(reportTimer);
933
+ reportTimer = null;
934
+ }
935
+ } else if (!reportTimer) {
936
+ console.log(`[IncrementalCoverage] \u62A5\u544A\u751F\u6210\u51B7\u5374\u4E2D\uFF0C\u5C06\u5728 ${interval - elapsed}ms \u540E\u5C1D\u8BD5...`);
937
+ reportTimer = setTimeout(async () => {
938
+ if (pendingResult) {
939
+ await reporter.generate(pendingResult);
940
+ lastReportTime = Date.now();
941
+ }
942
+ reportTimer = null;
943
+ }, interval - elapsed);
944
+ }
945
+ res.json({
946
+ status: "ok",
947
+ coverage: {
948
+ rate: result.overall.coverageRate,
949
+ coveredLines: result.overall.coveredLines,
950
+ totalLines: result.overall.totalLines,
951
+ fileCount: result.files.length
952
+ }
953
+ });
954
+ };
955
+ try {
956
+ if (req.body && typeof req.body === "object" && Object.keys(req.body).length > 0) {
957
+ await handleCoverage(req.body);
958
+ return;
959
+ }
960
+ let chunks = [];
961
+ req.on("data", (chunk) => {
962
+ chunks.push(chunk);
963
+ });
964
+ req.on("end", async () => {
965
+ try {
966
+ const body = Buffer.concat(chunks).toString();
967
+ if (!body) {
968
+ res.status(400).json({ success: false, error: "Empty body" });
969
+ return;
970
+ }
971
+ const payload = JSON.parse(body);
972
+ await handleCoverage(payload);
973
+ } catch (parseError) {
974
+ console.error("[IncrementalCoverage] \u63A5\u6536\u6570\u636E\u89E3\u6790\u5931\u8D25:", parseError);
975
+ res.status(400).json({ success: false, error: "Invalid data format" });
976
+ }
977
+ });
978
+ } catch (error) {
979
+ console.error("[IncrementalCoverage] \u4E2D\u95F4\u4EF6\u5904\u7406\u5F02\u5E38:", error);
980
+ res.status(500).json({ success: false, error: String(error) });
981
+ }
982
+ };
983
+ };
984
+ const coverageHandler = createCoverageHandler();
985
+ const hasSetupMiddlewares = "setupMiddlewares" in (compiler.options.devServer || {});
986
+ const hasBefore = "before" in (compiler.options.devServer || {});
987
+ let useSetupMiddlewares = hasSetupMiddlewares;
988
+ if (!hasSetupMiddlewares && !hasBefore) {
989
+ try {
990
+ const wdsVersion = __require("webpack-dev-server/package.json").version;
991
+ const majorVersion = parseInt(wdsVersion.split(".")[0], 10);
992
+ useSetupMiddlewares = majorVersion >= 4;
993
+ console.log(`[IncrementalCoverage] \u68C0\u6D4B\u5230 webpack-dev-server v${wdsVersion}\uFF0C\u4F7F\u7528 ${useSetupMiddlewares ? "setupMiddlewares" : "before"} API`);
994
+ } catch (e) {
995
+ console.warn("[IncrementalCoverage] \u65E0\u6CD5\u68C0\u6D4B webpack-dev-server \u7248\u672C\uFF0C\u9ED8\u8BA4\u4F7F\u7528 before API");
996
+ useSetupMiddlewares = false;
997
+ }
998
+ }
999
+ if (useSetupMiddlewares) {
1000
+ const originalSetupMiddlewares = compiler.options.devServer.setupMiddlewares;
1001
+ compiler.options.devServer.setupMiddlewares = (middlewares, devServer) => {
1002
+ if (originalSetupMiddlewares) {
1003
+ middlewares = originalSetupMiddlewares(middlewares, devServer);
1004
+ }
1005
+ devServer.app?.post("/coverage", coverageHandler);
1006
+ console.log("[IncrementalCoverage] \u4E2D\u95F4\u4EF6\u5DF2\u6CE8\u518C (webpack-dev-server 4.x+)");
1007
+ return middlewares;
1008
+ };
1009
+ } else {
1010
+ const originalBefore = compiler.options.devServer.before;
1011
+ compiler.options.devServer.before = (app, server, compiler2) => {
1012
+ if (originalBefore) {
1013
+ originalBefore(app, server, compiler2);
1014
+ }
1015
+ app.post("/coverage", coverageHandler);
1016
+ console.log("[IncrementalCoverage] \u4E2D\u95F4\u4EF6\u5DF2\u6CE8\u518C (webpack-dev-server 3.x)");
1017
+ };
1018
+ }
1019
+ compiler.hooks.compilation.tap("IncrementalCoveragePlugin", (compilation) => {
1020
+ const isWebpack4 = !compiler.webpack || !compiler.webpack.Compilation || !compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONS;
1021
+ const emitClientScript = () => {
1022
+ const clientScript = `
1023
+ (function () {
1024
+ console.log('[Coverage Client] \u542F\u52A8 v2.2 with Overlay');
1025
+
1026
+ var config = {
1027
+ endpoint: '/coverage',
1028
+ interval: 5000,
1029
+ maxRetries: 3,
1030
+ retryDelay: 1000,
1031
+ timeout: 5000,
1032
+ storageKey: '__incremental_coverage_cache__',
1033
+ enableOverlay: ${options.enableOverlay !== false}
1034
+ };
1035
+
1036
+ var state = {
1037
+ lastDataHash: '',
1038
+ isReporting: false,
1039
+ retryQueue: JSON.parse(localStorage.getItem(config.storageKey) || '[]')
1040
+ };
1041
+
1042
+ // --- Overlay Logic ---
1043
+ var overlay = null;
1044
+
1045
+ function createOverlay() {
1046
+ if (!config.enableOverlay || overlay) return;
1047
+
1048
+ var host = document.createElement('div');
1049
+ host.id = 'inc-cov-overlay-host';
1050
+ host.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:999999;font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;';
1051
+ document.body.appendChild(host);
1052
+
1053
+ var shadow = host.attachShadow({ mode: 'open' });
1054
+ var container = document.createElement('div');
1055
+ container.id = 'inc-cov-pill';
1056
+
1057
+ var style = document.createElement('style');
1058
+ style.textContent = \`
1059
+ :host { all: initial; }
1060
+ .pill {
1061
+ background: #222;
1062
+ color: #fff;
1063
+ padding: 8px 16px;
1064
+ border-radius: 99px;
1065
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
1066
+ font-size: 13px;
1067
+ font-weight: 600;
1068
+ cursor: pointer;
1069
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
1070
+ display: flex;
1071
+ align-items: center;
1072
+ gap: 10px;
1073
+ border: 1px solid rgba(255,255,255,0.1);
1074
+ backdrop-filter: blur(10px);
1075
+ -webkit-backdrop-filter: blur(10px);
1076
+ }
1077
+ .pill:hover {
1078
+ transform: translateY(-2px);
1079
+ box-shadow: 0 8px 20px rgba(0,0,0,0.25);
1080
+ background: #2a2a2a;
1081
+ }
1082
+ .dot {
1083
+ width: 8px;
1084
+ height: 8px;
1085
+ border-radius: 50%;
1086
+ background: #666;
1087
+ box-shadow: 0 0 8px rgba(255,255,255,0.2);
1088
+ transition: background 0.3s ease;
1089
+ }
1090
+ .content { display: flex; flex-direction: column; line-height: 1.2; }
1091
+ .label { font-size: 10px; opacity: 0.6; text-transform: uppercase; letter-spacing: 0.5px; }
1092
+ .value { font-size: 14px; font-variant-numeric: tabular-nums; }
1093
+ \`;
1094
+
1095
+ shadow.appendChild(style);
1096
+ shadow.appendChild(container);
1097
+
1098
+ overlay = { shadow: shadow, container: container };
1099
+ updateOverlay(null, 'init');
1100
+
1101
+ // Click to open report (optional, logic needed to know report URL or open new tab)
1102
+ // For now, maybe just log or simple alert
1103
+ container.addEventListener('click', function() {
1104
+ console.log('[Coverage Overlay] Clicked');
1105
+ });
1106
+ }
1107
+
1108
+ function updateOverlay(data, status) {
1109
+ if (!overlay) return;
1110
+ var container = overlay.container;
1111
+
1112
+ var color = '#666';
1113
+ var labelText = 'Ready';
1114
+ var valueText = 'Inc. Coverage';
1115
+
1116
+ if (status === 'init') {
1117
+ valueText = 'Connecting...';
1118
+ } else if (status === 'ok' && data) {
1119
+ var rate = data.rate;
1120
+ if (rate >= 80) color = '#10b981'; // Green
1121
+ else if (rate >= 50) color = '#f59e0b'; // Orange
1122
+ else color = '#ef4444'; // Red
1123
+
1124
+ labelText = 'Changed Lines: ' + data.totalLines;
1125
+ valueText = rate + '%';
1126
+ } else {
1127
+ color = '#ef4444';
1128
+ valueText = 'Error';
1129
+ }
1130
+
1131
+ container.innerHTML = \`
1132
+ <div class="pill">
1133
+ <div class="dot" style="background: \${color}; box-shadow: 0 0 8px \${color}66;"></div>
1134
+ <div class="content">
1135
+ <span class="label">\${labelText}</span>
1136
+ <span class="value">\${valueText}</span>
1137
+ </div>
1138
+ </div>
1139
+ \`;
1140
+ }
1141
+
1142
+ function getHash(obj) {
1143
+ return JSON.stringify(obj).length + '';
1144
+ }
1145
+
1146
+ function compressAndEncode(data) {
1147
+ try {
1148
+ return btoa(encodeURIComponent(JSON.stringify(data)));
1149
+ } catch (e) {
1150
+ console.error('[Coverage Client] \u7F16\u7801\u5931\u8D25:', e);
1151
+ return null;
1152
+ }
1153
+ }
1154
+
1155
+ function sendData(coverageData, isRetry) {
1156
+ if (state.isReporting && !isRetry) return;
1157
+
1158
+ var currentHash = getHash(coverageData);
1159
+ if (!isRetry && currentHash === state.lastDataHash) return;
1160
+
1161
+ state.isReporting = true;
1162
+ var payload = compressAndEncode(coverageData);
1163
+ if (!payload) { state.isReporting = false; return; }
1164
+
1165
+ fetch(config.endpoint, {
1166
+ method: 'POST',
1167
+ headers: { 'Content-Type': 'application/json', 'X-Coverage-Compressed': 'base64' },
1168
+ body: JSON.stringify({ data: payload }),
1169
+ signal: AbortSignal.timeout ? AbortSignal.timeout(config.timeout) : undefined
1170
+ })
1171
+ .then(function(res) {
1172
+ return res.json().then(function(json) {
1173
+ if (res.ok) { return json; }
1174
+ throw new Error(json.error || 'Server error');
1175
+ });
1176
+ })
1177
+ .then(function(json) {
1178
+ state.lastDataHash = currentHash;
1179
+
1180
+ // Update Overlay
1181
+ if (json.coverage) {
1182
+ updateOverlay(json.coverage, 'ok');
1183
+ }
1184
+
1185
+ if (state.retryQueue.length > 0) {
1186
+ var next = state.retryQueue.shift();
1187
+ localStorage.setItem(config.storageKey, JSON.stringify(state.retryQueue));
1188
+ sendData(next, true);
1189
+ }
1190
+ })
1191
+ .catch(function(err) {
1192
+ console.warn('[Coverage Client] \u4E0A\u62A5\u5931\u8D25:', err.message);
1193
+ updateOverlay(null, 'error');
1194
+ if (!isRetry) {
1195
+ state.retryQueue.push(coverageData);
1196
+ if (state.retryQueue.length > 10) state.retryQueue.shift();
1197
+ localStorage.setItem(config.storageKey, JSON.stringify(state.retryQueue));
1198
+ }
1199
+ })
1200
+ .finally(function() {
1201
+ state.isReporting = false;
1202
+ });
1203
+ }
1204
+
1205
+ // Init
1206
+ if (document.readyState === 'loading') {
1207
+ document.addEventListener('DOMContentLoaded', createOverlay);
1208
+ } else {
1209
+ createOverlay();
1210
+ }
1211
+
1212
+ setInterval(function() {
1213
+ if (window.__coverage__) {
1214
+ sendData(window.__coverage__, false);
1215
+ }
1216
+ }, config.interval);
1217
+
1218
+ window.addEventListener('beforeunload', function() {
1219
+ if (window.__coverage__ && getHash(window.__coverage__) !== state.lastDataHash) {
1220
+ var payload = compressAndEncode(window.__coverage__);
1221
+ if (payload) {
1222
+ navigator.sendBeacon(config.endpoint, JSON.stringify({ data: payload }));
1223
+ }
1224
+ }
1225
+ });
1226
+ })();
1227
+ `.trim();
1228
+ if (isWebpack4) {
1229
+ let RawSource;
1230
+ try {
1231
+ const webpack = __require("webpack");
1232
+ RawSource = webpack.sources?.RawSource;
1233
+ if (!RawSource) {
1234
+ ({ RawSource } = __require("webpack-sources"));
1235
+ }
1236
+ } catch (e) {
1237
+ try {
1238
+ ({ RawSource } = __require("webpack-sources"));
1239
+ } catch (e2) {
1240
+ console.error("[IncrementalCoverage] \u65E0\u6CD5\u52A0\u8F7D webpack-sources:", e2);
1241
+ throw new Error("webpack-sources is required for Webpack 4. Please install it: npm install webpack-sources");
1242
+ }
1243
+ }
1244
+ compilation.assets["coverage-client.js"] = new RawSource(clientScript);
1245
+ } else {
1246
+ compilation.emitAsset(
1247
+ "coverage-client.js",
1248
+ new compiler.webpack.sources.RawSource(clientScript)
1249
+ );
1250
+ }
1251
+ };
1252
+ if (isWebpack4) {
1253
+ compilation.hooks.additionalAssets.tap("IncrementalCoveragePlugin", emitClientScript);
1254
+ console.log("[IncrementalCoverage] \u4F7F\u7528 Webpack 4 API (additionalAssets)");
1255
+ } else {
1256
+ compilation.hooks.processAssets.tap(
1257
+ {
1258
+ name: "IncrementalCoveragePlugin",
1259
+ stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONS
1260
+ },
1261
+ emitClientScript
1262
+ );
1263
+ console.log("[IncrementalCoverage] \u4F7F\u7528 Webpack 5+ API (processAssets)");
1264
+ }
1265
+ const HtmlWebpackPlugin = compiler.options.plugins?.find(
1266
+ (plugin) => plugin.constructor.name === "HtmlWebpackPlugin"
1267
+ )?.constructor;
1268
+ if (HtmlWebpackPlugin) {
1269
+ HtmlWebpackPlugin.getHooks(compilation).alterAssetTagGroups.tapAsync(
1270
+ "IncrementalCoveragePlugin",
1271
+ (data, cb) => {
1272
+ data.bodyTags.push({
1273
+ tagName: "script",
1274
+ voidTag: false,
1275
+ attributes: {
1276
+ src: "/coverage-client.js"
1277
+ }
1278
+ });
1279
+ console.log("[IncrementalCoverage] \u5BA2\u6237\u7AEF\u811A\u672C\u5DF2\u81EA\u52A8\u6CE8\u5165");
1280
+ cb(null, data);
1281
+ }
1282
+ );
1283
+ } else {
1284
+ console.warn("[IncrementalCoverage] \u672A\u627E\u5230 HtmlWebpackPlugin\uFF0C\u5BA2\u6237\u7AEF\u811A\u672C\u9700\u8981\u624B\u52A8\u5F15\u5165");
1285
+ }
1286
+ });
1287
+ const finalReport = async () => {
1288
+ if (pendingResult) {
1289
+ console.log("[IncrementalCoverage] \u6B63\u5728\u751F\u6210\u9000\u51FA\u524D\u7684\u6700\u7EC8\u62A5\u544A...");
1290
+ try {
1291
+ await reporter.generate(pendingResult);
1292
+ } catch (e) {
1293
+ console.error("[IncrementalCoverage] \u9000\u51FA\u524D\u751F\u6210\u62A5\u544A\u5931\u8D25:", e);
1294
+ }
1295
+ pendingResult = null;
1296
+ }
1297
+ };
1298
+ process.once("SIGINT", async () => {
1299
+ await finalReport();
1300
+ process.exit(0);
1301
+ });
1302
+ process.once("SIGTERM", async () => {
1303
+ await finalReport();
1304
+ process.exit(0);
1305
+ });
1306
+ },
1307
+ /**
1308
+ * Vite 特定的钩子
1309
+ *
1310
+ * 在 Vite 环境中,需要:
1311
+ * 1. 通过 transform 钩子注入 babel-plugin-istanbul
1312
+ * 2. 通过 configureServer 添加中间件
1313
+ * 3. 支持 HMR(热模块替换)
1314
+ */
1315
+ vite: {
1316
+ // 只在开发模式下应用
1317
+ apply: "serve",
1318
+ /**
1319
+ * 配置解析完成后的钩子
1320
+ *
1321
+ * 在这里可以访问完整的 Vite 配置
1322
+ */
1323
+ configResolved(config) {
1324
+ console.log("[IncrementalCoverage] Vite \u6A21\u5F0F");
1325
+ },
1326
+ /**
1327
+ * 配置开发服务器的钩子
1328
+ *
1329
+ * 在这里添加自定义中间件
1330
+ */
1331
+ configureServer(server) {
1332
+ }
1333
+ },
1334
+ /**
1335
+ * 构建开始时的钩子
1336
+ *
1337
+ * 可以在这里进行初始化工作
1338
+ */
1339
+ buildStart() {
1340
+ console.log("[IncrementalCoverage] \u6784\u5EFA\u5F00\u59CB");
1341
+ },
1342
+ /**
1343
+ * 构建结束时的钩子
1344
+ *
1345
+ * 可以在这里进行清理工作
1346
+ */
1347
+ buildEnd() {
1348
+ console.log("[IncrementalCoverage] \u6784\u5EFA\u7ED3\u675F");
1349
+ }
1350
+ };
1351
+ });
1352
+
1353
+ // src/webpack.ts
1354
+ var webpack_default = IncrementalCoveragePlugin.webpack;
1355
+ var WebpackIncrementalCoveragePlugin = IncrementalCoveragePlugin.webpack;
1356
+ export {
1357
+ WebpackIncrementalCoveragePlugin,
1358
+ webpack_default as default
1359
+ };
1360
+ //# sourceMappingURL=webpack.mjs.map