Skip to content

Commit 2e2336c

Browse files
authored
Proto mapping tests (#806)
We do not expose protobufs, let's make sure our public interface matches (discarding `?` vs default protobuf values dilemma).
1 parent 92e2231 commit 2e2336c

File tree

5 files changed

+181
-7
lines changed

5 files changed

+181
-7
lines changed

Sources/LiveKit/DataStream/Outgoing/OutgoingStreamManager.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,7 @@ extension Livekit_DataStream.Header {
271271
$0.totalLength = UInt64(totalLength)
272272
}
273273
$0.attributes = streamInfo.attributes
274+
$0.encryptionType = streamInfo.encryptionType.toPBType()
274275
$0.contentHeader = Livekit_DataStream.Header.OneOf_ContentHeader(streamInfo)
275276
}
276277
}

Sources/LiveKit/Types/ParticipantPermissions.swift

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,19 +42,31 @@ public class ParticipantPermissions: NSObject, @unchecked Sendable {
4242
@objc
4343
public let recorder: Bool
4444

45+
/// Indicates participant can update own metadata and attributes
46+
@objc
47+
public let canUpdateMetadata: Bool
48+
49+
/// Indicates participant can subscribe to metrics
50+
@objc
51+
public let canSubscribeMetrics: Bool
52+
4553
init(canSubscribe: Bool = false,
4654
canPublish: Bool = false,
4755
canPublishData: Bool = false,
4856
canPublishSources: Set<Track.Source> = [],
4957
hidden: Bool = false,
50-
recorder: Bool = false)
58+
recorder: Bool = false,
59+
canUpdateMetadata: Bool = false,
60+
canSubscribeMetrics: Bool = false)
5161
{
5262
self.canSubscribe = canSubscribe
5363
self.canPublish = canPublish
5464
self.canPublishData = canPublishData
5565
self.canPublishSources = Set(canPublishSources.map(\.rawValue))
5666
self.hidden = hidden
5767
self.recorder = recorder
68+
self.canUpdateMetadata = canUpdateMetadata
69+
self.canSubscribeMetrics = canSubscribeMetrics
5870
}
5971

6072
// MARK: - Equal
@@ -66,7 +78,9 @@ public class ParticipantPermissions: NSObject, @unchecked Sendable {
6678
canPublishData == other.canPublishData &&
6779
canPublishSources == other.canPublishSources &&
6880
hidden == other.hidden &&
69-
recorder == other.recorder
81+
recorder == other.recorder &&
82+
canUpdateMetadata == other.canUpdateMetadata &&
83+
canSubscribeMetrics == other.canSubscribeMetrics
7084
}
7185

7286
override public var hash: Int {
@@ -77,6 +91,8 @@ public class ParticipantPermissions: NSObject, @unchecked Sendable {
7791
hasher.combine(canPublishSources)
7892
hasher.combine(hidden)
7993
hasher.combine(recorder)
94+
hasher.combine(canUpdateMetadata)
95+
hasher.combine(canSubscribeMetrics)
8096
return hasher.finalize()
8197
}
8298
}
@@ -88,6 +104,8 @@ extension Livekit_ParticipantPermission {
88104
canPublishData: canPublishData,
89105
canPublishSources: Set(canPublishSources.map { $0.toLKType() }),
90106
hidden: hidden,
91-
recorder: recorder)
107+
recorder: recorder,
108+
canUpdateMetadata: canUpdateMetadata,
109+
canSubscribeMetrics: canSubscribeMetrics)
92110
}
93111
}

Tests/LiveKitCoreTests/DataStream/ByteStreamInfoTests.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ class ByteStreamInfoTests: LKTestCase {
2727
timestamp: Date(timeIntervalSince1970: 100),
2828
totalLength: 128,
2929
attributes: ["key": "value"],
30-
encryptionType: .none,
30+
encryptionType: .gcm,
3131
mimeType: "image/jpeg",
3232
name: "filename.bin"
3333
)
@@ -38,15 +38,17 @@ class ByteStreamInfoTests: LKTestCase {
3838
XCTAssertEqual(header.timestamp, Int64(info.timestamp.timeIntervalSince1970 * TimeInterval(1000)))
3939
XCTAssertEqual(header.totalLength, UInt64(info.totalLength ?? -1))
4040
XCTAssertEqual(header.attributes, info.attributes)
41+
XCTAssertEqual(header.encryptionType.rawValue, info.encryptionType.rawValue)
4142
XCTAssertEqual(header.byteHeader.name, info.name)
4243

43-
let newInfo = ByteStreamInfo(header, header.byteHeader, .none)
44+
let newInfo = ByteStreamInfo(header, header.byteHeader, .gcm)
4445
XCTAssertEqual(newInfo.id, info.id)
4546
XCTAssertEqual(newInfo.mimeType, info.mimeType)
4647
XCTAssertEqual(newInfo.topic, info.topic)
4748
XCTAssertEqual(newInfo.timestamp, info.timestamp)
4849
XCTAssertEqual(newInfo.totalLength, info.totalLength)
4950
XCTAssertEqual(newInfo.attributes, info.attributes)
51+
XCTAssertEqual(newInfo.encryptionType, info.encryptionType)
5052
XCTAssertEqual(newInfo.name, info.name)
5153
}
5254
}

Tests/LiveKitCoreTests/DataStream/TextStreamInfoTests.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ class TextStreamInfoTests: LKTestCase {
2727
timestamp: Date(timeIntervalSince1970: 100),
2828
totalLength: 128,
2929
attributes: ["key": "value"],
30-
encryptionType: .none,
30+
encryptionType: .gcm,
3131
operationType: .reaction,
3232
version: 10,
3333
replyToStreamID: "replyID",
@@ -40,18 +40,20 @@ class TextStreamInfoTests: LKTestCase {
4040
XCTAssertEqual(header.timestamp, Int64(info.timestamp.timeIntervalSince1970 * TimeInterval(1000)))
4141
XCTAssertEqual(header.totalLength, UInt64(info.totalLength ?? -1))
4242
XCTAssertEqual(header.attributes, info.attributes)
43+
XCTAssertEqual(header.encryptionType.rawValue, info.encryptionType.rawValue)
4344
XCTAssertEqual(header.textHeader.operationType.rawValue, info.operationType.rawValue)
4445
XCTAssertEqual(header.textHeader.version, Int32(info.version))
4546
XCTAssertEqual(header.textHeader.replyToStreamID, info.replyToStreamID)
4647
XCTAssertEqual(header.textHeader.attachedStreamIds, info.attachedStreamIDs)
4748
XCTAssertEqual(header.textHeader.generated, info.generated)
4849

49-
let newInfo = TextStreamInfo(header, header.textHeader, .none)
50+
let newInfo = TextStreamInfo(header, header.textHeader, .gcm)
5051
XCTAssertEqual(newInfo.id, info.id)
5152
XCTAssertEqual(newInfo.topic, info.topic)
5253
XCTAssertEqual(newInfo.timestamp, info.timestamp)
5354
XCTAssertEqual(newInfo.totalLength, info.totalLength)
5455
XCTAssertEqual(newInfo.attributes, info.attributes)
56+
XCTAssertEqual(newInfo.encryptionType, info.encryptionType)
5557
XCTAssertEqual(newInfo.operationType, info.operationType)
5658
XCTAssertEqual(newInfo.version, info.version)
5759
XCTAssertEqual(newInfo.replyToStreamID, info.replyToStreamID)
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/*
2+
* Copyright 2025 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+
@testable import LiveKit
18+
#if canImport(LiveKitTestSupport)
19+
import LiveKitTestSupport
20+
#endif
21+
22+
class ProtoConverterTests: LKTestCase {
23+
func testParticipantPermissions() {
24+
let errors = Comparator.compareStructures(
25+
proto: Livekit_ParticipantPermission(),
26+
sdk: ParticipantPermissions(),
27+
excludedFields: ["agent"], // deprecated
28+
allowedTypeMismatches: ["canPublishSources"] // Array vs Set
29+
)
30+
31+
XCTAssert(errors.isEmpty, errors.description)
32+
}
33+
}
34+
35+
enum Comparator {
36+
enum ComparisonError: Error, CustomStringConvertible {
37+
case missingField(String)
38+
case extraField(String)
39+
case typeMismatch(field: String, proto: String, sdk: String)
40+
41+
var description: String {
42+
switch self {
43+
case let .missingField(field):
44+
"Missing field: '\(field)'"
45+
case let .extraField(field):
46+
"Extra field: '\(field)'"
47+
case let .typeMismatch(field, proto, sdk):
48+
"Type mismatch for '\(field)': proto has \(proto), sdk has \(sdk)"
49+
}
50+
}
51+
}
52+
53+
struct FieldInfo {
54+
let name: String
55+
let type: String
56+
let nonOptionalType: String
57+
}
58+
59+
static func extractFields(from instance: some Any, excludedFields: Set<String> = []) -> [FieldInfo] {
60+
let mirror = Mirror(reflecting: instance)
61+
var fields: [FieldInfo] = []
62+
var backingFields: Set<String> = []
63+
64+
// Collect all backing fields
65+
for child in mirror.children {
66+
guard let label = child.label, label.hasPrefix("_") else { continue }
67+
backingFields.insert(String(label.dropFirst())) // Remove the underscore
68+
}
69+
70+
for child in mirror.children {
71+
guard let label = child.label else { continue }
72+
73+
// Skip excluded/unknown fields
74+
if excludedFields.contains(label) || label == "unknownFields" {
75+
continue
76+
}
77+
78+
// Skip private backing fields (they have public computed properties)
79+
if label.hasPrefix("_"), backingFields.contains(String(label.dropFirst())) {
80+
// But add the public version instead
81+
let publicName = String(label.dropFirst())
82+
let typeString = String(describing: type(of: child.value))
83+
let nonOptional = extractNonOptionalType(from: typeString)
84+
85+
if !fields.contains(where: { $0.name == publicName }) {
86+
fields.append(FieldInfo(name: publicName, type: typeString, nonOptionalType: nonOptional))
87+
}
88+
continue
89+
}
90+
91+
// Skip other private fields
92+
if label.hasPrefix("_") {
93+
continue
94+
}
95+
96+
let typeString = String(describing: type(of: child.value))
97+
let nonOptional = extractNonOptionalType(from: typeString)
98+
99+
fields.append(FieldInfo(name: label, type: typeString, nonOptionalType: nonOptional))
100+
}
101+
102+
return fields.sorted { $0.name < $1.name }
103+
}
104+
105+
static func extractNonOptionalType(from typeString: String) -> String {
106+
if typeString.hasPrefix("Optional<"), typeString.hasSuffix(">") {
107+
let start = typeString.index(typeString.startIndex, offsetBy: 9)
108+
let end = typeString.index(before: typeString.endIndex)
109+
return String(typeString[start ..< end])
110+
}
111+
return typeString
112+
}
113+
114+
static func compareStructures(
115+
proto: some Any,
116+
sdk: some Any,
117+
excludedFields: Set<String> = [],
118+
allowedTypeMismatches: Set<String> = []
119+
) -> [ComparisonError] {
120+
let protoFields = extractFields(from: proto, excludedFields: excludedFields)
121+
let sdkFields = extractFields(from: sdk, excludedFields: excludedFields)
122+
123+
var errors: [ComparisonError] = []
124+
125+
let protoFieldMap = Dictionary(uniqueKeysWithValues: protoFields.map { ($0.name, $0) })
126+
let sdkFieldMap = Dictionary(uniqueKeysWithValues: sdkFields.map { ($0.name, $0) })
127+
128+
for protoField in protoFields {
129+
guard let sdkField = sdkFieldMap[protoField.name] else {
130+
errors.append(.missingField(protoField.name))
131+
continue
132+
}
133+
134+
if protoField.nonOptionalType != sdkField.nonOptionalType, !allowedTypeMismatches.contains(protoField.name) {
135+
errors.append(.typeMismatch(
136+
field: protoField.name,
137+
proto: protoField.type,
138+
sdk: sdkField.type
139+
))
140+
}
141+
}
142+
143+
for sdkField in sdkFields {
144+
if protoFieldMap[sdkField.name] == nil {
145+
errors.append(.extraField(sdkField.name))
146+
}
147+
}
148+
149+
return errors
150+
}
151+
}

0 commit comments

Comments
 (0)