@epicat/toon-reporter 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # toon-reporter
2
2
 
3
- A minimal Vitest reporter optimized for LLM consumption. Outputs test results in a compact, token-efficient format.
3
+ A minimal Vitest and Playwright reporter optimized for LLM consumption. Outputs test results in a compact, token-efficient format.
4
4
 
5
5
  ## Installation
6
6
 
@@ -10,13 +10,15 @@ npm install @epicat/toon-reporter
10
10
 
11
11
  ## Usage
12
12
 
13
- ### CLI
13
+ ### Vitest
14
+
15
+ #### CLI
14
16
 
15
17
  ```bash
16
18
  npx vitest run --reporter=@epicat/toon-reporter
17
19
  ```
18
20
 
19
- ### Config
21
+ #### Config
20
22
 
21
23
  ```ts
22
24
  // vitest.config.ts
@@ -29,6 +31,34 @@ export default defineConfig({
29
31
  })
30
32
  ```
31
33
 
34
+ ### Playwright
35
+
36
+ #### Config
37
+
38
+ ```ts
39
+ // playwright.config.ts
40
+ import { defineConfig } from '@playwright/test'
41
+
42
+ export default defineConfig({
43
+ reporter: [['@epicat/toon-reporter/playwright']],
44
+ })
45
+ ```
46
+
47
+ #### With options
48
+
49
+ ```ts
50
+ // playwright.config.ts
51
+ import { defineConfig } from '@playwright/test'
52
+
53
+ export default defineConfig({
54
+ reporter: [
55
+ ['@epicat/toon-reporter/playwright', {
56
+ outputFile: 'test-results.toon'
57
+ }]
58
+ ],
59
+ })
60
+ ```
61
+
32
62
  ## Output Format
33
63
 
34
64
  ### All tests passing
@@ -37,7 +67,7 @@ export default defineConfig({
37
67
  passing: 42
38
68
  ```
39
69
 
40
- ### With failures
70
+ ### With failures (Vitest)
41
71
 
42
72
  ```
43
73
  passing: 40
@@ -49,7 +79,15 @@ failing[2]:
49
79
  error: TypeError: Cannot read property 'id' of undefined
50
80
  ```
51
81
 
52
- ### With parameterized test failures
82
+ ### With failures (Playwright)
83
+
84
+ ```
85
+ passing: 1
86
+ failing[1]{at,expected,got}:
87
+ "login.spec.ts:7:42",Welcome,Hello World
88
+ ```
89
+
90
+ ### With parameterized test failures (Vitest)
53
91
 
54
92
  Uses TOON tabular format for uniform parameter arrays:
55
93
 
@@ -69,16 +107,60 @@ Uses TOON tabular format for uniform arrays:
69
107
  ```
70
108
  passing: 38
71
109
  todo[1]{at,name}:
72
- src/api.test.ts,implement error handling
110
+ "src/api.test.ts:15:3",implement error handling
73
111
  skipped[2]{at,name}:
74
- src/utils.test.ts,handles edge case
112
+ "src/utils.test.ts:8:3",handles edge case
113
+ ```
114
+
115
+ ### With flaky tests (Playwright)
116
+
117
+ Tests that fail initially but pass on retry are reported as flaky:
118
+
119
+ ```
120
+ passing: 5
121
+ flaky[1]{at,name,retries}:
122
+ "checkout.spec.ts:12:3",should complete payment,2
123
+ ```
124
+
125
+ ### With coverage (Vitest only)
126
+
127
+ Coverage is automatically included when running with `--coverage`. No extra configuration needed:
128
+
129
+ ```bash
130
+ npx vitest run --coverage --reporter=@epicat/toon-reporter
131
+ ```
132
+
133
+ Output includes total percentages and uncovered lines per file:
134
+
135
+ ```
136
+ passing: 8
137
+ coverage:
138
+ "total%":
139
+ lines: 60.99
140
+ stmts: 58.82
141
+ branch: 44.34
142
+ funcs: 57.57
143
+ files[1]{file,uncoveredLines}:
144
+ src/toon-reporter.ts,"12-14,19-23,32,99"
145
+ ```
146
+
147
+ - **Total percentages**: Help teams track coverage thresholds
148
+ - **Per-file uncovered lines**: Give LLMs actionable info to improve coverage
149
+ - **100% covered files are hidden** by default to reduce noise
150
+
151
+ With `verbose: true`, all files appear with per-file percentages:
152
+
153
+ ```
154
+ files[2]{file,uncoveredLines,"lines%","stmts%","branch%","funcs%"}:
155
+ src/toon-reporter.ts,"12-14,19-23,32,99",56.89,55.63,43.63,61.53
156
+ test/test-utils.ts,"",100,100,100,100
75
157
  ```
76
158
 
77
159
  ## Colors
78
160
 
79
161
  - **Green**: `passing` count
80
162
  - **Red**: `failing` header
81
- - **Yellow**: file paths
163
+ - **Yellow**: `flaky` header, file paths
82
164
  - **Gray**: `skipped` tests
83
165
  - **Cyan**: `todo` tests
84
166
 
@@ -119,11 +201,21 @@ COLOR=1 npx vitest run --reporter=@epicat/toon-reporter
119
201
  Write report to a file instead of stdout.
120
202
 
121
203
  ```ts
122
- reporters: [['toon-reporter', { outputFile: 'test-results.txt' }]]
204
+ reporters: [['@epicat/toon-reporter', { outputFile: 'test-results.txt' }]]
205
+ ```
206
+
207
+ ### `verbose`
208
+
209
+ Include per-file coverage percentages (lines, stmts, branch, funcs) alongside uncovered lines.
210
+
211
+ ```ts
212
+ reporters: [new ToonReporter({ verbose: true })]
123
213
  ```
124
214
 
125
215
  ## Skipped/Todo Line Numbers
126
216
 
217
+ ### Vitest
218
+
127
219
  To get line:column information for skipped and todo tests, enable `includeTaskLocation` in your vitest config:
128
220
 
129
221
  ```ts
@@ -131,7 +223,7 @@ To get line:column information for skipped and todo tests, enable `includeTaskLo
131
223
  export default defineConfig({
132
224
  test: {
133
225
  includeTaskLocation: true,
134
- reporters: ['toon-reporter'],
226
+ reporters: ['@epicat/toon-reporter'],
135
227
  },
136
228
  })
137
229
  ```
@@ -139,18 +231,40 @@ export default defineConfig({
139
231
  Or via CLI:
140
232
 
141
233
  ```bash
142
- npx vitest run --reporter=toon-reporter --includeTaskLocation
234
+ npx vitest run --reporter=@epicat/toon-reporter --includeTaskLocation
143
235
  ```
144
236
 
145
237
  Without this option, skipped/todo tests will only show the file path (not line:column). This is a Vitest limitation - test locations are only collected when this config is enabled before test collection.
146
238
 
239
+ ### Playwright
240
+
241
+ Playwright always includes test locations. Tests marked with `test.fixme()` are reported as `todo`, while `test.skip()` tests are reported as `skipped`.
242
+
243
+ ## Playwright-Specific Features
244
+
245
+ ### Flaky Test Detection
246
+
247
+ When `retries` is configured in your Playwright config, tests that fail initially but pass on retry are reported as flaky:
248
+
249
+ ```ts
250
+ // playwright.config.ts
251
+ export default defineConfig({
252
+ retries: 2,
253
+ reporter: [['@epicat/toon-reporter/playwright']],
254
+ })
255
+ ```
256
+
257
+ Output: `flaky[1]{at,name,retries}: "test.spec.ts:5:3",should work,1`
258
+
147
259
  ## Why?
148
260
 
149
261
  Traditional test reporters output verbose information optimized for human readability. When feeding test results to an LLM for automated fixing, this verbosity wastes tokens. This reporter outputs only what's needed:
150
262
 
151
263
  - Pass count
152
264
  - Failure locations with expected/got values
265
+ - Flaky test detection with retry counts (Playwright)
153
266
  - Skipped/todo test names for context
267
+ - Coverage totals and uncovered lines (Vitest with `--coverage`)
154
268
 
155
269
  ## Token Efficiency
156
270
 
package/dist/index.d.mts CHANGED
@@ -5,6 +5,8 @@ import { Reporter, TestRunEndReason, Vitest } from "vitest/node";
5
5
  interface ToonReporterOptions {
6
6
  outputFile?: string;
7
7
  color?: boolean;
8
+ /** Include per-file coverage percentages (lines, stmts, branch, funcs) */
9
+ verbose?: boolean;
8
10
  /** @internal Used for testing to capture output */
9
11
  _captureOutput?: (output: string) => void;
10
12
  }
@@ -13,8 +15,12 @@ declare class ToonReporter implements Reporter {
13
15
  ctx: Vitest;
14
16
  options: ToonReporterOptions;
15
17
  private useColor;
18
+ private coverageMap;
16
19
  constructor(options?: ToonReporterOptions);
17
20
  onInit(ctx: Vitest): void;
21
+ onCoverage(coverage: unknown): void;
22
+ private getCoverageSummary;
23
+ private formatLineRanges;
18
24
  private formatLocation;
19
25
  private parseErrorLocation;
20
26
  private parseExpectedGot;
package/dist/index.mjs CHANGED
@@ -54,6 +54,63 @@ var ToonReporter = class {
54
54
  onInit(ctx) {
55
55
  this.ctx = ctx;
56
56
  this.start = Date.now();
57
+ this.coverageMap = void 0;
58
+ const coverage = ctx.config.coverage;
59
+ if (coverage?.reporter) coverage.reporter = [];
60
+ }
61
+ onCoverage(coverage) {
62
+ this.coverageMap = coverage;
63
+ }
64
+ getCoverageSummary() {
65
+ if (!this.coverageMap) return void 0;
66
+ const map = this.coverageMap;
67
+ if (typeof map.getCoverageSummary !== "function") return void 0;
68
+ const summary = map.getCoverageSummary().toJSON();
69
+ const rootDir = this.ctx.config.root;
70
+ const result = { "total%": {
71
+ lines: summary.lines?.pct ?? 0,
72
+ stmts: summary.statements?.pct ?? 0,
73
+ branch: summary.branches?.pct ?? 0,
74
+ funcs: summary.functions?.pct ?? 0
75
+ } };
76
+ if (map.files && map.fileCoverageFor) {
77
+ const entries = [];
78
+ const verbose = this.options.verbose;
79
+ for (const file of map.files()) {
80
+ const fc = map.fileCoverageFor(file);
81
+ const uncoveredLines = fc.getUncoveredLines();
82
+ const hasGaps = uncoveredLines.length > 0;
83
+ if (!verbose && !hasGaps) continue;
84
+ const entry = {
85
+ file: relative(rootDir, file),
86
+ uncoveredLines: this.formatLineRanges(uncoveredLines)
87
+ };
88
+ if (verbose) {
89
+ const fileSummary = fc.toSummary().toJSON();
90
+ entry["lines%"] = fileSummary.lines?.pct ?? 0;
91
+ entry["stmts%"] = fileSummary.statements?.pct ?? 0;
92
+ entry["branch%"] = fileSummary.branches?.pct ?? 0;
93
+ entry["funcs%"] = fileSummary.functions?.pct ?? 0;
94
+ }
95
+ entries.push(entry);
96
+ }
97
+ if (entries.length > 0) result.files = entries;
98
+ }
99
+ return result;
100
+ }
101
+ formatLineRanges(lines) {
102
+ if (lines.length === 0) return "";
103
+ const sorted = lines.map(Number).sort((a, b) => a - b);
104
+ const ranges = [];
105
+ let start = sorted[0];
106
+ let end = start;
107
+ for (let i = 1; i <= sorted.length; i++) if (sorted[i] === end + 1) end = sorted[i];
108
+ else {
109
+ ranges.push(start === end ? String(start) : `${start}-${end}`);
110
+ start = sorted[i];
111
+ end = start;
112
+ }
113
+ return ranges.join(",");
57
114
  }
58
115
  formatLocation(relPath, line, column) {
59
116
  return line ? `${relPath}:${line}:${column || 0}` : relPath;
@@ -135,6 +192,8 @@ var ToonReporter = class {
135
192
  if (failures.length > 0) report.failing = failures;
136
193
  if (todoTests.length > 0) report.todo = todoTests.map(mapToSkipped);
137
194
  if (skippedTests.length > 0) report.skipped = skippedTests.map(mapToSkipped);
195
+ const coverage = this.getCoverageSummary();
196
+ if (coverage) report.coverage = coverage;
138
197
  await this.writeReport(encode(report));
139
198
  }
140
199
  colorize(report) {
@@ -0,0 +1,25 @@
1
+ import { FullConfig, FullResult, Reporter, Suite } from "@playwright/test/reporter";
2
+
3
+ //#region src/toon-playwright-reporter.d.ts
4
+ interface ToonPlaywrightReporterOptions {
5
+ outputFile?: string;
6
+ /** @internal Used for testing to capture output */
7
+ _captureOutput?: (output: string) => void;
8
+ }
9
+ declare class ToonPlaywrightReporter implements Reporter {
10
+ private config;
11
+ private suite;
12
+ private options;
13
+ constructor(options?: ToonPlaywrightReporterOptions);
14
+ onBegin(config: FullConfig, suite: Suite): void;
15
+ private get rootDir();
16
+ private formatLocation;
17
+ private stripAnsi;
18
+ private stripOuterQuotes;
19
+ private parseExpectedGot;
20
+ onEnd(result: FullResult): Promise<void>;
21
+ writeReport(report: string): Promise<void>;
22
+ printsToStdio(): boolean;
23
+ }
24
+ //#endregion
25
+ export { ToonPlaywrightReporter, ToonPlaywrightReporter as default, type ToonPlaywrightReporterOptions };
@@ -0,0 +1,111 @@
1
+ import { existsSync, promises } from "node:fs";
2
+ import { dirname, relative, resolve } from "pathe";
3
+ import { encode } from "@toon-format/toon";
4
+
5
+ //#region src/toon-playwright-reporter.ts
6
+ var ToonPlaywrightReporter = class {
7
+ constructor(options = {}) {
8
+ this.options = options;
9
+ }
10
+ onBegin(config, suite) {
11
+ this.config = config;
12
+ this.suite = suite;
13
+ }
14
+ get rootDir() {
15
+ return this.config.rootDir;
16
+ }
17
+ formatLocation(filePath, line, column) {
18
+ const relPath = relative(this.rootDir, filePath);
19
+ return line ? `${relPath}:${line}:${column || 0}` : relPath;
20
+ }
21
+ stripAnsi(str) {
22
+ return str.replace(/\x1b\[[0-9;]*m/g, "");
23
+ }
24
+ stripOuterQuotes(str) {
25
+ if (str.startsWith("\"") && str.endsWith("\"") || str.startsWith("'") && str.endsWith("'")) return str.slice(1, -1);
26
+ return str;
27
+ }
28
+ parseExpectedGot(error) {
29
+ const message = this.stripAnsi(error?.message || "");
30
+ const expectedMatch = message.match(/Expected:\s*(.+?)(?:\n|$)/i);
31
+ const receivedMatch = message.match(/Received:\s*(.+?)(?:\n|$)/i);
32
+ if (expectedMatch && receivedMatch) return {
33
+ expected: this.stripOuterQuotes(expectedMatch[1].trim()),
34
+ got: this.stripOuterQuotes(receivedMatch[1].trim())
35
+ };
36
+ const toBeMatch = message.match(/expect\((.+?)\)\.toBe\((.+?)\)/i);
37
+ if (toBeMatch) return {
38
+ expected: this.stripOuterQuotes(toBeMatch[2].trim()),
39
+ got: this.stripOuterQuotes(toBeMatch[1].trim())
40
+ };
41
+ return {};
42
+ }
43
+ async onEnd(result) {
44
+ const allTests = this.suite.allTests();
45
+ const passedTests = [];
46
+ const failedTests = [];
47
+ const skippedTests = [];
48
+ const todoTests = [];
49
+ const flakyTests = [];
50
+ for (const test of allTests) {
51
+ const lastResult = test.results[test.results.length - 1];
52
+ if (!lastResult) continue;
53
+ const status = lastResult.status;
54
+ const isFixme = test.annotations.some((a) => a.type === "fixme");
55
+ if (status === "passed" && test.results.length > 1 && test.results.some((r) => r.status === "failed")) flakyTests.push(test);
56
+ else if (status === "passed") passedTests.push(test);
57
+ else if (status === "failed" || status === "timedOut" || status === "interrupted") failedTests.push(test);
58
+ else if (status === "skipped") if (isFixme) todoTests.push(test);
59
+ else skippedTests.push(test);
60
+ }
61
+ const failures = [];
62
+ for (const test of failedTests) {
63
+ const error = test.results[test.results.length - 1]?.errors?.[0];
64
+ const loc = error?.location || test.location;
65
+ const failure = { at: this.formatLocation(loc.file, loc.line, loc.column) };
66
+ if (error) {
67
+ const { expected, got } = this.parseExpectedGot(error);
68
+ if (expected !== void 0 && got !== void 0) {
69
+ failure.expected = expected;
70
+ failure.got = got;
71
+ } else failure.error = error.message;
72
+ }
73
+ failures.push(failure);
74
+ }
75
+ const mapToSkipped = (test) => ({
76
+ at: this.formatLocation(test.location.file, test.location.line, test.location.column),
77
+ name: test.title
78
+ });
79
+ const mapToFlaky = (test) => ({
80
+ at: this.formatLocation(test.location.file, test.location.line, test.location.column),
81
+ name: test.title,
82
+ retries: test.results.length - 1
83
+ });
84
+ const report = { passing: passedTests.length };
85
+ if (flakyTests.length > 0) report.flaky = flakyTests.map(mapToFlaky);
86
+ if (failures.length > 0) report.failing = failures;
87
+ if (todoTests.length > 0) report.todo = todoTests.map(mapToSkipped);
88
+ if (skippedTests.length > 0) report.skipped = skippedTests.map(mapToSkipped);
89
+ await this.writeReport(encode(report));
90
+ }
91
+ async writeReport(report) {
92
+ if (this.options._captureOutput) {
93
+ this.options._captureOutput(report);
94
+ return;
95
+ }
96
+ const outputFile = this.options.outputFile;
97
+ if (outputFile) {
98
+ const reportFile = resolve(this.rootDir, outputFile);
99
+ const outputDirectory = dirname(reportFile);
100
+ if (!existsSync(outputDirectory)) await promises.mkdir(outputDirectory, { recursive: true });
101
+ await promises.writeFile(reportFile, report, "utf-8");
102
+ console.log(`TOON report written to ${reportFile}`);
103
+ } else console.log(report);
104
+ }
105
+ printsToStdio() {
106
+ return !this.options.outputFile;
107
+ }
108
+ };
109
+
110
+ //#endregion
111
+ export { ToonPlaywrightReporter, ToonPlaywrightReporter as default };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@epicat/toon-reporter",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "description": "A minimal Vitest reporter optimized for LLM consumption",
5
5
  "repository": {
6
6
  "type": "git",
@@ -16,20 +16,35 @@
16
16
  "types": "./dist/index.d.mts",
17
17
  "exports": {
18
18
  ".": "./dist/index.mjs",
19
+ "./playwright": "./dist/playwright.mjs",
19
20
  "./package.json": "./package.json"
20
21
  },
21
22
  "peerDependencies": {
23
+ "@playwright/test": ">=1.40.0",
22
24
  "vitest": ">=2.0.0"
23
25
  },
26
+ "peerDependenciesMeta": {
27
+ "@playwright/test": {
28
+ "optional": true
29
+ },
30
+ "vitest": {
31
+ "optional": true
32
+ }
33
+ },
24
34
  "dependencies": {
25
35
  "@toon-format/toon": "^2.0.1",
26
36
  "pathe": "^2.0.3"
27
37
  },
28
38
  "devDependencies": {
29
39
  "@changesets/cli": "^2.29.8",
40
+ "@playwright/test": "^1.57.0",
30
41
  "@types/node": "^24.10.1",
42
+ "@vitest/coverage-v8": "^4.0.15",
31
43
  "@vitest/runner": "4.0.15",
32
44
  "@vitest/utils": "4.0.15",
45
+ "gpt-tokenizer": "^3.4.0",
46
+ "istanbul-lib-coverage": "^3.2.2",
47
+ "serve": "^14.2.5",
33
48
  "tsdown": "^0.16.4",
34
49
  "typescript": "^5.9.3",
35
50
  "vitest": "4.0.15"
@@ -38,6 +53,7 @@
38
53
  "build": "tsdown",
39
54
  "dev": "tsdown --watch",
40
55
  "test": "vitest run",
56
+ "test:coverage": "vitest run --coverage",
41
57
  "test:watch": "vitest"
42
58
  }
43
59
  }