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 +21 -0
- package/README.md +87 -0
- package/bin.js +52 -0
- package/index.js +116 -0
- package/lib/child.js +66 -0
- package/lib/manifest.js +105 -0
- package/lib/report.js +42 -0
- package/package.json +39 -0
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
|
+
}
|
package/lib/manifest.js
ADDED
|
@@ -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
|
+
}
|