brittle-jobs 0.1.2 → 0.2.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/README.md CHANGED
@@ -1,6 +1,10 @@
1
1
  # brittle-jobs
2
2
 
3
- Run [brittle](https://github.com/holepunchto/brittle) test files concurrently, one process per file, on Node or Bare.
3
+ Run [brittle](https://github.com/holepunchto/brittle) test files concurrently, one isolated process per file.
4
+
5
+ - A hung file costs one file, not the run: idle and hard timeouts kill the whole process group, grandchildren included.
6
+ - Every file gets its own `TMPDIR`, wiped by the runner on exit or kill, so leaked tmp data cannot starve the disk.
7
+ - Pool size is not tied to core count. Test files are mostly wait-bound, so the default oversubscribes.
4
8
 
5
9
  ## Installation
6
10
 
@@ -8,97 +12,65 @@ Run [brittle](https://github.com/holepunchto/brittle) test files concurrently, o
8
12
  npm install --save-dev brittle-jobs
9
13
  ```
10
14
 
11
- `brittle` is a peer dependency: the consumer's install is what runs each file.
15
+ The runner needs Node >= 22. Test files run on `bare` by default, or on anything passed to `--runtime`.
12
16
 
13
17
  ## Usage
14
18
 
15
19
  ```bash
16
- brittle-jobs test/*.js
17
- brittle-jobs --bare -j 4 test/all.ts
20
+ brittle-jobs # test/*.{js,ts,mjs}, minus all.*
21
+ brittle-jobs 'test/**/*.ts' --jobs 8
22
+ brittle-jobs test/*.js --runtime node
18
23
  ```
19
24
 
20
25
  ```
21
- brittle-jobs [flags] <files...>
22
-
23
- --bare run test files with bare instead of node
24
- --jobs|-j <n> concurrent test-file workers (positive integer; default: available CPU parallelism)
25
- --bail|-b bail on first assert failure and stop scheduling files
26
- --timeout|-t <ms> per-test timeout passed to brittle
27
-
28
- BRITTLE_JOBS=<n> concurrent test-file workers, overridden by --jobs
26
+ brittle-jobs [globs|files...] [flags]
27
+
28
+ -j, --jobs N concurrent processes (default BRITTLE_JOBS or 2 x cores)
29
+ -t, --timeout MS hard cap per file (default 300000)
30
+ --idle-timeout MS kill file after this long without output (default 120000)
31
+ --retries N rerun failed files, pass on retry = FLAKY (default 0)
32
+ --min-free-mb N wait for free disk on tmp root before spawning (default 1024)
33
+ --shard i/n run the i-th of n slices
34
+ --runtime CMD child command (default bare)
35
+ -b, --bail stop on first failure
29
36
  ```
30
37
 
31
- Each file runs as `<node|bare> brittle/cmd.js [--bail] [--timeout ms] <file>`, exactly what
32
- `brittle-node`/`brittle-bare` do, so per-file semantics are unchanged. On `--bare` the binary comes
33
- from the project's own `bare-runtime` install, not from `PATH`: a global `bare` is often an older
34
- version, and `node_modules/.bin/bare` is a Node process whose only job is to spawn the real binary.
35
- `PATH` is used only when `bare-runtime` is not installed.
38
+ Each file runs as `<runtime> <file>`; a brittle test file runs itself. `bare` resolves from the project's own
39
+ `bare-runtime` install, then from `PATH`.
36
40
 
37
- Output is one line per file as it finishes, the full spec-formatted TAP for any file that fails, and a
38
- summary. On a TTY a progress line names the files in flight so a long file cannot look like a hang.
39
- Exit code is 1 when any file fails its TAP or exits non-zero.
41
+ Output is one line per file as it finishes, the full output of any file that fails, the ten slowest files, and a
42
+ summary. Exit code is 1 when any file fails or hangs.
40
43
 
41
44
  ```
42
- 200 files · bare · jobs=10
45
+ [98/99] PASS (3.5s) test/harness.ts
46
+ [99/99] HUNG (120.4s) test/exec.ts: no output for 120000ms, last seen "python create package table"
43
47
 
44
- ✓ test/basic/chat.ts · 12 tests · 3.2s
45
- ✗ test/basic/boot.ts · 4.1s
46
- ...
47
- 198/200 files passed · 1840 tests · 61.3s
48
- failed: test/basic/boot.ts, test/api/mesh.ts
48
+ 98 pass, 0 flaky, 0 fail, 1 hung of 99 in 120.4s
49
49
  ```
50
50
 
51
- ### Runner files
51
+ A `HUNG` report includes the process tree of the file at the moment it was killed. On GitHub Actions, failures emit
52
+ `::error file=...::` annotations and their output is grouped.
52
53
 
53
- A generated runner containing only the declarative wrapper emitted by `brittle-make-test` is expanded
54
- into the files it loads instead of being run as one process. A line prefixed with `if (isBare)` is
55
- skipped when running on node:
54
+ ### Isolation
56
55
 
57
- ```js
58
- const isBare = typeof Bare !== 'undefined'
56
+ - `TMPDIR`, `TMP`, `TEMP` point at a per-file directory under `os.tmpdir()/brittle-jobs-<cwd hash>`. It is removed
57
+ when the file ends, however it ends. A stale root from a killed runner is removed at startup.
58
+ - `BRITTLE_JOBS_WORKER_ID` and `JEST_WORKER_ID` hold the pool slot (`1..jobs`), for namespacing ports or databases.
59
+ - Files start largest first. `--shard i/n` takes every n-th file of that order, so heavy files spread across shards.
59
60
 
60
- await test.load(import.meta.resolve('./basic/chat.ts'))
61
- if (isBare) await test.load(import.meta.resolve('./tui/keys.ts'))
62
- ```
61
+ ### Limits
63
62
 
64
- The runner file stays a valid brittle entrypoint, so `bare test/all.ts` still works serially. A file
65
- with setup, inline tests, comments, unsupported guards, or other unrecognized source runs as one
66
- process so its behavior stays intact. A runner with no applicable loads also runs as one process; a
67
- file that emits incomplete or no TAP counts as failed.
63
+ - POSIX only: process groups are not available on Windows.
64
+ - A grandchild that calls `setsid` escapes the group kill.
65
+ - Two concurrent runs from the same working directory share a tmp root and wipe each other.
68
66
 
69
- ### Scheduling
70
-
71
- `--jobs` sets the number of concurrent test-file workers. The default is exactly the host's available
72
- CPU parallelism. `BRITTLE_JOBS` sets the worker count for CI without changing the command line, and
73
- `--jobs` overrides both; either setting may be higher than the host CPU count. For example:
74
-
75
- ```bash
76
- brittle-jobs --bare --jobs=16 test/*.js
77
- BRITTLE_JOBS=16 brittle-jobs --bare test/*.js
78
- ```
79
-
80
- Each run records how long every file took in `node_modules/.cache/brittle-jobs.json`, and the next run
81
- starts the slowest files first, with never-seen files ahead of them. This only matters once a single
82
- file takes longer than the rest of the suite divided by `--jobs`; below that the queue is packed
83
- either way. `--bail` keeps the given order instead, so the first file listed is the first one to fail.
84
-
85
- ## API
86
-
87
- ```js
88
- const run = require('brittle-jobs')
89
-
90
- const { passed, failed, results } = await run(['test/all.ts'], {
91
- bare: false,
92
- jobs: 4,
93
- bail: false,
94
- timeout: 30000,
95
- cwd: process.cwd(),
96
- out: process.stdout
97
- })
98
- ```
67
+ ## Migrating from 0.1
99
68
 
100
- `results` holds one entry per file: `{ file, results, code, signal, stderr, failed, ms }`, where
101
- `results` is a [prettytap](https://www.npmjs.com/package/prettytap) `Results`.
69
+ - The runner is a CLI only; the `require('brittle-jobs')` API is gone.
70
+ - Default runtime is `bare`. `--bare` is accepted and ignored; use `--runtime node` for Node.
71
+ - Generated `all.*` runner files are skipped, not expanded. Pass the test files or a glob.
72
+ - `--timeout` is now a per-file cap enforced by the runner, not brittle's per-test timeout.
73
+ - No timing cache; files are ordered by size.
102
74
 
103
75
  ## License
104
76
 
package/bin.js CHANGED
@@ -1,55 +1,266 @@
1
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(
11
- '--jobs|-j <n>',
12
- 'Concurrent test-file workers (default: available CPU parallelism)'
13
- ),
14
- paparam.flag('--bail|-b', 'Bail on first assert failure and stop scheduling files'),
15
- paparam.flag('--timeout|-t <ms>', 'Per-test timeout passed to brittle'),
16
- paparam.rest('<files>'),
17
- paparam.bail((bail) => {
18
- console.error(bail.reason)
19
- console.error(bail.command.usage())
20
- process.exit(1)
21
- })
2
+ import { spawn, execFileSync } from 'child_process'
3
+ import crypto from 'crypto'
4
+ import fs from 'fs'
5
+ import os from 'os'
6
+ import path from 'path'
7
+ import { createRequire } from 'module'
8
+ import { parseArgs } from 'util'
9
+
10
+ const { values: opts, positionals } = parseArgs({
11
+ allowPositionals: true,
12
+ options: {
13
+ jobs: { type: 'string', short: 'j' },
14
+ timeout: { type: 'string', short: 't', default: '300000' },
15
+ 'idle-timeout': { type: 'string', default: '120000' },
16
+ retries: { type: 'string', default: '0' },
17
+ 'min-free-mb': { type: 'string', default: '1024' },
18
+ shard: { type: 'string' },
19
+ runtime: { type: 'string' },
20
+ bail: { type: 'boolean', short: 'b', default: false },
21
+ bare: { type: 'boolean', default: false },
22
+ help: { type: 'boolean', short: 'h', default: false }
23
+ }
24
+ })
25
+
26
+ if (opts.help) {
27
+ console.log(`brittle-jobs [globs|files...] [flags]
28
+
29
+ -j, --jobs N concurrent processes (default BRITTLE_JOBS or 2 x cores)
30
+ -t, --timeout MS hard cap per file (default 300000)
31
+ --idle-timeout MS kill file after this long without output (default 120000)
32
+ --retries N rerun failed files, pass on retry = FLAKY (default 0)
33
+ --min-free-mb N wait for free disk on tmp root before spawning (default 1024)
34
+ --shard i/n run the i-th of n slices
35
+ --runtime CMD child command (default bare)
36
+ -b, --bail stop on first failure`)
37
+ process.exit(0)
38
+ }
39
+
40
+ const GRACE_MS = 2000
41
+ const jobs = Math.max(
42
+ 1,
43
+ Number(opts.jobs || process.env.BRITTLE_JOBS) || 2 * os.availableParallelism()
44
+ )
45
+ const timeoutMs = Number(opts.timeout)
46
+ const idleMs = Number(opts['idle-timeout'])
47
+ const retries = Number(opts.retries)
48
+ const minFree = Number(opts['min-free-mb']) * 1024 * 1024
49
+ const gha = !!process.env.GITHUB_ACTIONS
50
+ const [runtime, ...runtimeArgs] = (opts.runtime || defaultRuntime()).split(' ')
51
+
52
+ function defaultRuntime() {
53
+ try {
54
+ return createRequire(path.join(process.cwd(), 'package.json'))('bare-runtime')()
55
+ } catch {
56
+ return 'bare'
57
+ }
58
+ }
59
+
60
+ let files = [
61
+ ...new Set(
62
+ fs
63
+ .globSync(positionals.length ? positionals : ['test/*.{js,ts,mjs}'])
64
+ .filter((f) => !/^all\./.test(path.basename(f)) && fs.statSync(f).isFile())
22
65
  )
23
- .parse()
66
+ ]
67
+ .map((f) => ({ f, size: fs.statSync(f).size }))
68
+ .sort((a, b) => b.size - a.size || a.f.localeCompare(b.f))
69
+ .map((x) => x.f)
70
+
71
+ if (opts.shard) {
72
+ const [i, n] = opts.shard.split('/').map(Number)
73
+ if (!(i >= 1 && i <= n)) die(`bad --shard ${opts.shard}`)
74
+ files = files.filter((_, k) => k % n === i - 1)
75
+ }
76
+ if (files.length === 0) die('no test files matched')
77
+
78
+ // ponytail: tmp root keyed by cwd, two concurrent runs in one cwd wipe each other. Key by pid if that happens.
79
+ const cwdHash = crypto.createHash('sha1').update(process.cwd()).digest('hex').slice(0, 8)
80
+ const tmpRoot = path.join(fs.realpathSync(os.tmpdir()), `brittle-jobs-${cwdHash}`)
81
+ fs.rmSync(tmpRoot, { recursive: true, force: true })
82
+ fs.mkdirSync(tmpRoot, { recursive: true })
83
+
84
+ const active = new Set()
85
+ const results = []
86
+ let waiters = []
87
+ let stop = false
24
88
 
25
- if (!cmd) process.exit(0)
26
- if (cmd.rest.length === 0) {
27
- console.error('Error: No test files were specified')
28
- process.exit(1)
89
+ function die(msg) {
90
+ console.error(`brittle-jobs: ${msg}`)
91
+ process.exit(2)
29
92
  }
30
93
 
31
- const { bare, jobs, bail, timeout } = cmd.flags
32
- let parsedJobs
33
- if (jobs !== undefined) {
34
- parsedJobs = Number(jobs)
94
+ function killGroup(pid, sig) {
35
95
  try {
36
- run.validateJobs(parsedJobs)
96
+ process.kill(-pid, sig)
97
+ } catch {}
98
+ }
99
+
100
+ function processTree(pgid) {
101
+ try {
102
+ const rows = execFileSync('ps', ['-eo', 'pid,ppid,pgid,etime,command'], {
103
+ encoding: 'utf8'
104
+ }).split('\n')
105
+ return [rows[0], ...rows.filter((r) => r.trim().split(/\s+/)[2] === String(pgid))].join('\n')
37
106
  } catch {
38
- console.error(`Error: --jobs must be a positive safe integer`)
39
- process.exit(1)
107
+ return ''
40
108
  }
41
109
  }
42
110
 
43
- run(cmd.rest, {
44
- bare,
45
- bail,
46
- jobs: parsedJobs,
47
- timeout: timeout ? Number(timeout) : undefined
48
- })
49
- .then(({ failed }) => {
50
- if (failed && !process.exitCode) process.exitCode = 1
111
+ function shutdown(code) {
112
+ stop = true
113
+ for (const pid of active) killGroup(pid, 'SIGKILL')
114
+ fs.rmSync(tmpRoot, { recursive: true, force: true })
115
+ process.exit(code)
116
+ }
117
+ process.on('SIGINT', () => shutdown(130))
118
+ process.on('SIGTERM', () => shutdown(143))
119
+
120
+ async function diskGate() {
121
+ while (fs.statfsSync(tmpRoot).bavail * fs.statfsSync(tmpRoot).bsize < minFree) {
122
+ if (active.size === 0) {
123
+ console.error(
124
+ `brittle-jobs: free disk on ${tmpRoot} below ${opts['min-free-mb']} MB with nothing running`
125
+ )
126
+ shutdown(2)
127
+ }
128
+ await new Promise((resolve) => waiters.push(resolve))
129
+ }
130
+ }
131
+
132
+ function runOnce(file, slot) {
133
+ return new Promise((resolve) => {
134
+ const tmp = fs.mkdtempSync(path.join(tmpRoot, 'bb-'))
135
+ const started = Date.now()
136
+ const child = spawn(runtime, [...runtimeArgs, file], {
137
+ detached: true,
138
+ stdio: ['ignore', 'pipe', 'pipe'],
139
+ env: {
140
+ ...process.env,
141
+ TMPDIR: tmp,
142
+ TMP: tmp,
143
+ TEMP: tmp,
144
+ BRITTLE_JOBS_WORKER_ID: String(slot),
145
+ JEST_WORKER_ID: String(slot)
146
+ }
147
+ })
148
+ const pid = child.pid
149
+ if (pid) active.add(pid)
150
+
151
+ let output = ''
152
+ let lastTest = ''
153
+ let hung = ''
154
+ let idleTimer
155
+
156
+ const hang = (why) => {
157
+ if (hung) return
158
+ hung = why
159
+ output += `\n--- process tree at kill ---\n${processTree(pid)}\n`
160
+ killGroup(pid, 'SIGTERM')
161
+ setTimeout(() => killGroup(pid, 'SIGKILL'), GRACE_MS)
162
+ }
163
+ const hardTimer = setTimeout(() => hang(`timeout ${timeoutMs}ms`), timeoutMs)
164
+ const touch = () => {
165
+ clearTimeout(idleTimer)
166
+ idleTimer = setTimeout(() => hang(`no output for ${idleMs}ms`), idleMs)
167
+ }
168
+ const ondata = (chunk) => {
169
+ const text = String(chunk)
170
+ output += text
171
+ for (const line of text.split('\n')) {
172
+ const m = line.match(/^\s*(?:# |(?:not )?ok \d+ - )(.+)/)
173
+ if (m) lastTest = m[1].replace(/ # time.*$/, '')
174
+ }
175
+ touch()
176
+ }
177
+ child.stdout.on('data', ondata)
178
+ child.stderr.on('data', ondata)
179
+ touch()
180
+
181
+ let exit = null
182
+ const finish = () => {
183
+ clearTimeout(hardTimer)
184
+ clearTimeout(idleTimer)
185
+ active.delete(pid)
186
+ fs.rmSync(tmp, { recursive: true, force: true })
187
+ for (const w of waiters.splice(0)) w()
188
+ const secs = (Date.now() - started) / 1000
189
+ if (hung)
190
+ return resolve({ status: 'HUNG', reason: `${hung}, last seen "${lastTest}"`, output, secs })
191
+ if (exit.code === 0) return resolve({ status: 'PASS', output, secs })
192
+ resolve({
193
+ status: 'FAIL',
194
+ reason: exit.err || `exit ${exit.code ?? exit.signal}`,
195
+ output,
196
+ secs
197
+ })
198
+ }
199
+ child.on('error', (err) => {
200
+ exit = { err: err.message }
201
+ finish()
202
+ })
203
+ child.on('exit', (code, signal) => {
204
+ exit = { code, signal }
205
+ killGroup(pid, 'SIGKILL')
206
+ })
207
+ child.on('close', () => exit && !exit.err && finish())
51
208
  })
52
- .catch((err) => {
53
- console.error(`Error: ${err.message}`)
54
- process.exitCode = 1
209
+ }
210
+
211
+ async function runFile(file, slot) {
212
+ let res
213
+ for (let attempt = 0; attempt <= retries && !stop; attempt++) {
214
+ await diskGate()
215
+ res = await runOnce(file, slot)
216
+ if (res.status === 'PASS') {
217
+ if (attempt > 0) res.status = 'FLAKY'
218
+ break
219
+ }
220
+ }
221
+ return res
222
+ }
223
+
224
+ function report(file, res) {
225
+ results.push({ file, ...res })
226
+ const ok = res.status === 'PASS' || res.status === 'FLAKY'
227
+ const color = res.status === 'PASS' ? 32 : res.status === 'FLAKY' ? 33 : 31
228
+ const reason = res.reason ? `: ${res.reason}` : ''
229
+ console.log(
230
+ `[${results.length}/${files.length}] \x1b[${color}m${res.status}\x1b[0m (${res.secs.toFixed(1)}s) ${file}${reason}`
231
+ )
232
+ if (ok) return
233
+ if (gha) console.log(`::error file=${file}::${res.status}${reason}\n::group::${file} output`)
234
+ console.log(res.output.trim())
235
+ if (gha) console.log('::endgroup::')
236
+ if (opts.bail) stop = true
237
+ }
238
+
239
+ const started = Date.now()
240
+ console.log(`brittle-jobs: ${files.length} files, ${jobs} jobs, runtime ${runtime}`)
241
+
242
+ let next = 0
243
+ await Promise.all(
244
+ Array.from({ length: Math.min(jobs, files.length) }, async (_, i) => {
245
+ while (!stop && next < files.length) {
246
+ const file = files[next++]
247
+ const res = await runFile(file, i + 1)
248
+ if (res) report(file, res)
249
+ }
55
250
  })
251
+ )
252
+
253
+ fs.rmSync(tmpRoot, { recursive: true, force: true })
254
+
255
+ const count = (s) => results.filter((r) => r.status === s).length
256
+ const bad = results.filter((r) => r.status === 'FAIL' || r.status === 'HUNG')
257
+ console.log(`\nslowest:`)
258
+ for (const r of [...results].sort((a, b) => b.secs - a.secs).slice(0, 10)) {
259
+ console.log(` ${r.secs.toFixed(1)}s ${r.file}`)
260
+ }
261
+ console.log(
262
+ `\n${count('PASS')} pass, ${count('FLAKY')} flaky, ${count('FAIL')} fail, ${count('HUNG')} hung` +
263
+ ` of ${files.length} in ${((Date.now() - started) / 1000).toFixed(1)}s`
264
+ )
265
+ for (const r of bad) console.log(` - ${r.status} ${r.file}: ${r.reason}`)
266
+ process.exit(bad.length || results.length < files.length ? 1 : 0)
package/package.json CHANGED
@@ -1,33 +1,24 @@
1
1
  {
2
2
  "name": "brittle-jobs",
3
- "version": "0.1.2",
4
- "description": "Run brittle test files concurrently, one process per file, on Node or Bare.",
5
- "main": "index.js",
3
+ "version": "0.2.0",
4
+ "description": "Run brittle test files concurrently, one isolated process per file, with hang detection and tmp cleanup.",
5
+ "type": "module",
6
6
  "bin": {
7
7
  "brittle-jobs": "bin.js"
8
8
  },
9
9
  "files": [
10
- "index.js",
11
- "bin.js",
12
- "lib"
10
+ "bin.js"
13
11
  ],
12
+ "engines": {
13
+ "node": ">=22"
14
+ },
14
15
  "scripts": {
15
- "test": "brittle-node test/index.js",
16
- "lint": "prettier . --check && lunte",
16
+ "test": "node --test test/run.test.js",
17
+ "lint": "prettier . --check",
17
18
  "format": "prettier . --write"
18
19
  },
19
20
  "prettier": "prettier-config-holepunch",
20
- "dependencies": {
21
- "paparam": "^1.13.0",
22
- "prettytap": "^0.2.0"
23
- },
24
- "peerDependencies": {
25
- "brittle": ">=4"
26
- },
27
21
  "devDependencies": {
28
- "bare-runtime": "^1.30.0",
29
- "brittle": "^4.1.0",
30
- "lunte": "^1.8.4",
31
22
  "prettier": "^3.6.2",
32
23
  "prettier-config-holepunch": "^2.0.0"
33
24
  },
package/index.js DELETED
@@ -1,151 +0,0 @@
1
- const fs = require('fs')
2
- const path = require('path')
3
- const expand = require('./lib/manifest')
4
- const runFile = require('./lib/child')
5
- const report = require('./lib/report')
6
- const timings = require('./lib/timings')
7
- const resolveJobs = require('./lib/jobs')
8
-
9
- module.exports = async function run(files, opts = {}) {
10
- const cwd = opts.cwd ?? process.cwd()
11
- const out = opts.out ?? process.stdout
12
- const bare = !!opts.bare
13
- const jobs = resolveJobs(opts.jobs)
14
- validateJobs(jobs)
15
- const runtime = bare ? bareRuntime(cwd) : process.execPath
16
-
17
- const args = [require.resolve('brittle/cmd.js', { paths: [cwd] })]
18
- if (opts.bail) args.push('--bail')
19
- if (opts.timeout) args.push('--timeout', String(opts.timeout))
20
-
21
- const expanded = files.flatMap((file) => expand(path.resolve(cwd, file), { bare }))
22
- // Bailing asks for the first failure, not the shortest run. Keep the order
23
- // that was asked for so the file that fails is the file that was listed first.
24
- const queue = opts.bail ? expanded : timings.order(expanded, cwd)
25
- const total = queue.length
26
- const results = []
27
- const running = new Set()
28
- const start = Date.now()
29
- let stop = false
30
- let bar = report.progress({}, total)
31
-
32
- const abort = () => {
33
- stop = true
34
- for (const r of running) {
35
- try {
36
- r.kill()
37
- } catch {}
38
- }
39
- process.exitCode = 130
40
- }
41
- let listenerInstalled = false
42
-
43
- try {
44
- process.once('SIGINT', abort)
45
- listenerInstalled = true
46
-
47
- out.write(report.header({ total, runtime, jobs }))
48
- bar = report.progress(out, total)
49
- const tick = () =>
50
- bar.update(
51
- results.length,
52
- [...running].map((r) => path.relative(cwd, r.file))
53
- )
54
-
55
- await new Promise((resolve, reject) => {
56
- let settled = false
57
-
58
- const stopWithError = (err) => {
59
- if (settled) return
60
- stop = true
61
- const pending = []
62
- for (const r of running) {
63
- try {
64
- r.kill()
65
- } catch {}
66
- pending.push(Promise.resolve(r.promise).catch(() => {}))
67
- }
68
- settled = true
69
- Promise.all(pending).then(() => {
70
- running.clear()
71
- reject(err)
72
- })
73
- }
74
-
75
- const next = () => {
76
- if (settled) return
77
- if (running.size === 0 && (stop || queue.length === 0)) {
78
- settled = true
79
- resolve()
80
- return
81
- }
82
- while (!stop && running.size < jobs && queue.length > 0) {
83
- let r
84
- try {
85
- r = runFile(queue.shift(), { runtime, args, cwd })
86
- } catch (err) {
87
- stopWithError(err)
88
- return
89
- }
90
- running.add(r)
91
- Promise.resolve(r.promise).then((res) => {
92
- if (settled) return
93
- bar.clear()
94
- running.delete(r)
95
- try {
96
- results.push(res)
97
- out.write(report.line(res, cwd))
98
- if (res.failed) {
99
- out.write(report.detail(res))
100
- if (opts.bail) stop = true
101
- }
102
- } catch (err) {
103
- stopWithError(err)
104
- return
105
- }
106
- next()
107
- }, stopWithError)
108
- }
109
- tick()
110
- }
111
-
112
- next()
113
- })
114
-
115
- out.write(report.summary(results, { total, ms: Date.now() - start, cwd }))
116
- timings.write(cwd, results)
117
- } finally {
118
- bar.stop()
119
- if (listenerInstalled) process.off('SIGINT', abort)
120
- }
121
-
122
- const failed = results.filter((r) => r.failed).length
123
- return { passed: results.length - failed, failed, results }
124
- }
125
-
126
- // The launcher on PATH is whichever Bare the shell found, often an older
127
- // global one, and node_modules/.bin/bare is a Node process that only spawns the
128
- // real binary. Both cost a process per file. Take the binary the project
129
- // installed, and only fall back to the launcher when there is none.
130
- function bareRuntime(cwd) {
131
- let bin
132
- try {
133
- bin = require(require.resolve('bare-runtime', { paths: [cwd] }))('bare')
134
- } catch {
135
- return 'bare'
136
- }
137
- try {
138
- fs.accessSync(bin, fs.constants.X_OK)
139
- } catch {
140
- fs.chmodSync(bin, 0o755)
141
- }
142
- return bin
143
- }
144
-
145
- function validateJobs(jobs) {
146
- if (typeof jobs !== 'number' || !Number.isSafeInteger(jobs) || jobs < 1) {
147
- throw new TypeError('jobs must be a positive safe integer')
148
- }
149
- }
150
-
151
- module.exports.validateJobs = validateJobs
package/lib/child.js DELETED
@@ -1,66 +0,0 @@
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 { file, promise, kill: () => child.kill() }
66
- }
package/lib/cpus.js DELETED
@@ -1,11 +0,0 @@
1
- // Kept as a compatibility alias for callers that used the old internal name.
2
- // Job sizing no longer reads cgroup quota files: CPU parallelism is only the
3
- // fallback when neither the API nor BRITTLE_JOBS specifies a worker count.
4
- const resolveJobs = require('./jobs')
5
-
6
- function cpus() {
7
- return resolveJobs()
8
- }
9
-
10
- module.exports = cpus
11
- module.exports.resolve = resolveJobs
package/lib/jobs.js DELETED
@@ -1,16 +0,0 @@
1
- const os = require('os')
2
-
3
- // An explicit API/CLI value is authoritative. Otherwise let CI choose a
4
- // worker count without tying it to the host's CPU count, and use the host
5
- // parallelism only when neither setting is present.
6
- function resolveJobs(explicit) {
7
- if (explicit !== undefined && explicit !== null) return explicit
8
-
9
- const fromEnv = Number(process.env.BRITTLE_JOBS)
10
- if (Number.isSafeInteger(fromEnv) && fromEnv > 0) return fromEnv
11
-
12
- return os.availableParallelism()
13
- }
14
-
15
- module.exports = resolveJobs
16
- module.exports.resolve = resolveJobs
package/lib/manifest.js DELETED
@@ -1,93 +0,0 @@
1
- const fs = require('fs')
2
- const { fileURLToPath, pathToFileURL } = require('url')
3
-
4
- const LOAD = /^(if \(isBare\) )?await test\.load\(import\.meta\.resolve\((['"])([^'"]+)\2\)\);?$/
5
- const IMPORT = /^import\s+[^'"]+\sfrom\s*(['"])([^'"]+)\1;?$/
6
-
7
- const PATTERNS = {
8
- call: /^await runTests\(\);?$/,
9
- wrapper: /^async\s+function runTests\s*\(\s*\)\s*\{$/,
10
- brittle: /^const test = \(await import\((['"])brittle\1\)\)\.default;?$/,
11
- pause: /^test\.pause\(\);?$/,
12
- resume: /^test\.resume\(\);?$/,
13
- isBare: /^const isBare = typeof Bare !== (['"])undefined\1;?$/,
14
- jobs: /^test\.configure\(\{\s*jobs:[^{}]*\}\);?$/,
15
- comment: /^\/\//,
16
- close: /^\}$/
17
- }
18
- const REQUIRED = ['call', 'wrapper', 'brittle', 'pause', 'resume']
19
-
20
- module.exports = function expand(file, { bare }) {
21
- const specifiers = parse(fs.readFileSync(file, 'utf8'))
22
- if (specifiers === null) return [file]
23
-
24
- const files = []
25
- for (const { guarded, specifier } of specifiers) {
26
- if (guarded && !bare) continue
27
- const resolved = resolve(file, specifier)
28
- if (resolved === null) return [file]
29
- files.push(resolved)
30
- }
31
- return files.length > 0 ? files : [file]
32
- }
33
-
34
- // Recognize only the small declarative wrapper emitted by brittle-make-test,
35
- // plus the two edits it survives in practice: package imports and a jobs
36
- // configuration, both of which this runner replaces with one process per file.
37
- // Any other statement must run as the original file so setup and inline tests
38
- // remain in the same process and unsupported control flow is never guessed at.
39
- function parse(source) {
40
- const seen = new Set()
41
- const loads = []
42
-
43
- for (const raw of source.split(/\r?\n/)) {
44
- const line = raw.trim()
45
- if (line === '') continue
46
-
47
- const load = LOAD.exec(line)
48
- if (load) {
49
- loads.push({ guarded: !!load[1], specifier: load[3] })
50
- continue
51
- }
52
-
53
- const imported = IMPORT.exec(line)
54
- if (imported) {
55
- // A package import cannot be observed once the loads are split out. A
56
- // relative one is local setup, so keep the file whole.
57
- if (/^[./]/.test(imported[2])) return null
58
- continue
59
- }
60
-
61
- const name = Object.keys(PATTERNS).find((key) => PATTERNS[key].test(line))
62
- if (name === undefined) return null
63
- seen.add(name)
64
- }
65
-
66
- if (!REQUIRED.every((key) => seen.has(key))) return null
67
- if (loads.some((load) => load.guarded) && !seen.has('isBare')) return null
68
- return loads
69
- }
70
-
71
- function resolve(file, specifier) {
72
- // Keep the accepted subset equivalent to import.meta.resolve for relative
73
- // file specifiers. A process can only be started with a filesystem path.
74
- // The recognizer intentionally does not decode JavaScript string escapes.
75
- // Reject them rather than resolving a different path from the runner.
76
- if (
77
- specifier.includes('\\') ||
78
- (specifier !== '.' &&
79
- specifier !== '..' &&
80
- !specifier.startsWith('./') &&
81
- !specifier.startsWith('../'))
82
- ) {
83
- return null
84
- }
85
-
86
- try {
87
- const url = new URL(specifier, pathToFileURL(file))
88
- if (url.protocol !== 'file:' || url.hostname || url.search || url.hash) return null
89
- return fileURLToPath(url)
90
- } catch {
91
- return null
92
- }
93
- }
package/lib/report.js DELETED
@@ -1,89 +0,0 @@
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
- const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
12
- const ERASE = '\x1b[2K\x1b[1G'
13
-
14
- // A long file keeps a terminal silent for minutes. Repaint a single line so the
15
- // run is visibly alive and says which files it is waiting on.
16
- function progress(out, total) {
17
- if (!out.isTTY) return { update() {}, clear() {}, stop() {} }
18
-
19
- const start = Date.now()
20
- let frame = 0
21
- let done = 0
22
- let running = []
23
- let shown = false
24
-
25
- const paint = () => {
26
- const names = running.slice(0, 3).join(' ')
27
- const more = running.length > 3 ? ` +${running.length - 3}` : ''
28
- const line = `${SPINNER[frame]} ${done}/${total} · ${names}${more} · ${secs(Date.now() - start)}`
29
- out.write((shown ? ERASE : '') + faint(line.slice(0, (out.columns || 80) - 1)))
30
- shown = true
31
- }
32
- const clear = () => {
33
- if (!shown) return
34
- out.write(ERASE)
35
- shown = false
36
- }
37
-
38
- const timer = setInterval(() => {
39
- frame = (frame + 1) % SPINNER.length
40
- paint()
41
- }, 100)
42
- if (timer.unref) timer.unref()
43
-
44
- return {
45
- update(nextDone, nextRunning) {
46
- done = nextDone
47
- running = nextRunning
48
- paint()
49
- },
50
- clear,
51
- stop() {
52
- clearInterval(timer)
53
- clear()
54
- }
55
- }
56
- }
57
-
58
- function header({ total, runtime, jobs }) {
59
- return faint(`${total} files · ${path.basename(runtime)} · jobs=${jobs}`) + '\n\n'
60
- }
61
-
62
- function line(res, cwd) {
63
- const name = path.relative(cwd, res.file)
64
- if (res.failed) return `${red('✗')} ${bold(name)} ${faint('· ' + secs(res.ms))}\n`
65
- return `${green('✓')} ${name} ${faint(`· ${res.results.expectedTests} tests · ${secs(res.ms)}`)}\n`
66
- }
67
-
68
- function detail(res) {
69
- let out = ''
70
- if (res.results.foundTapData) {
71
- const spec = new SpecFormatter()
72
- out += spec.formatToString(res.results) + spec.summaryToString()
73
- }
74
- out += res.stderr
75
- if (!res.results.foundTapData) out += red('no TAP output') + '\n'
76
- if (res.code !== 0) out += red(`runner exited: ${res.signal ?? res.code}`) + '\n'
77
- return indent(out.trimEnd()) + '\n\n'
78
- }
79
-
80
- function summary(results, { total, ms, cwd }) {
81
- const failed = results.filter((r) => r.failed)
82
- const tests = results.reduce((n, r) => n + Math.max(r.results.expectedTests, 0), 0)
83
- const color = failed.length ? red : green
84
- const out = `\n${color(bold(`${results.length - failed.length}/${total} files passed`))} ${faint(`· ${tests} tests · ${secs(ms)}`)}\n`
85
- if (failed.length === 0) return out
86
- return out + red('failed: ') + failed.map((r) => path.relative(cwd, r.file)).join(', ') + '\n'
87
- }
88
-
89
- module.exports = { header, line, detail, summary, progress }
package/lib/timings.js DELETED
@@ -1,43 +0,0 @@
1
- const fs = require('fs')
2
- const path = require('path')
3
-
4
- // Unknown files are scheduled first: a file with no recorded time may be the
5
- // long one, and starting it last is what leaves a single process owning the
6
- // tail of the run.
7
- const UNKNOWN = Number.MAX_SAFE_INTEGER
8
-
9
- const cache = (cwd) => path.join(cwd, 'node_modules', '.cache', 'brittle-jobs.json')
10
-
11
- function read(cwd) {
12
- try {
13
- return JSON.parse(fs.readFileSync(cache(cwd), 'utf8'))
14
- } catch {
15
- return {}
16
- }
17
- }
18
-
19
- // Longest processing time first. The makespan of a greedy queue is bounded by
20
- // 4/3 of the optimum in this order, against no bound at all in runner order.
21
- function order(queue, cwd) {
22
- const ms = read(cwd)
23
- const of = (file) => ms[path.relative(cwd, file)] ?? UNKNOWN
24
- return queue
25
- .map((file, index) => ({ file, index, ms: of(file) }))
26
- .sort((a, b) => b.ms - a.ms || a.index - b.index)
27
- .map((entry) => entry.file)
28
- }
29
-
30
- function write(cwd, results) {
31
- if (results.length === 0) return
32
- const ms = read(cwd)
33
- for (const res of results) ms[path.relative(cwd, res.file)] = res.ms
34
- try {
35
- fs.mkdirSync(path.dirname(cache(cwd)), { recursive: true })
36
- fs.writeFileSync(cache(cwd), JSON.stringify(ms))
37
- } catch {
38
- // A read-only or absent node_modules costs the next run its ordering, not
39
- // this run its results.
40
- }
41
- }
42
-
43
- module.exports = { order, write, cache }