Skip to content

Commit f0590aa

Browse files
committed
feat: add in tests ported to use livekit-uniffi provided data tracks v2 version
1 parent 8e072a9 commit f0590aa

7 files changed

Lines changed: 783 additions & 0 deletions

Package.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ let package = Package(
5757
dependencies: [
5858
"LiveKit",
5959
"LiveKitTestSupport",
60+
.product(name: "LiveKitUniFFI", package: "LiveKitUniFFI"),
6061
],
6162
),
6263
.testTarget(

Package@swift-6.2.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ let package = Package(
5858
dependencies: [
5959
"LiveKit",
6060
"LiveKitTestSupport",
61+
.product(name: "LiveKitUniFFI", package: "LiveKitUniFFI"),
6162
],
6263
),
6364
.testTarget(
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
/*
2+
* Copyright 2026 LiveKit
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import Foundation
18+
@testable import LiveKit
19+
import LiveKitUniFFI
20+
import Testing
21+
#if canImport(LiveKitTestSupport)
22+
import LiveKitTestSupport
23+
#endif
24+
25+
/// Exercises every ``ByteStreamReader`` interface (iterating, `readAll()`, `writeToFile()`) against
26+
/// a reader minted by the UniFFI incoming manager: a real `IncomingDataStreamManager` is fed data
27+
/// stream packets, the opened reader is captured, and its contents are read back through each API.
28+
@Suite(.tags(.dataStream))
29+
struct ByteStreamReaderTests {
30+
private let topic = "someTopic"
31+
private let name = "filename.bin"
32+
private let mimeType = "application/octet-stream"
33+
34+
private let testChunks = [
35+
Data(repeating: 0xAB, count: 128),
36+
Data(repeating: 0xCD, count: 128),
37+
Data(repeating: 0xEF, count: 256),
38+
Data(repeating: 0x12, count: 32),
39+
]
40+
41+
/// All chunks combined.
42+
private var testPayload: Data {
43+
testChunks.reduce(Data()) { $0 + $1 }
44+
}
45+
46+
@Test func chunkRead() async throws {
47+
let (reader, manager) = await openReader()
48+
// The reader may deliver chunks with different boundaries than they were sent, so validate
49+
// the reassembled payload rather than a one-to-one chunk correspondence.
50+
var received = Data()
51+
for try await chunk in reader {
52+
received += chunk
53+
}
54+
#expect(received == testPayload)
55+
_ = manager
56+
}
57+
58+
@Test func chunkReadError() async throws {
59+
let (reader, manager) = await openReader(trailerReason: "test")
60+
await #expect(throws: StreamError.abnormalEnd(reason: "test")) {
61+
for try await _ in reader {}
62+
}
63+
_ = manager
64+
}
65+
66+
@Test func readAll() async throws {
67+
let (reader, manager) = await openReader()
68+
let fullPayload = try await reader.readAll()
69+
#expect(fullPayload == testPayload)
70+
_ = manager
71+
}
72+
73+
@Test func readToFile() async throws {
74+
let (reader, manager) = await openReader()
75+
let fileURL = try await reader.writeToFile()
76+
#expect(fileURL.lastPathComponent == reader.info.name)
77+
#expect(try Data(contentsOf: fileURL) == testPayload)
78+
_ = manager
79+
}
80+
81+
@Test func info() async throws {
82+
let (reader, manager) = await openReader()
83+
#expect(reader.info.topic == topic)
84+
#expect(reader.info.name == name)
85+
#expect(reader.info.mimeType == mimeType)
86+
_ = manager
87+
}
88+
89+
// MARK: - Static filename resolution (no FFI)
90+
91+
struct FileNameCase: CustomTestStringConvertible {
92+
let preferred: String?
93+
let fallback: String
94+
let mimeType: String
95+
let expected: String
96+
var testDescription: String { "preferred=\(preferred ?? "nil"), mime=\(mimeType)\(expected)" }
97+
}
98+
99+
@Test(arguments: [
100+
FileNameCase(preferred: nil, fallback: "[fallback]", mimeType: "text/plain", expected: "[fallback].txt"),
101+
FileNameCase(preferred: "name", fallback: "[fallback]", mimeType: "text/plain", expected: "name.txt"),
102+
FileNameCase(preferred: "name.jpeg", fallback: "[fallback]", mimeType: "text/plain", expected: "name.jpeg"),
103+
FileNameCase(preferred: "name", fallback: "[fallback]", mimeType: "image/jpeg", expected: "name.jpeg"),
104+
FileNameCase(preferred: "name", fallback: "[fallback]", mimeType: "text/invalid", expected: "name.bin"),
105+
])
106+
func resolveFileName(_ c: FileNameCase) {
107+
#expect(
108+
LiveKit.ByteStreamReader.resolveFileName(
109+
preferredName: c.preferred,
110+
fallbackName: c.fallback,
111+
mimeType: c.mimeType,
112+
) == c.expected,
113+
)
114+
}
115+
116+
// MARK: - Helpers
117+
118+
/// Delegate that wraps the FFI reader into the public ``ByteStreamReader`` and hands it back
119+
/// through a continuation.
120+
private final class Capture: IncomingDataStreamManagerDelegate, @unchecked Sendable {
121+
let pending = StateSync<CheckedContinuation<LiveKit.ByteStreamReader, Never>?>(nil)
122+
123+
func onByteStreamOpened(reader: LiveKitUniFFI.ByteStreamReader, identity _: String) {
124+
let info = LiveKit.ByteStreamInfo(reader.info(), encryptionType: .none)
125+
let publicReader = LiveKit.ByteStreamReader(reader, info: info)
126+
let continuation = pending.mutate { current -> CheckedContinuation<LiveKit.ByteStreamReader, Never>? in
127+
defer { current = nil }
128+
return current
129+
}
130+
continuation?.resume(returning: publicReader)
131+
}
132+
133+
func onTextStreamOpened(reader _: LiveKitUniFFI.TextStreamReader, identity _: String) {}
134+
}
135+
136+
/// Opens a byte stream through the FFI incoming manager and returns the public reader plus the
137+
/// manager, which the caller must keep alive while reading.
138+
private func openReader(trailerReason: String = "") async -> (LiveKit.ByteStreamReader, IncomingDataStreamManager) {
139+
let capture = Capture()
140+
let manager = IncomingDataStreamManager(delegate: capture, reservedTopics: [], maxPayloadByteLength: nil)
141+
let streamID = UUID().uuidString
142+
143+
let reader = await withCheckedContinuation { (continuation: CheckedContinuation<LiveKit.ByteStreamReader, Never>) in
144+
capture.pending.mutate { $0 = continuation }
145+
146+
var header = Livekit_DataStream.Header()
147+
header.streamID = streamID
148+
header.topic = topic
149+
header.mimeType = mimeType
150+
header.contentHeader = .byteHeader(.with { $0.name = name })
151+
feed(manager) { $0.streamHeader = header }
152+
153+
for (index, chunk) in testChunks.enumerated() {
154+
var streamChunk = Livekit_DataStream.Chunk()
155+
streamChunk.streamID = streamID
156+
streamChunk.chunkIndex = UInt64(index)
157+
streamChunk.content = chunk
158+
feed(manager) { $0.streamChunk = streamChunk }
159+
}
160+
161+
var trailer = Livekit_DataStream.Trailer()
162+
trailer.streamID = streamID
163+
trailer.reason = trailerReason
164+
feed(manager) { $0.streamTrailer = trailer }
165+
}
166+
return (reader, manager)
167+
}
168+
169+
private func feed(_ manager: IncomingDataStreamManager, _ configure: (inout Livekit_DataPacket) -> Void) {
170+
var packet = Livekit_DataPacket()
171+
packet.participantIdentity = "someName"
172+
configure(&packet)
173+
guard let data = try? packet.serializedData() else { return }
174+
manager.handlePacketReceived(packet: data)
175+
}
176+
}

0 commit comments

Comments
 (0)