|
1 | 1 | /** |
2 | | - * @file Reporters - VerboseReporter |
3 | | - * @module tests/reporters/VerboseReporter |
| 2 | + * @file Reporters - Notifier |
| 3 | + * @module tests/reporters/Notifier |
4 | 4 | * @see https://vitest.dev/advanced/reporters#exported-reporters |
5 | 5 | */ |
6 | 6 |
|
7 | | -import type { Task, TaskResultPack } from '@vitest/runner' |
8 | | -import { getNames, getTests } from '@vitest/runner/utils' |
9 | | -import colors, { type Colors } from 'tinyrainbow' |
10 | | -import type { RunnerTask, RunnerTestFile } from 'vitest' |
11 | | -import { DefaultReporter, type Reporter } from 'vitest/reporters' |
| 7 | +import type { Test } from '@vitest/runner' |
| 8 | +import { getTests } from '@vitest/runner/utils' |
| 9 | +import ci from 'is-ci' |
| 10 | +import notifier from 'node-notifier' |
| 11 | +import type { Notification } from 'node-notifier/notifiers/notificationcenter' |
| 12 | +import { performance } from 'node:perf_hooks' |
| 13 | +import { promisify } from 'node:util' |
| 14 | +import { dedent } from 'ts-dedent' |
| 15 | +import type { RunnerTestFile } from 'vitest' |
| 16 | +import type { Vitest } from 'vitest/node' |
| 17 | +import type { Reporter } from 'vitest/reporters' |
12 | 18 |
|
13 | 19 | /** |
14 | | - * Verbose reporter. |
| 20 | + * Test report summary notifier. |
15 | 21 | * |
16 | | - * @see {@linkcode DefaultReporter} |
17 | 22 | * @see {@linkcode Reporter} |
18 | 23 | * |
19 | | - * @extends {DefaultReporter} |
20 | 24 | * @implements {Reporter} |
21 | 25 | */ |
22 | | -class VerboseReporter extends DefaultReporter implements Reporter { |
| 26 | +class Notifier implements Reporter { |
23 | 27 | /** |
24 | | - * Color functions map. |
| 28 | + * Reporter context. |
| 29 | + * |
| 30 | + * @see {@linkcode Vitest} |
25 | 31 | * |
26 | 32 | * @public |
27 | 33 | * @instance |
28 | | - * @member {Colors} colors |
| 34 | + * @member {Vitest} ctx |
29 | 35 | */ |
30 | | - public colors: Colors |
| 36 | + public ctx!: Vitest |
31 | 37 |
|
32 | 38 | /** |
33 | | - * Create a new verbose reporter. |
34 | | - */ |
35 | | - constructor() { |
36 | | - super({ summary: true }) |
37 | | - |
38 | | - this.colors = colors |
39 | | - this.renderSucceed = true |
40 | | - this.verbose = true |
41 | | - } |
42 | | - |
43 | | - /** |
44 | | - * Format a project `name`. |
| 39 | + * Test run end time (in milliseconds). |
45 | 40 | * |
46 | | - * @protected |
| 41 | + * @public |
47 | 42 | * @instance |
48 | | - * |
49 | | - * @param {string | null | undefined} name |
50 | | - * The project name to format |
51 | | - * @param {boolean | null | undefined} dim |
52 | | - * Dim formattted project name? |
53 | | - * @return {string} |
54 | | - * Formatted project name |
| 43 | + * @member {number} end |
55 | 44 | */ |
56 | | - protected formatProjectName( |
57 | | - name: string | null | undefined, |
58 | | - dim?: boolean | null | undefined |
59 | | - ): string { |
60 | | - if (name) { |
61 | | - name = this.colors.magenta(`[${name}]`) |
62 | | - if (dim) name = this.colors.dim(name) |
63 | | - return name |
64 | | - } |
65 | | - |
66 | | - return '' |
67 | | - } |
| 45 | + public end!: number |
68 | 46 |
|
69 | 47 | /** |
70 | | - * Get a symbol representing `task`. |
| 48 | + * Test run start time (in milliseconds). |
71 | 49 | * |
72 | | - * @see {@linkcode RunnerTask} |
73 | | - * |
74 | | - * @protected |
| 50 | + * @public |
75 | 51 | * @instance |
76 | | - * |
77 | | - * @param {RunnerTask} task |
78 | | - * The runner task to handle |
79 | | - * @return {string} |
80 | | - * Task state symbol |
| 52 | + * @member {number} start |
81 | 53 | */ |
82 | | - protected getTaskSymbol(task: RunnerTask): string { |
83 | | - if (task.mode === 'skip') return this.colors.dim(this.colors.gray('↓')) |
84 | | - |
85 | | - if (task.mode === 'todo') return this.colors.yellow('→') |
86 | | - |
87 | | - if (!task.result) return this.colors.gray('.') |
88 | | - |
89 | | - if (task.result.state === 'pass') { |
90 | | - return this.colors.green(task.meta.benchmark ? '·' : '✓') |
91 | | - } |
92 | | - |
93 | | - if (task.result.state === 'fail') { |
94 | | - return this.colors.red(task.type === 'suite' ? '❯' : '✖') |
95 | | - } |
96 | | - |
97 | | - return '' |
98 | | - } |
| 54 | + public start!: number |
99 | 55 |
|
100 | 56 | /** |
101 | | - * Print tasks. |
| 57 | + * Send a notification after all tests have ran (in non ci/cd environments). |
102 | 58 | * |
103 | 59 | * @see {@linkcode RunnerTestFile} |
104 | 60 | * |
105 | 61 | * @public |
106 | | - * @override |
107 | 62 | * @instance |
108 | 63 | * |
109 | | - * @param {RunnerTestFile[] | undefined} [files] |
| 64 | + * @async |
| 65 | + * |
| 66 | + * @param {RunnerTestFile[] | undefined} [files=this.ctx.state.getFiles()] |
110 | 67 | * List of test files |
111 | | - * @param {unknown[] | undefined} [errors] |
| 68 | + * @param {unknown[] | undefined} [errors=this.ctx.state.getUnhandledErrors()] |
112 | 69 | * List of unhandled errors |
113 | 70 | * @return {undefined} |
114 | 71 | */ |
115 | | - public override onFinished( |
116 | | - files?: RunnerTestFile[] | undefined, |
117 | | - errors?: unknown[] | undefined |
118 | | - ): undefined { |
119 | | - if (files) { for (const task of files) this.printTask(task, true) } |
120 | | - return void super.onFinished(files, errors) |
| 72 | + public async onFinished( |
| 73 | + files: RunnerTestFile[] = this.ctx.state.getFiles(), |
| 74 | + errors: unknown[] = this.ctx.state.getUnhandledErrors() |
| 75 | + ): Promise<undefined> { |
| 76 | + this.end = performance.now() |
| 77 | + return void await (ci || this.reportSummary(files, errors)) |
121 | 78 | } |
122 | 79 |
|
123 | 80 | /** |
124 | | - * Handle task updates. |
| 81 | + * Initialize the reporter. |
125 | 82 | * |
126 | | - * @see {@linkcode TaskResultPack} |
| 83 | + * @see {@linkcode Vitest} |
127 | 84 | * |
128 | 85 | * @public |
129 | | - * @override |
130 | 86 | * @instance |
131 | 87 | * |
132 | | - * @param {TaskResultPack[]} packs |
133 | | - * List of task result packs |
| 88 | + * @param {Vitest} ctx |
| 89 | + * Reporter context |
134 | 90 | * @return {undefined} |
135 | 91 | */ |
136 | | - public override onTaskUpdate(packs: TaskResultPack[]): undefined { |
137 | | - return void (this.isTTY && void super.onTaskUpdate(packs)) |
| 92 | + public onInit(ctx: Vitest): undefined { |
| 93 | + return void (this.ctx = ctx, this.start = performance.now()) |
138 | 94 | } |
139 | 95 |
|
140 | 96 | /** |
141 | | - * Print `task`. |
| 97 | + * Send a notification. |
142 | 98 | * |
143 | | - * @see {@linkcode Task} |
| 99 | + * @see {@linkcode RunnerTestFile} |
144 | 100 | * |
145 | | - * @protected |
146 | | - * @override |
| 101 | + * @public |
147 | 102 | * @instance |
148 | 103 | * |
149 | | - * @param {Task | null | undefined} task |
150 | | - * The task to handle |
151 | | - * @param {boolean | null | undefined} [force] |
152 | | - * Print `task` even when {@linkcode isTTY} is `false`? |
153 | | - * @return {undefined} |
| 104 | + * @async |
| 105 | + * |
| 106 | + * @param {RunnerTestFile[] | undefined} [files=this.ctx.state.getFiles()] |
| 107 | + * List of test files |
| 108 | + * @param {unknown[] | undefined} [errors=this.ctx.state.getUnhandledErrors()] |
| 109 | + * List of unhandled errors |
| 110 | + * @return {Promise<undefined>} |
154 | 111 | */ |
155 | | - protected override printTask( |
156 | | - task: Task | null | undefined, |
157 | | - force?: boolean | null | undefined |
158 | | - ): undefined { |
159 | | - if ( |
160 | | - (!this.isTTY || force) && |
161 | | - task?.result?.state && |
162 | | - task.result.state !== 'queued' && |
163 | | - task.result.state !== 'run' |
164 | | - ) { |
| 112 | + public async reportSummary( |
| 113 | + files: RunnerTestFile[] = this.ctx.state.getFiles(), |
| 114 | + errors: unknown[] = this.ctx.state.getUnhandledErrors() |
| 115 | + ): Promise<undefined> { |
| 116 | + /** |
| 117 | + * Tests that have been run. |
| 118 | + * |
| 119 | + * @const {Test[]} tests |
| 120 | + */ |
| 121 | + const tests: Test[] = getTests(files) |
| 122 | + |
| 123 | + /** |
| 124 | + * Total number of failed tests. |
| 125 | + * |
| 126 | + * @const {number} fails |
| 127 | + */ |
| 128 | + const fails: number = tests.filter(t => t.result?.state === 'fail').length |
| 129 | + |
| 130 | + /** |
| 131 | + * Total number of passed tests. |
| 132 | + * |
| 133 | + * @const {number} passes |
| 134 | + */ |
| 135 | + const passes: number = tests.filter(t => t.result?.state === 'pass').length |
| 136 | + |
| 137 | + /** |
| 138 | + * Notification message. |
| 139 | + * |
| 140 | + * @var {string} message |
| 141 | + */ |
| 142 | + let message: string = '' |
| 143 | + |
| 144 | + /** |
| 145 | + * Notification title. |
| 146 | + * |
| 147 | + * @var {string} title |
| 148 | + */ |
| 149 | + let title: string = '' |
| 150 | + |
| 151 | + // get notification title and message based on number of failed tests |
| 152 | + if (fails || errors.length > 0) { |
| 153 | + message = dedent` |
| 154 | + ${fails} of ${tests.length} tests failed |
| 155 | + ${errors.length} unhandled errors |
| 156 | + ` |
| 157 | + |
| 158 | + title = '\u274C Failed' |
| 159 | + } else { |
165 | 160 | /** |
166 | | - * Task skipped? |
| 161 | + * Time to run all tests (in milliseconds). |
167 | 162 | * |
168 | | - * @const {boolean} skip |
| 163 | + * @const {number} time |
169 | 164 | */ |
170 | | - const skip: boolean = task.mode === 'skip' |
| 165 | + const time: number = this.end - this.start |
171 | 166 |
|
172 | | - /** |
173 | | - * Printed task. |
174 | | - * |
175 | | - * @var {string} state |
176 | | - */ |
177 | | - let state: string = '' |
178 | | - |
179 | | - state = ' '.repeat(getNames(task).length * 2) |
180 | | - state += this.getTaskSymbol(task) + ' ' |
181 | | - |
182 | | - if (task.type !== 'suite') { |
183 | | - this.log(state += skip ? this.colors.blackBright(task.name) : task.name) |
184 | | - } else { |
185 | | - /** |
186 | | - * Suite title. |
187 | | - * |
188 | | - * @var {string} suite |
189 | | - */ |
190 | | - let suite: string = '' |
191 | | - |
192 | | - if ('filepath' in task) { |
193 | | - suite = task.file.name |
194 | | - |
195 | | - if (task.file.projectName) { |
196 | | - state += this.formatProjectName(task.file.projectName, skip) + ' ' |
197 | | - } |
198 | | - } else { |
199 | | - suite = task.name |
200 | | - } |
201 | | - |
202 | | - suite += ` (${getTests(task).length})` |
203 | | - state += skip ? this.colors.blackBright(suite) : suite |
204 | | - |
205 | | - this.log(state) |
206 | | - |
207 | | - if (!skip) { |
208 | | - for (const subtask of task.tasks) void this.printTask(subtask, force) |
209 | | - } |
| 167 | + message = dedent` |
| 168 | + ${passes} tests passed in ${ |
| 169 | + time > 1000 |
| 170 | + ? `${(time / 1000).toFixed(2)}ms` |
| 171 | + : `${Math.round(time)}ms` |
210 | 172 | } |
| 173 | + ` |
| 174 | + |
| 175 | + title = '\u2705 Passed' |
211 | 176 | } |
212 | 177 |
|
213 | | - return void task |
| 178 | + return void await promisify<Notification>(notifier.notify.bind(notifier))({ |
| 179 | + message, |
| 180 | + sound: true, |
| 181 | + timeout: 15, |
| 182 | + title |
| 183 | + }) |
214 | 184 | } |
215 | 185 | } |
216 | 186 |
|
217 | | -export default VerboseReporter |
| 187 | +export default Notifier |
0 commit comments