@reporters/gh 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.
Files changed (3) hide show
  1. package/README.md +41 -0
  2. package/index.js +260 -0
  3. package/package.json +31 -0
package/README.md ADDED
@@ -0,0 +1,41 @@
1
+ [![npm version](https://img.shields.io/npm/v/@reporters/gh)](https://www.npmjs.com/package/@reporters/gh) ![tests](https://github.com/MoLow/reporters/actions/workflows/test.yaml/badge.svg?branch=main) [![codecov](https://codecov.io/gh/MoLow/reporters/branch/main/graph/badge.svg?token=0LFVC8SCQV)](https://codecov.io/gh/MoLow/reporters)
2
+
3
+ # Github Actions Reporter
4
+ A Github actions reporter for `node:test`
5
+
6
+ ## Installation
7
+
8
+ ```bash
9
+ npm install --save-dev @reporters/gh
10
+ ```
11
+ or
12
+ ```bash
13
+ yarn add --dev @reporters/gh
14
+ ```
15
+
16
+ ## Usage
17
+
18
+ ```yaml
19
+ # .github/workflows/test.yml
20
+ - name: Run tests
21
+ run: node --test --test-reporter=@reporters/gh
22
+ ```
23
+
24
+ ## Result
25
+
26
+ when test failed, annotations will be added inside the github UI, with corresponding errors and diagnostics.
27
+ see [example run](https://github.com/MoLow/reporters/actions/runs/5607828636):
28
+
29
+ #### Inline annotations
30
+
31
+ <img width="810" alt="Inline Annotation" src="https://user-images.githubusercontent.com/8221854/254798653-0c06278e-696b-42eb-8275-364b7eb3133b.png">
32
+
33
+ additionally, Annotations and summary will be added to the summary of the test run.
34
+
35
+ #### Annotations
36
+
37
+ <img width="810" alt="Annotation" src="https://user-images.githubusercontent.com/8221854/254798495-38c2a8ea-c9e0-4e87-a13e-677826b72192.png">
38
+
39
+ #### Summary
40
+ <img width="815" alt="Summary" src="https://github.com/MoLow/reporters/assets/8221854/8934f5bb-3342-430c-9ae0-3c608a40c9f0">
41
+
package/index.js ADDED
@@ -0,0 +1,260 @@
1
+ 'use strict';
2
+
3
+ /* eslint-disable no-underscore-dangle */
4
+
5
+ const assert = require('node:assert');
6
+ const { styleText, inspect } = require('node:util');
7
+ const { Transform } = require('node:stream');
8
+ const { relative } = require('node:path');
9
+ // eslint-disable-next-line import/no-unresolved
10
+ const { spec: Spec } = require('node:test/reporters');
11
+ const { emitSummary, handleEvent, isTopLevelDiagnostic } = require('@reporters/github');
12
+
13
+ const reporterColorMap = {
14
+ 'test:fail': 'red',
15
+ 'test:pass': 'green',
16
+ 'test:diagnostic': 'blue',
17
+ warn: 'yellow',
18
+ error: 'red',
19
+ info: 'blue',
20
+ };
21
+
22
+ const reporterUnicodeSymbolMap = {
23
+ 'test:fail': '\u2716 ',
24
+ 'test:pass': '\u2714 ',
25
+ 'test:diagnostic': '\u2139 ',
26
+ 'test:coverage': '\u2139 ',
27
+ 'arrow:right': '\u25B6 ',
28
+ 'hyphen:minus': '\uFE63 ',
29
+ };
30
+
31
+ const indentMemo = new Map();
32
+ function indent(nesting) {
33
+ let value = indentMemo.get(nesting);
34
+ if (value === undefined) {
35
+ value = ' '.repeat(nesting);
36
+ indentMemo.set(nesting, value);
37
+ }
38
+ return value;
39
+ }
40
+
41
+ function inspectWithNoCustomRetry(obj, options) {
42
+ try {
43
+ return inspect(obj, options);
44
+ } catch {
45
+ /* c8 ignore next 2 */
46
+ return inspect(obj, { ...options, customInspect: false });
47
+ }
48
+ }
49
+
50
+ const inspectOptions = {
51
+ __proto__: null,
52
+ breakLength: Infinity,
53
+ };
54
+
55
+ function formatError(error, indentation) {
56
+ if (!error) return '';
57
+ const err = error.code === 'ERR_TEST_FAILURE' ? error.cause : error;
58
+ const message = inspectWithNoCustomRetry(err, inspectOptions).split(/\r?\n/).join(`\n${indentation} `);
59
+ return `\n${indentation} ${message}\n`;
60
+ }
61
+
62
+ const formatDuration = (m) => {
63
+ let ms = m;
64
+ if (ms < 0) ms = -ms;
65
+ const time = {
66
+ day: Math.floor(ms / 86400000),
67
+ hour: Math.floor(ms / 3600000) % 24,
68
+ minute: Math.floor(ms / 60000) % 60,
69
+ second: Math.floor(ms / 1000) % 60,
70
+ millisecond: Math.floor(ms) % 1000,
71
+ };
72
+ if (ms < 1 && ms > 0) {
73
+ return `${ms.toFixed(3)} milliseconds`;
74
+ }
75
+ if (time.day !== 0 || time.hour !== 0 || time.minute !== 0 || time.second !== 0) {
76
+ /* c8 ignore next 2 */
77
+ time.millisecond = 0;
78
+ }
79
+ return Object.entries(time)
80
+ .filter((val) => val[1] !== 0)
81
+ .map(([key, val]) => `${val} ${key}${val !== 1 ? 's' : ''}`)
82
+ .join(', ');
83
+ };
84
+
85
+ class SpecReporter extends Transform {
86
+ #isGitHubActions = Boolean(process.env.GITHUB_ACTIONS);
87
+
88
+ #specReporter = new Spec();
89
+
90
+ #stack = [];
91
+
92
+ #reported = [];
93
+
94
+ #failedTests = [];
95
+
96
+ #cwd = process.cwd();
97
+
98
+ #reportedGroup = false;
99
+
100
+ constructor() {
101
+ super({ __proto__: null, writableObjectMode: true });
102
+ if (this.#isGitHubActions) {
103
+ inspectOptions.colors = true;
104
+ }
105
+ }
106
+
107
+ #formatTestReport(type, data, prefix = '', indentation = '', hasChildren = false, showErrorDetails = true) {
108
+ let color = reporterColorMap[type] ?? 'white';
109
+ let symbol = reporterUnicodeSymbolMap[type] ?? ' ';
110
+ const { skip, todo } = data;
111
+ const durationMs = data.details?.duration_ms ? styleText(['gray', 'italic'], ` (${formatDuration(data.details.duration_ms)})`, { validateStream: !this.#isGitHubActions }) : '';
112
+ let title = `${data.name}${durationMs}`;
113
+
114
+ if (skip !== undefined) {
115
+ title += ` # ${typeof skip === 'string' && skip.length ? skip : 'SKIP'}`;
116
+ } else if (todo !== undefined) {
117
+ title += ` # ${typeof todo === 'string' && todo.length ? todo : 'TODO'}`;
118
+ }
119
+
120
+ const error = showErrorDetails ? formatError(data.details?.error, indentation) : '';
121
+ let err = error;
122
+ if (hasChildren) {
123
+ err = !error || data.details?.error?.failureType === 'subtestsFailed' ? '' : `\n${error}`;
124
+ }
125
+
126
+ if (skip !== undefined) {
127
+ color = 'gray';
128
+ symbol = reporterUnicodeSymbolMap['hyphen:minus'];
129
+ }
130
+
131
+ let ghGroup = '';
132
+ let p = prefix;
133
+ if (this.#isGitHubActions) {
134
+ ghGroup = '::group::';
135
+ if (this.#reportedGroup) {
136
+ p = `::endgroup::\n${prefix}`;
137
+ }
138
+ this.#reportedGroup = true;
139
+ }
140
+ return `${p}${ghGroup}${indentation}${styleText(color, `${symbol}${title}`, { validateStream: !this.#isGitHubActions })}${err}`;
141
+ }
142
+
143
+ #formatFailedTestResults() {
144
+ if (this.#failedTests.length === 0) {
145
+ /* c8 ignore next 2 */
146
+ return this.#reportedGroup ? '::endgroup::\n' : '';
147
+ }
148
+
149
+ const results = [
150
+ `\n${styleText('red', `${reporterUnicodeSymbolMap['test:fail']}failing tests:`, { validateStream: !this.#isGitHubActions })}\n`,
151
+ ];
152
+
153
+ if (this.#reportedGroup) {
154
+ results.unshift('::endgroup::\n');
155
+ this.#reportedGroup = false; // Reset the group state for the next run
156
+ }
157
+
158
+ for (let i = 0; i < this.#failedTests.length; i += 1) {
159
+ const test = this.#failedTests[i];
160
+ const formattedErr = this.#formatTestReport('test:fail', test);
161
+
162
+ if (test.file) {
163
+ const relPath = relative(this.#cwd, test.file);
164
+ const location = `test at ${relPath}:${test.line}:${test.column}`;
165
+ results.push(location);
166
+ }
167
+
168
+ results.push(formattedErr);
169
+ }
170
+
171
+ if (this.#reportedGroup) {
172
+ results.push('::endgroup::\n');
173
+ }
174
+
175
+ this.#failedTests = []; // Clean up the failed tests
176
+ return results.join('\n');
177
+ }
178
+
179
+ #handleTestReportEvent(type, data) {
180
+ const subtest = this.#stack.shift(); // This is the matching `test:start` event
181
+ if (subtest) {
182
+ assert(subtest.type === 'test:start');
183
+ assert(subtest.data.nesting === data.nesting);
184
+ assert(subtest.data.name === data.name);
185
+ }
186
+ let prefix = '';
187
+ while (this.#stack.length) {
188
+ // Report all the parent `test:start` events
189
+ const parent = this.#stack.pop();
190
+ assert(parent.type === 'test:start');
191
+ const msg = parent.data;
192
+ this.#reported.unshift(msg);
193
+ if (this.#isGitHubActions) {
194
+ prefix += `${reporterUnicodeSymbolMap['arrow:right']}${indent(msg.nesting)}${msg.name}\n`;
195
+ } else {
196
+ prefix += `${indent(msg.nesting)}${reporterUnicodeSymbolMap['arrow:right']}${msg.name}\n`;
197
+ }
198
+ }
199
+ let hasChildren = false;
200
+ if (this.#reported[0] && this.#reported[0].nesting === data.nesting
201
+ && this.#reported[0].name === data.name) {
202
+ this.#reported.shift();
203
+ hasChildren = true;
204
+ }
205
+ const indentation = indent(data.nesting);
206
+ return `${this.#formatTestReport(type, data, prefix, indentation, hasChildren, false)}\n`;
207
+ }
208
+
209
+ #handleEvent({ type, data }) {
210
+ if (this.#isGitHubActions) {
211
+ handleEvent({ type, data });
212
+ }
213
+ switch (type) {
214
+ case 'test:fail':
215
+ if (data.details?.error?.failureType !== 'subtestsFailed') {
216
+ this.#failedTests.push(data);
217
+ }
218
+ return this.#handleTestReportEvent(type, data);
219
+ case 'test:pass':
220
+ return this.#handleTestReportEvent(type, data);
221
+ case 'test:start':
222
+ this.#stack.unshift({ __proto__: null, data, type });
223
+ break;
224
+ case 'test:diagnostic': {
225
+ if (isTopLevelDiagnostic(data)) {
226
+ return '';
227
+ }
228
+ const diagnosticColor = reporterColorMap[data.level] || reporterColorMap.info;
229
+ return `${indent(data.nesting)}${styleText(diagnosticColor, `${reporterUnicodeSymbolMap[type]}${data.message}`, { validateStream: !this.#isGitHubActions })}\n`;
230
+ }
231
+ case 'test:summary':
232
+ // We report only the root test summary
233
+ if (data.file === undefined) {
234
+ /* c8 ignore next 2 */
235
+ return this.#formatFailedTestResults();
236
+ }
237
+ break;
238
+ default:
239
+ }
240
+ return ''; // No output for other event types
241
+ }
242
+
243
+ _transform({ type, data }, encoding, callback) {
244
+ if (type === 'test:coverage' || type === 'test:stderr' || type === 'test:stdout') {
245
+ /* c8 ignore next 3 */
246
+ this.#specReporter._transform({ type, data }, encoding, callback);
247
+ return;
248
+ }
249
+ callback(null, this.#handleEvent({ __proto__: null, type, data }));
250
+ }
251
+
252
+ _flush(callback) {
253
+ callback(null, this.#formatFailedTestResults());
254
+ if (this.#isGitHubActions) {
255
+ emitSummary();
256
+ }
257
+ }
258
+ }
259
+
260
+ module.exports = new SpecReporter();
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@reporters/gh",
3
+ "version": "1.0.1",
4
+ "description": "A github actions reporter for `node:test`",
5
+ "type": "commonjs",
6
+ "keywords": [
7
+ "github actions",
8
+ "node:test",
9
+ "test",
10
+ "reporter",
11
+ "reporters"
12
+ ],
13
+ "dependencies": {
14
+ "@actions/core": "^1.10.0",
15
+ "@reporters/github": "*"
16
+ },
17
+ "files": [
18
+ "./index.js"
19
+ ],
20
+ "scripts": {
21
+ "test": "node --test-reporter=./index.js --test-reporter-destination=stdout --test"
22
+ },
23
+ "bugs": {
24
+ "url": "https://github.com/MoLow/reporters/issues"
25
+ },
26
+ "main": "index.js",
27
+ "homepage": "https://github.com/MoLow/reporters/tree/main/packages/github",
28
+ "repository": "https://github.com/MoLow/reporters.git",
29
+ "author": "Moshe Atlow",
30
+ "license": "MIT"
31
+ }