- streamer/windows: vash-broadcast.exe (setup/login/start/stop/status/doctor) with per-instance OBS, tosu and FFmpeg, a supervised worker, OCR room selection and OBS scene reconciliation; built and tested on Linux only - streamer/relay.ts shared with Vash TV; Cloudflare Access proxy for dev/staging - worker status validation and live score helpers for the site - STREAMING_HANDOFF.md and streamer/ALPHA_TEST.md record what is unverified Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
232 lines
7.6 KiB
TypeScript
232 lines
7.6 KiB
TypeScript
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<number | null>((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<typeof fakeChild>[] = [];
|
|
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')
|
|
);
|
|
});
|
|
});
|