diff --git a/client/eslint.config.mjs b/client/eslint.config.mjs index 995793350..6e61e7da1 100644 --- a/client/eslint.config.mjs +++ b/client/eslint.config.mjs @@ -1,6 +1,7 @@ import jsxA11Y from "eslint-plugin-jsx-a11y"; import react from "eslint-plugin-react"; import jest from "eslint-plugin-jest"; +import reactHooks from "eslint-plugin-react-hooks"; import typescriptEslint from "@typescript-eslint/eslint-plugin"; import globals from "globals"; import tsParser from "@typescript-eslint/parser"; @@ -40,8 +41,9 @@ export default [{ plugins: { "jsx-a11y": jsxA11Y, react, + "react-hooks": reactHooks, jest, - "@typescript-eslint": typescriptEslint, + "@typescript-eslint": typescriptEslint }, languageOptions: { @@ -86,7 +88,7 @@ export default [{ "@typescript-eslint/no-unused-vars": [ "error", { - "caughtErrorsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_", } ], "no-console": "error", diff --git a/client/jest.config.json b/client/jest.config.json index 7db0ff83f..c6aeb5c9a 100644 --- a/client/jest.config.json +++ b/client/jest.config.json @@ -1,49 +1,49 @@ { - "preset": "ts-jest", - "testEnvironment": "jsdom", - "transform": { - "^.+\\.tsx?$": "ts-jest" - }, - "transformIgnorePatterns": [ - "/node_modules/(?![d3-shape|recharts]).+\\.js$" - ], - "collectCoverage": true, - "coverageReporters": [ - "text", - "cobertura", - "clover", - "lcov", - "json" - ], - "testTimeout": 15000, - "collectCoverageFrom": [ - "src/**/*.{ts,tsx}" - ], - "coveragePathIgnorePatterns": [ - "node_modules", - "build", - "src/index.tsx", - "src/AppProvider.tsx", - "src/store/store.ts", - "src/preview/util/gitlabDriver.ts" - ], - "modulePathIgnorePatterns": [ - "test/e2e", - "mocks", - "config" - ], - "coverageDirectory": "/coverage/", - "globals": { - "window.ENV.SERVER_HOSTNAME": "localhost", - "window.ENV.SERVER_PORT": 3500 - }, - "verbose": true, - "testRegex": "/test/.*\\.test.tsx?$", - "modulePaths": [ - "/src/" - ], - "moduleNameMapper": { - "^test/(.*)$": "/test/$1", - "\\.(css|less|scss)$": "/test/preview/__mocks__/styleMock.ts" - } -} + "preset": "ts-jest", + "testEnvironment": "jsdom", + "transform": { + "^.+\\.tsx?$": "ts-jest" + }, + "transformIgnorePatterns": [ + "/node_modules/(?![d3-shape|recharts]).+\\.js$" + ], + "collectCoverage": true, + "coverageReporters": [ + "text", + "cobertura", + "clover", + "lcov", + "json" + ], + "testTimeout": 15000, + "collectCoverageFrom": [ + "src/**/*.{ts,tsx}" + ], + "coveragePathIgnorePatterns": [ + "node_modules", + "build", + "src/index.tsx", + "src/AppProvider.tsx", + "src/store/store.ts", + "src/preview/util/gitlabDriver.ts" + ], + "modulePathIgnorePatterns": [ + "test/e2e", + "mocks", + "config" + ], + "coverageDirectory": "/coverage/", + "globals": { + "window.ENV.SERVER_HOSTNAME": "localhost", + "window.ENV.SERVER_PORT": 3500 + }, + "verbose": true, + "testRegex": "/test/.*\\.test.tsx?$", + "modulePaths": [ + "/src/" + ], + "moduleNameMapper": { + "^test/(.*)$": "/test/$1", + "\\.(css|less|scss)$": "/test/preview/__mocks__/styleMock.ts" + } +} \ No newline at end of file diff --git a/client/package.json b/client/package.json index 7989f5acf..edcf27072 100644 --- a/client/package.json +++ b/client/package.json @@ -1,137 +1,140 @@ { - "name": "@into-cps-association/dtaas-web", - "version": "0.8.0", - "description": "Web client for Digital Twin as a Service (DTaaS)", - "main": "index.tsx", - "author": "prasadtalasila (http://prasad.talasila.in/)", - "contributors": [ - "Omar Suleiman", - "Asger Busk Breinholm", - "Mathias Brændgaard", - "Emre Temel", - "Cesar Vela", - "Vanessa Scherma" - ], - "license": "SEE LICENSE IN ", - "private": false, - "type": "module", - "scripts": { - "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 react-scripts start", - "format": "prettier --ignore-path ../.gitignore --write \"**/*.{ts,tsx,css,scss}\"", - "graph": "npx madge --image src.svg src && npx madge --image test.svg test", - "start": "serve -s build -l 4000", - "stop": "npx kill-port 4000", - "syntax": "npx eslint . --fix", - "test:all": "yarn test:unit && yarn test:int && yarn test:e2e", - "test:e2e:ext": "cross-env ext=true yarn test:e2e", - "test:e2e": "yarn config:test && playwright test -c ./playwright.config.ts", - "test:coverage:int-unit": "npx istanbul-combine -d coverage/all -r lcov -r json -r text coverage/unit/coverage-final.json coverage/int/coverage-final.json coverage/preview/unit/coverage-final.json coverage/preview/int/coverage-final.json", - "test:int": "jest -c ./jest.config.json jest --coverage --coverageDirectory=coverage/int ../test/integration --setupFilesAfterEnv ./test/integration/jest.setup.ts", - "test:unit": "jest -c ./jest.config.json --coverageDirectory=coverage/unit ../test/unit --setupFilesAfterEnv ./test/unit/jest.setup.ts", - "test:preview:int": "jest -c ./jest.config.json --coverageDirectory=coverage/preview/int ../test/preview/integration --setupFilesAfterEnv ./test/preview/integration/jest.setup.ts", - "test:preview:unit": "jest -c ./jest.config.json --coverageDirectory=coverage/preview/unit ../test/preview/unit --setupFilesAfterEnv ./test/preview/unit/jest.setup.ts" - }, - "eslintConfig": { - "extends": [ - "react-app" - ] - }, - "prettier": { - "singleQuote": true - }, - "dependencies": { - "@emotion/react": "^11.11.1", - "@emotion/styled": "^11.11.0", - "@eslint/migrate-config": "^1.3.0", - "@fontsource/roboto": "^5.0.8", - "@gitbeaker/rest": "^40.1.2", - "@monaco-editor/react": "^4.6.0", - "@mui/icons-material": "^6.1.1", - "@mui/material": "^6.1.1", - "@mui/x-tree-view": "^7.19.0", - "@reduxjs/toolkit": "^2.2.7", - "@testing-library/react-hooks": "^8.0.1", - "@types/react-syntax-highlighter": "^15.5.13", - "@types/remarkable": "^2.0.8", - "@types/styled-components": "^5.1.32", - "@typescript-eslint/eslint-plugin": "^8.7.0", - "@typescript-eslint/parser": "^8.7.0", - "cross-env": "^7.0.3", - "dotenv": "^16.1.4", - "eslint": "^8.2.0", - "eslint-config-airbnb-base": "^15.0.0", - "eslint-config-prettier": "^9.0.0", - "eslint-plugin-import": "^2.29.0", - "eslint-plugin-jest": "^28.8.3", - "eslint-plugin-jsx-a11y": "^6.8.0", - "eslint-plugin-react": "^7.33.2", - "jest-fetch-mock": "^3.0.3", - "katex": "^0.16.11", - "markdown-it-katex": "^2.0.3", - "oidc-client-ts": "^3.0.1", - "prop-types": "^15.8.1", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-iframe": "^1.8.5", - "react-is": "^18.2.0", - "react-oidc-context": "^3.1.1", - "react-redux": "^9.1.2", - "react-router-dom": "^6.20.0", - "react-scripts": "^5.0.1", - "react-syntax-highlighter": "^15.5.0", - "react-tabs": "^6.0.2", - "redux": "^5.0.1", - "remarkable": "^2.0.1", - "remarkable-katex": "^1.2.1", - "reselect": "^5.1.1", - "resize-observer-polyfill": "^1.5.1", - "serve": "^14.2.1", - "styled-components": "^6.1.1", - "typescript": "5.1.6" - }, - "devDependencies": { - "@babel/core": "7.25.8", - "@babel/plugin-proposal-private-property-in-object": "^7.21.11", - "@babel/plugin-syntax-flow": "7.25.7", - "@babel/plugin-transform-react-jsx": "7.25.7", - "@eslint/eslintrc": "3.1.0", - "@eslint/js": "9.12.0", - "@playwright/test": "1.48.1", - "@testing-library/dom": "10.4.0", - "@testing-library/jest-dom": "6.6.1", - "@testing-library/react": "16.0.1", - "@testing-library/user-event": "14.5.2", - "@types/jest": "29.5.13", - "@types/node": "^22.7.5", - "@types/react": "^18.3.11", - "@types/react-dom": "^18.3.1", - "eslint-config-react-app": "^7.0.1", - "globals": "15.11.0", - "jest": "^29.7.0", - "jest-environment-jsdom": "29.7.0", - "jest-watch-typeahead": "^2.2.2", - "monocart-coverage-reports": "2.11.1", - "playwright": "1.48.1", - "prettier": "3.3.3", - "shx": "0.3.4", - "ts-jest": "29.2.5" - }, - "browserslist": { - "production": [ - ">0.2%", - "not dead", - "not op_mini all" + "name": "@into-cps-association/dtaas-web", + "version": "0.8.1", + "description": "Web client for Digital Twin as a Service (DTaaS)", + "main": "index.tsx", + "author": "prasadtalasila (http://prasad.talasila.in/)", + "contributors": [ + "Omar Suleiman", + "Asger Busk Breinholm", + "Mathias Brændgaard", + "Emre Temel", + "Cesar Vela", + "Vanessa Scherma" ], - "development": [ - "last 1 chrome version", - "last 1 firefox version", - "last 1 safari version" - ] - } -} + "license": "SEE LICENSE IN ", + "private": false, + "type": "module", + "scripts": { + "analyze": "source-map-explorer 'build/static/js/*.js'", + "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 react-scripts start", + "format": "prettier --ignore-path ../.gitignore --write \"**/*.{ts,tsx,css,scss}\"", + "graph": "npx madge --image src.svg src && npx madge --image test.svg test", + "start": "serve -s build -l 4000", + "stop": "npx kill-port 4000", + "syntax": "npx eslint . --fix", + "test:all": "yarn test:unit && yarn test:int && yarn test:e2e", + "test:e2e:ext": "cross-env ext=true yarn test:e2e", + "test:e2e": "yarn config:test && playwright test -c ./playwright.config.ts", + "test:coverage:int-unit": "npx istanbul-combine -d coverage/all -r lcov -r json -r text coverage/unit/coverage-final.json coverage/int/coverage-final.json coverage/preview/unit/coverage-final.json coverage/preview/int/coverage-final.json", + "test:int": "jest -c ./jest.config.json jest --coverage --coverageDirectory=coverage/int ../test/integration --setupFilesAfterEnv ./test/integration/jest.setup.ts", + "test:unit": "jest -c ./jest.config.json --coverageDirectory=coverage/unit ../test/unit --setupFilesAfterEnv ./test/unit/jest.setup.ts", + "test:preview:int": "jest -c ./jest.config.json --coverageDirectory=coverage/preview/int ../test/preview/integration --setupFilesAfterEnv ./test/preview/integration/jest.setup.ts", + "test:preview:unit": "jest -c ./jest.config.json --coverageDirectory=coverage/preview/unit ../test/preview/unit --setupFilesAfterEnv ./test/preview/unit/jest.setup.ts" + }, + "eslintConfig": { + "extends": [ + "react-app" + ] + }, + "prettier": { + "singleQuote": true + }, + "dependencies": { + "@emotion/react": "^11.11.1", + "@emotion/styled": "^11.11.0", + "@eslint/migrate-config": "^1.3.0", + "@fontsource/roboto": "^5.0.8", + "@gitbeaker/rest": "^40.1.2", + "@monaco-editor/react": "^4.6.0", + "@mui/icons-material": "^6.1.1", + "@mui/material": "^6.1.1", + "@mui/x-tree-view": "^7.19.0", + "@reduxjs/toolkit": "^2.2.7", + "@testing-library/react-hooks": "^8.0.1", + "@types/react-syntax-highlighter": "^15.5.13", + "@types/remarkable": "^2.0.8", + "@types/styled-components": "^5.1.32", + "@typescript-eslint/eslint-plugin": "^8.7.0", + "@typescript-eslint/parser": "^8.7.0", + "cross-env": "^7.0.3", + "dotenv": "^16.1.4", + "eslint": "^8.2.0", + "eslint-config-airbnb-base": "^15.0.0", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-import": "^2.29.0", + "eslint-plugin-jest": "^28.8.3", + "eslint-plugin-jsx-a11y": "^6.8.0", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^5.1.0", + "jest-fetch-mock": "^3.0.3", + "katex": "^0.16.11", + "markdown-it-katex": "^2.0.3", + "oidc-client-ts": "^3.0.1", + "prop-types": "^15.8.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-iframe": "^1.8.5", + "react-is": "^18.2.0", + "react-oidc-context": "^3.1.1", + "react-redux": "^9.1.2", + "react-router-dom": "^6.20.0", + "react-scripts": "^5.0.1", + "react-syntax-highlighter": "^15.5.0", + "react-tabs": "^6.0.2", + "redux": "^5.0.1", + "remarkable": "^2.0.1", + "remarkable-katex": "^1.2.1", + "reselect": "^5.1.1", + "resize-observer-polyfill": "^1.5.1", + "serve": "^14.2.1", + "styled-components": "^6.1.1", + "typescript": "5.1.6", + "zod": "^3.23.8" + }, + "devDependencies": { + "@babel/core": "7.25.8", + "@babel/plugin-proposal-private-property-in-object": "^7.21.11", + "@babel/plugin-syntax-flow": "7.25.7", + "@babel/plugin-transform-react-jsx": "7.25.7", + "@eslint/eslintrc": "3.1.0", + "@eslint/js": "9.12.0", + "@playwright/test": "1.48.1", + "@testing-library/dom": "10.4.0", + "@testing-library/jest-dom": "6.6.1", + "@testing-library/react": "16.0.1", + "@testing-library/user-event": "14.5.2", + "@types/jest": "29.5.13", + "@types/node": "^22.7.5", + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.1", + "eslint-config-react-app": "^7.0.1", + "globals": "15.11.0", + "jest": "^29.7.0", + "jest-environment-jsdom": "29.7.0", + "jest-watch-typeahead": "^2.2.2", + "monocart-coverage-reports": "2.11.1", + "playwright": "1.48.1", + "prettier": "3.3.3", + "shx": "0.3.4", + "ts-jest": "29.2.5" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} \ No newline at end of file diff --git a/client/playwright.config.ts b/client/playwright.config.ts index ee8021393..3703bfe63 100644 --- a/client/playwright.config.ts +++ b/client/playwright.config.ts @@ -22,7 +22,7 @@ export default defineConfig({ command: 'yarn start', }, retries: process.env.CI ? 0 : 1, // Disable retries on Github actions for now as setup always fails - timeout: 60 * 1000, + timeout: 120 * 1000, globalTimeout: 10 * 60 * 1000, testDir: './test/e2e/tests', testMatch: /.*\.test\.ts/, diff --git a/client/src/page/LayoutPublic.tsx b/client/src/page/LayoutPublic.tsx index 9acc61376..25ae94135 100644 --- a/client/src/page/LayoutPublic.tsx +++ b/client/src/page/LayoutPublic.tsx @@ -4,7 +4,7 @@ import AppBar from '@mui/material/AppBar'; import Footer from 'page/Footer'; import Typography from '@mui/material/Typography'; import Box from '@mui/material/Box'; -import { Container } from '@mui/material'; +import { Breakpoint, Container } from '@mui/material'; import LinkButtons from 'components/LinkButtons'; import toolbarLinkValues from 'util/toolbarUtil'; @@ -26,7 +26,10 @@ const DTappBar = () => ( ); -function LayoutPublic(props: { children: React.ReactNode }) { +function LayoutPublic(props: { + children: React.ReactNode; + containerMaxWidth?: Breakpoint; +}) { return ( - + {props.children} diff --git a/client/src/route/auth/ConfigItems.tsx b/client/src/route/auth/ConfigItems.tsx new file mode 100644 index 000000000..1b24a3e44 --- /dev/null +++ b/client/src/route/auth/ConfigItems.tsx @@ -0,0 +1,85 @@ +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; +import { Tooltip } from '@mui/material'; +import * as React from 'react'; +import { validationType } from './VerifyConfig'; + +const ConfigIcon = (toolTipTitle: string, icon: JSX.Element): JSX.Element => ( + + {icon} + +); + +export const getConfigIcon = ( + validation: validationType, + label: string, +): JSX.Element => { + let icon = ; + let toolTipTitle = `${label} threw the following error: ${validation.error}`; + const configHasStatus = validation.status !== undefined; + const configHasError = validation.error !== undefined; + if (!configHasError) { + const statusMessage = configHasStatus + ? `${validation.value} responded with status code ${validation.status}.` + : ''; + const validationStatusIsOK = + configHasStatus && + ((validation.status! >= 200 && validation.status! <= 299) || + validation.status! === 302); + icon = + validationStatusIsOK || !configHasStatus ? ( + + ) : ( + + ); + toolTipTitle = + validationStatusIsOK || !configHasStatus + ? `${label} field is configured correctly.` + : `${label} field may not be configured correctly.`; + toolTipTitle += ` ${statusMessage}`; + } + return ConfigIcon(toolTipTitle, icon); +}; + +export const ConfigItem: React.FC<{ + label: string; + value: string; + validation?: validationType; +}> = ({ label, value, validation = { error: 'Validation unavailable' } }) => ( +
+ {getConfigIcon(validation, label)} +
+ {label}: {value} +
+
+); +ConfigItem.displayName = 'ConfigItem'; + +export const windowEnvironmentVariables: Record = { + environment: window.env.REACT_APP_ENVIRONMENT, + url: window.env.REACT_APP_URL, + url_basename: window.env.REACT_APP_URL_BASENAME, + url_dtlink: window.env.REACT_APP_URL_DTLINK, + url_liblink: window.env.REACT_APP_URL_LIBLINK, + workbenchlink_vncdesktop: window.env.REACT_APP_WORKBENCHLINK_VNCDESKTOP, + workbenchlink_vscode: window.env.REACT_APP_WORKBENCHLINK_VSCODE, + workbenchlink_jupyterlab: window.env.REACT_APP_WORKBENCHLINK_JUPYTERLAB, + workbenchlink_jupyternotebook: + window.env.REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK, + client_id: window.env.REACT_APP_CLIENT_ID, + auth_authority: window.env.REACT_APP_AUTH_AUTHORITY, + redirect_uri: window.env.REACT_APP_REDIRECT_URI, + logout_redirect_uri: window.env.REACT_APP_LOGOUT_REDIRECT_URI, + gitlab_scopes: window.env.REACT_APP_GITLAB_SCOPES, +}; diff --git a/client/src/route/auth/Signin.tsx b/client/src/route/auth/Signin.tsx index a113ab265..b3de581d1 100644 --- a/client/src/route/auth/Signin.tsx +++ b/client/src/route/auth/Signin.tsx @@ -2,66 +2,130 @@ import * as React from 'react'; import Avatar from '@mui/material/Avatar'; import Box from '@mui/material/Box'; import LockOutlinedIcon from '@mui/icons-material/LockOutlined'; - import { useAuth } from 'react-oidc-context'; import Button from '@mui/material/Button'; +import { useState, useEffect } from 'react'; +import { CircularProgress } from '@mui/material'; +import VerifyConfig, { + getValidationResults, + validationType, +} from './VerifyConfig'; function SignIn() { const auth = useAuth(); + const [validationResults, setValidationResults] = useState<{ + [key: string]: validationType; + }>({}); + const [isLoading, setIsLoading] = useState(true); + + const configsToVerify = [ + 'url', + 'auth_authority', + 'redirect_uri', + 'logout_redirect_uri', + ]; + + useEffect(() => { + const fetchValidationResults = async () => { + const results = await getValidationResults(configsToVerify); + setValidationResults(results); + setIsLoading(false); + }; + fetchValidationResults(); + }); const startAuthProcess = () => { auth.signinRedirect(); }; - return ( - validationResults[key]?.error !== undefined, + ); + + if (!isLoading) { + // Show signin if config is ready and good, otherwise show problems + displayedComponent = signin; + if (hasConfigErrors) { + displayedComponent = verifyConfig; + } + } + + return displayedComponent; +} + +const verifyConfigComponent = (configsToVerify: string[]): React.ReactNode => + VerifyConfig({ + keys: configsToVerify, + title: 'Config validation failed', + }); + +const loadingComponent = (): React.ReactNode => ( + + Verifying configuration + + +); + +const signInComponent = (startAuthProcess: () => void): React.ReactNode => ( + + + + + - - ); -} + }, + }} + startIcon={ + GitLab logo + } + > + Sign In with GitLab + + +); export default SignIn; diff --git a/client/src/route/auth/VerifyConfig.tsx b/client/src/route/auth/VerifyConfig.tsx new file mode 100644 index 000000000..4128c3eb5 --- /dev/null +++ b/client/src/route/auth/VerifyConfig.tsx @@ -0,0 +1,201 @@ +import { Paper, Typography } from '@mui/material'; +import * as React from 'react'; +import { z } from 'zod'; +import { ConfigItem, windowEnvironmentVariables } from './ConfigItems'; + +const EnvironmentEnum = z.enum(['dev', 'local', 'prod', 'test']); +const PathString = z.string(); +const ScopesString = z.literal('openid profile read_user read_repository api'); + +export type validationType = { + value?: string; + status?: number; + error?: string; +}; + +async function opaqueRequest(url: string): Promise { + const urlValidation: validationType = { + value: url, + status: undefined, + error: undefined, + }; + try { + await fetch(url, { + method: 'HEAD', + mode: 'no-cors', + signal: AbortSignal.timeout(1000), + }); + urlValidation.status = 0; + } catch (error) { + urlValidation.error = `An error occurred when fetching ${url}: ${error}`; + } + return urlValidation; +} + +async function corsRequest(url: string): Promise { + const urlValidation: validationType = { + value: url, + status: undefined, + error: undefined, + }; + const response = await fetch(url, { + method: 'HEAD', + signal: AbortSignal.timeout(1000), + }); + const responseIsAcceptable = response.ok || response.status === 302; + if (!responseIsAcceptable) { + urlValidation.error = `Unexpected response code ${response.status} from ${url}.`; + } + urlValidation.status = response.status; + return urlValidation; +} + +async function urlIsReachable(url: string): Promise { + let urlValidation: validationType; + try { + urlValidation = await corsRequest(url); + } catch { + urlValidation = await opaqueRequest(url); + } + return urlValidation; +} + +const parseField = ( + parser: { + safeParse: (value: string) => { + success: boolean; + error?: { message?: string }; + }; + }, + value: string, +): validationType => { + const result = parser.safeParse(value); + return result.success + ? { value, error: undefined } + : { value: undefined, error: result.error?.message }; +}; + +export const getValidationResults = async ( + keysToValidate: string[], +): Promise<{ + [key: string]: validationType; +}> => { + const allVerifications = { + environment: Promise.resolve( + parseField(EnvironmentEnum, window.env.REACT_APP_ENVIRONMENT), + ), + url: urlIsReachable(window.env.REACT_APP_URL), + url_basename: Promise.resolve( + parseField(PathString, window.env.REACT_APP_URL_BASENAME), + ), + url_dtlink: Promise.resolve( + parseField(PathString, window.env.REACT_APP_URL_DTLINK), + ), + url_liblink: Promise.resolve( + parseField(PathString, window.env.REACT_APP_URL_LIBLINK), + ), + workbenchlink_vncdesktop: Promise.resolve( + parseField(PathString, window.env.REACT_APP_WORKBENCHLINK_VNCDESKTOP), + ), + workbenchlink_vscode: Promise.resolve( + parseField(PathString, window.env.REACT_APP_WORKBENCHLINK_VSCODE), + ), + workbenchlink_jupyterlab: Promise.resolve( + parseField(PathString, window.env.REACT_APP_WORKBENCHLINK_JUPYTERLAB), + ), + workbenchlink_jupyternotebook: Promise.resolve( + parseField( + PathString, + window.env.REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK, + ), + ), + client_id: Promise.resolve( + parseField(PathString, window.env.REACT_APP_CLIENT_ID), + ), + auth_authority: urlIsReachable(window.env.REACT_APP_AUTH_AUTHORITY), + redirect_uri: urlIsReachable(window.env.REACT_APP_REDIRECT_URI), + logout_redirect_uri: urlIsReachable( + window.env.REACT_APP_LOGOUT_REDIRECT_URI, + ), + gitlab_scopes: Promise.resolve( + parseField(ScopesString, window.env.REACT_APP_GITLAB_SCOPES), + ), + }; + + const verifications = + keysToValidate.length === 0 + ? allVerifications + : Object.fromEntries( + keysToValidate + .filter((key) => key in allVerifications) + .map((key) => [ + key, + allVerifications[key as keyof typeof allVerifications], + ]), + ); + + const results = await Promise.all( + Object.entries(verifications).map(async ([key, task]) => ({ + [key]: await task, + })), + ); + + return results.reduce((acc, result) => ({ ...acc, ...result }), {}); +}; + +const VerifyConfig: React.FC<{ keys?: string[]; title?: string }> = ({ + keys = [], + title = 'Config verification', +}) => { + const [validationResults, setValidationResults] = React.useState<{ + [key: string]: validationType; + }>({}); + + React.useEffect(() => { + const fetchValidations = async () => { + const results = await getValidationResults(keys); + setValidationResults(results); + }; + fetchValidations(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const displayedConfigs: Record = + keys.length === 0 + ? windowEnvironmentVariables + : Object.fromEntries( + keys + .filter((key) => key in windowEnvironmentVariables) + .map((key) => [ + key, + windowEnvironmentVariables[ + key as keyof typeof windowEnvironmentVariables + ] as string, + ]), + ); + return ( + + {title} +
+ {Object.entries(displayedConfigs).map(([key, value]) => ( + + ))} +
+
+ ); +}; + +export default VerifyConfig; diff --git a/client/src/routes.tsx b/client/src/routes.tsx index 6a28666cd..98c7cfa25 100644 --- a/client/src/routes.tsx +++ b/client/src/routes.tsx @@ -8,6 +8,7 @@ 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'; +import VerifyConfig from './route/auth/VerifyConfig'; export const routes = [ { @@ -18,6 +19,14 @@ export const routes = [ ), }, + { + path: 'verify', + element: ( + + + + ), + }, { path: 'library', element: ( diff --git a/client/test/__mocks__/global_mocks.ts b/client/test/__mocks__/global_mocks.ts index 19303efae..8846292a1 100644 --- a/client/test/__mocks__/global_mocks.ts +++ b/client/test/__mocks__/global_mocks.ts @@ -59,3 +59,8 @@ jest.mock('util/envUtil', () => ({ { key: '3', link: 'link3' }, ], })); + +jest.mock('route/auth/VerifyConfig', () => ({ + ...jest.requireActual('route/auth/VerifyConfig'), + getValidationResults: jest.fn(), +})); diff --git a/client/test/e2e/tests/Auth.test.ts b/client/test/e2e/tests/Auth.test.ts index 14afe3eb9..835d9094c 100644 --- a/client/test/e2e/tests/Auth.test.ts +++ b/client/test/e2e/tests/Auth.test.ts @@ -1,7 +1,7 @@ // src: https://playwright.dev/docs/writing-tests import { expect } from '@playwright/test'; import test from 'test/e2e/setup/fixtures'; -import links from './Links'; // Extension is required with Playwright import +import links from './Links'; test.describe('Tests on Authentication Flow', () => { test.beforeEach(async ({ page }) => { diff --git a/client/test/e2e/tests/Menu.test.ts b/client/test/e2e/tests/Menu.test.ts index bbc16af61..13669e6b6 100644 --- a/client/test/e2e/tests/Menu.test.ts +++ b/client/test/e2e/tests/Menu.test.ts @@ -2,7 +2,7 @@ import { expect } from '@playwright/test'; import test from 'test/e2e/setup/fixtures'; -import links from './Links'; // Extension is required with Playwright import +import links from './Links'; test.describe('Menu Links from first page (Layout)', () => { test.beforeEach(async ({ page }) => { diff --git a/client/test/e2e/tests/verify.route.test.ts b/client/test/e2e/tests/verify.route.test.ts new file mode 100644 index 000000000..cb60a209b --- /dev/null +++ b/client/test/e2e/tests/verify.route.test.ts @@ -0,0 +1,28 @@ +import test from 'test/e2e/setup/fixtures'; +import { expect } from '@playwright/test'; + +test('Verification is visible', async ({ page }) => { + await page.goto('./verify'); + + await page.waitForSelector('[data-testid="success-icon"]', { + timeout: 4000, + state: 'visible', + }); + + await expect( + page.getByRole('heading', { name: 'Config verification' }), + ).toBeVisible(); + + await expect(page.getByText('CLIENT ID:', { exact: true })).toBeVisible(); + await expect( + page.getByText('AUTH AUTHORITY:', { exact: true }), + ).toBeVisible(); + + await expect( + page + .getByLabel('ENVIRONMENT field is configured correctly.') + .locator('path'), + ).toBeVisible(); + + await expect(page.getByTestId('error-icon')).toBeHidden(); +}); diff --git a/client/test/integration/Auth/WaitAndNavigate.test.tsx b/client/test/integration/Auth/WaitAndNavigate.test.tsx index afd27df2e..781fbbd3d 100644 --- a/client/test/integration/Auth/WaitAndNavigate.test.tsx +++ b/client/test/integration/Auth/WaitAndNavigate.test.tsx @@ -1,4 +1,5 @@ import { act, screen } from '@testing-library/react'; +import { getValidationResults } from 'route/auth/VerifyConfig'; import { mockAuthState } from 'test/__mocks__/global_mocks'; import { setupIntegrationTest } from 'test/integration/integration.testUtil'; @@ -16,6 +17,9 @@ Object.defineProperty(window, 'location', { describe('WaitAndNavigate', () => { beforeEach(async () => { + (getValidationResults as jest.Mock).mockReturnValue( + Promise.resolve({ configField: 'test' }), + ); await setup(); }); diff --git a/client/test/integration/Auth/authRedux.test.tsx b/client/test/integration/Auth/authRedux.test.tsx index b086aa7c8..ba7279763 100644 --- a/client/test/integration/Auth/authRedux.test.tsx +++ b/client/test/integration/Auth/authRedux.test.tsx @@ -1,12 +1,13 @@ import * as React from 'react'; import { createStore } from 'redux'; -import { screen } from '@testing-library/react'; +import { screen, act } from '@testing-library/react'; import { useAuth } from 'react-oidc-context'; import PrivateRoute from 'route/auth/PrivateRoute'; import Library from 'route/library/Library'; import authReducer from 'store/auth.slice'; import { mockUser } from 'test/__mocks__/global_mocks'; import { renderWithRouter } from 'test/unit/unit.testUtil'; +import { getValidationResults } from 'route/auth/VerifyConfig'; jest.mock('util/auth/Authentication', () => ({ useGetAndSetUsername: () => jest.fn(), @@ -28,8 +29,11 @@ type AuthState = { isAuthenticated: boolean; }; -const setupTest = (authState: AuthState) => { +const setupTest = async (authState: AuthState) => { (useAuth as jest.Mock).mockReturnValue({ ...authState, user: mockUser }); + (getValidationResults as jest.Mock).mockReturnValue( + Promise.resolve({ configField: 'test' }), + ); if (authState.isAuthenticated) { store.dispatch({ @@ -40,12 +44,14 @@ const setupTest = (authState: AuthState) => { store.dispatch({ type: 'auth/setUserName', payload: undefined }); } - renderWithRouter( - - - , - { route: '/private', store }, - ); + await act(async () => { + renderWithRouter( + + + , + { route: '/private', store }, + ); + }); }; describe('Redux and Authentication integration test', () => { @@ -63,8 +69,8 @@ describe('Redux and Authentication integration test', () => { }; }); - it('renders undefined username when not authenticated', () => { - setupTest({ + it('renders undefined username when not authenticated', async () => { + await setupTest({ isAuthenticated: false, }); @@ -75,8 +81,8 @@ describe('Redux and Authentication integration test', () => { expect(store.getState().userName).toBe(undefined); }); - it('renders the correct username when authenticated', () => { - setupTest({ + it('renders the correct username when authenticated', async () => { + await setupTest({ isAuthenticated: true, }); @@ -84,14 +90,14 @@ describe('Redux and Authentication integration test', () => { expect(store.getState().userName).toBe('username'); }); - it('renders undefined username after ending authentication', () => { - setupTest({ + it('renders undefined username after ending authentication', async () => { + await setupTest({ isAuthenticated: true, }); expect(screen.getByText('Functions')).toBeInTheDocument(); expect(store.getState().userName).toBe('username'); - setupTest({ + await setupTest({ isAuthenticated: false, }); expect(screen.getByText('Sign In with GitLab')).toBeInTheDocument(); diff --git a/client/test/integration/Routes/Signin.test.tsx b/client/test/integration/Routes/Signin.test.tsx index e26828eb3..337301d9b 100644 --- a/client/test/integration/Routes/Signin.test.tsx +++ b/client/test/integration/Routes/Signin.test.tsx @@ -1,15 +1,18 @@ -import { screen } from '@testing-library/react'; +import { screen, act } from '@testing-library/react'; import { setupIntegrationTest } from 'test/integration/integration.testUtil'; +import { getValidationResults } from 'route/auth/VerifyConfig'; import { testPublicLayout } from './routes.testUtil'; const setup = () => setupIntegrationTest('/'); describe('Signin', () => { - beforeEach(async () => { - await setup(); - }); - it('renders the Sign in page with the Public Layout correctly', async () => { + (getValidationResults as jest.Mock).mockReturnValue( + Promise.resolve({ configField: 'test' }), + ); + await act(async () => { + await setup(); + }); await testPublicLayout(); expect( screen.getByRole('button', { name: /Sign In with GitLab/i }), diff --git a/client/test/integration/jest.setup.ts b/client/test/integration/jest.setup.ts index a609475d2..588a833ba 100644 --- a/client/test/integration/jest.setup.ts +++ b/client/test/integration/jest.setup.ts @@ -6,8 +6,21 @@ beforeEach(() => { jest.resetAllMocks(); }); -global.window.env = { +window.env = { ...global.window.env, - REACT_APP_AUTH_AUTHORITY: - process.env.REACT_APP_AUTH_AUTHORITY || 'https://example.com', + REACT_APP_AUTH_AUTHORITY: 'https://example.com', + REACT_APP_ENVIRONMENT: 'test', + REACT_APP_URL: 'https://example.com', + REACT_APP_URL_BASENAME: 'mock_url_basename', + REACT_APP_URL_DTLINK: '/lab', + REACT_APP_URL_LIBLINK: '', + REACT_APP_WORKBENCHLINK_VNCDESKTOP: '/tools/vnc/?password=vncpassword', + REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', + REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', + REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + + REACT_APP_CLIENT_ID: 'abc123', + REACT_APP_REDIRECT_URI: 'https://example.com', + REACT_APP_LOGOUT_REDIRECT_URI: 'https://example.com', + REACT_APP_GITLAB_SCOPES: 'openid profile read_user read_repository api', }; diff --git a/client/test/unit/jest.setup.ts b/client/test/unit/jest.setup.ts index 53953aa4b..6e522fe11 100644 --- a/client/test/unit/jest.setup.ts +++ b/client/test/unit/jest.setup.ts @@ -7,3 +7,22 @@ import 'test/__mocks__/unit/module_mocks'; beforeEach(() => { jest.resetAllMocks(); }); + +window.env = { + ...global.window.env, + REACT_APP_AUTH_AUTHORITY: 'https://example.com', + REACT_APP_ENVIRONMENT: 'test', + REACT_APP_URL: 'https://example.com', + REACT_APP_URL_BASENAME: 'mock_url_basename', + REACT_APP_URL_DTLINK: '/lab', + REACT_APP_URL_LIBLINK: '', + REACT_APP_WORKBENCHLINK_VNCDESKTOP: '/tools/vnc/?password=vncpassword', + REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', + REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', + REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + + REACT_APP_CLIENT_ID: 'abc123', + REACT_APP_REDIRECT_URI: 'https://example.com', + REACT_APP_LOGOUT_REDIRECT_URI: 'https://example.com', + REACT_APP_GITLAB_SCOPES: 'openid profile read_user read_repository api', +}; diff --git a/client/test/unit/routes/SignIn.test.tsx b/client/test/unit/routes/SignIn.test.tsx index 3ab03ab3b..d5fdbe380 100644 --- a/client/test/unit/routes/SignIn.test.tsx +++ b/client/test/unit/routes/SignIn.test.tsx @@ -1,8 +1,15 @@ import * as React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { + render, + screen, + fireEvent, + waitFor, + act, +} from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import SignIn from 'route/auth/Signin'; import { useAuth } from 'react-oidc-context'; +import { getValidationResults } from 'route/auth/VerifyConfig'; jest.unmock('route/auth/Signin'); jest.mock('react-oidc-context'); @@ -20,25 +27,91 @@ describe('SignIn', () => { jest.clearAllMocks(); }); - it('renders the SignIn button', () => { - render( - - - , + it('renders config loading', async () => { + // Create a promise that won't resolve immediately to simulate loading state + let resolveValidation: (value: unknown) => void; + const validationPromise = new Promise((resolve) => { + resolveValidation = resolve; + }); + + (getValidationResults as jest.Mock).mockReturnValue(validationPromise); + + const renderResult = await act(async () => + render( + + + , + ), ); expect( - screen.getByRole('button', { name: /Sign In With GitLab/i }), + renderResult.getByText('Verifying configuration'), ).toBeInTheDocument(); + expect(renderResult.getByRole('progressbar')).toBeInTheDocument(); + + // Resolve the promise to allow the component to complete loading + await act(async () => { + resolveValidation({ config: 'loading' }); + }); }); - it('handles button click', () => { - render( - - - , + it('renders the SignIn button', async () => { + (getValidationResults as jest.Mock).mockReturnValue( + Promise.resolve({ button: 'test' }), ); + await act(async () => { + render( + + + , + ); + }); + await waitFor(() => + screen.getByRole('button', { name: /Sign In With GitLab/i }), + ); + expect( + screen.getByRole('button', { name: /Sign In With GitLab/i }), + ).toBeInTheDocument(); + }); + + it('renders the config problems', async () => { + const res = { + url: { + error: + 'An error occurred when fetching https://example.com: ReferenceError: fetch is not defined', + status: undefined, + value: 'https://example.com', + }, + }; + (getValidationResults as jest.Mock).mockReturnValue(Promise.resolve(res)); + + await act(async () => { + render( + + + , + ); + }); + await waitFor(() => { + expect(screen.getByText(/Config validation failed/i)).toBeInTheDocument(); + }); + }); + + it('handles button click', async () => { + (getValidationResults as jest.Mock).mockReturnValue( + Promise.resolve({ button: 'click' }), + ); + await act(async () => { + render( + + + , + ); + }); + await waitFor(() => + screen.getByRole('button', { name: /Sign In With GitLab/i }), + ); const signInButton = screen.getByRole('button', { name: /Sign In With GitLab/i, }); diff --git a/client/yarn.lock b/client/yarn.lock index f4deaf909..0bdbd7bb7 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -5341,6 +5341,11 @@ eslint-plugin-react-hooks@^4.3.0: resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz#c829eb06c0e6f484b3fbb85a97e57784f328c596" integrity sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ== +eslint-plugin-react-hooks@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.1.0.tgz#3d34e37d5770866c34b87d5b499f5f0b53bf0854" + integrity sha512-mpJRtPgHN2tNAvZ35AMfqeB3Xqeo273QxrHJsbBEPWODRM4r0yB6jfoROqKEYrOn27UtRPpcpHc2UqyBSuUNTw== + eslint-plugin-react@^7.27.1, eslint-plugin-react@^7.33.2: version "7.37.2" resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.37.2.tgz#cd0935987876ba2900df2f58339f6d92305acc7a" @@ -12034,3 +12039,8 @@ yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +zod@^3.23.8: + version "3.23.8" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d" + integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==