diff --git a/Tests/PlayMode/SnapshotParticipantEventsTests.cs b/Tests/PlayMode/SnapshotParticipantEventsTests.cs new file mode 100644 index 00000000..504b4a25 --- /dev/null +++ b/Tests/PlayMode/SnapshotParticipantEventsTests.cs @@ -0,0 +1,107 @@ +using System.Collections; +using System.Collections.Generic; +using LiveKit.PlayModeTests.Utils; +using NUnit.Framework; +using UnityEngine.TestTools; + +namespace LiveKit.PlayModeTests +{ + /// + /// Reproduces the "agent dispatched at connect time" report: a remote + /// participant that is already in the room when the local participant + /// connects arrives in the connect snapshot instead of as a + /// ParticipantConnected delta — and the SDK never raises + /// ParticipantConnected for it, even for handlers wired before Connect(). + /// Apps that drive their "remote participant joined → subscribe/render" + /// logic purely from that event never learn the participant exists, + /// although it is present in Room.RemoteParticipants. + /// + /// Whether a concurrently connecting participant (typically an agent) + /// lands in the snapshot or in the event stream is a server-side race, so + /// in the field this manifests intermittently. The test makes the losing + /// side deterministic by fully connecting the "agent" first. + /// + public class SnapshotParticipantEventsTests + { + const float ControlEventTimeoutSeconds = 15f; + + static (TestRoomContext.ConnectionOptions agent, + TestRoomContext.ConnectionOptions subscriber, + TestRoomContext.ConnectionOptions lateJoiner) ThreePeers() + { + var agent = TestRoomContext.ConnectionOptions.Default; + agent.Identity = "snapshot-agent"; + var subscriber = TestRoomContext.ConnectionOptions.Default; + subscriber.Identity = "snapshot-subscriber"; + var lateJoiner = TestRoomContext.ConnectionOptions.Default; + lateJoiner.Identity = "snapshot-late-joiner"; + return (agent, subscriber, lateJoiner); + } + + [UnityTest, Category("E2E")] + public IEnumerator Connect_RaisesParticipantConnected_ForParticipantAlreadyInRoom() + { + var (agentOptions, subscriberOptions, lateJoinerOptions) = ThreePeers(); + using var context = new TestRoomContext(new[] { agentOptions, subscriberOptions, lateJoinerOptions }); + + // 1. The "agent" is fully connected before the subscriber starts + // connecting, guaranteeing it is part of the subscriber's + // connect snapshot rather than a ParticipantConnected delta. + yield return context.ConnectRoom(0); + Assert.IsNull(context.ConnectionError, context.ConnectionError); + + var agentIdentity = context.Rooms[0].LocalParticipant.Identity; + var lateJoinerIdentity = lateJoinerOptions.Identity; + var subscriberRoom = context.Rooms[1]; + + // 2. Wire ParticipantConnected BEFORE Connect() — the earliest + // possible subscription an app can make. + var connectedIdentities = new List(); + subscriberRoom.ParticipantConnected += participant => + { + lock (connectedIdentities) connectedIdentities.Add(participant.Identity); + }; + + // 3. Subscriber joins; the agent is already in the room. + yield return context.ConnectRoom(1); + Assert.IsNull(context.ConnectionError, context.ConnectionError); + + // 4. Control: a participant joining AFTER the subscriber must fire + // ParticipantConnected via the regular delta path. Room events + // are delivered in order, so once the control event has fired, + // any event for the agent would already have been dispatched. + // This keeps the failing case fast and proves the handler + // wiring works. + yield return context.ConnectRoom(2); + Assert.IsNull(context.ConnectionError, context.ConnectionError); + + var controlEvent = new Expectation( + predicate: () => + { + lock (connectedIdentities) return connectedIdentities.Contains(lateJoinerIdentity); + }, + timeoutSeconds: ControlEventTimeoutSeconds); + yield return controlEvent.Wait(); + Assert.IsNull(controlEvent.Error, + $"Control failed: ParticipantConnected never fired for the late joiner " + + $"'{lateJoinerIdentity}' — event delivery is broken beyond the snapshot case. " + + $"Received: [{string.Join(", ", connectedIdentities)}]"); + + // 5. The snapshot data itself must have arrived: the agent is + // visible in RemoteParticipants. This isolates the defect to + // event emission, not data delivery. + Assert.IsTrue(subscriberRoom.RemoteParticipants.ContainsKey(agentIdentity), + $"Agent '{agentIdentity}' missing from RemoteParticipants — snapshot itself was lost"); + + // 6. The repro assertion: ParticipantConnected must also fire for + // the participant that was already in the room at connect time. + bool agentConnectedFired; + lock (connectedIdentities) agentConnectedFired = connectedIdentities.Contains(agentIdentity); + Assert.IsTrue(agentConnectedFired, + $"ParticipantConnected never fired for '{agentIdentity}', which was already in the " + + $"room when the subscriber connected (snapshot participant). It IS present in " + + $"RemoteParticipants, so only the event is missing. " + + $"Received: [{string.Join(", ", connectedIdentities)}]"); + } + } +} diff --git a/Tests/PlayMode/SnapshotParticipantEventsTests.cs.meta b/Tests/PlayMode/SnapshotParticipantEventsTests.cs.meta new file mode 100644 index 00000000..c305650a --- /dev/null +++ b/Tests/PlayMode/SnapshotParticipantEventsTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fb62102e6f034de1b527e7d150663c57 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: