Files
esports/streamer/windows/runtime.test.ts
Stan Runge 54444a77f0 feat: add native Windows broadcaster and live score modules
- 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>
2026-10-18 22:02:25 +00:00

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')
);
});
});