borp 0.20.0 → 0.20.2

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.
@@ -16,7 +16,7 @@ jobs:
16
16
 
17
17
  strategy:
18
18
  matrix:
19
- node-version: [18.x, 20.x, 21.x, 22.x, 23.x]
19
+ node-version: [18.x, 20.x, 21.x, 22.x, 23.x, 24.x]
20
20
  os: [ubuntu-latest, windows-latest]
21
21
  exclude:
22
22
  - os: windows-latest
package/CLAUDE.md ADDED
@@ -0,0 +1,62 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Project Overview
6
+
7
+ Borp is a TypeScript-aware test runner for `node:test` with built-in code coverage support via c8. It's self-hosted and uses ESM modules throughout.
8
+
9
+ ## Development Commands
10
+
11
+ ### Primary Commands
12
+ - `npm test` - Run complete test suite (clean, lint, and unit tests)
13
+ - `npm run unit` - Run unit tests with coverage, excluding fixtures
14
+ - `npm run lint` - Run ESLint with neostandard configuration
15
+ - `npm run lint:fix` - Run ESLint with automatic fixes
16
+ - `npm run clean` - Remove build artifacts and test directories
17
+
18
+ ### Running Individual Tests
19
+ - Use borp directly: `node borp.js [options] [test-files]`
20
+ - With coverage: `node borp.js --coverage`
21
+ - Single test file: `node borp.js test/basic.test.js`
22
+
23
+ ## Architecture
24
+
25
+ ### Core Structure
26
+ - `borp.js` - Main CLI entry point with argument parsing and orchestration
27
+ - `lib/run.js` - Core test runner with TypeScript compilation support
28
+ - `lib/conf.js` - Configuration file loading (`.borp.yaml` or `.borp.yml`)
29
+
30
+ ### Key Features
31
+ - Automatic TypeScript compilation detection via `tsconfig.json`
32
+ - Multiple reporter support (spec, tap, dot, junit, github)
33
+ - Code coverage via c8 with customizable thresholds
34
+ - Watch mode for development
35
+ - Post-compilation hooks
36
+ - Configuration file support
37
+
38
+ ### Test Structure
39
+ - Tests use `node:test` with `@matteo.collina/tspl` for planning
40
+ - Test files follow `*.test.{js|ts}` pattern
41
+ - Fixtures in `fixtures/` directory demonstrate various scenarios
42
+ - Coverage excludes test files and fixtures by default
43
+
44
+ ### TypeScript Support
45
+ - Automatically compiles TypeScript when `tsconfig.json` found
46
+ - Supports both ESM and CJS module formats
47
+ - Source map support for debugging
48
+ - Incremental compilation for performance
49
+
50
+ ## Configuration
51
+
52
+ ### CLI Options
53
+ - Coverage: `--coverage` or `-C`
54
+ - Concurrency: `--concurrency` or `-c` (defaults to CPU count - 1)
55
+ - Timeout: `--timeout` or `-t` (default 30s)
56
+ - Watch: `--watch` or `-w`
57
+ - Reporter: `--reporter` or `-r`
58
+
59
+ ### Config File
60
+ Supports `.borp.yaml`/`.borp.yml` with:
61
+ - `files`: Array of test file globs
62
+ - `reporters`: Array of reporter configurations
package/borp.js CHANGED
@@ -23,51 +23,108 @@ process.on('unhandledRejection', (err) => {
23
23
  process.exit(1)
24
24
  })
25
25
 
26
+ function showHelp () {
27
+ console.log(`Usage: borp [options] [files...]
28
+
29
+ Options:
30
+ -h, --help Show this help message
31
+ -o, --only Only run tests with the 'only' option set
32
+ -w, --watch Re-run tests on changes
33
+ -p, --pattern <pattern> Run tests matching the given glob pattern
34
+ -c, --concurrency <num> Set number of concurrent tests (default: ${os.availableParallelism() - 1 || 1})
35
+ -C, --coverage Enable code coverage
36
+ -t, --timeout <ms> Set test timeout in milliseconds (default: 30000)
37
+ --no-timeout Disable test timeout
38
+ -X, --coverage-exclude Exclude patterns from coverage (can be used multiple times)
39
+ -i, --ignore <pattern> Ignore glob pattern (can be used multiple times)
40
+ --expose-gc Expose the gc() function to tests
41
+ -T, --no-typescript Disable automatic TypeScript compilation
42
+ -P, --post-compile <file> Execute file after TypeScript compilation
43
+ -r, --reporter <name> Set reporter (can be used multiple times, default: spec)
44
+ --check-coverage Enable coverage threshold checking
45
+ --lines <threshold> Set lines coverage threshold (default: 100)
46
+ --branches <threshold> Set branches coverage threshold (default: 100)
47
+ --functions <threshold> Set functions coverage threshold (default: 100)
48
+ --statements <threshold> Set statements coverage threshold (default: 100)
49
+
50
+ Examples:
51
+ borp # Run all tests
52
+ borp --coverage # Run tests with coverage
53
+ borp --watch # Run tests in watch mode
54
+ borp test/specific.test.js # Run specific test file
55
+ borp --reporter tap --reporter gh # Use multiple reporters`)
56
+ }
57
+
26
58
  const foundConfig = await loadConfig()
27
59
  if (foundConfig.length > 0) {
28
60
  Array.prototype.push.apply(process.argv, foundConfig)
29
61
  }
30
62
 
31
- const args = parseArgs({
32
- args: process.argv.slice(2),
33
- options: {
34
- only: { type: 'boolean', short: 'o' },
35
- watch: { type: 'boolean', short: 'w' },
36
- pattern: { type: 'string', short: 'p' },
37
- concurrency: { type: 'string', short: 'c', default: (os.availableParallelism() - 1 || 1) + '' },
38
- coverage: { type: 'boolean', short: 'C' },
39
- timeout: { type: 'string', short: 't', default: '30000' },
40
- 'no-timeout': { type: 'boolean' },
41
- 'coverage-exclude': { type: 'string', short: 'X', multiple: true },
42
- ignore: { type: 'string', short: 'i', multiple: true },
43
- 'expose-gc': { type: 'boolean' },
44
- help: { type: 'boolean', short: 'h' },
45
- 'no-typescript': { type: 'boolean', short: 'T' },
46
- 'post-compile': { type: 'string', short: 'P' },
47
- reporter: {
48
- type: 'string',
49
- short: 'r',
50
- default: ['spec'],
51
- multiple: true
52
- },
53
- 'check-coverage': { type: 'boolean' },
54
- lines: { type: 'string', default: '100' },
55
- branches: { type: 'string', default: '100' },
56
- functions: { type: 'string', default: '100' },
57
- statements: { type: 'string', default: '100' }
63
+ const optionsConfig = {
64
+ only: { type: 'boolean', short: 'o' },
65
+ watch: { type: 'boolean', short: 'w' },
66
+ pattern: { type: 'string', short: 'p' },
67
+ concurrency: { type: 'string', short: 'c', default: (os.availableParallelism() - 1 || 1) + '' },
68
+ coverage: { type: 'boolean', short: 'C' },
69
+ timeout: { type: 'string', short: 't', default: '30000' },
70
+ 'no-timeout': { type: 'boolean' },
71
+ 'coverage-exclude': { type: 'string', short: 'X', multiple: true },
72
+ ignore: { type: 'string', short: 'i', multiple: true },
73
+ 'expose-gc': { type: 'boolean' },
74
+ help: { type: 'boolean', short: 'h' },
75
+ 'no-typescript': { type: 'boolean', short: 'T' },
76
+ 'post-compile': { type: 'string', short: 'P' },
77
+ reporter: {
78
+ type: 'string',
79
+ short: 'r',
80
+ default: ['spec'],
81
+ multiple: true
58
82
  },
59
- allowPositionals: true
60
- })
83
+ 'check-coverage': { type: 'boolean' },
84
+ lines: { type: 'string', default: '100' },
85
+ branches: { type: 'string', default: '100' },
86
+ functions: { type: 'string', default: '100' },
87
+ statements: { type: 'string', default: '100' }
88
+ }
61
89
 
62
- /* c8 ignore next 5 */
90
+ let args
91
+ try {
92
+ args = parseArgs({
93
+ args: process.argv.slice(2),
94
+ options: optionsConfig,
95
+ allowPositionals: true
96
+ })
97
+ } catch (error) {
98
+ if (error.code === 'ERR_PARSE_ARGS_UNKNOWN_OPTION') {
99
+ console.error(`Error: ${error.message}\n`)
100
+ // Send help to stderr when showing error
101
+ const originalConsoleLog = console.log
102
+ console.log = console.error
103
+ showHelp()
104
+ console.log = originalConsoleLog
105
+ process.exit(1)
106
+ }
107
+ throw error
108
+ }
109
+
110
+ /* c8 ignore next 4 */
63
111
  if (args.values.help) {
64
- console.log(await readFile(new URL('./README.md', import.meta.url), 'utf8'))
112
+ showHelp()
65
113
  process.exit(0)
66
114
  }
67
115
 
116
+ /* c8 ignore next 20 */
68
117
  if (args.values['expose-gc'] && typeof global.gc !== 'function') {
118
+ const args = [...process.argv.slice(1)]
119
+ const nodeVersion = process.version.split('.').map((v) => parseInt(v.replace('v', '')))[0]
120
+ if (nodeVersion >= 24) {
121
+ process.env.NODE_OPTIONS = (process.env.NODE_OPTIONS ? process.env.NODE_OPTIONS + ' ' : '') + '--expose-gc'
122
+ } else {
123
+ args.unshift('--expose-gc')
124
+ }
125
+
69
126
  try {
70
- await execa('node', ['--expose-gc', ...process.argv.slice(1)], {
127
+ await execa('node', args, {
71
128
  stdio: 'inherit',
72
129
  env: {
73
130
  ...process.env
@@ -0,0 +1,10 @@
1
+ import neostandard from 'neostandard'
2
+
3
+ export default neostandard({
4
+ ts: true,
5
+ ignores: [
6
+ 'fixtures/**/dist/**',
7
+ 'fixtures/**/node_modules/**',
8
+ '**/.test-*/**'
9
+ ]
10
+ })
@@ -5,6 +5,6 @@ test('this will take a long time', (t, done) => {
5
5
  setTimeout(() => {
6
6
  ok(true)
7
7
  done()
8
- }, 1e3)
8
+ }, 5000) // 5 seconds - longer than the 1-2s timeouts
9
9
  console.log('test:waiting')
10
10
  })
@@ -1,4 +1,3 @@
1
-
2
1
  export function add (x: number, y: number): number {
3
2
  return x + y
4
3
  }
@@ -1,4 +1,3 @@
1
-
2
1
  export function add (x: number, y: number): number {
3
2
  return x + y
4
3
  }
@@ -1,4 +1,3 @@
1
-
2
1
  export function add (x: number, y: number): number {
3
2
  return x + y
4
3
  }
@@ -1,4 +1,3 @@
1
-
2
1
  export function add (x: number, y: number): number {
3
2
  return x + y
4
3
  }
@@ -1 +1 @@
1
- console.log('Doing stuff')
1
+ console.log('Doing stuff')
@@ -1,4 +1,3 @@
1
-
2
1
  export function add (x: number, y: number): number {
3
2
  return x + y
4
3
  }
@@ -1,4 +1,3 @@
1
-
2
1
  export function add (x: number, y: number): number {
3
2
  return x + y
4
3
  }
@@ -1,4 +1,3 @@
1
-
2
1
  export function add (x: number, y: number): number {
3
2
  return x + y
4
3
  }
@@ -1,2 +1 @@
1
-
2
- console.log('Doing stuff')
1
+ console.log('Doing stuff')
@@ -1,4 +1,3 @@
1
-
2
1
  export function add (x: number, y: number): number {
3
2
  return x + y
4
3
  }
@@ -1,4 +1,3 @@
1
-
2
1
  export function add (x: number, y: number): number {
3
2
  return x + y
4
3
  }
@@ -1,4 +1,3 @@
1
-
2
1
  export function add (x: number, y: number): number {
3
2
  return x + y
4
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "borp",
3
- "version": "0.20.0",
3
+ "version": "0.20.2",
4
4
  "type": "module",
5
5
  "description": "node:test wrapper with TypeScript support",
6
6
  "main": "borp.js",
@@ -13,22 +13,27 @@
13
13
  },
14
14
  "scripts": {
15
15
  "clean": "rm -rf fixtures/*/dist .test-*",
16
- "lint": "standard | snazzy",
16
+ "lint": "eslint .",
17
+ "lint:fix": "eslint . --fix",
17
18
  "unit": "node borp.js --ignore \"fixtures/**/*\" -C --coverage-exclude \"fixtures/**/*\" --coverage-exclude \"test*/**/*\"",
18
19
  "test": "npm run clean ; npm run lint && npm run unit"
19
20
  },
20
21
  "keywords": [],
21
22
  "author": "Matteo Collina <hello@matteocollina.com>",
22
23
  "license": "MIT",
24
+ "pre-commit": [
25
+ "lint"
26
+ ],
23
27
  "devDependencies": {
28
+ "@fastify/pre-commit": "^2.2.0",
24
29
  "@matteo.collina/tspl": "^0.1.0",
25
30
  "@reporters/silent": "^1.2.4",
26
31
  "@sinonjs/fake-timers": "^14.0.0",
27
- "@types/node": "^22.2.0",
32
+ "@types/node": "^24.0.14",
28
33
  "desm": "^1.3.0",
29
34
  "semver": "^7.6.3",
30
- "snazzy": "^9.0.0",
31
- "standard": "^17.1.0",
35
+ "eslint": "^9.9.1",
36
+ "neostandard": "^0.11.0",
32
37
  "typescript": "^5.3.2"
33
38
  },
34
39
  "dependencies": {
@@ -0,0 +1,92 @@
1
+ import { test } from 'node:test'
2
+ import { execa } from 'execa'
3
+ import { join } from 'desm'
4
+ import { rejects, strictEqual } from 'node:assert'
5
+
6
+ const borp = join(import.meta.url, '..', 'borp.js')
7
+ const isWindows = process.platform === 'win32'
8
+
9
+ delete process.env.GITHUB_ACTION
10
+
11
+ test('invalid option shows help text', { skip: isWindows }, async () => {
12
+ const testCwd = join(import.meta.url, '..', 'fixtures', 'js-esm')
13
+
14
+ await rejects(async () => {
15
+ await execa('node', [borp, '--invalid-option'], {
16
+ cwd: testCwd,
17
+ timeout: 15000
18
+ })
19
+ throw new Error('Expected command to fail')
20
+ }, (error) => {
21
+ strictEqual(error.exitCode, 1)
22
+ strictEqual(error.stderr.includes('Error: Unknown option \'--invalid-option\''), true, 'Should show error message')
23
+ strictEqual(error.stderr.includes('Usage: borp [options] [files...]'), true, 'Should show usage line')
24
+ strictEqual(error.stderr.includes('--help'), true, 'Should show help option')
25
+ strictEqual(error.stderr.includes('--coverage'), true, 'Should show coverage option')
26
+ strictEqual(error.stderr.includes('Examples:'), true, 'Should show examples section')
27
+ return true
28
+ })
29
+ })
30
+
31
+ test('multiple invalid options show help text', { skip: isWindows }, async () => {
32
+ const testCwd = join(import.meta.url, '..', 'fixtures', 'js-esm')
33
+
34
+ await rejects(async () => {
35
+ await execa('node', [borp, '--foo', '--bar'], {
36
+ cwd: testCwd,
37
+ timeout: 15000
38
+ })
39
+ throw new Error('Expected command to fail')
40
+ }, (error) => {
41
+ strictEqual(error.exitCode, 1)
42
+ strictEqual(error.stderr.includes('Error: Unknown option \'--foo\''), true, 'Should show error for first invalid option')
43
+ strictEqual(error.stderr.includes('Usage: borp [options] [files...]'), true, 'Should show help text')
44
+ return true
45
+ })
46
+ })
47
+
48
+ test('invalid short option shows help text', { skip: isWindows }, async () => {
49
+ const testCwd = join(import.meta.url, '..', 'fixtures', 'js-esm')
50
+
51
+ await rejects(async () => {
52
+ await execa('node', [borp, '-z'], {
53
+ cwd: testCwd,
54
+ timeout: 15000
55
+ })
56
+ throw new Error('Expected command to fail')
57
+ }, (error) => {
58
+ strictEqual(error.exitCode, 1)
59
+ strictEqual(error.stderr.includes('Error: Unknown option \'-z\''), true, 'Should show error message')
60
+ strictEqual(error.stderr.includes('Usage: borp [options] [files...]'), true, 'Should show help text')
61
+ return true
62
+ })
63
+ })
64
+
65
+ test('--help option shows help text and exits successfully', { skip: isWindows }, async () => {
66
+ const testCwd = join(import.meta.url, '..', 'fixtures', 'js-esm')
67
+
68
+ const { stdout, exitCode } = await execa('node', [borp, '--help'], {
69
+ cwd: testCwd,
70
+ timeout: 15000
71
+ })
72
+
73
+ strictEqual(exitCode, 0, 'Should exit with code 0')
74
+ strictEqual(stdout.includes('Usage: borp [options] [files...]'), true, 'Should show usage line')
75
+ strictEqual(stdout.includes('--help'), true, 'Should show help option')
76
+ strictEqual(stdout.includes('--coverage'), true, 'Should show coverage option')
77
+ strictEqual(stdout.includes('Examples:'), true, 'Should show examples section')
78
+ strictEqual(stdout.includes('borp --coverage'), true, 'Should show coverage example')
79
+ })
80
+
81
+ test('-h option shows help text and exits successfully', { skip: isWindows }, async () => {
82
+ const testCwd = join(import.meta.url, '..', 'fixtures', 'js-esm')
83
+
84
+ const { stdout, exitCode } = await execa('node', [borp, '-h'], {
85
+ cwd: testCwd,
86
+ timeout: 15000
87
+ })
88
+
89
+ strictEqual(exitCode, 0, 'Should exit with code 0')
90
+ strictEqual(stdout.includes('Usage: borp [options] [files...]'), true, 'Should show usage line')
91
+ strictEqual(stdout.includes('Examples:'), true, 'Should show examples section')
92
+ })
@@ -52,9 +52,9 @@ test('borp should return right error when check coverage is active with default
52
52
  }
53
53
 
54
54
  equal(e.exitCode, 1)
55
- match(e.stderr, /ERROR: Coverage for lines \(75%\) does not meet global threshold \(100%\)/)
56
- match(e.stderr, /ERROR: Coverage for functions \(50%\) does not meet global threshold \(100%\)/)
57
- match(e.stderr, /ERROR: Coverage for statements \(75%\) does not meet global threshold \(100%\)/)
55
+ match(e.stderr, /ERROR: Coverage for lines \(\d+(?:.\d+)?%\) does not meet global threshold \(100%\)/)
56
+ match(e.stderr, /ERROR: Coverage for functions \(\d+(?:.\d+)?%\) does not meet global threshold \(100%\)/)
57
+ match(e.stderr, /ERROR: Coverage for statements \(\d+(?:.\d+)?%\) does not meet global threshold \(100%\)/)
58
58
  }
59
59
  })
60
60
 
@@ -78,6 +78,6 @@ test('borp should return right error when check coverage is active with defined
78
78
  }
79
79
 
80
80
  equal(e.exitCode, 1)
81
- match(e.stderr, /ERROR: Coverage for lines \(75%\) does not meet global threshold \(80%\)/)
81
+ match(e.stderr, /ERROR: Coverage for lines \(\d+(?:.\d+)?%\) does not meet global threshold \(80%\)/)
82
82
  }
83
83
  })
@@ -1,32 +1,29 @@
1
1
  import { test } from 'node:test'
2
2
  import { once } from 'node:events'
3
- import { pathToFileURL } from 'node:url'
4
3
  import { fork } from 'node:child_process'
5
4
  import { tspl } from '@matteo.collina/tspl'
6
- import { join } from 'desm'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { join, dirname } from 'node:path'
7
7
 
8
- const borp = join(import.meta.url, '..', 'borp.js')
9
- const clock = join(import.meta.url, '..', 'test-utils', 'clock.js')
8
+ const __filename = fileURLToPath(import.meta.url)
9
+ const __dirname = dirname(__filename)
10
+ const borp = join(__dirname, '..', 'borp.js')
11
+ const longFixturePath = join(__dirname, '..', 'fixtures', 'long')
10
12
  const forkOpts = {
11
- cwd: join(import.meta.url, '..', 'fixtures', 'long'),
12
- env: { NODE_OPTIONS: `--import=${pathToFileURL(clock)}` },
13
+ cwd: longFixturePath,
13
14
  stdio: ['pipe', 'pipe', 'pipe', 'ipc']
14
15
  }
15
16
 
16
- test('times out after 30s by default', async (t) => {
17
+ test('times out after 2s by default', async (t) => {
17
18
  const { ok, equal } = tspl(t, { plan: 4 })
18
- const borpProcess = fork(borp, forkOpts)
19
+ const borpProcess = fork(borp, ['--timeout', '2000'], forkOpts)
19
20
  let stdout = ''
20
21
  borpProcess.stdout.on('data', (data) => {
21
22
  stdout += data
22
- if (data.includes('test:waiting')) {
23
- borpProcess.send(['tick', 30e3])
24
- borpProcess.send(['uninstall'])
25
- }
26
23
  })
27
24
  const [code] = await once(borpProcess, 'exit')
28
25
  equal(code, 1)
29
- ok(stdout.includes('test timed out after 30000ms'))
26
+ ok(stdout.includes('test timed out after 2000ms'))
30
27
  ok(stdout.includes('tests 1'))
31
28
  ok(stdout.includes('cancelled 1'))
32
29
  })
@@ -38,10 +35,6 @@ test('does not timeout when setting --no-timeout', async (t) => {
38
35
  let stdout = ''
39
36
  borpProcess.stdout.on('data', (data) => {
40
37
  stdout += data
41
- if (data.includes('test:waiting')) {
42
- borpProcess.send(['tick', 30e3])
43
- borpProcess.send(['uninstall'])
44
- }
45
38
  })
46
39
  const [code] = await once(borpProcess, 'exit')
47
40
  equal(code, 0)
@@ -52,18 +45,14 @@ test('does not timeout when setting --no-timeout', async (t) => {
52
45
 
53
46
  test('timeout is configurable', async (t) => {
54
47
  const { ok, equal } = tspl(t, { plan: 4 })
55
- const borpProcess = fork(borp, ['--timeout', '10000'], forkOpts)
48
+ const borpProcess = fork(borp, ['--timeout', '1000'], forkOpts)
56
49
  let stdout = ''
57
50
  borpProcess.stdout.on('data', (data) => {
58
51
  stdout += data
59
- if (data.includes('test:waiting')) {
60
- borpProcess.send(['tick', 10e3])
61
- borpProcess.send(['uninstall'])
62
- }
63
52
  })
64
53
  const [code] = await once(borpProcess, 'exit')
65
54
  equal(code, 1)
66
- ok(stdout.includes('test timed out after 10000ms'))
55
+ ok(stdout.includes('test timed out after 1000ms'))
67
56
  ok(stdout.includes('tests 1'))
68
57
  ok(stdout.includes('cancelled 1'))
69
58
  })
@@ -1,20 +0,0 @@
1
- import FakeTimers from '@sinonjs/fake-timers'
2
-
3
- let clock
4
-
5
- if (process.argv[1].endsWith('borp.js')) {
6
- clock = FakeTimers.install({
7
- now: Date.now(),
8
- shouldAdvanceTime: true,
9
- advanceTimeDelta: 100,
10
- toFake: ['Date', 'setTimeout', 'clearTimeout']
11
- })
12
- process.on('message', listener)
13
- }
14
-
15
- function listener ([fn, ...args]) {
16
- clock[fn](...args)
17
- if (fn === 'uninstall') {
18
- process.off('message', listener)
19
- }
20
- }