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 }); + } + }); });