diff --git a/docs/development.md b/docs/development.md index eeaa55e..a882b8e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -16,8 +16,13 @@ bun run dev the server on :8080 and the web app with hot reload on :5173 (it proxies `/api`). Open http://localhost:5173. Data goes to `./.data`. `WF_DEV_UI` (set by `bun run dev`) names the Vite server the binary redirects to while no web app is built in; it is a development setting only. -Ctrl+C, closing the terminal or a SIGTERM stops both, with everything they started; when one of -them exits, the other is stopped too. Ctrl+Z suspends both, `fg` resumes them. +The two run as `dev:server` (`cargo run -p windfenster`) and `dev:web` (the web app's `bun run dev`) +side by side through `bun run --parallel`, which prefixes each line with the script's name and stops +the other when one of them fails. Their output goes through a pipe and their input is `/dev/null`, +so in a terminal `bun run dev` sets `FORCE_COLOR` and `CARGO_TERM_COLOR` to keep the colours, and +Vite's keyboard shortcuts (`h`, `r`, `o`, `q`) are only there with `bun run dev:web`. Ctrl+C, +Ctrl+Z and closing the terminal reach both from the terminal itself (they run in its foreground +process group): Ctrl+C stops both, Ctrl+Z suspends both, `fg` resumes them. Each can also run on its own (`bun run dev:server`, `bun run dev:web`). Checks, as CI runs them: @@ -36,8 +41,7 @@ bun run e2e # browser tests (Playwright): the map's shaders and t ## Tests Unit tests: `cargo test` (next to the code, API and pipeline tests in `crates/*/tests` against -local stand-ins for the data sources), Vitest for the logic in `web/src/lib` and `bun test` for -the scripts in `scripts/` (`bun run test` runs both). +local stand-ins for the data sources) and Vitest for the logic in `web/src/lib` (`bun run test`). `bun run e2e` runs two sets of browser tests in Chromium, with SwiftShader for a GPU; the Playwright runner and the Vite server of the gpu tests run on Bun (`bun --bun playwright test`, diff --git a/package.json b/package.json index 9a97f87..1114054 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,11 @@ "web" ], "scripts": { - "dev": "bun scripts/dev.ts", + "dev": "[ -f .env ] || { cp .env.example .env && echo '.env created from .env.example'; } && printf '\\n Windfenster: http://localhost:5173\\n\\n' && if [ -t 1 ]; then export FORCE_COLOR=1 CARGO_TERM_COLOR=always; fi && bun run --parallel dev:server dev:web", + "dev:server": "cargo run -p windfenster", + "dev:web": "bun run --cwd web dev", "build": "bun run --filter @windfenster/web build", - "test": "bun test ./scripts && bun run --filter @windfenster/web test", + "test": "bun run --filter @windfenster/web test", "typecheck": "bun run --filter @windfenster/web typecheck", "bindings": "cargo test --workspace export_bindings", "lint": "biome check .", diff --git a/scripts/dev.test.ts b/scripts/dev.test.ts deleted file mode 100644 index 382d681..0000000 --- a/scripts/dev.test.ts +++ /dev/null @@ -1,229 +0,0 @@ -// `bun run dev` (scripts/dev.ts) with stand-ins for `cargo run` and the web app's `bun run dev`: -// what is left running when the script itself is stopped. -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[] = []; - -afterEach(() => { - for (const pid of pids) { - try { - process.kill(pid, 'SIGKILL'); - } catch {} - } - pids = []; - if (dir) rmSync(dir, { recursive: true, force: true }); -}); - -/** A folder with `.env`, a web app whose dev script and a `cargo` that both sleep and note their pid */ -function sandbox() { - dir = mkdtempSync(join(tmpdir(), 'windfenster-dev-')); - const bin = join(dir, 'bin'); - mkdirSync(bin); - mkdirSync(join(dir, 'web')); - writeFileSync(join(dir, '.env'), ''); - writeFileSync(join(bin, 'cargo'), `#!/bin/sh\necho $$ > "${dir}/server.pid"\nexec sleep 600\n`); - chmodSync(join(bin, 'cargo'), 0o755); - writeFileSync( - join(dir, 'web', 'package.json'), - JSON.stringify({ name: 'web', scripts: { dev: `echo $$ > "${dir}/web.pid" && exec sleep 600` } }), - ); - return bin; -} - -/** - * As `sandbox`, but like the real ones the stand-ins start what does the work as a child of their - * own (cargo the `windfenster` binary, `bun run` Vite) and wait for it: a plain `sleep` that notes - * its pid. The parents are Bun scripts, as `sh` would start a background `sleep` with SIGINT ignored. - * The `cargo` stand-in exits with 3 once `crash` exists, as a server that fails. - */ -function sandboxWithGrandchildren() { - dir = mkdtempSync(join(tmpdir(), 'windfenster-dev-')); - const bin = join(dir, 'bin'); - mkdirSync(bin); - mkdirSync(join(dir, 'web')); - writeFileSync(join(dir, '.env'), ''); - const parent = (pidFile: string, crash: boolean) => - [ - `const child = Bun.spawn(['sleep', '600'], { stdio: ['ignore', 'ignore', 'ignore'] });`, - `await Bun.write(${JSON.stringify(join(dir, pidFile))}, String(child.pid));`, - crash - ? `while (!(await Bun.file(${JSON.stringify(join(dir, 'crash'))}).exists())) await Bun.sleep(50);\nprocess.exit(3);` - : 'await child.exited;', - '', - ].join('\n'); - writeFileSync(join(bin, 'cargo'), `#!${process.execPath}\n${parent('server.pid', true)}`); - chmodSync(join(bin, 'cargo'), 0o755); - writeFileSync(join(dir, 'web', 'vite.ts'), parent('web.pid', false)); - writeFileSync(join(dir, 'web', 'package.json'), JSON.stringify({ name: 'web', scripts: { dev: 'bun vite.ts' } })); - return bin; -} - -/** Whether `pid` still runs: a zombie (stopped, not yet reaped by an init that reaps late) does not */ -function alive(pid: number) { - try { - process.kill(pid, 0); - } catch { - return false; - } - try { - return readFileSync(`/proc/${pid}/stat`, 'utf8').split(') ').at(-1)?.[0] !== 'Z'; - } catch { - return !existsSync('/proc/self/stat'); - } -} - -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); - } -} - -/** Runs dev.ts with the stand-ins in `bin` and waits until both have noted their pid */ -async function start(bin: string) { - const dev = Bun.spawn([process.execPath, DEV], { - cwd: dir, - env: { ...process.env, PATH: `${bin}:${process.env.PATH}` }, - stdio: ['ignore', 'ignore', 'ignore'], - }); - const files = ['server.pid', 'web.pid'].map((name) => join(dir, name)); - await until(() => files.every((file) => existsSync(file) && readFileSync(file, 'utf8').trim())); - pids = files.map((file) => Number(readFileSync(file, 'utf8'))); - return dev; -} - -test('stopping `bun run dev` with SIGTERM stops the server and Vite too', async () => { - const dev = await start(sandbox()); - - dev.kill('SIGTERM'); - await dev.exited; - await Bun.sleep(500); - - expect(pids.filter(alive)).toEqual([]); -}, 20_000); - -for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) { - test(`${signal} also stops what cargo and \`bun run\` started`, async () => { - const dev = await start(sandboxWithGrandchildren()); - - dev.kill(signal); - await dev.exited; - await Bun.sleep(500); - - expect(pids.filter(alive)).toEqual([]); - }, 20_000); -} - -test('when the server exits, Vite started by `bun run` is stopped and its exit code passed on', async () => { - const dev = await start(sandboxWithGrandchildren()); - - writeFileSync(join(dir, 'crash'), ''); - const code = await dev.exited; - await Bun.sleep(500); - - expect(pids.filter(alive)).toEqual([]); - expect(code).toBe(3); -}, 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 server and the web app run in sessions of their own -test('SIGTSTP (Ctrl+Z) to dev.ts stops what cargo and `bun run` started, SIGCONT (fg) resumes them', async () => { - const dev = await start(sandboxWithGrandchildren()); - - dev.kill('SIGTSTP'); - await until(() => states().every((state) => state === 'T'), 3_000).catch(() => {}); - expect(states()).toEqual(['T', 'T']); - - dev.kill('SIGCONT'); - await until(() => states().every((state) => state !== 'T'), 3_000).catch(() => {}); - expect(states().filter((state) => state === 'T')).toEqual([]); - - dev.kill('SIGTERM'); - await dev.exited; -}, 20_000); - -/** Kills what the shell `shellPid` still runs (dev.ts, stopped or not) and the shell itself */ -function killShell(shellPid: number) { - const children = Bun.spawnSync(['ps', '-o', 'pid=', '--ppid', String(shellPid)]).stdout.toString(); - for (const pid of [...children.trim().split(/\s+/).filter(Boolean).map(Number), shellPid]) { - try { - process.kill(pid, 'SIGKILL'); - } catch {} - } -} - -test.skipIf(process.platform !== 'linux' || !Bun.which('script'))( - 'after Ctrl+Z in a terminal, what is typed goes to the shell, not to Vite', - async () => { - const bin = sandbox(); - // Vite reads the keys of its shortcuts (h, r, o, q) line by line from stdin when it is a terminal - writeFileSync( - join(dir, 'web', 'vite.ts'), - [ - `import { appendFileSync, writeFileSync } from 'node:fs';`, - `import { createInterface } from 'node:readline';`, - `createInterface({ input: process.stdin }).on('line', (line) => appendFileSync(${JSON.stringify(join(dir, 'typed'))}, line + '\\n'));`, - `writeFileSync(${JSON.stringify(join(dir, 'web.pid'))}, String(process.pid));`, - 'setInterval(() => {}, 1000);', - ].join('\n'), - ); - writeFileSync(join(dir, 'web', 'package.json'), JSON.stringify({ name: 'web', scripts: { dev: 'bun vite.ts' } })); - // an interactive shell in a pseudo-terminal, typed into through `script` - const shell = Bun.spawn(['script', '-qfec', 'bash --norc --noprofile -i', '/dev/null'], { - cwd: dir, - env: { ...process.env, PATH: `${bin}:${process.env.PATH}` }, - stdin: 'pipe', - stdout: 'pipe', - stderr: 'ignore', - }); - let output = ''; - (async () => { - for await (const chunk of shell.stdout) output += new TextDecoder().decode(chunk); - })(); - const type = (text: string) => { - shell.stdin.write(text); - shell.stdin.flush(); - }; - type(`echo $$ > shell.pid; ${process.execPath} ${DEV}\n`); - const files = ['server.pid', 'web.pid', 'shell.pid'].map((name) => join(dir, name)); - await until(() => files.every((file) => existsSync(file) && readFileSync(file, 'utf8').trim())); - const [server, web, shellPid] = files.map((file) => Number(readFileSync(file, 'utf8'))); - pids = [server, web]; - await Bun.sleep(500); - - try { - type('\x1a'); - await Bun.sleep(500); - // in canonical mode each line goes to one reader: whoever reads the terminal first - const lines = [1, 2, 3, 4, 5].map((n) => `typed-${n * 42}`); - for (const n of [1, 2, 3, 4, 5]) { - type(`echo typed-$((${n} * 42))\n`); - await Bun.sleep(300); - } - await until(() => lines.every((line) => output.includes(line)), 3_000).catch(() => {}); - - expect(existsSync(join(dir, 'typed')) ? readFileSync(join(dir, 'typed'), 'utf8') : '').toBe(''); - expect(lines.filter((line) => !output.includes(line))).toEqual([]); - } finally { - killShell(shellPid); - shell.kill('SIGKILL'); - } - }, - 20_000, -); diff --git a/scripts/dev.ts b/scripts/dev.ts deleted file mode 100644 index d849e0b..0000000 --- a/scripts/dev.ts +++ /dev/null @@ -1,49 +0,0 @@ -// `bun run dev`: creates .env on the first run, then runs the Rust server (:8080) and the Vite -// dev server (:5173, proxies /api to :8080) side by side. When one of them exits, the other is -// stopped too (as `bun dev/dev.ts` in firsttris/kontext), so a crash leaves nothing half running. -// A signal to this script goes on to both, as concurrently did: stopped with SIGTERM (an IDE, kill), -// Bun would otherwise exit at once and leave the server on :8080 and Vite on :5173 running. -// Each runs in a process group of its own and signals go to the whole group (as scripts/dev.ts in -// firsttris/ccu-addon-mui), so they also reach what cargo and `bun run` start, even if those -// don't pass a signal on. -import { copyFileSync, existsSync } from 'node:fs'; - -if (!existsSync('.env')) { - copyFileSync('.env.example', '.env'); - console.log('.env created from .env.example'); -} - -console.log('\n Windfenster: http://localhost:5173\n'); - -const processes = [ - Bun.spawn(['cargo', 'run', '-p', 'windfenster'], { stdio: ['ignore', 'inherit', 'inherit'], detached: true }), - Bun.spawn(['bun', 'run', 'dev'], { cwd: 'web', stdio: ['inherit', 'inherit', 'inherit'], detached: true }), -]; - -const signalAll = (signal: NodeJS.Signals) => { - for (const child of processes) { - try { - process.kill(-child.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 this script, the children's sessions are out of the terminal's reach: -// stop them too, or Vite keeps reading the terminal the shell has taken back and takes what is typed -// there. SIGSTOP, as the kernel drops SIGTSTP sent to an orphaned group (a session of its own is -// one); `fg` (SIGCONT) resumes them. As scripts/dev.ts in firsttris/haul. -process.on('SIGTSTP', () => { - signalAll('SIGSTOP'); - process.kill(process.pid, 'SIGSTOP'); -}); -process.on('SIGCONT', () => signalAll('SIGCONT')); - -// the exit code of the first to exit, 128 + n when a signal n stopped it -const code = await Promise.race(processes.map((child) => child.exited)); -signalAll('SIGTERM'); -await Promise.all(processes.map((child) => child.exited)); -process.exit(code);