borp 0.5.0 → 0.7.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
@@ -1,6 +1,6 @@
1
1
  # borp
2
2
 
3
- Borp is a typescript-aware runner for tests written using `node:test`.
3
+ Borp is a typescript-aware test runner for `node:test`.
4
4
  It also support code coverage via [c8](http://npm.im/c8).
5
5
 
6
6
  Borp is self-hosted, i.e. Borp runs its own tests.
@@ -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,71 @@ 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
+ * `--no-typescript` or `-T`, disable automatic TypeScript compilation if `tsconfig.json` is found.
98
+
99
+ ## Reporters
100
+
101
+ Here are the available reporters:
102
+
103
+ * `md`: creates a markdown table, useful for setting up a Summary in your GitHub Action
104
+ * `gh`: emits `::error` workflow commands for GitHub Actions to show inlined error. Enabled by default when running on GHA.
105
+ * `tap`: outputs the test results in the TAP format.
106
+ * `spec`: outputs the test results in a human-readable format.
107
+ * `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.
108
+ * `junit`: outputs test results in a jUnit XML format
109
+
110
+ ## GitHub Action Summary
111
+
112
+ The following will automatically show the summary of the test run in the summary page of GitHub Actions.
113
+
114
+ ```yaml
115
+ name: ci
116
+
117
+ on:
118
+ push:
119
+ paths-ignore:
120
+ - 'docs/**'
121
+ - '*.md'
122
+ pull_request:
123
+ paths-ignore:
124
+ - 'docs/**'
125
+ - '*.md'
126
+
127
+ jobs:
128
+ test:
129
+ runs-on: ${{matrix.os}}
130
+
131
+ strategy:
132
+ matrix:
133
+ node-version: [18.x, 20.x, 21.x]
134
+ os: [ubuntu-latest, windows-latest]
135
+ steps:
136
+ - uses: actions/checkout@v4
137
+
138
+ - name: Use Node.js
139
+ uses: actions/setup-node@v4
140
+ with:
141
+ node-version: ${{ matrix.node-version }}
142
+
143
+ - name: Install
144
+ run: |
145
+ npm install
146
+
147
+ - name: Lint
148
+ run: |
149
+ npm run lint
150
+
151
+ - name: Run tests
152
+ run: |
153
+ npm run unit -- --reporter spec --reporter md:report.md
154
+
155
+ - name: Upload report
156
+ shell: bash
157
+ if: success() || failure()
158
+ run: |
159
+ cat report.md >> "$GITHUB_STEP_SUMMARY"
160
+ ```
96
161
 
97
162
  ## License
98
163
 
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,14 @@ 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
+ 'no-typescript': { type: 'boolean', short: 'T' },
36
+ reporter: {
37
+ type: 'string',
38
+ short: 'r',
39
+ default: ['spec'],
40
+ multiple: true
41
+ }
36
42
  },
37
43
  allowPositionals: true
38
44
  })
@@ -73,19 +79,51 @@ if (args.values.coverage) {
73
79
 
74
80
  const config = {
75
81
  ...args.values,
82
+ typescript: !args.values['no-typescript'],
76
83
  files: args.positionals,
77
84
  pattern: args.values.pattern,
78
85
  cwd: process.cwd()
79
86
  }
80
87
 
81
88
  try {
89
+ const pipes = []
90
+
91
+ const reporters = {
92
+ ...Reporters,
93
+ md: new MarkdownReporter(config),
94
+ gh: new GithubWorkflowFailuresReporter(config),
95
+ /* eslint new-cap: "off" */
96
+ spec: new Reporters.spec()
97
+ }
98
+
99
+ // If we're running in a GitHub action, adds the gh reporter
100
+ // by default so that we can report failures to GitHub
101
+ if (process.env.GITHUB_ACTION) {
102
+ args.values.reporter.push('gh')
103
+ }
104
+
105
+ for (const input of args.values.reporter) {
106
+ const [name, dest] = input.split(':')
107
+ const reporter = reporters[name]
108
+ if (!reporter) {
109
+ throw new Error(`Unknown reporter: ${name}`)
110
+ }
111
+ let output = process.stdout
112
+ if (dest) {
113
+ output = createWriteStream(dest)
114
+ }
115
+ pipes.push([reporter, output])
116
+ }
117
+
82
118
  const stream = await runWithTypeScript(config)
83
119
 
84
120
  stream.on('test:fail', () => {
85
121
  process.exitCode = 1
86
122
  })
87
123
 
88
- stream.compose(reporter).pipe(process.stdout)
124
+ for (const [reporter, output] of pipes) {
125
+ stream.compose(reporter).pipe(output)
126
+ }
89
127
 
90
128
  await finished(stream)
91
129
 
@@ -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,4 @@
1
+
2
+ export function add (x: number, y: number): number {
3
+ return x + y
4
+ }
@@ -0,0 +1,7 @@
1
+ import { test } from 'node:test'
2
+ import { add } from '../src/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 '../src/add.js'
3
+ import { strictEqual } from 'node:assert'
4
+
5
+ test('add2', () => {
6
+ strictEqual(add(3, 2), 5)
7
+ })
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "sourceMap": true,
6
+ "target": "ES2022",
7
+ "module": "NodeNext",
8
+ "moduleResolution": "NodeNext",
9
+ "esModuleInterop": true,
10
+ "strict": true,
11
+ "resolveJsonModule": true,
12
+ "removeComments": true,
13
+ "newLine": "lf",
14
+ "noUnusedLocals": true,
15
+ "noFallthroughCasesInSwitch": true,
16
+ "isolatedModules": true,
17
+ "forceConsistentCasingInFileNames": true,
18
+ "skipLibCheck": true,
19
+ "lib": [
20
+ "ESNext"
21
+ ],
22
+ "incremental": true
23
+ }
24
+ }
@@ -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
@@ -28,7 +28,7 @@ export default async function runWithTypeScript (config) {
28
28
  let prefix = ''
29
29
  let tscPath
30
30
 
31
- if (tsconfigPath) {
31
+ if (tsconfigPath && config.typescript !== false) {
32
32
  const _require = createRequire(tsconfigPath)
33
33
  const typescriptPathCWD = _require.resolve('typescript')
34
34
  tscPath = join(typescriptPathCWD, '..', '..', 'bin', 'tsc')
@@ -56,12 +56,25 @@ export default async function runWithTypeScript (config) {
56
56
  prefix = join(dirname(tsconfigPath), outDir)
57
57
  }
58
58
  }
59
+
60
+ // TODO remove those and create a new object
61
+ delete config.typescript
62
+ delete config['no-typescript']
63
+
59
64
  config.prefix = prefix
60
65
  config.setup = (test) => {
61
- for (const chunk of pushable) {
62
- test.reporter.push(chunk)
66
+ /* c8 ignore next 12 */
67
+ if (test.reporter) {
68
+ for (const chunk of pushable) {
69
+ test.reporter.push(chunk)
70
+ }
71
+ pushable = test.reporter
72
+ } else {
73
+ for (const chunk of pushable) {
74
+ test.push(chunk)
75
+ }
76
+ pushable = test
63
77
  }
64
- pushable = test.reporter
65
78
  }
66
79
 
67
80
  let tscChild
@@ -124,7 +137,7 @@ export default async function runWithTypeScript (config) {
124
137
  } else if (prefix) {
125
138
  files = await glob(join(prefix, join('**', '*.test.{cjs,mjs,js}')), { ignore, cwd, windowsPathsNoEscape: true })
126
139
  } else {
127
- files = await glob(join('**', '*.test.{cjs,mjs,js}'), { ignore, cwd, windowsPathsNoEscape: true })
140
+ files = await glob(join('**', '*.test.{cjs,mjs,js}'), { ignore, cwd, windowsPathsNoEscape: true, absolute: true })
128
141
  }
129
142
 
130
143
  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.7.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"
@@ -46,7 +46,7 @@ test('ts-cjs', async (t) => {
46
46
  await completed
47
47
  })
48
48
 
49
- test('ts-esm with named failes', async (t) => {
49
+ test('ts-esm with named files', async (t) => {
50
50
  const { strictEqual, completed, match } = tspl(t, { plan: 3 })
51
51
  const config = {
52
52
  files: ['test/add.test.ts'],
@@ -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
@@ -1,10 +1,14 @@
1
1
  import { test } from 'node:test'
2
2
  import { execa } from 'execa'
3
3
  import { join } from 'desm'
4
- import { rejects } from 'node:assert'
4
+ import { rejects, strictEqual } from 'node:assert'
5
+ import { rm } from 'node:fs/promises'
6
+ import path from 'node:path'
5
7
 
6
8
  const borp = join(import.meta.url, '..', 'borp.js')
7
9
 
10
+ delete process.env.GITHUB_ACTION
11
+
8
12
  test('limit concurrency', async () => {
9
13
  await execa('node', [
10
14
  borp,
@@ -42,3 +46,17 @@ test('failing test with --expose-gc flag sets correct status code', async () =>
42
46
  cwd: join(import.meta.url, '..', 'fixtures', 'fails')
43
47
  }))
44
48
  })
49
+
50
+ test('disable ts and run no tests', async () => {
51
+ const cwd = join(import.meta.url, '..', 'fixtures', 'ts-esm2')
52
+ await rm(path.join(cwd, 'dist'), { recursive: true, force: true })
53
+ const { stdout } = await execa('node', [
54
+ borp,
55
+ '--reporter=spec',
56
+ '--no-typescript'
57
+ ], {
58
+ cwd
59
+ })
60
+
61
+ strictEqual(stdout.indexOf('tests 0') >= 0, true)
62
+ })
@@ -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
+ })