borp 0.5.0 → 0.6.0

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.
@@ -19,10 +19,10 @@ jobs:
19
19
  node-version: [18.x, 20.x, 21.x]
20
20
  os: [ubuntu-latest, windows-latest]
21
21
  steps:
22
- - uses: actions/checkout@v3
22
+ - uses: actions/checkout@v4
23
23
 
24
24
  - name: Use Node.js
25
- uses: actions/setup-node@v2
25
+ uses: actions/setup-node@v4
26
26
  with:
27
27
  node-version: ${{ matrix.node-version }}
28
28
 
@@ -30,6 +30,16 @@ jobs:
30
30
  run: |
31
31
  npm install
32
32
 
33
+ - name: Lint
34
+ run: |
35
+ npm run lint
36
+
33
37
  - name: Run tests
34
38
  run: |
35
- npm run test
39
+ npm run unit -- --reporter spec --reporter md:report.md --reporter gh
40
+
41
+ - name: Upload report
42
+ shell: bash
43
+ if: success() || failure()
44
+ run: |
45
+ cat report.md >> "$GITHUB_STEP_SUMMARY"
package/README.md CHANGED
@@ -17,7 +17,7 @@ npm i borp --save-dev
17
17
  borp --coverage
18
18
  ```
19
19
 
20
- Borp will autumatically run all tests files matching `*.test.{js|ts}`.
20
+ Borp will automatically run all tests files matching `*.test.{js|ts}`.
21
21
 
22
22
  ### Example project setup
23
23
 
@@ -93,6 +93,70 @@ Note the use of `incremental: true`, which speed up compilation massively.
93
93
  * `--ignore` or `-i`, ignore a glob pattern, and not look for tests there
94
94
  * `--expose-gc`, exposes the gc() function to tests
95
95
  * `--pattern` or `-p`, run tests matching the given glob pattern
96
+ * `--reporter` or `-r`, set up a reporter, use a colon to set a file destination. Default: `spec`.
97
+
98
+ ## Reporters
99
+
100
+ Here are the available reporters:
101
+
102
+ * `md`: creates a markdown table, useful for setting up a Summary in your GitHub Action
103
+ * `gh`: emits `::error` workflow commands for GitHub Actions to show inlined error. Enabled by default when running on GHA.
104
+ * `tap`: outputs the test results in the TAP format.
105
+ * `spec`: outputs the test results in a human-readable format.
106
+ * `dot`: outputs the test results in a compact format, where each passing test is represented by a ., and each failing test is represented by a X.
107
+ * `junit`: outputs test results in a jUnit XML format
108
+
109
+ ## GitHub Action Summary
110
+
111
+ The following will automatically show the summary of the test run in the summary page of GitHub Actions.
112
+
113
+ ```yaml
114
+ name: ci
115
+
116
+ on:
117
+ push:
118
+ paths-ignore:
119
+ - 'docs/**'
120
+ - '*.md'
121
+ pull_request:
122
+ paths-ignore:
123
+ - 'docs/**'
124
+ - '*.md'
125
+
126
+ jobs:
127
+ test:
128
+ runs-on: ${{matrix.os}}
129
+
130
+ strategy:
131
+ matrix:
132
+ node-version: [18.x, 20.x, 21.x]
133
+ os: [ubuntu-latest, windows-latest]
134
+ steps:
135
+ - uses: actions/checkout@v4
136
+
137
+ - name: Use Node.js
138
+ uses: actions/setup-node@v4
139
+ with:
140
+ node-version: ${{ matrix.node-version }}
141
+
142
+ - name: Install
143
+ run: |
144
+ npm install
145
+
146
+ - name: Lint
147
+ run: |
148
+ npm run lint
149
+
150
+ - name: Run tests
151
+ run: |
152
+ npm run unit -- --reporter spec --reporter md:report.md
153
+
154
+ - name: Upload report
155
+ shell: bash
156
+ if: success() || failure()
157
+ run: |
158
+ cat report.md >> "$GITHUB_STEP_SUMMARY"
159
+ ```
96
160
 
97
161
  ## License
98
162
 
package/borp.js CHANGED
@@ -1,24 +1,23 @@
1
1
  #! /usr/bin/env node
2
2
 
3
3
  import { parseArgs } from 'node:util'
4
- import { tap, spec } from 'node:test/reporters'
4
+ import Reporters from 'node:test/reporters'
5
5
  import { mkdtemp, rm, readFile } from 'node:fs/promises'
6
+ import { createWriteStream } from 'node:fs'
6
7
  import { finished } from 'node:stream/promises'
7
8
  import { join, relative } from 'node:path'
8
9
  import posix from 'node:path/posix'
9
10
  import runWithTypeScript from './lib/run.js'
11
+ import { MarkdownReporter, GithubWorkflowFailuresReporter } from './lib/reporters.js'
10
12
  import { Report } from 'c8'
11
13
  import os from 'node:os'
12
14
  import { execa } from 'execa'
13
15
 
14
- let reporter
15
16
  /* c8 ignore next 4 */
16
- if (process.stdout.isTTY) {
17
- /* eslint new-cap: "off" */
18
- reporter = new spec()
19
- } else {
20
- reporter = tap
21
- }
17
+ process.on('unhandledRejection', (err) => {
18
+ console.error(err)
19
+ process.exit(1)
20
+ })
22
21
 
23
22
  const args = parseArgs({
24
23
  args: process.argv.slice(2),
@@ -32,7 +31,13 @@ const args = parseArgs({
32
31
  'coverage-exclude': { type: 'string', short: 'X', multiple: true },
33
32
  ignore: { type: 'string', short: 'i', multiple: true },
34
33
  'expose-gc': { type: 'boolean' },
35
- help: { type: 'boolean', short: 'h' }
34
+ help: { type: 'boolean', short: 'h' },
35
+ reporter: {
36
+ type: 'string',
37
+ short: 'r',
38
+ default: ['spec'],
39
+ multiple: true
40
+ }
36
41
  },
37
42
  allowPositionals: true
38
43
  })
@@ -79,13 +84,44 @@ const config = {
79
84
  }
80
85
 
81
86
  try {
87
+ const pipes = []
88
+
89
+ const reporters = {
90
+ ...Reporters,
91
+ md: new MarkdownReporter(config),
92
+ gh: new GithubWorkflowFailuresReporter(config),
93
+ /* eslint new-cap: "off" */
94
+ spec: new Reporters.spec()
95
+ }
96
+
97
+ // If we're running in a GitHub action, adds the gh reporter
98
+ // by default so that we can report failures to GitHub
99
+ if (process.env.GITHUB_ACTION) {
100
+ args.values.reporter.push('gh')
101
+ }
102
+
103
+ for (const input of args.values.reporter) {
104
+ const [name, dest] = input.split(':')
105
+ const reporter = reporters[name]
106
+ if (!reporter) {
107
+ throw new Error(`Unknown reporter: ${name}`)
108
+ }
109
+ let output = process.stdout
110
+ if (dest) {
111
+ output = createWriteStream(dest)
112
+ }
113
+ pipes.push([reporter, output])
114
+ }
115
+
82
116
  const stream = await runWithTypeScript(config)
83
117
 
84
118
  stream.on('test:fail', () => {
85
119
  process.exitCode = 1
86
120
  })
87
121
 
88
- stream.compose(reporter).pipe(process.stdout)
122
+ for (const [reporter, output] of pipes) {
123
+ stream.compose(reporter).pipe(output)
124
+ }
89
125
 
90
126
  await finished(stream)
91
127
 
@@ -0,0 +1,3 @@
1
+ export function add (x, y) {
2
+ return x + y
3
+ }
@@ -0,0 +1,7 @@
1
+ import { test } from 'node:test'
2
+ import { add } from '../lib/add.js'
3
+ import { strictEqual } from 'node:assert'
4
+
5
+ test('add', () => {
6
+ strictEqual(add(1, 2), 3)
7
+ })
@@ -0,0 +1,7 @@
1
+ import { test } from 'node:test'
2
+ import { add } from '../lib/add.js'
3
+ import { strictEqual } from 'node:assert'
4
+
5
+ test('add2', () => {
6
+ strictEqual(add(3, 2), 5)
7
+ })
@@ -0,0 +1,114 @@
1
+ import { Transform } from 'node:stream'
2
+ import { fileURLToPath } from 'node:url'
3
+
4
+ function normalizeFile (file, cwd) {
5
+ let res = file
6
+ if (file.startsWith('file://')) {
7
+ try {
8
+ res = fileURLToPath(new URL(file))
9
+ } catch (err) {
10
+ if (err.code === 'ERR_INVALID_FILE_URL_PATH') {
11
+ res = fileURLToPath(new URL(file.replace('file:///', 'file://')))
12
+ }
13
+ }
14
+ }
15
+ res = res.replace(cwd, '')
16
+ if (res.startsWith('/') || res.startsWith('\\')) {
17
+ res = res.slice(1)
18
+ }
19
+ return res
20
+ }
21
+
22
+ function eventToLine (event) {
23
+ return `* __${event.data.name}__, duration ${event.data.details.duration_ms}ms, line ${event.data.line}\n`
24
+ }
25
+
26
+ export class MarkdownReporter extends Transform {
27
+ constructor (opts) {
28
+ super({
29
+ ...opts,
30
+ objectMode: true
31
+ })
32
+
33
+ this._files = {}
34
+ this._cwd = opts?.cwd
35
+ }
36
+
37
+ getFile (path) {
38
+ const file = this._files[path] || {
39
+ pass: [],
40
+ fail: []
41
+ }
42
+ this._files[path] = file
43
+ return file
44
+ }
45
+
46
+ _transform (event, encoding, callback) {
47
+ if (!event.data.file) {
48
+ callback()
49
+ return
50
+ }
51
+
52
+ const path = normalizeFile(event.data.file, this._cwd)
53
+ const file = this.getFile(path)
54
+ switch (event.type) {
55
+ case 'test:pass':
56
+ file.pass.push(event)
57
+ break
58
+ case 'test:fail':
59
+ file.fail.push(event)
60
+ break
61
+ }
62
+
63
+ callback()
64
+ }
65
+
66
+ _flush (callback) {
67
+ this.push('# Summary\n')
68
+ for (const [path, file] of Object.entries(this._files)) {
69
+ this.push(`## ${path}\n`)
70
+ if (file.pass.length > 0) {
71
+ this.push('### :white_check_mark: Pass\n')
72
+ for (const event of file.pass) {
73
+ this.push(eventToLine(event))
74
+ }
75
+ }
76
+ if (file.fail.length > 0) {
77
+ this.push('### :x: Fail\n')
78
+ for (const event of file.fail) {
79
+ this.push(eventToLine(event))
80
+ }
81
+ }
82
+ }
83
+ this.push(null)
84
+ callback()
85
+ }
86
+ }
87
+
88
+ export class GithubWorkflowFailuresReporter extends Transform {
89
+ constructor (opts) {
90
+ super({
91
+ ...opts,
92
+ objectMode: true
93
+ })
94
+
95
+ this._files = {}
96
+ this._cwd = opts?.cwd
97
+ }
98
+
99
+ _transform (event, encoding, callback) {
100
+ if (!event.data.file) {
101
+ callback()
102
+ return
103
+ }
104
+
105
+ const path = normalizeFile(event.data.file, this._cwd)
106
+ switch (event.type) {
107
+ case 'test:fail':
108
+ this.push(`::error file=${path},line=${event.data.line}::${event.data.name}\n`)
109
+ break
110
+ }
111
+
112
+ callback()
113
+ }
114
+ }
package/lib/run.js CHANGED
@@ -58,10 +58,18 @@ export default async function runWithTypeScript (config) {
58
58
  }
59
59
  config.prefix = prefix
60
60
  config.setup = (test) => {
61
- for (const chunk of pushable) {
62
- test.reporter.push(chunk)
61
+ /* c8 ignore next 12 */
62
+ if (test.reporter) {
63
+ for (const chunk of pushable) {
64
+ test.reporter.push(chunk)
65
+ }
66
+ pushable = test.reporter
67
+ } else {
68
+ for (const chunk of pushable) {
69
+ test.push(chunk)
70
+ }
71
+ pushable = test
63
72
  }
64
- pushable = test.reporter
65
73
  }
66
74
 
67
75
  let tscChild
@@ -124,7 +132,7 @@ export default async function runWithTypeScript (config) {
124
132
  } else if (prefix) {
125
133
  files = await glob(join(prefix, join('**', '*.test.{cjs,mjs,js}')), { ignore, cwd, windowsPathsNoEscape: true })
126
134
  } else {
127
- files = await glob(join('**', '*.test.{cjs,mjs,js}'), { ignore, cwd, windowsPathsNoEscape: true })
135
+ files = await glob(join('**', '*.test.{cjs,mjs,js}'), { ignore, cwd, windowsPathsNoEscape: true, absolute: true })
128
136
  }
129
137
 
130
138
  config.files = files
package/package.json CHANGED
@@ -1,12 +1,16 @@
1
1
  {
2
2
  "name": "borp",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "node:test wrapper with TypeScript support",
6
6
  "main": "borp.js",
7
7
  "bin": {
8
8
  "borp": "borp.js"
9
9
  },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/mcollina/borp"
13
+ },
10
14
  "scripts": {
11
15
  "clean": "rm -rf fixtures/*/dist .test-*",
12
16
  "lint": "standard | snazzy",
@@ -25,7 +29,7 @@
25
29
  "typescript": "^5.3.2"
26
30
  },
27
31
  "dependencies": {
28
- "c8": "^8.0.1",
32
+ "c8": "^9.0.0",
29
33
  "execa": "^8.0.1",
30
34
  "find-up": "^7.0.0",
31
35
  "glob": "^10.3.10"
@@ -165,3 +165,24 @@ test('only-src', async (t) => {
165
165
 
166
166
  await completed
167
167
  })
168
+
169
+ test('js-esm', async (t) => {
170
+ const { strictEqual, completed } = tspl(t, { plan: 2 })
171
+ const config = {
172
+ files: [],
173
+ cwd: join(import.meta.url, '..', 'fixtures', 'js-esm')
174
+ }
175
+
176
+ const stream = await runWithTypeScript(config)
177
+
178
+ const names = new Set(['add', 'add2'])
179
+
180
+ stream.on('test:pass', (test) => {
181
+ strictEqual(names.has(test.name), true)
182
+ names.delete(test.name)
183
+ })
184
+
185
+ stream.resume()
186
+
187
+ await completed
188
+ })
package/test/cli.test.js CHANGED
@@ -5,6 +5,8 @@ import { rejects } from 'node:assert'
5
5
 
6
6
  const borp = join(import.meta.url, '..', 'borp.js')
7
7
 
8
+ delete process.env.GITHUB_ACTION
9
+
8
10
  test('limit concurrency', async () => {
9
11
  await execa('node', [
10
12
  borp,
@@ -3,6 +3,7 @@ import { match, doesNotMatch } from 'node:assert'
3
3
  import { execa } from 'execa'
4
4
  import { join } from 'desm'
5
5
 
6
+ delete process.env.GITHUB_ACTION
6
7
  const borp = join(import.meta.url, '..', 'borp.js')
7
8
 
8
9
  test('coverage', async () => {
@@ -0,0 +1,172 @@
1
+ import { MarkdownReporter, GithubWorkflowFailuresReporter } from '../lib/reporters.js'
2
+ import { test, describe } from 'node:test'
3
+ import { strictEqual } from 'node:assert'
4
+
5
+ const cwd = process.platform === 'win32' ? 'C:\\foo' : '/foo'
6
+ const base = process.platform === 'win32' ? 'file://C:\\foo\\test\\' : 'file:///foo/test/'
7
+
8
+ describe('MarkdownReporter', async () => {
9
+ test('should write a report', async () => {
10
+ const reporter = new MarkdownReporter({ cwd })
11
+
12
+ // This is skipped
13
+ reporter.write({
14
+ type: 'test:start',
15
+ data: {}
16
+ })
17
+
18
+ reporter.write({
19
+ type: 'test:pass',
20
+ data: {
21
+ name: 'add',
22
+ file: base + 'add.test.ts',
23
+ line: 1,
24
+ details: {
25
+ duration_ms: 100
26
+ }
27
+ }
28
+ })
29
+
30
+ reporter.write({
31
+ type: 'test:pass',
32
+ data: {
33
+ name: 'add2',
34
+ file: base + 'add.test.ts',
35
+ line: 2,
36
+ details: {
37
+ duration_ms: 100
38
+ }
39
+ }
40
+ })
41
+
42
+ reporter.write({
43
+ type: 'test:fail',
44
+ data: {
45
+ name: 'add3',
46
+ file: base + 'add.test.ts',
47
+ line: 10,
48
+ details: {
49
+ duration_ms: 100
50
+ }
51
+ }
52
+ })
53
+ reporter.end()
54
+
55
+ let output = ''
56
+ for await (const chunk of reporter) {
57
+ output += chunk
58
+ }
59
+
60
+ strictEqual(output.replaceAll('\\', '/'), `# Summary
61
+ ## test/add.test.ts
62
+ ### :white_check_mark: Pass
63
+ * __add__, duration 100ms, line 1
64
+ * __add2__, duration 100ms, line 2
65
+ ### :x: Fail
66
+ * __add3__, duration 100ms, line 10
67
+ `)
68
+ })
69
+
70
+ test('skip fail heading if no failing tests', async () => {
71
+ const reporter = new MarkdownReporter({ cwd })
72
+
73
+ reporter.write({
74
+ type: 'test:pass',
75
+ data: {
76
+ name: 'add',
77
+ file: base + 'add.test.ts',
78
+ line: 1,
79
+ details: {
80
+ duration_ms: 100
81
+ }
82
+ }
83
+ })
84
+
85
+ reporter.write({
86
+ type: 'test:pass',
87
+ data: {
88
+ name: 'add2',
89
+ file: base + 'add.test.ts',
90
+ line: 2,
91
+ details: {
92
+ duration_ms: 100
93
+ }
94
+ }
95
+ })
96
+
97
+ reporter.end()
98
+
99
+ let output = ''
100
+ for await (const chunk of reporter) {
101
+ output += chunk
102
+ }
103
+
104
+ strictEqual(output.replaceAll('\\', '/'), `# Summary
105
+ ## test/add.test.ts
106
+ ### :white_check_mark: Pass
107
+ * __add__, duration 100ms, line 1
108
+ * __add2__, duration 100ms, line 2
109
+ `)
110
+ })
111
+ })
112
+
113
+ describe('GithubWorkflowFailuresReporter', async () => {
114
+ test('should write error in github format', async () => {
115
+ const reporter = new GithubWorkflowFailuresReporter({ cwd })
116
+
117
+ // This is skipped
118
+ reporter.write({
119
+ type: 'test:start',
120
+ data: {}
121
+ })
122
+
123
+ reporter.write({
124
+ type: 'test:pass',
125
+ data: {
126
+ name: 'add',
127
+ file: base + 'add.test.ts',
128
+ line: 1,
129
+ details: {
130
+ duration_ms: 100
131
+ }
132
+ }
133
+ })
134
+
135
+ reporter.write({
136
+ type: 'test:fail',
137
+ data: {
138
+ name: 'add2',
139
+ file: base + 'add.test.ts',
140
+ line: 2,
141
+ details: {
142
+ duration_ms: 100
143
+ }
144
+ }
145
+ })
146
+
147
+ reporter.write({
148
+ type: 'test:fail',
149
+ data: {
150
+ name: 'add3',
151
+ file: base + 'add.test.ts',
152
+ line: 10,
153
+ details: {
154
+ duration_ms: 100
155
+ }
156
+ }
157
+ })
158
+ reporter.end()
159
+
160
+ let output = ''
161
+ for await (const chunk of reporter) {
162
+ output += chunk
163
+ }
164
+
165
+ const expected = [
166
+ '::error file=test/add.test.ts,line=2::add2\n',
167
+ '::error file=test/add.test.ts,line=10::add3\n'
168
+ ].join('')
169
+
170
+ strictEqual(output.replaceAll('\\', '/'), expected)
171
+ })
172
+ })