Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,26 @@
]
}
],
"tailwindcss/classnames-order": ["error"]
"tailwindcss/classnames-order": ["error"],
"no-restricted-exports": "off", // This rule goes against sonar cloud recommendations
"no-restricted-properties": [
"warn",
{
"object": "it",
"property": "only",
"message": "Remove .only from tests before committing!"
},
{
"object": "describe",
"property": "only",
"message": "Remove .only from tests before committing!"
},
{
"object": "test",
"property": "only",
"message": "Remove .only from tests before committing!"
}
]
},
"settings": {
"tailwindcss": {
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,4 @@ build
/scripts/allLicenseResults.json

vcr.yml
.scannerwork/
4 changes: 4 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,18 @@
"autolinker": "^4.0.0",
"autoprefixer": "^10.4.19",
"axios": "^1.12.0",
"classnames": "^2.5.1",
"events": "^3.3.0",
"i18next": "^25.3.2",
"i18next-browser-languagedetector": "^8.2.0",
"json-storage-formatter": "^2.0.9",
"lodash": "^4.17.21",
"opentok-layout-js": "^5.4.0",
"opentok-solutions-logging": "^1.1.5",
"postcss": "^8.4.38",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-global-state-hooks": "^12.0.4",
"react-i18next": "^15.6.1",
"react-router-dom": "^6.11.0",
"resize-observer-polyfill": "^1.5.1",
Expand All @@ -66,6 +69,7 @@
"@vitest/coverage-v8": "^1.3.0",
"@vitest/ui": "^1.3",
"jsdom": "^22.1.0",
"typescript": "^5.8.3",
"vite-plugin-checker": "^0.11.0"
}
}
28 changes: 1 addition & 27 deletions frontend/src/App.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { PropsWithChildren } from 'react';
import App from './App';

Expand All @@ -10,35 +10,9 @@ vi.mock('./pages/UnsupportedBrowserPage', () => ({
default: () => <div>Unsupported Browser</div>,
}));

// Mock context providers and wrappers
vi.mock('./Context/PreviewPublisherProvider', () => ({
__esModule: true,
PreviewPublisherProvider: ({ children }: PropsWithChildren) => children,
default: ({ children }: PropsWithChildren) => children,
}));
vi.mock('./Context/PublisherProvider', () => ({
__esModule: true,
PublisherProvider: ({ children }: PropsWithChildren) => children,
default: ({ children }: PropsWithChildren) => children,
}));
vi.mock('./Context/SessionProvider/session', () => ({
default: ({ children }: PropsWithChildren) => children,
}));
vi.mock('./components/RedirectToWaitingRoom', () => ({
default: ({ children }: PropsWithChildren) => children,
}));
vi.mock('./Context/RoomContext', () => ({
default: ({ children }: PropsWithChildren) => children,
}));
vi.mock('./Context/ConfigProvider', () => ({
__esModule: true,
ConfigContextProvider: ({ children }: PropsWithChildren) => children,
default: ({ children }: PropsWithChildren) => children,
}));

afterEach(() => {
vi.clearAllMocks();
});

describe('App routing', () => {
it('renders LandingPage on unknown route', () => {
Expand Down
133 changes: 133 additions & 0 deletions frontend/src/Context/AppConfig/AppConfigContext.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { describe, expect, it, vi } from 'vitest';
import { act, renderHook as renderHookBase, waitFor } from '@testing-library/react';
import defaultAppConfig from './helpers/defaultAppConfig';
import appConfigStore from './AppConfigContext';
import type { AppConfig } from './AppConfigContext.types';

describe('AppConfigContext', () => {
it('returns the default config when no config.json is loaded', async () => {
const { result } = renderHook(() => appConfigStore.use()[0]);

await waitFor(() => {
expect(result.current).toEqual(expect.objectContaining(defaultAppConfig));
});
});

it('merges config.json values if loaded (mocked fetch)', async () => {
expect.assertions(2);

// All values in this config should override the defaultConfig
const mockConfig: AppConfig = {
isAppConfigLoaded: true,
videoSettings: {
allowCameraControl: false,
defaultResolution: '640x480',
allowVideoOnJoin: false,
allowBackgroundEffects: false,
},
audioSettings: {
allowAdvancedNoiseSuppression: false,
allowAudioOnJoin: false,
allowMicrophoneControl: false,
},
waitingRoomSettings: {
allowDeviceSelection: false,
},
meetingRoomSettings: {
allowArchiving: false,
allowCaptions: false,
allowChat: false,
allowDeviceSelection: false,
allowEmojis: false,
allowScreenShare: false,
defaultLayoutMode: 'grid',
showParticipantList: false,
},
};

vi.spyOn(global, 'fetch').mockResolvedValue({
json: async () => mockConfig,
headers: {
get: () => 'application/json',
},
} as unknown as Response);

const { result } = renderHook(() => appConfigStore.use());
let [appConfig, { loadAppConfig }] = result.current;

expect(appConfig).not.toEqual(mockConfig);

await loadAppConfig();

[appConfig, { loadAppConfig }] = result.current;

expect(appConfig).toEqual({
...mockConfig,
isAppConfigLoaded: true,
});
});

it('falls back to defaultConfig if fetch fails', async () => {
expect.assertions(4);

const mockFetchError = new Error('mocking a failure to fetch');

vi.spyOn(global, 'fetch').mockRejectedValue(mockFetchError as unknown as Response);

const { result, rerender } = renderHook(() => appConfigStore.use());
let [appConfig, { loadAppConfig }] = result.current;

expect(appConfig.isAppConfigLoaded).toBe(false);
expect(loadAppConfig()).rejects.toThrow('mocking a failure to fetch');

await act(() => {
rerender();
});

[appConfig, { loadAppConfig }] = result.current;

expect(appConfig.isAppConfigLoaded).toBe(true);

expect(appConfig).toEqual({
...defaultAppConfig,
isAppConfigLoaded: true,
});
});

it('falls back to defaultConfig if no config.json is found', async () => {
expect.assertions(4);

vi.spyOn(global, 'fetch').mockResolvedValue({
ok: false,
status: 404,
statusText: 'Not Found',
headers: {
get: () => 'text/html',
},
} as unknown as Response);

const { result, rerender } = renderHook(() => appConfigStore.use());
let [appConfig, { loadAppConfig }] = result.current;

expect(appConfig).toEqual(defaultAppConfig);

expect(loadAppConfig()).rejects.toThrow('No valid JSON found, using default config');

await act(() => {
rerender();
});

[appConfig, { loadAppConfig }] = result.current;

expect(appConfig.isAppConfigLoaded).toBe(true);

expect(appConfig).toEqual({
...defaultAppConfig,
isAppConfigLoaded: true,
});
});
});

function renderHook<Result, Props>(render: (initialProps: Props) => Result) {
return renderHookBase(render, { wrapper: appConfigStore.Provider });
}
23 changes: 23 additions & 0 deletions frontend/src/Context/AppConfig/AppConfigContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import createContext, { type InferContextApi } from 'react-global-state-hooks/createContext';
import updateAppConfig from './actions/updateAppConfig';
import loadAppConfig from './actions/loadAppConfig';
import initialValue from './helpers/defaultAppConfig';

/**
* Creates the AppConfig store
* The store includes Context, Provider, and use hook for consuming the context.
*/
const appConfig = createContext(initialValue, {
actions: {
updateAppConfig,
loadAppConfig,
},
});

/**
* The AppConfig context type.
* Represents the shape of the context including state and actions.
*/
export type AppConfigApi = InferContextApi<typeof appConfig.Context>;

export default appConfig;
48 changes: 48 additions & 0 deletions frontend/src/Context/AppConfig/AppConfigContext.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { LayoutMode } from '@app-types/session';

export type VideoSettings = {
allowBackgroundEffects: boolean;
allowCameraControl: boolean;
allowVideoOnJoin: boolean;
defaultResolution:
| '1920x1080'
| '1280x960'
| '1280x720'
| '640x480'
| '640x360'
| '320x240'
| '320x180';
};

export type AudioSettings = {
allowAdvancedNoiseSuppression: boolean;
allowAudioOnJoin: boolean;
allowMicrophoneControl: boolean;
};

export type WaitingRoomSettings = {
allowDeviceSelection: boolean;
};

export type MeetingRoomSettings = {
allowArchiving: boolean;
allowCaptions: boolean;
allowChat: boolean;
allowDeviceSelection: boolean;
allowEmojis: boolean;
allowScreenShare: boolean;
defaultLayoutMode: LayoutMode;
showParticipantList: boolean;
};

export type AppConfig = {
isAppConfigLoaded: boolean;

videoSettings: VideoSettings;

audioSettings: AudioSettings;

waitingRoomSettings: WaitingRoomSettings;

meetingRoomSettings: MeetingRoomSettings;
};
34 changes: 34 additions & 0 deletions frontend/src/Context/AppConfig/actions/loadAppConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { AppConfig } from '../AppConfigContext.types';

export type AppConfigApi = import('../AppConfigContext').AppConfigApi;

/**
* Loads the application configuration from public/config.json file.
* If the fetch fails or the content is invalid, it falls back to the default configuration.
* Finally, it marks the app config as loaded.
* @returns {Function} The thunk action to load the app config.
*/
function loadAppConfig(this: AppConfigApi['actions']) {
return async (_: AppConfigApi) => {
try {
const response = await fetch('/config.json', {
cache: 'no-cache',
});

const contentType = response.headers.get('content-type');
if (!contentType?.includes('application/json')) {
throw new Error('No valid JSON found, using default config');
}

const json: Partial<AppConfig> = await response.json();

this.updateAppConfig(json);
} finally {
this.updateAppConfig({
isAppConfigLoaded: true,
});
}
};
}

export default loadAppConfig;
18 changes: 18 additions & 0 deletions frontend/src/Context/AppConfig/actions/updateAppConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { DeepPartial } from '@app-types/index';
import type { AppConfig } from '../AppConfigContext.types';
import mergeAppConfigs from '../helpers/mergeAppConfigs';

export type AppConfigApi = import('../AppConfigContext').AppConfigApi;

/**
* Partially updates the app config state
* @param {DeepPartial<AppConfig>} updates - Partial updates to apply to the app config state.
* @returns {Function} A function that updates the app config state.
*/
function updateAppConfig(this: AppConfigApi['actions'], updates: DeepPartial<AppConfig>) {
return ({ setState }: AppConfigApi) => {
setState((previous) => mergeAppConfigs({ previous, updates }));
};
}

export default updateAppConfig;
35 changes: 35 additions & 0 deletions frontend/src/Context/AppConfig/helpers/defaultAppConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { AppConfig } from '../AppConfigContext.types';

const defaultAppConfig: AppConfig = {
isAppConfigLoaded: false,

videoSettings: {
allowBackgroundEffects: true,
allowCameraControl: true,
allowVideoOnJoin: true,
defaultResolution: '1280x720',
},

audioSettings: {
allowAdvancedNoiseSuppression: true,
allowAudioOnJoin: true,
allowMicrophoneControl: true,
},

waitingRoomSettings: {
allowDeviceSelection: true,
},

meetingRoomSettings: {
allowArchiving: true,
allowCaptions: true,
allowChat: true,
allowDeviceSelection: true,
allowEmojis: true,
allowScreenShare: true,
defaultLayoutMode: 'active-speaker',
showParticipantList: true,
},
};

export default defaultAppConfig;
Loading