diff --git a/docs/development.md b/docs/development.md index 0e3fc27..625c73e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -22,8 +22,17 @@ bun run dev `bun run dev` creates `.env` from [`.env.example`](https://github.com/firsttris/haul/blob/main/.env.example) on the first run, builds the plugins and starts the server (`:8080`) and the UI with hot reload (`:5173`). Open http://localhost:5173 and log in with `admin` / `adminadmin`. Data goes to `./.data`. -Ctrl+C, an IDE's stop (SIGTERM) or closing the terminal (SIGHUP) stops both with everything they -started; when one of them exits, the other is stopped too. Ctrl+Z suspends both, `fg` resumes them. +The first part is [`scripts/dev-prepare.ts`](https://github.com/firsttris/haul/blob/main/scripts/dev-prepare.ts); then +`bun run --parallel dev:server dev:ui` runs `cargo run -p haul` and `bun run --cwd ui dev` side by +side, each line prefixed with its script's name, and stops the other when one of them exits with an +error. Ctrl+C, Ctrl+Z and closing the terminal reach both, as the terminal signals the whole +foreground process group; `fg` resumes them after Ctrl+Z. +`--parallel` pipes both scripts' output for the prefix and gives them no input, so in a terminal +`dev` sets `FORCE_COLOR` and `CARGO_TERM_COLOR` to keep their colours; Vite's keyboard shortcuts +(`h`, `r`, `o`, `q`) need a terminal on its input and are there only with `bun run dev:ui` on its own. +A signal sent to the `bun run dev` process alone (`kill `, an IDE's stop without a terminal) +stops it but leaves both servers running: send the signal to its process group instead +(`kill -- -$(ps -o pgid= -p )`) or run the IDE's configuration in a terminal. The server reads `.env` itself, so `cargo run -p haul` works on its own too. Variables set in the environment take precedence. As long as no UI is built into the binary, `HAUL_DEV_UI` @@ -35,7 +44,7 @@ environment take precedence. As long as no UI is built into the binary, `HAUL_DE cargo test --workspace cargo clippy --workspace --all-targets -- -D warnings cargo fmt --all --check -bun run test # bun run dev's process handling, SDK helpers and hoster plugins against a fake context +bun run test # bun run dev's prepare step, SDK helpers and hoster plugins against a fake context bun run typecheck ``` @@ -118,7 +127,7 @@ ui web UI extension browser extension for Chrome and Firefox (bun run build:extension) docker Dockerfile, Compose file and Podman Quadlet units docs this documentation (mkdocs.yml, requirements-docs.txt) -scripts dev.ts (bun run dev), build-plugins.ts, screenshots/ (bun run screenshots) +scripts dev-prepare.ts (bun run dev), build-plugins.ts, screenshots/ (bun run screenshots) .github/workflows CI, extension, docs, screenshots, bump and release ``` diff --git a/package.json b/package.json index db7a3f4..737467a 100644 --- a/package.json +++ b/package.json @@ -11,13 +11,13 @@ "extension" ], "scripts": { - "dev": "bun scripts/dev.ts", + "dev": "bun scripts/dev-prepare.ts && if [ -t 1 ]; then export FORCE_COLOR=1 CARGO_TERM_COLOR=always; fi && bun run --parallel dev:server dev:ui", "dev:server": "cargo run -p haul", "dev:ui": "bun run --cwd ui dev", "build": "bun run build:plugins && bun run --filter @haul/ui build", "build:plugins": "bun scripts/build-plugins.ts", "build:extension": "bun run --filter @haul/extension build", - "test": "bun test ./scripts/dev.test.ts && bun run --filter @haul/ui i18n && bun --bun vitest run", + "test": "bun test ./scripts/dev-prepare.test.ts ./scripts/dev-run.test.ts && bun run --filter @haul/ui i18n && bun --bun vitest run", "typecheck": "bun run --workspaces typecheck", "screenshots": "bun --bun playwright test -c scripts/screenshots/playwright.config.ts" }, diff --git a/scripts/dev-prepare.test.ts b/scripts/dev-prepare.test.ts new file mode 100644 index 0000000..7d5fbf6 --- /dev/null +++ b/scripts/dev-prepare.test.ts @@ -0,0 +1,40 @@ +// scripts/dev-prepare.ts, the first step of `bun run dev`, in a sandbox with a stand-in plugin build +import { afterEach, expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +const PREPARE = resolve(import.meta.dir, 'dev-prepare.ts'); +let dir = ''; + +afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }); + dir = ''; +}); + +/** Runs dev-prepare.ts in a checkout with `.env.example` and a plugin build exiting with `pluginsExit` */ +function prepare(pluginsExit = 0) { + dir ||= mkdtempSync(join(tmpdir(), 'haul-dev-')); + mkdirSync(join(dir, 'scripts'), { recursive: true }); + writeFileSync(join(dir, '.env.example'), 'HAUL_DEV_UI=http://localhost:5173\n'); + writeFileSync(join(dir, 'scripts', 'build-plugins.ts'), `process.exit(${pluginsExit});\n`); + return Bun.spawnSync([process.execPath, PREPARE], { cwd: dir, stdio: ['ignore', 'pipe', 'pipe'] }); +} + +test('creates .env from .env.example on the first run', () => { + expect(prepare().exitCode).toBe(0); + expect(readFileSync(join(dir, '.env'), 'utf8')).toBe('HAUL_DEV_UI=http://localhost:5173\n'); +}); + +test('keeps an existing .env', () => { + dir = mkdtempSync(join(tmpdir(), 'haul-dev-')); + writeFileSync(join(dir, '.env'), 'HAUL_USER=me\n'); + expect(prepare().exitCode).toBe(0); + expect(readFileSync(join(dir, '.env'), 'utf8')).toBe('HAUL_USER=me\n'); +}); + +test('fails with the plugin build, so `bun run dev` starts no server', () => { + const run = prepare(3); + expect(run.exitCode).toBe(3); + expect(run.stdout.toString()).not.toContain('Haul-UI'); +}); diff --git a/scripts/dev-prepare.ts b/scripts/dev-prepare.ts new file mode 100644 index 0000000..25e83af --- /dev/null +++ b/scripts/dev-prepare.ts @@ -0,0 +1,12 @@ +// First step of `bun run dev`: creates .env on the first run, builds the plugins and prints where +// the UI is. The servers are started by `bun run --parallel dev:server dev:ui` after it. +import { copyFileSync, existsSync } from 'node:fs'; + +if (!existsSync('.env')) { + copyFileSync('.env.example', '.env'); + console.log('.env aus .env.example angelegt'); +} +const plugins = Bun.spawnSync(['bun', 'scripts/build-plugins.ts'], { stdio: ['inherit', 'inherit', 'inherit'] }); +if (plugins.exitCode !== 0) process.exit(plugins.exitCode ?? 1); + +console.log('\n Haul-UI: http://localhost:5173 (Login aus .env, Standard admin / adminadmin)\n'); diff --git a/scripts/dev-run.test.ts b/scripts/dev-run.test.ts new file mode 100644 index 0000000..3bdb845 --- /dev/null +++ b/scripts/dev-run.test.ts @@ -0,0 +1,86 @@ +// The `dev` script of package.json, run by `bun run dev` in a sandbox with stand-ins for +// `cargo run -p haul`, `bun run --cwd ui dev` and the plugin build: what the servers get to see. +import { afterEach, expect, test } from 'bun:test'; +import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +const ROOT = resolve(import.meta.dir, '..'); +const { scripts } = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')); + +let dir = ''; +afterEach(() => { + for (const name of ['server', 'ui']) { + const file = join(dir, `${name}.pids`); + if (!existsSync(file)) continue; + for (const pid of readFileSync(file, 'utf8').split(' ').map(Number)) { + try { + process.kill(pid, 'SIGKILL'); + } catch {} + } + } + if (dir) rmSync(dir, { recursive: true, force: true }); + dir = ''; +}); + +// A server stand-in: notes what it sees, then waits (a sleep as its child) +const SERVER = ` +const name = process.argv[2]; +const child = Bun.spawn(['sleep', '600'], { stdio: ['ignore', 'ignore', 'ignore'] }); +await Bun.write(import.meta.dir + '/' + name + '.json', JSON.stringify({ + FORCE_COLOR: process.env.FORCE_COLOR ?? null, + CARGO_TERM_COLOR: process.env.CARGO_TERM_COLOR ?? null, +})); +await Bun.write(import.meta.dir + '/' + name + '.pids', process.pid + ' ' + child.pid); +await child.exited; +`; + +function sandbox() { + dir = mkdtempSync(join(tmpdir(), 'haul-dev-run-')); + for (const sub of ['bin', 'scripts', 'ui']) mkdirSync(join(dir, sub)); + const pick = ['dev', 'dev:server', 'dev:ui']; + const own = Object.fromEntries(pick.map((name) => [name, scripts[name]])); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'sandbox', private: true, scripts: own })); + copyFileSync(join(ROOT, 'scripts', 'dev-prepare.ts'), join(dir, 'scripts', 'dev-prepare.ts')); + writeFileSync(join(dir, 'scripts', 'build-plugins.ts'), 'process.exit(0);\n'); + writeFileSync(join(dir, '.env.example'), 'HAUL_DEV_UI=http://localhost:5173\n'); + writeFileSync(join(dir, 'server.ts'), SERVER); + writeFileSync(join(dir, 'bin', 'cargo'), `#!/bin/sh\nexec "${process.execPath}" "${dir}/server.ts" server\n`); + chmodSync(join(dir, 'bin', 'cargo'), 0o755); + writeFileSync( + join(dir, 'ui', 'package.json'), + JSON.stringify({ name: 'ui', scripts: { dev: `"${process.execPath}" ../server.ts ui` } }), + ); +} + +/** Runs `bun run dev` in a terminal (a pty from Bun.spawn) or with its output in a pipe */ +async function seen(terminal: boolean) { + sandbox(); + const { FORCE_COLOR: _f, CARGO_TERM_COLOR: _c, NO_COLOR: _n, ...env } = process.env; + const proc = Bun.spawn([process.execPath, 'run', 'dev'], { + cwd: dir, + env: { ...env, PATH: `${join(dir, 'bin')}:${process.env.PATH}` }, + ...(terminal ? { terminal: { cols: 80, rows: 24, data() {} } } : { stdio: ['ignore', 'pipe', 'pipe'] as const }), + }); + const files = ['server', 'ui'].map((name) => join(dir, `${name}.pids`)); + const end = Date.now() + 10_000; + while (!files.every(existsSync) && Date.now() < end) await Bun.sleep(50); + proc.kill('SIGKILL'); + return ['server', 'ui'].map((name) => JSON.parse(readFileSync(join(dir, `${name}.json`), 'utf8'))); +} + +// --parallel pipes the scripts' output for its name prefix, so cargo and Vite (picocolors) see no +// terminal and would drop their colours, which the old scripts/dev.ts kept by passing it on. +test('in a terminal, cargo and Vite are told to keep their colours', async () => { + expect(await seen(true)).toEqual([ + { FORCE_COLOR: '1', CARGO_TERM_COLOR: 'always' }, + { FORCE_COLOR: '1', CARGO_TERM_COLOR: 'always' }, + ]); +}, 20_000); + +test('with the output in a pipe, no colours are forced', async () => { + expect(await seen(false)).toEqual([ + { FORCE_COLOR: null, CARGO_TERM_COLOR: null }, + { FORCE_COLOR: null, CARGO_TERM_COLOR: null }, + ]); +}, 20_000); diff --git a/scripts/dev.test.ts b/scripts/dev.test.ts deleted file mode 100644 index baeca09..0000000 --- a/scripts/dev.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -// `bun run dev` (scripts/dev.ts) with stand-ins for `cargo run -p haul`, `bun run --cwd ui dev` and -// the plugin build: what is left running when dev.ts is stopped or one server exits. As with the -// real commands, `cargo` execs the server (cargo run replaces itself with the binary) and `bun run` -// starts it as its child; each server starts a process of its own, as haul starts 7z or unrar to -// extract and Vite starts esbuild. -import { afterEach, expect, test } from 'bun:test'; -import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; - -const DEV = resolve(import.meta.dir, 'dev.ts'); - -let dir = ''; -let pids: number[] = []; -let dev: Bun.Subprocess | undefined; - -afterEach(() => { - for (const pid of [...pids, dev?.pid ?? 0]) { - if (!pid) continue; - try { - process.kill(pid, 'SIGKILL'); - } catch {} - } - pids = []; - dev = undefined; - if (dir) rmSync(dir, { recursive: true, force: true }); - dir = ''; -}); - -// A server stand-in: notes its pid and its child's and the HAUL_DEV_UI it got, then waits on the -// child (a sleep with the default signal handling), or, with SERVER_EXIT or UI_EXIT set, exits -// with that code once both servers run. -const SERVER = ` -const [name] = process.argv.slice(2); -const dir = process.env.SANDBOX; -const child = Bun.spawn(['sleep', '600'], { stdio: ['ignore', 'ignore', 'ignore'] }); -await Bun.write(dir + '/' + name + '.env', process.env.HAUL_DEV_UI ?? ''); -await Bun.write(dir + '/' + name + '.pids', process.pid + ' ' + child.pid); -const exit = process.env[name.toUpperCase() + '_EXIT']; -if (exit) { - while (!(await Bun.file(dir + '/ui.pids').exists()) || !(await Bun.file(dir + '/server.pids').exists())) await Bun.sleep(50); - child.kill('SIGKILL'); - process.exit(Number(exit)); -} -await child.exited; -`; - -/** A checkout with `.env.example`, a plugin build, a `cargo` on PATH and a ui package */ -function sandbox({ pluginsExit = 0 } = {}) { - dir = mkdtempSync(join(tmpdir(), 'haul-dev-')); - const bin = join(dir, 'bin'); - for (const sub of ['bin', 'scripts', 'ui']) mkdirSync(join(dir, sub)); - writeFileSync(join(dir, '.env.example'), 'HAUL_DEV_UI=http://localhost:5173\n'); - writeFileSync(join(dir, 'server.ts'), SERVER); - writeFileSync(join(dir, 'scripts', 'build-plugins.ts'), `process.exit(${pluginsExit});\n`); - writeFileSync(join(bin, 'cargo'), `#!/bin/sh\nexec "${process.execPath}" "${dir}/server.ts" server\n`); - chmodSync(join(bin, 'cargo'), 0o755); - writeFileSync( - join(dir, 'ui', 'package.json'), - JSON.stringify({ name: 'ui', scripts: { dev: `exec "${process.execPath}" ../server.ts ui` } }), - ); - return bin; -} - -/** Starts dev.ts in a fresh sandbox, without HAUL_DEV_UI so its default applies */ -function start(env: Record = {}, pluginsExit = 0) { - const bin = sandbox({ pluginsExit }); - const { HAUL_DEV_UI: _, ...inherited } = process.env; - dev = Bun.spawn([process.execPath, DEV], { - cwd: dir, - env: { ...inherited, PATH: `${bin}:${process.env.PATH}`, SANDBOX: dir, ...env }, - stdio: ['ignore', 'ignore', 'ignore'], - }); - return dev; -} - -function alive(pid: number) { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -async function until(check: () => boolean, ms = 10_000) { - const end = Date.now() + ms; - while (!check()) { - if (Date.now() > end) throw new Error('timed out'); - await Bun.sleep(50); - } -} - -const NAMES = ['haul', "haul's child (7z)", 'vite', "vite's child (esbuild)"]; - -/** Waits for both stand-ins and notes their pids and their children's */ -async function started() { - const files = ['server.pids', 'ui.pids'].map((name) => join(dir, name)); - await until(() => files.every((file) => existsSync(file) && readFileSync(file, 'utf8').includes(' '))); - pids = files.flatMap((file) => readFileSync(file, 'utf8').trim().split(' ').map(Number)); -} - -/** What is still running of what dev.ts started, once everything had 2 s to stop */ -async function leftovers() { - await until(() => pids.every((pid) => !alive(pid)), 2_000).catch(() => {}); - return NAMES.filter((_, i) => alive(pids[i] ?? 0)); -} - -for (const [signal, number] of [ - ['SIGINT', 2], - ['SIGTERM', 15], - ['SIGHUP', 1], -] as const) { - test(`${signal} to dev.ts stops both servers and what they started`, async () => { - const proc = start(); - await started(); - - proc.kill(signal); - const code = await Promise.race([proc.exited, Bun.sleep(5_000).then(() => 'still running')]); - - expect(await leftovers()).toEqual([]); - expect(code).toBe(128 + number); - }, 20_000); -} - -/** The process states `ps` shows (T: stopped), one letter per pid */ -function states() { - const ps = Bun.spawnSync(['ps', '-o', 'stat=', '-p', pids.join(',')]); - return ps.stdout.toString().trim().split('\n').map((line) => line.trim()[0]); -} - -// Ctrl+Z reaches only dev.ts: the servers run in sessions of their own. Left running, Vite -// would keep reading the terminal and swallow what is typed into the shell. -test('SIGTSTP (Ctrl+Z) to dev.ts stops both servers and what they started, SIGCONT (fg) resumes them', async () => { - const proc = start(); - await started(); - - proc.kill('SIGTSTP'); - await until(() => states().every((state) => state === 'T'), 3_000).catch(() => {}); - expect(states()).toEqual(['T', 'T', 'T', 'T']); - - proc.kill('SIGCONT'); - await until(() => states().every((state) => state !== 'T'), 3_000).catch(() => {}); - expect(states().filter((state) => state === 'T')).toEqual([]); -}, 20_000); - -test('when one server exits, the other is stopped and dev.ts exits with its code', async () => { - const proc = start({ SERVER_EXIT: '3' }); - await started(); - - const code = await Promise.race([proc.exited, Bun.sleep(5_000).then(() => 'still running')]); - - expect(await leftovers()).toEqual([]); - expect(code).toBe(3); -}, 20_000); - -test('creates .env and passes HAUL_DEV_UI to the server', async () => { - start(); - await started(); - expect(readFileSync(join(dir, '.env'), 'utf8')).toBe('HAUL_DEV_UI=http://localhost:5173\n'); - expect(readFileSync(join(dir, 'server.env'), 'utf8')).toBe('http://localhost:5173'); -}, 20_000); - -test('a failing plugin build aborts before any server starts', async () => { - const proc = start({}, 4); - expect(await proc.exited).toBe(4); - expect(existsSync(join(dir, 'server.pids'))).toBe(false); - expect(existsSync(join(dir, 'ui.pids'))).toBe(false); -}, 20_000); diff --git a/scripts/dev.ts b/scripts/dev.ts deleted file mode 100644 index 6154f41..0000000 --- a/scripts/dev.ts +++ /dev/null @@ -1,54 +0,0 @@ -// `bun run dev`: creates .env on first run, builds the plugins, then runs the Rust server -// (:8080) and the Vite UI (:5173, proxies /api) side by side. Each runs in a process group of its -// own; Ctrl+C, SIGTERM (an IDE's stop, `kill`) and SIGHUP (the terminal closed) reach each group -// whole, with what the servers started (7z, esbuild), and when one exits, the other is stopped too. -// Tested in dev.test.ts (`bun test ./scripts/dev.test.ts`, part of `bun run test`). -import { copyFileSync, existsSync } from 'node:fs'; -import { constants } from 'node:os'; - -if (!existsSync('.env')) { - copyFileSync('.env.example', '.env'); - console.log('.env aus .env.example angelegt'); -} -const plugins = Bun.spawnSync(['bun', 'scripts/build-plugins.ts'], { stdio: ['inherit', 'inherit', 'inherit'] }); -if (plugins.exitCode !== 0) process.exit(plugins.exitCode ?? 1); - -console.log('\n Haul-UI: http://localhost:5173 (Login aus .env, Standard admin / adminadmin)\n'); - -// detached: a process group of its own, so a signal to the group reaches everything inside -const processes = [ - Bun.spawn(['cargo', 'run', '-p', 'haul'], { - stdio: ['ignore', 'inherit', 'inherit'], - // Until the UI is built into the binary, :8080 redirects to Vite. - env: { ...process.env, HAUL_DEV_UI: process.env.HAUL_DEV_UI ?? 'http://localhost:5173' }, - detached: true, - }), - Bun.spawn(['bun', 'run', '--cwd', 'ui', 'dev'], { stdio: ['inherit', 'inherit', 'inherit'], detached: true }), -]; - -const signalAll = (signal: NodeJS.Signals) => { - for (const p of processes) { - try { - process.kill(-p.pid, signal); - } catch { - // the group has already exited - } - } -}; - -for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) process.on(signal, () => signalAll(signal)); - -// Ctrl+Z (SIGTSTP) reaches only dev.ts, the servers' sessions are out of the terminal's reach: stop -// them too, or Vite keeps reading the terminal the shell has taken back. SIGSTOP, as the kernel drops -// SIGTSTP sent to an orphaned group (a session of its own is one); `fg` (SIGCONT) resumes them. -process.on('SIGTSTP', () => { - signalAll('SIGSTOP'); - process.kill(process.pid, 'SIGSTOP'); -}); -process.on('SIGCONT', () => signalAll('SIGCONT')); - -// The first to exit decides the exit code: its own, or 128 + n when signal n stopped it -const first = await Promise.race(processes.map((p) => p.exited.then(() => p))); -signalAll('SIGTERM'); -await Promise.all(processes.map((p) => p.exited)); -process.exit(first.signalCode ? 128 + constants.signals[first.signalCode] : (first.exitCode ?? 1));