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; } /** 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 }>([ ['Desktop Audio', { kind: 'wasapi_output_capture', settings: {} }] ]), items: new Map([['Scene', []]]), muted: new Set(), service: { streamServiceType: 'rtmp_common', streamServiceSettings: {} as Record }, sources: new Map(), requests: [] as string[], nextId: 1 }; const salt = 'salt'; const challenge = 'challenge'; const handle = (type: string, data: Record): Record | 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[] = []; afterEach(() => { for (const server of servers.splice(0)) server.stop(true); }); const target = (overrides: Partial = {}): 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(); }); });