brittle-jobs 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 matheus1lva
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # brittle-jobs
2
+
3
+ Run [brittle](https://github.com/holepunchto/brittle) test files concurrently, one process per file, on Node or Bare.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install --save-dev brittle-jobs
9
+ ```
10
+
11
+ `brittle` is a peer dependency: the consumer's install is what runs each file.
12
+
13
+ ## Usage
14
+
15
+ ```bash
16
+ brittle-jobs test/*.js
17
+ brittle-jobs --bare -j 4 test/all.ts
18
+ ```
19
+
20
+ ```
21
+ brittle-jobs [flags] <files...>
22
+
23
+ --bare run test files with bare instead of node
24
+ --jobs|-j <n> test files to run concurrently (positive integer; default: all cores)
25
+ --bail|-b bail on first assert failure and stop scheduling files
26
+ --timeout|-t <ms> per-test timeout passed to brittle
27
+ ```
28
+
29
+ Each file runs as `<node|bare> brittle/cmd.js [--bail] [--timeout ms] <file>`, exactly what
30
+ `brittle-node`/`brittle-bare` do, so per-file semantics are unchanged. `bare` is resolved on `PATH`
31
+ (npm and bun scripts put `node_modules/.bin` there).
32
+
33
+ Output is one line per file as it finishes, the full spec-formatted TAP for any file that fails, and a
34
+ summary. Exit code is 1 when any file fails its TAP or exits non-zero.
35
+
36
+ ```
37
+ 200 files · bare · jobs=10
38
+
39
+ ✓ test/basic/chat.ts · 12 tests · 3.2s
40
+ ✗ test/basic/boot.ts · 4.1s
41
+ ...
42
+ 198/200 files passed · 1840 tests · 61.3s
43
+ failed: test/basic/boot.ts, test/api/mesh.ts
44
+ ```
45
+
46
+ ### Runner files
47
+
48
+ A generated runner containing only the declarative wrapper emitted by `brittle-make-test` is expanded
49
+ into the files it loads instead of being run as one process. A line prefixed with `if (isBare)` is
50
+ skipped when running on node:
51
+
52
+ ```js
53
+ const isBare = typeof Bare !== 'undefined'
54
+
55
+ await test.load(import.meta.resolve('./basic/chat.ts'))
56
+ if (isBare) await test.load(import.meta.resolve('./tui/keys.ts'))
57
+ ```
58
+
59
+ The runner file stays a valid brittle entrypoint, so `bare test/all.ts` still works serially. A file
60
+ with setup, inline tests, comments, unsupported guards, or other unrecognized source runs as one
61
+ process so its behavior stays intact. A runner with no applicable loads also runs as one process; a
62
+ file that emits incomplete or no TAP counts as failed.
63
+
64
+ `--jobs` limits the number of test file processes running at once. The default is the available CPU
65
+ parallelism, and each process uses the selected runtime (`node` or `bare`).
66
+
67
+ ## API
68
+
69
+ ```js
70
+ const run = require('brittle-jobs')
71
+
72
+ const { passed, failed, results } = await run(['test/all.ts'], {
73
+ bare: false,
74
+ jobs: 4,
75
+ bail: false,
76
+ timeout: 30000,
77
+ cwd: process.cwd(),
78
+ out: process.stdout
79
+ })
80
+ ```
81
+
82
+ `results` holds one entry per file: `{ file, results, code, signal, stderr, failed, ms }`, where
83
+ `results` is a [prettytap](https://www.npmjs.com/package/prettytap) `Results`.
84
+
85
+ ## License
86
+
87
+ MIT
package/bin.js ADDED
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ const process = require('process')
3
+ const paparam = require('paparam')
4
+ const run = require('./index')
5
+
6
+ const cmd = paparam
7
+ .command(
8
+ 'brittle-jobs',
9
+ paparam.flag('--bare', 'Run test files with bare instead of node'),
10
+ paparam.flag('--jobs|-j <n>', 'Test files to run concurrently (default: all cores)'),
11
+ paparam.flag('--bail|-b', 'Bail on first assert failure and stop scheduling files'),
12
+ paparam.flag('--timeout|-t <ms>', 'Per-test timeout passed to brittle'),
13
+ paparam.rest('<files>'),
14
+ paparam.bail((bail) => {
15
+ console.error(bail.reason)
16
+ console.error(bail.command.usage())
17
+ process.exit(1)
18
+ })
19
+ )
20
+ .parse()
21
+
22
+ if (!cmd) process.exit(0)
23
+ if (cmd.rest.length === 0) {
24
+ console.error('Error: No test files were specified')
25
+ process.exit(1)
26
+ }
27
+
28
+ const { bare, jobs, bail, timeout } = cmd.flags
29
+ let parsedJobs
30
+ if (jobs !== undefined) {
31
+ parsedJobs = Number(jobs)
32
+ try {
33
+ run.validateJobs(parsedJobs)
34
+ } catch {
35
+ console.error(`Error: --jobs must be a positive safe integer`)
36
+ process.exit(1)
37
+ }
38
+ }
39
+
40
+ run(cmd.rest, {
41
+ bare,
42
+ bail,
43
+ jobs: parsedJobs,
44
+ timeout: timeout ? Number(timeout) : undefined
45
+ })
46
+ .then(({ failed }) => {
47
+ if (failed && !process.exitCode) process.exitCode = 1
48
+ })
49
+ .catch((err) => {
50
+ console.error(`Error: ${err.message}`)
51
+ process.exitCode = 1
52
+ })
package/index.js ADDED
@@ -0,0 +1,116 @@
1
+ const os = require('os')
2
+ const path = require('path')
3
+ const expand = require('./lib/manifest')
4
+ const runFile = require('./lib/child')
5
+ const report = require('./lib/report')
6
+
7
+ module.exports = async function run(files, opts = {}) {
8
+ const cwd = opts.cwd ?? process.cwd()
9
+ const out = opts.out ?? process.stdout
10
+ const bare = !!opts.bare
11
+ const jobs = opts.jobs ?? os.availableParallelism()
12
+ validateJobs(jobs)
13
+ const runtime = bare ? 'bare' : process.execPath
14
+
15
+ const args = [require.resolve('brittle/cmd.js', { paths: [cwd] })]
16
+ if (opts.bail) args.push('--bail')
17
+ if (opts.timeout) args.push('--timeout', String(opts.timeout))
18
+
19
+ const queue = files.flatMap((file) => expand(path.resolve(cwd, file), { bare }))
20
+ const total = queue.length
21
+ const results = []
22
+ const running = new Set()
23
+ const start = Date.now()
24
+ let stop = false
25
+
26
+ const abort = () => {
27
+ stop = true
28
+ for (const r of running) {
29
+ try {
30
+ r.kill()
31
+ } catch {}
32
+ }
33
+ process.exitCode = 130
34
+ }
35
+ let listenerInstalled = false
36
+
37
+ try {
38
+ process.once('SIGINT', abort)
39
+ listenerInstalled = true
40
+
41
+ out.write(report.header({ total, runtime, jobs }))
42
+
43
+ await new Promise((resolve, reject) => {
44
+ let settled = false
45
+
46
+ const stopWithError = (err) => {
47
+ if (settled) return
48
+ stop = true
49
+ const pending = []
50
+ for (const r of running) {
51
+ try {
52
+ r.kill()
53
+ } catch {}
54
+ pending.push(Promise.resolve(r.promise).catch(() => {}))
55
+ }
56
+ settled = true
57
+ Promise.all(pending).then(() => {
58
+ running.clear()
59
+ reject(err)
60
+ })
61
+ }
62
+
63
+ const next = () => {
64
+ if (settled) return
65
+ if (running.size === 0 && (stop || queue.length === 0)) {
66
+ settled = true
67
+ resolve()
68
+ return
69
+ }
70
+ while (!stop && running.size < jobs && queue.length > 0) {
71
+ let r
72
+ try {
73
+ r = runFile(queue.shift(), { runtime, args, cwd })
74
+ } catch (err) {
75
+ stopWithError(err)
76
+ return
77
+ }
78
+ running.add(r)
79
+ Promise.resolve(r.promise).then((res) => {
80
+ if (settled) return
81
+ running.delete(r)
82
+ try {
83
+ results.push(res)
84
+ out.write(report.line(res, cwd))
85
+ if (res.failed) {
86
+ out.write(report.detail(res))
87
+ if (opts.bail) stop = true
88
+ }
89
+ } catch (err) {
90
+ stopWithError(err)
91
+ return
92
+ }
93
+ next()
94
+ }, stopWithError)
95
+ }
96
+ }
97
+
98
+ next()
99
+ })
100
+
101
+ out.write(report.summary(results, { total, ms: Date.now() - start, cwd }))
102
+ } finally {
103
+ if (listenerInstalled) process.off('SIGINT', abort)
104
+ }
105
+
106
+ const failed = results.filter((r) => r.failed).length
107
+ return { passed: results.length - failed, failed, results }
108
+ }
109
+
110
+ function validateJobs(jobs) {
111
+ if (typeof jobs !== 'number' || !Number.isSafeInteger(jobs) || jobs < 1) {
112
+ throw new TypeError('jobs must be a positive safe integer')
113
+ }
114
+ }
115
+
116
+ module.exports.validateJobs = validateJobs
package/lib/child.js ADDED
@@ -0,0 +1,66 @@
1
+ const path = require('path')
2
+ const { spawn } = require('child_process')
3
+ const { Parser } = require('prettytap')
4
+
5
+ const planLine = /^1\.\.(\d+)$/
6
+ const resultLine = /^(?:not )?ok\b/
7
+
8
+ function validateTap(results) {
9
+ // An empty stream is handled by the existing "no TAP output" report. Do not
10
+ // turn that into a second, less useful validation error.
11
+ if (!results.foundTapData) return null
12
+
13
+ const header = results.lines.findIndex((line) => /^TAP version \d+$/.test(line))
14
+ const tapLines = results.lines.slice(header + 1)
15
+ const plans = tapLines.map((line) => line.match(planLine)).filter(Boolean)
16
+ if (plans.length === 0) return 'missing TAP plan'
17
+ if (plans.length > 1) return 'multiple TAP plans'
18
+
19
+ const expectedTests = Number(plans[0][1])
20
+ if (!Number.isSafeInteger(expectedTests)) return 'invalid TAP plan count'
21
+ const observedTests = tapLines.filter((line) => resultLine.test(line)).length
22
+ if (observedTests !== expectedTests) {
23
+ return `TAP plan expected ${expectedTests} tests but observed ${observedTests}`
24
+ }
25
+
26
+ return null
27
+ }
28
+
29
+ module.exports = function runFile(file, { runtime, args, cwd }) {
30
+ const start = Date.now()
31
+ const child = spawn(runtime, [...args, path.relative(cwd, file)], {
32
+ cwd,
33
+ stdio: ['ignore', 'pipe', 'pipe']
34
+ })
35
+ const parser = new Parser()
36
+ let stderr = ''
37
+
38
+ child.stdout.setEncoding('utf8').on('data', (chunk) => parser.write(chunk))
39
+ child.stderr.setEncoding('utf8').on('data', (chunk) => {
40
+ stderr += chunk
41
+ })
42
+
43
+ const promise = new Promise((resolve) => {
44
+ let settled = false
45
+ const done = (code, signal) => {
46
+ if (settled) return
47
+ settled = true
48
+ parser.end()
49
+ const results = parser.results
50
+ const validationError = validateTap(results)
51
+ if (validationError !== null) {
52
+ results.validationError = validationError
53
+ stderr += `TAP validation failed: ${validationError}\n`
54
+ }
55
+ const failed =
56
+ !results.isPassing() || validationError !== null || code !== 0 || signal !== null
57
+ resolve({ file, results, code, signal, stderr, failed, ms: Date.now() - start })
58
+ }
59
+ child.on('error', (err) => {
60
+ stderr += err.message + '\n'
61
+ })
62
+ child.on('close', done)
63
+ })
64
+
65
+ return { promise, kill: () => child.kill() }
66
+ }
@@ -0,0 +1,105 @@
1
+ const fs = require('fs')
2
+ const { fileURLToPath, pathToFileURL } = require('url')
3
+
4
+ const HEADER = '// This runner is auto-generated by Brittle'
5
+ const IMPORT = /^const test = \(await import\((['"])brittle\1\)\)\.default;?$/
6
+ const FUNCTION = /^async\s+function runTests\s*\(\s*\)\s*\{$/
7
+ const LOAD = /^(if \(isBare\) )?await test\.load\(import\.meta\.resolve\((['"])([^'"]+)\2\)\);?$/
8
+
9
+ module.exports = function expand(file, { bare }) {
10
+ const specifiers = parse(fs.readFileSync(file, 'utf8'))
11
+ if (specifiers === null) return [file]
12
+
13
+ const files = []
14
+ for (const { guarded, specifier } of specifiers) {
15
+ if (guarded && !bare) continue
16
+ const resolved = resolve(file, specifier)
17
+ if (resolved === null) return [file]
18
+ files.push(resolved)
19
+ }
20
+ return files.length > 0 ? files : [file]
21
+ }
22
+
23
+ // Recognize only the small declarative wrapper emitted by brittle-make-test.
24
+ // Anything else must run as the original file so setup and inline tests remain
25
+ // in the same process and unsupported control flow is never guessed at.
26
+ function parse(source) {
27
+ const lines = source.split(/\r?\n/)
28
+ let i = 0
29
+
30
+ const blank = () => {
31
+ while (i < lines.length && lines[i].trim() === '') i++
32
+ }
33
+ const line = (pattern) => {
34
+ if (i >= lines.length || !pattern.test(lines[i].trim())) return false
35
+ i++
36
+ return true
37
+ }
38
+
39
+ blank()
40
+ if (lines[i]?.trim() === HEADER) i++
41
+ blank()
42
+ if (!line(/^await runTests\(\);?$/)) return null
43
+ blank()
44
+ if (!line(FUNCTION)) return null
45
+ blank()
46
+ if (!line(IMPORT)) return null
47
+ blank()
48
+ if (!line(/^test\.pause\(\);?$/)) return null
49
+
50
+ blank()
51
+ let hasIsBare = false
52
+ if (line(/^const isBare = typeof Bare !== (['"])undefined\1;?$/)) {
53
+ hasIsBare = true
54
+ blank()
55
+ }
56
+
57
+ const loads = []
58
+ let hasGuardedLoad = false
59
+ while (i < lines.length) {
60
+ const current = lines[i].trim()
61
+ if (current === '') {
62
+ blank()
63
+ continue
64
+ }
65
+ if (current === 'test.resume()' || current === 'test.resume();') break
66
+
67
+ const match = LOAD.exec(current)
68
+ if (!match) return null
69
+ if (match[1]) hasGuardedLoad = true
70
+ loads.push({ guarded: !!match[1], specifier: match[3] })
71
+ i++
72
+ }
73
+
74
+ if (!line(/^test\.resume\(\);?$/)) return null
75
+ blank()
76
+ if (!line(/^}$/)) return null
77
+ blank()
78
+ if (i !== lines.length) return null
79
+ if (hasGuardedLoad && !hasIsBare) return null
80
+ return loads
81
+ }
82
+
83
+ function resolve(file, specifier) {
84
+ // Keep the accepted subset equivalent to import.meta.resolve for relative
85
+ // file specifiers. A process can only be started with a filesystem path.
86
+ // The recognizer intentionally does not decode JavaScript string escapes.
87
+ // Reject them rather than resolving a different path from the runner.
88
+ if (
89
+ specifier.includes('\\') ||
90
+ (specifier !== '.' &&
91
+ specifier !== '..' &&
92
+ !specifier.startsWith('./') &&
93
+ !specifier.startsWith('../'))
94
+ ) {
95
+ return null
96
+ }
97
+
98
+ try {
99
+ const url = new URL(specifier, pathToFileURL(file))
100
+ if (url.protocol !== 'file:' || url.hostname || url.search || url.hash) return null
101
+ return fileURLToPath(url)
102
+ } catch {
103
+ return null
104
+ }
105
+ }
package/lib/report.js ADDED
@@ -0,0 +1,42 @@
1
+ const path = require('path')
2
+ const { SpecFormatter, bold, faint, green, red } = require('prettytap')
3
+
4
+ const secs = (ms) => (ms / 1000).toFixed(1) + 's'
5
+ const indent = (text) =>
6
+ text
7
+ .split('\n')
8
+ .map((line) => (line ? ' ' + line : line))
9
+ .join('\n')
10
+
11
+ function header({ total, runtime, jobs }) {
12
+ return faint(`${total} files · ${path.basename(runtime)} · jobs=${jobs}`) + '\n\n'
13
+ }
14
+
15
+ function line(res, cwd) {
16
+ const name = path.relative(cwd, res.file)
17
+ if (res.failed) return `${red('✗')} ${bold(name)} ${faint('· ' + secs(res.ms))}\n`
18
+ return `${green('✓')} ${name} ${faint(`· ${res.results.expectedTests} tests · ${secs(res.ms)}`)}\n`
19
+ }
20
+
21
+ function detail(res) {
22
+ let out = ''
23
+ if (res.results.foundTapData) {
24
+ const spec = new SpecFormatter()
25
+ out += spec.formatToString(res.results) + spec.summaryToString()
26
+ }
27
+ out += res.stderr
28
+ if (!res.results.foundTapData) out += red('no TAP output') + '\n'
29
+ if (res.code !== 0) out += red(`runner exited: ${res.signal ?? res.code}`) + '\n'
30
+ return indent(out.trimEnd()) + '\n\n'
31
+ }
32
+
33
+ function summary(results, { total, ms, cwd }) {
34
+ const failed = results.filter((r) => r.failed)
35
+ const tests = results.reduce((n, r) => n + Math.max(r.results.expectedTests, 0), 0)
36
+ const color = failed.length ? red : green
37
+ const out = `\n${color(bold(`${results.length - failed.length}/${total} files passed`))} ${faint(`· ${tests} tests · ${secs(ms)}`)}\n`
38
+ if (failed.length === 0) return out
39
+ return out + red('failed: ') + failed.map((r) => path.relative(cwd, r.file)).join(', ') + '\n'
40
+ }
41
+
42
+ module.exports = { header, line, detail, summary }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "brittle-jobs",
3
+ "version": "0.1.0",
4
+ "description": "Run brittle test files concurrently, one process per file, on Node or Bare.",
5
+ "main": "index.js",
6
+ "bin": {
7
+ "brittle-jobs": "bin.js"
8
+ },
9
+ "files": [
10
+ "index.js",
11
+ "bin.js",
12
+ "lib"
13
+ ],
14
+ "scripts": {
15
+ "test": "brittle-node test/index.js",
16
+ "lint": "prettier . --check && lunte",
17
+ "format": "prettier . --write"
18
+ },
19
+ "prettier": "prettier-config-holepunch",
20
+ "dependencies": {
21
+ "paparam": "^1.13.0",
22
+ "prettytap": "^0.2.0"
23
+ },
24
+ "peerDependencies": {
25
+ "brittle": ">=4"
26
+ },
27
+ "devDependencies": {
28
+ "bare-runtime": "^1.30.0",
29
+ "brittle": "^4.1.0",
30
+ "lunte": "^1.8.4",
31
+ "prettier": "^3.6.2",
32
+ "prettier-config-holepunch": "^2.0.0"
33
+ },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/matheus1lva/brittle-jobs.git"
37
+ },
38
+ "license": "MIT"
39
+ }