Files
esports/streamer/windows/obs.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

383 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { afterEach, describe, expect, test } from 'bun:test';
import { mkdtemp, readFile, rm, writeFile, mkdir, readdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { Server } from 'bun';
import { Obs, obsAuthentication } from '../obs';
import { ObsScene, windowSetting, type SceneTarget } from './obs-control';
import { encoderFromLog, parseIni, setIni, stringifyIni, writeObsSettings } from './obs-files';
interface Item {
sceneItemId: number;
sourceName: string;
sceneItemIndex: number;
sceneItemEnabled: boolean;
transform: Record<string, unknown>;
}
/** Just enough obs-websocket 5 for the requests ObsScene makes, with an in-memory scene. */
function fakeObs(password: string, options: { ignore?: string[] } = {}) {
const model = {
scenes: ['Scene'],
program: 'Scene',
inputs: new Map<string, { kind: string; settings: Record<string, unknown> }>([
['Desktop Audio', { kind: 'wasapi_output_capture', settings: {} }]
]),
items: new Map<string, Item[]>([['Scene', []]]),
muted: new Set<string>(),
service: {
streamServiceType: 'rtmp_common',
streamServiceSettings: {} as Record<string, unknown>
},
sources: new Map<string, { width: number; height: number }>(),
requests: [] as string[],
nextId: 1
};
const salt = 'salt';
const challenge = 'challenge';
const handle = (type: string, data: Record<string, unknown>): Record<string, unknown> | null => {
const sceneItems = (scene: string) => model.items.get(scene) ?? [];
switch (type) {
case 'GetSceneList':
return {
scenes: model.scenes.map((sceneName) => ({ sceneName })),
currentProgramSceneName: model.program
};
case 'CreateScene':
model.scenes.push(String(data.sceneName));
model.items.set(String(data.sceneName), []);
return {};
case 'SetCurrentProgramScene':
model.program = String(data.sceneName);
return {};
case 'GetInputList':
return { inputs: [...model.inputs.keys()].map((inputName) => ({ inputName })) };
case 'CreateInput': {
const name = String(data.inputName);
model.inputs.set(name, {
kind: String(data.inputKind),
settings: { ...(data.inputSettings as object) }
});
const items = sceneItems(String(data.sceneName));
items.push({
sceneItemId: model.nextId++,
sourceName: name,
sceneItemIndex: items.length,
sceneItemEnabled: data.sceneItemEnabled !== false,
transform: {}
});
return {};
}
case 'GetInputSettings': {
const input = model.inputs.get(String(data.inputName))!;
return { inputKind: input.kind, inputSettings: input.settings };
}
case 'SetInputSettings':
Object.assign(model.inputs.get(String(data.inputName))!.settings, data.inputSettings);
return {};
case 'GetSceneItemList':
return {
sceneItems: sceneItems(String(data.sceneName)).map((item) => ({
sceneItemId: item.sceneItemId,
sourceName: item.sourceName,
sceneItemIndex: item.sceneItemIndex,
sceneItemEnabled: item.sceneItemEnabled
}))
};
case 'CreateSceneItem': {
const items = sceneItems(String(data.sceneName));
items.push({
sceneItemId: model.nextId++,
sourceName: String(data.sourceName),
sceneItemIndex: items.length,
sceneItemEnabled: true,
transform: {}
});
return {};
}
case 'SetSceneItemIndex': {
const items = sceneItems(String(data.sceneName));
const item = items.find((each) => each.sceneItemId === data.sceneItemId)!;
items.splice(items.indexOf(item), 1);
items.splice(Number(data.sceneItemIndex), 0, item);
items.forEach((each, index) => (each.sceneItemIndex = index));
return {};
}
case 'SetSceneItemEnabled':
sceneItems(String(data.sceneName)).find(
(each) => each.sceneItemId === data.sceneItemId
)!.sceneItemEnabled = data.sceneItemEnabled === true;
return {};
case 'GetSceneItemTransform': {
const item = sceneItems(String(data.sceneName)).find(
(each) => each.sceneItemId === data.sceneItemId
)!;
const size = model.sources.get(item.sourceName) ?? { width: 0, height: 0 };
return {
sceneItemTransform: {
sourceWidth: size.width,
sourceHeight: size.height,
cropBottom: 0,
boundsType: 'OBS_BOUNDS_NONE',
boundsWidth: 0,
boundsHeight: 0,
...item.transform
}
};
}
case 'SetSceneItemTransform':
Object.assign(
sceneItems(String(data.sceneName)).find((each) => each.sceneItemId === data.sceneItemId)!
.transform,
data.sceneItemTransform
);
return {};
case 'GetSpecialInputs':
return { desktop1: 'Desktop Audio', desktop2: null, mic1: null };
case 'SetInputMute':
model.muted.add(String(data.inputName));
return {};
case 'GetStreamServiceSettings':
return model.service;
case 'SetStreamServiceSettings':
model.service = data as typeof model.service;
return {};
default:
return null;
}
};
const server: Server<{ identified: boolean }> = Bun.serve<{ identified: boolean }>({
port: 0,
fetch(request, server) {
return server.upgrade(request, { data: { identified: false } })
? undefined
: new Response('no');
},
websocket: {
open(ws) {
ws.send(
JSON.stringify({ op: 0, d: { rpcVersion: 1, authentication: { salt, challenge } } })
);
},
message(ws, raw) {
const { op, d } = JSON.parse(String(raw));
if (op === 1) {
if (d.authentication !== obsAuthentication(password, salt, challenge)) {
ws.close(4009, 'auth');
return;
}
ws.data.identified = true;
ws.send(JSON.stringify({ op: 2, d: { negotiatedRpcVersion: 1 } }));
return;
}
if (op !== 6 || !ws.data.identified) return;
model.requests.push(d.requestType);
if (options.ignore?.includes(d.requestType)) return;
const result = handle(d.requestType, d.requestData ?? {});
ws.send(
JSON.stringify({
op: 7,
d: {
requestType: d.requestType,
requestId: d.requestId,
requestStatus: result
? { result: true, code: 100 }
: { result: false, code: 204, comment: 'unknown' },
responseData: result ?? undefined
}
})
);
}
}
});
return { server, model, url: `ws://127.0.0.1:${server.port}` };
}
const servers: Server<unknown>[] = [];
afterEach(() => {
for (const server of servers.splice(0)) server.stop(true);
});
const target = (overrides: Partial<SceneTarget> = {}): SceneTarget => ({
canvas: { width: 1920, height: 1080 },
fps: 60,
overlayUrl: 'https://esports.vash.software/broadcast/abc/overlay',
capture: { title: 'Vash osu!tourney (prod)', className: 'WindowsForms10', exe: 'osu!.exe' },
showGameplay: false,
ingest: { server: 'rtmp://127.0.0.1:47103/live', key: 'k' },
...overrides
});
describe('Obs client', () => {
test('reports a wrong websocket password clearly', async () => {
const fake = fakeObs('right');
servers.push(fake.server);
const obs = new Obs(fake.url, 'wrong', 2000);
await expect(obs.connect()).rejects.toThrow('OBS rejected the websocket password');
});
test('connected means authenticated, and a hung request times out', async () => {
const fake = fakeObs('pw', { ignore: ['GetStreamStatus'] });
servers.push(fake.server);
const obs = new Obs(fake.url, 'pw', 300);
expect(obs.connected).toBe(false);
await obs.connect();
expect(obs.connected).toBe(true);
await expect(obs.request('GetStreamStatus')).rejects.toThrow('OBS GetStreamStatus timed out');
await Bun.sleep(400);
obs.close();
expect(obs.connected).toBe(false);
});
});
describe('ObsScene', () => {
test('builds the scene once, keeps the overlay on top, and changes nothing on a second pass', async () => {
const fake = fakeObs('pw');
servers.push(fake.server);
const obs = new Obs(fake.url, 'pw');
await obs.connect();
const scene = new ObsScene(obs, () => {});
await scene.reconcile(target());
const items = fake.model.items.get('Vash')!;
expect(fake.model.program).toBe('Vash');
expect(items.map((item) => item.sourceName)).toEqual(['Gameplay', 'Overlay']);
expect(items[0].sceneItemEnabled).toBe(false);
expect(fake.model.inputs.get('Gameplay')!.settings).toMatchObject({
window: 'Vash osu!tourney (prod):WindowsForms10:osu!.exe',
priority: 1,
capture_audio: true
});
expect(fake.model.inputs.get('Overlay')!.settings).toMatchObject({
width: 1920,
height: 1080,
fps: 60
});
expect(fake.model.muted.has('Desktop Audio')).toBe(true);
expect(fake.model.service.streamServiceSettings).toMatchObject({
server: 'rtmp://127.0.0.1:47103/live',
key: 'k'
});
fake.model.requests.length = 0;
await scene.reconcile(target());
expect(fake.model.requests.filter((type) => /^(Create|Set|Remove)/.test(type))).toEqual([]);
// Someone reorders the scene: a changed target re-checks everything.
scene.reset();
const overlay = items.find((item) => item.sourceName === 'Overlay')!;
items.splice(items.indexOf(overlay), 1);
items.unshift(overlay);
items.forEach((item, index) => (item.sceneItemIndex = index));
await scene.reconcile(target());
expect(fake.model.items.get('Vash')!.at(-1)!.sourceName).toBe('Overlay');
obs.close();
});
test('crops the control panel off the capture and shows it only while spectating', async () => {
const fake = fakeObs('pw');
servers.push(fake.server);
const obs = new Obs(fake.url, 'pw');
await obs.connect();
const scene = new ObsScene(obs, () => {});
await scene.reconcile(target());
// At 125 % display scaling the 1280×920 window captures as 1600×1150.
fake.model.sources.set('Gameplay', { width: 1600, height: 1150 });
await scene.reconcile(target({ showGameplay: true }));
const gameplay = fake.model.items.get('Vash')!.find((item) => item.sourceName === 'Gameplay')!;
expect(gameplay.sceneItemEnabled).toBe(true);
expect(gameplay.transform).toMatchObject({
cropBottom: 250,
boundsType: 'OBS_BOUNDS_SCALE_INNER',
boundsWidth: 1920,
boundsHeight: 1080
});
await scene.reconcile(target({ showGameplay: false }));
expect(gameplay.sceneItemEnabled).toBe(false);
obs.close();
});
});
describe('OBS files', () => {
let dir: string;
afterEach(async () => {
if (dir) await rm(dir, { recursive: true, force: true });
});
test('escapes window settings like libobs', () => {
expect(windowSetting({ title: 'a:b#c', className: 'X', exe: 'osu!.exe' })).toBe(
'a#3Ab#22c:X:osu!.exe'
);
});
test('ini round trip keeps unknown keys', () => {
const ini = parseIni('\uFEFF[General]\r\nName=Old\r\nKeep=1\r\n[Video]\r\nBaseCX=1280\r\n');
setIni(ini, { General: { Name: 'Vash' }, Audio: { SampleRate: 48000 } });
expect(stringifyIni(ini)).toBe(
'[General]\r\nName=Vash\r\nKeep=1\r\n\r\n[Video]\r\nBaseCX=1280\r\n\r\n[Audio]\r\nSampleRate=48000\r\n'
);
});
test('writes profile, websocket and first-run settings, and clears the crash sentinel', async () => {
dir = await mkdtemp(join(tmpdir(), 'vash-obs-'));
await mkdir(join(dir, '.sentinel'), { recursive: true });
await writeFile(join(dir, '.sentinel', 'run_123'), '');
await mkdir(join(dir, 'basic', 'profiles', 'Vash'), { recursive: true });
await writeFile(join(dir, 'basic', 'profiles', 'Vash', 'basic.ini'), '[Hotkeys]\r\nX=1\r\n');
await writeObsSettings(dir, {
websocketPort: 47101,
websocketPassword: 'pw',
width: 1920,
height: 1080,
fps: 60,
bitrateKbps: 6000,
encoder: 'amd',
ingestUrl: 'rtmp://127.0.0.1:47103/live',
ingestKey: 'key'
});
const profile = parseIni(
await readFile(join(dir, 'basic', 'profiles', 'Vash', 'basic.ini'), 'utf8')
);
expect(profile.get('SimpleOutput')?.get('StreamEncoder')).toBe('amd');
expect(profile.get('Video')?.get('FPSInt')).toBe('60');
expect(profile.get('Hotkeys')?.get('X')).toBe('1');
const user = parseIni(await readFile(join(dir, 'user.ini'), 'utf8'));
expect(user.get('General')?.get('FirstRun')).toBe('true');
expect(user.get('Basic')?.get('Profile')).toBe('Vash');
const websocket = JSON.parse(
await readFile(join(dir, 'plugin_config', 'obs-websocket', 'config.json'), 'utf8')
);
expect(websocket).toMatchObject({
server_enabled: true,
server_port: 47101,
auth_required: true,
server_password: 'pw',
first_load: false
});
const service = JSON.parse(
await readFile(join(dir, 'basic', 'profiles', 'Vash', 'service.json'), 'utf8')
);
expect(service).toEqual({
type: 'rtmp_custom',
settings: {
server: 'rtmp://127.0.0.1:47103/live',
key: 'key',
use_auth: false,
bwtest: false
}
});
expect(await readdir(join(dir, '.sentinel'))).toEqual([]);
});
test('reads which encoder OBS started last from its log', () => {
expect(
encoderFromLog("12:00:00.000: [x264 encoder: 'simple_video_stream'] preset: veryfast")
).toBe('x264');
expect(
encoderFromLog(
"[x264 encoder: 'simple_video_stream'] preset: veryfast\n[texture-amf-h264: 'simple_video_stream'] settings:"
)
).toBe('amd');
expect(encoderFromLog('no encoders here')).toBeNull();
});
});