diff --git a/__mocks__/config.mock.ts b/__mocks__/config.mock.ts index 2c279909..50f3aca4 100644 --- a/__mocks__/config.mock.ts +++ b/__mocks__/config.mock.ts @@ -5,7 +5,8 @@ export const MOCK_CONFIG: Configuration = { ablyKey: 'unit-test-ably-key', apiKey: 'unit-test-api-key', apiUrl: 'unit-test-api-url', - conferenceLayerUrl: 'unit-test-conference-layer-url', + conferenceLayerUrl: 'https://unit-test-conference-layer-url', environment: EnvironmentTypes.DEV, roomId: 'unit-test-room-id', + debug: true, }; diff --git a/__mocks__/realtime.mock.ts b/__mocks__/realtime.mock.ts index a83df9b0..99e1384a 100644 --- a/__mocks__/realtime.mock.ts +++ b/__mocks__/realtime.mock.ts @@ -33,6 +33,7 @@ export const MOCK_REALTIME_SERVICE: AblyRealtimeService = { } as unknown as AblyRealtimeService; export const ABLY_REALTIME_MOCK = { + isLocalParticipantHost: true, setGather: jest.fn(), setParticipantData: jest.fn(), setSyncProperty: jest.fn(), diff --git a/src/core/index.test.ts b/src/core/index.test.ts index ea13386a..e6d8c1a8 100644 --- a/src/core/index.test.ts +++ b/src/core/index.test.ts @@ -1,7 +1,134 @@ -import * as Core from '.'; +import { Group, Participant } from '../common/types/participant.types'; +import { SuperVizSdkOptions } from '../common/types/sdk-options.types'; +import ApiService from '../services/api'; +import RemoteConfigService from '../services/remote-config-service'; -describe('Core', () => { - test('should be export Laucher', () => { - expect(Core.Laucher).toBeDefined(); +import sdk from '.'; + +const REMOTE_CONFIG_MOCK = { + apiUrl: 'https://dev.nodeapi.superviz.com', + conferenceLayerUrl: 'https://video-conference-layer.superviz.com/14.0.1-rc.2/index.html', +}; + +const COMMUNICATOR_INSTANCE_MOCK = { + setSyncProperty: jest.fn(), + subscribe: jest.fn(), + unsubscribe: jest.fn(), + destroy: jest.fn(), + follow: jest.fn(), + fetchSyncProperty: jest.fn(), + gather: jest.fn(), + goTo: jest.fn(), + toggleMeetingSetup: jest.fn(), + toggleMicrophone: jest.fn(), + toggleCam: jest.fn(), + toggleScreenShare: jest.fn(), + hangUp: jest.fn(), + toggleChat: jest.fn(), + startTranscription: jest.fn(), + stopTranscription: jest.fn(), + loadPlugin: jest.fn(), + unloadPlugin: jest.fn(), +}; + +const UNIT_TEST_API_KEY = 'unit-test-api-key'; + +const SIMPLE_INITIALIZATION_MOCK: SuperVizSdkOptions = { + roomId: 'unit-test-room-id', + participant: { + id: 'unit-test-participant-id', + name: 'unit-test-participant-name', + }, + group: { + name: 'unit-test-group-test-name', + id: 'unit-test-group-test-id', + }, +}; + +jest.mock('../services/api'); +jest.mock('../services/auth-service', () => ({ + __esModule: true, + default: jest.fn().mockImplementation((_, apiKey: string) => { + if (apiKey === UNIT_TEST_API_KEY) { + return true; + } + + return false; + }), +})); +jest.mock('../services/remote-config-service'); +jest.mock('../services/communicator', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => { + return COMMUNICATOR_INSTANCE_MOCK; + }), +})); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('root export', () => { + test('should export init function', () => { + expect(sdk).toEqual(expect.any(Function)); + }); +}); + +describe('initialization errors', () => { + test('should throw an error if no API key is provided', async () => { + await expect(sdk('', SIMPLE_INITIALIZATION_MOCK)).rejects.toThrow('API key is required'); + }); + + test('should throw an error if API key is invalid', async () => { + RemoteConfigService.getRemoteConfig = jest.fn().mockResolvedValue(REMOTE_CONFIG_MOCK); + + await expect(sdk('invalid-api-key', SIMPLE_INITIALIZATION_MOCK)).rejects.toThrow( + 'Failed to validate API key', + ); + }); + + test('should throw an error if no options are provided', async () => { + await expect( + sdk(UNIT_TEST_API_KEY, undefined as unknown as SuperVizSdkOptions), + ).rejects.toThrow('Options is required'); + }); + + test('should throw an error if no room id is provided', async () => { + await expect( + sdk(UNIT_TEST_API_KEY, { ...SIMPLE_INITIALIZATION_MOCK, roomId: '' }), + ).rejects.toThrow('Room id is required'); + }); + + test('should throw an error if no participant id is provided', async () => { + await expect( + sdk(UNIT_TEST_API_KEY, { + ...SIMPLE_INITIALIZATION_MOCK, + participant: { name: 'unit-test-participant-name' } as Participant, + }), + ).rejects.toThrow('Participants fields is required'); + + await expect( + sdk(UNIT_TEST_API_KEY, { + ...SIMPLE_INITIALIZATION_MOCK, + participant: undefined as unknown as Participant, + }), + ).rejects.toThrow('Participants fields is required'); + }); + + test('should throw an error if no group name is provided', async () => { + await expect( + sdk(UNIT_TEST_API_KEY, { + ...SIMPLE_INITIALIZATION_MOCK, + group: { id: 'unit-test-group-test-id' } as Group, + }), + ).rejects.toThrow('Group fields is required'); + }); + + test('should throw an error if envoriment is invalid', async () => { + ApiService.fetchConfig = jest.fn().mockResolvedValue(undefined); + + expect(sdk(UNIT_TEST_API_KEY, SIMPLE_INITIALIZATION_MOCK)).rejects.toThrow( + 'Failed to load configuration from server', + ); }); }); diff --git a/src/core/index.ts b/src/core/index.ts index 3fa4e82f..8752f655 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1 +1,88 @@ -export { default as Laucher } from './laucher'; +import { debug } from 'debug'; + +import { SuperVizSdkOptions } from '../common/types/sdk-options.types'; +import ApiService from '../services/api'; +import AuthService from '../services/auth-service'; +import { BrowserService } from '../services/browser'; +import config from '../services/config'; +import RemoteConfigService from '../services/remote-config-service'; +import { SuperVizSdk } from '../types'; + +import LauncherFacade from './launcher'; + +/** + * @function validateOptions + * @description Validate the options passed to the SDK + * @param {SuperVizSdkOptions} param + * @returns {void} + */ +const validateOptions = ({ group, participant, roomId }: SuperVizSdkOptions): void => { + if (!group || !group.name || !group.id) { + throw new Error('Group fields is required'); + } + + if (!participant || !participant.id) { + throw new Error('Participants fields is required'); + } + + if (!roomId) { + throw new Error('Room id is required'); + } +}; + +/** + * @function init + * @description Initialize the SDK + * @param apiKey - API key + * @param options - SDK options + * @returns {SuperVizSdk} + */ +const init = async (apiKey: string, options: SuperVizSdkOptions): Promise => { + const validApiKey = apiKey && apiKey.trim(); + + if (!validApiKey) throw new Error('API key is required'); + + if (!options) throw new Error('Options is required'); + + validateOptions(options); + + if (options.debug) { + debug.enable('*'); + } else { + debug.disable(); + } + + const { apiUrl, conferenceLayerUrl } = await RemoteConfigService.getRemoteConfig( + options.environment, + ); + + const isValid = await AuthService(apiUrl, apiKey); + + if (!isValid) { + throw new Error('Failed to validate API key'); + } + + const environment = await ApiService.fetchConfig(apiUrl, apiKey); + + if (!environment || !environment.ablyKey) { + throw new Error('Failed to load configuration from server'); + } + + const { ablyKey } = environment; + + config.setConfig({ + apiUrl, + ablyKey, + apiKey, + conferenceLayerUrl, + environment, + roomId: options.roomId, + debug: options.debug, + }); + + return LauncherFacade( + Object.assign({}, options, { apiKey, ablyKey, conferenceLayerUrl, apiUrl }), + ); +}; + +export default init; diff --git a/src/core/laucher/index.test.ts b/src/core/launcher/index.test.ts similarity index 65% rename from src/core/laucher/index.test.ts rename to src/core/launcher/index.test.ts index 05a3115a..0169049d 100644 --- a/src/core/laucher/index.test.ts +++ b/src/core/launcher/index.test.ts @@ -1,13 +1,13 @@ import { MOCK_CONFIG } from '../../../__mocks__/config.mock'; import { MOCK_GROUP, MOCK_LOCAL_PARTICIPANT } from '../../../__mocks__/participants.mock'; import { ABLY_REALTIME_MOCK } from '../../../__mocks__/realtime.mock'; -import { ParticipantEvent } from '../../common/types/events.types'; +import { ParticipantEvent, RealtimeEvent } from '../../common/types/events.types'; import { BaseComponent } from '../../components/base'; import { AblyParticipant } from '../../services/realtime/ably/types'; -import { LaucherFacade, LaucherOptions } from './types'; +import { LauncherFacade, LauncherOptions } from './types'; -import Facade, { Laucher } from '.'; +import Facade, { Launcher } from '.'; jest.mock('../../services/realtime', () => ({ AblyRealtimeService: jest.fn().mockImplementation(() => ABLY_REALTIME_MOCK), @@ -18,7 +18,7 @@ const MOCK_COMPONENT = { detach: jest.fn(), } as unknown as BaseComponent; -const DEFAULT_INITIALIZATION_MOCK: LaucherOptions = { +const DEFAULT_INITIALIZATION_MOCK: LauncherOptions = { participant: MOCK_LOCAL_PARTICIPANT, group: MOCK_GROUP, }; @@ -35,17 +35,17 @@ jest.mock('../../services/pubsub', () => ({ PubSub: jest.fn().mockImplementation(() => PUB_SUB_MOCK), })); -describe('Laucher', () => { - let LaucherInstance: Laucher; +describe('Launcher', () => { + let LauncherInstance: Launcher; beforeEach(() => { jest.clearAllMocks(); - LaucherInstance = new Laucher(DEFAULT_INITIALIZATION_MOCK); + LauncherInstance = new Launcher(DEFAULT_INITIALIZATION_MOCK); }); test('should be defined', () => { - expect(Laucher).toBeDefined(); + expect(Launcher).toBeDefined(); }); test('should be inicialize realtime service', () => { @@ -56,7 +56,7 @@ describe('Laucher', () => { test('should be subscribe to event', () => { const callback = jest.fn(); - LaucherInstance.subscribeToPubSubEvent('test', callback); + LauncherInstance.subscribeToPubSubEvent('test', callback); expect(PUB_SUB_MOCK.subscribe).toHaveBeenCalledWith('test', callback); }); @@ -64,19 +64,19 @@ describe('Laucher', () => { test('should be unsubscribe from event', () => { const callback = jest.fn(); - LaucherInstance.unsubscribeFromPubSubEvent('test', callback); + LauncherInstance.unsubscribeFromPubSubEvent('test', callback); expect(PUB_SUB_MOCK.unsubscribe).toHaveBeenCalledWith('test', callback); }); test('should be publish event to realtime', () => { - LaucherInstance.publishToPubSubEvent('test', 'test'); + LauncherInstance.publishToPubSubEvent('test', 'test'); expect(PUB_SUB_MOCK.publish).toHaveBeenCalledWith('test', 'test'); }); test('should be fetch history', async () => { - LaucherInstance.fetchPubSubHistory('test'); + LauncherInstance.fetchPubSubHistory('test'); expect(PUB_SUB_MOCK.fetchHistory).toHaveBeenCalledWith('test'); }); @@ -84,7 +84,7 @@ describe('Laucher', () => { describe('Components', () => { test('should be add component', () => { - LaucherInstance.addComponent(MOCK_COMPONENT); + LauncherInstance.addComponent(MOCK_COMPONENT); expect(MOCK_COMPONENT.attach).toHaveBeenCalledWith({ localParticipant: MOCK_LOCAL_PARTICIPANT, @@ -93,7 +93,7 @@ describe('Laucher', () => { }); test('should be remove component', () => { - LaucherInstance.removeComponent(MOCK_COMPONENT); + LauncherInstance.removeComponent(MOCK_COMPONENT); expect(MOCK_COMPONENT.detach).toHaveBeenCalled(); }); @@ -102,9 +102,9 @@ describe('Laucher', () => { describe('Participant Events', () => { test('should publish ParticipantEvent.JOINED event', () => { const callback = jest.fn(); - LaucherInstance.subscribeToPubSubEvent(ParticipantEvent.LIST_UPDATED, callback); + LauncherInstance.subscribeToPubSubEvent(ParticipantEvent.LIST_UPDATED, callback); - LaucherInstance['onParticipantListUpdate']({ + LauncherInstance['onParticipantListUpdate']({ participant1: { clientId: 'client1', action: 'present', @@ -124,9 +124,9 @@ describe('Laucher', () => { test('should publish ParticipantEvent.LOCAL_UPDATED event', () => { const callback = jest.fn(); - LaucherInstance.subscribeToPubSubEvent(ParticipantEvent.JOINED, callback); + LauncherInstance.subscribeToPubSubEvent(ParticipantEvent.JOINED, callback); - LaucherInstance['onParticipantListUpdate']({ + LauncherInstance['onParticipantListUpdate']({ [MOCK_LOCAL_PARTICIPANT.id]: { clientId: 'client1', action: 'present', @@ -146,7 +146,7 @@ describe('Laucher', () => { test('should publish ParticipantEvent.LEFT event', () => { const callback = jest.fn(); - LaucherInstance.subscribeToPubSubEvent(ParticipantEvent.LEFT, callback); + LauncherInstance.subscribeToPubSubEvent(ParticipantEvent.LEFT, callback); const participant = { clientId: 'client1', @@ -160,10 +160,10 @@ describe('Laucher', () => { }, }; - LaucherInstance['onParticipantListUpdate']({ + LauncherInstance['onParticipantListUpdate']({ [participant.data.participantId]: participant as AblyParticipant, }); - LaucherInstance['onParticipantLeave'](participant as AblyParticipant); + LauncherInstance['onParticipantLeave'](participant as AblyParticipant); expect(PUB_SUB_MOCK.subscribe).toHaveBeenCalledWith(ParticipantEvent.LEFT, callback); expect(PUB_SUB_MOCK.publishEventToClient).toHaveBeenCalled(); @@ -171,7 +171,7 @@ describe('Laucher', () => { test('should skip and not publish ParticipantEvent.LEFT event', () => { const callback = jest.fn(); - LaucherInstance.subscribeToPubSubEvent(ParticipantEvent.LEFT, callback); + LauncherInstance.subscribeToPubSubEvent(ParticipantEvent.LEFT, callback); const participant = { clientId: 'client1', @@ -185,7 +185,7 @@ describe('Laucher', () => { }, }; - LaucherInstance['onParticipantLeave'](participant as AblyParticipant); + LauncherInstance['onParticipantLeave'](participant as AblyParticipant); expect(PUB_SUB_MOCK.subscribe).toHaveBeenCalledWith(ParticipantEvent.LEFT, callback); expect(PUB_SUB_MOCK.publishEventToClient).not.toHaveBeenCalled(); @@ -193,7 +193,7 @@ describe('Laucher', () => { test('should publish ParticipantEvent.LOCAL_LEFT event', () => { const callback = jest.fn(); - LaucherInstance.subscribeToPubSubEvent(ParticipantEvent.LEFT, callback); + LauncherInstance.subscribeToPubSubEvent(ParticipantEvent.LEFT, callback); const participant = { clientId: 'client1', @@ -208,10 +208,10 @@ describe('Laucher', () => { }, }; - LaucherInstance['onParticipantListUpdate']({ + LauncherInstance['onParticipantListUpdate']({ [MOCK_LOCAL_PARTICIPANT.id]: participant as AblyParticipant, }); - LaucherInstance['onParticipantLeave'](participant as AblyParticipant); + LauncherInstance['onParticipantLeave'](participant as AblyParticipant); expect(PUB_SUB_MOCK.subscribe).toHaveBeenCalledWith(ParticipantEvent.LEFT, callback); expect(PUB_SUB_MOCK.publishEventToClient).toHaveBeenCalled(); @@ -219,7 +219,7 @@ describe('Laucher', () => { test('should publish ParticipantEvent.JOINED event', () => { const callback = jest.fn(); - LaucherInstance.subscribeToPubSubEvent(ParticipantEvent.JOINED, callback); + LauncherInstance.subscribeToPubSubEvent(ParticipantEvent.JOINED, callback); const participant = { clientId: 'client1', @@ -234,10 +234,10 @@ describe('Laucher', () => { }, }; - LaucherInstance['onParticipantListUpdate']({ + LauncherInstance['onParticipantListUpdate']({ [MOCK_LOCAL_PARTICIPANT.id]: participant as AblyParticipant, }); - LaucherInstance['onParticipantJoined'](participant as AblyParticipant); + LauncherInstance['onParticipantJoined'](participant as AblyParticipant); expect(PUB_SUB_MOCK.subscribe).toHaveBeenCalledWith(ParticipantEvent.JOINED, callback); expect(PUB_SUB_MOCK.publishEventToClient).toHaveBeenCalled(); @@ -245,7 +245,7 @@ describe('Laucher', () => { test('should skip and publish ParticipantEvent.JOINED event', () => { const callback = jest.fn(); - LaucherInstance.subscribeToPubSubEvent(ParticipantEvent.JOINED, callback); + LauncherInstance.subscribeToPubSubEvent(ParticipantEvent.JOINED, callback); const participant = { clientId: 'client1', @@ -260,20 +260,54 @@ describe('Laucher', () => { }, }; - LaucherInstance['onParticipantJoined'](participant as AblyParticipant); + LauncherInstance['onParticipantJoined'](participant as AblyParticipant); expect(PUB_SUB_MOCK.subscribe).toHaveBeenCalledWith(ParticipantEvent.JOINED, callback); expect(PUB_SUB_MOCK.publishEventToClient).not.toHaveBeenCalled(); }); + + test("should not publish RealtimeEvent.REALTIME_HOST_CHANGE if my participant isn't host", () => { + const callback = jest.fn(); + LauncherInstance.subscribeToPubSubEvent(RealtimeEvent.REALTIME_HOST_CHANGE, callback); + + LauncherInstance['onHostParticipantDidChange']({ + newHostParticipantId: MOCK_LOCAL_PARTICIPANT.id, + oldHostParticipantId: 'test', + }); + + expect(PUB_SUB_MOCK.subscribe).toHaveBeenCalledWith( + RealtimeEvent.REALTIME_HOST_CHANGE, + callback, + ); + expect(PUB_SUB_MOCK.publishEventToClient).not.toHaveBeenCalled(); + }); + + test('should publish RealtimeEvent.REALTIME_HOST_CHANGE', () => { + const callback = jest.fn(); + LauncherInstance.subscribeToPubSubEvent(RealtimeEvent.REALTIME_HOST_CHANGE, callback); + LauncherInstance['participants'] = [MOCK_LOCAL_PARTICIPANT]; + + LauncherInstance['onHostParticipantDidChange']({ + newHostParticipantId: MOCK_LOCAL_PARTICIPANT.id, + oldHostParticipantId: 'test', + }); + + expect(PUB_SUB_MOCK.subscribe).toHaveBeenCalledWith( + RealtimeEvent.REALTIME_HOST_CHANGE, + callback, + ); + + expect(PUB_SUB_MOCK.publish).toHaveBeenCalled(); + }); }); }); -describe('Laucher Facade', () => { - let LaucherFacadeInstance: LaucherFacade; +describe('Launcher Facade', () => { + let LauncherFacadeInstance: LauncherFacade; beforeEach(() => { jest.clearAllMocks(); - LaucherFacadeInstance = Facade(DEFAULT_INITIALIZATION_MOCK); + LauncherFacadeInstance = Facade(DEFAULT_INITIALIZATION_MOCK); }); test('should be defined', () => { @@ -281,11 +315,11 @@ describe('Laucher Facade', () => { }); test('should be return a facade with the correct methods', () => { - expect(LaucherFacadeInstance).toHaveProperty('subscribe'); - expect(LaucherFacadeInstance).toHaveProperty('unsubscribe'); - expect(LaucherFacadeInstance).toHaveProperty('publish'); - expect(LaucherFacadeInstance).toHaveProperty('fetchHistory'); - expect(LaucherFacadeInstance).toHaveProperty('addComponent'); - expect(LaucherFacadeInstance).toHaveProperty('removeComponent'); + expect(LauncherFacadeInstance).toHaveProperty('subscribe'); + expect(LauncherFacadeInstance).toHaveProperty('unsubscribe'); + expect(LauncherFacadeInstance).toHaveProperty('publish'); + expect(LauncherFacadeInstance).toHaveProperty('fetchHistory'); + expect(LauncherFacadeInstance).toHaveProperty('addComponent'); + expect(LauncherFacadeInstance).toHaveProperty('removeComponent'); }); }); diff --git a/src/core/laucher/index.ts b/src/core/launcher/index.ts similarity index 73% rename from src/core/laucher/index.ts rename to src/core/launcher/index.ts index 4041868f..f708b564 100644 --- a/src/core/laucher/index.ts +++ b/src/core/launcher/index.ts @@ -8,10 +8,11 @@ import config from '../../services/config'; import { PubSub } from '../../services/pubsub'; import { AblyRealtimeService } from '../../services/realtime'; import { AblyParticipant, RealtimeMessage } from '../../services/realtime/ably/types'; +import { HostObserverCallbackResponse } from '../../services/realtime/base/types'; -import { DefaultLaucher, LaucherFacade, LaucherOptions } from './types'; +import { DefaultLauncher, LauncherFacade, LauncherOptions } from './types'; -export class Laucher implements DefaultLaucher { +export class Launcher implements DefaultLauncher { private readonly shouldKickParticipantsOnHostLeave: boolean; private readonly logger: Logger; @@ -24,26 +25,26 @@ export class Laucher implements DefaultLaucher { private components: BaseComponent[] = []; private participants: Participant[] = []; - constructor({ participant, group, shouldKickParticipantsOnHostLeave }: LaucherOptions) { + constructor({ participant, group, shouldKickParticipantsOnHostLeave }: LauncherOptions) { this.shouldKickParticipantsOnHostLeave = shouldKickParticipantsOnHostLeave ?? true; this.participant = participant; this.group = group; - this.logger = new Logger('@superviz/sdk/laucher'); + this.logger = new Logger('@superviz/sdk/launcher'); this.realtime = new AblyRealtimeService( config.get('apiUrl'), config.get('ablyKey'), ); this.pubsub = new PubSub(this.realtime); - this.logger.log('laucher created'); + this.logger.log('launcher created'); this.startRealtime(); } /** * @function addComponent - * @description add component to laucher + * @description add component to launcher * @param component - component to add * @returns {void} */ @@ -56,7 +57,7 @@ export class Laucher implements DefaultLaucher { /** * @function removeComponent - * @description remove component from laucher + * @description remove component from launcher * @param component - component to remove * @returns {void} */ @@ -72,7 +73,7 @@ export class Laucher implements DefaultLaucher { * @returns {void} */ public subscribeToPubSubEvent = (event: string, callback: (data: unknown) => void): void => { - this.logger.log('laucher service @ subscribeToPubSubEvent'); + this.logger.log('launcher service @ subscribeToPubSubEvent'); this.pubsub.subscribe(event, callback); }; @@ -84,7 +85,7 @@ export class Laucher implements DefaultLaucher { * @returns {void} */ public unsubscribeFromPubSubEvent = (event: string, callback: (data: unknown) => void): void => { - this.logger.log('laucher service @ unsubscribeFromPubSubEvent'); + this.logger.log('launcher service @ unsubscribeFromPubSubEvent'); this.pubsub.unsubscribe(event, callback); }; @@ -96,7 +97,7 @@ export class Laucher implements DefaultLaucher { * @returns {void} */ public publishToPubSubEvent = (event: string, data: unknown): void => { - this.logger.log('laucher service @ publishToPubSubEvent'); + this.logger.log('launcher service @ publishToPubSubEvent'); this.pubsub.publish(event, data); }; @@ -118,7 +119,7 @@ export class Laucher implements DefaultLaucher { * @returns {void} */ private startRealtime = (): void => { - this.logger.log('laucher service @ startRealtime'); + this.logger.log('launcher service @ startRealtime'); this.realtime.start({ participant: this.participant, @@ -142,6 +143,7 @@ export class Laucher implements DefaultLaucher { this.realtime.participantJoinedObserver.subscribe(this.onParticipantJoined); this.realtime.participantLeaveObserver.subscribe(this.onParticipantLeave); this.realtime.participantsObserver.subscribe(this.onParticipantListUpdate); + this.realtime.hostObserver.subscribe(this.onHostParticipantDidChange); }; /** Realtime Listeners */ @@ -153,7 +155,7 @@ export class Laucher implements DefaultLaucher { * @returns {void} */ private onParticipantListUpdate = (participants: Record): void => { - this.logger.log('laucher service @ onParticipantListUpdate'); + this.logger.log('launcher service @ onParticipantListUpdate'); const participantList = Object.values(participants).map((participant) => ({ id: participant.data.id, @@ -191,7 +193,7 @@ export class Laucher implements DefaultLaucher { * @returns {void} */ private onParticipantJoined = (ablyParticipant: AblyParticipant): void => { - this.logger.log('laucher service @ onParticipantJoined'); + this.logger.log('launcher service @ onParticipantJoined'); const participant = this.participants.find( (participant) => participant.id === ablyParticipant.data.id, @@ -200,11 +202,11 @@ export class Laucher implements DefaultLaucher { if (!participant) return; if (participant.id === this.participant.id) { - this.logger.log('laucher service @ onParticipantJoined - local participant joined'); + this.logger.log('launcher service @ onParticipantJoined - local participant joined'); this.pubsub.publishEventToClient(ParticipantEvent.LOCAL_JOINED, participant); } - this.logger.log('laucher service @ onParticipantJoined - participant joined', participant); + this.logger.log('launcher service @ onParticipantJoined - participant joined', participant); this.pubsub.publishEventToClient(ParticipantEvent.JOINED, participant); }; @@ -215,7 +217,7 @@ export class Laucher implements DefaultLaucher { * @returns {void} */ private onParticipantLeave = (ablyParticipant: AblyParticipant): void => { - this.logger.log('laucher service @ onParticipantLeave'); + this.logger.log('launcher service @ onParticipantLeave'); const participant = this.participants.find((participant) => { return participant.id === ablyParticipant.data.id; @@ -224,30 +226,46 @@ export class Laucher implements DefaultLaucher { if (!participant) return; if (participant.id === this.participant.id) { - this.logger.log('laucher service @ onParticipantLeave - local participant left'); + this.logger.log('launcher service @ onParticipantLeave - local participant left'); this.pubsub.publishEventToClient(ParticipantEvent.LOCAL_LEFT, participant); } - this.logger.log('laucher service @ onParticipantLeave - participant left', participant); + this.logger.log('launcher service @ onParticipantLeave - participant left', participant); this.pubsub.publishEventToClient(ParticipantEvent.LEFT, participant); }; + + /** + * @function onHostParticipantDidChange + * @description handler for host participant change event + * @param {HostObserverCallbackResponse} data - host change data + * @returns {void} + * */ + private onHostParticipantDidChange = (data: HostObserverCallbackResponse): void => { + const newHost = this.participants.find((participant) => { + return participant.id === data?.newHostParticipantId; + }); + + if (this.realtime.isLocalParticipantHost) { + this.publishToPubSubEvent(RealtimeEvent.REALTIME_HOST_CHANGE, newHost); + } + }; } /** - * @function Laucher - * @description create laucher instance - * @param options - laucher options - * @returns {LaucherFacade} + * @function Launcher + * @description create launcher instance + * @param options - launcher options + * @returns {LauncherFacade} */ -export default (options: LaucherOptions): LaucherFacade => { - const laucher = new Laucher(options); +export default (options: LauncherOptions): LauncherFacade => { + const launcher = new Launcher(options); return { - subscribe: laucher.subscribeToPubSubEvent, - unsubscribe: laucher.unsubscribeFromPubSubEvent, - publish: laucher.publishToPubSubEvent, - fetchHistory: laucher.fetchPubSubHistory, - addComponent: laucher.addComponent, - removeComponent: laucher.removeComponent, + subscribe: launcher.subscribeToPubSubEvent, + unsubscribe: launcher.unsubscribeFromPubSubEvent, + publish: launcher.publishToPubSubEvent, + fetchHistory: launcher.fetchPubSubHistory, + addComponent: launcher.addComponent, + removeComponent: launcher.removeComponent, }; }; diff --git a/src/core/laucher/types.ts b/src/core/launcher/types.ts similarity index 84% rename from src/core/laucher/types.ts rename to src/core/launcher/types.ts index 25b28ebb..7053e456 100644 --- a/src/core/laucher/types.ts +++ b/src/core/launcher/types.ts @@ -2,12 +2,12 @@ import { SuperVizSdkOptions } from '../../common/types/sdk-options.types'; import { BaseComponent } from '../../components/base'; import { PubSub } from '../../services/pubsub'; -export interface DefaultLaucher {} +export interface DefaultLauncher {} -export interface LaucherOptions +export interface LauncherOptions extends Omit {} -export interface LaucherFacade { +export interface LauncherFacade { subscribe: typeof PubSub.prototype.subscribe; unsubscribe: typeof PubSub.prototype.unsubscribe; publish: typeof PubSub.prototype.publish; diff --git a/src/index.test.ts b/src/index.test.ts deleted file mode 100644 index e2758bfe..00000000 --- a/src/index.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import debug from 'debug'; - -import ApiService from './services/api'; -import RemoteConfigService from './services/remote-config-service'; -import { ColorsVariables } from './services/video-conference-manager/types'; - -import sdk, { - Group, - Participant, - SuperVizSdkOptions, - BrowserService, - DeviceEvent, - MeetingConnectionStatus, - MeetingControlsEvent, - MeetingEvent, - MeetingState, - ParticipantType, - RealtimeEvent, -} from '.'; -import * as DEFAULT_EXPORT from './index'; - -const REMOTE_CONFIG_MOCK = { - apiUrl: 'https://dev.nodeapi.superviz.com', - conferenceLayerUrl: 'https://video-conference-layer.superviz.com/14.0.1-rc.2/index.html', -}; - -const COMMUNICATOR_INSTANCE_MOCK = { - setSyncProperty: jest.fn(), - subscribe: jest.fn(), - unsubscribe: jest.fn(), - destroy: jest.fn(), - follow: jest.fn(), - fetchSyncProperty: jest.fn(), - gather: jest.fn(), - goTo: jest.fn(), - toggleMeetingSetup: jest.fn(), - toggleMicrophone: jest.fn(), - toggleCam: jest.fn(), - toggleScreenShare: jest.fn(), - hangUp: jest.fn(), - toggleChat: jest.fn(), - startTranscription: jest.fn(), - stopTranscription: jest.fn(), - loadPlugin: jest.fn(), - unloadPlugin: jest.fn(), -}; - -const UNIT_TEST_API_KEY = 'unit-test-api-key'; - -const SIMPLE_INITIALIZATION_MOCK: SuperVizSdkOptions = { - roomId: 'unit-test-room-id', - participant: { - id: 'unit-test-participant-id', - name: 'unit-test-participant-name', - }, - group: { - name: 'unit-test-group-test-name', - id: 'unit-test-group-test-id', - }, -}; - -jest.mock('./services/api'); -jest.mock('./services/auth-service', () => ({ - __esModule: true, - default: jest.fn().mockImplementation((_, apiKey: string) => { - if (apiKey === UNIT_TEST_API_KEY) { - return true; - } - - return false; - }), -})); -jest.mock('./services/remote-config-service'); -jest.mock('./services/communicator', () => ({ - __esModule: true, - default: jest.fn().mockImplementation(() => { - return COMMUNICATOR_INSTANCE_MOCK; - }), -})); - -beforeEach(() => { - jest.clearAllMocks(); -}); - -describe('root export', () => { - test('should export init function', () => { - expect(sdk).toEqual(expect.any(Function)); - }); - - test('should export the enums', () => { - expect(DEFAULT_EXPORT.MeetingEvent).toEqual(MeetingEvent); - expect(DEFAULT_EXPORT.RealtimeEvent).toEqual(RealtimeEvent); - expect(DEFAULT_EXPORT.DeviceEvent).toEqual(DeviceEvent); - expect(DEFAULT_EXPORT.MeetingConnectionStatus).toEqual(MeetingConnectionStatus); - expect(DEFAULT_EXPORT.MeetingEvent).toEqual(MeetingEvent); - expect(DEFAULT_EXPORT.MeetingControlsEvent).toEqual(MeetingControlsEvent); - expect(DEFAULT_EXPORT.MeetingState).toEqual(MeetingState); - expect(DEFAULT_EXPORT.ParticipantType).toEqual(ParticipantType); - }); - - test('should export BrowserService', () => { - expect(DEFAULT_EXPORT.BrowserService).toEqual(BrowserService); - }); -}); - -describe('initialization errors', () => { - test('should throw an error if no API key is provided', async () => { - await expect(sdk('', SIMPLE_INITIALIZATION_MOCK)).rejects.toThrow('API key is required'); - }); - - test('should throw an error if API key is invalid', async () => { - RemoteConfigService.getRemoteConfig = jest.fn().mockResolvedValue(REMOTE_CONFIG_MOCK); - - await expect(sdk('invalid-api-key', SIMPLE_INITIALIZATION_MOCK)).rejects.toThrow( - 'Failed to validate API key', - ); - }); - - test('should throw an error if no options are provided', async () => { - await expect( - sdk(UNIT_TEST_API_KEY, undefined as unknown as SuperVizSdkOptions), - ).rejects.toThrow('Options is required'); - }); - - test('should throw an error if no room id is provided', async () => { - await expect( - sdk(UNIT_TEST_API_KEY, { ...SIMPLE_INITIALIZATION_MOCK, roomId: '' }), - ).rejects.toThrow('Room id is required'); - }); - - test('should throw an error if no participant id is provided', async () => { - await expect( - sdk(UNIT_TEST_API_KEY, { - ...SIMPLE_INITIALIZATION_MOCK, - participant: { name: 'unit-test-participant-name' } as Participant, - }), - ).rejects.toThrow('Participants fields is required'); - - await expect( - sdk(UNIT_TEST_API_KEY, { - ...SIMPLE_INITIALIZATION_MOCK, - participant: undefined as unknown as Participant, - }), - ).rejects.toThrow('Participants fields is required'); - }); - - test('should throw an error if no group name is provided', async () => { - await expect( - sdk(UNIT_TEST_API_KEY, { - ...SIMPLE_INITIALIZATION_MOCK, - group: { id: 'unit-test-group-test-id' } as Group, - }), - ).rejects.toThrow('Group fields is required'); - }); - - test('should throw an error if envoriment is invalid', async () => { - ApiService.fetchConfig = jest.fn().mockResolvedValue(undefined); - - expect(sdk(UNIT_TEST_API_KEY, SIMPLE_INITIALIZATION_MOCK)).rejects.toThrow( - 'Failed to load configuration from server', - ); - }); -}); diff --git a/src/index.ts b/src/index.ts index 8c89b7a8..1b8dfc31 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,3 @@ -import { debug } from 'debug'; - import { MeetingEvent, RealtimeEvent, @@ -9,92 +7,18 @@ import { MeetingControlsEvent, ParticipantEvent, } from './common/types/events.types'; -import { Participant, Group, Avatar, ParticipantType } from './common/types/participant.types'; -import { SuperVizSdkOptions, DevicesOptions } from './common/types/sdk-options.types'; -import { Laucher } from './core'; -import ApiService from './services/api'; -import AuthService from './services/auth-service'; -import { BrowserService } from './services/browser'; -import { BrowserStats } from './services/browser/types'; -import { PluginOptions } from './services/communicator/types'; -import config from './services/config'; -import { PluginMethods, Plugin } from './services/integration/base-plugin/types'; -import { ParticipantOn3D, ParticipantTo3D } from './services/integration/participants/types'; -import { RealtimeMessage } from './services/realtime/ably/types'; -import RemoteConfigService from './services/remote-config-service'; -import { SuperVizSdk } from './types'; - -/** - * @function validateOptions - * @description Validate the options passed to the SDK - * @param {SuperVizSdkOptions} param - * @returns {void} - */ -const validateOptions = ({ group, participant, roomId }: SuperVizSdkOptions): void => { - if (!group || !group.name || !group.id) { - throw new Error('Group fields is required'); - } - - if (!participant || !participant.id) { - throw new Error('Participants fields is required'); - } - - if (!roomId) { - throw new Error('Room id is required'); - } -}; - -/** - * @function init - * @description Initialize the SDK - * @param apiKey - API key - * @param options - SDK options - * @returns {SuperVizSdk} - */ -const init = async (apiKey: string, options: SuperVizSdkOptions): Promise => { - const validApiKey = apiKey && apiKey.trim(); - - if (!validApiKey) throw new Error('API key is required'); - - if (!options) throw new Error('Options is required'); - - validateOptions(options); - - if (options.debug) { - debug.enable('*'); - } else { - debug.disable(); - } - - const { apiUrl, conferenceLayerUrl } = await RemoteConfigService.getRemoteConfig( - options.environment, - ); - - const isValid = await AuthService(apiUrl, apiKey); - - if (!isValid) { - throw new Error('Failed to validate API key'); - } - - const environment = await ApiService.fetchConfig(apiUrl, apiKey); - - if (!environment || !environment.ablyKey) { - throw new Error('Failed to load configuration from server'); - } - - const { ablyKey } = environment; - - config.setConfig({ - apiUrl, - ablyKey, - apiKey, - conferenceLayerUrl, - environment, - roomId: options.roomId, - }); - - return Laucher(Object.assign({}, options, { apiKey, ablyKey, conferenceLayerUrl, apiUrl })); -}; +import init from './core'; +import './web-components'; + +export { Participant, Group, Avatar, ParticipantType } from './common/types/participant.types'; +export { SuperVizSdkOptions, DevicesOptions } from './common/types/sdk-options.types'; +export { BrowserService } from './services/browser'; +export { BrowserStats } from './services/browser/types'; +export { PluginOptions } from './services/communicator/types'; +export { PluginMethods, Plugin } from './services/integration/base-plugin/types'; +export { ParticipantOn3D, ParticipantTo3D } from './services/integration/participants/types'; +export { RealtimeMessage } from './services/realtime/ably/types'; +export { SuperVizSdk } from './types'; if (window) { window.SuperVizSdk = { @@ -109,28 +33,14 @@ if (window) { }; } -export default init; export { MeetingEvent, RealtimeEvent, - SuperVizSdkOptions, DeviceEvent, - SuperVizSdk, MeetingState, - Participant, - ParticipantType, - Group, MeetingConnectionStatus, - PluginMethods, - PluginOptions, - Plugin, - ParticipantOn3D, - ParticipantTo3D, - BrowserService, - BrowserStats, - Avatar, MeetingControlsEvent, - DevicesOptions, - RealtimeMessage, ParticipantEvent, }; + +export default init; diff --git a/src/services/config/types.ts b/src/services/config/types.ts index 4520e6f8..d41839a7 100644 --- a/src/services/config/types.ts +++ b/src/services/config/types.ts @@ -7,4 +7,5 @@ export interface Configuration { ablyKey: string; apiUrl: string; conferenceLayerUrl: string; + debug: boolean; } diff --git a/src/types.ts b/src/types.ts index 00fb7bf0..4b18ecd0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -import { LaucherFacade } from './core/laucher/types'; +import { LauncherFacade } from './core/launcher/types'; import { CommunicatorFacade } from './services/communicator/types'; -export type SuperVizSdk = CommunicatorFacade | LaucherFacade; +export type SuperVizSdk = CommunicatorFacade | LauncherFacade;