/* streamer-tools OBS Camera Plugin - session wrapper tests Copyright (C) 2026 CyberCoveLLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 */ // What is and is not covered here, stated plainly because it matters: // // COVERED headlessly -- the session's own decision-making: the state // machine's transitions (including the publisher-swap and reconnect paths // that motivated this plugin), track selection, frame geometry validation, // and the real connect() failure paths against the real SDK (bad URL, // unreachable host, garbage token). // // NOT COVERED here -- anything that needs a LiveKit server to answer: // a successful connect, actual subscription, and actual decoded frames // reaching the handlers. Those can only be verified against a real room, // and the design doc's Testing section puts that in the integration-test / // manual-sign-off bucket. #include #include #include #include #include "stplugin/session.h" #include "stplugin/session_types.h" #include "test_util.h" using namespace stplugin; namespace { // --------------------------------------------------------------------------- // Pure logic // --------------------------------------------------------------------------- void testFrameGeometry() { ST_ASSERT_EQ(planeCount(PixelFormat::I420), 3); ST_ASSERT_EQ(planeCount(PixelFormat::NV12), 2); ST_ASSERT_EQ(planeCount(PixelFormat::BGRA), 1); // 1280x720 I420: 921600 luma + 2 * 230400 chroma. ST_ASSERT_EQ(expectedFrameBytes(PixelFormat::I420, 1280, 720), std::size_t(1382400)); ST_ASSERT_EQ(expectedFrameBytes(PixelFormat::NV12, 1280, 720), std::size_t(1382400)); ST_ASSERT_EQ(expectedFrameBytes(PixelFormat::BGRA, 1280, 720), std::size_t(3686400)); // Odd dimensions round the chroma planes up, the way libyuv does. ST_ASSERT_EQ(expectedFrameBytes(PixelFormat::I420, 3, 3), std::size_t(9 + 2 * 4)); ST_ASSERT_EQ(expectedFrameBytes(PixelFormat::I420, 1, 1), std::size_t(1 + 2)); // Degenerate geometry is 0, which the reader treats as "drop the frame". ST_ASSERT_EQ(expectedFrameBytes(PixelFormat::I420, 0, 720), std::size_t(0)); ST_ASSERT_EQ(expectedFrameBytes(PixelFormat::I420, 1280, 0), std::size_t(0)); ST_ASSERT_EQ(expectedFrameBytes(PixelFormat::I420, -1, -1), std::size_t(0)); ST_ASSERT_EQ(std::string(describePixelFormat(PixelFormat::I420)), std::string("I420")); } void testTrackSelection() { const std::string want = "cam1"; // The camera we asked for. ST_ASSERT(isWantedVideoTrack(want, "cam1", MediaKind::Video, MediaSource::Camera)); // A video track with no declared source is taken on kind alone. ST_ASSERT(isWantedVideoTrack(want, "cam1", MediaKind::Video, MediaSource::Unknown)); // Someone else's camera. ST_ASSERT(!isWantedVideoTrack(want, "cam2", MediaKind::Video, MediaSource::Camera)); // The right participant's screenshare is explicitly NOT the camera -- // streamer-tools publishes those as separate sources. ST_ASSERT(!isWantedVideoTrack(want, "cam1", MediaKind::Video, MediaSource::Screenshare)); // Their microphone is not a video track. ST_ASSERT(!isWantedVideoTrack(want, "cam1", MediaKind::Audio, MediaSource::Microphone)); // No selection means nothing matches -- never "the first thing we see". ST_ASSERT(!isWantedVideoTrack("", "cam1", MediaKind::Video, MediaSource::Camera)); ST_ASSERT(!isWantedVideoTrack("", "", MediaKind::Video, MediaSource::Camera)); ST_ASSERT(isWantedAudioTrack(want, "cam1", MediaKind::Audio, MediaSource::Microphone)); ST_ASSERT(isWantedAudioTrack(want, "cam1", MediaKind::Audio, MediaSource::Unknown)); ST_ASSERT(!isWantedAudioTrack(want, "cam1", MediaKind::Audio, MediaSource::ScreenshareAudio)); ST_ASSERT(!isWantedAudioTrack(want, "cam1", MediaKind::Video, MediaSource::Camera)); ST_ASSERT(!isWantedAudioTrack(want, "other", MediaKind::Audio, MediaSource::Microphone)); } void testStateMachineHappyPath() { SessionStateMachine m; ST_ASSERT(m.state() == SessionState::Idle); ST_ASSERT(!m.hasVideo()); ST_ASSERT(!m.waitingForCamera()); m.onConnectRequested(); ST_ASSERT(m.state() == SessionState::Connecting); // Connecting is not "waiting for camera": the placeholder belongs to a // live connection with a dark slot, not to a connection in progress. ST_ASSERT(!m.waitingForCamera()); m.onConnectSucceeded(); ST_ASSERT(m.state() == SessionState::Connected); ST_ASSERT(m.waitingForCamera()); m.onVideoAttached(); ST_ASSERT(m.hasVideo()); ST_ASSERT(!m.waitingForCamera()); m.onAudioAttached(); ST_ASSERT(m.hasAudio()); m.onLocalDisconnect(); ST_ASSERT(m.state() == SessionState::Disconnected); ST_ASSERT(!m.hasVideo()); ST_ASSERT(!m.hasAudio()); } void testPublisherSwapIsNotAnError() { // The motivating bug: a slot's publisher restarts mid-show. That must // read as "waiting for camera", never as a failure, and the connection // state must not move at all. SessionStateMachine m; m.onConnectRequested(); m.onConnectSucceeded(); m.onVideoAttached(); m.onVideoDetached(); ST_ASSERT(m.state() == SessionState::Connected); ST_ASSERT(!m.hasVideo()); ST_ASSERT(m.waitingForCamera()); ST_ASSERT(m.detail().empty()); m.onVideoAttached(); ST_ASSERT(m.state() == SessionState::Connected); ST_ASSERT(m.hasVideo()); ST_ASSERT(!m.waitingForCamera()); } void testReconnect() { SessionStateMachine m; m.onConnectRequested(); m.onConnectSucceeded(); m.onVideoAttached(); m.onReconnecting(); ST_ASSERT(m.state() == SessionState::Reconnecting); // Tracks are re-subscribed on the far side, so video is not live yet. ST_ASSERT(!m.hasVideo()); ST_ASSERT(m.waitingForCamera()); ST_ASSERT_EQ(m.detail(), std::string("reconnecting")); m.onReconnected(); ST_ASSERT(m.state() == SessionState::Connected); ST_ASSERT(m.detail().empty()); // A stray reconnect notification after a hard failure must not resurrect // the session. SessionStateMachine dead; dead.onConnectRequested(); dead.onConnectFailed("token rejected"); dead.onReconnecting(); ST_ASSERT(dead.state() == SessionState::Failed); dead.onReconnected(); ST_ASSERT(dead.state() == SessionState::Failed); } void testFailureAndRecovery() { SessionStateMachine m; m.onConnectRequested(); m.onConnectFailed("token rejected"); ST_ASSERT(m.state() == SessionState::Failed); ST_ASSERT_EQ(m.detail(), std::string("token rejected")); ST_ASSERT(!m.waitingForCamera()); // A fresh attempt clears the stale reason, so a healthy connection can // never be shown next to the previous failure's message. m.onConnectRequested(); ST_ASSERT(m.detail().empty()); m.onConnectSucceeded(); ST_ASSERT(m.state() == SessionState::Connected); ST_ASSERT(m.detail().empty()); // A fatal room end (duplicate identity, token rejected) is Failed; an // ordinary drop is Disconnected. SessionStateMachine fatal; fatal.onConnectRequested(); fatal.onConnectSucceeded(); fatal.onRoomEnded("another client joined with the same identity", true); ST_ASSERT(fatal.state() == SessionState::Failed); SessionStateMachine dropped; dropped.onConnectRequested(); dropped.onConnectSucceeded(); dropped.onRoomEnded("the signalling connection closed", false); ST_ASSERT(dropped.state() == SessionState::Disconnected); // Room-ended events after we are already down are ignored, so a late // event cannot overwrite the reason the operator needs to see. SessionStateMachine idle; idle.onRoomEnded("stray", true); ST_ASSERT(idle.state() == SessionState::Idle); } // --------------------------------------------------------------------------- // Real SDK, failure paths only (no LiveKit server available headlessly) // --------------------------------------------------------------------------- void testConnectRejectsIncompleteConfig() { LiveKitSession session; std::atomic state_calls{0}; session.setStateHandler([&](SessionState, const std::string &) { state_calls.fetch_add(1); }); SessionConfig config; config.ws_url = ""; config.token = "t"; config.participant_identity = "cam1"; ST_ASSERT(!session.connect(config)); ST_ASSERT(session.state() == SessionState::Failed); ST_ASSERT(!session.stateDetail().empty()); config.ws_url = "ws://127.0.0.1:1"; config.token = ""; ST_ASSERT(!session.connect(config)); ST_ASSERT(session.state() == SessionState::Failed); config.token = "t"; config.participant_identity = ""; ST_ASSERT(!session.connect(config)); ST_ASSERT(session.state() == SessionState::Failed); // The state handler fired for each attempt (Connecting + Failed). ST_ASSERT(state_calls.load() >= 6); // Frame counters stay at zero and nothing crashes on teardown. ST_ASSERT_EQ(session.videoFrameCount(), std::uint64_t(0)); ST_ASSERT_EQ(session.audioFrameCount(), std::uint64_t(0)); session.disconnect(); session.disconnect(); // idempotent ST_ASSERT(session.state() == SessionState::Failed || session.state() == SessionState::Disconnected); } void testConnectToUnreachableServerFailsCleanly() { // Port 1 on loopback: nothing is listening, and the connection is // refused immediately rather than hanging. This exercises the real // livekit::Room::connect() failure path, with a real (garbage) token. LiveKitSession session; std::atomic video_frames{0}; session.setVideoHandler([&](const VideoFrameData &) { video_frames.fetch_add(1); }); SessionConfig config; config.ws_url = "ws://127.0.0.1:1"; config.token = "not.a.real.token"; config.participant_identity = "cam1"; config.connect_timeout_ms = 3000; const auto start = std::chrono::steady_clock::now(); const bool ok = session.connect(config); const auto elapsed = std::chrono::steady_clock::now() - start; ST_ASSERT(!ok); ST_ASSERT(session.state() == SessionState::Failed); ST_ASSERT(!session.stateDetail().empty()); ST_ASSERT_EQ(video_frames.load(), 0); // Must not sit on the caller's thread indefinitely -- this runs on an OBS // thread in the real adapter. ST_ASSERT(std::chrono::duration_cast(elapsed).count() < 60); session.disconnect(); } void testConnectToNonLiveKitServerFailsCleanly() { // A URL that resolves and connects but is not a LiveKit signalling // endpoint. The realistic operator mistake: pasting the app URL. LiveKitSession session; SessionConfig config; config.ws_url = "ws://127.0.0.1:1/rtc"; config.token = "eyJhbGciOiJIUzI1NiJ9.bm90YXRva2Vu.x"; config.participant_identity = "cam1"; config.connect_timeout_ms = 3000; ST_ASSERT(!session.connect(config)); ST_ASSERT(session.state() == SessionState::Failed); session.disconnect(); } void testDestroyWithoutDisconnect() { // The OBS adapter destroys sources without necessarily having called // disconnect() first (an OBS shutdown mid-connect, say). The destructor // must join every thread it started rather than terminating. { LiveKitSession session; SessionConfig config; config.ws_url = "ws://127.0.0.1:1"; config.token = "t"; config.participant_identity = "cam1"; config.connect_timeout_ms = 2000; (void)session.connect(config); } ST_ASSERT(true); // reaching here at all is the assertion } void testGlobalInitIsReferenceCounted() { // Several OBS sources may each hold the SDK open; the last one out turns // the lights off, and an unbalanced extra shutdown must not underflow. LiveKitSession::globalInitialize(); LiveKitSession::globalInitialize(); LiveKitSession::globalShutdown(); LiveKitSession::globalShutdown(); LiveKitSession::globalShutdown(); // extra, must be harmless LiveKitSession::globalInitialize(); LiveKitSession::globalShutdown(); ST_ASSERT(true); } } // namespace int main() { testFrameGeometry(); testTrackSelection(); testStateMachineHappyPath(); testPublisherSwapIsNotAnError(); testReconnect(); testFailureAndRecovery(); LiveKitSession::globalInitialize(); testConnectRejectsIncompleteConfig(); testConnectToUnreachableServerFailsCleanly(); testConnectToNonLiveKitServerFailsCleanly(); testDestroyWithoutDisconnect(); LiveKitSession::globalShutdown(); testGlobalInitIsReferenceCounted(); return st_test_report("session"); }