@justinmiehle/reporter-vitest 0.0.6 → 0.0.7

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/dist/index.d.ts CHANGED
@@ -16,5 +16,13 @@ export default class PanoptesReporter implements Reporter {
16
16
  onTestCaseResult(test: any): void;
17
17
  onTestRunEnd(): Promise<void>;
18
18
  private mapStatus;
19
+ private readCodeSnippet;
20
+ private getDiagnostic;
21
+ private getArtifacts;
22
+ /**
23
+ * Read Istanbul-style coverage from coverage/coverage-final.json (or PANOPTES_COVERAGE_PATH).
24
+ * Enable coverage in Vitest (e.g. coverage: { provider: 'v8', reporter: ['json'] }) for LOC to be sent.
25
+ */
26
+ private readCoverage;
19
27
  }
20
28
  export {};
package/dist/index.js CHANGED
@@ -6,6 +6,10 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
6
6
  });
7
7
 
8
8
  // src/index.ts
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+ var CODE_SNIPPET_CONTEXT_LINES = 10;
12
+ var DEFAULT_COVERAGE_PATH = "coverage/coverage-final.json";
9
13
  function getCommitSha() {
10
14
  if (process.env.GITHUB_SHA) {
11
15
  return process.env.GITHUB_SHA;
@@ -45,11 +49,22 @@ var PanoptesReporter = class {
45
49
  }
46
50
  // biome-ignore lint/suspicious/noExplicitAny: Vitest reporter interface doesn't provide strict types
47
51
  onTestCaseResult(test) {
52
+ const filePath = test.file?.name || test.filepath || "unknown";
53
+ const line = test.file?.line ?? test.location?.line;
54
+ const codeSnippet = this.readCodeSnippet(filePath, line);
55
+ const diagnostic = this.getDiagnostic(test);
56
+ const artifactsList = this.getArtifacts(test);
57
+ const metadata = {
58
+ type: test.type,
59
+ mode: test.mode,
60
+ ...diagnostic && { diagnostic },
61
+ ...artifactsList && artifactsList.length > 0 && { artifacts: artifactsList }
62
+ };
48
63
  const testResult = {
49
64
  name: test.name || test.title || "Unknown test",
50
- file: test.file?.name || test.filepath || "unknown",
51
- line: test.file?.line,
52
- column: test.file?.column,
65
+ file: filePath,
66
+ line,
67
+ column: test.file?.column ?? test.location?.column,
53
68
  status: this.mapStatus(test.status),
54
69
  duration: test.duration || 0,
55
70
  error: test.error?.message,
@@ -57,10 +72,8 @@ var PanoptesReporter = class {
57
72
  retries: test.retryCount,
58
73
  suite: test.suite?.name,
59
74
  tags: test.meta?.tags,
60
- metadata: {
61
- type: test.type,
62
- mode: test.mode
63
- }
75
+ codeSnippet,
76
+ metadata
64
77
  };
65
78
  this.tests.push(testResult);
66
79
  if (test.suite) {
@@ -98,6 +111,7 @@ var PanoptesReporter = class {
98
111
  skippedTests: skipped
99
112
  };
100
113
  });
114
+ const coverage = this.readCoverage();
101
115
  const testRun = {
102
116
  projectName: this.options.projectName,
103
117
  framework: "vitest",
@@ -114,7 +128,8 @@ var PanoptesReporter = class {
114
128
  ci: this.options.ci,
115
129
  commitSha: getCommitSha(),
116
130
  tests: this.tests,
117
- suites: suiteData
131
+ suites: suiteData,
132
+ ...coverage && { coverage }
118
133
  };
119
134
  if (!this.options.convexUrl) {
120
135
  console.warn("[Panoptes] CONVEX_URL not set. Test results will not be sent.");
@@ -154,6 +169,125 @@ var PanoptesReporter = class {
154
169
  return "running";
155
170
  }
156
171
  }
172
+ readCodeSnippet(filePath, line) {
173
+ if (line == null) return void 0;
174
+ let absPath;
175
+ try {
176
+ absPath = path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath);
177
+ if (!fs.existsSync(absPath)) return void 0;
178
+ } catch {
179
+ return void 0;
180
+ }
181
+ try {
182
+ const content = fs.readFileSync(absPath, "utf-8");
183
+ const lines = content.split("\n");
184
+ const start = Math.max(0, line - 1 - CODE_SNIPPET_CONTEXT_LINES);
185
+ const end = Math.min(lines.length, line + CODE_SNIPPET_CONTEXT_LINES);
186
+ const slice = lines.slice(start, end).join("\n");
187
+ const ext = path.extname(filePath).toLowerCase();
188
+ const language = ext === ".ts" || ext === ".tsx" ? "typescript" : ext === ".js" || ext === ".jsx" ? "javascript" : void 0;
189
+ return {
190
+ startLine: start + 1,
191
+ endLine: end,
192
+ content: slice,
193
+ language
194
+ };
195
+ } catch {
196
+ return void 0;
197
+ }
198
+ }
199
+ // biome-ignore lint/suspicious/noExplicitAny: Vitest reporter interface
200
+ getDiagnostic(test) {
201
+ try {
202
+ if (typeof test.diagnostic !== "function") return void 0;
203
+ const d = test.diagnostic();
204
+ if (!d) return void 0;
205
+ return {
206
+ duration: d.duration,
207
+ slow: d.slow,
208
+ flaky: d.flaky,
209
+ retryCount: d.retryCount,
210
+ repeatCount: d.repeatCount,
211
+ heap: d.heap,
212
+ startTime: d.startTime
213
+ };
214
+ } catch {
215
+ return void 0;
216
+ }
217
+ }
218
+ // biome-ignore lint/suspicious/noExplicitAny: Vitest reporter interface; artifacts() is experimental
219
+ getArtifacts(test) {
220
+ try {
221
+ if (typeof test.artifacts !== "function") return void 0;
222
+ const list = test.artifacts();
223
+ if (!Array.isArray(list) || list.length === 0) return void 0;
224
+ return list.map((a) => ({ type: a?.type ?? "unknown" }));
225
+ } catch {
226
+ return void 0;
227
+ }
228
+ }
229
+ /**
230
+ * Read Istanbul-style coverage from coverage/coverage-final.json (or PANOPTES_COVERAGE_PATH).
231
+ * Enable coverage in Vitest (e.g. coverage: { provider: 'v8', reporter: ['json'] }) for LOC to be sent.
232
+ */
233
+ readCoverage() {
234
+ const coveragePath = process.env.PANOPTES_COVERAGE_PATH ?? path.resolve(process.cwd(), DEFAULT_COVERAGE_PATH);
235
+ try {
236
+ if (!fs.existsSync(coveragePath)) return void 0;
237
+ const raw = fs.readFileSync(coveragePath, "utf-8");
238
+ const data = JSON.parse(raw);
239
+ const files = {};
240
+ let totalLines = 0;
241
+ let coveredLines = 0;
242
+ for (const [filePath, entry] of Object.entries(data)) {
243
+ if (!entry || typeof entry !== "object") continue;
244
+ const statementCounts = entry.s ?? {};
245
+ const statementMap = entry.statementMap;
246
+ let linesTotal = 0;
247
+ let linesCovered = 0;
248
+ if (statementMap && typeof statementMap === "object") {
249
+ const lineHits = /* @__PURE__ */ new Map();
250
+ for (const [id, loc] of Object.entries(statementMap)) {
251
+ if (!loc?.start?.line) continue;
252
+ const count = statementCounts[id] ?? 0;
253
+ const line = loc.start.line;
254
+ lineHits.set(line, (lineHits.get(line) ?? 0) + count);
255
+ }
256
+ linesTotal = lineHits.size;
257
+ for (const hits of Array.from(lineHits.values())) {
258
+ if (hits > 0) linesCovered += 1;
259
+ }
260
+ }
261
+ const statementsTotal = Object.keys(statementCounts).length;
262
+ const statementsCovered = Object.values(statementCounts).filter(
263
+ (c) => typeof c === "number" && c > 0
264
+ ).length;
265
+ if (statementsTotal > 0 && linesTotal === 0) {
266
+ linesTotal = statementsTotal;
267
+ linesCovered = statementsCovered;
268
+ }
269
+ totalLines += linesTotal;
270
+ coveredLines += linesCovered;
271
+ const relPath = path.isAbsolute(filePath) ? path.relative(process.cwd(), filePath) : filePath;
272
+ files[relPath] = {
273
+ linesCovered,
274
+ linesTotal: linesTotal || 1,
275
+ statementsCovered: statementsTotal > 0 ? statementsCovered : void 0,
276
+ statementsTotal: statementsTotal > 0 ? statementsTotal : void 0
277
+ };
278
+ }
279
+ if (Object.keys(files).length === 0) return void 0;
280
+ return {
281
+ summary: {
282
+ lines: { total: totalLines, covered: coveredLines },
283
+ statements: { total: totalLines, covered: coveredLines }
284
+ },
285
+ files
286
+ };
287
+ } catch {
288
+ return void 0;
289
+ }
290
+ }
157
291
  };
158
292
  export {
159
293
  PanoptesReporter as default
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@justinmiehle/reporter-vitest",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "description": "Panoptes reporter for Vitest test results",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -26,7 +26,7 @@
26
26
  "access": "public"
27
27
  },
28
28
  "dependencies": {
29
- "@justinmiehle/shared": "0.0.2"
29
+ "@justinmiehle/shared": "workspace:*"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/node": "^20.10.0",
package/src/index.ts CHANGED
@@ -1,6 +1,12 @@
1
- import type { TestResult, TestRunIngest } from "@justinmiehle/shared";
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import type { CoverageIngest, FileCoverage, TestResult, TestRunIngest } from "@justinmiehle/shared";
2
5
  import type { Reporter } from "vitest/reporters";
3
6
 
7
+ const CODE_SNIPPET_CONTEXT_LINES = 10;
8
+ const DEFAULT_COVERAGE_PATH = "coverage/coverage-final.json";
9
+
4
10
  interface PanoptesReporterOptions {
5
11
  convexUrl?: string;
6
12
  projectName?: string;
@@ -30,6 +36,20 @@ function getCommitSha(): string | undefined {
30
36
  }
31
37
  }
32
38
 
39
+ function getTriggeredBy(): string | undefined {
40
+ // CI: use provider's actor/username
41
+ if (process.env.GITHUB_ACTOR) return process.env.GITHUB_ACTOR;
42
+ if (process.env.CIRCLE_USERNAME) return process.env.CIRCLE_USERNAME;
43
+ if (process.env.GITLAB_USER_LOGIN) return process.env.GITLAB_USER_LOGIN;
44
+ // Local: machine username
45
+ if (process.env.USER) return process.env.USER;
46
+ try {
47
+ return os.userInfo().username;
48
+ } catch {
49
+ return undefined;
50
+ }
51
+ }
52
+
33
53
  export default class PanoptesReporter implements Reporter {
34
54
  private options: Required<PanoptesReporterOptions>;
35
55
  private startTime = 0;
@@ -56,11 +76,23 @@ export default class PanoptesReporter implements Reporter {
56
76
 
57
77
  // biome-ignore lint/suspicious/noExplicitAny: Vitest reporter interface doesn't provide strict types
58
78
  onTestCaseResult(test: any) {
79
+ const filePath = test.file?.name || test.filepath || "unknown";
80
+ const line = test.file?.line ?? test.location?.line;
81
+ const codeSnippet = this.readCodeSnippet(filePath, line);
82
+ const diagnostic = this.getDiagnostic(test);
83
+ const artifactsList = this.getArtifacts(test);
84
+ const metadata: Record<string, unknown> = {
85
+ type: test.type,
86
+ mode: test.mode,
87
+ ...(diagnostic && { diagnostic: diagnostic }),
88
+ ...(artifactsList && artifactsList.length > 0 && { artifacts: artifactsList }),
89
+ };
90
+
59
91
  const testResult: TestResult = {
60
92
  name: test.name || test.title || "Unknown test",
61
- file: test.file?.name || test.filepath || "unknown",
62
- line: test.file?.line,
63
- column: test.file?.column,
93
+ file: filePath,
94
+ line,
95
+ column: test.file?.column ?? test.location?.column,
64
96
  status: this.mapStatus(test.status),
65
97
  duration: test.duration || 0,
66
98
  error: test.error?.message,
@@ -68,10 +100,8 @@ export default class PanoptesReporter implements Reporter {
68
100
  retries: test.retryCount,
69
101
  suite: test.suite?.name,
70
102
  tags: test.meta?.tags,
71
- metadata: {
72
- type: test.type,
73
- mode: test.mode,
74
- },
103
+ codeSnippet,
104
+ metadata,
75
105
  };
76
106
 
77
107
  this.tests.push(testResult);
@@ -119,6 +149,8 @@ export default class PanoptesReporter implements Reporter {
119
149
  };
120
150
  });
121
151
 
152
+ const coverage = this.readCoverage();
153
+
122
154
  const testRun: TestRunIngest = {
123
155
  projectName: this.options.projectName,
124
156
  framework: "vitest",
@@ -135,6 +167,7 @@ export default class PanoptesReporter implements Reporter {
135
167
  commitSha: getCommitSha(),
136
168
  tests: this.tests,
137
169
  suites: suiteData,
170
+ ...(coverage && { coverage }),
138
171
  };
139
172
 
140
173
  if (!this.options.convexUrl) {
@@ -178,4 +211,151 @@ export default class PanoptesReporter implements Reporter {
178
211
  return "running";
179
212
  }
180
213
  }
214
+
215
+ private readCodeSnippet(filePath: string, line: number | undefined): TestResult["codeSnippet"] {
216
+ if (line == null) return undefined;
217
+ let absPath: string;
218
+ try {
219
+ absPath = path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath);
220
+ if (!fs.existsSync(absPath)) return undefined;
221
+ } catch {
222
+ return undefined;
223
+ }
224
+ try {
225
+ const content = fs.readFileSync(absPath, "utf-8");
226
+ const lines = content.split("\n");
227
+ const start = Math.max(0, line - 1 - CODE_SNIPPET_CONTEXT_LINES);
228
+ const end = Math.min(lines.length, line + CODE_SNIPPET_CONTEXT_LINES);
229
+ const slice = lines.slice(start, end).join("\n");
230
+ const ext = path.extname(filePath).toLowerCase();
231
+ const language =
232
+ ext === ".ts" || ext === ".tsx"
233
+ ? "typescript"
234
+ : ext === ".js" || ext === ".jsx"
235
+ ? "javascript"
236
+ : undefined;
237
+ return {
238
+ startLine: start + 1,
239
+ endLine: end,
240
+ content: slice,
241
+ language,
242
+ };
243
+ } catch {
244
+ return undefined;
245
+ }
246
+ }
247
+
248
+ // biome-ignore lint/suspicious/noExplicitAny: Vitest reporter interface
249
+ private getDiagnostic(test: any): Record<string, unknown> | undefined {
250
+ try {
251
+ if (typeof test.diagnostic !== "function") return undefined;
252
+ const d = test.diagnostic();
253
+ if (!d) return undefined;
254
+ return {
255
+ duration: d.duration,
256
+ slow: d.slow,
257
+ flaky: d.flaky,
258
+ retryCount: d.retryCount,
259
+ repeatCount: d.repeatCount,
260
+ heap: d.heap,
261
+ startTime: d.startTime,
262
+ };
263
+ } catch {
264
+ return undefined;
265
+ }
266
+ }
267
+
268
+ // biome-ignore lint/suspicious/noExplicitAny: Vitest reporter interface; artifacts() is experimental
269
+ private getArtifacts(test: any): Array<{ type: string }> | undefined {
270
+ try {
271
+ if (typeof test.artifacts !== "function") return undefined;
272
+ const list = test.artifacts();
273
+ if (!Array.isArray(list) || list.length === 0) return undefined;
274
+ return list.map((a: { type?: string }) => ({ type: a?.type ?? "unknown" }));
275
+ } catch {
276
+ return undefined;
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Read Istanbul-style coverage from coverage/coverage-final.json (or PANOPTES_COVERAGE_PATH).
282
+ * Enable coverage in Vitest (e.g. coverage: { provider: 'v8', reporter: ['json'] }) for LOC to be sent.
283
+ */
284
+ private readCoverage(): CoverageIngest | undefined {
285
+ const coveragePath =
286
+ process.env.PANOPTES_COVERAGE_PATH ?? path.resolve(process.cwd(), DEFAULT_COVERAGE_PATH);
287
+ try {
288
+ if (!fs.existsSync(coveragePath)) return undefined;
289
+ const raw = fs.readFileSync(coveragePath, "utf-8");
290
+ const data = JSON.parse(raw) as Record<
291
+ string,
292
+ {
293
+ s?: Record<string, number>;
294
+ b?: Record<string, number[]>;
295
+ f?: Record<string, number>;
296
+ statementMap?: Record<string, unknown>;
297
+ branchMap?: Record<string, unknown>;
298
+ fnMap?: Record<string, unknown>;
299
+ }
300
+ >;
301
+ const files: Record<string, FileCoverage> = {};
302
+ let totalLines = 0;
303
+ let coveredLines = 0;
304
+ for (const [filePath, entry] of Object.entries(data)) {
305
+ if (!entry || typeof entry !== "object") continue;
306
+ // Istanbul does not always have 'l' (line counts); derive from statementMap + s if needed
307
+ const statementCounts = entry.s ?? {};
308
+ const statementMap = (
309
+ entry as {
310
+ statementMap?: Record<string, { start: { line: number }; end: { line: number } }>;
311
+ }
312
+ ).statementMap;
313
+ let linesTotal = 0;
314
+ let linesCovered = 0;
315
+ if (statementMap && typeof statementMap === "object") {
316
+ const lineHits = new Map<number, number>();
317
+ for (const [id, loc] of Object.entries(statementMap)) {
318
+ if (!loc?.start?.line) continue;
319
+ const count = statementCounts[id] ?? 0;
320
+ const line = loc.start.line;
321
+ lineHits.set(line, (lineHits.get(line) ?? 0) + count);
322
+ }
323
+ linesTotal = lineHits.size;
324
+ for (const hits of Array.from(lineHits.values())) {
325
+ if (hits > 0) linesCovered += 1;
326
+ }
327
+ }
328
+ const statementsTotal = Object.keys(statementCounts).length;
329
+ const statementsCovered = Object.values(statementCounts).filter(
330
+ (c) => typeof c === "number" && c > 0
331
+ ).length;
332
+ if (statementsTotal > 0 && linesTotal === 0) {
333
+ linesTotal = statementsTotal;
334
+ linesCovered = statementsCovered;
335
+ }
336
+ totalLines += linesTotal;
337
+ coveredLines += linesCovered;
338
+ // Use relative path for consistency
339
+ const relPath = path.isAbsolute(filePath)
340
+ ? path.relative(process.cwd(), filePath)
341
+ : filePath;
342
+ files[relPath] = {
343
+ linesCovered,
344
+ linesTotal: linesTotal || 1,
345
+ statementsCovered: statementsTotal > 0 ? statementsCovered : undefined,
346
+ statementsTotal: statementsTotal > 0 ? statementsTotal : undefined,
347
+ };
348
+ }
349
+ if (Object.keys(files).length === 0) return undefined;
350
+ return {
351
+ summary: {
352
+ lines: { total: totalLines, covered: coveredLines },
353
+ statements: { total: totalLines, covered: coveredLines },
354
+ },
355
+ files,
356
+ };
357
+ } catch {
358
+ return undefined;
359
+ }
360
+ }
181
361
  }