import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { createHash } from 'node:crypto'; import { existsSync } from 'node:fs'; import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { allocatePorts, instanceDir, layout, listInstances, portsForSlot, saveConfig, validateConfig, validateServer, type InstanceConfig } from './config'; import { fetchPinned, osuCandidates, setSubsystem, subsystemOf, tosuEnv } from './install'; import { acquireLock, lockOwner, releaseLock } from './lock'; import { Supervised, type Child } from './supervise'; let home: string; beforeEach(async () => { home = await mkdtemp(join(tmpdir(), 'vash-home-')); process.env.VASH_BROADCAST_HOME = home; }); afterEach(async () => { delete process.env.VASH_BROADCAST_HOME; await rm(home, { recursive: true, force: true }); }); const config = (name: string, slot: number): InstanceConfig => ({ version: 1, name, server: 'https://esports.vash.software', channel: '0b7f3c2a-1d2e-4f5a-8b9c-0d1e2f3a4b5c', token: 'vash_token', access: null, ports: portsForSlot(slot), obsPassword: 'a', ingestKey: 'b', osuSource: null, tourneyHeight: 720, encoder: 'amd', autostart: true }); describe('instances', () => { test('each instance gets its own folder, programs and ports', async () => { await saveConfig(config('dev', 0)); const others = await listInstances(); const staging = { ...config('staging', 1), ports: allocatePorts(others.map((each) => each.ports)) }; await saveConfig(staging); const prod = { ...config('prod', 0), ports: allocatePorts((await listInstances()).map((each) => each.ports)) }; const all = [config('dev', 0), staging, prod]; const ports = all.flatMap((each) => Object.values(each.ports)); expect(new Set(ports).size).toBe(ports.length); const dirs = all.map((each) => layout(each.name)); for (const key of ['dir', 'obs', 'osu', 'tosu', 'ffmpeg', 'hls', 'log', 'state'] as const) expect(new Set(dirs.map((paths) => paths[key])).size).toBe(3); // Programs live under their instance folder, so stopping one never touches another. for (const paths of dirs) for (const key of ['obsExe', 'osuExe', 'tosuExe', 'ffmpeg'] as const) expect(paths[key].startsWith(paths.dir)).toBe(true); expect(layout('dev').lock).toBe(layout('prod').lock); }); test('rejects bad names, servers and configs', () => { expect(() => instanceDir('../prod')).toThrow(); expect(() => instanceDir('Prod')).toThrow(); expect(validateServer('https://esports.vash.software/broadcast')).toBe( 'https://esports.vash.software' ); expect(() => validateServer('http://esports.vash.software')).toThrow('https'); expect(validateServer('http://127.0.0.1:3102')).toBe('http://127.0.0.1:3102'); expect(() => validateConfig({ ...config('dev', 0), channel: 'nope' })).toThrow('channel'); expect(() => validateConfig({ ...config('dev', 0), encoder: 'nvenc' })).toThrow('encoder'); }); }); describe('tourney lock', () => { test('one instance at a time; a dead holder is replaced; only the holder releases it', async () => { const path = join(home, 'tourney.lock'); expect(await acquireLock(path, 'prod', process.pid)).toEqual({ ok: true }); // Another live process (this test's parent stands in) cannot take it. const other = await acquireLock(path, 'dev', process.ppid); expect(other).toMatchObject({ ok: false, owner: { instance: 'prod' } }); await releaseLock(path, process.ppid); expect(existsSync(path)).toBe(true); await releaseLock(path, process.pid); expect(existsSync(path)).toBe(false); await writeFile(path, JSON.stringify({ instance: 'staging', pid: 2 ** 22 + 12345, at: '' })); expect(await lockOwner(path)).toBeNull(); expect(await acquireLock(path, 'dev', process.pid)).toEqual({ ok: true }); }); }); describe('Supervised', () => { function fakeChild(pid: number) { let exit!: (code: number) => void; const child: Child & { exit: (code: number) => void; killed: boolean } = { pid, exited: new Promise((resolve) => (exit = resolve)), killed: false, kill() { child.killed = true; exit(1); }, exit: (code) => exit(code) }; return child; } test('restarts with growing delays, forgets failures after a stable run, and stays stopped', async () => { let now = 0; const children: ReturnType[] = []; const lines: string[] = []; const supervised = new Supervised( 'OBS', () => { const child = fakeChild(children.length + 1); children.push(child); return child; }, { log: (line) => lines.push(String(line)), now: () => now, stableMs: 60_000 } ); await supervised.ensure(); expect(supervised.state).toBe('running'); children[0].exit(3); await Bun.sleep(0); expect(supervised.state).toBe('backoff'); expect(lines.at(-1)).toBe('OBS exited with 3; restarting in 2s'); now += 1000; await supervised.ensure(); expect(children).toHaveLength(1); now += 1000; await supervised.ensure(); expect(children).toHaveLength(2); children[1].exit(1); await Bun.sleep(0); expect(lines.at(-1)).toContain('restarting in 4s'); now += 4000; await supervised.ensure(); now += 120_000; children[2].exit(1); await Bun.sleep(0); expect(lines.at(-1)).toContain('restarting in 2s'); now += 2000; await supervised.ensure(); await supervised.stop(); expect(children[3].killed).toBe(true); expect(supervised.state).toBe('stopped'); now += 60_000; expect(supervised.restarts).toBe(3); }); }); describe('install', () => { test('switches a PE executable between console and windowless', () => { // MZ header pointing at a PE header with a PE32+ optional header. const exe = new Uint8Array(512); const view = new DataView(exe.buffer); view.setUint16(0, 0x5a4d, true); view.setUint32(0x3c, 0x80, true); view.setUint32(0x80, 0x00004550, true); view.setUint16(0x80 + 24, 0x20b, true); view.setUint32(0x80 + 24 + 64, 0x1234, true); view.setUint16(0x80 + 24 + 68, 3, true); setSubsystem(exe, 2); expect(subsystemOf(exe)).toBe(2); expect(view.getUint32(0x80 + 24 + 64, true)).toBe(0); expect(() => setSubsystem(new Uint8Array(512), 2)).toThrow('Not an MZ executable'); }); test('downloads only what matches the pinned checksum', async () => { const body = new TextEncoder().encode('pinned release bytes'); const server = Bun.serve({ port: 0, fetch: () => new Response(body) }); const downloads = join(home, 'downloads'); const sha256 = createHash('sha256').update(body).digest('hex'); const url = `http://127.0.0.1:${server.port}/tool.zip`; try { const path = await fetchPinned( { name: 'tool', version: '1', url, sha256, bytes: body.length }, downloads, () => {} ); expect(path).toBe(join(downloads, 'tool.zip')); await expect( fetchPinned( { name: 'bad', version: '1', url: url.replace('tool', 'bad'), sha256: '0'.repeat(64), bytes: 0 }, downloads, () => {} ) ).rejects.toThrow('did not match its pinned SHA-256'); expect(await readdir(downloads)).toEqual(['tool.zip']); } finally { server.stop(true); } }); test('tosu gets its port, no auto-update and no browser', () => { const env = tosuEnv(47102); expect(env).toContain('SERVER_PORT=47102'); expect(env).toContain('SERVER_IP=127.0.0.1'); expect(env).toContain('ENABLE_AUTOUPDATE=false'); expect(env).toContain('OPEN_DASHBOARD_ON_STARTUP=false'); }); test('looks for osu! where stable installs itself', () => { expect(osuCandidates({ LOCALAPPDATA: 'C:\\Users\\stan\\AppData\\Local' })[0]).toBe( join('C:\\Users\\stan\\AppData\\Local', 'osu!', 'osu!.exe') ); }); });