Skip to content

Commit 91bc88a

Browse files
authored
E2E Test: basic tests. (#641)
1 parent e6b6d40 commit 91bc88a

1 file changed

Lines changed: 221 additions & 0 deletions

File tree

‎livekit-rtc/tests/test_basic.py‎

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
"""End-to-end Test for basic scenarios.
2+
3+
The test exercises connect/disconnect lifecycle, participant
4+
visibility, reconnection, and track publish/subscribe between four rooms.
5+
6+
Requires the following environment variables to run:
7+
LIVEKIT_URL
8+
LIVEKIT_API_KEY
9+
LIVEKIT_API_SECRET
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import asyncio
15+
import os
16+
import uuid
17+
from typing import Callable, Optional
18+
19+
import pytest
20+
21+
from livekit import api, rtc
22+
from livekit.rtc.room import EventTypes
23+
24+
25+
WAIT_TIMEOUT = 20.0
26+
WAIT_INTERVAL = 0.1
27+
28+
29+
def skip_if_no_credentials():
30+
required_vars = ["LIVEKIT_URL", "LIVEKIT_API_KEY", "LIVEKIT_API_SECRET"]
31+
missing = [var for var in required_vars if not os.getenv(var)]
32+
return pytest.mark.skipif(
33+
bool(missing), reason=f"Missing environment variables: {', '.join(missing)}"
34+
)
35+
36+
37+
def create_token(identity: str, room_name: str) -> str:
38+
return (
39+
api.AccessToken()
40+
.with_identity(identity)
41+
.with_name(identity)
42+
.with_grants(
43+
api.VideoGrants(
44+
room_join=True,
45+
room=room_name,
46+
)
47+
)
48+
.to_jwt()
49+
)
50+
51+
52+
def unique_room_name(base: str) -> str:
53+
return f"{base}-{uuid.uuid4().hex[:8]}"
54+
55+
56+
async def _wait_until(
57+
predicate: Callable[[], bool],
58+
*,
59+
timeout: float = WAIT_TIMEOUT,
60+
interval: float = WAIT_INTERVAL,
61+
message: str = "condition not met",
62+
) -> None:
63+
loop = asyncio.get_event_loop()
64+
deadline = loop.time() + timeout
65+
while loop.time() < deadline:
66+
if predicate():
67+
return
68+
await asyncio.sleep(interval)
69+
raise AssertionError(f"timeout waiting: {message}")
70+
71+
72+
async def _connect(room: rtc.Room, identity: str, room_name: str) -> str:
73+
"""Mints a token, connects `room`, and returns the token (for reconnect)."""
74+
token = create_token(identity, room_name)
75+
url = os.environ["LIVEKIT_URL"]
76+
await room.connect(url, token)
77+
return token
78+
79+
80+
async def _ensure_all_connected(rooms: list[rtc.Room]) -> None:
81+
await _wait_until(
82+
lambda: all(r.connection_state == rtc.ConnectionState.CONN_CONNECTED for r in rooms),
83+
message="not all rooms reached CONN_CONNECTED",
84+
)
85+
86+
87+
async def _ensure_track_subscribed(room: rtc.Room, track_sid: str) -> None:
88+
def _has_subscribed() -> bool:
89+
for participant in room.remote_participants.values():
90+
pub = participant.track_publications.get(track_sid)
91+
if pub is not None and pub.subscribed:
92+
return True
93+
return False
94+
95+
await _wait_until(
96+
_has_subscribed,
97+
message=f"room did not subscribe to track {track_sid}",
98+
)
99+
100+
101+
def _expect_event(
102+
room: rtc.Room,
103+
event: EventTypes,
104+
predicate: Optional[Callable[..., bool]] = None,
105+
) -> asyncio.Future:
106+
"""Returns a future that resolves when `event` (optionally matching
107+
`predicate`) is fired on `room`."""
108+
loop = asyncio.get_event_loop()
109+
fut: asyncio.Future = loop.create_future()
110+
111+
def _on_event(*args, **kwargs) -> None:
112+
if fut.done():
113+
return
114+
if predicate is None or predicate(*args, **kwargs):
115+
fut.set_result(args)
116+
117+
room.on(event, _on_event)
118+
return fut
119+
120+
121+
async def _await_event(fut: asyncio.Future, timeout: float = WAIT_TIMEOUT) -> None:
122+
try:
123+
await asyncio.wait_for(fut, timeout=timeout)
124+
except asyncio.TimeoutError as e:
125+
raise AssertionError("timed out waiting for event") from e
126+
127+
128+
async def _publish_video(room: rtc.Room, track_name: str) -> rtc.LocalTrackPublication:
129+
source = rtc.VideoSource(320, 240)
130+
track = rtc.LocalVideoTrack.create_video_track(track_name, source)
131+
options = rtc.TrackPublishOptions(source=rtc.TrackSource.SOURCE_CAMERA)
132+
return await room.local_participant.publish_track(track, options)
133+
134+
135+
async def _publish_audio(room: rtc.Room, track_name: str) -> rtc.LocalTrackPublication:
136+
source = rtc.AudioSource(48000, 1)
137+
track = rtc.LocalAudioTrack.create_audio_track(track_name, source)
138+
options = rtc.TrackPublishOptions(source=rtc.TrackSource.SOURCE_MICROPHONE)
139+
return await room.local_participant.publish_track(track, options)
140+
141+
142+
@skip_if_no_credentials()
143+
@pytest.mark.asyncio
144+
async def test_connection_basics() -> None:
145+
room_name = unique_room_name("py-basics")
146+
147+
p1, p2 = rtc.Room(), rtc.Room()
148+
await _connect(p1, "p1", room_name)
149+
await _connect(p2, "p2", room_name)
150+
await _ensure_all_connected([p1, p2])
151+
152+
# p2 should observe p1 leaving
153+
p2_saw_p1_left = _expect_event(
154+
p2,
155+
"participant_disconnected",
156+
predicate=lambda p: p.identity == "p1",
157+
)
158+
await p1.disconnect()
159+
await _await_event(p2_saw_p1_left)
160+
161+
await _wait_until(
162+
lambda: p1.connection_state == rtc.ConnectionState.CONN_DISCONNECTED,
163+
message="p1 did not reach CONN_DISCONNECTED",
164+
)
165+
166+
await p2.disconnect()
167+
await _wait_until(
168+
lambda: p2.connection_state == rtc.ConnectionState.CONN_DISCONNECTED,
169+
message="p2 did not reach CONN_DISCONNECTED",
170+
)
171+
172+
# p3: connect, disconnect, reconnect, disconnect cycle
173+
p3 = rtc.Room()
174+
p3_token = await _connect(p3, "p3", room_name)
175+
p3_url = os.environ["LIVEKIT_URL"]
176+
177+
await p3.disconnect()
178+
assert p3.connection_state == rtc.ConnectionState.CONN_DISCONNECTED, (
179+
f"expected p3 disconnected, got {p3.connection_state}"
180+
)
181+
182+
await p3.connect(p3_url, p3_token)
183+
assert p3.connection_state == rtc.ConnectionState.CONN_CONNECTED, (
184+
f"expected p3 connected, got {p3.connection_state}"
185+
)
186+
187+
await p3.disconnect()
188+
assert p3.connection_state == rtc.ConnectionState.CONN_DISCONNECTED, (
189+
f"expected p3 disconnected, got {p3.connection_state}"
190+
)
191+
192+
# p4 joins, then p3 reconnects to publish to p4
193+
p4 = rtc.Room()
194+
await _connect(p4, "p4", room_name)
195+
196+
await p3.connect(p3_url, p3_token)
197+
assert p3.connection_state == rtc.ConnectionState.CONN_CONNECTED, (
198+
f"expected p3 reconnected, got {p3.connection_state}"
199+
)
200+
201+
# publish camera from p3, expect p4 to see track_published
202+
video_published = _expect_event(p4, "track_published")
203+
video_pub = await _publish_video(p3, "p3-camera")
204+
await _await_event(video_published)
205+
await _ensure_track_subscribed(p4, video_pub.sid)
206+
207+
# publish microphone from p3, expect p4 to see another track_published
208+
audio_published = _expect_event(
209+
p4,
210+
"track_published",
211+
predicate=lambda pub, _p: pub.sid != video_pub.sid,
212+
)
213+
audio_pub = await _publish_audio(p3, "p3-mic")
214+
await _await_event(audio_published)
215+
await _ensure_track_subscribed(p4, audio_pub.sid)
216+
217+
await p3.disconnect()
218+
await p4.disconnect()
219+
220+
assert p3.connection_state == rtc.ConnectionState.CONN_DISCONNECTED
221+
assert p4.connection_state == rtc.ConnectionState.CONN_DISCONNECTED

0 commit comments

Comments
 (0)