Skip to content

Commit 93df6b9

Browse files
committed
feat: expand LiveKit integration harness with two-client scenarios and data track signaling
1 parent 55dd8dc commit 93df6b9

4 files changed

Lines changed: 218 additions & 11 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -450,7 +450,9 @@ swift test --filter LiveKitNativeIntegrationTests
450450
```
451451

452452
The harness generates per-run room names with an `lknative-` prefix and
453-
short-lived room-scoped participant tokens. Strict production release mode now
453+
short-lived room-scoped participant tokens. It currently covers one-client
454+
connect/disconnect, two-client participant join/leave, and two-client
455+
data-track subscriber-handle signaling. Strict production release mode now
454456
requires those integration variables so the future `productionReady` marker
455457
cannot pass while live tests are silently skipped.
456458

Tests/LiveKitNativeIntegrationTests/IntegrationOptInTests.swift

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,4 +87,86 @@ final class IntegrationOptInTests: XCTestCase {
8787

8888
XCTAssertEqual(room.connectionState, .disconnected)
8989
}
90+
91+
func testTwoLiveKitClientsObserveParticipantJoinAndLeave() async throws {
92+
let harness = try LiveKitIntegrationHarness.load()
93+
let roomName = harness.roomName(suffix: "two-client")
94+
let firstIdentity = "swift-native-first"
95+
let secondIdentity = "swift-native-second"
96+
let firstRoom = Room(options: liveIntegrationRoomOptions())
97+
let secondRoom = Room(options: liveIntegrationRoomOptions())
98+
let firstEvents = LiveKitIntegrationEventRecorder()
99+
firstRoom.delegate = firstEvents
100+
101+
do {
102+
try await harness.connect(firstRoom, identity: firstIdentity, roomName: roomName)
103+
try await harness.connect(secondRoom, identity: secondIdentity, roomName: roomName)
104+
105+
let joinedParticipant = try await firstEvents.waitForParticipantConnected(
106+
identity: secondIdentity
107+
)
108+
XCTAssertEqual(joinedParticipant.identity, secondIdentity)
109+
XCTAssertEqual(firstRoom.remoteParticipants.map(\.identity), [secondIdentity])
110+
XCTAssertTrue(secondRoom.remoteParticipants.contains { $0.identity == firstIdentity })
111+
112+
await secondRoom.disconnect()
113+
114+
let leftParticipant = try await firstEvents.waitForParticipantDisconnected(
115+
identity: secondIdentity
116+
)
117+
XCTAssertEqual(leftParticipant.identity, secondIdentity)
118+
XCTAssertTrue(firstRoom.remoteParticipants.isEmpty)
119+
120+
await firstRoom.disconnect()
121+
} catch {
122+
await secondRoom.disconnect()
123+
await firstRoom.disconnect()
124+
throw error
125+
}
126+
}
127+
128+
func testTwoLiveKitClientsReceiveDataTrackSubscriberHandles() async throws {
129+
let harness = try LiveKitIntegrationHarness.load()
130+
let roomName = harness.roomName(suffix: "data-track")
131+
let firstIdentity = "swift-native-data-sub"
132+
let secondIdentity = "swift-native-data-pub"
133+
let firstRoom = Room(options: liveIntegrationRoomOptions())
134+
let secondRoom = Room(options: liveIntegrationRoomOptions())
135+
let firstEvents = LiveKitIntegrationEventRecorder()
136+
firstRoom.delegate = firstEvents
137+
138+
do {
139+
try await harness.connect(firstRoom, identity: firstIdentity, roomName: roomName)
140+
try await harness.connect(secondRoom, identity: secondIdentity, roomName: roomName)
141+
_ = try await firstEvents.waitForParticipantConnected(identity: secondIdentity)
142+
143+
let dataTrack = try await withLiveKitIntegrationTimeout(seconds: 10) {
144+
try await secondRoom.localParticipant.publishDataTrack(name: "telemetry")
145+
}
146+
XCTAssertFalse(dataTrack.sid.isEmpty)
147+
XCTAssertEqual(dataTrack.name, "telemetry")
148+
149+
let subscriberHandle = try await firstEvents.waitForDataTrackSubscriberHandle(
150+
publisherIdentity: secondIdentity
151+
)
152+
XCTAssertEqual(subscriberHandle.publisherIdentity, secondIdentity)
153+
XCTAssertEqual(subscriberHandle.trackSID, dataTrack.sid)
154+
155+
await secondRoom.disconnect()
156+
await firstRoom.disconnect()
157+
} catch {
158+
await secondRoom.disconnect()
159+
await firstRoom.disconnect()
160+
throw error
161+
}
162+
}
163+
}
164+
165+
private func liveIntegrationRoomOptions() -> RoomOptions {
166+
RoomOptions(
167+
defaultAutoSubscribe: true,
168+
defaultAdaptiveStream: true,
169+
defaultSubscriberAllowPause: true,
170+
defaultAutoSubscribeDataTrack: true
171+
)
90172
}

Tests/LiveKitNativeIntegrationTests/LiveKitIntegrationHarness.swift

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,23 @@ struct LiveKitIntegrationHarness: Sendable {
6363
)
6464
}
6565

66+
func connect(
67+
_ room: Room,
68+
identity: String,
69+
roomName: String,
70+
timeoutSeconds: TimeInterval = 15
71+
) async throws {
72+
let token = try token(identity: identity, roomName: roomName)
73+
do {
74+
try await withLiveKitIntegrationTimeout(seconds: timeoutSeconds) {
75+
try await room.connect(url: liveKitURL, token: token)
76+
}
77+
} catch {
78+
await room.disconnect()
79+
throw error
80+
}
81+
}
82+
6683
private static func requiredValue(_ name: String, in environment: [String: String]) throws -> String {
6784
let value = environment[name]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
6885
guard !value.isEmpty else {
@@ -73,6 +90,108 @@ struct LiveKitIntegrationHarness: Sendable {
7390
}
7491
}
7592

93+
final class LiveKitIntegrationEventRecorder: RoomDelegate, @unchecked Sendable {
94+
private let lock = NSLock()
95+
private var events: [RoomEvent] = []
96+
97+
var recordedEvents: [RoomEvent] {
98+
lock.withLock {
99+
events
100+
}
101+
}
102+
103+
func room(_ room: Room, didEmit event: RoomEvent) {
104+
lock.withLock {
105+
events.append(event)
106+
}
107+
}
108+
109+
func waitForParticipantConnected(
110+
identity: String,
111+
timeoutSeconds: TimeInterval = 10
112+
) async throws -> RemoteParticipant {
113+
try await wait(timeoutSeconds: timeoutSeconds) { events in
114+
for event in events.reversed() {
115+
if case let .participantConnected(participant) = event,
116+
participant.identity == identity {
117+
return participant
118+
}
119+
}
120+
121+
return nil
122+
}
123+
}
124+
125+
func waitForParticipantDisconnected(
126+
identity: String,
127+
timeoutSeconds: TimeInterval = 10
128+
) async throws -> RemoteParticipant {
129+
try await wait(timeoutSeconds: timeoutSeconds) { events in
130+
for event in events.reversed() {
131+
if case let .participantDisconnected(participant) = event,
132+
participant.identity == identity {
133+
return participant
134+
}
135+
}
136+
137+
return nil
138+
}
139+
}
140+
141+
func waitForDataTrackSubscriberHandle(
142+
publisherIdentity: String,
143+
timeoutSeconds: TimeInterval = 10
144+
) async throws -> DataTrackSubscriberHandleInfo {
145+
try await wait(timeoutSeconds: timeoutSeconds) { events in
146+
for event in events.reversed() {
147+
if case let .dataTrackSubscriberHandlesChanged(handles) = event,
148+
let handle = handles.handles.first(where: { $0.publisherIdentity == publisherIdentity }) {
149+
return handle
150+
}
151+
}
152+
153+
return nil
154+
}
155+
}
156+
157+
func waitForDataReceived(
158+
payload: Data,
159+
topic: String? = nil,
160+
participantIdentity: String? = nil,
161+
timeoutSeconds: TimeInterval = 10
162+
) async throws -> (Data, RemoteParticipant?, String?) {
163+
try await wait(timeoutSeconds: timeoutSeconds) { events in
164+
for event in events.reversed() {
165+
if case let .dataReceived(eventPayload, participant, eventTopic) = event,
166+
eventPayload == payload,
167+
eventTopic == topic,
168+
participantIdentity == nil || participant?.identity == participantIdentity {
169+
return (eventPayload, participant, eventTopic)
170+
}
171+
}
172+
173+
return nil
174+
}
175+
}
176+
177+
private func wait<T: Sendable>(
178+
timeoutSeconds: TimeInterval,
179+
match: @escaping @Sendable ([RoomEvent]) -> T?
180+
) async throws -> T {
181+
try await withLiveKitIntegrationTimeout(seconds: timeoutSeconds) {
182+
while !Task.isCancelled {
183+
if let value = match(self.recordedEvents) {
184+
return value
185+
}
186+
187+
try await Task.sleep(nanoseconds: 50_000_000)
188+
}
189+
190+
throw CancellationError()
191+
}
192+
}
193+
}
194+
76195
private struct LiveKitIntegrationTokenFactory: Sendable {
77196
var apiKey: String
78197
var apiSecret: String

docs/STATUS.md

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -844,8 +844,11 @@ The old binary WebRTC dependency path has been removed from the package model.
844844
The following checks passed after the latest implementation pass:
845845

846846
- `swift test`
847-
- 477 tests passed
848-
- 1 test skipped by opt-in guard
847+
- 485 tests passed
848+
- 4 tests skipped by opt-in guard
849+
- `swift test --filter LiveKitNativeIntegrationTests --jobs 1`
850+
- 6 integration tests selected
851+
- 4 live tests skipped by opt-in guard without LiveKit environment variables
849852
- `swift build --target LiveKitNativeWebRTC --jobs 1 --disable-index-store -debug-info-format none`
850853
- target build passed
851854
- `xcodebuild build -scheme LiveKitNative -destination 'generic/platform=iOS Simulator'`
@@ -856,6 +859,10 @@ The following checks passed after the latest implementation pass:
856859
- Release gates:
857860
- `scripts/check_release_readiness.sh` validates package shape, dependency
858861
guard, tests, benchmark smoke, and size gate in non-strict mode
862+
- The release-shape check passes with tests, benchmarks, and size gate
863+
disabled through `LIVEKIT_NATIVE_RELEASE_RUN_TESTS=0`,
864+
`LIVEKIT_NATIVE_RELEASE_RUN_BENCHMARKS=0`, and
865+
`LIVEKIT_NATIVE_RELEASE_RUN_SIZE_GATE=0`
859866
- `scripts/check_release_size.sh` passes with the current compressed
860867
`LiveKitNativeBenchmarks` release binary at 2,841,082 bytes under the 5 MB
861868
proxy limit
@@ -979,15 +986,12 @@ The following checks passed after the latest implementation pass:
979986

980987
### Integration
981988

982-
- Opt-in LiveKit integration harness using `LIVEKIT_NATIVE_RUN_INTEGRATION=1`,
983-
`LIVEKIT_NATIVE_LIVEKIT_URL`, `LIVEKIT_NATIVE_API_KEY`,
984-
`LIVEKIT_NATIVE_API_SECRET`, generated `lknative-` room prefixes, and
985-
short-lived room-scoped participant tokens.
986-
- End-to-end one-client connection and disconnect against a configured LiveKit
987-
server.
988-
- Subscribe path.
989+
- Expand the current opt-in LiveKit integration harness beyond one-client
990+
connect/disconnect, two-client participant join/leave, generated `lknative-`
991+
room prefixes, short-lived room-scoped tokens, and two-client data-track
992+
subscriber-handle signaling.
993+
- Data packet publish/receive over standards-compliant SCTP.
989994
- Publish path.
990-
- Two-client media/data test.
991995
- Reconnect integration test.
992996
- Multi-participant meeting tests with simultaneous publish/subscribe.
993997
- Weak-network tests with packet loss, jitter, bandwidth changes, and recovery

0 commit comments

Comments
 (0)