- 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>
132 lines
4.2 KiB
TypeScript
132 lines
4.2 KiB
TypeScript
import { afterEach, beforeEach, expect, test } from 'bun:test';
|
|
import { existsSync } from 'node:fs';
|
|
import { mkdtemp, rm } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import type { Server } from 'bun';
|
|
import { layout, portsForSlot, readJson, saveConfig } from './config';
|
|
import { callWorker } from './control';
|
|
import { FakeDesktop } from './fakes';
|
|
import { runWorker, type WorkerState } from './worker';
|
|
|
|
const CHANNEL = '0b7f3c2a-1d2e-4f5a-8b9c-0d1e2f3a4b5c';
|
|
let home: string;
|
|
let vash: Server<undefined>;
|
|
const received: Record<string, unknown>[] = [];
|
|
const posts: Record<string, unknown>[] = [];
|
|
let live = false;
|
|
|
|
beforeEach(async () => {
|
|
home = await mkdtemp(join(tmpdir(), 'vash-worker-'));
|
|
process.env.VASH_BROADCAST_HOME = home;
|
|
received.length = 0;
|
|
posts.length = 0;
|
|
live = false;
|
|
vash = Bun.serve({
|
|
port: 0,
|
|
async fetch(request, server) {
|
|
const url = new URL(request.url);
|
|
if (url.pathname === '/ws/agent')
|
|
return server.upgrade(request) ? undefined : new Response('no');
|
|
if (request.headers.get('authorization') !== 'Bearer vash_test')
|
|
return new Response('no', { status: 401 });
|
|
if (url.pathname === `/api/broadcast/${CHANNEL}/stream` && request.method === 'GET')
|
|
return Response.json({
|
|
live,
|
|
name: 'Main',
|
|
width: 1920,
|
|
height: 1080,
|
|
fps: 60,
|
|
bitrateKbps: 6000,
|
|
overlayUrl: `${url.origin}/broadcast/${CHANNEL}/overlay`,
|
|
record: false,
|
|
destinations: []
|
|
});
|
|
if (url.pathname === `/api/broadcast/${CHANNEL}/stream`) {
|
|
const body = (await request.json()) as Record<string, unknown>;
|
|
posts.push(body);
|
|
return Response.json(
|
|
body.event === 'start' ? { id: 'session-1', storagePrefix: null } : { ok: true }
|
|
);
|
|
}
|
|
return new Response('not found', { status: 404 });
|
|
},
|
|
websocket: {
|
|
message(ws, raw) {
|
|
const message = JSON.parse(String(raw));
|
|
received.push(message);
|
|
if (message.type === 'hello') {
|
|
ws.send(JSON.stringify({ type: 'welcome', channel: 'Main' }));
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: 'follow',
|
|
scene: 'idle',
|
|
matchId: null,
|
|
lobbyId: null,
|
|
roomName: null,
|
|
teamSize: null,
|
|
title: null
|
|
})
|
|
);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
await saveConfig({
|
|
version: 1,
|
|
name: 'test',
|
|
server: `http://127.0.0.1:${vash.port}`,
|
|
channel: CHANNEL,
|
|
token: 'vash_test',
|
|
access: null,
|
|
ports: portsForSlot(40 + Math.floor(Math.random() * 9)),
|
|
obsPassword: 'pw',
|
|
ingestKey: 'key',
|
|
osuSource: null,
|
|
tourneyHeight: 720,
|
|
encoder: 'amd',
|
|
autostart: false
|
|
});
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vash.stop(true);
|
|
delete process.env.VASH_BROADCAST_HOME;
|
|
await rm(home, { recursive: true, force: true });
|
|
});
|
|
|
|
test('runs without its programs, reports why, follows Go live, and stops cleanly', async () => {
|
|
const paths = layout('test');
|
|
const desktop = new FakeDesktop();
|
|
const done = runWorker('test', desktop);
|
|
let status: WorkerState | null = null;
|
|
for (let waited = 0; waited < 80 && !status?.vash.connected; waited++) {
|
|
await Bun.sleep(100);
|
|
status = await callWorker<WorkerState>(paths.runtime, '/status');
|
|
}
|
|
expect(status?.vash).toMatchObject({ connected: true, channel: 'Main' });
|
|
// OBS is not installed here, so the worker says it is (re)starting it rather than claiming success.
|
|
expect(['starting', 'reconnecting']).toContain(status!.state);
|
|
expect(status!.tourney.state).not.toBe('spectating');
|
|
expect(received.some((message) => message.type === 'status' && message.instance === 'test')).toBe(
|
|
true
|
|
);
|
|
expect(existsSync(paths.lock)).toBe(true);
|
|
|
|
live = true;
|
|
for (let waited = 0; waited < 80 && !posts.length; waited++) await Bun.sleep(100);
|
|
expect(posts[0]).toEqual({ event: 'start' });
|
|
|
|
expect(await callWorker<{ ok: boolean }>(paths.runtime, '/stop', { method: 'POST' })).toEqual({
|
|
ok: true
|
|
});
|
|
expect(await done).toBe(0);
|
|
expect(posts.at(-1)).toMatchObject({ event: 'end', sessionId: 'session-1' });
|
|
expect(existsSync(paths.runtime)).toBe(false);
|
|
expect(existsSync(paths.lock)).toBe(false);
|
|
expect((await readJson<WorkerState>(paths.state))?.state).toBe('stopped');
|
|
expect(
|
|
received.some((message) => message.type === 'status' && message.state === 'stopping')
|
|
).toBe(true);
|
|
}, 30_000);
|