diff --git a/README.md b/README.md
index b7c71f14..79254bb5 100644
--- a/README.md
+++ b/README.md
@@ -47,6 +47,7 @@ package-local `TODO.md` files (for example
bun run test:rip # language suite (PR code check)
bun run test # fast compiler/runtime suite
bun run test:all # exhaustive: extended tier + every package
+bun run test:tui # the same lanes, drawn live on packages/tui
rip check [paths...] # headless TypeScript checking over Rip source
bun run parser # regenerate src/parser.js
bun run corpus
@@ -66,6 +67,22 @@ every entry, and compile errors are never cached. `RIP_CACHE_DIR=
`
relocates the cache and `RIP_NO_CACHE=1` disables it. Entries unread for
a week are pruned; `rm -rf .rip/cache` is the full reset.
+### The live runner
+
+`bun run test:tui` runs the lanes `test:all` runs, with the same
+scheduler, the same verdict and the same exit code, and draws them
+live: a cell per lane, a bar for the whole run with its ETA, a bar per
+lane in flight against its last duration, each finished lane scrolled
+into the scrollback as one aligned row, and a card for the first
+failure of each failing lane (`↑`/`↓` choose, Enter opens the lane's
+output, `f` shows only the failing lanes, `q` stops the run and every
+lane's process group). At the end each failing lane's output is printed
+as `test:all` prints it, then a summary card. Each complete run leaves
+the lanes' times and counts in `.rip/test-last.json` for the next run's
+bars and ETA. Piped, under `CI`, or with `--plan` it is `test:all`
+itself. It takes `test:all`'s flags; both front ends are
+`scripts/lanes.mjs` underneath.
+
`test:all` needs `janus` on PATH for the Sites integration lane. Install
[Janus](https://github.com/shreeve/janus#prebuilt-releases), or put a Janus
build on PATH. The lane exercises that binary and rejects a local Go module
diff --git a/package.json b/package.json
index 557180f1..0202ca1c 100644
--- a/package.json
+++ b/package.json
@@ -22,7 +22,8 @@
"test:browser": "bun --cwd=test/browser run test:smoke",
"test:live": "bun --cwd=test/browser run test:live",
"test:rip": "bun test test/rip.test.js --timeout 15000",
- "test:spawn": "bun test --timeout 15000 test/spawn"
+ "test:spawn": "bun test --timeout 15000 test/spawn",
+ "test:tui": "bun scripts/test-live.rip"
},
"bin": {
"rip": "bin/rip"
diff --git a/packages/tui/README.md b/packages/tui/README.md
index db25b924..747dfe25 100644
--- a/packages/tui/README.md
+++ b/packages/tui/README.md
@@ -171,6 +171,7 @@ Each runs with `rip examples/.rip` from `packages/tui`.
| `log.rip` | A build log: finished steps into the scrollback through `Static`, a spinner and a bar on the `clock`, a warning above the frame through `print`, the terminal's own progress indicator, and a quit when the last step is done. |
| `input.rip` | A single-line text field in 40 lines of code: the cursor placed by measured cells, so a wide glyph is two columns and a letter with its marks one; typing inserts at the cursor, the arrows, Home and End move it, Backspace and Delete take a cluster, a paste goes in whole, Enter prints the value above the field and clears it, Escape clears it. |
| `ink/*.rip` | The four ports above, Ink's source beside each. |
+| [`scripts/test-live.rip`](../../scripts/test-live.rip) | The framework in daily use: the repository's own `bun run test:tui`, every lane of the test run drawn live — spinners and bars on one `clock`, finished lanes into the scrollback through `Static`, failure cards in rounded borders with `link` to the file, the terminal's progress indicator — and `test/live.rip` drives it headless through `mount`. |
`log.rip`, `input.rip` and the ports export their component and run it
only as the entry (`run App if import.meta.main`), which is how
@@ -873,7 +874,12 @@ four ports under `examples/ink/` through `mount` and holds each frame
to the one Ink draws for its example, holds the line table above to
what `test/lines.rip` counts, and types, moves, deletes and pastes
into `examples/input.rip`, holding the frame and the cursor after
-every key. `test/text.rip` holds the
+every key. `test/live.rip` drives the repository's live test runner
+with a lane source fed by hand: the lanes in flight and their bars, the
+strip, the scrollback rows in their colors, the progress indicator, the
+failure cards and `f`, the output Enter opens, and the end — the
+failing output as the plain runner prints it, the end card, the exit
+code. `test/text.rip` holds the
text engine — sanitizing, cluster widths, every wrap and truncate mode
— and `test/layout.rip` the layout engine's own pins. `test/input.rip`
holds the terminal input parser: 244 of Ink's input cases as a table
diff --git a/packages/tui/package.json b/packages/tui/package.json
index 9a776392..04491d2a 100644
--- a/packages/tui/package.json
+++ b/packages/tui/package.json
@@ -8,7 +8,7 @@
".": "./tui.rip"
},
"scripts": {
- "test": "rip test.rip && rip test/text.rip && rip test/layout.rip && rip test/input.rip && rip test/events.rip && rip test/mouse.rip && rip test/ink.rip && rip test/yoga.rip && rip test/yoga-aspect.rip && rip test/yoga-hand.rip && rip test/fuzz.rip && rip test/damage.rip && rip test/terminal.rip && rip test/examples.rip",
+ "test": "rip test.rip && rip test/text.rip && rip test/layout.rip && rip test/input.rip && rip test/events.rip && rip test/mouse.rip && rip test/ink.rip && rip test/yoga.rip && rip test/yoga-aspect.rip && rip test/yoga-hand.rip && rip test/fuzz.rip && rip test/damage.rip && rip test/terminal.rip && rip test/examples.rip && rip test/live.rip",
"demo": "rip demo.rip"
},
"files": [
diff --git a/packages/tui/test/live.rip b/packages/tui/test/live.rip
new file mode 100644
index 00000000..6396eba7
--- /dev/null
+++ b/packages/tui/test/live.rip
@@ -0,0 +1,305 @@
+# The repository's live test runner, scripts/test-live.rip, driven
+# headless through `mount`: the lanes' events are fed by hand as the
+# scheduler (scripts/lanes.mjs) would send them, the lanes' clock is a
+# number the test moves, and the board's clock moves with `view.tick`.
+# Every frame is the one a terminal would show at that moment.
+#
+# rip test/live.rip
+
+import { test, eq, ok } from 'rip/testing'
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { mount } from 'rip/tui'
+import * as cells from './ink/cells.rip'
+import { Board, Tally, blocks, firstFailure, recall, remember, PAINT } from '../../../scripts/test-live.rip'
+import { failureBlock } from '../../../scripts/lanes.mjs'
+
+# Four lanes as the scheduler plans them, the last one skipped, and what
+# a last run said of the first three: the root lane prints no line per
+# test, sites and time print one for each as they go.
+lane =! (label, skip) -> { label, cwd: '/repo', cmd: 'bun', args: [], skip }
+LANES =! -> [lane('root (extended tier)'), lane('packages/sites'), lane('packages/time'), lane('packages/tray', '`swift` is not on PATH')]
+MEMORY =! -> { wall: 60000, lanes: { 'root (extended tier)': { ms: 50000, ran: 7900, streamed: 0, spread: null }, 'packages/sites': { ms: 30000, ran: 145, streamed: 145, spread: 0.4 }, 'packages/time': { ms: 800, ran: 548, streamed: 548, spread: 0.5 } } }
+
+# bun's failure under a PTY, as a lane's captured output carries it.
+BUN_FAILURE =! [
+ '$ bun test suite.test.js'
+ 'suite.test.js:'
+ "2 | test('adds', () => { expect(1 + 1).toBe(3) });"
+ ' ^'
+ 'error: expect(received).toBe(expected)'
+ ''
+ 'Expected: 3'
+ 'Received: 2'
+ ''
+ ' at (/repo/packages/sites/suite.test.js:2:38)'
+ '✗ adds [0.12ms]'
+ ''
+ ' 0 pass'
+ ' 1 fail'
+ 'Ran 1 test across 1 file. [2.00ms]'
+].join '\n'
+
+# A board over the four lanes, `memory` its last run (null for a first
+# run), and `hear`, which feeds it an event with the lanes' clock at
+# `at` milliseconds and moves the board's clock a tick. The mount is
+# closed when `body` is done, or when the promise it returns settles.
+board =! (memory, body) ->
+ planned = { lanes: LANES(), excluded: [], skipped: [] }
+ planned.skipped = planned.lanes.filter (it) -> it.skip
+ clockAt = { now: 0 }
+ tally = Tally.new planned, { root: '/repo', cores: 10, jobs: 2, ci: false }, memory, -> clockAt.now
+ view = mount Board, cols: 100, rows: 30, props: { tally }
+ hear = (at, ...events) ->
+ clockAt.now = at
+ tally.hear event for event in events
+ view.tick 100
+ closing = -> view.close()
+ try
+ result = body { view, tally, hear, lanes: planned.lanes }
+ catch error
+ closing()
+ throw error
+ return result.finally(closing) if result?.then
+ closing()
+ result
+
+# A frame's rows, with their trailing blanks cut.
+rows =! (text) -> (row.trimEnd() for row in text.split '\n')
+
+console.log "\nThe live test runner"
+
+test "blocks: a bar fills in eighths of a cell, and the track takes the rest", ->
+ eq blocks(0, 8), { done: '', rest: '────────' }
+ eq blocks(0.5, 8), { done: '████', rest: '────' }
+ eq blocks(0.24, 40), { done: '█████████▋', rest: '──────────────────────────────' }, '76.8 eighths round to 77: nine cells and five eighths'
+ eq blocks(1 / 64, 8), { done: '▏', rest: '───────' }
+ eq blocks(2, 4), { done: '████', rest: '' }, 'a fill past the whole is the whole'
+
+test "firstFailure: bun's error, expected and received above its failing line, and where; the rip harness's one line; a crash's last lines", ->
+ card = firstFailure BUN_FAILURE
+ eq [card.name, card.detail, card.place], ['adds', ['error: expect(received).toBe(expected)', 'Expected: 3', 'Received: 2'], { path: '/repo/packages/sites/suite.test.js', line: 2 }]
+ eq card.lines[card.at], '✗ adds [0.12ms]'
+ piped = firstFailure BUN_FAILURE.replace('✗ adds', '(fail) adds')
+ eq piped.name, 'adds', "a pipe's `(fail)` line is the same failure"
+ painted = firstFailure BUN_FAILURE.replace('✗ adds', '\x1b[0m\x1b[31m✗\x1b[0m\x1b[1m adds\x1b[0m')
+ eq painted.name, 'adds', 'a painted mark is read through its paint'
+ rip = firstFailure " ✓ fine\n ✗ stop exits: expected 0, got null\n2 tests: 1 passed, 1 failed"
+ eq [rip.name, rip.detail, rip.place], ['stop exits', ['expected 0, got null'], null]
+ crash = firstFailure "starting\nSegmentation fault\n\n"
+ eq [crash.name, crash.detail], [null, ['starting', 'Segmentation fault']]
+
+test "lanes in flight: a spinner, the name, a bar against the last run — by time, or by tests for a lane that prints one line each as it goes — and the header's clock", ->
+ board MEMORY(), ({ view, hear, lanes }) ->
+ hear 0, { type: 'plan' }, { type: 'finish', result: { lane: lanes[3], status: 'skip', ms: 0, why: lanes[3].skip, output: '' } }, { type: 'start', lane: lanes[0] }, { type: 'start', lane: lanes[1] }
+ hear 12000, { type: 'chunk', lane: lanes[1], data: Buffer.from(' ✓ one\n \x1b[32m✓\x1b[0m two\r\n ✓ th') }, { type: 'chunk', lane: lanes[1], data: Buffer.from('ree\n … a line that is no test\n') }
+ frame = rows view.frame()
+ eq frame[0], ' rip test:all 3 lanes · 2 at a time · 10 cores 0:12'
+ eq frame[4], ' ⠹ root (extended tier) █████████▋────────────────────────────── 12.0s / ~50s', 'the second tick turns the first set to its third glyph'
+ eq frame[5], ' ⣟ packages/sites ▉─────────────────────────────────────── 3 / 145 tests', 'the second lane is on a set and a phase of its own, and three of 145 tests is seven eighths of its first cell'
+ eq frame[6], ' 0 passed · 0 failed · 1 skipped · 0 tests q quit · f failures'
+ styledRow = cells.styled(view.ansi)[4]
+ ok styledRow.startsWith(' «#22d3ee»⠹«» root (extended tier) «#2cd6d4»█████████▋«dim»────'), "the bar is 24% of the way from cyan to green: #{styledRow}"
+
+test "a lane past its last duration turns amber, and red past twice it; a first run sweeps a bar with nothing to fill it by", ->
+ board MEMORY(), ({ view, hear, lanes }) ->
+ hear 0, { type: 'plan' }, { type: 'start', lane: lanes[0] }
+ hear 60000
+ ok cells.styled(view.frame() and view.ansi)[4].includes('«#fbbf24»████'), 'amber past its last duration'
+ hear 101000
+ ok cells.styled(view.frame() and view.ansi)[4].includes('«#f87171»████'), 'red past twice it'
+ board null, ({ view, hear, lanes }) ->
+ hear 0, { type: 'plan' }, { type: 'start', lane: lanes[0] }
+ hear 3000
+ frame = rows view.frame()
+ eq frame[2], ' ' + ' ███'.padEnd(93) + ' 0/3', 'the whole run: lanes finished of lanes to run'
+ eq frame[4], ' ⠹ root (extended tier) ' + ' ███'.padEnd(40) + ' 3.0s'
+
+test "a finished lane scrolls into the scrollback as one aligned row in its color, with a bar against the slowest lane and how far it moved from its last run", ->
+ board MEMORY(), ({ view, hear, lanes }) ->
+ hear 0, { type: 'plan' }, { type: 'finish', result: { lane: lanes[3], status: 'skip', ms: 0, why: lanes[3].skip, output: '' } }, { type: 'start', lane: lanes[0] }, { type: 'start', lane: lanes[2] }
+ hear 700, { type: 'finish', result: { lane: lanes[2], status: 'pass', ms: 700, ran: 548, output: '548 tests: 548 passed, 0 failed' } }, { type: 'start', lane: lanes[1] }
+ hear 5000, { type: 'finish', result: { lane: lanes[1], status: 'fail', ms: 4300, why: 'exit 1', output: BUN_FAILURE } }
+ view.frame()
+ eq cells.plain(view.scrollback), [
+ '⊘ packages/tray skipped: `swift` is not on PATH'
+ '✓ packages/time 548 tests 0.7s ▏ −0.1s'
+ '✗ packages/sites 4.3s exit 1'
+ ''
+ ]
+ eq cells.styled(view.scrollback), [
+ '«#facc15»⊘«» «#facc15»packages/tray«» «#facc15»skipped:«» «#facc15»`swift`«» «#facc15»is«» «#facc15»not«» «#facc15»on«» «#facc15»PATH'
+ '«#4ade80»✓«» packages/time 548 tests «dim»0.7s«» «#64748b»▏«» «#4ade80»−0.1s'
+ '«#f87171 bold»✗«» «#f87171 bold»packages/sites«» «dim»4.3s«» «#f87171»exit«» «#f87171»1'
+ ''
+ ]
+
+test "the strip holds one cell per lane in the order they start, and the footer the totals", ->
+ board MEMORY(), ({ view, hear, lanes }) ->
+ hear 0, { type: 'plan' }, { type: 'finish', result: { lane: lanes[3], status: 'skip', ms: 0, why: lanes[3].skip, output: '' } }, { type: 'start', lane: lanes[0] }, { type: 'start', lane: lanes[2] }
+ hear 700, { type: 'finish', result: { lane: lanes[2], status: 'pass', ms: 700, ran: 548, output: '548 tests: 548 passed, 0 failed' } }, { type: 'start', lane: lanes[1] }
+ hear 5000, { type: 'finish', result: { lane: lanes[1], status: 'fail', ms: 4300, why: 'exit 1', output: BUN_FAILURE } }
+ frame = rows view.frame()
+ eq frame[1], ' ⠸✗●⊘'
+ eq cells.styled(view.ansi)[1], ' «#22d3ee»⠸«#f87171 bold»✗«#4ade80»●«#facc15»⊘'
+ eq frame[frame.length - 1], ' 1 passed · 1 failed · 1 skipped · 548 tests q quit · f failures · ↑↓ choose · ⏎ output'
+
+test "the terminal's progress indicator follows the run's share, weighed by the last run, and says error once a lane fails", ->
+ board MEMORY(), ({ view, hear, lanes }) ->
+ hear 0, { type: 'plan' }, { type: 'start', lane: lanes[0] }, { type: 'start', lane: lanes[2] }
+ view.frame()
+ hear 20000, { type: 'finish', result: { lane: lanes[2], status: 'pass', ms: 700, ran: 548, output: '548 tests: ok' } }
+ view.frame()
+ ok view.bytes.includes('\x1b]9;4;1;26\x1b\\'), "20 s of the root lane's 50 and time's 0.8 whole, of 80.8"
+ hear 21000, { type: 'start', lane: lanes[1] }, { type: 'finish', result: { lane: lanes[1], status: 'fail', ms: 100, why: 'exit 1', output: BUN_FAILURE } }
+ view.frame()
+ ok view.bytes.includes('\x1b]9;4;2\x1b\\'), 'error once a lane fails'
+ board null, ({ view, hear, lanes }) ->
+ hear 0, { type: 'plan' }, { type: 'start', lane: lanes[2] }
+ hear 700, { type: 'finish', result: { lane: lanes[2], status: 'pass', ms: 700, ran: 548, output: '548 tests: ok' } }
+ view.frame()
+ ok view.bytes.includes('\x1b]9;4;1;33\x1b\\'), 'on a first run, the lanes finished of the lanes to run'
+
+test "a failing lane's card: the test, what it said, and where, as a link; ↑ and ↓ go between the cards; f shows the failing lanes in place of the lanes in flight", ->
+ board MEMORY(), ({ view, hear, lanes }) ->
+ hear 0, { type: 'plan' }, { type: 'start', lane: lanes[0] }, { type: 'start', lane: lanes[1] }
+ hear 4000, { type: 'finish', result: { lane: lanes[1], status: 'fail', ms: 4000, why: 'exit 1', output: BUN_FAILURE } }, { type: 'start', lane: lanes[2] }
+ hear 5000, { type: 'finish', result: { lane: lanes[2], status: 'timeout', ms: 1000, why: 'timed out after 1.0s', output: " ✗ clocks: never settled\n" } }
+ frame = rows view.frame()
+ eq frame.slice(5, 12), [
+ '╭──────────────────────────────────────────────────────────────────────────────────────────────────╮'
+ '│ ✗ packages/sites exit 1 1/2 │'
+ '│ adds │'
+ '│ error: expect(received).toBe(expected) │'
+ '│ Expected: 3 │'
+ '│ Received: 2 │'
+ '│ packages/sites/suite.test.js:2 │'
+ ]
+ ok cells.styled(view.ansi)[11].includes('«dim underline link file:///repo/packages/sites/suite.test.js»packages/sites/suite.test.js:2'), 'the place, under the root, is a link to the file'
+ view.press 'ArrowDown'
+ frame = rows view.frame()
+ eq frame.slice(6, 8), [
+ '│ ✗ packages/time timed out after 1.0s 2/2 │'
+ '│ clocks │'
+ ]
+ view.press 'ArrowDown'
+ eq rows(view.frame())[6], '│ ✗ packages/sites exit 1 1/2 │', 'and round again'
+ view.press 'f'
+ frame = rows view.frame()
+ eq frame.slice(4, 6), [' › ✗ packages/sites exit 1', ' ✗ packages/time timed out after 1.0s']
+ view.press 'ArrowUp'
+ eq rows(view.frame()).slice(4, 6), [' ✗ packages/sites exit 1', ' › ✗ packages/time timed out after 1.0s']
+ view.press 'f'
+ eq rows(view.frame())[4].slice(3, 23), 'root (extended tier)', 'f again: the lanes in flight'
+
+test "Enter opens the failing lane's captured output below the frame at its failure, the arrows move through it, Escape closes it", ->
+ board MEMORY(), ({ view, hear, lanes }) ->
+ hear 0, { type: 'plan' }, { type: 'start', lane: lanes[1] }
+ hear 4000, { type: 'finish', result: { lane: lanes[1], status: 'fail', ms: 4000, why: 'exit 1', output: BUN_FAILURE } }
+ view.press 'Enter'
+ frame = rows view.frame()
+ eq frame.length, 30, 'the pane takes the rows the rest of the frame leaves, and no more'
+ eq frame.slice(12, 16), [
+ '┌──────────────────────────────────────────────────────────────────────────────────────────────────┐'
+ '│ packages/sites · lines 2–15 of 15 │'
+ '│ suite.test.js: │'
+ "│ 2 | test('adds', () => { expect(1 + 1).toBe(3) }); │"
+ ], 'opened at the failure, as far down as the output goes'
+ eq frame[frame.length - 1], ' 0 passed · 1 failed · 0 skipped · 0 tests'.padEnd(70) + '↑↓ PgUp PgDn scroll · ⏎ close', 'the keys the open output takes'
+ view.press 'Home'
+ eq rows(view.frame())[13], '│ packages/sites · lines 1–14 of 15 │'
+ view.press 'ArrowDown'
+ eq rows(view.frame())[14], '│ suite.test.js: │'
+ view.press 'Escape'
+ frame = rows view.frame()
+ eq frame.length, 13, 'closed: the header, the strip, the bar, a blank row, the card and the footer'
+ eq frame[frame.length - 1], ' 0 passed · 1 failed · 0 skipped · 0 tests'.padEnd(57) + 'q quit · f failures · ↑↓ choose · ⏎ output'
+
+test! "the end: every failing lane's output printed above the frame as the plain runner prints it, then the end card, and the plain runner's exit code", ->
+ board! MEMORY(), ({ view, hear, lanes, tally }) ->
+ hear 0, { type: 'plan' }, { type: 'finish', result: { lane: lanes[3], status: 'skip', ms: 0, why: lanes[3].skip, output: '' } }, { type: 'start', lane: lanes[0] }, { type: 'start', lane: lanes[1] }
+ failing = { lane: lanes[1], status: 'fail', ms: 4000, why: 'exit 1', output: BUN_FAILURE }
+ hear 4000, { type: 'finish', result: failing }, { type: 'start', lane: lanes[2] }
+ passing = [{ lane: lanes[2], status: 'pass', ms: 700, ran: 548, output: '548 tests: ok' }, { lane: lanes[0], status: 'pass', ms: 45000, ran: 7938, output: 'Ran 7938 tests' }]
+ hear 4700, { type: 'finish', result: passing[0] }
+ hear 45000, { type: 'finish', result: passing[1] }, { type: 'end', summary: { results: [failing, ...passing], skipped: [lanes[3]], excluded: [], failed: [failing], wall: 45000, ranTotal: 8486, code: 1 } }
+ view.frame()
+ before = view.scrollback
+ ok cells.plain(before).some((row) -> row.startsWith '✓ root (extended tier) 7,938 tests'), 'the last lane is in the scrollback'
+ ok not before.includes('failures, then last 60 lines'), 'the run ends a tick after its last lane, once that lane is in the scrollback'
+ view.tick 100
+ frame = rows view.frame()
+ after = view.scrollback.slice before.length
+ ok after.startsWith(failureBlock(failing, PAINT) + '\n'), "the failing lane's block, byte for byte the plain runner's"
+ eq cells.plain(after.slice(failureBlock(failing, PAINT).length + 1)), [
+ '╭──────────────────────────────────────────────────────────────────────────────────────────────────╮'
+ '│ lanes 2 passed · 1 failed · 1 skipped │'
+ '│ tests 8,486 │'
+ '│ wall 45.0s last 60.0s, −15.0s │'
+ '│ lanes 49.7s of lane time · 1.1× parallel │'
+ '│ slowest root (extended tier) 45.0s ████████████ │'
+ '│ packages/sites 4.0s █▏ │'
+ '│ packages/time 0.7s ▏ │'
+ '│ │'
+ '│ ✗ 1 of 3 lanes failed: packages/sites │'
+ '╰──────────────────────────────────────────────────────────────────────────────────────────────────╯'
+ ''
+ ]
+ eq frame, [
+ ' rip test:all 3 lanes · 2 at a time · 10 cores 0:45'
+ ' ●✗●⊘'
+ ' ████████████████████████████████████████████████████████████████████████████████████████████ 100%'
+ ' 2 passed · 1 failed · 1 skipped · 8,486 tests'
+ ]
+ eq (await view.done), 1
+ ok tally.closed
+
+test! "a green run's end card says so in its verdict, and exits 0", ->
+ board! MEMORY(), ({ view, hear, lanes }) ->
+ hear 0, { type: 'plan' }, { type: 'start', lane: lanes[2] }
+ passing = { lane: lanes[2], status: 'pass', ms: 700, ran: 548, output: '548 tests: ok' }
+ hear 700, { type: 'finish', result: passing }, { type: 'end', summary: { results: [passing], skipped: [], excluded: [], failed: [], wall: 700, ranTotal: 548, code: 0 } }
+ view.tick 100
+ view.frame()
+ card = cells.styled view.scrollback
+ ok card.some((row) -> row.includes '«#4ade80 bold»✓«» «#4ade80 bold»All«» «#4ade80 bold»1«» «#4ade80 bold»lanes«» «#4ade80 bold»green«»'), 'the verdict, bold green'
+ ok cells.plain(view.scrollback).some((row) -> row.includes '✓ All 1 lanes green · 548 tests · 0.7s')
+ eq (await view.done), 0
+
+test "a complete run is remembered for the next: each passing lane's time, its count, and how its lines arrived", ->
+ root = mkdtempSync join(tmpdir(), 'rip-live-')
+ try
+ eq recall(root), null, 'nothing before the first run'
+ board MEMORY(), ({ view, hear, lanes, tally }) ->
+ hear 0, { type: 'plan' }, { type: 'start', lane: lanes[2] }
+ hear 200, { type: 'chunk', lane: lanes[2], data: Buffer.from(' ✓ a\n') }
+ hear 600, { type: 'chunk', lane: lanes[2], data: Buffer.from(' ✓ b\n') }
+ passing = { lane: lanes[2], status: 'pass', ms: 800, ran: 2, output: '2 tests: ok' }
+ hear 800, { type: 'finish', result: passing }, { type: 'end', summary: { results: [passing], skipped: [], excluded: [], failed: [], wall: 800, ranTotal: 2, code: 0 } }
+ remember root, tally
+ memory = recall root
+ eq memory.wall, 800
+ eq memory.lanes['packages/time'], { ms: 800, ran: 2, streamed: 2, spread: 0.5 }
+ eq memory.lanes['root (extended tier)'], MEMORY().lanes['root (extended tier)'], 'a lane that did not pass keeps what the last run said'
+ finally
+ rmSync root, { recursive: true, force: true }
+
+test "a terminal under 70 columns keeps each lane in flight to its spinner, name and time, and one under 40 drops the strip", ->
+ board MEMORY(), ({ view, hear, lanes }) ->
+ hear 0, { type: 'plan' }, { type: 'start', lane: lanes[0] }
+ hear 12000
+ view.resize 60, 30
+ frame = rows view.frame()
+ eq frame[0], ' rip test:all 3 lanes'.padEnd(55) + '0:12'
+ eq frame[4], ' ⠹ root (extended tier) 12.0s'
+ view.resize 36, 30
+ frame = rows view.frame()
+ ok frame[1].includes('%'), 'the whole run is the second row'
+ ok not frame.some((row) -> row.includes '··⊘'), 'and the strip is gone'
+
+test! "q quits the board mid-run, with no exit code of its own", ->
+ board! MEMORY(), ({ view, hear, lanes }) ->
+ hear 0, { type: 'plan' }, { type: 'start', lane: lanes[0] }
+ view.press 'q'
+ eq (await view.done), undefined
diff --git a/scripts/lanes.mjs b/scripts/lanes.mjs
new file mode 100644
index 00000000..a18c51d5
--- /dev/null
+++ b/scripts/lanes.mjs
@@ -0,0 +1,495 @@
+// scripts/lanes.mjs — the lanes behind `bun run test:all` and `bun run test:tui`.
+//
+// No single `bun test` covers the repository: bunfig keeps the root suite
+// out of packages/**, and each workspace package runs its own suite from
+// its own directory. A lane is one such suite, spawned as the process its
+// package.json declares. Lanes are found by walking packages/*/ for a
+// `test` script, never from a list kept here. test/browser (Playwright)
+// is not a lane: it needs installed browsers and runs as
+// `bun run test:browser` / CI's browser job.
+//
+// This module plans the lanes, runs them, and says what each one's output
+// means; it prints nothing. A front end reads the plan and starts the run
+// with a listener, which hears every step as one event:
+//
+// { type: 'plan', plan } as the run starts, before any lane
+// { type: 'start', lane, at } a lane is spawned (`at` is Date.now())
+// { type: 'chunk', lane, data } bytes the lane wrote, a Buffer, as they come
+// { type: 'finish', result } { lane, status, ms, ran, why, output }
+// { type: 'end', summary } { results, skipped, excluded, failed, wall, ranTotal, code }
+//
+// A result's status is 'pass', 'fail', 'timeout' or 'skip'; a skipped
+// lane (its tool is missing) finishes as the run starts and is never
+// spawned. `ran` is the test count its runner reported, `output` what
+// it wrote, normalized as a pipe would carry it. The summary's `code`
+// is the exit status the run earns: 1 when a lane failed, 1 when a lane
+// was skipped in CI, 0 otherwise. `results` holds the lanes that ran,
+// in the order they finished.
+
+import { spawnSync } from 'node:child_process';
+import { readFileSync, readdirSync, statSync } from 'node:fs';
+import { availableParallelism } from 'node:os';
+import { dirname, join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+export const HERE = dirname(dirname(fileURLToPath(import.meta.url)));
+
+// OVERSUBSCRIBE is the peak as a multiple of the cores. Above 1.0 because
+// lanes spawn subprocesses and wait on sockets rather than burn CPU flat
+// out, so a strict 1:1 budget leaves cores idle; past ~1.5 the timed
+// suites start missing deadlines. It only holds if every lane keeps to
+// its share, which is what RIP_LANE_WORKERS is for.
+const OVERSUBSCRIBE = 1.4;
+
+// The label names its tier: the extended tier is what makes this lane
+// ~2x the work of a bare `bun run test`, so the two wall times are not
+// comparable.
+export const ROOT_LANE = 'root (extended tier)';
+
+// Lanes start in this order, longest first, so a long lane picked up late
+// cannot stretch the wall clock past the root suite. A lane not listed
+// starts after every listed one, in discovery order.
+const LONGEST_FIRST = [
+ ROOT_LANE,
+ 'packages/sites',
+ 'packages/vscode',
+ 'packages/ui',
+ 'packages/print',
+ 'packages/email',
+ 'packages/db',
+ 'packages/swarm',
+];
+
+// Named package exclusions (none today — Playwright lives under test/browser).
+const EXCLUDED = new Map();
+
+export const secs = (ms) => `${(ms / 1000).toFixed(1)}s`;
+
+// ── Configuration ──────────────────────────────────────────────────────
+// Flags: --root repository to orchestrate (default: this checkout)
+// --jobs lanes in flight at once (default: half the cores, min 2)
+// --timeout per-lane timeout (default: 600000)
+// --plan print the lanes that would run, spawn nothing
+//
+// Every package lane gets RIP_LANE_WORKERS, its share of the CPU budget
+// below: a suite that fans out CPU-bound work (packages/vscode's `bun test
+// --parallel`) sizes itself by it instead of by the machine. packages/sites
+// ignores it on purpose — its sub-suites mostly wait, so its cap of 4 is a
+// latency choice.
+export const configure = (argv, env = process.env) => {
+ const flag = (name, fallback) => {
+ const i = argv.indexOf(`--${name}`);
+ return i === -1 || i === argv.length - 1 ? fallback : argv[i + 1];
+ };
+
+ // A numeric flag is refused rather than coerced, because NaN is not inert
+ // here: `live.size < NaN` is false forever, so a mistyped --jobs starts no
+ // lane and the run hangs with no output; and setTimeout treats a NaN delay
+ // as 0, so a mistyped --timeout kills every lane the instant it spawns and
+ // blames the suites. Exit 2 is this repo's usage-error code.
+ const number = (name, fallback, min) => {
+ const i = argv.indexOf(`--${name}`);
+ if (i === -1) return fallback;
+ const raw = argv[i + 1];
+ const n = Number(raw);
+ if (raw === undefined || raw.startsWith('--') || !Number.isFinite(n) || n < min) {
+ console.error(`[rip] --${name} needs a number >= ${min}; got ${raw === undefined ? '(nothing)' : JSON.stringify(raw)}`);
+ process.exit(2);
+ }
+ return n;
+ };
+
+ const root = resolve(flag('root', HERE));
+
+ // One CPU budget for the whole run: oversubscription does not fail loudly,
+ // it stretches every clock until the suites that time real machinery miss
+ // deadlines they meet idle. availableParallelism respects CPU affinity, so
+ // a pinned or containerised runner is sized by what it may actually use.
+ const cores = availableParallelism();
+ const peak = Math.max(3, Math.round(cores * OVERSUBSCRIBE));
+
+ // Lane slots are a packing constraint, not a CPU one: the long sibling
+ // lanes have to overlap the root lane or the wall clock becomes their sum.
+ const jobs = Math.floor(number('jobs', Math.max(2, Math.floor(cores / 2)), 1));
+
+ // The peak is split between the root lane and the jobs-1 siblings beside
+ // it. One sibling fans out (vscode, sized by RIP_LANE_WORKERS) and counts
+ // at laneWorkers, the rest are one process each, and the root lane — the
+ // CPU-bound critical path — gets the remainder, never more than the
+ // machine, never fewer than two. laneWorkers is four where the peak
+ // affords it and shrinks before the root lane would drop below two.
+ const siblings = Math.max(0, jobs - 1);
+ const plainSiblings = Math.max(0, siblings - 1);
+ const laneWorkers = Math.max(1, Math.min(4, peak - 2 - plainSiblings));
+ const rootWorkers = Math.max(2, Math.min(cores, peak - plainSiblings - laneWorkers));
+
+ const timeoutMs = number('timeout', 600_000, 1);
+
+ return {
+ root,
+ cores,
+ jobs,
+ laneWorkers,
+ rootWorkers,
+ timeoutMs,
+ ci: Boolean(env.CI),
+ plan: argv.includes('--plan'),
+ };
+};
+
+// ── Tool resolution ────────────────────────────────────────────────────
+// A package lane runs `bun run test`, but what that script INVOKES may be
+// absent (no install, so no node_modules/.bin/rip). Resolving the script's
+// first token up front is what separates "this suite could not run" from
+// "this suite failed" — the distinction the CI teeth depend on.
+const resolveTool = (tool, cwd, root) => {
+ if (!tool) return null;
+ if (tool === 'bun' || tool === 'bunx') return process.execPath;
+ const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
+ if (tool.includes('/')) {
+ const p = resolve(cwd, tool);
+ return isFile(p) ? p : null;
+ }
+ for (const dir of [join(cwd, 'node_modules/.bin'), join(root, 'node_modules/.bin')]) {
+ if (isFile(join(dir, tool))) return join(dir, tool);
+ }
+ for (const dir of (process.env.PATH ?? '').split(':')) {
+ if (dir && isFile(join(dir, tool))) return join(dir, tool);
+ }
+ return null;
+};
+
+// ── Lane planning ──────────────────────────────────────────────────────
+const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
+
+// The lanes in the order they start, the packages excluded by name, and
+// the lanes whose tool is missing (`skip` says why), which are among
+// `lanes` too.
+export const planLanes = (config) => {
+ const { root } = config;
+ const lanes = [];
+ const excluded = [];
+ // tsgo is a Go binary and every lane starts it many times (rip check,
+ // the editor server); with the default GOGC=100 half of a short session
+ // is the collector. 400 is a fifth of the collections, identical
+ // answers, measured 7% off the check gate — and bun ignores the variable.
+ const GO_ENV = { GOGC: process.env.GOGC ?? '400' };
+
+ // The full root suite: in-process + test/spawn + the extended tier
+ // (see test/support/extended.js). `bun run test` is the fast edit loop
+ // (in-process path list only); this lane runs all of `test/` so the
+ // process lane and extended gates still certify here.
+ lanes.push({
+ label: ROOT_LANE,
+ cwd: root,
+ cmd: process.execPath,
+ // 60s, not 15s: the extended tier's scaling gates budget up to three
+ // full measurements, and a busy lane stretches one past 5s.
+ args: ['test', `--parallel=${config.rootWorkers}`, '--timeout', '60000'],
+ env: { ...GO_ENV, RIP_EXTENDED: '1', RIP_REQUIRE_TSC: '1' },
+ });
+
+ const packagesDir = join(root, 'packages');
+ let names = [];
+ try { names = readdirSync(packagesDir).sort(); } catch { names = []; }
+
+ for (const name of names) {
+ const cwd = join(packagesDir, name);
+ const pkg = readJson(join(cwd, 'package.json'));
+ const script = pkg?.scripts?.test;
+ if (!script) continue; // not a suite — nothing to run, nothing to report
+
+ if (EXCLUDED.has(name)) { excluded.push({ name, why: EXCLUDED.get(name) }); continue; }
+
+ const tool = script.trim().split(/\s+/)[0];
+ lanes.push({
+ label: `packages/${name}`,
+ cwd,
+ cmd: process.execPath,
+ args: ['run', 'test'],
+ env: { ...GO_ENV, RIP_LANE_WORKERS: String(config.laneWorkers) },
+ skip: resolveTool(tool, cwd, root) ? undefined : `\`${tool}\` is not on PATH or in node_modules/.bin`,
+ });
+ }
+
+ // Stable sort: unlisted lanes keep discovery order after the listed ones.
+ const rank = (lane) => { const i = LONGEST_FIRST.indexOf(lane.label); return i === -1 ? LONGEST_FIRST.length : i; };
+ lanes.sort((a, b) => rank(a) - rank(b));
+
+ return { lanes, excluded, skipped: lanes.filter((l) => l.skip) };
+};
+
+// ── Guardrails ─────────────────────────────────────────────────────────
+// Must hold before any lane starts, and fails confusingly INSIDE
+// unrelated lanes when it does not: a missing tsgo makes the vscode
+// suite skip its whole LSP surface and pass. The root lane's extended
+// tier needs tsgo too and has no preflight of its own, so the
+// guarantee lives here — every entry point reaches it. A foreign
+// --root is a fixture repository, which has neither to check.
+export const guardrails = (config) => {
+ if (config.root !== HERE || config.plan) return;
+ for (const [script, args] of [['preflight.mjs', []]]) {
+ const r = spawnSync(process.execPath, [join(HERE, 'scripts', script), ...args], { stdio: 'inherit' });
+ if (r.status !== 0) process.exit(r.status ?? 1);
+ }
+};
+
+// ── What a lane's output says ──────────────────────────────────────────
+// A lane that exits 0 having run NOTHING is, to an exit code alone,
+// indistinguishable from a lane that passed — and it is reachable:
+// `bun test` over a file whose every describe is skipped prints
+// "Ran 0 tests" and exits 0, and the rip harness sets a non-zero code
+// only for a failure. Seven vscode lanes would go green that way with
+// tsgo absent (the guardrails refuse first, but that closes one
+// instance, not the class).
+//
+// All three runners end with a count, so require one and require it to
+// be positive. This is the only thing here that reads a lane's output, and
+// a lane that prints no count at all is a failure too: silence is the
+// state being ruled out, so it cannot be the state that passes.
+//
+// The sum is per COMMAND, not per lane — packages/app runs three, and
+// all three count. Two sharp edges: bun writes "Ran 1 test" singular,
+// and a one-test lane is the case sitting closest to the zero this
+// gate exists to catch; and the anchor is a real newline rather than
+// /m, whose ^ also matches after a bare carriage return, where a
+// redrawn progress line would count as a run.
+const TEST_COUNTS = [
+ /(?:^|\n)Ran (\d+) tests?\b/g, // bun test
+ /(?:^|\n)(\d+) tests?:/g, // rip test (rip/testing)
+ /(?:^|\n) *(\d+) passed \(/g, // playwright test (packages/ui)
+];
+// Strip CSI / OSC so painted tallies still match the count regexes.
+export const stripAnsi = (s) => s.replace(/\x1b(?:\[[0-9;?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\))/g, '');
+// PTYs speak CRLF / bare CR (progress redraws); normalize before matching
+// and before reprinting so blocks read like a captured pipe.
+export const normalizeOut = (chunks) =>
+ Buffer.concat(chunks).toString('utf8').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
+export const testsReported = (output) => {
+ const plain = stripAnsi(output);
+ let total = null;
+ for (const re of TEST_COUNTS) {
+ for (const m of plain.matchAll(re)) total = (total ?? 0) + Number(m[1]);
+ }
+ return total; // null → the lane never printed a count
+};
+
+// A runner prints a failure's DETAIL where the test ran and only its
+// NAME in the closing summary, so a lane's tail alone carries the name
+// and loses the assertion message — which for a measurement gate is the
+// whole point. Lift the lines leading up to each failure marker too:
+// bun's `(fail)` line (`✗` under a PTY), and the rip harness's
+// `✗ name: message` line, which a package lane of many sub-suites
+// prints far above its tail.
+export const FAILURE_MARK = /^\(fail\)|^ *✗ /;
+export const failureDetail = (output, lead = 30) => {
+ const lines = output.split('\n');
+ const out = [];
+ lines.forEach((line, i) => {
+ if (!FAILURE_MARK.test(line) || out.length > 400) return;
+ out.push(...lines.slice(Math.max(0, i - lead), i + 1), '');
+ });
+ return out.join('\n').trimEnd();
+};
+
+// A failing lane's block as the plain runner prints it after the
+// summary: the lines around each failure, then the lane's last 60
+// lines, under a rule that names the lane. `paint` holds the runner's
+// `dim` and `red`.
+export const failureBlock = (result, { dim, red }) => {
+ const detail = failureDetail(result.output);
+ const tail = result.output.split('\n').slice(-60).join('\n').trimEnd();
+ let text = `\n${dim('─'.repeat(72))}\n${red(`✗ ${result.lane.label}`)} ${dim('— failures, then last 60 lines')}\n${dim('─'.repeat(72))}`;
+ if (detail) text += `\n${detail}\n`;
+ if (tail) text += `\n${tail}`;
+ return text;
+};
+
+// ── Running ────────────────────────────────────────────────────────────
+// A lane under a PTY leads a session of its own, so its process group
+// is its pid and everything it spawned without leaving the group dies
+// with it; a piped lane shares ours and is signalled alone.
+const signal = (proc, sig) => {
+ if (proc.group) { try { process.kill(-proc.pid, sig); } catch { /* gone */ } }
+ try { proc.kill(sig); } catch { /* already dead */ }
+};
+
+// Every process below `pids`, with the process group each leads. A
+// lane's descendants need not stay in its group: `bun test --parallel`
+// puts each worker in a group of its own, and a worker whose
+// coordinator is killed is re-parented and runs on. So an abort reads
+// the whole tree while the lanes are still its roots, and signals every
+// process in it and every group one of them leads. Synchronous, so an
+// exit handler can use it.
+const tree = (pids) => {
+ const r = spawnSync('ps', ['-Ao', 'pid=,ppid=,pgid='], { encoding: 'utf8' });
+ const rows = (r.stdout ?? '').trim().split('\n').map((line) => line.trim().split(/\s+/).map(Number));
+ const below = new Set(pids);
+ for (let grew = true; grew;) {
+ grew = false;
+ for (const [pid, ppid] of rows) {
+ if (below.has(ppid) && !below.has(pid)) { below.add(pid); grew = true; }
+ }
+ }
+ const leaders = rows.filter(([pid, , pgid]) => below.has(pid) && pid === pgid).map(([pid]) => pid);
+ return { pids: [...below], leaders };
+};
+
+const signalTree = (procs, sig) => {
+ const { pids, leaders } = tree(procs.map((proc) => proc.pid));
+ for (const pgid of leaders) { try { process.kill(-pgid, sig); } catch { /* gone */ } }
+ for (const pid of pids) { try { process.kill(pid, sig); } catch { /* gone */ } }
+};
+
+// Start the planned run: `usePty` gives each lane a PTY of `cols` by
+// `rows`, so runners see isTTY and paint; `hear` is told every event.
+// The run returns at once with `done`, which resolves with the summary,
+// and `abort`, which signals every process under the lanes in flight,
+// and every process group one of them leads, with `sig` and, after
+// `grace` milliseconds, SIGKILL; it resolves once every lane has exited
+// or been killed.
+export const launch = (config, planned, { usePty, cols = 120, rows = 40 }, hear) => {
+ const { lanes, excluded, skipped } = planned;
+ const { jobs, timeoutMs, ci } = config;
+
+ // Every lane process in flight, so an interrupted run can take them
+ // down. Left alone, Ctrl-C kills only this process: the lanes run in
+ // their own sessions (the PTY below), keep going without a reader, and
+ // a suite that dies of the closed PTY mid-flight strands whatever it
+ // had spawned detached — a Playwright web server on :4180, say, which
+ // the NEXT run then trips over.
+ const live = new Set();
+
+ const runLane = async (lane) => {
+ const started = Date.now();
+ const chunks = [];
+ const take = (data) => {
+ const bytes = Buffer.from(data);
+ chunks.push(bytes);
+ hear({ type: 'chunk', lane, data: bytes });
+ };
+ const finish = (extra) => ({
+ lane,
+ ms: Date.now() - started,
+ output: normalizeOut(chunks),
+ ...extra,
+ });
+
+ const env = { ...process.env, ...lane.env };
+ // Parent may have FORCE_COLOR (piped `test:all | less -R`). Strip it
+ // so lane children that pin stdout stay byte-stable; the PTY is what
+ // paints the lane reporters.
+ delete env.FORCE_COLOR;
+ let proc;
+ try {
+ if (usePty) {
+ proc = Bun.spawn([lane.cmd, ...lane.args], {
+ cwd: lane.cwd,
+ env,
+ terminal: { cols, rows, data(_term, data) { take(data); } },
+ });
+ } else {
+ proc = Bun.spawn([lane.cmd, ...lane.args], {
+ cwd: lane.cwd,
+ env,
+ stdin: 'ignore',
+ stdout: 'pipe',
+ stderr: 'pipe',
+ });
+ }
+ } catch (e) {
+ return finish({ status: 'fail', why: `could not spawn: ${e?.message ?? e}` });
+ }
+ proc.group = usePty;
+ live.add(proc);
+ proc.exited.then(() => live.delete(proc));
+
+ // A lane past its deadline is told to stop, then killed. The exit wait
+ // below is bounded too: a lane that has been killed is finished whether
+ // or not its exit is ever observed (a PTY lane's can go unreported).
+ let timedOut = false;
+ let killed;
+ const gaveUp = new Promise((resolve) => { killed = resolve; });
+ const timer = setTimeout(() => {
+ timedOut = true;
+ signal(proc, 'SIGTERM');
+ setTimeout(() => {
+ signal(proc, 'SIGKILL');
+ try { proc.terminal?.close(); } catch { /* closed */ }
+ setTimeout(() => killed(null), 5000).unref();
+ }, 5000).unref();
+ }, timeoutMs);
+
+ if (!usePty) {
+ const pull = async (stream) => {
+ if (!stream) return;
+ for await (const chunk of stream) take(chunk);
+ };
+ await Promise.all([pull(proc.stdout), pull(proc.stderr)]);
+ }
+
+ const code = await Promise.race([proc.exited, gaveUp]);
+ clearTimeout(timer);
+ try { proc.terminal?.close(); } catch { /* closed */ }
+
+ if (timedOut) return finish({ status: 'timeout', why: `timed out after ${secs(timeoutMs)}` });
+ if (code !== 0) return finish({ status: 'fail', why: `exit ${code}` });
+ const ran = testsReported(normalizeOut(chunks));
+ if (ran === null) return finish({ status: 'fail', why: 'exited 0 without reporting a test count' });
+ if (ran === 0) return finish({ status: 'fail', why: 'exited 0 having run no tests' });
+ return finish({ status: 'pass', ran });
+ };
+
+ const run = async () => {
+ const startedAt = Date.now();
+ hear({ type: 'plan', plan: { ...planned, config } });
+ for (const lane of skipped) hear({ type: 'finish', result: { lane, status: 'skip', ms: 0, why: lane.skip, output: '' } });
+
+ const queue = lanes.filter((l) => !l.skip);
+ const results = [];
+ let inFlight = 0;
+ let next = 0;
+ await new Promise((allDone) => {
+ if (queue.length === 0) return allDone();
+ const pump = () => {
+ while (inFlight < jobs && next < queue.length) {
+ const lane = queue[next++];
+ inFlight += 1;
+ hear({ type: 'start', lane, at: Date.now() });
+ runLane(lane).then((result) => {
+ inFlight -= 1;
+ results.push(result);
+ hear({ type: 'finish', result });
+ if (results.length === queue.length) allDone();
+ else pump();
+ });
+ }
+ };
+ pump();
+ });
+
+ const wall = Date.now() - startedAt;
+ const failed = results.filter((r) => r.status !== 'pass');
+ const ranTotal = results.reduce((sum, r) => sum + (r.ran ?? 0), 0);
+ // The teeth: locally a missing tool is a visible skip, in CI it is a
+ // failure. A CI run that stops covering a suite must never go green.
+ const code = failed.length > 0 || (ci && skipped.length > 0) ? 1 : 0;
+ const summary = { results, skipped, excluded, failed, wall, ranTotal, code };
+ hear({ type: 'end', summary });
+ return summary;
+ };
+
+ const abort = (sig = 'SIGKILL', grace = 0) => {
+ if (live.size) signalTree([...live], sig);
+ return new Promise((resolve) => {
+ const deadline = setTimeout(() => {
+ if (live.size) signalTree([...live], 'SIGKILL');
+ resolve();
+ }, grace);
+ deadline.unref?.();
+ Promise.all([...live].map((proc) => proc.exited)).then(() => { clearTimeout(deadline); resolve(); });
+ });
+ };
+
+ return { done: run(), abort, live };
+};
diff --git a/scripts/test-all.mjs b/scripts/test-all.mjs
index 7257400c..6ae10b5e 100644
--- a/scripts/test-all.mjs
+++ b/scripts/test-all.mjs
@@ -1,118 +1,29 @@
#!/usr/bin/env bun
-// scripts/test-all.mjs — the lane orchestrator behind `bun run test:all`.
+// scripts/test-all.mjs — the plain front end behind `bun run test:all`.
//
-// No single `bun test` covers the repository: bunfig keeps the root suite
-// out of packages/**, and each workspace package runs its own suite from
-// its own directory. This script spawns each suite as the process its
-// package.json declares and aggregates the exit codes. Lanes are found by
-// walking packages/*/ for a `test` script, never from a list kept here.
-// test/browser (Playwright) is not a lane: it needs installed browsers and
-// runs as `bun run test:browser` / CI's browser job.
+// scripts/lanes.mjs finds the lanes, runs them and reads their output;
+// this prints the run as text: a line as each lane starts, a heartbeat
+// while lanes are in flight, each lane's output as one labeled block when
+// it finishes, then a summary table, the failing lanes' failures again,
+// and the verdict. `bun run test:tui` is the same run drawn live.
//
// scripts/preflight.mjs (tsgo resolves) runs before any lane, because a
// missing tsgo otherwise shows up inside unrelated lanes or as a green run
// with the editor surface skipped. A lane whose tool is missing SKIPS
// locally behind a visible line and FAILS the run in CI, the same teeth
-// test/support/extended.js puts on the extended tier. Lane output is
-// buffered and printed as one labeled block when the lane finishes.
+// test/support/extended.js puts on the extended tier.
//
// Flags: --root repository to orchestrate (default: this checkout)
// --jobs lanes in flight at once (default: half the cores, min 2)
// --timeout per-lane timeout (default: 600000)
// --plan print the lanes that would run, spawn nothing
-//
-// Every package lane gets RIP_LANE_WORKERS, its share of the CPU budget
-// below: a suite that fans out CPU-bound work (packages/vscode's `bun test
-// --parallel`) sizes itself by it instead of by the machine. packages/sites
-// ignores it on purpose — its sub-suites mostly wait, so its cap of 4 is a
-// latency choice.
-import { spawnSync } from 'node:child_process';
-import { readFileSync, readdirSync, statSync } from 'node:fs';
-import { availableParallelism } from 'node:os';
-import { dirname, join, resolve } from 'node:path';
-import { fileURLToPath } from 'node:url';
+import { ROOT_LANE, configure, failureBlock, guardrails, launch, planLanes, secs } from './lanes.mjs';
const argv = process.argv.slice(2);
-const flag = (name, fallback) => {
- const i = argv.indexOf(`--${name}`);
- return i === -1 || i === argv.length - 1 ? fallback : argv[i + 1];
-};
-
-// A numeric flag is refused rather than coerced, because NaN is not inert
-// here: `live.size < NaN` is false forever, so a mistyped --jobs starts no
-// lane and the run hangs with no output; and setTimeout treats a NaN delay
-// as 0, so a mistyped --timeout kills every lane the instant it spawns and
-// blames the suites. Exit 2 is this repo's usage-error code.
-const number = (name, fallback, min) => {
- const i = argv.indexOf(`--${name}`);
- if (i === -1) return fallback;
- const raw = argv[i + 1];
- const n = Number(raw);
- if (raw === undefined || raw.startsWith('--') || !Number.isFinite(n) || n < min) {
- console.error(`[rip] --${name} needs a number >= ${min}; got ${raw === undefined ? '(nothing)' : JSON.stringify(raw)}`);
- process.exit(2);
- }
- return n;
-};
-
-const HERE = dirname(dirname(fileURLToPath(import.meta.url)));
-const ROOT = resolve(flag('root', HERE));
-
-// One CPU budget for the whole run: oversubscription does not fail loudly,
-// it stretches every clock until the suites that time real machinery miss
-// deadlines they meet idle. availableParallelism respects CPU affinity, so
-// a pinned or containerised runner is sized by what it may actually use.
-const CORES = availableParallelism();
-
-// OVERSUBSCRIBE is the peak as a multiple of the cores. Above 1.0 because
-// lanes spawn subprocesses and wait on sockets rather than burn CPU flat
-// out, so a strict 1:1 budget leaves cores idle; past ~1.5 the timed
-// suites start missing deadlines. It only holds if every lane keeps to
-// its share, which is what RIP_LANE_WORKERS is for.
-const OVERSUBSCRIBE = 1.4;
-const PEAK = Math.max(3, Math.round(CORES * OVERSUBSCRIBE));
-
-// Lane slots are a packing constraint, not a CPU one: the long sibling
-// lanes have to overlap the root lane or the wall clock becomes their sum.
-const JOBS = Math.floor(number('jobs', Math.max(2, Math.floor(CORES / 2)), 1));
-
-// The peak is split between the root lane and the JOBS-1 siblings beside
-// it. One sibling fans out (vscode, sized by RIP_LANE_WORKERS) and counts
-// at LANE_WORKERS, the rest are one process each, and the root lane — the
-// CPU-bound critical path — gets the remainder, never more than the
-// machine, never fewer than two. LANE_WORKERS is four where the peak
-// affords it and shrinks before the root lane would drop below two.
-const SIBLINGS = Math.max(0, JOBS - 1);
-const PLAIN_SIBLINGS = Math.max(0, SIBLINGS - 1);
-const LANE_WORKERS = Math.max(1, Math.min(4, PEAK - 2 - PLAIN_SIBLINGS));
-const ROOT_WORKERS = Math.max(2, Math.min(CORES, PEAK - PLAIN_SIBLINGS - LANE_WORKERS));
-
-const TIMEOUT_MS = number('timeout', 600_000, 1);
-const CI = Boolean(process.env.CI);
-
-// Named package exclusions (none today — Playwright lives under test/browser).
-const EXCLUDED = new Map();
-
-// The label names its tier: the extended tier is what makes this lane
-// ~2x the work of a bare `bun run test`, so the two wall times are not
-// comparable.
-const ROOT_LANE = 'root (extended tier)';
-
-// Lanes start in this order, longest first, so a long lane picked up late
-// cannot stretch the wall clock past the root suite. A lane not listed
-// starts after every listed one, in discovery order.
-const LONGEST_FIRST = [
- ROOT_LANE,
- 'packages/sites',
- 'packages/vscode',
- 'packages/ui',
- 'packages/print',
- 'packages/email',
- 'packages/db',
- 'packages/swarm',
-];
+const config = configure(argv);
+const { root: ROOT, cores: CORES, jobs: JOBS, laneWorkers: LANE_WORKERS, rootWorkers: ROOT_WORKERS, ci: CI } = config;
// Bun's gate (TTY / NO_COLOR / FORCE_COLOR / CI). When this process will
// paint, lanes get a PTY (Bun.spawn `terminal`) so runners see isTTY and
@@ -126,292 +37,21 @@ const dim = (s) => paint('2', s);
const red = (s) => paint('31', s);
const green = (s) => paint('32', s);
const yellow = (s) => paint('33', s);
-const secs = (ms) => `${(ms / 1000).toFixed(1)}s`;
-
-// ── Tool resolution ────────────────────────────────────────────────────
-// A package lane runs `bun run test`, but what that script INVOKES may be
-// absent (no install, so no node_modules/.bin/rip). Resolving the script's
-// first token up front is what separates "this suite could not run" from
-// "this suite failed" — the distinction the CI teeth below depend on.
-const resolveTool = (tool, cwd) => {
- if (!tool) return null;
- if (tool === 'bun' || tool === 'bunx') return process.execPath;
- const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
- if (tool.includes('/')) {
- const p = resolve(cwd, tool);
- return isFile(p) ? p : null;
- }
- for (const dir of [join(cwd, 'node_modules/.bin'), join(ROOT, 'node_modules/.bin')]) {
- if (isFile(join(dir, tool))) return join(dir, tool);
- }
- for (const dir of (process.env.PATH ?? '').split(':')) {
- if (dir && isFile(join(dir, tool))) return join(dir, tool);
- }
- return null;
-};
-
-// ── Lane planning ──────────────────────────────────────────────────────
-const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
-
-const planLanes = () => {
- const lanes = [];
- const excluded = [];
- // tsgo is a Go binary and every lane starts it many times (rip check,
- // the editor server); with the default GOGC=100 half of a short session
- // is the collector. 400 is a fifth of the collections, identical
- // answers, measured 7% off the check gate — and bun ignores the variable.
- const GO_ENV = { GOGC: process.env.GOGC ?? '400' };
-
- // The full root suite: in-process + test/spawn + the extended tier
- // (see test/support/extended.js). `bun run test` is the fast edit loop
- // (in-process path list only); this lane runs all of `test/` so the
- // process lane and extended gates still certify here.
- lanes.push({
- label: ROOT_LANE,
- cwd: ROOT,
- cmd: process.execPath,
- // 60s, not 15s: the extended tier's scaling gates budget up to three
- // full measurements, and a busy lane stretches one past 5s.
- args: ['test', `--parallel=${ROOT_WORKERS}`, '--timeout', '60000'],
- env: { ...GO_ENV, RIP_EXTENDED: '1', RIP_REQUIRE_TSC: '1' },
- });
-
- const packagesDir = join(ROOT, 'packages');
- let names = [];
- try { names = readdirSync(packagesDir).sort(); } catch { names = []; }
-
- for (const name of names) {
- const cwd = join(packagesDir, name);
- const pkg = readJson(join(cwd, 'package.json'));
- const script = pkg?.scripts?.test;
- if (!script) continue; // not a suite — nothing to run, nothing to report
-
- if (EXCLUDED.has(name)) { excluded.push({ name, why: EXCLUDED.get(name) }); continue; }
-
- const tool = script.trim().split(/\s+/)[0];
- lanes.push({
- label: `packages/${name}`,
- cwd,
- cmd: process.execPath,
- args: ['run', 'test'],
- env: { ...GO_ENV, RIP_LANE_WORKERS: String(LANE_WORKERS) },
- skip: resolveTool(tool, cwd) ? undefined : `\`${tool}\` is not on PATH or in node_modules/.bin`,
- });
- }
-
- // Stable sort: unlisted lanes keep discovery order after the listed ones.
- const rank = (lane) => { const i = LONGEST_FIRST.indexOf(lane.label); return i === -1 ? LONGEST_FIRST.length : i; };
- lanes.sort((a, b) => rank(a) - rank(b));
-
- return { lanes, excluded };
-};
-
-// ── Running ────────────────────────────────────────────────────────────
-// A lane that exits 0 having run NOTHING is, to an exit code alone,
-// indistinguishable from a lane that passed — and it is reachable:
-// `bun test` over a file whose every describe is skipped prints
-// "Ran 0 tests" and exits 0, and the rip harness sets a non-zero code
-// only for a failure. Seven vscode lanes would go green that way with
-// tsgo absent (the guardrails refuse first, but that closes one
-// instance, not the class).
-//
-// All three runners end with a count, so require one and require it to
-// be positive. This is the only thing here that reads a lane's output, and
-// a lane that prints no count at all is a failure too: silence is the
-// state being ruled out, so it cannot be the state that passes.
-//
-// The sum is per COMMAND, not per lane — packages/app runs three, and
-// all three count. Two sharp edges: bun writes "Ran 1 test" singular,
-// and a one-test lane is the case sitting closest to the zero this
-// gate exists to catch; and the anchor is a real newline rather than
-// /m, whose ^ also matches after a bare carriage return, where a
-// redrawn progress line would count as a run.
-const TEST_COUNTS = [
- /(?:^|\n)Ran (\d+) tests?\b/g, // bun test
- /(?:^|\n)(\d+) tests?:/g, // rip test (rip/testing)
- /(?:^|\n) *(\d+) passed \(/g, // playwright test (packages/ui)
-];
-// Strip CSI / OSC so painted tallies still match the count regexes.
-const stripAnsi = (s) => s.replace(/\x1b(?:\[[0-9;?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\))/g, '');
-// PTYs speak CRLF / bare CR (progress redraws); normalize before matching
-// and before reprinting so blocks read like a captured pipe.
-const normalizeOut = (chunks) =>
- Buffer.concat(chunks).toString('utf8').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
-const testsReported = (output) => {
- const plain = stripAnsi(output);
- let total = null;
- for (const re of TEST_COUNTS) {
- for (const m of plain.matchAll(re)) total = (total ?? 0) + Number(m[1]);
- }
- return total; // null → the lane never printed a count
-};
-
-// Every lane process in flight, so an interrupted run can take them
-// down. Left alone, Ctrl-C kills only this process: the lanes run in
-// their own sessions (the PTY below), keep going without a reader, and
-// a suite that dies of the closed PTY mid-flight strands whatever it
-// had spawned detached — a Playwright web server on :4180, say, which
-// the NEXT run then trips over.
-const live = new Set();
-
-const runLane = async (lane) => {
- const started = Date.now();
- const chunks = [];
- const finish = (extra) => ({
- lane,
- ms: Date.now() - started,
- output: normalizeOut(chunks),
- ...extra,
- });
-
- const env = { ...process.env, ...lane.env };
- // Parent may have FORCE_COLOR (piped `test:all | less -R`). Strip it
- // so lane children that pin stdout stay byte-stable; the PTY above is
- // what paints the lane reporters.
- delete env.FORCE_COLOR;
- let proc;
- try {
- if (usePty) {
- proc = Bun.spawn([lane.cmd, ...lane.args], {
- cwd: lane.cwd,
- env,
- terminal: {
- cols: process.stdout.columns || 120,
- rows: process.stdout.rows || 40,
- data(_term, data) { chunks.push(Buffer.from(data)); },
- },
- });
- } else {
- proc = Bun.spawn([lane.cmd, ...lane.args], {
- cwd: lane.cwd,
- env,
- stdin: 'ignore',
- stdout: 'pipe',
- stderr: 'pipe',
- });
- }
- } catch (e) {
- return finish({ status: 'fail', why: `could not spawn: ${e?.message ?? e}` });
- }
- live.add(proc);
- proc.exited.then(() => live.delete(proc));
-
- // A lane past its deadline is told to stop, then killed. The exit wait
- // below is bounded too: a lane that has been killed is finished whether
- // or not its exit is ever observed (a PTY lane's can go unreported).
- let timedOut = false;
- let killed;
- const gaveUp = new Promise((resolve) => { killed = resolve; });
- const timer = setTimeout(() => {
- timedOut = true;
- try { proc.kill('SIGTERM'); } catch { /* already dead */ }
- setTimeout(() => {
- try { proc.kill('SIGKILL'); } catch { /* already dead */ }
- try { proc.terminal?.close(); } catch { /* closed */ }
- setTimeout(() => killed(null), 5000).unref();
- }, 5000).unref();
- }, TIMEOUT_MS);
-
- if (!usePty) {
- const pull = async (stream) => {
- if (!stream) return;
- for await (const chunk of stream) chunks.push(Buffer.from(chunk));
- };
- await Promise.all([pull(proc.stdout), pull(proc.stderr)]);
- }
-
- const code = await Promise.race([proc.exited, gaveUp]);
- clearTimeout(timer);
- try { proc.terminal?.close(); } catch { /* closed */ }
-
- if (timedOut) return finish({ status: 'fail', why: `timed out after ${secs(TIMEOUT_MS)}` });
- if (code !== 0) return finish({ status: 'fail', why: `exit ${code}` });
- const ran = testsReported(normalizeOut(chunks));
- if (ran === null) return finish({ status: 'fail', why: 'exited 0 without reporting a test count' });
- if (ran === 0) return finish({ status: 'fail', why: 'exited 0 having run no tests' });
- return finish({ status: 'pass', ran });
-};
// Interrupted (Ctrl-C, a supervisor's SIGTERM): tell every lane in
// flight, give it a moment to tear down what it spawned, then leave
// with the conventional status. A lane that will not stop is killed.
+let run = null;
const interrupt = (signal) => {
- for (const proc of live) { try { proc.kill('SIGTERM'); } catch { /* gone */ } }
const status = signal === 'SIGINT' ? 130 : 143;
- const deadline = setTimeout(() => {
- for (const proc of live) { try { proc.kill('SIGKILL'); } catch { /* gone */ } }
- process.exit(status);
- }, 3000);
- deadline.unref?.();
- Promise.all([...live].map((proc) => proc.exited)).then(() => process.exit(status));
+ (run ? run.abort('SIGTERM', 3000) : Promise.resolve()).then(() => process.exit(status));
};
process.on('SIGINT', () => interrupt('SIGINT'));
process.on('SIGTERM', () => interrupt('SIGTERM'));
-const runAll = async (lanes) => {
- const queue = lanes.filter((l) => !l.skip);
- const results = [];
- const live = new Map();
-
- const heartbeat = setInterval(() => {
- if (live.size === 0) return;
- const now = Date.now();
- const running = [...live.entries()].map(([label, at]) => `${label} ${secs(now - at)}`).join(', ');
- console.log(dim(` … ${live.size} running: ${running}`));
- }, 5_000);
- heartbeat.unref?.();
+guardrails(config);
- let next = 0;
- await new Promise((allDone) => {
- if (queue.length === 0) return allDone();
- const pump = () => {
- while (live.size < JOBS && next < queue.length) {
- const lane = queue[next++];
- live.set(lane.label, Date.now());
- console.log(dim(`▸ ${lane.label}`));
- runLane(lane).then((result) => {
- live.delete(lane.label);
- results.push(result);
- report(result);
- if (results.length === queue.length) allDone();
- else pump();
- });
- }
- };
- pump();
- });
-
- clearInterval(heartbeat);
- return results;
-};
-
-const report = ({ lane, status, ms, why, output }) => {
- const mark = status === 'pass' ? green('✓') : red('✗');
- const head = `${mark} ${lane.label} ${dim(secs(ms))}${why ? red(` — ${why}`) : ''}`;
- console.log(`\n${dim('─'.repeat(72))}\n${head}\n${dim('─'.repeat(72))}`);
- if (output.trim()) console.log(output.trimEnd());
-};
-
-// ── Guardrails ─────────────────────────────────────────────────────────
-// Must hold before any lane starts, and fails confusingly INSIDE
-// unrelated lanes when it does not: a missing tsgo makes the vscode
-// suite skip its whole LSP surface and pass. The root lane's extended
-// tier needs tsgo too and has no preflight of its own, so the
-// guarantee lives here — every entry point reaches it, not just
-// `bun run test:all`.
-const guardrails = () => {
- for (const [script, args] of [['preflight.mjs', []]]) {
- const r = spawnSync(process.execPath, [join(HERE, 'scripts', script), ...args], { stdio: 'inherit' });
- if (r.status !== 0) process.exit(r.status ?? 1);
- }
-};
-
-// ── Main ───────────────────────────────────────────────────────────────
-// A foreign --root is a fixture repository, which has neither to check.
-if (ROOT === HERE && !argv.includes('--plan')) guardrails();
-
-const { lanes, excluded } = planLanes();
-const skipped = lanes.filter((l) => l.skip);
+const { lanes, excluded, skipped } = planLanes(config);
// "repo", not "root" — `root` names a lane, and the two would read as
// the same thing on adjacent lines.
@@ -423,7 +63,7 @@ for (const lane of skipped) {
// A discovery walk that silently matches nothing reads as a fast, green
// run; printing the plan without spawning is what makes it assertable.
-if (argv.includes('--plan')) {
+if (config.plan) {
for (const lane of lanes.filter((l) => !l.skip)) console.log(`▸ ${lane.label}`);
// The budget as it reaches the lanes, so a plan is assertable on the
// arguments as well as the list.
@@ -433,11 +73,35 @@ if (argv.includes('--plan')) {
process.exit(0);
}
-const startedAt = Date.now();
-const results = await runAll(lanes);
-const wall = Date.now() - startedAt;
+const report = ({ lane, status, ms, why, output }) => {
+ const mark = status === 'pass' ? green('✓') : red('✗');
+ const head = `${mark} ${lane.label} ${dim(secs(ms))}${why ? red(` — ${why}`) : ''}`;
+ console.log(`\n${dim('─'.repeat(72))}\n${head}\n${dim('─'.repeat(72))}`);
+ if (output.trim()) console.log(output.trimEnd());
+};
-const failed = results.filter((r) => r.status !== 'pass');
+// The lanes in flight, by label, with when each started: the heartbeat
+// names them every five seconds.
+const running = new Map();
+const heartbeat = setInterval(() => {
+ if (running.size === 0) return;
+ const now = Date.now();
+ const names = [...running.entries()].map(([label, at]) => `${label} ${secs(now - at)}`).join(', ');
+ console.log(dim(` … ${running.size} running: ${names}`));
+}, 5_000);
+heartbeat.unref?.();
+
+run = launch(config, { lanes, excluded, skipped }, { usePty, cols: process.stdout.columns || 120, rows: process.stdout.rows || 40 }, (event) => {
+ if (event.type === 'start') {
+ running.set(event.lane.label, Date.now());
+ console.log(dim(`▸ ${event.lane.label}`));
+ } else if (event.type === 'finish' && event.result.status !== 'skip') {
+ running.delete(event.result.lane.label);
+ report(event.result);
+ }
+});
+const { results, failed, wall, ranTotal, code } = await run.done;
+clearInterval(heartbeat);
// One column layout for every row — run, skipped, excluded — so the
// times, counts and trailing notes line up down the whole table. Pad
@@ -452,8 +116,8 @@ for (const r of [...results].sort((a, b) => b.ms - a.ms)) {
console.log(` ${mark} ${cols(r.lane.label, secs(r.ms), tests, dim)}${r.why ? red(` ${r.why}`) : ''}`);
}
for (const lane of skipped) {
- const paint = CI ? red : yellow;
- console.log(` ${paint('⊘')} ${cols(lane.label)} ${paint(lane.skip)}`);
+ const tint = CI ? red : yellow;
+ console.log(` ${tint('⊘')} ${cols(lane.label)} ${tint(lane.skip)}`);
}
for (const { name } of excluded) {
console.log(` ${dim('·')} ${dim(cols(`packages/${name}`))} ${dim('excluded (CI runs it as its own job)')}`);
@@ -463,47 +127,22 @@ for (const { name } of excluded) {
// CI is the middle of a very long log — and GitHub drops the MIDDLE of a
// log it has to truncate, keeping the head and the tail. The root lane
// alone prints ~6000 lines, so the one thing worth reading (the name of
-// the test that failed) is exactly what goes missing. Repeat the tail of
-// each failing lane after the summary, where truncation cannot reach it.
-// A runner prints a failure's DETAIL where the test ran and only its
-// NAME in the closing summary, so the tail alone carries the name and
-// loses the assertion message — which for a measurement gate is the
-// whole point. Lift the lines leading up to each failure marker too:
-// bun's `(fail)` line, and the rip harness's `✗ name: message` line,
-// which a package lane of many sub-suites prints far above its tail.
-const FAILURE_MARK = /^\(fail\)|^ *✗ /;
-const failureDetail = (output, lead = 30) => {
- const lines = output.split('\n');
- const out = [];
- lines.forEach((line, i) => {
- if (!FAILURE_MARK.test(line) || out.length > 400) return;
- out.push(...lines.slice(Math.max(0, i - lead), i + 1), '');
- });
- return out.join('\n').trimEnd();
-};
-
+// the test that failed) is exactly what goes missing. Repeat each
+// failing lane's failures and tail after the summary, where truncation
+// cannot reach it.
if (failed.length > 0) {
- for (const r of failed) {
- const detail = failureDetail(r.output);
- const tail = r.output.split('\n').slice(-60).join('\n').trimEnd();
- console.log(`\n${dim('─'.repeat(72))}\n${red(`✗ ${r.lane.label}`)} ${dim('— failures, then last 60 lines')}\n${dim('─'.repeat(72))}`);
- if (detail) console.log(`${detail}\n`);
- if (tail) console.log(tail);
- }
+ for (const r of failed) console.log(failureBlock(r, { dim, red }));
console.log(red(`\n✗ ${failed.length} of ${results.length} lanes failed: ${failed.map((r) => r.lane.label).join(', ')}\n`));
- process.exit(1);
+ process.exit(code);
}
-// The teeth: locally a missing tool is a visible skip, in CI it is a
-// failure. A CI run that stops covering a suite must never go green.
-if (CI && skipped.length > 0) {
+if (code !== 0) {
console.log(red(
`\n✗ ${skipped.length} lane(s) skipped in CI: ${skipped.map((l) => l.label).join(', ')}\n` +
' CI must run every lane; a skipped suite cannot pass. Fix the tool this lane needs\n' +
' (a full `bun install` provisions every workspace member) or remove the suite.\n',
));
- process.exit(1);
+ process.exit(code);
}
-const ranTotal = results.reduce((sum, r) => sum + (r.ran ?? 0), 0);
console.log(green(`\n✓ ${results.length} lanes, ${ranTotal} tests passed in ${secs(wall)}\n`));
diff --git a/scripts/test-live.rip b/scripts/test-live.rip
new file mode 100644
index 00000000..e6436a57
--- /dev/null
+++ b/scripts/test-live.rip
@@ -0,0 +1,540 @@
+# scripts/test-live.rip — `bun run test:tui`: the lanes of `bun run test:all`
+# drawn live on packages/tui.
+#
+# The same lanes, the same scheduler and the same verdict as the plain
+# runner (scripts/lanes.mjs); only the drawing differs. At the top, the
+# run's header and its clock, one cell per lane in the order they start,
+# and one bar for the whole run with its ETA. Below, a row per lane in
+# flight: a spinner, a bar that fills against the lane's last duration
+# (or its last test count, for a lane that prints a line per test), and
+# the time. A finished lane scrolls into the scrollback as one aligned
+# row, so after the run the scrollback reads as a report; a failing lane
+# puts a card with its first failure in the live area. At the end the
+# failing lanes' output is printed above the frame exactly as the plain
+# runner prints it, an end card follows, and the process exits with the
+# plain runner's code. Each complete run leaves the lanes' durations and
+# counts in .rip/test-last.json, which the next run's bars and ETA read.
+#
+# Keys: q quits (every lane and its process group is killed), f shows the
+# failing lanes in place of the lanes in flight, ↑ ↓ choose a failing
+# lane, Enter opens its captured output below the frame (↑ ↓ PgUp PgDn
+# Home End move through it, Enter or Escape closes it).
+#
+# Off a terminal — a pipe, or CI set — and for --plan, this is the plain
+# runner: scripts/test-all.mjs runs in this process, as it would alone.
+#
+# bun run test:tui [--jobs n] [--timeout ms] [--root dir]
+
+import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
+import { join, relative } from 'node:path'
+import { run, quit, screen, print, clock, Box, Text, Static } from '../packages/tui/tui.rip'
+import { configure, guardrails, planLanes, launch, failureBlock, stripAnsi, FAILURE_MARK, secs } from './lanes.mjs'
+
+# ── Drawing ───────────────────────────────────────────────────────────────────
+
+# Spinners from several braille sets, each lane on its own set and phase,
+# so neighbors never pulse in lockstep.
+SPINS =! ['⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏', '⣾⣽⣻⢿⡿⣟⣯⣷', '⠁⠂⠄⡀⢀⠠⠐⠈', '⢄⢂⢁⡁⡈⡐⡠', '⠈⠐⠠⢀⡀⠄⠂⠁']
+EIGHTHS =! ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
+TICK =! 100 # milliseconds between frames: the run paints at most once a tick
+
+# Colors as true color, and as the sixteen where the terminal has no more.
+HUES =! { cyan: '#22d3ee', green: '#4ade80', amber: '#fbbf24', red: '#f87171', slate: '#64748b', yellow: '#facc15' }
+NAMED =! { cyan: 'cyan', green: 'green', amber: 'yellow', red: 'red', slate: 'gray', yellow: 'yellow' }
+
+hue =! (name) -> if screen.colors >= 256 then HUES[name] else NAMED[name]
+
+# A true color `t` of the way from one to the other.
+blend =! (from, to, t) ->
+ mix = (k) -> Math.round(parseInt(from.slice(k, k + 2), 16) * (1 - t) + parseInt(to.slice(k, k + 2), 16) * t)
+ '#' + ((mix(k).toString(16).padStart 2, '0') for k in [1, 3, 5]).join ''
+
+# A lane's bar: cyan toward green as it fills, amber once it runs past
+# its last duration, red past twice that.
+fillHue =! (fill, over) ->
+ return hue('red') if over > 2
+ return hue('amber') if over > 1
+ return (if fill < 0.5 then 'cyan' else 'green') if screen.colors < 256
+ blend HUES.cyan, HUES.green, Math.min(1, fill)
+
+# `fill` of `width` cells in eighth blocks: the filled glyphs and the
+# track after them, which together are `width` cells.
+export blocks =! (fill, width) ->
+ eighths = Math.round(Math.max(0, Math.min(1, fill)) * width * 8)
+ whole = Math.floor eighths / 8
+ part = EIGHTHS[eighths % 8]
+ { done: '█'.repeat(whole) + part, rest: '─'.repeat(width - whole - (if part then 1 else 0)) }
+
+# A block of three that sweeps the track, for a bar with nothing to fill it by.
+sweep =! (frame, width) ->
+ span = Math.max 1, width - 3
+ at = frame % (span * 2)
+ at = span * 2 - at if at > span
+ ' '.repeat(at) + '███' + ' '.repeat(Math.max 0, width - at - 3)
+
+spinOf =! (index, frame) ->
+ set = SPINS[index % SPINS.length]
+ set[(frame + index * 3) % set.length]
+
+commas =! (n) -> n.toLocaleString 'en-US'
+clockOf =! (ms) ->
+ whole = Math.max 0, Math.floor ms / 1000
+ "#{Math.floor whole / 60}:#{String(whole % 60).padStart 2, '0'}"
+about =! (ms) -> if ms >= 10000 then "#{Math.round ms / 1000}s" else secs(ms)
+plural =! (n, word) -> "#{commas n} #{word}#{if n is 1 then '' else 's'}"
+
+seg =! (text, style = {}) -> { text, style }
+
+# A lane's cell in the strip: waiting, in flight, passed, skipped, failed.
+cellSeg =! (cell) ->
+ switch cell.status
+ when 'waiting' then seg('·', { dimColor: true })
+ when 'running' then seg(cell.spin, { color: hue('cyan') })
+ when 'pass' then seg('●', { color: hue('green') })
+ when 'skip' then seg('⊘', { color: hue('yellow') })
+ else seg('✗', { color: hue('red'), bold: true })
+
+# ── A lane's failure ──────────────────────────────────────────────────────────
+
+# The first failure in a lane's output: the test's name, the lines that
+# say what went wrong, and where, when the runner says. bun prints the
+# error, its expected and received values and the frame's place above
+# the failing test's `(fail)` line (`✗` under a PTY); the rip harness
+# prints `✗ name: message` on one line. A lane that failed with no
+# failure line — a crash, a timeout — gives its last lines.
+PLACE =! /\(?((?:\/|[A-Za-z]:\\)[^():]+):(\d+):(\d+)\)?\s*$/
+
+export firstFailure =! (output) ->
+ lines = stripAnsi(output).split('\n').map (text) -> text.replace(/\t/g, ' ').trimEnd()
+ at = lines.findIndex (text) -> FAILURE_MARK.test text
+ if at < 0
+ tail = lines.filter((text) -> text.trim()).slice(-3)
+ return { name: null, detail: tail, place: null, at: Math.max(0, lines.length - 1), lines }
+ head = lines[at].replace(/^\(fail\)\s*|^\s*✗\s*/, '').replace(/\s*\[[\d.]+m?s\]$/, '')
+ from = at
+ from -= 1 while from > 0 and at - from < 30 and not FAILURE_MARK.test(lines[from - 1]) and not /^\s*✓ /.test(lines[from - 1])
+ window = lines.slice from, at
+ error = window.findLastIndex (text) -> /^\s*error\b/.test text
+ if error < 0
+ split = head.indexOf ': '
+ return { name: head, detail: [], place: null, at, lines } if split < 0
+ return { name: head.slice(0, split), detail: [head.slice(split + 2)], place: null, at, lines }
+ after = window.slice error
+ detail = after.filter((text) -> text.trim() and not /^\s*at /.test(text)).slice(0, 6).map (text) -> text.trim()
+ found = null
+ for text in after when not found and /^\s*at /.test(text)
+ found = PLACE.exec text
+ place = if found then { path: found[1], line: Number(found[2]) } else null
+ { name: head, detail, place, at, lines }
+
+# ── The run as it stands ──────────────────────────────────────────────────────
+
+# What the lanes' events have said so far, and what the last complete
+# run said: a model a frame reads, never a reactive one. Events land here
+# as they come and the board reads it once a tick, so a lane that writes
+# a thousand chunks a second costs no frame. `now` is the clock the
+# lanes' starts are read on.
+export class Tally
+ constructor: (planned, config, memory = null, now = Date.now) ->
+ @config = config
+ @memory = memory ?? { wall: null, lanes: {} }
+ @now = now
+ @entries = planned.lanes.map (lane, index) -> { lane, index, status: 'waiting', at: null, result: null, seen: 0, arrivals: 0, carry: '', decoder: TextDecoder.new(), card: null }
+ @byLabel = Map.new @entries.map((entry) -> [entry.lane.label, entry])
+ @finished = [] # the scrollback's rows, in the order the lanes finished
+ @began = null
+ @summary = null
+ @shown = false # whether a frame has shown the summary yet
+ @closed = false
+ known = (record.ms for own label, record of @memory.lanes when record?.ms > 0)
+ known.sort (x, y) -> x - y
+ @median = if known.length then known[Math.floor known.length / 2] else null
+ @slowest = if known.length then known[known.length - 1] else 0
+
+ hear!: (event) ->
+ switch event.type
+ when 'plan' then @began = @now()
+ when 'start'
+ entry = @byLabel.get event.lane.label
+ entry.status = 'running'
+ entry.at = @now()
+ when 'chunk' then @count @byLabel.get(event.lane.label), event.data
+ when 'finish' then @settle event.result
+ when 'end' then @summary = event.summary
+
+ # The lines a lane prints one per test — the rip harness's `✓` and `✗`,
+ # bun's `(pass)` and `(fail)` — counted as they stream, a line at a
+ # time, with when each arrived.
+ count!: (entry, data) ->
+ text = entry.carry + entry.decoder.decode(data, { stream: true })
+ parts = text.split /\r\n|\r|\n/
+ entry.carry = parts.pop()
+ for part in parts when /^\s*(?:✓|✗|\(pass\)|\(fail\)) /.test stripAnsi(part)
+ entry.seen += 1
+ entry.arrivals += @now() - entry.at
+ return
+
+ settle!: (result) ->
+ entry = @byLabel.get result.lane.label
+ entry.status = result.status
+ entry.result = result
+ entry.card = firstFailure result.output if result.status is 'fail' or result.status is 'timeout'
+ @slowest = Math.max @slowest, result.ms
+ @finished.push { result, slowest: @slowest, last: @memory.lanes[result.lane.label] ?? null }
+
+ # A lane's last duration, or the middle of the known ones for a lane
+ # with none; null on a first run.
+ estimate: (label) -> @memory.lanes[label]?.ms ?? @median
+
+ # A lane counts by tests when its last run printed a line for every
+ # test it ran, and printed them as it went: a lane whose lines arrive
+ # in bursts late in its run (sub-suites that report when they end)
+ # fills by time.
+ counted: (label) ->
+ record = @memory.lanes[label]
+ record?.ran > 0 and record.streamed is record.ran and record.spread < 0.7
+
+ get failures: -> (entry for entry in @entries when entry.status is 'fail' or entry.status is 'timeout')
+
+ # The run's share done: each lane weighed by its last duration, a
+ # finished lane whole and a lane in flight by its time so far, up to
+ # its weight — null on a first run.
+ share: (now) ->
+ return 1 if @summary
+ total = 0
+ done = 0
+ for entry in @entries when not entry.lane.skip
+ weight = @estimate entry.lane.label
+ return null unless weight
+ total += weight
+ done += weight if entry.result
+ done += Math.min(now - entry.at, weight) if entry.status is 'running'
+ if total then done / total else null
+
+ # Everything a frame shows, as of now; `frame` turns the spinners. The
+ # summary shows a tick after it arrives, so the rows of the lanes that
+ # finished with it are in the tree before the run's end prints above
+ # them.
+ snapshot: (frame) ->
+ now = @now()
+ over = @summary? and @shown
+ @shown = @summary?
+ elapsed = if @began? then now - @began else 0
+ tally = { passed: 0, failed: 0, skipped: 0, tests: 0, finished: 0 }
+ for entry in @entries
+ switch entry.status
+ when 'pass'
+ tally.passed += 1
+ tally.tests += entry.result.ran ?? 0
+ when 'fail', 'timeout' then tally.failed += 1
+ when 'skip' then tally.skipped += 1
+ tally.finished = tally.passed + tally.failed
+ tally.total = @entries.filter((entry) -> not entry.lane.skip).length
+ share = @share now
+ running = for entry in @entries when entry.status is 'running'
+ spent = now - entry.at
+ weight = @estimate entry.lane.label
+ counts = @counted entry.lane.label
+ record = @memory.lanes[entry.lane.label]
+ {
+ label: entry.lane.label
+ spin: spinOf entry.index, frame
+ spent
+ over: if weight then spent / weight else 0
+ fill: if counts then entry.seen / record.ran else if weight then spent / weight else null
+ info: if counts then "#{commas entry.seen} / #{commas record.ran} tests" else if weight then "#{secs spent} / ~#{about weight}" else secs(spent)
+ }
+ {
+ frame
+ elapsed
+ share
+ eta: if share? and share >= 0.02 and share < 1 and elapsed > 2000 then elapsed * (1 - share) / share else null
+ cells: ({ status: entry.status, spin: spinOf(entry.index, frame) } for entry in @entries)
+ running
+ tally
+ failures: @failures
+ finished: @finished
+ summary: @summary
+ over
+ }
+
+# ── The memory of the last run ────────────────────────────────────────────────
+
+memoryPath =! (root) -> join root, '.rip', 'test-last.json'
+
+export recall =! (root) ->
+ try JSON.parse(readFileSync(memoryPath(root), 'utf8')) catch then null
+
+# The durations and counts a complete run leaves for the next: every lane
+# that passed, with how many lines it printed one per test and when they
+# arrived on average, as a share of its time; a lane that failed or was
+# skipped keeps what the run before said of it.
+export remember! =! (root, tally) ->
+ lanes = { ...tally.memory.lanes }
+ for entry in tally.entries when entry.status is 'pass'
+ spread = if entry.seen then Math.round(entry.arrivals / entry.seen / Math.max(1, entry.result.ms) * 100) / 100 else null
+ lanes[entry.lane.label] = { ms: entry.result.ms, ran: entry.result.ran, streamed: entry.seen, spread }
+ mkdirSync join(root, '.rip'), { recursive: true }
+ writeFileSync memoryPath(root), JSON.stringify({ wall: tally.summary.wall, lanes }, null, 2) + '\n'
+
+# ── The plain runner's paint, for what is printed as it prints it ─────────────
+
+painting =! Bun.enableANSIColors
+tint =! (code, text) -> if painting then "\x1b[#{code}m#{text}\x1b[0m" else text
+export PAINT =! { dim: ((text) -> tint 2, text), red: ((text) -> tint 31, text) }
+
+# ── The board ─────────────────────────────────────────────────────────────────
+
+# One row of styled runs of text: runs nested in one text, which a
+# terminal too narrow for the row cuts short with `…` instead of wrapping.
+Line = component
+ @segs := []
+ render
+ Text wrap: 'truncate'
+ for piece, n in @segs
+ Text key: n, style: piece.style, "#{piece.text}"
+
+# The rows a finished lane leaves in the scrollback: mark, name, count
+# and time in their columns, a bar of its time against the slowest lane
+# yet, and how far it moved from its last run.
+export laneRow =! (row, name, wide) ->
+ { result, slowest, last } = row
+ label = result.lane.label.padEnd name
+ switch result.status
+ when 'skip'
+ return [seg("⊘ #{label}", { color: hue('yellow') }), seg(" skipped: #{result.why}", { color: hue('yellow') })]
+ when 'fail', 'timeout'
+ return [seg("✗ #{label}", { color: hue('red'), bold: true }), seg("#{' '.repeat 12}#{secs(result.ms).padStart 8}", { dimColor: true }), seg(" #{result.why}", { color: hue('red') })]
+ out = [seg('✓', { color: hue('green') }), seg(" #{label}"), seg(plural(result.ran, 'test').padStart(12)), seg(secs(result.ms).padStart(8), { dimColor: true })]
+ return out unless wide
+ fill = blocks (if slowest then result.ms / slowest else 0), 10
+ out.push seg(" #{fill.done}#{' '.repeat fill.rest.length}", { color: hue('slate') })
+ if last?.ms
+ change = result.ms - last.ms
+ text = "#{if change < 0 then '−' else '+'}#{secs Math.abs(change)}"
+ style = if change < 0 then { color: hue('green') } else if change > last.ms * 0.2 then { color: hue('amber') } else { dimColor: true }
+ out.push seg(" #{text}", style)
+ out
+
+# The end card's rows: the lanes, the tests, the wall against the last
+# run's and against the lanes' own time, the three slowest lanes, and the
+# verdict — the plain runner's words when the run fails.
+export endRows =! (tally, summary) ->
+ { results, skipped, failed, wall, ranTotal } = summary
+ passed = results.length - failed.length
+ laneTime = results.reduce ((sum, result) -> sum + result.ms), 0
+ last = tally.memory.wall
+ rows = []
+ rows.push([seg('lanes ', { dimColor: true }), seg("#{passed} passed", { color: hue('green') }), seg(' · ', { dimColor: true }), seg("#{failed.length} failed", (if failed.length then { color: hue('red') } else { dimColor: true })), seg(' · ', { dimColor: true }), seg("#{skipped.length} skipped", (if skipped.length then { color: hue('yellow') } else { dimColor: true }))])
+ rows.push([seg('tests ', { dimColor: true }), seg(commas ranTotal)])
+ wallRow = [seg('wall ', { dimColor: true }), seg(secs wall)]
+ if last
+ change = wall - last
+ wallRow.push seg(" last #{secs last}, ", { dimColor: true })
+ wallRow.push seg("#{if change < 0 then '−' else '+'}#{secs Math.abs(change)}", (if change < 0 then { color: hue('green') } else if change > last * 0.2 then { color: hue('amber') } else { dimColor: true }))
+ rows.push wallRow
+ rows.push([seg('lanes ', { dimColor: true }), seg(secs laneTime), seg(' of lane time · ', { dimColor: true }), seg("#{(laneTime / Math.max(1, wall)).toFixed 1}× parallel", { color: hue('cyan') })])
+ slow = [...results].sort((x, y) -> y.ms - x.ms).slice(0, 3)
+ top = slow[0]?.ms or 1
+ width = Math.max ...slow.map((result) -> result.lane.label.length), 0
+ for result, n in slow
+ fill = blocks result.ms / top, 12
+ rows.push([seg((if n is 0 then 'slowest ' else ' '), { dimColor: true }), seg(result.lane.label.padEnd(width + 2)), seg(secs(result.ms).padStart(6), { dimColor: true }), seg(" #{fill.done}", { color: hue('slate') })])
+ rows.push([seg(' ')])
+ if failed.length
+ rows.push([seg("✗ #{failed.length} of #{results.length} lanes failed: #{failed.map((result) -> result.lane.label).join ', '}", { color: hue('red'), bold: true })])
+ else if tally.config.ci and skipped.length
+ rows.push([seg("✗ #{skipped.length} lane(s) skipped in CI: #{skipped.map((lane) -> lane.label).join ', '}", { color: hue('red'), bold: true })])
+ else
+ extra = if skipped.length then " · #{skipped.length} skipped" else ''
+ rows.push([seg("✓ All #{results.length} lanes green#{extra} · #{plural ranTotal, 'test'} · #{secs wall}", { color: hue('green'), bold: true })])
+ rows
+
+# The failure card's rows for a failing lane: which lane and why, the
+# test, what it said, and where, as a link a terminal can open.
+export cardRows =! (entry, root, order, count) ->
+ card = entry.card
+ rows = [[seg("✗ #{entry.lane.label}", { color: hue('red'), bold: true }), seg(" #{entry.result.why}", { color: hue('red') }), seg((if count > 1 then " #{order + 1}/#{count}" else ''), { dimColor: true })]]
+ rows.push([seg(card.name, { bold: true })]) if card.name
+ rows.push([seg(text)]) for text in card.detail
+ if card.place
+ shown = relative root, card.place.path
+ shown = card.place.path if shown.startsWith '..'
+ rows.push([seg("#{shown}:#{card.place.line}", { dimColor: true, underline: true, link: "file://#{encodeURI card.place.path}" })])
+ rows
+
+export Board = component
+ @tally := null
+ tick = clock TICK
+ pick := 0 # the failing lane chosen, by its place among the failures
+ open := false # whether its captured output is shown below the frame
+ only := false # the failing lanes shown in place of the lanes in flight
+ top := 0 # the first line of that output shown
+ finale := false # the end card is written above the frame
+
+ snap ~= @tally.snapshot tick.frame
+ wide ~= screen.cols >= 70
+ name ~= Math.max 10, ...@tally.entries.map((entry) -> entry.lane.label.length)
+ chosen ~= snap.failures[Math.min(pick, snap.failures.length - 1)] ?? null
+
+ heading ~=
+ config = @tally.config
+ blurb = if wide then "#{snap.tally.total} lanes · #{config.jobs} at a time · #{config.cores} cores" else "#{snap.tally.total} lanes"
+ left = [seg(' rip ', { bold: true, color: hue('cyan') }), seg('test:all ', { bold: true }), seg(blurb, { dimColor: true })]
+ used = left.reduce ((sum, piece) -> sum + piece.text.length), 0
+ stamp = clockOf snap.elapsed
+ [...left, seg(' '.repeat(Math.max 1, screen.cols - used - stamp.length - 1)), seg(stamp, { bold: true })]
+
+ strip ~= [seg(' '), ...snap.cells.map(cellSeg)]
+
+ overall ~=
+ share = snap.share
+ eta = if snap.eta? then " ETA #{clockOf snap.eta}" else ''
+ info = if share? then "#{String(Math.floor share * 100).padStart 3}%#{eta}" else "#{snap.tally.finished}/#{snap.tally.total}"
+ width = Math.max 10, screen.cols - info.length - 4
+ tone = if snap.tally.failed then hue('red') else if share? then fillHue(share, 0) else hue('cyan')
+ if share?
+ fill = blocks share, width
+ [seg(' '), seg(fill.done, { color: tone }), seg(fill.rest, { dimColor: true }), seg(" #{info}")]
+ else
+ [seg(' '), seg(sweep(snap.frame, width), { color: tone }), seg(" #{info}")]
+
+ flight ~=
+ for lane in snap.running
+ label = lane.label.padEnd name
+ unless wide
+ [seg(" #{lane.spin} ", { color: hue('cyan') }), seg(label), seg(" #{secs lane.spent}", { dimColor: true })]
+ else
+ width = Math.max 10, Math.min(40, screen.cols - name - 28)
+ tone = fillHue lane.fill ?? 0, lane.over
+ bar = if lane.fill? then blocks(lane.fill, width) else { done: sweep(snap.frame, width), rest: '' }
+ [seg(" #{lane.spin} ", { color: hue('cyan') }), seg(label), seg(' '), seg(bar.done, { color: tone }), seg(bar.rest, { dimColor: true }), seg(" #{lane.info}", { dimColor: true })]
+
+ failing ~=
+ return [] if snap.summary
+ return [[seg(' no lane has failed', { dimColor: true })]] unless snap.failures.length
+ for entry, n in snap.failures
+ mark = if n is pick then '›' else ' '
+ [seg(" #{mark} ", { color: hue('red'), bold: true }), seg("✗ #{entry.lane.label.padEnd name}", { color: hue('red'), bold: n is pick }), seg(" #{entry.result.why}", { dimColor: true })]
+
+ cardLines ~= if chosen and not snap.summary then cardRows(chosen, @tally.config.root, Math.min(pick, snap.failures.length - 1), snap.failures.length) else []
+
+ totals ~=
+ t = snap.tally
+ runs = [seg(' '), seg("#{t.passed} passed", { color: hue('green') }), seg(' · ', { dimColor: true }), seg("#{t.failed} failed", (if t.failed then { color: hue('red'), bold: true } else { dimColor: true })), seg(' · ', { dimColor: true }), seg("#{t.skipped} skipped", (if t.skipped then { color: hue('yellow') } else { dimColor: true })), seg(' · ', { dimColor: true }), seg(plural(t.tests, 'test'))]
+ keys = if open then '↑↓ PgUp PgDn scroll · ⏎ close' else if snap.failures.length then 'q quit · f failures · ↑↓ choose · ⏎ output' else 'q quit · f failures'
+ used = runs.reduce ((sum, piece) -> sum + piece.text.length), 0
+ runs.push seg(' '.repeat(Math.max 2, screen.cols - used - keys.length - 1)), seg(keys, { dimColor: true }) if wide and not snap.summary
+ runs
+
+ # The output pane takes the rows the rest of the frame leaves — the
+ # header, the strip, the bar and a blank row above the lanes, the card,
+ # the footer — less its border and its title, so the frame fills the
+ # terminal and its header stays on screen.
+ tall ~=
+ above = (if screen.cols >= 40 then 4 else 3) + (if only then failing.length else flight.length) + (if cardLines.length then cardLines.length + 2 else 0)
+ Math.max 4, screen.rows - above - 1 - 3
+ captured ~= chosen?.card.lines ?? []
+ visible ~= if open then captured.slice(top, top + tall) else []
+ paneTitle ~= " #{chosen?.lane.label ?? ''} · lines #{top + 1}–#{Math.min(captured.length, top + tall)} of #{captured.length} "
+
+ ~>
+ return unless snap.over and not @tally.closed
+ @tally.closed = true
+ for lost in snap.summary.failed
+ print failureBlock(lost, PAINT)
+ open = false
+ finale = true
+ quit snap.summary.code
+
+ # The terminal's own indicator: the run's share, or its lanes on a first
+ # run, and 'error' once a lane fails; in whole percents, so it is sent
+ # again only when it moves.
+ indicator ~=
+ t = snap.tally
+ if snap.summary then null
+ else if t.failed then 'error'
+ else Math.round((snap.share ?? t.finished / Math.max(1, t.total)) * 100) / 100
+ ~> screen.progress indicator
+
+ scroll: (to) -> top = Math.max 0, Math.min(to, captured.length - tall)
+
+ pressed: (event) ->
+ return quit() if event.key is 'q'
+ count = snap.failures.length
+ if open
+ switch event.key
+ when 'ArrowUp' then @scroll top - 1
+ when 'ArrowDown' then @scroll top + 1
+ when 'PageUp' then @scroll top - tall
+ when 'PageDown' then @scroll top + tall
+ when 'Home' then @scroll 0
+ when 'End' then @scroll captured.length
+ when 'Enter', 'Escape' then open = false
+ return
+ switch event.key
+ when 'f' then only = not only
+ when 'ArrowUp' then pick = (Math.min(pick, count - 1) - 1 + count) % count if count
+ when 'ArrowDown' then pick = (Math.min(pick, count - 1) + 1) % count if count
+ when 'Enter'
+ if chosen
+ open = true
+ @scroll chosen.card.at - 8
+
+ render
+ Box flexDirection: 'column', focusable: true, autofocus: true, @keydown: @pressed
+ Static
+ for row in snap.finished
+ Line key: row.result.lane.label, segs: laneRow(row, name, wide)
+ Static
+ if finale
+ Box flexDirection: 'column', borderStyle: 'round', borderColor: hue('slate'), paddingX: 1
+ for runs, n in endRows(@tally, snap.summary)
+ Line key: n, segs: runs
+ Line segs: heading
+ if screen.cols >= 40
+ Line segs: strip
+ Line segs: overall
+ unless snap.summary
+ Box height: 1
+ if only
+ for runs, n in failing
+ Line key: n, segs: runs
+ else
+ for runs, n in flight
+ Line key: snap.running[n].label, segs: runs
+ if cardLines.length
+ Box flexDirection: 'column', borderStyle: 'round', borderColor: hue('red'), paddingX: 1
+ for runs, n in cardLines
+ Line key: n, segs: runs
+ if open
+ Box flexDirection: 'column', borderStyle: 'single', borderDimColor: true, paddingX: 1
+ Text dimColor: true, wrap: 'truncate', "#{paneTitle}"
+ for row, n in visible
+ Text key: top + n, wrap: 'truncate', "#{row or ' '}"
+ Line segs: totals
+
+# ── Main ──────────────────────────────────────────────────────────────────────
+
+main! =! ->
+ argv = process.argv.slice 2
+ unless process.stdout.isTTY and not process.env.CI and not argv.includes '--plan'
+ import!('./test-all.mjs')
+ return
+ config = configure argv
+ guardrails config
+ planned = planLanes config
+ tally = Tally.new planned, config, recall(config.root)
+ board = run Board, props: { tally }
+ usePty = Bun.enableANSIColors and process.platform isnt 'win32'
+ lanes = launch config, planned, { usePty, cols: process.stdout.columns or 120, rows: process.stdout.rows or 40 }, (event) -> tally.hear event
+ # However the process leaves — a signal, a crash — no lane outlives it.
+ process.on 'exit', -> lanes.abort 'SIGKILL'
+ code = await board.done
+ if tally.summary
+ remember config.root, tally
+ process.exit tally.summary.code
+ lanes.abort! 'SIGKILL', 2000
+ process.exit (if typeof code is 'number' then code else 130)
+
+main() if import.meta.main
diff --git a/test/spawn/cli/test-all.test.js b/test/spawn/cli/test-all.test.js
index 436e10ed..55647b6d 100644
--- a/test/spawn/cli/test-all.test.js
+++ b/test/spawn/cli/test-all.test.js
@@ -29,7 +29,7 @@ import { spawnSync } from '../../support/spawn.js';
import { alive, until } from '../../support/wait.js';
import { spawn } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
-import { tmpdir } from 'node:os';
+import { availableParallelism, tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
const ORCHESTRATOR = resolve(import.meta.dir, '../../../scripts/test-all.mjs');
@@ -299,6 +299,44 @@ describe('the lane orchestrator', () => {
expect(readdirSync(join(root, 'test'))).toEqual(['root.test.js']); // a plan writes nothing
});
+ // The plan's bytes are pinned whole, painted and plain: the header's
+ // budget, the skip line, the queue and the two argument lines. The
+ // budget is recomputed here from the cores this machine offers, by
+ // the rule the orchestrator documents, so the pin holds on any box.
+ test('the plan prints exactly its header, skips, queue and budget, plain and painted', () => {
+ const root = fixture({ zebra: GREEN, sites: GREEN, toolless: TOOLLESS });
+ const cores = availableParallelism();
+ const peak = Math.max(3, Math.round(cores * 1.4));
+ const lane = Math.max(1, Math.min(4, peak - 2)); // --jobs 2: one sibling, which fans out
+ const rootWorkers = Math.max(2, Math.min(cores, peak - lane));
+ const skip = ' ⊘ packages/toolless SKIPPED: `rip-no-such-tool-6f2a` is not on PATH or in node_modules/.bin';
+ const expected = (paint) => [
+ `[rip] test:all — 3 lanes, 2 at a time on ${cores} cores (root lane ${rootWorkers} workers, ${lane} per sibling lane), repo ${root}`,
+ paint('33', skip),
+ '▸ root (extended tier)',
+ '▸ packages/sites',
+ '▸ packages/zebra',
+ paint('2', ` · root lane: bun test --parallel=${rootWorkers} --timeout 60000`),
+ paint('2', ` · package lanes: bun run test (RIP_LANE_WORKERS=${lane})`),
+ '',
+ ].join('\n');
+
+ const plain = orchestrate(root, {}, '--plan', '--jobs', '2');
+ expect(plain.status).toBe(0);
+ expect(plain.stdout).toBe(expected((_, s) => s));
+
+ const env = { ...process.env, CI: '', FORCE_COLOR: '1' };
+ delete env.NO_COLOR;
+ const painted = spawnSync(process.execPath, [ORCHESTRATOR, '--root', root, '--plan', '--jobs', '2'], {
+ encoding: 'utf8',
+ env,
+ keepForceColor: true,
+ ...BOUND,
+ });
+ expect(painted.status).toBe(0);
+ expect(painted.stdout).toBe(expected((code, s) => `\x1b[${code}m${s}\x1b[0m`));
+ });
+
// A real run starts lanes in the planned order too (the plan is the
// queue, not a separate listing). One lane at a time makes the start
// order the output order.
@@ -381,4 +419,33 @@ describe('an interrupted run takes its lanes down', () => {
expect(status).toEqual({ code: 143, signal: null });
rmSync(root, { recursive: true, force: true });
});
+
+ // `bun test --parallel` puts each worker in a process group of its own,
+ // and a worker whose coordinator dies is re-parented and runs on. The
+ // stand-in here is a lane that starts a detached child — a group, and
+ // a session, of its own — and parks beside it.
+ test('SIGTERM to the orchestrator stops every process under a lane, in whatever group it put itself', async () => {
+ const tree = {
+ script: `bun -e "const c = require('child_process').spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { detached: true, stdio: 'ignore' }); require('fs').writeFileSync('lane.pid', process.pid + ' ' + c.pid); setInterval(() => {}, 1000)"`,
+ };
+ const root = fixture({ tree });
+ const pidFile = join(root, 'packages', 'tree', 'lane.pid');
+ const orchestrator = spawn(process.execPath, [ORCHESTRATOR, '--root', root, '--timeout', '120000'], {
+ stdio: 'ignore',
+ env: { ...process.env, CI: '', NO_COLOR: '1' },
+ });
+ expect(await until(() => existsSync(pidFile) && readFileSync(pidFile, 'utf8').includes(' '), 15000)).toBe(true);
+ const [lane, detached] = readFileSync(pidFile, 'utf8').split(' ').map(Number);
+ try {
+ expect(alive(detached)).toBe(true);
+ const exited = new Promise((resolve) => orchestrator.once('exit', (code, signal) => resolve({ code, signal })));
+ orchestrator.kill('SIGTERM');
+ expect(await until(() => !alive(lane) && !alive(detached), 5000)).toBe(true);
+ expect(await exited).toEqual({ code: 143, signal: null });
+ } finally {
+ // Nothing outlives the test, whatever it found.
+ for (const pid of [lane, detached]) { try { process.kill(pid, 'SIGKILL'); } catch { /* gone */ } }
+ rmSync(root, { recursive: true, force: true });
+ }
+ });
});