From 9bd546b9a27a4fdaaf076e9da6f99d301e2a284d Mon Sep 17 00:00:00 2001 From: davidliu Date: Sun, 6 Feb 2022 20:36:42 +0900 Subject: [PATCH] tests and mocks for e2e testing (#78) * tests and mocks for e2e testing * formatting * fix analyze errors --- example/lib/widgets/participant.dart | 2 + example/pubspec.lock | 9 +- lib/src/core/engine.dart | 27 ++-- lib/src/core/room.dart | 19 +-- lib/src/core/signal_client.dart | 13 +- lib/src/core/transport.dart | 9 +- lib/src/participant/local.dart | 1 + lib/src/participant/remote.dart | 1 + lib/src/support/native.dart | 1 + lib/src/support/platform.dart | 8 + lib/src/support/websocket.dart | 10 +- lib/src/utils.dart | 6 + pubspec.lock | 114 +++++++++++++- pubspec.yaml | 1 + test/core/room_e2e_test.dart | 125 +++++++++++++++ test/core/signal_client_test.dart | 124 +++++++++++++++ test/mock/datachannel_mock.dart | 23 +++ test/mock/e2e_container.dart | 35 +++++ test/mock/e2e_container_test.dart | 25 +++ test/mock/peerconnection_mock.dart | 227 +++++++++++++++++++++++++++ test/mock/test_data.dart | 31 ++++ test/mock/websocket_mock.dart | 22 +++ 22 files changed, 799 insertions(+), 34 deletions(-) create mode 100644 test/core/room_e2e_test.dart create mode 100644 test/core/signal_client_test.dart create mode 100644 test/mock/datachannel_mock.dart create mode 100644 test/mock/e2e_container.dart create mode 100644 test/mock/e2e_container_test.dart create mode 100644 test/mock/peerconnection_mock.dart create mode 100644 test/mock/test_data.dart create mode 100644 test/mock/websocket_mock.dart diff --git a/example/lib/widgets/participant.dart b/example/lib/widgets/participant.dart index f43f974..a8b3e97 100644 --- a/example/lib/widgets/participant.dart +++ b/example/lib/widgets/participant.dart @@ -158,6 +158,7 @@ class _LocalParticipantWidgetState _visible) { return firstVideoPublication?.track; } + return null; } } @@ -180,6 +181,7 @@ class _RemoteParticipantWidgetState return trackPublication.track; } } + return null; } @override diff --git a/example/pubspec.lock b/example/pubspec.lock index 7c0f3e3..676367b 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -240,6 +240,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.12.11" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.3" meta: dependency: transitive description: @@ -475,7 +482,7 @@ packages: name: test_api url: "https://pub.dartlang.org" source: hosted - version: "0.4.3" + version: "0.4.8" typed_data: dependency: transitive description: diff --git a/lib/src/core/engine.dart b/lib/src/core/engine.dart index 4db8802..6e8b2c8 100644 --- a/lib/src/core/engine.dart +++ b/lib/src/core/engine.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; +import 'package:livekit_client/src/support/websocket.dart'; import 'package:meta/meta.dart'; import '../constants.dart'; @@ -28,11 +29,10 @@ class Engine extends Disposable with EventsEmittable { static const _lossyDCLabel = '_lossy'; static const _reliableDCLabel = '_reliable'; - // Reference to the Room - final Room room; - final SignalClient signalClient; + final PeerConnectionCreate _peerConnectionCreate; + @internal PCTransport? publisher; @@ -60,6 +60,7 @@ class Engine extends Disposable with EventsEmittable { // remember url and token for reconnect String? url; String? token; + ConnectOptions? connectOptions; bool _subscriberPrimary = false; @@ -71,9 +72,11 @@ class Engine extends Disposable with EventsEmittable { final delays = CancelableDelayManager(); Engine({ - required this.room, SignalClient? signalClient, - }) : signalClient = signalClient ?? SignalClient() { + PeerConnectionCreate? peerConnectionCreate, + }) : signalClient = signalClient ?? SignalClient(LiveKitWebSocket.connect), + _peerConnectionCreate = + peerConnectionCreate ?? rtc.createPeerConnection { if (kDebugMode) { // log all EngineEvents events.listen((event) => logger.fine('[EngineEvent] $objectId ${event}')); @@ -92,9 +95,11 @@ class Engine extends Disposable with EventsEmittable { Future connect( String url, String token, + ConnectOptions? connectOptions, ) async { this.url = url; this.token = token; + this.connectOptions = connectOptions ?? const ConnectOptions(); _updateConnectionState(ConnectionState.connecting); @@ -103,7 +108,7 @@ class Engine extends Disposable with EventsEmittable { await signalClient.connect( url, token, - connectOptions: room.connectOptions, + connectOptions: this.connectOptions, ); // wait for join response @@ -272,7 +277,7 @@ class Engine extends Disposable with EventsEmittable { await signalClient.connect( url!, token!, - connectOptions: room.connectOptions, + connectOptions: connectOptions, reconnect: true, ); @@ -331,7 +336,7 @@ class Engine extends Disposable with EventsEmittable { // RTCConfiguration? config; // use server-provided iceServers if not provided by user - final connectOptions = room.connectOptions ?? const ConnectOptions(); + final connectOptions = this.connectOptions ?? const ConnectOptions(); final serverIceServers = _serverProvidedIceServers.map((e) => e.toSDKType()).toList(); @@ -342,8 +347,10 @@ class Engine extends Disposable with EventsEmittable { .copyWith(iceServers: serverIceServers); } - publisher = await PCTransport.create(rtcConfiguration); - subscriber = await PCTransport.create(rtcConfiguration); + publisher = + await PCTransport.create(_peerConnectionCreate, rtcConfiguration); + subscriber = + await PCTransport.create(_peerConnectionCreate, rtcConfiguration); publisher?.pc.onIceCandidate = (rtc.RTCIceCandidate candidate) { logger.fine('publisher onIceCandidate'); diff --git a/lib/src/core/room.dart b/lib/src/core/room.dart index cb0a14b..63bb202 100644 --- a/lib/src/core/room.dart +++ b/lib/src/core/room.dart @@ -62,16 +62,14 @@ class Room extends DisposableChangeNotifier with EventsEmittable { UnmodifiableListView get activeSpeakers => UnmodifiableListView(_activeSpeakers); - late final engine = Engine(room: this); + final Engine engine; // suppport for multiple event listeners - late final _engineListener = engine.createListener(); + late final EventsListener _engineListener; - Room({ - this.connectOptions, - this.roomOptions, - }) { - // + Room({this.connectOptions, this.roomOptions, Engine? engine}) + : engine = engine ?? Engine() { + _engineListener = this.engine.createListener(); _setUpListeners(); // Any event emitted will trigger ChangeNotifier @@ -88,7 +86,7 @@ class Room extends DisposableChangeNotifier with EventsEmittable { // dispose all listeners for RTCEngine await _engineListener.dispose(); // dispose the engine - await engine.dispose(); + await this.engine.dispose(); }); } @@ -102,10 +100,7 @@ class Room extends DisposableChangeNotifier with EventsEmittable { this.connectOptions = connectOptions ?? this.connectOptions; this.roomOptions = roomOptions ?? this.roomOptions; - return engine.connect( - url, - token, - ); + return engine.connect(url, token, this.connectOptions); } void _setUpListeners() => _engineListener diff --git a/lib/src/core/signal_client.dart b/lib/src/core/signal_client.dart index 90dfa99..dbfbccd 100644 --- a/lib/src/core/signal_client.dart +++ b/lib/src/core/signal_client.dart @@ -22,10 +22,11 @@ class SignalClient extends Disposable with EventsEmittable { // Connection state of the socket conection. ConnectionState _connectionState = ConnectionState.disconnected; + final WebSocketConnector _wsConnector; LiveKitWebSocket? _ws; @internal - SignalClient() { + SignalClient(WebSocketConnector wsConnector) : _wsConnector = wsConnector { events.listen((event) { logger.fine('[SignalEvent] $event'); }); @@ -59,7 +60,7 @@ class SignalClient extends Disposable with EventsEmittable { // Clean up existing socket await _cleanUp(); // Attempt to connect - _ws = await LiveKitWebSocket.connect( + _ws = await _wsConnector( rtcUri, WebSocketEventHandlers( onData: _onSocketData, @@ -174,6 +175,9 @@ class SignalClient extends Disposable with EventsEmittable { events.emit( SignalSpeakersChangedEvent(speakers: msg.speakersChanged.speakers)); break; + case lk_rtc.SignalResponse_Message.roomUpdate: + // TODO + break; case lk_rtc.SignalResponse_Message.connectionQuality: events.emit(SignalConnectionQualityUpdateEvent( updates: msg.connectionQuality.updates, @@ -209,8 +213,9 @@ class SignalClient extends Disposable with EventsEmittable { case lk_rtc.SignalResponse_Message.refreshToken: events.emit(SignalTokenUpdatedEvent(token: msg.refreshToken)); break; - default: - logger.warning('skipping unsupported signal message'); + case lk_rtc.SignalResponse_Message.notSet: + logger.info('signal message not set'); + break; } } diff --git a/lib/src/core/transport.dart b/lib/src/core/transport.dart index 8927375..efdf43e 100644 --- a/lib/src/core/transport.dart +++ b/lib/src/core/transport.dart @@ -11,6 +11,9 @@ import '../types.dart'; import '../utils.dart'; typedef PCTransportOnOffer = void Function(rtc.RTCSessionDescription offer); +typedef PeerConnectionCreate = Future Function( + Map configuration, + [Map constraints]); /// a wrapper around PeerConnection class PCTransport extends Disposable { @@ -55,10 +58,11 @@ class PCTransport extends Disposable { }); } - static Future create([RTCConfiguration? rtcConfig]) async { + static Future create(PeerConnectionCreate peerConnectionCreate, + [RTCConfiguration? rtcConfig]) async { rtcConfig ??= const RTCConfiguration(); logger.fine('[PCTransport] creating ${rtcConfig.toMap()}'); - final _ = await rtc.createPeerConnection(rtcConfig.toMap()); + final _ = await peerConnectionCreate(rtcConfig.toMap()); return PCTransport._(_); } @@ -159,5 +163,6 @@ class PCTransport extends Disposable { } catch (_) { logger.warning('pc.getRemoteDescription failed with error: $_'); } + return null; } } diff --git a/lib/src/participant/local.dart b/lib/src/participant/local.dart index c3baea8..746655a 100644 --- a/lib/src/participant/local.dart +++ b/lib/src/participant/local.dart @@ -305,6 +305,7 @@ class LocalParticipant extends Participant { return await publishVideoTrack(track); } } + return null; } /// Control who can subscribe to LocalParticipant's published tracks. diff --git a/lib/src/participant/remote.dart b/lib/src/participant/remote.dart index 2a64fd4..31e2c6b 100644 --- a/lib/src/participant/remote.dart +++ b/lib/src/participant/remote.dart @@ -65,6 +65,7 @@ class RemoteParticipant extends Participant { RemoteTrackPublication? getTrackPublication(String sid) { final pub = trackPublications[sid]; if (pub is RemoteTrackPublication) return pub; + return null; } /// for internal use diff --git a/lib/src/support/native.dart b/lib/src/support/native.dart index 1704754..b2aec5c 100644 --- a/lib/src/support/native.dart +++ b/lib/src/support/native.dart @@ -36,5 +36,6 @@ class Native { } catch (error) { logger.warning('appleOSVersionString did throw error: ${error}'); } + return null; } } diff --git a/lib/src/support/platform.dart b/lib/src/support/platform.dart index de68a8d..3e7ba4b 100644 --- a/lib/src/support/platform.dart +++ b/lib/src/support/platform.dart @@ -1,9 +1,17 @@ +import 'dart:io'; + +import 'package:meta/meta.dart'; + import 'platform/io.dart' if (dart.library.html) 'platform/web.dart'; // Returns the current platform which works for both web and devices. PlatformType lkPlatform() => lkPlatformImplementation(); + bool lkPlatformIs(PlatformType type) => lkPlatform() == type; +@internal +bool lkPlatformIsTest() => Platform.environment.containsKey('FLUTTER_TEST'); + enum PlatformType { web, windows, diff --git a/lib/src/support/websocket.dart b/lib/src/support/websocket.dart index cf94e6d..07808f7 100644 --- a/lib/src/support/websocket.dart +++ b/lib/src/support/websocket.dart @@ -23,6 +23,7 @@ class WebSocketEventHandlers { final WebSocketOnData? onData; final WebSocketOnError? onError; final WebSocketOnDispose? onDispose; + const WebSocketEventHandlers({ this.onData, this.onError, @@ -30,12 +31,13 @@ class WebSocketEventHandlers { }); } +typedef WebSocketConnector = Future Function(Uri uri, + [WebSocketEventHandlers? options]); + abstract class LiveKitWebSocket extends Disposable { void send(List data); - static Future connect( - Uri uri, [ - WebSocketEventHandlers? options, - ]) => + static Future connect(Uri uri, + [WebSocketEventHandlers? options]) => lkWebSocketConnect(uri, options); } diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 534efa3..ec02ac2 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -65,6 +65,11 @@ class Utils { static final _deviceInfoPlugin = DeviceInfoPlugin(); static Future _clientInfo() async { + if (lkPlatformIsTest()) { + return lk_models.ClientInfo( + os: 'test', + ); + } switch (lkPlatform()) { case PlatformType.web: return lk_models.ClientInfo( @@ -123,6 +128,7 @@ class Utils { default: // case PlatformType.fuchsia: } + return null; } @internal diff --git a/pubspec.lock b/pubspec.lock index 74e42c1..f934c8d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,20 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + url: "https://pub.dartlang.org" + source: hosted + version: "34.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + url: "https://pub.dartlang.org" + source: hosted + version: "3.2.0" args: dependency: transitive description: @@ -22,6 +36,27 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.0" + build: + dependency: transitive + description: + name: build + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.1" + built_collection: + dependency: transitive + description: + name: built_collection + url: "https://pub.dartlang.org" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + url: "https://pub.dartlang.org" + source: hosted + version: "8.1.4" characters: dependency: transitive description: @@ -36,6 +71,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.3.1" + cli_util: + dependency: transitive + description: + name: cli_util + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.5" clock: dependency: transitive description: @@ -43,6 +85,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.1.0" + code_builder: + dependency: transitive + description: + name: code_builder + url: "https://pub.dartlang.org" + source: hosted + version: "4.1.0" collection: dependency: "direct main" description: @@ -50,6 +99,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.15.0" + convert: + dependency: transitive + description: + name: convert + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.1" crypto: dependency: transitive description: @@ -57,6 +113,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.0.1" + dart_style: + dependency: transitive + description: + name: dart_style + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.1" dart_webrtc: dependency: "direct main" description: @@ -163,6 +226,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.8.1" + glob: + dependency: transitive + description: + name: glob + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.2" http: dependency: "direct main" description: @@ -212,6 +282,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.12.11" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.3" meta: dependency: "direct main" description: @@ -219,6 +296,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.7.0" + mockito: + dependency: "direct dev" + description: + name: mockito + url: "https://pub.dartlang.org" + source: hosted + version: "5.0.17" + package_config: + dependency: transitive + description: + name: package_config + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.2" path: dependency: transitive description: @@ -303,11 +394,25 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.1" + pub_semver: + dependency: transitive + description: + name: pub_semver + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.99" + source_gen: + dependency: transitive + description: + name: source_gen + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.1" source_span: dependency: transitive description: @@ -356,7 +461,7 @@ packages: name: test_api url: "https://pub.dartlang.org" source: hosted - version: "0.4.3" + version: "0.4.8" tint: dependency: transitive description: @@ -385,6 +490,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.1" + watcher: + dependency: transitive + description: + name: watcher + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" webrtc_interface: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 3866b4c..fd08e5a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,6 +30,7 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^1.0.4 + mockito: ^5.0.17 import_sorter: ^4.6.0 flutter: diff --git a/test/core/room_e2e_test.dart b/test/core/room_e2e_test.dart new file mode 100644 index 0000000..8581486 --- /dev/null +++ b/test/core/room_e2e_test.dart @@ -0,0 +1,125 @@ +@Timeout(Duration(seconds: 5)) +import 'package:flutter_test/flutter_test.dart'; +import 'package:livekit_client/livekit_client.dart'; + +import '../mock/e2e_container.dart'; +import '../mock/test_data.dart'; +import '../mock/websocket_mock.dart'; +import 'signal_client_test.dart'; + +void main() { + late E2EContainer container; + late Room room; + late MockWebSocketConnector ws; + setUp(() async { + container = E2EContainer(); + room = container.room; + ws = container.wsConnector; + await container.connectRoom(); + }); + + tearDown(() async { + await container.dispose(); + }); + + group('connection', () { + test('disconnect and connect', () async { + await room.disconnect(); + await container.connectRoom(); + expect(room.connectionState, ConnectionState.connected); + expect(room.localParticipant?.sid, joinResponse.join.participant.sid); + }, skip: 'todo'); + }); + + group('room updates', () { + test('participant join', () async { + expect( + room.events.streamCtrl.stream, + emits(predicate( + (event) => event.participant.sid == remoteParticipantData.sid, + )), + ); + ws.onData(participantJoinResponse.writeToBuffer()); + + await room.events.waitFor( + duration: const Duration(seconds: 1)); + expect(room.participants.length, 1); + }); + + test('participant disconnect', () async { + ws.onData(participantJoinResponse.writeToBuffer()); + await room.events.waitFor( + duration: const Duration(seconds: 1)); + + ws.onData(participantDisconnectResponse.writeToBuffer()); + expect( + room.events.streamCtrl.stream, + emitsInOrder([ + predicate( + (event) => event.participant.sid == remoteParticipantData.sid), + predicate( + (event) => event.participant.sid == remoteParticipantData.sid), + ]), + ); + + await room.events.waitFor( + duration: const Duration(seconds: 1)); + expect(room.participants.length, 0); + }); + + test('participant metadata changed', () async { + ws.onData(participantJoinResponse.writeToBuffer()); + await room.events.waitFor( + duration: const Duration(seconds: 1)); + + ws.onData(participantMetadataChangedResponse.writeToBuffer()); + expect( + room.events.streamCtrl.stream, + emits( + predicate((event) => + event.participant.metadata == + participantMetadataChangedResponse + .update.participants[0].metadata), + ), + ); + }); + + test('room update', () async { + ws.onData(roomUpdateResponse.writeToBuffer()); + // TODO: room update event and handling + }, skip: 'todo'); + + test('connection quality', () async { + expect( + room.events.streamCtrl.stream, + emits(predicate((event) => + event.participant.sid == localParticipantData.sid && + event.connectionQuality == ConnectionQuality.excellent)), + ); + ws.onData(connectionQualityResponse.writeToBuffer()); + }); + + test('active speakers changed', () async { + ws.onData(participantJoinResponse.writeToBuffer()); + await room.events.waitFor( + duration: const Duration(seconds: 1)); + + expect( + room.events.streamCtrl.stream, + emits( + predicate( + (event) => event.speakers[0].sid == remoteParticipantData.sid), + ), + ); + ws.onData(activeSpeakerResponse.writeToBuffer()); + }); + + test('leave', () async { + expect( + room.events.streamCtrl.stream, + emits(predicate((event) => + room.connectionState == ConnectionState.disconnected))); + ws.onData(leaveResponse.writeToBuffer()); + }); + }); +} diff --git a/test/core/signal_client_test.dart b/test/core/signal_client_test.dart new file mode 100644 index 0000000..80062b0 --- /dev/null +++ b/test/core/signal_client_test.dart @@ -0,0 +1,124 @@ +@Timeout(Duration(seconds: 5)) +import 'package:flutter_test/flutter_test.dart'; +import 'package:livekit_client/livekit_client.dart'; +import 'package:livekit_client/src/core/signal_client.dart'; +import 'package:livekit_client/src/internal/events.dart'; +import 'package:livekit_client/src/proto/livekit_models.pb.dart' as lk_models; +import 'package:livekit_client/src/proto/livekit_rtc.pb.dart' as lk_rtc; +import 'package:protobuf/protobuf.dart'; + +import '../mock/test_data.dart'; +import '../mock/websocket_mock.dart'; + +void main() { + late SignalClient client; + late MockWebSocketConnector connector; + setUp(() async { + connector = MockWebSocketConnector(); + client = SignalClient(connector.connect); + }); + + group('connection', () { + test('connect', () async { + expect( + client.events.streamCtrl.stream, + emitsInOrder([ + predicate( + (event) => event.connectionState == ConnectionState.connecting), + predicate( + (event) => event.connectionState == ConnectionState.connected), + ])); + await client.connect(exampleUri, token); + }); + test('reconnect', () async { + expect( + client.events.streamCtrl.stream, + emitsInOrder([ + predicate((event) => + event.connectionState == ConnectionState.reconnecting), + predicate((event) => + event.connectionState == ConnectionState.connected && + event.didReconnect == true), + ])); + await client.connect(exampleUri, token, reconnect: true); + }); + }); + + group('messaging', () { + test('join', () async { + await client.connect(exampleUri, token); + expect(client.events.streamCtrl.stream, + emits(isA())); + connector.handlers?.onData!(joinResponse.writeToBuffer()); + }); + }); +} + +final lk_rtc.SignalResponse joinResponse = lk_rtc.SignalResponse( + join: lk_rtc.JoinResponse( + room: lk_models.Room( + name: 'room_name', + sid: 'room_sid', + ), + participant: localParticipantData, + subscriberPrimary: true, + serverVersion: '99.999', + ), +); + +final lk_rtc.SignalResponse offerResponse = lk_rtc.SignalResponse( + offer: lk_rtc.SessionDescription( + sdp: 'remote_offer', + type: 'offer', +)); + +final lk_rtc.SignalResponse participantJoinResponse = lk_rtc.SignalResponse( + update: lk_rtc.ParticipantUpdate( + participants: [remoteParticipantData], + ), +); + +final lk_rtc.SignalResponse participantDisconnectResponse = + lk_rtc.SignalResponse( + update: lk_rtc.ParticipantUpdate( + participants: [ + remoteParticipantData.deepCopy() + ..state = lk_models.ParticipantInfo_State.DISCONNECTED, + ], + ), +); +final lk_rtc.SignalResponse participantMetadataChangedResponse = + lk_rtc.SignalResponse( + update: lk_rtc.ParticipantUpdate( + participants: [ + remoteParticipantData.deepCopy()..metadata = 'metadata_changed', + ], + ), +); + +final lk_rtc.SignalResponse roomUpdateResponse = lk_rtc.SignalResponse( + roomUpdate: lk_rtc.RoomUpdate( + room: lk_models.Room( + metadata: 'changed_metadata', + ), + ), +); + +final lk_rtc.SignalResponse connectionQualityResponse = lk_rtc.SignalResponse( + connectionQuality: lk_rtc.ConnectionQualityUpdate(updates: [ + lk_rtc.ConnectionQualityInfo( + participantSid: localParticipantData.sid, + quality: lk_models.ConnectionQuality.EXCELLENT, + ) + ]), +); + +final lk_rtc.SignalResponse activeSpeakerResponse = lk_rtc.SignalResponse( + speakersChanged: lk_rtc.SpeakersChanged( + speakers: [remoteSpeakerInfo], + ), +); +final lk_rtc.SignalResponse leaveResponse = + lk_rtc.SignalResponse(leave: lk_rtc.LeaveRequest()); +const exampleUri = 'ws://www.example.com'; +const token = 'token'; diff --git a/test/mock/datachannel_mock.dart b/test/mock/datachannel_mock.dart new file mode 100644 index 0000000..04f346c --- /dev/null +++ b/test/mock/datachannel_mock.dart @@ -0,0 +1,23 @@ +import 'package:flutter_webrtc/flutter_webrtc.dart'; + +class MockDataChannel extends RTCDataChannel { + final String? _label; + RTCDataChannelState? _state = RTCDataChannelState.RTCDataChannelOpen; + + MockDataChannel(this._label); + + @override + String? get label => _label; + + @override + Future send(RTCDataChannelMessage message) async {} + + @override + RTCDataChannelState? get state => _state; + + @override + Future close() async { + _state = RTCDataChannelState.RTCDataChannelClosing; + _state = RTCDataChannelState.RTCDataChannelClosed; + } +} diff --git a/test/mock/e2e_container.dart b/test/mock/e2e_container.dart new file mode 100644 index 0000000..34bfef3 --- /dev/null +++ b/test/mock/e2e_container.dart @@ -0,0 +1,35 @@ +import 'package:livekit_client/livekit_client.dart'; +import 'package:livekit_client/src/core/engine.dart'; +import 'package:livekit_client/src/core/signal_client.dart'; + +import '../core/signal_client_test.dart'; +import 'peerconnection_mock.dart'; +import 'websocket_mock.dart'; + +class E2EContainer { + late MockWebSocketConnector wsConnector; + late SignalClient client; + late Room room; + late Engine engine; + + E2EContainer() { + wsConnector = MockWebSocketConnector(); + client = SignalClient(wsConnector.connect); + engine = Engine( + signalClient: client, peerConnectionCreate: MockPeerConnection.create); + room = Room(engine: engine); + } + + Future dispose() async { + await room.dispose(); + } + + Future connectRoom() async { + final connectFuture = room.connect(exampleUri, token); + Future.delayed(const Duration(milliseconds: 1), () { + wsConnector.onData(joinResponse.writeToBuffer()); + wsConnector.onData(offerResponse.writeToBuffer()); + }); + await connectFuture; + } +} diff --git a/test/mock/e2e_container_test.dart b/test/mock/e2e_container_test.dart new file mode 100644 index 0000000..cdf5e54 --- /dev/null +++ b/test/mock/e2e_container_test.dart @@ -0,0 +1,25 @@ +@Timeout(Duration(seconds: 5)) +import 'package:flutter_test/flutter_test.dart'; +import 'package:livekit_client/livekit_client.dart'; + +import '../core/signal_client_test.dart'; +import '../mock/e2e_container.dart'; + +void main() { + late E2EContainer container; + late Room room; + setUp(() async { + container = E2EContainer(); + room = container.room; + }); + + tearDown(() async { + await container.dispose(); + }); + + test('connect', () async { + await container.connectRoom(); + expect(room.connectionState, ConnectionState.connected); + expect(room.localParticipant?.sid, joinResponse.join.participant.sid); + }); +} diff --git a/test/mock/peerconnection_mock.dart b/test/mock/peerconnection_mock.dart new file mode 100644 index 0000000..f2df6b4 --- /dev/null +++ b/test/mock/peerconnection_mock.dart @@ -0,0 +1,227 @@ +import 'package:flutter_webrtc/flutter_webrtc.dart'; + +import 'datachannel_mock.dart'; + +class MockPeerConnection extends RTCPeerConnection { + static const _offerType = 'offer'; + static const _answerType = 'answer'; + + bool closed = false; + RTCSessionDescription? _localDescription; + RTCSessionDescription? _remoteDescription; + + RTCPeerConnectionState _connectionState = + RTCPeerConnectionState.RTCPeerConnectionStateNew; + RTCIceConnectionState _iceConnectionState = + RTCIceConnectionState.RTCIceConnectionStateNew; + RTCIceGatheringState _iceGatheringState = + RTCIceGatheringState.RTCIceGatheringStateNew; + + @override + Future getLocalDescription() async => + _localDescription; + + @override + Future setLocalDescription(RTCSessionDescription description) async { + _localDescription = description; + _handleIceConnection(); + } + + @override + Future getRemoteDescription() async => + _remoteDescription; + + @override + Future setRemoteDescription(RTCSessionDescription description) async { + _remoteDescription = description; + _handleIceConnection(); + } + + void _handleIceConnection() { + if ((_localDescription?.type == _offerType && + _remoteDescription?.type == _answerType) || + (_localDescription?.type == _answerType && + _remoteDescription?.type == _offerType)) { + iceConnectionState = RTCIceConnectionState.RTCIceConnectionStateCompleted; + } + } + + @override + RTCPeerConnectionState get connectionState => _connectionState; + + set connectionState(RTCPeerConnectionState newState) { + if (newState != _connectionState) { + _connectionState = newState; + onConnectionState?.call(newState); + } + } + + @override + RTCIceConnectionState get iceConnectionState => _iceConnectionState; + + set iceConnectionState(RTCIceConnectionState newState) { + if (newState != _iceConnectionState) { + _iceConnectionState = newState; + onIceConnectionState?.call(newState); + + switch (newState) { + case RTCIceConnectionState.RTCIceConnectionStateNew: + connectionState = RTCPeerConnectionState.RTCPeerConnectionStateNew; + break; + case RTCIceConnectionState.RTCIceConnectionStateChecking: + connectionState = + RTCPeerConnectionState.RTCPeerConnectionStateConnecting; + break; + case RTCIceConnectionState.RTCIceConnectionStateConnected: + case RTCIceConnectionState.RTCIceConnectionStateCompleted: + connectionState = + RTCPeerConnectionState.RTCPeerConnectionStateConnected; + break; + case RTCIceConnectionState.RTCIceConnectionStateFailed: + connectionState = RTCPeerConnectionState.RTCPeerConnectionStateFailed; + break; + case RTCIceConnectionState.RTCIceConnectionStateDisconnected: + connectionState = + RTCPeerConnectionState.RTCPeerConnectionStateDisconnected; + break; + case RTCIceConnectionState.RTCIceConnectionStateClosed: + connectionState = RTCPeerConnectionState.RTCPeerConnectionStateClosed; + break; + case RTCIceConnectionState.RTCIceConnectionStateCount: + throw Exception("This state shouldn't exist (not in RFC)."); + } + } + } + + @override + RTCIceGatheringState get iceGatheringState => _iceGatheringState; + + set iceGatheringState(RTCIceGatheringState newState) { + if (newState != _iceGatheringState) { + _iceGatheringState = newState; + onIceGatheringState?.call(newState); + } + } + + @override + Future addCandidate(RTCIceCandidate candidate) async {} + + @override + Future addStream(MediaStream stream) async {} + + @override + Future addTrack(MediaStreamTrack track, + [MediaStream? stream]) async { + // TODO: implement addTrack + throw UnimplementedError(); + } + + @override + Future addTransceiver( + {MediaStreamTrack? track, + RTCRtpMediaType? kind, + RTCRtpTransceiverInit? init}) { + // TODO: implement addTransceiver + throw UnimplementedError(); + } + + @override + Future close() async { + closed = true; + } + + @override + Future createAnswer( + [Map? constraints]) async { + return RTCSessionDescription('local_answer', 'answer'); + } + + @override + Future createOffer( + [Map? constraints]) async { + return RTCSessionDescription('local_offer', 'offer'); + } + + @override + RTCSignalingState? get signalingState { + if (closed) { + return RTCSignalingState.RTCSignalingStateClosed; + } + + if ((_localDescription?.type == null && _remoteDescription?.type == null) || + (_localDescription?.type == _offerType && + _remoteDescription?.type == _answerType) || + (_localDescription?.type == _answerType && + _remoteDescription?.type == _offerType)) { + return RTCSignalingState.RTCSignalingStateStable; + } + + if (_localDescription?.type == _offerType && + _remoteDescription?.type == null) { + return RTCSignalingState.RTCSignalingStateHaveLocalOffer; + } + if (_remoteDescription?.type == _offerType && + _localDescription?.type == null) { + return RTCSignalingState.RTCSignalingStateHaveRemoteOffer; + } + + throw Exception( + 'Illegal signalling state? localDesc: $_localDescription, remoteDesc: $_remoteDescription'); + } + + @override + Future createDataChannel( + String label, RTCDataChannelInit dataChannelDict) async { + return MockDataChannel(label); + } + + @override + RTCDTMFSender createDtmfSender(MediaStreamTrack track) { + // TODO: implement createDtmfSender + throw UnimplementedError(); + } + + @override + Future dispose() async { + await close(); + } + + @override + // TODO: implement getConfiguration + Map get getConfiguration => throw UnimplementedError(); + + @override + List getLocalStreams() => List.empty(); + + @override + Future> getReceivers() async => List.empty(); + + @override + List getRemoteStreams() => List.empty(); + + @override + Future> getSenders() async => List.empty(); + + @override + Future> getStats([MediaStreamTrack? track]) async => + List.empty(); + + @override + Future> getTransceivers() async => List.empty(); + + @override + Future removeStream(MediaStream stream) async {} + + @override + Future removeTrack(RTCRtpSender sender) async => true; + + @override + Future setConfiguration(Map configuration) { + // TODO: implement setConfiguration + throw UnimplementedError(); + } + + static Future create(Map configuration, + [Map? constraints]) async => + MockPeerConnection(); +} diff --git a/test/mock/test_data.dart b/test/mock/test_data.dart new file mode 100644 index 0000000..3d27c27 --- /dev/null +++ b/test/mock/test_data.dart @@ -0,0 +1,31 @@ +import 'package:livekit_client/livekit_client.dart'; +import 'package:livekit_client/src/proto/livekit_models.pb.dart' as lk_models; + +final localAudioTrack = lk_models.TrackInfo( + sid: 'local_audio_track_sid', + type: TrackType.AUDIO, +); + +final remoteAudioTrack = lk_models.TrackInfo( + sid: 'remote_audio_track_sid', + type: TrackType.AUDIO, +); + +final localParticipantData = lk_models.ParticipantInfo( + sid: 'local_participant_sid', + identity: 'local_participant_identity', + state: lk_models.ParticipantInfo_State.ACTIVE, +); + +final remoteParticipantData = lk_models.ParticipantInfo( + sid: 'remote_participant_sid', + identity: 'remote_participant_identity', + state: lk_models.ParticipantInfo_State.ACTIVE, + tracks: [remoteAudioTrack], +); + +final remoteSpeakerInfo = lk_models.SpeakerInfo( + sid: remoteParticipantData.sid, + level: 1.0, + active: true, +); diff --git a/test/mock/websocket_mock.dart b/test/mock/websocket_mock.dart new file mode 100644 index 0000000..59ad8c0 --- /dev/null +++ b/test/mock/websocket_mock.dart @@ -0,0 +1,22 @@ +import 'package:livekit_client/src/support/websocket.dart'; + +class MockWebSocket extends LiveKitWebSocket { + @override + void send(List data) {} +} + +class MockWebSocketConnector { + WebSocketEventHandlers? handlers; + + WebSocketOnData get onData => handlers!.onData!; + + WebSocketOnDispose get onDispose => handlers!.onDispose!; + + WebSocketOnError get onError => handlers!.onError!; + + Future connect(Uri uri, + [WebSocketEventHandlers? options]) async { + handlers = options; + return MockWebSocket(); + } +}