borp 0.4.2 → 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
 
@@ -91,7 +91,72 @@ Note the use of `incremental: true`, which speed up compilation massively.
91
91
  * `--timeout` or `-t`, timeouts the tests after a given time; default is 30000 ms
92
92
  * `--coverage-exclude` or `-X`, a list of comma-separated patterns to exclude from the coverage report. All tests files are ignored by default.
93
93
  * `--ignore` or `-i`, ignore a glob pattern, and not look for tests there
94
+ * `--expose-gc`, exposes the gc() function to tests
94
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
+ ```
95
160
 
96
161
  ## License
97
162
 
package/borp.js CHANGED
@@ -1,23 +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'
14
+ import { execa } from 'execa'
12
15
 
13
- let reporter
14
16
  /* c8 ignore next 4 */
15
- if (process.stdout.isTTY) {
16
- /* eslint new-cap: "off" */
17
- reporter = new spec()
18
- } else {
19
- reporter = tap
20
- }
17
+ process.on('unhandledRejection', (err) => {
18
+ console.error(err)
19
+ process.exit(1)
20
+ })
21
21
 
22
22
  const args = parseArgs({
23
23
  args: process.argv.slice(2),
@@ -30,17 +30,38 @@ const args = parseArgs({
30
30
  timeout: { type: 'string', short: 't', default: '30000' },
31
31
  'coverage-exclude': { type: 'string', short: 'X', multiple: true },
32
32
  ignore: { type: 'string', short: 'i', multiple: true },
33
- help: { type: 'boolean', short: 'h' }
33
+ 'expose-gc': { type: 'boolean' },
34
+ help: { type: 'boolean', short: 'h' },
35
+ reporter: {
36
+ type: 'string',
37
+ short: 'r',
38
+ default: ['spec'],
39
+ multiple: true
40
+ }
34
41
  },
35
42
  allowPositionals: true
36
43
  })
37
44
 
38
- /* c8 ignore next 4 */
45
+ /* c8 ignore next 5 */
39
46
  if (args.values.help) {
40
47
  console.log(await readFile(new URL('./README.md', import.meta.url), 'utf8'))
41
48
  process.exit(0)
42
49
  }
43
50
 
51
+ if (args.values['expose-gc'] && typeof global.gc !== 'function') {
52
+ try {
53
+ await execa('node', ['--expose-gc', ...process.argv.slice(1)], {
54
+ stdio: 'inherit',
55
+ env: {
56
+ ...process.env
57
+ }
58
+ })
59
+ process.exit(0)
60
+ } catch (error) {
61
+ process.exit(1)
62
+ }
63
+ }
64
+
44
65
  if (args.values.concurrency) {
45
66
  args.values.concurrency = parseInt(args.values.concurrency)
46
67
  }
@@ -63,13 +84,44 @@ const config = {
63
84
  }
64
85
 
65
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
+
66
116
  const stream = await runWithTypeScript(config)
67
117
 
68
118
  stream.on('test:fail', () => {
69
119
  process.exitCode = 1
70
120
  })
71
121
 
72
- stream.compose(reporter).pipe(process.stdout)
122
+ for (const [reporter, output] of pipes) {
123
+ stream.compose(reporter).pipe(output)
124
+ }
73
125
 
74
126
  await finished(stream)
75
127
 
@@ -0,0 +1,8 @@
1
+ import { doesNotThrow } from 'node:assert'
2
+ import { test } from 'node:test'
3
+
4
+ test('this needs gc', () => {
5
+ doesNotThrow(() => {
6
+ global.gc()
7
+ })
8
+ })
@@ -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
@@ -17,6 +17,10 @@ function deferred () {
17
17
  }
18
18
 
19
19
  export default async function runWithTypeScript (config) {
20
+ // This is a hack to override
21
+ // https://github.com/nodejs/node/commit/d5c9adf3df
22
+ delete process.env.NODE_TEST_CONTEXT
23
+
20
24
  const { cwd } = config
21
25
  let pushable = []
22
26
  const tsconfigPath = await findUp('tsconfig.json', { cwd })
@@ -36,6 +40,7 @@ export default async function runWithTypeScript (config) {
36
40
  if (!config.watch) {
37
41
  const start = Date.now()
38
42
  await execa('node', [tscPath], { cwd: dirname(tsconfigPath) })
43
+ process.stdout.write(`TypeScript compilation complete (${Date.now() - start}ms)\n`)
39
44
  pushable.push({
40
45
  type: 'test:diagnostic',
41
46
  data: {
@@ -53,10 +58,18 @@ export default async function runWithTypeScript (config) {
53
58
  }
54
59
  config.prefix = prefix
55
60
  config.setup = (test) => {
56
- for (const chunk of pushable) {
57
- 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
58
72
  }
59
- pushable = test.reporter
60
73
  }
61
74
 
62
75
  let tscChild
@@ -119,7 +132,7 @@ export default async function runWithTypeScript (config) {
119
132
  } else if (prefix) {
120
133
  files = await glob(join(prefix, join('**', '*.test.{cjs,mjs,js}')), { ignore, cwd, windowsPathsNoEscape: true })
121
134
  } else {
122
- 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 })
123
136
  }
124
137
 
125
138
  config.files = files
package/package.json CHANGED
@@ -1,12 +1,16 @@
1
1
  {
2
2
  "name": "borp",
3
- "version": "0.4.2",
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,
@@ -23,3 +25,22 @@ test('failing test set correct status code', async () => {
23
25
  cwd: join(import.meta.url, '..', 'fixtures', 'fails')
24
26
  }))
25
27
  })
28
+
29
+ test('--expose-gc flag enables garbage collection in tests', async () => {
30
+ await execa('node', [
31
+ borp,
32
+ '--expose-gc'
33
+ ], {
34
+ cwd: join(import.meta.url, '..', 'fixtures', 'gc')
35
+ })
36
+ })
37
+
38
+ test('failing test with --expose-gc flag sets correct status code', async () => {
39
+ // execa rejects if status code is not 0
40
+ await rejects(execa('node', [
41
+ borp,
42
+ '--expose-gc'
43
+ ], {
44
+ cwd: join(import.meta.url, '..', 'fixtures', 'fails')
45
+ }))
46
+ })
@@ -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
+ })