shout/SHOUT.md
2026-03-10 00:04:38 -07:00

3.8 KiB

Shout — Proposed Improvements

Two additions to the shout test framework: automatic process cleanup and test isolation primitives.

1. Automatic Process Cleanup

Problem

Any test that backgrounds a process (&) must manually clean it up. If a test fails or times out before reaching its cleanup command, the process is orphaned and holds its port indefinitely. Every test author has to remember the trap pattern:

$ my-server &; SVR=$!; trap "kill $SVR 2>/dev/null" EXIT; ...

This is error-prone and noisy.

Proposal

Shout already owns the shell process (via Bun.spawn). After the shell exits, shout should kill the entire process group to reap any lingering children.

In run.ts, after the shell completes:

// Kill the shell's process group to clean up backgrounded children
try {
  process.kill(-proc.pid, "SIGTERM")
} catch {}

The -pid syntax sends the signal to the entire process group. Since shout spawns the shell, the shell and all its children share a process group.

This requires no syntax changes, no test file modifications, and no action from test authors. Background a process, forget about it — shout cleans up.

For defense in depth, follow up with SIGKILL after a short grace period:

try {
  process.kill(-proc.pid, "SIGTERM")
} catch {}
setTimeout(() => {
  try { process.kill(-proc.pid, "SIGKILL") } catch {}
}, 500)

Migration

Remove the manual kill / trap lines from existing test files. They become no-ops but add visual noise.

2. Test Isolation Primitives

Problem

Every test file that needs a server repeats the same boilerplate:

$ PORT=19001 ... dev-server > /dev/null 2>&1 & SVR=$!; trap "kill $SVR 2>/dev/null" EXIT; i=0; while ! curl -sf http://localhost:19001/...; do ...; done; echo "ok"
ok

$ mkdir -p .config/dev && echo '{"server":"http://localhost:19001",...}' > .config/dev/config.json

Each test picks a hardcoded port. Adding a new test means manually checking which ports are taken. Parallel test runs risk port collisions.

Proposal: # setup directive

A new directive that includes commands from a shared file before the test's own commands:

# setup tests/setup.shout

The setup file is a normal .shout file. Its commands are prepended to the test's script (same shell, same working directory, same environment). This is purely textual inclusion — no new execution model.

Example tests/setup.shout:

$ dev-server > /dev/null 2>&1 &
$ mkdir -p .config/dev && echo "{\"server\":\"http://localhost:$PORT\",\"token\":\"dev-token-1\"}" > .config/dev/config.json
$ i=0; while ! curl -sf http://localhost:$PORT/api/whoami -H "Authorization: Bearer dev-token-1" > /dev/null 2>&1; do i=$((i+1)); if [ $i -gt 30 ]; then echo "server failed"; exit 1; fi; sleep 0.2; done; echo "ok"
ok

Then a test file becomes:

# Phase 1 — Linear Timeline
# setup tests/setup.shout

$ dev init myapp
initialized repo myapp in ./myapp

$ cd myapp
...

Proposal: --port-from <N> flag

A CLI flag that auto-assigns ports to test files:

shout --port-from 19000 tests/

Shout sets $PORT in each test file's environment, incrementing from the base. When --parallel is used, each file gets a unique port with no coordination needed.

Implementation in the runner:

let nextPort = options.portFrom
for (const file of files) {
  const env = { ...baseEnv, PORT: String(nextPort++) }
  await runFile(file, { ...options, env })
}

Test files reference $PORT instead of hardcoded values. Combined with # setup, the per-file boilerplate drops to one line.

Proposal: # env directive

For cases simpler than # setup — setting environment variables without a separate file:

# env PORT=19001
# env NODE_ENV=production

These are injected into the shell environment before any commands run. Lighter than a setup file when all you need is a few variables.