@epicat/toon-reporter 0.0.1 → 0.0.2

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,145 @@
1
+ import { Awaitable, SerializedError } from "@vitest/utils";
2
+ import { File, TaskEventPack, TaskResultPack, TestAnnotation, TestArtifact } from "@vitest/runner";
3
+ import { ReportedHookContext, TestCase, TestModule, TestProject, TestSpecification, TestSuite, Vitest } from "vitest/node";
4
+
5
+ //#region src/reporter.d.ts
6
+ type TestRunEndReason = 'passed' | 'interrupted' | 'failed';
7
+ interface UserConsoleLog {
8
+ content: string;
9
+ origin?: string;
10
+ browser?: boolean;
11
+ type: 'stdout' | 'stderr';
12
+ taskId?: string;
13
+ time: number;
14
+ size: number;
15
+ }
16
+ interface Reporter {
17
+ onInit?: (vitest: Vitest) => void;
18
+ /**
19
+ * Called when the project initiated the browser instance.
20
+ * project.browser will always be defined.
21
+ * @experimental
22
+ */
23
+ onBrowserInit?: (project: TestProject) => Awaitable<void>;
24
+ /** @internal */
25
+ onTaskUpdate?: (packs: TaskResultPack[], events: TaskEventPack[]) => Awaitable<void>;
26
+ onTestRemoved?: (trigger?: string) => Awaitable<void>;
27
+ onWatcherStart?: (files?: File[], errors?: unknown[]) => Awaitable<void>;
28
+ onWatcherRerun?: (files: string[], trigger?: string) => Awaitable<void>;
29
+ onServerRestart?: (reason?: string) => Awaitable<void>;
30
+ onUserConsoleLog?: (log: UserConsoleLog) => Awaitable<void>;
31
+ onProcessTimeout?: () => Awaitable<void>;
32
+ /**
33
+ * Called when the new test run starts.
34
+ */
35
+ onTestRunStart?: (specifications: ReadonlyArray<TestSpecification>) => Awaitable<void>;
36
+ /**
37
+ * Called when the test run is finished.
38
+ */
39
+ onTestRunEnd?: (testModules: ReadonlyArray<TestModule>, unhandledErrors: ReadonlyArray<SerializedError>, reason: TestRunEndReason) => Awaitable<void>;
40
+ /**
41
+ * Called when the module is enqueued for testing. The file itself is not loaded yet.
42
+ */
43
+ onTestModuleQueued?: (testModule: TestModule) => Awaitable<void>;
44
+ /**
45
+ * Called when the test file is loaded and the module is ready to run tests.
46
+ */
47
+ onTestModuleCollected?: (testModule: TestModule) => Awaitable<void>;
48
+ /**
49
+ * Called when starting to run tests of the test file
50
+ */
51
+ onTestModuleStart?: (testModule: TestModule) => Awaitable<void>;
52
+ /**
53
+ * Called when all tests of the test file have finished running.
54
+ */
55
+ onTestModuleEnd?: (testModule: TestModule) => Awaitable<void>;
56
+ /**
57
+ * Called when test case is ready to run.
58
+ * Called before the `beforeEach` hooks for the test are run.
59
+ */
60
+ onTestCaseReady?: (testCase: TestCase) => Awaitable<void>;
61
+ /**
62
+ * Called after the test and its hooks are finished running.
63
+ * The `result()` cannot be `pending`.
64
+ */
65
+ onTestCaseResult?: (testCase: TestCase) => Awaitable<void>;
66
+ /**
67
+ * Called when annotation is added via the `task.annotate` API.
68
+ */
69
+ onTestCaseAnnotate?: (testCase: TestCase, annotation: TestAnnotation) => Awaitable<void>;
70
+ /**
71
+ * Called when artifacts are recorded on tests via the `recordArtifact` utility.
72
+ */
73
+ onTestCaseArtifactRecord?: (testCase: TestCase, artifact: TestArtifact) => Awaitable<void>;
74
+ /**
75
+ * Called when test suite is ready to run.
76
+ * Called before the `beforeAll` hooks for the test are run.
77
+ */
78
+ onTestSuiteReady?: (testSuite: TestSuite) => Awaitable<void>;
79
+ /**
80
+ * Called after the test suite and its hooks are finished running.
81
+ * The `state` cannot be `pending`.
82
+ */
83
+ onTestSuiteResult?: (testSuite: TestSuite) => Awaitable<void>;
84
+ /**
85
+ * Called before the hook starts to run.
86
+ */
87
+ onHookStart?: (hook: ReportedHookContext) => Awaitable<void>;
88
+ /**
89
+ * Called after the hook finished running.
90
+ */
91
+ onHookEnd?: (hook: ReportedHookContext) => Awaitable<void>;
92
+ onCoverage?: (coverage: unknown) => Awaitable<void>;
93
+ }
94
+ //#endregion
95
+ //#region src/toon-reporter.d.ts
96
+ interface ToonFailure {
97
+ at: string;
98
+ absPath: string;
99
+ line?: number;
100
+ column?: number;
101
+ expected?: string;
102
+ got?: string;
103
+ error?: string;
104
+ each?: boolean;
105
+ }
106
+ interface ToonSkipped {
107
+ at: string;
108
+ absPath: string;
109
+ line?: number;
110
+ column?: number;
111
+ name: string;
112
+ }
113
+ interface ToonReport {
114
+ passing: number;
115
+ failing?: ToonFailure[];
116
+ skipped?: ToonSkipped[];
117
+ todo?: ToonSkipped[];
118
+ }
119
+ interface ToonReporterOptions {
120
+ outputFile?: string;
121
+ color?: boolean;
122
+ /** @internal Used for testing to capture output */
123
+ _captureOutput?: (output: string) => void;
124
+ }
125
+ declare class ToonReporter implements Reporter {
126
+ start: number;
127
+ ctx: Vitest;
128
+ options: ToonReporterOptions;
129
+ constructor(options?: ToonReporterOptions);
130
+ onInit(ctx: Vitest): void;
131
+ private parseLocation;
132
+ private parseExpectedGot;
133
+ onTestRunEnd(testModules: ReadonlyArray<any>, _unhandledErrors: ReadonlyArray<SerializedError>, _reason: TestRunEndReason): Promise<void>;
134
+ /**
135
+ * Quote a string value per TOON spec:
136
+ * - Quote if contains delimiter (comma), newline, carriage return, tab, backslash, or quote
137
+ * - Quote if matches reserved keywords (true, false, null)
138
+ * - Quote if looks like a number but should be a string
139
+ */
140
+ private toonQuote;
141
+ private formatOutput;
142
+ writeReport(report: string): Promise<void>;
143
+ }
144
+ //#endregion
145
+ export { type Reporter, type TestRunEndReason, type ToonFailure, type ToonReport, ToonReporter, ToonReporter as default, type ToonReporterOptions, type ToonSkipped, type UserConsoleLog };
package/dist/index.mjs ADDED
@@ -0,0 +1,345 @@
1
+ import { existsSync, promises } from "node:fs";
2
+
3
+ //#region node_modules/.pnpm/@vitest+utils@4.0.15/node_modules/@vitest/utils/dist/helpers.js
4
+ function toArray(array) {
5
+ if (array === null || array === void 0) array = [];
6
+ if (Array.isArray(array)) return array;
7
+ return [array];
8
+ }
9
+
10
+ //#endregion
11
+ //#region node_modules/.pnpm/pathe@2.0.3/node_modules/pathe/dist/shared/pathe.M-eThtNZ.mjs
12
+ const _DRIVE_LETTER_START_RE = /^[A-Za-z]:\//;
13
+ function normalizeWindowsPath(input = "") {
14
+ if (!input) return input;
15
+ return input.replace(/\\/g, "/").replace(_DRIVE_LETTER_START_RE, (r) => r.toUpperCase());
16
+ }
17
+ const _IS_ABSOLUTE_RE = /^[/\\](?![/\\])|^[/\\]{2}(?!\.)|^[A-Za-z]:[/\\]/;
18
+ const _DRIVE_LETTER_RE = /^[A-Za-z]:$/;
19
+ const _ROOT_FOLDER_RE = /^\/([A-Za-z]:)?$/;
20
+ function cwd() {
21
+ if (typeof process !== "undefined" && typeof process.cwd === "function") return process.cwd().replace(/\\/g, "/");
22
+ return "/";
23
+ }
24
+ const resolve = function(...arguments_) {
25
+ arguments_ = arguments_.map((argument) => normalizeWindowsPath(argument));
26
+ let resolvedPath = "";
27
+ let resolvedAbsolute = false;
28
+ for (let index = arguments_.length - 1; index >= -1 && !resolvedAbsolute; index--) {
29
+ const path = index >= 0 ? arguments_[index] : cwd();
30
+ if (!path || path.length === 0) continue;
31
+ resolvedPath = `${path}/${resolvedPath}`;
32
+ resolvedAbsolute = isAbsolute(path);
33
+ }
34
+ resolvedPath = normalizeString(resolvedPath, !resolvedAbsolute);
35
+ if (resolvedAbsolute && !isAbsolute(resolvedPath)) return `/${resolvedPath}`;
36
+ return resolvedPath.length > 0 ? resolvedPath : ".";
37
+ };
38
+ function normalizeString(path, allowAboveRoot) {
39
+ let res = "";
40
+ let lastSegmentLength = 0;
41
+ let lastSlash = -1;
42
+ let dots = 0;
43
+ let char = null;
44
+ for (let index = 0; index <= path.length; ++index) {
45
+ if (index < path.length) char = path[index];
46
+ else if (char === "/") break;
47
+ else char = "/";
48
+ if (char === "/") {
49
+ if (lastSlash === index - 1 || dots === 1);
50
+ else if (dots === 2) {
51
+ if (res.length < 2 || lastSegmentLength !== 2 || res[res.length - 1] !== "." || res[res.length - 2] !== ".") {
52
+ if (res.length > 2) {
53
+ const lastSlashIndex = res.lastIndexOf("/");
54
+ if (lastSlashIndex === -1) {
55
+ res = "";
56
+ lastSegmentLength = 0;
57
+ } else {
58
+ res = res.slice(0, lastSlashIndex);
59
+ lastSegmentLength = res.length - 1 - res.lastIndexOf("/");
60
+ }
61
+ lastSlash = index;
62
+ dots = 0;
63
+ continue;
64
+ } else if (res.length > 0) {
65
+ res = "";
66
+ lastSegmentLength = 0;
67
+ lastSlash = index;
68
+ dots = 0;
69
+ continue;
70
+ }
71
+ }
72
+ if (allowAboveRoot) {
73
+ res += res.length > 0 ? "/.." : "..";
74
+ lastSegmentLength = 2;
75
+ }
76
+ } else {
77
+ if (res.length > 0) res += `/${path.slice(lastSlash + 1, index)}`;
78
+ else res = path.slice(lastSlash + 1, index);
79
+ lastSegmentLength = index - lastSlash - 1;
80
+ }
81
+ lastSlash = index;
82
+ dots = 0;
83
+ } else if (char === "." && dots !== -1) ++dots;
84
+ else dots = -1;
85
+ }
86
+ return res;
87
+ }
88
+ const isAbsolute = function(p) {
89
+ return _IS_ABSOLUTE_RE.test(p);
90
+ };
91
+ const relative = function(from, to) {
92
+ const _from = resolve(from).replace(_ROOT_FOLDER_RE, "$1").split("/");
93
+ const _to = resolve(to).replace(_ROOT_FOLDER_RE, "$1").split("/");
94
+ if (_to[0][1] === ":" && _from[0][1] === ":" && _from[0] !== _to[0]) return _to.join("/");
95
+ const _fromCopy = [..._from];
96
+ for (const segment of _fromCopy) {
97
+ if (_to[0] !== segment) break;
98
+ _from.shift();
99
+ _to.shift();
100
+ }
101
+ return [..._from.map(() => ".."), ..._to].join("/");
102
+ };
103
+ const dirname = function(p) {
104
+ const segments = normalizeWindowsPath(p).replace(/\/$/, "").split("/").slice(0, -1);
105
+ if (segments.length === 1 && _DRIVE_LETTER_RE.test(segments[0])) segments[0] += "/";
106
+ return segments.join("/") || (isAbsolute(p) ? "/" : ".");
107
+ };
108
+
109
+ //#endregion
110
+ //#region node_modules/.pnpm/@vitest+runner@4.0.15/node_modules/@vitest/runner/dist/chunk-tasks.js
111
+ function isTestCase(s) {
112
+ return s.type === "test";
113
+ }
114
+ function getTests(suite) {
115
+ const tests = [];
116
+ const arraySuites = toArray(suite);
117
+ for (const s of arraySuites) if (isTestCase(s)) tests.push(s);
118
+ else for (const task of s.tasks) if (isTestCase(task)) tests.push(task);
119
+ else {
120
+ const taskTests = getTests(task);
121
+ for (const test of taskTests) tests.push(test);
122
+ }
123
+ return tests;
124
+ }
125
+
126
+ //#endregion
127
+ //#region src/config-helpers.ts
128
+ function getOutputFile(config, reporter) {
129
+ if (!config?.outputFile) return;
130
+ if (typeof config.outputFile === "string") return config.outputFile;
131
+ return config.outputFile[reporter];
132
+ }
133
+
134
+ //#endregion
135
+ //#region src/toon-reporter.ts
136
+ const colors = {
137
+ green: "\x1B[32m",
138
+ red: "\x1B[31m",
139
+ yellow: "\x1B[33m",
140
+ cyan: "\x1B[36m",
141
+ gray: "\x1B[90m",
142
+ reset: "\x1B[0m"
143
+ };
144
+ function link(url, text) {
145
+ return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`;
146
+ }
147
+ function fileLink(filePath, line, column) {
148
+ let url = `file://${filePath}`;
149
+ if (line) {
150
+ url += `:${line}`;
151
+ if (column) url += `:${column}`;
152
+ }
153
+ return url;
154
+ }
155
+ var ToonReporter = class {
156
+ constructor(options = {}) {
157
+ this.start = 0;
158
+ this.options = {
159
+ color: false,
160
+ ...options
161
+ };
162
+ }
163
+ onInit(ctx) {
164
+ this.ctx = ctx;
165
+ this.start = Date.now();
166
+ }
167
+ parseLocation(error, rootDir) {
168
+ const lines = (error?.stack || "").split("\n");
169
+ for (const line of lines) {
170
+ const match = line.match(/at\s+(?:.*?\s+\()?([^)\s]+):(\d+):(\d+)\)?/);
171
+ if (match) {
172
+ const [, filePath, lineNum, column] = match;
173
+ if (filePath.includes("node_modules") || filePath.startsWith("file:")) continue;
174
+ const absPath = filePath.startsWith(rootDir) ? filePath : resolve(rootDir, filePath);
175
+ return {
176
+ absPath,
177
+ relPath: relative(rootDir, absPath),
178
+ line: parseInt(lineNum, 10),
179
+ column: parseInt(column, 10)
180
+ };
181
+ }
182
+ }
183
+ return null;
184
+ }
185
+ parseExpectedGot(error) {
186
+ const matchToBe = (error?.message || "").match(/expected\s+(.+?)\s+to\s+(?:be|equal|deeply equal)\s+(.+?)(?:\s*\/\/|$)/i);
187
+ if (matchToBe) return {
188
+ expected: matchToBe[2].trim(),
189
+ got: matchToBe[1].trim()
190
+ };
191
+ if (error?.expected !== void 0 && error?.actual !== void 0) return {
192
+ expected: String(error.expected),
193
+ got: String(error.actual)
194
+ };
195
+ return {};
196
+ }
197
+ async onTestRunEnd(testModules, _unhandledErrors, _reason) {
198
+ const tests = getTests(testModules.map((testModule) => testModule.task));
199
+ const rootDir = this.ctx.config.root;
200
+ const failedTests = tests.filter((t) => t.result?.state === "fail");
201
+ const passedCount = tests.filter((t) => t.result?.state === "pass").length;
202
+ const skippedTests = tests.filter((t) => t.mode === "skip");
203
+ const todoTests = tests.filter((t) => t.mode === "todo");
204
+ const failures = failedTests.map((t) => {
205
+ const error = t.result?.errors?.[0];
206
+ const parsed = this.parseLocation(error, rootDir);
207
+ const { expected, got } = this.parseExpectedGot(error);
208
+ const absPath = parsed?.absPath || t.file.filepath;
209
+ const relPath = parsed?.relPath || relative(rootDir, t.file.filepath);
210
+ const line = parsed?.line || t.location?.line;
211
+ const column = parsed?.column || t.location?.column;
212
+ const failure = {
213
+ at: line ? `${relPath}:${line}:${column || 0}` : relPath,
214
+ absPath,
215
+ line,
216
+ column,
217
+ each: t.each
218
+ };
219
+ if (expected !== void 0 && got !== void 0) {
220
+ failure.expected = expected;
221
+ failure.got = got;
222
+ } else if (error) {
223
+ let message = error.message || String(error);
224
+ if (message.startsWith("Test timed out")) message = message.split("\n")[0];
225
+ const errorName = error.name;
226
+ if (errorName && errorName !== "Error" && !message.startsWith(errorName)) message = `${errorName}: ${message}`;
227
+ failure.error = message;
228
+ }
229
+ return failure;
230
+ });
231
+ const skipped = skippedTests.map((t) => {
232
+ const relPath = relative(rootDir, t.file.filepath);
233
+ const line = t.location?.line;
234
+ const column = t.location?.column;
235
+ return {
236
+ at: line ? `${relPath}:${line}:${column || 0}` : relPath,
237
+ absPath: t.file.filepath,
238
+ line,
239
+ column,
240
+ name: t.name
241
+ };
242
+ });
243
+ const todo = todoTests.map((t) => {
244
+ const relPath = relative(rootDir, t.file.filepath);
245
+ const line = t.location?.line;
246
+ const column = t.location?.column;
247
+ return {
248
+ at: line ? `${relPath}:${line}:${column || 0}` : relPath,
249
+ absPath: t.file.filepath,
250
+ line,
251
+ column,
252
+ name: t.name
253
+ };
254
+ });
255
+ const output = this.formatOutput(passedCount, failures, skipped, todo);
256
+ await this.writeReport(output);
257
+ }
258
+ /**
259
+ * Quote a string value per TOON spec:
260
+ * - Quote if contains delimiter (comma), newline, carriage return, tab, backslash, or quote
261
+ * - Quote if matches reserved keywords (true, false, null)
262
+ * - Quote if looks like a number but should be a string
263
+ */
264
+ toonQuote(value, delimiter = ",") {
265
+ if (!(value.includes(delimiter) || value.includes("\n") || value.includes("\r") || value.includes(" ") || value.includes("\\") || value.includes("\"") || value === "true" || value === "false" || value === "null" || /^-?\d+(\.\d+)?$/.test(value))) return value;
266
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t")}"`;
267
+ }
268
+ formatOutput(passing, failures, skipped, todo) {
269
+ const useColor = this.options.color && !this.options.outputFile && !this.options._captureOutput && !process.env.NO_COLOR && !process.env.CI;
270
+ const { green, red, yellow, cyan, gray, reset } = useColor ? colors : {
271
+ green: "",
272
+ red: "",
273
+ yellow: "",
274
+ cyan: "",
275
+ gray: "",
276
+ reset: ""
277
+ };
278
+ const formatLocation = (at, absPath, line, column, color = yellow) => {
279
+ if (useColor) return `${color}${link(fileLink(absPath, line, column), at)}${reset}`;
280
+ return at;
281
+ };
282
+ const lines = [];
283
+ lines.push(`${green}passing: ${passing}${reset}`);
284
+ if (failures.length > 0) {
285
+ lines.push(`${red}failing[${failures.length}]:${reset}`);
286
+ const grouped = /* @__PURE__ */ new Map();
287
+ const regular = [];
288
+ for (const failure of failures) if (failure.each) {
289
+ const key = failure.at;
290
+ if (!grouped.has(key)) grouped.set(key, []);
291
+ grouped.get(key).push(failure);
292
+ } else regular.push(failure);
293
+ for (const failure of regular) {
294
+ lines.push(`- at: ${formatLocation(failure.at, failure.absPath, failure.line, failure.column)}`);
295
+ if (failure.expected !== void 0 && failure.got !== void 0) {
296
+ lines.push(` expected: ${this.toonQuote(failure.expected)}`);
297
+ lines.push(` got: ${this.toonQuote(failure.got)}`);
298
+ } else if (failure.error) lines.push(` error: ${this.toonQuote(failure.error)}`);
299
+ }
300
+ for (const [, groupedFailures] of grouped) {
301
+ const first = groupedFailures[0];
302
+ lines.push(`- at: ${formatLocation(first.at, first.absPath, first.line, first.column)}`);
303
+ if (groupedFailures.every((f) => f.expected !== void 0 && f.got !== void 0)) {
304
+ lines.push(` parameters[${groupedFailures.length}]{expected,got}:`);
305
+ for (const failure of groupedFailures) lines.push(` ${this.toonQuote(failure.expected)},${this.toonQuote(failure.got)}`);
306
+ } else {
307
+ lines.push(` parameters[${groupedFailures.length}]{error}:`);
308
+ for (const failure of groupedFailures) if (failure.error) lines.push(` ${this.toonQuote(failure.error)}`);
309
+ }
310
+ }
311
+ }
312
+ if (todo.length > 0) {
313
+ lines.push(`${cyan}todo[${todo.length}]{at,name}:${reset}`);
314
+ for (const t of todo) {
315
+ const at = formatLocation(t.at, t.absPath, t.line, t.column);
316
+ lines.push(`${cyan} ${at},${this.toonQuote(t.name)}${reset}`);
317
+ }
318
+ }
319
+ if (skipped.length > 0) {
320
+ lines.push(`${gray}skipped[${skipped.length}]{at,name}:${reset}`);
321
+ for (const s of skipped) {
322
+ const at = formatLocation(s.at, s.absPath, s.line, s.column, gray);
323
+ lines.push(`${gray} ${at},${this.toonQuote(s.name)}${reset}`);
324
+ }
325
+ }
326
+ return lines.join("\n");
327
+ }
328
+ async writeReport(report) {
329
+ if (this.options._captureOutput) {
330
+ this.options._captureOutput(report);
331
+ return;
332
+ }
333
+ const outputFile = this.options.outputFile ?? getOutputFile(this.ctx.config, "toon");
334
+ if (outputFile) {
335
+ const reportFile = resolve(this.ctx.config.root, outputFile);
336
+ const outputDirectory = dirname(reportFile);
337
+ if (!existsSync(outputDirectory)) await promises.mkdir(outputDirectory, { recursive: true });
338
+ await promises.writeFile(reportFile, report, "utf-8");
339
+ this.ctx.logger.log(`TOON report written to ${reportFile}`);
340
+ } else this.ctx.logger.log(report);
341
+ }
342
+ };
343
+
344
+ //#endregion
345
+ export { ToonReporter, ToonReporter as default };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@epicat/toon-reporter",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "A minimal Vitest reporter optimized for LLM consumption",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,12 +18,6 @@
18
18
  ".": "./dist/index.mjs",
19
19
  "./package.json": "./package.json"
20
20
  },
21
- "scripts": {
22
- "build": "tsdown",
23
- "dev": "tsdown --watch",
24
- "test": "vitest run",
25
- "test:watch": "vitest"
26
- },
27
21
  "devDependencies": {
28
22
  "@changesets/cli": "^2.29.8",
29
23
  "@types/istanbul-lib-coverage": "^2.0.6",
@@ -40,8 +34,13 @@
40
34
  "vite": "^7.2.6",
41
35
  "vitest": "4.0.15"
42
36
  },
43
- "packageManager": "pnpm@10.22.0+sha512.bf049efe995b28f527fd2b41ae0474ce29186f7edcb3bf545087bd61fbbebb2bf75362d1307fda09c2d288e1e499787ac12d4fcb617a974718a6051f2eee741c",
44
37
  "dependencies": {
45
38
  "@toon-format/toon": "^2.0.1"
39
+ },
40
+ "scripts": {
41
+ "build": "tsdown",
42
+ "dev": "tsdown --watch",
43
+ "test": "vitest run",
44
+ "test:watch": "vitest"
46
45
  }
47
- }
46
+ }