Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pid>`, 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 <pid>)`) 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`
Expand All @@ -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
```

Expand Down Expand Up @@ -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
```

Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
40 changes: 40 additions & 0 deletions scripts/dev-prepare.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
12 changes: 12 additions & 0 deletions scripts/dev-prepare.ts
Original file line number Diff line number Diff line change
@@ -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');
86 changes: 86 additions & 0 deletions scripts/dev-run.test.ts
Original file line number Diff line number Diff line change
@@ -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);
169 changes: 0 additions & 169 deletions scripts/dev.test.ts

This file was deleted.

Loading
Loading