Skip to content

Commit 33ec5b6

Browse files
committed
e2e: basic tests.
1 parent b15e3bb commit 33ec5b6

2 files changed

Lines changed: 223 additions & 2 deletions

File tree

.github/workflows/tests.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,14 +128,14 @@ jobs:
128128
LIVEKIT_API_SECRET: ${{ secrets.LIVEKIT_API_SECRET }}
129129
run: |
130130
source .test-venv/bin/activate
131-
pytest tests/
131+
pytest tests/ livekit-rtc/tests/
132132
133133
- name: Run tests (Windows)
134134
if: runner.os == 'Windows'
135135
env:
136136
LIVEKIT_URL: ${{ secrets.LIVEKIT_URL }}
137137
LIVEKIT_API_KEY: ${{ secrets.LIVEKIT_API_KEY }}
138138
LIVEKIT_API_SECRET: ${{ secrets.LIVEKIT_API_SECRET }}
139-
run: .test-venv\Scripts\python.exe -m pytest tests/
139+
run: .test-venv\Scripts\python.exe -m pytest tests/ livekit-rtc/tests/
140140
shell: pwsh
141141

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 translation of the Flutter `BasicsTest` scenario.
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+
23+
24+
WAIT_TIMEOUT = 20.0
25+
WAIT_INTERVAL = 0.1
26+
27+
28+
def skip_if_no_credentials():
29+
required_vars = ["LIVEKIT_URL", "LIVEKIT_API_KEY", "LIVEKIT_API_SECRET"]
30+
missing = [var for var in required_vars if not os.getenv(var)]
31+
return pytest.mark.skipif(
32+
bool(missing), reason=f"Missing environment variables: {', '.join(missing)}"
33+
)
34+
35+
36+
def create_token(identity: str, room_name: str) -> str:
37+
return (
38+
api.AccessToken()
39+
.with_identity(identity)
40+
.with_name(identity)
41+
.with_grants(
42+
api.VideoGrants(
43+
room_join=True,
44+
room=room_name,
45+
)
46+
)
47+
.to_jwt()
48+
)
49+
50+
def unique_room_name(base: str) -> str:
51+
return f"{base}-{uuid.uuid4().hex[:8]}"
52+
53+
54+
async def _wait_until(
55+
predicate: Callable[[], bool],
56+
*,
57+
timeout: float = WAIT_TIMEOUT,
58+
interval: float = WAIT_INTERVAL,
59+
message: str = "condition not met",
60+
) -> None:
61+
loop = asyncio.get_event_loop()
62+
deadline = loop.time() + timeout
63+
while loop.time() < deadline:
64+
if predicate():
65+
return
66+
await asyncio.sleep(interval)
67+
raise AssertionError(f"timeout waiting: {message}")
68+
69+
70+
async def _connect(room: rtc.Room, identity: str, room_name: str) -> str:
71+
"""Mints a token, connects `room`, and returns the token (for reconnect)."""
72+
token = create_token(identity, room_name)
73+
url = os.environ["LIVEKIT_URL"]
74+
await room.connect(url, token)
75+
return token
76+
77+
78+
async def _ensure_all_connected(rooms: list[rtc.Room]) -> None:
79+
await _wait_until(
80+
lambda: all(
81+
r.connection_state == rtc.ConnectionState.CONN_CONNECTED for r in rooms
82+
),
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, event: str, predicate: Optional[Callable[..., bool]] = None
103+
) -> asyncio.Future:
104+
"""Returns a future that resolves when `event` (optionally matching
105+
`predicate`) is fired on `room`."""
106+
loop = asyncio.get_event_loop()
107+
fut: asyncio.Future = loop.create_future()
108+
109+
def _on_event(*args, **kwargs) -> None:
110+
if fut.done():
111+
return
112+
if predicate is None or predicate(*args, **kwargs):
113+
fut.set_result(args)
114+
115+
room.on(event, _on_event)
116+
return fut
117+
118+
119+
async def _await_event(fut: asyncio.Future, timeout: float = WAIT_TIMEOUT) -> None:
120+
try:
121+
await asyncio.wait_for(fut, timeout=timeout)
122+
except asyncio.TimeoutError as e:
123+
raise AssertionError("timed out waiting for event") from e
124+
125+
126+
async def _publish_camera(
127+
room: rtc.Room, track_name: str
128+
) -> 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_mic(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_camera(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_mic(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)