// The CLI runs on Node 20+ (npm bundle) and Bun: cli/ reaches the runtime only through cli/lib/rt.ts, and rt behaves // like the Bun APIs it replaced (spawn defaults and exit codes, which, Glob, type stripping of a config). import { describe, expect, test } from 'bun:test'; import { chmodSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, relative } from 'node:path'; import * as rt from '../cli/lib/rt'; const ROOT = join(import.meta.dir, '..'); function cliSources(dir = join(ROOT, 'cli')): string[] { return readdirSync(dir, { withFileTypes: true }).flatMap(e => { const p = join(dir, e.name); if (e.isDirectory()) return cliSources(p); return e.name.endsWith('.ts') && !e.name.endsWith('.test.ts') ? [p] : []; }); } describe('cli/ is runtime neutral', () => { test('no Bun global, bun: module, import.meta.dir or text import outside cli/lib/rt.ts', () => { const bad: string[] = []; for (const f of cliSources()) { const rel = relative(ROOT, f); if (rel === join('cli', 'lib', 'rt.ts')) continue; readFileSync(f, 'utf8').split('\n').forEach((line, i) => { if (/\bBun\.[A-Za-z]|['"]bun:[a-z]|import\.meta\.(dir|dirname|path|file|filename|main)\b|with \{ type: 'text' \}/.test(line)) bad.push(`${rel}:${i + 1}: ${line.trim().slice(0, 120)}`); }); } expect(bad).toEqual([]); }); test('no Bun-only runtime behaviour: console as a stdin iterator, prompt(), alert(), HTMLRewriter (use rt.readLine)', () => { // `for await (const line of console)` works only on Bun; on Node (npx storebound) it throws // "console is not async iterable" at the first prompt (owner's report on Windows, 0.8.2). const bad: string[] = []; for (const f of cliSources()) { const rel = relative(ROOT, f); readFileSync(f, 'utf8').split('\n').forEach((line, i) => { if (/\bof\s+console\b|console\s*\[\s*Symbol\.asyncIterator|(^|[;{(=\s])(globalThis\.)?(prompt|alert)\s*\(\s*['"`]|\bnew\s+HTMLRewriter\b/.test(line.replace(/\/\/.*$/, ''))) bad.push(`${rel}:${i + 1}: ${line.trim().slice(0, 120)}`); }); } expect(bad).toEqual([]); }); test('package.json: bin is the Node bundle, engines name Node 20, npm pack builds it', () => { const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')) as { bin: Record; engines: Record; files: string[]; scripts: Record }; expect(pkg.bin.storebound).toBe('dist/storebound.mjs'); expect(pkg.engines.node).toBe('>=20'); expect(pkg.files).toContain('dist/storebound.mjs'); expect(pkg.scripts.prepack).toContain('tools/build-node.ts'); }); }); describe('rt: processes', () => { test('spawn: Bun defaults, exit code, 128 + signal, streams', async () => { const p = rt.spawn(['sh', '-c', 'echo out; echo err >&2; exit 3'], { stderr: 'pipe' }); const [out, err, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited]); expect([out, err, code, p.exitCode]).toEqual(['out\n', 'err\n', 3, 3]); const s = rt.spawn(['sleep', '10']); s.kill(); expect(await s.exited).toBe(143); expect(s.signalCode).toBe('SIGTERM'); const i = rt.spawn(['sleep', '10']); i.kill('SIGINT'); expect(await i.exited).toBe(130); }); test('a missing executable throws right away, like Bun', () => { expect(() => rt.spawn(['storebound-no-such-tool'])).toThrow('Executable not found in $PATH: "storebound-no-such-tool"'); expect(() => rt.spawnSync(['storebound-no-such-tool'])).toThrow('Executable not found in $PATH'); }); test('spawnSync: stdin bytes, env, exit code, timeout without a throw', () => { const r = rt.spawnSync(['sh', '-c', 'tr a-z A-Z; echo "$X" >&2; exit 2'], { stdin: new TextEncoder().encode('hi'), env: { ...process.env, X: 'x1' }, stdout: 'pipe', stderr: 'pipe' }); expect([r.stdout.toString(), r.stderr.toString(), r.exitCode, r.success]).toEqual(['HI', 'x1\n', 2, false]); const t = rt.spawnSync(['sleep', '5'], { timeout: 100 }); expect([t.exitCode, t.signalCode, t.success]).toEqual([null, 'SIGTERM', false]); }); test('which: PATH lookup with an explicit PATH, executables only', () => { const dir = mkdtempSync(join(tmpdir(), 'sb-which-')); try { writeFileSync(join(dir, 'tool-x'), '#!/bin/sh\n'); chmodSync(join(dir, 'tool-x'), 0o755); writeFileSync(join(dir, 'plain-y'), ''); expect(rt.which('tool-x', { PATH: dir })).toBe(join(dir, 'tool-x')); expect(rt.which('plain-y', { PATH: dir })).toBeNull(); expect(rt.which('tool-x', { PATH: '' })).toBeNull(); expect(rt.which('sh')).toBe(Bun.which('sh')); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('hasher and gunzip match Bun', () => { expect(rt.hasher('sha256').update('a').update('b').digest('hex')).toBe(new Bun.CryptoHasher('sha256').update('ab').digest('hex')); expect(new TextDecoder().decode(rt.gunzipSync(Bun.gzipSync(new TextEncoder().encode('zip'))))).toBe('zip'); }); }); describe('rt: Glob like Bun.Glob', () => { const cases: [string, string][] = [ ['apps/*', 'apps/a'], ['apps/*', 'apps/a/b'], ['apps/**', 'apps/a/b'], ['**/game', 'game'], ['**/game', 'x/y/game'], ['studio-*/app?', 'studio-1/app2'], ['studio-*/app?', 'studio-1/app22'], ['{a,b}/c', 'b/c'], ['{a,b}/c', 'd/c'], ['[ab]x', 'bx'], ['[!ab]x', 'cx'], ['[!ab]x', 'ax'], ['*.ts', 'a.ts'], ['*.ts', 'dir/a.ts'], ['a/**/z', 'a/z'], ['a/**/z', 'a/b/c/z'], ]; for (const [g, p] of cases) test(`${g} vs ${p}`, () => expect(new rt.Glob(g).match(p)).toBe(new Bun.Glob(g).match(p))); test('scanSync finds the same folders, no dot entries', () => { const dir = mkdtempSync(join(tmpdir(), 'sb-glob-')); try { for (const d of ['apps/one', 'apps/two/deep', 'apps/.hidden', 'other/three']) mkdirSync(join(dir, d), { recursive: true }); writeFileSync(join(dir, 'apps/file.txt'), ''); for (const g of ['apps/*', 'apps/**', '*/*', 'apps/t*']) { const mine = [...new rt.Glob(g).scanSync({ cwd: dir, onlyFiles: false, dot: false })].sort(); const bun = [...new Bun.Glob(g).scanSync({ cwd: dir, onlyFiles: false, dot: false })].sort(); expect(mine, g).toEqual(bun); } } finally { rmSync(dir, { recursive: true, force: true }); } }); }); describe('rt: configs on Node without type stripping', () => { test('stripTypes turns every shipped config into JavaScript with the same value', async () => { const files = ['templates/storebound.config.ts', ...readdirSync(join(ROOT, 'templates/profiles')).map(p => `templates/profiles/${p}/storebound.config.ts`), 'examples/demo-game/storebound.config.ts', 'examples/demo-app/storebound.config.ts'] .filter(f => { try { readFileSync(join(ROOT, f)); return true; } catch { return false; } }); expect(files.length).toBeGreaterThan(3); for (const f of files) { const js = rt.stripTypes(readFileSync(join(ROOT, f), 'utf8')); const viaJs = (await import(`data:text/javascript;base64,${Buffer.from(js).toString('base64')}`)).default; const viaTs = (await import(join(ROOT, f))).default; expect(JSON.stringify(viaJs), f).toBe(JSON.stringify(viaTs)); } }); test('stripTypes keeps strings and comments that look like types', () => { const js = rt.stripTypes("import type { X } from 'x';\n// keep: a as B\nconst c: X = { s: 'a satisfies B', n: 1 as number } satisfies X;\nexport default c as X;\n"); expect(js).toContain("// keep: a as B"); expect(js).toContain("'a satisfies B'"); expect(js).not.toContain('import type'); expect(js).toContain('const c = {'); expect(js).toContain('n: 1 }'); expect(js).toContain('export default c;'); }); }); describe('rt: serve', () => { test('fetch handler, file responses, 404', async () => { const dir = mkdtempSync(join(tmpdir(), 'sb-serve-')); writeFileSync(join(dir, 'a.txt'), 'file body'); const s = await rt.serve({ port: 0, hostname: '127.0.0.1', fetch(req) { const u = new URL(req.url); if (u.pathname === '/json') return Response.json({ q: u.searchParams.get('q') }); return rt.fileResponse(join(dir, u.pathname.slice(1)), { 'content-type': 'text/plain' }) ?? new Response('no', { status: 404 }); } }); try { const base = `http://127.0.0.1:${s.port}`; expect(await (await fetch(`${base}/json?q=1`)).json()).toEqual({ q: '1' }); const f = await fetch(`${base}/a.txt`); expect([f.status, f.headers.get('content-type'), await f.text()]).toEqual([200, 'text/plain', 'file body']); expect((await fetch(`${base}/missing`)).status).toBe(404); } finally { s.stop(true); rmSync(dir, { recursive: true, force: true }); } }); test('packageRoot is the storebound folder', () => { expect(rt.packageRoot()).toBe(ROOT); }); }); describe('rt: readLine', () => { test('one line, CRLF trimmed, null at EOF, stream paused afterwards', async () => { const { Readable } = await import('node:stream'); const a = Readable.from(['n\r\nmore\n']); expect(await rt.readLine(a)).toBe('n'); expect(a.isPaused()).toBe(true); expect(await rt.readLine(Readable.from(['\n']))).toBe(''); expect(await rt.readLine(Readable.from([]))).toBe(null); expect(await rt.readLine(Readable.from(['yes']))).toBe('yes'); }); });