diff --git a/client/jest.config.json b/client/jest.config.json index 4b4d4ca77..520a8bab2 100644 --- a/client/jest.config.json +++ b/client/jest.config.json @@ -4,10 +4,21 @@ "transform": { "^.+\\.tsx?$": "ts-jest" }, - "transformIgnorePatterns": ["/node_modules/(?![d3-shape|recharts]).+\\.js$"], + "transformIgnorePatterns": [ + "/node_modules/(?![d3-shape|recharts]).+\\.js$" + ], "collectCoverage": true, - "coverageReporters": ["text", "cobertura", "clover", "lcov", "json"], - "collectCoverageFrom": ["src/**/*.{ts,tsx}"], + "coverageReporters": [ + "text", + "cobertura", + "clover", + "lcov", + "json" + ], + "testTimeout": 15000, + "collectCoverageFrom": [ + "src/**/*.{ts,tsx}" + ], "coveragePathIgnorePatterns": [ "node_modules", "build", @@ -15,7 +26,11 @@ "src/AppProvider.tsx", "src/store/store.ts" ], - "modulePathIgnorePatterns": ["test/e2e", "mocks", "config"], + "modulePathIgnorePatterns": [ + "test/e2e", + "mocks", + "config" + ], "coverageDirectory": "/coverage/", "coverageThreshold": { "global": { @@ -31,6 +46,10 @@ }, "verbose": true, "testRegex": "/test/.*\\.test.tsx?$", - "modulePaths": ["/src/"], - "setupFilesAfterEnv": ["/test/unitTests/jest.setup.ts"] -} + "modulePaths": [ + "/src/" + ], + "moduleNameMapper": { + "^test/(.*)$": "/test/$1" + } +} \ No newline at end of file diff --git a/client/jest.integration.config.json b/client/jest.integration.config.json deleted file mode 100644 index 45cfd871e..000000000 --- a/client/jest.integration.config.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "preset": "ts-jest", - "testEnvironment": "jsdom", - "transform": { - "^.+\\.tsx?$": "ts-jest" - }, - "transformIgnorePatterns": ["/node_modules/(?![d3-shape|recharts]).+\\.js$"], - "collectCoverage": false, - "coverageReporters": ["text", "cobertura", "clover", "lcov", "json"], - "collectCoverageFrom": ["src/**/*.{ts,tsx}"], - "coveragePathIgnorePatterns": [ - "node_modules", - "build", - "src/index.tsx", - "src/AppProvider.tsx", - "src/store/store.ts" - ], - "modulePathIgnorePatterns": ["test/e2e", "mocks", "config"], - "coverageDirectory": "/coverage/", - "globals": { - "window.ENV.SERVER_HOSTNAME": "localhost", - "window.ENV.SERVER_PORT": 3500 - }, - "verbose": true, - "testRegex": "/test/.*\\.test.tsx?$", - "modulePaths": ["/src/"], - "setupFilesAfterEnv": ["/test/unitTests/jest.setup.ts"] - } - \ No newline at end of file diff --git a/client/package.json b/client/package.json index 99c74f152..a37e19d81 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@into-cps-association/dtaas-web", - "version": "0.3.5", + "version": "0.3.6", "description": "Web client for Digital Twin as a Service (DTaaS)", "main": "index.tsx", "author": "prasadtalasila (http://prasad.talasila.in/)", @@ -29,8 +29,8 @@ "syntax": "npx eslint . --fix", "test:all": "yarn test:unit && yarn test:int && yarn test:e2e", "test:e2e": "yarn build && yarn config:test && npx kill-port 4000 && yarn start >/dev/null & playwright test && npx kill-port 4000", - "test:int": "jest -c ./jest.integration.config.json ../test/integration --coverage", - "test:unit": "jest -c ./jest.config.json ../test/unitTests --coverage" + "test:int": "jest -c ./jest.config.json ../test/integration --setupFilesAfterEnv ./test/integration/jest.setup.ts", + "test:unit": "jest -c ./jest.config.json ../test/unit --setupFilesAfterEnv ./test/unit/jest.setup.ts" }, "eslintConfig": { "extends": [ @@ -85,7 +85,7 @@ "@testing-library/react": "^14.1.2", "@testing-library/user-event": "^14.5.1", "@types/jest": "^29.5.10", - "@types/react": "^18.2.38", + "@types/react": "^18.3.3", "@types/react-dom": "^18.2.17", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", diff --git a/client/src/components/LinkButtons.tsx b/client/src/components/LinkButtons.tsx index a3780f18f..9dfcec45e 100644 --- a/client/src/components/LinkButtons.tsx +++ b/client/src/components/LinkButtons.tsx @@ -52,6 +52,7 @@ interface LinkButtonProps { * */ const LinkButtons = ({ buttons, size, marginRight }: LinkButtonProps) => { const iconButtons = getIconButtons(buttons); + const root = document.getElementById('root'); return ( {iconButtons.map((button, index) => ( @@ -59,7 +60,11 @@ interface LinkButtonProps { key={index} style={{ marginRight: marginRight ? `${marginRight}px` : '0px' }} > - + { diff --git a/client/src/index.tsx b/client/src/index.tsx index 7db8206ed..00ab2cf54 100644 --- a/client/src/index.tsx +++ b/client/src/index.tsx @@ -2,63 +2,13 @@ import '@fontsource/roboto'; import * as React from 'react'; import { createBrowserRouter, RouterProvider } from 'react-router-dom'; import ReactDOM from 'react-dom/client'; -import WorkBench from 'route/workbench/Workbench'; import AppProvider from 'AppProvider'; import { useURLbasename } from 'util/envUtil'; -import LayoutPublic from 'page/LayoutPublic'; -import PrivateRoute from 'route/auth/PrivateRoute'; -import Library from './route/library/Library'; -import DigitalTwins from './route/digitaltwins/DigitalTwins'; -import SignIn from './route/auth/Signin'; -import Account from './route/auth/Account'; +import routes from 'routes'; -const router = createBrowserRouter( - [ - { - path: '/', - element: ( - - - - ), - }, - { - path: 'library', - element: ( - - - - ), - }, - { - path: 'digitaltwins', - element: ( - - - - ), - }, - { - path: 'account', - element: ( - - - - ), - }, - { - path: 'workbench', - element: ( - - - - ), - }, - ], - { - basename: `/${useURLbasename()}`, - }, -); +const router = createBrowserRouter(routes, { + basename: `/${useURLbasename()}`, +}); const root = document.getElementById('root'); diff --git a/client/src/page/DrawerComponent.tsx b/client/src/page/DrawerComponent.tsx index d2b1ea1e1..f874b190f 100644 --- a/client/src/page/DrawerComponent.tsx +++ b/client/src/page/DrawerComponent.tsx @@ -7,10 +7,10 @@ import { transition } from './MenuToolbar'; import MenuItems from './MenuItems'; import DrawerHeaderComponent from './DrawerHeaderComponent'; -const drawerWidth = 240; +const drawerwidth = 240; const openedMixin = (theme: Theme): CSSObject => ({ - width: drawerWidth, + width: drawerwidth, transition: transition({ theme, open: true }), overflowX: 'hidden', }); @@ -27,7 +27,7 @@ const closedMixin = (theme: Theme): CSSObject => ({ const Drawer = styled(MuiDrawer, { shouldForwardProp: (prop) => prop !== 'open', })(({ theme, open }) => ({ - width: drawerWidth, + width: drawerwidth, flexShrink: 0, whiteSpace: 'nowrap', boxSizing: 'border-box', diff --git a/client/src/page/Menu.tsx b/client/src/page/Menu.tsx index 9466e8f7d..6333f0279 100644 --- a/client/src/page/Menu.tsx +++ b/client/src/page/Menu.tsx @@ -7,7 +7,7 @@ import { RootState } from 'store/store'; import MenuToolbar from './MenuToolbar'; import DrawerComponent from './DrawerComponent'; -const drawerWidth = 240; +const drawerwidth = 240; const hooks = () => { const theme = useTheme(); @@ -29,7 +29,7 @@ function MiniDrawer() { prop !== 'open', -})(({ theme, open, drawerWidth }) => ({ +})(({ theme, open, drawerwidth }) => ({ zIndex: theme.zIndex.drawer + 1, transition: transition({ theme, open }), ...(open && { - marginLeft: drawerWidth, - width: `calc(100% - ${drawerWidth}px)`, + marginLeft: drawerwidth, + width: `calc(100% - ${drawerwidth}px)`, transition: transition({ theme, open }), }), })); @@ -51,28 +51,28 @@ interface MenuToolbarProps { handleDrawerOpen: () => void; handleOpenUserMenu: (event: React.MouseEvent) => void; handleCloseUserMenu: () => void; - drawerWidth: number; + drawerwidth: number; anchorElUser: HTMLElement | null; } function MenuToolbar({ open, - drawerWidth, + drawerwidth, handleCloseUserMenu, handleOpenUserMenu, handleDrawerOpen, anchorElUser, }: MenuToolbarProps) { const auth = useAuth(); + const root = document.getElementById('root'); const handleSignOut = async () => { if (auth) { await signOut(auth); } }; - return ( - + - + A @@ -103,6 +103,7 @@ function MenuToolbar({ anchorEl={anchorElUser} anchorOrigin={{ vertical: 'top', horizontal: 'right' }} keepMounted + container={root} transformOrigin={{ vertical: 'top', horizontal: 'right' }} open={Boolean(anchorElUser)} onClose={handleCloseUserMenu} diff --git a/client/src/page/Title.tsx b/client/src/page/Title.tsx deleted file mode 100644 index 6b3237716..000000000 --- a/client/src/page/Title.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import * as React from 'react'; -import Typography from '@mui/material/Typography'; - -interface TitleProps { - children: React.ReactNode; -} - -const Title = (props: TitleProps) => ( - /* jshint ignore:start */ - - {props.children} - - /* jshint ignore:end */ -); - -export default Title; diff --git a/client/src/route/auth/AuthProvider.tsx b/client/src/route/auth/AuthProvider.tsx index 5b12ff257..fa423f624 100644 --- a/client/src/route/auth/AuthProvider.tsx +++ b/client/src/route/auth/AuthProvider.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { AuthProvider as OIDCAuthProvider } from 'react-oidc-context'; -import { useOidcConfig } from '../../util/auth/useOidcConfig'; +import { useOidcConfig } from 'util/auth/useOidcConfig'; interface AuthProviderProps { children: React.ReactNode; @@ -8,11 +8,9 @@ interface AuthProviderProps { const AuthProvider: React.FC = ({ children }) => { const oidcConfig = useOidcConfig(); - if (!oidcConfig) { return
Authentication service unavailable...try again later
; } - return {children}; }; diff --git a/client/src/route/auth/WaitAndNavigate.tsx b/client/src/route/auth/WaitAndNavigate.tsx index 35b24bd16..88f146328 100644 --- a/client/src/route/auth/WaitAndNavigate.tsx +++ b/client/src/route/auth/WaitAndNavigate.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import * as React from 'react'; -import { wait } from '../../util/auth/Authentication'; +import { wait } from 'util/auth/Authentication'; /* WaitNavigateAndReload was made in case of an auth.error to show the error for 5 seconds and then redirect the user back to the Signin page */ diff --git a/client/src/route/library/Library.tsx b/client/src/route/library/Library.tsx index 75356056d..bf12fb665 100644 --- a/client/src/route/library/Library.tsx +++ b/client/src/route/library/Library.tsx @@ -5,7 +5,7 @@ import Iframe from 'components/Iframe'; import { useURLforLIB } from 'util/envUtil'; import { Typography } from '@mui/material'; import { useAuth } from 'react-oidc-context'; -import { getAndSetUsername } from '../../util/auth/Authentication'; +import { getAndSetUsername } from 'util/auth/Authentication'; import { assetType, scope } from './LibraryTabData'; export function createTabs() { diff --git a/client/src/route/library/LibraryTabData.ts b/client/src/route/library/LibraryTabData.ts index a1b95af24..ac82d08cc 100644 --- a/client/src/route/library/LibraryTabData.ts +++ b/client/src/route/library/LibraryTabData.ts @@ -35,6 +35,6 @@ export const scope: ITabs[] = [ }, { label: 'Common', - body: `These reusable assets are only visible to all users. Other users can use these assets in their digital twins.`, + body: `These reusable assets are visible to all users. Other users can use these assets in their digital twins.`, }, ]; diff --git a/client/src/routes.tsx b/client/src/routes.tsx new file mode 100644 index 000000000..cd0890198 --- /dev/null +++ b/client/src/routes.tsx @@ -0,0 +1,53 @@ +import * as React from 'react'; +import WorkBench from 'route/workbench/Workbench'; +import LayoutPublic from 'page/LayoutPublic'; +import PrivateRoute from 'route/auth/PrivateRoute'; +import Library from './route/library/Library'; +import DigitalTwins from './route/digitaltwins/DigitalTwins'; +import SignIn from './route/auth/Signin'; +import Account from './route/auth/Account'; + +export const routes = [ + { + path: '/', + element: ( + + + + ), + }, + { + path: 'library', + element: ( + + + + ), + }, + { + path: 'digitaltwins', + element: ( + + + + ), + }, + { + path: 'account', + element: ( + + + + ), + }, + { + path: 'workbench', + element: ( + + + + ), + }, +]; + +export default routes; diff --git a/client/src/util/auth/useOidcConfig.ts b/client/src/util/auth/useOidcConfig.ts index 4d32c5486..50d478939 100644 --- a/client/src/util/auth/useOidcConfig.ts +++ b/client/src/util/auth/useOidcConfig.ts @@ -5,7 +5,7 @@ import { getLogoutRedirectURI, getGitLabScopes, getRedirectURI, -} from '../envUtil'; +} from 'util/envUtil'; export interface OidcConfig { authority: string; diff --git a/client/test/unitTests/__mocks__/global_mocks.ts b/client/test/__mocks__/global_mocks.ts similarity index 55% rename from client/test/unitTests/__mocks__/global_mocks.ts rename to client/test/__mocks__/global_mocks.ts index 2e8470de4..2bae050b8 100644 --- a/client/test/unitTests/__mocks__/global_mocks.ts +++ b/client/test/__mocks__/global_mocks.ts @@ -1,3 +1,4 @@ +export const mockAppURL = 'https://example.com/'; export const mockURLforDT = 'https://example.com/URL_DT'; export const mockURLforLIB = 'https://example.com/URL_LIB'; export const mockURLforWorkbench = 'https://example.com/URL_WORKBENCH'; @@ -7,7 +8,42 @@ export const mockRedirectURI = 'https://example.com/REDIRECT_URI'; export const mockLogoutRedirectURI = 'https://example.com/LOGOUT_REDIRECT_URI'; export const mockGitLabScopes = 'example scopes'; +export type mockUserType = { + access_token: string; + profile: { + groups: string[] | string | undefined; + picture: string | undefined; + preferred_username: string | undefined; + profile: string | undefined; + }; +}; + +export const mockUser: mockUserType = { + access_token: 'example_token', + profile: { + groups: 'group-one', + picture: 'pfp.jpg', + preferred_username: 'username', + profile: 'example/username', + }, +}; + +export type mockAuthStateType = { + user?: mockUserType | null; + isLoading: boolean; + isAuthenticated: boolean; + activeNavigator?: string; + error?: Error; +}; + +export const mockAuthState: mockAuthStateType = { + isAuthenticated: true, + isLoading: false, + user: mockUser, +}; + jest.mock('util/envUtil', () => ({ + ...jest.requireActual('util/envUtil'), useURLforDT: () => mockURLforDT, useURLforLIB: () => mockURLforLIB, getClientID: () => mockClientID, @@ -22,8 +58,3 @@ jest.mock('util/envUtil', () => ({ { key: '3', link: 'link3' }, ], })); - -jest.mock('react-redux', () => ({ - useSelector: jest.fn(), - useDispatch: () => jest.fn(), -})); diff --git a/client/test/__mocks__/integration/module_mocks.tsx b/client/test/__mocks__/integration/module_mocks.tsx new file mode 100644 index 000000000..c28208a90 --- /dev/null +++ b/client/test/__mocks__/integration/module_mocks.tsx @@ -0,0 +1,4 @@ +jest.mock('react-oidc-context', () => ({ + ...jest.requireActual('react-oidc-context'), + useAuth: jest.fn(), +})); diff --git a/client/test/unitTests/__mocks__/component_mocks.tsx b/client/test/__mocks__/unit/component_mocks.tsx similarity index 100% rename from client/test/unitTests/__mocks__/component_mocks.tsx rename to client/test/__mocks__/unit/component_mocks.tsx diff --git a/client/test/__mocks__/unit/module_mocks.tsx b/client/test/__mocks__/unit/module_mocks.tsx new file mode 100644 index 000000000..cb08227b2 --- /dev/null +++ b/client/test/__mocks__/unit/module_mocks.tsx @@ -0,0 +1,4 @@ +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), + useDispatch: () => jest.fn(), +})); diff --git a/client/test/unitTests/__mocks__/page_mocks.tsx b/client/test/__mocks__/unit/page_mocks.tsx similarity index 82% rename from client/test/unitTests/__mocks__/page_mocks.tsx rename to client/test/__mocks__/unit/page_mocks.tsx index af497e64d..835efd5f0 100644 --- a/client/test/unitTests/__mocks__/page_mocks.tsx +++ b/client/test/__mocks__/unit/page_mocks.tsx @@ -20,3 +20,8 @@ jest.mock('page/Menu', () => ({ __esModule: true, default: () =>
, })); + +jest.mock('route/auth/Signin', () => ({ + __esModule: true, + default: () =>
Signin
, +})); diff --git a/client/test/integration/Auth/WaitAndNavigate.test.tsx b/client/test/integration/Auth/WaitAndNavigate.test.tsx new file mode 100644 index 000000000..afd27df2e --- /dev/null +++ b/client/test/integration/Auth/WaitAndNavigate.test.tsx @@ -0,0 +1,32 @@ +import { act, screen } from '@testing-library/react'; +import { mockAuthState } from 'test/__mocks__/global_mocks'; +import { setupIntegrationTest } from 'test/integration/integration.testUtil'; + +jest.useFakeTimers(); + +const authStateWithError = { ...mockAuthState, error: Error('Test Error') }; +const setup = () => setupIntegrationTest('/library', authStateWithError); +Object.defineProperty(window, 'location', { + value: { + ...window.location, + reload: jest.fn(), + }, + writable: true, +}); + +describe('WaitAndNavigate', () => { + beforeEach(async () => { + await setup(); + }); + + it('redirects to the WaitAndNavigate page when getting useAuth throws an error', async () => { + expect(screen.getByText('Oops... Test Error')).toBeVisible(); + expect(screen.getByText('Waiting for 5 seconds...')).toBeVisible(); + + await act(async () => { + jest.advanceTimersByTime(5000); + }); + + expect(screen.getByText(/Sign In with GitLab/i)).toBeVisible(); + }); +}); diff --git a/client/test/integration/authRedux.test.tsx b/client/test/integration/Auth/authRedux.test.tsx similarity index 72% rename from client/test/integration/authRedux.test.tsx rename to client/test/integration/Auth/authRedux.test.tsx index bd59bb3e6..98a9bf389 100644 --- a/client/test/integration/authRedux.test.tsx +++ b/client/test/integration/Auth/authRedux.test.tsx @@ -2,13 +2,14 @@ import * as React from 'react'; import { createStore } from 'redux'; import { screen } from '@testing-library/react'; import { useAuth } from 'react-oidc-context'; -import PrivateRoute from '../../src/route/auth/PrivateRoute'; -import Library from '../../src/route/library/Library'; -import authReducer from '../../src/store/auth.slice'; -import { renderWithRouter } from '../unitTests/testUtils'; +import PrivateRoute from 'route/auth/PrivateRoute'; +import Library from 'route/library/Library'; +import authReducer from 'store/auth.slice'; +import { mockUser } from 'test/__mocks__/global_mocks'; +import { renderWithRouter } from 'test/unit/unit.testUtil'; -jest.mock('react-oidc-context', () => ({ - useAuth: jest.fn(), +jest.mock('util/auth/Authentication', () => ({ + getAndSetUsername: jest.fn(), })); jest.mock('react-redux', () => ({ @@ -16,8 +17,9 @@ jest.mock('react-redux', () => ({ useDispatch: jest.fn(), })); -jest.mock('../../src/util/auth/Authentication', () => ({ - getAndSetUsername: jest.fn(), +jest.mock('page/Menu', () => ({ + __esModule: true, + default: () =>
, })); const store = createStore(authReducer); @@ -27,19 +29,12 @@ type AuthState = { }; const setupTest = (authState: AuthState) => { - const userMock = { - profile: { - profile: 'example/username', - }, - access_token: 'example_token', - }; - - (useAuth as jest.Mock).mockReturnValue({ ...authState, user: userMock }); + (useAuth as jest.Mock).mockReturnValue({ ...authState, user: mockUser }); if (authState.isAuthenticated) { store.dispatch({ type: 'auth/setUserName', - payload: userMock.profile.profile.split('/')[1], + payload: mockUser.profile.profile!.split('/')[1], }); } else { store.dispatch({ type: 'auth/setUserName', payload: undefined }); @@ -73,7 +68,7 @@ describe('Redux and Authentication integration test', () => { isAuthenticated: false, }); - expect(screen.getByText('Signin')).toBeInTheDocument(); + expect(screen.getByText('Sign In with GitLab')).toBeInTheDocument(); expect(authReducer(undefined, { type: 'unknown' })).toEqual( initialState.auth, ); @@ -99,7 +94,7 @@ describe('Redux and Authentication integration test', () => { setupTest({ isAuthenticated: false, }); - expect(screen.getByText('Signin')).toBeInTheDocument(); + expect(screen.getByText('Sign In with GitLab')).toBeInTheDocument(); expect(store.getState().userName).toBe(undefined); }); }); diff --git a/client/test/integration/Routes/Account.test.tsx b/client/test/integration/Routes/Account.test.tsx new file mode 100644 index 000000000..f4496191b --- /dev/null +++ b/client/test/integration/Routes/Account.test.tsx @@ -0,0 +1,68 @@ +import { screen } from '@testing-library/react'; +import { + testAccountSettings, + testStaticAccountProfile, +} from 'test/unit/unit.testUtil'; +import { + mockAuthState, + mockUser, + mockUserType, +} from 'test/__mocks__/global_mocks'; +import { setupIntegrationTest } from 'test/integration/integration.testUtil'; +import { testLayout } from './routes.testUtil'; + +const setup = async (user: mockUserType) => + setupIntegrationTest('/account', { ...mockAuthState, user }); + +describe('Account', () => { + it('renders the Account page and Layout correctly', async () => { + await setup(mockUser); + await testLayout(); + testStaticAccountProfile(mockUser); + await testAccountSettings(mockUser); + }); + + it('renders the Account page with different amounts of groups', async () => { + await setup({ ...mockUser, profile: { ...mockUser.profile, groups: [] } }); + expect(screen.getByText(/belong to/)).toHaveProperty( + 'innerHTML', + 'username does not belong to any groups.', + ); + + await setup({ + ...mockUser, + profile: { ...mockUser.profile, groups: ['g1'] }, + }); + expect(screen.getByText(/belongs to/)).toHaveProperty( + 'innerHTML', + 'username belongs to g1 group.', + ); + + await setup({ + ...mockUser, + profile: { ...mockUser.profile, groups: ['g1', 'g2'] }, + }); + expect(screen.getByText(/belongs to/)).toHaveProperty( + 'innerHTML', + 'username belongs to g1 and g2 groups.', + ); + + await setup({ + ...mockUser, + profile: { ...mockUser.profile, groups: ['g1', 'g2', 'g3'] }, + }); + expect(screen.getByText(/belongs to/)).toHaveProperty( + 'innerHTML', + 'username belongs to g1, g2 and g3 groups.', + ); + + await setup({ + ...mockUser, + profile: { ...mockUser.profile, groups: ['g1', 'g2', 'g3', 'g4'] }, + }); + expect(screen.getByText(/belongs to/)).toHaveProperty( + 'innerHTML', + 'username belongs to g1, g2, g3 and g4 groups.', + ); + }); +}); diff --git a/client/test/integration/Routes/DigitalTwins.test.tsx b/client/test/integration/Routes/DigitalTwins.test.tsx new file mode 100644 index 000000000..1ac4bebc5 --- /dev/null +++ b/client/test/integration/Routes/DigitalTwins.test.tsx @@ -0,0 +1,66 @@ +import { screen, within } from '@testing-library/react'; +import tabs from 'route/digitaltwins/DigitalTwinTabData'; +import userEvent from '@testing-library/user-event'; +import { + closestDiv, + itShowsTheParagraphOfToTheSelectedTab, + normalizer, + setupIntegrationTest, +} from 'test/integration/integration.testUtil'; +import { testLayout } from './routes.testUtil'; + +const setup = () => setupIntegrationTest('/digitaltwins'); + +describe('Digital Twins', () => { + beforeEach(async () => { + await setup(); + }); + + it('renders the Digital Twins page and Layout correctly', async () => { + await testLayout(); + + const tablists = screen.getAllByRole('tablist'); + expect(tablists).toHaveLength(2); + + // The div of the Digital Twins (Create, Execute and Analyze) tabs + const mainTabsDiv = closestDiv(tablists[0]); + const mainTablist = within(mainTabsDiv).getAllByRole('tablist')[0]; + const mainTabs = within(mainTablist).getAllByRole('tab'); + expect(mainTabs).toHaveLength(3); + + mainTabs.forEach((tab, tabIndex) => { + expect(tab).toHaveTextContent(tabs[tabIndex].label); + }); + + const mainParagraph = screen.getByText(tabs[0].body, { normalizer }); + expect(mainParagraph).toBeInTheDocument(); + + const mainParagraphDiv = closestDiv(mainParagraph); + const iframe = within(mainParagraphDiv).getByTitle( + /JupyterLight-Demo-Create/i, + ); + expect(iframe).toBeInTheDocument(); + expect(iframe).toHaveProperty('src', 'https://example.com/URL_DT'); + }); + + it('shows the paragraph of to the selected tab', async () => { + await itShowsTheParagraphOfToTheSelectedTab([tabs]); + }); + + /* eslint-disable no-await-in-loop */ + it('changes iframe src according to the selected tab', async () => { + for (let tabsIndex = 0; tabsIndex < tabs.length; tabsIndex += 1) { + const tabsData = tabs[tabsIndex]; + const isFirstTab = tabsIndex === 0; + const tab = screen.getByRole('tab', { + name: tabsData.label, + selected: isFirstTab, + }); + await userEvent.click(tab); + const iframe = screen.getByTitle(`JupyterLight-Demo-${tabsData.label}`); + expect(iframe).toBeInTheDocument(); + expect(iframe).toHaveProperty('src', `https://example.com/URL_DT`); + } + }); + /* eslint-enable no-await-in-loop */ +}); diff --git a/client/test/integration/Routes/Library.test.tsx b/client/test/integration/Routes/Library.test.tsx new file mode 100644 index 000000000..0ffbbf18a --- /dev/null +++ b/client/test/integration/Routes/Library.test.tsx @@ -0,0 +1,168 @@ +import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { assetType, scope } from 'route/library/LibraryTabData'; +import { + normalizer, + closestDiv, + itShowsTheParagraphOfToTheSelectedTab, + setupIntegrationTest, +} from 'test/integration/integration.testUtil'; +import { testLayout } from './routes.testUtil'; + +const setup = () => setupIntegrationTest('/library'); + +describe('Library', () => { + beforeEach(async () => { + await setup(); + }); + + it('renders the Library and Layout correctly', async () => { + await testLayout(); + + const tablists = screen.getAllByRole('tablist'); + expect(tablists).toHaveLength(2); + + // The div of the assetType (Functions, Models, etc.) tabs + const mainTabsDiv = closestDiv(tablists[0]); + const mainTablist = within(mainTabsDiv).getAllByRole('tablist')[0]; + const mainTabs = within(mainTablist).getAllByRole('tab'); + expect(mainTabs).toHaveLength(5); + + const mainParagraph = screen.getByText(assetType[0].body, { normalizer }); + expect(mainParagraph).toBeInTheDocument(); + + const mainParagraphDiv = closestDiv(mainParagraph); + + // The div of the scope (Private and Common) tabs within the chosen assetType tab + const subTabsDiv = closestDiv(mainParagraphDiv.parentElement!); + + const subTabslist = within(subTabsDiv).getByRole('tablist'); + expect(subTabslist).toBeInTheDocument(); + const subTabs = within(subTabslist).getAllByRole('tab'); + expect(subTabs).toHaveLength(2); + + const subParagraph = screen.getByText(scope[0].body, { normalizer }); + expect(subParagraph).toBeInTheDocument(); + + const subiframe = screen.getByTitle(assetType[0].label); + expect(subiframe).toBeInTheDocument(); + const assetTypeSegment = `${assetType[0].label.replace(' ', '_').toLowerCase()}`; + expect(subiframe).toHaveProperty( + 'src', + `https://example.com/URL_LIBtree/${assetTypeSegment}`, + ); + }); + + it('shows the paragraph of to the selected tab', async () => { + await itShowsTheParagraphOfToTheSelectedTab([assetType, scope]); + }); + + /* eslint-disable no-await-in-loop */ + it('selects the first scope tab when you select an assetType tab', async () => { + // Starting from 1 as the first tab is already selected so we won't need to click it + for ( + let assetTypeIndex = 1; + assetTypeIndex < assetType.length; + assetTypeIndex += 1 + ) { + const assetTypeData = assetType[assetTypeIndex]; + const commonTab = screen.getByRole('tab', { + name: 'Common', + selected: false, + }); + expect(commonTab).toBeInTheDocument(); + + await userEvent.click(commonTab); + + expect(commonTab).toHaveAttribute('aria-selected', 'true'); + + const assetTypeTab = screen.getByRole('tab', { + name: assetTypeData.label, + selected: false, + }); + + expect(assetTypeTab).toBeInTheDocument(); + + await userEvent.click(assetTypeTab); + + expect(assetTypeTab).toHaveAttribute('aria-selected', 'true'); + + const newCommonTab = screen.getByRole('tab', { + name: 'Common', + selected: false, + }); + expect(newCommonTab).toBeInTheDocument(); + + await setup(); + } + }); + + it('does not change the selected assetType tab when you select a scope tab', async () => { + for ( + let assetTypeIndex = 0; + assetTypeIndex < assetType.length; + assetTypeIndex += 1 + ) { + const assetTypeData = assetType[assetTypeIndex]; + const isFirstAssetTypeTab = assetTypeIndex === 0; + const assetTypeTab = screen.getByRole('tab', { + name: assetTypeData.label, + selected: isFirstAssetTypeTab, + }); + await userEvent.click(assetTypeTab); + for (let scopeIndex = 0; scopeIndex < scope.length; scopeIndex += 1) { + const scopeData = scope[scopeIndex]; + const isFirstScopeTab = scopeIndex === 0; + const scopeTab = screen.getByRole('tab', { + name: scopeData.label, + selected: isFirstScopeTab, + }); + await userEvent.click(scopeTab); + const scopeTabAfterClicks = screen.getByRole('tab', { + name: scopeData.label, + selected: true, + }); + const assetTypeTabAfterClicks = screen.getByRole('tab', { + name: assetTypeData.label, + selected: true, + }); + expect(scopeTabAfterClicks).toBeInTheDocument(); + expect(assetTypeTabAfterClicks).toBeInTheDocument(); + } + } + }, 6000); + + it('changes iframe src according to the combination of the selected tabs', async () => { + for ( + let assetTypeIndex = 0; + assetTypeIndex < assetType.length; + assetTypeIndex += 1 + ) { + const assetTypeData = assetType[assetTypeIndex]; + const isFirstAssetTypeTab = assetTypeIndex === 0; + const assetTypeTab = screen.getByRole('tab', { + name: assetTypeData.label, + selected: isFirstAssetTypeTab, + }); + await userEvent.click(assetTypeTab); + for (let scopeIndex = 0; scopeIndex < scope.length; scopeIndex += 1) { + const scopeData = scope[scopeIndex]; + const isFirstScopeTab = scopeIndex === 0; + const scopeTab = screen.getByRole('tab', { + name: scopeData.label, + selected: isFirstScopeTab, + }); + await userEvent.click(scopeTab); + const iframe = screen.getByTitle(assetTypeData.label); + const scopeSegment = `${isFirstScopeTab ? '' : 'common/'}`; + const assetTypeSegment = `${assetTypeData.label.replace(' ', '_').toLowerCase()}`; + expect(iframe).toBeInTheDocument(); + expect(iframe).toHaveProperty( + 'src', + `https://example.com/URL_LIBtree/${scopeSegment}${assetTypeSegment}`, + ); + } + } + }, 6000); + /* eslint-enable no-await-in-loop */ +}); diff --git a/client/test/integration/Routes/Signin.test.tsx b/client/test/integration/Routes/Signin.test.tsx new file mode 100644 index 000000000..e26828eb3 --- /dev/null +++ b/client/test/integration/Routes/Signin.test.tsx @@ -0,0 +1,19 @@ +import { screen } from '@testing-library/react'; +import { setupIntegrationTest } from 'test/integration/integration.testUtil'; +import { testPublicLayout } from './routes.testUtil'; + +const setup = () => setupIntegrationTest('/'); + +describe('Signin', () => { + beforeEach(async () => { + await setup(); + }); + + it('renders the Sign in page with the Public Layout correctly', async () => { + await testPublicLayout(); + expect( + screen.getByRole('button', { name: /Sign In with GitLab/i }), + ).toBeVisible(); + expect(screen.getByTestId(/LockOutlinedIcon/i)).toBeVisible(); + }); +}); diff --git a/client/test/integration/Routes/Workbench.test.tsx b/client/test/integration/Routes/Workbench.test.tsx new file mode 100644 index 000000000..68a4b88f9 --- /dev/null +++ b/client/test/integration/Routes/Workbench.test.tsx @@ -0,0 +1,61 @@ +import { screen, within } from '@testing-library/react'; +import { + itShowsTheTooltipWhenHoveringButton, + setupIntegrationTest, +} from 'test/integration/integration.testUtil'; +import { testLayout } from './routes.testUtil'; + +window.env = { + ...window.env, + REACT_APP_URL: 'http://example.com/', + REACT_APP_URL_BASENAME: 'basename', + REACT_APP_WORKBENCHLINK_VNCDESKTOP: '/tools/vnc/?password=vncpassword', + REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', + REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', + REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', +}; + +jest.deepUnmock('util/envUtil'); + +async function testTool(toolTipText: string, name: string) { + const toolDiv = screen.getByLabelText(toolTipText); + expect(toolDiv).toBeInTheDocument(); + const toolHeading = within(toolDiv).getByRole('heading', { level: 6 }); + expect(toolHeading).toBeInTheDocument(); + expect(toolHeading).toHaveTextContent(name); + const toolButton = within(toolDiv).getByTitle(`${name}-btn`); + expect(toolButton).toBeInTheDocument(); +} + +const setup = () => setupIntegrationTest('/workbench'); + +describe('Workbench', () => { + const desktopLabel = + 'http://example.com/basename/username/tools/vnc/?password=vncpassword'; + const VSCodeLabel = 'http://example.com/basename/username/tools/vscode'; + const jupyterLabLabel = 'http://example.com/basename/username/lab'; + const jupyterNotebookLabel = 'http://example.com/basename/username/'; + beforeEach(async () => { + await setup(); + }); + + it('renders the Workbench and Layout correctly', async () => { + await testLayout(); + + const mainHeading = screen.getByRole('heading', { level: 4 }); + expect(mainHeading).toBeInTheDocument(); + expect(mainHeading).toHaveTextContent(/Workbench Tools/); + + await testTool(desktopLabel, 'Desktop'); + await testTool(VSCodeLabel, 'VSCode'); + await testTool(jupyterLabLabel, 'JupyterLab'); + await testTool(jupyterNotebookLabel, 'Jupyter Notebook'); + }); + + it('shows the tooltip when hovering over the tools', async () => { + await itShowsTheTooltipWhenHoveringButton(desktopLabel); + await itShowsTheTooltipWhenHoveringButton(VSCodeLabel); + await itShowsTheTooltipWhenHoveringButton(jupyterLabLabel); + await itShowsTheTooltipWhenHoveringButton(jupyterNotebookLabel); + }); +}); diff --git a/client/test/integration/Routes/routes.testUtil.tsx b/client/test/integration/Routes/routes.testUtil.tsx new file mode 100644 index 000000000..5e9a8043c --- /dev/null +++ b/client/test/integration/Routes/routes.testUtil.tsx @@ -0,0 +1,153 @@ +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { + closestDiv, + itShowsTheTooltipWhenHoveringButton, +} from 'test/integration/integration.testUtil'; + +export async function testLayout() { + testFooter(); + await testDrawer(); + await testToolbar(); + await testSettingsButton(); +} + +export async function testPublicLayout() { + testFooter(); + await testToolbar(); +} + +export async function testDrawer() { + expect(screen.getByTestId(/ChevronLeftIcon/)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Library/ })).toBeInTheDocument(); + expect(screen.getByTestId(/ExtensionIcon/)).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: /Digital Twins/ }), + ).toBeInTheDocument(); + expect(screen.getByTestId(/PeopleIcon/)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Workbench/ })).toBeInTheDocument(); + expect(screen.getByTestId(/EngineeringIcon/)).toBeInTheDocument(); + + await itOpensAndClosesTheDrawer(); +} + +export async function testToolbar() { + expect(screen.getByText(/The Digital Twin as a Service/)).toBeInTheDocument(); + await testToolbarButton( + 'https://github.com/INTO-CPS-Association/DTaaS', + 'GitHubIcon', + ); + await testToolbarButton( + 'https://into-cps-association.github.io/DTaaS', + 'HelpOutlineIcon', + ); +} + +async function testToolbarButton(labelText: string, iconTestId: string) { + const button = screen.getByLabelText(labelText); + expect(button).toBeInTheDocument(); + expect(within(button).getByTestId(iconTestId)).toBeInTheDocument(); + await itShowsTheTooltipWhenHoveringButton(labelText); +} + +async function testSettingsButton() { + // Button exists + const labelText = 'Open settings'; + const settingsButton = screen.getByLabelText(labelText, { + selector: 'button', + }); + expect(settingsButton).toBeInTheDocument(); + expect(within(settingsButton).getByText('A')).toBeInTheDocument(); + + // Has visible tooltip + await itShowsTheTooltipWhenHoveringButton(labelText); + + // Can open and close + await itOpensAndClosesTheSettingsMenu(); +} + +async function itOpensAndClosesTheSettingsMenu() { + // Opens and shows contents + await userEvent.click( + screen.getByLabelText('Open settings', { + selector: 'button', + }), + ); + await waitFor(() => { + expect(screen.getByRole('menu')).toBeInTheDocument(); + expect( + screen.getByRole('menuitem', { name: /Account/ }), + ).toBeInTheDocument(); + expect( + screen.getByRole('menuitem', { name: /Logout/ }), + ).toBeInTheDocument(); + }); + + // Closes and hides contents + await userEvent.tab(); + await waitFor(() => { + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + expect( + screen.queryByRole('menuitem', { name: /Account/ }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('menuitem', { name: /Logout/ }), + ).not.toBeInTheDocument(); + }); +} + +async function itOpensAndClosesTheDrawer() { + // Drawer is collapsed + const drawerInnerDiv = closestDiv( + screen.getByRole('link', { name: /Library/ }), + ); + expect(drawerInnerDiv).toHaveStyle('width:calc(56px + 1px);'); + // Open-drawer-button is visible + const menuButton = screen.getByLabelText(/Open drawer/i); + expect(menuButton).toBeVisible(); + + // Open the drawer + await userEvent.click(menuButton); + + // Drawer is expanded, Open-drawer-button is hidden + expect(drawerInnerDiv).toHaveStyle('width:240px'); + expect(menuButton).not.toBeVisible(); + + // Close the drawer + const chevronLeftButton = screen + .getByTestId(/ChevronLeftIcon/) + .closest('button'); + expect(chevronLeftButton).toBeInTheDocument(); + await userEvent.click(chevronLeftButton!); + + // Drawer is collapsed, Open-drawer-button is visible again + expect(drawerInnerDiv).toHaveStyle('width:calc(56px + 1px);'); + expect(menuButton).toBeVisible(); +} + +export function testFooter() { + const firstFooterParagraph = screen.getByText(/Copyright ©/); + expect(firstFooterParagraph).toBeInTheDocument(); + const firstFooterLink = within(firstFooterParagraph).getByRole('link', { + name: /The INTO-CPS Association/, + }); + expect(firstFooterLink).toBeInTheDocument(); + expect(firstFooterLink).toHaveAttribute('href', 'https://into-cps.org/'); + const footerLinkClasses = + 'MuiTypography-root MuiTypography-inherit MuiLink-root MuiLink-underlineAlways'; + expect(firstFooterLink).toHaveClass(footerLinkClasses); + const footerDiv = closestDiv(firstFooterParagraph); + const secondFooterParagraph = within(footerDiv).getByText( + /Thanks to Material-UI for the/, + ); + expect(secondFooterParagraph).toBeInTheDocument(); + const secondFooterLink = within(secondFooterParagraph).getByRole('link', { + name: /Dashboard template/, + }); + expect(secondFooterLink).toBeInTheDocument(); + expect(secondFooterLink).toHaveAttribute( + 'href', + 'https://github.com/mui/material-ui/tree/v5.11.9/docs/data/material/getting-started/templates/dashboard', + ); + expect(secondFooterLink).toHaveClass(footerLinkClasses); +} diff --git a/client/test/integration/integration.testUtil.tsx b/client/test/integration/integration.testUtil.tsx new file mode 100644 index 000000000..007efd5ae --- /dev/null +++ b/client/test/integration/integration.testUtil.tsx @@ -0,0 +1,123 @@ +import { + cleanup, + getDefaultNormalizer, + render, + screen, + waitFor, + act, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; +import { useAuth } from 'react-oidc-context'; +import { ITabs } from 'route/IData'; +import store from 'store/store'; +import AppProvider from 'AppProvider'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import routes from 'routes'; +import { mockAuthState, mockAuthStateType } from 'test/__mocks__/global_mocks'; + +export const normalizer = getDefaultNormalizer({ + trim: false, + collapseWhitespace: false, +}); + +const renderWithAppProvider = (route: string) => { + window.history.pushState({}, 'Test page', route); + return render( + AppProvider({ + children: ( + + + {routes.map((routeElement) => ( + + ))} + ; + + + ), + }), + ); +}; + +export async function setupIntegrationTest( + route: string, + authState?: mockAuthStateType, +) { + cleanup(); + const returnedAuthState = authState ?? mockAuthState; + + (useAuth as jest.Mock).mockReturnValue({ + ...returnedAuthState, + }); + + if (returnedAuthState.isAuthenticated) { + store.dispatch({ + type: 'auth/setUserName', + payload: returnedAuthState.user!.profile.profile!.split('/')[1], + }); + } else { + store.dispatch({ type: 'auth/setUserName', payload: undefined }); + } + const container = await act(async () => renderWithAppProvider(route)); + return container; +} + +export function closestDiv(element: HTMLElement) { + const div = element.closest('div'); + expect(div).toBeInTheDocument(); + return div!; +} + +export async function itShowsTheTooltipWhenHoveringButton(toolTipText: string) { + const button = screen.getByLabelText(toolTipText); + expect( + screen.queryByRole('tooltip', { name: toolTipText }), + ).not.toBeInTheDocument(); + await userEvent.hover(button); + await waitFor(() => { + expect( + screen.getByRole('tooltip', { name: toolTipText }), + ).toBeInTheDocument(); + }); + + await userEvent.unhover(button); + await waitFor(() => { + expect( + screen.queryByRole('tooltip', { name: toolTipText }), + ).not.toBeInTheDocument(); + }); +} + +/* eslint-disable no-await-in-loop */ +export async function itShowsTheParagraphOfToTheSelectedTab( + tablistsData: ITabs[][], +) { + for ( + let tablistsIndex = 0; + tablistsIndex < tablistsData.length; + tablistsIndex += 1 + ) { + const tablistData = tablistsData[tablistsIndex]; + for (let tabIndex = 0; tabIndex < tablistData.length; tabIndex += 1) { + const tabData = tablistData[tabIndex]; + const isFirstTab = tabIndex === 0; + const tab = screen.getByRole('tab', { + name: tabData.label, + selected: isFirstTab, + }); + expect(tab).toBeInTheDocument(); + + await userEvent.click(tab); + + const tabParagraph = screen.getByText(tabData.body, { + normalizer, + }); + expect(tabParagraph).toBeInTheDocument(); + } + } +} +/* eslint-enable no-await-in-loop */ diff --git a/client/test/integration/jest.setup.ts b/client/test/integration/jest.setup.ts new file mode 100644 index 000000000..f48d08d20 --- /dev/null +++ b/client/test/integration/jest.setup.ts @@ -0,0 +1,7 @@ +import '@testing-library/jest-dom'; +import 'test/__mocks__/integration/module_mocks'; +import 'test/__mocks__/global_mocks'; + +beforeEach(() => { + jest.resetAllMocks(); +}); diff --git a/client/test/unitTests/auth/AuthProvider.test.tsx b/client/test/unit/auth/AuthProvider.test.tsx similarity index 88% rename from client/test/unitTests/auth/AuthProvider.test.tsx rename to client/test/unit/auth/AuthProvider.test.tsx index 87d7803c6..d9a9ab488 100644 --- a/client/test/unitTests/auth/AuthProvider.test.tsx +++ b/client/test/unit/auth/AuthProvider.test.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { render } from '@testing-library/react'; -import AuthProvider from '../../../src/route/auth/AuthProvider'; -import { useOidcConfig } from '../../../src/util/auth/useOidcConfig'; +import AuthProvider from 'route/auth/AuthProvider'; +import { useOidcConfig } from 'util/auth/useOidcConfig'; jest.mock('react-oidc-context', () => { const actualModule = jest.requireActual('react-oidc-context'); @@ -13,7 +13,7 @@ jest.mock('react-oidc-context', () => { }; }); -jest.mock('../../../src/util/auth/useOidcConfig', () => ({ +jest.mock('util/auth/useOidcConfig', () => ({ useOidcConfig: jest.fn(), })); diff --git a/client/test/unitTests/Components/Iframe.test.tsx b/client/test/unit/components/Iframe.test.tsx similarity index 100% rename from client/test/unitTests/Components/Iframe.test.tsx rename to client/test/unit/components/Iframe.test.tsx diff --git a/client/test/unitTests/Components/Linkbuttons.test.tsx b/client/test/unit/components/Linkbuttons.test.tsx similarity index 100% rename from client/test/unitTests/Components/Linkbuttons.test.tsx rename to client/test/unit/components/Linkbuttons.test.tsx diff --git a/client/test/unitTests/Components/PrivateRoute.test.tsx b/client/test/unit/components/PrivateRoute.test.tsx similarity index 92% rename from client/test/unitTests/Components/PrivateRoute.test.tsx rename to client/test/unit/components/PrivateRoute.test.tsx index 25ae748d8..87dddc9b7 100644 --- a/client/test/unitTests/Components/PrivateRoute.test.tsx +++ b/client/test/unit/components/PrivateRoute.test.tsx @@ -1,8 +1,8 @@ import * as React from 'react'; import { screen } from '@testing-library/react'; import { useAuth } from 'react-oidc-context'; -import PrivateRoute from '../../../src/route/auth/PrivateRoute'; -import { renderWithRouter } from '../testUtils'; +import PrivateRoute from 'route/auth/PrivateRoute'; +import { renderWithRouter } from 'test/unit/unit.testUtil'; jest.mock('react-oidc-context', () => ({ useAuth: jest.fn(), diff --git a/client/test/unitTests/Components/TabComponent.test.tsx b/client/test/unit/components/TabComponent.test.tsx similarity index 100% rename from client/test/unitTests/Components/TabComponent.test.tsx rename to client/test/unit/components/TabComponent.test.tsx diff --git a/client/test/unit/jest.setup.ts b/client/test/unit/jest.setup.ts new file mode 100644 index 000000000..53953aa4b --- /dev/null +++ b/client/test/unit/jest.setup.ts @@ -0,0 +1,9 @@ +import '@testing-library/jest-dom'; +import 'test/__mocks__/global_mocks'; +import 'test/__mocks__/unit/page_mocks'; +import 'test/__mocks__/unit/component_mocks'; +import 'test/__mocks__/unit/module_mocks'; + +beforeEach(() => { + jest.resetAllMocks(); +}); diff --git a/client/test/unitTests/Page/Layout.test.tsx b/client/test/unit/page/Layout.test.tsx similarity index 97% rename from client/test/unitTests/Page/Layout.test.tsx rename to client/test/unit/page/Layout.test.tsx index 1b75393c5..ad32c81be 100644 --- a/client/test/unitTests/Page/Layout.test.tsx +++ b/client/test/unit/page/Layout.test.tsx @@ -6,7 +6,7 @@ import { itHasMultipleChildren, itHasSingleChild, renderLayoutWithRouter, -} from './page.testUtils'; +} from './page.testUtil'; jest.unmock('page/Layout'); diff --git a/client/test/unitTests/Page/LayoutPublic.test.tsx b/client/test/unit/page/LayoutPublic.test.tsx similarity index 96% rename from client/test/unitTests/Page/LayoutPublic.test.tsx rename to client/test/unit/page/LayoutPublic.test.tsx index 0af0b5e1e..0208d79c3 100644 --- a/client/test/unitTests/Page/LayoutPublic.test.tsx +++ b/client/test/unit/page/LayoutPublic.test.tsx @@ -5,7 +5,7 @@ import { itHasMultipleChildren, itHasSingleChild, renderLayoutWithRouter, -} from './page.testUtils'; +} from './page.testUtil'; const PublicTestComponentId = 'public-component'; diff --git a/client/test/unit/page/Menu.test.tsx b/client/test/unit/page/Menu.test.tsx new file mode 100644 index 000000000..f61521e81 --- /dev/null +++ b/client/test/unit/page/Menu.test.tsx @@ -0,0 +1,79 @@ +import * as React from 'react'; +import { render, screen, act } from '@testing-library/react'; +import MiniDrawer from 'page/Menu'; +import { MemoryRouter } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { closeMenu, openMenu } from 'store/menu.slice'; +import { useAuth } from 'react-oidc-context'; +import store from 'store/store'; +import userEvent from '@testing-library/user-event'; +import { mockUser } from 'test/__mocks__/global_mocks'; +import { closestDiv } from 'test/integration/integration.testUtil'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), +})); + +jest.mock('react-oidc-context', () => ({ + ...jest.requireActual('react-oidc-context'), + useAuth: jest.fn(), +})); + +jest.mock('page/Menu', () => ({ + ...jest.requireActual('page/Menu'), +})); + +describe('Menu', () => { + beforeEach(() => { + (useAuth as jest.Mock).mockReturnValue(mockUser); + render( + + + + + , + ); + }); + + it('renders the Menu correctly', async () => { + expect(screen.getByTestId(/toolbar/)).toBeInTheDocument(); + + const chevronLeftIcon = screen.getByTestId(/ChevronLeftIcon/); + expect(chevronLeftIcon).toBeInTheDocument(); + const chevronLeftButton = chevronLeftIcon.closest('button'); + expect(chevronLeftButton).toBeInTheDocument(); + + await userEvent.click(chevronLeftButton!); + + const libraryButton = screen.getByRole('link', { name: /Library/ }); + expect(libraryButton).toBeInTheDocument(); + + expect(screen.getByTestId(/ExtensionIcon/)).toBeInTheDocument(); + + expect( + screen.getByRole('link', { name: /Digital Twins/ }), + ).toBeInTheDocument(); + expect(screen.getByTestId(/PeopleIcon/)).toBeInTheDocument(); + + expect(screen.getByRole('link', { name: /Workbench/ })).toBeInTheDocument(); + expect(screen.getByTestId(/EngineeringIcon/)).toBeInTheDocument(); + }); + + it('changes the width of the drawer when the isOpen state changes', () => { + const libraryButton = screen.getByRole('link', { name: /Library/ }); + const buttonsDiv = closestDiv(libraryButton); + expect(buttonsDiv).toHaveStyle('width:calc(56px + 1px);'); + + act(() => { + store.dispatch(openMenu()); + }); + + expect(buttonsDiv).toHaveStyle('width:240px'); + + act(() => { + store.dispatch(closeMenu()); + }); + + expect(buttonsDiv).toHaveStyle('width:calc(56px + 1px);'); + }); +}); diff --git a/client/test/unitTests/Page/page.testUtils.tsx b/client/test/unit/page/page.testUtil.tsx similarity index 95% rename from client/test/unitTests/Page/page.testUtils.tsx rename to client/test/unit/page/page.testUtil.tsx index 2cca112b3..4ca8574c8 100644 --- a/client/test/unitTests/Page/page.testUtils.tsx +++ b/client/test/unit/page/page.testUtil.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react'; import * as React from 'react'; import { BrowserRouter } from 'react-router-dom'; -import { generateTestDivs } from '../testUtils'; +import { generateTestDivs } from 'test/unit/unit.testUtil'; export const TestComponentIdList = ['component1', 'component2', 'component3']; diff --git a/client/test/unitTests/Routes/Account.test.tsx b/client/test/unit/routes/Account.test.tsx similarity index 54% rename from client/test/unitTests/Routes/Account.test.tsx rename to client/test/unit/routes/Account.test.tsx index 3204e40a1..13f0e7b0c 100644 --- a/client/test/unitTests/Routes/Account.test.tsx +++ b/client/test/unit/routes/Account.test.tsx @@ -2,110 +2,88 @@ import * as React from 'react'; import Account from 'route/auth/Account'; import { render, screen } from '@testing-library/react'; import { useAuth } from 'react-oidc-context'; +import { mockUser } from 'test/__mocks__/global_mocks'; +import { + testAccountSettings, + testStaticAccountProfile, +} from 'test/unit/unit.testUtil'; jest.mock('react-oidc-context'); describe('AccountTabs', () => { - type Profile = { - preferred_username: string | undefined; - picture: string | undefined; - profile: string | undefined; - groups: string[] | string | undefined; - }; - - const mockProfile: Profile = { - preferred_username: 'user1', - picture: 'pfp.jpg', - profile: 'test.com', - groups: 'group-one', - }; - + let accountMockUser = mockUser; function setupTest(groups: string[] | string) { - mockProfile.groups = groups; - const mockuser = { profile: mockProfile }; + accountMockUser.profile.groups = groups; (useAuth as jest.Mock).mockReturnValue({ - user: mockuser, + user: accountMockUser, }); render(); } afterEach(() => { jest.clearAllMocks(); + accountMockUser = mockUser; }); - function testStaticElements() { - const profilePicture = screen.getByTestId('profile-picture'); - expect(profilePicture).toBeInTheDocument(); - expect(profilePicture).toHaveAttribute('src', 'pfp.jpg'); - - const username = screen.getAllByText('user1'); - expect(username).not.toBeNull(); - expect(username).toHaveLength(2); - - const profileLink = screen.getByRole('link', { - name: /SSO OAuth Provider/i, - }); - expect(profileLink).toBeInTheDocument(); - expect(profileLink).toHaveAttribute('href', 'test.com'); - } + test('renders the Settings tab correctly', async () => { + setupTest([]); + await testAccountSettings(accountMockUser); + }); test('renders AccountTabs with correct profile information when user is in 0 groups', () => { setupTest([]); - - testStaticElements(); + testStaticAccountProfile(accountMockUser); const groupInfo = screen.getByText(/belong to/); expect(groupInfo).toHaveProperty( 'innerHTML', - 'user1 does not belong to any groups.', + 'username does not belong to any groups.', ); }); test('renders AccountTabs with correct profile information when user is in 1 group', () => { setupTest('group-one'); - - testStaticElements(); + testStaticAccountProfile(accountMockUser); const groupInfo = screen.getByText(/belongs to/); expect(groupInfo).toHaveProperty( 'innerHTML', - 'user1 belongs to group-one group.', + 'username belongs to group-one group.', ); }); test('renders AccountTabs with correct profile information when user is in 2 groups', () => { setupTest(['first-group', 'second-group']); - - testStaticElements(); + testStaticAccountProfile(accountMockUser); const groupInfo = screen.getByText(/belongs to/); expect(groupInfo).toHaveProperty( 'innerHTML', - 'user1 belongs to first-group and second-group groups.', + 'username belongs to first-group and second-group groups.', ); }); test('renders AccountTabs with correct profile information when user is in 3 groups', () => { setupTest(['group1', 'group2', 'group3']); - testStaticElements(); + testStaticAccountProfile(accountMockUser); const groupInfo = screen.getByText(/belongs to/); expect(groupInfo).toHaveProperty( 'innerHTML', - 'user1 belongs to group1, group2 and group3 groups.', + 'username belongs to group1, group2 and group3 groups.', ); }); test('renders AccountTabs with correct profile information when user is in more than 3 groups', () => { setupTest(['g1', 'g2', 'g3', 'g4', 'g5']); - testStaticElements(); + testStaticAccountProfile(accountMockUser); const groupInfo = screen.getByText(/belongs to/); expect(groupInfo).toHaveProperty( 'innerHTML', - 'user1 belongs to g1, g2, g3, g4 and g5 groups.', + 'username belongs to g1, g2, g3, g4 and g5 groups.', ); }); }); diff --git a/client/test/unitTests/Routes/DigitalTwins.test.tsx b/client/test/unit/routes/DigitalTwins.test.tsx similarity index 93% rename from client/test/unitTests/Routes/DigitalTwins.test.tsx rename to client/test/unit/routes/DigitalTwins.test.tsx index bd97be0f3..d3d274250 100644 --- a/client/test/unitTests/Routes/DigitalTwins.test.tsx +++ b/client/test/unit/routes/DigitalTwins.test.tsx @@ -5,7 +5,7 @@ import { InitRouteTests, itDisplaysContentOfTabs, itHasCorrectTabNameinDTIframe, -} from '../testUtils'; +} from 'test/unit/unit.testUtil'; describe('Digital Twins', () => { const tabLabels: string[] = []; diff --git a/client/test/unitTests/Routes/Library.test.tsx b/client/test/unit/routes/Library.test.tsx similarity index 79% rename from client/test/unitTests/Routes/Library.test.tsx rename to client/test/unit/routes/Library.test.tsx index 81fea8a8c..47cc0830b 100644 --- a/client/test/unitTests/Routes/Library.test.tsx +++ b/client/test/unit/routes/Library.test.tsx @@ -1,14 +1,14 @@ import * as React from 'react'; import Library from 'route/library/Library'; import { assetType } from 'route/library/LibraryTabData'; -import { mockURLforLIB } from '../__mocks__/global_mocks'; +import AuthProvider from 'route/auth/AuthProvider'; +import { mockURLforLIB } from 'test/__mocks__/global_mocks'; import { InitRouteTests, itDisplaysContentOfTabs, itHasCorrectURLOfTabsWithIframe, TabLabelURLPair, -} from '../testUtils'; -import AuthProvider from '../../../src/route/auth/AuthProvider'; +} from 'test/unit/unit.testUtil'; const urlsByTabs: TabLabelURLPair[] = assetType.map((tab) => ({ label: tab.label, diff --git a/client/test/unitTests/Routes/SignIn.test.tsx b/client/test/unit/routes/SignIn.test.tsx similarity index 96% rename from client/test/unitTests/Routes/SignIn.test.tsx rename to client/test/unit/routes/SignIn.test.tsx index e53d8f66d..3ab03ab3b 100644 --- a/client/test/unitTests/Routes/SignIn.test.tsx +++ b/client/test/unit/routes/SignIn.test.tsx @@ -4,6 +4,7 @@ import { MemoryRouter } from 'react-router-dom'; import SignIn from 'route/auth/Signin'; import { useAuth } from 'react-oidc-context'; +jest.unmock('route/auth/Signin'); jest.mock('react-oidc-context'); describe('SignIn', () => { diff --git a/client/test/unitTests/Routes/Workbench.test.tsx b/client/test/unit/routes/Workbench.test.tsx similarity index 85% rename from client/test/unitTests/Routes/Workbench.test.tsx rename to client/test/unit/routes/Workbench.test.tsx index bac661b96..aef12384c 100644 --- a/client/test/unitTests/Routes/Workbench.test.tsx +++ b/client/test/unit/routes/Workbench.test.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { screen } from '@testing-library/react'; import WorkBench from 'route/workbench/Workbench'; -import { InitRouteTests } from '../testUtils'; +import { InitRouteTests } from 'test/unit/unit.testUtil'; describe('Workbench', () => { InitRouteTests(); diff --git a/client/test/unitTests/testUtils.tsx b/client/test/unit/unit.testUtil.tsx similarity index 72% rename from client/test/unitTests/testUtils.tsx rename to client/test/unit/unit.testUtil.tsx index c4a95534b..c2fde11ce 100644 --- a/client/test/unitTests/testUtils.tsx +++ b/client/test/unit/unit.testUtil.tsx @@ -6,10 +6,14 @@ import { getDefaultNormalizer, render, screen, + waitFor, } from '@testing-library/react'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { Provider } from 'react-redux'; import { Store } from 'redux'; +import userEvent from '@testing-library/user-event'; +import routes from 'routes'; +import { mockUserType } from 'test/__mocks__/global_mocks'; type RouterOptions = { route?: string; @@ -40,10 +44,18 @@ const RouterComponent: React.FC = ({ ui, route }) => ( - Signin
} /> + {routes.map((routeElement) => ( + + ))} + ; ); + export function generateTestDivs(testIds: string[]) { return testIds.map((id, i) => (
@@ -156,3 +168,37 @@ export function itHasCorrectTabNameinDTIframe(tablabels: string[]) { }); }); } + +export function testStaticAccountProfile(mockUser: mockUserType) { + const profilePicture = screen.getByTestId('profile-picture'); + expect(profilePicture).toBeInTheDocument(); + expect(profilePicture).toHaveAttribute('src', mockUser.profile.picture); + + const username = screen.getAllByText( + `${mockUser.profile.preferred_username}`, + ); + expect(username).not.toBeNull(); + expect(username).toHaveLength(2); + + const profileLink = screen.getByRole('link', { + name: /SSO OAuth Provider/i, + }); + expect(profileLink).toBeInTheDocument(); + expect(profileLink).toHaveAttribute('href', mockUser.profile.profile); +} + +export async function testAccountSettings(mockUser: mockUserType) { + await userEvent.click(screen.getByText('Settings')); + waitFor(() => { + expect( + screen.getByRole('heading', { level: 2, name: 'Settings' }), + ).toBeInTheDocument(); + + // Testing that the text is present, the link is correct and in bold + const settingsParagraph = screen.getByText(/Edit the profile on/); + expect(settingsParagraph).toHaveProperty( + 'innerHTML', + `Edit the profile on SSO OAuth Provider.`, + ); + }); +} diff --git a/client/test/unitTests/Util/Auth/Authentication.test.ts b/client/test/unit/util/Auth/Authentication.test.ts similarity index 97% rename from client/test/unitTests/Util/Auth/Authentication.test.ts rename to client/test/unit/util/Auth/Authentication.test.ts index 501a2424a..4f5eb4b12 100644 --- a/client/test/unitTests/Util/Auth/Authentication.test.ts +++ b/client/test/unit/util/Auth/Authentication.test.ts @@ -139,7 +139,7 @@ describe('signOut', () => { it('reloads the page', async () => { const auth = useAuth(); await signOut(auth); - await waitMiliseconds(3000); + jest.advanceTimersByTime(3000); expect(window.location.reload).toHaveBeenCalled(); }); @@ -150,7 +150,3 @@ describe('signOut', () => { expect(mockClear).toHaveBeenCalled(); }); }); - -async function waitMiliseconds(ms: number) { - jest.advanceTimersByTime(ms); -} diff --git a/client/test/unitTests/Util/Store.test.ts b/client/test/unit/util/Store.test.ts similarity index 100% rename from client/test/unitTests/Util/Store.test.ts rename to client/test/unit/util/Store.test.ts diff --git a/client/test/unitTests/Util/envUtil.test.ts b/client/test/unit/util/envUtil.test.ts similarity index 100% rename from client/test/unitTests/Util/envUtil.test.ts rename to client/test/unit/util/envUtil.test.ts diff --git a/client/test/unitTests/jest.setup.ts b/client/test/unitTests/jest.setup.ts deleted file mode 100644 index d6a8ae01c..000000000 --- a/client/test/unitTests/jest.setup.ts +++ /dev/null @@ -1,8 +0,0 @@ -import '@testing-library/jest-dom'; -import './__mocks__/global_mocks'; -import './__mocks__/page_mocks'; -import './__mocks__/component_mocks'; - -beforeEach(() => { - jest.resetAllMocks(); -}); diff --git a/client/tsconfig.json b/client/tsconfig.json index f4fc17a09..ed79e285a 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -5,7 +5,10 @@ "sourceMap": true, //generate .map files "target": "es6", //target es6 "jsx": "react", //use react - "types": ["react", "node"], //use react and node types + "types": [ + "react", + "node" + ], //use react and node types "module": "esnext", //use esnext modules "moduleResolution": "node", //use node module resolution strategy node "experimentalDecorators": true, //allow experimental decorators for es7 @@ -17,10 +20,19 @@ "strict": true, //enable all strict type-checking options "outDir": "dist", //output directory "baseUrl": "src", //base url for imports + "paths": { + "test/*": [ + "../test/*" + ] + }, "typeRoots": [ "node_modules/@types" //use node_modules/@types for type definitions ], "strictNullChecks": true //enable strict null checks }, - "exclude": ["**/node_modules/*", "babel.config.cjs", "dist"] -} + "exclude": [ + "**/node_modules/*", + "babel.config.cjs", + "dist" + ] +} \ No newline at end of file