diff --git a/client/.eslintignore b/client/.eslintignore index c431aa956..2fc6ed4c8 100644 --- a/client/.eslintignore +++ b/client/.eslintignore @@ -3,4 +3,8 @@ build/ config/ node_modules/ script/ -coverage/ \ No newline at end of file +coverage/ +dist/ +test-results/ +playwright-report/ +public/ \ No newline at end of file diff --git a/client/DEVELOPER.md b/client/DEVELOPER.md index 7133eefe1..c87e41d62 100644 --- a/client/DEVELOPER.md +++ b/client/DEVELOPER.md @@ -118,6 +118,7 @@ if (typeof window !== 'undefined') { REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '1be55736756190b3ace4c2c4fb19bde386d1dcc748d20b47ea8cfb5935b8446c', REACT_APP_AUTH_AUTHORITY: 'https://gitlab.com/', @@ -147,6 +148,7 @@ if (typeof window !== 'undefined') { REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '1be55736756190b3ace4c2c4fb19bde386d1dcc748d20b47ea8cfb5935b8446c', REACT_APP_AUTH_AUTHORITY: 'https://gitlab.com/', @@ -204,6 +206,12 @@ api, read_api, read_user, create_runner, k8s_proxy, read_repository, write_repos The token information needs to be updated in `config/gitlab.json`. +In addition to the personal access token, you also need to create a +[pipeline trigger token](https://archives.docs.gitlab.com/16.4/ee/ci/triggers/index.html). +This token is required to trigger pipelines by using the API. +You can create this token in your GitLab project's CI/CD settings under +the *Pipeline trigger tokens* section. + Once the token configuration is in place, the gitlab code can be developed and tested using the following yarn commands. @@ -237,3 +245,11 @@ Execution Logs: [ } ] ``` + +## Digital Twins page preview + +In the Workbench section, there is a link to preview the **Digital Twins** +page. The GitLab account used as OAuth provider must have a *DTaaS* group, +a project under your username, and a *digital_twins* folder which contains +the Digital Twins. From this interface, you can start or stop execution of +Digital Twins, and once the execution is complete, view the complete logs. diff --git a/client/README.md b/client/README.md index 9d30beddf..ba39b4227 100644 --- a/client/README.md +++ b/client/README.md @@ -97,3 +97,38 @@ This error is expected. If you would like to try the complete DTaaS application, please see localhost installation in [docs](https://into-cps-association.github.io/DTaaS/development/admin/localhost.html). + +## Gitlab Runner configuration + +To properly use the Digital Twins page preview, you need to configure at least +one project runner in your GitLab profile. Follow the steps below: + +1. Login to the GitLab profile that will be used as the OAuth provider. + +1. Navigate to the *DTaaS* group and select the project named after your + GitLab username. + +1. In the project menu, go to Settings and select CI/CD. + +1. Expand the **Runners** section and click on *New project runner*. Follow the + configuration instructions carefully: + - Add **linux** as a tag during configuration. + - Click on *Create runner*. + - Ensure GitLab Runner is installed before proceeding. Depending on your + environment, you will be shown the correct command to install GitLab Runner. + - Once GitLab Runner is installed, follow these steps to register the runner: + - Copy and paste the command shown in the GitLab interface into your command + line to register the runner. It includes a URL and a token for your specific + GitLab instance. + - Choose *docker* as executor when prompted by the command line. + - Choose the default docker image. You must use an image based on Linux, + like the default one (*ruby:2.7*). + +You can manually verify that the runner is available to pick up jobs by running +the following command: + +```bash +sudo gitlab-runner run +``` + +It can also be used to reactivate offline runners during subsequent sessions. \ No newline at end of file diff --git a/client/config/dev.js b/client/config/dev.js index 3dfe3b0a3..82df13a27 100644 --- a/client/config/dev.js +++ b/client/config/dev.js @@ -9,6 +9,7 @@ if (typeof window !== 'undefined') { REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '1be55736756190b3ace4c2c4fb19bde386d1dcc748d20b47ea8cfb5935b8446c', REACT_APP_AUTH_AUTHORITY: 'https://gitlab.foo.com/', diff --git a/client/config/local.js b/client/config/local.js index 30de796f0..ebf935f90 100644 --- a/client/config/local.js +++ b/client/config/local.js @@ -9,6 +9,7 @@ if (typeof window !== 'undefined') { REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '1be55736756190b3ace4c2c4fb19bde386d1dcc748d20b47ea8cfb5935b8446c', REACT_APP_AUTH_AUTHORITY: 'https://gitlab.com/', diff --git a/client/config/prod.js b/client/config/prod.js index bda37f0bf..8b54ea9c3 100644 --- a/client/config/prod.js +++ b/client/config/prod.js @@ -9,6 +9,7 @@ if (typeof window !== 'undefined') { REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '1be55736756190b3ace4c2c4fb19bde386d1dcc748d20b47ea8cfb5935b8446c', REACT_APP_AUTH_AUTHORITY: 'https://gitlab.com/', diff --git a/client/env.d.ts b/client/env.d.ts index 0a5485157..6aad96ef7 100644 --- a/client/env.d.ts +++ b/client/env.d.ts @@ -12,6 +12,7 @@ declare global { REACT_APP_WORKBENCHLINK_VSCODE: string; REACT_APP_WORKBENCHLINK_JUPYTERLAB: string; REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: string; + REACT_APP_WORKBENCHLINK_DT_PREVIEW: string; REACT_APP_CLIENT_ID: string; REACT_APP_AUTH_AUTHORITY: string; diff --git a/client/jest.config.json b/client/jest.config.json index ef2ff1967..cecfe0a5e 100644 --- a/client/jest.config.json +++ b/client/jest.config.json @@ -25,7 +25,7 @@ "src/index.tsx", "src/AppProvider.tsx", "src/store/store.ts", - "src/util/gitlabDriver.ts" + "src/preview/util/gitlabDriver.ts" ], "modulePathIgnorePatterns": [ "test/e2e", diff --git a/client/package.json b/client/package.json index bd2088bf5..963ec539b 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@into-cps-association/dtaas-web", - "version": "0.4.1", + "version": "0.5.0", "description": "Web client for Digital Twin as a Service (DTaaS)", "main": "index.tsx", "author": "prasadtalasila (http://prasad.talasila.in/)", @@ -9,22 +9,23 @@ "Asger Busk Breinholm", "Mathias Brændgaard", "Emre Temel", - "Cesar Vela" + "Cesar Vela", + "Vanessa Scherma" ], "license": "SEE LICENSE IN ", "private": false, "type": "module", "scripts": { - "build": "npx shx cp config/gitlab.json src/util/gitlab.json && npx react-scripts build && npx rimraf src/util/gitlab.json", - "clean": "npx rimraf build/ dist/ node_modules/ coverage/ playwright-report/ test-results/ *.svg src/util/gitlab.json", + "build": "npx react-scripts build", + "clean": "npx rimraf build/ dist/ node_modules/ coverage/ playwright-report/ test-results/ test.svg src.svg src/util/gitlab.json", "config:dev": "npx shx cp config/dev.js public/env.js && npx shx cp config/dev.js build/env.js", "config:local": "npx shx cp config/local.js public/env.js && npx shx cp config/local.js build/env.js", "config:prod": "npx shx cp config/prod.js public/env.js && npx shx cp config/prod.js build/env.js", "config:test": "npx shx cp config/test.js public/env.js && npx shx cp config/test.js build/env.js", - "develop": "npx shx cp config/gitlab.json src/util/gitlab.json && npx react-scripts start", + "develop": "npx react-scripts start", "format": "prettier --ignore-path ../.gitignore --write \"**/*.{ts,tsx,css,scss}\"", - "gitlab:compile": "npx shx cp config/gitlab.json src/util/gitlab.json && npx tsc --project tsconfig.gitlab.json && npx rimraf src/util/gitlab.json", - "gitlab:run": "node dist/gitlabDriver.js", + "gitlab:compile": "npx tsc --project tsconfig.gitlab.json", + "gitlab:run": "node dist/src/preview/util/gitlabDriver.js", "graph": "npx madge --image src.svg src && npx madge --image test.svg test", "start": "serve -s build -l 4000", "stop": "npx kill-port 4000", @@ -33,7 +34,9 @@ "test:e2e:ext": "cross-env ext=true yarn test:e2e", "test:e2e": "playwright test -c ./playwright.config.ts", "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" + "test:unit": "jest -c ./jest.config.json ../test/unit --setupFilesAfterEnv ./test/unit/jest.setup.ts", + "test:preview:int": "jest -c ./jest.config.json ../test/preview/integration --setupFilesAfterEnv ./test/preview/integration/jest.setup.ts", + "test:preview:unit": "jest -c ./jest.config.json ../test/preview/unit --setupFilesAfterEnv ./test/preview/unit/jest.setup.ts" }, "eslintConfig": { "extends": [ diff --git a/client/src/components/LinkIconsLib.tsx b/client/src/components/LinkIconsLib.tsx index 9af182044..46c2efcf0 100644 --- a/client/src/components/LinkIconsLib.tsx +++ b/client/src/components/LinkIconsLib.tsx @@ -6,6 +6,7 @@ import NoteAltOutlinedIcon from '@mui/icons-material/NoteAltOutlined'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import GitHubIcon from '@mui/icons-material/GitHub'; import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; +import TabIcon from '@mui/icons-material/Tab'; type LinkIconsType = { [key: string]: { icon: React.ReactElement; name: string | undefined }; @@ -28,6 +29,10 @@ const LinkIcons: LinkIconsType = { icon: , name: 'Jupyter Notebook', }, + DT_PREVIEW: { + icon: , + name: 'Digital Twins page preview', + }, GITHUB: { icon: , name: 'ToolbarIcon', diff --git a/client/src/preview/components/asset/Asset.ts b/client/src/preview/components/asset/Asset.ts new file mode 100644 index 000000000..70a8e8717 --- /dev/null +++ b/client/src/preview/components/asset/Asset.ts @@ -0,0 +1,5 @@ +export interface Asset { + name: string; + description?: string; + path: string; +} diff --git a/client/src/preview/components/asset/AssetBoard.tsx b/client/src/preview/components/asset/AssetBoard.tsx new file mode 100644 index 000000000..4271cd58d --- /dev/null +++ b/client/src/preview/components/asset/AssetBoard.tsx @@ -0,0 +1,57 @@ +import * as React from 'react'; +import { Grid } from '@mui/material'; +import { useSelector } from 'react-redux'; +import { RootState } from 'store/store'; +import AssetCardExecute from './AssetCard'; +import { Asset } from './Asset'; + +const outerGridContainerProps = { + container: true, + spacing: 2, + sx: { + justifyContent: 'flex-start', + overflow: 'auto', + maxHeight: 'inherent', + marginTop: 2, + }, +}; + +interface AssetBoardProps { + tab: string; + error: string | null; +} + +const AssetGridItem: React.FC<{ + asset: Asset; + tab: string; +}> = ({ asset }) => ( + + + +); + +const AssetBoard: React.FC = ({ tab, error }) => { + const assets = useSelector((state: RootState) => state.assets.items); + + if (error) { + return {error}; + } + + return ( + + {assets.map((asset: Asset) => ( + + ))} + + ); +}; + +export default AssetBoard; diff --git a/client/src/preview/components/asset/AssetCard.tsx b/client/src/preview/components/asset/AssetCard.tsx new file mode 100644 index 000000000..59cad1ee7 --- /dev/null +++ b/client/src/preview/components/asset/AssetCard.tsx @@ -0,0 +1,136 @@ +import * as React from 'react'; +import { useState, Dispatch, SetStateAction } from 'react'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import Typography from '@mui/material/Typography'; +import { AlertColor, CardActions, Grid } from '@mui/material'; +import styled from '@emotion/styled'; +import { formatName } from 'preview/util/gitlabDigitalTwin'; +import CustomSnackbar from 'preview/route/digitaltwins/Snackbar'; +import { useSelector } from 'react-redux'; +import { selectDigitalTwinByName } from 'preview/store/digitalTwin.slice'; +import { RootState } from 'store/store'; +import LogDialog from 'preview/route/digitaltwins/execute/LogDialog'; +import StartStopButton from './StartStopButton'; +import LogButton from './LogButton'; +import { Asset } from './Asset'; + +interface AssetCardProps { + asset: Asset; + buttons?: React.ReactNode; +} + +interface CardButtonsContainerExecuteProps { + assetName: string; + setShowLog: Dispatch>; +} + +const Header = styled(Typography)` + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + white-space. nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +const Description = styled(Typography)` + display: -webkit-box; + -webkit-box-orient: vertical; + text-overflow: ellipsis; +`; + +function CardActionAreaContainer(asset: Asset) { + const digitalTwin = useSelector( + (state: RootState) => state.digitalTwin[asset.name], + ); + + return ( + + + + + {digitalTwin.description} + + + + + ); +} + +function CardButtonsContainerExecute({ + assetName, + setShowLog, +}: CardButtonsContainerExecuteProps) { + const [logButtonDisabled, setLogButtonDisabled] = useState(true); + return ( + + + + + ); +} + +function AssetCard({ asset, buttons }: AssetCardProps) { + return ( + +
{formatName(asset.name)}
+ + {buttons} +
+ ); +} + +function AssetCardExecute({ asset }: AssetCardProps) { + useState('success'); + const [showLog, setShowLog] = useState(false); + const digitalTwin = useSelector(selectDigitalTwinByName(asset.name)); + + return ( + digitalTwin && ( + <> + + } + /> + + + + ) + ); +} + +export default AssetCardExecute; diff --git a/client/src/preview/components/asset/LogButton.tsx b/client/src/preview/components/asset/LogButton.tsx new file mode 100644 index 000000000..ed02dcd51 --- /dev/null +++ b/client/src/preview/components/asset/LogButton.tsx @@ -0,0 +1,30 @@ +import * as React from 'react'; +import { Dispatch, SetStateAction } from 'react'; +import { Button } from '@mui/material'; + +interface LogButtonProps { + setShowLog: Dispatch>; + logButtonDisabled: boolean; +} + +export const handleToggleLog = ( + setShowLog: Dispatch>, +) => { + setShowLog((prev) => !prev); +}; + +function LogButton({ setShowLog, logButtonDisabled }: LogButtonProps) { + return ( + + ); +} + +export default LogButton; diff --git a/client/src/preview/components/asset/StartStopButton.tsx b/client/src/preview/components/asset/StartStopButton.tsx new file mode 100644 index 000000000..393ebb8a7 --- /dev/null +++ b/client/src/preview/components/asset/StartStopButton.tsx @@ -0,0 +1,52 @@ +import * as React from 'react'; +import { useState, Dispatch, SetStateAction } from 'react'; +import { Button, CircularProgress } from '@mui/material'; +import { handleButtonClick } from 'preview/route/digitaltwins/execute/pipelineHandler'; +import { useSelector, useDispatch } from 'react-redux'; +import { selectDigitalTwinByName } from 'preview/store/digitalTwin.slice'; + +export interface JobLog { + jobName: string; + log: string; +} + +interface StartStopButtonProps { + assetName: string; + setLogButtonDisabled: Dispatch>; +} + +function StartStopButton({ + assetName, + setLogButtonDisabled, +}: StartStopButtonProps) { + const [buttonText, setButtonText] = useState('Start'); + + const dispatch = useDispatch(); + const digitalTwin = useSelector(selectDigitalTwinByName(assetName)); + + return ( + <> + {digitalTwin?.pipelineLoading ? ( + + ) : null} + + + ); +} + +export default StartStopButton; diff --git a/client/src/preview/route/digitaltwins/DigitalTwinTabDataPreview.ts b/client/src/preview/route/digitaltwins/DigitalTwinTabDataPreview.ts new file mode 100644 index 000000000..b1d0b344c --- /dev/null +++ b/client/src/preview/route/digitaltwins/DigitalTwinTabDataPreview.ts @@ -0,0 +1,22 @@ +import { ITabs } from 'route/IData'; + +const tabs: ITabs[] = [ + { + label: 'Create', + body: `Create digital twins from tools provided within user workspaces. Each digital twin will have one directory. It is suggested that user provide one bash shell script to run their digital twin. Users can create the required scripts and other files from tools provided in Workbench page.`, + }, + { + label: 'Manage', + body: `Read the complete description of digital twins. If necessary, users can delete a digital twin, removing it from the workspace with all its associated data. Users can also reconfigure the digital twin, through a link that redirects to the Create tab.`, + }, + { + label: 'Execute', + body: 'This page demonstrates integration of DTaaS with gitlab CI/CD workflows. The feature is experimental and requires certain gitlab setup in order for it to work.', + }, + { + label: 'Analyze', + body: 'The analysis of digital twins requires running of digital twin script from user workspace. The execution results placed within data directory are processed by analysis scripts and results are placed back in the data directory. These scripts can either be executed from VSCode and graphical results or can be executed from VNC GUI.', + }, +]; + +export default tabs; diff --git a/client/src/preview/route/digitaltwins/DigitalTwinsPreview.tsx b/client/src/preview/route/digitaltwins/DigitalTwinsPreview.tsx new file mode 100644 index 000000000..e825d8d38 --- /dev/null +++ b/client/src/preview/route/digitaltwins/DigitalTwinsPreview.tsx @@ -0,0 +1,95 @@ +import * as React from 'react'; +import { useState, useEffect } from 'react'; +import { useDispatch } from 'react-redux'; +import { Typography } from '@mui/material'; +import Layout from 'page/Layout'; +import TabComponent from 'components/tab/TabComponent'; +import { TabData } from 'components/tab/subcomponents/TabRender'; +import AssetBoard from 'preview/components/asset/AssetBoard'; +import GitlabInstance from 'preview/util/gitlab'; +import { getAuthority } from 'util/envUtil'; +import { setAssets } from 'preview/store/assets.slice'; +import { Asset } from 'preview/components/asset/Asset'; +import DigitalTwin from 'preview/util/gitlabDigitalTwin'; +import { setDigitalTwin } from 'preview/store/digitalTwin.slice'; +import tabs from './DigitalTwinTabDataPreview'; + +export const createDTTab = (error: string | null): TabData[] => + tabs + .filter((tab) => tab.label === 'Execute') + .map((tab) => ({ + label: tab.label, + body: ( + <> + {tab.body} + + + ), + })); + +export const fetchSubfolders = async ( + gitlabInstance: GitlabInstance, + dispatch: ReturnType, + setError: React.Dispatch>, +) => { + try { + await gitlabInstance.init(); + if (gitlabInstance.projectId) { + const subfolders = await gitlabInstance.getDTSubfolders( + gitlabInstance.projectId, + ); + dispatch(setAssets(subfolders)); + return subfolders; + } + dispatch(setAssets([])); + return []; + } catch (error) { + setError('An error occurred'); + return []; + } +}; + +export const createDigitalTwinsForAssets = async ( + assets: Asset[], + dispatch: ReturnType, +) => { + assets.forEach(async (asset) => { + const gitlabInstance = new GitlabInstance( + sessionStorage.getItem('username') || '', + getAuthority(), + sessionStorage.getItem('access_token') || '', + ); + await gitlabInstance.init(); + const digitalTwin = new DigitalTwin(asset.name, gitlabInstance); + digitalTwin.description = asset.description; + dispatch(setDigitalTwin({ assetName: asset.name, digitalTwin })); + }); +}; + +export const DTContent = () => { + const [error, setError] = useState(null); + const dispatch = useDispatch(); + const gitlabInstance = new GitlabInstance( + sessionStorage.getItem('username') || '', + getAuthority(), + sessionStorage.getItem('access_token') || '', + ); + + useEffect(() => { + fetchSubfolders(gitlabInstance, dispatch, setError).then((assets) => { + if (assets) { + createDigitalTwinsForAssets(assets, dispatch); + } + }); + }, [dispatch]); + + return ( + + + + ); +}; + +export default function DigitalTwinsPreview() { + return ; +} diff --git a/client/src/preview/route/digitaltwins/Snackbar.tsx b/client/src/preview/route/digitaltwins/Snackbar.tsx new file mode 100644 index 000000000..91c9a000d --- /dev/null +++ b/client/src/preview/route/digitaltwins/Snackbar.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Snackbar from '@mui/material/Snackbar'; +import Alert from '@mui/material/Alert'; +import { RootState } from 'store/store'; +import { hideSnackbar } from 'preview/store/snackbar.slice'; + +const CustomSnackbar: React.FC = () => { + const dispatch = useDispatch(); + + const { open, message, severity } = useSelector( + (state: RootState) => state.snackbar, + ); + + const handleClose = () => { + dispatch(hideSnackbar()); + }; + + return ( + + + {message} + + + ); +}; + +export default CustomSnackbar; diff --git a/client/src/preview/route/digitaltwins/execute/LogDialog.tsx b/client/src/preview/route/digitaltwins/execute/LogDialog.tsx new file mode 100644 index 000000000..d4fcbbd00 --- /dev/null +++ b/client/src/preview/route/digitaltwins/execute/LogDialog.tsx @@ -0,0 +1,56 @@ +import * as React from 'react'; +import { Dispatch, SetStateAction } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Typography, +} from '@mui/material'; +import { useSelector } from 'react-redux'; +import { selectDigitalTwinByName } from 'preview/store/digitalTwin.slice'; +import { formatName } from 'preview/util/gitlabDigitalTwin'; + +interface LogDialogProps { + showLog: boolean; + setShowLog: Dispatch>; + name: string; +} + +const handleCloseLog = (setShowLog: Dispatch>) => { + setShowLog(false); +}; + +function LogDialog({ showLog, setShowLog, name }: LogDialogProps) { + const digitalTwin = useSelector(selectDigitalTwinByName(name)); + + return ( + + {`${formatName(name)} log`} + + {digitalTwin.jobLogs.length > 0 ? ( + digitalTwin.jobLogs.map( + (jobLog: { jobName: string; log: string }, index: number) => ( +
+ {jobLog.jobName} + + {jobLog.log} + +
+ ), + ) + ) : ( + No logs available + )} +
+ + + +
+ ); +} + +export default LogDialog; diff --git a/client/src/preview/route/digitaltwins/execute/pipelineChecks.ts b/client/src/preview/route/digitaltwins/execute/pipelineChecks.ts new file mode 100644 index 000000000..49ead6de5 --- /dev/null +++ b/client/src/preview/route/digitaltwins/execute/pipelineChecks.ts @@ -0,0 +1,167 @@ +import { Dispatch, SetStateAction } from 'react'; +import { useDispatch } from 'react-redux'; +import DigitalTwin, { formatName } from 'preview/util/gitlabDigitalTwin'; +import { + fetchJobLogs, + updatePipelineStateOnCompletion, +} from 'preview/route/digitaltwins/execute/pipelineUtils'; +import { showSnackbar } from 'preview/store/snackbar.slice'; + +interface PipelineStatusParams { + setButtonText: Dispatch>; + digitalTwin: DigitalTwin; + setLogButtonDisabled: Dispatch>; + dispatch: ReturnType; +} + +const MAX_EXECUTION_TIME = 10 * 60 * 1000; +export const delay = (ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +export const hasTimedOut = (startTime: number) => + Date.now() - startTime > MAX_EXECUTION_TIME; + +export const handleTimeout = ( + DTName: string, + setButtonText: Dispatch>, + setLogButtonDisabled: Dispatch>, + dispatch: ReturnType, +) => { + dispatch( + showSnackbar({ + message: `Execution timed out for ${formatName(DTName)}`, + severity: 'error', + }), + ); + setButtonText('Start'); + setLogButtonDisabled(false); +}; + +export const startPipelineStatusCheck = (params: PipelineStatusParams) => { + const startTime = Date.now(); + checkParentPipelineStatus({ ...params, startTime }); +}; + +export const checkParentPipelineStatus = async ({ + setButtonText, + digitalTwin, + setLogButtonDisabled, + dispatch, + startTime, +}: PipelineStatusParams & { + startTime: number; +}) => { + const pipelineStatus = await digitalTwin.gitlabInstance.getPipelineStatus( + digitalTwin.gitlabInstance.projectId!, + digitalTwin.pipelineId!, + ); + + if (pipelineStatus === 'success') { + await checkChildPipelineStatus({ + setButtonText, + digitalTwin, + setLogButtonDisabled, + dispatch, + startTime, + }); + } else if (pipelineStatus === 'failed') { + const jobLogs = await fetchJobLogs( + digitalTwin.gitlabInstance, + digitalTwin.pipelineId!, + ); + updatePipelineStateOnCompletion( + digitalTwin, + jobLogs, + setButtonText, + setLogButtonDisabled, + dispatch, + ); + } else if (hasTimedOut(startTime)) { + handleTimeout( + digitalTwin.DTName, + setButtonText, + setLogButtonDisabled, + dispatch, + ); + } else { + await delay(5000); + checkParentPipelineStatus({ + setButtonText, + digitalTwin, + setLogButtonDisabled, + dispatch, + startTime, + }); + } +}; + +export const handlePipelineCompletion = async ( + pipelineId: number, + digitalTwin: DigitalTwin, + setButtonText: Dispatch>, + setLogButtonDisabled: Dispatch>, + dispatch: ReturnType, + pipelineStatus: 'success' | 'failed', +) => { + const jobLogs = await fetchJobLogs(digitalTwin.gitlabInstance, pipelineId); + updatePipelineStateOnCompletion( + digitalTwin, + jobLogs, + setButtonText, + setLogButtonDisabled, + dispatch, + ); + if (pipelineStatus === 'failed') { + dispatch( + showSnackbar({ + message: `Execution failed for ${formatName(digitalTwin.DTName)}`, + severity: 'error', + }), + ); + } +}; + +export const checkChildPipelineStatus = async ({ + setButtonText, + digitalTwin, + setLogButtonDisabled, + dispatch, + startTime, +}: PipelineStatusParams & { + startTime: number; +}) => { + const pipelineId = digitalTwin.pipelineId! + 1; + const pipelineStatus = await digitalTwin.gitlabInstance.getPipelineStatus( + digitalTwin.gitlabInstance.projectId!, + pipelineId, + ); + + if (pipelineStatus === 'success' || pipelineStatus === 'failed') { + await handlePipelineCompletion( + pipelineId, + digitalTwin, + setButtonText, + setLogButtonDisabled, + dispatch, + pipelineStatus, + ); + } else if (hasTimedOut(startTime)) { + handleTimeout( + digitalTwin.DTName, + setButtonText, + setLogButtonDisabled, + dispatch, + ); + } else { + await delay(5000); + await checkChildPipelineStatus({ + setButtonText, + digitalTwin, + setLogButtonDisabled, + dispatch, + startTime, + }); + } +}; diff --git a/client/src/preview/route/digitaltwins/execute/pipelineHandler.ts b/client/src/preview/route/digitaltwins/execute/pipelineHandler.ts new file mode 100644 index 000000000..3c3cd1d54 --- /dev/null +++ b/client/src/preview/route/digitaltwins/execute/pipelineHandler.ts @@ -0,0 +1,94 @@ +import { Dispatch, SetStateAction } from 'react'; +import DigitalTwin, { formatName } from 'preview/util/gitlabDigitalTwin'; +import { useDispatch } from 'react-redux'; +import { showSnackbar } from 'preview/store/snackbar.slice'; +import { + startPipeline, + updatePipelineState, + updatePipelineStateOnStop, +} from './pipelineUtils'; +import { startPipelineStatusCheck } from './pipelineChecks'; + +export const handleButtonClick = ( + buttonText: string, + setButtonText: Dispatch>, + digitalTwin: DigitalTwin, + setLogButtonDisabled: Dispatch>, + dispatch: ReturnType, +) => { + if (buttonText === 'Start') { + handleStart( + buttonText, + setButtonText, + digitalTwin, + setLogButtonDisabled, + dispatch, + ); + } else { + handleStop(digitalTwin, setButtonText, dispatch); + } +}; + +export const handleStart = async ( + buttonText: string, + setButtonText: Dispatch>, + digitalTwin: DigitalTwin, + setLogButtonDisabled: Dispatch>, + dispatch: ReturnType, +) => { + if (buttonText === 'Start') { + setButtonText('Stop'); + setLogButtonDisabled(true); + updatePipelineState(digitalTwin, dispatch); + await startPipeline(digitalTwin, dispatch, setLogButtonDisabled); + const params = { + setButtonText, + digitalTwin, + setLogButtonDisabled, + dispatch, + }; + startPipelineStatusCheck(params); + } else { + setButtonText('Start'); + } +}; + +export const handleStop = async ( + digitalTwin: DigitalTwin, + setButtonText: Dispatch>, + dispatch: ReturnType, +) => { + try { + await stopPipelines(digitalTwin); + dispatch( + showSnackbar({ + message: `Execution stopped successfully for ${formatName( + digitalTwin.DTName, + )}`, + severity: 'success', + }), + ); + } catch (error) { + dispatch( + showSnackbar({ + message: `Execution stop failed for ${formatName(digitalTwin.DTName)}`, + severity: 'error', + }), + ); + } finally { + updatePipelineStateOnStop(digitalTwin, setButtonText, dispatch); + } +}; + +export const stopPipelines = async (digitalTwin: DigitalTwin) => { + if (digitalTwin.gitlabInstance.projectId && digitalTwin.pipelineId) { + await digitalTwin.stop( + digitalTwin.gitlabInstance.projectId, + 'parentPipeline', + ); + await digitalTwin.stop( + digitalTwin.gitlabInstance.projectId, + 'childPipeline', + ); + } +}; diff --git a/client/src/preview/route/digitaltwins/execute/pipelineUtils.ts b/client/src/preview/route/digitaltwins/execute/pipelineUtils.ts new file mode 100644 index 000000000..50b901d3a --- /dev/null +++ b/client/src/preview/route/digitaltwins/execute/pipelineUtils.ts @@ -0,0 +1,126 @@ +import { Dispatch, SetStateAction } from 'react'; +import DigitalTwin, { formatName } from 'preview/util/gitlabDigitalTwin'; +import GitlabInstance from 'preview/util/gitlab'; +import { + setJobLogs, + setPipelineCompleted, + setPipelineLoading, +} from 'preview/store/digitalTwin.slice'; +import { useDispatch } from 'react-redux'; +import { showSnackbar } from 'preview/store/snackbar.slice'; + +export const startPipeline = async ( + digitalTwin: DigitalTwin, + dispatch: ReturnType, + setLogButtonDisabled: Dispatch>, +) => { + await digitalTwin.execute(); + const executionStatusMessage = + digitalTwin.lastExecutionStatus === 'success' + ? `Execution started successfully for ${formatName(digitalTwin.DTName)}. Wait until completion for the logs...` + : `Execution ${digitalTwin.lastExecutionStatus} for ${formatName(digitalTwin.DTName)}`; + dispatch( + showSnackbar({ + message: executionStatusMessage, + severity: + digitalTwin.lastExecutionStatus === 'success' ? 'success' : 'error', + }), + ); + setLogButtonDisabled(true); +}; + +export const updatePipelineState = ( + digitalTwin: DigitalTwin, + dispatch: ReturnType, +) => { + dispatch( + setPipelineCompleted({ + assetName: digitalTwin.DTName, + pipelineCompleted: false, + }), + ); + dispatch( + setPipelineLoading({ + assetName: digitalTwin.DTName, + pipelineLoading: true, + }), + ); +}; + +export const updatePipelineStateOnCompletion = ( + digitalTwin: DigitalTwin, + jobLogs: { jobName: string; log: string }[], + setButtonText: Dispatch>, + setLogButtonDisabled: Dispatch>, + dispatch: ReturnType, +) => { + dispatch(setJobLogs({ assetName: digitalTwin.DTName, jobLogs })); + dispatch( + setPipelineCompleted({ + assetName: digitalTwin.DTName, + pipelineCompleted: true, + }), + ); + dispatch( + setPipelineLoading({ + assetName: digitalTwin.DTName, + pipelineLoading: false, + }), + ); + setButtonText('Start'); + setLogButtonDisabled(false); +}; + +export const updatePipelineStateOnStop = ( + digitalTwin: DigitalTwin, + setButtonText: Dispatch>, + dispatch: ReturnType, +) => { + setButtonText('Start'); + dispatch( + setPipelineCompleted({ + assetName: digitalTwin.DTName, + pipelineCompleted: true, + }), + ); + dispatch( + setPipelineLoading({ + assetName: digitalTwin.DTName, + pipelineLoading: false, + }), + ); +}; + +export const fetchJobLogs = async ( + gitlabInstance: GitlabInstance, + pipelineId: number, +) => { + const jobs = await gitlabInstance.getPipelineJobs( + gitlabInstance.projectId!, + pipelineId, + ); + const logPromises = jobs.map(async (job) => { + const log = await gitlabInstance.getJobTrace( + gitlabInstance.projectId!, + job.id, + ); + if (typeof log === 'string') { + log + .replace( + // TODO: Fix ansi character stripping + // eslint-disable-next-line no-control-regex + /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, + '', + ) + .split('\n') + .map((line: string) => + line + .replace(/section_start:\d+:[^A-Z]*/, '') + .replace(/section_end:\d+:[^A-Z]*/, ''), + ) + .join('\n'); + } + return { jobName: job.name, log }; + }); + return (await Promise.all(logPromises)).reverse(); +}; diff --git a/client/src/preview/store/assets.slice.ts b/client/src/preview/store/assets.slice.ts new file mode 100644 index 000000000..ae89c6d02 --- /dev/null +++ b/client/src/preview/store/assets.slice.ts @@ -0,0 +1,24 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { Asset } from '../components/asset/Asset'; + +interface AssetsState { + items: Asset[]; +} + +const initialState: AssetsState = { + items: [], +}; + +const assetsSlice = createSlice({ + name: 'assets', + initialState, + reducers: { + setAssets: (state, action: PayloadAction) => { + state.items = action.payload; + }, + }, +}); + +export const { setAssets } = assetsSlice.actions; + +export default assetsSlice.reducer; diff --git a/client/src/preview/store/digitalTwin.slice.ts b/client/src/preview/store/digitalTwin.slice.ts new file mode 100644 index 000000000..ec836273f --- /dev/null +++ b/client/src/preview/store/digitalTwin.slice.ts @@ -0,0 +1,61 @@ +import { PayloadAction, createSlice } from '@reduxjs/toolkit'; +import DigitalTwin from 'preview/util/gitlabDigitalTwin'; +import { JobLog } from 'preview/components/asset/StartStopButton'; +import { RootState } from 'store/store'; + +interface DigitalTwinState { + [key: string]: DigitalTwin; +} + +const initialState: DigitalTwinState = {}; + +const digitalTwinSlice = createSlice({ + name: 'digitalTwin', + initialState, + reducers: { + setDigitalTwin: ( + state, + action: PayloadAction<{ assetName: string; digitalTwin: DigitalTwin }>, + ) => { + state[action.payload.assetName] = action.payload.digitalTwin; + }, + setJobLogs: ( + state, + action: PayloadAction<{ assetName: string; jobLogs: JobLog[] }>, + ) => { + const digitalTwin = state[action.payload.assetName]; + if (digitalTwin) { + digitalTwin.jobLogs = action.payload.jobLogs; + } + }, + setPipelineCompleted: ( + state, + action: PayloadAction<{ assetName: string; pipelineCompleted: boolean }>, + ) => { + const digitalTwin = state[action.payload.assetName]; + if (digitalTwin) { + digitalTwin.pipelineCompleted = action.payload.pipelineCompleted; + } + }, + setPipelineLoading: ( + state, + action: PayloadAction<{ assetName: string; pipelineLoading: boolean }>, + ) => { + const digitalTwin = state[action.payload.assetName]; + if (digitalTwin) { + digitalTwin.pipelineLoading = action.payload.pipelineLoading; + } + }, + }, +}); + +export const selectDigitalTwinByName = (name: string) => (state: RootState) => + state.digitalTwin[name]; + +export const { + setDigitalTwin, + setJobLogs, + setPipelineCompleted, + setPipelineLoading, +} = digitalTwinSlice.actions; +export default digitalTwinSlice.reducer; diff --git a/client/src/preview/store/snackbar.slice.ts b/client/src/preview/store/snackbar.slice.ts new file mode 100644 index 000000000..6db2b8d9c --- /dev/null +++ b/client/src/preview/store/snackbar.slice.ts @@ -0,0 +1,37 @@ +import { AlertColor } from '@mui/material'; +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +export interface SnackbarState { + open: boolean; + message: string; + severity: AlertColor; +} + +const initialState: SnackbarState = { + open: false, + message: '', + severity: 'info', +}; + +const snackbarSlice = createSlice({ + name: 'snackbar', + initialState, + reducers: { + showSnackbar( + state, + action: PayloadAction<{ message: string; severity: AlertColor }>, + ) { + state.open = true; + state.message = action.payload.message; + state.severity = action.payload.severity; + }, + hideSnackbar(state) { + state.open = false; + state.message = ''; + state.severity = 'info'; + }, + }, +}); + +export const { showSnackbar, hideSnackbar } = snackbarSlice.actions; +export default snackbarSlice.reducer; diff --git a/client/src/preview/util/gitlab.ts b/client/src/preview/util/gitlab.ts new file mode 100644 index 000000000..7aa965909 --- /dev/null +++ b/client/src/preview/util/gitlab.ts @@ -0,0 +1,127 @@ +import { Camelize, Gitlab, JobSchema } from '@gitbeaker/rest'; +import { Asset } from '../components/asset/Asset'; + +const GROUP_NAME = 'DTaaS'; +const DT_DIRECTORY = 'digital_twins'; + +interface LogEntry { + status: string; + DTName: string; + runnerTag: string; + error?: Error; +} + +class GitlabInstance { + public username: string | null; + + public api: InstanceType; + + public logs: LogEntry[]; + + public subfolders: Asset[]; + + public projectId: number | null = null; + + public triggerToken: string | null = null; + + constructor(username: string, host: string, oauthToken: string) { + this.username = username; + this.api = new Gitlab({ + host, + oauthToken, + }); + this.logs = []; + this.subfolders = []; + } + + async init() { + const projectId = await this.getProjectId(); + this.projectId = projectId; + + if (this.projectId !== null) { + const token = await this.getTriggerToken(this.projectId); + this.triggerToken = token; + } + } + + async getProjectId(): Promise { + let projectId: number | null = null; + + const group = await this.api.Groups.show(GROUP_NAME); + const projects = await this.api.Groups.allProjects(group.id); + const project = projects.find((proj) => proj.name === this.username); + + if (project) { + projectId = project.id; + } + return projectId; + } + + async getTriggerToken(projectId: number): Promise { + let token: string | null = null; + + const triggers = await this.api.PipelineTriggerTokens.all(projectId); + + if (triggers && triggers.length > 0) { + token = triggers[0].token; + } + return token; + } + + async getDTDescription(DTName: string): Promise { + const readmePath = `digital_twins/${DTName}/description.md`; + const fileData = await this.api.RepositoryFiles.show( + this.projectId!, + readmePath, + 'main', + ); + return atob(fileData.content); + } + + async getDTSubfolders(projectId: number): Promise { + const files = await this.api.Repositories.allRepositoryTrees(projectId, { + path: DT_DIRECTORY, + recursive: false, + }); + + const subfolders: Asset[] = await Promise.all( + files + .filter((file) => file.type === 'tree' && file.path !== DT_DIRECTORY) + .map(async (file) => ({ + name: file.name, + path: file.path, + description: await this.getDTDescription(file.name), + })), + ); + + this.subfolders = subfolders; + return subfolders; + } + + executionLogs(): LogEntry[] { + return this.logs; + } + + async getPipelineJobs( + projectId: number, + pipelineId: number, + ): Promise<(JobSchema | Camelize)[]> { + const jobs = await this.api.Jobs.all(projectId, { pipelineId }); + return jobs; + } + + async getJobTrace(projectId: number, jobId: number): Promise { + const log = await this.api.Jobs.showLog(projectId, jobId); + return log; + } + + async getPipelineStatus( + projectId: number, + pipelineId: number, + ): Promise { + const pipeline = await this.api.Pipelines.show(projectId, pipelineId); + return pipeline.status; + } +} + +export default GitlabInstance; diff --git a/client/src/preview/util/gitlabDigitalTwin.ts b/client/src/preview/util/gitlabDigitalTwin.ts new file mode 100644 index 000000000..b3cd4305c --- /dev/null +++ b/client/src/preview/util/gitlabDigitalTwin.ts @@ -0,0 +1,129 @@ +import GitlabInstance from './gitlab'; + +const RUNNER_TAG = 'linux'; + +export const formatName = (name: string) => + name.replace(/-/g, ' ').replace(/^./, (char) => char.toUpperCase()); + +class DigitalTwin { + public DTName: string; + + public description: string | undefined = ''; + + public fullDescription: string = ''; + + public gitlabInstance: GitlabInstance; + + public pipelineId: number | null = null; + + public lastExecutionStatus: string | null = null; + + public jobLogs: { jobName: string; log: string }[] = []; + + public pipelineLoading: boolean = false; + + public pipelineCompleted: boolean = false; + + public descriptionFiles: string[] = []; + + public configFiles: string[] = []; + + constructor(DTName: string, gitlabInstance: GitlabInstance) { + this.DTName = DTName; + this.gitlabInstance = gitlabInstance; + } + + async getFullDescription(): Promise { + if (this.gitlabInstance.projectId) { + const readmePath = `digital_twins/${this.DTName}/README.md`; + try { + const fileData = await this.gitlabInstance.api.RepositoryFiles.show( + this.gitlabInstance.projectId, + readmePath, + 'main', + ); + this.fullDescription = atob(fileData.content); + } catch (error) { + this.fullDescription = `There is no README.md file in the ${this.DTName} GitLab folder`; + } + } else { + this.fullDescription = 'Error fetching description, retry.'; + } + } + + isValidInstance(): boolean { + return !!( + this.gitlabInstance.projectId && this.gitlabInstance.triggerToken + ); + } + + logSuccess(): void { + this.gitlabInstance.logs.push({ + status: 'success', + DTName: this.DTName, + runnerTag: RUNNER_TAG, + }); + this.lastExecutionStatus = 'success'; + } + + logError(error: string): void { + this.gitlabInstance.logs.push({ + status: 'error', + error: new Error(error), + DTName: this.DTName, + runnerTag: RUNNER_TAG, + }); + this.lastExecutionStatus = 'error'; + } + + async triggerPipeline() { + const variables = { DTName: this.DTName, RunnerTag: RUNNER_TAG }; + return this.gitlabInstance.api.PipelineTriggerTokens.trigger( + this.gitlabInstance.projectId!, + 'main', + this.gitlabInstance.triggerToken!, + { variables }, + ); + } + + async execute(): Promise { + if (!this.isValidInstance()) { + this.logError('Missing projectId or triggerToken'); + return null; + } + + try { + const response = await this.triggerPipeline(); + this.logSuccess(); + this.pipelineId = response.id; + return this.pipelineId; + } catch (error) { + this.logError(String(error)); + return null; + } + } + + async stop(projectId: number, pipeline: string): Promise { + const pipelineId = + pipeline === 'parentPipeline' ? this.pipelineId : this.pipelineId! + 1; + try { + await this.gitlabInstance.api.Pipelines.cancel(projectId, pipelineId!); + this.gitlabInstance.logs.push({ + status: 'canceled', + DTName: this.DTName, + runnerTag: RUNNER_TAG, + }); + this.lastExecutionStatus = 'canceled'; + } catch (error) { + this.gitlabInstance.logs.push({ + status: 'error', + error: new Error(String(error)), + DTName: this.DTName, + runnerTag: RUNNER_TAG, + }); + this.lastExecutionStatus = 'error'; + } + } +} + +export default DigitalTwin; diff --git a/client/src/util/gitlabDriver.ts b/client/src/preview/util/gitlabDriver.ts old mode 100755 new mode 100644 similarity index 81% rename from client/src/util/gitlabDriver.ts rename to client/src/preview/util/gitlabDriver.ts index f15965b8a..5737b3f9b --- a/client/src/util/gitlabDriver.ts +++ b/client/src/preview/util/gitlabDriver.ts @@ -1,7 +1,7 @@ /* eslint-disable no-console */ import GitlabInstance from './gitlab.js'; import DigitalTwin from './gitlabDigitalTwin.js'; -import config from './gitlab.json' assert { type: 'json' }; +import config from '../../../config/gitlab.json' assert { type: 'json' }; class GitlabDriver { public static async run(): Promise { @@ -10,6 +10,9 @@ class GitlabDriver { config.host, config.oauth_token, ); + + await gitlabInstance.init(); + console.log('GitLab username:', gitlabInstance.username); console.log('GitLab logs:', gitlabInstance.logs); console.log('GitLab subfolders:', gitlabInstance.subfolders); @@ -21,18 +24,16 @@ class GitlabDriver { console.log('Subfolders:', subfolders); const dtName = subfolders[0].name; - const runnerTag = 'dtaas'; const triggerToken = await gitlabInstance.getTriggerToken(projectId); console.log('Trigger token:', triggerToken); const digitalTwin = new DigitalTwin(dtName, gitlabInstance); - const result = await digitalTwin.execute(runnerTag); + const result = await digitalTwin.execute(); console.log('Execution Result:', result); - const lastExecutionStatus = digitalTwin.executionStatus(); - console.log('Execution Status:', lastExecutionStatus); + console.log('Last execution Status:', digitalTwin.lastExecutionStatus); const logs = gitlabInstance.executionLogs(); console.log('Execution Logs:', logs); diff --git a/client/src/routes.tsx b/client/src/routes.tsx index cd0890198..676481ae0 100644 --- a/client/src/routes.tsx +++ b/client/src/routes.tsx @@ -4,6 +4,7 @@ import LayoutPublic from 'page/LayoutPublic'; import PrivateRoute from 'route/auth/PrivateRoute'; import Library from './route/library/Library'; import DigitalTwins from './route/digitaltwins/DigitalTwins'; +import DigitalTwinsPreview from './preview/route/digitaltwins/DigitalTwinsPreview'; import SignIn from './route/auth/Signin'; import Account from './route/auth/Account'; @@ -48,6 +49,14 @@ export const routes = [ ), }, + { + path: 'preview/digitaltwins', + element: ( + + + + ), + }, ]; export default routes; diff --git a/client/src/store/store.ts b/client/src/store/store.ts index 566c8b5f6..1e7a31850 100644 --- a/client/src/store/store.ts +++ b/client/src/store/store.ts @@ -1,16 +1,29 @@ import { combineReducers } from 'redux'; import { configureStore } from '@reduxjs/toolkit'; +import digitalTwinSlice from 'preview/store/digitalTwin.slice'; +import snackbarSlice from 'preview/store/snackbar.slice'; +import assetsSlice from 'preview/store/assets.slice'; import menuSlice from './menu.slice'; import authSlice from './auth.slice'; const rootReducer = combineReducers({ menu: menuSlice, auth: authSlice, + assets: assetsSlice, + digitalTwin: digitalTwinSlice, + snackbar: snackbarSlice, }); const store = configureStore({ reducer: rootReducer, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ + serializableCheck: { + ignoredActions: ['digitalTwin/setDigitalTwin'], + }, + }), }); export type RootState = ReturnType; + export default store; diff --git a/client/src/util/envUtil.ts b/client/src/util/envUtil.ts index 490e57a5b..b82b0a078 100644 --- a/client/src/util/envUtil.ts +++ b/client/src/util/envUtil.ts @@ -62,9 +62,13 @@ export function getWorkbenchLinkValues(): KeyLinkPair[] { const value = window.env[key]; if (value !== undefined) { const keyWithoutPrefix = key.slice(prefix.length); + const linkValue = + keyWithoutPrefix === 'DT_PREVIEW' + ? value + : useUserLink(useAppURL(), value); workbenchLinkValues.push({ key: keyWithoutPrefix, - link: useUserLink(useAppURL(), value), + link: linkValue, }); } }); @@ -72,6 +76,10 @@ export function getWorkbenchLinkValues(): KeyLinkPair[] { return workbenchLinkValues; } +export function getDTPagePreviewLink(): string { + return useUserLink(useAppURL(), 'preview/digitaltwins'); +} + export function getClientID(): string { return window.env.REACT_APP_CLIENT_ID; } diff --git a/client/src/util/gitlab.ts b/client/src/util/gitlab.ts deleted file mode 100644 index 8905e6525..000000000 --- a/client/src/util/gitlab.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { Gitlab } from '@gitbeaker/rest'; - -const GROUP_NAME = 'DTaaS'; -const DT_DIRECTORY = 'digital_twins'; - -interface LogEntry { - status: string; - DTName: string; - runnerTag: string; - error?: Error; -} - -interface FolderEntry { - name: string; - path: string; -} - -class GitlabInstance { - public username: string | null; - - public api: InstanceType; - - public logs: LogEntry[]; - - public subfolders: FolderEntry[]; - - constructor(username: string, host: string, oauthToken: string) { - this.username = username; - this.api = new Gitlab({ - host, - oauthToken, - }); - this.logs = []; - this.subfolders = []; - } - - async getProjectId(): Promise { - let projectId: number | null = null; - - const group = await this.api.Groups.show(GROUP_NAME); - const projects = await this.api.Groups.allProjects(group.id); - const project = projects.find((proj) => proj.name === this.username); - - if (project) { - projectId = project.id; - } - - return projectId; - } - - async getTriggerToken(projectId: number): Promise { - let token: string | null = null; - - const triggers = await this.api.PipelineTriggerTokens.all(projectId); - if (triggers && triggers.length > 0) { - token = triggers[0].token; - } - return token; - } - - async getDTSubfolders(projectId: number): Promise { - let subfolders: FolderEntry[] = []; - - const files = await this.api.Repositories.allRepositoryTrees(projectId, { - path: DT_DIRECTORY, - recursive: false, - }); - - subfolders = files - .filter((file) => file.type === 'tree' && file.path !== DT_DIRECTORY) - .map((file) => ({ - name: file.name, - path: file.path, - })); - - this.subfolders = subfolders; - return subfolders; - } - - executionLogs(): LogEntry[] { - return this.logs; - } -} - -export default GitlabInstance; diff --git a/client/src/util/gitlabDigitalTwin.ts b/client/src/util/gitlabDigitalTwin.ts deleted file mode 100644 index 8593de49a..000000000 --- a/client/src/util/gitlabDigitalTwin.ts +++ /dev/null @@ -1,58 +0,0 @@ -import GitlabInstance from './gitlab'; - -class DigitalTwin { - public DTName: string; - - public gitlabInstance: GitlabInstance; - - public lastExecutionStatus: string | null = null; - - constructor(DTName: string, gitlabInstance: GitlabInstance) { - this.DTName = DTName; - this.gitlabInstance = gitlabInstance; - } - - async execute(runnerTag: string): Promise { - const projectId = await this.gitlabInstance.getProjectId(); - const triggerToken = - projectId && (await this.gitlabInstance.getTriggerToken(projectId)); - - if (!projectId || !triggerToken) { - this.lastExecutionStatus = 'error'; - return false; - } - - const variables = { DTName: this.DTName, RunnerTag: runnerTag }; - - try { - await this.gitlabInstance.api.PipelineTriggerTokens.trigger( - projectId, - 'main', - triggerToken, - { variables }, - ); - this.gitlabInstance.logs.push({ - status: 'success', - DTName: this.DTName, - runnerTag, - }); - this.lastExecutionStatus = 'success'; - return true; - } catch (error) { - this.gitlabInstance.logs.push({ - status: 'error', - error: new Error(String(error)), - DTName: this.DTName, - runnerTag, - }); - this.lastExecutionStatus = 'error'; - return false; - } - } - - executionStatus(): string | null { - return this.lastExecutionStatus; - } -} - -export default DigitalTwin; diff --git a/client/test/README.md b/client/test/README.md index ec1f512bb..0ef073dd4 100644 --- a/client/test/README.md +++ b/client/test/README.md @@ -78,6 +78,7 @@ window.env = { REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '934b98f03f1b6f743832b2840bf7cccaed93c3bfe579093dd0942a433691ccc0', REACT_APP_AUTH_AUTHORITY: 'https://gitlab.foo.com/', @@ -101,6 +102,7 @@ window.env = { REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '934b98f03f1b6f743832b2840bf7cccaed93c3bfe579093dd0942a433691ccc0', REACT_APP_AUTH_AUTHORITY: 'https://gitlab.foo.com/', diff --git a/client/test/preview/__mocks__/global_mocks.ts b/client/test/preview/__mocks__/global_mocks.ts new file mode 100644 index 000000000..9cb299c48 --- /dev/null +++ b/client/test/preview/__mocks__/global_mocks.ts @@ -0,0 +1,114 @@ +import { Gitlab } from '@gitbeaker/core'; +import GitlabInstance from 'preview/util/gitlab'; +import DigitalTwin from 'preview/util/gitlabDigitalTwin'; + +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'; +export const mockClientID = 'mockedClientID'; +export const mockAuthority = 'https://example.com/AUTHORITY'; +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, +}; + +export type mockGitlabInstanceType = { + projectId: number; + triggerToken: string; + getPipelineStatus: jest.Mock; +}; + +export const mockGitlabInstance: GitlabInstance = { + username: 'mockedUsername', + api: new Gitlab({ + host: 'mockedHost', + token: 'mockedToken', + requesterFn: jest.fn(), + }), + logs: [], + subfolders: [], + projectId: 1, + triggerToken: 'mock trigger token', + init: jest.fn(), + getProjectId: jest.fn(), + getTriggerToken: jest.fn(), + getDTDescription: jest.fn(), + getDTSubfolders: jest.fn(), + executionLogs: jest.fn(), + getPipelineJobs: jest.fn(), + getJobTrace: jest.fn(), + getPipelineStatus: jest.fn(), +}; + +export const mockDigitalTwin: DigitalTwin = { + DTName: 'mockedDTName', + description: 'mockedDescription', + fullDescription: 'mockedFullDescription', + gitlabInstance: mockGitlabInstance, + pipelineId: 1, + lastExecutionStatus: 'mockedStatus', + jobLogs: [{ jobName: 'job1', log: 'log1' }], + pipelineLoading: false, + pipelineCompleted: false, + descriptionFiles: ['file1'], + configFiles: ['file2'], + + getFullDescription: jest.fn(), + execute: jest.fn(), + isValidInstance: jest.fn(), + triggerPipeline: jest.fn(), + logSuccess: jest.fn(), + logError: jest.fn(), + stop: jest.fn(), +}; + +jest.mock('util/envUtil', () => ({ + ...jest.requireActual('util/envUtil'), + useURLforDT: () => mockURLforDT, + useURLforLIB: () => mockURLforLIB, + getClientID: () => mockClientID, + getAuthority: () => mockAuthority, + getRedirectURI: () => mockRedirectURI, + getLogoutRedirectURI: () => mockLogoutRedirectURI, + getGitLabScopes: () => mockGitLabScopes, + getURLforWorkbench: () => mockURLforWorkbench, + getWorkbenchLinkValues: () => [ + { key: '1', link: 'link1' }, + { key: '2', link: 'link2' }, + { key: '3', link: 'link3' }, + ], +})); diff --git a/client/test/preview/__mocks__/integration/module_mocks.tsx b/client/test/preview/__mocks__/integration/module_mocks.tsx new file mode 100644 index 000000000..c28208a90 --- /dev/null +++ b/client/test/preview/__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/preview/__mocks__/unit/component_mocks.tsx b/client/test/preview/__mocks__/unit/component_mocks.tsx new file mode 100644 index 000000000..961c43142 --- /dev/null +++ b/client/test/preview/__mocks__/unit/component_mocks.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; + +jest.mock('@mui/material/CircularProgress', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('@mui/material/Backdrop', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('components/tab/TabComponent', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('@mui/material/Backdrop', () => ({ + __esModule: true, + default: () =>
, +})); diff --git a/client/test/preview/integration/components/asset/AssetBoard.test.tsx b/client/test/preview/integration/components/asset/AssetBoard.test.tsx new file mode 100644 index 000000000..2e7dfc1e6 --- /dev/null +++ b/client/test/preview/integration/components/asset/AssetBoard.test.tsx @@ -0,0 +1,81 @@ +import * as React from 'react'; +import { render, screen } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import AssetBoard from 'preview/components/asset/AssetBoard'; +import { + combineReducers, + configureStore, + getDefaultMiddleware, +} from '@reduxjs/toolkit'; +import assetsReducer, { setAssets } from 'preview/store/assets.slice'; +import digitalTwinReducer, { + setDigitalTwin, +} from 'preview/store/digitalTwin.slice'; +import snackbarSlice from 'preview/store/snackbar.slice'; +import { Asset } from 'preview/components/asset/Asset'; +import { mockDigitalTwin } from 'test/preview/__mocks__/global_mocks'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: jest.fn(), +})); + +jest.useFakeTimers(); + +const preSetItems: Asset[] = [ + { name: 'Asset 1', description: 'Mocked description', path: 'path/asset1' }, +]; + +const store = configureStore({ + reducer: combineReducers({ + assets: assetsReducer, + digitalTwin: digitalTwinReducer, + snackbar: snackbarSlice, + }), + middleware: getDefaultMiddleware({ + serializableCheck: false, + }), +}); + +mockDigitalTwin.description = 'Mocked description'; + +describe('AssetBoard Integration Tests', () => { + const setupTest = () => { + store.dispatch(setAssets(preSetItems)); + store.dispatch( + setDigitalTwin({ + assetName: 'Asset 1', + digitalTwin: mockDigitalTwin, + }), + ); + }; + + beforeEach(() => { + setupTest(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders AssetBoard with assets', () => { + render( + + + , + ); + + expect(screen.getByText('Asset 1')).toBeInTheDocument(); + expect(screen.getByText('Mocked description')).toBeInTheDocument(); + }); + + it('renders error message when error is present', () => { + render( + + + , + ); + + expect(screen.getByText('An error occurred')).toBeInTheDocument(); + }); +}); diff --git a/client/test/preview/integration/components/asset/AssetCardExecute.test.tsx b/client/test/preview/integration/components/asset/AssetCardExecute.test.tsx new file mode 100644 index 000000000..6fcdd5fcb --- /dev/null +++ b/client/test/preview/integration/components/asset/AssetCardExecute.test.tsx @@ -0,0 +1,74 @@ +import { + combineReducers, + configureStore, + getDefaultMiddleware, +} from '@reduxjs/toolkit'; +import { fireEvent, render, screen, act } from '@testing-library/react'; +import AssetCardExecute from 'preview/components/asset/AssetCard'; +import * as React from 'react'; +import { Provider } from 'react-redux'; +import assetsReducer, { setAssets } from 'preview/store/assets.slice'; +import digitalTwinReducer, { + setDigitalTwin, +} from 'preview/store/digitalTwin.slice'; +import snackbarSlice from 'preview/store/snackbar.slice'; +import { mockDigitalTwin } from 'test/preview/__mocks__/global_mocks'; + +const store = configureStore({ + reducer: combineReducers({ + assets: assetsReducer, + digitalTwin: digitalTwinReducer, + snackbar: snackbarSlice, + }), + middleware: getDefaultMiddleware({ + serializableCheck: false, + }), +}); + +describe('AssetCardExecute Integration Test', () => { + const asset = { + name: 'Asset 1', + description: 'Mocked description', + path: 'path/asset1', + }; + + beforeEach(() => { + store.dispatch( + setAssets([ + { + name: 'Asset 1', + description: 'Mocked description', + path: 'path/asset1', + }, + ]), + ); + store.dispatch( + setDigitalTwin({ + assetName: 'Asset 1', + digitalTwin: mockDigitalTwin, + }), + ); + + render( + + + , + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('opens the Snackbar after clicking the Start button', async () => { + const startStopButton = screen.getByRole('button', { name: /Start/i }); + + await act(async () => { + fireEvent.click(startStopButton); + }); + + expect( + screen.getByText('Execution mockedStatus for MockedDTName'), + ).toBeInTheDocument(); + }); +}); diff --git a/client/test/preview/integration/components/asset/LogButton.test.tsx b/client/test/preview/integration/components/asset/LogButton.test.tsx new file mode 100644 index 000000000..affccdba4 --- /dev/null +++ b/client/test/preview/integration/components/asset/LogButton.test.tsx @@ -0,0 +1,66 @@ +import { screen, render, fireEvent } from '@testing-library/react'; +import LogButton from 'preview/components/asset/LogButton'; +import * as React from 'react'; +import { Provider } from 'react-redux'; +import store from 'store/store'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), +})); + +describe('LogButton', () => { + const renderLogButton = ( + setShowLog: jest.Mock = jest.fn(), + logButtonDisabled = false, + ) => + render( + + + , + ); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders the Log button', () => { + renderLogButton(); + expect(screen.getByRole('button', { name: /Log/i })).toBeInTheDocument(); + }); + + it('handles button click when enabled', () => { + renderLogButton(); + + const logButton = screen.getByRole('button', { name: /Log/i }); + fireEvent.click(logButton); + + expect(logButton).toBeEnabled(); + }); + + it('does not handle button click when disabled', () => { + renderLogButton(jest.fn(), true); + + const logButton = screen.getByRole('button', { name: /Log/i }); + fireEvent.click(logButton); + }); + + it('toggles setShowLog value correctly', () => { + let toggleValue = false; + const mockSetShowLog = jest.fn((callback) => { + toggleValue = callback(toggleValue); + }); + + renderLogButton(mockSetShowLog); + + const logButton = screen.getByRole('button', { name: /Log/i }); + + fireEvent.click(logButton); + expect(toggleValue).toBe(true); + + fireEvent.click(logButton); + expect(toggleValue).toBe(false); + }); +}); diff --git a/client/test/preview/integration/components/asset/StartStopButton.test.tsx b/client/test/preview/integration/components/asset/StartStopButton.test.tsx new file mode 100644 index 000000000..e0a37e283 --- /dev/null +++ b/client/test/preview/integration/components/asset/StartStopButton.test.tsx @@ -0,0 +1,101 @@ +import { + fireEvent, + render, + screen, + act, + waitFor, +} from '@testing-library/react'; +import StartStopButton from 'preview/components/asset/StartStopButton'; +import * as React from 'react'; +import { Provider } from 'react-redux'; +import { + combineReducers, + configureStore, + getDefaultMiddleware, +} from '@reduxjs/toolkit'; +import digitalTwinReducer, { + setDigitalTwin, + setPipelineLoading, +} from 'preview/store/digitalTwin.slice'; +import { handleButtonClick } from 'preview/route/digitaltwins/execute/pipelineHandler'; +import '@testing-library/jest-dom'; +import { mockDigitalTwin } from 'test/preview/__mocks__/global_mocks'; + +jest.mock('preview/route/digitaltwins/execute/pipelineHandler', () => ({ + handleButtonClick: jest.fn(), +})); + +jest.mock('@mui/material/CircularProgress', () => ({ + __esModule: true, + default: () =>
, +})); + +const createStore = () => + configureStore({ + reducer: combineReducers({ + digitalTwin: digitalTwinReducer, + }), + middleware: getDefaultMiddleware({ + serializableCheck: false, + }), + }); + +describe('StartStopButton Integration Test', () => { + let store: ReturnType; + const assetName = 'mockedDTName'; + const setLogButtonDisabled = jest.fn(); + + beforeEach(() => { + store = createStore(); + render( + + + , + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders only the Start button', () => { + expect(screen.getByRole('button', { name: /Start/i })).toBeInTheDocument(); + expect(screen.queryByTestId('circular-progress')).not.toBeInTheDocument(); + }); + + it('handles button click', async () => { + const startButton = screen.getByRole('button', { name: /Start/i }); + + await act(async () => { + fireEvent.click(startButton); + }); + + expect(handleButtonClick).toHaveBeenCalled(); + expect(screen.queryByTestId('circular-progress')).not.toBeInTheDocument(); + }); + + it('renders the circular progress when pipelineLoading is true', async () => { + await act(async () => { + store.dispatch( + setDigitalTwin({ + assetName: 'mockedDTName', + digitalTwin: mockDigitalTwin, + }), + ); + store.dispatch(setPipelineLoading({ assetName, pipelineLoading: true })); + }); + + const startButton = screen.getByRole('button', { name: /Start/i }); + + await act(async () => { + fireEvent.click(startButton); + }); + + await waitFor(() => { + expect(screen.queryByTestId('circular-progress')).toBeInTheDocument(); + }); + }); +}); diff --git a/client/test/preview/integration/components/asset/components.testUtil.tsx b/client/test/preview/integration/components/asset/components.testUtil.tsx new file mode 100644 index 000000000..1f8d8fe46 --- /dev/null +++ b/client/test/preview/integration/components/asset/components.testUtil.tsx @@ -0,0 +1,11 @@ +import { screen, within } from '@testing-library/react'; + +export async function testAssetBoard() { + const grid = screen.getByRole('grid'); + expect(grid).toBeInTheDocument(); +} + +export async function testGridItem(assetName: string) { + const gridItem = within(screen.getByRole('grid')).getByText(assetName); + expect(gridItem).toBeInTheDocument(); +} diff --git a/client/test/preview/integration/integration.testUtil.tsx b/client/test/preview/integration/integration.testUtil.tsx new file mode 100644 index 000000000..dcca132a5 --- /dev/null +++ b/client/test/preview/integration/integration.testUtil.tsx @@ -0,0 +1,55 @@ +import { cleanup, render, act } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { AppProvider } from 'AppProvider'; +import routes from 'routes'; +import * as React from 'react'; +import { useAuth } from 'react-oidc-context'; +import store from 'store/store'; +import { mockAuthState, mockAuthStateType } from '../__mocks__/global_mocks'; + +const renderWithAppProvider = (route: string) => { + window.history.pushState({}, 'Test page', route); + return render( + AppProvider({ + children: ( + + + {routes.map((routeElement) => ( + + ))} + ; + + + ), + }), + ); +}; + +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 default setupIntegrationTest; diff --git a/client/test/preview/integration/jest.setup.ts b/client/test/preview/integration/jest.setup.ts new file mode 100644 index 000000000..7bb13bd15 --- /dev/null +++ b/client/test/preview/integration/jest.setup.ts @@ -0,0 +1,7 @@ +import '@testing-library/jest-dom'; +import 'test/preview/__mocks__/integration/module_mocks'; +import 'test/preview/__mocks__/global_mocks'; + +beforeEach(() => { + jest.resetAllMocks(); +}); diff --git a/client/test/preview/integration/route/digitaltwins/DigitalTwinsPreview.test.tsx b/client/test/preview/integration/route/digitaltwins/DigitalTwinsPreview.test.tsx new file mode 100644 index 000000000..44baf7a66 --- /dev/null +++ b/client/test/preview/integration/route/digitaltwins/DigitalTwinsPreview.test.tsx @@ -0,0 +1,89 @@ +import * as React from 'react'; +import DigitalTwinsPreview, * as functions from 'preview/route/digitaltwins/DigitalTwinsPreview'; +import store from 'store/store'; +import { act, render, screen } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import { mockGitlabInstance } from 'test/preview/__mocks__/global_mocks'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), +})); + +jest.mock('react-oidc-context', () => ({ + ...jest.requireActual('react-oidc-context'), + useAuth: jest.fn(), +})); + +describe('Digital Twins', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('displays content of tabs', async () => { + await act(async () => { + render( + + + + + , + ); + }); + + expect( + screen.getByText('The Digital Twin as a Service'), + ).toBeInTheDocument(); + }); + + it('fetches subfolders with project id', async () => { + const gitlabInstance = mockGitlabInstance; + const dispatch = jest.fn(); + const setError = jest.fn(); + + const init = jest.spyOn(mockGitlabInstance, 'init'); + gitlabInstance.projectId = 1; + const getDTSubfolders = jest.spyOn(mockGitlabInstance, 'getDTSubfolders'); + + await functions.fetchSubfolders(gitlabInstance, dispatch, setError); + + expect(init).toHaveBeenCalled(); + expect(getDTSubfolders).toHaveBeenCalledWith(1); + expect(dispatch).toHaveBeenCalled(); + }); + + it('fetches subfolders without project id', async () => { + const gitlabInstance = mockGitlabInstance; + const dispatch = jest.fn(); + const setError = jest.fn(); + + const init = jest.spyOn(mockGitlabInstance, 'init'); + gitlabInstance.projectId = null; + const getDTSubfolders = jest.spyOn(mockGitlabInstance, 'getDTSubfolders'); + + await functions.fetchSubfolders(gitlabInstance, dispatch, setError); + + expect(init).toHaveBeenCalled(); + expect(getDTSubfolders).not.toHaveBeenCalled(); + expect(dispatch).toHaveBeenCalled(); + }); + + it('fetches subfolders with error', async () => { + const gitlabInstance = mockGitlabInstance; + const dispatch = jest.fn(); + const setError = jest.fn(); + + const init = jest.spyOn(mockGitlabInstance, 'init'); + gitlabInstance.projectId = 1; + const getDTSubfolders = jest + .spyOn(mockGitlabInstance, 'getDTSubfolders') + .mockRejectedValue(new Error('error')); + + await functions.fetchSubfolders(gitlabInstance, dispatch, setError); + + expect(init).toHaveBeenCalled(); + expect(getDTSubfolders).toHaveBeenCalledWith(1); + expect(dispatch).not.toHaveBeenCalled(); + expect(setError).toHaveBeenCalledWith('An error occurred'); + }); +}); diff --git a/client/test/preview/integration/route/digitaltwins/Snackbar.test.tsx b/client/test/preview/integration/route/digitaltwins/Snackbar.test.tsx new file mode 100644 index 000000000..d2092ad9d --- /dev/null +++ b/client/test/preview/integration/route/digitaltwins/Snackbar.test.tsx @@ -0,0 +1,65 @@ +import * as React from 'react'; +import { act, render, screen } from '@testing-library/react'; +import CustomSnackbar from 'preview/route/digitaltwins/Snackbar'; +import { Provider } from 'react-redux'; +import { + configureStore, + combineReducers, + getDefaultMiddleware, +} from '@reduxjs/toolkit'; +import snackbarReducer, { showSnackbar } from 'preview/store/snackbar.slice'; + +jest.useFakeTimers(); + +const store = configureStore({ + reducer: combineReducers({ + snackbar: snackbarReducer, + }), + middleware: getDefaultMiddleware({ + serializableCheck: false, + }), +}); + +describe('CustomSnackbar Integration Test', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders the Snackbar with the correct message', async () => { + store.dispatch( + showSnackbar({ + message: 'test message', + severity: 'success', + }), + ); + + render( + + + , + ); + + expect(screen.getByText('test message')).toBeInTheDocument(); + }); + + it('handles the close event', async () => { + store.dispatch( + showSnackbar({ + message: 'test message', + severity: 'success', + }), + ); + + render( + + + , + ); + + act(() => { + jest.advanceTimersByTime(6000); + }); + const state = store.getState(); + expect(state.snackbar.open).toBe(false); + }); +}); diff --git a/client/test/preview/integration/route/digitaltwins/execute/LogDialog.test.tsx b/client/test/preview/integration/route/digitaltwins/execute/LogDialog.test.tsx new file mode 100644 index 000000000..c1a8baefb --- /dev/null +++ b/client/test/preview/integration/route/digitaltwins/execute/LogDialog.test.tsx @@ -0,0 +1,93 @@ +import * as React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import LogDialog from 'preview/route/digitaltwins/execute/LogDialog'; +import { Provider } from 'react-redux'; +import { + combineReducers, + configureStore, + getDefaultMiddleware, +} from '@reduxjs/toolkit'; +import digitalTwinReducer, { + setDigitalTwin, + setJobLogs, +} from 'preview/store/digitalTwin.slice'; +import { mockDigitalTwin } from 'test/preview/__mocks__/global_mocks'; + +const store = configureStore({ + reducer: combineReducers({ + digitalTwin: digitalTwinReducer, + }), + middleware: getDefaultMiddleware({ + serializableCheck: false, + }), +}); + +describe('LogDialog', () => { + const assetName = 'mockedDTName'; + const setShowLog = jest.fn(); + + const renderLogDialog = () => { + render( + + + , + ); + }; + + beforeEach(() => { + store.dispatch( + setDigitalTwin({ + assetName: 'mockedDTName', + digitalTwin: mockDigitalTwin, + }), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders the LogDialog with logs available', () => { + store.dispatch( + setJobLogs({ + assetName, + jobLogs: [{ jobName: 'job', log: 'testLog' }], + }), + ); + + renderLogDialog(); + + expect(screen.getByText(/mockedDTName log/i)).toBeInTheDocument(); + expect(screen.getByText(/job/i)).toBeInTheDocument(); + expect(screen.getByText(/testLog/i)).toBeInTheDocument(); + }); + + it('renders the LogDialog with no logs available', () => { + store.dispatch( + setJobLogs({ + assetName, + jobLogs: [], + }), + ); + + renderLogDialog(); + + expect(screen.getByText(/No logs available/i)).toBeInTheDocument(); + }); + + it('handles button click', async () => { + store.dispatch( + setJobLogs({ + assetName, + jobLogs: [{ jobName: 'create', log: 'create log' }], + }), + ); + + renderLogDialog(); + + const closeButton = screen.getByRole('button', { name: /Close/i }); + fireEvent.click(closeButton); + + expect(setShowLog).toHaveBeenCalled(); + }); +}); diff --git a/client/test/preview/integration/route/digitaltwins/execute/PipelineChecks.test.tsx b/client/test/preview/integration/route/digitaltwins/execute/PipelineChecks.test.tsx new file mode 100644 index 000000000..7dbd68023 --- /dev/null +++ b/client/test/preview/integration/route/digitaltwins/execute/PipelineChecks.test.tsx @@ -0,0 +1,228 @@ +import { configureStore, getDefaultMiddleware } from '@reduxjs/toolkit'; +import * as PipelineChecks from 'preview/route/digitaltwins/execute/pipelineChecks'; +import * as PipelineUtils from 'preview/route/digitaltwins/execute/pipelineUtils'; +import digitalTwinReducer, { + setDigitalTwin, +} from 'preview/store/digitalTwin.slice'; +import snackbarSlice from 'preview/store/snackbar.slice'; +import { mockDigitalTwin } from 'test/preview/__mocks__/global_mocks'; + +const store = configureStore({ + reducer: { + digitalTwin: digitalTwinReducer, + snackbar: snackbarSlice, + }, + middleware: getDefaultMiddleware({ + serializableCheck: false, + }), +}); + +jest.useFakeTimers(); + +jest.mock('preview/route/digitaltwins/execute/pipelineUtils', () => ({ + fetchJobLogs: jest.fn(), + updatePipelineStateOnCompletion: jest.fn(), +})); + +describe('PipelineChecks', () => { + const digitalTwin = mockDigitalTwin; + + const setButtonText = jest.fn(); + const setLogButtonDisabled = jest.fn(); + const dispatch = jest.fn(); + const startTime = Date.now(); + const params = { setButtonText, digitalTwin, setLogButtonDisabled, dispatch }; + + Object.defineProperty(AbortSignal, 'timeout', { + value: jest.fn(), + writable: false, + }); + + beforeEach(() => { + store.dispatch(setDigitalTwin({ assetName: 'mockedDTName', digitalTwin })); + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + + it('handles timeout', () => { + PipelineChecks.handleTimeout( + digitalTwin.DTName, + jest.fn(), + jest.fn(), + store.dispatch, + ); + + const snackbarState = store.getState().snackbar; + + const expectedSnackbarState = { + open: true, + message: 'Execution timed out for MockedDTName', + severity: 'error', + }; + + expect(snackbarState).toEqual(expectedSnackbarState); + }); + + it('starts pipeline status check', async () => { + const checkParentPipelineStatus = jest + .spyOn(PipelineChecks, 'checkParentPipelineStatus') + .mockImplementation(() => Promise.resolve()); + + jest.spyOn(global.Date, 'now').mockReturnValue(startTime); + + await PipelineChecks.startPipelineStatusCheck(params); + + expect(checkParentPipelineStatus).toHaveBeenCalled(); + }); + + it('checks parent pipeline status and returns success', async () => { + const checkChildPipelineStatus = jest.spyOn( + PipelineChecks, + 'checkChildPipelineStatus', + ); + + jest + .spyOn(digitalTwin.gitlabInstance, 'getPipelineStatus') + .mockResolvedValue('success'); + await PipelineChecks.checkParentPipelineStatus({ + setButtonText, + digitalTwin, + setLogButtonDisabled, + dispatch: store.dispatch, + startTime, + }); + + expect(checkChildPipelineStatus).toHaveBeenCalled(); + }); + + it('checks parent pipeline status and returns failed', async () => { + const updatePipelineStateOnCompletion = jest.spyOn( + PipelineUtils, + 'updatePipelineStateOnCompletion', + ); + + jest + .spyOn(digitalTwin.gitlabInstance, 'getPipelineStatus') + .mockResolvedValue('failed'); + await PipelineChecks.checkParentPipelineStatus({ + setButtonText, + digitalTwin, + setLogButtonDisabled, + dispatch: store.dispatch, + startTime, + }); + + expect(updatePipelineStateOnCompletion).toHaveBeenCalled(); + }); + + it('checks parent pipeline status and returns timeout', async () => { + const handleTimeout = jest.spyOn(PipelineChecks, 'handleTimeout'); + + jest + .spyOn(digitalTwin.gitlabInstance, 'getPipelineStatus') + .mockResolvedValue('running'); + jest.spyOn(PipelineChecks, 'hasTimedOut').mockReturnValue(true); + await PipelineChecks.checkParentPipelineStatus({ + setButtonText, + digitalTwin, + setLogButtonDisabled, + dispatch: store.dispatch, + startTime, + }); + + jest.advanceTimersByTime(5000); + + expect(handleTimeout).toHaveBeenCalled(); + }); + + it('checks parent pipeline status and returns running', async () => { + const delay = jest.spyOn(PipelineChecks, 'delay'); + delay.mockImplementation(() => Promise.resolve()); + + jest + .spyOn(digitalTwin.gitlabInstance, 'getPipelineStatus') + .mockResolvedValue('running'); + jest + .spyOn(PipelineChecks, 'hasTimedOut') + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + + await PipelineChecks.checkParentPipelineStatus({ + setButtonText, + digitalTwin, + setLogButtonDisabled, + dispatch: store.dispatch, + startTime, + }); + + expect(delay).toHaveBeenCalled(); + }); + + it('handles pipeline completion with failed status', async () => { + await PipelineChecks.handlePipelineCompletion( + 1, + digitalTwin, + jest.fn(), + jest.fn(), + store.dispatch, + 'failed', + ); + + const snackbarState = store.getState().snackbar; + + const expectedSnackbarState = { + open: true, + message: 'Execution failed for MockedDTName', + severity: 'error', + }; + + expect(snackbarState).toEqual(expectedSnackbarState); + }); + + it('checks child pipeline status and returns timeout', async () => { + const completeParams = { + setButtonText: jest.fn(), + digitalTwin, + setLogButtonDisabled: jest.fn(), + dispatch: jest.fn(), + startTime: Date.now(), + }; + const handleTimeout = jest.spyOn(PipelineChecks, 'handleTimeout'); + + jest + .spyOn(digitalTwin.gitlabInstance, 'getPipelineStatus') + .mockResolvedValue('running'); + jest.spyOn(PipelineChecks, 'hasTimedOut').mockReturnValue(true); + + await PipelineChecks.checkChildPipelineStatus(completeParams); + + expect(handleTimeout).toHaveBeenCalled(); + }); + + it('checks child pipeline status and returns running', async () => { + const delay = jest.spyOn(PipelineChecks, 'delay'); + delay.mockImplementation(() => Promise.resolve()); + + const getPipelineStatusMock = jest.spyOn( + digitalTwin.gitlabInstance, + 'getPipelineStatus', + ); + getPipelineStatusMock + .mockResolvedValueOnce('running') + .mockResolvedValue('success'); + + await PipelineChecks.checkChildPipelineStatus({ + setButtonText, + digitalTwin, + setLogButtonDisabled, + dispatch, + startTime, + }); + + expect(getPipelineStatusMock).toHaveBeenCalled(); + getPipelineStatusMock.mockRestore(); + }); +}); diff --git a/client/test/preview/integration/route/digitaltwins/execute/PipelineHandler.test.tsx b/client/test/preview/integration/route/digitaltwins/execute/PipelineHandler.test.tsx new file mode 100644 index 000000000..f2f50deef --- /dev/null +++ b/client/test/preview/integration/route/digitaltwins/execute/PipelineHandler.test.tsx @@ -0,0 +1,88 @@ +import * as PipelineHandlers from 'preview/route/digitaltwins/execute/pipelineHandler'; +import { mockDigitalTwin } from 'test/preview/__mocks__/global_mocks'; +import { configureStore, getDefaultMiddleware } from '@reduxjs/toolkit'; +import digitalTwinReducer, { + setDigitalTwin, +} from 'preview/store/digitalTwin.slice'; +import snackbarSlice, { SnackbarState } from 'preview/store/snackbar.slice'; +import { formatName } from 'preview/util/gitlabDigitalTwin'; + +const store = configureStore({ + reducer: { + digitalTwin: digitalTwinReducer, + snackbar: snackbarSlice, + }, + middleware: getDefaultMiddleware({ + serializableCheck: false, + }), +}); + +describe('PipelineHandler Integration Tests', () => { + const digitalTwin = mockDigitalTwin; + + beforeEach(() => { + store.dispatch(setDigitalTwin({ assetName: 'mockedDTName', digitalTwin })); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('handles button click when button text is Stop', async () => { + await PipelineHandlers.handleButtonClick( + 'Start', + jest.fn(), + digitalTwin, + jest.fn(), + store.dispatch, + ); + + await PipelineHandlers.handleButtonClick( + 'Stop', + jest.fn(), + digitalTwin, + jest.fn(), + store.dispatch, + ); + + const snackbarState = store.getState().snackbar; + + const expectedSnackbarState = { + open: true, + message: 'Execution mockedStatus for MockedDTName', + severity: 'error', + }; + + expect(snackbarState).toEqual(expectedSnackbarState); + }); + + it('handles start when button text is Stop', async () => { + const setButtonText = jest.fn(); + const setLogButtonDisabled = jest.fn(); + + await PipelineHandlers.handleStart( + 'Stop', + setButtonText, + digitalTwin, + setLogButtonDisabled, + store.dispatch, + ); + + expect(setButtonText).toHaveBeenCalledWith('Start'); + }); + + it('handles stop and catches error', async () => { + const stopPipelinesMock = jest + .spyOn(PipelineHandlers, 'stopPipelines') + .mockRejectedValueOnce(new Error('error')); + + await PipelineHandlers.handleStop(digitalTwin, jest.fn(), store.dispatch); + + const snackbarState = store.getState().snackbar as SnackbarState; + expect(snackbarState.message).toBe( + `Execution stop failed for ${formatName(digitalTwin.DTName)}`, + ); + + stopPipelinesMock.mockRestore(); + }); +}); diff --git a/client/test/preview/integration/route/digitaltwins/execute/PipelineUtils.test.tsx b/client/test/preview/integration/route/digitaltwins/execute/PipelineUtils.test.tsx new file mode 100644 index 000000000..ee32aa1f4 --- /dev/null +++ b/client/test/preview/integration/route/digitaltwins/execute/PipelineUtils.test.tsx @@ -0,0 +1,94 @@ +import { JobSchema } from '@gitbeaker/rest'; +import { configureStore, getDefaultMiddleware } from '@reduxjs/toolkit'; +import * as PipelineUtils from 'preview/route/digitaltwins/execute/pipelineUtils'; +import digitalTwinReducer, { + setDigitalTwin, +} from 'preview/store/digitalTwin.slice'; +import snackbarSlice from 'preview/store/snackbar.slice'; +import { mockDigitalTwin } from 'test/preview/__mocks__/global_mocks'; + +const store = configureStore({ + reducer: { + digitalTwin: digitalTwinReducer, + snackbar: snackbarSlice, + }, + middleware: getDefaultMiddleware({ + serializableCheck: false, + }), +}); + +describe('PipelineUtils', () => { + const digitalTwin = mockDigitalTwin; + digitalTwin.lastExecutionStatus = 'success'; + + const { gitlabInstance } = digitalTwin; + + beforeEach(() => { + store.dispatch(setDigitalTwin({ assetName: 'mockedDTName', digitalTwin })); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('starts pipeline and handle success', async () => { + await PipelineUtils.startPipeline(digitalTwin, store.dispatch, jest.fn()); + + const snackbarState = store.getState().snackbar; + + const expectedSnackbarState = { + open: true, + message: + 'Execution started successfully for MockedDTName. Wait until completion for the logs...', + severity: 'success', + }; + + expect(snackbarState).toEqual(expectedSnackbarState); + }); + + it('updates pipeline state on completion', async () => { + await PipelineUtils.updatePipelineStateOnCompletion( + digitalTwin, + [{ jobName: 'job1', log: 'log1' }], + jest.fn(), + jest.fn(), + store.dispatch, + ); + + const state = store.getState().digitalTwin; + expect(state.mockedDTName.jobLogs).toEqual([ + { jobName: 'job1', log: 'log1' }, + ]); + expect(state.mockedDTName.pipelineCompleted).toBe(true); + expect(state.mockedDTName.pipelineLoading).toBe(false); + }); + + it('fetches job logs', async () => { + const mockGetPipelineJobs = jest + .spyOn(gitlabInstance, 'getPipelineJobs') + .mockResolvedValue([ + { + id: 1, + name: 'job1', + status: 'success', + stage: 'build', + } as unknown as JobSchema, + ]); + + const mockGetJobTrace = jest + .spyOn(gitlabInstance, 'getJobTrace') + .mockResolvedValue('log1'); + + const result = await PipelineUtils.fetchJobLogs(gitlabInstance, 1); + + expect(mockGetPipelineJobs).toHaveBeenCalledWith( + gitlabInstance.projectId, + 1, + ); + expect(mockGetJobTrace).toHaveBeenCalledWith(gitlabInstance.projectId, 1); + expect(result).toEqual([{ jobName: 'job1', log: 'log1' }]); + + mockGetPipelineJobs.mockRestore(); + mockGetJobTrace.mockRestore(); + }); +}); diff --git a/client/test/preview/unit/components/asset/AssetBoard.test.tsx b/client/test/preview/unit/components/asset/AssetBoard.test.tsx new file mode 100644 index 000000000..6e8f89bee --- /dev/null +++ b/client/test/preview/unit/components/asset/AssetBoard.test.tsx @@ -0,0 +1,60 @@ +import * as React from 'react'; +import { render, screen } from '@testing-library/react'; +import { Provider, useDispatch, useSelector } from 'react-redux'; +import AssetBoard from 'preview/components/asset/AssetBoard'; +import store from 'store/store'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), + useDispatch: jest.fn(), +})); + +jest.mock('preview/components/asset/AssetCard', () => ({ + default: () =>
Asset Card
, +})); + +jest.mock('preview/store/assets.slice', () => ({ + ...jest.requireActual('preview/store/assets.slice'), +})); + +describe('AssetBoard', () => { + const mockDispatch = jest.fn(); + + const renderAssetBoard = (error: null | string) => + render( + + + , + ); + + beforeEach(() => { + (useDispatch as jest.Mock).mockReturnValue(mockDispatch); + + const mockAssets = [ + { name: 'Asset 1', description: 'Test Asset', path: 'path1' }, + ]; + + (useSelector as jest.Mock).mockImplementation((selector) => + selector({ + assets: { items: mockAssets }, + }), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders AssetBoard with assets', () => { + renderAssetBoard(null); + + expect(screen.getByText('Asset Card')).toBeInTheDocument(); + }); + + it('renders error message when error is present', () => { + renderAssetBoard('An error occurred'); + + expect(screen.getByText('An error occurred')).toBeInTheDocument(); + }); +}); diff --git a/client/test/preview/unit/components/asset/AssetCard.test.tsx b/client/test/preview/unit/components/asset/AssetCard.test.tsx new file mode 100644 index 000000000..e80b46b39 --- /dev/null +++ b/client/test/preview/unit/components/asset/AssetCard.test.tsx @@ -0,0 +1,54 @@ +import { render, screen } from '@testing-library/react'; +import AssetCardExecute from 'preview/components/asset/AssetCard'; +import * as React from 'react'; +import { Provider, useSelector } from 'react-redux'; +import store from 'store/store'; +import { formatName } from 'preview/util/gitlabDigitalTwin'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + +jest.mock('preview/route/digitaltwins/Snackbar', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('preview/route/digitaltwins/execute/LogDialog', () => ({ + __esModule: true, + default: () =>
, +})); + +describe('AssetCardExecute', () => { + const asset = { + name: 'asset', + description: 'Asset description', + path: 'path', + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders AssetCardExecute with digital twin description', () => { + (useSelector as jest.Mock).mockImplementation((selector) => + selector({ + digitalTwin: { + [asset.name]: { description: 'Digital Twin description' }, + }, + }), + ); + + render( + + + , + ); + + expect(screen.getByText(formatName(asset.name))).toBeInTheDocument(); + expect(screen.getByText('Digital Twin description')).toBeInTheDocument(); + expect(screen.getByTestId('custom-snackbar')).toBeInTheDocument(); + expect(screen.getByTestId('log-dialog')).toBeInTheDocument(); + }); +}); diff --git a/client/test/preview/unit/components/asset/LogButton.test.tsx b/client/test/preview/unit/components/asset/LogButton.test.tsx new file mode 100644 index 000000000..affccdba4 --- /dev/null +++ b/client/test/preview/unit/components/asset/LogButton.test.tsx @@ -0,0 +1,66 @@ +import { screen, render, fireEvent } from '@testing-library/react'; +import LogButton from 'preview/components/asset/LogButton'; +import * as React from 'react'; +import { Provider } from 'react-redux'; +import store from 'store/store'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), +})); + +describe('LogButton', () => { + const renderLogButton = ( + setShowLog: jest.Mock = jest.fn(), + logButtonDisabled = false, + ) => + render( + + + , + ); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders the Log button', () => { + renderLogButton(); + expect(screen.getByRole('button', { name: /Log/i })).toBeInTheDocument(); + }); + + it('handles button click when enabled', () => { + renderLogButton(); + + const logButton = screen.getByRole('button', { name: /Log/i }); + fireEvent.click(logButton); + + expect(logButton).toBeEnabled(); + }); + + it('does not handle button click when disabled', () => { + renderLogButton(jest.fn(), true); + + const logButton = screen.getByRole('button', { name: /Log/i }); + fireEvent.click(logButton); + }); + + it('toggles setShowLog value correctly', () => { + let toggleValue = false; + const mockSetShowLog = jest.fn((callback) => { + toggleValue = callback(toggleValue); + }); + + renderLogButton(mockSetShowLog); + + const logButton = screen.getByRole('button', { name: /Log/i }); + + fireEvent.click(logButton); + expect(toggleValue).toBe(true); + + fireEvent.click(logButton); + expect(toggleValue).toBe(false); + }); +}); diff --git a/client/test/preview/unit/components/asset/StartStopButton.test.tsx b/client/test/preview/unit/components/asset/StartStopButton.test.tsx new file mode 100644 index 000000000..46e93acac --- /dev/null +++ b/client/test/preview/unit/components/asset/StartStopButton.test.tsx @@ -0,0 +1,67 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import StartStopButton from 'preview/components/asset/StartStopButton'; +import * as React from 'react'; +import { Provider } from 'react-redux'; +import store from 'store/store'; +import { handleButtonClick } from 'preview/route/digitaltwins/execute/pipelineHandler'; +import * as redux from 'react-redux'; + +jest.mock('preview/route/digitaltwins/execute/pipelineHandler', () => ({ + handleButtonClick: jest.fn(), +})); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + +const renderStartStopButton = ( + assetName: string, + setLogButtonDisabled: jest.Mock, +) => + render( + + + , + ); + +describe('StartStopButton', () => { + const assetName = 'testAssetName'; + const setLogButtonDisabled = jest.fn(); + + beforeEach(() => { + renderStartStopButton(assetName, setLogButtonDisabled); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders only the Start button', () => { + expect(screen.getByRole('button', { name: /Start/i })).toBeInTheDocument(); + expect(screen.queryByTestId('circular-progress')).not.toBeInTheDocument(); + }); + + it('handles button click', () => { + const startButton = screen.getByRole('button', { + name: /Start/i, + }); + fireEvent.click(startButton); + + expect(handleButtonClick).toHaveBeenCalled(); + }); + + it('renders the circular progress when pipelineLoading is true', () => { + (redux.useSelector as jest.Mock).mockReturnValue({ + DTName: assetName, + pipelineLoading: true, + }); + + renderStartStopButton(assetName, setLogButtonDisabled); + + expect(screen.queryByTestId('circular-progress')).toBeInTheDocument(); + }); +}); diff --git a/client/test/preview/unit/jest.setup.ts b/client/test/preview/unit/jest.setup.ts new file mode 100644 index 000000000..3f38356b7 --- /dev/null +++ b/client/test/preview/unit/jest.setup.ts @@ -0,0 +1,9 @@ +import '@testing-library/jest-dom'; +import 'test/preview/__mocks__/global_mocks'; +// import 'test/preview/__mocks__/unit/page_mocks'; +import 'test/preview/__mocks__/unit/component_mocks'; +// import 'test/preview/__mocks__/unit/module_mocks'; + +beforeEach(() => { + jest.resetAllMocks(); +}); diff --git a/client/test/preview/unit/routes/digitaltwins/DigitalTwinsPreview.test.tsx b/client/test/preview/unit/routes/digitaltwins/DigitalTwinsPreview.test.tsx new file mode 100644 index 000000000..97e1ce7d7 --- /dev/null +++ b/client/test/preview/unit/routes/digitaltwins/DigitalTwinsPreview.test.tsx @@ -0,0 +1,98 @@ +import * as React from 'react'; +import DigitalTwinsPreview, * as functions from 'preview/route/digitaltwins/DigitalTwinsPreview'; +import store from 'store/store'; +import { act, render, screen } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import { + mockDigitalTwin, + mockGitlabInstance, +} from 'test/preview/__mocks__/global_mocks'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), +})); + +jest.mock('react-oidc-context', () => ({ + ...jest.requireActual('react-oidc-context'), + useAuth: jest.fn(), +})); + +jest.mock('preview/util/gitlab', () => ({ + default: jest.fn().mockImplementation(() => mockGitlabInstance), +})); +jest.mock('preview/util/gitlabDigitalTwin', () => ({ + DigitalTwin: jest.fn().mockImplementation(() => mockDigitalTwin), +})); + +describe('Digital Twins', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('displays content of tabs', async () => { + await act(async () => { + render( + + + + + , + ); + }); + + const tabComponent = screen.getByTestId('tab-component'); + expect(tabComponent).toBeInTheDocument(); + }); + + it('fetches subfolders with project id', async () => { + const gitlabInstance = mockGitlabInstance; + const dispatch = jest.fn(); + const setError = jest.fn(); + + const init = jest.spyOn(mockGitlabInstance, 'init'); + gitlabInstance.projectId = 1; + const getDTSubfolders = jest.spyOn(mockGitlabInstance, 'getDTSubfolders'); + + await functions.fetchSubfolders(gitlabInstance, dispatch, setError); + + expect(init).toHaveBeenCalled(); + expect(getDTSubfolders).toHaveBeenCalledWith(1); + expect(dispatch).toHaveBeenCalled(); + }); + + it('fetches subfolders without project id', async () => { + const gitlabInstance = mockGitlabInstance; + const dispatch = jest.fn(); + const setError = jest.fn(); + + const init = jest.spyOn(mockGitlabInstance, 'init'); + gitlabInstance.projectId = null; + const getDTSubfolders = jest.spyOn(mockGitlabInstance, 'getDTSubfolders'); + + await functions.fetchSubfolders(gitlabInstance, dispatch, setError); + + expect(init).toHaveBeenCalled(); + expect(getDTSubfolders).not.toHaveBeenCalled(); + expect(dispatch).toHaveBeenCalled(); + }); + + it('fetches subfolders with error', async () => { + const gitlabInstance = mockGitlabInstance; + const dispatch = jest.fn(); + const setError = jest.fn(); + + const init = jest.spyOn(mockGitlabInstance, 'init'); + gitlabInstance.projectId = 1; + const getDTSubfolders = jest + .spyOn(mockGitlabInstance, 'getDTSubfolders') + .mockRejectedValue(new Error('error')); + + await functions.fetchSubfolders(gitlabInstance, dispatch, setError); + + expect(init).toHaveBeenCalled(); + expect(getDTSubfolders).toHaveBeenCalledWith(1); + expect(dispatch).not.toHaveBeenCalled(); + expect(setError).toHaveBeenCalledWith('An error occurred'); + }); +}); diff --git a/client/test/preview/unit/routes/digitaltwins/Snackbar.test.tsx b/client/test/preview/unit/routes/digitaltwins/Snackbar.test.tsx new file mode 100644 index 000000000..d17cd56aa --- /dev/null +++ b/client/test/preview/unit/routes/digitaltwins/Snackbar.test.tsx @@ -0,0 +1,81 @@ +import * as React from 'react'; +import { act, render, screen } from '@testing-library/react'; +import CustomSnackbar from 'preview/route/digitaltwins/Snackbar'; +import { Provider, useSelector, useDispatch } from 'react-redux'; +import store from 'store/store'; +import { hideSnackbar } from 'preview/store/snackbar.slice'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), + useDispatch: jest.fn(), +})); + +jest.useFakeTimers(); + +describe('CustomSnackbar', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders the Snackbar with the correct message', () => { + (useSelector as jest.Mock).mockReturnValue({ + open: true, + message: 'test message', + severity: 'success', + }); + + render( + + + , + ); + + expect(screen.getByText('test message')).toBeInTheDocument(); + }); + + it('handles the close event', () => { + (useSelector as jest.Mock).mockReturnValue({ + open: true, + message: 'test message', + severity: 'success', + }); + + const mockDispatch = jest.fn(); + (useDispatch as jest.Mock).mockReturnValue(mockDispatch); + + render( + + + , + ); + + act(() => { + jest.advanceTimersByTime(6000); + }); + + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith(hideSnackbar()); + }); + + it('calls useSelector with correct function', () => { + const mockSnackbarState = { + open: true, + message: 'test message', + severity: 'success', + }; + (useSelector as jest.Mock).mockReturnValue(mockSnackbarState); + + render( + + + , + ); + + expect(useSelector).toHaveBeenCalledWith(expect.any(Function)); + + const selectState = (useSelector as jest.Mock).mock.calls[0][0]; + const result = selectState({ snackbar: mockSnackbarState }); + expect(result).toEqual(mockSnackbarState); + }); +}); diff --git a/client/test/preview/unit/routes/digitaltwins/execute/LogDialog.test.tsx b/client/test/preview/unit/routes/digitaltwins/execute/LogDialog.test.tsx new file mode 100644 index 000000000..c0d0bfa7a --- /dev/null +++ b/client/test/preview/unit/routes/digitaltwins/execute/LogDialog.test.tsx @@ -0,0 +1,61 @@ +import * as React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import LogDialog from 'preview/route/digitaltwins/execute/LogDialog'; +import { Provider, useSelector } from 'react-redux'; +import store from 'store/store'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + +describe('LogDialog', () => { + const name = 'testName'; + const setShowLog = jest.fn(); + + const renderLogDialog = () => + render( + + , + , + ); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders the LogDialog with logs available', () => { + (useSelector as jest.Mock).mockReturnValue({ + jobLogs: [{ jobName: 'job', log: 'testLog' }], + }); + + renderLogDialog(); + + expect(screen.getByText(/TestName log/i)).toBeInTheDocument(); + expect(screen.getByText(/job/i)).toBeInTheDocument(); + expect(screen.getByText(/testLog/i)).toBeInTheDocument(); + }); + + it('renders the LogDialog with no logs available', () => { + (useSelector as jest.Mock).mockReturnValue({ + jobLogs: [], + }); + + renderLogDialog(); + + expect(screen.getByText(/No logs available/i)).toBeInTheDocument(); + }); + + it('handles button click', async () => { + (useSelector as jest.Mock).mockReturnValue({ + jobLogs: [{ jobName: 'create', log: 'create log' }], + }); + + renderLogDialog(); + + const closeButton = screen.getByRole('button', { name: /Close/i }); + fireEvent.click(closeButton); + + expect(setShowLog).toHaveBeenCalled(); + }); +}); diff --git a/client/test/preview/unit/routes/digitaltwins/execute/PipelineChecks.test.ts b/client/test/preview/unit/routes/digitaltwins/execute/PipelineChecks.test.ts new file mode 100644 index 000000000..bdad925ec --- /dev/null +++ b/client/test/preview/unit/routes/digitaltwins/execute/PipelineChecks.test.ts @@ -0,0 +1,207 @@ +import * as PipelineChecks from 'preview/route/digitaltwins/execute/pipelineChecks'; +import * as PipelineUtils from 'preview/route/digitaltwins/execute/pipelineUtils'; +import { mockDigitalTwin } from 'test/preview/__mocks__/global_mocks'; + +jest.mock('preview/util/gitlabDigitalTwin', () => ({ + DigitalTwin: jest.fn().mockImplementation(() => mockDigitalTwin), + formatName: jest.fn(), +})); + +jest.mock('preview/route/digitaltwins/execute/pipelineUtils', () => ({ + fetchJobLogs: jest.fn(), + updatePipelineStateOnCompletion: jest.fn(), +})); + +jest.useFakeTimers(); + +describe('PipelineChecks', () => { + const DTName = 'testName'; + const setButtonText = jest.fn(); + const setLogButtonDisabled = jest.fn(); + const dispatch = jest.fn(); + const startTime = Date.now(); + const digitalTwin = mockDigitalTwin; + const params = { setButtonText, digitalTwin, setLogButtonDisabled, dispatch }; + const pipelineId = 1; + + Object.defineProperty(AbortSignal, 'timeout', { + value: jest.fn(), + writable: false, + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + + it('handles timeout', () => { + PipelineChecks.handleTimeout( + DTName, + setButtonText, + setLogButtonDisabled, + dispatch, + ); + + expect(setButtonText).toHaveBeenCalled(); + expect(setLogButtonDisabled).toHaveBeenCalledWith(false); + }); + + it('starts pipeline status check', async () => { + const checkParentPipelineStatus = jest + .spyOn(PipelineChecks, 'checkParentPipelineStatus') + .mockImplementation(() => Promise.resolve()); + + jest.spyOn(global.Date, 'now').mockReturnValue(startTime); + + await PipelineChecks.startPipelineStatusCheck(params); + + expect(checkParentPipelineStatus).toHaveBeenCalled(); + }); + + it('checks parent pipeline status and returns success', async () => { + const checkChildPipelineStatus = jest.spyOn( + PipelineChecks, + 'checkChildPipelineStatus', + ); + + jest + .spyOn(digitalTwin.gitlabInstance, 'getPipelineStatus') + .mockResolvedValue('success'); + await PipelineChecks.checkParentPipelineStatus({ + setButtonText, + digitalTwin, + setLogButtonDisabled, + dispatch, + startTime, + }); + + expect(checkChildPipelineStatus).toHaveBeenCalled(); + }); + + it('checks parent pipeline status and returns failed', async () => { + const updatePipelineStateOnCompletion = jest.spyOn( + PipelineUtils, + 'updatePipelineStateOnCompletion', + ); + + jest + .spyOn(digitalTwin.gitlabInstance, 'getPipelineStatus') + .mockResolvedValue('failed'); + await PipelineChecks.checkParentPipelineStatus({ + setButtonText, + digitalTwin, + setLogButtonDisabled, + dispatch, + startTime, + }); + + expect(updatePipelineStateOnCompletion).toHaveBeenCalled(); + }); + + it('checks parent pipeline status and returns timeout', async () => { + const handleTimeout = jest.spyOn(PipelineChecks, 'handleTimeout'); + + jest + .spyOn(digitalTwin.gitlabInstance, 'getPipelineStatus') + .mockResolvedValue('running'); + jest.spyOn(PipelineChecks, 'hasTimedOut').mockReturnValue(true); + await PipelineChecks.checkParentPipelineStatus({ + setButtonText, + digitalTwin, + setLogButtonDisabled, + dispatch, + startTime, + }); + + jest.advanceTimersByTime(5000); + + expect(handleTimeout).toHaveBeenCalled(); + }); + + it('checks parent pipeline status and returns running', async () => { + const delay = jest.spyOn(PipelineChecks, 'delay'); + delay.mockImplementation(() => Promise.resolve()); + + jest + .spyOn(digitalTwin.gitlabInstance, 'getPipelineStatus') + .mockResolvedValue('running'); + jest + .spyOn(PipelineChecks, 'hasTimedOut') + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + + await PipelineChecks.checkParentPipelineStatus({ + setButtonText, + digitalTwin, + setLogButtonDisabled, + dispatch, + startTime, + }); + + expect(delay).toHaveBeenCalled(); + }); + + it('handles pipeline completion with failed status', async () => { + const fetchJobLogs = jest.spyOn(PipelineUtils, 'fetchJobLogs'); + const updatePipelineStateOnCompletion = jest.spyOn( + PipelineUtils, + 'updatePipelineStateOnCompletion', + ); + await PipelineChecks.handlePipelineCompletion( + pipelineId, + digitalTwin, + setButtonText, + setLogButtonDisabled, + dispatch, + 'failed', + ); + + expect(fetchJobLogs).toHaveBeenCalled(); + expect(updatePipelineStateOnCompletion).toHaveBeenCalled(); + expect(dispatch).toHaveBeenCalledTimes(1); + }); + + it('checks child pipeline status and returns timeout', async () => { + const completeParams = { + setButtonText: jest.fn(), + digitalTwin, + setLogButtonDisabled: jest.fn(), + dispatch: jest.fn(), + startTime: Date.now(), + }; + const handleTimeout = jest.spyOn(PipelineChecks, 'handleTimeout'); + + jest + .spyOn(digitalTwin.gitlabInstance, 'getPipelineStatus') + .mockResolvedValue('running'); + jest.spyOn(PipelineChecks, 'hasTimedOut').mockReturnValue(true); + + await PipelineChecks.checkChildPipelineStatus(completeParams); + + expect(handleTimeout).toHaveBeenCalled(); + }); + + it('checks child pipeline status and returns running', async () => { + const delay = jest.spyOn(PipelineChecks, 'delay'); + delay.mockImplementation(() => Promise.resolve()); + + const getPipelineStatusMock = jest.spyOn( + digitalTwin.gitlabInstance, + 'getPipelineStatus', + ); + getPipelineStatusMock + .mockResolvedValueOnce('running') + .mockResolvedValue('success'); + + await PipelineChecks.checkChildPipelineStatus({ + setButtonText, + digitalTwin, + setLogButtonDisabled, + dispatch, + startTime, + }); + + expect(getPipelineStatusMock).toHaveBeenCalled(); + getPipelineStatusMock.mockRestore(); + }); +}); diff --git a/client/test/preview/unit/routes/digitaltwins/execute/PipelineHandler.test.ts b/client/test/preview/unit/routes/digitaltwins/execute/PipelineHandler.test.ts new file mode 100644 index 000000000..d4c645986 --- /dev/null +++ b/client/test/preview/unit/routes/digitaltwins/execute/PipelineHandler.test.ts @@ -0,0 +1,107 @@ +import * as PipelineHandlers from 'preview/route/digitaltwins/execute/pipelineHandler'; +import * as PipelineUtils from 'preview/route/digitaltwins/execute/pipelineUtils'; +import * as PipelineChecks from 'preview/route/digitaltwins/execute/pipelineChecks'; +import { mockDigitalTwin } from 'test/preview/__mocks__/global_mocks'; + +describe('PipelineHandler', () => { + const setButtonText = jest.fn(); + const digitalTwin = mockDigitalTwin; + const setLogButtonDisabled = jest.fn(); + const dispatch = jest.fn(); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('handles button click when button text is Start', async () => { + const handleStart = jest.spyOn(PipelineHandlers, 'handleStart'); + await PipelineHandlers.handleButtonClick( + 'Start', + setButtonText, + digitalTwin, + setLogButtonDisabled, + dispatch, + ); + + expect(handleStart).toHaveBeenCalled(); + + handleStart.mockRestore(); + }); + + it('handles button click when button text is Stop', async () => { + const handleStop = jest.spyOn(PipelineHandlers, 'handleStop'); + await PipelineHandlers.handleButtonClick( + 'Stop', + setButtonText, + digitalTwin, + setLogButtonDisabled, + dispatch, + ); + + expect(handleStop).toHaveBeenCalled(); + + handleStop.mockRestore(); + }); + + it('handles start when button text is Start', async () => { + const updatePipelineState = jest.spyOn( + PipelineUtils, + 'updatePipelineState', + ); + const startPipeline = jest.spyOn(PipelineUtils, 'startPipeline'); + const startPipelineStatusCheck = jest.spyOn( + PipelineChecks, + 'startPipelineStatusCheck', + ); + + await PipelineHandlers.handleStart( + 'Start', + setButtonText, + digitalTwin, + setLogButtonDisabled, + dispatch, + ); + + expect(updatePipelineState).toHaveBeenCalledWith(digitalTwin, dispatch); + expect(startPipeline).toHaveBeenCalledWith( + digitalTwin, + dispatch, + setLogButtonDisabled, + ); + expect(startPipelineStatusCheck).toHaveBeenCalled(); + + updatePipelineState.mockRestore(); + startPipeline.mockRestore(); + startPipelineStatusCheck.mockRestore(); + }); + + it('handles start when button text is Stop', async () => { + await PipelineHandlers.handleStart( + 'Stop', + setButtonText, + digitalTwin, + setLogButtonDisabled, + dispatch, + ); + + expect(setButtonText).toHaveBeenCalledWith('Start'); + }); + + it('handles stop and catches error', async () => { + const updatePipelineStateOnStop = jest.spyOn( + PipelineUtils, + 'updatePipelineStateOnStop', + ); + + const stopPipelines = jest + .spyOn(PipelineHandlers, 'stopPipelines') + .mockRejectedValueOnce(new Error('error')); + await PipelineHandlers.handleStop(digitalTwin, setButtonText, dispatch); + + expect(dispatch).toHaveBeenCalled(); + expect(updatePipelineStateOnStop).toHaveBeenCalled(); + + updatePipelineStateOnStop.mockRestore(); + stopPipelines.mockRestore(); + }); +}); diff --git a/client/test/preview/unit/routes/digitaltwins/execute/PipelineUtils.test.ts b/client/test/preview/unit/routes/digitaltwins/execute/PipelineUtils.test.ts new file mode 100644 index 000000000..fa3175f06 --- /dev/null +++ b/client/test/preview/unit/routes/digitaltwins/execute/PipelineUtils.test.ts @@ -0,0 +1,107 @@ +import { JobSchema } from '@gitbeaker/rest'; +import { + fetchJobLogs, + startPipeline, + updatePipelineStateOnCompletion, +} from 'preview/route/digitaltwins/execute/pipelineUtils'; +import { mockDigitalTwin } from 'test/preview/__mocks__/global_mocks'; + +describe('PipelineUtils', () => { + const digitalTwin = mockDigitalTwin; + const dispatch = jest.fn(); + const setLogButtonDisabled = jest.fn(); + const setButtonText = jest.fn(); + const { gitlabInstance } = digitalTwin; + const pipelineId = 1; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('starts pipeline and handles success', async () => { + const execute = jest.spyOn(digitalTwin, 'execute'); + digitalTwin.lastExecutionStatus = 'success'; + + await startPipeline(digitalTwin, dispatch, setLogButtonDisabled); + + expect(execute).toHaveBeenCalled(); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'snackbar/showSnackbar', + payload: { + message: expect.stringContaining('Execution started successfully'), + severity: 'success', + }, + }), + ); + expect(setLogButtonDisabled).toHaveBeenCalledWith(true); + + execute.mockRestore(); + }); + + it('starts pipeline and handles failed', async () => { + const execute = jest.spyOn(digitalTwin, 'execute'); + digitalTwin.lastExecutionStatus = 'failed'; + + await startPipeline(digitalTwin, dispatch, setLogButtonDisabled); + + expect(execute).toHaveBeenCalled(); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'snackbar/showSnackbar', + payload: { + message: expect.stringContaining('Execution failed'), + severity: 'error', + }, + }), + ); + expect(setLogButtonDisabled).toHaveBeenCalledWith(true); + + execute.mockRestore(); + }); + + it('updates pipeline state on completion', async () => { + await updatePipelineStateOnCompletion( + digitalTwin, + [{ jobName: 'job1', log: 'log1' }], + setButtonText, + setLogButtonDisabled, + dispatch, + ); + + expect(dispatch).toHaveBeenCalledTimes(3); + expect(setButtonText).toHaveBeenCalledWith('Start'); + expect(setLogButtonDisabled).toHaveBeenCalledWith(false); + }); + + it('fetches job logs', async () => { + const mockGetPipelineJobs = jest + .spyOn(gitlabInstance, 'getPipelineJobs') + .mockResolvedValue([ + { + id: 1, + name: 'job1', + status: 'success', + stage: 'build', + } as unknown as JobSchema, + ]); + + const mockGetJobTrace = jest + .spyOn(gitlabInstance, 'getJobTrace') + .mockResolvedValue('log1'); + + const result = await fetchJobLogs(gitlabInstance, pipelineId); + + expect(mockGetPipelineJobs).toHaveBeenCalledWith( + gitlabInstance.projectId, + pipelineId, + ); + expect(mockGetJobTrace).toHaveBeenCalledWith(gitlabInstance.projectId, 1); + expect(result).toEqual([{ jobName: 'job1', log: 'log1' }]); + + mockGetPipelineJobs.mockRestore(); + mockGetJobTrace.mockRestore(); + }); +}); diff --git a/client/test/preview/unit/unit.testUtil.tsx b/client/test/preview/unit/unit.testUtil.tsx new file mode 100644 index 000000000..d94af6077 --- /dev/null +++ b/client/test/preview/unit/unit.testUtil.tsx @@ -0,0 +1,15 @@ +import { act, render } from '@testing-library/react'; + +function InitRouteTests(component: React.ReactElement) { + beforeEach(async () => { + await act(async () => { + render(component); + }); + }); + + it('renders', () => { + expect(true); + }); +} + +export default InitRouteTests; diff --git a/client/test/preview/unit/util/Store.test.ts b/client/test/preview/unit/util/Store.test.ts new file mode 100644 index 000000000..5038a77e4 --- /dev/null +++ b/client/test/preview/unit/util/Store.test.ts @@ -0,0 +1,143 @@ +import assetsSlice, { setAssets } from 'preview/store/assets.slice'; +import digitalTwinReducer, { + setDigitalTwin, + setJobLogs, + setPipelineCompleted, + setPipelineLoading, +} from 'preview/store/digitalTwin.slice'; +import DigitalTwin from 'preview/util/gitlabDigitalTwin'; +import GitlabInstance from 'preview/util/gitlab'; +import { JobLog } from 'preview/components/asset/StartStopButton'; +import { Asset } from 'preview/components/asset/Asset'; +import snackbarSlice, { + hideSnackbar, + showSnackbar, +} from 'preview/store/snackbar.slice'; +import { AlertColor } from '@mui/material'; + +describe('reducers', () => { + let initialState: { + assets: { + items: Asset[]; + }; + digitalTwin: { + [key: string]: DigitalTwin; + }; + snackbar: { + open: boolean; + message: string; + severity: AlertColor; + }; + }; + + beforeEach(() => { + initialState = { + assets: { items: [] }, + digitalTwin: {}, + snackbar: { + open: false, + message: '', + severity: 'info', + }, + }; + }); + + describe('assets reducer', () => { + it('should handle setAssets', () => { + const asset1 = { + name: 'asset1', + description: 'description', + path: 'path', + }; + + const newState = assetsSlice(initialState.assets, setAssets([asset1])); + + expect(newState.items).toEqual([asset1]); + }); + }); + + describe('digitalTwin reducer', () => { + it('digitalTwinReducer should return the initial digitalTwin state when an unknown action type is passed with an undefined state', () => { + expect(digitalTwinReducer(undefined, { type: 'unknown' })).toEqual( + initialState.digitalTwin, + ); + }); + + it('should handle setDigitalTwin', () => { + const digitalTwin = new DigitalTwin( + 'asset1', + new GitlabInstance('user1', 'authority', 'token1'), + ); + const newState = digitalTwinReducer( + initialState.digitalTwin, + setDigitalTwin({ assetName: 'asset1', digitalTwin }), + ); + expect(newState.asset1).toEqual(digitalTwin); + }); + + it('should handle setJobLogs', () => { + const jobLogs: JobLog[] = [{ jobName: 'job1', log: 'log' }]; + const digitalTwin = new DigitalTwin( + 'asset1', + new GitlabInstance('user1', 'authority', 'token1'), + ); + digitalTwin.jobLogs = jobLogs; + initialState.digitalTwin.asset1 = digitalTwin; + const newState = digitalTwinReducer( + initialState.digitalTwin, + setJobLogs({ assetName: 'asset1', jobLogs }), + ); + expect(newState.asset1.jobLogs).toEqual(jobLogs); + }); + + it('should handle setPipelineCompleted', () => { + const digitalTwin = new DigitalTwin( + 'asset1', + new GitlabInstance('user1', 'authority', 'token1'), + ); + initialState.digitalTwin.asset1 = digitalTwin; + const newState = digitalTwinReducer( + initialState.digitalTwin, + setPipelineCompleted({ assetName: 'asset1', pipelineCompleted: true }), + ); + expect(newState.asset1.pipelineCompleted).toBe(true); + }); + + it('should handle setPipelineLoading', () => { + const digitalTwin = new DigitalTwin( + 'asset1', + new GitlabInstance('user1', 'authority', 'token1'), + ); + initialState.digitalTwin.asset1 = digitalTwin; + const newState = digitalTwinReducer( + initialState.digitalTwin, + setPipelineLoading({ assetName: 'asset1', pipelineLoading: true }), + ); + expect(newState.asset1.pipelineLoading).toBe(true); + }); + }); + + describe('snackbar reducer', () => { + it('should handle showSnackbar', () => { + const message = 'message'; + const severity = 'error'; + const newState = snackbarSlice( + initialState.snackbar, + showSnackbar({ message, severity }), + ); + expect(newState.open).toBe(true); + expect(newState.message).toBe(message); + expect(newState.severity).toBe(severity); + }); + + it('should handle hideSnackbar', () => { + initialState.snackbar.open = true; + initialState.snackbar.message = 'message'; + initialState.snackbar.severity = 'error'; + const newState = snackbarSlice(initialState.snackbar, hideSnackbar()); + expect(newState.open).toBe(false); + expect(newState.message).toBe(''); + expect(newState.severity).toBe('info'); + }); + }); +}); diff --git a/client/test/preview/unit/util/gitlab.test.ts b/client/test/preview/unit/util/gitlab.test.ts new file mode 100644 index 000000000..bea4f33ea --- /dev/null +++ b/client/test/preview/unit/util/gitlab.test.ts @@ -0,0 +1,220 @@ +import { Gitlab } from '@gitbeaker/rest'; +import GitlabInstance from 'preview/util/gitlab'; + +jest.mock('@gitbeaker/rest'); + +describe('GitlabInstance', () => { + let gitlab: GitlabInstance; + const mockApi = { + Groups: { + show: jest.fn(), + allProjects: jest.fn(), + }, + PipelineTriggerTokens: { + all: jest.fn(), + }, + RepositoryFiles: { + show: jest.fn(), + }, + Repositories: { + allRepositoryTrees: jest.fn(), + }, + Jobs: { + all: jest.fn(), + showLog: jest.fn(), + }, + Pipelines: { + show: jest.fn(), + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + + gitlab = new GitlabInstance( + 'user1', + 'https://gitlab.example.com', + 'test_token', + ); + gitlab.api = mockApi as unknown as InstanceType; + }); + + it('should initialize with a project ID and trigger token', async () => { + mockApi.Groups.show.mockResolvedValue({ id: 1, name: 'DTaaS' }); + mockApi.Groups.allProjects.mockResolvedValue([{ id: 1, name: 'user1' }]); + mockApi.PipelineTriggerTokens.all.mockResolvedValue([ + { token: 'test-token' }, + ]); + + await gitlab.init(); + + expect(gitlab.projectId).toBe(1); + expect(gitlab.triggerToken).toBe('test-token'); + expect(mockApi.Groups.show).toHaveBeenCalledWith('DTaaS'); + expect(mockApi.Groups.allProjects).toHaveBeenCalledWith(1); + expect(mockApi.PipelineTriggerTokens.all).toHaveBeenCalledWith(1); + }); + + it('should handle no project ID found', async () => { + mockApi.Groups.show.mockResolvedValue({ id: 1, name: 'DTaaS' }); + mockApi.Groups.allProjects.mockResolvedValue([]); + + await gitlab.init(); + + expect(gitlab.projectId).toBeNull(); + expect(gitlab.triggerToken).toBeNull(); + expect(mockApi.Groups.show).toHaveBeenCalledWith('DTaaS'); + expect(mockApi.Groups.allProjects).toHaveBeenCalledWith(1); + expect(mockApi.PipelineTriggerTokens.all).not.toHaveBeenCalled(); + }); + + it('should handle no trigger token found', async () => { + mockApi.Groups.show.mockResolvedValue({ id: 1, name: 'DTaaS' }); + mockApi.Groups.allProjects.mockResolvedValue([{ id: 1, name: 'user1' }]); + mockApi.PipelineTriggerTokens.all.mockResolvedValue([]); + + await gitlab.init(); + + expect(gitlab.projectId).toBe(1); + expect(gitlab.triggerToken).toBeNull(); + expect(mockApi.Groups.show).toHaveBeenCalledWith('DTaaS'); + expect(mockApi.Groups.allProjects).toHaveBeenCalledWith(1); + expect(mockApi.PipelineTriggerTokens.all).toHaveBeenCalledWith(1); + }); + + it('should handle error fetching DT description', async () => { + const dtName = 'test-dt'; + const readmePath = `digital_twins/${dtName}/description.md`; + + mockApi.RepositoryFiles.show.mockRejectedValue( + new Error('Failed to fetch'), + ); + + await expect(gitlab.getDTDescription(dtName)).rejects.toThrow( + 'Failed to fetch', + ); + expect(mockApi.RepositoryFiles.show).toHaveBeenCalledWith( + gitlab.projectId!, + readmePath, + 'main', + ); + }); + + it('should fetch DT subfolders successfully', async () => { + const projectId = 1; + const files = [ + { name: 'subfolder1', path: 'digital_twins/subfolder1', type: 'tree' }, + { name: 'subfolder2', path: 'digital_twins/subfolder2', type: 'tree' }, + { name: 'file1', path: 'digital_twins/file1', type: 'blob' }, + ]; + + gitlab.getDTDescription = jest + .fn() + .mockImplementation((name: string) => + Promise.resolve(`Description for ${name}`), + ); + + mockApi.Repositories.allRepositoryTrees.mockResolvedValue(files); + + const subfolders = await gitlab.getDTSubfolders(projectId); + + expect(subfolders).toHaveLength(2); + expect(subfolders).toEqual([ + { + name: 'subfolder1', + path: 'digital_twins/subfolder1', + description: 'Description for subfolder1', + }, + { + name: 'subfolder2', + path: 'digital_twins/subfolder2', + description: 'Description for subfolder2', + }, + ]); + expect(mockApi.Repositories.allRepositoryTrees).toHaveBeenCalledWith( + projectId, + { + path: 'digital_twins', + recursive: false, + }, + ); + }); + + it('should return execution logs', () => { + const mockLog = { + status: 'success', + DTName: 'test-DTName', + runnerTag: 'test-runnerTag', + error: undefined, + }; + + gitlab.logs.push(mockLog); + + const logs = gitlab.executionLogs(); + + expect(logs).toHaveLength(1); + expect(logs[0].status).toBe('success'); + expect(logs[0].DTName).toBe('test-DTName'); + expect(logs[0].runnerTag).toBe('test-runnerTag'); + }); + + it('should fetch pipeline jobs successfully', async () => { + const projectId = 1; + const pipelineId = 2; + const jobs = [ + { id: 1, name: 'job1' }, + { id: 2, name: 'job2' }, + ]; + + mockApi.Jobs.all.mockResolvedValue(jobs); + + const result = await gitlab.getPipelineJobs(projectId, pipelineId); + + expect(result).toEqual(jobs); + expect(mockApi.Jobs.all).toHaveBeenCalledWith(projectId, { pipelineId }); + }); + + it('should fetch job trace successfully', async () => { + const projectId = 1; + const jobId = 2; + const log = 'Job log content'; + + mockApi.Jobs.showLog.mockResolvedValue(log); + + const result = await gitlab.getJobTrace(projectId, jobId); + + expect(result).toBe(log); + expect(mockApi.Jobs.showLog).toHaveBeenCalledWith(projectId, jobId); + }); + + it('should fetch pipeline status successfully', async () => { + const projectId = 1; + const pipelineId = 2; + const status = 'success'; + + mockApi.Pipelines.show.mockResolvedValue({ status }); + + const result = await gitlab.getPipelineStatus(projectId, pipelineId); + + expect(result).toBe(status); + expect(mockApi.Pipelines.show).toHaveBeenCalledWith(projectId, pipelineId); + }); + + it('should fetch DT description successfully and decode content', async () => { + const dtName = 'test-dt'; + const readmePath = `digital_twins/${dtName}/description.md`; + const encodedContent = btoa('Description content'); + const mockFileData = { content: encodedContent }; + + mockApi.RepositoryFiles.show.mockResolvedValue(mockFileData); + + const description = await gitlab.getDTDescription(dtName); + + expect(description).toBe('Description content'); + expect(mockApi.RepositoryFiles.show).toHaveBeenCalledWith( + gitlab.projectId!, + readmePath, + 'main', + ); + }); +}); diff --git a/client/test/preview/unit/util/gitlabDigitalTwin.test.ts b/client/test/preview/unit/util/gitlabDigitalTwin.test.ts new file mode 100644 index 000000000..38658a776 --- /dev/null +++ b/client/test/preview/unit/util/gitlabDigitalTwin.test.ts @@ -0,0 +1,175 @@ +import GitlabInstance from 'preview/util/gitlab'; +import DigitalTwin, { formatName } from 'preview/util/gitlabDigitalTwin'; + +const mockApi = { + RepositoryFiles: { + show: jest.fn(), + remove: jest.fn(), + }, + PipelineTriggerTokens: { + trigger: jest.fn(), + }, + Pipelines: { + cancel: jest.fn(), + }, +}; + +const mockGitlabInstance = { + api: mockApi as unknown as GitlabInstance['api'], + projectId: 1, + triggerToken: 'test-token', + logs: [] as { jobName: string; log: string }[], + getProjectId: jest.fn(), + getTriggerToken: jest.fn(), +} as unknown as GitlabInstance; + +describe('DigitalTwin', () => { + let dt: DigitalTwin; + + beforeEach(() => { + mockGitlabInstance.projectId = 1; + dt = new DigitalTwin('test-DTName', mockGitlabInstance); + }); + + it('should return full description if projectId exists', async () => { + const mockContent = btoa('Test README content'); + (mockApi.RepositoryFiles.show as jest.Mock).mockResolvedValue({ + content: mockContent, + }); + + await dt.getFullDescription(); + + expect(dt.fullDescription).toBe('Test README content'); + expect(mockApi.RepositoryFiles.show).toHaveBeenCalledWith( + 1, + 'digital_twins/test-DTName/README.md', + 'main', + ); + }); + + it('should return error message if no README.md file exists', async () => { + (mockApi.RepositoryFiles.show as jest.Mock).mockRejectedValue( + new Error('File not found'), + ); + + await dt.getFullDescription(); + + expect(dt.fullDescription).toBe( + 'There is no README.md file in the test-DTName GitLab folder', + ); + }); + + it('should return error message when projectId is missing', async () => { + dt.gitlabInstance.projectId = null; + await dt.getFullDescription(); + expect(dt.fullDescription).toBe('Error fetching description, retry.'); + }); + + it('should execute pipeline and return the pipeline ID', async () => { + const mockResponse = { id: 123 }; + (mockApi.PipelineTriggerTokens.trigger as jest.Mock).mockResolvedValue( + mockResponse, + ); + (mockGitlabInstance.getProjectId as jest.Mock).mockResolvedValue(1); + (mockGitlabInstance.getTriggerToken as jest.Mock).mockResolvedValue( + 'test-token', + ); + + const pipelineId = await dt.execute(); + + expect(pipelineId).toBe(123); + expect(dt.lastExecutionStatus).toBe('success'); + expect(mockApi.PipelineTriggerTokens.trigger).toHaveBeenCalledWith( + 1, + 'main', + 'test-token', + { variables: { DTName: 'test-DTName', RunnerTag: 'linux' } }, + ); + }); + + it('should log error and return null when projectId or triggerToken is missing', async () => { + dt.gitlabInstance.projectId = null; + dt.gitlabInstance.triggerToken = null; + + jest.spyOn(dt, 'isValidInstance').mockReturnValue(false); + + (mockApi.PipelineTriggerTokens.trigger as jest.Mock).mockReset(); + + const pipelineId = await dt.execute(); + + expect(pipelineId).toBeNull(); + expect(dt.lastExecutionStatus).toBe('error'); + expect(mockApi.PipelineTriggerTokens.trigger).not.toHaveBeenCalled(); + }); + + it('should log success and update status', () => { + dt.logSuccess(); + + expect(dt.gitlabInstance.logs).toContainEqual({ + status: 'success', + DTName: 'test-DTName', + runnerTag: 'linux', + }); + expect(dt.lastExecutionStatus).toBe('success'); + }); + + it('should log error when triggering pipeline fails', async () => { + jest.spyOn(dt, 'isValidInstance').mockReturnValue(true); + const errorMessage = 'Trigger failed'; + (mockApi.PipelineTriggerTokens.trigger as jest.Mock).mockRejectedValue( + errorMessage, + ); + + const pipelineId = await dt.execute(); + + expect(pipelineId).toBeNull(); + expect(dt.lastExecutionStatus).toBe('error'); + }); + + it('should handle non-Error thrown during pipeline execution', async () => { + (mockApi.PipelineTriggerTokens.trigger as jest.Mock).mockRejectedValue( + 'String error message', + ); + + const pipelineId = await dt.execute(); + + expect(pipelineId).toBeNull(); + expect(dt.lastExecutionStatus).toBe('error'); + }); + + it('should stop the parent pipeline and update status', async () => { + (mockApi.Pipelines.cancel as jest.Mock).mockResolvedValue({}); + + await dt.stop(1, 'parentPipeline'); + + expect(mockApi.Pipelines.cancel).toHaveBeenCalled(); + expect(dt.lastExecutionStatus).toBe('canceled'); + }); + + it('should stop the child pipeline and update status', async () => { + (mockApi.Pipelines.cancel as jest.Mock).mockResolvedValue({}); + + await dt.stop(1, 'childPipeline'); + + expect(mockApi.Pipelines.cancel).toHaveBeenCalled(); + expect(dt.lastExecutionStatus).toBe('canceled'); + }); + + it('should handle stop error', async () => { + (mockApi.Pipelines.cancel as jest.Mock).mockRejectedValue( + new Error('Stop failed'), + ); + + await dt.stop(1, 'parentPipeline'); + + expect(dt.lastExecutionStatus).toBe('error'); + }); + + it('should format the name correctly', () => { + const testCases = [{ input: 'digital-twin', expected: 'Digital twin' }]; + + testCases.forEach(({ input, expected }) => { + expect(formatName(input)).toBe(expected); + }); + }); +}); diff --git a/client/test/unit/unit.testUtil.tsx b/client/test/unit/unit.testUtil.tsx index c2fde11ce..aab550f8e 100644 --- a/client/test/unit/unit.testUtil.tsx +++ b/client/test/unit/unit.testUtil.tsx @@ -106,6 +106,39 @@ export function itDisplaysContentOfTabs( }); } +export function itDisplaysContentOfExecuteTab( + tabs: { label: string; body: string }[], +) { + const executeTab = tabs.find((tab) => tab.label === 'Execute'); + + if (!executeTab) return; + + it('should render the label of the Execute tab', () => { + expect( + screen.getByRole('tab', { name: executeTab.label }), + ).toBeInTheDocument(); + }); + + it('should render the content of the clicked Execute tab', () => { + const tabElement = screen.getByRole('tab', { name: executeTab.label }); + + act(() => { + tabElement.click(); + }); + + const executeTabContent = screen.getAllByText(executeTab.body, { + normalizer: getDefaultNormalizer({ collapseWhitespace: false }), + }); + expect(executeTabContent.length).toBeGreaterThan(0); + + tabs + .filter((tab) => tab.label !== 'Execute') + .forEach((otherTab) => { + expect(screen.queryByText(otherTab.body)).not.toBeInTheDocument(); + }); + }); +} + export function itPreventsDefaultActionWhenLinkIsClicked(linkText: string) { it(`should prevent default action when ${linkText} is clicked`, () => { const linkElement = screen.getByRole('link', { name: linkText }); @@ -169,6 +202,20 @@ export function itHasCorrectTabNameinDTIframe(tablabels: string[]) { }); } +export function itHasCorrectExecuteTabNameInDTIframe(tablabels: string[]) { + it("should render the Iframe component on DT page for the 'Execute' tab with the correct title", () => { + const executeTabLabel = tablabels.find((label) => label === 'Execute'); + + if (!executeTabLabel) return; + + const tabElement = screen.getByRole('tab', { + name: executeTabLabel, + }); + + expect(tabElement).toBeTruthy(); + }); +} + export function testStaticAccountProfile(mockUser: mockUserType) { const profilePicture = screen.getByTestId('profile-picture'); expect(profilePicture).toBeInTheDocument(); @@ -194,7 +241,6 @@ export async function testAccountSettings(mockUser: mockUserType) { 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', diff --git a/client/test/unit/util/Store.test.ts b/client/test/unit/util/Store.test.ts index dd638a44c..30df3990b 100644 --- a/client/test/unit/util/Store.test.ts +++ b/client/test/unit/util/Store.test.ts @@ -48,7 +48,7 @@ describe('reducers', () => { }); describe('auth reducer', () => { - it('authReducer should return the initial menu state when an unknown action type is passed with an undefined state', () => { + it('authReducer should return the initial auth state when an unknown action type is passed with an undefined state', () => { expect(authReducer(undefined, { type: 'unknown' })).toEqual( initialState.auth, ); diff --git a/client/test/unit/util/envUtil.test.ts b/client/test/unit/util/envUtil.test.ts index 883b08af4..8ead1ccc5 100644 --- a/client/test/unit/util/envUtil.test.ts +++ b/client/test/unit/util/envUtil.test.ts @@ -32,6 +32,7 @@ describe('envUtil', () => { REACT_APP_WORKBENCHLINK_VSCODE: testWorkbenchEndpoints[1], REACT_APP_WORKBENCHLINK_JUPYTERLAB: testWorkbenchEndpoints[2], REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: testWorkbenchEndpoints[3], + REACT_APP_WORKBENCHLINK_DT_PREVIEW: testWorkbenchEndpoints[4], REACT_APP_CLIENT_ID: testAppID, REACT_APP_AUTH_AUTHORITY: testAuthority, diff --git a/client/test/unitTests/Util/gitlab.test.ts b/client/test/unitTests/Util/gitlab.test.ts deleted file mode 100644 index 6e82034f5..000000000 --- a/client/test/unitTests/Util/gitlab.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { Gitlab } from '@gitbeaker/rest'; -import GitlabInstance from 'util/gitlab'; - -jest.mock('@gitbeaker/rest'); - -describe('GitlabInstance', () => { - let gitlab: GitlabInstance; - const mockApi = { - Groups: { - show: jest.fn(), - allProjects: jest.fn(), - }, - PipelineTriggerTokens: { - all: jest.fn(), - trigger: jest.fn(), - }, - Repositories: { - allRepositoryTrees: jest.fn(), - }, - }; - - beforeEach(() => { - window.sessionStorage.clear(); - jest.clearAllMocks(); - - gitlab = new GitlabInstance( - 'user1', - 'https://gitlab.example.com', - 'test_token', - ); - gitlab.api = mockApi as unknown as InstanceType; - }); - - it('should initialize the Gitlab API with the correct parameters', () => { - expect(Gitlab).toHaveBeenCalledWith({ - host: 'https://gitlab.example.com', - oauthToken: 'test_token', - }); - }); - - it('should fetch project ID successfully', async () => { - mockApi.Groups.show.mockResolvedValue({ id: 1, name: 'DTaaS' }); - mockApi.Groups.allProjects.mockResolvedValue([{ id: 1, name: 'user1' }]); - - const projectId = await gitlab.getProjectId(); - - expect(projectId).toBe(1); - expect(mockApi.Groups.show).toHaveBeenCalledWith('DTaaS'); - expect(mockApi.Groups.allProjects).toHaveBeenCalledWith(1); - }); - - it('should handle project ID not found', async () => { - mockApi.Groups.show.mockResolvedValue({ id: 1, name: 'DTaaS' }); - mockApi.Groups.allProjects.mockResolvedValue([]); - - const projectId = await gitlab.getProjectId(); - - expect(projectId).toBeNull(); - }); - - it('should fetch trigger token successfully', async () => { - mockApi.PipelineTriggerTokens.all.mockResolvedValue([ - { token: 'test-token' }, - ]); - - const token = await gitlab.getTriggerToken(1); - - expect(token).toBe('test-token'); - expect(mockApi.PipelineTriggerTokens.all).toHaveBeenCalledWith(1); - }); - - it('should handle no trigger tokens found', async () => { - mockApi.PipelineTriggerTokens.all.mockResolvedValue([]); - - const token = await gitlab.getTriggerToken(1); - - expect(token).toBeNull(); - expect(mockApi.PipelineTriggerTokens.all).toHaveBeenCalledWith(1); - }); - - it('should handle undefined trigger tokens', async () => { - mockApi.PipelineTriggerTokens.all.mockResolvedValue(undefined); - - const token = await gitlab.getTriggerToken(1); - - expect(token).toBeNull(); - }); - - it('should fetch DT subfolders successfully', async () => { - mockApi.Repositories.allRepositoryTrees.mockResolvedValue([ - { name: 'subfolder1', path: 'digital_twins/subfolder1', type: 'tree' }, - { name: 'subfolder2', path: 'digital_twins/subfolder2', type: 'tree' }, - { name: 'file1', path: 'digital_twins/file1', type: 'blob' }, - ]); - - const subfolders = await gitlab.getDTSubfolders(1); - - expect(subfolders).toHaveLength(2); - expect(subfolders).toEqual([ - { name: 'subfolder1', path: 'digital_twins/subfolder1' }, - { name: 'subfolder2', path: 'digital_twins/subfolder2' }, - ]); - expect(mockApi.Repositories.allRepositoryTrees).toHaveBeenCalledWith(1, { - path: 'digital_twins', - recursive: false, - }); - }); - - it('should return execution logs', () => { - const mockLog = { - status: 'success', - DTName: 'test-DTName', - runnerTag: 'test-runnerTag', - error: undefined, - }; - - gitlab.logs.push(mockLog); - - const logs = gitlab.executionLogs(); - - expect(logs).toHaveLength(1); - expect(logs[0].status).toBe('success'); - expect(logs[0].DTName).toBe('test-DTName'); - expect(logs[0].runnerTag).toBe('test-runnerTag'); - }); -}); diff --git a/client/test/unitTests/Util/gitlabDigitalTwin.test.ts b/client/test/unitTests/Util/gitlabDigitalTwin.test.ts deleted file mode 100644 index 84c1eea32..000000000 --- a/client/test/unitTests/Util/gitlabDigitalTwin.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { ProjectSchema, PipelineTriggerTokenSchema } from '@gitbeaker/rest'; -import DigitalTwin from 'util/gitlabDigitalTwin'; -import GitlabInstance from 'util/gitlab'; - -type LogEntry = { status: string; DTName: string; runnerTag: string }; - -const mockApi = { - Groups: { - show: jest.fn(), - allProjects: jest.fn(), - }, - PipelineTriggerTokens: { - all: jest.fn(), - trigger: jest.fn(), - }, - Repositories: { - allRepositoryTrees: jest.fn(), - }, -}; - -const mockGitlabInstance = { - api: mockApi as unknown as GitlabInstance['api'], - executionLogs: jest.fn() as jest.Mock, - getProjectId: jest.fn(), - getTriggerToken: jest.fn(), - getDTSubfolders: jest.fn(), - logs: [], -} as unknown as GitlabInstance; - -describe('DigitalTwin', () => { - let dt: DigitalTwin; - - beforeEach(() => { - dt = new DigitalTwin('test-DTName', mockGitlabInstance); - }); - - it('should handle null project ID during pipeline execution', async () => { - mockApi.Groups.show.mockResolvedValue({ id: 1, name: 'DTaaS' }); - mockApi.Groups.allProjects.mockResolvedValue([]); - (mockGitlabInstance.getProjectId as jest.Mock).mockResolvedValue(null); - - const success = await dt.execute('test-runnerTag'); - - expect(success).toBe(false); - expect(dt.executionStatus()).toBe('error'); - expect(mockApi.PipelineTriggerTokens.trigger).not.toHaveBeenCalled(); - }); - - it('should handle null trigger token during pipeline execution', async () => { - mockApi.Groups.show.mockResolvedValue({ id: 1, name: 'DTaaS' }); - mockApi.Groups.allProjects.mockResolvedValue([ - { id: 1, name: 'user1' } as ProjectSchema, - ]); - mockApi.PipelineTriggerTokens.all.mockResolvedValue([]); - (mockGitlabInstance.getTriggerToken as jest.Mock).mockResolvedValue(null); - - const success = await dt.execute('test-runnerTag'); - - expect(success).toBe(false); - expect(dt.executionStatus()).toBe('error'); - expect(mockApi.PipelineTriggerTokens.trigger).not.toHaveBeenCalled(); - }); - - it('should execute pipeline successfully', async () => { - mockApi.Groups.show.mockResolvedValue({ id: 1, name: 'DTaaS' }); - mockApi.Groups.allProjects.mockResolvedValue([ - { id: 1, name: 'user1' } as ProjectSchema, - ]); - mockApi.PipelineTriggerTokens.all.mockResolvedValue([ - { token: 'test-token' } as PipelineTriggerTokenSchema, - ]); - (mockGitlabInstance.getProjectId as jest.Mock).mockResolvedValue(1); - (mockGitlabInstance.getTriggerToken as jest.Mock).mockResolvedValue( - 'test-token', - ); - (mockApi.PipelineTriggerTokens.trigger as jest.Mock).mockResolvedValue( - undefined, - ); - - const success = await dt.execute('test-runnerTag'); - - expect(success).toBe(true); - expect(dt.executionStatus()).toBe('success'); - expect(mockApi.PipelineTriggerTokens.trigger).toHaveBeenCalledWith( - 1, - 'main', - 'test-token', - { variables: { DTName: 'test-DTName', RunnerTag: 'test-runnerTag' } }, - ); - }); - - it('should handle non-Error thrown during pipeline execution', async () => { - mockApi.Groups.show.mockResolvedValue({ id: 1, name: 'DTaaS' }); - mockApi.Groups.allProjects.mockResolvedValue([ - { id: 1, name: 'user1' } as ProjectSchema, - ]); - mockApi.PipelineTriggerTokens.all.mockResolvedValue([ - { token: 'test-token' } as PipelineTriggerTokenSchema, - ]); - (mockGitlabInstance.getProjectId as jest.Mock).mockResolvedValue(1); - (mockGitlabInstance.getTriggerToken as jest.Mock).mockResolvedValue( - 'test-token', - ); - (mockApi.PipelineTriggerTokens.trigger as jest.Mock).mockRejectedValue( - 'String error message', - ); - - const success = await dt.execute('test-runnerTag'); - - expect(success).toBe(false); - expect(dt.executionStatus()).toBe('error'); - expect(mockApi.PipelineTriggerTokens.trigger).toHaveBeenCalledWith( - 1, - 'main', - 'test-token', - { variables: { DTName: 'test-DTName', RunnerTag: 'test-runnerTag' } }, - ); - }); - - it('should handle Error thrown during pipeline execution', async () => { - mockApi.Groups.show.mockResolvedValue({ id: 1, name: 'DTaaS' }); - mockApi.Groups.allProjects.mockResolvedValue([ - { id: 1, name: 'user1' } as ProjectSchema, - ]); - mockApi.PipelineTriggerTokens.all.mockResolvedValue([ - { token: 'test-token' } as PipelineTriggerTokenSchema, - ]); - - mockApi.PipelineTriggerTokens.trigger.mockRejectedValue( - new Error('Error instance message'), - ); - - const success = await dt.execute('test-runnerTag'); - - expect(success).toBe(false); - - expect(dt.executionStatus()).toBe('error'); - }); - - it('should return execution logs', async () => { - mockApi.Groups.show.mockResolvedValue({ id: 1, name: 'DTaaS' }); - mockApi.Groups.allProjects.mockResolvedValue([ - { id: 1, name: 'user1' } as ProjectSchema, - ]); - mockApi.PipelineTriggerTokens.all.mockResolvedValue([ - { token: 'test-token' } as PipelineTriggerTokenSchema, - ]); - mockApi.PipelineTriggerTokens.trigger.mockResolvedValue(undefined); - - await dt.execute('test-runnerTag'); - - (mockGitlabInstance.executionLogs as jest.Mock).mockReturnValue([ - { status: 'success', DTName: 'test-DTName', runnerTag: 'test-runnerTag' }, - ]); - - const logs = dt.gitlabInstance.executionLogs(); - expect(logs).toHaveLength(1); - expect(logs[0].status).toBe('success'); - expect(logs[0].DTName).toBe('test-DTName'); - expect(logs[0].runnerTag).toBe('test-runnerTag'); - }); -}); diff --git a/client/tsconfig.gitlab.json b/client/tsconfig.gitlab.json index 882fb3cad..66615df39 100644 --- a/client/tsconfig.gitlab.json +++ b/client/tsconfig.gitlab.json @@ -26,9 +26,9 @@ }, "exclude": ["**/node_modules/*", "babel.config.cjs", "dist", "test"], "include": [ - "./src/util/gitlab.json", - "./src/util/gitlab*.ts", - "./test/unitTests/Util/gitlab*.test.ts" + "./config/gitlab.json", + "./src/preview/util/gitlab*.ts", + "./test/preview/unit/util/gitlab*.test.ts" ], "typeRoots": [ "**/node_modules/@types" ] -} +} \ No newline at end of file diff --git a/deploy/config/client/env.js b/deploy/config/client/env.js index 17fd6374f..979860b18 100644 --- a/deploy/config/client/env.js +++ b/deploy/config/client/env.js @@ -9,6 +9,7 @@ if (typeof window !== 'undefined') { REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '1be55736756190b3ace4c2c4fb19bde386d1dcc748d20b47ea8cfb5935b8446c', REACT_APP_AUTH_AUTHORITY: 'https://gitlab.foo.com/', diff --git a/deploy/config/client/env.local.js b/deploy/config/client/env.local.js index 9a9fc669b..e989f87bf 100644 --- a/deploy/config/client/env.local.js +++ b/deploy/config/client/env.local.js @@ -9,6 +9,7 @@ if (typeof window !== 'undefined') { REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '1be55736756190b3ace4c2c4fb19bde386d1dcc748d20b47ea8cfb5935b8446c', REACT_APP_AUTH_AUTHORITY: 'https://gitlab.com/', diff --git a/docs/admin/client/config.md b/docs/admin/client/config.md index 54f973478..37216acf8 100644 --- a/docs/admin/client/config.md +++ b/docs/admin/client/config.md @@ -15,6 +15,7 @@ This page describes various configuration options for react website. REACT_APP_WORKBENCHLINK_JUPYTERLAB: "Endpoint for the Jupyter Lab link", REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: "Endpoint for the Jupyter Notebook link", + REACT_APP_WORKBENCHLINK_DT_PREVIEW: "Endpoint for the Digital Twins page preview", REACT_APP_CLIENT_ID: 'AppID genereated by the gitlab OAuth provider', REACT_APP_AUTH_AUTHORITY: 'URL of the private gitlab instance', REACT_APP_REDIRECT_URI: 'URL of the homepage for the logged in users of the website', @@ -35,6 +36,7 @@ This page describes various configuration options for react website. REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '1be55736756190b3ace4c2c4fb19bde386d1dcc748d20b47ea8cfb5935b8446c', REACT_APP_AUTH_AUTHORITY: 'https://gitlab.foo.com/', REACT_APP_REDIRECT_URI: 'https://foo.com/Library', @@ -57,6 +59,7 @@ This page describes various configuration options for react website. REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '1be55736756190b3ace4c2c4fb19bde386d1dcc748d20b47ea8cfb5935b8446c', REACT_APP_AUTH_AUTHORITY: 'https://gitlab.foo.com/', REACT_APP_REDIRECT_URI: 'http://localhost:4000/bar/Library', diff --git a/docs/developer/client/GITLAB-RUNNER.md b/docs/developer/client/GITLAB-RUNNER.md new file mode 100644 index 000000000..fc760ad2e --- /dev/null +++ b/docs/developer/client/GITLAB-RUNNER.md @@ -0,0 +1,73 @@ +# Gitlab Runner Integration + +To properly use the __Digital Twins__ page preview, you need to +configure at least one project runner in your GitLab profile. +The first step is to configure the CI/CD pipeline in gitlab project. +The second step is to install the runner and integrate it +with the selected gitlab project. + +## Configure Gitlab Project + +Follow the steps below: + +1. Navigate to the _DTaaS_ group and select the project named after your + GitLab username. +1. In the project menu, go to Settings and select CI/CD. +1. Expand the __Runners__ section and click on _New project runner_. Follow the + configuration instructions carefully: + - Add __linux__ as a tag during configuration. + - Click on _Create runner_. A runner authentication token is generated. + This token will be used later for registering a runner. + +## Runner + +### Install Runner + +A detailed guide on installation of +[gitlab runners](https://docs.gitlab.com/runner/install/) +on Linux OS is available on +[gitlab website](https://docs.gitlab.com/runner/install/linux-repository.html) + +### Register Runner + +Please see this [gitlab guide](https://docs.gitlab.com/runner/register/) +on registering a runner. + +Remember to choose _docker_ as executor and _ruby:2.7_ as +the default docker image. + +```bash +$sudo gitlab-runner register --url https://gitlab.foo.com \ + --token xxxxx +``` + +Or, you can also register the runner in non-interactive mode by running + +```bash +$sudo gitlab-runner register \ + --non-interactive \ + --url "https://gitlab.foo.com/" \ + --token "xxxx" \ + --executor "docker" \ + --docker-image ruby:2.7 \ + --description "docker-runner" +``` + +### Start Runner + +You can manually verify that the runner is available to pick up jobs by running +the following command: + +```bash +$sudo gitlab-runner run +``` + +It can also be used to reactivate offline runners during subsequent sessions. + +## Pipeline Trigger Token + +You also need to create a +[pipeline trigger token](https://archives.docs.gitlab.com/16.4/ee/ci/triggers/index.html). +This token is required to trigger pipelines by using the API. +You can create this token in your GitLab project's CI/CD settings under +the *Pipeline trigger tokens* section. \ No newline at end of file diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 000000000..fb57ccd13 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,4 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + +