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 +43 -71
- package/bin.js +254 -43
- package/package.json +9 -18
- package/index.js +0 -151
- package/lib/child.js +0 -66
- package/lib/cpus.js +0 -11
- package/lib/jobs.js +0 -16
- package/lib/manifest.js +0 -93
- package/lib/report.js +0 -89
- package/lib/timings.js +0 -43
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
|
|
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
|
-
|
|
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 --
|
|
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]
|
|
22
|
-
|
|
23
|
-
--
|
|
24
|
-
--
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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 `<
|
|
32
|
-
`
|
|
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
|
|
38
|
-
summary.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
61
|
-
if (isBare) await test.load(import.meta.resolve('./tui/keys.ts'))
|
|
62
|
-
```
|
|
61
|
+
### Limits
|
|
63
62
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
`
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
process.exit(1)
|
|
89
|
+
function die(msg) {
|
|
90
|
+
console.error(`brittle-jobs: ${msg}`)
|
|
91
|
+
process.exit(2)
|
|
29
92
|
}
|
|
30
93
|
|
|
31
|
-
|
|
32
|
-
let parsedJobs
|
|
33
|
-
if (jobs !== undefined) {
|
|
34
|
-
parsedJobs = Number(jobs)
|
|
94
|
+
function killGroup(pid, sig) {
|
|
35
95
|
try {
|
|
36
|
-
|
|
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
|
-
|
|
39
|
-
process.exit(1)
|
|
107
|
+
return ''
|
|
40
108
|
}
|
|
41
109
|
}
|
|
42
110
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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.
|
|
4
|
-
"description": "Run brittle test files concurrently, one process per file,
|
|
5
|
-
"
|
|
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
|
-
"
|
|
11
|
-
"bin.js",
|
|
12
|
-
"lib"
|
|
10
|
+
"bin.js"
|
|
13
11
|
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=22"
|
|
14
|
+
},
|
|
14
15
|
"scripts": {
|
|
15
|
-
"test": "
|
|
16
|
-
"lint": "prettier . --check
|
|
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 }
|